import { describe, expect, it } from 'vitest'; import { validateProject } from '@/domain/validation/engine'; import { buildSignalPlan } from '@/domain/plan/signalPlan'; import { createPhase, createStandardIntersectionProject } from '@/domain/model/factory'; import { ANLAGENART_KURZ } from '@/domain/rilsa/constants'; import type { Anlagenart, Project } from '@/domain/model/project'; /* * Stellen, an denen der Pruefbericht dem Bearbeiter etwas Falsches sagte. * Alle waren Wortlaut, keine Rechnung - und gerade deshalb schwer zu * bemerken: Die Zahlen stimmten, der Satz daneben nicht. * * Die letzte Gruppe kam mit Fassung 5.4.0 (Befunde B1, B2, B4) * hinzu: Die Stufenmeldung schrieb "nach HBS", waehrend nach HCM gerechnet * wurde, und nannte nicht, woran die Stufe haengt. */ const STICHTAG = new Date('2026-01-01T00:00:00Z'); function vollstaendig(name: string): Project { const base = createStandardIntersectionProject(name, STICHTAG); return { ...base, meta: { ...base.meta, projectNumber: 'VP-2026-001', client: 'Stadt Musterhausen', planner: 'O. Willerding', location: name, }, }; } describe('Uebergang ohne Zwischenzeit: erfasst oder nicht erfasst', () => { /* * Die Regel prueft transition.duration === 0 und schloss daraus, es sei keine * Beziehung erfasst. Eine Dauer von 0 s entsteht aber auch, wenn ALLE * Beziehungen erfasst sind und rechnerisch 0 s ergeben. Der Bericht forderte * dann, Beziehungen zu erfassen, die alle bereits erfasst waren, und sperrte * dabei den Export. (Hier stand bis 5.42.1 "der Regelfall bei langen * Furten" - das war der Einfahrweg des Fussgaengers ab der falschen * Bordkante; siehe tests/domain/furtrichtung.test.ts.) */ function alleNull(): Project { const roh = vollstaendig('Nullzwischenzeiten'); return { ...roh, // Kurzer Konfliktbereich, weit zurueckliegende Haltlinien: Die // Einfahrzeit erreicht die Raeum- und Ueberfahrzeit, tz wird 0. conflicts: roh.conflicts.map((c) => ({ ...c, clearingDistance: 3, enteringDistance: 60, })), }; } it('schweigt, wenn die Beziehungen erfasst sind und rechnerisch 0 s ergeben', () => { const projekt = alleNull(); const plan = buildSignalPlan(projekt); const bericht = validateProject(projekt, plan, STICHTAG); // Voraussetzung des Aufbaus: Es gibt Uebergaenge ohne Dauer. expect(plan.transitions.some((t) => t.duration === 0)).toBe(true); expect([...plan.intergreens.values()].every((i) => i.value === 0)).toBe(true); expect( bericht.findings.some((f) => f.rule === 'zwischenzeiten.uebergang-ungesichert'), 'Die Beziehungen sind erfasst - die Meldung behauptete das Gegenteil', ).toBe(false); // Hinsehen muss der Bearbeiter trotzdem; dafuer steht die Meldung je // Beziehung, und die ist eine Warnung. const null0 = bericht.findings.filter((f) => f.rule === 'zwischenzeiten.null'); expect(null0.length).toBeGreaterThan(0); expect(null0.every((f) => f.severity === 'warnung')).toBe(true); }); it('meldet weiterhin, wenn zwischen den wechselnden Gruppen gar nichts erfasst ist', () => { const roh = vollstaendig('Ohne Beziehungen'); const projekt: Project = { ...roh, conflicts: [] }; const bericht = validateProject(projekt, buildSignalPlan(projekt), STICHTAG); const befund = bericht.findings.find((f) => f.rule === 'zwischenzeiten.uebergang-ungesichert'); expect(befund).toBeDefined(); expect(befund!.severity).toBe('fehler'); expect(bericht.exportBlocked).toBe(true); }); it('meldet denselben Uebergang nicht zweimal mit verschiedener Schwere', () => { for (const projekt of [alleNull(), { ...vollstaendig('Leer'), conflicts: [] }]) { const bericht = validateProject(projekt, buildSignalPlan(projekt), STICHTAG); // Die zweite Meldung aus dem Planaufbau ist entfallen; sie sagte zum // selben Uebergang etwas anderes und wog anders. expect( bericht.findings.some((f) => f.rule === 'signalplan.uebergang-ohne-zwischenzeit'), ).toBe(false); } }); }); describe('Feindliche Paare werden einmal gezaehlt', () => { it('meldet je Paar genau einen Fehler, nicht zwei', () => { const roh = vollstaendig('Doppelzaehlung'); const alle = createPhase( 'Alles gemeinsam', roh.signalGroups.map((g) => g.id), ); const projekt: Project = { ...roh, phases: [alle], program: { ...roh.program, phaseOrder: [alle.id] }, }; const bericht = validateProject(projekt, buildSignalPlan(projekt), STICHTAG); const paare = new Set(projekt.conflicts.map((c) => [c.fromId, c.toId].sort().join('|'))); const feindlich = bericht.findings.filter( (f) => f.rule === 'phasen.feindlich-in-phase' || f.rule === 'signalplan.feindliche-freigabe', ); // Acht Paare seit 5.43.0: Die Vorlage erfasst jede Furt auch gegen den // Strom, der sie beim Ausfahren quert (K2/F2, K4/F1). Bis 5.42.1 waren es // sechs. Die Aussage bleibt: je Paar ein Fehler, nicht zwei. expect(paare.size).toBe(8); expect(feindlich, 'acht Konflikte duerfen nicht sechzehn Fehler ergeben').toHaveLength(8); // Jede Meldung nennt ihr Paar; achtmal derselbe Titel waere im Bericht // nicht auseinanderzuhalten. expect(new Set(feindlich.map((f) => f.title)).size).toBe(8); }); }); describe('Anlagenart im Wortlaut der Uebersaettigung', () => { function uebersaettigt(anlagenart: Anlagenart): Project { const roh = vollstaendig('Uebersaettigt'); return { ...roh, anlagenart, // Verkehrsstaerken weit ueber der Kapazitaet. demands: roh.signalGroups .filter((g) => g.mode === 'kfz') .map((g) => ({ signalGroupId: g.id, volume: 1800, heavyVehicleShare: 0.1 })), }; } it('nennt bei einer Baustellenampel keinen Knotenpunkt', () => { const bericht = validateProject(uebersaettigt('einstreifig'), undefined, STICHTAG); const befund = bericht.findings.find((f) => f.rule === 'signalplan.uebersaettigt'); expect(befund, 'Der Aufbau muss die Uebersaettigung ausloesen').toBeDefined(); // Ueberschrift und Fliesstext duerfen sich nicht widersprechen. expect(befund!.title).toContain(ANLAGENART_KURZ.einstreifig); expect(befund!.message).not.toContain('Knotenpunkt'); // "Fahrstreifen ergaenzen" ist bei einstreifiger Fuehrung kein Rat. expect(befund!.suggestion).not.toContain('ergänzen Sie Fahrstreifen'); }); it('haelt beim Knotenpunkt am gewohnten Rat fest', () => { const bericht = validateProject(uebersaettigt('knotenpunkt'), undefined, STICHTAG); const befund = bericht.findings.find((f) => f.rule === 'signalplan.uebersaettigt'); expect(befund).toBeDefined(); expect(befund!.title).toContain('Knotenpunkt'); expect(befund!.suggestion).toContain('Fahrstreifen'); }); it('gibt fuer jede Anlagenart einen eigenen Rat', () => { const raete = (['knotenpunkt', 'fussgaengerschutzanlage', 'einstreifig'] as const).map( (art) => validateProject(uebersaettigt(art), undefined, STICHTAG).findings.find( (f) => f.rule === 'signalplan.uebersaettigt', )?.suggestion ?? '', ); expect(raete.every((r) => r !== '')).toBe(true); expect(new Set(raete).size, 'drei Anlagenarten, drei Raete').toBe(3); }); }); describe('Verfahren und Kriterium in der Stufenmeldung', () => { /* * Vor Fassung 5.4.0 hiess es "nach HBS" oder "nach HCM", je * nachdem, welche Tafel eingestellt war - gerechnet wurde immer dieselbe * HCM-Formel (Befund B2). Und die Stufe F fuer Kfz haengt nach HBS 2015 nicht * an der Wartezeit, sondern an q > C (Befund B1): "Stufe F, mittlere * Wartezeit 45 s" neben einer Tafel, die bei 45 s C ausweist, ist ein * Widerspruch im selben Dokument. Deshalb muss jede Stufenmeldung sagen, * welches Verfahren gerechnet wurde und woran gemessen wurde. */ function hochbelastet(scale: 'HBS' | 'HCM'): Project { const roh = vollstaendig('Hochbelastet'); return { ...roh, settings: { ...roh.settings, serviceLevelScale: scale }, // K1 weit ueber der Kapazitaet, K2 hoch belastet, die Furten mit langer // Sperrzeit: alle drei Kriterien kommen im selben Bericht vor. demands: roh.demands.map((d, i) => ({ ...d, volume: i === 0 ? 2500 : 900 })), }; } const KRITERIEN = ['mittlere Wartezeit', 'maximale Wartezeit', 'überlastet (q > C)']; it('nennt nach HBS an jeder Stufenmeldung "HBS 2015" und genau ein Kriterium', () => { const bericht = validateProject(hochbelastet('HBS'), undefined, STICHTAG); const stufen = bericht.findings.filter((f) => f.rule === 'leistungsfaehigkeit.qualitaetsstufe'); expect(stufen.length, 'der Aufbau muss Stufenmeldungen erzeugen').toBeGreaterThan(1); // Kfz und Furt sind beide dabei - zwei verschiedene Kriterien. expect(stufen.some((f) => f.message.includes('maximale Wartezeit'))).toBe(true); expect(stufen.some((f) => f.message.includes('überlastet (q > C)'))).toBe(true); for (const f of stufen) { expect(f.message, f.message).toContain('HBS 2015'); expect(f.message).not.toContain('HCM'); expect(f.reference).toBe('HBS 2015 - Qualitätsstufen des Verkehrsablaufs'); const genannt = KRITERIEN.filter((k) => f.message.includes(k)); expect(genannt, `genau ein Kriterium: ${f.message}`).toHaveLength(1); } }); it('behauptet nach HCM nirgends "HBS 2015"', () => { const bericht = validateProject(hochbelastet('HCM'), undefined, STICHTAG); const leistung = bericht.findings.filter((f) => f.category === 'leistungsfaehigkeit'); const stufen = leistung.filter((f) => f.rule === 'leistungsfaehigkeit.qualitaetsstufe'); expect(stufen.length).toBeGreaterThan(0); for (const f of stufen) { expect(f.message).toContain('HCM'); expect(f.message).not.toContain('HBS'); expect(f.reference).toBe('HCM - Level of Service'); } // Die Furten bekommen nach HCM keine Stufe - und keine Stufenmeldung, die // eine behauptet. Stattdessen sagt der Bericht einmal, dass sie nicht // bewertet werden. expect(stufen.some((f) => f.message.includes('maximale Wartezeit'))).toBe(false); expect( leistung.filter((f) => f.rule === 'leistungsfaehigkeit.fuss-rad-nicht-bewertet'), ).toHaveLength(1); }); it('haengt die Stufe F fuer Kfz an q > C und nennt beide Zahlen', () => { const projekt = hochbelastet('HBS'); const plan = buildSignalPlan(projekt); const bericht = validateProject(projekt, plan, STICHTAG); const k1 = plan.groups.find((g) => g.name === 'K1')!; const befund = bericht.findings.find( (f) => f.rule === 'leistungsfaehigkeit.qualitaetsstufe' && f.target?.label === 'K1', ); expect(befund).toBeDefined(); expect(befund!.title).toBe('Qualitätsstufe F für "K1"'); expect(befund!.message).toContain('2500 Fz/h'); expect(befund!.message).toContain(`${k1.capacity!.capacity} Fz/h`); expect(befund!.message).toContain('unabhängig von der Wartezeit'); }); });