import { afterEach, describe, expect, it, vi } from 'vitest'; import { ProjectStore } from '@/app/store'; import { buildSignalPlan } from '@/domain/plan/signalPlan'; import { validateProject } from '@/domain/validation'; import { createStandardIntersectionProject } from '@/domain/model/factory'; import type { Project } from '@/domain/model/project'; import type { ViewDefinition } from '@/ui/shell'; import { button, el } from '@/ui/dom'; import { Panel, confirmDialog, meldungsdauer, notify, offeneFensterAnzahl, setzeMeldungsdauer, } from '@/ui/feedback'; import { DEFAULT_APP_SETTINGS, MELDUNGSDAUER_STUFEN, loadAppSettings, saveAppSettings, } from '@/services/storage'; import { zeigeGlossar } from '@/ui/help/hilfe'; import { numberCell } from '@/ui/inputs'; import { exportView } from '@/ui/views/exportView'; import { reportView } from '@/ui/views/reportView'; import { settingsView } from '@/ui/views/settingsView'; /** * Zwei Gruppen von Befunden zur Barrierefreiheit. * * FENSTER-STAPEL (M1, M8, M9, M10, L7). Der einzige Bereich der * Oberflaeche mit echtem Datenverlust, und er trifft Tastatur- und * Sprachausgabennutzer allein: Mit der Maus tritt er gar nicht auf. Bis hierher * fasste kein einziger Test `Panel` oder `confirmDialog` ueberhaupt an - weder * Escape noch Tabulator, Fokusrueckgabe oder Zeigerabbruch waren abgedeckt. * * FEHLERMELDUNGEN UND STATUSANSAGEN (M7, L4, L5, L6). * * Gegen den Altstand schlagen die Faelle fehl: Dort schloss EIN Escape alle * uebereinanderliegenden Fenster, zog der Tabulator aus der Rueckfrage in das * stillgelegte Fenster darunter, blieb der Fokus nach dem Schliessen auf einem * abgehaengten Knoten haengen, schloss ein blosses Herunterdruecken der * Maustaste das Fenster, stand der Hintergrund trotz `aria-hidden` weiter im * Tabulatorlauf, fiel der Fokus im Pruefbericht nach jedem Filterdruck auf den * Seitenkoerper, sagte die Glossarsuche nichts an, trugen zwei Balken ein * `role="status"`, das nichts ansagen konnte, und ein Feld behielt * `aria-invalid` mit einem einwandfreien Wert darin. */ const FESTES_DATUM = new Date('2026-01-01T00:00:00Z'); afterEach(() => { document.body.replaceChildren(); }); // --- Hilfsmittel ------------------------------------------------------------ function overlays(): HTMLElement[] { return [...document.querySelectorAll('.dialog-hintergrund')]; } /** Tastendruck, wie ihn der Browser liefert: am Fokus, aufsteigend, abweisbar. */ function taste(key: string, shiftKey = false): void { const ziel = document.activeElement ?? document.body; ziel.dispatchEvent( new KeyboardEvent('keydown', { key, shiftKey, bubbles: true, cancelable: true }), ); } /** Wartet einen Mikroschritt ab - die Fokusrueckgabe laeuft in einem solchen. */ async function mikroschritt(): Promise { await new Promise((fertig) => { queueMicrotask(fertig); }); } /** Ein Fenster mit einem bedienbaren Inhalt, damit die Fokusfalle etwas zu fassen hat. */ function fenster(titel: string): { panel: Panel; geschlossen: () => boolean } { let zu = false; const panel = new Panel({ title: titel }); panel.onClose(() => { zu = true; }); panel.setActions( button({ label: `Weiter (${titel})`, onClick: () => { /* nichts - der Knopf ist nur ein Fokusziel */ }, }), ); return { panel, geschlossen: () => zu }; } function zeichne( view: ViewDefinition, project: Project, ): { wurzel: HTMLElement; store: ProjectStore; aufraeumen: () => void } { const wurzel = document.createElement('div'); document.body.append(wurzel); const store = new ProjectStore(project); const aufraeumen = view.render(wurzel, { store, refresh: () => {}, navigate: () => {}, target: null, }); return { wurzel, store, aufraeumen: () => { aufraeumen(); wurzel.remove(); }, }; } /** Ein Projekt mit Fehlern: alle Kfz-Staerken so hoch, dass die Anlage uebersaettigt ist. */ function gesperrt(): Project { const p = createStandardIntersectionProject('Gesperrt', FESTES_DATUM); const mitFehlern = { ...p, demands: p.demands.map((d) => ({ ...d, volume: 1500 })) }; expect( validateProject(mitFehlern, buildSignalPlan(mitFehlern), FESTES_DATUM).exportBlocked, 'Vorbedingung: Export gesperrt', ).toBe(true); return mitFehlern; } // --- Fenster-Stapel --------------------------------------------------------- describe('Fenster-Stapel: Escape schliesst genau das oberste Fenster (Befund M1)', () => { it('laesst bei zwei Fenstern das untere stehen und schliesst es erst beim zweiten Escape', () => { const unten = fenster('Glossar'); const oben = fenster('Kurzhilfe'); expect(overlays()).toHaveLength(2); expect(offeneFensterAnzahl()).toBe(2); taste('Escape'); expect(oben.geschlossen(), 'oberes Fenster bleibt offen').toBe(true); expect(unten.geschlossen(), 'unteres Fenster wurde mitgeschlossen').toBe(false); expect(overlays()).toHaveLength(1); taste('Escape'); expect(unten.geschlossen()).toBe(true); expect(overlays()).toHaveLength(0); // Der Stapel gibt seinen letzten Eintrag frei - sonst bliebe der // Tastaturempfaenger am Dokument haengen und fasste ein Fenster an, das es // nicht mehr gibt. expect(offeneFensterAnzahl()).toBe(0); }); it('schliesst mit einem Escape die Rueckfrage ueber einem Fenster, nicht das Fenster', async () => { // Der Schadensfall aus dem Befund: Im gefuehrten Einstieg oeffnet ein // Hilfeknopf ein zweites Fenster. Ein Escape darauf verwarf bis hierher // auch den Assistenten - und `onClose` loest dort mit `null` auf, also // ohne Rueckfrage und ohne die Eingaben aller vier Schritte. const assistent = fenster('Geführter Einstieg'); const antwort = confirmDialog({ title: 'Rückfrage', message: 'Wirklich?' }); expect(overlays()).toHaveLength(2); taste('Escape'); expect(await antwort).toBe('abgebrochen'); expect(assistent.geschlossen(), 'der Assistent darf nicht mitgehen').toBe(false); expect(overlays()).toHaveLength(1); assistent.panel.close(); }); it('holt den Fokus in ein schon offenes Fenster - aber nur, wenn es obenauf liegt', () => { // Der Waechter in showHelp() (main.ts) baut darauf auf: Ein zweiter Druck // auf F1 soll kein zweites, wortgleiches Fenster stapeln, sondern in das // vorhandene zurueckfuehren. const unten = fenster('Kurzhilfe'); const oben = fenster('Rückfrage darüber'); unten.panel.fokussiere(); expect( overlays()[0]!.contains(document.activeElement), 'Fokus wurde in ein stillgelegtes Fenster gezogen', ).toBe(false); oben.panel.close(); unten.panel.fokussiere(); expect(document.activeElement?.textContent).toContain('Weiter (Kurzhilfe)'); unten.panel.close(); }); it('gibt den Fokus in das Fenster darunter zurueck, nicht in die stillgelegte Ansicht', async () => { const inhalt = el('div', { id: 'inhalt' }); inhalt.tabIndex = -1; document.body.append(inhalt); const unten = fenster('Glossar'); const oben = fenster('Kurzhilfe'); // Der ausloesende Knopf des oberen Fensters steht im unteren; er ist beim // Schliessen noch da, also gehoert der Fokus dorthin zurueck. oben.panel.close(); await mikroschritt(); expect(document.activeElement?.textContent).toContain('Weiter (Glossar)'); unten.panel.close(); }); }); describe('Fenster-Stapel: der Tabulator bleibt im obersten Fenster (Befund M8)', () => { it('haelt den Fokus in der Rueckfrage, obwohl ein Fenster darunter liegt', async () => { const unten = fenster('Geführter Einstieg'); const antwort = confirmDialog({ title: 'Rückfrage', message: 'Wirklich?' }); const rueckfrage = overlays()[1]!; const bestaetigen = [...rueckfrage.querySelectorAll('button')].at(-1)!; bestaetigen.focus(); expect(document.activeElement).toBe(bestaetigen); taste('Tab'); // Die Fokusfalle der Rueckfrage kannte den Rueckfall "der Fokus steht gar // nicht in diesem Dialog" nicht. Die des Fensters darunter kannte ihn und // lief als erster Empfaenger - sie zog den Fokus in einen Teilbaum, den // `legeHintergrundStill` gerade auf aria-hidden und inert gesetzt hatte. expect(rueckfrage.contains(document.activeElement), 'Fokus verliess die Rueckfrage').toBe(true); taste('Escape'); await antwort; unten.panel.close(); }); it('uebergeht gesperrte Schaltflaechen beim Umlauf', () => { const panel = new Panel({ title: 'Mit gesperrtem Knopf' }); const gesperrterKnopf = button({ label: 'Gesperrt', disabled: true, onClick: () => {} }); const offenerKnopf = button({ label: 'Offen', onClick: () => {} }); panel.setActions(gesperrterKnopf, offenerKnopf); offenerKnopf.focus(); taste('Tab'); // Ein gesperrter Knopf nimmt den Fokus nicht an; ein Umlauf, der ihn // mitzaehlt, laeuft ins Leere. expect(document.activeElement).not.toBe(gesperrterKnopf); panel.close(); }); }); describe('Fenster-Stapel: Fokusrueckgabe an einen Knoten, den es nicht mehr gibt (Befund M9)', () => { it('faellt auf den Inhaltsbereich zurueck, wenn der Ausloeser beim Schliessen ersetzt ist', async () => { const inhalt = el('div', { id: 'inhalt' }); inhalt.tabIndex = -1; document.body.append(inhalt); // Die Matrixzelle, aus der das Zwischenzeitenfenster geoeffnet wird: Die // Matrix baut sich bei jeder Eingabe neu auf, die Zelle ist beim Schliessen // laengst ersetzt. const zelle = button({ label: 'K1 räumt, K3 fährt ein', onClick: () => {} }); document.body.append(zelle); zelle.focus(); const panel = new Panel({ title: 'Zwischenzeit' }); zelle.remove(); panel.close(); await mikroschritt(); // `focus()` auf einem abgehaengten Knoten bleibt wirkungslos - der Fokus // fiel auf den Seitenkoerper, und der naechste Tabulatordruck begann wieder // ganz oben. expect(document.activeElement).toBe(inhalt); }); it('laesst dem Abschlusshaken den Vortritt, wenn der den Fokus selbst setzt', async () => { /* * KORREKTUR (Befund B2): Der Aufschub auf den Mikroschritt - noetig fuer * M9 - hatte einen Nebenschaden. Im Altstand lief die Rueckgabe SYNCHRON * vor `onClose`; eine Ansicht, die sich im Abschlusshaken neu aufbaut und * den Fokus selbst setzt, gewann damit. Danach feuerte der Mikroschritt * hinterher, fand den Ausloeser abgehaengt und warf den Fokus auf * `#inhalt` - gemessen: unmittelbar nach `close()` stand er auf * "Zwischenzeit bearbeiten", nach dem Mikroschritt auf `div#inhalt`. * * Das ist genau das Muster, das src/ui/fokus.ts empfiehlt, und es waere * hier stillschweigend wirkungslos geworden. */ const inhalt = el('div', { id: 'inhalt' }); inhalt.tabIndex = -1; document.body.append(inhalt); const ausloeser = button({ label: 'Zwischenzeit bearbeiten', onClick: () => {} }); document.body.append(ausloeser); ausloeser.focus(); // Der Nachfolger, den die neu aufgebaute Ansicht im Abschlusshaken setzt. const nachfolger = button({ label: 'Zwischenzeit bearbeiten (neu)', onClick: () => {} }); const panel = new Panel({ title: 'Zwischenzeit' }); panel.onClose(() => { ausloeser.remove(); document.body.append(nachfolger); nachfolger.focus(); }); panel.close(); expect(document.activeElement, 'sofort nach dem Schliessen').toBe(nachfolger); await mikroschritt(); expect(document.activeElement, 'nach dem Mikroschritt').toBe(nachfolger); }); it('laesst bei "schliessen und gleich das naechste oeffnen" das neue Fenster gewinnen', async () => { /* * Die Kette aus src/ui/help/hilfe.ts: Der Knopf "Glossar" der Kurzhilfe * ruft `panel.close(); zeigeGlossar();`. Dass der Fokus danach im GLOSSAR * steht und nicht auf dem Knopf dahinter, haengt an der Reihenfolge zweier * Mikroschritte - der Fokusrueckgabe und dem des `Panel`-Erbauers. Sie * traegt, war aber nirgends festgehalten (Befund B2). */ const inhalt = el('div', { id: 'inhalt' }); inhalt.tabIndex = -1; document.body.append(inhalt); const ausloeser = button({ label: 'Hilfe', onClick: () => {} }); document.body.append(ausloeser); ausloeser.focus(); const kurzhilfe = fenster('Kurzhilfe'); await mikroschritt(); kurzhilfe.panel.close(); const glossar = fenster('Glossar'); // Beide Mikroschritte abwarten: der der Rueckgabe und der des Erbauers. await mikroschritt(); await mikroschritt(); expect(document.activeElement?.textContent).toContain('Weiter (Glossar)'); glossar.panel.close(); }); }); describe('Fenster-Stapel: Klick neben das Fenster erst beim Loslassen (Befund M10, WCAG 2.5.2)', () => { it('schliesst nicht beim blossen Herunterdruecken der Maustaste', () => { const auf = fenster('Geführter Einstieg'); const overlay = overlays()[0]!; overlay.dispatchEvent(new MouseEvent('mousedown', { bubbles: true })); expect(auf.geschlossen(), 'schon beim Herunterdruecken geschlossen').toBe(false); overlay.dispatchEvent(new MouseEvent('mouseup', { bubbles: true })); expect(auf.geschlossen()).toBe(true); }); it('bricht die Geste ab, wenn der Zeiger vor dem Loslassen auf das Fenster wandert', () => { const auf = fenster('Geführter Einstieg'); const overlay = overlays()[0]!; const dialog = overlay.querySelector('.dialog')!; overlay.dispatchEvent(new MouseEvent('mousedown', { bubbles: true })); dialog.dispatchEvent(new MouseEvent('mouseup', { bubbles: true })); expect(auf.geschlossen(), 'Geste liess sich nicht mehr abbrechen').toBe(false); auf.panel.close(); }); }); describe('Fenster-Stapel: der Hintergrund wird wirklich stillgelegt (Befund L7)', () => { it('setzt aria-hidden UND inert und nimmt beides beim Schliessen zurueck', () => { const hintergrund = el('div', {}, button({ label: 'Speichern', onClick: () => {} })); document.body.append(hintergrund); const auf = fenster('Glossar'); expect(hintergrund.getAttribute('aria-hidden')).toBe('true'); // Ohne `inert` war der Zustand widerspruechlich: fuer die Sprachausgabe // nicht vorhanden, im Tabulatorlauf aber weiter erreichbar. expect(hintergrund.hasAttribute('inert'), 'Hintergrund bleibt bedienbar').toBe(true); auf.panel.close(); expect(hintergrund.hasAttribute('aria-hidden')).toBe(false); expect(hintergrund.hasAttribute('inert')).toBe(false); }); it('nimmt nur zurueck, was es selbst gesetzt hat', () => { const fremd = el('div', { 'aria-hidden': 'true' }); document.body.append(fremd); const auf = fenster('Glossar'); auf.panel.close(); // Das fremde aria-hidden bleibt: Es ist aus einem anderen Grund gesetzt. expect(fremd.getAttribute('aria-hidden')).toBe('true'); expect(fremd.hasAttribute('inert')).toBe(false); }); it('laesst den Meldungsstapel bedienbar UND hoerbar (Befunde M4 und L7)', () => { /* * KORREKTUR (Befunde B1/B2 bzw. B3): Der Stapel war von `inert` * ausgenommen, von `aria-hidden` aber nicht. Gemessen: `aria-hidden="true"` * am Stapel, `inert` nicht gesetzt. Damit lagen die beiden Live-Bereiche - * role="status" und role="alert" - in einem Teilbaum, den eine * Sprachausgabe nicht kennt (4.1.3, Stufe AA), und zugleich stand dort ein * mit der Tastatur erreichbarer Schliessen-Knopf, zu dem nichts angesagt * wird (4.1.2; genau der Widerspruch, den L7 am Hintergrund ruegt, nur * andersherum). * * Der Prueffall war bis dahin ein Scheinnachweis: Er hiess "laesst den * Meldungsstapel bedienbar" und pruefte allein `inert`. */ notify('Die Selbstsicherung ist fehlgeschlagen.', 'warnung'); const stapel = document.querySelector('.meldungen')!; const auf = fenster('Glossar'); // Warnungen bleiben mit Absicht stehen, bis sie gelesen sind - ihre // Schliessen-Schaltflaeche muss erreichbar bleiben. expect(stapel.hasAttribute('inert')).toBe(false); expect(stapel.hasAttribute('aria-hidden'), 'aria-hidden am Meldungsstapel').toBe(false); // Und zwar bis zu den Live-Bereichen hinunter: Ein aria-hidden an einem // VORFAHREN wirkt genauso. for (const rolle of ['status', 'alert']) { const bereich = document.querySelector(`.meldungen [role="${rolle}"]`); expect(bereich, `Ansagebereich role="${rolle}"`).not.toBeNull(); expect( bereich!.closest('[aria-hidden="true"]'), `role="${rolle}" liegt in einem verborgenen Teilbaum`, ).toBeNull(); } auf.panel.close(); }); it('behandelt den Meldungsstapel gleich, gleich wann er entstanden ist', () => { // Zuvor hing das Verhalten davon ab, ob in der Sitzung schon einmal // gemeldet worden war: Ein vorher entstandener Stapel bekam aria-hidden, // ein im Fenster entstandener nicht - dieselbe Bedienhandlung mit zwei // Ergebnissen. const auf = fenster('Zwischenzeiten'); notify('„abc" ist keine Zahl. Der vorherige Wert gilt weiter.', 'warnung'); const stapel = document.querySelector('.meldungen')!; expect(stapel.hasAttribute('aria-hidden')).toBe(false); expect(stapel.hasAttribute('inert')).toBe(false); auf.panel.close(); }); it('legt alles andere im Hintergrund weiterhin still', () => { // Die Ausnahme gilt genau einem Knoten - sonst waere aus der Behebung von // M4 eine Ruecknahme von L7 geworden. const hintergrund = el('div', {}, button({ label: 'Speichern', onClick: () => {} })); document.body.append(hintergrund); notify('Gespeichert.', 'erfolg'); const auf = fenster('Glossar'); expect(hintergrund.getAttribute('aria-hidden')).toBe('true'); expect(hintergrund.hasAttribute('inert')).toBe(true); auf.panel.close(); }); }); // --- Meldungen und Statusansagen -------------------------------------------- describe('Pruefbericht: Filterergebnis wird angesagt, der Fokus bleibt (Befund M7)', () => { it('behaelt den Fokus auf dem gedrueckten Filterknopf', () => { const p = createStandardIntersectionProject('Prüfbericht', FESTES_DATUM); const { wurzel, aufraeumen } = zeichne(reportView, p); try { const fehlerKnopf = [...wurzel.querySelectorAll('button')].find((b) => b.textContent?.startsWith('Fehler ('), )!; fehlerKnopf.focus(); fehlerKnopf.click(); // Der Fokus fiel bis hierher auf den Seitenkoerper, und die // Tabulatorfolge begann wieder am Dokumentanfang. expect(document.activeElement).not.toBe(document.body); expect(document.activeElement?.textContent).toContain('Fehler ('); } finally { aufraeumen(); } }); it('nennt Filter und Trefferzahl in einem Live-Bereich, der den Neuaufbau ueberlebt', () => { const p = createStandardIntersectionProject('Prüfbericht', FESTES_DATUM); const bericht = validateProject(p, buildSignalPlan(p), FESTES_DATUM); const { wurzel, aufraeumen } = zeichne(reportView, p); try { const ansage = wurzel.querySelector('[role="status"]')!; expect(ansage, 'kein Ansageraum in der Ansicht').not.toBeNull(); expect(ansage.textContent).toContain('Anzeige: Alle Beanstandungen'); expect(ansage.textContent).toContain( `${bericht.findings.length} von ${bericht.findings.length}`, ); const fehlerKnopf = [...wurzel.querySelectorAll('button')].find((b) => b.textContent?.startsWith('Fehler ('), )!; fehlerKnopf.click(); // DERSELBE Knoten, nur mit neuem Text: Ein Live-Bereich, der samt Inhalt // neu eingehaengt wird, sagt nichts an. expect(wurzel.querySelector('[role="status"]')).toBe(ansage); expect(ansage.textContent).toContain('Anzeige: Fehler'); expect(ansage.textContent).toContain(`${bericht.errorCount} von ${bericht.findings.length}`); } finally { aufraeumen(); } }); }); describe('Glossarsuche sagt die Trefferzahl an (Befund L4)', () => { it('aendert den Text eines dauerhaft vorhandenen Live-Bereichs bei jeder Eingabe', () => { zeigeGlossar(); const overlay = overlays()[0]!; const ansage = overlay.querySelector('[role="status"]')!; expect(ansage, 'kein Ansageraum im Glossar').not.toBeNull(); expect(ansage.textContent).toContain('gefunden'); const suchfeld = overlay.querySelector('input[type="search"]')!; suchfeld.value = 'xyzabc'; suchfeld.dispatchEvent(new Event('input')); expect(overlay.querySelector('[role="status"]'), 'neuer Knoten statt neuem Text').toBe(ansage); expect(ansage.textContent).toBe('Kein Begriff gefunden.'); suchfeld.value = 'Räumweg'; suchfeld.dispatchEvent(new Event('input')); expect(ansage.textContent).toMatch(/^\d+ Begriffe? gefunden\.$/); }); }); describe('Ausgabe und Vorgaben: Live-Bereiche, die wirklich ansagen (Befund L5)', () => { it('sagt den Wechsel der Exportsperre im selben Knoten an', () => { const { wurzel, store, aufraeumen } = zeichne(exportView, gesperrt()); try { const ansage = wurzel.querySelector('.nur-vorlesen[role="status"]')!; expect(ansage, 'kein Ansageraum in der Ansicht').not.toBeNull(); expect(ansage.textContent).toContain('Ausgabe gesperrt'); // Der sichtbare Sperrgrund traegt keine eigene Rolle mehr - er entsteht // im Neuaufbau und koennte als Live-Bereich nichts ansagen. expect(wurzel.querySelector('.export-sperrgrund')!.getAttribute('role')).toBeNull(); // Die Sperre faellt, waehrend der Anwender in der Ansicht steht - so wie // nach einem "Rückgängig" in der Kopfzeile. store.update((p) => ({ ...p, demands: p.demands.map((d) => ({ ...d, volume: 300 })) }), { label: 'Verkehrsstärken gesenkt', }); expect(wurzel.querySelector('.nur-vorlesen[role="status"]')).toBe(ansage); expect(ansage.textContent).toContain('Ausgabe möglich'); } finally { aufraeumen(); } }); it('verspricht am Vorgabenbalken keine Ansage, die er nicht leisten kann', () => { const roh = createStandardIntersectionProject('Vorgaben', FESTES_DATUM); const p: Project = { ...roh, anlagenart: 'knotenpunkt', settings: { ...roh.settings, rilsa: { ...roh.settings.rilsa, cycleTimeMax: 250 } }, }; const { wurzel, aufraeumen } = zeichne(settingsView, p); try { const balken = wurzel.querySelector('.hinweisbalken-fehler')!; expect(balken, 'Kein Fehlerbalken fuer die unzulaessige Vorgabe').not.toBeNull(); expect(balken.textContent).toContain('1 Vorgabe wird nicht wie eingetragen angesetzt'); // Jede geaenderte Vorgabe baut die ganze Ansicht neu auf; der Balken kommt // dabei samt Inhalt neu in den Baum und kann nichts ansagen. expect(balken.getAttribute('role')).toBeNull(); } finally { aufraeumen(); } }); }); describe('Zahlenfeld: der Zustand richtet sich nach dem, was im Feld steht (Befund L6)', () => { it('nimmt aria-invalid zurueck, wenn der vorherige gueltige Wert wiederhergestellt ist', () => { let gemeldet: number | null = -1; const feld = numberCell( 130, (wert) => { gemeldet = wert; }, { min: 5, max: 130, einheit: 'km/h' }, ); feld.setAttribute('aria-label', 'Zulässige Höchstgeschwindigkeit in km/h, K1'); document.body.append(feld); // Was beim Tippen von "abc" in ein Zahlenfeld wirklich ankommt: Der Browser // - und jsdom mit ihm - laesst den Wert eines `input[type=number]` gar // nicht erst ungueltig werden, gemeldet wird ein leeres Feld. Genau diesen // Weg hat die Pruefung in der laufenden Anwendung nachgestellt: Feldwert // danach "130", aria-invalid="true" am selben Knoten. feld.value = 'abc'; feld.dispatchEvent(new Event('change')); expect(feld.value, 'vorheriger Wert steht wieder im Feld').toBe('130'); expect(gemeldet, 'ein abgelehnter Wert darf nicht weitergereicht werden').toBe(-1); // Die Meldung bleibt - sie ist die Fehleridentifizierung nach 3.3.1, und // sie nennt Feld, Grund und den wiederhergestellten Wert. const meldung = document.querySelector('.meldung-warnung')?.textContent ?? ''; expect(meldung).toContain('Zulässige Höchstgeschwindigkeit in km/h, K1'); expect(meldung).toContain('Der vorherige Wert 130 wurde wiederhergestellt'); // Der Zustand nicht: In dem Feld steht 130, und daran ist nichts ungueltig. // Die Kennzeichnung blieb bis hierher stehen, bis die Ansicht wechselte - // diese beiden Wege rufen `onChange` nicht, es gibt also keinen Neuaufbau, // der den Knoten ersetzt. expect( feld.hasAttribute('aria-invalid'), 'gueltiger Wert bleibt als fehlerhaft gekennzeichnet', ).toBe(false); }); it('haelt die Kennzeichnung bei einem leeren Pflichtfeld ohne vorherigen Wert', () => { const feld = numberCell(null, () => {}, { min: 0, max: 100 }); document.body.append(feld); feld.value = ''; feld.dispatchEvent(new Event('change')); // Hier bleibt das Feld leer, und eine Pflichtangabe ist damit weiterhin // offen - die Kennzeichnung beschreibt dann den richtigen Zustand. expect(feld.value).toBe(''); expect(feld.getAttribute('aria-invalid')).toBe('true'); }); it('kennzeichnet weiterhin einen uebernommenen Wert ausserhalb der Grenzen', () => { // Gegenprobe zur Aenderung oben: Wo der unzulaessige Wert STEHEN BLEIBT, // gehoert die Kennzeichnung hin - sonst waere die Uebernahme von einer // richtigen Eingabe nicht zu unterscheiden. const feld = numberCell(50, () => {}, { min: 0, max: 100, einheit: '%', ausserhalbUebernehmen: 'Werte außerhalb werden verworfen.', }); document.body.append(feld); feld.value = '130'; feld.dispatchEvent(new Event('change')); expect(feld.value).toBe('130'); expect(feld.getAttribute('aria-invalid')).toBe('true'); }); }); // --- Anzeigedauer der Kurzmeldungen (Befund L8) ----------------------------- describe('Anzeigedauer der Kurzmeldungen ist einstellbar (Befund L8, WCAG 2.2.1)', () => { /* * 2.2.1 "Zeiteinteilung anpassbar" (Stufe A) verlangt fuer eine vom Inhalt * gesetzte Frist mindestens einen von drei Wegen: abschalten, auf das * Zehnfache verlaengern oder vor Ablauf verlaengern. Hinweise und * Erfolgsmeldungen verschwanden nach fest verdrahteten fuenf Sekunden; das * Anhalten bei Zeiger und Fokus ist keiner der drei, weil es eine * Zeigergeste oder einen Tastaturweg voraussetzt. * * Warnungen und Fehler standen ohnehin nie unter einer Frist - der Fall * unten haelt das fest. */ afterEach(() => { setzeMeldungsdauer(5); vi.useRealTimers(); }); function offeneMeldungen(): number { return document.querySelectorAll('.meldung').length; } it('streckt die Dauer im selben Verhaeltnis, in dem der Aufrufer sie angibt', () => { vi.useFakeTimers(); // Zehnfache Vorgabe - der Wert, den 2.2.1 fuer die Anpassung nennt. setzeMeldungsdauer(50); expect(meldungsdauer()).toBe(50); // Der Aufrufer bittet um 2,5 s (so meldet die Kopfzeile "Rueckgaengig: // ..."); gestreckt sind das 25 s. notify('Rückgängig: Phase gelöscht', 'info', 2500); vi.advanceTimersByTime(24_000); expect(offeneMeldungen(), 'zu frueh geschlossen').toBe(1); vi.advanceTimersByTime(2_000); expect(offeneMeldungen()).toBe(0); }); it('laesst die Meldung stehen, wenn die Dauer auf null steht', () => { vi.useFakeTimers(); setzeMeldungsdauer(0); expect(meldungsdauer()).toBe(0); notify('Gespeichert.', 'erfolg'); vi.advanceTimersByTime(600_000); expect(offeneMeldungen(), 'trotz Abschaltung geschlossen').toBe(1); // Von Hand geht sie weiterhin weg - sonst waere aus der Einstellung eine // Falle geworden. document.querySelector('.meldung-schliessen')!.click(); expect(offeneMeldungen()).toBe(0); }); it('aendert an der Vorgabe nichts', () => { vi.useFakeTimers(); setzeMeldungsdauer(DEFAULT_APP_SETTINGS.meldungsdauerSekunden); notify('Gespeichert.', 'erfolg'); vi.advanceTimersByTime(4_900); expect(offeneMeldungen()).toBe(1); vi.advanceTimersByTime(200); expect(offeneMeldungen()).toBe(0); }); it('laesst Warnungen und Fehler unberuehrt stehen', () => { vi.useFakeTimers(); setzeMeldungsdauer(5); notify('„abc" ist keine Zahl.', 'warnung'); vi.advanceTimersByTime(600_000); expect(offeneMeldungen(), 'eine Warnung darf nie von selbst verschwinden').toBe(1); }); it('weist eine unbrauchbare Angabe ab, statt die Uhr zu verstellen', () => { setzeMeldungsdauer(20); setzeMeldungsdauer(Number.NaN); setzeMeldungsdauer(-5); expect(meldungsdauer()).toBe(20); }); }); describe('Anzeigedauer ueberlebt den Programmstart (Befund L8)', () => { it('nimmt nur die angebotenen Stufen und faellt sonst auf die Vorgabe zurueck', () => { for (const stufe of MELDUNGSDAUER_STUFEN) { saveAppSettings({ ...DEFAULT_APP_SETTINGS, meldungsdauerSekunden: stufe }); expect(loadAppSettings().meldungsdauerSekunden).toBe(stufe); } // Ein von Hand eingetragener Zwischenwert: Die Auswahlliste zeigte sonst // eine Dauer an, die gar nicht gilt. // Derselbe Schluessel, den saveAppSettings benutzt - sonst pruefte der Fall // an einer Datei vorbei, die gar nicht gelesen wird. const schluessel = Object.keys(window.localStorage).find((k) => k.endsWith('.settings')); expect(schluessel, 'Einstellungen wurden nicht gesichert').toBeDefined(); window.localStorage.setItem( schluessel!, JSON.stringify({ ...DEFAULT_APP_SETTINGS, meldungsdauerSekunden: 7 }), ); expect(loadAppSettings().meldungsdauerSekunden).toBe( DEFAULT_APP_SETTINGS.meldungsdauerSekunden, ); }); });