import { afterEach, describe, expect, it } from 'vitest'; import { ProjectStore } from '@/app/store'; import { createStandardIntersectionProject } from '@/domain/model/factory'; import { LEERER_LAGEPLAN } from '@/domain/geometrie/lageplan'; import type { Project } from '@/domain/model/project'; import type { ViewDefinition } from '@/ui/shell'; import { compatibilityView } from '@/ui/views/compatibilityView'; import { conflictsView } from '@/ui/views/conflictsView'; import { lageplanView } from '@/ui/views/lageplanView'; import { phasesView } from '@/ui/views/phasesView'; import { planView } from '@/ui/views/planView'; import { reportView } from '@/ui/views/reportView'; import { simulationView } from '@/ui/views/simulationView'; /** * Zwei Zusagen zur Barrierefreiheit, geprueft am Code. * * Zugesagt sind zwei Dinge, die bis August 2026 nicht durchgaengig zutrafen: * * 1. "Umschalter tragen `aria-pressed`" (WCAG 4.1.2). Die vier Filterknoepfe * des Pruefberichts taten es nicht. Welcher Filter gilt, hing allein an der * Klasse `knopf-primaer`, also an Farbe und Schriftschnitt - fuer eine * Sprachausgabe hiessen alle vier gleich viel. Und die Trefferzahl in der * Beschriftung hilft nicht: "Fehler (0)" liest sich gleich, ob dieser * Filter gerade greift oder nicht. * 2. Die Farbpunkte seien `aria-hidden` (WCAG 1.1.1). Von neun erzeugenden * Stellen trug genau eine das Merkmal. * * Beide Faelle pruefen deshalb nicht eine Beispielstelle, sondern den ganzen * Satz: alle vier Filterknoepfe und ALLE Farbpunkte einer Ansicht. Ein Fall, * der nur eine Stelle abfragt, haette den Befund nicht gefunden - eine der neun * Stellen war ja in Ordnung. * * NICHT ERFASST, und zwar mit Grund: die Vertraeglichkeitsmatrix. Ihre Knoepfe * sehen wie eine Umschaltergruppe aus, melden ihren Zustand aber bewusst ueber * die Beschriftung statt ueber `aria-pressed`; die Begruendung steht bei ihnen * im Quelltext (compatibilityView.ts). * * WAS HIER NACHWEIS IST UND WAS WAECHTER: Gegen den Stand vor der Behebung * fallen sieben der vierzehn Faelle um - der Filterfall und die sechs * Farbpunktfaelle. Die uebrigen sieben ("haelt den Namen des Knopfes von seinem * Zustand getrennt", "laesst keinen Farbpunkt allein eine Aussage tragen") * blieben auch ohne die Aenderung gruen. Sie belegen die Behebung nicht, * sondern sichern ihre Voraussetzung: `aria-hidden` ist nur zulaessig, solange * neben dem Punkt der Klartext derselben Aussage steht. Faellt der weg, ist * das Verbergen selbst der Fehler. */ const FESTES_DATUM = new Date('2026-01-01T00:00:00Z'); function zeichne( view: ViewDefinition, project: Project, ): { wurzel: HTMLElement; aufraeumen: () => void } { const wurzel = document.createElement('div'); document.body.append(wurzel); const aufraeumen = view.render(wurzel, { store: new ProjectStore(project), refresh: () => {}, navigate: () => {}, target: null, }); return { wurzel, aufraeumen: () => { aufraeumen(); wurzel.remove(); }, }; } afterEach(() => { document.body.replaceChildren(); }); // --- Befund 1: Filter des Pruefberichts ------------------------------------- /** Die vier Knoepfe in der Reihenfolge, in der sie in der Ansicht stehen. */ function filterknoepfe(wurzel: HTMLElement): HTMLButtonElement[] { return [...wurzel.querySelectorAll('.abschnitt-aktionen button')]; } /** Beschriftung ohne die Trefferzahl in Klammern. */ function filternamen(wurzel: HTMLElement): string[] { return filterknoepfe(wurzel).map((knopf) => (knopf.textContent ?? '').replace(/\s*\(.*\)$/, '')); } function gemeldeteZustaende(wurzel: HTMLElement): (string | null)[] { return filterknoepfe(wurzel).map((knopf) => knopf.getAttribute('aria-pressed')); } describe('Pruefbericht: der geltende Filter meldet sich als Zustand (WCAG 4.1.2)', () => { it('traegt an genau einem Knopf aria-pressed="true" und an den drei anderen "false"', () => { const { wurzel, aufraeumen } = zeichne( reportView, createStandardIntersectionProject('Prüfbericht', FESTES_DATUM), ); try { expect(filternamen(wurzel)).toEqual(['Alle', 'Fehler', 'Warnungen', 'Hinweise']); // Ausgangslage: "Alle" gilt. expect(gemeldeteZustaende(wurzel)).toEqual(['true', 'false', 'false', 'false']); // Und dasselbe nach jedem Wechsel: Der Pruefbericht baut sich bei jedem // Druck neu auf, und der gemeldete Zustand darf dabei nicht am // abgehaengten Knopf zurueckbleiben. for (const gewaehlt of [1, 2, 3, 0]) { const knopf = filterknoepfe(wurzel)[gewaehlt]; expect(knopf).toBeDefined(); const name = filternamen(wurzel)[gewaehlt]; knopf!.click(); expect(gemeldeteZustaende(wurzel), `nach Druck auf "${name}"`).toEqual( [0, 1, 2, 3].map((i) => (i === gewaehlt ? 'true' : 'false')), ); } } finally { aufraeumen(); } }); it('haelt den Namen des Knopfes von seinem Zustand getrennt', () => { // Der Name darf nicht mitkippen: Traege der gewaehlte Knopf zusaetzlich ein // Wort wie "gewaehlt", saehe eine Sprachausgabe den Zustand zweimal - und // die drei uebrigen Knoepfe hiessen anders als der vierte, obwohl sie // dieselbe Handlung anbieten. const { wurzel, aufraeumen } = zeichne( reportView, createStandardIntersectionProject('Prüfbericht', FESTES_DATUM), ); try { const vorher = filterknoepfe(wurzel).map((knopf) => knopf.textContent); const fehlerknopf = filterknoepfe(wurzel)[1]; expect(fehlerknopf).toBeDefined(); fehlerknopf!.click(); expect(filterknoepfe(wurzel).map((knopf) => knopf.textContent)).toEqual(vorher); } finally { aufraeumen(); } }); }); // --- Befund 2: Farbpunkte --------------------------------------------------- /** * Ein Projekt mit einer gezeichneten Fahrlinie. * * Ohne sie zeigt die Lageplanansicht nur den Satz "Noch keine Fahrlinie * gezeichnet" - und die Pruefung liefe an der Stelle vorbei, die sie meint. */ function mitFahrlinie(): Project { const projekt = createStandardIntersectionProject('Farbpunkte', FESTES_DATUM); return { ...projekt, lageplan: { ...LEERER_LAGEPLAN, linien: [ { id: 'linie-nord-geradeaus', name: 'Nord geradeaus', mode: 'kfz', movement: 'geradeaus', breiteMeter: 3.25, punkte: [ { x: 0, y: 0 }, { x: 100, y: 0 }, ], haltlinieId: null, startT: 0, signalGroupId: null, }, ], }, }; } const ANSICHTEN_MIT_FARBPUNKTEN: readonly (readonly [string, ViewDefinition])[] = [ ['Verträglichkeit', compatibilityView], ['Zwischenzeiten', conflictsView], ['Phasen', phasesView], ['Signalzeitenplan', planView], ['Simulation', simulationView], ['Lageplan', lageplanView], ]; describe('Farbpunkte sind Schmuck und melden sich als solcher (WCAG 1.1.1)', () => { for (const [name, ansicht] of ANSICHTEN_MIT_FARBPUNKTEN) { it(`verbirgt in der Ansicht "${name}" jeden einzelnen Farbpunkt`, () => { const { wurzel, aufraeumen } = zeichne(ansicht, mitFahrlinie()); try { const punkte = [...wurzel.querySelectorAll('.farbpunkt')]; // Ohne diese Schranke ginge der Fall auch dann durch, wenn die Ansicht // gar nichts gezeichnet haette. expect(punkte.length, `keine Farbpunkte in "${name}" gefunden`).toBeGreaterThan(0); const offen = punkte.filter((punkt) => punkt.getAttribute('aria-hidden') !== 'true'); expect( offen.length, `${offen.length} von ${punkte.length} Farbpunkten in "${name}" ohne aria-hidden`, ).toBe(0); } finally { aufraeumen(); } }); it(`laesst in der Ansicht "${name}" keinen Farbpunkt allein eine Aussage tragen`, () => { // Verbergen ist nur dann richtig, wenn die Aussage anderswo im Klartext // steht. Geprueft wird das an der Umgebung: Der Punkt hat keinen eigenen // Text, und die Zelle oder Schaltflaeche um ihn herum hat einen. const { wurzel, aufraeumen } = zeichne(ansicht, mitFahrlinie()); try { for (const punkt of wurzel.querySelectorAll('.farbpunkt')) { expect(punkt.textContent, 'ein verborgener Punkt mit eigenem Text').toBe(''); const traeger = punkt.closest('td, th, button'); expect(traeger, 'Farbpunkt ohne umgebende Zelle oder Schaltflaeche').not.toBeNull(); expect( (traeger!.textContent ?? '').trim().length, `Farbpunkt in "${name}" ist der einzige Inhalt seiner Umgebung`, ).toBeGreaterThan(0); } } finally { aufraeumen(); } }); } });