lsa-planer

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

/ tests electron unteilbaresSpeichern.test.ts

11,0 KB Rohdatei
tests/electron/unteilbaresSpeichern.test.ts — 338 Zeilen
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 });