lsa-planer

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

/ tests ui bfNamenUndAnsagen.test.ts

16,9 KB Rohdatei
tests/ui/bfNamenUndAnsagen.test.ts — 426 Zeilen
1 import { describe, expect, it } from 'vitest';
2 import { ProjectStore } from '@/app/store';
3 import { ASSISTENT_VORGABE, projektAusAngaben } from '@/ui/assistent';
4 import { beschrifteTabelle, el } from '@/ui/dom';
5 import { selectCell } from '@/ui/inputs';
6 import type { Project } from '@/domain/model/project';
7 import type { ViewDefinition } from '@/ui/shell';
8 import { phasesView } from '@/ui/views/phasesView';
9 import { projectView } from '@/ui/views/projectView';
10 import { signalGroupsView } from '@/ui/views/signalGroupsView';
11 import { zugaenglicherName } from './zugaenglicherName';
12
13 /**
14 * Namen in der zentralen Eingabemaske.
15 *
16 * Befunde M2, M3 und L3. Alle drei betreffen dieselbe Frage: Erfaehrt jemand,
17 * der die Signalgruppentabelle nicht sieht, WELCHE Signalgruppe er gerade
18 * einstellt?
19 *
20 * Gemessen wurde in der laufenden Anwendung:
21 * - 6 Auswahlfelder "Fahrbeziehung" hiessen alle
22 * "Abbiegende Kraftfahrzeuge raeumen langsamer und erhalten laengere
23 * Zwischenzeiten." - bei sechzehn Signalgruppen sechzehnmal derselbe Satz,
24 * ohne Spalte, ohne Zeile.
25 * - 6 Auswahlfelder "Fahrzeugart" ebenso ("Bestimmt den Laengenzuschlag beim
26 * Raeumweg.").
27 * - 6 Farbfelder hiessen alle "Farbe im Signalzeitenplan".
28 * - Die Schaltflaechen hiessen "↑", "↓" und "×" - hinter dem "×" steckt das
29 * Loeschen einer Signalgruppe samt all ihrer Konfliktbeziehungen.
30 *
31 * Deshalb pruefen die Faelle unten nicht nur, DASS ein Name da ist - das tut
32 * `barrierefreiheit.test.ts` -, sondern dass er die Zeile nennt und sich damit
33 * von dem der Nachbarzeile unterscheidet.
34 */
35
36 const FESTES_DATUM = '2026-01-01T00:00:00.000Z';
37
38 function gefuelltesProjekt(): Project {
39 return projektAusAngaben(
40 { ...ASSISTENT_VORGABE, fussgaenger: true, rad: true, linksabbieger: true },
41 FESTES_DATUM,
42 );
43 }
44
45 function zeichne(view: ViewDefinition): { wurzel: HTMLElement; aufraeumen: () => void } {
46 const wurzel = document.createElement('div');
47 document.body.append(wurzel);
48 const aufraeumen = view.render(wurzel, {
49 store: new ProjectStore(gefuelltesProjekt()),
50 refresh: () => {},
51 navigate: () => {},
52 target: null,
53 });
54 return {
55 wurzel,
56 aufraeumen: () => {
57 aufraeumen();
58 wurzel.remove();
59 },
60 };
61 }
62
63 /** Die Tabelle mit den Signalgruppen - die einzige der Ansicht. */
64 function signalgruppentabelle(wurzel: HTMLElement): HTMLTableElement {
65 const tabelle = wurzel.querySelector('table');
66 expect(tabelle, 'Keine Signalgruppentabelle in der Ansicht').not.toBeNull();
67 return tabelle as HTMLTableElement;
68 }
69
70 /** Sichtbarer Kopftext einer Spalte ohne den Hilfeknopf, den spaltenkopf() anhaengt. */
71 function kopftext(th: Element): string {
72 return [...th.childNodes]
73 .filter((k) => !(k instanceof Element && k.classList.contains('hilfe-knopf')))
74 .map((k) => k.textContent ?? '')
75 .join('')
76 .trim();
77 }
78
79 /** Zu jeder Zeile: das Bedienelement der genannten Spalte und der Name der Zeile. */
80 function spalte(
81 tabelle: HTMLTableElement,
82 kopf: string,
83 ): { feld: HTMLElement; zeilenname: string }[] {
84 const kopfzellen = [...tabelle.querySelectorAll('thead th')];
85 const index = kopfzellen.findIndex((th) => kopftext(th) === kopf);
86 expect(index, `Keine Spalte "${kopf}" in der Tabelle`).toBeGreaterThanOrEqual(0);
87
88 return [...tabelle.querySelectorAll<HTMLTableRowElement>('tbody tr')].flatMap((tr) => {
89 const zelle = tr.children[index];
90 const feld = zelle?.querySelector<HTMLElement>('input, select, textarea');
91 // Fussgaenger- und Radgruppen tragen in den Kfz-Spalten nur ein "–".
92 if (feld === null || feld === undefined) return [];
93 const zeilenname = tr.querySelector<HTMLInputElement>('input[type="text"]')?.value.trim() ?? '';
94 return [{ feld, zeilenname }];
95 });
96 }
97
98 describe('Signalgruppentabelle: Auswahlfelder heissen nach Spalte und Zeile (Befund M2)', () => {
99 for (const kopf of ['Fahrbeziehung', 'Fahrzeugart']) {
100 it(`"${kopf}" nennt die Signalgruppe statt des Erklaersatzes`, () => {
101 const { wurzel, aufraeumen } = zeichne(signalGroupsView);
102 try {
103 const felder = spalte(signalgruppentabelle(wurzel), kopf);
104 expect(felder.length, `Keine Felder unter "${kopf}"`).toBeGreaterThan(1);
105
106 for (const { feld, zeilenname } of felder) {
107 expect(zeilenname, 'Zeile ohne Bezeichnung im Beispielprojekt').not.toBe('');
108 expect(zugaenglicherName(feld)).toBe(`${kopf}, ${zeilenname}`);
109 }
110
111 // Der eigentliche Schaden war nicht der schlechte Name, sondern der
112 // GLEICHE: In sechzehn Zeilen hoerte man sechzehnmal denselben Satz.
113 const namen = felder.map(({ feld }) => zugaenglicherName(feld));
114 expect(
115 new Set(namen).size,
116 `Mehrfach vergebene Namen unter "${kopf}": ${namen.join(' | ')}`,
117 ).toBe(namen.length);
118 } finally {
119 aufraeumen();
120 }
121 });
122 }
123
124 it('stellt den Erklaersatz als Beschreibung zu und nicht als Namen', () => {
125 // Der Satz darf nicht verschwinden, nur weil er kein Name mehr ist: Er
126 // sagt, was die Wahl bewirkt - "Abbiegende Kraftfahrzeuge raeumen
127 // langsamer", und daran haengt jede Zwischenzeit dieser Gruppe.
128 const { wurzel, aufraeumen } = zeichne(signalGroupsView);
129 try {
130 const [erstes] = spalte(signalgruppentabelle(wurzel), 'Fahrbeziehung');
131 expect(erstes, 'Kein Feld unter "Fahrbeziehung"').toBeDefined();
132 const feld = erstes?.feld as HTMLElement;
133
134 const satz =
135 'Abbiegende Kraftfahrzeuge räumen langsamer und erhalten längere Zwischenzeiten.';
136 expect(feld.getAttribute('title'), 'Der Kurzhinweis fuer die Maus fehlt').toBe(satz);
137
138 const kennung = feld.getAttribute('aria-describedby') ?? '';
139 expect(kennung, 'Das Feld verweist auf keine Beschreibung').not.toBe('');
140 const beschreibung = wurzel.querySelector(`#${kennung}`);
141 expect(beschreibung?.textContent, 'Die Beschreibung traegt den Erklaersatz nicht').toBe(satz);
142
143 // Und er ist ausdruecklich NICHT der Name.
144 expect(zugaenglicherName(feld)).not.toContain('räumen langsamer');
145 } finally {
146 aufraeumen();
147 }
148 });
149
150 it('laesst die Felder mit eigenem Kurzhinweis benannt (Mindestfreigabe, Lastzuege)', () => {
151 /*
152 * Diese beiden Felder tragen einen `title` UND einen Namen. Bis zur
153 * Behebung von M2 setzte die Ansicht ihn von Hand, weil ein `title` das
154 * Feld sonst von der automatischen Benennung ausschloss; jetzt vergibt ihn
155 * `beschrifteTabelle()`. Der Fall haelt fest, dass dabei genau derselbe
156 * Name herauskommt - er steht in jeder Meldung von `numberCell()`.
157 */
158 const { wurzel, aufraeumen } = zeichne(signalGroupsView);
159 try {
160 const tabelle = signalgruppentabelle(wurzel);
161 for (const [kopf, ausgeschrieben] of [
162 ['Mindestfreigabe', 'Mindestfreigabezeit in Sekunden'],
163 ['Lastzüge am SV', 'Anteil der Lastzüge am Schwerverkehr in Prozent'],
164 ] as const) {
165 const felder = spalte(tabelle, kopf);
166 expect(felder.length, `Keine Felder unter "${kopf}"`).toBeGreaterThan(0);
167 for (const { feld, zeilenname } of felder) {
168 expect(feld.getAttribute('title'), `"${kopf}" ohne Kurzhinweis`).not.toBeNull();
169 expect(zugaenglicherName(feld)).toBe(`${ausgeschrieben}, ${zeilenname}`);
170 }
171 }
172 } finally {
173 aufraeumen();
174 }
175 });
176 });
177
178 describe('Signalgruppentabelle: das Farbfeld nennt seine Signalgruppe (Befund L3)', () => {
179 it('unterscheidet die Farbfelder voneinander', () => {
180 const { wurzel, aufraeumen } = zeichne(signalGroupsView);
181 try {
182 const felder = [
183 ...signalgruppentabelle(wurzel).querySelectorAll<HTMLInputElement>(
184 'tbody input[type="color"]',
185 ),
186 ];
187 expect(felder.length, 'Keine Farbfelder in der Tabelle').toBeGreaterThan(1);
188
189 const namen = felder.map((feld) => zugaenglicherName(feld));
190 for (const [i, name] of namen.entries()) {
191 const zeile = felder[i]?.closest('tr');
192 const bezeichnung =
193 zeile?.querySelector<HTMLInputElement>('input[type="text"]')?.value.trim() ?? '';
194 expect(name).toBe(`Farbe im Signalzeitenplan, ${bezeichnung}`);
195 }
196 expect(new Set(namen).size, `Gleichlautende Farbfelder: ${namen.join(' | ')}`).toBe(
197 namen.length,
198 );
199 } finally {
200 aufraeumen();
201 }
202 });
203 });
204
205 describe('Schaltflaechen mit einem Zeichen nennen ihre Zeile (Befund M3)', () => {
206 /** Alle Schaltflaechen, deren sichtbare Beschriftung genau ein Zeichen ist. */
207 const zeichenknoepfe = (wurzel: HTMLElement, zeichen: string): HTMLButtonElement[] =>
208 [...wurzel.querySelectorAll<HTMLButtonElement>('button')].filter(
209 (b) => (b.textContent ?? '').trim() === zeichen,
210 );
211
212 it('in der Signalgruppentabelle', () => {
213 const { wurzel, aufraeumen } = zeichne(signalGroupsView);
214 try {
215 for (const [zeichen, tat] of [
216 ['↑', 'nach oben'],
217 ['↓', 'nach unten'],
218 ['×', 'löschen'],
219 ] as const) {
220 const knoepfe = zeichenknoepfe(wurzel, zeichen);
221 expect(knoepfe.length, `Keine Schaltflaeche "${zeichen}"`).toBeGreaterThan(1);
222
223 const namen = knoepfe.map((knopf) => {
224 const bezeichnung =
225 knopf
226 .closest('tr')
227 ?.querySelector<HTMLInputElement>('input[type="text"]')
228 ?.value.trim() ?? '';
229 const name = zugaenglicherName(knopf);
230 // Der Name muss die Signalgruppe UND die Tat nennen: "×" allein
231 // sagt nicht, dass hier eine Signalgruppe samt ihrer
232 // Konfliktbeziehungen verschwindet.
233 expect(name).toBe(`Signalgruppe "${bezeichnung}" ${tat}`);
234 return name;
235 });
236 expect(new Set(namen).size, `Gleichlautende "${zeichen}": ${namen.join(' | ')}`).toBe(
237 namen.length,
238 );
239 }
240 } finally {
241 aufraeumen();
242 }
243 });
244
245 it('in der Phasenfolge', () => {
246 const { wurzel, aufraeumen } = zeichne(phasesView);
247 try {
248 for (const [zeichen, tat] of [
249 ['↑', 'nach vorne'],
250 ['↓', 'nach hinten'],
251 ['×', 'löschen'],
252 ] as const) {
253 const knoepfe = zeichenknoepfe(wurzel, zeichen);
254 expect(knoepfe.length, `Keine Schaltflaeche "${zeichen}"`).toBeGreaterThan(1);
255
256 const namen = knoepfe.map((knopf, i) => {
257 const name = zugaenglicherName(knopf);
258 expect(name, `"${zeichen}" ohne Bezug zur Phase`).toMatch(
259 new RegExp(`^${i + 1}\\. Phase .*${tat}$`),
260 );
261 return name;
262 });
263 expect(new Set(namen).size, `Gleichlautende "${zeichen}": ${namen.join(' | ')}`).toBe(
264 namen.length,
265 );
266 }
267 } finally {
268 aufraeumen();
269 }
270 });
271 });
272
273 describe('beschrifteTabelle: ein title ist kein Name (Ursache von Befund M2)', () => {
274 it('benennt auch ein Feld, das einen Kurzhinweis traegt', () => {
275 /*
276 * Der Fall gehoert hierher und nicht in die Ansicht: Solange
277 * `benenneBedienelemente()` bei jedem `title` abbrach, kehrte der Befund
278 * mit der naechsten Tabelle wieder, die einem Feld einen Kurzhinweis gibt.
279 */
280 const satz = 'Ein ganzer Erklärsatz, der keinen Namen ergibt.';
281 const feld = selectCell('a', [{ value: 'a', label: 'A' }], () => {}, { title: satz });
282
283 const tabelle = beschrifteTabelle(
284 el(
285 'table',
286 {},
287 el('thead', {}, el('tr', {}, el('th', { text: 'Fahrbeziehung' }))),
288 el('tbody', {}, el('tr', {}, el('td', {}, feld))),
289 ),
290 { beschriftung: 'Probe', zeilenname: () => 'K1' },
291 );
292 document.body.append(tabelle);
293 try {
294 expect(zugaenglicherName(feld)).toBe('Fahrbeziehung, K1');
295 // Der Kurzhinweis bleibt, wo er hingehoert.
296 expect(feld.getAttribute('title')).toBe(satz);
297 } finally {
298 tabelle.remove();
299 }
300 });
301 });
302
303 // --- Zufahrtentabelle der Ansicht "Projekt" (Befunde M6, L9) ----------------
304
305 /**
306 * Die Zufahrtentabelle baute ihre Felder selbst - mit zwei eigenen Empfaengern
307 * statt der Zellenfelder aus `inputs.ts` (Befund M6, WCAG 3.3.1 und 3.3.2).
308 *
309 * `Number(input.value)` machte aus einem geleerten Feld eine 0 und speicherte
310 * sie: null Fahrstreifen, 0 km/h. `min` und `max` standen nur als Attribute im
311 * Baum und wurden nie ausgewertet. Es gab weder `aria-invalid` noch eine
312 * Meldung - die zulaessige Hoechstgeschwindigkeit der Zufahrt geht ueber die
313 * Gelbzeitstaffel unmittelbar in jede Zwischenzeit ein.
314 */
315 describe('Zufahrten: Zahlenfelder lehnen ab, statt still eine Null zu speichern (Befund M6)', () => {
316 /** Alle Zahlenfelder der Ansicht - in der Zufahrtentabelle und am Knotenpunkt. */
317 function zahlenfelder(wurzel: HTMLElement): HTMLInputElement[] {
318 return [...wurzel.querySelectorAll<HTMLInputElement>('input[type="number"]')];
319 }
320
321 it('stellt bei geleertem Feld den vorherigen Wert wieder her und kennzeichnet nichts falsch', () => {
322 const { wurzel, aufraeumen } = zeichne(projectView);
323 try {
324 const felder = zahlenfelder(wurzel);
325 expect(felder.length, 'keine Zahlenfelder gefunden').toBeGreaterThan(0);
326 const feld = felder[0]!;
327 const vorher = feld.value;
328 expect(vorher).not.toBe('');
329
330 feld.value = '';
331 feld.dispatchEvent(new Event('change'));
332
333 expect(feld.value, 'leeres Feld wurde als 0 uebernommen').toBe(vorher);
334 // Im Feld steht ein einwandfreier Wert - dann gehoert dort auch keine
335 // Fehlerkennzeichnung hin (Befund L6).
336 expect(feld.hasAttribute('aria-invalid')).toBe(false);
337 } finally {
338 aufraeumen();
339 }
340 });
341
342 it('begrenzt einen Wert ausserhalb der Schranken und sagt es', () => {
343 const { wurzel, aufraeumen } = zeichne(projectView);
344 try {
345 const feld = zahlenfelder(wurzel)[0]!;
346 const grenze = Number(feld.getAttribute('max'));
347 expect(Number.isFinite(grenze), 'das Feld nennt keine Obergrenze').toBe(true);
348
349 feld.value = String(grenze + 100);
350 feld.dispatchEvent(new Event('change'));
351
352 expect(feld.value, 'die Schranke wurde nicht ausgewertet').toBe(String(grenze));
353 const meldung = document.querySelector('.meldungen [role="alert"] .meldung');
354 expect(meldung, 'die Begrenzung geschah stillschweigend').not.toBeNull();
355 } finally {
356 document.body.replaceChildren();
357 aufraeumen();
358 }
359 });
360
361 it('nennt den Wertebereich, bevor die Eingabe geschieht', () => {
362 // Eine Sprachausgabe meldet `min`/`max` erst NACH einer Fehleingabe - und
363 // dann ist der eingegebene Wert schon verworfen.
364 const { wurzel, aufraeumen } = zeichne(projectView);
365 try {
366 for (const feld of zahlenfelder(wurzel)) {
367 const hinweisId = feld.getAttribute('aria-describedby');
368 expect(hinweisId, 'kein Wertebereichshinweis am Feld').not.toBeNull();
369 const hinweis = wurzel.querySelector(`#${hinweisId!.split(/\s+/)[0]!}`);
370 expect(hinweis?.textContent ?? '').toMatch(/Zulässig sind/);
371 }
372 } finally {
373 aufraeumen();
374 }
375 });
376 });
377
378 describe('Projektdaten: der Bearbeiter nennt seinen Eingabezweck (Befund L9, WCAG 1.3.5)', () => {
379 it('traegt autocomplete="name" - und nur dieses eine Feld', () => {
380 const { wurzel, aufraeumen } = zeichne(projectView);
381 try {
382 const beschriftung = [...wurzel.querySelectorAll('label')].find(
383 (l) => l.textContent?.trim() === 'Bearbeiter',
384 );
385 expect(beschriftung, 'Feld "Bearbeiter" nicht gefunden').toBeDefined();
386 const feld = wurzel.querySelector<HTMLInputElement>(`#${beschriftung!.htmlFor}`);
387 expect(feld?.getAttribute('autocomplete')).toBe('name');
388
389 /*
390 * Und sonst keines: 1.3.5 gilt fuer Angaben UEBER DEN ANWENDER. Projekt-
391 * bezeichnung, Auftraggeber, Ort und Variante sind Angaben ueber die
392 * Planung; fuer sie kennt die Liste der Eingabezwecke keinen Eintrag,
393 * und `autocomplete="organization"` am Auftraggeber waere sogar falsch -
394 * gemeint ist die Behoerde, die den Auftrag erteilt, nicht die eigene.
395 */
396 const mitZweck = [...wurzel.querySelectorAll('input[autocomplete]')];
397 expect(mitZweck).toEqual([feld]);
398 } finally {
399 aufraeumen();
400 }
401 });
402 });
403
404 describe('Zufahrten: die Loeschschaltflaeche nennt ihre Zufahrt', () => {
405 it('haelt die sichtbare Beschriftung im Namen (WCAG 2.4.6 und 2.5.3)', () => {
406 // Vier gleichlautende Knoepfe "Löschen" untereinander sagen einer
407 // Sprachausgabe nicht, WELCHE Zufahrt verschwindet - und mit ihr gehen
408 // Fahrstreifen und zulaessige Hoechstgeschwindigkeit weg, aus denen die
409 // Zwischenzeiten gerechnet werden.
410 const { wurzel, aufraeumen } = zeichne(projectView);
411 try {
412 const knoepfe = [...wurzel.querySelectorAll<HTMLButtonElement>('button')].filter(
413 (b) => b.textContent?.trim() === 'Löschen',
414 );
415 expect(knoepfe.length, 'keine Loeschschaltflaechen gefunden').toBeGreaterThan(1);
416
417 const namen = knoepfe.map((b) => zugaenglicherName(b));
418 for (const name of namen) {
419 expect(name).toMatch(/^Zufahrt ".+" löschen$/);
420 }
421 expect(new Set(namen).size, 'die Namen sind nicht unterscheidbar').toBe(namen.length);
422 } finally {
423 aufraeumen();
424 }
425 });
426 });