lsa-planer

LSA-Planer Professional – Planungssoftware für Lichtsignalanlagen nach RiLSA 2015 und § 45 StVO. EUPL-1.2.

/ tests ui umschalterUndFarbpunkte.test.ts

8,7 KB Rohdatei
tests/ui/umschalterUndFarbpunkte.test.ts — 225 Zeilen
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 });