import { afterEach, describe, expect, it } from 'vitest'; import { ProjectStore } from '@/app/store'; import { ASSISTENT_VORGABE, projektAusAngaben } from '@/ui/assistent'; import { Panel, confirmDialog, offeneFensterAnzahl } from '@/ui/feedback'; import { button, el } from '@/ui/dom'; import type { ViewDefinition } from '@/ui/shell'; 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'; /** * Zwei Prueflucken im Testbestand zur Barrierefreiheit. * * (c) DER FENSTER-STAPEL. `tests/ui/bfFensterUndMeldungen.test.ts` deckt * bereits ab, was die Befunde M1, M8, M9, M10 und L7 unmittelbar * verlangen: Escape bei ZWEI Fenstern, die Fokusfalle des oberen, die * Fokusrueckgabe, den Zeigerabbruch und das Stilllegen eines Hintergrunds aus * gewoehnlichen Knoten. Hier steht ausschliesslich, was dort NICHT steht und * was den Stapel als Stapel betrifft: * * - mehr als zwei Ebenen, in umgekehrter Reihenfolge abgebaut, * - ein Fenster, das UNTER einem anderen liegt, ist selbst stillgelegt - * geprueft wurde bisher nur ein Hintergrund aus einfachen Knoten, * - ein Fenster, das nicht von oben, sondern aus der Mitte des Stapels * geschlossen wird (der Regelfall, wenn ein Aufrufer `panel.close()` * selbst ruft), * - der Stapel raeumt sich vollstaendig ab und laesst keinen Empfaenger * zurueck, der auf ein nicht mehr vorhandenes Fenster zeigt. * * (d) DIE KNOTENIDENTITAET DER LIVE-BEREICHE. Ein `role="status"`, das * zusammen mit seinem Inhalt neu in den Baum kommt, sagt nichts an: Angesagt * wird die AENDERUNG eines vorhandenen Live-Bereichs, nicht das Auftauchen * eines neuen. Genau daran scheiterten die Balken aus Befund L5 - und niemand * hat es bemerkt, weil kein Test die Frage ueberhaupt gestellt hat. Die * Einzelfaelle zu Pruefbericht, Ausgabe und Glossar stehen in * `bfFensterUndMeldungen.test.ts`; hier laeuft die Regel ueber ALLE Ansichten, * damit sie auch fuer die zwoelfte gilt. * * Gegen den Altstand schlagen die Faelle fehl: Dort schloss EIN Escape alle * Fenster auf einmal, und in Pruefbericht wie Ausgabe entstand der Live-Bereich * innerhalb des ausgetauschten Teils. */ const FESTES_DATUM = '2026-01-01T00:00:00.000Z'; afterEach(() => { document.body.replaceChildren(); }); // --- (c) Der Fenster-Stapel ------------------------------------------------- /** Ein Fenster mit einem Fokusziel darin, das seinen Zustand mitschreibt. */ function fenster(titel: string): { panel: Panel; offen: () => boolean } { let zu = false; const panel = new Panel({ title: titel }); panel.onClose(() => { zu = true; }); panel.setActions(button({ label: `Weiter (${titel})`, onClick: () => {} })); return { panel, offen: () => !zu }; } function overlays(): HTMLElement[] { return [...document.querySelectorAll('.dialog-hintergrund')]; } /** Tastendruck, wie ihn der Browser liefert: am Fokus, aufsteigend, abweisbar. */ function taste(key: string): void { const ziel = document.activeElement ?? document.body; ziel.dispatchEvent(new KeyboardEvent('keydown', { key, bubbles: true, cancelable: true })); } describe('Fenster-Stapel: mehr als zwei Ebenen', () => { it('baut drei Fenster in umgekehrter Reihenfolge ab, eines je Escape', () => { /* * Drei Ebenen sind keine Erfindung: Der gefuehrte Einstieg ist ein Fenster, * seine Felder tragen Hilfeknoepfe, und ein Hilfefenster kann seinerseits * eine Rueckfrage stellen. Ein Stapel, der bei zweien richtig liegt, kann * bei dreien dennoch falsch abbauen - etwa wenn er statt des obersten den * zuletzt ANGELEGTEN Eintrag schliesst. */ const unten = fenster('Geführter Einstieg'); const mitte = fenster('Hilfe zu Räumweg'); const oben = fenster('Glossar'); expect(offeneFensterAnzahl()).toBe(3); taste('Escape'); expect([unten.offen(), mitte.offen(), oben.offen()], 'nach dem ersten Escape').toEqual([ true, true, false, ]); taste('Escape'); expect([unten.offen(), mitte.offen(), oben.offen()], 'nach dem zweiten Escape').toEqual([ true, false, false, ]); taste('Escape'); expect([unten.offen(), mitte.offen(), oben.offen()], 'nach dem dritten Escape').toEqual([ false, false, false, ]); expect(offeneFensterAnzahl()).toBe(0); expect(overlays()).toHaveLength(0); }); it('legt auch ein Fenster stumm, das unter einem anderen liegt', () => { /* * Bisher geprueft war nur ein Hintergrund aus gewoehnlichen Knoten. Das * untere FENSTER ist aber derselbe Fall: Es steht als Kind des * Seitenkoerpers neben dem oberen, und wer es nicht stilllegt, laesst eine * Sprachausgabe zwei modale Fenster gleichzeitig vorlesen und den Zeiger * das verdeckte bedienen. */ const unten = fenster('Geführter Einstieg'); const untenOverlay = overlays()[0]; expect(untenOverlay, 'kein unteres Fenster').not.toBeUndefined(); const oben = fenster('Hilfe'); expect(untenOverlay?.getAttribute('aria-hidden')).toBe('true'); expect(untenOverlay?.hasAttribute('inert'), 'unteres Fenster bleibt bedienbar').toBe(true); oben.panel.close(); // Und wieder frei: Sonst waere das untere Fenster nach dem Schliessen des // oberen dauerhaft unbedienbar - der Anwender saesse in einem Fenster // fest, das keine Taste mehr annimmt. expect(untenOverlay?.hasAttribute('aria-hidden')).toBe(false); expect(untenOverlay?.hasAttribute('inert')).toBe(false); unten.panel.close(); }); it('nimmt ein Fenster aus der Mitte des Stapels, ohne die Reihenfolge zu verlieren', () => { /* * Ein Fenster wird nicht nur von oben geschlossen: `frageLaenge` im * Lageplan ruft `panel.close()` selbst, und der Waechter der Kurzhilfe * schliesst ein vorhandenes Fenster, ehe er ein neues oeffnet. Wuerde der * Stapel dabei blind den letzten Eintrag abtragen, schloesse der naechste * Escape das falsche Fenster - und im gefuehrten Einstieg heisst das: * Eingaben aus vier Schritten weg, ohne Rueckfrage. */ const unten = fenster('Geführter Einstieg'); const mitte = fenster('Maßstab festlegen'); const oben = fenster('Rückfrage'); mitte.panel.close(); expect(offeneFensterAnzahl()).toBe(2); taste('Escape'); expect(oben.offen(), 'Escape traf nicht das oberste Fenster').toBe(false); expect(unten.offen(), 'das unterste Fenster wurde mitgeschlossen').toBe(true); unten.panel.close(); expect(offeneFensterAnzahl()).toBe(0); }); it('laesst nach dem letzten Fenster keinen Tastaturempfaenger zurueck', async () => { // Ein Empfaenger, der am Dokument haengen bleibt, faengt jedes weitere // Escape der Anwendung ab: `stopImmediatePropagation` unterbindet dann // Tastendruecke, die niemandem mehr gehoeren. const auf = fenster('Glossar'); auf.panel.close(); expect(offeneFensterAnzahl()).toBe(0); let durchgelassen = false; const empfaenger = (): void => { durchgelassen = true; }; document.addEventListener('keydown', empfaenger); try { taste('Escape'); expect(durchgelassen, 'Escape wurde von einem verwaisten Empfaenger abgefangen').toBe(true); } finally { document.removeEventListener('keydown', empfaenger); } // Und die Rueckfrage danach verhaelt sich wieder wie die erste. const antwort = confirmDialog({ title: 'Rückfrage', message: 'Wirklich?' }); expect(offeneFensterAnzahl()).toBe(1); taste('Escape'); expect(await antwort).toBe('abgebrochen'); expect(offeneFensterAnzahl()).toBe(0); }); }); // --- (d) Knotenidentitaet der Live-Bereiche --------------------------------- const ANSICHTEN: readonly ViewDefinition[] = [ projectView, lageplanView, signalGroupsView, compatibilityView, phasesView, conflictsView, planView, simulationView, koordinierungView, vergleichView, reportView, exportView, settingsView, ]; function gefuellterStore(): ProjectStore { return new ProjectStore( projektAusAngaben( { ...ASSISTENT_VORGABE, fussgaenger: true, rad: true, linksabbieger: true }, FESTES_DATUM, ), ); } interface Gezeichnet { readonly wurzel: HTMLElement; readonly store: ProjectStore; readonly aufraeumen: () => void; } function zeichne(view: ViewDefinition): Gezeichnet { const store = gefuellterStore(); const wurzel = el('div', {}); document.body.append(wurzel); const schliessen = view.render(wurzel, { store, refresh: () => {}, navigate: () => {}, target: null, }); return { wurzel, store, aufraeumen: () => { schliessen(); wurzel.remove(); }, }; } /** * Alle Ansageraeume eines Teilbaums. * * ABGEGRENZT AN `aria-live` UND NICHT AN `role="status"` - das ist eine * Entscheidung, die begruendet gehoert, weil `role="status"` ein `aria-live` * von sich aus mitbringt und die Auswahl damit auf den ersten Blick zu eng * aussieht. * * Die Anwendung fuehrt beide Faelle getrennt, und die Trennung ist an der * Auszeichnung abzulesen (nachgezaehlt: alle Ansageraeume tragen beides, genau * ein Balken traegt nur die Rolle): * * - ANSAGERAUM - beides gesetzt. Ein Knoten, der stehen bleibt und dessen * TEXT sich aendert: der Rahmen, der Pruefbericht, die Ausgabe, die * Zeigerlage im Lageplan, die Glossarsuche. Nur hier greift Befund L5: * Wird der Knoten samt Inhalt ersetzt, geht die Ansage verloren, weil * angesagt wird, was sich IN einem vorhandenen Live-Bereich aendert. * * - EINMALBALKEN - nur `role="status"`. Ein Knoten mit festem Text, der ein- * und ausgeblendet wird; angesagt wird sein Erscheinen. In der ganzen * Anwendung ist das ein einziger: der Vermerk "Rechenweg abgewählt" in * der Ausgabe. Sein Text aendert sich nie, und der Weg, auf dem er * erscheint - das Kontrollkaestchen - baut nichts neu auf. Ihn hier * mitzuzaehlen hiesse, einen Mangel zu behaupten, den es nicht gibt. * * Damit die Grenze nicht stillschweigend verrutscht, ist sie selbst ein * Prueffall: Der zweite Fall unten laesst genau die benannten Einmalbalken zu * und faellt bei jedem weiteren `role="status"` ohne `aria-live` aus. */ function ansageraeume(wurzel: ParentNode): HTMLElement[] { return [...wurzel.querySelectorAll('[aria-live]')]; } describe('Ansageraeume ueberleben einen Neuaufbau als DERSELBE Knoten', () => { for (const view of ANSICHTEN) { it(`in der Ansicht "${view.label}"`, () => { /* * Der Neuaufbau, um den es geht, ist der, den der Anwender nicht * ausloest: "Rueckgaengig" in der Kopfzeile, die Selbstsicherung, ein * zweites Fenster, das in den Speicher schreibt. Er trifft die Ansicht, * waehrend jemand darin steht - und genau dann soll die Ansage kommen. * * Ansichten ohne Zustandsempfaenger bauen dabei nichts neu auf; fuer sie * ist der Fall eine Zusage fuer die Zukunft. Das ist Absicht: Wer * spaeter einen Empfaenger nachruestet und dabei den Ansageraum in den * ausgetauschten Teil legt, faellt hier auf. */ const { wurzel, store, aufraeumen } = zeichne(view); try { const vorher = ansageraeume(wurzel); store.update((p) => ({ ...p, meta: { ...p.meta, variant: 'Prüfvariante' } }), { label: 'Variante geändert', }); const abgehaengt = vorher.filter((k) => !wurzel.contains(k)); expect( abgehaengt.map((k) => `${k.tagName.toLowerCase()}.${k.className}`), `Ansageraum in "${view.label}" wurde beim Neuaufbau ersetzt statt beschriftet`, ).toEqual([]); } finally { aufraeumen(); } }); } }); /** * Die einzigen Live-Bereiche, die mit Absicht ohne `aria-live` auskommen. * * Ein Einmalbalken hat einen festen Text und wird nur ein- und ausgeblendet. * Die Liste steht hier, damit ein neuer Balken nicht unbemerkt in den * ausgetauschten Teil einer Ansicht geraet: Wer einen anlegt, muss sich hier * dazu erklaeren. */ const EINMALBALKEN: Readonly> = { // "Rechenweg abgewählt" in der Ausgabe (Befund C15). Nach Ansicht UND Klasse // aufgeschluesselt: "hinweisbalken-warnung" ist die gewoehnlichste Klasse der // Anwendung, und ein Balken der Vorgabenansicht mit derselben Klasse waere // ein ganz anderer Fall. export: ['hinweisbalken hinweisbalken-warnung'], }; describe('Die Grenze zwischen Ansageraum und Einmalbalken ist benannt', () => { for (const view of ANSICHTEN) { it(`in der Ansicht "${view.label}"`, () => { const { wurzel, aufraeumen } = zeichne(view); try { const zugelassen = EINMALBALKEN[view.id] ?? []; const ohneLive = [ ...wurzel.querySelectorAll('[role="status"], [role="alert"]'), ].filter((k) => !k.hasAttribute('aria-live')); expect( ohneLive.map((k) => k.className).filter((k) => !zugelassen.includes(k)), `Neuer Live-Bereich ohne aria-live in "${view.label}" - Ansageraum oder Einmalbalken?`, ).toEqual([]); } finally { aufraeumen(); } }); } }); describe('Lageplan: die Zeigerlage wird beschriftet, nicht ersetzt', () => { it('bleibt derselbe Knoten, wenn das Werkzeug gewechselt wird', () => { /* * Der Werkzeugwechsel baut die ganze Seitenleiste neu auf. Die Zeigerlage * ist der Bereich, der bei jedem Tastenschritt sagt, wo der Zeiger steht * und ob er auf eine Haltlinie einrastet - fuer eine Vermessung ohne Maus * die einzige Rueckmeldung. Waere sie Teil des ausgetauschten Bereichs, * bliebe sie nach dem ersten Werkzeugwechsel stumm. */ const { wurzel, aufraeumen } = zeichne(lageplanView); try { const zeigerlage = wurzel.querySelector('.lageplan-rahmen [role="status"]'); expect(zeigerlage, 'keine Zeigerlage im Rahmen').not.toBeNull(); expect(zeigerlage?.getAttribute('aria-live')).toBe('polite'); const ansehen = [...wurzel.querySelectorAll('.lageplan-werkzeuge button')]; expect(ansehen.length, 'keine Werkzeugknoepfe').toBeGreaterThan(0); ansehen[0]?.click(); expect(wurzel.querySelector('.lageplan-rahmen [role="status"]')).toBe(zeigerlage); } finally { aufraeumen(); } }); });