lsa-planer

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

/ tests ui bfFensterUndMeldungen.test.ts

29,5 KB Rohdatei
tests/ui/bfFensterUndMeldungen.test.ts — 745 Zeilen
1 import { afterEach, describe, expect, it, vi } from 'vitest';
2 import { ProjectStore } from '@/app/store';
3 import { buildSignalPlan } from '@/domain/plan/signalPlan';
4 import { validateProject } from '@/domain/validation';
5 import { createStandardIntersectionProject } from '@/domain/model/factory';
6 import type { Project } from '@/domain/model/project';
7 import type { ViewDefinition } from '@/ui/shell';
8 import { button, el } from '@/ui/dom';
9 import {
10 Panel,
11 confirmDialog,
12 meldungsdauer,
13 notify,
14 offeneFensterAnzahl,
15 setzeMeldungsdauer,
16 } from '@/ui/feedback';
17 import {
18 DEFAULT_APP_SETTINGS,
19 MELDUNGSDAUER_STUFEN,
20 loadAppSettings,
21 saveAppSettings,
22 } from '@/services/storage';
23 import { zeigeGlossar } from '@/ui/help/hilfe';
24 import { numberCell } from '@/ui/inputs';
25 import { exportView } from '@/ui/views/exportView';
26 import { reportView } from '@/ui/views/reportView';
27 import { settingsView } from '@/ui/views/settingsView';
28
29 /**
30 * Zwei Gruppen von Befunden zur Barrierefreiheit.
31 *
32 * FENSTER-STAPEL (M1, M8, M9, M10, L7). Der einzige Bereich der
33 * Oberflaeche mit echtem Datenverlust, und er trifft Tastatur- und
34 * Sprachausgabennutzer allein: Mit der Maus tritt er gar nicht auf. Bis hierher
35 * fasste kein einziger Test `Panel` oder `confirmDialog` ueberhaupt an - weder
36 * Escape noch Tabulator, Fokusrueckgabe oder Zeigerabbruch waren abgedeckt.
37 *
38 * FEHLERMELDUNGEN UND STATUSANSAGEN (M7, L4, L5, L6).
39 *
40 * Gegen den Altstand schlagen die Faelle fehl: Dort schloss EIN Escape alle
41 * uebereinanderliegenden Fenster, zog der Tabulator aus der Rueckfrage in das
42 * stillgelegte Fenster darunter, blieb der Fokus nach dem Schliessen auf einem
43 * abgehaengten Knoten haengen, schloss ein blosses Herunterdruecken der
44 * Maustaste das Fenster, stand der Hintergrund trotz `aria-hidden` weiter im
45 * Tabulatorlauf, fiel der Fokus im Pruefbericht nach jedem Filterdruck auf den
46 * Seitenkoerper, sagte die Glossarsuche nichts an, trugen zwei Balken ein
47 * `role="status"`, das nichts ansagen konnte, und ein Feld behielt
48 * `aria-invalid` mit einem einwandfreien Wert darin.
49 */
50
51 const FESTES_DATUM = new Date('2026-01-01T00:00:00Z');
52
53 afterEach(() => {
54 document.body.replaceChildren();
55 });
56
57 // --- Hilfsmittel ------------------------------------------------------------
58
59 function overlays(): HTMLElement[] {
60 return [...document.querySelectorAll<HTMLElement>('.dialog-hintergrund')];
61 }
62
63 /** Tastendruck, wie ihn der Browser liefert: am Fokus, aufsteigend, abweisbar. */
64 function taste(key: string, shiftKey = false): void {
65 const ziel = document.activeElement ?? document.body;
66 ziel.dispatchEvent(
67 new KeyboardEvent('keydown', { key, shiftKey, bubbles: true, cancelable: true }),
68 );
69 }
70
71 /** Wartet einen Mikroschritt ab - die Fokusrueckgabe laeuft in einem solchen. */
72 async function mikroschritt(): Promise<void> {
73 await new Promise<void>((fertig) => {
74 queueMicrotask(fertig);
75 });
76 }
77
78 /** Ein Fenster mit einem bedienbaren Inhalt, damit die Fokusfalle etwas zu fassen hat. */
79 function fenster(titel: string): { panel: Panel; geschlossen: () => boolean } {
80 let zu = false;
81 const panel = new Panel({ title: titel });
82 panel.onClose(() => {
83 zu = true;
84 });
85 panel.setActions(
86 button({
87 label: `Weiter (${titel})`,
88 onClick: () => {
89 /* nichts - der Knopf ist nur ein Fokusziel */
90 },
91 }),
92 );
93 return { panel, geschlossen: () => zu };
94 }
95
96 function zeichne(
97 view: ViewDefinition,
98 project: Project,
99 ): { wurzel: HTMLElement; store: ProjectStore; aufraeumen: () => void } {
100 const wurzel = document.createElement('div');
101 document.body.append(wurzel);
102 const store = new ProjectStore(project);
103 const aufraeumen = view.render(wurzel, {
104 store,
105 refresh: () => {},
106 navigate: () => {},
107 target: null,
108 });
109 return {
110 wurzel,
111 store,
112 aufraeumen: () => {
113 aufraeumen();
114 wurzel.remove();
115 },
116 };
117 }
118
119 /** Ein Projekt mit Fehlern: alle Kfz-Staerken so hoch, dass die Anlage uebersaettigt ist. */
120 function gesperrt(): Project {
121 const p = createStandardIntersectionProject('Gesperrt', FESTES_DATUM);
122 const mitFehlern = { ...p, demands: p.demands.map((d) => ({ ...d, volume: 1500 })) };
123 expect(
124 validateProject(mitFehlern, buildSignalPlan(mitFehlern), FESTES_DATUM).exportBlocked,
125 'Vorbedingung: Export gesperrt',
126 ).toBe(true);
127 return mitFehlern;
128 }
129
130 // --- Fenster-Stapel ---------------------------------------------------------
131
132 describe('Fenster-Stapel: Escape schliesst genau das oberste Fenster (Befund M1)', () => {
133 it('laesst bei zwei Fenstern das untere stehen und schliesst es erst beim zweiten Escape', () => {
134 const unten = fenster('Glossar');
135 const oben = fenster('Kurzhilfe');
136 expect(overlays()).toHaveLength(2);
137 expect(offeneFensterAnzahl()).toBe(2);
138
139 taste('Escape');
140 expect(oben.geschlossen(), 'oberes Fenster bleibt offen').toBe(true);
141 expect(unten.geschlossen(), 'unteres Fenster wurde mitgeschlossen').toBe(false);
142 expect(overlays()).toHaveLength(1);
143
144 taste('Escape');
145 expect(unten.geschlossen()).toBe(true);
146 expect(overlays()).toHaveLength(0);
147 // Der Stapel gibt seinen letzten Eintrag frei - sonst bliebe der
148 // Tastaturempfaenger am Dokument haengen und fasste ein Fenster an, das es
149 // nicht mehr gibt.
150 expect(offeneFensterAnzahl()).toBe(0);
151 });
152
153 it('schliesst mit einem Escape die Rueckfrage ueber einem Fenster, nicht das Fenster', async () => {
154 // Der Schadensfall aus dem Befund: Im gefuehrten Einstieg oeffnet ein
155 // Hilfeknopf ein zweites Fenster. Ein Escape darauf verwarf bis hierher
156 // auch den Assistenten - und `onClose` loest dort mit `null` auf, also
157 // ohne Rueckfrage und ohne die Eingaben aller vier Schritte.
158 const assistent = fenster('Geführter Einstieg');
159 const antwort = confirmDialog({ title: 'Rückfrage', message: 'Wirklich?' });
160 expect(overlays()).toHaveLength(2);
161
162 taste('Escape');
163 expect(await antwort).toBe('abgebrochen');
164 expect(assistent.geschlossen(), 'der Assistent darf nicht mitgehen').toBe(false);
165 expect(overlays()).toHaveLength(1);
166
167 assistent.panel.close();
168 });
169
170 it('holt den Fokus in ein schon offenes Fenster - aber nur, wenn es obenauf liegt', () => {
171 // Der Waechter in showHelp() (main.ts) baut darauf auf: Ein zweiter Druck
172 // auf F1 soll kein zweites, wortgleiches Fenster stapeln, sondern in das
173 // vorhandene zurueckfuehren.
174 const unten = fenster('Kurzhilfe');
175 const oben = fenster('Rückfrage darüber');
176
177 unten.panel.fokussiere();
178 expect(
179 overlays()[0]!.contains(document.activeElement),
180 'Fokus wurde in ein stillgelegtes Fenster gezogen',
181 ).toBe(false);
182
183 oben.panel.close();
184 unten.panel.fokussiere();
185 expect(document.activeElement?.textContent).toContain('Weiter (Kurzhilfe)');
186
187 unten.panel.close();
188 });
189
190 it('gibt den Fokus in das Fenster darunter zurueck, nicht in die stillgelegte Ansicht', async () => {
191 const inhalt = el('div', { id: 'inhalt' });
192 inhalt.tabIndex = -1;
193 document.body.append(inhalt);
194
195 const unten = fenster('Glossar');
196 const oben = fenster('Kurzhilfe');
197 // Der ausloesende Knopf des oberen Fensters steht im unteren; er ist beim
198 // Schliessen noch da, also gehoert der Fokus dorthin zurueck.
199 oben.panel.close();
200 await mikroschritt();
201 expect(document.activeElement?.textContent).toContain('Weiter (Glossar)');
202
203 unten.panel.close();
204 });
205 });
206
207 describe('Fenster-Stapel: der Tabulator bleibt im obersten Fenster (Befund M8)', () => {
208 it('haelt den Fokus in der Rueckfrage, obwohl ein Fenster darunter liegt', async () => {
209 const unten = fenster('Geführter Einstieg');
210 const antwort = confirmDialog({ title: 'Rückfrage', message: 'Wirklich?' });
211
212 const rueckfrage = overlays()[1]!;
213 const bestaetigen = [...rueckfrage.querySelectorAll<HTMLButtonElement>('button')].at(-1)!;
214 bestaetigen.focus();
215 expect(document.activeElement).toBe(bestaetigen);
216
217 taste('Tab');
218 // Die Fokusfalle der Rueckfrage kannte den Rueckfall "der Fokus steht gar
219 // nicht in diesem Dialog" nicht. Die des Fensters darunter kannte ihn und
220 // lief als erster Empfaenger - sie zog den Fokus in einen Teilbaum, den
221 // `legeHintergrundStill` gerade auf aria-hidden und inert gesetzt hatte.
222 expect(rueckfrage.contains(document.activeElement), 'Fokus verliess die Rueckfrage').toBe(true);
223
224 taste('Escape');
225 await antwort;
226 unten.panel.close();
227 });
228
229 it('uebergeht gesperrte Schaltflaechen beim Umlauf', () => {
230 const panel = new Panel({ title: 'Mit gesperrtem Knopf' });
231 const gesperrterKnopf = button({ label: 'Gesperrt', disabled: true, onClick: () => {} });
232 const offenerKnopf = button({ label: 'Offen', onClick: () => {} });
233 panel.setActions(gesperrterKnopf, offenerKnopf);
234
235 offenerKnopf.focus();
236 taste('Tab');
237 // Ein gesperrter Knopf nimmt den Fokus nicht an; ein Umlauf, der ihn
238 // mitzaehlt, laeuft ins Leere.
239 expect(document.activeElement).not.toBe(gesperrterKnopf);
240
241 panel.close();
242 });
243 });
244
245 describe('Fenster-Stapel: Fokusrueckgabe an einen Knoten, den es nicht mehr gibt (Befund M9)', () => {
246 it('faellt auf den Inhaltsbereich zurueck, wenn der Ausloeser beim Schliessen ersetzt ist', async () => {
247 const inhalt = el('div', { id: 'inhalt' });
248 inhalt.tabIndex = -1;
249 document.body.append(inhalt);
250
251 // Die Matrixzelle, aus der das Zwischenzeitenfenster geoeffnet wird: Die
252 // Matrix baut sich bei jeder Eingabe neu auf, die Zelle ist beim Schliessen
253 // laengst ersetzt.
254 const zelle = button({ label: 'K1 räumt, K3 fährt ein', onClick: () => {} });
255 document.body.append(zelle);
256 zelle.focus();
257
258 const panel = new Panel({ title: 'Zwischenzeit' });
259 zelle.remove();
260 panel.close();
261 await mikroschritt();
262
263 // `focus()` auf einem abgehaengten Knoten bleibt wirkungslos - der Fokus
264 // fiel auf den Seitenkoerper, und der naechste Tabulatordruck begann wieder
265 // ganz oben.
266 expect(document.activeElement).toBe(inhalt);
267 });
268
269 it('laesst dem Abschlusshaken den Vortritt, wenn der den Fokus selbst setzt', async () => {
270 /*
271 * KORREKTUR (Befund B2): Der Aufschub auf den Mikroschritt - noetig fuer
272 * M9 - hatte einen Nebenschaden. Im Altstand lief die Rueckgabe SYNCHRON
273 * vor `onClose`; eine Ansicht, die sich im Abschlusshaken neu aufbaut und
274 * den Fokus selbst setzt, gewann damit. Danach feuerte der Mikroschritt
275 * hinterher, fand den Ausloeser abgehaengt und warf den Fokus auf
276 * `#inhalt` - gemessen: unmittelbar nach `close()` stand er auf
277 * "Zwischenzeit bearbeiten", nach dem Mikroschritt auf `div#inhalt`.
278 *
279 * Das ist genau das Muster, das src/ui/fokus.ts empfiehlt, und es waere
280 * hier stillschweigend wirkungslos geworden.
281 */
282 const inhalt = el('div', { id: 'inhalt' });
283 inhalt.tabIndex = -1;
284 document.body.append(inhalt);
285
286 const ausloeser = button({ label: 'Zwischenzeit bearbeiten', onClick: () => {} });
287 document.body.append(ausloeser);
288 ausloeser.focus();
289
290 // Der Nachfolger, den die neu aufgebaute Ansicht im Abschlusshaken setzt.
291 const nachfolger = button({ label: 'Zwischenzeit bearbeiten (neu)', onClick: () => {} });
292
293 const panel = new Panel({ title: 'Zwischenzeit' });
294 panel.onClose(() => {
295 ausloeser.remove();
296 document.body.append(nachfolger);
297 nachfolger.focus();
298 });
299 panel.close();
300
301 expect(document.activeElement, 'sofort nach dem Schliessen').toBe(nachfolger);
302 await mikroschritt();
303 expect(document.activeElement, 'nach dem Mikroschritt').toBe(nachfolger);
304 });
305
306 it('laesst bei "schliessen und gleich das naechste oeffnen" das neue Fenster gewinnen', async () => {
307 /*
308 * Die Kette aus src/ui/help/hilfe.ts: Der Knopf "Glossar" der Kurzhilfe
309 * ruft `panel.close(); zeigeGlossar();`. Dass der Fokus danach im GLOSSAR
310 * steht und nicht auf dem Knopf dahinter, haengt an der Reihenfolge zweier
311 * Mikroschritte - der Fokusrueckgabe und dem des `Panel`-Erbauers. Sie
312 * traegt, war aber nirgends festgehalten (Befund B2).
313 */
314 const inhalt = el('div', { id: 'inhalt' });
315 inhalt.tabIndex = -1;
316 document.body.append(inhalt);
317
318 const ausloeser = button({ label: 'Hilfe', onClick: () => {} });
319 document.body.append(ausloeser);
320 ausloeser.focus();
321
322 const kurzhilfe = fenster('Kurzhilfe');
323 await mikroschritt();
324 kurzhilfe.panel.close();
325 const glossar = fenster('Glossar');
326
327 // Beide Mikroschritte abwarten: der der Rueckgabe und der des Erbauers.
328 await mikroschritt();
329 await mikroschritt();
330 expect(document.activeElement?.textContent).toContain('Weiter (Glossar)');
331
332 glossar.panel.close();
333 });
334 });
335
336 describe('Fenster-Stapel: Klick neben das Fenster erst beim Loslassen (Befund M10, WCAG 2.5.2)', () => {
337 it('schliesst nicht beim blossen Herunterdruecken der Maustaste', () => {
338 const auf = fenster('Geführter Einstieg');
339 const overlay = overlays()[0]!;
340
341 overlay.dispatchEvent(new MouseEvent('mousedown', { bubbles: true }));
342 expect(auf.geschlossen(), 'schon beim Herunterdruecken geschlossen').toBe(false);
343
344 overlay.dispatchEvent(new MouseEvent('mouseup', { bubbles: true }));
345 expect(auf.geschlossen()).toBe(true);
346 });
347
348 it('bricht die Geste ab, wenn der Zeiger vor dem Loslassen auf das Fenster wandert', () => {
349 const auf = fenster('Geführter Einstieg');
350 const overlay = overlays()[0]!;
351 const dialog = overlay.querySelector<HTMLElement>('.dialog')!;
352
353 overlay.dispatchEvent(new MouseEvent('mousedown', { bubbles: true }));
354 dialog.dispatchEvent(new MouseEvent('mouseup', { bubbles: true }));
355 expect(auf.geschlossen(), 'Geste liess sich nicht mehr abbrechen').toBe(false);
356
357 auf.panel.close();
358 });
359 });
360
361 describe('Fenster-Stapel: der Hintergrund wird wirklich stillgelegt (Befund L7)', () => {
362 it('setzt aria-hidden UND inert und nimmt beides beim Schliessen zurueck', () => {
363 const hintergrund = el('div', {}, button({ label: 'Speichern', onClick: () => {} }));
364 document.body.append(hintergrund);
365
366 const auf = fenster('Glossar');
367 expect(hintergrund.getAttribute('aria-hidden')).toBe('true');
368 // Ohne `inert` war der Zustand widerspruechlich: fuer die Sprachausgabe
369 // nicht vorhanden, im Tabulatorlauf aber weiter erreichbar.
370 expect(hintergrund.hasAttribute('inert'), 'Hintergrund bleibt bedienbar').toBe(true);
371
372 auf.panel.close();
373 expect(hintergrund.hasAttribute('aria-hidden')).toBe(false);
374 expect(hintergrund.hasAttribute('inert')).toBe(false);
375 });
376
377 it('nimmt nur zurueck, was es selbst gesetzt hat', () => {
378 const fremd = el('div', { 'aria-hidden': 'true' });
379 document.body.append(fremd);
380
381 const auf = fenster('Glossar');
382 auf.panel.close();
383
384 // Das fremde aria-hidden bleibt: Es ist aus einem anderen Grund gesetzt.
385 expect(fremd.getAttribute('aria-hidden')).toBe('true');
386 expect(fremd.hasAttribute('inert')).toBe(false);
387 });
388
389 it('laesst den Meldungsstapel bedienbar UND hoerbar (Befunde M4 und L7)', () => {
390 /*
391 * KORREKTUR (Befunde B1/B2 bzw. B3): Der Stapel war von `inert`
392 * ausgenommen, von `aria-hidden` aber nicht. Gemessen: `aria-hidden="true"`
393 * am Stapel, `inert` nicht gesetzt. Damit lagen die beiden Live-Bereiche -
394 * role="status" und role="alert" - in einem Teilbaum, den eine
395 * Sprachausgabe nicht kennt (4.1.3, Stufe AA), und zugleich stand dort ein
396 * mit der Tastatur erreichbarer Schliessen-Knopf, zu dem nichts angesagt
397 * wird (4.1.2; genau der Widerspruch, den L7 am Hintergrund ruegt, nur
398 * andersherum).
399 *
400 * Der Prueffall war bis dahin ein Scheinnachweis: Er hiess "laesst den
401 * Meldungsstapel bedienbar" und pruefte allein `inert`.
402 */
403 notify('Die Selbstsicherung ist fehlgeschlagen.', 'warnung');
404 const stapel = document.querySelector<HTMLElement>('.meldungen')!;
405
406 const auf = fenster('Glossar');
407 // Warnungen bleiben mit Absicht stehen, bis sie gelesen sind - ihre
408 // Schliessen-Schaltflaeche muss erreichbar bleiben.
409 expect(stapel.hasAttribute('inert')).toBe(false);
410 expect(stapel.hasAttribute('aria-hidden'), 'aria-hidden am Meldungsstapel').toBe(false);
411
412 // Und zwar bis zu den Live-Bereichen hinunter: Ein aria-hidden an einem
413 // VORFAHREN wirkt genauso.
414 for (const rolle of ['status', 'alert']) {
415 const bereich = document.querySelector<HTMLElement>(`.meldungen [role="${rolle}"]`);
416 expect(bereich, `Ansagebereich role="${rolle}"`).not.toBeNull();
417 expect(
418 bereich!.closest('[aria-hidden="true"]'),
419 `role="${rolle}" liegt in einem verborgenen Teilbaum`,
420 ).toBeNull();
421 }
422
423 auf.panel.close();
424 });
425
426 it('behandelt den Meldungsstapel gleich, gleich wann er entstanden ist', () => {
427 // Zuvor hing das Verhalten davon ab, ob in der Sitzung schon einmal
428 // gemeldet worden war: Ein vorher entstandener Stapel bekam aria-hidden,
429 // ein im Fenster entstandener nicht - dieselbe Bedienhandlung mit zwei
430 // Ergebnissen.
431 const auf = fenster('Zwischenzeiten');
432 notify('„abc" ist keine Zahl. Der vorherige Wert gilt weiter.', 'warnung');
433 const stapel = document.querySelector<HTMLElement>('.meldungen')!;
434
435 expect(stapel.hasAttribute('aria-hidden')).toBe(false);
436 expect(stapel.hasAttribute('inert')).toBe(false);
437
438 auf.panel.close();
439 });
440
441 it('legt alles andere im Hintergrund weiterhin still', () => {
442 // Die Ausnahme gilt genau einem Knoten - sonst waere aus der Behebung von
443 // M4 eine Ruecknahme von L7 geworden.
444 const hintergrund = el('div', {}, button({ label: 'Speichern', onClick: () => {} }));
445 document.body.append(hintergrund);
446 notify('Gespeichert.', 'erfolg');
447
448 const auf = fenster('Glossar');
449 expect(hintergrund.getAttribute('aria-hidden')).toBe('true');
450 expect(hintergrund.hasAttribute('inert')).toBe(true);
451 auf.panel.close();
452 });
453 });
454
455 // --- Meldungen und Statusansagen --------------------------------------------
456
457 describe('Pruefbericht: Filterergebnis wird angesagt, der Fokus bleibt (Befund M7)', () => {
458 it('behaelt den Fokus auf dem gedrueckten Filterknopf', () => {
459 const p = createStandardIntersectionProject('Prüfbericht', FESTES_DATUM);
460 const { wurzel, aufraeumen } = zeichne(reportView, p);
461 try {
462 const fehlerKnopf = [...wurzel.querySelectorAll<HTMLButtonElement>('button')].find((b) =>
463 b.textContent?.startsWith('Fehler ('),
464 )!;
465 fehlerKnopf.focus();
466 fehlerKnopf.click();
467
468 // Der Fokus fiel bis hierher auf den Seitenkoerper, und die
469 // Tabulatorfolge begann wieder am Dokumentanfang.
470 expect(document.activeElement).not.toBe(document.body);
471 expect(document.activeElement?.textContent).toContain('Fehler (');
472 } finally {
473 aufraeumen();
474 }
475 });
476
477 it('nennt Filter und Trefferzahl in einem Live-Bereich, der den Neuaufbau ueberlebt', () => {
478 const p = createStandardIntersectionProject('Prüfbericht', FESTES_DATUM);
479 const bericht = validateProject(p, buildSignalPlan(p), FESTES_DATUM);
480 const { wurzel, aufraeumen } = zeichne(reportView, p);
481 try {
482 const ansage = wurzel.querySelector<HTMLElement>('[role="status"]')!;
483 expect(ansage, 'kein Ansageraum in der Ansicht').not.toBeNull();
484 expect(ansage.textContent).toContain('Anzeige: Alle Beanstandungen');
485 expect(ansage.textContent).toContain(
486 `${bericht.findings.length} von ${bericht.findings.length}`,
487 );
488
489 const fehlerKnopf = [...wurzel.querySelectorAll<HTMLButtonElement>('button')].find((b) =>
490 b.textContent?.startsWith('Fehler ('),
491 )!;
492 fehlerKnopf.click();
493
494 // DERSELBE Knoten, nur mit neuem Text: Ein Live-Bereich, der samt Inhalt
495 // neu eingehaengt wird, sagt nichts an.
496 expect(wurzel.querySelector('[role="status"]')).toBe(ansage);
497 expect(ansage.textContent).toContain('Anzeige: Fehler');
498 expect(ansage.textContent).toContain(`${bericht.errorCount} von ${bericht.findings.length}`);
499 } finally {
500 aufraeumen();
501 }
502 });
503 });
504
505 describe('Glossarsuche sagt die Trefferzahl an (Befund L4)', () => {
506 it('aendert den Text eines dauerhaft vorhandenen Live-Bereichs bei jeder Eingabe', () => {
507 zeigeGlossar();
508 const overlay = overlays()[0]!;
509 const ansage = overlay.querySelector<HTMLElement>('[role="status"]')!;
510 expect(ansage, 'kein Ansageraum im Glossar').not.toBeNull();
511 expect(ansage.textContent).toContain('gefunden');
512
513 const suchfeld = overlay.querySelector<HTMLInputElement>('input[type="search"]')!;
514 suchfeld.value = 'xyzabc';
515 suchfeld.dispatchEvent(new Event('input'));
516
517 expect(overlay.querySelector('[role="status"]'), 'neuer Knoten statt neuem Text').toBe(ansage);
518 expect(ansage.textContent).toBe('Kein Begriff gefunden.');
519
520 suchfeld.value = 'Räumweg';
521 suchfeld.dispatchEvent(new Event('input'));
522 expect(ansage.textContent).toMatch(/^\d+ Begriffe? gefunden\.$/);
523 });
524 });
525
526 describe('Ausgabe und Vorgaben: Live-Bereiche, die wirklich ansagen (Befund L5)', () => {
527 it('sagt den Wechsel der Exportsperre im selben Knoten an', () => {
528 const { wurzel, store, aufraeumen } = zeichne(exportView, gesperrt());
529 try {
530 const ansage = wurzel.querySelector<HTMLElement>('.nur-vorlesen[role="status"]')!;
531 expect(ansage, 'kein Ansageraum in der Ansicht').not.toBeNull();
532 expect(ansage.textContent).toContain('Ausgabe gesperrt');
533
534 // Der sichtbare Sperrgrund traegt keine eigene Rolle mehr - er entsteht
535 // im Neuaufbau und koennte als Live-Bereich nichts ansagen.
536 expect(wurzel.querySelector('.export-sperrgrund')!.getAttribute('role')).toBeNull();
537
538 // Die Sperre faellt, waehrend der Anwender in der Ansicht steht - so wie
539 // nach einem "Rückgängig" in der Kopfzeile.
540 store.update((p) => ({ ...p, demands: p.demands.map((d) => ({ ...d, volume: 300 })) }), {
541 label: 'Verkehrsstärken gesenkt',
542 });
543
544 expect(wurzel.querySelector('.nur-vorlesen[role="status"]')).toBe(ansage);
545 expect(ansage.textContent).toContain('Ausgabe möglich');
546 } finally {
547 aufraeumen();
548 }
549 });
550
551 it('verspricht am Vorgabenbalken keine Ansage, die er nicht leisten kann', () => {
552 const roh = createStandardIntersectionProject('Vorgaben', FESTES_DATUM);
553 const p: Project = {
554 ...roh,
555 anlagenart: 'knotenpunkt',
556 settings: { ...roh.settings, rilsa: { ...roh.settings.rilsa, cycleTimeMax: 250 } },
557 };
558 const { wurzel, aufraeumen } = zeichne(settingsView, p);
559 try {
560 const balken = wurzel.querySelector<HTMLElement>('.hinweisbalken-fehler')!;
561 expect(balken, 'Kein Fehlerbalken fuer die unzulaessige Vorgabe').not.toBeNull();
562 expect(balken.textContent).toContain('1 Vorgabe wird nicht wie eingetragen angesetzt');
563 // Jede geaenderte Vorgabe baut die ganze Ansicht neu auf; der Balken kommt
564 // dabei samt Inhalt neu in den Baum und kann nichts ansagen.
565 expect(balken.getAttribute('role')).toBeNull();
566 } finally {
567 aufraeumen();
568 }
569 });
570 });
571
572 describe('Zahlenfeld: der Zustand richtet sich nach dem, was im Feld steht (Befund L6)', () => {
573 it('nimmt aria-invalid zurueck, wenn der vorherige gueltige Wert wiederhergestellt ist', () => {
574 let gemeldet: number | null = -1;
575 const feld = numberCell(
576 130,
577 (wert) => {
578 gemeldet = wert;
579 },
580 { min: 5, max: 130, einheit: 'km/h' },
581 );
582 feld.setAttribute('aria-label', 'Zulässige Höchstgeschwindigkeit in km/h, K1');
583 document.body.append(feld);
584
585 // Was beim Tippen von "abc" in ein Zahlenfeld wirklich ankommt: Der Browser
586 // - und jsdom mit ihm - laesst den Wert eines `input[type=number]` gar
587 // nicht erst ungueltig werden, gemeldet wird ein leeres Feld. Genau diesen
588 // Weg hat die Pruefung in der laufenden Anwendung nachgestellt: Feldwert
589 // danach "130", aria-invalid="true" am selben Knoten.
590 feld.value = 'abc';
591 feld.dispatchEvent(new Event('change'));
592
593 expect(feld.value, 'vorheriger Wert steht wieder im Feld').toBe('130');
594 expect(gemeldet, 'ein abgelehnter Wert darf nicht weitergereicht werden').toBe(-1);
595 // Die Meldung bleibt - sie ist die Fehleridentifizierung nach 3.3.1, und
596 // sie nennt Feld, Grund und den wiederhergestellten Wert.
597 const meldung = document.querySelector('.meldung-warnung')?.textContent ?? '';
598 expect(meldung).toContain('Zulässige Höchstgeschwindigkeit in km/h, K1');
599 expect(meldung).toContain('Der vorherige Wert 130 wurde wiederhergestellt');
600 // Der Zustand nicht: In dem Feld steht 130, und daran ist nichts ungueltig.
601 // Die Kennzeichnung blieb bis hierher stehen, bis die Ansicht wechselte -
602 // diese beiden Wege rufen `onChange` nicht, es gibt also keinen Neuaufbau,
603 // der den Knoten ersetzt.
604 expect(
605 feld.hasAttribute('aria-invalid'),
606 'gueltiger Wert bleibt als fehlerhaft gekennzeichnet',
607 ).toBe(false);
608 });
609
610 it('haelt die Kennzeichnung bei einem leeren Pflichtfeld ohne vorherigen Wert', () => {
611 const feld = numberCell(null, () => {}, { min: 0, max: 100 });
612 document.body.append(feld);
613
614 feld.value = '';
615 feld.dispatchEvent(new Event('change'));
616
617 // Hier bleibt das Feld leer, und eine Pflichtangabe ist damit weiterhin
618 // offen - die Kennzeichnung beschreibt dann den richtigen Zustand.
619 expect(feld.value).toBe('');
620 expect(feld.getAttribute('aria-invalid')).toBe('true');
621 });
622
623 it('kennzeichnet weiterhin einen uebernommenen Wert ausserhalb der Grenzen', () => {
624 // Gegenprobe zur Aenderung oben: Wo der unzulaessige Wert STEHEN BLEIBT,
625 // gehoert die Kennzeichnung hin - sonst waere die Uebernahme von einer
626 // richtigen Eingabe nicht zu unterscheiden.
627 const feld = numberCell(50, () => {}, {
628 min: 0,
629 max: 100,
630 einheit: '%',
631 ausserhalbUebernehmen: 'Werte außerhalb werden verworfen.',
632 });
633 document.body.append(feld);
634
635 feld.value = '130';
636 feld.dispatchEvent(new Event('change'));
637
638 expect(feld.value).toBe('130');
639 expect(feld.getAttribute('aria-invalid')).toBe('true');
640 });
641 });
642
643 // --- Anzeigedauer der Kurzmeldungen (Befund L8) -----------------------------
644
645 describe('Anzeigedauer der Kurzmeldungen ist einstellbar (Befund L8, WCAG 2.2.1)', () => {
646 /*
647 * 2.2.1 "Zeiteinteilung anpassbar" (Stufe A) verlangt fuer eine vom Inhalt
648 * gesetzte Frist mindestens einen von drei Wegen: abschalten, auf das
649 * Zehnfache verlaengern oder vor Ablauf verlaengern. Hinweise und
650 * Erfolgsmeldungen verschwanden nach fest verdrahteten fuenf Sekunden; das
651 * Anhalten bei Zeiger und Fokus ist keiner der drei, weil es eine
652 * Zeigergeste oder einen Tastaturweg voraussetzt.
653 *
654 * Warnungen und Fehler standen ohnehin nie unter einer Frist - der Fall
655 * unten haelt das fest.
656 */
657 afterEach(() => {
658 setzeMeldungsdauer(5);
659 vi.useRealTimers();
660 });
661
662 function offeneMeldungen(): number {
663 return document.querySelectorAll('.meldung').length;
664 }
665
666 it('streckt die Dauer im selben Verhaeltnis, in dem der Aufrufer sie angibt', () => {
667 vi.useFakeTimers();
668 // Zehnfache Vorgabe - der Wert, den 2.2.1 fuer die Anpassung nennt.
669 setzeMeldungsdauer(50);
670 expect(meldungsdauer()).toBe(50);
671
672 // Der Aufrufer bittet um 2,5 s (so meldet die Kopfzeile "Rueckgaengig:
673 // ..."); gestreckt sind das 25 s.
674 notify('Rückgängig: Phase gelöscht', 'info', 2500);
675 vi.advanceTimersByTime(24_000);
676 expect(offeneMeldungen(), 'zu frueh geschlossen').toBe(1);
677 vi.advanceTimersByTime(2_000);
678 expect(offeneMeldungen()).toBe(0);
679 });
680
681 it('laesst die Meldung stehen, wenn die Dauer auf null steht', () => {
682 vi.useFakeTimers();
683 setzeMeldungsdauer(0);
684 expect(meldungsdauer()).toBe(0);
685
686 notify('Gespeichert.', 'erfolg');
687 vi.advanceTimersByTime(600_000);
688 expect(offeneMeldungen(), 'trotz Abschaltung geschlossen').toBe(1);
689
690 // Von Hand geht sie weiterhin weg - sonst waere aus der Einstellung eine
691 // Falle geworden.
692 document.querySelector<HTMLButtonElement>('.meldung-schliessen')!.click();
693 expect(offeneMeldungen()).toBe(0);
694 });
695
696 it('aendert an der Vorgabe nichts', () => {
697 vi.useFakeTimers();
698 setzeMeldungsdauer(DEFAULT_APP_SETTINGS.meldungsdauerSekunden);
699
700 notify('Gespeichert.', 'erfolg');
701 vi.advanceTimersByTime(4_900);
702 expect(offeneMeldungen()).toBe(1);
703 vi.advanceTimersByTime(200);
704 expect(offeneMeldungen()).toBe(0);
705 });
706
707 it('laesst Warnungen und Fehler unberuehrt stehen', () => {
708 vi.useFakeTimers();
709 setzeMeldungsdauer(5);
710
711 notify('„abc" ist keine Zahl.', 'warnung');
712 vi.advanceTimersByTime(600_000);
713 expect(offeneMeldungen(), 'eine Warnung darf nie von selbst verschwinden').toBe(1);
714 });
715
716 it('weist eine unbrauchbare Angabe ab, statt die Uhr zu verstellen', () => {
717 setzeMeldungsdauer(20);
718 setzeMeldungsdauer(Number.NaN);
719 setzeMeldungsdauer(-5);
720 expect(meldungsdauer()).toBe(20);
721 });
722 });
723
724 describe('Anzeigedauer ueberlebt den Programmstart (Befund L8)', () => {
725 it('nimmt nur die angebotenen Stufen und faellt sonst auf die Vorgabe zurueck', () => {
726 for (const stufe of MELDUNGSDAUER_STUFEN) {
727 saveAppSettings({ ...DEFAULT_APP_SETTINGS, meldungsdauerSekunden: stufe });
728 expect(loadAppSettings().meldungsdauerSekunden).toBe(stufe);
729 }
730
731 // Ein von Hand eingetragener Zwischenwert: Die Auswahlliste zeigte sonst
732 // eine Dauer an, die gar nicht gilt.
733 // Derselbe Schluessel, den saveAppSettings benutzt - sonst pruefte der Fall
734 // an einer Datei vorbei, die gar nicht gelesen wird.
735 const schluessel = Object.keys(window.localStorage).find((k) => k.endsWith('.settings'));
736 expect(schluessel, 'Einstellungen wurden nicht gesichert').toBeDefined();
737 window.localStorage.setItem(
738 schluessel!,
739 JSON.stringify({ ...DEFAULT_APP_SETTINGS, meldungsdauerSekunden: 7 }),
740 );
741 expect(loadAppSettings().meldungsdauerSekunden).toBe(
742 DEFAULT_APP_SETTINGS.meldungsdauerSekunden,
743 );
744 });
745 });