import { afterEach, describe, expect, it } from 'vitest'; import { ProjectStore } from '@/app/store'; import { createStandardIntersectionProject } from '@/domain/model/factory'; import type { Project } from '@/domain/model/project'; import type { ViewDefinition } from '@/ui/shell'; import { planView } from '@/ui/views/planView'; import { simulationView } from '@/ui/views/simulationView'; /** * Befund 46 zur Fassung 5.10.0 - Zeilenkoepfe in den beiden breiten * Texttabellen. * * "Signalzeiten je Signalgruppe" (elf Spalten) und "Rückstau und Wartezeit je * Signalgruppe" (neun Spalten) fuehrten die Signalgruppe in einem `td`. Damit * gab es in diesen Tabellen kein einziges `tbody th`, und `beschrifteTabelle()` * hatte nichts, dem es `scope="row"` geben konnte: Eine Sprachausgabe im * Tabellenmodus sagt dann je Zelle nur den Spaltenkopf und den Wert an - die * Signalgruppe, auf die sich alle uebrigen Werte der Zeile beziehen, wird nie * wiederholt. Die beiden Matrizen loesen dieselbe Aufgabe seit jeher mit einem * `th` links in jeder Zeile (conflictsView, compatibilityView); die Begruendung * steht in src/ui/dom.ts bei `beschrifteTabelle`. * * Bei "Signalzeiten je Signalgruppe" wiegt das doppelt: Sie ist der * Alternativinhalt, auf den der Beschreibungstext der Zeichenflaeche verweist * ("Dieselben Angaben stehen als Zahlen in der Tabelle ..."). * * WARUM DER BESTEHENDE PRUEFSTAND DAS NICHT BEMERKT HAT: Der Fall in * tests/ui/barrierefreiheit.test.ts filtert vorhandene `th` ohne `scope`. Eine * Tabelle ganz ohne Zeilenkopf liefert eine leere Trefferliste und laeuft gruen * durch. Hier wird deshalb die Zahl der Zeilenkoepfe gegen die Zahl der Zeilen * gehalten. */ const STICHTAG = new Date('2026-01-01T00:00:00Z'); let aufraeumen: (() => void) | null = null; afterEach(() => { aufraeumen?.(); aufraeumen = null; document.body.replaceChildren(); }); function zeichne(view: ViewDefinition, project: Project): HTMLElement { const wurzel = document.createElement('div'); document.body.append(wurzel); const schliessen = view.render(wurzel, { store: new ProjectStore(project), refresh: () => {}, navigate: () => {}, target: null, }); aufraeumen = () => { schliessen(); wurzel.remove(); }; return wurzel; } /** Tabelle ueber ihre Beschriftung - sie ist ihr zugaenglicher Name. */ function tabelle(wurzel: HTMLElement, beschriftung: string): HTMLTableElement { const treffer = [...wurzel.querySelectorAll('table')].find( (t) => (t.querySelector('caption')?.textContent ?? '').trim() === beschriftung, ); expect(treffer, `Keine Tabelle "${beschriftung}"`).toBeDefined(); return treffer as HTMLTableElement; } /** * Prueft, dass JEDE Koerperzeile einen Zeilenkopf mit dem erwarteten Text * traegt - und zwar als erste Zelle. */ function pruefeZeilenkoepfe(tab: HTMLTableElement, namen: readonly string[]): void { const zeilen = [...tab.querySelectorAll('tbody tr')]; expect(zeilen.length, 'Die Tabelle hat keine Koerperzeile').toBe(namen.length); expect(tab.querySelectorAll('tbody th').length, 'Jede Zeile braucht genau einen Zeilenkopf').toBe( namen.length, ); zeilen.forEach((tr, index) => { const erste = tr.children[0]; expect(erste?.tagName, `Zeile ${index + 1}`).toBe('TH'); expect(erste?.getAttribute('scope'), `Zeile ${index + 1}`).toBe('row'); expect((erste?.textContent ?? '').trim()).toBe(namen[index]); }); } describe('Befund 46 - die Signalgruppe ist der Zeilenkopf ihrer Zeile', () => { it('fuehrt sie im Signalzeitenplan als th scope="row"', () => { const projekt = createStandardIntersectionProject('Zeilenkopf', STICHTAG); const wurzel = zeichne(planView, projekt); pruefeZeilenkoepfe( tabelle(wurzel, 'Signalzeiten je Signalgruppe'), projekt.signalGroups.map((g) => g.name), ); }); it('fuehrt sie in der Simulation als th scope="row"', () => { const projekt = createStandardIntersectionProject('Zeilenkopf', STICHTAG); const wurzel = zeichne(simulationView, projekt); const tab = tabelle(wurzel, 'Rückstau und Wartezeit je Signalgruppe'); // Simuliert werden nur Gruppen mit Saettigungsverkehrsstaerke; welche das // sind, sagt die Tabelle selbst - gemessen wird der Zeilenbezug, nicht die // Auswahl. const namen = [...tab.querySelectorAll('tbody tr')].map((tr) => (tr.children[0]?.textContent ?? '').trim(), ); expect(namen.length).toBeGreaterThan(0); pruefeZeilenkoepfe(tab, namen); }); it('setzt am Zeilenkopf die inline erreichbaren Kopfzeileneigenschaften zurueck', () => { /* * `th { position: sticky; top: 0; z-index: 1; font-size: 0.75rem; * font-weight: 600; color: gedaempft; background: gedaempft; * vertical-align: bottom }` in app.css ist fuer SPALTENkoepfe gedacht und * griffe im Koerper mit: Der Signalgruppenname stuende klein, gedaempft * und klebend in der Zeile. ZEILENKOPF_STIL setzt davon genau fuenf * Eigenschaften zurueck - hier sind alle fuenf gemessen, damit keine von * ihnen unbemerkt aus der Konstante faellt. * * NICHT GEMESSEN, WEIL NICHT GELEISTET: Ein Inline-Stil kennt weder * `:hover` noch `:last-child`. `font-weight: 600` bleibt wirksam, die * Zeilenhervorhebung `tbody tr:hover td` laesst die erste Zelle aus, und * `tbody tr:last-child td { border-bottom: none }` erreicht den * Zeilenkopf nicht. Diese drei Punkte sind erst mit einer Regel * `tbody th` in src/styles/app.css zu haben; die Begruendung steht bei * ZEILENKOPF_STIL. Der Testname sagt deshalb "inline erreichbar" und * nicht "sieht nicht wie ein Spaltenkopf aus". */ const projekt = createStandardIntersectionProject('Zeilenkopf', STICHTAG); for (const [view, beschriftung] of [ [planView, 'Signalzeiten je Signalgruppe'], [simulationView, 'Rückstau und Wartezeit je Signalgruppe'], ] as const) { const wurzel = zeichne(view, projekt); const kopf = tabelle(wurzel, beschriftung).querySelector('tbody th'); expect(kopf?.style.position, beschriftung).toBe('static'); expect(kopf?.style.fontSize, beschriftung).toBe('inherit'); expect(kopf?.style.color, beschriftung).toBe('inherit'); expect(kopf?.style.background, beschriftung).toBe('transparent'); expect(kopf?.style.verticalAlign, beschriftung).toBe('inherit'); aufraeumen?.(); aufraeumen = null; } }); });