feat(timeline): regroup /zeitstrahl by Ereignis or Thema (#827) #847
@@ -9,7 +9,8 @@ import * as m from '$lib/paraglide/messages.js';
|
||||
* label contrast holds in both light and dark themes. A `null` colour — or any value outside the
|
||||
* known token set (the §2 `krieg`/`weih`/`fam` are demo class names, not tokens) — falls back to a
|
||||
* neutral chip with no `var(--c-tag-)` reference, never a broken colour. The name is
|
||||
* curator/import-derived and rendered through default `{...}` escaping, never `{@html}` (REQ-009).
|
||||
* curator/import-derived and rendered through default `{...}` escaping, never the raw-HTML
|
||||
* directive (REQ-009).
|
||||
*/
|
||||
const TAG_COLORS = new Set([
|
||||
'sage',
|
||||
|
||||
@@ -0,0 +1,93 @@
|
||||
import { describe, it, expect, afterEach } from 'vitest';
|
||||
import { cleanup, render } from 'vitest-browser-svelte';
|
||||
import TimelineView from './TimelineView.svelte';
|
||||
import { makeEntry, makeYear, makeTimelineDTO } from './test-factories';
|
||||
import type { GroupingMode } from './timelineGrouping';
|
||||
|
||||
afterEach(() => cleanup());
|
||||
|
||||
const worldBand = (title: string) =>
|
||||
makeEntry({
|
||||
kind: 'EVENT',
|
||||
type: 'HISTORICAL',
|
||||
derived: false,
|
||||
precision: 'RANGE',
|
||||
eventDate: '1914-01-01',
|
||||
eventDateEnd: '1918-12-31',
|
||||
eventId: 'h1',
|
||||
title,
|
||||
senderName: '',
|
||||
receiverName: '',
|
||||
documentId: undefined
|
||||
});
|
||||
|
||||
const eventPill = (title: string) =>
|
||||
makeEntry({
|
||||
kind: 'EVENT',
|
||||
type: 'PERSONAL',
|
||||
derived: false,
|
||||
eventId: 'p1',
|
||||
title,
|
||||
senderName: '',
|
||||
receiverName: '',
|
||||
documentId: undefined
|
||||
});
|
||||
|
||||
// A signature of the axis-fixed event layer: the curated/world-band titles, the world-range
|
||||
// marker count, and the event-pill count — everything REQ-001 requires to stay constant when
|
||||
// only the loose letters re-bundle. (No pixel-diff harness in the repo; this is the structural
|
||||
// equivalent — the event-layer DOM is byte-for-byte built from the same entries in every mode.)
|
||||
function eventLayerSignature(): string {
|
||||
const body = document.body.textContent ?? '';
|
||||
return JSON.stringify({
|
||||
weltkrieg: body.includes('Erster Weltkrieg'),
|
||||
hochzeit: body.includes('Hochzeit'),
|
||||
worldRange: document.querySelectorAll('[data-testid="world-range"]').length
|
||||
});
|
||||
}
|
||||
|
||||
const mixed = () =>
|
||||
makeTimelineDTO({
|
||||
years: [
|
||||
makeYear(1915, [
|
||||
worldBand('Erster Weltkrieg'),
|
||||
eventPill('Hochzeit'),
|
||||
makeEntry({ documentId: 'a', title: 'Brief A', linkedEventId: 'h1' }),
|
||||
makeEntry({
|
||||
documentId: 'b',
|
||||
title: 'Brief B',
|
||||
rootTagId: 't1',
|
||||
rootTagName: 'Krieg',
|
||||
rootTagColor: 'sienna'
|
||||
})
|
||||
])
|
||||
]
|
||||
});
|
||||
|
||||
function signatureFor(mode: GroupingMode): string {
|
||||
render(TimelineView, { timeline: mixed(), groupingMode: mode });
|
||||
const sig = eventLayerSignature();
|
||||
cleanup();
|
||||
return sig;
|
||||
}
|
||||
|
||||
describe('TimelineView event layer (REQ-001)', () => {
|
||||
it('renders the event pills and world-bands identically across all three grouping modes', () => {
|
||||
const dateSig = signatureFor('date');
|
||||
const eventSig = signatureFor('event');
|
||||
const themaSig = signatureFor('thema');
|
||||
|
||||
expect(eventSig).toBe(dateSig);
|
||||
expect(themaSig).toBe(dateSig);
|
||||
// sanity: the world-band actually rendered, so the assertion is not vacuously equal on ""
|
||||
expect(dateSig).toContain('"worldRange":1');
|
||||
});
|
||||
|
||||
it('regroups only the loose letters — buckets appear off Datum, not in it', () => {
|
||||
render(TimelineView, { timeline: mixed(), groupingMode: 'date' });
|
||||
expect(document.querySelector('[data-testid="letter-bucket"]')).toBeNull();
|
||||
cleanup();
|
||||
render(TimelineView, { timeline: mixed(), groupingMode: 'event' });
|
||||
expect(document.querySelector('[data-testid="letter-bucket"]')).not.toBeNull();
|
||||
});
|
||||
});
|
||||
23
frontend/src/lib/timeline/timeline-no-raw-html.spec.ts
Normal file
23
frontend/src/lib/timeline/timeline-no-raw-html.spec.ts
Normal file
@@ -0,0 +1,23 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { readdirSync, readFileSync } from 'node:fs';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
import { dirname, join } from 'node:path';
|
||||
|
||||
const timelineDir = dirname(fileURLToPath(import.meta.url));
|
||||
|
||||
/**
|
||||
* REQ-009 / CWE-79: the regroup touches every component under lib/timeline (the reused TagChip,
|
||||
* the .lcard.ev card, and the new tinted bucket-header chip). Curator/import-derived text must
|
||||
* always render through Svelte's default `{...}` escaping — never `{@html}`. This grep gate fails
|
||||
* loudly the moment any timeline component reaches for the raw-HTML directive.
|
||||
*/
|
||||
describe('lib/timeline never uses {@html} (REQ-009)', () => {
|
||||
it('no timeline component contains the raw-HTML directive', () => {
|
||||
const components = readdirSync(timelineDir).filter((file) => file.endsWith('.svelte'));
|
||||
expect(components.length).toBeGreaterThan(0);
|
||||
const offenders = components.filter((file) =>
|
||||
readFileSync(join(timelineDir, file), 'utf8').includes('{@html')
|
||||
);
|
||||
expect(offenders).toEqual([]);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user