import { describe, expect, it } from 'vitest'; import { computeIntergreen } from '@/domain/rilsa/intergreen'; import { RILSA_DEFAULTS, SAFETY_FLOORS } from '@/domain/rilsa/constants'; import { buildSignalPlan, type PlanNote } from '@/domain/plan/signalPlan'; import { validateProject } from '@/domain/validation/engine'; import { createConflict, createEmptyProject, createPhase, createSignalGroup, } from '@/domain/model/factory'; import type { Project, SignalGroup } from '@/domain/model/project'; import type { ClearingStream, EnteringStream, TrafficMode, VehicleClass, } from '@/domain/rilsa/types'; /* * Laengenzuschlag unter der harten Schranke (Fassung 5.9.0). * * Die Behebung von Befund 4 hat die Auswahlliste der Oberflaeche geschlossen: An * einem Kfz- oder OePNV-Strom ist keine Fahrzeugart mehr waehlbar, deren * Laengenzuschlag unter SAFETY_FLOORS.minVehicleLengthKfz liegt. Der Fachkern * zog nicht nach - `resolveVehicleLength` nahm den hinterlegten Wert, auch * wenn er 0 m betrug, und die Zwischenzeit fiel um rund eine Sekunde kuerzer * aus: die gefaehrliche Richtung, ohne eine einzige Meldung. Die Notiz * `fahrzeugart-unbekannt` greift nur bei einer UNBEKANNTEN Art; 0 m ist ein * hinterlegter Wert. */ const CODE = 'fahrzeugart-laengenzuschlag-zu-kurz'; const DATUM = new Date('2026-01-01T00:00:00Z'); const raeumend = ( mode: TrafficMode, vehicleClass: VehicleClass, overrides: Partial = {}, ): ClearingStream => ({ mode, movement: 'geradeaus', clearingDistance: 22, vehicleClass, vZul: 50, ...overrides, }); const einfahrend: EnteringStream = { mode: 'kfz', movement: 'geradeaus', enteringDistance: 8, }; /** Zwei Gruppen in zwei Phasen; die raeumende Gruppe laesst sich anpassen. */ function zweiGruppen(raeumendeGruppe: Partial): Project { const basis = createEmptyProject('Längenzuschlag', DATUM); const a: SignalGroup = { ...createSignalGroup({ name: 'K1', mode: 'kfz', index: 0 }), ...raeumendeGruppe, }; const b = createSignalGroup({ name: 'K2', mode: 'kfz', index: 1 }); const phase1 = createPhase('Phase 1', [a.id]); const phase2 = createPhase('Phase 2', [b.id]); return { ...basis, signalGroups: [a, b], conflicts: [ { ...createConflict(a.id, b.id), clearingDistance: 22, enteringDistance: 8 }, { ...createConflict(b.id, a.id), clearingDistance: 22, enteringDistance: 8 }, ], phases: [phase1, phase2], program: { ...basis.program, phaseOrder: [phase1.id, phase2.id] }, }; } function notizen(project: Project, code: string): PlanNote[] { return buildSignalPlan(project).notes.filter((n) => n.code === code); } describe('Zu kurzer Laengenzuschlag am Kfz-Strom wird gemeldet und ersetzt', () => { it('setzt bei Fahrzeugart "keine" die RiLSA-Rechenlaenge an statt 0 m', () => { const result = computeIntergreen(raeumend('kfz', 'keine'), einfahrend); expect(RILSA_DEFAULTS.vehicleLength.keine).toBe(0); expect(result.vehicleLength).toBe(SAFETY_FLOORS.minVehicleLengthKfz); expect(result.clearingPath).toBe(28); }); it('meldet die Ersetzung als Fehler mit eigenem Notizcode', () => { const result = computeIntergreen(raeumend('kfz', 'keine'), einfahrend); const notiz = result.notes.find((n) => n.code === CODE); expect(notiz).toBeDefined(); expect(notiz!.severity).toBe('fehler'); expect(notiz!.message).toContain('6 m'); // Die Notiz nennt die Richtung: Der kuerzere Zuschlag verkuerzt. expect(notiz!.message).toContain('verkürz'); }); it('rechnet damit dieselbe Zwischenzeit wie ein Pkw - nicht eine Sekunde kuerzer', () => { // Altstand: 0 m -> sr = 22 m -> tr = 2,2 s -> 3 + 2,2 - 0,72 = 4,48 s -> 5 s. // Jetzt: 6 m -> sr = 28 m -> tr = 2,8 s -> 5,08 s -> 6 s, wie beim Pkw. const pkw = computeIntergreen(raeumend('kfz', 'pkw'), einfahrend); const keine = computeIntergreen(raeumend('kfz', 'keine'), einfahrend); expect(pkw.intergreen).toBe(6); expect(keine.intergreen).toBe(pkw.intergreen); expect(keine.clearingTime).toBeCloseTo(pkw.clearingTime, 6); }); it('gilt ebenso am OePNV-Strom', () => { const result = computeIntergreen(raeumend('oepnv', 'rad'), einfahrend); expect(RILSA_DEFAULTS.vehicleLength.rad).toBeLessThan(SAFETY_FLOORS.minVehicleLengthKfz); expect(result.vehicleLength).toBe(SAFETY_FLOORS.minVehicleLengthKfz); expect(result.notes.some((n) => n.code === CODE)).toBe(true); }); it('laesst Fussgaenger- und Radstroeme unberuehrt - dort ist ein kurzer Zuschlag zulaessig', () => { for (const mode of ['fuss', 'rad'] as const) { const result = computeIntergreen( raeumend(mode, 'keine', { movement: mode === 'fuss' ? 'querung' : 'geradeaus' }), einfahrend, ); expect(result.vehicleLength, mode).toBe(0); expect( result.notes.some((n) => n.code === CODE), mode, ).toBe(false); } }); it('meldet die Arten mit ausreichendem Zuschlag nicht', () => { for (const art of ['pkw', 'lkw', 'bus', 'lastzug', 'strassenbahn'] as const) { const result = computeIntergreen(raeumend('kfz', art), einfahrend); expect( result.notes.some((n) => n.code === CODE), art, ).toBe(false); expect(result.vehicleLength, art).toBe(RILSA_DEFAULTS.vehicleLength[art]); } }); it('haelt die Notiz von der unbekannten Fahrzeugart getrennt', () => { const unbekannt = computeIntergreen(raeumend('kfz', 'traktor' as VehicleClass), einfahrend); expect(unbekannt.notes.some((n) => n.code === 'fahrzeugart-unbekannt')).toBe(true); expect(unbekannt.notes.some((n) => n.code === CODE)).toBe(false); }); it('traegt die Meldung in den Plan und sperrt als Fehler den Export', () => { const projekt = zweiGruppen({ vehicleClass: 'keine' }); const [notiz] = notizen(projekt, CODE); expect(notiz).toBeDefined(); expect(notiz!.severity).toBe('fehler'); expect(notiz!.konfliktId).toBeDefined(); const bericht = validateProject(projekt, undefined, DATUM); const befund = bericht.findings.find((f) => f.rule === `signalplan.${CODE}`); expect(befund).toBeDefined(); expect(befund!.severity).toBe('fehler'); // Der Praefix `fahrzeugart` trifft in mapNoteCategory bereits das // Sachgebiet; Ueberschrift und naechster Schritt traegt engine.ts nach. expect(befund!.category).toBe('zwischenzeiten'); expect(bericht.exportBlocked).toBe(true); }); });