/** * Der Rechenkern der Prüfungsreife. * * Diese Datei prüft nicht nur, dass die Formel rechnet, sondern dass sie die * **Fallen** vermeidet, an denen der erste Entwurf gescheitert ist. Die * Messwerte in den Kommentaren stammen aus der Nachrechnung gegen die * FSRS-Konstanten dieses Projekts; sie stehen ausgeschrieben, damit ein * späterer Umbau merkt, wenn er sie verschiebt. */ import { describe, expect, it } from 'vitest'; import { abrufwahrscheinlichkeit, ersterStand, naechsterStand } from '../src/shared/fsrs'; import { abrufFuer, belegteFragen, benoetigteFragen, gesamtstufeMitDeckel, reifegradVon, reifesatz, SCHWELLE_KIPPE, SCHWELLE_REIF, stufeFuer, type ReifeFrage, } from '../src/shared/reife'; function frage(teil: Partial = {}): ReifeFrage { return { bereich: 'I.1', stabilitaet: null, tageSeitAntwort: 0, bestaetigt: false, ...teil, }; } describe('Der Beitrag einer einzelnen Frage', () => { it('ist null, solange der Abruf nicht belegt ist', () => { /* Der Kern der ganzen Umstellung. Ohne diese Regel stünde eine Frage mit S = 2,3065 – der Wert nach genau einer richtigen Antwort – bei 0,9468 und damit über der Schwelle „prüfungsreif“. Eine einzige Antwort. */ const einmalRichtig = frage({ stabilitaet: 2.3065, tageSeitAntwort: 1, bestaetigt: false }); expect(abrufFuer(einmalRichtig)).toBe(0); expect(abrufFuer({ ...einmalRichtig, bestaetigt: true })).toBeCloseTo(0.9468, 4); }); it('ist null für eine nie beantwortete Frage', () => { expect(abrufFuer(frage())).toBe(0); expect(abrufFuer(frage({ bestaetigt: true }))).toBe(0); }); it('rechnet nie mit weniger als einem Tag Abstand', () => { /* `abrufwahrscheinlichkeit(S, 0)` ist für JEDES S exakt 1 – und `tageZwischen` im Anwendungskern liefert Bruchteile von Tagen. Ohne die Untergrenze stünde jede gerade beantwortete Frage bei 100 Prozent, und der Reifegrad spränge während einer Sitzung nach oben. */ expect(abrufwahrscheinlichkeit(0.2, 0)).toBe(1); expect(abrufwahrscheinlichkeit(1000, 0)).toBe(1); const eben = frage({ stabilitaet: 2.3065, tageSeitAntwort: 0.01, bestaetigt: true }); expect(abrufFuer(eben)).toBeCloseTo(abrufwahrscheinlichkeit(2.3065, 1), 10); expect(abrufFuer(eben)).toBeLessThan(0.95); }); it('sinkt mit der Zeit', () => { const stand = { stabilitaet: 10, bestaetigt: true }; const gestern = abrufFuer(frage({ ...stand, tageSeitAntwort: 1 })); const vorEinemMonat = abrufFuer(frage({ ...stand, tageSeitAntwort: 30 })); expect(vorEinemMonat).toBeLessThan(gestern); }); }); describe('Die Zahl steigt nie durch eine falsche Antwort', () => { /* Der blockierende Befund der Gegenprobe, in einen Test gegossen. Mit der reinen FSRS-Abrufwahrscheinlichkeit stieg der Beitrag jeder überfälligen Frage beim „Nicht gewusst“ – gemessen in allen fünf Fällen unten, etwa von 0,707 auf 0,884. Der Grund: Der neue Stand wird wieder am ersten Tag bewertet, der alte ging mit seinem echten, viel größeren Alter ein. Eine Lernanwendung, die das Falschantworten belohnt, ist kaputt. */ const FAELLE = [ { stabilitaet: 0.212, tageSeitAntwort: 30 }, { stabilitaet: 1.2931, tageSeitAntwort: 10 }, { stabilitaet: 2.3065, tageSeitAntwort: 20 }, { stabilitaet: 8.2956, tageSeitAntwort: 60 }, { stabilitaet: 60, tageSeitAntwort: 90 }, ] as const; it.each(FAELLE)( 'S=$stabilitaet nach $tageSeitAntwort Tagen: das rohe FSRS-Modell stiege, der Reifegrad nicht', ({ stabilitaet, tageSeitAntwort }) => { const vorher = frage({ stabilitaet, tageSeitAntwort, bestaetigt: true }); // Was FSRS aus einem „Nicht gewusst“ macht. const neu = naechsterStand({ stabilitaet, schwierigkeit: 5 }, 1, tageSeitAntwort); /* Zum Vergleich: So sähe es ohne die Belegregel aus – nachweislich höher als vorher. Der Test hält das ausdrücklich fest, damit niemand die Regel als überflüssige Vorsicht wieder herausnimmt. */ const ohneBelegregel = abrufwahrscheinlichkeit(neu.stabilitaet, 1); expect(ohneBelegregel).toBeGreaterThan(abrufwahrscheinlichkeit(stabilitaet, tageSeitAntwort)); // Mit der Belegregel: Die falsche Antwort nimmt den Beleg weg. const nachher = frage({ stabilitaet: neu.stabilitaet, tageSeitAntwort: 0, bestaetigt: false, }); expect(abrufFuer(nachher)).toBe(0); expect(abrufFuer(nachher)).toBeLessThanOrEqual(abrufFuer(vorher)); }, ); }); describe('Der Reifegrad über eine Fragenmenge', () => { it('lässt unbelegte Fragen im Nenner stehen', () => { /* Sie herauszunehmen ergäbe eine Zahl, die bei der ersten belegten Frage auf 100 Prozent springt – die häufigste Art, eine Fortschrittsanzeige zu belügen. */ const menge = [ frage({ stabilitaet: 10, tageSeitAntwort: 1, bestaetigt: true }), ...Array.from({ length: 9 }, () => frage()), ]; expect(reifegradVon(menge)).toBeCloseTo(abrufwahrscheinlichkeit(10, 1) / 10, 10); }); it('ist null für eine leere Menge, nicht NaN', () => { expect(reifegradVon([])).toBe(0); }); it('bleibt bei null, wenn der ganze Katalog genau einmal durchgearbeitet wurde', () => { /* Der zweite blockierende Befund. Mit der reinen Abrufwahrscheinlichkeit ergab genau dieser Zustand: 0 % richtig -> 0,766 -> 372 von 486 50 % richtig -> 0,857 -> 416 von 486, also „Prüfungsreif“ Wer jede Frage einmal falsch beantwortet, hätte 372 von 486 gelesen. */ const einDurchgang = [ ...Array.from({ length: 50 }, () => frage({ stabilitaet: ersterStand(3).stabilitaet, tageSeitAntwort: 1 }), ), ...Array.from({ length: 50 }, () => frage({ stabilitaet: ersterStand(1).stabilitaet, tageSeitAntwort: 1 }), ), ]; expect(reifegradVon(einDurchgang)).toBe(0); }); it('blickt für die Prognose in die Zukunft', () => { const menge = [frage({ stabilitaet: 10, tageSeitAntwort: 1, bestaetigt: true })]; expect(reifegradVon(menge, 30)).toBeCloseTo(abrufwahrscheinlichkeit(10, 31), 10); expect(reifegradVon(menge, 30)).toBeLessThan(reifegradVon(menge, 0)); }); }); describe('Die Einstufung', () => { it('entscheidet an derselben ganzen Zahl, die die Anzeige nennt', () => { /* Sonst gäbe es ein Band, in dem „Prüfungsreif“ neben einer Zahl steht, die eine Stufe tiefer als nicht ausreichend ausgewiesen wird: Bei 486 Fragen verlangt die Schwelle 0,85 genau 414 Fragen (aufgerundet von 413,1). Ein Reifegrad von 0,8505 ergibt gerundet 413 – also NICHT reif, obwohl der Bruchwert über der Schwelle liegt. */ expect(benoetigteFragen(SCHWELLE_REIF, 486)).toBe(414); expect(belegteFragen(0.8505, 486)).toBe(413); expect(stufeFuer(413, 486)).toBe('kippe'); expect(stufeFuer(414, 486)).toBe('reif'); }); it.each([23, 35, 43, 49, 89, 90, 92, 154, 486, 575])( 'nennt bei %i Fragen nie eine Stufe, die die genannte Zahl nicht trägt', (gesamt) => { /* Die Gegenprobe der Gegenprobe: Für JEDE Zahl belegter Fragen muss gelten, dass die Stufe genau dann erreicht ist, wenn die Zahl die Zielzahl erreicht – in beide Richtungen. */ for (let belegt = 0; belegt <= gesamt; belegt++) { const stufe = stufeFuer(belegt, gesamt); if (stufe === 'reif') { expect(belegt).toBeGreaterThanOrEqual(benoetigteFragen(SCHWELLE_REIF, gesamt)); } else if (belegt > 0) { expect(belegt).toBeLessThan(benoetigteFragen(SCHWELLE_REIF, gesamt)); } if (stufe === 'kippe') { expect(belegt).toBeGreaterThanOrEqual(benoetigteFragen(SCHWELLE_KIPPE, gesamt)); } } }, ); it('unterscheidet „noch nichts belegt“ von „noch nicht so weit“', () => { /* Wer noch keine Frage wiedergesehen hat, bekommt keine Einstufung vorgegaukelt, sondern die Auskunft, woran das liegt. */ expect(stufeFuer(0, 486)).toBe('ohne_beleg'); expect(stufeFuer(1, 486)).toBe('zurueck'); expect(stufeFuer(0, 0)).toBe('ohne_beleg'); }); it('deckelt das Gesamturteil an einem zurückliegenden K.-o.-Bereich', () => { /* Wer insgesamt bei 88 Prozent steht und in Notwehr und Notstand bei 60, fällt unter manchen Ordnungen sicher durch. Gedeckelt wird auf „Kippe“, nicht auf „zurück“: Der übrige Stand ist ja vorhanden. */ expect(gesamtstufeMitDeckel('reif', ['reif'])).toBe('reif'); expect(gesamtstufeMitDeckel('reif', ['kippe'])).toBe('kippe'); expect(gesamtstufeMitDeckel('reif', ['zurueck'])).toBe('kippe'); expect(gesamtstufeMitDeckel('reif', [])).toBe('reif'); // Wer ohnehin zurückliegt, wird durch den Deckel nicht angehoben. expect(gesamtstufeMitDeckel('zurueck', ['zurueck'])).toBe('zurueck'); }); }); describe('Der Satz zum Stand', () => { it('nennt die nachprüfbare Zahl vor der Auslegung', () => { const satz = reifesatz(414, 486, 'reif'); expect(satz.startsWith('414 von 486 Fragen sitzen belegt.')).toBe(true); }); it('verspricht bei „Prüfungsreif“ keine Bestehenswahrscheinlichkeit', () => { /* Eine Angabe wie „neun von zehn Läufen“ setzte voraus, dass alle Fragen dieselbe Trefferchance haben. Das Gegenteil ist der Fall – die Verteilung ist zweigipflig, und die Zahl wäre exakt aussehend und falsch. */ const satz = reifesatz(414, 486, 'reif'); expect(satz).not.toMatch(/Prozent|von zehn|wahrscheinlich/u); expect(satz).toContain('strengsten Bestehensgrenze'); }); it('nennt bei „Auf der Kippe“, wie viele Fragen noch fehlen', () => { const satz = reifesatz(389, 486, 'kippe'); expect(satz).toContain('389 von 486'); expect(satz).toContain('414'); }); it('stellt den Grund vor die Einordnung, wenn ein K.-o.-Bereich deckelt', () => { /* Wer hier „prüfungsreif“ läse und den Nachsatz überginge, ginge mit einer Lücke in die Prüfung, die ihn unabhängig vom Rest durchfallen lässt. */ const satz = reifesatz(430, 486, 'kippe', ['I.5 – Notwehr und Notstand']); expect(satz).toContain('I.5 – Notwehr und Notstand'); expect(satz).toContain('zwei Fehler'); expect(satz.indexOf('liegt zurück')).toBeLessThan(satz.indexOf('zwei Fehler')); }); it('erklärt beim leeren Stand die Regel, statt eine Stufe zu nennen', () => { const satz = reifesatz(0, 486, 'ohne_beleg'); expect(satz).toContain('mindestens einem Tag Abstand'); expect(satz).not.toContain('0 von 486'); }); }); describe('reifesatz – der Deckel bei schwachem Gesamtstand', () => { /* Gemeldet aus der laufenden Anwendung: „2 von 486 Fragen sitzen belegt. Insgesamt reicht das – aber I.5 – Notwehr und Notstand liegt zurück.“ Bei 2 von 486 reicht überhaupt nichts. Die Ursache war, dass der Satz den Deckel unbedingt vor die Einordnung zog, sobald ein K.-o.-Bereich zurücklag – ohne zu prüfen, ob der Gesamtstand für sich genommen genügt. Keine der übrigen Prüfungen hier hat das bemerkt, weil sie den Deckel nur beim starken Stand ansahen. */ it('behauptet bei schwachem Gesamtstand nicht, dass es insgesamt reicht', () => { const satz = reifesatz(2, 486, 'zurueck', ['I.5 – Notwehr und Notstand']); expect(satz).toContain('2 von 486 Fragen sitzen belegt.'); expect(satz).not.toContain('Insgesamt reicht das'); expect(satz).toContain('I.5 – Notwehr und Notstand'); expect(satz).toContain('nur zwei Fehler'); }); it('nennt bei schwachem Stand die Bestehensgrenze und den Bereich', () => { const satz = reifesatz(2, 486, 'zurueck', ['I.5 – Notwehr und Notstand']); expect(satz).toContain('Die strengste Bestehensgrenze dieses Programms verlangt'); expect(satz).toContain('Dazu liegt'); }); it('sagt „Insgesamt reicht das“ weiterhin, wenn der Gesamtstand genügt', () => { /* Der Fall, für den der Satz gedacht war: Der Stand allein trüge, und nur der K.-o.-Bereich hält ihn auf. */ const satz = reifesatz(460, 486, 'kippe', ['I.5 – Notwehr und Notstand']); expect(satz).toContain('Insgesamt reicht das – aber I.5 – Notwehr und Notstand liegt zurück.'); }); it('bleibt beim Stand auf der Kippe bei der Einordnung, statt sie zu überschreiben', () => { const satz = reifesatz(400, 486, 'kippe', ['I.5 – Notwehr und Notstand']); expect(satz).not.toContain('Insgesamt reicht das'); expect(satz).toContain('strengste Bestehensgrenze'); expect(satz).toContain('Dazu liegt I.5 – Notwehr und Notstand zurück.'); }); it('lässt den Zusatz weg, wenn kein Bereich zurückliegt', () => { expect(reifesatz(2, 486, 'zurueck')).not.toContain('Dazu liegt'); expect(reifesatz(460, 486, 'reif')).not.toContain('Insgesamt reicht das'); }); });