lsa-planer

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

/ tests domain stilleErsetzungenFachkern.test.ts

16,9 KB Rohdatei
tests/domain/stilleErsetzungenFachkern.test.ts — 380 Zeilen
1 import { describe, expect, it } from 'vitest';
2 import {
3 UMLAUFZEIT_ERSATZWERT_VERMERK,
4 buildSignalPlan,
5 intergreenKey,
6 umlaufzeitMitVermerk,
7 } from '@/domain/plan/signalPlan';
8 import { validateProject } from '@/domain/validation/engine';
9 import { computeIntergreen } from '@/domain/rilsa/intergreen';
10 import { resolveRilsaSettings, settingFields } from '@/domain/rilsa/settings';
11 import {
12 ANLAGENART_GRENZEN,
13 RILSA_DEFAULTS,
14 SAFETY_FLOORS,
15 fahrzeugartBeschriftung,
16 } from '@/domain/rilsa/constants';
17 import {
18 createConflict,
19 createEmptyProject,
20 createPhase,
21 createSignalGroup,
22 createStandardIntersectionProject,
23 } from '@/domain/model/factory';
24 import type { Anlagenart, Project, SignalGroup } from '@/domain/model/project';
25 import type { VehicleClass } from '@/domain/rilsa/types';
26
27 /*
28 * Befunde C7 bis C12 - weitere Fachkern-Befunde:
29 *
30 * - Der Ersatzwert-Vermerk traegt den Gedankenstrich der Anwendertexte, und
31 * das Zusammenfuegen "Zahl – Vermerk" steht EINMAL im Fachkern.
32 * - Der Ersatzwert ist die groesste ZULAESSIGE Umlaufzeit - eine engere eigene
33 * Vorgabe gilt, nicht die Schranke der Anlagenart.
34 * - Eine unzulaessige Vorgabe steht EINMAL im Bericht, nicht zweimal.
35 * - Die Umlaufzeitfelder begruenden die verletzte Schranke, nicht die andere.
36 * - Widerspruch kleinste > groesste Umlaufzeit: die Notiz ueberlebt den
37 * Rueckfall auf die Regelwerte.
38 * - Schwerverkehrsanteil: 0 bis 100 % wie das Schema; ausserhalb begrenzt UND
39 * gemeldet - vorher stumm bei 90 % gekappt.
40 * - Zwischenzeit von Hand: Aufrunden wird gemeldet, ein negativer Wert ist ein
41 * Fehler und ersetzt die Rechnung nicht mehr durch 0 s.
42 * - Beschriftung einer unbekannten Fahrzeugart nennt keine falsche Zahl.
43 *
44 * Gegen den Altstand schlagen alle Faelle fehl, bis auf die als Waechter
45 * gekennzeichneten.
46 */
47
48 const DATUM = new Date('2026-01-01T00:00:00Z');
49
50 /** Zwei Kfz-Gruppen in zwei Phasen; Konflikte und Gruppe 2 anpassbar. */
51 function zweiGruppen(
52 zweite: Partial<SignalGroup> = {},
53 anlagenart: Anlagenart = 'knotenpunkt',
54 rilsa: Project['settings']['rilsa'] = {},
55 konflikt: Partial<Project['conflicts'][number]> = {},
56 ): Project {
57 const basis = createEmptyProject('Zwei Gruppen', DATUM, anlagenart);
58 const a = createSignalGroup({ name: 'K1', mode: 'kfz', index: 0 });
59 const b: SignalGroup = { ...createSignalGroup({ name: 'K2', mode: 'kfz', index: 1 }), ...zweite };
60 const phase1 = createPhase('Phase 1', [a.id]);
61 const phase2 = createPhase('Phase 2', [b.id]);
62 return {
63 ...basis,
64 signalGroups: [a, b],
65 conflicts: [
66 { ...createConflict(a.id, b.id), clearingDistance: 22, enteringDistance: 8, ...konflikt },
67 { ...createConflict(b.id, a.id), clearingDistance: 22, enteringDistance: 8 },
68 ],
69 phases: [phase1, phase2],
70 program: { ...basis.program, phaseOrder: [phase1.id, phase2.id] },
71 settings: { ...basis.settings, rilsa },
72 };
73 }
74
75 /** Der Musterknotenpunkt mit Verkehrsstaerken weit ueber der Kapazitaet. */
76 function uebersaettigt(rilsa: Project['settings']['rilsa'] = {}): Project {
77 const roh = createStandardIntersectionProject('Uebersaettigt', DATUM);
78 return {
79 ...roh,
80 settings: { ...roh.settings, rilsa },
81 demands: roh.signalGroups
82 .filter((g) => g.mode === 'kfz')
83 .map((g) => ({ signalGroupId: g.id, volume: 1800, heavyVehicleShare: 0.1 })),
84 };
85 }
86
87 /** Musterknotenpunkt mit einem Schwerverkehrsanteil an K1. */
88 function mitSchwerverkehr(anteil: number): Project {
89 const roh = createStandardIntersectionProject('Schwerverkehr', DATUM);
90 const k1 = roh.signalGroups.find((g) => g.name === 'K1')!;
91 return {
92 ...roh,
93 demands: roh.demands.map((d) =>
94 d.signalGroupId === k1.id ? { ...d, heavyVehicleShare: anteil } : d,
95 ),
96 };
97 }
98
99 function saettigungK1(project: Project): number {
100 const k1 = project.signalGroups.find((g) => g.name === 'K1')!;
101 return buildSignalPlan(project).groups.find((g) => g.groupId === k1.id)!.capacity!.saturationFlow;
102 }
103
104 // --- Vermerk ------------------------------------------------------------------
105
106 describe('Ersatzwert-Vermerk: EIN Wortlaut, EIN Zusammenfuegen', () => {
107 it('traegt den Gedankenstrich der Anwendertexte - kein Bindestrich', () => {
108 expect(UMLAUFZEIT_ERSATZWERT_VERMERK).toBe('nicht bemessbar (übersättigt) – Ersatzwert');
109 expect(UMLAUFZEIT_ERSATZWERT_VERMERK).not.toContain(' - ');
110 });
111
112 it('haengt den Vermerk nur bei Uebersaettigung an die formatierte Zahl', () => {
113 const ersatz = buildSignalPlan(uebersaettigt());
114 expect(umlaufzeitMitVermerk(ersatz, '120 s')).toBe(`120 s – ${UMLAUFZEIT_ERSATZWERT_VERMERK}`);
115 const normal = buildSignalPlan(createStandardIntersectionProject('Normal', DATUM));
116 expect(umlaufzeitMitVermerk(normal, '80 s')).toBe('80 s');
117 // Waechter: keine Zahl im Fachkern - die formatiert der Aufrufer.
118 expect(umlaufzeitMitVermerk(ersatz, 'x')).toBe(`x – ${UMLAUFZEIT_ERSATZWERT_VERMERK}`);
119 });
120
121 it('ist die groesste ZULAESSIGE Umlaufzeit: eine engere eigene Vorgabe gilt vor der Anlagenart', () => {
122 // Knotenpunkt (Schranke 120 s) mit "Groesste Umlaufzeit 90 s", uebersaettigt.
123 const plan = buildSignalPlan(uebersaettigt({ cycleTimeMax: 90 }));
124 expect(plan.cycleResult?.bounded).toBe('uebersaettigt');
125 expect(ANLAGENART_GRENZEN.knotenpunkt.cycleTime.max).toBe(120);
126 expect(plan.cycleResult?.cycleTime).toBe(90);
127 expect(plan.grenzen.cycleTime.max).toBe(90);
128 // Die Fehlermeldung nennt die 90 s - nicht die 120 s der Anlagenart.
129 const notiz = plan.notes.find((n) => n.code === 'uebersaettigt');
130 expect(notiz?.message).toContain('Obergrenze von 90 s');
131 });
132 });
133
134 // --- Doppelmeldung ------------------------------------------------------------
135
136 describe('Unzulaessige Vorgabe: einmal im Bericht, nicht zweimal', () => {
137 it('fuehrt cycleTimeMax 150 s am Knotenpunkt als EINEN Fehler unter Vorgaben', () => {
138 const projekt = zweiGruppen({}, 'knotenpunkt', { cycleTimeMax: 150 });
139 const plan = buildSignalPlan(projekt);
140 // Der Plan traegt die Notiz weiterhin - er ist fuer sich vollstaendig.
141 expect(plan.notes.filter((n) => n.code === 'vorgabe-ueberschritten:cycleTimeMax')).toHaveLength(
142 1,
143 );
144
145 const bericht = validateProject(projekt, plan, DATUM);
146 const treffer = bericht.findings.filter((f) =>
147 f.rule.endsWith('vorgabe-ueberschritten:cycleTimeMax'),
148 );
149 expect(treffer).toHaveLength(1);
150 expect(treffer[0]!.rule).toBe('vorgaben.vorgabe-ueberschritten:cycleTimeMax');
151 expect(treffer[0]!.category).toBe('vorgaben');
152 expect(treffer[0]!.title).toBe('Unzulässige Vorgabe');
153 expect(bericht.findings.some((f) => f.rule.startsWith('signalplan.vorgabe-'))).toBe(false);
154 // Und sie zaehlt genau einmal: Der Plan hat sonst keinen Fehler.
155 expect(bericht.errorCount).toBe(1);
156 expect(bericht.exportBlocked).toBe(true);
157 });
158
159 it('gilt fuer jedes Vorgabenfeld - auch fuer die Gelbzeit', () => {
160 const projekt = zweiGruppen({}, 'knotenpunkt', { yellowKfzUpTo50: 1 });
161 const bericht = validateProject(projekt, undefined, DATUM);
162 expect(
163 bericht.findings.filter((f) => f.rule.endsWith('vorgabe-unterschritten:yellowKfzUpTo50')),
164 ).toHaveLength(1);
165 expect(bericht.findings.some((f) => f.rule.startsWith('signalplan.vorgabe-'))).toBe(false);
166 });
167 });
168
169 // --- Begruendung der Schranke -----------------------------------------------
170
171 describe('Umlaufzeitfelder begruenden die verletzte Schranke', () => {
172 it('nennt bei "Kleinste Umlaufzeit 150 s" die Obergrenze der Anlagenart, nicht "nicht schaltbar"', () => {
173 const { notes } = resolveRilsaSettings({ cycleTimeMin: 150 }, undefined, 'knotenpunkt');
174 const notiz = notes.find((n) => n.code === 'vorgabe-ueberschritten:cycleTimeMin');
175 expect(notiz).toBeDefined();
176 expect(notiz!.message).toContain('höchstens 120 s');
177 expect(notiz!.message).toContain('Umlaufzeiten über 120 s sind nach RiLSA nicht vorzusehen.');
178 expect(notiz!.message).not.toContain('nicht schaltbar');
179 });
180
181 it('nennt bei "Groesste Umlaufzeit 10 s" die Schaltbarkeit, nicht die RiLSA-Obergrenze', () => {
182 const { notes } = resolveRilsaSettings({ cycleTimeMax: 10 }, undefined, 'knotenpunkt');
183 const notiz = notes.find((n) => n.code === 'vorgabe-unterschritten:cycleTimeMax');
184 expect(notiz).toBeDefined();
185 expect(notiz!.message).toContain('mindestens 30 s');
186 expect(notiz!.message).toContain('Umlaufzeiten unter 30 s sind nicht schaltbar.');
187 expect(notiz!.message).not.toContain('nicht vorzusehen');
188 });
189
190 it('begruendet auch den Regelbereich - und bei einstreifiger Fuehrung mit dem RSA-Vorbehalt', () => {
191 const { notes } = resolveRilsaSettings(
192 { cycleTimeRecommendedMax: 400, cycleTimeRecommendedMin: 10 },
193 undefined,
194 'einstreifig',
195 );
196 expect(
197 notes.find((n) => n.code === 'vorgabe-ueberschritten:cycleTimeRecommendedMax')?.message,
198 ).toContain('RSA');
199 expect(
200 notes.find((n) => n.code === 'vorgabe-unterschritten:cycleTimeRecommendedMin')?.message,
201 ).toContain('nicht schaltbar');
202 });
203
204 it('nennt in der Vorgabenansicht beide Gruende, wenn sie sich unterscheiden', () => {
205 const feld = settingFields(undefined, 'knotenpunkt').find((f) => f.key === 'cycleTimeMax')!;
206 expect(feld.schrankenBegruendung).toContain('nicht schaltbar');
207 expect(feld.schrankenBegruendung).toContain('nach RiLSA nicht vorzusehen');
208 // Waechter: Felder mit EINER Begruendung bleiben bei ihr.
209 const gelb = settingFields().find((f) => f.key === 'yellowKfzUpTo50')!;
210 expect(gelb.schrankenBegruendung).toBe(
211 'Die Gelbzeit für Kraftfahrzeuge darf 3 s nicht unterschreiten.',
212 );
213 });
214 });
215
216 // --- Widerspruch --------------------------------------------------------------
217
218 describe('Kleinste Umlaufzeit groesser als groesste: der Widerspruch bleibt gemeldet', () => {
219 it('faellt auf die Regelwerte zurueck UND behaelt die Notiz', () => {
220 const { defaults, notes, deviations } = resolveRilsaSettings(
221 { cycleTimeMin: 100, cycleTimeMax: 50 },
222 undefined,
223 'knotenpunkt',
224 );
225 expect(defaults.cycleTime.min).toBe(RILSA_DEFAULTS.cycleTime.min);
226 expect(defaults.cycleTime.max).toBe(RILSA_DEFAULTS.cycleTime.max);
227 expect(deviations).toHaveLength(0);
228 const notiz = notes.find((n) => n.code === 'vorgabe-umlaufzeit-widerspruch');
229 expect(notiz).toBeDefined();
230 expect(notiz!.severity).toBe('fehler');
231 // Und im Bericht - als Fehler mit Sperre.
232 const bericht = validateProject(
233 zweiGruppen({}, 'knotenpunkt', { cycleTimeMin: 100, cycleTimeMax: 50 }),
234 undefined,
235 DATUM,
236 );
237 expect(
238 bericht.findings.filter((f) => f.rule === 'vorgaben.vorgabe-umlaufzeit-widerspruch'),
239 ).toHaveLength(1);
240 expect(bericht.exportBlocked).toBe(true);
241 });
242 });
243
244 // --- Schwerverkehrsanteil -----------------------------------------------------
245
246 describe('Schwerverkehrsanteil: 0 bis 100 % wie das Schema, kein stiller Deckel bei 90 %', () => {
247 it('rechnet mit 95 %, wenn 95 % eingetragen sind', () => {
248 const neunzig = saettigungK1(mitSchwerverkehr(0.9));
249 const fuenfundneunzig = saettigungK1(mitSchwerverkehr(0.95));
250 const hundert = saettigungK1(mitSchwerverkehr(1));
251 /*
252 * Altstand: alle drei gleich (Deckel 0,9).
253 *
254 * ANPASSUNG (Fassung 5.5.0, Frage E6): Gerechnet wird nicht
255 * mehr `base / (1 + Anteil)` - das war der Pkw-Gleichwert 2,0 des HCM -,
256 * sondern `base / fSV` mit fSV = 1 + 0,90 * Anteil nach HBS 2015
257 * (Gl. 2-6). Die GEPRUEFTE AUSSAGE bleibt dieselbe: Es gibt keinen stillen
258 * Deckel bei 90 %, ein hoeherer Anteil senkt die
259 * Saettigungsverkehrsstaerke tatsaechlich. Nur die Zahlen wandern.
260 */
261 expect(fuenfundneunzig).toBeLessThan(neunzig);
262 expect(hundert).toBeLessThan(fuenfundneunzig);
263 expect(hundert / neunzig).toBeCloseTo(1.81 / 1.9, 6);
264 const ohne = saettigungK1(mitSchwerverkehr(0));
265 // fSV(0,95) = 1 + 0,9 * 0,95 = 1,855; fSV(0,90) = 1,81.
266 expect(fuenfundneunzig).toBeCloseTo(ohne / 1.855, 6);
267 expect(neunzig).toBeCloseTo(ohne / 1.81, 6);
268 expect(
269 buildSignalPlan(mitSchwerverkehr(0.95)).notes.some(
270 (n) => n.code === 'schwerverkehrsanteil-ausserhalb',
271 ),
272 ).toBe(false);
273 });
274
275 it('begrenzt einen Anteil ueber 100 % auf 100 % und MELDET das an der Gruppe', () => {
276 const projekt = mitSchwerverkehr(1.2);
277 const plan = buildSignalPlan(projekt);
278 expect(saettigungK1(projekt)).toBe(saettigungK1(mitSchwerverkehr(1)));
279 const notiz = plan.notes.find((n) => n.code === 'schwerverkehrsanteil-ausserhalb');
280 expect(notiz).toBeDefined();
281 expect(notiz!.severity).toBe('warnung');
282 expect(notiz!.message).toContain('"K1"');
283 expect(notiz!.message).toContain('120 %');
284 expect(notiz!.message).toContain('Gerechnet wird mit 100 %');
285
286 const bericht = validateProject(projekt, plan, DATUM);
287 const befund = bericht.findings.find(
288 (f) => f.rule === 'signalplan.schwerverkehrsanteil-ausserhalb',
289 );
290 expect(befund).toBeDefined();
291 expect(befund!.category).toBe('leistungsfaehigkeit');
292 expect(befund!.target).toMatchObject({ kind: 'signalgruppe', label: 'K1' });
293 expect(befund!.title).toContain('Schwerverkehrsanteil');
294 expect(befund!.suggestion).toContain('0 und 100 %');
295 });
296
297 it('meldet einen negativen Anteil ebenso und rechnet mit 0 %', () => {
298 const projekt = mitSchwerverkehr(-0.2);
299 expect(saettigungK1(projekt)).toBe(saettigungK1(mitSchwerverkehr(0)));
300 const notiz = buildSignalPlan(projekt).notes.find(
301 (n) => n.code === 'schwerverkehrsanteil-ausserhalb',
302 );
303 expect(notiz?.message).toContain('-20 %');
304 expect(notiz?.message).toContain('Gerechnet wird mit 0 %');
305 });
306 });
307
308 // --- Zwischenzeit von Hand ----------------------------------------------------
309
310 describe('Zwischenzeit von Hand: Aufrunden gemeldet, negativ ist ein Fehler', () => {
311 it('rundet 4,2 s auf 5 s auf und sagt es als Hinweis an der Konfliktbeziehung', () => {
312 const projekt = zweiGruppen({}, 'knotenpunkt', {}, { manualIntergreen: 4.2 });
313 const plan = buildSignalPlan(projekt);
314 const [k1, k2] = projekt.signalGroups;
315 const eintrag = plan.intergreens.get(intergreenKey(k1!.id, k2!.id))!;
316 expect(eintrag.value).toBe(5);
317 expect(eintrag.source).toBe('vorgegeben');
318 const notiz = plan.notes.find((n) => n.code === 'zwischenzeit-vorgabe-aufgerundet');
319 expect(notiz).toBeDefined();
320 expect(notiz!.severity).toBe('hinweis');
321 expect(notiz!.konfliktId).toBe(projekt.conflicts[0]!.id);
322 expect(notiz!.message).toContain('4,2 s');
323 expect(notiz!.message).toContain('angesetzt werden 5 s');
324
325 const befund = validateProject(projekt, plan, DATUM).findings.find(
326 (f) => f.rule === 'signalplan.zwischenzeit-vorgabe-aufgerundet',
327 );
328 expect(befund).toBeDefined();
329 expect(befund!.category).toBe('zwischenzeiten');
330 expect(befund!.target).toMatchObject({ kind: 'konflikt' });
331 });
332
333 it('schweigt bei ganzen Sekunden (Waechter)', () => {
334 const plan = buildSignalPlan(zweiGruppen({}, 'knotenpunkt', {}, { manualIntergreen: 9 }));
335 expect(plan.notes.some((n) => n.code === 'zwischenzeit-vorgabe-aufgerundet')).toBe(false);
336 });
337
338 it('nimmt eine negative Vorgabe nicht an: Fehler, gerechnet wird aus den Wegen - nicht 0 s', () => {
339 const projekt = zweiGruppen({}, 'knotenpunkt', {}, { manualIntergreen: -3 });
340 const plan = buildSignalPlan(projekt);
341 const [k1, k2] = projekt.signalGroups;
342 const eintrag = plan.intergreens.get(intergreenKey(k1!.id, k2!.id))!;
343 // Altstand: Math.max(0, ceil(-3)) = 0 s als VORGABE - keine Zwischenzeit.
344 expect(eintrag.source).toBe('berechnet');
345 expect(eintrag.value).toBe(
346 computeIntergreen(
347 { mode: 'kfz', movement: 'geradeaus', clearingDistance: 22, vehicleClass: 'pkw', vZul: 50 },
348 { mode: 'kfz', movement: 'geradeaus', enteringDistance: 8 },
349 ).intergreen,
350 );
351 expect(eintrag.value).toBeGreaterThan(0);
352 const notiz = plan.notes.find((n) => n.code === 'zwischenzeit-vorgabe-ungueltig');
353 expect(notiz).toBeDefined();
354 expect(notiz!.severity).toBe('fehler');
355 expect(notiz!.message).toContain('-3 s');
356 expect(notiz!.message).toContain('nicht übernommen');
357
358 const bericht = validateProject(projekt, plan, DATUM);
359 expect(
360 bericht.findings.some((f) => f.rule === 'signalplan.zwischenzeit-vorgabe-ungueltig'),
361 ).toBe(true);
362 expect(bericht.exportBlocked).toBe(true);
363 });
364 });
365
366 // --- Fahrzeugart --------------------------------------------------------------
367
368 describe('Beschriftung einer unbekannten Fahrzeugart', () => {
369 it('nennt keine erfundene Zahl und kein "undefined"', () => {
370 const text = fahrzeugartBeschriftung('traktor' as VehicleClass);
371 expect(text).not.toContain('undefined');
372 expect(text).not.toContain('kein Zuschlag');
373 expect(text).toContain('unbekannt');
374 expect(text).toContain('traktor');
375 expect(text).toContain('Prüfbericht');
376 // Waechter: bekannte Fahrzeugarten wie bisher.
377 expect(fahrzeugartBeschriftung('pkw')).toBe(`Pkw (${SAFETY_FLOORS.minVehicleLengthKfz} m)`);
378 expect(fahrzeugartBeschriftung('keine')).toBe('ohne Längenzuschlag');
379 });
380 });