lsa-planer

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

/ tests ui barrierefreiheit.test.ts

26,5 KB Rohdatei
tests/ui/barrierefreiheit.test.ts — 671 Zeilen
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 });