lsa-planer

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

/ tests services autosave.test.ts

18,3 KB Rohdatei
tests/services/autosave.test.ts — 564 Zeilen
1 import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
2 import { AutoSave } from '@/services/autosave';
3 import { ProjectStore } from '@/app/store';
4 import * as actions from '@/app/actions';
5 import { createEmptyProject } from '@/domain/model/factory';
6 import { addRecoveryPoint, saveSession } from '@/services/storage';
7 import type { AppSettings, StorageResult } from '@/services/storage';
8 import type { Project } from '@/domain/model/project';
9
10 /**
11 * Zwischenspeichern des Arbeitsstands.
12 *
13 * Geprueft wird das, was im Altbestand nachweislich schiefging und was durch die
14 * Umstellung auf einen asynchronen Speicher hinzukam:
15 *
16 * - Bei rascher Eingabe darf nicht jede Aenderung geschrieben werden, aber die
17 * LETZTE muss ankommen. Ein Verfahren, das nur die erste Aenderung sichert,
18 * faellt in keiner Sichtpruefung auf - es sichert ja etwas.
19 * - Waehrend ein Schreibvorgang laeuft, wird kein zweiter gestartet. Sonst
20 * stapelten sich mehrere 20-MB-Sicherungen, von denen die aelteste
21 * womoeglich als letzte ankaeme; der Anwender haette dann einen aelteren
22 * Stand auf der Platte als auf dem Bildschirm.
23 * - `stop()` muss vollstaendig abraeumen. Der Altbestand liess je
24 * Projektwechsel ein setInterval stehen.
25 * - Eine wiederholte Meldung darf den Meldungsbereich nicht zumauern; die
26 * Warnung, dass der Stand nur noch im Ersatzspeicher liegt, muss aber
27 * ueberhaupt einmal herauskommen.
28 *
29 * WARUM EINE EIGENE NACHBILDUNG
30 *
31 * Die Pruefung laeuft in Node; dort gibt es weder `document` noch `window`, und
32 * der Sitzungsspeicher wuerde ohne IndexedDB in den Ersatzweg fallen. Der
33 * Speichervorgang wird deshalb durch vi.fn() ersetzt - so laesst sich ein
34 * langsamer und ein fehlschlagender Schreibvorgang genau steuern - und die
35 * beiden Ereignisziele werden nachgebildet. Der Zustandsspeicher bleibt der
36 * echte: Ob eine Aenderung als `dirty` ankommt, ist Teil dessen, was hier
37 * abgesichert wird.
38 */
39
40 vi.mock('@/services/storage', () => ({
41 saveSession: vi.fn(),
42 addRecoveryPoint: vi.fn(),
43 }));
44
45 // --- Nachbildung der Ereignisziele -----------------------------------------
46
47 class Ereignisziel {
48 private readonly empfaenger = new Map<string, Set<() => void>>();
49
50 addEventListener(typ: string, hoerer: () => void): void {
51 const menge = this.empfaenger.get(typ) ?? new Set<() => void>();
52 menge.add(hoerer);
53 this.empfaenger.set(typ, menge);
54 }
55
56 removeEventListener(typ: string, hoerer: () => void): void {
57 this.empfaenger.get(typ)?.delete(hoerer);
58 }
59
60 /** Wie viele Hoerer haengen an diesem Ereignis? Deckt doppelte Anmeldung auf. */
61 anzahl(typ: string): number {
62 return this.empfaenger.get(typ)?.size ?? 0;
63 }
64
65 ausloesen(typ: string): void {
66 for (const hoerer of [...(this.empfaenger.get(typ) ?? [])]) hoerer();
67 }
68 }
69
70 class Anzeige extends Ereignisziel {
71 visibilityState: 'visible' | 'hidden' = 'visible';
72 }
73
74 // --- Geruest ----------------------------------------------------------------
75
76 const EINSTELLUNGEN: AppSettings = {
77 theme: 'system',
78 autoSaveEnabled: true,
79 autoSaveIntervalSeconds: 120,
80 locale: 'de',
81 meldungsdauerSekunden: 5,
82 fundstellen: {},
83 };
84
85 const ERFOLG: StorageResult = { ok: true, message: '' };
86
87 let store: ProjectStore;
88 let anzeige: Anzeige;
89 let fenster: Ereignisziel;
90 let meldungen: { text: string; art: 'info' | 'warnung' | 'fehler' }[];
91 let laufend: AutoSave | null;
92
93 beforeEach(() => {
94 vi.useFakeTimers();
95 vi.mocked(saveSession).mockReset();
96 vi.mocked(saveSession).mockResolvedValue(ERFOLG);
97 vi.mocked(addRecoveryPoint).mockReset();
98 vi.mocked(addRecoveryPoint).mockResolvedValue(undefined);
99
100 store = new ProjectStore(createEmptyProject('Bahnhofstrasse'));
101 anzeige = new Anzeige();
102 fenster = new Ereignisziel();
103 meldungen = [];
104 laufend = null;
105 vi.stubGlobal('document', anzeige);
106 vi.stubGlobal('window', fenster);
107 });
108
109 afterEach(() => {
110 laufend?.stop();
111 vi.useRealTimers();
112 vi.unstubAllGlobals();
113 });
114
115 function starte(abweichung: Partial<AppSettings> = {}): AutoSave {
116 const dienst = new AutoSave(store, { ...EINSTELLUNGEN, ...abweichung }, (text, art) => {
117 meldungen.push({ text, art });
118 });
119 dienst.start();
120 laufend = dienst;
121 return dienst;
122 }
123
124 /** Eine Aenderung, wie sie der Anwender ausloest: neues Projektobjekt, dirty. */
125 function aendere(name: string): void {
126 store.update((p) => actions.setMeta(p, { name }), { label: 'Name geaendert' });
127 }
128
129 async function warte(ms: number): Promise<void> {
130 await vi.advanceTimersByTimeAsync(ms);
131 }
132
133 /** Die Namen der Staende, die tatsaechlich geschrieben wurden - in Reihenfolge. */
134 function gesichert(): string[] {
135 return vi.mocked(saveSession).mock.calls.map(([projekt]: [Project]) => projekt.meta.name);
136 }
137
138 interface Aufschub {
139 readonly versprechen: Promise<StorageResult>;
140 loesen(ergebnis: StorageResult): void;
141 }
142
143 /** Ein Schreibvorgang, der erst auf Zuruf fertig wird. */
144 function aufschieben(): Aufschub {
145 let loesen: ((ergebnis: StorageResult) => void) | null = null;
146 const versprechen = new Promise<StorageResult>((erfuellen) => {
147 loesen = erfuellen;
148 });
149 return {
150 versprechen,
151 loesen: (ergebnis: StorageResult): void => {
152 loesen?.(ergebnis);
153 },
154 };
155 }
156
157 // --- Pruefungen -------------------------------------------------------------
158
159 describe('Verzoegertes Sichern nach einer Aenderung', () => {
160 it('schreibt vor Ablauf der Wartezeit nicht und danach genau einmal', async () => {
161 starte();
162 aendere('Erste Eingabe');
163
164 await warte(1400);
165 expect(saveSession).not.toHaveBeenCalled();
166
167 await warte(100);
168 expect(gesichert()).toEqual(['Erste Eingabe']);
169 });
170
171 it('fasst rasch aufeinanderfolgende Aenderungen zusammen - und sichert die letzte', async () => {
172 starte();
173
174 aendere('A');
175 await warte(500);
176 aendere('B');
177 await warte(500);
178 aendere('C');
179 await warte(500);
180 // Bis hierhin ist die Wartezeit nie ganz abgelaufen.
181 expect(saveSession).not.toHaveBeenCalled();
182
183 await warte(1500);
184 // Ein Schreibvorgang, und darin steht der letzte Stand. Genau das ist der
185 // Punkt: Zusammenfassen darf nicht heissen, die spaeteren Eingaben zu
186 // verlieren.
187 expect(gesichert()).toEqual(['C']);
188 });
189
190 it('ueberspringt einen unveraenderten Stand', async () => {
191 const dienst = starte();
192 aendere('Einmalig');
193 await warte(1500);
194 expect(saveSession).toHaveBeenCalledTimes(1);
195
196 // Ohne Aenderung kein Schreibvorgang - bei einem Luftbild von 20 MB waere
197 // jeder davon umsonst.
198 await dienst.flush();
199 await dienst.flush();
200 expect(saveSession).toHaveBeenCalledTimes(1);
201 });
202
203 it('sichert nicht, solange sich nichts geaendert hat', async () => {
204 starte();
205 await warte(60_000);
206 expect(saveSession).not.toHaveBeenCalled();
207 });
208 });
209
210 describe('Ein Schreibvorgang nach dem anderen', () => {
211 it('fasst Aenderungen waehrend des Schreibens zu einem Nachzuegler zusammen', async () => {
212 const erster = aufschieben();
213 vi.mocked(saveSession).mockReturnValueOnce(erster.versprechen);
214 starte();
215
216 aendere('A');
217 await warte(1500);
218 expect(saveSession).toHaveBeenCalledTimes(1);
219
220 // Waehrend der erste Vorgang noch laeuft, tippt der Anwender weiter.
221 aendere('B');
222 await warte(1500);
223 aendere('C');
224 await warte(1500);
225 expect(saveSession).toHaveBeenCalledTimes(1);
226
227 erster.loesen(ERFOLG);
228 await warte(0);
229
230 // Zwei Vorgaenge statt vier, und der Nachzuegler traegt den neuesten Stand.
231 expect(gesichert()).toEqual(['A', 'C']);
232 });
233
234 it('holt den Nachzuegler auch dann nach, wenn er ueber flush kam', async () => {
235 const erster = aufschieben();
236 vi.mocked(saveSession).mockReturnValueOnce(erster.versprechen);
237 const dienst = starte();
238
239 aendere('A');
240 await warte(1500);
241 expect(saveSession).toHaveBeenCalledTimes(1);
242
243 aendere('B');
244 // HEUTIGES VERHALTEN: Das Versprechen aus flush() ist bereits erfuellt,
245 // obwohl B noch nicht geschrieben ist - es wartet nicht auf den laufenden
246 // Vorgang. Wer sich beim Verlassen darauf verlaesst, wartet auf nichts.
247 await dienst.flush();
248 expect(saveSession).toHaveBeenCalledTimes(1);
249
250 erster.loesen(ERFOLG);
251 await warte(0);
252 expect(gesichert()).toEqual(['A', 'B']);
253 });
254 });
255
256 describe('Sichern beim Sichtwechsel und beim Verlassen', () => {
257 it('sichert sofort, sobald das Fenster in den Hintergrund geht', async () => {
258 starte();
259 aendere('Vor dem Wechsel');
260 await warte(100);
261 expect(saveSession).not.toHaveBeenCalled();
262
263 anzeige.visibilityState = 'hidden';
264 anzeige.ausloesen('visibilitychange');
265 await warte(0);
266 expect(gesichert()).toEqual(['Vor dem Wechsel']);
267
268 // Die eingeplante Verzoegerung ist damit abgeraeumt und schreibt nicht noch
269 // einmal hinterher.
270 await warte(1500);
271 expect(saveSession).toHaveBeenCalledTimes(1);
272 });
273
274 it('sichert nicht, solange das Fenster sichtbar bleibt', async () => {
275 starte();
276 aendere('Sichtbar');
277 anzeige.ausloesen('visibilitychange');
278 await warte(0);
279 expect(saveSession).not.toHaveBeenCalled();
280 });
281
282 it('sichert beim Schliessen des Fensters', async () => {
283 starte();
284 aendere('Letzter Stand');
285 await warte(100);
286
287 fenster.ausloesen('beforeunload');
288 await warte(0);
289 expect(gesichert()).toEqual(['Letzter Stand']);
290 });
291 });
292
293 describe('Anhalten', () => {
294 it('schreibt nach stop() nichts mehr - auch keinen eingeplanten Nachzuegler', async () => {
295 const dienst = starte();
296 aendere('Eingeplant');
297 await warte(1000);
298 expect(saveSession).not.toHaveBeenCalled();
299
300 dienst.stop();
301
302 await warte(60_000);
303 aendere('Nach dem Anhalten');
304 await warte(60_000);
305 expect(saveSession).not.toHaveBeenCalled();
306 expect(addRecoveryPoint).not.toHaveBeenCalled();
307 });
308
309 it('meldet sich von Fenster und Anzeige ab', async () => {
310 const dienst = starte();
311 expect(anzeige.anzahl('visibilitychange')).toBe(1);
312 expect(fenster.anzahl('beforeunload')).toBe(1);
313
314 dienst.stop();
315 expect(anzeige.anzahl('visibilitychange')).toBe(0);
316 expect(fenster.anzahl('beforeunload')).toBe(0);
317
318 anzeige.visibilityState = 'hidden';
319 anzeige.ausloesen('visibilitychange');
320 fenster.ausloesen('beforeunload');
321 await warte(0);
322 expect(saveSession).not.toHaveBeenCalled();
323 });
324
325 it('haelt auch nach mehrfachem Start genau einen Zeitgeber und einen Hoerer', async () => {
326 const dienst = starte();
327 dienst.start();
328 dienst.start();
329
330 expect(anzeige.anzahl('visibilitychange')).toBe(1);
331 expect(fenster.anzahl('beforeunload')).toBe(1);
332
333 aendere('Einmal');
334 await warte(1500);
335 // Der Altbestand haette hier je Start einen eigenen Zeitgeber laufen
336 // lassen; drei Sicherungen desselben Stands haetten sich ueberholt.
337 expect(saveSession).toHaveBeenCalledTimes(1);
338
339 await warte(120_000);
340 expect(addRecoveryPoint).toHaveBeenCalledTimes(1);
341 });
342 });
343
344 describe('Wiederherstellungspunkte', () => {
345 it('legt fruehestens nach 15 Sekunden einen Punkt an, auch bei kleinerer Einstellung', async () => {
346 starte({ autoSaveIntervalSeconds: 5 });
347 aendere('Laufende Arbeit');
348
349 await warte(14_000);
350 expect(addRecoveryPoint).not.toHaveBeenCalled();
351
352 await warte(1_000);
353 expect(addRecoveryPoint).toHaveBeenCalledTimes(1);
354 });
355
356 it('legt keinen Punkt an, wenn seit dem letzten Sichern nichts geschehen ist', async () => {
357 const dienst = starte();
358 aendere('Fertig');
359 await dienst.flush();
360 store.markSaved();
361
362 await warte(360_000);
363 expect(addRecoveryPoint).not.toHaveBeenCalled();
364 });
365
366 it('laeuft weiter, wenn ein Punkt nicht angelegt werden kann', async () => {
367 vi.mocked(addRecoveryPoint).mockRejectedValue(new Error('Kein Platz mehr.'));
368 starte();
369 aendere('Laufende Arbeit');
370
371 await warte(120_000);
372 expect(addRecoveryPoint).toHaveBeenCalledTimes(1);
373
374 // Beiwerk: Ein gescheiterter Punkt darf den Zeitgeber nicht anhalten und
375 // nicht als Meldung beim Anwender landen.
376 await warte(120_000);
377 expect(addRecoveryPoint).toHaveBeenCalledTimes(2);
378 expect(meldungen).toEqual([]);
379 });
380 });
381
382 describe('Meldungen an den Anwender', () => {
383 it('meldet einen gelungenen Speichervorgang nicht', async () => {
384 starte();
385 aendere('Still');
386 await warte(1500);
387 expect(saveSession).toHaveBeenCalledTimes(1);
388 expect(meldungen).toEqual([]);
389 });
390
391 it('gibt einen geglueckten Speichervorgang mit Hinweis als Warnung aus', async () => {
392 vi.mocked(saveSession).mockResolvedValue({ ok: true, message: 'Ersatzweg in Benutzung.' });
393 starte();
394 aendere('Ersatzweg');
395 await warte(1500);
396
397 // Geglueckt, aber nicht so, wie es sollte: Der Stand liegt im
398 // Ersatzspeicher, in den kein Luftbild passt. Als Kurzmeldung ginge das
399 // unter - es muss eine Warnung sein.
400 expect(meldungen).toEqual([{ text: 'Ersatzweg in Benutzung.', art: 'warnung' }]);
401 });
402
403 it('warnt genau einmal, auch wenn jeder Speichervorgang im Ersatzweg landet', async () => {
404 vi.mocked(saveSession).mockResolvedValue({ ok: true, message: 'Ersatzweg in Benutzung.' });
405 starte();
406
407 for (const name of ['A', 'B', 'C']) {
408 aendere(name);
409 await warte(1500);
410 }
411
412 expect(saveSession).toHaveBeenCalledTimes(3);
413 expect(meldungen).toEqual([{ text: 'Ersatzweg in Benutzung.', art: 'warnung' }]);
414 });
415
416 it('wiederholt dieselbe Meldung nicht bei jedem Speichern', async () => {
417 vi.mocked(saveSession).mockResolvedValue({ ok: false, message: 'Kein Speicher vorhanden.' });
418 starte();
419
420 for (const name of ['A', 'B', 'C', 'D']) {
421 aendere(name);
422 await warte(1500);
423 }
424
425 // Vier Versuche, eine Meldung. Ohne die Sperre stuende der Meldungsbereich
426 // alle 1,5 s voll und waere fuer alles andere unbrauchbar.
427 expect(saveSession).toHaveBeenCalledTimes(4);
428 expect(meldungen).toEqual([{ text: 'Kein Speicher vorhanden.', art: 'fehler' }]);
429 });
430
431 it('meldet einen anderen Text sehr wohl', async () => {
432 vi.mocked(saveSession)
433 .mockResolvedValueOnce({ ok: false, message: 'Kein Speicher vorhanden.' })
434 .mockResolvedValue({ ok: false, message: 'Der Stand ist zu gross.' });
435 starte();
436
437 aendere('A');
438 await warte(1500);
439 aendere('B');
440 await warte(1500);
441
442 expect(meldungen).toEqual([
443 { text: 'Kein Speicher vorhanden.', art: 'fehler' },
444 { text: 'Der Stand ist zu gross.', art: 'fehler' },
445 ]);
446 });
447
448 it('meldet einen wiederkehrenden Fehler erneut, wenn zwischendurch gesichert wurde', async () => {
449 vi.mocked(saveSession)
450 .mockResolvedValueOnce({ ok: false, message: 'Kein Speicher vorhanden.' })
451 .mockResolvedValueOnce(ERFOLG)
452 .mockResolvedValue({ ok: false, message: 'Kein Speicher vorhanden.' });
453 starte();
454
455 for (const name of ['A', 'B', 'C']) {
456 aendere(name);
457 await warte(1500);
458 }
459
460 // Die Sperre gilt der Dauerwiederholung, nicht dem erneuten Auftreten:
461 // Dass es nach einem gelungenen Speichern wieder klemmt, ist eine
462 // Nachricht.
463 expect(meldungen).toEqual([
464 { text: 'Kein Speicher vorhanden.', art: 'fehler' },
465 { text: 'Kein Speicher vorhanden.', art: 'fehler' },
466 ]);
467 });
468 });
469
470 describe('Wenn das Sichern fehlschlaegt', () => {
471 it('merkt sich den Stand nicht als gesichert und schreibt ihn erneut', async () => {
472 vi.mocked(saveSession).mockResolvedValueOnce({
473 ok: false,
474 message: 'Kein Speicher vorhanden.',
475 });
476 const dienst = starte();
477
478 aendere('Wichtiger Stand');
479 await warte(1500);
480 expect(gesichert()).toEqual(['Wichtiger Stand']);
481
482 // Ohne weitere Aenderung: Der fehlgeschlagene Stand gilt nicht als
483 // geschrieben, der naechste Anlauf nimmt ihn erneut mit.
484 await dienst.flush();
485 expect(gesichert()).toEqual(['Wichtiger Stand', 'Wichtiger Stand']);
486 });
487
488 it('legt nach einem Fehlschlag weiter Wiederherstellungspunkte an', async () => {
489 vi.mocked(saveSession).mockResolvedValue({ ok: false, message: 'Kein Speicher vorhanden.' });
490 starte();
491 aendere('Laufende Arbeit');
492
493 await warte(120_000);
494 expect(addRecoveryPoint).toHaveBeenCalledTimes(1);
495 });
496
497 it('holt den waehrenddessen aufgelaufenen Nachzuegler nach', async () => {
498 const erster = aufschieben();
499 vi.mocked(saveSession).mockReturnValueOnce(erster.versprechen);
500 starte();
501
502 aendere('A');
503 await warte(1500);
504 aendere('B');
505 await warte(1500);
506 expect(saveSession).toHaveBeenCalledTimes(1);
507
508 erster.loesen({ ok: false, message: 'Kein Speicher vorhanden.' });
509 await warte(120_000);
510
511 /*
512 * Hier stand zuvor `toEqual(['A'])` unter der Marke "HEUTIGES VERHALTEN,
513 * festgehalten und gemeldet". Der Fall war damit als Mangel benannt, nicht
514 * begruendet: Der Fehlschlag beendete die Schleife, und der vorgemerkte
515 * Nachzuegler B fiel weg, obwohl der Entprellzeitgeber laengst abgeraeumt
516 * war - es stand also kein Sitzungsschreibversuch mehr an. Das widersprach
517 * der Zusage im Klassenkommentar ("Der laufende Vorgang holt den neuesten
518 * Stand selbst nach") und dem Pruefziel im Kopf dieser Datei ("die LETZTE
519 * muss ankommen"). Behoben als Befund 53 zur Fassung 5.9.0;
520 * die eigene Wache dazu liegt in
521 * tests/services/nachzueglerNachFehlschlag.test.ts.
522 */
523 expect(gesichert()).toEqual(['A', 'B']);
524 });
525 });
526
527 describe('Abgeschaltete Zwischenspeicherung', () => {
528 it('richtet nichts ein und schreibt nichts', async () => {
529 starte({ autoSaveEnabled: false });
530
531 expect(anzeige.anzahl('visibilitychange')).toBe(0);
532 expect(fenster.anzahl('beforeunload')).toBe(0);
533
534 aendere('Nicht zu sichern');
535 await warte(360_000);
536 expect(saveSession).not.toHaveBeenCalled();
537 expect(addRecoveryPoint).not.toHaveBeenCalled();
538 });
539
540 it('haelt sofort an, wenn sie waehrend des Betriebs abgeschaltet wird', async () => {
541 const dienst = starte();
542 aendere('A');
543 await warte(1500);
544 expect(saveSession).toHaveBeenCalledTimes(1);
545
546 dienst.updateSettings({ ...EINSTELLUNGEN, autoSaveEnabled: false });
547 expect(anzeige.anzahl('visibilitychange')).toBe(0);
548
549 aendere('B');
550 await warte(360_000);
551 expect(saveSession).toHaveBeenCalledTimes(1);
552 });
553
554 it('uebernimmt einen geaenderten Abstand fuer die Wiederherstellungspunkte', async () => {
555 const dienst = starte();
556 dienst.updateSettings({ ...EINSTELLUNGEN, autoSaveIntervalSeconds: 20 });
557 aendere('Laufende Arbeit');
558
559 await warte(19_000);
560 expect(addRecoveryPoint).not.toHaveBeenCalled();
561 await warte(1_000);
562 expect(addRecoveryPoint).toHaveBeenCalledTimes(1);
563 });
564 });