import { afterEach, beforeEach, describe, expect, it } from 'vitest'; import { addRecoveryPoint, ladeSitzung, listRecoveryPoints, loadSession, saveSession, setzeSpeicherUmgebung, sitzungsspeicherArt, } from '@/services/storage'; import { createEmptyProject } from '@/domain/model/factory'; import type { Project } from '@/domain/model/project'; /** * Sitzungsspeicher. * * Geprueft wird das, was im Betrieb niemandem auffiele, bevor es zu spaet ist: * ob der Arbeitsstand samt Luftbild wirklich zurueckkommt, ob ein Fehler mitten * im Schreiben den vorherigen Stand unangetastet laesst, und ob die Anwendung * ohne IndexedDB weiterlaeuft UND das sagt. * * Das Luftbild ist dabei keine Nebensache: Aus ihm werden Raeum- und * Einfahrwege abgegriffen, die unmittelbar in die Zwischenzeit eingehen. Ein * Stand, der mit falschem oder fehlendem Bild zurueckkommt, ist gefaehrlicher * als einer, der gar nicht zurueckkommt. * * WARUM EINE EIGENE NACHBILDUNG * * Die Pruefung laeuft in Node; dort gibt es weder IndexedDB noch localStorage, * und 'fake-indexeddb' liegt nicht in node_modules (die Anwendung wird ohne * Netzzugang gebaut - eine neue Abhaengigkeit ist keine Option). Die * Nachbildung unten bildet genau das nach, was der Sitzungsspeicher benutzt, * und ist in einem Punkt bewusst streng: Schreibvorgaenge landen erst beim * Abschluss der Transaktion im Datenbestand. Ein Abbruch laesst deshalb * nachweislich nichts Halbes zurueck. */ // --- Nachbildung von IndexedDB --------------------------------------------- type Lager = Map; interface Anfrage { result: T; onsuccess: (() => void) | null; onerror: (() => void) | null; } class Bestand { readonly lager = new Map(); version = 0; /** Laesst jeden Schreibvorgang scheitern - fuer den Abbruchfall. */ scheitereBeimSchreiben = false; /** Zaehlt, wie oft eine Grossdatei tatsaechlich geschrieben wurde. */ bildschreibvorgaenge = 0; /** Laesst das Oeffnen scheitern - privater Modus, gesperrte Datenbank. */ oeffnenSchlaegtFehl = false; /** * Laesst jeden Lesevorgang scheitern. * * Der Fall unterscheidet sich vom fehlgeschlagenen Oeffnen: Die Datenbank * ist da, der Arbeitsstand steht darin, er kommt nur nicht heraus. Genau * dieser Ausgang war bis zur Einfuehrung des Sitzungsbefunds nicht von * "es wurde nie etwas gespeichert" zu unterscheiden. */ scheitereBeimLesen = false; } class Nachbildung { readonly bestand = new Bestand(); fabrik(): IDBFactory { const bestand = this.bestand; return { open: (_name: string, version?: number): unknown => { const anfrage: Record = { result: undefined, onupgradeneeded: null, onsuccess: null, onerror: null, onblocked: null, }; queueMicrotask(() => { if (bestand.oeffnenSchlaegtFehl) { (anfrage['onerror'] as (() => void) | null)?.(); return; } anfrage['result'] = new NachbildungDatenbank(bestand); if (version !== undefined && bestand.version < version) { bestand.version = version; (anfrage['onupgradeneeded'] as (() => void) | null)?.(); } (anfrage['onsuccess'] as (() => void) | null)?.(); }); return anfrage; }, } as unknown as IDBFactory; } /** * Zaehlt die abgelegten Grossdaten ueber alle Lager hinweg. * * Bewusst ueber die Laenge statt ueber den Lagernamen: Die Pruefung soll * nicht daran haengen, wie das Lager innen heisst. */ anzahlBilder(): number { let anzahl = 0; for (const lager of this.bestand.lager.values()) { for (const wert of lager.values()) { if (typeof wert === 'string' && wert.length >= 32_768) anzahl += 1; } } return anzahl; } } class NachbildungDatenbank { onversionchange: (() => void) | null = null; constructor(private readonly bestand: Bestand) {} get objectStoreNames(): { contains: (name: string) => boolean } { return { contains: (name: string) => this.bestand.lager.has(name) }; } createObjectStore(name: string): unknown { this.bestand.lager.set(name, new Map()); return {}; } transaction(namen: string | string[]): NachbildungTransaktion { return new NachbildungTransaktion(this.bestand, typeof namen === 'string' ? [namen] : namen); } close(): void { /* nichts zu tun */ } } class NachbildungTransaktion { error: Error | null = null; oncomplete: (() => void) | null = null; onerror: (() => void) | null = null; onabort: (() => void) | null = null; /** Arbeitskopie: erst beim Abschluss wird sie in den Bestand uebernommen. */ readonly entwurf = new Map(); private readonly auftraege: (() => void)[] = []; private geplant = false; private beendet = false; constructor( private readonly bestand: Bestand, namen: readonly string[], ) { for (const name of namen) { const vorhanden = bestand.lager.get(name); if (vorhanden === undefined) throw new Error(`Unbekanntes Lager: ${name}`); this.entwurf.set(name, new Map(vorhanden)); } this.pumpe(); } objectStore(name: string): NachbildungLager { return new NachbildungLager(this, this.bestand, name); } inhalt(name: string): Lager { return this.entwurf.get(name) ?? new Map(); } plane(auftrag: () => void): void { if (this.beendet) throw new Error('Die Transaktion ist bereits beendet.'); this.auftraege.push(auftrag); this.pumpe(); } abort(): void { this.abbrechen(new Error('Die Transaktion wurde abgebrochen.')); } private pumpe(): void { if (this.geplant || this.beendet) return; this.geplant = true; queueMicrotask(() => { this.geplant = false; if (this.beendet) return; const auftrag = this.auftraege.shift(); if (auftrag === undefined) { this.festschreiben(); return; } try { auftrag(); } catch (fehler) { this.abbrechen(fehler instanceof Error ? fehler : new Error(String(fehler))); return; } this.pumpe(); }); } private festschreiben(): void { this.beendet = true; for (const [name, inhalt] of this.entwurf) this.bestand.lager.set(name, inhalt); this.oncomplete?.(); } private abbrechen(fehler: Error): void { if (this.beendet) return; this.beendet = true; this.error = fehler; // Die Arbeitskopie wird verworfen - der vorherige Bestand bleibt stehen. this.entwurf.clear(); this.onabort?.(); } } class NachbildungLager { constructor( private readonly tx: NachbildungTransaktion, private readonly bestand: Bestand, private readonly name: string, ) {} get(schluessel: IDBValidKey): Anfrage { return this.plane(() => { if (this.bestand.scheitereBeimLesen) { throw new Error('Lesen nicht moeglich (Pruefung).'); } return this.tx.inhalt(this.name).get(schluessel); }); } getAllKeys(): Anfrage { return this.plane(() => [...this.tx.inhalt(this.name).keys()]); } put(wert: unknown, schluessel: IDBValidKey): Anfrage { return this.plane(() => { if (this.bestand.scheitereBeimSchreiben) { throw new Error('Schreiben nicht moeglich (Pruefung).'); } if (typeof wert === 'string' && wert.length >= 32_768) { this.bestand.bildschreibvorgaenge += 1; } this.tx.inhalt(this.name).set(schluessel, wert); return schluessel; }); } delete(schluessel: IDBValidKey): Anfrage { return this.plane(() => { this.tx.inhalt(this.name).delete(schluessel); return undefined; }); } private plane(arbeit: () => T): Anfrage { const anfrage: Anfrage = { result: undefined as T, onsuccess: null, onerror: null }; this.tx.plane(() => { anfrage.result = arbeit(); anfrage.onsuccess?.(); }); return anfrage; } } // --- Nachbildung von localStorage ------------------------------------------ function nachbildungSpeicher(): Storage { const daten = new Map(); return { get length(): number { return daten.size; }, clear: (): void => { daten.clear(); }, getItem: (schluessel: string): string | null => daten.get(schluessel) ?? null, key: (index: number): string | null => [...daten.keys()][index] ?? null, removeItem: (schluessel: string): void => { daten.delete(schluessel); }, setItem: (schluessel: string, wert: string): void => { daten.set(schluessel, String(wert)); }, }; } // --- Pruefdaten ------------------------------------------------------------- const KEY_SESSION = 'lsa-planer.v5.session'; const KEY_RECOVERY = 'lsa-planer.v5.recovery'; /** Gueltige Bild-Daten-URL; das Schema laesst nur png/jpeg/webp in Base64 zu. */ const BILD = `data:image/png;base64,${'A'.repeat(200_000)}`; const ZWEITES_BILD = `data:image/png;base64,${'B'.repeat(200_000)}`; function projektMitBild(name: string, bild: string = BILD): Project { const leer = createEmptyProject(name); return { ...leer, lageplan: { ...leer.lageplan, bild: { datenUrl: bild, breite: 7000, hoehe: 7000, herkunft: 'Pruefung', geladenAm: '2026-08-14T10:00:00.000Z', }, }, }; } function bildVon(projekt: unknown): string | undefined { return (projekt as Project | undefined)?.lageplan.bild?.datenUrl; } let nachbildung: Nachbildung; let speicher: Storage; beforeEach(() => { nachbildung = new Nachbildung(); speicher = nachbildungSpeicher(); setzeSpeicherUmgebung({ indexedDB: nachbildung.fabrik(), localStorage: speicher }); }); afterEach(() => { setzeSpeicherUmgebung(null); }); /** Simuliert einen Programmneustart auf demselben Datenbestand. */ function neustart(): void { setzeSpeicherUmgebung({ indexedDB: nachbildung.fabrik(), localStorage: speicher }); } // --- Pruefungen ------------------------------------------------------------- describe('Sitzungsstand in IndexedDB', () => { it('gibt Projekt und Luftbild unveraendert zurueck', async () => { const ergebnis = await saveSession(projektMitBild('Bahnhofstrasse')); expect(ergebnis.ok).toBe(true); expect(ergebnis.message).toBe(''); const geladen = await loadSession(); expect(geladen?.project.meta.name).toBe('Bahnhofstrasse'); // Kein Zeichen darf fehlen: Ein beschnittenes Bild wuerde beim Abgreifen // der Raeumwege einen falschen Massstab vortaeuschen. expect(bildVon(geladen?.project)).toBe(BILD); }); it('meldet keinen gespeicherten Stand, wenn nie gespeichert wurde', async () => { expect(await loadSession()).toBeNull(); }); it('benutzt IndexedDB, sobald sie zur Verfuegung steht', async () => { expect(await sitzungsspeicherArt()).toBe('indexeddb'); }); }); describe('Wiederherstellungspunkte', () => { it('haelt fuenf Staende, den neuesten zuerst', async () => { for (let i = 0; i < 7; i += 1) { const projekt = projektMitBild(`Stand ${i}`); await addRecoveryPoint(projekt, new Date(Date.UTC(2026, 7, 14, 10, i))); } const punkte = await listRecoveryPoints(); expect(punkte).toHaveLength(5); expect(punkte.map((p) => p.projectName)).toEqual([ 'Stand 6', 'Stand 5', 'Stand 4', 'Stand 3', 'Stand 2', ]); expect(bildVon(punkte[0]?.project)).toBe(BILD); }); it('legt dasselbe Luftbild nur einmal ab', async () => { await saveSession(projektMitBild('Sitzung')); for (let i = 0; i < 5; i += 1) { await addRecoveryPoint(projektMitBild(`Stand ${i}`), new Date(Date.UTC(2026, 7, 14, 10, i))); } // Sechs Staende, ein Bild: Fuenf Vollkopien eines 20-MB-Projekts waeren // 100 MB fuer Daten, die sich zwischen den Staenden nicht unterscheiden. expect(nachbildung.anzahlBilder()).toBe(1); }); it('haelt verschiedene Luftbilder auseinander', async () => { await addRecoveryPoint(projektMitBild('Bestand', BILD), new Date(Date.UTC(2026, 7, 14, 10, 0))); await addRecoveryPoint( projektMitBild('Planfall', ZWEITES_BILD), new Date(Date.UTC(2026, 7, 14, 10, 1)), ); expect(nachbildung.anzahlBilder()).toBe(2); const punkte = await listRecoveryPoints(); expect(bildVon(punkte[0]?.project)).toBe(ZWEITES_BILD); expect(bildVon(punkte[1]?.project)).toBe(BILD); }); it('gibt ein Luftbild frei, auf das kein Stand mehr zeigt', async () => { await saveSession(projektMitBild('Mit Bild')); expect(nachbildung.anzahlBilder()).toBe(1); await saveSession(createEmptyProject('Ohne Bild')); expect(nachbildung.anzahlBilder()).toBe(0); }); it('legt ein freigegebenes Luftbild wieder an, wenn es zurueckkehrt', async () => { await saveSession(projektMitBild('Mit Bild')); await saveSession(createEmptyProject('Bild entfernt')); expect(nachbildung.anzahlBilder()).toBe(0); // Der Anwender nimmt denselben Ausschnitt erneut auf. Die Merkliste darf // das Bild jetzt nicht mehr als vorhanden fuehren - sonst bliebe nur der // Zeiger auf ein geloeschtes Bild uebrig, und der Lageplan kaeme leer // zurueck, waehrend die daraus abgegriffenen Wege im Projekt stehen. await saveSession(projektMitBild('Bild wieder da')); expect(nachbildung.anzahlBilder()).toBe(1); expect(bildVon((await loadSession())?.project)).toBe(BILD); }); }); describe('Sparsamkeit beim Schreiben', () => { it('schreibt ein unveraendertes Luftbild nicht erneut', async () => { const projekt = projektMitBild('Erstfassung'); await saveSession(projekt); expect(nachbildung.bestand.bildschreibvorgaenge).toBe(1); await saveSession({ ...projekt, meta: { ...projekt.meta, name: 'Zweitfassung' } }); expect(nachbildung.bestand.bildschreibvorgaenge).toBe(1); }); it('schreibt es auch nach einem Neustart nicht erneut', async () => { await saveSession(projektMitBild('Erstfassung')); neustart(); const geladen = await loadSession(); expect(bildVon(geladen?.project)).toBe(BILD); // Das geladene Bild ist als bekannt vermerkt; die erste Sicherung nach dem // Start darf keine zweite 20-MB-Kopie erzeugen. await saveSession(projektMitBild('Nach dem Start')); expect(nachbildung.bestand.bildschreibvorgaenge).toBe(1); expect(nachbildung.anzahlBilder()).toBe(1); }); }); describe('Fehler mitten im Schreiben', () => { it('laesst den vorherigen Stand vollstaendig stehen', async () => { await saveSession(projektMitBild('Erster Stand')); nachbildung.bestand.scheitereBeimSchreiben = true; const ergebnis = await saveSession(projektMitBild('Zweiter Stand', ZWEITES_BILD)); expect(ergebnis.ok).toBe(false); expect(ergebnis.message).not.toBe(''); nachbildung.bestand.scheitereBeimSchreiben = false; const geladen = await loadSession(); expect(geladen?.project.meta.name).toBe('Erster Stand'); expect(bildVon(geladen?.project)).toBe(BILD); // Kein halb geschriebenes zweites Bild ist liegen geblieben. expect(nachbildung.anzahlBilder()).toBe(1); }); it('schreibt ein nach dem Fehlschlag erneut gesichertes Bild vollstaendig', async () => { nachbildung.bestand.scheitereBeimSchreiben = true; expect((await saveSession(projektMitBild('Fehlschlag'))).ok).toBe(false); nachbildung.bestand.scheitereBeimSchreiben = false; // Dieselben Bilddaten noch einmal: Die Kennung wurde beim Fehlschlag // vergeben, der Inhalt aber nie abgelegt. Wuerde er jetzt als "schon // vorhanden" uebersprungen, kaeme der Lageplan ohne Bild zurueck. expect((await saveSession(projektMitBild('Zweiter Versuch'))).ok).toBe(true); const geladen = await loadSession(); expect(bildVon(geladen?.project)).toBe(BILD); }); }); describe('Uebernahme aus dem alten Browserspeicher', () => { it('holt Sitzungsstand und Punkte einmalig herueber und raeumt sie weg', async () => { speicher.setItem(KEY_SESSION, JSON.stringify(projektMitBild('Altstand'))); speicher.setItem( KEY_RECOVERY, JSON.stringify([ { savedAt: '2026-08-13T09:00:00.000Z', projectName: 'Alt', project: createEmptyProject('Alt'), }, ]), ); const geladen = await loadSession(); expect(geladen?.project.meta.name).toBe('Altstand'); expect(bildVon(geladen?.project)).toBe(BILD); expect(await listRecoveryPoints()).toHaveLength(1); // Danach ist der alte Platz frei - und erst danach, nicht vorher. expect(speicher.getItem(KEY_SESSION)).toBeNull(); expect(speicher.getItem(KEY_RECOVERY)).toBeNull(); }); it('ueberschreibt einen bereits vorhandenen Stand nicht', async () => { await saveSession(projektMitBild('Neuer Stand')); // Zweiter Start; im alten Speicher liegt noch ein aelterer Eintrag. neustart(); speicher.setItem(KEY_SESSION, JSON.stringify(createEmptyProject('Alter Stand'))); const geladen = await loadSession(); expect(geladen?.project.meta.name).toBe('Neuer Stand'); expect(speicher.getItem(KEY_SESSION)).toBeNull(); }); }); describe('Ohne IndexedDB', () => { it('sichert im Browserspeicher und sagt es', async () => { setzeSpeicherUmgebung({ indexedDB: null, localStorage: speicher }); const ergebnis = await saveSession(createEmptyProject('Ohne Datenbank')); expect(ergebnis.ok).toBe(true); // Der Anwender muss erfahren, dass hier kein Luftbild hineinpasst - // solange er noch in eine Datei speichern kann. expect(ergebnis.message).not.toBe(''); expect((await loadSession())?.project.meta.name).toBe('Ohne Datenbank'); expect(await sitzungsspeicherArt()).toBe('browserspeicher'); }); it('weicht auch aus, wenn sich die Datenbank nicht oeffnen laesst', async () => { nachbildung.bestand.oeffnenSchlaegtFehl = true; neustart(); expect((await saveSession(createEmptyProject('Privater Modus'))).ok).toBe(true); expect(await sitzungsspeicherArt()).toBe('browserspeicher'); expect((await loadSession())?.project.meta.name).toBe('Privater Modus'); }); it('laeuft weiter, wenn ueberhaupt kein Speicher zur Verfuegung steht', async () => { setzeSpeicherUmgebung({ indexedDB: null, localStorage: null }); const ergebnis = await saveSession(createEmptyProject('Nirgendwo')); expect(ergebnis.ok).toBe(false); expect(ergebnis.message).not.toBe(''); expect(await loadSession()).toBeNull(); expect(await listRecoveryPoints()).toEqual([]); await expect(addRecoveryPoint(createEmptyProject('Nirgendwo'))).resolves.toBeUndefined(); expect(await sitzungsspeicherArt()).toBe('keiner'); }); }); /* * Drei voellig verschiedene Gruende lieferten dasselbe null: Es wurde nie etwas * gespeichert, die Datenbank laesst sich nicht lesen, der Satz ist beschaedigt. * Der Programmstart konnte sie nicht auseinanderhalten und gab in jedem Fall * ein leeres Projekt aus - ohne ein Wort, auch dann, wenn der Arbeitsstand noch * da, aber unlesbar war. */ describe('Der Ladeversuch sagt, was dabei herauskam', () => { it('unterscheidet "nie gespeichert" von einem geladenen Stand', async () => { expect(await ladeSitzung()).toEqual({ art: 'leer' }); await saveSession(createEmptyProject('Bahnhofstrasse')); const befund = await ladeSitzung(); expect(befund.art).toBe('geladen'); expect(befund.art === 'geladen' && befund.stand.project.meta.name).toBe('Bahnhofstrasse'); }); it('meldet eine nicht lesbare Datenbank als solche - und nicht als leer', async () => { await saveSession(createEmptyProject('Noch da')); nachbildung.bestand.scheitereBeimLesen = true; const befund = await ladeSitzung(); expect(befund).toEqual({ art: 'nicht-lesbar' }); // Der Stand darf dabei NICHT angetastet werden: Ihn wegen einer // abgebrochenen Transaktion beiseitezulegen, hiesse einen womoeglich // heilen Stand aus dem Weg zu raeumen. nachbildung.bestand.scheitereBeimLesen = false; const danach = await ladeSitzung(); expect(danach.art).toBe('geladen'); expect(danach.art === 'geladen' && danach.stand.project.meta.name).toBe('Noch da'); }); it('meldet einen Satz ohne Projekt als beschaedigt statt als leeres Projekt', async () => { /* * Der gefaehrlichste der drei Faelle. Ein Satz ohne Projektfeld lief in * loadProject(undefined); die Schemapruefung repariert daraus ein LEERES * Projekt, und das Programm meldete "Der zuletzt bearbeitete Stand wurde * wiederhergestellt". Ein stiller Verlust mit einer Erfolgsmeldung * darueber ist schlimmer als gar keine Meldung. */ await saveSession(createEmptyProject('Wird beschaedigt')); nachbildung.bestand.lager.get('stand')?.set('sitzung', { kaputt: true }); const befund = await ladeSitzung(); expect(befund).toEqual({ art: 'beschaedigt', beiseitegelegt: true }); // Beiseitegelegt heisst: noch vorhanden, nur nicht mehr im Weg. expect(nachbildung.bestand.lager.get('stand')?.has('sitzung.beschaedigt')).toBe(true); expect(await ladeSitzung()).toEqual({ art: 'leer' }); }); it('meldet auch ohne Datenbank leer statt eines Fehlers', async () => { // Kein Speicher heisst: Es gibt nichts zu laden, aber auch nichts, was // verloren waere. setzeSpeicherUmgebung({ indexedDB: null, localStorage: null }); expect(await ladeSitzung()).toEqual({ art: 'leer' }); }); it('erkennt einen beschaedigten Satz auch im Ersatzspeicher', async () => { setzeSpeicherUmgebung({ indexedDB: null, localStorage: speicher }); await saveSession(createEmptyProject('Ersatzweg')); // Der Schluessel steht nicht als Eigenschaft am Speicher; die Nachbildung // fuehrt ihn wie der echte localStorage ueber key(i). const schluessel = Array.from({ length: speicher.length }, (_, i) => speicher.key(i)) .filter((k): k is string => k !== null) .find((k) => !k.endsWith('.beschaedigt')); expect(schluessel, 'der Ersatzspeicher muss den Stand fuehren').toBeDefined(); speicher.setItem(schluessel!, '{kein gueltiges JSON'); const befund = await ladeSitzung(); expect(befund).toEqual({ art: 'beschaedigt', beiseitegelegt: true }); expect(speicher.getItem(`${schluessel!}.beschaedigt`)).not.toBeNull(); }); });