Files
glchat/web/tests/guildHeaderAndSounds.test.tsx
grendervill cf36488e6f fix(web): права в шапке сервера обновляются после выдачи роли
По MEMBER_UPDATE/ROLE_* клиент перечитывал участников, роли и комнаты, но
не сводку сервера: `my_permissions` в сторе сессии оставались прежними, и
кнопки управления (🔗 приглашение, ⚙ настройки, + комната) появлялись
только после перезагрузки. Матрица 11.6 требует, чтобы права сразу меняли
доступные действия (AGENT.md 11.6).

- в ветках `MEMBER_UPDATE`/`MEMBER_REMOVE` и `ROLE_*` вызывается
  `addGuildFromRest` — сводка сервера перечитывается и мержится;
- vitest: кнопки появляются по событию (тест падает без правки).
2026-09-26 11:06:32 +03:00

359 lines
13 KiB
TypeScript

import { beforeEach, describe, expect, it, vi } from 'vitest';
import { act, screen, waitFor, within } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import {
findRequest,
installFetch,
installGatewaySocket,
json,
makeReadySnapshot,
makeUser,
renderApp,
resetStores,
type FetchRoute,
} from './helpers';
import { makeVoiceState } from './voiceRoutes';
import {
setAudioContextFactory,
unlockInterfaceSounds,
type AudioContextLike,
} from '@/lib/interfaceSounds';
import { playEventSound } from '@/stores/soundboard';
import { useSessionStore } from '@/stores/session';
import { useVoiceStore } from '@/stores/voice';
/**
* Замечания пользователя: иконка должна меняться сразу, баннер сервера виден в
* шапке, есть кнопка приглашения, а звуки событий играют даже без своих файлов
* на сервере (AGENT.md 7.4, 7.13, 7.15).
*/
const user = makeUser();
const channels = [{ id: 'c-general', name: 'общий', type: 'text' as const, position: 0 }];
/**
* Права, которые отдаёт REST-сводка сервера: тест выдачи роли меняет их и
* проверяет, что клиент перечитал сводку по событию MEMBER_UPDATE.
*/
let restPermissions: string[] | null = null;
function guildSummary(overrides: Record<string, unknown> = {}) {
return {
id: 'g-1',
name: 'Сервер',
owner_id: 'user-1',
is_main: true,
member_count: 1,
my_permissions: ['MANAGE_GUILD', 'CREATE_INVITES'],
my_role_ids: [],
...overrides,
};
}
function guildDetail(overrides: Record<string, unknown> = {}) {
return {
...guildSummary(overrides),
description: '',
roles: [],
channels,
};
}
/** Маршруты оболочки, сервера и приглашений. */
function routes(guild: Record<string, unknown> = guildDetail()): FetchRoute[] {
return [
{
match: '/api/v1/guilds/g-1/invites',
method: 'POST',
response: () =>
json({
invite: {
code: 'abc123',
guild_id: 'g-1',
max_uses: 0,
uses: 0,
max_age_seconds: 86400,
created_at: '2026-09-20T10:00:00Z',
url: 'https://gl.test/invite/abc123',
},
}),
},
{
match: '/api/v1/guilds/g-1/appearance/icon',
method: 'POST',
response: () => json({ guild: guildDetail({ icon_file_id: '777', banner_file_id: '555' }) }),
},
{
match: '/api/v1/users/@me/guilds',
response: () =>
json({
guilds: [
guildSummary(
restPermissions === null ? guild : { ...guild, my_permissions: restPermissions },
),
],
}),
},
{ match: '/api/v1/users/@me', response: () => json({ user }) },
{ match: '/api/v1/instance', response: () => json({ instance: { name: 'glchat-test' } }) },
{ match: '/api/v1/guilds/g-1/members', response: () => json({ members: [] }) },
{ match: '/api/v1/guilds/g-1/roles', response: () => json({ roles: [] }) },
{ match: '/api/v1/guilds/g-1/channels', response: () => json({ channels }) },
{ match: '/api/v1/guilds/g-1', response: () => json({ guild }) },
];
}
/**
* Рендерит приложение с READY-снапшотом сервера. Права и владелец берутся из
* того же описания сервера, что и ответ REST: снапшот — источник правды.
*/
async function bootApp(guild: Record<string, unknown> = guildDetail()) {
const permissions = Array.isArray(guild.my_permissions)
? (guild.my_permissions as string[])
: ['MANAGE_GUILD', 'CREATE_INVITES'];
const ownerId = typeof guild.owner_id === 'string' ? guild.owner_id : 'user-1';
const snapshot = makeReadySnapshot([
{
id: 'g-1',
name: 'Сервер',
is_main: true,
owner_id: ownerId,
my_permissions: permissions,
channels,
voice_states: [makeVoiceState({ user_id: 'user-2', channel_id: 'c-voice' })],
// Оформление приходит в снапшоте шлюза — так же, как в ReadyGuild.
...(typeof guild.banner_file_id === 'string' ? { banner_file_id: guild.banner_file_id } : {}),
...(typeof guild.icon_file_id === 'string' ? { icon_file_id: guild.icon_file_id } : {}),
},
]);
const fetchMock = installFetch(routes(guild));
const gateway = installGatewaySocket(snapshot);
renderApp('/app/g-1/c-general');
await gateway.greet();
await screen.findByTestId('guild-rail');
return { fetchMock, gateway };
}
beforeEach(() => {
resetStores();
restPermissions = null;
});
describe('оформление сервера в шапке и рейке', () => {
it('показывает баннер в шапке и иконку в рейке', async () => {
await bootApp(guildDetail({ banner_file_id: '555', icon_file_id: '777' }));
const header = await screen.findByTestId('guild-header');
expect(header.getAttribute('style') ?? '').toContain('/files/555');
expect(within(header).getByTestId('guild-banner')).toBeInTheDocument();
const rail = screen.getByTestId('guild-rail');
expect(rail.querySelector('img')?.getAttribute('src')).toBe('/files/777');
});
it('обновляет иконку сразу после загрузки, без перезагрузки', async () => {
const { fetchMock } = await bootApp(guildDetail());
// Загружаем иконку через секцию оформления в настройках сервера.
const dialog = await openServerAppearance();
const section = within(dialog).getByTestId('guild-appearance');
await userEvent.upload(
within(section).getByTestId('appearance-icon-input'),
new File(['картинка'], 'icon.png', { type: 'image/png' }),
);
await waitFor(() => {
expect(findRequest(fetchMock, { url: '/appearance/icon', method: 'POST' })).toBeDefined();
});
// Рейка берёт иконку из session-store: она обязана обновиться сразу.
await waitFor(() => {
const rail = screen.getByTestId('guild-rail');
expect(rail.querySelector('img')?.getAttribute('src')).toBe('/files/777');
});
expect(useSessionStore.getState().guilds[0]?.icon_file_id).toBe('777');
});
it('применяет оформление из GUILD_UPDATE другого клиента', async () => {
const { gateway } = await bootApp(guildDetail());
act(() => {
gateway.dispatch('GUILD_UPDATE', {
guild_id: 'g-1',
name: 'Сервер',
icon_file_id: '999',
banner_file_id: '888',
accent_color: 0x2ecc71,
});
});
await waitFor(() => {
expect(useSessionStore.getState().guilds[0]?.icon_file_id).toBe('999');
});
expect(useSessionStore.getState().guilds[0]?.banner_file_id).toBe('888');
const header = screen.getByTestId('guild-header');
expect(header.getAttribute('style') ?? '').toContain('/files/888');
});
});
/** Открывает настройки сервера и ждёт секцию оформления. */
async function openServerAppearance(): Promise<HTMLElement> {
await userEvent.click(await screen.findByLabelText('Настройки сервера'));
const dialog = await screen.findByRole('dialog', { name: 'Настройки' });
await within(dialog).findByTestId('guild-appearance');
return dialog;
}
describe('приглашение из шапки сервера', () => {
it('создаёт ссылку и копирует её', async () => {
const writeText = vi.fn().mockResolvedValue(undefined);
Object.defineProperty(navigator, 'clipboard', {
value: { writeText },
configurable: true,
});
const { fetchMock } = await bootApp();
await userEvent.click(await screen.findByTestId('guild-invite'));
const panel = await screen.findByTestId('guild-invite-panel');
const link = await within(panel).findByTestId('guild-invite-link');
expect(findRequest(fetchMock, { url: '/invites', method: 'POST' })).toBeDefined();
expect(link).toHaveValue('https://gl.test/invite/abc123');
await userEvent.click(within(panel).getByTestId('guild-invite-copy'));
await waitFor(() => {
expect(writeText).toHaveBeenCalledWith('https://gl.test/invite/abc123');
});
expect(await within(panel).findByText('Скопировано')).toBeInTheDocument();
});
it('не показывает кнопку без права приглашать', async () => {
await bootApp(guildDetail({ my_permissions: [], owner_id: 'user-9' }));
await screen.findByTestId('guild-header');
expect(screen.queryByTestId('guild-invite')).toBeNull();
});
it('появляет кнопки после выдачи роли, без перезагрузки', async () => {
// Роль выдали уже в открытом клиенте: сводка сервера перечитывается по
// MEMBER_UPDATE, иначе кнопки управления ждали бы перезагрузки (AGENT.md 11.6).
const { gateway } = await bootApp(guildDetail({ my_permissions: [], owner_id: 'user-9' }));
await screen.findByTestId('guild-header');
expect(screen.queryByTestId('guild-invite')).toBeNull();
restPermissions = ['MANAGE_GUILD', 'CREATE_INVITES'];
act(() => {
gateway.dispatch('MEMBER_UPDATE', { guild_id: 'g-1', user_id: 'user-1' });
});
await waitFor(() => {
expect(screen.getByTestId('guild-invite')).toBeInTheDocument();
});
expect(screen.getByLabelText('Настройки сервера')).toBeInTheDocument();
expect(screen.getByLabelText('Создать комнату')).toBeInTheDocument();
});
});
describe('встроенные звуки интерфейса', () => {
/** Простой счётчик осцилляторов: проверяем, что сигнал действительно играет. */
function fakeAudio(options: { suspended?: boolean } = {}): {
context: AudioContextLike;
started: number[];
resumed: () => number;
} {
const started: number[] = [];
let resumeCount = 0;
const context: AudioContextLike = {
currentTime: 0,
...(options.suspended === true
? {
state: 'suspended',
resume: () => {
resumeCount += 1;
return Promise.resolve();
},
}
: {}),
destination: {} as AudioNode,
createOscillator: () =>
({
type: 'sine',
frequency: { value: 0 },
connect: () => undefined,
start: (when: number) => started.push(when),
stop: () => undefined,
}) as unknown as OscillatorNode,
createGain: () =>
({
gain: {
setValueAtTime: () => undefined,
linearRampToValueAtTime: () => undefined,
},
connect: () => undefined,
}) as unknown as GainNode,
};
return { context, started, resumed: () => resumeCount };
}
it('играет сигнал события, когда на сервере нет своего звука', () => {
const { context, started } = fakeAudio();
setAudioContextFactory(() => context);
playEventSound('g-1', 'message');
expect(started).toHaveLength(1);
});
it('не играет, когда встроенные звуки выключены', () => {
const { context, started } = fakeAudio();
setAudioContextFactory(() => context);
useVoiceStore.setState({ interfaceSoundsEnabled: false });
playEventSound('g-1', 'message');
expect(started).toHaveLength(0);
});
it('молчит при выключенном звуке', () => {
const { context, started } = fakeAudio();
setAudioContextFactory(() => context);
useVoiceStore.setState({ headphonesEnabled: false });
playEventSound('g-1', 'message');
expect(started).toHaveLength(0);
});
it('уважает звук сервера из палитры', () => {
const { context, started } = fakeAudio();
setAudioContextFactory(() => context);
useSessionStore.setState({
guilds: [
{
...guildSummary(),
channels,
emojis: [],
sounds: [{ id: 's-1', name: 'ping', file_id: '42', kind: 'ui', event: 'message' }],
},
],
});
playEventSound('g-1', 'message');
// Встроенный сигнал не играет: приоритет у звука сервера.
expect(started).toHaveLength(0);
});
it('будит звук первым жестом: до него браузер молчит', () => {
const { context, resumed } = fakeAudio({ suspended: true });
setAudioContextFactory(() => context);
unlockInterfaceSounds();
window.dispatchEvent(new Event('pointerdown'));
// Контекст создан уже внутри жеста и сразу разбужен.
expect(resumed()).toBe(1);
});
});