lsa-planer

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

/ tests services sitzungsspeicher.test.ts

21,9 KB Rohdatei
tests/services/sitzungsspeicher.test.ts — 630 Zeilen
1 import { afterEach, beforeEach, describe, expect, it } from 'vitest';
2 import {
3 addRecoveryPoint,
4 ladeSitzung,
5 listRecoveryPoints,
6 loadSession,
7 saveSession,
8 setzeSpeicherUmgebung,
9 sitzungsspeicherArt,
10 } from '@/services/storage';
11 import { createEmptyProject } from '@/domain/model/factory';
12 import type { Project } from '@/domain/model/project';
13
14 /**
15 * Sitzungsspeicher.
16 *
17 * Geprueft wird das, was im Betrieb niemandem auffiele, bevor es zu spaet ist:
18 * ob der Arbeitsstand samt Luftbild wirklich zurueckkommt, ob ein Fehler mitten
19 * im Schreiben den vorherigen Stand unangetastet laesst, und ob die Anwendung
20 * ohne IndexedDB weiterlaeuft UND das sagt.
21 *
22 * Das Luftbild ist dabei keine Nebensache: Aus ihm werden Raeum- und
23 * Einfahrwege abgegriffen, die unmittelbar in die Zwischenzeit eingehen. Ein
24 * Stand, der mit falschem oder fehlendem Bild zurueckkommt, ist gefaehrlicher
25 * als einer, der gar nicht zurueckkommt.
26 *
27 * WARUM EINE EIGENE NACHBILDUNG
28 *
29 * Die Pruefung laeuft in Node; dort gibt es weder IndexedDB noch localStorage,
30 * und 'fake-indexeddb' liegt nicht in node_modules (die Anwendung wird ohne
31 * Netzzugang gebaut - eine neue Abhaengigkeit ist keine Option). Die
32 * Nachbildung unten bildet genau das nach, was der Sitzungsspeicher benutzt,
33 * und ist in einem Punkt bewusst streng: Schreibvorgaenge landen erst beim
34 * Abschluss der Transaktion im Datenbestand. Ein Abbruch laesst deshalb
35 * nachweislich nichts Halbes zurueck.
36 */
37
38 // --- Nachbildung von IndexedDB ---------------------------------------------
39
40 type Lager = Map<IDBValidKey, unknown>;
41
42 interface Anfrage<T> {
43 result: T;
44 onsuccess: (() => void) | null;
45 onerror: (() => void) | null;
46 }
47
48 class Bestand {
49 readonly lager = new Map<string, Lager>();
50 version = 0;
51 /** Laesst jeden Schreibvorgang scheitern - fuer den Abbruchfall. */
52 scheitereBeimSchreiben = false;
53 /** Zaehlt, wie oft eine Grossdatei tatsaechlich geschrieben wurde. */
54 bildschreibvorgaenge = 0;
55 /** Laesst das Oeffnen scheitern - privater Modus, gesperrte Datenbank. */
56 oeffnenSchlaegtFehl = false;
57 /**
58 * Laesst jeden Lesevorgang scheitern.
59 *
60 * Der Fall unterscheidet sich vom fehlgeschlagenen Oeffnen: Die Datenbank
61 * ist da, der Arbeitsstand steht darin, er kommt nur nicht heraus. Genau
62 * dieser Ausgang war bis zur Einfuehrung des Sitzungsbefunds nicht von
63 * "es wurde nie etwas gespeichert" zu unterscheiden.
64 */
65 scheitereBeimLesen = false;
66 }
67
68 class Nachbildung {
69 readonly bestand = new Bestand();
70
71 fabrik(): IDBFactory {
72 const bestand = this.bestand;
73 return {
74 open: (_name: string, version?: number): unknown => {
75 const anfrage: Record<string, unknown> = {
76 result: undefined,
77 onupgradeneeded: null,
78 onsuccess: null,
79 onerror: null,
80 onblocked: null,
81 };
82 queueMicrotask(() => {
83 if (bestand.oeffnenSchlaegtFehl) {
84 (anfrage['onerror'] as (() => void) | null)?.();
85 return;
86 }
87 anfrage['result'] = new NachbildungDatenbank(bestand);
88 if (version !== undefined && bestand.version < version) {
89 bestand.version = version;
90 (anfrage['onupgradeneeded'] as (() => void) | null)?.();
91 }
92 (anfrage['onsuccess'] as (() => void) | null)?.();
93 });
94 return anfrage;
95 },
96 } as unknown as IDBFactory;
97 }
98
99 /**
100 * Zaehlt die abgelegten Grossdaten ueber alle Lager hinweg.
101 *
102 * Bewusst ueber die Laenge statt ueber den Lagernamen: Die Pruefung soll
103 * nicht daran haengen, wie das Lager innen heisst.
104 */
105 anzahlBilder(): number {
106 let anzahl = 0;
107 for (const lager of this.bestand.lager.values()) {
108 for (const wert of lager.values()) {
109 if (typeof wert === 'string' && wert.length >= 32_768) anzahl += 1;
110 }
111 }
112 return anzahl;
113 }
114 }
115
116 class NachbildungDatenbank {
117 onversionchange: (() => void) | null = null;
118
119 constructor(private readonly bestand: Bestand) {}
120
121 get objectStoreNames(): { contains: (name: string) => boolean } {
122 return { contains: (name: string) => this.bestand.lager.has(name) };
123 }
124
125 createObjectStore(name: string): unknown {
126 this.bestand.lager.set(name, new Map());
127 return {};
128 }
129
130 transaction(namen: string | string[]): NachbildungTransaktion {
131 return new NachbildungTransaktion(this.bestand, typeof namen === 'string' ? [namen] : namen);
132 }
133
134 close(): void {
135 /* nichts zu tun */
136 }
137 }
138
139 class NachbildungTransaktion {
140 error: Error | null = null;
141 oncomplete: (() => void) | null = null;
142 onerror: (() => void) | null = null;
143 onabort: (() => void) | null = null;
144
145 /** Arbeitskopie: erst beim Abschluss wird sie in den Bestand uebernommen. */
146 readonly entwurf = new Map<string, Lager>();
147 private readonly auftraege: (() => void)[] = [];
148 private geplant = false;
149 private beendet = false;
150
151 constructor(
152 private readonly bestand: Bestand,
153 namen: readonly string[],
154 ) {
155 for (const name of namen) {
156 const vorhanden = bestand.lager.get(name);
157 if (vorhanden === undefined) throw new Error(`Unbekanntes Lager: ${name}`);
158 this.entwurf.set(name, new Map(vorhanden));
159 }
160 this.pumpe();
161 }
162
163 objectStore(name: string): NachbildungLager {
164 return new NachbildungLager(this, this.bestand, name);
165 }
166
167 inhalt(name: string): Lager {
168 return this.entwurf.get(name) ?? new Map();
169 }
170
171 plane(auftrag: () => void): void {
172 if (this.beendet) throw new Error('Die Transaktion ist bereits beendet.');
173 this.auftraege.push(auftrag);
174 this.pumpe();
175 }
176
177 abort(): void {
178 this.abbrechen(new Error('Die Transaktion wurde abgebrochen.'));
179 }
180
181 private pumpe(): void {
182 if (this.geplant || this.beendet) return;
183 this.geplant = true;
184 queueMicrotask(() => {
185 this.geplant = false;
186 if (this.beendet) return;
187 const auftrag = this.auftraege.shift();
188 if (auftrag === undefined) {
189 this.festschreiben();
190 return;
191 }
192 try {
193 auftrag();
194 } catch (fehler) {
195 this.abbrechen(fehler instanceof Error ? fehler : new Error(String(fehler)));
196 return;
197 }
198 this.pumpe();
199 });
200 }
201
202 private festschreiben(): void {
203 this.beendet = true;
204 for (const [name, inhalt] of this.entwurf) this.bestand.lager.set(name, inhalt);
205 this.oncomplete?.();
206 }
207
208 private abbrechen(fehler: Error): void {
209 if (this.beendet) return;
210 this.beendet = true;
211 this.error = fehler;
212 // Die Arbeitskopie wird verworfen - der vorherige Bestand bleibt stehen.
213 this.entwurf.clear();
214 this.onabort?.();
215 }
216 }
217
218 class NachbildungLager {
219 constructor(
220 private readonly tx: NachbildungTransaktion,
221 private readonly bestand: Bestand,
222 private readonly name: string,
223 ) {}
224
225 get(schluessel: IDBValidKey): Anfrage<unknown> {
226 return this.plane(() => {
227 if (this.bestand.scheitereBeimLesen) {
228 throw new Error('Lesen nicht moeglich (Pruefung).');
229 }
230 return this.tx.inhalt(this.name).get(schluessel);
231 });
232 }
233
234 getAllKeys(): Anfrage<IDBValidKey[]> {
235 return this.plane(() => [...this.tx.inhalt(this.name).keys()]);
236 }
237
238 put(wert: unknown, schluessel: IDBValidKey): Anfrage<unknown> {
239 return this.plane(() => {
240 if (this.bestand.scheitereBeimSchreiben) {
241 throw new Error('Schreiben nicht moeglich (Pruefung).');
242 }
243 if (typeof wert === 'string' && wert.length >= 32_768) {
244 this.bestand.bildschreibvorgaenge += 1;
245 }
246 this.tx.inhalt(this.name).set(schluessel, wert);
247 return schluessel;
248 });
249 }
250
251 delete(schluessel: IDBValidKey): Anfrage<unknown> {
252 return this.plane(() => {
253 this.tx.inhalt(this.name).delete(schluessel);
254 return undefined;
255 });
256 }
257
258 private plane<T>(arbeit: () => T): Anfrage<T> {
259 const anfrage: Anfrage<T> = { result: undefined as T, onsuccess: null, onerror: null };
260 this.tx.plane(() => {
261 anfrage.result = arbeit();
262 anfrage.onsuccess?.();
263 });
264 return anfrage;
265 }
266 }
267
268 // --- Nachbildung von localStorage ------------------------------------------
269
270 function nachbildungSpeicher(): Storage {
271 const daten = new Map<string, string>();
272 return {
273 get length(): number {
274 return daten.size;
275 },
276 clear: (): void => {
277 daten.clear();
278 },
279 getItem: (schluessel: string): string | null => daten.get(schluessel) ?? null,
280 key: (index: number): string | null => [...daten.keys()][index] ?? null,
281 removeItem: (schluessel: string): void => {
282 daten.delete(schluessel);
283 },
284 setItem: (schluessel: string, wert: string): void => {
285 daten.set(schluessel, String(wert));
286 },
287 };
288 }
289
290 // --- Pruefdaten -------------------------------------------------------------
291
292 const KEY_SESSION = 'lsa-planer.v5.session';
293 const KEY_RECOVERY = 'lsa-planer.v5.recovery';
294
295 /** Gueltige Bild-Daten-URL; das Schema laesst nur png/jpeg/webp in Base64 zu. */
296 const BILD = `data:image/png;base64,${'A'.repeat(200_000)}`;
297 const ZWEITES_BILD = `data:image/png;base64,${'B'.repeat(200_000)}`;
298
299 function projektMitBild(name: string, bild: string = BILD): Project {
300 const leer = createEmptyProject(name);
301 return {
302 ...leer,
303 lageplan: {
304 ...leer.lageplan,
305 bild: {
306 datenUrl: bild,
307 breite: 7000,
308 hoehe: 7000,
309 herkunft: 'Pruefung',
310 geladenAm: '2026-08-14T10:00:00.000Z',
311 },
312 },
313 };
314 }
315
316 function bildVon(projekt: unknown): string | undefined {
317 return (projekt as Project | undefined)?.lageplan.bild?.datenUrl;
318 }
319
320 let nachbildung: Nachbildung;
321 let speicher: Storage;
322
323 beforeEach(() => {
324 nachbildung = new Nachbildung();
325 speicher = nachbildungSpeicher();
326 setzeSpeicherUmgebung({ indexedDB: nachbildung.fabrik(), localStorage: speicher });
327 });
328
329 afterEach(() => {
330 setzeSpeicherUmgebung(null);
331 });
332
333 /** Simuliert einen Programmneustart auf demselben Datenbestand. */
334 function neustart(): void {
335 setzeSpeicherUmgebung({ indexedDB: nachbildung.fabrik(), localStorage: speicher });
336 }
337
338 // --- Pruefungen -------------------------------------------------------------
339
340 describe('Sitzungsstand in IndexedDB', () => {
341 it('gibt Projekt und Luftbild unveraendert zurueck', async () => {
342 const ergebnis = await saveSession(projektMitBild('Bahnhofstrasse'));
343 expect(ergebnis.ok).toBe(true);
344 expect(ergebnis.message).toBe('');
345
346 const geladen = await loadSession();
347 expect(geladen?.project.meta.name).toBe('Bahnhofstrasse');
348 // Kein Zeichen darf fehlen: Ein beschnittenes Bild wuerde beim Abgreifen
349 // der Raeumwege einen falschen Massstab vortaeuschen.
350 expect(bildVon(geladen?.project)).toBe(BILD);
351 });
352
353 it('meldet keinen gespeicherten Stand, wenn nie gespeichert wurde', async () => {
354 expect(await loadSession()).toBeNull();
355 });
356
357 it('benutzt IndexedDB, sobald sie zur Verfuegung steht', async () => {
358 expect(await sitzungsspeicherArt()).toBe('indexeddb');
359 });
360 });
361
362 describe('Wiederherstellungspunkte', () => {
363 it('haelt fuenf Staende, den neuesten zuerst', async () => {
364 for (let i = 0; i < 7; i += 1) {
365 const projekt = projektMitBild(`Stand ${i}`);
366 await addRecoveryPoint(projekt, new Date(Date.UTC(2026, 7, 14, 10, i)));
367 }
368 const punkte = await listRecoveryPoints();
369 expect(punkte).toHaveLength(5);
370 expect(punkte.map((p) => p.projectName)).toEqual([
371 'Stand 6',
372 'Stand 5',
373 'Stand 4',
374 'Stand 3',
375 'Stand 2',
376 ]);
377 expect(bildVon(punkte[0]?.project)).toBe(BILD);
378 });
379
380 it('legt dasselbe Luftbild nur einmal ab', async () => {
381 await saveSession(projektMitBild('Sitzung'));
382 for (let i = 0; i < 5; i += 1) {
383 await addRecoveryPoint(projektMitBild(`Stand ${i}`), new Date(Date.UTC(2026, 7, 14, 10, i)));
384 }
385 // Sechs Staende, ein Bild: Fuenf Vollkopien eines 20-MB-Projekts waeren
386 // 100 MB fuer Daten, die sich zwischen den Staenden nicht unterscheiden.
387 expect(nachbildung.anzahlBilder()).toBe(1);
388 });
389
390 it('haelt verschiedene Luftbilder auseinander', async () => {
391 await addRecoveryPoint(projektMitBild('Bestand', BILD), new Date(Date.UTC(2026, 7, 14, 10, 0)));
392 await addRecoveryPoint(
393 projektMitBild('Planfall', ZWEITES_BILD),
394 new Date(Date.UTC(2026, 7, 14, 10, 1)),
395 );
396 expect(nachbildung.anzahlBilder()).toBe(2);
397
398 const punkte = await listRecoveryPoints();
399 expect(bildVon(punkte[0]?.project)).toBe(ZWEITES_BILD);
400 expect(bildVon(punkte[1]?.project)).toBe(BILD);
401 });
402
403 it('gibt ein Luftbild frei, auf das kein Stand mehr zeigt', async () => {
404 await saveSession(projektMitBild('Mit Bild'));
405 expect(nachbildung.anzahlBilder()).toBe(1);
406
407 await saveSession(createEmptyProject('Ohne Bild'));
408 expect(nachbildung.anzahlBilder()).toBe(0);
409 });
410
411 it('legt ein freigegebenes Luftbild wieder an, wenn es zurueckkehrt', async () => {
412 await saveSession(projektMitBild('Mit Bild'));
413 await saveSession(createEmptyProject('Bild entfernt'));
414 expect(nachbildung.anzahlBilder()).toBe(0);
415
416 // Der Anwender nimmt denselben Ausschnitt erneut auf. Die Merkliste darf
417 // das Bild jetzt nicht mehr als vorhanden fuehren - sonst bliebe nur der
418 // Zeiger auf ein geloeschtes Bild uebrig, und der Lageplan kaeme leer
419 // zurueck, waehrend die daraus abgegriffenen Wege im Projekt stehen.
420 await saveSession(projektMitBild('Bild wieder da'));
421 expect(nachbildung.anzahlBilder()).toBe(1);
422 expect(bildVon((await loadSession())?.project)).toBe(BILD);
423 });
424 });
425
426 describe('Sparsamkeit beim Schreiben', () => {
427 it('schreibt ein unveraendertes Luftbild nicht erneut', async () => {
428 const projekt = projektMitBild('Erstfassung');
429 await saveSession(projekt);
430 expect(nachbildung.bestand.bildschreibvorgaenge).toBe(1);
431
432 await saveSession({ ...projekt, meta: { ...projekt.meta, name: 'Zweitfassung' } });
433 expect(nachbildung.bestand.bildschreibvorgaenge).toBe(1);
434 });
435
436 it('schreibt es auch nach einem Neustart nicht erneut', async () => {
437 await saveSession(projektMitBild('Erstfassung'));
438 neustart();
439
440 const geladen = await loadSession();
441 expect(bildVon(geladen?.project)).toBe(BILD);
442
443 // Das geladene Bild ist als bekannt vermerkt; die erste Sicherung nach dem
444 // Start darf keine zweite 20-MB-Kopie erzeugen.
445 await saveSession(projektMitBild('Nach dem Start'));
446 expect(nachbildung.bestand.bildschreibvorgaenge).toBe(1);
447 expect(nachbildung.anzahlBilder()).toBe(1);
448 });
449 });
450
451 describe('Fehler mitten im Schreiben', () => {
452 it('laesst den vorherigen Stand vollstaendig stehen', async () => {
453 await saveSession(projektMitBild('Erster Stand'));
454
455 nachbildung.bestand.scheitereBeimSchreiben = true;
456 const ergebnis = await saveSession(projektMitBild('Zweiter Stand', ZWEITES_BILD));
457 expect(ergebnis.ok).toBe(false);
458 expect(ergebnis.message).not.toBe('');
459 nachbildung.bestand.scheitereBeimSchreiben = false;
460
461 const geladen = await loadSession();
462 expect(geladen?.project.meta.name).toBe('Erster Stand');
463 expect(bildVon(geladen?.project)).toBe(BILD);
464 // Kein halb geschriebenes zweites Bild ist liegen geblieben.
465 expect(nachbildung.anzahlBilder()).toBe(1);
466 });
467
468 it('schreibt ein nach dem Fehlschlag erneut gesichertes Bild vollstaendig', async () => {
469 nachbildung.bestand.scheitereBeimSchreiben = true;
470 expect((await saveSession(projektMitBild('Fehlschlag'))).ok).toBe(false);
471 nachbildung.bestand.scheitereBeimSchreiben = false;
472
473 // Dieselben Bilddaten noch einmal: Die Kennung wurde beim Fehlschlag
474 // vergeben, der Inhalt aber nie abgelegt. Wuerde er jetzt als "schon
475 // vorhanden" uebersprungen, kaeme der Lageplan ohne Bild zurueck.
476 expect((await saveSession(projektMitBild('Zweiter Versuch'))).ok).toBe(true);
477 const geladen = await loadSession();
478 expect(bildVon(geladen?.project)).toBe(BILD);
479 });
480 });
481
482 describe('Uebernahme aus dem alten Browserspeicher', () => {
483 it('holt Sitzungsstand und Punkte einmalig herueber und raeumt sie weg', async () => {
484 speicher.setItem(KEY_SESSION, JSON.stringify(projektMitBild('Altstand')));
485 speicher.setItem(
486 KEY_RECOVERY,
487 JSON.stringify([
488 {
489 savedAt: '2026-08-13T09:00:00.000Z',
490 projectName: 'Alt',
491 project: createEmptyProject('Alt'),
492 },
493 ]),
494 );
495
496 const geladen = await loadSession();
497 expect(geladen?.project.meta.name).toBe('Altstand');
498 expect(bildVon(geladen?.project)).toBe(BILD);
499 expect(await listRecoveryPoints()).toHaveLength(1);
500
501 // Danach ist der alte Platz frei - und erst danach, nicht vorher.
502 expect(speicher.getItem(KEY_SESSION)).toBeNull();
503 expect(speicher.getItem(KEY_RECOVERY)).toBeNull();
504 });
505
506 it('ueberschreibt einen bereits vorhandenen Stand nicht', async () => {
507 await saveSession(projektMitBild('Neuer Stand'));
508
509 // Zweiter Start; im alten Speicher liegt noch ein aelterer Eintrag.
510 neustart();
511 speicher.setItem(KEY_SESSION, JSON.stringify(createEmptyProject('Alter Stand')));
512
513 const geladen = await loadSession();
514 expect(geladen?.project.meta.name).toBe('Neuer Stand');
515 expect(speicher.getItem(KEY_SESSION)).toBeNull();
516 });
517 });
518
519 describe('Ohne IndexedDB', () => {
520 it('sichert im Browserspeicher und sagt es', async () => {
521 setzeSpeicherUmgebung({ indexedDB: null, localStorage: speicher });
522
523 const ergebnis = await saveSession(createEmptyProject('Ohne Datenbank'));
524 expect(ergebnis.ok).toBe(true);
525 // Der Anwender muss erfahren, dass hier kein Luftbild hineinpasst -
526 // solange er noch in eine Datei speichern kann.
527 expect(ergebnis.message).not.toBe('');
528
529 expect((await loadSession())?.project.meta.name).toBe('Ohne Datenbank');
530 expect(await sitzungsspeicherArt()).toBe('browserspeicher');
531 });
532
533 it('weicht auch aus, wenn sich die Datenbank nicht oeffnen laesst', async () => {
534 nachbildung.bestand.oeffnenSchlaegtFehl = true;
535 neustart();
536
537 expect((await saveSession(createEmptyProject('Privater Modus'))).ok).toBe(true);
538 expect(await sitzungsspeicherArt()).toBe('browserspeicher');
539 expect((await loadSession())?.project.meta.name).toBe('Privater Modus');
540 });
541
542 it('laeuft weiter, wenn ueberhaupt kein Speicher zur Verfuegung steht', async () => {
543 setzeSpeicherUmgebung({ indexedDB: null, localStorage: null });
544
545 const ergebnis = await saveSession(createEmptyProject('Nirgendwo'));
546 expect(ergebnis.ok).toBe(false);
547 expect(ergebnis.message).not.toBe('');
548 expect(await loadSession()).toBeNull();
549 expect(await listRecoveryPoints()).toEqual([]);
550 await expect(addRecoveryPoint(createEmptyProject('Nirgendwo'))).resolves.toBeUndefined();
551 expect(await sitzungsspeicherArt()).toBe('keiner');
552 });
553 });
554
555 /*
556 * Drei voellig verschiedene Gruende lieferten dasselbe null: Es wurde nie etwas
557 * gespeichert, die Datenbank laesst sich nicht lesen, der Satz ist beschaedigt.
558 * Der Programmstart konnte sie nicht auseinanderhalten und gab in jedem Fall
559 * ein leeres Projekt aus - ohne ein Wort, auch dann, wenn der Arbeitsstand noch
560 * da, aber unlesbar war.
561 */
562 describe('Der Ladeversuch sagt, was dabei herauskam', () => {
563 it('unterscheidet "nie gespeichert" von einem geladenen Stand', async () => {
564 expect(await ladeSitzung()).toEqual({ art: 'leer' });
565
566 await saveSession(createEmptyProject('Bahnhofstrasse'));
567 const befund = await ladeSitzung();
568 expect(befund.art).toBe('geladen');
569 expect(befund.art === 'geladen' && befund.stand.project.meta.name).toBe('Bahnhofstrasse');
570 });
571
572 it('meldet eine nicht lesbare Datenbank als solche - und nicht als leer', async () => {
573 await saveSession(createEmptyProject('Noch da'));
574 nachbildung.bestand.scheitereBeimLesen = true;
575
576 const befund = await ladeSitzung();
577 expect(befund).toEqual({ art: 'nicht-lesbar' });
578
579 // Der Stand darf dabei NICHT angetastet werden: Ihn wegen einer
580 // abgebrochenen Transaktion beiseitezulegen, hiesse einen womoeglich
581 // heilen Stand aus dem Weg zu raeumen.
582 nachbildung.bestand.scheitereBeimLesen = false;
583 const danach = await ladeSitzung();
584 expect(danach.art).toBe('geladen');
585 expect(danach.art === 'geladen' && danach.stand.project.meta.name).toBe('Noch da');
586 });
587
588 it('meldet einen Satz ohne Projekt als beschaedigt statt als leeres Projekt', async () => {
589 /*
590 * Der gefaehrlichste der drei Faelle. Ein Satz ohne Projektfeld lief in
591 * loadProject(undefined); die Schemapruefung repariert daraus ein LEERES
592 * Projekt, und das Programm meldete "Der zuletzt bearbeitete Stand wurde
593 * wiederhergestellt". Ein stiller Verlust mit einer Erfolgsmeldung
594 * darueber ist schlimmer als gar keine Meldung.
595 */
596 await saveSession(createEmptyProject('Wird beschaedigt'));
597 nachbildung.bestand.lager.get('stand')?.set('sitzung', { kaputt: true });
598
599 const befund = await ladeSitzung();
600 expect(befund).toEqual({ art: 'beschaedigt', beiseitegelegt: true });
601
602 // Beiseitegelegt heisst: noch vorhanden, nur nicht mehr im Weg.
603 expect(nachbildung.bestand.lager.get('stand')?.has('sitzung.beschaedigt')).toBe(true);
604 expect(await ladeSitzung()).toEqual({ art: 'leer' });
605 });
606
607 it('meldet auch ohne Datenbank leer statt eines Fehlers', async () => {
608 // Kein Speicher heisst: Es gibt nichts zu laden, aber auch nichts, was
609 // verloren waere.
610 setzeSpeicherUmgebung({ indexedDB: null, localStorage: null });
611 expect(await ladeSitzung()).toEqual({ art: 'leer' });
612 });
613
614 it('erkennt einen beschaedigten Satz auch im Ersatzspeicher', async () => {
615 setzeSpeicherUmgebung({ indexedDB: null, localStorage: speicher });
616 await saveSession(createEmptyProject('Ersatzweg'));
617
618 // Der Schluessel steht nicht als Eigenschaft am Speicher; die Nachbildung
619 // fuehrt ihn wie der echte localStorage ueber key(i).
620 const schluessel = Array.from({ length: speicher.length }, (_, i) => speicher.key(i))
621 .filter((k): k is string => k !== null)
622 .find((k) => !k.endsWith('.beschaedigt'));
623 expect(schluessel, 'der Ersatzspeicher muss den Stand fuehren').toBeDefined();
624 speicher.setItem(schluessel!, '{kein gueltiges JSON');
625
626 const befund = await ladeSitzung();
627 expect(befund).toEqual({ art: 'beschaedigt', beiseitegelegt: true });
628 expect(speicher.getItem(`${schluessel!}.beschaedigt`)).not.toBeNull();
629 });
630 });