// @vitest-environment node /** * Ein Profil aus einer Sicherung dazunehmen. * * Alles hier läuft gegen echte SQLite-Dateien, nicht gegen Attrappen: Die * Fallen dieses Merkmals sind Fallen von SQLite selbst. */ import { mkdtempSync, readdirSync, rmSync, writeFileSync } from 'node:fs'; import { tmpdir } from 'node:os'; import { join } from 'node:path'; import Database from 'better-sqlite3'; import { afterEach, beforeEach, describe, expect, it } from 'vitest'; import { LERNSTAND_SCHEMA, MIGRATIONEN, SCHEMA_VERSION } from '../src/main/schema'; import { freierProfilname, profilUebernehmen, VOR_DEM_UEBERNEHMEN, } from '../src/main/profil-uebernehmen'; let ordner: string; let offene: Database.Database[]; /** Eine Datenbank mit dem echten Grundschema. */ function datenbank(pfad: string): Database.Database { const db = new Database(pfad); offene.push(db); db.pragma('journal_mode = WAL'); db.exec(LERNSTAND_SCHEMA); /* Die später ergänzten Spalten kommen über dieselben Migrationsschritte wie in der Anwendung – eine handgeschriebene Kopie liefe beim nächsten Schritt auseinander. */ for (let version = 2; version <= SCHEMA_VERSION; version += 1) { const schritt = MIGRATIONEN[version]; if (typeof schritt === 'string') { db.exec(schritt); } else if (typeof schritt === 'function') { schritt(db); } } db.prepare('INSERT OR IGNORE INTO schema_version (version, angewendet_am) VALUES (?, ?)').run( SCHEMA_VERSION, '2026-08-23T09:00:00.000Z', ); return db; } function profilFuellen( db: Database.Database, name: string, antworten: number, termin: string | null = null, ): number { const id = Number( db .prepare('INSERT INTO profil (name, pruefungstermin, erstellt_am) VALUES (?, ?, ?)') .run(name, termin, '2026-01-01T00:00:00.000Z').lastInsertRowid, ); const log = db.prepare( `INSERT INTO antwort_log (profil_id, frage_id, zeitpunkt, richtig, bewertung, dauer_ms) VALUES (?, ?, ?, 1, 'gut', 1000)`, ); const stand = db.prepare( 'INSERT INTO frage_stand (profil_id, frage_id, versuche, richtige, gemerkt) VALUES (?, ?, 1, 1, ?)', ); db.transaction(() => { for (let i = 0; i < antworten; i += 1) { const frage = `I.1-${String(i % 20).padStart(2, '0')}`; log.run(id, frage, `2026-08-${String(10 + (i % 10)).padStart(2, '0')}T09:00:00.000Z`); } for (let i = 0; i < 20; i += 1) { stand.run(id, `I.1-${String(i).padStart(2, '0')}`, i < 3 ? 1 : 0); } })(); return id; } function uebernehmen(ziel: Database.Database, quellPfad: string, quellId: number) { return profilUebernehmen({ ziel, quelle: quellPfad, quellProfilId: quellId, ordner, jetzt: new Date('2026-08-23T10:00:00.000Z'), Datenbank: Database, }); } beforeEach(() => { ordner = mkdtempSync(join(tmpdir(), 'wsk-uebernehmen-')); offene = []; }); afterEach(() => { for (const db of offene) { try { db.close(); } catch { /* schon zu */ } } rmSync(ordner, { recursive: true, force: true }); }); describe('freierProfilname', () => { it('lässt einen freien Namen stehen', () => { expect(freierProfilname(new Set(['Anna']), 'Olaf')).toBe('Olaf'); }); it('hängt bei Namensgleichheit einen Zusatz an – der vorhandene bleibt', () => { expect(freierProfilname(new Set(['Olaf']), 'Olaf')).toBe('Olaf (übernommen)'); expect(freierProfilname(new Set(['Olaf', 'Olaf (übernommen)']), 'Olaf')).toBe( 'Olaf (übernommen 2)', ); }); }); describe('profilUebernehmen', () => { it('nimmt Profil und Zeilen mit und lässt das Vorhandene unberührt', () => { const zielPfad = join(ordner, 'lernstand.db'); const ziel = datenbank(zielPfad); profilFuellen(ziel, 'Standard', 30); const quellPfad = join(ordner, 'quelle.db'); const quelle = datenbank(quellPfad); const quellId = profilFuellen(quelle, 'Notebook', 45, '2026-12-01'); quelle.close(); const ergebnis = uebernehmen(ziel, quellPfad, quellId); expect(ergebnis.art).toBe('uebernommen'); if (ergebnis.art !== 'uebernommen') return; expect(ergebnis.name).toBe('Notebook'); expect(ergebnis.antworten).toBe(45); expect(ergebnis.bearbeiteteFragen).toBe(20); /* Das vorhandene Profil ist zeichengenau, wie es war. */ expect(ziel.prepare('SELECT name FROM profil ORDER BY id').all()).toEqual([ { name: 'Standard' }, { name: 'Notebook' }, ]); expect(ziel.prepare('SELECT COUNT(*) AS n FROM antwort_log WHERE profil_id = 1').get()).toEqual( { n: 30 }, ); expect( ziel .prepare('SELECT COUNT(*) AS n FROM antwort_log WHERE profil_id = ?') .get(ergebnis.profilId), ).toEqual({ n: 45 }); /* Der Prüfungstermin kommt mit – er gehört zum Profil. */ expect( ziel.prepare('SELECT pruefungstermin AS t FROM profil WHERE id = ?').get(ergebnis.profilId), ).toEqual({ t: '2026-12-01' }); }); /* Die Wache, die gefehlt hat. `kapitel_ausschluss` steht als Spalte am Profil, nicht in einer Kindtabelle – und wurde deshalb von der Schleife über `KINDTABELLEN` nicht erfasst. Das `SELECT` daneben las bis 0.26.4 nur `name` und `pruefungstermin`; die Spalte fiel auf ihren Vorgabewert `'[]'` zurück. Die Folge trifft genau den Weg, den das Handbuch für zwei Geräte empfiehlt: Wer auf dem Rechner ein Kapitel abgewählt hat – etwa „Not- und Seenotsignalmittel“, das nicht jede Prüfung verlangt – bekommt es auf dem zweiten Rechner stillschweigend zurück. Die Prüfungsreife fällt dabei um zwei Ampelstufen, nachgerechnet in `shared/verlust.ts`: derselbe Bestand von 85,0 auf 71,9 Prozent. Nichts sagt es, nichts fragt nach. */ it('nimmt die Kapitelabwahl mit – sie gehört zum Profil wie der Termin', () => { const ziel = datenbank(join(ordner, 'lernstand.db')); profilFuellen(ziel, 'Standard', 5); const quellPfad = join(ordner, 'quelle.db'); const quelle = datenbank(quellPfad); const quellId = profilFuellen(quelle, 'Notebook', 12); quelle.prepare('UPDATE profil SET kapitel_ausschluss = ? WHERE id = ?').run('["IV"]', quellId); quelle.close(); const ergebnis = uebernehmen(ziel, quellPfad, quellId); expect(ergebnis.art).toBe('uebernommen'); if (ergebnis.art !== 'uebernommen') return; expect( ziel .prepare('SELECT kapitel_ausschluss AS k FROM profil WHERE id = ?') .get(ergebnis.profilId), ).toEqual({ k: '["IV"]' }); /* Und das vorhandene Profil behält seine eigene Abwahl – hier keine. */ expect(ziel.prepare('SELECT kapitel_ausschluss AS k FROM profil WHERE id = 1').get()).toEqual({ k: '[]', }); }); it('benennt ein gleichnamiges Profil um, statt es anzutasten', () => { const ziel = datenbank(join(ordner, 'lernstand.db')); profilFuellen(ziel, 'Olaf', 10); const quellPfad = join(ordner, 'quelle.db'); const quelle = datenbank(quellPfad); const quellId = profilFuellen(quelle, 'Olaf', 7); quelle.close(); const ergebnis = uebernehmen(ziel, quellPfad, quellId); expect(ergebnis.art === 'uebernommen' && ergebnis.name).toBe('Olaf (übernommen)'); expect(ziel.prepare('SELECT COUNT(*) AS n FROM antwort_log WHERE profil_id = 1').get()).toEqual( { n: 10 }, ); }); /* Der Einwand, an dem der erste Entwurf durchgefallen ist: Benutzten beide Wege denselben Dateivorsatz, löschten drei harmlose Übernahmen den einzigen Rückweg eines vorangegangenen Ersetzens – nachgestellt mit 900 Antworten, die danach unwiederbringlich fort waren. */ it('räumt nur die eigenen Sicherheitskopien ab, nie die des Ersetzens', () => { const ziel = datenbank(join(ordner, 'lernstand.db')); profilFuellen(ziel, 'Standard', 5); const quellPfad = join(ordner, 'quelle.db'); const quelle = datenbank(quellPfad); const quellId = profilFuellen(quelle, 'Gast', 3); quelle.close(); /* Eine Kopie des ANDEREN Weges, wie sie das Ersetzen anlegt. */ const fremde = join(ordner, 'Lernstand-vor-dem-Einspielen-2026-08-22-120000.wsklernstand'); writeFileSync(fremde, 'egal'); for (let i = 0; i < 4; i += 1) { const ergebnis = uebernehmen(ziel, quellPfad, quellId); expect(ergebnis.art).toBe('uebernommen'); } const dateien = readdirSync(ordner); expect(dateien).toContain('Lernstand-vor-dem-Einspielen-2026-08-22-120000.wsklernstand'); expect(dateien.filter((n) => n.startsWith(VOR_DEM_UEBERNEHMEN)).length).toBeLessThanOrEqual(3); }); it('lässt bei einer unbekannten Profilnummer alles unverändert', () => { const ziel = datenbank(join(ordner, 'lernstand.db')); profilFuellen(ziel, 'Standard', 12); const quellPfad = join(ordner, 'quelle.db'); const quelle = datenbank(quellPfad); profilFuellen(quelle, 'Gast', 3); quelle.close(); const ergebnis = uebernehmen(ziel, quellPfad, 999); expect(ergebnis.art).toBe('gescheitert'); expect(ziel.prepare('SELECT COUNT(*) AS n FROM profil').get()).toEqual({ n: 1 }); expect(ziel.prepare('SELECT COUNT(*) AS n FROM antwort_log').get()).toEqual({ n: 12 }); }); it('meldet eine fehlende Datei, ohne etwas anzufassen', () => { const ziel = datenbank(join(ordner, 'lernstand.db')); profilFuellen(ziel, 'Standard', 4); const ergebnis = uebernehmen(ziel, join(ordner, 'gibt-es-nicht.db'), 1); expect(ergebnis.art).toBe('gescheitert'); expect(ziel.prepare('SELECT COUNT(*) AS n FROM profil').get()).toEqual({ n: 1 }); expect(readdirSync(ordner).filter((n) => n.startsWith(VOR_DEM_UEBERNEHMEN))).toHaveLength(0); }); /* Eine ältere Sicherung kennt die später ergänzten Spalten nicht. Sie zu verlangen bräche die Übernahme; sie zu erfinden wäre schlechter, als die Vorgabewerte des Schemas greifen zu lassen. */ it('kommt mit einer Quelle ohne die später ergänzten Spalten zurecht', () => { const ziel = datenbank(join(ordner, 'lernstand.db')); profilFuellen(ziel, 'Standard', 5); const quellPfad = join(ordner, 'alt.db'); const quelle = new Database(quellPfad); offene.push(quelle); quelle.exec(` CREATE TABLE profil ( id INTEGER PRIMARY KEY AUTOINCREMENT, name TEXT NOT NULL UNIQUE, pruefungstermin TEXT, erstellt_am TEXT NOT NULL ); CREATE TABLE antwort_log ( id INTEGER PRIMARY KEY AUTOINCREMENT, profil_id INTEGER NOT NULL, frage_id TEXT NOT NULL, zeitpunkt TEXT NOT NULL, richtig INTEGER NOT NULL, bewertung TEXT NOT NULL, dauer_ms INTEGER NOT NULL ); `); quelle .prepare('INSERT INTO profil (name, pruefungstermin, erstellt_am) VALUES (?, NULL, ?)') .run('Alt', '2025-01-01T00:00:00.000Z'); quelle .prepare( `INSERT INTO antwort_log (profil_id, frage_id, zeitpunkt, richtig, bewertung, dauer_ms) VALUES (1, 'I.1-01', '2025-06-01T09:00:00.000Z', 1, 'gut', 500)`, ) .run(); quelle.close(); const ergebnis = uebernehmen(ziel, quellPfad, 1); expect(ergebnis.art).toBe('uebernommen'); if (ergebnis.art !== 'uebernommen') return; expect(ergebnis.antworten).toBe(1); /* Die fehlenden Spalten stehen auf ihrem Vorgabewert, nicht auf Geratenem. */ expect( ziel .prepare('SELECT nur_historie AS h, auswahl AS a FROM antwort_log WHERE profil_id = ?') .get(ergebnis.profilId), ).toEqual({ h: 0, a: '[]' }); }); it('legt vor jeder Übernahme eine Sicherheitskopie an', () => { const ziel = datenbank(join(ordner, 'lernstand.db')); profilFuellen(ziel, 'Standard', 8); const quellPfad = join(ordner, 'quelle.db'); const quelle = datenbank(quellPfad); const quellId = profilFuellen(quelle, 'Gast', 2); quelle.close(); const ergebnis = uebernehmen(ziel, quellPfad, quellId); expect(ergebnis.art === 'uebernommen' && ergebnis.sicherheitskopie).toMatch( /^Lernstand-vor-dem-Uebernehmen-/u, ); const kopie = readdirSync(ordner).find((n) => n.startsWith(VOR_DEM_UEBERNEHMEN)); expect(kopie).toBeDefined(); /* Die Kopie enthält den Stand VOR der Übernahme. */ const alt = new Database(join(ordner, kopie ?? ''), { readonly: true }); offene.push(alt); expect(alt.prepare('SELECT COUNT(*) AS n FROM profil').get()).toEqual({ n: 1 }); }); });