lsa-planer

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

/ tests ui bfStapelUndAnsageraeume.test.ts

14,8 KB Rohdatei
tests/ui/bfStapelUndAnsageraeume.test.ts — 380 Zeilen
1 import { afterEach, describe, expect, it } from 'vitest';
2 import { ProjectStore } from '@/app/store';
3 import { ASSISTENT_VORGABE, projektAusAngaben } from '@/ui/assistent';
4 import { Panel, confirmDialog, offeneFensterAnzahl } from '@/ui/feedback';
5 import { button, el } from '@/ui/dom';
6 import type { ViewDefinition } from '@/ui/shell';
7 import { projectView } from '@/ui/views/projectView';
8 import { lageplanView } from '@/ui/views/lageplanView';
9 import { signalGroupsView } from '@/ui/views/signalGroupsView';
10 import { compatibilityView } from '@/ui/views/compatibilityView';
11 import { phasesView } from '@/ui/views/phasesView';
12 import { conflictsView } from '@/ui/views/conflictsView';
13 import { planView } from '@/ui/views/planView';
14 import { simulationView } from '@/ui/views/simulationView';
15 import { reportView } from '@/ui/views/reportView';
16 import { exportView } from '@/ui/views/exportView';
17 import { settingsView } from '@/ui/views/settingsView';
18 import { vergleichView } from '@/ui/views/vergleichView';
19 import { koordinierungView } from '@/ui/views/koordinierungView';
20
21 /**
22 * Zwei Prueflucken im Testbestand zur Barrierefreiheit.
23 *
24 * (c) DER FENSTER-STAPEL. `tests/ui/bfFensterUndMeldungen.test.ts` deckt
25 * bereits ab, was die Befunde M1, M8, M9, M10 und L7 unmittelbar
26 * verlangen: Escape bei ZWEI Fenstern, die Fokusfalle des oberen, die
27 * Fokusrueckgabe, den Zeigerabbruch und das Stilllegen eines Hintergrunds aus
28 * gewoehnlichen Knoten. Hier steht ausschliesslich, was dort NICHT steht und
29 * was den Stapel als Stapel betrifft:
30 *
31 * - mehr als zwei Ebenen, in umgekehrter Reihenfolge abgebaut,
32 * - ein Fenster, das UNTER einem anderen liegt, ist selbst stillgelegt -
33 * geprueft wurde bisher nur ein Hintergrund aus einfachen Knoten,
34 * - ein Fenster, das nicht von oben, sondern aus der Mitte des Stapels
35 * geschlossen wird (der Regelfall, wenn ein Aufrufer `panel.close()`
36 * selbst ruft),
37 * - der Stapel raeumt sich vollstaendig ab und laesst keinen Empfaenger
38 * zurueck, der auf ein nicht mehr vorhandenes Fenster zeigt.
39 *
40 * (d) DIE KNOTENIDENTITAET DER LIVE-BEREICHE. Ein `role="status"`, das
41 * zusammen mit seinem Inhalt neu in den Baum kommt, sagt nichts an: Angesagt
42 * wird die AENDERUNG eines vorhandenen Live-Bereichs, nicht das Auftauchen
43 * eines neuen. Genau daran scheiterten die Balken aus Befund L5 - und niemand
44 * hat es bemerkt, weil kein Test die Frage ueberhaupt gestellt hat. Die
45 * Einzelfaelle zu Pruefbericht, Ausgabe und Glossar stehen in
46 * `bfFensterUndMeldungen.test.ts`; hier laeuft die Regel ueber ALLE Ansichten,
47 * damit sie auch fuer die zwoelfte gilt.
48 *
49 * Gegen den Altstand schlagen die Faelle fehl: Dort schloss EIN Escape alle
50 * Fenster auf einmal, und in Pruefbericht wie Ausgabe entstand der Live-Bereich
51 * innerhalb des ausgetauschten Teils.
52 */
53
54 const FESTES_DATUM = '2026-01-01T00:00:00.000Z';
55
56 afterEach(() => {
57 document.body.replaceChildren();
58 });
59
60 // --- (c) Der Fenster-Stapel -------------------------------------------------
61
62 /** Ein Fenster mit einem Fokusziel darin, das seinen Zustand mitschreibt. */
63 function fenster(titel: string): { panel: Panel; offen: () => boolean } {
64 let zu = false;
65 const panel = new Panel({ title: titel });
66 panel.onClose(() => {
67 zu = true;
68 });
69 panel.setActions(button({ label: `Weiter (${titel})`, onClick: () => {} }));
70 return { panel, offen: () => !zu };
71 }
72
73 function overlays(): HTMLElement[] {
74 return [...document.querySelectorAll<HTMLElement>('.dialog-hintergrund')];
75 }
76
77 /** Tastendruck, wie ihn der Browser liefert: am Fokus, aufsteigend, abweisbar. */
78 function taste(key: string): void {
79 const ziel = document.activeElement ?? document.body;
80 ziel.dispatchEvent(new KeyboardEvent('keydown', { key, bubbles: true, cancelable: true }));
81 }
82
83 describe('Fenster-Stapel: mehr als zwei Ebenen', () => {
84 it('baut drei Fenster in umgekehrter Reihenfolge ab, eines je Escape', () => {
85 /*
86 * Drei Ebenen sind keine Erfindung: Der gefuehrte Einstieg ist ein Fenster,
87 * seine Felder tragen Hilfeknoepfe, und ein Hilfefenster kann seinerseits
88 * eine Rueckfrage stellen. Ein Stapel, der bei zweien richtig liegt, kann
89 * bei dreien dennoch falsch abbauen - etwa wenn er statt des obersten den
90 * zuletzt ANGELEGTEN Eintrag schliesst.
91 */
92 const unten = fenster('Geführter Einstieg');
93 const mitte = fenster('Hilfe zu Räumweg');
94 const oben = fenster('Glossar');
95 expect(offeneFensterAnzahl()).toBe(3);
96
97 taste('Escape');
98 expect([unten.offen(), mitte.offen(), oben.offen()], 'nach dem ersten Escape').toEqual([
99 true,
100 true,
101 false,
102 ]);
103
104 taste('Escape');
105 expect([unten.offen(), mitte.offen(), oben.offen()], 'nach dem zweiten Escape').toEqual([
106 true,
107 false,
108 false,
109 ]);
110
111 taste('Escape');
112 expect([unten.offen(), mitte.offen(), oben.offen()], 'nach dem dritten Escape').toEqual([
113 false,
114 false,
115 false,
116 ]);
117 expect(offeneFensterAnzahl()).toBe(0);
118 expect(overlays()).toHaveLength(0);
119 });
120
121 it('legt auch ein Fenster stumm, das unter einem anderen liegt', () => {
122 /*
123 * Bisher geprueft war nur ein Hintergrund aus gewoehnlichen Knoten. Das
124 * untere FENSTER ist aber derselbe Fall: Es steht als Kind des
125 * Seitenkoerpers neben dem oberen, und wer es nicht stilllegt, laesst eine
126 * Sprachausgabe zwei modale Fenster gleichzeitig vorlesen und den Zeiger
127 * das verdeckte bedienen.
128 */
129 const unten = fenster('Geführter Einstieg');
130 const untenOverlay = overlays()[0];
131 expect(untenOverlay, 'kein unteres Fenster').not.toBeUndefined();
132
133 const oben = fenster('Hilfe');
134 expect(untenOverlay?.getAttribute('aria-hidden')).toBe('true');
135 expect(untenOverlay?.hasAttribute('inert'), 'unteres Fenster bleibt bedienbar').toBe(true);
136
137 oben.panel.close();
138 // Und wieder frei: Sonst waere das untere Fenster nach dem Schliessen des
139 // oberen dauerhaft unbedienbar - der Anwender saesse in einem Fenster
140 // fest, das keine Taste mehr annimmt.
141 expect(untenOverlay?.hasAttribute('aria-hidden')).toBe(false);
142 expect(untenOverlay?.hasAttribute('inert')).toBe(false);
143
144 unten.panel.close();
145 });
146
147 it('nimmt ein Fenster aus der Mitte des Stapels, ohne die Reihenfolge zu verlieren', () => {
148 /*
149 * Ein Fenster wird nicht nur von oben geschlossen: `frageLaenge` im
150 * Lageplan ruft `panel.close()` selbst, und der Waechter der Kurzhilfe
151 * schliesst ein vorhandenes Fenster, ehe er ein neues oeffnet. Wuerde der
152 * Stapel dabei blind den letzten Eintrag abtragen, schloesse der naechste
153 * Escape das falsche Fenster - und im gefuehrten Einstieg heisst das:
154 * Eingaben aus vier Schritten weg, ohne Rueckfrage.
155 */
156 const unten = fenster('Geführter Einstieg');
157 const mitte = fenster('Maßstab festlegen');
158 const oben = fenster('Rückfrage');
159
160 mitte.panel.close();
161 expect(offeneFensterAnzahl()).toBe(2);
162
163 taste('Escape');
164 expect(oben.offen(), 'Escape traf nicht das oberste Fenster').toBe(false);
165 expect(unten.offen(), 'das unterste Fenster wurde mitgeschlossen').toBe(true);
166
167 unten.panel.close();
168 expect(offeneFensterAnzahl()).toBe(0);
169 });
170
171 it('laesst nach dem letzten Fenster keinen Tastaturempfaenger zurueck', async () => {
172 // Ein Empfaenger, der am Dokument haengen bleibt, faengt jedes weitere
173 // Escape der Anwendung ab: `stopImmediatePropagation` unterbindet dann
174 // Tastendruecke, die niemandem mehr gehoeren.
175 const auf = fenster('Glossar');
176 auf.panel.close();
177 expect(offeneFensterAnzahl()).toBe(0);
178
179 let durchgelassen = false;
180 const empfaenger = (): void => {
181 durchgelassen = true;
182 };
183 document.addEventListener('keydown', empfaenger);
184 try {
185 taste('Escape');
186 expect(durchgelassen, 'Escape wurde von einem verwaisten Empfaenger abgefangen').toBe(true);
187 } finally {
188 document.removeEventListener('keydown', empfaenger);
189 }
190
191 // Und die Rueckfrage danach verhaelt sich wieder wie die erste.
192 const antwort = confirmDialog({ title: 'Rückfrage', message: 'Wirklich?' });
193 expect(offeneFensterAnzahl()).toBe(1);
194 taste('Escape');
195 expect(await antwort).toBe('abgebrochen');
196 expect(offeneFensterAnzahl()).toBe(0);
197 });
198 });
199
200 // --- (d) Knotenidentitaet der Live-Bereiche ---------------------------------
201
202 const ANSICHTEN: readonly ViewDefinition[] = [
203 projectView,
204 lageplanView,
205 signalGroupsView,
206 compatibilityView,
207 phasesView,
208 conflictsView,
209 planView,
210 simulationView,
211 koordinierungView,
212 vergleichView,
213 reportView,
214 exportView,
215 settingsView,
216 ];
217
218 function gefuellterStore(): ProjectStore {
219 return new ProjectStore(
220 projektAusAngaben(
221 { ...ASSISTENT_VORGABE, fussgaenger: true, rad: true, linksabbieger: true },
222 FESTES_DATUM,
223 ),
224 );
225 }
226
227 interface Gezeichnet {
228 readonly wurzel: HTMLElement;
229 readonly store: ProjectStore;
230 readonly aufraeumen: () => void;
231 }
232
233 function zeichne(view: ViewDefinition): Gezeichnet {
234 const store = gefuellterStore();
235 const wurzel = el('div', {});
236 document.body.append(wurzel);
237 const schliessen = view.render(wurzel, {
238 store,
239 refresh: () => {},
240 navigate: () => {},
241 target: null,
242 });
243 return {
244 wurzel,
245 store,
246 aufraeumen: () => {
247 schliessen();
248 wurzel.remove();
249 },
250 };
251 }
252
253 /**
254 * Alle Ansageraeume eines Teilbaums.
255 *
256 * ABGEGRENZT AN `aria-live` UND NICHT AN `role="status"` - das ist eine
257 * Entscheidung, die begruendet gehoert, weil `role="status"` ein `aria-live`
258 * von sich aus mitbringt und die Auswahl damit auf den ersten Blick zu eng
259 * aussieht.
260 *
261 * Die Anwendung fuehrt beide Faelle getrennt, und die Trennung ist an der
262 * Auszeichnung abzulesen (nachgezaehlt: alle Ansageraeume tragen beides, genau
263 * ein Balken traegt nur die Rolle):
264 *
265 * - ANSAGERAUM - beides gesetzt. Ein Knoten, der stehen bleibt und dessen
266 * TEXT sich aendert: der Rahmen, der Pruefbericht, die Ausgabe, die
267 * Zeigerlage im Lageplan, die Glossarsuche. Nur hier greift Befund L5:
268 * Wird der Knoten samt Inhalt ersetzt, geht die Ansage verloren, weil
269 * angesagt wird, was sich IN einem vorhandenen Live-Bereich aendert.
270 *
271 * - EINMALBALKEN - nur `role="status"`. Ein Knoten mit festem Text, der ein-
272 * und ausgeblendet wird; angesagt wird sein Erscheinen. In der ganzen
273 * Anwendung ist das ein einziger: der Vermerk "Rechenweg abgewählt" in
274 * der Ausgabe. Sein Text aendert sich nie, und der Weg, auf dem er
275 * erscheint - das Kontrollkaestchen - baut nichts neu auf. Ihn hier
276 * mitzuzaehlen hiesse, einen Mangel zu behaupten, den es nicht gibt.
277 *
278 * Damit die Grenze nicht stillschweigend verrutscht, ist sie selbst ein
279 * Prueffall: Der zweite Fall unten laesst genau die benannten Einmalbalken zu
280 * und faellt bei jedem weiteren `role="status"` ohne `aria-live` aus.
281 */
282 function ansageraeume(wurzel: ParentNode): HTMLElement[] {
283 return [...wurzel.querySelectorAll<HTMLElement>('[aria-live]')];
284 }
285
286 describe('Ansageraeume ueberleben einen Neuaufbau als DERSELBE Knoten', () => {
287 for (const view of ANSICHTEN) {
288 it(`in der Ansicht "${view.label}"`, () => {
289 /*
290 * Der Neuaufbau, um den es geht, ist der, den der Anwender nicht
291 * ausloest: "Rueckgaengig" in der Kopfzeile, die Selbstsicherung, ein
292 * zweites Fenster, das in den Speicher schreibt. Er trifft die Ansicht,
293 * waehrend jemand darin steht - und genau dann soll die Ansage kommen.
294 *
295 * Ansichten ohne Zustandsempfaenger bauen dabei nichts neu auf; fuer sie
296 * ist der Fall eine Zusage fuer die Zukunft. Das ist Absicht: Wer
297 * spaeter einen Empfaenger nachruestet und dabei den Ansageraum in den
298 * ausgetauschten Teil legt, faellt hier auf.
299 */
300 const { wurzel, store, aufraeumen } = zeichne(view);
301 try {
302 const vorher = ansageraeume(wurzel);
303
304 store.update((p) => ({ ...p, meta: { ...p.meta, variant: 'Prüfvariante' } }), {
305 label: 'Variante geändert',
306 });
307
308 const abgehaengt = vorher.filter((k) => !wurzel.contains(k));
309 expect(
310 abgehaengt.map((k) => `${k.tagName.toLowerCase()}.${k.className}`),
311 `Ansageraum in "${view.label}" wurde beim Neuaufbau ersetzt statt beschriftet`,
312 ).toEqual([]);
313 } finally {
314 aufraeumen();
315 }
316 });
317 }
318 });
319
320 /**
321 * Die einzigen Live-Bereiche, die mit Absicht ohne `aria-live` auskommen.
322 *
323 * Ein Einmalbalken hat einen festen Text und wird nur ein- und ausgeblendet.
324 * Die Liste steht hier, damit ein neuer Balken nicht unbemerkt in den
325 * ausgetauschten Teil einer Ansicht geraet: Wer einen anlegt, muss sich hier
326 * dazu erklaeren.
327 */
328 const EINMALBALKEN: Readonly<Record<string, readonly string[]>> = {
329 // "Rechenweg abgewählt" in der Ausgabe (Befund C15). Nach Ansicht UND Klasse
330 // aufgeschluesselt: "hinweisbalken-warnung" ist die gewoehnlichste Klasse der
331 // Anwendung, und ein Balken der Vorgabenansicht mit derselben Klasse waere
332 // ein ganz anderer Fall.
333 export: ['hinweisbalken hinweisbalken-warnung'],
334 };
335
336 describe('Die Grenze zwischen Ansageraum und Einmalbalken ist benannt', () => {
337 for (const view of ANSICHTEN) {
338 it(`in der Ansicht "${view.label}"`, () => {
339 const { wurzel, aufraeumen } = zeichne(view);
340 try {
341 const zugelassen = EINMALBALKEN[view.id] ?? [];
342 const ohneLive = [
343 ...wurzel.querySelectorAll<HTMLElement>('[role="status"], [role="alert"]'),
344 ].filter((k) => !k.hasAttribute('aria-live'));
345 expect(
346 ohneLive.map((k) => k.className).filter((k) => !zugelassen.includes(k)),
347 `Neuer Live-Bereich ohne aria-live in "${view.label}" - Ansageraum oder Einmalbalken?`,
348 ).toEqual([]);
349 } finally {
350 aufraeumen();
351 }
352 });
353 }
354 });
355
356 describe('Lageplan: die Zeigerlage wird beschriftet, nicht ersetzt', () => {
357 it('bleibt derselbe Knoten, wenn das Werkzeug gewechselt wird', () => {
358 /*
359 * Der Werkzeugwechsel baut die ganze Seitenleiste neu auf. Die Zeigerlage
360 * ist der Bereich, der bei jedem Tastenschritt sagt, wo der Zeiger steht
361 * und ob er auf eine Haltlinie einrastet - fuer eine Vermessung ohne Maus
362 * die einzige Rueckmeldung. Waere sie Teil des ausgetauschten Bereichs,
363 * bliebe sie nach dem ersten Werkzeugwechsel stumm.
364 */
365 const { wurzel, aufraeumen } = zeichne(lageplanView);
366 try {
367 const zeigerlage = wurzel.querySelector<HTMLElement>('.lageplan-rahmen [role="status"]');
368 expect(zeigerlage, 'keine Zeigerlage im Rahmen').not.toBeNull();
369 expect(zeigerlage?.getAttribute('aria-live')).toBe('polite');
370
371 const ansehen = [...wurzel.querySelectorAll<HTMLButtonElement>('.lageplan-werkzeuge button')];
372 expect(ansehen.length, 'keine Werkzeugknoepfe').toBeGreaterThan(0);
373 ansehen[0]?.click();
374
375 expect(wurzel.querySelector('.lageplan-rahmen [role="status"]')).toBe(zeigerlage);
376 } finally {
377 aufraeumen();
378 }
379 });
380 });