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 1 und 2 zur Fassung 5.9.0. * * `ipcMain.handle` reiht nichts ein: Ein zweiter Aufruf laeuft los, sobald der * erste an einem `await` haengt. Der Name der Nachbardatei entstand allein aus * dem Zielpfad und war fuer beide Laeufe derselbe. Zwei Speichervorgaenge auf * dieselbe Projektdatei - zweimal Strg+S, waehrend ein eingebettetes Luftbild * auf ein Netzlaufwerk geschrieben wird, oder Strg+S und danach "Speichern und * schliessen" - oeffneten damit dieselbe `.teil` mit dem Kennzeichen 'w'. * Wer zuerst umbenannte, schob sie auf das Ziel; der ueberlebende Griff zeigte * danach auf den Dateikoerper, der jetzt DAS ZIEL ist, und schrieb unmittelbar * hinein. Der zweite `rename` fand seine Nachbardatei nicht mehr vor und * meldete "Die Datei oder der Ordner wurde nicht gefunden.", waehrend der * Anwender daneben "Gespeichert: ..." las. * * Damit bricht die Zusage im Kopf von `schreibeUnteilbar` ("Am Zielort steht * stets entweder die alte oder die neue vollstaendige Fassung") und die Regel, * dass die Projektdatei nie unmittelbar ueberschrieben wird. * * Geprueft wird gegen ein echtes Verzeichnis. Ersetzt ist nur die Dauer des * ersten Schreibvorgangs: Der erste geoeffnete Griff haelt beim Schreiben an, * bis der Test ihn freigibt - genauso, wie ein Netzlaufwerk 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: '.', oeffnenZiel: null as string | null, /** * Bremse fuer den naechsten geoeffneten Griff. * * Genau EIN Griff wird angehalten - der erste, der nach dem Scharfstellen * geoeffnet wird. Alle weiteren laufen ungebremst, damit sich der zweite * Speichervorgang wirklich an dem ersten vorbeidraengeln kann. */ bremse: null as Promise | 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.9.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: true, filePath: 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.oeffnenZiel = null; zustand.bremse = null; behandler.clear(); }, }; }); vi.mock('electron', () => welt.electron); /** * Ersatz fuer `node:fs/promises`. * * Nur `open` ist ersetzt, und auch das nur fuer einen einzigen Griff: Der erste * Griff, der nach dem Scharfstellen der Bremse geoeffnet wird, haelt vor dem * Schreiben an. Alles uebrige - das Kennzeichen 'w', das Kuerzen beim Oeffnen, * das Umbenennen - geht an das echte Dateisystem. */ vi.mock('node:fs/promises', async (echtesModul) => { const echt = await echtesModul(); return { ...echt, default: echt, async open(ziel: unknown, kennzeichen?: unknown): Promise { const griff = await echt.open(ziel as string, (kennzeichen as string | undefined) ?? 'r'); const bremse = welt.zustand.bremse; if (bremse === null) return griff; welt.zustand.bremse = null; return { async writeFile(inhalt: string): Promise { await bremse; await griff.writeFile(inhalt); }, sync: (): Promise => griff.sync(), close: (): Promise => griff.close(), }; }, }; }); process.setMaxListeners(64); const ALTER_STAND = '{"schemaVersion":13,"meta":{"name":"Bahnhofstraße"}}'; /** Der lange erste Stand - ein Projekt mit eingebettetem Luftbild. */ const LANGER_STAND = `{"schemaVersion":13,"bild":"${'A'.repeat(4_000)}"}`; /** Der kurze zweite Stand, den der Anwender waehrend des Wartens erzeugt. */ const KURZER_STAND = '{"schemaVersion":13,"meta":{"name":"Bahnhofstraße West"}}'; let ablage = ''; 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. */ function speichere(ziel: string, daten: string): Promise { return welt.behandler.get(IPC.writeFile)?.(null, { filePath: ziel, data: daten, }) as Promise; } /** Laesst die angestossenen Zusagen so weit laufen, wie sie ohne uns kommen. */ async function laufenLassen(): Promise { for (let i = 0; i < 40; i += 1) await new Promise((fertig) => setTimeout(fertig, 0)); } interface Bremse { readonly versprechen: Promise; freigeben(): void; } /** Ein Schreibvorgang, der erst auf Zuruf weiterlaeuft. */ function neueBremse(): Bremse { let loesen: (() => void) | null = null; const versprechen = new Promise((fertig) => { loesen = () => { fertig(); }; }); return { versprechen, freigeben: (): void => { loesen?.(); }, }; } beforeEach(async () => { vi.spyOn(console, 'error').mockImplementation(() => undefined); ablage = mkdtempSync(path.join(os.tmpdir(), 'lsa-gleichzeitig-')); welt.zustand.benutzerdaten = mkdtempSync(path.join(os.tmpdir(), 'lsa-benutzer-')); await starteHauptprozess(); }); afterEach(() => { vi.restoreAllMocks(); }); describe('Zwei Speichervorgänge auf dieselbe Projektdatei', () => { it('schreibt sie nacheinander und meldet beide als gelungen', async () => { const ziel = path.join(ablage, 'knotenpunkt.lsap'); writeFileSync(ziel, ALTER_STAND, 'utf8'); await oeffne(ziel); // Der erste Speichervorgang haengt beim Schreiben - Luftbild, Netzlaufwerk. const bremse = neueBremse(); welt.zustand.bremse = bremse.versprechen; const erster = speichere(ziel, LANGER_STAND); await laufenLassen(); // Waehrenddessen drueckt der Anwender erneut Strg+S. const zweiter = speichere(ziel, KURZER_STAND); await laufenLassen(); bremse.freigeben(); const [a, b] = await Promise.all([erster, zweiter]); // Ohne Einreihung meldet einer der beiden "Die Datei oder der Ordner wurde // nicht gefunden.", weil der andere die gemeinsame Nachbardatei bereits // umbenannt hat. expect(a.error, 'Der erste Speichervorgang meldet einen Fehler').toBeNull(); expect(a.ok).toBe(true); expect(b.error, 'Der zweite Speichervorgang meldet einen Fehler').toBeNull(); expect(b.ok).toBe(true); }); it('lässt den zuletzt angeforderten Stand am Ablageort stehen', async () => { const ziel = path.join(ablage, 'knotenpunkt.lsap'); writeFileSync(ziel, ALTER_STAND, 'utf8'); await oeffne(ziel); const bremse = neueBremse(); welt.zustand.bremse = bremse.versprechen; const erster = speichere(ziel, LANGER_STAND); await laufenLassen(); const zweiter = speichere(ziel, KURZER_STAND); await laufenLassen(); bremse.freigeben(); await Promise.all([erster, zweiter]); // Der zuletzt angeforderte Stand gewinnt. Ohne Einreihung schrieb der erste // Griff nach dem Umbenennen des zweiten unmittelbar in die Projektdatei und // legte den AELTEREN Stand darueber. expect(readFileSync(ziel, 'utf8')).toBe(KURZER_STAND); expect(readdirSync(ablage)).toEqual(['knotenpunkt.lsap']); }); });