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