waffensachkunde

Waffensachkunde – Lernsoftware für die Sachkundeprüfung nach § 7 WaffG. Barrierefrei, offline, EUPL-1.2.

/ app tests sicherung.test.ts

24,8 KB Rohdatei
app/tests/sicherung.test.ts — 640 Zeilen
1 /**
2 * Sicherung des Lernstands.
3 *
4 * Alles hier läuft gegen echte SQLite-Dateien auf der Platte, nicht gegen
5 * Attrappen: Die Fallen dieses Merkmals sind Fallen von SQLite selbst, und
6 * eine Attrappe hätte keine davon.
7 */
8
9 import {
10 copyFileSync,
11 existsSync,
12 mkdtempSync,
13 readFileSync,
14 rmSync,
15 statSync,
16 writeFileSync,
17 } from 'node:fs';
18 import { tmpdir } from 'node:os';
19 import { join } from 'node:path';
20
21 import Database from 'better-sqlite3';
22 import { afterEach, beforeEach, describe, expect, it } from 'vitest';
23
24 import { SCHEMA_VERSION } from '../src/main/schema';
25 import {
26 ANWENDUNGSKENNUNG,
27 aufraeumen,
28 dateiPruefen,
29 einstellungenAusSicherung,
30 kennzahlenLesen,
31 nebendateienAufraeumen,
32 sicherungsDateiname,
33 reisendeEinstellungen,
34 sicherungSchreiben,
35 } from '../src/main/sicherung';
36 import { EINSTELLUNGEN_STANDARD } from '../src/shared/ipc';
37
38 let ordner: string;
39 let offene: Database.Database[];
40
41 /** Ein Lernstand, wie ihn die Anwendung anlegt – im WAL-Modus. */
42 function lernstandAnlegen(pfad: string, antworten = 5): Database.Database {
43 const db = new Database(pfad);
44 offene.push(db);
45 db.pragma('journal_mode = WAL');
46 db.exec(`
47 CREATE TABLE schema_version (version INTEGER PRIMARY KEY);
48 CREATE TABLE profil (id INTEGER PRIMARY KEY AUTOINCREMENT, name TEXT NOT NULL);
49 CREATE TABLE frage_stand (
50 profil_id INTEGER NOT NULL REFERENCES profil(id),
51 frage_id TEXT NOT NULL,
52 gemerkt INTEGER NOT NULL DEFAULT 0,
53 PRIMARY KEY (profil_id, frage_id)
54 );
55 CREATE TABLE antwort_log (
56 id INTEGER PRIMARY KEY AUTOINCREMENT,
57 profil_id INTEGER NOT NULL REFERENCES profil(id),
58 zeitpunkt TEXT NOT NULL
59 );
60 `);
61 for (let version = 1; version <= SCHEMA_VERSION; version++) {
62 db.prepare('INSERT INTO schema_version (version) VALUES (?)').run(version);
63 }
64 db.prepare('INSERT INTO profil (name) VALUES (?)').run('Standard');
65 const einfuegen = db.prepare('INSERT INTO antwort_log (profil_id, zeitpunkt) VALUES (1, ?)');
66 const alle = db.transaction(() => {
67 for (let i = 0; i < antworten; i++) {
68 einfuegen.run(`2026-08-${String(10 + (i % 10)).padStart(2, '0')}T09:00:00.000Z`);
69 }
70 });
71 alle();
72 db.prepare('INSERT INTO frage_stand (profil_id, frage_id, gemerkt) VALUES (1, ?, 1)').run(
73 'I.1-01',
74 );
75 return db;
76 }
77
78 beforeEach(() => {
79 ordner = mkdtempSync(join(tmpdir(), 'wsk-sicherung-'));
80 offene = [];
81 });
82
83 afterEach(() => {
84 for (const db of offene) {
85 try {
86 db.close();
87 } catch {
88 /* Schon zu. */
89 }
90 }
91 rmSync(ordner, { recursive: true, force: true });
92 });
93
94 describe('Warum eine bloße Dateikopie nicht genügt', () => {
95 it('verliert bei offener Verbindung im WAL-Modus alles – und meldet trotzdem „ok“', () => {
96 /*
97 Der Messwert, der die ganze Umsetzung begründet. Er steht hier als Test,
98 damit niemand später auf die naheliegende Abkürzung verfällt: Die Kopie
99 ist nicht bloß älter, sie ist LEER – bei tadellosem integrity_check.
100 */
101 const quelle = join(ordner, 'lernstand.db');
102 lernstandAnlegen(quelle, 200);
103
104 const kopie = join(ordner, 'nur-hauptdatei.db');
105 copyFileSync(quelle, kopie);
106
107 const db = new Database(kopie, { readonly: true });
108 offene.push(db);
109 const zeilen = db.pragma('integrity_check') as { integrity_check: string }[];
110 expect(zeilen[0]?.integrity_check).toBe('ok');
111
112 const tabellen = db
113 .prepare<[], { name: string }>("SELECT name FROM sqlite_master WHERE type = 'table'")
114 .all();
115 expect(tabellen).toHaveLength(0);
116 });
117 });
118
119 describe('Eine Sicherung schreiben', () => {
120 it('nimmt alles mit, obwohl die Verbindung offen bleibt', () => {
121 const quelle = join(ordner, 'lernstand.db');
122 const db = lernstandAnlegen(quelle, 200);
123
124 const ziel = join(ordner, 'sicherung.wsklernstand');
125 const bytes = sicherungSchreiben(db, ziel, Database);
126
127 expect(bytes).toBeGreaterThan(0);
128 const gelesen = new Database(ziel, { readonly: true });
129 offene.push(gelesen);
130 expect(
131 gelesen.prepare<[], { anzahl: number }>('SELECT COUNT(*) AS anzahl FROM antwort_log').get()
132 ?.anzahl,
133 ).toBe(200);
134 });
135
136 it('hinterlässt genau eine Datei, auch nachdem sie gelesen wurde', () => {
137 /*
138 Der Grund gegen `db.backup()`: Dessen Ergebnis steht im WAL-Modus, und
139 wer es auch nur ansieht, erzeugt daneben `-wal` und `-shm`, die nach dem
140 Schließen liegen bleiben. Eine Sicherung, von der man zwei Dateien
141 vergisst, ist genau der Fehler, den sie verhüten soll.
142 */
143 const db = lernstandAnlegen(join(ordner, 'lernstand.db'));
144 const ziel = join(ordner, 'sicherung.wsklernstand');
145 sicherungSchreiben(db, ziel, Database);
146
147 const gelesen = new Database(ziel, { readonly: true });
148 expect((gelesen.pragma('journal_mode') as { journal_mode: string }[])[0]?.journal_mode).toBe(
149 'delete',
150 );
151 gelesen.close();
152
153 expect(existsSync(`${ziel}-wal`)).toBe(false);
154 expect(existsSync(`${ziel}-shm`)).toBe(false);
155 });
156
157 it('trägt die Kennung dieser Anwendung und die Schemafassung im Kopf', () => {
158 const db = lernstandAnlegen(join(ordner, 'lernstand.db'));
159 const ziel = join(ordner, 'sicherung.wsklernstand');
160 sicherungSchreiben(db, ziel, Database);
161
162 const gelesen = new Database(ziel, { readonly: true });
163 offene.push(gelesen);
164 expect(
165 (gelesen.pragma('application_id') as { application_id: number }[])[0]?.application_id,
166 ).toBe(ANWENDUNGSKENNUNG);
167 expect((gelesen.pragma('user_version') as { user_version: number }[])[0]?.user_version).toBe(
168 SCHEMA_VERSION,
169 );
170 });
171
172 it('lässt keine Teildatei zurück', () => {
173 /* Erst nach `.teil`, dann umbenennen: Ein Abbruch mitten im Schreiben
174 hinterlässt damit nie eine halbe Datei unter dem richtigen Namen. */
175 const db = lernstandAnlegen(join(ordner, 'lernstand.db'));
176 const ziel = join(ordner, 'sicherung.wsklernstand');
177 sicherungSchreiben(db, ziel, Database);
178
179 expect(existsSync(`${ziel}.teil`)).toBe(false);
180 });
181
182 it('nimmt einen Pfad mit Anführungszeichen im Namen', () => {
183 /* Der Zielpfad wird gebunden und nicht in die Anweisung eingesetzt.
184 Eingesetzt zerlegte ein Anführungszeichen die Anweisung. */
185 const db = lernstandAnlegen(join(ordner, 'lernstand.db'));
186 const ziel = join(ordner, "Olafs 'Sicherung'.wsklernstand");
187
188 expect(() => sicherungSchreiben(db, ziel, Database)).not.toThrow();
189 expect(existsSync(ziel)).toBe(true);
190 });
191 });
192
193 describe('Die Prüfkette', () => {
194 it('nimmt eine echte Sicherung an und beziffert sie', () => {
195 const db = lernstandAnlegen(join(ordner, 'lernstand.db'), 42);
196 const ziel = join(ordner, 'sicherung.wsklernstand');
197 sicherungSchreiben(db, ziel, Database);
198
199 const befund = dateiPruefen(ziel, Database);
200
201 expect(befund.art).toBe('brauchbar');
202 if (befund.art !== 'brauchbar') {
203 return;
204 }
205 expect(befund.kennzahlen.profilnamen).toEqual(['Standard']);
206 expect(befund.kennzahlen.antworten).toBe(42);
207 expect(befund.kennzahlen.gemerkt).toBe(1);
208 expect(befund.kennzahlen.schemafassung).toBe(SCHEMA_VERSION);
209 expect(befund.kennzahlen.letzteAntwort).not.toBeNull();
210 });
211
212 it('weist eine leere Datei ab – der gefährlichste Fall überhaupt', () => {
213 /*
214 Eine Datei von null Byte besteht `integrity_check` mit „ok“ und hat null
215 Tabellen. Ohne diese Abweisung durchliefe sie die vollständige
216 Migrationskette und stünde als tadelloser, LEERER Lernstand da. Das
217 Einspielen meldete Erfolg, und die Arbeit von Wochen wäre fort.
218 */
219 const leer = join(ordner, 'leer.wsklernstand');
220 writeFileSync(leer, '');
221
222 const befund = dateiPruefen(leer, Database);
223
224 expect(befund.art).toBe('abgelehnt');
225 expect(befund.art === 'abgelehnt' && befund.grund).toContain('Es wurde nichts verändert.');
226 });
227
228 it('weist eine gültige, aber fremde SQLite-Datenbank ab', () => {
229 /* Auch eine Datenbank mit null Tabellen ist syntaktisch tadellos. Was
230 fehlt, sind die Tabellen dieser Anwendung. */
231 const fremd = join(ordner, 'fremd.db');
232 const db = new Database(fremd);
233 db.exec('CREATE TABLE adressen (id INTEGER PRIMARY KEY, name TEXT)');
234 db.close();
235
236 const befund = dateiPruefen(fremd, Database);
237
238 expect(befund.art).toBe('abgelehnt');
239 expect(befund.art === 'abgelehnt' && befund.grund).toContain('kein Lernstand dieser Anwendung');
240 });
241
242 it('weist ein umbenanntes Bild ab, ohne eine englische Datenbankmeldung', () => {
243 /* Eine Textdatei lässt sich readonly ÖFFNEN – erst die erste Abfrage
244 wirft SQLITE_NOTADB. Deshalb wird der Dateikopf vorher gelesen. */
245 const foto = join(ordner, 'urlaub.wsklernstand');
246 writeFileSync(foto, 'x'.repeat(2048));
247
248 const befund = dateiPruefen(foto, Database);
249
250 expect(befund.art).toBe('abgelehnt');
251 expect(befund.art === 'abgelehnt' && befund.grund).toContain('keine Datenbank');
252 expect(befund.art === 'abgelehnt' && befund.grund).not.toMatch(/SQLITE_|not a database/u);
253 });
254
255 it('weist eine abgeschnittene Datei ab', () => {
256 const db = lernstandAnlegen(join(ordner, 'lernstand.db'), 500);
257 const ziel = join(ordner, 'sicherung.wsklernstand');
258 sicherungSchreiben(db, ziel, Database);
259
260 const halb = join(ordner, 'halb.wsklernstand');
261 const { size } = statSync(ziel);
262 const inhalt = readFileSync(ziel);
263 writeFileSync(halb, inhalt.subarray(0, Math.floor(size * 0.4)));
264
265 const befund = dateiPruefen(halb, Database);
266
267 expect(befund.art).toBe('abgelehnt');
268 });
269
270 it('weist eine Sicherung aus einer neueren Programmfassung ab', () => {
271 /* Vorwärts migrieren kann die Anwendung, rückwärts nicht. Eine neuere
272 Sicherung stillschweigend anzunehmen hiesse, Spalten zu verlieren. */
273 const db = lernstandAnlegen(join(ordner, 'lernstand.db'));
274 const ziel = join(ordner, 'sicherung.wsklernstand');
275 sicherungSchreiben(db, ziel, Database);
276
277 /* Die Fassung wird erst NACH dem Schreiben hochgesetzt: Diese Anwendung
278 kann eine solche Datei gar nicht erzeugen – sie entsteht auf einem
279 Rechner mit neuerem Programm. */
280 const zukunft = join(ordner, 'zukunft.wsklernstand');
281 copyFileSync(ziel, zukunft);
282 const neuer = new Database(zukunft);
283 neuer.prepare('INSERT INTO schema_version (version) VALUES (?)').run(SCHEMA_VERSION + 1);
284 neuer.close();
285
286 const befund = dateiPruefen(zukunft, Database);
287
288 expect(befund.art).toBe('abgelehnt');
289 expect(befund.art === 'abgelehnt' && befund.grund).toContain('neueren Fassung');
290 });
291
292 it('nimmt eine ältere Sicherung an – die Migrationskette zieht sie hoch', () => {
293 /*
294 `docs/stand.md` sagte „bei gleicher Schemafassung". Das ist zu streng:
295 Eine alte Sicherung einzuspielen ist byteweise dieselbe Handlung wie ein
296 alter Lernstand nach einem Programmupdate, und diesen Weg geht jeder
297 Nutzer nach jeder Aktualisierung.
298 */
299 const db = lernstandAnlegen(join(ordner, 'lernstand.db'));
300 db.prepare('DELETE FROM schema_version WHERE version > 3').run();
301 const ziel = join(ordner, 'alt.wsklernstand');
302 sicherungSchreiben(db, ziel, Database);
303
304 const befund = dateiPruefen(ziel, Database);
305
306 expect(befund.art).toBe('brauchbar');
307 expect(befund.art === 'brauchbar' && befund.kennzahlen.schemafassung).toBe(3);
308 });
309
310 it('lässt nach jeder Abweisung die Datei wieder los', () => {
311 /* Bleibt die Verbindung offen, ist die Datei unter Windows gesperrt und
312 liesse sich weder löschen noch umbenennen. */
313 const fremd = join(ordner, 'fremd.db');
314 const db = new Database(fremd);
315 db.exec('CREATE TABLE adressen (id INTEGER PRIMARY KEY)');
316 db.close();
317
318 expect(dateiPruefen(fremd, Database).art).toBe('abgelehnt');
319 expect(() => {
320 rmSync(fremd);
321 }).not.toThrow();
322 });
323 });
324
325 describe('Der Dateiname', () => {
326 it('trägt Datum und Uhrzeit auf die Sekunde genau', () => {
327 /* Auf die Sekunde, damit zwei Sicherungen in derselben Minute einander
328 nicht überschreiben – die selbsttätige Sicherheitskopie vor dem
329 Einspielen fragt niemanden. */
330 const name = sicherungsDateiname(new Date(2026, 7, 22, 14, 32, 7));
331
332 expect(name).toBe('Waffensachkunde-Lernstand-2026-08-22-143207.wsklernstand');
333 });
334
335 it('nennt keinen Profilnamen', () => {
336 /* Gesichert wird die ganze Datenbank mit allen Profilen. Ein Name darin
337 wäre gelogen und verleitete dazu, die Datei später für die Sicherung
338 eines einzelnen Profils zu halten. */
339 expect(sicherungsDateiname(new Date())).not.toMatch(/Standard|Olaf/u);
340 });
341 });
342
343 describe('Aufräumen und Kennzahlen', () => {
344 /*
345 Nachgemessen: Wird eine Datenbankdatei nur LESEND geöffnet, bleiben
346 danach ein 0 Byte grosses `-wal` und ein 32 KiB grosses `-shm` liegen.
347 Im heutigen Ablauf ist das harmlos – der nächste Durchgang liest die
348 richtige Datei. Läge dort aber je ein GEFÜLLTES `-wal`, bekäme SQLite den
349 Inhalt der vorigen Datenbank untergeschoben, bei grünem
350 `integrity_check`; nachgestellt: erwartet 900 Zeilen, gelesen 5000 aus der
351 anderen Datei. Deshalb wird es entfernt, bevor der erste schreibende
352 Zugriff auf eine Arbeitskopie dazukommt.
353 */
354 it('nimmt beim Aufräumen die Nebendateien mit', () => {
355 const pfad = join(ordner, 'stand.db');
356 /* Die schreibende Verbindung muss zu sein, sonst hält Windows die
357 Nebendateien und der Test misst das Betriebssystem statt den Code. */
358 lernstandAnlegen(pfad).close();
359
360 const lesend = new Database(pfad, { readonly: true });
361 offene.push(lesend);
362 lesend.prepare('SELECT COUNT(*) AS n FROM antwort_log').get();
363 lesend.close();
364
365 expect(existsSync(`${pfad}-shm`)).toBe(true);
366
367 aufraeumen(pfad);
368
369 expect(existsSync(pfad)).toBe(false);
370 expect(existsSync(`${pfad}-wal`)).toBe(false);
371 expect(existsSync(`${pfad}-shm`)).toBe(false);
372 });
373
374 it('stört sich nicht daran, wenn nichts da ist', () => {
375 expect(() => {
376 aufraeumen(join(ordner, 'gibt-es-nicht.db'));
377 }).not.toThrow();
378 });
379
380 /*
381 Beim Anlegen einer Sicherung über eine vorhandene darf nur das
382 Schreibprotokoll fallen, nie die Datei selbst: Bis 0.24.1 wurde sie vorher
383 gelöscht, und ein Fehlschlag danach nahm beide Fassungen mit.
384 */
385 it('räumt nur die Nebendateien weg und lässt die Sicherung stehen', () => {
386 const pfad = join(ordner, 'sicherung.wsklernstand');
387 writeFileSync(pfad, 'ALTE SICHERUNG', 'utf8');
388 writeFileSync(`${pfad}-wal`, 'protokoll', 'utf8');
389 writeFileSync(`${pfad}-shm`, 'gemeinsam', 'utf8');
390
391 nebendateienAufraeumen(pfad);
392
393 expect(readFileSync(pfad, 'utf8')).toBe('ALTE SICHERUNG');
394 expect(existsSync(`${pfad}-wal`)).toBe(false);
395 expect(existsSync(`${pfad}-shm`)).toBe(false);
396 });
397
398 /*
399 Der Grund, warum das Vorab-Löschen entbehrlich ist: `sicherungSchreiben`
400 schreibt nach `<ziel>.teil` und schiebt die fertige Datei mit `renameSync`
401 an ihren Platz. Das ersetzt ein vorhandenes Ziel unteilbar.
402 */
403 it('ersetzt eine vorhandene Zieldatei, ohne dass sie vorher gelöscht wird', () => {
404 const db = lernstandAnlegen(join(ordner, 'lernstand.db'), 30);
405 const ziel = join(ordner, 'sicherung.wsklernstand');
406 writeFileSync(ziel, 'ALTE SICHERUNG', 'utf8');
407
408 const bytes = sicherungSchreiben(db, ziel, Database);
409
410 expect(bytes).toBeGreaterThan(0);
411 expect(dateiPruefen(ziel, Database).art).toBe('brauchbar');
412 });
413
414 it('lässt auch dann keinen Rest liegen, wenn der Schreibzugriff scheitert', () => {
415 /*
416 Befund der Prüfrunde zu 0.27.2. Aufgeräumt wurde bisher nur im Zweig, in
417 dem die Gegenprobe ablehnt. Wirft `VACUUM INTO` – voller Datenträger,
418 abgezogener Stick – oder der schreibende Zugriff darauf, verlässt die
419 Ausnahme die Funktion, und `⟨ziel⟩.teil` bleibt liegen. Weggeräumt wird
420 er nie: Der Zielname trägt die Sekunde, der nächste Versuch räumt also
421 einen anderen `.teil` weg, und die drei Aufräumwege
422 (`kopienAufraeumen`, `aufraeumenAlte`, `eigeneAufraeumen`) filtern
423 sämtlich auf `.wsklernstand`.
424
425 Auf einem vollen Datenträger belegt der Rest genau den Platz, der beim
426 nächsten Versuch fehlt.
427 */
428 const db = lernstandAnlegen(join(ordner, 'lernstand.db'), 30);
429 const ziel = join(ordner, 'sicherung.wsklernstand');
430 /* Ein Konstruktor, der wirft – so verhält sich `new Database(pfad)` auf
431 einem vollen Datenträger. */
432 function Wirft(): never {
433 throw new Error('Kein Platz auf dem Datenträger');
434 }
435
436 expect(() => sicherungSchreiben(db, ziel, Wirft as unknown as typeof Database)).toThrow(
437 /Kein Platz/u,
438 );
439
440 expect(existsSync(`${ziel}.teil`)).toBe(false);
441 expect(existsSync(ziel)).toBe(false);
442 });
443
444 it('lässt die vorhandene Zieldatei unangetastet, wenn das Schreiben scheitert', () => {
445 /* Eine Quelle ohne die Pflichttabellen: Die Gegenprobe in
446 `sicherungSchreiben` lehnt das Ergebnis ab und wirft. */
447 const untauglich = new Database(join(ordner, 'untauglich.db'));
448 offene.push(untauglich);
449 untauglich.exec('CREATE TABLE irgendwas (a INTEGER)');
450
451 const ziel = join(ordner, 'sicherung.wsklernstand');
452 writeFileSync(ziel, 'ALTE SICHERUNG', 'utf8');
453
454 expect(() => sicherungSchreiben(untauglich, ziel, Database)).toThrow();
455 expect(readFileSync(ziel, 'utf8')).toBe('ALTE SICHERUNG');
456 expect(existsSync(`${ziel}.teil`)).toBe(false);
457 });
458
459 /*
460 `antwort_log` enthält seit Schemafassung 8 auch Zeilen mit
461 `nur_historie = 1`: Fragen eines abgelaufenen Prüfungsbogens, die nie
462 aufgeschlagen wurden. Sie gehören in die Historie, aber nicht in eine
463 Zahl, über die jemand einen nicht rücknehmbaren Schritt freigibt.
464 */
465 it('zählt nie aufgeschlagene Prüfungsfragen nicht als Antworten', () => {
466 const pfad = join(ordner, 'mit-historie.db');
467 const db = lernstandAnlegen(pfad, 5);
468 db.exec('ALTER TABLE antwort_log ADD COLUMN nur_historie INTEGER NOT NULL DEFAULT 0');
469 db.prepare('INSERT INTO antwort_log (profil_id, zeitpunkt, nur_historie) VALUES (1, ?, 1)').run(
470 '2026-08-20T09:00:00.000Z',
471 );
472 db.prepare('INSERT INTO antwort_log (profil_id, zeitpunkt, nur_historie) VALUES (1, ?, 1)').run(
473 '2026-08-21T09:00:00.000Z',
474 );
475
476 const kennzahlen = kennzahlenLesen(db, SCHEMA_VERSION, new Set(['profil', 'antwort_log']));
477
478 expect(db.prepare('SELECT COUNT(*) AS n FROM antwort_log').get()).toEqual({ n: 7 });
479 expect(kennzahlen.antworten).toBe(5);
480
481 /*
482 Befund der Prüfrunde zu 0.27.2: Das Datum daneben zählte dieselben
483 Zeilen mit, die die Zahl darüber ausschließt. Ein frisches Profil, das
484 nur eine abgelaufene Simulation hinter sich hat, zeigte in der
485 Gegenüberstellung „0 Antworten … zuletzt gelernt am ⟨Prüfungstag⟩“ –
486 zwei Zahlen, die einander widersprechen, unmittelbar vor der
487 Entscheidung, den Lernstand zu ersetzen.
488 */
489 expect(kennzahlen.letzteAntwort).toBe('2026-08-14T09:00:00.000Z');
490 expect(kennzahlen.jeProfil[0]?.letzteAntwort).toBe('2026-08-14T09:00:00.000Z');
491 });
492
493 it('zählt bei älteren Sicherungen ohne die Spalte weiterhin alles', () => {
494 /* Rückwirkend liesse sich nicht ermitteln, welche Zeile nie gestellt
495 wurde. Zu raten wäre schlechter, als es stehen zu lassen. */
496 const pfad = join(ordner, 'alt.db');
497 const db = lernstandAnlegen(pfad, 4);
498
499 const kennzahlen = kennzahlenLesen(db, 7, new Set(['profil', 'antwort_log']));
500
501 expect(kennzahlen.antworten).toBe(4);
502 });
503 });
504
505 describe('Die Einstellungen reisen mit der Sicherung', () => {
506 /*
507 Bis Fassung 0.26.7 enthielt eine Sicherung Profile, Antworten, Merklisten,
508 Termine und Verlauf – aber keine Einstellung. Wer 400 Prozent
509 Anzeigegröße braucht, musste auf dem zweiten Rechner ohne sie anfangen,
510 um sie einzustellen. Genau das, was Barrierefreiheit herstellt, blieb
511 zurück.
512 */
513
514 it('schreibt die übergebenen Einstellungen in die Datei', () => {
515 const db = lernstandAnlegen(join(ordner, 'mit-einstellungen.db'), 3);
516 const ziel = join(ordner, 'mit-einstellungen.wsklernstand');
517
518 sicherungSchreiben(db, ziel, Database, { anzeigegroesse: 400, thema: 'dunkel' });
519
520 const gelesen = new Database(ziel, { readonly: true });
521 offene.push(gelesen);
522 expect(einstellungenAusSicherung(gelesen)).toEqual({ anzeigegroesse: 400, thema: 'dunkel' });
523 });
524
525 it('schreibt ohne Übergabe keine Tabelle – alte Sicherungen bleiben, wie sie waren', () => {
526 const db = lernstandAnlegen(join(ordner, 'ohne-einstellungen.db'), 3);
527 const ziel = join(ordner, 'ohne-einstellungen.wsklernstand');
528
529 sicherungSchreiben(db, ziel, Database);
530
531 const gelesen = new Database(ziel, { readonly: true });
532 offene.push(gelesen);
533 expect(
534 gelesen
535 .prepare<[], { anzahl: number }>(
536 "SELECT COUNT(*) AS anzahl FROM sqlite_master WHERE name = 'einstellungen_kopie'",
537 )
538 .get()?.anzahl,
539 ).toBe(0);
540 expect(einstellungenAusSicherung(gelesen)).toBeNull();
541 });
542
543 it('liest eine Sicherung ohne die Tabelle als „keine Einstellungen“, nicht als Fehler', () => {
544 /* Der Regelfall der Vergangenheit: jede Sicherung vor 0.27.0. */
545 const db = lernstandAnlegen(join(ordner, 'alt.db'), 1);
546 offene.push(db);
547
548 expect(() => einstellungenAusSicherung(db)).not.toThrow();
549 expect(einstellungenAusSicherung(db)).toBeNull();
550 });
551
552 it('liest beschädigten Inhalt als „keine“ – das Einspielen darf daran nicht scheitern', () => {
553 const db = lernstandAnlegen(join(ordner, 'kaputt.db'), 1);
554 offene.push(db);
555 db.exec('CREATE TABLE einstellungen_kopie (inhalt TEXT NOT NULL)');
556 db.prepare<[string]>('INSERT INTO einstellungen_kopie (inhalt) VALUES (?)').run('{kein json');
557
558 expect(einstellungenAusSicherung(db)).toBeNull();
559 });
560
561 it.each(['profilId', 'fenster', 'letzteSicherung', 'zuschnittGefragt'])(
562 'lässt %s auch dann liegen, wenn es von Hand in die Datei geschrieben wurde',
563 (verboten) => {
564 /*
565 Die Auswahl gilt auf BEIDEN Seiten. Ohne die Filterung beim Lesen
566 brächte eine von Hand veränderte Sicherungsdatei eine Profilnummer
567 des fremden Rechners mit – und die zeigt hier auf ein anderes Profil
568 oder auf keines. Begründung je Feld bei EINSTELLUNGEN_REISEN.
569 */
570 const db = lernstandAnlegen(join(ordner, `verboten-${verboten}.db`), 1);
571 offene.push(db);
572 db.exec('CREATE TABLE einstellungen_kopie (inhalt TEXT NOT NULL)');
573 db.prepare<[string]>('INSERT INTO einstellungen_kopie (inhalt) VALUES (?)').run(
574 JSON.stringify({ [verboten]: 99, sprechtempo: 1.5 }),
575 );
576
577 expect(einstellungenAusSicherung(db)).toEqual({ sprechtempo: 1.5 });
578 },
579 );
580
581 it('reicht unsinnige Werte durch, statt sie hier zu prüfen', () => {
582 /*
583 Festgehalten, damit es niemand für eine Prüfstelle hält: Gelesen wird
584 die Form, nicht der Wert. Gereinigt wird auf dem Weg dahinter, in
585 `einstellungenBereinigen` – genau einmal und an einer Stelle. Stünde
586 hier eine zweite Prüfung, gäbe es zwei Wahrheiten darüber, was gilt.
587 */
588 const db = lernstandAnlegen(join(ordner, 'unsinn.db'), 1);
589 offene.push(db);
590 db.exec('CREATE TABLE einstellungen_kopie (inhalt TEXT NOT NULL)');
591 db.prepare<[string]>('INSERT INTO einstellungen_kopie (inhalt) VALUES (?)').run(
592 JSON.stringify({ anzeigegroesse: 'riesig', thema: 42 }),
593 );
594
595 expect(einstellungenAusSicherung(db)).toEqual({ anzeigegroesse: 'riesig', thema: 42 });
596 });
597
598 it('nimmt aus dem laufenden Betrieb nur die mitreisenden Felder', () => {
599 const stueck = reisendeEinstellungen({
600 ...EINSTELLUNGEN_STANDARD,
601 anzeigegroesse: 250,
602 profilId: 7,
603 letzteSicherung: '2026-09-01T00:00:00.000Z',
604 fenster: { breite: 1, hoehe: 2 },
605 zuschnittGefragt: true,
606 });
607
608 expect(stueck).not.toHaveProperty('profilId');
609 expect(stueck).not.toHaveProperty('letzteSicherung');
610 expect(stueck).not.toHaveProperty('fenster');
611 expect(stueck).not.toHaveProperty('zuschnittGefragt');
612 expect(stueck.anzeigegroesse).toBe(250);
613 });
614
615 it('ersetzt beim erneuten Sichern die mitgereisten Werte, statt sie zu häufen', () => {
616 /*
617 `VACUUM INTO` kopiert die laufende Datenbank. Wer eine Sicherung
618 eingespielt hat, trägt die Tabelle darin – ohne Löschen davor stünden
619 nach dem zweiten Sichern zwei Zeilen da, und gelesen würde die falsche.
620 */
621 const quelle = join(ordner, 'zweimal.db');
622 const db = lernstandAnlegen(quelle, 1);
623 db.exec('CREATE TABLE einstellungen_kopie (inhalt TEXT NOT NULL)');
624 db.prepare<[string]>('INSERT INTO einstellungen_kopie (inhalt) VALUES (?)').run(
625 JSON.stringify({ anzeigegroesse: 100 }),
626 );
627
628 const ziel = join(ordner, 'zweimal.wsklernstand');
629 sicherungSchreiben(db, ziel, Database, { anzeigegroesse: 400 });
630
631 const gelesen = new Database(ziel, { readonly: true });
632 offene.push(gelesen);
633 expect(
634 gelesen
635 .prepare<[], { anzahl: number }>('SELECT COUNT(*) AS anzahl FROM einstellungen_kopie')
636 .get()?.anzahl,
637 ).toBe(1);
638 expect(einstellungenAusSicherung(gelesen)).toEqual({ anzeigegroesse: 400 });
639 });
640 });