lsa-planer

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

/ tests electron gleichzeitigesSpeichern.test.ts

10,7 KB Rohdatei
tests/electron/gleichzeitigesSpeichern.test.ts — 331 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 1 und 2 zur Fassung 5.9.0.
9 *
10 * `ipcMain.handle` reiht nichts ein: Ein zweiter Aufruf laeuft los, sobald der
11 * erste an einem `await` haengt. Der Name der Nachbardatei entstand allein aus
12 * dem Zielpfad und war fuer beide Laeufe derselbe. Zwei Speichervorgaenge auf
13 * dieselbe Projektdatei - zweimal Strg+S, waehrend ein eingebettetes Luftbild
14 * auf ein Netzlaufwerk geschrieben wird, oder Strg+S und danach "Speichern und
15 * schliessen" - oeffneten damit dieselbe `<Ziel>.teil` mit dem Kennzeichen 'w'.
16 * Wer zuerst umbenannte, schob sie auf das Ziel; der ueberlebende Griff zeigte
17 * danach auf den Dateikoerper, der jetzt DAS ZIEL ist, und schrieb unmittelbar
18 * hinein. Der zweite `rename` fand seine Nachbardatei nicht mehr vor und
19 * meldete "Die Datei oder der Ordner wurde nicht gefunden.", waehrend der
20 * Anwender daneben "Gespeichert: ..." las.
21 *
22 * Damit bricht die Zusage im Kopf von `schreibeUnteilbar` ("Am Zielort steht
23 * stets entweder die alte oder die neue vollstaendige Fassung") und die Regel,
24 * dass die Projektdatei nie unmittelbar ueberschrieben wird.
25 *
26 * Geprueft wird gegen ein echtes Verzeichnis. Ersetzt ist nur die Dauer des
27 * ersten Schreibvorgangs: Der erste geoeffnete Griff haelt beim Schreiben an,
28 * bis der Test ihn freigibt - genauso, wie ein Netzlaufwerk es tut.
29 */
30
31 const welt = vi.hoisted(() => {
32 type Horcher = (...werte: unknown[]) => void;
33
34 class Ereignisquelle {
35 private readonly horcher = new Map<string, Horcher[]>();
36
37 on(name: string, fn: Horcher): this {
38 const liste = this.horcher.get(name) ?? [];
39 liste.push(fn);
40 this.horcher.set(name, liste);
41 return this;
42 }
43
44 once(name: string, fn: Horcher): this {
45 return this.on(name, fn);
46 }
47
48 removeListener(name: string, fn: Horcher): this {
49 this.horcher.set(
50 name,
51 (this.horcher.get(name) ?? []).filter((h) => h !== fn),
52 );
53 return this;
54 }
55
56 off(name: string, fn: Horcher): this {
57 return this.removeListener(name, fn);
58 }
59 }
60
61 class Anzeigeinhalt extends Ereignisquelle {
62 send(): void {
63 /* Im Test ohne Wirkung. */
64 }
65 setZoomFactor(): void {
66 /* Im Test ohne Wirkung. */
67 }
68 setWindowOpenHandler(): void {
69 /* Im Test ohne Wirkung. */
70 }
71 isCrashed(): boolean {
72 return false;
73 }
74 isDestroyed(): boolean {
75 return false;
76 }
77 }
78
79 const zustand = {
80 fenster: null as Fenster | null,
81 benutzerdaten: '.',
82 oeffnenZiel: null as string | null,
83 /**
84 * Bremse fuer den naechsten geoeffneten Griff.
85 *
86 * Genau EIN Griff wird angehalten - der erste, der nach dem Scharfstellen
87 * geoeffnet wird. Alle weiteren laufen ungebremst, damit sich der zweite
88 * Speichervorgang wirklich an dem ersten vorbeidraengeln kann.
89 */
90 bremse: null as Promise<void> | null,
91 };
92
93 class Fenster extends Ereignisquelle {
94 readonly webContents = new Anzeigeinhalt();
95
96 constructor() {
97 super();
98 zustand.fenster = this;
99 }
100
101 loadFile(): Promise<void> {
102 return Promise.resolve();
103 }
104 show(): void {
105 /* Im Test ohne Wirkung. */
106 }
107 setBackgroundColor(): void {
108 /* Im Test ohne Wirkung. */
109 }
110 setDocumentEdited(): void {
111 /* Im Test ohne Wirkung. */
112 }
113 isDestroyed(): boolean {
114 return false;
115 }
116 close(): void {
117 /* Im Test ohne Wirkung. */
118 }
119
120 static getAllWindows(): Fenster[] {
121 return zustand.fenster === null ? [] : [zustand.fenster];
122 }
123 }
124
125 const behandler = new Map<string, (...werte: unknown[]) => unknown>();
126
127 const electron = {
128 app: {
129 isPackaged: false,
130 requestSingleInstanceLock: (): boolean => true,
131 on: (): void => undefined,
132 whenReady: (): Promise<void> => Promise.resolve(),
133 getPath: (): string => zustand.benutzerdaten,
134 getVersion: (): string => '5.9.0',
135 quit: (): void => undefined,
136 },
137 BrowserWindow: Fenster,
138 Menu: {
139 buildFromTemplate: (): { getMenuItemById: () => null } => ({ getMenuItemById: () => null }),
140 setApplicationMenu: (): void => undefined,
141 },
142 dialog: {
143 showMessageBoxSync: (): number => 0,
144 showErrorBox: (): void => undefined,
145 showSaveDialog: (): Promise<{ canceled: boolean; filePath: string | undefined }> =>
146 Promise.resolve({ canceled: true, filePath: undefined }),
147 showOpenDialog: (): Promise<{ canceled: boolean; filePaths: string[] }> =>
148 Promise.resolve({
149 canceled: zustand.oeffnenZiel === null,
150 filePaths: zustand.oeffnenZiel === null ? [] : [zustand.oeffnenZiel],
151 }),
152 },
153 ipcMain: {
154 handle: (kanal: string, fn: (...werte: unknown[]) => unknown): void => {
155 behandler.set(kanal, fn);
156 },
157 on: (): void => undefined,
158 },
159 nativeTheme: { shouldUseDarkColors: false, on: (): void => undefined },
160 session: {
161 defaultSession: {
162 webRequest: { onHeadersReceived: (): void => undefined },
163 setPermissionRequestHandler: (): void => undefined,
164 setPermissionCheckHandler: (): void => undefined,
165 },
166 },
167 shell: { openExternal: (): Promise<void> => Promise.resolve() },
168 };
169
170 return {
171 electron,
172 zustand,
173 behandler,
174 zuruecksetzen: (): void => {
175 zustand.fenster = null;
176 zustand.oeffnenZiel = null;
177 zustand.bremse = null;
178 behandler.clear();
179 },
180 };
181 });
182
183 vi.mock('electron', () => welt.electron);
184
185 /**
186 * Ersatz fuer `node:fs/promises`.
187 *
188 * Nur `open` ist ersetzt, und auch das nur fuer einen einzigen Griff: Der erste
189 * Griff, der nach dem Scharfstellen der Bremse geoeffnet wird, haelt vor dem
190 * Schreiben an. Alles uebrige - das Kennzeichen 'w', das Kuerzen beim Oeffnen,
191 * das Umbenennen - geht an das echte Dateisystem.
192 */
193 vi.mock('node:fs/promises', async (echtesModul) => {
194 const echt = await echtesModul<typeof import('node:fs/promises')>();
195 return {
196 ...echt,
197 default: echt,
198 async open(ziel: unknown, kennzeichen?: unknown): Promise<unknown> {
199 const griff = await echt.open(ziel as string, (kennzeichen as string | undefined) ?? 'r');
200 const bremse = welt.zustand.bremse;
201 if (bremse === null) return griff;
202 welt.zustand.bremse = null;
203 return {
204 async writeFile(inhalt: string): Promise<void> {
205 await bremse;
206 await griff.writeFile(inhalt);
207 },
208 sync: (): Promise<void> => griff.sync(),
209 close: (): Promise<void> => griff.close(),
210 };
211 },
212 };
213 });
214
215 process.setMaxListeners(64);
216
217 const ALTER_STAND = '{"schemaVersion":13,"meta":{"name":"Bahnhofstraße"}}';
218 /** Der lange erste Stand - ein Projekt mit eingebettetem Luftbild. */
219 const LANGER_STAND = `{"schemaVersion":13,"bild":"${'A'.repeat(4_000)}"}`;
220 /** Der kurze zweite Stand, den der Anwender waehrend des Wartens erzeugt. */
221 const KURZER_STAND = '{"schemaVersion":13,"meta":{"name":"Bahnhofstraße West"}}';
222
223 let ablage = '';
224
225 async function starteHauptprozess(): Promise<void> {
226 vi.resetModules();
227 welt.zuruecksetzen();
228 await import('../../electron/main');
229 for (let i = 0; i < 5; i += 1) await Promise.resolve();
230 }
231
232 /** Der Anwender oeffnet die Datei - danach steht ihr Pfad in der Freigabeliste. */
233 async function oeffne(ziel: string): Promise<OpenFileResult> {
234 welt.zustand.oeffnenZiel = ziel;
235 return (await welt.behandler.get(IPC.openFile)?.(null, [])) as OpenFileResult;
236 }
237
238 /** Strg+S auf einen bereits bekannten Pfad. */
239 function speichere(ziel: string, daten: string): Promise<SaveFileResult> {
240 return welt.behandler.get(IPC.writeFile)?.(null, {
241 filePath: ziel,
242 data: daten,
243 }) as Promise<SaveFileResult>;
244 }
245
246 /** Laesst die angestossenen Zusagen so weit laufen, wie sie ohne uns kommen. */
247 async function laufenLassen(): Promise<void> {
248 for (let i = 0; i < 40; i += 1) await new Promise((fertig) => setTimeout(fertig, 0));
249 }
250
251 interface Bremse {
252 readonly versprechen: Promise<void>;
253 freigeben(): void;
254 }
255
256 /** Ein Schreibvorgang, der erst auf Zuruf weiterlaeuft. */
257 function neueBremse(): Bremse {
258 let loesen: (() => void) | null = null;
259 const versprechen = new Promise<void>((fertig) => {
260 loesen = () => {
261 fertig();
262 };
263 });
264 return {
265 versprechen,
266 freigeben: (): void => {
267 loesen?.();
268 },
269 };
270 }
271
272 beforeEach(async () => {
273 vi.spyOn(console, 'error').mockImplementation(() => undefined);
274 ablage = mkdtempSync(path.join(os.tmpdir(), 'lsa-gleichzeitig-'));
275 welt.zustand.benutzerdaten = mkdtempSync(path.join(os.tmpdir(), 'lsa-benutzer-'));
276 await starteHauptprozess();
277 });
278
279 afterEach(() => {
280 vi.restoreAllMocks();
281 });
282
283 describe('Zwei Speichervorgänge auf dieselbe Projektdatei', () => {
284 it('schreibt sie nacheinander und meldet beide als gelungen', async () => {
285 const ziel = path.join(ablage, 'knotenpunkt.lsap');
286 writeFileSync(ziel, ALTER_STAND, 'utf8');
287 await oeffne(ziel);
288
289 // Der erste Speichervorgang haengt beim Schreiben - Luftbild, Netzlaufwerk.
290 const bremse = neueBremse();
291 welt.zustand.bremse = bremse.versprechen;
292 const erster = speichere(ziel, LANGER_STAND);
293 await laufenLassen();
294
295 // Waehrenddessen drueckt der Anwender erneut Strg+S.
296 const zweiter = speichere(ziel, KURZER_STAND);
297 await laufenLassen();
298
299 bremse.freigeben();
300 const [a, b] = await Promise.all([erster, zweiter]);
301
302 // Ohne Einreihung meldet einer der beiden "Die Datei oder der Ordner wurde
303 // nicht gefunden.", weil der andere die gemeinsame Nachbardatei bereits
304 // umbenannt hat.
305 expect(a.error, 'Der erste Speichervorgang meldet einen Fehler').toBeNull();
306 expect(a.ok).toBe(true);
307 expect(b.error, 'Der zweite Speichervorgang meldet einen Fehler').toBeNull();
308 expect(b.ok).toBe(true);
309 });
310
311 it('lässt den zuletzt angeforderten Stand am Ablageort stehen', async () => {
312 const ziel = path.join(ablage, 'knotenpunkt.lsap');
313 writeFileSync(ziel, ALTER_STAND, 'utf8');
314 await oeffne(ziel);
315
316 const bremse = neueBremse();
317 welt.zustand.bremse = bremse.versprechen;
318 const erster = speichere(ziel, LANGER_STAND);
319 await laufenLassen();
320 const zweiter = speichere(ziel, KURZER_STAND);
321 await laufenLassen();
322 bremse.freigeben();
323 await Promise.all([erster, zweiter]);
324
325 // Der zuletzt angeforderte Stand gewinnt. Ohne Einreihung schrieb der erste
326 // Griff nach dem Umbenennen des zweiten unmittelbar in die Projektdatei und
327 // legte den AELTEREN Stand darueber.
328 expect(readFileSync(ziel, 'utf8')).toBe(KURZER_STAND);
329 expect(readdirSync(ablage)).toEqual(['knotenpunkt.lsap']);
330 });
331 });