waffensachkunde
Waffensachkunde – Lernsoftware für die Sachkundeprüfung nach § 7 WaffG. Barrierefrei, offline, EUPL-1.2.
| 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 | }); |