Moves ~25 components, utils (search, filename, groupDocuments, documentStatusLabel, validateFile), bulkSelection store, and TranscriptionSection sub-component. Fixes broken relative imports. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
229 lines
6.6 KiB
TypeScript
229 lines
6.6 KiB
TypeScript
import { describe, it, expect, afterEach } from 'vitest';
|
|
import { cleanup, render } from 'vitest-browser-svelte';
|
|
import * as m from '$lib/paraglide/messages.js';
|
|
|
|
import ThumbnailRow from './ThumbnailRow.svelte';
|
|
|
|
afterEach(() => {
|
|
cleanup();
|
|
});
|
|
|
|
const baseDoc = {
|
|
id: 'd1',
|
|
title: 'Liebe Anna',
|
|
originalFilename: 'liebe_anna.pdf',
|
|
documentDate: '1950-06-01',
|
|
location: 'Berlin',
|
|
summary: 'Heute schreibe ich Dir, weil die Kinder gesund sind.',
|
|
contentType: 'application/pdf',
|
|
thumbnailKey: 'thumbnails/d1.jpg',
|
|
thumbnailGeneratedAt: '2026-04-01T12:00:00Z',
|
|
thumbnailAspect: 'PORTRAIT' as const,
|
|
pageCount: 2,
|
|
sender: { id: 'hans', firstName: 'Hans', lastName: 'Müller', displayName: 'Hans Müller' },
|
|
receivers: [{ id: 'anna', firstName: 'Anna', lastName: 'Schmidt', displayName: 'Anna Schmidt' }],
|
|
tags: [
|
|
{ id: 't1', name: 'Familie' },
|
|
{ id: 't2', name: 'Krieg' },
|
|
{ id: 't3', name: 'Reise' },
|
|
{ id: 't4', name: 'Arbeit' },
|
|
{ id: 't5', name: 'Zuhause' }
|
|
]
|
|
};
|
|
|
|
describe('ThumbnailRow', () => {
|
|
it('renders the title, date, location, and summary quote', () => {
|
|
render(ThumbnailRow, {
|
|
doc: baseDoc,
|
|
isOut: true,
|
|
showOtherParty: false
|
|
});
|
|
|
|
expect(document.body.textContent).toContain('Liebe Anna');
|
|
expect(document.body.textContent).toContain('Berlin');
|
|
expect(document.body.textContent).toContain('Heute schreibe ich Dir');
|
|
});
|
|
|
|
it('falls back to originalFilename when title is empty', () => {
|
|
render(ThumbnailRow, {
|
|
doc: { ...baseDoc, title: '' },
|
|
isOut: true,
|
|
showOtherParty: false
|
|
});
|
|
|
|
expect(document.body.textContent).toContain('liebe_anna.pdf');
|
|
});
|
|
|
|
it('shows the other-party name when showOtherParty=true (non-bilateral list)', () => {
|
|
render(ThumbnailRow, {
|
|
doc: baseDoc,
|
|
isOut: true,
|
|
showOtherParty: true
|
|
});
|
|
|
|
// Out-going from Hans, other party is first receiver (Anna Schmidt)
|
|
expect(document.body.textContent).toContain('Anna Schmidt');
|
|
});
|
|
|
|
it('hides the other-party name when showOtherParty=false (bilateral list)', () => {
|
|
render(ThumbnailRow, {
|
|
doc: baseDoc,
|
|
isOut: false,
|
|
showOtherParty: false
|
|
});
|
|
|
|
// Anna is the receiver; in a bilateral list we suppress party names.
|
|
expect(document.body.textContent).not.toContain('Anna Schmidt');
|
|
});
|
|
|
|
it('renders at most 3 tag chips and signals any remainder with "+N"', () => {
|
|
render(ThumbnailRow, {
|
|
doc: baseDoc,
|
|
isOut: true,
|
|
showOtherParty: false
|
|
});
|
|
|
|
const chips = document.querySelectorAll('[data-testid="thumb-row-tag"]');
|
|
expect(chips.length).toBeLessThanOrEqual(3);
|
|
expect(document.body.textContent).toMatch(/\+2/);
|
|
});
|
|
|
|
it('does not render a relative-year label', () => {
|
|
// Document date is historical; we deliberately omit the "vor N Jahren"
|
|
// chip so the row can give vertical space to the title + summary.
|
|
render(ThumbnailRow, {
|
|
doc: baseDoc,
|
|
isOut: true,
|
|
showOtherParty: false
|
|
});
|
|
|
|
expect(document.body.textContent).not.toMatch(/vor \d+ Jahr/);
|
|
expect(document.body.textContent).not.toMatch(/vor weniger/);
|
|
});
|
|
|
|
it('renders the title at text-lg so the row uses its full vertical space', () => {
|
|
render(ThumbnailRow, {
|
|
doc: baseDoc,
|
|
isOut: true,
|
|
showOtherParty: false
|
|
});
|
|
|
|
const titleEl = [...document.querySelectorAll('div')].find(
|
|
(el) => el.textContent?.trim() === 'Liebe Anna' && el.className.includes('truncate')
|
|
) as HTMLElement | undefined;
|
|
expect(titleEl, 'title element not found').toBeDefined();
|
|
expect(titleEl!.className).toContain('text-lg');
|
|
});
|
|
|
|
it('renders a right-arrow icon for outgoing letters', () => {
|
|
render(ThumbnailRow, {
|
|
doc: baseDoc,
|
|
isOut: true,
|
|
showOtherParty: false
|
|
});
|
|
|
|
const arrow = document.querySelector(
|
|
'[data-testid="thumb-row-direction-icon"]'
|
|
) as HTMLImageElement | null;
|
|
expect(arrow).not.toBeNull();
|
|
expect(arrow!.getAttribute('src') ?? '').toMatch(/Long-Arrow-Right/);
|
|
// Decorative — direction is already announced via the aria-label prefix.
|
|
expect(arrow!.getAttribute('aria-hidden')).toBe('true');
|
|
});
|
|
|
|
it('renders a left-arrow icon for incoming letters', () => {
|
|
render(ThumbnailRow, {
|
|
doc: baseDoc,
|
|
isOut: false,
|
|
showOtherParty: false
|
|
});
|
|
|
|
const arrow = document.querySelector(
|
|
'[data-testid="thumb-row-direction-icon"]'
|
|
) as HTMLImageElement | null;
|
|
expect(arrow).not.toBeNull();
|
|
expect(arrow!.getAttribute('src') ?? '').toMatch(/Long-Arrow-Left/);
|
|
});
|
|
|
|
it('sets border-l class based on isOut', () => {
|
|
const { unmount } = render(ThumbnailRow, {
|
|
doc: baseDoc,
|
|
isOut: true,
|
|
showOtherParty: false
|
|
});
|
|
|
|
let link = document.querySelector('a[href="/documents/d1"]') as HTMLElement;
|
|
expect(link.className).toContain('border-l-primary');
|
|
|
|
unmount();
|
|
|
|
render(ThumbnailRow, {
|
|
doc: baseDoc,
|
|
isOut: false,
|
|
showOtherParty: false
|
|
});
|
|
link = document.querySelector('a[href="/documents/d1"]') as HTMLElement;
|
|
expect(link.className).toContain('border-l-accent');
|
|
});
|
|
|
|
it('exposes a descriptive aria-label combining direction, title, and date', () => {
|
|
render(ThumbnailRow, {
|
|
doc: baseDoc,
|
|
isOut: true,
|
|
showOtherParty: false
|
|
});
|
|
|
|
const link = document.querySelector('a[href="/documents/d1"]') as HTMLElement;
|
|
const label = link.getAttribute('aria-label') ?? '';
|
|
// Direction label routes through Paraglide so EN / ES users don't hear
|
|
// "Gesendet" in their screen reader.
|
|
expect(label.startsWith(`${m.row_direction_sent()}:`)).toBe(true);
|
|
expect(label).toContain('Liebe Anna');
|
|
expect(label).toMatch(/1950/);
|
|
});
|
|
|
|
it('aria-label leads with the received direction label for incoming letters', () => {
|
|
render(ThumbnailRow, {
|
|
doc: baseDoc,
|
|
isOut: false,
|
|
showOtherParty: false
|
|
});
|
|
|
|
const link = document.querySelector('a[href="/documents/d1"]') as HTMLElement;
|
|
const label = link.getAttribute('aria-label') ?? '';
|
|
expect(label.startsWith(`${m.row_direction_received()}:`)).toBe(true);
|
|
});
|
|
|
|
it('does not inject raw HTML when summary contains markup (XSS regression)', () => {
|
|
render(ThumbnailRow, {
|
|
doc: {
|
|
...baseDoc,
|
|
summary: 'safe <img src=x onerror="alert(1)"> text'
|
|
},
|
|
isOut: true,
|
|
showOtherParty: false
|
|
});
|
|
|
|
// No real img tag from the summary, the ConversationThumbnail img is fine.
|
|
const imgs = document.querySelectorAll('img[onerror]');
|
|
expect(imgs.length).toBe(0);
|
|
expect(document.body.textContent).toContain('<img src=x onerror="alert(1)">');
|
|
});
|
|
|
|
it('handles missing optional fields without crashing', () => {
|
|
render(ThumbnailRow, {
|
|
doc: {
|
|
id: 'n1',
|
|
title: 'Ohne Datum',
|
|
originalFilename: 'x.pdf',
|
|
contentType: 'application/pdf',
|
|
thumbnailAspect: 'PORTRAIT'
|
|
},
|
|
isOut: true,
|
|
showOtherParty: false
|
|
});
|
|
|
|
expect(document.body.textContent).toContain('Ohne Datum');
|
|
});
|
|
});
|