import { afterEach, describe, expect, it } from 'vitest'; import { projectView } from '@/ui/views/projectView'; import { lageplanView } from '@/ui/views/lageplanView'; import { signalGroupsView } from '@/ui/views/signalGroupsView'; import { compatibilityView } from '@/ui/views/compatibilityView'; import { phasesView } from '@/ui/views/phasesView'; import { conflictsView } from '@/ui/views/conflictsView'; import { planView } from '@/ui/views/planView'; import { simulationView } from '@/ui/views/simulationView'; import { reportView } from '@/ui/views/reportView'; import { exportView } from '@/ui/views/exportView'; import { settingsView } from '@/ui/views/settingsView'; import { vergleichView } from '@/ui/views/vergleichView'; import { koordinierungView } from '@/ui/views/koordinierungView'; import { ASSISTENT_VORGABE, projektAusAngaben, starteAssistent } from '@/ui/assistent'; import { ProjectStore } from '@/app/store'; import { Shell, type ViewDefinition } from '@/ui/shell'; import { Panel, confirmDialog, notify } from '@/ui/feedback'; import { schliesseHilfe, zeigeGlossar, zeigeHilfe } from '@/ui/help/hilfe'; import { luftbildDialog } from '@/ui/lageplan/luftbild'; import { button, el } from '@/ui/dom'; import { bedienelemente, beschreibe, namensmangel, zugaenglicherName } from './zugaenglicherName'; /** * Barrierefreiheit der Oberflaeche. * * Diese Faelle sichern Zusagen ab, die sich sonst still verlieren: Sie gelten * fuer JEDE Ansicht, also auch fuer die zwoelfte, die noch niemand geschrieben * hat. Genau deshalb wird ueber die Liste aller Ansichten gelaufen und nicht * ueber ausgewaehlte Beispiele. * * WAS DIESE DATEI SEIT AUGUST 2026 ZUSAETZLICH ERFASST * * Sie lief zuvor ausschliesslich ueber die elf Ansichten. Das war die groesste * Luecke des Testbestandes: Kopfzeile, Navigation, Statusleiste, die * Kurzmeldungen, die beiden Fensterarten und der gefuehrte Einstieg standen * ausserhalb - und genau dort lagen die Befunde M1, M4, M11 und L4, die keiner * der bestehenden Faelle bemerkt hat. Ein Anwender unterscheidet nicht zwischen * "Ansicht" und "Rahmen"; er bedient eine Oberflaeche. Der zweite Teil dieser * Datei laeuft deshalb ueber den Rahmen und ueber alles, was sich darueberlegt. * * WAS DAVON GEGEN DEN ALTSTAND ROT WIRD - und was nicht. Gegen die Fassung 5.6.0 * fallen die Namenspruefungen der elf Ansichten (die beiden Auswahlfelder je * Zeile trugen ihren "Namen" in `title`, und `benenneBedienelemente` uebersprang * jedes Feld mit `title`; die Reihenfolge- und Loeschknoepfe hiessen "hoch", * "runter" und "x") und der Navigationsfall (der Eintrag hiess gemessen * "1Phasen1", ohne das ausgeschriebene "Schritt 1,"). Die Faelle zu * Kurzmeldungen, Fenstern, Hilfefenstern und Luftbildfenster gehen auch gegen * den Altstand durch - dort war nichts kaputt, es hat nur nie jemand * nachgesehen. Sie stehen deshalb nicht als Nachweis einer Behebung hier, * sondern damit die naechste Aenderung an diesen Stellen nicht wieder * ungeprueft bleibt. * * ABGRENZUNG: Die Ueberschriftenpruefung bleibt ansichtsweise. Ueber den * gesamten Rahmen gemessen steht die h1 der Ansicht NICHT an erster Stelle - * davor liegen die vier h2 der Navigationsgruppen. Das ist kein Verstoss: Eine * h1 muss nicht die erste Ueberschrift eines Dokuments sein, und die Gruppen * sind ueber `aria-labelledby` sauber an ihre Listen gebunden. Ein Fall, der * das ruegte, wuerde eine Zusage erfinden, die WCAG nicht kennt. * * RECHTLICHER MASSSTAB: EN 301 549 V3.2.1, Kapitel 11 (Software) mit den * Erfolgskriterien der WCAG 2.1 Stufen A und AA. Die Anwendung richtet sich an * Strassenverkehrsbehoerden. Das sind oeffentliche Stellen - aber als Landes- * und Kommunalbehoerden NICHT solche des Bundes: Fuer sie gelten die * Landesbehindertengleichstellungsgesetze und die jeweilige Landes-BITV, nicht * die BITV 2.0, die nach Paragraf 12 BGG die Bundesstellen bindet. (Diese Datei * behauptete bis August 2026 das Gegenteil.) Der Sachmassstab ist ueber * die EU-Richtlinie 2016/2102 in allen Faellen die EN 301 549, weshalb sich an * dem, was hier geprueft wird, nichts aendert. * * Was sich hier NICHT pruefen laesst: Farbkontraste (jsdom rechnet keine * Darstellung), tatsaechliche Vorleseergebnisse und die Bedienbarkeit bei * starker Vergroesserung. Das bleibt Sache der Sichtpruefung. */ const FESTES_DATUM = '2026-01-01T00:00:00.000Z'; const ANSICHTEN: readonly ViewDefinition[] = [ projectView, lageplanView, signalGroupsView, compatibilityView, phasesView, conflictsView, planView, simulationView, koordinierungView, vergleichView, reportView, exportView, settingsView, ]; /** * Ein Projekt mit Inhalt. * * Ein leeres Projekt zeigt in den meisten Ansichten nur einen Leerhinweis - * gerade die Tabellen mit ihren Eingabefeldern blieben dann ungeprueft. */ function gefuellterStore(): ProjectStore { const project = projektAusAngaben( { ...ASSISTENT_VORGABE, fussgaenger: true, rad: true, linksabbieger: true }, FESTES_DATUM, ); return new ProjectStore(project); } function zeichne(view: ViewDefinition): { wurzel: HTMLElement; aufraeumen: () => void } { const store = gefuellterStore(); const wurzel = document.createElement('div'); document.body.append(wurzel); const aufraeumen = view.render(wurzel, { store, refresh: () => {}, navigate: () => {}, target: null, }); return { wurzel, aufraeumen: () => { aufraeumen(); wurzel.remove(); }, }; } describe('Jedes Bedienelement hat einen zugaenglichen Namen', () => { for (const view of ANSICHTEN) { it(`in der Ansicht "${view.label}"`, () => { const { wurzel, aufraeumen } = zeichne(view); try { const ohneNamen = bedienelemente(wurzel) .map((e) => ({ e, mangel: namensmangel(e) })) .filter((x) => x.mangel !== null) .map((x) => `${beschreibe(x.e)}: ${x.mangel}`); // Die Meldung nennt die Elemente, damit ein Fehlschlag unmittelbar // zeigt, welches Feld unbeschriftet ist. expect(ohneNamen, `Ohne tragenden Namen in "${view.label}"`).toEqual([]); } finally { aufraeumen(); } }); } }); describe('Ueberschriften und Bereiche', () => { for (const view of ANSICHTEN) { it(`"${view.label}" hat genau eine Hauptueberschrift`, () => { const { wurzel, aufraeumen } = zeichne(view); try { expect(wurzel.querySelectorAll('h1')).toHaveLength(1); } finally { aufraeumen(); } }); it(`"${view.label}" laesst keine Ueberschriftenebene aus`, () => { // Eine Sprachausgabe erkundet die Seite ueber die Ueberschriften. Springt // die Ebene von h1 auf h3, wirkt der Abschnitt untergeordnet, obwohl er // es nicht ist. const { wurzel, aufraeumen } = zeichne(view); try { const ebenen = [...wurzel.querySelectorAll('h1, h2, h3, h4, h5, h6')].map((h) => Number(h.tagName.slice(1)), ); let vorher = 0; for (const ebene of ebenen) { if (vorher !== 0) { expect( ebene, `Sprung von h${vorher} auf h${ebene} in "${view.label}"`, ).toBeLessThanOrEqual(vorher + 1); } vorher = ebene; } } finally { aufraeumen(); } }); } }); describe('Tabellen', () => { for (const view of ANSICHTEN) { it(`ordnet in "${view.label}" jeden Spaltenkopf zu`, () => { // Ohne scope weiss eine Sprachausgabe bei einer Tabelle mit mehreren // Kopfzeilen nicht, ob ein th die Spalte oder die Zeile beschreibt. Bei // acht Spalten - so viele hat die Fahrlinientabelle - ist das der // Unterschied zwischen benutzbar und unbenutzbar. const { wurzel, aufraeumen } = zeichne(view); try { const ohneScope = [...wurzel.querySelectorAll('th')].filter( (th) => !th.hasAttribute('scope') && (th.textContent ?? '').trim() !== '', ); expect( ohneScope.map((th) => th.textContent?.trim()), view.label, ).toEqual([]); } finally { aufraeumen(); } }); it(`gibt jeder Tabelle in "${view.label}" eine Beschriftung`, () => { const { wurzel, aufraeumen } = zeichne(view); try { const ohneNamen = [...wurzel.querySelectorAll('table')].filter( (t) => t.querySelector('caption') === null && !t.hasAttribute('aria-label') && !t.hasAttribute('aria-labelledby'), ); expect(ohneNamen.length, `Tabellen ohne Beschriftung in "${view.label}"`).toBe(0); } finally { aufraeumen(); } }); } }); describe('Zeichenflaechen', () => { for (const view of ANSICHTEN) { it(`erklaert jede Zeichenflaeche in "${view.label}"`, () => { // Eine Canvas ist fuer eine Sprachausgabe ein leeres Rechteck. Sie braucht // einen Namen UND einen Verweis darauf, wo derselbe Inhalt als Text steht. const { wurzel, aufraeumen } = zeichne(view); try { for (const canvas of wurzel.querySelectorAll('canvas')) { expect( zugaenglicherName(canvas), `Zeichenflaeche ohne Namen in "${view.label}"`, ).not.toBe(''); expect( canvas.getAttribute('role'), `Zeichenflaeche ohne Rolle in "${view.label}"`, ).not.toBeNull(); } } finally { aufraeumen(); } }); } }); // --------------------------------------------------------------------------- // Der Rahmen und alles, was sich darueberlegt // --------------------------------------------------------------------------- /** * Nach jedem Fall wieder ein leerer Seitenkoerper. * * Kurzmeldungen und Fenster haengen an `document.body` und nicht an einem * Behaelter, den der Fall selbst wieder entfernt. Bleibt ein Fenster stehen, * traegt der naechste Fall dessen `inert`/`aria-hidden` am Hintergrund - und * `bedienelemente()` liefert dann eine andere Menge, ohne dass etwas am * Quelltext geaendert waere. */ afterEach(() => { for (const kind of [...document.body.children]) kind.remove(); }); /** Alle Namensmaengel eines Teilbaums, als lesbare Zeilen. */ function namensmaengel( wurzel: ParentNode, ausser: (e: HTMLElement) => boolean = () => false, ): string[] { return bedienelemente(wurzel) .filter((e) => !ausser(e)) .map((e) => ({ e, mangel: namensmangel(e) })) .filter((x) => x.mangel !== null) .map((x) => `${beschreibe(x.e)}: ${x.mangel ?? ''}`); } /** * Die Kopfzeilenaktionen, wie `src/main.ts` sie anmeldet. * * NACHGEBILDET UND NICHT ABGEGRIFFEN - und das ist eine Einschraenkung, die * benannt gehoert: `src/main.ts` startet die ganze Anwendung beim blossen * Einlesen (`boot()` steht am Dateiende), einschliesslich Sitzungsspeicher und * Electron-Bruecke. Ein Test kann die Liste dort deshalb nicht abfragen. * * Geprueft wird damit, dass die Shell aus einer angemeldeten Aktion eine * benannte Schaltflaeche baut - nicht, dass jede kuenftige Aktion in main.ts * eine Beschriftung bekommt. Die Beschriftungen sind hier aus main.ts * uebernommen, damit der Fall am wirklichen Bestand laeuft und nicht an einem * Kunstbeispiel. */ const KOPFAKTIONEN: readonly { readonly label: string; readonly title: string }[] = [ { label: 'Neu', title: 'Neues Projekt anlegen (Strg+N)' }, { label: 'Öffnen', title: 'Projektdatei öffnen (Strg+O)' }, { label: 'Speichern', title: 'Projekt speichern (Strg+S)' }, { label: 'Speichern unter', title: 'Projekt unter neuem Namen speichern (Strg+Umschalt+S)' }, { label: 'Rückgängig', title: 'Letzte Änderung rückgängig machen (Strg+Z)' }, { label: 'Wiederherstellen', title: 'Änderung wiederherstellen (Strg+Y)' }, { label: 'Ansicht: System', title: 'Zwischen hellem, dunklem und Systemerscheinungsbild wechseln', }, { label: 'Glossar', title: 'Alle Fachbegriffe nachschlagen' }, { label: 'Meldungen', title: 'Die Kurzmeldungen dieser Sitzung noch einmal ansehen' }, { label: 'Hilfe', title: 'Kurzhilfe und Tastenkürzel anzeigen (F1)' }, ]; interface Rahmen { readonly wurzel: HTMLElement; readonly shell: Shell; readonly store: ProjectStore; readonly aufraeumen: () => void; } function baueRahmen(): Rahmen { const store = gefuellterStore(); const wurzel = document.createElement('div'); wurzel.id = 'anwendung'; document.body.append(wurzel); const shell = new Shell(wurzel, store, '0.0.0-pruefung'); for (const view of ANSICHTEN) shell.addView(view); for (const aktion of KOPFAKTIONEN) { shell.addHeaderAction({ label: aktion.label, title: aktion.title, run: () => {} }); } shell.start('projekt'); return { wurzel, shell, store, aufraeumen: () => { shell.stop(); wurzel.remove(); }, }; } /** Alles im Rahmen, was zur gerade gezeichneten Ansicht gehoert. */ function inDerAnsicht(element: HTMLElement): boolean { return element.closest('.ansicht-halter') !== null; } describe('Rahmen: Kopfzeile, Navigation und Statusleiste', () => { it('benennt jedes Bedienelement ausserhalb der Ansicht', () => { const rahmen = baueRahmen(); try { expect(namensmaengel(rahmen.wurzel, inDerAnsicht)).toEqual([]); } finally { rahmen.aufraeumen(); } }); it('nennt in jedem Navigationseintrag Schritt und Ansichtsnamen', () => { const rahmen = baueRahmen(); try { const namen = [...rahmen.wurzel.querySelectorAll('.nav-knopf')].map( zugaenglicherName, ); expect(namen).toHaveLength(ANSICHTEN.length); namen.forEach((name, i) => { // Die sichtbare Ziffer ist aria-hidden; ohne das ausgeschriebene // "Schritt N," hiess der Eintrag gemessen "1Phasen1 Fehler". expect(name, `Navigationseintrag ${i + 1}`).toContain(`Schritt ${i + 1},`); expect(name, `Navigationseintrag ${i + 1}`).toContain(ANSICHTEN[i]?.label ?? ''); }); } finally { rahmen.aufraeumen(); } }); it('gibt Navigation, Inhalt und Statusleiste je einen Namen', () => { // Ohne Namen meldet eine Sprachausgabe beim Durchgehen der Bereiche nur // "Navigation", "Hauptbereich", "Fusszeile" und laesst offen, was darin // steht. const rahmen = baueRahmen(); try { for (const auswahl of ['nav', 'main', 'footer'] as const) { const knoten = rahmen.wurzel.querySelector(auswahl); expect(knoten, `${auswahl} fehlt`).not.toBeNull(); expect((knoten?.getAttribute('aria-label') ?? '').trim(), `${auswahl} ohne Namen`).not.toBe( '', ); } } finally { rahmen.aufraeumen(); } }); it('haelt genau eine Hauptueberschrift im ganzen Rahmen', () => { // Die Kopfzeile verzichtet mit Absicht auf eine eigene h1: Zwei // gleichrangige Hauptueberschriften, davon eine auf jeder Seite dieselbe, // machen die Ueberschriftengliederung unbrauchbar. const rahmen = baueRahmen(); try { expect(rahmen.wurzel.querySelectorAll('h1')).toHaveLength(1); expect(rahmen.wurzel.querySelector('header')?.querySelector('h1') ?? null).toBeNull(); } finally { rahmen.aufraeumen(); } }); it('bindet jede Navigationsgruppe an ihren sichtbaren Titel', () => { const rahmen = baueRahmen(); try { const listen = [...rahmen.wurzel.querySelectorAll('.nav-liste')]; expect(listen.length).toBeGreaterThan(0); for (const liste of listen) { const id = liste.getAttribute('aria-labelledby'); expect(id, 'Navigationsliste ohne aria-labelledby').not.toBeNull(); const titel = rahmen.wurzel.ownerDocument.getElementById(id ?? ''); expect(titel, `Kein Gruppentitel zu "${id ?? ''}"`).not.toBeNull(); expect((titel?.textContent ?? '').trim()).not.toBe(''); } } finally { rahmen.aufraeumen(); } }); it('fuehrt einen Ansageraum, der den Wechsel der Ansicht ueberdauert', () => { const rahmen = baueRahmen(); try { // `:scope >`, weil auch der Pruefbericht einen eigenen Ansageraum // mitbringt; gemeint ist hier der des Rahmens. const ansage = rahmen.wurzel.querySelector(':scope > .nur-vorlesen[role="status"]'); expect(ansage, 'kein Ansageraum im Rahmen').not.toBeNull(); expect(ansage?.textContent).toContain('Umlaufzeit'); rahmen.shell.navigate('pruefung'); // Derselbe Knoten - ein Live-Bereich, der mit seinem Inhalt neu in den // Baum kommt, sagt nichts an. expect(rahmen.wurzel.querySelector(':scope > .nur-vorlesen[role="status"]')).toBe(ansage); } finally { rahmen.aufraeumen(); } }); }); describe('Kurzmeldungen', () => { const ARTEN = ['info', 'erfolg', 'warnung', 'fehler'] as const; for (const art of ARTEN) { it(`benennt die Schliessen-Schaltflaeche einer Meldung der Art "${art}"`, () => { notify(`Prüfmeldung ${art}`, art); const stapel = document.querySelector('.meldungen'); expect(stapel, 'kein Meldungsstapel').not.toBeNull(); expect(namensmaengel(stapel ?? document.body)).toEqual([]); }); } it('haelt zwei dauerhafte Live-Bereiche mit getrennter Dringlichkeit', () => { // Hinweise hoeflich, Warnungen bestimmt: Wer nicht sieht, dass eine Eingabe // abgelehnt wurde, arbeitet sonst auf einem Stand weiter, den es nicht gibt. notify('Hinweis zur Prüfung', 'info'); notify('Warnung zur Prüfung', 'warnung'); const stapel = document.querySelector('.meldungen'); const hoeflich = stapel?.querySelector('[role="status"][aria-live="polite"]') ?? null; const bestimmt = stapel?.querySelector('[role="alert"][aria-live="assertive"]') ?? null; expect(hoeflich, 'kein hoeflicher Bereich').not.toBeNull(); expect(bestimmt, 'kein bestimmter Bereich').not.toBeNull(); expect(hoeflich?.textContent).toContain('Hinweis zur Prüfung'); expect(bestimmt?.textContent).toContain('Warnung zur Prüfung'); }); it('haengt jede weitere Meldung in denselben Bereich, statt ihn zu ersetzen', () => { notify('Erste', 'info'); const bereich = document.querySelector('.meldungen [role="status"]'); notify('Zweite', 'info'); expect(document.querySelector('.meldungen [role="status"]')).toBe(bereich); expect(bereich?.querySelectorAll('.meldung')).toHaveLength(2); }); }); describe('Fenster: Rueckfrage und Panel', () => { /** Die erste Schaltflaeche im Aktionsbereich - dort steht "Abbrechen". */ function schliesseFenster(): void { document.querySelector('.dialog-aktionen button')?.click(); } it('benennt jedes Bedienelement der Rueckfrage', async () => { const antwort = confirmDialog({ title: 'Ungespeicherte Änderungen', message: 'Am Projekt bestehen ungespeicherte Änderungen.', confirmLabel: 'Verwerfen und neu beginnen', extraLabel: 'Vorher speichern', danger: true, }); const dialog = document.querySelector('.dialog'); expect(dialog, 'keine Rueckfrage im Baum').not.toBeNull(); expect(namensmaengel(dialog ?? document.body)).toEqual([]); schliesseFenster(); expect(await antwort).toBe('abgebrochen'); }); it('gibt der Rueckfrage Rolle, Modalitaet und einen aufloesbaren Namen', async () => { const antwort = confirmDialog({ title: 'Bild ersetzen?', message: 'Es sind 3 Fahrlinien gezeichnet.', }); const dialog = document.querySelector('.dialog'); expect(dialog?.getAttribute('role')).toBe('dialog'); expect(dialog?.getAttribute('aria-modal')).toBe('true'); // Der Name muss sich auch aufloesen lassen: Zwei uebereinanderliegende // Fenster trugen zuvor dieselbe feste Kennung, und aria-labelledby zeigte // auf den Titel des ERSTEN. expect(zugaenglicherName(dialog ?? document.body)).toBe('Bild ersetzen?'); // Die Entscheidungsgrundlage steht im Text, nicht im Titel. const beschriebenVon = dialog?.getAttribute('aria-describedby') ?? ''; expect(beschriebenVon).not.toBe(''); expect(document.getElementById(beschriebenVon)?.textContent).toContain('Fahrlinien'); schliesseFenster(); await antwort; }); it('benennt jedes Bedienelement eines Panels samt Schliessen-Knopf', () => { const panel = new Panel({ title: 'Haltlinie festlegen', width: 520 }); try { panel.setContent(el('p', { text: 'An dieser Haltlinie beginnen die Ströme der Zufahrt.' })); panel.setActions( button({ label: 'Abbrechen', onClick: () => {} }), button({ label: 'Haltlinie anlegen', variant: 'primaer', onClick: () => {} }), ); const dialog = document.querySelector('.dialog'); expect(namensmaengel(dialog ?? document.body)).toEqual([]); // Der Schliessen-Knopf traegt sichtbar nur ein Kreuz - ohne aria-label // hiesse er fuer eine Sprachausgabe genau so. const schliessen = dialog?.querySelector('.dialog-schliessen'); expect(schliessen, 'kein Schliessen-Knopf').not.toBeNull(); expect(zugaenglicherName(schliessen ?? document.body)).toBe('Schließen'); } finally { panel.close(); } }); it('nennt den Titel eines Panels auch nach dem Weiterblaettern', () => { const panel = new Panel({ title: 'Erster Titel' }); try { const dialog = document.querySelector('.dialog'); expect(zugaenglicherName(dialog ?? document.body)).toBe('Erster Titel'); panel.setTitle('Zweiter Titel'); // Ueber die gemerkte Ueberschrift und nicht ueber eine feste Kennung: // Die Kennungen werden fortlaufend nummeriert, ein festes "#panel-titel" // traf nach dem zweiten Fenster nichts mehr. expect(zugaenglicherName(dialog ?? document.body)).toBe('Zweiter Titel'); } finally { panel.close(); } }); }); describe('Gefuehrter Einstieg', () => { /** Der Assistent von Schritt zu Schritt, mit einer Pruefung je Schritt. */ async function durchlaufe(pruefe: (dialog: HTMLElement, schritt: number) => void): Promise { const ergebnis = starteAssistent(); // Schranke gegen eine Endlosschleife, falls "Weiter" den Schritt einmal // nicht weiterzaehlt. const HOECHSTZAHL = 12; let schritt = 0; for (; schritt < HOECHSTZAHL; schritt += 1) { const dialog = document.querySelector('.dialog'); expect(dialog, `kein Fenster in Schritt ${schritt + 1}`).not.toBeNull(); pruefe(dialog ?? document.body, schritt); const weiter = [ ...(dialog?.querySelectorAll('.dialog-aktionen button') ?? []), ].find((b) => (b.textContent ?? '').trim() === 'Weiter'); if (weiter === undefined) break; weiter.click(); } expect(schritt, 'Der Assistent hat nie einen letzten Schritt erreicht').toBeLessThan( HOECHSTZAHL, ); const abbrechen = [ ...document.querySelectorAll('.dialog-aktionen button'), ].find((b) => (b.textContent ?? '').trim() === 'Abbrechen'); expect(abbrechen, 'kein Abbrechen im letzten Schritt').not.toBeNull(); abbrechen?.click(); expect(await ergebnis).toBeNull(); } it('benennt in jedem Schritt jedes Bedienelement', async () => { // Der gefuehrte Einstieg stand bis August 2026 vollstaendig // ausserhalb der Testabdeckung - und er ist der erste Bildschirm, den ein // neuer Anwender ueberhaupt sieht. const maengel: string[] = []; await durchlaufe((dialog, schritt) => { for (const zeile of namensmaengel(dialog)) maengel.push(`Schritt ${schritt + 1}: ${zeile}`); }); expect(maengel).toEqual([]); }); it('laesst in keinem Schritt eine Ueberschriftenebene aus', async () => { await durchlaufe((dialog, schritt) => { const ebenen = [...dialog.querySelectorAll('h1, h2, h3, h4, h5, h6')].map((h) => Number(h.tagName.slice(1)), ); let vorher = 0; for (const ebene of ebenen) { if (vorher !== 0) { expect( ebene, `Sprung von h${vorher} auf h${ebene} in Schritt ${schritt + 1}`, ).toBeLessThanOrEqual(vorher + 1); } vorher = ebene; } }); }); it('sagt in Fensterueberschrift und Schrittueberschrift, der wievielte Schritt ansteht', async () => { // Der Fokus springt bei jedem Schritt auf die Ueberschrift. Stuende dort // nur die Frage, waere der Schrittwechsel nicht zu hoeren - eine reine // Textaenderung am Fenstertitel sagen die meisten Sprachausgaben nicht an. await durchlaufe((dialog, schritt) => { const h3 = dialog.querySelector('h3'); expect(h3, `keine Schrittueberschrift in Schritt ${schritt + 1}`).not.toBeNull(); expect(h3?.textContent).toContain(`Schritt ${schritt + 1} von `); expect(zugaenglicherName(dialog)).toContain(`Schritt ${schritt + 1} von `); }); }); }); describe('Hilfefenster und Luftbildfenster', () => { it('benennt jedes Bedienelement des Glossars', () => { // Das Glossar ist der Weg, auf dem ein Einsteiger die Fachbegriffe klaert - // und es stand bis August 2026 ausserhalb jeder Pruefung. zeigeGlossar(); try { const dialog = document.querySelector('.dialog'); expect(dialog, 'kein Glossarfenster').not.toBeNull(); expect(namensmaengel(dialog ?? document.body)).toEqual([]); // Die Trefferzahl steht in einem dauerhaften Ansageraum: Die Liste wird // bei jedem Tastendruck ausgetauscht, angesagt wird die Zahl. const ansage = dialog?.querySelector('[role="status"][aria-live="polite"]'); expect(ansage, 'kein Ansageraum im Glossar').not.toBeNull(); } finally { schliesseHilfe(); } }); it('benennt jedes Bedienelement eines Hilfefensters', () => { zeigeHilfe('raeumweg'); try { const dialog = document.querySelector('.dialog'); expect(dialog, 'kein Hilfefenster').not.toBeNull(); expect(namensmaengel(dialog ?? document.body)).toEqual([]); expect(zugaenglicherName(dialog ?? document.body)).not.toBe(''); } finally { schliesseHilfe(); } }); it('benennt jedes Bedienelement der Ortseingabe zum Luftbild', async () => { // Der Weg zum Hintergrundbild - und damit zu jeder Vermessung. Er fuehrt // ueber ein eigenes Fenster mit Suchfeld, Trefferliste und Massstabswahl. const ergebnis = luftbildDialog(); try { const dialog = document.querySelector('.dialog'); expect(dialog, 'kein Luftbildfenster').not.toBeNull(); expect(namensmaengel(dialog ?? document.body)).toEqual([]); } finally { const abbrechen = [ ...document.querySelectorAll('.dialog-aktionen button'), ].find((b) => (b.textContent ?? '').trim() === 'Abbrechen'); abbrechen?.click(); document.querySelector('.dialog-schliessen')?.click(); await ergebnis; } }); });