import { describe, expect, it } from 'vitest'; import * as actions from '@/app/actions'; import { ProjectStore } from '@/app/store'; import { createConflict, createEmptyProject, createPhase, createSignalGroup, createStandardIntersectionProject, } from '@/domain/model/factory'; import { koordinierungslage } from '@/domain/plan/koordinierung'; import { buildSignalPlan } from '@/domain/plan/signalPlan'; import { koordinierungView } from '@/ui/views/koordinierungView'; import type { Project } from '@/domain/model/project'; import type { SignalPlan } from '@/domain/plan/signalPlan'; /** * Fassung 5.43.0: Was die Ansicht "Koordinierung" ueber die Wahl des * Freigabefensters sagt. * * DER BEFUND. Der Hinweis unter der Beurteilung lautete: "Die Signalgruppe der * Hauptrichtung ist in mehreren getrennten Fenstern freigegeben. Gerechnet wird * mit dem laengsten - durch das kuerzere passt der Pulk nicht." Das war der * ganze Satz, und er beschrieb die Rechnung nur zur Haelfte. Seit der Behebung * des Gleichstands (Fall E in tests/domain/gleichstandOhneReihenfolge.test.ts) * waehlt `koordinierungslage` unter mehreren GLEICH LANGEN Fenstern das mit der * groesseren Bandbreite und erst bei gleicher Bandbreite das im Umlauf fruehere. * Der Anwender las eine Auswahlregel, die es so nicht mehr gibt - und nach ihr * waren zwei gleich lange Fenster gar nicht zu unterscheiden. * * WARUM DIE ANSICHT DIESEN SATZ UEBERHAUPT FUEHRT: Es ist eine Auswahl, die * niemand getroffen hat. Ein Hinweis, der sie nennt, aber falsch erklaert, ist * schlechter als keiner - der Anwender rechnet dann mit dem falschen Fenster * nach und haelt das Ergebnis fuer einen Fehler des Programms. * * DIE FAELLE MESSEN DIE RECHNUNG MIT, nicht nur den Wortlaut: Ein Satz ueber * eine Rechnung ist nur so lange richtig, wie die Rechnung ihn traegt. */ const DATUM = new Date('2026-01-01T12:00:00Z'); /** Der alte Wortlaut - er darf nicht wieder allein dastehen. */ const ALTER_SATZ = 'Die Signalgruppe der Hauptrichtung ist in mehreren getrennten Fenstern freigegeben. ' + 'Gerechnet wird mit dem längsten – durch das kürzere passt der Pulk nicht.'; /** * Vier Phasen, die Hauptrichtung in der ersten und der dritten - zwei getrennte * Fenster gleicher Laenge (Aufbau des Falls E, dort gemessen). * * `nachbarBeginn` und `nachbarDauer` legen fest, WELCHES der beiden Fenster ein * Band traegt und ob beide eines tragen. */ function zweiFenster(nachbarBeginn = 20, nachbarDauer = 12): Project { const basis = createEmptyProject('Zwei Fenster', DATUM, 'knotenpunkt'); const a = createSignalGroup({ name: 'A', mode: 'kfz' as const, vZul: 50, index: 0 }); const b = createSignalGroup({ name: 'B', mode: 'kfz' as const, vZul: 50, index: 1 }); const phasen = [ createPhase('P1', [a.id]), createPhase('P2', [b.id]), createPhase('P3', [a.id]), createPhase('P4', [b.id]), ]; const beziehung = (von: string, nach: string) => ({ ...createConflict(von, nach), clearingDistance: 20, enteringDistance: 0, }); let projekt: Project = { ...basis, signalGroups: [a, b], phases: phasen, conflicts: [beziehung(a.id, b.id), beziehung(b.id, a.id)], program: { ...basis.program, method: 'manuell', manualCycleTime: 60, phaseOrder: phasen.map((p) => p.id), }, }; projekt = actions.setzeKoordinierung(projekt, { hauptrichtung: a.id, progressionsgeschwindigkeit: 36, platz: 0, }); projekt = actions.koordinierungsknotenAnlegen(projekt, 'West'); const west = projekt.koordinierung!.nachbarn[0]!; return actions.koordinierungsknotenAendern(projekt, west.id, { abstand: 200, freigabeBeginn: nachbarBeginn, freigabeDauer: nachbarDauer, }); } /** Derselbe Plan mit umgekehrter Reihenfolge der Freigabefenster. */ function mitUmgekehrtenFenstern(plan: SignalPlan, groupId: string): SignalPlan { return { ...plan, groups: plan.groups.map((g) => g.groupId === groupId ? { ...g, greens: [...g.greens].reverse() } : g, ), }; } /** Nur ein Fenster einer Signalgruppe im Plan lassen - fuer die Messung. */ function nurFenster(plan: SignalPlan, groupId: string, index: number): SignalPlan { return { ...plan, groups: plan.groups.map((g) => g.groupId === groupId ? { ...g, greens: g.greens.slice(index, index + 1) } : g, ), }; } /** Alle Hinweistexte der Ansicht, zusammengefuegt wie sie dastehen. */ function hinweise(projekt: Project): string[] { const wurzel = document.createElement('div'); document.body.append(wurzel); const ende = koordinierungView.render(wurzel, { store: new ProjectStore(projekt), refresh: () => {}, navigate: () => {}, target: null, }); try { return [...wurzel.querySelectorAll('.feld-hinweis')].map((p) => (p.textContent ?? '').replace(/\s+/gu, ' ').trim(), ); } finally { ende(); wurzel.remove(); } } /** Der Hinweis zu den mehreren Freigabefenstern. */ function fensterhinweis(projekt: Project): string { const treffer = hinweise(projekt).filter((t) => t.includes('mehreren getrennten Fenstern')); expect(treffer.length, 'der Hinweis zu den mehreren Fenstern steht genau einmal da').toBe(1); return treffer[0]!; } describe('Der Hinweis nennt die Auswahlregel, mit der gerechnet wird', () => { it('Vorbedingung: zwei gleich lange Fenster, und die Ansicht meldet sie', () => { const projekt = zweiFenster(); const gruppe = buildSignalPlan(projekt).groups.find( (g) => g.groupId === projekt.koordinierung!.hauptrichtung, ); expect( gruppe?.greens.map((g) => g.duration), 'gleich lang', ).toEqual([9, 9]); expect(fensterhinweis(projekt)).toContain('mehreren getrennten Fenstern'); }); it('nennt die Bandbreite als das, was bei gleicher Laenge entscheidet', () => { const text = fensterhinweis(zweiFenster()); expect(text, 'die Laenge bleibt die erste Stufe').toContain('längsten'); expect(text, 'die zweite Stufe, die es seit 5.43.0 gibt').toContain('Bandbreite'); expect(text, 'die dritte Stufe, die nichts bedeutet').toContain('im Umlauf frühere'); expect(text, 'der halbe Satz von vorher').not.toBe(ALTER_SATZ); }); it('sagt, dass die letzte Stufe nur festgelegt ist und nichts bedeutet', () => { // Grundsatz: Wo eine stabile Ordnung entscheidet, gehoert dazugeschrieben, // dass sie nichts bedeutet. Der Anwender soll aus dem frueheren Fenster // keinen fachlichen Vorzug lesen. expect(fensterhinweis(zweiFenster())).toContain('Reihenfolge der Phasen'); }); }); describe('Die Rechnung dahinter waehlt wirklich an der Bandbreite', () => { /** Das gewaehlte Freigabefenster dieses Knotenpunkts. */ function gewaehlt(projekt: Project, plan: SignalPlan) { const lage = koordinierungslage(projekt, plan); return { freigabe: lage.kette.find((k) => k.eigener)?.freigabe, bandbreite: lage.ergebnis?.bandwidth, }; } it('nimmt das spaetere Fenster, wenn dieses die groessere Bandbreite traegt', () => { // Gibt der Nachbar erst ab 50 s frei, traegt das Fenster ab 30 s das Band. // Nach dem alten Satz - "das laengste" - waeren beide gleichwertig. const projekt = zweiFenster(50); const haupt = projekt.koordinierung!.hauptrichtung!; const plan = buildSignalPlan(projekt); expect(gewaehlt(projekt, nurFenster(plan, haupt, 0)).bandbreite, 'Vorbedingung').toBe(0); for (const p of [plan, mitUmgekehrtenFenstern(plan, haupt)]) { expect(gewaehlt(projekt, p).freigabe).toEqual({ start: 30, duration: 9 }); expect(gewaehlt(projekt, p).bandbreite).toBe(9); } }); it('nimmt bei gleicher Bandbreite das im Umlauf fruehere', () => { // Der Nachbar gibt von 20 s an 40 s lang frei und deckt damit beide // Bandlagen; die Bandbreite traegt die Entscheidung dann nicht mehr. const projekt = zweiFenster(20, 40); const haupt = projekt.koordinierung!.hauptrichtung!; const plan = buildSignalPlan(projekt); expect( gewaehlt(projekt, nurFenster(plan, haupt, 0)).bandbreite, 'Vorbedingung: beide Fenster tragen dasselbe Band', ).toBe(gewaehlt(projekt, nurFenster(plan, haupt, 1)).bandbreite); for (const p of [plan, mitUmgekehrtenFenstern(plan, haupt)]) { expect(gewaehlt(projekt, p).freigabe).toEqual({ start: 0, duration: 9 }); } }); }); /** * Nachtrag: Der Hinweis hing allein an der HAUPTRICHTUNG. * * `fensterMitBand` waehlt fuer jede Richtung an ihrem eigenen Band - zuerst fuer * die Hin-, dann fuer die Rueckrichtung. Hatte nur die Gegenrichtung mehrere * Fenster, traf das Programm dieselbe dreistufige Wahl und sagte kein Wort * darueber: dieselbe Luecke, die der Satz fuer die Hauptrichtung gerade * geschlossen hat. Vorbestand, durch den neuen Satz sichtbar geworden. */ describe('Der Hinweis nennt die Richtung, deren Fenster gewaehlt wurden', () => { /** Nur die Gegenrichtung ist in zwei getrennten Fenstern frei. */ function nurGegenrichtungMehrfach(): Project { const basis = createEmptyProject('Nur Gegenrichtung', DATUM, 'knotenpunkt'); const a = createSignalGroup({ name: 'A', mode: 'kfz' as const, vZul: 50, index: 0 }); const b = createSignalGroup({ name: 'B', mode: 'kfz' as const, vZul: 50, index: 1 }); const c = createSignalGroup({ name: 'C', mode: 'kfz' as const, vZul: 50, index: 2 }); const phasen = [ createPhase('P1', [b.id]), createPhase('P2', [a.id]), createPhase('P3', [b.id]), createPhase('P4', [c.id]), ]; const beziehung = (von: string, nach: string) => ({ ...createConflict(von, nach), clearingDistance: 20, enteringDistance: 0, }); let projekt: Project = { ...basis, signalGroups: [a, b, c], phases: phasen, conflicts: [beziehung(a.id, b.id), beziehung(b.id, a.id)], program: { ...basis.program, method: 'manuell', manualCycleTime: 60, phaseOrder: phasen.map((p) => p.id), }, }; projekt = actions.setzeKoordinierung(projekt, { hauptrichtung: a.id, gegenrichtung: b.id, progressionsgeschwindigkeit: 36, platz: 0, }); projekt = actions.koordinierungsknotenAnlegen(projekt, 'West'); const west = projekt.koordinierung!.nachbarn[0]!; return actions.koordinierungsknotenAendern(projekt, west.id, { abstand: 200, freigabeBeginn: 20, freigabeDauer: 12, gegenBeginn: 20, gegenDauer: 12, }); } it('meldet den Fall auch, wenn ihn nur die Gegenrichtung hat', () => { const projekt = nurGegenrichtungMehrfach(); const gruppen = buildSignalPlan(projekt).groups; const fenster = (id: string | null) => gruppen.find((g) => g.groupId === id)?.greens.map((g) => g.duration); expect(fenster(projekt.koordinierung!.hauptrichtung), 'Vorbedingung: ein Fenster').toHaveLength( 1, ); expect( fenster(projekt.koordinierung!.gegenrichtung), 'Vorbedingung: zwei Fenster', ).toHaveLength(2); const text = fensterhinweis(projekt); expect(text, 'benannt wird die Richtung, um die es geht').toContain( 'Signalgruppe der Gegenrichtung', ); expect(text, 'und nicht die, die nur ein Fenster hat').not.toContain('der Hauptrichtung ist'); expect(text, 'die Auswahlregel steht auch hier').toContain('Bandbreite'); }); it('nennt beide Richtungen, wenn beide mehrere Fenster haben', () => { // In diesem Aufbau ist auch die Gegenrichtung in zwei Phasen frei (P2, P4). const basis = zweiFenster(); const projekt = actions.setzeKoordinierung(basis, { gegenrichtung: basis.signalGroups[1]!.id, }); const lage = koordinierungslage(projekt, buildSignalPlan(projekt)); expect( lage.mehrereFreigabefenster && lage.mehrereFreigabefensterGegenrichtung, 'Vorbedingung: beide', ).toBe(true); expect(fensterhinweis(projekt)).toContain( 'Signalgruppen der Hauptrichtung und der Gegenrichtung', ); }); it('schweigt, wo keine der beiden Richtungen mehrere Fenster hat', () => { const projekt = createStandardIntersectionProject('Ein Fenster', DATUM); const mitKette = actions.koordinierungsknotenAnlegen( actions.setzeKoordinierung(projekt, { hauptrichtung: projekt.signalGroups[0]!.id, progressionsgeschwindigkeit: 36, platz: 0, }), 'West', ); const west = mitKette.koordinierung!.nachbarn[0]!; const fertig = actions.koordinierungsknotenAendern(mitKette, west.id, { abstand: 200, freigabeBeginn: 10, freigabeDauer: 20, }); expect(hinweise(fertig).some((t) => t.includes('mehreren getrennten Fenstern'))).toBe(false); }); });