import { describe, expect, it } from 'vitest'; import { buildSignalPlan } from '@/domain/plan/signalPlan'; import { validateProject } from '@/domain/validation/engine'; import { createPhase, createSignalGroup, createStandardIntersectionProject, } from '@/domain/model/factory'; import type { Project } from '@/domain/model/project'; /** * Eine Hoechstfreigabezeit unter der Mindestfreigabezeit wird auch * gemeldet, wenn sie in den Projektvorgaben steht. * * Vor Fassung 5.13.0 meldete `phaseMaxGreen` (plan/signalPlan.ts) * den Fall nur fuer eine an der SIGNALGRUPPE eingetragene Zahl; bei * `massgebend === null` kehrte die Funktion ohne Meldung und mit dem * unveraenderten Regelwert zurueck. Derselbe Sachverhalt wurde damit einmal * gemeldet und einmal nicht - je nachdem, an welcher Stelle der Bearbeiter die * Zahl eingetragen hatte. * * DAS ERGEBNIS IST IN BEIDEN FAELLEN DASSELBE, DER BERICHT NICHT. Gemessen an * der Standardvorlage mit minGreenKfz 40 s und maxGreen 10 s bleiben tU 100 s * und die Freigabezeiten 40/40 s unveraendert; die Verteilung bekam aber eine * Phase mit maxGreen < minGreen, und daraus stand die Warnung * 'freigabezeit-rest' im Bericht - "Alle Phasen erreichen ihre * Höchstfreigabezeit; 30 s der Umlaufzeit bleiben übrig ... von 10 s auf 40 s" * -, obwohl beide Phasen mit 40 s das Vierfache ihrer angeblichen Obergrenze * fuehrten. Mit einer dritten Phase und fester Umlaufzeit von 120 s sind es * vorher wie nachher 60/40/10 s, doch dieselbe Warnung nannte 50 s Rest und * eine Verlaengerung "von 10 s auf 60 s", waehrend es 20 s und "von 40 s auf * 60 s" sind. Angehoben wird die Schranke deshalb hier, gleich der Behandlung * der Signalgruppenvorgabe. * * GEGEN DEN ALTSTAND (gemessen: `signalPlan.ts` auf den Stand vor Fassung 5.13.0 * zurueckgespielt) sind vier der sieben Faelle rot: die beiden zur * Vorgabemeldung, der zur fortgefallenen und der zum berichtigten Wortlaut der * Restmeldung. Die uebrigen drei sind Gegenproben - Umlaufzeit und * Freigabezeiten unveraendert, Regelwert ueber allen Mindestfreigabezeiten, * Vorgabe an der Signalgruppe - und vorher wie nachher gruen. * * NACHTRAG (10.09.2026, Fassung 5.23.0): Die Zahlenreihe 60/40/10 s und die * Verlaengerung "von 40 s auf 60 s" gelten dem Stand bis 5.22.0 und stehen * oben, weil sie den damaligen Befund tragen. Der Rest der * Freigabezeitverteilung geht seither nicht mehr ganz an die Phase mit dem * groessten Gewicht, sondern an alle Phasen mit Spielraum: Hier sind es * 40/40/30 s, und die Meldung nennt die Empfaengerphase statt einer * Verlaengerung. Nachgezogen ist deshalb ein Fall dieser Datei; der Befund * dieser Datei bleibt davon unberuehrt. Warum die Verteilung geaendert wurde, * steht in tests/domain/freigabezeitrestVerteilung.test.ts. */ const DATUM = new Date('2026-09-07T10:00:00Z'); /* * ZWEI KENNUNGEN, EIN SACHVERHALT (Nachtrag). Der naechste Schritt ist * je Fall ein anderer - an der Signalgruppe etwas entfernen oder anheben, oder * die Zahl in der Vorgabenverwaltung -, und `suggestionFor` in * domain/validation/engine.ts kennt nur die Kennung, nicht die Meldung. Unter * einer gemeinsamen Kennung schickte der gedruckte Rat den Bearbeiter im * Vorgabefall an eine Signalgruppe, an der nichts eingetragen ist. */ const KENNUNG_GRUPPE = 'hoechstfreigabezeit-unter-mindestfreigabezeit'; const KENNUNG_VORGABE = 'hoechstfreigabezeit-vorgabe-unter-mindestfreigabezeit'; /** Ein Grundprojekt je Fall: createStandardIntersectionProject vergibt neue Kennungen. */ function vorlage(rilsa: Record): Project { const basis = createStandardIntersectionProject('R2', DATUM); return { ...basis, settings: { ...basis.settings, rilsa: { ...basis.settings.rilsa, ...rilsa } }, }; } /** Dieselbe Vorlage mit einer dritten, reinen Fussgaengerphase und fester Umlaufzeit. */ function vorlageMitDritterPhase(rilsa: Record, umlaufzeit: number): Project { const basis = vorlage(rilsa); const gruppe = createSignalGroup({ name: 'F9', mode: 'fuss', armId: basis.intersection.arms[0]?.id ?? null, index: 6, }); const phasen = [...basis.phases, createPhase('Phase 3 - Fuß', [gruppe.id])]; return { ...basis, signalGroups: [...basis.signalGroups, gruppe], phases: phasen, program: { ...basis.program, method: 'manuell', manualCycleTime: umlaufzeit, phaseOrder: phasen.map((p) => p.id), }, }; } describe('Eine Hoechstfreigabezeit aus den Projektvorgaben unter der Mindestfreigabezeit', () => { it('wird gemeldet - einmal fuer die Anlage, mit allen betroffenen Phasen', () => { const plan = buildSignalPlan(vorlage({ minGreenKfz: 40, maxGreen: 10 })); const treffer = plan.notes.filter((n) => n.code === KENNUNG_VORGABE); expect(treffer, 'genau eine Meldung, nicht eine je Phase').toHaveLength(1); expect(treffer[0]!.severity, 'das Ergebnis ist richtig, die Eingabe irrefuehrend').toBe( 'warnung', ); expect(treffer[0]!.signalGroupId, 'die Zahl steht an keiner Signalgruppe').toBeUndefined(); expect(treffer[0]!.message).toContain('In den Projektvorgaben steht eine Höchstfreigabezeit'); expect(treffer[0]!.message, 'der eingetragene Wert').toContain('10 s'); expect(treffer[0]!.message, 'erste betroffene Phase').toContain('"Phase 1 - Nord/Süd" (40 s)'); expect(treffer[0]!.message, 'zweite betroffene Phase').toContain('"Phase 2 - Ost/West" (40 s)'); }); it('aendert an Umlaufzeit und Freigabezeiten nichts, wo die Zeit ohnehin aufgeht', () => { // Die Mindestfreigabezeit ging schon bisher vor - an dieser Vorlage // bleiben tU und Freigabezeiten unveraendert. Was sich aendert, ist die // Auskunft daneben. const plan = buildSignalPlan(vorlage({ minGreenKfz: 40, maxGreen: 10 })); expect(plan.cycleTime).toBe(100); expect(plan.phases.map((p) => p.duration)).toEqual([40, 40]); }); it('nimmt der Verteilung die falsche Restmeldung, weil keine Phase mehr maxGreen < minGreen traegt', () => { /* * Bis zur Aenderung stand hier 'freigabezeit-rest' mit dem Satz "Alle * Phasen erreichen ihre Höchstfreigabezeit" - bei Freigabezeiten von * 40 s gegen eine angebliche Obergrenze von 10 s. */ const plan = buildSignalPlan(vorlage({ minGreenKfz: 40, maxGreen: 10 })); expect(plan.notes.map((n) => n.code)).not.toContain('freigabezeit-rest'); }); it('nennt die Restmeldung die Phasen, die den Rest wirklich bekommen', () => { /* * Dieselbe Vorlage mit einer dritten Phase und 120 s festem Umlauf: Hier * bleibt wirklich ein Rest. Vor Fassung 5.13.0 meldete der * Plan 50 s Rest und eine Verlaengerung "von 10 s auf 60 s" - 10 s war die * Zahl, die die Phase nie hatte. * * NACHGEZOGEN AM 10.09.2026 (Fassung 5.23.0): Der Satz "von 40 s auf 60 s" * stand bis dahin hier und war richtig, solange der Rest ganz an EINE * Phase ging. Er geht jetzt an die Phasen mit Spielraum, und das sind hier * die Fussgaengerphase allein - die beiden Kfz-Phasen liegen mit * minGreen === maxGreen fest, weil ihre Hoechstfreigabezeit auf die * Mindestfreigabezeit angehoben wurde. Bewacht wird deshalb, was die * Meldung jetzt zusagt: die Zahl des Rests, die Empfaengerphase und der * Weg, ihn loszuwerden. */ const plan = buildSignalPlan(vorlageMitDritterPhase({ minGreenKfz: 40, maxGreen: 10 }, 120)); const rest = plan.notes.find((n) => n.code === 'freigabezeit-rest'); expect(rest, 'Restmeldung').toBeDefined(); expect(rest!.message).toContain('20 s der Umlaufzeit bleiben übrig'); expect(rest!.message, 'die Empfaengerphase').toContain('Sie gehen an Phase 3 –'); expect(rest!.message, 'der Weg heraus').toContain('Verkürzen Sie die Umlaufzeit um 20 s'); expect(rest!.message, 'die angebliche Obergrenze 10 s war nie die Freigabezeit').not.toContain( '10 s', ); // Und die Verteilung dazu: Die beiden gleichgestellten Kfz-Phasen bleiben // gleich lang. Bis 5.22.0 bekam Phase 1 den ganzen Rest - 60/40/10 -, // obwohl nichts sie von Phase 2 unterschied. expect(plan.phases.map((p) => p.duration)).toEqual([40, 40, 30]); }); it('schweigt, wo der Regelwert ueber allen Mindestfreigabezeiten liegt', () => { const plan = buildSignalPlan(vorlage({ minGreenKfz: 40 })); const kennungen = plan.notes.map((n) => n.code); expect(kennungen).not.toContain(KENNUNG_VORGABE); expect(kennungen, 'auch die Meldung der Signalgruppe nicht').not.toContain(KENNUNG_GRUPPE); }); it('bleibt die Meldung je Signalgruppe unveraendert und tritt nicht doppelt auf', () => { const basis = vorlage({ minGreenKfz: 40 }); const projekt: Project = { ...basis, signalGroups: basis.signalGroups.map((g) => g.name === 'K1' ? { ...g, maxGreenOverride: 10 } : g, ), }; const alle = buildSignalPlan(projekt).notes; const treffer = alle.filter((n) => n.code === KENNUNG_GRUPPE); expect(treffer, 'eine Meldung, und zwar die der Signalgruppe').toHaveLength(1); expect(treffer[0]!.signalGroupId, 'sie haengt an der Signalgruppe').toBeDefined(); expect(treffer[0]!.message).toContain('Für die Signalgruppe "K1"'); // Nicht doppelt: Der Vorgabewert liegt hier ueber allen // Mindestfreigabezeiten, seine Meldung darf danebenstehen. expect( alle.map((n) => n.code), 'die Vorgabemeldung tritt nicht zusaetzlich auf', ).not.toContain(KENNUNG_VORGABE); }); it('laesst eine Phase mit fester Freigabezeit aus - dort gilt die Handvorgabe', () => { /* * Bei fester Freigabezeit ist der Regelwert nicht die Schranke der Phase; * die Handvorgabe geht ihm vor und haelt die Mindestfreigabezeit ein. * Genannt wird deshalb nur die uebrige Phase. */ const basis = vorlage({ minGreenKfz: 40, maxGreen: 10 }); const projekt: Project = { ...basis, phases: basis.phases.map((phase, index) => index === 0 ? { ...phase, manualGreen: 45 } : phase, ), }; const treffer = buildSignalPlan(projekt).notes.filter((n) => n.code === KENNUNG_VORGABE); expect(treffer).toHaveLength(1); expect(treffer[0]!.message, 'Einzahl bei einer betroffenen Phase').toContain( 'Diese Phase braucht mehr', ); expect(treffer[0]!.message).toContain('"Phase 2 - Ost/West" (40 s)'); expect(treffer[0]!.message, 'die feste Freigabezeit gehoert nicht in die Liste').not.toContain( '"Phase 1 - Nord/Süd"', ); }); /* * DER GEDRUCKTE NAECHSTE SCHRITT - der eigentliche Grund fuer die getrennte * Kennung. * * `suggestionFor` (domain/validation/engine.ts) bekommt nur die Kennung, nicht * die Meldung. Solange beide Faelle dieselbe trugen, stand unter der * Vorgabemeldung der Rat "Entfernen Sie die Höchstfreigabezeit an der * Signalgruppe" - und an keiner Signalgruppe war etwas eingetragen. Der * Bearbeiter suchte an einer Stelle, an der nichts zu finden ist. * * Dieser Fall haelt beide Raete an ihrem Fall fest und die Ueberschriften * dazu. Er ist gegen den Stand mit gemeinsamer Kennung rot. */ it('zeigt der Rat auf die Vorgabenverwaltung und nicht auf eine Signalgruppe', () => { const ausVorgabe = vorlage({ minGreenKfz: 40, maxGreen: 10 }); const befundVorgabe = validateProject(ausVorgabe, buildSignalPlan(ausVorgabe)).findings.find( (f) => f.rule === `signalplan.${KENNUNG_VORGABE}`, ); expect(befundVorgabe, 'der Befund zur Projektvorgabe fehlt').toBeDefined(); expect(befundVorgabe!.suggestion, 'der Rat schickt an eine Signalgruppe').not.toContain( 'an der Signalgruppe', ); expect(befundVorgabe!.suggestion, 'der Rat nennt die Vorgabenverwaltung nicht').toContain( 'Vorgabenverwaltung', ); expect(befundVorgabe!.title, 'kein Rueckfall auf den Sammeltitel').not.toBe( 'Hinweis aus dem Planaufbau', ); // Gegenprobe: Der Fall an der Signalgruppe behaelt seinen Rat. const basis = vorlage({ minGreenKfz: 40 }); const jeGruppe: Project = { ...basis, signalGroups: basis.signalGroups.map((g) => g.name === 'K1' ? { ...g, maxGreenOverride: 10 } : g, ), }; const befundGruppe = validateProject(jeGruppe, buildSignalPlan(jeGruppe)).findings.find( (f) => f.rule === `signalplan.${KENNUNG_GRUPPE}`, ); expect(befundGruppe, 'der Befund zur Signalgruppe fehlt').toBeDefined(); expect(befundGruppe!.suggestion, 'dort gehoert die Signalgruppe in den Rat').toContain( 'an der Signalgruppe', ); expect(befundVorgabe!.title, 'beide Faelle tragen denselben Titel').not.toBe( befundGruppe!.title, ); }); });