import { readFileSync } from 'node:fs'; import { describe, expect, it } from 'vitest'; import { leiteAb, uebernimmWege, wegSchluessel } from '@/domain/geometrie/ableitung'; import { createStandardIntersectionProject } from '@/domain/model/factory'; import { parseProject } from '@/domain/model/schema'; import type { Conflict, Project } from '@/domain/model/project'; import { buildSignalPlan, intergreenKey } from '@/domain/plan/signalPlan'; import { RILSA_DEFAULTS } from '@/domain/rilsa/constants'; import { validateProject } from '@/domain/validation/engine'; import { raeumbeziehungBefunde } from '@/domain/validation/rules'; import type { ValidationFinding } from '@/domain/validation/types'; /* * Befund 4, behoben in Fassung 5.11.0 - der Widerspruch zwischen der * Fahrbeziehung einer Signalgruppe und den Stroemen, die sie im Lageplan * fuehrt, wurde von keiner Regel gemeldet. * * Er steht bis heute in der mitgelieferten `beispiele/vierarmiger- * knotenpunkt.lsap`: "K Nord" ist als 'geradeaus' erfasst und fuehrt im * Lageplan die Linien "Nord geradeaus" UND "Nord rechts". Die Beziehung * sg-k-nord -> sg-k-ost traegt den Raeumweg des Rechtsabbiegers (19,52 m) mit * dem Vermerk "(6 s)"; gerechnet wird sie mit der Fahrbeziehung der Gruppe und * damit mit 10,0 m/s - 5 s. Zu kurz ist die gefaehrliche Richtung. * * Die Datei ist zugleich Prueffstueck fuer den Altdatei-Pfad und wird deshalb * NICHT geaendert. Der Bericht muss den Zustand aber benennen. */ const DATEI = 'beispiele/vierarmiger-knotenpunkt.lsap'; const DATUM = new Date('2026-01-01T00:00:00Z'); const REGEL = 'zwischenzeiten.raeumbeziehung-nicht-nachgewiesen'; /** Die mitgelieferte Altdatei, eingelesen wie beim Oeffnen. */ function beispiel(): Project { const ergebnis = parseProject(JSON.parse(readFileSync(DATEI, 'utf8')), DATUM); expect(ergebnis.issues.filter((i) => i.severity === 'fehler')).toEqual([]); return ergebnis.project; } /** Dieselbe Datei nach "Ausgewählte übernehmen" ueber alle Beziehungen. */ function nachUebernahme(basis: Project = beispiel()): Project { const ableitung = leiteAb(basis, basis.lageplan); return uebernimmWege(basis, basis.lageplan, ableitung, ableitung.wege.map(wegSchluessel), DATUM) .project; } function befunde(project: Project): ValidationFinding[] { return validateProject(project, buildSignalPlan(project), DATUM).findings.filter( (f) => f.rule === REGEL, ); } describe('N1/5: Der Bericht meldet die nicht nachgewiesene Fahrbeziehung', () => { it('trifft die vier Sammelsignalgruppen der Beispieldatei', () => { const gemeldet = befunde(beispiel()); expect(gemeldet.map((f) => (f.target?.kind === 'signalgruppe' ? f.target.id : ''))).toEqual([ 'sg-k-nord', 'sg-k-ost', 'sg-k-sued', 'sg-k-west', ]); // Zu kurz gerechnet ist die gefaehrliche Richtung: Der Rechtsabbieger // raeumt mit 7,0 m/s, gerechnet wird mit 10,0 m/s. expect(gemeldet.every((f) => f.severity === 'warnung')).toBe(true); expect(gemeldet.every((f) => f.category === 'zwischenzeiten')).toBe(true); }); it('nennt beide Fahrbeziehungen, die Zahl der Beziehungen und die Richtung', () => { const befund = befunde(beispiel())[0]!; expect(befund.title).toContain('K Nord'); expect(befund.message).toContain('rechts abbiegend'); expect(befund.message).toContain('geradeaus'); // Alle sieben Beziehungen der Gruppe sind betroffen - keine traegt die // Fahrbeziehung ihres massgebenden Stroms. expect(befund.message).toContain('7 von 7'); expect(befund.message).toContain('zu kurz'); // Eine Regel traegt Ueberschrift und naechsten Schritt selbst; sie faellt // nicht auf den Sammeltitel der Plan-Notizen zurueck. expect(befund.title).not.toBe('Hinweis aus dem Planaufbau'); expect(befund.suggestion).not.toBe('Prüfen Sie die zugrunde liegenden Eingaben.'); expect(befund.suggestion).toContain('Lageplan'); expect(befund.reference).not.toBe(''); }); it('schweigt nach einer Uebernahme aus dem Lageplan', () => { // Dort steht das Feld, wo es gebraucht wird - und wo es fehlt, ist sein // Fehlen die Entscheidung derselben Uebernahme. const project = nachUebernahme(); // Zwoelf seit 5.24.0; bis dahin acht - siehe massgebenderStrom. expect(project.conflicts.filter((c) => 'raeumbeziehung' in c)).toHaveLength(12); expect(befunde(project)).toEqual([]); }); it('schweigt ohne Lageplan', () => { // Der Standardknotenpunkt fuehrt keine Planlinien; ueber die Stroeme // seiner Signalgruppen ist nichts bekannt, und eine Meldung waere eine // Behauptung ins Blaue. expect(befunde(createStandardIntersectionProject('Ohne Lageplan', DATUM))).toEqual([]); }); it('bleibt ein Hinweis, wo die Zwischenzeit dadurch zu lang ausfaellt', () => { /* * Spiegelfall: Traegt die Sammelgruppe 'rechts', so raeumt der Plan ihre * Beziehungen mit 7,0 m/s, waehrend der gebuendelte Geradeausstrom 10,0 * m/s faehrt. Die Zwischenzeit faellt dann zu LANG aus - zu klaeren ist * das auch, gefaehrlich ist es nicht. */ const basis = beispiel(); const gedreht: Project = { ...basis, signalGroups: basis.signalGroups.map((g) => g.id === 'sg-k-nord' ? { ...g, movement: 'rechts' as const } : g, ), }; const nord = befunde(gedreht).find( (f) => f.target?.kind === 'signalgruppe' && f.target.id === 'sg-k-nord', ); expect(nord?.severity).toBe('hinweis'); expect(nord?.message).toContain('zu lang'); // Die uebrigen drei bleiben bei der Warnung. expect(befunde(gedreht).filter((f) => f.severity === 'warnung')).toHaveLength(3); }); it('sagt den Rueckfall mit dem Wortlaut, den auch die Planunterlage fuehrt', () => { // Beide Auskunftsstellen beschreiben denselben Sachverhalt; der // Erlaeuterungsabsatz der Unterlage nennt ihn seit Fassung 5.11.0 ebenfalls. expect(befunde(beispiel())[0]?.message).toContain('mit der Fahrbeziehung der Signalgruppe'); }); it('zaehlt eine von Hand vorgegebene Zwischenzeit nicht mit', () => { // Wo die Zwischenzeit von Hand steht, geht keine Fahrbeziehung in eine // Zahl ein - dort ist nichts zu klaeren. const basis = beispiel(); const project: Project = { ...basis, conflicts: basis.conflicts.map((c) => c.fromId === 'sg-k-nord' && c.toId === 'sg-k-ost' ? { ...c, manualIntergreen: 9 } : c, ), }; const nord = befunde(project).find( (f) => f.target?.kind === 'signalgruppe' && f.target.id === 'sg-k-nord', ); expect(nord?.message).toContain('6 von 7'); }); }); /* * NACHTRAG (Fassung 5.11.0): Die Regel loeste die Raeumgeschwindigkeit selbst * auf (`clearingSpeedOverride ?? clearingSpeedFor`) und liess damit JEDE * Handeingabe als massgeblich gelten - auch eine, die der Fachkern verwirft. * Sie haengt nicht an der Fahrbeziehung; der Regelwert, auf den zurueckgefallen * wird, haengt sehr wohl an ihr. Wo die Regel schwieg, raeumte der Plan weiter * mit 10,0 m/s als Gruppe und mit 7,0 m/s als Abbieger. Schweigen ist die * gefaehrliche Richtung. */ describe('N1/6: Die Regel prueft die Handeingabe wie der Fachkern', () => { /** Die Beispieldatei mit einem Zusatz an allen sieben Beziehungen von "K Nord". */ function mitZusatz(zusatz: Partial, basis: Project = beispiel()): Project { return { ...basis, conflicts: basis.conflicts.map((c) => (c.fromId === 'sg-k-nord' ? { ...c, ...zusatz } : c)), }; } function nordBefund(project: Project): ValidationFinding | undefined { return befunde(project).find( (f) => f.target?.kind === 'signalgruppe' && f.target.id === 'sg-k-nord', ); } it('meldet weiter, wo der Fachkern die eingetragene Raeumgeschwindigkeit verwirft', () => { const project = mitZusatz({ clearingSpeedOverride: 0 }); // Der Plan setzt die 0 nicht an: Er faellt auf den Regelwert der // Fahrbeziehung zurueck und raeumt die Gruppe mit 10,0 m/s. expect( buildSignalPlan(project).intergreens.get(intergreenKey('sg-k-nord', 'sg-k-ost'))?.calculation ?.clearingSpeed, ).toBe(RILSA_DEFAULTS.clearingSpeed.kfzStraight); const nord = nordBefund(project); expect(nord?.severity).toBe('warnung'); expect(nord?.message).toContain('7 von 7'); }); it('schweigt, wo eine gueltige Handeingabe die Fahrbeziehung gegenstandslos macht', () => { // Gegenprobe zum Fall darueber: 8,0 m/s setzt der Fachkern an, und zwar // fuer jede Fahrbeziehung dieselben. Dann ist nichts zu klaeren. expect(nordBefund(mitZusatz({ clearingSpeedOverride: 8 }))).toBeUndefined(); }); it('misst am Regelwert und damit an der harten Obergrenze der Verkehrsart', () => { /* * Ueber der Obergrenze von 10,0 m/s rechnet der Plan geradeaus wie * abbiegend mit dem Deckel - die Fahrbeziehung faellt aus der Rechnung. * `clearingSpeedFor` kennt ihn nicht und meldete hier 14,0 gegen 12,0 m/s: * eine Warnung "zu kurz gerechnet" ueber zwei Zahlen, die im Plan * dieselben sind. */ const defaults = { ...RILSA_DEFAULTS, clearingSpeed: { ...RILSA_DEFAULTS.clearingSpeed, kfzStraight: 14, kfzTurning: 12 }, }; expect(raeumbeziehungBefunde(beispiel(), defaults)).toEqual([]); // Ohne die Kappung waeren es die vier Sammelsignalgruppen - der Fall // prueft nur etwas, solange die Regel ohne Kappung anschlagen wuerde. expect(befunde(beispiel())).toHaveLength(4); }); it('meldet weiter, wo der Fachkern die eingetragene Ueberfahrzeit verwirft', () => { /* * Beim Ansatz "feste Ueberfahrzeit" haengt auch die Ueberfahrzeit an der * Fahrbeziehung (geradeaus laenger als abbiegend). Die eingetragene * Raeumgeschwindigkeit nimmt vr aus dem Vergleich; bleibt eine * Ueberfahrzeit von -1 s, die der Fachkern verwirft. Ungeprueft * uebernommen sah die Regel zwei gleiche Werte und schwieg. */ const basis = beispiel(); const festerAnsatz: Project = { ...basis, settings: { ...basis.settings, ueberfahrzeitAnsatz: 'fest' }, }; const project = mitZusatz({ clearingSpeedOverride: 8, crossingTimeOverride: -1 }, festerAnsatz); const nord = nordBefund(project); expect(nord?.message).toContain('7 von 7'); // Der abbiegende Strom ueberfaehrt kuerzer: gerechnet wird zu LANG. expect(nord?.severity).toBe('hinweis'); }); it('behaelt den Regelwert als Untergrenze der Kfz-Ueberfahrzeit', () => { /* * 2,5 s liegen zwischen den beiden festen Werten. Der Fachkern setzt sie * beim Abbieger an (2 s Regelwert) und verwirft sie geradeaus (3 s * Regelwert) - die Fahrbeziehung entscheidet also weiterhin, obwohl an der * Beziehung eine gueltige Ueberfahrzeit steht. */ const basis = beispiel(); const festerAnsatz: Project = { ...basis, settings: { ...basis.settings, ueberfahrzeitAnsatz: 'fest' }, }; const project = mitZusatz( { clearingSpeedOverride: 8, crossingTimeOverride: 2.5 }, festerAnsatz, ); // Was der Plan an der Gruppe rechnet: die 3 s des Geradeausstroms. expect( buildSignalPlan(project).intergreens.get(intergreenKey('sg-k-nord', 'sg-k-ost'))?.calculation ?.crossingTime, ).toBe(RILSA_DEFAULTS.crossingTime.kfzGeradeaus); expect(nordBefund(project)?.message).toContain('7 von 7'); }); });