lsa-planer
LSA-Planer Professional – Planungssoftware für Lichtsignalanlagen nach RiLSA 2015 und § 45 StVO. EUPL-1.2.
/ tests domain stilleErsetzungen.test.ts
| 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 | }); |