waffensachkunde

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

/ app tests lernstand.test.ts

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