lsa-planer
LSA-Planer Professional – Planungssoftware für Lichtsignalanlagen nach RiLSA 2015 und § 45 StVO. EUPL-1.2.
| 1 | import { afterEach, describe, expect, it } from 'vitest'; |
| 2 | import { ProjectStore } from '@/app/store'; |
| 3 | import { createStandardIntersectionProject } from '@/domain/model/factory'; |
| 4 | import type { Project } from '@/domain/model/project'; |
| 5 | import type { ViewDefinition } from '@/ui/shell'; |
| 6 | import { planView } from '@/ui/views/planView'; |
| 7 | import { simulationView } from '@/ui/views/simulationView'; |
| 8 | |
| 9 | /** |
| 10 | * Befund 46 zur Fassung 5.10.0 - Zeilenkoepfe in den beiden breiten |
| 11 | * Texttabellen. |
| 12 | * |
| 13 | * "Signalzeiten je Signalgruppe" (elf Spalten) und "Rückstau und Wartezeit je |
| 14 | * Signalgruppe" (neun Spalten) fuehrten die Signalgruppe in einem `td`. Damit |
| 15 | * gab es in diesen Tabellen kein einziges `tbody th`, und `beschrifteTabelle()` |
| 16 | * hatte nichts, dem es `scope="row"` geben konnte: Eine Sprachausgabe im |
| 17 | * Tabellenmodus sagt dann je Zelle nur den Spaltenkopf und den Wert an - die |
| 18 | * Signalgruppe, auf die sich alle uebrigen Werte der Zeile beziehen, wird nie |
| 19 | * wiederholt. Die beiden Matrizen loesen dieselbe Aufgabe seit jeher mit einem |
| 20 | * `th` links in jeder Zeile (conflictsView, compatibilityView); die Begruendung |
| 21 | * steht in src/ui/dom.ts bei `beschrifteTabelle`. |
| 22 | * |
| 23 | * Bei "Signalzeiten je Signalgruppe" wiegt das doppelt: Sie ist der |
| 24 | * Alternativinhalt, auf den der Beschreibungstext der Zeichenflaeche verweist |
| 25 | * ("Dieselben Angaben stehen als Zahlen in der Tabelle ..."). |
| 26 | * |
| 27 | * WARUM DER BESTEHENDE PRUEFSTAND DAS NICHT BEMERKT HAT: Der Fall in |
| 28 | * tests/ui/barrierefreiheit.test.ts filtert vorhandene `th` ohne `scope`. Eine |
| 29 | * Tabelle ganz ohne Zeilenkopf liefert eine leere Trefferliste und laeuft gruen |
| 30 | * durch. Hier wird deshalb die Zahl der Zeilenkoepfe gegen die Zahl der Zeilen |
| 31 | * gehalten. |
| 32 | */ |
| 33 | |
| 34 | const STICHTAG = new Date('2026-01-01T00:00:00Z'); |
| 35 | |
| 36 | let aufraeumen: (() => void) | null = null; |
| 37 | afterEach(() => { |
| 38 | aufraeumen?.(); |
| 39 | aufraeumen = null; |
| 40 | document.body.replaceChildren(); |
| 41 | }); |
| 42 | |
| 43 | function zeichne(view: ViewDefinition, project: Project): HTMLElement { |
| 44 | const wurzel = document.createElement('div'); |
| 45 | document.body.append(wurzel); |
| 46 | const schliessen = view.render(wurzel, { |
| 47 | store: new ProjectStore(project), |
| 48 | refresh: () => {}, |
| 49 | navigate: () => {}, |
| 50 | target: null, |
| 51 | }); |
| 52 | aufraeumen = () => { |
| 53 | schliessen(); |
| 54 | wurzel.remove(); |
| 55 | }; |
| 56 | return wurzel; |
| 57 | } |
| 58 | |
| 59 | /** Tabelle ueber ihre Beschriftung - sie ist ihr zugaenglicher Name. */ |
| 60 | function tabelle(wurzel: HTMLElement, beschriftung: string): HTMLTableElement { |
| 61 | const treffer = [...wurzel.querySelectorAll('table')].find( |
| 62 | (t) => (t.querySelector('caption')?.textContent ?? '').trim() === beschriftung, |
| 63 | ); |
| 64 | expect(treffer, `Keine Tabelle "${beschriftung}"`).toBeDefined(); |
| 65 | return treffer as HTMLTableElement; |
| 66 | } |
| 67 | |
| 68 | /** |
| 69 | * Prueft, dass JEDE Koerperzeile einen Zeilenkopf mit dem erwarteten Text |
| 70 | * traegt - und zwar als erste Zelle. |
| 71 | */ |
| 72 | function pruefeZeilenkoepfe(tab: HTMLTableElement, namen: readonly string[]): void { |
| 73 | const zeilen = [...tab.querySelectorAll<HTMLTableRowElement>('tbody tr')]; |
| 74 | expect(zeilen.length, 'Die Tabelle hat keine Koerperzeile').toBe(namen.length); |
| 75 | expect(tab.querySelectorAll('tbody th').length, 'Jede Zeile braucht genau einen Zeilenkopf').toBe( |
| 76 | namen.length, |
| 77 | ); |
| 78 | zeilen.forEach((tr, index) => { |
| 79 | const erste = tr.children[0]; |
| 80 | expect(erste?.tagName, `Zeile ${index + 1}`).toBe('TH'); |
| 81 | expect(erste?.getAttribute('scope'), `Zeile ${index + 1}`).toBe('row'); |
| 82 | expect((erste?.textContent ?? '').trim()).toBe(namen[index]); |
| 83 | }); |
| 84 | } |
| 85 | |
| 86 | describe('Befund 46 - die Signalgruppe ist der Zeilenkopf ihrer Zeile', () => { |
| 87 | it('fuehrt sie im Signalzeitenplan als th scope="row"', () => { |
| 88 | const projekt = createStandardIntersectionProject('Zeilenkopf', STICHTAG); |
| 89 | const wurzel = zeichne(planView, projekt); |
| 90 | pruefeZeilenkoepfe( |
| 91 | tabelle(wurzel, 'Signalzeiten je Signalgruppe'), |
| 92 | projekt.signalGroups.map((g) => g.name), |
| 93 | ); |
| 94 | }); |
| 95 | |
| 96 | it('fuehrt sie in der Simulation als th scope="row"', () => { |
| 97 | const projekt = createStandardIntersectionProject('Zeilenkopf', STICHTAG); |
| 98 | const wurzel = zeichne(simulationView, projekt); |
| 99 | const tab = tabelle(wurzel, 'Rückstau und Wartezeit je Signalgruppe'); |
| 100 | // Simuliert werden nur Gruppen mit Saettigungsverkehrsstaerke; welche das |
| 101 | // sind, sagt die Tabelle selbst - gemessen wird der Zeilenbezug, nicht die |
| 102 | // Auswahl. |
| 103 | const namen = [...tab.querySelectorAll<HTMLTableRowElement>('tbody tr')].map((tr) => |
| 104 | (tr.children[0]?.textContent ?? '').trim(), |
| 105 | ); |
| 106 | expect(namen.length).toBeGreaterThan(0); |
| 107 | pruefeZeilenkoepfe(tab, namen); |
| 108 | }); |
| 109 | |
| 110 | it('setzt am Zeilenkopf die inline erreichbaren Kopfzeileneigenschaften zurueck', () => { |
| 111 | /* |
| 112 | * `th { position: sticky; top: 0; z-index: 1; font-size: 0.75rem; |
| 113 | * font-weight: 600; color: gedaempft; background: gedaempft; |
| 114 | * vertical-align: bottom }` in app.css ist fuer SPALTENkoepfe gedacht und |
| 115 | * griffe im Koerper mit: Der Signalgruppenname stuende klein, gedaempft |
| 116 | * und klebend in der Zeile. ZEILENKOPF_STIL setzt davon genau fuenf |
| 117 | * Eigenschaften zurueck - hier sind alle fuenf gemessen, damit keine von |
| 118 | * ihnen unbemerkt aus der Konstante faellt. |
| 119 | * |
| 120 | * NICHT GEMESSEN, WEIL NICHT GELEISTET: Ein Inline-Stil kennt weder |
| 121 | * `:hover` noch `:last-child`. `font-weight: 600` bleibt wirksam, die |
| 122 | * Zeilenhervorhebung `tbody tr:hover td` laesst die erste Zelle aus, und |
| 123 | * `tbody tr:last-child td { border-bottom: none }` erreicht den |
| 124 | * Zeilenkopf nicht. Diese drei Punkte sind erst mit einer Regel |
| 125 | * `tbody th` in src/styles/app.css zu haben; die Begruendung steht bei |
| 126 | * ZEILENKOPF_STIL. Der Testname sagt deshalb "inline erreichbar" und |
| 127 | * nicht "sieht nicht wie ein Spaltenkopf aus". |
| 128 | */ |
| 129 | const projekt = createStandardIntersectionProject('Zeilenkopf', STICHTAG); |
| 130 | for (const [view, beschriftung] of [ |
| 131 | [planView, 'Signalzeiten je Signalgruppe'], |
| 132 | [simulationView, 'Rückstau und Wartezeit je Signalgruppe'], |
| 133 | ] as const) { |
| 134 | const wurzel = zeichne(view, projekt); |
| 135 | const kopf = tabelle(wurzel, beschriftung).querySelector<HTMLElement>('tbody th'); |
| 136 | expect(kopf?.style.position, beschriftung).toBe('static'); |
| 137 | expect(kopf?.style.fontSize, beschriftung).toBe('inherit'); |
| 138 | expect(kopf?.style.color, beschriftung).toBe('inherit'); |
| 139 | expect(kopf?.style.background, beschriftung).toBe('transparent'); |
| 140 | expect(kopf?.style.verticalAlign, beschriftung).toBe('inherit'); |
| 141 | aufraeumen?.(); |
| 142 | aufraeumen = null; |
| 143 | } |
| 144 | }); |
| 145 | }); |