lsa-planer

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

/ tests domain ersatzbelege.test.ts

27,8 KB Rohdatei
tests/domain/ersatzbelege.test.ts — 610 Zeilen
1 import { describe, expect, it } from 'vitest';
2 import { computeIntergreen } from '@/domain/rilsa/intergreen';
3 import { schwerverkehrsfaktor } from '@/domain/rilsa/capacity';
4 import { HBS_PKW_GLEICHWERTE, RILSA_DEFAULTS } from '@/domain/rilsa/constants';
5 import { QUELLEN } from '@/domain/rilsa/quellen';
6 import { resolveRilsaSettings, settingFields, wirkungslosigkeit } from '@/domain/rilsa/settings';
7 import type { ClearingStream, EnteringStream } from '@/domain/rilsa/types';
8 import { buildSignalPlan } from '@/domain/plan/signalPlan';
9 import { validateProject } from '@/domain/validation/engine';
10 import { loadProject } from '@/domain/model/migrate';
11 import { createStandardIntersectionProject } from '@/domain/model/factory';
12 import { CURRENT_SCHEMA_VERSION, type Project } from '@/domain/model/project';
13
14 /**
15 * Fassung 5.5.0, Fragen E1, E2, E5 und E6 - die Ersatzbelege aus oeffentlichen
16 * Quellen.
17 *
18 * Diese vier Fragen galten bis zum 15.08.2026 als "nur gegen die Druckausgabe
19 * pruefbar". Eine Recherche hat oeffentliche Ersatzbelege gehoben; massgebend
20 * ist, was einer kritischen Pruefung jedes einzelnen Belegs standhielt -
21 * mehrere Belege hielten ihr nicht stand.
22 *
23 * E6 (Rechenaenderung) - BASt-Bericht V 400 (2025), S. 10, woertlich:
24 * "fSV = (qLV + 1,75 * qLkw+Bus + 2,5 * qLkwK) / qKfz (2-5)"
25 * "fSV = (qLV + 1,9 * qSV) / qKfz (2-6)"
26 * "tB,j = fSV * f1 * f2 * 1,8" - "qS,j = 3600 / tB,j"
27 * und S. 68 zur Herkunft des bisherigen Programmwerts:
28 * "Im HCM 2010 (TRB, 2010) wurde an LSA ein konstanter Pkw-Gleichwert von
29 * 2,00 verwendet."
30 * Der Altstand rechnete `qS = 2000 / (1 + pSV)` - genau dieser HCM-Wert, unter
31 * der HBS-Quelle gefuehrt.
32 *
33 * E2 (waehlbar, Vorgabe unveraendert) - Hamburger LSA-Leitfaden, Anlage A:
34 * "geradeausfahrende Kfz (schnell): vr 10 m/s, tue 3 s, lFZ 6 m"
35 * "abbiegende Kfz (langsam): vr 7 m/s, tue 2 s, lFZ 6 m"
36 * Die 60-km/h-Spalte liest bei s0 = 14 m den Wert 5,0 s - mit tue = 4 s
37 * muessten dort 6,0 s stehen. Eine geschwindigkeitsabhaengige Ueberfahrzeit ist
38 * damit arithmetisch ausgeschlossen. Der Wortlaut der RiLSA zu den Faellen 1
39 * und 2 bleibt oeffentlich unzugaenglich; deshalb waehlbar, nicht umgestellt.
40 *
41 * E1 (waehlbar, Vorgabe AUS) - eine einzige Quellenlinie fuer "5 m/s bei
42 * Kurvenradius unter 10 m". Aus dem Regelwerk woertlich belegt ist nur die
43 * qualitative Radfahrerregel (Fall 5, verdeckte Textschicht des
44 * FGSV-Aenderungsblatts): "Sie ist zu verringern, wenn Radfahrer vor oder der
45 * Radfahrerfurt sehr enge Boegen durchfahren."
46 *
47 * E5 (Beleglage) - die drei QSV-Tafeln sind zweifach herausgeberseitig belegt
48 * (FGSV-Einfuehrungskolloquium 2015, Folie "Qualitaetskriterien"; BASt V 400
49 * Tab. 2-2).
50 */
51
52 const DATUM = new Date('2026-01-01T00:00:00Z');
53
54 // --- E6: Schwerverkehrsfaktor ----------------------------------------------
55
56 describe('E6 - Schwerverkehr nach HBS 2015 statt Pkw-Gleichwert 2,0', () => {
57 it('haelt die belegten Pkw-Gleichwerte fest', () => {
58 // Die drei Zahlen aus V 400, S. 68. Sie stehen als benannte Konstanten und
59 // nicht mehr hart in plan/signalPlan.ts.
60 expect(HBS_PKW_GLEICHWERTE.lkwUndBus).toBe(1.75);
61 expect(HBS_PKW_GLEICHWERTE.lkwMitAnhaenger).toBe(2.5);
62 expect(HBS_PKW_GLEICHWERTE.schwerverkehrPauschal).toBe(1.9);
63 });
64
65 it('rechnet Gl. 2-6 als fSV = 1 + 0,90 * pSV', () => {
66 expect(schwerverkehrsfaktor(0)).toBe(1);
67 expect(schwerverkehrsfaktor(0.25)).toBeCloseTo(1.225, 12);
68 expect(schwerverkehrsfaktor(1)).toBeCloseTo(1.9, 12);
69 });
70
71 it('trifft die Belegzahlen der Saettigungsverkehrsstaerke', () => {
72 // Standardbedingungen, reiner Pkw-Verkehr: 1,8 s je Kfz -> 2000 Kfz/h.
73 const grund = RILSA_DEFAULTS.capacity.saturationFlow;
74 expect(grund).toBe(2000);
75 expect(grund / schwerverkehrsfaktor(0)).toBe(2000);
76 // 25 % Schwerverkehr: 2000 / 1,225 = 1632,7 Kfz/h.
77 expect(grund / schwerverkehrsfaktor(0.25)).toBeCloseTo(1632.653, 3);
78 });
79
80 it('bestaetigt Gl. 2-6 aus Gl. 2-5 fuer den in V 400 genannten Lastzuganteil', () => {
81 /*
82 * V 400 zu Bild 2-1 d): "Bei einem Anteil der Lkw mit Anhaenger und
83 * Sattel-Kfz (Fahrzeugklasse LkwK) am Schwerverkehr von 20 % ergibt sich
84 * der Funktionsverlauf der Gleichung (2-6)."
85 *
86 * Nachgerechnet: mit 20 % LkwK am Schwerverkehr ist
87 * fSV = 1 + 0,75*0,8*p + 1,50*0,2*p = 1 + 0,9*p - Gl. 2-5 und Gl. 2-6
88 * fallen dort exakt zusammen. Das ist die Gegenprobe darauf, dass beide
89 * Gleichungen richtig umgesetzt sind: Ein Vorzeichen- oder
90 * Bezugsgroessenfehler in einer der beiden wuerde sie auseinanderfallen
91 * lassen.
92 */
93 for (const pSV of [0, 0.05, 0.1, 0.2, 0.35, 0.5]) {
94 const ausAufteilung = schwerverkehrsfaktor(pSV, {
95 lkwUndBus: 0.8 * pSV,
96 lkwMitAnhaenger: 0.2 * pSV,
97 });
98 expect(ausAufteilung, `pSV ${pSV}`).toBeCloseTo(schwerverkehrsfaktor(pSV), 12);
99 }
100 });
101
102 it('laesst den Schwerverkehr nicht groesser werden als den Verkehr', () => {
103 // Unmoegliche Aufteilung: 80 % Lkw + 80 % Lastzuege. Ohne Begrenzung
104 // ergaebe sich fSV = 2,8 - eine Saettigungsverkehrsstaerke von 714 Kfz/h,
105 // die kein Fahrstreifen hat. Gekuerzt wird der leichtere Anteil.
106 expect(schwerverkehrsfaktor(0, { lkwUndBus: 0.8, lkwMitAnhaenger: 0.8 })).toBeCloseTo(
107 1 + 0.75 * 0.2 + 1.5 * 0.8,
108 12,
109 );
110 expect(schwerverkehrsfaktor(0, { lkwUndBus: 1, lkwMitAnhaenger: 1 })).toBeCloseTo(2.5, 12);
111 });
112
113 it('rechnet den Lastzugfall mit Gl. 2-5 deutlich unguenstiger', () => {
114 // 20 % Schwerverkehr, davon alles Lastzuege: fSV = 1 + 1,50*0,20 = 1,300.
115 const nurLastzuege = schwerverkehrsfaktor(0.2, { lkwUndBus: 0, lkwMitAnhaenger: 0.2 });
116 expect(nurLastzuege).toBeCloseTo(1.3, 12);
117 expect(2000 / nurLastzuege).toBeCloseTo(1538.46, 2);
118 // Der Altstand kam hier auf 2000/1,2 = 1667 Kfz/h, also 8,3 % zu gross -
119 // die eigentliche unsichere Richtung, die Gl. 2-5 abfaengt.
120 expect(2000 / 1.2 / (2000 / nurLastzuege)).toBeCloseTo(1.083, 3);
121 });
122
123 it('nennt die Fehlerrichtung gegenueber dem Altstand ehrlich', () => {
124 /*
125 * MUTATIONSNACHWEIS zugleich: Der Altstand rechnete 2000/(1+pSV). Diese
126 * Erwartungen schlagen gegen ihn fehl, weil er bei jedem pSV > 0 einen
127 * anderen Wert liefert.
128 */
129 for (const pSV of [0.05, 0.1, 0.2, 0.3]) {
130 const neu = 2000 / schwerverkehrsfaktor(pSV);
131 const alt = 2000 / (1 + pSV);
132 // Weniger konservativ: die Kapazitaet faellt groesser aus als bisher.
133 expect(neu, `pSV ${pSV}`).toBeGreaterThan(alt);
134 // Und zwar um hoechstens 2,3 % - die Zahl, die in die Unterlagen gehoert.
135 expect(neu / alt).toBeLessThanOrEqual(1.024);
136 }
137 expect(2000 / schwerverkehrsfaktor(0.3) / (2000 / 1.3)).toBeCloseTo(1.0236, 4);
138 });
139
140 it('setzt den Faktor im Signalzeitenplan ein - nicht mehr das feste 2,0', () => {
141 const roh = createStandardIntersectionProject('Schwerverkehr', DATUM);
142 const k1 = roh.signalGroups.find((g) => g.name === 'K1')!;
143 expect(k1.movement).toBe('geradeaus');
144 expect(k1.lanes).toBe(1);
145
146 const project: Project = {
147 ...roh,
148 demands: [{ signalGroupId: k1.id, volume: 600, heavyVehicleShare: 0.25 }],
149 };
150 const plan = buildSignalPlan(project);
151 const gruppe = plan.groups.find((g) => g.groupId === k1.id);
152 expect(gruppe?.capacity).not.toBeNull();
153 // 2000 / 1,225 = 1632,65 - der Altstand lieferte hier 2000/1,25 = 1600.
154 expect(gruppe!.capacity!.saturationFlow).toBeCloseTo(1632.653, 3);
155 expect(gruppe!.capacity!.saturationFlow).not.toBeCloseTo(1600, 1);
156 });
157
158 it('fuehrt Gl. 2-5 nicht als wirkungslosen Kennwert in der Vorgabenverwaltung', () => {
159 // Befund C20: keine Zahl unter der Ueberschrift "Vorgaben", die in kein
160 // Ergebnis eingeht. Die Pkw-Gleichwerte sind deshalb Konstanten mit Beleg
161 // und kein Vorgabenfeld.
162 const schluessel = settingFields().map((f) => String(f.key));
163 for (const verboten of ['pkwGleichwertLkw', 'pkwGleichwertLastzug', 'schwerverkehrsfaktor']) {
164 expect(schluessel).not.toContain(verboten);
165 }
166 });
167
168 it('sagt im Fundstellenverzeichnis, welche Gleichung gerechnet wird', () => {
169 const hinweis = QUELLEN['hbs-kapazitaet'].hinweis ?? '';
170 expect(hinweis).toContain('1,75');
171 expect(hinweis).toContain('2,5');
172 expect(hinweis).toContain('1,9');
173 // Die falsche Zuschreibung ist benannt, nicht spurlos verschwunden.
174 expect(hinweis).toContain('HCM 2010');
175 // Und die Grenzen der Rechnung stehen dabei.
176 expect(hinweis).toContain('Standardbedingungen');
177 expect(hinweis).toContain('Mischfahrstreifen');
178 });
179 });
180
181 // --- E2: Ueberfahrzeit-Ansatz ----------------------------------------------
182
183 /** Abbiegender Kfz-Strom, 20 m Raeumweg, ohne Einfahrweg. */
184 function abbieger(overrides: Partial<ClearingStream> = {}): ClearingStream {
185 return {
186 mode: 'kfz',
187 movement: 'rechts',
188 clearingDistance: 20,
189 vehicleClass: 'pkw',
190 vZul: 70,
191 ...overrides,
192 };
193 }
194
195 const OHNE_EINFAHRWEG: EnteringStream = { mode: 'kfz', movement: 'geradeaus', enteringDistance: 0 };
196
197 describe('E2 - Ueberfahrzeit als waehlbarer Ansatz', () => {
198 it('rechnet ohne Angabe wie bisher mit der Gelbzeit', () => {
199 // Kein Feld gesetzt: der Stand bis Schema 11. Bei 70 km/h sind das 5 s.
200 const ohne = computeIntergreen(abbieger(), OHNE_EINFAHRWEG);
201 const ausdruecklich = computeIntergreen(
202 abbieger({ ueberfahrzeitAnsatz: 'gelbzeit' }),
203 OHNE_EINFAHRWEG,
204 );
205 expect(ohne.crossingTime).toBe(5);
206 expect(ausdruecklich.crossingTime).toBe(5);
207 // tue 5 s + (20 + 6)/7 = 8,714 -> 9 s
208 expect(ohne.intergreen).toBe(9);
209 expect(ohne.raw).toBeCloseTo(8.714, 3);
210 });
211
212 it('rechnet bei "fest" mit 3 s geradeaus und 2 s abbiegend', () => {
213 const abbiegend = computeIntergreen(abbieger({ ueberfahrzeitAnsatz: 'fest' }), OHNE_EINFAHRWEG);
214 expect(abbiegend.crossingTime).toBe(2);
215 // tue 2 s + 26/7 = 5,714 -> 6 s, also 3 s kuerzer als mit Gelbzeitkopplung.
216 expect(abbiegend.intergreen).toBe(6);
217
218 const geradeaus = computeIntergreen(
219 abbieger({ movement: 'geradeaus', ueberfahrzeitAnsatz: 'fest' }),
220 OHNE_EINFAHRWEG,
221 );
222 expect(geradeaus.crossingTime).toBe(3);
223 // vr geradeaus 10 m/s: 3 + 26/10 = 5,6 -> 6 s
224 expect(geradeaus.intergreen).toBe(6);
225 });
226
227 it('bleibt bei 50 km/h geradeaus in beiden Ansaetzen gleich', () => {
228 // Dort faellt die Gelbzeit (3 s) mit dem festen Wert zusammen - die Wahl
229 // aendert nichts, und der Test haelt fest, dass sie es auch nicht soll.
230 const gemeinsam = { movement: 'geradeaus' as const, vZul: 50 };
231 const gelb = computeIntergreen(abbieger(gemeinsam), OHNE_EINFAHRWEG);
232 const fest = computeIntergreen(
233 abbieger({ ...gemeinsam, ueberfahrzeitAnsatz: 'fest' }),
234 OHNE_EINFAHRWEG,
235 );
236 expect(fest.crossingTime).toBe(gelb.crossingTime);
237 expect(fest.intergreen).toBe(gelb.intergreen);
238 });
239
240 it('haelt die Untergrenze tue + tr >= tG + 1 s ein', () => {
241 /*
242 * UMGEDREHT MIT 5.27.0. Hier stand das Gegenteil: Die Regel sei "NICHT
243 * eingebaut", weil sie "als woertliche Quelle nur eine Webseite" habe,
244 * "die sich im selben Text widerspricht". Sie steht in der RiLSA 2015,
245 * Abschnitt 2.5.2, unmittelbar vor der Aufzaehlung der sechs Faelle, und
246 * gilt den Faellen 1 und 2.
247 *
248 * Bei 70 km/h ist tG = 5 s, die Untergrenze also 6 s. Mit festem Ansatz
249 * und 1 m Raeumweg ergibt die Rechnung 2 + 7/7 = 3,0 s; angesetzt werden
250 * 6 s. Dieselbe Beziehung lieferte bis 5.26.0 3 s.
251 */
252 const kurz = computeIntergreen(
253 abbieger({ clearingDistance: 1, ueberfahrzeitAnsatz: 'fest' }),
254 OHNE_EINFAHRWEG,
255 );
256 expect(kurz.intergreen).toBe(6);
257 expect(kurz.ueberfahrRaeumzeitUntergrenze).toBe(6);
258 // Die Einzelwerte bleiben, was sie sind - angehoben wird die SUMME.
259 expect(kurz.crossingTime).toBe(2);
260 expect(kurz.clearingTime).toBe(1);
261 const meldung = kurz.notes.find((n) => n.code === 'ueberfahr-raeumzeit-untergrenze');
262 expect(meldung?.severity).toBe('hinweis');
263 expect(meldung?.message).toContain('2.5.2');
264 });
265
266 it('meldet die Untergrenze nicht, wo sie nicht greift', () => {
267 // Derselbe Strom mit langem Raeumweg: 2 + 26/7 = 5,71 s liegt unter 6 s
268 // und wird angehoben - mit 40 m dagegen 2 + 46/7 = 8,57 s, darueber.
269 const lang = computeIntergreen(
270 abbieger({ clearingDistance: 40, ueberfahrzeitAnsatz: 'fest' }),
271 OHNE_EINFAHRWEG,
272 );
273 expect(lang.ueberfahrRaeumzeitUntergrenze).toBeNull();
274 expect(lang.intergreen).toBe(9);
275 expect(lang.notes.some((n) => n.code === 'ueberfahr-raeumzeit-untergrenze')).toBe(false);
276 });
277
278 it('laesst Rad, Fuss und OePNV von der Untergrenze unberuehrt', () => {
279 /*
280 * Die RiLSA nennt die Bedingung ausdruecklich nur fuer die Faelle 1 und 2 -
281 * geradeaus fahrende und abbiegende Kraftfahrzeuge. Sie auf die uebrigen
282 * Verkehrsarten zu uebertragen hiesse, das Regelwerk zu ergaenzen; beim
283 * OePNV gaebe es das tG dafuer nicht einmal.
284 */
285 const rad = computeIntergreen(
286 { mode: 'rad', movement: 'geradeaus', clearingDistance: 1, vehicleClass: 'rad', vZul: 70 },
287 OHNE_EINFAHRWEG,
288 );
289 expect(rad.ueberfahrRaeumzeitUntergrenze).toBeNull();
290 // 1 s Ueberfahrzeit + 1/4 s = 1,25 -> 2 s. Mit Untergrenze waeren es 6 s.
291 expect(rad.intergreen).toBe(2);
292 });
293
294 it('laesst die Ueberfahrzeit auch bei "fest" nicht unterschreiten', () => {
295 // Die harte Kfz-Schranke gilt in beiden Ansaetzen - sonst waere ein
296 // Wechsel des Ansatzes der Weg, aus einem Fehler eine Warnung zu machen.
297 const ergebnis = computeIntergreen(
298 abbieger({ ueberfahrzeitAnsatz: 'fest', crossingTimeOverride: 0 }),
299 OHNE_EINFAHRWEG,
300 );
301 expect(ergebnis.crossingTime).toBe(2);
302 const meldung = ergebnis.notes.find((n) => n.code === 'ueberfahrzeit-unter-gelbzeitschranke');
303 expect(meldung?.severity).toBe('fehler');
304 // Und sie behauptet nicht mehr, der Wert sei die Gelbzeit.
305 expect(meldung?.message).toContain('feste');
306 expect(meldung?.message).not.toContain('die Gelbzeit von');
307 });
308
309 it('nimmt die Projektwahl in den Plan und meldet sie als Abweichung', () => {
310 const roh = createStandardIntersectionProject('Ansatz', DATUM);
311 const gelbzeit = buildSignalPlan(roh);
312 const fest = buildSignalPlan({
313 ...roh,
314 settings: { ...roh.settings, ueberfahrzeitAnsatz: 'fest' },
315 });
316
317 // Der Musterknotenpunkt faehrt 50 km/h geradeaus; dort sind beide Ansaetze
318 // gleich. Massgebend ist deshalb, dass die Wahl ueberhaupt ankommt:
319 const k1 = roh.signalGroups.find((g) => g.name === 'K1')!;
320 const f2 = roh.signalGroups.find((g) => g.name === 'F2')!;
321 const schluessel = `${k1.id}|${f2.id}`;
322 expect(gelbzeit.intergreens.get(schluessel)?.calculation?.crossingTime).toBe(3);
323 expect(fest.intergreens.get(schluessel)?.calculation?.crossingTime).toBe(3);
324
325 // Bei 70 km/h trennen sich die Wege um 2 s.
326 const schnell: Project = {
327 ...roh,
328 signalGroups: roh.signalGroups.map((g) => (g.id === k1.id ? { ...g, vZul: 70 } : g)),
329 settings: { ...roh.settings, ueberfahrzeitAnsatz: 'fest' },
330 };
331 const schnellPlan = buildSignalPlan(schnell);
332 expect(schnellPlan.intergreens.get(schluessel)?.calculation?.crossingTime).toBe(3);
333 const schnellGelb = buildSignalPlan({ ...schnell, settings: roh.settings });
334 expect(schnellGelb.intergreens.get(schluessel)?.calculation?.crossingTime).toBe(5);
335
336 const bericht = validateProject(
337 { ...roh, settings: { ...roh.settings, ueberfahrzeitAnsatz: 'fest' } },
338 fest,
339 DATUM,
340 );
341 const befund = bericht.findings.find(
342 (f) => f.rule === 'zwischenzeiten.ueberfahrzeit-ansatz-fest',
343 );
344 expect(befund?.severity).toBe('warnung');
345 expect(befund?.message).toContain('VERKÜRZT');
346 expect(befund?.suggestion).toContain('Begründen Sie die Wahl');
347 // Die Vorgabe erzeugt keinen Befund.
348 const ohne = validateProject(roh, gelbzeit, DATUM);
349 expect(ohne.findings.map((f) => f.rule)).not.toContain(
350 'zwischenzeiten.ueberfahrzeit-ansatz-fest',
351 );
352 });
353
354 it('kennzeichnet die festen Werte als wirkungslos, solange die Gelbzeit gilt', () => {
355 // Befund C20 in neuer Gestalt: Die beiden Vorgabenfelder gibt es, aber sie
356 // geben nicht vor zu wirken, wenn der Ansatz sie nicht abruft.
357 expect(
358 wirkungslosigkeit('praxis-ueberfahrzeit-fest', 'HBS', { ueberfahrzeitAnsatz: 'gelbzeit' }),
359 ).not.toBeNull();
360 expect(
361 wirkungslosigkeit('praxis-ueberfahrzeit-fest', 'HBS', { ueberfahrzeitAnsatz: 'fest' }),
362 ).toBeNull();
363 // Ohne Kenntnis des Ansatzes wird NICHTS behauptet - lieber eine
364 // Begruendung zu viel verlangen als eine Wirkung zu Unrecht bestreiten.
365 expect(wirkungslosigkeit('praxis-ueberfahrzeit-fest', 'HBS')).toBeNull();
366 });
367
368 /*
369 * Gegenstueck zum Vorherigen (Fassung 5.5.0, Befund B6):
370 * Dieselbe Lage, dieselbe Auskunft - bis dahin bekam der eine Kennwert einen
371 * Vermerk und der andere keinen, ohne dass der Quelltext den Unterschied
372 * begruendete.
373 */
374 it('kennzeichnet die Raeumgeschwindigkeit bei engem Innenradius als wirkungslos, solange kein Merkmal gesetzt ist', () => {
375 expect(
376 wirkungslosigkeit('praxis-enger-radius', 'HBS', { engerRadiusGesetzt: false }),
377 ).not.toBeNull();
378 expect(
379 wirkungslosigkeit('praxis-enger-radius', 'HBS', { engerRadiusGesetzt: true }),
380 ).toBeNull();
381 expect(wirkungslosigkeit('praxis-enger-radius', 'HBS')).toBeNull();
382 });
383
384 it('haelt die belegten Werte in den Vorgaben und laesst sie anpassen', () => {
385 expect(RILSA_DEFAULTS.crossingTime.kfzGeradeaus).toBe(3);
386 expect(RILSA_DEFAULTS.crossingTime.kfzAbbiegend).toBe(2);
387
388 const { defaults } = resolveRilsaSettings({ crossingTimeKfzAbbiegend: 2.5 });
389 expect(defaults.crossingTime.kfzAbbiegend).toBe(2.5);
390 const ergebnis = computeIntergreen(
391 abbieger({ ueberfahrzeitAnsatz: 'fest' }),
392 OHNE_EINFAHRWEG,
393 defaults,
394 );
395 expect(ergebnis.crossingTime).toBe(2.5);
396 });
397 });
398
399 // --- E1: enger Innenradius --------------------------------------------------
400
401 describe('E1 - enger Innenradius als Merkmal der Konfliktbeziehung', () => {
402 it('raeumt mit 5,0 m/s statt 7,0 m/s und verlaengert die Zwischenzeit', () => {
403 const ohne = computeIntergreen(abbieger(), OHNE_EINFAHRWEG);
404 const mit = computeIntergreen(abbieger({ engerRadius: true }), OHNE_EINFAHRWEG);
405
406 expect(ohne.clearingSpeed).toBe(7);
407 expect(mit.clearingSpeed).toBe(5);
408 // 5 s + 26/5 = 10,2 -> 11 s gegen 9 s: die sichere Richtung.
409 expect(mit.intergreen).toBe(11);
410 expect(mit.intergreen).toBeGreaterThan(ohne.intergreen);
411 });
412
413 it('wirkt nur beim Abbiegen', () => {
414 const geradeaus = computeIntergreen(
415 abbieger({ movement: 'geradeaus', engerRadius: true }),
416 OHNE_EINFAHRWEG,
417 );
418 expect(geradeaus.clearingSpeed).toBe(RILSA_DEFAULTS.clearingSpeed.kfzStraight);
419 });
420
421 it('ist ohne Angabe aus - Altdateien rechnen unveraendert', () => {
422 expect(RILSA_DEFAULTS.clearingSpeed.kfzTurningEngerRadius).toBe(5);
423 const ohneFeld = computeIntergreen(abbieger(), OHNE_EINFAHRWEG);
424 const ausdruecklichAus = computeIntergreen(abbieger({ engerRadius: false }), OHNE_EINFAHRWEG);
425 expect(ohneFeld.clearingSpeed).toBe(7);
426 expect(ausdruecklichAus.clearingSpeed).toBe(7);
427 });
428
429 it('meldet das gesetzte Merkmal als begruendungspflichtige Abweichung', () => {
430 const roh = createStandardIntersectionProject('Radius', DATUM);
431 const k1 = roh.signalGroups.find((g) => g.name === 'K1')!;
432 const f2 = roh.signalGroups.find((g) => g.name === 'F2')!;
433 const project: Project = {
434 ...roh,
435 // K1 biegt hier rechts ab und quert dabei die Furt F2.
436 signalGroups: roh.signalGroups.map((g) =>
437 g.id === k1.id ? { ...g, movement: 'rechts' } : g,
438 ),
439 conflicts: roh.conflicts.map((c) =>
440 c.fromId === k1.id && c.toId === f2.id ? { ...c, engerRadius: true } : c,
441 ),
442 };
443 const bericht = validateProject(project, buildSignalPlan(project), DATUM);
444 const befund = bericht.findings.find((f) => f.rule === 'zwischenzeiten.enger-radius');
445 expect(befund?.severity).toBe('hinweis');
446 // Dieselbe Schreibweise wie in Ansicht und Ausdruck (ansaetze.ts): "5,0 m/s"
447 // und nicht "5 m/s" - eine Geschwindigkeit, die je Ausgabe anders aussieht,
448 // liest sich wie zwei verschiedene Werte.
449 expect(befund?.message).toContain('5,0 m/s');
450 expect(befund?.message).toContain('7,0 m/s');
451 expect(befund?.message).toContain('länger');
452 expect(befund?.message).toContain('RiLSA 2015');
453 // Seit 5.27.0 die RiLSA und nicht mehr "Praxisansatz": Abschnitt 2.5.2,
454 // Fall 2 nennt die 5 m/s und die Radiusschwelle.
455 expect(befund?.reference).toContain('RiLSA 2015');
456 expect(befund?.reference).not.toContain('Praxisansatz');
457 });
458
459 it('meldet ein Merkmal, das an dieser Beziehung nichts bewirkt', () => {
460 // Gesetzt an einem geradeausfahrenden Strom: Das Kaestchen steht an, die
461 // Zwischenzeit aendert sich nicht. Schweigen waere hier die stille
462 // Ersetzung, gegen die die Befunde C7 bis C12 angetreten sind.
463 const roh = createStandardIntersectionProject('Radius ohne Wirkung', DATUM);
464 const k1 = roh.signalGroups.find((g) => g.name === 'K1')!;
465 const f2 = roh.signalGroups.find((g) => g.name === 'F2')!;
466 const project: Project = {
467 ...roh,
468 conflicts: roh.conflicts.map((c) =>
469 c.fromId === k1.id && c.toId === f2.id ? { ...c, engerRadius: true } : c,
470 ),
471 };
472 const bericht = validateProject(project, buildSignalPlan(project), DATUM);
473 const befund = bericht.findings.find(
474 (f) => f.rule === 'zwischenzeiten.enger-radius-ohne-wirkung',
475 );
476 expect(befund?.severity).toBe('warnung');
477 expect(befund?.message).toContain('geht');
478 });
479
480 it('laesst den Wert nicht ueber die Raeumgeschwindigkeit des Regelfalls steigen', () => {
481 // Ein "enger Innenradius", der schneller raeumt als der Regelabbieger,
482 // waere eine Verkuerzung durch die Hintertuer.
483 const feld = settingFields().find((f) => f.key === 'clearingSpeedKfzTurningEngerRadius');
484 expect(feld?.max).toBe(RILSA_DEFAULTS.clearingSpeed.kfzTurning);
485 const { defaults, notes } = resolveRilsaSettings({ clearingSpeedKfzTurningEngerRadius: 9 });
486 expect(defaults.clearingSpeed.kfzTurningEngerRadius).toBe(7);
487 expect(notes.some((n) => n.code.includes('clearingSpeedKfzTurningEngerRadius'))).toBe(true);
488 });
489 });
490
491 // --- Schemastand 12 ---------------------------------------------------------
492
493 describe('Schemastand 12 - Rundreise und Altdateien', () => {
494 it('steht inzwischen auf 13 - die Felder aus Schema 12 bleiben', () => {
495 // Die Felder engerRadius und ueberfahrzeitAnsatz kamen mit
496 // Schema 12. Mit dem Lastzuganteil ist die Zahl auf 13 gestiegen
497 // (TrafficDemand.lastzugAnteil); geprueft wird hier weiter, dass die
498 // Angaben aus 12 Speichern und Laden unveraendert ueberstehen.
499 expect(CURRENT_SCHEMA_VERSION).toBe(19);
500 });
501
502 it('traegt beide neuen Angaben durch Speichern und Laden', () => {
503 const roh = createStandardIntersectionProject('Rundreise', DATUM);
504 const erster = roh.conflicts[0]!;
505 const project: Project = {
506 ...roh,
507 conflicts: roh.conflicts.map((c) => (c.id === erster.id ? { ...c, engerRadius: true } : c)),
508 settings: { ...roh.settings, ueberfahrzeitAnsatz: 'fest' },
509 };
510
511 const geladen = loadProject(JSON.parse(JSON.stringify(project)), DATUM);
512 expect(geladen.issues).toEqual([]);
513 expect(geladen.project.schemaVersion).toBe(CURRENT_SCHEMA_VERSION);
514 expect(geladen.project.settings.ueberfahrzeitAnsatz).toBe('fest');
515 expect(geladen.project.conflicts.find((c) => c.id === erster.id)?.engerRadius).toBe(true);
516 // Und die uebrigen Beziehungen bekommen das Feld NICHT hinzugeschrieben.
517 expect(geladen.project.conflicts.filter((c) => 'engerRadius' in c)).toHaveLength(1);
518 });
519
520 it('liest eine Datei aus Schemastand 11 ohne Meldung und ohne Rechenaenderung', () => {
521 const roh = createStandardIntersectionProject('Altdatei', DATUM);
522 const alt = JSON.parse(JSON.stringify(roh)) as Record<string, unknown>;
523 alt['schemaVersion'] = 11;
524 // Genau der Zustand einer Datei bis Fassung 5.4.0: kein Ansatz, kein
525 // Radiusmerkmal.
526 delete (alt['settings'] as Record<string, unknown>)['ueberfahrzeitAnsatz'];
527 for (const conflict of alt['conflicts'] as Record<string, unknown>[]) {
528 delete conflict['engerRadius'];
529 }
530
531 const geladen = loadProject(alt, DATUM);
532 expect(geladen.issues).toEqual([]);
533 expect(geladen.project.settings.ueberfahrzeitAnsatz).toBe('gelbzeit');
534 expect(geladen.project.conflicts.every((c) => c.engerRadius === undefined)).toBe(true);
535
536 // Und die Zwischenzeiten sind dieselben wie vor der Umstellung.
537 const vorher = buildSignalPlan(roh);
538 const nachher = buildSignalPlan(geladen.project);
539 for (const [schluessel, wert] of vorher.intergreens) {
540 expect(nachher.intergreens.get(schluessel)?.value, schluessel).toBe(wert.value);
541 }
542 const bericht = validateProject(geladen.project, nachher, DATUM);
543 for (const regel of [
544 'zwischenzeiten.ueberfahrzeit-ansatz-fest',
545 'zwischenzeiten.enger-radius',
546 'zwischenzeiten.enger-radius-ohne-wirkung',
547 ]) {
548 expect(bericht.findings.map((f) => f.rule)).not.toContain(regel);
549 }
550 });
551
552 it('faellt bei unlesbarem Ansatz auf die konservative Seite zurueck', () => {
553 const roh = createStandardIntersectionProject('Unlesbar', DATUM);
554 const kaputt = JSON.parse(JSON.stringify(roh)) as Record<string, unknown>;
555 (kaputt['settings'] as Record<string, unknown>)['ueberfahrzeitAnsatz'] = 'irgendwas';
556
557 const geladen = loadProject(kaputt, DATUM);
558 expect(geladen.project.settings.ueberfahrzeitAnsatz).toBe('gelbzeit');
559 // Nicht stillschweigend: Der Bericht der Datei sagt, was ersetzt wurde.
560 expect(geladen.issues.some((i) => i.path === 'settings.ueberfahrzeitAnsatz')).toBe(true);
561 });
562 });
563
564 // --- E5 und E7: Beleglage ---------------------------------------------------
565
566 describe('E5 und E7 - Pruefstaende nach der neuen Beleglage', () => {
567 it('fuehrt die Qualitaetsstufen als gesichert und sagt, worauf das beruht', () => {
568 const quelle = QUELLEN['hbs-qualitaetsstufen'];
569 expect(quelle.pruefstand).toBe('gesichert');
570 const hinweis = quelle.hinweis ?? '';
571 // Zwei Herausgeberquellen, zehn Jahre auseinander.
572 expect(hinweis).toContain('FGSV-Einführungskolloquium');
573 expect(hinweis).toContain('V 400');
574 // Alle drei Tafeln.
575 expect(hinweis).toContain('20/35/50/70');
576 expect(hinweis).toContain('5/15/25/40/60');
577 expect(hinweis).toContain('30/40/55/70/85');
578 // Und die Einschraenkung: zwei Wiedergaben EINES Originals.
579 expect(hinweis).toContain('Wiedergaben eines Originals');
580 // Die Tabelle D-4 des BASt-QM-Leitfadens ist ausdruecklich ausgeschlossen.
581 expect(hinweis).toContain('HBS 2001');
582 });
583
584 it('nennt die belegte Ermittlung der maximalen Wartezeit', () => {
585 // V 400 unmittelbar unter Tab. 2-2 - das stuetzt die Umsetzung aus B4.
586 expect(QUELLEN['hbs-qualitaetsstufen'].hinweis).toContain('Sperrzeit');
587 });
588
589 it('traegt die 95-Prozent-Regel des FGSV-Dialogs am Abbiegerfaktor', () => {
590 expect(QUELLEN['hbs-abminderung'].hinweis).toContain('x ≥ 95 %');
591 expect(QUELLEN['hbs-abminderung'].hinweis).toContain('0 Kfz/h');
592 });
593
594 it('sagt bei den Rad- und Fusswerten, was woertlich belegt ist', () => {
595 const hinweis = QUELLEN['rilsa-zwischenzeiten'].hinweis ?? '';
596 expect(hinweis).toContain('vr = 4 m/s');
597 expect(hinweis).toContain('tü = 0 s');
598 expect(hinweis).toContain('vr = 1,2 m/s');
599 // Und dass dieses Programm strenger ist als das Zitat.
600 expect(hinweis).toContain('1,5 m/s');
601 expect(hinweis).toContain('1,2 m/s');
602 });
603
604 it('nimmt den RiLSA-Bezug vom empfohlenen Umlaufzeitbereich', () => {
605 const hinweis = QUELLEN['rilsa-umlaufzeit-regelbereich'].hinweis ?? '';
606 expect(QUELLEN['rilsa-umlaufzeit-regelbereich'].pruefstand).toBe('abzugleichen');
607 expect(hinweis).toContain('50 bis 75');
608 expect(hinweis).toContain('an keine weiteren Vorgaben gebunden');
609 });
610 });