lsa-planer

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

/ tests services ersatzstandUebernahme.test.ts

10,7 KB Rohdatei
tests/services/ersatzstandUebernahme.test.ts — 340 Zeilen
1 import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
2 import {
3 ladeSitzung,
4 listRecoveryPoints,
5 saveSession,
6 setzeSpeicherUmgebung,
7 } from '@/services/storage';
8 import { createEmptyProject } from '@/domain/model/factory';
9
10 /**
11 * Befund 8 zur Fassung 5.9.0.
12 *
13 * `uebernehmeAltbestand` entschied allein danach, OB in IndexedDB unter dem
14 * Sitzungsschluessel etwas steht. Stand dort etwas, wurde der localStorage-Stand
15 * nicht uebernommen und anschliessend - ausserhalb des Zweiges, also unbedingt -
16 * geloescht. Der Kommentar begruendete das mit "Liegt in IndexedDB bereits ein
17 * Stand, ist der localStorage-Eintrag der aeltere".
18 *
19 * Diese Annahme trifft nicht zu, sobald IndexedDB einmal ausgefallen ist:
20 * `oeffneDatenbank` liefert bei jedem Oeffnungsfehler `null`, die ganze Sitzung
21 * laeuft dann ueber den Ersatzspeicher, und der dort geschriebene Stand ist der
22 * JUENGERE. Der Uebernahmeweg war damit zugleich der Entsorgungsweg des
23 * laufenden Ersatzbetriebs - stumm, ohne ein Wort an den Anwender.
24 *
25 * Zweiter, davon unabhaengiger Teil desselben Befundes: Geprueft wurde nur der
26 * Sitzungsschluessel, geloescht wurden Sitzung UND Wiederherstellungspunkte.
27 * Liegen in der Datenbank keine Punkte, verschwanden sie ersatzlos.
28 *
29 * WARUM EINE EIGENE NACHBILDUNG: Die Pruefung laeuft in Node; dort gibt es
30 * weder IndexedDB noch localStorage, und 'fake-indexeddb' liegt nicht in
31 * node_modules. Nachgebildet ist genau das, was der Sitzungsspeicher benutzt.
32 */
33
34 // --- Nachbildung von IndexedDB ---------------------------------------------
35
36 type Lager = Map<IDBValidKey, unknown>;
37
38 interface Anfrage<T> {
39 result: T;
40 onsuccess: (() => void) | null;
41 onerror: (() => void) | null;
42 }
43
44 class Bestand {
45 readonly lager = new Map<string, Lager>();
46 version = 0;
47 /** Laesst das Oeffnen scheitern - privater Modus, gesperrte Datenbank. */
48 oeffnenSchlaegtFehl = false;
49 }
50
51 class Nachbildung {
52 readonly bestand = new Bestand();
53
54 fabrik(): IDBFactory {
55 const bestand = this.bestand;
56 return {
57 open: (_name: string, version?: number): unknown => {
58 const anfrage: Record<string, unknown> = {
59 result: undefined,
60 onupgradeneeded: null,
61 onsuccess: null,
62 onerror: null,
63 onblocked: null,
64 };
65 queueMicrotask(() => {
66 if (bestand.oeffnenSchlaegtFehl) {
67 (anfrage['onerror'] as (() => void) | null)?.();
68 return;
69 }
70 anfrage['result'] = new NachbildungDatenbank(bestand);
71 if (version !== undefined && bestand.version < version) {
72 bestand.version = version;
73 (anfrage['onupgradeneeded'] as (() => void) | null)?.();
74 }
75 (anfrage['onsuccess'] as (() => void) | null)?.();
76 });
77 return anfrage;
78 },
79 } as unknown as IDBFactory;
80 }
81 }
82
83 class NachbildungDatenbank {
84 onversionchange: (() => void) | null = null;
85
86 constructor(private readonly bestand: Bestand) {}
87
88 get objectStoreNames(): { contains: (name: string) => boolean } {
89 return { contains: (name: string) => this.bestand.lager.has(name) };
90 }
91
92 createObjectStore(name: string): unknown {
93 this.bestand.lager.set(name, new Map());
94 return {};
95 }
96
97 transaction(namen: string | string[]): NachbildungTransaktion {
98 return new NachbildungTransaktion(this.bestand, typeof namen === 'string' ? [namen] : namen);
99 }
100
101 close(): void {
102 /* nichts zu tun */
103 }
104 }
105
106 class NachbildungTransaktion {
107 error: Error | null = null;
108 oncomplete: (() => void) | null = null;
109 onerror: (() => void) | null = null;
110 onabort: (() => void) | null = null;
111
112 /** Arbeitskopie: erst beim Abschluss wird sie in den Bestand uebernommen. */
113 readonly entwurf = new Map<string, Lager>();
114 private readonly auftraege: (() => void)[] = [];
115 private geplant = false;
116 private beendet = false;
117
118 constructor(
119 private readonly bestand: Bestand,
120 namen: readonly string[],
121 ) {
122 for (const name of namen) {
123 const vorhanden = bestand.lager.get(name);
124 if (vorhanden === undefined) throw new Error(`Unbekanntes Lager: ${name}`);
125 this.entwurf.set(name, new Map(vorhanden));
126 }
127 this.pumpe();
128 }
129
130 objectStore(name: string): NachbildungLager {
131 return new NachbildungLager(this, name);
132 }
133
134 inhalt(name: string): Lager {
135 return this.entwurf.get(name) ?? new Map();
136 }
137
138 plane(auftrag: () => void): void {
139 if (this.beendet) throw new Error('Die Transaktion ist bereits beendet.');
140 this.auftraege.push(auftrag);
141 this.pumpe();
142 }
143
144 abort(): void {
145 if (this.beendet) return;
146 this.beendet = true;
147 this.error = new Error('Die Transaktion wurde abgebrochen.');
148 this.entwurf.clear();
149 this.onabort?.();
150 }
151
152 private pumpe(): void {
153 if (this.geplant || this.beendet) return;
154 this.geplant = true;
155 queueMicrotask(() => {
156 this.geplant = false;
157 if (this.beendet) return;
158 const auftrag = this.auftraege.shift();
159 if (auftrag === undefined) {
160 this.beendet = true;
161 for (const [name, inhalt] of this.entwurf) this.bestand.lager.set(name, inhalt);
162 this.oncomplete?.();
163 return;
164 }
165 auftrag();
166 this.pumpe();
167 });
168 }
169 }
170
171 class NachbildungLager {
172 constructor(
173 private readonly tx: NachbildungTransaktion,
174 private readonly name: string,
175 ) {}
176
177 get(schluessel: IDBValidKey): Anfrage<unknown> {
178 return this.plane(() => this.tx.inhalt(this.name).get(schluessel));
179 }
180
181 getAllKeys(): Anfrage<IDBValidKey[]> {
182 return this.plane(() => [...this.tx.inhalt(this.name).keys()]);
183 }
184
185 put(wert: unknown, schluessel: IDBValidKey): Anfrage<unknown> {
186 return this.plane(() => {
187 this.tx.inhalt(this.name).set(schluessel, wert);
188 return schluessel;
189 });
190 }
191
192 delete(schluessel: IDBValidKey): Anfrage<unknown> {
193 return this.plane(() => {
194 this.tx.inhalt(this.name).delete(schluessel);
195 return undefined;
196 });
197 }
198
199 private plane<T>(arbeit: () => T): Anfrage<T> {
200 const anfrage: Anfrage<T> = { result: undefined as T, onsuccess: null, onerror: null };
201 this.tx.plane(() => {
202 anfrage.result = arbeit();
203 anfrage.onsuccess?.();
204 });
205 return anfrage;
206 }
207 }
208
209 // --- Nachbildung von localStorage ------------------------------------------
210
211 function nachbildungSpeicher(): Storage {
212 const daten = new Map<string, string>();
213 return {
214 get length(): number {
215 return daten.size;
216 },
217 clear: (): void => {
218 daten.clear();
219 },
220 getItem: (schluessel: string): string | null => daten.get(schluessel) ?? null,
221 key: (index: number): string | null => [...daten.keys()][index] ?? null,
222 removeItem: (schluessel: string): void => {
223 daten.delete(schluessel);
224 },
225 setItem: (schluessel: string, wert: string): void => {
226 daten.set(schluessel, String(wert));
227 },
228 };
229 }
230
231 // --- Geruest ----------------------------------------------------------------
232
233 const KEY_SESSION = 'lsa-planer.v5.session';
234 const KEY_RECOVERY = 'lsa-planer.v5.recovery';
235
236 let nachbildung: Nachbildung;
237 let speicher: Storage;
238
239 /** Ein Programmstart auf demselben Datenbestand. */
240 function sitzungBeginnt(): void {
241 setzeSpeicherUmgebung({ indexedDB: nachbildung.fabrik(), localStorage: speicher });
242 }
243
244 beforeEach(() => {
245 // Nur die Uhr wird gestellt: Der Sitzungsstand traegt seinen Zeitpunkt aus
246 // `new Date()`, und ohne feste Uhr laegen beide Staende in derselben
247 // Millisekunde. Zeitgeber und Mikroauftraege bleiben echt - die Nachbildung
248 // der Datenbank haengt an `queueMicrotask`.
249 vi.useFakeTimers({ toFake: ['Date'] });
250 nachbildung = new Nachbildung();
251 speicher = nachbildungSpeicher();
252 sitzungBeginnt();
253 });
254
255 afterEach(() => {
256 setzeSpeicherUmgebung(null);
257 vi.useRealTimers();
258 });
259
260 // --- Pruefungen -------------------------------------------------------------
261
262 describe('Ein im Ersatzspeicher entstandener Sitzungsstand', () => {
263 it('wird uebernommen, weil er juenger ist als der in der Datenbank', async () => {
264 // Sitzung A: Die Datenbank laeuft.
265 vi.setSystemTime(new Date('2026-09-01T08:00:00.000Z'));
266 expect((await saveSession(createEmptyProject('Knotenpunkt X'))).ok).toBe(true);
267
268 // Sitzung B: Das Oeffnen scheitert einmal - die ganze Sitzung laeuft im
269 // Ersatzspeicher. Der Anwender arbeitet zwei Stunden.
270 nachbildung.bestand.oeffnenSchlaegtFehl = true;
271 sitzungBeginnt();
272 vi.setSystemTime(new Date('2026-09-01T10:00:00.000Z'));
273 expect((await saveSession(createEmptyProject('Knotenpunkt Y'))).ok).toBe(true);
274
275 // Sitzung C: Die Datenbank laeuft wieder.
276 nachbildung.bestand.oeffnenSchlaegtFehl = false;
277 sitzungBeginnt();
278 vi.setSystemTime(new Date('2026-09-01T12:00:00.000Z'));
279
280 const befund = await ladeSitzung();
281 expect(befund.art).toBe('geladen');
282 expect(
283 befund.art === 'geladen' ? befund.stand.project.meta.name : '',
284 'Die Arbeit der Ersatzsitzung wurde verworfen',
285 ).toBe('Knotenpunkt Y');
286 });
287
288 it('bleibt greifbar, wenn der Stand in der Datenbank der juengere ist', async () => {
289 // Umgekehrte Lage: In der Datenbank steht der juengere Stand.
290 vi.setSystemTime(new Date('2026-09-01T10:00:00.000Z'));
291 await saveSession(createEmptyProject('Juengerer Stand'));
292
293 // Neuer Start; im Ersatzspeicher liegt ein aelterer Eintrag.
294 sitzungBeginnt();
295 speicher.setItem(
296 KEY_SESSION,
297 JSON.stringify({
298 gespeichertAm: '2026-09-01T08:00:00.000Z',
299 projekt: createEmptyProject('Aelterer Ersatzstand'),
300 }),
301 );
302
303 const befund = await ladeSitzung();
304 expect(befund.art === 'geladen' ? befund.stand.project.meta.name : '').toBe('Juengerer Stand');
305
306 // Nicht uebernommen heisst beiseitegelegt, nicht geloescht - derselbe
307 // Grundsatz wie bei einem beschaedigten Satz.
308 expect(speicher.getItem(KEY_SESSION)).toBeNull();
309 expect(
310 speicher.getItem(`${KEY_SESSION}.beiseite`),
311 'Der nicht uebernommene Stand wurde ersatzlos geloescht',
312 ).not.toBeNull();
313 });
314 });
315
316 describe('Wiederherstellungspunkte im Ersatzspeicher', () => {
317 it('werden nicht mitgeloescht, nur weil ein Sitzungsstand in der Datenbank liegt', async () => {
318 vi.setSystemTime(new Date('2026-09-01T08:00:00.000Z'));
319 await saveSession(createEmptyProject('Stand in der Datenbank'));
320
321 // In der Datenbank liegen KEINE Punkte; im Ersatzspeicher schon.
322 sitzungBeginnt();
323 speicher.setItem(
324 KEY_RECOVERY,
325 JSON.stringify([
326 {
327 savedAt: '2026-09-01T07:30:00.000Z',
328 projectName: 'Nur im Ersatzspeicher',
329 project: createEmptyProject('Nur im Ersatzspeicher'),
330 },
331 ]),
332 );
333
334 const punkte = await listRecoveryPoints();
335 expect(
336 punkte.map((p) => p.projectName),
337 'Die Punkte wurden geloescht, obwohl es in der Datenbank keinen Ersatz gab',
338 ).toEqual(['Nur im Ersatzspeicher']);
339 });
340 });