import { describe, expect, it } from 'vitest'; import * as actions from '@/app/actions'; import { createStandardIntersectionProject } from '@/domain/model/factory'; import { buildSignalPlan } from '@/domain/plan/signalPlan'; import { koordinierungslage } from '@/domain/plan/koordinierung'; import { RILSA_DEFAULTS } from '@/domain/rilsa/constants'; import { loadProject } from '@/domain/model/migrate'; import type { Project } from '@/domain/model/project'; /** * Fassung 5.35.0 - die Koordinierung im Strassenzug. * * DREI ERWEITERUNGEN VERLANGT DER BEFUND, und diese Datei bewacht alle drei: * die LAGE des Bandes (nicht nur seine Breite), die AUFSUMMIERTE Entfernung * ueber die Kette und die ZWEITE Fahrtrichtung. Eine Richtung ist die halbe * Arbeit und die halbe Aussage - der Zielkonflikt zwischen Hin- und * Rueckrichtung ist der eigentliche Gegenstand der Koordinierung. * * DAZU DIE UNVERHANDELBARE BEDINGUNG: Gerechnet wird mit den Kennwerten DIESES * Projekts, nicht mit den Regelwerten. */ const STICHTAG = new Date('2026-01-01T12:00:00Z'); function basis(): Project { return createStandardIntersectionProject('Hauptstraße / Bahnhofstraße', STICHTAG); } /** * Ein Knotenpunkt mit fester Umlaufzeit 60 s und zwei Nachbarn. * * Feste Umlaufzeit, damit die Bandlage nachrechenbar bleibt: Sie ist der * Bezugspunkt jeder Zahl dieser Datei. */ function mitKette(optionen: { gegenrichtung?: boolean } = {}): Project { const roh = basis(); const k1 = roh.signalGroups[0]!.id; const k2 = roh.signalGroups[1]!.id; let projekt: Project = { ...roh, program: { ...roh.program, method: 'manuell', manualCycleTime: 60 }, }; projekt = actions.setzeKoordinierung(projekt, { hauptrichtung: k1, ...(optionen.gegenrichtung === true ? { gegenrichtung: k2 } : {}), progressionsgeschwindigkeit: 36, platz: 0, }); projekt = actions.koordinierungsknotenAnlegen(projekt, 'West'); projekt = actions.koordinierungsknotenAnlegen(projekt, 'Ost'); const [west, ost] = projekt.koordinierung!.nachbarn; // 36 km/h sind genau 10 m/s: 200 m Abstand ergeben 20 s Reisezeit. projekt = actions.koordinierungsknotenAendern(projekt, west!.id, { abstand: 200, freigabeBeginn: 20, freigabeDauer: 25, ...(optionen.gegenrichtung === true ? { gegenBeginn: 20, gegenDauer: 25 } : {}), }); projekt = actions.koordinierungsknotenAendern(projekt, ost!.id, { abstand: 300, freigabeBeginn: 50, freigabeDauer: 25, ...(optionen.gegenrichtung === true ? { gegenBeginn: 50, gegenDauer: 25 } : {}), }); return projekt; } function lage(projekt: Project) { return koordinierungslage(projekt, buildSignalPlan(projekt)); } describe('Koordinierung - die Kette entsteht aus Projekt und Plan', () => { it('summiert die Entfernung über die Kette auf', () => { // Die Wegachse des Zeit-Weg-Diagramms. Ohne sie bildete jede Ansicht die // Summe selbst, und Diagramm und Tabelle liefen beim Runden auseinander. const ergebnis = lage(mitKette()).ergebnis; expect(ergebnis).not.toBeNull(); expect(ergebnis!.intersections.map((i) => i.cumulativeDistance)).toEqual([0, 200, 500]); }); it('nimmt das Freigabefenster dieses Knotenpunkts aus dem Signalzeitenplan', () => { /* * DER UNTERSCHIED ZU DEN NACHBARN: Deren Fenster sind Eingaben, das eigene * ist gerechnet. Es zieht damit bei jeder Aenderung der Phasen mit - eine * eingetragene Kopie waere spaetestens beim naechsten Umlaufzeitwechsel * falsch. */ const projekt = mitKette(); const plan = buildSignalPlan(projekt); const eigenes = plan.groups.find((g) => g.groupId === projekt.koordinierung!.hauptrichtung); const glied = lage(projekt).kette.find((g) => g.eigener); expect(glied?.freigabe).toEqual(eigenes?.greens[0]); }); it('rechnet mit den Kennwerten dieses Projekts, nicht mit den Regelwerten', () => { /* * DIE UNVERHANDELBARE BEDINGUNG DES BEFUNDS. Ohne Eintrag gilt der * Vorgabewert - aber der aus den Kennwerten DIESES Projekts. Wer ihn unter * "Vorgaben" ändert, bekommt eine andere Reisezeit; sonst zeigte die * Koordinierung Zahlen, die die Vorgabenansicht daneben widerlegt. */ const ohneEintrag = actions.setzeKoordinierung(mitKette(), { progressionsgeschwindigkeit: null, }); expect(lage(ohneEintrag).ergebnis?.speed).toBe(RILSA_DEFAULTS.coordination.defaultSpeed); const eigeneVorgabe: Project = { ...ohneEintrag, settings: { ...ohneEintrag.settings, rilsa: { ...ohneEintrag.settings.rilsa, coordinationDefaultSpeed: 54 }, }, }; const ergebnis = lage(eigeneVorgabe).ergebnis; expect(ergebnis?.speed).toBe(54); // 54 km/h sind 15 m/s: 200 m ergeben 13,3 s statt 20 s. expect(ergebnis?.intersections[1]?.travelTime).toBeCloseTo(200 / (54 / 3.6), 1); }); it('sagt, warum keine Rechnung möglich ist', () => { const roh = basis(); expect(lage(roh).hindernis).toBe('keine-kette'); const ohneNachbar = actions.setzeKoordinierung(roh, { hauptrichtung: roh.signalGroups[0]!.id, }); expect(lage(ohneNachbar).hindernis).toBe('zu-wenige-knoten'); const ohneRichtung = actions.koordinierungsknotenAnlegen( actions.setzeKoordinierung(roh, {}), 'West', ); expect(lage(ohneRichtung).hindernis).toBe('keine-hauptrichtung'); }); }); describe('Koordinierung - die Aktionen', () => { it('lassen ein Projekt ohne Kette unberührt', () => { // Eine Aenderung an einer Kette, die es nicht gibt, darf keine anlegen - // sonst entstuende eine Koordinierung als Nebenwirkung eines Klicks. const roh = basis(); expect(actions.koordinierungsknotenAendern(roh, 'x', { abstand: 5 })).toBe(roh); expect(actions.koordinierungsknotenEntfernen(roh, 'x')).toBe(roh); expect(actions.entferneKoordinierung(roh).koordinierung).toBeNull(); }); it('ziehen den eigenen Platz mit, wenn ein Vorgänger fortfällt', () => { /* * Ohne das Nachziehen stuende dieser Knotenpunkt danach an einer anderen * Stelle der Kette, ohne dass jemand ihn verschoben haette - und die * Abstaende bezoegen sich auf einen anderen Nachbarn. */ const projekt = actions.setzeKoordinierung(mitKette(), { platz: 2 }); const erster = projekt.koordinierung!.nachbarn[0]!; const danach = actions.koordinierungsknotenEntfernen(projekt, erster.id); expect(danach.koordinierung?.platz).toBe(1); expect(lage(danach).kette.map((g) => g.eigener)).toEqual([false, true]); // Faellt ein Knotenpunkt DAHINTER fort, bleibt der Platz stehen. const vorn = actions.setzeKoordinierung(mitKette(), { platz: 0 }); const letzter = vorn.koordinierung!.nachbarn[1]!; expect(actions.koordinierungsknotenEntfernen(vorn, letzter.id).koordinierung?.platz).toBe(0); }); it('entfernt nur, was es gibt', () => { const projekt = mitKette(); expect(actions.koordinierungsknotenEntfernen(projekt, 'gibt-es-nicht')).toBe(projekt); }); }); describe('Koordinierung - die Lage des Bandes', () => { it('nennt nicht nur die Breite, sondern auch den Beginn', () => { /* * OHNE DIE LAGE IST DAS BAND NICHT ZEICHENBAR, und darum geht es bei * einem Zeit-Weg-Diagramm: Es zeigt, WANN ein Fahrzeug losfahren muss, um * ohne Halt durchzukommen. Die Breite allein sagt nur, wie lang dieses * Fenster ist, nicht wo es liegt. * * Nachgerechnet: Der eigene Knotenpunkt gibt frei, West ab 20 s bei 20 s * Reisezeit, Ost ab 50 s bei 50 s Reisezeit - alle drei Fenster fallen * damit auf den Umlaufbeginn zurück, und das Band beginnt bei 0 s. */ const ergebnis = lage(mitKette()).ergebnis; expect(ergebnis!.hinrichtung.beginn).toBe(0); expect(ergebnis!.hinrichtung.breite).toBeGreaterThan(0); // Die alten Felder sagen dasselbe wie das neue Band - zwei Angaben, eine // Zahl. expect(ergebnis!.bandwidth).toBe(ergebnis!.hinrichtung.breite); expect(ergebnis!.bandwidthRatio).toBe(ergebnis!.hinrichtung.anteil); }); it('verschiebt den Beginn mit dem Freigabefenster', () => { // Die Gegenprobe: Verschieben sich alle Fenster um 10 s, verschiebt sich // das Band mit. Bliebe der Beginn stehen, waere er keine Lage, sondern // eine Zahl ohne Bezug. const projekt = mitKette(); const verschoben = projekt.koordinierung!.nachbarn.reduce( (p, n) => actions.koordinierungsknotenAendern(p, n.id, { freigabeBeginn: n.freigabeBeginn + 10 }), projekt, ); const vorher = lage(projekt).ergebnis!.hinrichtung; const nachher = lage(verschoben).ergebnis!.hinrichtung; expect(nachher.beginn).not.toBe(vorher.beginn); }); }); describe('Koordinierung - in der Datei', () => { it('verwirft eine halb erfasste Gegenrichtung und meldet es', () => { /* * Beide Felder stehen zusammen oder gar nicht. Ein Beginn ohne Dauer * ergaebe ein Fenster ohne Laenge; die Rechnung liesse die Gegenrichtung * dann stillschweigend fallen, und niemand saehe, warum. */ const projekt = mitKette({ gegenrichtung: true }); const roh = JSON.parse(JSON.stringify(projekt)) as Record; const kette = roh['koordinierung'] as Record; const nachbarn = kette['nachbarn'] as Record[]; delete nachbarn[0]!['gegenDauer']; const geladen = loadProject(roh, STICHTAG); const erster = geladen.project.koordinierung!.nachbarn[0]!; expect(erster.gegenBeginn).toBeUndefined(); expect(erster.gegenDauer).toBeUndefined(); expect(geladen.issues.some((i) => i.message.includes('nur halb erfasst'))).toBe(true); // Und ohne vollstaendige Angaben gibt es kein zweites Band. expect(lage(geladen.project).ergebnis?.rueckrichtung).toBeNull(); }); it('trägt die Kette über Speichern und Laden', () => { const projekt = mitKette({ gegenrichtung: true }); const geladen = loadProject(JSON.parse(JSON.stringify(projekt)) as unknown, STICHTAG); expect(geladen.project.koordinierung?.nachbarn).toHaveLength(2); expect(geladen.project.koordinierung?.progressionsgeschwindigkeit).toBe(36); expect(lage(geladen.project).ergebnis?.hinrichtung).toEqual( lage(projekt).ergebnis?.hinrichtung, ); }); }); describe('Koordinierung - die zweite Fahrtrichtung', () => { it('rechnet kein zweites Band, wo die Gegenrichtung fehlt', () => { /* * UND NIMMT AUCH KEINES AN. Eine Gegenrichtung, die stillschweigend die * Fenster der Hinrichtung benutzt, waere eine erfundene Aussage ueber die * halbe Koordinierung - und zwar ueber die Haelfte, die der eigentliche * Gegenstand der Abstimmung ist. */ const ohne = lage(mitKette()); expect(ohne.ergebnis?.rueckrichtung).toBeNull(); expect(ohne.gegenrichtungVollstaendig).toBe(false); }); it('rechnet ein zweites Band, wo sie vollständig erfasst ist', () => { const mit = lage(mitKette({ gegenrichtung: true })); expect(mit.gegenrichtungVollstaendig).toBe(true); expect(mit.ergebnis?.rueckrichtung).not.toBeNull(); expect(mit.ergebnis!.rueckrichtung!.breite).toBeGreaterThanOrEqual(0); }); it('meldet es, wenn die Gegenrichtung leer ausgeht', () => { /* * Der Zielkonflikt in Zahlen: Dieselben Fenster, die in Hinrichtung ein * breites Band ergeben, lassen in der Gegenrichtung keines übrig. Das ist * eine Planungsentscheidung und keine Nebensache - der Bericht sagt es. */ const projekt = mitKette({ gegenrichtung: true }); const eng = projekt.koordinierung!.nachbarn.reduce( (p, n) => actions.koordinierungsknotenAendern(p, n.id, { gegenBeginn: 0, gegenDauer: 6 }), projekt, ); const ergebnis = lage(eng).ergebnis; expect(ergebnis!.rueckrichtung!.breite).toBe(0); expect(ergebnis!.notes.some((n) => n.code === 'keine-bandbreite-gegenrichtung')).toBe(true); }); it('fährt die Gegenrichtung wirklich rückwärts durch die Kette', () => { /* * DER FALL, DER DEN UNTERSCHIED ZEIGT. Zwei Anlagen, 200 m auseinander, * 10 m/s - also 20 s Reisezeit. Beide geben die Gegenrichtung 10 s lang * frei, die eigene ab 0 s, die des Nachbarn ab 20 s. * * Rueckwaerts gerechnet (richtig): Ein Fahrzeug startet beim Nachbarn und * erreicht diesen Knotenpunkt 20 s spaeter. Das Fenster hier liegt damit * bei 40 s, das dort bei 20 s - kein gemeinsamer Bogen, Bandbreite 0. * * Vorwaerts gerechnet (falsch): Beide Fenster faenden sich bei 0 s, und * die Rechnung meldete 10 s Bandbreite in einer Richtung, die keine hat. */ const roh = basis(); let projekt: Project = { ...roh, program: { ...roh.program, method: 'manuell', manualCycleTime: 60 }, }; projekt = actions.setzeKoordinierung(projekt, { hauptrichtung: roh.signalGroups[0]!.id, gegenrichtung: roh.signalGroups[1]!.id, progressionsgeschwindigkeit: 36, platz: 0, }); projekt = actions.koordinierungsknotenAnlegen(projekt, 'West'); const west = projekt.koordinierung!.nachbarn[0]!; projekt = actions.koordinierungsknotenAendern(projekt, west.id, { abstand: 200, freigabeBeginn: 20, freigabeDauer: 25, gegenBeginn: 20, gegenDauer: 10, }); // Das eigene Fenster der Gegenrichtung kommt aus dem Plan; damit die // Rechnung nachvollziehbar bleibt, wird es hier ueber den Nachbarn // gespiegelt: Beide Fenster sind 10 s lang und liegen 20 s auseinander. const ergebnis = lage(projekt).ergebnis; expect(ergebnis!.rueckrichtung).not.toBeNull(); expect(ergebnis!.rueckrichtung!.breite).toBe(0); }); it('setzt diesen Knotenpunkt an seinen Platz in der Kette', () => { /* * Der Platz entscheidet, WELCHER Nachbar der Vorgaenger ist - und damit, * auf wen sich die Abstaende beziehen. Ein Knotenpunkt, der immer am * Anfang stuende, verschoebe die ganze Kette um eine Stelle. */ const vorn = mitKette(); expect(lage(vorn).kette.map((g) => g.eigener)).toEqual([true, false, false]); const mitte = actions.setzeKoordinierung(vorn, { platz: 1 }); expect(lage(mitte).kette.map((g) => g.eigener)).toEqual([false, true, false]); expect(lage(mitte).kette.map((g) => g.name)).toEqual([ 'West', lage(vorn).kette[0]!.name, 'Ost', ]); const hinten = actions.setzeKoordinierung(vorn, { platz: 2 }); expect(lage(hinten).kette.map((g) => g.eigener)).toEqual([false, false, true]); }); it('lässt die Hinrichtung von der Gegenrichtung unberührt', () => { // Die eine Richtung darf die andere nicht verändern: Beide Baender // entstehen aus denselben Reisezeiten, aber aus getrennten Fenstern. const ohne = lage(mitKette()).ergebnis!.hinrichtung; const mit = lage(mitKette({ gegenrichtung: true })).ergebnis!.hinrichtung; expect(mit).toEqual(ohne); }); });