waffensachkunde

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

/ app tests hartnaeckig.test.ts

12,2 KB Rohdatei
app/tests/hartnaeckig.test.ts — 370 Zeilen
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 });