import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; import { mkdtempSync, readFileSync, readdirSync, writeFileSync } from 'node:fs'; import os from 'node:os'; import path from 'node:path'; import { IPC, type OpenFileResult, type SaveFileResult } from '../../shared/ipc'; /** * Befunde 17 und 40 zur Fassung 5.8.0. * * Beide Schreibwege des Hauptprozesses - der Kanal "Speichern unter" und der * Kanal "Speichern" auf einen bereits bekannten Pfad - schrieben mit * `fs/promises.writeFile` unmittelbar auf das Ziel. `writeFile` oeffnet mit dem * Kennzeichen 'w' und kuerzt die vorhandene Datei damit auf null Byte, BEVOR * das erste Byte des neuen Inhalts geschrieben ist. Scheiterte das Schreiben * danach - kein Platz mehr auf dem Datentraeger, abgezogener * Wechseldatentraeger, weggebrochenes Netzlaufwerk -, war die zuletzt * gesicherte Planunterlage fort. Der Anwender las "Auf dem Datenträger ist kein * Platz mehr frei." und nahm an, seine alte Datei stehe noch; beim naechsten * Oeffnen kam nur "Die Datei enthält kein gültiges JSON". * * Geprueft wird gegen ein echtes Verzeichnis. Ersetzt ist nur der Zeitpunkt des * Fehlschlags: Der Ersatz fuer `node:fs/promises` bildet das Kennzeichen 'w' * nach - er kuerzt beim Oeffnen und laesst erst das Schreiben scheitern, genau * wie ein voller Datentraeger es tut. */ const welt = vi.hoisted(() => { type Horcher = (...werte: unknown[]) => void; class Ereignisquelle { private readonly horcher = new Map(); on(name: string, fn: Horcher): this { const liste = this.horcher.get(name) ?? []; liste.push(fn); this.horcher.set(name, liste); return this; } once(name: string, fn: Horcher): this { return this.on(name, fn); } removeListener(name: string, fn: Horcher): this { this.horcher.set( name, (this.horcher.get(name) ?? []).filter((h) => h !== fn), ); return this; } off(name: string, fn: Horcher): this { return this.removeListener(name, fn); } } class Anzeigeinhalt extends Ereignisquelle { send(): void { /* Im Test ohne Wirkung. */ } setZoomFactor(): void { /* Im Test ohne Wirkung. */ } setWindowOpenHandler(): void { /* Im Test ohne Wirkung. */ } isCrashed(): boolean { return false; } isDestroyed(): boolean { return false; } } const zustand = { fenster: null as Fenster | null, benutzerdaten: '.', /** Was der Speichern-unter-Dialog zurueckgibt. */ speichernZiel: null as string | null, /** Was der Oeffnen-Dialog zurueckgibt. */ oeffnenZiel: null as string | null, /** Scharfgestellter Fehler beim Schreiben der Bytes (nicht beim Oeffnen). */ schreibfehler: null as NodeJS.ErrnoException | null, }; class Fenster extends Ereignisquelle { readonly webContents = new Anzeigeinhalt(); constructor() { super(); zustand.fenster = this; } loadFile(): Promise { return Promise.resolve(); } show(): void { /* Im Test ohne Wirkung. */ } setBackgroundColor(): void { /* Im Test ohne Wirkung. */ } setDocumentEdited(): void { /* Im Test ohne Wirkung. */ } isDestroyed(): boolean { return false; } close(): void { /* Im Test ohne Wirkung. */ } static getAllWindows(): Fenster[] { return zustand.fenster === null ? [] : [zustand.fenster]; } } const behandler = new Map unknown>(); const electron = { app: { isPackaged: false, requestSingleInstanceLock: (): boolean => true, on: (): void => undefined, whenReady: (): Promise => Promise.resolve(), getPath: (): string => zustand.benutzerdaten, getVersion: (): string => '5.8.0', quit: (): void => undefined, }, BrowserWindow: Fenster, Menu: { buildFromTemplate: (): { getMenuItemById: () => null } => ({ getMenuItemById: () => null }), setApplicationMenu: (): void => undefined, }, dialog: { showMessageBoxSync: (): number => 0, showErrorBox: (): void => undefined, showSaveDialog: (): Promise<{ canceled: boolean; filePath: string | undefined }> => Promise.resolve({ canceled: zustand.speichernZiel === null, filePath: zustand.speichernZiel ?? undefined, }), showOpenDialog: (): Promise<{ canceled: boolean; filePaths: string[] }> => Promise.resolve({ canceled: zustand.oeffnenZiel === null, filePaths: zustand.oeffnenZiel === null ? [] : [zustand.oeffnenZiel], }), }, ipcMain: { handle: (kanal: string, fn: (...werte: unknown[]) => unknown): void => { behandler.set(kanal, fn); }, on: (): void => undefined, }, nativeTheme: { shouldUseDarkColors: false, on: (): void => undefined }, session: { defaultSession: { webRequest: { onHeadersReceived: (): void => undefined }, setPermissionRequestHandler: (): void => undefined, setPermissionCheckHandler: (): void => undefined, }, }, shell: { openExternal: (): Promise => Promise.resolve() }, }; return { electron, zustand, behandler, zuruecksetzen: (): void => { zustand.fenster = null; zustand.speichernZiel = null; zustand.oeffnenZiel = null; zustand.schreibfehler = null; behandler.clear(); }, }; }); vi.mock('electron', () => welt.electron); /** * Ersatz fuer `node:fs/promises`. * * Er bildet nach, was ein voller Datentraeger tut: Das Oeffnen mit dem * Kennzeichen 'w' gelingt und kuerzt die Datei, erst das Schreiben der Bytes * scheitert. Alles uebrige geht an das echte Dateisystem. */ vi.mock('node:fs/promises', async (echtesModul) => { const echt = await echtesModul(); return { ...echt, default: echt, async writeFile(ziel: unknown, inhalt: unknown, optionen?: unknown): Promise { const griff = await echt.open(ziel as string, 'w'); try { if (welt.zustand.schreibfehler !== null) throw welt.zustand.schreibfehler; await griff.writeFile(inhalt as string, optionen as never); } finally { await griff.close(); } }, async open(ziel: unknown, kennzeichen?: unknown): Promise { const griff = await echt.open(ziel as string, (kennzeichen as string | undefined) ?? 'r'); const fehler = welt.zustand.schreibfehler; if (fehler === null) return griff; return { writeFile: (): Promise => Promise.reject(fehler), sync: (): Promise => griff.sync(), close: (): Promise => griff.close(), }; }, }; }); process.setMaxListeners(64); const ALTER_STAND = '{"schemaVersion":13,"meta":{"name":"Bahnhofstraße"}}'; const NEUER_STAND = '{"schemaVersion":13,"meta":{"name":"Bahnhofstraße West"}}'; let ablage = ''; /** Ein voller Datentraeger - der Fehler, den describe() ausdruecklich uebersetzt. */ function datentraegerVoll(): NodeJS.ErrnoException { const fehler: NodeJS.ErrnoException = new Error('no space left on device'); fehler.code = 'ENOSPC'; return fehler; } async function starteHauptprozess(): Promise { vi.resetModules(); welt.zuruecksetzen(); await import('../../electron/main'); for (let i = 0; i < 5; i += 1) await Promise.resolve(); } /** Der Anwender oeffnet die Datei - danach steht ihr Pfad in der Freigabeliste. */ async function oeffne(ziel: string): Promise { welt.zustand.oeffnenZiel = ziel; return (await welt.behandler.get(IPC.openFile)?.(null, [])) as OpenFileResult; } /** Strg+S auf einen bereits bekannten Pfad. */ async function speichere(ziel: string, daten: string): Promise { return (await welt.behandler.get(IPC.writeFile)?.(null, { filePath: ziel, data: daten, })) as SaveFileResult; } /** "Speichern unter" mit dem Dialog davor. */ async function speichereUnter(ziel: string, daten: string): Promise { welt.zustand.speichernZiel = ziel; return (await welt.behandler.get(IPC.saveFile)?.(null, { defaultName: path.basename(ziel), filters: [], data: daten, })) as SaveFileResult; } beforeEach(async () => { vi.spyOn(console, 'error').mockImplementation(() => undefined); ablage = mkdtempSync(path.join(os.tmpdir(), 'lsa-speichern-')); welt.zustand.benutzerdaten = mkdtempSync(path.join(os.tmpdir(), 'lsa-benutzer-')); await starteHauptprozess(); }); afterEach(() => { vi.restoreAllMocks(); }); describe('Speichern: die vorhandene Projektdatei überlebt einen Fehlschlag', () => { it('behält den zuletzt gesicherten Plan, wenn der Datenträger beim Strg+S volläuft', async () => { const ziel = path.join(ablage, 'knotenpunkt.lsap'); writeFileSync(ziel, ALTER_STAND, 'utf8'); await oeffne(ziel); welt.zustand.schreibfehler = datentraegerVoll(); const ergebnis = await speichere(ziel, NEUER_STAND); expect(ergebnis.ok).toBe(false); expect(ergebnis.error).toBe('Auf dem Datenträger ist kein Platz mehr frei.'); expect( readFileSync(ziel, 'utf8'), 'Die zuletzt gesicherte Planunterlage wurde beim Fehlschlag vernichtet', ).toBe(ALTER_STAND); }); it('lässt nach einem Fehlschlag keine halbe Datei neben dem Plan liegen', async () => { const ziel = path.join(ablage, 'knotenpunkt.lsap'); writeFileSync(ziel, ALTER_STAND, 'utf8'); await oeffne(ziel); welt.zustand.schreibfehler = datentraegerVoll(); await speichere(ziel, NEUER_STAND); expect(readdirSync(ablage)).toEqual(['knotenpunkt.lsap']); }); it('behält die überschriebene Datei auch bei "Speichern unter"', async () => { const ziel = path.join(ablage, 'vorlage.lsap'); writeFileSync(ziel, ALTER_STAND, 'utf8'); welt.zustand.schreibfehler = datentraegerVoll(); const ergebnis = await speichereUnter(ziel, NEUER_STAND); expect(ergebnis.ok).toBe(false); expect(ergebnis.error).toBe('Auf dem Datenträger ist kein Platz mehr frei.'); expect(readFileSync(ziel, 'utf8')).toBe(ALTER_STAND); expect(readdirSync(ablage)).toEqual(['vorlage.lsap']); }); }); describe('Speichern: der gelingende Weg bleibt unverändert', () => { it('schreibt den neuen Stand vollständig und räumt hinter sich auf', async () => { const ziel = path.join(ablage, 'knotenpunkt.lsap'); writeFileSync(ziel, ALTER_STAND, 'utf8'); await oeffne(ziel); const ergebnis = await speichere(ziel, NEUER_STAND); expect(ergebnis.ok).toBe(true); expect(ergebnis.filePath).toBe(ziel); expect(readFileSync(ziel, 'utf8')).toBe(NEUER_STAND); expect(readdirSync(ablage)).toEqual(['knotenpunkt.lsap']); }); it('legt bei "Speichern unter" die Datei an und gibt den freigegebenen Pfad zurück', async () => { const ziel = path.join(ablage, 'neu.lsap'); const ergebnis = await speichereUnter(ziel, NEUER_STAND); expect(ergebnis.ok).toBe(true); expect(ergebnis.filePath).toBe(path.resolve(ziel)); expect(readFileSync(ziel, 'utf8')).toBe(NEUER_STAND); expect(readdirSync(ablage)).toEqual(['neu.lsap']); }); });