/** * Prüft die Prüfungstermin-Schicht über FSRS. * * Alles hier ist eigene Entscheidung und nicht durch eine Referenz gedeckt – * deshalb wird geprüft, was die Regeln *zusagen*, nicht welche Zahlen gerade * herauskommen. Ein Test, der nur die aktuelle Ausgabe festschreibt, würde bei * jeder Feinjustierung rot, ohne dass etwas kaputt wäre. */ import { describe, expect, it } from 'vitest'; import { ANHEBUNG_AB_TAGEN, ausgeglichenesIntervall, einfuehrungstage, gestreutesIntervall, lernplanBerechnen, machbarkeitFuer, MAX_INTERVALL_TAGE, SEKUNDEN_PRO_FRAGE_VORGABE, SITZUNGSUMFANG, VORSCHAU_TAGE, wiedervorlageBerechnen, zielquoteFuer, ZIELQUOTE_GRUND, ZIELQUOTE_TERMIN, ZIELQUOTE_KO, type PlanFrage, } from '../src/shared/lernplan'; import { abrufwahrscheinlichkeit } from '../src/shared/fsrs'; function frage(teil: Partial = {}): PlanFrage { /* `bestaetigt: true` als Vorgabe: Diese Datei prüft die Planung, nicht die Belegregel. Ohne den Beleg wäre jede Prognose hier null, und die Tests prüften nichts mehr. Die Regel selbst steht in `reife.test.ts`. */ return { bereich: 'I.1', stabilitaet: null, tageSeitAntwort: 0, bestaetigt: true, faellig: false, ...teil, }; } /** `anzahl` gleichartige Fragen – spart Wiederholung in den Fällen unten. */ function fragen(anzahl: number, teil: Partial = {}): PlanFrage[] { return Array.from({ length: anzahl }, () => frage(teil)); } describe('Zielquote', () => { it('bleibt ohne Termin beim Vorgabewert', () => { expect(zielquoteFuer(null)).toBe(ZIELQUOTE_GRUND); }); it('bleibt weit vor dem Termin beim Vorgabewert', () => { expect(zielquoteFuer(ANHEBUNG_AB_TAGEN)).toBe(ZIELQUOTE_GRUND); expect(zielquoteFuer(ANHEBUNG_AB_TAGEN + 100)).toBe(ZIELQUOTE_GRUND); }); it('erreicht am Prüfungstag die höchste Stufe', () => { expect(zielquoteFuer(0)).toBeCloseTo(ZIELQUOTE_TERMIN, 10); }); it('steigt dazwischen streng monoton', () => { let vorher = zielquoteFuer(ANHEBUNG_AB_TAGEN); for (let tage = ANHEBUNG_AB_TAGEN - 1; tage >= 0; tage -= 1) { const jetzt = zielquoteFuer(tage); expect(jetzt).toBeGreaterThan(vorher); vorher = jetzt; } }); it('bleibt nach einem verstrichenen Termin oben', () => { // Wer den Termin verpasst hat, lernt vermutlich für einen Nachholtermin. // Die Quote darf dann nicht auf den Ruhewert zurückfallen. expect(zielquoteFuer(-1)).toBeCloseTo(ZIELQUOTE_TERMIN, 10); expect(zielquoteFuer(-90)).toBeCloseTo(ZIELQUOTE_TERMIN, 10); }); }); describe('Wiedervorlage', () => { it('macht eine Frage bei „nochmal“ sofort wieder fällig', () => { expect(wiedervorlageBerechnen(null, 1, 0, null).intervallTage).toBe(0); expect( wiedervorlageBerechnen({ stabilitaet: 200, schwierigkeit: 2 }, 1, 30, null).intervallTage, ).toBe(0); }); it('schreibt den Gedächtnisstand auch bei „nochmal“ fort', () => { // Intervall 0 ist eine Entscheidung der Oberfläche, kein Zurücksetzen des // Modells. Sonst ginge die Historie der Frage verloren. const vorher = { stabilitaet: 40, schwierigkeit: 3 }; const nachher = wiedervorlageBerechnen(vorher, 1, 30, null); expect(nachher.stabilitaet).toBeGreaterThan(0); expect(nachher.stabilitaet).toBeLessThan(vorher.stabilitaet); expect(nachher.schwierigkeit).toBeGreaterThan(vorher.schwierigkeit); }); it('vergibt sonst nie weniger als einen Tag', () => { for (const grad of [2, 3, 4] as const) { expect(wiedervorlageBerechnen(null, grad, 0, null).intervallTage).toBeGreaterThanOrEqual(1); } }); it('überschreitet die Obergrenze nicht', () => { const fest = { stabilitaet: 100_000, schwierigkeit: 1 }; expect(wiedervorlageBerechnen(fest, 4, 10, null).intervallTage).toBe(MAX_INTERVALL_TAGE); }); it('verkürzt sich, je näher der Termin rückt', () => { const stand = { stabilitaet: 60, schwierigkeit: 5 }; const weit = wiedervorlageBerechnen(stand, 3, 60, 200).intervallTage; const mittel = wiedervorlageBerechnen(stand, 3, 60, 14).intervallTage; const nah = wiedervorlageBerechnen(stand, 3, 60, 2).intervallTage; expect(mittel).toBeLessThan(weit); expect(nah).toBeLessThan(mittel); }); /* Die Wache, die gefehlt hat. Seit 0.22.0 deckelt die Reifeampel ihr Gesamturteil am schwächsten K.-o.-Bereich: Wer insgesamt bei 88 Prozent steht und in Notwehr bei 60, bekommt kein „Prüfungsreif“. Der Planer wusste davon nichts und terminierte die 43 Fragen aus I.5 wie jede andere. Die Ampel warnte, und die Wiedervorlage handelte nicht danach. */ it('hält die Zielquote in einem K.-o.-Bereich auf mindestens 0,95', () => { expect(zielquoteFuer(null, true)).toBeGreaterThanOrEqual(ZIELQUOTE_KO); expect(zielquoteFuer(365, true)).toBeGreaterThanOrEqual(ZIELQUOTE_KO); /* Ohne die Kennzeichnung bleibt es bei der Grundquote – der Rest des Bestands wird nicht mitgezogen. */ expect(zielquoteFuer(365, false)).toBe(ZIELQUOTE_GRUND); }); it('lässt die Terminquote über die Untergrenze steigen', () => { /* Am Termin selbst gilt für alle dieselbe, höhere Quote; die Untergrenze greift dann nicht mehr und verdirbt sie auch nicht. */ expect(zielquoteFuer(0, true)).toBeCloseTo(ZIELQUOTE_TERMIN, 10); expect(zielquoteFuer(0, false)).toBeCloseTo(ZIELQUOTE_TERMIN, 10); }); it('legt eine K.-o.-Frage früher wieder vor als dieselbe Frage anderswo', () => { /* Derselbe Gedächtnisstand, derselbe Grad, derselbe Abstand, kein Termin – der einzige Unterschied ist der Bereich. */ const stand = { stabilitaet: 60, schwierigkeit: 5 }; const gewoehnlich = wiedervorlageBerechnen(stand, 3, 1, null, false); const koFrage = wiedervorlageBerechnen(stand, 3, 1, null, true); expect(koFrage.intervallTage).toBeLessThan(gewoehnlich.intervallTage); /* An der Rechnung dieses Projekts abgelesen: 62 gegen 25 Tage – die K.-o.-Frage kommt zweieinhalbmal so oft. */ expect(gewoehnlich.intervallTage).toBe(62); expect(koFrage.intervallTage).toBe(25); }); it('lässt den Gedächtnisstand vom Termin unberührt', () => { // Der Termin darf nur den Vorlagerhythmus ändern, nicht das Modell – // sonst würde eine Terminänderung die gemessene Gedächtnisleistung // rückwirkend verfälschen. const stand = { stabilitaet: 20, schwierigkeit: 4 }; const ohne = wiedervorlageBerechnen(stand, 3, 20, null); const mit = wiedervorlageBerechnen(stand, 3, 20, 3); expect(mit.stabilitaet).toBe(ohne.stabilitaet); expect(mit.schwierigkeit).toBe(ohne.schwierigkeit); }); }); describe('Einführungstage', () => { it('lässt bei langer Vorbereitung fast alle Tage übrig', () => { expect(einfuehrungstage(100)).toBe(97); }); it('behält bei sehr kurzer Vorbereitung mindestens einen Tag', () => { expect(einfuehrungstage(0)).toBe(1); expect(einfuehrungstage(1)).toBe(1); expect(einfuehrungstage(2)).toBe(2); }); it('nimmt nie mehr als den Puffer weg', () => { for (let tage = 0; tage <= 400; tage += 1) { const uebrig = einfuehrungstage(tage); expect(uebrig).toBeGreaterThanOrEqual(1); expect(uebrig).toBeLessThanOrEqual(Math.max(1, tage)); expect(tage - uebrig).toBeLessThanOrEqual(3); } }); }); describe('Machbarkeit', () => { it('meldet fehlenden Termin gesondert', () => { expect(machbarkeitFuer(0, null)).toBe('kein_termin'); expect(machbarkeitFuer(500, null)).toBe('kein_termin'); }); it('meldet einen verstrichenen Termin gesondert', () => { expect(machbarkeitFuer(10, -1)).toBe('termin_vorbei'); }); it('stuft nach Tagesaufwand ab', () => { expect(machbarkeitFuer(10, 30)).toBe('entspannt'); expect(machbarkeitFuer(30, 30)).toBe('machbar'); expect(machbarkeitFuer(60, 30)).toBe('knapp'); expect(machbarkeitFuer(200, 30)).toBe('zu_wenig_zeit'); }); it('wird mit steigendem Aufwand nie günstiger', () => { const rang = ['entspannt', 'machbar', 'knapp', 'zu_wenig_zeit']; let bisher = -1; for (let minuten = 0; minuten <= 300; minuten += 1) { const stufe = rang.indexOf(machbarkeitFuer(minuten, 30)); expect(stufe).toBeGreaterThanOrEqual(bisher); bisher = stufe; } }); }); describe('Lernplan', () => { it('zählt nie beantwortete Fragen und Fällige getrennt', () => { const plan = lernplanBerechnen({ termin: '2026-09-01', tageBisTermin: 30, fragen: [ ...fragen(10), ...fragen(5, { stabilitaet: 10, tageSeitAntwort: 12, faellig: true }), ...fragen(3, { stabilitaet: 10, tageSeitAntwort: 1, faellig: false }), ], sekundenProFrage: null, }); expect(plan.gesamtFragen).toBe(18); expect(plan.nieBeantwortet).toBe(10); expect(plan.faellig).toBe(5); }); it('wertet eine nie beantwortete Frage als nicht gekonnt', () => { const plan = lernplanBerechnen({ termin: null, tageBisTermin: null, fragen: fragen(100), sekundenProFrage: null, }); expect(plan.prognoseHeute).toBe(0); }); it('meldet auch bei eben beantwortetem Katalog nie volle Prognose', () => { /* Dieser Test hat bis Fassung 0.11.0 das Gegenteil verlangt – er prüfte auf „fast genau 1“ und schrieb damit eine Unwahrheit fest: `abrufwahrscheinlichkeit(S, 0)` ist für JEDES S exakt 1, auch für eine Frage, die eben erst zum ersten Mal aufgetaucht ist. Während einer Sitzung wanderte die Prognose deshalb gegen 100 Prozent, und zwar unabhängig davon, wie gut jemand geantwortet hatte. Seit `reife.ts` wird nie mit weniger als einem Tag Abstand gerechnet. Der Wert bleibt hoch – 30 Tage Stabilität sind viel –, aber er ist keine Zusage mehr. */ const plan = lernplanBerechnen({ termin: null, tageBisTermin: null, fragen: fragen(50, { stabilitaet: 30, tageSeitAntwort: 0 }), sekundenProFrage: null, }); expect(plan.prognoseHeute).toBeLessThan(1); expect(plan.prognoseHeute).toBeCloseTo(abrufwahrscheinlichkeit(30, 1), 10); }); it('zeigt in der Prognose zum Termin, was Nichtstun kostet', () => { const plan = lernplanBerechnen({ termin: '2026-04-01', tageBisTermin: 40, fragen: fragen(50, { stabilitaet: 20, tageSeitAntwort: 0 }), sekundenProFrage: null, }); expect(plan.prognoseAmTermin).not.toBeNull(); expect(plan.prognoseAmTermin!).toBeLessThan(plan.prognoseHeute); }); it('lässt die Prognose zum Termin ohne Termin offen', () => { const plan = lernplanBerechnen({ termin: null, tageBisTermin: null, fragen: fragen(10, { stabilitaet: 5, tageSeitAntwort: 1 }), sekundenProFrage: null, }); expect(plan.prognoseAmTermin).toBeNull(); }); it('verteilt neue Fragen über die verbleibende Zeit', () => { const plan = lernplanBerechnen({ termin: '2026-04-01', tageBisTermin: 40, fragen: fragen(370), sekundenProFrage: null, }); // 370 Fragen auf 37 Einführungstage. expect(plan.pensum.neu).toBe(10); expect(plan.pensum.wiederholung).toBe(0); expect(plan.pensum.gesamt).toBe(10); }); it('verlangt bei knapper Zeit mehr pro Tag', () => { const wenige = lernplanBerechnen({ termin: '2026-04-01', tageBisTermin: 5, fragen: fragen(575), sekundenProFrage: null, }); const viele = lernplanBerechnen({ termin: '2026-09-01', tageBisTermin: 180, fragen: fragen(575), sekundenProFrage: null, }); // 575 Fragen auf vier Einführungstage sind rund eine Stunde am Tag – // anstrengend, aber machbar. Erst darunter wird es unrealistisch. expect(wenige.pensum.neu).toBeGreaterThan(viele.pensum.neu); expect(wenige.machbarkeit).toBe('knapp'); expect(viele.machbarkeit).toBe('entspannt'); }); it('sagt deutlich, wenn die Zeit nicht mehr reicht', () => { const plan = lernplanBerechnen({ termin: '2026-03-03', tageBisTermin: 2, fragen: fragen(575), sekundenProFrage: null, }); expect(plan.pensum.minuten).toBeGreaterThan(90); expect(plan.machbarkeit).toBe('zu_wenig_zeit'); }); it('verlangt nie mehr neue Fragen, als es überhaupt gibt', () => { const plan = lernplanBerechnen({ termin: '2026-03-02', tageBisTermin: 1, fragen: fragen(5), sekundenProFrage: null, }); expect(plan.pensum.neu).toBe(5); }); it('schlägt ohne Termin eine Sitzung vor', () => { /* Ohne Stichtag gibt es nichts zu verteilen. „Nichts zu tun“ wäre aber falsch, solange der halbe Katalog ungesehen ist – deshalb ein Vorschlag in der Größe einer Lernsitzung. */ const plan = lernplanBerechnen({ termin: null, tageBisTermin: null, fragen: fragen(575), sekundenProFrage: null, }); expect(plan.pensum.neu).toBe(SITZUNGSUMFANG); expect(plan.machbarkeit).toBe('kein_termin'); }); it('meldet ohne Termin nichts zu tun, wenn alles beantwortet ist', () => { const plan = lernplanBerechnen({ termin: null, tageBisTermin: null, fragen: fragen(575, { stabilitaet: 30, tageSeitAntwort: 1 }), sekundenProFrage: null, }); expect(plan.pensum.gesamt).toBe(0); }); it('rechnet die Dauer mit dem eigenen Tempo, wenn eines vorliegt', () => { const daten = { termin: '2026-04-01', tageBisTermin: 10, fragen: fragen(60, { stabilitaet: 1, tageSeitAntwort: 5, faellig: true }), }; const schnell = lernplanBerechnen({ ...daten, sekundenProFrage: 10 }); const langsam = lernplanBerechnen({ ...daten, sekundenProFrage: 40 }); expect(schnell.pensum.minuten).toBe(10); expect(langsam.pensum.minuten).toBe(40); expect(schnell.sekundenProFrage).toBe(10); }); it('greift ohne eigenes Tempo auf den Vorgabewert zurück', () => { const plan = lernplanBerechnen({ termin: null, tageBisTermin: null, fragen: fragen(1), sekundenProFrage: null, }); expect(plan.sekundenProFrage).toBe(SEKUNDEN_PRO_FRAGE_VORGABE); }); it('weist ein unsinniges Tempo ab, statt damit zu rechnen', () => { for (const tempo of [0, -5]) { const plan = lernplanBerechnen({ termin: null, tageBisTermin: null, fragen: fragen(1), sekundenProFrage: tempo, }); expect(plan.sekundenProFrage).toBe(SEKUNDEN_PRO_FRAGE_VORGABE); } }); it('kommt mit einem leeren Katalog zurecht', () => { const plan = lernplanBerechnen({ termin: null, tageBisTermin: null, fragen: [], sekundenProFrage: null, }); expect(plan.gesamtFragen).toBe(0); expect(plan.prognoseHeute).toBe(0); expect(plan.pensum.gesamt).toBe(0); }); it('führt nach dem Termin keine neuen Fragen mehr ein', () => { const plan = lernplanBerechnen({ termin: '2026-01-01', tageBisTermin: -30, fragen: fragen(100), sekundenProFrage: null, }); expect(plan.pensum.neu).toBe(0); expect(plan.prognoseAmTermin).toBeNull(); expect(plan.machbarkeit).toBe('termin_vorbei'); }); }); /* Die Arbeitslast-Vorschau. Der Plan kannte bis 0.22.0 nur „heute“. Weil die Zielquote ab ANHEBUNG_AB_TAGEN Tagen vor dem Termin steigt und die Intervalle dadurch zusammenrücken, türmt sich ein Wiederholungsberg, den der Lernende erst am jeweiligen Morgen erfuhr. Was hier geprüft wird, ist vor allem die **Ehrlichkeit** der Zahl: Sie ist eine Untergrenze und darf nichts enthalten, was noch gar keinen Termin hat. */ describe('Arbeitslast-Vorschau', () => { it('zählt die Wiederholungen je Tag', () => { const plan = lernplanBerechnen({ termin: null, tageBisTermin: null, sekundenProFrage: null, fragen: [ ...fragen(3, { stabilitaet: 5, faelligInTagen: 0, faellig: true }), ...fragen(2, { stabilitaet: 5, faelligInTagen: 1 }), ...fragen(7, { stabilitaet: 5, faelligInTagen: 4 }), ], }); expect(plan.vorschau?.[0]).toBe(3); expect(plan.vorschau?.[1]).toBe(2); expect(plan.vorschau?.[4]).toBe(7); expect(plan.vorschau?.[2]).toBe(0); }); it('lässt nie beantwortete Fragen draußen', () => { /* Sie haben keinen Termin, sondern kommen mit der Einführungsrate — eine eigene, planbare Größe. Sie hier mitzuzählen machte aus der Bestandsauskunft eine Vermutung. */ const plan = lernplanBerechnen({ termin: null, tageBisTermin: null, sekundenProFrage: null, fragen: fragen(20, { stabilitaet: null, faelligInTagen: null }), }); expect(plan.vorschau?.every((anzahl) => anzahl === 0)).toBe(true); }); it('reicht nie über den Prüfungstermin hinaus', () => { /* Eine Vorschau auf Tage nach der Prüfung wäre eine Auskunft ohne Adressaten. */ const plan = lernplanBerechnen({ termin: '2026-03-05', tageBisTermin: 3, sekundenProFrage: null, fragen: fragen(5, { stabilitaet: 5, faelligInTagen: 9 }), }); expect(plan.vorschau).toHaveLength(4); expect(plan.vorschau?.every((anzahl) => anzahl === 0)).toBe(true); }); it('reicht ohne Termin bis zur festen Weite', () => { const plan = lernplanBerechnen({ termin: null, tageBisTermin: null, sekundenProFrage: null, fragen: fragen(1, { stabilitaet: 5, faelligInTagen: 0, faellig: true }), }); expect(plan.vorschau).toHaveLength(VORSCHAU_TAGE + 1); }); it('kommt ohne die neue Angabe zurecht', () => { /* Ein Lernstand einer älteren Fassung liefert `faelligInTagen` nicht. Das darf die Planung nicht zu Fall bringen. */ const plan = lernplanBerechnen({ termin: null, tageBisTermin: null, sekundenProFrage: null, fragen: fragen(4, { stabilitaet: 5, faellig: true }), }); expect(plan.vorschau?.every((anzahl) => anzahl === 0)).toBe(true); }); }); describe('Der Prüfungstag', () => { it('verlangt keine neuen Fragen mehr', () => { /* Der Grund ist keine Schonung, sondern eine Ableitung aus der Belegregel: Eine Frage, die heute zum ersten Mal auftaucht, kann nicht mehr belegt werden – dafür bräuchte es eine richtige Antwort nach mindestens einem Tag Abstand, und diesen Tag gibt es nicht mehr. Vorher stand hier `< 0`. Da `einfuehrungstage(0)` auf 1 zurückfällt, ergab das den ganzen Rest: 300 ungesehene Fragen und 20 fällige wurden am Morgen der Prüfung zu „Heute 320 Fragen, etwa 133 Minuten“. */ const plan = lernplanBerechnen({ termin: '2026-06-15', tageBisTermin: 0, fragen: [ ...fragen(300), ...fragen(20, { stabilitaet: 5, tageSeitAntwort: 9, faellig: true }), ], sekundenProFrage: null, }); expect(plan.pensum.neu).toBe(0); expect(plan.pensum.wiederholung).toBe(20); expect(plan.pensum.gesamt).toBe(20); }); it('lässt den Vortag unberührt', () => { /* Dort ist der Beleg noch erreichbar, und mehr Tage gibt es nicht. Der Randfall gehört benannt, nicht behoben – ein harter Einführungsstopp über mehrere Tage verstecke ungesehene Fragen ganz. */ const plan = lernplanBerechnen({ termin: '2026-06-15', tageBisTermin: 1, fragen: fragen(300), sekundenProFrage: null, }); expect(plan.pensum.neu).toBeGreaterThan(0); }); }); describe('Was bewusst nicht gebaut ist', () => { /* Diese Prüfungen halten Ablehnungen fest, die in `docs/entscheidung-lernphasen.md` begründet sind. Ohne sie wäre jede Ablehnung nur ein Kommentar – und Kommentare überschreibt irgendwann jemand, der PLAN.md liest und die Zusage darin findet. */ it('deckelt Wiedervorlagen nicht am Prüfungstermin', () => { /* Jede geprüfte Kappung schiebt alles, was über den Termin hinausginge, auf den Prüfungstag selbst – gemessen 432 von 486 Fragen an einem Tag, rund drei Stunden. Und sie senkt den Reifegrad, weil in dieser Anwendung eine überflüssige Wiederholung nicht nur Zeit kostet: Eine falsche Antwort nimmt den Beleg weg. */ const stand = { stabilitaet: 60, schwierigkeit: 5 }; const vorlage = wiedervorlageBerechnen(stand, 3, 1, 10); expect(vorlage.intervallTage).toBeGreaterThan(10); }); it('kennt keinen Phasenbegriff im Lernplan', () => { /* Phase 1 wäre die Belegregel aus `reife.ts`, Phase 2 die Definition des FSRS-Intervalls, Phase 3 rechnet der Plan von selbst nach, und Phase 4 wäre ein Etikett über `ANHEBUNG_AB_TAGEN`. Vier Namen für Dinge, die es gibt oder die nichts ändern. */ const plan = lernplanBerechnen({ termin: '2026-06-15', tageBisTermin: 30, fragen: fragen(100), sekundenProFrage: null, }); expect(Object.keys(plan)).not.toContain('phase'); expect(Object.keys(plan)).not.toContain('rueckstand'); }); it('holt ausgelassene Tage von selbst auf', () => { /* Die Zusage „Rückstand erhöht das Tagespensum statt still zu verschieben“ ist durch die Bauart bereits erfüllt: Die Rate ergibt sich täglich neu aus den ungesehenen Fragen und den Resttagen. Wer Tage auslässt, bekommt danach mehr – ohne dass irgendwo „Rückstand“ stünde. Gemessen wird mit zehn ausgelassenen Tagen und nicht mit dreien: Bei 40 Tagen Rest ändern drei Tage die Rate gar nicht (9 bleibt 9). Das ist keine Schwäche, sondern der eigentliche Grund gegen einen Rückstandsbegriff – der Plan schluckt kleine Lücken, und eine Anzeige, die sie benennte, machte aus Nichts eine Mahnung. */ const ungesehen = 300; const vorher = lernplanBerechnen({ termin: '2026-06-15', tageBisTermin: 20, fragen: fragen(ungesehen), sekundenProFrage: null, }); const nachZehnTagenNichtstun = lernplanBerechnen({ termin: '2026-06-15', tageBisTermin: 10, fragen: fragen(ungesehen), sekundenProFrage: null, }); expect(nachZehnTagenNichtstun.pensum.neu).toBeGreaterThan(vorher.pensum.neu); }); it('lässt drei ausgelassene Tage bei viel Vorlauf gar nicht auffallen', () => { /* Genau deshalb gibt es keinen Rückstandsbegriff: Es gibt nichts zu melden. */ const bei40 = lernplanBerechnen({ termin: '2026-06-15', tageBisTermin: 40, fragen: fragen(300), sekundenProFrage: null, }); const bei37 = lernplanBerechnen({ termin: '2026-06-15', tageBisTermin: 37, fragen: fragen(300), sekundenProFrage: null, }); expect(bei37.pensum.neu).toBe(bei40.pensum.neu); }); }); describe('Lastausgleich der Wiedervorlage', () => { /* Die Streuung verteilt Fragen über ein Fenster, aber blind: Sie weiß nicht, dass am Donnerstag schon neunzig Wiederholungen stehen und am Freitag vier. Der Ausgleich wählt aus demselben Fenster den leersten Tag. */ it('liefert ohne Lastauskunft genau den gestreuten Wert', () => { for (const tage of [1, 2, 3, 7, 20, 60, 180]) { for (const id of ['I.1-01', 'II-14', 'IV-88']) { expect(ausgeglichenesIntervall(tage, id, null)).toBe(gestreutesIntervall(tage, id)); } } }); it('liefert bei gleichmäßiger Last ebenfalls den gestreuten Wert', () => { /* Die Eigenschaft, die den Ausgleich zu einer Verfeinerung macht statt zu einer Ablösung: Wo nichts zu glätten ist, ändert er nichts. Sonst verschöbe er Termine ohne Grund und machte die Streuung unprüfbar. */ const gleich = new Map(); for (let tag = 1; tag <= MAX_INTERVALL_TAGE; tag += 1) { gleich.set(tag, 5); } for (const tage of [3, 10, 40, 120]) { expect(ausgeglichenesIntervall(tage, 'II-07', gleich)).toBe( gestreutesIntervall(tage, 'II-07'), ); } }); it('weicht auf den leersten Tag des Fensters aus', () => { /* Fenster um 20 Tage: 15 Prozent von 20 sind 3, also 17 bis 23. */ const last = new Map(); for (let tag = 17; tag <= 23; tag += 1) { last.set(tag, 90); } last.set(22, 0); expect(ausgeglichenesIntervall(20, 'I.1-01', last)).toBe(22); }); it('verlässt das zulässige Fenster nicht, wie leer es draußen auch ist', () => { /* Der Ausgleich darf die Genauigkeit des Modells nicht kaufen. Das Fenster ist dasselbe wie das der Streuung – höchstens 15 Prozent, gedeckelt auf sieben Tage. */ const last = new Map(); for (let tag = 1; tag <= MAX_INTERVALL_TAGE; tag += 1) { last.set(tag, tag >= 17 && tag <= 23 ? 90 : 0); } const gewaehlt = ausgeglichenesIntervall(20, 'I.1-01', last); expect(gewaehlt).toBeGreaterThanOrEqual(17); expect(gewaehlt).toBeLessThanOrEqual(23); }); it('lässt kurze Intervalle unangetastet', () => { /* Unter drei Tagen ist ein Tag Versatz keine Verschiebung mehr, sondern eine andere Antwort – dieselbe Grenze wie bei der Streuung. */ const leer = new Map([[1, 0]]); expect(ausgeglichenesIntervall(1, 'I.1-01', leer)).toBe(1); expect(ausgeglichenesIntervall(2, 'I.1-01', leer)).toBe(2); }); it('bleibt innerhalb der Höchstgrenze', () => { const leer = new Map(); expect(ausgeglichenesIntervall(MAX_INTERVALL_TAGE, 'I.1-01', leer)).toBeLessThanOrEqual( MAX_INTERVALL_TAGE, ); expect(ausgeglichenesIntervall(MAX_INTERVALL_TAGE, 'I.1-01', leer)).toBeGreaterThanOrEqual(1); }); });