import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; import { AutoSave } from '@/services/autosave'; import { ProjectStore } from '@/app/store'; import * as actions from '@/app/actions'; import { createEmptyProject } from '@/domain/model/factory'; import { addRecoveryPoint, saveSession } from '@/services/storage'; import type { AppSettings, StorageResult } from '@/services/storage'; import type { Project } from '@/domain/model/project'; /** * Befund 53 zur Fassung 5.9.0. * * `writeSession` fasst Aenderungen, die waehrend eines laufenden * Schreibvorgangs eintreffen, zu EINEM Nachzuegler zusammen; der zweite Aufruf * setzt nur `nachholen` und kehrt zurueck. Der Kommentar daneben sagt zu: "Der * laufende Vorgang holt den neuesten Stand selbst nach." * * Im Fehlerfall fiel genau das aus: Der Fehlerzweig kehrte bedingungslos * zurueck, ohne `nachholen` anzusehen, waehrend der Erfolgszweig es tut. Der * Entprellzeitgeber war zu diesem Zeitpunkt bereits abgeraeumt - es stand also * kein weiterer Sitzungsschreibversuch mehr an, und der zuletzt eingegebene * Wert lag nur noch im Arbeitsspeicher. * * Geprueft wird beides: dass der Nachzuegler ankommt UND dass daraus kein * Dauerlauf wird, wenn der Speicher dauerhaft defekt ist. * * WARUM EINE EIGENE NACHBILDUNG: Die Pruefung laeuft in Node; dort gibt es * weder `document` noch `window`, und der echte Sitzungsspeicher fiele ohne * IndexedDB in den Ersatzweg. Der Schreibvorgang wird deshalb durch `vi.fn()` * ersetzt - nur so laesst sich ein haengender und ein fehlschlagender Vorgang * genau steuern. */ vi.mock('@/services/storage', () => ({ saveSession: vi.fn(), addRecoveryPoint: vi.fn(), })); class Ereignisziel { private readonly empfaenger = new Map void>>(); addEventListener(typ: string, hoerer: () => void): void { const menge = this.empfaenger.get(typ) ?? new Set<() => void>(); menge.add(hoerer); this.empfaenger.set(typ, menge); } removeEventListener(typ: string, hoerer: () => void): void { this.empfaenger.get(typ)?.delete(hoerer); } } class Anzeige extends Ereignisziel { visibilityState: 'visible' | 'hidden' = 'visible'; } const EINSTELLUNGEN: AppSettings = { theme: 'system', autoSaveEnabled: true, autoSaveIntervalSeconds: 120, locale: 'de', meldungsdauerSekunden: 5, fundstellen: {}, }; const FEHLSCHLAG: StorageResult = { ok: false, message: 'Kein Speicher vorhanden.' }; let store: ProjectStore; let laufend: AutoSave | null; beforeEach(() => { vi.useFakeTimers(); vi.mocked(saveSession).mockReset(); vi.mocked(saveSession).mockResolvedValue({ ok: true, message: '' }); vi.mocked(addRecoveryPoint).mockReset(); vi.mocked(addRecoveryPoint).mockResolvedValue(undefined); store = new ProjectStore(createEmptyProject('Bahnhofstrasse')); laufend = null; vi.stubGlobal('document', new Anzeige()); vi.stubGlobal('window', new Ereignisziel()); }); afterEach(() => { laufend?.stop(); vi.useRealTimers(); vi.unstubAllGlobals(); }); function starte(): AutoSave { const dienst = new AutoSave(store, EINSTELLUNGEN, () => undefined); dienst.start(); laufend = dienst; return dienst; } /** Eine Aenderung, wie sie der Anwender ausloest: neues Projektobjekt, dirty. */ function aendere(name: string): void { store.update((p) => actions.setMeta(p, { name }), { label: 'Name geaendert' }); } async function warte(ms: number): Promise { await vi.advanceTimersByTimeAsync(ms); } /** Die Namen der Staende, die tatsaechlich geschrieben wurden - in Reihenfolge. */ function gesichert(): string[] { return vi.mocked(saveSession).mock.calls.map(([projekt]: [Project]) => projekt.meta.name); } /** Ein Schreibvorgang, der erst auf Zuruf fertig wird. */ function aufschieben(): { versprechen: Promise; loesen: (ergebnis: StorageResult) => void; } { let loesen: ((ergebnis: StorageResult) => void) | null = null; const versprechen = new Promise((erfuellen) => { loesen = erfuellen; }); return { versprechen, loesen: (ergebnis: StorageResult): void => { loesen?.(ergebnis); }, }; } describe('Nachzuegler nach einem fehlgeschlagenen Sitzungsschreiben', () => { it('kommt trotzdem an', async () => { const erster = aufschieben(); vi.mocked(saveSession).mockReturnValueOnce(erster.versprechen); starte(); aendere('Räumweg 12,0 m'); await warte(1500); expect(saveSession).toHaveBeenCalledTimes(1); // Waehrend der erste Vorgang noch haengt, berichtigt der Anwender den // Raeumweg nach oben. Der zweite Anlauf merkt den Stand nur vor. aendere('Räumweg 14,5 m'); await warte(1500); expect(saveSession).toHaveBeenCalledTimes(1); erster.loesen(FEHLSCHLAG); await warte(120_000); expect( gesichert(), 'Der vorgemerkte Nachzuegler faellt nach einem Fehlschlag weg, obwohl der Entprellzeitgeber laengst abgeraeumt ist', ).toEqual(['Räumweg 12,0 m', 'Räumweg 14,5 m']); }); it('wird genau einmal nachgeholt und nicht in einer Schleife wiederholt', async () => { const erster = aufschieben(); vi.mocked(saveSession).mockReturnValueOnce(erster.versprechen); vi.mocked(saveSession).mockResolvedValue(FEHLSCHLAG); starte(); aendere('Räumweg 12,0 m'); await warte(1500); aendere('Räumweg 14,5 m'); await warte(1500); erster.loesen(FEHLSCHLAG); // Zehn Minuten bei dauerhaft defektem Speicher: Der nachgeholte Versuch // scheitert ebenfalls, danach steht nichts mehr an. Wuerde der Fehlerzweig // in der Schleife sofort weiterversuchen, liefe er hier durch. await warte(600_000); expect(saveSession, 'Der Fehlerzweig dreht durch').toHaveBeenCalledTimes(2); }); });