// @vitest-environment node /** * Bogenzusammenstellung gegen den **echten** Katalog. * * Die übrigen Prüfungstests arbeiten mit einem synthetischen Katalog, in dem * jeder Bereich reichlich offene Fragen hat. Genau das verdeckte einen * Befund: Beim DSB-Profil enthalten die Bereiche I.4 und III je genau eine * offene Frage, und der Bogen kam reproduzierbar auf 18 statt der zugesagten * 20 – bei einer Profilbeschreibung, die „höchstens 80 Prozent Multiple * Choice" verspricht. * * Dieser Test zieht deshalb aus `content/katalog/katalog.json`. */ import { readFileSync } from 'node:fs'; import { join } from 'node:path'; import { fileURLToPath } from 'node:url'; 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 { bogenAusKatalog, gehoertZuBereich } from '../src/renderer/src/pruefung/bogen'; import type { Katalog } from '../src/shared/katalog'; import { istKoFrage, KO_BEREICHE, PRUEFUNGSPROFILE, type Pruefungsauftrag, } from '../src/shared/pruefung'; const wurzel = fileURLToPath(new URL('..', import.meta.url)); const KATALOG = JSON.parse( readFileSync(join(wurzel, '..', 'content', 'katalog', 'katalog.json'), 'utf8'), ) as Katalog; /** Deterministischer Zufall – derselbe Startwert liefert denselben Bogen. */ function zufallMit(saat: number): () => number { let zustand = saat; return () => { zustand = (zustand * 1_103_515_245 + 12_345) % 2_147_483_648; return zustand / 2_147_483_648; }; } let db: Database.Database; let lernstand: Lernstand; let profilId: number; beforeEach(() => { db = new Database(':memory:'); lernstand = new Lernstand(db, KATALOG); profilId = lernstand.profile()[0]!.id; }); afterEach(() => { if (db.open) { db.close(); } }); function auftrag(profil: string): Pruefungsauftrag { return { profilId: profil, zeitmodus: 'normal', kapitelAusschluss: [] }; } function offeneImBogen(saat: number, profil: string, ausschluss: string[] = []): number { const pruefung = new Pruefung(lernstand, KATALOG, { zufall: zufallMit(saat) }); const bogen = pruefung.starten(profilId, { ...auftrag(profil), kapitelAusschluss: ausschluss }); const nachId = new Map(KATALOG.fragen.map((f) => [f.id, f])); return bogen.fragen.filter((eintrag) => nachId.get(eintrag.frageId)?.typ !== 'mc').length; } describe('Bogen aus dem echten Katalog', () => { it('enthält im DSB-Profil die zugesagten 20 offenen Fragen', () => { /* Der Befund war reproduzierbar über mehrere Startwerte – deshalb hier ebenso mehrere, statt sich auf einen glücklichen zu verlassen. */ const profil = PRUEFUNGSPROFILE.find((p) => p.id === 'dsb'); expect(profil?.anteilOffen).toBe(0.2); expect(profil?.fragenAnzahl).toBe(100); for (const saat of [1, 7, 42, 1234, 99_999]) { expect(offeneImBogen(saat, 'dsb'), `Startwert ${String(saat)}`).toBe(20); } }); it('hält den Anteil auch, wenn Kapitel IV abgewählt ist', () => { // Kapitel IV bringt 29 offene Fragen mit; ohne sie wird es enger. for (const saat of [3, 17, 555]) { expect(offeneImBogen(saat, 'dsb', ['IV']), `Startwert ${String(saat)}`).toBe(20); } }); it('zieht für das Standardprofil ausschließlich Multiple Choice', () => { expect(offeneImBogen(11, 'standard')).toBe(0); }); it('liefert immer die vorgesehene Fragenzahl', () => { const pruefung = new Pruefung(lernstand, KATALOG, { zufall: zufallMit(5) }); expect(pruefung.starten(profilId, auftrag('dsb')).fragen).toHaveLength(100); expect(pruefung.starten(profilId, auftrag('standard')).fragen).toHaveLength(80); }); it('zieht keine Frage doppelt', () => { const pruefung = new Pruefung(lernstand, KATALOG, { zufall: zufallMit(23) }); const bogen = pruefung.starten(profilId, auftrag('dsb')); expect(new Set(bogen.fragen.map((e) => e.frageId)).size).toBe(bogen.fragen.length); }); /* Der echte Katalog trägt die Themenquoten des DSB-Profils und seine 104 offenen Fragen; ein Bogen daraus darf keine Abweichung melden. Der Test hält damit fest, dass das Warnungsfeld nicht bei jedem Lauf anschlägt. */ it('meldet für den echten Katalog keine Abweichung', () => { const pruefung = new Pruefung(lernstand, KATALOG, { zufall: zufallMit(5) }); expect(pruefung.starten(profilId, auftrag('dsb')).warnungen).toEqual([]); expect(pruefung.starten(profilId, auftrag('standard')).warnungen).toEqual([]); }); }); /* Der Ersatzweg: Steht der Anwendungskern nicht bereit, stellt die Oberfläche den Bogen selbst aus dem Katalog zusammen. Er wird sonst nie berührt und wäre deshalb die einzige Stelle, an der ein alter Rückfallwert überlebt – ein Fehler, der nur bei fehlendem IPC-Kanal aufträte. */ describe('Ersatzbogen ohne Anwendungskern', () => { const standard = PRUEFUNGSPROFILE.find((p) => p.id === 'standard'); if (!standard) throw new Error('Profil „standard“ fehlt.'); const nachId = new Map(KATALOG.fragen.map((frage) => [frage.id, frage])); it('folgt ohne ausdrückliches optionenMischen der Katalogreihenfolge', () => { const bogen = bogenAusKatalog(KATALOG, standard, { profilId: 'standard', zeitmodus: 'aus', }); expect(bogen.length).toBeGreaterThan(0); for (const eintrag of bogen) { const frage = nachId.get(eintrag.frageId); expect(eintrag.optionsReihenfolge).toEqual((frage?.optionen ?? []).map((o) => o.label)); } }); /* Gemessener Befund (27.08.2026, bei Einführung der Abdeckungsmessung): Der Ersatzweg behandelte Themenquoten und Anteil offener Fragen als Entweder-oder. Für das DSB-Profil – das einzige mit Quoten, und zugleich eines mit `anteilOffen: 0.2` – gewann die Quote, und der Anteil verfiel stillschweigend: Fünf Läufe ergaben 19, 11, 11, 12 und 13 offene Fragen statt der zugesagten 20. Die Profilbeschreibung verspricht „höchstens 80 Prozent Multiple Choice"; der Anwendungskern hält das seit dem 18-statt-20-Befund oben, der Ersatzweg hielt es nie. */ it('hält im DSB-Profil auch ersatzweise den Anteil offener Fragen', () => { const dsb = PRUEFUNGSPROFILE.find((p) => p.id === 'dsb'); if (!dsb) throw new Error('Profil „dsb“ fehlt.'); /* Der Ersatzweg mischt ohne einstellbaren Zufall – deshalb mehrere Läufe, damit nicht ein glücklicher Einzelfall die Zusicherung trägt. */ for (let lauf = 1; lauf <= 10; lauf += 1) { const bogen = bogenAusKatalog(KATALOG, dsb, { profilId: 'dsb', zeitmodus: 'aus' }); const offene = bogen.filter((e) => nachId.get(e.frageId)?.typ !== 'mc').length; expect(bogen, `Lauf ${String(lauf)}`).toHaveLength(100); expect(offene, `Lauf ${String(lauf)}: offene Fragen`).toBe(20); } }); /* Ohne Quoten greift der andere Zweig – er war ebenfalls unbelegt. */ it('hält den Anteil auch im Profil ohne Themenquoten', () => { const fehlerpunkte = PRUEFUNGSPROFILE.find((p) => p.id === 'fehlerpunkte'); if (!fehlerpunkte) throw new Error('Profil „fehlerpunkte“ fehlt.'); for (let lauf = 1; lauf <= 5; lauf += 1) { const bogen = bogenAusKatalog(KATALOG, fehlerpunkte, { profilId: 'fehlerpunkte', zeitmodus: 'aus', }); const offene = bogen.filter((e) => nachId.get(e.frageId)?.typ !== 'mc').length; expect(bogen, `Lauf ${String(lauf)}`).toHaveLength(75); // 15 Prozent von 75 Fragen, kaufmännisch gerundet. expect(offene, `Lauf ${String(lauf)}: offene Fragen`).toBe(11); } }); /* Die Themenquoten selbst dürfen über der Anteilsrechnung nicht leiden. */ it('bedient jeden Themenblock des DSB-Profils in der vorgesehenen Stärke', () => { const dsb = PRUEFUNGSPROFILE.find((p) => p.id === 'dsb'); if (!dsb?.themenquoten) throw new Error('Themenquoten des DSB-Profils fehlen.'); const bogen = bogenAusKatalog(KATALOG, dsb, { profilId: 'dsb', zeitmodus: 'aus' }); const fragen = bogen.map((e) => nachId.get(e.frageId)).filter((f) => f !== undefined); for (const [bereich, menge] of Object.entries(dsb.themenquoten)) { const treffer = fragen.filter((f) => gehoertZuBereich(f, bereich)).length; expect(treffer, `Bereich ${bereich}`).toBe(menge); } }); it('mischt sie auf ausdrücklichen Wunsch', () => { const bogen = bogenAusKatalog(KATALOG, standard, { profilId: 'standard', zeitmodus: 'aus', optionenMischen: true, }); let mindestensEinmalGemischt = false; for (const eintrag of bogen) { const labels = (nachId.get(eintrag.frageId)?.optionen ?? []).map((o) => o.label); // Keine Option geht verloren, gleich in welcher Reihenfolge. expect([...eintrag.optionsReihenfolge].sort()).toEqual([...labels].sort()); if (eintrag.optionsReihenfolge.join('') !== labels.join('')) { mindestensEinmalGemischt = true; } } expect(mindestensEinmalGemischt).toBe(true); }); }); describe('K.-o.-Bereiche am echten Katalog', () => { /* Die Wache, die gefehlt hat. Der Prüfkatalog der übrigen Tests kennt die Abschnitte I.1, I.2, II und IV – aber kein I.5. Genau dort liegt das einzige K.-o.-Kriterium dieses Projekts, und deshalb ließ sich die Verbindung zwischen Ampel und Planer nirgends sonst prüfen. Sie muss am echten Bestand geprüft werden oder gar nicht. */ it('kennt genau einen K.-o.-Bereich, und der steht im Katalog', () => { expect(KO_BEREICHE).toEqual(['I.5']); const drin = KATALOG.fragen.filter((frage) => istKoFrage(frage.kapitel, frage.abschnitt ?? null), ); /* Nachgezählt am 01.09.2026: 43 der 575 Fragen. */ expect(drin).toHaveLength(43); }); it('legt eine K.-o.-Frage früher wieder vor als eine sonst gleiche', () => { const koFrage = KATALOG.fragen.find((f) => istKoFrage(f.kapitel, f.abschnitt ?? null)); const andere = KATALOG.fragen.find((f) => !istKoFrage(f.kapitel, f.abschnitt ?? null)); expect(koFrage).toBeDefined(); expect(andere).toBeDefined(); const beantworten = (frageId: string) => lernstand.antworten(profilId, { frageId, auswahl: [], richtig: true, bewertung: 'leicht', dauerMs: 1500, }); const ko = beantworten(koFrage?.id ?? ''); const rest = beantworten(andere?.id ?? ''); const zeitpunkt = (stand: { faelligAb: string | null }) => Date.parse(stand.faelligAb ?? ''); expect(ko.faelligAb).not.toBeNull(); expect(rest.faelligAb).not.toBeNull(); expect(zeitpunkt(ko)).toBeLessThan(zeitpunkt(rest)); }); });