// @vitest-environment node // Die Prüfungssimulation läuft im Main-Prozess gegen echtes SQLite. Getestet // wird gegen eine `:memory:`-Datenbank – dieselbe Bibliothek, dasselbe Schema, // nur ohne Datei auf der Platte. Es wird nichts nachgebildet: gezogen wird aus // einem echten Katalog, ausgewertet mit den echten Regeln des Vertrags. import Database from 'better-sqlite3'; import { afterEach, beforeEach, describe, expect, it } from 'vitest'; import { Lernstand } from '../src/main/lernstand'; import { Pruefung } from '../src/main/pruefung'; import { SCHEMA_VERSION } from '../src/main/schema'; import { richtigeLabels, type Antwortoption, type Frage, type Katalog, } from '../src/shared/katalog'; import { profilFinden, urteilBilden, type Pruefungsantwort, type Pruefungsauftrag, type Pruefungsfrage, } from '../src/shared/pruefung'; // ─── Prüfkatalog ──────────────────────────────────────────────────────────── // Aufbau wie im amtlichen Katalog: Kapitel I mit fünf Abschnitten, die // Kapitel II bis IV ohne Abschnitte. Groß genug für die Themenquoten des // DSB-Profils (bis zu 20 Fragen je Bereich, davon ein Fünftel offen). /** Bereiche als Paar aus Kapitel und Abschnitt; `null` heißt „ohne Abschnitt“. */ const BEREICHE: readonly (readonly [string, string | null])[] = [ ['I', 'I.1'], ['I', 'I.2'], ['I', 'I.3'], ['I', 'I.4'], ['I', 'I.5'], ['II', null], ['III', null], ['IV', null], ]; const MC_JE_BEREICH = 30; const OFFEN_JE_BEREICH = 8; 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, nummer: number): Frage { // Jede dritte Frage hat zwei richtige Antworten – im amtlichen Katalog sind // es 114 von 471, die Mehrfachauswahl darf also kein Sonderfall sein. const korrekt = nummer % 3 === 0 ? ['a', 'c'] : ['b']; return { id, amtliche_nummer: id, kapitel, abschnitt, typ: 'mc', seite: 1, frage: { text: `Frage ${id}`, segmente: [{ t: `Frage ${id}` }] }, bilder: [], optionen: ['a', 'b', 'c', 'd'].map((label) => option(label, korrekt.includes(label))), }; } function offeneFrage(id: string, kapitel: string, abschnitt: string | null): Frage { return { id, amtliche_nummer: id, kapitel, abschnitt, typ: 'freitext', seite: 1, frage: { text: `Frage ${id}`, segmente: [{ t: `Frage ${id}` }] }, bilder: [], musterantwort: { text: 'Musterantwort', segmente: [{ t: 'Musterantwort', h: true }] }, }; } function katalogBauen(mcJeBereich: number, offenJeBereich: number): Katalog { const fragen: Frage[] = []; for (const [kapitel, abschnitt] of BEREICHE) { const bereich = abschnitt ?? kapitel; for (let i = 1; i <= mcJeBereich; i += 1) { fragen.push(mcFrage(`${bereich}-mc-${String(i).padStart(2, '0')}`, kapitel, abschnitt, i)); } for (let i = 1; i <= offenJeBereich; i += 1) { fragen.push( offeneFrage(`${bereich}-offen-${String(i).padStart(2, '0')}`, kapitel, abschnitt), ); } } return { 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 und sonstige Rechtsvorschriften', abschnitte: [ { id: 'I.1', titel: 'Begriffe des Waffenrechts' }, { id: 'I.2', titel: 'Rechte und Pflichten' }, { id: 'I.3', titel: 'Aufbewahrung' }, { id: 'I.4', titel: 'Führen und Transport' }, { id: 'I.5', titel: 'Notwehr und Notstand' }, ], }, { id: 'II', titel: 'Waffentechnik', abschnitte: [] }, { id: 'III', titel: 'Handhabung von Schusswaffen', abschnitte: [] }, { id: 'IV', titel: 'Not- und Seenotsignalmittel', abschnitte: [] }, ], bilder: [], fragen, }; } const KATALOG = katalogBauen(MC_JE_BEREICH, OFFEN_JE_BEREICH); /** Bereich einer Frage, so wie die Auswertung ihn bildet. */ function bereichVon(frage: Frage): string { return frage.abschnitt ?? frage.kapitel; } function frageZu(katalog: Katalog, id: string): Frage { const treffer = katalog.fragen.find((f) => f.id === id); if (treffer === undefined) { throw new Error(`Prüfkatalog kennt die Frage „${id}“ nicht.`); } return treffer; } // ─── Prüfstand ────────────────────────────────────────────────────────────── const START = '2026-03-01T10:00:00.000Z'; /** * Reproduzierbarer Zufall (mulberry32). Die Zusammenstellung soll zufällig * sein, ein Test aber trotzdem immer dasselbe Ergebnis liefern. */ function zufallsfolge(saat: number): () => number { let zustand = saat >>> 0; return (): number => { zustand = (zustand + 0x6d2b79f5) >>> 0; let t = zustand; t = Math.imul(t ^ (t >>> 15), t | 1); t ^= t + Math.imul(t ^ (t >>> 7), t | 61); return ((t ^ (t >>> 14)) >>> 0) / 4294967296; }; } let uhr: Date; let db: Database.Database; let lernstand: Lernstand; let pruefung: Pruefung; let profilId: number; let warnungen: string[]; let verbindungen: Database.Database[] = []; function starten(katalog: Katalog = KATALOG, saat = 7): void { uhr = new Date(START); db = new Database(':memory:'); verbindungen.push(db); warnungen = []; lernstand = new Lernstand(db, katalog, { jetzt: () => uhr }); profilId = lernstand.profile()[0]!.id; pruefung = new Pruefung(lernstand, katalog, { jetzt: () => uhr, zufall: zufallsfolge(saat), warnen: (meldung) => warnungen.push(meldung), }); } function auftrag(teil: Partial & { profilId: string }): Pruefungsauftrag { return { zeitmodus: 'aus', ...teil }; } /** * Zieht einen Bogen und gibt nur seinen Fragensatz zurück. * * `starten` liefert seit dem Warnungsfeld einen {@link Pruefungsbogen}; die * Warnungen prüfen die Fälle, die sie betreffen, ausdrücklich über * `pruefung.starten(...).warnungen`. */ function bogenZiehen(profil: number, auftragWert: Pruefungsauftrag): readonly Pruefungsfrage[] { return pruefung.starten(profil, auftragWert).fragen; } /** Antwortoption, die bei dieser Frage falsch ist. */ function falscheAuswahl(frage: Frage): string[] { const soll = new Set(richtigeLabels(frage)); const daneben = (frage.optionen ?? []).map((o) => o.label).find((label) => !soll.has(label)); return daneben === undefined ? [] : [daneben]; } function antwort(frage: Frage, richtig: boolean): Pruefungsantwort { if (frage.typ === 'mc') { return { frageId: frage.id, auswahl: richtig ? richtigeLabels(frage) : falscheAuswahl(frage) }; } return { frageId: frage.id, auswahl: [], freitext: 'Sinngemäß richtig formuliert.', selbstAlsRichtig: richtig, }; } /** Multiple-Choice-Fragen eines Bereichs in Katalogreihenfolge. */ function mcAus(bereich: string, anzahl: number, ab = 0): Frage[] { return KATALOG.fragen .filter((f) => f.typ === 'mc' && bereichVon(f) === bereich) .slice(ab, ab + anzahl); } /** Bogen mit genau dieser Zahl richtiger und falscher Antworten (alle aus I.1). */ function bogen(richtig: number, falsch: number): Pruefungsantwort[] { return mcAus('I.1', richtig + falsch).map((frage, i) => antwort(frage, i < richtig)); } function auswerten( profilKennung: string, antworten: Pruefungsantwort[], dauerMs = 60_000, ): ReturnType { return pruefung.auswerten( profilId, auftrag({ profilId: profilKennung }), antworten, dauerMs, false, ); } beforeEach(() => { verbindungen = []; starten(); }); afterEach(() => { for (const verbindung of verbindungen) { if (verbindung.open) { verbindung.close(); } } }); // ─── Bogen zusammenstellen ────────────────────────────────────────────────── describe('pruefung:starten', () => { it('zieht so viele Fragen, wie das Profil vorsieht', () => { expect(bogenZiehen(profilId, auftrag({ profilId: 'standard' }))).toHaveLength(80); expect(bogenZiehen(profilId, auftrag({ profilId: 'dsb' }))).toHaveLength(100); expect(bogenZiehen(profilId, auftrag({ profilId: 'fehlerpunkte' }))).toHaveLength(75); expect(bogenZiehen(profilId, auftrag({ profilId: 'frei' }))).toHaveLength(40); }); it('nimmt keine Frage zweimal in denselben Bogen', () => { for (const kennung of ['standard', 'dsb', 'bdmp', 'fehlerpunkte']) { const bogenFragen = bogenZiehen(profilId, auftrag({ profilId: kennung })); const ids = new Set(bogenFragen.map((f) => f.frageId)); expect(ids.size).toBe(bogenFragen.length); } }); it('mischt die Antwortoptionen auf Wunsch, ohne eine wegzulassen', () => { const bogenFragen = bogenZiehen( profilId, auftrag({ profilId: 'standard', optionenMischen: true }), ); let mindestensEinmalGemischt = false; for (const eintrag of bogenFragen) { const frage = frageZu(KATALOG, eintrag.frageId); const labels = (frage.optionen ?? []).map((o) => o.label); expect([...eintrag.optionsReihenfolge].sort()).toEqual([...labels].sort()); if (eintrag.optionsReihenfolge.join('') !== labels.join('')) { mindestensEinmalGemischt = true; } } expect(mindestensEinmalGemischt).toBe(true); }); it('lässt die Antwortoptionen auf Wunsch in Katalogreihenfolge', () => { /* Die Einstellung gilt auch in der Simulation: Wer die Antworten über ihre Stelle behalten muss, bräuchte sie sonst ausgerechnet hier nicht. */ const bogenFragen = bogenZiehen( profilId, auftrag({ profilId: 'standard', optionenMischen: false }), ); for (const eintrag of bogenFragen) { const frage = frageZu(KATALOG, eintrag.frageId); expect(eintrag.optionsReihenfolge).toEqual((frage.optionen ?? []).map((o) => o.label)); } }); it('zieht ohne ausdrückliches optionenMischen einen Bogen in Katalogreihenfolge', () => { /* Der Standard ist „nicht mischen“ – gerade die Simulation soll dem gedruckten Bogen nahekommen, und sie zeigt keine Ziffernspalte, die eine verdrehte Buchstabenfolge abmildern könnte. Sichert den Rückfallwert gegen ein späteres Zurückdrehen ab. */ const bogenFragen = bogenZiehen(profilId, auftrag({ profilId: 'standard' })); for (const eintrag of bogenFragen) { const frage = frageZu(KATALOG, eintrag.frageId); expect(eintrag.optionsReihenfolge).toEqual((frage.optionen ?? []).map((o) => o.label)); } }); it('weist ein optionenMischen zurück, das kein Wahrheitswert ist', () => { expect(() => pruefung.starten(profilId, auftrag({ profilId: 'standard', optionenMischen: 'ja' as never })), ).toThrow(/optionenMischen/u); }); it('stellt ohne anteilOffen ausschließlich Multiple Choice zusammen', () => { const bogenFragen = bogenZiehen(profilId, auftrag({ profilId: 'standard' })); const typen = new Set(bogenFragen.map((f) => frageZu(KATALOG, f.frageId).typ)); expect([...typen]).toEqual(['mc']); }); it('beachtet den Anteil offener Fragen', () => { const bogenFragen = bogenZiehen(profilId, auftrag({ profilId: 'dsb' })); const offene = bogenFragen.filter((f) => frageZu(KATALOG, f.frageId).typ !== 'mc'); // 20 Prozent von 100 Fragen, verteilt über die Themenquoten. expect(offene).toHaveLength(20); expect(warnungen).toEqual([]); }); /* „Anteil offener Fragen: 0“ heißt null – auch dann, wenn die Auswahlfragen nicht reichen. Der Katalog hat 471 Auswahlfragen und 104 offene. Bis Fassung 0.24.1 legte der Kern bei 500 gewünschten Fragen stillschweigend 29 offene nach, obwohl die Prüfungswahl unmittelbar davor zusagt: „Der Bogen besteht damit ausschließlich aus Auswahlfragen.“ Nach der Abgabe sprang dann unerwartet die Selbstbewertung dazwischen. */ it('legt bei null Prozent offenen Fragen keine offenen nach', () => { const bogenFragen = bogenZiehen( profilId, auftrag({ profilId: 'frei', fragenAnzahl: 500, anteilOffen: 0 }), ); const offene = bogenFragen.filter((f) => frageZu(KATALOG, f.frageId).typ !== 'mc'); expect(offene).toEqual([]); expect(warnungen.join(' ')).toMatch(/fehlt|fehlen/u); }); /* Das frei einstellbare Profil trägt selbst keinen `anteilOffen`. Ohne diese Kette bliebe der Regler in der Oberfläche wirkungslos: Der Kern bildet das wirksame Profil neu und übernahm bis Fassung 0.16.0 nur Fragenzahl, Zeit und Bestehensgrenze. */ it('übernimmt den Anteil offener Fragen aus dem Auftrag', () => { const bogenFragen = bogenZiehen( profilId, auftrag({ profilId: 'frei', fragenAnzahl: 20, anteilOffen: 0.25 }), ); const offene = bogenFragen.filter((f) => frageZu(KATALOG, f.frageId).typ !== 'mc'); expect(bogenFragen).toHaveLength(20); expect(offene).toHaveLength(5); expect(warnungen).toEqual([]); }); it('füllt mit Auswahlfragen auf, wenn der Wunsch den Vorrat übersteigt', () => { /* Der Wunsch ist eine Bitte, keine Zusage – ein Bogen ganz aus offenen Fragen ist im echten Katalog nicht zu haben. Zurechtgebogen wird der Wert trotzdem nicht; stattdessen wird es gesagt. */ const bogenFragen = bogenZiehen( profilId, // 8 Bereiche à 8 offene Fragen sind 64 – ein Bogen von 80 ganz aus // offenen Fragen ist damit nicht zu haben. auftrag({ profilId: 'frei', fragenAnzahl: 80, anteilOffen: 1 }), ); const offene = bogenFragen.filter((f) => frageZu(KATALOG, f.frageId).typ !== 'mc'); expect(bogenFragen).toHaveLength(80); expect(offene).toHaveLength(64); expect(warnungen.some((w) => w.includes('durch 16 Auswahlfragen ersetzt'))).toBe(true); }); /* Die Warnungen gehen nicht mehr nur ins Protokoll: Sie hängen am Bogen und landen damit auf dem Bildschirm (Befund 7.10). Bis Fassung 0.20.0 hielt jeder, der simulierte, seinen Bogen für profilgetreu. */ it('reicht dieselben Warnungen mit dem Bogen heraus', () => { const bogen = pruefung.starten( profilId, auftrag({ profilId: 'frei', fragenAnzahl: 80, anteilOffen: 1 }), ); expect(bogen.fragen).toHaveLength(80); expect(bogen.warnungen).toEqual(warnungen); expect(bogen.warnungen.some((w) => w.includes('durch 16 Auswahlfragen ersetzt'))).toBe(true); }); it('gibt einen unauffälligen Bogen ohne Warnungen heraus', () => { const bogen = pruefung.starten(profilId, auftrag({ profilId: 'standard' })); expect(bogen.fragen).toHaveLength(80); expect(bogen.warnungen).toEqual([]); }); /* Die Sätze stehen auf dem Bildschirm. Ein Feldname aus dem Vertrag wäre dort eine Zumutung, „Frage(n)“ ebenso. */ it('formuliert die Warnungen ohne Feldnamen und ohne Klammerformen', () => { const bogen = pruefung.starten( profilId, auftrag({ profilId: 'standard', anteilOffen: 0.5, fragenAnzahl: 12 }), ); expect(bogen.warnungen.length).toBeGreaterThan(0); const alles = bogen.warnungen.join(' '); expect(alles).toContain('der Anteil offener Fragen'); expect(alles).toContain('die Fragenzahl'); expect(alles).not.toContain('anteilOffen'); expect(alles).not.toContain('fragenAnzahl'); expect(alles).not.toContain('(n)'); }); /* Der Bogen wird je Themenbereich gezogen; ohne Entdoppelung stünde derselbe Satz achtmal untereinander auf dem Bildschirm. */ it('sammelt gleichlautende Warnungen nur einmal', () => { const knapp = katalogBauen(5, 0); starten(knapp, 3); const bogen = pruefung.starten(profilId, auftrag({ profilId: 'dsb', anteilOffen: 0.5 })); expect(bogen.warnungen.length).toBe(new Set(bogen.warnungen).size); }); it('weist einen Anteil außerhalb von 0 bis 1 ab', () => { expect(() => pruefung.starten(profilId, auftrag({ profilId: 'frei', anteilOffen: 1.5 })), ).toThrow(/anteilOffen/u); expect(() => pruefung.starten(profilId, auftrag({ profilId: 'frei', anteilOffen: -0.1 })), ).toThrow(/anteilOffen/u); expect(() => pruefung.starten(profilId, auftrag({ profilId: 'frei', anteilOffen: 'viel' as never })), ).toThrow(/anteilOffen/u); }); it('übernimmt den Anteil nicht bei festen Profilen und sagt es', () => { const bogenFragen = bogenZiehen(profilId, auftrag({ profilId: 'standard', anteilOffen: 0.5 })); const typen = new Set(bogenFragen.map((f) => frageZu(KATALOG, f.frageId).typ)); expect([...typen]).toEqual(['mc']); expect(warnungen.some((w) => w.includes('der Anteil offener Fragen'))).toBe(true); }); it('hält die Themenquoten des Profils ein', () => { const quoten = profilFinden('dsb')?.themenquoten ?? {}; const bogenFragen = bogenZiehen(profilId, auftrag({ profilId: 'dsb' })); const gezaehlt = new Map(); for (const eintrag of bogenFragen) { const bereich = bereichVon(frageZu(KATALOG, eintrag.frageId)); gezaehlt.set(bereich, (gezaehlt.get(bereich) ?? 0) + 1); } expect(Object.fromEntries(gezaehlt)).toEqual(quoten); }); it('füllt einen zu kleinen Themenbereich aus den übrigen auf und protokolliert das', () => { // Nur 6 Fragen je Bereich – die DSB-Quote von 20 für „I.2“ ist damit // nicht zu erfüllen. const knapp = katalogBauen(5, 1); starten(knapp, 3); const bogenFragen = bogenZiehen(profilId, auftrag({ profilId: 'dsb' })); const ids = new Set(bogenFragen.map((f) => f.frageId)); // 8 Bereiche à 6 Fragen = 48; mehr gibt der Katalog nicht her. expect(bogenFragen).toHaveLength(48); expect(ids.size).toBe(48); /* Mit Titel statt nur mit der Kennung: „I.2“ allein sagt niemandem etwas, der nicht den Aufbau des Katalogs im Kopf hat. */ expect(warnungen.some((w) => w.includes('Themenbereich „I.2 – Rechte und Pflichten“'))).toBe( true, ); expect(warnungen.some((w) => w.includes('stehen aber nur 48'))).toBe(true); }); it('lässt ausgeschlossene Kapitel weg', () => { const bogenFragen = bogenZiehen( profilId, auftrag({ profilId: 'standard', kapitelAusschluss: ['IV'] }), ); const kapitel = new Set(bogenFragen.map((f) => frageZu(KATALOG, f.frageId).kapitel)); expect(bogenFragen).toHaveLength(80); expect(kapitel.has('IV')).toBe(false); }); it('übernimmt Überschreibungen nur bei anpassbaren Profilen', () => { const frei = bogenZiehen(profilId, auftrag({ profilId: 'frei', fragenAnzahl: 12 })); expect(frei).toHaveLength(12); expect(warnungen).toEqual([]); const fest = bogenZiehen(profilId, auftrag({ profilId: 'standard', fragenAnzahl: 12 })); expect(fest).toHaveLength(80); expect(warnungen.some((w) => w.includes('hat feste Vorgaben'))).toBe(true); }); it('weist unbekannte Profile, Zeitmodi, Kapitel und Lernprofile ab', () => { expect(() => pruefung.starten(profilId, auftrag({ profilId: 'phantasie' }))).toThrow( /Unbekanntes Prüfungsprofil/u, ); expect(() => pruefung.starten(profilId, { profilId: 'standard', zeitmodus: 'schneller' as never }), ).toThrow(/zeitmodus/u); expect(() => pruefung.starten(profilId, auftrag({ profilId: 'standard', kapitelAusschluss: ['XI'] })), ).toThrow(/Unbekanntes Kapitel/u); expect(() => pruefung.starten(999, auftrag({ profilId: 'standard' }))).toThrow( /Unbekanntes Profil/u, ); }); it('weist einen Auftrag ab, der den ganzen Katalog abwählt', () => { expect(() => pruefung.starten( profilId, auftrag({ profilId: 'standard', kapitelAusschluss: ['I', 'II', 'III', 'IV'] }), ), ).toThrow(/keine einzige Frage/u); }); }); // ─── Auswertung ───────────────────────────────────────────────────────────── describe('Bogen und Auswertung gehören zusammen', () => { /* Ohne Abgleich nähme der Kern jede beliebige Antwortliste an: Ein Lauf mit einer einzigen richtigen Antwort landete als „bestanden“ im Verlauf, obwohl der Bogen 80 Fragen hatte. */ it('weist eine Antwortliste ab, die nicht zum ausgegebenen Bogen passt', () => { const bogenAusgabe = bogenZiehen(profilId, auftrag({ profilId: 'standard' })); expect(bogenAusgabe.length).toBeGreaterThan(1); const eine = KATALOG.fragen.find((f) => f.id === bogenAusgabe[0]?.frageId)!; expect(() => pruefung.auswerten( profilId, auftrag({ profilId: 'standard' }), [antwort(eine, true)], 60_000, false, ), ).toThrow(/umfasste .* Fragen/u); }); it('weist eine Frage ab, die nicht im Bogen stand', () => { const bogenAusgabe = bogenZiehen(profilId, auftrag({ profilId: 'frei', fragenAnzahl: 3 })); const imBogen = new Set(bogenAusgabe.map((f) => f.frageId)); const fremd = KATALOG.fragen.find((f) => !imBogen.has(f.id) && f.typ === 'mc')!; const antworten = bogenAusgabe.slice(0, 2).map((eintrag) => { const frage = KATALOG.fragen.find((f) => f.id === eintrag.frageId)!; return antwort(frage, true); }); antworten.push(antwort(fremd, true)); expect(() => pruefung.auswerten( profilId, auftrag({ profilId: 'frei', fragenAnzahl: 3 }), antworten, 1, false, ), ).toThrow(/nicht Teil des Bogens/u); }); it('nimmt den passenden Bogen an und wertet ihn genau einmal aus', () => { const bogenAusgabe = bogenZiehen(profilId, auftrag({ profilId: 'frei', fragenAnzahl: 3 })); const antworten = bogenAusgabe.map((eintrag) => { const frage = KATALOG.fragen.find((f) => f.id === eintrag.frageId)!; return antwort(frage, true); }); const ergebnis = pruefung.auswerten( profilId, auftrag({ profilId: 'frei', fragenAnzahl: 3 }), antworten, 1_000, false, ); expect(ergebnis.gesamt).toBe(3); /* Und wirklich genau einmal. Bis 0.27.2 stand hier `not.toThrow()` – der Titel sagte „genau einmal“, geprüft wurde das Gegenteil: dass ein zweiter Aufruf durchgeht. Er ging durch, schrieb einen zweiten Verlaufseintrag und buchte jede Antwort ein zweites Mal in die Wiedervorlage (gemessen: `versuche` 80 → 160). */ expect(() => pruefung.auswerten( profilId, auftrag({ profilId: 'frei', fragenAnzahl: 3 }), antworten, 1_000, false, ), ).toThrow(/bereits ausgewertet/u); expect((db.prepare('SELECT COUNT(*) AS n FROM pruefung_lauf').get() as { n: number }).n).toBe( 1, ); }); }); describe('pruefung:auswerten', () => { it('rechnet Multiple Choice selbst nach und glaubt der Selbsteinschätzung nicht', () => { const frage = mcAus('I.1', 1)[0]!; const ergebnis = auswerten('standard', [ { frageId: frage.id, auswahl: falscheAuswahl(frage), selbstAlsRichtig: true }, ]); expect(ergebnis.richtig).toBe(0); expect(ergebnis.falsch).toBe(1); expect(ergebnis.fehlerIds).toEqual([frage.id]); }); it('wertet eine unvollständige Mehrfachauswahl als falsch', () => { const mehrfach = KATALOG.fragen.find((f) => f.typ === 'mc' && richtigeLabels(f).length > 1); const soll = richtigeLabels(mehrfach!); const ergebnis = auswerten('standard', [ { frageId: mehrfach!.id, auswahl: [soll[0]!] }, { frageId: mcAus('I.2', 1)[0]!.id, auswahl: richtigeLabels(mcAus('I.2', 1)[0]!) }, ]); expect(soll.length).toBeGreaterThan(1); expect(ergebnis.richtig).toBe(1); expect(ergebnis.fehlerIds).toEqual([mehrfach!.id]); expect(ergebnis.unbeantwortet).toBe(0); }); it('zählt zu viel Angekreuztes als falsch', () => { const einfach = mcAus('I.1', 1)[0]!; const zuViel = [...richtigeLabels(einfach), ...falscheAuswahl(einfach)]; const ergebnis = auswerten('standard', [{ frageId: einfach.id, auswahl: zuViel }]); expect(ergebnis.richtig).toBe(0); }); it('weist unbeantwortete Fragen getrennt aus, ohne sie in „falsch“ mitzuzählen', () => { /* Die drei Zahlen bilden eine Zerlegung von `gesamt`. Steckten die unbeantworteten zusätzlich in `falsch`, zeigte die Auswertung mehr Fragen an, als der Bogen hat. Für die Wertung bleiben sie dennoch nicht richtig – und in `fehlerIds` gehören sie, damit man sie üben kann. */ const mc = mcAus('I.1', 3); const offen = KATALOG.fragen.find((f) => f.typ !== 'mc')!; const ergebnis = auswerten('standard', [ antwort(mc[0]!, true), antwort(mc[1]!, false), { frageId: mc[2]!.id, auswahl: [] }, { frageId: offen.id, auswahl: [] }, ]); expect(ergebnis.gesamt).toBe(4); expect(ergebnis.richtig).toBe(1); expect(ergebnis.falsch).toBe(1); expect(ergebnis.unbeantwortet).toBe(2); // Die Zerlegung geht auf. expect(ergebnis.richtig + ergebnis.falsch + ergebnis.unbeantwortet).toBe(ergebnis.gesamt); expect(ergebnis.fehlerIds).toEqual([mc[1]!.id, mc[2]!.id, offen.id]); }); it('wertet offene Fragen nach der Selbsteinschätzung', () => { const offene = KATALOG.fragen.filter((f) => f.typ !== 'mc').slice(0, 2); const ergebnis = auswerten('dsb', [antwort(offene[0]!, true), antwort(offene[1]!, false)]); expect(ergebnis.richtig).toBe(1); expect(ergebnis.unbeantwortet).toBe(0); }); it('füllt die Bereiche je Abschnitt bzw. Kapitel in Katalogreihenfolge', () => { const ergebnis = auswerten('standard', [ ...mcAus('I.2', 2).map((f) => antwort(f, true)), ...mcAus('I.1', 2).map((f) => antwort(f, false)), ...mcAus('III', 1).map((f) => antwort(f, true)), ]); expect(ergebnis.bereiche).toEqual([ { bereich: 'I.1', titel: 'Begriffe des Waffenrechts', gesamt: 2, richtig: 0 }, { bereich: 'I.2', titel: 'Rechte und Pflichten', gesamt: 2, richtig: 2 }, { bereich: 'III', titel: 'Handhabung von Schusswaffen', gesamt: 1, richtig: 1 }, ]); }); it('lässt das K.-o.-Kriterium unabhängig vom Gesamtergebnis greifen', () => { // Profil „fehlerpunkte“: höchstens 15 Fehler, aber höchstens 2 in I.5. const antworten = [ ...mcAus('I.1', 20).map((f) => antwort(f, true)), ...mcAus('I.5', 3).map((f) => antwort(f, false)), ]; const ergebnis = auswerten('fehlerpunkte', antworten); expect(ergebnis.falsch).toBe(3); expect(ergebnis.urteil).toBe('nicht_bestanden'); expect(ergebnis.verletzteKriterien).toEqual([ 'Notwehr und Notstand (3 Fehler, höchstens 2 zulässig)', ]); expect(ergebnis.begruendung).toContain('Notwehr und Notstand'); }); it('lässt zwei Fehler in Notwehr und Notstand durchgehen', () => { const ergebnis = auswerten('fehlerpunkte', [ ...mcAus('I.1', 20).map((f) => antwort(f, true)), ...mcAus('I.5', 2).map((f) => antwort(f, false)), ]); expect(ergebnis.verletzteKriterien).toEqual([]); expect(ergebnis.urteil).toBe('bestanden'); }); it.each([ ['standard', 16, 4, 'bestanden'], ['standard', 15, 5, 'nicht_bestanden'], ['bdmp', 14, 6, 'bestanden'], ['bdmp', 13, 7, 'nicht_bestanden'], ['dsb', 15, 5, 'bestanden'], ['dsb', 13, 7, 'nachpruefung'], ['dsb', 12, 8, 'nachpruefung'], ['dsb', 11, 9, 'nicht_bestanden'], ['fehlerpunkte', 15, 5, 'bestanden'], ['fehlerpunkte', 4, 16, 'nicht_bestanden'], ])('bildet für %s bei %i von 20 richtigen das Urteil %s', (kennung, richtig, falsch, urteil) => { const ergebnis = auswerten(kennung, bogen(richtig, falsch)); expect(ergebnis.gesamt).toBe(20); expect(ergebnis.richtig).toBe(richtig); expect(ergebnis.quote).toBeCloseTo(richtig / 20, 10); expect(ergebnis.urteil).toBe(urteil); }); it('baut die Begründung aus Trefferzahlen, nicht aus gerundeten Prozenten', () => { /* 149 von 200 sind 74,5 Prozent – kaufmännisch gerundet 75. Aus Prozentwerten gebaut lautete die Anzeige „Nicht bestanden. 75 Prozent richtig, nötig waren 75 Prozent." Ein Satz, der sich selbst widerspricht. */ const knapp = urteilBilden(profilFinden('frei')!, 149, 200, []); expect(knapp.urteil).toBe('nicht_bestanden'); expect(knapp.begruendung).toBe('149 von 200 richtig, nötig waren 150.'); const gerade = urteilBilden(profilFinden('frei')!, 150, 200, []); expect(gerade.urteil).toBe('bestanden'); expect(gerade.begruendung).toBe('150 von 200 richtig, nötig waren 150.'); }); it('rundet die nötige Trefferzahl nicht durch Gleitkomma hoch', () => { // 0,8 * 80 kann als 64,000000000000006 herauskommen – aufgerundet 65. const ergebnis = urteilBilden(profilFinden('standard')!, 64, 80, []); expect(ergebnis.urteil).toBe('bestanden'); expect(ergebnis.begruendung).toBe('64 von 80 richtig, nötig waren 64.'); }); it('nennt die Grauzone des DSB-Profils in der Begründung', () => { const ergebnis = auswerten('dsb', bogen(13, 7)); expect(ergebnis.urteil).toBe('nachpruefung'); expect(ergebnis.begruendung).toContain('mündliche Nachprüfung'); }); it('rechnet die Bestehensgrenze eines anpassbaren Profils nach', () => { const streng = pruefung.auswerten( profilId, auftrag({ profilId: 'frei', bestehensQuote: 0.9 }), bogen(16, 4), 1000, false, ); expect(streng.urteil).toBe('nicht_bestanden'); const milde = pruefung.auswerten( profilId, auftrag({ profilId: 'frei', bestehensQuote: 0.5 }), bogen(16, 4), 1000, false, ); expect(milde.urteil).toBe('bestanden'); }); it('übernimmt Dauer und Zeitangabe unverändert', () => { const ergebnis = pruefung.auswerten( profilId, auftrag({ profilId: 'standard' }), bogen(10, 10), 1_234_567, true, ); expect(ergebnis.dauerMs).toBe(1_234_567); expect(ergebnis.zeitAbgelaufen).toBe(true); expect(ergebnis.zeitpunkt).toBe(START); expect(ergebnis.profilId).toBe('standard'); }); it('weist unsaubere Nutzlasten ab', () => { const frage = mcAus('I.1', 1)[0]!; expect(() => auswerten('standard', [])).toThrow(/mindestens eine Antwort/u); expect(() => auswerten('standard', [antwort(frage, true), antwort(frage, true)])).toThrow( /mehrfach im Bogen/u, ); expect(() => auswerten('standard', [{ frageId: 'gibt-es-nicht', auswahl: [] }])).toThrow( /Unbekannte Frage-ID/u, ); expect(() => auswerten('standard', [{ frageId: frage.id, auswahl: ['z'] }])).toThrow( /keine Antwortoption/u, ); expect(() => pruefung.auswerten(profilId, auftrag({ profilId: 'standard' }), bogen(1, 0), -5, false), ).toThrow(/dauerMs/u); expect(() => pruefung.auswerten(profilId, auftrag({ profilId: 'standard' }), bogen(1, 0), 'lange', false), ).toThrow(/dauerMs/u); }); it('lässt die Wiedervorlage nie aufgeschlagener Fragen unberührt', () => { /* Läuft die Zeit ab, schickt die Oberfläche auch für nie gezeigte Fragen einen Eintrag mit leerer Auswahl. Diese gehören in die Historie – der Bogen enthielt sie ja –, dürfen den Lernstand aber nicht zurückstufen: Eine viermal sicher gewusste Frage wäre sonst allein deshalb wieder fällig, weil ein Prüfungslauf nicht bis zu ihr kam. */ const [gezeigt, nieGezeigt] = mcAus('I.1', 2); if (!gezeigt || !nieGezeigt) { throw new Error('Zwei Fragen erwartet'); } // Die zweite Frage viermal sicher beantworten, damit sie weit hinausrückt. for (let i = 0; i < 4; i += 1) { lernstand.antworten(profilId, { frageId: nieGezeigt.id, auswahl: nieGezeigt.optionen?.filter((o) => o.korrekt).map((o) => o.label) ?? [], richtig: true, bewertung: 'leicht', dauerMs: 1_000, }); } const vorher = lernstand.frageStand(profilId, nieGezeigt.id); expect(vorher.faelligAb).not.toBeNull(); pruefung.auswerten( profilId, auftrag({ profilId: 'standard' }), [antwort(gezeigt, true), { frageId: nieGezeigt.id, auswahl: [] }], 60_000, true, ); const nachher = lernstand.frageStand(profilId, nieGezeigt.id); expect(nachher.faelligAb).toBe(vorher.faelligAb); expect(nachher.letzteBewertung).toBe('leicht'); expect(nachher.versuche).toBe(vorher.versuche); }); it('kappt eine übergroße Dauer, statt den Lauf zu verwerfen', () => { /* Wer ohne Zeitbegrenzung übt – der Nachteilsausgleich für alle, die mehr Zeit brauchen – lässt das Fenster womöglich über Nacht offen. Diesen Lauf zu verwerfen träfe genau die Gruppe, für die die Einstellung da ist. Der Lauf wird gespeichert, die Dauer auf 24 Stunden gedeckelt. */ const ergebnis = pruefung.auswerten( profilId, auftrag({ profilId: 'standard' }), bogen(3, 1), 90_000_000, false, ); expect(ergebnis.dauerMs).toBe(86_400_000); expect(ergebnis.richtig).toBe(3); const gespeichert = pruefung.verlauf(profilId); expect(gespeichert).toHaveLength(1); expect(gespeichert[0]?.gesamt).toBe(4); }); it('rundet eine gemessene Dauer mit Nachkommastellen, statt sie abzuweisen', () => { const ergebnis = pruefung.auswerten( profilId, auftrag({ profilId: 'standard' }), bogen(1, 0), 1234.6, false, ); expect(ergebnis.dauerMs).toBe(1235); }); }); // ─── Fortschreibung des Lernstands ────────────────────────────────────────── describe('Prüfungslauf im Lernstand', () => { it('protokolliert jede Antwort wie eine Lernantwort', () => { const antworten = bogen(3, 2); auswerten('standard', antworten, 50_000); const anzahl = db .prepare<[number], { n: number }>('SELECT COUNT(*) AS n FROM antwort_log WHERE profil_id = ?') .get(profilId); expect(anzahl?.n).toBe(5); expect(lernstand.uebersicht(profilId).beantwortet).toBe(5); expect(lernstand.frageStand(profilId, antworten[0]!.frageId)).toMatchObject({ versuche: 1, richtige: 1, letzteBewertung: 'gut', }); expect(lernstand.frageStand(profilId, antworten[4]!.frageId)).toMatchObject({ versuche: 1, richtige: 0, letzteBewertung: 'nochmal', }); }); it('verteilt die gemessene Gesamtdauer gleichmäßig auf die Fragen', () => { auswerten('standard', bogen(2, 2), 40_000); const dauern = db .prepare<[number], { dauer_ms: number }>( 'SELECT dauer_ms FROM antwort_log WHERE profil_id = ?', ) .all(profilId) .map((z) => z.dauer_ms); expect(dauern).toEqual([10_000, 10_000, 10_000, 10_000]); }); it('speichert nichts, wenn die Auswertung abbricht', () => { const frage = mcAus('I.1', 1)[0]!; expect(() => auswerten('standard', [{ frageId: frage.id, auswahl: ['z'] }])).toThrow(); expect(pruefung.verlauf(profilId)).toEqual([]); expect(lernstand.uebersicht(profilId).beantwortet).toBe(0); }); }); // ─── Verlauf ──────────────────────────────────────────────────────────────── // ─── Unterbrochener Lauf ──────────────────────────────────────────────────── describe('Unterbrochener Lauf', () => { /** Vollständige Antwortliste zum gerade gestarteten Bogen. */ function bogenAntworten(bogenListe: readonly { frageId: string }[]): Pruefungsantwort[] { return bogenListe.map((eintrag) => { const frage = KATALOG.fragen.find((f) => f.id === eintrag.frageId)!; return antwort(frage, true); }); } function offeneZeilen(): number { return (db.prepare('SELECT COUNT(*) AS n FROM pruefung_offen').get() as { n: number }).n; } it('legt den Bogen schon beim Starten ab, nicht erst beim ersten Sichern', () => { /* Stürbe das Programm in der ersten Sekunde, wäre der Bogen sonst trotz aller Vorsorge weg. */ const gezogen = bogenZiehen(profilId, auftrag({ profilId: 'standard' })); const offen = pruefung.offenerBogen(profilId); expect(offen).not.toBeNull(); expect(offen?.bogen.map((e) => e.frageId)).toEqual(gezogen.map((e) => e.frageId)); expect(offen?.position).toBe(0); expect(offen?.verbrauchtMs).toBe(0); expect(offen?.phase).toBe('bearbeiten'); expect(offen?.profil.id).toBe('standard'); }); it('sichert Eingaben, Position, Phase und verbrauchte Zeit', () => { const gezogen = bogenZiehen(profilId, auftrag({ profilId: 'standard' })); const erste = gezogen[0]!.frageId; expect( pruefung.sichern(profilId, { eingaben: { [erste]: { auswahl: ['a'], freitext: '' } }, position: 5, phase: 'bearbeiten', zeitAbgelaufen: false, verbrauchtMs: 90_000, }), ).toBe(true); const offen = pruefung.offenerBogen(profilId); expect(offen?.eingaben[erste]).toEqual({ auswahl: ['a'], freitext: '' }); expect(offen?.position).toBe(5); expect(offen?.verbrauchtMs).toBe(90_000); }); it('unterscheidet nicht bewertet von als falsch bewertet', () => { /* Ein Ersatzwert `false` schriebe eine unbewertete offene Frage beim Fortsetzen als falsch fest. */ const gezogen = bogenZiehen(profilId, auftrag({ profilId: 'dsb' })); const a = gezogen[0]!.frageId; const b = gezogen[1]!.frageId; pruefung.sichern(profilId, { eingaben: { [a]: { auswahl: [], freitext: 'Text', selbst: false }, [b]: { auswahl: [], freitext: 'Text' }, }, position: 0, phase: 'nachbewertung', zeitAbgelaufen: false, verbrauchtMs: 0, }); const offen = pruefung.offenerBogen(profilId); expect(offen?.eingaben[a]?.selbst).toBe(false); expect(offen?.eingaben[b]).not.toHaveProperty('selbst'); expect(offen?.phase).toBe('nachbewertung'); }); it('löscht die Zeile beim Auswerten – in derselben Transaktion', () => { const gezogen = bogenZiehen(profilId, auftrag({ profilId: 'standard' })); expect(offeneZeilen()).toBe(1); auswerten('standard', bogenAntworten(gezogen)); expect(offeneZeilen()).toBe(0); expect(pruefung.offenerBogen(profilId)).toBeNull(); }); it('lässt eine verspätete Sicherung den gewerteten Lauf nicht wiederbeleben', () => { /* Der schwerste Fall: Das Sichern ist entprellt. Feuert ein Nachzügler nach der Abgabe, dürfte er die eben gelöschte Zeile nicht wieder anlegen – sonst böte die Anwendung beim nächsten Start dieselbe, bereits gewertete Prüfung zum Fortsetzen an, und jede Antwort ginge ein zweites Mal in die Wiedervorlage. */ const gezogen = bogenZiehen(profilId, auftrag({ profilId: 'standard' })); auswerten('standard', bogenAntworten(gezogen)); const angekommen = pruefung.sichern(profilId, { eingaben: {}, position: 3, phase: 'bearbeiten', zeitAbgelaufen: false, verbrauchtMs: 120_000, }); expect(angekommen).toBe(false); expect(offeneZeilen()).toBe(0); }); it('hinterlässt nach der Auswertung genau einen Verlaufseintrag', () => { const gezogen = bogenZiehen(profilId, auftrag({ profilId: 'standard' })); auswerten('standard', bogenAntworten(gezogen)); expect(pruefung.offenerBogen(profilId)).toBeNull(); expect((db.prepare('SELECT COUNT(*) AS n FROM pruefung_lauf').get() as { n: number }).n).toBe( 1, ); }); it('sichert nicht in einen Lauf, der inzwischen ein anderer ist', () => { /* Dafuer ist die Lauf-Kennung da, und nur dafuer: Die Zeile existiert noch, gehoert aber schon zum naechsten Bogen. Ein Nachzuegler aus dem alten Lauf duerfte dessen Position und Eingaben nicht in den neuen schreiben. */ pruefung.starten(profilId, auftrag({ profilId: 'standard' })); const alteKennung = ( db.prepare('SELECT lauf_id FROM pruefung_offen').get() as { lauf_id: string } ).lauf_id; const zweiter = bogenZiehen(profilId, auftrag({ profilId: 'bdmp' })); const neueKennung = ( db.prepare('SELECT lauf_id FROM pruefung_offen').get() as { lauf_id: string } ).lauf_id; expect(neueKennung).not.toBe(alteKennung); pruefung.sichern(profilId, { eingaben: { [zweiter[0]!.frageId]: { auswahl: ['a'], freitext: '' } }, position: 1, phase: 'bearbeiten', zeitAbgelaufen: false, verbrauchtMs: 1000, }); // Der neue Lauf nimmt seine eigene Sicherung an – die Kennung stimmt. expect(pruefung.offenerBogen(profilId)?.position).toBe(1); }); it('verwirft die Zeile auf ausdrücklichen Wunsch', () => { pruefung.starten(profilId, auftrag({ profilId: 'standard' })); pruefung.verwerfen(profilId); expect(pruefung.offenerBogen(profilId)).toBeNull(); expect(offeneZeilen()).toBe(0); }); it('führt je Profil höchstens einen offenen Lauf', () => { pruefung.starten(profilId, auftrag({ profilId: 'standard' })); const zweiter = bogenZiehen(profilId, auftrag({ profilId: 'bdmp' })); expect(offeneZeilen()).toBe(1); expect(pruefung.offenerBogen(profilId)?.bogen.map((e) => e.frageId)).toEqual( zweiter.map((e) => e.frageId), ); }); it('hält die Läufe verschiedener Profile auseinander', () => { const zweites = lernstand.profilAnlegen('Zweitprüfling'); pruefung.starten(profilId, auftrag({ profilId: 'standard' })); pruefung.starten(zweites.id, auftrag({ profilId: 'bdmp' })); expect(pruefung.offenerBogen(profilId)?.profil.id).toBe('standard'); expect(pruefung.offenerBogen(zweites.id)?.profil.id).toBe('bdmp'); }); it('nimmt das gelöschte Profil mit', () => { const zweites = lernstand.profilAnlegen('Zweitprüfling'); pruefung.starten(zweites.id, auftrag({ profilId: 'standard' })); lernstand.profilLoeschen(zweites.id); expect(offeneZeilen()).toBe(0); }); it('verschwindet beim vollständigen Zurücksetzen', () => { /* „Von vorn" und „die halbe Prüfung von gestern liegt noch da" passen nicht zusammen. */ pruefung.starten(profilId, auftrag({ profilId: 'standard' })); lernstand.zuruecksetzen(profilId, null); expect(offeneZeilen()).toBe(0); }); it('bleibt beim kapitelweisen Zurücksetzen stehen', () => { /* Ein Bogen geht über den ganzen Katalog und lässt sich nicht kapitelweise herausrechnen. */ pruefung.starten(profilId, auftrag({ profilId: 'standard' })); lernstand.zuruecksetzen(profilId, 'I'); expect(offeneZeilen()).toBe(1); }); it('verwirft einen Bogen, dessen Fragen es nicht mehr gibt', () => { /* Katalogwechsel zwischen Unterbrechung und Fortsetzen: Eine fehlende Frage ließe eine Lücke, und die Auswertung zählte gegen eine andere Gesamtzahl als die Anzeige. */ pruefung.starten(profilId, auftrag({ profilId: 'standard' })); db.prepare('UPDATE pruefung_offen SET bogen = ?').run( '[{"frageId":"gibt-es-nicht","optionsReihenfolge":["a"]}]', ); expect(pruefung.offenerBogen(profilId)).toBeNull(); expect(offeneZeilen()).toBe(0); expect(warnungen.join(' ')).toMatch(/gibt-es-nicht/u); }); it('verwirft eine Zeile mit ungültigem Auftrag', () => { /* Die Datei liegt im userData-Verzeichnis und ist von außen beschreibbar; ein erfundenes Profil darf nicht in die Wertung. */ pruefung.starten(profilId, auftrag({ profilId: 'standard' })); db.prepare('UPDATE pruefung_offen SET auftrag = ?').run('{"profilId":"gibt-es-nicht"}'); expect(pruefung.offenerBogen(profilId)).toBeNull(); expect(offeneZeilen()).toBe(0); }); it('nimmt beim Auswerten den gespeicherten Auftrag, nicht den eingereichten', () => { /* Sonst ließe sich ein fortgesetzter Bogen mit einem anderen Profil abgeben: Bogen und Urteilsregeln kämen aus verschiedenen Läufen. */ const gezogen = bogenZiehen(profilId, auftrag({ profilId: 'bdmp' })); const ergebnis = pruefung.auswerten( profilId, auftrag({ profilId: 'standard' }), bogenAntworten(gezogen), 60_000, false, ); expect(ergebnis.profilId).toBe('bdmp'); }); it('prüft die eingereichte Liste auch nach einem Neustart', () => { /* Bisher galt: Ist der Bogen nicht bekannt – etwa nach einem Neustart –, wird jede Liste ungeprüft angenommen. Mit der gespeicherten Zeile lässt sich der Bogen wiederherstellen, und die Prüfung greift wieder. */ const gezogen = bogenZiehen(profilId, auftrag({ profilId: 'standard' })); const antworten = bogenAntworten(gezogen); // Neustart nachbilden: neue Instanz, leerer Speicher, dieselbe Datenbank. const nachNeustart = new Pruefung(lernstand, KATALOG, { jetzt: () => uhr, zufall: zufallsfolge(7), warnen: (meldung) => warnungen.push(meldung), }); nachNeustart.offenerBogen(profilId); expect(() => nachNeustart.auswerten( profilId, auftrag({ profilId: 'standard' }), antworten.slice(0, 3), 1000, false, ), ).toThrow(/Bogen umfasste/u); }); it('weist eine Position außerhalb des Bogens ab', () => { pruefung.starten(profilId, auftrag({ profilId: 'standard' })); expect(() => pruefung.sichern(profilId, { eingaben: {}, position: 999, phase: 'bearbeiten', zeitAbgelaufen: false, verbrauchtMs: 0, }), ).toThrow(/position/u); }); it('weist eine Eingabe zu einer fremden Frage ab', () => { pruefung.starten(profilId, auftrag({ profilId: 'standard' })); expect(() => pruefung.sichern(profilId, { eingaben: { 'gibt-es-nicht': { auswahl: ['a'], freitext: '' } }, position: 0, phase: 'bearbeiten', zeitAbgelaufen: false, verbrauchtMs: 0, }), ).toThrow(/gehört nicht zu diesem Bogen/u); }); it('kappt einen übergroßen Freitext und eine unsinnige Dauer', () => { const gezogen = bogenZiehen(profilId, auftrag({ profilId: 'standard' })); const erste = gezogen[0]!.frageId; pruefung.sichern(profilId, { eingaben: { [erste]: { auswahl: [], freitext: 'x'.repeat(9000) } }, position: 0, phase: 'bearbeiten', zeitAbgelaufen: false, verbrauchtMs: 99 * 24 * 60 * 60 * 1000, }); const offen = pruefung.offenerBogen(profilId); expect(offen?.eingaben[erste]?.freitext).toHaveLength(4000); expect(offen?.verbrauchtMs).toBe(24 * 60 * 60 * 1000); }); }); describe('pruefung:verlauf', () => { it('liefert die Läufe mit dem neuesten zuerst', () => { auswerten('standard', bogen(16, 4)); uhr = new Date(new Date(START).getTime() + 3_600_000); auswerten('dsb', bogen(10, 10)); const verlauf = pruefung.verlauf(profilId); expect(verlauf.map((l) => l.profilId)).toEqual(['dsb', 'standard']); expect(verlauf[0]).toMatchObject({ profilName: 'Nach Art des Deutschen Schützenbundes', gesamt: 20, richtig: 10, quote: 0.5, urteil: 'nicht_bestanden', }); expect(verlauf[1]).toMatchObject({ profilName: 'Standard', richtig: 16, urteil: 'bestanden', }); expect(verlauf[0]!.id).not.toBe(verlauf[1]!.id); }); it('hält die Läufe verschiedener Lernprofile auseinander', () => { const zweites = lernstand.profilAnlegen('Zweite Person'); auswerten('standard', bogen(16, 4)); expect(pruefung.verlauf(profilId)).toHaveLength(1); expect(pruefung.verlauf(zweites.id)).toEqual([]); }); it('weist ein unbekanntes Lernprofil ab', () => { expect(() => pruefung.verlauf(4711)).toThrow(/Unbekanntes Profil/u); }); it('liefert die Dauer mit, die es seit jeher speichert', () => { // Die Spalte gibt es seit Schema-Version 2, `verlauf()` las sie bis 0.22.0 // nur nicht aus. Zeitnot ist ein eigenes Durchfallrisiko. auswerten('standard', bogen(16, 4), 71 * 60_000); expect(pruefung.verlauf(profilId)[0]?.dauerMs).toBe(71 * 60_000); }); it('hält fest, wie viele Fragen offen blieben und ob die Zeit ablief', () => { /* Ohne diese beiden Angaben sähe ein abgelaufener Lauf im Vergleich wie ein Wissenseinbruch aus: `quote` zählt Unbeantwortete wie falsch beantwortete. */ const fragen = mcAus('I.1', 4); pruefung.auswerten( profilId, auftrag({ profilId: 'standard' }), [ antwort(fragen[0]!, true), antwort(fragen[1]!, true), { frageId: fragen[2]!.id, auswahl: [] }, { frageId: fragen[3]!.id, auswahl: [] }, ], 60_000, true, ); expect(pruefung.verlauf(profilId)[0]).toMatchObject({ unbeantwortet: 2, zeitAbgelaufen: true, quote: 0.5, }); }); it('speichert die Themenanalyse des Laufs mit', () => { const fragen = [...mcAus('I.1', 2), ...mcAus('I.2', 2)]; pruefung.auswerten( profilId, auftrag({ profilId: 'standard' }), [ antwort(fragen[0]!, true), antwort(fragen[1]!, false), antwort(fragen[2]!, true), antwort(fragen[3]!, true), ], 60_000, false, ); const bereiche = pruefung.verlauf(profilId)[0]?.bereiche; expect(bereiche).toHaveLength(2); expect(bereiche?.[0]).toMatchObject({ bereich: 'I.1', gesamt: 2, richtig: 1 }); expect(bereiche?.[1]).toMatchObject({ bereich: 'I.2', gesamt: 2, richtig: 2 }); /* Der Titel reist mit: Der Verlauf muss ihn auch dann noch nennen können, wenn der Katalog den Abschnitt später anders benennt. */ expect(bereiche?.[0]?.titel).toBe(KATALOG.kapitel[0]?.abschnitte[0]?.titel); }); it('speichert die Bestehensgrenze des Laufs, nicht die des Vertrags', () => { /* Beim frei eingestellten Profil sind die gewählten Werte nach dem Lauf fort. Holte die Anzeige die Grenze hinterher aus dem Vertrag, bekämen genau diese Läufe eine falsche. */ auswerten('standard', bogen(16, 4)); expect(pruefung.verlauf(profilId)[0]?.bestehensQuote).toBeCloseTo(0.8); uhr = new Date(new Date(START).getTime() + 3_600_000); pruefung.auswerten( profilId, auftrag({ profilId: 'frei', bestehensQuote: 0.5 }), bogen(2, 2), 60_000, false, ); expect(pruefung.verlauf(profilId)[0]?.bestehensQuote).toBeCloseTo(0.5); }); it('rechnet eine Fehlerpunktegrenze in eine Quote um', () => { // 75 Fragen bei höchstens 15 Fehlern sind 60 von 75 – dieselbe Bedingung, // anders geschrieben. Ein Bogen von 20 Fragen ergibt entsprechend 5 von 20. pruefung.auswerten( profilId, auftrag({ profilId: 'fehlerpunkte' }), bogen(16, 4), 60_000, false, ); expect(pruefung.verlauf(profilId)[0]?.bestehensQuote).toBeCloseTo(0.25); }); it('nennt die Kennung des gespeicherten Laufs', () => { // Der Verlauf enthält den eben beendeten Lauf bereits. Ohne die Kennung // verglichen ihn Auswertung und Anzeige mit sich selbst. const ergebnis = auswerten('standard', bogen(16, 4)); expect(ergebnis.laufId).toBe(pruefung.verlauf(profilId)[0]?.id); }); it('übersteht eine beschädigte Themenanalyse, ohne den Verlauf zu verlieren', () => { /* Die Datei liegt im `userData`-Verzeichnis. Was nicht als Eintrag lesbar ist, fällt weg – schlimmstenfalls fehlt einem Lauf die Aufschlüsselung, statt dass die Anzeige ausfällt. */ auswerten('standard', bogen(16, 4)); db.prepare("UPDATE pruefung_lauf SET bereiche = '{kaputt'").run(); const verlauf = pruefung.verlauf(profilId); expect(verlauf).toHaveLength(1); expect(verlauf[0]?.bereiche).toBeNull(); }); it('nimmt aus einer halb lesbaren Analyse nur die gültigen Einträge', () => { auswerten('standard', bogen(16, 4)); db.prepare( `UPDATE pruefung_lauf SET bereiche = '[ {"bereich":"I.1","titel":"Begriffe","gesamt":4,"richtig":3}, {"bereich":"I.2","titel":"Rechte","gesamt":2,"richtig":5}, {"bereich":"I.3","gesamt":2,"richtig":1}, "unfug" ]'`, ).run(); // Mehr richtige als gestellte Fragen, fehlender Titel, kein Objekt: weg. expect(pruefung.verlauf(profilId)[0]?.bereiche).toEqual([ { bereich: 'I.1', titel: 'Begriffe', gesamt: 4, richtig: 3 }, ]); }); }); // ─── Migration ────────────────────────────────────────────────────────────── describe('Migration auf Schema 2', () => { function tabellen(verbindung: Database.Database): string[] { return verbindung .prepare<[], { name: string }>("SELECT name FROM sqlite_master WHERE type = 'table'") .all() .map((z) => z.name); } it('ergänzt pruefung_lauf, ohne einen bestehenden Lernstand zu verlieren', () => { const alt = new Database(':memory:'); verbindungen.push(alt); const vorher = new Lernstand(alt, KATALOG, { jetzt: () => uhr }); const pid = vorher.profile()[0]!.id; const gelernt = mcAus('I.2', 1)[0]!; vorher.antworten(pid, { frageId: gelernt.id, auswahl: richtigeLabels(gelernt), richtig: true, bewertung: 'gut', dauerMs: 1200, }); vorher.merken(pid, mcAus('I.3', 1)[0]!.id, true); // Stand einer Installation mit Schema-Version 1 herstellen. alt.exec('DROP TABLE pruefung_lauf'); alt.prepare('DELETE FROM schema_version WHERE version > 1').run(); expect(tabellen(alt)).not.toContain('pruefung_lauf'); const nachher = new Lernstand(alt, KATALOG, { jetzt: () => uhr }); expect(tabellen(alt)).toContain('pruefung_lauf'); expect( alt .prepare<[], { version: number }>('SELECT MAX(version) AS version FROM schema_version') .get()?.version, ).toBe(SCHEMA_VERSION); // Der bestehende Lernstand ist unversehrt. expect(nachher.profile()).toHaveLength(1); expect(nachher.frageStand(pid, gelernt.id)).toMatchObject({ versuche: 1, richtige: 1 }); expect(nachher.frageStand(pid, mcAus('I.3', 1)[0]!.id).gemerkt).toBe(true); expect(nachher.uebersicht(pid).beantwortet).toBe(1); // Und die Simulation lässt sich sofort benutzen. const migriert = new Pruefung(nachher, KATALOG, { jetzt: () => uhr }); migriert.auswerten(pid, auftrag({ profilId: 'standard' }), bogen(16, 4), 1000, false); expect(migriert.verlauf(pid)).toHaveLength(1); }); it('ergänzt die Zeitstufe und lässt ältere Läufe ohne sie stehen', () => { /* Die Zeitstufe eines Laufs von vor Schema-Version 4 ist nicht mehr zu ermitteln. Sie zu raten wäre schlechter, als sie offen zu lassen – sonst stünde in der Tabelle eine Angabe, die niemand gemessen hat. */ const alt = new Database(':memory:'); verbindungen.push(alt); const vorher = new Lernstand(alt, KATALOG, { jetzt: () => uhr }); const pid = vorher.profile()[0]!.id; const simulation = new Pruefung(vorher, KATALOG, { jetzt: () => uhr }); simulation.auswerten(pid, auftrag({ profilId: 'standard' }), bogen(16, 4), 1000, false); // Stand vor der Umstellung nachbilden: Spalte leeren, Version zurück. alt.prepare('UPDATE pruefung_lauf SET zeitmodus = NULL').run(); alt.prepare('DELETE FROM schema_version WHERE version > 3').run(); const nachher = new Lernstand(alt, KATALOG, { jetzt: () => uhr }); const spalten = alt .prepare<[], { name: string }>('PRAGMA table_info(pruefung_lauf)') .all() .map((z) => z.name); expect(spalten).toContain('zeitmodus'); const migriert = new Pruefung(nachher, KATALOG, { jetzt: () => uhr }); expect(migriert.verlauf(pid)[0]?.zeitmodus).toBeNull(); // Ein neuer Lauf trägt sie dagegen. migriert.auswerten( pid, auftrag({ profilId: 'standard', zeitmodus: 'aus' }), bogen(16, 4), 1000, false, ); expect(migriert.verlauf(pid)[0]?.zeitmodus).toBe('aus'); }); it('ergänzt die Kennzahlen aus Fassung 10 und erfindet sie für alte Läufe nicht', () => { /* `unbeantwortet = 0` hieße „es blieb nichts offen“ – eine Behauptung über einen Lauf, von dem niemand weiß, wie er endete. `NULL` heißt „nicht festgehalten“, und die Oberfläche sagt genau das. */ const alt = new Database(':memory:'); verbindungen.push(alt); const vorher = new Lernstand(alt, KATALOG, { jetzt: () => uhr }); const pid = vorher.profile()[0]!.id; const simulation = new Pruefung(vorher, KATALOG, { jetzt: () => uhr }); simulation.auswerten(pid, auftrag({ profilId: 'standard' }), bogen(16, 4), 1000, false); // Stand vor der Umstellung nachbilden: Spalten leeren, Version zurück. alt .prepare( `UPDATE pruefung_lauf SET unbeantwortet = NULL, zeit_abgelaufen = NULL, bereiche = NULL, bestehens_quote = NULL`, ) .run(); alt.prepare('DELETE FROM schema_version WHERE version > 9').run(); const nachher = new Lernstand(alt, KATALOG, { jetzt: () => uhr }); const migriert = new Pruefung(nachher, KATALOG, { jetzt: () => uhr }); expect(migriert.verlauf(pid)[0]).toMatchObject({ unbeantwortet: null, zeitAbgelaufen: null, bereiche: null, bestehensQuote: null, // Die Dauer stand schon in Fassung 2 in der Tabelle. dauerMs: 1000, }); // Ein neuer Lauf trägt sie dagegen. uhr = new Date(new Date(START).getTime() + 3_600_000); migriert.auswerten(pid, auftrag({ profilId: 'standard' }), bogen(16, 4), 1000, false); expect(migriert.verlauf(pid)[0]).toMatchObject({ unbeantwortet: 0, zeitAbgelaufen: false, bestehensQuote: 0.8, }); expect(migriert.verlauf(pid)[0]?.bereiche).not.toBeNull(); }); it('legt die Spalten aus Fassung 10 auch auf einer Datenbank ohne sie an', () => { /* Der Schritt muss auch dann durchlaufen, wenn `pruefung_lauf` noch die alte Gestalt hat – sonst startet die Anwendung überhaupt nicht mehr. */ const alt = new Database(':memory:'); verbindungen.push(alt); const vorher = new Lernstand(alt, KATALOG, { jetzt: () => uhr }); const pid = vorher.profile()[0]!.id; alt.exec('DROP TABLE pruefung_lauf'); alt.prepare('DELETE FROM schema_version WHERE version > 1').run(); const nachher = new Lernstand(alt, KATALOG, { jetzt: () => uhr }); const spalten = alt .prepare<[], { name: string }>('PRAGMA table_info(pruefung_lauf)') .all() .map((z) => z.name); expect(spalten).toEqual( expect.arrayContaining(['unbeantwortet', 'zeit_abgelaufen', 'bereiche', 'bestehens_quote']), ); const migriert = new Pruefung(nachher, KATALOG, { jetzt: () => uhr }); migriert.auswerten(pid, auftrag({ profilId: 'standard' }), bogen(16, 4), 1000, false); expect(migriert.verlauf(pid)[0]?.bestehensQuote).toBeCloseTo(0.8); }); }); describe('Ein Lauf wird genau einmal ausgewertet', () => { /* Befund der Prüfrunde zu 0.27.2. `bogenPruefen` nimmt eine Antwortliste ungeprüft an, wenn kein Bogen bekannt ist — gedacht für den Neustart mitten im Lauf. Diesen Fall gibt es dort aber gar nicht mehr: `offenerBogen()` füllt den Merkposten beim Fortsetzen wieder. Erreicht wird der Zweig praktisch nur noch nach einer bereits erfolgten Auswertung — und dann nahm er alles an. Gemessen an einem Bogen mit 80 Fragen: `versuche` 80 → 160, Einträge in `pruefung_lauf` 1 → 2, und der zweite trug das Profil „fehlerpunkte“ mit 80 Fragen, obwohl dieses Profil 75 vorsieht und der Bogen als „standard“ gezogen wurde. `sichern()` hat gegen genau diese Verdopplung zwei Riegel; `auswerten` hatte keinen. */ function bogenAntworten(bogenListe: readonly { frageId: string }[]): Pruefungsantwort[] { return bogenListe.map((eintrag) => { const frage = KATALOG.fragen.find((f) => f.id === eintrag.frageId)!; return antwort(frage, true); }); } function versuche(): number { return ( db .prepare<[number], { s: number }>( 'SELECT COALESCE(SUM(versuche), 0) AS s FROM frage_stand WHERE profil_id = ?', ) .get(profilId) as { s: number } ).s; } it('weist einen zweiten Aufruf ab, statt alles ein zweites Mal zu buchen', () => { const gezogen = bogenZiehen(profilId, auftrag({ profilId: 'standard' })); const antworten = bogenAntworten(gezogen); auswerten('standard', antworten); const nachErstem = versuche(); expect(() => auswerten('standard', antworten)).toThrow(/bereits ausgewertet/u); expect(versuche()).toBe(nachErstem); expect((db.prepare('SELECT COUNT(*) AS n FROM pruefung_lauf').get() as { n: number }).n).toBe( 1, ); }); it('lässt den zweiten Aufruf auch kein fremdes Profil einschmuggeln', () => { /* Die Verschärfung: Nach der ersten Auswertung ist auch der gespeicherte Auftrag fort, der zweite Aufruf zöge also den des Renderers heran — mit anderer Bestehensgrenze für denselben Bogen. */ const gezogen = bogenZiehen(profilId, auftrag({ profilId: 'standard' })); const antworten = bogenAntworten(gezogen); auswerten('standard', antworten); expect(() => auswerten('fehlerpunkte', antworten)).toThrow(/bereits ausgewertet/u); expect( db .prepare<[], { pruefungsprofil: string }>('SELECT pruefungsprofil FROM pruefung_lauf') .all() .map((zeile) => zeile.pruefungsprofil), ).toEqual(['standard']); }); it('wertet einen nach Neustart fortgesetzten Lauf weiterhin aus', () => { /* Die Gegenprobe, und sie ist die wichtigere: Der Riegel darf die unterbrochene Sitzung nicht treffen. „Eine unterbrochene Sitzung soll niemandem seine Arbeit kosten“ — die Zeile des offenen Laufs ist dann noch da, und `offenerBogen()` füllt den Merkposten wieder. */ const gezogen = bogenZiehen(profilId, auftrag({ profilId: 'standard' })); const antworten = bogenAntworten(gezogen); /* Neustart nachgestellt: neue Instanz auf derselben Datenbank. */ const nachNeustart = new Pruefung(lernstand, KATALOG, { jetzt: () => uhr, zufall: zufallsfolge(7), warnen: (meldung) => warnungen.push(meldung), }); expect(nachNeustart.offenerBogen(profilId)).not.toBeNull(); expect(() => nachNeustart.auswerten(profilId, auftrag({ profilId: 'standard' }), antworten, 60_000, false), ).not.toThrow(); }); });