lsa-planer
LSA-Planer Professional – Planungssoftware für Lichtsignalanlagen nach RiLSA 2015 und § 45 StVO. EUPL-1.2.
/ tests ui umschalterUndFarbpunkte.test.ts
| 1 | import { afterEach, describe, expect, it } from 'vitest'; |
| 2 | import { ProjectStore } from '@/app/store'; |
| 3 | import { createStandardIntersectionProject } from '@/domain/model/factory'; |
| 4 | import { LEERER_LAGEPLAN } from '@/domain/geometrie/lageplan'; |
| 5 | import type { Project } from '@/domain/model/project'; |
| 6 | import type { ViewDefinition } from '@/ui/shell'; |
| 7 | import { compatibilityView } from '@/ui/views/compatibilityView'; |
| 8 | import { conflictsView } from '@/ui/views/conflictsView'; |
| 9 | import { lageplanView } from '@/ui/views/lageplanView'; |
| 10 | import { phasesView } from '@/ui/views/phasesView'; |
| 11 | import { planView } from '@/ui/views/planView'; |
| 12 | import { reportView } from '@/ui/views/reportView'; |
| 13 | import { simulationView } from '@/ui/views/simulationView'; |
| 14 | |
| 15 | /** |
| 16 | * Zwei Zusagen zur Barrierefreiheit, geprueft am Code. |
| 17 | * |
| 18 | * Zugesagt sind zwei Dinge, die bis August 2026 nicht durchgaengig zutrafen: |
| 19 | * |
| 20 | * 1. "Umschalter tragen `aria-pressed`" (WCAG 4.1.2). Die vier Filterknoepfe |
| 21 | * des Pruefberichts taten es nicht. Welcher Filter gilt, hing allein an der |
| 22 | * Klasse `knopf-primaer`, also an Farbe und Schriftschnitt - fuer eine |
| 23 | * Sprachausgabe hiessen alle vier gleich viel. Und die Trefferzahl in der |
| 24 | * Beschriftung hilft nicht: "Fehler (0)" liest sich gleich, ob dieser |
| 25 | * Filter gerade greift oder nicht. |
| 26 | * 2. Die Farbpunkte seien `aria-hidden` (WCAG 1.1.1). Von neun erzeugenden |
| 27 | * Stellen trug genau eine das Merkmal. |
| 28 | * |
| 29 | * Beide Faelle pruefen deshalb nicht eine Beispielstelle, sondern den ganzen |
| 30 | * Satz: alle vier Filterknoepfe und ALLE Farbpunkte einer Ansicht. Ein Fall, |
| 31 | * der nur eine Stelle abfragt, haette den Befund nicht gefunden - eine der neun |
| 32 | * Stellen war ja in Ordnung. |
| 33 | * |
| 34 | * NICHT ERFASST, und zwar mit Grund: die Vertraeglichkeitsmatrix. Ihre Knoepfe |
| 35 | * sehen wie eine Umschaltergruppe aus, melden ihren Zustand aber bewusst ueber |
| 36 | * die Beschriftung statt ueber `aria-pressed`; die Begruendung steht bei ihnen |
| 37 | * im Quelltext (compatibilityView.ts). |
| 38 | * |
| 39 | * WAS HIER NACHWEIS IST UND WAS WAECHTER: Gegen den Stand vor der Behebung |
| 40 | * fallen sieben der vierzehn Faelle um - der Filterfall und die sechs |
| 41 | * Farbpunktfaelle. Die uebrigen sieben ("haelt den Namen des Knopfes von seinem |
| 42 | * Zustand getrennt", "laesst keinen Farbpunkt allein eine Aussage tragen") |
| 43 | * blieben auch ohne die Aenderung gruen. Sie belegen die Behebung nicht, |
| 44 | * sondern sichern ihre Voraussetzung: `aria-hidden` ist nur zulaessig, solange |
| 45 | * neben dem Punkt der Klartext derselben Aussage steht. Faellt der weg, ist |
| 46 | * das Verbergen selbst der Fehler. |
| 47 | */ |
| 48 | |
| 49 | const FESTES_DATUM = new Date('2026-01-01T00:00:00Z'); |
| 50 | |
| 51 | function zeichne( |
| 52 | view: ViewDefinition, |
| 53 | project: Project, |
| 54 | ): { wurzel: HTMLElement; aufraeumen: () => void } { |
| 55 | const wurzel = document.createElement('div'); |
| 56 | document.body.append(wurzel); |
| 57 | const aufraeumen = view.render(wurzel, { |
| 58 | store: new ProjectStore(project), |
| 59 | refresh: () => {}, |
| 60 | navigate: () => {}, |
| 61 | target: null, |
| 62 | }); |
| 63 | return { |
| 64 | wurzel, |
| 65 | aufraeumen: () => { |
| 66 | aufraeumen(); |
| 67 | wurzel.remove(); |
| 68 | }, |
| 69 | }; |
| 70 | } |
| 71 | |
| 72 | afterEach(() => { |
| 73 | document.body.replaceChildren(); |
| 74 | }); |
| 75 | |
| 76 | // --- Befund 1: Filter des Pruefberichts ------------------------------------- |
| 77 | |
| 78 | /** Die vier Knoepfe in der Reihenfolge, in der sie in der Ansicht stehen. */ |
| 79 | function filterknoepfe(wurzel: HTMLElement): HTMLButtonElement[] { |
| 80 | return [...wurzel.querySelectorAll<HTMLButtonElement>('.abschnitt-aktionen button')]; |
| 81 | } |
| 82 | |
| 83 | /** Beschriftung ohne die Trefferzahl in Klammern. */ |
| 84 | function filternamen(wurzel: HTMLElement): string[] { |
| 85 | return filterknoepfe(wurzel).map((knopf) => (knopf.textContent ?? '').replace(/\s*\(.*\)$/, '')); |
| 86 | } |
| 87 | |
| 88 | function gemeldeteZustaende(wurzel: HTMLElement): (string | null)[] { |
| 89 | return filterknoepfe(wurzel).map((knopf) => knopf.getAttribute('aria-pressed')); |
| 90 | } |
| 91 | |
| 92 | describe('Pruefbericht: der geltende Filter meldet sich als Zustand (WCAG 4.1.2)', () => { |
| 93 | it('traegt an genau einem Knopf aria-pressed="true" und an den drei anderen "false"', () => { |
| 94 | const { wurzel, aufraeumen } = zeichne( |
| 95 | reportView, |
| 96 | createStandardIntersectionProject('Prüfbericht', FESTES_DATUM), |
| 97 | ); |
| 98 | try { |
| 99 | expect(filternamen(wurzel)).toEqual(['Alle', 'Fehler', 'Warnungen', 'Hinweise']); |
| 100 | |
| 101 | // Ausgangslage: "Alle" gilt. |
| 102 | expect(gemeldeteZustaende(wurzel)).toEqual(['true', 'false', 'false', 'false']); |
| 103 | |
| 104 | // Und dasselbe nach jedem Wechsel: Der Pruefbericht baut sich bei jedem |
| 105 | // Druck neu auf, und der gemeldete Zustand darf dabei nicht am |
| 106 | // abgehaengten Knopf zurueckbleiben. |
| 107 | for (const gewaehlt of [1, 2, 3, 0]) { |
| 108 | const knopf = filterknoepfe(wurzel)[gewaehlt]; |
| 109 | expect(knopf).toBeDefined(); |
| 110 | const name = filternamen(wurzel)[gewaehlt]; |
| 111 | knopf!.click(); |
| 112 | expect(gemeldeteZustaende(wurzel), `nach Druck auf "${name}"`).toEqual( |
| 113 | [0, 1, 2, 3].map((i) => (i === gewaehlt ? 'true' : 'false')), |
| 114 | ); |
| 115 | } |
| 116 | } finally { |
| 117 | aufraeumen(); |
| 118 | } |
| 119 | }); |
| 120 | |
| 121 | it('haelt den Namen des Knopfes von seinem Zustand getrennt', () => { |
| 122 | // Der Name darf nicht mitkippen: Traege der gewaehlte Knopf zusaetzlich ein |
| 123 | // Wort wie "gewaehlt", saehe eine Sprachausgabe den Zustand zweimal - und |
| 124 | // die drei uebrigen Knoepfe hiessen anders als der vierte, obwohl sie |
| 125 | // dieselbe Handlung anbieten. |
| 126 | const { wurzel, aufraeumen } = zeichne( |
| 127 | reportView, |
| 128 | createStandardIntersectionProject('Prüfbericht', FESTES_DATUM), |
| 129 | ); |
| 130 | try { |
| 131 | const vorher = filterknoepfe(wurzel).map((knopf) => knopf.textContent); |
| 132 | const fehlerknopf = filterknoepfe(wurzel)[1]; |
| 133 | expect(fehlerknopf).toBeDefined(); |
| 134 | fehlerknopf!.click(); |
| 135 | expect(filterknoepfe(wurzel).map((knopf) => knopf.textContent)).toEqual(vorher); |
| 136 | } finally { |
| 137 | aufraeumen(); |
| 138 | } |
| 139 | }); |
| 140 | }); |
| 141 | |
| 142 | // --- Befund 2: Farbpunkte --------------------------------------------------- |
| 143 | |
| 144 | /** |
| 145 | * Ein Projekt mit einer gezeichneten Fahrlinie. |
| 146 | * |
| 147 | * Ohne sie zeigt die Lageplanansicht nur den Satz "Noch keine Fahrlinie |
| 148 | * gezeichnet" - und die Pruefung liefe an der Stelle vorbei, die sie meint. |
| 149 | */ |
| 150 | function mitFahrlinie(): Project { |
| 151 | const projekt = createStandardIntersectionProject('Farbpunkte', FESTES_DATUM); |
| 152 | return { |
| 153 | ...projekt, |
| 154 | lageplan: { |
| 155 | ...LEERER_LAGEPLAN, |
| 156 | linien: [ |
| 157 | { |
| 158 | id: 'linie-nord-geradeaus', |
| 159 | name: 'Nord geradeaus', |
| 160 | mode: 'kfz', |
| 161 | movement: 'geradeaus', |
| 162 | breiteMeter: 3.25, |
| 163 | punkte: [ |
| 164 | { x: 0, y: 0 }, |
| 165 | { x: 100, y: 0 }, |
| 166 | ], |
| 167 | haltlinieId: null, |
| 168 | startT: 0, |
| 169 | signalGroupId: null, |
| 170 | }, |
| 171 | ], |
| 172 | }, |
| 173 | }; |
| 174 | } |
| 175 | |
| 176 | const ANSICHTEN_MIT_FARBPUNKTEN: readonly (readonly [string, ViewDefinition])[] = [ |
| 177 | ['Verträglichkeit', compatibilityView], |
| 178 | ['Zwischenzeiten', conflictsView], |
| 179 | ['Phasen', phasesView], |
| 180 | ['Signalzeitenplan', planView], |
| 181 | ['Simulation', simulationView], |
| 182 | ['Lageplan', lageplanView], |
| 183 | ]; |
| 184 | |
| 185 | describe('Farbpunkte sind Schmuck und melden sich als solcher (WCAG 1.1.1)', () => { |
| 186 | for (const [name, ansicht] of ANSICHTEN_MIT_FARBPUNKTEN) { |
| 187 | it(`verbirgt in der Ansicht "${name}" jeden einzelnen Farbpunkt`, () => { |
| 188 | const { wurzel, aufraeumen } = zeichne(ansicht, mitFahrlinie()); |
| 189 | try { |
| 190 | const punkte = [...wurzel.querySelectorAll<HTMLElement>('.farbpunkt')]; |
| 191 | // Ohne diese Schranke ginge der Fall auch dann durch, wenn die Ansicht |
| 192 | // gar nichts gezeichnet haette. |
| 193 | expect(punkte.length, `keine Farbpunkte in "${name}" gefunden`).toBeGreaterThan(0); |
| 194 | |
| 195 | const offen = punkte.filter((punkt) => punkt.getAttribute('aria-hidden') !== 'true'); |
| 196 | expect( |
| 197 | offen.length, |
| 198 | `${offen.length} von ${punkte.length} Farbpunkten in "${name}" ohne aria-hidden`, |
| 199 | ).toBe(0); |
| 200 | } finally { |
| 201 | aufraeumen(); |
| 202 | } |
| 203 | }); |
| 204 | |
| 205 | it(`laesst in der Ansicht "${name}" keinen Farbpunkt allein eine Aussage tragen`, () => { |
| 206 | // Verbergen ist nur dann richtig, wenn die Aussage anderswo im Klartext |
| 207 | // steht. Geprueft wird das an der Umgebung: Der Punkt hat keinen eigenen |
| 208 | // Text, und die Zelle oder Schaltflaeche um ihn herum hat einen. |
| 209 | const { wurzel, aufraeumen } = zeichne(ansicht, mitFahrlinie()); |
| 210 | try { |
| 211 | for (const punkt of wurzel.querySelectorAll<HTMLElement>('.farbpunkt')) { |
| 212 | expect(punkt.textContent, 'ein verborgener Punkt mit eigenem Text').toBe(''); |
| 213 | const traeger = punkt.closest('td, th, button'); |
| 214 | expect(traeger, 'Farbpunkt ohne umgebende Zelle oder Schaltflaeche').not.toBeNull(); |
| 215 | expect( |
| 216 | (traeger!.textContent ?? '').trim().length, |
| 217 | `Farbpunkt in "${name}" ist der einzige Inhalt seiner Umgebung`, |
| 218 | ).toBeGreaterThan(0); |
| 219 | } |
| 220 | } finally { |
| 221 | aufraeumen(); |
| 222 | } |
| 223 | }); |
| 224 | } |
| 225 | }); |