waffensachkunde

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

/ app tests reife.test.ts

14,9 KB Rohdatei
app/tests/reife.test.ts — 371 Zeilen
1 /**
2 * Der Rechenkern der Prüfungsreife.
3 *
4 * Diese Datei prüft nicht nur, dass die Formel rechnet, sondern dass sie die
5 * **Fallen** vermeidet, an denen der erste Entwurf gescheitert ist. Die
6 * Messwerte in den Kommentaren stammen aus der Nachrechnung gegen die
7 * FSRS-Konstanten dieses Projekts; sie stehen ausgeschrieben, damit ein
8 * späterer Umbau merkt, wenn er sie verschiebt.
9 */
10
11 import { describe, expect, it } from 'vitest';
12
13 import { abrufwahrscheinlichkeit, ersterStand, naechsterStand } from '../src/shared/fsrs';
14 import {
15 abrufFuer,
16 belegteFragen,
17 bereicheZusammenfassen,
18 benoetigteFragen,
19 gesamtstufeMitDeckel,
20 reifegradVon,
21 reifesatz,
22 SCHWELLE_KIPPE,
23 SCHWELLE_REIF,
24 stufeFuer,
25 type ReifeFrage,
26 } from '../src/shared/reife';
27
28 function frage(teil: Partial<ReifeFrage> = {}): ReifeFrage {
29 return {
30 bereich: 'I.1',
31 stabilitaet: null,
32 tageSeitAntwort: 0,
33 bestaetigt: false,
34 ...teil,
35 };
36 }
37
38 describe('Der Beitrag einer einzelnen Frage', () => {
39 it('ist null, solange der Abruf nicht belegt ist', () => {
40 /* Der Kern der ganzen Umstellung. Ohne diese Regel stünde eine Frage mit
41 S = 2,3065 – der Wert nach genau einer richtigen Antwort – bei 0,9468
42 und damit über der Schwelle „prüfungsreif“. Eine einzige Antwort. */
43 const einmalRichtig = frage({ stabilitaet: 2.3065, tageSeitAntwort: 1, bestaetigt: false });
44
45 expect(abrufFuer(einmalRichtig)).toBe(0);
46 expect(abrufFuer({ ...einmalRichtig, bestaetigt: true })).toBeCloseTo(0.9468, 4);
47 });
48
49 it('ist null für eine nie beantwortete Frage', () => {
50 expect(abrufFuer(frage())).toBe(0);
51 expect(abrufFuer(frage({ bestaetigt: true }))).toBe(0);
52 });
53
54 it('rechnet nie mit weniger als einem Tag Abstand', () => {
55 /* `abrufwahrscheinlichkeit(S, 0)` ist für JEDES S exakt 1 – und
56 `tageZwischen` im Anwendungskern liefert Bruchteile von Tagen. Ohne die
57 Untergrenze stünde jede gerade beantwortete Frage bei 100 Prozent, und
58 der Reifegrad spränge während einer Sitzung nach oben. */
59 expect(abrufwahrscheinlichkeit(0.2, 0)).toBe(1);
60 expect(abrufwahrscheinlichkeit(1000, 0)).toBe(1);
61
62 const eben = frage({ stabilitaet: 2.3065, tageSeitAntwort: 0.01, bestaetigt: true });
63 expect(abrufFuer(eben)).toBeCloseTo(abrufwahrscheinlichkeit(2.3065, 1), 10);
64 expect(abrufFuer(eben)).toBeLessThan(0.95);
65 });
66
67 it('sinkt mit der Zeit', () => {
68 const stand = { stabilitaet: 10, bestaetigt: true };
69 const gestern = abrufFuer(frage({ ...stand, tageSeitAntwort: 1 }));
70 const vorEinemMonat = abrufFuer(frage({ ...stand, tageSeitAntwort: 30 }));
71
72 expect(vorEinemMonat).toBeLessThan(gestern);
73 });
74 });
75
76 describe('Die Zahl steigt nie durch eine falsche Antwort', () => {
77 /*
78 Der blockierende Befund der Gegenprobe, in einen Test gegossen.
79
80 Mit der reinen FSRS-Abrufwahrscheinlichkeit stieg der Beitrag jeder
81 überfälligen Frage beim „Nicht gewusst“ – gemessen in allen fünf Fällen
82 unten, etwa von 0,707 auf 0,884. Der Grund: Der neue Stand wird wieder am
83 ersten Tag bewertet, der alte ging mit seinem echten, viel größeren Alter
84 ein. Eine Lernanwendung, die das Falschantworten belohnt, ist kaputt.
85 */
86 const FAELLE = [
87 { stabilitaet: 0.212, tageSeitAntwort: 30 },
88 { stabilitaet: 1.2931, tageSeitAntwort: 10 },
89 { stabilitaet: 2.3065, tageSeitAntwort: 20 },
90 { stabilitaet: 8.2956, tageSeitAntwort: 60 },
91 { stabilitaet: 60, tageSeitAntwort: 90 },
92 ] as const;
93
94 it.each(FAELLE)(
95 'S=$stabilitaet nach $tageSeitAntwort Tagen: das rohe FSRS-Modell stiege, der Reifegrad nicht',
96 ({ stabilitaet, tageSeitAntwort }) => {
97 const vorher = frage({ stabilitaet, tageSeitAntwort, bestaetigt: true });
98
99 // Was FSRS aus einem „Nicht gewusst“ macht.
100 const neu = naechsterStand({ stabilitaet, schwierigkeit: 5 }, 1, tageSeitAntwort);
101
102 /* Zum Vergleich: So sähe es ohne die Belegregel aus – nachweislich
103 höher als vorher. Der Test hält das ausdrücklich fest, damit
104 niemand die Regel als überflüssige Vorsicht wieder herausnimmt. */
105 const ohneBelegregel = abrufwahrscheinlichkeit(neu.stabilitaet, 1);
106 expect(ohneBelegregel).toBeGreaterThan(abrufwahrscheinlichkeit(stabilitaet, tageSeitAntwort));
107
108 // Mit der Belegregel: Die falsche Antwort nimmt den Beleg weg.
109 const nachher = frage({
110 stabilitaet: neu.stabilitaet,
111 tageSeitAntwort: 0,
112 bestaetigt: false,
113 });
114 expect(abrufFuer(nachher)).toBe(0);
115 expect(abrufFuer(nachher)).toBeLessThanOrEqual(abrufFuer(vorher));
116 },
117 );
118 });
119
120 describe('Der Reifegrad über eine Fragenmenge', () => {
121 it('lässt unbelegte Fragen im Nenner stehen', () => {
122 /* Sie herauszunehmen ergäbe eine Zahl, die bei der ersten belegten Frage
123 auf 100 Prozent springt – die häufigste Art, eine Fortschrittsanzeige
124 zu belügen. */
125 const menge = [
126 frage({ stabilitaet: 10, tageSeitAntwort: 1, bestaetigt: true }),
127 ...Array.from({ length: 9 }, () => frage()),
128 ];
129
130 expect(reifegradVon(menge)).toBeCloseTo(abrufwahrscheinlichkeit(10, 1) / 10, 10);
131 });
132
133 it('ist null für eine leere Menge, nicht NaN', () => {
134 expect(reifegradVon([])).toBe(0);
135 });
136
137 it('bleibt bei null, wenn der ganze Katalog genau einmal durchgearbeitet wurde', () => {
138 /*
139 Der zweite blockierende Befund. Mit der reinen Abrufwahrscheinlichkeit
140 ergab genau dieser Zustand:
141
142 0 % richtig -> 0,766 -> 372 von 486
143 50 % richtig -> 0,857 -> 416 von 486, also „Prüfungsreif“
144
145 Wer jede Frage einmal falsch beantwortet, hätte 372 von 486 gelesen.
146 */
147 const einDurchgang = [
148 ...Array.from({ length: 50 }, () =>
149 frage({ stabilitaet: ersterStand(3).stabilitaet, tageSeitAntwort: 1 }),
150 ),
151 ...Array.from({ length: 50 }, () =>
152 frage({ stabilitaet: ersterStand(1).stabilitaet, tageSeitAntwort: 1 }),
153 ),
154 ];
155
156 expect(reifegradVon(einDurchgang)).toBe(0);
157 });
158
159 it('blickt für die Prognose in die Zukunft', () => {
160 const menge = [frage({ stabilitaet: 10, tageSeitAntwort: 1, bestaetigt: true })];
161
162 expect(reifegradVon(menge, 30)).toBeCloseTo(abrufwahrscheinlichkeit(10, 31), 10);
163 expect(reifegradVon(menge, 30)).toBeLessThan(reifegradVon(menge, 0));
164 });
165 });
166
167 describe('Die Einstufung', () => {
168 it('entscheidet an derselben ganzen Zahl, die die Anzeige nennt', () => {
169 /*
170 Sonst gäbe es ein Band, in dem „Prüfungsreif“ neben einer Zahl steht,
171 die eine Stufe tiefer als nicht ausreichend ausgewiesen wird: Bei 486
172 Fragen verlangt die Schwelle 0,85 genau 414 Fragen (aufgerundet von
173 413,1). Ein Reifegrad von 0,8505 ergibt gerundet 413 – also NICHT reif,
174 obwohl der Bruchwert über der Schwelle liegt.
175 */
176 expect(benoetigteFragen(SCHWELLE_REIF, 486)).toBe(414);
177 expect(belegteFragen(0.8505, 486)).toBe(413);
178 expect(stufeFuer(413, 486)).toBe('kippe');
179 expect(stufeFuer(414, 486)).toBe('reif');
180 });
181
182 it.each([23, 35, 43, 49, 89, 90, 92, 154, 486, 575])(
183 'nennt bei %i Fragen nie eine Stufe, die die genannte Zahl nicht trägt',
184 (gesamt) => {
185 /* Die Gegenprobe der Gegenprobe: Für JEDE Zahl belegter Fragen muss
186 gelten, dass die Stufe genau dann erreicht ist, wenn die Zahl die
187 Zielzahl erreicht – in beide Richtungen. */
188 for (let belegt = 0; belegt <= gesamt; belegt++) {
189 const stufe = stufeFuer(belegt, gesamt);
190 if (stufe === 'reif') {
191 expect(belegt).toBeGreaterThanOrEqual(benoetigteFragen(SCHWELLE_REIF, gesamt));
192 } else if (belegt > 0) {
193 expect(belegt).toBeLessThan(benoetigteFragen(SCHWELLE_REIF, gesamt));
194 }
195 if (stufe === 'kippe') {
196 expect(belegt).toBeGreaterThanOrEqual(benoetigteFragen(SCHWELLE_KIPPE, gesamt));
197 }
198 }
199 },
200 );
201
202 it('unterscheidet „noch nichts belegt“ von „noch nicht so weit“', () => {
203 /* Wer noch keine Frage wiedergesehen hat, bekommt keine Einstufung
204 vorgegaukelt, sondern die Auskunft, woran das liegt. */
205 expect(stufeFuer(0, 486)).toBe('ohne_beleg');
206 expect(stufeFuer(1, 486)).toBe('zurueck');
207 expect(stufeFuer(0, 0)).toBe('ohne_beleg');
208 });
209
210 it('deckelt das Gesamturteil an einem zurückliegenden K.-o.-Bereich', () => {
211 /* Wer insgesamt bei 88 Prozent steht und in Notwehr und Notstand bei 60,
212 fällt unter manchen Ordnungen sicher durch. Gedeckelt wird auf „Kippe“,
213 nicht auf „zurück“: Der übrige Stand ist ja vorhanden. */
214 expect(gesamtstufeMitDeckel('reif', ['reif'])).toBe('reif');
215 expect(gesamtstufeMitDeckel('reif', ['kippe'])).toBe('kippe');
216 expect(gesamtstufeMitDeckel('reif', ['zurueck'])).toBe('kippe');
217 expect(gesamtstufeMitDeckel('reif', [])).toBe('reif');
218 // Wer ohnehin zurückliegt, wird durch den Deckel nicht angehoben.
219 expect(gesamtstufeMitDeckel('zurueck', ['zurueck'])).toBe('zurueck');
220 });
221 });
222
223 describe('Der Satz zum Stand', () => {
224 it('nennt die nachprüfbare Zahl vor der Auslegung', () => {
225 const satz = reifesatz(414, 486, 'reif');
226
227 expect(satz.startsWith('414 von 486 Fragen sitzen belegt.')).toBe(true);
228 });
229
230 it('verspricht bei „Prüfungsreif“ keine Bestehenswahrscheinlichkeit', () => {
231 /* Eine Angabe wie „neun von zehn Läufen“ setzte voraus, dass alle Fragen
232 dieselbe Trefferchance haben. Das Gegenteil ist der Fall – die
233 Verteilung ist zweigipflig, und die Zahl wäre exakt aussehend und
234 falsch. */
235 const satz = reifesatz(414, 486, 'reif');
236
237 expect(satz).not.toMatch(/Prozent|von zehn|wahrscheinlich/u);
238 expect(satz).toContain('strengsten Bestehensgrenze');
239 });
240
241 it('nennt bei „Auf der Kippe“, wie viele Fragen noch fehlen', () => {
242 const satz = reifesatz(389, 486, 'kippe');
243
244 expect(satz).toContain('389 von 486');
245 expect(satz).toContain('414');
246 });
247
248 it('stellt den Grund vor die Einordnung, wenn ein K.-o.-Bereich deckelt', () => {
249 /* Wer hier „prüfungsreif“ läse und den Nachsatz überginge, ginge mit
250 einer Lücke in die Prüfung, die ihn unabhängig vom Rest durchfallen
251 lässt. */
252 const satz = reifesatz(430, 486, 'kippe', ['I.5 – Notwehr und Notstand']);
253
254 expect(satz).toContain('I.5 – Notwehr und Notstand');
255 expect(satz).toContain('zwei Fehler');
256 expect(satz.indexOf('liegt zurück')).toBeLessThan(satz.indexOf('zwei Fehler'));
257 });
258
259 it('erklärt beim leeren Stand die Regel, statt eine Stufe zu nennen', () => {
260 const satz = reifesatz(0, 486, 'ohne_beleg');
261
262 expect(satz).toContain('mindestens einem Tag Abstand');
263 expect(satz).not.toContain('0 von 486');
264 });
265 });
266
267 describe('reifesatz – der Deckel bei schwachem Gesamtstand', () => {
268 /*
269 Gemeldet aus der laufenden Anwendung: „2 von 486 Fragen sitzen belegt.
270 Insgesamt reicht das – aber I.5 – Notwehr und Notstand liegt zurück.“
271 Bei 2 von 486 reicht überhaupt nichts.
272
273 Die Ursache war, dass der Satz den Deckel unbedingt vor die Einordnung
274 zog, sobald ein K.-o.-Bereich zurücklag – ohne zu prüfen, ob der
275 Gesamtstand für sich genommen genügt. Keine der übrigen Prüfungen hier
276 hat das bemerkt, weil sie den Deckel nur beim starken Stand ansahen.
277 */
278 it('behauptet bei schwachem Gesamtstand nicht, dass es insgesamt reicht', () => {
279 const satz = reifesatz(2, 486, 'zurueck', ['I.5 – Notwehr und Notstand']);
280
281 expect(satz).toContain('2 von 486 Fragen sitzen belegt.');
282 expect(satz).not.toContain('Insgesamt reicht das');
283 expect(satz).toContain('I.5 – Notwehr und Notstand');
284 expect(satz).toContain('nur zwei Fehler');
285 });
286
287 it('nennt bei schwachem Stand die Bestehensgrenze und den Bereich', () => {
288 const satz = reifesatz(2, 486, 'zurueck', ['I.5 – Notwehr und Notstand']);
289
290 expect(satz).toContain('Die strengste Bestehensgrenze dieses Programms verlangt');
291 expect(satz).toContain('Dazu liegt');
292 });
293
294 it('sagt „Insgesamt reicht das“ weiterhin, wenn der Gesamtstand genügt', () => {
295 /* Der Fall, für den der Satz gedacht war: Der Stand allein trüge, und
296 nur der K.-o.-Bereich hält ihn auf. */
297 const satz = reifesatz(460, 486, 'kippe', ['I.5 – Notwehr und Notstand']);
298
299 expect(satz).toContain('Insgesamt reicht das – aber I.5 – Notwehr und Notstand liegt zurück.');
300 });
301
302 it('bleibt beim Stand auf der Kippe bei der Einordnung, statt sie zu überschreiben', () => {
303 const satz = reifesatz(400, 486, 'kippe', ['I.5 – Notwehr und Notstand']);
304
305 expect(satz).not.toContain('Insgesamt reicht das');
306 expect(satz).toContain('strengste Bestehensgrenze');
307 expect(satz).toContain('Dazu liegt I.5 – Notwehr und Notstand zurück.');
308 });
309
310 it('lässt den Zusatz weg, wenn kein Bereich zurückliegt', () => {
311 expect(reifesatz(2, 486, 'zurueck')).not.toContain('Dazu liegt');
312 expect(reifesatz(460, 486, 'reif')).not.toContain('Insgesamt reicht das');
313 });
314 });
315
316 describe('Bereiche zusammenfassen', () => {
317 /*
318 Gebraucht für „Ganzes Kapitel üben“: Der amtliche Katalog gliedert nur
319 Kapitel I in Abschnitte, die Bereichsliste kennt deshalb kein `I`.
320 */
321
322 it('ergibt genau denselben Wert wie eine Rechnung über alle Fragen', () => {
323 /*
324 Die Zusicherung, auf der die ganze Zusammenfassung ruht: Der Reifegrad
325 ist ein Mittelwert über Fragen. Über disjunkte Bereiche ist das
326 gewichtete Mittel deshalb **gleich** und nicht ungefähr gleich. Wäre es
327 nur ungefähr, stünden am Kapitelknopf und in der Übersicht zwei Zahlen,
328 die einander widersprechen.
329 */
330 const alle: ReifeFrage[] = [
331 ...Array.from({ length: 7 }, () =>
332 frage({ stabilitaet: 30, tageSeitAntwort: 2, bestaetigt: true }),
333 ),
334 ...Array.from({ length: 5 }, () =>
335 frage({ stabilitaet: 3, tageSeitAntwort: 9, bestaetigt: true }),
336 ),
337 ...Array.from({ length: 4 }, () => frage({ bestaetigt: false })),
338 ];
339 const teile = [alle.slice(0, 7), alle.slice(7, 12), alle.slice(12)];
340
341 const zusammen = bereicheZusammenfassen(
342 teile.map((teil) => ({ fragenGesamt: teil.length, reifegrad: reifegradVon(teil) })),
343 );
344
345 expect(zusammen.fragenGesamt).toBe(alle.length);
346 expect(zusammen.reifegrad).toBeCloseTo(reifegradVon(alle), 12);
347 expect(zusammen.belegt).toBe(belegteFragen(reifegradVon(alle), alle.length));
348 expect(zusammen.stufe).toBe(stufeFuer(zusammen.belegt, alle.length));
349 });
350
351 it('gewichtet nach Fragenzahl und nicht nach Bereichszahl', () => {
352 /* Der naheliegende Fehler: das arithmetische Mittel der Bereichswerte.
353 Bei 90 gegen 10 Fragen wäre es um Längen daneben. */
354 const zusammen = bereicheZusammenfassen([
355 { fragenGesamt: 90, reifegrad: 0 },
356 { fragenGesamt: 10, reifegrad: 1 },
357 ]);
358
359 expect(zusammen.reifegrad).toBeCloseTo(0.1, 12);
360 expect(zusammen.belegt).toBe(10);
361 });
362
363 it('kommt mit einer leeren Liste zurecht', () => {
364 expect(bereicheZusammenfassen([])).toEqual({
365 fragenGesamt: 0,
366 belegt: 0,
367 reifegrad: 0,
368 stufe: 'ohne_beleg',
369 });
370 });
371 });