lsa-planer

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

/ tests ui fensterwahlDerKoordinierung.test.ts

12,5 KB Rohdatei
tests/ui/fensterwahlDerKoordinierung.test.ts — 324 Zeilen
1 import { describe, expect, it } from 'vitest';
2 import * as actions from '@/app/actions';
3 import { ProjectStore } from '@/app/store';
4 import {
5 createConflict,
6 createEmptyProject,
7 createPhase,
8 createSignalGroup,
9 createStandardIntersectionProject,
10 } from '@/domain/model/factory';
11 import { koordinierungslage } from '@/domain/plan/koordinierung';
12 import { buildSignalPlan } from '@/domain/plan/signalPlan';
13 import { koordinierungView } from '@/ui/views/koordinierungView';
14 import type { Project } from '@/domain/model/project';
15 import type { SignalPlan } from '@/domain/plan/signalPlan';
16
17 /**
18 * Fassung 5.43.0: Was die Ansicht "Koordinierung" ueber die Wahl des
19 * Freigabefensters sagt.
20 *
21 * DER BEFUND. Der Hinweis unter der Beurteilung lautete: "Die Signalgruppe der
22 * Hauptrichtung ist in mehreren getrennten Fenstern freigegeben. Gerechnet wird
23 * mit dem laengsten - durch das kuerzere passt der Pulk nicht." Das war der
24 * ganze Satz, und er beschrieb die Rechnung nur zur Haelfte. Seit der Behebung
25 * des Gleichstands (Fall E in tests/domain/gleichstandOhneReihenfolge.test.ts)
26 * waehlt `koordinierungslage` unter mehreren GLEICH LANGEN Fenstern das mit der
27 * groesseren Bandbreite und erst bei gleicher Bandbreite das im Umlauf fruehere.
28 * Der Anwender las eine Auswahlregel, die es so nicht mehr gibt - und nach ihr
29 * waren zwei gleich lange Fenster gar nicht zu unterscheiden.
30 *
31 * WARUM DIE ANSICHT DIESEN SATZ UEBERHAUPT FUEHRT: Es ist eine Auswahl, die
32 * niemand getroffen hat. Ein Hinweis, der sie nennt, aber falsch erklaert, ist
33 * schlechter als keiner - der Anwender rechnet dann mit dem falschen Fenster
34 * nach und haelt das Ergebnis fuer einen Fehler des Programms.
35 *
36 * DIE FAELLE MESSEN DIE RECHNUNG MIT, nicht nur den Wortlaut: Ein Satz ueber
37 * eine Rechnung ist nur so lange richtig, wie die Rechnung ihn traegt.
38 */
39
40 const DATUM = new Date('2026-01-01T12:00:00Z');
41
42 /** Der alte Wortlaut - er darf nicht wieder allein dastehen. */
43 const ALTER_SATZ =
44 'Die Signalgruppe der Hauptrichtung ist in mehreren getrennten Fenstern freigegeben. ' +
45 'Gerechnet wird mit dem längsten – durch das kürzere passt der Pulk nicht.';
46
47 /**
48 * Vier Phasen, die Hauptrichtung in der ersten und der dritten - zwei getrennte
49 * Fenster gleicher Laenge (Aufbau des Falls E, dort gemessen).
50 *
51 * `nachbarBeginn` und `nachbarDauer` legen fest, WELCHES der beiden Fenster ein
52 * Band traegt und ob beide eines tragen.
53 */
54 function zweiFenster(nachbarBeginn = 20, nachbarDauer = 12): Project {
55 const basis = createEmptyProject('Zwei Fenster', DATUM, 'knotenpunkt');
56 const a = createSignalGroup({ name: 'A', mode: 'kfz' as const, vZul: 50, index: 0 });
57 const b = createSignalGroup({ name: 'B', mode: 'kfz' as const, vZul: 50, index: 1 });
58 const phasen = [
59 createPhase('P1', [a.id]),
60 createPhase('P2', [b.id]),
61 createPhase('P3', [a.id]),
62 createPhase('P4', [b.id]),
63 ];
64 const beziehung = (von: string, nach: string) => ({
65 ...createConflict(von, nach),
66 clearingDistance: 20,
67 enteringDistance: 0,
68 });
69 let projekt: Project = {
70 ...basis,
71 signalGroups: [a, b],
72 phases: phasen,
73 conflicts: [beziehung(a.id, b.id), beziehung(b.id, a.id)],
74 program: {
75 ...basis.program,
76 method: 'manuell',
77 manualCycleTime: 60,
78 phaseOrder: phasen.map((p) => p.id),
79 },
80 };
81 projekt = actions.setzeKoordinierung(projekt, {
82 hauptrichtung: a.id,
83 progressionsgeschwindigkeit: 36,
84 platz: 0,
85 });
86 projekt = actions.koordinierungsknotenAnlegen(projekt, 'West');
87 const west = projekt.koordinierung!.nachbarn[0]!;
88 return actions.koordinierungsknotenAendern(projekt, west.id, {
89 abstand: 200,
90 freigabeBeginn: nachbarBeginn,
91 freigabeDauer: nachbarDauer,
92 });
93 }
94
95 /** Derselbe Plan mit umgekehrter Reihenfolge der Freigabefenster. */
96 function mitUmgekehrtenFenstern(plan: SignalPlan, groupId: string): SignalPlan {
97 return {
98 ...plan,
99 groups: plan.groups.map((g) =>
100 g.groupId === groupId ? { ...g, greens: [...g.greens].reverse() } : g,
101 ),
102 };
103 }
104
105 /** Nur ein Fenster einer Signalgruppe im Plan lassen - fuer die Messung. */
106 function nurFenster(plan: SignalPlan, groupId: string, index: number): SignalPlan {
107 return {
108 ...plan,
109 groups: plan.groups.map((g) =>
110 g.groupId === groupId ? { ...g, greens: g.greens.slice(index, index + 1) } : g,
111 ),
112 };
113 }
114
115 /** Alle Hinweistexte der Ansicht, zusammengefuegt wie sie dastehen. */
116 function hinweise(projekt: Project): string[] {
117 const wurzel = document.createElement('div');
118 document.body.append(wurzel);
119 const ende = koordinierungView.render(wurzel, {
120 store: new ProjectStore(projekt),
121 refresh: () => {},
122 navigate: () => {},
123 target: null,
124 });
125 try {
126 return [...wurzel.querySelectorAll('.feld-hinweis')].map((p) =>
127 (p.textContent ?? '').replace(/\s+/gu, ' ').trim(),
128 );
129 } finally {
130 ende();
131 wurzel.remove();
132 }
133 }
134
135 /** Der Hinweis zu den mehreren Freigabefenstern. */
136 function fensterhinweis(projekt: Project): string {
137 const treffer = hinweise(projekt).filter((t) => t.includes('mehreren getrennten Fenstern'));
138 expect(treffer.length, 'der Hinweis zu den mehreren Fenstern steht genau einmal da').toBe(1);
139 return treffer[0]!;
140 }
141
142 describe('Der Hinweis nennt die Auswahlregel, mit der gerechnet wird', () => {
143 it('Vorbedingung: zwei gleich lange Fenster, und die Ansicht meldet sie', () => {
144 const projekt = zweiFenster();
145 const gruppe = buildSignalPlan(projekt).groups.find(
146 (g) => g.groupId === projekt.koordinierung!.hauptrichtung,
147 );
148 expect(
149 gruppe?.greens.map((g) => g.duration),
150 'gleich lang',
151 ).toEqual([9, 9]);
152 expect(fensterhinweis(projekt)).toContain('mehreren getrennten Fenstern');
153 });
154
155 it('nennt die Bandbreite als das, was bei gleicher Laenge entscheidet', () => {
156 const text = fensterhinweis(zweiFenster());
157
158 expect(text, 'die Laenge bleibt die erste Stufe').toContain('längsten');
159 expect(text, 'die zweite Stufe, die es seit 5.43.0 gibt').toContain('Bandbreite');
160 expect(text, 'die dritte Stufe, die nichts bedeutet').toContain('im Umlauf frühere');
161 expect(text, 'der halbe Satz von vorher').not.toBe(ALTER_SATZ);
162 });
163
164 it('sagt, dass die letzte Stufe nur festgelegt ist und nichts bedeutet', () => {
165 // Grundsatz: Wo eine stabile Ordnung entscheidet, gehoert dazugeschrieben,
166 // dass sie nichts bedeutet. Der Anwender soll aus dem frueheren Fenster
167 // keinen fachlichen Vorzug lesen.
168 expect(fensterhinweis(zweiFenster())).toContain('Reihenfolge der Phasen');
169 });
170 });
171
172 describe('Die Rechnung dahinter waehlt wirklich an der Bandbreite', () => {
173 /** Das gewaehlte Freigabefenster dieses Knotenpunkts. */
174 function gewaehlt(projekt: Project, plan: SignalPlan) {
175 const lage = koordinierungslage(projekt, plan);
176 return {
177 freigabe: lage.kette.find((k) => k.eigener)?.freigabe,
178 bandbreite: lage.ergebnis?.bandwidth,
179 };
180 }
181
182 it('nimmt das spaetere Fenster, wenn dieses die groessere Bandbreite traegt', () => {
183 // Gibt der Nachbar erst ab 50 s frei, traegt das Fenster ab 30 s das Band.
184 // Nach dem alten Satz - "das laengste" - waeren beide gleichwertig.
185 const projekt = zweiFenster(50);
186 const haupt = projekt.koordinierung!.hauptrichtung!;
187 const plan = buildSignalPlan(projekt);
188 expect(gewaehlt(projekt, nurFenster(plan, haupt, 0)).bandbreite, 'Vorbedingung').toBe(0);
189
190 for (const p of [plan, mitUmgekehrtenFenstern(plan, haupt)]) {
191 expect(gewaehlt(projekt, p).freigabe).toEqual({ start: 30, duration: 9 });
192 expect(gewaehlt(projekt, p).bandbreite).toBe(9);
193 }
194 });
195
196 it('nimmt bei gleicher Bandbreite das im Umlauf fruehere', () => {
197 // Der Nachbar gibt von 20 s an 40 s lang frei und deckt damit beide
198 // Bandlagen; die Bandbreite traegt die Entscheidung dann nicht mehr.
199 const projekt = zweiFenster(20, 40);
200 const haupt = projekt.koordinierung!.hauptrichtung!;
201 const plan = buildSignalPlan(projekt);
202 expect(
203 gewaehlt(projekt, nurFenster(plan, haupt, 0)).bandbreite,
204 'Vorbedingung: beide Fenster tragen dasselbe Band',
205 ).toBe(gewaehlt(projekt, nurFenster(plan, haupt, 1)).bandbreite);
206
207 for (const p of [plan, mitUmgekehrtenFenstern(plan, haupt)]) {
208 expect(gewaehlt(projekt, p).freigabe).toEqual({ start: 0, duration: 9 });
209 }
210 });
211 });
212
213 /**
214 * Nachtrag: Der Hinweis hing allein an der HAUPTRICHTUNG.
215 *
216 * `fensterMitBand` waehlt fuer jede Richtung an ihrem eigenen Band - zuerst fuer
217 * die Hin-, dann fuer die Rueckrichtung. Hatte nur die Gegenrichtung mehrere
218 * Fenster, traf das Programm dieselbe dreistufige Wahl und sagte kein Wort
219 * darueber: dieselbe Luecke, die der Satz fuer die Hauptrichtung gerade
220 * geschlossen hat. Vorbestand, durch den neuen Satz sichtbar geworden.
221 */
222 describe('Der Hinweis nennt die Richtung, deren Fenster gewaehlt wurden', () => {
223 /** Nur die Gegenrichtung ist in zwei getrennten Fenstern frei. */
224 function nurGegenrichtungMehrfach(): Project {
225 const basis = createEmptyProject('Nur Gegenrichtung', DATUM, 'knotenpunkt');
226 const a = createSignalGroup({ name: 'A', mode: 'kfz' as const, vZul: 50, index: 0 });
227 const b = createSignalGroup({ name: 'B', mode: 'kfz' as const, vZul: 50, index: 1 });
228 const c = createSignalGroup({ name: 'C', mode: 'kfz' as const, vZul: 50, index: 2 });
229 const phasen = [
230 createPhase('P1', [b.id]),
231 createPhase('P2', [a.id]),
232 createPhase('P3', [b.id]),
233 createPhase('P4', [c.id]),
234 ];
235 const beziehung = (von: string, nach: string) => ({
236 ...createConflict(von, nach),
237 clearingDistance: 20,
238 enteringDistance: 0,
239 });
240 let projekt: Project = {
241 ...basis,
242 signalGroups: [a, b, c],
243 phases: phasen,
244 conflicts: [beziehung(a.id, b.id), beziehung(b.id, a.id)],
245 program: {
246 ...basis.program,
247 method: 'manuell',
248 manualCycleTime: 60,
249 phaseOrder: phasen.map((p) => p.id),
250 },
251 };
252 projekt = actions.setzeKoordinierung(projekt, {
253 hauptrichtung: a.id,
254 gegenrichtung: b.id,
255 progressionsgeschwindigkeit: 36,
256 platz: 0,
257 });
258 projekt = actions.koordinierungsknotenAnlegen(projekt, 'West');
259 const west = projekt.koordinierung!.nachbarn[0]!;
260 return actions.koordinierungsknotenAendern(projekt, west.id, {
261 abstand: 200,
262 freigabeBeginn: 20,
263 freigabeDauer: 12,
264 gegenBeginn: 20,
265 gegenDauer: 12,
266 });
267 }
268
269 it('meldet den Fall auch, wenn ihn nur die Gegenrichtung hat', () => {
270 const projekt = nurGegenrichtungMehrfach();
271 const gruppen = buildSignalPlan(projekt).groups;
272 const fenster = (id: string | null) =>
273 gruppen.find((g) => g.groupId === id)?.greens.map((g) => g.duration);
274 expect(fenster(projekt.koordinierung!.hauptrichtung), 'Vorbedingung: ein Fenster').toHaveLength(
275 1,
276 );
277 expect(
278 fenster(projekt.koordinierung!.gegenrichtung),
279 'Vorbedingung: zwei Fenster',
280 ).toHaveLength(2);
281
282 const text = fensterhinweis(projekt);
283 expect(text, 'benannt wird die Richtung, um die es geht').toContain(
284 'Signalgruppe der Gegenrichtung',
285 );
286 expect(text, 'und nicht die, die nur ein Fenster hat').not.toContain('der Hauptrichtung ist');
287 expect(text, 'die Auswahlregel steht auch hier').toContain('Bandbreite');
288 });
289
290 it('nennt beide Richtungen, wenn beide mehrere Fenster haben', () => {
291 // In diesem Aufbau ist auch die Gegenrichtung in zwei Phasen frei (P2, P4).
292 const basis = zweiFenster();
293 const projekt = actions.setzeKoordinierung(basis, {
294 gegenrichtung: basis.signalGroups[1]!.id,
295 });
296 const lage = koordinierungslage(projekt, buildSignalPlan(projekt));
297 expect(
298 lage.mehrereFreigabefenster && lage.mehrereFreigabefensterGegenrichtung,
299 'Vorbedingung: beide',
300 ).toBe(true);
301 expect(fensterhinweis(projekt)).toContain(
302 'Signalgruppen der Hauptrichtung und der Gegenrichtung',
303 );
304 });
305
306 it('schweigt, wo keine der beiden Richtungen mehrere Fenster hat', () => {
307 const projekt = createStandardIntersectionProject('Ein Fenster', DATUM);
308 const mitKette = actions.koordinierungsknotenAnlegen(
309 actions.setzeKoordinierung(projekt, {
310 hauptrichtung: projekt.signalGroups[0]!.id,
311 progressionsgeschwindigkeit: 36,
312 platz: 0,
313 }),
314 'West',
315 );
316 const west = mitKette.koordinierung!.nachbarn[0]!;
317 const fertig = actions.koordinierungsknotenAendern(mitKette, west.id, {
318 abstand: 200,
319 freigabeBeginn: 10,
320 freigabeDauer: 20,
321 });
322 expect(hinweise(fertig).some((t) => t.includes('mehreren getrennten Fenstern'))).toBe(false);
323 });
324 });