// @vitest-environment node /** * Der nachgerechnete Reifegrad-Verlauf. * * ## Die eine Zusicherung, um die es hier geht * * Der Verlauf entsteht aus dem Antwortprotokoll, die Ampel aus * `frage_stand`. Das sind zwei Wege zu derselben Zahl – und zwei Wege zu * derselben Zahl sind in diesem Projekt der Anfang eines Befundes, nicht ein * Komfortmerkmal. Ein Verlauf, dessen letzter Punkt neben der Ampel eine * andere Zahl zeigte, wäre schlimmer als gar keiner. * * Deshalb steht die Gleichheit hier an erster Stelle, und zwar nicht an einem * ausgedachten Protokoll, sondern an einem, das dieselbe `antworten()`-Methode * geschrieben hat, die auch im Betrieb schreibt. */ import Database from 'better-sqlite3'; import { afterEach, beforeEach, describe, expect, it } from 'vitest'; import { Lernstand } from '../src/main/lernstand'; import type { Antwortoption, Frage, Katalog } from '../src/shared/katalog'; import type { Bewertung } from '../src/shared/lernstand'; import { reifeverlauf } from '../src/shared/reifeverlauf'; const TAG_MS = 86_400_000; function option(label: string, korrekt: boolean): Antwortoption { return { label, inhalt: { text: `Antwort ${label}`, segmente: [{ t: `Antwort ${label}` }] }, korrekt, bilder: [], }; } function mcFrage(id: string, kapitel: string, abschnitt: string | null): Frage { return { id, amtliche_nummer: id, kapitel, abschnitt, typ: 'mc', seite: 1, frage: { text: `Frage ${id}`, segmente: [{ t: `Frage ${id}` }] }, bilder: [], optionen: [option('a', true), option('b', false)], }; } const FRAGEN: readonly Frage[] = [ mcFrage('I.1-01', 'I', 'I.1'), mcFrage('I.1-02', 'I', 'I.1'), mcFrage('I.1-03', 'I', 'I.1'), mcFrage('II-01', 'II', null), mcFrage('II-02', 'II', null), ]; const KATALOG: Katalog = { meta: { titel: 'Prüfkatalog', herausgeber: 'Bundesverwaltungsamt', stand: '2024-12-16', quellenangabe: 'Amtlicher Fragenkatalog, Stand 16.12.2024.', quelle_url: 'https://www.bva.bund.de/', quelldatei_sha256: 'a'.repeat(64), fragen_gesamt: FRAGEN.length, }, kapitel: [ { id: 'I', titel: 'Waffenrecht', abschnitte: [{ id: 'I.1', titel: 'Begriffe' }] }, { id: 'II', titel: 'Waffentechnik', abschnitte: [] }, ], bilder: [], fragen: FRAGEN, }; const START = '2026-03-01T10:00:00.000Z'; let uhr: Date; let db: Database.Database; let lernstand: Lernstand; let profilId: number; beforeEach(() => { uhr = new Date(START); db = new Database(':memory:'); lernstand = new Lernstand(db, KATALOG, { jetzt: () => uhr }); profilId = lernstand.profile()[0]!.id; }); afterEach(() => { db.close(); }); function antworten(frageId: string, bewertung: Bewertung, richtig = true): void { lernstand.antworten(profilId, { frageId, auswahl: richtig ? ['a'] : ['b'], richtig, bewertung, dauerMs: 1500, }); } function tageWeiter(tage: number): void { uhr = new Date(uhr.getTime() + tage * TAG_MS); } describe('Reifegrad-Verlauf', () => { it('endet auf genau der Zahl, die die Ampel zeigt', () => { /* Die Zusicherung, an der die ganze Anzeige hängt. Das Protokoll entsteht hier durch `antworten()` – also durch denselben Weg wie im Betrieb, mit denselben Bewertungen, Abständen und Belegregeln. */ antworten('I.1-01', 'gut'); antworten('I.1-02', 'schwer'); antworten('II-01', 'nochmal', false); tageWeiter(2); antworten('I.1-01', 'gut'); antworten('II-01', 'gut'); tageWeiter(3); antworten('I.1-02', 'leicht'); antworten('I.1-03', 'gut'); const verlauf = lernstand.reifeverlauf(profilId, 14); const letzter = verlauf[verlauf.length - 1]; const uebersicht = lernstand.uebersicht(profilId); /* Zuerst: Es wird überhaupt etwas verglichen. Zwei Nullen wären gleich und sagten nichts – genau so ist dieser Test beim ersten Schreiben durchgelaufen, weil die Attrappe der Fragen eine falsche Form hatte und keine Antwort als richtig zählte. */ expect(uebersicht.reifegrad).toBeGreaterThan(0); expect(uebersicht.belegt).toBeGreaterThan(0); expect(letzter?.reifegrad).toBeCloseTo(uebersicht.reifegrad, 12); expect(letzter?.belegt).toBe(uebersicht.belegt); expect(letzter?.beantwortet).toBe(uebersicht.beantwortet); }); it('gibt für jeden angefragten Tag genau einen Punkt zurück', () => { const verlauf = lernstand.reifeverlauf(profilId, 7); expect(verlauf).toHaveLength(7); expect(new Set(verlauf.map((punkt) => punkt.tag)).size).toBe(7); }); it('steht auf null, solange nichts beantwortet wurde', () => { const verlauf = lernstand.reifeverlauf(profilId, 5); expect(verlauf.every((punkt) => punkt.reifegrad === 0)).toBe(true); expect(verlauf.every((punkt) => punkt.beantwortet === 0)).toBe(true); }); it('zeigt den Anstieg dort, wo er stattgefunden hat – und nicht früher', () => { /* Der eigentliche Zweck: Wer sieht, dass die Linie steigt, soll ihr glauben können. Ein Beleg entsteht frühestens nach einem Tag Abstand (`shared/reife.ts`); vorher muss der Verlauf null zeigen, auch wenn schon geantwortet wurde. */ antworten('I.1-01', 'gut'); antworten('I.1-02', 'gut'); tageWeiter(2); antworten('I.1-01', 'gut'); antworten('I.1-02', 'gut'); const verlauf = lernstand.reifeverlauf(profilId, 5); const [vorvorher, vorher, tag1, tag2, heute] = verlauf; expect(vorvorher?.reifegrad).toBe(0); expect(vorher?.reifegrad).toBe(0); /* Tag der ersten Antworten: beantwortet, aber nichts belegt. */ expect(tag1?.beantwortet).toBe(2); expect(tag1?.reifegrad).toBe(0); expect(tag2?.reifegrad).toBe(0); /* Erst die Wiederholung mit Abstand belegt. */ expect(heute?.reifegrad).toBeGreaterThan(0); }); it('fällt wieder, wenn eine belegte Frage falsch beantwortet wird', () => { /* „Die Zahl kann durch eine falsche Antwort nie steigen“ – dieselbe Eigenschaft, die `shared/reife.ts` für die Ampel zusichert. */ antworten('I.1-01', 'gut'); tageWeiter(2); antworten('I.1-01', 'gut'); const vorher = lernstand.reifeverlauf(profilId, 3); const hoehe = vorher[vorher.length - 1]?.reifegrad ?? 0; expect(hoehe).toBeGreaterThan(0); antworten('I.1-01', 'nochmal', false); const nachher = lernstand.reifeverlauf(profilId, 3); expect(nachher[nachher.length - 1]?.reifegrad).toBeLessThan(hoehe); }); it('übergeht Antworten auf Fragen außerhalb des Lernumfangs', () => { /* Sonst zählte ein abgewähltes Kapitel im Verlauf mit und in der Ampel nicht – zwei Zahlen, die einander widersprechen. */ antworten('II-01', 'gut'); tageWeiter(2); antworten('II-01', 'gut'); lernstand.profilAktualisieren({ id: profilId, kapitelAusschluss: ['II'] }); const verlauf = lernstand.reifeverlauf(profilId, 5); expect(verlauf[verlauf.length - 1]?.reifegrad).toBe(0); expect(verlauf[verlauf.length - 1]?.beantwortet).toBe(0); expect(lernstand.uebersicht(profilId).reifegrad).toBe(0); }); it('weist eine Tageszahl außerhalb des Bereichs ab', () => { /* Der Wert kommt aus dem Renderer. Die Rechnung ist Stichtage mal Fragen; ohne Grenze wäre sie der Weg, den Kern lahmzulegen. */ expect(() => lernstand.reifeverlauf(profilId, 0)).toThrow(); expect(() => lernstand.reifeverlauf(profilId, 10_000)).toThrow(); expect(() => lernstand.reifeverlauf(profilId, 'viele')).toThrow(); }); it('kommt ohne Lernumfang und ohne Tage zurecht', () => { expect(reifeverlauf([], [], new Date(START), 5)).toEqual([]); expect(reifeverlauf([], ['I.1-01'], new Date(START), 0)).toEqual([]); }); });