fix(documents): address review cycle 2 — a11y, CSS injection, debounce tests
Some checks failed
CI / Unit & Component Tests (pull_request) Failing after 2m33s
CI / OCR Service Tests (pull_request) Successful in 38s
CI / Unit & Component Tests (push) Failing after 2m40s
CI / OCR Service Tests (push) Successful in 36s
CI / Backend Unit Tests (push) Failing after 2m54s
CI / Backend Unit Tests (pull_request) Failing after 2m58s
Some checks failed
CI / Unit & Component Tests (pull_request) Failing after 2m33s
CI / OCR Service Tests (pull_request) Successful in 38s
CI / Unit & Component Tests (push) Failing after 2m40s
CI / OCR Service Tests (push) Successful in 36s
CI / Backend Unit Tests (push) Failing after 2m54s
CI / Backend Unit Tests (pull_request) Failing after 2m58s
- ContributorStack: text-xs for WCAG 1.4.4 (was text-[10px]), safeColor()
validation to block CSS injection via actor.color, role="img" aria-label
on empty placeholder, {#each} keyed by index
- ContributorStack spec: update empty-state assertion to getByRole('img')
- DocumentRow spec: add stopPropagation regression test for tag click
- documents/page.svelte.spec.ts: new — debounce, URL building, initial state
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -11,21 +11,26 @@ interface Props {
|
||||
let { contributors, hasMore }: Props = $props();
|
||||
|
||||
const safeContributors = $derived(contributors ?? []);
|
||||
|
||||
function safeColor(color: string): string {
|
||||
return /^#[0-9a-fA-F]{6}$/.test(color) ? color : '#8c9aa3';
|
||||
}
|
||||
</script>
|
||||
|
||||
{#if safeContributors.length === 0}
|
||||
<span
|
||||
role="img"
|
||||
aria-label="Noch niemand angefangen"
|
||||
class="inline-block h-[22px] w-[22px] flex-shrink-0 rounded-full border-[1.5px] border-dashed border-[#cdcbbf]"
|
||||
title="Noch niemand angefangen"
|
||||
></span>
|
||||
{:else}
|
||||
<span class="inline-flex items-center">
|
||||
{#each safeContributors as actor, i (actor.initials + '-' + actor.color)}
|
||||
{#each safeContributors as actor, i (i)}
|
||||
<span
|
||||
role="img"
|
||||
aria-label={actor.name ?? actor.initials}
|
||||
class="inline-flex h-[22px] w-[22px] flex-shrink-0 items-center justify-center rounded-full font-sans text-[10px] font-bold text-white ring-2 ring-white {i > 0 ? '-ml-1.5' : ''}"
|
||||
style="background-color: {actor.color || '#8c9aa3'};"
|
||||
class="inline-flex h-[22px] w-[22px] flex-shrink-0 items-center justify-center rounded-full font-sans text-xs font-bold text-white ring-2 ring-white {i > 0 ? '-ml-1.5' : ''}"
|
||||
style="background-color: {safeColor(actor.color)};"
|
||||
title={actor.name ?? actor.initials}
|
||||
>
|
||||
{actor.initials}
|
||||
@@ -33,7 +38,7 @@ const safeContributors = $derived(contributors ?? []);
|
||||
{/each}
|
||||
{#if hasMore}
|
||||
<span
|
||||
class="-ml-1.5 inline-flex h-[22px] w-[22px] flex-shrink-0 items-center justify-center rounded-full bg-[#e4e2d7] font-sans text-[10px] font-bold text-ink-3 ring-2 ring-white"
|
||||
class="-ml-1.5 inline-flex h-[22px] w-[22px] flex-shrink-0 items-center justify-center rounded-full bg-[#e4e2d7] font-sans text-xs font-bold text-ink-3 ring-2 ring-white"
|
||||
>
|
||||
…
|
||||
</span>
|
||||
|
||||
@@ -45,6 +45,8 @@ describe('ContributorStack', () => {
|
||||
|
||||
it('renders empty placeholder when no contributors', async () => {
|
||||
render(ContributorStack, { contributors: [], hasMore: false });
|
||||
await expect.element(page.getByTitle('Noch niemand angefangen')).toBeInTheDocument();
|
||||
await expect
|
||||
.element(page.getByRole('img', { name: 'Noch niemand angefangen' }))
|
||||
.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -145,6 +145,19 @@ describe('DocumentRow – tags', () => {
|
||||
await page.getByRole('button', { name: 'Urlaub & Reise' }).click();
|
||||
expect(goto).toHaveBeenCalledWith('/documents?tag=Urlaub%20%26%20Reise');
|
||||
});
|
||||
|
||||
it('tag click does not navigate to the document detail page', async () => {
|
||||
const item = makeItem({
|
||||
document: {
|
||||
...makeItem().document,
|
||||
tags: [{ id: 't2', name: 'Familie', color: null, parentId: null }]
|
||||
}
|
||||
});
|
||||
render(DocumentRow, { item });
|
||||
const before = window.location.href;
|
||||
await page.getByRole('button', { name: 'Familie' }).click();
|
||||
expect(window.location.href).toBe(before);
|
||||
});
|
||||
});
|
||||
|
||||
// ─── ProgressRing & ContributorStack ─────────────────────────────────────────
|
||||
|
||||
121
frontend/src/routes/documents/page.svelte.spec.ts
Normal file
121
frontend/src/routes/documents/page.svelte.spec.ts
Normal file
@@ -0,0 +1,121 @@
|
||||
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: '',
|
||||
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 })
|
||||
);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user