import { readFileSync, readdirSync } from 'node:fs'; import path from 'node:path'; import { afterEach, describe, expect, it } from 'vitest'; import { addRecoveryPoint, setzeSpeicherUmgebung } from '@/services/storage'; import { createEmptyProject } from '@/domain/model/factory'; /** * Befunde 31 und 32 zur Fassung 5.9.0. * * BEFUND 31: `src/services/autosave.ts` sagte im Klassenkommentar zu, dass sich * auf einen Wiederherstellungspunkt "gezielt zurueckgehen laesst". * `listRecoveryPoints` hat ausserhalb der Pruefungen keinen Aufrufer, und in der * Oberflaeche gibt es kein Element, keinen Menuepunkt und kein Tastenkuerzel * dafuer. Zurueckgehen laesst sich auf keinen. Zusagen in Kommentaren sind * Teil des Programms; Zusage und Bedienweg gehoeren also zusammen. * * BEFUND 32: `addRecoveryPoint` fing seinen Fehler selbst ab und erfuellte das * Versprechen auch dann, wenn nichts geschrieben wurde. `AutoSave` setzte * daraufhin `letzterPunkt` und sperrte damit jeden weiteren Versuch fuer * denselben Projektzustand - obwohl der Kommentar drei Zeilen darueber * ausdruecklich festlegt: "Erst NACH dem geglueckten Schreiben gesetzt: Ein * gescheiterter Punkt liegt nirgends und darf den naechsten Versuch nicht * sperren." Wer nach der letzten Aenderung nicht weiterarbeitet - der Regelfall * am Feierabend -, bekam fuer diesen Stand nie einen Punkt. * * Im Ersatzweg wiegt es schwerer: `fuegePunktErsatzweiseHinzu` LEERT bei einem * Kontingentfehler die ganze Punkteliste. Danach loeste das Versprechen auf, * `letzterPunkt` wurde gesetzt, und die eben geleerte Liste wurde fuer diesen * Arbeitsstand nie wieder gefuellt. */ // --- Befund 31: die Zusage im Kommentar ------------------------------------- const AUTOSAVE = readFileSync('src/services/autosave.ts', 'utf8'); /** Alle Quelldateien unter `src/`. */ function quelldateien(verzeichnis: string): string[] { const gefunden: string[] = []; for (const eintrag of readdirSync(verzeichnis, { withFileTypes: true })) { const pfad = path.join(verzeichnis, eintrag.name); if (eintrag.isDirectory()) gefunden.push(...quelldateien(pfad)); else if (eintrag.name.endsWith('.ts')) gefunden.push(pfad); } return gefunden; } /** * Ruft die Datei die Leseseite wirklich auf? * * Kommentarzeilen bleiben aussen vor: Eine Erwaehnung im Fliesstext ist kein * Bedienweg, wuerde die Wache aber sonst still ausser Kraft setzen. */ function ruftLeseseiteAuf(quelltext: string): boolean { return quelltext.split('\n').some((zeile) => { const rumpf = zeile.trim(); if (rumpf.startsWith('*') || rumpf.startsWith('//') || rumpf.startsWith('/*')) return false; return /listRecoveryPoints\s*\(/.test(rumpf); }); } describe('Wiederherstellungspunkte: Zusage und Bedienweg', () => { it('verspricht nur dann ein gezieltes Zurueckgehen, wenn es einen Weg dorthin gibt', () => { // Sachstand zuerst: Ruft ausser der Speicherschicht selbst irgendetwas in // `src/` die Leseseite auf? Nur ueber sie kaeme ein Bedienweg an die Punkte. const eigenerOrt = path.join('src', 'services', 'storage.ts'); const aufrufer = quelldateien('src').filter( (datei) => datei !== eigenerOrt && ruftLeseseiteAuf(readFileSync(datei, 'utf8')), ); // Und dann: Der Kommentar sagt genau so viel zu, wie eingeloest ist. expect( /gezielt zurueckgehen/.test(AUTOSAVE), aufrufer.length === 0 ? 'Der Kommentar verspricht ein gezieltes Zurueckgehen, das es nicht gibt' : 'Es gibt einen Bedienweg, aber der Kommentar nennt ihn nicht mehr', ).toBe(aufrufer.length > 0); }); }); // --- Befund 32: ein gescheiterter Punkt darf nicht als geschrieben gelten ---- /** Eine Datenbank, deren Transaktionen sich nicht oeffnen lassen. */ function fabrikOhneTransaktion(): IDBFactory { return { open: (): unknown => { const anfrage: Record = { result: undefined, onupgradeneeded: null, onsuccess: null, onerror: null, onblocked: null, }; queueMicrotask(() => { anfrage['result'] = { objectStoreNames: { contains: (): boolean => true }, createObjectStore: (): unknown => ({}), transaction: (): never => { throw new Error('Die Transaktion wurde abgebrochen.'); }, close: (): void => undefined, onversionchange: null, }; (anfrage['onsuccess'] as (() => void) | null)?.(); }); return anfrage; }, } as unknown as IDBFactory; } const KEY_RECOVERY = 'lsa-planer.v5.recovery'; /** * Ein Ersatzspeicher, dem beim Ablegen eines Punktes der Platz ausgeht. * * Die Probe von `storage()` und das Leeren der Liste gelingen weiterhin - genau * so verhaelt sich ein voller localStorage: Was kuerzer ist als das * Vorhandene, passt noch hinein. */ function speicherMitVollerPunkteliste(): Storage { const daten = new Map(); return { get length(): number { return daten.size; }, clear: (): void => { daten.clear(); }, getItem: (schluessel: string): string | null => daten.get(schluessel) ?? null, key: (index: number): string | null => [...daten.keys()][index] ?? null, removeItem: (schluessel: string): void => { daten.delete(schluessel); }, setItem: (schluessel: string, wert: string): void => { if (schluessel === KEY_RECOVERY && wert !== '[]') { const fehler = new Error('Der Speicher ist voll.'); fehler.name = 'QuotaExceededError'; throw fehler; } daten.set(schluessel, String(wert)); }, }; } afterEach(() => { setzeSpeicherUmgebung(null); }); describe('Ein Wiederherstellungspunkt, der nicht geschrieben werden konnte', () => { it('meldet den Fehlschlag, statt das Versprechen zu erfuellen', async () => { setzeSpeicherUmgebung({ indexedDB: fabrikOhneTransaktion(), localStorage: null }); await expect( addRecoveryPoint(createEmptyProject('Knotenpunkt')), 'Ein gescheiterter Punkt gilt als geschrieben und sperrt den naechsten Versuch', ).rejects.toThrow(); }); it('meldet ihn auch im Ersatzweg, wo dabei die ganze Liste geleert wird', async () => { const speicher = speicherMitVollerPunkteliste(); setzeSpeicherUmgebung({ indexedDB: null, localStorage: speicher }); await expect(addRecoveryPoint(createEmptyProject('Knotenpunkt'))).rejects.toThrow(); // Der Verlust ist echt: Vorher konnten dort bis zu fuenf Staende liegen. expect(speicher.getItem(KEY_RECOVERY)).toBe('[]'); }); it('meldet keinen Fehlschlag, wenn es ueberhaupt keinen Speicher gibt', async () => { // Ohne jeden Speicher gibt es nichts zu wiederholen - ein erneuter Versuch // alle zwei Minuten schriebe wieder nichts. Das ist kein Fehlschlag, // sondern eine Umgebung ohne Ablage. setzeSpeicherUmgebung({ indexedDB: null, localStorage: null }); await expect(addRecoveryPoint(createEmptyProject('Knotenpunkt'))).resolves.toBeUndefined(); }); });