lsa-planer

LSA-Planer Professional – Planungssoftware für Lichtsignalanlagen nach RiLSA 2015 und § 45 StVO. EUPL-1.2.

/ tests domain signalPlan.test.ts

7,5 KB Rohdatei
tests/domain/signalPlan.test.ts — 198 Zeilen
1 import { describe, expect, it } from 'vitest';
2 import { buildSignalPlan, cyclicRuns, intergreenBetween } from '@/domain/plan/signalPlan';
3 import { aspectSegments, aspectTotals, isGreenAt } from '@/domain/plan/aspects';
4 import { createStandardIntersectionProject } from '@/domain/model/factory';
5 import type { Project } from '@/domain/model/project';
6
7 const FIXED_DATE = new Date('2026-01-01T00:00:00Z');
8
9 function standard(): Project {
10 return createStandardIntersectionProject('Prueffall', FIXED_DATE);
11 }
12
13 describe('Aufbau des Signalzeitenplans', () => {
14 it('erzeugt einen in sich schluessigen Umlauf', () => {
15 const plan = buildSignalPlan(standard());
16
17 expect(plan.feasible).toBe(true);
18 expect(plan.cycleTime).toBeGreaterThan(0);
19
20 // Die Summe aus Freigabe- und Uebergangszeiten muss exakt die Umlaufzeit ergeben.
21 const phaseSum = plan.phases.reduce((a, p) => a + p.duration, 0);
22 expect(phaseSum + plan.transitionTime).toBeCloseTo(plan.cycleTime, 6);
23 });
24
25 it('leitet die Uebergangszeit aus der groessten Zwischenzeit ab', () => {
26 const plan = buildSignalPlan(standard());
27
28 for (const transition of plan.transitions) {
29 if (transition.critical === null) continue;
30 expect(transition.duration).toBe(transition.critical.intergreen);
31
32 // Kein anderes Paar darf eine groessere Zwischenzeit verlangen.
33 const fromPhase = plan.phases.find((p) => p.id === transition.fromPhaseId)!;
34 const toPhase = plan.phases.find((p) => p.id === transition.toPhaseId)!;
35 for (const endingId of fromPhase.signalGroupIds) {
36 if (toPhase.signalGroupIds.includes(endingId)) continue;
37 for (const startingId of toPhase.signalGroupIds) {
38 if (fromPhase.signalGroupIds.includes(startingId)) continue;
39 expect(intergreenBetween(plan.intergreens, endingId, startingId)).toBeLessThanOrEqual(
40 transition.duration,
41 );
42 }
43 }
44 }
45 });
46
47 it('haelt fuer jede Signalgruppe die Mindestfreigabezeit ein', () => {
48 const plan = buildSignalPlan(standard());
49 for (const group of plan.groups) {
50 if (group.greens.length === 0) continue;
51 expect(group.totalGreen).toBeGreaterThanOrEqual(group.times.minGreen);
52 }
53 });
54
55 it('gibt feindliche Signalgruppen nie gleichzeitig frei', () => {
56 const project = standard();
57 const plan = buildSignalPlan(project);
58
59 // Jede erfasste Konfliktbeziehung wird ueber den gesamten Umlauf in
60 // Schritten von 0,5 s geprueft.
61 for (const conflict of project.conflicts) {
62 const from = plan.groups.find((g) => g.groupId === conflict.fromId)!;
63 const to = plan.groups.find((g) => g.groupId === conflict.toId)!;
64 for (let t = 0; t < plan.cycleTime; t += 0.5) {
65 const bothGreen = isGreenAt(from, t, plan.cycleTime) && isGreenAt(to, t, plan.cycleTime);
66 expect(bothGreen, `${from.name} und ${to.name} bei t=${t}s`).toBe(false);
67 }
68 }
69 });
70
71 it('haelt die Zwischenzeit zwischen Freigabeende und Freigabebeginn ein', () => {
72 const project = standard();
73 const plan = buildSignalPlan(project);
74
75 for (const transition of plan.transitions) {
76 const fromPhase = plan.phases.find((p) => p.id === transition.fromPhaseId)!;
77 const toPhase = plan.phases.find((p) => p.id === transition.toPhaseId)!;
78 const endingIds = fromPhase.signalGroupIds.filter(
79 (id) => !toPhase.signalGroupIds.includes(id),
80 );
81 const startingIds = toPhase.signalGroupIds.filter(
82 (id) => !fromPhase.signalGroupIds.includes(id),
83 );
84
85 for (const startingId of startingIds) {
86 const starting = plan.groups.find((g) => g.groupId === startingId)!;
87 const greenStart = starting.greens
88 .map((g) => g.start)
89 .find((s) => Math.abs(s - transition.start) <= transition.duration + 0.001);
90 if (greenStart === undefined) continue;
91
92 for (const endingId of endingIds) {
93 const required = intergreenBetween(plan.intergreens, endingId, startingId);
94 const actual = greenStart - transition.start;
95 expect(actual + 1e-6).toBeGreaterThanOrEqual(required);
96 }
97 }
98 }
99 });
100
101 it('meldet eine Signalgruppe ohne Phasenzuordnung', () => {
102 const base = standard();
103 const project: Project = {
104 ...base,
105 phases: base.phases.map((p) => ({ ...p, signalGroupIds: p.signalGroupIds.slice(0, 1) })),
106 };
107 const plan = buildSignalPlan(project);
108 expect(plan.notes.some((n) => n.code === 'signalgruppe-ohne-phase')).toBe(true);
109 });
110
111 it('meldet fehlende Phasen statt einen leeren Plan zu zeichnen', () => {
112 const base = standard();
113 const plan = buildSignalPlan({
114 ...base,
115 phases: [],
116 program: { ...base.program, phaseOrder: [] },
117 });
118 expect(plan.feasible).toBe(false);
119 expect(plan.notes.some((n) => n.code === 'keine-phasen')).toBe(true);
120 });
121
122 it('weist eine zu kurz vorgegebene feste Umlaufzeit zurueck', () => {
123 const base = standard();
124 const plan = buildSignalPlan({
125 ...base,
126 program: { ...base.program, method: 'manuell', manualCycleTime: 20 },
127 });
128 expect(plan.notes.some((n) => n.code === 'umlaufzeit-zu-kurz')).toBe(true);
129 expect(plan.cycleTime).toBeGreaterThan(20);
130 });
131
132 it('uebernimmt eine von Hand gesetzte Zwischenzeit', () => {
133 const base = standard();
134 const first = base.conflicts[0]!;
135 const plan = buildSignalPlan({
136 ...base,
137 conflicts: base.conflicts.map((c) => (c.id === first.id ? { ...c, manualIntergreen: 9 } : c)),
138 });
139 const resolved = plan.intergreens.get(`${first.fromId}|${first.toId}`)!;
140 expect(resolved.value).toBe(9);
141 expect(resolved.source).toBe('vorgegeben');
142 });
143 });
144
145 describe('Zyklische Phasenlaeufe', () => {
146 it('erkennt einen zusammenhaengenden Lauf', () => {
147 expect(cyclicRuns([false, true, true, false])).toEqual([[1, 2]]);
148 });
149
150 it('erkennt einen Lauf ueber die Umlaufgrenze hinweg', () => {
151 expect(cyclicRuns([true, false, false, true])).toEqual([[3, 0]]);
152 });
153
154 it('erkennt zwei getrennte Laeufe', () => {
155 expect(cyclicRuns([true, false, true, false])).toEqual([[0], [2]]);
156 });
157
158 it('behandelt Dauerfreigabe und Dauerrot', () => {
159 expect(cyclicRuns([true, true, true])).toEqual([[0, 1, 2]]);
160 expect(cyclicRuns([false, false])).toEqual([]);
161 });
162 });
163
164 describe('Signalbilder', () => {
165 it('zerlegt den Umlauf lueckenlos und ueberschneidungsfrei', () => {
166 const plan = buildSignalPlan(standard());
167 for (const group of plan.groups) {
168 const segments = aspectSegments(group, plan.cycleTime);
169 const total = segments.reduce((a, s) => a + s.duration, 0);
170 expect(total).toBeCloseTo(plan.cycleTime, 3);
171
172 let cursor = 0;
173 for (const segment of segments) {
174 expect(segment.start).toBeCloseTo(cursor, 3);
175 cursor += segment.duration;
176 }
177 }
178 });
179
180 it('ordnet Rot-Gelb vor und Gelb nach der Freigabezeit an', () => {
181 const plan = buildSignalPlan(standard());
182 const group = plan.groups.find((g) => g.mode === 'kfz' && g.greens.length === 1)!;
183 const totals = aspectTotals(group, plan.cycleTime);
184
185 expect(totals.rotgelb).toBeCloseTo(group.times.redYellow, 3);
186 expect(totals.gelb).toBeCloseTo(group.times.yellow, 3);
187 expect(totals.gruen).toBeCloseTo(group.totalGreen, 3);
188 expect(totals.rot).toBeGreaterThan(0);
189 });
190
191 it('gibt Fussgaengern weder Gelb noch Rot-Gelb', () => {
192 const plan = buildSignalPlan(standard());
193 const pedestrian = plan.groups.find((g) => g.mode === 'fuss')!;
194 const totals = aspectTotals(pedestrian, plan.cycleTime);
195 expect(totals.gelb).toBe(0);
196 expect(totals.rotgelb).toBe(0);
197 });
198 });