waffensachkunde
Waffensachkunde – Lernsoftware für die Sachkundeprüfung nach § 7 WaffG. Barrierefrei, offline, EUPL-1.2.
/ app tests bogen-echt.test.ts
| 1 | // @vitest-environment node |
| 2 | /** |
| 3 | * Bogenzusammenstellung gegen den **echten** Katalog. |
| 4 | * |
| 5 | * Die übrigen Prüfungstests arbeiten mit einem synthetischen Katalog, in dem |
| 6 | * jeder Bereich reichlich offene Fragen hat. Genau das verdeckte einen |
| 7 | * Befund: Beim DSB-Profil enthalten die Bereiche I.4 und III je genau eine |
| 8 | * offene Frage, und der Bogen kam reproduzierbar auf 18 statt der zugesagten |
| 9 | * 20 – bei einer Profilbeschreibung, die „höchstens 80 Prozent Multiple |
| 10 | * Choice" verspricht. |
| 11 | * |
| 12 | * Dieser Test zieht deshalb aus `content/katalog/katalog.json`. |
| 13 | */ |
| 14 | |
| 15 | import { readFileSync } from 'node:fs'; |
| 16 | import { join } from 'node:path'; |
| 17 | import { fileURLToPath } from 'node:url'; |
| 18 | |
| 19 | import Database from 'better-sqlite3'; |
| 20 | import { afterEach, beforeEach, describe, expect, it } from 'vitest'; |
| 21 | |
| 22 | import { Lernstand } from '../src/main/lernstand'; |
| 23 | import { Pruefung } from '../src/main/pruefung'; |
| 24 | import { bogenAusKatalog, gehoertZuBereich } from '../src/renderer/src/pruefung/bogen'; |
| 25 | import type { Katalog } from '../src/shared/katalog'; |
| 26 | import { |
| 27 | istKoFrage, |
| 28 | KO_BEREICHE, |
| 29 | PRUEFUNGSPROFILE, |
| 30 | type Pruefungsauftrag, |
| 31 | } from '../src/shared/pruefung'; |
| 32 | |
| 33 | const wurzel = fileURLToPath(new URL('..', import.meta.url)); |
| 34 | |
| 35 | const KATALOG = JSON.parse( |
| 36 | readFileSync(join(wurzel, '..', 'content', 'katalog', 'katalog.json'), 'utf8'), |
| 37 | ) as Katalog; |
| 38 | |
| 39 | /** Deterministischer Zufall – derselbe Startwert liefert denselben Bogen. */ |
| 40 | function zufallMit(saat: number): () => number { |
| 41 | let zustand = saat; |
| 42 | return () => { |
| 43 | zustand = (zustand * 1_103_515_245 + 12_345) % 2_147_483_648; |
| 44 | return zustand / 2_147_483_648; |
| 45 | }; |
| 46 | } |
| 47 | |
| 48 | let db: Database.Database; |
| 49 | let lernstand: Lernstand; |
| 50 | let profilId: number; |
| 51 | |
| 52 | beforeEach(() => { |
| 53 | db = new Database(':memory:'); |
| 54 | lernstand = new Lernstand(db, KATALOG); |
| 55 | profilId = lernstand.profile()[0]!.id; |
| 56 | }); |
| 57 | |
| 58 | afterEach(() => { |
| 59 | if (db.open) { |
| 60 | db.close(); |
| 61 | } |
| 62 | }); |
| 63 | |
| 64 | function auftrag(profil: string): Pruefungsauftrag { |
| 65 | return { profilId: profil, zeitmodus: 'normal', kapitelAusschluss: [] }; |
| 66 | } |
| 67 | |
| 68 | function offeneImBogen(saat: number, profil: string, ausschluss: string[] = []): number { |
| 69 | const pruefung = new Pruefung(lernstand, KATALOG, { zufall: zufallMit(saat) }); |
| 70 | const bogen = pruefung.starten(profilId, { ...auftrag(profil), kapitelAusschluss: ausschluss }); |
| 71 | const nachId = new Map(KATALOG.fragen.map((f) => [f.id, f])); |
| 72 | return bogen.fragen.filter((eintrag) => nachId.get(eintrag.frageId)?.typ !== 'mc').length; |
| 73 | } |
| 74 | |
| 75 | describe('Bogen aus dem echten Katalog', () => { |
| 76 | it('enthält im DSB-Profil die zugesagten 20 offenen Fragen', () => { |
| 77 | /* Der Befund war reproduzierbar über mehrere Startwerte – deshalb hier |
| 78 | ebenso mehrere, statt sich auf einen glücklichen zu verlassen. */ |
| 79 | const profil = PRUEFUNGSPROFILE.find((p) => p.id === 'dsb'); |
| 80 | expect(profil?.anteilOffen).toBe(0.2); |
| 81 | expect(profil?.fragenAnzahl).toBe(100); |
| 82 | |
| 83 | for (const saat of [1, 7, 42, 1234, 99_999]) { |
| 84 | expect(offeneImBogen(saat, 'dsb'), `Startwert ${String(saat)}`).toBe(20); |
| 85 | } |
| 86 | }); |
| 87 | |
| 88 | it('hält den Anteil auch, wenn Kapitel IV abgewählt ist', () => { |
| 89 | // Kapitel IV bringt 29 offene Fragen mit; ohne sie wird es enger. |
| 90 | for (const saat of [3, 17, 555]) { |
| 91 | expect(offeneImBogen(saat, 'dsb', ['IV']), `Startwert ${String(saat)}`).toBe(20); |
| 92 | } |
| 93 | }); |
| 94 | |
| 95 | it('zieht für das Standardprofil ausschließlich Multiple Choice', () => { |
| 96 | expect(offeneImBogen(11, 'standard')).toBe(0); |
| 97 | }); |
| 98 | |
| 99 | it('liefert immer die vorgesehene Fragenzahl', () => { |
| 100 | const pruefung = new Pruefung(lernstand, KATALOG, { zufall: zufallMit(5) }); |
| 101 | expect(pruefung.starten(profilId, auftrag('dsb')).fragen).toHaveLength(100); |
| 102 | expect(pruefung.starten(profilId, auftrag('standard')).fragen).toHaveLength(80); |
| 103 | }); |
| 104 | |
| 105 | it('zieht keine Frage doppelt', () => { |
| 106 | const pruefung = new Pruefung(lernstand, KATALOG, { zufall: zufallMit(23) }); |
| 107 | const bogen = pruefung.starten(profilId, auftrag('dsb')); |
| 108 | |
| 109 | expect(new Set(bogen.fragen.map((e) => e.frageId)).size).toBe(bogen.fragen.length); |
| 110 | }); |
| 111 | |
| 112 | /* Der echte Katalog trägt die Themenquoten des DSB-Profils und seine 104 |
| 113 | offenen Fragen; ein Bogen daraus darf keine Abweichung melden. Der Test |
| 114 | hält damit fest, dass das Warnungsfeld nicht bei jedem Lauf anschlägt. */ |
| 115 | it('meldet für den echten Katalog keine Abweichung', () => { |
| 116 | const pruefung = new Pruefung(lernstand, KATALOG, { zufall: zufallMit(5) }); |
| 117 | |
| 118 | expect(pruefung.starten(profilId, auftrag('dsb')).warnungen).toEqual([]); |
| 119 | expect(pruefung.starten(profilId, auftrag('standard')).warnungen).toEqual([]); |
| 120 | }); |
| 121 | }); |
| 122 | |
| 123 | /* |
| 124 | Der Ersatzweg: Steht der Anwendungskern nicht bereit, stellt die Oberfläche |
| 125 | den Bogen selbst aus dem Katalog zusammen. Er wird sonst nie berührt und |
| 126 | wäre deshalb die einzige Stelle, an der ein alter Rückfallwert überlebt – |
| 127 | ein Fehler, der nur bei fehlendem IPC-Kanal aufträte. |
| 128 | */ |
| 129 | describe('Ersatzbogen ohne Anwendungskern', () => { |
| 130 | const standard = PRUEFUNGSPROFILE.find((p) => p.id === 'standard'); |
| 131 | if (!standard) throw new Error('Profil „standard“ fehlt.'); |
| 132 | const nachId = new Map(KATALOG.fragen.map((frage) => [frage.id, frage])); |
| 133 | |
| 134 | it('folgt ohne ausdrückliches optionenMischen der Katalogreihenfolge', () => { |
| 135 | const bogen = bogenAusKatalog(KATALOG, standard, { |
| 136 | profilId: 'standard', |
| 137 | zeitmodus: 'aus', |
| 138 | }); |
| 139 | |
| 140 | expect(bogen.length).toBeGreaterThan(0); |
| 141 | for (const eintrag of bogen) { |
| 142 | const frage = nachId.get(eintrag.frageId); |
| 143 | expect(eintrag.optionsReihenfolge).toEqual((frage?.optionen ?? []).map((o) => o.label)); |
| 144 | } |
| 145 | }); |
| 146 | |
| 147 | /* |
| 148 | Gemessener Befund (27.08.2026, bei Einführung der Abdeckungsmessung): |
| 149 | Der Ersatzweg behandelte Themenquoten und Anteil offener Fragen als |
| 150 | Entweder-oder. Für das DSB-Profil – das einzige mit Quoten, und zugleich |
| 151 | eines mit `anteilOffen: 0.2` – gewann die Quote, und der Anteil verfiel |
| 152 | stillschweigend: Fünf Läufe ergaben 19, 11, 11, 12 und 13 offene Fragen |
| 153 | statt der zugesagten 20. Die Profilbeschreibung verspricht „höchstens |
| 154 | 80 Prozent Multiple Choice"; der Anwendungskern hält das seit dem |
| 155 | 18-statt-20-Befund oben, der Ersatzweg hielt es nie. |
| 156 | */ |
| 157 | it('hält im DSB-Profil auch ersatzweise den Anteil offener Fragen', () => { |
| 158 | const dsb = PRUEFUNGSPROFILE.find((p) => p.id === 'dsb'); |
| 159 | if (!dsb) throw new Error('Profil „dsb“ fehlt.'); |
| 160 | |
| 161 | /* Der Ersatzweg mischt ohne einstellbaren Zufall – deshalb mehrere Läufe, |
| 162 | damit nicht ein glücklicher Einzelfall die Zusicherung trägt. */ |
| 163 | for (let lauf = 1; lauf <= 10; lauf += 1) { |
| 164 | const bogen = bogenAusKatalog(KATALOG, dsb, { profilId: 'dsb', zeitmodus: 'aus' }); |
| 165 | const offene = bogen.filter((e) => nachId.get(e.frageId)?.typ !== 'mc').length; |
| 166 | |
| 167 | expect(bogen, `Lauf ${String(lauf)}`).toHaveLength(100); |
| 168 | expect(offene, `Lauf ${String(lauf)}: offene Fragen`).toBe(20); |
| 169 | } |
| 170 | }); |
| 171 | |
| 172 | /* Ohne Quoten greift der andere Zweig – er war ebenfalls unbelegt. */ |
| 173 | it('hält den Anteil auch im Profil ohne Themenquoten', () => { |
| 174 | const fehlerpunkte = PRUEFUNGSPROFILE.find((p) => p.id === 'fehlerpunkte'); |
| 175 | if (!fehlerpunkte) throw new Error('Profil „fehlerpunkte“ fehlt.'); |
| 176 | |
| 177 | for (let lauf = 1; lauf <= 5; lauf += 1) { |
| 178 | const bogen = bogenAusKatalog(KATALOG, fehlerpunkte, { |
| 179 | profilId: 'fehlerpunkte', |
| 180 | zeitmodus: 'aus', |
| 181 | }); |
| 182 | const offene = bogen.filter((e) => nachId.get(e.frageId)?.typ !== 'mc').length; |
| 183 | |
| 184 | expect(bogen, `Lauf ${String(lauf)}`).toHaveLength(75); |
| 185 | // 15 Prozent von 75 Fragen, kaufmännisch gerundet. |
| 186 | expect(offene, `Lauf ${String(lauf)}: offene Fragen`).toBe(11); |
| 187 | } |
| 188 | }); |
| 189 | |
| 190 | /* Die Themenquoten selbst dürfen über der Anteilsrechnung nicht leiden. */ |
| 191 | it('bedient jeden Themenblock des DSB-Profils in der vorgesehenen Stärke', () => { |
| 192 | const dsb = PRUEFUNGSPROFILE.find((p) => p.id === 'dsb'); |
| 193 | if (!dsb?.themenquoten) throw new Error('Themenquoten des DSB-Profils fehlen.'); |
| 194 | |
| 195 | const bogen = bogenAusKatalog(KATALOG, dsb, { profilId: 'dsb', zeitmodus: 'aus' }); |
| 196 | const fragen = bogen.map((e) => nachId.get(e.frageId)).filter((f) => f !== undefined); |
| 197 | |
| 198 | for (const [bereich, menge] of Object.entries(dsb.themenquoten)) { |
| 199 | const treffer = fragen.filter((f) => gehoertZuBereich(f, bereich)).length; |
| 200 | expect(treffer, `Bereich ${bereich}`).toBe(menge); |
| 201 | } |
| 202 | }); |
| 203 | |
| 204 | it('mischt sie auf ausdrücklichen Wunsch', () => { |
| 205 | const bogen = bogenAusKatalog(KATALOG, standard, { |
| 206 | profilId: 'standard', |
| 207 | zeitmodus: 'aus', |
| 208 | optionenMischen: true, |
| 209 | }); |
| 210 | let mindestensEinmalGemischt = false; |
| 211 | |
| 212 | for (const eintrag of bogen) { |
| 213 | const labels = (nachId.get(eintrag.frageId)?.optionen ?? []).map((o) => o.label); |
| 214 | |
| 215 | // Keine Option geht verloren, gleich in welcher Reihenfolge. |
| 216 | expect([...eintrag.optionsReihenfolge].sort()).toEqual([...labels].sort()); |
| 217 | if (eintrag.optionsReihenfolge.join('') !== labels.join('')) { |
| 218 | mindestensEinmalGemischt = true; |
| 219 | } |
| 220 | } |
| 221 | |
| 222 | expect(mindestensEinmalGemischt).toBe(true); |
| 223 | }); |
| 224 | }); |
| 225 | |
| 226 | describe('K.-o.-Bereiche am echten Katalog', () => { |
| 227 | /* |
| 228 | Die Wache, die gefehlt hat. |
| 229 | |
| 230 | Der Prüfkatalog der übrigen Tests kennt die Abschnitte I.1, I.2, II und |
| 231 | IV – aber kein I.5. Genau dort liegt das einzige K.-o.-Kriterium dieses |
| 232 | Projekts, und deshalb ließ sich die Verbindung zwischen Ampel und Planer |
| 233 | nirgends sonst prüfen. Sie muss am echten Bestand geprüft werden oder |
| 234 | gar nicht. |
| 235 | */ |
| 236 | it('kennt genau einen K.-o.-Bereich, und der steht im Katalog', () => { |
| 237 | expect(KO_BEREICHE).toEqual(['I.5']); |
| 238 | |
| 239 | const drin = KATALOG.fragen.filter((frage) => |
| 240 | istKoFrage(frage.kapitel, frage.abschnitt ?? null), |
| 241 | ); |
| 242 | |
| 243 | /* Nachgezählt am 01.09.2026: 43 der 575 Fragen. */ |
| 244 | expect(drin).toHaveLength(43); |
| 245 | }); |
| 246 | |
| 247 | it('legt eine K.-o.-Frage früher wieder vor als eine sonst gleiche', () => { |
| 248 | const koFrage = KATALOG.fragen.find((f) => istKoFrage(f.kapitel, f.abschnitt ?? null)); |
| 249 | const andere = KATALOG.fragen.find((f) => !istKoFrage(f.kapitel, f.abschnitt ?? null)); |
| 250 | expect(koFrage).toBeDefined(); |
| 251 | expect(andere).toBeDefined(); |
| 252 | |
| 253 | const beantworten = (frageId: string) => |
| 254 | lernstand.antworten(profilId, { |
| 255 | frageId, |
| 256 | auswahl: [], |
| 257 | richtig: true, |
| 258 | bewertung: 'leicht', |
| 259 | dauerMs: 1500, |
| 260 | }); |
| 261 | |
| 262 | const ko = beantworten(koFrage?.id ?? ''); |
| 263 | const rest = beantworten(andere?.id ?? ''); |
| 264 | |
| 265 | const zeitpunkt = (stand: { faelligAb: string | null }) => Date.parse(stand.faelligAb ?? ''); |
| 266 | |
| 267 | expect(ko.faelligAb).not.toBeNull(); |
| 268 | expect(rest.faelligAb).not.toBeNull(); |
| 269 | expect(zeitpunkt(ko)).toBeLessThan(zeitpunkt(rest)); |
| 270 | }); |
| 271 | }); |