All checks were successful
CI / Unit & Component Tests (pull_request) Successful in 3m22s
CI / OCR Service Tests (pull_request) Successful in 24s
CI / Backend Unit Tests (pull_request) Successful in 3m47s
CI / fail2ban Regex (pull_request) Successful in 46s
CI / Semgrep Security Scan (pull_request) Successful in 24s
CI / Compose Bucket Idempotency (pull_request) Successful in 1m6s
Addresses @Sara review: browser tests in this spec fail silently when the project path contains '+' (common in git worktrees). The comment tells developers to copy the frontend directory to a clean path. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
215 lines
7.8 KiB
TypeScript
215 lines
7.8 KiB
TypeScript
// NOTE: vitest-browser fails silently when the project path contains '+' (common in git worktrees
|
||
// named 'feat+issue-NNN-slug'). If tests fail with iframe routing errors, copy the frontend
|
||
// directory to a path without '+' (e.g. /tmp/fe-copy) and run the suite from there.
|
||
import { describe, expect, it, vi, afterEach } from 'vitest';
|
||
import { cleanup, render } from 'vitest-browser-svelte';
|
||
import { page } from 'vitest/browser';
|
||
import InterpretationChipRow from './InterpretationChipRow.svelte';
|
||
import type { components } from '$lib/generated/api';
|
||
|
||
type NlQueryInterpretation = components['schemas']['NlQueryInterpretation'];
|
||
type PersonHint = components['schemas']['PersonHint'];
|
||
type TagHint = components['schemas']['TagHint'];
|
||
|
||
afterEach(() => cleanup());
|
||
|
||
const makePerson = (id: string, displayName: string): PersonHint => ({ id, displayName });
|
||
|
||
const makeInterpretation = (
|
||
overrides: Partial<NlQueryInterpretation> = {}
|
||
): NlQueryInterpretation => ({
|
||
resolvedPersons: [],
|
||
ambiguousPersons: [],
|
||
keywords: [],
|
||
resolvedTags: [],
|
||
rawQuery: 'test',
|
||
keywordsApplied: true,
|
||
tagsApplied: false,
|
||
...overrides
|
||
});
|
||
|
||
describe('InterpretationChipRow', () => {
|
||
it('renders type-prefixed labels for sender, date and keyword chips', async () => {
|
||
render(InterpretationChipRow, {
|
||
interpretation: makeInterpretation({
|
||
resolvedPersons: [makePerson('p1', 'Walter Raddatz')],
|
||
dateFrom: '1914-01-01',
|
||
dateTo: '1918-12-31',
|
||
keywords: ['krieg']
|
||
}),
|
||
onRemoveChip: vi.fn()
|
||
});
|
||
await expect.element(page.getByText('Absender: Walter Raddatz')).toBeInTheDocument();
|
||
await expect.element(page.getByText('Zeitraum: 1914–1918')).toBeInTheDocument();
|
||
await expect.element(page.getByText('Stichwort: krieg')).toBeInTheDocument();
|
||
});
|
||
|
||
it('calls onRemoveChip with "sender" when the sender chip × is clicked', async () => {
|
||
const onRemoveChip = vi.fn();
|
||
render(InterpretationChipRow, {
|
||
interpretation: makeInterpretation({
|
||
resolvedPersons: [makePerson('p1', 'Walter Raddatz')]
|
||
}),
|
||
onRemoveChip
|
||
});
|
||
await page.getByRole('button', { name: /Absender: Walter Raddatz/ }).click();
|
||
expect(onRemoveChip).toHaveBeenCalledWith('sender', undefined);
|
||
});
|
||
|
||
it('removes a chip from the DOM but keeps the rest when one × is clicked', async () => {
|
||
const { container } = render(InterpretationChipRow, {
|
||
interpretation: makeInterpretation({
|
||
resolvedPersons: [makePerson('p1', 'Walter Raddatz')],
|
||
dateFrom: '1914-01-01',
|
||
dateTo: '1918-12-31',
|
||
keywords: ['krieg']
|
||
}),
|
||
onRemoveChip: vi.fn()
|
||
});
|
||
expect(container.querySelectorAll('[data-chip-type]')).toHaveLength(3);
|
||
await page.getByRole('button', { name: /Absender/ }).click();
|
||
await vi.waitFor(() => expect(container.querySelectorAll('[data-chip-type]')).toHaveLength(2));
|
||
});
|
||
|
||
it('renders a single directional chip with an arrow for a 2-name query', async () => {
|
||
const { container } = render(InterpretationChipRow, {
|
||
interpretation: makeInterpretation({
|
||
resolvedPersons: [makePerson('p1', 'Walter Raddatz'), makePerson('p2', 'Emma Raddatz')]
|
||
}),
|
||
onRemoveChip: vi.fn()
|
||
});
|
||
expect(container.querySelectorAll('[data-chip-type="directional"]')).toHaveLength(1);
|
||
await expect.element(page.getByText(/→/)).toBeInTheDocument();
|
||
});
|
||
|
||
it('calls onRemoveChip with "directional" when the directional chip × is clicked', async () => {
|
||
const onRemoveChip = vi.fn();
|
||
render(InterpretationChipRow, {
|
||
interpretation: makeInterpretation({
|
||
resolvedPersons: [makePerson('p1', 'Walter Raddatz'), makePerson('p2', 'Emma Raddatz')]
|
||
}),
|
||
onRemoveChip
|
||
});
|
||
await page.getByRole('button', { name: /Walter Raddatz/ }).click();
|
||
expect(onRemoveChip).toHaveBeenCalledWith('directional', undefined);
|
||
});
|
||
|
||
it('does not render keyword chips when keywordsApplied is false', async () => {
|
||
const { container } = render(InterpretationChipRow, {
|
||
interpretation: makeInterpretation({
|
||
keywordsApplied: false,
|
||
keywords: ['krieg', 'brief']
|
||
}),
|
||
onRemoveChip: vi.fn()
|
||
});
|
||
expect(container.querySelectorAll('[data-chip-type="keyword"]')).toHaveLength(0);
|
||
});
|
||
|
||
it('renders no keyword chips when keywords is empty', async () => {
|
||
const { container } = render(InterpretationChipRow, {
|
||
interpretation: makeInterpretation({ keywordsApplied: true, keywords: [] }),
|
||
onRemoveChip: vi.fn()
|
||
});
|
||
expect(container.querySelectorAll('[data-chip-type="keyword"]')).toHaveLength(0);
|
||
});
|
||
|
||
it('renders exactly one keyword chip per keyword', async () => {
|
||
const { container } = render(InterpretationChipRow, {
|
||
interpretation: makeInterpretation({
|
||
keywordsApplied: true,
|
||
keywords: ['krieg', 'brief', 'front']
|
||
}),
|
||
onRemoveChip: vi.fn()
|
||
});
|
||
expect(container.querySelectorAll('[data-chip-type="keyword"]')).toHaveLength(3);
|
||
});
|
||
|
||
it('keeps the × button in the DOM when a display name is 100 characters', async () => {
|
||
const longName = 'W'.repeat(100);
|
||
render(InterpretationChipRow, {
|
||
interpretation: makeInterpretation({
|
||
resolvedPersons: [makePerson('p1', longName)]
|
||
}),
|
||
onRemoveChip: vi.fn()
|
||
});
|
||
await expect
|
||
.element(page.getByRole('button', { name: new RegExp('Absender') }))
|
||
.toBeInTheDocument();
|
||
});
|
||
|
||
// ── theme chips ─────────────────────────────────────────────────────────────
|
||
|
||
const makeTag = (id: string, name: string, color?: string): TagHint => ({ id, name, color });
|
||
|
||
it('renders theme chips when tagsApplied is true', async () => {
|
||
const { container } = render(InterpretationChipRow, {
|
||
interpretation: makeInterpretation({
|
||
resolvedTags: [makeTag('t1', 'Hochzeit')],
|
||
tagsApplied: true
|
||
}),
|
||
onRemoveChip: vi.fn()
|
||
});
|
||
expect(container.querySelectorAll('[data-chip-type="theme"]')).toHaveLength(1);
|
||
await expect.element(page.getByText(/Thema: Hochzeit/)).toBeInTheDocument();
|
||
});
|
||
|
||
it('renders no theme chips when tagsApplied is false', async () => {
|
||
const { container } = render(InterpretationChipRow, {
|
||
interpretation: makeInterpretation({
|
||
resolvedTags: [makeTag('t1', 'Hochzeit')],
|
||
tagsApplied: false
|
||
}),
|
||
onRemoveChip: vi.fn()
|
||
});
|
||
expect(container.querySelectorAll('[data-chip-type="theme"]')).toHaveLength(0);
|
||
});
|
||
|
||
it('renders exactly N theme chips for N resolved tags', async () => {
|
||
const { container } = render(InterpretationChipRow, {
|
||
interpretation: makeInterpretation({
|
||
resolvedTags: [makeTag('t1', 'Krieg'), makeTag('t2', 'Hochzeit'), makeTag('t3', 'Familie')],
|
||
tagsApplied: true
|
||
}),
|
||
onRemoveChip: vi.fn()
|
||
});
|
||
expect(container.querySelectorAll('[data-chip-type="theme"]')).toHaveLength(3);
|
||
});
|
||
|
||
it('calls onRemoveChip with "theme" and tag name when × is clicked', async () => {
|
||
const onRemoveChip = vi.fn();
|
||
render(InterpretationChipRow, {
|
||
interpretation: makeInterpretation({
|
||
resolvedTags: [makeTag('t1', 'Hochzeit')],
|
||
tagsApplied: true
|
||
}),
|
||
onRemoveChip
|
||
});
|
||
await page.getByRole('button', { name: /Thema: Hochzeit/ }).click();
|
||
expect(onRemoveChip).toHaveBeenCalledWith('theme', 'Hochzeit');
|
||
});
|
||
|
||
it('applies inline color style for a tag with a color', async () => {
|
||
const { container } = render(InterpretationChipRow, {
|
||
interpretation: makeInterpretation({
|
||
resolvedTags: [makeTag('t1', 'Hochzeit', 'sage')],
|
||
tagsApplied: true
|
||
}),
|
||
onRemoveChip: vi.fn()
|
||
});
|
||
const chip = container.querySelector('[data-chip-type="theme"]') as HTMLElement;
|
||
expect(chip.style.backgroundColor).toBeTruthy();
|
||
});
|
||
|
||
it('omits color style for a tag with no color', async () => {
|
||
const { container } = render(InterpretationChipRow, {
|
||
interpretation: makeInterpretation({
|
||
resolvedTags: [makeTag('t1', 'Hochzeit')],
|
||
tagsApplied: true
|
||
}),
|
||
onRemoveChip: vi.fn()
|
||
});
|
||
const chip = container.querySelector('[data-chip-type="theme"]') as HTMLElement;
|
||
expect(chip.getAttribute('style')).toBeFalsy();
|
||
});
|
||
});
|