import { describe, expect, it } from 'vitest'; import { createConflict, createEmptyProject, createPhase, createSignalGroup, createStandardIntersectionProject, } from '@/domain/model/factory'; import type { Anlagenart, Conflict, Project, SignalGroup } from '@/domain/model/project'; import { buildSignalPlan } from '@/domain/plan/signalPlan'; import { validateProject } from '@/domain/validation/engine'; import type { ValidationFinding } from '@/domain/validation/types'; /* * Fassung 5.12.0: Es fehlte die Regel "Signalgruppe ohne Konfliktbeziehung". * * Feindlich ist im Modell nur, was erfasst ist; alles andere gilt als * vertraeglich. Eine Signalgruppe, die in keiner Beziehung steht, bekommt * deshalb keine einzige Zwischenzeit - der Plan darf sie zu jedem Zeitpunkt * freigeben. * * DIE LUECKE, DIE DIE REGEL SCHLIESST, steht im zweiten Fall: Phase 1 fuehrt * den Fahrzeugstrom K1 und die Furt F1, Phase 2 den querenden K2. K1 und K2 * sind feindlich erfasst, F1 mit niemandem. Der Uebergang ist ueber K1/K2 * bemessen, `zwischenzeiten.uebergang-ungesichert` schweigt deshalb - und die * Furt bekommt ihre Freigabe ohne jeden Abstand zu dem Strom, der sie quert. * * DIE SCHWERE HAENGT AN DER ANLAGENART: An Fussgaengerschutzanlage und * einstreifiger Verkehrsfuehrung ist ein Strom ohne jeden Konflikt nicht * moeglich - Fehler. Am Knotenpunkt ist er es (Rechtsabbieger auf eigenem * Fahrstreifen ohne Furt), und der Anwender haette keinen Weg, einen Fehler * auszuraeumen ausser die Signalgruppe zu loeschen - Warnung. * * GEGEN DEN ALTSTAND schlagen alle Faelle bis auf die drei letzten fehl; jene * bewachen, dass die Regel schweigt, wo sie schweigen muss, und bestehen dort * ebenso. */ const DATUM = new Date('2026-01-01T00:00:00Z'); const REGEL = 'signalgruppen.ohne-konflikt'; function beziehung(fromId: string, toId: string): Conflict { return { ...createConflict(fromId, toId), clearingDistance: 22, enteringDistance: 8 }; } /** * Zwei Fahrzeugstroeme in zwei Phasen, feindlich erfasst - dazu ein dritter * Strom in Phase 1, der in keiner Beziehung steht. */ function mitStrommOhneBeziehung( anlagenart: Anlagenart, dritter: Partial> = {}, ): Project { const basis = createEmptyProject('Prueffall', DATUM, anlagenart); const k1 = createSignalGroup({ name: 'K1', mode: 'kfz', armId: null, index: 0 }); const k2 = createSignalGroup({ name: 'K2', mode: 'kfz', armId: null, index: 1 }); const f1 = createSignalGroup({ name: 'F1', mode: dritter.mode ?? 'fuss', armId: null, index: 2 }); const p1 = createPhase('Phase 1', [k1.id, f1.id]); const p2 = createPhase('Phase 2', [k2.id]); return { ...basis, signalGroups: [k1, k2, f1], phases: [p1, p2], conflicts: [beziehung(k1.id, k2.id), beziehung(k2.id, k1.id)], program: { ...basis.program, phaseOrder: [p1.id, p2.id] }, }; } function befunde(project: Project): readonly ValidationFinding[] { return validateProject(project, buildSignalPlan(project), DATUM).findings; } function ohneKonflikt(project: Project): readonly ValidationFinding[] { return befunde(project).filter((f) => f.rule === REGEL); } describe('Signalgruppe ohne jede Konfliktbeziehung', () => { it('meldet die Signalgruppe am Knotenpunkt als Warnung', () => { const project = mitStrommOhneBeziehung('knotenpunkt'); const treffer = ohneKonflikt(project); expect(treffer).toHaveLength(1); expect(treffer[0]!.severity).toBe('warnung'); expect(treffer[0]!.title).toBe('"F1" steht in keiner Konfliktbeziehung'); expect(treffer[0]!.category).toBe('signalgruppen'); // Das Sprungziel zeigt auf die Signalgruppe selbst. expect(treffer[0]!.target).toEqual({ kind: 'signalgruppe', id: project.signalGroups.find((g) => g.name === 'F1')!.id, label: 'F1', }); // Die Warnung sperrt nicht - am Knotenpunkt ist ein vertraeglicher Strom // moeglich, und ausraeumen liesse sich ein Fehler hier nicht. expect(treffer[0]!.message).toContain('Rechtsabbieger auf eigenem Fahrstreifen'); }); it('schliesst die Luecke, die keine andere Regel faengt', () => { const project = mitStrommOhneBeziehung('knotenpunkt'); const alle = befunde(project); const f1 = project.signalGroups.find((g) => g.name === 'F1')!; // Der Phasenuebergang ist ueber K1/K2 bemessen; die Regel zum ungesicherten // Uebergang sieht die Furt deshalb nicht. expect(alle.some((f) => f.rule === 'zwischenzeiten.uebergang-ungesichert')).toBe(false); // Und es gibt zu F1 keine Beziehung, deren Gegenrichtung fehlen koennte. expect(alle.some((f) => f.rule === 'konflikte.gegenrichtung-fehlt')).toBe(false); // Ohne die neue Regel stand im Bericht nichts ueber die fehlende // Absicherung von F1. Was sonst zu ihr steht, ist der Hinweis auf die // unbekannte Furtlaenge - er sagt ueber Zwischenzeiten nichts. const zuF1 = alle.filter((f) => f.target?.kind === 'signalgruppe' && f.target.id === f1.id); expect([...zuF1.map((f) => f.rule)].sort()).toEqual([ REGEL, 'signalplan.mindestfreigabezeit-ohne-furtlaenge', ]); expect(alle.some((f) => f.category === 'zwischenzeiten' || f.category === 'konflikte')).toBe( false, ); }); it('meldet sie an der Fussgaengerschutzanlage als Fehler und sperrt die Ausgabe', () => { const project = mitStrommOhneBeziehung('fussgaengerschutzanlage'); const bericht = validateProject(project, buildSignalPlan(project), DATUM); const treffer = bericht.findings.filter((f) => f.rule === REGEL); expect(treffer).toHaveLength(1); expect(treffer[0]!.severity).toBe('fehler'); expect(treffer[0]!.message).toContain('Fußgängerschutzanlage'); expect(bericht.exportBlocked).toBe(true); }); it('meldet sie an der einstreifigen Verkehrsfuehrung als Fehler', () => { const treffer = ohneKonflikt(mitStrommOhneBeziehung('einstreifig')); expect(treffer).toHaveLength(1); expect(treffer[0]!.severity).toBe('fehler'); expect(treffer[0]!.message).toContain('denselben Fahrstreifen'); }); it('nennt jede betroffene Signalgruppe einmal, auch mehrere', () => { const basis = mitStrommOhneBeziehung('knotenpunkt'); const r1 = createSignalGroup({ name: 'R1', mode: 'rad', armId: null, index: 3 }); const project: Project = { ...basis, signalGroups: [...basis.signalGroups, r1], phases: [ basis.phases[0]!, { ...basis.phases[1]!, signalGroupIds: [...basis.phases[1]!.signalGroupIds, r1.id] }, ], }; expect(ohneKonflikt(project).map((f) => f.title)).toEqual([ '"F1" steht in keiner Konfliktbeziehung', '"R1" steht in keiner Konfliktbeziehung', ]); }); it('schweigt, wo jede Signalgruppe in einer Beziehung steht', () => { // Der Regelknotenpunkt der Vorlage: alle sechs Gruppen sind erfasst. expect(ohneKonflikt(createStandardIntersectionProject('Vorlage', DATUM))).toHaveLength(0); }); it('schweigt bei einer Signalgruppe ohne Phase - dort steht der schwerere Befund', () => { const basis = mitStrommOhneBeziehung('knotenpunkt'); const f1 = basis.signalGroups.find((g) => g.name === 'F1')!; const ohnePhase: Project = { ...basis, phases: basis.phases.map((p) => ({ ...p, signalGroupIds: p.signalGroupIds.filter((id) => id !== f1.id), })), }; expect(ohneKonflikt(ohnePhase)).toHaveLength(0); expect(befunde(ohnePhase).some((f) => f.rule === 'signalgruppen.ohne-phase')).toBe(true); }); it('schweigt bei einer einzigen Signalgruppe - es gibt kein Paar', () => { const basis = createEmptyProject('Einzelgruppe', DATUM, 'fussgaengerschutzanlage'); const k1 = createSignalGroup({ name: 'K1', mode: 'kfz', armId: null, index: 0 }); const p1 = createPhase('Phase 1', [k1.id]); const project: Project = { ...basis, signalGroups: [k1], phases: [p1], program: { ...basis.program, phaseOrder: [p1.id] }, }; expect(ohneKonflikt(project)).toHaveLength(0); }); });