waffensachkunde

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

/ app tests lernplan.test.ts

25,2 KB Rohdatei
app/tests/lernplan.test.ts — 735 Zeilen
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 });