refactor(search): delete frontend NLP search components and utilities

Removes SmartModeToggle, SmartSearchStatus, InterpretationChipRow,
DisambiguationPicker, chip-types utilities, and theme-chip-removal
utilities as part of NLP feature removal.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Marcel
2026-06-07 18:43:34 +02:00
committed by marcel
parent 35642ce6c4
commit b790a6f823
11 changed files with 0 additions and 975 deletions

View File

@@ -1,85 +0,0 @@
import { describe, it, expect } from 'vitest';
import { buildThemeRemovalUrl } from './theme-chip-removal.js';
import type { components } from '$lib/generated/api';
type NlQueryInterpretation = components['schemas']['NlQueryInterpretation'];
function makeInterp(overrides: Partial<NlQueryInterpretation> = {}): NlQueryInterpretation {
return {
resolvedPersons: [],
ambiguousPersons: [],
keywords: [],
resolvedTags: [],
rawQuery: '',
keywordsApplied: false,
tagsApplied: true,
...overrides
};
}
function makeTag(id: string, name: string, color?: string) {
return color ? { id, name, color } : { id, name };
}
describe('buildThemeRemovalUrl', () => {
it('N remaining tags → N tag params + tagOp=OR', () => {
const interp = makeInterp({
resolvedTags: [
makeTag('aaa', 'Hochzeit'),
makeTag('bbb', 'Weltkrieg'),
makeTag('ccc', 'Familie')
]
});
const url = buildThemeRemovalUrl(interp, 'Hochzeit');
const params = new URL(url, 'http://x').searchParams;
expect(params.getAll('tag')).toEqual(['Weltkrieg', 'Familie']);
expect(params.get('tagOp')).toBe('OR');
});
it('last tag removed → no tag or tagOp params in URL', () => {
const interp = makeInterp({
resolvedTags: [makeTag('aaa', 'Hochzeit')]
});
const url = buildThemeRemovalUrl(interp, 'Hochzeit');
const params = new URL(url, 'http://x').searchParams;
expect(params.getAll('tag')).toEqual([]);
expect(params.get('tagOp')).toBeNull();
});
it('last tag removed with resolved sender person → sender param intact', () => {
const interp = makeInterp({
resolvedPersons: [{ id: '11111111-1111-1111-1111-111111111111', displayName: 'Walter' }],
resolvedTags: [makeTag('aaa', 'Hochzeit')]
});
const url = buildThemeRemovalUrl(interp, 'Hochzeit');
const params = new URL(url, 'http://x').searchParams;
expect(params.get('senderId')).toBe('11111111-1111-1111-1111-111111111111');
expect(params.getAll('tag')).toEqual([]);
expect(params.get('tagOp')).toBeNull();
});
it('null-color tag → tag name emitted correctly; color does not affect params', () => {
const interp = makeInterp({
resolvedTags: [makeTag('aaa', 'Erbschaft'), makeTag('bbb', 'Migration')]
});
const url = buildThemeRemovalUrl(interp, 'Erbschaft');
const params = new URL(url, 'http://x').searchParams;
expect(params.getAll('tag')).toEqual(['Migration']);
expect(params.get('tagOp')).toBe('OR');
});
it('directional pair → senderId and receiverId both emitted', () => {
const interp = makeInterp({
resolvedPersons: [
{ id: '11111111-1111-1111-1111-111111111111', displayName: 'Walter' },
{ id: '22222222-2222-2222-2222-222222222222', displayName: 'Emma' }
],
resolvedTags: [makeTag('aaa', 'Krieg'), makeTag('bbb', 'Heimat')]
});
const url = buildThemeRemovalUrl(interp, 'Krieg');
const params = new URL(url, 'http://x').searchParams;
expect(params.get('senderId')).toBe('11111111-1111-1111-1111-111111111111');
expect(params.get('receiverId')).toBe('22222222-2222-2222-2222-222222222222');
expect(params.getAll('tag')).toEqual(['Heimat']);
});
});

View File

@@ -1,26 +0,0 @@
import type { components } from '$lib/generated/api';
type NlQueryInterpretation = components['schemas']['NlQueryInterpretation'];
export function buildThemeRemovalUrl(
interp: NlQueryInterpretation,
removedTagName: string
): string {
const remaining = interp.resolvedTags.filter((t) => t.name !== removedTagName);
const params = new URLSearchParams();
const resolved = interp.resolvedPersons;
if (resolved.length >= 1) params.set('senderId', resolved[0].id);
if (resolved.length >= 2) params.set('receiverId', resolved[1].id);
if (interp.dateFrom) params.set('from', interp.dateFrom);
if (interp.dateTo) params.set('to', interp.dateTo);
if (interp.keywordsApplied && interp.keywords.length > 0) {
params.set('q', interp.keywords.join(' '));
}
remaining.forEach((tag) => params.append('tag', tag.name));
if (remaining.length > 0) params.set('tagOp', 'OR');
const qs = params.toString();
return qs ? `/documents?${qs}` : '/documents';
}

View File

@@ -1,102 +0,0 @@
<script lang="ts">
import { tick } from 'svelte';
import { m } from '$lib/paraglide/messages.js';
import { clickOutside } from '$lib/shared/actions/clickOutside';
import type { components } from '$lib/generated/api';
type PersonHint = components['schemas']['PersonHint'];
let {
persons,
heading,
showCue,
onSelect
}: {
persons: PersonHint[];
heading: string;
showCue: boolean;
onSelect: (person: PersonHint) => void;
} = $props();
let open = $state(false);
let triggerEl = $state<HTMLButtonElement>();
let listEl = $state<HTMLUListElement>();
const panelId = 'disambiguation-panel';
const headingId = 'disambiguation-heading';
const names = $derived(persons.map((person) => person.displayName).join(', '));
const triggerLabel = $derived(
persons.length === 1 ? heading : m.search_disambiguation_trigger_label()
);
async function openPicker() {
open = true;
await tick();
listEl?.querySelector<HTMLButtonElement>('button')?.focus();
}
function closePicker() {
open = false;
triggerEl?.focus();
}
function toggle() {
if (open) closePicker();
else openPicker();
}
function select(person: PersonHint) {
open = false;
onSelect(person);
}
function onKeydown(event: KeyboardEvent) {
if (event.key === 'Escape' && open) {
event.stopPropagation();
closePicker();
}
}
</script>
<svelte:window onkeydown={onKeydown} />
<div class="relative inline-block" use:clickOutside onclickoutside={() => open && closePicker()}>
<button
bind:this={triggerEl}
type="button"
aria-haspopup="true"
aria-expanded={open}
aria-controls={panelId}
aria-label={triggerLabel}
onclick={toggle}
class="inline-flex min-h-[44px] items-center gap-1.5 rounded-full border border-line bg-muted px-3 text-sm text-ink-2 outline-none focus-visible:ring-2 focus-visible:ring-brand-navy"
>
<span class="max-w-[8rem] truncate sm:max-w-[12rem]">{names}</span>
{#if showCue}
<span class="text-ink-3">{m.search_disambiguation_cue()}</span>
{/if}
</button>
{#if open}
<div
id={panelId}
class="absolute left-0 z-10 mt-1 min-w-[12rem] rounded-sm border border-line bg-surface py-1 shadow-md"
>
<p id={headingId} class="px-4 py-1.5 text-sm font-bold text-ink">{heading}</p>
<ul bind:this={listEl} aria-labelledby={headingId}>
{#each persons as person (person.id)}
<li>
<button
type="button"
aria-label={m.search_disambiguation_select_label({ name: person.displayName })}
onclick={() => select(person)}
class="flex min-h-[44px] w-full items-center px-4 text-left text-sm text-ink outline-none hover:bg-muted focus-visible:bg-muted focus-visible:ring-2 focus-visible:ring-brand-navy"
>
{person.displayName}
</button>
</li>
{/each}
</ul>
</div>
{/if}
</div>

View File

@@ -1,118 +0,0 @@
import { describe, expect, it, vi, afterEach } from 'vitest';
import { cleanup, render } from 'vitest-browser-svelte';
import { page } from 'vitest/browser';
import DisambiguationPicker from './DisambiguationPicker.svelte';
import type { components } from '$lib/generated/api';
type PersonHint = components['schemas']['PersonHint'];
afterEach(() => cleanup());
const persons: PersonHint[] = [
{ id: 'w1', displayName: 'Walter Raddatz' },
{ id: 'w2', displayName: 'Walter Müller' }
];
const multiProps = { persons, heading: 'Person auswählen', showCue: true };
function pressEscape() {
(document.activeElement as HTMLElement).dispatchEvent(
new KeyboardEvent('keydown', { key: 'Escape', bubbles: true })
);
}
describe('DisambiguationPicker', () => {
it('opens the picker and shows a select option per ambiguous person', async () => {
render(DisambiguationPicker, { ...multiProps, onSelect: vi.fn() });
await page.getByRole('button', { name: /Mehrere Personen gefunden/ }).click();
await expect
.element(page.getByRole('button', { name: 'Walter Raddatz auswählen' }))
.toBeInTheDocument();
await expect
.element(page.getByRole('button', { name: 'Walter Müller auswählen' }))
.toBeInTheDocument();
});
it('moves focus into the picker list on open', async () => {
render(DisambiguationPicker, { ...multiProps, onSelect: vi.fn() });
await page.getByRole('button', { name: /Mehrere Personen gefunden/ }).click();
await expect
.element(page.getByRole('button', { name: 'Walter Raddatz auswählen' }))
.toHaveFocus();
});
it('returns focus to the trigger when closed with Escape', async () => {
render(DisambiguationPicker, { ...multiProps, onSelect: vi.fn() });
const trigger = page.getByRole('button', { name: /Mehrere Personen gefunden/ });
await trigger.click();
await expect
.element(page.getByRole('button', { name: 'Walter Raddatz auswählen' }))
.toHaveFocus();
pressEscape();
await expect.element(trigger).toHaveFocus();
});
it('does not call onSelect when dismissed without choosing', async () => {
const onSelect = vi.fn();
render(DisambiguationPicker, { ...multiProps, onSelect });
await page.getByRole('button', { name: /Mehrere Personen gefunden/ }).click();
await expect
.element(page.getByRole('button', { name: 'Walter Raddatz auswählen' }))
.toHaveFocus();
pressEscape();
expect(onSelect).not.toHaveBeenCalled();
});
it('calls onSelect with the chosen person', async () => {
const onSelect = vi.fn();
render(DisambiguationPicker, { ...multiProps, onSelect });
await page.getByRole('button', { name: /Mehrere Personen gefunden/ }).click();
await page.getByRole('button', { name: 'Walter Müller auswählen' }).click();
expect(onSelect).toHaveBeenCalledWith(persons[1]);
});
it('renders the supplied heading as a visible panel heading', async () => {
render(DisambiguationPicker, {
persons: [{ id: 'c1', displayName: 'Clara Cramer' }],
heading: 'Meintest du Clara Cramer?',
showCue: false,
onSelect: vi.fn()
});
await page.getByRole('button', { name: 'Meintest du Clara Cramer?' }).click();
await expect.element(page.getByText('Meintest du Clara Cramer?')).toBeVisible();
});
it('suppresses the cue when showCue is false', async () => {
render(DisambiguationPicker, {
persons: [{ id: 'c1', displayName: 'Clara Cramer' }],
heading: 'Meintest du Clara Cramer?',
showCue: false,
onSelect: vi.fn()
});
await expect.element(page.getByText('(auswählen…)')).not.toBeInTheDocument();
});
it('shows the cue when showCue is true', async () => {
render(DisambiguationPicker, { ...multiProps, onSelect: vi.fn() });
await expect.element(page.getByText('(auswählen…)')).toBeVisible();
});
it('announces the did-you-mean heading as the trigger accessible name for a single suggestion', async () => {
render(DisambiguationPicker, {
persons: [{ id: 'c1', displayName: 'Clara Cramer' }],
heading: 'Meintest du Clara Cramer?',
showCue: false,
onSelect: vi.fn()
});
await expect
.element(page.getByRole('button', { name: 'Meintest du Clara Cramer?' }))
.toBeInTheDocument();
});
it('keeps the multiple-people trigger accessible name for two or more suggestions', async () => {
render(DisambiguationPicker, { ...multiProps, onSelect: vi.fn() });
await expect
.element(page.getByRole('button', { name: /Mehrere Personen gefunden/ }))
.toBeInTheDocument();
});
});

View File

@@ -1,181 +0,0 @@
<script lang="ts">
import { SvelteSet } from 'svelte/reactivity';
import { m } from '$lib/paraglide/messages.js';
import type { components } from '$lib/generated/api';
import type { ChipType } from './chip-types.js';
type NlQueryInterpretation = components['schemas']['NlQueryInterpretation'];
type TagHint = components['schemas']['TagHint'];
let {
interpretation,
onRemoveChip
}: {
interpretation: NlQueryInterpretation;
onRemoveChip: (type: ChipType, value?: string) => void;
} = $props();
type Chip =
| { key: string; type: 'sender'; label: string }
| { key: string; type: 'directional'; from: string; to: string }
| { key: string; type: 'date'; label: string }
| { key: string; type: 'keyword'; value: string; label: string }
| { key: string; type: 'theme'; tag: TagHint; label: string };
// Locally removed chips. The parent remounts this component (via {#key}) on every
// new NL search, so this set never needs an explicit reset.
const removed = new SvelteSet<string>();
function yearOf(iso: string | undefined): string | undefined {
return iso?.slice(0, 4);
}
function dateRangeLabel(from: string | undefined, to: string | undefined): string {
const fromYear = yearOf(from);
const toYear = yearOf(to);
if (fromYear && toYear) return fromYear === toYear ? fromYear : `${fromYear}${toYear}`;
return fromYear ?? toYear ?? '';
}
function tagColorStyle(color: string | undefined): string | undefined {
if (!color) return undefined;
return `background-color: var(--c-tag-${color}); border-left-color: var(--c-tag-${color})`;
}
const chips = $derived.by(() => {
const list: Chip[] = [];
const {
resolvedPersons,
dateFrom,
dateTo,
keywords,
keywordsApplied,
resolvedTags,
tagsApplied
} = interpretation;
if (resolvedPersons.length >= 2) {
list.push({
key: 'directional',
type: 'directional',
from: resolvedPersons[0].displayName,
to: resolvedPersons[1].displayName
});
} else if (resolvedPersons.length === 1) {
list.push({
key: 'sender:' + resolvedPersons[0].id,
type: 'sender',
label: `${m.search_chip_sender()}: ${resolvedPersons[0].displayName}`
});
}
if (dateFrom || dateTo) {
list.push({
key: 'date',
type: 'date',
label: `${m.search_chip_date()}: ${dateRangeLabel(dateFrom, dateTo)}`
});
}
if (keywordsApplied) {
for (const keyword of keywords) {
list.push({
key: 'keyword:' + keyword,
type: 'keyword',
value: keyword,
label: `${m.search_chip_keyword()}: ${keyword}`
});
}
}
if (tagsApplied) {
for (const tag of resolvedTags) {
list.push({
key: 'theme:' + tag.id,
type: 'theme',
tag,
label: `${m.search_chip_theme_prefix()}: ${tag.name}`
});
}
}
return list.filter((chip) => !removed.has(chip.key));
});
const showKeywordsNotApplied = $derived(
!interpretation.keywordsApplied && interpretation.keywords.length > 0
);
function remove(chip: Chip) {
removed.add(chip.key);
if (chip.type === 'keyword') {
onRemoveChip(chip.type, chip.value);
} else if (chip.type === 'theme') {
onRemoveChip(chip.type, chip.tag.name);
} else {
onRemoveChip(chip.type, undefined);
}
}
const nameSpan = 'sm:max-w-[12rem] max-w-[8rem] truncate';
const chipWrapper =
'inline-flex items-center gap-1.5 rounded-full border border-line bg-muted px-3 text-sm text-ink-2 focus-within:ring-2 focus-within:ring-brand-navy';
const removeButton =
'flex min-h-[44px] w-6 shrink-0 items-center justify-center text-ink-3 outline-none hover:text-red-500 focus-visible:ring-2 focus-visible:ring-brand-navy';
</script>
<div class="flex flex-wrap gap-2">
{#each chips as chip (chip.key)}
{#if chip.type === 'directional'}
<span
data-chip-type="directional"
class={chipWrapper}
aria-label={m.search_chip_directional_label({ from: chip.from, to: chip.to })}
>
<span class={nameSpan}>{chip.from}</span>
<span aria-hidden="true"></span>
<span class={nameSpan}>{chip.to}</span>
<button
type="button"
class={removeButton}
aria-label={m.search_filter_remove_label({ label: `${chip.from} ${chip.to}` })}
onclick={() => remove(chip)}
>
<span aria-hidden="true">×</span>
</button>
</span>
{:else if chip.type === 'theme'}
<span data-chip-type="theme" class={chipWrapper} style={tagColorStyle(chip.tag.color)}>
<span>{m.search_chip_theme_prefix()}:</span>
<span class={nameSpan}>{chip.tag.name}</span>
<button
type="button"
class={removeButton}
aria-label={m.search_filter_remove_label({
label: `${m.search_chip_theme_prefix()}: ${chip.tag.name}`
})}
onclick={() => remove(chip)}
>
<span aria-hidden="true">×</span>
</button>
</span>
{:else}
<span data-chip-type={chip.type} class={chipWrapper}>
<span class={nameSpan}>{chip.label}</span>
<button
type="button"
class={removeButton}
aria-label={m.search_filter_remove_label({ label: chip.label })}
onclick={() => remove(chip)}
>
<span aria-hidden="true">×</span>
</button>
</span>
{/if}
{/each}
</div>
{#if showKeywordsNotApplied}
<p class="mt-2 text-xs text-ink-3">{m.smart_search_keywords_not_applied()}</p>
{/if}

View File

@@ -1,214 +0,0 @@
// 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: 19141918')).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();
});
});

View File

@@ -1,38 +0,0 @@
<script lang="ts">
import { m } from '$lib/paraglide/messages.js';
let { smartMode = $bindable(false), onToggle }: { smartMode?: boolean; onToggle?: () => void } =
$props();
const label = $derived(smartMode ? m.search_toggle_smart_label() : m.search_toggle_keyword_label());
const labelSuffix = $derived(
smartMode ? m.search_toggle_smart_label_suffix() : m.search_toggle_keyword_label_suffix()
);
function toggle() {
smartMode = !smartMode;
onToggle?.();
}
</script>
<button
type="button"
aria-pressed={smartMode}
onclick={toggle}
class="pointer-events-auto absolute top-1/2 right-2 flex -translate-y-1/2 cursor-pointer items-center gap-1.5 rounded-full px-2.5 py-1 text-xs font-bold uppercase outline-none focus-visible:ring-2 focus-visible:ring-brand-navy {smartMode
? 'border border-primary bg-primary text-primary-fg'
: 'border border-line bg-muted text-ink-2'}"
>
<svg
aria-hidden="true"
viewBox="0 0 24 24"
fill="currentColor"
class="h-3.5 w-3.5"
xmlns="http://www.w3.org/2000/svg"
>
<path d="M12 2l2.09 6.26L20 10l-5.91 1.74L12 18l-2.09-6.26L4 10l5.91-1.74L12 2z" />
</svg>
<span>
{label}<span class="sm:hidden">{labelSuffix}</span>
</span>
</button>

View File

@@ -1,81 +0,0 @@
import { describe, expect, it, vi, afterEach } from 'vitest';
import { cleanup, render } from 'vitest-browser-svelte';
import { page } from 'vitest/browser';
import SmartModeToggle from './SmartModeToggle.svelte';
import SearchFilterBar from '../SearchFilterBar.svelte';
afterEach(() => cleanup());
const SEARCH_PLACEHOLDER = 'Titel, Personen, Tags durchsuchen…';
describe('SmartModeToggle', () => {
it('renders aria-pressed="false" by default and toggles on click', async () => {
render(SmartModeToggle, { smartMode: false });
const btn = page.getByRole('button');
await expect.element(btn).toHaveAttribute('aria-pressed', 'false');
await btn.click();
await expect.element(btn).toHaveAttribute('aria-pressed', 'true');
await btn.click();
await expect.element(btn).toHaveAttribute('aria-pressed', 'false');
});
it('shows the smart label when smartMode is true', async () => {
render(SmartModeToggle, { smartMode: true });
const btn = page.getByRole('button');
await expect.element(btn).toHaveTextContent('Smart');
});
it('shows the keyword label when smartMode is false', async () => {
render(SmartModeToggle, { smartMode: false });
const btn = page.getByRole('button');
await expect.element(btn).toHaveTextContent('Text');
});
it('applies the active pill style only in smart mode', async () => {
render(SmartModeToggle, { smartMode: true });
const btn = page.getByRole('button');
await expect.element(btn).toHaveClass(/bg-primary/);
});
});
describe('SmartModeToggle inside SearchFilterBar', () => {
it('adds maxlength="500" to the search input only in smart mode', async () => {
render(SearchFilterBar, { onSearch: vi.fn(), sort: 'DATE', dir: 'desc', smartMode: true });
await expect
.element(page.getByPlaceholder(SEARCH_PLACEHOLDER))
.toHaveAttribute('maxlength', '500');
});
it('omits maxlength from the search input in keyword mode', async () => {
render(SearchFilterBar, { onSearch: vi.fn(), sort: 'DATE', dir: 'desc', smartMode: false });
await expect
.element(page.getByPlaceholder(SEARCH_PLACEHOLDER))
.not.toHaveAttribute('maxlength');
});
it('does not fire the keyword search on input while in smart mode', async () => {
const onSearch = vi.fn();
render(SearchFilterBar, { onSearch, sort: 'DATE', dir: 'desc', smartMode: true });
await page.getByPlaceholder(SEARCH_PLACEHOLDER).fill('Walter im Krieg');
expect(onSearch).not.toHaveBeenCalled();
});
it('fires the smart search callback on Enter in smart mode', async () => {
const onSmartSearch = vi.fn();
render(SearchFilterBar, {
onSearch: vi.fn(),
onSmartSearch,
sort: 'DATE',
dir: 'desc',
smartMode: true
});
const input = page.getByPlaceholder(SEARCH_PLACEHOLDER);
await input.fill('Walter im Krieg');
await input.click();
// Enter submits the NL query in smart mode
(document.activeElement as HTMLElement).dispatchEvent(
new KeyboardEvent('keydown', { key: 'Enter', bubbles: true })
);
await vi.waitFor(() => expect(onSmartSearch).toHaveBeenCalled());
});
});

View File

@@ -1,69 +0,0 @@
<script lang="ts">
import { m } from '$lib/paraglide/messages.js';
type SmartSearchErrorCode = 'SMART_SEARCH_UNAVAILABLE' | 'SMART_SEARCH_RATE_LIMITED';
let {
status,
errorCode,
onSwitchToKeyword
}: {
status: 'loading' | 'error';
errorCode?: SmartSearchErrorCode;
onSwitchToKeyword?: () => void;
} = $props();
const isRateLimited = $derived(errorCode === 'SMART_SEARCH_RATE_LIMITED');
const title = $derived(
isRateLimited ? m.search_error_rate_limited() : m.search_error_unavailable()
);
const body = $derived(
isRateLimited ? m.search_error_rate_limited_body() : m.search_error_unavailable_body()
);
</script>
{#if status === 'loading'}
<div
role="status"
aria-live="polite"
class="flex flex-col items-center justify-center gap-3 py-16 text-center"
>
<div
aria-hidden="true"
class="h-9 w-9 rounded-full border-[3px] border-primary/12 border-t-primary motion-safe:animate-spin"
></div>
<p class="text-sm font-bold text-ink">{m.search_loading_nl()}</p>
<p class="max-w-xs text-xs text-ink-3 motion-safe:animate-pulse">
{m.search_loading_nl_sub()}
</p>
</div>
{:else if status === 'error'}
<div role="alert" class="flex flex-col items-center justify-center gap-3 py-16 text-center">
<div
aria-hidden="true"
class="flex h-10 w-10 items-center justify-center rounded-full border-2 text-lg font-bold {isRateLimited
? 'border-amber-400 bg-amber-50 text-amber-600'
: 'border-red-400 bg-red-50 text-red-600'}"
>
{#if isRateLimited}
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" class="h-5 w-5">
<circle cx="12" cy="12" r="9" />
<path d="M12 7v5l3 2" stroke-linecap="round" stroke-linejoin="round" />
</svg>
{:else}
<span>!</span>
{/if}
</div>
<p class="text-sm font-bold text-ink">{title}</p>
<p class="max-w-xs text-xs text-ink-3">{body}</p>
{#if !isRateLimited}
<button
type="button"
onclick={onSwitchToKeyword}
class="mt-2 inline-flex min-h-[44px] items-center rounded border border-primary bg-primary px-4 py-2 text-sm font-bold text-primary-fg outline-none focus-visible:ring-2 focus-visible:ring-brand-navy"
>
{m.search_switch_to_keyword()}
</button>
{/if}
</div>
{/if}

View File

@@ -1,60 +0,0 @@
import { describe, expect, it, vi, afterEach } from 'vitest';
import { cleanup, render } from 'vitest-browser-svelte';
import { page } from 'vitest/browser';
import SmartSearchStatus from './SmartSearchStatus.svelte';
afterEach(() => {
cleanup();
vi.restoreAllMocks();
});
describe('SmartSearchStatus', () => {
it('renders a role="status" loading panel with the loading title', async () => {
render(SmartSearchStatus, { status: 'loading' });
const status = page.getByRole('status');
await expect.element(status).toBeInTheDocument();
await expect.element(status).toHaveTextContent('Archiv wird befragt');
});
it('hides the loading panel once the status changes away from loading', async () => {
const { rerender } = render(SmartSearchStatus, { status: 'loading' });
await expect.element(page.getByRole('status')).toBeInTheDocument();
await rerender({ status: 'error', errorCode: 'SMART_SEARCH_UNAVAILABLE' });
await expect.element(page.getByRole('status')).not.toBeInTheDocument();
});
it('renders the 503 panel with title, body and a switch-to-keyword button', async () => {
render(SmartSearchStatus, {
status: 'error',
errorCode: 'SMART_SEARCH_UNAVAILABLE',
onSwitchToKeyword: vi.fn()
});
await expect.element(page.getByText('Intelligente Suche nicht verfügbar')).toBeInTheDocument();
await expect
.element(page.getByRole('button', { name: /Volltextsuche wechseln/ }))
.toBeInTheDocument();
});
it('invokes onSwitchToKeyword when the 503 fallback button is clicked', async () => {
const onSwitchToKeyword = vi.fn();
render(SmartSearchStatus, {
status: 'error',
errorCode: 'SMART_SEARCH_UNAVAILABLE',
onSwitchToKeyword
});
await page.getByRole('button', { name: /Volltextsuche wechseln/ }).click();
expect(onSwitchToKeyword).toHaveBeenCalledOnce();
});
it('renders the 429 panel with title and body but no switch-to-keyword button', async () => {
render(SmartSearchStatus, {
status: 'error',
errorCode: 'SMART_SEARCH_RATE_LIMITED',
onSwitchToKeyword: vi.fn()
});
await expect.element(page.getByText('Zu viele Anfragen')).toBeInTheDocument();
await expect
.element(page.getByRole('button', { name: /Volltextsuche wechseln/ }))
.not.toBeInTheDocument();
});
});

View File

@@ -1 +0,0 @@
export type ChipType = 'sender' | 'directional' | 'date' | 'keyword' | 'theme';