lsa-planer
LSA-Planer Professional – Planungssoftware für Lichtsignalanlagen nach RiLSA 2015 und § 45 StVO. EUPL-1.2.
/ tests ui fensterwahlDerKoordinierung.test.ts
| 1 | import { describe, expect, it } from 'vitest'; |
| 2 | import * as actions from '@/app/actions'; |
| 3 | import { ProjectStore } from '@/app/store'; |
| 4 | import { |
| 5 | createConflict, |
| 6 | createEmptyProject, |
| 7 | createPhase, |
| 8 | createSignalGroup, |
| 9 | createStandardIntersectionProject, |
| 10 | } from '@/domain/model/factory'; |
| 11 | import { koordinierungslage } from '@/domain/plan/koordinierung'; |
| 12 | import { buildSignalPlan } from '@/domain/plan/signalPlan'; |
| 13 | import { koordinierungView } from '@/ui/views/koordinierungView'; |
| 14 | import type { Project } from '@/domain/model/project'; |
| 15 | import type { SignalPlan } from '@/domain/plan/signalPlan'; |
| 16 | |
| 17 | /** |
| 18 | * Fassung 5.43.0: Was die Ansicht "Koordinierung" ueber die Wahl des |
| 19 | * Freigabefensters sagt. |
| 20 | * |
| 21 | * DER BEFUND. Der Hinweis unter der Beurteilung lautete: "Die Signalgruppe der |
| 22 | * Hauptrichtung ist in mehreren getrennten Fenstern freigegeben. Gerechnet wird |
| 23 | * mit dem laengsten - durch das kuerzere passt der Pulk nicht." Das war der |
| 24 | * ganze Satz, und er beschrieb die Rechnung nur zur Haelfte. Seit der Behebung |
| 25 | * des Gleichstands (Fall E in tests/domain/gleichstandOhneReihenfolge.test.ts) |
| 26 | * waehlt `koordinierungslage` unter mehreren GLEICH LANGEN Fenstern das mit der |
| 27 | * groesseren Bandbreite und erst bei gleicher Bandbreite das im Umlauf fruehere. |
| 28 | * Der Anwender las eine Auswahlregel, die es so nicht mehr gibt - und nach ihr |
| 29 | * waren zwei gleich lange Fenster gar nicht zu unterscheiden. |
| 30 | * |
| 31 | * WARUM DIE ANSICHT DIESEN SATZ UEBERHAUPT FUEHRT: Es ist eine Auswahl, die |
| 32 | * niemand getroffen hat. Ein Hinweis, der sie nennt, aber falsch erklaert, ist |
| 33 | * schlechter als keiner - der Anwender rechnet dann mit dem falschen Fenster |
| 34 | * nach und haelt das Ergebnis fuer einen Fehler des Programms. |
| 35 | * |
| 36 | * DIE FAELLE MESSEN DIE RECHNUNG MIT, nicht nur den Wortlaut: Ein Satz ueber |
| 37 | * eine Rechnung ist nur so lange richtig, wie die Rechnung ihn traegt. |
| 38 | */ |
| 39 | |
| 40 | const DATUM = new Date('2026-01-01T12:00:00Z'); |
| 41 | |
| 42 | /** Der alte Wortlaut - er darf nicht wieder allein dastehen. */ |
| 43 | const ALTER_SATZ = |
| 44 | 'Die Signalgruppe der Hauptrichtung ist in mehreren getrennten Fenstern freigegeben. ' + |
| 45 | 'Gerechnet wird mit dem längsten – durch das kürzere passt der Pulk nicht.'; |
| 46 | |
| 47 | /** |
| 48 | * Vier Phasen, die Hauptrichtung in der ersten und der dritten - zwei getrennte |
| 49 | * Fenster gleicher Laenge (Aufbau des Falls E, dort gemessen). |
| 50 | * |
| 51 | * `nachbarBeginn` und `nachbarDauer` legen fest, WELCHES der beiden Fenster ein |
| 52 | * Band traegt und ob beide eines tragen. |
| 53 | */ |
| 54 | function zweiFenster(nachbarBeginn = 20, nachbarDauer = 12): Project { |
| 55 | const basis = createEmptyProject('Zwei Fenster', DATUM, 'knotenpunkt'); |
| 56 | const a = createSignalGroup({ name: 'A', mode: 'kfz' as const, vZul: 50, index: 0 }); |
| 57 | const b = createSignalGroup({ name: 'B', mode: 'kfz' as const, vZul: 50, index: 1 }); |
| 58 | const phasen = [ |
| 59 | createPhase('P1', [a.id]), |
| 60 | createPhase('P2', [b.id]), |
| 61 | createPhase('P3', [a.id]), |
| 62 | createPhase('P4', [b.id]), |
| 63 | ]; |
| 64 | const beziehung = (von: string, nach: string) => ({ |
| 65 | ...createConflict(von, nach), |
| 66 | clearingDistance: 20, |
| 67 | enteringDistance: 0, |
| 68 | }); |
| 69 | let projekt: Project = { |
| 70 | ...basis, |
| 71 | signalGroups: [a, b], |
| 72 | phases: phasen, |
| 73 | conflicts: [beziehung(a.id, b.id), beziehung(b.id, a.id)], |
| 74 | program: { |
| 75 | ...basis.program, |
| 76 | method: 'manuell', |
| 77 | manualCycleTime: 60, |
| 78 | phaseOrder: phasen.map((p) => p.id), |
| 79 | }, |
| 80 | }; |
| 81 | projekt = actions.setzeKoordinierung(projekt, { |
| 82 | hauptrichtung: a.id, |
| 83 | progressionsgeschwindigkeit: 36, |
| 84 | platz: 0, |
| 85 | }); |
| 86 | projekt = actions.koordinierungsknotenAnlegen(projekt, 'West'); |
| 87 | const west = projekt.koordinierung!.nachbarn[0]!; |
| 88 | return actions.koordinierungsknotenAendern(projekt, west.id, { |
| 89 | abstand: 200, |
| 90 | freigabeBeginn: nachbarBeginn, |
| 91 | freigabeDauer: nachbarDauer, |
| 92 | }); |
| 93 | } |
| 94 | |
| 95 | /** Derselbe Plan mit umgekehrter Reihenfolge der Freigabefenster. */ |
| 96 | function mitUmgekehrtenFenstern(plan: SignalPlan, groupId: string): SignalPlan { |
| 97 | return { |
| 98 | ...plan, |
| 99 | groups: plan.groups.map((g) => |
| 100 | g.groupId === groupId ? { ...g, greens: [...g.greens].reverse() } : g, |
| 101 | ), |
| 102 | }; |
| 103 | } |
| 104 | |
| 105 | /** Nur ein Fenster einer Signalgruppe im Plan lassen - fuer die Messung. */ |
| 106 | function nurFenster(plan: SignalPlan, groupId: string, index: number): SignalPlan { |
| 107 | return { |
| 108 | ...plan, |
| 109 | groups: plan.groups.map((g) => |
| 110 | g.groupId === groupId ? { ...g, greens: g.greens.slice(index, index + 1) } : g, |
| 111 | ), |
| 112 | }; |
| 113 | } |
| 114 | |
| 115 | /** Alle Hinweistexte der Ansicht, zusammengefuegt wie sie dastehen. */ |
| 116 | function hinweise(projekt: Project): string[] { |
| 117 | const wurzel = document.createElement('div'); |
| 118 | document.body.append(wurzel); |
| 119 | const ende = koordinierungView.render(wurzel, { |
| 120 | store: new ProjectStore(projekt), |
| 121 | refresh: () => {}, |
| 122 | navigate: () => {}, |
| 123 | target: null, |
| 124 | }); |
| 125 | try { |
| 126 | return [...wurzel.querySelectorAll('.feld-hinweis')].map((p) => |
| 127 | (p.textContent ?? '').replace(/\s+/gu, ' ').trim(), |
| 128 | ); |
| 129 | } finally { |
| 130 | ende(); |
| 131 | wurzel.remove(); |
| 132 | } |
| 133 | } |
| 134 | |
| 135 | /** Der Hinweis zu den mehreren Freigabefenstern. */ |
| 136 | function fensterhinweis(projekt: Project): string { |
| 137 | const treffer = hinweise(projekt).filter((t) => t.includes('mehreren getrennten Fenstern')); |
| 138 | expect(treffer.length, 'der Hinweis zu den mehreren Fenstern steht genau einmal da').toBe(1); |
| 139 | return treffer[0]!; |
| 140 | } |
| 141 | |
| 142 | describe('Der Hinweis nennt die Auswahlregel, mit der gerechnet wird', () => { |
| 143 | it('Vorbedingung: zwei gleich lange Fenster, und die Ansicht meldet sie', () => { |
| 144 | const projekt = zweiFenster(); |
| 145 | const gruppe = buildSignalPlan(projekt).groups.find( |
| 146 | (g) => g.groupId === projekt.koordinierung!.hauptrichtung, |
| 147 | ); |
| 148 | expect( |
| 149 | gruppe?.greens.map((g) => g.duration), |
| 150 | 'gleich lang', |
| 151 | ).toEqual([9, 9]); |
| 152 | expect(fensterhinweis(projekt)).toContain('mehreren getrennten Fenstern'); |
| 153 | }); |
| 154 | |
| 155 | it('nennt die Bandbreite als das, was bei gleicher Laenge entscheidet', () => { |
| 156 | const text = fensterhinweis(zweiFenster()); |
| 157 | |
| 158 | expect(text, 'die Laenge bleibt die erste Stufe').toContain('längsten'); |
| 159 | expect(text, 'die zweite Stufe, die es seit 5.43.0 gibt').toContain('Bandbreite'); |
| 160 | expect(text, 'die dritte Stufe, die nichts bedeutet').toContain('im Umlauf frühere'); |
| 161 | expect(text, 'der halbe Satz von vorher').not.toBe(ALTER_SATZ); |
| 162 | }); |
| 163 | |
| 164 | it('sagt, dass die letzte Stufe nur festgelegt ist und nichts bedeutet', () => { |
| 165 | // Grundsatz: Wo eine stabile Ordnung entscheidet, gehoert dazugeschrieben, |
| 166 | // dass sie nichts bedeutet. Der Anwender soll aus dem frueheren Fenster |
| 167 | // keinen fachlichen Vorzug lesen. |
| 168 | expect(fensterhinweis(zweiFenster())).toContain('Reihenfolge der Phasen'); |
| 169 | }); |
| 170 | }); |
| 171 | |
| 172 | describe('Die Rechnung dahinter waehlt wirklich an der Bandbreite', () => { |
| 173 | /** Das gewaehlte Freigabefenster dieses Knotenpunkts. */ |
| 174 | function gewaehlt(projekt: Project, plan: SignalPlan) { |
| 175 | const lage = koordinierungslage(projekt, plan); |
| 176 | return { |
| 177 | freigabe: lage.kette.find((k) => k.eigener)?.freigabe, |
| 178 | bandbreite: lage.ergebnis?.bandwidth, |
| 179 | }; |
| 180 | } |
| 181 | |
| 182 | it('nimmt das spaetere Fenster, wenn dieses die groessere Bandbreite traegt', () => { |
| 183 | // Gibt der Nachbar erst ab 50 s frei, traegt das Fenster ab 30 s das Band. |
| 184 | // Nach dem alten Satz - "das laengste" - waeren beide gleichwertig. |
| 185 | const projekt = zweiFenster(50); |
| 186 | const haupt = projekt.koordinierung!.hauptrichtung!; |
| 187 | const plan = buildSignalPlan(projekt); |
| 188 | expect(gewaehlt(projekt, nurFenster(plan, haupt, 0)).bandbreite, 'Vorbedingung').toBe(0); |
| 189 | |
| 190 | for (const p of [plan, mitUmgekehrtenFenstern(plan, haupt)]) { |
| 191 | expect(gewaehlt(projekt, p).freigabe).toEqual({ start: 30, duration: 9 }); |
| 192 | expect(gewaehlt(projekt, p).bandbreite).toBe(9); |
| 193 | } |
| 194 | }); |
| 195 | |
| 196 | it('nimmt bei gleicher Bandbreite das im Umlauf fruehere', () => { |
| 197 | // Der Nachbar gibt von 20 s an 40 s lang frei und deckt damit beide |
| 198 | // Bandlagen; die Bandbreite traegt die Entscheidung dann nicht mehr. |
| 199 | const projekt = zweiFenster(20, 40); |
| 200 | const haupt = projekt.koordinierung!.hauptrichtung!; |
| 201 | const plan = buildSignalPlan(projekt); |
| 202 | expect( |
| 203 | gewaehlt(projekt, nurFenster(plan, haupt, 0)).bandbreite, |
| 204 | 'Vorbedingung: beide Fenster tragen dasselbe Band', |
| 205 | ).toBe(gewaehlt(projekt, nurFenster(plan, haupt, 1)).bandbreite); |
| 206 | |
| 207 | for (const p of [plan, mitUmgekehrtenFenstern(plan, haupt)]) { |
| 208 | expect(gewaehlt(projekt, p).freigabe).toEqual({ start: 0, duration: 9 }); |
| 209 | } |
| 210 | }); |
| 211 | }); |
| 212 | |
| 213 | /** |
| 214 | * Nachtrag: Der Hinweis hing allein an der HAUPTRICHTUNG. |
| 215 | * |
| 216 | * `fensterMitBand` waehlt fuer jede Richtung an ihrem eigenen Band - zuerst fuer |
| 217 | * die Hin-, dann fuer die Rueckrichtung. Hatte nur die Gegenrichtung mehrere |
| 218 | * Fenster, traf das Programm dieselbe dreistufige Wahl und sagte kein Wort |
| 219 | * darueber: dieselbe Luecke, die der Satz fuer die Hauptrichtung gerade |
| 220 | * geschlossen hat. Vorbestand, durch den neuen Satz sichtbar geworden. |
| 221 | */ |
| 222 | describe('Der Hinweis nennt die Richtung, deren Fenster gewaehlt wurden', () => { |
| 223 | /** Nur die Gegenrichtung ist in zwei getrennten Fenstern frei. */ |
| 224 | function nurGegenrichtungMehrfach(): Project { |
| 225 | const basis = createEmptyProject('Nur Gegenrichtung', DATUM, 'knotenpunkt'); |
| 226 | const a = createSignalGroup({ name: 'A', mode: 'kfz' as const, vZul: 50, index: 0 }); |
| 227 | const b = createSignalGroup({ name: 'B', mode: 'kfz' as const, vZul: 50, index: 1 }); |
| 228 | const c = createSignalGroup({ name: 'C', mode: 'kfz' as const, vZul: 50, index: 2 }); |
| 229 | const phasen = [ |
| 230 | createPhase('P1', [b.id]), |
| 231 | createPhase('P2', [a.id]), |
| 232 | createPhase('P3', [b.id]), |
| 233 | createPhase('P4', [c.id]), |
| 234 | ]; |
| 235 | const beziehung = (von: string, nach: string) => ({ |
| 236 | ...createConflict(von, nach), |
| 237 | clearingDistance: 20, |
| 238 | enteringDistance: 0, |
| 239 | }); |
| 240 | let projekt: Project = { |
| 241 | ...basis, |
| 242 | signalGroups: [a, b, c], |
| 243 | phases: phasen, |
| 244 | conflicts: [beziehung(a.id, b.id), beziehung(b.id, a.id)], |
| 245 | program: { |
| 246 | ...basis.program, |
| 247 | method: 'manuell', |
| 248 | manualCycleTime: 60, |
| 249 | phaseOrder: phasen.map((p) => p.id), |
| 250 | }, |
| 251 | }; |
| 252 | projekt = actions.setzeKoordinierung(projekt, { |
| 253 | hauptrichtung: a.id, |
| 254 | gegenrichtung: b.id, |
| 255 | progressionsgeschwindigkeit: 36, |
| 256 | platz: 0, |
| 257 | }); |
| 258 | projekt = actions.koordinierungsknotenAnlegen(projekt, 'West'); |
| 259 | const west = projekt.koordinierung!.nachbarn[0]!; |
| 260 | return actions.koordinierungsknotenAendern(projekt, west.id, { |
| 261 | abstand: 200, |
| 262 | freigabeBeginn: 20, |
| 263 | freigabeDauer: 12, |
| 264 | gegenBeginn: 20, |
| 265 | gegenDauer: 12, |
| 266 | }); |
| 267 | } |
| 268 | |
| 269 | it('meldet den Fall auch, wenn ihn nur die Gegenrichtung hat', () => { |
| 270 | const projekt = nurGegenrichtungMehrfach(); |
| 271 | const gruppen = buildSignalPlan(projekt).groups; |
| 272 | const fenster = (id: string | null) => |
| 273 | gruppen.find((g) => g.groupId === id)?.greens.map((g) => g.duration); |
| 274 | expect(fenster(projekt.koordinierung!.hauptrichtung), 'Vorbedingung: ein Fenster').toHaveLength( |
| 275 | 1, |
| 276 | ); |
| 277 | expect( |
| 278 | fenster(projekt.koordinierung!.gegenrichtung), |
| 279 | 'Vorbedingung: zwei Fenster', |
| 280 | ).toHaveLength(2); |
| 281 | |
| 282 | const text = fensterhinweis(projekt); |
| 283 | expect(text, 'benannt wird die Richtung, um die es geht').toContain( |
| 284 | 'Signalgruppe der Gegenrichtung', |
| 285 | ); |
| 286 | expect(text, 'und nicht die, die nur ein Fenster hat').not.toContain('der Hauptrichtung ist'); |
| 287 | expect(text, 'die Auswahlregel steht auch hier').toContain('Bandbreite'); |
| 288 | }); |
| 289 | |
| 290 | it('nennt beide Richtungen, wenn beide mehrere Fenster haben', () => { |
| 291 | // In diesem Aufbau ist auch die Gegenrichtung in zwei Phasen frei (P2, P4). |
| 292 | const basis = zweiFenster(); |
| 293 | const projekt = actions.setzeKoordinierung(basis, { |
| 294 | gegenrichtung: basis.signalGroups[1]!.id, |
| 295 | }); |
| 296 | const lage = koordinierungslage(projekt, buildSignalPlan(projekt)); |
| 297 | expect( |
| 298 | lage.mehrereFreigabefenster && lage.mehrereFreigabefensterGegenrichtung, |
| 299 | 'Vorbedingung: beide', |
| 300 | ).toBe(true); |
| 301 | expect(fensterhinweis(projekt)).toContain( |
| 302 | 'Signalgruppen der Hauptrichtung und der Gegenrichtung', |
| 303 | ); |
| 304 | }); |
| 305 | |
| 306 | it('schweigt, wo keine der beiden Richtungen mehrere Fenster hat', () => { |
| 307 | const projekt = createStandardIntersectionProject('Ein Fenster', DATUM); |
| 308 | const mitKette = actions.koordinierungsknotenAnlegen( |
| 309 | actions.setzeKoordinierung(projekt, { |
| 310 | hauptrichtung: projekt.signalGroups[0]!.id, |
| 311 | progressionsgeschwindigkeit: 36, |
| 312 | platz: 0, |
| 313 | }), |
| 314 | 'West', |
| 315 | ); |
| 316 | const west = mitKette.koordinierung!.nachbarn[0]!; |
| 317 | const fertig = actions.koordinierungsknotenAendern(mitKette, west.id, { |
| 318 | abstand: 200, |
| 319 | freigabeBeginn: 10, |
| 320 | freigabeDauer: 20, |
| 321 | }); |
| 322 | expect(hinweise(fertig).some((t) => t.includes('mehreren getrennten Fenstern'))).toBe(false); |
| 323 | }); |
| 324 | }); |