// @vitest-environment node // Der Lernstand läuft im Main-Prozess gegen echtes SQLite. Getestet wird // gegen eine `:memory:`-Datenbank – dieselbe Bibliothek, dasselbe Schema, // nur ohne Datei auf der Platte. Es wird nichts nachgebildet. import Database from 'better-sqlite3'; import { afterEach, beforeEach, describe, expect, it } from 'vitest'; import { Lernstand } from '../src/main/lernstand'; import { SCHEMA_VERSION } from '../src/main/schema'; import type { Antwortoption, Frage, Katalog } from '../src/shared/katalog'; import type { Bewertung } from '../src/shared/lernstand'; import type { Themen } from '../src/shared/themen'; const TAG_MS = 86_400_000; // ─── Prüfkatalog ──────────────────────────────────────────────────────────── function option(label: string, korrekt: boolean): Antwortoption { return { label, inhalt: { text: `Antwort ${label}`, segmente: [{ t: `Antwort ${label}` }] }, korrekt, bilder: [], }; } function mcFrage( id: string, kapitel: string, abschnitt: string | null, labels: readonly string[], korrekt: readonly string[], ): Frage { return { id, amtliche_nummer: id, kapitel, abschnitt, typ: 'mc', seite: 1, frage: { text: `Frage ${id}`, segmente: [{ t: `Frage ${id}` }] }, bilder: [], optionen: labels.map((label) => option(label, korrekt.includes(label))), }; } function freitextFrage(id: string, kapitel: string, abschnitt: string | null): Frage { return { id, amtliche_nummer: id, kapitel, abschnitt, typ: 'freitext', seite: 1, frage: { text: `Frage ${id}`, segmente: [{ t: `Frage ${id}` }] }, bilder: [], musterantwort: { text: 'Musterantwort', segmente: [{ t: 'Musterantwort', h: true }] }, }; } const FRAGEN: readonly Frage[] = [ mcFrage('I.1-01', 'I', 'I.1', ['a', 'b', 'c'], ['a', 'c']), mcFrage('I.1-02', 'I', 'I.1', ['a', 'b'], ['b']), freitextFrage('I.1-03', 'I', 'I.1'), mcFrage('I.2-01', 'I', 'I.2', ['a', 'b', 'c', 'd'], ['d']), freitextFrage('I.2-02', 'I', 'I.2'), mcFrage('II-01', 'II', null, ['a', 'b'], ['a']), mcFrage('II-02', 'II', null, ['a', 'b', 'c'], ['b']), freitextFrage('II-03', 'II', null), /* Kapitel IV steht hier ausschließlich für die dauerhafte Abwahl. Ohne es ließe sich gar nichts abwählen, und jeder Test dazu bestünde scheinbar und prüfte nichts. */ mcFrage('IV-01', 'IV', null, ['a', 'b'], ['a']), mcFrage('IV-02', 'IV', null, ['a', 'b'], ['b']), ]; const KATALOG: Katalog = { meta: { titel: 'Prüfkatalog', herausgeber: 'Bundesverwaltungsamt', stand: '2024-12-16', quellenangabe: 'Amtlicher Fragenkatalog, Stand 16.12.2024.', quelle_url: 'https://www.bva.bund.de/', quelldatei_sha256: 'a'.repeat(64), fragen_gesamt: FRAGEN.length, }, kapitel: [ { id: 'I', titel: 'Waffenrecht', abschnitte: [ { id: 'I.1', titel: 'Begriffe des Waffenrechts' }, { id: 'I.2', titel: 'Rechte und Pflichten' }, ], }, { id: 'II', titel: 'Waffentechnik', abschnitte: [] }, { id: 'IV', titel: 'Not- und Seenotsignalmittel', abschnitte: [] }, ], bilder: [], fragen: FRAGEN, }; // ─── Prüfstand ────────────────────────────────────────────────────────────── const START = '2026-03-01T10:00:00.000Z'; let uhr: Date; let db: Database.Database; let lernstand: Lernstand; let profilId: number; /** Alle in einem Testfall geöffneten Verbindungen, damit keine offen bleibt. */ let verbindungen: Database.Database[] = []; /** Verschiebt die Uhr um ganze Tage; alle Fälligkeiten werden dagegen gerechnet. */ function tageWeiter(tage: number): void { uhr = new Date(uhr.getTime() + tage * TAG_MS); } /** Startet einen frischen Lernstand. Darf innerhalb eines Tests erneut laufen. */ function starten(zufall?: () => number): void { uhr = new Date(START); db = new Database(':memory:'); verbindungen.push(db); lernstand = new Lernstand(db, KATALOG, { jetzt: () => uhr, ...(zufall === undefined ? {} : { zufall }), }); profilId = lernstand.profile()[0]!.id; } function antworten( frageId: string, bewertung: Bewertung, auswahl: readonly string[] = [], richtig = true, ): ReturnType { return lernstand.antworten(profilId, { frageId, auswahl, richtig, bewertung, dauerMs: 1500, }); } /** Abstand zwischen Antwortzeitpunkt und Wiedervorlage in Tagen. */ function faelligInTagen(faelligAb: string | null): number { return (new Date(faelligAb ?? 0).getTime() - uhr.getTime()) / TAG_MS; } beforeEach(() => { verbindungen = []; starten(); }); afterEach(() => { for (const verbindung of verbindungen) { if (verbindung.open) { verbindung.close(); } } }); // ─── Profile ──────────────────────────────────────────────────────────────── describe('Profile', () => { it('legt beim ersten Start automatisch das Profil „Standard“ an', () => { const profile = lernstand.profile(); expect(profile).toHaveLength(1); expect(profile[0]?.name).toBe('Standard'); expect(profile[0]?.pruefungstermin).toBeNull(); expect(profile[0]?.erstelltAm).toBe(START); }); it('legt weitere Profile an und behält die Reihenfolge', () => { lernstand.profilAnlegen(' Olaf '); lernstand.profilAnlegen('Zweitprüfling'); expect(lernstand.profile().map((p) => p.name)).toEqual(['Standard', 'Olaf', 'Zweitprüfling']); }); it('weist einen doppelten Profilnamen zurück', () => { lernstand.profilAnlegen('Olaf'); expect(() => lernstand.profilAnlegen('Olaf')).toThrow(/bereits ein Profil/u); }); it.each([[''], [' '], [null], [42], [{ name: 'Olaf' }]])( 'weist den ungültigen Profilnamen %o zurück', (name) => { expect(() => lernstand.profilAnlegen(name)).toThrow(); }, ); it('weist einen zu langen Profilnamen zurück', () => { expect(() => lernstand.profilAnlegen('x'.repeat(61))).toThrow(/höchstens 60 Zeichen/u); }); it('setzt und löscht den Prüfungstermin', () => { expect( lernstand.profilAktualisieren({ id: profilId, pruefungstermin: '2026-09-30' }), ).toMatchObject({ pruefungstermin: '2026-09-30', }); expect(lernstand.profilAktualisieren({ id: profilId, pruefungstermin: null })).toMatchObject({ pruefungstermin: null, kapitelAusschluss: [], }); }); it('lässt den Prüfungstermin unangetastet, wenn er nicht mitgeschickt wird', () => { lernstand.profilAktualisieren({ id: profilId, pruefungstermin: '2026-09-30' }); const nachher = lernstand.profilAktualisieren({ id: profilId, name: 'Olaf' }); expect(nachher).toMatchObject({ name: 'Olaf', pruefungstermin: '2026-09-30' }); }); it.each([ ['30.09.2026'], ['2026-9-30'], ['morgen'], [20260930], /* Tage, die es nicht gibt. Sie sehen wie gültige ISO-Daten aus, und `Date.parse` rechnet sie stillschweigend in den Folgemonat um – ein Prüfungstermin, der sich beim Speichern verschiebt, wäre schlimmer als eine Abweisung. */ ['2026-02-30'], ['2025-02-29'], ['2026-04-31'], ['2026-13-01'], ])('weist den ungültigen Prüfungstermin %o zurück', (termin) => { expect(() => lernstand.profilAktualisieren({ id: profilId, pruefungstermin: termin })).toThrow( /Prüfungstermin/u, ); }); it('nimmt einen echten Schalttag an', () => { // Die Korrektur darf den 29. Februar eines Schaltjahres nicht mitreißen. expect( lernstand.profilAktualisieren({ id: profilId, pruefungstermin: '2024-02-29' }), ).toMatchObject({ pruefungstermin: '2024-02-29' }); }); it.each([[999], [0], [-1], ['1'], [1.5], [null]])( 'weist die unbekannte oder ungültige Profil-ID %o zurück', (id) => { expect(() => lernstand.uebersicht(id)).toThrow(); }, ); }); // ─── Antworten protokollieren ─────────────────────────────────────────────── describe('Antworten protokollieren', () => { it('schreibt das Protokoll und schreibt den Fragenstand fort', () => { const stand = antworten('I.1-01', 'gut', ['a', 'c']); expect(stand).toMatchObject({ frageId: 'I.1-01', versuche: 1, richtige: 1, zuletztBeantwortet: START, gemerkt: false, letzteBewertung: 'gut', }); const zeilen = db .prepare('SELECT frage_id, richtig, bewertung, dauer_ms, auswahl, freitext FROM antwort_log') .all() as { frage_id: string; richtig: number; bewertung: string; dauer_ms: number; auswahl: string; freitext: string | null; }[]; expect(zeilen).toHaveLength(1); expect(zeilen[0]).toEqual({ frage_id: 'I.1-01', richtig: 1, bewertung: 'gut', dauer_ms: 1500, auswahl: '["a","c"]', freitext: null, }); }); it('zählt Versuche und richtige Antworten über mehrere Durchgänge', () => { antworten('I.1-01', 'nochmal', ['a']); antworten('I.1-01', 'schwer', ['a', 'c']); const stand = antworten('I.1-01', 'gut', ['a', 'c']); expect(stand).toMatchObject({ versuche: 3, richtige: 2 }); expect(db.prepare('SELECT COUNT(*) AS n FROM antwort_log').get()).toEqual({ n: 3 }); }); it('rechnet Multiple Choice selbst nach und glaubt dem Renderer nicht', () => { // Der Renderer behauptet „richtig“, obwohl die Auswahl unvollständig ist. const stand = antworten('I.1-01', 'gut', ['a'], true); expect(stand.richtige).toBe(0); expect(db.prepare('SELECT richtig FROM antwort_log').get()).toEqual({ richtig: 0 }); }); it('wertet eine Auswahl mit zu vielen Optionen als falsch', () => { expect(antworten('I.1-02', 'gut', ['a', 'b'], true).richtige).toBe(0); expect(antworten('I.1-02', 'gut', ['b'], false).richtige).toBe(1); }); it('übernimmt bei offenen Fragen die Selbsteinschätzung', () => { expect(antworten('I.1-03', 'gut', [], true).richtige).toBe(1); expect(antworten('I.2-02', 'nochmal', [], false).richtige).toBe(0); }); it('speichert den Freitext und kürzt ihn auf ein vernünftiges Maß', () => { lernstand.antworten(profilId, { frageId: 'I.1-03', auswahl: [], freitext: 'x'.repeat(5000), richtig: true, bewertung: 'gut', dauerMs: 10, }); const zeile = db.prepare('SELECT freitext FROM antwort_log').get() as { freitext: string }; expect(zeile.freitext).toHaveLength(4000); }); it.each([ [ { frageId: 'gibt-es-nicht', auswahl: [], richtig: true, bewertung: 'gut', dauerMs: 1 }, /Unbekannte Frage-ID/u, ], [ { frageId: 'I.1-01', auswahl: ['z'], richtig: true, bewertung: 'gut', dauerMs: 1 }, /keine Antwortoption/u, ], [ { frageId: 'I.1-01', auswahl: [], richtig: true, bewertung: 'super', dauerMs: 1 }, /bewertung/u, ], [{ frageId: 'I.1-01', auswahl: [], richtig: 'ja', bewertung: 'gut', dauerMs: 1 }, /richtig/u], [{ frageId: 'I.1-01', auswahl: [], richtig: true, bewertung: 'gut', dauerMs: -5 }, /dauerMs/u], [{ frageId: 'I.1-01', auswahl: 'a', richtig: true, bewertung: 'gut', dauerMs: 1 }, /auswahl/u], ['kein Objekt', /Antwortprotokoll/u], ])('weist das ungültige Protokoll %o zurück', (protokoll, muster) => { expect(() => lernstand.antworten(profilId, protokoll)).toThrow(muster); }); it('schreibt bei einem ungültigen Protokoll überhaupt nichts', () => { expect(() => lernstand.antworten(profilId, { frageId: 'I.1-01', auswahl: ['z'], richtig: true, bewertung: 'gut', dauerMs: 1, }), ).toThrow(); expect(db.prepare('SELECT COUNT(*) AS n FROM antwort_log').get()).toEqual({ n: 0 }); expect(db.prepare('SELECT COUNT(*) AS n FROM frage_stand').get()).toEqual({ n: 0 }); }); it('trennt die Lernstände verschiedener Profile', () => { const zweites = lernstand.profilAnlegen('Zweitprüfling'); antworten('I.1-01', 'gut', ['a', 'c']); expect(lernstand.frageStand(zweites.id, 'I.1-01').versuche).toBe(0); expect(lernstand.frageStand(profilId, 'I.1-01').versuche).toBe(1); }); }); // ─── Wiedervorlage ────────────────────────────────────────────────────────── describe('Wiedervorlage nach FSRS', () => { /* * Die Sollwerte stammen aus `data-pipeline/fsrs_referenz.py` – derselben * Referenzrechnung, gegen die auch `tests/fsrs.test.ts` prüft. Hier wird * nicht die Formel geprüft (das tut jene Datei), sondern dass der Lernstand * sie tatsächlich anwendet und das Ergebnis richtig in `faellig_ab` * umsetzt. */ it.each([ ['nochmal', 0], ['schwer', 1], ['gut', 2], ['leicht', 9], ] as const)('setzt bei „%s“ die erste Wiedervorlage auf %i Tage', (bewertung, tage) => { const stand = antworten('I.1-01', bewertung, ['a', 'c']); expect(faelligInTagen(stand.faelligAb)).toBe(tage); expect(stand.letzteBewertung).toBe(bewertung); }); it('dehnt die Abstände, wenn jeweils zum Termin wiederholt wird', () => { // Der eigentliche Zweck des Verfahrens: Wer den Stoff hält, sieht ihn // seltener. Gerechnet wird mit dem tatsächlichen Abstand, nicht mit der // Zahl der Versuche. /* 0,5 legt die Streuung still: Der Versatz ist `(zufall * 2 - 1) * Spanne` und damit bei 0,5 genau null. Dieser Test misst das Wachstum des Verfahrens, nicht die Verteilung der Termine – die hat ihren eigenen. */ starten(() => 0.5); for (const erwartet of [2, 10, 48, 167, 180]) { const stand = antworten('I.1-01', 'gut', ['a', 'c']); expect(faelligInTagen(stand.faelligAb)).toBe(erwartet); tageWeiter(erwartet); } }); it('dehnt bei „leicht“ deutlich schneller als bei „schwer“', () => { starten(() => 0.5); // Streuung still, siehe oben const leicht: number[] = []; for (let i = 0; i < 3; i += 1) { const tage = faelligInTagen(antworten('I.1-01', 'leicht', ['a', 'c']).faelligAb); leicht.push(tage); tageWeiter(tage); } starten(); const schwer: number[] = []; for (let i = 0; i < 3; i += 1) { const tage = faelligInTagen(antworten('I.1-01', 'schwer', ['a', 'c']).faelligAb); schwer.push(tage); tageWeiter(tage); } expect(leicht).toEqual([9, 75, 180]); expect(schwer).toEqual([1, 4, 9]); }); it('gewinnt durch Wiederholung am selben Tag nichts dazu', () => { /* Der wichtigste Unterschied zum früheren Verdopplungsschema: Dreimal hintereinander „gut“ innerhalb einer Minute ist kein Lernfortschritt, und das Intervall wächst deshalb auch nicht. */ expect(faelligInTagen(antworten('I.1-01', 'gut', ['a', 'c']).faelligAb)).toBe(2); expect(faelligInTagen(antworten('I.1-01', 'gut', ['a', 'c']).faelligAb)).toBe(2); expect(faelligInTagen(antworten('I.1-01', 'gut', ['a', 'c']).faelligAb)).toBe(2); }); it('macht eine Frage bei „nochmal“ sofort wieder fällig', () => { antworten('I.1-01', 'leicht', ['a', 'c']); tageWeiter(8); expect(faelligInTagen(antworten('I.1-01', 'nochmal', ['a']).faelligAb)).toBe(0); }); it('behält nach einem Fehler den früheren Fortschritt teilweise', () => { /* Beide Fragen haben dieselbe jüngste Historie: erst „nochmal“, einen Tag später „gut“. Sie unterscheiden sich nur darin, dass die erste vorher schon einmal fest saß. Genau das ist der Gewinn gegenüber einem Verfahren, das bei einem Fehler den Zähler auf null setzt. */ antworten('I.1-01', 'leicht', ['a', 'c']); tageWeiter(9); antworten('I.1-01', 'nochmal', ['a'], false); tageWeiter(1); const vorbelastet = faelligInTagen(antworten('I.1-01', 'gut', ['a', 'c']).faelligAb); starten(); antworten('I.1-01', 'nochmal', ['a'], false); tageWeiter(1); const frisch = faelligInTagen(antworten('I.1-01', 'gut', ['a', 'c']).faelligAb); /* Beide Zahlen enthalten seit 0.26.6 die feste Streuung je Frage; der Punkt des Falls ist der Abstand zwischen ihnen. */ expect(vorbelastet).toBe(5); expect(frisch).toBe(2); }); /* Die Wache, die gefehlt hat. Ohne Streuung ist der Termin punktgenau `jetzt + n Tage`. Wer an einem Abend zwanzig neue Fragen mit derselben Bewertung beantwortet, bekommt für alle zwanzig denselben Termin – und zwei Termine später wieder. Die Arbeitslastvorschau machte diese Berge sichtbar, geglättet hat sie sie nie. */ it('verteilt gleich bewertete Fragen desselben Tages auf mehrere Termine', () => { starten(); /* Je Frage ihre eigene gültige Auswahl – `protokollPruefen` weist eine Antwortmarke zurück, die es an der Frage nicht gibt. */ const fragen: readonly (readonly [string, readonly string[]])[] = [ ['I.1-01', ['a', 'c']], ['I.1-02', ['b']], ['I.2-01', ['d']], ['II-01', ['a']], ['II-02', ['b']], ['IV-01', ['a']], ]; const termine = new Set( fragen.map(([id, auswahl]) => faelligInTagen(antworten(id, 'leicht', auswahl).faelligAb)), ); /* Ohne Streuung wäre das genau ein Termin für alle fünf. */ expect(termine.size).toBeGreaterThan(1); }); it('streut denselben Termin für dieselbe Frage immer gleich', () => { /* Der Versatz stammt aus der Frage-Kennung, nicht aus einer Zufallsquelle: Zwei Läufe derselben Antwort müssen denselben Tag ergeben, sonst wäre keine Zusicherung dieses Projekts mehr nachrechenbar. Der erste Entwurf nahm `this.zufall` – und machte auf der Stelle zwei fremde Zusicherungen zufällig. */ starten(); const erst = faelligInTagen(antworten('I.1-01', 'leicht', ['a', 'c']).faelligAb); starten(); const zweit = faelligInTagen(antworten('I.1-01', 'leicht', ['a', 'c']).faelligAb); expect(erst).toBe(zweit); }); it('lässt kurze Abstände unangetastet', () => { /* Unter drei Tagen wäre ein Tag Versatz keine Streuung mehr, sondern eine andere Antwort: Bei Intervall 1 hieße „ein Tag früher“ noch heute. */ starten(); expect(faelligInTagen(antworten('I.1-01', 'schwer', ['a', 'c']).faelligAb)).toBe(1); starten(); expect(faelligInTagen(antworten('I.1-01', 'gut', ['a', 'c']).faelligAb)).toBe(2); }); it('verkürzt die Abstände, wenn der Prüfungstermin nahe ist', () => { // Dieselbe Antwort auf dieselbe Frage – nur der Termin unterscheidet sich. lernstand.profilAktualisieren({ id: profilId, pruefungstermin: '2026-03-08' }); const knapp = faelligInTagen(antworten('I.1-01', 'gut', ['a', 'c']).faelligAb); starten(); const ohneTermin = faelligInTagen(antworten('I.1-01', 'gut', ['a', 'c']).faelligAb); expect(knapp).toBe(1); expect(ohneTermin).toBe(2); }); it('lässt einen weit entfernten Termin die Abstände unberührt', () => { // Ein Termin in einem halben Jahr darf nicht dazu führen, dass von Anfang // an übermäßig oft wiederholt wird. lernstand.profilAktualisieren({ id: profilId, pruefungstermin: '2026-09-01' }); expect(faelligInTagen(antworten('I.1-01', 'gut', ['a', 'c']).faelligAb)).toBe(2); }); it('deckelt die Wiedervorlage bei 180 Tagen', () => { for (let i = 0; i < 8; i += 1) { const tage = faelligInTagen(antworten('I.1-01', 'leicht', ['a', 'c']).faelligAb); expect(tage).toBeLessThanOrEqual(180); tageWeiter(tage); } }); }); // ─── Merkliste ────────────────────────────────────────────────────────────── describe('Merkliste', () => { it('merkt eine Frage vor, ohne einen Versuch zu zählen', () => { const stand = lernstand.merken(profilId, 'II-01', true); expect(stand).toMatchObject({ frageId: 'II-01', gemerkt: true, versuche: 0, faelligAb: null }); expect(lernstand.uebersicht(profilId).gemerkt).toBe(1); }); it('nimmt eine Frage wieder von der Merkliste', () => { lernstand.merken(profilId, 'II-01', true); expect(lernstand.merken(profilId, 'II-01', false).gemerkt).toBe(false); expect(lernstand.uebersicht(profilId).gemerkt).toBe(0); }); it('behält den Antwortstand beim Merken bei', () => { antworten('II-01', 'gut', ['a']); const stand = lernstand.merken(profilId, 'II-01', true); expect(stand).toMatchObject({ gemerkt: true, versuche: 1, richtige: 1 }); }); it.each([['gibt-es-nicht'], [''], [null], [7]])('weist die Frage-ID %o zurück', (frageId) => { expect(() => lernstand.merken(profilId, frageId, true)).toThrow(); }); it('weist einen nicht-booleschen Merkwert zurück', () => { expect(() => lernstand.merken(profilId, 'II-01', 'ja')).toThrow(/gemerkt/u); }); /* Der Merkzustand muss mit der Sitzung mitkommen, sonst weiß die Oberfläche ihn erst, nachdem die Frage beantwortet wurde. Bis 0.24.1 war das so: Der Stern stand zu Sitzungsbeginn an jeder Frage auf „nicht gemerkt“ – auch in einer Sitzung „nur Gemerkte“, in der jede einzelne Frage gemerkt ist. */ it('gibt den Merkzustand mit der Sitzung heraus', () => { lernstand.merken(profilId, 'II-01', true); const sitzung = lernstand.sitzung(profilId, { mischen: false }); expect(sitzung.find((s) => s.frageId === 'II-01')?.gemerkt).toBe(true); expect(sitzung.find((s) => s.frageId === 'I.1-01')?.gemerkt).toBe(false); }); it('meldet in einer Sitzung „nur Gemerkte“ jede Frage als gemerkt', () => { lernstand.merken(profilId, 'II-01', true); lernstand.merken(profilId, 'I.1-01', true); const sitzung = lernstand.sitzung(profilId, { nurGemerkte: true, mischen: false }); expect(sitzung.length).toBeGreaterThan(0); expect(sitzung.every((s) => s.gemerkt)).toBe(true); }); }); // ─── Übersicht ────────────────────────────────────────────────────────────── describe('Übersicht', () => { it('meldet auf einem leeren Lernstand überall null', () => { const uebersicht = lernstand.uebersicht(profilId); expect(uebersicht).toMatchObject({ fragenGesamt: 10, beantwortet: 0, belegt: 0, reifegrad: 0 / 10, stufe: 'ohne_beleg', faellig: 0, gemerkt: 0, /* Kein Nullwert: `offen` zählt den Katalog, nicht den Lernstand. Der Prüfkatalog enthält drei auszuformulierende Fragen. */ offen: 3, fehler: 0, heuteRichtig: 0, heuteFalsch: 0, heuteBearbeitet: 0, tageSeitLetzterAntwort: null, }); }); it('zählt bearbeitete und fällige Fragen', () => { antworten('I.1-01', 'gut', ['a', 'c']); // richtig, fällig in 2 Tagen antworten('I.1-02', 'nochmal', ['a']); // falsch, sofort fällig antworten('II-01', 'leicht', ['a']); // richtig, fällig in 4 Tagen const uebersicht = lernstand.uebersicht(profilId); expect(uebersicht).toMatchObject({ beantwortet: 3, faellig: 1 }); /* Belegt ist noch nichts: Alle drei sind zum ersten Mal aufgetaucht. */ expect(uebersicht.belegt).toBe(0); expect(uebersicht.stufe).toBe('ohne_beleg'); }); it('belegt eine Frage erst beim Wiedersehen nach mindestens einem Tag', () => { /* Der Kern der Umstellung, an der echten Datenbank. Die erste richtige Antwort beweist nichts über das Behalten – sie kann geraten sein, und Wiedererkennen ist kein Erinnern. */ antworten('I.1-01', 'gut', ['a', 'c']); expect(lernstand.uebersicht(profilId).belegt).toBe(0); // Noch am selben Tag: kein Beleg, aber auch kein Schaden. antworten('I.1-01', 'gut', ['a', 'c']); expect(lernstand.uebersicht(profilId).belegt).toBe(0); tageWeiter(2); antworten('I.1-01', 'gut', ['a', 'c']); expect(lernstand.uebersicht(profilId).belegt).toBe(1); }); /* Eine als geraten eingestandene Antwort belegt nichts. Bis 0.22.0 hing der Beleg allein an `richtig` – und `richtig` rechnet der Kern aus der Auswahl nach, ohne zu wissen, ob jemand die Antwort wusste oder traf. Wer über „Ich hatte geraten“ die Selbsteinschätzung nachreicht, sagt genau das: angekreuzt war das Richtige, gewusst war es nicht. Ein Beleg dafür wäre eine Reifezahl auf einem Zufallstreffer – gemessen an einem nachgestellten Rater fiel er an 40 Prozent der Tage zu Unrecht (`docs/entscheidung-ratewahrscheinlichkeit.md`). */ it('belegt eine richtige Antwort nicht, wenn sie als geraten gilt', () => { antworten('I.1-01', 'gut', ['a', 'c']); tageWeiter(2); /* Richtig angekreuzt, aber als „nicht gewusst“ eingestanden. */ antworten('I.1-01', 'nochmal', ['a', 'c']); expect(lernstand.uebersicht(profilId).belegt).toBe(0); }); it('nimmt einen vorhandenen Beleg dabei nicht weg', () => { /* Die Antwort war richtig – sie widerlegt nichts. Ein früherer Beleg bleibt deshalb stehen, anders als bei einer falschen Antwort. */ antworten('I.1-01', 'gut', ['a', 'c']); tageWeiter(2); antworten('I.1-01', 'gut', ['a', 'c']); expect(lernstand.uebersicht(profilId).belegt).toBe(1); tageWeiter(2); antworten('I.1-01', 'nochmal', ['a', 'c']); expect(lernstand.uebersicht(profilId).belegt).toBe(1); }); it('nimmt den Beleg bei einer falschen Antwort wieder weg', () => { /* Der Beleg ist eine Aussage über das Jetzt, kein Orden für früher. Daraus folgt die Eigenschaft, an der der erste Entwurf gescheitert war: Die Zahl kann durch eine falsche Antwort nie steigen. */ antworten('I.1-01', 'gut', ['a', 'c']); tageWeiter(2); antworten('I.1-01', 'gut', ['a', 'c']); expect(lernstand.uebersicht(profilId).belegt).toBe(1); tageWeiter(20); const vorher = lernstand.uebersicht(profilId).reifegrad; antworten('I.1-01', 'nochmal', ['b'], false); const nachher = lernstand.uebersicht(profilId); expect(nachher.belegt).toBe(0); expect(nachher.reifegrad).toBeLessThanOrEqual(vorher); }); it('lässt den Beleg mit der Zeit verfallen, ohne ihn zu löschen', () => { /* Die Zahl darf fallen, ohne dass jemand etwas falsch gemacht hat – das ist der ganze Punkt eines Gedächtnismodells. Der Beleg selbst bleibt aber stehen: Wer die Frage wiedersieht, fängt nicht von vorn an. */ antworten('I.1-01', 'gut', ['a', 'c']); tageWeiter(2); antworten('I.1-01', 'gut', ['a', 'c']); const frisch = lernstand.uebersicht(profilId).reifegrad; tageWeiter(120); const alt = lernstand.uebersicht(profilId).reifegrad; expect(alt).toBeLessThan(frisch); expect(alt).toBeGreaterThan(0); }); it('führt die Tagesbilanz nur für den heutigen Kalendertag', () => { antworten('I.1-01', 'gut', ['a', 'c']); antworten('I.1-02', 'nochmal', ['a']); expect(lernstand.uebersicht(profilId)).toMatchObject({ heuteRichtig: 1, heuteFalsch: 1 }); tageWeiter(1); expect(lernstand.uebersicht(profilId)).toMatchObject({ heuteRichtig: 0, heuteFalsch: 0 }); antworten('II-01', 'gut', ['a']); expect(lernstand.uebersicht(profilId)).toMatchObject({ heuteRichtig: 1, heuteFalsch: 0 }); }); it('gliedert Kapitel mit Abschnitten nach Abschnitt, andere nach Kapitel', () => { const bereiche = lernstand.uebersicht(profilId).bereiche; expect(bereiche.map((b) => b.id)).toEqual(['I.1', 'I.2', 'II', 'IV']); expect(bereiche.map((b) => b.titel)).toEqual([ 'Begriffe des Waffenrechts', 'Rechte und Pflichten', 'Waffentechnik', 'Not- und Seenotsignalmittel', ]); expect(bereiche.map((b) => b.fragenGesamt)).toEqual([3, 2, 3, 2]); }); it('rechnet den Reifegrad je Bereich aus derselben Summe wie die Gesamtzahl', () => { antworten('I.1-01', 'gut', ['a', 'c']); antworten('I.1-02', 'nochmal', ['a'], false); tageWeiter(2); antworten('I.1-01', 'gut', ['a', 'c']); const uebersicht = lernstand.uebersicht(profilId); const bereich = uebersicht.bereiche.find((b) => b.id === 'I.1'); expect(bereich).toMatchObject({ fragenGesamt: 3, beantwortet: 2, belegt: 1 }); /* Bereichswerte und Gesamtwert sind nicht bloß aufeinander abgestimmt, sondern dieselbe Summe, nur anders gruppiert. */ const ausBereichen = uebersicht.bereiche.reduce( (summe, b) => summe + b.reifegrad * b.fragenGesamt, 0, ); expect(ausBereichen / uebersicht.fragenGesamt).toBeCloseTo(uebersicht.reifegrad, 10); }); }); // ─── Zurücksetzen ─────────────────────────────────────────────────────────── describe('Profile löschen', () => { it('löscht alles, was am Profil hängt', () => { /* Die eine Zeile `DELETE FROM profil` verlässt sich darauf, dass SQLite kaskadiert. Geprüft war bisher nur, dass das Pragma auf 1 steht – nicht, dass die Kindzeilen wirklich mitgehen. Genau darauf beruht aber das Löschen. */ const zweites = lernstand.profilAnlegen('Zweitprofil'); antworten('I.1-01', 'gut', ['a']); lernstand.merken(profilId, 'II-02', true); db.prepare( `INSERT INTO pruefung_lauf (profil_id, pruefungsprofil, zeitpunkt, gesamt, richtig, quote, urteil, dauer_ms) VALUES (?, 'standard', '2026-08-01T10:00:00.000Z', 10, 8, 0.8, 'bestanden', 1000)`, ).run(profilId); const zaehle = (tabelle: string, id: number): number => ( db.prepare(`SELECT COUNT(*) AS n FROM ${tabelle} WHERE profil_id = ?`).get(id) as { n: number; } ).n; expect(zaehle('antwort_log', profilId)).toBeGreaterThan(0); expect(zaehle('frage_stand', profilId)).toBeGreaterThan(0); expect(zaehle('pruefung_lauf', profilId)).toBe(1); const uebrig = lernstand.profilLoeschen(profilId); expect(uebrig.map((p) => p.id)).toEqual([zweites.id]); expect(zaehle('antwort_log', profilId)).toBe(0); expect(zaehle('frage_stand', profilId)).toBe(0); expect(zaehle('pruefung_lauf', profilId)).toBe(0); }); it('lässt das letzte Profil stehen', () => { /* Ohne Profil hätte die Anwendung keinen Ort für Antworten mehr, und ein neues entstünde erst beim nächsten Start. */ expect(lernstand.profile()).toHaveLength(1); expect(() => lernstand.profilLoeschen(profilId)).toThrow(/letzte Profil/u); expect(lernstand.profile()).toHaveLength(1); }); it('lässt die Daten der übrigen Profile unberührt', () => { const zweites = lernstand.profilAnlegen('Zweitprofil'); lernstand.merken(zweites.id, 'II-02', true); antworten('I.1-01', 'gut', ['a']); lernstand.profilLoeschen(profilId); expect(lernstand.frageStand(zweites.id, 'II-02').gemerkt).toBe(true); }); it('weist eine unbekannte Profil-ID ab, statt still nichts zu tun', () => { /* Ein Löschauftrag ins Leere ist ein Fehler des Aufrufers, kein Erfolg. Die Prüfung liegt schon in profilIdPruefen. */ lernstand.profilAnlegen('Zweitprofil'); expect(() => lernstand.profilLoeschen(9999)).toThrow(/Unbekanntes Profil/u); expect(lernstand.profile()).toHaveLength(2); }); }); describe('Profile anlegen', () => { it('unterscheidet Namen nicht nach Groß- und Kleinschreibung', () => { /* In einer Liste, aus der jemand sein Profil wiedererkennen soll, sind „Olaf“ und „olaf“ keine Unterscheidung, sondern eine Falle. */ lernstand.profilAnlegen('Olaf'); expect(() => lernstand.profilAnlegen('olaf')).toThrow(/bereits ein Profil/u); expect(() => lernstand.profilAnlegen('OLAF')).toThrow(/bereits ein Profil/u); }); it('legt beim Umbenennen denselben Maßstab an wie beim Anlegen', () => { /* Der Vergleich beim Umbenennen war buchstabengenau, der beim Anlegen nicht. Über den Umweg „anlegen, dann umbenennen" ließ sich also herstellen, was das Anlegen abweist. */ lernstand.profilAnlegen('Olaf'); const zweites = lernstand.profilAnlegen('Anna'); expect(() => lernstand.profilAktualisieren({ id: zweites.id, name: 'olaf' })).toThrow( /bereits ein Profil/u, ); expect(lernstand.profile().map((profil) => profil.name)).toContain('Anna'); }); it('lässt ein Profil auf seinen eigenen Namen umbenennen', () => { /* Sonst scheiterte das Ändern der Groß- und Kleinschreibung am eigenen Eintrag: „olaf" zu „Olaf" wäre ein Konflikt mit sich selbst. */ const profil = lernstand.profilAnlegen('olaf'); expect(lernstand.profilAktualisieren({ id: profil.id, name: 'Olaf' })).toMatchObject({ name: 'Olaf', }); }); it('begrenzt die Zahl der Profile', () => { /* Keine technische Grenze, sondern eine gegen Versehen. */ for (let i = lernstand.profile().length; i < 20; i += 1) { lernstand.profilAnlegen(`Profil ${String(i)}`); } expect(lernstand.profile()).toHaveLength(20); expect(() => lernstand.profilAnlegen('Eins zu viel')).toThrow(/höchstens 20/u); }); }); describe('Zurücksetzen', () => { beforeEach(() => { antworten('I.1-01', 'gut', ['a', 'c']); antworten('I.2-01', 'gut', ['d']); antworten('II-01', 'gut', ['a']); lernstand.merken(profilId, 'II-02', true); }); it('löscht Stand und Historie vollständig', () => { const uebersicht = lernstand.zuruecksetzen(profilId, null); expect(uebersicht).toMatchObject({ beantwortet: 0, belegt: 0, gemerkt: 0, heuteRichtig: 0 }); expect(db.prepare('SELECT COUNT(*) AS n FROM antwort_log').get()).toEqual({ n: 0 }); expect(db.prepare('SELECT COUNT(*) AS n FROM frage_stand').get()).toEqual({ n: 0 }); }); it('löscht auch den Prüfungsverlauf', () => { /* Wer von vorn anfangen will, meint von vorn. Bliebe der Verlauf stehen, stünden alte Simulationsergebnisse weiter in der Auswertung und im Lernbericht, während der Lernstand bei null ist – zwei Zahlen, die sich widersprechen. */ db.prepare( `INSERT INTO pruefung_lauf (profil_id, pruefungsprofil, zeitpunkt, gesamt, richtig, quote, urteil, dauer_ms) VALUES (?, 'standard', '2026-08-01T10:00:00.000Z', 10, 8, 0.8, 'bestanden', 1000)`, ).run(profilId); expect(db.prepare('SELECT COUNT(*) AS n FROM pruefung_lauf').get()).toEqual({ n: 1 }); lernstand.zuruecksetzen(profilId, null); expect(db.prepare('SELECT COUNT(*) AS n FROM pruefung_lauf').get()).toEqual({ n: 0 }); }); it('lässt den Prüfungsverlauf stehen, wenn nur ein Kapitel zurückgesetzt wird', () => { /* Ein Lauf geht über den ganzen Bogen und lässt sich nicht kapitelweise herausrechnen. Ihn dann mitzulöschen wäre mehr, als verlangt war. */ db.prepare( `INSERT INTO pruefung_lauf (profil_id, pruefungsprofil, zeitpunkt, gesamt, richtig, quote, urteil, dauer_ms) VALUES (?, 'standard', '2026-08-01T10:00:00.000Z', 10, 8, 0.8, 'bestanden', 1000)`, ).run(profilId); lernstand.zuruecksetzen(profilId, 'I'); expect(db.prepare('SELECT COUNT(*) AS n FROM pruefung_lauf').get()).toEqual({ n: 1 }); }); it('löscht auf Wunsch nur ein Kapitel', () => { const uebersicht = lernstand.zuruecksetzen(profilId, 'I'); expect(uebersicht.beantwortet).toBe(1); expect(lernstand.frageStand(profilId, 'I.1-01').versuche).toBe(0); expect(lernstand.frageStand(profilId, 'II-01').versuche).toBe(1); expect(lernstand.frageStand(profilId, 'II-02').gemerkt).toBe(true); }); it('behält beim Kapitel-Zurücksetzen die Merkliste dieses Kapitels', () => { /* `gemerkt` steht in derselben Tabelle wie der Fortschritt. Die Zeile einfach zu löschen nahm deshalb auch die Markierung mit – beim vollständigen Zurücksetzen gewollt und angesagt, kapitelweise weder das eine noch das andere. Wer eine Frage als schwer markiert hat, will sie wiederfinden, gerade wenn er das Kapitel neu lernt. Der vorhandene Test „löscht auf Wunsch nur ein Kapitel" prüft dazu nichts: Die dort gemerkte Frage II-02 liegt außerhalb von Kapitel I und wurde nie angefasst. */ lernstand.merken(profilId, 'I.1-01', true); expect(lernstand.uebersicht(profilId).gemerkt).toBe(2); const uebersicht = lernstand.zuruecksetzen(profilId, 'I'); const stand = lernstand.frageStand(profilId, 'I.1-01'); expect(stand.gemerkt).toBe(true); expect(stand).toMatchObject({ versuche: 0, richtige: 0, zuletztBeantwortet: null, faelligAb: null, letzteBewertung: null, }); // Die Kennzahl zählt sie weiter mit, der Fortschritt nicht. expect(uebersicht.gemerkt).toBe(2); expect(uebersicht.beantwortet).toBe(1); }); it('lässt die gemerkte Frage danach in der Merkliste stehen', () => { /* Zahl und Liste müssen dasselbe sagen: Was in „Gemerkt: n Fragen" mitzählt, muss in der Sitzung „Gemerkte Fragen" auch auftauchen. */ lernstand.merken(profilId, 'I.1-01', true); lernstand.zuruecksetzen(profilId, 'I'); const sitzung = lernstand.sitzung(profilId, { nurGemerkte: true, mischen: false }); expect(sitzung.map((f) => f.frageId)).toContain('I.1-01'); }); it('räumt die Zeilen der nicht gemerkten Fragen ganz weg', () => { /* Sonst sammelte die Tabelle bei jedem Zurücksetzen leere Zeilen an. */ lernstand.merken(profilId, 'I.1-01', true); lernstand.zuruecksetzen(profilId, 'I'); expect( db.prepare("SELECT COUNT(*) AS n FROM frage_stand WHERE frage_id LIKE 'I.%'").get(), ).toEqual({ n: 1 }); }); it('zurückgesetzt sieht aus wie nie beantwortet', () => { /* Der eigentliche Wächter dieser Änderung, und zwar ohne Spaltenliste im Test: Verglichen wird die zurückgesetzte Zeile mit einer, die nur gemerkt und nie beantwortet wurde. Beide müssen über `SELECT *` Feld für Feld gleich sein. Kommt `frage_stand` später eine Spalte hinzu, die das Antworten füllt und das Zurücksetzen vergisst, laufen die beiden Zeilen auseinander und dieser Test fällt um – ohne dass jemand daran gedacht hätte, ihn zu erweitern. */ lernstand.merken(profilId, 'I.1-02', true); // Vergleichszeile: nie beantwortet antworten('I.1-01', 'schwer', ['a'], false); lernstand.merken(profilId, 'I.1-01', true); tageWeiter(3); lernstand.zuruecksetzen(profilId, 'I'); const zeilen = db .prepare<[number], Record>( `SELECT * FROM frage_stand WHERE profil_id = ? AND frage_id IN ('I.1-01', 'I.1-02') ORDER BY frage_id`, ) .all(profilId); expect(zeilen).toHaveLength(2); const ohneKennung = (zeile: Record): Record => { const { frage_id: _weg, ...rest } = zeile; return rest; }; expect(ohneKennung(zeilen[0]!)).toEqual(ohneKennung(zeilen[1]!)); }); it('räumt beim vollständigen Zurücksetzen auch die Merkliste', () => { /* Gegenprobe zum Kapitel-Fall: Hier ist das Mitlöschen gewollt, und die Oberfläche sagt es auch an (Neuanfang.tsx). */ lernstand.merken(profilId, 'I.1-01', true); const uebersicht = lernstand.zuruecksetzen(profilId, null); expect(uebersicht.gemerkt).toBe(0); expect(lernstand.frageStand(profilId, 'I.1-01').gemerkt).toBe(false); expect(db.prepare('SELECT COUNT(*) AS n FROM frage_stand').get()).toEqual({ n: 0 }); }); it('lässt sich beliebig oft wiederholen', () => { for (let i = 0; i < 5; i += 1) { expect(lernstand.zuruecksetzen(profilId, null).beantwortet).toBe(0); expect(lernstand.zuruecksetzen(profilId, 'I').beantwortet).toBe(0); } }); it('lässt andere Profile unberührt', () => { const zweites = lernstand.profilAnlegen('Zweitprüfling'); lernstand.antworten(zweites.id, { frageId: 'I.1-01', auswahl: ['a', 'c'], richtig: true, bewertung: 'gut', dauerMs: 10, }); lernstand.zuruecksetzen(profilId, null); expect(lernstand.frageStand(zweites.id, 'I.1-01').versuche).toBe(1); }); it('weist ein unbekanntes Kapitel zurück, ohne etwas zu löschen', () => { expect(() => lernstand.zuruecksetzen(profilId, 'IX')).toThrow(/Unbekanntes Kapitel/u); expect(lernstand.uebersicht(profilId).beantwortet).toBe(3); }); }); // ─── Sitzungszusammenstellung ─────────────────────────────────────────────── describe('Sitzungszusammenstellung', () => { it('liefert ohne Filter alle Fragen in Katalogreihenfolge', () => { const sitzung = lernstand.sitzung(profilId, { mischen: false }); expect(sitzung.map((s) => s.frageId)).toEqual(FRAGEN.map((f) => f.id)); }); it('filtert nach Kapitel', () => { const sitzung = lernstand.sitzung(profilId, { kapitel: ['II'], mischen: false }); expect(sitzung.map((s) => s.frageId)).toEqual(['II-01', 'II-02', 'II-03']); }); it('filtert nach Abschnitt', () => { const sitzung = lernstand.sitzung(profilId, { abschnitte: ['I.2'], mischen: false }); expect(sitzung.map((s) => s.frageId)).toEqual(['I.2-01', 'I.2-02']); }); it('verknüpft Kapitel- und Abschnittsfilter mit UND', () => { expect( lernstand.sitzung(profilId, { kapitel: ['I'], abschnitte: ['I.1'], mischen: false }), ).toHaveLength(3); expect( lernstand.sitzung(profilId, { kapitel: ['II'], abschnitte: ['I.1'], mischen: false }), ).toHaveLength(0); }); it('liefert mit „nurGemerkte“ nur die Merkliste', () => { lernstand.merken(profilId, 'II-02', true); lernstand.merken(profilId, 'I.2-01', true); const sitzung = lernstand.sitzung(profilId, { nurGemerkte: true, mischen: false }); expect(sitzung.map((s) => s.frageId)).toEqual(['I.2-01', 'II-02']); }); it('liefert mit „nurNeue“ nur nie beantwortete Fragen', () => { antworten('I.1-01', 'gut', ['a', 'c']); antworten('II-01', 'gut', ['a']); const sitzung = lernstand.sitzung(profilId, { nurNeue: true, mischen: false }); expect(sitzung.map((s) => s.frageId)).toEqual([ 'I.1-02', 'I.1-03', 'I.2-01', 'I.2-02', 'II-02', 'II-03', 'IV-01', 'IV-02', ]); }); /* Der einzige Filter, der den Katalog befragt statt den Lernstand: Der Fragetyp ist eine Eigenschaft der Frage, keine des Lernenden. Deshalb wirkt er auch auf noch nie beantwortete Fragen und braucht keine Zeile in `frage_stand`. */ it('liefert mit „nurOffene“ nur auszuformulierende Fragen', () => { const sitzung = lernstand.sitzung(profilId, { nurOffene: true, mischen: false }); expect(sitzung.map((s) => s.frageId)).toEqual(['I.1-03', 'I.2-02', 'II-03']); }); it('lässt „nurOffene“ mit der Kapitelabwahl zusammenwirken', () => { /* Beides muss zugleich gelten – sonst legte ausgerechnet der neue Weg die dauerhaft abgewählten Fragen wieder vor, wie es „nur Fehler“ früher tat. Kapitel IV enthält hier keine offene Frage; geprüft wird deshalb die Gegenrichtung: Ein Kapitelfilter darf den Typfilter nicht aushebeln. */ const sitzung = lernstand.sitzung(profilId, { nurOffene: true, kapitel: ['I'], mischen: false, }); expect(sitzung.map((s) => s.frageId)).toEqual(['I.1-03', 'I.2-02']); }); /* Der Text lag seit jeher in `antwort_log.freitext` und wurde von keiner Abfrage gelesen – geschrieben, aufbewahrt, nie gezeigt. Sein Wert liegt im Wiedersehen: Wer dieselbe Frage nach zwei Wochen wiederbekommt, sieht, ob er heute genauer ist als damals. */ it('gibt die zuletzt geschriebene Freitextantwort mit der Sitzung heraus', () => { lernstand.antworten(profilId, { frageId: 'I.1-03', auswahl: [], freitext: 'Meine erste Fassung.', richtig: true, bewertung: 'gut', dauerMs: 5000, }); const sitzung = lernstand.sitzung(profilId, { nurOffene: true, mischen: false }); const eintrag = sitzung.find((s) => s.frageId === 'I.1-03'); expect(eintrag?.letzterFreitext?.text).toBe('Meine erste Fassung.'); expect(eintrag?.letzterFreitext?.zeitpunkt).toBeTruthy(); }); it('nennt die jüngste Fassung, nicht die erste', () => { for (const text of ['Erster Versuch.', 'Zweiter Versuch.', 'Dritter Versuch.']) { lernstand.antworten(profilId, { frageId: 'I.1-03', auswahl: [], freitext: text, richtig: true, bewertung: 'gut', dauerMs: 5000, }); } const sitzung = lernstand.sitzung(profilId, { nurOffene: true, mischen: false }); expect(sitzung.find((s) => s.frageId === 'I.1-03')?.letzterFreitext?.text).toBe( 'Dritter Versuch.', ); }); it('lässt leere Eingaben weg – das Feld darf leer bleiben', () => { /* Ein Kasten „Beim letzten Mal schrieben Sie“ über einer leeren Zeile wäre eine Vorhaltung ohne Inhalt. */ lernstand.antworten(profilId, { frageId: 'I.1-03', auswahl: [], freitext: ' ', richtig: false, bewertung: 'nochmal', dauerMs: 1000, }); const sitzung = lernstand.sitzung(profilId, { nurOffene: true, mischen: false }); expect(sitzung.find((s) => s.frageId === 'I.1-03')?.letzterFreitext).toBeUndefined(); }); it('hängt keinen Freitext an Auswahlfragen', () => { const sitzung = lernstand.sitzung(profilId, { mischen: false, anzahl: 999 }); const auswahlfragen = sitzung.filter((s) => s.frageId.includes('mc') || s.frageId === 'I.1-01'); for (const eintrag of auswahlfragen) { expect(eintrag.letzterFreitext).toBeUndefined(); } }); it('zählt die offenen Fragen in der Übersicht mit', () => { /* Die Zahl an der Schaltfläche und die Sitzung dahinter müssen dasselbe sagen. Sie stammen aus zwei verschiedenen Wegen durch den Kern. */ const uebersicht = lernstand.uebersicht(profilId); const sitzung = lernstand.sitzung(profilId, { nurOffene: true, anzahl: 999 }); expect(uebersicht.offen).toBe(3); expect(sitzung).toHaveLength(uebersicht.offen); }); it('liefert mit „nurFehler“ nur zuletzt falsch beantwortete Fragen', () => { antworten('I.1-01', 'nochmal', ['b']); // falsch antworten('I.1-02', 'gut', ['b']); // richtig antworten('II-01', 'nochmal', ['b']); // falsch antworten('II-01', 'gut', ['a']); // danach richtig – zählt nicht mehr const sitzung = lernstand.sitzung(profilId, { nurFehler: true, mischen: false }); expect(sitzung.map((s) => s.frageId)).toEqual(['I.1-01']); }); it('begrenzt die Sitzung auf die gewünschte Anzahl', () => { expect(lernstand.sitzung(profilId, { anzahl: 3, mischen: false })).toHaveLength(3); expect(lernstand.sitzung(profilId, { anzahl: 1, mischen: false })).toHaveLength(1); }); it('schneidet ohne Angabe von „anzahl“ nichts ab', () => { expect(lernstand.sitzung(profilId, { mischen: false })).toHaveLength(FRAGEN.length); expect(lernstand.sitzung(profilId, { anzahl: 100, mischen: false })).toHaveLength( FRAGEN.length, ); }); it('stellt fällige und noch nie beantwortete Fragen nach vorn', () => { // I.1-01 liegt vier Tage in der Zukunft, I.1-02 wird wieder fällig. antworten('I.1-01', 'leicht', ['a', 'c']); antworten('I.1-02', 'leicht', ['b']); antworten('I.1-03', 'leicht', [], true); tageWeiter(2); antworten('I.1-02', 'nochmal', ['a']); // sofort wieder fällig const reihenfolge = lernstand.sitzung(profilId, { mischen: false }).map((s) => s.frageId); // Zuerst die fällige Frage und die sechs neuen, danach die beiden, die // noch nicht wieder dran sind. expect(reihenfolge.slice(0, 7)).toEqual([ 'I.1-02', 'I.2-01', 'I.2-02', 'II-01', 'II-02', 'II-03', 'IV-01', ]); expect(reihenfolge.slice(7)).toEqual(['IV-02', 'I.1-01', 'I.1-03']); }); it('legt fällige Wiederholungen vor die neuen Fragen', () => { /* II-01 steht im Katalog hinter allen I-Fragen. Bis Fassung 0.20.0 bildeten Fällige und Neue eine gemeinsame Gruppe in Katalogreihenfolge – die fällige Frage wäre erst an sechster Stelle gekommen. */ antworten('II-01', 'nochmal', ['b'], false); // sofort wieder fällig const reihenfolge = lernstand.sitzung(profilId, { mischen: false }).map((s) => s.frageId); expect(reihenfolge).toEqual([ 'II-01', 'I.1-01', 'I.1-02', 'I.1-03', 'I.2-01', 'I.2-02', 'II-02', 'II-03', 'IV-01', 'IV-02', ]); }); it('deckelt neue Fragen auf dieselbe Rate, die der Lernplan anzeigt', () => { // Fünf Fragen beantwortet, fünf nie gesehen, Termin in vier Tagen: // Rate = ceil(5 / 4) = 2 – im Plan wie in der Sitzung. antworten('I.1-01', 'gut', ['a', 'c']); antworten('I.1-02', 'gut', ['b']); antworten('I.1-03', 'gut'); antworten('I.2-01', 'gut', ['d']); antworten('I.2-02', 'gut'); lernstand.profilAktualisieren({ id: profilId, pruefungstermin: '2026-03-05' }); const plan = lernstand.lernplan(profilId); const sitzung = lernstand .sitzung(profilId, { anzahl: 5, mischen: false }) .map((s) => s.frageId); expect(plan.pensum.neu).toBe(2); // Keine fälligen: erst die zwei neuen der Tagesration, dann bereits // Beantwortetes – nicht weitere neue Fragen. expect(sitzung).toEqual(['II-01', 'II-02', 'I.1-01', 'I.1-02', 'I.1-03']); }); it('verhält sich ohne Termin wie bisher: neue Fragen bis zum Sitzungsumfang vorn', () => { /* Die dokumentierte Voreinstellung: Ohne Termin liegt die Rate beim Sitzungsumfang (20). Für die übliche 20er-Sitzung ist das kein Deckel – alle neuen Fragen stehen vor dem bereits Beantworteten. */ antworten('I.1-01', 'gut', ['a', 'c']); // beantwortet, nicht fällig const reihenfolge = lernstand.sitzung(profilId, { mischen: false }).map((s) => s.frageId); expect(reihenfolge).toEqual([ 'I.1-02', 'I.1-03', 'I.2-01', 'I.2-02', 'II-01', 'II-02', 'II-03', 'IV-01', 'IV-02', 'I.1-01', ]); }); it('stellt am Prüfungstag neue Fragen ganz nach hinten, ohne sie zu verstecken', () => { /* Rate 0 am Prüfungstag (siehe `einfuehrungsrate`): Fälliges und bereits Beantwortetes zuerst, die neuen Fragen bilden die letzte Gruppe. Der Deckel ordnet, er versteckt nicht – die Sitzung bleibt vollständig. */ antworten('IV-01', 'nochmal', ['b'], false); // sofort wieder fällig antworten('IV-02', 'gut', ['b']); // beantwortet, nicht fällig lernstand.profilAktualisieren({ id: profilId, pruefungstermin: '2026-03-01' }); const reihenfolge = lernstand.sitzung(profilId, { mischen: false }).map((s) => s.frageId); expect(reihenfolge).toEqual([ 'IV-01', 'IV-02', 'I.1-01', 'I.1-02', 'I.1-03', 'I.2-01', 'I.2-02', 'II-01', 'II-02', 'II-03', ]); }); it('lässt „nur Neue“ auch am Prüfungstag vollständig üben', () => { // Wer ausdrücklich neue Fragen anfordert, bekommt sie – die Rate ordnet // nur die gemischte Sitzung, sie beschneidet keine ausdrückliche Wahl. lernstand.profilAktualisieren({ id: profilId, pruefungstermin: '2026-03-01' }); expect(lernstand.sitzung(profilId, { nurNeue: true, mischen: false })).toHaveLength( FRAGEN.length, ); }); it('behält die Fragenmenge bei, wenn gemischt wird', () => { const ungemischt = lernstand.sitzung(profilId, { mischen: false }).map((s) => s.frageId); starten(() => 0); // deterministische Zufallsquelle const gemischt = lernstand.sitzung(profilId, { mischen: true }).map((s) => s.frageId); expect(gemischt).toHaveLength(ungemischt.length); expect([...gemischt].sort()).toEqual([...ungemischt].sort()); expect(gemischt).not.toEqual(ungemischt); }); it('mischt nur innerhalb der Gruppen, nie über ihre Grenzen', () => { starten(() => 0); // deterministische Zufallsquelle antworten('I.1-01', 'leicht', ['a', 'c']); // vier Tage Pause → Restgruppe const reihenfolge = lernstand.sitzung(profilId, { mischen: true }).map((s) => s.frageId); // Die pausierte Frage bleibt trotz Mischen hinter allen neuen. expect(reihenfolge.at(-1)).toBe('I.1-01'); expect(reihenfolge).toHaveLength(10); }); /* Die Wache, die gefehlt hat. Bis 0.26.6 wurde die fällige Gruppe gemischt und danach auf den Sitzungsumfang abgeschnitten. Stehen mehr Fragen fällig als in eine Sitzung passen, entschied damit das Los, welche vorgelegt werden – und die am stärksten vergessene konnte Tag um Tag hinten bleiben. Geprüft wird an zwei Fragen, die beide fällig sind, sich aber im Gedächtnisstand unterscheiden: „nochmal“ setzt eine kleine Stabilität, „leicht“ eine große. Die schwache Frage ist nach derselben Wartezeit weiter gefallen und gehört deshalb nach vorn. */ it('legt bei Rückstand das am stärksten Vergessene zuerst vor', () => { /* 0,99 und nicht 0: Fisher-Yates mit einer Quelle, die stets 0 liefert, kehrt ein Zweierfeld um – die alte Fassung hätte die schwache Frage dann zufällig richtig einsortiert und dieser Test wäre auch ohne die Behebung grün gewesen. Mit 0,99 bleibt j gleich i, die Katalogfolge steht, und die alte Fassung fällt nachweislich durch. */ starten(() => 0.99); /* Beide am selben Tag beantwortet, mit weit auseinanderliegender Bewertung – und beide danach so lange liegengelassen, dass sie fällig sind. */ antworten('I.1-01', 'leicht', ['a', 'c']); antworten('I.1-02', 'schwer', ['b']); uhr.setTime(uhr.getTime() + 400 * TAG_MS); const reihenfolge = lernstand .sitzung(profilId, { mischen: true }) .map((sitzungsfrage) => sitzungsfrage.frageId); const schwach = reihenfolge.indexOf('I.1-02'); const fest = reihenfolge.indexOf('I.1-01'); expect(schwach).toBeGreaterThanOrEqual(0); expect(fest).toBeGreaterThanOrEqual(0); expect(schwach).toBeLessThan(fest); }); it('liefert die Antwortlabels in Katalogreihenfolge, wenn nicht gemischt wird', () => { const sitzung = lernstand.sitzung(profilId, { mischen: false }); expect(sitzung.find((s) => s.frageId === 'I.2-01')?.optionsReihenfolge).toEqual([ 'a', 'b', 'c', 'd', ]); expect(sitzung.find((s) => s.frageId === 'I.1-03')?.optionsReihenfolge).toEqual([]); }); it('permutiert die Antwortlabels beim Mischen, ohne welche zu verlieren', () => { starten(() => 0); const sitzung = lernstand.sitzung(profilId, { mischen: true, optionenMischen: true }); const labels = sitzung.find((s) => s.frageId === 'I.2-01')?.optionsReihenfolge ?? []; expect([...labels].sort()).toEqual(['a', 'b', 'c', 'd']); expect(labels).not.toEqual(['a', 'b', 'c', 'd']); }); /* `optionenMischen` trennt die Antwortreihenfolge von der Fragenreihenfolge. Wer sich Antworten über ihre Stelle merkt, braucht sie fest – aber deswegen nicht auch noch immer dieselben Fragen zuerst. */ it('lässt die Antwortlabels stehen, während die Fragen gemischt werden', () => { starten(() => 0); const sitzung = lernstand.sitzung(profilId, { mischen: true, optionenMischen: false }); expect(sitzung.find((s) => s.frageId === 'I.2-01')?.optionsReihenfolge).toEqual([ 'a', 'b', 'c', 'd', ]); }); it('mischt die Antwortlabels, während die Fragen in Katalogreihenfolge bleiben', () => { starten(() => 0); const sitzung = lernstand.sitzung(profilId, { mischen: false, optionenMischen: true }); const labels = sitzung.find((s) => s.frageId === 'I.2-01')?.optionsReihenfolge ?? []; expect([...labels].sort()).toEqual(['a', 'b', 'c', 'd']); expect(labels).not.toEqual(['a', 'b', 'c', 'd']); }); it('bleibt ohne eigene Angabe in Katalogreihenfolge – auch beim Fragenmischen', () => { /* Der eigentliche Regressionsschutz. Früher fiel `optionenMischen` auf `mischen` zurück: Wer nur die Fragen mischen wollte, mischte die Antworten stillschweigend mit – die Ursache des Befundes, der zu dieser Umstellung geführt hat. Beide Fälle müssen jetzt dieselbe, amtliche Reihenfolge liefern. */ starten(() => 0); for (const filter of [{ mischen: false }, { mischen: true }]) { const ohne = lernstand.sitzung(profilId, filter); expect(ohne.find((s) => s.frageId === 'I.2-01')?.optionsReihenfolge).toEqual([ 'a', 'b', 'c', 'd', ]); } }); it.each([ [{ kapitel: ['IX'] }, /Unbekanntes Kapitel/u], [{ abschnitte: ['I.9'] }, /Unbekannter Abschnitt/u], [{ kapitel: 'I' }, /kapitel/u], [{ anzahl: 0 }, /anzahl/u], [{ anzahl: 5000 }, /anzahl/u], [{ anzahl: 2.5 }, /anzahl/u], [{ nurNeue: 'ja' }, /nurNeue/u], ['kein Objekt', /Filter/u], ])('weist den ungültigen Filter %o zurück', (filter, muster) => { expect(() => lernstand.sitzung(profilId, filter)).toThrow(muster); }); it('nimmt einen fehlenden Filter als „alles, gemischt“ an', () => { expect(lernstand.sitzung(profilId, undefined)).toHaveLength(10); expect(lernstand.sitzung(profilId, {})).toHaveLength(10); }); }); // ─── Migration auf Schema 3 ───────────────────────────────────────────────── describe('Migration auf den Gedächtnisstand', () => { /** Spaltennamen einer Tabelle. */ function spalten(verbindung: Database.Database, tabelle: string): string[] { return verbindung .prepare<[], { name: string }>(`PRAGMA table_info(${tabelle})`) .all() .map((z) => z.name); } /** * Versetzt eine geöffnete Datenbank in den Stand vor der FSRS-Umstellung: * ohne die beiden Spalten und mit vermerkter Schema-Version 2. */ function aufVersionZwei(verbindung: Database.Database): void { /* SQLite kann Spalten erst seit 3.35 löschen – vorhanden, aber der ausdrückliche Weg über eine neue Tabelle ist hier ohnehin näher an dem, was eine echte alte Installation enthält. */ verbindung.exec(` CREATE TABLE frage_stand_alt ( profil_id INTEGER NOT NULL, frage_id TEXT NOT NULL, versuche INTEGER NOT NULL DEFAULT 0, richtige INTEGER NOT NULL DEFAULT 0, zuletzt_beantwortet TEXT, faellig_ab TEXT, gemerkt INTEGER NOT NULL DEFAULT 0, letzte_bewertung TEXT, intervall_tage REAL NOT NULL DEFAULT 0, PRIMARY KEY (profil_id, frage_id) ); INSERT INTO frage_stand_alt SELECT profil_id, frage_id, versuche, richtige, zuletzt_beantwortet, faellig_ab, gemerkt, letzte_bewertung, intervall_tage FROM frage_stand; DROP TABLE frage_stand; ALTER TABLE frage_stand_alt RENAME TO frage_stand; `); verbindung.prepare('DELETE FROM schema_version WHERE version > 2').run(); } it('ergänzt die Spalten, ohne einen bestehenden Lernstand zu verlieren', () => { antworten('I.1-01', 'gut', ['a', 'c']); lernstand.merken(profilId, 'I.1-02', true); const vorher = lernstand.frageStand(profilId, 'I.1-01'); aufVersionZwei(db); expect(spalten(db, 'frage_stand')).not.toContain('stabilitaet'); const nachher = new Lernstand(db, KATALOG, { jetzt: () => uhr }); expect(spalten(db, 'frage_stand')).toContain('stabilitaet'); expect(spalten(db, 'frage_stand')).toContain('schwierigkeit'); expect( db .prepare<[], { version: number }>('SELECT MAX(version) AS version FROM schema_version') .get()?.version, ).toBe(SCHEMA_VERSION); expect(nachher.frageStand(profilId, 'I.1-01')).toMatchObject({ versuche: vorher.versuche, richtige: vorher.richtige, faelligAb: vorher.faelligAb, }); expect(nachher.frageStand(profilId, 'I.1-02').gemerkt).toBe(true); }); it('nimmt eine Frage ohne Gedächtnisstand als neu, ohne den Zähler zu verlieren', () => { // Eine Frage aus der Zeit vor der Umstellung hat Versuche, aber keine // Stabilität. Sie darf deshalb nicht als „nie beantwortet“ verschwinden. antworten('I.1-01', 'leicht', ['a', 'c']); aufVersionZwei(db); const nachher = new Lernstand(db, KATALOG, { jetzt: () => uhr }); const stand = nachher.antworten(profilId, { frageId: 'I.1-01', auswahl: ['a', 'c'], richtig: true, bewertung: 'gut', dauerMs: 1000, }); expect(stand.versuche).toBe(2); expect(stand.richtige).toBe(2); // Anfangsstabilität für „gut“ sind 2,3 Tage – aufgerundet zwei. expect(faelligInTagen(stand.faelligAb)).toBe(2); }); it('lässt sich beliebig oft öffnen, auch wenn die Version zurückgesetzt wurde', () => { /* Ein Schritt, der beim zweiten Lauf scheitert, würde die Anwendung beim Start abstürzen lassen – ohne Weg zurück für den Nutzer. */ antworten('I.1-01', 'gut', ['a', 'c']); for (let i = 0; i < 3; i += 1) { db.prepare('DELETE FROM schema_version WHERE version > 1').run(); expect(() => new Lernstand(db, KATALOG, { jetzt: () => uhr })).not.toThrow(); } expect(new Lernstand(db, KATALOG, { jetzt: () => uhr }).uebersicht(profilId).beantwortet).toBe( 1, ); }); it('weist eine Datenbank aus einer neueren Programmversion ab', () => { db.prepare('INSERT OR IGNORE INTO schema_version (version, angewendet_am) VALUES (?, ?)').run( SCHEMA_VERSION + 1, new Date().toISOString(), ); expect(() => new Lernstand(db, KATALOG, { jetzt: () => uhr })).toThrow( /neueren Programmversion/u, ); }); it('fasst einen Lernstand aus neuerer Version gar nicht erst an', () => { /* Die neuere Fassung muss ihn danach noch öffnen können. Würde erst das Schema angewandt und dann abgewiesen, hätte die ältere Fassung bereits hineingeschrieben – WAL-Modus eingeschaltet, Tabellen angelegt. */ const fremd = new Database(':memory:'); verbindungen.push(fremd); fremd.exec(` CREATE TABLE schema_version (version INTEGER NOT NULL PRIMARY KEY, angewendet_am TEXT); INSERT INTO schema_version VALUES (${String(SCHEMA_VERSION + 1)}, '2026-01-01'); `); expect(() => new Lernstand(fremd, KATALOG, { jetzt: () => uhr })).toThrow( /neueren Programmversion/u, ); const tabellen = fremd .prepare<[], { name: string }>("SELECT name FROM sqlite_master WHERE type = 'table'") .all() .map((z) => z.name); expect(tabellen).toEqual(['schema_version']); }); it('nimmt eine noch leere Datei an, in der es nichts zu schützen gibt', () => { const leer = new Database(':memory:'); verbindungen.push(leer); expect(() => new Lernstand(leer, KATALOG, { jetzt: () => uhr })).not.toThrow(); }); }); // ─── Katalogstand-Abgleich (Schema 9) ─────────────────────────────────────── describe('Katalogstand-Abgleich', () => { /* Eine neue BVA-Fassung kann Fragen umnummerieren oder streichen; Zeilen in `frage_stand` und `antwort_log` zeigten dann still ins Leere. Ein echter Migrationspfad ist ohne die künftige Fassung nicht baubar – was sich bauen lässt, ist Ehrlichkeit: erkennen, beziffern, melden, nichts löschen. */ /** Derselbe Katalog mit anderem Stand und (wahlweise) anderer Fragenliste. */ function katalogFassung(stand: string, fragen: readonly Frage[] = FRAGEN): Katalog { return { ...KATALOG, meta: { ...KATALOG.meta, stand, fragen_gesamt: fragen.length }, fragen, }; } it('schweigt, solange der Stand unverändert ist', () => { expect(lernstand.katalogwechsel).toBeNull(); const zweite = new Lernstand(db, KATALOG, { jetzt: () => uhr }); expect(zweite.katalogwechsel).toBeNull(); /* Und es entsteht kein zweiter Vermerk – die Tabelle wüchse sonst mit jedem Start, und ihr Verlauf sagte nichts mehr. */ expect(db.prepare<[], { n: number }>('SELECT COUNT(*) AS n FROM katalog_stand').get()?.n).toBe( 1, ); }); it('meldet einen Wechsel und zählt die verwaisten Zeilen', () => { /* Zwei Antworten auf I.1-01, eine auf II-01, eine Merkzeile zu IV-01. */ antworten('I.1-01', 'gut', ['a', 'c']); tageWeiter(1); antworten('I.1-01', 'gut', ['a', 'c']); antworten('II-01', 'gut', ['a']); lernstand.merken(profilId, 'IV-01', true); /* Die neue Fassung kennt I.1-01 und IV-01 nicht mehr. */ const neueFragen = FRAGEN.filter((f) => f.id !== 'I.1-01' && f.id !== 'IV-01'); const zweite = new Lernstand(db, katalogFassung('2025-06-01', neueFragen), { jetzt: () => uhr, }); expect(zweite.katalogwechsel).toEqual({ vorher: '2024-12-16', nachher: '2025-06-01', // I.1-01 (beantwortet) und IV-01 (nur gemerkt) – je eine Standzeile. verwaisteStaende: 2, // Die beiden Protokollzeilen zu I.1-01; II-01 gibt es weiterhin. verwaisteAntworten: 2, }); }); it('löscht die verwaisten Zeilen nicht', () => { antworten('I.1-01', 'gut', ['a', 'c']); const neueFragen = FRAGEN.filter((f) => f.id !== 'I.1-01'); void new Lernstand(db, katalogFassung('2025-06-01', neueFragen), { jetzt: () => uhr }); /* Eine spätere Programmfassung kann die Zeilen vielleicht noch zuordnen – gelöscht kann sie es sicher nicht mehr. */ expect( db .prepare<[], { n: number }>( "SELECT COUNT(*) AS n FROM antwort_log WHERE frage_id = 'I.1-01'", ) .get()?.n, ).toBe(1); expect( db .prepare<[], { n: number }>( "SELECT COUNT(*) AS n FROM frage_stand WHERE frage_id = 'I.1-01'", ) .get()?.n, ).toBe(1); }); it('übernimmt den neuen Stand nach dem Befund und meldet ihn danach nicht erneut', () => { const fassung = katalogFassung('2025-06-01'); const zweite = new Lernstand(db, fassung, { jetzt: () => uhr }); expect(zweite.katalogwechsel).not.toBeNull(); /* Der Wechsel ist jetzt vermerkt: Der nächste Start findet Gleichstand vor und schweigt – die Meldung kommt genau einmal. */ const dritte = new Lernstand(db, fassung, { jetzt: () => uhr }); expect(dritte.katalogwechsel).toBeNull(); }); it('meldet auch einen Wechsel, bei dem nichts verwaist', () => { /* Gleiche Fragen, neuer Stand – etwa eine Fassung, die nur Druckfehler berichtigt. Der Wechsel bleibt meldenswert, die Zählungen sagen dann ehrlich: nichts betroffen. */ antworten('I.1-01', 'gut', ['a', 'c']); const zweite = new Lernstand(db, katalogFassung('2025-06-01'), { jetzt: () => uhr }); expect(zweite.katalogwechsel).toEqual({ vorher: '2024-12-16', nachher: '2025-06-01', verwaisteStaende: 0, verwaisteAntworten: 0, }); }); }); describe('Dauerhaft abgewählte Kapitel', () => { /* Kapitel IV („Not- und Seenotsignalmittel") prüft nicht jede Prüfungsstelle. Wer es nie braucht, schleppte 89 der 575 Fragen durch jede Zahl der Anwendung: Fortschritt, Tagespensum, Prognose, Prüfungsreife. Die Kartierung vor dem Bau hat gewarnt, dass eine grüne Suite hier fast nichts beweist – die meisten Stellen mit „575" sind Attrappenwerte. Diese Tests laufen deshalb durch den echten Kern, gegen eine echte Datenbank. */ it('beginnt ohne Abwahl', () => { expect(lernstand.profile()[0]?.kapitelAusschluss).toEqual([]); expect(lernstand.uebersicht(profilId).fragenGesamt).toBe(10); }); it('nimmt abgewählte Fragen aus jeder Lernsitzung', () => { lernstand.profilAktualisieren({ id: profilId, kapitelAusschluss: ['IV'] }); const alle = lernstand.sitzung(profilId, { mischen: false }).map((s) => s.frageId); expect(alle).toHaveLength(8); expect(alle.some((id) => id.startsWith('IV-'))).toBe(false); }); it('nimmt sie auch aus „nur neue" und „nur gemerkte"', () => { lernstand.merken(profilId, 'IV-01', true); lernstand.profilAktualisieren({ id: profilId, kapitelAusschluss: ['IV'] }); expect(lernstand.sitzung(profilId, { nurNeue: true }).map((s) => s.frageId)).not.toContain( 'IV-01', ); expect(lernstand.sitzung(profilId, { nurGemerkte: true })).toEqual([]); }); it('nimmt sie aus „nur Fehler" – gerade dort ist es nötig', () => { /* Die Quelle von „nur Fehler" ist antwort_log, und dort landen auch Antworten aus Prüfungsläufen, die Kapitel IV enthielten. Ohne Vorfilterung legte ausgerechnet dieser Weg die abgewählten Fragen wieder vor – und zwar genau die, die man zwangsläufig falsch hatte. */ antworten('IV-01', 'nochmal', ['b'], false); expect(lernstand.sitzung(profilId, { nurFehler: true }).map((s) => s.frageId)).toContain( 'IV-01', ); lernstand.profilAktualisieren({ id: profilId, kapitelAusschluss: ['IV'] }); expect(lernstand.sitzung(profilId, { nurFehler: true })).toEqual([]); }); it('rechnet die Übersicht ohne sie – Nenner UND Zähler', () => { /* Nur den Nenner zu filtern wäre der teure Fehler: Ein Bildschirmleser läse dann „10 von 8 Fragen sicher". */ antworten('IV-01', 'leicht', ['a'], true); tageWeiter(2); antworten('IV-01', 'leicht', ['a'], true); expect(lernstand.uebersicht(profilId)).toMatchObject({ fragenGesamt: 10, belegt: 1 }); lernstand.profilAktualisieren({ id: profilId, kapitelAusschluss: ['IV'] }); const nachher = lernstand.uebersicht(profilId); expect(nachher.fragenGesamt).toBe(8); expect(nachher.belegt).toBe(0); expect(nachher.beantwortet).toBe(0); }); it('lässt das Kapitel aus der Bereichsaufschlüsselung verschwinden', () => { /* Bliebe es mit 0 stehen, summierten sich die Bereiche zu 10, während fragenGesamt 8 sagt – zwei Zahlen auf einem Bildschirm, die einander widersprechen. */ lernstand.profilAktualisieren({ id: profilId, kapitelAusschluss: ['IV'] }); const bereiche = lernstand.uebersicht(profilId).bereiche; expect(bereiche.map((b) => b.id)).toEqual(['I.1', 'I.2', 'II']); expect(bereiche.reduce((summe, b) => summe + b.fragenGesamt, 0)).toBe( lernstand.uebersicht(profilId).fragenGesamt, ); }); it('rechnet den Lernplan ohne sie', () => { const vorher = lernstand.lernplan(profilId); expect(vorher.gesamtFragen).toBe(10); lernstand.profilAktualisieren({ id: profilId, kapitelAusschluss: ['IV'] }); const nachher = lernstand.lernplan(profilId); /* ENTFERNT, nicht neutralisiert: Wer die Fragen mit stabilitaet null durchreichte, ließe sie im Nenner von reifegradVon stehen – die Prognose bliebe exakt gleich, und die Zahl sähe trotzdem plausibel aus. */ expect(nachher.gesamtFragen).toBe(8); expect(nachher.nieBeantwortet).toBe(8); }); it('blendet aus, statt zu löschen – und alles kehrt zurück', () => { antworten('IV-01', 'leicht', ['a'], true); tageWeiter(2); antworten('IV-01', 'leicht', ['a'], true); lernstand.merken(profilId, 'IV-02', true); lernstand.profilAktualisieren({ id: profilId, kapitelAusschluss: ['IV'] }); expect(lernstand.uebersicht(profilId).belegt).toBe(0); lernstand.profilAktualisieren({ id: profilId, kapitelAusschluss: [] }); const zurueck = lernstand.uebersicht(profilId); expect(zurueck.fragenGesamt).toBe(10); expect(zurueck.belegt).toBe(1); expect(zurueck.gemerkt).toBe(1); }); it('gilt je Profil, nicht für die Anwendung', () => { const zweites = lernstand.profilAnlegen('Wassersportlerin'); lernstand.profilAktualisieren({ id: profilId, kapitelAusschluss: ['IV'] }); expect(lernstand.uebersicht(profilId).fragenGesamt).toBe(8); expect(lernstand.uebersicht(zweites.id).fragenGesamt).toBe(10); }); it('weist ein unbekanntes Kapitel ab', () => { expect(() => lernstand.profilAktualisieren({ id: profilId, kapitelAusschluss: ['XI'] }), ).toThrow(/Unbekanntes Kapitel/u); expect(() => lernstand.profilAktualisieren({ id: profilId, kapitelAusschluss: 'IV' })).toThrow( /muss eine Liste sein/u, ); }); it('speichert in Katalogreihenfolge und ohne Doppelte', () => { /* Was gespeichert wird, soll nicht davon abhängen, in welcher Reihenfolge jemand geklickt hat. */ const profil = lernstand.profilAktualisieren({ id: profilId, kapitelAusschluss: ['IV', 'II', 'IV'], }); expect(profil.kapitelAusschluss).toEqual(['II', 'IV']); }); it('rührt Zurücksetzen und Tagesbilanz nicht an', () => { /* Die Tagesbilanz ist ein Protokoll dessen, was jemand getan hat – keine Bestandszahl. Wer heute eine Kapitel-IV-Frage beantwortet und danach abwählt, sieht sie dort weiterhin. */ antworten('IV-01', 'leicht', ['a'], true); lernstand.profilAktualisieren({ id: profilId, kapitelAusschluss: ['IV'] }); expect(lernstand.uebersicht(profilId).heuteRichtig).toBe(1); expect(() => lernstand.zuruecksetzen(profilId, 'IV')).not.toThrow(); }); }); describe('Tagesbilanz und Tageszahlen', () => { /* Die Tagesbilanz war verzerrt: Lief in der Prüfungssimulation die Zeit ab, buchte `abgeben` auch die nie aufgeschlagenen Fragen in die Historie – als falsch. Sie gehören dorthin, der Bogen enthielt sie ja; eine Antwort sind sie nicht. */ it('zählt nie aufgeschlagene Prüfungsfragen nicht als heute falsch beantwortet', () => { antworten('I.1-01', 'gut', ['a', 'c']); /* So bucht `Pruefung.abgeben` eine Frage, die im Bogen stand, aber nie gezeigt wurde. */ lernstand.protokollieren(profilId, { frageId: 'I.1-02', auswahl: [], richtig: false, bewertung: 'nochmal', dauerMs: 1000, }); const uebersicht = lernstand.uebersicht(profilId); expect(uebersicht.heuteRichtig).toBe(1); expect(uebersicht.heuteFalsch).toBe(0); }); /* Dieselbe Verunreinigung, drei Stellen weiter: `letzteAntwortKarte` filterte `nur_historie` nicht heraus. Die Folge war eine Zahl, ein Filter und ein gedrucktes Blatt, die alle dasselbe behaupteten – der Lernende habe eine Frage falsch beantwortet, die er nie gesehen hat. Ein 80-Fragen-Bogen mit Zeitablauf nach Frage 20 erzeugte so 60 Fehler. */ it('stellt eine heute Abend fällige Frage unter „heute“, nicht unter „morgen“', () => { /* Die Vorschau beschriftet ihre Spalten als Kalendertage („heute“, „morgen“, danach Wochentag und Datum). Bis 0.24.1 rechnete sie in Vierundzwanzig-Stunden-Blöcken: Wer abends lernte und morgens plante, sah jeden Tag die Last des Vortages. Hier nachgestellt: Antwort am Abend, Wiedervorlage am Folgetag zur selben Stunde – geplant wird am Morgen dieses Folgetags. */ uhr = new Date('2026-03-02T20:00:00.000Z'); const stand = antworten('I.1-01', 'gut', ['a', 'c']); const termin = new Date(stand.faelligAb ?? ''); expect(Number.isNaN(termin.getTime())).toBe(false); /* Am Morgen des Fälligkeitstages: Die Frage wird heute Abend fällig. */ uhr = new Date(termin.getFullYear(), termin.getMonth(), termin.getDate(), 8, 0, 0); expect(uhr.getTime()).toBeLessThan(termin.getTime()); const plan = lernstand.lernplan(profilId); expect(plan.vorschau?.[0], 'Die heute fällige Frage steht nicht in der Spalte „heute“.').toBe( 1, ); expect(plan.vorschau?.[1]).toBe(0); }); it('lässt nie aufgeschlagene Prüfungsfragen aus der Tempo-Schätzung heraus', () => { /* `dauer_ms` einer Historienzeile ist keine gemessene Zeit, sondern Gesamtdauer geteilt durch Fragenzahl. Bis 0.24.1 ging sie in den Median ein und verschob die Zeitschätzung des Tagespensums. */ for (const frage of ['I.1-01', 'I.1-02', 'I.1-03', 'I.2-01', 'I.2-02']) { lernstand.antworten(profilId, { frageId: frage, auswahl: [], richtig: true, bewertung: 'gut', dauerMs: 10_000, }); } const echtesTempo = lernstand.lernplan(profilId).sekundenProFrage; /* Sechzig erfundene Werte von je einer Sekunde – so sieht ein abgelaufener Bogen aus. */ for (let i = 0; i < 60; i++) { lernstand.protokollieren(profilId, { frageId: 'II-01', auswahl: [], richtig: false, bewertung: 'nochmal', dauerMs: 1000, }); } expect(lernstand.lernplan(profilId).sekundenProFrage).toBe(echtesTempo); }); it('zählt nie aufgeschlagene Prüfungsfragen nicht als „zuletzt falsch beantwortet“', () => { antworten('I.1-01', 'nochmal', ['b']); // wirklich falsch beantwortet lernstand.protokollieren(profilId, { frageId: 'I.1-02', auswahl: [], richtig: false, bewertung: 'nochmal', dauerMs: 1000, }); expect(lernstand.uebersicht(profilId).fehler).toBe(1); expect( lernstand.sitzung(profilId, { nurFehler: true, mischen: false }).map((s) => s.frageId), ).toEqual(['I.1-01']); }); it('lässt eine echte spätere Antwort auf dieselbe Frage weiterhin gelten', () => { /* Die Gegenprobe: Der Ausschluss darf nur Historienzeilen treffen, nicht die Frage stumm schalten. */ lernstand.protokollieren(profilId, { frageId: 'I.1-01', auswahl: [], richtig: false, bewertung: 'nochmal', dauerMs: 1000, }); antworten('I.1-01', 'nochmal', ['b']); expect( lernstand.sitzung(profilId, { nurFehler: true, mischen: false }).map((s) => s.frageId), ).toEqual(['I.1-01']); }); it('lässt die Historie trotzdem vollständig', () => { /* Der Eintrag verschwindet nicht – er zählt nur nicht als Antwort des Tages. Die Historie ist die Grundlage für Statistik und für ein späteres Nachtrainieren der FSRS-Parameter. */ lernstand.protokollieren(profilId, { frageId: 'I.1-02', auswahl: [], richtig: false, bewertung: 'nochmal', dauerMs: 1000, }); const zeilen = db .prepare<[], { anzahl: number }>('SELECT COUNT(*) AS anzahl FROM antwort_log') .get(); expect(zeilen?.anzahl).toBe(1); }); it('zählt heute bearbeitete Fragen einmal, nicht je Antwort', () => { /* Eine mit „Nicht gewusst“ bewertete Frage ist sofort wieder fällig und wird in derselben Sitzung noch einmal beantwortet. Als Zeilen im Protokoll wären das zwei; bearbeitet wurde eine Frage. */ antworten('I.1-01', 'nochmal', ['b'], false); antworten('I.1-01', 'gut', ['a', 'c']); antworten('I.1-02', 'gut', ['a']); expect(lernstand.uebersicht(profilId).heuteBearbeitet).toBe(2); }); it('rechnet den Abstand in Kalendertagen, nicht in Stunden', () => { /* Wer gestern abend und heute früh lernt, hat nicht zwei Tage Abstand. */ antworten('I.1-01', 'gut', ['a', 'c']); expect(lernstand.uebersicht(profilId).tageSeitLetzterAntwort).toBe(0); tageWeiter(1); expect(lernstand.uebersicht(profilId).tageSeitLetzterAntwort).toBe(1); tageWeiter(13); expect(lernstand.uebersicht(profilId).tageSeitLetzterAntwort).toBe(14); expect(lernstand.uebersicht(profilId).heuteBearbeitet).toBe(0); }); it('meldet „nie beantwortet“ als null und nicht als null Tage', () => { /* Der Unterschied zwischen „heute gelernt“ und „noch nie gelernt“ darf nicht verlorengehen – beides wäre sonst die Zahl 0. */ expect(lernstand.uebersicht(profilId).tageSeitLetzterAntwort).toBeNull(); }); }); describe('Feingliederung der Kapitel II bis IV', () => { /* Der amtliche Katalog gliedert nur Kapitel I in Abschnitte. Für die 230 Fragen der Kapitel II bis IV standen in der Aufschlüsselung deshalb genau drei Balken – „Kapitel IV, 61 von 88“ sagt einem Lernenden nicht, was er üben soll. */ const THEMEN: Themen = { meta: { version: 1, stand: '2026-09-01', hinweis: 'Prüfstand' }, gruppen: [ { id: 'II.1', kapitel: 'II', titel: 'Waffenarten', fragen: ['II-01', 'II-02'] }, { id: 'II.2', kapitel: 'II', titel: 'Munition', fragen: ['II-03'] }, { id: 'IV.1', kapitel: 'IV', titel: 'Signalmittel', fragen: ['IV-01', 'IV-02'] }, ], }; function mitThemen(): Lernstand { const eigene = new Database(':memory:'); verbindungen.push(eigene); return new Lernstand(eigene, KATALOG, { jetzt: () => uhr, themen: THEMEN }); } it('bleibt leer, solange keine Feingliederung gereicht wird', () => { /* Der Lernstand lädt sie nicht selbst – `main/themen.ts` hängt an Electron. Ohne sie zeigt die Oberfläche die Gruppen nicht, und keine amtliche Zahl ändert sich. */ expect(lernstand.uebersicht(profilId).themengruppen).toEqual([]); }); it('schlüsselt die Kapitel in Gruppen auf, ohne eine amtliche Zahl zu ändern', () => { const stand = mitThemen(); const id = stand.profile()[0]!.id; const uebersicht = stand.uebersicht(id); expect(uebersicht.themengruppen.map((gruppe) => gruppe.id)).toEqual(['II.1', 'II.2', 'IV.1']); /* Die Kapitelzeilen sind unverändert – die Gruppen stehen daneben, nicht an ihrer Stelle. */ expect(uebersicht.bereiche.map((bereich) => bereich.id).sort()).toEqual([ 'I.1', 'I.2', 'II', 'IV', ]); expect(uebersicht.fragenGesamt).toBe(lernstand.uebersicht(profilId).fragenGesamt); }); it('zählt in jeder Gruppe dieselben Fragen wie im Kapitel darüber', () => { /* Die Zusicherung, an der die ganze Anzeige hängt: Die Summe der Gruppen eines Kapitels muss das Kapitel ergeben. Sonst stünden zwei Zahlen untereinander auf einem Bildschirm, die einander widersprechen – genau der Fehler, den `docs/stand.md` 7.1 schon einmal beschrieben hat. */ const stand = mitThemen(); const id = stand.profile()[0]!.id; const uebersicht = stand.uebersicht(id); for (const kapitel of ['II', 'IV']) { const zeile = uebersicht.bereiche.find((bereich) => bereich.id === kapitel); const summe = uebersicht.themengruppen .filter((gruppe) => gruppe.kapitel === kapitel) .reduce((zwischen, gruppe) => zwischen + gruppe.fragenGesamt, 0); expect(summe, `Kapitel ${kapitel}`).toBe(zeile?.fragenGesamt); } }); it('zählt beantwortete Fragen je Gruppe und nicht je Kapitel', () => { const stand = mitThemen(); const id = stand.profile()[0]!.id; stand.antworten(id, { frageId: 'II-01', auswahl: ['a'], richtig: true, bewertung: 'gut', dauerMs: 1500, }); const gruppen = stand.uebersicht(id).themengruppen; expect(gruppen.find((gruppe) => gruppe.id === 'II.1')?.beantwortet).toBe(1); expect(gruppen.find((gruppe) => gruppe.id === 'II.2')?.beantwortet).toBe(0); }); it('lässt eine Gruppe weg, deren Kapitel abgewählt ist', () => { /* Sonst summierten sich die Gruppen zu mehr, als `fragenGesamt` sagt – dieselbe Regel wie bei den Kapitelzeilen. */ const stand = mitThemen(); const id = stand.profile()[0]!.id; stand.profilAktualisieren({ id, kapitelAusschluss: ['IV'] }); const gruppen = stand.uebersicht(id).themengruppen; expect(gruppen.map((gruppe) => gruppe.id)).toEqual(['II.1', 'II.2']); }); }); describe('Lastausgleich am laufenden Lernstand', () => { /* Der Ausgleich sitzt in `antworten()` und liest die schon angesetzten Termine des Profils. Geprüft wird deshalb hier und nicht nur an der reinen Funktion: Dass der Anwendungskern die Last überhaupt richtig zählt, ist die Hälfte der Sache. */ /** Wie viele Fragen an welchem Tag ab heute fällig sind. */ function lastJeTag(): Map { const zeilen = db .prepare<[number], { faellig_ab: string | null }>( 'SELECT faellig_ab FROM frage_stand WHERE profil_id = ? AND faellig_ab IS NOT NULL', ) .all(profilId); const last = new Map(); for (const zeile of zeilen) { const tag = Math.round((Date.parse(zeile.faellig_ab ?? '') - uhr.getTime()) / TAG_MS); last.set(tag, (last.get(tag) ?? 0) + 1); } return last; } it('verteilt einen Abend voller Antworten über mehrere Tage', () => { /* Ohne jede Verteilung stünden alle Fragen desselben Abends auf demselben Tag – und zwei Termine später wieder. Mit Streuung und Ausgleich stehen sie auf verschiedenen. */ for (const frage of FRAGEN) { antworten(frage.id, 'leicht'); } /* Zweite Runde nach Abstand: Erst dann sind die Intervalle lang genug, dass überhaupt ein Fenster entsteht (unter drei Tagen wird nicht verschoben). */ tageWeiter(3); for (const frage of FRAGEN) { antworten(frage.id, 'leicht'); } const last = lastJeTag(); expect(last.size).toBeGreaterThan(1); /* Kein Tag trägt alles. */ expect(Math.max(...last.values())).toBeLessThan(FRAGEN.length); }); it('setzt eine Frage nicht auf einen Tag, der schon voll ist', () => { /* Der eigentliche Zweck – und er wird zweimal gemessen, weil eine Behauptung über eine Verschiebung nur gilt, wenn der Vergleichswert bekannt ist. Erster Lauf: leerer Kalender, der Termin wird notiert. Zweiter Lauf: dieselbe Vorgeschichte, aber genau dieser Tag ist voll. Ohne den Ausgleich kommt zweimal derselbe Tag heraus – der erste Entwurf dieser Wache hat genau das übersehen und war grün, obwohl die Behebung ausgebaut war. */ function terminNachVorgeschichte(bergAufTag: number | null): number { starten(); antworten('I.1-01', 'leicht'); tageWeiter(4); antworten('I.1-01', 'leicht'); tageWeiter(10); if (bergAufTag !== null) { /* Alle anderen Fragen bekommen eine Zeile und werden dann auf genau diesen Tag gezwungen. */ for (const frage of FRAGEN) { if (frage.id !== 'I.1-01') { antworten(frage.id, 'leicht'); } } const ziel = new Date(uhr.getTime() + bergAufTag * TAG_MS).toISOString(); db.prepare<[string, number, string]>( 'UPDATE frage_stand SET faellig_ab = ? WHERE profil_id = ? AND frage_id <> ?', ).run(ziel, profilId, 'I.1-01'); } const stand = antworten('I.1-01', 'leicht'); return Math.round((Date.parse(stand.faelligAb ?? '') - uhr.getTime()) / TAG_MS); } const ohneBerg = terminNachVorgeschichte(null); /* Nur sinnvoll, wenn überhaupt ein Fenster besteht (ab drei Tagen). */ expect(ohneBerg).toBeGreaterThanOrEqual(3); const mitBerg = terminNachVorgeschichte(ohneBerg); expect(mitBerg).not.toBe(ohneBerg); /* Und das Fenster wird nicht verlassen: höchstens 15 Prozent, gedeckelt auf sieben Tage. */ const spanne = Math.min(7, Math.max(1, Math.round(ohneBerg * 0.15))); expect(Math.abs(mitBerg - ohneBerg)).toBeLessThanOrEqual(2 * spanne); }); });