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