lsa-planer
LSA-Planer Professional – Planungssoftware für Lichtsignalanlagen nach RiLSA 2015 und § 45 StVO. EUPL-1.2.
/ tests electron schliessabfrage.test.ts
| 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 | }); |