lsa-planer

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

/ tests ui bewertungAnzeige.test.ts

28,8 KB Rohdatei
tests/ui/bewertungAnzeige.test.ts — 688 Zeilen
1 import { afterEach, describe, expect, it } from 'vitest';
2 import { ProjectStore } from '@/app/store';
3 import * as actions from '@/app/actions';
4 import { buildSignalPlan } from '@/domain/plan/signalPlan';
5 import { validateProject } from '@/domain/validation';
6 import {
7 createConflict,
8 createEmptyProject,
9 createPhase,
10 createSignalGroup,
11 } from '@/domain/model/factory';
12 import type { Project } from '@/domain/model/project';
13 import { roundTo } from '@/domain/units';
14 import type { AppState } from '@/app/store';
15 import type { ViewDefinition } from '@/ui/shell';
16 import { ASSISTENT_VORGABE, projektAusAngaben } from '@/ui/assistent';
17 import { naechsterSchritt } from '@/ui/naechsterSchritt';
18 import { BEWERTUNGSVERFAHREN_LABELS, KRITERIUM_LABELS } from '@/domain/rilsa/constants';
19 import { HILFE, type Hilfetext } from '@/ui/help/texte';
20 import { planView, stufenErlaeuterung, bewertungsHinweis } from '@/ui/views/planView';
21 import { projectView } from '@/ui/views/projectView';
22 import { reportView } from '@/ui/views/reportView';
23 import { signalGroupsView } from '@/ui/views/signalGroupsView';
24 import { simulationView } from '@/ui/views/simulationView';
25 import { settingsView } from '@/ui/views/settingsView';
26
27 /**
28 * Anzeige der Bewertung seit Fassung 5.4.0 (Befunde B1 bis B4).
29 *
30 * Grundsatz: Was angezeigt wird, muss sich aus den angezeigten
31 * Groessen nachrechnen lassen, und nichts darf behauptet werden, was nicht
32 * gerechnet wird. Fuer die Oberflaeche heisst das:
33 *
34 * - Neben jeder Stufe stehen Verfahren UND Kriterium ("nach HBS 2015
35 * (maximale Wartezeit)"), denn derselbe Buchstabe bedeutet je Verkehrsart
36 * etwas anderes, und F fuer Kfz heisst "ueberlastet", nicht "ueber 100 s".
37 * - Fussgaenger und Radverkehr haben keine Kapazitaet, keinen Auslastungsgrad
38 * und kein Verkehrsstaerkefeld; ihre Wartezeit-Spalte zeigt die MAXIMALE
39 * Wartezeit (laengste Sperrzeit) und ist als "max." gekennzeichnet.
40 * - Nach HCM erhalten Fussgaenger und Radverkehr keine Stufe - die Zelle sagt
41 * das, statt einen Buchstaben zu erfinden.
42 *
43 * Gegen den Altstand schlagen diese Faelle fehl: Dort zeigte die Plan-Ansicht
44 * fuer Furten die mittlere Wartezeit ohne Kennzeichnung, fuer Radgruppen
45 * Kapazitaet und Auslastung aus einer Kfz-Saettigungsverkehrsstaerke, und die
46 * Erlaeuterung nannte nur "nach HBS" ohne Kriterium.
47 */
48
49 const FESTES_DATUM = new Date('2026-01-01T00:00:00Z');
50
51 function projekt(verfahren: 'HBS' | 'HCM' = 'HBS'): Project {
52 const basis = projektAusAngaben(
53 { ...ASSISTENT_VORGABE, fussgaenger: true, rad: true, verkehrsstaerke: 400 },
54 'Bewertung',
55 FESTES_DATUM,
56 );
57 return { ...basis, settings: { ...basis.settings, serviceLevelScale: verfahren } };
58 }
59
60 function zustand(project: Project): AppState {
61 const plan = buildSignalPlan(project);
62 return {
63 project,
64 plan,
65 report: validateProject(project, plan, FESTES_DATUM),
66 dirty: false,
67 filePath: null,
68 canUndo: false,
69 canRedo: false,
70 undoLabel: null,
71 redoLabel: null,
72 };
73 }
74
75 function zeichne(
76 view: ViewDefinition,
77 project: Project,
78 ): { wurzel: HTMLElement; aufraeumen: () => void } {
79 const wurzel = document.createElement('div');
80 document.body.append(wurzel);
81 const store = new ProjectStore(project);
82 const aufraeumen = view.render(wurzel, {
83 store,
84 refresh: () => {},
85 navigate: () => {},
86 target: null,
87 });
88 return {
89 wurzel,
90 aufraeumen: () => {
91 aufraeumen();
92 wurzel.remove();
93 },
94 };
95 }
96
97 /** Tabelle ueber ihre (vorgelesene) Beschriftung finden. */
98 function tabelleMit(wurzel: ParentNode, beschriftung: string): HTMLTableElement {
99 const tabelle = [...wurzel.querySelectorAll('table')].find(
100 (t) => t.querySelector('caption')?.textContent?.trim() === beschriftung,
101 );
102 if (!tabelle) throw new Error(`Keine Tabelle "${beschriftung}"`);
103 return tabelle;
104 }
105
106 /** Spaltenindex zu einem sichtbaren Kopftext (ohne Hilfeknopf). */
107 function spalte(tabelle: HTMLTableElement, kopf: string): number {
108 const koepfe = [...tabelle.querySelectorAll('thead th')].map((th) =>
109 [...th.childNodes]
110 .filter((k) => !(k instanceof Element && k.classList.contains('hilfe-knopf')))
111 .map((k) => k.textContent ?? '')
112 .join('')
113 .trim(),
114 );
115 const index = koepfe.indexOf(kopf);
116 if (index < 0) throw new Error(`Keine Spalte "${kopf}" in ${koepfe.join(' | ')}`);
117 return index;
118 }
119
120 /** Zeile der Signalzeitentabelle zu einer Signalgruppe (Name in Spalte 1). */
121 function zeileVon(tabelle: HTMLTableElement, name: string): HTMLTableRowElement {
122 const zeile = [...tabelle.querySelectorAll<HTMLTableRowElement>('tbody tr')].find(
123 (tr) => (tr.children[0]?.textContent ?? '').trim() === name,
124 );
125 if (!zeile) throw new Error(`Keine Zeile "${name}"`);
126 return zeile;
127 }
128
129 afterEach(() => {
130 document.body.replaceChildren();
131 });
132
133 describe('Signalzeitenplan: Fussgaenger und Radverkehr nach maximaler Wartezeit', () => {
134 it('zeigt fuer eine Furt nach HBS 2015 die maximale Wartezeit, eine Stufe und keine Kapazitaet', () => {
135 const p = projekt('HBS');
136 const plan = buildSignalPlan(p);
137 const furt = plan.groups.find((g) => g.mode === 'fuss')!;
138 const { wurzel, aufraeumen } = zeichne(planView, p);
139 try {
140 const tabelle = tabelleMit(wurzel, 'Signalzeiten je Signalgruppe');
141 const zeile = zeileVon(tabelle, furt.name);
142 const zelle = (kopf: string): HTMLElement =>
143 zeile.children[spalte(tabelle, kopf)] as HTMLElement;
144
145 expect(zelle('Kapazität').textContent?.trim()).toBe('–');
146 expect(zelle('Auslastung').textContent?.trim()).toBe('–');
147
148 // Die MAXIMALE Wartezeit, als solche gekennzeichnet - nicht die mittlere.
149 const wartezeit = zelle('Wartezeit');
150 expect(wartezeit.textContent).toMatch(/^max\. /);
151 expect(wartezeit.textContent).toContain(furt.delay!.maximumDelay.toLocaleString('de-DE'));
152 expect(wartezeit.title).toContain('maximale Wartezeit');
153
154 // Stufe mit Verfahren und Kriterium - sichtbar (title) und vorgelesen.
155 const stufe = zelle('Stufe');
156 const marke = stufe.querySelector<HTMLElement>('.stufe');
157 expect(marke, 'Stufenmarke fehlt').not.toBeNull();
158 expect(marke!.textContent).toBe(furt.delay!.serviceLevel!.grade);
159 expect(marke!.title).toContain('nach HBS 2015 (maximale Wartezeit)');
160 expect(stufe.textContent).toContain('nach HBS 2015 (maximale Wartezeit)');
161 } finally {
162 aufraeumen();
163 }
164 });
165
166 it('zeigt fuer eine Furt nach HCM keine Stufe und sagt warum', () => {
167 const p = projekt('HCM');
168 const plan = buildSignalPlan(p);
169 const furt = plan.groups.find((g) => g.mode === 'fuss')!;
170 expect(furt.delay?.serviceLevel).toBeNull();
171
172 const { wurzel, aufraeumen } = zeichne(planView, p);
173 try {
174 const tabelle = tabelleMit(wurzel, 'Signalzeiten je Signalgruppe');
175 const zeile = zeileVon(tabelle, furt.name);
176 const stufe = zeile.children[spalte(tabelle, 'Stufe')] as HTMLElement;
177 expect(stufe.querySelector('.stufe')).toBeNull();
178 expect(stufe.textContent).toContain('–');
179 expect(stufe.textContent).toContain('keine Stufe (HCM');
180 // Die maximale Wartezeit steht trotzdem da - sie ist gerechnet.
181 const wartezeit = zeile.children[spalte(tabelle, 'Wartezeit')] as HTMLElement;
182 expect(wartezeit.textContent).toMatch(/^max\. /);
183 // Und der Hinweis unter der Tabelle behauptet HCM, nicht HBS 2015.
184 const hinweis = wurzel.textContent ?? '';
185 expect(hinweis).toContain('Bewertung nach HCM');
186 expect(hinweis).not.toContain('Bewertung nach HBS 2015');
187 } finally {
188 aufraeumen();
189 }
190 });
191
192 it('zeigt fuer Radverkehr weder Kapazitaet noch Auslastung, aber die maximale Wartezeit', () => {
193 const p = projekt('HBS');
194 const plan = buildSignalPlan(p);
195 const rad = plan.groups.find((g) => g.mode === 'rad')!;
196 expect(rad.capacity).toBeNull();
197 const { wurzel, aufraeumen } = zeichne(planView, p);
198 try {
199 const tabelle = tabelleMit(wurzel, 'Signalzeiten je Signalgruppe');
200 const zeile = zeileVon(tabelle, rad.name);
201 expect(
202 (zeile.children[spalte(tabelle, 'Kapazität')] as HTMLElement).textContent?.trim(),
203 ).toBe('–');
204 expect(
205 (zeile.children[spalte(tabelle, 'Auslastung')] as HTMLElement).textContent?.trim(),
206 ).toBe('–');
207 expect((zeile.children[spalte(tabelle, 'Wartezeit')] as HTMLElement).textContent).toMatch(
208 /^max\. /,
209 );
210 const marke = zeile.querySelector<HTMLElement>('.stufe');
211 expect(marke?.title).toContain('(maximale Wartezeit)');
212 } finally {
213 aufraeumen();
214 }
215 });
216
217 it('nennt bei Kfz die mittlere Wartezeit als Kriterium und bei Ueberlastung den Grund', () => {
218 const p = projekt('HBS');
219 const kfz = p.signalGroups.find((g) => g.mode === 'kfz')!;
220 // Eine Gruppe deutlich ueber die Kapazitaet treiben: q > C -> Stufe F wegen
221 // Ueberlastung, unabhaengig von der Wartezeit.
222 const ueberlastet = actions.setDemand(p, kfz.id, { volume: 6000 });
223 const plan = buildSignalPlan(ueberlastet);
224 const geplant = plan.groups.find((g) => g.groupId === kfz.id)!;
225 expect(geplant.capacity!.degreeOfSaturation!).toBeGreaterThan(1);
226 expect(geplant.delay!.serviceLevel!.kriterium).toBe('ueberlastung');
227
228 const { wurzel, aufraeumen } = zeichne(planView, ueberlastet);
229 try {
230 const tabelle = tabelleMit(wurzel, 'Signalzeiten je Signalgruppe');
231 const zeile = zeileVon(tabelle, kfz.name);
232 const marke = zeile.querySelector<HTMLElement>('.stufe')!;
233 expect(marke.textContent).toBe('F');
234 expect(marke.title).toContain('überlastet');
235 expect(marke.title).toContain('nach HBS 2015');
236 // Die Wartezeit-Spalte ohne "max." - hier steht die mittlere.
237 const wartezeit = zeile.children[spalte(tabelle, 'Wartezeit')] as HTMLElement;
238 expect(wartezeit.textContent).not.toMatch(/max\./);
239 expect(wartezeit.title).toContain('mittlere Wartezeit');
240
241 // Eine nicht ueberlastete Kfz-Gruppe nennt das Kriterium ausdruecklich.
242 const andere = plan.groups.find((g) => g.mode === 'kfz' && g.groupId !== kfz.id)!;
243 const andereMarke = zeileVon(tabelle, andere.name).querySelector<HTMLElement>('.stufe')!;
244 expect(andereMarke.title).toContain('nach HBS 2015 (mittlere Wartezeit)');
245 } finally {
246 aufraeumen();
247 }
248 });
249
250 /**
251 * Nachtrag zu Befund B1: Die Stufe F haengt an x > 1. Mit zwei
252 * Nachkommastellen stuende bei x = 1,004 "1,00" neben "F (überlastet)" -
253 * aus den angezeigten Ziffern nicht mehr nachvollziehbar. Pruefbericht und
254 * Ausdruck fuehren drei Stellen; die Ansicht muss dieselben Ziffern zeigen.
255 */
256 it('zeigt den Auslastungsgrad mit drei Nachkommastellen - in der Tabelle und in der Kennzahl', () => {
257 const p = projekt('HBS');
258 const plan = buildSignalPlan(p);
259 const kfzGruppen = plan.groups.filter((g) => g.mode === 'kfz' && g.capacity !== null);
260 expect(kfzGruppen.length).toBeGreaterThan(0);
261 const dreiStellen = (x: number): string =>
262 roundTo(x, 3).toLocaleString('de-DE', { minimumFractionDigits: 3, maximumFractionDigits: 3 });
263
264 const { wurzel, aufraeumen } = zeichne(planView, p);
265 try {
266 const tabelle = tabelleMit(wurzel, 'Signalzeiten je Signalgruppe');
267 for (const gruppe of kfzGruppen) {
268 const zelle = zeileVon(tabelle, gruppe.name).children[
269 spalte(tabelle, 'Auslastung')
270 ] as HTMLElement;
271 expect(zelle.textContent?.trim()).toBe(dreiStellen(gruppe.capacity!.degreeOfSaturation!));
272 expect(zelle.textContent?.trim()).toMatch(/^\d+,\d{3}$/);
273 }
274 const groesste = Math.max(...kfzGruppen.map((g) => g.capacity!.degreeOfSaturation!));
275 const kennzahl = [...wurzel.querySelectorAll('.kennzahl')].find(
276 (k) => k.querySelector('dt')?.textContent === 'Größte Auslastung',
277 )!;
278 expect(kennzahl.querySelector('dd')?.textContent).toBe(dreiStellen(groesste));
279 } finally {
280 aufraeumen();
281 }
282 });
283
284 it('mittelt die Kennzahl "Mittlere Wartezeit" nur ueber Kfz und OePNV und benennt die Basis', () => {
285 const p = projekt('HBS');
286 const plan = buildSignalPlan(p);
287 const kfzOepnv = plan.groups.filter((g) => g.mode === 'kfz' || g.mode === 'oepnv');
288 const werte = kfzOepnv.map((g) => g.delay!.averageDelay);
289 const erwartet = werte.reduce((a, b) => a + b, 0) / werte.length;
290
291 const { wurzel, aufraeumen } = zeichne(planView, p);
292 try {
293 const kennzahl = [...wurzel.querySelectorAll('.kennzahl')].find(
294 (k) => k.querySelector('dt')?.textContent === 'Mittlere Wartezeit',
295 )!;
296 expect(kennzahl.querySelector('dd')?.textContent).toBe(
297 `${erwartet.toLocaleString('de-DE', { minimumFractionDigits: 1, maximumFractionDigits: 1 })} s`,
298 );
299 expect(kennzahl.textContent).toContain('Kfz und ÖPNV');
300 // Und die laengste Sperrzeit fuer Fuss/Rad steht als eigene Kennzahl da.
301 const fussRad = [...wurzel.querySelectorAll('.kennzahl')].find(
302 (k) => k.querySelector('dt')?.textContent === 'Längste Wartezeit Fuß/Rad',
303 )!;
304 const laengste = Math.max(
305 ...plan.groups
306 .filter((g) => g.mode === 'fuss' || g.mode === 'rad')
307 .map((g) => g.delay!.maximumDelay),
308 );
309 expect(fussRad.querySelector('dd')?.textContent).toContain(
310 laengste.toLocaleString('de-DE', { minimumFractionDigits: 1, maximumFractionDigits: 1 }),
311 );
312 } finally {
313 aufraeumen();
314 }
315 });
316 });
317
318 describe('Stufenerlaeuterung', () => {
319 it('nennt Verfahren und Kriterium', () => {
320 expect(
321 stufenErlaeuterung({
322 grade: 'D',
323 label: 'ausreichend',
324 scale: 'HBS',
325 kriterium: 'maximale-wartezeit',
326 }),
327 ).toBe('ausreichend nach HBS 2015 (maximale Wartezeit)');
328 expect(
329 stufenErlaeuterung({
330 grade: 'B',
331 label: 'gut',
332 scale: 'HCM',
333 kriterium: 'mittlere-wartezeit',
334 }),
335 ).toBe('gut nach HCM (mittlere Wartezeit)');
336 expect(
337 stufenErlaeuterung({
338 grade: 'F',
339 label: 'ungenügend (überlastet)',
340 scale: 'HBS',
341 kriterium: 'ueberlastung',
342 }),
343 ).toBe('ungenügend (überlastet) nach HBS 2015');
344 });
345
346 /**
347 * Nachtrag zu Befund B2: Die Simulation rechnet keine HBS-Wartezeit,
348 * sie legt nur die Tafel des Verfahrens an den simulierten Mittelwert an.
349 * Die Erlaeuterung dort darf deshalb keine HBS-Rechnung behaupten.
350 */
351 it('sagt in der Simulationsfassung "nach der Tafel des ..." und "simuliert"', () => {
352 expect(
353 stufenErlaeuterung(
354 { grade: 'C', label: 'befriedigend', scale: 'HBS', kriterium: 'mittlere-wartezeit' },
355 { simuliert: true },
356 ),
357 ).toBe('befriedigend nach der Tafel des HBS 2015 (mittlere Wartezeit, simuliert)');
358 expect(
359 stufenErlaeuterung(
360 { grade: 'B', label: 'gut', scale: 'HCM', kriterium: 'mittlere-wartezeit' },
361 { simuliert: true },
362 ),
363 ).toBe('gut nach der Tafel des HCM (mittlere Wartezeit, simuliert)');
364 // Die Stufe F bei Ueberlastung haengt auch hier am Auslastungsgrad q/C
365 // des Signalzeitenplans - keine Simulationsgroesse, das steht dabei.
366 const ueberlastet = stufenErlaeuterung(
367 { grade: 'F', label: 'ungenügend (überlastet)', scale: 'HBS', kriterium: 'ueberlastung' },
368 { simuliert: true },
369 );
370 expect(ueberlastet).toContain('nach der Tafel des HBS 2015');
371 expect(ueberlastet).toContain('Signalzeitenplan');
372 expect(ueberlastet).not.toContain('simuliert');
373 });
374
375 /**
376 * Eine Quelle der Woerter: Verfahren und Kriterium kommen aus constants.ts,
377 * damit Bildschirm, Ausdruck und Tabelle nicht auseinanderlaufen koennen.
378 */
379 it('nimmt Verfahrens- und Kriteriumsnamen aus den Konstanten des Fachkerns', () => {
380 for (const scale of ['HBS', 'HCM'] as const) {
381 const text = stufenErlaeuterung({
382 grade: 'A',
383 label: 'sehr gut',
384 scale,
385 kriterium: 'maximale-wartezeit',
386 });
387 expect(text).toContain(BEWERTUNGSVERFAHREN_LABELS[scale]);
388 expect(text).toContain(KRITERIUM_LABELS['maximale-wartezeit']);
389 }
390 });
391
392 it('behauptet "HBS 2015" nur fuer das HBS-Verfahren', () => {
393 expect(bewertungsHinweis('HCM', true)).not.toContain('HBS 2015');
394 expect(bewertungsHinweis('HCM', true)).toContain('keine Bewertungstafel');
395 expect(bewertungsHinweis('HBS', true)).toContain('maximalen Wartezeit');
396 expect(bewertungsHinweis('HBS', false)).not.toContain('Fußgänger');
397 });
398 });
399
400 describe('Signalgruppen: Verkehrsstaerke nur fuer Kfz und OePNV', () => {
401 it('bietet fuer Radverkehr und Fussgaenger kein Verkehrsstaerkefeld an', () => {
402 const p = projekt('HBS');
403 const { wurzel, aufraeumen } = zeichne(signalGroupsView, p);
404 try {
405 const tabelle = tabelleMit(wurzel, 'Signalgruppen mit ihren Kennwerten');
406 const sVerkehr = spalte(tabelle, 'Verkehrsstärke');
407 const sSv = spalte(tabelle, 'SV-Anteil');
408 for (const gruppe of p.signalGroups) {
409 const zeile = wurzel.querySelector<HTMLTableRowElement>(`#sg-${gruppe.id}`)!;
410 const verkehr = zeile.children[sVerkehr] as HTMLElement;
411 const sv = zeile.children[sSv] as HTMLElement;
412 if (gruppe.mode === 'kfz' || gruppe.mode === 'oepnv') {
413 expect(verkehr.querySelector('input'), `${gruppe.name}: Feld fehlt`).not.toBeNull();
414 expect(sv.querySelector('input'), `${gruppe.name}: SV-Feld fehlt`).not.toBeNull();
415 } else {
416 expect(
417 verkehr.querySelector('input'),
418 `${gruppe.name}: Feld darf nicht da sein`,
419 ).toBeNull();
420 expect(verkehr.textContent?.trim()).toBe('–');
421 expect(
422 sv.querySelector('input'),
423 `${gruppe.name}: SV-Feld darf nicht da sein`,
424 ).toBeNull();
425 }
426 }
427 } finally {
428 aufraeumen();
429 }
430 });
431 });
432
433 describe('Projekt: Bewertungsverfahren statt Bewertungsskala', () => {
434 it('beschriftet das Feld als Verfahren und nennt, was jede Wahl rechnet', () => {
435 const { wurzel, aufraeumen } = zeichne(projectView, projekt('HBS'));
436 try {
437 const label = [...wurzel.querySelectorAll('label')].find(
438 (l) => l.textContent?.trim() === 'Bewertungsverfahren',
439 );
440 expect(label, 'Feld "Bewertungsverfahren" fehlt').toBeDefined();
441 const auswahl = wurzel.querySelector<HTMLSelectElement>(`#${label!.htmlFor}`)!;
442 const optionen = [...auswahl.options].map((o) => o.textContent);
443 expect(optionen).toEqual([
444 'HBS 2015 (Kapazität, Wartezeit und Qualitätsstufen)',
445 'HCM (Highway Capacity Manual)',
446 ]);
447 expect(wurzel.textContent).not.toContain('Bewertungsskala');
448 } finally {
449 aufraeumen();
450 }
451 });
452 });
453
454 describe('Simulation: Stufe mit Verfahren und Kriterium', () => {
455 /**
456 * Nachtrag zu Befund B2: Die Simulation rechnet keine HBS-Wartezeit -
457 * sie legt die Tafel des Verfahrens an den simulierten Mittelwert an. Die
458 * Erlaeuterung sagt "nach der Tafel des HBS 2015 (mittlere Wartezeit,
459 * simuliert)", nicht "nach HBS 2015 (mittlere Wartezeit)", und der Kopftext
460 * sagt, was die Stufe hier ist.
461 */
462 it('erlaeutert die Stufe je Gruppe mit "nach der Tafel des HBS 2015 (mittlere Wartezeit, simuliert)"', () => {
463 const { wurzel, aufraeumen } = zeichne(simulationView, projekt('HBS'));
464 try {
465 const marken = [...wurzel.querySelectorAll<HTMLElement>('td .stufe')];
466 expect(marken.length).toBeGreaterThan(0);
467 for (const marke of marken) {
468 expect(marke.title).toContain('nach der Tafel des HBS 2015');
469 expect(marke.title).toMatch(/\(mittlere Wartezeit, simuliert\)$|Signalzeitenplan\)$/);
470 expect(marke.title).not.toMatch(/nach HBS 2015 \(mittlere Wartezeit\)/);
471 }
472 // Auch die Gesamtstufe unter den Kennzahlen.
473 const gesamt = [...wurzel.querySelectorAll('.kennzahl')].find(
474 (k) => k.querySelector('dt')?.textContent === 'Qualitätsstufe',
475 )!;
476 expect(gesamt.textContent).toContain('nach der Tafel des HBS 2015');
477 // Der Kopftext benennt, was die Stufe ist und was die Wartezeit nicht ist.
478 expect(wurzel.textContent).toContain(
479 'Die Stufe wendet die Tafel des gewählten Verfahrens auf den simulierten Mittelwert an; ' +
480 'die Wartezeit selbst ist eine Simulationsgröße, keine HBS-Rechnung.',
481 );
482 expect(wurzel.textContent).toContain('Fußgänger und Radverkehr werden nicht simuliert');
483 } finally {
484 aufraeumen();
485 }
486 });
487
488 it('nennt nach HCM die Tafel des HCM, nicht das HBS', () => {
489 const { wurzel, aufraeumen } = zeichne(simulationView, projekt('HCM'));
490 try {
491 const marken = [...wurzel.querySelectorAll<HTMLElement>('td .stufe')];
492 expect(marken.length).toBeGreaterThan(0);
493 for (const marke of marken) {
494 expect(marke.title).toContain('nach der Tafel des HCM');
495 expect(marke.title).not.toContain('HBS');
496 }
497 } finally {
498 aufraeumen();
499 }
500 });
501 });
502
503 describe('Pruefbericht: Beschreibung behauptet kein Verfahren', () => {
504 it('nennt in der Navigation das gewaehlte Bewertungsverfahren statt fest "HBS 2015"', () => {
505 expect(reportView.description).not.toBe('Prüfung gegen RiLSA 2015 und HBS 2015');
506 expect(reportView.description).toContain('RiLSA 2015');
507 expect(reportView.description).toContain('gewählte Bewertungsverfahren');
508 expect(reportView.description).toContain('HCM');
509 });
510 });
511
512 describe('Vorgaben: Instationaritaetsfaktor', () => {
513 it('fuehrt den Instationaritaetsfaktor mit Regelwert 1,1 in der Kapazitaetsgruppe', () => {
514 const { wurzel, aufraeumen } = zeichne(settingsView, projekt('HBS'));
515 try {
516 const zeile = [...wurzel.querySelectorAll('tbody tr')].find((tr) =>
517 (tr.children[0]?.textContent ?? '').startsWith('Instationaritätsfaktor'),
518 );
519 expect(zeile, 'Zeile Instationaritätsfaktor fehlt').toBeDefined();
520 expect(zeile!.children[2]?.textContent).toBe('1,1');
521 // Der Kopfsatz behauptet nicht mehr pauschal "alle Kennwerte nach RiLSA/HBS".
522 expect(wurzel.textContent).not.toContain(
523 'Alle Kennwerte entsprechen RiLSA 2015 bzw. HBS 2015',
524 );
525 } finally {
526 aufraeumen();
527 }
528 });
529 });
530
531 describe('Hilfetexte zur Bewertung', () => {
532 it('fuehrt die HBS-2015-Tafeln und keine HBS-2001-Grenze von 100 s', () => {
533 const q = HILFE.qualitaetsstufe;
534 expect(q.wasIstDas).toContain('bis 70 s D, darüber E');
535 expect(q.wasIstDas).toContain('bis 85 s E');
536 expect(q.wasIstDas).toContain('bis 60 s E');
537 // "bis 100 s" darf nur noch als Hinweis auf den Altstand vorkommen.
538 expect(q.wasIstDas).not.toContain('bis 100 s');
539 expect(HILFE.saettigungsverkehrsstaerke.woher).toContain('2000 Kfz/h');
540 expect(HILFE.saettigungsverkehrsstaerke.woher).not.toContain('1800');
541 expect(HILFE.wartezeit.wasIstDas).toContain('tW,G + tW,R');
542 expect(HILFE.wartezeit.wasIstDas).toContain('d1 + d2');
543 expect(HILFE.instationaritaetsfaktor.woher).toContain('1,1');
544 expect(HILFE.bewertungsverfahren.wasIstDas).toContain('tA = tF + 1 s');
545 expect(HILFE.verkehrsstaerke.wasIstDas).toContain('Radverkehrsstärke');
546 expect(HILFE.auslastungsgrad.wasIstDas).toContain('x = q / C');
547 });
548
549 /**
550 * Nachtrag (Restbefunde zu B1/B2/B4): Die Hilfe darf nichts sagen, was
551 * die Oberflaeche oder der Fachkern anders macht.
552 */
553 it('nennt den Vorgaben-Abschnitt so, wie die Vorgabenansicht ihn beschriftet', () => {
554 // Der Gruppenname in settingsView lautet "Umlaufzeit, Kapazität und Wartezeit".
555 expect(HILFE.instationaritaetsfaktor.woher).toContain('Umlaufzeit, Kapazität und Wartezeit');
556 expect(HILFE.instationaritaetsfaktor.woher).not.toContain('Abschnitt Umlaufzeit und Kapazität');
557 });
558
559 it('sagt zur OePNV-Tafel, dass F auch bei Ueberlastung gilt - so rechnet delay.ts', () => {
560 const oepnvZeile = HILFE.qualitaetsstufe.wasIstDas
561 .split('\n')
562 .find((zeile) => zeile.startsWith('• ÖPNV'));
563 expect(oepnvZeile, 'OePNV-Zeile fehlt').toBeDefined();
564 expect(oepnvZeile).toContain('darüber F');
565 expect(oepnvZeile).toContain('Überlastung');
566 });
567
568 it('sagt zur gespeicherten Fuss/Rad-Verkehrsstaerke, wie der Pruefbericht sie meldet', () => {
569 expect(HILFE.verkehrsstaerke.wasIstDas).toContain('Verkehrsstärke ohne Rechenwirkung');
570 expect(HILFE.verkehrsstaerke.wasIstDas).toContain('gedruckt wird sie nicht');
571 });
572
573 it('nennt den Pruefstand der HBS-Formelkette als "abzugleichen", nicht als gesichert', () => {
574 expect(HILFE.wartezeit.woher).toContain('abzugleichen');
575 expect(HILFE.wartezeit.woher).toContain('Druckwerk');
576 const eintraege: readonly Hilfetext[] = [
577 HILFE.wartezeit,
578 HILFE.bewertungsverfahren,
579 HILFE.instationaritaetsfaktor,
580 ];
581 for (const eintrag of eintraege) {
582 const text = [
583 eintrag.wasIstDas,
584 eintrag.woher ?? '',
585 eintrag.ueblich ?? '',
586 eintrag.wennFalsch ?? '',
587 ].join(' ');
588 expect(text).not.toMatch(/gesichert/i);
589 }
590 });
591 });
592
593 describe('Naechster Schritt und Assistent: Verkehrsstaerken nur fuer Kfz und OePNV', () => {
594 /**
595 * Eine vollstaendige Anlage nur aus Furt und Radfurt: Konflikte mit Wegen,
596 * zwei Phasen, Projektdaten ausgefuellt - der Pruefbericht hat keinen
597 * Fehler, und der naechste Schritt kommt bis zu der Stelle, an der vor
598 * Fassung 5.4.0 (Befund B4) Verkehrsstaerken verlangt wurden, die
599 * fuer diese Gruppen nirgends eingetragen werden koennen.
600 */
601 it('verlangt fuer eine Anlage nur aus Furten und Radfurten keine Verkehrsstaerken', () => {
602 const leer = createEmptyProject('Nur Fuss/Rad', FESTES_DATUM);
603 const furt = createSignalGroup({ name: 'F1', mode: 'fuss', index: 0 });
604 const rad = createSignalGroup({ name: 'R1', mode: 'rad', index: 1 });
605 const konflikte = [
606 { ...createConflict(furt.id, rad.id), clearingDistance: 10, enteringDistance: 5 },
607 { ...createConflict(rad.id, furt.id), clearingDistance: 10, enteringDistance: 5 },
608 ];
609 const phasen = [createPhase('Phase 1', [furt.id]), createPhase('Phase 2', [rad.id])];
610 const nurFussRad: Project = {
611 ...leer,
612 signalGroups: [furt, rad],
613 conflicts: konflikte,
614 phases: phasen,
615 program: { ...leer.program, phaseOrder: phasen.map((p) => p.id) },
616 meta: {
617 ...leer.meta,
618 name: 'x',
619 projectNumber: '1',
620 client: 'c',
621 planner: 'p',
622 location: 'l',
623 },
624 };
625 const state = zustand(nurFussRad);
626 expect(state.report.errorCount, 'Vorbedingung: kein Fehler im Pruefbericht').toBe(0);
627 const schritt = naechsterSchritt(state);
628 expect(schritt.text).not.toContain('Verkehrsstärke');
629 expect(schritt.ansicht).not.toBe('signalgruppen');
630 });
631
632 it('nennt im Schritt die Kfz- und OePNV-Gruppen als Adressaten', () => {
633 const p = projektAusAngaben(
634 { ...ASSISTENT_VORGABE, fussgaenger: true, rad: true },
635 'Ohne Zaehlung',
636 FESTES_DATUM,
637 );
638 const mitDaten: Project = {
639 ...p,
640 meta: { ...p.meta, name: 'x', projectNumber: '1', client: 'c', planner: 'p', location: 'l' },
641 };
642 const state = zustand(mitDaten);
643 expect(state.report.errorCount, 'Vorbedingung: kein Fehler im Pruefbericht').toBe(0);
644 const schritt = naechsterSchritt(state);
645 expect(schritt.text).toContain('Verkehrsstärken der Kfz- und ÖPNV-Signalgruppen');
646 expect(schritt.grund).toContain('Fußgänger und Radverkehr brauchen keine Verkehrsstärke');
647 expect(schritt.ansicht).toBe('signalgruppen');
648 });
649
650 /**
651 * Nachtrag zu Befund B4: Der Schritt zaehlte project.demands.length -
652 * eine gespeicherte Radverkehrsstaerke (aeltere Projektdatei) liess ihn als
653 * erledigt erscheinen, waehrend keine einzige Kfz-Gruppe eine Zahl hatte.
654 */
655 it('verlangt weiterhin Verkehrsstaerken, wenn nur eine Radgruppe eine gespeicherte hat', () => {
656 const p = projektAusAngaben(
657 { ...ASSISTENT_VORGABE, fussgaenger: true, rad: true },
658 'Nur Rad gezaehlt',
659 FESTES_DATUM,
660 );
661 const rad = p.signalGroups.find((g) => g.mode === 'rad')!;
662 expect(p.signalGroups.some((g) => g.mode === 'kfz')).toBe(true);
663 const mitRadZahl: Project = {
664 ...actions.setDemand(p, rad.id, { volume: 300 }),
665 meta: { ...p.meta, name: 'x', projectNumber: '1', client: 'c', planner: 'p', location: 'l' },
666 };
667 expect(mitRadZahl.demands.length).toBe(1);
668 const state = zustand(mitRadZahl);
669 expect(state.report.errorCount, 'Vorbedingung: kein Fehler im Pruefbericht').toBe(0);
670 const schritt = naechsterSchritt(state);
671 expect(schritt.text).toContain('Verkehrsstärken der Kfz- und ÖPNV-Signalgruppen');
672 expect(schritt.ansicht).toBe('signalgruppen');
673 });
674
675 it('legt im Assistenten fuer Radgruppen keine Verkehrsstaerke an', () => {
676 const p = projektAusAngaben(
677 { ...ASSISTENT_VORGABE, rad: true, fussgaenger: true, verkehrsstaerke: 400 },
678 'Assistent',
679 FESTES_DATUM,
680 );
681 const radIds = new Set(
682 p.signalGroups.filter((g) => g.mode === 'rad' || g.mode === 'fuss').map((g) => g.id),
683 );
684 expect(radIds.size).toBeGreaterThan(0);
685 expect(p.demands.some((d) => radIds.has(d.signalGroupId))).toBe(false);
686 expect(p.demands.length).toBe(p.signalGroups.filter((g) => g.mode === 'kfz').length);
687 });
688 });