import { readFileSync } from 'node:fs'; import { describe, expect, it } from 'vitest'; import { validateProject } from '@/domain/validation/engine'; import { buildSignalPlan, type PlanNote, type SignalPlan } from '@/domain/plan/signalPlan'; import { createConflict, createPhase, createSignalGroup, createStandardIntersectionProject, } from '@/domain/model/factory'; import type { Project } from '@/domain/model/project'; /* * Fassung 5.9.0 - Ueberschrift, naechster Schritt, Sachgebiet * und Fundstelle der Plan-Notizen im Pruefbericht (Befunde 13 und 38). * * Der Bericht loest den Notizcode ueber vier Tabellen auf. Wo ein Code in * keiner steht, druckt er den Sammeltitel "Hinweis aus dem Planaufbau", den * Ersatzrat "Prüfen Sie die zugrunde liegenden Eingaben.", das Sammelgebiet * "Signalzeitenplan" und die Sammelfundstelle "RiLSA 2015" - eine Warnung, die * sich selbst Hinweis nennt, in der falschen Karte, mit einem Regelwerksbeleg, * den es fuer den genannten Wert nicht gibt. */ const DATUM = new Date('2026-01-01T00:00:00Z'); const SAMMELTITEL = 'Hinweis aus dem Planaufbau'; const ERSATZRAT = 'Prüfen Sie die zugrunde liegenden Eingaben.'; function bericht(project: Project) { return validateProject(project, buildSignalPlan(project), DATUM); } describe('Befund 13 - Die Handkuerzung der Zwischenzeit traegt eine eigene Ueberschrift', () => { /** K1 nach K3 mit einer von Hand auf 1 s gekuerzten Zwischenzeit. */ function handgekuerzt(): Project { const roh = createStandardIntersectionProject('Handkürzung', DATUM); const k1 = roh.signalGroups.find((g) => g.name === 'K1')!; const k3 = roh.signalGroups.find((g) => g.name === 'K3')!; return { ...roh, conflicts: roh.conflicts.map((c) => c.fromId === k1.id && c.toId === k3.id ? { ...c, manualIntergreen: 1 } : c, ), }; } it('nennt die von Hand unterschrittene Zwischenzeit beim Namen', () => { // Die gefaehrliche Richtung: eingetragen ist weniger, als gerechnet wird. // Wer die Zeile ueberfliegt, liest sonst "Hinweis aus dem Planaufbau" und // als naechsten Schritt "Prüfen Sie die zugrunde liegenden Eingaben." const befund = bericht(handgekuerzt()).findings.find( (f) => f.rule === 'signalplan.zwischenzeit-vorgabe-unter-rechenwert', ); expect(befund?.severity).toBe('warnung'); expect(befund?.category).toBe('zwischenzeiten'); expect(befund?.title).not.toBe(SAMMELTITEL); expect(befund?.suggestion).not.toBe(ERSATZRAT); }); }); describe('Befund 38 - Meldungen der Zwischenzeitrechnung ohne Praefixtreffer', () => { /** * Ein Knotenpunkt mit einer OePNV-Gruppe ohne zulaessige * Hoechstgeschwindigkeit. Erreichbar ueber den Altdatei-Pfad, der die * Geschwindigkeit ungeprueft uebernimmt. */ function oepnvOhneVmax(): Project { const roh = createStandardIntersectionProject('ÖPNV', DATUM); const k1 = roh.signalGroups.find((g) => g.name === 'K1')!; const bahn = createSignalGroup({ name: 'B1', mode: 'oepnv', index: 6 }); const ohneVmax = { ...bahn, vZul: 0 }; const phase3 = createPhase('Phase 3 - ÖPNV', [ohneVmax.id]); return { ...roh, signalGroups: [...roh.signalGroups, ohneVmax], phases: [...roh.phases, phase3], program: { ...roh.program, phaseOrder: [...roh.program.phaseOrder, phase3.id] }, conflicts: [ ...roh.conflicts, { ...createConflict(ohneVmax.id, k1.id), clearingDistance: 20, enteringDistance: 8 }, ], }; } it('fuehrt die fehlende OePNV-Hoechstgeschwindigkeit unter Zwischenzeiten', () => { const befund = bericht(oepnvOhneVmax()).findings.find( (f) => f.rule === 'signalplan.oepnv-vmax-fehlt', ); expect(befund?.severity).toBe('warnung'); expect(befund?.category).toBe('zwischenzeiten'); expect(befund?.title).not.toBe(SAMMELTITEL); expect(befund?.suggestion).not.toBe(ERSATZRAT); }); it('schreibt den Rueckfallwert ohne Vmax nicht der RiLSA 2015 zu', () => { // Der Rueckfallwert ist ein Programmansatz. Eine erfundene Fundstelle // waere schlimmer als keine - der Bericht geht in die Planunterlage. const befund = bericht(oepnvOhneVmax()).findings.find( (f) => f.rule === 'signalplan.oepnv-vmax-fehlt', ); expect(befund?.reference).not.toBe('RiLSA 2015'); }); }); /* * Waechter ueber alle Notizcodes, die der Planaufbau erzeugen kann. * * Ohne ihn wandert der Befund beim naechsten neuen Code sofort zurueck: Wer * eine Notiz hinzufuegt und die vier Tabellen in engine.ts nicht ergaenzt, * bekommt keinen roten Test, sondern eine Zeile mit Sammeltitel und Ersatzrat * im gedruckten Bericht. * * Gelesen werden die Dateien, deren Notizen in `plan.notes` gelangen. Die * Meldungen der Vorgabenpruefung (Praefix "vorgabe-") bleiben aussen vor: Sie * fuehrt checkSettings mit eigener Kategorie, und die Notizschleife * ueberspringt sie. */ const NOTIZQUELLEN: readonly { readonly datei: string; readonly ohneHinweise: boolean; /** Meldungen der Zwischenzeitrechnung - sie gehoeren unter "Zwischenzeiten". */ readonly ausZwischenzeitrechnung: boolean; }[] = [ // Die Hinweise aus computeIntergreen verwirft der Planaufbau, bevor sie in // plan.notes gelangen (signalPlan.ts, Filter auf severity 'hinweis'). { datei: 'src/domain/rilsa/intergreen.ts', ohneHinweise: true, ausZwischenzeitrechnung: true, }, { datei: 'src/domain/plan/signalPlan.ts', ohneHinweise: false, ausZwischenzeitrechnung: false }, { datei: 'src/domain/rilsa/cycle.ts', ohneHinweise: false, ausZwischenzeitrechnung: false }, { datei: 'src/domain/rilsa/greenSplit.ts', ohneHinweise: false, ausZwischenzeitrechnung: false }, ]; /** Eine gefundene Notiz samt der Herkunft, die ihr Sachgebiet bestimmt. */ interface Notizfund { readonly note: PlanNote; readonly ausZwischenzeitrechnung: boolean; } function notizcodes(): readonly Notizfund[] { const gefunden = new Map(); for (const quelle of NOTIZQUELLEN) { const text = readFileSync(quelle.datei, 'utf8'); const treffer = text.matchAll(/severity: '(fehler|warnung|hinweis)',\s*code: '([^']+)'/g); for (const [, severity, code] of treffer) { if (severity === undefined || code === undefined) continue; if (code.startsWith('vorgabe-')) continue; if (quelle.ohneHinweise && severity === 'hinweis') continue; // Derselbe Code kann an mehreren Stellen mit verschiedener Schwere // entstehen; die schwerere Fassung ist die massgebende. const bisher = gefunden.get(code); if (bisher !== undefined && bisher.note.severity !== 'hinweis') continue; gefunden.set(code, { note: { severity: severity as PlanNote['severity'], code, message: `Prüftext zu ${code}`, }, ausZwischenzeitrechnung: quelle.ausZwischenzeitrechnung, }); } } return [...gefunden.values()]; } /** Derselbe Plan, aber mit einer untergeschobenen Notiz. */ function planMitNotiz(plan: SignalPlan, note: PlanNote): SignalPlan { return { ...plan, notes: [note] }; } describe('Waechter - keine Plan-Notiz faellt auf Sammeltitel oder Ersatzrat', () => { const project = createStandardIntersectionProject('Wächter', DATUM); const plan = buildSignalPlan(project); const codes = notizcodes(); it('findet die Notizcodes des Planaufbaus', () => { // Faellt die Suche leer aus, bewacht der Waechter nichts mehr - etwa // nachdem eine Datei umbenannt wurde. expect(codes.length).toBeGreaterThan(20); const namen = codes.map((n) => n.note.code); expect(namen).toContain('zwischenzeit-vorgabe-unter-rechenwert'); expect(namen).toContain('oepnv-vmax-fehlt'); expect(namen).toContain('oepnv-vmax-ueber-70'); expect(namen).toContain('enger-radius-schneller-als-regelfall'); expect(namen).toContain('abbiegend-schneller-als-geradeaus'); expect(namen).toContain('erhoehter-zeitbedarf-schneller-als-regelfall'); }); it.each(codes.map((n) => [n.note.code, n] as const))( '%s traegt eine eigene Ueberschrift, einen eigenen naechsten Schritt und das richtige Sachgebiet', (_code, fund) => { const ergebnis = validateProject(project, planMitNotiz(plan, fund.note), DATUM); const befund = ergebnis.findings.find((f) => f.rule === `signalplan.${fund.note.code}`); // Kein Befund heisst: Der Sachverhalt hat im Bericht eine eigene Regel, // und die Notizschleife ueberspringt ihn. Auch das ist in Ordnung. if (befund === undefined) return; expect(befund.title).not.toBe(SAMMELTITEL); expect(befund.suggestion).not.toBe(ERSATZRAT); // Jede Meldung aus computeIntergreen traegt bereits das Vorwort // "Zwischenzeit A nach B:". Unter "Signalzeitenplan" gefuehrt, sucht // der Anwender sie in der falschen Karte des Berichts. if (fund.ausZwischenzeitrechnung) expect(befund.category).toBe('zwischenzeiten'); }, ); }); /* * Fundstelle der beiden Meldungen, deren Zahl NICHT aus der RiLSA stammt * (Befund 38). Der Pruefbericht geht in die Planunterlage; eine erfundene * Fundstelle ist schlimmer als keine. */ describe('Befund 38 - Fundstelle nennt die wirkliche Herkunft', () => { const project = createStandardIntersectionProject('Fundstelle', DATUM); const plan = buildSignalPlan(project); function befundZu(code: string) { const note: PlanNote = { severity: 'fehler', code, message: `Prüftext zu ${code}` }; return validateProject(project, planMitNotiz(plan, note), DATUM).findings.find( (f) => f.rule === `signalplan.${code}`, ); } it('fuehrt den Sonderwert bei engem Innenradius als Praxisansatz, nicht als RiLSA 2015', () => { // Die Engine sagt an ihrer eigenen Meldung zur selben Sache, in der RiLSA // 2015 sei ein Sonderwert fuer enge Innenradien nicht nachweisbar. Zwei // Zeilen desselben Berichts nannten dafuer zwei verschiedene Herkuenfte. const befund = befundZu('enger-radius-schneller-als-regelfall'); expect(befund).toBeDefined(); expect(befund?.category).toBe('zwischenzeiten'); expect(befund?.reference).not.toBe('RiLSA 2015'); // Seit 5.27.0 nennt der Rueckfall die RiLSA-Fundstelle - der Sonderwert // steht dort (Abschnitt 2.5.2, Fall 2). Der eigene Zweig bleibt, weil er // das Sachgebiet mitliefert statt eines pauschalen "RiLSA 2015". expect(befund?.reference).toContain('Räumgeschwindigkeit bei engem Innenradius'); }); it('fuehrt die 70-km/h-Grenze des OePNV unter der VwV-StVO', () => { // Die Grenze steht in der Verwaltungsvorschrift zu Paragraph 37, nicht in // der RiLSA - der Meldungstext nennt sie selbst so. const befund = befundZu('oepnv-vmax-ueber-70'); expect(befund).toBeDefined(); expect(befund?.category).toBe('zwischenzeiten'); expect(befund?.reference).not.toBe('RiLSA 2015'); expect(befund?.reference).toContain('VwV-StVO'); }); }); /* * Ueberschrift und naechster Schritt duerfen nicht behaupten, was der * Meldungstext derselben Zeile bestreitet (Nachtrag zu den Befunden 13 * und 38). Alle drei Meldungen enden mit "Angesetzt wird der kleinere Wert": * Die Kappung in clearingSpeedFor verhindert die Verkuerzung gerade, sie * tritt nicht ein. */ describe('Befunde 13 und 38 - Kein Titel behauptet eine eingetretene Verkuerzung', () => { const project = createStandardIntersectionProject('Kappung', DATUM); const plan = buildSignalPlan(project); const GEKAPPTE_WIDERSPRUECHE = [ 'enger-radius-schneller-als-regelfall', 'abbiegend-schneller-als-geradeaus', 'erhoehter-zeitbedarf-schneller-als-regelfall', ] as const; it.each(GEKAPPTE_WIDERSPRUECHE)('%s: Ueberschrift bleibt bei der Ursache', (code) => { const note: PlanNote = { severity: 'fehler', code, message: `Prüftext zu ${code}` }; const befund = validateProject(project, planMitNotiz(plan, note), DATUM).findings.find( (f) => f.rule === `signalplan.${code}`, ); expect(befund).toBeDefined(); expect(befund?.title).not.toBe(SAMMELTITEL); // "verkürzte die Zwischenzeit" und "räumt schneller" sagen, es sei // geschehen. Es ist nicht geschehen - der Bericht widerspraeche sich in // derselben Zeile. expect(befund?.title).not.toMatch(/verkürzt/); expect(befund?.title).not.toMatch(/räumt schneller/); }); it('haelt den naechsten Schritt zum engen Innenradius im Konjunktiv der Vorgabenverwaltung', () => { // Wortlaut wie in der Feldbegruendung von settings.ts: "sonst verkürzte // das Merkmal die Zwischenzeit". Der Indikativ "verkürzt" behauptete eine // Wirkung, die die Kappung gerade abwendet. const note: PlanNote = { severity: 'fehler', code: 'enger-radius-schneller-als-regelfall', message: 'Prüftext', }; const befund = validateProject(project, planMitNotiz(plan, note), DATUM).findings.find( (f) => f.rule === 'signalplan.enger-radius-schneller-als-regelfall', ); expect(befund).toBeDefined(); expect(befund?.suggestion).not.toContain('sonst verkürzt das Merkmal'); expect(befund?.suggestion).toContain('sonst verkürzte das Merkmal die Zwischenzeit'); }); });