lsa-planer

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

/ tests ui anordnungsangaben.test.ts

16,6 KB Rohdatei
tests/ui/anordnungsangaben.test.ts — 406 Zeilen
1 import { afterEach, describe, expect, it } from 'vitest';
2 import { ProjectStore } from '@/app/store';
3 import { buildSignalPlan } from '@/domain/plan/signalPlan';
4 import { validateProject } from '@/domain/validation';
5 import { createStandardIntersectionProject } from '@/domain/model/factory';
6 import { RILSA_DEFAULTS } from '@/domain/rilsa/constants';
7 import { RECHTSGRUNDLAGEN, RECHTSGRUNDLAGEN_EINSTREIFIG } from '@/domain/rilsa/quellen';
8 import { DEFAULT_PDF_OPTIONS } from '@/services/export/pdf';
9 import type { Anlagenart, Project } from '@/domain/model/project';
10 import type { AppState } from '@/app/store';
11 import type { ViewDefinition } from '@/ui/shell';
12 import { ASSISTENT_VORGABE, projektAusAngaben } from '@/ui/assistent';
13 import { naechsterSchritt } from '@/ui/naechsterSchritt';
14 import { HILFE, glossarEintraege, glossarSuche } from '@/ui/help/texte';
15 import { projectView } from '@/ui/views/projectView';
16 import { settingsView } from '@/ui/views/settingsView';
17 import {
18 RECHENWEG_ABGEWAEHLT_HINWEIS,
19 RECHENWEG_SCHALTER,
20 exportView,
21 } from '@/ui/views/exportView';
22
23 /**
24 * Unterlagen und Rechtsgrundlagen in der Oberflaeche (Befunde C13, C14, C15,
25 * C17, C19).
26 *
27 * C14 - Einsatzzeiten und Betriebsverantwortlicher: Beide Pflichtinhalte einer
28 * verkehrsbehoerdlichen Anordnung hatten im Programm kein Feld. Der gefaehrliche
29 * Teil war nicht die fehlende Zeile, sondern der fehlende Lueckenausweis: Eine
30 * Unterlage, die alle Felder ausgefuellt zeigt, die sie kennt, sieht
31 * vollstaendig aus.
32 * C15 - Der Rechenweg der Zwischenzeiten und der Wartezeit war voreingestellt
33 * abgewaehlt und hiess nur "Rechenweg der Zwischenzeiten".
34 * C13 - RSA 21, TL und ZTV transportable LSA 2023 fehlten in der rechtlichen
35 * Einordnung; sie erscheinen jetzt bei einstreifiger Fuehrung.
36 * C17 - Die VwV-StVO gibt Gelb- und Rot-Gelb-Zeit selbst vor; die Hilfe sagte
37 * nur "aus dem Regelwerk abgeleitet".
38 * C19 - Die OePNV-Mindestfreigabezeit stand als gesicherter RiLSA-Wert.
39 *
40 * Gegen den Altstand uebersetzt diese Datei nicht einmal: HILFE.rechtsgrundlagen,
41 * HILFE.einsatzzeiten, HILFE.betriebsverantwortlicher, RECHENWEG_SCHALTER,
42 * RECHENWEG_ABGEWAEHLT_HINWEIS, RECHTSGRUNDLAGEN_EINSTREIFIG und
43 * meta.einsatzzeiten gab es nicht.
44 */
45
46 const FESTES_DATUM = new Date('2026-01-01T00:00:00Z');
47
48 function zeichne(
49 view: ViewDefinition,
50 project: Project,
51 ): { wurzel: HTMLElement; store: ProjectStore; aufraeumen: () => void } {
52 const wurzel = document.createElement('div');
53 document.body.append(wurzel);
54 const store = new ProjectStore(project);
55 const abmelden = view.render(wurzel, {
56 store,
57 refresh: () => {},
58 navigate: () => {},
59 target: null,
60 });
61 return {
62 wurzel,
63 store,
64 aufraeumen: () => {
65 abmelden();
66 wurzel.remove();
67 },
68 };
69 }
70
71 /** Eingabefeld zu einer sichtbaren Beschriftung - ueber `for`, nicht ueber die Lage. */
72 function feldZu(wurzel: ParentNode, beschriftung: string): HTMLInputElement {
73 const label = [...wurzel.querySelectorAll('label')].find(
74 (l) => (l.textContent ?? '').trim() === beschriftung,
75 );
76 if (label === undefined) throw new Error(`Keine Beschriftung "${beschriftung}"`);
77 const feld = wurzel.querySelector<HTMLInputElement>(`#${label.htmlFor}`);
78 if (feld === null) throw new Error(`Beschriftung "${beschriftung}" ohne Feld`);
79 return feld;
80 }
81
82 /** Kontrollkaestchen zu einer sichtbaren Beschriftung. */
83 function schalterZu(wurzel: ParentNode, beschriftung: string): HTMLInputElement {
84 return feldZu(wurzel, beschriftung);
85 }
86
87 function projektMit(anlagenart: Anlagenart, patch: Partial<Project['meta']> = {}): Project {
88 const basis = createStandardIntersectionProject('Anordnung', FESTES_DATUM);
89 return {
90 ...basis,
91 anlagenart,
92 meta: { ...basis.meta, ...patch },
93 };
94 }
95
96 function zustand(project: Project): AppState {
97 const plan = buildSignalPlan(project);
98 return {
99 project,
100 plan,
101 report: validateProject(project, plan, FESTES_DATUM),
102 dirty: false,
103 filePath: null,
104 canUndo: false,
105 canRedo: false,
106 undoLabel: null,
107 redoLabel: null,
108 };
109 }
110
111 /** Eine bemessene Engstelle ohne Fehler - damit der Schritt ueberhaupt so weit kommt. */
112 function engstelle(patch: Partial<Project['meta']> = {}): Project {
113 const basis = projektAusAngaben(
114 { ...ASSISTENT_VORGABE, form: 'einstreifig', verkehrsstaerke: 300 },
115 'Engstelle',
116 FESTES_DATUM,
117 );
118 return {
119 ...basis,
120 meta: {
121 ...basis.meta,
122 projectNumber: 'VP-2026-018',
123 client: 'Stadt Musterhausen',
124 planner: 'Willerding',
125 location: 'Hauptstraße',
126 ...patch,
127 },
128 };
129 }
130
131 afterEach(() => {
132 document.body.replaceChildren();
133 });
134
135 describe('Projektdaten: Einsatzzeiten und Betriebsverantwortlicher (Befund C14)', () => {
136 it('zeigt beide Felder mit ihrem Wert und schreibt Änderungen mit eigener Undo-Beschriftung', () => {
137 const { wurzel, store, aufraeumen } = zeichne(
138 projectView,
139 projektMit('knotenpunkt', { einsatzzeiten: '12.05.–30.06.2027, ganztägig' }),
140 );
141 try {
142 const zeiten = feldZu(wurzel, 'Einsatzzeiten / Geltungszeitraum');
143 expect(zeiten.value).toBe('12.05.–30.06.2027, ganztägig');
144
145 const verantwortlich = feldZu(wurzel, 'Betriebsverantwortlicher');
146 expect(verantwortlich.value).toBe('');
147
148 verantwortlich.value = 'Mustermann Bau GmbH, 0170 1234567';
149 verantwortlich.dispatchEvent(new Event('change'));
150 expect(store.getProject().meta.betriebsverantwortlicher).toBe(
151 'Mustermann Bau GmbH, 0170 1234567',
152 );
153 // Die Undo-Beschriftung nennt die Angabe, nicht "Projektdaten geändert":
154 // Wer zurueckgeht, muss wissen, was er zuruecknimmt.
155 expect(store.getState().undoLabel).toBe('Betriebsverantwortlicher geändert');
156
157 zeiten.value = 'werktags 6–20 Uhr';
158 zeiten.dispatchEvent(new Event('change'));
159 expect(store.getProject().meta.einsatzzeiten).toBe('werktags 6–20 Uhr');
160 expect(store.getState().undoLabel).toBe('Einsatzzeiten geändert');
161 } finally {
162 aufraeumen();
163 }
164 });
165
166 it('nennt bei einstreifiger Führung die RSA-Fundstelle und sonst § 45 StVO', () => {
167 const einstreifig = zeichne(projectView, projektMit('einstreifig'));
168 try {
169 const text = einstreifig.wurzel.textContent ?? '';
170 expect(text).toContain('Angaben der verkehrsbehördlichen Anordnung');
171 expect(text).toContain('RSA 21, Teil A 1.4');
172 expect(text).toContain('warnt der Prüfbericht');
173 } finally {
174 einstreifig.aufraeumen();
175 }
176
177 const knoten = zeichne(projectView, projektMit('knotenpunkt'));
178 try {
179 const text = knoten.wurzel.textContent ?? '';
180 // Am Knotenpunkt keine RSA: Sie zu nennen behauptete einen Bezug, den es
181 // nicht gibt - und genau das ist der Grundsatz hinter Befund C13.
182 expect(text).not.toContain('RSA 21');
183 expect(text).toContain('§ 45 StVO');
184 expect(text).toContain('kein bezifferter Regelwerkswert');
185 } finally {
186 knoten.aufraeumen();
187 }
188 });
189
190 it('stellt beiden Feldern einen Hilfeknopf zur Seite, ohne ihn in den Namen zu ziehen', () => {
191 const { wurzel, aufraeumen } = zeichne(projectView, projektMit('einstreifig'));
192 try {
193 for (const beschriftung of ['Einsatzzeiten / Geltungszeitraum', 'Betriebsverantwortlicher']) {
194 const label = [...wurzel.querySelectorAll('label')].find(
195 (l) => (l.textContent ?? '').trim() === beschriftung,
196 );
197 expect(label, beschriftung).toBeDefined();
198 const kopf = label!.parentElement;
199 expect(kopf?.classList.contains('feld-kopf'), beschriftung).toBe(true);
200 // Der Knopf steht NEBEN der Beschriftung: Im `label` waere er Teil des
201 // Feldnamens ("Betriebsverantwortlicher Hilfe zu ...").
202 expect(label!.querySelector('.hilfe-knopf'), beschriftung).toBeNull();
203 expect(kopf!.querySelector('button.hilfe-knopf'), beschriftung).not.toBeNull();
204 }
205 } finally {
206 aufraeumen();
207 }
208 });
209 });
210
211 describe('Nächster Arbeitsschritt nennt die Anordnungsangaben (Befund C14)', () => {
212 it('verlangt bei einstreifiger Führung beide Angaben und führt zu den Projektdaten', () => {
213 const state = zustand(engstelle());
214 expect(state.report.errorCount, 'Vorbedingung: kein Fehler mehr offen').toBe(0);
215
216 const schritt = naechsterSchritt(state);
217 expect(schritt.ansicht).toBe('projekt');
218 expect(schritt.text).toContain('die Einsatzzeiten');
219 expect(schritt.text).toContain('den Betriebsverantwortlichen');
220 expect(schritt.grund).toContain('RSA 21, Teil A 1.4');
221 expect(schritt.ziel).toBe('Zu den Projektdaten');
222 });
223
224 it('nennt nur noch die fehlende der beiden Angaben', () => {
225 const schritt = naechsterSchritt(zustand(engstelle({ einsatzzeiten: 'ganztägig' })));
226 expect(schritt.ansicht).toBe('projekt');
227 expect(schritt.text).not.toContain('die Einsatzzeiten');
228 expect(schritt.text).toContain('den Betriebsverantwortlichen');
229 });
230
231 it('schweigt, sobald beide Angaben stehen', () => {
232 const schritt = naechsterSchritt(
233 zustand(engstelle({ einsatzzeiten: 'ganztägig', betriebsverantwortlicher: 'Mustermann' })),
234 );
235 expect(schritt.text).not.toContain('Einsatzzeiten');
236 expect(schritt.text).not.toContain('Betriebsverantwortlichen');
237 });
238
239 it('verlangt sie am Knotenpunkt nicht, auch wenn sie fehlen', () => {
240 // Dort meldet der Pruefbericht sie als Hinweis - ein Arbeitsschritt, der an
241 // jeder Anlage nach zwei Textfeldern verlangt, waere der Schritt, den man
242 // ueberliest.
243 const knoten = zustand({ ...engstelle(), anlagenart: 'knotenpunkt' });
244 expect(knoten.project.meta.einsatzzeiten ?? '').toBe('');
245 expect(naechsterSchritt(knoten).ansicht).not.toBe('projekt');
246 });
247 });
248
249 describe('Ausgabe: Rechenweg der Zwischenzeiten und der Wartezeit (Befund C15)', () => {
250 it('beschriftet den Schalter mit beiden Rechenwegen und übernimmt die Vorgabe des Ausdrucks', () => {
251 const { wurzel, aufraeumen } = zeichne(exportView, projektMit('knotenpunkt'));
252 try {
253 const schalter = schalterZu(wurzel, RECHENWEG_SCHALTER);
254 expect(schalter.type).toBe('checkbox');
255 // Die Vorgabe selbst steht in pdf.ts (DEFAULT_PDF_OPTIONS); die Ansicht
256 // fuehrt keine zweite Fassung, sondern zeigt genau sie an - und sie steht
257 // seit Befund C15 auf "an", weil die Behoerdenpraxis den
258 // Zwischenzeitennachweis verlangt.
259 expect(DEFAULT_PDF_OPTIONS.includeCalculationDetails).toBe(true);
260 expect(schalter.checked).toBe(DEFAULT_PDF_OPTIONS.includeCalculationDetails);
261 const hinweis = document.getElementById(`${schalter.id}-hinweis`)?.textContent ?? '';
262 expect(hinweis).toContain('Rechenweg der Wartezeit');
263 expect(hinweis).toContain('Zwischenzeitennachweis');
264 } finally {
265 aufraeumen();
266 }
267 });
268
269 it('blendet beim Abwählen den Vermerk ein und beim Anwählen wieder aus', () => {
270 const { wurzel, aufraeumen } = zeichne(exportView, projektMit('knotenpunkt'));
271 try {
272 const schalter = schalterZu(wurzel, RECHENWEG_SCHALTER);
273 const vermerk = [...wurzel.querySelectorAll<HTMLElement>('[role="status"]')].find((k) =>
274 (k.textContent ?? '').includes(RECHENWEG_ABGEWAEHLT_HINWEIS),
275 );
276 expect(vermerk, 'Vermerk zum abgewählten Rechenweg fehlt').toBeDefined();
277
278 const setze = (wert: boolean): void => {
279 schalter.checked = wert;
280 schalter.dispatchEvent(new Event('change'));
281 };
282
283 setze(false);
284 expect(vermerk!.hidden, 'abgewählt: Vermerk sichtbar').toBe(false);
285 expect(vermerk!.textContent).toContain('nicht nachrechenbar');
286
287 setze(true);
288 expect(vermerk!.hidden, 'angewählt: Vermerk verborgen').toBe(true);
289
290 setze(false);
291 expect(vermerk!.hidden, 'erneut abgewählt: Vermerk wieder sichtbar').toBe(false);
292 } finally {
293 aufraeumen();
294 }
295 });
296 });
297
298 describe('Rechtliche Einordnung je Anlagenart (Befund C13)', () => {
299 const nurEinstreifig = RECHTSGRUNDLAGEN_EINSTREIFIG.map((g) => g.bezeichnung);
300
301 it('führt bei einstreifiger Führung RSA 21, TL und ZTV zusätzlich zum Grundstock', () => {
302 const { wurzel, aufraeumen } = zeichne(settingsView, projektMit('einstreifig'));
303 try {
304 const text = wurzel.textContent ?? '';
305 for (const bezeichnung of [
306 ...RECHTSGRUNDLAGEN.map((g) => g.bezeichnung),
307 ...nurEinstreifig,
308 ]) {
309 expect(text, bezeichnung).toContain(bezeichnung);
310 }
311 expect(text).toContain('ARS Nr. 07/2024');
312 expect(text).toContain('Regelwerke der Arbeitsstellensicherung');
313 } finally {
314 aufraeumen();
315 }
316 });
317
318 it('lässt sie am Knotenpunkt weg und sagt, wann sie erscheinen', () => {
319 const { wurzel, aufraeumen } = zeichne(settingsView, projektMit('knotenpunkt'));
320 try {
321 const text = wurzel.textContent ?? '';
322 for (const bezeichnung of RECHTSGRUNDLAGEN.map((g) => g.bezeichnung)) {
323 expect(text, bezeichnung).toContain(bezeichnung);
324 }
325 for (const bezeichnung of nurEinstreifig) {
326 expect(text, bezeichnung).not.toContain(bezeichnung);
327 }
328 expect(text).toContain('nur bei der Anlagenart „Einstreifige Verkehrsführung“');
329 } finally {
330 aufraeumen();
331 }
332 });
333
334 it('stellt dem Abschnitt einen Hilfeknopf zu den Rechtsgrundlagen voran', () => {
335 const { wurzel, aufraeumen } = zeichne(settingsView, projektMit('knotenpunkt'));
336 try {
337 const ueberschrift = [...wurzel.querySelectorAll('h2, h3')].find((h) =>
338 (h.textContent ?? '').includes('Rechtliche Einordnung'),
339 );
340 expect(ueberschrift, 'Abschnitt "Rechtliche Einordnung" fehlt').toBeDefined();
341 const kopf = ueberschrift!.closest('.abschnitt-kopf') ?? ueberschrift!.parentElement;
342 const knopf = kopf?.querySelector<HTMLButtonElement>('button.hilfe-knopf');
343 expect(knopf, 'Hilfeknopf am Abschnitt fehlt').not.toBeNull();
344 expect(knopf!.getAttribute('aria-label')).toBe(`Hilfe zu ${HILFE.rechtsgrundlagen.titel}`);
345 } finally {
346 aufraeumen();
347 }
348 });
349 });
350
351 describe('Hilfetexte zu Rechtsgrundlagen und Kennwerten (Befunde C13, C17, C19)', () => {
352 it('erklärt die VwV-StVO als eigene Vorgabe, nicht als Verweis auf die FGSV', () => {
353 const eintrag = HILFE.rechtsgrundlagen;
354 expect(eintrag.wasIstDas).toContain('Verwaltungsvorschrift');
355 expect(eintrag.wasIstDas).toContain('selbst vor (Rn. 17)');
356 expect(eintrag.wasIstDas).toContain('3/4/5 s bei 50/60/70 km/h');
357 expect(eintrag.wasIstDas).toContain('1 s, höchstens 2 s');
358 expect(eintrag.wasIstDas).toContain('Rn. 10');
359 // Der Vorbehalt zur Neufassung 2025 gehoert dazu: Die Randnummern koennten
360 // sich verschoben haben.
361 expect(eintrag.ueblich).toContain('2025 neu gefasst');
362 });
363
364 it('nennt die Regelwerke der Arbeitsstellensicherung und ihre Einführung', () => {
365 const text = HILFE.rechtsgrundlagen.wasIstDas;
366 expect(text).toContain('RSA 21');
367 expect(text).toContain('TL und ZTV transportable Lichtsignalanlagen 2023');
368 expect(text).toContain('ARS Nr. 07/2024');
369 // Und den Unterschied Gesetz / Vorschrift / Regel der Technik.
370 expect(text).toContain('anerkannte Regeln der Technik');
371 expect(HILFE.rechtsgrundlagen.woher).toContain('nur bei einstreifiger Verkehrsführung');
372 });
373
374 it('führt die Gelbzeitstaffel auf die VwV-StVO zurück', () => {
375 expect(HILFE.gelbzeit.woher).toContain('VwV-StVO zu § 37 (Rn. 17) selbst vor');
376 expect(HILFE.gelbzeit.woher).toContain('die RiLSA nennt dieselben Werte');
377 });
378
379 it('weist die ÖPNV-Mindestfreigabezeit als Wert der Planungspraxis aus', () => {
380 const text = HILFE.mindestfreigabezeit.wasIstDas;
381 expect(text).toContain(`${RILSA_DEFAULTS.minGreen.oepnv} s`);
382 expect(text).toContain('Plausibilitätswert der Planungspraxis');
383 expect(text).toContain('kein Wert der RiLSA');
384 expect(text).toContain('Planungspraxis“');
385 // Der belegte Wert bleibt der belegte Wert.
386 expect(text).toContain('allgemeine Mindestfreigabezeit von 5 s');
387 });
388
389 it('erklärt beide Anordnungsangaben und führt sie im Glossar', () => {
390 for (const schluessel of ['einsatzzeiten', 'betriebsverantwortlicher'] as const) {
391 const eintrag = HILFE[schluessel];
392 expect(eintrag.woher.length, schluessel).toBeGreaterThan(40);
393 // Beide sind ausdruecklich KEINE Rechengroessen - sonst sucht jemand die
394 // Wirkung im Signalzeitenplan.
395 expect(eintrag.wennFalsch, schluessel).toContain('Keine Rechenwirkung');
396 expect(
397 glossarEintraege().map((e) => e.schluessel),
398 schluessel,
399 ).toContain(schluessel);
400 }
401 expect(glossarSuche('Betriebsverantwortlicher').map((e) => e.schluessel)).toContain(
402 'betriebsverantwortlicher',
403 );
404 expect(glossarSuche('Rechtsgrundlagen').map((e) => e.schluessel)).toContain('rechtsgrundlagen');
405 });
406 });