import { readFileSync, readdirSync } from 'node:fs'; import { join } from 'node:path'; import { describe, expect, it } from 'vitest'; import { validateProject } from '@/domain/validation/engine'; import { buildSignalPlan, type PlanNote, type SignalPlan } from '@/domain/plan/signalPlan'; import { createStandardIntersectionProject } from '@/domain/model/factory'; /* * Aufraeumen in den Notiztabellen des Pruefberichts (Fassung 5.9.0). * * Zwei Befunde, die sich gegenseitig bedingen: Ein Titel ohne Erzeuger bleibt * unbemerkt stehen, weil ihn nie jemand sieht; ein Sachgebiet, das niemand * prueft, wandert beim naechsten Praefixtest zurueck. */ const DATUM = new Date('2026-01-01T00:00:00Z'); const ENGINE = 'src/domain/validation/engine.ts'; /** Alle Quelldateien des Fachkerns - dort und nur dort entstehen Notizcodes. */ function fachkernDateien(verzeichnis = 'src/domain'): string[] { const gefunden: string[] = []; for (const eintrag of readdirSync(verzeichnis, { withFileTypes: true })) { const pfad = join(verzeichnis, eintrag.name); if (eintrag.isDirectory()) gefunden.push(...fachkernDateien(pfad)); else if (eintrag.name.endsWith('.ts')) gefunden.push(pfad); } return gefunden; } /** Jeder Code, den irgendeine Rechnung des Fachkerns erzeugt. */ function erzeugteCodes(): ReadonlySet { const codes = new Set(); for (const datei of fachkernDateien()) { if (datei.replace(/\\/g, '/') === ENGINE) continue; for (const [, code] of readFileSync(datei, 'utf8').matchAll(/\bcode: '([^']+)'/g)) { if (code !== undefined) codes.add(code); } } return codes; } /** * Schluessel einer der beiden Tabellen aus engine.ts. * * Gelesen wird der Quelltext: Die Tabellen stehen in Funktionen und sind * bewusst nicht ausgefuehrt. Ein Aufruf je Code wuerde nur zeigen, dass ein * Titel herauskommt - nicht, dass ihn noch jemand braucht. */ function tabellenschluessel(einleitung: string): readonly string[] { const text = readFileSync(ENGINE, 'utf8'); const beginn = text.indexOf(einleitung); expect(beginn).toBeGreaterThan(-1); const ende = text.indexOf('\n };', beginn); expect(ende).toBeGreaterThan(beginn); const koerper = text.slice(beginn + einleitung.length, ende); const schluessel: string[] = []; for (const [, mitAnfuehrung, ohne] of koerper.matchAll( /^ {4}(?:'([^']+)'|([A-Za-z][A-Za-z0-9]*)):/gm, )) { const name = mitAnfuehrung ?? ohne; if (name !== undefined) schluessel.push(name); } return schluessel; } describe('Kein Titel und kein Rat ohne Erzeuger', () => { const codes = erzeugteCodes(); it('findet die Notizcodes des Fachkerns', () => { // Faellt die Suche leer aus, bewacht der Waechter nichts mehr. expect(codes.size).toBeGreaterThan(40); expect(codes.has('freigabezeit-rest')).toBe(true); }); it('findet die Schluessel beider Tabellen', () => { expect(tabellenschluessel('const titles: Record = {').length).toBeGreaterThan( 30, ); expect( tabellenschluessel('const suggestions: Record = {').length, ).toBeGreaterThan(30); }); it('fuehrt in der Ueberschriftentabelle nur Codes, die auch entstehen', () => { // 'umlaufzeit-zu-kurz-fuer-freigabezeiten' stand hier, obwohl ihn seit // Langem niemand mehr erzeugt: eine Ueberschrift zu einer Meldung, die es // nicht gibt, gepflegt bei jeder Aenderung mit. const ohneErzeuger = tabellenschluessel('const titles: Record = {').filter( (code) => !codes.has(code), ); expect(ohneErzeuger).toEqual([]); }); it('fuehrt in der Rattabelle nur Codes, die auch entstehen', () => { // Dieser Fall war vor der Behebung bereits gruen: Zu // 'umlaufzeit-zu-kurz-fuer-freigabezeiten' stand nur eine Ueberschrift, // nie ein Rat (nachgemessen am Stand vor der Behebung: 21 Schluessel, // keiner ohne Erzeuger). Er belegt die Behebung also nicht - er faengt // kuenftige verwaiste Raete. const ohneErzeuger = tabellenschluessel('const suggestions: Record = {').filter( (code) => !codes.has(code), ); expect(ohneErzeuger).toEqual([]); }); }); describe('Der ungueltige Saettigungsgrad steht bei der Leistungsfaehigkeit', () => { const project = createStandardIntersectionProject('Sachgebiet', DATUM); const plan = buildSignalPlan(project); function befundZu(note: PlanNote) { const mitNotiz: SignalPlan = { ...plan, notes: [note] }; return validateProject(project, mitNotiz, DATUM).findings.find( (f) => f.rule === `signalplan.${note.code}`, ); } it('fuehrt saettigungsgrad-ungueltig unter Leistungsfaehigkeit', () => { // Der Praefixtest lautete 'saettigungsgrade' (Mehrzahl) und traf die // Einzahl nicht: Die Meldung stand in der Karte "Signalzeitenplan", // waehrend die Meldung zum FEHLENDEN Saettigungsgrad daneben unter // "Leistungsfähigkeit" stand. const befund = befundZu({ severity: 'fehler', code: 'saettigungsgrad-ungueltig', message: 'Prüftext', }); expect(befund?.category).toBe('leistungsfaehigkeit'); }); it('laesst die Meldung zu fehlenden Saettigungsgraden, wo sie war', () => { const befund = befundZu({ severity: 'fehler', code: 'saettigungsgrade-fehlen', message: 'Prüftext', }); expect(befund?.category).toBe('leistungsfaehigkeit'); }); it('fuehrt die unvollstaendig erfassten Verkehrsstaerken ebenfalls dort', () => { // Derselbe Sachverhalt in schwaecherer Form (Befund 32). const befund = befundZu({ severity: 'warnung', code: 'saettigungsgrade-unvollstaendig', message: 'Prüftext', }); expect(befund?.category).toBe('leistungsfaehigkeit'); }); });