import { describe, expect, it } from 'vitest'; import { isGreenAt } from '@/domain/plan/aspects'; import { buildSignalPlan } from '@/domain/plan/signalPlan'; import { validateProject } from '@/domain/validation'; import { ASSISTENT_VORGABE, assistentZusammenfassung, projektAusAngaben } from '@/ui/assistent'; import type { AssistentAngaben } from '@/ui/assistent'; import type { Project, SignalGroup } from '@/domain/model/project'; /** * Befund 2: das Kaestchen "Eigene Freigabe fuer Linksabbieger" des gefuehrten * Einstiegs. * * Der Assistent legte die neue Linksabbiegergruppe in DIESELBE Phase wie den * Geradeausstrom ihrer eigenen Zufahrt. Die Konfliktschleife ueberspringt jedes * Paar derselben Phase, und damit stand der Linksabbieger aus Nord gegenueber * dem geradeausfahrenden Gegenverkehr aus Sued als vertraeglich im Plan: keine * Konfliktbeziehung, keine Zwischenzeit, gleichzeitiges Gruen. Dieselbe Phase * fuehrte ausserdem die Furten der Querachse mit, die der Linksabbieger quert. * * Die Zusage des Kaestchens ist die gesicherte Fuehrung ("Noetig, wenn * Linksabbieger nicht in Luecken des Gegenverkehrs abbiegen koennen"), und das * Programm nennt sie an zwei weiteren Stellen so: die Hilfe zur Phase ("wird * daraus eine dritte Phase") und der Pruefbericht ("fuehren Sie ... mit eigener * Freigabezeit"). Geprueft wird deshalb das Erzeugnis, nicht die Oberflaeche. */ const FESTES_DATUM = new Date('2026-01-01T00:00:00Z'); function baue(teil: Partial): Project { return projektAusAngaben({ ...ASSISTENT_VORGABE, ...teil }, 'Pruefung', FESTES_DATUM); } /** Kfz-Signalgruppe einer Zufahrt in der gesuchten Fahrtrichtung. */ function kfz(projekt: Project, zufahrt: string, movement: string): SignalGroup { const arm = projekt.intersection.arms.find((a) => a.name === zufahrt); expect(arm, `Zufahrt "${zufahrt}" fehlt`).toBeDefined(); const treffer = projekt.signalGroups.find( (g) => g.armId === arm!.id && g.mode === 'kfz' && g.movement === movement, ); expect(treffer, `Kfz-Gruppe ${zufahrt}/${movement} fehlt`).toBeDefined(); return treffer!; } /** Die Phase, in der die Gruppe Freigabe bekommt. */ function phaseVon(projekt: Project, gruppe: SignalGroup): { name: string; ids: readonly string[] } { const phase = projekt.phases.find((p) => p.signalGroupIds.includes(gruppe.id)); expect(phase, `${gruppe.name} steht in keiner Phase`).toBeDefined(); return { name: phase!.name, ids: phase!.signalGroupIds }; } /** Gibt es zwischen beiden Gruppen eine erfasste Beziehung - in einer Richtung? */ function beziehung(projekt: Project, a: SignalGroup, b: SignalGroup): boolean { return projekt.conflicts.some( (c) => (c.fromId === a.id && c.toId === b.id) || (c.fromId === b.id && c.toId === a.id), ); } /** Zeigen beide Gruppen zu irgendeinem Zeitpunkt gemeinsam Freigabe? */ function gemeinsamesGruen(projekt: Project, a: SignalGroup, b: SignalGroup): number { const plan = buildSignalPlan(projekt); expect(plan.feasible, 'Der Plan ist nicht schaltbar').toBe(true); const links = plan.groups.find((g) => g.groupId === a.id)!; const gegen = plan.groups.find((g) => g.groupId === b.id)!; let sekunden = 0; for (let t = 0; t < plan.cycleTime; t += 0.5) { if (isGreenAt(links, t, plan.cycleTime) && isGreenAt(gegen, t, plan.cycleTime)) sekunden += 0.5; } return sekunden; } describe('Eigene Freigabe fuer Linksabbieger: eigene Phase statt gemeinsamer', () => { it('legt den Linksabbieger nicht in die Phase seines Gegenverkehrs', () => { const projekt = baue({ form: 'kreuzung', linksabbieger: true }); const nordLinks = kfz(projekt, 'Nord', 'links'); const suedGerade = kfz(projekt, 'Süd', 'geradeaus'); expect(phaseVon(projekt, nordLinks).name).not.toBe(phaseVon(projekt, suedGerade).name); }); it('fuehrt Linksabbieger und Gegenverkehr als Konfliktbeziehung', () => { const projekt = baue({ form: 'kreuzung', linksabbieger: true }); const nordLinks = kfz(projekt, 'Nord', 'links'); // Der Gegenverkehr der eigenen Achse - geradeaus wie abbiegend ist der // Linksabbieger aus Sued unkritisch (beide biegen links ab und kreuzen sich // nicht), der GERADEAUSSTROM aus Sued kreuzt ihn dagegen. expect(beziehung(projekt, nordLinks, kfz(projekt, 'Süd', 'geradeaus'))).toBe(true); expect( beziehung(projekt, kfz(projekt, 'Süd', 'links'), kfz(projekt, 'Nord', 'geradeaus')), ).toBe(true); }); it('gibt Linksabbieger und Gegenverkehr nie gleichzeitig frei', () => { const projekt = baue({ form: 'kreuzung', linksabbieger: true }); expect( gemeinsamesGruen(projekt, kfz(projekt, 'Nord', 'links'), kfz(projekt, 'Süd', 'geradeaus')), ).toBe(0); }); it('fuehrt in der Linksabbiegerphase keine Furt und keinen Geradeausstrom mit', () => { const projekt = baue({ form: 'kreuzung', linksabbieger: true, fussgaenger: true, rad: true }); const phase = phaseVon(projekt, kfz(projekt, 'Nord', 'links')); const mitlaeufer = phase.ids.map((id) => projekt.signalGroups.find((g) => g.id === id)!); for (const g of mitlaeufer) { expect(`${g.name}: ${g.mode}/${g.movement}`).toBe(`${g.name}: kfz/links`); } }); it('gibt die gegenueberliegenden Linksabbieger gemeinsam frei', () => { const projekt = baue({ form: 'kreuzung', linksabbieger: true }); // Sie kreuzen einander nicht - eine eigene Phase je Zufahrt waere ein // Umlauf, den kein Knotenpunkt braucht. expect(phaseVon(projekt, kfz(projekt, 'Nord', 'links')).name).toBe( phaseVon(projekt, kfz(projekt, 'Süd', 'links')).name, ); expect(beziehung(projekt, kfz(projekt, 'Nord', 'links'), kfz(projekt, 'Süd', 'links'))).toBe( false, ); }); it('legt ohne das Kaestchen keine zusaetzliche Phase an', () => { // Die Gegenprobe: Wer nicht ankreuzt, bekommt den Umlauf von vorher. expect(baue({ form: 'kreuzung' }).phases.map((p) => p.name)).toEqual([ 'Phase 1 – Nord/Süd', 'Phase 2 – Ost/West', ]); expect(baue({ form: 'fuenfarmig' }).phases).toHaveLength(3); }); it('nummeriert die Phasen des Umlaufs lueckenlos durch', () => { for (const teil of [ { form: 'kreuzung' as const, linksabbieger: true }, { form: 'einmuendung' as const, linksabbieger: true }, { form: 'fuenfarmig' as const, linksabbieger: true }, ]) { const projekt = baue(teil); projekt.phases.forEach((p, i) => { expect(p.name.startsWith(`Phase ${i + 1} – `), `${teil.form}: ${p.name}`).toBe(true); expect(p.signalGroupIds.length, `${teil.form}: ${p.name} ist leer`).toBeGreaterThan(0); }); expect(projekt.program.phaseOrder).toHaveLength(projekt.phases.length); } }); it('gibt dem Ostarm der Einmuendung keinen Linksabbieger und keine leere Phase', () => { const projekt = baue({ form: 'einmuendung', linksabbieger: true }); const arm = projekt.intersection.arms.find((a) => a.name === 'Ost')!; expect( projekt.signalGroups.filter((g) => g.armId === arm.id && g.movement === 'links'), ).toHaveLength(0); // Nord und Sued haben Gegenverkehr, Ost nicht: drei Phasen, nicht vier. expect(projekt.phases).toHaveLength(3); }); it('bleibt mit eigener Linksabbiegerphase schaltbar und ohne Fachfehler', () => { for (const teil of [ { form: 'kreuzung' as const, linksabbieger: true, rad: true }, { form: 'einmuendung' as const, linksabbieger: true, rad: true }, { form: 'fuenfarmig' as const, linksabbieger: true, rad: true }, ]) { const projekt = baue(teil); const plan = buildSignalPlan(projekt); expect(plan.feasible, teil.form).toBe(true); const fehler = validateProject(projekt, plan, FESTES_DATUM) .findings.filter((f) => f.severity === 'fehler' && f.category !== 'projektdaten') .map((f) => `${f.rule}: ${f.message}`); expect(fehler, `${teil.form}: ${fehler.join('\n')}`).toHaveLength(0); } }); it('nennt die eigene Phase in der Zusammenfassung der Annahmen', () => { // Was der Assistent an Fuehrungsform festlegt, steht im letzten Schritt - // dieselbe Regel wie fuer die Furtwege (Befund C12). const mit = assistentZusammenfassung({ ...ASSISTENT_VORGABE, linksabbieger: true }).join(' '); expect(mit).toContain('Linksabbieger'); expect(mit).toContain('eigene Phase'); expect(mit).toContain('Gegenverkehr'); const ohne = assistentZusammenfassung({ ...ASSISTENT_VORGABE, linksabbieger: false }).join(' '); expect(ohne).not.toContain('Linksabbieger'); }); });