import { afterEach, beforeEach, describe, expect, it } from 'vitest'; import * as actions from '@/app/actions'; import { ProjectStore } from '@/app/store'; import type { FindingTarget, ProjektOrt } from '@/domain/validation'; import { ASSISTENT_VORGABE, projektAusAngaben } from '@/ui/assistent'; import { Shell } from '@/ui/shell'; import { conflictsView } from '@/ui/views/conflictsView'; import { phasesView } from '@/ui/views/phasesView'; import { projectView } from '@/ui/views/projectView'; import { reportView } from '@/ui/views/reportView'; import { signalGroupsView } from '@/ui/views/signalGroupsView'; /** * Der Sprung aus dem Pruefbericht (Befunde 51 und 63 zur Fassung 5.8.0). * * Jede Beanstandung mit Fundstelle traegt einen Knopf, dessen Beschriftung das * Ziel namentlich nennt - 'Zu "K1"'. Er wechselte die Ansicht und liess den * Anwender dort am Seitenanfang stehen: Das Sprungziel wurde von der Shell * zwar in den Ansichtskontext gelegt, aber von keiner Stelle ausgewertet, und * `main.scrollTop = 0` zog den Aufbau ausdruecklich an den Anfang. * * Zugesagt ist der Sprung schriftlich ("mit Sprung zum betroffenen Element", * "zeigen auf ihre Konfliktbeziehung"). * * WAS OHNE DIE BEHEBUNG ROT IST: die fuenf Faelle je Zielart (nichts wird * aufgerollt, der Fokus steht auf der Ueberschrift der Ansicht), der Fall zum * fehlenden Ziel (es gab keine Meldung darueber) und der Fall zum Seitenanfang * (er wurde auch beim Sprung auf 0 gezogen). Die beiden letzten Faelle - der * Wechsel ohne Ziel und der Neuaufbau - sind Wachen und keine Nachweise: Sie * gehen auch gegen den Altstand durch und halten fest, dass der neue Weg das * bisherige Verhalten nicht mitnimmt. * * `scrollIntoView` gibt es in jsdom nicht; es wird hier als Aufzeichner * eingesetzt. * * NACHTRAG (Fassung 5.9.0): Die Zielart "projekt" entscheidet seither ueber * ihren ORT, wohin sie fuehrt - dieselbe Zielart landet je nach Ort in einer * anderen Ansicht ("Verkehrsstärken" bei den Signalgruppen, "Projektdaten" im * Projekt). Ein Fall zu dieser Zielart muss den Ort deshalb mitnennen; die * blosse Zielart benennt kein Ziel mehr. Je Ort eine Wache steht in * `tests/ui/sprungorte.test.ts`; hier bleibt der eine Ort * "projektdaten". */ const FESTES_DATUM = '2026-01-01T00:00:00.000Z'; /** Elemente, auf denen `scrollIntoView` gerufen wurde - in der Reihenfolge. */ let angerollt: Element[] = []; let vorherigesAufrollen: PropertyDescriptor | undefined; beforeEach(() => { angerollt = []; vorherigesAufrollen = Object.getOwnPropertyDescriptor(Element.prototype, 'scrollIntoView'); Object.defineProperty(Element.prototype, 'scrollIntoView', { configurable: true, writable: true, value(this: Element) { angerollt.push(this); }, }); document.querySelector('.meldungen')?.remove(); }); afterEach(() => { if (vorherigesAufrollen === undefined) { delete (Element.prototype as Partial).scrollIntoView; } else { Object.defineProperty(Element.prototype, 'scrollIntoView', vorherigesAufrollen); } document.querySelector('.meldungen')?.remove(); }); interface Rahmen { readonly wurzel: HTMLElement; readonly shell: Shell; readonly store: ProjectStore; /** Der Inhaltsbereich - er fuehrt den Rollstand der Seite. */ readonly inhalt: HTMLElement; aufraeumen(): void; } /** * Rahmen mit den vier Zielansichten und dem Pruefbericht. * * Das Projekt wird so zugerichtet, dass der Bericht zu JEDER Zielart aus * `FindingTarget` mindestens eine Beanstandung mit Fundstelle fuehrt: eine * Signalgruppe ohne Bezeichnung, eine Konfliktbeziehung ohne Raeumweg und eine * Phase ohne Signalgruppe. "projekt" und "programm" ergeben sich von selbst - * das Schriftfeld ist leer, und die Verkehrsstaerken fehlen. */ function baueRahmen(): Rahmen { const project = projektAusAngaben( { ...ASSISTENT_VORGABE, fussgaenger: true, rad: true, linksabbieger: true }, FESTES_DATUM, ); const store = new ProjectStore(project); // Die LETZTE Gruppe verliert ihren Namen, nicht die erste: Die erste ist der // Raeumstrom der Konfliktbeziehung unten, und deren Beschriftung im Bericht // setzt sich aus beiden Namen zusammen. const letzteGruppe = project.signalGroups[project.signalGroups.length - 1]; const ersterKonflikt = project.conflicts[0]; if (letzteGruppe === undefined || ersterKonflikt === undefined) { throw new Error('Das Prüfprojekt führt weder Signalgruppen noch Konfliktbeziehungen.'); } store.update((p) => actions.updateSignalGroup(p, letzteGruppe.id, { name: '' }), { label: 'Bezeichnung geleert', }); store.update( (p) => actions.setConflict(p, ersterKonflikt.fromId, ersterKonflikt.toId, { clearingDistance: 0 }), { label: 'Räumweg geleert' }, ); store.update((p) => actions.addPhase(p), { label: 'Phase hinzugefügt' }); const wurzel = document.createElement('div'); document.body.append(wurzel); const shell = new Shell(wurzel, store, '0.0.0-pruefung'); for (const ansicht of [projectView, signalGroupsView, conflictsView, phasesView, reportView]) { shell.addView(ansicht); } shell.start('pruefung'); const inhalt = wurzel.querySelector('main'); if (inhalt === null) throw new Error('Kein Inhaltsbereich im Rahmen.'); return { wurzel, shell, store, inhalt, aufraeumen: () => { shell.stop(); wurzel.remove(); }, }; } /** * Sucht die erste Beanstandung dieser Zielart und drueckt ihren Sprungknopf. * * Fuer die Zielart "projekt" ist `ort` anzugeben: Sie fuehrt je nach Ort in eine * andere Ansicht, die Zielart allein benennt also kein Ziel. */ function springeAusDemBericht( rahmen: Rahmen, art: FindingTarget['kind'], ort?: ProjektOrt, ): FindingTarget { const ziel = rahmen.store.getReport().findings.find((f) => { const t = f.target; if (t === null || t.kind !== art) return false; return ort === undefined || (t.kind === 'projekt' && t.ort === ort); })?.target; if (ziel === undefined || ziel === null) { throw new Error(`Keine Beanstandung mit der Zielart "${art}".`); } const beschriftung = `Zu "${ziel.label}"`; const knopf = [...rahmen.wurzel.querySelectorAll('button')].find( (b) => b.textContent === beschriftung, ); if (knopf === undefined) throw new Error(`Kein Sprungknopf ${beschriftung} im Prüfbericht.`); knopf.click(); return ziel; } /** Abschnittskarte mit diesem sichtbaren Titel. */ function karteMitTitel(rahmen: Rahmen, titel: string): Element | null { return ( [...rahmen.wurzel.querySelectorAll('section.karte')].find( (k) => k.querySelector('.abschnitt-titel')?.textContent === titel, ) ?? null ); } describe('Sprung aus dem Pruefbericht - je Zielart', () => { it('"signalgruppe" rollt die Zeile der Signalgruppe auf und fokussiert sie', () => { const rahmen = baueRahmen(); try { const ziel = springeAusDemBericht(rahmen, 'signalgruppe'); expect(rahmen.shell.aktiveAnsichtId).toBe('signalgruppen'); const zeile = rahmen.wurzel.querySelector(`#sg-${ziel.id}`); expect(zeile, 'keine Zeile zu dieser Signalgruppe').not.toBeNull(); expect(angerollt).toEqual([zeile]); expect(document.activeElement).toBe(zeile); } finally { rahmen.aufraeumen(); } }); it('"phase" rollt die Karte der Phase auf und fokussiert sie', () => { const rahmen = baueRahmen(); try { const ziel = springeAusDemBericht(rahmen, 'phase'); expect(rahmen.shell.aktiveAnsichtId).toBe('phasen'); const karte = rahmen.wurzel.querySelector(`#phase-${ziel.id}`); expect(karte, 'keine Karte zu dieser Phase').not.toBeNull(); expect(angerollt).toEqual([karte]); expect(document.activeElement).toBe(karte); } finally { rahmen.aufraeumen(); } }); it('"konflikt" rollt die Zelle der Konfliktbeziehung auf und fokussiert sie', () => { const rahmen = baueRahmen(); try { const ziel = springeAusDemBericht(rahmen, 'konflikt'); expect(rahmen.shell.aktiveAnsichtId).toBe('zwischenzeiten'); const project = rahmen.store.getProject(); const konflikt = project.conflicts.find((c) => c.id === ziel.id); const von = project.signalGroups.find((g) => g.id === konflikt?.fromId); const nach = project.signalGroups.find((g) => g.id === konflikt?.toId); expect(von, 'räumende Signalgruppe fehlt').toBeDefined(); expect(nach, 'einfahrende Signalgruppe fehlt').toBeDefined(); expect(angerollt).toHaveLength(1); const zelle = angerollt[0] ?? null; expect(document.activeElement).toBe(zelle); expect(zelle?.tagName).toBe('BUTTON'); // Ueber den zugaenglichen Namen und nicht ueber die Kennung: Er ist die // Auskunft, die der Anwender nach dem Sprung bekommt. expect(zelle?.getAttribute('aria-label') ?? '').toContain( `${von?.name ?? ''} räumt, ${nach?.name ?? ''} fährt ein`, ); } finally { rahmen.aufraeumen(); } }); it('"programm" rollt den Abschnitt "Signalprogramm" auf und fokussiert ihn', () => { const rahmen = baueRahmen(); try { springeAusDemBericht(rahmen, 'programm'); expect(rahmen.shell.aktiveAnsichtId).toBe('phasen'); const abschnitt = karteMitTitel(rahmen, 'Signalprogramm'); expect(abschnitt, 'kein Abschnitt "Signalprogramm"').not.toBeNull(); expect(angerollt).toEqual([abschnitt]); expect(document.activeElement).toBe(abschnitt); } finally { rahmen.aufraeumen(); } }); it('"projekt" rollt den Abschnitt "Projektdaten" auf und fokussiert ihn', () => { const rahmen = baueRahmen(); try { springeAusDemBericht(rahmen, 'projekt', 'projektdaten'); expect(rahmen.shell.aktiveAnsichtId).toBe('projekt'); const abschnitt = karteMitTitel(rahmen, 'Projektdaten'); expect(abschnitt, 'kein Abschnitt "Projektdaten"').not.toBeNull(); expect(angerollt).toEqual([abschnitt]); expect(document.activeElement).toBe(abschnitt); } finally { rahmen.aufraeumen(); } }); }); describe('Sprung aus dem Pruefbericht - Randfaelle', () => { it('zieht einen geglueckten Sprung nicht an den Seitenanfang zurueck', () => { const rahmen = baueRahmen(); try { rahmen.inhalt.scrollTop = 400; springeAusDemBericht(rahmen, 'signalgruppe'); expect(rahmen.inhalt.scrollTop).toBe(400); } finally { rahmen.aufraeumen(); } }); it('meldet ein Ziel, das es nicht mehr gibt, und laesst den Fokus nicht ins Leere gehen', () => { const rahmen = baueRahmen(); try { rahmen.inhalt.scrollTop = 400; rahmen.shell.navigate('signalgruppen', 'sg-inzwischen-geloescht'); expect(angerollt).toEqual([]); const ueberschrift = rahmen.wurzel.querySelector('.ansicht-halter h1'); expect(document.activeElement, 'Fokus nicht auf der Ueberschrift').toBe(ueberschrift); expect(rahmen.inhalt.scrollTop).toBe(0); const meldung = document.querySelector('.meldungen .meldung-warnung'); expect(meldung, 'keine Meldung über das fehlende Ziel').not.toBeNull(); expect(meldung?.textContent ?? '').toContain('nicht mehr vorhanden'); } finally { rahmen.aufraeumen(); } }); // Wache, kein Nachweis: geht auch ohne die Behebung durch. it('setzt einen Wechsel ohne Ziel weiterhin an den Seitenanfang', () => { const rahmen = baueRahmen(); try { rahmen.inhalt.scrollTop = 400; rahmen.shell.navigate('phasen'); expect(angerollt).toEqual([]); expect(rahmen.inhalt.scrollTop).toBe(0); } finally { rahmen.aufraeumen(); } }); // Wache, kein Nachweis: Ein Neuaufbau ist kein Ortswechsel - er darf den // Fokus nicht ein zweites Mal auf das Sprungziel reissen, waehrend der // Anwender dort schon arbeitet. it('springt beim Neuaufbau derselben Ansicht kein zweites Mal', () => { const rahmen = baueRahmen(); try { springeAusDemBericht(rahmen, 'signalgruppe'); angerollt.length = 0; rahmen.shell.baueSichtbareAnsichtNeu(); expect(angerollt).toEqual([]); } finally { rahmen.aufraeumen(); } }); }); /** * Der Sprung darf die Tabulatorfolge nicht beschaedigen (WCAG 2.4.3). * * Vier der fuenf Zielarten sind von Haus aus nicht fokussierbar - Zeile, Karte, * Abschnitt. Die Zielart "konflikt" landet dagegen auf einem Knopf, und der ist * es. Ein `tabindex="-1"` darauf naehme ausgerechnet die beanstandete Zelle aus * der Tabulatorfolge: Wer nach dem Sprung weitertabbt oder mit Umschalt und * Tabulator zurueckgeht, uebersprang genau die Zelle, zu der der Knopf ihn * geschickt hat. */ describe('Sprung aus dem Pruefbericht - Tabulatorfolge', () => { it('laesst eine angesprungene Konfliktzelle in der Tabulatorfolge', () => { const rahmen = baueRahmen(); try { springeAusDemBericht(rahmen, 'konflikt'); const zelle = document.activeElement as HTMLElement | null; expect(zelle?.tagName).toBe('BUTTON'); expect(zelle?.tabIndex, 'die Zelle ist aus der Tabulatorfolge gefallen').toBe(0); } finally { rahmen.aufraeumen(); } }); // Wache, kein Nachweis: geht auch ohne die Behebung durch. Sie haelt fest, // dass die Vorkehrung fuer die nicht bedienbaren Ziele bestehen bleibt - // ohne tabindex nimmt eine Tabellenzeile den Fokus gar nicht erst an. it('macht eine nicht bedienbare Zeile programmatisch erreichbar', () => { const rahmen = baueRahmen(); try { springeAusDemBericht(rahmen, 'signalgruppe'); const zeile = document.activeElement as HTMLElement | null; expect(zeile?.tagName).toBe('TR'); expect(zeile?.getAttribute('tabindex')).toBe('-1'); } finally { rahmen.aufraeumen(); } }); });