lsa-planer

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

/ tests domain stilleErsetzungen.test.ts

22,1 KB Rohdatei
tests/domain/stilleErsetzungen.test.ts — 500 Zeilen
1 import { describe, expect, it } from 'vitest';
2 import {
3 UMLAUFZEIT_ERSATZWERT_VERMERK,
4 buildSignalPlan,
5 mindestfreigabezeitDerGruppe,
6 umlaufzeitIstErsatzwert,
7 type PlanNote,
8 } from '@/domain/plan/signalPlan';
9 import { validateProject } from '@/domain/validation/engine';
10 import { computeIntergreen } from '@/domain/rilsa/intergreen';
11 import { compareCycleTimeMethods, computeCycleTime } from '@/domain/rilsa/cycle';
12 import { resolveRilsaSettings, settingFields } from '@/domain/rilsa/settings';
13 import {
14 createConflict,
15 createEmptyProject,
16 createPhase,
17 createSignalGroup,
18 createStandardIntersectionProject,
19 } from '@/domain/model/factory';
20 import { parseProject } from '@/domain/model/schema';
21 import type { Anlagenart, Project, SignalGroup } from '@/domain/model/project';
22 import type { ClearingStream, EnteringStream, VehicleClass } from '@/domain/rilsa/types';
23 import { ANLAGENART_GRENZEN, RILSA_DEFAULTS, SAFETY_FLOORS } from '@/domain/rilsa/constants';
24
25 /*
26 * Exportsperre und stille Ersetzungen - die Fachkern-Befunde C8 bis C11:
27 *
28 * C8 Unbekannte Fahrzeugart setzte stumm 0 m Laengenzuschlag an und
29 * verkuerzte die Zwischenzeit. Jetzt Fehler-Notiz und 6 m (RiLSA-
30 * Rechenlaenge Kfz); Fussgaenger 0 m ohne Notiz.
31 * C9 Eine Mindestfreigabezeit-Vorgabe unter dem Regelwert wurde still auf
32 * den Regelwert gehoben; der Wert blieb im Feld. Jetzt Warnung
33 * "eingetragen 3 s, massgebend bleibt 5 s".
34 * C10 resolveRilsaSettings wertete grenzeVonArt nie aus: Umlaufzeit-Vorgaben
35 * passierten ungeprueft. Jetzt Schranken der Anlagenart (120 s
36 * Knotenpunkt, 300 s einstreifig) - und ALLE Aufrufer reichen sie durch.
37 * C11 Uebersaettigung lieferte bounded 'maximum' und 120 s wie ein
38 * Ergebnis. Jetzt bounded 'uebersaettigt', der Plan traegt den Zustand.
39 *
40 * Mutationsnachweis: Die Kernfaelle unten schlagen gegen den Altstand fehl
41 * (die Notiz gab es nicht, der Wert war 0 m, die Vorgabe wurde nicht gekappt
42 * bzw. am falschen Ort gekappt, bounded war 'maximum'). Einige Faelle sind
43 * bewusst Waechter, die auch am Altstand bestehen - sie halten fest, was sich
44 * NICHT aendern darf: Fussgaenger bleiben bei 0 m, bekannte Fahrzeugarten
45 * schweigen, das Schema bleibt der erste Riegel, eine Vorgabe in Hoehe des
46 * Regelwerts schweigt, 250 s bleiben bei einstreifiger Fuehrung ungekappt,
47 * 'maximum' bleibt dem rechenbaren Umlauf. (Vor der Fassung 5.4.0 behauptete
48 * dieser Kommentar "jeder Fall".)
49 */
50
51 const DATUM = new Date('2026-01-01T00:00:00Z');
52
53 /** Ein raeumender Kfz-Strom, wie in tests/domain/intergreen.test.ts. */
54 const kfzClearing = (overrides: Partial<ClearingStream> = {}): ClearingStream => ({
55 mode: 'kfz',
56 movement: 'geradeaus',
57 clearingDistance: 22,
58 vehicleClass: 'pkw',
59 vZul: 50,
60 ...overrides,
61 });
62
63 const kfzEntering = (overrides: Partial<EnteringStream> = {}): EnteringStream => ({
64 mode: 'kfz',
65 movement: 'geradeaus',
66 enteringDistance: 8,
67 ...overrides,
68 });
69
70 /** Eine Fahrzeugart, die nur aus einer fremden oder alten Datei stammen kann. */
71 const UNBEKANNT = 'traktor' as VehicleClass;
72
73 /** Zwei Kfz-Gruppen in zwei Phasen; die zweite Gruppe laesst sich anpassen. */
74 function zweiGruppen(
75 zweite: Partial<SignalGroup> = {},
76 anlagenart: Anlagenart = 'knotenpunkt',
77 rilsa: Project['settings']['rilsa'] = {},
78 ): Project {
79 const basis = createEmptyProject('Zwei Gruppen', DATUM, anlagenart);
80 const a = createSignalGroup({ name: 'K1', mode: 'kfz', index: 0 });
81 const b: SignalGroup = { ...createSignalGroup({ name: 'K2', mode: 'kfz', index: 1 }), ...zweite };
82 const phase1 = createPhase('Phase 1', [a.id]);
83 const phase2 = createPhase('Phase 2', [b.id]);
84 return {
85 ...basis,
86 signalGroups: [a, b],
87 conflicts: [
88 { ...createConflict(a.id, b.id), clearingDistance: 22, enteringDistance: 8 },
89 { ...createConflict(b.id, a.id), clearingDistance: 22, enteringDistance: 8 },
90 ],
91 phases: [phase1, phase2],
92 program: { ...basis.program, phaseOrder: [phase1.id, phase2.id] },
93 settings: { ...basis.settings, rilsa },
94 };
95 }
96
97 /** Kfz-Strom und Furt (14 m Furtlaenge -> Furtbedarf 6 s), Furt anpassbar. */
98 function furtProjekt(furt: Partial<SignalGroup> = {}): Project {
99 const basis = createEmptyProject('Furt', DATUM);
100 const k = createSignalGroup({ name: 'K1', mode: 'kfz', index: 0 });
101 const f: SignalGroup = { ...createSignalGroup({ name: 'F1', mode: 'fuss', index: 1 }), ...furt };
102 const phaseK = createPhase('Kfz', [k.id]);
103 const phaseF = createPhase('Fuss', [f.id]);
104 return {
105 ...basis,
106 signalGroups: [k, f],
107 conflicts: [
108 { ...createConflict(f.id, k.id), clearingDistance: 14, enteringDistance: 3 },
109 { ...createConflict(k.id, f.id), clearingDistance: 15, enteringDistance: 5 },
110 ],
111 phases: [phaseK, phaseF],
112 program: { ...basis.program, phaseOrder: [phaseK.id, phaseF.id] },
113 };
114 }
115
116 /** Der Musterknotenpunkt mit Verkehrsstaerken weit ueber der Kapazitaet. */
117 function uebersaettigt(anlagenart: Anlagenart = 'knotenpunkt'): Project {
118 const roh = createStandardIntersectionProject('Uebersaettigt', DATUM);
119 return {
120 ...roh,
121 anlagenart,
122 demands: roh.signalGroups
123 .filter((g) => g.mode === 'kfz')
124 .map((g) => ({ signalGroupId: g.id, volume: 1800, heavyVehicleShare: 0.1 })),
125 };
126 }
127
128 function notizen(project: Project, code: string): PlanNote[] {
129 return buildSignalPlan(project).notes.filter((n) => n.code === code);
130 }
131
132 // --- C8 ----------------------------------------------------------------------
133
134 describe('C8: unbekannte Fahrzeugart - kein stiller Rueckfall auf 0 m', () => {
135 it('setzt die RiLSA-Rechenlaenge 6 m an und meldet einen Fehler', () => {
136 const result = computeIntergreen(kfzClearing({ vehicleClass: UNBEKANNT }), kfzEntering());
137 expect(result.vehicleLength).toBe(SAFETY_FLOORS.minVehicleLengthKfz);
138 expect(result.vehicleLength).toBe(6);
139 expect(result.clearingPath).toBe(28);
140 const notiz = result.notes.find((n) => n.code === 'fahrzeugart-unbekannt');
141 expect(notiz).toBeDefined();
142 expect(notiz!.severity).toBe('fehler');
143 expect(notiz!.message).toContain('traktor');
144 expect(notiz!.message).toContain('unbekannt');
145 expect(notiz!.message).toContain('6 m');
146 });
147
148 it('rechnet damit dieselbe Zwischenzeit wie ein Pkw - nicht eine Sekunde kuerzer', () => {
149 // Altstand: 0 m -> sr = 22 m -> tr = 2,2 s -> 3 + 2,2 - 0,72 = 4,48 s -> 5 s.
150 // Jetzt: 6 m -> sr = 28 m -> tr = 2,8 s -> 5,08 s -> 6 s, wie beim Pkw.
151 const pkw = computeIntergreen(kfzClearing(), kfzEntering());
152 const unbekannt = computeIntergreen(kfzClearing({ vehicleClass: UNBEKANNT }), kfzEntering());
153 expect(pkw.intergreen).toBe(6);
154 expect(unbekannt.intergreen).toBe(6);
155 expect(unbekannt.clearingTime).toBeCloseTo(pkw.clearingTime, 6);
156 });
157
158 it('laesst Fussgaenger bei 0 m - dort gibt es keinen Laengenzuschlag, also auch keine Notiz', () => {
159 const result = computeIntergreen(
160 {
161 mode: 'fuss',
162 movement: 'querung',
163 clearingDistance: 12,
164 vehicleClass: UNBEKANNT,
165 vZul: 0,
166 },
167 kfzEntering(),
168 );
169 expect(result.vehicleLength).toBe(0);
170 expect(result.notes.some((n) => n.code === 'fahrzeugart-unbekannt')).toBe(false);
171 });
172
173 it('meldet bekannte Fahrzeugarten nicht', () => {
174 for (const art of ['pkw', 'lkw', 'bus', 'lastzug', 'strassenbahn', 'rad', 'keine'] as const) {
175 const result = computeIntergreen(kfzClearing({ vehicleClass: art }), kfzEntering());
176 expect(
177 result.notes.some((n) => n.code === 'fahrzeugart-unbekannt'),
178 art,
179 ).toBe(false);
180 /*
181 * NACHGEZOGEN (Fassung 5.9.0): Hier stand
182 * `toBe(RILSA_DEFAULTS.vehicleLength[art])` fuer ALLE sieben Arten - auch
183 * fuer "Fahrrad" und "ohne Laengenzuschlag", die mit 0 m im Kennwertsatz
184 * stehen. Damit schrieb diese Zeile am KFZ-Strom (kfzClearing) genau das
185 * Verhalten fest, das seit der Fassung 5.9.0 als Mangel gilt: Der
186 * Raeumweg blieb ohne Fahrzeuglaenge, und die Zwischenzeit fiel um rund
187 * eine Sekunde kuerzer aus, waehrend die Vorgabenverwaltung an jedem
188 * Kfz-Laengenfeld SAFETY_FLOORS.minVehicleLengthKfz erzwingt. Der
189 * Gegenstand DIESES Falles ist die Zeile darueber - die unbekannte Art -,
190 * und die bleibt unveraendert: Die neue Meldung heisst
191 * 'fahrzeugart-laengenzuschlag-zu-kurz' und ist eine eigene.
192 */
193 const erwartet = Math.max(
194 RILSA_DEFAULTS.vehicleLength[art],
195 SAFETY_FLOORS.minVehicleLengthKfz,
196 );
197 expect(result.vehicleLength, art).toBe(erwartet);
198 }
199 });
200
201 it('traegt die Meldung mit Konfliktbeziehung in den Plan und als Fehler in den Bericht', () => {
202 const projekt = zweiGruppen({ vehicleClass: UNBEKANNT });
203 const [notiz] = notizen(projekt, 'fahrzeugart-unbekannt');
204 expect(notiz).toBeDefined();
205 expect(notiz!.severity).toBe('fehler');
206 expect(notiz!.konfliktId).toBeDefined();
207 expect(notiz!.message).toContain('Zwischenzeit K2 nach K1');
208
209 const bericht = validateProject(projekt, undefined, DATUM);
210 const befund = bericht.findings.find((f) => f.rule === 'signalplan.fahrzeugart-unbekannt');
211 expect(befund).toBeDefined();
212 expect(befund!.severity).toBe('fehler');
213 expect(befund!.category).toBe('zwischenzeiten');
214 expect(befund!.target).toMatchObject({ kind: 'konflikt' });
215 expect(befund!.title).toContain('Fahrzeugart unbekannt');
216 expect(befund!.title).toContain('6 m');
217 expect(befund!.reference).toContain('Zwischenzeiten');
218 expect(befund!.suggestion).toContain('Fahrzeugart');
219 expect(bericht.exportBlocked).toBe(true);
220 });
221
222 it('ist der zweite Riegel: das Schema setzt Unbekanntes beim Einlesen auf Pkw und meldet es', () => {
223 const roh = JSON.parse(JSON.stringify(zweiGruppen({ vehicleClass: UNBEKANNT }))) as Record<
224 string,
225 unknown
226 >;
227 const { project, issues } = parseProject(roh);
228 const k2 = project.signalGroups.find((g) => g.name === 'K2')!;
229 expect(k2.vehicleClass).toBe('pkw');
230 expect(
231 issues.some((i) => i.path.endsWith('vehicleClass') && i.message.includes('traktor')),
232 ).toBe(true);
233 // Und mit dem reparierten Projekt schweigt die Zwischenzeitrechnung.
234 expect(notizen(project, 'fahrzeugart-unbekannt')).toHaveLength(0);
235 });
236 });
237
238 // --- C9 ----------------------------------------------------------------------
239
240 describe('C9: Mindestfreigabezeit-Vorgabe unter dem Regelwert wird gemeldet', () => {
241 it('meldet "eingetragen 3 s, massgebend bleibt 5 s" als Warnung an der Gruppe', () => {
242 const projekt = zweiGruppen({ minGreenOverride: 3 });
243 const k2 = projekt.signalGroups.find((g) => g.name === 'K2')!;
244 const mfz = mindestfreigabezeitDerGruppe(projekt, k2, RILSA_DEFAULTS);
245 expect(mfz.vorgabe).toBe(3);
246 expect(mfz.wert).toBe(5);
247 expect(mfz.massgebend).toBe('regelwert');
248
249 const plan = buildSignalPlan(projekt);
250 const geplant = plan.groups.find((g) => g.groupId === k2.id)!;
251 expect(geplant.times.minGreen).toBe(5);
252
253 const [notiz] = plan.notes.filter(
254 (n) => n.code === 'mindestfreigabezeit-vorgabe-unterschritten',
255 );
256 expect(notiz).toBeDefined();
257 expect(notiz!.severity).toBe('warnung');
258 expect(notiz!.signalGroupId).toBe(k2.id);
259 expect(notiz!.message).toContain('"K2"');
260 expect(notiz!.message).toContain('3 s eingetragen');
261 expect(notiz!.message).toContain('Regelwert der Verkehrsart von 5 s');
262 expect(notiz!.message).toContain('Maßgebend bleibt 5 s');
263 expect(notiz!.message).toContain('keine Wirkung');
264 });
265
266 it('steht im Pruefbericht unter Signalgruppen und zeigt auf die Gruppe', () => {
267 const projekt = zweiGruppen({ minGreenOverride: 3 });
268 const bericht = validateProject(projekt, undefined, DATUM);
269 const befund = bericht.findings.find(
270 (f) => f.rule === 'signalplan.mindestfreigabezeit-vorgabe-unterschritten',
271 );
272 expect(befund).toBeDefined();
273 expect(befund!.severity).toBe('warnung');
274 expect(befund!.category).toBe('signalgruppen');
275 expect(befund!.target).toMatchObject({ kind: 'signalgruppe', label: 'K2' });
276 expect(befund!.title).toContain('Vorgabe unter der Mindestfreigabezeit');
277 expect(befund!.reference).toContain('Freigabezeiten');
278 expect(befund!.suggestion).toContain('Vorgabe');
279 // Eine Warnung, kein Fehler: das Rechenergebnis ist richtig.
280 expect(bericht.exportBlocked).toBe(false);
281 });
282
283 it('schweigt bei einer Vorgabe in Hoehe des Regelwerts oder darueber', () => {
284 expect(
285 notizen(zweiGruppen({ minGreenOverride: 5 }), 'mindestfreigabezeit-vorgabe-unterschritten'),
286 ).toHaveLength(0);
287 expect(
288 notizen(zweiGruppen({ minGreenOverride: 8 }), 'mindestfreigabezeit-vorgabe-unterschritten'),
289 ).toHaveLength(0);
290 expect(notizen(zweiGruppen(), 'mindestfreigabezeit-vorgabe-unterschritten')).toHaveLength(0);
291 });
292
293 it('meldet auch eine Vorgabe ueber dem Regelwert, aber unter dem Furtbedarf', () => {
294 // Furt 14 m: halbe Furt 7 m / 1,2 m/s = 5,83 s -> 6 s. Vorgabe 5,5 s liegt
295 // ueber dem Regelwert (5 s), aber unter dem Furtbedarf - ebenso wirkungslos.
296 const projekt = furtProjekt({ minGreenOverride: 5.5 });
297 const f1 = projekt.signalGroups.find((g) => g.mode === 'fuss')!;
298 const mfz = mindestfreigabezeitDerGruppe(projekt, f1, RILSA_DEFAULTS);
299 expect(mfz.wert).toBe(6);
300 expect(mfz.massgebend).toBe('furt');
301 const [notiz] = notizen(projekt, 'mindestfreigabezeit-vorgabe-unterschritten');
302 expect(notiz).toBeDefined();
303 expect(notiz!.signalGroupId).toBe(f1.id);
304 expect(notiz!.message).toContain('5,5 s eingetragen');
305 expect(notiz!.message).toContain('Furtbedarf von 6 s');
306 expect(notiz!.message).toContain('Maßgebend bleibt 6 s');
307 });
308 });
309
310 // --- C10 ---------------------------------------------------------------------
311
312 describe('C10: Umlaufzeit-Vorgaben gegen die Schranken der Anlagenart', () => {
313 it('kappt 250 s am Knotenpunkt auf 120 s und meldet einen Fehler', () => {
314 const { defaults, notes } = resolveRilsaSettings(
315 { cycleTimeMax: 250 },
316 undefined,
317 'knotenpunkt',
318 );
319 expect(defaults.cycleTime.max).toBe(ANLAGENART_GRENZEN.knotenpunkt.cycleTime.max);
320 expect(defaults.cycleTime.max).toBe(120);
321 const notiz = notes.find((n) => n.code === 'vorgabe-ueberschritten:cycleTimeMax');
322 expect(notiz).toBeDefined();
323 expect(notiz!.severity).toBe('fehler');
324 expect(notiz!.message).toContain('250');
325 expect(notiz!.message).toContain('höchstens 120 s');
326 // Die Begruendung ist die der Anlagenart - als Text, nicht als Funktion.
327 expect(notiz!.message).toContain('nach RiLSA nicht vorzusehen');
328 expect(notiz!.message).not.toContain('function');
329 });
330
331 it('laesst 250 s bei einstreifiger Verkehrsfuehrung ungekappt und ohne Meldung', () => {
332 const { defaults, notes } = resolveRilsaSettings(
333 { cycleTimeMax: 250 },
334 undefined,
335 'einstreifig',
336 );
337 expect(ANLAGENART_GRENZEN.einstreifig.cycleTime.max).toBe(300);
338 expect(defaults.cycleTime.max).toBe(250);
339 expect(notes).toHaveLength(0);
340 });
341
342 it('kappt bei einstreifiger Fuehrung erst oberhalb von 300 s - mit der RSA-Begruendung', () => {
343 const { defaults, notes } = resolveRilsaSettings(
344 { cycleTimeMax: 400 },
345 undefined,
346 'einstreifig',
347 );
348 expect(defaults.cycleTime.max).toBe(300);
349 const notiz = notes.find((n) => n.code === 'vorgabe-ueberschritten:cycleTimeMax');
350 expect(notiz).toBeDefined();
351 expect(notiz!.message).toContain('300');
352 expect(notiz!.message).toContain('einstreifigen Verkehrsführung');
353 expect(notiz!.message).toContain('RSA');
354 });
355
356 it('nimmt ohne Anlagenart den Knotenpunkt an - alte Aufrufe uebersetzen, kappen aber', () => {
357 const { defaults, notes } = resolveRilsaSettings({ cycleTimeMax: 250 });
358 expect(defaults.cycleTime.max).toBe(120);
359 expect(notes.some((n) => n.code === 'vorgabe-ueberschritten:cycleTimeMax')).toBe(true);
360 });
361
362 it('haelt die untere Schranke von 30 s fuer alle Umlaufzeitfelder', () => {
363 const { defaults, notes } = resolveRilsaSettings(
364 { cycleTimeMin: 5, cycleTimeRecommendedMin: 10 },
365 undefined,
366 'einstreifig',
367 );
368 expect(defaults.cycleTime.min).toBe(SAFETY_FLOORS.minCycleTime);
369 expect(defaults.cycleTime.recommendedMin).toBe(SAFETY_FLOORS.minCycleTime);
370 expect(notes.filter((n) => n.code.startsWith('vorgabe-unterschritten:cycleTime'))).toHaveLength(
371 2,
372 );
373 expect(notes.find((n) => n.code === 'vorgabe-unterschritten:cycleTimeMin')!.message).toContain(
374 'nicht schaltbar',
375 );
376 });
377
378 it('prueft in der Rechnung dieselben Schranken wie die Vorgabenansicht', () => {
379 for (const art of ['knotenpunkt', 'fussgaengerschutzanlage', 'einstreifig'] as const) {
380 const feld = settingFields(undefined, art).find((f) => f.key === 'cycleTimeMax')!;
381 const knappDrueber = resolveRilsaSettings({ cycleTimeMax: feld.max + 1 }, undefined, art);
382 expect(knappDrueber.defaults.cycleTime.max, art).toBe(feld.max);
383 const genau = resolveRilsaSettings({ cycleTimeMax: feld.max }, undefined, art);
384 expect(genau.notes, art).toHaveLength(0);
385 }
386 });
387
388 it('kappt einen einstreifigen Plan mit 250 s NICHT - der Plan reicht die Anlagenart durch', () => {
389 const projekt = zweiGruppen({}, 'einstreifig', { cycleTimeMax: 250 });
390 const plan = buildSignalPlan(projekt);
391 expect(plan.defaults.cycleTime.max).toBe(250);
392 expect(plan.grenzen.cycleTime.max).toBe(250);
393 expect(plan.notes.some((n) => n.code.startsWith('vorgabe-ueberschritten'))).toBe(false);
394
395 const bericht = validateProject(projekt, undefined, DATUM);
396 expect(
397 bericht.findings.some((f) => f.rule === 'vorgaben.vorgabe-ueberschritten:cycleTimeMax'),
398 ).toBe(false);
399 expect(
400 bericht.findings.filter((f) => f.category === 'vorgaben' && f.severity === 'fehler'),
401 ).toHaveLength(0);
402 });
403
404 it('kappt denselben Wert am Knotenpunkt auf 120 s - mit Fehler im Plan und im Bericht', () => {
405 const projekt = zweiGruppen({}, 'knotenpunkt', { cycleTimeMax: 250 });
406 const plan = buildSignalPlan(projekt);
407 expect(plan.defaults.cycleTime.max).toBe(120);
408 expect(plan.grenzen.cycleTime.max).toBe(120);
409 expect(plan.notes.some((n) => n.code === 'vorgabe-ueberschritten:cycleTimeMax')).toBe(true);
410
411 const bericht = validateProject(projekt, undefined, DATUM);
412 const befund = bericht.findings.find(
413 (f) => f.rule === 'vorgaben.vorgabe-ueberschritten:cycleTimeMax',
414 );
415 expect(befund).toBeDefined();
416 expect(befund!.severity).toBe('fehler');
417 expect(befund!.category).toBe('vorgaben');
418 expect(bericht.exportBlocked).toBe(true);
419 });
420 });
421
422 // --- C11 ---------------------------------------------------------------------
423
424 describe('C11: Uebersaettigung ist kein Umlaufzeitergebnis', () => {
425 it('liefert bounded "uebersaettigt" statt "maximum" - die Zahl ist ein Ersatzwert', () => {
426 // Y = 0,7 + 0,35 = 1,05 >= 1: nicht bemessbar.
427 const result = computeCycleTime('webster', { lostTime: 20, criticalFlowRatios: [0.7, 0.35] });
428 expect(result.bounded).toBe('uebersaettigt');
429 expect(result.bounded).not.toBe('maximum');
430 expect(result.cycleTime).toBe(RILSA_DEFAULTS.cycleTime.max);
431 expect(result.raw).toBe(Number.POSITIVE_INFINITY);
432 const notiz = result.notes.find((n) => n.code === 'uebersaettigt');
433 expect(notiz).toBeDefined();
434 expect(notiz!.severity).toBe('fehler');
435 expect(notiz!.message).toContain('Ersatzwert');
436 expect(notiz!.message).toContain('nicht bemessbar');
437 expect(notiz!.message).toContain('120 s');
438 });
439
440 it('haelt "maximum" fuer den rechenbaren, aber zu langen Umlauf getrennt', () => {
441 // Y = 0,85 < 1: rechenbar (433 s), aber ueber 120 s.
442 const result = computeCycleTime('webster', { lostTime: 40, criticalFlowRatios: [0.55, 0.3] });
443 expect(result.bounded).toBe('maximum');
444 expect(result.notes.some((n) => n.code === 'uebersaettigt')).toBe(false);
445 });
446
447 it('gilt fuer die kapazitaetsorientierten Verfahren ab ihrer eigenen Grenze', () => {
448 // HBS: Y >= Ziel-Auslastungsgrad 0,85.
449 const hbs = computeCycleTime('hbs', { lostTime: 20, criticalFlowRatios: [0.5, 0.4] });
450 expect(hbs.bounded).toBe('uebersaettigt');
451 // Webster ist bei Y = 0,9 noch rechenbar.
452 const webster = computeCycleTime('webster', { lostTime: 20, criticalFlowRatios: [0.5, 0.4] });
453 expect(webster.bounded).not.toBe('uebersaettigt');
454 const vergleich = compareCycleTimeMethods({ lostTime: 20, criticalFlowRatios: [0.5, 0.4] });
455 expect(vergleich.reason).toContain('übersättigt');
456 });
457
458 it('der Plan traegt den Zustand: cycleResult.bounded und umlaufzeitIstErsatzwert', () => {
459 const plan = buildSignalPlan(uebersaettigt());
460 expect(plan.cycleResult).not.toBeNull();
461 expect(plan.cycleResult!.bounded).toBe('uebersaettigt');
462 expect(umlaufzeitIstErsatzwert(plan)).toBe(true);
463 // Der Plan hat trotzdem eine Zahl - die Obergrenze der Anlagenart.
464 expect(plan.cycleTime).toBeGreaterThan(0);
465 expect(plan.notes.some((n) => n.code === 'uebersaettigt' && n.severity === 'fehler')).toBe(
466 true,
467 );
468 });
469
470 it('kennzeichnet einen bemessbaren oder fest vorgegebenen Umlauf nicht als Ersatzwert', () => {
471 const bemessbar = buildSignalPlan(createStandardIntersectionProject('Normal', DATUM));
472 expect(umlaufzeitIstErsatzwert(bemessbar)).toBe(false);
473
474 const roh = uebersaettigt();
475 const fest: Project = {
476 ...roh,
477 program: { ...roh.program, method: 'manuell', manualCycleTime: 90 },
478 };
479 const plan = buildSignalPlan(fest);
480 expect(plan.cycleResult).toBeNull();
481 expect(umlaufzeitIstErsatzwert(plan)).toBe(false);
482 });
483
484 it('haelt EINEN Wortlaut fuer den Vermerk bereit', () => {
485 expect(UMLAUFZEIT_ERSATZWERT_VERMERK).toContain('nicht bemessbar');
486 expect(UMLAUFZEIT_ERSATZWERT_VERMERK).toContain('übersättigt');
487 expect(UMLAUFZEIT_ERSATZWERT_VERMERK).toContain('Ersatzwert');
488 });
489
490 it('nennt im Bericht die Anlagenart und den Ersatzwert', () => {
491 const bericht = validateProject(uebersaettigt('einstreifig'), undefined, DATUM);
492 const befund = bericht.findings.find((f) => f.rule === 'signalplan.uebersaettigt');
493 expect(befund).toBeDefined();
494 expect(befund!.severity).toBe('fehler');
495 expect(befund!.message).toContain('Ersatzwert');
496 expect(befund!.message).toContain(`${ANLAGENART_GRENZEN.einstreifig.cycleTime.max} s`);
497 expect(befund!.reference).toContain('Umlaufzeit');
498 expect(bericht.exportBlocked).toBe(true);
499 });
500 });