lsa-planer

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

/ tests ui ersatzbelege.test.ts

33,8 KB Rohdatei
tests/ui/ersatzbelege.test.ts — 811 Zeilen
1 import { afterEach, describe, expect, it } from 'vitest';
2 import { ProjectStore } from '@/app/store';
3 import { createStandardIntersectionProject } from '@/domain/model/factory';
4 import {
5 ENGER_RADIUS_LABEL,
6 HBS_PKW_GLEICHWERTE,
7 RILSA_DEFAULTS,
8 UEBERFAHRZEIT_ANSATZ_LABELS,
9 } from '@/domain/rilsa/constants';
10 import { QUELLEN } from '@/domain/rilsa/quellen';
11 import { gelbzeitstaffelSatz } from '@/services/export/bewertung';
12 import type { Project } from '@/domain/model/project';
13 import type { UeberfahrzeitAnsatz } from '@/domain/rilsa/types';
14 import type { ViewDefinition } from '@/ui/shell';
15 import { HILFE, glossarEintraege, glossarSuche } from '@/ui/help/texte';
16 import { conflictsView } from '@/ui/views/conflictsView';
17 import { projectView } from '@/ui/views/projectView';
18 import { settingsView } from '@/ui/views/settingsView';
19 import { schwerverkehrsanteilHinweis, signalGroupsView } from '@/ui/views/signalGroupsView';
20
21 /**
22 * Was die Oberflaeche ueber die beiden neuen Rechenansaetze und ueber den
23 * Schwerverkehr sagt (Fassung 5.5.0, Fragen E1, E2, E5 und E6).
24 *
25 * Der gemeinsame Nenner aller Faelle hier ist der Grundsatz des Programms: Ein
26 * Wert darf nur dann wie ein Regelwerkswert dastehen, wenn er belegt ist.
27 *
28 * - E2: Die Ueberfahrzeit des Kraftfahrzeugverkehrs haengt in diesem Programm
29 * an der Gelbzeit. Dass sie das in der RiLSA NICHT tut, ist aus den
30 * Hamburger Zwischenzeitentafeln nachgerechnet; der RiLSA-Wortlaut zu den
31 * Faellen 1 und 2 ist oeffentlich nicht abgleichbar. Deshalb ein WAEHLBARER
32 * Ansatz mit der Vorgabe auf dem heutigen, konservativen Stand - und mit dem
33 * ehrlichen Hinweis, dass die Wahl 'fest' die Zwischenzeiten VERKUERZT.
34 * - E1: Die 5 m/s bei engem Innenradius stehen auf einer einzigen
35 * Quellenlinie. Sie sind ein Merkmal der einzelnen Konfliktbeziehung, stehen
36 * AUS und tragen ihre Beleglage in der Beschriftung.
37 * - E5: Der Pruefstand der HBS-Qualitaetsstufen ist auf 'gesichert' gewechselt;
38 * die Auslastungsgrade 0,85/0,95 bleiben Praxiswerte.
39 * - E6: Der Schwerverkehr geht ueber den Anpassungsfaktor fSV des HBS 2015 in
40 * die Saettigungsverkehrsstaerke ein, nicht mehr ueber den HCM-Wert 2,0.
41 *
42 * Gegen den Stand vor dem 15.08.2026 schlagen diese Faelle fehl: Dort gab es
43 * weder die Auswahl noch das Kaestchen, die Vorgabenansicht kannte die drei
44 * bedingten Kennwerte nicht, und die Hilfe erklaerte den Schwerverkehr mit
45 * "wie etwa zwei Pkw".
46 */
47
48 const FESTES_DATUM = new Date('2026-01-01T00:00:00Z');
49
50 /** Deutsche Schreibweise wie in der Oberflaeche - fuer die Erwartungswerte. */
51 function deutsch(wert: number, stellen = 1): string {
52 return new Intl.NumberFormat('de-DE', {
53 minimumFractionDigits: stellen,
54 maximumFractionDigits: stellen,
55 }).format(wert);
56 }
57
58 function zeichne(
59 view: ViewDefinition,
60 project: Project,
61 ): { wurzel: HTMLElement; store: ProjectStore; aufraeumen: () => void } {
62 const wurzel = document.createElement('div');
63 document.body.append(wurzel);
64 const store = new ProjectStore(project);
65 const abmelden = view.render(wurzel, {
66 store,
67 refresh: () => {},
68 navigate: () => {},
69 target: null,
70 });
71 return {
72 wurzel,
73 store,
74 aufraeumen: () => {
75 abmelden();
76 wurzel.remove();
77 },
78 };
79 }
80
81 /**
82 * Sichtbarer Text einer Beschriftung - ohne den Hilfeknopf.
83 *
84 * `labelled()` im Zwischenzeitfenster haengt den Knopf IN die Beschriftung; ihr
85 * `textContent` lautet dann "Raeumgeschwindigkeit?". Dieselbe Filterung nimmt
86 * dom.ts an den Spaltenkoepfen vor, aus demselben Grund.
87 */
88 function beschriftungstext(label: HTMLLabelElement): string {
89 return [...label.childNodes]
90 .filter((knoten) => !(knoten instanceof Element && knoten.classList.contains('hilfe-knopf')))
91 .map((knoten) => knoten.textContent ?? '')
92 .join('')
93 .trim();
94 }
95
96 /** Bedienelement zu einer Beschriftung - ueber `for`, nicht ueber die Lage. */
97 function feldZu(wurzel: ParentNode, beschriftung: string): HTMLElement | null {
98 const label = [...wurzel.querySelectorAll('label')].find(
99 (l) => beschriftungstext(l) === beschriftung,
100 );
101 const id = label?.getAttribute('for');
102 if (id === undefined || id === null || id === '') return null;
103 return wurzel.querySelector<HTMLElement>(`[id="${id}"]`);
104 }
105
106 /** Der Feldhinweis, der zu einem Bedienelement vorgelesen wird. */
107 function hinweisZu(wurzel: ParentNode, feld: HTMLElement): string {
108 const id = feld.getAttribute('aria-describedby');
109 if (id === null) return '';
110 return id
111 .split(' ')
112 .map((teil) => wurzel.querySelector(`[id="${teil}"]`)?.textContent ?? '')
113 .join(' ');
114 }
115
116 /** Projekt mit abweichenden Vorgaben und/oder Ueberfahrzeit-Ansatz. */
117 function projekt(
118 options: {
119 ansatz?: UeberfahrzeitAnsatz;
120 rilsa?: Record<string, number>;
121 } = {},
122 ): Project {
123 const basis = createStandardIntersectionProject('Ersatzbelege', FESTES_DATUM);
124 return {
125 ...basis,
126 settings: {
127 ...basis.settings,
128 rilsa: options.rilsa ?? {},
129 ueberfahrzeitAnsatz: options.ansatz ?? basis.settings.ueberfahrzeitAnsatz,
130 },
131 };
132 }
133
134 /**
135 * Knotenpunkt, dessen erste Signalgruppe rechts abbiegt.
136 *
137 * Der Regelknotenpunkt der Vorlage faehrt durchweg geradeaus - dort wirkt der
138 * enge Innenradius nirgends, und genau das ist einer der Faelle unten.
139 */
140 function mitAbbieger(): Project {
141 const basis = projekt();
142 const ersteGruppe = basis.signalGroups[0]!;
143 return {
144 ...basis,
145 signalGroups: basis.signalGroups.map((gruppe) =>
146 gruppe.id === ersteGruppe.id ? { ...gruppe, movement: 'rechts' as const } : gruppe,
147 ),
148 };
149 }
150
151 /** Das Bearbeitungsfenster der ersten Beziehung, deren Zeile `fromId` raeumt. */
152 function oeffneBeziehung(wurzel: HTMLElement, project: Project, fromName: string): ParentNode {
153 const von = project.signalGroups.find((g) => g.name === fromName)!;
154 const konflikt = project.conflicts.find((c) => c.fromId === von.id)!;
155 const zeile = project.signalGroups.findIndex((g) => g.id === konflikt.fromId);
156 const spalte = project.signalGroups.findIndex((g) => g.id === konflikt.toId);
157 const zellen = [...wurzel.querySelectorAll<HTMLButtonElement>('.matrix-zelle')];
158 // Die Matrix ist quadratisch ohne Diagonale: je Zeile n-1 Zellen.
159 const n = project.signalGroups.length;
160 const versatz = spalte > zeile ? spalte - 1 : spalte;
161 zellen[zeile * (n - 1) + versatz]?.click();
162 const fenster = document.querySelector('.dialog');
163 expect(fenster, `Das Fenster zu ${fromName} ist nicht offen`).not.toBeNull();
164 return fenster as ParentNode;
165 }
166
167 /** Abschnitt der Vorgabenansicht, an seiner Ueberschrift gesucht. */
168 function abschnitt(wurzel: HTMLElement, ueberschrift: string): HTMLElement {
169 const kopf = [...wurzel.querySelectorAll('h2, h3')].find(
170 (h) => h.textContent?.trim() === ueberschrift,
171 );
172 if (!kopf) throw new Error(`Abschnitt "${ueberschrift}" fehlt`);
173 return kopf.closest('section.karte') as HTMLElement;
174 }
175
176 /** Zeile der Kennwerttabelle, an ihrer Bezeichnung gesucht. */
177 function kennwertzeile(karte: HTMLElement, bezeichnung: string): HTMLTableRowElement {
178 const zeile = [...karte.querySelectorAll('tbody tr')].find(
179 (tr) => tr.children[0]?.textContent?.trim() === bezeichnung,
180 );
181 if (!zeile) throw new Error(`Kennwert "${bezeichnung}" fehlt`);
182 return zeile as HTMLTableRowElement;
183 }
184
185 afterEach(() => {
186 document.body.replaceChildren();
187 });
188
189 describe('E2 - Auswahl des Ueberfahrzeit-Ansatzes in den Projektdaten', () => {
190 const BESCHRIFTUNG = 'Überfahrzeit des Kraftfahrzeugverkehrs';
191
192 it('fuehrt beide Ansaetze mit den Beschriftungen des Fachkerns', () => {
193 const { wurzel, aufraeumen } = zeichne(projectView, projekt());
194 try {
195 const feld = feldZu(wurzel, BESCHRIFTUNG);
196 expect(feld, 'Die Auswahl des Ansatzes fehlt in den Projektdaten').not.toBeNull();
197 const auswahl = feld as HTMLSelectElement;
198 expect([...auswahl.options].map((o) => o.value)).toEqual(['gelbzeit', 'fest']);
199 // Aus constants.ts und nicht hier nachgebaut: Bildschirm, Pruefbericht
200 // und Ausdruck muessen dieselbe Beschriftung fuehren - samt dem Zusatz,
201 // der die feste Ueberfahrzeit als Regelwert der RiLSA 2015 und die
202 // Kopplung an die Gelbzeit als Ansatz dieses Programms kennzeichnet.
203 expect([...auswahl.options].map((o) => o.textContent)).toEqual([
204 UEBERFAHRZEIT_ANSATZ_LABELS.gelbzeit,
205 UEBERFAHRZEIT_ANSATZ_LABELS.fest,
206 ]);
207 // Seit 5.27.0 nennt die Beschriftung die Fundstelle: Der feste Ansatz
208 // IST der Regelwerksansatz (RiLSA 2015, Abschnitt 2.5.2).
209 expect(UEBERFAHRZEIT_ANSATZ_LABELS.fest).toContain('RiLSA 2015');
210 expect(UEBERFAHRZEIT_ANSATZ_LABELS.fest).not.toContain('nicht abgleichbar');
211 } finally {
212 aufraeumen();
213 }
214 });
215
216 it('ist mit dem konservativen Ansatz vorbelegt', () => {
217 const vorlage = createStandardIntersectionProject('Vorgabe', FESTES_DATUM);
218 expect(vorlage.settings.ueberfahrzeitAnsatz).toBe('gelbzeit');
219
220 const { wurzel, aufraeumen } = zeichne(projectView, vorlage);
221 try {
222 expect((feldZu(wurzel, BESCHRIFTUNG) as HTMLSelectElement).value).toBe('gelbzeit');
223 } finally {
224 aufraeumen();
225 }
226 });
227
228 it('nennt am Feld die Verkuerzung und die Herkunft der festen Werte', () => {
229 const { wurzel, aufraeumen } = zeichne(projectView, projekt());
230 try {
231 const hinweis = hinweisZu(wurzel, feldZu(wurzel, BESCHRIFTUNG)!);
232 expect(hinweis).toContain('VERKÜRZT');
233 // Seit 5.27.0 nennt der Hinweis die Fundstelle statt der Leitfaeden.
234 expect(hinweis).toContain('Regelwerte der RiLSA 2015 (Abschnitt 2.5.2)');
235 expect(hinweis).not.toContain('nicht abgleichbar');
236 // Die Vorgabe ist als solche benannt - sonst liest sich die Auswahl wie
237 // zwei gleichrangige Rechenwege.
238 expect(hinweis).toContain('Vorgabe ist');
239 } finally {
240 aufraeumen();
241 }
242 });
243
244 it('nennt die WIRKSAMEN Ueberfahrzeiten, nicht die Regelwerte', () => {
245 // Befund C21: Beide Werte sind ueber die Vorgabenverwaltung einstellbar.
246 // Ein fest geschriebenes "3 s geradeaus" waere falsch, sobald jemand sie
247 // aendert.
248 const { wurzel, aufraeumen } = zeichne(
249 projectView,
250 projekt({ rilsa: { crossingTimeKfzGeradeaus: 4, crossingTimeKfzAbbiegend: 3 } }),
251 );
252 try {
253 const hinweis = hinweisZu(wurzel, feldZu(wurzel, BESCHRIFTUNG)!);
254 expect(hinweis).toContain('4 s');
255 expect(hinweis).toContain('3 s');
256 /*
257 * Auch die GROESSENORDNUNG der Verkuerzung folgt den Vorgaben
258 * (Fassung 5.5.0, Befund B5): Sie stand fest geschrieben als "beim
259 * Abbiegen um bis zu 3 s je Uebergang" - eine Zahl, die nur fuer die
260 * Regelwerte 3 s/2 s gilt. Mit 4 s und 3 s sind es 1 s geradeaus und 2 s
261 * abbiegend (gegen die groesste Gelbzeit von 5 s).
262 */
263 expect(hinweis).toContain('beim Abbiegen um bis zu 2 s');
264 expect(hinweis).toContain('geradeaus um bis zu 1 s');
265 expect(hinweis).not.toContain('bis zu 3 s');
266 } finally {
267 aufraeumen();
268 }
269 });
270
271 /*
272 * Die Umkehrung desselben Befundes: Eine feste Ueberfahrzeit OBERHALB jeder
273 * Gelbzeit verkuerzt nichts. Der Ansatz bleibt begruendungspflichtig, aber
274 * das Wort "VERKUERZT" waere dann schlicht falsch.
275 */
276 it('behauptet keine Verkuerzung, wenn die festen Werte laenger sind als jede Gelbzeit', () => {
277 const { wurzel, aufraeumen } = zeichne(
278 projectView,
279 projekt({ rilsa: { crossingTimeKfzGeradeaus: 6, crossingTimeKfzAbbiegend: 6 } }),
280 );
281 try {
282 const hinweis = hinweisZu(wurzel, feldZu(wurzel, BESCHRIFTUNG)!);
283 expect(hinweis).not.toContain('VERKÜRZT');
284 expect(hinweis).toContain('nirgends kürzer');
285 } finally {
286 aufraeumen();
287 }
288 });
289
290 it('warnt sichtbar, solange die feste Ueberfahrzeit gewaehlt ist', () => {
291 const { wurzel, aufraeumen } = zeichne(projectView, projekt({ ansatz: 'fest' }));
292 try {
293 const balken = [...wurzel.querySelectorAll('.hinweisbalken-warnung')].find((b) =>
294 b.textContent?.includes('Feste Überfahrzeit gewählt'),
295 );
296 expect(balken, 'Der Warnbalken zur festen Überfahrzeit fehlt').toBeDefined();
297 const text = balken!.textContent ?? '';
298 expect(text).toContain('kürzer');
299 /*
300 * NACHGEZOGEN (5.27.0): Bis dahin verlangte der Balken die Saetze "kein
301 * belegter Regelwerkswert" und den Rat, die Wahl mit der
302 * Strassenverkehrsbehoerde abzustimmen. Die beiden Zahlen SIND der
303 * Regelwerkswert (RiLSA 2015, Abschnitt 2.5.2); zu begruenden ist nicht
304 * mehr die Herkunft, sondern die Wahl gegenueber der laengeren Vorgabe
305 * dieses Programms. Die Untergrenze bleibt im Balken - jetzt als
306 * eingebaute Regel statt als fehlendes Sicherheitsnetz.
307 */
308 expect(text).toContain('Regelwerte der RiLSA 2015 (Abschnitt 2.5.2');
309 expect(text).not.toContain('kein belegter Regelwerkswert');
310 expect(text).toContain('Planunterlagen');
311 expect(text).toContain('Untergrenze');
312 expect(text).toContain('ist eingebaut');
313 expect(text).not.toContain('ist nicht eingebaut');
314 // Die Gelbzeitstaffel, gegen die verkuerzt wird, steht mit ihren Zahlen
315 // dabei - und zwar in derselben Fassung wie im Fundstellenverzeichnis
316 // (Befund C21), nicht als zweite handgeschriebene Zahlenreihe.
317 expect(text).toContain(gelbzeitstaffelSatz(RILSA_DEFAULTS));
318 } finally {
319 aufraeumen();
320 }
321 });
322
323 it('warnt im Regelfall nicht', () => {
324 const { wurzel, aufraeumen } = zeichne(projectView, projekt());
325 try {
326 const balken = [...wurzel.querySelectorAll('.hinweisbalken-warnung')].filter((b) =>
327 b.textContent?.includes('Überfahrzeit'),
328 );
329 expect(balken, 'Der Regelfall braucht keine Warnung').toHaveLength(0);
330 } finally {
331 aufraeumen();
332 }
333 });
334
335 it('uebernimmt die Wahl in das Projekt', () => {
336 const { wurzel, store, aufraeumen } = zeichne(projectView, projekt());
337 try {
338 const auswahl = feldZu(wurzel, BESCHRIFTUNG) as HTMLSelectElement;
339 auswahl.value = 'fest';
340 auswahl.dispatchEvent(new Event('change'));
341 expect(store.getProject().settings.ueberfahrzeitAnsatz).toBe('fest');
342
343 // Und zurueck - der Weg darf nicht einbahnig sein.
344 const zweite = feldZu(wurzel, BESCHRIFTUNG) as HTMLSelectElement;
345 zweite.value = 'gelbzeit';
346 zweite.dispatchEvent(new Event('change'));
347 expect(store.getProject().settings.ueberfahrzeitAnsatz).toBe('gelbzeit');
348 } finally {
349 aufraeumen();
350 }
351 });
352 });
353
354 describe('E1 - Kontrollkaestchen "enger Innenradius" je Konfliktbeziehung', () => {
355 it('steht an einer Beziehung mit abbiegendem Kfz-Raeumstrom, mit ehrlicher Beschriftung', () => {
356 const project = mitAbbieger();
357 const { wurzel, aufraeumen } = zeichne(conflictsView, project);
358 try {
359 const fenster = oeffneBeziehung(wurzel, project, 'K1');
360 const kaestchen = feldZu(fenster, ENGER_RADIUS_LABEL);
361 expect(kaestchen, 'Das Kästchen fehlt am abbiegenden Kfz-Strom').not.toBeNull();
362 expect((kaestchen as HTMLInputElement).checked, 'Vorgabe ist AUS').toBe(false);
363 // Die Beschriftung ist die Aussage: Wer sie liest, weiss ohne Hilfeseite,
364 // dass hier kein Regelwerkswert steht.
365 expect(ENGER_RADIUS_LABEL).toContain('unter 10 m');
366 expect(ENGER_RADIUS_LABEL).toContain('RiLSA 2015, Abschnitt 2.5.2, Fall 2');
367 expect(ENGER_RADIUS_LABEL).not.toContain('Praxisansatz');
368 } finally {
369 aufraeumen();
370 }
371 });
372
373 it('steht nicht an einer Beziehung ohne abbiegenden Kfz-Raeumstrom', () => {
374 const project = projekt();
375 const { wurzel, aufraeumen } = zeichne(conflictsView, project);
376 try {
377 const fenster = oeffneBeziehung(wurzel, project, 'K1');
378 expect(
379 feldZu(fenster, ENGER_RADIUS_LABEL),
380 'Geradeaus gibt es keinen Innenradius - ein Kästchen ohne Wirkung wäre eine Falle',
381 ).toBeNull();
382 } finally {
383 aufraeumen();
384 }
385 });
386
387 it('nennt die WIRKSAMEN Raeumgeschwindigkeiten beider Faelle', () => {
388 const project = mitAbbieger();
389 const { wurzel, aufraeumen } = zeichne(conflictsView, project);
390 try {
391 const fenster = oeffneBeziehung(wurzel, project, 'K1');
392 const hinweis = hinweisZu(fenster, feldZu(fenster, ENGER_RADIUS_LABEL)!);
393 expect(hinweis).toContain(
394 `${deutsch(RILSA_DEFAULTS.clearingSpeed.kfzTurningEngerRadius)} m/s`,
395 );
396 expect(hinweis).toContain(`${deutsch(RILSA_DEFAULTS.clearingSpeed.kfzTurning)} m/s`);
397 expect(hinweis).toContain('länger');
398 // Die Schwelle steht nur in der Beschriftung - das Programm kennt keinen
399 // Abbiegeradius. Das darf die Ansicht nicht verschweigen.
400 expect(hinweis).toContain('prüft sie nicht nach');
401 } finally {
402 aufraeumen();
403 }
404 });
405
406 it('nennt eine geaenderte Vorgabe statt des Regelwerts', () => {
407 const basis = mitAbbieger();
408 const project: Project = {
409 ...basis,
410 settings: { ...basis.settings, rilsa: { clearingSpeedKfzTurningEngerRadius: 6 } },
411 };
412 const { wurzel, aufraeumen } = zeichne(conflictsView, project);
413 try {
414 const fenster = oeffneBeziehung(wurzel, project, 'K1');
415 const hinweis = hinweisZu(fenster, feldZu(fenster, ENGER_RADIUS_LABEL)!);
416 expect(hinweis).toContain('6,0 m/s');
417 expect(hinweis).not.toContain('5,0 m/s');
418 } finally {
419 aufraeumen();
420 }
421 });
422
423 it('schreibt das Merkmal in die Konfliktbeziehung', () => {
424 const project = mitAbbieger();
425 const { wurzel, store, aufraeumen } = zeichne(conflictsView, project);
426 try {
427 const fenster = oeffneBeziehung(wurzel, project, 'K1');
428 const kaestchen = feldZu(fenster, ENGER_RADIUS_LABEL) as HTMLInputElement;
429 kaestchen.checked = true;
430 kaestchen.dispatchEvent(new Event('change'));
431
432 const konflikt = store
433 .getProject()
434 .conflicts.find((c) => c.fromId === project.signalGroups[0]!.id)!;
435 expect(konflikt.engerRadius).toBe(true);
436 // Das Merkmal ist eine Angabe zur Oertlichkeit, keine ersetzte
437 // Messgroesse: Der Herkunftsnachweis der Wege bleibt stehen (Muster
438 // haltVorKnoten).
439 expect(konflikt.wegnachweis.herkunft).toBe(
440 project.conflicts.find((c) => c.id === konflikt.id)!.wegnachweis.herkunft,
441 );
442 } finally {
443 aufraeumen();
444 }
445 });
446
447 it('zeigt ein gesetztes, aber wirkungsloses Merkmal weiter an und sagt es', () => {
448 /*
449 * Der Fall entsteht aus einer Datei oder dadurch, dass die Fahrbeziehung
450 * der Signalgruppe nachtraeglich auf "geradeaus" gestellt wird. Waere das
451 * Kaestchen dann ausgeblendet, gaebe es keine Stelle mehr, an der sich die
452 * Warnung des Pruefberichts abstellen liesse - und im Ausdruck stuende ein
453 * Merkmal, das die Ansicht nicht zeigt.
454 */
455 const basis = projekt();
456 const project: Project = {
457 ...basis,
458 conflicts: basis.conflicts.map((c, index) => (index === 0 ? { ...c, engerRadius: true } : c)),
459 };
460 const { wurzel, aufraeumen } = zeichne(conflictsView, project);
461 try {
462 const fenster = oeffneBeziehung(wurzel, project, 'K1');
463 const kaestchen = feldZu(fenster, ENGER_RADIUS_LABEL);
464 expect(kaestchen, 'Ein gesetztes Merkmal muss sichtbar bleiben').not.toBeNull();
465 expect((kaestchen as HTMLInputElement).checked).toBe(true);
466 expect(hinweisZu(fenster, kaestchen as HTMLElement)).toContain('Ohne Wirkung');
467 } finally {
468 aufraeumen();
469 }
470 });
471
472 it('nennt die angesetzte Raeumgeschwindigkeit nicht mehr "Regelwert", wo der Ansatz wirkt', () => {
473 /*
474 * Der stillste Fehler dieses Fensters: Die Zeile "Regelwert 5 m/s" steht
475 * unmittelbar am Feld und behauptet damit ueber eine Zahl, die auf einer
476 * einzigen Quellenlinie steht, sie sei der Wert des Regelwerks.
477 */
478 const basis = mitAbbieger();
479 const project: Project = {
480 ...basis,
481 conflicts: basis.conflicts.map((c) =>
482 c.fromId === basis.signalGroups[0]!.id ? { ...c, engerRadius: true } : c,
483 ),
484 };
485 const { wurzel, aufraeumen } = zeichne(conflictsView, project);
486 try {
487 const fenster = oeffneBeziehung(wurzel, project, 'K1');
488 const hinweis = hinweisZu(fenster, feldZu(fenster, 'Räumgeschwindigkeit')!);
489 expect(hinweis).toContain('Angesetzt');
490 expect(hinweis).toContain('enger Innenradius');
491 expect(hinweis).not.toContain('Regelwert');
492 } finally {
493 aufraeumen();
494 }
495 });
496
497 it('nennt sie am Regelfall weiterhin Regelwert', () => {
498 const project = mitAbbieger();
499 const { wurzel, aufraeumen } = zeichne(conflictsView, project);
500 try {
501 const fenster = oeffneBeziehung(wurzel, project, 'K1');
502 expect(hinweisZu(fenster, feldZu(fenster, 'Räumgeschwindigkeit')!)).toContain('Regelwert');
503 } finally {
504 aufraeumen();
505 }
506 });
507 });
508
509 describe('E1/E2 - die drei bedingten Kennwerte in der Vorgabenansicht', () => {
510 const ENGER_RADIUS_KENNWERT = 'Räumgeschwindigkeit Kfz abbiegend bei engem Innenradius';
511 const GERADEAUS = 'Überfahrzeit Kfz geradeaus (Ansatz „feste Überfahrzeit")';
512 const ABBIEGEND = 'Überfahrzeit Kfz abbiegend (Ansatz „feste Überfahrzeit")';
513
514 it('fuehrt sie im Abschnitt Zwischenzeiten', () => {
515 const { wurzel, aufraeumen } = zeichne(settingsView, projekt());
516 try {
517 const karte = abschnitt(wurzel, 'Zwischenzeiten');
518 const zeilen = [...karte.querySelectorAll('tbody tr')].map(
519 (tr) => tr.children[0]?.textContent?.trim() ?? '',
520 );
521 expect(zeilen).toContain(ENGER_RADIUS_KENNWERT);
522 expect(zeilen).toContain(GERADEAUS);
523 expect(zeilen).toContain(ABBIEGEND);
524 // Ohne diesen Satz stuenden drei Kennwerte zwischen lauter wirksamen,
525 // die nur unter einer Bedingung in eine Zahl eingehen.
526 expect(karte.textContent).toContain('wirken nur unter einer Bedingung');
527 } finally {
528 aufraeumen();
529 }
530 });
531
532 it('nennt die Herkunft der drei Werte - Praxisansatz, nicht RiLSA', () => {
533 const { wurzel, aufraeumen } = zeichne(settingsView, projekt());
534 try {
535 const karte = abschnitt(wurzel, 'Zwischenzeiten');
536 for (const bezeichnung of [ENGER_RADIUS_KENNWERT, GERADEAUS, ABBIEGEND]) {
537 const herkunft = kennwertzeile(karte, bezeichnung).children[1]?.textContent ?? '';
538 expect(herkunft, bezeichnung).toContain(QUELLEN['praxis-enger-radius'].kurz);
539 }
540 } finally {
541 aufraeumen();
542 }
543 });
544
545 it('vermerkt die festen Ueberfahrzeiten beim Ansatz "Gelbzeit" als ohne Wirkung', () => {
546 const { wurzel, aufraeumen } = zeichne(settingsView, projekt());
547 try {
548 const karte = abschnitt(wurzel, 'Zwischenzeiten');
549 for (const bezeichnung of [GERADEAUS, ABBIEGEND]) {
550 expect(kennwertzeile(karte, bezeichnung).textContent, bezeichnung).toContain(
551 'ohne Wirkung: Überfahrzeit = Gelbzeit',
552 );
553 }
554 /*
555 * UND DEN ENGEN INNENRADIUS EBENSO (Fassung 5.5.0, Befund B6): Traegt
556 * keine Beziehung das Merkmal, geht auch dieser Kennwert in keine Zahl
557 * ein. Hier stand die Gegenprobe "nicht pauschal wirkungslos" - richtig
558 * gedacht, aber sie deckte eine Ungleichbehandlung zu: Der eine bedingte
559 * Kennwert bekam einen Vermerk, der andere unter derselben Bedingung
560 * keinen.
561 */
562 expect(kennwertzeile(karte, ENGER_RADIUS_KENNWERT).textContent).toContain(
563 'ohne Wirkung: kein enger Innenradius gesetzt',
564 );
565 } finally {
566 aufraeumen();
567 }
568 });
569
570 it('nimmt den Vermerk zum engen Innenradius zurueck, sobald eine Beziehung ihn traegt', () => {
571 const basis = mitAbbieger();
572 const project: Project = {
573 ...basis,
574 conflicts: basis.conflicts.map((c, i) => (i === 0 ? { ...c, engerRadius: true } : c)),
575 };
576 const { wurzel, aufraeumen } = zeichne(settingsView, project);
577 try {
578 const karte = abschnitt(wurzel, 'Zwischenzeiten');
579 expect(kennwertzeile(karte, ENGER_RADIUS_KENNWERT).textContent).not.toContain('ohne Wirkung');
580 } finally {
581 aufraeumen();
582 }
583 });
584
585 it('nimmt den Vermerk zurueck, sobald der Ansatz gewaehlt ist', () => {
586 const { wurzel, aufraeumen } = zeichne(settingsView, projekt({ ansatz: 'fest' }));
587 try {
588 const karte = abschnitt(wurzel, 'Zwischenzeiten');
589 for (const bezeichnung of [GERADEAUS, ABBIEGEND]) {
590 expect(kennwertzeile(karte, bezeichnung).textContent, bezeichnung).not.toContain(
591 'ohne Wirkung',
592 );
593 }
594 } finally {
595 aufraeumen();
596 }
597 });
598
599 it('kennzeichnet eine wirkungslose Abweichung auch im Abweichungsbalken', () => {
600 const { wurzel, aufraeumen } = zeichne(
601 settingsView,
602 projekt({ rilsa: { crossingTimeKfzGeradeaus: 4 } }),
603 );
604 try {
605 const balken = [...wurzel.querySelectorAll('.hinweisbalken-warnung')].find((b) =>
606 b.textContent?.includes('weichen vom Regelwerk ab'),
607 );
608 const eintrag = [...balken!.querySelectorAll('li')].find((li) =>
609 li.textContent?.includes('Überfahrzeit Kfz geradeaus'),
610 );
611 expect(eintrag, 'Die Abweichung fehlt im Balken').toBeDefined();
612 expect(eintrag!.textContent).toContain('(ohne Wirkung: Überfahrzeit = Gelbzeit)');
613 } finally {
614 aufraeumen();
615 }
616 });
617
618 it('fuehrt denselben Vermerk im Fundstellenverzeichnis wie im Ausdruck', () => {
619 const { wurzel, aufraeumen } = zeichne(settingsView, projekt());
620 try {
621 [...wurzel.querySelectorAll('button')]
622 .find((b) => b.textContent?.trim() === 'Fundstellenverzeichnis')!
623 .click();
624 const dialog = document.querySelector<HTMLElement>('[role="dialog"]')!;
625 const eintrag = [...dialog.querySelectorAll('p')].find((p) =>
626 p.textContent?.includes('Überfahrzeit Kfz geradeaus'),
627 );
628 expect(eintrag, 'Die festen Überfahrzeiten fehlen im Verzeichnis').toBeDefined();
629 expect(eintrag!.textContent).toContain('(ohne Wirkung: Überfahrzeit = Gelbzeit)');
630 } finally {
631 aufraeumen();
632 }
633 });
634
635 it('verlangt die Begruendung, sobald der Ansatz gewaehlt ist', () => {
636 const { wurzel, aufraeumen } = zeichne(
637 settingsView,
638 projekt({ ansatz: 'fest', rilsa: { crossingTimeKfzGeradeaus: 4 } }),
639 );
640 try {
641 const balken = [...wurzel.querySelectorAll('.hinweisbalken-warnung')].find((b) =>
642 b.textContent?.includes('weichen vom Regelwerk ab'),
643 );
644 const eintrag = [...balken!.querySelectorAll('li')].find((li) =>
645 li.textContent?.includes('Überfahrzeit Kfz geradeaus'),
646 );
647 expect(eintrag!.textContent).not.toContain('ohne Wirkung');
648 } finally {
649 aufraeumen();
650 }
651 });
652 });
653
654 describe('E6 - was die Signalgruppenansicht ueber den Schwerverkehr sagt', () => {
655 it('nennt Anpassungsfaktor und Pkw-Gleichwert aus den Konstanten', () => {
656 const hinweis = schwerverkehrsanteilHinweis();
657 const gleichwert = HBS_PKW_GLEICHWERTE.schwerverkehrPauschal;
658 expect(hinweis).toContain(`1 + ${deutsch(gleichwert - 1)} · Anteil`);
659 expect(hinweis).toContain(`Pkw-Gleichwert ${deutsch(gleichwert)}`);
660 expect(hinweis).toContain('HBS 2015');
661 // Der Pauschalwert ist als solcher benannt - samt seiner Grenze.
662 expect(hinweis).toContain('unbekannter Aufteilung');
663 expect(hinweis).toContain('Lastzügen');
664 });
665
666 it('behauptet kein Pkw-Aequivalent von 2,0 mehr', () => {
667 // Die 2,0 sind der Wert des HCM 2010 und standen bis zum 15.08.2026 unter
668 // der HBS-Quelle - dieselbe Fehlerklasse wie Befund B3 der Fassung 5.4.0.
669 expect(schwerverkehrsanteilHinweis()).not.toContain('2,0');
670 expect(HBS_PKW_GLEICHWERTE.schwerverkehrPauschal).not.toBe(2);
671 });
672
673 it('stellt den Satz unter die Tabelle, ohne den Feldern ihren Namen zu nehmen', () => {
674 const { wurzel, aufraeumen } = zeichne(signalGroupsView, projekt());
675 try {
676 expect(wurzel.textContent).toContain(schwerverkehrsanteilHinweis());
677 /*
678 * Die Gegenprobe zum naheliegenden Fehler: Derselbe Satz als `title` am
679 * Eingabefeld haette ihm seinen Namen genommen - benenneBedienelemente()
680 * ueberspringt jedes Feld mit `title`, und statt "Schwerverkehrsanteil in
681 * Prozent, K1" hiesse das Feld nach dem Hinweistext.
682 */
683 const felder = [...wurzel.querySelectorAll<HTMLInputElement>('input[type="number"]')];
684 expect(felder.some((f) => f.getAttribute('title') === schwerverkehrsanteilHinweis())).toBe(
685 false,
686 );
687 expect(
688 felder.some((f) =>
689 (f.getAttribute('aria-label') ?? '').startsWith('Schwerverkehrsanteil in Prozent'),
690 ),
691 'Das Feld hat seinen Namen aus Spalte und Zeile behalten',
692 ).toBe(true);
693 } finally {
694 aufraeumen();
695 }
696 });
697 });
698
699 describe('Hilfetexte zu den Ersatzbelegen', () => {
700 it('erklaert den engen Innenradius in einem eigenen Eintrag', () => {
701 const eintrag = HILFE.engerRadius;
702 expect(eintrag.wasIstDas).toContain('5,0 m/s');
703 expect(eintrag.wasIstDas).toContain('7,0 m/s');
704 // Seit 5.27.0 die Fundstelle statt der Luecke - und der Vermerk, dass das
705 // Programm den Radius nach wie vor nicht pruefen kann.
706 expect(eintrag.wasIstDas).toContain('Abschnitt 2.5.2');
707 expect(eintrag.wasIstDas).toContain('den Radius prüfen');
708 expect(eintrag.wasIstDas).not.toContain('PRAXISANSATZ');
709 // Und der Grund, warum es trotzdem waehlbar ist: die sichere Richtung.
710 expect(eintrag.wennFalsch).toBeDefined();
711 expect(eintrag.wennFalsch).toContain('verlängert es die Zwischenzeit');
712 expect(eintrag.woher).toContain('prüft sie nicht nach');
713 });
714
715 it('haengt den Eintrag dort an, wo das Merkmal begegnet', () => {
716 expect(HILFE.raeumgeschwindigkeit.siehe ?? []).toContain('engerRadius');
717 expect(HILFE.raeumgeschwindigkeit.woher).toContain('enger Innenradius');
718 expect(glossarEintraege().map((e) => e.schluessel)).toContain('engerRadius');
719 expect(glossarSuche('Innenradius').map((e) => e.schluessel)).toContain('engerRadius');
720 });
721
722 it('erklaert die Ueberfahrzeit als Wahl zwischen zwei Ansaetzen', () => {
723 const eintrag = HILFE.ueberfahrzeit;
724 expect(eintrag.wasIstDas).toContain('Vorgabe');
725 expect(eintrag.wasIstDas).toContain('feste Überfahrzeit');
726 expect(eintrag.wasIstDas).toContain('VERKÜRZT');
727 // Der tragende Beweis gehoert in die Hilfe, nicht nur in den Quelltext.
728 // Seit 5.27.0 die Fundstelle statt des Umkehrschlusses aus den
729 // Zwischenzeitentafeln des Hamburger LSA-Leitfadens.
730 expect(eintrag.woher).toContain('Abschnitt 2.5.2');
731 expect(eintrag.woher).not.toContain('60-km/h-Spalte');
732 // Die frueher offene Frage ist beantwortet.
733 expect(eintrag.woher).not.toContain('am Regelwerk zu klären');
734 });
735
736 it('sagt, dass die Untergrenze der RiLSA eingebaut ist - und fuer wen', () => {
737 /*
738 * UMGEDREHT MIT 5.27.0. Bis dahin verlangte dieser Fall den Satz "NICHT
739 * EINGEBAUT" samt der drei einander widersprechenden Praxisfassungen. Die
740 * RiLSA 2015 nennt in Abschnitt 2.5.2 eine, und sie ist eingebaut.
741 */
742 const wennFalsch = HILFE.ueberfahrzeit.wennFalsch;
743 expect(wennFalsch).toContain('EINGEBAUT ist seit der Fassung 5.27.0');
744 // Die alte Aussage steht noch im Text, aber nur als Bericht ueber den
745 // frueheren Stand - also HINTER dem Satz, der sie widerruft.
746 expect(wennFalsch.indexOf('NICHT EINGEBAUT')).toBeGreaterThan(
747 wennFalsch.indexOf('EINGEBAUT ist seit der Fassung 5.27.0'),
748 );
749 expect(wennFalsch).toContain('Abschnitt 2.5.2');
750 expect(wennFalsch).toContain('Gelbzeit zuzüglich einer Sekunde');
751 // Und fuer wen sie NICHT gilt - sonst wird sie auf Rad und Fuss uebertragen.
752 expect(wennFalsch).toContain('Für Rad, Fußgänger und ÖPNV gilt sie nicht');
753 });
754
755 it('erklaert den Schwerverkehr mit den Pkw-Gleichwerten des HBS', () => {
756 const eintrag = HILFE.schwerverkehrsanteil;
757 expect(eintrag.wasIstDas).toContain('1,75');
758 expect(eintrag.wasIstDas).toContain('2,50');
759 expect(eintrag.wasIstDas).toContain('1,90');
760 expect(eintrag.wasIstDas).toContain('fSV = 1 + 0,90 · Anteil');
761 // Der alte Satz behauptete das Falsche ueber das Regelwerk.
762 expect(eintrag.wasIstDas).not.toContain('wie etwa zwei Pkw');
763 });
764
765 it('nennt die Fehlerrichtung des Pauschalwerts und die Umstellung', () => {
766 const wennFalsch = HILFE.schwerverkehrsanteil.wennFalsch;
767 expect(wennFalsch).toContain('Lastzügen');
768 expect(wennFalsch).toContain('unsicheren Seite');
769 expect(wennFalsch).toContain('HCM 2010');
770 /*
771 * Die Groessenordnung der Umstellung - jetzt mit der richtigen Obergrenze
772 * (Fassung 5.5.0, Befund F1): Der Zuwachs waechst streng monoton mit dem
773 * Schwerverkehrsanteil bis 5,3 % und war mit "bis zu 2,3 %" als Obergrenze
774 * ausgewiesen.
775 */
776 expect(wennFalsch).toContain('5,3 Prozent');
777 expect(wennFalsch).toContain('2,4 Prozent');
778 expect(wennFalsch).not.toContain('2,3 Prozent');
779 // Und die groessere, unsichere Richtung mit ihrer Zahl (Fassung 5.5.0, Befund F2).
780 expect(wennFalsch).toContain('10,2 Prozent');
781 expect(wennFalsch).toContain('31,6 Prozent');
782 });
783
784 it('nennt die Standardbedingungen der 2000 Kfz/h und was nicht gerechnet wird', () => {
785 const woher = HILFE.saettigungsverkehrsstaerke.woher;
786 expect(woher).toContain('Standardbedingungen');
787 expect(woher).toContain('20 m');
788 expect(woher).toContain('3,00 m');
789 expect(woher).toContain('NICHT ab');
790 expect(woher).toContain('zu groß');
791 });
792
793 it('fuehrt die Qualitaetsstufen mit dem Pruefstand, den das Verzeichnis fuehrt', () => {
794 // Waechter: Faellt der Pruefstand der Quelle zurueck, muss dieser Satz
795 // mitgehen - sonst behauptet die Hilfe einen Beleg, den das
796 // Fundstellenverzeichnis daneben nicht mehr fuehrt.
797 expect(QUELLEN['hbs-qualitaetsstufen'].pruefstand).toBe('gesichert');
798 const woher = HILFE.qualitaetsstufe.woher;
799 expect(woher).toContain('zweifach herausgeberseitig belegt');
800 expect(woher).toContain('V 400');
801 expect(woher).toContain('gesichert');
802 // Und die Einschraenkung, die dazu festgehalten ist.
803 expect(woher).toContain('dieselbe gedruckte Tabelle');
804 });
805
806 it('fuehrt die Auslastungsgrade als Praxiswerte, nicht als HBS-Werte', () => {
807 const ueblich = HILFE.auslastungsgrad.ueblich;
808 expect(ueblich).toContain('Praxiswerte dieses Programms');
809 expect(ueblich).toContain('q > C');
810 });
811 });