import { afterEach, expect, it, vi } from 'vitest'; import { flushSync, mount, tick, unmount } from 'svelte'; import { fromStore, writable } from 'svelte/store'; import CaseAssessment from '../../src/lib/ui/components/moderation/CaseAssessment.svelte'; import type { ModerationCaseAssessment } from '../../src/lib/moderation.js'; let component: ReturnType | undefined; afterEach(async () => { if (component) await unmount(component); component = undefined; document.body.replaceChildren(); vi.unstubAllGlobals(); }); const current: ModerationCaseAssessment = { revision: 4, summary: 'Slur documented; threatening intent unclear.', rules: ['Rule 2: Respect other players'], findings: [ { category: 'hate_speech', finding: 'substantiated', detail: 'The preserved message contains a slur.' }, { category: 'threats', finding: 'inconclusive', detail: 'The reply could refer to gameplay.' } ], actor: 'Moderator One', createdAt: '2026-10-17T10:00:00Z' }; function show( assessment: ModerationCaseAssessment | null = null, permitted = true, history: ModerationCaseAssessment[] = [] ) { const props = writable({ revision: 4, assessment }); const state = fromStore(props); const saved = vi.fn(); component = mount(CaseAssessment, { target: document.body, props: { caseId: 'case-1', get revision() { return state.current.revision; }, get assessment() { return state.current.assessment; }, history, permitted, onsubmitted: saved } }); flushSync(); return { saved, update(revision: number, next = assessment) { props.set({ revision, assessment: next }); flushSync(); } }; } const button = (label: string) => [...document.querySelectorAll('button')].find((node) => node.textContent?.trim() === label)!; function field(label: string): T { const node = [...document.querySelectorAll('dialog label')].find( (node) => node.textContent?.replace('*', '').trim() === label )!; return document.querySelector(`[aria-labelledby="${node.id}"]`)! as T; } function enter(label: string, value: string) { const node = field(label); node.value = value; node.dispatchEvent(new Event('input', { bubbles: true })); flushSync(); } function select(label: string, value: string) { const node = field(label); const query = node.querySelector.bind(node); node.querySelector = ((selector: string) => selector === ':checked' ? (node.selectedOptions[0] ?? null) : query(selector)) as typeof node.querySelector; node.value = value; node.dispatchEvent(new Event('change', { bubbles: true })); flushSync(); } const response = (value: unknown, status = 200) => new Response(JSON.stringify(value), { status }); function complete() { enter('Current summary', 'Preserved evidence supports a safeguarding review.'); enter('Applicable rules', ' Rule 4: Safeguarding\n\nRule 2: Respect '); select('Concern 1', 'safeguarding'); select('Finding 1', 'inconclusive'); enter('Explanation 1', 'The surrounding messages establish age but intent remains unclear.'); } it('creates structured findings and separate rule references with one concern per category', async () => { const fetch = vi.fn().mockResolvedValue(response({ ok: true, id: 'case-1' })); vi.stubGlobal('fetch', fetch); const { saved } = show(); expect(document.body.textContent).toContain('Not assessed'); button('Assess case').click(); await tick(); complete(); button('Add finding').click(); flushSync(); expect( [...field('Concern 2').options].find( (option) => option.value === 'safeguarding' )?.disabled ).toBe(true); select('Concern 2', 'threats'); select('Finding 2', 'unsubstantiated'); enter('Explanation 2', 'No threat is present in the preserved exchange.'); button('Save assessment').click(); await vi.waitFor(() => expect(saved).toHaveBeenCalledOnce()); expect(JSON.parse(fetch.mock.calls[0][1].body)).toEqual({ op: 'assess-case', id: 'case-1', revision: 4, summary: 'Preserved evidence supports a safeguarding review.', rules: ['Rule 4: Safeguarding', 'Rule 2: Respect'], findings: [ { category: 'safeguarding', finding: 'inconclusive', detail: 'The surrounding messages establish age but intent remains unclear.' }, { category: 'threats', finding: 'unsubstantiated', detail: 'No threat is present in the preserved exchange.' } ], idempotencyKey: expect.any(String) }); }); it('shows current findings and preserves prior assessments for read-only viewers', () => { const previous = { ...current, revision: 2, summary: 'Initial concern awaiting full context.', actor: 'Moderator Two' }; show(current, false, [current, previous]); expect(document.body.textContent).toContain(current.summary); expect(document.body.textContent).toContain('Substantiated'); expect(document.body.textContent).toContain('Inconclusive'); expect(document.querySelector('details summary')?.textContent).toBe('Previous assessments (1)'); expect(document.querySelector('details')?.textContent).toContain(previous.summary); expect(document.querySelector('details')?.textContent).toContain('Moderator Two'); expect(button('Edit assessment')).toBeUndefined(); expect(document.querySelector('dialog')).toBeNull(); }); it('retains a conflicted assessment and uses the refreshed revision after reopening', async () => { const fetch = vi .fn() .mockResolvedValueOnce( response({ error: 'This case changed. Reload it before making another change.' }, 409) ) .mockResolvedValue(response({ ok: true, id: 'case-1' })); vi.stubGlobal('fetch', fetch); const { update, saved } = show(current); button('Edit assessment').click(); await tick(); expect(field('Current summary').value).toBe(current.summary); enter('Current summary', 'Revised conclusion after review.'); button('Save assessment').click(); await vi.waitFor(() => expect(document.querySelector('[role="alert"]')?.textContent).toContain( 'This case changed.' ) ); expect(field('Current summary').value).toBe( 'Revised conclusion after review.' ); expect(saved).not.toHaveBeenCalled(); button('Cancel').click(); await tick(); update(9); button('Edit assessment').click(); await tick(); enter('Current summary', 'Reviewed against the refreshed case.'); document.querySelector('[aria-label="Remove finding 2"]')!.click(); flushSync(); button('Save assessment').click(); await vi.waitFor(() => expect(saved).toHaveBeenCalledOnce()); expect(JSON.parse(fetch.mock.calls[1][1].body)).toMatchObject({ revision: 9, findings: [current.findings[0]] }); }); it('retries an uncertain assessment as the exact same request', async () => { const fetch = vi .fn() .mockRejectedValueOnce(new Error('Connection lost')) .mockResolvedValue(response({ ok: true, id: 'case-1' })); vi.stubGlobal('fetch', fetch); const { saved } = show(); button('Assess case').click(); await tick(); complete(); button('Save assessment').click(); 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(saved).toHaveBeenCalledOnce()); expect(fetch.mock.calls[1][1].body).toBe(fetch.mock.calls[0][1].body); });