fix(web): баннер сервера из снапшота, приглашение, встроенные звуки
- READY-снапшот больше не теряет banner/splash/accent: шапка сервера показывает баннер сразу после подключения (AGENT.md 7.4) - GUILD_UPDATE несёт всё оформление, клиент применяет его без перезагрузки - кнопка приглашения в шапке сервера со ссылкой и копированием - встроенные звуки интерфейса на Web Audio с разблокировкой по жесту - object URL превью через безопасные обёртки (jsdom и урезанные webview)
This commit is contained in:
@@ -0,0 +1,314 @@
|
||||
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 }];
|
||||
|
||||
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(guild)] }) },
|
||||
{ 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-снапшотом сервера. */
|
||||
async function bootApp(guild: Record<string, unknown> = guildDetail()) {
|
||||
const snapshot = makeReadySnapshot([
|
||||
{
|
||||
id: 'g-1',
|
||||
name: 'Сервер',
|
||||
is_main: true,
|
||||
my_permissions: ['MANAGE_GUILD', 'CREATE_INVITES'],
|
||||
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();
|
||||
});
|
||||
|
||||
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();
|
||||
});
|
||||
});
|
||||
|
||||
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);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user