lsa-planer

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

/ tests domain zwischenzeitAbstand.test.ts

8,7 KB Rohdatei
tests/domain/zwischenzeitAbstand.test.ts — 199 Zeilen
1 import { describe, expect, it } from 'vitest';
2 import { validateProject } from '@/domain/validation/engine';
3 import { buildSignalPlan, intergreenKey } from '@/domain/plan/signalPlan';
4 import {
5 createConflict,
6 createPhase,
7 createStandardIntersectionProject,
8 } from '@/domain/model/factory';
9 import type { Project } from '@/domain/model/project';
10
11 /*
12 * Die Zwischenzeit wurde ausschliesslich ueber die Phasenuebergaenge
13 * durchgesetzt. Das erfasst nur Paare, die ueber EINEN Uebergang gegeneinander
14 * laufen.
15 *
16 * Ab drei Phasen gibt es Paare, auf die das nicht zutrifft: Endet eine Furt in
17 * Phase 1 und beginnt der querende Fahrzeugstrom erst in Phase 3, so endet die
18 * eine beim Uebergang 1 nach 2 und beginnt die andere beim Uebergang 2 nach 3.
19 * Kein einzelner Uebergang bringt die beiden gegeneinander. Die Pruefung auf
20 * gleichzeitige Freigabe faengt das nicht auf, weil sie nur auf Ueberlappung
21 * abtastet, nicht auf zu geringen Abstand.
22 *
23 * Der Stand vor dieser Regel: Furt fordert 33 s, erhaelt 11 s, null Fehler,
24 * Export freigegeben.
25 */
26
27 const STICHTAG = new Date('2026-01-01T00:00:00Z');
28
29 /** Drei Phasen mit kurzer Mittelphase; F1 in Phase 1, K3 erst in Phase 3. */
30 function dreiPhasen(optionen: { mittelphase: number; raeumwegFurt: number }): Project {
31 const roh = createStandardIntersectionProject('Abstandspruefung', STICHTAG);
32 const [k1, k2, k3, k4, f1, f2] = roh.signalGroups;
33
34 const p1 = {
35 ...createPhase('Phase 1 - Nord/Sued mit Furt', [k1!.id, k2!.id, f1!.id]),
36 manualGreen: 20,
37 };
38 const p2 = {
39 ...createPhase('Phase 2 - nur Nord/Sued', [k1!.id, k2!.id]),
40 manualGreen: optionen.mittelphase,
41 };
42 const p3 = { ...createPhase('Phase 3 - Ost/West', [k3!.id, k4!.id, f2!.id]), manualGreen: 20 };
43
44 return {
45 ...roh,
46 conflicts: [
47 ...roh.conflicts.map((c) =>
48 c.fromId === f1!.id && c.toId === k3!.id
49 ? { ...c, clearingDistance: optionen.raeumwegFurt, enteringDistance: 4 }
50 : c,
51 ),
52 { ...createConflict(k2!.id, f2!.id), clearingDistance: 22, enteringDistance: 6 },
53 { ...createConflict(f2!.id, k2!.id), clearingDistance: 12, enteringDistance: 8 },
54 ],
55 phases: [p1, p2, p3],
56 program: {
57 ...roh.program,
58 phaseOrder: [p1.id, p2.id, p3.id],
59 method: 'manuell',
60 manualCycleTime: 80,
61 },
62 meta: {
63 ...roh.meta,
64 projectNumber: 'VP-2026-001',
65 client: 'Stadt Musterhausen',
66 planner: 'O. Willerding',
67 location: 'Abstandspruefung',
68 },
69 };
70 }
71
72 describe('Zwischenzeit ueber mehr als einen Phasenuebergang', () => {
73 it('meldet einen zu geringen Abstand und sperrt den Export', () => {
74 const projekt = dreiPhasen({ mittelphase: 5, raeumwegFurt: 40 });
75 const plan = buildSignalPlan(projekt);
76 const bericht = validateProject(projekt, plan, STICHTAG);
77
78 const [, , k3, , f1] = projekt.signalGroups;
79 const gefordert = plan.intergreens.get(intergreenKey(f1!.id, k3!.id))?.value ?? 0;
80 expect(gefordert).toBeGreaterThan(30);
81
82 const befund = bericht.findings.find((f) => f.rule === 'zwischenzeiten.abstand-zu-kurz');
83 expect(befund, 'Der zu geringe Abstand muss gemeldet werden').toBeDefined();
84 expect(befund!.severity).toBe('fehler');
85 expect(befund!.category).toBe('zwischenzeiten');
86 expect(befund!.title).toContain('F1');
87 expect(befund!.title).toContain('K3');
88 // Die Meldung muss den tatsaechlichen und den geforderten Wert nennen -
89 // ohne beide Zahlen kann der Bearbeiter nicht entscheiden, wie viel fehlt.
90 expect(befund!.message).toContain(`${gefordert} s`);
91 // Deutsches Dezimalkomma. Ein englischer Punkt stuende in derselben
92 // Unterlage neben den Zahlen aller uebrigen Meldungen, die formatSeconds
93 // benutzen.
94 expect(befund!.message).toMatch(/nur \d+(,\d+)? s/);
95 expect(befund!.message).not.toMatch(/\d\.\d/);
96 expect(bericht.exportBlocked).toBe(true);
97 });
98
99 it('schreibt eine halbe Sekunde aus der Projektdatei mit deutschem Komma', () => {
100 /*
101 * Frueher stand hier: Eine Mittelphase mit halber Sekunde bringt einen
102 * Bruchteil in die Abstandsmeldung, und die Frage war, ob dort "11.5 s"
103 * oder "11,5 s" steht.
104 *
105 * Seit der Behebung von Befund 31 (Fassung 5.9.0) erreicht die halbe
106 * Sekunde den Plan nicht mehr: Die Freigabezeitverteilung rundet eine
107 * gebrochene Vorgabe vor dem Summenausgleich auf die naechste ganze
108 * Sekunde auf, statt sie hinterher anzuheben und die Umlaufzeit zu
109 * sprengen. Der Abstand ist damit ganzzahlig, und die Frage nach dem
110 * Komma stellt sich an dieser Meldung nicht mehr.
111 *
112 * Der Bruchteil ist deshalb nicht verschwunden, sondern umgezogen: Er
113 * steht jetzt in der Notiz, die das Aufrunden ausweist. Genau dort muss
114 * das Komma stehen - und die Abstandsmeldung darf nach wie vor keinen
115 * englischen Punkt fuehren.
116 */
117 const projekt = dreiPhasen({ mittelphase: 5.5, raeumwegFurt: 40 });
118 const plan = buildSignalPlan(projekt);
119 const befund = validateProject(projekt, plan, STICHTAG).findings.find(
120 (f) => f.rule === 'zwischenzeiten.abstand-zu-kurz',
121 );
122
123 expect(befund, 'Der Aufbau muss die Regel ausloesen').toBeDefined();
124 expect(befund!.message).not.toMatch(/\d\.\d/);
125
126 const notiz = plan.notes.find((n) => n.code === 'freigabezeit-vorgabe-aufgerundet');
127 expect(notiz, 'Das Aufrunden der halben Sekunde muss im Plan stehen').toBeDefined();
128 expect(notiz!.message, 'Bruchteil mit Komma erwartet').toMatch(/\d+,\d+ s/);
129 expect(notiz!.message).not.toMatch(/\d\.\d/);
130 });
131
132 it('schweigt, sobald die Mittelphase lang genug ist', () => {
133 const projekt = dreiPhasen({ mittelphase: 30, raeumwegFurt: 40 });
134 const bericht = validateProject(projekt, buildSignalPlan(projekt), STICHTAG);
135
136 expect(bericht.findings.some((f) => f.rule === 'zwischenzeiten.abstand-zu-kurz')).toBe(false);
137 });
138
139 it('schweigt beim Regelknotenpunkt mit zwei Phasen', () => {
140 // Bei zwei Phasen ist jedes Paar benachbart; die Uebergangsbemessung
141 // deckt alles ab. Hier darf die neue Regel nichts melden, sonst waere
142 // jeder Regelplan unbrauchbar.
143 const projekt = createStandardIntersectionProject('Regelfall', STICHTAG);
144 const bericht = validateProject(projekt, buildSignalPlan(projekt), STICHTAG);
145
146 expect(bericht.findings.some((f) => f.rule === 'zwischenzeiten.abstand-zu-kurz')).toBe(false);
147 });
148
149 it('ueberlaesst ueberlappende Freigabe der Pruefung auf gleichzeitiges Gruen', () => {
150 // Beide feindlichen Gruppen in dieselbe Phase legen: Dann ueberlappen sie,
151 // und es soll GENAU EINE Meldung je Paar entstehen - sonst zaehlt der Kopf
152 // des Berichts doppelt so viele Fehler, wie es Konflikte gibt.
153 const roh = createStandardIntersectionProject('Ueberlappung', STICHTAG);
154 const [k1, k2, k3, k4, f1, f2] = roh.signalGroups;
155 const alle = createPhase('Alles gemeinsam', [k1!.id, k2!.id, k3!.id, k4!.id, f1!.id, f2!.id]);
156 const projekt: Project = {
157 ...roh,
158 phases: [alle],
159 program: { ...roh.program, phaseOrder: [alle.id] },
160 };
161
162 const bericht = validateProject(projekt, buildSignalPlan(projekt), STICHTAG);
163
164 // Zustaendig ist die genauere Auskunft: Sie nennt die Phase, in der die
165 // Zuordnung zu aendern ist.
166 const inPhase = bericht.findings.filter((f) => f.rule === 'phasen.feindlich-in-phase');
167 const ueberlappend = bericht.findings.filter(
168 (f) => f.rule === 'signalplan.feindliche-freigabe',
169 );
170
171 const paare = new Set(projekt.conflicts.map((c) => [c.fromId, c.toId].sort().join('|')));
172 expect(inPhase).toHaveLength(paare.size);
173 expect(ueberlappend, 'kein Paar darf zweimal gemeldet werden').toHaveLength(0);
174
175 // Jede Meldung nennt beide Gruppen im Titel - sechsmal derselbe Titel
176 // waere im Bericht nicht auseinanderzuhalten.
177 expect(new Set(inPhase.map((f) => f.title)).size).toBe(inPhase.length);
178
179 expect(bericht.findings.some((f) => f.rule === 'zwischenzeiten.abstand-zu-kurz')).toBe(false);
180 });
181
182 it('prueft beide Richtungen getrennt, weil die Zwischenzeiten verschieden sind', () => {
183 const projekt = dreiPhasen({ mittelphase: 5, raeumwegFurt: 40 });
184 const plan = buildSignalPlan(projekt);
185 const [, , k3, , f1] = projekt.signalGroups;
186
187 const hin = plan.intergreens.get(intergreenKey(f1!.id, k3!.id))?.value ?? 0;
188 const zurueck = plan.intergreens.get(intergreenKey(k3!.id, f1!.id))?.value ?? 0;
189 expect(hin).not.toBe(zurueck);
190
191 // Nur die Richtung mit dem langen Raeumweg ist zu knapp; die Gegenrichtung
192 // darf nicht mitgemeldet werden.
193 const befunde = validateProject(projekt, plan, STICHTAG).findings.filter(
194 (f) => f.rule === 'zwischenzeiten.abstand-zu-kurz',
195 );
196 expect(befunde).toHaveLength(1);
197 expect(befunde[0]!.title).toMatch(/F1 nach K3/);
198 });
199 });