waffensachkunde

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

/ app tests profil-uebernehmen.test.ts

12,2 KB Rohdatei
app/tests/profil-uebernehmen.test.ts — 347 Zeilen
1 // @vitest-environment node
2 /**
3 * Ein Profil aus einer Sicherung dazunehmen.
4 *
5 * Alles hier läuft gegen echte SQLite-Dateien, nicht gegen Attrappen: Die
6 * Fallen dieses Merkmals sind Fallen von SQLite selbst.
7 */
8
9 import { mkdtempSync, readdirSync, rmSync, writeFileSync } from 'node:fs';
10 import { tmpdir } from 'node:os';
11 import { join } from 'node:path';
12
13 import Database from 'better-sqlite3';
14 import { afterEach, beforeEach, describe, expect, it } from 'vitest';
15
16 import { LERNSTAND_SCHEMA, MIGRATIONEN, SCHEMA_VERSION } from '../src/main/schema';
17 import {
18 freierProfilname,
19 profilUebernehmen,
20 VOR_DEM_UEBERNEHMEN,
21 } from '../src/main/profil-uebernehmen';
22
23 let ordner: string;
24 let offene: Database.Database[];
25
26 /** Eine Datenbank mit dem echten Grundschema. */
27 function datenbank(pfad: string): Database.Database {
28 const db = new Database(pfad);
29 offene.push(db);
30 db.pragma('journal_mode = WAL');
31 db.exec(LERNSTAND_SCHEMA);
32 /* Die später ergänzten Spalten kommen über dieselben Migrationsschritte
33 wie in der Anwendung – eine handgeschriebene Kopie liefe beim nächsten
34 Schritt auseinander. */
35 for (let version = 2; version <= SCHEMA_VERSION; version += 1) {
36 const schritt = MIGRATIONEN[version];
37 if (typeof schritt === 'string') {
38 db.exec(schritt);
39 } else if (typeof schritt === 'function') {
40 schritt(db);
41 }
42 }
43 db.prepare('INSERT OR IGNORE INTO schema_version (version, angewendet_am) VALUES (?, ?)').run(
44 SCHEMA_VERSION,
45 '2026-08-23T09:00:00.000Z',
46 );
47 return db;
48 }
49
50 function profilFuellen(
51 db: Database.Database,
52 name: string,
53 antworten: number,
54 termin: string | null = null,
55 ): number {
56 const id = Number(
57 db
58 .prepare('INSERT INTO profil (name, pruefungstermin, erstellt_am) VALUES (?, ?, ?)')
59 .run(name, termin, '2026-01-01T00:00:00.000Z').lastInsertRowid,
60 );
61 const log = db.prepare(
62 `INSERT INTO antwort_log (profil_id, frage_id, zeitpunkt, richtig, bewertung, dauer_ms)
63 VALUES (?, ?, ?, 1, 'gut', 1000)`,
64 );
65 const stand = db.prepare(
66 'INSERT INTO frage_stand (profil_id, frage_id, versuche, richtige, gemerkt) VALUES (?, ?, 1, 1, ?)',
67 );
68 db.transaction(() => {
69 for (let i = 0; i < antworten; i += 1) {
70 const frage = `I.1-${String(i % 20).padStart(2, '0')}`;
71 log.run(id, frage, `2026-08-${String(10 + (i % 10)).padStart(2, '0')}T09:00:00.000Z`);
72 }
73 for (let i = 0; i < 20; i += 1) {
74 stand.run(id, `I.1-${String(i).padStart(2, '0')}`, i < 3 ? 1 : 0);
75 }
76 })();
77 return id;
78 }
79
80 function uebernehmen(ziel: Database.Database, quellPfad: string, quellId: number) {
81 return profilUebernehmen({
82 ziel,
83 quelle: quellPfad,
84 quellProfilId: quellId,
85 ordner,
86 jetzt: new Date('2026-08-23T10:00:00.000Z'),
87 Datenbank: Database,
88 });
89 }
90
91 beforeEach(() => {
92 ordner = mkdtempSync(join(tmpdir(), 'wsk-uebernehmen-'));
93 offene = [];
94 });
95
96 afterEach(() => {
97 for (const db of offene) {
98 try {
99 db.close();
100 } catch {
101 /* schon zu */
102 }
103 }
104 rmSync(ordner, { recursive: true, force: true });
105 });
106
107 describe('freierProfilname', () => {
108 it('lässt einen freien Namen stehen', () => {
109 expect(freierProfilname(new Set(['Anna']), 'Olaf')).toBe('Olaf');
110 });
111
112 it('hängt bei Namensgleichheit einen Zusatz an – der vorhandene bleibt', () => {
113 expect(freierProfilname(new Set(['Olaf']), 'Olaf')).toBe('Olaf (übernommen)');
114 expect(freierProfilname(new Set(['Olaf', 'Olaf (übernommen)']), 'Olaf')).toBe(
115 'Olaf (übernommen 2)',
116 );
117 });
118 });
119
120 describe('profilUebernehmen', () => {
121 it('nimmt Profil und Zeilen mit und lässt das Vorhandene unberührt', () => {
122 const zielPfad = join(ordner, 'lernstand.db');
123 const ziel = datenbank(zielPfad);
124 profilFuellen(ziel, 'Standard', 30);
125
126 const quellPfad = join(ordner, 'quelle.db');
127 const quelle = datenbank(quellPfad);
128 const quellId = profilFuellen(quelle, 'Notebook', 45, '2026-12-01');
129 quelle.close();
130
131 const ergebnis = uebernehmen(ziel, quellPfad, quellId);
132
133 expect(ergebnis.art).toBe('uebernommen');
134 if (ergebnis.art !== 'uebernommen') return;
135 expect(ergebnis.name).toBe('Notebook');
136 expect(ergebnis.antworten).toBe(45);
137 expect(ergebnis.bearbeiteteFragen).toBe(20);
138
139 /* Das vorhandene Profil ist zeichengenau, wie es war. */
140 expect(ziel.prepare('SELECT name FROM profil ORDER BY id').all()).toEqual([
141 { name: 'Standard' },
142 { name: 'Notebook' },
143 ]);
144 expect(ziel.prepare('SELECT COUNT(*) AS n FROM antwort_log WHERE profil_id = 1').get()).toEqual(
145 { n: 30 },
146 );
147 expect(
148 ziel
149 .prepare('SELECT COUNT(*) AS n FROM antwort_log WHERE profil_id = ?')
150 .get(ergebnis.profilId),
151 ).toEqual({ n: 45 });
152 /* Der Prüfungstermin kommt mit – er gehört zum Profil. */
153 expect(
154 ziel.prepare('SELECT pruefungstermin AS t FROM profil WHERE id = ?').get(ergebnis.profilId),
155 ).toEqual({ t: '2026-12-01' });
156 });
157
158 /*
159 Die Wache, die gefehlt hat.
160
161 `kapitel_ausschluss` steht als Spalte am Profil, nicht in einer
162 Kindtabelle – und wurde deshalb von der Schleife über `KINDTABELLEN`
163 nicht erfasst. Das `SELECT` daneben las bis 0.26.4 nur `name` und
164 `pruefungstermin`; die Spalte fiel auf ihren Vorgabewert `'[]'` zurück.
165
166 Die Folge trifft genau den Weg, den das Handbuch für zwei Geräte
167 empfiehlt: Wer auf dem Rechner ein Kapitel abgewählt hat – etwa „Not- und
168 Seenotsignalmittel“, das nicht jede Prüfung verlangt – bekommt es auf dem
169 zweiten Rechner stillschweigend zurück. Die Prüfungsreife fällt dabei um
170 zwei Ampelstufen, nachgerechnet in `shared/verlust.ts`: derselbe Bestand
171 von 85,0 auf 71,9 Prozent. Nichts sagt es, nichts fragt nach.
172 */
173 it('nimmt die Kapitelabwahl mit – sie gehört zum Profil wie der Termin', () => {
174 const ziel = datenbank(join(ordner, 'lernstand.db'));
175 profilFuellen(ziel, 'Standard', 5);
176
177 const quellPfad = join(ordner, 'quelle.db');
178 const quelle = datenbank(quellPfad);
179 const quellId = profilFuellen(quelle, 'Notebook', 12);
180 quelle.prepare('UPDATE profil SET kapitel_ausschluss = ? WHERE id = ?').run('["IV"]', quellId);
181 quelle.close();
182
183 const ergebnis = uebernehmen(ziel, quellPfad, quellId);
184
185 expect(ergebnis.art).toBe('uebernommen');
186 if (ergebnis.art !== 'uebernommen') return;
187 expect(
188 ziel
189 .prepare('SELECT kapitel_ausschluss AS k FROM profil WHERE id = ?')
190 .get(ergebnis.profilId),
191 ).toEqual({ k: '["IV"]' });
192
193 /* Und das vorhandene Profil behält seine eigene Abwahl – hier keine. */
194 expect(ziel.prepare('SELECT kapitel_ausschluss AS k FROM profil WHERE id = 1').get()).toEqual({
195 k: '[]',
196 });
197 });
198
199 it('benennt ein gleichnamiges Profil um, statt es anzutasten', () => {
200 const ziel = datenbank(join(ordner, 'lernstand.db'));
201 profilFuellen(ziel, 'Olaf', 10);
202
203 const quellPfad = join(ordner, 'quelle.db');
204 const quelle = datenbank(quellPfad);
205 const quellId = profilFuellen(quelle, 'Olaf', 7);
206 quelle.close();
207
208 const ergebnis = uebernehmen(ziel, quellPfad, quellId);
209
210 expect(ergebnis.art === 'uebernommen' && ergebnis.name).toBe('Olaf (übernommen)');
211 expect(ziel.prepare('SELECT COUNT(*) AS n FROM antwort_log WHERE profil_id = 1').get()).toEqual(
212 { n: 10 },
213 );
214 });
215
216 /*
217 Der Einwand, an dem der erste Entwurf durchgefallen ist: Benutzten beide
218 Wege denselben Dateivorsatz, löschten drei harmlose Übernahmen den
219 einzigen Rückweg eines vorangegangenen Ersetzens – nachgestellt mit 900
220 Antworten, die danach unwiederbringlich fort waren.
221 */
222 it('räumt nur die eigenen Sicherheitskopien ab, nie die des Ersetzens', () => {
223 const ziel = datenbank(join(ordner, 'lernstand.db'));
224 profilFuellen(ziel, 'Standard', 5);
225
226 const quellPfad = join(ordner, 'quelle.db');
227 const quelle = datenbank(quellPfad);
228 const quellId = profilFuellen(quelle, 'Gast', 3);
229 quelle.close();
230
231 /* Eine Kopie des ANDEREN Weges, wie sie das Ersetzen anlegt. */
232 const fremde = join(ordner, 'Lernstand-vor-dem-Einspielen-2026-08-22-120000.wsklernstand');
233 writeFileSync(fremde, 'egal');
234
235 for (let i = 0; i < 4; i += 1) {
236 const ergebnis = uebernehmen(ziel, quellPfad, quellId);
237 expect(ergebnis.art).toBe('uebernommen');
238 }
239
240 const dateien = readdirSync(ordner);
241 expect(dateien).toContain('Lernstand-vor-dem-Einspielen-2026-08-22-120000.wsklernstand');
242 expect(dateien.filter((n) => n.startsWith(VOR_DEM_UEBERNEHMEN)).length).toBeLessThanOrEqual(3);
243 });
244
245 it('lässt bei einer unbekannten Profilnummer alles unverändert', () => {
246 const ziel = datenbank(join(ordner, 'lernstand.db'));
247 profilFuellen(ziel, 'Standard', 12);
248
249 const quellPfad = join(ordner, 'quelle.db');
250 const quelle = datenbank(quellPfad);
251 profilFuellen(quelle, 'Gast', 3);
252 quelle.close();
253
254 const ergebnis = uebernehmen(ziel, quellPfad, 999);
255
256 expect(ergebnis.art).toBe('gescheitert');
257 expect(ziel.prepare('SELECT COUNT(*) AS n FROM profil').get()).toEqual({ n: 1 });
258 expect(ziel.prepare('SELECT COUNT(*) AS n FROM antwort_log').get()).toEqual({ n: 12 });
259 });
260
261 it('meldet eine fehlende Datei, ohne etwas anzufassen', () => {
262 const ziel = datenbank(join(ordner, 'lernstand.db'));
263 profilFuellen(ziel, 'Standard', 4);
264
265 const ergebnis = uebernehmen(ziel, join(ordner, 'gibt-es-nicht.db'), 1);
266
267 expect(ergebnis.art).toBe('gescheitert');
268 expect(ziel.prepare('SELECT COUNT(*) AS n FROM profil').get()).toEqual({ n: 1 });
269 expect(readdirSync(ordner).filter((n) => n.startsWith(VOR_DEM_UEBERNEHMEN))).toHaveLength(0);
270 });
271
272 /*
273 Eine ältere Sicherung kennt die später ergänzten Spalten nicht. Sie zu
274 verlangen bräche die Übernahme; sie zu erfinden wäre schlechter, als die
275 Vorgabewerte des Schemas greifen zu lassen.
276 */
277 it('kommt mit einer Quelle ohne die später ergänzten Spalten zurecht', () => {
278 const ziel = datenbank(join(ordner, 'lernstand.db'));
279 profilFuellen(ziel, 'Standard', 5);
280
281 const quellPfad = join(ordner, 'alt.db');
282 const quelle = new Database(quellPfad);
283 offene.push(quelle);
284 quelle.exec(`
285 CREATE TABLE profil (
286 id INTEGER PRIMARY KEY AUTOINCREMENT,
287 name TEXT NOT NULL UNIQUE,
288 pruefungstermin TEXT,
289 erstellt_am TEXT NOT NULL
290 );
291 CREATE TABLE antwort_log (
292 id INTEGER PRIMARY KEY AUTOINCREMENT,
293 profil_id INTEGER NOT NULL,
294 frage_id TEXT NOT NULL,
295 zeitpunkt TEXT NOT NULL,
296 richtig INTEGER NOT NULL,
297 bewertung TEXT NOT NULL,
298 dauer_ms INTEGER NOT NULL
299 );
300 `);
301 quelle
302 .prepare('INSERT INTO profil (name, pruefungstermin, erstellt_am) VALUES (?, NULL, ?)')
303 .run('Alt', '2025-01-01T00:00:00.000Z');
304 quelle
305 .prepare(
306 `INSERT INTO antwort_log (profil_id, frage_id, zeitpunkt, richtig, bewertung, dauer_ms)
307 VALUES (1, 'I.1-01', '2025-06-01T09:00:00.000Z', 1, 'gut', 500)`,
308 )
309 .run();
310 quelle.close();
311
312 const ergebnis = uebernehmen(ziel, quellPfad, 1);
313
314 expect(ergebnis.art).toBe('uebernommen');
315 if (ergebnis.art !== 'uebernommen') return;
316 expect(ergebnis.antworten).toBe(1);
317 /* Die fehlenden Spalten stehen auf ihrem Vorgabewert, nicht auf Geratenem. */
318 expect(
319 ziel
320 .prepare('SELECT nur_historie AS h, auswahl AS a FROM antwort_log WHERE profil_id = ?')
321 .get(ergebnis.profilId),
322 ).toEqual({ h: 0, a: '[]' });
323 });
324
325 it('legt vor jeder Übernahme eine Sicherheitskopie an', () => {
326 const ziel = datenbank(join(ordner, 'lernstand.db'));
327 profilFuellen(ziel, 'Standard', 8);
328
329 const quellPfad = join(ordner, 'quelle.db');
330 const quelle = datenbank(quellPfad);
331 const quellId = profilFuellen(quelle, 'Gast', 2);
332 quelle.close();
333
334 const ergebnis = uebernehmen(ziel, quellPfad, quellId);
335
336 expect(ergebnis.art === 'uebernommen' && ergebnis.sicherheitskopie).toMatch(
337 /^Lernstand-vor-dem-Uebernehmen-/u,
338 );
339 const kopie = readdirSync(ordner).find((n) => n.startsWith(VOR_DEM_UEBERNEHMEN));
340 expect(kopie).toBeDefined();
341
342 /* Die Kopie enthält den Stand VOR der Übernahme. */
343 const alt = new Database(join(ordner, kopie ?? ''), { readonly: true });
344 offene.push(alt);
345 expect(alt.prepare('SELECT COUNT(*) AS n FROM profil').get()).toEqual({ n: 1 });
346 });
347 });