import { afterEach, describe, expect, it } from 'vitest'; import { ProjectStore } from '@/app/store'; import { leiteAb, uebernimmSignalgruppen, uebernimmWege, wegSchluessel, } from '@/domain/geometrie/ableitung'; import { REGELBREITE } from '@/domain/geometrie/vermessung'; import { createEmptyProject, createStandardIntersectionProject } from '@/domain/model/factory'; import { phasesView, vergessEinteilungssuche } from '@/ui/views/phasesView'; import type { Lageplan, Planlinie } from '@/domain/geometrie/lageplan'; import type { Project } from '@/domain/model/project'; /** * Die Karte "Zulaessige Phaseneinteilungen". * * SIE ZEIGT ENTWEDER VORSCHLAEGE ODER DEN GRUND, WARUM ES KEINE GIBT. Ein * leeres Feld waere hier das Schlimmste: Der Bearbeiter hielte es fuer "es gibt * keine zulaessige Einteilung", und die Wahrheit ist meistens "die * Vertraeglichkeit ist nicht belegt". */ const STICHTAG = new Date('2026-01-01T00:00:00Z'); function linie(id: string, name: string, punkte: readonly { x: number; y: number }[]): Planlinie { return { id, name, mode: 'kfz', movement: 'geradeaus', breiteMeter: REGELBREITE.kfz, punkte, haltlinieId: null, startT: 0.5, signalGroupId: null, }; } function lageplan(): Lageplan { return { arbeitsbereiche: [], signalgeber: [], haltlinien: [], bild: { datenUrl: 'data:image/png;base64,AAAA', breite: 400, hoehe: 400, herkunft: 'Prüfstück', geladenAm: STICHTAG.toISOString(), }, kalibrierung: { von: { x: 0, y: 0 }, bis: { x: 100, y: 0 }, laengeMeter: 50, herkunft: 'gemessen', }, linien: [ linie('l-w', 'K1', [ { x: 0, y: 100 }, { x: 200, y: 100 }, ]), linie('l-s', 'K2', [ { x: 100, y: 0 }, { x: 100, y: 200 }, ]), linie('l-p', 'K3', [ { x: 0, y: 160 }, { x: 200, y: 160 }, ]), ], }; } /** Ein Projekt, dessen Verträglichkeit vollständig aus dem Lageplan stammt. */ function belegtesProjekt(): Project { const leer: Project = { ...createEmptyProject('Kreuzung', STICHTAG), lageplan: lageplan() }; const gruppen = uebernimmSignalgruppen( leer, leer.lageplan, leer.lageplan.linien.map((l) => l.id), ); const mitPlan: Project = { ...gruppen.project, lageplan: gruppen.lageplan }; const ableitung = leiteAb(mitPlan, mitPlan.lageplan); return uebernimmWege( mitPlan, mitPlan.lageplan, ableitung, ableitung.wege.map((w) => wegSchluessel(w)), STICHTAG, ).project; } function zeichne(project: Project): { wurzel: HTMLElement; store: ProjectStore; aufraeumen: () => void; } { const wurzel = document.createElement('div'); document.body.append(wurzel); const store = new ProjectStore(project); const ende = phasesView.render(wurzel, { store, refresh: () => {}, navigate: () => {}, target: null, }); return { wurzel, store, aufraeumen: () => { ende(); wurzel.remove(); }, }; } function knopf(wurzel: ParentNode, text: string): HTMLButtonElement { const treffer = [...wurzel.querySelectorAll('button')].find( (b) => b.textContent?.trim() === text, ); if (!treffer) throw new Error(`Schaltfläche "${text}" nicht gefunden.`); return treffer; } afterEach(() => { vergessEinteilungssuche(); }); describe('Phasenansicht - zulässige Einteilungen', () => { it('sucht erst auf Knopfdruck', () => { /* * Die Suche baut je Vorschlag einen ganzen Signalzeitenplan. In einer * Zeichnung, die bei jeder Tastatureingabe laeuft, hat das nichts zu * suchen - eine traege Oberflaeche ist das erste, was der Bearbeiter * merkt. */ const { wurzel, aufraeumen } = zeichne(belegtesProjekt()); try { expect(wurzel.textContent).toContain('Die Suche läuft auf Knopfdruck'); expect(wurzel.textContent).not.toContain('gereiht nach der Umlaufzeit'); knopf(wurzel, 'Einteilungen suchen').click(); expect(wurzel.textContent).toContain('gereiht nach der Umlaufzeit'); } finally { aufraeumen(); } }); it('macht ohne Beleg keinen Vorschlag und sagt warum', () => { /* * DIE BEDINGUNG DES BEFUNDS, an der Oberfläche. Das Standardprojekt hat * keinen Lageplan: Seine Verträglichkeit ist nirgends belegt, sie ist nur * nicht widerlegt. */ const { wurzel, aufraeumen } = zeichne( createStandardIntersectionProject('Hauptstraße', STICHTAG), ); try { knopf(wurzel, 'Einteilungen suchen').click(); expect(wurzel.textContent).toContain('Keine Vorschläge'); expect(wurzel.textContent).toContain('nicht belegt'); expect(wurzel.textContent).toContain('Ohne Beleg sind'); // Und kein einziger Vorschlag - auch kein gekennzeichneter. expect(wurzel.textContent).not.toContain('Übernehmen'); } finally { aufraeumen(); } }); it('übernimmt einen Vorschlag erst nach Rückfrage', async () => { const { wurzel, store, aufraeumen } = zeichne(belegtesProjekt()); try { knopf(wurzel, 'Einteilungen suchen').click(); const vorher = store.getProject().phases.length; knopf(wurzel, 'Übernehmen').click(); const dialog = document.querySelector('[role="dialog"]'); expect(dialog, 'Ohne Rückfrage wäre die Einteilung mit einem Klick ersetzt').not.toBeNull(); expect(dialog!.textContent).toContain('in jedem Tagesprogramm'); [...dialog!.querySelectorAll('button')] .find((b) => b.textContent?.trim() === 'Übernehmen')! .click(); await new Promise((fertig) => setTimeout(fertig, 0)); const nachher = store.getProject(); expect(nachher.phases.length).toBeGreaterThan(vorher); // Jede Gruppe hat eine Phase, und die Phasenfolge kennt jede Phase. const inPhasen = new Set(nachher.phases.flatMap((p) => p.signalGroupIds)); expect(inPhasen.size).toBe(nachher.signalGroups.length); expect(nachher.program.phaseOrder).toEqual(nachher.phases.map((p) => p.id)); } finally { aufraeumen(); } }); it('ändert nichts, wenn die Rückfrage abgebrochen wird', async () => { // Die Rueckfrage ist keine Zierde: Sie ersetzt die ganze Phaseneinteilung // samt den festen Freigabezeiten jedes Tagesprogramms. const { wurzel, store, aufraeumen } = zeichne(belegtesProjekt()); try { knopf(wurzel, 'Einteilungen suchen').click(); const vorher = store.getProject(); knopf(wurzel, 'Übernehmen').click(); const dialog = document.querySelector('[role="dialog"]')!; [...dialog.querySelectorAll('button')] .find((b) => b.textContent?.trim() === 'Abbrechen')! .click(); await new Promise((fertig) => setTimeout(fertig, 0)); expect(store.getProject()).toBe(vorher); } finally { aufraeumen(); } }); it('kennzeichnet die heutige Einteilung und bietet sie nicht zum Übernehmen an', async () => { // Ein Knopf, der nichts aendert, ist eine Falle: Der Bearbeiter druecke // ihn, bekaeme einen Rueckgaengig-Schritt und dieselbe Einteilung. const erste = zeichne(belegtesProjekt()); let uebernommen: Project; try { knopf(erste.wurzel, 'Einteilungen suchen').click(); knopf(erste.wurzel, 'Übernehmen').click(); const dialog = document.querySelector('[role="dialog"]')!; [...dialog.querySelectorAll('button')] .find((b) => b.textContent?.trim() === 'Übernehmen')! .click(); await new Promise((fertig) => setTimeout(fertig, 0)); uebernommen = erste.store.getProject(); } finally { erste.aufraeumen(); vergessEinteilungssuche(); } const zweite = zeichne(uebernommen); try { knopf(zweite.wurzel, 'Einteilungen suchen').click(); expect(zweite.wurzel.textContent).toContain('die heutige'); const uebernehmen = [...zweite.wurzel.querySelectorAll('button')].filter( (b) => b.textContent?.trim() === 'Übernehmen', ); const zeilen = [...zweite.wurzel.querySelectorAll('tbody tr')].filter((tr) => tr.textContent?.includes('die heutige'), ); expect(zeilen.length).toBe(1); expect(zeilen[0]!.querySelectorAll('button')).toHaveLength(0); // Die uebrigen Vorschlaege behalten ihren Knopf. expect(uebernehmen.length).toBeLessThan( [...zweite.wurzel.querySelectorAll('tbody tr')].length, ); } finally { zweite.aufraeumen(); } }); });