waffensachkunde

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

/ app tests lernplan.test.ts

22,3 KB Rohdatei
app/tests/lernplan.test.ts — 657 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 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 });