waffensachkunde

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

/ app tests reife.test.ts

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