lsa-planer
LSA-Planer Professional – Planungssoftware für Lichtsignalanlagen nach RiLSA 2015 und § 45 StVO. EUPL-1.2.
/ tests domain gleichstandOhneReihenfolge.test.ts
| 1 | import { readFileSync } from 'node:fs'; |
| 2 | import { describe, expect, it } from 'vitest'; |
| 3 | import { leiteAb, uebernimmWege, wegSchluessel } from '@/domain/geometrie/ableitung'; |
| 4 | import { parseProject } from '@/domain/model/schema'; |
| 5 | import { |
| 6 | createConflict, |
| 7 | createEmptyProject, |
| 8 | createPhase, |
| 9 | createSignalGroup, |
| 10 | createStandardIntersectionProject, |
| 11 | } from '@/domain/model/factory'; |
| 12 | import { buildSignalPlan } from '@/domain/plan/signalPlan'; |
| 13 | import { koordinierungslage } from '@/domain/plan/koordinierung'; |
| 14 | import { distributeGreenTimes } from '@/domain/rilsa/greenSplit'; |
| 15 | import * as actions from '@/app/actions'; |
| 16 | import type { SignalPlan } from '@/domain/plan/signalPlan'; |
| 17 | import type { Project } from '@/domain/model/project'; |
| 18 | |
| 19 | /** |
| 20 | * Gleichstand entscheidet nicht mehr die Reihenfolge. |
| 21 | * |
| 22 | * WORUM ES GEHT. Fassung 5.23.0 hat einen Fall behoben, in dem zwei in allem |
| 23 | * gleiche Richtungen einer Baustellenampel verschieden lange Freigabezeiten |
| 24 | * bekamen, weil bei gleichem Gewicht der Array-Index entschied. Die Frage |
| 25 | * danach war, ob es weitere solche Stellen gibt. Eine Suche ueber den Baum hat |
| 26 | * vier gefunden - alle von derselben Gestalt: ein Vergleich mit `>`, bei dem |
| 27 | * der Gleichstand still an die Feldreihenfolge faellt. Eine FUENFTE ist mit |
| 28 | * Fassung 5.43.0 dazugekommen (Abschnitt E): Die Suche vom |
| 29 | * 10.09.2026 konnte sie nicht finden, denn die Koordinierungskette ist erst mit |
| 30 | * 5.35.0 in den Baum gekommen. Diese Datei bewacht alle fuenf. |
| 31 | * |
| 32 | * DREI DER VIER SIND ENTSCHIEDEN, EINER IST AUSGESPROCHEN, und der Unterschied |
| 33 | * ist der Kern der Sache: |
| 34 | * |
| 35 | * A Uebernahme aus dem Lageplan (geometrie/ableitung.ts). Zwei Strompaare |
| 36 | * desselben Gruppenpaares runden auf dieselbe Zwischenzeit auf. Jetzt |
| 37 | * entscheidet der ungerundete Wert - eine fachliche Auskunft, die vorher |
| 38 | * ungenutzt blieb. |
| 39 | * |
| 40 | * B Massgebende Beziehung eines Phasenuebergangs (plan/signalPlan.ts). Erst |
| 41 | * die aufgerundete Zwischenzeit, dann der ungerundete Wert, dann der |
| 42 | * Raeumweg - und zuletzt der Name, der nichts bedeutet, sich aber nicht |
| 43 | * verschiebt, wenn jemand eine Signalgruppe neu zuordnet. |
| 44 | * |
| 45 | * C Warnung zur Hoechstfreigabezeit (plan/signalPlan.ts). Tragen zwei |
| 46 | * Signalgruppen derselben Phase dieselbe wirkungslose Vorgabe, werden jetzt |
| 47 | * beide genannt. Es gibt nichts zu waehlen - es waren nur nicht alle |
| 48 | * genannt. |
| 49 | * |
| 50 | * D Die zusaetzliche Sekunde beim Summenausgleich (rilsa/greenSplit.ts). Hier |
| 51 | * laesst sich nichts entscheiden: Ist die verfuegbare Zeit ungerade, MUSS |
| 52 | * eine von zwei gleichgestellten Phasen eine Sekunde mehr bekommen. Jede |
| 53 | * Regel dafuer waere so beliebig wie die jetzige. Geaendert ist, dass es |
| 54 | * dasteht. |
| 55 | * |
| 56 | * E Das Freigabefenster der koordinierten Hauptrichtung |
| 57 | * (plan/koordinierung.ts, Fassung 5.43.0). Hier GIBT es eine |
| 58 | * feinere fachliche Auskunft, und sie entscheidet jetzt: welches der beiden |
| 59 | * gleich langen Fenster ueberhaupt ein durchgehendes Band ergibt. Erst bei |
| 60 | * gleicher Bandbreite eine stabile Ordnung, und dass DIESE nichts bedeutet, |
| 61 | * steht im Quelltext. Die Ansicht meldet den Fall ausserdem. |
| 62 | * |
| 63 | * MUTATIONSNACHWEIS (10.09.2026, acht Mutationen an drei Dateien): Jede wird |
| 64 | * gefunden. Zwei Mutationen an der Uebernahme, zwei am Rang des Uebergangs, |
| 65 | * zwei an der Warnung, zwei an der Restsekunde - je einer bis zwei rote Faelle. |
| 66 | * |
| 67 | * EINE BLIEB IM ERSTEN ANLAUF GRUEN, und das gehoert hierher: Laesst man den |
| 68 | * Rang des Uebergangs auf die aufgerundete Zwischenzeit zusammenfallen, meldete |
| 69 | * sich nichts. Der Fall an der Beispieldatei prueft die Regel dort naemlich |
| 70 | * nicht - das Umdrehen der Gruppenzuordnung kehrt die Reihenfolge der beiden |
| 71 | * gleichauf liegenden Paare gar nicht um. Erst der eigens gebaute Fall mit zwei |
| 72 | * Beziehungen (4,2 s und 4,8 s, beide 5 s) hat Zaehne. |
| 73 | */ |
| 74 | |
| 75 | const DATEI = 'beispiele/vierarmiger-knotenpunkt.lsap'; |
| 76 | const DATUM = new Date('2026-01-01T00:00:00Z'); |
| 77 | |
| 78 | function beispiel(): Project { |
| 79 | return parseProject(JSON.parse(readFileSync(DATEI, 'utf8')), DATUM).project; |
| 80 | } |
| 81 | |
| 82 | /** Dieselbe Datei nach "Ausgewählte übernehmen" ueber alle Beziehungen. */ |
| 83 | function nachUebernahme(basis: Project): Project { |
| 84 | const ableitung = leiteAb(basis, basis.lageplan); |
| 85 | return uebernimmWege(basis, basis.lageplan, ableitung, ableitung.wege.map(wegSchluessel), DATUM) |
| 86 | .project; |
| 87 | } |
| 88 | |
| 89 | /** Dasselbe Projekt mit umgekehrter Zeichenreihenfolge der Fahrlinien. */ |
| 90 | function mitUmgekehrtenLinien(basis: Project): Project { |
| 91 | return { |
| 92 | ...basis, |
| 93 | lageplan: { ...basis.lageplan, linien: [...basis.lageplan.linien].reverse() }, |
| 94 | }; |
| 95 | } |
| 96 | |
| 97 | /** Die uebernommene Geometrie je Beziehung, in vergleichbarer Form. */ |
| 98 | function geometrie(project: Project): string[] { |
| 99 | return project.conflicts |
| 100 | .map( |
| 101 | (c) => |
| 102 | `${c.fromId}->${c.toId} sr=${c.clearingDistance.toFixed(3)} se=${c.enteringDistance.toFixed(3)} rb=${String(c.raeumbeziehung)}`, |
| 103 | ) |
| 104 | .sort(); |
| 105 | } |
| 106 | |
| 107 | describe('A: Die Uebernahme aus dem Lageplan haengt nicht an der Zeichenreihenfolge', () => { |
| 108 | it('liefert dieselbe Geometrie, wenn die Fahrlinien umgekehrt gezeichnet sind', () => { |
| 109 | /* |
| 110 | * DER EIGENTLICHE FALL. Bis 5.23.0 gewann bei gleicher aufgerundeter |
| 111 | * Zwischenzeit der zuerst gezeichnete Strom, und mit ihm wurden sein |
| 112 | * Raeumweg, sein Einfahrweg und seine Fahrbeziehung gespeichert. Wer |
| 113 | * dieselbe Oertlichkeit in anderer Reihenfolge zeichnete, bekam eine |
| 114 | * andere Projektdatei und eine andere gedruckte Konflikttabelle. |
| 115 | */ |
| 116 | const vorwaerts = nachUebernahme(beispiel()); |
| 117 | const rueckwaerts = nachUebernahme(mitUmgekehrtenLinien(beispiel())); |
| 118 | expect(geometrie(rueckwaerts)).toEqual(geometrie(vorwaerts)); |
| 119 | }); |
| 120 | |
| 121 | it('nimmt bei gleicher Zwischenzeit den anspruchsvolleren Strom', () => { |
| 122 | /* |
| 123 | * Die eigene Furt: "Nord geradeaus" fordert roh 4,100 s (bis 5.42.1 |
| 124 | * 4,013 s; seither faehrt der Fussgaenger mit te = 0 s ein), "Nord rechts" |
| 125 | * fordert 4,591 s, beide runden auf 5 s. Gespeichert wird seit 5.24.0 der |
| 126 | * Rechtsabbieger - der Strom, der massgebend BLEIBT, wenn sich Gelbzeit |
| 127 | * oder zulaessige Hoechstgeschwindigkeit spaeter aendern. Die gedruckte |
| 128 | * Zwischenzeit ist in beiden Faellen dieselbe. |
| 129 | */ |
| 130 | const project = nachUebernahme(beispiel()); |
| 131 | const furt = project.conflicts.find((c) => c.fromId === 'sg-k-nord' && c.toId === 'sg-f-nord'); |
| 132 | expect(furt, 'Beziehung K Nord -> F Nord').toBeDefined(); |
| 133 | expect(furt!.raeumbeziehung, 'der Rechtsabbieger gibt den Ausschlag').toBe('rechts'); |
| 134 | const plan = buildSignalPlan(project); |
| 135 | expect( |
| 136 | [...plan.intergreens.values()].find((i) => i.fromId === 'sg-k-nord' && i.toId === 'sg-f-nord') |
| 137 | ?.value, |
| 138 | 'die gedruckte Zahl bleibt dieselbe', |
| 139 | ).toBe(5); |
| 140 | }); |
| 141 | }); |
| 142 | |
| 143 | describe('B: Die massgebende Beziehung eines Uebergangs haengt nicht an der Gruppenreihenfolge', () => { |
| 144 | /** Dasselbe Projekt mit umgekehrter Zuordnung der Gruppen in jeder Phase. */ |
| 145 | function mitUmgekehrtenGruppen(basis: Project): Project { |
| 146 | return { |
| 147 | ...basis, |
| 148 | phases: basis.phases.map((p) => ({ |
| 149 | ...p, |
| 150 | signalGroupIds: [...p.signalGroupIds].reverse(), |
| 151 | })), |
| 152 | }; |
| 153 | } |
| 154 | |
| 155 | const beschriftung = (project: Project): string[] => |
| 156 | buildSignalPlan(project).transitions.map( |
| 157 | (t) => |
| 158 | `${t.duration}:${t.critical === null ? '-' : `${t.critical.fromGroupName}->${t.critical.toGroupName}`}`, |
| 159 | ); |
| 160 | |
| 161 | it('nennt dieselbe Beziehung, wenn die Signalgruppen der Phase umgekehrt zugeordnet sind', () => { |
| 162 | /* |
| 163 | * Nachgestellt aus der Bedienung: In der Phasenansicht eine Signalgruppe |
| 164 | * abwaehlen und wieder anwaehlen haengt sie hinten an. Bis 5.23.0 stand |
| 165 | * danach in der Uebergangstabelle der Planunterlage ein anderes Paar, bei |
| 166 | * Byte fuer Byte demselben Plan. |
| 167 | */ |
| 168 | const basis = beispiel(); |
| 169 | expect(beschriftung(mitUmgekehrtenGruppen(basis))).toEqual(beschriftung(basis)); |
| 170 | }); |
| 171 | |
| 172 | /** |
| 173 | * Zwei Beziehungen mit derselben gedruckten Zwischenzeit und verschiedenem |
| 174 | * ungerundeten Wert - eigens dafuer gebaut. |
| 175 | * |
| 176 | * WARUM ES DIESEN FALL BRAUCHT. Der Fall darueber laeuft an der |
| 177 | * Beispieldatei, und dort war er ZAHNLOS: Eine Mutation, die den Rang auf |
| 178 | * die aufgerundete Zwischenzeit zusammenfallen laesst, blieb gruen. Am |
| 179 | * Beispielknotenpunkt kehrt das Umdrehen der Gruppenzuordnung die |
| 180 | * Reihenfolge der beiden gleichauf liegenden Paare gar nicht um - der Fall |
| 181 | * prueft dort also die Regel nicht, sondern eine Zufaelligkeit. Hier stehen |
| 182 | * genau zwei Paare, und ihre Reihenfolge kehrt sich wirklich um. |
| 183 | * |
| 184 | * tue = 3 s, vr = 10 m/s, Pkw-Zuschlag 6 m, kein Einfahrweg: |
| 185 | * 6 m ergeben roh 4,2 s, 12 m ergeben roh 4,8 s - beide 5 s. |
| 186 | */ |
| 187 | function zweiGleichauf(raeumwege: readonly [number, number]): Project { |
| 188 | const basis = createEmptyProject('Gleichauf', DATUM, 'knotenpunkt'); |
| 189 | const gruppe = (name: string, index: number) => |
| 190 | createSignalGroup({ name, mode: 'kfz' as const, vZul: 50, index }); |
| 191 | const a = gruppe('A', 0); |
| 192 | const b = gruppe('B', 1); |
| 193 | const c = gruppe('C', 2); |
| 194 | const d = gruppe('D', 3); |
| 195 | const p1 = createPhase('Erste', [a.id, b.id]); |
| 196 | const p2 = createPhase('Zweite', [c.id, d.id]); |
| 197 | const beziehung = (von: string, nach: string, weg: number) => ({ |
| 198 | ...createConflict(von, nach), |
| 199 | clearingDistance: weg, |
| 200 | enteringDistance: 0, |
| 201 | }); |
| 202 | return { |
| 203 | ...basis, |
| 204 | signalGroups: [a, b, c, d], |
| 205 | phases: [p1, p2], |
| 206 | conflicts: [ |
| 207 | beziehung(a.id, c.id, raeumwege[0]), |
| 208 | beziehung(b.id, d.id, raeumwege[1]), |
| 209 | beziehung(c.id, a.id, raeumwege[0]), |
| 210 | beziehung(d.id, b.id, raeumwege[1]), |
| 211 | ], |
| 212 | program: { ...basis.program, phaseOrder: [p1.id, p2.id] }, |
| 213 | }; |
| 214 | } |
| 215 | |
| 216 | it('waehlt bei gleicher gedruckter Zwischenzeit die Beziehung mit dem groesseren Rohwert', () => { |
| 217 | const plan = buildSignalPlan(zweiGleichauf([6, 12])); |
| 218 | const erster = plan.transitions[0]; |
| 219 | expect(erster, 'erster Uebergang').toBeDefined(); |
| 220 | expect(erster!.duration, 'beide runden auf 5 s').toBe(5); |
| 221 | expect( |
| 222 | [...plan.intergreens.values()].map((i) => i.value).every((v) => v === 5), |
| 223 | 'wirklich Gleichstand in der gedruckten Zahl', |
| 224 | ).toBe(true); |
| 225 | // B -> D fordert roh 4,8 s, A -> C nur 4,2 s. |
| 226 | expect(erster!.critical?.fromGroupName).toBe('B'); |
| 227 | expect(erster!.critical?.toGroupName).toBe('D'); |
| 228 | }); |
| 229 | |
| 230 | it('waehlt sie auch, wenn die Gruppen umgekehrt zugeordnet sind', () => { |
| 231 | // Ohne den Rang entschiede hier die Reihenfolge, und die Antwort kippte. |
| 232 | const basis = zweiGleichauf([6, 12]); |
| 233 | expect(beschriftung(mitUmgekehrtenGruppen(basis))).toEqual(beschriftung(basis)); |
| 234 | expect(beschriftung(basis)[0]).toContain('B->D'); |
| 235 | }); |
| 236 | |
| 237 | it('kehrt die Antwort mit dem Rohwert um, wenn die Wege getauscht sind', () => { |
| 238 | // Die Gegenprobe zum Fall darueber: Nicht der Name "B" gewinnt, sondern |
| 239 | // der groessere Rohwert - sonst pruefte der Fall nur eine Konstante. |
| 240 | const plan = buildSignalPlan(zweiGleichauf([12, 6])); |
| 241 | expect(plan.transitions[0]?.critical?.fromGroupName).toBe('A'); |
| 242 | expect(plan.transitions[0]?.critical?.toGroupName).toBe('C'); |
| 243 | }); |
| 244 | |
| 245 | it('bleibt die Dauer der Uebergaenge davon ohnehin unberuehrt', () => { |
| 246 | // Die Gegenprobe: Der Fall oben waere auch dann gruen, wenn die Uebergaenge |
| 247 | // gar keine Beschriftung mehr traegen. Hier steht, dass es sie gibt. |
| 248 | const plan = buildSignalPlan(beispiel()); |
| 249 | expect(plan.transitions.length).toBeGreaterThan(0); |
| 250 | expect(plan.transitions.filter((t) => t.critical !== null).length).toBe( |
| 251 | plan.transitions.length, |
| 252 | ); |
| 253 | expect(plan.cycleTime).toBe(100); |
| 254 | }); |
| 255 | |
| 256 | it('meldet keine massgebende Beziehung, wo alle Zwischenzeiten 0 s sind', () => { |
| 257 | /* |
| 258 | * `critical === null` heisst "keine Beziehung mit einer Zwischenzeit ueber |
| 259 | * 0 s"; daran haengen die Uebergangszeile der Ansicht und der Fehler |
| 260 | * zwischenzeiten.uebergang-ungesichert. Beim Umbau auf den Rang ist diese |
| 261 | * Bedingung einmal herausgefallen - drei Wachen haben es gemeldet, und |
| 262 | * dieser Fall haelt sie an Ort und Stelle fest. |
| 263 | */ |
| 264 | const basis = createStandardIntersectionProject('Null', DATUM); |
| 265 | const ohneWege: Project = { |
| 266 | ...basis, |
| 267 | conflicts: basis.conflicts.map((c) => ({ |
| 268 | ...c, |
| 269 | clearingDistance: 0, |
| 270 | enteringDistance: 0, |
| 271 | manualIntergreen: 0, |
| 272 | })), |
| 273 | }; |
| 274 | const plan = buildSignalPlan(ohneWege); |
| 275 | expect([...plan.intergreens.values()].every((i) => i.value === 0)).toBe(true); |
| 276 | expect(plan.transitions.every((t) => t.critical === null)).toBe(true); |
| 277 | }); |
| 278 | }); |
| 279 | |
| 280 | describe('C: Die Warnung zur Hoechstfreigabezeit nennt jede betroffene Signalgruppe', () => { |
| 281 | /** Standardvorlage mit derselben Vorgabe an mehreren Gruppen der ersten Phase. */ |
| 282 | function mitVorgabe(sekunden: number, gruppen: readonly string[]): Project { |
| 283 | const basis = createStandardIntersectionProject('Gleichstand', DATUM); |
| 284 | return { |
| 285 | ...basis, |
| 286 | signalGroups: basis.signalGroups.map((g) => |
| 287 | gruppen.includes(g.name) ? { ...g, maxGreenOverride: sekunden } : g, |
| 288 | ), |
| 289 | }; |
| 290 | } |
| 291 | |
| 292 | const meldung = (project: Project): string => { |
| 293 | const notiz = buildSignalPlan(project).notes.find( |
| 294 | (n) => n.code === 'hoechstfreigabezeit-unter-mindestfreigabezeit', |
| 295 | ); |
| 296 | expect(notiz, 'Warnung zur Hoechstfreigabezeit').toBeDefined(); |
| 297 | return notiz!.message; |
| 298 | }; |
| 299 | |
| 300 | it('nennt beide Gruppen, wo beide dieselbe wirkungslose Vorgabe tragen', () => { |
| 301 | const text = meldung(mitVorgabe(3, ['K1', 'K2'])); |
| 302 | expect(text, 'die erste Gruppe').toContain('"K1"'); |
| 303 | expect(text, 'die zweite ebenso').toContain('"K2"'); |
| 304 | expect(text, 'im Plural').toContain('Für die Signalgruppen'); |
| 305 | }); |
| 306 | |
| 307 | it('bleibt bei einer Gruppe im Singular', () => { |
| 308 | // Gegenprobe: Der Wortlaut des Regelfalls darf sich nicht geaendert haben. |
| 309 | const text = meldung(mitVorgabe(3, ['K1'])); |
| 310 | expect(text).toContain('Für die Signalgruppe "K1" ist'); |
| 311 | expect(text).not.toContain('Signalgruppen'); |
| 312 | }); |
| 313 | |
| 314 | it('sagt dasselbe, wenn die Gruppen der Phase umgekehrt zugeordnet sind', () => { |
| 315 | const basis = mitVorgabe(3, ['K1', 'K2']); |
| 316 | const umgekehrt: Project = { |
| 317 | ...basis, |
| 318 | phases: basis.phases.map((p) => ({ |
| 319 | ...p, |
| 320 | signalGroupIds: [...p.signalGroupIds].reverse(), |
| 321 | })), |
| 322 | }; |
| 323 | // Bis 5.23.0 wechselte hier der genannte Name, bei unveraendertem Plan. |
| 324 | expect(meldung(umgekehrt)).toBe(meldung(basis)); |
| 325 | }); |
| 326 | }); |
| 327 | |
| 328 | describe('D: Die zusaetzliche Sekunde bei gleichgestellten Phasen steht in der Unterlage', () => { |
| 329 | /** Zwei in allem gleiche Richtungen, je 7 s Uebergang. */ |
| 330 | const zweiGleiche = (umlauf: number, gewicht = 0) => |
| 331 | distributeGreenTimes( |
| 332 | umlauf, |
| 333 | [ |
| 334 | { id: 'a', weight: gewicht, minGreen: 5 }, |
| 335 | { id: 'b', weight: gewicht, minGreen: 5 }, |
| 336 | ], |
| 337 | [7, 7], |
| 338 | ); |
| 339 | |
| 340 | const restsekunde = (umlauf: number, gewicht = 0) => |
| 341 | zweiGleiche(umlauf, gewicht).notes.find((n) => n.code === 'freigabezeit-restsekunde'); |
| 342 | |
| 343 | it('meldet sich, wo die verfuegbare Zeit nicht aufgeht', () => { |
| 344 | const ergebnis = zweiGleiche(99); |
| 345 | expect( |
| 346 | ergebnis.phases.map((p) => p.green), |
| 347 | 'eine Sekunde Unterschied', |
| 348 | ).toEqual([43, 42]); |
| 349 | const notiz = restsekunde(99); |
| 350 | expect(notiz, 'die Meldung zur Restsekunde').toBeDefined(); |
| 351 | expect(notiz!.severity).toBe('hinweis'); |
| 352 | expect(notiz!.message).toContain('85 s lässt sich nicht ganzzahlig auf 2 Phasen aufteilen'); |
| 353 | expect(notiz!.message, 'wer sie bekommt').toContain( |
| 354 | 'Je eine zusätzliche Sekunde geht an Phase 1', |
| 355 | ); |
| 356 | expect(notiz!.message, 'und dass es dafuer keinen Grund gibt').toContain( |
| 357 | 'Ein sachlicher Grund für diese Auswahl besteht nicht', |
| 358 | ); |
| 359 | }); |
| 360 | |
| 361 | it('meldet sich auch bei erfassten, gleich grossen Verkehrsstaerken', () => { |
| 362 | // Der Fall, der bis 5.23.0 GAR KEINE Meldung trug: Zwei Phasen mit |
| 363 | // demselben Saettigungsgrad, 43 s und 42 s im Plan, kein Wort dazu. |
| 364 | expect(zweiGleiche(99, 0.4).phases.map((p) => p.green)).toEqual([43, 42]); |
| 365 | expect(restsekunde(99, 0.4), 'auch hier').toBeDefined(); |
| 366 | }); |
| 367 | |
| 368 | it('schweigt, wo die Zeit aufgeht', () => { |
| 369 | expect(zweiGleiche(100).phases.map((p) => p.green)).toEqual([43, 43]); |
| 370 | expect(restsekunde(100), 'ohne Rest kein Hinweis').toBeUndefined(); |
| 371 | }); |
| 372 | |
| 373 | it('schweigt, wo der Unterschied begruendet ist', () => { |
| 374 | /* |
| 375 | * Die wichtigste Gegenprobe: Verschiedene Verkehrsstaerken ergeben |
| 376 | * verschiedene Freigabezeiten, und daran ist nichts zu erklaeren. Eine |
| 377 | * Meldung hier waere Laerm. |
| 378 | */ |
| 379 | const ergebnis = distributeGreenTimes( |
| 380 | 99, |
| 381 | [ |
| 382 | { id: 'a', weight: 0.6, minGreen: 5 }, |
| 383 | { id: 'b', weight: 0.2, minGreen: 5 }, |
| 384 | ], |
| 385 | [7, 7], |
| 386 | ); |
| 387 | expect(ergebnis.notes.map((n) => n.code)).not.toContain('freigabezeit-restsekunde'); |
| 388 | }); |
| 389 | }); |
| 390 | |
| 391 | /* |
| 392 | * E: Das Freigabefenster der koordinierten Hauptrichtung (plan/koordinierung.ts). |
| 393 | * |
| 394 | * DIE FUENFTE STELLE, behoben mit Fassung 5.43.0. Der Kopf |
| 395 | * dieser Datei nannte vier; eine hatte die Suche vom 10.09.2026 nicht gesehen, |
| 396 | * weil die Koordinierungskette erst mit 5.35.0 dazugekommen ist. |
| 397 | * |
| 398 | * Eine Signalgruppe kann in getrennten Phasen freigegeben sein. Fuer die Gruene |
| 399 | * Welle traegt das laengste Fenster - durch das kuerzere passt der Pulk nicht -, |
| 400 | * und `fenster` suchte es mit `g.duration > laengstes.duration`. Sind zwei |
| 401 | * Fenster gleich lang, gewinnt damit das, das in `greens` zuerst steht, also die |
| 402 | * Phasenreihenfolge. Das ist genau die Gestalt der vier Faelle darueber. |
| 403 | * |
| 404 | * ES GIBT HIER EINE FEINERE FACHLICHE AUSKUNFT, und der Grundsatz verlangt, zuerst |
| 405 | * nach ihr zu suchen: WELCHES DER BEIDEN FENSTER EIN BAND ERGIBT. Am |
| 406 | * Knotenpunkt dieses Abschnitts ergibt das eine 9 s Bandbreite, das andere 0 s |
| 407 | * und den Fehler "keine durchgehende Bandbreite" - fuer denselben Plan. Die |
| 408 | * Wahl wird deshalb am Band getroffen; die stabile Ordnung greift erst bei |
| 409 | * gleicher Bandbreite, und dass SIE nichts bedeutet, steht im Quelltext daneben. |
| 410 | * |
| 411 | * BERICHTIGT (Fassung 5.43.0): Hier stand "ES GIBT HIER KEINE FEINERE FACHLICHE |
| 412 | * AUSKUNFT ... die Bandbreite bleibt dieselbe", und die beiden Groessen waren |
| 413 | * vertauscht. Die Bandbreite haengt an der Wahl, die Versatzzeitempfehlungen |
| 414 | * haengen nicht daran: Sie entstehen in `coordination.ts` allein aus Abstand |
| 415 | * und Progressionsgeschwindigkeit, die Bandbreite dagegen als Schnittmenge |
| 416 | * aller Fenster - und die der Nachbarn sind Eingabedaten, die sich nicht |
| 417 | * mitverschieben. Der Fall darunter, der das belegt, bewachte deshalb genau die |
| 418 | * Groesse, die von der Wahl unberuehrt ist. |
| 419 | */ |
| 420 | describe('E: Das Freigabefenster der Hauptrichtung haengt nicht an der Fensterreihenfolge', () => { |
| 421 | /** |
| 422 | * Vier Phasen, die Hauptrichtung in der ersten und der dritten - zwei |
| 423 | * getrennte Fenster gleicher Laenge. |
| 424 | * |
| 425 | * `nachbar` legt fest, ab wann der Nachbar in 200 m freigibt, und damit, |
| 426 | * WELCHES der beiden Fenster ein Band traegt: bei 20 s das im Umlauf fruehere, |
| 427 | * bei 50 s das spaetere. Beides wird geprueft - sonst waere "das mit dem Band" |
| 428 | * von "immer das fruehere" nicht zu unterscheiden. |
| 429 | * |
| 430 | * `gegenrichtung` haengt der Kette die Rueckrichtung an (Signalgruppe B, frei |
| 431 | * in der zweiten und vierten Phase, ebenfalls zwei gleich lange Fenster). |
| 432 | */ |
| 433 | function zweiFenster( |
| 434 | nachbar = 20, |
| 435 | gegenrichtung: { gegenBeginn: number; gegenDauer: number } | null = null, |
| 436 | ): Project { |
| 437 | const basis = createEmptyProject('Zwei Fenster', DATUM, 'knotenpunkt'); |
| 438 | const a = createSignalGroup({ name: 'A', mode: 'kfz' as const, vZul: 50, index: 0 }); |
| 439 | const b = createSignalGroup({ name: 'B', mode: 'kfz' as const, vZul: 50, index: 1 }); |
| 440 | const phasen = [ |
| 441 | createPhase('P1', [a.id]), |
| 442 | createPhase('P2', [b.id]), |
| 443 | createPhase('P3', [a.id]), |
| 444 | createPhase('P4', [b.id]), |
| 445 | ]; |
| 446 | const beziehung = (von: string, nach: string) => ({ |
| 447 | ...createConflict(von, nach), |
| 448 | clearingDistance: 20, |
| 449 | enteringDistance: 0, |
| 450 | }); |
| 451 | let projekt: Project = { |
| 452 | ...basis, |
| 453 | signalGroups: [a, b], |
| 454 | phases: phasen, |
| 455 | conflicts: [beziehung(a.id, b.id), beziehung(b.id, a.id)], |
| 456 | program: { |
| 457 | ...basis.program, |
| 458 | method: 'manuell', |
| 459 | manualCycleTime: 60, |
| 460 | phaseOrder: phasen.map((p) => p.id), |
| 461 | }, |
| 462 | }; |
| 463 | projekt = actions.setzeKoordinierung(projekt, { |
| 464 | hauptrichtung: a.id, |
| 465 | ...(gegenrichtung === null ? {} : { gegenrichtung: b.id }), |
| 466 | progressionsgeschwindigkeit: 36, |
| 467 | platz: 0, |
| 468 | }); |
| 469 | projekt = actions.koordinierungsknotenAnlegen(projekt, 'West'); |
| 470 | const west = projekt.koordinierung!.nachbarn[0]!; |
| 471 | return actions.koordinierungsknotenAendern(projekt, west.id, { |
| 472 | abstand: 200, |
| 473 | freigabeBeginn: nachbar, |
| 474 | freigabeDauer: 12, |
| 475 | ...(gegenrichtung ?? {}), |
| 476 | }); |
| 477 | } |
| 478 | |
| 479 | /** Nur ein Fenster einer Signalgruppe im Plan lassen - fuer die Messung. */ |
| 480 | function nurFenster(plan: SignalPlan, groupId: string, index: number): SignalPlan { |
| 481 | return { |
| 482 | ...plan, |
| 483 | groups: plan.groups.map((g) => |
| 484 | g.groupId === groupId ? { ...g, greens: g.greens.slice(index, index + 1) } : g, |
| 485 | ), |
| 486 | }; |
| 487 | } |
| 488 | |
| 489 | /** Das gewaehlte Freigabefenster dieses Knotenpunkts. */ |
| 490 | function eigenes(projekt: Project, plan: SignalPlan) { |
| 491 | const lage = koordinierungslage(projekt, plan); |
| 492 | const glied = lage.kette.find((k) => k.eigener); |
| 493 | return { |
| 494 | freigabe: glied?.freigabe, |
| 495 | gegenrichtung: glied?.gegenrichtung, |
| 496 | bandbreite: lage.ergebnis?.bandwidth, |
| 497 | rueckbreite: lage.ergebnis?.rueckrichtung?.breite, |
| 498 | meldungen: lage.ergebnis?.notes.map((n) => n.code) ?? [], |
| 499 | versatz: lage.ergebnis?.intersections, |
| 500 | }; |
| 501 | } |
| 502 | |
| 503 | /** Derselbe Plan mit umgekehrter Reihenfolge der Freigabefenster. */ |
| 504 | function mitUmgekehrtenFenstern(plan: SignalPlan, groupId: string): SignalPlan { |
| 505 | return { |
| 506 | ...plan, |
| 507 | groups: plan.groups.map((g) => |
| 508 | g.groupId === groupId ? { ...g, greens: [...g.greens].reverse() } : g, |
| 509 | ), |
| 510 | }; |
| 511 | } |
| 512 | |
| 513 | it('baut wirklich zwei gleich lange Fenster', () => { |
| 514 | // Vorbedingung des Falls darunter, und zugleich der Beleg, dass die Lage |
| 515 | // aus gewoehnlicher Eingabe entsteht und nicht von Hand gebaut ist. |
| 516 | const projekt = zweiFenster(); |
| 517 | const gruppe = buildSignalPlan(projekt).groups.find( |
| 518 | (g) => g.groupId === projekt.koordinierung!.hauptrichtung, |
| 519 | ); |
| 520 | expect(gruppe?.greens.map((g) => g.duration)).toEqual([9, 9]); |
| 521 | expect(gruppe?.greens.map((g) => g.start)).toEqual([0, 30]); |
| 522 | }); |
| 523 | |
| 524 | it('die beiden Fenster sind fuer das Band nicht gleichwertig', () => { |
| 525 | /* |
| 526 | * DER BELEG, DASS ES EINE FEINERE AUSKUNFT GIBT, und die Messung, auf die |
| 527 | * sich der Kommentar in koordinierung.ts beruft. Ohne diesen Fall waere die |
| 528 | * Begruendung der Wahl wieder eine Behauptung - genau der Befund, aus dem |
| 529 | * diese Berichtigung entstanden ist. |
| 530 | */ |
| 531 | const projekt = zweiFenster(); |
| 532 | const haupt = projekt.koordinierung!.hauptrichtung!; |
| 533 | const plan = buildSignalPlan(projekt); |
| 534 | const mitErstem = eigenes(projekt, nurFenster(plan, haupt, 0)); |
| 535 | const mitZweitem = eigenes(projekt, nurFenster(plan, haupt, 1)); |
| 536 | |
| 537 | expect(mitErstem.bandbreite, 'das Fenster ab 0 s traegt ein Band').toBe(9); |
| 538 | expect(mitZweitem.bandbreite, 'das Fenster ab 30 s traegt keines').toBe(0); |
| 539 | expect(mitZweitem.meldungen, 'und meldet dafuer einen Fehler').toContain('keine-bandbreite'); |
| 540 | /* |
| 541 | * Die andere Haelfte der berichtigten Begruendung: Die Versatzzeiten |
| 542 | * beruehrt die Wahl NICHT. Sie entstehen aus Abstand und |
| 543 | * Progressionsgeschwindigkeit. Dieser Fall haelt fest, warum die Wahl am |
| 544 | * Band zu treffen ist und nicht an ihnen. |
| 545 | */ |
| 546 | expect(mitZweitem.versatz, 'die Versatzzeiten haengen nicht am Fenster').toEqual( |
| 547 | mitErstem.versatz, |
| 548 | ); |
| 549 | }); |
| 550 | |
| 551 | it('waehlt das Fenster mit dem Band, gleich wie die beiden im Plan stehen', () => { |
| 552 | const projekt = zweiFenster(); |
| 553 | const haupt = projekt.koordinierung!.hauptrichtung!; |
| 554 | const plan = buildSignalPlan(projekt); |
| 555 | |
| 556 | for (const [wie, p] of [ |
| 557 | ['wie gebaut', plan], |
| 558 | ['umgekehrt', mitUmgekehrtenFenstern(plan, haupt)], |
| 559 | ] as const) { |
| 560 | const gewaehlt = eigenes(projekt, p); |
| 561 | expect(gewaehlt.freigabe, wie).toEqual({ start: 0, duration: 9 }); |
| 562 | expect(gewaehlt.bandbreite, wie).toBe(9); |
| 563 | expect(gewaehlt.meldungen, wie).not.toContain('keine-bandbreite'); |
| 564 | } |
| 565 | }); |
| 566 | |
| 567 | it('nimmt dafuer auch das spaetere Fenster, wenn dieses das Band traegt', () => { |
| 568 | /* |
| 569 | * DIE GEGENPROBE ZUR STABILEN ORDNUNG: Gibt der Nachbar erst ab 50 s frei, |
| 570 | * traegt das Fenster ab 30 s das Band und das ab 0 s keines. Ohne diesen |
| 571 | * Fall waere "das Fenster mit dem Band" von "immer das im Umlauf fruehere" |
| 572 | * nicht zu unterscheiden - und genau diese Verwechslung war der Stand vor |
| 573 | * der Berichtigung. |
| 574 | */ |
| 575 | const projekt = zweiFenster(50); |
| 576 | const haupt = projekt.koordinierung!.hauptrichtung!; |
| 577 | const plan = buildSignalPlan(projekt); |
| 578 | |
| 579 | expect(eigenes(projekt, nurFenster(plan, haupt, 0)).bandbreite, 'Vorbedingung').toBe(0); |
| 580 | for (const p of [plan, mitUmgekehrtenFenstern(plan, haupt)]) { |
| 581 | const gewaehlt = eigenes(projekt, p); |
| 582 | expect(gewaehlt.freigabe).toEqual({ start: 30, duration: 9 }); |
| 583 | expect(gewaehlt.bandbreite).toBe(9); |
| 584 | } |
| 585 | }); |
| 586 | |
| 587 | it('waehlt das Fenster der Gegenrichtung an deren eigenem Band', () => { |
| 588 | /* |
| 589 | * DIESELBE STELLE, ZWEITE RICHTUNG: `fenster` hat die Gegenrichtung mit |
| 590 | * demselben Vergleich gesucht, also mit demselben Gleichstand. Hier traegt |
| 591 | * das im Umlauf SPAETERE der beiden Fenster das Rueckband - waehlte die |
| 592 | * Reihenfolge, entstuende die Warnung "kein durchgehendes Band in der |
| 593 | * Gegenrichtung" fuer einen Plan, der eines hat. |
| 594 | */ |
| 595 | const projekt = zweiFenster(20, { gegenBeginn: 26, gegenDauer: 12 }); |
| 596 | const gegen = projekt.koordinierung!.gegenrichtung!; |
| 597 | const plan = buildSignalPlan(projekt); |
| 598 | expect( |
| 599 | plan.groups.find((g) => g.groupId === gegen)?.greens, |
| 600 | 'Vorbedingung: zwei gleich lange Fenster der Gegenrichtung', |
| 601 | ).toEqual([ |
| 602 | { start: 15, duration: 9 }, |
| 603 | { start: 45, duration: 9 }, |
| 604 | ]); |
| 605 | |
| 606 | const gewaehlt = eigenes(projekt, plan); |
| 607 | expect(gewaehlt.gegenrichtung).toEqual({ start: 45, duration: 9 }); |
| 608 | expect(gewaehlt.rueckbreite).toBe(8); |
| 609 | expect(gewaehlt.meldungen).not.toContain('keine-bandbreite-gegenrichtung'); |
| 610 | // Und die Hinrichtung bleibt davon unberuehrt. |
| 611 | expect(gewaehlt.freigabe).toEqual({ start: 0, duration: 9 }); |
| 612 | }); |
| 613 | |
| 614 | it('nimmt weiterhin das laengere Fenster, wo es eines gibt', () => { |
| 615 | /* |
| 616 | * Die Gegenprobe: Nicht das fruehere gewinnt, sondern das laengere. Ohne |
| 617 | * diesen Fall waere der vorige auch mit "immer das erste" gruen. |
| 618 | */ |
| 619 | const projekt = zweiFenster(); |
| 620 | const haupt = projekt.koordinierung!.hauptrichtung!; |
| 621 | const plan = buildSignalPlan(projekt); |
| 622 | const verlaengert: SignalPlan = { |
| 623 | ...plan, |
| 624 | groups: plan.groups.map((g) => |
| 625 | g.groupId === haupt |
| 626 | ? { ...g, greens: [g.greens[0]!, { ...g.greens[1]!, duration: 12 }] } |
| 627 | : g, |
| 628 | ), |
| 629 | }; |
| 630 | expect(koordinierungslage(projekt, verlaengert).kette.find((k) => k.eigener)?.freigabe).toEqual( |
| 631 | { |
| 632 | start: 30, |
| 633 | duration: 12, |
| 634 | }, |
| 635 | ); |
| 636 | // Auch wenn es vorne steht. |
| 637 | expect( |
| 638 | koordinierungslage(projekt, mitUmgekehrtenFenstern(verlaengert, haupt)).kette.find( |
| 639 | (k) => k.eigener, |
| 640 | )?.freigabe, |
| 641 | ).toEqual({ start: 30, duration: 12 }); |
| 642 | }); |
| 643 | |
| 644 | it('sagt der Ansicht, dass es mehrere Fenster sind', () => { |
| 645 | /* |
| 646 | * Eine Auswahl, die niemand getroffen hat, gehoert ausgesprochen. Gemeldet |
| 647 | * wird, DASS es mehrere sind - nicht, welches gilt; der Hinweistext dazu |
| 648 | * steht in ui/views/koordinierungView.ts und nennt das gewaehlte Fenster |
| 649 | * bisher nicht (offener Punkt seit Fassung 5.43.0). |
| 650 | */ |
| 651 | const projekt = zweiFenster(); |
| 652 | expect(koordinierungslage(projekt, buildSignalPlan(projekt)).mehrereFreigabefenster).toBe(true); |
| 653 | }); |
| 654 | }); |