import { afterEach, describe, expect, it } from 'vitest'; import { openProjectFromFile, serializeProject, suggestFileName } from '@/services/storage'; import { parseProject, type ParseResult } from '@/domain/model/schema'; import { createEmptyProject } from '@/domain/model/factory'; import { CURRENT_SCHEMA_VERSION, type Project } from '@/domain/model/project'; import { resolveRilsaSettings, type RilsaOverrides } from '@/domain/rilsa/settings'; import type { Anlagenart } from '@/domain/rilsa/types'; import type { DesktopBridge, OpenFileResult } from '@/platform/bridge'; /** * Die Projektdatei als Ganzes: Hin- und Rueckweg, Dateiname, Einlesen. * * WARUM DIESE PRUEFUNG SEIN MUSS * * serializeProject kam bis hierher in keiner einzigen Pruefung vor. Der * vorhandene Rundreisetest (tests/domain/schema.test.ts) vergleicht nur * Listenlaengen und die Schemaversion - genau dadurch blieb unentdeckt, dass die * Bewertungsskala HCM bei jedem Speichern auf HBS zurueckfiel. Ein Feld, das den * Weg durch die Datei nicht uebersteht, faellt niemandem auf: Das Projekt * oeffnet sich klaglos, es steht nur etwas anderes darin als vorher. * * Deshalb wird hier VOLLSTAENDIG verglichen - jedes Feld gegen sein Gegenstueck, * nicht die Anzahl der Eintraege. Das Vergleichsprojekt weicht dazu in moeglichst * vielen Angaben vom Vorgabewert ab: Ein Feld, das seinen Regelwert traegt, * koennte den Weg auch dann "ueberstehen", wenn es unterwegs verloren geht und * beim Einlesen neu vergeben wird. */ const JETZT = new Date('2026-08-14T09:00:00.000Z'); /** * Ein Projekt, in dem nahezu jedes Feld vom Vorgabewert abweicht. * * Die Raeumwege bleiben unter 100 m, damit dasselbe Projekt fuer alle drei * Anlagenarten gilt - die Fussgaengerschutzanlage hat die engste Schranke. Der * lange Raeumweg einer einstreifigen Fuehrung wird getrennt geprueft. */ function vollstaendigesProjekt(anlagenart: Anlagenart): Project { return { schemaVersion: CURRENT_SCHEMA_VERSION, id: 'prj-rundreise', anlagenart, meta: { name: 'Königsallee / Südring – Planfall 2', projectNumber: 'VB-2026/0815', client: 'Straßenverkehrsbehörde Köln', location: 'Köln, Königsallee × Südring', planner: 'O. Willerding', variant: 'Planfall 2 (Umbau)', description: 'Zweistufige Furt.\nAufstellfläche „Mittelinsel" 2,5 m breit; Räumen in einem Zug.', // Pflichtangaben der Anordnung (Befund C14). Beide sind neu und im leeren // Projekt '' - hier deshalb mit Inhalt, sonst kaeme dieselbe Zeichenkette // beim Einlesen auch dann heraus, wenn das Feld unterwegs verloren geht. einsatzzeiten: '12.05.–30.06.2027, werktags 6–19 Uhr', betriebsverantwortlicher: 'Mustermann Bau GmbH, Rufbereitschaft 0170 1234567', // Verweise auf Unterlagen, die dieses Programm nicht erzeugt. Aus // demselben Grund mit Inhalt: Sie sind im leeren Projekt '' und fielen // sonst auch dann nicht auf, wenn sie unterwegs verloren gingen. verkehrszeichenplan: 'VZP 2027-14, Stand 03.05.2027', regelplan: 'B I/5', zustimmungBaulasttraeger: 'LBM Az. 4711-2027 vom 28.04.2027', createdAt: '2026-02-01T07:30:00.000Z', modifiedAt: '2026-08-13T16:45:12.750Z', }, intersection: { name: 'Königsallee / Südring', vZul: 70, arms: [ { id: 'arm-nord', name: 'Zufahrt Nord', direction: 'Nord (Königsallee)', lanes: 3, vZul: 70, }, { id: 'arm-sued', name: 'Zufahrt Süd', direction: 'Süd (Bahnhofstraße)', lanes: 2, vZul: 30, }, ], notes: 'Vierarmig, Südzufahrt aufgeweitet (Rechtsabbieger frei).', }, signalGroups: [ { id: 'sg-k1', name: 'K1', mode: 'kfz', movement: 'geradeaus', armId: 'arm-nord', vZul: 70, lanes: 3, vehicleClass: 'pkw', minGreenOverride: 9, maxGreenOverride: 55, reducedMobility: false, color: '#1a2b3c', }, { id: 'sg-k2', name: 'K2', mode: 'kfz', movement: 'links', armId: 'arm-sued', vZul: 30, lanes: 1, vehicleClass: 'lkw', minGreenOverride: null, maxGreenOverride: null, reducedMobility: false, color: '#e07b39', }, { id: 'sg-k3', name: 'K3', mode: 'kfz', movement: 'rechts', armId: 'arm-nord', vZul: 70, lanes: 2, vehicleClass: 'lastzug', minGreenOverride: 12, maxGreenOverride: null, reducedMobility: false, color: '#4a9e6a', }, { id: 'sg-r1', name: 'R1', mode: 'rad', movement: 'querung', armId: 'arm-sued', vZul: 20, lanes: 1, vehicleClass: 'rad', minGreenOverride: null, maxGreenOverride: 40, reducedMobility: false, color: '#a05ec4', }, { id: 'sg-f1', name: 'F1', mode: 'fuss', movement: 'querung', armId: null, vZul: 5, lanes: 1, vehicleClass: 'keine', minGreenOverride: 7, maxGreenOverride: null, reducedMobility: true, color: '#c4526a', }, { id: 'sg-b1', name: 'B1', mode: 'oepnv', movement: 'geradeaus', armId: 'arm-sued', vZul: 50, lanes: 1, vehicleClass: 'bus', minGreenOverride: null, maxGreenOverride: null, reducedMobility: false, color: '#3d9bb0', }, ], conflicts: [ { id: 'cf-1', fromId: 'sg-k1', toId: 'sg-f1', clearingDistance: 88.5, enteringDistance: 12.25, clearingSpeedOverride: 9.5, enteringSpeedOverride: 11.5, crossingTimeOverride: 2.5, manualIntergreen: null, wegnachweis: { herkunft: 'lageplan', uebernommenAm: '2026-03-04T08:15:30.250Z' }, note: 'Räumweg bis Hinterkante Furt – Maßstab 1:250; Zuschlag für „Mittelinsel".', }, { id: 'cf-2', fromId: 'sg-f1', toId: 'sg-k1', clearingDistance: 24, enteringDistance: 3.5, clearingSpeedOverride: null, enteringSpeedOverride: null, crossingTimeOverride: null, manualIntergreen: 11, wegnachweis: { herkunft: 'handeingabe', uebernommenAm: null }, note: 'Zwischenzeit nach Absprache mit der Straßenverkehrsbehörde festgesetzt (11 s).', }, { id: 'cf-3', fromId: 'sg-k2', toId: 'sg-b1', clearingDistance: 40, enteringDistance: 8, clearingSpeedOverride: null, enteringSpeedOverride: 8.5, crossingTimeOverride: null, manualIntergreen: null, wegnachweis: { herkunft: 'unbestimmt', uebernommenAm: null }, // sg-k2 biegt links ab - hier wirkt das Merkmal, es ist also keine // Angabe, die beim Einlesen ohnehin auf 'false' zurueckfaellt. engerRadius: true, note: '', }, { id: 'cf-4', fromId: 'sg-r1', toId: 'sg-k3', clearingDistance: 15.75, enteringDistance: 5.25, clearingSpeedOverride: 4.5, enteringSpeedOverride: null, crossingTimeOverride: 1.5, manualIntergreen: null, wegnachweis: { herkunft: 'lageplan', uebernommenAm: '2026-03-04T08:17:02.000Z' }, note: 'Radquerung, Überfahrzeit abweichend.', }, ], phases: [ { id: 'ph-1', name: 'Phase 1 – Nord/Süd', signalGroupIds: ['sg-k1', 'sg-k3', 'sg-f1'], manualGreen: 28, }, { id: 'ph-2', name: 'Phase 2 – ÖPNV und Rad', signalGroupIds: ['sg-k2', 'sg-b1', 'sg-r1'], manualGreen: null, }, ], program: { id: 'prog-1', name: 'Programm 3 – Nachtabsenkung', // Absichtlich NICHT die Reihenfolge der Phasenliste: Ein Umlauf, der beim // Speichern still umsortiert wird, schaltet danach anders. phaseOrder: ['ph-2', 'ph-1'], method: 'manuell', manualCycleTime: 95, offset: 37, }, demands: [ { signalGroupId: 'sg-k1', volume: 1240, heavyVehicleShare: 0.125 }, { signalGroupId: 'sg-k2', volume: 615, heavyVehicleShare: 0.08 }, { signalGroupId: 'sg-b1', volume: 42, heavyVehicleShare: 1 }, { signalGroupId: 'sg-r1', volume: 0, heavyVehicleShare: 0 }, ], /* * Zwei eingelagerte Tagesprogramme (Schema 16), und beide fuehren etwas, * das ein Vorgabewert nicht hergibt: eigene Verkehrsstaerken, eine eigene * feste Freigabezeit, ein anderes Verfahren, einen anderen Versatz. Geht * eines davon auf dem Weg durch die Datei verloren, plant der Bearbeiter * nach dem Sichern eine andere Anlage. */ programmbibliothek: [ { programm: { id: 'prog-tag', name: 'Programm 1 – Tag', phaseOrder: ['ph-1', 'ph-2'], method: 'webster', manualCycleTime: null, offset: 0, }, demands: [ { signalGroupId: 'sg-k1', volume: 1810, heavyVehicleShare: 0.06 }, { signalGroupId: 'sg-k2', volume: 940, heavyVehicleShare: 0.04, lastzugAnteil: 0.3 }, ], festeFreigabezeiten: [{ phaseId: 'ph-1', sekunden: 48 }], }, { programm: { id: 'prog-fruehspitze', name: 'Programm 2 – Frühspitze', phaseOrder: ['ph-2', 'ph-1'], method: 'hbs', manualCycleTime: null, offset: 12, }, demands: [{ signalGroupId: 'sg-r1', volume: 130, heavyVehicleShare: 0 }], festeFreigabezeiten: [], }, ], // Absichtlich nicht 0: Das aktive Programm steht an dritter Stelle, und // diese Stelle ueberlebt das Sichern - sonst sortierte sich die Auswahl // beim Oeffnen um. programmplatz: 2, /* * Eine Koordinierungskette (Schema 17) mit zwei Nachbarn, einer davon mit * Gegenrichtung. Die Abstaende sind eine Vermessung vor Ort: Gehen sie auf * dem Weg durch die Datei verloren, ist die Gruene Welle nach dem naechsten * Sichern eine andere. */ koordinierung: { progressionsgeschwindigkeit: 48, hauptrichtung: 'sg-k1', gegenrichtung: 'sg-k2', eigenerAbstand: 285, platz: 1, nachbarn: [ { id: 'knoten-west', name: 'Königsallee / Weststraße', abstand: 320, freigabeBeginn: 12, freigabeDauer: 34, gegenBeginn: 55, gegenDauer: 28, }, { id: 'knoten-ost', name: 'Königsallee / Oststraße', abstand: 410, freigabeBeginn: 5, freigabeDauer: 40, }, ], }, settings: { rilsa: { yellowKfzUpTo50: 4, minGreenFuss: 7, clearingSpeedKfzTurning: 6.5, enteringSpeedFuss: 1.4, crossingTimeFuss: 1.2, vehicleLengthLastzug: 18.5, cycleTimeMax: 110, saturationFlow: 1900, coordinationDefaultSpeed: 45, }, serviceLevelScale: 'HCM', // Absichtlich NICHT die Vorgabe 'gelbzeit': Der Ansatz der Ueberfahrzeit // waere sonst auch dann richtig wieder da, wenn er unterwegs verloren // geht und beim Einlesen neu vergeben wird. ueberfahrzeitAnsatz: 'fest', }, lageplan: { arbeitsbereiche: [], signalgeber: [], bild: { datenUrl: 'data:image/png;base64,iVBORw0KGgoAAAANSUhEUg==', breite: 1024, hoehe: 768, herkunft: 'Amtliches Luftbild (DOP20), Bezirksregierung Köln', geladenAm: '2026-03-04T08:10:00.000Z', }, kalibrierung: { von: { x: 10, y: 20 }, bis: { x: 410, y: 20 }, laengeMeter: 40, herkunft: 'georeferenziert', }, haltlinien: [ { id: 'hl-nord', name: 'Haltlinie Nord', art: 'zufahrt', armId: 'arm-nord', von: { x: 100, y: 300 }, bis: { x: 300, y: 300 }, }, { id: 'hl-furt', name: 'Aufstelllinie Furt Ost', art: 'querung', armId: 'arm-sued', von: { x: 400, y: 100 }, bis: { x: 400, y: 260 }, }, ], linien: [ { id: 'pl-1', name: 'K1 geradeaus', mode: 'kfz', movement: 'geradeaus', breiteMeter: 3.5, // Der erste Punkt folgt aus Haltlinie und startT (Mitte von hl-nord). punkte: [ { x: 200, y: 300 }, { x: 200, y: 180 }, { x: 205, y: 60 }, ], haltlinieId: 'hl-nord', startT: 0.5, signalGroupId: 'sg-k1', }, { id: 'pl-2', name: 'F1 Querung', mode: 'fuss', movement: 'querung', breiteMeter: 4.5, // Bei startT 0.25 liegt der Startpunkt auf y = 100 + 0,25 * 160 = 140. punkte: [ { x: 400, y: 140 }, { x: 480, y: 140 }, ], haltlinieId: 'hl-furt', startT: 0.25, signalGroupId: 'sg-f1', }, { id: 'pl-3', name: 'Freie Linie ohne Haltlinie', mode: 'oepnv', movement: 'links', breiteMeter: 2.9, punkte: [ { x: 12.5, y: 640.25 }, { x: 300, y: 500 }, ], haltlinieId: null, startT: 0.75, signalGroupId: null, }, ], }, }; } /** Der Weg, den eine Projektdatei tatsaechlich nimmt. */ function rundreise(project: Project): ParseResult { return parseProject(JSON.parse(serializeProject(project)), JETZT); } const ANLAGENARTEN: readonly Anlagenart[] = [ 'knotenpunkt', 'fussgaengerschutzanlage', 'einstreifig', ]; describe('Projektdatei: Hin- und Rueckweg', () => { for (const art of ANLAGENARTEN) { it(`traegt jedes Feld unveraendert durch Datei und Einlesen (${art})`, () => { const original = vollstaendigesProjekt(art); const { project, issues, repaired } = rundreise(original); // Der Vollvergleich ist der eigentliche Punkt dieser Pruefung: Ein // Laengenvergleich haette den HCM-Fehler nicht gefunden. expect(project).toEqual(original); // Ein in sich stimmiges Projekt darf beim Einlesen NICHTS zu melden haben. // Jede Meldung hier hiesse, dass etwas angefasst wurde. expect(issues).toEqual([]); expect(repaired).toBe(false); }); } it('haelt die Bewertungsskala HCM ueber die Datei - der Fehler, der das ausloeste', () => { const original = vollstaendigesProjekt('knotenpunkt'); const { project } = rundreise(original); expect(project.settings.serviceLevelScale).toBe('HCM'); }); it('schreibt lesbares JSON mit der aktuellen Schemaversion', () => { const original = vollstaendigesProjekt('knotenpunkt'); const text = serializeProject(original); const roh = JSON.parse(text) as Record; expect(roh['schemaVersion']).toBe(CURRENT_SCHEMA_VERSION); // Eingerueckt geschrieben - eine Projektdatei ist auch ein Pruefdokument. expect(text).toContain('\n "id": "prj-rundreise"'); }); it('setzt die Schemaversion auch dann richtig, wenn im Projekt eine alte steht', () => { const alt: Project = { ...vollstaendigesProjekt('knotenpunkt'), schemaVersion: 7 }; const roh = JSON.parse(serializeProject(alt)) as Record; expect(roh['schemaVersion']).toBe(CURRENT_SCHEMA_VERSION); }); it('behaelt die vorgegebene Phasenreihenfolge, statt sie der Phasenliste anzugleichen', () => { const original = vollstaendigesProjekt('knotenpunkt'); const { project } = rundreise(original); expect(project.program.phaseOrder).toEqual(['ph-2', 'ph-1']); }); it('behaelt den Raeumweg einer langen einstreifigen Fuehrung', () => { const basis = vollstaendigesProjekt('einstreifig'); const original: Project = { ...basis, conflicts: basis.conflicts.map((c) => c.id === 'cf-1' ? { ...c, clearingDistance: 800, enteringDistance: 620 } : c, ), }; const { project, issues } = rundreise(original); expect(project.conflicts[0]?.clearingDistance).toBe(800); expect(project.conflicts[0]?.enteringDistance).toBe(620); expect(issues).toEqual([]); }); it('kuerzt denselben Raeumweg am Knotenpunkt auf die Schranke und meldet das', () => { const basis = vollstaendigesProjekt('knotenpunkt'); const original: Project = { ...basis, conflicts: basis.conflicts.map((c) => c.id === 'cf-1' ? { ...c, clearingDistance: 800 } : c, ), }; const { project, issues } = rundreise(original); expect(project.conflicts[0]?.clearingDistance).toBe(500); expect(issues.some((i) => i.path === 'conflicts[0].clearingDistance')).toBe(true); }); /* * Die Datei bewahrt die Vorgaben unter settings.rilsa unveraendert; geprueft * werden sie erst dort, wo damit gerechnet wird. Das ist die richtige * Reihenfolge - eine Datei, die Eingaben beim Speichern zurechtruecken wuerde, * naehme dem Bearbeiter die Moeglichkeit, seinen Wert wiederzufinden. */ it('traegt eine unzulaessige Vorgabe unveraendert durch die Datei und beanstandet sie erst beim Rechnen', () => { const basis = vollstaendigesProjekt('knotenpunkt'); const original: Project = { ...basis, settings: { ...basis.settings, rilsa: { ...basis.settings.rilsa, yellowKfzUpTo50: 0.5 } }, }; const { project, issues } = rundreise(original); expect(project.settings.rilsa.yellowKfzUpTo50).toBe(0.5); expect(issues).toEqual([]); const { defaults, notes } = resolveRilsaSettings( project.settings.rilsa, undefined, project.anlagenart, ); expect(defaults.yellowBySpeed[0]?.yellow).toBe(3); expect(notes.some((n) => n.code === 'vorgabe-unterschritten:yellowKfzUpTo50')).toBe(true); }); /* * HEUTIGES VERHALTEN, ausdruecklich als Mangel gemeldet: Unter settings.rilsa * bleibt jeder Schluessel stehen, auch ein unbekannter. Ein verschriebener * Kennwertname wird damit ohne ein Wort mitgeschleppt und nie angewandt - der * Bearbeiter sieht seine Vorgabe in der Datei stehen und rechnet trotzdem mit * dem Regelwert. */ it('behaelt auch eine unbekannte Vorgabe unter settings.rilsa, ohne sie zu melden', () => { const basis = vollstaendigesProjekt('knotenpunkt'); const original: Project = { ...basis, settings: { ...basis.settings, rilsa: { ...basis.settings.rilsa, gelbzeitKfz: 4 } as unknown as RilsaOverrides, }, }; const { project, issues } = rundreise(original); expect((project.settings.rilsa as unknown as Record)['gelbzeitKfz']).toBe(4); expect(issues).toEqual([]); }); it('behaelt das leere Projekt unveraendert', () => { const leer = createEmptyProject('Neues Projekt', JETZT); const { project, issues } = rundreise(leer); expect(project).toEqual(leer); expect(issues).toEqual([]); }); }); describe('Dateiname aus dem Projekt', () => { function mitMeta(projectNumber: string, name: string): Project { const basis = createEmptyProject('x', JETZT); return { ...basis, meta: { ...basis.meta, projectNumber, name } }; } it('setzt Nummer und Bezeichnung zusammen und haengt die Erweiterung an', () => { expect(suggestFileName(mitMeta('VB-2026/0815', 'Königsallee'))).toBe( 'VB-2026-0815 - Königsallee.lsap', ); }); it('behaelt Umlaute und Eszett, ersetzt aber die im Dateinamen verbotenen Zeichen', () => { const name = suggestFileName(mitMeta('', 'Königsallee/Südring: Maß? "Bestand" <1> |neu|*')); expect(name).toBe('Königsallee-Südring- Maß- -Bestand- -1- -neu--.lsap'); expect(name).not.toMatch(/[\\/:*?"<>|]/); }); it('nimmt einen Ersatznamen, wenn Nummer und Bezeichnung leer sind', () => { expect(suggestFileName(mitMeta('', ''))).toBe('Projekt.lsap'); expect(suggestFileName(mitMeta(' ', ' '))).toBe('Projekt.lsap'); }); it('laesst nur einen Teil weg, wenn nur einer leer ist', () => { expect(suggestFileName(mitMeta('', 'Nur Name'))).toBe('Nur Name.lsap'); expect(suggestFileName(mitMeta('VB-1', ''))).toBe('VB-1.lsap'); }); it('zieht Zeilenumbrueche und Mehrfachleerzeichen zu einem Leerzeichen zusammen', () => { expect(suggestFileName(mitMeta('', 'Zeile eins\nZeile zwei\tdrei'))).toBe( 'Zeile eins Zeile zwei drei.lsap', ); }); it('begrenzt den Namensteil auf 120 Zeichen', () => { const lang = 'K'.repeat(200); const name = suggestFileName(mitMeta('', lang)); expect(name).toBe(`${'K'.repeat(120)}.lsap`); }); it('uebernimmt eine abweichende Erweiterung', () => { expect(suggestFileName(mitMeta('VB-1', 'Name'), 'pdf')).toBe('VB-1 - Name.pdf'); expect(suggestFileName(mitMeta('VB-1', 'Name'), 'csv')).toBe('VB-1 - Name.csv'); }); /* * HEUTIGES VERHALTEN, ausdruecklich als Mangel gemeldet: Die Begrenzung auf * 120 Zeichen schneidet nach dem Trimmen. Faellt der Schnitt auf ein * Leerzeichen, bleibt es stehen - der Dateiname endet dann auf " .lsap". * Windows entfernt Leerzeichen vor der Erweiterung beim Anlegen, sodass der * zurueckgemeldete Pfad von dem abweicht, was das Programm vorgeschlagen hat. */ it('laesst ein Leerzeichen vor der Erweiterung stehen, wenn der Schnitt darauf faellt', () => { const name = suggestFileName(mitMeta('', `${'K'.repeat(119)} Rest dahinter`)); expect(name).toBe(`${'K'.repeat(119)} .lsap`); }); }); // --- Einlesen einer Datei --------------------------------------------------- /** * parseFileContent ist nicht ausgefuehrt und laesst sich nur ueber * openProjectFromFile erreichen. Die Bruecke wird dafuer nachgebildet - Electron * wird nicht gebraucht, nur das Ergebnis eines Oeffnen-Dialogs. */ function mitBruecke(antwort: OpenFileResult): void { const bruecke = { openFile: () => Promise.resolve(antwort), } as unknown as DesktopBridge; (globalThis as unknown as { window?: { lsaPlaner?: DesktopBridge } }).window = { lsaPlaner: bruecke, }; } function antwort(teil: Partial): OpenFileResult { return { ok: true, canceled: false, filePath: 'C:/Plaene/Test.lsap', content: null, error: null, ...teil, }; } afterEach(() => { delete (globalThis as unknown as { window?: unknown }).window; }); describe('Einlesen einer Projektdatei', () => { it('liest eine selbst geschriebene Datei vollstaendig wieder ein', async () => { const original = vollstaendigesProjekt('knotenpunkt'); mitBruecke(antwort({ content: serializeProject(original) })); const ergebnis = await openProjectFromFile(); expect(ergebnis.ok).toBe(true); expect(ergebnis.filePath).toBe('C:/Plaene/Test.lsap'); expect(ergebnis.message).toBe('Projekt geladen.'); expect(ergebnis.result?.migrated).toBe(false); expect(ergebnis.result?.project.settings.serviceLevelScale).toBe('HCM'); expect(ergebnis.result?.project.meta.name).toBe('Königsallee / Südring – Planfall 2'); }); it('meldet unlesbaren Inhalt und liefert kein Projekt', async () => { mitBruecke(antwort({ content: 'Das ist kein JSON, sondern Text.' })); const ergebnis = await openProjectFromFile(); expect(ergebnis.ok).toBe(false); expect(ergebnis.result).toBeNull(); expect(ergebnis.filePath).toBeNull(); expect(ergebnis.message).toContain('kein gültiges JSON'); }); it('meldet eine abgeschnittene Datei, statt ein halbes Projekt zu bauen', async () => { const halb = serializeProject(vollstaendigesProjekt('knotenpunkt')).slice(0, 400); mitBruecke(antwort({ content: halb })); const ergebnis = await openProjectFromFile(); expect(ergebnis.ok).toBe(false); expect(ergebnis.result).toBeNull(); }); it('gibt einen abgebrochenen Dialog als Abbruch weiter, nicht als Fehler', async () => { mitBruecke(antwort({ ok: false, canceled: true, filePath: null })); const ergebnis = await openProjectFromFile(); expect(ergebnis.canceled).toBe(true); expect(ergebnis.ok).toBe(false); expect(ergebnis.message).toBe(''); expect(ergebnis.result).toBeNull(); }); it('reicht die Fehlermeldung der Bruecke durch, wenn die Datei nicht lesbar ist', async () => { mitBruecke(antwort({ ok: false, content: null, error: 'Zugriff verweigert.' })); const ergebnis = await openProjectFromFile(); expect(ergebnis.ok).toBe(false); expect(ergebnis.message).toBe('Zugriff verweigert.'); }); it('weist eine Datei aus einer neueren Programmversion aus', async () => { const neuer = { ...vollstaendigesProjekt('knotenpunkt'), schemaVersion: CURRENT_SCHEMA_VERSION + 5, }; mitBruecke(antwort({ content: JSON.stringify(neuer) })); const ergebnis = await openProjectFromFile(); expect(ergebnis.result?.issues.some((i) => i.path === 'schemaVersion')).toBe(true); }); /* * BEHOBEN als Befund 41 zur Fassung 5.10.0. Zuvor wurde jede beliebige * JSON-Datei angenommen: Der Anwender bekam "Projekt geladen." und ein LEERES * Projekt namens "Importiertes Projekt", sein bisheriger Stand war * ueberschrieben, ohne dass ein Wort darueber fiel - und weil der Dateipfad * mitkam, ersetzte das naechste Strg+S auch noch die fremde Datei durch * dieses leere Projekt. Die Pruefung looksLikeProject() aus * src/domain/model/schema.ts gab es, sie hatte nur keinen Aufrufer; * `parseFileContent` wendet sie jetzt an. Die ausfuehrliche Wache steht in * tests/services/fremdeDateiAbweisen.test.ts. */ it('weist eine beliebige JSON-Datei ab, statt ein leeres Projekt zu bauen', async () => { mitBruecke(antwort({ content: '{"einkaufsliste":["Milch","Brot"]}' })); const ergebnis = await openProjectFromFile(); expect(ergebnis.ok).toBe(false); expect(ergebnis.message).toContain('kein Projekt des LSA-Planers'); expect(ergebnis.result).toBeNull(); expect(ergebnis.filePath).toBeNull(); }); });