waffensachkunde

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

/ app tests bogen-echt.test.ts

8,9 KB Rohdatei
app/tests/bogen-echt.test.ts — 219 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 { 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 });