lsa-planer
LSA-Planer Professional – Planungssoftware für Lichtsignalanlagen nach RiLSA 2015 und § 45 StVO. EUPL-1.2.
/ tests ui barrierefreiheit.test.ts
| 1 | import { afterEach, describe, expect, it } from 'vitest'; |
| 2 | import { projectView } from '@/ui/views/projectView'; |
| 3 | import { lageplanView } from '@/ui/views/lageplanView'; |
| 4 | import { signalGroupsView } from '@/ui/views/signalGroupsView'; |
| 5 | import { compatibilityView } from '@/ui/views/compatibilityView'; |
| 6 | import { phasesView } from '@/ui/views/phasesView'; |
| 7 | import { conflictsView } from '@/ui/views/conflictsView'; |
| 8 | import { planView } from '@/ui/views/planView'; |
| 9 | import { simulationView } from '@/ui/views/simulationView'; |
| 10 | import { reportView } from '@/ui/views/reportView'; |
| 11 | import { exportView } from '@/ui/views/exportView'; |
| 12 | import { settingsView } from '@/ui/views/settingsView'; |
| 13 | import { vergleichView } from '@/ui/views/vergleichView'; |
| 14 | import { koordinierungView } from '@/ui/views/koordinierungView'; |
| 15 | import { ASSISTENT_VORGABE, projektAusAngaben, starteAssistent } from '@/ui/assistent'; |
| 16 | import { ProjectStore } from '@/app/store'; |
| 17 | import { Shell, type ViewDefinition } from '@/ui/shell'; |
| 18 | import { Panel, confirmDialog, notify } from '@/ui/feedback'; |
| 19 | import { schliesseHilfe, zeigeGlossar, zeigeHilfe } from '@/ui/help/hilfe'; |
| 20 | import { luftbildDialog } from '@/ui/lageplan/luftbild'; |
| 21 | import { button, el } from '@/ui/dom'; |
| 22 | import { bedienelemente, beschreibe, namensmangel, zugaenglicherName } from './zugaenglicherName'; |
| 23 | |
| 24 | /** |
| 25 | * Barrierefreiheit der Oberflaeche. |
| 26 | * |
| 27 | * Diese Faelle sichern Zusagen ab, die sich sonst still verlieren: Sie gelten |
| 28 | * fuer JEDE Ansicht, also auch fuer die zwoelfte, die noch niemand geschrieben |
| 29 | * hat. Genau deshalb wird ueber die Liste aller Ansichten gelaufen und nicht |
| 30 | * ueber ausgewaehlte Beispiele. |
| 31 | * |
| 32 | * WAS DIESE DATEI SEIT AUGUST 2026 ZUSAETZLICH ERFASST |
| 33 | * |
| 34 | * Sie lief zuvor ausschliesslich ueber die elf Ansichten. Das war die groesste |
| 35 | * Luecke des Testbestandes: Kopfzeile, Navigation, Statusleiste, die |
| 36 | * Kurzmeldungen, die beiden Fensterarten und der gefuehrte Einstieg standen |
| 37 | * ausserhalb - und genau dort lagen die Befunde M1, M4, M11 und L4, die keiner |
| 38 | * der bestehenden Faelle bemerkt hat. Ein Anwender unterscheidet nicht zwischen |
| 39 | * "Ansicht" und "Rahmen"; er bedient eine Oberflaeche. Der zweite Teil dieser |
| 40 | * Datei laeuft deshalb ueber den Rahmen und ueber alles, was sich darueberlegt. |
| 41 | * |
| 42 | * WAS DAVON GEGEN DEN ALTSTAND ROT WIRD - und was nicht. Gegen die Fassung 5.6.0 |
| 43 | * fallen die Namenspruefungen der elf Ansichten (die beiden Auswahlfelder je |
| 44 | * Zeile trugen ihren "Namen" in `title`, und `benenneBedienelemente` uebersprang |
| 45 | * jedes Feld mit `title`; die Reihenfolge- und Loeschknoepfe hiessen "hoch", |
| 46 | * "runter" und "x") und der Navigationsfall (der Eintrag hiess gemessen |
| 47 | * "1Phasen1", ohne das ausgeschriebene "Schritt 1,"). Die Faelle zu |
| 48 | * Kurzmeldungen, Fenstern, Hilfefenstern und Luftbildfenster gehen auch gegen |
| 49 | * den Altstand durch - dort war nichts kaputt, es hat nur nie jemand |
| 50 | * nachgesehen. Sie stehen deshalb nicht als Nachweis einer Behebung hier, |
| 51 | * sondern damit die naechste Aenderung an diesen Stellen nicht wieder |
| 52 | * ungeprueft bleibt. |
| 53 | * |
| 54 | * ABGRENZUNG: Die Ueberschriftenpruefung bleibt ansichtsweise. Ueber den |
| 55 | * gesamten Rahmen gemessen steht die h1 der Ansicht NICHT an erster Stelle - |
| 56 | * davor liegen die vier h2 der Navigationsgruppen. Das ist kein Verstoss: Eine |
| 57 | * h1 muss nicht die erste Ueberschrift eines Dokuments sein, und die Gruppen |
| 58 | * sind ueber `aria-labelledby` sauber an ihre Listen gebunden. Ein Fall, der |
| 59 | * das ruegte, wuerde eine Zusage erfinden, die WCAG nicht kennt. |
| 60 | * |
| 61 | * RECHTLICHER MASSSTAB: EN 301 549 V3.2.1, Kapitel 11 (Software) mit den |
| 62 | * Erfolgskriterien der WCAG 2.1 Stufen A und AA. Die Anwendung richtet sich an |
| 63 | * Strassenverkehrsbehoerden. Das sind oeffentliche Stellen - aber als Landes- |
| 64 | * und Kommunalbehoerden NICHT solche des Bundes: Fuer sie gelten die |
| 65 | * Landesbehindertengleichstellungsgesetze und die jeweilige Landes-BITV, nicht |
| 66 | * die BITV 2.0, die nach Paragraf 12 BGG die Bundesstellen bindet. (Diese Datei |
| 67 | * behauptete bis August 2026 das Gegenteil.) Der Sachmassstab ist ueber |
| 68 | * die EU-Richtlinie 2016/2102 in allen Faellen die EN 301 549, weshalb sich an |
| 69 | * dem, was hier geprueft wird, nichts aendert. |
| 70 | * |
| 71 | * Was sich hier NICHT pruefen laesst: Farbkontraste (jsdom rechnet keine |
| 72 | * Darstellung), tatsaechliche Vorleseergebnisse und die Bedienbarkeit bei |
| 73 | * starker Vergroesserung. Das bleibt Sache der Sichtpruefung. |
| 74 | */ |
| 75 | |
| 76 | const FESTES_DATUM = '2026-01-01T00:00:00.000Z'; |
| 77 | |
| 78 | const ANSICHTEN: readonly ViewDefinition[] = [ |
| 79 | projectView, |
| 80 | lageplanView, |
| 81 | signalGroupsView, |
| 82 | compatibilityView, |
| 83 | phasesView, |
| 84 | conflictsView, |
| 85 | planView, |
| 86 | simulationView, |
| 87 | koordinierungView, |
| 88 | vergleichView, |
| 89 | reportView, |
| 90 | exportView, |
| 91 | settingsView, |
| 92 | ]; |
| 93 | |
| 94 | /** |
| 95 | * Ein Projekt mit Inhalt. |
| 96 | * |
| 97 | * Ein leeres Projekt zeigt in den meisten Ansichten nur einen Leerhinweis - |
| 98 | * gerade die Tabellen mit ihren Eingabefeldern blieben dann ungeprueft. |
| 99 | */ |
| 100 | function gefuellterStore(): ProjectStore { |
| 101 | const project = projektAusAngaben( |
| 102 | { ...ASSISTENT_VORGABE, fussgaenger: true, rad: true, linksabbieger: true }, |
| 103 | FESTES_DATUM, |
| 104 | ); |
| 105 | return new ProjectStore(project); |
| 106 | } |
| 107 | |
| 108 | function zeichne(view: ViewDefinition): { wurzel: HTMLElement; aufraeumen: () => void } { |
| 109 | const store = gefuellterStore(); |
| 110 | const wurzel = document.createElement('div'); |
| 111 | document.body.append(wurzel); |
| 112 | |
| 113 | const aufraeumen = view.render(wurzel, { |
| 114 | store, |
| 115 | refresh: () => {}, |
| 116 | navigate: () => {}, |
| 117 | target: null, |
| 118 | }); |
| 119 | |
| 120 | return { |
| 121 | wurzel, |
| 122 | aufraeumen: () => { |
| 123 | aufraeumen(); |
| 124 | wurzel.remove(); |
| 125 | }, |
| 126 | }; |
| 127 | } |
| 128 | |
| 129 | describe('Jedes Bedienelement hat einen zugaenglichen Namen', () => { |
| 130 | for (const view of ANSICHTEN) { |
| 131 | it(`in der Ansicht "${view.label}"`, () => { |
| 132 | const { wurzel, aufraeumen } = zeichne(view); |
| 133 | try { |
| 134 | const ohneNamen = bedienelemente(wurzel) |
| 135 | .map((e) => ({ e, mangel: namensmangel(e) })) |
| 136 | .filter((x) => x.mangel !== null) |
| 137 | .map((x) => `${beschreibe(x.e)}: ${x.mangel}`); |
| 138 | |
| 139 | // Die Meldung nennt die Elemente, damit ein Fehlschlag unmittelbar |
| 140 | // zeigt, welches Feld unbeschriftet ist. |
| 141 | expect(ohneNamen, `Ohne tragenden Namen in "${view.label}"`).toEqual([]); |
| 142 | } finally { |
| 143 | aufraeumen(); |
| 144 | } |
| 145 | }); |
| 146 | } |
| 147 | }); |
| 148 | |
| 149 | describe('Ueberschriften und Bereiche', () => { |
| 150 | for (const view of ANSICHTEN) { |
| 151 | it(`"${view.label}" hat genau eine Hauptueberschrift`, () => { |
| 152 | const { wurzel, aufraeumen } = zeichne(view); |
| 153 | try { |
| 154 | expect(wurzel.querySelectorAll('h1')).toHaveLength(1); |
| 155 | } finally { |
| 156 | aufraeumen(); |
| 157 | } |
| 158 | }); |
| 159 | |
| 160 | it(`"${view.label}" laesst keine Ueberschriftenebene aus`, () => { |
| 161 | // Eine Sprachausgabe erkundet die Seite ueber die Ueberschriften. Springt |
| 162 | // die Ebene von h1 auf h3, wirkt der Abschnitt untergeordnet, obwohl er |
| 163 | // es nicht ist. |
| 164 | const { wurzel, aufraeumen } = zeichne(view); |
| 165 | try { |
| 166 | const ebenen = [...wurzel.querySelectorAll('h1, h2, h3, h4, h5, h6')].map((h) => |
| 167 | Number(h.tagName.slice(1)), |
| 168 | ); |
| 169 | let vorher = 0; |
| 170 | for (const ebene of ebenen) { |
| 171 | if (vorher !== 0) { |
| 172 | expect( |
| 173 | ebene, |
| 174 | `Sprung von h${vorher} auf h${ebene} in "${view.label}"`, |
| 175 | ).toBeLessThanOrEqual(vorher + 1); |
| 176 | } |
| 177 | vorher = ebene; |
| 178 | } |
| 179 | } finally { |
| 180 | aufraeumen(); |
| 181 | } |
| 182 | }); |
| 183 | } |
| 184 | }); |
| 185 | |
| 186 | describe('Tabellen', () => { |
| 187 | for (const view of ANSICHTEN) { |
| 188 | it(`ordnet in "${view.label}" jeden Spaltenkopf zu`, () => { |
| 189 | // Ohne scope weiss eine Sprachausgabe bei einer Tabelle mit mehreren |
| 190 | // Kopfzeilen nicht, ob ein th die Spalte oder die Zeile beschreibt. Bei |
| 191 | // acht Spalten - so viele hat die Fahrlinientabelle - ist das der |
| 192 | // Unterschied zwischen benutzbar und unbenutzbar. |
| 193 | const { wurzel, aufraeumen } = zeichne(view); |
| 194 | try { |
| 195 | const ohneScope = [...wurzel.querySelectorAll('th')].filter( |
| 196 | (th) => !th.hasAttribute('scope') && (th.textContent ?? '').trim() !== '', |
| 197 | ); |
| 198 | expect( |
| 199 | ohneScope.map((th) => th.textContent?.trim()), |
| 200 | view.label, |
| 201 | ).toEqual([]); |
| 202 | } finally { |
| 203 | aufraeumen(); |
| 204 | } |
| 205 | }); |
| 206 | |
| 207 | it(`gibt jeder Tabelle in "${view.label}" eine Beschriftung`, () => { |
| 208 | const { wurzel, aufraeumen } = zeichne(view); |
| 209 | try { |
| 210 | const ohneNamen = [...wurzel.querySelectorAll('table')].filter( |
| 211 | (t) => |
| 212 | t.querySelector('caption') === null && |
| 213 | !t.hasAttribute('aria-label') && |
| 214 | !t.hasAttribute('aria-labelledby'), |
| 215 | ); |
| 216 | expect(ohneNamen.length, `Tabellen ohne Beschriftung in "${view.label}"`).toBe(0); |
| 217 | } finally { |
| 218 | aufraeumen(); |
| 219 | } |
| 220 | }); |
| 221 | } |
| 222 | }); |
| 223 | |
| 224 | describe('Zeichenflaechen', () => { |
| 225 | for (const view of ANSICHTEN) { |
| 226 | it(`erklaert jede Zeichenflaeche in "${view.label}"`, () => { |
| 227 | // Eine Canvas ist fuer eine Sprachausgabe ein leeres Rechteck. Sie braucht |
| 228 | // einen Namen UND einen Verweis darauf, wo derselbe Inhalt als Text steht. |
| 229 | const { wurzel, aufraeumen } = zeichne(view); |
| 230 | try { |
| 231 | for (const canvas of wurzel.querySelectorAll('canvas')) { |
| 232 | expect( |
| 233 | zugaenglicherName(canvas), |
| 234 | `Zeichenflaeche ohne Namen in "${view.label}"`, |
| 235 | ).not.toBe(''); |
| 236 | expect( |
| 237 | canvas.getAttribute('role'), |
| 238 | `Zeichenflaeche ohne Rolle in "${view.label}"`, |
| 239 | ).not.toBeNull(); |
| 240 | } |
| 241 | } finally { |
| 242 | aufraeumen(); |
| 243 | } |
| 244 | }); |
| 245 | } |
| 246 | }); |
| 247 | |
| 248 | // --------------------------------------------------------------------------- |
| 249 | // Der Rahmen und alles, was sich darueberlegt |
| 250 | // --------------------------------------------------------------------------- |
| 251 | |
| 252 | /** |
| 253 | * Nach jedem Fall wieder ein leerer Seitenkoerper. |
| 254 | * |
| 255 | * Kurzmeldungen und Fenster haengen an `document.body` und nicht an einem |
| 256 | * Behaelter, den der Fall selbst wieder entfernt. Bleibt ein Fenster stehen, |
| 257 | * traegt der naechste Fall dessen `inert`/`aria-hidden` am Hintergrund - und |
| 258 | * `bedienelemente()` liefert dann eine andere Menge, ohne dass etwas am |
| 259 | * Quelltext geaendert waere. |
| 260 | */ |
| 261 | afterEach(() => { |
| 262 | for (const kind of [...document.body.children]) kind.remove(); |
| 263 | }); |
| 264 | |
| 265 | /** Alle Namensmaengel eines Teilbaums, als lesbare Zeilen. */ |
| 266 | function namensmaengel( |
| 267 | wurzel: ParentNode, |
| 268 | ausser: (e: HTMLElement) => boolean = () => false, |
| 269 | ): string[] { |
| 270 | return bedienelemente(wurzel) |
| 271 | .filter((e) => !ausser(e)) |
| 272 | .map((e) => ({ e, mangel: namensmangel(e) })) |
| 273 | .filter((x) => x.mangel !== null) |
| 274 | .map((x) => `${beschreibe(x.e)}: ${x.mangel ?? ''}`); |
| 275 | } |
| 276 | |
| 277 | /** |
| 278 | * Die Kopfzeilenaktionen, wie `src/main.ts` sie anmeldet. |
| 279 | * |
| 280 | * NACHGEBILDET UND NICHT ABGEGRIFFEN - und das ist eine Einschraenkung, die |
| 281 | * benannt gehoert: `src/main.ts` startet die ganze Anwendung beim blossen |
| 282 | * Einlesen (`boot()` steht am Dateiende), einschliesslich Sitzungsspeicher und |
| 283 | * Electron-Bruecke. Ein Test kann die Liste dort deshalb nicht abfragen. |
| 284 | * |
| 285 | * Geprueft wird damit, dass die Shell aus einer angemeldeten Aktion eine |
| 286 | * benannte Schaltflaeche baut - nicht, dass jede kuenftige Aktion in main.ts |
| 287 | * eine Beschriftung bekommt. Die Beschriftungen sind hier aus main.ts |
| 288 | * uebernommen, damit der Fall am wirklichen Bestand laeuft und nicht an einem |
| 289 | * Kunstbeispiel. |
| 290 | */ |
| 291 | const KOPFAKTIONEN: readonly { readonly label: string; readonly title: string }[] = [ |
| 292 | { label: 'Neu', title: 'Neues Projekt anlegen (Strg+N)' }, |
| 293 | { label: 'Öffnen', title: 'Projektdatei öffnen (Strg+O)' }, |
| 294 | { label: 'Speichern', title: 'Projekt speichern (Strg+S)' }, |
| 295 | { label: 'Speichern unter', title: 'Projekt unter neuem Namen speichern (Strg+Umschalt+S)' }, |
| 296 | { label: 'Rückgängig', title: 'Letzte Änderung rückgängig machen (Strg+Z)' }, |
| 297 | { label: 'Wiederherstellen', title: 'Änderung wiederherstellen (Strg+Y)' }, |
| 298 | { |
| 299 | label: 'Ansicht: System', |
| 300 | title: 'Zwischen hellem, dunklem und Systemerscheinungsbild wechseln', |
| 301 | }, |
| 302 | { label: 'Glossar', title: 'Alle Fachbegriffe nachschlagen' }, |
| 303 | { label: 'Meldungen', title: 'Die Kurzmeldungen dieser Sitzung noch einmal ansehen' }, |
| 304 | { label: 'Hilfe', title: 'Kurzhilfe und Tastenkürzel anzeigen (F1)' }, |
| 305 | ]; |
| 306 | |
| 307 | interface Rahmen { |
| 308 | readonly wurzel: HTMLElement; |
| 309 | readonly shell: Shell; |
| 310 | readonly store: ProjectStore; |
| 311 | readonly aufraeumen: () => void; |
| 312 | } |
| 313 | |
| 314 | function baueRahmen(): Rahmen { |
| 315 | const store = gefuellterStore(); |
| 316 | const wurzel = document.createElement('div'); |
| 317 | wurzel.id = 'anwendung'; |
| 318 | document.body.append(wurzel); |
| 319 | |
| 320 | const shell = new Shell(wurzel, store, '0.0.0-pruefung'); |
| 321 | for (const view of ANSICHTEN) shell.addView(view); |
| 322 | for (const aktion of KOPFAKTIONEN) { |
| 323 | shell.addHeaderAction({ label: aktion.label, title: aktion.title, run: () => {} }); |
| 324 | } |
| 325 | shell.start('projekt'); |
| 326 | |
| 327 | return { |
| 328 | wurzel, |
| 329 | shell, |
| 330 | store, |
| 331 | aufraeumen: () => { |
| 332 | shell.stop(); |
| 333 | wurzel.remove(); |
| 334 | }, |
| 335 | }; |
| 336 | } |
| 337 | |
| 338 | /** Alles im Rahmen, was zur gerade gezeichneten Ansicht gehoert. */ |
| 339 | function inDerAnsicht(element: HTMLElement): boolean { |
| 340 | return element.closest('.ansicht-halter') !== null; |
| 341 | } |
| 342 | |
| 343 | describe('Rahmen: Kopfzeile, Navigation und Statusleiste', () => { |
| 344 | it('benennt jedes Bedienelement ausserhalb der Ansicht', () => { |
| 345 | const rahmen = baueRahmen(); |
| 346 | try { |
| 347 | expect(namensmaengel(rahmen.wurzel, inDerAnsicht)).toEqual([]); |
| 348 | } finally { |
| 349 | rahmen.aufraeumen(); |
| 350 | } |
| 351 | }); |
| 352 | |
| 353 | it('nennt in jedem Navigationseintrag Schritt und Ansichtsnamen', () => { |
| 354 | const rahmen = baueRahmen(); |
| 355 | try { |
| 356 | const namen = [...rahmen.wurzel.querySelectorAll<HTMLElement>('.nav-knopf')].map( |
| 357 | zugaenglicherName, |
| 358 | ); |
| 359 | expect(namen).toHaveLength(ANSICHTEN.length); |
| 360 | namen.forEach((name, i) => { |
| 361 | // Die sichtbare Ziffer ist aria-hidden; ohne das ausgeschriebene |
| 362 | // "Schritt N," hiess der Eintrag gemessen "1Phasen1 Fehler". |
| 363 | expect(name, `Navigationseintrag ${i + 1}`).toContain(`Schritt ${i + 1},`); |
| 364 | expect(name, `Navigationseintrag ${i + 1}`).toContain(ANSICHTEN[i]?.label ?? ''); |
| 365 | }); |
| 366 | } finally { |
| 367 | rahmen.aufraeumen(); |
| 368 | } |
| 369 | }); |
| 370 | |
| 371 | it('gibt Navigation, Inhalt und Statusleiste je einen Namen', () => { |
| 372 | // Ohne Namen meldet eine Sprachausgabe beim Durchgehen der Bereiche nur |
| 373 | // "Navigation", "Hauptbereich", "Fusszeile" und laesst offen, was darin |
| 374 | // steht. |
| 375 | const rahmen = baueRahmen(); |
| 376 | try { |
| 377 | for (const auswahl of ['nav', 'main', 'footer'] as const) { |
| 378 | const knoten = rahmen.wurzel.querySelector(auswahl); |
| 379 | expect(knoten, `${auswahl} fehlt`).not.toBeNull(); |
| 380 | expect((knoten?.getAttribute('aria-label') ?? '').trim(), `${auswahl} ohne Namen`).not.toBe( |
| 381 | '', |
| 382 | ); |
| 383 | } |
| 384 | } finally { |
| 385 | rahmen.aufraeumen(); |
| 386 | } |
| 387 | }); |
| 388 | |
| 389 | it('haelt genau eine Hauptueberschrift im ganzen Rahmen', () => { |
| 390 | // Die Kopfzeile verzichtet mit Absicht auf eine eigene h1: Zwei |
| 391 | // gleichrangige Hauptueberschriften, davon eine auf jeder Seite dieselbe, |
| 392 | // machen die Ueberschriftengliederung unbrauchbar. |
| 393 | const rahmen = baueRahmen(); |
| 394 | try { |
| 395 | expect(rahmen.wurzel.querySelectorAll('h1')).toHaveLength(1); |
| 396 | expect(rahmen.wurzel.querySelector('header')?.querySelector('h1') ?? null).toBeNull(); |
| 397 | } finally { |
| 398 | rahmen.aufraeumen(); |
| 399 | } |
| 400 | }); |
| 401 | |
| 402 | it('bindet jede Navigationsgruppe an ihren sichtbaren Titel', () => { |
| 403 | const rahmen = baueRahmen(); |
| 404 | try { |
| 405 | const listen = [...rahmen.wurzel.querySelectorAll('.nav-liste')]; |
| 406 | expect(listen.length).toBeGreaterThan(0); |
| 407 | for (const liste of listen) { |
| 408 | const id = liste.getAttribute('aria-labelledby'); |
| 409 | expect(id, 'Navigationsliste ohne aria-labelledby').not.toBeNull(); |
| 410 | const titel = rahmen.wurzel.ownerDocument.getElementById(id ?? ''); |
| 411 | expect(titel, `Kein Gruppentitel zu "${id ?? ''}"`).not.toBeNull(); |
| 412 | expect((titel?.textContent ?? '').trim()).not.toBe(''); |
| 413 | } |
| 414 | } finally { |
| 415 | rahmen.aufraeumen(); |
| 416 | } |
| 417 | }); |
| 418 | |
| 419 | it('fuehrt einen Ansageraum, der den Wechsel der Ansicht ueberdauert', () => { |
| 420 | const rahmen = baueRahmen(); |
| 421 | try { |
| 422 | // `:scope >`, weil auch der Pruefbericht einen eigenen Ansageraum |
| 423 | // mitbringt; gemeint ist hier der des Rahmens. |
| 424 | const ansage = rahmen.wurzel.querySelector(':scope > .nur-vorlesen[role="status"]'); |
| 425 | expect(ansage, 'kein Ansageraum im Rahmen').not.toBeNull(); |
| 426 | expect(ansage?.textContent).toContain('Umlaufzeit'); |
| 427 | rahmen.shell.navigate('pruefung'); |
| 428 | // Derselbe Knoten - ein Live-Bereich, der mit seinem Inhalt neu in den |
| 429 | // Baum kommt, sagt nichts an. |
| 430 | expect(rahmen.wurzel.querySelector(':scope > .nur-vorlesen[role="status"]')).toBe(ansage); |
| 431 | } finally { |
| 432 | rahmen.aufraeumen(); |
| 433 | } |
| 434 | }); |
| 435 | }); |
| 436 | |
| 437 | describe('Kurzmeldungen', () => { |
| 438 | const ARTEN = ['info', 'erfolg', 'warnung', 'fehler'] as const; |
| 439 | |
| 440 | for (const art of ARTEN) { |
| 441 | it(`benennt die Schliessen-Schaltflaeche einer Meldung der Art "${art}"`, () => { |
| 442 | notify(`Prüfmeldung ${art}`, art); |
| 443 | const stapel = document.querySelector<HTMLElement>('.meldungen'); |
| 444 | expect(stapel, 'kein Meldungsstapel').not.toBeNull(); |
| 445 | expect(namensmaengel(stapel ?? document.body)).toEqual([]); |
| 446 | }); |
| 447 | } |
| 448 | |
| 449 | it('haelt zwei dauerhafte Live-Bereiche mit getrennter Dringlichkeit', () => { |
| 450 | // Hinweise hoeflich, Warnungen bestimmt: Wer nicht sieht, dass eine Eingabe |
| 451 | // abgelehnt wurde, arbeitet sonst auf einem Stand weiter, den es nicht gibt. |
| 452 | notify('Hinweis zur Prüfung', 'info'); |
| 453 | notify('Warnung zur Prüfung', 'warnung'); |
| 454 | const stapel = document.querySelector<HTMLElement>('.meldungen'); |
| 455 | const hoeflich = stapel?.querySelector('[role="status"][aria-live="polite"]') ?? null; |
| 456 | const bestimmt = stapel?.querySelector('[role="alert"][aria-live="assertive"]') ?? null; |
| 457 | expect(hoeflich, 'kein hoeflicher Bereich').not.toBeNull(); |
| 458 | expect(bestimmt, 'kein bestimmter Bereich').not.toBeNull(); |
| 459 | expect(hoeflich?.textContent).toContain('Hinweis zur Prüfung'); |
| 460 | expect(bestimmt?.textContent).toContain('Warnung zur Prüfung'); |
| 461 | }); |
| 462 | |
| 463 | it('haengt jede weitere Meldung in denselben Bereich, statt ihn zu ersetzen', () => { |
| 464 | notify('Erste', 'info'); |
| 465 | const bereich = document.querySelector('.meldungen [role="status"]'); |
| 466 | notify('Zweite', 'info'); |
| 467 | expect(document.querySelector('.meldungen [role="status"]')).toBe(bereich); |
| 468 | expect(bereich?.querySelectorAll('.meldung')).toHaveLength(2); |
| 469 | }); |
| 470 | }); |
| 471 | |
| 472 | describe('Fenster: Rueckfrage und Panel', () => { |
| 473 | /** Die erste Schaltflaeche im Aktionsbereich - dort steht "Abbrechen". */ |
| 474 | function schliesseFenster(): void { |
| 475 | document.querySelector<HTMLButtonElement>('.dialog-aktionen button')?.click(); |
| 476 | } |
| 477 | |
| 478 | it('benennt jedes Bedienelement der Rueckfrage', async () => { |
| 479 | const antwort = confirmDialog({ |
| 480 | title: 'Ungespeicherte Änderungen', |
| 481 | message: 'Am Projekt bestehen ungespeicherte Änderungen.', |
| 482 | confirmLabel: 'Verwerfen und neu beginnen', |
| 483 | extraLabel: 'Vorher speichern', |
| 484 | danger: true, |
| 485 | }); |
| 486 | const dialog = document.querySelector<HTMLElement>('.dialog'); |
| 487 | expect(dialog, 'keine Rueckfrage im Baum').not.toBeNull(); |
| 488 | expect(namensmaengel(dialog ?? document.body)).toEqual([]); |
| 489 | |
| 490 | schliesseFenster(); |
| 491 | expect(await antwort).toBe('abgebrochen'); |
| 492 | }); |
| 493 | |
| 494 | it('gibt der Rueckfrage Rolle, Modalitaet und einen aufloesbaren Namen', async () => { |
| 495 | const antwort = confirmDialog({ |
| 496 | title: 'Bild ersetzen?', |
| 497 | message: 'Es sind 3 Fahrlinien gezeichnet.', |
| 498 | }); |
| 499 | const dialog = document.querySelector<HTMLElement>('.dialog'); |
| 500 | expect(dialog?.getAttribute('role')).toBe('dialog'); |
| 501 | expect(dialog?.getAttribute('aria-modal')).toBe('true'); |
| 502 | // Der Name muss sich auch aufloesen lassen: Zwei uebereinanderliegende |
| 503 | // Fenster trugen zuvor dieselbe feste Kennung, und aria-labelledby zeigte |
| 504 | // auf den Titel des ERSTEN. |
| 505 | expect(zugaenglicherName(dialog ?? document.body)).toBe('Bild ersetzen?'); |
| 506 | // Die Entscheidungsgrundlage steht im Text, nicht im Titel. |
| 507 | const beschriebenVon = dialog?.getAttribute('aria-describedby') ?? ''; |
| 508 | expect(beschriebenVon).not.toBe(''); |
| 509 | expect(document.getElementById(beschriebenVon)?.textContent).toContain('Fahrlinien'); |
| 510 | |
| 511 | schliesseFenster(); |
| 512 | await antwort; |
| 513 | }); |
| 514 | |
| 515 | it('benennt jedes Bedienelement eines Panels samt Schliessen-Knopf', () => { |
| 516 | const panel = new Panel({ title: 'Haltlinie festlegen', width: 520 }); |
| 517 | try { |
| 518 | panel.setContent(el('p', { text: 'An dieser Haltlinie beginnen die Ströme der Zufahrt.' })); |
| 519 | panel.setActions( |
| 520 | button({ label: 'Abbrechen', onClick: () => {} }), |
| 521 | button({ label: 'Haltlinie anlegen', variant: 'primaer', onClick: () => {} }), |
| 522 | ); |
| 523 | const dialog = document.querySelector<HTMLElement>('.dialog'); |
| 524 | expect(namensmaengel(dialog ?? document.body)).toEqual([]); |
| 525 | // Der Schliessen-Knopf traegt sichtbar nur ein Kreuz - ohne aria-label |
| 526 | // hiesse er fuer eine Sprachausgabe genau so. |
| 527 | const schliessen = dialog?.querySelector<HTMLElement>('.dialog-schliessen'); |
| 528 | expect(schliessen, 'kein Schliessen-Knopf').not.toBeNull(); |
| 529 | expect(zugaenglicherName(schliessen ?? document.body)).toBe('Schließen'); |
| 530 | } finally { |
| 531 | panel.close(); |
| 532 | } |
| 533 | }); |
| 534 | |
| 535 | it('nennt den Titel eines Panels auch nach dem Weiterblaettern', () => { |
| 536 | const panel = new Panel({ title: 'Erster Titel' }); |
| 537 | try { |
| 538 | const dialog = document.querySelector<HTMLElement>('.dialog'); |
| 539 | expect(zugaenglicherName(dialog ?? document.body)).toBe('Erster Titel'); |
| 540 | panel.setTitle('Zweiter Titel'); |
| 541 | // Ueber die gemerkte Ueberschrift und nicht ueber eine feste Kennung: |
| 542 | // Die Kennungen werden fortlaufend nummeriert, ein festes "#panel-titel" |
| 543 | // traf nach dem zweiten Fenster nichts mehr. |
| 544 | expect(zugaenglicherName(dialog ?? document.body)).toBe('Zweiter Titel'); |
| 545 | } finally { |
| 546 | panel.close(); |
| 547 | } |
| 548 | }); |
| 549 | }); |
| 550 | |
| 551 | describe('Gefuehrter Einstieg', () => { |
| 552 | /** Der Assistent von Schritt zu Schritt, mit einer Pruefung je Schritt. */ |
| 553 | async function durchlaufe(pruefe: (dialog: HTMLElement, schritt: number) => void): Promise<void> { |
| 554 | const ergebnis = starteAssistent(); |
| 555 | // Schranke gegen eine Endlosschleife, falls "Weiter" den Schritt einmal |
| 556 | // nicht weiterzaehlt. |
| 557 | const HOECHSTZAHL = 12; |
| 558 | let schritt = 0; |
| 559 | for (; schritt < HOECHSTZAHL; schritt += 1) { |
| 560 | const dialog = document.querySelector<HTMLElement>('.dialog'); |
| 561 | expect(dialog, `kein Fenster in Schritt ${schritt + 1}`).not.toBeNull(); |
| 562 | pruefe(dialog ?? document.body, schritt); |
| 563 | |
| 564 | const weiter = [ |
| 565 | ...(dialog?.querySelectorAll<HTMLButtonElement>('.dialog-aktionen button') ?? []), |
| 566 | ].find((b) => (b.textContent ?? '').trim() === 'Weiter'); |
| 567 | if (weiter === undefined) break; |
| 568 | weiter.click(); |
| 569 | } |
| 570 | expect(schritt, 'Der Assistent hat nie einen letzten Schritt erreicht').toBeLessThan( |
| 571 | HOECHSTZAHL, |
| 572 | ); |
| 573 | |
| 574 | const abbrechen = [ |
| 575 | ...document.querySelectorAll<HTMLButtonElement>('.dialog-aktionen button'), |
| 576 | ].find((b) => (b.textContent ?? '').trim() === 'Abbrechen'); |
| 577 | expect(abbrechen, 'kein Abbrechen im letzten Schritt').not.toBeNull(); |
| 578 | abbrechen?.click(); |
| 579 | expect(await ergebnis).toBeNull(); |
| 580 | } |
| 581 | |
| 582 | it('benennt in jedem Schritt jedes Bedienelement', async () => { |
| 583 | // Der gefuehrte Einstieg stand bis August 2026 vollstaendig |
| 584 | // ausserhalb der Testabdeckung - und er ist der erste Bildschirm, den ein |
| 585 | // neuer Anwender ueberhaupt sieht. |
| 586 | const maengel: string[] = []; |
| 587 | await durchlaufe((dialog, schritt) => { |
| 588 | for (const zeile of namensmaengel(dialog)) maengel.push(`Schritt ${schritt + 1}: ${zeile}`); |
| 589 | }); |
| 590 | expect(maengel).toEqual([]); |
| 591 | }); |
| 592 | |
| 593 | it('laesst in keinem Schritt eine Ueberschriftenebene aus', async () => { |
| 594 | await durchlaufe((dialog, schritt) => { |
| 595 | const ebenen = [...dialog.querySelectorAll('h1, h2, h3, h4, h5, h6')].map((h) => |
| 596 | Number(h.tagName.slice(1)), |
| 597 | ); |
| 598 | let vorher = 0; |
| 599 | for (const ebene of ebenen) { |
| 600 | if (vorher !== 0) { |
| 601 | expect( |
| 602 | ebene, |
| 603 | `Sprung von h${vorher} auf h${ebene} in Schritt ${schritt + 1}`, |
| 604 | ).toBeLessThanOrEqual(vorher + 1); |
| 605 | } |
| 606 | vorher = ebene; |
| 607 | } |
| 608 | }); |
| 609 | }); |
| 610 | |
| 611 | it('sagt in Fensterueberschrift und Schrittueberschrift, der wievielte Schritt ansteht', async () => { |
| 612 | // Der Fokus springt bei jedem Schritt auf die Ueberschrift. Stuende dort |
| 613 | // nur die Frage, waere der Schrittwechsel nicht zu hoeren - eine reine |
| 614 | // Textaenderung am Fenstertitel sagen die meisten Sprachausgaben nicht an. |
| 615 | await durchlaufe((dialog, schritt) => { |
| 616 | const h3 = dialog.querySelector('h3'); |
| 617 | expect(h3, `keine Schrittueberschrift in Schritt ${schritt + 1}`).not.toBeNull(); |
| 618 | expect(h3?.textContent).toContain(`Schritt ${schritt + 1} von `); |
| 619 | expect(zugaenglicherName(dialog)).toContain(`Schritt ${schritt + 1} von `); |
| 620 | }); |
| 621 | }); |
| 622 | }); |
| 623 | |
| 624 | describe('Hilfefenster und Luftbildfenster', () => { |
| 625 | it('benennt jedes Bedienelement des Glossars', () => { |
| 626 | // Das Glossar ist der Weg, auf dem ein Einsteiger die Fachbegriffe klaert - |
| 627 | // und es stand bis August 2026 ausserhalb jeder Pruefung. |
| 628 | zeigeGlossar(); |
| 629 | try { |
| 630 | const dialog = document.querySelector<HTMLElement>('.dialog'); |
| 631 | expect(dialog, 'kein Glossarfenster').not.toBeNull(); |
| 632 | expect(namensmaengel(dialog ?? document.body)).toEqual([]); |
| 633 | // Die Trefferzahl steht in einem dauerhaften Ansageraum: Die Liste wird |
| 634 | // bei jedem Tastendruck ausgetauscht, angesagt wird die Zahl. |
| 635 | const ansage = dialog?.querySelector('[role="status"][aria-live="polite"]'); |
| 636 | expect(ansage, 'kein Ansageraum im Glossar').not.toBeNull(); |
| 637 | } finally { |
| 638 | schliesseHilfe(); |
| 639 | } |
| 640 | }); |
| 641 | |
| 642 | it('benennt jedes Bedienelement eines Hilfefensters', () => { |
| 643 | zeigeHilfe('raeumweg'); |
| 644 | try { |
| 645 | const dialog = document.querySelector<HTMLElement>('.dialog'); |
| 646 | expect(dialog, 'kein Hilfefenster').not.toBeNull(); |
| 647 | expect(namensmaengel(dialog ?? document.body)).toEqual([]); |
| 648 | expect(zugaenglicherName(dialog ?? document.body)).not.toBe(''); |
| 649 | } finally { |
| 650 | schliesseHilfe(); |
| 651 | } |
| 652 | }); |
| 653 | |
| 654 | it('benennt jedes Bedienelement der Ortseingabe zum Luftbild', async () => { |
| 655 | // Der Weg zum Hintergrundbild - und damit zu jeder Vermessung. Er fuehrt |
| 656 | // ueber ein eigenes Fenster mit Suchfeld, Trefferliste und Massstabswahl. |
| 657 | const ergebnis = luftbildDialog(); |
| 658 | try { |
| 659 | const dialog = document.querySelector<HTMLElement>('.dialog'); |
| 660 | expect(dialog, 'kein Luftbildfenster').not.toBeNull(); |
| 661 | expect(namensmaengel(dialog ?? document.body)).toEqual([]); |
| 662 | } finally { |
| 663 | const abbrechen = [ |
| 664 | ...document.querySelectorAll<HTMLButtonElement>('.dialog-aktionen button'), |
| 665 | ].find((b) => (b.textContent ?? '').trim() === 'Abbrechen'); |
| 666 | abbrechen?.click(); |
| 667 | document.querySelector<HTMLElement>('.dialog-schliessen')?.click(); |
| 668 | await ergebnis; |
| 669 | } |
| 670 | }); |
| 671 | }); |