// @vitest-environment node /** * Die Persistenz der Nutzereinstellungen. * * Anlass ist eine Randnotiz aus der Abnahme (`docs/abnahme-befunde.md`, * Befund 13): `einstellungenBereinigen` füllt fehlende Felder mit den * Standardwerten auf. Legte man eine Teiländerung – etwa nur das Farbschema – * unmittelbar zugrunde, setzte jede Umschaltung alle übrigen Einstellungen * zurück. Damals war das folgenlos, weil kein Renderer-Code die anderen * Felder schrieb. * * Seit „Antwortmöglichkeiten mischen“ und „Vorlesen“ wirklich benutzt werden, * ist es das nicht mehr. Das Zusammenlegen in `einstellungenSchreiben` gibt * es zwar schon; geprüft wurde es nie. Diese Datei holt das nach – damit die * Zusammenlegung nicht eines Tages als überflüssig entfernt wird. */ import { existsSync, mkdirSync, mkdtempSync, readFileSync, rmSync } from 'node:fs'; import { tmpdir } from 'node:os'; import { join } from 'node:path'; import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; /* `app.getPath('userData')` ist die einzige Electron-Abhängigkeit des Moduls – ein Verzeichnis je Test hält die Läufe voneinander getrennt. */ let verzeichnis = ''; vi.mock('electron', () => ({ app: { getPath: (): string => verzeichnis, }, })); const { einstellungenBereinigen, einstellungenLesen, einstellungenSchreiben } = await import('../src/main/einstellungen'); function gespeichert(): Record { return JSON.parse(readFileSync(join(verzeichnis, 'einstellungen.json'), 'utf8')) as Record< string, unknown >; } beforeEach(() => { verzeichnis = mkdtempSync(join(tmpdir(), 'wsk-einstellungen-')); }); afterEach(() => { rmSync(verzeichnis, { recursive: true, force: true }); }); describe('einstellungenBereinigen', () => { it('nimmt für alles Unbrauchbare die Standardwerte', () => { /* Beide Schwierigkeitsregler stehen ab Werk auf „aus“: Ohne eigene Angabe steht die Anwendung so nah am amtlichen Katalog wie möglich. */ expect(einstellungenBereinigen(null)).toEqual({ thema: 'system', optionenMischen: false, antwortzahlVerbergen: false, vorlesen: false, vorlesenAutomatisch: false, vorlesenErklaerungBeiFehler: false, zuschnittGefragt: false, anzeigegroesse: 100, textabstand: 'normal', sprechtempo: 1, }); expect(einstellungenBereinigen({ thema: 'lila', vorlesen: 'ja' })).toEqual({ thema: 'system', optionenMischen: false, antwortzahlVerbergen: false, vorlesen: false, vorlesenAutomatisch: false, vorlesenErklaerungBeiFehler: false, zuschnittGefragt: false, anzeigegroesse: 100, textabstand: 'normal', sprechtempo: 1, }); }); it('übernimmt gültige Werte unverändert', () => { expect( einstellungenBereinigen({ thema: 'dunkel', optionenMischen: true, antwortzahlVerbergen: true, vorlesen: true, vorlesenAutomatisch: false, vorlesenErklaerungBeiFehler: false, zuschnittGefragt: false, profilId: 3, }), ).toEqual({ thema: 'dunkel', optionenMischen: true, antwortzahlVerbergen: true, vorlesen: true, vorlesenAutomatisch: false, vorlesenErklaerungBeiFehler: false, zuschnittGefragt: false, anzeigegroesse: 100, textabstand: 'normal', sprechtempo: 1, profilId: 3, }); }); }); describe('Tastenkürzel', () => { /* Dreiwertig, und das ist der ganze Punkt: Bis 0.21.0 lag diese Wahl im `localStorage` des Renderers. Beim Umzug in die Datei darf sie nicht zu einem gewöhnlichen Schalter mit Standardwert werden – das Fehlen des Feldes bedeutet „noch nicht entschieden“ und steuert die Voreinstellung nach erkanntem Hilfsmittel. */ it('erfindet keine Entscheidung, wo keine getroffen wurde', () => { expect(einstellungenBereinigen({}).tastenkuerzel).toBeUndefined(); expect('tastenkuerzel' in einstellungenBereinigen({})).toBe(false); }); it('hält beide ausdrücklichen Entscheidungen fest', () => { /* Besonders `false`: Ein Rückfallwert `?? true` schluckte genau die Entscheidung, die jemand bewusst getroffen hat. */ expect(einstellungenBereinigen({ tastenkuerzel: false }).tastenkuerzel).toBe(false); expect(einstellungenBereinigen({ tastenkuerzel: true }).tastenkuerzel).toBe(true); }); it('verwirft, was keine Entscheidung ist', () => { expect(einstellungenBereinigen({ tastenkuerzel: 'aus' }).tastenkuerzel).toBeUndefined(); expect(einstellungenBereinigen({ tastenkuerzel: null }).tastenkuerzel).toBeUndefined(); }); it('überlebt einen Neustart', () => { einstellungenSchreiben({ tastenkuerzel: false }); expect(einstellungenLesen().tastenkuerzel).toBe(false); expect(gespeichert()['tastenkuerzel']).toBe(false); }); }); describe('Anzeigegröße', () => { it('bereinigt eine Größe, die es nicht gibt', () => { /* Ein Wert aus einer früheren Fassung oder von Hand in die Datei geschrieben darf die Anwendung nicht in eine unbedienbare Größe zwingen. */ expect(einstellungenBereinigen({ anzeigegroesse: 5000 }).anzeigegroesse).toBe(400); expect(einstellungenBereinigen({ anzeigegroesse: 1 }).anzeigegroesse).toBe(80); expect(einstellungenBereinigen({ anzeigegroesse: 'gross' }).anzeigegroesse).toBe(100); }); it('hält eine gültige Stufe fest', () => { expect(einstellungenBereinigen({ anzeigegroesse: 150 }).anzeigegroesse).toBe(150); }); }); describe('einstellungenSchreiben', () => { it('legt eine Teiländerung über den gespeicherten Stand', () => { einstellungenSchreiben({ optionenMischen: true, antwortzahlVerbergen: true, vorlesen: true, vorlesenAutomatisch: false, vorlesenErklaerungBeiFehler: false, zuschnittGefragt: false, profilId: 2, }); /* Genau der Aufruf, den `useThema` macht: nur das Farbschema. */ const danach = einstellungenSchreiben({ thema: 'dunkel' }); /* Bewusst mit den vom Standard ABWEICHENDEN Werten geprüft: Nur so zeigt sich, dass die Teiländerung sie stehen lässt, statt sie stillschweigend auf die Standardwerte zurückzusetzen. */ expect(danach).toEqual({ thema: 'dunkel', optionenMischen: true, antwortzahlVerbergen: true, vorlesen: true, vorlesenAutomatisch: false, vorlesenErklaerungBeiFehler: false, zuschnittGefragt: false, anzeigegroesse: 100, textabstand: 'normal', sprechtempo: 1, profilId: 2, }); expect(gespeichert()).toEqual(danach); }); it('überlebt einen Neustart', () => { einstellungenSchreiben({ vorlesen: true, optionenMischen: false }); expect(einstellungenLesen()).toMatchObject({ vorlesen: true, optionenMischen: false }); }); it('meldet, wenn nicht geschrieben werden konnte', () => { /* Ein Pfad, unter dem sich nichts anlegen lässt: Die Datei tritt an die Stelle des Verzeichnisses. Stiller Verlust wäre hier das Schlimmste – die Einstellung gälte bis zum Beenden und wäre danach weg. */ einstellungenSchreiben({ thema: 'hell' }); verzeichnis = join(verzeichnis, 'einstellungen.json'); expect(() => einstellungenSchreiben({ thema: 'dunkel' })).toThrow( /konnten nicht gespeichert werden/u, ); }); }); /* Geschrieben wird über eine Nebendatei und ein `rename`. Vorher ging es geradewegs in die Zieldatei: Ein Abbruch mitten im Schreiben – Stromausfall, voller Datenträger nach dem halben Puffer – hinterließ eine verstümmelte Datei. Gelesen wurde sie danach zwar sauber abgefangen, aber der Nutzer verlor wortlos Farbschema, Anzeigegröße, Vorlesen, Tastenkürzel und das zuletzt benutzte Profil. Es war der einzige nicht unteilbare Schreibweg der Anwendung. */ describe('einstellungenSchreiben – unteilbar', () => { const ziel = (): string => join(verzeichnis, 'einstellungen.json'); const neben = (): string => `${ziel()}.neu`; it('lässt nach dem Schreiben keine Nebendatei zurück', () => { einstellungenSchreiben({ thema: 'dunkel' }); expect(existsSync(ziel())).toBe(true); expect(existsSync(neben())).toBe(false); }); it('lässt den alten Stand unversehrt, wenn das Schreiben scheitert', () => { einstellungenSchreiben({ thema: 'hell', anzeigegroesse: 250 }); /* Der Abbruch mitten im Vorgang, ohne Attrappe: Ein Verzeichnis an der Stelle der Nebendatei lässt `writeFileSync` scheitern. Ginge es geradewegs in die Zieldatei, träfe derselbe Fehler sie – und zwar erst, nachdem sie geöffnet und geleert wurde. */ mkdirSync(neben(), { recursive: true }); expect(() => einstellungenSchreiben({ thema: 'dunkel' })).toThrow( /konnten nicht gespeichert werden/u, ); rmSync(neben(), { recursive: true, force: true }); expect(einstellungenLesen()).toMatchObject({ thema: 'hell', anzeigegroesse: 250 }); }); it('räumt die Nebendatei weg, wenn das Umbenennen scheitert', () => { einstellungenSchreiben({ thema: 'hell' }); /* Ein nicht leeres Verzeichnis an der Stelle der Zieldatei: Schreiben gelingt, Umbenennen nicht. Bliebe die Nebendatei liegen, stünde ein halber Stand neben dem gültigen und belegte Platz, den es gerade nicht gab. */ rmSync(ziel(), { force: true }); mkdirSync(join(ziel(), 'sperre'), { recursive: true }); expect(() => einstellungenSchreiben({ thema: 'dunkel' })).toThrow( /konnten nicht gespeichert werden/u, ); expect(existsSync(neben())).toBe(false); }); });