Three root causes prevented filters from reflecting the URL after SvelteKit client-side navigation: 1. +page.server.ts now resolves sender/receiver display names in parallel with the document search (UUID validation + silent 404 drop), so initialSenderName / initialReceiverName land in server data ready for the UI to use. 2. +page.svelte passes initialSenderName, initialReceiverName, and navKey (incremented via untrack on every navigation) down to SearchFilterBar. The untrack() prevents the effect from re-running due to its own navKey write. 3. SearchFilterBar forwards navKey as resetKey to each PersonTypeahead, which already had a void resetKey guard added in the previous commit. Together these ensure that after navigating to /documents?senderId=<uuid> the typeahead shows the person's display name, and clicking × reset clears it. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
237 lines
7.2 KiB
TypeScript
237 lines
7.2 KiB
TypeScript
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
|
import { cleanup, render } from 'vitest-browser-svelte';
|
|
import { page } from 'vitest/browser';
|
|
|
|
vi.mock('$app/navigation', () => ({ goto: vi.fn() }));
|
|
vi.mock('$app/state', () => ({ navigating: { to: null } }));
|
|
|
|
import Page from './+page.svelte';
|
|
|
|
afterEach(() => {
|
|
cleanup();
|
|
vi.useRealTimers();
|
|
});
|
|
|
|
const SEARCH_LABEL = 'Titel, Personen, Tags durchsuchen…';
|
|
|
|
function makeData(overrides: Record<string, unknown> = {}) {
|
|
return {
|
|
items: [],
|
|
total: 0,
|
|
q: '',
|
|
from: '',
|
|
to: '',
|
|
senderId: '',
|
|
receiverId: '',
|
|
initialSenderName: '',
|
|
initialReceiverName: '',
|
|
tags: [],
|
|
sort: 'DATE',
|
|
dir: 'desc',
|
|
tagQ: '',
|
|
tagOp: 'AND',
|
|
canWrite: false,
|
|
error: null,
|
|
...overrides
|
|
};
|
|
}
|
|
|
|
// ─── Initial state from server data ───────────────────────────────────────────
|
|
|
|
describe('documents page — initial state', () => {
|
|
it('pre-fills the search input from data.q', async () => {
|
|
render(Page, { data: makeData({ q: 'Geburtstag' }) });
|
|
await expect
|
|
.element(page.getByRole('textbox', { name: SEARCH_LABEL }))
|
|
.toHaveValue('Geburtstag');
|
|
});
|
|
|
|
it('leaves the search input empty when data.q is not set', async () => {
|
|
render(Page, { data: makeData() });
|
|
await expect.element(page.getByRole('textbox', { name: SEARCH_LABEL })).toHaveValue('');
|
|
});
|
|
});
|
|
|
|
// ─── URL building via triggerSearch ───────────────────────────────────────────
|
|
|
|
describe('documents page — URL building', () => {
|
|
beforeEach(() => vi.useFakeTimers());
|
|
|
|
it('calls goto with /documents?q=… after the 500 ms debounce', async () => {
|
|
const { goto } = await import('$app/navigation');
|
|
vi.mocked(goto).mockClear();
|
|
|
|
render(Page, { data: makeData() });
|
|
|
|
const input = page.getByRole('textbox', { name: SEARCH_LABEL });
|
|
await input.fill('Urlaub');
|
|
|
|
expect(goto).not.toHaveBeenCalled();
|
|
|
|
vi.advanceTimersByTime(500);
|
|
|
|
expect(goto).toHaveBeenCalledOnce();
|
|
const [url] = vi.mocked(goto).mock.calls[0];
|
|
expect(url).toContain('q=Urlaub');
|
|
expect(url).toMatch(/^\/documents\?/);
|
|
});
|
|
|
|
it('omits q from the URL when the search field is empty', async () => {
|
|
const { goto } = await import('$app/navigation');
|
|
vi.mocked(goto).mockClear();
|
|
|
|
render(Page, { data: makeData() });
|
|
|
|
const input = page.getByRole('textbox', { name: SEARCH_LABEL });
|
|
await input.fill('');
|
|
|
|
vi.advanceTimersByTime(500);
|
|
|
|
const [url] = vi.mocked(goto).mock.calls[0] ?? [''];
|
|
expect(url).not.toContain('q=');
|
|
});
|
|
|
|
it('second keystroke within 500 ms cancels the first timer — goto called only once', async () => {
|
|
const { goto } = await import('$app/navigation');
|
|
vi.mocked(goto).mockClear();
|
|
|
|
render(Page, { data: makeData() });
|
|
|
|
const input = page.getByRole('textbox', { name: SEARCH_LABEL });
|
|
await input.fill('U');
|
|
vi.advanceTimersByTime(200);
|
|
await input.fill('Urlaub');
|
|
vi.advanceTimersByTime(500);
|
|
|
|
expect(goto).toHaveBeenCalledOnce();
|
|
});
|
|
|
|
it('passes keepFocus and noScroll options to goto', async () => {
|
|
const { goto } = await import('$app/navigation');
|
|
vi.mocked(goto).mockClear();
|
|
|
|
render(Page, { data: makeData() });
|
|
const input = page.getByRole('textbox', { name: SEARCH_LABEL });
|
|
await input.fill('Brief');
|
|
vi.advanceTimersByTime(500);
|
|
|
|
expect(goto).toHaveBeenCalledWith(
|
|
expect.any(String),
|
|
expect.objectContaining({ keepFocus: true, noScroll: true })
|
|
);
|
|
});
|
|
|
|
it('filter change does not carry the current page — goto URL drops page param', async () => {
|
|
const { goto } = await import('$app/navigation');
|
|
vi.mocked(goto).mockClear();
|
|
|
|
// User is mid-way through results at page 5; change the search text.
|
|
render(Page, { data: makeData({ q: 'old', pageNumber: 5 }) });
|
|
|
|
const input = page.getByRole('textbox', { name: SEARCH_LABEL });
|
|
await input.fill('Brief');
|
|
vi.advanceTimersByTime(500);
|
|
|
|
const [url] = vi.mocked(goto).mock.calls[0];
|
|
expect(url).toContain('q=Brief');
|
|
expect(url).not.toContain('page=');
|
|
});
|
|
});
|
|
|
|
// ─── Sender / receiver name display ──────────────────────────────────────────
|
|
|
|
describe('documents page — sender/receiver display', () => {
|
|
it('pre-fills sender typeahead from initialSenderName when senderId filter is active', async () => {
|
|
render(Page, {
|
|
data: makeData({
|
|
senderId: '11111111-1111-1111-1111-111111111111',
|
|
initialSenderName: 'Max Mustermann'
|
|
})
|
|
});
|
|
// Advanced filters are auto-shown when senderId is set
|
|
const inputs = page.getByPlaceholder('Namen tippen...');
|
|
await expect.element(inputs.first()).toHaveValue('Max Mustermann');
|
|
});
|
|
});
|
|
|
|
// ─── Timeline density widget wiring (#385) ────────────────────────────────────
|
|
|
|
describe('documents page — timeline density widget', () => {
|
|
it('renders the timeline widget when density data is present', async () => {
|
|
render(Page, {
|
|
data: makeData({
|
|
density: [{ month: '1915-08', count: 3 }],
|
|
minDate: '1915-08-01',
|
|
maxDate: '1915-08-31'
|
|
})
|
|
});
|
|
|
|
await expect.element(page.getByTestId('timeline-density-filter')).toBeInTheDocument();
|
|
});
|
|
|
|
it('hides the timeline widget when density is null (mobile / calendar view)', async () => {
|
|
render(Page, { data: makeData({ density: null, minDate: null, maxDate: null }) });
|
|
expect(document.querySelector('[data-testid="timeline-density-filter"]')).toBeNull();
|
|
});
|
|
|
|
it('clicking a timeline bar navigates with from/to set to that month boundary', async () => {
|
|
const { goto } = await import('$app/navigation');
|
|
vi.mocked(goto).mockClear();
|
|
|
|
render(Page, {
|
|
data: makeData({
|
|
density: [{ month: '1915-08', count: 3 }],
|
|
minDate: '1915-08-01',
|
|
maxDate: '1915-08-31'
|
|
})
|
|
});
|
|
|
|
const bar = document.querySelector('[data-testid="timeline-bar"]') as HTMLButtonElement;
|
|
bar.dispatchEvent(new MouseEvent('click', { bubbles: true }));
|
|
|
|
expect(goto).toHaveBeenCalledOnce();
|
|
const [url] = vi.mocked(goto).mock.calls[0];
|
|
expect(url).toContain('from=1915-08-01');
|
|
expect(url).toContain('to=1915-08-31');
|
|
});
|
|
|
|
it('the standalone zoom-in button no longer exists (drag replaces it)', async () => {
|
|
render(Page, {
|
|
data: makeData({
|
|
density: [{ month: '1915-08', count: 3 }],
|
|
minDate: '1915-08-01',
|
|
maxDate: '1915-08-31',
|
|
from: '1915-08-01',
|
|
to: '1915-08-31'
|
|
})
|
|
});
|
|
|
|
expect(document.querySelector('[data-testid="timeline-zoom-in"]')).toBeNull();
|
|
});
|
|
|
|
it('clicking reset-zoom drops zoomFrom/zoomTo from the URL', async () => {
|
|
const { goto } = await import('$app/navigation');
|
|
vi.mocked(goto).mockClear();
|
|
|
|
render(Page, {
|
|
data: makeData({
|
|
density: [{ month: '1915-08', count: 3 }],
|
|
minDate: '1915-08-01',
|
|
maxDate: '1915-08-31',
|
|
zoomFrom: '1915-08-01',
|
|
zoomTo: '1915-08-31'
|
|
})
|
|
});
|
|
|
|
const resetBtn = document.querySelector(
|
|
'[data-testid="timeline-zoom-reset"]'
|
|
) as HTMLButtonElement;
|
|
resetBtn.dispatchEvent(new MouseEvent('click', { bubbles: true }));
|
|
|
|
expect(goto).toHaveBeenCalledOnce();
|
|
const [url] = vi.mocked(goto).mock.calls[0];
|
|
expect(url).not.toContain('zoomFrom=');
|
|
expect(url).not.toContain('zoomTo=');
|
|
});
|
|
});
|