lsa-planer

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

/ tests electron schliessabfrage.test.ts

11,4 KB Rohdatei
tests/electron/schliessabfrage.test.ts — 375 Zeilen
1 import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
2 import { mkdtempSync } from 'node:fs';
3 import os from 'node:os';
4 import path from 'node:path';
5 import { IPC } from '../../shared/ipc';
6
7 /**
8 * Befund 15 zur Fassung 5.8.0.
9 *
10 * Der Hauptprozess fragte den Anzeigeprozess vor dem Schliessen, ob
11 * ungespeicherte Aenderungen verworfen werden duerfen - und setzte auf die
12 * ANTWORT eine Frist von fuenf Sekunden. Beantwortet wird die Rueckfrage aber
13 * von einem Menschen: Der Anzeigeprozess sendet erst, wenn eine der drei
14 * Schaltflaechen gewaehlt ist, beim Weg "Speichern und schliessen" sogar erst
15 * nach dem Sichern. Wer laenger als fuenf Sekunden las, bekam eine zweite,
16 * modale Meldung "Die Anwendung antwortet nicht." ueber den offenen Dialog
17 * gelegt, mit dem Angebot "Trotzdem schließen".
18 *
19 * Zwei weitere Folgen traten ohne jedes Zutun ein: Nach Ablauf der Frist war
20 * der Wartezustand geloescht, sodass die spaeter eintreffende echte Antwort
21 * verpuffte und sich das Fenster trotz beantworteter Rueckfrage nicht mehr
22 * schliessen liess; und jeder weitere Klick auf das Schliesskreuz legte einen
23 * weiteren Zeitgeber an, der die Meldung spaeter erneut zeigte.
24 *
25 * Geprueft wird am Hauptprozess selbst: Electron ist ersetzt, das Fenster und
26 * die Kanaele sind nachgebildet.
27 */
28
29 const welt = vi.hoisted(() => {
30 type Horcher = (...werte: unknown[]) => void;
31
32 class Ereignisquelle {
33 private readonly horcher = new Map<string, Horcher[]>();
34
35 on(name: string, fn: Horcher): this {
36 const liste = this.horcher.get(name) ?? [];
37 liste.push(fn);
38 this.horcher.set(name, liste);
39 return this;
40 }
41
42 once(name: string, fn: Horcher): this {
43 const einmal: Horcher = (...werte: unknown[]) => {
44 this.removeListener(name, einmal);
45 fn(...werte);
46 };
47 return this.on(name, einmal);
48 }
49
50 removeListener(name: string, fn: Horcher): this {
51 const liste = (this.horcher.get(name) ?? []).filter((h) => h !== fn);
52 this.horcher.set(name, liste);
53 return this;
54 }
55
56 off(name: string, fn: Horcher): this {
57 return this.removeListener(name, fn);
58 }
59
60 /** Loest ein Ereignis aus, wie Electron es taete. */
61 loese(name: string, ...werte: unknown[]): void {
62 for (const fn of [...(this.horcher.get(name) ?? [])]) fn(...werte);
63 }
64
65 /** Wie viele Empfaenger haengen an diesem Ereignis? */
66 anzahl(name: string): number {
67 return (this.horcher.get(name) ?? []).length;
68 }
69 }
70
71 class Anzeigeinhalt extends Ereignisquelle {
72 abgestuerzt = false;
73 zerstoert = false;
74 readonly gesendet: string[] = [];
75
76 send(kanal: string): void {
77 this.gesendet.push(kanal);
78 }
79
80 setZoomFactor(): void {
81 /* Im Test ohne Wirkung. */
82 }
83
84 setWindowOpenHandler(): void {
85 /* Im Test ohne Wirkung. */
86 }
87
88 isCrashed(): boolean {
89 return this.abgestuerzt;
90 }
91
92 isDestroyed(): boolean {
93 return this.zerstoert;
94 }
95 }
96
97 const zustand = {
98 fenster: null as Fenster | null,
99 /** Alle Meldungskaesten, die der Hauptprozess gezeigt hat. */
100 meldungen: [] as Record<string, unknown>[],
101 /** Welche Schaltflaeche der Anwender im Meldungskasten waehlt. */
102 antwort: 0,
103 benutzerdaten: '.',
104 };
105
106 class Fenster extends Ereignisquelle {
107 readonly webContents = new Anzeigeinhalt();
108 geschlossen = 0;
109
110 constructor() {
111 super();
112 zustand.fenster = this;
113 }
114
115 loadFile(): Promise<void> {
116 return Promise.resolve();
117 }
118
119 show(): void {
120 /* Im Test ohne Wirkung. */
121 }
122
123 setBackgroundColor(): void {
124 /* Im Test ohne Wirkung. */
125 }
126
127 setDocumentEdited(): void {
128 /* Im Test ohne Wirkung. */
129 }
130
131 isDestroyed(): boolean {
132 return false;
133 }
134
135 /**
136 * Wie in Electron: `close()` loest erst `close` aus und - wenn niemand
137 * widerspricht - danach `closed`. Der Hauptprozess setzt sein Fenster
138 * daraufhin auf null, und ein zweiter Schliessversuch laeuft ins Leere.
139 */
140 close(): void {
141 this.geschlossen += 1;
142 let verhindert = false;
143 this.loese('close', {
144 preventDefault: (): void => {
145 verhindert = true;
146 },
147 });
148 if (!verhindert) this.loese('closed');
149 }
150
151 static getAllWindows(): Fenster[] {
152 return zustand.fenster === null ? [] : [zustand.fenster];
153 }
154 }
155
156 const behandler = new Map<string, (...werte: unknown[]) => unknown>();
157 const empfaenger = new Map<string, (...werte: unknown[]) => void>();
158
159 const electron = {
160 app: {
161 isPackaged: false,
162 requestSingleInstanceLock: (): boolean => true,
163 on: (): void => undefined,
164 whenReady: (): Promise<void> => Promise.resolve(),
165 getPath: (): string => zustand.benutzerdaten,
166 getVersion: (): string => '5.8.0',
167 quit: (): void => undefined,
168 },
169 BrowserWindow: Fenster,
170 Menu: {
171 buildFromTemplate: (): { getMenuItemById: () => null } => ({ getMenuItemById: () => null }),
172 setApplicationMenu: (): void => undefined,
173 },
174 dialog: {
175 showMessageBoxSync: (_fenster: unknown, optionen: Record<string, unknown>): number => {
176 zustand.meldungen.push(optionen);
177 return zustand.antwort;
178 },
179 showErrorBox: (): void => undefined,
180 },
181 ipcMain: {
182 handle: (kanal: string, fn: (...werte: unknown[]) => unknown): void => {
183 behandler.set(kanal, fn);
184 },
185 on: (kanal: string, fn: (...werte: unknown[]) => void): void => {
186 empfaenger.set(kanal, fn);
187 },
188 },
189 nativeTheme: { shouldUseDarkColors: false, on: (): void => undefined },
190 session: {
191 defaultSession: {
192 webRequest: { onHeadersReceived: (): void => undefined },
193 setPermissionRequestHandler: (): void => undefined,
194 setPermissionCheckHandler: (): void => undefined,
195 },
196 },
197 shell: { openExternal: (): Promise<void> => Promise.resolve() },
198 };
199
200 return {
201 electron,
202 zustand,
203 empfaenger,
204 zuruecksetzen: (): void => {
205 zustand.fenster = null;
206 zustand.meldungen = [];
207 zustand.antwort = 0;
208 behandler.clear();
209 empfaenger.clear();
210 },
211 };
212 });
213
214 vi.mock('electron', () => welt.electron);
215
216 // Der Hauptprozess meldet den Start ins Fehlerprotokoll; jeder Import legte
217 // sonst weitere Empfaenger an, und die Konsole liefe voll.
218 process.setMaxListeners(64);
219
220 /** Laesst schwebende Zusagen durchlaufen, ohne einen Zeitgeber zu bewegen. */
221 async function takt(): Promise<void> {
222 for (let i = 0; i < 5; i += 1) await Promise.resolve();
223 }
224
225 async function starteHauptprozess(): Promise<void> {
226 vi.resetModules();
227 welt.zuruecksetzen();
228 await import('../../electron/main');
229 await takt();
230 }
231
232 function fenster(): {
233 webContents: { gesendet: string[]; abgestuerzt: boolean; loese: (name: string) => void };
234 geschlossen: number;
235 loese: (name: string, ...werte: unknown[]) => void;
236 } {
237 const gefunden = welt.zustand.fenster;
238 expect(gefunden, 'Der Hauptprozess hat kein Fenster gebaut').not.toBeNull();
239 return gefunden as never;
240 }
241
242 /** Der Anzeigeprozess meldet ungespeicherte Aenderungen. */
243 function meldeUngespeichert(): void {
244 welt.empfaenger.get(IPC.setDirty)?.(null, true);
245 }
246
247 /** Klick auf das Schliesskreuz. */
248 function klickeSchliesskreuz(): void {
249 fenster().loese('close', { preventDefault: (): void => undefined });
250 }
251
252 /** Der Anzeigeprozess antwortet - nachdem der Anwender gewaehlt hat. */
253 async function anzeigeAntwortet(erlaubt: boolean): Promise<void> {
254 welt.empfaenger.get(IPC.closeResponse)?.(null, erlaubt);
255 await takt();
256 }
257
258 function rueckfragen(): number {
259 return fenster().webContents.gesendet.filter((k) => k === IPC.requestClose).length;
260 }
261
262 beforeEach(async () => {
263 vi.spyOn(console, 'error').mockImplementation(() => undefined);
264 welt.zustand.benutzerdaten = mkdtempSync(path.join(os.tmpdir(), 'lsa-schliessen-'));
265 vi.useFakeTimers();
266 await starteHauptprozess();
267 });
268
269 afterEach(() => {
270 vi.useRealTimers();
271 vi.restoreAllMocks();
272 });
273
274 describe('Schliessabfrage: die Frist misst nicht die Bedenkzeit des Anwenders', () => {
275 it('behauptet nicht nach fünf Sekunden, die Anwendung antworte nicht', async () => {
276 // Der Anwender liest die drei Schaltflaechen - eine halbe Minute lang.
277 meldeUngespeichert();
278 klickeSchliesskreuz();
279 expect(rueckfragen()).toBe(1);
280
281 await vi.advanceTimersByTimeAsync(30_000);
282
283 expect(
284 welt.zustand.meldungen.map((m) => m['message']),
285 'Der Hauptprozess hat den antwortenden Anzeigeprozess für hängend erklärt',
286 ).toEqual([]);
287 expect(fenster().geschlossen).toBe(0);
288 });
289
290 it('nimmt die Antwort auch dann noch an, wenn der Anwender lange überlegt hat', async () => {
291 meldeUngespeichert();
292 klickeSchliesskreuz();
293 await vi.advanceTimersByTimeAsync(30_000);
294
295 // Jetzt erst waehlt der Anwender "Ohne Speichern schließen".
296 await anzeigeAntwortet(true);
297
298 expect(
299 fenster().geschlossen,
300 'Das Fenster ließ sich trotz beantworteter Rückfrage nicht schließen',
301 ).toBe(1);
302 });
303
304 it('stellt beim zweiten Klick auf das Schließkreuz keine zweite Rückfrage', async () => {
305 meldeUngespeichert();
306 klickeSchliesskreuz();
307 klickeSchliesskreuz();
308
309 expect(rueckfragen(), 'Der Anzeigeprozess bekam die Rückfrage doppelt').toBe(1);
310
311 await vi.advanceTimersByTimeAsync(30_000);
312 expect(welt.zustand.meldungen).toEqual([]);
313
314 // Und die eine Antwort schliesst das Fenster.
315 await anzeigeAntwortet(true);
316 expect(fenster().geschlossen).toBe(1);
317 });
318 });
319
320 describe('Schliessabfrage: der Ausweg bleibt, haengt aber an einem Lebenszeichen', () => {
321 it('fragt im Hauptprozess nach, sobald die Anzeige wirklich nicht mehr antwortet', async () => {
322 welt.zustand.antwort = 1; // "Trotzdem schließen"
323 meldeUngespeichert();
324 klickeSchliesskreuz();
325
326 // Chromiums Wache meldet die haengende Anzeige.
327 fenster().webContents.loese('unresponsive');
328 await takt();
329
330 expect(welt.zustand.meldungen.map((m) => m['message'])).toEqual([
331 'Die Anwendung antwortet nicht.',
332 ]);
333 expect(fenster().geschlossen).toBe(1);
334 });
335
336 it('fragt eine abgestürzte Anzeige gar nicht erst', async () => {
337 welt.zustand.antwort = 1;
338 meldeUngespeichert();
339 fenster().webContents.abgestuerzt = true;
340 klickeSchliesskreuz();
341 await takt();
342
343 expect(rueckfragen(), 'Eine abgestürzte Anzeige wurde noch befragt').toBe(0);
344 expect(welt.zustand.meldungen.map((m) => m['message'])).toEqual([
345 'Die Anwendung antwortet nicht.',
346 ]);
347 expect(fenster().geschlossen).toBe(1);
348 });
349
350 it('zeigt die Warnung beim zweiten Klick erneut, wenn der Anwender die erste abgebrochen hat', async () => {
351 meldeUngespeichert();
352 fenster().webContents.abgestuerzt = true;
353
354 // Erster Versuch: der Anwender waehlt "Abbrechen" - das Fenster bleibt
355 // offen, und genau dafuer ist der Ausweg da.
356 welt.zustand.antwort = 0;
357 klickeSchliesskreuz();
358 await takt();
359 expect(fenster().geschlossen).toBe(0);
360
361 // Zweiter Versuch: jetzt waehlt er "Trotzdem schließen".
362 welt.zustand.antwort = 1;
363 klickeSchliesskreuz();
364 await takt();
365
366 expect(
367 welt.zustand.meldungen.map((m) => m['message']),
368 'Der Ausweg war nach einmaligem Abbrechen versperrt: keine zweite Warnung',
369 ).toEqual(['Die Anwendung antwortet nicht.', 'Die Anwendung antwortet nicht.']);
370 expect(
371 fenster().geschlossen,
372 'Das abgestürzte Fenster liess sich nur noch über den Task-Manager beenden',
373 ).toBe(1);
374 });
375 });