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