waffensachkunde
Waffensachkunde – Lernsoftware für die Sachkundeprüfung nach § 7 WaffG. Barrierefrei, offline, EUPL-1.2.
/ app tests hartnaeckig.test.ts
| 1 | // @vitest-environment node |
| 2 | /** |
| 3 | * Hartnäckige Fragen – die, die immer wieder danebengehen. |
| 4 | * |
| 5 | * **Der Anlass.** Die vollständige Fehlerhistorie liegt seit jeher in |
| 6 | * `antwort_log`, aber keine einzige Abfrage wertete sie je Frage aus: |
| 7 | * `letzteAntwortKarte` liest nur die **letzte** Antwort, und der Filter „Nur |
| 8 | * Fehler“ hängt genau daran. Eine Frage, die über Wochen viermal durchfiel und |
| 9 | * gestern zufällig saß, verschwand aus jeder Liste — obwohl sie das Gegenteil |
| 10 | * von gekonnt ist. |
| 11 | * |
| 12 | * Gemessen wird hier gegen eine echte SQLite-Datenbank mit einer echten |
| 13 | * Historie über mehrere Tage, nicht gegen eine Attrappe. |
| 14 | */ |
| 15 | |
| 16 | import Database from 'better-sqlite3'; |
| 17 | import { afterEach, beforeEach, describe, expect, it } from 'vitest'; |
| 18 | |
| 19 | import { Lernstand } from '../src/main/lernstand'; |
| 20 | import { HARTNAECKIG_AB_TAGEN, hartnaeckigText } from '../src/shared/hartnaeckig'; |
| 21 | import type { Frage, Katalog } from '../src/shared/katalog'; |
| 22 | |
| 23 | const TAG_MS = 86_400_000; |
| 24 | |
| 25 | function mcFrage(id: string, kapitel: string, abschnitt: string | null): Frage { |
| 26 | return { |
| 27 | id, |
| 28 | amtliche_nummer: id, |
| 29 | kapitel, |
| 30 | abschnitt, |
| 31 | typ: 'mc', |
| 32 | seite: 1, |
| 33 | frage: { text: `Frage ${id}`, segmente: [{ t: `Frage ${id}` }] }, |
| 34 | bilder: [], |
| 35 | optionen: ['a', 'b', 'c'].map((label) => ({ |
| 36 | label, |
| 37 | inhalt: { text: label, segmente: [{ t: label }] }, |
| 38 | korrekt: label === 'a', |
| 39 | bilder: [], |
| 40 | })), |
| 41 | } as unknown as Frage; |
| 42 | } |
| 43 | |
| 44 | const FRAGEN: readonly Frage[] = [ |
| 45 | mcFrage('I.1-01', 'I', 'I.1'), |
| 46 | mcFrage('I.1-02', 'I', 'I.1'), |
| 47 | mcFrage('II-01', 'II', null), |
| 48 | mcFrage('IV-01', 'IV', null), |
| 49 | ]; |
| 50 | |
| 51 | const KATALOG: Katalog = { |
| 52 | meta: { |
| 53 | titel: 'Prüfkatalog', |
| 54 | herausgeber: 'Bundesverwaltungsamt', |
| 55 | stand: '2024-12-16', |
| 56 | quellenangabe: 'Attrappe', |
| 57 | quelle_url: 'https://www.bva.bund.de/', |
| 58 | quelldatei_sha256: 'a'.repeat(64), |
| 59 | fragen_gesamt: FRAGEN.length, |
| 60 | }, |
| 61 | kapitel: [ |
| 62 | { id: 'I', titel: 'Waffenrecht', abschnitte: [{ id: 'I.1', titel: 'Begriffe' }] }, |
| 63 | { id: 'II', titel: 'Waffentechnik', abschnitte: [] }, |
| 64 | { id: 'IV', titel: 'Seenotsignalmittel', abschnitte: [] }, |
| 65 | ], |
| 66 | bilder: [], |
| 67 | fragen: FRAGEN, |
| 68 | }; |
| 69 | |
| 70 | let uhr: Date; |
| 71 | let db: Database.Database; |
| 72 | let lernstand: Lernstand; |
| 73 | let profilId: number; |
| 74 | |
| 75 | /** Antwortet auf eine Frage; `auswahl` steuert die Fehlwahl-Auswertung. */ |
| 76 | function antworten(frageId: string, richtig: boolean, auswahl: readonly string[] = ['b']): void { |
| 77 | lernstand.antworten(profilId, { |
| 78 | frageId, |
| 79 | auswahl: richtig ? ['a'] : auswahl, |
| 80 | richtig, |
| 81 | bewertung: richtig ? 'gut' : 'nochmal', |
| 82 | dauerMs: 1500, |
| 83 | }); |
| 84 | } |
| 85 | |
| 86 | function tageWeiter(tage: number): void { |
| 87 | uhr = new Date(uhr.getTime() + tage * TAG_MS); |
| 88 | } |
| 89 | |
| 90 | beforeEach(() => { |
| 91 | uhr = new Date('2026-03-01T10:00:00.000Z'); |
| 92 | db = new Database(':memory:'); |
| 93 | lernstand = new Lernstand(db, KATALOG, { jetzt: () => uhr }); |
| 94 | profilId = lernstand.profile()[0]!.id; |
| 95 | }); |
| 96 | |
| 97 | afterEach(() => { |
| 98 | if (db.open) { |
| 99 | db.close(); |
| 100 | } |
| 101 | }); |
| 102 | |
| 103 | describe('Auszählung', () => { |
| 104 | it('meldet nichts, solange nichts danebenging', () => { |
| 105 | antworten('I.1-01', true); |
| 106 | |
| 107 | expect(lernstand.hartnaeckige(profilId)).toEqual([]); |
| 108 | }); |
| 109 | |
| 110 | it('meldet eine Frage erst ab der Schwelle', () => { |
| 111 | for (let tag = 0; tag < HARTNAECKIG_AB_TAGEN - 1; tag++) { |
| 112 | antworten('I.1-01', false); |
| 113 | tageWeiter(1); |
| 114 | } |
| 115 | expect(lernstand.hartnaeckige(profilId)).toEqual([]); |
| 116 | |
| 117 | antworten('I.1-01', false); |
| 118 | expect(lernstand.hartnaeckige(profilId)).toHaveLength(1); |
| 119 | }); |
| 120 | |
| 121 | it('zählt Tage und nicht bloße Fehlschläge', () => { |
| 122 | /* Drei Fehlschläge in einer Sitzung sind die gewöhnliche |
| 123 | Wiedereinreihung, keine Hartnäckigkeit – die Frage kommt in derselben |
| 124 | Sitzung zweimal wieder (`MAX_WIEDEREINREIHUNGEN`). */ |
| 125 | for (let versuch = 0; versuch < 6; versuch++) { |
| 126 | antworten('I.1-01', false); |
| 127 | } |
| 128 | |
| 129 | expect(lernstand.hartnaeckige(profilId)).toEqual([]); |
| 130 | }); |
| 131 | |
| 132 | it('behält eine Frage, die zwischendurch einmal saß', () => { |
| 133 | /* Der eigentliche Befund: „Nur Fehler“ fragt die letzte Antwort. Wer |
| 134 | gestern zufällig richtig lag, verschwand dort – und war doch nicht |
| 135 | gekonnt. */ |
| 136 | for (let tag = 0; tag < HARTNAECKIG_AB_TAGEN; tag++) { |
| 137 | antworten('I.1-01', false); |
| 138 | tageWeiter(1); |
| 139 | } |
| 140 | antworten('I.1-01', true); |
| 141 | |
| 142 | const liste = lernstand.hartnaeckige(profilId); |
| 143 | expect(liste).toHaveLength(1); |
| 144 | expect(liste[0]?.frageId).toBe('I.1-01'); |
| 145 | }); |
| 146 | |
| 147 | it('nennt Fehlschläge, Tage und den letzten Zeitpunkt', () => { |
| 148 | for (let tag = 0; tag < HARTNAECKIG_AB_TAGEN; tag++) { |
| 149 | antworten('I.1-01', false); |
| 150 | antworten('I.1-01', false); |
| 151 | tageWeiter(1); |
| 152 | } |
| 153 | |
| 154 | const eintrag = lernstand.hartnaeckige(profilId)[0]; |
| 155 | expect(eintrag?.tage).toBe(HARTNAECKIG_AB_TAGEN); |
| 156 | expect(eintrag?.fehlschlaege).toBe(HARTNAECKIG_AB_TAGEN * 2); |
| 157 | expect(eintrag?.zuletzt).not.toBe(''); |
| 158 | }); |
| 159 | |
| 160 | it('sortiert die auffälligsten nach vorn', () => { |
| 161 | for (let tag = 0; tag < HARTNAECKIG_AB_TAGEN + 2; tag++) { |
| 162 | antworten('II-01', false); |
| 163 | tageWeiter(1); |
| 164 | } |
| 165 | uhr = new Date('2026-03-01T10:00:00.000Z'); |
| 166 | for (let tag = 0; tag < HARTNAECKIG_AB_TAGEN; tag++) { |
| 167 | antworten('I.1-01', false); |
| 168 | tageWeiter(1); |
| 169 | } |
| 170 | |
| 171 | const liste = lernstand.hartnaeckige(profilId); |
| 172 | expect(liste[0]?.frageId).toBe('II-01'); |
| 173 | }); |
| 174 | }); |
| 175 | |
| 176 | describe('Wiederkehrende Fehlwahl', () => { |
| 177 | it('nennt den immer gleichen falschen Buchstaben', () => { |
| 178 | /* Die Spalte `auswahl` wurde bis 0.22.0 geschrieben und von keiner |
| 179 | Abfrage gelesen. Immer derselbe Fehlgriff heißt Verwechslung – das ist |
| 180 | mit einem Merksatz zu heilen. */ |
| 181 | for (let tag = 0; tag < HARTNAECKIG_AB_TAGEN; tag++) { |
| 182 | antworten('I.1-01', false, ['c']); |
| 183 | tageWeiter(1); |
| 184 | } |
| 185 | |
| 186 | expect(lernstand.hartnaeckige(profilId)[0]?.haeufigsteFehlwahl).toBe('c'); |
| 187 | }); |
| 188 | |
| 189 | it('schweigt, wenn die Fehlgriffe streuen', () => { |
| 190 | /* Gestreute Fehlgriffe heißen Nichtwissen – da gibt es kein Muster zu |
| 191 | benennen, und eines zu behaupten wäre schlimmer als Schweigen. */ |
| 192 | antworten('I.1-01', false, ['b']); |
| 193 | tageWeiter(1); |
| 194 | antworten('I.1-01', false, ['c']); |
| 195 | tageWeiter(1); |
| 196 | antworten('I.1-01', false, ['b', 'c']); |
| 197 | |
| 198 | expect(lernstand.hartnaeckige(profilId)[0]?.haeufigsteFehlwahl).toBeUndefined(); |
| 199 | }); |
| 200 | |
| 201 | it('schweigt bei einem Gleichstand', () => { |
| 202 | for (const wahl of [['b'], ['b'], ['c'], ['c']]) { |
| 203 | antworten('I.1-01', false, wahl); |
| 204 | tageWeiter(1); |
| 205 | } |
| 206 | |
| 207 | expect(lernstand.hartnaeckige(profilId)[0]?.haeufigsteFehlwahl).toBeUndefined(); |
| 208 | }); |
| 209 | }); |
| 210 | |
| 211 | describe('Lernumfang', () => { |
| 212 | it('übergeht ein dauerhaft abgewähltes Kapitel', () => { |
| 213 | /* Wer Kapitel IV abgewählt hat, soll es auch hier nicht sehen – |
| 214 | dieselbe Regel wie überall sonst. */ |
| 215 | for (let tag = 0; tag < HARTNAECKIG_AB_TAGEN; tag++) { |
| 216 | antworten('IV-01', false); |
| 217 | tageWeiter(1); |
| 218 | } |
| 219 | expect(lernstand.hartnaeckige(profilId)).toHaveLength(1); |
| 220 | |
| 221 | lernstand.profilAktualisieren({ id: profilId, kapitelAusschluss: ['IV'] }); |
| 222 | |
| 223 | expect(lernstand.hartnaeckige(profilId)).toEqual([]); |
| 224 | }); |
| 225 | }); |
| 226 | |
| 227 | describe('Als Sitzungsfilter', () => { |
| 228 | it('legt genau die hartnäckigen Fragen vor', () => { |
| 229 | for (let tag = 0; tag < HARTNAECKIG_AB_TAGEN; tag++) { |
| 230 | antworten('I.1-01', false); |
| 231 | tageWeiter(1); |
| 232 | } |
| 233 | antworten('I.1-02', false); |
| 234 | |
| 235 | const sitzung = lernstand.sitzung(profilId, { nurHartnaeckige: true, anzahl: 50 }); |
| 236 | |
| 237 | expect(sitzung.map((eintrag) => eintrag.frageId)).toEqual(['I.1-01']); |
| 238 | }); |
| 239 | |
| 240 | it('steht neben „nur Fehler“ und ersetzt es nicht', () => { |
| 241 | /* Das gedruckte Fehlerprotokoll verspricht ausdrücklich: „Sobald Sie |
| 242 | eine davon wieder richtig beantworten, verschwindet sie aus dieser |
| 243 | Liste.“ Diese Zusage darf der neue Filter nicht brechen. */ |
| 244 | for (let tag = 0; tag < HARTNAECKIG_AB_TAGEN; tag++) { |
| 245 | antworten('I.1-01', false); |
| 246 | tageWeiter(1); |
| 247 | } |
| 248 | antworten('I.1-01', true); |
| 249 | |
| 250 | expect(lernstand.sitzung(profilId, { nurFehler: true, anzahl: 50 })).toEqual([]); |
| 251 | expect(lernstand.sitzung(profilId, { nurHartnaeckige: true, anzahl: 50 })).toHaveLength(1); |
| 252 | }); |
| 253 | }); |
| 254 | |
| 255 | /* |
| 256 | Die Ist-Behaltensquote. |
| 257 | |
| 258 | Der Lernplan steuert auf eine Zielquote (0,90 bis 0,97), und die Reife-Ampel |
| 259 | zeigt modellierte Abrufwahrscheinlichkeiten. Wie oft der Lernende fällige |
| 260 | Wiederholungen **tatsächlich** trifft, stand bis 0.22.0 nirgends – obwohl es |
| 261 | im Protokoll steht und nur zu zählen war. |
| 262 | |
| 263 | Geprüft wird hier vor allem, was **nicht** hineinzählt: Antworten ohne |
| 264 | Abstand und die Zeilen, die eine Simulation für nie aufgeschlagene Fragen |
| 265 | anlegt. |
| 266 | */ |
| 267 | describe('Behaltensquote', () => { |
| 268 | /** Beantwortet `anzahl` Fragen an aufeinanderfolgenden Tagen. */ |
| 269 | function wiederholungen(anzahl: number, richtig: boolean): void { |
| 270 | for (let lauf = 0; lauf < anzahl; lauf++) { |
| 271 | antworten('I.1-01', richtig); |
| 272 | tageWeiter(1); |
| 273 | } |
| 274 | } |
| 275 | |
| 276 | it('schweigt, solange zu wenig vorliegt', () => { |
| 277 | /* Eine Quote aus drei Antworten schwankt zwischen 0 und 100 Prozent und |
| 278 | sähe nur aus wie eine Auskunft. */ |
| 279 | wiederholungen(5, true); |
| 280 | |
| 281 | expect(lernstand.uebersicht(profilId).behaltensquote).toBeNull(); |
| 282 | }); |
| 283 | |
| 284 | it('zählt Wiederholungen mit Abstand und ihre Treffer', () => { |
| 285 | wiederholungen(20, true); |
| 286 | wiederholungen(5, false); |
| 287 | |
| 288 | const quote = lernstand.uebersicht(profilId).behaltensquote; |
| 289 | /* Die allererste Antwort hat keinen Vorgänger und zählt nicht mit. */ |
| 290 | expect(quote?.gesamt).toBe(24); |
| 291 | expect(quote?.richtig).toBe(19); |
| 292 | }); |
| 293 | |
| 294 | it('zählt Antworten ohne Abstand nicht mit', () => { |
| 295 | /* Zwei Antworten am selben Tag sagen nichts über das Behalten – das ist |
| 296 | dieselbe Grenze, nach der auch der Reife-Beleg entsteht. |
| 297 | |
| 298 | Zehn Fehlversuche hintereinander an einem Tag: Der **erste** hat einen |
| 299 | Tag Abstand zur letzten Antwort und zählt zu Recht mit; die neun |
| 300 | darauf haben keinen. Ohne die Abstandsregel fiele die Quote hier um |
| 301 | zehn Fehlschläge statt um einen. */ |
| 302 | wiederholungen(25, true); |
| 303 | const vorher = lernstand.uebersicht(profilId).behaltensquote; |
| 304 | |
| 305 | for (let lauf = 0; lauf < 10; lauf++) { |
| 306 | antworten('I.1-01', false); |
| 307 | } |
| 308 | |
| 309 | const nachher = lernstand.uebersicht(profilId).behaltensquote; |
| 310 | expect(nachher?.gesamt).toBe((vorher?.gesamt ?? 0) + 1); |
| 311 | expect(nachher?.richtig).toBe(vorher?.richtig); |
| 312 | }); |
| 313 | |
| 314 | it('zählt nie aufgeschlagene Simulationsfragen nicht mit', () => { |
| 315 | /* Sie tragen `richtig = 0`, ohne dass jemand sie gesehen hätte. Als |
| 316 | Antwort brächten sie erfundene Fehlschläge in die Quote – genau die |
| 317 | Verunreinigung, die entscheidung-reifegrad.md Abschnitt 8 benennt. */ |
| 318 | wiederholungen(25, true); |
| 319 | const vorher = lernstand.uebersicht(profilId).behaltensquote; |
| 320 | |
| 321 | for (let lauf = 0; lauf < 10; lauf++) { |
| 322 | lernstand.protokollieren(profilId, { |
| 323 | frageId: 'I.1-01', |
| 324 | auswahl: [], |
| 325 | richtig: false, |
| 326 | bewertung: 'nochmal', |
| 327 | dauerMs: 0, |
| 328 | }); |
| 329 | tageWeiter(1); |
| 330 | } |
| 331 | |
| 332 | expect(lernstand.uebersicht(profilId).behaltensquote).toEqual(vorher); |
| 333 | }); |
| 334 | |
| 335 | it('lässt sie auch nicht als Vorgänger durchgehen', () => { |
| 336 | /* Der zweite Teil des doppelten Ausschlusses: Als Vorgänger verschöben |
| 337 | sie den gemessenen Abstand – eine Antwort von heute erschiene dann als |
| 338 | Wiederholung nach einem Tag, obwohl die Frage nie vorlag. */ |
| 339 | wiederholungen(25, true); |
| 340 | const vorher = lernstand.uebersicht(profilId).behaltensquote; |
| 341 | |
| 342 | /* Eine Simulationszeile von gestern, dann eine echte Antwort von heute: |
| 343 | Ohne den Ausschluss zählte die echte Antwort mit dem Abstand zur |
| 344 | Simulationszeile. */ |
| 345 | lernstand.protokollieren(profilId, { |
| 346 | frageId: 'II-01', |
| 347 | auswahl: [], |
| 348 | richtig: false, |
| 349 | bewertung: 'nochmal', |
| 350 | dauerMs: 0, |
| 351 | }); |
| 352 | tageWeiter(2); |
| 353 | antworten('II-01', true); |
| 354 | |
| 355 | /* Die Antwort auf II-01 ist deren erste echte – ohne Vorgänger, also |
| 356 | ohne Beitrag zur Quote. */ |
| 357 | expect(lernstand.uebersicht(profilId).behaltensquote).toEqual(vorher); |
| 358 | }); |
| 359 | }); |
| 360 | |
| 361 | describe('Der Satz zur Bilanz', () => { |
| 362 | it('beugt Einzahl und Mehrzahl', () => { |
| 363 | expect(hartnaeckigText({ frageId: 'x', fehlschlaege: 1, tage: 1, zuletzt: '' })).toBe( |
| 364 | 'einmal an einem Tag danebengegangen', |
| 365 | ); |
| 366 | expect(hartnaeckigText({ frageId: 'x', fehlschlaege: 5, tage: 3, zuletzt: '' })).toBe( |
| 367 | '5-mal an 3 Tagen danebengegangen', |
| 368 | ); |
| 369 | }); |
| 370 | }); |