import { describe, expect, it } from 'vitest'; import { buildSignalPlan } from '@/domain/plan/signalPlan'; import { validateProject } from '@/domain/validation/engine'; import { createStandardIntersectionProject } from '@/domain/model/factory'; import { parseProject } from '@/domain/model/schema'; import type { Project } from '@/domain/model/project'; /* * Befunde 36 und 42, behoben in Fassung 5.10.0 - die Hoechstfreigabezeit je * Signalgruppe wurde eingelesen, gemeldet und gespeichert, aber von keiner * Rechnung gelesen. * * `SignalGroup.maxGreenOverride` kam aus der Projektdatei und aus dem * 4.x-Aufstieg (Feld "maxGruen", uebernommen "damit bewusst gesetzte Werte * nicht verloren gehen"), ueberlebte jedes Speichern - und die * Freigabezeitverteilung setzte fuer jede Phase ohne Handvorgabe unbedingt * `defaults.maxGreen`. Damit war die Behandlung genau verkehrt herum: Ein * UNLESBARER Eintrag wurde gemeldet ("Gerechnet wird ohne diese obere * Schranke"), ein LESBARER stumm verworfen. Die Meldung setzt voraus, dass ein * lesbarer Wert als obere Schranke wirkt. * * Massgebend fuer die Phase ist der KLEINSTE Wert ihrer Signalgruppen: Alle * Gruppen einer Phase erhalten dieselbe Freigabezeit, eine Obergrenze ist * nur eingehalten, wenn die kleinste eingehalten ist. Liegt sie unter der * Mindestfreigabezeit, bleibt diese massgebend - sie ist die harte Schranke -, * und die Vorgabe wird als wirkungslos gemeldet statt still uebergangen. * * TRAEGT DIE PHASE EINE FESTE FREIGABEZEIT, bestimmt sie den Wert - die * Schranke der Signalgruppen kommt dann nicht zur Wirkung. Gemeldet wird das * mit dem tatsaechlich massgebenden Wert (Nachtrag zu denselben * Befunden): Der Satz zur Mindestfreigabezeit waere hier falsch, und stumm * verfallen darf der lesbare Eintrag ebenso wenig. * * GEGEN DEN ALTSTAND (5.9.0) schlagen fuenf der acht Faelle fehl: die drei * zur wirkenden Schranke und die beiden zur festen Freigabezeit. Die drei * uebrigen sind Gegenproben (Regelwert ohne Vorgabe, eingehaltene Vorgabe, * unlesbarer Eintrag) und bestehen dort ebenso. */ const DATUM = new Date('2026-01-01T00:00:00Z'); /** Standardknotenpunkt mit fester Umlaufzeit und Vorgaben je Signalgruppe. */ function mitHoechstfreigabezeit( werte: Readonly>, festeFreigabezeitPhase1: number | null = null, ): Project { const roh = createStandardIntersectionProject('Höchstfreigabezeit', DATUM); return { ...roh, signalGroups: roh.signalGroups.map((g) => werte[g.name] === undefined ? g : { ...g, maxGreenOverride: werte[g.name] ?? null }, ), phases: roh.phases.map((p, index) => index === 0 ? { ...p, manualGreen: festeFreigabezeitPhase1 } : p, ), program: { ...roh.program, method: 'manuell', manualCycleTime: 120 }, }; } describe('Befunde 36/42 - Hoechstfreigabezeit je Signalgruppe', () => { it('deckelt die Freigabezeit der Phase mit der Vorgabe der Signalgruppe', () => { const plan = buildSignalPlan(mitHoechstfreigabezeit({ K1: 30 })); expect(plan.phases[0]?.duration).toBeLessThanOrEqual(30); // Die frei gewordene Zeit geht an die zweite Phase; die Summe bleibt der // Umlauf. expect(plan.phases[1]?.duration ?? 0).toBeGreaterThan(39); expect(plan.groups.find((g) => g.name === 'K1')?.totalGreen ?? 0).toBeLessThanOrEqual(30); }); it('nimmt den kleinsten Wert der Signalgruppen einer Phase', () => { // K1 und K2 laufen in derselben Phase. Eine Obergrenze ist nur // eingehalten, wenn die kleinste eingehalten ist. const plan = buildSignalPlan(mitHoechstfreigabezeit({ K1: 40, K2: 25 })); expect(plan.phases[0]?.duration).toBeLessThanOrEqual(25); }); it('meldet eine Vorgabe unter der Mindestfreigabezeit, statt sie still zu uebergehen', () => { const projekt = mitHoechstfreigabezeit({ K1: 3 }); const plan = buildSignalPlan(projekt); const notiz = plan.notes.find( (n) => n.code === 'hoechstfreigabezeit-unter-mindestfreigabezeit', ); expect(notiz).toBeDefined(); expect(notiz?.severity).toBe('warnung'); expect(notiz?.message).toContain('K1'); // Die Mindestfreigabezeit bleibt die harte Schranke. const k1 = plan.groups.find((g) => g.name === 'K1')!; expect(plan.phases[0]?.duration).toBe(k1.mindestfreigabezeit.wert); // Und im Bericht steht die Meldung mit eigener Ueberschrift statt unter // dem Sammeltitel. const befund = validateProject(projekt, plan).findings.find( (f) => f.rule === 'signalplan.hoechstfreigabezeit-unter-mindestfreigabezeit', ); expect(befund?.title).not.toBe('Hinweis aus dem Planaufbau'); expect(befund?.category).toBe('signalgruppen'); }); it('laesst ohne Vorgabe den Regelwert gelten', () => { const plan = buildSignalPlan(mitHoechstfreigabezeit({})); expect(plan.phases.map((p) => p.duration)).toEqual([61, 39]); expect(plan.notes.some((n) => n.code === 'hoechstfreigabezeit-unter-mindestfreigabezeit')).toBe( false, ); }); it('nennt bei einer festen Freigabezeit der Phase diese als massgebend', () => { // Nachtrag: Die Phase traegt 60 s von Hand, die Signalgruppe eine // Hoechstfreigabezeit von 3 s. Massgebend sind die 60 s - der Satz // "Massgebend bleibt die Mindestfreigabezeit" (5 s) war hier falsch und // lief ueber den Bericht in die gedruckte Planunterlage. const projekt = mitHoechstfreigabezeit({ K1: 3 }, 60); const plan = buildSignalPlan(projekt); expect(plan.phases[0]?.duration).toBe(60); expect(plan.notes.some((n) => n.code === 'hoechstfreigabezeit-unter-mindestfreigabezeit')).toBe( false, ); const notiz = plan.notes.find( (n) => n.code === 'hoechstfreigabezeit-unter-fester-freigabezeit', ); expect(notiz?.severity).toBe('warnung'); expect(notiz?.message).toContain('K1'); expect(notiz?.message).toContain('60 s'); expect(notiz?.message).not.toContain('Mindestfreigabezeit'); const befund = validateProject(projekt, plan).findings.find( (f) => f.rule === 'signalplan.hoechstfreigabezeit-unter-fester-freigabezeit', ); expect(befund?.title).not.toBe('Hinweis aus dem Planaufbau'); expect(befund?.category).toBe('signalgruppen'); }); it('meldet die Vorgabe auch dann, wenn sie zwischen Mindest- und fester Freigabezeit liegt', () => { // 20 s liegen ueber der Mindestfreigabezeit (5 s) und unter der festen // Freigabezeit (60 s). Vor dem Nachtrag verfiel der lesbare Eintrag // hier vollstaendig stumm - genau der Zustand, den die Befunde 36 und 42 // beanstanden. const plan = buildSignalPlan(mitHoechstfreigabezeit({ K1: 20 }, 60)); expect(plan.phases[0]?.duration).toBe(60); const notiz = plan.notes.find( (n) => n.code === 'hoechstfreigabezeit-unter-fester-freigabezeit', ); expect(notiz).toBeDefined(); expect(notiz?.message).toContain('20 s'); }); it('schweigt, wo die feste Freigabezeit die Vorgabe einhaelt', () => { // Gegenprobe: 90 s Hoechstfreigabezeit, 60 s fest vorgegeben - die // Schranke ist eingehalten, es gibt nichts zu melden. const plan = buildSignalPlan(mitHoechstfreigabezeit({ K1: 90 }, 60)); expect(plan.phases[0]?.duration).toBe(60); expect(plan.notes.some((n) => n.code.startsWith('hoechstfreigabezeit'))).toBe(false); }); it('rechnet nach einem unlesbaren Eintrag wirklich ohne die obere Schranke', () => { // Die Einlesemeldung sagt "Gerechnet wird ohne diese obere Schranke" - // diese Zusage haelt jetzt, weil ein lesbarer Wert eine ist. const { project, issues } = parseProject({ signalGroups: [ { id: 'k1', name: 'K1', mode: 'kfz', maxGreenOverride: 'lang' }, { id: 'k2', name: 'K2', mode: 'kfz' }, ], phases: [{ id: 'p1', name: 'Phase 1', signalGroupIds: ['k1'] }], }); expect(project.signalGroups[0]!.maxGreenOverride).toBeNull(); expect( issues.some((i) => i.path === 'signalGroups[0].maxGreenOverride' && i.severity === 'warnung'), ).toBe(true); expect( buildSignalPlan(project).notes.some((n) => n.code.startsWith('hoechstfreigabezeit')), ).toBe(false); }); });