lsa-planer

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

/ tests domain gleichstandOhneReihenfolge.test.ts

27,4 KB Rohdatei
tests/domain/gleichstandOhneReihenfolge.test.ts — 654 Zeilen
1 import { readFileSync } from 'node:fs';
2 import { describe, expect, it } from 'vitest';
3 import { leiteAb, uebernimmWege, wegSchluessel } from '@/domain/geometrie/ableitung';
4 import { parseProject } from '@/domain/model/schema';
5 import {
6 createConflict,
7 createEmptyProject,
8 createPhase,
9 createSignalGroup,
10 createStandardIntersectionProject,
11 } from '@/domain/model/factory';
12 import { buildSignalPlan } from '@/domain/plan/signalPlan';
13 import { koordinierungslage } from '@/domain/plan/koordinierung';
14 import { distributeGreenTimes } from '@/domain/rilsa/greenSplit';
15 import * as actions from '@/app/actions';
16 import type { SignalPlan } from '@/domain/plan/signalPlan';
17 import type { Project } from '@/domain/model/project';
18
19 /**
20 * Gleichstand entscheidet nicht mehr die Reihenfolge.
21 *
22 * WORUM ES GEHT. Fassung 5.23.0 hat einen Fall behoben, in dem zwei in allem
23 * gleiche Richtungen einer Baustellenampel verschieden lange Freigabezeiten
24 * bekamen, weil bei gleichem Gewicht der Array-Index entschied. Die Frage
25 * danach war, ob es weitere solche Stellen gibt. Eine Suche ueber den Baum hat
26 * vier gefunden - alle von derselben Gestalt: ein Vergleich mit `>`, bei dem
27 * der Gleichstand still an die Feldreihenfolge faellt. Eine FUENFTE ist mit
28 * Fassung 5.43.0 dazugekommen (Abschnitt E): Die Suche vom
29 * 10.09.2026 konnte sie nicht finden, denn die Koordinierungskette ist erst mit
30 * 5.35.0 in den Baum gekommen. Diese Datei bewacht alle fuenf.
31 *
32 * DREI DER VIER SIND ENTSCHIEDEN, EINER IST AUSGESPROCHEN, und der Unterschied
33 * ist der Kern der Sache:
34 *
35 * A Uebernahme aus dem Lageplan (geometrie/ableitung.ts). Zwei Strompaare
36 * desselben Gruppenpaares runden auf dieselbe Zwischenzeit auf. Jetzt
37 * entscheidet der ungerundete Wert - eine fachliche Auskunft, die vorher
38 * ungenutzt blieb.
39 *
40 * B Massgebende Beziehung eines Phasenuebergangs (plan/signalPlan.ts). Erst
41 * die aufgerundete Zwischenzeit, dann der ungerundete Wert, dann der
42 * Raeumweg - und zuletzt der Name, der nichts bedeutet, sich aber nicht
43 * verschiebt, wenn jemand eine Signalgruppe neu zuordnet.
44 *
45 * C Warnung zur Hoechstfreigabezeit (plan/signalPlan.ts). Tragen zwei
46 * Signalgruppen derselben Phase dieselbe wirkungslose Vorgabe, werden jetzt
47 * beide genannt. Es gibt nichts zu waehlen - es waren nur nicht alle
48 * genannt.
49 *
50 * D Die zusaetzliche Sekunde beim Summenausgleich (rilsa/greenSplit.ts). Hier
51 * laesst sich nichts entscheiden: Ist die verfuegbare Zeit ungerade, MUSS
52 * eine von zwei gleichgestellten Phasen eine Sekunde mehr bekommen. Jede
53 * Regel dafuer waere so beliebig wie die jetzige. Geaendert ist, dass es
54 * dasteht.
55 *
56 * E Das Freigabefenster der koordinierten Hauptrichtung
57 * (plan/koordinierung.ts, Fassung 5.43.0). Hier GIBT es eine
58 * feinere fachliche Auskunft, und sie entscheidet jetzt: welches der beiden
59 * gleich langen Fenster ueberhaupt ein durchgehendes Band ergibt. Erst bei
60 * gleicher Bandbreite eine stabile Ordnung, und dass DIESE nichts bedeutet,
61 * steht im Quelltext. Die Ansicht meldet den Fall ausserdem.
62 *
63 * MUTATIONSNACHWEIS (10.09.2026, acht Mutationen an drei Dateien): Jede wird
64 * gefunden. Zwei Mutationen an der Uebernahme, zwei am Rang des Uebergangs,
65 * zwei an der Warnung, zwei an der Restsekunde - je einer bis zwei rote Faelle.
66 *
67 * EINE BLIEB IM ERSTEN ANLAUF GRUEN, und das gehoert hierher: Laesst man den
68 * Rang des Uebergangs auf die aufgerundete Zwischenzeit zusammenfallen, meldete
69 * sich nichts. Der Fall an der Beispieldatei prueft die Regel dort naemlich
70 * nicht - das Umdrehen der Gruppenzuordnung kehrt die Reihenfolge der beiden
71 * gleichauf liegenden Paare gar nicht um. Erst der eigens gebaute Fall mit zwei
72 * Beziehungen (4,2 s und 4,8 s, beide 5 s) hat Zaehne.
73 */
74
75 const DATEI = 'beispiele/vierarmiger-knotenpunkt.lsap';
76 const DATUM = new Date('2026-01-01T00:00:00Z');
77
78 function beispiel(): Project {
79 return parseProject(JSON.parse(readFileSync(DATEI, 'utf8')), DATUM).project;
80 }
81
82 /** Dieselbe Datei nach "Ausgewählte übernehmen" ueber alle Beziehungen. */
83 function nachUebernahme(basis: Project): Project {
84 const ableitung = leiteAb(basis, basis.lageplan);
85 return uebernimmWege(basis, basis.lageplan, ableitung, ableitung.wege.map(wegSchluessel), DATUM)
86 .project;
87 }
88
89 /** Dasselbe Projekt mit umgekehrter Zeichenreihenfolge der Fahrlinien. */
90 function mitUmgekehrtenLinien(basis: Project): Project {
91 return {
92 ...basis,
93 lageplan: { ...basis.lageplan, linien: [...basis.lageplan.linien].reverse() },
94 };
95 }
96
97 /** Die uebernommene Geometrie je Beziehung, in vergleichbarer Form. */
98 function geometrie(project: Project): string[] {
99 return project.conflicts
100 .map(
101 (c) =>
102 `${c.fromId}->${c.toId} sr=${c.clearingDistance.toFixed(3)} se=${c.enteringDistance.toFixed(3)} rb=${String(c.raeumbeziehung)}`,
103 )
104 .sort();
105 }
106
107 describe('A: Die Uebernahme aus dem Lageplan haengt nicht an der Zeichenreihenfolge', () => {
108 it('liefert dieselbe Geometrie, wenn die Fahrlinien umgekehrt gezeichnet sind', () => {
109 /*
110 * DER EIGENTLICHE FALL. Bis 5.23.0 gewann bei gleicher aufgerundeter
111 * Zwischenzeit der zuerst gezeichnete Strom, und mit ihm wurden sein
112 * Raeumweg, sein Einfahrweg und seine Fahrbeziehung gespeichert. Wer
113 * dieselbe Oertlichkeit in anderer Reihenfolge zeichnete, bekam eine
114 * andere Projektdatei und eine andere gedruckte Konflikttabelle.
115 */
116 const vorwaerts = nachUebernahme(beispiel());
117 const rueckwaerts = nachUebernahme(mitUmgekehrtenLinien(beispiel()));
118 expect(geometrie(rueckwaerts)).toEqual(geometrie(vorwaerts));
119 });
120
121 it('nimmt bei gleicher Zwischenzeit den anspruchsvolleren Strom', () => {
122 /*
123 * Die eigene Furt: "Nord geradeaus" fordert roh 4,100 s (bis 5.42.1
124 * 4,013 s; seither faehrt der Fussgaenger mit te = 0 s ein), "Nord rechts"
125 * fordert 4,591 s, beide runden auf 5 s. Gespeichert wird seit 5.24.0 der
126 * Rechtsabbieger - der Strom, der massgebend BLEIBT, wenn sich Gelbzeit
127 * oder zulaessige Hoechstgeschwindigkeit spaeter aendern. Die gedruckte
128 * Zwischenzeit ist in beiden Faellen dieselbe.
129 */
130 const project = nachUebernahme(beispiel());
131 const furt = project.conflicts.find((c) => c.fromId === 'sg-k-nord' && c.toId === 'sg-f-nord');
132 expect(furt, 'Beziehung K Nord -> F Nord').toBeDefined();
133 expect(furt!.raeumbeziehung, 'der Rechtsabbieger gibt den Ausschlag').toBe('rechts');
134 const plan = buildSignalPlan(project);
135 expect(
136 [...plan.intergreens.values()].find((i) => i.fromId === 'sg-k-nord' && i.toId === 'sg-f-nord')
137 ?.value,
138 'die gedruckte Zahl bleibt dieselbe',
139 ).toBe(5);
140 });
141 });
142
143 describe('B: Die massgebende Beziehung eines Uebergangs haengt nicht an der Gruppenreihenfolge', () => {
144 /** Dasselbe Projekt mit umgekehrter Zuordnung der Gruppen in jeder Phase. */
145 function mitUmgekehrtenGruppen(basis: Project): Project {
146 return {
147 ...basis,
148 phases: basis.phases.map((p) => ({
149 ...p,
150 signalGroupIds: [...p.signalGroupIds].reverse(),
151 })),
152 };
153 }
154
155 const beschriftung = (project: Project): string[] =>
156 buildSignalPlan(project).transitions.map(
157 (t) =>
158 `${t.duration}:${t.critical === null ? '-' : `${t.critical.fromGroupName}->${t.critical.toGroupName}`}`,
159 );
160
161 it('nennt dieselbe Beziehung, wenn die Signalgruppen der Phase umgekehrt zugeordnet sind', () => {
162 /*
163 * Nachgestellt aus der Bedienung: In der Phasenansicht eine Signalgruppe
164 * abwaehlen und wieder anwaehlen haengt sie hinten an. Bis 5.23.0 stand
165 * danach in der Uebergangstabelle der Planunterlage ein anderes Paar, bei
166 * Byte fuer Byte demselben Plan.
167 */
168 const basis = beispiel();
169 expect(beschriftung(mitUmgekehrtenGruppen(basis))).toEqual(beschriftung(basis));
170 });
171
172 /**
173 * Zwei Beziehungen mit derselben gedruckten Zwischenzeit und verschiedenem
174 * ungerundeten Wert - eigens dafuer gebaut.
175 *
176 * WARUM ES DIESEN FALL BRAUCHT. Der Fall darueber laeuft an der
177 * Beispieldatei, und dort war er ZAHNLOS: Eine Mutation, die den Rang auf
178 * die aufgerundete Zwischenzeit zusammenfallen laesst, blieb gruen. Am
179 * Beispielknotenpunkt kehrt das Umdrehen der Gruppenzuordnung die
180 * Reihenfolge der beiden gleichauf liegenden Paare gar nicht um - der Fall
181 * prueft dort also die Regel nicht, sondern eine Zufaelligkeit. Hier stehen
182 * genau zwei Paare, und ihre Reihenfolge kehrt sich wirklich um.
183 *
184 * tue = 3 s, vr = 10 m/s, Pkw-Zuschlag 6 m, kein Einfahrweg:
185 * 6 m ergeben roh 4,2 s, 12 m ergeben roh 4,8 s - beide 5 s.
186 */
187 function zweiGleichauf(raeumwege: readonly [number, number]): Project {
188 const basis = createEmptyProject('Gleichauf', DATUM, 'knotenpunkt');
189 const gruppe = (name: string, index: number) =>
190 createSignalGroup({ name, mode: 'kfz' as const, vZul: 50, index });
191 const a = gruppe('A', 0);
192 const b = gruppe('B', 1);
193 const c = gruppe('C', 2);
194 const d = gruppe('D', 3);
195 const p1 = createPhase('Erste', [a.id, b.id]);
196 const p2 = createPhase('Zweite', [c.id, d.id]);
197 const beziehung = (von: string, nach: string, weg: number) => ({
198 ...createConflict(von, nach),
199 clearingDistance: weg,
200 enteringDistance: 0,
201 });
202 return {
203 ...basis,
204 signalGroups: [a, b, c, d],
205 phases: [p1, p2],
206 conflicts: [
207 beziehung(a.id, c.id, raeumwege[0]),
208 beziehung(b.id, d.id, raeumwege[1]),
209 beziehung(c.id, a.id, raeumwege[0]),
210 beziehung(d.id, b.id, raeumwege[1]),
211 ],
212 program: { ...basis.program, phaseOrder: [p1.id, p2.id] },
213 };
214 }
215
216 it('waehlt bei gleicher gedruckter Zwischenzeit die Beziehung mit dem groesseren Rohwert', () => {
217 const plan = buildSignalPlan(zweiGleichauf([6, 12]));
218 const erster = plan.transitions[0];
219 expect(erster, 'erster Uebergang').toBeDefined();
220 expect(erster!.duration, 'beide runden auf 5 s').toBe(5);
221 expect(
222 [...plan.intergreens.values()].map((i) => i.value).every((v) => v === 5),
223 'wirklich Gleichstand in der gedruckten Zahl',
224 ).toBe(true);
225 // B -> D fordert roh 4,8 s, A -> C nur 4,2 s.
226 expect(erster!.critical?.fromGroupName).toBe('B');
227 expect(erster!.critical?.toGroupName).toBe('D');
228 });
229
230 it('waehlt sie auch, wenn die Gruppen umgekehrt zugeordnet sind', () => {
231 // Ohne den Rang entschiede hier die Reihenfolge, und die Antwort kippte.
232 const basis = zweiGleichauf([6, 12]);
233 expect(beschriftung(mitUmgekehrtenGruppen(basis))).toEqual(beschriftung(basis));
234 expect(beschriftung(basis)[0]).toContain('B->D');
235 });
236
237 it('kehrt die Antwort mit dem Rohwert um, wenn die Wege getauscht sind', () => {
238 // Die Gegenprobe zum Fall darueber: Nicht der Name "B" gewinnt, sondern
239 // der groessere Rohwert - sonst pruefte der Fall nur eine Konstante.
240 const plan = buildSignalPlan(zweiGleichauf([12, 6]));
241 expect(plan.transitions[0]?.critical?.fromGroupName).toBe('A');
242 expect(plan.transitions[0]?.critical?.toGroupName).toBe('C');
243 });
244
245 it('bleibt die Dauer der Uebergaenge davon ohnehin unberuehrt', () => {
246 // Die Gegenprobe: Der Fall oben waere auch dann gruen, wenn die Uebergaenge
247 // gar keine Beschriftung mehr traegen. Hier steht, dass es sie gibt.
248 const plan = buildSignalPlan(beispiel());
249 expect(plan.transitions.length).toBeGreaterThan(0);
250 expect(plan.transitions.filter((t) => t.critical !== null).length).toBe(
251 plan.transitions.length,
252 );
253 expect(plan.cycleTime).toBe(100);
254 });
255
256 it('meldet keine massgebende Beziehung, wo alle Zwischenzeiten 0 s sind', () => {
257 /*
258 * `critical === null` heisst "keine Beziehung mit einer Zwischenzeit ueber
259 * 0 s"; daran haengen die Uebergangszeile der Ansicht und der Fehler
260 * zwischenzeiten.uebergang-ungesichert. Beim Umbau auf den Rang ist diese
261 * Bedingung einmal herausgefallen - drei Wachen haben es gemeldet, und
262 * dieser Fall haelt sie an Ort und Stelle fest.
263 */
264 const basis = createStandardIntersectionProject('Null', DATUM);
265 const ohneWege: Project = {
266 ...basis,
267 conflicts: basis.conflicts.map((c) => ({
268 ...c,
269 clearingDistance: 0,
270 enteringDistance: 0,
271 manualIntergreen: 0,
272 })),
273 };
274 const plan = buildSignalPlan(ohneWege);
275 expect([...plan.intergreens.values()].every((i) => i.value === 0)).toBe(true);
276 expect(plan.transitions.every((t) => t.critical === null)).toBe(true);
277 });
278 });
279
280 describe('C: Die Warnung zur Hoechstfreigabezeit nennt jede betroffene Signalgruppe', () => {
281 /** Standardvorlage mit derselben Vorgabe an mehreren Gruppen der ersten Phase. */
282 function mitVorgabe(sekunden: number, gruppen: readonly string[]): Project {
283 const basis = createStandardIntersectionProject('Gleichstand', DATUM);
284 return {
285 ...basis,
286 signalGroups: basis.signalGroups.map((g) =>
287 gruppen.includes(g.name) ? { ...g, maxGreenOverride: sekunden } : g,
288 ),
289 };
290 }
291
292 const meldung = (project: Project): string => {
293 const notiz = buildSignalPlan(project).notes.find(
294 (n) => n.code === 'hoechstfreigabezeit-unter-mindestfreigabezeit',
295 );
296 expect(notiz, 'Warnung zur Hoechstfreigabezeit').toBeDefined();
297 return notiz!.message;
298 };
299
300 it('nennt beide Gruppen, wo beide dieselbe wirkungslose Vorgabe tragen', () => {
301 const text = meldung(mitVorgabe(3, ['K1', 'K2']));
302 expect(text, 'die erste Gruppe').toContain('"K1"');
303 expect(text, 'die zweite ebenso').toContain('"K2"');
304 expect(text, 'im Plural').toContain('Für die Signalgruppen');
305 });
306
307 it('bleibt bei einer Gruppe im Singular', () => {
308 // Gegenprobe: Der Wortlaut des Regelfalls darf sich nicht geaendert haben.
309 const text = meldung(mitVorgabe(3, ['K1']));
310 expect(text).toContain('Für die Signalgruppe "K1" ist');
311 expect(text).not.toContain('Signalgruppen');
312 });
313
314 it('sagt dasselbe, wenn die Gruppen der Phase umgekehrt zugeordnet sind', () => {
315 const basis = mitVorgabe(3, ['K1', 'K2']);
316 const umgekehrt: Project = {
317 ...basis,
318 phases: basis.phases.map((p) => ({
319 ...p,
320 signalGroupIds: [...p.signalGroupIds].reverse(),
321 })),
322 };
323 // Bis 5.23.0 wechselte hier der genannte Name, bei unveraendertem Plan.
324 expect(meldung(umgekehrt)).toBe(meldung(basis));
325 });
326 });
327
328 describe('D: Die zusaetzliche Sekunde bei gleichgestellten Phasen steht in der Unterlage', () => {
329 /** Zwei in allem gleiche Richtungen, je 7 s Uebergang. */
330 const zweiGleiche = (umlauf: number, gewicht = 0) =>
331 distributeGreenTimes(
332 umlauf,
333 [
334 { id: 'a', weight: gewicht, minGreen: 5 },
335 { id: 'b', weight: gewicht, minGreen: 5 },
336 ],
337 [7, 7],
338 );
339
340 const restsekunde = (umlauf: number, gewicht = 0) =>
341 zweiGleiche(umlauf, gewicht).notes.find((n) => n.code === 'freigabezeit-restsekunde');
342
343 it('meldet sich, wo die verfuegbare Zeit nicht aufgeht', () => {
344 const ergebnis = zweiGleiche(99);
345 expect(
346 ergebnis.phases.map((p) => p.green),
347 'eine Sekunde Unterschied',
348 ).toEqual([43, 42]);
349 const notiz = restsekunde(99);
350 expect(notiz, 'die Meldung zur Restsekunde').toBeDefined();
351 expect(notiz!.severity).toBe('hinweis');
352 expect(notiz!.message).toContain('85 s lässt sich nicht ganzzahlig auf 2 Phasen aufteilen');
353 expect(notiz!.message, 'wer sie bekommt').toContain(
354 'Je eine zusätzliche Sekunde geht an Phase 1',
355 );
356 expect(notiz!.message, 'und dass es dafuer keinen Grund gibt').toContain(
357 'Ein sachlicher Grund für diese Auswahl besteht nicht',
358 );
359 });
360
361 it('meldet sich auch bei erfassten, gleich grossen Verkehrsstaerken', () => {
362 // Der Fall, der bis 5.23.0 GAR KEINE Meldung trug: Zwei Phasen mit
363 // demselben Saettigungsgrad, 43 s und 42 s im Plan, kein Wort dazu.
364 expect(zweiGleiche(99, 0.4).phases.map((p) => p.green)).toEqual([43, 42]);
365 expect(restsekunde(99, 0.4), 'auch hier').toBeDefined();
366 });
367
368 it('schweigt, wo die Zeit aufgeht', () => {
369 expect(zweiGleiche(100).phases.map((p) => p.green)).toEqual([43, 43]);
370 expect(restsekunde(100), 'ohne Rest kein Hinweis').toBeUndefined();
371 });
372
373 it('schweigt, wo der Unterschied begruendet ist', () => {
374 /*
375 * Die wichtigste Gegenprobe: Verschiedene Verkehrsstaerken ergeben
376 * verschiedene Freigabezeiten, und daran ist nichts zu erklaeren. Eine
377 * Meldung hier waere Laerm.
378 */
379 const ergebnis = distributeGreenTimes(
380 99,
381 [
382 { id: 'a', weight: 0.6, minGreen: 5 },
383 { id: 'b', weight: 0.2, minGreen: 5 },
384 ],
385 [7, 7],
386 );
387 expect(ergebnis.notes.map((n) => n.code)).not.toContain('freigabezeit-restsekunde');
388 });
389 });
390
391 /*
392 * E: Das Freigabefenster der koordinierten Hauptrichtung (plan/koordinierung.ts).
393 *
394 * DIE FUENFTE STELLE, behoben mit Fassung 5.43.0. Der Kopf
395 * dieser Datei nannte vier; eine hatte die Suche vom 10.09.2026 nicht gesehen,
396 * weil die Koordinierungskette erst mit 5.35.0 dazugekommen ist.
397 *
398 * Eine Signalgruppe kann in getrennten Phasen freigegeben sein. Fuer die Gruene
399 * Welle traegt das laengste Fenster - durch das kuerzere passt der Pulk nicht -,
400 * und `fenster` suchte es mit `g.duration > laengstes.duration`. Sind zwei
401 * Fenster gleich lang, gewinnt damit das, das in `greens` zuerst steht, also die
402 * Phasenreihenfolge. Das ist genau die Gestalt der vier Faelle darueber.
403 *
404 * ES GIBT HIER EINE FEINERE FACHLICHE AUSKUNFT, und der Grundsatz verlangt, zuerst
405 * nach ihr zu suchen: WELCHES DER BEIDEN FENSTER EIN BAND ERGIBT. Am
406 * Knotenpunkt dieses Abschnitts ergibt das eine 9 s Bandbreite, das andere 0 s
407 * und den Fehler "keine durchgehende Bandbreite" - fuer denselben Plan. Die
408 * Wahl wird deshalb am Band getroffen; die stabile Ordnung greift erst bei
409 * gleicher Bandbreite, und dass SIE nichts bedeutet, steht im Quelltext daneben.
410 *
411 * BERICHTIGT (Fassung 5.43.0): Hier stand "ES GIBT HIER KEINE FEINERE FACHLICHE
412 * AUSKUNFT ... die Bandbreite bleibt dieselbe", und die beiden Groessen waren
413 * vertauscht. Die Bandbreite haengt an der Wahl, die Versatzzeitempfehlungen
414 * haengen nicht daran: Sie entstehen in `coordination.ts` allein aus Abstand
415 * und Progressionsgeschwindigkeit, die Bandbreite dagegen als Schnittmenge
416 * aller Fenster - und die der Nachbarn sind Eingabedaten, die sich nicht
417 * mitverschieben. Der Fall darunter, der das belegt, bewachte deshalb genau die
418 * Groesse, die von der Wahl unberuehrt ist.
419 */
420 describe('E: Das Freigabefenster der Hauptrichtung haengt nicht an der Fensterreihenfolge', () => {
421 /**
422 * Vier Phasen, die Hauptrichtung in der ersten und der dritten - zwei
423 * getrennte Fenster gleicher Laenge.
424 *
425 * `nachbar` legt fest, ab wann der Nachbar in 200 m freigibt, und damit,
426 * WELCHES der beiden Fenster ein Band traegt: bei 20 s das im Umlauf fruehere,
427 * bei 50 s das spaetere. Beides wird geprueft - sonst waere "das mit dem Band"
428 * von "immer das fruehere" nicht zu unterscheiden.
429 *
430 * `gegenrichtung` haengt der Kette die Rueckrichtung an (Signalgruppe B, frei
431 * in der zweiten und vierten Phase, ebenfalls zwei gleich lange Fenster).
432 */
433 function zweiFenster(
434 nachbar = 20,
435 gegenrichtung: { gegenBeginn: number; gegenDauer: number } | null = null,
436 ): Project {
437 const basis = createEmptyProject('Zwei Fenster', DATUM, 'knotenpunkt');
438 const a = createSignalGroup({ name: 'A', mode: 'kfz' as const, vZul: 50, index: 0 });
439 const b = createSignalGroup({ name: 'B', mode: 'kfz' as const, vZul: 50, index: 1 });
440 const phasen = [
441 createPhase('P1', [a.id]),
442 createPhase('P2', [b.id]),
443 createPhase('P3', [a.id]),
444 createPhase('P4', [b.id]),
445 ];
446 const beziehung = (von: string, nach: string) => ({
447 ...createConflict(von, nach),
448 clearingDistance: 20,
449 enteringDistance: 0,
450 });
451 let projekt: Project = {
452 ...basis,
453 signalGroups: [a, b],
454 phases: phasen,
455 conflicts: [beziehung(a.id, b.id), beziehung(b.id, a.id)],
456 program: {
457 ...basis.program,
458 method: 'manuell',
459 manualCycleTime: 60,
460 phaseOrder: phasen.map((p) => p.id),
461 },
462 };
463 projekt = actions.setzeKoordinierung(projekt, {
464 hauptrichtung: a.id,
465 ...(gegenrichtung === null ? {} : { gegenrichtung: b.id }),
466 progressionsgeschwindigkeit: 36,
467 platz: 0,
468 });
469 projekt = actions.koordinierungsknotenAnlegen(projekt, 'West');
470 const west = projekt.koordinierung!.nachbarn[0]!;
471 return actions.koordinierungsknotenAendern(projekt, west.id, {
472 abstand: 200,
473 freigabeBeginn: nachbar,
474 freigabeDauer: 12,
475 ...(gegenrichtung ?? {}),
476 });
477 }
478
479 /** Nur ein Fenster einer Signalgruppe im Plan lassen - fuer die Messung. */
480 function nurFenster(plan: SignalPlan, groupId: string, index: number): SignalPlan {
481 return {
482 ...plan,
483 groups: plan.groups.map((g) =>
484 g.groupId === groupId ? { ...g, greens: g.greens.slice(index, index + 1) } : g,
485 ),
486 };
487 }
488
489 /** Das gewaehlte Freigabefenster dieses Knotenpunkts. */
490 function eigenes(projekt: Project, plan: SignalPlan) {
491 const lage = koordinierungslage(projekt, plan);
492 const glied = lage.kette.find((k) => k.eigener);
493 return {
494 freigabe: glied?.freigabe,
495 gegenrichtung: glied?.gegenrichtung,
496 bandbreite: lage.ergebnis?.bandwidth,
497 rueckbreite: lage.ergebnis?.rueckrichtung?.breite,
498 meldungen: lage.ergebnis?.notes.map((n) => n.code) ?? [],
499 versatz: lage.ergebnis?.intersections,
500 };
501 }
502
503 /** Derselbe Plan mit umgekehrter Reihenfolge der Freigabefenster. */
504 function mitUmgekehrtenFenstern(plan: SignalPlan, groupId: string): SignalPlan {
505 return {
506 ...plan,
507 groups: plan.groups.map((g) =>
508 g.groupId === groupId ? { ...g, greens: [...g.greens].reverse() } : g,
509 ),
510 };
511 }
512
513 it('baut wirklich zwei gleich lange Fenster', () => {
514 // Vorbedingung des Falls darunter, und zugleich der Beleg, dass die Lage
515 // aus gewoehnlicher Eingabe entsteht und nicht von Hand gebaut ist.
516 const projekt = zweiFenster();
517 const gruppe = buildSignalPlan(projekt).groups.find(
518 (g) => g.groupId === projekt.koordinierung!.hauptrichtung,
519 );
520 expect(gruppe?.greens.map((g) => g.duration)).toEqual([9, 9]);
521 expect(gruppe?.greens.map((g) => g.start)).toEqual([0, 30]);
522 });
523
524 it('die beiden Fenster sind fuer das Band nicht gleichwertig', () => {
525 /*
526 * DER BELEG, DASS ES EINE FEINERE AUSKUNFT GIBT, und die Messung, auf die
527 * sich der Kommentar in koordinierung.ts beruft. Ohne diesen Fall waere die
528 * Begruendung der Wahl wieder eine Behauptung - genau der Befund, aus dem
529 * diese Berichtigung entstanden ist.
530 */
531 const projekt = zweiFenster();
532 const haupt = projekt.koordinierung!.hauptrichtung!;
533 const plan = buildSignalPlan(projekt);
534 const mitErstem = eigenes(projekt, nurFenster(plan, haupt, 0));
535 const mitZweitem = eigenes(projekt, nurFenster(plan, haupt, 1));
536
537 expect(mitErstem.bandbreite, 'das Fenster ab 0 s traegt ein Band').toBe(9);
538 expect(mitZweitem.bandbreite, 'das Fenster ab 30 s traegt keines').toBe(0);
539 expect(mitZweitem.meldungen, 'und meldet dafuer einen Fehler').toContain('keine-bandbreite');
540 /*
541 * Die andere Haelfte der berichtigten Begruendung: Die Versatzzeiten
542 * beruehrt die Wahl NICHT. Sie entstehen aus Abstand und
543 * Progressionsgeschwindigkeit. Dieser Fall haelt fest, warum die Wahl am
544 * Band zu treffen ist und nicht an ihnen.
545 */
546 expect(mitZweitem.versatz, 'die Versatzzeiten haengen nicht am Fenster').toEqual(
547 mitErstem.versatz,
548 );
549 });
550
551 it('waehlt das Fenster mit dem Band, gleich wie die beiden im Plan stehen', () => {
552 const projekt = zweiFenster();
553 const haupt = projekt.koordinierung!.hauptrichtung!;
554 const plan = buildSignalPlan(projekt);
555
556 for (const [wie, p] of [
557 ['wie gebaut', plan],
558 ['umgekehrt', mitUmgekehrtenFenstern(plan, haupt)],
559 ] as const) {
560 const gewaehlt = eigenes(projekt, p);
561 expect(gewaehlt.freigabe, wie).toEqual({ start: 0, duration: 9 });
562 expect(gewaehlt.bandbreite, wie).toBe(9);
563 expect(gewaehlt.meldungen, wie).not.toContain('keine-bandbreite');
564 }
565 });
566
567 it('nimmt dafuer auch das spaetere Fenster, wenn dieses das Band traegt', () => {
568 /*
569 * DIE GEGENPROBE ZUR STABILEN ORDNUNG: Gibt der Nachbar erst ab 50 s frei,
570 * traegt das Fenster ab 30 s das Band und das ab 0 s keines. Ohne diesen
571 * Fall waere "das Fenster mit dem Band" von "immer das im Umlauf fruehere"
572 * nicht zu unterscheiden - und genau diese Verwechslung war der Stand vor
573 * der Berichtigung.
574 */
575 const projekt = zweiFenster(50);
576 const haupt = projekt.koordinierung!.hauptrichtung!;
577 const plan = buildSignalPlan(projekt);
578
579 expect(eigenes(projekt, nurFenster(plan, haupt, 0)).bandbreite, 'Vorbedingung').toBe(0);
580 for (const p of [plan, mitUmgekehrtenFenstern(plan, haupt)]) {
581 const gewaehlt = eigenes(projekt, p);
582 expect(gewaehlt.freigabe).toEqual({ start: 30, duration: 9 });
583 expect(gewaehlt.bandbreite).toBe(9);
584 }
585 });
586
587 it('waehlt das Fenster der Gegenrichtung an deren eigenem Band', () => {
588 /*
589 * DIESELBE STELLE, ZWEITE RICHTUNG: `fenster` hat die Gegenrichtung mit
590 * demselben Vergleich gesucht, also mit demselben Gleichstand. Hier traegt
591 * das im Umlauf SPAETERE der beiden Fenster das Rueckband - waehlte die
592 * Reihenfolge, entstuende die Warnung "kein durchgehendes Band in der
593 * Gegenrichtung" fuer einen Plan, der eines hat.
594 */
595 const projekt = zweiFenster(20, { gegenBeginn: 26, gegenDauer: 12 });
596 const gegen = projekt.koordinierung!.gegenrichtung!;
597 const plan = buildSignalPlan(projekt);
598 expect(
599 plan.groups.find((g) => g.groupId === gegen)?.greens,
600 'Vorbedingung: zwei gleich lange Fenster der Gegenrichtung',
601 ).toEqual([
602 { start: 15, duration: 9 },
603 { start: 45, duration: 9 },
604 ]);
605
606 const gewaehlt = eigenes(projekt, plan);
607 expect(gewaehlt.gegenrichtung).toEqual({ start: 45, duration: 9 });
608 expect(gewaehlt.rueckbreite).toBe(8);
609 expect(gewaehlt.meldungen).not.toContain('keine-bandbreite-gegenrichtung');
610 // Und die Hinrichtung bleibt davon unberuehrt.
611 expect(gewaehlt.freigabe).toEqual({ start: 0, duration: 9 });
612 });
613
614 it('nimmt weiterhin das laengere Fenster, wo es eines gibt', () => {
615 /*
616 * Die Gegenprobe: Nicht das fruehere gewinnt, sondern das laengere. Ohne
617 * diesen Fall waere der vorige auch mit "immer das erste" gruen.
618 */
619 const projekt = zweiFenster();
620 const haupt = projekt.koordinierung!.hauptrichtung!;
621 const plan = buildSignalPlan(projekt);
622 const verlaengert: SignalPlan = {
623 ...plan,
624 groups: plan.groups.map((g) =>
625 g.groupId === haupt
626 ? { ...g, greens: [g.greens[0]!, { ...g.greens[1]!, duration: 12 }] }
627 : g,
628 ),
629 };
630 expect(koordinierungslage(projekt, verlaengert).kette.find((k) => k.eigener)?.freigabe).toEqual(
631 {
632 start: 30,
633 duration: 12,
634 },
635 );
636 // Auch wenn es vorne steht.
637 expect(
638 koordinierungslage(projekt, mitUmgekehrtenFenstern(verlaengert, haupt)).kette.find(
639 (k) => k.eigener,
640 )?.freigabe,
641 ).toEqual({ start: 30, duration: 12 });
642 });
643
644 it('sagt der Ansicht, dass es mehrere Fenster sind', () => {
645 /*
646 * Eine Auswahl, die niemand getroffen hat, gehoert ausgesprochen. Gemeldet
647 * wird, DASS es mehrere sind - nicht, welches gilt; der Hinweistext dazu
648 * steht in ui/views/koordinierungView.ts und nennt das gewaehlte Fenster
649 * bisher nicht (offener Punkt seit Fassung 5.43.0).
650 */
651 const projekt = zweiFenster();
652 expect(koordinierungslage(projekt, buildSignalPlan(projekt)).mehrereFreigabefenster).toBe(true);
653 });
654 });