lsa-planer
LSA-Planer Professional – Planungssoftware für Lichtsignalanlagen nach RiLSA 2015 und § 45 StVO. EUPL-1.2.
/ tests ui bfStapelUndAnsageraeume.test.ts
| 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 | }); |