// @vitest-environment node /** * Hartnäckige Fragen – die, die immer wieder danebengehen. * * **Der Anlass.** Die vollständige Fehlerhistorie liegt seit jeher in * `antwort_log`, aber keine einzige Abfrage wertete sie je Frage aus: * `letzteAntwortKarte` liest nur die **letzte** Antwort, und der Filter „Nur * Fehler“ hängt genau daran. Eine Frage, die über Wochen viermal durchfiel und * gestern zufällig saß, verschwand aus jeder Liste — obwohl sie das Gegenteil * von gekonnt ist. * * Gemessen wird hier gegen eine echte SQLite-Datenbank mit einer echten * Historie über mehrere Tage, nicht gegen eine Attrappe. */ import Database from 'better-sqlite3'; import { afterEach, beforeEach, describe, expect, it } from 'vitest'; import { Lernstand } from '../src/main/lernstand'; import { HARTNAECKIG_AB_TAGEN, hartnaeckigText } from '../src/shared/hartnaeckig'; import type { Frage, Katalog } from '../src/shared/katalog'; const TAG_MS = 86_400_000; function mcFrage(id: string, kapitel: string, abschnitt: string | null): Frage { return { id, amtliche_nummer: id, kapitel, abschnitt, typ: 'mc', seite: 1, frage: { text: `Frage ${id}`, segmente: [{ t: `Frage ${id}` }] }, bilder: [], optionen: ['a', 'b', 'c'].map((label) => ({ label, inhalt: { text: label, segmente: [{ t: label }] }, korrekt: label === 'a', bilder: [], })), } as unknown as Frage; } const FRAGEN: readonly Frage[] = [ mcFrage('I.1-01', 'I', 'I.1'), mcFrage('I.1-02', 'I', 'I.1'), mcFrage('II-01', 'II', null), mcFrage('IV-01', 'IV', null), ]; const KATALOG: Katalog = { meta: { titel: 'Prüfkatalog', herausgeber: 'Bundesverwaltungsamt', stand: '2024-12-16', quellenangabe: 'Attrappe', 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' }] }, { id: 'II', titel: 'Waffentechnik', abschnitte: [] }, { id: 'IV', titel: 'Seenotsignalmittel', abschnitte: [] }, ], bilder: [], fragen: FRAGEN, }; let uhr: Date; let db: Database.Database; let lernstand: Lernstand; let profilId: number; /** Antwortet auf eine Frage; `auswahl` steuert die Fehlwahl-Auswertung. */ function antworten(frageId: string, richtig: boolean, auswahl: readonly string[] = ['b']): void { lernstand.antworten(profilId, { frageId, auswahl: richtig ? ['a'] : auswahl, richtig, bewertung: richtig ? 'gut' : 'nochmal', dauerMs: 1500, }); } function tageWeiter(tage: number): void { uhr = new Date(uhr.getTime() + tage * TAG_MS); } beforeEach(() => { uhr = new Date('2026-03-01T10:00:00.000Z'); db = new Database(':memory:'); lernstand = new Lernstand(db, KATALOG, { jetzt: () => uhr }); profilId = lernstand.profile()[0]!.id; }); afterEach(() => { if (db.open) { db.close(); } }); describe('Auszählung', () => { it('meldet nichts, solange nichts danebenging', () => { antworten('I.1-01', true); expect(lernstand.hartnaeckige(profilId)).toEqual([]); }); it('meldet eine Frage erst ab der Schwelle', () => { for (let tag = 0; tag < HARTNAECKIG_AB_TAGEN - 1; tag++) { antworten('I.1-01', false); tageWeiter(1); } expect(lernstand.hartnaeckige(profilId)).toEqual([]); antworten('I.1-01', false); expect(lernstand.hartnaeckige(profilId)).toHaveLength(1); }); it('zählt Tage und nicht bloße Fehlschläge', () => { /* Drei Fehlschläge in einer Sitzung sind die gewöhnliche Wiedereinreihung, keine Hartnäckigkeit – die Frage kommt in derselben Sitzung zweimal wieder (`MAX_WIEDEREINREIHUNGEN`). */ for (let versuch = 0; versuch < 6; versuch++) { antworten('I.1-01', false); } expect(lernstand.hartnaeckige(profilId)).toEqual([]); }); it('behält eine Frage, die zwischendurch einmal saß', () => { /* Der eigentliche Befund: „Nur Fehler“ fragt die letzte Antwort. Wer gestern zufällig richtig lag, verschwand dort – und war doch nicht gekonnt. */ for (let tag = 0; tag < HARTNAECKIG_AB_TAGEN; tag++) { antworten('I.1-01', false); tageWeiter(1); } antworten('I.1-01', true); const liste = lernstand.hartnaeckige(profilId); expect(liste).toHaveLength(1); expect(liste[0]?.frageId).toBe('I.1-01'); }); it('nennt Fehlschläge, Tage und den letzten Zeitpunkt', () => { for (let tag = 0; tag < HARTNAECKIG_AB_TAGEN; tag++) { antworten('I.1-01', false); antworten('I.1-01', false); tageWeiter(1); } const eintrag = lernstand.hartnaeckige(profilId)[0]; expect(eintrag?.tage).toBe(HARTNAECKIG_AB_TAGEN); expect(eintrag?.fehlschlaege).toBe(HARTNAECKIG_AB_TAGEN * 2); expect(eintrag?.zuletzt).not.toBe(''); }); it('sortiert die auffälligsten nach vorn', () => { for (let tag = 0; tag < HARTNAECKIG_AB_TAGEN + 2; tag++) { antworten('II-01', false); tageWeiter(1); } uhr = new Date('2026-03-01T10:00:00.000Z'); for (let tag = 0; tag < HARTNAECKIG_AB_TAGEN; tag++) { antworten('I.1-01', false); tageWeiter(1); } const liste = lernstand.hartnaeckige(profilId); expect(liste[0]?.frageId).toBe('II-01'); }); }); describe('Wiederkehrende Fehlwahl', () => { it('nennt den immer gleichen falschen Buchstaben', () => { /* Die Spalte `auswahl` wurde bis 0.22.0 geschrieben und von keiner Abfrage gelesen. Immer derselbe Fehlgriff heißt Verwechslung – das ist mit einem Merksatz zu heilen. */ for (let tag = 0; tag < HARTNAECKIG_AB_TAGEN; tag++) { antworten('I.1-01', false, ['c']); tageWeiter(1); } expect(lernstand.hartnaeckige(profilId)[0]?.haeufigsteFehlwahl).toBe('c'); }); it('schweigt, wenn die Fehlgriffe streuen', () => { /* Gestreute Fehlgriffe heißen Nichtwissen – da gibt es kein Muster zu benennen, und eines zu behaupten wäre schlimmer als Schweigen. */ antworten('I.1-01', false, ['b']); tageWeiter(1); antworten('I.1-01', false, ['c']); tageWeiter(1); antworten('I.1-01', false, ['b', 'c']); expect(lernstand.hartnaeckige(profilId)[0]?.haeufigsteFehlwahl).toBeUndefined(); }); it('schweigt bei einem Gleichstand', () => { for (const wahl of [['b'], ['b'], ['c'], ['c']]) { antworten('I.1-01', false, wahl); tageWeiter(1); } expect(lernstand.hartnaeckige(profilId)[0]?.haeufigsteFehlwahl).toBeUndefined(); }); }); describe('Lernumfang', () => { it('übergeht ein dauerhaft abgewähltes Kapitel', () => { /* Wer Kapitel IV abgewählt hat, soll es auch hier nicht sehen – dieselbe Regel wie überall sonst. */ for (let tag = 0; tag < HARTNAECKIG_AB_TAGEN; tag++) { antworten('IV-01', false); tageWeiter(1); } expect(lernstand.hartnaeckige(profilId)).toHaveLength(1); lernstand.profilAktualisieren({ id: profilId, kapitelAusschluss: ['IV'] }); expect(lernstand.hartnaeckige(profilId)).toEqual([]); }); }); describe('Als Sitzungsfilter', () => { it('legt genau die hartnäckigen Fragen vor', () => { for (let tag = 0; tag < HARTNAECKIG_AB_TAGEN; tag++) { antworten('I.1-01', false); tageWeiter(1); } antworten('I.1-02', false); const sitzung = lernstand.sitzung(profilId, { nurHartnaeckige: true, anzahl: 50 }); expect(sitzung.map((eintrag) => eintrag.frageId)).toEqual(['I.1-01']); }); it('steht neben „nur Fehler“ und ersetzt es nicht', () => { /* Das gedruckte Fehlerprotokoll verspricht ausdrücklich: „Sobald Sie eine davon wieder richtig beantworten, verschwindet sie aus dieser Liste.“ Diese Zusage darf der neue Filter nicht brechen. */ for (let tag = 0; tag < HARTNAECKIG_AB_TAGEN; tag++) { antworten('I.1-01', false); tageWeiter(1); } antworten('I.1-01', true); expect(lernstand.sitzung(profilId, { nurFehler: true, anzahl: 50 })).toEqual([]); expect(lernstand.sitzung(profilId, { nurHartnaeckige: true, anzahl: 50 })).toHaveLength(1); }); }); /* Die Ist-Behaltensquote. Der Lernplan steuert auf eine Zielquote (0,90 bis 0,97), und die Reife-Ampel zeigt modellierte Abrufwahrscheinlichkeiten. Wie oft der Lernende fällige Wiederholungen **tatsächlich** trifft, stand bis 0.22.0 nirgends – obwohl es im Protokoll steht und nur zu zählen war. Geprüft wird hier vor allem, was **nicht** hineinzählt: Antworten ohne Abstand und die Zeilen, die eine Simulation für nie aufgeschlagene Fragen anlegt. */ describe('Behaltensquote', () => { /** Beantwortet `anzahl` Fragen an aufeinanderfolgenden Tagen. */ function wiederholungen(anzahl: number, richtig: boolean): void { for (let lauf = 0; lauf < anzahl; lauf++) { antworten('I.1-01', richtig); tageWeiter(1); } } it('schweigt, solange zu wenig vorliegt', () => { /* Eine Quote aus drei Antworten schwankt zwischen 0 und 100 Prozent und sähe nur aus wie eine Auskunft. */ wiederholungen(5, true); expect(lernstand.uebersicht(profilId).behaltensquote).toBeNull(); }); it('zählt Wiederholungen mit Abstand und ihre Treffer', () => { wiederholungen(20, true); wiederholungen(5, false); const quote = lernstand.uebersicht(profilId).behaltensquote; /* Die allererste Antwort hat keinen Vorgänger und zählt nicht mit. */ expect(quote?.gesamt).toBe(24); expect(quote?.richtig).toBe(19); }); it('zählt Antworten ohne Abstand nicht mit', () => { /* Zwei Antworten am selben Tag sagen nichts über das Behalten – das ist dieselbe Grenze, nach der auch der Reife-Beleg entsteht. Zehn Fehlversuche hintereinander an einem Tag: Der **erste** hat einen Tag Abstand zur letzten Antwort und zählt zu Recht mit; die neun darauf haben keinen. Ohne die Abstandsregel fiele die Quote hier um zehn Fehlschläge statt um einen. */ wiederholungen(25, true); const vorher = lernstand.uebersicht(profilId).behaltensquote; for (let lauf = 0; lauf < 10; lauf++) { antworten('I.1-01', false); } const nachher = lernstand.uebersicht(profilId).behaltensquote; expect(nachher?.gesamt).toBe((vorher?.gesamt ?? 0) + 1); expect(nachher?.richtig).toBe(vorher?.richtig); }); it('zählt nie aufgeschlagene Simulationsfragen nicht mit', () => { /* Sie tragen `richtig = 0`, ohne dass jemand sie gesehen hätte. Als Antwort brächten sie erfundene Fehlschläge in die Quote – genau die Verunreinigung, die entscheidung-reifegrad.md Abschnitt 8 benennt. */ wiederholungen(25, true); const vorher = lernstand.uebersicht(profilId).behaltensquote; for (let lauf = 0; lauf < 10; lauf++) { lernstand.protokollieren(profilId, { frageId: 'I.1-01', auswahl: [], richtig: false, bewertung: 'nochmal', dauerMs: 0, }); tageWeiter(1); } expect(lernstand.uebersicht(profilId).behaltensquote).toEqual(vorher); }); it('lässt sie auch nicht als Vorgänger durchgehen', () => { /* Der zweite Teil des doppelten Ausschlusses: Als Vorgänger verschöben sie den gemessenen Abstand – eine Antwort von heute erschiene dann als Wiederholung nach einem Tag, obwohl die Frage nie vorlag. */ wiederholungen(25, true); const vorher = lernstand.uebersicht(profilId).behaltensquote; /* Eine Simulationszeile von gestern, dann eine echte Antwort von heute: Ohne den Ausschluss zählte die echte Antwort mit dem Abstand zur Simulationszeile. */ lernstand.protokollieren(profilId, { frageId: 'II-01', auswahl: [], richtig: false, bewertung: 'nochmal', dauerMs: 0, }); tageWeiter(2); antworten('II-01', true); /* Die Antwort auf II-01 ist deren erste echte – ohne Vorgänger, also ohne Beitrag zur Quote. */ expect(lernstand.uebersicht(profilId).behaltensquote).toEqual(vorher); }); }); describe('Der Satz zur Bilanz', () => { it('beugt Einzahl und Mehrzahl', () => { expect(hartnaeckigText({ frageId: 'x', fehlschlaege: 1, tage: 1, zuletzt: '' })).toBe( 'einmal an einem Tag danebengegangen', ); expect(hartnaeckigText({ frageId: 'x', fehlschlaege: 5, tage: 3, zuletzt: '' })).toBe( '5-mal an 3 Tagen danebengegangen', ); }); });