/** * Einordnung eines Laufs gegenüber den früheren. * * Der Schwerpunkt liegt auf dem, was die Gegenprüfung als Falle benannt hat: * `quote` zählt unbeantwortete Fragen wie falsch beantwortete, und ein Lauf, * in dem die Zeit ablief, darf deshalb nicht als Wissenseinbruch dastehen. */ import { describe, expect, it } from 'vitest'; import type { BereichErgebnis, Pruefungsverlauf } from '../src/shared/pruefung'; import { laufVergleichen, wiederkehrendeSchwaechen, type Laufkennzahlen, } from '../src/shared/verlaufsvergleich'; function lauf(werte: Partial = {}): Pruefungsverlauf { return { id: 1, profilId: 'standard', profilName: 'Standard', zeitpunkt: '2026-08-01T10:00:00.000Z', gesamt: 80, richtig: 60, quote: 0.75, urteil: 'nicht_bestanden', dauerMs: 60 * 60_000, zeitmodus: 'normal', unbeantwortet: 0, zeitAbgelaufen: false, bereiche: null, bestehensQuote: 0.8, ...werte, }; } function aktuell(werte: Partial = {}): Laufkennzahlen { return { profilId: 'standard', zeitmodus: 'normal', gesamt: 80, quote: 0.8, dauerMs: 50 * 60_000, unbeantwortet: 0, zeitAbgelaufen: false, laufId: 99, ...werte, }; } /** Datum unverändert durchreichen: Der Test prüft Sätze, nicht `Intl`. */ const roh = (iso: string): string => iso; describe('laufVergleichen', () => { it('nennt Prozentpunkte und Minuten', () => { const vergleich = laufVergleichen(aktuell(), [lauf()], roh); expect(vergleich?.punkte).toBe(5); expect(vergleich?.minuten).toBe(10); expect(vergleich?.satz).toContain('5 Prozentpunkte besser'); expect(vergleich?.satz).toContain('10 Minuten schneller'); }); it('nennt eine Verschlechterung genauso deutlich', () => { const vergleich = laufVergleichen(aktuell({ quote: 0.6, dauerMs: 75 * 60_000 }), [lauf()], roh); expect(vergleich?.satz).toContain('15 Prozentpunkte schlechter'); expect(vergleich?.satz).toContain('15 Minuten langsamer'); }); it('sagt bei einem einzelnen Punkt „Prozentpunkt“', () => { const vergleich = laufVergleichen(aktuell({ quote: 0.76 }), [lauf()], roh); expect(vergleich?.satz).toContain('1 Prozentpunkt besser'); expect(vergleich?.satz).not.toContain('1 Prozentpunkte'); }); it('behauptet keinen Unterschied, wo keiner ist', () => { const vergleich = laufVergleichen( aktuell({ quote: 0.75, dauerMs: 60 * 60_000 + 10_000 }), [lauf()], roh, ); expect(vergleich?.satz).toContain('gleichauf in der Trefferquote'); expect(vergleich?.satz).toContain('bei praktisch gleicher Bearbeitungsdauer'); expect(vergleich?.minuten).toBeNull(); }); it('rundet oberhalb der Schwelle nie auf null Minuten', () => { // 45 Sekunden liegen über der Schwelle, wären aber auf 1 Minute gerundet // knapp – „0 Minuten schneller“ wäre ein Satz, der sich selbst widerspricht. const vergleich = laufVergleichen(aktuell({ dauerMs: 60 * 60_000 - 45_000 }), [lauf()], roh); expect(vergleich?.minuten).toBe(1); expect(vergleich?.satz).toContain('1 Minute schneller'); }); it('vergleicht nicht mit sich selbst', () => { // Der eben gespeicherte Lauf steht bereits im Verlauf. Ohne den Ausschluss // über die Kennung meldete die Anwendung jedem Prüfling ein „gleichauf“. expect(laufVergleichen(aktuell({ laufId: 7 }), [lauf({ id: 7 })], roh)).toBeNull(); }); it('vergleicht nur mit demselben Profil und derselben Zeitstufe', () => { const fremd = [ lauf({ id: 2, profilId: 'dsb' }), lauf({ id: 3, zeitmodus: 'plus50' }), // Ohne vermerkte Zeitstufe ist nicht bekannt, was zutrifft. lauf({ id: 4, zeitmodus: null }), ]; expect(laufVergleichen(aktuell(), fremd, roh)).toBeNull(); }); it('nimmt den jüngsten passenden Lauf, nicht den ersten überhaupt', () => { const verlauf = [ lauf({ id: 5, profilId: 'dsb' }), lauf({ id: 4, zeitpunkt: '2026-07-20T10:00:00.000Z' }), lauf({ id: 3, zeitpunkt: '2026-07-01T10:00:00.000Z' }), ]; expect(laufVergleichen(aktuell(), verlauf, roh)?.frueher.id).toBe(4); }); it('sagt dazu, wenn diesmal die Zeit ablief', () => { const vergleich = laufVergleichen( aktuell({ quote: 0.4, unbeantwortet: 23, zeitAbgelaufen: true }), [lauf()], roh, ); expect(vergleich?.vorbehalte).toContain( 'Diesmal blieben 23 Fragen unbeantwortet, weil die Zeit ablief.', ); expect(vergleich?.vorbehalte.join(' ')).toContain( 'zählen in der Trefferquote wie falsch beantwortete', ); }); it('sagt dazu, wenn damals die Zeit ablief', () => { const vergleich = laufVergleichen( aktuell(), [lauf({ unbeantwortet: 12, zeitAbgelaufen: true })], roh, ); expect(vergleich?.vorbehalte).toContain( 'Damals blieben 12 Fragen unbeantwortet, weil die Zeit ablief.', ); }); it('unterscheidet „nicht festgehalten“ von „nichts offen geblieben“', () => { const vergleich = laufVergleichen( aktuell(), [lauf({ unbeantwortet: null, zeitAbgelaufen: null })], roh, ); expect(vergleich?.vorbehalte.join(' ')).toContain('nicht festgehalten'); // Der allgemeine Satz gehört nur dahin, wo tatsächlich etwas offen blieb. expect(vergleich?.vorbehalte.join(' ')).not.toContain('wie falsch beantwortete'); }); it('schweigt über Unbeantwortete, wenn es keine gab', () => { /* Bis 0.26.4 stand hier `toEqual([])`. Das war zu weit gefasst: Die Standardwerte dieser Datei – 80 Fragen, 75 gegen 80 Prozent – sind selbst ein Rauschfall, und seit dem Vorbehalt zur Ziehung steht dort zu Recht ein Satz. Geprüft wird jetzt, was der Name verspricht. */ const vergleich = laufVergleichen(aktuell(), [lauf()], roh); expect(vergleich?.vorbehalte.join(' ')).not.toContain('unbeantwortet'); expect(vergleich?.vorbehalte.join(' ')).not.toContain('wie falsch beantwortete'); }); /* Der Vorbehalt, der gefehlt hat. Ein Bogen zieht 75 bis 100 Fragen aus 575. Zwei Läufe messen deshalb zwei Stichproben und nicht denselben Bestand; auch ohne jede Änderung am Wissensstand fallen sie auseinander. Ohne diesen Satz meldet die Anwendung „5 Prozentpunkte besser“ – vier Fragen – als Fortschritt, und der Prüfling richtet sein Lernen danach aus. */ it('nennt den Vorbehalt, wenn der Unterschied unter der Streuung der Ziehung liegt', () => { const vergleich = laufVergleichen(aktuell(), [lauf()], roh); expect(vergleich?.punkte).toBe(5); const text = vergleich?.vorbehalte.join(' ') ?? ''; expect(text).toContain('5 Prozentpunkten entspricht 4 Fragen'); expect(text).toContain('durch die Ziehung'); expect(text).toContain('keine Aussage'); }); it('schweigt, sobald der Unterschied über der Streuung liegt', () => { const vergleich = laufVergleichen(aktuell({ quote: 0.95 }), [lauf({ quote: 0.6 })], roh); expect(vergleich?.punkte).toBe(35); expect(vergleich?.vorbehalte.join(' ')).not.toContain('durch die Ziehung'); }); it('schweigt bei Gleichstand – dort behauptet niemand einen Unterschied', () => { const vergleich = laufVergleichen(aktuell({ quote: 0.75 }), [lauf({ quote: 0.75 })], roh); expect(vergleich?.punkte).toBe(0); expect(vergleich?.vorbehalte.join(' ')).not.toContain('durch die Ziehung'); }); it('rechnet die Streuung aus beiden Bogengrößen, nicht aus einer', () => { /* Ein kleiner Bogen streut stärker. Bei 20 gegen 20 Fragen sind selbst 10 Prozentpunkte noch Rauschen, bei 200 gegen 200 nicht mehr. */ const klein = laufVergleichen( aktuell({ gesamt: 20, quote: 0.85 }), [lauf({ gesamt: 20, quote: 0.75 })], roh, ); const gross = laufVergleichen( aktuell({ gesamt: 200, quote: 0.85 }), [lauf({ gesamt: 200, quote: 0.75 })], roh, ); expect(klein?.punkte).toBe(10); expect(gross?.punkte).toBe(10); expect(klein?.vorbehalte.join(' ')).toContain('durch die Ziehung'); expect(gross?.vorbehalte.join(' ')).not.toContain('durch die Ziehung'); }); it('nennt eine abweichende Bogengröße', () => { const vergleich = laufVergleichen(aktuell({ gesamt: 40 }), [lauf({ gesamt: 80 })], roh); expect(vergleich?.vorbehalte).toContain('Der Bogen hatte damals 80 Fragen, dieser 40 Fragen.'); }); it('trägt das Datum des Vergleichslaufs in den Satz', () => { const vergleich = laufVergleichen( aktuell(), [lauf({ zeitpunkt: '2026-07-04T08:00:00.000Z' })], roh, ); expect(vergleich?.satz).toContain('(2026-07-04T08:00:00.000Z)'); }); it('vergleicht auch, wenn der Lauf gar nicht gespeichert wurde', () => { // Ersatzauswertung ohne Kanal: Es gibt keine Kennung, weil keine Zeile // geschrieben wurde – dann ist auch nichts auszuschließen. const { laufId: _weg, ...ohneKennung } = aktuell(); expect(laufVergleichen(ohneKennung, [lauf()], roh)?.frueher.id).toBe(1); }); }); function bereich(id: string, gesamt: number, richtig: number): BereichErgebnis { return { bereich: id, titel: `Titel ${id}`, gesamt, richtig }; } describe('wiederkehrendeSchwaechen', () => { it('meldet einen Bereich, der zweimal unter der Grenze lag', () => { const verlauf = [ lauf({ id: 2, bereiche: [bereich('I.4', 10, 4), bereich('I.1', 10, 9)] }), lauf({ id: 1, bereiche: [bereich('I.4', 10, 5), bereich('I.1', 10, 10)] }), ]; const befund = wiederkehrendeSchwaechen(verlauf); expect(befund.grundlage).toBe(2); expect(befund.bereiche).toHaveLength(1); expect(befund.bereiche[0]).toMatchObject({ bereich: 'I.4', titel: 'Titel I.4', laeufe: 2, unterGrenze: 2, gesamt: 20, richtig: 9, }); }); it('macht aus einem schlechten Tag keine Lücke', () => { const verlauf = [ lauf({ id: 2, bereiche: [bereich('I.4', 10, 3)] }), lauf({ id: 1, bereiche: [bereich('I.4', 10, 10)] }), ]; expect(wiederkehrendeSchwaechen(verlauf).bereiche).toEqual([]); }); it('zählt Bereiche mit zu wenigen Fragen gar nicht erst', () => { // Bei zwei Fragen entscheidet ein Fehler über 50 Prozentpunkte. const verlauf = [ lauf({ id: 2, bereiche: [bereich('III', 2, 0)] }), lauf({ id: 1, bereiche: [bereich('III', 2, 0)] }), ]; expect(wiederkehrendeSchwaechen(verlauf).bereiche).toEqual([]); }); it('misst an der Grenze des jeweiligen Laufs', () => { // Sieben von zehn: unter einer Grenze von 80, über einer von 60 Prozent. const verlauf = [ lauf({ id: 2, bestehensQuote: 0.8, bereiche: [bereich('I.4', 10, 7)] }), lauf({ id: 1, bestehensQuote: 0.6, bereiche: [bereich('I.4', 10, 7)] }), ]; const befund = wiederkehrendeSchwaechen(verlauf); expect(befund.bereiche).toEqual([]); // Gezählt wurde er trotzdem in beiden Läufen – nur eben einmal als Treffer. const verlauf2 = [ lauf({ id: 2, bestehensQuote: 0.8, bereiche: [bereich('I.4', 10, 7)] }), lauf({ id: 1, bestehensQuote: 0.8, bereiche: [bereich('I.4', 10, 7)] }), ]; expect(wiederkehrendeSchwaechen(verlauf2).bereiche[0]).toMatchObject({ laeufe: 2, unterGrenze: 2, }); }); it('lässt Läufe ohne gespeicherte Bereiche außen vor', () => { const verlauf = [ lauf({ id: 3, bereiche: null }), lauf({ id: 2, bereiche: [bereich('I.4', 10, 2)] }), lauf({ id: 1, bereiche: [bereich('I.4', 10, 2)] }), ]; // Grundlage sind zwei Läufe, nicht drei: Der alte trägt nichts bei, und // ihn mitzuzählen behauptete eine breitere Grundlage, als es gibt. expect(wiederkehrendeSchwaechen(verlauf).grundlage).toBe(2); }); it('lässt Läufe ohne gespeicherte Grenze außen vor', () => { const verlauf = [ lauf({ id: 2, bestehensQuote: null, bereiche: [bereich('I.4', 10, 1)] }), lauf({ id: 1, bestehensQuote: null, bereiche: [bereich('I.4', 10, 1)] }), ]; expect(wiederkehrendeSchwaechen(verlauf)).toEqual({ grundlage: 0, bereiche: [] }); }); it('betrachtet höchstens zehn Läufe', () => { const verlauf = Array.from({ length: 14 }, (_unbenutzt, i) => lauf({ id: 100 - i, bereiche: [bereich('I.4', 10, 4)] }), ); const befund = wiederkehrendeSchwaechen(verlauf); expect(befund.grundlage).toBe(10); expect(befund.bereiche[0]).toMatchObject({ laeufe: 10, gesamt: 100 }); }); it('stellt den schwächsten Bereich nach vorn', () => { const verlauf = [ lauf({ id: 2, bereiche: [bereich('I.4', 10, 6), bereich('II', 10, 2)] }), lauf({ id: 1, bereiche: [bereich('I.4', 10, 6), bereich('II', 10, 2)] }), ]; expect(wiederkehrendeSchwaechen(verlauf).bereiche.map((b) => b.bereich)).toEqual(['II', 'I.4']); }); it('nennt höchstens fünf Bereiche', () => { const bereiche = ['I.1', 'I.2', 'I.3', 'I.4', 'I.5', 'II', 'III'].map((id) => bereich(id, 10, 1), ); const verlauf = [lauf({ id: 2, bereiche }), lauf({ id: 1, bereiche })]; expect(wiederkehrendeSchwaechen(verlauf).bereiche).toHaveLength(5); }); it('sagt bei leerem Verlauf nichts', () => { expect(wiederkehrendeSchwaechen([])).toEqual({ grundlage: 0, bereiche: [] }); }); });