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