waffensachkunde

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

/ app tests lernstand.test.ts

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