lsa-planer

LSA-Planer Professional – Planungssoftware für Lichtsignalanlagen nach RiLSA 2015 und § 45 StVO. EUPL-1.2.

/ tests services projektdatei.test.ts

25,8 KB Rohdatei
tests/services/projektdatei.test.ts — 770 Zeilen
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 });