import { describe, expect, it } from 'vitest'; import { isGreenAt } from '@/domain/plan/aspects'; import { buildSignalPlan } from '@/domain/plan/signalPlan'; import { ANLAGENART_GRENZEN } from '@/domain/rilsa/constants'; import { validateProject } from '@/domain/validation'; import { ASSISTENT_VORGABE, engstellenFolgen, projektAusAngaben } from '@/ui/assistent'; import type { AssistentAngaben } from '@/ui/assistent'; import type { Project } from '@/domain/model/project'; /** * Die neuen Anlagenformen des gefuehrten Einstiegs. * * Geprueft wird nicht die Oberflaeche, sondern das Erzeugnis: Ein Assistent, * der eine Baustellenampel anbietet und daraus einen nicht schaltbaren Plan * macht, waere schlimmer als einer, der sie gar nicht anbietet - der Anwender * haette dann eine Planung, der er ansieht, dass sie fertig ist. */ const FESTES_DATUM = new Date('2026-01-01T00:00:00Z'); function angaben(teil: Partial): AssistentAngaben { return { ...ASSISTENT_VORGABE, ...teil }; } function baue(teil: Partial): Project { return projektAusAngaben(angaben(teil), 'Pruefung', FESTES_DATUM); } /** Keine feindliche Signalgruppe darf gleichzeitig Freigabe zeigen. */ function pruefeVertraeglichkeit(projekt: Project): void { const plan = buildSignalPlan(projekt); expect(plan.feasible).toBe(true); for (const konflikt of projekt.conflicts) { const a = plan.groups.find((g) => g.groupId === konflikt.fromId)!; const b = plan.groups.find((g) => g.groupId === konflikt.toId)!; for (let t = 0; t < plan.cycleTime; t += 0.5) { expect( isGreenAt(a, t, plan.cycleTime) && isGreenAt(b, t, plan.cycleTime), `${a.name} und ${b.name} bei t=${t}s`, ).toBe(false); } } } /** Fachfehler ohne die Projektstammdaten, die der Assistent nicht kennt. */ function fachfehler(projekt: Project): readonly string[] { const plan = buildSignalPlan(projekt); return validateProject(projekt, plan, FESTES_DATUM) .findings.filter((f) => f.severity === 'fehler' && f.category !== 'projektdaten') .map((f) => `${f.rule}: ${f.message}`); } describe('Anlagenart des erzeugten Projekts', () => { it('traegt die gewaehlte Art ein', () => { expect(baue({ form: 'kreuzung' }).anlagenart).toBe('knotenpunkt'); expect(baue({ form: 'einmuendung' }).anlagenart).toBe('knotenpunkt'); expect(baue({ form: 'fuenfarmig' }).anlagenart).toBe('knotenpunkt'); expect(baue({ form: 'fussgaengerschutzanlage' }).anlagenart).toBe('fussgaengerschutzanlage'); expect(baue({ form: 'einstreifig' }).anlagenart).toBe('einstreifig'); }); }); describe('Fuenfarmiger Knotenpunkt', () => { it('legt fuenf Zufahrten und drei Phasen an', () => { const projekt = baue({ form: 'fuenfarmig' }); expect(projekt.intersection.arms).toHaveLength(5); expect(projekt.phases).toHaveLength(3); expect(projekt.program.phaseOrder).toHaveLength(3); // Jede Phase fuehrt Signalgruppen - eine leere Phase waere ein Umlauf, in // dem nichts faehrt. for (const phase of projekt.phases) expect(phase.signalGroupIds.length).toBeGreaterThan(0); }); it('gibt den schraegen Arm nie mit einer der Hauptachsen frei', () => { pruefeVertraeglichkeit(baue({ form: 'fuenfarmig', rad: true, linksabbieger: true })); }); it('bleibt ohne Fachfehler rechenbar', () => { expect(fachfehler(baue({ form: 'fuenfarmig' }))).toHaveLength(0); }); }); describe('Fussgaengerschutzanlage', () => { it('legt zwei Signalgruppen, zwei Phasen und beide Konfliktrichtungen an', () => { const projekt = baue({ form: 'fussgaengerschutzanlage' }); expect(projekt.signalGroups.map((g) => g.mode)).toEqual(['kfz', 'fuss']); expect(projekt.phases).toHaveLength(2); expect(projekt.conflicts).toHaveLength(2); // Gerichtet und in beiden Richtungen erfasst. const [hin, zurueck] = projekt.conflicts; expect(hin!.fromId).toBe(zurueck!.toId); expect(hin!.toId).toBe(zurueck!.fromId); }); it('bildet den Raeumweg aus der Fahrbahnbreite', () => { const projekt = baue({ form: 'fussgaengerschutzanlage', fahrbahnbreite: 9 }); const furt = projekt.signalGroups.find((g) => g.mode === 'fuss')!; const raeumtFurt = projekt.conflicts.find((c) => c.fromId === furt.id)!; // Der Fussgaenger raeumt genau die Fahrbahnbreite. expect(raeumtFurt.clearingDistance).toBe(9); // Das Fahrzeug raeumt darueber hinaus bis hinter die Furt und faehrt von // der Haltlinie aus ein - der Einfahrweg des Fussgaengers ist dagegen 0. const raeumtKfz = projekt.conflicts.find((c) => c.toId === furt.id)!; expect(raeumtKfz.clearingDistance).toBeGreaterThan(9); expect(raeumtKfz.enteringDistance).toBe(0); expect(raeumtFurt.enteringDistance).toBeGreaterThan(0); }); it('erbt den Wegnachweis "unbestimmt" - der Assistent vermisst nichts', () => { const projekt = baue({ form: 'fussgaengerschutzanlage' }); for (const konflikt of projekt.conflicts) { expect(konflikt.wegnachweis.herkunft).toBe('unbestimmt'); } }); it('bleibt ohne Fachfehler schaltbar', () => { const projekt = baue({ form: 'fussgaengerschutzanlage' }); pruefeVertraeglichkeit(projekt); expect(fachfehler(projekt)).toHaveLength(0); }); }); describe('Einstreifige Verkehrsfuehrung', () => { it('macht die Laenge der Engstelle zum Raeumweg beider Richtungen', () => { const projekt = baue({ form: 'einstreifig', engstellenlaenge: 400 }); expect(projekt.signalGroups).toHaveLength(2); expect(projekt.conflicts).toHaveLength(2); for (const konflikt of projekt.conflicts) { expect(konflikt.clearingDistance).toBe(400); // Das entgegenkommende Fahrzeug steht an der Haltlinie und ist mit dem // ersten Meter im Konfliktbereich. expect(konflikt.enteringDistance).toBe(0); } }); it('ist auch bei 400 m Engstelle schaltbar - dafuer gibt es die Anlagenart', () => { const projekt = baue({ form: 'einstreifig', engstellenlaenge: 400 }); const plan = buildSignalPlan(projekt); expect(plan.feasible).toBe(true); // Genau der Umlauf, an dem die Knotenpunktschranke von 120 s zerbrach. expect(plan.cycleTime).toBeGreaterThan(ANLAGENART_GRENZEN.knotenpunkt.cycleTime.max); expect(plan.cycleTime).toBeLessThanOrEqual(ANLAGENART_GRENZEN.einstreifig.cycleTime.max); pruefeVertraeglichkeit(projekt); expect(fachfehler(projekt)).toHaveLength(0); }); it('haelt bei kurzer Engstelle einen kurzen Umlauf', () => { const projekt = baue({ form: 'einstreifig', engstellenlaenge: 120 }); expect(fachfehler(projekt)).toHaveLength(0); pruefeVertraeglichkeit(projekt); }); }); describe('Folgen der Engstellenlaenge', () => { it('nennt dieselbe Zwischenzeit, die anschliessend im Plan steht', () => { const folgen = engstellenFolgen(400, 50); // Seit 5.27.0 nach RiLSA 2015, Abschnitt 5.2.2: 4 s Ueberfahrzeit + 400 m // bei 40 km/h = 40 s. Bis 5.26.0: 400 m + 6 m Pkw bei 10 m/s // Raeumgeschwindigkeit, dazu 3 s Gelbzeit - 44 s. expect(folgen.zwischenzeit).toBe(40); const plan = buildSignalPlan(baue({ form: 'einstreifig', engstellenlaenge: 400 })); for (const uebergang of plan.transitions) { expect(uebergang.duration).toBe(folgen.zwischenzeit); } }); it('waechst mit der Laenge und warnt vor unzumutbarer Wartezeit', () => { const kurz = engstellenFolgen(120, 50); const lang = engstellenFolgen(ANLAGENART_GRENZEN.einstreifig.maxClearingDistance, 50); expect(lang.zwischenzeit).toBeGreaterThan(kurz.zwischenzeit); expect(lang.wartezeit).toBeGreaterThan(kurz.wartezeit); // Die Wartezeit ist der Umlauf abzueglich der eigenen Freigabezeit. expect(kurz.wartezeit).toBeLessThan(kurz.umlaufzeit); expect(kurz.stufe).toBe('hinweis'); expect(lang.stufe).toBe('warnung'); expect(lang.wartezeit).toBeGreaterThanOrEqual(ANLAGENART_GRENZEN.einstreifig.warteZeitWarnung); }); it('haelt die groesste eintragbare Engstelle unter der Fehlerschwelle', () => { // Die Obergrenze des Raeumwegs ist so gewaehlt, dass die Anlage bei // kuerzest zulaessiger Freigabezeit noch schaltbar bleibt. Laeuft eine der // beiden Zahlen im Regelwerk davon, faellt es hier auf. const grenzen = ANLAGENART_GRENZEN.einstreifig; const folgen = engstellenFolgen(grenzen.maxClearingDistance, 50); expect(folgen.umlaufzeit).toBeLessThanOrEqual(grenzen.cycleTime.max); expect(folgen.wartezeit).toBeLessThan(grenzen.warteZeitFehler); }); });