215 lines
8.1 KiB
TypeScript
215 lines
8.1 KiB
TypeScript
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<typeof mount> | 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<T extends HTMLTextAreaElement | HTMLSelectElement>(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<HTMLTextAreaElement>(label);
|
|
node.value = value;
|
|
node.dispatchEvent(new Event('input', { bubbles: true }));
|
|
flushSync();
|
|
}
|
|
function select(label: string, value: string) {
|
|
const node = field<HTMLSelectElement>(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<HTMLSelectElement>('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<HTMLTextAreaElement>('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<HTMLTextAreaElement>('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<HTMLButtonElement>('[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);
|
|
});
|