Preserve conversation excerpts and deduplicate case evidence

This commit is contained in:
2026-10-17 19:35:52 -04:00
parent 83f1c91877
commit ef96a2c308
13 changed files with 1222 additions and 50 deletions
+152
View File
@@ -0,0 +1,152 @@
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<typeof currentPage.subscribe>[0]) => currentPage.subscribe(run)
}
}));
let component: ReturnType<typeof mount> | 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<HTMLSelectElement>(`[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<HTMLInputElement>('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('Message time unknown');
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<HTMLInputElement>('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<HTMLInputElement>('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();
});