Files
familienarchiv/frontend/src/lib/geschichte/JourneyItemCard.svelte.spec.ts
Marcel 3572de487a test(journeyitemcard): use getBoundingClientRect for 44px touch-target assertion
CSS class string assertion was fragile — class names can change without
breaking the actual layout. DOM measurement via getBoundingClientRect is the
correct way to verify computed height meets WCAG 2.2 minimum.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-08 23:25:12 +02:00

125 lines
3.6 KiB
TypeScript

import { describe, it, expect, afterEach } from 'vitest';
import { cleanup, render } from 'vitest-browser-svelte';
import { page } from 'vitest/browser';
import type { components } from '$lib/generated/api';
const { default: JourneyItemCard } = await import('./JourneyItemCard.svelte');
afterEach(cleanup);
declare global {
interface Window {
__xss_note?: number;
}
}
type JourneyItemView = components['schemas']['JourneyItemView'];
const baseItem = (overrides: Partial<JourneyItemView> = {}): JourneyItemView => ({
id: 'item1',
position: 0,
document: {
id: 'd1',
title: 'Brief an Helene',
documentDate: '1923-05-15',
datePrecision: 'FULL'
},
...overrides
});
describe('JourneyItemCard', () => {
it('renders the document title', async () => {
render(JourneyItemCard, { props: { item: baseItem() } });
await expect.element(page.getByText('Brief an Helene')).toBeVisible();
});
it('renders the document date when documentDate is present', async () => {
render(JourneyItemCard, { props: { item: baseItem() } });
await expect.element(page.getByText(/1923/)).toBeVisible();
});
it('whole card is a single <a> element', async () => {
render(JourneyItemCard, { props: { item: baseItem() } });
const link = document.querySelector('a');
expect(link).not.toBeNull();
expect(link?.href).toContain('/documents/d1');
});
it('link has dated aria-label when documentDate is present', async () => {
render(JourneyItemCard, { props: { item: baseItem() } });
const link = document.querySelector('a');
expect(link?.getAttribute('aria-label')).toContain('Brief');
expect(link?.getAttribute('aria-label')).toContain('1923');
});
it('link has undated aria-label when documentDate is absent', async () => {
render(JourneyItemCard, {
props: {
item: baseItem({
document: { id: 'd2', title: 'Ohne Datum', datePrecision: 'NONE' }
})
}
});
const link = document.querySelector('a');
expect(link?.getAttribute('aria-label')).toBe('Brief öffnen');
});
it('omits date text when documentDate is absent', async () => {
render(JourneyItemCard, {
props: {
item: baseItem({
document: { id: 'd2', title: 'Ohne Datum', datePrecision: 'NONE' }
})
}
});
await expect.element(page.getByText(/1923/)).not.toBeInTheDocument();
});
it('renders ✎ glyph and note text when note is present', async () => {
render(JourneyItemCard, { props: { item: baseItem({ note: 'Ein wichtiger Brief' }) } });
expect(document.body.textContent).toContain('✎');
await expect.element(page.getByText('Ein wichtiger Brief')).toBeVisible();
});
it('omits annotation block when note is blank or whitespace', async () => {
render(JourneyItemCard, { props: { item: baseItem({ note: ' ' }) } });
expect(document.body.textContent).not.toContain('✎');
});
it('omits annotation block when note is absent', async () => {
render(JourneyItemCard, { props: { item: baseItem({ note: undefined }) } });
expect(document.body.textContent).not.toContain('✎');
});
it('link meets 44px touch-target minimum height', async () => {
render(JourneyItemCard, { props: { item: baseItem() } });
const link = document.querySelector('a');
const rect = link?.getBoundingClientRect();
expect(rect?.height).toBeGreaterThanOrEqual(44);
});
it('XSS: note is rendered as plaintext — injected payload does not execute', async () => {
// Note uses Svelte text interpolation ({note}), NOT {@html}.
render(JourneyItemCard, {
props: {
item: baseItem({
note: '<img src=x onerror="window.__xss_note=1">'
})
}
});
expect(window.__xss_note).toBeUndefined();
expect(document.body.textContent).toContain('<img src=x onerror=');
});
});