import { describe, expect, it } from 'vitest'; import { ProjectStore } from '@/app/store'; import { ASSISTENT_VORGABE, projektAusAngaben } from '@/ui/assistent'; import { beschrifteTabelle, el } from '@/ui/dom'; import { selectCell } from '@/ui/inputs'; import type { Project } from '@/domain/model/project'; import type { ViewDefinition } from '@/ui/shell'; import { phasesView } from '@/ui/views/phasesView'; import { projectView } from '@/ui/views/projectView'; import { signalGroupsView } from '@/ui/views/signalGroupsView'; import { zugaenglicherName } from './zugaenglicherName'; /** * Namen in der zentralen Eingabemaske. * * Befunde M2, M3 und L3. Alle drei betreffen dieselbe Frage: Erfaehrt jemand, * der die Signalgruppentabelle nicht sieht, WELCHE Signalgruppe er gerade * einstellt? * * Gemessen wurde in der laufenden Anwendung: * - 6 Auswahlfelder "Fahrbeziehung" hiessen alle * "Abbiegende Kraftfahrzeuge raeumen langsamer und erhalten laengere * Zwischenzeiten." - bei sechzehn Signalgruppen sechzehnmal derselbe Satz, * ohne Spalte, ohne Zeile. * - 6 Auswahlfelder "Fahrzeugart" ebenso ("Bestimmt den Laengenzuschlag beim * Raeumweg."). * - 6 Farbfelder hiessen alle "Farbe im Signalzeitenplan". * - Die Schaltflaechen hiessen "↑", "↓" und "×" - hinter dem "×" steckt das * Loeschen einer Signalgruppe samt all ihrer Konfliktbeziehungen. * * Deshalb pruefen die Faelle unten nicht nur, DASS ein Name da ist - das tut * `barrierefreiheit.test.ts` -, sondern dass er die Zeile nennt und sich damit * von dem der Nachbarzeile unterscheidet. */ const FESTES_DATUM = '2026-01-01T00:00:00.000Z'; function gefuelltesProjekt(): Project { return projektAusAngaben( { ...ASSISTENT_VORGABE, fussgaenger: true, rad: true, linksabbieger: true }, FESTES_DATUM, ); } function zeichne(view: ViewDefinition): { wurzel: HTMLElement; aufraeumen: () => void } { const wurzel = document.createElement('div'); document.body.append(wurzel); const aufraeumen = view.render(wurzel, { store: new ProjectStore(gefuelltesProjekt()), refresh: () => {}, navigate: () => {}, target: null, }); return { wurzel, aufraeumen: () => { aufraeumen(); wurzel.remove(); }, }; } /** Die Tabelle mit den Signalgruppen - die einzige der Ansicht. */ function signalgruppentabelle(wurzel: HTMLElement): HTMLTableElement { const tabelle = wurzel.querySelector('table'); expect(tabelle, 'Keine Signalgruppentabelle in der Ansicht').not.toBeNull(); return tabelle as HTMLTableElement; } /** Sichtbarer Kopftext einer Spalte ohne den Hilfeknopf, den spaltenkopf() anhaengt. */ function kopftext(th: Element): string { return [...th.childNodes] .filter((k) => !(k instanceof Element && k.classList.contains('hilfe-knopf'))) .map((k) => k.textContent ?? '') .join('') .trim(); } /** Zu jeder Zeile: das Bedienelement der genannten Spalte und der Name der Zeile. */ function spalte( tabelle: HTMLTableElement, kopf: string, ): { feld: HTMLElement; zeilenname: string }[] { const kopfzellen = [...tabelle.querySelectorAll('thead th')]; const index = kopfzellen.findIndex((th) => kopftext(th) === kopf); expect(index, `Keine Spalte "${kopf}" in der Tabelle`).toBeGreaterThanOrEqual(0); return [...tabelle.querySelectorAll('tbody tr')].flatMap((tr) => { const zelle = tr.children[index]; const feld = zelle?.querySelector('input, select, textarea'); // Fussgaenger- und Radgruppen tragen in den Kfz-Spalten nur ein "–". if (feld === null || feld === undefined) return []; const zeilenname = tr.querySelector('input[type="text"]')?.value.trim() ?? ''; return [{ feld, zeilenname }]; }); } describe('Signalgruppentabelle: Auswahlfelder heissen nach Spalte und Zeile (Befund M2)', () => { for (const kopf of ['Fahrbeziehung', 'Fahrzeugart']) { it(`"${kopf}" nennt die Signalgruppe statt des Erklaersatzes`, () => { const { wurzel, aufraeumen } = zeichne(signalGroupsView); try { const felder = spalte(signalgruppentabelle(wurzel), kopf); expect(felder.length, `Keine Felder unter "${kopf}"`).toBeGreaterThan(1); for (const { feld, zeilenname } of felder) { expect(zeilenname, 'Zeile ohne Bezeichnung im Beispielprojekt').not.toBe(''); expect(zugaenglicherName(feld)).toBe(`${kopf}, ${zeilenname}`); } // Der eigentliche Schaden war nicht der schlechte Name, sondern der // GLEICHE: In sechzehn Zeilen hoerte man sechzehnmal denselben Satz. const namen = felder.map(({ feld }) => zugaenglicherName(feld)); expect( new Set(namen).size, `Mehrfach vergebene Namen unter "${kopf}": ${namen.join(' | ')}`, ).toBe(namen.length); } finally { aufraeumen(); } }); } it('stellt den Erklaersatz als Beschreibung zu und nicht als Namen', () => { // Der Satz darf nicht verschwinden, nur weil er kein Name mehr ist: Er // sagt, was die Wahl bewirkt - "Abbiegende Kraftfahrzeuge raeumen // langsamer", und daran haengt jede Zwischenzeit dieser Gruppe. const { wurzel, aufraeumen } = zeichne(signalGroupsView); try { const [erstes] = spalte(signalgruppentabelle(wurzel), 'Fahrbeziehung'); expect(erstes, 'Kein Feld unter "Fahrbeziehung"').toBeDefined(); const feld = erstes?.feld as HTMLElement; const satz = 'Abbiegende Kraftfahrzeuge räumen langsamer und erhalten längere Zwischenzeiten.'; expect(feld.getAttribute('title'), 'Der Kurzhinweis fuer die Maus fehlt').toBe(satz); const kennung = feld.getAttribute('aria-describedby') ?? ''; expect(kennung, 'Das Feld verweist auf keine Beschreibung').not.toBe(''); const beschreibung = wurzel.querySelector(`#${kennung}`); expect(beschreibung?.textContent, 'Die Beschreibung traegt den Erklaersatz nicht').toBe(satz); // Und er ist ausdruecklich NICHT der Name. expect(zugaenglicherName(feld)).not.toContain('räumen langsamer'); } finally { aufraeumen(); } }); it('laesst die Felder mit eigenem Kurzhinweis benannt (Mindestfreigabe, Lastzuege)', () => { /* * Diese beiden Felder tragen einen `title` UND einen Namen. Bis zur * Behebung von M2 setzte die Ansicht ihn von Hand, weil ein `title` das * Feld sonst von der automatischen Benennung ausschloss; jetzt vergibt ihn * `beschrifteTabelle()`. Der Fall haelt fest, dass dabei genau derselbe * Name herauskommt - er steht in jeder Meldung von `numberCell()`. */ const { wurzel, aufraeumen } = zeichne(signalGroupsView); try { const tabelle = signalgruppentabelle(wurzel); for (const [kopf, ausgeschrieben] of [ ['Mindestfreigabe', 'Mindestfreigabezeit in Sekunden'], ['Lastzüge am SV', 'Anteil der Lastzüge am Schwerverkehr in Prozent'], ] as const) { const felder = spalte(tabelle, kopf); expect(felder.length, `Keine Felder unter "${kopf}"`).toBeGreaterThan(0); for (const { feld, zeilenname } of felder) { expect(feld.getAttribute('title'), `"${kopf}" ohne Kurzhinweis`).not.toBeNull(); expect(zugaenglicherName(feld)).toBe(`${ausgeschrieben}, ${zeilenname}`); } } } finally { aufraeumen(); } }); }); describe('Signalgruppentabelle: das Farbfeld nennt seine Signalgruppe (Befund L3)', () => { it('unterscheidet die Farbfelder voneinander', () => { const { wurzel, aufraeumen } = zeichne(signalGroupsView); try { const felder = [ ...signalgruppentabelle(wurzel).querySelectorAll( 'tbody input[type="color"]', ), ]; expect(felder.length, 'Keine Farbfelder in der Tabelle').toBeGreaterThan(1); const namen = felder.map((feld) => zugaenglicherName(feld)); for (const [i, name] of namen.entries()) { const zeile = felder[i]?.closest('tr'); const bezeichnung = zeile?.querySelector('input[type="text"]')?.value.trim() ?? ''; expect(name).toBe(`Farbe im Signalzeitenplan, ${bezeichnung}`); } expect(new Set(namen).size, `Gleichlautende Farbfelder: ${namen.join(' | ')}`).toBe( namen.length, ); } finally { aufraeumen(); } }); }); describe('Schaltflaechen mit einem Zeichen nennen ihre Zeile (Befund M3)', () => { /** Alle Schaltflaechen, deren sichtbare Beschriftung genau ein Zeichen ist. */ const zeichenknoepfe = (wurzel: HTMLElement, zeichen: string): HTMLButtonElement[] => [...wurzel.querySelectorAll('button')].filter( (b) => (b.textContent ?? '').trim() === zeichen, ); it('in der Signalgruppentabelle', () => { const { wurzel, aufraeumen } = zeichne(signalGroupsView); try { for (const [zeichen, tat] of [ ['↑', 'nach oben'], ['↓', 'nach unten'], ['×', 'löschen'], ] as const) { const knoepfe = zeichenknoepfe(wurzel, zeichen); expect(knoepfe.length, `Keine Schaltflaeche "${zeichen}"`).toBeGreaterThan(1); const namen = knoepfe.map((knopf) => { const bezeichnung = knopf .closest('tr') ?.querySelector('input[type="text"]') ?.value.trim() ?? ''; const name = zugaenglicherName(knopf); // Der Name muss die Signalgruppe UND die Tat nennen: "×" allein // sagt nicht, dass hier eine Signalgruppe samt ihrer // Konfliktbeziehungen verschwindet. expect(name).toBe(`Signalgruppe "${bezeichnung}" ${tat}`); return name; }); expect(new Set(namen).size, `Gleichlautende "${zeichen}": ${namen.join(' | ')}`).toBe( namen.length, ); } } finally { aufraeumen(); } }); it('in der Phasenfolge', () => { const { wurzel, aufraeumen } = zeichne(phasesView); try { for (const [zeichen, tat] of [ ['↑', 'nach vorne'], ['↓', 'nach hinten'], ['×', 'löschen'], ] as const) { const knoepfe = zeichenknoepfe(wurzel, zeichen); expect(knoepfe.length, `Keine Schaltflaeche "${zeichen}"`).toBeGreaterThan(1); const namen = knoepfe.map((knopf, i) => { const name = zugaenglicherName(knopf); expect(name, `"${zeichen}" ohne Bezug zur Phase`).toMatch( new RegExp(`^${i + 1}\\. Phase .*${tat}$`), ); return name; }); expect(new Set(namen).size, `Gleichlautende "${zeichen}": ${namen.join(' | ')}`).toBe( namen.length, ); } } finally { aufraeumen(); } }); }); describe('beschrifteTabelle: ein title ist kein Name (Ursache von Befund M2)', () => { it('benennt auch ein Feld, das einen Kurzhinweis traegt', () => { /* * Der Fall gehoert hierher und nicht in die Ansicht: Solange * `benenneBedienelemente()` bei jedem `title` abbrach, kehrte der Befund * mit der naechsten Tabelle wieder, die einem Feld einen Kurzhinweis gibt. */ const satz = 'Ein ganzer Erklärsatz, der keinen Namen ergibt.'; const feld = selectCell('a', [{ value: 'a', label: 'A' }], () => {}, { title: satz }); const tabelle = beschrifteTabelle( el( 'table', {}, el('thead', {}, el('tr', {}, el('th', { text: 'Fahrbeziehung' }))), el('tbody', {}, el('tr', {}, el('td', {}, feld))), ), { beschriftung: 'Probe', zeilenname: () => 'K1' }, ); document.body.append(tabelle); try { expect(zugaenglicherName(feld)).toBe('Fahrbeziehung, K1'); // Der Kurzhinweis bleibt, wo er hingehoert. expect(feld.getAttribute('title')).toBe(satz); } finally { tabelle.remove(); } }); }); // --- Zufahrtentabelle der Ansicht "Projekt" (Befunde M6, L9) ---------------- /** * Die Zufahrtentabelle baute ihre Felder selbst - mit zwei eigenen Empfaengern * statt der Zellenfelder aus `inputs.ts` (Befund M6, WCAG 3.3.1 und 3.3.2). * * `Number(input.value)` machte aus einem geleerten Feld eine 0 und speicherte * sie: null Fahrstreifen, 0 km/h. `min` und `max` standen nur als Attribute im * Baum und wurden nie ausgewertet. Es gab weder `aria-invalid` noch eine * Meldung - die zulaessige Hoechstgeschwindigkeit der Zufahrt geht ueber die * Gelbzeitstaffel unmittelbar in jede Zwischenzeit ein. */ describe('Zufahrten: Zahlenfelder lehnen ab, statt still eine Null zu speichern (Befund M6)', () => { /** Alle Zahlenfelder der Ansicht - in der Zufahrtentabelle und am Knotenpunkt. */ function zahlenfelder(wurzel: HTMLElement): HTMLInputElement[] { return [...wurzel.querySelectorAll('input[type="number"]')]; } it('stellt bei geleertem Feld den vorherigen Wert wieder her und kennzeichnet nichts falsch', () => { const { wurzel, aufraeumen } = zeichne(projectView); try { const felder = zahlenfelder(wurzel); expect(felder.length, 'keine Zahlenfelder gefunden').toBeGreaterThan(0); const feld = felder[0]!; const vorher = feld.value; expect(vorher).not.toBe(''); feld.value = ''; feld.dispatchEvent(new Event('change')); expect(feld.value, 'leeres Feld wurde als 0 uebernommen').toBe(vorher); // Im Feld steht ein einwandfreier Wert - dann gehoert dort auch keine // Fehlerkennzeichnung hin (Befund L6). expect(feld.hasAttribute('aria-invalid')).toBe(false); } finally { aufraeumen(); } }); it('begrenzt einen Wert ausserhalb der Schranken und sagt es', () => { const { wurzel, aufraeumen } = zeichne(projectView); try { const feld = zahlenfelder(wurzel)[0]!; const grenze = Number(feld.getAttribute('max')); expect(Number.isFinite(grenze), 'das Feld nennt keine Obergrenze').toBe(true); feld.value = String(grenze + 100); feld.dispatchEvent(new Event('change')); expect(feld.value, 'die Schranke wurde nicht ausgewertet').toBe(String(grenze)); const meldung = document.querySelector('.meldungen [role="alert"] .meldung'); expect(meldung, 'die Begrenzung geschah stillschweigend').not.toBeNull(); } finally { document.body.replaceChildren(); aufraeumen(); } }); it('nennt den Wertebereich, bevor die Eingabe geschieht', () => { // Eine Sprachausgabe meldet `min`/`max` erst NACH einer Fehleingabe - und // dann ist der eingegebene Wert schon verworfen. const { wurzel, aufraeumen } = zeichne(projectView); try { for (const feld of zahlenfelder(wurzel)) { const hinweisId = feld.getAttribute('aria-describedby'); expect(hinweisId, 'kein Wertebereichshinweis am Feld').not.toBeNull(); const hinweis = wurzel.querySelector(`#${hinweisId!.split(/\s+/)[0]!}`); expect(hinweis?.textContent ?? '').toMatch(/Zulässig sind/); } } finally { aufraeumen(); } }); }); describe('Projektdaten: der Bearbeiter nennt seinen Eingabezweck (Befund L9, WCAG 1.3.5)', () => { it('traegt autocomplete="name" - und nur dieses eine Feld', () => { const { wurzel, aufraeumen } = zeichne(projectView); try { const beschriftung = [...wurzel.querySelectorAll('label')].find( (l) => l.textContent?.trim() === 'Bearbeiter', ); expect(beschriftung, 'Feld "Bearbeiter" nicht gefunden').toBeDefined(); const feld = wurzel.querySelector(`#${beschriftung!.htmlFor}`); expect(feld?.getAttribute('autocomplete')).toBe('name'); /* * Und sonst keines: 1.3.5 gilt fuer Angaben UEBER DEN ANWENDER. Projekt- * bezeichnung, Auftraggeber, Ort und Variante sind Angaben ueber die * Planung; fuer sie kennt die Liste der Eingabezwecke keinen Eintrag, * und `autocomplete="organization"` am Auftraggeber waere sogar falsch - * gemeint ist die Behoerde, die den Auftrag erteilt, nicht die eigene. */ const mitZweck = [...wurzel.querySelectorAll('input[autocomplete]')]; expect(mitZweck).toEqual([feld]); } finally { aufraeumen(); } }); }); describe('Zufahrten: die Loeschschaltflaeche nennt ihre Zufahrt', () => { it('haelt die sichtbare Beschriftung im Namen (WCAG 2.4.6 und 2.5.3)', () => { // Vier gleichlautende Knoepfe "Löschen" untereinander sagen einer // Sprachausgabe nicht, WELCHE Zufahrt verschwindet - und mit ihr gehen // Fahrstreifen und zulaessige Hoechstgeschwindigkeit weg, aus denen die // Zwischenzeiten gerechnet werden. const { wurzel, aufraeumen } = zeichne(projectView); try { const knoepfe = [...wurzel.querySelectorAll('button')].filter( (b) => b.textContent?.trim() === 'Löschen', ); expect(knoepfe.length, 'keine Loeschschaltflaechen gefunden').toBeGreaterThan(1); const namen = knoepfe.map((b) => zugaenglicherName(b)); for (const name of namen) { expect(name).toMatch(/^Zufahrt ".+" löschen$/); } expect(new Set(namen).size, 'die Namen sind nicht unterscheidbar').toBe(namen.length); } finally { aufraeumen(); } }); });