import { describe, expect, it } from 'vitest'; import { createArm, createConflict, createEmptyProject, createPhase, createSignalGroup, } from '@/domain/model/factory'; import { buildSignalPlan } from '@/domain/plan/signalPlan'; import { validateProject } from '@/domain/validation/engine'; import type { Conflict, Project } from '@/domain/model/project'; import type { TrafficMode } from '@/domain/rilsa/types'; /** * Zwei Querungen derselben Verkehrsart, die zugleich Freigabe zeigen. * * ANLASS (die zweistufige Fussgaengerfurt). Das Programm bildet die Furt mit * Mittelinsel nur durchgehend ab - nachbauen laesst sie sich trotzdem, indem * der Bearbeiter je Abschnitt eine eigene Gruppe anlegt und ihr den halben * Raeumweg gibt. Stehen beide Abschnitte dann in DERSELBEN Phase, ist das * gerade keine zweistufige Signalisierung: Der Querende darf nach dem * Signalbild in einem Zug durchgehen, waehrend Raeumweg, Raeumzeit und * Furtregel nur den halben Weg abdecken. Zwei Fussgaengergruppen sind einander * nie feindlich, und deshalb sah `signalplan.feindliche-freigabe` das nicht. * * WARUM DIESE DATEI DIE ZWEITE IHRER ART IST. Die Fassung 5.38.0 hatte die * Regel schon, gruppierte aber ueber `armId` und schwieg, wo das Feld leer war. * Das Feld ist optional, und beide Wege, auf denen ein Bearbeiter eine * ZUSAETZLICHE Gruppe anlegt, lassen es leer - der Knopf "+ Fussgaenger" ueber * `addSignalGroup` und die Uebernahme aus dem Lageplan. Die Regel schwieg damit * genau in dem Fall, fuer den sie geschrieben wurde, und ein Testfall * ("schweigt bei Fussgaengergruppen ohne Arm") fror dieses Schweigen ein. Der * erste Fall dieser Datei ist deshalb der wichtigste. */ const JETZT = new Date('2026-09-11T00:00:00Z'); const REGEL = 'signalplan.querungen-gleichzeitig'; interface Gruppenangabe { readonly name: string; readonly mode: TrafficMode; /** Name der Zufahrt; null, wenn keine erfasst ist. */ readonly arm: string | null; } interface Aufbau { readonly arme: readonly string[]; readonly gruppen: readonly Gruppenangabe[]; /** Je Phase die Namen der freigegebenen Gruppen. */ readonly phasen: readonly (readonly string[])[]; /** Namenspaare, die als feindlich mit Raeumweg erfasst werden. */ readonly feindlich?: readonly (readonly [string, string])[]; } /** * Ein Kfz-Strom, dazu die Querungen des Aufbaus. Jede Querung steht mit dem * Kfz-Strom in Konflikt, damit ueberhaupt ein schaltbarer Plan entsteht. */ function baue(aufbau: Aufbau): Project { const leer = createEmptyProject('Querungen', JETZT, 'knotenpunkt'); const arme = aufbau.arme.map((name) => createArm(name, name, 2, 50)); const armId = (name: string | null) => name === null ? null : (arme.find((a) => a.name === name)?.id ?? null); const kfz = createSignalGroup({ name: 'K1', mode: 'kfz', armId: arme[0]?.id ?? null, index: 0 }); const weitere = aufbau.gruppen.map((g, i) => createSignalGroup({ name: g.name, mode: g.mode, armId: armId(g.arm), index: 1 + i }), ); const alle = [kfz, ...weitere]; /** Die erste Gruppe dieses Namens - Namen sind hier absichtlich nicht eindeutig. */ const id = (name: string) => alle.find((g) => g.name === name)!.id; const conflicts: Conflict[] = []; for (const g of weitere) { conflicts.push( { ...createConflict(g.id, kfz.id), clearingDistance: 5.75, enteringDistance: 0 }, { ...createConflict(kfz.id, g.id), clearingDistance: 20, enteringDistance: 5 }, ); } // Beide Richtungen mit Weg, damit daraus wirklich eine Zwischenzeit wird. for (const [a, b] of aufbau.feindlich ?? []) { conflicts.push( { ...createConflict(id(a), id(b)), clearingDistance: 5.75, enteringDistance: 0 }, { ...createConflict(id(b), id(a)), clearingDistance: 5.75, enteringDistance: 0 }, ); } const phasen = [ createPhase('Kfz', [kfz.id]), ...aufbau.phasen.map((namen, i) => createPhase( `Querung ${i + 1}`, // Bei gleichnamigen Gruppen die Reihenfolge der Erfassung nehmen. namen.map((n, k) => weitere.filter((g) => g.name === n)[k === 0 ? 0 : k]?.id ?? id(n)), ), ), ]; return { ...leer, intersection: { ...leer.intersection, arms: arme }, signalGroups: alle, conflicts, phases: phasen, program: { ...leer.program, phaseOrder: phasen.map((p) => p.id) }, }; } function befunde(project: Project) { return validateProject(project, buildSignalPlan(project), JETZT).findings.filter( (f) => f.rule === REGEL, ); } /** Zwei Abschnitte einer Furt, wie der Knopf "+ Fußgänger" sie anlegt: ohne Zufahrt. */ const OHNE_ZUFAHRT: Aufbau = { arme: ['Nord'], gruppen: [ { name: 'F1', mode: 'fuss', arm: null }, { name: 'F2', mode: 'fuss', arm: null }, ], phasen: [['F1', 'F2']], }; describe('Der Fall, den 5.38.0 nicht sah: zwei Querungen ohne erfasste Zufahrt', () => { it('meldet die gemeinsame Freigabe auch ohne jede Zufahrt', () => { // Der Knopf "+ Fußgänger" legt keine Zufahrt an. Wer die zweistufige Furt // von Hand nachbaut, hat deshalb im Regelfall zwei Gruppen ohne Zufahrt. const gefunden = befunde(baue(OHNE_ZUFAHRT)); expect(gefunden).toHaveLength(1); expect(gefunden[0]?.message).toContain('„F1“'); expect(gefunden[0]?.message).toContain('„F2“'); }); it('sagt, dass die Zufahrt fehlt und deshalb nichts eingegrenzt werden kann', () => { expect(befunde(baue(OHNE_ZUFAHRT))[0]?.message).toContain('Zufahrt nicht erfasst'); }); it('meldet auch, wenn nur eine der beiden eine Zufahrt trägt', () => { expect( befunde( baue({ ...OHNE_ZUFAHRT, gruppen: [ { name: 'F1', mode: 'fuss', arm: 'Nord' }, { name: 'F2', mode: 'fuss', arm: null }, ], }), ), ).toHaveLength(1); }); it('meldet auch bei zwei gleichnamigen Gruppen', () => { // Der Befund darf nicht an einer Menge von NAMEN hängen: Wer den zweiten // Abschnitt kopiert und den Namen stehen lässt, hat zweimal "F1". expect( befunde( baue({ arme: ['Nord'], gruppen: [ { name: 'F1', mode: 'fuss', arm: null }, { name: 'F1', mode: 'fuss', arm: null }, ], phasen: [['F1', 'F1']], }), ), ).toHaveLength(1); }); }); describe('Die Zufahrt grenzt ein, sie ist keine Bedingung', () => { it('meldet zwei Querungen derselben Zufahrt und nennt sie', () => { const gefunden = befunde( baue({ arme: ['Nord'], gruppen: [ { name: 'F1', mode: 'fuss', arm: 'Nord' }, { name: 'F2', mode: 'fuss', arm: 'Nord' }, ], phasen: [['F1', 'F2']], }), ); expect(gefunden).toHaveLength(1); expect(gefunden[0]?.message).toContain('Zufahrt „Nord“'); }); it('schweigt bei zwei erfassten, verschiedenen Zufahrten', () => { // Nur das schließt dieselbe Querung aus. expect( befunde( baue({ arme: ['Nord', 'Süd'], gruppen: [ { name: 'F1', mode: 'fuss', arm: 'Nord' }, { name: 'F2', mode: 'fuss', arm: 'Süd' }, ], phasen: [['F1', 'F2']], }), ), ).toHaveLength(0); }); }); describe('Wo die Regel sonst schweigt', () => { it('schweigt bei getrennten Phasen', () => { expect(befunde(baue({ ...OHNE_ZUFAHRT, phasen: [['F1'], ['F2']] }))).toHaveLength(0); }); it('schweigt bei einer Furt neben einer Radquerung', () => { // Furt und Radquerung nebeneinander sind der Regelfall, kein geteiltes Gebilde. expect( befunde( baue({ arme: ['Nord'], gruppen: [ { name: 'F1', mode: 'fuss', arm: null }, { name: 'R1', mode: 'rad', arm: null }, ], phasen: [['F1', 'R1']], }), ), ).toHaveLength(0); }); it('schweigt bei einem als feindlich erfassten Paar', () => { // Dann meldet signalplan.feindliche-freigabe einen FEHLER. Stünde daneben // dieser Befund, sagte der Bericht in zwei Zeilen Gegensätzliches. const project = baue({ ...OHNE_ZUFAHRT, feindlich: [['F1', 'F2']] }); expect(befunde(project)).toHaveLength(0); // Welche der beiden Fehlerregeln zuerst greift, entscheidet die // Reihenfolge im Pruefer: Ein feindliches Paar in EINER Phase meldet // phasen.feindlich-in-phase, sonst signalplan.feindliche-freigabe. Beide // sperren die Ausgabe; welche es ist, gehoert nicht hierher. const bericht = validateProject(project, buildSignalPlan(project), JETZT); expect( bericht.findings.some( (f) => f.rule === 'signalplan.feindliche-freigabe' || f.rule === 'phasen.feindlich-in-phase', ), ).toBe(true); expect(bericht.exportBlocked).toBe(true); }); it('schweigt bei einer einzigen Querung', () => { expect( befunde( baue({ arme: ['Nord'], gruppen: [{ name: 'F1', mode: 'fuss', arm: null }], phasen: [['F1']], }), ), ).toHaveLength(0); }); }); describe('Die geteilte Radquerung ist derselbe Sachverhalt', () => { it('meldet zwei zugleich freigegebene Radquerungen', () => { const gefunden = befunde( baue({ arme: ['Nord'], gruppen: [ { name: 'R1', mode: 'rad', arm: null }, { name: 'R2', mode: 'rad', arm: null }, ], phasen: [['R1', 'R2']], }), ); expect(gefunden).toHaveLength(1); expect(gefunden[0]?.message).toContain('Radquerungen'); }); }); describe('Ein Befund je Paar, und jeder nennt nur sein Paar', () => { /** F1 und F2 zusammen, F3 allein in einer eigenen Phase. */ const DREI: Aufbau = { arme: ['Nord'], gruppen: [ { name: 'F1', mode: 'fuss', arm: null }, { name: 'F2', mode: 'fuss', arm: null }, { name: 'F3', mode: 'fuss', arm: null }, ], phasen: [['F1', 'F2'], ['F3']], }; it('meldet nur das Paar, das sich wirklich überschneidet', () => { const gefunden = befunde(baue(DREI)); expect(gefunden).toHaveLength(1); expect(gefunden[0]?.message).not.toContain('„F3“'); }); it('meldet bei drei zugleich freigegebenen Querungen drei Paare', () => { // Bis 5.38.0 stand hier EIN Befund mit allen drei Namen und der Behauptung, // sie zeigten gleichzeitig Freigabe - richtig war das nur, wenn sich // wirklich alle drei überschnitten. const gefunden = befunde(baue({ ...DREI, phasen: [['F1', 'F2', 'F3']] })); expect(gefunden).toHaveLength(3); }); }); describe('Was der Befund sagt und wohin er zeigt', () => { it('ist eine Warnung und sperrt die Ausgabe nicht', () => { // Warnung und nicht Hinweis: Dieselbe Bauart wie zwischenzeiten.null - kann // der Regelfall sein, entsteht aber genauso aus einem zu kurz erfassten // Räumweg. Ein Hinweis fiele zudem aus dem Bericht der übrigen // Tagesprogramme heraus. const project = baue(OHNE_ZUFAHRT); const bericht = validateProject(project, buildSignalPlan(project), JETZT); expect(bericht.findings.find((f) => f.rule === REGEL)?.severity).toBe('warnung'); expect(bericht.exportBlocked).toBe(false); }); it('beruft sich nicht auf ein Regelwerk, denn dort steht die Regel nicht', () => { expect(befunde(baue(OHNE_ZUFAHRT))[0]?.reference).not.toContain('RiLSA'); }); it('nennt beide Lesarten, damit der Regelfall nicht wie ein Mangel aussieht', () => { const text = befunde(baue(OHNE_ZUFAHRT))[0]?.message ?? ''; expect(text).toContain('zwei getrennte Querungen'); expect(text).toContain('Mittelinsel'); }); it('nennt alle drei Größen, die der halbe Weg verkürzt', () => { const text = befunde(baue(OHNE_ZUFAHRT))[0]?.message ?? ''; for (const groesse of ['Räumweg', 'Räumzeit', 'Furtregel']) { expect(text, groesse).toContain(groesse); } }); it('rät nicht dazu, die Abschnitte einfach in getrennte Phasen zu stellen', () => { // Das war der Rat bis 5.38.0. Er führt genau in die Lage, die das Programm // nicht prüft: zwei halbe Furten nacheinander, ohne Nachweis. const rat = befunde(baue(OHNE_ZUFAHRT))[0]?.suggestion ?? ''; expect(rat).toContain('Bordkante zu Bordkante'); expect(rat).toContain('getrennte Phasen allein machen daraus keine'); }); it('zeigt auf eine der beiden Signalgruppen und nicht auf die ganze Ansicht', () => { expect(befunde(baue(OHNE_ZUFAHRT))[0]?.target?.kind).toBe('signalgruppe'); }); }); describe('Die Reihenfolge der Namen hängt nicht an der Eingabe', () => { it('nennt die Gruppen gleich, egal wie sie erfasst sind', () => { const vorwaerts = befunde(baue(OHNE_ZUFAHRT))[0]?.title; const rueckwaerts = befunde( baue({ arme: ['Nord'], gruppen: [ { name: 'F2', mode: 'fuss', arm: null }, { name: 'F1', mode: 'fuss', arm: null }, ], phasen: [['F2', 'F1']], }), )[0]?.title; expect(vorwaerts).toBe(rueckwaerts); expect(vorwaerts?.indexOf('F1')).toBeLessThan(vorwaerts?.indexOf('F2') ?? -1); }); }); describe('Der Regelplan bleibt still', () => { it('meldet nichts an einem Knotenpunkt mit einer Furt je Zufahrt', () => { // Was überall meldet, sagt nichts. Der Regelfall muss still bleiben. expect( befunde( baue({ arme: ['Nord', 'Ost', 'Süd', 'West'], gruppen: [ { name: 'F1', mode: 'fuss', arm: 'Nord' }, { name: 'F2', mode: 'fuss', arm: 'Ost' }, { name: 'F3', mode: 'fuss', arm: 'Süd' }, { name: 'F4', mode: 'fuss', arm: 'West' }, ], phasen: [['F1', 'F2', 'F3', 'F4']], }), ), ).toHaveLength(0); }); });