lsa-planer

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

/ tests ui massgebenderStromKonfliktfenster.test.ts

9,5 KB Rohdatei
tests/ui/massgebenderStromKonfliktfenster.test.ts — 244 Zeilen
1 import { afterEach, describe, expect, it } from 'vitest';
2 import { ProjectStore } from '@/app/store';
3 import { createStandardIntersectionProject } from '@/domain/model/factory';
4 import { buildSignalPlan, intergreenKey } from '@/domain/plan/signalPlan';
5 import { raeumbeziehungSatz } from '@/domain/rilsa/ansaetze';
6 import { RILSA_DEFAULTS } from '@/domain/rilsa/constants';
7 import type { Conflict, Project } from '@/domain/model/project';
8 import type { ViewDefinition } from '@/ui/shell';
9 import { conflictsView } from '@/ui/views/conflictsView';
10
11 /*
12 * Der massgebende Strom im KONFLIKTFENSTER.
13 *
14 * Das Fenster bildete die Entscheidung, mit welcher Fahrbeziehung eine
15 * Beziehung raeumt, ein zweites Mal nach und nahm dabei `from.movement`. An
16 * einer Sammelsignalgruppe "geradeaus UND rechts" widersprach es damit dem
17 * Rechenweg, den es selbst zwei Zeilen tiefer zeigt: Ueber der Zeile
18 * "Räumgeschwindigkeit vr 7,00 m/s" stand "Regelwert 10 m/s".
19 */
20
21 const DATUM = new Date('2026-01-01T00:00:00Z');
22
23 function zeichne(
24 view: ViewDefinition,
25 project: Project,
26 ): { wurzel: HTMLElement; aufraeumen: () => void } {
27 const wurzel = document.createElement('div');
28 document.body.append(wurzel);
29 const abmelden = view.render(wurzel, {
30 store: new ProjectStore(project),
31 refresh: () => {},
32 navigate: () => {},
33 target: null,
34 });
35 return {
36 wurzel,
37 aufraeumen: () => {
38 abmelden();
39 wurzel.remove();
40 },
41 };
42 }
43
44 /** Sichtbarer Text einer Beschriftung - ohne den Hilfeknopf. */
45 function beschriftungstext(label: HTMLLabelElement): string {
46 return [...label.childNodes]
47 .filter((knoten) => !(knoten instanceof Element && knoten.classList.contains('hilfe-knopf')))
48 .map((knoten) => knoten.textContent ?? '')
49 .join('')
50 .trim();
51 }
52
53 function hinweisZu(wurzel: ParentNode, beschriftung: string): string {
54 const label = [...wurzel.querySelectorAll('label')].find(
55 (l) => beschriftungstext(l) === beschriftung,
56 );
57 const id = label?.getAttribute('for');
58 expect(id, `Das Feld "${beschriftung}" fehlt im Fenster`).toBeTruthy();
59 const feld = wurzel.querySelector<HTMLElement>(`[id="${id ?? ''}"]`);
60 const beschrieben = feld?.getAttribute('aria-describedby') ?? null;
61 if (beschrieben === null) return '';
62 return beschrieben
63 .split(' ')
64 .map((teil) => wurzel.querySelector(`[id="${teil}"]`)?.textContent ?? '')
65 .join(' ');
66 }
67
68 /** Erlaeuterungsspalte einer Zeile des Rechenwegs. */
69 function rechenwegZeile(fenster: ParentNode, bezeichnung: string): string {
70 const zeile = [...fenster.querySelectorAll('tbody tr')].find(
71 (tr) => tr.children[0]?.textContent?.trim() === bezeichnung,
72 );
73 if (!zeile) throw new Error(`Zeile "${bezeichnung}" fehlt im Rechenweg`);
74 return zeile.children[1]?.textContent?.trim() ?? '';
75 }
76
77 /** Der ganze sichtbare Text des Fensters. */
78 function fenstertext(fenster: ParentNode): string {
79 return ((fenster as HTMLElement).textContent ?? '').replace(/\s+/g, ' ');
80 }
81
82 function oeffneBeziehung(wurzel: HTMLElement, project: Project, konflikt: Conflict): ParentNode {
83 const zeile = project.signalGroups.findIndex((g) => g.id === konflikt.fromId);
84 const spalte = project.signalGroups.findIndex((g) => g.id === konflikt.toId);
85 const zellen = [...wurzel.querySelectorAll<HTMLButtonElement>('.matrix-zelle')];
86 const n = project.signalGroups.length;
87 const versatz = spalte > zeile ? spalte - 1 : spalte;
88 zellen[zeile * (n - 1) + versatz]?.click();
89 const fenster = document.querySelector('.dialog');
90 expect(fenster, 'Das Konfliktfenster ist nicht offen').not.toBeNull();
91 return fenster as ParentNode;
92 }
93
94 /**
95 * Standardknotenpunkt, an dem "K1" geradeaus UND rechts fuehrt: Die Gruppe
96 * bleibt bei 'geradeaus', die Beziehung K1 -> K3 traegt die Fahrbeziehung
97 * ihres massgebenden Stroms.
98 */
99 function sammelgruppe(zusatz: Partial<Conflict> = {}): Project {
100 const basis = createStandardIntersectionProject('Sammelgruppe', DATUM);
101 const k1 = basis.signalGroups.find((g) => g.name === 'K1')!;
102 const k3 = basis.signalGroups.find((g) => g.name === 'K3')!;
103 return {
104 ...basis,
105 conflicts: basis.conflicts.map((c) =>
106 c.fromId === k1.id && c.toId === k3.id
107 ? { ...c, raeumbeziehung: 'rechts' as const, ...zusatz }
108 : c,
109 ),
110 };
111 }
112
113 function beziehung(project: Project): Conflict {
114 return project.conflicts.find((c) => c.raeumbeziehung !== undefined)!;
115 }
116
117 afterEach(() => {
118 document.body.replaceChildren();
119 });
120
121 describe('N1/4: Das Konfliktfenster nennt den Regelwert des massgebenden Stroms', () => {
122 it('beschriftet die Raeumgeschwindigkeit mit dem Wert, mit dem der Plan rechnet', () => {
123 const project = sammelgruppe();
124 const konflikt = beziehung(project);
125 const gerechnet = buildSignalPlan(project).intergreens.get(
126 intergreenKey(konflikt.fromId, konflikt.toId),
127 );
128 expect(gerechnet?.calculation?.clearingSpeed).toBe(RILSA_DEFAULTS.clearingSpeed.kfzTurning);
129
130 const { wurzel, aufraeumen } = zeichne(conflictsView, project);
131 try {
132 const fenster = oeffneBeziehung(wurzel, project, konflikt);
133 expect(hinweisZu(fenster, 'Räumgeschwindigkeit')).toContain('Regelwert 7 m/s');
134 expect(hinweisZu(fenster, 'Räumgeschwindigkeit')).not.toContain('Regelwert 10 m/s');
135 // Und der Rechenweg im selben Fenster sagt dasselbe.
136 expect(rechenwegZeile(fenster, 'Räumgeschwindigkeit vr')).toContain('7,00 m/s');
137 } finally {
138 aufraeumen();
139 }
140 });
141
142 it('zeigt den engen Innenradius als wirksam, wo der Plan mit ihm rechnet', () => {
143 const project = sammelgruppe({ engerRadius: true });
144 const konflikt = beziehung(project);
145 const gerechnet = buildSignalPlan(project).intergreens.get(
146 intergreenKey(konflikt.fromId, konflikt.toId),
147 );
148 expect(gerechnet?.calculation?.clearingSpeed).toBe(
149 RILSA_DEFAULTS.clearingSpeed.kfzTurningEngerRadius,
150 );
151
152 const { wurzel, aufraeumen } = zeichne(conflictsView, project);
153 try {
154 const fenster = oeffneBeziehung(wurzel, project, konflikt);
155 expect(hinweisZu(fenster, 'Räumgeschwindigkeit')).toContain('Angesetzt 5 m/s');
156 expect(hinweisZu(fenster, 'Räumgeschwindigkeit')).toContain('enger Innenradius');
157 // Das Kaestchen darf nicht behaupten, der Strom biege nicht ab.
158 expect(fenstertext(fenster)).not.toContain('ist kein abbiegender Kraftfahrzeugstrom');
159 } finally {
160 aufraeumen();
161 }
162 });
163
164 it('nennt beim Ansatz "feste Ueberfahrzeit" den Wert des abbiegenden Stroms', () => {
165 const basis = sammelgruppe({ crossingTimeOverride: 4 });
166 const project: Project = {
167 ...basis,
168 settings: { ...basis.settings, ueberfahrzeitAnsatz: 'fest' },
169 };
170 const { wurzel, aufraeumen } = zeichne(conflictsView, project);
171 try {
172 const fenster = oeffneBeziehung(wurzel, project, beziehung(project));
173 const hinweis = hinweisZu(fenster, 'Überfahrzeit');
174 expect(hinweis).toContain('Angesetzt 4 s');
175 // "Regelwert" seit Fassung 5.43.0 (Befund 17): Der feste
176 // Ansatz ist der der RiLSA 2015. Hier stand "ohne diesen Eintrag".
177 expect(hinweis).toContain(`Regelwert ${RILSA_DEFAULTS.crossingTime.kfzAbbiegend} s`);
178 expect(hinweis).not.toContain(`Regelwert ${RILSA_DEFAULTS.crossingTime.kfzGeradeaus} s`);
179 } finally {
180 aufraeumen();
181 }
182 });
183
184 it('sagt im Kopf des Fensters, welcher Strom den Raeumweg faehrt', () => {
185 /*
186 * Sonst steht "K1 (Kfz, geradeaus) räumt" ueber einer Zeile mit 7 m/s,
187 * und nichts im Fenster erklaert den Unterschied.
188 */
189 const project = sammelgruppe();
190 const { wurzel, aufraeumen } = zeichne(conflictsView, project);
191 try {
192 const fenster = oeffneBeziehung(wurzel, project, beziehung(project));
193 expect(fenstertext(fenster)).toContain(
194 'Den Räumweg dieser Beziehung fährt ein Strom der Signalgruppe „K1“ mit der ' +
195 'Fahrbeziehung „rechts abbiegend“; geräumt wird mit ihr.',
196 );
197 } finally {
198 aufraeumen();
199 }
200 });
201
202 it('setzt denselben Satz in den Kopf, den der Pruefbericht fuehrt', () => {
203 /*
204 * NACHTRAG (Fassung 5.11.0): Der Satz steht einmal (`raeumbeziehungSatz`,
205 * domain/rilsa/ansaetze.ts) und wird von Konfliktfenster und Pruefbericht
206 * geteilt. Zwei Fassungen desselben Sachverhalts liefen beim naechsten Wort
207 * auseinander - genau das war der Befund, aus dem diese Gruppe entstanden
208 * ist.
209 */
210 const project = sammelgruppe();
211 const konflikt = beziehung(project);
212 const from = project.signalGroups.find((g) => g.id === konflikt.fromId)!;
213 const satz = raeumbeziehungSatz(from, konflikt);
214
215 expect(satz).not.toBe('');
216 const { wurzel, aufraeumen } = zeichne(conflictsView, project);
217 try {
218 expect(fenstertext(oeffneBeziehung(wurzel, project, konflikt))).toContain(satz);
219 } finally {
220 aufraeumen();
221 }
222 });
223
224 it('bleibt ohne eigene Fahrbeziehung beim Regelwert der Signalgruppe', () => {
225 // Gegenprobe: Ohne `raeumbeziehung` gilt die Fahrbeziehung der Gruppe.
226 const basis = sammelgruppe();
227 const konflikt = beziehung(basis);
228 const project: Project = {
229 ...basis,
230 conflicts: basis.conflicts.map((c) => {
231 const { raeumbeziehung: _weg, ...ohne } = c;
232 return ohne;
233 }),
234 };
235 const { wurzel, aufraeumen } = zeichne(conflictsView, project);
236 try {
237 const fenster = oeffneBeziehung(wurzel, project, konflikt);
238 expect(hinweisZu(fenster, 'Räumgeschwindigkeit')).toContain('Regelwert 10 m/s');
239 expect(fenstertext(fenster)).not.toContain('Den Räumweg dieser Beziehung fährt');
240 } finally {
241 aufraeumen();
242 }
243 });
244 });