lsa-planer

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

/ tests ui schwerverkehrsaufteilung.test.ts

33,0 KB Rohdatei
tests/ui/schwerverkehrsaufteilung.test.ts — 774 Zeilen
1 import { afterEach, 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 type { Project, TrafficDemand } from '@/domain/model/project';
6 import {
7 HBS_LASTZUGANTEIL_BEI_UNBEKANNTER_AUFTEILUNG,
8 HBS_PKW_GLEICHWERTE,
9 } from '@/domain/rilsa/constants';
10 import { SCHWERVERKEHR_AUFTEILUNG_MELDESCHWELLE } from '@/domain/validation/rules';
11 import { HILFE, glossarEintraege, glossarSuche } from '@/ui/help/texte';
12 import {
13 ASSISTENT_VORGABE,
14 assistentZusammenfassung,
15 projektAusAngaben,
16 starteAssistent,
17 } from '@/ui/assistent';
18 import type { ViewDefinition } from '@/ui/shell';
19 import { meldungsverlauf } from '@/ui/feedback';
20 import * as fmt from '@/ui/format';
21 import {
22 lastzuganteilAusserhalbFolge,
23 lastzuganteilHinweis,
24 lastzuganteilPlatzhalter,
25 schwerverkehrsanteilHinweis,
26 schwerverkehrsfaktorVermerk,
27 signalGroupsView,
28 wirksamerSchwerverkehrsfaktor,
29 } from '@/ui/views/signalGroupsView';
30
31 /**
32 * Die Aufteilung des Schwerverkehrs auf dem Bildschirm.
33 *
34 * Der Fachkern rechnet nach HBS 2015 Gl. 2-5, sobald an einer
35 * Verkehrsstaerke ein Lastzuganteil erfasst ist, und sonst unveraendert nach
36 * Gl. 2-6. Erfassen laesst sich der Anteil aber nur, wenn die Oberflaeche ein
37 * Feld dafuer hat - und ohne den wirksamen Faktor daneben bliebe eine Eingabe
38 * folgenlos aussehen: Man traegt 100 % Lastzuege ein, am Bildschirm aendert
39 * sich nichts, und ob das Feld ueberhaupt gewirkt hat, erfaehrt man erst im
40 * Ausdruck.
41 *
42 * GEGEN DEN ALTSTAND SCHLAEGT JEDER UNTERSCHEIDENDE FALL FEHL: Dort gab es
43 * weder die Spalte noch den Hilfeeintrag noch die Ausfuhr
44 * `wirksamerSchwerverkehrsfaktor`, und der Satz unter der Tabelle behauptete
45 * ausdruecklich, "eine Aufteilung nach Fahrzeugklassen erfasst dieses Programm
46 * nicht".
47 *
48 * KORREKTUR DIESER ZUSAGE (Fassung 5.6.0): Hier stand "JEDER FALL". Zwei Faelle
49 * dieser Datei bestehen auch gegen den Altstand, und das ist Absicht und kein
50 * Mangel:
51 *
52 * - "steht unter der Tabelle, sobald es eine Gruppe mit
53 * Saettigungsverkehrsstaerke gibt" prueft die VERDRAHTUNG - dass der Satz
54 * ueberhaupt angezeigt wird - gegen `schwerverkehrsanteilHinweis()`, also
55 * gegen sich selbst. Er faellt, wenn die Ansicht den Satz weglaesst, und
56 * nicht, wenn der Satz anders lautet. Was IM Satz steht, pruefen die
57 * Faelle unter "Satz unter der Signalgruppentabelle", und die schlagen
58 * gegen den Altstand fehl.
59 * - "Assistent legt die Verkehrsstaerken ohne Lastzuganteil an" ist eine
60 * NEGATIVzusage. Auf dem Altstand gilt sie trivial - dort kann es das Feld
61 * gar nicht geben. Sie ist trotzdem noetig: Sie haelt fest, dass der
62 * Assistent es auch kuenftig nicht vorbelegt, denn eine Vorbelegung waere
63 * eine Behauptung ueber eine Zaehlung, die es nicht gibt.
64 *
65 * Eine Zusage, die mehr behauptet, als der Nachweis hergibt, ist so wenig wert
66 * wie die Zusage, die sie prueft.
67 *
68 * DIE ZAHLEN SIND NACHGERECHNET, nicht abgeschrieben:
69 * pSV 20 %, keine Aufteilung -> fSV = 1 + 0,90 · 0,20 = 1,180 -> qS 1695 Kfz/h
70 * pSV 20 %, 100 % Lastzuege -> fSV = 1 + 1,50 · 0,20 = 1,300 -> qS 1538 Kfz/h
71 * pSV 20 %, 20 % Lastzuege -> fSV = 1 + 0,75 · 0,16 + 1,50 · 0,04 = 1,180
72 * Der letzte Fall ist der Konsistenzbeleg: 1,75 · 0,80 + 2,50 · 0,20 = 1,90,
73 * also ist Gl. 2-6 nichts anderes als Gl. 2-5 mit 20 % Lastzuegen (BASt-Bericht
74 * V 400, 2025, S. 10). Wer 20 % eintraegt, aendert an seinen Zahlen nichts -
75 * und das muss auch der Bildschirm zeigen.
76 */
77
78 const FESTES_DATUM = new Date('2026-01-01T00:00:00Z');
79
80 function zeichne(
81 view: ViewDefinition,
82 project: Project,
83 ): { wurzel: HTMLElement; store: ProjectStore; aufraeumen: () => void } {
84 const wurzel = document.createElement('div');
85 document.body.append(wurzel);
86 const store = new ProjectStore(project);
87 const abmelden = view.render(wurzel, {
88 store,
89 refresh: () => {},
90 navigate: () => {},
91 target: null,
92 });
93 return {
94 wurzel,
95 store,
96 aufraeumen: () => {
97 abmelden();
98 wurzel.remove();
99 },
100 };
101 }
102
103 /**
104 * Ein Knotenpunkt, bei dem K1 den geprueften Fall traegt: 20 % Schwerverkehr
105 * und - wenn `lastzugAnteil` gesetzt ist - eine erfasste Aufteilung. Die
106 * uebrigen Stroeme bleiben unveraendert, damit ein Unterschied in der Zeile K1
107 * nicht aus einer Verschiebung des ganzen Plans kommen kann.
108 */
109 function mitAufteilung(lastzugAnteil?: number): Project {
110 const basis = createStandardIntersectionProject('Aufteilung', FESTES_DATUM);
111 const k1 = basis.signalGroups.find((g) => g.name === 'K1')!;
112 return {
113 ...basis,
114 demands: basis.demands.map((d) =>
115 d.signalGroupId === k1.id
116 ? {
117 ...d,
118 heavyVehicleShare: 0.2,
119 // Ausdruecklich weggelassen statt auf `undefined` gesetzt: Mit
120 // `exactOptionalPropertyTypes` ist das nicht dasselbe, und genau
121 // dieser Unterschied - Feld da oder nicht - entscheidet ueber die
122 // Gleichung.
123 ...(lastzugAnteil === undefined ? {} : { lastzugAnteil }),
124 }
125 : d,
126 ),
127 };
128 }
129
130 function zeile(wurzel: HTMLElement, name: string): HTMLTableRowElement {
131 const treffer = [...wurzel.querySelectorAll<HTMLTableRowElement>('tbody tr')].find(
132 (tr) => tr.querySelector<HTMLInputElement>('input[type="text"]')?.value === name,
133 );
134 if (!treffer) throw new Error(`Zeile "${name}" fehlt`);
135 return treffer;
136 }
137
138 function lastzugFeld(wurzel: HTMLElement, name: string): HTMLInputElement | null {
139 return zeile(wurzel, name).querySelector<HTMLInputElement>(
140 `input[aria-label="Anteil der Lastzüge am Schwerverkehr in Prozent, ${name}"]`,
141 );
142 }
143
144 function faktorVermerk(wurzel: HTMLElement, name: string): HTMLElement | null {
145 return zeile(wurzel, name).querySelector<HTMLElement>('.schwerverkehrsfaktor');
146 }
147
148 /**
149 * Das Feld "Schwerverkehrsanteil" derselben Zeile - das Gegenstueck, an dem
150 * BEGRENZEN richtig ist.
151 *
152 * Es traegt keinen eigenen `aria-label`-Aufruf in der Ansicht; den Namen
153 * vergibt `benenneBedienelemente()` aus Spaltenkopf und Zeilennamen.
154 */
155 /**
156 * Der Satz, den eine Sprachausgabe VOR der Eingabe vorliest.
157 *
158 * `numberCell` legt ihn als `data-bereich` ab; beim Einhaengen macht
159 * `ergaenzeBereichshinweis()` daraus einen eigenen Absatz mit `aria-describedby`
160 * und entfernt das Merkmal wieder. Geprueft wird deshalb, was am Ende am Feld
161 * haengt, und nicht der Zwischenstand.
162 */
163 function bereichssatz(feld: HTMLInputElement): string {
164 const id = feld.getAttribute('aria-describedby');
165 if (id === null) return feld.getAttribute('data-bereich') ?? '';
166 return document.getElementById(id)?.textContent ?? '';
167 }
168
169 function schwerverkehrsfeld(wurzel: HTMLElement, name: string): HTMLInputElement {
170 const feld = zeile(wurzel, name).querySelector<HTMLInputElement>(
171 'input[aria-label^="Schwerverkehrsanteil in Prozent"]',
172 );
173 if (!feld) throw new Error(`Schwerverkehrsfeld der Zeile "${name}" fehlt`);
174 return feld;
175 }
176
177 function nachfrage(project: Project, name: string): TrafficDemand {
178 const gruppe = project.signalGroups.find((g) => g.name === name)!;
179 const demand = project.demands.find((d) => d.signalGroupId === gruppe.id);
180 if (!demand) throw new Error(`Keine Verkehrsstärke zu "${name}"`);
181 return demand;
182 }
183
184 afterEach(() => {
185 document.body.replaceChildren();
186 });
187
188 describe('Spalte "Lastzüge am SV"', () => {
189 it('steht neben dem Schwerverkehrsanteil und führt zur Hilfe über die Aufteilung', () => {
190 const { wurzel, aufraeumen } = zeichne(signalGroupsView, mitAufteilung());
191 try {
192 const koepfe = [...wurzel.querySelectorAll('thead th')];
193 const namen = koepfe.map((th) => th.querySelector('span')?.textContent ?? '');
194 const svSpalte = namen.indexOf('SV-Anteil');
195 expect(svSpalte, 'Spalte "SV-Anteil" fehlt').toBeGreaterThan(-1);
196 expect(namen[svSpalte + 1]).toBe('Lastzüge am SV');
197
198 // Der Hilfeknopf der Spalte zeigt auf den neuen Eintrag - sonst landet
199 // wer fragt beim Schwerverkehrsanteil, der die Aufteilung nicht erklaert.
200 const knopf = koepfe[svSpalte + 1]!.querySelector<HTMLButtonElement>('.hilfe-knopf');
201 expect(knopf, 'Spaltenkopf ohne Hilfeknopf').not.toBeNull();
202 expect(knopf!.getAttribute('aria-label')).toBe(
203 `Hilfe zu ${HILFE.schwerverkehrsaufteilung.titel}`,
204 );
205 } finally {
206 aufraeumen();
207 }
208 });
209
210 it('gibt es nur für Signalgruppen mit Sättigungsverkehrsstärke', () => {
211 const { wurzel, aufraeumen } = zeichne(signalGroupsView, mitAufteilung());
212 try {
213 expect(lastzugFeld(wurzel, 'K1'), 'Kfz-Gruppe ohne Feld').not.toBeNull();
214 // Fussgaenger werden nach HBS 2015 ueber die maximale Wartezeit bewertet;
215 // ein Schwerverkehr, den es nicht gibt, laesst sich nicht aufteilen.
216 expect(lastzugFeld(wurzel, 'F1'), 'Fußgängergruppe mit Aufteilungsfeld').toBeNull();
217 expect(faktorVermerk(wurzel, 'F1')).toBeNull();
218 } finally {
219 aufraeumen();
220 }
221 });
222
223 it('nennt im Platzhalter die Annahme, die bei leerem Feld gilt', () => {
224 // Nicht "unbekannt": Leer lassen heisst nicht, auf eine Annahme zu
225 // verzichten, sondern mit 20 % zu rechnen (Gl. 2-6 IST Gl. 2-5 mit 20 %).
226 expect(lastzuganteilPlatzhalter()).toBe('20 (Annahme)');
227 const { wurzel, aufraeumen } = zeichne(signalGroupsView, mitAufteilung());
228 try {
229 const feld = lastzugFeld(wurzel, 'K1')!;
230 expect(feld.value, 'Ohne Erfassung muss das Feld leer sein').toBe('');
231 expect(feld.placeholder).toBe(lastzuganteilPlatzhalter());
232 } finally {
233 aufraeumen();
234 }
235 });
236
237 it('nennt im title die Bezugsgröße, beide Gleichungen und die Herkunft der 20 %', () => {
238 const hinweis = lastzuganteilHinweis();
239 expect(hinweis).toContain('AM SCHWERVERKEHR');
240 expect(hinweis).toContain('nicht an der Verkehrsstärke');
241 expect(hinweis).toContain('Gleichung (2-5)');
242 expect(hinweis).toContain('Gleichung (2-6)');
243 expect(hinweis).toContain('1,75');
244 expect(hinweis).toContain('2,50');
245 expect(hinweis).toContain('1,90');
246 expect(hinweis).toContain('20 % Lastzüge am Schwerverkehr');
247 expect(hinweis).toContain('BASt-Bericht V 400, 2025, S. 10');
248 expect(hinweis).toContain('ändert an den Zahlen nichts');
249
250 const { wurzel, aufraeumen } = zeichne(signalGroupsView, mitAufteilung());
251 try {
252 expect(lastzugFeld(wurzel, 'K1')!.title).toBe(hinweis);
253 } finally {
254 aufraeumen();
255 }
256 });
257
258 it('behält trotz des title seinen Namen für die Sprachausgabe', () => {
259 // benenneBedienelemente() ueberspringt jedes Feld mit `title`. Ohne
260 // eigenes aria-label waere der ganze Hinweistext der Name des Felds, und
261 // jede Meldung von numberCell begaenne damit.
262 const { wurzel, aufraeumen } = zeichne(signalGroupsView, mitAufteilung());
263 try {
264 const feld = lastzugFeld(wurzel, 'K1')!;
265 expect(feld.getAttribute('aria-label')).toBe(
266 'Anteil der Lastzüge am Schwerverkehr in Prozent, K1',
267 );
268 expect(feld.getAttribute('aria-label')).not.toBe(feld.title);
269 } finally {
270 aufraeumen();
271 }
272 });
273 });
274
275 describe('Der wirksame Faktor fSV steht an der Zeile', () => {
276 it('zeigt ohne erfasste Aufteilung den Pauschalwert und sagt, welche Gleichung gilt', () => {
277 const { wurzel, aufraeumen } = zeichne(signalGroupsView, mitAufteilung());
278 try {
279 const vermerk = faktorVermerk(wurzel, 'K1')!;
280 expect(vermerk.textContent).toBe('fSV 1,180');
281 expect(vermerk.title).toContain('Keine Aufteilung des Schwerverkehrs erfasst');
282 expect(vermerk.title).toContain('Gleichung (2-6)');
283 expect(vermerk.title).toContain('Pauschalwert 1,90');
284 expect(vermerk.title).toContain('20 % Lastzüge am Schwerverkehr');
285 expect(vermerk.title).toContain('Sättigungsverkehrsstärke 1.695 Fz/h');
286 } finally {
287 aufraeumen();
288 }
289 });
290
291 it('springt mit der erfassten Aufteilung auf Gleichung (2-5)', () => {
292 const { wurzel, aufraeumen } = zeichne(signalGroupsView, mitAufteilung(1));
293 try {
294 const vermerk = faktorVermerk(wurzel, 'K1')!;
295 // Der Nachweis, dass die Eingabe wirkt: 1,180 -> 1,300, 1695 -> 1538.
296 expect(vermerk.textContent).toBe('fSV 1,300');
297 expect(vermerk.title).toContain('Gleichung (2-5)');
298 expect(vermerk.title).toContain('20 % Schwerverkehr, davon 100 % Lastzüge');
299 expect(vermerk.title).toContain('Sättigungsverkehrsstärke 1.538 Fz/h');
300 expect(vermerk.title).not.toContain('Keine Aufteilung');
301
302 // Und die Nachbarzeile ohne Eintrag bleibt, wo sie war.
303 expect(faktorVermerk(wurzel, 'K2')!.textContent).toBe('fSV 1,045');
304 } finally {
305 aufraeumen();
306 }
307 });
308
309 it('zeigt bei genau der Annahme dieselbe Zahl wie ohne Eintrag', () => {
310 // Der Konsistenzbeleg am Bildschirm: 1,75 · 0,80 + 2,50 · 0,20 = 1,90.
311 const ohne = zeichne(signalGroupsView, mitAufteilung());
312 const mit = zeichne(
313 signalGroupsView,
314 mitAufteilung(HBS_LASTZUGANTEIL_BEI_UNBEKANNTER_AUFTEILUNG),
315 );
316 try {
317 expect(faktorVermerk(mit.wurzel, 'K1')!.textContent).toBe(
318 faktorVermerk(ohne.wurzel, 'K1')!.textContent,
319 );
320 // Aber die Herkunft der Zahl ist eine andere - erfasst statt angenommen.
321 expect(faktorVermerk(mit.wurzel, 'K1')!.title).toContain('Gleichung (2-5)');
322 expect(faktorVermerk(ohne.wurzel, 'K1')!.title).toContain('Gleichung (2-6)');
323 } finally {
324 mit.aufraeumen();
325 ohne.aufraeumen();
326 }
327 });
328
329 it('macht einen unbrauchbaren Eintrag sichtbar, statt ihn still zu verwerfen', () => {
330 // Ueber die Datei ist der Fall nicht erreichbar (das Schema verwirft ihn
331 // beim Lesen), ueber Fremdwerkzeuge schon. Verworfen wird er auch hier -
332 // aber nicht stillschweigend.
333 const { wurzel, aufraeumen } = zeichne(signalGroupsView, mitAufteilung(1.5));
334 try {
335 const vermerk = faktorVermerk(wurzel, 'K1')!;
336 expect(vermerk.textContent).toBe('fSV 1,180');
337 expect(vermerk.title).toContain('außerhalb von 0 bis 100 %');
338 expect(vermerk.title).toContain('gilt als nicht erfasst');
339 expect(vermerk.title).toContain('Gleichung (2-6)');
340 expect(vermerk.title).toContain('meldet das als Warnung');
341 // Der gespeicherte Wert bleibt im Feld stehen - er ist gespeichert, nur
342 // ohne Wirkung; ein leeres Feld verschwiege die Fehleingabe.
343 expect(lastzugFeld(wurzel, 'K1')!.value).toBe('150');
344 } finally {
345 aufraeumen();
346 }
347 });
348
349 it('rechnet denselben Faktor wie der Signalzeitenplan', () => {
350 // Keine zweite Fassung der Fallunterscheidung in der Ansicht: qS des Plans
351 // geteilt durch den angezeigten Faktor muss die Grundsaettigung ergeben.
352 for (const anteil of [undefined, 0, 0.2, 0.35, 1]) {
353 const projekt = mitAufteilung(anteil);
354 const plan = buildSignalPlan(projekt);
355 const k1 = projekt.signalGroups.find((g) => g.name === 'K1')!;
356 const geplant = plan.groups.find((g) => g.groupId === k1.id)!;
357 const faktor = wirksamerSchwerverkehrsfaktor(nachfrage(projekt, 'K1'));
358 expect(geplant.capacity, `Kein Kapazitätsergebnis bei ${String(anteil)}`).not.toBeNull();
359 expect(geplant.capacity!.saturationFlow * faktor, `Aufteilung ${String(anteil)}`).toBeCloseTo(
360 2000,
361 6,
362 );
363 }
364 });
365
366 it('steht ohne Verkehrsstärke gar nicht erst da', () => {
367 // Ein Faktor zu einem Strom, ueber den nichts erfasst ist, waere eine Zahl
368 // ohne Grundlage - das Feld bleibt, der Vermerk nicht.
369 const ohne: Project = { ...mitAufteilung(), demands: [] };
370 expect(schwerverkehrsfaktorVermerk(undefined, null)).toBeNull();
371 const { wurzel, aufraeumen } = zeichne(signalGroupsView, ohne);
372 try {
373 expect(lastzugFeld(wurzel, 'K1'), 'Das Feld selbst bleibt').not.toBeNull();
374 expect(faktorVermerk(wurzel, 'K1')).toBeNull();
375 } finally {
376 aufraeumen();
377 }
378 });
379
380 it('spricht ohne Schwerverkehr nicht von einer fehlenden Aufteilung', () => {
381 // Bei 0 % Schwerverkehr liefern beide Gleichungen fSV = 1. Ein Hinweis auf
382 // eine "nicht erfasste Aufteilung" spraeche dort von einer Annahme, die auf
383 // keine Zahl wirkt - und wird nach dem dritten Mal ueberlesen.
384 const ohneSV: TrafficDemand = { signalGroupId: 'x', volume: 500, heavyVehicleShare: 0 };
385 const vermerk = schwerverkehrsfaktorVermerk(ohneSV, 2000)!;
386 expect(vermerk.text).toBe('fSV 1,000');
387 expect(vermerk.title).toContain('Ohne Schwerverkehr wirkt die Aufteilung nicht');
388 expect(vermerk.title).not.toContain('Keine Aufteilung des Schwerverkehrs erfasst');
389 });
390
391 it('nennt die Sättigungsverkehrsstärke nur, wenn der Plan eine hergibt', () => {
392 const demand: TrafficDemand = { signalGroupId: 'x', volume: 500, heavyVehicleShare: 0.2 };
393 expect(schwerverkehrsfaktorVermerk(demand, null)!.title).not.toContain(
394 'Sättigungsverkehrsstärke',
395 );
396 expect(schwerverkehrsfaktorVermerk(demand, 1695)!.title).toContain(
397 'Sättigungsverkehrsstärke 1.695 Fz/h',
398 );
399 });
400 });
401
402 describe('Eingabe des Lastzuganteils', () => {
403 const setze = (feld: HTMLInputElement, wert: string): void => {
404 feld.value = wert;
405 feld.dispatchEvent(new Event('change'));
406 };
407
408 it('speichert den Anteil AM SCHWERVERKEHR, nicht an der Verkehrsstärke', () => {
409 const { wurzel, store, aufraeumen } = zeichne(signalGroupsView, mitAufteilung());
410 try {
411 setze(lastzugFeld(wurzel, 'K1')!, '25');
412 const demand = nachfrage(store.getProject(), 'K1');
413 // 25 % der Schwerfahrzeuge, nicht 25 % der Verkehrsstaerke: Der
414 // Schwerverkehrsanteil bleibt unveraendert bei 20 %.
415 expect(demand.lastzugAnteil).toBeCloseTo(0.25, 9);
416 expect(demand.heavyVehicleShare).toBeCloseTo(0.2, 9);
417 expect(demand.volume).toBe(620);
418 } finally {
419 aufraeumen();
420 }
421 });
422
423 it('entfernt die Angabe beim Leeren, statt 0 % zu speichern', () => {
424 // "0 % Lastzuege" und "nicht erfasst" sind der Unterschied zwischen
425 // Gl. 2-5 und Gl. 2-6 - ein Feld, das beides gleich behandelt, waere
426 // wertlos.
427 const { wurzel, store, aufraeumen } = zeichne(signalGroupsView, mitAufteilung(0.4));
428 try {
429 const feld = lastzugFeld(wurzel, 'K1')!;
430 expect(feld.value).toBe('40');
431 setze(feld, '');
432 const demand = nachfrage(store.getProject(), 'K1');
433 expect('lastzugAnteil' in demand, 'Das Feld muss verschwinden, nicht auf 0 fallen').toBe(
434 false,
435 );
436 } finally {
437 aufraeumen();
438 }
439 });
440
441 it('zeigt 12,5 % als 12,5 und nicht als 13', () => {
442 /*
443 * Der Befund: Das Feld rundete auf ganze Prozent, waehrend Ausdruck und CSV
444 * eine Nachkommastelle ausweisen und gerechnet wird mit dem gespeicherten
445 * Wert - drei Zahlen zu einer Angabe. Der Schaden steht eine Zeile weiter:
446 * Wer das Feld anfasst, schreibt die gerundete Zahl zurueck.
447 */
448 const basis = mitAufteilung(0.125);
449 const projekt: Project = {
450 ...basis,
451 // Derselbe Fehler stand am Feld daneben; beide Zahlen kommen aus
452 // derselben Umrechnung.
453 demands: basis.demands.map((d) =>
454 d.lastzugAnteil === 0.125 ? { ...d, heavyVehicleShare: 0.125 } : d,
455 ),
456 };
457 const { wurzel, store, aufraeumen } = zeichne(signalGroupsView, projekt);
458 try {
459 expect(lastzugFeld(wurzel, 'K1')!.value).toBe('12.5');
460 expect(schwerverkehrsfeld(wurzel, 'K1').value).toBe('12.5');
461 // Nichts geaendert heisst nichts gespeichert: Der Wert bleibt, wie er ist.
462 expect(nachfrage(store.getProject(), 'K1').lastzugAnteil).toBe(0.125);
463 } finally {
464 aufraeumen();
465 }
466 });
467
468 it('unterscheidet 0 % von "nicht erfasst"', () => {
469 const { wurzel, store, aufraeumen } = zeichne(signalGroupsView, mitAufteilung());
470 try {
471 setze(lastzugFeld(wurzel, 'K1')!, '0');
472 const demand = nachfrage(store.getProject(), 'K1');
473 expect(demand.lastzugAnteil).toBe(0);
474 // Ein Schwerverkehr ohne Lastzuege ist die guenstigere Rechnung als der
475 // Pauschalwert - deshalb darf sie nur aus einer Angabe entstehen.
476 expect(wirksamerSchwerverkehrsfaktor(demand)).toBeCloseTo(1.15, 9);
477 } finally {
478 aufraeumen();
479 }
480 });
481 });
482
483 /**
484 * Das Feld klemmt einen Wert ausserhalb von 0 bis 100 % NICHT.
485 *
486 * DER SCHWERSTE BEFUND ZUR AUFTEILUNG DES SCHWERVERKEHRS (Fassung 5.6.0). Die
487 * Ansicht gab `min: 0, max: 100` an `numberCell`, und das begrenzt: Aus `-30`
488 * wurde still `0`. Damit galt der Strom als "Aufteilung erfasst, kein einziger
489 * Lastzug", die Saettigungsverkehrsstaerke stieg von 1695 auf 1739 Kfz/h (2,6 %
490 * in die unsichere Richtung), die Plannotiz `lastzuganteil-ausserhalb` entstand
491 * nie, der Pruefbericht schwieg, und Ausdruck wie CSV druckten "Lastzuege 0,0 %
492 * des Schwerverkehrs" - eine Behauptung ueber eine Zaehlung, die niemand
493 * vorgenommen hat. Das einzige Signal war eine Kurzmeldung, die nach fuenf
494 * Sekunden verschwindet; der Eintrag war dauerhaft.
495 *
496 * Genau diesen Rueckfall schliessen `istLastzuganteil`,
497 * `actions.setLastzugAnteil`, das Schema und die Bedienungsanleitung
498 * ausdruecklich aus - er war nur eine Ebene hoeher wieder eingebaut.
499 */
500 describe('Ein Wert außerhalb von 0 bis 100 % bleibt stehen', () => {
501 const setze = (feld: HTMLInputElement, wert: string): void => {
502 feld.value = wert;
503 feld.dispatchEvent(new Event('change'));
504 };
505
506 it('macht aus -30 % keine gezählte 0 %', () => {
507 const { wurzel, store, aufraeumen } = zeichne(signalGroupsView, mitAufteilung());
508 try {
509 const feld = lastzugFeld(wurzel, 'K1')!;
510 setze(feld, '-30');
511
512 const demand = nachfrage(store.getProject(), 'K1');
513 expect(demand.lastzugAnteil, 'Der Eintrag darf nicht auf 0 gezogen werden').toBeCloseTo(
514 -0.3,
515 9,
516 );
517 expect(feld.value, 'Im Feld steht, was eingegeben wurde').toBe('-30');
518 expect(feld.getAttribute('aria-invalid'), 'Das Feld ist gekennzeichnet').toBe('true');
519
520 // Verworfen und nicht gerechnet: Gl. 2-6 wie ohne jede Angabe - und
521 // NICHT 1,150, was ein Rueckfall auf 0 % ergaebe.
522 expect(wirksamerSchwerverkehrsfaktor(demand)).toBeCloseTo(1.18, 9);
523
524 const plan = buildSignalPlan(store.getProject());
525 const notiz = plan.notes.find((n) => n.code === 'lastzuganteil-ausserhalb');
526 expect(notiz?.severity, 'Der Plan meldet den unbrauchbaren Eintrag').toBe('warnung');
527 expect(notiz?.message).toContain('-30 %');
528
529 /*
530 * Und beim naechsten Zeichnen steht es immer noch da. Das ist der
531 * eigentliche Unterschied zur Kurzmeldung: Die Ansicht baut sich nach
532 * jeder Eingabe neu auf, die Meldung verschwindet nach fuenf Sekunden -
533 * der Eintrag bleibt. Kennzeichnung und Vermerk muessen den Neuaufbau
534 * ueberleben, sonst ist der unbrauchbare Wert ab der naechsten Sitzung
535 * nur noch im Pruefbericht zu finden.
536 */
537 const neu = zeichne(signalGroupsView, store.getProject());
538 try {
539 expect(lastzugFeld(neu.wurzel, 'K1')!.value).toBe('-30');
540 expect(lastzugFeld(neu.wurzel, 'K1')!.getAttribute('aria-invalid')).toBe('true');
541 expect(faktorVermerk(neu.wurzel, 'K1')!.title).toContain('gilt als nicht erfasst');
542 } finally {
543 neu.aufraeumen();
544 }
545 } finally {
546 aufraeumen();
547 }
548 });
549
550 it('macht aus 150 % keine gezählten 100 %', () => {
551 // Die andere Richtung ist die konservative - und derselbe Fehler: Aus
552 // einer Fehleingabe wuerde die Aussage "alles Lastzuege".
553 const { wurzel, store, aufraeumen } = zeichne(signalGroupsView, mitAufteilung());
554 try {
555 setze(lastzugFeld(wurzel, 'K1')!, '150');
556 const demand = nachfrage(store.getProject(), 'K1');
557 expect(demand.lastzugAnteil).toBeCloseTo(1.5, 9);
558 expect(wirksamerSchwerverkehrsfaktor(demand), 'nicht 1,300').toBeCloseTo(1.18, 9);
559 } finally {
560 aufraeumen();
561 }
562 });
563
564 it('sagt vor der Eingabe und nach der Fehleingabe, was daraus folgt', () => {
565 const { wurzel, aufraeumen } = zeichne(signalGroupsView, mitAufteilung());
566 try {
567 const feld = lastzugFeld(wurzel, 'K1')!;
568 // Vorgelesen wird der Satz beim Fokussieren (dom.ts,
569 // ergaenzeBereichshinweis) - er muss die Folge nennen und nicht nur die
570 // Grenze, sonst hoert der Anwender "zulaessig sind 0 bis 100 %" und
571 // schliesst daraus auf ein Feld, das ihn schuetzt.
572 expect(bereichssatz(feld)).toContain(lastzuganteilAusserhalbFolge());
573 expect(bereichssatz(feld)).toContain('Zulässig sind 0 % bis 100 %.');
574
575 setze(feld, '-30');
576 const meldung = meldungsverlauf()[0]!;
577 expect(meldung.art).toBe('warnung');
578 expect(meldung.text).toContain('außerhalb des zulässigen Bereichs');
579 expect(meldung.text).toContain(lastzuganteilAusserhalbFolge());
580 // Kein Satz ueber einen ersatzweise eingetragenen Wert - es gibt keinen.
581 expect(meldung.text).not.toContain('Eingetragen wurde');
582 } finally {
583 aufraeumen();
584 }
585 });
586
587 it('nennt in der Folge die Gleichung und den Pauschalwert', () => {
588 const satz = lastzuganteilAusserhalbFolge();
589 expect(satz).toContain('nicht begrenzt');
590 expect(satz).toContain('verworfen');
591 expect(satz).toContain('Gleichung (2-6)');
592 expect(satz).toContain(fmt.num(HBS_PKW_GLEICHWERTE.schwerverkehrPauschal, 2));
593 });
594
595 it('begrenzt den Schwerverkehrsanteil daneben unverändert', () => {
596 /*
597 * Die Ausnahme gilt genau EINEM Feld. Beim Schwerverkehrsanteil ist
598 * Begrenzen richtig: Ein Anteil ueber 100 % ist eine unmoegliche Menge,
599 * aber eine Angabe - "sehr viel Schwerverkehr" auf 100 % zu ziehen bleibt
600 * in der Sache. Beim Lastzuganteil entscheidet der Bereich dagegen
601 * darueber, OB eine Aufteilung erfasst ist.
602 */
603 const { wurzel, store, aufraeumen } = zeichne(signalGroupsView, mitAufteilung());
604 try {
605 const feld = schwerverkehrsfeld(wurzel, 'K1');
606 setze(feld, '150');
607 expect(feld.value).toBe('100');
608 expect(nachfrage(store.getProject(), 'K1').heavyVehicleShare).toBeCloseTo(1, 9);
609 expect(bereichssatz(feld)).not.toContain('nicht begrenzt');
610 } finally {
611 aufraeumen();
612 }
613 });
614 });
615
616 describe('Satz unter der Signalgruppentabelle', () => {
617 const satz = schwerverkehrsanteilHinweis();
618
619 it('nennt beide Gleichungen und rechnet den Pauschalwert nach', () => {
620 expect(satz).toContain('Gleichung (2-5)');
621 expect(satz).toContain('Gleichung (2-6)');
622 expect(satz).toContain('1,75 · 0,80 + 2,50 · 0,20 = 1,90');
623 expect(satz).toContain('Lastzüge am SV');
624 });
625
626 it('behauptet nicht mehr, das Programm erfasse keine Aufteilung', () => {
627 expect(satz).not.toContain('erfasst dieses Programm nicht');
628 // Der Pauschalwert ist nicht mehr die Eigenschaft des Programms, sondern
629 // die Folge einer Datenlage - und das muss der Satz sagen.
630 expect(satz).toContain('Welche Gleichung des HBS 2015 gilt, entscheidet die Spalte');
631 });
632
633 it('bildet die Zahlen aus den Konstanten, nicht als festen Text', () => {
634 // Befund C21: Ein fester Text veraltet still. Wenn eine Konstante sich
635 // aendert, muss der Satz mitgehen - hier nachgewiesen ueber die Zahl selbst.
636 expect(satz).toContain(HBS_PKW_GLEICHWERTE.schwerverkehrPauschal.toFixed(2).replace('.', ','));
637 expect(satz).toContain(HBS_PKW_GLEICHWERTE.lkwMitAnhaenger.toFixed(2).replace('.', ','));
638 expect(satz).toContain(`${HBS_LASTZUGANTEIL_BEI_UNBEKANNTER_AUFTEILUNG * 100} %`);
639 });
640
641 it('steht unter der Tabelle, sobald es eine Gruppe mit Sättigungsverkehrsstärke gibt', () => {
642 const { wurzel, aufraeumen } = zeichne(signalGroupsView, mitAufteilung());
643 try {
644 expect(wurzel.textContent).toContain(satz);
645 } finally {
646 aufraeumen();
647 }
648 });
649 });
650
651 describe('Hilfe zur Aufteilung des Schwerverkehrs', () => {
652 const eintrag = HILFE.schwerverkehrsaufteilung;
653
654 it('sagt, was zu zählen ist', () => {
655 expect(eintrag.wasIstDas).toContain('Lkw mit Anhänger und Sattelkraftfahrzeuge');
656 // Die Bezugsgroesse ist die haeufigste Verwechslung: bei 20 %
657 // Schwerverkehr liegt zwischen beiden Lesarten ein Faktor 5.
658 expect(eintrag.wasIstDas).toContain(
659 'bezieht sich auf den SCHWERVERKEHR, nicht auf die Verkehrsstärke',
660 );
661 expect(eintrag.wasIstDas).toContain('jedes vierte Schwerfahrzeug ist ein Lastzug');
662 expect(eintrag.woher).toBeDefined();
663 expect(eintrag.woher).toContain('nicht die Solo-Lkw und nicht die Busse');
664 expect(eintrag.woher).toContain('Fahrzeugklassen');
665 });
666
667 it('nennt die drei Pkw-Gleichwerte und rechnet den Pauschalwert nach', () => {
668 expect(eintrag.wasIstDas).toContain('1,75');
669 expect(eintrag.wasIstDas).toContain('2,50');
670 expect(eintrag.wasIstDas).toContain('1,90');
671 expect(eintrag.wasIstDas).toContain('1,75 · 0,80 + 2,50 · 0,20 = 1,90');
672 expect(eintrag.wasIstDas).toContain('BASt-Bericht V 400');
673 });
674
675 it('sagt, was ohne Angabe passiert - und dass ein leeres Feld keine vorsichtige Wahl ist', () => {
676 expect(eintrag.woher).toContain('lassen Sie das Feld leer');
677 expect(eintrag.woher).toContain('Gleichung (2-6)');
678 expect(eintrag.wennFalsch).toBeDefined();
679 expect(eintrag.wennFalsch).toContain('1538');
680 expect(eintrag.wennFalsch).toContain('1695');
681 expect(eintrag.wennFalsch).toContain('10,2 Prozent zu groß');
682 expect(eintrag.wennFalsch).toContain('31,6 Prozent');
683 expect(eintrag.wennFalsch).toContain('2,5 Prozent zu klein');
684 expect(eintrag.wennFalsch).toContain('keine vorsichtige Wahl');
685 });
686
687 it('nennt die Meldeschwelle der Prüfregel und nicht eine eigene Zahl', () => {
688 expect(eintrag.woher).toContain(`über ${SCHWERVERKEHR_AUFTEILUNG_MELDESCHWELLE * 100} Prozent`);
689 });
690
691 it('trennt Regelwerks- von Erfahrungswerten', () => {
692 expect(eintrag.ueblich).toBeDefined();
693 expect(eintrag.ueblich).toContain('einzige belegte Zahl');
694 expect(eintrag.ueblich).toContain('Erfahrung und kein Regelwerkswert');
695 // Und der Umgang mit unbrauchbaren Werten: verworfen, nicht begrenzt.
696 expect(eintrag.ueblich).toContain('nicht begrenzt, sondern verworfen');
697 });
698
699 it('steht im Glossar und ist von den Nachbareinträgen aus erreichbar', () => {
700 expect(glossarEintraege().map((e) => e.schluessel)).toContain('schwerverkehrsaufteilung');
701 expect(glossarSuche('Aufteilung').map((e) => e.schluessel)).toContain(
702 'schwerverkehrsaufteilung',
703 );
704 for (const schluessel of ['schwerverkehrsanteil', 'saettigungsverkehrsstaerke'] as const) {
705 expect(HILFE[schluessel].siehe ?? [], schluessel).toContain('schwerverkehrsaufteilung');
706 }
707 });
708
709 it('nimmt dem Eintrag zum Schwerverkehrsanteil die überholte Behauptung', () => {
710 const anteil = HILFE.schwerverkehrsanteil;
711 expect(anteil.wasIstDas).not.toContain('keine Aufteilung nach Fahrzeugklassen');
712 expect(anteil.wasIstDas).not.toContain('rechnet deshalb immer mit dem Pauschalwert');
713 expect(anteil.wasIstDas).toContain('Bleibt die Spalte „Lastzüge am SV“ daneben leer');
714 expect(anteil.wennFalsch).toContain('Tragen Sie in diesem Fall den Lastzuganteil ein');
715 });
716 });
717
718 describe('Assistent erfindet keine Aufteilung', () => {
719 it('legt die Verkehrsstärken ohne Lastzuganteil an', () => {
720 const projekt = projektAusAngaben(
721 { ...ASSISTENT_VORGABE, verkehrsstaerke: 400, schwerverkehrsanteil: 9 },
722 'Assistent',
723 FESTES_DATUM,
724 );
725 expect(projekt.demands.length).toBeGreaterThan(0);
726 for (const demand of projekt.demands) {
727 // Nicht 0 und nicht 20 %: Der Assistent hat nicht gezaehlt.
728 expect('lastzugAnteil' in demand, 'Der Assistent behauptet eine Erhebung').toBe(false);
729 }
730 });
731
732 it('nennt die Annahme trotzdem in der Zusammenfassung', () => {
733 const saetze = assistentZusammenfassung({ ...ASSISTENT_VORGABE, verkehrsstaerke: 400 }).join(
734 ' ',
735 );
736 expect(saetze).toContain('Keine Aufteilung des Schwerverkehrs');
737 expect(saetze).toContain('Gleichung (2-6)');
738 expect(saetze).toContain('Pauschalwert 1,90');
739 expect(saetze).toContain('20 % Lastzügen am Schwerverkehr');
740
741 // Ohne Verkehrsstaerke gibt es keinen Schwerverkehr und nichts anzunehmen.
742 const ohne = assistentZusammenfassung({ ...ASSISTENT_VORGABE, verkehrsstaerke: 0 }).join(' ');
743 expect(ohne).not.toContain('Aufteilung des Schwerverkehrs');
744 });
745
746 it('erwähnt sie auch im Hinweis am Schwerverkehrsfeld des Dialogs', async () => {
747 const ergebnis = starteAssistent();
748 const dialog = (): HTMLElement => document.querySelector<HTMLElement>('.dialog')!;
749 const knopf = (beschriftung: string): HTMLButtonElement =>
750 [...dialog().querySelectorAll<HTMLButtonElement>('button')].find(
751 (b) => b.textContent?.trim() === beschriftung,
752 )!;
753 knopf('Weiter').click();
754 knopf('Weiter').click();
755
756 const label = [...dialog().querySelectorAll('label')].find(
757 (l) => l.textContent?.trim() === 'Schwerverkehrsanteil',
758 )!;
759 const feld = dialog().querySelector<HTMLInputElement>(`#${label.htmlFor}`)!;
760 const hinweis = dialog().querySelector<HTMLElement>(
761 `#${feld.getAttribute('aria-describedby')}`,
762 )!;
763 expect(hinweis.textContent).toContain(
764 'Eine Aufteilung des Schwerverkehrs erfasst der Assistent nicht',
765 );
766 expect(hinweis.textContent).toContain('Gleichung (2-6)');
767 expect(hinweis.textContent).toContain('20 %');
768 expect(hinweis.textContent).toContain('Lastzüge am SV');
769 expect(hinweis.textContent).toContain('Gleichung (2-5)');
770
771 knopf('Abbrechen').click();
772 await ergebnis;
773 });
774 });