lsa-planer

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

/ tests ui vertraeglichkeit.test.ts

9,6 KB Rohdatei
tests/ui/vertraeglichkeit.test.ts — 348 Zeilen
1 import { afterEach, describe, expect, it } from 'vitest';
2 import { ProjectStore } from '@/app/store';
3 import type { Haltlinie, Lageplan, Planlinie } from '@/domain/geometrie/lageplan';
4 import { REGELBREITE } from '@/domain/geometrie/vermessung';
5 import { createEmptyProject } from '@/domain/model/factory';
6 import {
7 WEGNACHWEIS_UNBESTIMMT,
8 type Conflict,
9 type Project,
10 type SignalGroup,
11 } from '@/domain/model/project';
12 import { lageplanView } from '@/ui/views/lageplanView';
13
14 /**
15 * Folge aus Befund 1: Die Ansicht zaehlte und benannte auf
16 * Strompaar-Ebene, waehrend das Aufheben auf Signalgruppenpaar-Ebene
17 * entscheidet.
18 *
19 * WAS DER ANWENDER SONST ERLEBT
20 *
21 * Signalgruppe A schaltet geradeaus UND rechts aus derselben Zufahrt - der
22 * Regelfall eines Signalgebers. Rechts kreuzt die Furt der Gruppe B, geradeaus
23 * nicht. Das Aufheben laesst die Beziehung A/B deshalb zu Recht stehen: Zwischen
24 * Stroemen einer Gruppe gibt es keine Zwischenzeit, der Rechtsabbieger verloere
25 * seine Sicherung.
26 *
27 * Der Warnbalken zaehlte trotzdem "1 erfasste Konflikte kreuzen sich im Plan
28 * nicht", der Bestaetigungstext nannte "geradeaus und Furt", und danach meldete
29 * das Programm "0 Konflikt(e) aufgehoben" - ohne Grund. Der Balken blieb stehen
30 * und liess sich ueber diesen Weg nie abtragen.
31 *
32 * Beide Seiten benutzen jetzt dieselbe Verdichtung aus `ableitung.ts`. Was
33 * nicht aufhebbar ist, steht mit seinem Grund in einem eigenen Abschnitt.
34 */
35
36 const FIXED_DATE = new Date('2026-01-01T00:00:00Z');
37
38 /** 1 Bildpunkt = 1 m, damit sich die Masse unmittelbar ablesen lassen. */
39 const KALIBRIERUNG = {
40 von: { x: 0, y: 0 },
41 bis: { x: 100, y: 0 },
42 laengeMeter: 100,
43 herkunft: 'gemessen',
44 } as const;
45
46 const HL_NORD = 'hl-nord';
47 const HL_FURT = 'hl-furt';
48 const HL_WEST = 'hl-west';
49
50 const HALTLINIEN: readonly Haltlinie[] = [
51 {
52 id: HL_NORD,
53 name: 'Zufahrt Nord',
54 art: 'zufahrt',
55 armId: 'arm-nord',
56 von: { x: 0, y: 0 },
57 bis: { x: 70, y: 0 },
58 },
59 {
60 id: HL_FURT,
61 name: 'Querung Ost',
62 art: 'querung',
63 armId: 'arm-ost',
64 von: { x: 60, y: 5 },
65 bis: { x: 60, y: 30 },
66 },
67 {
68 id: HL_WEST,
69 name: 'Zufahrt West',
70 art: 'zufahrt',
71 armId: 'arm-west',
72 von: { x: 200, y: 0 },
73 bis: { x: 240, y: 0 },
74 },
75 ];
76
77 const linie = (
78 id: string,
79 name: string,
80 punkte: readonly { x: number; y: number }[],
81 patch: Partial<Planlinie> = {},
82 ): Planlinie => ({
83 id,
84 name,
85 mode: 'kfz',
86 movement: 'geradeaus',
87 breiteMeter: REGELBREITE.kfz,
88 punkte,
89 haltlinieId: HL_NORD,
90 startT: 0.5,
91 signalGroupId: null,
92 ...patch,
93 });
94
95 /** Geradeaus aus der Nordzufahrt - kreuzt die Querung im Osten nicht. */
96 const GERADEAUS = linie(
97 'l-gerade',
98 'Nord geradeaus',
99 [
100 { x: 35, y: 0 },
101 { x: 35, y: 40 },
102 ],
103 { signalGroupId: 'sg-a' },
104 );
105
106 /** Rechtsabbieger aus DERSELBEN Zufahrt und DERSELBEN Gruppe - kreuzt die Querung. */
107 const RECHTS = linie(
108 'l-rechts',
109 'Nord rechts',
110 [
111 { x: 35, y: 0 },
112 { x: 38, y: 12 },
113 { x: 70, y: 16 },
114 ],
115 { movement: 'rechts', signalGroupId: 'sg-a' },
116 );
117
118 /** Fussgaengerfurt ueber den Ausfahrtarm Ost. */
119 const FURT = linie(
120 'l-furt',
121 'Querung Ost',
122 [
123 { x: 60, y: 5 },
124 { x: 60, y: 30 },
125 ],
126 {
127 mode: 'fuss',
128 movement: 'querung',
129 breiteMeter: REGELBREITE.fuss,
130 haltlinieId: HL_FURT,
131 signalGroupId: 'sg-b',
132 },
133 );
134
135 /**
136 * Ein Strom weit ab vom Knotenpunkt, allein in seiner Gruppe.
137 *
138 * Er ist die Gegenprobe: Sein Gruppenpaar mit der Querung ist wirklich
139 * aufhebbar, und nur er darf gezaehlt und im Bestaetigungstext genannt werden.
140 */
141 const WEST = linie(
142 'l-west',
143 'West geradeaus',
144 [
145 { x: 220, y: 0 },
146 { x: 220, y: 40 },
147 ],
148 { haltlinieId: HL_WEST, signalGroupId: 'sg-c' },
149 );
150
151 function plan(linien: readonly Planlinie[]): Lageplan {
152 return {
153 bild: null,
154 kalibrierung: KALIBRIERUNG,
155 haltlinien: HALTLINIEN,
156 linien,
157 arbeitsbereiche: [],
158 signalgeber: [],
159 };
160 }
161
162 const gruppe = (id: string, patch: Partial<SignalGroup> = {}): SignalGroup => ({
163 id,
164 name: id,
165 mode: 'kfz',
166 movement: 'geradeaus',
167 armId: null,
168 vZul: 50,
169 lanes: 1,
170 vehicleClass: 'pkw',
171 minGreenOverride: null,
172 maxGreenOverride: null,
173 reducedMobility: false,
174 color: '#2f6fd0',
175 ...patch,
176 });
177
178 const konflikt = (id: string, fromId: string, toId: string): Conflict => ({
179 id,
180 fromId,
181 toId,
182 clearingDistance: 25,
183 enteringDistance: 5,
184 clearingSpeedOverride: null,
185 enteringSpeedOverride: null,
186 crossingTimeOverride: null,
187 manualIntergreen: null,
188 wegnachweis: WEGNACHWEIS_UNBESTIMMT,
189 note: '',
190 });
191
192 function projekt(lageplan: Lageplan, conflicts: readonly Conflict[]): Project {
193 const leer = createEmptyProject('Verdichtung', FIXED_DATE);
194 return {
195 ...leer,
196 signalGroups: [
197 gruppe('sg-a'),
198 gruppe('sg-b', { mode: 'fuss', movement: 'querung', vehicleClass: 'keine' }),
199 gruppe('sg-c'),
200 ],
201 conflicts: [...conflicts],
202 lageplan,
203 };
204 }
205
206 interface Ansicht {
207 readonly store: ProjectStore;
208 readonly wurzel: HTMLElement;
209 readonly aufraeumen: () => void;
210 }
211
212 function baueAnsicht(project: Project): Ansicht {
213 const store = new ProjectStore(project);
214 const wurzel = document.createElement('div');
215 document.body.append(wurzel);
216 const schliessen = lageplanView.render(wurzel, {
217 store,
218 refresh: () => {},
219 navigate: () => {},
220 target: null,
221 });
222
223 return {
224 store,
225 wurzel,
226 aufraeumen: () => {
227 schliessen();
228 wurzel.remove();
229 },
230 };
231 }
232
233 afterEach(() => {
234 document.body.replaceChildren();
235 });
236
237 /** Laesst die angestossenen Zusagen der Reihe nach ablaufen. */
238 async function ruhe(): Promise<void> {
239 for (let i = 0; i < 6; i += 1) {
240 await new Promise((erfuellen) => setTimeout(erfuellen, 0));
241 }
242 }
243
244 function knopf(ansicht: Ansicht, beschriftung: string): HTMLButtonElement | undefined {
245 return [...ansicht.wurzel.querySelectorAll<HTMLButtonElement>('button')].find(
246 (b) => (b.textContent ?? '').trim() === beschriftung,
247 );
248 }
249
250 function text(ansicht: Ansicht): string {
251 return ansicht.wurzel.textContent ?? '';
252 }
253
254 function dialogtext(): string {
255 return document.querySelector('.dialog .dialog-text')?.textContent ?? '';
256 }
257
258 function knopfImFenster(beschriftung: string): HTMLButtonElement {
259 const fenster = document.querySelector<HTMLElement>('.dialog');
260 if (fenster === null) throw new Error('Es steht kein Fenster offen.');
261 const treffer = [...fenster.querySelectorAll<HTMLButtonElement>('button')].find(
262 (b) => (b.textContent ?? '').trim() === beschriftung,
263 );
264 if (treffer === undefined) throw new Error(`Kein Knopf "${beschriftung}" im Fenster`);
265 return treffer;
266 }
267
268 function letzteWarnung(): string {
269 const meldungen = [...document.querySelectorAll('.meldung-warnung')];
270 return meldungen[meldungen.length - 1]?.textContent ?? '';
271 }
272
273 describe('Ableitung: Warnbalken und Bestätigungstext auf Signalgruppenpaar-Ebene', () => {
274 it('zeigt keinen Warnbalken, wenn ein anderer Strom derselben Gruppen kreuzt', () => {
275 const lageplan = plan([GERADEAUS, RECHTS, FURT]);
276 const ansicht = baueAnsicht(
277 projekt(lageplan, [konflikt('k-hin', 'sg-a', 'sg-b'), konflikt('k-rueck', 'sg-b', 'sg-a')]),
278 );
279 try {
280 // Das Aufheben laesst die Beziehung stehen. Ein Balken, der ein Aufheben
281 // ankuendigt, das nicht stattfindet, ist die Aufforderung zu einem
282 // Bedienweg, der immer bei "0 Konflikt(e) aufgehoben" endet.
283 expect(text(ansicht), 'der Warnbalken zählt noch Strompaare').not.toContain(
284 'kreuzen sich im Plan nicht',
285 );
286 expect(knopf(ansicht, 'Konflikte prüfen und aufheben')).toBeUndefined();
287 } finally {
288 ansicht.aufraeumen();
289 }
290 });
291
292 it('nennt den Grund, warum die verträgliche Beziehung trotzdem bestehen bleibt', () => {
293 const lageplan = plan([GERADEAUS, RECHTS, FURT]);
294 const ansicht = baueAnsicht(
295 projekt(lageplan, [konflikt('k-hin', 'sg-a', 'sg-b'), konflikt('k-rueck', 'sg-b', 'sg-a')]),
296 );
297 try {
298 const gelesen = text(ansicht);
299 expect(gelesen).toContain('Nord geradeaus und Querung Ost');
300 expect(gelesen).toContain('ein anderer Strom derselben Signalgruppen kreuzt');
301 } finally {
302 ansicht.aufraeumen();
303 }
304 });
305
306 it('zählt und nennt nur das Gruppenpaar, das wirklich aufgehoben wird', async () => {
307 const lageplan = plan([GERADEAUS, RECHTS, FURT, WEST]);
308 const ansicht = baueAnsicht(
309 projekt(lageplan, [
310 konflikt('k-hin', 'sg-a', 'sg-b'),
311 konflikt('k-rueck', 'sg-b', 'sg-a'),
312 konflikt('k-west', 'sg-c', 'sg-b'),
313 ]),
314 );
315 try {
316 expect(text(ansicht)).toContain('1 erfasste Konflikte kreuzen sich im Plan nicht');
317
318 const aufheben = knopf(ansicht, 'Konflikte prüfen und aufheben');
319 expect(aufheben, 'ohne Knopf lässt sich der Balken nicht abtragen').toBeDefined();
320 aufheben?.click();
321 await ruhe();
322
323 const gefragt = dialogtext();
324 expect(gefragt).toContain('Querung Ost und West geradeaus');
325 // Der Kern der Aenderung: Das Paar, das nicht aufgehoben wird, darf im
326 // Bestaetigungstext nicht stehen - er kuendigt sonst etwas an, was nicht
327 // geschieht.
328 expect(gefragt, 'der Text nennt ein Paar, das bestehen bleibt').not.toContain(
329 'Nord geradeaus',
330 );
331
332 knopfImFenster('Aufheben').click();
333 await ruhe();
334
335 expect(letzteWarnung()).toContain('1 Konflikt(e) aufgehoben');
336 expect(
337 ansicht.store
338 .getProject()
339 .conflicts.map((c) => c.id)
340 .sort(),
341 ).toEqual(['k-hin', 'k-rueck']);
342 // Der Balken ist abgetragen - genau das ging ueber diesen Weg nie.
343 expect(text(ansicht)).not.toContain('kreuzen sich im Plan nicht');
344 } finally {
345 ansicht.aufraeumen();
346 }
347 });
348 });