import { describe, expect, it } from 'vitest'; import { anlagenartGrenzen, buildSignalPlan } from '@/domain/plan/signalPlan'; import { laengsteRotzeit, wartezeitBefunde } from '@/domain/validation/rules'; import { validateProject } from '@/domain/validation/engine'; import { createConflict, createEmptyProject, createPhase, createSignalGroup, createStandardIntersectionProject, } from '@/domain/model/factory'; import { RILSA_DEFAULTS } from '@/domain/rilsa/constants'; import type { Anlagenart, Project } from '@/domain/model/project'; const DATUM = new Date('2026-01-01T00:00:00Z'); /** * Eine einstreifige Verkehrsfuehrung: zwei Richtungen, ein Fahrstreifen, die * Engstelle ist der Raeumweg beider Richtungen. */ function engstelle( laenge: number, anlagenart: Anlagenart = 'einstreifig', festeUmlaufzeit: number | null = null, ): Project { const basis = createEmptyProject('Arbeitsstelle', DATUM, anlagenart); const nord = createSignalGroup({ name: 'A1', mode: 'kfz', index: 0 }); const sued = createSignalGroup({ name: 'A2', mode: 'kfz', index: 1 }); const phase1 = createPhase('Richtung Nord', [nord.id]); const phase2 = createPhase('Richtung Sued', [sued.id]); const konflikte = [ { ...createConflict(nord.id, sued.id), clearingDistance: laenge, enteringDistance: 0 }, { ...createConflict(sued.id, nord.id), clearingDistance: laenge, enteringDistance: 0 }, ]; return { ...basis, meta: { ...basis.meta, projectNumber: 'B-2026-001', client: 'Strassenmeisterei', planner: 'O. Willerding', location: 'B 1, km 12,4', }, signalGroups: [nord, sued], conflicts: konflikte, phases: [phase1, phase2], program: { ...basis.program, phaseOrder: [phase1.id, phase2.id], ...(festeUmlaufzeit === null ? {} : { method: 'manuell' as const, manualCycleTime: festeUmlaufzeit }), }, }; } /** Fussgaengerschutzanlage: eine Fahrbahn, eine Furt, feste Umlaufzeit. */ function schutzanlage(umlaufzeit: number, gruenFurt: number): Project { const basis = createEmptyProject('Furt', DATUM, 'fussgaengerschutzanlage'); const kfz = createSignalGroup({ name: 'K1', mode: 'kfz', index: 0 }); const fuss = createSignalGroup({ name: 'F1', mode: 'fuss', index: 1 }); const phase1 = createPhase('Fahrverkehr', [kfz.id]); const phase2 = createPhase('Furt', [fuss.id]); const konflikte = [ { ...createConflict(kfz.id, fuss.id), clearingDistance: 25, enteringDistance: 2 }, { ...createConflict(fuss.id, kfz.id), clearingDistance: 12, enteringDistance: 5 }, ]; return { ...basis, signalGroups: [kfz, fuss], conflicts: konflikte, phases: [phase1, { ...phase2, manualGreen: gruenFurt }], program: { ...basis.program, phaseOrder: [phase1.id, phase2.id], method: 'manuell', manualCycleTime: umlaufzeit, }, }; } describe('Umlaufzeitschranken nach Anlagenart', () => { it('laesst eine 400 m lange Engstelle ueber 120 s Umlauf zu', () => { const plan = buildSignalPlan(engstelle(400)); /* * Seit 5.27.0 nach RiLSA 2015, Abschnitt 5.2.2 (Engstellensignalisierung): * 4 s Ueberfahrzeit + 400 m / 40 km/h * 3,6 = 40 s je Richtung. Bis 5.26.0 * rechnete das Programm hier die Knotenpunktformel - 400 m + 6 m * Fahrzeuglaenge bei 10 m/s zuzueglich 3 s Gelbzeit, also 44 s. Damit * liegt der Umlauf so oder so ueber 120 s, und darum geht es hier. */ const uebergang = plan.transitions[0]?.duration ?? 0; expect(uebergang).toBe(40); expect(plan.cycleTime).toBeGreaterThan(120); expect(plan.grenzen.cycleTime.max).toBe(300); }); it('meldet den langen Umlauf der Arbeitsstelle nicht als Fehler', () => { const bericht = validateProject(engstelle(400), undefined, DATUM); expect(bericht.findings.some((f) => f.rule === 'signalplan.umlaufzeit-zu-lang')).toBe(false); const fehler = bericht.findings.filter((f) => f.severity === 'fehler'); expect(fehler.map((f) => `${f.rule}: ${f.message}`).join('\n')).toBe(''); }); it('meldet den rechnerischen Bedarf am Knotenpunkt weiterhin als zu lang', () => { // 400 m Engstelle an einem Knotenpunkt: Der Bedarf uebersteigt die 120 s // der RiLSA. Dieselbe Geometrie als Arbeitsstelle bleibt unbeanstandet. const knoten = validateProject(engstelle(400, 'knotenpunkt'), undefined, DATUM); expect(knoten.findings.some((f) => f.rule === 'signalplan.umlaufzeit-ueber-hoechstwert')).toBe( true, ); const arbeitsstelle = validateProject(engstelle(400), undefined, DATUM); expect( arbeitsstelle.findings.some((f) => f.rule === 'signalplan.umlaufzeit-ueber-hoechstwert'), ).toBe(false); }); it('nennt die Anlagenart in der Meldung zur festen Umlaufzeit', () => { const bericht = validateProject(engstelle(400, 'knotenpunkt', 200), undefined, DATUM); const meldung = bericht.findings.find((f) => f.rule === 'signalplan.umlaufzeit-zu-lang'); expect(meldung?.message).toContain('Knotenpunkt'); expect(meldung?.message).toContain('120'); // Dieselben 200 s sind fuer eine einstreifige Verkehrsfuehrung zulaessig. const arbeitsstelle = validateProject(engstelle(400, 'einstreifig', 200), undefined, DATUM); expect(arbeitsstelle.findings.some((f) => f.rule === 'signalplan.umlaufzeit-zu-lang')).toBe( false, ); }); it('gibt der Arbeitsstelle einen brauchbaren Vorschlag statt "Zufahrt ausbauen"', () => { // 1200 m Engstelle: 124 s Zwischenzeit je Richtung, damit sprengt selbst // die Arbeitsstelle ihre Schranke von 300 s. const bericht = validateProject(engstelle(1200), undefined, DATUM); const meldung = bericht.findings.find( (f) => f.rule === 'signalplan.umlaufzeit-ueber-hoechstwert', ); // RSA-konforme Alternativen (Befund B7): keine "Verkehrsposten" - // Warnposten duerfen keine Verkehrsregelung vornehmen. expect(meldung?.suggestion).toContain('Umleitung mindestens einer Fahrtrichtung'); expect(meldung?.suggestion).toContain('RSA 21'); expect(meldung?.suggestion).not.toContain('Verkehrsposten'); expect(meldung?.suggestion).not.toContain('Begleitfahrzeug'); expect(meldung?.suggestion).not.toContain('Zufahrt'); }); it('nimmt die Schranke der Anlagenart, solange der Anwender nichts eintraegt', () => { const grenzen = anlagenartGrenzen(engstelle(400), RILSA_DEFAULTS); expect(grenzen.cycleTime.max).toBe(300); expect(grenzen.cycleTime.min).toBe(30); }); it('laesst eine ausdrueckliche Vorgabe des Anwenders einengen', () => { const basis = engstelle(400); const projekt: Project = { ...basis, settings: { ...basis.settings, rilsa: { cycleTimeMax: 100, cycleTimeMin: 60 } }, }; const plan = buildSignalPlan(projekt); expect(plan.grenzen.cycleTime.max).toBe(100); expect(plan.grenzen.cycleTime.min).toBe(60); }); }); describe('Wartezeit vor Rot', () => { it('warnt bei einer sehr langen Engstelle unter Nennung von Gruppe und Zahl', () => { const plan = buildSignalPlan(engstelle(800)); const befunde = wartezeitBefunde(plan); expect(befunde.length).toBeGreaterThan(0); const erster = befunde[0]!; expect(erster.severity).toBe('warnung'); expect(erster.message).toContain('A1'); expect(erster.message).toMatch(/\d/); expect(erster.suggestion).toContain('Handschaltung'); expect(erster.suggestion).not.toContain('Verkehrsposten'); }); it('schweigt bei einer Engstelle, die die Schwelle einhaelt', () => { expect(wartezeitBefunde(buildSignalPlan(engstelle(400)))).toHaveLength(0); }); it('nennt an der Fussgaengerschutzanlage den Grund - Fussgaenger gehen bei Rot', () => { const plan = buildSignalPlan(schutzanlage(90, 20)); const befund = wartezeitBefunde(plan).find((f) => f.message.includes('F1')); expect(befund).toBeDefined(); expect(befund?.message).toContain('geht bei Rot'); expect(befund?.message).toContain('Schutzwirkung'); }); it('stuft die Fussgaengerschutzanlage ab 90 s als Fehler ein', () => { const plan = buildSignalPlan(schutzanlage(120, 5)); const befund = wartezeitBefunde(plan).find((f) => f.message.includes('F1')); expect(befund?.severity).toBe('fehler'); expect(befund?.rule).toBe('signalplan.wartezeit-unzumutbar'); }); it('misst bei zwei Freigabefenstern die groesste Luecke, nicht die Summe', () => { // Zwei Fenster zu je 10 s in einem Umlauf von 100 s, gegenueberliegend: // Der Fahrer wartet 40 s, nicht 80 s. const gruen = [ { start: 0, duration: 10 }, { start: 50, duration: 10 }, ]; expect(laengsteRotzeit(gruen, 100)).toBe(40); // Ein einziges Fenster: Umlaufzeit abzueglich eigener Freigabezeit. expect(laengsteRotzeit([{ start: 0, duration: 10 }], 100)).toBe(90); // Ueber den Umlaufbeginn hinweg. expect(laengsteRotzeit([{ start: 95, duration: 10 }], 100)).toBe(90); }); }); describe('Regeln, die fuer eine Anlagenart schweigen', () => { it('fordert an der Arbeitsstelle keine Verkehrsstaerken', () => { const bericht = validateProject(engstelle(400), undefined, DATUM); expect(bericht.findings.some((f) => f.rule.startsWith('leistungsfaehigkeit.'))).toBe(false); expect(bericht.findings.some((f) => f.rule === 'signalplan.saettigungsgrade-fehlen')).toBe( false, ); }); it('bewertet an der Fussgaengerschutzanlage die Furt, nicht den Fahrverkehr', () => { /* * Befund B4: Vor Fassung 5.4.0 hiess dieser Test "bewertet * eine Fussgaengerschutzanlage nicht nach HBS" und verlangte, dass gar * keine Leistungsfaehigkeitsmeldung erscheint. Das war nur zur Haelfte * richtig: Der Fahrverkehr hat auf freier Strecke keinen Knotenpunkt und * keine konkurrierende Zufahrt - Auslastungsgrad, Kfz-Stufe und die * Forderung nach Verkehrsstaerken waeren dort sinnlos. Die Furt aber wird * nach HBS 2015 sehr wohl bewertet, nach der MAXIMALEN Wartezeit (laengste * Sperrzeit im Umlauf) - und an der Schutzanlage ist genau das das Mass, * an dem die Anlage gemessen wird. */ // 90 s Umlauf, 20 s Furtfreigabe: Sperrzeit 70 s -> Stufe D. Keine Meldung // fuer die Furt, und keine Kfz-Meldung (keine Verkehrsstaerken gefordert, // kein Auslastungsgrad). const ruhig = validateProject(schutzanlage(90, 20), undefined, DATUM); expect(ruhig.findings.filter((f) => f.category === 'leistungsfaehigkeit')).toHaveLength(0); // 120 s Umlauf, 5 s Furtfreigabe: Sperrzeit ueber 85 s -> Stufe F nach der // maximalen Wartezeit. Die Meldung gilt der Furt F1 und nennt das // Kriterium; der Fahrverkehr K1 bleibt ohne Leistungsfaehigkeitsbefund. const lang = validateProject(schutzanlage(120, 5), undefined, DATUM); const leistung = lang.findings.filter((f) => f.category === 'leistungsfaehigkeit'); expect(leistung.map((f) => f.rule)).toEqual(['leistungsfaehigkeit.qualitaetsstufe']); const furt = leistung[0]!; expect(furt.target).toMatchObject({ kind: 'signalgruppe', label: 'F1' }); expect(furt.title).toContain('Qualitätsstufe F'); expect(furt.message).toContain('maximale Wartezeit'); expect(furt.message).toContain('HBS 2015'); expect(furt.message).not.toContain('K1'); expect(lang.findings.some((f) => f.rule === 'leistungsfaehigkeit.keine-verkehrsstaerken')).toBe( false, ); }); it('laesst den Knotenpunkt unveraendert', () => { const basis = createStandardIntersectionProject('Prueffall', DATUM); const projekt: Project = { ...basis, meta: { ...basis.meta, projectNumber: 'VP-2026-001', client: 'Stadt Musterhausen', planner: 'O. Willerding', location: 'Hauptstrasse / Bahnhofstrasse', }, }; const bericht = validateProject(projekt, undefined, DATUM); const fehler = bericht.findings.filter((f) => f.severity === 'fehler'); expect(fehler.map((f) => `${f.rule}: ${f.message}`).join('\n')).toBe(''); expect(bericht.findings.some((f) => f.rule.startsWith('signalplan.wartezeit'))).toBe(false); }); });