waffensachkunde

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

/ app tests profil-uebernehmen.test.ts

18,3 KB Rohdatei
app/tests/profil-uebernehmen.test.ts — 506 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('zählt nur echte Antworten und nur wirklich bearbeitete Fragen', () => {
130 /*
131 Befund der Prüfrunde zu 0.27.2, zwei Zählfehler in einer Meldung.
132
133 Erstens zählte `antworten` alle eingefügten Protokollzeilen – auch die
134 mit `nur_historie = 1`, also die Fragen eines abgelaufenen Bogens, die
135 nie aufgeschlagen wurden. Die Auswahlliste unmittelbar davor
136 (`kennzahlenLesen` in `sicherung.ts`) filtert sie ausdrücklich aus:
137 „nicht in eine Zahl, die ‚beantwortete Fragen‘ heisst und über die
138 jemand eine nicht rücknehmbare Entscheidung trifft“. Zwei Zahlen zu
139 derselben Sache, verschieden gerechnet.
140
141 Zweitens zählte `bearbeiteteFragen` die Zeilen in `frage_stand` – und
142 eine solche Zeile entsteht schon, wenn jemand eine Frage nur merkt. Die
143 Meldung sagt aber „Antworten zu N verschiedenen Fragen“.
144 */
145 const zielPfad = join(ordner, 'lernstand.db');
146 const ziel = datenbank(zielPfad);
147 /* Ohne ein vorhandenes Profil lehnt die Sicherheitskopie ihre eigene
148 Gegenprobe ab – und dann wird gar nichts übernommen. */
149 profilFuellen(ziel, 'Standard', 3);
150
151 const quellPfad = join(ordner, 'quelle.db');
152 const quelle = datenbank(quellPfad);
153 const quellId = Number(
154 quelle
155 .prepare('INSERT INTO profil (name, pruefungstermin, erstellt_am) VALUES (?, NULL, ?)')
156 .run('Notebook', '2026-01-01T00:00:00.000Z').lastInsertRowid,
157 );
158 const log = quelle.prepare(
159 `INSERT INTO antwort_log (profil_id, frage_id, zeitpunkt, richtig, bewertung, dauer_ms, nur_historie)
160 VALUES (?, ?, ?, 1, 'gut', 1000, ?)`,
161 );
162 /* Fünf echte Antworten auf drei verschiedene Fragen … */
163 for (let i = 0; i < 5; i += 1) {
164 log.run(quellId, `I.1-0${String(i % 3)}`, `2026-08-1${String(i)}T09:00:00.000Z`, 0);
165 }
166 /* … und zwei Zeilen eines abgelaufenen Bogens, die niemand gesehen hat. */
167 log.run(quellId, 'I.1-08', '2026-08-20T09:00:00.000Z', 1);
168 log.run(quellId, 'I.1-09', '2026-08-21T09:00:00.000Z', 1);
169 /* Eine vierte Frage ist nur gemerkt, nie beantwortet. */
170 quelle
171 .prepare(
172 'INSERT INTO frage_stand (profil_id, frage_id, versuche, richtige, gemerkt) VALUES (?, ?, 0, 0, 1)',
173 )
174 .run(quellId, 'I.1-07');
175 for (let i = 0; i < 3; i += 1) {
176 quelle
177 .prepare(
178 'INSERT INTO frage_stand (profil_id, frage_id, versuche, richtige, gemerkt) VALUES (?, ?, 1, 1, 0)',
179 )
180 .run(quellId, `I.1-0${String(i)}`);
181 }
182 quelle.close();
183
184 const ergebnis = uebernehmen(ziel, quellPfad, quellId);
185
186 if (ergebnis.art !== 'uebernommen') {
187 throw new Error(`Nicht übernommen: ${JSON.stringify(ergebnis)}`);
188 }
189 expect(ergebnis.antworten).toBe(5);
190 expect(ergebnis.bearbeiteteFragen).toBe(3);
191
192 /* Die Gegenprobe: Mitgenommen wird trotzdem alles – gezählt wird nur
193 anders. Die gemerkte Frage und die Historienzeilen sind da. */
194 const neueId = ergebnis.profilId;
195 expect(
196 ziel.prepare('SELECT COUNT(*) AS n FROM antwort_log WHERE profil_id = ?').get(neueId),
197 ).toEqual({ n: 7 });
198 expect(
199 ziel.prepare('SELECT COUNT(*) AS n FROM frage_stand WHERE profil_id = ?').get(neueId),
200 ).toEqual({ n: 4 });
201 });
202
203 it('nimmt Profil und Zeilen mit und lässt das Vorhandene unberührt', () => {
204 const zielPfad = join(ordner, 'lernstand.db');
205 const ziel = datenbank(zielPfad);
206 profilFuellen(ziel, 'Standard', 30);
207
208 const quellPfad = join(ordner, 'quelle.db');
209 const quelle = datenbank(quellPfad);
210 const quellId = profilFuellen(quelle, 'Notebook', 45, '2026-12-01');
211 quelle.close();
212
213 const ergebnis = uebernehmen(ziel, quellPfad, quellId);
214
215 expect(ergebnis.art).toBe('uebernommen');
216 if (ergebnis.art !== 'uebernommen') return;
217 expect(ergebnis.name).toBe('Notebook');
218 expect(ergebnis.antworten).toBe(45);
219 expect(ergebnis.bearbeiteteFragen).toBe(20);
220
221 /* Das vorhandene Profil ist zeichengenau, wie es war. */
222 expect(ziel.prepare('SELECT name FROM profil ORDER BY id').all()).toEqual([
223 { name: 'Standard' },
224 { name: 'Notebook' },
225 ]);
226 expect(ziel.prepare('SELECT COUNT(*) AS n FROM antwort_log WHERE profil_id = 1').get()).toEqual(
227 { n: 30 },
228 );
229 expect(
230 ziel
231 .prepare('SELECT COUNT(*) AS n FROM antwort_log WHERE profil_id = ?')
232 .get(ergebnis.profilId),
233 ).toEqual({ n: 45 });
234 /* Der Prüfungstermin kommt mit – er gehört zum Profil. */
235 expect(
236 ziel.prepare('SELECT pruefungstermin AS t FROM profil WHERE id = ?').get(ergebnis.profilId),
237 ).toEqual({ t: '2026-12-01' });
238 });
239
240 /*
241 Die Wache, die gefehlt hat.
242
243 `kapitel_ausschluss` steht als Spalte am Profil, nicht in einer
244 Kindtabelle – und wurde deshalb von der Schleife über `KINDTABELLEN`
245 nicht erfasst. Das `SELECT` daneben las bis 0.26.4 nur `name` und
246 `pruefungstermin`; die Spalte fiel auf ihren Vorgabewert `'[]'` zurück.
247
248 Die Folge trifft genau den Weg, den das Handbuch für zwei Geräte
249 empfiehlt: Wer auf dem Rechner ein Kapitel abgewählt hat – etwa „Not- und
250 Seenotsignalmittel“, das nicht jede Prüfung verlangt – bekommt es auf dem
251 zweiten Rechner stillschweigend zurück. Die Prüfungsreife fällt dabei um
252 zwei Ampelstufen, nachgerechnet in `shared/verlust.ts`: derselbe Bestand
253 von 85,0 auf 71,9 Prozent. Nichts sagt es, nichts fragt nach.
254 */
255 it('nimmt die Kapitelabwahl mit – sie gehört zum Profil wie der Termin', () => {
256 const ziel = datenbank(join(ordner, 'lernstand.db'));
257 profilFuellen(ziel, 'Standard', 5);
258
259 const quellPfad = join(ordner, 'quelle.db');
260 const quelle = datenbank(quellPfad);
261 const quellId = profilFuellen(quelle, 'Notebook', 12);
262 quelle.prepare('UPDATE profil SET kapitel_ausschluss = ? WHERE id = ?').run('["IV"]', quellId);
263 quelle.close();
264
265 const ergebnis = uebernehmen(ziel, quellPfad, quellId);
266
267 expect(ergebnis.art).toBe('uebernommen');
268 if (ergebnis.art !== 'uebernommen') return;
269 expect(
270 ziel
271 .prepare('SELECT kapitel_ausschluss AS k FROM profil WHERE id = ?')
272 .get(ergebnis.profilId),
273 ).toEqual({ k: '["IV"]' });
274
275 /* Und das vorhandene Profil behält seine eigene Abwahl – hier keine. */
276 expect(ziel.prepare('SELECT kapitel_ausschluss AS k FROM profil WHERE id = 1').get()).toEqual({
277 k: '[]',
278 });
279 });
280
281 /*
282 Die Wache, die gefehlt hat.
283
284 Beim Katalogwechsel auf diesem Rechner meldet der Lernstand seit jeher,
285 was daran verwaist (`Katalogwechsel`). Beim Übernehmen eines fremden
286 Profils gab es diese Prüfung nicht: Ein Profil aus einer Fassung mit
287 anderem Katalog kam stillschweigend herein, und seine Fragenkennungen
288 zeigten teils ins Leere.
289 */
290 it('meldet einen abweichenden Katalogstand der Quelle', () => {
291 const ziel = datenbank(join(ordner, 'lernstand.db'));
292 profilFuellen(ziel, 'Standard', 5);
293
294 const quellPfad = join(ordner, 'quelle.db');
295 const quelle = datenbank(quellPfad);
296 const quellId = profilFuellen(quelle, 'Alt', 7);
297 quelle
298 .prepare('INSERT INTO katalog_stand (stand, vermerkt_am) VALUES (?, ?)')
299 .run('2023-01-01', '2023-01-01T00:00:00.000Z');
300 quelle.close();
301
302 const ergebnis = uebernehmen(ziel, quellPfad, quellId, '2024-12-16');
303
304 expect(ergebnis.art).toBe('uebernommen');
305 if (ergebnis.art !== 'uebernommen') return;
306 expect(ergebnis.katalogstandDerQuelle).toBe('2023-01-01');
307 });
308
309 it('schweigt, wenn die Stände übereinstimmen', () => {
310 /* Sonst stünde bei jeder Übernahme eine Zeile, die nichts sagt. */
311 const ziel = datenbank(join(ordner, 'lernstand.db'));
312 profilFuellen(ziel, 'Standard', 5);
313
314 const quellPfad = join(ordner, 'quelle.db');
315 const quelle = datenbank(quellPfad);
316 const quellId = profilFuellen(quelle, 'Gleich', 3);
317 quelle
318 .prepare('INSERT INTO katalog_stand (stand, vermerkt_am) VALUES (?, ?)')
319 .run('2024-12-16', '2024-12-16T00:00:00.000Z');
320 quelle.close();
321
322 const ergebnis = uebernehmen(ziel, quellPfad, quellId, '2024-12-16');
323
324 expect(ergebnis.art).toBe('uebernommen');
325 if (ergebnis.art !== 'uebernommen') return;
326 expect(ergebnis.katalogstandDerQuelle).toBeNull();
327 });
328
329 it('schweigt auch, wenn die Quelle keinen Stand führt', () => {
330 /* Sicherungen vor Schemafassung 9 haben die Tabelle nicht. Eine Warnung
331 „unbekannter Stand“ wäre lauter als ihr Inhalt. */
332 const ziel = datenbank(join(ordner, 'lernstand.db'));
333 profilFuellen(ziel, 'Standard', 5);
334
335 const quellPfad = join(ordner, 'alt2.db');
336 const quelle = new Database(quellPfad);
337 offene.push(quelle);
338 quelle.exec(`
339 CREATE TABLE profil (
340 id INTEGER PRIMARY KEY AUTOINCREMENT,
341 name TEXT NOT NULL UNIQUE,
342 pruefungstermin TEXT,
343 erstellt_am TEXT NOT NULL
344 );
345 `);
346 quelle
347 .prepare('INSERT INTO profil (name, pruefungstermin, erstellt_am) VALUES (?, NULL, ?)')
348 .run('Uralt', '2024-01-01T00:00:00.000Z');
349 quelle.close();
350
351 const ergebnis = uebernehmen(ziel, quellPfad, 1, '2024-12-16');
352
353 expect(ergebnis.art).toBe('uebernommen');
354 if (ergebnis.art !== 'uebernommen') return;
355 expect(ergebnis.katalogstandDerQuelle).toBeNull();
356 });
357
358 it('benennt ein gleichnamiges Profil um, statt es anzutasten', () => {
359 const ziel = datenbank(join(ordner, 'lernstand.db'));
360 profilFuellen(ziel, 'Olaf', 10);
361
362 const quellPfad = join(ordner, 'quelle.db');
363 const quelle = datenbank(quellPfad);
364 const quellId = profilFuellen(quelle, 'Olaf', 7);
365 quelle.close();
366
367 const ergebnis = uebernehmen(ziel, quellPfad, quellId);
368
369 expect(ergebnis.art === 'uebernommen' && ergebnis.name).toBe('Olaf (übernommen)');
370 expect(ziel.prepare('SELECT COUNT(*) AS n FROM antwort_log WHERE profil_id = 1').get()).toEqual(
371 { n: 10 },
372 );
373 });
374
375 /*
376 Der Einwand, an dem der erste Entwurf durchgefallen ist: Benutzten beide
377 Wege denselben Dateivorsatz, löschten drei harmlose Übernahmen den
378 einzigen Rückweg eines vorangegangenen Ersetzens – nachgestellt mit 900
379 Antworten, die danach unwiederbringlich fort waren.
380 */
381 it('räumt nur die eigenen Sicherheitskopien ab, nie die des Ersetzens', () => {
382 const ziel = datenbank(join(ordner, 'lernstand.db'));
383 profilFuellen(ziel, 'Standard', 5);
384
385 const quellPfad = join(ordner, 'quelle.db');
386 const quelle = datenbank(quellPfad);
387 const quellId = profilFuellen(quelle, 'Gast', 3);
388 quelle.close();
389
390 /* Eine Kopie des ANDEREN Weges, wie sie das Ersetzen anlegt. */
391 const fremde = join(ordner, 'Lernstand-vor-dem-Einspielen-2026-08-22-120000.wsklernstand');
392 writeFileSync(fremde, 'egal');
393
394 for (let i = 0; i < 4; i += 1) {
395 const ergebnis = uebernehmen(ziel, quellPfad, quellId);
396 expect(ergebnis.art).toBe('uebernommen');
397 }
398
399 const dateien = readdirSync(ordner);
400 expect(dateien).toContain('Lernstand-vor-dem-Einspielen-2026-08-22-120000.wsklernstand');
401 expect(dateien.filter((n) => n.startsWith(VOR_DEM_UEBERNEHMEN)).length).toBeLessThanOrEqual(3);
402 });
403
404 it('lässt bei einer unbekannten Profilnummer alles unverändert', () => {
405 const ziel = datenbank(join(ordner, 'lernstand.db'));
406 profilFuellen(ziel, 'Standard', 12);
407
408 const quellPfad = join(ordner, 'quelle.db');
409 const quelle = datenbank(quellPfad);
410 profilFuellen(quelle, 'Gast', 3);
411 quelle.close();
412
413 const ergebnis = uebernehmen(ziel, quellPfad, 999);
414
415 expect(ergebnis.art).toBe('gescheitert');
416 expect(ziel.prepare('SELECT COUNT(*) AS n FROM profil').get()).toEqual({ n: 1 });
417 expect(ziel.prepare('SELECT COUNT(*) AS n FROM antwort_log').get()).toEqual({ n: 12 });
418 });
419
420 it('meldet eine fehlende Datei, ohne etwas anzufassen', () => {
421 const ziel = datenbank(join(ordner, 'lernstand.db'));
422 profilFuellen(ziel, 'Standard', 4);
423
424 const ergebnis = uebernehmen(ziel, join(ordner, 'gibt-es-nicht.db'), 1);
425
426 expect(ergebnis.art).toBe('gescheitert');
427 expect(ziel.prepare('SELECT COUNT(*) AS n FROM profil').get()).toEqual({ n: 1 });
428 expect(readdirSync(ordner).filter((n) => n.startsWith(VOR_DEM_UEBERNEHMEN))).toHaveLength(0);
429 });
430
431 /*
432 Eine ältere Sicherung kennt die später ergänzten Spalten nicht. Sie zu
433 verlangen bräche die Übernahme; sie zu erfinden wäre schlechter, als die
434 Vorgabewerte des Schemas greifen zu lassen.
435 */
436 it('kommt mit einer Quelle ohne die später ergänzten Spalten zurecht', () => {
437 const ziel = datenbank(join(ordner, 'lernstand.db'));
438 profilFuellen(ziel, 'Standard', 5);
439
440 const quellPfad = join(ordner, 'alt.db');
441 const quelle = new Database(quellPfad);
442 offene.push(quelle);
443 quelle.exec(`
444 CREATE TABLE profil (
445 id INTEGER PRIMARY KEY AUTOINCREMENT,
446 name TEXT NOT NULL UNIQUE,
447 pruefungstermin TEXT,
448 erstellt_am TEXT NOT NULL
449 );
450 CREATE TABLE antwort_log (
451 id INTEGER PRIMARY KEY AUTOINCREMENT,
452 profil_id INTEGER NOT NULL,
453 frage_id TEXT NOT NULL,
454 zeitpunkt TEXT NOT NULL,
455 richtig INTEGER NOT NULL,
456 bewertung TEXT NOT NULL,
457 dauer_ms INTEGER NOT NULL
458 );
459 `);
460 quelle
461 .prepare('INSERT INTO profil (name, pruefungstermin, erstellt_am) VALUES (?, NULL, ?)')
462 .run('Alt', '2025-01-01T00:00:00.000Z');
463 quelle
464 .prepare(
465 `INSERT INTO antwort_log (profil_id, frage_id, zeitpunkt, richtig, bewertung, dauer_ms)
466 VALUES (1, 'I.1-01', '2025-06-01T09:00:00.000Z', 1, 'gut', 500)`,
467 )
468 .run();
469 quelle.close();
470
471 const ergebnis = uebernehmen(ziel, quellPfad, 1);
472
473 expect(ergebnis.art).toBe('uebernommen');
474 if (ergebnis.art !== 'uebernommen') return;
475 expect(ergebnis.antworten).toBe(1);
476 /* Die fehlenden Spalten stehen auf ihrem Vorgabewert, nicht auf Geratenem. */
477 expect(
478 ziel
479 .prepare('SELECT nur_historie AS h, auswahl AS a FROM antwort_log WHERE profil_id = ?')
480 .get(ergebnis.profilId),
481 ).toEqual({ h: 0, a: '[]' });
482 });
483
484 it('legt vor jeder Übernahme eine Sicherheitskopie an', () => {
485 const ziel = datenbank(join(ordner, 'lernstand.db'));
486 profilFuellen(ziel, 'Standard', 8);
487
488 const quellPfad = join(ordner, 'quelle.db');
489 const quelle = datenbank(quellPfad);
490 const quellId = profilFuellen(quelle, 'Gast', 2);
491 quelle.close();
492
493 const ergebnis = uebernehmen(ziel, quellPfad, quellId);
494
495 expect(ergebnis.art === 'uebernommen' && ergebnis.sicherheitskopie).toMatch(
496 /^Lernstand-vor-dem-Uebernehmen-/u,
497 );
498 const kopie = readdirSync(ordner).find((n) => n.startsWith(VOR_DEM_UEBERNEHMEN));
499 expect(kopie).toBeDefined();
500
501 /* Die Kopie enthält den Stand VOR der Übernahme. */
502 const alt = new Database(join(ordner, kopie ?? ''), { readonly: true });
503 offene.push(alt);
504 expect(alt.prepare('SELECT COUNT(*) AS n FROM profil').get()).toEqual({ n: 1 });
505 });
506 });