lsa-planer
LSA-Planer Professional – Planungssoftware für Lichtsignalanlagen nach RiLSA 2015 und § 45 StVO. EUPL-1.2.
/ tests services projektdatei.test.ts
| 1 | import { afterEach, describe, expect, it } from 'vitest'; |
| 2 | import { openProjectFromFile, serializeProject, suggestFileName } from '@/services/storage'; |
| 3 | import { parseProject, type ParseResult } from '@/domain/model/schema'; |
| 4 | import { createEmptyProject } from '@/domain/model/factory'; |
| 5 | import { CURRENT_SCHEMA_VERSION, type Project } from '@/domain/model/project'; |
| 6 | import { resolveRilsaSettings, type RilsaOverrides } from '@/domain/rilsa/settings'; |
| 7 | import type { Anlagenart } from '@/domain/rilsa/types'; |
| 8 | import type { DesktopBridge, OpenFileResult } from '@/platform/bridge'; |
| 9 | |
| 10 | /** |
| 11 | * Die Projektdatei als Ganzes: Hin- und Rueckweg, Dateiname, Einlesen. |
| 12 | * |
| 13 | * WARUM DIESE PRUEFUNG SEIN MUSS |
| 14 | * |
| 15 | * serializeProject kam bis hierher in keiner einzigen Pruefung vor. Der |
| 16 | * vorhandene Rundreisetest (tests/domain/schema.test.ts) vergleicht nur |
| 17 | * Listenlaengen und die Schemaversion - genau dadurch blieb unentdeckt, dass die |
| 18 | * Bewertungsskala HCM bei jedem Speichern auf HBS zurueckfiel. Ein Feld, das den |
| 19 | * Weg durch die Datei nicht uebersteht, faellt niemandem auf: Das Projekt |
| 20 | * oeffnet sich klaglos, es steht nur etwas anderes darin als vorher. |
| 21 | * |
| 22 | * Deshalb wird hier VOLLSTAENDIG verglichen - jedes Feld gegen sein Gegenstueck, |
| 23 | * nicht die Anzahl der Eintraege. Das Vergleichsprojekt weicht dazu in moeglichst |
| 24 | * vielen Angaben vom Vorgabewert ab: Ein Feld, das seinen Regelwert traegt, |
| 25 | * koennte den Weg auch dann "ueberstehen", wenn es unterwegs verloren geht und |
| 26 | * beim Einlesen neu vergeben wird. |
| 27 | */ |
| 28 | |
| 29 | const JETZT = new Date('2026-08-14T09:00:00.000Z'); |
| 30 | |
| 31 | /** |
| 32 | * Ein Projekt, in dem nahezu jedes Feld vom Vorgabewert abweicht. |
| 33 | * |
| 34 | * Die Raeumwege bleiben unter 100 m, damit dasselbe Projekt fuer alle drei |
| 35 | * Anlagenarten gilt - die Fussgaengerschutzanlage hat die engste Schranke. Der |
| 36 | * lange Raeumweg einer einstreifigen Fuehrung wird getrennt geprueft. |
| 37 | */ |
| 38 | function vollstaendigesProjekt(anlagenart: Anlagenart): Project { |
| 39 | return { |
| 40 | schemaVersion: CURRENT_SCHEMA_VERSION, |
| 41 | id: 'prj-rundreise', |
| 42 | anlagenart, |
| 43 | meta: { |
| 44 | name: 'Königsallee / Südring – Planfall 2', |
| 45 | projectNumber: 'VB-2026/0815', |
| 46 | client: 'Straßenverkehrsbehörde Köln', |
| 47 | location: 'Köln, Königsallee × Südring', |
| 48 | planner: 'O. Willerding', |
| 49 | variant: 'Planfall 2 (Umbau)', |
| 50 | description: |
| 51 | 'Zweistufige Furt.\nAufstellfläche „Mittelinsel" 2,5 m breit; Räumen in einem Zug.', |
| 52 | // Pflichtangaben der Anordnung (Befund C14). Beide sind neu und im leeren |
| 53 | // Projekt '' - hier deshalb mit Inhalt, sonst kaeme dieselbe Zeichenkette |
| 54 | // beim Einlesen auch dann heraus, wenn das Feld unterwegs verloren geht. |
| 55 | einsatzzeiten: '12.05.–30.06.2027, werktags 6–19 Uhr', |
| 56 | betriebsverantwortlicher: 'Mustermann Bau GmbH, Rufbereitschaft 0170 1234567', |
| 57 | // Verweise auf Unterlagen, die dieses Programm nicht erzeugt. Aus |
| 58 | // demselben Grund mit Inhalt: Sie sind im leeren Projekt '' und fielen |
| 59 | // sonst auch dann nicht auf, wenn sie unterwegs verloren gingen. |
| 60 | verkehrszeichenplan: 'VZP 2027-14, Stand 03.05.2027', |
| 61 | regelplan: 'B I/5', |
| 62 | zustimmungBaulasttraeger: 'LBM Az. 4711-2027 vom 28.04.2027', |
| 63 | createdAt: '2026-02-01T07:30:00.000Z', |
| 64 | modifiedAt: '2026-08-13T16:45:12.750Z', |
| 65 | }, |
| 66 | intersection: { |
| 67 | name: 'Königsallee / Südring', |
| 68 | vZul: 70, |
| 69 | arms: [ |
| 70 | { |
| 71 | id: 'arm-nord', |
| 72 | name: 'Zufahrt Nord', |
| 73 | direction: 'Nord (Königsallee)', |
| 74 | lanes: 3, |
| 75 | vZul: 70, |
| 76 | }, |
| 77 | { |
| 78 | id: 'arm-sued', |
| 79 | name: 'Zufahrt Süd', |
| 80 | direction: 'Süd (Bahnhofstraße)', |
| 81 | lanes: 2, |
| 82 | vZul: 30, |
| 83 | }, |
| 84 | ], |
| 85 | notes: 'Vierarmig, Südzufahrt aufgeweitet (Rechtsabbieger frei).', |
| 86 | }, |
| 87 | signalGroups: [ |
| 88 | { |
| 89 | id: 'sg-k1', |
| 90 | name: 'K1', |
| 91 | mode: 'kfz', |
| 92 | movement: 'geradeaus', |
| 93 | armId: 'arm-nord', |
| 94 | vZul: 70, |
| 95 | lanes: 3, |
| 96 | vehicleClass: 'pkw', |
| 97 | minGreenOverride: 9, |
| 98 | maxGreenOverride: 55, |
| 99 | reducedMobility: false, |
| 100 | color: '#1a2b3c', |
| 101 | }, |
| 102 | { |
| 103 | id: 'sg-k2', |
| 104 | name: 'K2', |
| 105 | mode: 'kfz', |
| 106 | movement: 'links', |
| 107 | armId: 'arm-sued', |
| 108 | vZul: 30, |
| 109 | lanes: 1, |
| 110 | vehicleClass: 'lkw', |
| 111 | minGreenOverride: null, |
| 112 | maxGreenOverride: null, |
| 113 | reducedMobility: false, |
| 114 | color: '#e07b39', |
| 115 | }, |
| 116 | { |
| 117 | id: 'sg-k3', |
| 118 | name: 'K3', |
| 119 | mode: 'kfz', |
| 120 | movement: 'rechts', |
| 121 | armId: 'arm-nord', |
| 122 | vZul: 70, |
| 123 | lanes: 2, |
| 124 | vehicleClass: 'lastzug', |
| 125 | minGreenOverride: 12, |
| 126 | maxGreenOverride: null, |
| 127 | reducedMobility: false, |
| 128 | color: '#4a9e6a', |
| 129 | }, |
| 130 | { |
| 131 | id: 'sg-r1', |
| 132 | name: 'R1', |
| 133 | mode: 'rad', |
| 134 | movement: 'querung', |
| 135 | armId: 'arm-sued', |
| 136 | vZul: 20, |
| 137 | lanes: 1, |
| 138 | vehicleClass: 'rad', |
| 139 | minGreenOverride: null, |
| 140 | maxGreenOverride: 40, |
| 141 | reducedMobility: false, |
| 142 | color: '#a05ec4', |
| 143 | }, |
| 144 | { |
| 145 | id: 'sg-f1', |
| 146 | name: 'F1', |
| 147 | mode: 'fuss', |
| 148 | movement: 'querung', |
| 149 | armId: null, |
| 150 | vZul: 5, |
| 151 | lanes: 1, |
| 152 | vehicleClass: 'keine', |
| 153 | minGreenOverride: 7, |
| 154 | maxGreenOverride: null, |
| 155 | reducedMobility: true, |
| 156 | color: '#c4526a', |
| 157 | }, |
| 158 | { |
| 159 | id: 'sg-b1', |
| 160 | name: 'B1', |
| 161 | mode: 'oepnv', |
| 162 | movement: 'geradeaus', |
| 163 | armId: 'arm-sued', |
| 164 | vZul: 50, |
| 165 | lanes: 1, |
| 166 | vehicleClass: 'bus', |
| 167 | minGreenOverride: null, |
| 168 | maxGreenOverride: null, |
| 169 | reducedMobility: false, |
| 170 | color: '#3d9bb0', |
| 171 | }, |
| 172 | ], |
| 173 | conflicts: [ |
| 174 | { |
| 175 | id: 'cf-1', |
| 176 | fromId: 'sg-k1', |
| 177 | toId: 'sg-f1', |
| 178 | clearingDistance: 88.5, |
| 179 | enteringDistance: 12.25, |
| 180 | clearingSpeedOverride: 9.5, |
| 181 | enteringSpeedOverride: 11.5, |
| 182 | crossingTimeOverride: 2.5, |
| 183 | manualIntergreen: null, |
| 184 | wegnachweis: { herkunft: 'lageplan', uebernommenAm: '2026-03-04T08:15:30.250Z' }, |
| 185 | note: 'Räumweg bis Hinterkante Furt – Maßstab 1:250; Zuschlag für „Mittelinsel".', |
| 186 | }, |
| 187 | { |
| 188 | id: 'cf-2', |
| 189 | fromId: 'sg-f1', |
| 190 | toId: 'sg-k1', |
| 191 | clearingDistance: 24, |
| 192 | enteringDistance: 3.5, |
| 193 | clearingSpeedOverride: null, |
| 194 | enteringSpeedOverride: null, |
| 195 | crossingTimeOverride: null, |
| 196 | manualIntergreen: 11, |
| 197 | wegnachweis: { herkunft: 'handeingabe', uebernommenAm: null }, |
| 198 | note: 'Zwischenzeit nach Absprache mit der Straßenverkehrsbehörde festgesetzt (11 s).', |
| 199 | }, |
| 200 | { |
| 201 | id: 'cf-3', |
| 202 | fromId: 'sg-k2', |
| 203 | toId: 'sg-b1', |
| 204 | clearingDistance: 40, |
| 205 | enteringDistance: 8, |
| 206 | clearingSpeedOverride: null, |
| 207 | enteringSpeedOverride: 8.5, |
| 208 | crossingTimeOverride: null, |
| 209 | manualIntergreen: null, |
| 210 | wegnachweis: { herkunft: 'unbestimmt', uebernommenAm: null }, |
| 211 | // sg-k2 biegt links ab - hier wirkt das Merkmal, es ist also keine |
| 212 | // Angabe, die beim Einlesen ohnehin auf 'false' zurueckfaellt. |
| 213 | engerRadius: true, |
| 214 | note: '', |
| 215 | }, |
| 216 | { |
| 217 | id: 'cf-4', |
| 218 | fromId: 'sg-r1', |
| 219 | toId: 'sg-k3', |
| 220 | clearingDistance: 15.75, |
| 221 | enteringDistance: 5.25, |
| 222 | clearingSpeedOverride: 4.5, |
| 223 | enteringSpeedOverride: null, |
| 224 | crossingTimeOverride: 1.5, |
| 225 | manualIntergreen: null, |
| 226 | wegnachweis: { herkunft: 'lageplan', uebernommenAm: '2026-03-04T08:17:02.000Z' }, |
| 227 | note: 'Radquerung, Überfahrzeit abweichend.', |
| 228 | }, |
| 229 | ], |
| 230 | phases: [ |
| 231 | { |
| 232 | id: 'ph-1', |
| 233 | name: 'Phase 1 – Nord/Süd', |
| 234 | signalGroupIds: ['sg-k1', 'sg-k3', 'sg-f1'], |
| 235 | manualGreen: 28, |
| 236 | }, |
| 237 | { |
| 238 | id: 'ph-2', |
| 239 | name: 'Phase 2 – ÖPNV und Rad', |
| 240 | signalGroupIds: ['sg-k2', 'sg-b1', 'sg-r1'], |
| 241 | manualGreen: null, |
| 242 | }, |
| 243 | ], |
| 244 | program: { |
| 245 | id: 'prog-1', |
| 246 | name: 'Programm 3 – Nachtabsenkung', |
| 247 | // Absichtlich NICHT die Reihenfolge der Phasenliste: Ein Umlauf, der beim |
| 248 | // Speichern still umsortiert wird, schaltet danach anders. |
| 249 | phaseOrder: ['ph-2', 'ph-1'], |
| 250 | method: 'manuell', |
| 251 | manualCycleTime: 95, |
| 252 | offset: 37, |
| 253 | }, |
| 254 | demands: [ |
| 255 | { signalGroupId: 'sg-k1', volume: 1240, heavyVehicleShare: 0.125 }, |
| 256 | { signalGroupId: 'sg-k2', volume: 615, heavyVehicleShare: 0.08 }, |
| 257 | { signalGroupId: 'sg-b1', volume: 42, heavyVehicleShare: 1 }, |
| 258 | { signalGroupId: 'sg-r1', volume: 0, heavyVehicleShare: 0 }, |
| 259 | ], |
| 260 | /* |
| 261 | * Zwei eingelagerte Tagesprogramme (Schema 16), und beide fuehren etwas, |
| 262 | * das ein Vorgabewert nicht hergibt: eigene Verkehrsstaerken, eine eigene |
| 263 | * feste Freigabezeit, ein anderes Verfahren, einen anderen Versatz. Geht |
| 264 | * eines davon auf dem Weg durch die Datei verloren, plant der Bearbeiter |
| 265 | * nach dem Sichern eine andere Anlage. |
| 266 | */ |
| 267 | programmbibliothek: [ |
| 268 | { |
| 269 | programm: { |
| 270 | id: 'prog-tag', |
| 271 | name: 'Programm 1 – Tag', |
| 272 | phaseOrder: ['ph-1', 'ph-2'], |
| 273 | method: 'webster', |
| 274 | manualCycleTime: null, |
| 275 | offset: 0, |
| 276 | }, |
| 277 | demands: [ |
| 278 | { signalGroupId: 'sg-k1', volume: 1810, heavyVehicleShare: 0.06 }, |
| 279 | { signalGroupId: 'sg-k2', volume: 940, heavyVehicleShare: 0.04, lastzugAnteil: 0.3 }, |
| 280 | ], |
| 281 | festeFreigabezeiten: [{ phaseId: 'ph-1', sekunden: 48 }], |
| 282 | }, |
| 283 | { |
| 284 | programm: { |
| 285 | id: 'prog-fruehspitze', |
| 286 | name: 'Programm 2 – Frühspitze', |
| 287 | phaseOrder: ['ph-2', 'ph-1'], |
| 288 | method: 'hbs', |
| 289 | manualCycleTime: null, |
| 290 | offset: 12, |
| 291 | }, |
| 292 | demands: [{ signalGroupId: 'sg-r1', volume: 130, heavyVehicleShare: 0 }], |
| 293 | festeFreigabezeiten: [], |
| 294 | }, |
| 295 | ], |
| 296 | // Absichtlich nicht 0: Das aktive Programm steht an dritter Stelle, und |
| 297 | // diese Stelle ueberlebt das Sichern - sonst sortierte sich die Auswahl |
| 298 | // beim Oeffnen um. |
| 299 | programmplatz: 2, |
| 300 | /* |
| 301 | * Eine Koordinierungskette (Schema 17) mit zwei Nachbarn, einer davon mit |
| 302 | * Gegenrichtung. Die Abstaende sind eine Vermessung vor Ort: Gehen sie auf |
| 303 | * dem Weg durch die Datei verloren, ist die Gruene Welle nach dem naechsten |
| 304 | * Sichern eine andere. |
| 305 | */ |
| 306 | koordinierung: { |
| 307 | progressionsgeschwindigkeit: 48, |
| 308 | hauptrichtung: 'sg-k1', |
| 309 | gegenrichtung: 'sg-k2', |
| 310 | eigenerAbstand: 285, |
| 311 | platz: 1, |
| 312 | nachbarn: [ |
| 313 | { |
| 314 | id: 'knoten-west', |
| 315 | name: 'Königsallee / Weststraße', |
| 316 | abstand: 320, |
| 317 | freigabeBeginn: 12, |
| 318 | freigabeDauer: 34, |
| 319 | gegenBeginn: 55, |
| 320 | gegenDauer: 28, |
| 321 | }, |
| 322 | { |
| 323 | id: 'knoten-ost', |
| 324 | name: 'Königsallee / Oststraße', |
| 325 | abstand: 410, |
| 326 | freigabeBeginn: 5, |
| 327 | freigabeDauer: 40, |
| 328 | }, |
| 329 | ], |
| 330 | }, |
| 331 | settings: { |
| 332 | rilsa: { |
| 333 | yellowKfzUpTo50: 4, |
| 334 | minGreenFuss: 7, |
| 335 | clearingSpeedKfzTurning: 6.5, |
| 336 | enteringSpeedFuss: 1.4, |
| 337 | crossingTimeFuss: 1.2, |
| 338 | vehicleLengthLastzug: 18.5, |
| 339 | cycleTimeMax: 110, |
| 340 | saturationFlow: 1900, |
| 341 | coordinationDefaultSpeed: 45, |
| 342 | }, |
| 343 | serviceLevelScale: 'HCM', |
| 344 | // Absichtlich NICHT die Vorgabe 'gelbzeit': Der Ansatz der Ueberfahrzeit |
| 345 | // waere sonst auch dann richtig wieder da, wenn er unterwegs verloren |
| 346 | // geht und beim Einlesen neu vergeben wird. |
| 347 | ueberfahrzeitAnsatz: 'fest', |
| 348 | }, |
| 349 | lageplan: { |
| 350 | arbeitsbereiche: [], |
| 351 | signalgeber: [], |
| 352 | bild: { |
| 353 | datenUrl: 'data:image/png;base64,iVBORw0KGgoAAAANSUhEUg==', |
| 354 | breite: 1024, |
| 355 | hoehe: 768, |
| 356 | herkunft: 'Amtliches Luftbild (DOP20), Bezirksregierung Köln', |
| 357 | geladenAm: '2026-03-04T08:10:00.000Z', |
| 358 | }, |
| 359 | kalibrierung: { |
| 360 | von: { x: 10, y: 20 }, |
| 361 | bis: { x: 410, y: 20 }, |
| 362 | laengeMeter: 40, |
| 363 | herkunft: 'georeferenziert', |
| 364 | }, |
| 365 | haltlinien: [ |
| 366 | { |
| 367 | id: 'hl-nord', |
| 368 | name: 'Haltlinie Nord', |
| 369 | art: 'zufahrt', |
| 370 | armId: 'arm-nord', |
| 371 | von: { x: 100, y: 300 }, |
| 372 | bis: { x: 300, y: 300 }, |
| 373 | }, |
| 374 | { |
| 375 | id: 'hl-furt', |
| 376 | name: 'Aufstelllinie Furt Ost', |
| 377 | art: 'querung', |
| 378 | armId: 'arm-sued', |
| 379 | von: { x: 400, y: 100 }, |
| 380 | bis: { x: 400, y: 260 }, |
| 381 | }, |
| 382 | ], |
| 383 | linien: [ |
| 384 | { |
| 385 | id: 'pl-1', |
| 386 | name: 'K1 geradeaus', |
| 387 | mode: 'kfz', |
| 388 | movement: 'geradeaus', |
| 389 | breiteMeter: 3.5, |
| 390 | // Der erste Punkt folgt aus Haltlinie und startT (Mitte von hl-nord). |
| 391 | punkte: [ |
| 392 | { x: 200, y: 300 }, |
| 393 | { x: 200, y: 180 }, |
| 394 | { x: 205, y: 60 }, |
| 395 | ], |
| 396 | haltlinieId: 'hl-nord', |
| 397 | startT: 0.5, |
| 398 | signalGroupId: 'sg-k1', |
| 399 | }, |
| 400 | { |
| 401 | id: 'pl-2', |
| 402 | name: 'F1 Querung', |
| 403 | mode: 'fuss', |
| 404 | movement: 'querung', |
| 405 | breiteMeter: 4.5, |
| 406 | // Bei startT 0.25 liegt der Startpunkt auf y = 100 + 0,25 * 160 = 140. |
| 407 | punkte: [ |
| 408 | { x: 400, y: 140 }, |
| 409 | { x: 480, y: 140 }, |
| 410 | ], |
| 411 | haltlinieId: 'hl-furt', |
| 412 | startT: 0.25, |
| 413 | signalGroupId: 'sg-f1', |
| 414 | }, |
| 415 | { |
| 416 | id: 'pl-3', |
| 417 | name: 'Freie Linie ohne Haltlinie', |
| 418 | mode: 'oepnv', |
| 419 | movement: 'links', |
| 420 | breiteMeter: 2.9, |
| 421 | punkte: [ |
| 422 | { x: 12.5, y: 640.25 }, |
| 423 | { x: 300, y: 500 }, |
| 424 | ], |
| 425 | haltlinieId: null, |
| 426 | startT: 0.75, |
| 427 | signalGroupId: null, |
| 428 | }, |
| 429 | ], |
| 430 | }, |
| 431 | }; |
| 432 | } |
| 433 | |
| 434 | /** Der Weg, den eine Projektdatei tatsaechlich nimmt. */ |
| 435 | function rundreise(project: Project): ParseResult { |
| 436 | return parseProject(JSON.parse(serializeProject(project)), JETZT); |
| 437 | } |
| 438 | |
| 439 | const ANLAGENARTEN: readonly Anlagenart[] = [ |
| 440 | 'knotenpunkt', |
| 441 | 'fussgaengerschutzanlage', |
| 442 | 'einstreifig', |
| 443 | ]; |
| 444 | |
| 445 | describe('Projektdatei: Hin- und Rueckweg', () => { |
| 446 | for (const art of ANLAGENARTEN) { |
| 447 | it(`traegt jedes Feld unveraendert durch Datei und Einlesen (${art})`, () => { |
| 448 | const original = vollstaendigesProjekt(art); |
| 449 | |
| 450 | const { project, issues, repaired } = rundreise(original); |
| 451 | |
| 452 | // Der Vollvergleich ist der eigentliche Punkt dieser Pruefung: Ein |
| 453 | // Laengenvergleich haette den HCM-Fehler nicht gefunden. |
| 454 | expect(project).toEqual(original); |
| 455 | // Ein in sich stimmiges Projekt darf beim Einlesen NICHTS zu melden haben. |
| 456 | // Jede Meldung hier hiesse, dass etwas angefasst wurde. |
| 457 | expect(issues).toEqual([]); |
| 458 | expect(repaired).toBe(false); |
| 459 | }); |
| 460 | } |
| 461 | |
| 462 | it('haelt die Bewertungsskala HCM ueber die Datei - der Fehler, der das ausloeste', () => { |
| 463 | const original = vollstaendigesProjekt('knotenpunkt'); |
| 464 | |
| 465 | const { project } = rundreise(original); |
| 466 | |
| 467 | expect(project.settings.serviceLevelScale).toBe('HCM'); |
| 468 | }); |
| 469 | |
| 470 | it('schreibt lesbares JSON mit der aktuellen Schemaversion', () => { |
| 471 | const original = vollstaendigesProjekt('knotenpunkt'); |
| 472 | |
| 473 | const text = serializeProject(original); |
| 474 | const roh = JSON.parse(text) as Record<string, unknown>; |
| 475 | |
| 476 | expect(roh['schemaVersion']).toBe(CURRENT_SCHEMA_VERSION); |
| 477 | // Eingerueckt geschrieben - eine Projektdatei ist auch ein Pruefdokument. |
| 478 | expect(text).toContain('\n "id": "prj-rundreise"'); |
| 479 | }); |
| 480 | |
| 481 | it('setzt die Schemaversion auch dann richtig, wenn im Projekt eine alte steht', () => { |
| 482 | const alt: Project = { ...vollstaendigesProjekt('knotenpunkt'), schemaVersion: 7 }; |
| 483 | |
| 484 | const roh = JSON.parse(serializeProject(alt)) as Record<string, unknown>; |
| 485 | |
| 486 | expect(roh['schemaVersion']).toBe(CURRENT_SCHEMA_VERSION); |
| 487 | }); |
| 488 | |
| 489 | it('behaelt die vorgegebene Phasenreihenfolge, statt sie der Phasenliste anzugleichen', () => { |
| 490 | const original = vollstaendigesProjekt('knotenpunkt'); |
| 491 | |
| 492 | const { project } = rundreise(original); |
| 493 | |
| 494 | expect(project.program.phaseOrder).toEqual(['ph-2', 'ph-1']); |
| 495 | }); |
| 496 | |
| 497 | it('behaelt den Raeumweg einer langen einstreifigen Fuehrung', () => { |
| 498 | const basis = vollstaendigesProjekt('einstreifig'); |
| 499 | const original: Project = { |
| 500 | ...basis, |
| 501 | conflicts: basis.conflicts.map((c) => |
| 502 | c.id === 'cf-1' ? { ...c, clearingDistance: 800, enteringDistance: 620 } : c, |
| 503 | ), |
| 504 | }; |
| 505 | |
| 506 | const { project, issues } = rundreise(original); |
| 507 | |
| 508 | expect(project.conflicts[0]?.clearingDistance).toBe(800); |
| 509 | expect(project.conflicts[0]?.enteringDistance).toBe(620); |
| 510 | expect(issues).toEqual([]); |
| 511 | }); |
| 512 | |
| 513 | it('kuerzt denselben Raeumweg am Knotenpunkt auf die Schranke und meldet das', () => { |
| 514 | const basis = vollstaendigesProjekt('knotenpunkt'); |
| 515 | const original: Project = { |
| 516 | ...basis, |
| 517 | conflicts: basis.conflicts.map((c) => |
| 518 | c.id === 'cf-1' ? { ...c, clearingDistance: 800 } : c, |
| 519 | ), |
| 520 | }; |
| 521 | |
| 522 | const { project, issues } = rundreise(original); |
| 523 | |
| 524 | expect(project.conflicts[0]?.clearingDistance).toBe(500); |
| 525 | expect(issues.some((i) => i.path === 'conflicts[0].clearingDistance')).toBe(true); |
| 526 | }); |
| 527 | |
| 528 | /* |
| 529 | * Die Datei bewahrt die Vorgaben unter settings.rilsa unveraendert; geprueft |
| 530 | * werden sie erst dort, wo damit gerechnet wird. Das ist die richtige |
| 531 | * Reihenfolge - eine Datei, die Eingaben beim Speichern zurechtruecken wuerde, |
| 532 | * naehme dem Bearbeiter die Moeglichkeit, seinen Wert wiederzufinden. |
| 533 | */ |
| 534 | it('traegt eine unzulaessige Vorgabe unveraendert durch die Datei und beanstandet sie erst beim Rechnen', () => { |
| 535 | const basis = vollstaendigesProjekt('knotenpunkt'); |
| 536 | const original: Project = { |
| 537 | ...basis, |
| 538 | settings: { ...basis.settings, rilsa: { ...basis.settings.rilsa, yellowKfzUpTo50: 0.5 } }, |
| 539 | }; |
| 540 | |
| 541 | const { project, issues } = rundreise(original); |
| 542 | |
| 543 | expect(project.settings.rilsa.yellowKfzUpTo50).toBe(0.5); |
| 544 | expect(issues).toEqual([]); |
| 545 | |
| 546 | const { defaults, notes } = resolveRilsaSettings( |
| 547 | project.settings.rilsa, |
| 548 | undefined, |
| 549 | project.anlagenart, |
| 550 | ); |
| 551 | expect(defaults.yellowBySpeed[0]?.yellow).toBe(3); |
| 552 | expect(notes.some((n) => n.code === 'vorgabe-unterschritten:yellowKfzUpTo50')).toBe(true); |
| 553 | }); |
| 554 | |
| 555 | /* |
| 556 | * HEUTIGES VERHALTEN, ausdruecklich als Mangel gemeldet: Unter settings.rilsa |
| 557 | * bleibt jeder Schluessel stehen, auch ein unbekannter. Ein verschriebener |
| 558 | * Kennwertname wird damit ohne ein Wort mitgeschleppt und nie angewandt - der |
| 559 | * Bearbeiter sieht seine Vorgabe in der Datei stehen und rechnet trotzdem mit |
| 560 | * dem Regelwert. |
| 561 | */ |
| 562 | it('behaelt auch eine unbekannte Vorgabe unter settings.rilsa, ohne sie zu melden', () => { |
| 563 | const basis = vollstaendigesProjekt('knotenpunkt'); |
| 564 | const original: Project = { |
| 565 | ...basis, |
| 566 | settings: { |
| 567 | ...basis.settings, |
| 568 | rilsa: { ...basis.settings.rilsa, gelbzeitKfz: 4 } as unknown as RilsaOverrides, |
| 569 | }, |
| 570 | }; |
| 571 | |
| 572 | const { project, issues } = rundreise(original); |
| 573 | |
| 574 | expect((project.settings.rilsa as unknown as Record<string, unknown>)['gelbzeitKfz']).toBe(4); |
| 575 | expect(issues).toEqual([]); |
| 576 | }); |
| 577 | |
| 578 | it('behaelt das leere Projekt unveraendert', () => { |
| 579 | const leer = createEmptyProject('Neues Projekt', JETZT); |
| 580 | |
| 581 | const { project, issues } = rundreise(leer); |
| 582 | |
| 583 | expect(project).toEqual(leer); |
| 584 | expect(issues).toEqual([]); |
| 585 | }); |
| 586 | }); |
| 587 | |
| 588 | describe('Dateiname aus dem Projekt', () => { |
| 589 | function mitMeta(projectNumber: string, name: string): Project { |
| 590 | const basis = createEmptyProject('x', JETZT); |
| 591 | return { ...basis, meta: { ...basis.meta, projectNumber, name } }; |
| 592 | } |
| 593 | |
| 594 | it('setzt Nummer und Bezeichnung zusammen und haengt die Erweiterung an', () => { |
| 595 | expect(suggestFileName(mitMeta('VB-2026/0815', 'Königsallee'))).toBe( |
| 596 | 'VB-2026-0815 - Königsallee.lsap', |
| 597 | ); |
| 598 | }); |
| 599 | |
| 600 | it('behaelt Umlaute und Eszett, ersetzt aber die im Dateinamen verbotenen Zeichen', () => { |
| 601 | const name = suggestFileName(mitMeta('', 'Königsallee/Südring: Maß? "Bestand" <1> |neu|*')); |
| 602 | |
| 603 | expect(name).toBe('Königsallee-Südring- Maß- -Bestand- -1- -neu--.lsap'); |
| 604 | expect(name).not.toMatch(/[\\/:*?"<>|]/); |
| 605 | }); |
| 606 | |
| 607 | it('nimmt einen Ersatznamen, wenn Nummer und Bezeichnung leer sind', () => { |
| 608 | expect(suggestFileName(mitMeta('', ''))).toBe('Projekt.lsap'); |
| 609 | expect(suggestFileName(mitMeta(' ', ' '))).toBe('Projekt.lsap'); |
| 610 | }); |
| 611 | |
| 612 | it('laesst nur einen Teil weg, wenn nur einer leer ist', () => { |
| 613 | expect(suggestFileName(mitMeta('', 'Nur Name'))).toBe('Nur Name.lsap'); |
| 614 | expect(suggestFileName(mitMeta('VB-1', ''))).toBe('VB-1.lsap'); |
| 615 | }); |
| 616 | |
| 617 | it('zieht Zeilenumbrueche und Mehrfachleerzeichen zu einem Leerzeichen zusammen', () => { |
| 618 | expect(suggestFileName(mitMeta('', 'Zeile eins\nZeile zwei\tdrei'))).toBe( |
| 619 | 'Zeile eins Zeile zwei drei.lsap', |
| 620 | ); |
| 621 | }); |
| 622 | |
| 623 | it('begrenzt den Namensteil auf 120 Zeichen', () => { |
| 624 | const lang = 'K'.repeat(200); |
| 625 | |
| 626 | const name = suggestFileName(mitMeta('', lang)); |
| 627 | |
| 628 | expect(name).toBe(`${'K'.repeat(120)}.lsap`); |
| 629 | }); |
| 630 | |
| 631 | it('uebernimmt eine abweichende Erweiterung', () => { |
| 632 | expect(suggestFileName(mitMeta('VB-1', 'Name'), 'pdf')).toBe('VB-1 - Name.pdf'); |
| 633 | expect(suggestFileName(mitMeta('VB-1', 'Name'), 'csv')).toBe('VB-1 - Name.csv'); |
| 634 | }); |
| 635 | |
| 636 | /* |
| 637 | * HEUTIGES VERHALTEN, ausdruecklich als Mangel gemeldet: Die Begrenzung auf |
| 638 | * 120 Zeichen schneidet nach dem Trimmen. Faellt der Schnitt auf ein |
| 639 | * Leerzeichen, bleibt es stehen - der Dateiname endet dann auf " .lsap". |
| 640 | * Windows entfernt Leerzeichen vor der Erweiterung beim Anlegen, sodass der |
| 641 | * zurueckgemeldete Pfad von dem abweicht, was das Programm vorgeschlagen hat. |
| 642 | */ |
| 643 | it('laesst ein Leerzeichen vor der Erweiterung stehen, wenn der Schnitt darauf faellt', () => { |
| 644 | const name = suggestFileName(mitMeta('', `${'K'.repeat(119)} Rest dahinter`)); |
| 645 | |
| 646 | expect(name).toBe(`${'K'.repeat(119)} .lsap`); |
| 647 | }); |
| 648 | }); |
| 649 | |
| 650 | // --- Einlesen einer Datei --------------------------------------------------- |
| 651 | |
| 652 | /** |
| 653 | * parseFileContent ist nicht ausgefuehrt und laesst sich nur ueber |
| 654 | * openProjectFromFile erreichen. Die Bruecke wird dafuer nachgebildet - Electron |
| 655 | * wird nicht gebraucht, nur das Ergebnis eines Oeffnen-Dialogs. |
| 656 | */ |
| 657 | function mitBruecke(antwort: OpenFileResult): void { |
| 658 | const bruecke = { |
| 659 | openFile: () => Promise.resolve(antwort), |
| 660 | } as unknown as DesktopBridge; |
| 661 | (globalThis as unknown as { window?: { lsaPlaner?: DesktopBridge } }).window = { |
| 662 | lsaPlaner: bruecke, |
| 663 | }; |
| 664 | } |
| 665 | |
| 666 | function antwort(teil: Partial<OpenFileResult>): OpenFileResult { |
| 667 | return { |
| 668 | ok: true, |
| 669 | canceled: false, |
| 670 | filePath: 'C:/Plaene/Test.lsap', |
| 671 | content: null, |
| 672 | error: null, |
| 673 | ...teil, |
| 674 | }; |
| 675 | } |
| 676 | |
| 677 | afterEach(() => { |
| 678 | delete (globalThis as unknown as { window?: unknown }).window; |
| 679 | }); |
| 680 | |
| 681 | describe('Einlesen einer Projektdatei', () => { |
| 682 | it('liest eine selbst geschriebene Datei vollstaendig wieder ein', async () => { |
| 683 | const original = vollstaendigesProjekt('knotenpunkt'); |
| 684 | mitBruecke(antwort({ content: serializeProject(original) })); |
| 685 | |
| 686 | const ergebnis = await openProjectFromFile(); |
| 687 | |
| 688 | expect(ergebnis.ok).toBe(true); |
| 689 | expect(ergebnis.filePath).toBe('C:/Plaene/Test.lsap'); |
| 690 | expect(ergebnis.message).toBe('Projekt geladen.'); |
| 691 | expect(ergebnis.result?.migrated).toBe(false); |
| 692 | expect(ergebnis.result?.project.settings.serviceLevelScale).toBe('HCM'); |
| 693 | expect(ergebnis.result?.project.meta.name).toBe('Königsallee / Südring – Planfall 2'); |
| 694 | }); |
| 695 | |
| 696 | it('meldet unlesbaren Inhalt und liefert kein Projekt', async () => { |
| 697 | mitBruecke(antwort({ content: 'Das ist kein JSON, sondern Text.' })); |
| 698 | |
| 699 | const ergebnis = await openProjectFromFile(); |
| 700 | |
| 701 | expect(ergebnis.ok).toBe(false); |
| 702 | expect(ergebnis.result).toBeNull(); |
| 703 | expect(ergebnis.filePath).toBeNull(); |
| 704 | expect(ergebnis.message).toContain('kein gültiges JSON'); |
| 705 | }); |
| 706 | |
| 707 | it('meldet eine abgeschnittene Datei, statt ein halbes Projekt zu bauen', async () => { |
| 708 | const halb = serializeProject(vollstaendigesProjekt('knotenpunkt')).slice(0, 400); |
| 709 | mitBruecke(antwort({ content: halb })); |
| 710 | |
| 711 | const ergebnis = await openProjectFromFile(); |
| 712 | |
| 713 | expect(ergebnis.ok).toBe(false); |
| 714 | expect(ergebnis.result).toBeNull(); |
| 715 | }); |
| 716 | |
| 717 | it('gibt einen abgebrochenen Dialog als Abbruch weiter, nicht als Fehler', async () => { |
| 718 | mitBruecke(antwort({ ok: false, canceled: true, filePath: null })); |
| 719 | |
| 720 | const ergebnis = await openProjectFromFile(); |
| 721 | |
| 722 | expect(ergebnis.canceled).toBe(true); |
| 723 | expect(ergebnis.ok).toBe(false); |
| 724 | expect(ergebnis.message).toBe(''); |
| 725 | expect(ergebnis.result).toBeNull(); |
| 726 | }); |
| 727 | |
| 728 | it('reicht die Fehlermeldung der Bruecke durch, wenn die Datei nicht lesbar ist', async () => { |
| 729 | mitBruecke(antwort({ ok: false, content: null, error: 'Zugriff verweigert.' })); |
| 730 | |
| 731 | const ergebnis = await openProjectFromFile(); |
| 732 | |
| 733 | expect(ergebnis.ok).toBe(false); |
| 734 | expect(ergebnis.message).toBe('Zugriff verweigert.'); |
| 735 | }); |
| 736 | |
| 737 | it('weist eine Datei aus einer neueren Programmversion aus', async () => { |
| 738 | const neuer = { |
| 739 | ...vollstaendigesProjekt('knotenpunkt'), |
| 740 | schemaVersion: CURRENT_SCHEMA_VERSION + 5, |
| 741 | }; |
| 742 | mitBruecke(antwort({ content: JSON.stringify(neuer) })); |
| 743 | |
| 744 | const ergebnis = await openProjectFromFile(); |
| 745 | |
| 746 | expect(ergebnis.result?.issues.some((i) => i.path === 'schemaVersion')).toBe(true); |
| 747 | }); |
| 748 | |
| 749 | /* |
| 750 | * BEHOBEN als Befund 41 zur Fassung 5.10.0. Zuvor wurde jede beliebige |
| 751 | * JSON-Datei angenommen: Der Anwender bekam "Projekt geladen." und ein LEERES |
| 752 | * Projekt namens "Importiertes Projekt", sein bisheriger Stand war |
| 753 | * ueberschrieben, ohne dass ein Wort darueber fiel - und weil der Dateipfad |
| 754 | * mitkam, ersetzte das naechste Strg+S auch noch die fremde Datei durch |
| 755 | * dieses leere Projekt. Die Pruefung looksLikeProject() aus |
| 756 | * src/domain/model/schema.ts gab es, sie hatte nur keinen Aufrufer; |
| 757 | * `parseFileContent` wendet sie jetzt an. Die ausfuehrliche Wache steht in |
| 758 | * tests/services/fremdeDateiAbweisen.test.ts. |
| 759 | */ |
| 760 | it('weist eine beliebige JSON-Datei ab, statt ein leeres Projekt zu bauen', async () => { |
| 761 | mitBruecke(antwort({ content: '{"einkaufsliste":["Milch","Brot"]}' })); |
| 762 | |
| 763 | const ergebnis = await openProjectFromFile(); |
| 764 | |
| 765 | expect(ergebnis.ok).toBe(false); |
| 766 | expect(ergebnis.message).toContain('kein Projekt des LSA-Planers'); |
| 767 | expect(ergebnis.result).toBeNull(); |
| 768 | expect(ergebnis.filePath).toBeNull(); |
| 769 | }); |
| 770 | }); |