import { describe, expect, it } from 'vitest'; import { computeCycleTime } from '@/domain/rilsa/cycle'; import { buildSignalPlan } from '@/domain/plan/signalPlan'; import { validateProject } from '@/domain/validation/engine'; import { createConflict, createEmptyProject, createPhase, createSignalGroup, createStandardIntersectionProject, } from '@/domain/model/factory'; import type { Project } from '@/domain/model/project'; /* * Befund 32 (Fassung 5.9.0). * * Eine Phase ohne erfasste Verkehrsstaerke ging mit dem Saettigungsgrad 0 in * die Summe Y ein, ohne dass irgendwo stand, dass die Zahl fehlt: Die * Umlaufzeitermittlung meldete nur die LEERE Liste, und ihr einziger Aufrufer * uebergab statt einer Luecke die Zahl 0. Die ermittelte Umlaufzeit faellt * dadurch zu kurz aus - die Anlage erscheint leistungsfaehiger, als sie ist. * * Unterschieden werden muessen zwei Faelle, die beide das Gewicht 0 ergeben: * - "nicht erfasst": Die Phase fuehrt Signalgruppen mit * Saettigungsverkehrsstaerke (Kfz, OePNV), zu keiner davon ist eine * Verkehrsstaerke eingetragen. Das ist die Luecke. * - "kein Verkehr im Sinne der Rechnung": Eine reine Fussgaengerphase traegt * ueberhaupt keine Saettigungsverkehrsstaerke. Ihr Gewicht ist im Regelfall * 0, und daran ist nichts zu beanstanden. */ const DATUM = new Date('2026-01-01T00:00:00Z'); const CODE = 'saettigungsgrade-unvollstaendig'; describe('Umlaufzeitermittlung meldet fehlende Verkehrsstaerken', () => { it('meldet die Phase, deren Verkehrsstaerke nicht erfasst ist', () => { const ergebnis = computeCycleTime('webster', { lostTime: 20, criticalFlowRatios: [0.35, undefined], }); const notiz = ergebnis.notes.find((n) => n.code === CODE); expect(notiz).toBeDefined(); expect(notiz?.severity).toBe('warnung'); // Der Anwender muss wissen, WELCHE Phase gemeint ist - sonst sucht er in // allen. expect(notiz?.message).toContain('Phase 2'); expect(notiz?.message).not.toContain('Phase 1'); }); it('nennt alle betroffenen Phasen in einer einzigen Meldung', () => { const ergebnis = computeCycleTime('webster', { lostTime: 20, criticalFlowRatios: [undefined, 0.3, undefined], }); const notizen = ergebnis.notes.filter((n) => n.code === CODE); expect(notizen).toHaveLength(1); expect(notizen[0]?.message).toContain('Phase 1'); expect(notizen[0]?.message).toContain('Phase 3'); expect(notizen[0]?.message).not.toContain('Phase 2'); }); it('sagt, in welche Richtung die Luecke wirkt', () => { // Die gefaehrliche Richtung: Y faellt zu klein aus, die Umlaufzeit damit // zu kurz. Ohne diesen Satz liest sich die Meldung wie eine Formsache. const ergebnis = computeCycleTime('webster', { lostTime: 20, criticalFlowRatios: [0.35, undefined], }); const notiz = ergebnis.notes.find((n) => n.code === CODE); expect(notiz?.message).toContain('zu kurz'); }); it('schweigt bei einem erfassten Saettigungsgrad 0', () => { // 0 heisst "gezaehlt und kein Verkehr" - das ist eine Angabe, keine Luecke. const ergebnis = computeCycleTime('webster', { lostTime: 20, criticalFlowRatios: [0.35, 0], }); expect(ergebnis.notes.some((n) => n.code === CODE)).toBe(false); }); it('rechnet mit der Summe der erfassten Saettigungsgrade weiter', () => { // Die Luecke wird gemeldet, nicht geschaetzt: Ein Ersatzwert waere eine // erfundene Verkehrsstaerke im Ausdruck. const mitLuecke = computeCycleTime('webster', { lostTime: 20, criticalFlowRatios: [0.35, undefined], }); const ohnePhase = computeCycleTime('webster', { lostTime: 20, criticalFlowRatios: [0.35] }); expect(mitLuecke.totalFlowRatio).toBe(ohnePhase.totalFlowRatio); expect(mitLuecke.cycleTime).toBe(ohnePhase.cycleTime); }); }); /** Vierarmiger Knotenpunkt, dem die Verkehrsstaerken der Ost/West-Phase fehlen. */ function ohneVerkehrsstaerkenInPhase2(): Project { const roh = createStandardIntersectionProject('Lücke', DATUM); const k3 = roh.signalGroups.find((g) => g.name === 'K3')!; const k4 = roh.signalGroups.find((g) => g.name === 'K4')!; return { ...roh, demands: roh.demands.filter((d) => d.signalGroupId !== k3.id && d.signalGroupId !== k4.id), }; } /** Derselbe Knotenpunkt mit einer dritten, reinen Fussgaengerphase. */ function mitReinerFussgaengerphase(): Project { const roh = createStandardIntersectionProject('Furt', DATUM); const f3 = createSignalGroup({ name: 'F3', mode: 'fuss', index: 6 }); const phase3 = createPhase('Phase 3 - Furt', [f3.id]); return { ...roh, signalGroups: [...roh.signalGroups, f3], phases: [...roh.phases, phase3], program: { ...roh.program, phaseOrder: [...roh.program.phaseOrder, phase3.id] }, }; } describe('Der Planaufbau reicht die Luecke weiter', () => { it('meldet die Phase ohne erfasste Verkehrsstaerke im Signalzeitenplan', () => { const plan = buildSignalPlan(ohneVerkehrsstaerkenInPhase2()); const notiz = plan.notes.find((n) => n.code === CODE); expect(notiz).toBeDefined(); expect(notiz?.message).toContain('Phase 2'); }); it('schweigt, wenn alle Phasen mit Kraftfahrzeugen erfasst sind', () => { const plan = buildSignalPlan(createStandardIntersectionProject('Vollständig', DATUM)); expect(plan.notes.some((n) => n.code === CODE)).toBe(false); }); it('beanstandet eine reine Fussgaengerphase nicht', () => { // Der Regelfall. Wuerde er gemeldet, stuende an jedem zweiten Plan eine // Warnung, die nichts bedeutet - und die echten Luecken gingen darin unter. const plan = buildSignalPlan(mitReinerFussgaengerphase()); expect(plan.notes.some((n) => n.code === CODE)).toBe(false); }); }); /** Einstreifige Verkehrsfuehrung ueber eine Engstelle, ohne Verkehrsstaerken. */ function engstelleOhneVerkehrsstaerken(): Project { const basis = createEmptyProject('Arbeitsstelle', DATUM, 'einstreifig'); 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 Süd', [sued.id]); return { ...basis, signalGroups: [nord, sued], conflicts: [ { ...createConflict(nord.id, sued.id), clearingDistance: 200, enteringDistance: 0 }, { ...createConflict(sued.id, nord.id), clearingDistance: 200, enteringDistance: 0 }, ], phases: [phase1, phase2], program: { ...basis.program, phaseOrder: [phase1.id, phase2.id] }, }; } /** * Nachtrag zu Befund 32: EINE Ursache, EINE Zeile im Bericht. * * Der Pruefbericht wird gedruckt und traegt eine Warnungsbilanz. Ein Projekt * ganz ohne Verkehrsstaerken erhielt danach drei Warnungen fuer denselben * Grund - 'signalplan.saettigungsgrade-unvollstaendig', * 'signalplan.keine-gewichtung' und 'leistungsfaehigkeit.keine-verkehrsstaerken' -, * vorher zwei. Dasselbe Muster haben die Befunde 14, 35, 36 und 37 * (Fassung 5.9.0). */ describe('Der Bericht nennt die fehlenden Verkehrsstaerken nur einmal', () => { /** Derselbe Knotenpunkt, einmal mit und einmal ganz ohne Verkehrsstaerken. */ const mitVerkehrsstaerken = createStandardIntersectionProject('Vollständig', DATUM); const ohneVerkehrsstaerken: Project = { ...mitVerkehrsstaerken, demands: [] }; /** Regeln, die genau diese eine Ursache melden. */ const URSACHE = [ 'signalplan.saettigungsgrade-unvollstaendig', 'signalplan.keine-gewichtung', 'leistungsfaehigkeit.keine-verkehrsstaerken', ]; it('zaehlt fuer ein Projekt ganz ohne Verkehrsstaerken zwei Warnungen, nicht drei', () => { const mit = validateProject(mitVerkehrsstaerken, undefined, DATUM); const ohne = validateProject(ohneVerkehrsstaerken, undefined, DATUM); // Festgehalten wird der Zuwachs, nicht die Gesamtzahl: Die uebrigen // Warnungen des Beispielprojekts (Projektdaten, kurze Zwischenzeiten) // stehen in beiden Berichten und gehen andere Regeln an. expect(ohne.warningCount - mit.warningCount).toBe(2); expect(ohne.findings.filter((f) => URSACHE.includes(f.rule)).map((f) => f.rule)).toEqual([ 'signalplan.keine-gewichtung', 'leistungsfaehigkeit.keine-verkehrsstaerken', ]); }); it('behaelt die Richtungsaussage, obwohl die Plan-Notiz entfaellt', () => { // Die gefaehrliche Richtung stand nur in der entfallenden Notiz. Ohne // diesen Satz liest der Planer die verbleibende Warnung als Formsache. const ohne = validateProject(ohneVerkehrsstaerken, undefined, DATUM); const befund = ohne.findings.find( (f) => f.rule === 'leistungsfaehigkeit.keine-verkehrsstaerken', ); expect(befund?.message).toContain('zu kurz'); }); it('laesst die Plan-Notiz stehen, wenn nur einzelne Phasen fehlen', () => { // Hier schweigt 'leistungsfaehigkeit.keine-verkehrsstaerken' - sie greift // erst, wenn KEINE Signalgruppe eine Verkehrsstaerke traegt. Faellt die // Plan-Notiz auch hier weg, verschweigt der Bericht die Luecke. const bericht = validateProject(ohneVerkehrsstaerkenInPhase2(), undefined, DATUM); expect( bericht.findings.some((f) => f.rule === 'leistungsfaehigkeit.keine-verkehrsstaerken'), ).toBe(false); const befund = bericht.findings.find((f) => f.rule === `signalplan.${CODE}`); expect(befund).toBeDefined(); expect(befund?.message).toContain('Phase 2'); }); it('laesst die Plan-Notiz an der einstreifigen Verkehrsfuehrung stehen', () => { // Dort verlangt der Bericht bewusst keine Verkehrsstaerken // (schweigtBeiAnlagenart, checkPerformance); die Plan-Notiz ist die einzige // Stelle, an der die Luecke ueberhaupt vorkommt. const bericht = validateProject(engstelleOhneVerkehrsstaerken(), undefined, DATUM); expect( bericht.findings.some((f) => f.rule === 'leistungsfaehigkeit.keine-verkehrsstaerken'), ).toBe(false); expect(bericht.findings.some((f) => f.rule === `signalplan.${CODE}`)).toBe(true); }); it('nennt in der Ueberschrift nicht "einzelne" Phasen, wenn alle betroffen sind', () => { // Die Meldung derselben Zeile nennt "Phase 1, Phase 2" - also alle. const bericht = validateProject(engstelleOhneVerkehrsstaerken(), undefined, DATUM); const befund = bericht.findings.find((f) => f.rule === `signalplan.${CODE}`); expect(befund?.message).toContain('Phase 1'); expect(befund?.message).toContain('Phase 2'); expect(befund?.title).not.toMatch(/einzelne/i); }); });