import { afterEach, beforeEach, describe, expect, it } from 'vitest'; import { ProjectStore } from '@/app/store'; import type { Lageplan } from '@/domain/geometrie/lageplan'; import { createStandardIntersectionProject } from '@/domain/model/factory'; import type { Project } from '@/domain/model/project'; import { Shell } from '@/ui/shell'; import { lageplanView } from '@/ui/views/lageplanView'; import { phasesView } from '@/ui/views/phasesView'; import { projectView } from '@/ui/views/projectView'; import { reportView } from '@/ui/views/reportView'; import { settingsView } from '@/ui/views/settingsView'; import { signalGroupsView } from '@/ui/views/signalGroupsView'; /** * Der Sprung fuer die Zielart "projekt" trifft den Ort. * * Diese Zielart traegt an allen Fundstellen dieselbe Kennung (die des * Projekts) und unterscheidet neun Orte allein ueber den Anzeigetext. Der * Knopf hiess deshalb 'Zu "Verkehrsstärken"' und landete auf dem Abschnitt * "Projektdaten" der Ansicht "Projekt" - fuer acht der neun Orte war die * Beschriftung eine Zusage, die der Sprung nicht einloest. * * WAS OHNE DIE BEHEBUNG ROT IST: alle neun Ortsfaelle - fuenf landen in der * falschen Ansicht, die uebrigen vier auf dem falschen Abschnitt derselben * Ansicht - und der Fall, der zwei Orte gegeneinander stellt. Er ist der * eigentliche Befund: Zwei Orte, die verschiedene Dinge meinen, kamen am * selben Anker heraus. * * Geprueft wird ueber die Oberflaeche und nicht ueber das Sprungziel-Attribut: * Angesteuert werden soll der Abschnitt, den der Anwender sucht - dass er ein * Attribut traegt, ist der Weg dorthin und nicht die Zusage. * * `scrollIntoView` gibt es in jsdom nicht; es wird hier als Aufzeichner * eingesetzt. */ const FESTES_DATUM = new Date('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(); }); function basisProjekt(): Project { return createStandardIntersectionProject('Prueffall', FESTES_DATUM); } /** * Lageplan mit georeferenziertem Massstab und grober Bodenaufloesung. * * 2000 m auf 7000 Bildpunkte sind 28,6 cm je Bildpunkt - genug fuer * Kfz-Beziehungen, zu grob fuer die Furten des Prueffalls. Genau daraus * entsteht die Beanstandung, die auf den Lageplan zeigt. */ function groberLageplan(): Lageplan { return { arbeitsbereiche: [], signalgeber: [], bild: null, kalibrierung: { von: { x: 0, y: 0 }, bis: { x: 7000, y: 0 }, laengeMeter: 2000, herkunft: 'georeferenziert', }, haltlinien: [], linien: [], }; } interface Rahmen { readonly wurzel: HTMLElement; readonly shell: Shell; aufraeumen(): void; } function baueRahmen(projekt: Project): Rahmen { const wurzel = document.createElement('div'); document.body.append(wurzel); const shell = new Shell(wurzel, new ProjectStore(projekt), '0.0.0-pruefung'); for (const ansicht of [ projectView, signalGroupsView, phasesView, lageplanView, settingsView, reportView, ]) { shell.addView(ansicht); } shell.start('pruefung'); return { wurzel, shell, aufraeumen: () => { shell.stop(); wurzel.remove(); }, }; } /** Drueckt den Sprungknopf, dessen Beschriftung diesen Ort nennt. */ function springeZumOrt(rahmen: Rahmen, ort: string): void { const beschriftung = `Zu "${ort}"`; 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(); } /** 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 ?? '').startsWith(titel), ) ?? null ); } /** Das Feld (Beschriftung samt Auswahl) mit dieser Beschriftung. */ function feldMitBeschriftung(rahmen: Rahmen, beschriftung: string): Element | null { const marke = [...rahmen.wurzel.querySelectorAll('.feld > label')].find( (l) => l.textContent === beschriftung, ); return marke?.parentElement ?? null; } /** Hinweisbalken mit dieser Ueberschrift. */ function balkenMitTitel(rahmen: Rahmen, titel: string): Element | null { return ( [...rahmen.wurzel.querySelectorAll('.hinweisbalken')].find( (b) => b.querySelector('strong')?.textContent === titel, ) ?? null ); } /** Spaltenkopf, dessen Text so beginnt - der Hilfeknopf haengt hinten dran. */ function spaltenkopfMit(rahmen: Rahmen, text: string): Element | null { return ( [...rahmen.wurzel.querySelectorAll('th')].find((th) => (th.textContent ?? '').startsWith(text), ) ?? null ); } interface Ortsfall { /** Anzeigetext des Sprungknopfs - zugleich der Ort. */ readonly ort: string; /** Projekt, dessen Pruefbericht eine Beanstandung zu diesem Ort fuehrt. */ readonly projekt: () => Project; readonly ansicht: string; readonly anker: (rahmen: Rahmen) => Element | null; } const FAELLE: readonly Ortsfall[] = [ { ort: 'Projektdaten', projekt: basisProjekt, ansicht: 'projekt', anker: (r) => karteMitTitel(r, 'Projektdaten'), }, { ort: 'Knotenpunkt', projekt: () => { const basis = basisProjekt(); return { ...basis, intersection: { ...basis.intersection, vZul: 80 } }; }, ansicht: 'projekt', anker: (r) => karteMitTitel(r, 'Knotenpunkt'), }, { ort: 'Anlagenart', projekt: () => ({ ...basisProjekt(), anlagenart: 'einstreifig' }), ansicht: 'projekt', anker: (r) => feldMitBeschriftung(r, 'Anlagenart'), }, { ort: 'Bewertungsverfahren', projekt: () => { const basis = basisProjekt(); return { ...basis, settings: { ...basis.settings, serviceLevelScale: 'HCM' } }; }, ansicht: 'projekt', anker: (r) => feldMitBeschriftung(r, 'Bewertungsverfahren'), }, { ort: 'Signalgruppen', projekt: () => { const basis = basisProjekt(); return { ...basis, signalGroups: [], conflicts: [], demands: [], phases: basis.phases.map((phase) => ({ ...phase, signalGroupIds: [] })), }; }, ansicht: 'signalgruppen', anker: (r) => karteMitTitel(r, 'Signalgruppen'), }, { ort: 'Verkehrsstärken', projekt: () => ({ ...basisProjekt(), demands: [] }), ansicht: 'signalgruppen', anker: (r) => spaltenkopfMit(r, 'Verkehrsstärke'), }, { ort: 'Phasen', projekt: () => { const basis = basisProjekt(); const erste = basis.phases[0]!; return { ...basis, phases: [erste], program: { ...basis.program, phaseOrder: [erste.id] }, }; }, ansicht: 'phasen', anker: (r) => karteMitTitel(r, 'Phasenfolge'), }, { ort: 'Vorgaben', projekt: () => { const basis = basisProjekt(); return { ...basis, settings: { ...basis.settings, ueberfahrzeitAnsatz: 'fest' } }; }, ansicht: 'vorgaben', anker: (r) => balkenMitTitel(r, 'Es gelten durchgängig die Regelwerte.'), }, { ort: 'Lageplan', projekt: () => ({ ...basisProjekt(), lageplan: groberLageplan() }), ansicht: 'lageplan', anker: (r) => balkenMitTitel(r, 'Nur für Kfz-Beziehungen genau genug'), }, ]; describe('Sprung aus dem Pruefbericht - Ort der Zielart "projekt"', () => { for (const fall of FAELLE) { it(`"${fall.ort}" landet in der Ansicht "${fall.ansicht}" auf dem zugehörigen Anker`, () => { const rahmen = baueRahmen(fall.projekt()); try { springeZumOrt(rahmen, fall.ort); expect(rahmen.shell.aktiveAnsichtId).toBe(fall.ansicht); const anker = fall.anker(rahmen); expect(anker, `kein Anker für den Ort "${fall.ort}"`).not.toBeNull(); expect(angerollt).toEqual([anker]); expect(document.activeElement).toBe(anker); } finally { rahmen.aufraeumen(); } }); } }); describe('Sprung aus dem Pruefbericht - zwei Orte, zwei Anker', () => { /* * Der eigentliche Befund. Beide Beanstandungen stehen in demselben Bericht, * beide tragen die Kennung des Projekts, und beide zeigen auf dieselbe * Ansicht - unterschieden sind sie allein durch den Ort. Landen sie auf * demselben Element, ist eine der beiden Beschriftungen unwahr. */ /** * Titel des Abschnitts, auf dem der Fokus nach dem Sprung steht. * * Je Sprung ein eigener Rahmen: Beide Orte liegen in derselben Ansicht, und * verglichen wird der sichtbare Titel und nicht der Knoten - zwei Rahmen * fuehren zwei verschiedene Knoten, auch wenn sie dasselbe zeigen. */ function angesteuerterAbschnitt(projekt: Project, ort: string): string { const rahmen = baueRahmen(projekt); try { springeZumOrt(rahmen, ort); const ziel = document.activeElement; expect(ziel, `kein Sprungziel für "${ort}"`).not.toBeNull(); return ziel?.querySelector('.abschnitt-titel')?.textContent ?? ''; } finally { rahmen.aufraeumen(); } } it('führt "Projektdaten" und "Knotenpunkt" auf verschiedene Abschnitte', () => { const basis = basisProjekt(); const projekt: Project = { ...basis, intersection: { ...basis.intersection, vZul: 80 } }; const projektdaten = angesteuerterAbschnitt(projekt, 'Projektdaten'); const knotenpunkt = angesteuerterAbschnitt(projekt, 'Knotenpunkt'); expect(projektdaten).toBe('Projektdaten'); expect(knotenpunkt).toBe('Knotenpunkt'); expect(knotenpunkt).not.toBe(projektdaten); }); });