import { describe, expect, it } from 'vitest'; import { validateProject } from '@/domain/validation/engine'; import { buildSignalPlan, intergreenKey } from '@/domain/plan/signalPlan'; import { createConflict, createPhase, createStandardIntersectionProject, } from '@/domain/model/factory'; import type { Project } from '@/domain/model/project'; /* * Die Zwischenzeit wurde ausschliesslich ueber die Phasenuebergaenge * durchgesetzt. Das erfasst nur Paare, die ueber EINEN Uebergang gegeneinander * laufen. * * Ab drei Phasen gibt es Paare, auf die das nicht zutrifft: Endet eine Furt in * Phase 1 und beginnt der querende Fahrzeugstrom erst in Phase 3, so endet die * eine beim Uebergang 1 nach 2 und beginnt die andere beim Uebergang 2 nach 3. * Kein einzelner Uebergang bringt die beiden gegeneinander. Die Pruefung auf * gleichzeitige Freigabe faengt das nicht auf, weil sie nur auf Ueberlappung * abtastet, nicht auf zu geringen Abstand. * * Der Stand vor dieser Regel: Furt fordert 33 s, erhaelt 11 s, null Fehler, * Export freigegeben. */ const STICHTAG = new Date('2026-01-01T00:00:00Z'); /** Drei Phasen mit kurzer Mittelphase; F1 in Phase 1, K3 erst in Phase 3. */ function dreiPhasen(optionen: { mittelphase: number; raeumwegFurt: number }): Project { const roh = createStandardIntersectionProject('Abstandspruefung', STICHTAG); const [k1, k2, k3, k4, f1, f2] = roh.signalGroups; const p1 = { ...createPhase('Phase 1 - Nord/Sued mit Furt', [k1!.id, k2!.id, f1!.id]), manualGreen: 20, }; const p2 = { ...createPhase('Phase 2 - nur Nord/Sued', [k1!.id, k2!.id]), manualGreen: optionen.mittelphase, }; const p3 = { ...createPhase('Phase 3 - Ost/West', [k3!.id, k4!.id, f2!.id]), manualGreen: 20 }; return { ...roh, conflicts: [ ...roh.conflicts.map((c) => c.fromId === f1!.id && c.toId === k3!.id ? { ...c, clearingDistance: optionen.raeumwegFurt, enteringDistance: 4 } : c, ), { ...createConflict(k2!.id, f2!.id), clearingDistance: 22, enteringDistance: 6 }, { ...createConflict(f2!.id, k2!.id), clearingDistance: 12, enteringDistance: 8 }, ], phases: [p1, p2, p3], program: { ...roh.program, phaseOrder: [p1.id, p2.id, p3.id], method: 'manuell', manualCycleTime: 80, }, meta: { ...roh.meta, projectNumber: 'VP-2026-001', client: 'Stadt Musterhausen', planner: 'O. Willerding', location: 'Abstandspruefung', }, }; } describe('Zwischenzeit ueber mehr als einen Phasenuebergang', () => { it('meldet einen zu geringen Abstand und sperrt den Export', () => { const projekt = dreiPhasen({ mittelphase: 5, raeumwegFurt: 40 }); const plan = buildSignalPlan(projekt); const bericht = validateProject(projekt, plan, STICHTAG); const [, , k3, , f1] = projekt.signalGroups; const gefordert = plan.intergreens.get(intergreenKey(f1!.id, k3!.id))?.value ?? 0; expect(gefordert).toBeGreaterThan(30); const befund = bericht.findings.find((f) => f.rule === 'zwischenzeiten.abstand-zu-kurz'); expect(befund, 'Der zu geringe Abstand muss gemeldet werden').toBeDefined(); expect(befund!.severity).toBe('fehler'); expect(befund!.category).toBe('zwischenzeiten'); expect(befund!.title).toContain('F1'); expect(befund!.title).toContain('K3'); // Die Meldung muss den tatsaechlichen und den geforderten Wert nennen - // ohne beide Zahlen kann der Bearbeiter nicht entscheiden, wie viel fehlt. expect(befund!.message).toContain(`${gefordert} s`); // Deutsches Dezimalkomma. Ein englischer Punkt stuende in derselben // Unterlage neben den Zahlen aller uebrigen Meldungen, die formatSeconds // benutzen. expect(befund!.message).toMatch(/nur \d+(,\d+)? s/); expect(befund!.message).not.toMatch(/\d\.\d/); expect(bericht.exportBlocked).toBe(true); }); it('schreibt eine halbe Sekunde aus der Projektdatei mit deutschem Komma', () => { /* * Frueher stand hier: Eine Mittelphase mit halber Sekunde bringt einen * Bruchteil in die Abstandsmeldung, und die Frage war, ob dort "11.5 s" * oder "11,5 s" steht. * * Seit der Behebung von Befund 31 (Fassung 5.9.0) erreicht die halbe * Sekunde den Plan nicht mehr: Die Freigabezeitverteilung rundet eine * gebrochene Vorgabe vor dem Summenausgleich auf die naechste ganze * Sekunde auf, statt sie hinterher anzuheben und die Umlaufzeit zu * sprengen. Der Abstand ist damit ganzzahlig, und die Frage nach dem * Komma stellt sich an dieser Meldung nicht mehr. * * Der Bruchteil ist deshalb nicht verschwunden, sondern umgezogen: Er * steht jetzt in der Notiz, die das Aufrunden ausweist. Genau dort muss * das Komma stehen - und die Abstandsmeldung darf nach wie vor keinen * englischen Punkt fuehren. */ const projekt = dreiPhasen({ mittelphase: 5.5, raeumwegFurt: 40 }); const plan = buildSignalPlan(projekt); const befund = validateProject(projekt, plan, STICHTAG).findings.find( (f) => f.rule === 'zwischenzeiten.abstand-zu-kurz', ); expect(befund, 'Der Aufbau muss die Regel ausloesen').toBeDefined(); expect(befund!.message).not.toMatch(/\d\.\d/); const notiz = plan.notes.find((n) => n.code === 'freigabezeit-vorgabe-aufgerundet'); expect(notiz, 'Das Aufrunden der halben Sekunde muss im Plan stehen').toBeDefined(); expect(notiz!.message, 'Bruchteil mit Komma erwartet').toMatch(/\d+,\d+ s/); expect(notiz!.message).not.toMatch(/\d\.\d/); }); it('schweigt, sobald die Mittelphase lang genug ist', () => { const projekt = dreiPhasen({ mittelphase: 30, raeumwegFurt: 40 }); const bericht = validateProject(projekt, buildSignalPlan(projekt), STICHTAG); expect(bericht.findings.some((f) => f.rule === 'zwischenzeiten.abstand-zu-kurz')).toBe(false); }); it('schweigt beim Regelknotenpunkt mit zwei Phasen', () => { // Bei zwei Phasen ist jedes Paar benachbart; die Uebergangsbemessung // deckt alles ab. Hier darf die neue Regel nichts melden, sonst waere // jeder Regelplan unbrauchbar. const projekt = createStandardIntersectionProject('Regelfall', STICHTAG); const bericht = validateProject(projekt, buildSignalPlan(projekt), STICHTAG); expect(bericht.findings.some((f) => f.rule === 'zwischenzeiten.abstand-zu-kurz')).toBe(false); }); it('ueberlaesst ueberlappende Freigabe der Pruefung auf gleichzeitiges Gruen', () => { // Beide feindlichen Gruppen in dieselbe Phase legen: Dann ueberlappen sie, // und es soll GENAU EINE Meldung je Paar entstehen - sonst zaehlt der Kopf // des Berichts doppelt so viele Fehler, wie es Konflikte gibt. const roh = createStandardIntersectionProject('Ueberlappung', STICHTAG); const [k1, k2, k3, k4, f1, f2] = roh.signalGroups; const alle = createPhase('Alles gemeinsam', [k1!.id, k2!.id, k3!.id, k4!.id, f1!.id, f2!.id]); const projekt: Project = { ...roh, phases: [alle], program: { ...roh.program, phaseOrder: [alle.id] }, }; const bericht = validateProject(projekt, buildSignalPlan(projekt), STICHTAG); // Zustaendig ist die genauere Auskunft: Sie nennt die Phase, in der die // Zuordnung zu aendern ist. const inPhase = bericht.findings.filter((f) => f.rule === 'phasen.feindlich-in-phase'); const ueberlappend = bericht.findings.filter( (f) => f.rule === 'signalplan.feindliche-freigabe', ); const paare = new Set(projekt.conflicts.map((c) => [c.fromId, c.toId].sort().join('|'))); expect(inPhase).toHaveLength(paare.size); expect(ueberlappend, 'kein Paar darf zweimal gemeldet werden').toHaveLength(0); // Jede Meldung nennt beide Gruppen im Titel - sechsmal derselbe Titel // waere im Bericht nicht auseinanderzuhalten. expect(new Set(inPhase.map((f) => f.title)).size).toBe(inPhase.length); expect(bericht.findings.some((f) => f.rule === 'zwischenzeiten.abstand-zu-kurz')).toBe(false); }); it('prueft beide Richtungen getrennt, weil die Zwischenzeiten verschieden sind', () => { const projekt = dreiPhasen({ mittelphase: 5, raeumwegFurt: 40 }); const plan = buildSignalPlan(projekt); const [, , k3, , f1] = projekt.signalGroups; const hin = plan.intergreens.get(intergreenKey(f1!.id, k3!.id))?.value ?? 0; const zurueck = plan.intergreens.get(intergreenKey(k3!.id, f1!.id))?.value ?? 0; expect(hin).not.toBe(zurueck); // Nur die Richtung mit dem langen Raeumweg ist zu knapp; die Gegenrichtung // darf nicht mitgemeldet werden. const befunde = validateProject(projekt, plan, STICHTAG).findings.filter( (f) => f.rule === 'zwischenzeiten.abstand-zu-kurz', ); expect(befunde).toHaveLength(1); expect(befunde[0]!.title).toMatch(/F1 nach K3/); }); });