waffensachkunde

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

/ app tests profil-uebernehmen.test.ts

15,1 KB Rohdatei
app/tests/profil-uebernehmen.test.ts — 432 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 const KATALOGSTAND = '2024-12-16';
81
82 function uebernehmen(
83 ziel: Database.Database,
84 quellPfad: string,
85 quellId: number,
86 katalogstand = KATALOGSTAND,
87 ) {
88 return profilUebernehmen({
89 ziel,
90 quelle: quellPfad,
91 quellProfilId: quellId,
92 katalogstand,
93 ordner,
94 jetzt: new Date('2026-08-23T10:00:00.000Z'),
95 Datenbank: Database,
96 });
97 }
98
99 beforeEach(() => {
100 ordner = mkdtempSync(join(tmpdir(), 'wsk-uebernehmen-'));
101 offene = [];
102 });
103
104 afterEach(() => {
105 for (const db of offene) {
106 try {
107 db.close();
108 } catch {
109 /* schon zu */
110 }
111 }
112 rmSync(ordner, { recursive: true, force: true });
113 });
114
115 describe('freierProfilname', () => {
116 it('lässt einen freien Namen stehen', () => {
117 expect(freierProfilname(new Set(['Anna']), 'Olaf')).toBe('Olaf');
118 });
119
120 it('hängt bei Namensgleichheit einen Zusatz an – der vorhandene bleibt', () => {
121 expect(freierProfilname(new Set(['Olaf']), 'Olaf')).toBe('Olaf (übernommen)');
122 expect(freierProfilname(new Set(['Olaf', 'Olaf (übernommen)']), 'Olaf')).toBe(
123 'Olaf (übernommen 2)',
124 );
125 });
126 });
127
128 describe('profilUebernehmen', () => {
129 it('nimmt Profil und Zeilen mit und lässt das Vorhandene unberührt', () => {
130 const zielPfad = join(ordner, 'lernstand.db');
131 const ziel = datenbank(zielPfad);
132 profilFuellen(ziel, 'Standard', 30);
133
134 const quellPfad = join(ordner, 'quelle.db');
135 const quelle = datenbank(quellPfad);
136 const quellId = profilFuellen(quelle, 'Notebook', 45, '2026-12-01');
137 quelle.close();
138
139 const ergebnis = uebernehmen(ziel, quellPfad, quellId);
140
141 expect(ergebnis.art).toBe('uebernommen');
142 if (ergebnis.art !== 'uebernommen') return;
143 expect(ergebnis.name).toBe('Notebook');
144 expect(ergebnis.antworten).toBe(45);
145 expect(ergebnis.bearbeiteteFragen).toBe(20);
146
147 /* Das vorhandene Profil ist zeichengenau, wie es war. */
148 expect(ziel.prepare('SELECT name FROM profil ORDER BY id').all()).toEqual([
149 { name: 'Standard' },
150 { name: 'Notebook' },
151 ]);
152 expect(ziel.prepare('SELECT COUNT(*) AS n FROM antwort_log WHERE profil_id = 1').get()).toEqual(
153 { n: 30 },
154 );
155 expect(
156 ziel
157 .prepare('SELECT COUNT(*) AS n FROM antwort_log WHERE profil_id = ?')
158 .get(ergebnis.profilId),
159 ).toEqual({ n: 45 });
160 /* Der Prüfungstermin kommt mit – er gehört zum Profil. */
161 expect(
162 ziel.prepare('SELECT pruefungstermin AS t FROM profil WHERE id = ?').get(ergebnis.profilId),
163 ).toEqual({ t: '2026-12-01' });
164 });
165
166 /*
167 Die Wache, die gefehlt hat.
168
169 `kapitel_ausschluss` steht als Spalte am Profil, nicht in einer
170 Kindtabelle – und wurde deshalb von der Schleife über `KINDTABELLEN`
171 nicht erfasst. Das `SELECT` daneben las bis 0.26.4 nur `name` und
172 `pruefungstermin`; die Spalte fiel auf ihren Vorgabewert `'[]'` zurück.
173
174 Die Folge trifft genau den Weg, den das Handbuch für zwei Geräte
175 empfiehlt: Wer auf dem Rechner ein Kapitel abgewählt hat – etwa „Not- und
176 Seenotsignalmittel“, das nicht jede Prüfung verlangt – bekommt es auf dem
177 zweiten Rechner stillschweigend zurück. Die Prüfungsreife fällt dabei um
178 zwei Ampelstufen, nachgerechnet in `shared/verlust.ts`: derselbe Bestand
179 von 85,0 auf 71,9 Prozent. Nichts sagt es, nichts fragt nach.
180 */
181 it('nimmt die Kapitelabwahl mit – sie gehört zum Profil wie der Termin', () => {
182 const ziel = datenbank(join(ordner, 'lernstand.db'));
183 profilFuellen(ziel, 'Standard', 5);
184
185 const quellPfad = join(ordner, 'quelle.db');
186 const quelle = datenbank(quellPfad);
187 const quellId = profilFuellen(quelle, 'Notebook', 12);
188 quelle.prepare('UPDATE profil SET kapitel_ausschluss = ? WHERE id = ?').run('["IV"]', quellId);
189 quelle.close();
190
191 const ergebnis = uebernehmen(ziel, quellPfad, quellId);
192
193 expect(ergebnis.art).toBe('uebernommen');
194 if (ergebnis.art !== 'uebernommen') return;
195 expect(
196 ziel
197 .prepare('SELECT kapitel_ausschluss AS k FROM profil WHERE id = ?')
198 .get(ergebnis.profilId),
199 ).toEqual({ k: '["IV"]' });
200
201 /* Und das vorhandene Profil behält seine eigene Abwahl – hier keine. */
202 expect(ziel.prepare('SELECT kapitel_ausschluss AS k FROM profil WHERE id = 1').get()).toEqual({
203 k: '[]',
204 });
205 });
206
207 /*
208 Die Wache, die gefehlt hat.
209
210 Beim Katalogwechsel auf diesem Rechner meldet der Lernstand seit jeher,
211 was daran verwaist (`Katalogwechsel`). Beim Übernehmen eines fremden
212 Profils gab es diese Prüfung nicht: Ein Profil aus einer Fassung mit
213 anderem Katalog kam stillschweigend herein, und seine Fragenkennungen
214 zeigten teils ins Leere.
215 */
216 it('meldet einen abweichenden Katalogstand der Quelle', () => {
217 const ziel = datenbank(join(ordner, 'lernstand.db'));
218 profilFuellen(ziel, 'Standard', 5);
219
220 const quellPfad = join(ordner, 'quelle.db');
221 const quelle = datenbank(quellPfad);
222 const quellId = profilFuellen(quelle, 'Alt', 7);
223 quelle
224 .prepare('INSERT INTO katalog_stand (stand, vermerkt_am) VALUES (?, ?)')
225 .run('2023-01-01', '2023-01-01T00:00:00.000Z');
226 quelle.close();
227
228 const ergebnis = uebernehmen(ziel, quellPfad, quellId, '2024-12-16');
229
230 expect(ergebnis.art).toBe('uebernommen');
231 if (ergebnis.art !== 'uebernommen') return;
232 expect(ergebnis.katalogstandDerQuelle).toBe('2023-01-01');
233 });
234
235 it('schweigt, wenn die Stände übereinstimmen', () => {
236 /* Sonst stünde bei jeder Übernahme eine Zeile, die nichts sagt. */
237 const ziel = datenbank(join(ordner, 'lernstand.db'));
238 profilFuellen(ziel, 'Standard', 5);
239
240 const quellPfad = join(ordner, 'quelle.db');
241 const quelle = datenbank(quellPfad);
242 const quellId = profilFuellen(quelle, 'Gleich', 3);
243 quelle
244 .prepare('INSERT INTO katalog_stand (stand, vermerkt_am) VALUES (?, ?)')
245 .run('2024-12-16', '2024-12-16T00:00:00.000Z');
246 quelle.close();
247
248 const ergebnis = uebernehmen(ziel, quellPfad, quellId, '2024-12-16');
249
250 expect(ergebnis.art).toBe('uebernommen');
251 if (ergebnis.art !== 'uebernommen') return;
252 expect(ergebnis.katalogstandDerQuelle).toBeNull();
253 });
254
255 it('schweigt auch, wenn die Quelle keinen Stand führt', () => {
256 /* Sicherungen vor Schemafassung 9 haben die Tabelle nicht. Eine Warnung
257 „unbekannter Stand“ wäre lauter als ihr Inhalt. */
258 const ziel = datenbank(join(ordner, 'lernstand.db'));
259 profilFuellen(ziel, 'Standard', 5);
260
261 const quellPfad = join(ordner, 'alt2.db');
262 const quelle = new Database(quellPfad);
263 offene.push(quelle);
264 quelle.exec(`
265 CREATE TABLE profil (
266 id INTEGER PRIMARY KEY AUTOINCREMENT,
267 name TEXT NOT NULL UNIQUE,
268 pruefungstermin TEXT,
269 erstellt_am TEXT NOT NULL
270 );
271 `);
272 quelle
273 .prepare('INSERT INTO profil (name, pruefungstermin, erstellt_am) VALUES (?, NULL, ?)')
274 .run('Uralt', '2024-01-01T00:00:00.000Z');
275 quelle.close();
276
277 const ergebnis = uebernehmen(ziel, quellPfad, 1, '2024-12-16');
278
279 expect(ergebnis.art).toBe('uebernommen');
280 if (ergebnis.art !== 'uebernommen') return;
281 expect(ergebnis.katalogstandDerQuelle).toBeNull();
282 });
283
284 it('benennt ein gleichnamiges Profil um, statt es anzutasten', () => {
285 const ziel = datenbank(join(ordner, 'lernstand.db'));
286 profilFuellen(ziel, 'Olaf', 10);
287
288 const quellPfad = join(ordner, 'quelle.db');
289 const quelle = datenbank(quellPfad);
290 const quellId = profilFuellen(quelle, 'Olaf', 7);
291 quelle.close();
292
293 const ergebnis = uebernehmen(ziel, quellPfad, quellId);
294
295 expect(ergebnis.art === 'uebernommen' && ergebnis.name).toBe('Olaf (übernommen)');
296 expect(ziel.prepare('SELECT COUNT(*) AS n FROM antwort_log WHERE profil_id = 1').get()).toEqual(
297 { n: 10 },
298 );
299 });
300
301 /*
302 Der Einwand, an dem der erste Entwurf durchgefallen ist: Benutzten beide
303 Wege denselben Dateivorsatz, löschten drei harmlose Übernahmen den
304 einzigen Rückweg eines vorangegangenen Ersetzens – nachgestellt mit 900
305 Antworten, die danach unwiederbringlich fort waren.
306 */
307 it('räumt nur die eigenen Sicherheitskopien ab, nie die des Ersetzens', () => {
308 const ziel = datenbank(join(ordner, 'lernstand.db'));
309 profilFuellen(ziel, 'Standard', 5);
310
311 const quellPfad = join(ordner, 'quelle.db');
312 const quelle = datenbank(quellPfad);
313 const quellId = profilFuellen(quelle, 'Gast', 3);
314 quelle.close();
315
316 /* Eine Kopie des ANDEREN Weges, wie sie das Ersetzen anlegt. */
317 const fremde = join(ordner, 'Lernstand-vor-dem-Einspielen-2026-08-22-120000.wsklernstand');
318 writeFileSync(fremde, 'egal');
319
320 for (let i = 0; i < 4; i += 1) {
321 const ergebnis = uebernehmen(ziel, quellPfad, quellId);
322 expect(ergebnis.art).toBe('uebernommen');
323 }
324
325 const dateien = readdirSync(ordner);
326 expect(dateien).toContain('Lernstand-vor-dem-Einspielen-2026-08-22-120000.wsklernstand');
327 expect(dateien.filter((n) => n.startsWith(VOR_DEM_UEBERNEHMEN)).length).toBeLessThanOrEqual(3);
328 });
329
330 it('lässt bei einer unbekannten Profilnummer alles unverändert', () => {
331 const ziel = datenbank(join(ordner, 'lernstand.db'));
332 profilFuellen(ziel, 'Standard', 12);
333
334 const quellPfad = join(ordner, 'quelle.db');
335 const quelle = datenbank(quellPfad);
336 profilFuellen(quelle, 'Gast', 3);
337 quelle.close();
338
339 const ergebnis = uebernehmen(ziel, quellPfad, 999);
340
341 expect(ergebnis.art).toBe('gescheitert');
342 expect(ziel.prepare('SELECT COUNT(*) AS n FROM profil').get()).toEqual({ n: 1 });
343 expect(ziel.prepare('SELECT COUNT(*) AS n FROM antwort_log').get()).toEqual({ n: 12 });
344 });
345
346 it('meldet eine fehlende Datei, ohne etwas anzufassen', () => {
347 const ziel = datenbank(join(ordner, 'lernstand.db'));
348 profilFuellen(ziel, 'Standard', 4);
349
350 const ergebnis = uebernehmen(ziel, join(ordner, 'gibt-es-nicht.db'), 1);
351
352 expect(ergebnis.art).toBe('gescheitert');
353 expect(ziel.prepare('SELECT COUNT(*) AS n FROM profil').get()).toEqual({ n: 1 });
354 expect(readdirSync(ordner).filter((n) => n.startsWith(VOR_DEM_UEBERNEHMEN))).toHaveLength(0);
355 });
356
357 /*
358 Eine ältere Sicherung kennt die später ergänzten Spalten nicht. Sie zu
359 verlangen bräche die Übernahme; sie zu erfinden wäre schlechter, als die
360 Vorgabewerte des Schemas greifen zu lassen.
361 */
362 it('kommt mit einer Quelle ohne die später ergänzten Spalten zurecht', () => {
363 const ziel = datenbank(join(ordner, 'lernstand.db'));
364 profilFuellen(ziel, 'Standard', 5);
365
366 const quellPfad = join(ordner, 'alt.db');
367 const quelle = new Database(quellPfad);
368 offene.push(quelle);
369 quelle.exec(`
370 CREATE TABLE profil (
371 id INTEGER PRIMARY KEY AUTOINCREMENT,
372 name TEXT NOT NULL UNIQUE,
373 pruefungstermin TEXT,
374 erstellt_am TEXT NOT NULL
375 );
376 CREATE TABLE antwort_log (
377 id INTEGER PRIMARY KEY AUTOINCREMENT,
378 profil_id INTEGER NOT NULL,
379 frage_id TEXT NOT NULL,
380 zeitpunkt TEXT NOT NULL,
381 richtig INTEGER NOT NULL,
382 bewertung TEXT NOT NULL,
383 dauer_ms INTEGER NOT NULL
384 );
385 `);
386 quelle
387 .prepare('INSERT INTO profil (name, pruefungstermin, erstellt_am) VALUES (?, NULL, ?)')
388 .run('Alt', '2025-01-01T00:00:00.000Z');
389 quelle
390 .prepare(
391 `INSERT INTO antwort_log (profil_id, frage_id, zeitpunkt, richtig, bewertung, dauer_ms)
392 VALUES (1, 'I.1-01', '2025-06-01T09:00:00.000Z', 1, 'gut', 500)`,
393 )
394 .run();
395 quelle.close();
396
397 const ergebnis = uebernehmen(ziel, quellPfad, 1);
398
399 expect(ergebnis.art).toBe('uebernommen');
400 if (ergebnis.art !== 'uebernommen') return;
401 expect(ergebnis.antworten).toBe(1);
402 /* Die fehlenden Spalten stehen auf ihrem Vorgabewert, nicht auf Geratenem. */
403 expect(
404 ziel
405 .prepare('SELECT nur_historie AS h, auswahl AS a FROM antwort_log WHERE profil_id = ?')
406 .get(ergebnis.profilId),
407 ).toEqual({ h: 0, a: '[]' });
408 });
409
410 it('legt vor jeder Übernahme eine Sicherheitskopie an', () => {
411 const ziel = datenbank(join(ordner, 'lernstand.db'));
412 profilFuellen(ziel, 'Standard', 8);
413
414 const quellPfad = join(ordner, 'quelle.db');
415 const quelle = datenbank(quellPfad);
416 const quellId = profilFuellen(quelle, 'Gast', 2);
417 quelle.close();
418
419 const ergebnis = uebernehmen(ziel, quellPfad, quellId);
420
421 expect(ergebnis.art === 'uebernommen' && ergebnis.sicherheitskopie).toMatch(
422 /^Lernstand-vor-dem-Uebernehmen-/u,
423 );
424 const kopie = readdirSync(ordner).find((n) => n.startsWith(VOR_DEM_UEBERNEHMEN));
425 expect(kopie).toBeDefined();
426
427 /* Die Kopie enthält den Stand VOR der Übernahme. */
428 const alt = new Database(join(ordner, kopie ?? ''), { readonly: true });
429 offene.push(alt);
430 expect(alt.prepare('SELECT COUNT(*) AS n FROM profil').get()).toEqual({ n: 1 });
431 });
432 });