waffensachkunde

Waffensachkunde – Lernsoftware für die Sachkundeprüfung nach § 7 WaffG. Barrierefrei, offline, EUPL-1.2.

/ app tests bogen-echt.test.ts

10,5 KB Rohdatei
app/tests/bogen-echt.test.ts — 271 Zeilen
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 });