import { describe, expect, it } from 'vitest'; import { distributeGreenTimes } from '@/domain/rilsa/greenSplit'; import { buildSignalPlan } from '@/domain/plan/signalPlan'; import { createConflict, createEmptyProject, createPhase, createSignalGroup, } from '@/domain/model/factory'; import type { Project } from '@/domain/model/project'; /** * Der Rest der Freigabezeitverteilung geht an alle Phasen mit Spielraum, nicht * an eine. * * DER ANLASS IST EIN NACHGERECHNETER PLAN, keine Ueberlegung am Quelltext: ein * angeordneter Plan, einstreifige Verkehrsfuehrung, zwei Richtungen, * derselbe Raeumweg, dieselbe Zwischenzeit, dieselbe Hoechstfreigabezeit - und * das Programm gab der einen Richtung 44 s Gruen und der anderen 40 s. An einer * Baustellenampel ist das nicht erklaerbar: Es gibt nichts, was die beiden * Richtungen unterscheidet. Die Frage "wie kann es hier ueberhaupt einen * Unterschied geben" hat die Stelle gefunden. * * WAS DAHINTER STAND. Liegt jede Phase an ihrer Hoechstfreigabezeit und bleibt * von der festen Umlaufzeit trotzdem etwas uebrig, muss dieser Rest vergeben * werden - die Summe der Phasendauern hat die Umlaufzeit zu treffen. Bis 5.22.0 * ging er ganz an die Phase mit dem groessten Gewicht. Ohne Verkehrsstaerken * sind alle Gewichte gleich; dann entschied der Index, und das war die erste * Phase. Ein Gleichstand ist keine Auskunft darueber, wer die Zeit bekommen * soll. * * WAS JETZT GILT: Der Rest geht nach Gewichten an die Phasen, die einen Bereich * haben - und wo keine Verkehrsstaerken vorliegen, zu gleichen Teilen. Phasen, * deren Dauer festliegt (`minGreen === maxGreen`), bleiben aussen vor, solange * es andere gibt. Erst wenn jede Phase festliegt, werden alle ueberschritten. * * MUTATIONSNACHWEIS (10.09.2026, sechs Mutationen an * `src/domain/rilsa/greenSplit.ts`): Jede wird gefunden. Der Rueckfall auf die * erste Phase reisst sechs der neun Faelle nieder, ein unterbliebenes Vergeben * des Rests ebenfalls sechs; die Verteilung ueber wirklich alle Phasen, das * Uebergehen der Verkehrsstaerken und die beiden verstuemmelten Meldungen je * einen bis zwei. Zwei Faelle bleiben bei jeder Mutation gruen, und das ist * richtig so: Sie sind die Gegenproben mit einer Umlaufzeit von 96 s, bei der * gar kein Rest entsteht. */ const T = new Date('2026-01-22T00:00:00Z'); /** Zwei gleichgestellte Richtungen einer einstreifigen Fuehrung. */ function zweiRichtungen(umlauf: number, gewicht = 0) { return distributeGreenTimes( umlauf, [ { id: 'a', weight: gewicht, minGreen: 10, maxGreen: 40 }, { id: 'b', weight: gewicht, minGreen: 10, maxGreen: 40 }, ], [8, 8], ); } const gruen = (ergebnis: ReturnType): number[] => ergebnis.phases.map((p) => p.green); const restmeldung = (ergebnis: ReturnType) => ergebnis.notes.find((n) => n.code === 'freigabezeit-rest'); describe('Der Rest der Freigabezeitverteilung', () => { it('teilt sich gleichmaessig, wo nichts die Phasen unterscheidet', () => { // 100 s minus 2 x 8 s Zwischenzeit sind 84 s; zwei Hoechstfreigabezeiten // von 40 s nehmen davon 80 s auf. Die uebrigen 4 s gehoeren beiden. expect(gruen(zweiRichtungen(100))).toEqual([42, 42]); }); it('nennt die Meldung beide Phasen und die Kuerzung, die den Rest beseitigt', () => { const meldung = restmeldung(zweiRichtungen(100)); expect(meldung, 'Restmeldung').toBeDefined(); expect(meldung!.severity).toBe('warnung'); expect(meldung!.message, 'die Zahl des Rests').toContain('4 s der Umlaufzeit bleiben übrig'); expect(meldung!.message, 'beide Empfaenger').toContain('Sie gehen an Phase 1, Phase 2'); /* * Die Zahl, die weiterhilft: 100 - 4 = 96 s ist die groesste Umlaufzeit, * die sich mit diesen Hoechstfreigabezeiten einhalten laesst. Bis 5.22.0 * stand dort nur "Verkürzen Sie die Umlaufzeit", ohne Betrag - der Planer * musste ihn selbst ausrechnen. */ expect(meldung!.message, 'der Weg heraus').toContain('Verkürzen Sie die Umlaufzeit um 4 s'); }); it('schweigt bei der Umlaufzeit, die aufgeht', () => { const ergebnis = zweiRichtungen(96); expect(gruen(ergebnis)).toEqual([40, 40]); expect(restmeldung(ergebnis), 'ohne Rest keine Meldung').toBeUndefined(); }); it('laesst eine von Hand vorgegebene Freigabezeit unangetastet', () => { /* * GEGENPROBE ZUR GLEICHVERTEILUNG, und sie hat den ersten Anlauf dieser * Aenderung umgeworfen: Wer eine feste Freigabezeit eintraegt, hat * entschieden. Der Rest gehoert dann ganz an die Phase mit Bereich, auch * wenn das unsymmetrisch aussieht - hier unterscheidet die Vorgabe die * beiden Phasen sehr wohl. */ const ergebnis = distributeGreenTimes( 80, [ { id: 'fest', weight: 0.4, minGreen: 6, maxGreen: 6 }, { id: 'frei', weight: 0.6, minGreen: 10, maxGreen: 60 }, ], [5, 5], ); expect(gruen(ergebnis)).toEqual([6, 64]); expect(restmeldung(ergebnis)!.message, 'nur die Phase mit Bereich').toContain( 'Sie gehen an Phase 2 –', ); expect(restmeldung(ergebnis)!.message, 'die Zusage an die Vorgabe').toContain( 'Phasen, deren Freigabezeit festliegt, bleiben unangetastet.', ); }); it('ueberschreitet alle Vorgaben, wo keine Phase mehr Spielraum hat - und sagt es', () => { const ergebnis = distributeGreenTimes( 80, [ { id: 'a', weight: 0, minGreen: 20, maxGreen: 20 }, { id: 'b', weight: 0, minGreen: 20, maxGreen: 20 }, ], [5, 5], ); // 70 s verfuegbar, 40 s vorgegeben: Die 30 s Rest lassen sich nirgends // sonst unterbringen. expect(gruen(ergebnis)).toEqual([35, 35]); const meldung = restmeldung(ergebnis); expect(meldung!.message, 'der Vorbehalt faellt fort').not.toContain('bleiben unangetastet'); expect(meldung!.message, 'und wird durch das Gegenteil ersetzt').toContain( 'Keine Phase hat dabei Spielraum; auch eine fest vorgegebene Freigabezeit wird überschritten.', ); }); it('folgt der Verkehrsstaerke, wo eine vorliegt', () => { /* * Gleichverteilung ist nicht das Ziel, sondern die Folge fehlender * Angaben. Wo Verkehrsstaerken erfasst sind, ist eine unterschiedliche * Freigabezeit begruendet - und dann soll sie auch unterschiedlich sein. */ const ergebnis = zweiRichtungen(100, 0); const gewichtet = distributeGreenTimes( 100, [ { id: 'a', weight: 0.6, minGreen: 10, maxGreen: 40 }, { id: 'b', weight: 0.2, minGreen: 10, maxGreen: 40 }, ], [8, 8], ); expect(gruen(ergebnis), 'ohne Verkehrsstaerken gleich').toEqual([42, 42]); expect(gruen(gewichtet), 'mit Verkehrsstaerken nach Bedarf').toEqual([43, 41]); }); it('trifft die Summe in jedem dieser Faelle die verfuegbare Zeit', () => { // Der Grund, aus dem der Rest ueberhaupt vergeben werden muss. Ein // Signalzeitenplan, dessen Phasendauern die Umlaufzeit verfehlen, ist // nicht schaltbar. for (const umlauf of [96, 97, 98, 99, 100, 101, 110]) { const ergebnis = zweiRichtungen(umlauf); const summe = gruen(ergebnis).reduce((a, b) => a + b, 0); expect(summe, `Umlaufzeit ${umlauf} s`).toBe(ergebnis.availableGreen); } }); }); /** * Und derselbe Sachverhalt am ganzen Plan statt an der Verteilung allein: Der * nachgerechnete Plan B, aufgebaut aus den Angaben der Anordnung. */ describe('Die einstreifige Fuehrung von Plan B, nachgebaut', () => { function planB(umlauf: number): Project { const basis = createEmptyProject('Plan B', T, 'einstreifig'); const richtung = (name: string, index: number) => ({ ...createSignalGroup({ name, mode: 'kfz' as const, vZul: 50, index }), minGreenOverride: 10, maxGreenOverride: 40, }); const k1 = richtung('K1', 0); const k2 = richtung('K2', 1); const p1 = createPhase('Seite A', [k1.id]); const p2 = createPhase('Seite B', [k2.id]); const beziehung = (von: string, nach: string) => ({ ...createConflict(von, nach), clearingDistance: 40, enteringDistance: 0, }); return { ...basis, signalGroups: [k1, k2], phases: [p1, p2], conflicts: [beziehung(k1.id, k2.id), beziehung(k2.id, k1.id)], program: { ...basis.program, phaseOrder: [p1.id, p2.id], manualCycleTime: umlauf, method: 'manuell' as const, }, }; } it('gibt beiden Richtungen dieselbe Freigabezeit', () => { const plan = buildSignalPlan(planB(100)); expect(plan.cycleTime).toBe(100); expect( plan.transitions.map((t) => t.duration), 'Zwischenzeit je Richtung', ).toEqual([8, 8]); // Bis 5.22.0 standen hier 44 und 40 s. expect(plan.phases.map((p) => p.duration)).toEqual([42, 42]); }); it('braucht 96 s, damit die Hoechstfreigabezeit von 40 s eingehalten ist', () => { const plan = buildSignalPlan(planB(96)); expect(plan.phases.map((p) => p.duration)).toEqual([40, 40]); expect( plan.notes.map((n) => n.code), 'bei 96 s bleibt nichts uebrig', ).not.toContain('freigabezeit-rest'); }); });