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