import { afterEach, expect, it, vi } from 'vitest'; import { flushSync, mount, tick, unmount } from 'svelte'; import { writable } from 'svelte/store'; import ChatPanel from '../../src/lib/ui/components/ChatPanel.svelte'; import type { ContextualChatEntry } from '../../src/lib/game-chat.js'; import type { UserRole } from '../../src/lib/auth.js'; const currentPage = writable({ data: { user: { role: 'moderator' as UserRole } } }); vi.mock('$app/stores', () => ({ page: { subscribe: (run: Parameters[0]) => currentPage.subscribe(run) } })); let component: ReturnType | undefined; afterEach(async () => { if (component) await unmount(component); component = undefined; document.body.replaceChildren(); vi.unstubAllGlobals(); }); const history: ContextualChatEntry[] = [ ['11', 'Subject', 'A concerning reply'], ['22', 'Other player', 'The question that explains it'], ['11', 'Subject', 'A later reply'] ].map(([guid, player, message], index) => ({ type: 'message', matchId: 17, messageId: `17:message-${index}:1`, message: { guid, player, message, time: `1:0${index}`, slot: index }, context: { realTime: null, matchElapsedMs: index * 1000, round: 2 } })); const button = (label: string) => [...document.querySelectorAll('button')].find((node) => node.textContent?.trim() === label)!; const response = (value: unknown) => new Response(JSON.stringify(value)); function show(role: UserRole = 'moderator') { currentPage.set({ data: { user: { role } } }); component = mount(ChatPanel, { target: document.body, props: { game: 't5', history } }); flushSync(); } function select(label: string, value: string) { const labelNode = [...document.querySelectorAll('dialog label')].find( (node) => node.textContent?.replace('*', '').trim() === label )!; const node = document.querySelector(`[aria-labelledby="${labelNode.id}"]`)!; const originalQuery = node.querySelector.bind(node); node.querySelector = ((selector: string) => selector === ':checked' ? (node.selectedOptions[0] ?? null) : originalQuery(selector)) as typeof node.querySelector; node.value = value; node.dispatchEvent(new Event('change', { bubbles: true })); flushSync(); } const submit = () => document .querySelector('dialog form')! .dispatchEvent(new Event('submit', { bubbles: true, cancelable: true })); it('selects messages across participants, previews their context, and attaches to an existing case', async () => { const fetch = vi .fn() .mockResolvedValueOnce( response({ cases: [{ id: 'case-1', title: 'Existing review', status: 'open', revision: 4 }], total: 1 }) ) .mockResolvedValueOnce(response({ ok: true, id: 'case-1' })); vi.stubGlobal('fetch', fetch); show(); expect(button('Attach to case').disabled).toBe(true); const checkboxes = document.querySelectorAll('input[type="checkbox"]'); checkboxes[0].click(); checkboxes[1].click(); flushSync(); expect(document.body.textContent).toContain('2 selected'); button('Attach to case').click(); await vi.waitFor(() => expect(document.querySelector('dialog')?.textContent).toContain('Existing review') ); expect(fetch.mock.calls[0][0]).toContain('game=t5&guid=11'); expect(document.querySelector('dialog')?.textContent).toContain('GUID 22'); expect(document.querySelector('dialog')?.textContent).toContain('Unknown time'); const excerpt = document.querySelector('dialog [aria-label="Preserved conversation excerpt"]')!; expect(excerpt.querySelectorAll('li')).toHaveLength(3); expect(excerpt.querySelector('.highlighted')?.textContent).toContain('A concerning reply'); expect(excerpt.textContent).toContain('Selected context'); select('Destination', 'case-1'); submit(); await vi.waitFor(() => expect(document.body.textContent).toContain('Selected evidence saved.')); expect(JSON.parse(fetch.mock.calls[1][1].body)).toEqual({ op: 'attach-evidence', id: 'case-1', revision: 4, evidence: { matchId: 17, messageIds: ['17:message-0:1', '17:message-1:1'], contextBefore: 3, contextAfter: 3 }, idempotencyKey: expect.any(String) }); expect(document.querySelector('a[href="/moderation/cases/case-1"]')).not.toBeNull(); expect( [...document.querySelectorAll('input[type="checkbox"]')].every( (node) => !node.checked ) ).toBe(true); }); it('opens a new case with all selected messages and retries an uncertain request unchanged', async () => { const fetch = vi .fn() .mockResolvedValueOnce(response({ cases: [], total: 0 })) .mockRejectedValueOnce(new Error('Connection lost')) .mockResolvedValueOnce(response({ ok: true, id: 'new-case' })); vi.stubGlobal('fetch', fetch); show(); button('Select all').click(); flushSync(); button('Attach to case').click(); await vi.waitFor(() => expect(document.querySelector('dialog')?.textContent).toContain('No existing cases') ); const title = document.querySelector('dialog input:not([type="number"])')!; title.value = 'Conversation review'; title.dispatchEvent(new Event('input', { bubbles: true })); flushSync(); submit(); await vi.waitFor(() => expect(button('Retry same request')).toBeDefined()); expect(document.querySelector('dialog fieldset')?.hasAttribute('disabled')).toBe(true); button('Retry same request').click(); await vi.waitFor(() => expect(fetch).toHaveBeenCalledTimes(3)); expect(fetch.mock.calls[2][1].body).toBe(fetch.mock.calls[1][1].body); expect(JSON.parse(fetch.mock.calls[1][1].body)).toMatchObject({ op: 'create-case', game: 't5', guid: '11', title: 'Conversation review', evidence: { messageIds: history.map((entry) => entry.messageId) } }); await tick(); }); it('keeps evidence selection unavailable to read-only viewers', () => { show('super_viewer'); expect(document.querySelector('input[type="checkbox"]')).toBeNull(); expect(button('Attach to case')).toBeUndefined(); });