lsa-planer

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

/ tests domain unterlagenRechtsgrundlagen.test.ts

23,1 KB Rohdatei
tests/domain/unterlagenRechtsgrundlagen.test.ts — 546 Zeilen
1 import { describe, expect, it } from 'vitest';
2 import { buildSignalPlan } from '@/domain/plan/signalPlan';
3 import { validateProject } from '@/domain/validation/engine';
4 import {
5 abbiegerfaktorBefunde,
6 regelbereichFundstelle,
7 umlaufzeitFundstelle,
8 } from '@/domain/validation/rules';
9 import {
10 createConflict,
11 createEmptyProject,
12 createPhase,
13 createSignalGroup,
14 createArm,
15 } from '@/domain/model/factory';
16 import { parseProject } from '@/domain/model/schema';
17 import { CURRENT_SCHEMA_VERSION, type Project, type SignalGroup } from '@/domain/model/project';
18 import { ANLAGENART_GRENZEN, RILSA_DEFAULTS } from '@/domain/rilsa/constants';
19 import {
20 RECHTSGRUNDLAGEN,
21 RECHTSGRUNDLAGEN_EINSTREIFIG,
22 rechtsgrundlagenFuer,
23 } from '@/domain/rilsa/quellen';
24 import { settingFields } from '@/domain/rilsa/settings';
25 import type { Anlagenart } from '@/domain/rilsa/types';
26
27 /*
28 * Unterlagen und Rechtsgrundlagen (Befunde B8, B9, C13, C14, C17, C18, C19).
29 *
30 * B8 Jede Zeile der Schrankentabelle traegt ihre eigene Herkunft; belegt ist
31 * nur der Umlaufzeitbereich 30-120 s am Knotenpunkt und die 90 s als
32 * Obergrenze des Regelbereichs (Fassung 5.4.0, Fragen E4 und E7).
33 * B9 Die Umlaufzeitregeln des Pruefberichts nennen "RiLSA 2015 - Umlaufzeit"
34 * nur dort, wo der Wert wirklich aus der RiLSA stammt.
35 * C13 Rechtsgrundlagen anlagenartabhaengig: RSA 21, TL und ZTV transportable
36 * LSA 2023 nur bei einstreifiger Verkehrsfuehrung.
37 * C14 Einsatzzeiten und Betriebsverantwortlicher als Metafelder samt Pruefregel
38 * (einstreifig Warnung, sonst Hinweis) - ohne Schemabump.
39 * C17 Der VwV-StVO-Eintrag nennt die Zeiten, die die Vorschrift selbst vorgibt.
40 * C18 Hinweis auf den pauschalen Abbiegerfaktor an bedingt vertraeglich
41 * gefuehrten Linksabbiegern.
42 * C19 Die OePNV-Mindestfreigabezeit steht unter der Planungspraxis, nicht unter
43 * RiLSA. NACHGEZOGEN (Fassung 5.12.0): Sie hing dabei an der
44 * Sammelquelle 'praxis', deren gedruckter Hinweis eine Wirkungslosigkeit
45 * behauptet, die fuer sie nicht zutrifft; sie traegt jetzt die eigene
46 * Fundstelle 'praxis-mindestfreigabezeit-oepnv'. Der Fall unten prueft
47 * deshalb nicht mehr auf den Schluessel 'praxis', sondern auf das, was C19
48 * verlangt: eine Fundstelle der Planungspraxis und nicht des Regelwerks.
49 *
50 * MUTATIONSNACHWEIS: Gegen den Altstand schlagen diese Tests fehl - dort gab es
51 * weder das Feld `herkunft` noch `rechtsgrundlagenFuer`, noch die beiden
52 * Metafelder, noch die Abbiegerregel; die Umlaufzeitfundstelle lautete
53 * ausnahmslos "RiLSA 2015 - Umlaufzeit" und minGreenOepnv stand unter
54 * 'rilsa-signalzeiten'.
55 */
56
57 const DATUM = new Date('2026-01-01T00:00:00Z');
58
59 const ANLAGENARTEN: readonly Anlagenart[] = [
60 'knotenpunkt',
61 'fussgaengerschutzanlage',
62 'einstreifig',
63 ];
64
65 /**
66 * Einstreifige Verkehrsfuehrung mit fester Umlaufzeit. Die Engstelle ist der
67 * Raeumweg beider Richtungen; sie bestimmt die Zwischenzeit und damit, wie viel
68 * Freigabezeit vom festen Umlauf uebrig bleibt.
69 */
70 function engstelle(laenge: number, umlaufzeit: number): Project {
71 const basis = createEmptyProject('Arbeitsstelle', DATUM, 'einstreifig');
72 const a1 = createSignalGroup({ name: 'A1', mode: 'kfz', index: 0 });
73 const a2 = createSignalGroup({ name: 'A2', mode: 'kfz', index: 1 });
74 const phase1 = createPhase('Richtung 1', [a1.id]);
75 const phase2 = createPhase('Richtung 2', [a2.id]);
76 return {
77 ...basis,
78 signalGroups: [a1, a2],
79 conflicts: [
80 { ...createConflict(a1.id, a2.id), clearingDistance: laenge, enteringDistance: 0 },
81 { ...createConflict(a2.id, a1.id), clearingDistance: laenge, enteringDistance: 0 },
82 ],
83 phases: [phase1, phase2],
84 program: {
85 ...basis.program,
86 method: 'manuell',
87 manualCycleTime: umlaufzeit,
88 phaseOrder: [phase1.id, phase2.id],
89 },
90 };
91 }
92
93 /** Knotenpunkt mit fester Umlaufzeit ueber der RiLSA-Obergrenze. */
94 function knotenpunktMitUmlauf(umlaufzeit: number): Project {
95 const basis = createEmptyProject('Knotenpunkt', DATUM, 'knotenpunkt');
96 const k1 = createSignalGroup({ name: 'K1', mode: 'kfz', index: 0 });
97 const k2 = createSignalGroup({ name: 'K2', mode: 'kfz', index: 1 });
98 const phase1 = createPhase('Phase 1', [k1.id]);
99 const phase2 = createPhase('Phase 2', [k2.id]);
100 return {
101 ...basis,
102 signalGroups: [k1, k2],
103 conflicts: [
104 { ...createConflict(k1.id, k2.id), clearingDistance: 22, enteringDistance: 8 },
105 { ...createConflict(k2.id, k1.id), clearingDistance: 22, enteringDistance: 8 },
106 ],
107 phases: [phase1, phase2],
108 program: {
109 ...basis.program,
110 method: 'manuell',
111 manualCycleTime: umlaufzeit,
112 phaseOrder: [phase1.id, phase2.id],
113 },
114 };
115 }
116
117 describe('B8 - Herkunft je Schranke der Anlagenart', () => {
118 it('weist am Knotenpunkt nur den zulaessigen Umlaufzeitbereich als belegt aus', () => {
119 const herkunft = ANLAGENART_GRENZEN.knotenpunkt.herkunft;
120
121 expect(herkunft.umlaufzeit.pruefstand).toBe('gesichert');
122 expect(herkunft.umlaufzeit.text).toContain('RiLSA 2015');
123
124 /*
125 * BERICHTIGT (5.27.0): Der Regelbereich ist am Knotenpunkt vollstaendig
126 * belegt - RiLSA 2015, Abschnitt 2.6 nennt minimal 30 s und maximal
127 * 90 (120) s. Bis 5.26.0 stand hier die Erwartung, die Zeile weise die
128 * Untergrenze als Zahl dieses Programms aus; das war der Stand der Frage E4
129 * (Fassung 5.4.0) und ohne Original richtig. Gestrichen hat die RiLSA nicht
130 * die Untergrenze, sondern den frueher "normal" genannten Mittelbereich.
131 */
132 expect(herkunft.regelbereich.pruefstand).toBe('gesichert');
133 expect(herkunft.regelbereich.text).toContain('90 s');
134 expect(herkunft.regelbereich.text).toContain('30 s');
135 expect(herkunft.regelbereich.text).toContain('Abschnitt 2.6');
136 expect(herkunft.regelbereich.text).not.toContain('setzt dieses Programm');
137 });
138
139 it('nennt fuer keine Wartezeitschwelle ein Regelwerk als Fundstelle', () => {
140 // Frage E7 (Fassung 5.4.0): Bezifferte Wartezeitschranken sind in keinem
141 // Regelwerk belegt. Vor der Fassung 5.4.0 druckte der Ausdruck sie als
142 // "RiLSA 2015, gesichert" bzw. "RSA 21".
143 for (const art of ANLAGENARTEN) {
144 const wartezeit = ANLAGENART_GRENZEN[art].herkunft.wartezeit;
145 expect(wartezeit.pruefstand).not.toBe('gesichert');
146 expect(wartezeit.text).toContain('Schwelle dieses Programms');
147 expect(wartezeit.text).toContain('kein Regelwerkswert');
148 }
149 });
150
151 it('weist die Schranken der einstreifigen Verkehrsfuehrung als eigene Herleitung aus', () => {
152 const herkunft = ANLAGENART_GRENZEN.einstreifig.herkunft;
153
154 expect(herkunft.umlaufzeit.text).toContain('Schwelle dieses Programms');
155 expect(herkunft.umlaufzeit.text).toContain('kein RSA-Wert');
156 expect(herkunft.umlaufzeit.pruefstand).toBe('abzugleichen');
157 expect(herkunft.regelbereich.text).toContain('kein Regelwerkswert');
158 // Die RSA darf hier nicht als Herkunft eines Zahlenwerts erscheinen - sie
159 // wird nur genannt, um zu sagen, dass sie diese Zahl gerade NICHT enthaelt.
160 expect(herkunft.wartezeit.text).toContain('keine bezifferte Wartezeitschwelle');
161 });
162
163 it('gibt jeder Anlagenart eine Herkunft je Zeile der Schrankentabelle', () => {
164 for (const art of ANLAGENARTEN) {
165 const herkunft = ANLAGENART_GRENZEN[art].herkunft;
166 for (const zeile of [
167 herkunft.umlaufzeit,
168 herkunft.regelbereich,
169 herkunft.raeumweg,
170 herkunft.wartezeit,
171 ]) {
172 expect(zeile.text.length).toBeGreaterThan(20);
173 expect(['gesichert', 'abzugleichen', 'fachliteratur']).toContain(zeile.pruefstand);
174 }
175 // Der groesste eintragbare Weg ist eine Eingabegrenze, kein Kennwert.
176 expect(herkunft.raeumweg.text).toContain('Eingabegrenze dieses Programms');
177 }
178 });
179 });
180
181 describe('B9 - Fundstelle der Umlaufzeitregeln', () => {
182 it('nennt die RiLSA nur dort, wo die Schranke aus ihr stammt', () => {
183 expect(umlaufzeitFundstelle(ANLAGENART_GRENZEN.knotenpunkt)).toBe('RiLSA 2015 - Umlaufzeit');
184 expect(umlaufzeitFundstelle(ANLAGENART_GRENZEN.fussgaengerschutzanlage)).toBe(
185 'RiLSA 2015 - Umlaufzeit',
186 );
187
188 const einstreifig = umlaufzeitFundstelle(ANLAGENART_GRENZEN.einstreifig);
189 expect(einstreifig).not.toContain('RiLSA 2015 - Umlaufzeit');
190 expect(einstreifig).toContain('Schwelle dieses Programms');
191 expect(einstreifig).toContain('abzugleichen');
192 });
193
194 it('gibt einer Baustellenampel mit 250 s Umlauf keine RiLSA-Fundstelle', () => {
195 const bericht = validateProject(engstelle(600, 250), undefined, DATUM);
196 const umlaufmeldungen = bericht.findings.filter((f) =>
197 f.rule.startsWith('signalplan.umlaufzeit'),
198 );
199
200 // 250 s liegen ueber dem Regelbereich (60-180 s) dieser Anlagenart.
201 expect(umlaufmeldungen.length).toBeGreaterThan(0);
202 for (const meldung of umlaufmeldungen) {
203 expect(meldung.reference).not.toContain('RiLSA 2015 - Umlaufzeit');
204 expect(meldung.reference).toContain('Schwelle dieses Programms');
205 }
206 });
207
208 it('haelt an der Fundstelle fest, wo die RiLSA die Schranke wirklich nennt', () => {
209 // 150 s am Knotenpunkt: die RiLSA-Obergrenze von 120 s ist ueberschritten,
210 // und genau diese Zahl steht in der RiLSA.
211 const bericht = validateProject(knotenpunktMitUmlauf(150), undefined, DATUM);
212 const zuLang = bericht.findings.find((f) => f.rule === 'signalplan.umlaufzeit-zu-lang');
213
214 expect(zuLang?.reference).toBe('RiLSA 2015 - Umlaufzeit');
215 });
216
217 /*
218 * Der empfohlene Bereich ist eine eigene Schranke. Weil sein Meldungscode mit
219 * "umlaufzeit" beginnt, erbte er die Fundstelle des ZULAESSIGEN Bereichs und
220 * berief sich damit auf die RiLSA - auch an der Fussgaengerschutzanlage, deren
221 * Regelbereich 40-70 s dieses Programm gesetzt hat, und am Knotenpunkt, wo von
222 * 60-90 s nur die Obergrenze belegt ist (Fassung 5.4.0, Frage E4).
223 */
224 it('gibt dem empfohlenen Bereich die Fundstelle seiner eigenen Zeile', () => {
225 for (const art of ANLAGENARTEN) {
226 const grenzen = ANLAGENART_GRENZEN[art];
227 expect(regelbereichFundstelle(grenzen)).toBe(grenzen.herkunft.regelbereich.text);
228 // Keine Anlagenart darf hier die pauschale Fundstelle tragen.
229 expect(regelbereichFundstelle(grenzen)).not.toContain('RiLSA 2015 - Umlaufzeit');
230 }
231
232 // Die Fussgaengerschutzanlage ist der schaerfste Fall: Ihr zulaessiger
233 // Bereich stammt aus der RiLSA, ihr Regelbereich nicht.
234 const furt = ANLAGENART_GRENZEN.fussgaengerschutzanlage;
235 expect(umlaufzeitFundstelle(furt)).toBe('RiLSA 2015 - Umlaufzeit');
236 expect(regelbereichFundstelle(furt)).toContain('Schwelle dieses Programms');
237 });
238
239 it('nennt bei 100 s am Knotenpunkt die Fundstelle des Regelbereichs', () => {
240 // 100 s liegen ueber dem Regelbereich (30-90 s), aber unter der belegten
241 // Obergrenze von 120 s: Es meldet allein der Regelbereich.
242 const bericht = validateProject(knotenpunktMitUmlauf(100), undefined, DATUM);
243 const befund = bericht.findings.find(
244 (f) => f.rule === 'signalplan.umlaufzeit-ausserhalb-regelbereich',
245 );
246
247 expect(befund).toBeDefined();
248 expect(befund?.reference).toBe(ANLAGENART_GRENZEN.knotenpunkt.herkunft.regelbereich.text);
249 expect(befund?.reference).toContain('Abschnitt 2.6');
250 });
251
252 it('fuehrt die Wartezeitschwelle mit derselben Herkunft wie die Schrankentabelle', () => {
253 // 600 m Engstelle bei 250 s Umlauf: rund 64 s Zwischenzeit je Richtung,
254 // damit rund 189 s Wartezeit vor Rot - ueber der Warnschwelle von 180 s.
255 const bericht = validateProject(engstelle(600, 250), undefined, DATUM);
256 const wartezeit = bericht.findings.find((f) => f.rule.startsWith('signalplan.wartezeit'));
257
258 expect(wartezeit).toBeDefined();
259 expect(wartezeit?.reference).toBe(ANLAGENART_GRENZEN.einstreifig.herkunft.wartezeit.text);
260 });
261 });
262
263 describe('C13 - Rechtsgrundlagen je Anlagenart', () => {
264 it('fuehrt am Knotenpunkt nur den Grundstock', () => {
265 const grundlagen = rechtsgrundlagenFuer('knotenpunkt');
266 const bezeichnungen = grundlagen.map((g) => g.bezeichnung).join(' | ');
267
268 expect(bezeichnungen).toContain('§ 37 StVO');
269 expect(bezeichnungen).toContain('§ 45 StVO');
270 expect(bezeichnungen).toContain('VwV-StVO');
271 expect(bezeichnungen).toContain('RiLSA 2015');
272 expect(bezeichnungen).toContain('HBS 2015');
273 expect(bezeichnungen).not.toContain('RSA 21');
274 expect(bezeichnungen).not.toContain('transportable');
275 expect(grundlagen).toEqual(RECHTSGRUNDLAGEN);
276 });
277
278 it('ergaenzt bei einstreifiger Verkehrsfuehrung RSA 21, TL und ZTV', () => {
279 const bezeichnungen = rechtsgrundlagenFuer('einstreifig').map((g) => g.bezeichnung);
280
281 expect(bezeichnungen.some((b) => b.includes('RSA 21'))).toBe(true);
282 expect(bezeichnungen.some((b) => b.startsWith('TL transportable'))).toBe(true);
283 expect(bezeichnungen.some((b) => b.startsWith('ZTV transportable'))).toBe(true);
284 // Der Grundstock bleibt vollstaendig und steht voran.
285 expect(bezeichnungen.slice(0, RECHTSGRUNDLAGEN.length)).toEqual(
286 RECHTSGRUNDLAGEN.map((g) => g.bezeichnung),
287 );
288 });
289
290 it('gibt der Fussgaengerschutzanlage keine Arbeitsstellen-Regelwerke', () => {
291 expect(rechtsgrundlagenFuer('fussgaengerschutzanlage')).toEqual(RECHTSGRUNDLAGEN);
292 });
293
294 it('nennt bei den neuen Eintraegen Herausgeber, Einfuehrung und Pruefstand', () => {
295 const alle = RECHTSGRUNDLAGEN_EINSTREIFIG.map(
296 (g) => `${g.bezeichnung} ${g.inhalt} ${g.hinweis ?? ''}`,
297 );
298 const zusammen = alle.join(' | ');
299
300 expect(zusammen).toContain('ARS Nr. 07/2024');
301 expect(zusammen).toContain('Typ D');
302 expect(zusammen).toContain('Teil A 3.2');
303 expect(zusammen).toContain('FGSV-Nr. 370');
304 for (const grundlage of RECHTSGRUNDLAGEN_EINSTREIFIG) {
305 expect(grundlage.inhalt.length).toBeGreaterThan(40);
306 // Wortlaut und Nummern sind nicht am Original geprueft - das steht dran.
307 expect(grundlage.pruefstand).toBe('abzugleichen');
308 }
309 });
310 });
311
312 describe('C17 - VwV-StVO als eigene Vorgabe, nicht als blosser Verweis', () => {
313 it('nennt Gelbzeiten, Rot-Gelb-Zeit und die 70-km/h-Grenze', () => {
314 const vwv = RECHTSGRUNDLAGEN.find((g) => g.bezeichnung.includes('VwV-StVO'));
315
316 expect(vwv).toBeDefined();
317 expect(vwv?.inhalt).toContain('3/4/5 s');
318 expect(vwv?.inhalt).toContain('Rn. 17');
319 expect(vwv?.inhalt).toContain('Rn. 10');
320 expect(vwv?.inhalt).toContain('2 s');
321 // Der Verweis auf die FGSV-Regelwerke bleibt - er ist ja auch richtig.
322 expect(vwv?.inhalt).toContain('FGSV');
323 // Die Neufassung 2025 steht im Vorbehalt, nicht im Inhalt.
324 expect(vwv?.hinweis).toContain('2025');
325 });
326 });
327
328 describe('C14 - Einsatzzeiten und Betriebsverantwortlicher', () => {
329 it('legt beide Felder leer an, ohne die Schemaversion zu erhoehen', () => {
330 const projekt = createEmptyProject('Neu', DATUM);
331
332 expect(projekt.meta.einsatzzeiten).toBe('');
333 expect(projekt.meta.betriebsverantwortlicher).toBe('');
334 expect(projekt.schemaVersion).toBe(CURRENT_SCHEMA_VERSION);
335 });
336
337 it('liest eine Datei ohne die beiden Felder unveraendert und ohne Meldung ein', () => {
338 const alt = createEmptyProject('Alt', DATUM);
339 const rohdaten = JSON.parse(JSON.stringify(alt)) as Record<string, unknown>;
340 const meta = rohdaten['meta'] as Record<string, unknown>;
341 delete meta['einsatzzeiten'];
342 delete meta['betriebsverantwortlicher'];
343
344 const ergebnis = parseProject(rohdaten, DATUM);
345
346 expect(ergebnis.project.meta.einsatzzeiten).toBe('');
347 expect(ergebnis.project.meta.betriebsverantwortlicher).toBe('');
348 expect(ergebnis.repaired).toBe(false);
349 expect(ergebnis.issues).toEqual([]);
350 });
351
352 it('behaelt eingetragene Werte ueber Speichern und Laden', () => {
353 const projekt: Project = {
354 ...createEmptyProject('Mit Angaben', DATUM),
355 meta: {
356 ...createEmptyProject('Mit Angaben', DATUM).meta,
357 einsatzzeiten: '12.05.-30.06.2027, ganztägig',
358 betriebsverantwortlicher: 'Mustermann Bau GmbH, Herr Meier, 0170 1234567',
359 },
360 };
361
362 const gelesen = parseProject(JSON.parse(JSON.stringify(projekt)), DATUM).project;
363
364 expect(gelesen.meta.einsatzzeiten).toBe('12.05.-30.06.2027, ganztägig');
365 expect(gelesen.meta.betriebsverantwortlicher).toContain('0170 1234567');
366 });
367
368 it('meldet die Luecke bei einstreifiger Verkehrsfuehrung als Warnung', () => {
369 const bericht = validateProject(engstelle(200, 120), undefined, DATUM);
370 const einsatz = bericht.findings.find((f) => f.rule === 'projektdaten.einsatzzeiten');
371 const betrieb = bericht.findings.find(
372 (f) => f.rule === 'projektdaten.betriebsverantwortlicher',
373 );
374
375 expect(einsatz?.severity).toBe('warnung');
376 expect(betrieb?.severity).toBe('warnung');
377 expect(einsatz?.reference).toContain('RSA 21');
378 expect(betrieb?.reference).toContain('RSA 21');
379 // Eine fehlende Angabe der Anordnung sperrt die Ausgabe nicht - der
380 // Signalzeitenplan selbst ist deshalb nicht falsch.
381 expect(
382 bericht.findings.some(
383 (f) => f.rule === 'projektdaten.einsatzzeiten' && f.severity === 'fehler',
384 ),
385 ).toBe(false);
386 });
387
388 it('meldet die Luecke am Knotenpunkt als Hinweis ohne Regelwerksbehauptung', () => {
389 const bericht = validateProject(knotenpunktMitUmlauf(80), undefined, DATUM);
390 const einsatz = bericht.findings.find((f) => f.rule === 'projektdaten.einsatzzeiten');
391
392 expect(einsatz?.severity).toBe('hinweis');
393 expect(einsatz?.reference).toContain('§ 45 StVO');
394 expect(einsatz?.reference).not.toContain('RSA 21');
395 });
396
397 it('schweigt, sobald beide Angaben eingetragen sind', () => {
398 const basis = engstelle(200, 120);
399 const projekt: Project = {
400 ...basis,
401 meta: {
402 ...basis.meta,
403 einsatzzeiten: '12.05.-30.06.2027, ganztägig',
404 betriebsverantwortlicher: 'Mustermann Bau GmbH, 0170 1234567',
405 },
406 };
407 const bericht = validateProject(projekt, undefined, DATUM);
408
409 expect(bericht.findings.some((f) => f.rule === 'projektdaten.einsatzzeiten')).toBe(false);
410 expect(bericht.findings.some((f) => f.rule === 'projektdaten.betriebsverantwortlicher')).toBe(
411 false,
412 );
413 });
414 });
415
416 describe('C18 - pauschaler Abbiegerfaktor an bedingt vertraeglichen Linksabbiegern', () => {
417 /** Zwei Zufahrten, deren Kfz-Gruppen in derselben Phase Freigabe erhalten. */
418 function linksabbieger(
419 optionen: {
420 readonly gleicherArm?: boolean;
421 readonly ohneArme?: boolean;
422 readonly verkehrsstaerke?: number;
423 readonly movement?: SignalGroup['movement'];
424 readonly getrenntePhasen?: boolean;
425 } = {},
426 ): Project {
427 const basis = createEmptyProject('Knotenpunkt', DATUM, 'knotenpunkt');
428 const nord = createArm('Nord', 'Nord', 2, 50);
429 const sued = createArm('Süd', 'Süd', 2, 50);
430 const armLinks = optionen.ohneArme === true ? null : nord.id;
431 const armGegen =
432 optionen.ohneArme === true ? null : optionen.gleicherArm === true ? nord.id : sued.id;
433
434 const links: SignalGroup = {
435 ...createSignalGroup({ name: 'K1', mode: 'kfz', armId: armLinks, index: 0 }),
436 movement: optionen.movement ?? 'links',
437 };
438 const gegen = createSignalGroup({ name: 'K2', mode: 'kfz', armId: armGegen, index: 1 });
439
440 const phasen =
441 optionen.getrenntePhasen === true
442 ? [createPhase('Phase 1', [links.id]), createPhase('Phase 2', [gegen.id])]
443 : [createPhase('Phase 1 - Nord/Süd', [links.id, gegen.id])];
444
445 return {
446 ...basis,
447 intersection: { ...basis.intersection, arms: [nord, sued] },
448 signalGroups: [links, gegen],
449 conflicts: [],
450 phases: phasen,
451 program: { ...basis.program, phaseOrder: phasen.map((p) => p.id) },
452 demands: [
453 {
454 signalGroupId: links.id,
455 volume: optionen.verkehrsstaerke ?? 240,
456 heavyVehicleShare: 0.05,
457 },
458 { signalGroupId: gegen.id, volume: 600, heavyVehicleShare: 0.05 },
459 ],
460 };
461 }
462
463 it('meldet Verkehrsstaerke, Phase und Faktor', () => {
464 const befunde = abbiegerfaktorBefunde(linksabbieger());
465
466 expect(befunde).toHaveLength(1);
467 const befund = befunde[0]!;
468 expect(befund.rule).toBe('leistungsfaehigkeit.abbiegerfaktor-pauschal');
469 expect(befund.severity).toBe('hinweis');
470 expect(befund.category).toBe('leistungsfaehigkeit');
471 expect(befund.message).toContain('240 Kfz/h');
472 expect(befund.message).toContain('Phase 1 - Nord/Süd');
473 expect(befund.message).toContain('"K2"');
474 expect(befund.message).toContain('0,9');
475 expect(befund.reference).toContain('hbs-abminderung');
476 expect(befund.suggestion).toContain('HBS 2015');
477 expect(befund.target).toMatchObject({ kind: 'signalgruppe', label: 'K1' });
478 });
479
480 it('schweigt bei gleicher Zufahrt, ohne Verkehrsstaerke, ohne Linksabbieger und bei getrennten Phasen', () => {
481 expect(abbiegerfaktorBefunde(linksabbieger({ gleicherArm: true }))).toEqual([]);
482 expect(abbiegerfaktorBefunde(linksabbieger({ verkehrsstaerke: 0 }))).toEqual([]);
483 expect(abbiegerfaktorBefunde(linksabbieger({ movement: 'geradeaus' }))).toEqual([]);
484 expect(abbiegerfaktorBefunde(linksabbieger({ getrenntePhasen: true }))).toEqual([]);
485 });
486
487 it('sagt es, wenn die Zufahrten nicht erfasst sind, statt zu schweigen', () => {
488 const befunde = abbiegerfaktorBefunde(linksabbieger({ ohneArme: true }));
489
490 expect(befunde).toHaveLength(1);
491 expect(befunde[0]?.message).toContain('Zufahrten sind nicht erfasst');
492 });
493
494 it('schweigt, wenn gar nicht abgemindert wird', () => {
495 const ohneAbminderung = {
496 ...RILSA_DEFAULTS,
497 capacity: { ...RILSA_DEFAULTS.capacity, turningFactor: 1 },
498 };
499
500 expect(abbiegerfaktorBefunde(linksabbieger(), ohneAbminderung)).toEqual([]);
501 });
502
503 it('rechnet mit dem Faktor des Plans, nicht mit dem Regelwert', () => {
504 const projekt = linksabbieger();
505 const mitVorgabe: Project = {
506 ...projekt,
507 settings: { ...projekt.settings, rilsa: { ...projekt.settings.rilsa, turningFactor: 0.75 } },
508 };
509 const plan = buildSignalPlan(mitVorgabe);
510 const befunde = abbiegerfaktorBefunde(mitVorgabe, plan.defaults);
511
512 expect(befunde[0]?.message).toContain('0,75');
513 expect(befunde[0]?.title).toContain('0,75');
514 });
515
516 it('steht im Pruefbericht', () => {
517 const bericht = validateProject(linksabbieger(), undefined, DATUM);
518
519 expect(
520 bericht.findings.some((f) => f.rule === 'leistungsfaehigkeit.abbiegerfaktor-pauschal'),
521 ).toBe(true);
522 });
523 });
524
525 describe('C19 - OePNV-Mindestfreigabezeit ist ein Praxiswert', () => {
526 it('fuehrt das Vorgabenfeld unter der Planungspraxis', () => {
527 const feld = settingFields().find((f) => f.key === 'minGreenOepnv');
528
529 expect(feld?.quellenSchluessel).toBe('praxis-mindestfreigabezeit-oepnv');
530 expect(feld?.quelle.regelwerk).toBe('Planungspraxis');
531 // Der Kern von C19: keine Fundstelle des Regelwerks, kein Pruefstand
532 // "gesichert".
533 expect(feld?.quellenSchluessel.startsWith('rilsa-')).toBe(false);
534 expect(feld?.quelle.pruefstand).not.toBe('gesichert');
535 });
536
537 it('laesst die belegten Mindestfreigabezeiten unter der RiLSA', () => {
538 const felder = settingFields();
539 for (const key of ['minGreenKfz', 'minGreenRad', 'minGreenFuss'] as const) {
540 expect(felder.find((f) => f.key === key)?.quellenSchluessel).toBe('rilsa-signalzeiten');
541 }
542 // Der gerechnete Wert selbst bleibt unveraendert - nur seine Herkunft war
543 // falsch ausgewiesen.
544 expect(RILSA_DEFAULTS.minGreen.oepnv).toBe(3);
545 });
546 });