lsa-planer

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

/ tests ui blindenzusatzUndEngstelle.test.ts

14,7 KB Rohdatei
tests/ui/blindenzusatzUndEngstelle.test.ts — 332 Zeilen
1 import { describe, expect, it } from 'vitest';
2 import { ProjectStore } from '@/app/store';
3 import { buildSignalPlan } from '@/domain/plan/signalPlan';
4 import { createStandardIntersectionProject } from '@/domain/model/factory';
5 import { ENGSTELLE_PRAXIS, RSA_EINSTREIFIG, VZUL_OBERGRENZE_LSA } from '@/domain/rilsa/constants';
6 import type { Project } from '@/domain/model/project';
7 import type { ViewDefinition } from '@/ui/shell';
8 import { ASSISTENT_VORGABE, fuelleEngstellenkasten } from '@/ui/assistent';
9 import { HILFE } from '@/ui/help/texte';
10 import { projectView } from '@/ui/views/projectView';
11 import { mindestfreigabeHinweis, signalGroupsView } from '@/ui/views/signalGroupsView';
12
13 /**
14 * Oberflaeche zu den Befunden C1 bis C6:
15 *
16 * - C1: Das Kontrollkaestchen "Zusatzeinrichtung fuer Blinde und Sehbehinderte"
17 * gibt es nur fuer Fussgaengergruppen, und der Platzhalter der
18 * Mindestfreigabezeit nennt den MASSGEBENDEN Wert aus dem Plan - nicht mehr
19 * nur den Regelwert, waehrend der Plan mit einem hoeheren verteilt.
20 * - C2/C6: Hilfe und Projektansicht nennen die 70-km/h-Grenze der VwV-StVO.
21 * - C3/C5: Der Engstellen-Schritt des Assistenten zeigt die Einstufung nach
22 * Behoerdenpraxis (400/600 m, als solche benannt) und die RSA-Kriterien.
23 *
24 * Gegen den Altstand schlagen diese Faelle fehl: Dort gab es weder Kaestchen
25 * noch Hilfeeintrag, der Platzhalter stand fest auf dem Regelwert, und der
26 * Assistent schwieg zwischen 400 und rund 835 m.
27 */
28
29 const FESTES_DATUM = new Date('2026-01-01T00:00:00Z');
30
31 function zeichne(
32 view: ViewDefinition,
33 project: Project,
34 ): { wurzel: HTMLElement; store: ProjectStore; aufraeumen: () => void } {
35 const wurzel = document.createElement('div');
36 document.body.append(wurzel);
37 const store = new ProjectStore(project);
38 const aufraeumen = view.render(wurzel, {
39 store,
40 refresh: () => {},
41 navigate: () => {},
42 target: null,
43 });
44 return {
45 wurzel,
46 store,
47 aufraeumen: () => {
48 aufraeumen();
49 wurzel.remove();
50 },
51 };
52 }
53
54 /** Spaltenindex zu einem sichtbaren Kopftext (ohne Hilfeknopf). */
55 function spalte(wurzel: ParentNode, kopf: string): number {
56 const koepfe = [...wurzel.querySelectorAll('thead th')].map((th) =>
57 [...th.childNodes]
58 .filter((k) => !(k instanceof Element && k.classList.contains('hilfe-knopf')))
59 .map((k) => k.textContent ?? '')
60 .join('')
61 .trim(),
62 );
63 const index = koepfe.indexOf(kopf);
64 if (index < 0) throw new Error(`Keine Spalte "${kopf}" in ${koepfe.join(' | ')}`);
65 return index;
66 }
67
68 describe('Signalgruppen: Zusatzeinrichtung fuer Blinde und Sehbehinderte (Befund C1)', () => {
69 it('bietet das Kaestchen nur fuer Fussgaengergruppen an', () => {
70 const p = createStandardIntersectionProject('Furt', FESTES_DATUM);
71 const { wurzel, aufraeumen } = zeichne(signalGroupsView, p);
72 try {
73 const s = spalte(wurzel, 'Blindenzusatz');
74 for (const gruppe of p.signalGroups) {
75 const zeile = wurzel.querySelector<HTMLTableRowElement>(`#sg-${gruppe.id}`)!;
76 const zelle = zeile.children[s] as HTMLElement;
77 const kaestchen = zelle.querySelector<HTMLInputElement>('input[type="checkbox"]');
78 if (gruppe.mode === 'fuss') {
79 expect(kaestchen, `${gruppe.name}: Kaestchen fehlt`).not.toBeNull();
80 expect(kaestchen!.checked).toBe(false);
81 /*
82 * Der Name sagt, was der Haken bewirkt - nicht nur "Blindenzusatz" -
83 * UND welche Furt gemeint ist. Vor der Fassung 5.11.0 (Befund 18)
84 * stand der ganze Erklaersatz im `aria-label`, und damit hiessen alle
85 * Furtzeilen gleich. Der Satz ist nicht verschwunden, er ist jetzt
86 * die vorgelesene Beschreibung des Feldes; geprueft wird er dort, wo
87 * er steht.
88 */
89 expect(kaestchen!.getAttribute('aria-label')).toContain('Blinde und Sehbehinderte');
90 expect(kaestchen!.getAttribute('aria-label')).toContain(gruppe.name);
91 const beschreibung = wurzel.querySelector(
92 `[id="${kaestchen!.getAttribute('aria-describedby') ?? ''}"]`,
93 );
94 expect(beschreibung?.textContent).toContain('ganze Furt');
95 } else {
96 expect(kaestchen, `${gruppe.name}: Kaestchen darf nicht da sein`).toBeNull();
97 expect(zelle.textContent?.trim()).toBe('–');
98 }
99 }
100 } finally {
101 aufraeumen();
102 }
103 });
104
105 it('schreibt den Haken in die Signalgruppe', () => {
106 const p = createStandardIntersectionProject('Furt', FESTES_DATUM);
107 const { wurzel, store, aufraeumen } = zeichne(signalGroupsView, p);
108 try {
109 const f1 = p.signalGroups.find((g) => g.name === 'F1')!;
110 const s = spalte(wurzel, 'Blindenzusatz');
111 const zeile = wurzel.querySelector<HTMLTableRowElement>(`#sg-${f1.id}`)!;
112 const kaestchen = (zeile.children[s] as HTMLElement).querySelector<HTMLInputElement>(
113 'input',
114 )!;
115 kaestchen.checked = true;
116 kaestchen.dispatchEvent(new Event('change'));
117 expect(store.getProject().signalGroups.find((g) => g.id === f1.id)!.blindenzusatz).toBe(true);
118 // Der Plan rechnet sofort mit der ganzen Furt: 12 m / 1,2 m/s = 10 s.
119 expect(store.getPlan().groups.find((g) => g.groupId === f1.id)!.times.minGreen).toBe(10);
120 } finally {
121 aufraeumen();
122 }
123 });
124
125 it('nennt im Platzhalter der Mindestfreigabezeit den massgebenden Wert, nicht nur den Regelwert', () => {
126 const roh = createStandardIntersectionProject('Furt', FESTES_DATUM);
127 const p: Project = {
128 ...roh,
129 signalGroups: roh.signalGroups.map((g) =>
130 g.name === 'F1' ? { ...g, blindenzusatz: true } : g,
131 ),
132 };
133 const { wurzel, aufraeumen } = zeichne(signalGroupsView, p);
134 try {
135 const s = spalte(wurzel, 'Mindestfreigabe');
136 const f1 = p.signalGroups.find((g) => g.name === 'F1')!;
137 const f2 = p.signalGroups.find((g) => g.name === 'F2')!;
138 const feld = (id: string): HTMLInputElement =>
139 (
140 wurzel.querySelector<HTMLTableRowElement>(`#sg-${id}`)!.children[s] as HTMLElement
141 ).querySelector('input')!;
142 // F1 mit Zusatz: ganze Furt 12 m / 1,2 m/s = 10 s; F2 ohne: halbe Furt 6 m -> 5 s = Regelwert.
143 expect(feld(f1.id).placeholder).toBe('10');
144 expect(feld(f1.id).title).toContain('Regelwert 5 s');
145 expect(feld(f1.id).title).toContain('Maßgebend 10 s aus der Furtlänge');
146 expect(feld(f1.id).title).toContain('12,00 m / 1,2 m/s = 10,000 s');
147 // "Leer lassen" verspricht hier den Furtbedarf, nicht den Regelwert -
148 // den gaebe es bei leerem Feld nicht.
149 expect(feld(f1.id).title).toContain('Leer lassen: es gilt der Furtbedarf 10 s');
150 expect(feld(f1.id).title).not.toContain('um den Regelwert zu verwenden');
151 expect(feld(f2.id).placeholder).toBe('5');
152 expect(feld(f2.id).title).not.toContain('Furtlänge');
153 expect(feld(f2.id).title).toContain('Leer lassen, um den Regelwert zu verwenden');
154 } finally {
155 aufraeumen();
156 }
157 });
158
159 it('formuliert den Kurzhinweis aus dem Rechenweg des Plans', () => {
160 const roh = createStandardIntersectionProject('Furt', FESTES_DATUM);
161 const p: Project = {
162 ...roh,
163 signalGroups: roh.signalGroups.map((g) =>
164 g.name === 'F1' ? { ...g, reducedMobility: true, blindenzusatz: true } : g,
165 ),
166 };
167 const plan = buildSignalPlan(p);
168 const f1 = plan.groups.find((g) => g.name === 'F1')!;
169 // 12 m / 1,0 m/s = 12 s.
170 expect(f1.times.minGreen).toBe(12);
171 const hinweis = mindestfreigabeHinweis(5, f1.mindestfreigabezeit);
172 expect(hinweis).toContain('ganze Furt');
173 expect(hinweis).toContain('Blinde und Sehbehinderte');
174 expect(hinweis).toContain('12,00 m / 1,0 m/s = 12,000 s, aufgerundet 12 s');
175 expect(hinweis).toContain('Leer lassen: es gilt der Furtbedarf 12 s');
176 // Ohne Plan-Eintrag bleibt es beim Regelwert - und ohne Behauptung.
177 expect(mindestfreigabeHinweis(5, undefined)).toBe(
178 'Regelwert 5 s. Leer lassen, um den Regelwert zu verwenden; ein Eintrag gilt nur nach oben.',
179 );
180 });
181
182 it('nennt bei einer hoeheren Vorgabe den Furtbedarf trotzdem - denn der gilt bei leerem Feld', () => {
183 // Vorgabe 15 s ueber dem Furtbedarf von 12 s: massgebend die Vorgabe, aber
184 // "Leer lassen" fuehrt auf 12 s, nicht auf den Regelwert 5 s.
185 const roh = createStandardIntersectionProject('Furt', FESTES_DATUM);
186 const p: Project = {
187 ...roh,
188 signalGroups: roh.signalGroups.map((g) =>
189 g.name === 'F1'
190 ? { ...g, reducedMobility: true, blindenzusatz: true, minGreenOverride: 15 }
191 : g,
192 ),
193 };
194 const f1 = buildSignalPlan(p).groups.find((g) => g.name === 'F1')!;
195 expect(f1.mindestfreigabezeit.massgebend).toBe('vorgabe');
196 const hinweis = mindestfreigabeHinweis(5, f1.mindestfreigabezeit);
197 expect(hinweis).toContain('Furtbedarf aus der Furtlänge: ganze Furt');
198 expect(hinweis).toContain(
199 '12,00 m / 1,0 m/s = 12,000 s, aufgerundet 12 s; maßgebend die Vorgabe von 15 s.',
200 );
201 expect(hinweis).toContain('Leer lassen: es gilt der Furtbedarf 12 s');
202 expect(hinweis).not.toContain('um den Regelwert zu verwenden');
203 });
204 });
205
206 describe('Hilfetexte zu den Befunden C1 bis C6', () => {
207 it('erklaert die Zusatzeinrichtung fuer Blinde und Sehbehinderte', () => {
208 const h = HILFE.blindenzusatz;
209 expect(h.titel).toBe('Zusatzeinrichtung für Blinde und Sehbehinderte');
210 expect(h.wasIstDas).toContain('DIN 32981');
211 expect(h.wasIstDas).toContain('ganze Furt');
212 expect(h.wennFalsch).toContain('Sicherheitsrelevant');
213 expect(h.siehe).toContain('mindestfreigabezeit');
214 });
215
216 it('nennt bei der Mindestfreigabezeit die Furtregel mit Rechenweg', () => {
217 const h = HILFE.mindestfreigabezeit;
218 expect(h.wasIstDas).toContain('halbe Furt');
219 expect(h.wasIstDas).toContain('7 m / 1,2 m/s = 5,83 s');
220 expect(h.woher).toContain('ist die Furtlänge unbekannt');
221 expect(h.siehe).toContain('blindenzusatz');
222 });
223
224 it('nennt bei der Hoechstgeschwindigkeit die 70-km/h-Grenze der VwV-StVO', () => {
225 const h = HILFE.vZulKnoten;
226 expect(h.ueblich).toContain(
227 `Über ${VZUL_OBERGRENZE_LSA} km/h sollen Lichtsignalanlagen nicht eingerichtet werden`,
228 );
229 expect(h.ueblich).toContain('VwV-StVO');
230 expect(h.ueblich).toContain(`${RSA_EINSTREIFIG.vZulRegel} km/h`);
231 });
232
233 it('erklaert die Engstellenlaenge mit den Praxisgrenzen als Behoerdenpraxis', () => {
234 const h = HILFE.engstellenlaenge;
235 expect(h.ueblich).toContain(`${ENGSTELLE_PRAXIS.hinweisAb} m`);
236 expect(h.ueblich).toContain(`${ENGSTELLE_PRAXIS.warnungAb} m`);
237 expect(h.ueblich).toContain('Behördenpraxis');
238 expect(h.ueblich).toContain('kein Regelwerkswert');
239 expect(h.ueblich).toContain(`${RSA_EINSTREIFIG.kurzeEngstelleBis} m`);
240 expect(h.wennFalsch).toContain('Sicherheitsrelevant');
241 });
242
243 it('fuehrt die RSA-Anordnungskriterien als Hinweise, nicht als Rechenpruefung', () => {
244 const h = HILFE.rsaAnordnung;
245 expect(h.wasIstDas).toContain(`${RSA_EINSTREIFIG.verkehrsstaerkeRichtwert} Kfz/h`);
246 expect(h.wasIstDas).toContain(`${RSA_EINSTREIFIG.kurzeEngstelleBis} m`);
247 expect(h.wasIstDas).toContain('Typ D');
248 expect(h.wennFalsch).toContain('Keine Rechenwirkung');
249 expect(h.woher).toContain('abzugleichen');
250 });
251 });
252
253 describe('Assistent: Engstellen-Schritt mit Behoerdenpraxis und RSA-Hinweisen (Befunde C3, C5)', () => {
254 const kasten = (laenge: number): HTMLElement => {
255 const node = document.createElement('div');
256 fuelleEngstellenkasten(node, {
257 ...ASSISTENT_VORGABE,
258 form: 'einstreifig',
259 engstellenlaenge: laenge,
260 });
261 return node;
262 };
263
264 it('schweigt bei 120 m zur Behoerdenpraxis', () => {
265 const k = kasten(120);
266 expect(k.textContent).not.toContain('Behördenpraxis');
267 expect(k.className).toBe('hinweisbalken');
268 });
269
270 it('weist bei 450 m auf den Abstimmungsbedarf hin - als Behoerdenpraxis, kein Regelwerkswert', () => {
271 const k = kasten(450);
272 const praxis = k.querySelector('.assistent-praxishinweis')!;
273 expect(praxis, 'Absatz zur Behoerdenpraxis fehlt').not.toBeNull();
274 expect(praxis.textContent).toContain('Hinweis:');
275 expect(praxis.textContent).toContain('450 m lang');
276 expect(praxis.textContent).toContain('Behördenpraxis');
277 expect(praxis.textContent).toContain('kein Regelwerkswert');
278 expect(praxis.textContent).toContain(`bis ${ENGSTELLE_PRAXIS.hinweisAb} m`);
279 expect(praxis.textContent).toContain('Abstimmung mit der Straßenverkehrsbehörde');
280 // Die Wartezeit ist bei 450 m noch unauffaellig - der Kasten bleibt ein Hinweis.
281 expect(k.className).toBe('hinweisbalken');
282 expect(k.querySelector('strong')!.textContent).toBe('Das bewirkt diese Länge');
283 });
284
285 it('warnt bei 650 m, obwohl die Wartezeit noch nicht warnt', () => {
286 const k = kasten(650);
287 const praxis = k.querySelector('.assistent-praxishinweis')!;
288 expect(praxis.textContent).toContain('Warnung:');
289 expect(praxis.textContent).toContain(
290 'erreicht oder überschreitet auch die Ausnahmegrenze von 600 m',
291 );
292 expect(praxis.textContent).toContain('Ablehnung');
293 // Die Behoerdenpraxis hebt die Stufe des Kastens an: Farbe UND Ueberschrift.
294 expect(k.className).toContain('hinweisbalken-warnung');
295 expect(k.querySelector('strong')!.textContent).toContain('Behördenpraxis');
296 });
297
298 it('nennt bei kurzer Engstelle die Regelung ohne Ampel, sonst die drei RSA-Kriterien', () => {
299 const kurz = kasten(RSA_EINSTREIFIG.kurzeEngstelleBis);
300 expect(kurz.querySelector('.assistent-rsahinweis')!.textContent).toContain('Zeichen 208/308');
301 expect(kurz.querySelector('.assistent-rsahinweis')!.textContent).toContain('nicht zwingend');
302
303 const lang = kasten(RSA_EINSTREIFIG.kurzeEngstelleBis + 10);
304 const rsa = lang.querySelector('.assistent-rsahinweis')!.textContent ?? '';
305 expect(rsa).not.toContain('nicht zwingend');
306 expect(rsa).toContain('Verkehrsstärke');
307 expect(rsa).toContain(`${RSA_EINSTREIFIG.verkehrsstaerkeRichtwert} Fz/h`);
308 expect(rsa).toContain('Sichtverhältnisse');
309 });
310 });
311
312 describe('Projekt: Hoechstgeschwindigkeit mit 70-km/h-Hinweis (Befund C2)', () => {
313 it('nennt am Feld die Soll-Vorschrift der VwV-StVO', () => {
314 const { wurzel, aufraeumen } = zeichne(
315 projectView,
316 createStandardIntersectionProject('V', FESTES_DATUM),
317 );
318 try {
319 const label = [...wurzel.querySelectorAll('label')].find(
320 (l) => l.textContent?.trim() === 'Zulässige Höchstgeschwindigkeit',
321 );
322 expect(label, 'Feld fehlt').toBeDefined();
323 const feld = wurzel.querySelector<HTMLInputElement>(`#${label!.htmlFor}`)!;
324 const hinweis = wurzel.querySelector(`#${feld.getAttribute('aria-describedby')}`)!;
325 expect(hinweis.textContent).toContain(`Über ${VZUL_OBERGRENZE_LSA} km/h`);
326 expect(hinweis.textContent).toContain('VwV-StVO');
327 expect(hinweis.textContent).toContain('Prüfbericht');
328 } finally {
329 aufraeumen();
330 }
331 });
332 });