lsa-planer

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

/ tests domain hoechstfreigabezeitUnterMindestfreigabezeit.test.ts

12,5 KB Rohdatei
tests/domain/hoechstfreigabezeitUnterMindestfreigabezeit.test.ts — 267 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 createPhase,
6 createSignalGroup,
7 createStandardIntersectionProject,
8 } from '@/domain/model/factory';
9 import type { Project } from '@/domain/model/project';
10
11 /**
12 * Eine Hoechstfreigabezeit unter der Mindestfreigabezeit wird auch
13 * gemeldet, wenn sie in den Projektvorgaben steht.
14 *
15 * Vor Fassung 5.13.0 meldete `phaseMaxGreen` (plan/signalPlan.ts)
16 * den Fall nur fuer eine an der SIGNALGRUPPE eingetragene Zahl; bei
17 * `massgebend === null` kehrte die Funktion ohne Meldung und mit dem
18 * unveraenderten Regelwert zurueck. Derselbe Sachverhalt wurde damit einmal
19 * gemeldet und einmal nicht - je nachdem, an welcher Stelle der Bearbeiter die
20 * Zahl eingetragen hatte.
21 *
22 * DAS ERGEBNIS IST IN BEIDEN FAELLEN DASSELBE, DER BERICHT NICHT. Gemessen an
23 * der Standardvorlage mit minGreenKfz 40 s und maxGreen 10 s bleiben tU 100 s
24 * und die Freigabezeiten 40/40 s unveraendert; die Verteilung bekam aber eine
25 * Phase mit maxGreen < minGreen, und daraus stand die Warnung
26 * 'freigabezeit-rest' im Bericht - "Alle Phasen erreichen ihre
27 * Höchstfreigabezeit; 30 s der Umlaufzeit bleiben übrig ... von 10 s auf 40 s"
28 * -, obwohl beide Phasen mit 40 s das Vierfache ihrer angeblichen Obergrenze
29 * fuehrten. Mit einer dritten Phase und fester Umlaufzeit von 120 s sind es
30 * vorher wie nachher 60/40/10 s, doch dieselbe Warnung nannte 50 s Rest und
31 * eine Verlaengerung "von 10 s auf 60 s", waehrend es 20 s und "von 40 s auf
32 * 60 s" sind. Angehoben wird die Schranke deshalb hier, gleich der Behandlung
33 * der Signalgruppenvorgabe.
34 *
35 * GEGEN DEN ALTSTAND (gemessen: `signalPlan.ts` auf den Stand vor Fassung 5.13.0
36 * zurueckgespielt) sind vier der sieben Faelle rot: die beiden zur
37 * Vorgabemeldung, der zur fortgefallenen und der zum berichtigten Wortlaut der
38 * Restmeldung. Die uebrigen drei sind Gegenproben - Umlaufzeit und
39 * Freigabezeiten unveraendert, Regelwert ueber allen Mindestfreigabezeiten,
40 * Vorgabe an der Signalgruppe - und vorher wie nachher gruen.
41 *
42 * NACHTRAG (10.09.2026, Fassung 5.23.0): Die Zahlenreihe 60/40/10 s und die
43 * Verlaengerung "von 40 s auf 60 s" gelten dem Stand bis 5.22.0 und stehen
44 * oben, weil sie den damaligen Befund tragen. Der Rest der
45 * Freigabezeitverteilung geht seither nicht mehr ganz an die Phase mit dem
46 * groessten Gewicht, sondern an alle Phasen mit Spielraum: Hier sind es
47 * 40/40/30 s, und die Meldung nennt die Empfaengerphase statt einer
48 * Verlaengerung. Nachgezogen ist deshalb ein Fall dieser Datei; der Befund
49 * dieser Datei bleibt davon unberuehrt. Warum die Verteilung geaendert wurde,
50 * steht in tests/domain/freigabezeitrestVerteilung.test.ts.
51 */
52
53 const DATUM = new Date('2026-09-07T10:00:00Z');
54 /*
55 * ZWEI KENNUNGEN, EIN SACHVERHALT (Nachtrag). Der naechste Schritt ist
56 * je Fall ein anderer - an der Signalgruppe etwas entfernen oder anheben, oder
57 * die Zahl in der Vorgabenverwaltung -, und `suggestionFor` in
58 * domain/validation/engine.ts kennt nur die Kennung, nicht die Meldung. Unter
59 * einer gemeinsamen Kennung schickte der gedruckte Rat den Bearbeiter im
60 * Vorgabefall an eine Signalgruppe, an der nichts eingetragen ist.
61 */
62 const KENNUNG_GRUPPE = 'hoechstfreigabezeit-unter-mindestfreigabezeit';
63 const KENNUNG_VORGABE = 'hoechstfreigabezeit-vorgabe-unter-mindestfreigabezeit';
64
65 /** Ein Grundprojekt je Fall: createStandardIntersectionProject vergibt neue Kennungen. */
66 function vorlage(rilsa: Record<string, number>): Project {
67 const basis = createStandardIntersectionProject('R2', DATUM);
68 return {
69 ...basis,
70 settings: { ...basis.settings, rilsa: { ...basis.settings.rilsa, ...rilsa } },
71 };
72 }
73
74 /** Dieselbe Vorlage mit einer dritten, reinen Fussgaengerphase und fester Umlaufzeit. */
75 function vorlageMitDritterPhase(rilsa: Record<string, number>, umlaufzeit: number): Project {
76 const basis = vorlage(rilsa);
77 const gruppe = createSignalGroup({
78 name: 'F9',
79 mode: 'fuss',
80 armId: basis.intersection.arms[0]?.id ?? null,
81 index: 6,
82 });
83 const phasen = [...basis.phases, createPhase('Phase 3 - Fuß', [gruppe.id])];
84 return {
85 ...basis,
86 signalGroups: [...basis.signalGroups, gruppe],
87 phases: phasen,
88 program: {
89 ...basis.program,
90 method: 'manuell',
91 manualCycleTime: umlaufzeit,
92 phaseOrder: phasen.map((p) => p.id),
93 },
94 };
95 }
96
97 describe('Eine Hoechstfreigabezeit aus den Projektvorgaben unter der Mindestfreigabezeit', () => {
98 it('wird gemeldet - einmal fuer die Anlage, mit allen betroffenen Phasen', () => {
99 const plan = buildSignalPlan(vorlage({ minGreenKfz: 40, maxGreen: 10 }));
100 const treffer = plan.notes.filter((n) => n.code === KENNUNG_VORGABE);
101
102 expect(treffer, 'genau eine Meldung, nicht eine je Phase').toHaveLength(1);
103 expect(treffer[0]!.severity, 'das Ergebnis ist richtig, die Eingabe irrefuehrend').toBe(
104 'warnung',
105 );
106 expect(treffer[0]!.signalGroupId, 'die Zahl steht an keiner Signalgruppe').toBeUndefined();
107 expect(treffer[0]!.message).toContain('In den Projektvorgaben steht eine Höchstfreigabezeit');
108 expect(treffer[0]!.message, 'der eingetragene Wert').toContain('10 s');
109 expect(treffer[0]!.message, 'erste betroffene Phase').toContain('"Phase 1 - Nord/Süd" (40 s)');
110 expect(treffer[0]!.message, 'zweite betroffene Phase').toContain('"Phase 2 - Ost/West" (40 s)');
111 });
112
113 it('aendert an Umlaufzeit und Freigabezeiten nichts, wo die Zeit ohnehin aufgeht', () => {
114 // Die Mindestfreigabezeit ging schon bisher vor - an dieser Vorlage
115 // bleiben tU und Freigabezeiten unveraendert. Was sich aendert, ist die
116 // Auskunft daneben.
117 const plan = buildSignalPlan(vorlage({ minGreenKfz: 40, maxGreen: 10 }));
118 expect(plan.cycleTime).toBe(100);
119 expect(plan.phases.map((p) => p.duration)).toEqual([40, 40]);
120 });
121
122 it('nimmt der Verteilung die falsche Restmeldung, weil keine Phase mehr maxGreen < minGreen traegt', () => {
123 /*
124 * Bis zur Aenderung stand hier 'freigabezeit-rest' mit dem Satz "Alle
125 * Phasen erreichen ihre Höchstfreigabezeit" - bei Freigabezeiten von
126 * 40 s gegen eine angebliche Obergrenze von 10 s.
127 */
128 const plan = buildSignalPlan(vorlage({ minGreenKfz: 40, maxGreen: 10 }));
129 expect(plan.notes.map((n) => n.code)).not.toContain('freigabezeit-rest');
130 });
131
132 it('nennt die Restmeldung die Phasen, die den Rest wirklich bekommen', () => {
133 /*
134 * Dieselbe Vorlage mit einer dritten Phase und 120 s festem Umlauf: Hier
135 * bleibt wirklich ein Rest. Vor Fassung 5.13.0 meldete der
136 * Plan 50 s Rest und eine Verlaengerung "von 10 s auf 60 s" - 10 s war die
137 * Zahl, die die Phase nie hatte.
138 *
139 * NACHGEZOGEN AM 10.09.2026 (Fassung 5.23.0): Der Satz "von 40 s auf 60 s"
140 * stand bis dahin hier und war richtig, solange der Rest ganz an EINE
141 * Phase ging. Er geht jetzt an die Phasen mit Spielraum, und das sind hier
142 * die Fussgaengerphase allein - die beiden Kfz-Phasen liegen mit
143 * minGreen === maxGreen fest, weil ihre Hoechstfreigabezeit auf die
144 * Mindestfreigabezeit angehoben wurde. Bewacht wird deshalb, was die
145 * Meldung jetzt zusagt: die Zahl des Rests, die Empfaengerphase und der
146 * Weg, ihn loszuwerden.
147 */
148 const plan = buildSignalPlan(vorlageMitDritterPhase({ minGreenKfz: 40, maxGreen: 10 }, 120));
149 const rest = plan.notes.find((n) => n.code === 'freigabezeit-rest');
150 expect(rest, 'Restmeldung').toBeDefined();
151 expect(rest!.message).toContain('20 s der Umlaufzeit bleiben übrig');
152 expect(rest!.message, 'die Empfaengerphase').toContain('Sie gehen an Phase 3 –');
153 expect(rest!.message, 'der Weg heraus').toContain('Verkürzen Sie die Umlaufzeit um 20 s');
154 expect(rest!.message, 'die angebliche Obergrenze 10 s war nie die Freigabezeit').not.toContain(
155 '10 s',
156 );
157
158 // Und die Verteilung dazu: Die beiden gleichgestellten Kfz-Phasen bleiben
159 // gleich lang. Bis 5.22.0 bekam Phase 1 den ganzen Rest - 60/40/10 -,
160 // obwohl nichts sie von Phase 2 unterschied.
161 expect(plan.phases.map((p) => p.duration)).toEqual([40, 40, 30]);
162 });
163
164 it('schweigt, wo der Regelwert ueber allen Mindestfreigabezeiten liegt', () => {
165 const plan = buildSignalPlan(vorlage({ minGreenKfz: 40 }));
166 const kennungen = plan.notes.map((n) => n.code);
167 expect(kennungen).not.toContain(KENNUNG_VORGABE);
168 expect(kennungen, 'auch die Meldung der Signalgruppe nicht').not.toContain(KENNUNG_GRUPPE);
169 });
170
171 it('bleibt die Meldung je Signalgruppe unveraendert und tritt nicht doppelt auf', () => {
172 const basis = vorlage({ minGreenKfz: 40 });
173 const projekt: Project = {
174 ...basis,
175 signalGroups: basis.signalGroups.map((g) =>
176 g.name === 'K1' ? { ...g, maxGreenOverride: 10 } : g,
177 ),
178 };
179 const alle = buildSignalPlan(projekt).notes;
180 const treffer = alle.filter((n) => n.code === KENNUNG_GRUPPE);
181
182 expect(treffer, 'eine Meldung, und zwar die der Signalgruppe').toHaveLength(1);
183 expect(treffer[0]!.signalGroupId, 'sie haengt an der Signalgruppe').toBeDefined();
184 expect(treffer[0]!.message).toContain('Für die Signalgruppe "K1"');
185 // Nicht doppelt: Der Vorgabewert liegt hier ueber allen
186 // Mindestfreigabezeiten, seine Meldung darf danebenstehen.
187 expect(
188 alle.map((n) => n.code),
189 'die Vorgabemeldung tritt nicht zusaetzlich auf',
190 ).not.toContain(KENNUNG_VORGABE);
191 });
192
193 it('laesst eine Phase mit fester Freigabezeit aus - dort gilt die Handvorgabe', () => {
194 /*
195 * Bei fester Freigabezeit ist der Regelwert nicht die Schranke der Phase;
196 * die Handvorgabe geht ihm vor und haelt die Mindestfreigabezeit ein.
197 * Genannt wird deshalb nur die uebrige Phase.
198 */
199 const basis = vorlage({ minGreenKfz: 40, maxGreen: 10 });
200 const projekt: Project = {
201 ...basis,
202 phases: basis.phases.map((phase, index) =>
203 index === 0 ? { ...phase, manualGreen: 45 } : phase,
204 ),
205 };
206 const treffer = buildSignalPlan(projekt).notes.filter((n) => n.code === KENNUNG_VORGABE);
207
208 expect(treffer).toHaveLength(1);
209 expect(treffer[0]!.message, 'Einzahl bei einer betroffenen Phase').toContain(
210 'Diese Phase braucht mehr',
211 );
212 expect(treffer[0]!.message).toContain('"Phase 2 - Ost/West" (40 s)');
213 expect(treffer[0]!.message, 'die feste Freigabezeit gehoert nicht in die Liste').not.toContain(
214 '"Phase 1 - Nord/Süd"',
215 );
216 });
217 /*
218 * DER GEDRUCKTE NAECHSTE SCHRITT - der eigentliche Grund fuer die getrennte
219 * Kennung.
220 *
221 * `suggestionFor` (domain/validation/engine.ts) bekommt nur die Kennung, nicht
222 * die Meldung. Solange beide Faelle dieselbe trugen, stand unter der
223 * Vorgabemeldung der Rat "Entfernen Sie die Höchstfreigabezeit an der
224 * Signalgruppe" - und an keiner Signalgruppe war etwas eingetragen. Der
225 * Bearbeiter suchte an einer Stelle, an der nichts zu finden ist.
226 *
227 * Dieser Fall haelt beide Raete an ihrem Fall fest und die Ueberschriften
228 * dazu. Er ist gegen den Stand mit gemeinsamer Kennung rot.
229 */
230 it('zeigt der Rat auf die Vorgabenverwaltung und nicht auf eine Signalgruppe', () => {
231 const ausVorgabe = vorlage({ minGreenKfz: 40, maxGreen: 10 });
232 const befundVorgabe = validateProject(ausVorgabe, buildSignalPlan(ausVorgabe)).findings.find(
233 (f) => f.rule === `signalplan.${KENNUNG_VORGABE}`,
234 );
235
236 expect(befundVorgabe, 'der Befund zur Projektvorgabe fehlt').toBeDefined();
237 expect(befundVorgabe!.suggestion, 'der Rat schickt an eine Signalgruppe').not.toContain(
238 'an der Signalgruppe',
239 );
240 expect(befundVorgabe!.suggestion, 'der Rat nennt die Vorgabenverwaltung nicht').toContain(
241 'Vorgabenverwaltung',
242 );
243 expect(befundVorgabe!.title, 'kein Rueckfall auf den Sammeltitel').not.toBe(
244 'Hinweis aus dem Planaufbau',
245 );
246
247 // Gegenprobe: Der Fall an der Signalgruppe behaelt seinen Rat.
248 const basis = vorlage({ minGreenKfz: 40 });
249 const jeGruppe: Project = {
250 ...basis,
251 signalGroups: basis.signalGroups.map((g) =>
252 g.name === 'K1' ? { ...g, maxGreenOverride: 10 } : g,
253 ),
254 };
255 const befundGruppe = validateProject(jeGruppe, buildSignalPlan(jeGruppe)).findings.find(
256 (f) => f.rule === `signalplan.${KENNUNG_GRUPPE}`,
257 );
258
259 expect(befundGruppe, 'der Befund zur Signalgruppe fehlt').toBeDefined();
260 expect(befundGruppe!.suggestion, 'dort gehoert die Signalgruppe in den Rat').toContain(
261 'an der Signalgruppe',
262 );
263 expect(befundVorgabe!.title, 'beide Faelle tragen denselben Titel').not.toBe(
264 befundGruppe!.title,
265 );
266 });
267 });