lsa-planer
LSA-Planer Professional – Planungssoftware für Lichtsignalanlagen nach RiLSA 2015 und § 45 StVO. EUPL-1.2.
/ tests domain querungenGleichzeitig.test.ts
| 1 | import { describe, expect, it } from 'vitest'; |
| 2 | import { |
| 3 | createArm, |
| 4 | createConflict, |
| 5 | createEmptyProject, |
| 6 | createPhase, |
| 7 | createSignalGroup, |
| 8 | } from '@/domain/model/factory'; |
| 9 | import { buildSignalPlan } from '@/domain/plan/signalPlan'; |
| 10 | import { validateProject } from '@/domain/validation/engine'; |
| 11 | import type { Conflict, Project } from '@/domain/model/project'; |
| 12 | import type { TrafficMode } from '@/domain/rilsa/types'; |
| 13 | |
| 14 | /** |
| 15 | * Zwei Querungen derselben Verkehrsart, die zugleich Freigabe zeigen. |
| 16 | * |
| 17 | * ANLASS (die zweistufige Fussgaengerfurt). Das Programm bildet die Furt mit |
| 18 | * Mittelinsel nur durchgehend ab - nachbauen laesst sie sich trotzdem, indem |
| 19 | * der Bearbeiter je Abschnitt eine eigene Gruppe anlegt und ihr den halben |
| 20 | * Raeumweg gibt. Stehen beide Abschnitte dann in DERSELBEN Phase, ist das |
| 21 | * gerade keine zweistufige Signalisierung: Der Querende darf nach dem |
| 22 | * Signalbild in einem Zug durchgehen, waehrend Raeumweg, Raeumzeit und |
| 23 | * Furtregel nur den halben Weg abdecken. Zwei Fussgaengergruppen sind einander |
| 24 | * nie feindlich, und deshalb sah `signalplan.feindliche-freigabe` das nicht. |
| 25 | * |
| 26 | * WARUM DIESE DATEI DIE ZWEITE IHRER ART IST. Die Fassung 5.38.0 hatte die |
| 27 | * Regel schon, gruppierte aber ueber `armId` und schwieg, wo das Feld leer war. |
| 28 | * Das Feld ist optional, und beide Wege, auf denen ein Bearbeiter eine |
| 29 | * ZUSAETZLICHE Gruppe anlegt, lassen es leer - der Knopf "+ Fussgaenger" ueber |
| 30 | * `addSignalGroup` und die Uebernahme aus dem Lageplan. Die Regel schwieg damit |
| 31 | * genau in dem Fall, fuer den sie geschrieben wurde, und ein Testfall |
| 32 | * ("schweigt bei Fussgaengergruppen ohne Arm") fror dieses Schweigen ein. Der |
| 33 | * erste Fall dieser Datei ist deshalb der wichtigste. |
| 34 | */ |
| 35 | |
| 36 | const JETZT = new Date('2026-09-11T00:00:00Z'); |
| 37 | const REGEL = 'signalplan.querungen-gleichzeitig'; |
| 38 | |
| 39 | interface Gruppenangabe { |
| 40 | readonly name: string; |
| 41 | readonly mode: TrafficMode; |
| 42 | /** Name der Zufahrt; null, wenn keine erfasst ist. */ |
| 43 | readonly arm: string | null; |
| 44 | } |
| 45 | |
| 46 | interface Aufbau { |
| 47 | readonly arme: readonly string[]; |
| 48 | readonly gruppen: readonly Gruppenangabe[]; |
| 49 | /** Je Phase die Namen der freigegebenen Gruppen. */ |
| 50 | readonly phasen: readonly (readonly string[])[]; |
| 51 | /** Namenspaare, die als feindlich mit Raeumweg erfasst werden. */ |
| 52 | readonly feindlich?: readonly (readonly [string, string])[]; |
| 53 | } |
| 54 | |
| 55 | /** |
| 56 | * Ein Kfz-Strom, dazu die Querungen des Aufbaus. Jede Querung steht mit dem |
| 57 | * Kfz-Strom in Konflikt, damit ueberhaupt ein schaltbarer Plan entsteht. |
| 58 | */ |
| 59 | function baue(aufbau: Aufbau): Project { |
| 60 | const leer = createEmptyProject('Querungen', JETZT, 'knotenpunkt'); |
| 61 | const arme = aufbau.arme.map((name) => createArm(name, name, 2, 50)); |
| 62 | const armId = (name: string | null) => |
| 63 | name === null ? null : (arme.find((a) => a.name === name)?.id ?? null); |
| 64 | |
| 65 | const kfz = createSignalGroup({ name: 'K1', mode: 'kfz', armId: arme[0]?.id ?? null, index: 0 }); |
| 66 | const weitere = aufbau.gruppen.map((g, i) => |
| 67 | createSignalGroup({ name: g.name, mode: g.mode, armId: armId(g.arm), index: 1 + i }), |
| 68 | ); |
| 69 | const alle = [kfz, ...weitere]; |
| 70 | /** Die erste Gruppe dieses Namens - Namen sind hier absichtlich nicht eindeutig. */ |
| 71 | const id = (name: string) => alle.find((g) => g.name === name)!.id; |
| 72 | |
| 73 | const conflicts: Conflict[] = []; |
| 74 | for (const g of weitere) { |
| 75 | conflicts.push( |
| 76 | { ...createConflict(g.id, kfz.id), clearingDistance: 5.75, enteringDistance: 0 }, |
| 77 | { ...createConflict(kfz.id, g.id), clearingDistance: 20, enteringDistance: 5 }, |
| 78 | ); |
| 79 | } |
| 80 | // Beide Richtungen mit Weg, damit daraus wirklich eine Zwischenzeit wird. |
| 81 | for (const [a, b] of aufbau.feindlich ?? []) { |
| 82 | conflicts.push( |
| 83 | { ...createConflict(id(a), id(b)), clearingDistance: 5.75, enteringDistance: 0 }, |
| 84 | { ...createConflict(id(b), id(a)), clearingDistance: 5.75, enteringDistance: 0 }, |
| 85 | ); |
| 86 | } |
| 87 | |
| 88 | const phasen = [ |
| 89 | createPhase('Kfz', [kfz.id]), |
| 90 | ...aufbau.phasen.map((namen, i) => |
| 91 | createPhase( |
| 92 | `Querung ${i + 1}`, |
| 93 | // Bei gleichnamigen Gruppen die Reihenfolge der Erfassung nehmen. |
| 94 | namen.map((n, k) => weitere.filter((g) => g.name === n)[k === 0 ? 0 : k]?.id ?? id(n)), |
| 95 | ), |
| 96 | ), |
| 97 | ]; |
| 98 | |
| 99 | return { |
| 100 | ...leer, |
| 101 | intersection: { ...leer.intersection, arms: arme }, |
| 102 | signalGroups: alle, |
| 103 | conflicts, |
| 104 | phases: phasen, |
| 105 | program: { ...leer.program, phaseOrder: phasen.map((p) => p.id) }, |
| 106 | }; |
| 107 | } |
| 108 | |
| 109 | function befunde(project: Project) { |
| 110 | return validateProject(project, buildSignalPlan(project), JETZT).findings.filter( |
| 111 | (f) => f.rule === REGEL, |
| 112 | ); |
| 113 | } |
| 114 | |
| 115 | /** Zwei Abschnitte einer Furt, wie der Knopf "+ Fußgänger" sie anlegt: ohne Zufahrt. */ |
| 116 | const OHNE_ZUFAHRT: Aufbau = { |
| 117 | arme: ['Nord'], |
| 118 | gruppen: [ |
| 119 | { name: 'F1', mode: 'fuss', arm: null }, |
| 120 | { name: 'F2', mode: 'fuss', arm: null }, |
| 121 | ], |
| 122 | phasen: [['F1', 'F2']], |
| 123 | }; |
| 124 | |
| 125 | describe('Der Fall, den 5.38.0 nicht sah: zwei Querungen ohne erfasste Zufahrt', () => { |
| 126 | it('meldet die gemeinsame Freigabe auch ohne jede Zufahrt', () => { |
| 127 | // Der Knopf "+ Fußgänger" legt keine Zufahrt an. Wer die zweistufige Furt |
| 128 | // von Hand nachbaut, hat deshalb im Regelfall zwei Gruppen ohne Zufahrt. |
| 129 | const gefunden = befunde(baue(OHNE_ZUFAHRT)); |
| 130 | expect(gefunden).toHaveLength(1); |
| 131 | expect(gefunden[0]?.message).toContain('„F1“'); |
| 132 | expect(gefunden[0]?.message).toContain('„F2“'); |
| 133 | }); |
| 134 | |
| 135 | it('sagt, dass die Zufahrt fehlt und deshalb nichts eingegrenzt werden kann', () => { |
| 136 | expect(befunde(baue(OHNE_ZUFAHRT))[0]?.message).toContain('Zufahrt nicht erfasst'); |
| 137 | }); |
| 138 | |
| 139 | it('meldet auch, wenn nur eine der beiden eine Zufahrt trägt', () => { |
| 140 | expect( |
| 141 | befunde( |
| 142 | baue({ |
| 143 | ...OHNE_ZUFAHRT, |
| 144 | gruppen: [ |
| 145 | { name: 'F1', mode: 'fuss', arm: 'Nord' }, |
| 146 | { name: 'F2', mode: 'fuss', arm: null }, |
| 147 | ], |
| 148 | }), |
| 149 | ), |
| 150 | ).toHaveLength(1); |
| 151 | }); |
| 152 | |
| 153 | it('meldet auch bei zwei gleichnamigen Gruppen', () => { |
| 154 | // Der Befund darf nicht an einer Menge von NAMEN hängen: Wer den zweiten |
| 155 | // Abschnitt kopiert und den Namen stehen lässt, hat zweimal "F1". |
| 156 | expect( |
| 157 | befunde( |
| 158 | baue({ |
| 159 | arme: ['Nord'], |
| 160 | gruppen: [ |
| 161 | { name: 'F1', mode: 'fuss', arm: null }, |
| 162 | { name: 'F1', mode: 'fuss', arm: null }, |
| 163 | ], |
| 164 | phasen: [['F1', 'F1']], |
| 165 | }), |
| 166 | ), |
| 167 | ).toHaveLength(1); |
| 168 | }); |
| 169 | }); |
| 170 | |
| 171 | describe('Die Zufahrt grenzt ein, sie ist keine Bedingung', () => { |
| 172 | it('meldet zwei Querungen derselben Zufahrt und nennt sie', () => { |
| 173 | const gefunden = befunde( |
| 174 | baue({ |
| 175 | arme: ['Nord'], |
| 176 | gruppen: [ |
| 177 | { name: 'F1', mode: 'fuss', arm: 'Nord' }, |
| 178 | { name: 'F2', mode: 'fuss', arm: 'Nord' }, |
| 179 | ], |
| 180 | phasen: [['F1', 'F2']], |
| 181 | }), |
| 182 | ); |
| 183 | expect(gefunden).toHaveLength(1); |
| 184 | expect(gefunden[0]?.message).toContain('Zufahrt „Nord“'); |
| 185 | }); |
| 186 | |
| 187 | it('schweigt bei zwei erfassten, verschiedenen Zufahrten', () => { |
| 188 | // Nur das schließt dieselbe Querung aus. |
| 189 | expect( |
| 190 | befunde( |
| 191 | baue({ |
| 192 | arme: ['Nord', 'Süd'], |
| 193 | gruppen: [ |
| 194 | { name: 'F1', mode: 'fuss', arm: 'Nord' }, |
| 195 | { name: 'F2', mode: 'fuss', arm: 'Süd' }, |
| 196 | ], |
| 197 | phasen: [['F1', 'F2']], |
| 198 | }), |
| 199 | ), |
| 200 | ).toHaveLength(0); |
| 201 | }); |
| 202 | }); |
| 203 | |
| 204 | describe('Wo die Regel sonst schweigt', () => { |
| 205 | it('schweigt bei getrennten Phasen', () => { |
| 206 | expect(befunde(baue({ ...OHNE_ZUFAHRT, phasen: [['F1'], ['F2']] }))).toHaveLength(0); |
| 207 | }); |
| 208 | |
| 209 | it('schweigt bei einer Furt neben einer Radquerung', () => { |
| 210 | // Furt und Radquerung nebeneinander sind der Regelfall, kein geteiltes Gebilde. |
| 211 | expect( |
| 212 | befunde( |
| 213 | baue({ |
| 214 | arme: ['Nord'], |
| 215 | gruppen: [ |
| 216 | { name: 'F1', mode: 'fuss', arm: null }, |
| 217 | { name: 'R1', mode: 'rad', arm: null }, |
| 218 | ], |
| 219 | phasen: [['F1', 'R1']], |
| 220 | }), |
| 221 | ), |
| 222 | ).toHaveLength(0); |
| 223 | }); |
| 224 | |
| 225 | it('schweigt bei einem als feindlich erfassten Paar', () => { |
| 226 | // Dann meldet signalplan.feindliche-freigabe einen FEHLER. Stünde daneben |
| 227 | // dieser Befund, sagte der Bericht in zwei Zeilen Gegensätzliches. |
| 228 | const project = baue({ ...OHNE_ZUFAHRT, feindlich: [['F1', 'F2']] }); |
| 229 | expect(befunde(project)).toHaveLength(0); |
| 230 | // Welche der beiden Fehlerregeln zuerst greift, entscheidet die |
| 231 | // Reihenfolge im Pruefer: Ein feindliches Paar in EINER Phase meldet |
| 232 | // phasen.feindlich-in-phase, sonst signalplan.feindliche-freigabe. Beide |
| 233 | // sperren die Ausgabe; welche es ist, gehoert nicht hierher. |
| 234 | const bericht = validateProject(project, buildSignalPlan(project), JETZT); |
| 235 | expect( |
| 236 | bericht.findings.some( |
| 237 | (f) => |
| 238 | f.rule === 'signalplan.feindliche-freigabe' || f.rule === 'phasen.feindlich-in-phase', |
| 239 | ), |
| 240 | ).toBe(true); |
| 241 | expect(bericht.exportBlocked).toBe(true); |
| 242 | }); |
| 243 | |
| 244 | it('schweigt bei einer einzigen Querung', () => { |
| 245 | expect( |
| 246 | befunde( |
| 247 | baue({ |
| 248 | arme: ['Nord'], |
| 249 | gruppen: [{ name: 'F1', mode: 'fuss', arm: null }], |
| 250 | phasen: [['F1']], |
| 251 | }), |
| 252 | ), |
| 253 | ).toHaveLength(0); |
| 254 | }); |
| 255 | }); |
| 256 | |
| 257 | describe('Die geteilte Radquerung ist derselbe Sachverhalt', () => { |
| 258 | it('meldet zwei zugleich freigegebene Radquerungen', () => { |
| 259 | const gefunden = befunde( |
| 260 | baue({ |
| 261 | arme: ['Nord'], |
| 262 | gruppen: [ |
| 263 | { name: 'R1', mode: 'rad', arm: null }, |
| 264 | { name: 'R2', mode: 'rad', arm: null }, |
| 265 | ], |
| 266 | phasen: [['R1', 'R2']], |
| 267 | }), |
| 268 | ); |
| 269 | expect(gefunden).toHaveLength(1); |
| 270 | expect(gefunden[0]?.message).toContain('Radquerungen'); |
| 271 | }); |
| 272 | }); |
| 273 | |
| 274 | describe('Ein Befund je Paar, und jeder nennt nur sein Paar', () => { |
| 275 | /** F1 und F2 zusammen, F3 allein in einer eigenen Phase. */ |
| 276 | const DREI: Aufbau = { |
| 277 | arme: ['Nord'], |
| 278 | gruppen: [ |
| 279 | { name: 'F1', mode: 'fuss', arm: null }, |
| 280 | { name: 'F2', mode: 'fuss', arm: null }, |
| 281 | { name: 'F3', mode: 'fuss', arm: null }, |
| 282 | ], |
| 283 | phasen: [['F1', 'F2'], ['F3']], |
| 284 | }; |
| 285 | |
| 286 | it('meldet nur das Paar, das sich wirklich überschneidet', () => { |
| 287 | const gefunden = befunde(baue(DREI)); |
| 288 | expect(gefunden).toHaveLength(1); |
| 289 | expect(gefunden[0]?.message).not.toContain('„F3“'); |
| 290 | }); |
| 291 | |
| 292 | it('meldet bei drei zugleich freigegebenen Querungen drei Paare', () => { |
| 293 | // Bis 5.38.0 stand hier EIN Befund mit allen drei Namen und der Behauptung, |
| 294 | // sie zeigten gleichzeitig Freigabe - richtig war das nur, wenn sich |
| 295 | // wirklich alle drei überschnitten. |
| 296 | const gefunden = befunde(baue({ ...DREI, phasen: [['F1', 'F2', 'F3']] })); |
| 297 | expect(gefunden).toHaveLength(3); |
| 298 | }); |
| 299 | }); |
| 300 | |
| 301 | describe('Was der Befund sagt und wohin er zeigt', () => { |
| 302 | it('ist eine Warnung und sperrt die Ausgabe nicht', () => { |
| 303 | // Warnung und nicht Hinweis: Dieselbe Bauart wie zwischenzeiten.null - kann |
| 304 | // der Regelfall sein, entsteht aber genauso aus einem zu kurz erfassten |
| 305 | // Räumweg. Ein Hinweis fiele zudem aus dem Bericht der übrigen |
| 306 | // Tagesprogramme heraus. |
| 307 | const project = baue(OHNE_ZUFAHRT); |
| 308 | const bericht = validateProject(project, buildSignalPlan(project), JETZT); |
| 309 | expect(bericht.findings.find((f) => f.rule === REGEL)?.severity).toBe('warnung'); |
| 310 | expect(bericht.exportBlocked).toBe(false); |
| 311 | }); |
| 312 | |
| 313 | it('beruft sich nicht auf ein Regelwerk, denn dort steht die Regel nicht', () => { |
| 314 | expect(befunde(baue(OHNE_ZUFAHRT))[0]?.reference).not.toContain('RiLSA'); |
| 315 | }); |
| 316 | |
| 317 | it('nennt beide Lesarten, damit der Regelfall nicht wie ein Mangel aussieht', () => { |
| 318 | const text = befunde(baue(OHNE_ZUFAHRT))[0]?.message ?? ''; |
| 319 | expect(text).toContain('zwei getrennte Querungen'); |
| 320 | expect(text).toContain('Mittelinsel'); |
| 321 | }); |
| 322 | |
| 323 | it('nennt alle drei Größen, die der halbe Weg verkürzt', () => { |
| 324 | const text = befunde(baue(OHNE_ZUFAHRT))[0]?.message ?? ''; |
| 325 | for (const groesse of ['Räumweg', 'Räumzeit', 'Furtregel']) { |
| 326 | expect(text, groesse).toContain(groesse); |
| 327 | } |
| 328 | }); |
| 329 | |
| 330 | it('rät nicht dazu, die Abschnitte einfach in getrennte Phasen zu stellen', () => { |
| 331 | // Das war der Rat bis 5.38.0. Er führt genau in die Lage, die das Programm |
| 332 | // nicht prüft: zwei halbe Furten nacheinander, ohne Nachweis. |
| 333 | const rat = befunde(baue(OHNE_ZUFAHRT))[0]?.suggestion ?? ''; |
| 334 | expect(rat).toContain('Bordkante zu Bordkante'); |
| 335 | expect(rat).toContain('getrennte Phasen allein machen daraus keine'); |
| 336 | }); |
| 337 | |
| 338 | it('zeigt auf eine der beiden Signalgruppen und nicht auf die ganze Ansicht', () => { |
| 339 | expect(befunde(baue(OHNE_ZUFAHRT))[0]?.target?.kind).toBe('signalgruppe'); |
| 340 | }); |
| 341 | }); |
| 342 | |
| 343 | describe('Die Reihenfolge der Namen hängt nicht an der Eingabe', () => { |
| 344 | it('nennt die Gruppen gleich, egal wie sie erfasst sind', () => { |
| 345 | const vorwaerts = befunde(baue(OHNE_ZUFAHRT))[0]?.title; |
| 346 | const rueckwaerts = befunde( |
| 347 | baue({ |
| 348 | arme: ['Nord'], |
| 349 | gruppen: [ |
| 350 | { name: 'F2', mode: 'fuss', arm: null }, |
| 351 | { name: 'F1', mode: 'fuss', arm: null }, |
| 352 | ], |
| 353 | phasen: [['F2', 'F1']], |
| 354 | }), |
| 355 | )[0]?.title; |
| 356 | expect(vorwaerts).toBe(rueckwaerts); |
| 357 | expect(vorwaerts?.indexOf('F1')).toBeLessThan(vorwaerts?.indexOf('F2') ?? -1); |
| 358 | }); |
| 359 | }); |
| 360 | |
| 361 | describe('Der Regelplan bleibt still', () => { |
| 362 | it('meldet nichts an einem Knotenpunkt mit einer Furt je Zufahrt', () => { |
| 363 | // Was überall meldet, sagt nichts. Der Regelfall muss still bleiben. |
| 364 | expect( |
| 365 | befunde( |
| 366 | baue({ |
| 367 | arme: ['Nord', 'Ost', 'Süd', 'West'], |
| 368 | gruppen: [ |
| 369 | { name: 'F1', mode: 'fuss', arm: 'Nord' }, |
| 370 | { name: 'F2', mode: 'fuss', arm: 'Ost' }, |
| 371 | { name: 'F3', mode: 'fuss', arm: 'Süd' }, |
| 372 | { name: 'F4', mode: 'fuss', arm: 'West' }, |
| 373 | ], |
| 374 | phasen: [['F1', 'F2', 'F3', 'F4']], |
| 375 | }), |
| 376 | ), |
| 377 | ).toHaveLength(0); |
| 378 | }); |
| 379 | }); |