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