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 { PRUEFUNGSPROFILE, type Pruefungsauftrag } from '../src/shared/pruefung'; |
| 27 | |
| 28 | const wurzel = fileURLToPath(new URL('..', import.meta.url)); |
| 29 | |
| 30 | const KATALOG = JSON.parse( |
| 31 | readFileSync(join(wurzel, '..', 'content', 'katalog', 'katalog.json'), 'utf8'), |
| 32 | ) as Katalog; |
| 33 | |
| 34 | /** Deterministischer Zufall – derselbe Startwert liefert denselben Bogen. */ |
| 35 | function zufallMit(saat: number): () => number { |
| 36 | let zustand = saat; |
| 37 | return () => { |
| 38 | zustand = (zustand * 1_103_515_245 + 12_345) % 2_147_483_648; |
| 39 | return zustand / 2_147_483_648; |
| 40 | }; |
| 41 | } |
| 42 | |
| 43 | let db: Database.Database; |
| 44 | let lernstand: Lernstand; |
| 45 | let profilId: number; |
| 46 | |
| 47 | beforeEach(() => { |
| 48 | db = new Database(':memory:'); |
| 49 | lernstand = new Lernstand(db, KATALOG); |
| 50 | profilId = lernstand.profile()[0]!.id; |
| 51 | }); |
| 52 | |
| 53 | afterEach(() => { |
| 54 | if (db.open) { |
| 55 | db.close(); |
| 56 | } |
| 57 | }); |
| 58 | |
| 59 | function auftrag(profil: string): Pruefungsauftrag { |
| 60 | return { profilId: profil, zeitmodus: 'normal', kapitelAusschluss: [] }; |
| 61 | } |
| 62 | |
| 63 | function offeneImBogen(saat: number, profil: string, ausschluss: string[] = []): number { |
| 64 | const pruefung = new Pruefung(lernstand, KATALOG, { zufall: zufallMit(saat) }); |
| 65 | const bogen = pruefung.starten(profilId, { ...auftrag(profil), kapitelAusschluss: ausschluss }); |
| 66 | const nachId = new Map(KATALOG.fragen.map((f) => [f.id, f])); |
| 67 | return bogen.fragen.filter((eintrag) => nachId.get(eintrag.frageId)?.typ !== 'mc').length; |
| 68 | } |
| 69 | |
| 70 | describe('Bogen aus dem echten Katalog', () => { |
| 71 | it('enthält im DSB-Profil die zugesagten 20 offenen Fragen', () => { |
| 72 | /* Der Befund war reproduzierbar über mehrere Startwerte – deshalb hier |
| 73 | ebenso mehrere, statt sich auf einen glücklichen zu verlassen. */ |
| 74 | const profil = PRUEFUNGSPROFILE.find((p) => p.id === 'dsb'); |
| 75 | expect(profil?.anteilOffen).toBe(0.2); |
| 76 | expect(profil?.fragenAnzahl).toBe(100); |
| 77 | |
| 78 | for (const saat of [1, 7, 42, 1234, 99_999]) { |
| 79 | expect(offeneImBogen(saat, 'dsb'), `Startwert ${String(saat)}`).toBe(20); |
| 80 | } |
| 81 | }); |
| 82 | |
| 83 | it('hält den Anteil auch, wenn Kapitel IV abgewählt ist', () => { |
| 84 | // Kapitel IV bringt 29 offene Fragen mit; ohne sie wird es enger. |
| 85 | for (const saat of [3, 17, 555]) { |
| 86 | expect(offeneImBogen(saat, 'dsb', ['IV']), `Startwert ${String(saat)}`).toBe(20); |
| 87 | } |
| 88 | }); |
| 89 | |
| 90 | it('zieht für das Standardprofil ausschließlich Multiple Choice', () => { |
| 91 | expect(offeneImBogen(11, 'standard')).toBe(0); |
| 92 | }); |
| 93 | |
| 94 | it('liefert immer die vorgesehene Fragenzahl', () => { |
| 95 | const pruefung = new Pruefung(lernstand, KATALOG, { zufall: zufallMit(5) }); |
| 96 | expect(pruefung.starten(profilId, auftrag('dsb')).fragen).toHaveLength(100); |
| 97 | expect(pruefung.starten(profilId, auftrag('standard')).fragen).toHaveLength(80); |
| 98 | }); |
| 99 | |
| 100 | it('zieht keine Frage doppelt', () => { |
| 101 | const pruefung = new Pruefung(lernstand, KATALOG, { zufall: zufallMit(23) }); |
| 102 | const bogen = pruefung.starten(profilId, auftrag('dsb')); |
| 103 | |
| 104 | expect(new Set(bogen.fragen.map((e) => e.frageId)).size).toBe(bogen.fragen.length); |
| 105 | }); |
| 106 | |
| 107 | /* Der echte Katalog trägt die Themenquoten des DSB-Profils und seine 104 |
| 108 | offenen Fragen; ein Bogen daraus darf keine Abweichung melden. Der Test |
| 109 | hält damit fest, dass das Warnungsfeld nicht bei jedem Lauf anschlägt. */ |
| 110 | it('meldet für den echten Katalog keine Abweichung', () => { |
| 111 | const pruefung = new Pruefung(lernstand, KATALOG, { zufall: zufallMit(5) }); |
| 112 | |
| 113 | expect(pruefung.starten(profilId, auftrag('dsb')).warnungen).toEqual([]); |
| 114 | expect(pruefung.starten(profilId, auftrag('standard')).warnungen).toEqual([]); |
| 115 | }); |
| 116 | }); |
| 117 | |
| 118 | /* |
| 119 | Der Ersatzweg: Steht der Anwendungskern nicht bereit, stellt die Oberfläche |
| 120 | den Bogen selbst aus dem Katalog zusammen. Er wird sonst nie berührt und |
| 121 | wäre deshalb die einzige Stelle, an der ein alter Rückfallwert überlebt – |
| 122 | ein Fehler, der nur bei fehlendem IPC-Kanal aufträte. |
| 123 | */ |
| 124 | describe('Ersatzbogen ohne Anwendungskern', () => { |
| 125 | const standard = PRUEFUNGSPROFILE.find((p) => p.id === 'standard'); |
| 126 | if (!standard) throw new Error('Profil „standard“ fehlt.'); |
| 127 | const nachId = new Map(KATALOG.fragen.map((frage) => [frage.id, frage])); |
| 128 | |
| 129 | it('folgt ohne ausdrückliches optionenMischen der Katalogreihenfolge', () => { |
| 130 | const bogen = bogenAusKatalog(KATALOG, standard, { |
| 131 | profilId: 'standard', |
| 132 | zeitmodus: 'aus', |
| 133 | }); |
| 134 | |
| 135 | expect(bogen.length).toBeGreaterThan(0); |
| 136 | for (const eintrag of bogen) { |
| 137 | const frage = nachId.get(eintrag.frageId); |
| 138 | expect(eintrag.optionsReihenfolge).toEqual((frage?.optionen ?? []).map((o) => o.label)); |
| 139 | } |
| 140 | }); |
| 141 | |
| 142 | /* |
| 143 | Gemessener Befund (27.08.2026, bei Einführung der Abdeckungsmessung): |
| 144 | Der Ersatzweg behandelte Themenquoten und Anteil offener Fragen als |
| 145 | Entweder-oder. Für das DSB-Profil – das einzige mit Quoten, und zugleich |
| 146 | eines mit `anteilOffen: 0.2` – gewann die Quote, und der Anteil verfiel |
| 147 | stillschweigend: Fünf Läufe ergaben 19, 11, 11, 12 und 13 offene Fragen |
| 148 | statt der zugesagten 20. Die Profilbeschreibung verspricht „höchstens |
| 149 | 80 Prozent Multiple Choice"; der Anwendungskern hält das seit dem |
| 150 | 18-statt-20-Befund oben, der Ersatzweg hielt es nie. |
| 151 | */ |
| 152 | it('hält im DSB-Profil auch ersatzweise den Anteil offener Fragen', () => { |
| 153 | const dsb = PRUEFUNGSPROFILE.find((p) => p.id === 'dsb'); |
| 154 | if (!dsb) throw new Error('Profil „dsb“ fehlt.'); |
| 155 | |
| 156 | /* Der Ersatzweg mischt ohne einstellbaren Zufall – deshalb mehrere Läufe, |
| 157 | damit nicht ein glücklicher Einzelfall die Zusicherung trägt. */ |
| 158 | for (let lauf = 1; lauf <= 10; lauf += 1) { |
| 159 | const bogen = bogenAusKatalog(KATALOG, dsb, { profilId: 'dsb', zeitmodus: 'aus' }); |
| 160 | const offene = bogen.filter((e) => nachId.get(e.frageId)?.typ !== 'mc').length; |
| 161 | |
| 162 | expect(bogen, `Lauf ${String(lauf)}`).toHaveLength(100); |
| 163 | expect(offene, `Lauf ${String(lauf)}: offene Fragen`).toBe(20); |
| 164 | } |
| 165 | }); |
| 166 | |
| 167 | /* Ohne Quoten greift der andere Zweig – er war ebenfalls unbelegt. */ |
| 168 | it('hält den Anteil auch im Profil ohne Themenquoten', () => { |
| 169 | const fehlerpunkte = PRUEFUNGSPROFILE.find((p) => p.id === 'fehlerpunkte'); |
| 170 | if (!fehlerpunkte) throw new Error('Profil „fehlerpunkte“ fehlt.'); |
| 171 | |
| 172 | for (let lauf = 1; lauf <= 5; lauf += 1) { |
| 173 | const bogen = bogenAusKatalog(KATALOG, fehlerpunkte, { |
| 174 | profilId: 'fehlerpunkte', |
| 175 | zeitmodus: 'aus', |
| 176 | }); |
| 177 | const offene = bogen.filter((e) => nachId.get(e.frageId)?.typ !== 'mc').length; |
| 178 | |
| 179 | expect(bogen, `Lauf ${String(lauf)}`).toHaveLength(75); |
| 180 | // 15 Prozent von 75 Fragen, kaufmännisch gerundet. |
| 181 | expect(offene, `Lauf ${String(lauf)}: offene Fragen`).toBe(11); |
| 182 | } |
| 183 | }); |
| 184 | |
| 185 | /* Die Themenquoten selbst dürfen über der Anteilsrechnung nicht leiden. */ |
| 186 | it('bedient jeden Themenblock des DSB-Profils in der vorgesehenen Stärke', () => { |
| 187 | const dsb = PRUEFUNGSPROFILE.find((p) => p.id === 'dsb'); |
| 188 | if (!dsb?.themenquoten) throw new Error('Themenquoten des DSB-Profils fehlen.'); |
| 189 | |
| 190 | const bogen = bogenAusKatalog(KATALOG, dsb, { profilId: 'dsb', zeitmodus: 'aus' }); |
| 191 | const fragen = bogen.map((e) => nachId.get(e.frageId)).filter((f) => f !== undefined); |
| 192 | |
| 193 | for (const [bereich, menge] of Object.entries(dsb.themenquoten)) { |
| 194 | const treffer = fragen.filter((f) => gehoertZuBereich(f, bereich)).length; |
| 195 | expect(treffer, `Bereich ${bereich}`).toBe(menge); |
| 196 | } |
| 197 | }); |
| 198 | |
| 199 | it('mischt sie auf ausdrücklichen Wunsch', () => { |
| 200 | const bogen = bogenAusKatalog(KATALOG, standard, { |
| 201 | profilId: 'standard', |
| 202 | zeitmodus: 'aus', |
| 203 | optionenMischen: true, |
| 204 | }); |
| 205 | let mindestensEinmalGemischt = false; |
| 206 | |
| 207 | for (const eintrag of bogen) { |
| 208 | const labels = (nachId.get(eintrag.frageId)?.optionen ?? []).map((o) => o.label); |
| 209 | |
| 210 | // Keine Option geht verloren, gleich in welcher Reihenfolge. |
| 211 | expect([...eintrag.optionsReihenfolge].sort()).toEqual([...labels].sort()); |
| 212 | if (eintrag.optionsReihenfolge.join('') !== labels.join('')) { |
| 213 | mindestensEinmalGemischt = true; |
| 214 | } |
| 215 | } |
| 216 | |
| 217 | expect(mindestensEinmalGemischt).toBe(true); |
| 218 | }); |
| 219 | }); |