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