// 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 => ({ 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(); }); });