import { describe, expect, it } from 'vitest'; import { buildSignalPlan, cyclicRuns, intergreenBetween } from '@/domain/plan/signalPlan'; import { aspectSegments, aspectTotals, isGreenAt } from '@/domain/plan/aspects'; import { createStandardIntersectionProject } from '@/domain/model/factory'; import type { Project } from '@/domain/model/project'; const FIXED_DATE = new Date('2026-01-01T00:00:00Z'); function standard(): Project { return createStandardIntersectionProject('Prueffall', FIXED_DATE); } describe('Aufbau des Signalzeitenplans', () => { it('erzeugt einen in sich schluessigen Umlauf', () => { const plan = buildSignalPlan(standard()); expect(plan.feasible).toBe(true); expect(plan.cycleTime).toBeGreaterThan(0); // Die Summe aus Freigabe- und Uebergangszeiten muss exakt die Umlaufzeit ergeben. const phaseSum = plan.phases.reduce((a, p) => a + p.duration, 0); expect(phaseSum + plan.transitionTime).toBeCloseTo(plan.cycleTime, 6); }); it('leitet die Uebergangszeit aus der groessten Zwischenzeit ab', () => { const plan = buildSignalPlan(standard()); for (const transition of plan.transitions) { if (transition.critical === null) continue; expect(transition.duration).toBe(transition.critical.intergreen); // Kein anderes Paar darf eine groessere Zwischenzeit verlangen. const fromPhase = plan.phases.find((p) => p.id === transition.fromPhaseId)!; const toPhase = plan.phases.find((p) => p.id === transition.toPhaseId)!; for (const endingId of fromPhase.signalGroupIds) { if (toPhase.signalGroupIds.includes(endingId)) continue; for (const startingId of toPhase.signalGroupIds) { if (fromPhase.signalGroupIds.includes(startingId)) continue; expect(intergreenBetween(plan.intergreens, endingId, startingId)).toBeLessThanOrEqual( transition.duration, ); } } } }); it('haelt fuer jede Signalgruppe die Mindestfreigabezeit ein', () => { const plan = buildSignalPlan(standard()); for (const group of plan.groups) { if (group.greens.length === 0) continue; expect(group.totalGreen).toBeGreaterThanOrEqual(group.times.minGreen); } }); it('gibt feindliche Signalgruppen nie gleichzeitig frei', () => { const project = standard(); const plan = buildSignalPlan(project); // Jede erfasste Konfliktbeziehung wird ueber den gesamten Umlauf in // Schritten von 0,5 s geprueft. for (const conflict of project.conflicts) { const from = plan.groups.find((g) => g.groupId === conflict.fromId)!; const to = plan.groups.find((g) => g.groupId === conflict.toId)!; for (let t = 0; t < plan.cycleTime; t += 0.5) { const bothGreen = isGreenAt(from, t, plan.cycleTime) && isGreenAt(to, t, plan.cycleTime); expect(bothGreen, `${from.name} und ${to.name} bei t=${t}s`).toBe(false); } } }); it('haelt die Zwischenzeit zwischen Freigabeende und Freigabebeginn ein', () => { const project = standard(); const plan = buildSignalPlan(project); for (const transition of plan.transitions) { const fromPhase = plan.phases.find((p) => p.id === transition.fromPhaseId)!; const toPhase = plan.phases.find((p) => p.id === transition.toPhaseId)!; const endingIds = fromPhase.signalGroupIds.filter( (id) => !toPhase.signalGroupIds.includes(id), ); const startingIds = toPhase.signalGroupIds.filter( (id) => !fromPhase.signalGroupIds.includes(id), ); for (const startingId of startingIds) { const starting = plan.groups.find((g) => g.groupId === startingId)!; const greenStart = starting.greens .map((g) => g.start) .find((s) => Math.abs(s - transition.start) <= transition.duration + 0.001); if (greenStart === undefined) continue; for (const endingId of endingIds) { const required = intergreenBetween(plan.intergreens, endingId, startingId); const actual = greenStart - transition.start; expect(actual + 1e-6).toBeGreaterThanOrEqual(required); } } } }); it('meldet eine Signalgruppe ohne Phasenzuordnung', () => { const base = standard(); const project: Project = { ...base, phases: base.phases.map((p) => ({ ...p, signalGroupIds: p.signalGroupIds.slice(0, 1) })), }; const plan = buildSignalPlan(project); expect(plan.notes.some((n) => n.code === 'signalgruppe-ohne-phase')).toBe(true); }); it('meldet fehlende Phasen statt einen leeren Plan zu zeichnen', () => { const base = standard(); const plan = buildSignalPlan({ ...base, phases: [], program: { ...base.program, phaseOrder: [] }, }); expect(plan.feasible).toBe(false); expect(plan.notes.some((n) => n.code === 'keine-phasen')).toBe(true); }); it('weist eine zu kurz vorgegebene feste Umlaufzeit zurueck', () => { const base = standard(); const plan = buildSignalPlan({ ...base, program: { ...base.program, method: 'manuell', manualCycleTime: 20 }, }); expect(plan.notes.some((n) => n.code === 'umlaufzeit-zu-kurz')).toBe(true); expect(plan.cycleTime).toBeGreaterThan(20); }); it('uebernimmt eine von Hand gesetzte Zwischenzeit', () => { const base = standard(); const first = base.conflicts[0]!; const plan = buildSignalPlan({ ...base, conflicts: base.conflicts.map((c) => (c.id === first.id ? { ...c, manualIntergreen: 9 } : c)), }); const resolved = plan.intergreens.get(`${first.fromId}|${first.toId}`)!; expect(resolved.value).toBe(9); expect(resolved.source).toBe('vorgegeben'); }); }); describe('Zyklische Phasenlaeufe', () => { it('erkennt einen zusammenhaengenden Lauf', () => { expect(cyclicRuns([false, true, true, false])).toEqual([[1, 2]]); }); it('erkennt einen Lauf ueber die Umlaufgrenze hinweg', () => { expect(cyclicRuns([true, false, false, true])).toEqual([[3, 0]]); }); it('erkennt zwei getrennte Laeufe', () => { expect(cyclicRuns([true, false, true, false])).toEqual([[0], [2]]); }); it('behandelt Dauerfreigabe und Dauerrot', () => { expect(cyclicRuns([true, true, true])).toEqual([[0, 1, 2]]); expect(cyclicRuns([false, false])).toEqual([]); }); }); describe('Signalbilder', () => { it('zerlegt den Umlauf lueckenlos und ueberschneidungsfrei', () => { const plan = buildSignalPlan(standard()); for (const group of plan.groups) { const segments = aspectSegments(group, plan.cycleTime); const total = segments.reduce((a, s) => a + s.duration, 0); expect(total).toBeCloseTo(plan.cycleTime, 3); let cursor = 0; for (const segment of segments) { expect(segment.start).toBeCloseTo(cursor, 3); cursor += segment.duration; } } }); it('ordnet Rot-Gelb vor und Gelb nach der Freigabezeit an', () => { const plan = buildSignalPlan(standard()); const group = plan.groups.find((g) => g.mode === 'kfz' && g.greens.length === 1)!; const totals = aspectTotals(group, plan.cycleTime); expect(totals.rotgelb).toBeCloseTo(group.times.redYellow, 3); expect(totals.gelb).toBeCloseTo(group.times.yellow, 3); expect(totals.gruen).toBeCloseTo(group.totalGreen, 3); expect(totals.rot).toBeGreaterThan(0); }); it('gibt Fussgaengern weder Gelb noch Rot-Gelb', () => { const plan = buildSignalPlan(standard()); const pedestrian = plan.groups.find((g) => g.mode === 'fuss')!; const totals = aspectTotals(pedestrian, plan.cycleTime); expect(totals.gelb).toBe(0); expect(totals.rotgelb).toBe(0); }); });