import { describe, expect, it } from 'vitest'; import { buildSignalPlan } from '@/domain/plan/signalPlan'; import { validateProject } from '@/domain/validation/engine'; import { abbiegerfaktorBefunde } from '@/domain/validation/rules'; import { ANLAGENART_GRENZEN } from '@/domain/rilsa/constants'; import { createConflict, createEmptyProject, createPhase, createSignalGroup, createStandardIntersectionProject, } from '@/domain/model/factory'; import type { Anlagenart, Project, SignalGroup } from '@/domain/model/project'; /* * Fundstellen und Meldungstexte. * * Befund 3: Die Fundstelle der Plan-Notizen kam ueber einen Praefixtreffer * (`code.startsWith('umlaufzeit')`). Damit trugen Meldungen die Herkunft der * Umlaufzeitschranke, deren genannter Zahlenwert gerade NICHT diese Schranke * ist: die Mindestumlaufzeit (Summe aus Mindestfreigabe- und Zwischenzeiten), * die Uebersaettigung (Y gegen die Grenze des Verfahrens) und die unbrauchbare * Eingabe. An einer Arbeitsstelle stand an der Uebersaettigung - einem Fehler * mit Exportsperre - "Schwelle dieses Programms ...; Zahlenwert abzugleichen": * Der Pruefer las, das Kriterium sei eine mit der Behoerde abzustimmende * Eigenschwelle. * * Befund B-7 und Befund 4: Der Hinweis zum Abbiegerfaktor stand bei genau * einem Partner im Plural und behauptete "Kraftfahrzeugstroeme anderer * Zufahrten", ohne zu sagen, dass ueber den Gegenverkehr damit nichts * festgestellt ist. */ const DATUM = new Date('2026-01-01T00:00:00Z'); /** Der Musterknotenpunkt mit Verkehrsstaerken weit ueber der Kapazitaet. */ function uebersaettigt(anlagenart: Anlagenart): Project { const roh = createStandardIntersectionProject('Uebersaettigt', DATUM); return { ...roh, anlagenart, demands: roh.signalGroups .filter((g) => g.mode === 'kfz') .map((g) => ({ signalGroupId: g.id, volume: 1800, heavyVehicleShare: 0.1 })), }; } /** Feste Umlaufzeit ueber der Schranke der Anlagenart. */ function festeUmlaufzeit(anlagenart: Anlagenart, wert: number | null): Project { const roh = createStandardIntersectionProject('Umlaufzeit', DATUM); return { ...roh, anlagenart, program: { ...roh.program, method: 'manuell', manualCycleTime: wert }, }; } function fundstelle(project: Project, regel: string): string { const plan = buildSignalPlan(project); const bericht = validateProject(project, plan, DATUM); const treffer = bericht.findings.find((f) => f.rule === regel); expect(treffer, `${regel} muss im Bericht stehen`).toBeDefined(); return treffer!.reference; } describe('Befund 3 - jede Meldung nennt die Herkunft IHRER Zahl', () => { it('gibt der Uebersaettigung das Kriterium des Verfahrens, nicht die Umlaufzeitschranke', () => { // Der nachgemessene Fall der Pruefung: einstreifige Fuehrung, 1800 Kfz/h. const einstreifig = fundstelle(uebersaettigt('einstreifig'), 'signalplan.uebersaettigt'); expect(einstreifig).not.toContain('Schwelle dieses Programms'); expect(einstreifig).not.toContain('Zahlenwert abzugleichen'); // Der Musterknotenpunkt rechnet nach Webster; dort ist Y < 1 die // Voraussetzung der Formel und nicht eine Schranke der Anlagenart. expect(einstreifig).toContain('Webster 1958'); expect(einstreifig).toContain('Y < 1 als Voraussetzung der Formel'); const knoten = fundstelle(uebersaettigt('knotenpunkt'), 'signalplan.uebersaettigt'); expect(knoten).toBe(einstreifig); // Vor der Fassung 5.4.0 war es an beiden Anlagenarten die Umlaufzeitzeile. expect(knoten).not.toBe('RiLSA 2015 - Umlaufzeit'); }); it('nennt bei der Mindestumlaufzeit die Groessen, aus denen sie folgt', () => { const erwartet = 'RiLSA 2015 - Mindestfreigabezeiten und Zwischenzeiten (daraus folgt die Mindestumlaufzeit)'; for (const art of ['knotenpunkt', 'einstreifig'] as const) { // Feste Umlaufzeit gewaehlt, aber keine eingetragen: Angesetzt wird die // Mindestumlaufzeit, und genau die nennt die Meldung. expect(fundstelle(festeUmlaufzeit(art, null), 'signalplan.umlaufzeit-fehlt'), art).toBe( erwartet, ); } }); it('behaelt die Herkunft der Schranke dort, wo die Zahl auch die Schranke ist', () => { // Waechter fuer Befund B9: Die Meldung "Umlaufzeit ueberschreitet den // Hoechstwert" nennt die Schranke der Anlagenart - sie MUSS deren Herkunft // tragen. expect(ANLAGENART_GRENZEN.knotenpunkt.cycleTime.max).toBe(120); expect(fundstelle(festeUmlaufzeit('knotenpunkt', 200), 'signalplan.umlaufzeit-zu-lang')).toBe( 'RiLSA 2015 - Umlaufzeit', ); expect(ANLAGENART_GRENZEN.einstreifig.cycleTime.max).toBe(300); const engstelle = fundstelle( festeUmlaufzeit('einstreifig', 400), 'signalplan.umlaufzeit-zu-lang', ); expect(engstelle).toContain('Schwelle dieses Programms'); expect(engstelle).toContain('kein RSA-Wert'); }); it('nennt beim Regelbereich weiterhin dessen eigene Herkunft', () => { // Waechter fuer Befund B8/B9: Der empfohlene Bereich hat eine eigene // Herkunft und darf sie ueber die Umstellung nicht verlieren. const knoten = fundstelle( festeUmlaufzeit('knotenpunkt', 110), 'signalplan.umlaufzeit-ausserhalb-regelbereich', ); expect(knoten).toBe(ANLAGENART_GRENZEN.knotenpunkt.herkunft.regelbereich.text); }); /* * UMGESCHRIEBEN - der Fall lief nie durch seinen Rumpf. * * Er baute den Musterknotenpunkt mit `manualCycleTime: Number.NaN`, lief * ueber `bericht.findings` und uebersprang mit * `if (finding.rule !== 'signalplan.umlaufzeit-ungueltig') continue;` jeden * Befund. Diese Regel entsteht dort aber gar nicht: resolveCycleTime * (plan/signalPlan.ts) ersetzt jede unbrauchbare Handvorgabe durch die * Mindestumlaufzeit und meldet 'umlaufzeit-fehlt'. GEMESSEN: Der Bericht * jenes Projekts fuehrt 12 Befunde, keinen mit dieser Regel; ein * `expect.fail()` als erste Zeile des Rumpfes liess alle neun Faelle der * Datei gruen bleiben. Der Fall war seit jeher gruen und bewachte nichts. * * 'umlaufzeit-ungueltig' entsteht, wo die ANGESETZTE Umlaufzeit wirklich * kein brauchbarer Wert ist: distributeGreenTimes (rilsa/greenSplit.ts) * meldet sie bei einer Umlaufzeit <= 0 s. Dahin fuehrt eine Phase ohne * Signalgruppen - ihre Mindestfreigabezeit ist 0 s, es gibt keine * Uebergaenge, und damit ist auch die Mindestumlaufzeit 0 s, die * resolveCycleTime fuer die unbrauchbare Handvorgabe ansetzt. * * Die Fundstelle des Programms ist in Ordnung: referenceFor * (validation/engine.ts) faengt den Kode vor jedem Rueckfall ab und * antwortet mit "Kein Regelwerksbezug ...". Der Fall misst das jetzt. */ function umlaufzeitNull(): Project { const basis = createEmptyProject('Ungueltig', DATUM, 'knotenpunkt'); const leer = createPhase('Phase 1', []); return { ...basis, phases: [leer], program: { ...basis.program, phaseOrder: [leer.id], method: 'manuell', manualCycleTime: Number.NaN, }, }; } it('erfindet fuer eine unbrauchbare Umlaufzeit keine Fundstelle', () => { // Kein Regelwerk sagt etwas ueber einen ungueltigen Zahlenwert. Eine // erfundene Fundstelle waere hier schlimmer als keine. const kaputt = umlaufzeitNull(); // Beleg, dass der Kode ueberhaupt entstehen kann - sonst prueft der Fall // wieder nichts. expect(buildSignalPlan(kaputt).cycleTime, 'angesetzte Umlaufzeit').toBe(0); const gefunden = fundstelle(kaputt, 'signalplan.umlaufzeit-ungueltig'); /* * Der Wortlaut steht exakt. Erlaeuterung dazu, kein zweiter Pruefschritt: * Der Rueckfall von referenceFor (validation/engine.ts) lautet * "RiLSA 2015" - genau der waere hier die erfundene Fundstelle, und gegen * ihn haelt bereits dieses `toBe`. * * ENTFERNT (Fassung 5.16.0): Darunter stand eine Schleife ueber sieben * Regelwerksnamen mit `not.toContain`. Sie lief zwar - anders als die * beiden Schleifen, die mit dem Umschreiben entfielen -, konnte aber nie * die Ursache eines roten Laufs sein: Haelt das `toBe`, ist `gefunden` * genau diese eine Zeichenkette und enthaelt keinen der sieben Namen; haelt * es nicht, bricht der Fall vorher ab. GEMESSEN an der Mutation * "referenceFor gibt fuer 'umlaufzeit-ungueltig' den Rueckfall 'RiLSA * 2015 - Umlaufzeit'": Vitest meldete ausschliesslich das `toBe`, die * Schleife kam nicht zum Zug. Sie hatte damit keine Unterscheidungskraft, * las sich neben ihrem Kommentar aber wie eine eigenstaendige Pruefung - * dieselbe Gattung Irrefuehrung, gegen die dieser Fall geschrieben ist. Das * `toBe` zu einem `toContain` abzuschwaechen, nur damit die Schleife etwas * zu tun bekommt, waere ein Rueckschritt gewesen. */ expect(gefunden).toBe('Kein Regelwerksbezug - die Umlaufzeit ist kein gültiger Zahlenwert'); }); it('setzt fuer eine unbrauchbare Handvorgabe die Mindestumlaufzeit an, nicht den Ungueltig-Kode', () => { /* * Wache gegen die Rueckkehr der Blindstelle: Solange dieser Fall gilt, * fuehrt `manualCycleTime: Number.NaN` am Musterknotenpunkt zu * 'umlaufzeit-fehlt' - und ein Fall, der dort 'umlaufzeit-ungueltig' * sucht, laeuft leer. Er haelt zugleich fest, was das Programm mit einer * unbrauchbaren Zahl tut: Es rechnet nicht mit ihr weiter. */ const roh = createStandardIntersectionProject('Ungueltig', DATUM); const kaputt: Project = { ...roh, program: { ...roh.program, method: 'manuell', manualCycleTime: Number.NaN }, }; const bericht = validateProject(kaputt, buildSignalPlan(kaputt), DATUM); const regeln = bericht.findings.map((f) => f.rule); expect(regeln).toContain('signalplan.umlaufzeit-fehlt'); expect(regeln).not.toContain('signalplan.umlaufzeit-ungueltig'); }); it('laesst keine Fundstelle leer', () => { for (const art of ['knotenpunkt', 'fussgaengerschutzanlage', 'einstreifig'] as const) { const project = uebersaettigt(art); const bericht = validateProject(project, buildSignalPlan(project), DATUM); for (const finding of bericht.findings) { expect(finding.reference, finding.rule).not.toBe(''); } } }); }); /** Linksabbieger plus `anzahl` Kfz-Gruppen anderer Arme in derselben Phase. */ function linksabbiegerMitPartnern(anzahl: number): Project { const basis = createEmptyProject('Abbieger', DATUM, 'knotenpunkt'); const links: SignalGroup = { ...createSignalGroup({ name: 'K1', mode: 'kfz', index: 0 }), movement: 'links', armId: 'nord', }; const partner = Array.from({ length: anzahl }, (_, i) => ({ ...createSignalGroup({ name: `P${i + 1}`, mode: 'kfz', index: i + 1 }), movement: 'rechts' as const, armId: `arm-${i}`, })); const phase = createPhase('Phase 1', [links.id, ...partner.map((p) => p.id)]); return { ...basis, signalGroups: [links, ...partner], conflicts: partner.map((p) => createConflict(links.id, p.id)), phases: [phase], program: { ...basis.program, phaseOrder: [phase.id] }, demands: [{ signalGroupId: links.id, volume: 400, heavyVehicleShare: 0 }], }; } describe('Befund B-7 und 4 - Abbiegerfaktor: Numerus und Trefferschaerfe', () => { it('setzt den Singular, wenn es genau einen Partner gibt', () => { const befunde = abbiegerfaktorBefunde(linksabbiegerMitPartnern(1)); expect(befunde).toHaveLength(1); expect(befunde[0]!.message).toContain('ein Kraftfahrzeugstrom einer anderen Zufahrt'); expect(befunde[0]!.message).not.toContain('Kraftfahrzeugströme anderer Zufahrten'); }); it('setzt den Plural erst ab zwei Partnern', () => { const befunde = abbiegerfaktorBefunde(linksabbiegerMitPartnern(2)); expect(befunde).toHaveLength(1); expect(befunde[0]!.message).toContain('Kraftfahrzeugströme anderer Zufahrten'); }); it('behauptet keinen Gegenverkehr, den die Regel nicht geprueft hat', () => { // Ein Rechtseinbieger aus einem anderen Arm ist fuer einen Linksabbieger // kein Gegenverkehr. Die Regel meldet ihn bewusst weiterhin - der Text // sagt jetzt aber, was er nicht weiss. const befunde = abbiegerfaktorBefunde(linksabbiegerMitPartnern(1)); expect(befunde[0]!.message).toContain('ob darunter der Gegenverkehr ist, zeigt der Lageplan'); }); });