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'; /** * Zwischenspeichern des Arbeitsstands. * * Geprueft wird das, was im Altbestand nachweislich schiefging und was durch die * Umstellung auf einen asynchronen Speicher hinzukam: * * - Bei rascher Eingabe darf nicht jede Aenderung geschrieben werden, aber die * LETZTE muss ankommen. Ein Verfahren, das nur die erste Aenderung sichert, * faellt in keiner Sichtpruefung auf - es sichert ja etwas. * - Waehrend ein Schreibvorgang laeuft, wird kein zweiter gestartet. Sonst * stapelten sich mehrere 20-MB-Sicherungen, von denen die aelteste * womoeglich als letzte ankaeme; der Anwender haette dann einen aelteren * Stand auf der Platte als auf dem Bildschirm. * - `stop()` muss vollstaendig abraeumen. Der Altbestand liess je * Projektwechsel ein setInterval stehen. * - Eine wiederholte Meldung darf den Meldungsbereich nicht zumauern; die * Warnung, dass der Stand nur noch im Ersatzspeicher liegt, muss aber * ueberhaupt einmal herauskommen. * * WARUM EINE EIGENE NACHBILDUNG * * Die Pruefung laeuft in Node; dort gibt es weder `document` noch `window`, und * der Sitzungsspeicher wuerde ohne IndexedDB in den Ersatzweg fallen. Der * Speichervorgang wird deshalb durch vi.fn() ersetzt - so laesst sich ein * langsamer und ein fehlschlagender Schreibvorgang genau steuern - und die * beiden Ereignisziele werden nachgebildet. Der Zustandsspeicher bleibt der * echte: Ob eine Aenderung als `dirty` ankommt, ist Teil dessen, was hier * abgesichert wird. */ vi.mock('@/services/storage', () => ({ saveSession: vi.fn(), addRecoveryPoint: vi.fn(), })); // --- Nachbildung der Ereignisziele ----------------------------------------- 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); } /** Wie viele Hoerer haengen an diesem Ereignis? Deckt doppelte Anmeldung auf. */ anzahl(typ: string): number { return this.empfaenger.get(typ)?.size ?? 0; } ausloesen(typ: string): void { for (const hoerer of [...(this.empfaenger.get(typ) ?? [])]) hoerer(); } } class Anzeige extends Ereignisziel { visibilityState: 'visible' | 'hidden' = 'visible'; } // --- Geruest ---------------------------------------------------------------- const EINSTELLUNGEN: AppSettings = { theme: 'system', autoSaveEnabled: true, autoSaveIntervalSeconds: 120, locale: 'de', meldungsdauerSekunden: 5, fundstellen: {}, }; const ERFOLG: StorageResult = { ok: true, message: '' }; let store: ProjectStore; let anzeige: Anzeige; let fenster: Ereignisziel; let meldungen: { text: string; art: 'info' | 'warnung' | 'fehler' }[]; 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')); anzeige = new Anzeige(); fenster = new Ereignisziel(); meldungen = []; laufend = null; vi.stubGlobal('document', anzeige); vi.stubGlobal('window', fenster); }); afterEach(() => { laufend?.stop(); vi.useRealTimers(); vi.unstubAllGlobals(); }); function starte(abweichung: Partial = {}): AutoSave { const dienst = new AutoSave(store, { ...EINSTELLUNGEN, ...abweichung }, (text, art) => { meldungen.push({ text, art }); }); 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); } interface Aufschub { readonly versprechen: Promise; loesen(ergebnis: StorageResult): void; } /** Ein Schreibvorgang, der erst auf Zuruf fertig wird. */ function aufschieben(): Aufschub { let loesen: ((ergebnis: StorageResult) => void) | null = null; const versprechen = new Promise((erfuellen) => { loesen = erfuellen; }); return { versprechen, loesen: (ergebnis: StorageResult): void => { loesen?.(ergebnis); }, }; } // --- Pruefungen ------------------------------------------------------------- describe('Verzoegertes Sichern nach einer Aenderung', () => { it('schreibt vor Ablauf der Wartezeit nicht und danach genau einmal', async () => { starte(); aendere('Erste Eingabe'); await warte(1400); expect(saveSession).not.toHaveBeenCalled(); await warte(100); expect(gesichert()).toEqual(['Erste Eingabe']); }); it('fasst rasch aufeinanderfolgende Aenderungen zusammen - und sichert die letzte', async () => { starte(); aendere('A'); await warte(500); aendere('B'); await warte(500); aendere('C'); await warte(500); // Bis hierhin ist die Wartezeit nie ganz abgelaufen. expect(saveSession).not.toHaveBeenCalled(); await warte(1500); // Ein Schreibvorgang, und darin steht der letzte Stand. Genau das ist der // Punkt: Zusammenfassen darf nicht heissen, die spaeteren Eingaben zu // verlieren. expect(gesichert()).toEqual(['C']); }); it('ueberspringt einen unveraenderten Stand', async () => { const dienst = starte(); aendere('Einmalig'); await warte(1500); expect(saveSession).toHaveBeenCalledTimes(1); // Ohne Aenderung kein Schreibvorgang - bei einem Luftbild von 20 MB waere // jeder davon umsonst. await dienst.flush(); await dienst.flush(); expect(saveSession).toHaveBeenCalledTimes(1); }); it('sichert nicht, solange sich nichts geaendert hat', async () => { starte(); await warte(60_000); expect(saveSession).not.toHaveBeenCalled(); }); }); describe('Ein Schreibvorgang nach dem anderen', () => { it('fasst Aenderungen waehrend des Schreibens zu einem Nachzuegler zusammen', async () => { const erster = aufschieben(); vi.mocked(saveSession).mockReturnValueOnce(erster.versprechen); starte(); aendere('A'); await warte(1500); expect(saveSession).toHaveBeenCalledTimes(1); // Waehrend der erste Vorgang noch laeuft, tippt der Anwender weiter. aendere('B'); await warte(1500); aendere('C'); await warte(1500); expect(saveSession).toHaveBeenCalledTimes(1); erster.loesen(ERFOLG); await warte(0); // Zwei Vorgaenge statt vier, und der Nachzuegler traegt den neuesten Stand. expect(gesichert()).toEqual(['A', 'C']); }); it('holt den Nachzuegler auch dann nach, wenn er ueber flush kam', async () => { const erster = aufschieben(); vi.mocked(saveSession).mockReturnValueOnce(erster.versprechen); const dienst = starte(); aendere('A'); await warte(1500); expect(saveSession).toHaveBeenCalledTimes(1); aendere('B'); // HEUTIGES VERHALTEN: Das Versprechen aus flush() ist bereits erfuellt, // obwohl B noch nicht geschrieben ist - es wartet nicht auf den laufenden // Vorgang. Wer sich beim Verlassen darauf verlaesst, wartet auf nichts. await dienst.flush(); expect(saveSession).toHaveBeenCalledTimes(1); erster.loesen(ERFOLG); await warte(0); expect(gesichert()).toEqual(['A', 'B']); }); }); describe('Sichern beim Sichtwechsel und beim Verlassen', () => { it('sichert sofort, sobald das Fenster in den Hintergrund geht', async () => { starte(); aendere('Vor dem Wechsel'); await warte(100); expect(saveSession).not.toHaveBeenCalled(); anzeige.visibilityState = 'hidden'; anzeige.ausloesen('visibilitychange'); await warte(0); expect(gesichert()).toEqual(['Vor dem Wechsel']); // Die eingeplante Verzoegerung ist damit abgeraeumt und schreibt nicht noch // einmal hinterher. await warte(1500); expect(saveSession).toHaveBeenCalledTimes(1); }); it('sichert nicht, solange das Fenster sichtbar bleibt', async () => { starte(); aendere('Sichtbar'); anzeige.ausloesen('visibilitychange'); await warte(0); expect(saveSession).not.toHaveBeenCalled(); }); it('sichert beim Schliessen des Fensters', async () => { starte(); aendere('Letzter Stand'); await warte(100); fenster.ausloesen('beforeunload'); await warte(0); expect(gesichert()).toEqual(['Letzter Stand']); }); }); describe('Anhalten', () => { it('schreibt nach stop() nichts mehr - auch keinen eingeplanten Nachzuegler', async () => { const dienst = starte(); aendere('Eingeplant'); await warte(1000); expect(saveSession).not.toHaveBeenCalled(); dienst.stop(); await warte(60_000); aendere('Nach dem Anhalten'); await warte(60_000); expect(saveSession).not.toHaveBeenCalled(); expect(addRecoveryPoint).not.toHaveBeenCalled(); }); it('meldet sich von Fenster und Anzeige ab', async () => { const dienst = starte(); expect(anzeige.anzahl('visibilitychange')).toBe(1); expect(fenster.anzahl('beforeunload')).toBe(1); dienst.stop(); expect(anzeige.anzahl('visibilitychange')).toBe(0); expect(fenster.anzahl('beforeunload')).toBe(0); anzeige.visibilityState = 'hidden'; anzeige.ausloesen('visibilitychange'); fenster.ausloesen('beforeunload'); await warte(0); expect(saveSession).not.toHaveBeenCalled(); }); it('haelt auch nach mehrfachem Start genau einen Zeitgeber und einen Hoerer', async () => { const dienst = starte(); dienst.start(); dienst.start(); expect(anzeige.anzahl('visibilitychange')).toBe(1); expect(fenster.anzahl('beforeunload')).toBe(1); aendere('Einmal'); await warte(1500); // Der Altbestand haette hier je Start einen eigenen Zeitgeber laufen // lassen; drei Sicherungen desselben Stands haetten sich ueberholt. expect(saveSession).toHaveBeenCalledTimes(1); await warte(120_000); expect(addRecoveryPoint).toHaveBeenCalledTimes(1); }); }); describe('Wiederherstellungspunkte', () => { it('legt fruehestens nach 15 Sekunden einen Punkt an, auch bei kleinerer Einstellung', async () => { starte({ autoSaveIntervalSeconds: 5 }); aendere('Laufende Arbeit'); await warte(14_000); expect(addRecoveryPoint).not.toHaveBeenCalled(); await warte(1_000); expect(addRecoveryPoint).toHaveBeenCalledTimes(1); }); it('legt keinen Punkt an, wenn seit dem letzten Sichern nichts geschehen ist', async () => { const dienst = starte(); aendere('Fertig'); await dienst.flush(); store.markSaved(); await warte(360_000); expect(addRecoveryPoint).not.toHaveBeenCalled(); }); it('laeuft weiter, wenn ein Punkt nicht angelegt werden kann', async () => { vi.mocked(addRecoveryPoint).mockRejectedValue(new Error('Kein Platz mehr.')); starte(); aendere('Laufende Arbeit'); await warte(120_000); expect(addRecoveryPoint).toHaveBeenCalledTimes(1); // Beiwerk: Ein gescheiterter Punkt darf den Zeitgeber nicht anhalten und // nicht als Meldung beim Anwender landen. await warte(120_000); expect(addRecoveryPoint).toHaveBeenCalledTimes(2); expect(meldungen).toEqual([]); }); }); describe('Meldungen an den Anwender', () => { it('meldet einen gelungenen Speichervorgang nicht', async () => { starte(); aendere('Still'); await warte(1500); expect(saveSession).toHaveBeenCalledTimes(1); expect(meldungen).toEqual([]); }); it('gibt einen geglueckten Speichervorgang mit Hinweis als Warnung aus', async () => { vi.mocked(saveSession).mockResolvedValue({ ok: true, message: 'Ersatzweg in Benutzung.' }); starte(); aendere('Ersatzweg'); await warte(1500); // Geglueckt, aber nicht so, wie es sollte: Der Stand liegt im // Ersatzspeicher, in den kein Luftbild passt. Als Kurzmeldung ginge das // unter - es muss eine Warnung sein. expect(meldungen).toEqual([{ text: 'Ersatzweg in Benutzung.', art: 'warnung' }]); }); it('warnt genau einmal, auch wenn jeder Speichervorgang im Ersatzweg landet', async () => { vi.mocked(saveSession).mockResolvedValue({ ok: true, message: 'Ersatzweg in Benutzung.' }); starte(); for (const name of ['A', 'B', 'C']) { aendere(name); await warte(1500); } expect(saveSession).toHaveBeenCalledTimes(3); expect(meldungen).toEqual([{ text: 'Ersatzweg in Benutzung.', art: 'warnung' }]); }); it('wiederholt dieselbe Meldung nicht bei jedem Speichern', async () => { vi.mocked(saveSession).mockResolvedValue({ ok: false, message: 'Kein Speicher vorhanden.' }); starte(); for (const name of ['A', 'B', 'C', 'D']) { aendere(name); await warte(1500); } // Vier Versuche, eine Meldung. Ohne die Sperre stuende der Meldungsbereich // alle 1,5 s voll und waere fuer alles andere unbrauchbar. expect(saveSession).toHaveBeenCalledTimes(4); expect(meldungen).toEqual([{ text: 'Kein Speicher vorhanden.', art: 'fehler' }]); }); it('meldet einen anderen Text sehr wohl', async () => { vi.mocked(saveSession) .mockResolvedValueOnce({ ok: false, message: 'Kein Speicher vorhanden.' }) .mockResolvedValue({ ok: false, message: 'Der Stand ist zu gross.' }); starte(); aendere('A'); await warte(1500); aendere('B'); await warte(1500); expect(meldungen).toEqual([ { text: 'Kein Speicher vorhanden.', art: 'fehler' }, { text: 'Der Stand ist zu gross.', art: 'fehler' }, ]); }); it('meldet einen wiederkehrenden Fehler erneut, wenn zwischendurch gesichert wurde', async () => { vi.mocked(saveSession) .mockResolvedValueOnce({ ok: false, message: 'Kein Speicher vorhanden.' }) .mockResolvedValueOnce(ERFOLG) .mockResolvedValue({ ok: false, message: 'Kein Speicher vorhanden.' }); starte(); for (const name of ['A', 'B', 'C']) { aendere(name); await warte(1500); } // Die Sperre gilt der Dauerwiederholung, nicht dem erneuten Auftreten: // Dass es nach einem gelungenen Speichern wieder klemmt, ist eine // Nachricht. expect(meldungen).toEqual([ { text: 'Kein Speicher vorhanden.', art: 'fehler' }, { text: 'Kein Speicher vorhanden.', art: 'fehler' }, ]); }); }); describe('Wenn das Sichern fehlschlaegt', () => { it('merkt sich den Stand nicht als gesichert und schreibt ihn erneut', async () => { vi.mocked(saveSession).mockResolvedValueOnce({ ok: false, message: 'Kein Speicher vorhanden.', }); const dienst = starte(); aendere('Wichtiger Stand'); await warte(1500); expect(gesichert()).toEqual(['Wichtiger Stand']); // Ohne weitere Aenderung: Der fehlgeschlagene Stand gilt nicht als // geschrieben, der naechste Anlauf nimmt ihn erneut mit. await dienst.flush(); expect(gesichert()).toEqual(['Wichtiger Stand', 'Wichtiger Stand']); }); it('legt nach einem Fehlschlag weiter Wiederherstellungspunkte an', async () => { vi.mocked(saveSession).mockResolvedValue({ ok: false, message: 'Kein Speicher vorhanden.' }); starte(); aendere('Laufende Arbeit'); await warte(120_000); expect(addRecoveryPoint).toHaveBeenCalledTimes(1); }); it('holt den waehrenddessen aufgelaufenen Nachzuegler nach', async () => { const erster = aufschieben(); vi.mocked(saveSession).mockReturnValueOnce(erster.versprechen); starte(); aendere('A'); await warte(1500); aendere('B'); await warte(1500); expect(saveSession).toHaveBeenCalledTimes(1); erster.loesen({ ok: false, message: 'Kein Speicher vorhanden.' }); await warte(120_000); /* * Hier stand zuvor `toEqual(['A'])` unter der Marke "HEUTIGES VERHALTEN, * festgehalten und gemeldet". Der Fall war damit als Mangel benannt, nicht * begruendet: Der Fehlschlag beendete die Schleife, und der vorgemerkte * Nachzuegler B fiel weg, obwohl der Entprellzeitgeber laengst abgeraeumt * war - es stand also kein Sitzungsschreibversuch mehr an. Das widersprach * der Zusage im Klassenkommentar ("Der laufende Vorgang holt den neuesten * Stand selbst nach") und dem Pruefziel im Kopf dieser Datei ("die LETZTE * muss ankommen"). Behoben als Befund 53 zur Fassung 5.9.0; * die eigene Wache dazu liegt in * tests/services/nachzueglerNachFehlschlag.test.ts. */ expect(gesichert()).toEqual(['A', 'B']); }); }); describe('Abgeschaltete Zwischenspeicherung', () => { it('richtet nichts ein und schreibt nichts', async () => { starte({ autoSaveEnabled: false }); expect(anzeige.anzahl('visibilitychange')).toBe(0); expect(fenster.anzahl('beforeunload')).toBe(0); aendere('Nicht zu sichern'); await warte(360_000); expect(saveSession).not.toHaveBeenCalled(); expect(addRecoveryPoint).not.toHaveBeenCalled(); }); it('haelt sofort an, wenn sie waehrend des Betriebs abgeschaltet wird', async () => { const dienst = starte(); aendere('A'); await warte(1500); expect(saveSession).toHaveBeenCalledTimes(1); dienst.updateSettings({ ...EINSTELLUNGEN, autoSaveEnabled: false }); expect(anzeige.anzahl('visibilitychange')).toBe(0); aendere('B'); await warte(360_000); expect(saveSession).toHaveBeenCalledTimes(1); }); it('uebernimmt einen geaenderten Abstand fuer die Wiederherstellungspunkte', async () => { const dienst = starte(); dienst.updateSettings({ ...EINSTELLUNGEN, autoSaveIntervalSeconds: 20 }); aendere('Laufende Arbeit'); await warte(19_000); expect(addRecoveryPoint).not.toHaveBeenCalled(); await warte(1_000); expect(addRecoveryPoint).toHaveBeenCalledTimes(1); }); });