waffensachkunde
Waffensachkunde – Lernsoftware für die Sachkundeprüfung nach § 7 WaffG. Barrierefrei, offline, EUPL-1.2.
| 1 | /** |
| 2 | * Prüft die Prüfungstermin-Schicht über FSRS. |
| 3 | * |
| 4 | * Alles hier ist eigene Entscheidung und nicht durch eine Referenz gedeckt – |
| 5 | * deshalb wird geprüft, was die Regeln *zusagen*, nicht welche Zahlen gerade |
| 6 | * herauskommen. Ein Test, der nur die aktuelle Ausgabe festschreibt, würde bei |
| 7 | * jeder Feinjustierung rot, ohne dass etwas kaputt wäre. |
| 8 | */ |
| 9 | |
| 10 | import { describe, expect, it } from 'vitest'; |
| 11 | |
| 12 | import { |
| 13 | ANHEBUNG_AB_TAGEN, |
| 14 | ausgeglichenesIntervall, |
| 15 | einfuehrungstage, |
| 16 | gestreutesIntervall, |
| 17 | lernplanBerechnen, |
| 18 | machbarkeitFuer, |
| 19 | MAX_INTERVALL_TAGE, |
| 20 | SEKUNDEN_PRO_FRAGE_VORGABE, |
| 21 | SITZUNGSUMFANG, |
| 22 | VORSCHAU_TAGE, |
| 23 | wiedervorlageBerechnen, |
| 24 | zielquoteFuer, |
| 25 | ZIELQUOTE_GRUND, |
| 26 | ZIELQUOTE_TERMIN, |
| 27 | ZIELQUOTE_KO, |
| 28 | type PlanFrage, |
| 29 | } from '../src/shared/lernplan'; |
| 30 | import { abrufwahrscheinlichkeit } from '../src/shared/fsrs'; |
| 31 | |
| 32 | function frage(teil: Partial<PlanFrage> = {}): PlanFrage { |
| 33 | /* `bestaetigt: true` als Vorgabe: Diese Datei prüft die Planung, nicht die |
| 34 | Belegregel. Ohne den Beleg wäre jede Prognose hier null, und die Tests |
| 35 | prüften nichts mehr. Die Regel selbst steht in `reife.test.ts`. */ |
| 36 | return { |
| 37 | bereich: 'I.1', |
| 38 | stabilitaet: null, |
| 39 | tageSeitAntwort: 0, |
| 40 | bestaetigt: true, |
| 41 | faellig: false, |
| 42 | ...teil, |
| 43 | }; |
| 44 | } |
| 45 | |
| 46 | /** `anzahl` gleichartige Fragen – spart Wiederholung in den Fällen unten. */ |
| 47 | function fragen(anzahl: number, teil: Partial<PlanFrage> = {}): PlanFrage[] { |
| 48 | return Array.from({ length: anzahl }, () => frage(teil)); |
| 49 | } |
| 50 | |
| 51 | describe('Zielquote', () => { |
| 52 | it('bleibt ohne Termin beim Vorgabewert', () => { |
| 53 | expect(zielquoteFuer(null)).toBe(ZIELQUOTE_GRUND); |
| 54 | }); |
| 55 | |
| 56 | it('bleibt weit vor dem Termin beim Vorgabewert', () => { |
| 57 | expect(zielquoteFuer(ANHEBUNG_AB_TAGEN)).toBe(ZIELQUOTE_GRUND); |
| 58 | expect(zielquoteFuer(ANHEBUNG_AB_TAGEN + 100)).toBe(ZIELQUOTE_GRUND); |
| 59 | }); |
| 60 | |
| 61 | it('erreicht am Prüfungstag die höchste Stufe', () => { |
| 62 | expect(zielquoteFuer(0)).toBeCloseTo(ZIELQUOTE_TERMIN, 10); |
| 63 | }); |
| 64 | |
| 65 | it('steigt dazwischen streng monoton', () => { |
| 66 | let vorher = zielquoteFuer(ANHEBUNG_AB_TAGEN); |
| 67 | for (let tage = ANHEBUNG_AB_TAGEN - 1; tage >= 0; tage -= 1) { |
| 68 | const jetzt = zielquoteFuer(tage); |
| 69 | expect(jetzt).toBeGreaterThan(vorher); |
| 70 | vorher = jetzt; |
| 71 | } |
| 72 | }); |
| 73 | |
| 74 | it('bleibt nach einem verstrichenen Termin oben', () => { |
| 75 | // Wer den Termin verpasst hat, lernt vermutlich für einen Nachholtermin. |
| 76 | // Die Quote darf dann nicht auf den Ruhewert zurückfallen. |
| 77 | expect(zielquoteFuer(-1)).toBeCloseTo(ZIELQUOTE_TERMIN, 10); |
| 78 | expect(zielquoteFuer(-90)).toBeCloseTo(ZIELQUOTE_TERMIN, 10); |
| 79 | }); |
| 80 | }); |
| 81 | |
| 82 | describe('Wiedervorlage', () => { |
| 83 | it('macht eine Frage bei „nochmal“ sofort wieder fällig', () => { |
| 84 | expect(wiedervorlageBerechnen(null, 1, 0, null).intervallTage).toBe(0); |
| 85 | expect( |
| 86 | wiedervorlageBerechnen({ stabilitaet: 200, schwierigkeit: 2 }, 1, 30, null).intervallTage, |
| 87 | ).toBe(0); |
| 88 | }); |
| 89 | |
| 90 | it('schreibt den Gedächtnisstand auch bei „nochmal“ fort', () => { |
| 91 | // Intervall 0 ist eine Entscheidung der Oberfläche, kein Zurücksetzen des |
| 92 | // Modells. Sonst ginge die Historie der Frage verloren. |
| 93 | const vorher = { stabilitaet: 40, schwierigkeit: 3 }; |
| 94 | const nachher = wiedervorlageBerechnen(vorher, 1, 30, null); |
| 95 | |
| 96 | expect(nachher.stabilitaet).toBeGreaterThan(0); |
| 97 | expect(nachher.stabilitaet).toBeLessThan(vorher.stabilitaet); |
| 98 | expect(nachher.schwierigkeit).toBeGreaterThan(vorher.schwierigkeit); |
| 99 | }); |
| 100 | |
| 101 | it('vergibt sonst nie weniger als einen Tag', () => { |
| 102 | for (const grad of [2, 3, 4] as const) { |
| 103 | expect(wiedervorlageBerechnen(null, grad, 0, null).intervallTage).toBeGreaterThanOrEqual(1); |
| 104 | } |
| 105 | }); |
| 106 | |
| 107 | it('überschreitet die Obergrenze nicht', () => { |
| 108 | const fest = { stabilitaet: 100_000, schwierigkeit: 1 }; |
| 109 | expect(wiedervorlageBerechnen(fest, 4, 10, null).intervallTage).toBe(MAX_INTERVALL_TAGE); |
| 110 | }); |
| 111 | |
| 112 | it('verkürzt sich, je näher der Termin rückt', () => { |
| 113 | const stand = { stabilitaet: 60, schwierigkeit: 5 }; |
| 114 | const weit = wiedervorlageBerechnen(stand, 3, 60, 200).intervallTage; |
| 115 | const mittel = wiedervorlageBerechnen(stand, 3, 60, 14).intervallTage; |
| 116 | const nah = wiedervorlageBerechnen(stand, 3, 60, 2).intervallTage; |
| 117 | |
| 118 | expect(mittel).toBeLessThan(weit); |
| 119 | expect(nah).toBeLessThan(mittel); |
| 120 | }); |
| 121 | |
| 122 | /* |
| 123 | Die Wache, die gefehlt hat. |
| 124 | |
| 125 | Seit 0.22.0 deckelt die Reifeampel ihr Gesamturteil am schwächsten |
| 126 | K.-o.-Bereich: Wer insgesamt bei 88 Prozent steht und in Notwehr bei 60, |
| 127 | bekommt kein „Prüfungsreif“. Der Planer wusste davon nichts und |
| 128 | terminierte die 43 Fragen aus I.5 wie jede andere. Die Ampel warnte, und |
| 129 | die Wiedervorlage handelte nicht danach. |
| 130 | */ |
| 131 | it('hält die Zielquote in einem K.-o.-Bereich auf mindestens 0,95', () => { |
| 132 | expect(zielquoteFuer(null, true)).toBeGreaterThanOrEqual(ZIELQUOTE_KO); |
| 133 | expect(zielquoteFuer(365, true)).toBeGreaterThanOrEqual(ZIELQUOTE_KO); |
| 134 | /* Ohne die Kennzeichnung bleibt es bei der Grundquote – der Rest des |
| 135 | Bestands wird nicht mitgezogen. */ |
| 136 | expect(zielquoteFuer(365, false)).toBe(ZIELQUOTE_GRUND); |
| 137 | }); |
| 138 | |
| 139 | it('lässt die Terminquote über die Untergrenze steigen', () => { |
| 140 | /* Am Termin selbst gilt für alle dieselbe, höhere Quote; die |
| 141 | Untergrenze greift dann nicht mehr und verdirbt sie auch nicht. */ |
| 142 | expect(zielquoteFuer(0, true)).toBeCloseTo(ZIELQUOTE_TERMIN, 10); |
| 143 | expect(zielquoteFuer(0, false)).toBeCloseTo(ZIELQUOTE_TERMIN, 10); |
| 144 | }); |
| 145 | |
| 146 | it('legt eine K.-o.-Frage früher wieder vor als dieselbe Frage anderswo', () => { |
| 147 | /* Derselbe Gedächtnisstand, derselbe Grad, derselbe Abstand, kein |
| 148 | Termin – der einzige Unterschied ist der Bereich. */ |
| 149 | const stand = { stabilitaet: 60, schwierigkeit: 5 }; |
| 150 | const gewoehnlich = wiedervorlageBerechnen(stand, 3, 1, null, false); |
| 151 | const koFrage = wiedervorlageBerechnen(stand, 3, 1, null, true); |
| 152 | |
| 153 | expect(koFrage.intervallTage).toBeLessThan(gewoehnlich.intervallTage); |
| 154 | /* An der Rechnung dieses Projekts abgelesen: 62 gegen 25 Tage – die |
| 155 | K.-o.-Frage kommt zweieinhalbmal so oft. */ |
| 156 | expect(gewoehnlich.intervallTage).toBe(62); |
| 157 | expect(koFrage.intervallTage).toBe(25); |
| 158 | }); |
| 159 | |
| 160 | it('lässt den Gedächtnisstand vom Termin unberührt', () => { |
| 161 | // Der Termin darf nur den Vorlagerhythmus ändern, nicht das Modell – |
| 162 | // sonst würde eine Terminänderung die gemessene Gedächtnisleistung |
| 163 | // rückwirkend verfälschen. |
| 164 | const stand = { stabilitaet: 20, schwierigkeit: 4 }; |
| 165 | const ohne = wiedervorlageBerechnen(stand, 3, 20, null); |
| 166 | const mit = wiedervorlageBerechnen(stand, 3, 20, 3); |
| 167 | |
| 168 | expect(mit.stabilitaet).toBe(ohne.stabilitaet); |
| 169 | expect(mit.schwierigkeit).toBe(ohne.schwierigkeit); |
| 170 | }); |
| 171 | }); |
| 172 | |
| 173 | describe('Einführungstage', () => { |
| 174 | it('lässt bei langer Vorbereitung fast alle Tage übrig', () => { |
| 175 | expect(einfuehrungstage(100)).toBe(97); |
| 176 | }); |
| 177 | |
| 178 | it('behält bei sehr kurzer Vorbereitung mindestens einen Tag', () => { |
| 179 | expect(einfuehrungstage(0)).toBe(1); |
| 180 | expect(einfuehrungstage(1)).toBe(1); |
| 181 | expect(einfuehrungstage(2)).toBe(2); |
| 182 | }); |
| 183 | |
| 184 | it('nimmt nie mehr als den Puffer weg', () => { |
| 185 | for (let tage = 0; tage <= 400; tage += 1) { |
| 186 | const uebrig = einfuehrungstage(tage); |
| 187 | expect(uebrig).toBeGreaterThanOrEqual(1); |
| 188 | expect(uebrig).toBeLessThanOrEqual(Math.max(1, tage)); |
| 189 | expect(tage - uebrig).toBeLessThanOrEqual(3); |
| 190 | } |
| 191 | }); |
| 192 | }); |
| 193 | |
| 194 | describe('Machbarkeit', () => { |
| 195 | it('meldet fehlenden Termin gesondert', () => { |
| 196 | expect(machbarkeitFuer(0, null)).toBe('kein_termin'); |
| 197 | expect(machbarkeitFuer(500, null)).toBe('kein_termin'); |
| 198 | }); |
| 199 | |
| 200 | it('meldet einen verstrichenen Termin gesondert', () => { |
| 201 | expect(machbarkeitFuer(10, -1)).toBe('termin_vorbei'); |
| 202 | }); |
| 203 | |
| 204 | it('stuft nach Tagesaufwand ab', () => { |
| 205 | expect(machbarkeitFuer(10, 30)).toBe('entspannt'); |
| 206 | expect(machbarkeitFuer(30, 30)).toBe('machbar'); |
| 207 | expect(machbarkeitFuer(60, 30)).toBe('knapp'); |
| 208 | expect(machbarkeitFuer(200, 30)).toBe('zu_wenig_zeit'); |
| 209 | }); |
| 210 | |
| 211 | it('wird mit steigendem Aufwand nie günstiger', () => { |
| 212 | const rang = ['entspannt', 'machbar', 'knapp', 'zu_wenig_zeit']; |
| 213 | let bisher = -1; |
| 214 | for (let minuten = 0; minuten <= 300; minuten += 1) { |
| 215 | const stufe = rang.indexOf(machbarkeitFuer(minuten, 30)); |
| 216 | expect(stufe).toBeGreaterThanOrEqual(bisher); |
| 217 | bisher = stufe; |
| 218 | } |
| 219 | }); |
| 220 | }); |
| 221 | |
| 222 | describe('Lernplan', () => { |
| 223 | it('zählt nie beantwortete Fragen und Fällige getrennt', () => { |
| 224 | const plan = lernplanBerechnen({ |
| 225 | termin: '2026-09-01', |
| 226 | tageBisTermin: 30, |
| 227 | fragen: [ |
| 228 | ...fragen(10), |
| 229 | ...fragen(5, { stabilitaet: 10, tageSeitAntwort: 12, faellig: true }), |
| 230 | ...fragen(3, { stabilitaet: 10, tageSeitAntwort: 1, faellig: false }), |
| 231 | ], |
| 232 | sekundenProFrage: null, |
| 233 | }); |
| 234 | |
| 235 | expect(plan.gesamtFragen).toBe(18); |
| 236 | expect(plan.nieBeantwortet).toBe(10); |
| 237 | expect(plan.faellig).toBe(5); |
| 238 | }); |
| 239 | |
| 240 | it('wertet eine nie beantwortete Frage als nicht gekonnt', () => { |
| 241 | const plan = lernplanBerechnen({ |
| 242 | termin: null, |
| 243 | tageBisTermin: null, |
| 244 | fragen: fragen(100), |
| 245 | sekundenProFrage: null, |
| 246 | }); |
| 247 | |
| 248 | expect(plan.prognoseHeute).toBe(0); |
| 249 | }); |
| 250 | |
| 251 | it('meldet auch bei eben beantwortetem Katalog nie volle Prognose', () => { |
| 252 | /* |
| 253 | Dieser Test hat bis Fassung 0.11.0 das Gegenteil verlangt – er prüfte |
| 254 | auf „fast genau 1“ und schrieb damit eine Unwahrheit fest: |
| 255 | `abrufwahrscheinlichkeit(S, 0)` ist für JEDES S exakt 1, auch für eine |
| 256 | Frage, die eben erst zum ersten Mal aufgetaucht ist. Während einer |
| 257 | Sitzung wanderte die Prognose deshalb gegen 100 Prozent, und zwar |
| 258 | unabhängig davon, wie gut jemand geantwortet hatte. |
| 259 | |
| 260 | Seit `reife.ts` wird nie mit weniger als einem Tag Abstand gerechnet. |
| 261 | Der Wert bleibt hoch – 30 Tage Stabilität sind viel –, aber er ist |
| 262 | keine Zusage mehr. |
| 263 | */ |
| 264 | const plan = lernplanBerechnen({ |
| 265 | termin: null, |
| 266 | tageBisTermin: null, |
| 267 | fragen: fragen(50, { stabilitaet: 30, tageSeitAntwort: 0 }), |
| 268 | sekundenProFrage: null, |
| 269 | }); |
| 270 | |
| 271 | expect(plan.prognoseHeute).toBeLessThan(1); |
| 272 | expect(plan.prognoseHeute).toBeCloseTo(abrufwahrscheinlichkeit(30, 1), 10); |
| 273 | }); |
| 274 | |
| 275 | it('zeigt in der Prognose zum Termin, was Nichtstun kostet', () => { |
| 276 | const plan = lernplanBerechnen({ |
| 277 | termin: '2026-04-01', |
| 278 | tageBisTermin: 40, |
| 279 | fragen: fragen(50, { stabilitaet: 20, tageSeitAntwort: 0 }), |
| 280 | sekundenProFrage: null, |
| 281 | }); |
| 282 | |
| 283 | expect(plan.prognoseAmTermin).not.toBeNull(); |
| 284 | expect(plan.prognoseAmTermin!).toBeLessThan(plan.prognoseHeute); |
| 285 | }); |
| 286 | |
| 287 | it('lässt die Prognose zum Termin ohne Termin offen', () => { |
| 288 | const plan = lernplanBerechnen({ |
| 289 | termin: null, |
| 290 | tageBisTermin: null, |
| 291 | fragen: fragen(10, { stabilitaet: 5, tageSeitAntwort: 1 }), |
| 292 | sekundenProFrage: null, |
| 293 | }); |
| 294 | |
| 295 | expect(plan.prognoseAmTermin).toBeNull(); |
| 296 | }); |
| 297 | |
| 298 | it('verteilt neue Fragen über die verbleibende Zeit', () => { |
| 299 | const plan = lernplanBerechnen({ |
| 300 | termin: '2026-04-01', |
| 301 | tageBisTermin: 40, |
| 302 | fragen: fragen(370), |
| 303 | sekundenProFrage: null, |
| 304 | }); |
| 305 | |
| 306 | // 370 Fragen auf 37 Einführungstage. |
| 307 | expect(plan.pensum.neu).toBe(10); |
| 308 | expect(plan.pensum.wiederholung).toBe(0); |
| 309 | expect(plan.pensum.gesamt).toBe(10); |
| 310 | }); |
| 311 | |
| 312 | it('verlangt bei knapper Zeit mehr pro Tag', () => { |
| 313 | const wenige = lernplanBerechnen({ |
| 314 | termin: '2026-04-01', |
| 315 | tageBisTermin: 5, |
| 316 | fragen: fragen(575), |
| 317 | sekundenProFrage: null, |
| 318 | }); |
| 319 | const viele = lernplanBerechnen({ |
| 320 | termin: '2026-09-01', |
| 321 | tageBisTermin: 180, |
| 322 | fragen: fragen(575), |
| 323 | sekundenProFrage: null, |
| 324 | }); |
| 325 | |
| 326 | // 575 Fragen auf vier Einführungstage sind rund eine Stunde am Tag – |
| 327 | // anstrengend, aber machbar. Erst darunter wird es unrealistisch. |
| 328 | expect(wenige.pensum.neu).toBeGreaterThan(viele.pensum.neu); |
| 329 | expect(wenige.machbarkeit).toBe('knapp'); |
| 330 | expect(viele.machbarkeit).toBe('entspannt'); |
| 331 | }); |
| 332 | |
| 333 | it('sagt deutlich, wenn die Zeit nicht mehr reicht', () => { |
| 334 | const plan = lernplanBerechnen({ |
| 335 | termin: '2026-03-03', |
| 336 | tageBisTermin: 2, |
| 337 | fragen: fragen(575), |
| 338 | sekundenProFrage: null, |
| 339 | }); |
| 340 | |
| 341 | expect(plan.pensum.minuten).toBeGreaterThan(90); |
| 342 | expect(plan.machbarkeit).toBe('zu_wenig_zeit'); |
| 343 | }); |
| 344 | |
| 345 | it('verlangt nie mehr neue Fragen, als es überhaupt gibt', () => { |
| 346 | const plan = lernplanBerechnen({ |
| 347 | termin: '2026-03-02', |
| 348 | tageBisTermin: 1, |
| 349 | fragen: fragen(5), |
| 350 | sekundenProFrage: null, |
| 351 | }); |
| 352 | |
| 353 | expect(plan.pensum.neu).toBe(5); |
| 354 | }); |
| 355 | |
| 356 | it('schlägt ohne Termin eine Sitzung vor', () => { |
| 357 | /* Ohne Stichtag gibt es nichts zu verteilen. „Nichts zu tun“ wäre aber |
| 358 | falsch, solange der halbe Katalog ungesehen ist – deshalb ein |
| 359 | Vorschlag in der Größe einer Lernsitzung. */ |
| 360 | const plan = lernplanBerechnen({ |
| 361 | termin: null, |
| 362 | tageBisTermin: null, |
| 363 | fragen: fragen(575), |
| 364 | sekundenProFrage: null, |
| 365 | }); |
| 366 | |
| 367 | expect(plan.pensum.neu).toBe(SITZUNGSUMFANG); |
| 368 | expect(plan.machbarkeit).toBe('kein_termin'); |
| 369 | }); |
| 370 | |
| 371 | it('meldet ohne Termin nichts zu tun, wenn alles beantwortet ist', () => { |
| 372 | const plan = lernplanBerechnen({ |
| 373 | termin: null, |
| 374 | tageBisTermin: null, |
| 375 | fragen: fragen(575, { stabilitaet: 30, tageSeitAntwort: 1 }), |
| 376 | sekundenProFrage: null, |
| 377 | }); |
| 378 | |
| 379 | expect(plan.pensum.gesamt).toBe(0); |
| 380 | }); |
| 381 | |
| 382 | it('rechnet die Dauer mit dem eigenen Tempo, wenn eines vorliegt', () => { |
| 383 | const daten = { |
| 384 | termin: '2026-04-01', |
| 385 | tageBisTermin: 10, |
| 386 | fragen: fragen(60, { stabilitaet: 1, tageSeitAntwort: 5, faellig: true }), |
| 387 | }; |
| 388 | |
| 389 | const schnell = lernplanBerechnen({ ...daten, sekundenProFrage: 10 }); |
| 390 | const langsam = lernplanBerechnen({ ...daten, sekundenProFrage: 40 }); |
| 391 | |
| 392 | expect(schnell.pensum.minuten).toBe(10); |
| 393 | expect(langsam.pensum.minuten).toBe(40); |
| 394 | expect(schnell.sekundenProFrage).toBe(10); |
| 395 | }); |
| 396 | |
| 397 | it('greift ohne eigenes Tempo auf den Vorgabewert zurück', () => { |
| 398 | const plan = lernplanBerechnen({ |
| 399 | termin: null, |
| 400 | tageBisTermin: null, |
| 401 | fragen: fragen(1), |
| 402 | sekundenProFrage: null, |
| 403 | }); |
| 404 | |
| 405 | expect(plan.sekundenProFrage).toBe(SEKUNDEN_PRO_FRAGE_VORGABE); |
| 406 | }); |
| 407 | |
| 408 | it('weist ein unsinniges Tempo ab, statt damit zu rechnen', () => { |
| 409 | for (const tempo of [0, -5]) { |
| 410 | const plan = lernplanBerechnen({ |
| 411 | termin: null, |
| 412 | tageBisTermin: null, |
| 413 | fragen: fragen(1), |
| 414 | sekundenProFrage: tempo, |
| 415 | }); |
| 416 | expect(plan.sekundenProFrage).toBe(SEKUNDEN_PRO_FRAGE_VORGABE); |
| 417 | } |
| 418 | }); |
| 419 | |
| 420 | it('kommt mit einem leeren Katalog zurecht', () => { |
| 421 | const plan = lernplanBerechnen({ |
| 422 | termin: null, |
| 423 | tageBisTermin: null, |
| 424 | fragen: [], |
| 425 | sekundenProFrage: null, |
| 426 | }); |
| 427 | |
| 428 | expect(plan.gesamtFragen).toBe(0); |
| 429 | expect(plan.prognoseHeute).toBe(0); |
| 430 | expect(plan.pensum.gesamt).toBe(0); |
| 431 | }); |
| 432 | |
| 433 | it('führt nach dem Termin keine neuen Fragen mehr ein', () => { |
| 434 | const plan = lernplanBerechnen({ |
| 435 | termin: '2026-01-01', |
| 436 | tageBisTermin: -30, |
| 437 | fragen: fragen(100), |
| 438 | sekundenProFrage: null, |
| 439 | }); |
| 440 | |
| 441 | expect(plan.pensum.neu).toBe(0); |
| 442 | expect(plan.prognoseAmTermin).toBeNull(); |
| 443 | expect(plan.machbarkeit).toBe('termin_vorbei'); |
| 444 | }); |
| 445 | }); |
| 446 | |
| 447 | /* |
| 448 | Die Arbeitslast-Vorschau. |
| 449 | |
| 450 | Der Plan kannte bis 0.22.0 nur „heute“. Weil die Zielquote ab |
| 451 | ANHEBUNG_AB_TAGEN Tagen vor dem Termin steigt und die Intervalle dadurch |
| 452 | zusammenrücken, türmt sich ein Wiederholungsberg, den der Lernende erst am |
| 453 | jeweiligen Morgen erfuhr. |
| 454 | |
| 455 | Was hier geprüft wird, ist vor allem die **Ehrlichkeit** der Zahl: Sie ist |
| 456 | eine Untergrenze und darf nichts enthalten, was noch gar keinen Termin hat. |
| 457 | */ |
| 458 | describe('Arbeitslast-Vorschau', () => { |
| 459 | it('zählt die Wiederholungen je Tag', () => { |
| 460 | const plan = lernplanBerechnen({ |
| 461 | termin: null, |
| 462 | tageBisTermin: null, |
| 463 | sekundenProFrage: null, |
| 464 | fragen: [ |
| 465 | ...fragen(3, { stabilitaet: 5, faelligInTagen: 0, faellig: true }), |
| 466 | ...fragen(2, { stabilitaet: 5, faelligInTagen: 1 }), |
| 467 | ...fragen(7, { stabilitaet: 5, faelligInTagen: 4 }), |
| 468 | ], |
| 469 | }); |
| 470 | |
| 471 | expect(plan.vorschau?.[0]).toBe(3); |
| 472 | expect(plan.vorschau?.[1]).toBe(2); |
| 473 | expect(plan.vorschau?.[4]).toBe(7); |
| 474 | expect(plan.vorschau?.[2]).toBe(0); |
| 475 | }); |
| 476 | |
| 477 | it('lässt nie beantwortete Fragen draußen', () => { |
| 478 | /* Sie haben keinen Termin, sondern kommen mit der Einführungsrate — eine |
| 479 | eigene, planbare Größe. Sie hier mitzuzählen machte aus der |
| 480 | Bestandsauskunft eine Vermutung. */ |
| 481 | const plan = lernplanBerechnen({ |
| 482 | termin: null, |
| 483 | tageBisTermin: null, |
| 484 | sekundenProFrage: null, |
| 485 | fragen: fragen(20, { stabilitaet: null, faelligInTagen: null }), |
| 486 | }); |
| 487 | |
| 488 | expect(plan.vorschau?.every((anzahl) => anzahl === 0)).toBe(true); |
| 489 | }); |
| 490 | |
| 491 | it('reicht nie über den Prüfungstermin hinaus', () => { |
| 492 | /* Eine Vorschau auf Tage nach der Prüfung wäre eine Auskunft ohne |
| 493 | Adressaten. */ |
| 494 | const plan = lernplanBerechnen({ |
| 495 | termin: '2026-03-05', |
| 496 | tageBisTermin: 3, |
| 497 | sekundenProFrage: null, |
| 498 | fragen: fragen(5, { stabilitaet: 5, faelligInTagen: 9 }), |
| 499 | }); |
| 500 | |
| 501 | expect(plan.vorschau).toHaveLength(4); |
| 502 | expect(plan.vorschau?.every((anzahl) => anzahl === 0)).toBe(true); |
| 503 | }); |
| 504 | |
| 505 | it('reicht ohne Termin bis zur festen Weite', () => { |
| 506 | const plan = lernplanBerechnen({ |
| 507 | termin: null, |
| 508 | tageBisTermin: null, |
| 509 | sekundenProFrage: null, |
| 510 | fragen: fragen(1, { stabilitaet: 5, faelligInTagen: 0, faellig: true }), |
| 511 | }); |
| 512 | |
| 513 | expect(plan.vorschau).toHaveLength(VORSCHAU_TAGE + 1); |
| 514 | }); |
| 515 | |
| 516 | it('kommt ohne die neue Angabe zurecht', () => { |
| 517 | /* Ein Lernstand einer älteren Fassung liefert `faelligInTagen` nicht. |
| 518 | Das darf die Planung nicht zu Fall bringen. */ |
| 519 | const plan = lernplanBerechnen({ |
| 520 | termin: null, |
| 521 | tageBisTermin: null, |
| 522 | sekundenProFrage: null, |
| 523 | fragen: fragen(4, { stabilitaet: 5, faellig: true }), |
| 524 | }); |
| 525 | |
| 526 | expect(plan.vorschau?.every((anzahl) => anzahl === 0)).toBe(true); |
| 527 | }); |
| 528 | }); |
| 529 | |
| 530 | describe('Der Prüfungstag', () => { |
| 531 | it('verlangt keine neuen Fragen mehr', () => { |
| 532 | /* |
| 533 | Der Grund ist keine Schonung, sondern eine Ableitung aus der Belegregel: |
| 534 | Eine Frage, die heute zum ersten Mal auftaucht, kann nicht mehr belegt |
| 535 | werden – dafür bräuchte es eine richtige Antwort nach mindestens einem |
| 536 | Tag Abstand, und diesen Tag gibt es nicht mehr. |
| 537 | |
| 538 | Vorher stand hier `< 0`. Da `einfuehrungstage(0)` auf 1 zurückfällt, |
| 539 | ergab das den ganzen Rest: 300 ungesehene Fragen und 20 fällige wurden |
| 540 | am Morgen der Prüfung zu „Heute 320 Fragen, etwa 133 Minuten“. |
| 541 | */ |
| 542 | const plan = lernplanBerechnen({ |
| 543 | termin: '2026-06-15', |
| 544 | tageBisTermin: 0, |
| 545 | fragen: [ |
| 546 | ...fragen(300), |
| 547 | ...fragen(20, { stabilitaet: 5, tageSeitAntwort: 9, faellig: true }), |
| 548 | ], |
| 549 | sekundenProFrage: null, |
| 550 | }); |
| 551 | |
| 552 | expect(plan.pensum.neu).toBe(0); |
| 553 | expect(plan.pensum.wiederholung).toBe(20); |
| 554 | expect(plan.pensum.gesamt).toBe(20); |
| 555 | }); |
| 556 | |
| 557 | it('lässt den Vortag unberührt', () => { |
| 558 | /* Dort ist der Beleg noch erreichbar, und mehr Tage gibt es nicht. Der |
| 559 | Randfall gehört benannt, nicht behoben – ein harter Einführungsstopp |
| 560 | über mehrere Tage verstecke ungesehene Fragen ganz. */ |
| 561 | const plan = lernplanBerechnen({ |
| 562 | termin: '2026-06-15', |
| 563 | tageBisTermin: 1, |
| 564 | fragen: fragen(300), |
| 565 | sekundenProFrage: null, |
| 566 | }); |
| 567 | |
| 568 | expect(plan.pensum.neu).toBeGreaterThan(0); |
| 569 | }); |
| 570 | }); |
| 571 | |
| 572 | describe('Was bewusst nicht gebaut ist', () => { |
| 573 | /* |
| 574 | Diese Prüfungen halten Ablehnungen fest, die in |
| 575 | `docs/entscheidung-lernphasen.md` begründet sind. Ohne sie wäre jede |
| 576 | Ablehnung nur ein Kommentar – und Kommentare überschreibt irgendwann |
| 577 | jemand, der PLAN.md liest und die Zusage darin findet. |
| 578 | */ |
| 579 | it('deckelt Wiedervorlagen nicht am Prüfungstermin', () => { |
| 580 | /* |
| 581 | Jede geprüfte Kappung schiebt alles, was über den Termin hinausginge, |
| 582 | auf den Prüfungstag selbst – gemessen 432 von 486 Fragen an einem Tag, |
| 583 | rund drei Stunden. Und sie senkt den Reifegrad, weil in dieser Anwendung |
| 584 | eine überflüssige Wiederholung nicht nur Zeit kostet: Eine falsche |
| 585 | Antwort nimmt den Beleg weg. |
| 586 | */ |
| 587 | const stand = { stabilitaet: 60, schwierigkeit: 5 }; |
| 588 | const vorlage = wiedervorlageBerechnen(stand, 3, 1, 10); |
| 589 | |
| 590 | expect(vorlage.intervallTage).toBeGreaterThan(10); |
| 591 | }); |
| 592 | |
| 593 | it('kennt keinen Phasenbegriff im Lernplan', () => { |
| 594 | /* |
| 595 | Phase 1 wäre die Belegregel aus `reife.ts`, Phase 2 die Definition des |
| 596 | FSRS-Intervalls, Phase 3 rechnet der Plan von selbst nach, und Phase 4 |
| 597 | wäre ein Etikett über `ANHEBUNG_AB_TAGEN`. Vier Namen für Dinge, die es |
| 598 | gibt oder die nichts ändern. |
| 599 | */ |
| 600 | const plan = lernplanBerechnen({ |
| 601 | termin: '2026-06-15', |
| 602 | tageBisTermin: 30, |
| 603 | fragen: fragen(100), |
| 604 | sekundenProFrage: null, |
| 605 | }); |
| 606 | |
| 607 | expect(Object.keys(plan)).not.toContain('phase'); |
| 608 | expect(Object.keys(plan)).not.toContain('rueckstand'); |
| 609 | }); |
| 610 | |
| 611 | it('holt ausgelassene Tage von selbst auf', () => { |
| 612 | /* |
| 613 | Die Zusage „Rückstand erhöht das Tagespensum statt still zu |
| 614 | verschieben“ ist durch die Bauart bereits erfüllt: Die Rate ergibt sich |
| 615 | täglich neu aus den ungesehenen Fragen und den Resttagen. Wer Tage |
| 616 | auslässt, bekommt danach mehr – ohne dass irgendwo „Rückstand“ stünde. |
| 617 | |
| 618 | Gemessen wird mit zehn ausgelassenen Tagen und nicht mit dreien: Bei 40 |
| 619 | Tagen Rest ändern drei Tage die Rate gar nicht (9 bleibt 9). Das ist |
| 620 | keine Schwäche, sondern der eigentliche Grund gegen einen |
| 621 | Rückstandsbegriff – der Plan schluckt kleine Lücken, und eine Anzeige, |
| 622 | die sie benennte, machte aus Nichts eine Mahnung. |
| 623 | */ |
| 624 | const ungesehen = 300; |
| 625 | const vorher = lernplanBerechnen({ |
| 626 | termin: '2026-06-15', |
| 627 | tageBisTermin: 20, |
| 628 | fragen: fragen(ungesehen), |
| 629 | sekundenProFrage: null, |
| 630 | }); |
| 631 | const nachZehnTagenNichtstun = lernplanBerechnen({ |
| 632 | termin: '2026-06-15', |
| 633 | tageBisTermin: 10, |
| 634 | fragen: fragen(ungesehen), |
| 635 | sekundenProFrage: null, |
| 636 | }); |
| 637 | |
| 638 | expect(nachZehnTagenNichtstun.pensum.neu).toBeGreaterThan(vorher.pensum.neu); |
| 639 | }); |
| 640 | |
| 641 | it('lässt drei ausgelassene Tage bei viel Vorlauf gar nicht auffallen', () => { |
| 642 | /* Genau deshalb gibt es keinen Rückstandsbegriff: Es gibt nichts zu |
| 643 | melden. */ |
| 644 | const bei40 = lernplanBerechnen({ |
| 645 | termin: '2026-06-15', |
| 646 | tageBisTermin: 40, |
| 647 | fragen: fragen(300), |
| 648 | sekundenProFrage: null, |
| 649 | }); |
| 650 | const bei37 = lernplanBerechnen({ |
| 651 | termin: '2026-06-15', |
| 652 | tageBisTermin: 37, |
| 653 | fragen: fragen(300), |
| 654 | sekundenProFrage: null, |
| 655 | }); |
| 656 | |
| 657 | expect(bei37.pensum.neu).toBe(bei40.pensum.neu); |
| 658 | }); |
| 659 | }); |
| 660 | |
| 661 | describe('Lastausgleich der Wiedervorlage', () => { |
| 662 | /* |
| 663 | Die Streuung verteilt Fragen über ein Fenster, aber blind: Sie weiß nicht, |
| 664 | dass am Donnerstag schon neunzig Wiederholungen stehen und am Freitag |
| 665 | vier. Der Ausgleich wählt aus demselben Fenster den leersten Tag. |
| 666 | */ |
| 667 | |
| 668 | it('liefert ohne Lastauskunft genau den gestreuten Wert', () => { |
| 669 | for (const tage of [1, 2, 3, 7, 20, 60, 180]) { |
| 670 | for (const id of ['I.1-01', 'II-14', 'IV-88']) { |
| 671 | expect(ausgeglichenesIntervall(tage, id, null)).toBe(gestreutesIntervall(tage, id)); |
| 672 | } |
| 673 | } |
| 674 | }); |
| 675 | |
| 676 | it('liefert bei gleichmäßiger Last ebenfalls den gestreuten Wert', () => { |
| 677 | /* |
| 678 | Die Eigenschaft, die den Ausgleich zu einer Verfeinerung macht statt zu |
| 679 | einer Ablösung: Wo nichts zu glätten ist, ändert er nichts. Sonst |
| 680 | verschöbe er Termine ohne Grund und machte die Streuung unprüfbar. |
| 681 | */ |
| 682 | const gleich = new Map<number, number>(); |
| 683 | for (let tag = 1; tag <= MAX_INTERVALL_TAGE; tag += 1) { |
| 684 | gleich.set(tag, 5); |
| 685 | } |
| 686 | for (const tage of [3, 10, 40, 120]) { |
| 687 | expect(ausgeglichenesIntervall(tage, 'II-07', gleich)).toBe( |
| 688 | gestreutesIntervall(tage, 'II-07'), |
| 689 | ); |
| 690 | } |
| 691 | }); |
| 692 | |
| 693 | it('weicht auf den leersten Tag des Fensters aus', () => { |
| 694 | /* Fenster um 20 Tage: 15 Prozent von 20 sind 3, also 17 bis 23. */ |
| 695 | const last = new Map<number, number>(); |
| 696 | for (let tag = 17; tag <= 23; tag += 1) { |
| 697 | last.set(tag, 90); |
| 698 | } |
| 699 | last.set(22, 0); |
| 700 | |
| 701 | expect(ausgeglichenesIntervall(20, 'I.1-01', last)).toBe(22); |
| 702 | }); |
| 703 | |
| 704 | it('verlässt das zulässige Fenster nicht, wie leer es draußen auch ist', () => { |
| 705 | /* |
| 706 | Der Ausgleich darf die Genauigkeit des Modells nicht kaufen. Das Fenster |
| 707 | ist dasselbe wie das der Streuung – höchstens 15 Prozent, gedeckelt auf |
| 708 | sieben Tage. |
| 709 | */ |
| 710 | const last = new Map<number, number>(); |
| 711 | for (let tag = 1; tag <= MAX_INTERVALL_TAGE; tag += 1) { |
| 712 | last.set(tag, tag >= 17 && tag <= 23 ? 90 : 0); |
| 713 | } |
| 714 | |
| 715 | const gewaehlt = ausgeglichenesIntervall(20, 'I.1-01', last); |
| 716 | expect(gewaehlt).toBeGreaterThanOrEqual(17); |
| 717 | expect(gewaehlt).toBeLessThanOrEqual(23); |
| 718 | }); |
| 719 | |
| 720 | it('lässt kurze Intervalle unangetastet', () => { |
| 721 | /* Unter drei Tagen ist ein Tag Versatz keine Verschiebung mehr, sondern |
| 722 | eine andere Antwort – dieselbe Grenze wie bei der Streuung. */ |
| 723 | const leer = new Map<number, number>([[1, 0]]); |
| 724 | expect(ausgeglichenesIntervall(1, 'I.1-01', leer)).toBe(1); |
| 725 | expect(ausgeglichenesIntervall(2, 'I.1-01', leer)).toBe(2); |
| 726 | }); |
| 727 | |
| 728 | it('bleibt innerhalb der Höchstgrenze', () => { |
| 729 | const leer = new Map<number, number>(); |
| 730 | expect(ausgeglichenesIntervall(MAX_INTERVALL_TAGE, 'I.1-01', leer)).toBeLessThanOrEqual( |
| 731 | MAX_INTERVALL_TAGE, |
| 732 | ); |
| 733 | expect(ausgeglichenesIntervall(MAX_INTERVALL_TAGE, 'I.1-01', leer)).toBeGreaterThanOrEqual(1); |
| 734 | }); |
| 735 | }); |