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'; /** * Wiederherstellungspunkte im Leerlauf nach dem Programmstart (Befund 41). * * Was der Anwender sonst erlebt: Er startet das Programm, sieht seinen * wiederhergestellten Stand an und geht zum Kaffee. Ohne dass er eine Taste * anruehrt, legt das Programm alle zwei Minuten einen weiteren * Wiederherstellungspunkt mit genau demselben Projekt an. Nach zehn Minuten * enthaelt der Fuenferring nur noch identische Kopien des Startzustands - die * Punkte frueherer Sitzungen sind verdraengt -, und es wird im Zweiminutentakt * weitergeschrieben. * * Ursache: Die Sperre in `createRecoveryPoint()` lautete * `if (!state.dirty && this.lastSavedAt > 0) return;`. `lastSavedAt` wird nur * nach einem geglueckten Sitzungsspeichern gesetzt; `boot()` sichert beim Start * aber nicht, also bleibt es 0 und die Sperre greift nie. * * Die Nachbildung entspricht der in tests/services/autosave.test.ts: Die * Pruefung laeuft in Node, dort gibt es weder `document` noch `window`. */ 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); } ausloesen(typ: string): void { for (const hoerer of [...(this.empfaenger.get(typ) ?? [])]) hoerer(); } } class Anzeige extends Ereignisziel { visibilityState: 'visible' | 'hidden' = 'visible'; } const EINSTELLUNGEN: AppSettings = { theme: 'system', autoSaveEnabled: true, autoSaveIntervalSeconds: 120, locale: 'de', meldungsdauerSekunden: 5, fundstellen: {}, }; const ERFOLG: StorageResult = { ok: true, message: '' }; let store: ProjectStore; let laufend: AutoSave | null; beforeEach(() => { vi.useFakeTimers(); vi.mocked(saveSession).mockReset(); vi.mocked(saveSession).mockResolvedValue(ERFOLG); 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(abweichung: Partial = {}): AutoSave { const dienst = new AutoSave(store, { ...EINSTELLUNGEN, ...abweichung }, () => { /* Meldungen spielen hier keine Rolle. */ }); dienst.start(); laufend = dienst; return dienst; } 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); } describe('Wiederherstellungspunkte im Leerlauf', () => { it('legt nach dem Start genau einen Punkt an, solange nichts geaendert wird', async () => { // Wie nach `boot()`: Der Stand ist wiederhergestellt, es wurde noch nichts // gesichert (lastSavedAt === 0) und der Anwender ruehrt nichts an. starte(); await warte(120_000); expect(addRecoveryPoint).toHaveBeenCalledTimes(1); // Zehn Minuten Leerlauf: Der Fuenferring darf sich nicht mit Kopien // desselben Stands fuellen. await warte(600_000); expect(addRecoveryPoint).toHaveBeenCalledTimes(1); }); it('legt nach einer echten Aenderung wieder einen Punkt an', async () => { starte(); await warte(120_000); expect(addRecoveryPoint).toHaveBeenCalledTimes(1); aendere('Bahnhofstrasse Nord'); await warte(120_000); expect(addRecoveryPoint).toHaveBeenCalledTimes(2); // Und danach im Leerlauf wieder keinen. await warte(240_000); expect(addRecoveryPoint).toHaveBeenCalledTimes(2); }); it('versucht einen gescheiterten Punkt beim naechsten Takt erneut', async () => { // Ein Punkt, der nicht geschrieben werden konnte, ist nicht abgelegt - er // darf den naechsten Versuch nicht sperren. vi.mocked(addRecoveryPoint).mockRejectedValue(new Error('Kein Platz mehr.')); starte(); await warte(120_000); expect(addRecoveryPoint).toHaveBeenCalledTimes(1); await warte(120_000); expect(addRecoveryPoint).toHaveBeenCalledTimes(2); }); });