lsa-planer
LSA-Planer Professional – Planungssoftware für Lichtsignalanlagen nach RiLSA 2015 und § 45 StVO. EUPL-1.2.
/ tests electron unteilbaresSpeichern.test.ts
| 1 | import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; |
| 2 | import { mkdtempSync, readFileSync, readdirSync, writeFileSync } from 'node:fs'; |
| 3 | import os from 'node:os'; |
| 4 | import path from 'node:path'; |
| 5 | import { IPC, type OpenFileResult, type SaveFileResult } from '../../shared/ipc'; |
| 6 | |
| 7 | /** |
| 8 | * Befunde 17 und 40 zur Fassung 5.8.0. |
| 9 | * |
| 10 | * Beide Schreibwege des Hauptprozesses - der Kanal "Speichern unter" und der |
| 11 | * Kanal "Speichern" auf einen bereits bekannten Pfad - schrieben mit |
| 12 | * `fs/promises.writeFile` unmittelbar auf das Ziel. `writeFile` oeffnet mit dem |
| 13 | * Kennzeichen 'w' und kuerzt die vorhandene Datei damit auf null Byte, BEVOR |
| 14 | * das erste Byte des neuen Inhalts geschrieben ist. Scheiterte das Schreiben |
| 15 | * danach - kein Platz mehr auf dem Datentraeger, abgezogener |
| 16 | * Wechseldatentraeger, weggebrochenes Netzlaufwerk -, war die zuletzt |
| 17 | * gesicherte Planunterlage fort. Der Anwender las "Auf dem Datenträger ist kein |
| 18 | * Platz mehr frei." und nahm an, seine alte Datei stehe noch; beim naechsten |
| 19 | * Oeffnen kam nur "Die Datei enthält kein gültiges JSON". |
| 20 | * |
| 21 | * Geprueft wird gegen ein echtes Verzeichnis. Ersetzt ist nur der Zeitpunkt des |
| 22 | * Fehlschlags: Der Ersatz fuer `node:fs/promises` bildet das Kennzeichen 'w' |
| 23 | * nach - er kuerzt beim Oeffnen und laesst erst das Schreiben scheitern, genau |
| 24 | * wie ein voller Datentraeger es tut. |
| 25 | */ |
| 26 | |
| 27 | const welt = vi.hoisted(() => { |
| 28 | type Horcher = (...werte: unknown[]) => void; |
| 29 | |
| 30 | class Ereignisquelle { |
| 31 | private readonly horcher = new Map<string, Horcher[]>(); |
| 32 | |
| 33 | on(name: string, fn: Horcher): this { |
| 34 | const liste = this.horcher.get(name) ?? []; |
| 35 | liste.push(fn); |
| 36 | this.horcher.set(name, liste); |
| 37 | return this; |
| 38 | } |
| 39 | |
| 40 | once(name: string, fn: Horcher): this { |
| 41 | return this.on(name, fn); |
| 42 | } |
| 43 | |
| 44 | removeListener(name: string, fn: Horcher): this { |
| 45 | this.horcher.set( |
| 46 | name, |
| 47 | (this.horcher.get(name) ?? []).filter((h) => h !== fn), |
| 48 | ); |
| 49 | return this; |
| 50 | } |
| 51 | |
| 52 | off(name: string, fn: Horcher): this { |
| 53 | return this.removeListener(name, fn); |
| 54 | } |
| 55 | } |
| 56 | |
| 57 | class Anzeigeinhalt extends Ereignisquelle { |
| 58 | send(): void { |
| 59 | /* Im Test ohne Wirkung. */ |
| 60 | } |
| 61 | setZoomFactor(): void { |
| 62 | /* Im Test ohne Wirkung. */ |
| 63 | } |
| 64 | setWindowOpenHandler(): void { |
| 65 | /* Im Test ohne Wirkung. */ |
| 66 | } |
| 67 | isCrashed(): boolean { |
| 68 | return false; |
| 69 | } |
| 70 | isDestroyed(): boolean { |
| 71 | return false; |
| 72 | } |
| 73 | } |
| 74 | |
| 75 | const zustand = { |
| 76 | fenster: null as Fenster | null, |
| 77 | benutzerdaten: '.', |
| 78 | /** Was der Speichern-unter-Dialog zurueckgibt. */ |
| 79 | speichernZiel: null as string | null, |
| 80 | /** Was der Oeffnen-Dialog zurueckgibt. */ |
| 81 | oeffnenZiel: null as string | null, |
| 82 | /** Scharfgestellter Fehler beim Schreiben der Bytes (nicht beim Oeffnen). */ |
| 83 | schreibfehler: null as NodeJS.ErrnoException | null, |
| 84 | }; |
| 85 | |
| 86 | class Fenster extends Ereignisquelle { |
| 87 | readonly webContents = new Anzeigeinhalt(); |
| 88 | |
| 89 | constructor() { |
| 90 | super(); |
| 91 | zustand.fenster = this; |
| 92 | } |
| 93 | |
| 94 | loadFile(): Promise<void> { |
| 95 | return Promise.resolve(); |
| 96 | } |
| 97 | show(): void { |
| 98 | /* Im Test ohne Wirkung. */ |
| 99 | } |
| 100 | setBackgroundColor(): void { |
| 101 | /* Im Test ohne Wirkung. */ |
| 102 | } |
| 103 | setDocumentEdited(): void { |
| 104 | /* Im Test ohne Wirkung. */ |
| 105 | } |
| 106 | isDestroyed(): boolean { |
| 107 | return false; |
| 108 | } |
| 109 | close(): void { |
| 110 | /* Im Test ohne Wirkung. */ |
| 111 | } |
| 112 | |
| 113 | static getAllWindows(): Fenster[] { |
| 114 | return zustand.fenster === null ? [] : [zustand.fenster]; |
| 115 | } |
| 116 | } |
| 117 | |
| 118 | const behandler = new Map<string, (...werte: unknown[]) => unknown>(); |
| 119 | |
| 120 | const electron = { |
| 121 | app: { |
| 122 | isPackaged: false, |
| 123 | requestSingleInstanceLock: (): boolean => true, |
| 124 | on: (): void => undefined, |
| 125 | whenReady: (): Promise<void> => Promise.resolve(), |
| 126 | getPath: (): string => zustand.benutzerdaten, |
| 127 | getVersion: (): string => '5.8.0', |
| 128 | quit: (): void => undefined, |
| 129 | }, |
| 130 | BrowserWindow: Fenster, |
| 131 | Menu: { |
| 132 | buildFromTemplate: (): { getMenuItemById: () => null } => ({ getMenuItemById: () => null }), |
| 133 | setApplicationMenu: (): void => undefined, |
| 134 | }, |
| 135 | dialog: { |
| 136 | showMessageBoxSync: (): number => 0, |
| 137 | showErrorBox: (): void => undefined, |
| 138 | showSaveDialog: (): Promise<{ canceled: boolean; filePath: string | undefined }> => |
| 139 | Promise.resolve({ |
| 140 | canceled: zustand.speichernZiel === null, |
| 141 | filePath: zustand.speichernZiel ?? undefined, |
| 142 | }), |
| 143 | showOpenDialog: (): Promise<{ canceled: boolean; filePaths: string[] }> => |
| 144 | Promise.resolve({ |
| 145 | canceled: zustand.oeffnenZiel === null, |
| 146 | filePaths: zustand.oeffnenZiel === null ? [] : [zustand.oeffnenZiel], |
| 147 | }), |
| 148 | }, |
| 149 | ipcMain: { |
| 150 | handle: (kanal: string, fn: (...werte: unknown[]) => unknown): void => { |
| 151 | behandler.set(kanal, fn); |
| 152 | }, |
| 153 | on: (): void => undefined, |
| 154 | }, |
| 155 | nativeTheme: { shouldUseDarkColors: false, on: (): void => undefined }, |
| 156 | session: { |
| 157 | defaultSession: { |
| 158 | webRequest: { onHeadersReceived: (): void => undefined }, |
| 159 | setPermissionRequestHandler: (): void => undefined, |
| 160 | setPermissionCheckHandler: (): void => undefined, |
| 161 | }, |
| 162 | }, |
| 163 | shell: { openExternal: (): Promise<void> => Promise.resolve() }, |
| 164 | }; |
| 165 | |
| 166 | return { |
| 167 | electron, |
| 168 | zustand, |
| 169 | behandler, |
| 170 | zuruecksetzen: (): void => { |
| 171 | zustand.fenster = null; |
| 172 | zustand.speichernZiel = null; |
| 173 | zustand.oeffnenZiel = null; |
| 174 | zustand.schreibfehler = null; |
| 175 | behandler.clear(); |
| 176 | }, |
| 177 | }; |
| 178 | }); |
| 179 | |
| 180 | vi.mock('electron', () => welt.electron); |
| 181 | |
| 182 | /** |
| 183 | * Ersatz fuer `node:fs/promises`. |
| 184 | * |
| 185 | * Er bildet nach, was ein voller Datentraeger tut: Das Oeffnen mit dem |
| 186 | * Kennzeichen 'w' gelingt und kuerzt die Datei, erst das Schreiben der Bytes |
| 187 | * scheitert. Alles uebrige geht an das echte Dateisystem. |
| 188 | */ |
| 189 | vi.mock('node:fs/promises', async (echtesModul) => { |
| 190 | const echt = await echtesModul<typeof import('node:fs/promises')>(); |
| 191 | return { |
| 192 | ...echt, |
| 193 | default: echt, |
| 194 | async writeFile(ziel: unknown, inhalt: unknown, optionen?: unknown): Promise<void> { |
| 195 | const griff = await echt.open(ziel as string, 'w'); |
| 196 | try { |
| 197 | if (welt.zustand.schreibfehler !== null) throw welt.zustand.schreibfehler; |
| 198 | await griff.writeFile(inhalt as string, optionen as never); |
| 199 | } finally { |
| 200 | await griff.close(); |
| 201 | } |
| 202 | }, |
| 203 | async open(ziel: unknown, kennzeichen?: unknown): Promise<unknown> { |
| 204 | const griff = await echt.open(ziel as string, (kennzeichen as string | undefined) ?? 'r'); |
| 205 | const fehler = welt.zustand.schreibfehler; |
| 206 | if (fehler === null) return griff; |
| 207 | return { |
| 208 | writeFile: (): Promise<never> => Promise.reject(fehler), |
| 209 | sync: (): Promise<void> => griff.sync(), |
| 210 | close: (): Promise<void> => griff.close(), |
| 211 | }; |
| 212 | }, |
| 213 | }; |
| 214 | }); |
| 215 | |
| 216 | process.setMaxListeners(64); |
| 217 | |
| 218 | const ALTER_STAND = '{"schemaVersion":13,"meta":{"name":"Bahnhofstraße"}}'; |
| 219 | const NEUER_STAND = '{"schemaVersion":13,"meta":{"name":"Bahnhofstraße West"}}'; |
| 220 | |
| 221 | let ablage = ''; |
| 222 | |
| 223 | /** Ein voller Datentraeger - der Fehler, den describe() ausdruecklich uebersetzt. */ |
| 224 | function datentraegerVoll(): NodeJS.ErrnoException { |
| 225 | const fehler: NodeJS.ErrnoException = new Error('no space left on device'); |
| 226 | fehler.code = 'ENOSPC'; |
| 227 | return fehler; |
| 228 | } |
| 229 | |
| 230 | async function starteHauptprozess(): Promise<void> { |
| 231 | vi.resetModules(); |
| 232 | welt.zuruecksetzen(); |
| 233 | await import('../../electron/main'); |
| 234 | for (let i = 0; i < 5; i += 1) await Promise.resolve(); |
| 235 | } |
| 236 | |
| 237 | /** Der Anwender oeffnet die Datei - danach steht ihr Pfad in der Freigabeliste. */ |
| 238 | async function oeffne(ziel: string): Promise<OpenFileResult> { |
| 239 | welt.zustand.oeffnenZiel = ziel; |
| 240 | return (await welt.behandler.get(IPC.openFile)?.(null, [])) as OpenFileResult; |
| 241 | } |
| 242 | |
| 243 | /** Strg+S auf einen bereits bekannten Pfad. */ |
| 244 | async function speichere(ziel: string, daten: string): Promise<SaveFileResult> { |
| 245 | return (await welt.behandler.get(IPC.writeFile)?.(null, { |
| 246 | filePath: ziel, |
| 247 | data: daten, |
| 248 | })) as SaveFileResult; |
| 249 | } |
| 250 | |
| 251 | /** "Speichern unter" mit dem Dialog davor. */ |
| 252 | async function speichereUnter(ziel: string, daten: string): Promise<SaveFileResult> { |
| 253 | welt.zustand.speichernZiel = ziel; |
| 254 | return (await welt.behandler.get(IPC.saveFile)?.(null, { |
| 255 | defaultName: path.basename(ziel), |
| 256 | filters: [], |
| 257 | data: daten, |
| 258 | })) as SaveFileResult; |
| 259 | } |
| 260 | |
| 261 | beforeEach(async () => { |
| 262 | vi.spyOn(console, 'error').mockImplementation(() => undefined); |
| 263 | ablage = mkdtempSync(path.join(os.tmpdir(), 'lsa-speichern-')); |
| 264 | welt.zustand.benutzerdaten = mkdtempSync(path.join(os.tmpdir(), 'lsa-benutzer-')); |
| 265 | await starteHauptprozess(); |
| 266 | }); |
| 267 | |
| 268 | afterEach(() => { |
| 269 | vi.restoreAllMocks(); |
| 270 | }); |
| 271 | |
| 272 | describe('Speichern: die vorhandene Projektdatei überlebt einen Fehlschlag', () => { |
| 273 | it('behält den zuletzt gesicherten Plan, wenn der Datenträger beim Strg+S volläuft', async () => { |
| 274 | const ziel = path.join(ablage, 'knotenpunkt.lsap'); |
| 275 | writeFileSync(ziel, ALTER_STAND, 'utf8'); |
| 276 | await oeffne(ziel); |
| 277 | |
| 278 | welt.zustand.schreibfehler = datentraegerVoll(); |
| 279 | const ergebnis = await speichere(ziel, NEUER_STAND); |
| 280 | |
| 281 | expect(ergebnis.ok).toBe(false); |
| 282 | expect(ergebnis.error).toBe('Auf dem Datenträger ist kein Platz mehr frei.'); |
| 283 | expect( |
| 284 | readFileSync(ziel, 'utf8'), |
| 285 | 'Die zuletzt gesicherte Planunterlage wurde beim Fehlschlag vernichtet', |
| 286 | ).toBe(ALTER_STAND); |
| 287 | }); |
| 288 | |
| 289 | it('lässt nach einem Fehlschlag keine halbe Datei neben dem Plan liegen', async () => { |
| 290 | const ziel = path.join(ablage, 'knotenpunkt.lsap'); |
| 291 | writeFileSync(ziel, ALTER_STAND, 'utf8'); |
| 292 | await oeffne(ziel); |
| 293 | |
| 294 | welt.zustand.schreibfehler = datentraegerVoll(); |
| 295 | await speichere(ziel, NEUER_STAND); |
| 296 | |
| 297 | expect(readdirSync(ablage)).toEqual(['knotenpunkt.lsap']); |
| 298 | }); |
| 299 | |
| 300 | it('behält die überschriebene Datei auch bei "Speichern unter"', async () => { |
| 301 | const ziel = path.join(ablage, 'vorlage.lsap'); |
| 302 | writeFileSync(ziel, ALTER_STAND, 'utf8'); |
| 303 | |
| 304 | welt.zustand.schreibfehler = datentraegerVoll(); |
| 305 | const ergebnis = await speichereUnter(ziel, NEUER_STAND); |
| 306 | |
| 307 | expect(ergebnis.ok).toBe(false); |
| 308 | expect(ergebnis.error).toBe('Auf dem Datenträger ist kein Platz mehr frei.'); |
| 309 | expect(readFileSync(ziel, 'utf8')).toBe(ALTER_STAND); |
| 310 | expect(readdirSync(ablage)).toEqual(['vorlage.lsap']); |
| 311 | }); |
| 312 | }); |
| 313 | |
| 314 | describe('Speichern: der gelingende Weg bleibt unverändert', () => { |
| 315 | it('schreibt den neuen Stand vollständig und räumt hinter sich auf', async () => { |
| 316 | const ziel = path.join(ablage, 'knotenpunkt.lsap'); |
| 317 | writeFileSync(ziel, ALTER_STAND, 'utf8'); |
| 318 | await oeffne(ziel); |
| 319 | |
| 320 | const ergebnis = await speichere(ziel, NEUER_STAND); |
| 321 | |
| 322 | expect(ergebnis.ok).toBe(true); |
| 323 | expect(ergebnis.filePath).toBe(ziel); |
| 324 | expect(readFileSync(ziel, 'utf8')).toBe(NEUER_STAND); |
| 325 | expect(readdirSync(ablage)).toEqual(['knotenpunkt.lsap']); |
| 326 | }); |
| 327 | |
| 328 | it('legt bei "Speichern unter" die Datei an und gibt den freigegebenen Pfad zurück', async () => { |
| 329 | const ziel = path.join(ablage, 'neu.lsap'); |
| 330 | |
| 331 | const ergebnis = await speichereUnter(ziel, NEUER_STAND); |
| 332 | |
| 333 | expect(ergebnis.ok).toBe(true); |
| 334 | expect(ergebnis.filePath).toBe(path.resolve(ziel)); |
| 335 | expect(readFileSync(ziel, 'utf8')).toBe(NEUER_STAND); |
| 336 | expect(readdirSync(ablage)).toEqual(['neu.lsap']); |
| 337 | }); |
| 338 | }); |