waffensachkunde

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

/ app tests lernstand.test.ts

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