import { readFileSync } from 'node:fs'; import { describe, expect, it } from 'vitest'; import { validateProject } from '@/domain/validation/engine'; import { buildSignalPlan } from '@/domain/plan/signalPlan'; import { createConflict, createEmptyProject, createPhase, createSignalGroup, createStandardIntersectionProject, } from '@/domain/model/factory'; import type { Anlagenart, Project } from '@/domain/model/project'; /* * Pruefbericht zur Qualitaetsstufe seit Fassung 5.4.0 (Befunde * B1, B2, B4). * * Bis dahin lief jede Furt ueber die Kfz-Rechnung mit Kapazitaet 0, erhielt * formal die Stufe F, und der Bericht erklaerte die eigene Stufe fuer * aussagelos ("Wartezeit nicht bestimmbar - bei Fussgaengerfurten ist das der * Regelfall"). Die Kfz-Meldung schrieb "nach HBS", waehrend immer die * HCM-Formel gerechnet wurde, und nannte nicht, woran die Stufe haengt. * * Jetzt: Fussgaenger und Radverkehr werden nach der maximalen Wartezeit * (laengste Sperrzeit) bewertet - ohne Verkehrsstaerke, auch an der * Fussgaengerschutzanlage; Kfz und OePNV nennen Verfahren und Kriterium; nach * HCM sagt der Bericht, dass Fussgaenger und Radverkehr nicht bewertet werden. */ const STICHTAG = new Date('2026-01-01T00:00:00Z'); function vollstaendig(): Project { const base = createStandardIntersectionProject('Prueffall', STICHTAG); return { ...base, meta: { ...base.meta, projectNumber: 'VP-2026-001', client: 'Stadt Musterhausen', planner: 'O. Willerding', location: 'Hauptstrasse / Bahnhofstrasse', }, }; } /** * Eine Fahrbahn, eine Furt, feste Umlaufzeit und feste Freigabezeit der Furt. * Die Sperrzeit der Furt ist Umlaufzeit abzueglich Freigabezeit und * Uebergaengen - bei 120 s Umlauf und 10 s Freigabe rund 110 s. */ function furtprojekt( umlaufzeit: number, gruenFurt: number, anlagenart: Anlagenart, furtart: 'fuss' | 'rad' = 'fuss', ): Project { const basis = createEmptyProject('Furt', STICHTAG, anlagenart); const kfz = createSignalGroup({ name: 'K1', mode: 'kfz', index: 0 }); const furt = createSignalGroup({ name: 'F1', mode: furtart, index: 1 }); const phase1 = createPhase('Fahrverkehr', [kfz.id]); const phase2 = createPhase('Furt', [furt.id]); const konflikte = [ { ...createConflict(kfz.id, furt.id), clearingDistance: 25, enteringDistance: 2 }, { ...createConflict(furt.id, kfz.id), clearingDistance: 12, enteringDistance: 5 }, ]; return { ...basis, meta: { ...basis.meta, projectNumber: 'VP-2026-002', client: 'Stadt Musterhausen', planner: 'O. Willerding', location: 'Furt', }, signalGroups: [kfz, furt], conflicts: konflikte, phases: [phase1, { ...phase2, manualGreen: gruenFurt }], program: { ...basis.program, phaseOrder: [phase1.id, phase2.id], method: 'manuell', manualCycleTime: umlaufzeit, }, }; } function qualitaetsstufen(project: Project) { const plan = buildSignalPlan(project); const bericht = validateProject(project, plan, STICHTAG); return { plan, bericht, stufen: bericht.findings.filter((f) => f.rule === 'leistungsfaehigkeit.qualitaetsstufe'), }; } describe('Fussgaenger und Radverkehr: maximale Wartezeit statt "nicht bestimmbar"', () => { it('meldet fuer eine Furt mit langer Sperrzeit die Stufe F nach der maximalen Wartezeit', () => { // Umlauf 120 s, Furt 10 s Freigabe: Sperrzeit rund 110 s, Tafel F (> 85 s). const { plan, stufen } = qualitaetsstufen(furtprojekt(120, 10, 'knotenpunkt')); const furt = plan.groups.find((g) => g.name === 'F1')!; expect(furt.delay?.serviceLevel?.grade).toBe('F'); const befund = stufen.find((f) => f.target?.label === 'F1'); expect(befund, 'Die Furt muss eine Stufenmeldung erhalten').toBeDefined(); expect(befund!.severity).toBe('warnung'); expect(befund!.title).toBe('Qualitätsstufe F für "F1"'); expect(befund!.message).toContain('maximale Wartezeit'); expect(befund!.message).toContain('längste Sperrzeit im Umlauf'); expect(befund!.message).toContain('nach HBS 2015'); expect(befund!.message).toContain('Fußgänger und Radverkehr: maximale Wartezeit'); expect(befund!.message).not.toContain('mittlere Wartezeit'); expect(befund!.message).not.toContain('nicht bestimmbar'); expect(befund!.reference).toBe('HBS 2015 - Qualitätsstufen des Verkehrsablaufs'); expect(befund!.suggestion).toContain('Sperrzeit'); // Der genannte Wert ist der Wert des Plans - nachrechenbar aus Umlauf und // Freigabezeit, nicht eine zweite Zahl. const maximal = furt.delay!.maximumDelay; expect(maximal).toBeGreaterThan(85); expect(befund!.message).toContain(`${String(maximal).replace('.', ',')} s`); }); it('bewertet auch ohne erfasste Verkehrsstaerken - die Furt braucht keine', () => { const projekt = furtprojekt(120, 10, 'knotenpunkt'); expect(projekt.demands).toHaveLength(0); const { bericht, stufen } = qualitaetsstufen(projekt); // Fuer den Kfz-Strom fehlt die Verkehrsstaerke - das bleibt eine Warnung ... expect( bericht.findings.some((f) => f.rule === 'leistungsfaehigkeit.keine-verkehrsstaerken'), ).toBe(true); // ... die Furt wird trotzdem bewertet. expect(stufen.some((f) => f.target?.label === 'F1')).toBe(true); }); it('bewertet den Radverkehr genauso wie die Fussgaengerfurt', () => { const { stufen } = qualitaetsstufen(furtprojekt(120, 10, 'knotenpunkt', 'rad')); const befund = stufen.find((f) => f.target?.label === 'F1'); expect(befund).toBeDefined(); expect(befund!.message).toContain('maximale Wartezeit'); expect(befund!.message).toContain('Fußgänger und Radverkehr: maximale Wartezeit'); }); it('prueft die Furt auch an der Fussgaengerschutzanlage - dort ist die Wartezeit das Mass', () => { const { bericht, stufen } = qualitaetsstufen(furtprojekt(120, 10, 'fussgaengerschutzanlage')); const befund = stufen.find((f) => f.target?.label === 'F1'); expect(befund).toBeDefined(); expect(befund!.message).toContain('maximale Wartezeit'); // Die Kfz-Leistungsfaehigkeit bleibt dort ausser Betracht: kein // Auslastungsgrad, keine Forderung nach Verkehrsstaerken. expect( bericht.findings.some( (f) => f.rule === 'leistungsfaehigkeit.keine-verkehrsstaerken' || f.rule === 'leistungsfaehigkeit.ueberlastet' || (f.rule === 'leistungsfaehigkeit.qualitaetsstufe' && f.target?.label === 'K1'), ), ).toBe(false); }); it('stuft E als Hinweis ein und schweigt bei A bis D', () => { // 100 s Umlauf, 20 s Freigabe: Sperrzeit 80 s -> E (70 < tS <= 85). const e = qualitaetsstufen(furtprojekt(100, 20, 'knotenpunkt')); const furtE = e.plan.groups.find((g) => g.name === 'F1')!; expect(furtE.delay?.maximumDelay).toBe(80); expect(furtE.delay?.serviceLevel?.grade).toBe('E'); const befundE = e.stufen.find((f) => f.target?.label === 'F1'); expect(befundE?.severity).toBe('hinweis'); // 60 s Umlauf, 15 s Freigabe: Sperrzeit 45 s -> C, keine Meldung. const b = qualitaetsstufen(furtprojekt(60, 15, 'knotenpunkt')); const furtB = b.plan.groups.find((g) => g.name === 'F1')!; expect(['A', 'B', 'C', 'D']).toContain(furtB.delay?.serviceLevel?.grade); expect(b.stufen.some((f) => f.target?.label === 'F1')).toBe(false); }); it('kennt die alte Meldung "nicht bestimmbar" nicht mehr', () => { const quelltext = readFileSync( new URL('../../src/domain/validation/engine.ts', import.meta.url), 'utf8', ); expect(quelltext).not.toContain('nicht bestimmbar'); expect(quelltext).not.toContain('Bei Fußgängerfurten ist das der Regelfall'); }); }); describe('Kfz und OePNV: Verfahren und Kriterium stehen an der Stufe', () => { function ueberlastet(scale: 'HBS' | 'HCM'): Project { const base = vollstaendig(); return { ...base, settings: { ...base.settings, serviceLevelScale: scale }, demands: base.demands.map((d) => ({ ...d, volume: 2500 })), }; } it('nennt bei Ueberlastung das Kriterium q > C und die Stufe F nach HBS 2015', () => { const { plan, bericht, stufen } = qualitaetsstufen(ueberlastet('HBS')); const k1 = plan.groups.find((g) => g.name === 'K1')!; expect(k1.delay?.serviceLevel?.kriterium).toBe('ueberlastung'); const befund = stufen.find((f) => f.target?.label === 'K1'); expect(befund).toBeDefined(); expect(befund!.severity).toBe('warnung'); expect(befund!.message).toContain('ist überlastet (q > C)'); expect(befund!.message).toContain( 'Nach HBS 2015 ist das die Stufe F unabhängig von der Wartezeit', ); // q und C stehen in der Meldung - sonst liesse sich q > C nicht nachrechnen. expect(befund!.message).toContain('2500 Fz/h'); expect(befund!.message).toContain(`${k1.capacity!.capacity} Fz/h`); expect(befund!.reference).toBe('HBS 2015 - Qualitätsstufen des Verkehrsablaufs'); // Die Auslastungsgrad-Regel selbst bleibt und traegt die HBS-Fundstelle. const ueberlastung = bericht.findings.find( (f) => f.rule === 'leistungsfaehigkeit.ueberlastet' && f.target?.label === 'K1', ); expect(ueberlastung?.reference).toBe('HBS 2015 - Kapazität und Auslastungsgrad'); }); it('nennt bei einer Wartezeitstufe das Kriterium mittlere Wartezeit', () => { // K1 auf 1200 Fz/h: x knapp unter 1, mittlere Wartezeit weit ueber 70 s -> // Stufe E (HBS 2015 kennt fuer Kfz keine Wartezeitstufe F). const base = vollstaendig(); const projekt: Project = { ...base, demands: base.demands.map((d, i) => (i === 0 ? { ...d, volume: 1200 } : d)), }; const { plan, stufen } = qualitaetsstufen(projekt); const k1 = plan.groups.find((g) => g.name === 'K1')!; expect(k1.delay?.serviceLevel?.grade).toBe('E'); expect(k1.delay?.serviceLevel?.kriterium).toBe('mittlere-wartezeit'); const befund = stufen.find((f) => f.target?.label === 'K1'); expect(befund).toBeDefined(); expect(befund!.severity).toBe('hinweis'); expect(befund!.message).toContain('mittlere Wartezeit'); expect(befund!.message).toContain('Kriterium: mittlere Wartezeit'); expect(befund!.message).toContain('nach HBS 2015'); expect(befund!.message).toContain(`${String(k1.delay!.averageDelay).replace('.', ',')} s`); expect(befund!.message).not.toContain('überlastet'); }); it('sagt "HCM" statt "HBS 2015", wenn nach HCM gerechnet wurde', () => { const { plan, stufen } = qualitaetsstufen(ueberlastet('HCM')); const k1 = plan.groups.find((g) => g.name === 'K1')!; expect(k1.delay?.serviceLevel?.scale).toBe('HCM'); const befund = stufen.find((f) => f.target?.label === 'K1'); expect(befund).toBeDefined(); expect(befund!.message).toContain('Nach HCM ist das die Stufe F unabhängig von der Wartezeit'); expect(befund!.message).not.toContain('HBS'); expect(befund!.reference).toBe('HCM - Level of Service'); }); it('weist die Schwellen 0,85 und 0,95 als Praxiswerte aus, nicht als HBS-Zahlen', () => { // K1 auf 1300 Fz/h: K1 ueberlastet, K4 ueber dem Praxiswert, aber nicht // ueberlastet (die Umlaufzeit steigt bis an ihre Obergrenze). // // NACHGEZOGEN MIT FASSUNG 5.43.0 (Gruppe Furt): Hier standen 1500 Fz/h und // K2 als zweite Gruppe. Die Vorlage erfasst seither jede Furt auch gegen // den Strom, der sie beim Ausfahren quert (K2/F2, K4/F1), und K4 beginnt // erst 10 s nach dem Ende von F1. Mit 1500 Fz/h sind dadurch K1, K3 und K4 // ueberlastet, und keine Meldung nach Praxiswert bleibt uebrig; mit // 1300 Fz/h liegt K4 bei 0,961 - der Fall, den diese Wache braucht. const base = vollstaendig(); const projekt: Project = { ...base, demands: base.demands.map((d, i) => (i === 0 ? { ...d, volume: 1300 } : d)), }; const { bericht } = qualitaetsstufen(projekt); const praxis = bericht.findings.filter( (f) => f.rule === 'leistungsfaehigkeit.hoch' || f.rule === 'leistungsfaehigkeit.kritisch', ); expect(praxis.length).toBeGreaterThan(0); for (const f of praxis) { expect(f.reference).toContain('Praxiswerte'); expect(f.reference).not.toContain('HBS'); } }); }); describe('Nach HCM: Fussgaenger und Radverkehr werden nicht bewertet - und der Bericht sagt es', () => { it('gibt einen Hinweis je Projekt, nicht je Gruppe', () => { const base = furtprojekt(120, 10, 'knotenpunkt'); const rad = createSignalGroup({ name: 'R1', mode: 'rad', index: 2 }); const projekt: Project = { ...base, settings: { ...base.settings, serviceLevelScale: 'HCM' }, signalGroups: [...base.signalGroups, rad], phases: base.phases.map((p, i) => i === 1 ? { ...p, signalGroupIds: [...p.signalGroupIds, rad.id] } : p, ), }; const { plan, bericht, stufen } = qualitaetsstufen(projekt); // Voraussetzung: keine Stufe fuer Furt und Radfurt. expect(plan.groups.find((g) => g.name === 'F1')!.delay?.serviceLevel).toBeNull(); expect(plan.groups.find((g) => g.name === 'R1')!.delay?.serviceLevel).toBeNull(); const hinweise = bericht.findings.filter( (f) => f.rule === 'leistungsfaehigkeit.fuss-rad-nicht-bewertet', ); expect(hinweise, 'genau ein Hinweis fuer beide Gruppen').toHaveLength(1); expect(hinweise[0]!.severity).toBe('hinweis'); expect(hinweise[0]!.title).toContain('nicht bewertet'); expect(hinweise[0]!.message).toContain('"F1"'); expect(hinweise[0]!.message).toContain('"R1"'); expect(hinweise[0]!.message).toContain('maximale Wartezeit'); expect(hinweise[0]!.reference).toBe('HCM - Level of Service'); // Keine Stufenmeldung fuer die Furten - es gibt keine Stufe. expect(stufen.some((f) => f.target?.label === 'F1' || f.target?.label === 'R1')).toBe(false); }); it('schweigt nach HBS und ohne Fuss- oder Radgruppen', () => { const hbs = qualitaetsstufen(furtprojekt(120, 10, 'knotenpunkt')); expect( hbs.bericht.findings.some((f) => f.rule === 'leistungsfaehigkeit.fuss-rad-nicht-bewertet'), ).toBe(false); const base = vollstaendig(); const nurKfz: Project = { ...base, settings: { ...base.settings, serviceLevelScale: 'HCM' }, signalGroups: base.signalGroups.filter((g) => g.mode === 'kfz'), conflicts: base.conflicts.filter((c) => base.signalGroups .filter((g) => g.mode === 'kfz') .some((g) => g.id === c.fromId || g.id === c.toId), ), phases: base.phases.map((p) => ({ ...p, signalGroupIds: p.signalGroupIds.filter((id) => base.signalGroups.some((g) => g.id === id && g.mode === 'kfz'), ), })), }; expect( qualitaetsstufen(nurKfz).bericht.findings.some( (f) => f.rule === 'leistungsfaehigkeit.fuss-rad-nicht-bewertet', ), ).toBe(false); }); }); describe('Verkehrsstaerken werden nur verlangt, wo eine Saettigungsverkehrsstaerke gilt', () => { it('fordert bei einem Projekt nur aus Furt und Radfurt keine Verkehrsstaerken', () => { // Vor Fassung 5.4.0 loeste jede Gruppe mit mode !== 'fuss' // die Forderung aus - also auch der Radverkehr, der nach HBS 2015 keine // Saettigungsverkehrsstaerke hat. const basis = createEmptyProject('Rad und Fuss', STICHTAG, 'knotenpunkt'); const rad = createSignalGroup({ name: 'R1', mode: 'rad', index: 0 }); const fuss = createSignalGroup({ name: 'F1', mode: 'fuss', index: 1 }); const phase1 = createPhase('Rad', [rad.id]); const phase2 = createPhase('Fuss', [fuss.id]); const projekt: Project = { ...basis, signalGroups: [rad, fuss], conflicts: [ { ...createConflict(rad.id, fuss.id), clearingDistance: 10, enteringDistance: 3 }, { ...createConflict(fuss.id, rad.id), clearingDistance: 6, enteringDistance: 3 }, ], phases: [phase1, phase2], program: { ...basis.program, phaseOrder: [phase1.id, phase2.id] }, }; const bericht = validateProject(projekt, undefined, STICHTAG); expect( bericht.findings.some((f) => f.rule === 'leistungsfaehigkeit.keine-verkehrsstaerken'), ).toBe(false); }); it('fordert sie weiterhin, sobald ein Kfz-Strom ohne Verkehrsstaerke dabei ist', () => { const base = vollstaendig(); const bericht = validateProject({ ...base, demands: [] }, undefined, STICHTAG); expect( bericht.findings.some((f) => f.rule === 'leistungsfaehigkeit.keine-verkehrsstaerken'), ).toBe(true); }); }); describe('Gespeicherte Verkehrsstaerken ohne Rechenwirkung', () => { it('nennt eine Radverkehrsstaerke aus einer aelteren Datei, die in keine Rechnung mehr eingeht', () => { // Bis Fassung 5.3.5 ging die Verkehrsstaerke einer Radgruppe mit einer // Kfz-Saettigungsverkehrsstaerke in Kapazitaet und Umlaufzeit ein. Seit // Fassung 5.4.0 (Befund B4) nicht mehr - das darf beim // Oeffnen einer Altdatei nicht still geschehen. const base = vollstaendig(); const rad = createSignalGroup({ name: 'R1', mode: 'rad', index: base.signalGroups.length }); const projekt: Project = { ...base, signalGroups: [...base.signalGroups, rad], phases: base.phases.map((p, i) => i === 0 ? { ...p, signalGroupIds: [...p.signalGroupIds, rad.id] } : p, ), demands: [...base.demands, { signalGroupId: rad.id, volume: 300, heavyVehicleShare: 0 }], }; const bericht = validateProject(projekt, undefined, STICHTAG); const hinweis = bericht.findings.find( (f) => f.rule === 'leistungsfaehigkeit.verkehrsstaerke-ohne-wirkung', ); expect(hinweis).toBeDefined(); expect(hinweis?.severity).toBe('hinweis'); expect(hinweis?.message).toContain('"R1"'); expect(hinweis?.message).toContain('Umlaufzeit'); // Ohne solche Verkehrsstaerke schweigt der Bericht. const ohne = validateProject(base, undefined, STICHTAG); expect( ohne.findings.some((f) => f.rule === 'leistungsfaehigkeit.verkehrsstaerke-ohne-wirkung'), ).toBe(false); }); }); describe('Verfahrensabhaengige Meldungen im HCM-Projekt', () => { it('nennt bei einer Fuss/Rad-Verkehrsstaerke nach HCM kein HBS-Etikett', () => { const base = vollstaendig(); const rad = createSignalGroup({ name: 'R1', mode: 'rad', index: base.signalGroups.length }); const projekt: Project = { ...base, signalGroups: [...base.signalGroups, rad], phases: base.phases.map((p, i) => i === 0 ? { ...p, signalGroupIds: [...p.signalGroupIds, rad.id] } : p, ), demands: [...base.demands, { signalGroupId: rad.id, volume: 300, heavyVehicleShare: 0 }], settings: { ...base.settings, serviceLevelScale: 'HCM' }, }; const bericht = validateProject(projekt, undefined, STICHTAG); const hinweis = bericht.findings.find( (f) => f.rule === 'leistungsfaehigkeit.verkehrsstaerke-ohne-wirkung', ); expect(hinweis).toBeDefined(); expect(hinweis?.message).not.toContain('HBS'); expect(hinweis?.message).toContain('nach HCM erhalten sie keine Stufe'); expect(hinweis?.reference).toBe('HCM - Level of Service'); }); it('meldet eine Abweichung des Instationaritaetsfaktors nach HCM als wirkungslos, nach HBS mit HBS-Fundstelle', () => { const base = vollstaendig(); const mitAbweichung = (verfahren: 'HBS' | 'HCM'): Project => ({ ...base, settings: { ...base.settings, serviceLevelScale: verfahren, rilsa: { instationaritaetsfaktor: 1.2 }, }, }); const hcm = validateProject(mitAbweichung('HCM'), undefined, STICHTAG).findings.find( (f) => f.rule === 'vorgaben.abweichung.instationaritaetsfaktor', ); expect(hcm?.message).toContain('geht in keine Zahl ein'); expect(hcm?.reference).toContain('HBS 2015'); const hbs = validateProject(mitAbweichung('HBS'), undefined, STICHTAG).findings.find( (f) => f.rule === 'vorgaben.abweichung.instationaritaetsfaktor', ); expect(hbs?.message).not.toContain('geht in keine Zahl ein'); expect(hbs?.reference).toContain('mittlere Wartezeit'); // Die Fundstelle einer Abweichung ist die Quelle des Kennwerts - nicht // pauschal "RiLSA 2015 - Kennwerte". const rast = validateProject( { ...base, settings: { ...base.settings, rilsa: { cycleTimeStep: 2 } } }, undefined, STICHTAG, ).findings.find((f) => f.rule === 'vorgaben.abweichung.cycleTimeStep'); expect(rast?.reference).toContain('Planungspraxis'); }); });