waffensachkunde

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

/ app tests lernstand.test.ts

74,5 KB Rohdatei
app/tests/lernstand.test.ts — 2015 Zeilen
1 // @vitest-environment node
2 // Der Lernstand läuft im Main-Prozess gegen echtes SQLite. Getestet wird
3 // gegen eine `:memory:`-Datenbank – dieselbe Bibliothek, dasselbe Schema,
4 // nur ohne Datei auf der Platte. Es wird nichts nachgebildet.
5
6 import Database from 'better-sqlite3';
7 import { afterEach, beforeEach, describe, expect, it } from 'vitest';
8
9 import { Lernstand } from '../src/main/lernstand';
10 import { SCHEMA_VERSION } from '../src/main/schema';
11 import type { Antwortoption, Frage, Katalog } from '../src/shared/katalog';
12 import type { Bewertung } from '../src/shared/lernstand';
13
14 const TAG_MS = 86_400_000;
15
16 // ─── Prüfkatalog ────────────────────────────────────────────────────────────
17
18 function option(label: string, korrekt: boolean): Antwortoption {
19 return {
20 label,
21 inhalt: { text: `Antwort ${label}`, segmente: [{ t: `Antwort ${label}` }] },
22 korrekt,
23 bilder: [],
24 };
25 }
26
27 function mcFrage(
28 id: string,
29 kapitel: string,
30 abschnitt: string | null,
31 labels: readonly string[],
32 korrekt: readonly string[],
33 ): Frage {
34 return {
35 id,
36 amtliche_nummer: id,
37 kapitel,
38 abschnitt,
39 typ: 'mc',
40 seite: 1,
41 frage: { text: `Frage ${id}`, segmente: [{ t: `Frage ${id}` }] },
42 bilder: [],
43 optionen: labels.map((label) => option(label, korrekt.includes(label))),
44 };
45 }
46
47 function freitextFrage(id: string, kapitel: string, abschnitt: string | null): Frage {
48 return {
49 id,
50 amtliche_nummer: id,
51 kapitel,
52 abschnitt,
53 typ: 'freitext',
54 seite: 1,
55 frage: { text: `Frage ${id}`, segmente: [{ t: `Frage ${id}` }] },
56 bilder: [],
57 musterantwort: { text: 'Musterantwort', segmente: [{ t: 'Musterantwort', h: true }] },
58 };
59 }
60
61 const FRAGEN: readonly Frage[] = [
62 mcFrage('I.1-01', 'I', 'I.1', ['a', 'b', 'c'], ['a', 'c']),
63 mcFrage('I.1-02', 'I', 'I.1', ['a', 'b'], ['b']),
64 freitextFrage('I.1-03', 'I', 'I.1'),
65 mcFrage('I.2-01', 'I', 'I.2', ['a', 'b', 'c', 'd'], ['d']),
66 freitextFrage('I.2-02', 'I', 'I.2'),
67 mcFrage('II-01', 'II', null, ['a', 'b'], ['a']),
68 mcFrage('II-02', 'II', null, ['a', 'b', 'c'], ['b']),
69 freitextFrage('II-03', 'II', null),
70 /* Kapitel IV steht hier ausschließlich für die dauerhafte Abwahl. Ohne es
71 ließe sich gar nichts abwählen, und jeder Test dazu bestünde scheinbar
72 und prüfte nichts. */
73 mcFrage('IV-01', 'IV', null, ['a', 'b'], ['a']),
74 mcFrage('IV-02', 'IV', null, ['a', 'b'], ['b']),
75 ];
76
77 const KATALOG: Katalog = {
78 meta: {
79 titel: 'Prüfkatalog',
80 herausgeber: 'Bundesverwaltungsamt',
81 stand: '2024-12-16',
82 quellenangabe: 'Amtlicher Fragenkatalog, Stand 16.12.2024.',
83 quelle_url: 'https://www.bva.bund.de/',
84 quelldatei_sha256: 'a'.repeat(64),
85 fragen_gesamt: FRAGEN.length,
86 },
87 kapitel: [
88 {
89 id: 'I',
90 titel: 'Waffenrecht',
91 abschnitte: [
92 { id: 'I.1', titel: 'Begriffe des Waffenrechts' },
93 { id: 'I.2', titel: 'Rechte und Pflichten' },
94 ],
95 },
96 { id: 'II', titel: 'Waffentechnik', abschnitte: [] },
97 { id: 'IV', titel: 'Not- und Seenotsignalmittel', abschnitte: [] },
98 ],
99 bilder: [],
100 fragen: FRAGEN,
101 };
102
103 // ─── Prüfstand ──────────────────────────────────────────────────────────────
104
105 const START = '2026-03-01T10:00:00.000Z';
106
107 let uhr: Date;
108 let db: Database.Database;
109 let lernstand: Lernstand;
110 let profilId: number;
111 /** Alle in einem Testfall geöffneten Verbindungen, damit keine offen bleibt. */
112 let verbindungen: Database.Database[] = [];
113
114 /** Verschiebt die Uhr um ganze Tage; alle Fälligkeiten werden dagegen gerechnet. */
115 function tageWeiter(tage: number): void {
116 uhr = new Date(uhr.getTime() + tage * TAG_MS);
117 }
118
119 /** Startet einen frischen Lernstand. Darf innerhalb eines Tests erneut laufen. */
120 function starten(zufall?: () => number): void {
121 uhr = new Date(START);
122 db = new Database(':memory:');
123 verbindungen.push(db);
124 lernstand = new Lernstand(db, KATALOG, {
125 jetzt: () => uhr,
126 ...(zufall === undefined ? {} : { zufall }),
127 });
128 profilId = lernstand.profile()[0]!.id;
129 }
130
131 function antworten(
132 frageId: string,
133 bewertung: Bewertung,
134 auswahl: readonly string[] = [],
135 richtig = true,
136 ): ReturnType<Lernstand['antworten']> {
137 return lernstand.antworten(profilId, {
138 frageId,
139 auswahl,
140 richtig,
141 bewertung,
142 dauerMs: 1500,
143 });
144 }
145
146 /** Abstand zwischen Antwortzeitpunkt und Wiedervorlage in Tagen. */
147 function faelligInTagen(faelligAb: string | null): number {
148 return (new Date(faelligAb ?? 0).getTime() - uhr.getTime()) / TAG_MS;
149 }
150
151 beforeEach(() => {
152 verbindungen = [];
153 starten();
154 });
155
156 afterEach(() => {
157 for (const verbindung of verbindungen) {
158 if (verbindung.open) {
159 verbindung.close();
160 }
161 }
162 });
163
164 // ─── Profile ────────────────────────────────────────────────────────────────
165
166 describe('Profile', () => {
167 it('legt beim ersten Start automatisch das Profil „Standard“ an', () => {
168 const profile = lernstand.profile();
169
170 expect(profile).toHaveLength(1);
171 expect(profile[0]?.name).toBe('Standard');
172 expect(profile[0]?.pruefungstermin).toBeNull();
173 expect(profile[0]?.erstelltAm).toBe(START);
174 });
175
176 it('legt weitere Profile an und behält die Reihenfolge', () => {
177 lernstand.profilAnlegen(' Olaf ');
178 lernstand.profilAnlegen('Zweitprüfling');
179
180 expect(lernstand.profile().map((p) => p.name)).toEqual(['Standard', 'Olaf', 'Zweitprüfling']);
181 });
182
183 it('weist einen doppelten Profilnamen zurück', () => {
184 lernstand.profilAnlegen('Olaf');
185
186 expect(() => lernstand.profilAnlegen('Olaf')).toThrow(/bereits ein Profil/u);
187 });
188
189 it.each([[''], [' '], [null], [42], [{ name: 'Olaf' }]])(
190 'weist den ungültigen Profilnamen %o zurück',
191 (name) => {
192 expect(() => lernstand.profilAnlegen(name)).toThrow();
193 },
194 );
195
196 it('weist einen zu langen Profilnamen zurück', () => {
197 expect(() => lernstand.profilAnlegen('x'.repeat(61))).toThrow(/höchstens 60 Zeichen/u);
198 });
199
200 it('setzt und löscht den Prüfungstermin', () => {
201 expect(
202 lernstand.profilAktualisieren({ id: profilId, pruefungstermin: '2026-09-30' }),
203 ).toMatchObject({
204 pruefungstermin: '2026-09-30',
205 });
206
207 expect(lernstand.profilAktualisieren({ id: profilId, pruefungstermin: null })).toMatchObject({
208 pruefungstermin: null,
209 kapitelAusschluss: [],
210 });
211 });
212
213 it('lässt den Prüfungstermin unangetastet, wenn er nicht mitgeschickt wird', () => {
214 lernstand.profilAktualisieren({ id: profilId, pruefungstermin: '2026-09-30' });
215
216 const nachher = lernstand.profilAktualisieren({ id: profilId, name: 'Olaf' });
217
218 expect(nachher).toMatchObject({ name: 'Olaf', pruefungstermin: '2026-09-30' });
219 });
220
221 it.each([
222 ['30.09.2026'],
223 ['2026-9-30'],
224 ['morgen'],
225 [20260930],
226 /* Tage, die es nicht gibt. Sie sehen wie gültige ISO-Daten aus, und
227 `Date.parse` rechnet sie stillschweigend in den Folgemonat um – ein
228 Prüfungstermin, der sich beim Speichern verschiebt, wäre schlimmer
229 als eine Abweisung. */
230 ['2026-02-30'],
231 ['2025-02-29'],
232 ['2026-04-31'],
233 ['2026-13-01'],
234 ])('weist den ungültigen Prüfungstermin %o zurück', (termin) => {
235 expect(() => lernstand.profilAktualisieren({ id: profilId, pruefungstermin: termin })).toThrow(
236 /Prüfungstermin/u,
237 );
238 });
239
240 it('nimmt einen echten Schalttag an', () => {
241 // Die Korrektur darf den 29. Februar eines Schaltjahres nicht mitreißen.
242 expect(
243 lernstand.profilAktualisieren({ id: profilId, pruefungstermin: '2024-02-29' }),
244 ).toMatchObject({ pruefungstermin: '2024-02-29' });
245 });
246
247 it.each([[999], [0], [-1], ['1'], [1.5], [null]])(
248 'weist die unbekannte oder ungültige Profil-ID %o zurück',
249 (id) => {
250 expect(() => lernstand.uebersicht(id)).toThrow();
251 },
252 );
253 });
254
255 // ─── Antworten protokollieren ───────────────────────────────────────────────
256
257 describe('Antworten protokollieren', () => {
258 it('schreibt das Protokoll und schreibt den Fragenstand fort', () => {
259 const stand = antworten('I.1-01', 'gut', ['a', 'c']);
260
261 expect(stand).toMatchObject({
262 frageId: 'I.1-01',
263 versuche: 1,
264 richtige: 1,
265 zuletztBeantwortet: START,
266 gemerkt: false,
267 letzteBewertung: 'gut',
268 });
269
270 const zeilen = db
271 .prepare('SELECT frage_id, richtig, bewertung, dauer_ms, auswahl, freitext FROM antwort_log')
272 .all() as {
273 frage_id: string;
274 richtig: number;
275 bewertung: string;
276 dauer_ms: number;
277 auswahl: string;
278 freitext: string | null;
279 }[];
280
281 expect(zeilen).toHaveLength(1);
282 expect(zeilen[0]).toEqual({
283 frage_id: 'I.1-01',
284 richtig: 1,
285 bewertung: 'gut',
286 dauer_ms: 1500,
287 auswahl: '["a","c"]',
288 freitext: null,
289 });
290 });
291
292 it('zählt Versuche und richtige Antworten über mehrere Durchgänge', () => {
293 antworten('I.1-01', 'nochmal', ['a']);
294 antworten('I.1-01', 'schwer', ['a', 'c']);
295 const stand = antworten('I.1-01', 'gut', ['a', 'c']);
296
297 expect(stand).toMatchObject({ versuche: 3, richtige: 2 });
298 expect(db.prepare('SELECT COUNT(*) AS n FROM antwort_log').get()).toEqual({ n: 3 });
299 });
300
301 it('rechnet Multiple Choice selbst nach und glaubt dem Renderer nicht', () => {
302 // Der Renderer behauptet „richtig“, obwohl die Auswahl unvollständig ist.
303 const stand = antworten('I.1-01', 'gut', ['a'], true);
304
305 expect(stand.richtige).toBe(0);
306 expect(db.prepare('SELECT richtig FROM antwort_log').get()).toEqual({ richtig: 0 });
307 });
308
309 it('wertet eine Auswahl mit zu vielen Optionen als falsch', () => {
310 expect(antworten('I.1-02', 'gut', ['a', 'b'], true).richtige).toBe(0);
311 expect(antworten('I.1-02', 'gut', ['b'], false).richtige).toBe(1);
312 });
313
314 it('übernimmt bei offenen Fragen die Selbsteinschätzung', () => {
315 expect(antworten('I.1-03', 'gut', [], true).richtige).toBe(1);
316 expect(antworten('I.2-02', 'nochmal', [], false).richtige).toBe(0);
317 });
318
319 it('speichert den Freitext und kürzt ihn auf ein vernünftiges Maß', () => {
320 lernstand.antworten(profilId, {
321 frageId: 'I.1-03',
322 auswahl: [],
323 freitext: 'x'.repeat(5000),
324 richtig: true,
325 bewertung: 'gut',
326 dauerMs: 10,
327 });
328
329 const zeile = db.prepare('SELECT freitext FROM antwort_log').get() as { freitext: string };
330 expect(zeile.freitext).toHaveLength(4000);
331 });
332
333 it.each([
334 [
335 { frageId: 'gibt-es-nicht', auswahl: [], richtig: true, bewertung: 'gut', dauerMs: 1 },
336 /Unbekannte Frage-ID/u,
337 ],
338 [
339 { frageId: 'I.1-01', auswahl: ['z'], richtig: true, bewertung: 'gut', dauerMs: 1 },
340 /keine Antwortoption/u,
341 ],
342 [
343 { frageId: 'I.1-01', auswahl: [], richtig: true, bewertung: 'super', dauerMs: 1 },
344 /bewertung/u,
345 ],
346 [{ frageId: 'I.1-01', auswahl: [], richtig: 'ja', bewertung: 'gut', dauerMs: 1 }, /richtig/u],
347 [{ frageId: 'I.1-01', auswahl: [], richtig: true, bewertung: 'gut', dauerMs: -5 }, /dauerMs/u],
348 [{ frageId: 'I.1-01', auswahl: 'a', richtig: true, bewertung: 'gut', dauerMs: 1 }, /auswahl/u],
349 ['kein Objekt', /Antwortprotokoll/u],
350 ])('weist das ungültige Protokoll %o zurück', (protokoll, muster) => {
351 expect(() => lernstand.antworten(profilId, protokoll)).toThrow(muster);
352 });
353
354 it('schreibt bei einem ungültigen Protokoll überhaupt nichts', () => {
355 expect(() =>
356 lernstand.antworten(profilId, {
357 frageId: 'I.1-01',
358 auswahl: ['z'],
359 richtig: true,
360 bewertung: 'gut',
361 dauerMs: 1,
362 }),
363 ).toThrow();
364
365 expect(db.prepare('SELECT COUNT(*) AS n FROM antwort_log').get()).toEqual({ n: 0 });
366 expect(db.prepare('SELECT COUNT(*) AS n FROM frage_stand').get()).toEqual({ n: 0 });
367 });
368
369 it('trennt die Lernstände verschiedener Profile', () => {
370 const zweites = lernstand.profilAnlegen('Zweitprüfling');
371 antworten('I.1-01', 'gut', ['a', 'c']);
372
373 expect(lernstand.frageStand(zweites.id, 'I.1-01').versuche).toBe(0);
374 expect(lernstand.frageStand(profilId, 'I.1-01').versuche).toBe(1);
375 });
376 });
377
378 // ─── Wiedervorlage ──────────────────────────────────────────────────────────
379
380 describe('Wiedervorlage nach FSRS', () => {
381 /*
382 * Die Sollwerte stammen aus `data-pipeline/fsrs_referenz.py` – derselben
383 * Referenzrechnung, gegen die auch `tests/fsrs.test.ts` prüft. Hier wird
384 * nicht die Formel geprüft (das tut jene Datei), sondern dass der Lernstand
385 * sie tatsächlich anwendet und das Ergebnis richtig in `faellig_ab`
386 * umsetzt.
387 */
388
389 it.each([
390 ['nochmal', 0],
391 ['schwer', 1],
392 ['gut', 2],
393 ['leicht', 8],
394 ] as const)('setzt bei „%s“ die erste Wiedervorlage auf %i Tage', (bewertung, tage) => {
395 const stand = antworten('I.1-01', bewertung, ['a', 'c']);
396
397 expect(faelligInTagen(stand.faelligAb)).toBe(tage);
398 expect(stand.letzteBewertung).toBe(bewertung);
399 });
400
401 it('dehnt die Abstände, wenn jeweils zum Termin wiederholt wird', () => {
402 // Der eigentliche Zweck des Verfahrens: Wer den Stoff hält, sieht ihn
403 // seltener. Gerechnet wird mit dem tatsächlichen Abstand, nicht mit der
404 // Zahl der Versuche.
405 for (const erwartet of [2, 11, 46, 163, 180]) {
406 const stand = antworten('I.1-01', 'gut', ['a', 'c']);
407 expect(faelligInTagen(stand.faelligAb)).toBe(erwartet);
408 tageWeiter(erwartet);
409 }
410 });
411
412 it('dehnt bei „leicht“ deutlich schneller als bei „schwer“', () => {
413 const leicht: number[] = [];
414 for (let i = 0; i < 3; i += 1) {
415 const tage = faelligInTagen(antworten('I.1-01', 'leicht', ['a', 'c']).faelligAb);
416 leicht.push(tage);
417 tageWeiter(tage);
418 }
419
420 starten();
421 const schwer: number[] = [];
422 for (let i = 0; i < 3; i += 1) {
423 const tage = faelligInTagen(antworten('I.1-01', 'schwer', ['a', 'c']).faelligAb);
424 schwer.push(tage);
425 tageWeiter(tage);
426 }
427
428 expect(leicht).toEqual([8, 66, 180]);
429 expect(schwer).toEqual([1, 3, 7]);
430 });
431
432 it('gewinnt durch Wiederholung am selben Tag nichts dazu', () => {
433 /* Der wichtigste Unterschied zum früheren Verdopplungsschema: Dreimal
434 hintereinander „gut“ innerhalb einer Minute ist kein Lernfortschritt,
435 und das Intervall wächst deshalb auch nicht. */
436 expect(faelligInTagen(antworten('I.1-01', 'gut', ['a', 'c']).faelligAb)).toBe(2);
437 expect(faelligInTagen(antworten('I.1-01', 'gut', ['a', 'c']).faelligAb)).toBe(2);
438 expect(faelligInTagen(antworten('I.1-01', 'gut', ['a', 'c']).faelligAb)).toBe(2);
439 });
440
441 it('macht eine Frage bei „nochmal“ sofort wieder fällig', () => {
442 antworten('I.1-01', 'leicht', ['a', 'c']);
443 tageWeiter(8);
444
445 expect(faelligInTagen(antworten('I.1-01', 'nochmal', ['a']).faelligAb)).toBe(0);
446 });
447
448 it('behält nach einem Fehler den früheren Fortschritt teilweise', () => {
449 /* Beide Fragen haben dieselbe jüngste Historie: erst „nochmal“, einen Tag
450 später „gut“. Sie unterscheiden sich nur darin, dass die erste vorher
451 schon einmal fest saß. Genau das ist der Gewinn gegenüber einem
452 Verfahren, das bei einem Fehler den Zähler auf null setzt. */
453 antworten('I.1-01', 'leicht', ['a', 'c']);
454 tageWeiter(8);
455 antworten('I.1-01', 'nochmal', ['a'], false);
456 tageWeiter(1);
457 const vorbelastet = faelligInTagen(antworten('I.1-01', 'gut', ['a', 'c']).faelligAb);
458
459 starten();
460 antworten('I.1-01', 'nochmal', ['a'], false);
461 tageWeiter(1);
462 const frisch = faelligInTagen(antworten('I.1-01', 'gut', ['a', 'c']).faelligAb);
463
464 expect(vorbelastet).toBe(4);
465 expect(frisch).toBe(2);
466 });
467
468 it('verkürzt die Abstände, wenn der Prüfungstermin nahe ist', () => {
469 // Dieselbe Antwort auf dieselbe Frage – nur der Termin unterscheidet sich.
470 lernstand.profilAktualisieren({ id: profilId, pruefungstermin: '2026-03-08' });
471 const knapp = faelligInTagen(antworten('I.1-01', 'gut', ['a', 'c']).faelligAb);
472
473 starten();
474 const ohneTermin = faelligInTagen(antworten('I.1-01', 'gut', ['a', 'c']).faelligAb);
475
476 expect(knapp).toBe(1);
477 expect(ohneTermin).toBe(2);
478 });
479
480 it('lässt einen weit entfernten Termin die Abstände unberührt', () => {
481 // Ein Termin in einem halben Jahr darf nicht dazu führen, dass von Anfang
482 // an übermäßig oft wiederholt wird.
483 lernstand.profilAktualisieren({ id: profilId, pruefungstermin: '2026-09-01' });
484
485 expect(faelligInTagen(antworten('I.1-01', 'gut', ['a', 'c']).faelligAb)).toBe(2);
486 });
487
488 it('deckelt die Wiedervorlage bei 180 Tagen', () => {
489 for (let i = 0; i < 8; i += 1) {
490 const tage = faelligInTagen(antworten('I.1-01', 'leicht', ['a', 'c']).faelligAb);
491 expect(tage).toBeLessThanOrEqual(180);
492 tageWeiter(tage);
493 }
494 });
495 });
496
497 // ─── Merkliste ──────────────────────────────────────────────────────────────
498
499 describe('Merkliste', () => {
500 it('merkt eine Frage vor, ohne einen Versuch zu zählen', () => {
501 const stand = lernstand.merken(profilId, 'II-01', true);
502
503 expect(stand).toMatchObject({ frageId: 'II-01', gemerkt: true, versuche: 0, faelligAb: null });
504 expect(lernstand.uebersicht(profilId).gemerkt).toBe(1);
505 });
506
507 it('nimmt eine Frage wieder von der Merkliste', () => {
508 lernstand.merken(profilId, 'II-01', true);
509
510 expect(lernstand.merken(profilId, 'II-01', false).gemerkt).toBe(false);
511 expect(lernstand.uebersicht(profilId).gemerkt).toBe(0);
512 });
513
514 it('behält den Antwortstand beim Merken bei', () => {
515 antworten('II-01', 'gut', ['a']);
516
517 const stand = lernstand.merken(profilId, 'II-01', true);
518
519 expect(stand).toMatchObject({ gemerkt: true, versuche: 1, richtige: 1 });
520 });
521
522 it.each([['gibt-es-nicht'], [''], [null], [7]])('weist die Frage-ID %o zurück', (frageId) => {
523 expect(() => lernstand.merken(profilId, frageId, true)).toThrow();
524 });
525
526 it('weist einen nicht-booleschen Merkwert zurück', () => {
527 expect(() => lernstand.merken(profilId, 'II-01', 'ja')).toThrow(/gemerkt/u);
528 });
529
530 /*
531 Der Merkzustand muss mit der Sitzung mitkommen, sonst weiß die Oberfläche
532 ihn erst, nachdem die Frage beantwortet wurde. Bis 0.24.1 war das so: Der
533 Stern stand zu Sitzungsbeginn an jeder Frage auf „nicht gemerkt“ – auch in
534 einer Sitzung „nur Gemerkte“, in der jede einzelne Frage gemerkt ist.
535 */
536 it('gibt den Merkzustand mit der Sitzung heraus', () => {
537 lernstand.merken(profilId, 'II-01', true);
538
539 const sitzung = lernstand.sitzung(profilId, { mischen: false });
540
541 expect(sitzung.find((s) => s.frageId === 'II-01')?.gemerkt).toBe(true);
542 expect(sitzung.find((s) => s.frageId === 'I.1-01')?.gemerkt).toBe(false);
543 });
544
545 it('meldet in einer Sitzung „nur Gemerkte“ jede Frage als gemerkt', () => {
546 lernstand.merken(profilId, 'II-01', true);
547 lernstand.merken(profilId, 'I.1-01', true);
548
549 const sitzung = lernstand.sitzung(profilId, { nurGemerkte: true, mischen: false });
550
551 expect(sitzung.length).toBeGreaterThan(0);
552 expect(sitzung.every((s) => s.gemerkt)).toBe(true);
553 });
554 });
555
556 // ─── Übersicht ──────────────────────────────────────────────────────────────
557
558 describe('Übersicht', () => {
559 it('meldet auf einem leeren Lernstand überall null', () => {
560 const uebersicht = lernstand.uebersicht(profilId);
561
562 expect(uebersicht).toMatchObject({
563 fragenGesamt: 10,
564 beantwortet: 0,
565 belegt: 0,
566 reifegrad: 0 / 10,
567 stufe: 'ohne_beleg',
568 faellig: 0,
569 gemerkt: 0,
570 /* Kein Nullwert: `offen` zählt den Katalog, nicht den Lernstand. Der
571 Prüfkatalog enthält drei auszuformulierende Fragen. */
572 offen: 3,
573 fehler: 0,
574 heuteRichtig: 0,
575 heuteFalsch: 0,
576 heuteBearbeitet: 0,
577 tageSeitLetzterAntwort: null,
578 });
579 });
580
581 it('zählt bearbeitete und fällige Fragen', () => {
582 antworten('I.1-01', 'gut', ['a', 'c']); // richtig, fällig in 2 Tagen
583 antworten('I.1-02', 'nochmal', ['a']); // falsch, sofort fällig
584 antworten('II-01', 'leicht', ['a']); // richtig, fällig in 4 Tagen
585
586 const uebersicht = lernstand.uebersicht(profilId);
587
588 expect(uebersicht).toMatchObject({ beantwortet: 3, faellig: 1 });
589 /* Belegt ist noch nichts: Alle drei sind zum ersten Mal aufgetaucht. */
590 expect(uebersicht.belegt).toBe(0);
591 expect(uebersicht.stufe).toBe('ohne_beleg');
592 });
593
594 it('belegt eine Frage erst beim Wiedersehen nach mindestens einem Tag', () => {
595 /* Der Kern der Umstellung, an der echten Datenbank. Die erste richtige
596 Antwort beweist nichts über das Behalten – sie kann geraten sein, und
597 Wiedererkennen ist kein Erinnern. */
598 antworten('I.1-01', 'gut', ['a', 'c']);
599 expect(lernstand.uebersicht(profilId).belegt).toBe(0);
600
601 // Noch am selben Tag: kein Beleg, aber auch kein Schaden.
602 antworten('I.1-01', 'gut', ['a', 'c']);
603 expect(lernstand.uebersicht(profilId).belegt).toBe(0);
604
605 tageWeiter(2);
606 antworten('I.1-01', 'gut', ['a', 'c']);
607 expect(lernstand.uebersicht(profilId).belegt).toBe(1);
608 });
609
610 /*
611 Eine als geraten eingestandene Antwort belegt nichts.
612
613 Bis 0.22.0 hing der Beleg allein an `richtig` – und `richtig` rechnet der
614 Kern aus der Auswahl nach, ohne zu wissen, ob jemand die Antwort wusste
615 oder traf. Wer über „Ich hatte geraten“ die Selbsteinschätzung nachreicht,
616 sagt genau das: angekreuzt war das Richtige, gewusst war es nicht. Ein
617 Beleg dafür wäre eine Reifezahl auf einem Zufallstreffer – gemessen an
618 einem nachgestellten Rater fiel er an 40 Prozent der Tage zu Unrecht
619 (`docs/entscheidung-ratewahrscheinlichkeit.md`).
620 */
621 it('belegt eine richtige Antwort nicht, wenn sie als geraten gilt', () => {
622 antworten('I.1-01', 'gut', ['a', 'c']);
623 tageWeiter(2);
624
625 /* Richtig angekreuzt, aber als „nicht gewusst“ eingestanden. */
626 antworten('I.1-01', 'nochmal', ['a', 'c']);
627
628 expect(lernstand.uebersicht(profilId).belegt).toBe(0);
629 });
630
631 it('nimmt einen vorhandenen Beleg dabei nicht weg', () => {
632 /* Die Antwort war richtig – sie widerlegt nichts. Ein früherer Beleg
633 bleibt deshalb stehen, anders als bei einer falschen Antwort. */
634 antworten('I.1-01', 'gut', ['a', 'c']);
635 tageWeiter(2);
636 antworten('I.1-01', 'gut', ['a', 'c']);
637 expect(lernstand.uebersicht(profilId).belegt).toBe(1);
638
639 tageWeiter(2);
640 antworten('I.1-01', 'nochmal', ['a', 'c']);
641
642 expect(lernstand.uebersicht(profilId).belegt).toBe(1);
643 });
644
645 it('nimmt den Beleg bei einer falschen Antwort wieder weg', () => {
646 /* Der Beleg ist eine Aussage über das Jetzt, kein Orden für früher.
647 Daraus folgt die Eigenschaft, an der der erste Entwurf gescheitert
648 war: Die Zahl kann durch eine falsche Antwort nie steigen. */
649 antworten('I.1-01', 'gut', ['a', 'c']);
650 tageWeiter(2);
651 antworten('I.1-01', 'gut', ['a', 'c']);
652 expect(lernstand.uebersicht(profilId).belegt).toBe(1);
653
654 tageWeiter(20);
655 const vorher = lernstand.uebersicht(profilId).reifegrad;
656 antworten('I.1-01', 'nochmal', ['b'], false);
657 const nachher = lernstand.uebersicht(profilId);
658
659 expect(nachher.belegt).toBe(0);
660 expect(nachher.reifegrad).toBeLessThanOrEqual(vorher);
661 });
662
663 it('lässt den Beleg mit der Zeit verfallen, ohne ihn zu löschen', () => {
664 /* Die Zahl darf fallen, ohne dass jemand etwas falsch gemacht hat – das
665 ist der ganze Punkt eines Gedächtnismodells. Der Beleg selbst bleibt
666 aber stehen: Wer die Frage wiedersieht, fängt nicht von vorn an. */
667 antworten('I.1-01', 'gut', ['a', 'c']);
668 tageWeiter(2);
669 antworten('I.1-01', 'gut', ['a', 'c']);
670 const frisch = lernstand.uebersicht(profilId).reifegrad;
671
672 tageWeiter(120);
673 const alt = lernstand.uebersicht(profilId).reifegrad;
674
675 expect(alt).toBeLessThan(frisch);
676 expect(alt).toBeGreaterThan(0);
677 });
678
679 it('führt die Tagesbilanz nur für den heutigen Kalendertag', () => {
680 antworten('I.1-01', 'gut', ['a', 'c']);
681 antworten('I.1-02', 'nochmal', ['a']);
682
683 expect(lernstand.uebersicht(profilId)).toMatchObject({ heuteRichtig: 1, heuteFalsch: 1 });
684
685 tageWeiter(1);
686 expect(lernstand.uebersicht(profilId)).toMatchObject({ heuteRichtig: 0, heuteFalsch: 0 });
687
688 antworten('II-01', 'gut', ['a']);
689 expect(lernstand.uebersicht(profilId)).toMatchObject({ heuteRichtig: 1, heuteFalsch: 0 });
690 });
691
692 it('gliedert Kapitel mit Abschnitten nach Abschnitt, andere nach Kapitel', () => {
693 const bereiche = lernstand.uebersicht(profilId).bereiche;
694
695 expect(bereiche.map((b) => b.id)).toEqual(['I.1', 'I.2', 'II', 'IV']);
696 expect(bereiche.map((b) => b.titel)).toEqual([
697 'Begriffe des Waffenrechts',
698 'Rechte und Pflichten',
699 'Waffentechnik',
700 'Not- und Seenotsignalmittel',
701 ]);
702 expect(bereiche.map((b) => b.fragenGesamt)).toEqual([3, 2, 3, 2]);
703 });
704
705 it('rechnet den Reifegrad je Bereich aus derselben Summe wie die Gesamtzahl', () => {
706 antworten('I.1-01', 'gut', ['a', 'c']);
707 antworten('I.1-02', 'nochmal', ['a'], false);
708 tageWeiter(2);
709 antworten('I.1-01', 'gut', ['a', 'c']);
710
711 const uebersicht = lernstand.uebersicht(profilId);
712 const bereich = uebersicht.bereiche.find((b) => b.id === 'I.1');
713
714 expect(bereich).toMatchObject({ fragenGesamt: 3, beantwortet: 2, belegt: 1 });
715
716 /* Bereichswerte und Gesamtwert sind nicht bloß aufeinander abgestimmt,
717 sondern dieselbe Summe, nur anders gruppiert. */
718 const ausBereichen = uebersicht.bereiche.reduce(
719 (summe, b) => summe + b.reifegrad * b.fragenGesamt,
720 0,
721 );
722 expect(ausBereichen / uebersicht.fragenGesamt).toBeCloseTo(uebersicht.reifegrad, 10);
723 });
724 });
725
726 // ─── Zurücksetzen ───────────────────────────────────────────────────────────
727
728 describe('Profile löschen', () => {
729 it('löscht alles, was am Profil hängt', () => {
730 /*
731 Die eine Zeile `DELETE FROM profil` verlässt sich darauf, dass SQLite
732 kaskadiert. Geprüft war bisher nur, dass das Pragma auf 1 steht – nicht,
733 dass die Kindzeilen wirklich mitgehen. Genau darauf beruht aber das
734 Löschen.
735 */
736 const zweites = lernstand.profilAnlegen('Zweitprofil');
737 antworten('I.1-01', 'gut', ['a']);
738 lernstand.merken(profilId, 'II-02', true);
739 db.prepare(
740 `INSERT INTO pruefung_lauf
741 (profil_id, pruefungsprofil, zeitpunkt, gesamt, richtig, quote, urteil, dauer_ms)
742 VALUES (?, 'standard', '2026-08-01T10:00:00.000Z', 10, 8, 0.8, 'bestanden', 1000)`,
743 ).run(profilId);
744
745 const zaehle = (tabelle: string, id: number): number =>
746 (
747 db.prepare(`SELECT COUNT(*) AS n FROM ${tabelle} WHERE profil_id = ?`).get(id) as {
748 n: number;
749 }
750 ).n;
751
752 expect(zaehle('antwort_log', profilId)).toBeGreaterThan(0);
753 expect(zaehle('frage_stand', profilId)).toBeGreaterThan(0);
754 expect(zaehle('pruefung_lauf', profilId)).toBe(1);
755
756 const uebrig = lernstand.profilLoeschen(profilId);
757
758 expect(uebrig.map((p) => p.id)).toEqual([zweites.id]);
759 expect(zaehle('antwort_log', profilId)).toBe(0);
760 expect(zaehle('frage_stand', profilId)).toBe(0);
761 expect(zaehle('pruefung_lauf', profilId)).toBe(0);
762 });
763
764 it('lässt das letzte Profil stehen', () => {
765 /* Ohne Profil hätte die Anwendung keinen Ort für Antworten mehr, und ein
766 neues entstünde erst beim nächsten Start. */
767 expect(lernstand.profile()).toHaveLength(1);
768
769 expect(() => lernstand.profilLoeschen(profilId)).toThrow(/letzte Profil/u);
770 expect(lernstand.profile()).toHaveLength(1);
771 });
772
773 it('lässt die Daten der übrigen Profile unberührt', () => {
774 const zweites = lernstand.profilAnlegen('Zweitprofil');
775 lernstand.merken(zweites.id, 'II-02', true);
776 antworten('I.1-01', 'gut', ['a']);
777
778 lernstand.profilLoeschen(profilId);
779
780 expect(lernstand.frageStand(zweites.id, 'II-02').gemerkt).toBe(true);
781 });
782
783 it('weist eine unbekannte Profil-ID ab, statt still nichts zu tun', () => {
784 /* Ein Löschauftrag ins Leere ist ein Fehler des Aufrufers, kein
785 Erfolg. Die Prüfung liegt schon in profilIdPruefen. */
786 lernstand.profilAnlegen('Zweitprofil');
787
788 expect(() => lernstand.profilLoeschen(9999)).toThrow(/Unbekanntes Profil/u);
789 expect(lernstand.profile()).toHaveLength(2);
790 });
791 });
792
793 describe('Profile anlegen', () => {
794 it('unterscheidet Namen nicht nach Groß- und Kleinschreibung', () => {
795 /* In einer Liste, aus der jemand sein Profil wiedererkennen soll, sind
796 „Olaf“ und „olaf“ keine Unterscheidung, sondern eine Falle. */
797 lernstand.profilAnlegen('Olaf');
798
799 expect(() => lernstand.profilAnlegen('olaf')).toThrow(/bereits ein Profil/u);
800 expect(() => lernstand.profilAnlegen('OLAF')).toThrow(/bereits ein Profil/u);
801 });
802
803 it('legt beim Umbenennen denselben Maßstab an wie beim Anlegen', () => {
804 /* Der Vergleich beim Umbenennen war buchstabengenau, der beim Anlegen
805 nicht. Über den Umweg „anlegen, dann umbenennen" ließ sich also
806 herstellen, was das Anlegen abweist. */
807 lernstand.profilAnlegen('Olaf');
808 const zweites = lernstand.profilAnlegen('Anna');
809
810 expect(() => lernstand.profilAktualisieren({ id: zweites.id, name: 'olaf' })).toThrow(
811 /bereits ein Profil/u,
812 );
813 expect(lernstand.profile().map((profil) => profil.name)).toContain('Anna');
814 });
815
816 it('lässt ein Profil auf seinen eigenen Namen umbenennen', () => {
817 /* Sonst scheiterte das Ändern der Groß- und Kleinschreibung am eigenen
818 Eintrag: „olaf" zu „Olaf" wäre ein Konflikt mit sich selbst. */
819 const profil = lernstand.profilAnlegen('olaf');
820
821 expect(lernstand.profilAktualisieren({ id: profil.id, name: 'Olaf' })).toMatchObject({
822 name: 'Olaf',
823 });
824 });
825
826 it('begrenzt die Zahl der Profile', () => {
827 /* Keine technische Grenze, sondern eine gegen Versehen. */
828 for (let i = lernstand.profile().length; i < 20; i += 1) {
829 lernstand.profilAnlegen(`Profil ${String(i)}`);
830 }
831
832 expect(lernstand.profile()).toHaveLength(20);
833 expect(() => lernstand.profilAnlegen('Eins zu viel')).toThrow(/höchstens 20/u);
834 });
835 });
836
837 describe('Zurücksetzen', () => {
838 beforeEach(() => {
839 antworten('I.1-01', 'gut', ['a', 'c']);
840 antworten('I.2-01', 'gut', ['d']);
841 antworten('II-01', 'gut', ['a']);
842 lernstand.merken(profilId, 'II-02', true);
843 });
844
845 it('löscht Stand und Historie vollständig', () => {
846 const uebersicht = lernstand.zuruecksetzen(profilId, null);
847
848 expect(uebersicht).toMatchObject({ beantwortet: 0, belegt: 0, gemerkt: 0, heuteRichtig: 0 });
849 expect(db.prepare('SELECT COUNT(*) AS n FROM antwort_log').get()).toEqual({ n: 0 });
850 expect(db.prepare('SELECT COUNT(*) AS n FROM frage_stand').get()).toEqual({ n: 0 });
851 });
852
853 it('löscht auch den Prüfungsverlauf', () => {
854 /*
855 Wer von vorn anfangen will, meint von vorn. Bliebe der Verlauf stehen,
856 stünden alte Simulationsergebnisse weiter in der Auswertung und im
857 Lernbericht, während der Lernstand bei null ist – zwei Zahlen, die
858 sich widersprechen.
859 */
860 db.prepare(
861 `INSERT INTO pruefung_lauf
862 (profil_id, pruefungsprofil, zeitpunkt, gesamt, richtig, quote, urteil, dauer_ms)
863 VALUES (?, 'standard', '2026-08-01T10:00:00.000Z', 10, 8, 0.8, 'bestanden', 1000)`,
864 ).run(profilId);
865 expect(db.prepare('SELECT COUNT(*) AS n FROM pruefung_lauf').get()).toEqual({ n: 1 });
866
867 lernstand.zuruecksetzen(profilId, null);
868
869 expect(db.prepare('SELECT COUNT(*) AS n FROM pruefung_lauf').get()).toEqual({ n: 0 });
870 });
871
872 it('lässt den Prüfungsverlauf stehen, wenn nur ein Kapitel zurückgesetzt wird', () => {
873 /* Ein Lauf geht über den ganzen Bogen und lässt sich nicht kapitelweise
874 herausrechnen. Ihn dann mitzulöschen wäre mehr, als verlangt war. */
875 db.prepare(
876 `INSERT INTO pruefung_lauf
877 (profil_id, pruefungsprofil, zeitpunkt, gesamt, richtig, quote, urteil, dauer_ms)
878 VALUES (?, 'standard', '2026-08-01T10:00:00.000Z', 10, 8, 0.8, 'bestanden', 1000)`,
879 ).run(profilId);
880
881 lernstand.zuruecksetzen(profilId, 'I');
882
883 expect(db.prepare('SELECT COUNT(*) AS n FROM pruefung_lauf').get()).toEqual({ n: 1 });
884 });
885
886 it('löscht auf Wunsch nur ein Kapitel', () => {
887 const uebersicht = lernstand.zuruecksetzen(profilId, 'I');
888
889 expect(uebersicht.beantwortet).toBe(1);
890 expect(lernstand.frageStand(profilId, 'I.1-01').versuche).toBe(0);
891 expect(lernstand.frageStand(profilId, 'II-01').versuche).toBe(1);
892 expect(lernstand.frageStand(profilId, 'II-02').gemerkt).toBe(true);
893 });
894
895 it('behält beim Kapitel-Zurücksetzen die Merkliste dieses Kapitels', () => {
896 /*
897 `gemerkt` steht in derselben Tabelle wie der Fortschritt. Die Zeile
898 einfach zu löschen nahm deshalb auch die Markierung mit – beim
899 vollständigen Zurücksetzen gewollt und angesagt, kapitelweise weder
900 das eine noch das andere. Wer eine Frage als schwer markiert hat,
901 will sie wiederfinden, gerade wenn er das Kapitel neu lernt.
902
903 Der vorhandene Test „löscht auf Wunsch nur ein Kapitel" prüft dazu
904 nichts: Die dort gemerkte Frage II-02 liegt außerhalb von Kapitel I
905 und wurde nie angefasst.
906 */
907 lernstand.merken(profilId, 'I.1-01', true);
908 expect(lernstand.uebersicht(profilId).gemerkt).toBe(2);
909
910 const uebersicht = lernstand.zuruecksetzen(profilId, 'I');
911
912 const stand = lernstand.frageStand(profilId, 'I.1-01');
913 expect(stand.gemerkt).toBe(true);
914 expect(stand).toMatchObject({
915 versuche: 0,
916 richtige: 0,
917 zuletztBeantwortet: null,
918 faelligAb: null,
919 letzteBewertung: null,
920 });
921
922 // Die Kennzahl zählt sie weiter mit, der Fortschritt nicht.
923 expect(uebersicht.gemerkt).toBe(2);
924 expect(uebersicht.beantwortet).toBe(1);
925 });
926
927 it('lässt die gemerkte Frage danach in der Merkliste stehen', () => {
928 /* Zahl und Liste müssen dasselbe sagen: Was in „Gemerkt: n Fragen"
929 mitzählt, muss in der Sitzung „Gemerkte Fragen" auch auftauchen. */
930 lernstand.merken(profilId, 'I.1-01', true);
931
932 lernstand.zuruecksetzen(profilId, 'I');
933
934 const sitzung = lernstand.sitzung(profilId, { nurGemerkte: true, mischen: false });
935 expect(sitzung.map((f) => f.frageId)).toContain('I.1-01');
936 });
937
938 it('räumt die Zeilen der nicht gemerkten Fragen ganz weg', () => {
939 /* Sonst sammelte die Tabelle bei jedem Zurücksetzen leere Zeilen an. */
940 lernstand.merken(profilId, 'I.1-01', true);
941
942 lernstand.zuruecksetzen(profilId, 'I');
943
944 expect(
945 db.prepare("SELECT COUNT(*) AS n FROM frage_stand WHERE frage_id LIKE 'I.%'").get(),
946 ).toEqual({ n: 1 });
947 });
948
949 it('zurückgesetzt sieht aus wie nie beantwortet', () => {
950 /*
951 Der eigentliche Wächter dieser Änderung, und zwar ohne Spaltenliste
952 im Test: Verglichen wird die zurückgesetzte Zeile mit einer, die nur
953 gemerkt und nie beantwortet wurde. Beide müssen über `SELECT *` Feld
954 für Feld gleich sein.
955
956 Kommt `frage_stand` später eine Spalte hinzu, die das Antworten füllt
957 und das Zurücksetzen vergisst, laufen die beiden Zeilen auseinander
958 und dieser Test fällt um – ohne dass jemand daran gedacht hätte, ihn
959 zu erweitern.
960 */
961 lernstand.merken(profilId, 'I.1-02', true); // Vergleichszeile: nie beantwortet
962 antworten('I.1-01', 'schwer', ['a'], false);
963 lernstand.merken(profilId, 'I.1-01', true);
964 tageWeiter(3);
965
966 lernstand.zuruecksetzen(profilId, 'I');
967
968 const zeilen = db
969 .prepare<[number], Record<string, unknown>>(
970 `SELECT * FROM frage_stand
971 WHERE profil_id = ? AND frage_id IN ('I.1-01', 'I.1-02')
972 ORDER BY frage_id`,
973 )
974 .all(profilId);
975 expect(zeilen).toHaveLength(2);
976
977 const ohneKennung = (zeile: Record<string, unknown>): Record<string, unknown> => {
978 const { frage_id: _weg, ...rest } = zeile;
979 return rest;
980 };
981 expect(ohneKennung(zeilen[0]!)).toEqual(ohneKennung(zeilen[1]!));
982 });
983
984 it('räumt beim vollständigen Zurücksetzen auch die Merkliste', () => {
985 /* Gegenprobe zum Kapitel-Fall: Hier ist das Mitlöschen gewollt, und
986 die Oberfläche sagt es auch an (Neuanfang.tsx). */
987 lernstand.merken(profilId, 'I.1-01', true);
988
989 const uebersicht = lernstand.zuruecksetzen(profilId, null);
990
991 expect(uebersicht.gemerkt).toBe(0);
992 expect(lernstand.frageStand(profilId, 'I.1-01').gemerkt).toBe(false);
993 expect(db.prepare('SELECT COUNT(*) AS n FROM frage_stand').get()).toEqual({ n: 0 });
994 });
995
996 it('lässt sich beliebig oft wiederholen', () => {
997 for (let i = 0; i < 5; i += 1) {
998 expect(lernstand.zuruecksetzen(profilId, null).beantwortet).toBe(0);
999 expect(lernstand.zuruecksetzen(profilId, 'I').beantwortet).toBe(0);
1000 }
1001 });
1002
1003 it('lässt andere Profile unberührt', () => {
1004 const zweites = lernstand.profilAnlegen('Zweitprüfling');
1005 lernstand.antworten(zweites.id, {
1006 frageId: 'I.1-01',
1007 auswahl: ['a', 'c'],
1008 richtig: true,
1009 bewertung: 'gut',
1010 dauerMs: 10,
1011 });
1012
1013 lernstand.zuruecksetzen(profilId, null);
1014
1015 expect(lernstand.frageStand(zweites.id, 'I.1-01').versuche).toBe(1);
1016 });
1017
1018 it('weist ein unbekanntes Kapitel zurück, ohne etwas zu löschen', () => {
1019 expect(() => lernstand.zuruecksetzen(profilId, 'IX')).toThrow(/Unbekanntes Kapitel/u);
1020 expect(lernstand.uebersicht(profilId).beantwortet).toBe(3);
1021 });
1022 });
1023
1024 // ─── Sitzungszusammenstellung ───────────────────────────────────────────────
1025
1026 describe('Sitzungszusammenstellung', () => {
1027 it('liefert ohne Filter alle Fragen in Katalogreihenfolge', () => {
1028 const sitzung = lernstand.sitzung(profilId, { mischen: false });
1029
1030 expect(sitzung.map((s) => s.frageId)).toEqual(FRAGEN.map((f) => f.id));
1031 });
1032
1033 it('filtert nach Kapitel', () => {
1034 const sitzung = lernstand.sitzung(profilId, { kapitel: ['II'], mischen: false });
1035
1036 expect(sitzung.map((s) => s.frageId)).toEqual(['II-01', 'II-02', 'II-03']);
1037 });
1038
1039 it('filtert nach Abschnitt', () => {
1040 const sitzung = lernstand.sitzung(profilId, { abschnitte: ['I.2'], mischen: false });
1041
1042 expect(sitzung.map((s) => s.frageId)).toEqual(['I.2-01', 'I.2-02']);
1043 });
1044
1045 it('verknüpft Kapitel- und Abschnittsfilter mit UND', () => {
1046 expect(
1047 lernstand.sitzung(profilId, { kapitel: ['I'], abschnitte: ['I.1'], mischen: false }),
1048 ).toHaveLength(3);
1049 expect(
1050 lernstand.sitzung(profilId, { kapitel: ['II'], abschnitte: ['I.1'], mischen: false }),
1051 ).toHaveLength(0);
1052 });
1053
1054 it('liefert mit „nurGemerkte“ nur die Merkliste', () => {
1055 lernstand.merken(profilId, 'II-02', true);
1056 lernstand.merken(profilId, 'I.2-01', true);
1057
1058 const sitzung = lernstand.sitzung(profilId, { nurGemerkte: true, mischen: false });
1059
1060 expect(sitzung.map((s) => s.frageId)).toEqual(['I.2-01', 'II-02']);
1061 });
1062
1063 it('liefert mit „nurNeue“ nur nie beantwortete Fragen', () => {
1064 antworten('I.1-01', 'gut', ['a', 'c']);
1065 antworten('II-01', 'gut', ['a']);
1066
1067 const sitzung = lernstand.sitzung(profilId, { nurNeue: true, mischen: false });
1068
1069 expect(sitzung.map((s) => s.frageId)).toEqual([
1070 'I.1-02',
1071 'I.1-03',
1072 'I.2-01',
1073 'I.2-02',
1074 'II-02',
1075 'II-03',
1076 'IV-01',
1077 'IV-02',
1078 ]);
1079 });
1080
1081 /*
1082 Der einzige Filter, der den Katalog befragt statt den Lernstand: Der
1083 Fragetyp ist eine Eigenschaft der Frage, keine des Lernenden. Deshalb
1084 wirkt er auch auf noch nie beantwortete Fragen und braucht keine Zeile
1085 in `frage_stand`.
1086 */
1087 it('liefert mit „nurOffene“ nur auszuformulierende Fragen', () => {
1088 const sitzung = lernstand.sitzung(profilId, { nurOffene: true, mischen: false });
1089
1090 expect(sitzung.map((s) => s.frageId)).toEqual(['I.1-03', 'I.2-02', 'II-03']);
1091 });
1092
1093 it('lässt „nurOffene“ mit der Kapitelabwahl zusammenwirken', () => {
1094 /* Beides muss zugleich gelten – sonst legte ausgerechnet der neue Weg
1095 die dauerhaft abgewählten Fragen wieder vor, wie es „nur Fehler“
1096 früher tat. Kapitel IV enthält hier keine offene Frage; geprüft wird
1097 deshalb die Gegenrichtung: Ein Kapitelfilter darf den Typfilter nicht
1098 aushebeln. */
1099 const sitzung = lernstand.sitzung(profilId, {
1100 nurOffene: true,
1101 kapitel: ['I'],
1102 mischen: false,
1103 });
1104
1105 expect(sitzung.map((s) => s.frageId)).toEqual(['I.1-03', 'I.2-02']);
1106 });
1107
1108 /*
1109 Der Text lag seit jeher in `antwort_log.freitext` und wurde von keiner
1110 Abfrage gelesen – geschrieben, aufbewahrt, nie gezeigt. Sein Wert liegt
1111 im Wiedersehen: Wer dieselbe Frage nach zwei Wochen wiederbekommt, sieht,
1112 ob er heute genauer ist als damals.
1113 */
1114 it('gibt die zuletzt geschriebene Freitextantwort mit der Sitzung heraus', () => {
1115 lernstand.antworten(profilId, {
1116 frageId: 'I.1-03',
1117 auswahl: [],
1118 freitext: 'Meine erste Fassung.',
1119 richtig: true,
1120 bewertung: 'gut',
1121 dauerMs: 5000,
1122 });
1123
1124 const sitzung = lernstand.sitzung(profilId, { nurOffene: true, mischen: false });
1125 const eintrag = sitzung.find((s) => s.frageId === 'I.1-03');
1126
1127 expect(eintrag?.letzterFreitext?.text).toBe('Meine erste Fassung.');
1128 expect(eintrag?.letzterFreitext?.zeitpunkt).toBeTruthy();
1129 });
1130
1131 it('nennt die jüngste Fassung, nicht die erste', () => {
1132 for (const text of ['Erster Versuch.', 'Zweiter Versuch.', 'Dritter Versuch.']) {
1133 lernstand.antworten(profilId, {
1134 frageId: 'I.1-03',
1135 auswahl: [],
1136 freitext: text,
1137 richtig: true,
1138 bewertung: 'gut',
1139 dauerMs: 5000,
1140 });
1141 }
1142
1143 const sitzung = lernstand.sitzung(profilId, { nurOffene: true, mischen: false });
1144
1145 expect(sitzung.find((s) => s.frageId === 'I.1-03')?.letzterFreitext?.text).toBe(
1146 'Dritter Versuch.',
1147 );
1148 });
1149
1150 it('lässt leere Eingaben weg – das Feld darf leer bleiben', () => {
1151 /* Ein Kasten „Beim letzten Mal schrieben Sie“ über einer leeren Zeile
1152 wäre eine Vorhaltung ohne Inhalt. */
1153 lernstand.antworten(profilId, {
1154 frageId: 'I.1-03',
1155 auswahl: [],
1156 freitext: ' ',
1157 richtig: false,
1158 bewertung: 'nochmal',
1159 dauerMs: 1000,
1160 });
1161
1162 const sitzung = lernstand.sitzung(profilId, { nurOffene: true, mischen: false });
1163
1164 expect(sitzung.find((s) => s.frageId === 'I.1-03')?.letzterFreitext).toBeUndefined();
1165 });
1166
1167 it('hängt keinen Freitext an Auswahlfragen', () => {
1168 const sitzung = lernstand.sitzung(profilId, { mischen: false, anzahl: 999 });
1169 const auswahlfragen = sitzung.filter((s) => s.frageId.includes('mc') || s.frageId === 'I.1-01');
1170
1171 for (const eintrag of auswahlfragen) {
1172 expect(eintrag.letzterFreitext).toBeUndefined();
1173 }
1174 });
1175
1176 it('zählt die offenen Fragen in der Übersicht mit', () => {
1177 /* Die Zahl an der Schaltfläche und die Sitzung dahinter müssen dasselbe
1178 sagen. Sie stammen aus zwei verschiedenen Wegen durch den Kern. */
1179 const uebersicht = lernstand.uebersicht(profilId);
1180 const sitzung = lernstand.sitzung(profilId, { nurOffene: true, anzahl: 999 });
1181
1182 expect(uebersicht.offen).toBe(3);
1183 expect(sitzung).toHaveLength(uebersicht.offen);
1184 });
1185
1186 it('liefert mit „nurFehler“ nur zuletzt falsch beantwortete Fragen', () => {
1187 antworten('I.1-01', 'nochmal', ['b']); // falsch
1188 antworten('I.1-02', 'gut', ['b']); // richtig
1189 antworten('II-01', 'nochmal', ['b']); // falsch
1190 antworten('II-01', 'gut', ['a']); // danach richtig – zählt nicht mehr
1191
1192 const sitzung = lernstand.sitzung(profilId, { nurFehler: true, mischen: false });
1193
1194 expect(sitzung.map((s) => s.frageId)).toEqual(['I.1-01']);
1195 });
1196
1197 it('begrenzt die Sitzung auf die gewünschte Anzahl', () => {
1198 expect(lernstand.sitzung(profilId, { anzahl: 3, mischen: false })).toHaveLength(3);
1199 expect(lernstand.sitzung(profilId, { anzahl: 1, mischen: false })).toHaveLength(1);
1200 });
1201
1202 it('schneidet ohne Angabe von „anzahl“ nichts ab', () => {
1203 expect(lernstand.sitzung(profilId, { mischen: false })).toHaveLength(FRAGEN.length);
1204 expect(lernstand.sitzung(profilId, { anzahl: 100, mischen: false })).toHaveLength(
1205 FRAGEN.length,
1206 );
1207 });
1208
1209 it('stellt fällige und noch nie beantwortete Fragen nach vorn', () => {
1210 // I.1-01 liegt vier Tage in der Zukunft, I.1-02 wird wieder fällig.
1211 antworten('I.1-01', 'leicht', ['a', 'c']);
1212 antworten('I.1-02', 'leicht', ['b']);
1213 antworten('I.1-03', 'leicht', [], true);
1214 tageWeiter(2);
1215 antworten('I.1-02', 'nochmal', ['a']); // sofort wieder fällig
1216
1217 const reihenfolge = lernstand.sitzung(profilId, { mischen: false }).map((s) => s.frageId);
1218
1219 // Zuerst die fällige Frage und die sechs neuen, danach die beiden, die
1220 // noch nicht wieder dran sind.
1221 expect(reihenfolge.slice(0, 7)).toEqual([
1222 'I.1-02',
1223 'I.2-01',
1224 'I.2-02',
1225 'II-01',
1226 'II-02',
1227 'II-03',
1228 'IV-01',
1229 ]);
1230 expect(reihenfolge.slice(7)).toEqual(['IV-02', 'I.1-01', 'I.1-03']);
1231 });
1232
1233 it('legt fällige Wiederholungen vor die neuen Fragen', () => {
1234 /* II-01 steht im Katalog hinter allen I-Fragen. Bis Fassung 0.20.0
1235 bildeten Fällige und Neue eine gemeinsame Gruppe in Katalogreihenfolge –
1236 die fällige Frage wäre erst an sechster Stelle gekommen. */
1237 antworten('II-01', 'nochmal', ['b'], false); // sofort wieder fällig
1238
1239 const reihenfolge = lernstand.sitzung(profilId, { mischen: false }).map((s) => s.frageId);
1240
1241 expect(reihenfolge).toEqual([
1242 'II-01',
1243 'I.1-01',
1244 'I.1-02',
1245 'I.1-03',
1246 'I.2-01',
1247 'I.2-02',
1248 'II-02',
1249 'II-03',
1250 'IV-01',
1251 'IV-02',
1252 ]);
1253 });
1254
1255 it('deckelt neue Fragen auf dieselbe Rate, die der Lernplan anzeigt', () => {
1256 // Fünf Fragen beantwortet, fünf nie gesehen, Termin in vier Tagen:
1257 // Rate = ceil(5 / 4) = 2 – im Plan wie in der Sitzung.
1258 antworten('I.1-01', 'gut', ['a', 'c']);
1259 antworten('I.1-02', 'gut', ['b']);
1260 antworten('I.1-03', 'gut');
1261 antworten('I.2-01', 'gut', ['d']);
1262 antworten('I.2-02', 'gut');
1263 lernstand.profilAktualisieren({ id: profilId, pruefungstermin: '2026-03-05' });
1264
1265 const plan = lernstand.lernplan(profilId);
1266 const sitzung = lernstand
1267 .sitzung(profilId, { anzahl: 5, mischen: false })
1268 .map((s) => s.frageId);
1269
1270 expect(plan.pensum.neu).toBe(2);
1271 // Keine fälligen: erst die zwei neuen der Tagesration, dann bereits
1272 // Beantwortetes – nicht weitere neue Fragen.
1273 expect(sitzung).toEqual(['II-01', 'II-02', 'I.1-01', 'I.1-02', 'I.1-03']);
1274 });
1275
1276 it('verhält sich ohne Termin wie bisher: neue Fragen bis zum Sitzungsumfang vorn', () => {
1277 /* Die dokumentierte Voreinstellung: Ohne Termin liegt die Rate beim
1278 Sitzungsumfang (20). Für die übliche 20er-Sitzung ist das kein Deckel –
1279 alle neuen Fragen stehen vor dem bereits Beantworteten. */
1280 antworten('I.1-01', 'gut', ['a', 'c']); // beantwortet, nicht fällig
1281
1282 const reihenfolge = lernstand.sitzung(profilId, { mischen: false }).map((s) => s.frageId);
1283
1284 expect(reihenfolge).toEqual([
1285 'I.1-02',
1286 'I.1-03',
1287 'I.2-01',
1288 'I.2-02',
1289 'II-01',
1290 'II-02',
1291 'II-03',
1292 'IV-01',
1293 'IV-02',
1294 'I.1-01',
1295 ]);
1296 });
1297
1298 it('stellt am Prüfungstag neue Fragen ganz nach hinten, ohne sie zu verstecken', () => {
1299 /* Rate 0 am Prüfungstag (siehe `einfuehrungsrate`): Fälliges und bereits
1300 Beantwortetes zuerst, die neuen Fragen bilden die letzte Gruppe. Der
1301 Deckel ordnet, er versteckt nicht – die Sitzung bleibt vollständig. */
1302 antworten('IV-01', 'nochmal', ['b'], false); // sofort wieder fällig
1303 antworten('IV-02', 'gut', ['b']); // beantwortet, nicht fällig
1304 lernstand.profilAktualisieren({ id: profilId, pruefungstermin: '2026-03-01' });
1305
1306 const reihenfolge = lernstand.sitzung(profilId, { mischen: false }).map((s) => s.frageId);
1307
1308 expect(reihenfolge).toEqual([
1309 'IV-01',
1310 'IV-02',
1311 'I.1-01',
1312 'I.1-02',
1313 'I.1-03',
1314 'I.2-01',
1315 'I.2-02',
1316 'II-01',
1317 'II-02',
1318 'II-03',
1319 ]);
1320 });
1321
1322 it('lässt „nur Neue“ auch am Prüfungstag vollständig üben', () => {
1323 // Wer ausdrücklich neue Fragen anfordert, bekommt sie – die Rate ordnet
1324 // nur die gemischte Sitzung, sie beschneidet keine ausdrückliche Wahl.
1325 lernstand.profilAktualisieren({ id: profilId, pruefungstermin: '2026-03-01' });
1326
1327 expect(lernstand.sitzung(profilId, { nurNeue: true, mischen: false })).toHaveLength(
1328 FRAGEN.length,
1329 );
1330 });
1331
1332 it('behält die Fragenmenge bei, wenn gemischt wird', () => {
1333 const ungemischt = lernstand.sitzung(profilId, { mischen: false }).map((s) => s.frageId);
1334
1335 starten(() => 0); // deterministische Zufallsquelle
1336 const gemischt = lernstand.sitzung(profilId, { mischen: true }).map((s) => s.frageId);
1337
1338 expect(gemischt).toHaveLength(ungemischt.length);
1339 expect([...gemischt].sort()).toEqual([...ungemischt].sort());
1340 expect(gemischt).not.toEqual(ungemischt);
1341 });
1342
1343 it('mischt nur innerhalb der Gruppen, nie über ihre Grenzen', () => {
1344 starten(() => 0); // deterministische Zufallsquelle
1345 antworten('I.1-01', 'leicht', ['a', 'c']); // vier Tage Pause → Restgruppe
1346
1347 const reihenfolge = lernstand.sitzung(profilId, { mischen: true }).map((s) => s.frageId);
1348
1349 // Die pausierte Frage bleibt trotz Mischen hinter allen neuen.
1350 expect(reihenfolge.at(-1)).toBe('I.1-01');
1351 expect(reihenfolge).toHaveLength(10);
1352 });
1353
1354 it('liefert die Antwortlabels in Katalogreihenfolge, wenn nicht gemischt wird', () => {
1355 const sitzung = lernstand.sitzung(profilId, { mischen: false });
1356
1357 expect(sitzung.find((s) => s.frageId === 'I.2-01')?.optionsReihenfolge).toEqual([
1358 'a',
1359 'b',
1360 'c',
1361 'd',
1362 ]);
1363 expect(sitzung.find((s) => s.frageId === 'I.1-03')?.optionsReihenfolge).toEqual([]);
1364 });
1365
1366 it('permutiert die Antwortlabels beim Mischen, ohne welche zu verlieren', () => {
1367 starten(() => 0);
1368 const sitzung = lernstand.sitzung(profilId, { mischen: true, optionenMischen: true });
1369 const labels = sitzung.find((s) => s.frageId === 'I.2-01')?.optionsReihenfolge ?? [];
1370
1371 expect([...labels].sort()).toEqual(['a', 'b', 'c', 'd']);
1372 expect(labels).not.toEqual(['a', 'b', 'c', 'd']);
1373 });
1374
1375 /*
1376 `optionenMischen` trennt die Antwortreihenfolge von der Fragenreihenfolge.
1377 Wer sich Antworten über ihre Stelle merkt, braucht sie fest – aber
1378 deswegen nicht auch noch immer dieselben Fragen zuerst.
1379 */
1380 it('lässt die Antwortlabels stehen, während die Fragen gemischt werden', () => {
1381 starten(() => 0);
1382 const sitzung = lernstand.sitzung(profilId, { mischen: true, optionenMischen: false });
1383
1384 expect(sitzung.find((s) => s.frageId === 'I.2-01')?.optionsReihenfolge).toEqual([
1385 'a',
1386 'b',
1387 'c',
1388 'd',
1389 ]);
1390 });
1391
1392 it('mischt die Antwortlabels, während die Fragen in Katalogreihenfolge bleiben', () => {
1393 starten(() => 0);
1394 const sitzung = lernstand.sitzung(profilId, { mischen: false, optionenMischen: true });
1395 const labels = sitzung.find((s) => s.frageId === 'I.2-01')?.optionsReihenfolge ?? [];
1396
1397 expect([...labels].sort()).toEqual(['a', 'b', 'c', 'd']);
1398 expect(labels).not.toEqual(['a', 'b', 'c', 'd']);
1399 });
1400
1401 it('bleibt ohne eigene Angabe in Katalogreihenfolge – auch beim Fragenmischen', () => {
1402 /* Der eigentliche Regressionsschutz. Früher fiel `optionenMischen` auf
1403 `mischen` zurück: Wer nur die Fragen mischen wollte, mischte die
1404 Antworten stillschweigend mit – die Ursache des Befundes, der zu
1405 dieser Umstellung geführt hat. Beide Fälle müssen jetzt dieselbe,
1406 amtliche Reihenfolge liefern. */
1407 starten(() => 0);
1408
1409 for (const filter of [{ mischen: false }, { mischen: true }]) {
1410 const ohne = lernstand.sitzung(profilId, filter);
1411
1412 expect(ohne.find((s) => s.frageId === 'I.2-01')?.optionsReihenfolge).toEqual([
1413 'a',
1414 'b',
1415 'c',
1416 'd',
1417 ]);
1418 }
1419 });
1420
1421 it.each([
1422 [{ kapitel: ['IX'] }, /Unbekanntes Kapitel/u],
1423 [{ abschnitte: ['I.9'] }, /Unbekannter Abschnitt/u],
1424 [{ kapitel: 'I' }, /kapitel/u],
1425 [{ anzahl: 0 }, /anzahl/u],
1426 [{ anzahl: 5000 }, /anzahl/u],
1427 [{ anzahl: 2.5 }, /anzahl/u],
1428 [{ nurNeue: 'ja' }, /nurNeue/u],
1429 ['kein Objekt', /Filter/u],
1430 ])('weist den ungültigen Filter %o zurück', (filter, muster) => {
1431 expect(() => lernstand.sitzung(profilId, filter)).toThrow(muster);
1432 });
1433
1434 it('nimmt einen fehlenden Filter als „alles, gemischt“ an', () => {
1435 expect(lernstand.sitzung(profilId, undefined)).toHaveLength(10);
1436 expect(lernstand.sitzung(profilId, {})).toHaveLength(10);
1437 });
1438 });
1439
1440 // ─── Migration auf Schema 3 ─────────────────────────────────────────────────
1441
1442 describe('Migration auf den Gedächtnisstand', () => {
1443 /** Spaltennamen einer Tabelle. */
1444 function spalten(verbindung: Database.Database, tabelle: string): string[] {
1445 return verbindung
1446 .prepare<[], { name: string }>(`PRAGMA table_info(${tabelle})`)
1447 .all()
1448 .map((z) => z.name);
1449 }
1450
1451 /**
1452 * Versetzt eine geöffnete Datenbank in den Stand vor der FSRS-Umstellung:
1453 * ohne die beiden Spalten und mit vermerkter Schema-Version 2.
1454 */
1455 function aufVersionZwei(verbindung: Database.Database): void {
1456 /* SQLite kann Spalten erst seit 3.35 löschen – vorhanden, aber der
1457 ausdrückliche Weg über eine neue Tabelle ist hier ohnehin näher an dem,
1458 was eine echte alte Installation enthält. */
1459 verbindung.exec(`
1460 CREATE TABLE frage_stand_alt (
1461 profil_id INTEGER NOT NULL,
1462 frage_id TEXT NOT NULL,
1463 versuche INTEGER NOT NULL DEFAULT 0,
1464 richtige INTEGER NOT NULL DEFAULT 0,
1465 zuletzt_beantwortet TEXT,
1466 faellig_ab TEXT,
1467 gemerkt INTEGER NOT NULL DEFAULT 0,
1468 letzte_bewertung TEXT,
1469 intervall_tage REAL NOT NULL DEFAULT 0,
1470 PRIMARY KEY (profil_id, frage_id)
1471 );
1472 INSERT INTO frage_stand_alt
1473 SELECT profil_id, frage_id, versuche, richtige, zuletzt_beantwortet,
1474 faellig_ab, gemerkt, letzte_bewertung, intervall_tage
1475 FROM frage_stand;
1476 DROP TABLE frage_stand;
1477 ALTER TABLE frage_stand_alt RENAME TO frage_stand;
1478 `);
1479 verbindung.prepare('DELETE FROM schema_version WHERE version > 2').run();
1480 }
1481
1482 it('ergänzt die Spalten, ohne einen bestehenden Lernstand zu verlieren', () => {
1483 antworten('I.1-01', 'gut', ['a', 'c']);
1484 lernstand.merken(profilId, 'I.1-02', true);
1485 const vorher = lernstand.frageStand(profilId, 'I.1-01');
1486
1487 aufVersionZwei(db);
1488 expect(spalten(db, 'frage_stand')).not.toContain('stabilitaet');
1489
1490 const nachher = new Lernstand(db, KATALOG, { jetzt: () => uhr });
1491
1492 expect(spalten(db, 'frage_stand')).toContain('stabilitaet');
1493 expect(spalten(db, 'frage_stand')).toContain('schwierigkeit');
1494 expect(
1495 db
1496 .prepare<[], { version: number }>('SELECT MAX(version) AS version FROM schema_version')
1497 .get()?.version,
1498 ).toBe(SCHEMA_VERSION);
1499
1500 expect(nachher.frageStand(profilId, 'I.1-01')).toMatchObject({
1501 versuche: vorher.versuche,
1502 richtige: vorher.richtige,
1503 faelligAb: vorher.faelligAb,
1504 });
1505 expect(nachher.frageStand(profilId, 'I.1-02').gemerkt).toBe(true);
1506 });
1507
1508 it('nimmt eine Frage ohne Gedächtnisstand als neu, ohne den Zähler zu verlieren', () => {
1509 // Eine Frage aus der Zeit vor der Umstellung hat Versuche, aber keine
1510 // Stabilität. Sie darf deshalb nicht als „nie beantwortet“ verschwinden.
1511 antworten('I.1-01', 'leicht', ['a', 'c']);
1512 aufVersionZwei(db);
1513
1514 const nachher = new Lernstand(db, KATALOG, { jetzt: () => uhr });
1515 const stand = nachher.antworten(profilId, {
1516 frageId: 'I.1-01',
1517 auswahl: ['a', 'c'],
1518 richtig: true,
1519 bewertung: 'gut',
1520 dauerMs: 1000,
1521 });
1522
1523 expect(stand.versuche).toBe(2);
1524 expect(stand.richtige).toBe(2);
1525 // Anfangsstabilität für „gut“ sind 2,3 Tage – aufgerundet zwei.
1526 expect(faelligInTagen(stand.faelligAb)).toBe(2);
1527 });
1528
1529 it('lässt sich beliebig oft öffnen, auch wenn die Version zurückgesetzt wurde', () => {
1530 /* Ein Schritt, der beim zweiten Lauf scheitert, würde die Anwendung beim
1531 Start abstürzen lassen – ohne Weg zurück für den Nutzer. */
1532 antworten('I.1-01', 'gut', ['a', 'c']);
1533
1534 for (let i = 0; i < 3; i += 1) {
1535 db.prepare('DELETE FROM schema_version WHERE version > 1').run();
1536 expect(() => new Lernstand(db, KATALOG, { jetzt: () => uhr })).not.toThrow();
1537 }
1538
1539 expect(new Lernstand(db, KATALOG, { jetzt: () => uhr }).uebersicht(profilId).beantwortet).toBe(
1540 1,
1541 );
1542 });
1543
1544 it('weist eine Datenbank aus einer neueren Programmversion ab', () => {
1545 db.prepare('INSERT OR IGNORE INTO schema_version (version, angewendet_am) VALUES (?, ?)').run(
1546 SCHEMA_VERSION + 1,
1547 new Date().toISOString(),
1548 );
1549
1550 expect(() => new Lernstand(db, KATALOG, { jetzt: () => uhr })).toThrow(
1551 /neueren Programmversion/u,
1552 );
1553 });
1554
1555 it('fasst einen Lernstand aus neuerer Version gar nicht erst an', () => {
1556 /* Die neuere Fassung muss ihn danach noch öffnen können. Würde erst das
1557 Schema angewandt und dann abgewiesen, hätte die ältere Fassung bereits
1558 hineingeschrieben – WAL-Modus eingeschaltet, Tabellen angelegt. */
1559 const fremd = new Database(':memory:');
1560 verbindungen.push(fremd);
1561 fremd.exec(`
1562 CREATE TABLE schema_version (version INTEGER NOT NULL PRIMARY KEY, angewendet_am TEXT);
1563 INSERT INTO schema_version VALUES (${String(SCHEMA_VERSION + 1)}, '2026-01-01');
1564 `);
1565
1566 expect(() => new Lernstand(fremd, KATALOG, { jetzt: () => uhr })).toThrow(
1567 /neueren Programmversion/u,
1568 );
1569
1570 const tabellen = fremd
1571 .prepare<[], { name: string }>("SELECT name FROM sqlite_master WHERE type = 'table'")
1572 .all()
1573 .map((z) => z.name);
1574 expect(tabellen).toEqual(['schema_version']);
1575 });
1576
1577 it('nimmt eine noch leere Datei an, in der es nichts zu schützen gibt', () => {
1578 const leer = new Database(':memory:');
1579 verbindungen.push(leer);
1580
1581 expect(() => new Lernstand(leer, KATALOG, { jetzt: () => uhr })).not.toThrow();
1582 });
1583 });
1584
1585 // ─── Katalogstand-Abgleich (Schema 9) ───────────────────────────────────────
1586
1587 describe('Katalogstand-Abgleich', () => {
1588 /*
1589 Eine neue BVA-Fassung kann Fragen umnummerieren oder streichen; Zeilen in
1590 `frage_stand` und `antwort_log` zeigten dann still ins Leere. Ein echter
1591 Migrationspfad ist ohne die künftige Fassung nicht baubar – was sich bauen
1592 lässt, ist Ehrlichkeit: erkennen, beziffern, melden, nichts löschen.
1593 */
1594
1595 /** Derselbe Katalog mit anderem Stand und (wahlweise) anderer Fragenliste. */
1596 function katalogFassung(stand: string, fragen: readonly Frage[] = FRAGEN): Katalog {
1597 return {
1598 ...KATALOG,
1599 meta: { ...KATALOG.meta, stand, fragen_gesamt: fragen.length },
1600 fragen,
1601 };
1602 }
1603
1604 it('schweigt, solange der Stand unverändert ist', () => {
1605 expect(lernstand.katalogwechsel).toBeNull();
1606
1607 const zweite = new Lernstand(db, KATALOG, { jetzt: () => uhr });
1608
1609 expect(zweite.katalogwechsel).toBeNull();
1610 /* Und es entsteht kein zweiter Vermerk – die Tabelle wüchse sonst mit
1611 jedem Start, und ihr Verlauf sagte nichts mehr. */
1612 expect(db.prepare<[], { n: number }>('SELECT COUNT(*) AS n FROM katalog_stand').get()?.n).toBe(
1613 1,
1614 );
1615 });
1616
1617 it('meldet einen Wechsel und zählt die verwaisten Zeilen', () => {
1618 /* Zwei Antworten auf I.1-01, eine auf II-01, eine Merkzeile zu IV-01. */
1619 antworten('I.1-01', 'gut', ['a', 'c']);
1620 tageWeiter(1);
1621 antworten('I.1-01', 'gut', ['a', 'c']);
1622 antworten('II-01', 'gut', ['a']);
1623 lernstand.merken(profilId, 'IV-01', true);
1624
1625 /* Die neue Fassung kennt I.1-01 und IV-01 nicht mehr. */
1626 const neueFragen = FRAGEN.filter((f) => f.id !== 'I.1-01' && f.id !== 'IV-01');
1627 const zweite = new Lernstand(db, katalogFassung('2025-06-01', neueFragen), {
1628 jetzt: () => uhr,
1629 });
1630
1631 expect(zweite.katalogwechsel).toEqual({
1632 vorher: '2024-12-16',
1633 nachher: '2025-06-01',
1634 // I.1-01 (beantwortet) und IV-01 (nur gemerkt) – je eine Standzeile.
1635 verwaisteStaende: 2,
1636 // Die beiden Protokollzeilen zu I.1-01; II-01 gibt es weiterhin.
1637 verwaisteAntworten: 2,
1638 });
1639 });
1640
1641 it('löscht die verwaisten Zeilen nicht', () => {
1642 antworten('I.1-01', 'gut', ['a', 'c']);
1643
1644 const neueFragen = FRAGEN.filter((f) => f.id !== 'I.1-01');
1645 void new Lernstand(db, katalogFassung('2025-06-01', neueFragen), { jetzt: () => uhr });
1646
1647 /* Eine spätere Programmfassung kann die Zeilen vielleicht noch zuordnen –
1648 gelöscht kann sie es sicher nicht mehr. */
1649 expect(
1650 db
1651 .prepare<[], { n: number }>(
1652 "SELECT COUNT(*) AS n FROM antwort_log WHERE frage_id = 'I.1-01'",
1653 )
1654 .get()?.n,
1655 ).toBe(1);
1656 expect(
1657 db
1658 .prepare<[], { n: number }>(
1659 "SELECT COUNT(*) AS n FROM frage_stand WHERE frage_id = 'I.1-01'",
1660 )
1661 .get()?.n,
1662 ).toBe(1);
1663 });
1664
1665 it('übernimmt den neuen Stand nach dem Befund und meldet ihn danach nicht erneut', () => {
1666 const fassung = katalogFassung('2025-06-01');
1667
1668 const zweite = new Lernstand(db, fassung, { jetzt: () => uhr });
1669 expect(zweite.katalogwechsel).not.toBeNull();
1670
1671 /* Der Wechsel ist jetzt vermerkt: Der nächste Start findet Gleichstand
1672 vor und schweigt – die Meldung kommt genau einmal. */
1673 const dritte = new Lernstand(db, fassung, { jetzt: () => uhr });
1674 expect(dritte.katalogwechsel).toBeNull();
1675 });
1676
1677 it('meldet auch einen Wechsel, bei dem nichts verwaist', () => {
1678 /* Gleiche Fragen, neuer Stand – etwa eine Fassung, die nur Druckfehler
1679 berichtigt. Der Wechsel bleibt meldenswert, die Zählungen sagen dann
1680 ehrlich: nichts betroffen. */
1681 antworten('I.1-01', 'gut', ['a', 'c']);
1682
1683 const zweite = new Lernstand(db, katalogFassung('2025-06-01'), { jetzt: () => uhr });
1684
1685 expect(zweite.katalogwechsel).toEqual({
1686 vorher: '2024-12-16',
1687 nachher: '2025-06-01',
1688 verwaisteStaende: 0,
1689 verwaisteAntworten: 0,
1690 });
1691 });
1692 });
1693
1694 describe('Dauerhaft abgewählte Kapitel', () => {
1695 /*
1696 Kapitel IV („Not- und Seenotsignalmittel") prüft nicht jede
1697 Prüfungsstelle. Wer es nie braucht, schleppte 89 der 575 Fragen durch jede
1698 Zahl der Anwendung: Fortschritt, Tagespensum, Prognose, Prüfungsreife.
1699
1700 Die Kartierung vor dem Bau hat gewarnt, dass eine grüne Suite hier fast
1701 nichts beweist – die meisten Stellen mit „575" sind Attrappenwerte. Diese
1702 Tests laufen deshalb durch den echten Kern, gegen eine echte Datenbank.
1703 */
1704 it('beginnt ohne Abwahl', () => {
1705 expect(lernstand.profile()[0]?.kapitelAusschluss).toEqual([]);
1706 expect(lernstand.uebersicht(profilId).fragenGesamt).toBe(10);
1707 });
1708
1709 it('nimmt abgewählte Fragen aus jeder Lernsitzung', () => {
1710 lernstand.profilAktualisieren({ id: profilId, kapitelAusschluss: ['IV'] });
1711
1712 const alle = lernstand.sitzung(profilId, { mischen: false }).map((s) => s.frageId);
1713 expect(alle).toHaveLength(8);
1714 expect(alle.some((id) => id.startsWith('IV-'))).toBe(false);
1715 });
1716
1717 it('nimmt sie auch aus „nur neue" und „nur gemerkte"', () => {
1718 lernstand.merken(profilId, 'IV-01', true);
1719 lernstand.profilAktualisieren({ id: profilId, kapitelAusschluss: ['IV'] });
1720
1721 expect(lernstand.sitzung(profilId, { nurNeue: true }).map((s) => s.frageId)).not.toContain(
1722 'IV-01',
1723 );
1724 expect(lernstand.sitzung(profilId, { nurGemerkte: true })).toEqual([]);
1725 });
1726
1727 it('nimmt sie aus „nur Fehler" – gerade dort ist es nötig', () => {
1728 /*
1729 Die Quelle von „nur Fehler" ist antwort_log, und dort landen auch
1730 Antworten aus Prüfungsläufen, die Kapitel IV enthielten. Ohne
1731 Vorfilterung legte ausgerechnet dieser Weg die abgewählten Fragen wieder
1732 vor – und zwar genau die, die man zwangsläufig falsch hatte.
1733 */
1734 antworten('IV-01', 'nochmal', ['b'], false);
1735 expect(lernstand.sitzung(profilId, { nurFehler: true }).map((s) => s.frageId)).toContain(
1736 'IV-01',
1737 );
1738
1739 lernstand.profilAktualisieren({ id: profilId, kapitelAusschluss: ['IV'] });
1740 expect(lernstand.sitzung(profilId, { nurFehler: true })).toEqual([]);
1741 });
1742
1743 it('rechnet die Übersicht ohne sie – Nenner UND Zähler', () => {
1744 /* Nur den Nenner zu filtern wäre der teure Fehler: Ein Bildschirmleser
1745 läse dann „10 von 8 Fragen sicher". */
1746 antworten('IV-01', 'leicht', ['a'], true);
1747 tageWeiter(2);
1748 antworten('IV-01', 'leicht', ['a'], true);
1749 expect(lernstand.uebersicht(profilId)).toMatchObject({ fragenGesamt: 10, belegt: 1 });
1750
1751 lernstand.profilAktualisieren({ id: profilId, kapitelAusschluss: ['IV'] });
1752 const nachher = lernstand.uebersicht(profilId);
1753 expect(nachher.fragenGesamt).toBe(8);
1754 expect(nachher.belegt).toBe(0);
1755 expect(nachher.beantwortet).toBe(0);
1756 });
1757
1758 it('lässt das Kapitel aus der Bereichsaufschlüsselung verschwinden', () => {
1759 /* Bliebe es mit 0 stehen, summierten sich die Bereiche zu 10, während
1760 fragenGesamt 8 sagt – zwei Zahlen auf einem Bildschirm, die einander
1761 widersprechen. */
1762 lernstand.profilAktualisieren({ id: profilId, kapitelAusschluss: ['IV'] });
1763 const bereiche = lernstand.uebersicht(profilId).bereiche;
1764
1765 expect(bereiche.map((b) => b.id)).toEqual(['I.1', 'I.2', 'II']);
1766 expect(bereiche.reduce((summe, b) => summe + b.fragenGesamt, 0)).toBe(
1767 lernstand.uebersicht(profilId).fragenGesamt,
1768 );
1769 });
1770
1771 it('rechnet den Lernplan ohne sie', () => {
1772 const vorher = lernstand.lernplan(profilId);
1773 expect(vorher.gesamtFragen).toBe(10);
1774
1775 lernstand.profilAktualisieren({ id: profilId, kapitelAusschluss: ['IV'] });
1776 const nachher = lernstand.lernplan(profilId);
1777
1778 /* ENTFERNT, nicht neutralisiert: Wer die Fragen mit stabilitaet null
1779 durchreichte, ließe sie im Nenner von reifegradVon stehen – die
1780 Prognose bliebe exakt gleich, und die Zahl sähe trotzdem plausibel aus. */
1781 expect(nachher.gesamtFragen).toBe(8);
1782 expect(nachher.nieBeantwortet).toBe(8);
1783 });
1784
1785 it('blendet aus, statt zu löschen – und alles kehrt zurück', () => {
1786 antworten('IV-01', 'leicht', ['a'], true);
1787 tageWeiter(2);
1788 antworten('IV-01', 'leicht', ['a'], true);
1789 lernstand.merken(profilId, 'IV-02', true);
1790
1791 lernstand.profilAktualisieren({ id: profilId, kapitelAusschluss: ['IV'] });
1792 expect(lernstand.uebersicht(profilId).belegt).toBe(0);
1793
1794 lernstand.profilAktualisieren({ id: profilId, kapitelAusschluss: [] });
1795 const zurueck = lernstand.uebersicht(profilId);
1796 expect(zurueck.fragenGesamt).toBe(10);
1797 expect(zurueck.belegt).toBe(1);
1798 expect(zurueck.gemerkt).toBe(1);
1799 });
1800
1801 it('gilt je Profil, nicht für die Anwendung', () => {
1802 const zweites = lernstand.profilAnlegen('Wassersportlerin');
1803
1804 lernstand.profilAktualisieren({ id: profilId, kapitelAusschluss: ['IV'] });
1805
1806 expect(lernstand.uebersicht(profilId).fragenGesamt).toBe(8);
1807 expect(lernstand.uebersicht(zweites.id).fragenGesamt).toBe(10);
1808 });
1809
1810 it('weist ein unbekanntes Kapitel ab', () => {
1811 expect(() =>
1812 lernstand.profilAktualisieren({ id: profilId, kapitelAusschluss: ['XI'] }),
1813 ).toThrow(/Unbekanntes Kapitel/u);
1814 expect(() => lernstand.profilAktualisieren({ id: profilId, kapitelAusschluss: 'IV' })).toThrow(
1815 /muss eine Liste sein/u,
1816 );
1817 });
1818
1819 it('speichert in Katalogreihenfolge und ohne Doppelte', () => {
1820 /* Was gespeichert wird, soll nicht davon abhängen, in welcher Reihenfolge
1821 jemand geklickt hat. */
1822 const profil = lernstand.profilAktualisieren({
1823 id: profilId,
1824 kapitelAusschluss: ['IV', 'II', 'IV'],
1825 });
1826 expect(profil.kapitelAusschluss).toEqual(['II', 'IV']);
1827 });
1828
1829 it('rührt Zurücksetzen und Tagesbilanz nicht an', () => {
1830 /* Die Tagesbilanz ist ein Protokoll dessen, was jemand getan hat – keine
1831 Bestandszahl. Wer heute eine Kapitel-IV-Frage beantwortet und danach
1832 abwählt, sieht sie dort weiterhin. */
1833 antworten('IV-01', 'leicht', ['a'], true);
1834 lernstand.profilAktualisieren({ id: profilId, kapitelAusschluss: ['IV'] });
1835
1836 expect(lernstand.uebersicht(profilId).heuteRichtig).toBe(1);
1837 expect(() => lernstand.zuruecksetzen(profilId, 'IV')).not.toThrow();
1838 });
1839 });
1840
1841 describe('Tagesbilanz und Tageszahlen', () => {
1842 /*
1843 Die Tagesbilanz war verzerrt: Lief in der Prüfungssimulation die Zeit ab,
1844 buchte `abgeben` auch die nie aufgeschlagenen Fragen in die Historie – als
1845 falsch. Sie gehören dorthin, der Bogen enthielt sie ja; eine Antwort sind
1846 sie nicht.
1847 */
1848 it('zählt nie aufgeschlagene Prüfungsfragen nicht als heute falsch beantwortet', () => {
1849 antworten('I.1-01', 'gut', ['a', 'c']);
1850
1851 /* So bucht `Pruefung.abgeben` eine Frage, die im Bogen stand, aber nie
1852 gezeigt wurde. */
1853 lernstand.protokollieren(profilId, {
1854 frageId: 'I.1-02',
1855 auswahl: [],
1856 richtig: false,
1857 bewertung: 'nochmal',
1858 dauerMs: 1000,
1859 });
1860
1861 const uebersicht = lernstand.uebersicht(profilId);
1862
1863 expect(uebersicht.heuteRichtig).toBe(1);
1864 expect(uebersicht.heuteFalsch).toBe(0);
1865 });
1866
1867 /*
1868 Dieselbe Verunreinigung, drei Stellen weiter: `letzteAntwortKarte` filterte
1869 `nur_historie` nicht heraus. Die Folge war eine Zahl, ein Filter und ein
1870 gedrucktes Blatt, die alle dasselbe behaupteten – der Lernende habe eine
1871 Frage falsch beantwortet, die er nie gesehen hat. Ein 80-Fragen-Bogen mit
1872 Zeitablauf nach Frage 20 erzeugte so 60 Fehler.
1873 */
1874 it('stellt eine heute Abend fällige Frage unter „heute“, nicht unter „morgen“', () => {
1875 /*
1876 Die Vorschau beschriftet ihre Spalten als Kalendertage („heute“,
1877 „morgen“, danach Wochentag und Datum). Bis 0.24.1 rechnete sie in
1878 Vierundzwanzig-Stunden-Blöcken: Wer abends lernte und morgens plante,
1879 sah jeden Tag die Last des Vortages.
1880
1881 Hier nachgestellt: Antwort am Abend, Wiedervorlage am Folgetag zur
1882 selben Stunde – geplant wird am Morgen dieses Folgetags.
1883 */
1884 uhr = new Date('2026-03-02T20:00:00.000Z');
1885 const stand = antworten('I.1-01', 'gut', ['a', 'c']);
1886 const termin = new Date(stand.faelligAb ?? '');
1887 expect(Number.isNaN(termin.getTime())).toBe(false);
1888
1889 /* Am Morgen des Fälligkeitstages: Die Frage wird heute Abend fällig. */
1890 uhr = new Date(termin.getFullYear(), termin.getMonth(), termin.getDate(), 8, 0, 0);
1891 expect(uhr.getTime()).toBeLessThan(termin.getTime());
1892 const plan = lernstand.lernplan(profilId);
1893
1894 expect(plan.vorschau?.[0], 'Die heute fällige Frage steht nicht in der Spalte „heute“.').toBe(
1895 1,
1896 );
1897 expect(plan.vorschau?.[1]).toBe(0);
1898 });
1899
1900 it('lässt nie aufgeschlagene Prüfungsfragen aus der Tempo-Schätzung heraus', () => {
1901 /*
1902 `dauer_ms` einer Historienzeile ist keine gemessene Zeit, sondern
1903 Gesamtdauer geteilt durch Fragenzahl. Bis 0.24.1 ging sie in den Median
1904 ein und verschob die Zeitschätzung des Tagespensums.
1905 */
1906 for (const frage of ['I.1-01', 'I.1-02', 'I.1-03', 'I.2-01', 'I.2-02']) {
1907 lernstand.antworten(profilId, {
1908 frageId: frage,
1909 auswahl: [],
1910 richtig: true,
1911 bewertung: 'gut',
1912 dauerMs: 10_000,
1913 });
1914 }
1915 const echtesTempo = lernstand.lernplan(profilId).sekundenProFrage;
1916
1917 /* Sechzig erfundene Werte von je einer Sekunde – so sieht ein
1918 abgelaufener Bogen aus. */
1919 for (let i = 0; i < 60; i++) {
1920 lernstand.protokollieren(profilId, {
1921 frageId: 'II-01',
1922 auswahl: [],
1923 richtig: false,
1924 bewertung: 'nochmal',
1925 dauerMs: 1000,
1926 });
1927 }
1928
1929 expect(lernstand.lernplan(profilId).sekundenProFrage).toBe(echtesTempo);
1930 });
1931
1932 it('zählt nie aufgeschlagene Prüfungsfragen nicht als „zuletzt falsch beantwortet“', () => {
1933 antworten('I.1-01', 'nochmal', ['b']); // wirklich falsch beantwortet
1934
1935 lernstand.protokollieren(profilId, {
1936 frageId: 'I.1-02',
1937 auswahl: [],
1938 richtig: false,
1939 bewertung: 'nochmal',
1940 dauerMs: 1000,
1941 });
1942
1943 expect(lernstand.uebersicht(profilId).fehler).toBe(1);
1944 expect(
1945 lernstand.sitzung(profilId, { nurFehler: true, mischen: false }).map((s) => s.frageId),
1946 ).toEqual(['I.1-01']);
1947 });
1948
1949 it('lässt eine echte spätere Antwort auf dieselbe Frage weiterhin gelten', () => {
1950 /* Die Gegenprobe: Der Ausschluss darf nur Historienzeilen treffen, nicht
1951 die Frage stumm schalten. */
1952 lernstand.protokollieren(profilId, {
1953 frageId: 'I.1-01',
1954 auswahl: [],
1955 richtig: false,
1956 bewertung: 'nochmal',
1957 dauerMs: 1000,
1958 });
1959 antworten('I.1-01', 'nochmal', ['b']);
1960
1961 expect(
1962 lernstand.sitzung(profilId, { nurFehler: true, mischen: false }).map((s) => s.frageId),
1963 ).toEqual(['I.1-01']);
1964 });
1965
1966 it('lässt die Historie trotzdem vollständig', () => {
1967 /* Der Eintrag verschwindet nicht – er zählt nur nicht als Antwort des
1968 Tages. Die Historie ist die Grundlage für Statistik und für ein
1969 späteres Nachtrainieren der FSRS-Parameter. */
1970 lernstand.protokollieren(profilId, {
1971 frageId: 'I.1-02',
1972 auswahl: [],
1973 richtig: false,
1974 bewertung: 'nochmal',
1975 dauerMs: 1000,
1976 });
1977
1978 const zeilen = db
1979 .prepare<[], { anzahl: number }>('SELECT COUNT(*) AS anzahl FROM antwort_log')
1980 .get();
1981 expect(zeilen?.anzahl).toBe(1);
1982 });
1983
1984 it('zählt heute bearbeitete Fragen einmal, nicht je Antwort', () => {
1985 /*
1986 Eine mit „Nicht gewusst“ bewertete Frage ist sofort wieder fällig und
1987 wird in derselben Sitzung noch einmal beantwortet. Als Zeilen im
1988 Protokoll wären das zwei; bearbeitet wurde eine Frage.
1989 */
1990 antworten('I.1-01', 'nochmal', ['b'], false);
1991 antworten('I.1-01', 'gut', ['a', 'c']);
1992 antworten('I.1-02', 'gut', ['a']);
1993
1994 expect(lernstand.uebersicht(profilId).heuteBearbeitet).toBe(2);
1995 });
1996
1997 it('rechnet den Abstand in Kalendertagen, nicht in Stunden', () => {
1998 /* Wer gestern abend und heute früh lernt, hat nicht zwei Tage Abstand. */
1999 antworten('I.1-01', 'gut', ['a', 'c']);
2000 expect(lernstand.uebersicht(profilId).tageSeitLetzterAntwort).toBe(0);
2001
2002 tageWeiter(1);
2003 expect(lernstand.uebersicht(profilId).tageSeitLetzterAntwort).toBe(1);
2004
2005 tageWeiter(13);
2006 expect(lernstand.uebersicht(profilId).tageSeitLetzterAntwort).toBe(14);
2007 expect(lernstand.uebersicht(profilId).heuteBearbeitet).toBe(0);
2008 });
2009
2010 it('meldet „nie beantwortet“ als null und nicht als null Tage', () => {
2011 /* Der Unterschied zwischen „heute gelernt“ und „noch nie gelernt“ darf
2012 nicht verlorengehen – beides wäre sonst die Zahl 0. */
2013 expect(lernstand.uebersicht(profilId).tageSeitLetzterAntwort).toBeNull();
2014 });
2015 });