lsa-planer

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

/ tests domain querungenGleichzeitig.test.ts

13,5 KB Rohdatei
tests/domain/querungenGleichzeitig.test.ts — 379 Zeilen
1 import { describe, expect, it } from 'vitest';
2 import {
3 createArm,
4 createConflict,
5 createEmptyProject,
6 createPhase,
7 createSignalGroup,
8 } from '@/domain/model/factory';
9 import { buildSignalPlan } from '@/domain/plan/signalPlan';
10 import { validateProject } from '@/domain/validation/engine';
11 import type { Conflict, Project } from '@/domain/model/project';
12 import type { TrafficMode } from '@/domain/rilsa/types';
13
14 /**
15 * Zwei Querungen derselben Verkehrsart, die zugleich Freigabe zeigen.
16 *
17 * ANLASS (die zweistufige Fussgaengerfurt). Das Programm bildet die Furt mit
18 * Mittelinsel nur durchgehend ab - nachbauen laesst sie sich trotzdem, indem
19 * der Bearbeiter je Abschnitt eine eigene Gruppe anlegt und ihr den halben
20 * Raeumweg gibt. Stehen beide Abschnitte dann in DERSELBEN Phase, ist das
21 * gerade keine zweistufige Signalisierung: Der Querende darf nach dem
22 * Signalbild in einem Zug durchgehen, waehrend Raeumweg, Raeumzeit und
23 * Furtregel nur den halben Weg abdecken. Zwei Fussgaengergruppen sind einander
24 * nie feindlich, und deshalb sah `signalplan.feindliche-freigabe` das nicht.
25 *
26 * WARUM DIESE DATEI DIE ZWEITE IHRER ART IST. Die Fassung 5.38.0 hatte die
27 * Regel schon, gruppierte aber ueber `armId` und schwieg, wo das Feld leer war.
28 * Das Feld ist optional, und beide Wege, auf denen ein Bearbeiter eine
29 * ZUSAETZLICHE Gruppe anlegt, lassen es leer - der Knopf "+ Fussgaenger" ueber
30 * `addSignalGroup` und die Uebernahme aus dem Lageplan. Die Regel schwieg damit
31 * genau in dem Fall, fuer den sie geschrieben wurde, und ein Testfall
32 * ("schweigt bei Fussgaengergruppen ohne Arm") fror dieses Schweigen ein. Der
33 * erste Fall dieser Datei ist deshalb der wichtigste.
34 */
35
36 const JETZT = new Date('2026-09-11T00:00:00Z');
37 const REGEL = 'signalplan.querungen-gleichzeitig';
38
39 interface Gruppenangabe {
40 readonly name: string;
41 readonly mode: TrafficMode;
42 /** Name der Zufahrt; null, wenn keine erfasst ist. */
43 readonly arm: string | null;
44 }
45
46 interface Aufbau {
47 readonly arme: readonly string[];
48 readonly gruppen: readonly Gruppenangabe[];
49 /** Je Phase die Namen der freigegebenen Gruppen. */
50 readonly phasen: readonly (readonly string[])[];
51 /** Namenspaare, die als feindlich mit Raeumweg erfasst werden. */
52 readonly feindlich?: readonly (readonly [string, string])[];
53 }
54
55 /**
56 * Ein Kfz-Strom, dazu die Querungen des Aufbaus. Jede Querung steht mit dem
57 * Kfz-Strom in Konflikt, damit ueberhaupt ein schaltbarer Plan entsteht.
58 */
59 function baue(aufbau: Aufbau): Project {
60 const leer = createEmptyProject('Querungen', JETZT, 'knotenpunkt');
61 const arme = aufbau.arme.map((name) => createArm(name, name, 2, 50));
62 const armId = (name: string | null) =>
63 name === null ? null : (arme.find((a) => a.name === name)?.id ?? null);
64
65 const kfz = createSignalGroup({ name: 'K1', mode: 'kfz', armId: arme[0]?.id ?? null, index: 0 });
66 const weitere = aufbau.gruppen.map((g, i) =>
67 createSignalGroup({ name: g.name, mode: g.mode, armId: armId(g.arm), index: 1 + i }),
68 );
69 const alle = [kfz, ...weitere];
70 /** Die erste Gruppe dieses Namens - Namen sind hier absichtlich nicht eindeutig. */
71 const id = (name: string) => alle.find((g) => g.name === name)!.id;
72
73 const conflicts: Conflict[] = [];
74 for (const g of weitere) {
75 conflicts.push(
76 { ...createConflict(g.id, kfz.id), clearingDistance: 5.75, enteringDistance: 0 },
77 { ...createConflict(kfz.id, g.id), clearingDistance: 20, enteringDistance: 5 },
78 );
79 }
80 // Beide Richtungen mit Weg, damit daraus wirklich eine Zwischenzeit wird.
81 for (const [a, b] of aufbau.feindlich ?? []) {
82 conflicts.push(
83 { ...createConflict(id(a), id(b)), clearingDistance: 5.75, enteringDistance: 0 },
84 { ...createConflict(id(b), id(a)), clearingDistance: 5.75, enteringDistance: 0 },
85 );
86 }
87
88 const phasen = [
89 createPhase('Kfz', [kfz.id]),
90 ...aufbau.phasen.map((namen, i) =>
91 createPhase(
92 `Querung ${i + 1}`,
93 // Bei gleichnamigen Gruppen die Reihenfolge der Erfassung nehmen.
94 namen.map((n, k) => weitere.filter((g) => g.name === n)[k === 0 ? 0 : k]?.id ?? id(n)),
95 ),
96 ),
97 ];
98
99 return {
100 ...leer,
101 intersection: { ...leer.intersection, arms: arme },
102 signalGroups: alle,
103 conflicts,
104 phases: phasen,
105 program: { ...leer.program, phaseOrder: phasen.map((p) => p.id) },
106 };
107 }
108
109 function befunde(project: Project) {
110 return validateProject(project, buildSignalPlan(project), JETZT).findings.filter(
111 (f) => f.rule === REGEL,
112 );
113 }
114
115 /** Zwei Abschnitte einer Furt, wie der Knopf "+ Fußgänger" sie anlegt: ohne Zufahrt. */
116 const OHNE_ZUFAHRT: Aufbau = {
117 arme: ['Nord'],
118 gruppen: [
119 { name: 'F1', mode: 'fuss', arm: null },
120 { name: 'F2', mode: 'fuss', arm: null },
121 ],
122 phasen: [['F1', 'F2']],
123 };
124
125 describe('Der Fall, den 5.38.0 nicht sah: zwei Querungen ohne erfasste Zufahrt', () => {
126 it('meldet die gemeinsame Freigabe auch ohne jede Zufahrt', () => {
127 // Der Knopf "+ Fußgänger" legt keine Zufahrt an. Wer die zweistufige Furt
128 // von Hand nachbaut, hat deshalb im Regelfall zwei Gruppen ohne Zufahrt.
129 const gefunden = befunde(baue(OHNE_ZUFAHRT));
130 expect(gefunden).toHaveLength(1);
131 expect(gefunden[0]?.message).toContain('„F1“');
132 expect(gefunden[0]?.message).toContain('„F2“');
133 });
134
135 it('sagt, dass die Zufahrt fehlt und deshalb nichts eingegrenzt werden kann', () => {
136 expect(befunde(baue(OHNE_ZUFAHRT))[0]?.message).toContain('Zufahrt nicht erfasst');
137 });
138
139 it('meldet auch, wenn nur eine der beiden eine Zufahrt trägt', () => {
140 expect(
141 befunde(
142 baue({
143 ...OHNE_ZUFAHRT,
144 gruppen: [
145 { name: 'F1', mode: 'fuss', arm: 'Nord' },
146 { name: 'F2', mode: 'fuss', arm: null },
147 ],
148 }),
149 ),
150 ).toHaveLength(1);
151 });
152
153 it('meldet auch bei zwei gleichnamigen Gruppen', () => {
154 // Der Befund darf nicht an einer Menge von NAMEN hängen: Wer den zweiten
155 // Abschnitt kopiert und den Namen stehen lässt, hat zweimal "F1".
156 expect(
157 befunde(
158 baue({
159 arme: ['Nord'],
160 gruppen: [
161 { name: 'F1', mode: 'fuss', arm: null },
162 { name: 'F1', mode: 'fuss', arm: null },
163 ],
164 phasen: [['F1', 'F1']],
165 }),
166 ),
167 ).toHaveLength(1);
168 });
169 });
170
171 describe('Die Zufahrt grenzt ein, sie ist keine Bedingung', () => {
172 it('meldet zwei Querungen derselben Zufahrt und nennt sie', () => {
173 const gefunden = befunde(
174 baue({
175 arme: ['Nord'],
176 gruppen: [
177 { name: 'F1', mode: 'fuss', arm: 'Nord' },
178 { name: 'F2', mode: 'fuss', arm: 'Nord' },
179 ],
180 phasen: [['F1', 'F2']],
181 }),
182 );
183 expect(gefunden).toHaveLength(1);
184 expect(gefunden[0]?.message).toContain('Zufahrt „Nord“');
185 });
186
187 it('schweigt bei zwei erfassten, verschiedenen Zufahrten', () => {
188 // Nur das schließt dieselbe Querung aus.
189 expect(
190 befunde(
191 baue({
192 arme: ['Nord', 'Süd'],
193 gruppen: [
194 { name: 'F1', mode: 'fuss', arm: 'Nord' },
195 { name: 'F2', mode: 'fuss', arm: 'Süd' },
196 ],
197 phasen: [['F1', 'F2']],
198 }),
199 ),
200 ).toHaveLength(0);
201 });
202 });
203
204 describe('Wo die Regel sonst schweigt', () => {
205 it('schweigt bei getrennten Phasen', () => {
206 expect(befunde(baue({ ...OHNE_ZUFAHRT, phasen: [['F1'], ['F2']] }))).toHaveLength(0);
207 });
208
209 it('schweigt bei einer Furt neben einer Radquerung', () => {
210 // Furt und Radquerung nebeneinander sind der Regelfall, kein geteiltes Gebilde.
211 expect(
212 befunde(
213 baue({
214 arme: ['Nord'],
215 gruppen: [
216 { name: 'F1', mode: 'fuss', arm: null },
217 { name: 'R1', mode: 'rad', arm: null },
218 ],
219 phasen: [['F1', 'R1']],
220 }),
221 ),
222 ).toHaveLength(0);
223 });
224
225 it('schweigt bei einem als feindlich erfassten Paar', () => {
226 // Dann meldet signalplan.feindliche-freigabe einen FEHLER. Stünde daneben
227 // dieser Befund, sagte der Bericht in zwei Zeilen Gegensätzliches.
228 const project = baue({ ...OHNE_ZUFAHRT, feindlich: [['F1', 'F2']] });
229 expect(befunde(project)).toHaveLength(0);
230 // Welche der beiden Fehlerregeln zuerst greift, entscheidet die
231 // Reihenfolge im Pruefer: Ein feindliches Paar in EINER Phase meldet
232 // phasen.feindlich-in-phase, sonst signalplan.feindliche-freigabe. Beide
233 // sperren die Ausgabe; welche es ist, gehoert nicht hierher.
234 const bericht = validateProject(project, buildSignalPlan(project), JETZT);
235 expect(
236 bericht.findings.some(
237 (f) =>
238 f.rule === 'signalplan.feindliche-freigabe' || f.rule === 'phasen.feindlich-in-phase',
239 ),
240 ).toBe(true);
241 expect(bericht.exportBlocked).toBe(true);
242 });
243
244 it('schweigt bei einer einzigen Querung', () => {
245 expect(
246 befunde(
247 baue({
248 arme: ['Nord'],
249 gruppen: [{ name: 'F1', mode: 'fuss', arm: null }],
250 phasen: [['F1']],
251 }),
252 ),
253 ).toHaveLength(0);
254 });
255 });
256
257 describe('Die geteilte Radquerung ist derselbe Sachverhalt', () => {
258 it('meldet zwei zugleich freigegebene Radquerungen', () => {
259 const gefunden = befunde(
260 baue({
261 arme: ['Nord'],
262 gruppen: [
263 { name: 'R1', mode: 'rad', arm: null },
264 { name: 'R2', mode: 'rad', arm: null },
265 ],
266 phasen: [['R1', 'R2']],
267 }),
268 );
269 expect(gefunden).toHaveLength(1);
270 expect(gefunden[0]?.message).toContain('Radquerungen');
271 });
272 });
273
274 describe('Ein Befund je Paar, und jeder nennt nur sein Paar', () => {
275 /** F1 und F2 zusammen, F3 allein in einer eigenen Phase. */
276 const DREI: Aufbau = {
277 arme: ['Nord'],
278 gruppen: [
279 { name: 'F1', mode: 'fuss', arm: null },
280 { name: 'F2', mode: 'fuss', arm: null },
281 { name: 'F3', mode: 'fuss', arm: null },
282 ],
283 phasen: [['F1', 'F2'], ['F3']],
284 };
285
286 it('meldet nur das Paar, das sich wirklich überschneidet', () => {
287 const gefunden = befunde(baue(DREI));
288 expect(gefunden).toHaveLength(1);
289 expect(gefunden[0]?.message).not.toContain('„F3“');
290 });
291
292 it('meldet bei drei zugleich freigegebenen Querungen drei Paare', () => {
293 // Bis 5.38.0 stand hier EIN Befund mit allen drei Namen und der Behauptung,
294 // sie zeigten gleichzeitig Freigabe - richtig war das nur, wenn sich
295 // wirklich alle drei überschnitten.
296 const gefunden = befunde(baue({ ...DREI, phasen: [['F1', 'F2', 'F3']] }));
297 expect(gefunden).toHaveLength(3);
298 });
299 });
300
301 describe('Was der Befund sagt und wohin er zeigt', () => {
302 it('ist eine Warnung und sperrt die Ausgabe nicht', () => {
303 // Warnung und nicht Hinweis: Dieselbe Bauart wie zwischenzeiten.null - kann
304 // der Regelfall sein, entsteht aber genauso aus einem zu kurz erfassten
305 // Räumweg. Ein Hinweis fiele zudem aus dem Bericht der übrigen
306 // Tagesprogramme heraus.
307 const project = baue(OHNE_ZUFAHRT);
308 const bericht = validateProject(project, buildSignalPlan(project), JETZT);
309 expect(bericht.findings.find((f) => f.rule === REGEL)?.severity).toBe('warnung');
310 expect(bericht.exportBlocked).toBe(false);
311 });
312
313 it('beruft sich nicht auf ein Regelwerk, denn dort steht die Regel nicht', () => {
314 expect(befunde(baue(OHNE_ZUFAHRT))[0]?.reference).not.toContain('RiLSA');
315 });
316
317 it('nennt beide Lesarten, damit der Regelfall nicht wie ein Mangel aussieht', () => {
318 const text = befunde(baue(OHNE_ZUFAHRT))[0]?.message ?? '';
319 expect(text).toContain('zwei getrennte Querungen');
320 expect(text).toContain('Mittelinsel');
321 });
322
323 it('nennt alle drei Größen, die der halbe Weg verkürzt', () => {
324 const text = befunde(baue(OHNE_ZUFAHRT))[0]?.message ?? '';
325 for (const groesse of ['Räumweg', 'Räumzeit', 'Furtregel']) {
326 expect(text, groesse).toContain(groesse);
327 }
328 });
329
330 it('rät nicht dazu, die Abschnitte einfach in getrennte Phasen zu stellen', () => {
331 // Das war der Rat bis 5.38.0. Er führt genau in die Lage, die das Programm
332 // nicht prüft: zwei halbe Furten nacheinander, ohne Nachweis.
333 const rat = befunde(baue(OHNE_ZUFAHRT))[0]?.suggestion ?? '';
334 expect(rat).toContain('Bordkante zu Bordkante');
335 expect(rat).toContain('getrennte Phasen allein machen daraus keine');
336 });
337
338 it('zeigt auf eine der beiden Signalgruppen und nicht auf die ganze Ansicht', () => {
339 expect(befunde(baue(OHNE_ZUFAHRT))[0]?.target?.kind).toBe('signalgruppe');
340 });
341 });
342
343 describe('Die Reihenfolge der Namen hängt nicht an der Eingabe', () => {
344 it('nennt die Gruppen gleich, egal wie sie erfasst sind', () => {
345 const vorwaerts = befunde(baue(OHNE_ZUFAHRT))[0]?.title;
346 const rueckwaerts = befunde(
347 baue({
348 arme: ['Nord'],
349 gruppen: [
350 { name: 'F2', mode: 'fuss', arm: null },
351 { name: 'F1', mode: 'fuss', arm: null },
352 ],
353 phasen: [['F2', 'F1']],
354 }),
355 )[0]?.title;
356 expect(vorwaerts).toBe(rueckwaerts);
357 expect(vorwaerts?.indexOf('F1')).toBeLessThan(vorwaerts?.indexOf('F2') ?? -1);
358 });
359 });
360
361 describe('Der Regelplan bleibt still', () => {
362 it('meldet nichts an einem Knotenpunkt mit einer Furt je Zufahrt', () => {
363 // Was überall meldet, sagt nichts. Der Regelfall muss still bleiben.
364 expect(
365 befunde(
366 baue({
367 arme: ['Nord', 'Ost', 'Süd', 'West'],
368 gruppen: [
369 { name: 'F1', mode: 'fuss', arm: 'Nord' },
370 { name: 'F2', mode: 'fuss', arm: 'Ost' },
371 { name: 'F3', mode: 'fuss', arm: 'Süd' },
372 { name: 'F4', mode: 'fuss', arm: 'West' },
373 ],
374 phasen: [['F1', 'F2', 'F3', 'F4']],
375 }),
376 ),
377 ).toHaveLength(0);
378 });
379 });