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 * as actions from '@/app/actions'; import type { Project } from '@/domain/model/project'; /* * Befunde 12 und 18, behoben in Fassung 5.10.0 - ein Eintrag ohne * Verkehrsstaerke galt als gezaehlt. * * `setDemand` und `setLastzugAnteil` legen bei fehlendem Datensatz einen mit * `volume: 0` an - das geschieht schon, wenn in der Signalgruppentabelle nur * die Spalte "SV-Anteil" oder "Lastzuege am SV" ausgefuellt wird. Der Fachkern * entschied an zwei Stellen allein am VORHANDENSEIN des Datensatzes: * - `phaseFlowRatio` setzte `erfasst = true` und lieferte 0 statt * `undefined`; die Luecke 'saettigungsgrade-unvollstaendig' entstand nicht. * - `computeCapacity` bekam die 0 als Nachfrage und lieferte einen gesetzten * `degreeOfSaturation`; damit fiel auch * 'leistungsfaehigkeit.keine-verkehrsstaerken' weg. * Y blieb trotzdem zu klein, die Umlaufzeit fiel zu kurz aus - die gefaehrliche * Richtung, und im teilweise erfassten Fall ohne jede Meldung. * * Massstab ist das Kriterium, das der Fachkern sonst durchgaengig fuehrt * (rules.ts, simulation.ts, naechsterSchritt.ts): erfasst ist eine * Verkehrsstaerke nur bei `Number.isFinite(volume) && volume > 0`. * * GEGEN DEN ALTSTAND schlagen die ersten fuenf Faelle fehl. Der sechste ist die * Gegenprobe mit einer echten Verkehrsstaerke und besteht auch dort. */ const DATUM = new Date('2026-01-01T00:00:00Z'); const LUECKE = 'saettigungsgrade-unvollstaendig'; const REGEL = 'leistungsfaehigkeit.keine-verkehrsstaerken'; /** Standardknotenpunkt, dem die Verkehrsstaerken der zweiten Phase fehlen. */ function ohneVerkehrsstaerkenInPhase2(): Project { const roh = createStandardIntersectionProject('Teilweise erfasst', 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 ohne jede Verkehrsstaerke. */ function ohneJedeVerkehrsstaerke(): Project { return { ...createStandardIntersectionProject('Ohne Zaehlung', DATUM), demands: [] }; } describe('Befunde 12/18 - ein Eintrag mit der Verkehrsstaerke 0 ist keine Zaehlung', () => { it('meldet die Luecke weiter, wenn nur der Schwerverkehrsanteil eingetragen wird', () => { const basis = ohneVerkehrsstaerkenInPhase2(); const k3 = basis.signalGroups.find((g) => g.name === 'K3')!; // Genau der Bedienweg: Spalte "SV-Anteil" ausfuellen, Spalte // "Verkehrsstaerke" leer lassen. const mitAnteil = actions.setDemand(basis, k3.id, { heavyVehicleShare: 0.05 }); expect(mitAnteil.demands.some((d) => d.signalGroupId === k3.id && d.volume === 0)).toBe(true); const notiz = buildSignalPlan(mitAnteil).notes.find((n) => n.code === LUECKE); expect(notiz).toBeDefined(); expect(notiz?.message).toContain('Phase 2'); }); it('meldet die Luecke weiter, wenn nur der Lastzuganteil eingetragen wird', () => { const basis = ohneVerkehrsstaerkenInPhase2(); const k3 = basis.signalGroups.find((g) => g.name === 'K3')!; const mitAnteil = actions.setLastzugAnteil(basis, k3.id, 0.3); expect(buildSignalPlan(mitAnteil).notes.some((n) => n.code === LUECKE)).toBe(true); }); it('bildet ohne Verkehrsstaerke keinen Auslastungsgrad', () => { const basis = ohneVerkehrsstaerkenInPhase2(); const k3 = basis.signalGroups.find((g) => g.name === 'K3')!; const plan = buildSignalPlan(actions.setDemand(basis, k3.id, { heavyVehicleShare: 0.05 })); const geplant = plan.groups.find((g) => g.groupId === k3.id)!; // Ein aus dem Nichts gebildeter Auslastungsgrad 0,00 ist die unsichere // Aussage: Er stellt die Zufahrt als voellig unbelastet dar. expect(geplant.capacity?.degreeOfSaturation).toBeUndefined(); expect(geplant.flowRatio).toBeNull(); }); it('haelt die Warnung "Keine Verkehrsstaerken erfasst" aufrecht', () => { let projekt = ohneJedeVerkehrsstaerke(); for (const gruppe of projekt.signalGroups.filter((g) => g.mode === 'kfz')) { projekt = actions.setDemand(projekt, gruppe.id, { heavyVehicleShare: 0.05 }); } const bericht = validateProject(projekt, buildSignalPlan(projekt)); expect(bericht.findings.some((f) => f.rule === REGEL)).toBe(true); }); it('behandelt eine von Hand eingetragene 0 genauso', () => { // Das Programm kann "gezaehlt und 0 Fz/h" von "nicht erfasst" nicht // unterscheiden - beide Wege erzeugen denselben Datensatz. Eine // massgebende Kfz-Verkehrsstaerke von 0 ist kein Bemessungsfall; im // Zweifel nach der Zaehlung zu fragen ist die sichere Richtung. const basis = ohneVerkehrsstaerkenInPhase2(); const k3 = basis.signalGroups.find((g) => g.name === 'K3')!; const plan = buildSignalPlan(actions.setDemand(basis, k3.id, { volume: 0 })); expect(plan.notes.some((n) => n.code === LUECKE)).toBe(true); }); it('schweigt, sobald eine Verkehrsstaerke erfasst ist', () => { // Gegenprobe: Mit einer echten Zahl entsteht weder die Luecke noch die // Warnung, und der Auslastungsgrad steht. const basis = ohneVerkehrsstaerkenInPhase2(); const k3 = basis.signalGroups.find((g) => g.name === 'K3')!; const k4 = basis.signalGroups.find((g) => g.name === 'K4')!; let projekt = actions.setDemand(basis, k3.id, { volume: 410, heavyVehicleShare: 0.03 }); projekt = actions.setDemand(projekt, k4.id, { volume: 390, heavyVehicleShare: 0.03 }); const plan = buildSignalPlan(projekt); expect(plan.notes.some((n) => n.code === LUECKE)).toBe(false); expect( plan.groups.find((g) => g.groupId === k3.id)?.capacity?.degreeOfSaturation, ).toBeGreaterThan(0); expect(validateProject(projekt, plan).findings.some((f) => f.rule === REGEL)).toBe(false); }); });