waffensachkunde

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

/ app tests pruefung.test.ts

58,2 KB Rohdatei
app/tests/pruefung.test.ts — 1625 Zeilen
1 // @vitest-environment node
2 // Die Prüfungssimulation läuft im Main-Prozess gegen echtes SQLite. Getestet
3 // wird gegen eine `:memory:`-Datenbank – dieselbe Bibliothek, dasselbe Schema,
4 // nur ohne Datei auf der Platte. Es wird nichts nachgebildet: gezogen wird aus
5 // einem echten Katalog, ausgewertet mit den echten Regeln des Vertrags.
6
7 import Database from 'better-sqlite3';
8 import { afterEach, beforeEach, describe, expect, it } from 'vitest';
9
10 import { Lernstand } from '../src/main/lernstand';
11 import { Pruefung } from '../src/main/pruefung';
12 import { SCHEMA_VERSION } from '../src/main/schema';
13 import {
14 richtigeLabels,
15 type Antwortoption,
16 type Frage,
17 type Katalog,
18 } from '../src/shared/katalog';
19 import {
20 profilFinden,
21 urteilBilden,
22 type Pruefungsantwort,
23 type Pruefungsauftrag,
24 type Pruefungsfrage,
25 } from '../src/shared/pruefung';
26
27 // ─── Prüfkatalog ────────────────────────────────────────────────────────────
28 // Aufbau wie im amtlichen Katalog: Kapitel I mit fünf Abschnitten, die
29 // Kapitel II bis IV ohne Abschnitte. Groß genug für die Themenquoten des
30 // DSB-Profils (bis zu 20 Fragen je Bereich, davon ein Fünftel offen).
31
32 /** Bereiche als Paar aus Kapitel und Abschnitt; `null` heißt „ohne Abschnitt“. */
33 const BEREICHE: readonly (readonly [string, string | null])[] = [
34 ['I', 'I.1'],
35 ['I', 'I.2'],
36 ['I', 'I.3'],
37 ['I', 'I.4'],
38 ['I', 'I.5'],
39 ['II', null],
40 ['III', null],
41 ['IV', null],
42 ];
43
44 const MC_JE_BEREICH = 30;
45 const OFFEN_JE_BEREICH = 8;
46
47 function option(label: string, korrekt: boolean): Antwortoption {
48 return {
49 label,
50 inhalt: { text: `Antwort ${label}`, segmente: [{ t: `Antwort ${label}` }] },
51 korrekt,
52 bilder: [],
53 };
54 }
55
56 function mcFrage(id: string, kapitel: string, abschnitt: string | null, nummer: number): Frage {
57 // Jede dritte Frage hat zwei richtige Antworten – im amtlichen Katalog sind
58 // es 114 von 471, die Mehrfachauswahl darf also kein Sonderfall sein.
59 const korrekt = nummer % 3 === 0 ? ['a', 'c'] : ['b'];
60 return {
61 id,
62 amtliche_nummer: id,
63 kapitel,
64 abschnitt,
65 typ: 'mc',
66 seite: 1,
67 frage: { text: `Frage ${id}`, segmente: [{ t: `Frage ${id}` }] },
68 bilder: [],
69 optionen: ['a', 'b', 'c', 'd'].map((label) => option(label, korrekt.includes(label))),
70 };
71 }
72
73 function offeneFrage(id: string, kapitel: string, abschnitt: string | null): Frage {
74 return {
75 id,
76 amtliche_nummer: id,
77 kapitel,
78 abschnitt,
79 typ: 'freitext',
80 seite: 1,
81 frage: { text: `Frage ${id}`, segmente: [{ t: `Frage ${id}` }] },
82 bilder: [],
83 musterantwort: { text: 'Musterantwort', segmente: [{ t: 'Musterantwort', h: true }] },
84 };
85 }
86
87 function katalogBauen(mcJeBereich: number, offenJeBereich: number): Katalog {
88 const fragen: Frage[] = [];
89 for (const [kapitel, abschnitt] of BEREICHE) {
90 const bereich = abschnitt ?? kapitel;
91 for (let i = 1; i <= mcJeBereich; i += 1) {
92 fragen.push(mcFrage(`${bereich}-mc-${String(i).padStart(2, '0')}`, kapitel, abschnitt, i));
93 }
94 for (let i = 1; i <= offenJeBereich; i += 1) {
95 fragen.push(
96 offeneFrage(`${bereich}-offen-${String(i).padStart(2, '0')}`, kapitel, abschnitt),
97 );
98 }
99 }
100
101 return {
102 meta: {
103 titel: 'Prüfkatalog',
104 herausgeber: 'Bundesverwaltungsamt',
105 stand: '2024-12-16',
106 quellenangabe: 'Amtlicher Fragenkatalog, Stand 16.12.2024.',
107 quelle_url: 'https://www.bva.bund.de/',
108 quelldatei_sha256: 'a'.repeat(64),
109 fragen_gesamt: fragen.length,
110 },
111 kapitel: [
112 {
113 id: 'I',
114 titel: 'Waffenrecht und sonstige Rechtsvorschriften',
115 abschnitte: [
116 { id: 'I.1', titel: 'Begriffe des Waffenrechts' },
117 { id: 'I.2', titel: 'Rechte und Pflichten' },
118 { id: 'I.3', titel: 'Aufbewahrung' },
119 { id: 'I.4', titel: 'Führen und Transport' },
120 { id: 'I.5', titel: 'Notwehr und Notstand' },
121 ],
122 },
123 { id: 'II', titel: 'Waffentechnik', abschnitte: [] },
124 { id: 'III', titel: 'Handhabung von Schusswaffen', abschnitte: [] },
125 { id: 'IV', titel: 'Not- und Seenotsignalmittel', abschnitte: [] },
126 ],
127 bilder: [],
128 fragen,
129 };
130 }
131
132 const KATALOG = katalogBauen(MC_JE_BEREICH, OFFEN_JE_BEREICH);
133
134 /** Bereich einer Frage, so wie die Auswertung ihn bildet. */
135 function bereichVon(frage: Frage): string {
136 return frage.abschnitt ?? frage.kapitel;
137 }
138
139 function frageZu(katalog: Katalog, id: string): Frage {
140 const treffer = katalog.fragen.find((f) => f.id === id);
141 if (treffer === undefined) {
142 throw new Error(`Prüfkatalog kennt die Frage „${id}“ nicht.`);
143 }
144 return treffer;
145 }
146
147 // ─── Prüfstand ──────────────────────────────────────────────────────────────
148
149 const START = '2026-03-01T10:00:00.000Z';
150
151 /**
152 * Reproduzierbarer Zufall (mulberry32). Die Zusammenstellung soll zufällig
153 * sein, ein Test aber trotzdem immer dasselbe Ergebnis liefern.
154 */
155 function zufallsfolge(saat: number): () => number {
156 let zustand = saat >>> 0;
157 return (): number => {
158 zustand = (zustand + 0x6d2b79f5) >>> 0;
159 let t = zustand;
160 t = Math.imul(t ^ (t >>> 15), t | 1);
161 t ^= t + Math.imul(t ^ (t >>> 7), t | 61);
162 return ((t ^ (t >>> 14)) >>> 0) / 4294967296;
163 };
164 }
165
166 let uhr: Date;
167 let db: Database.Database;
168 let lernstand: Lernstand;
169 let pruefung: Pruefung;
170 let profilId: number;
171 let warnungen: string[];
172 let verbindungen: Database.Database[] = [];
173
174 function starten(katalog: Katalog = KATALOG, saat = 7): void {
175 uhr = new Date(START);
176 db = new Database(':memory:');
177 verbindungen.push(db);
178 warnungen = [];
179 lernstand = new Lernstand(db, katalog, { jetzt: () => uhr });
180 profilId = lernstand.profile()[0]!.id;
181 pruefung = new Pruefung(lernstand, katalog, {
182 jetzt: () => uhr,
183 zufall: zufallsfolge(saat),
184 warnen: (meldung) => warnungen.push(meldung),
185 });
186 }
187
188 function auftrag(teil: Partial<Pruefungsauftrag> & { profilId: string }): Pruefungsauftrag {
189 return { zeitmodus: 'aus', ...teil };
190 }
191
192 /**
193 * Zieht einen Bogen und gibt nur seinen Fragensatz zurück.
194 *
195 * `starten` liefert seit dem Warnungsfeld einen {@link Pruefungsbogen}; die
196 * Warnungen prüfen die Fälle, die sie betreffen, ausdrücklich über
197 * `pruefung.starten(...).warnungen`.
198 */
199 function bogenZiehen(profil: number, auftragWert: Pruefungsauftrag): readonly Pruefungsfrage[] {
200 return pruefung.starten(profil, auftragWert).fragen;
201 }
202
203 /** Antwortoption, die bei dieser Frage falsch ist. */
204 function falscheAuswahl(frage: Frage): string[] {
205 const soll = new Set(richtigeLabels(frage));
206 const daneben = (frage.optionen ?? []).map((o) => o.label).find((label) => !soll.has(label));
207 return daneben === undefined ? [] : [daneben];
208 }
209
210 function antwort(frage: Frage, richtig: boolean): Pruefungsantwort {
211 if (frage.typ === 'mc') {
212 return { frageId: frage.id, auswahl: richtig ? richtigeLabels(frage) : falscheAuswahl(frage) };
213 }
214 return {
215 frageId: frage.id,
216 auswahl: [],
217 freitext: 'Sinngemäß richtig formuliert.',
218 selbstAlsRichtig: richtig,
219 };
220 }
221
222 /** Multiple-Choice-Fragen eines Bereichs in Katalogreihenfolge. */
223 function mcAus(bereich: string, anzahl: number, ab = 0): Frage[] {
224 return KATALOG.fragen
225 .filter((f) => f.typ === 'mc' && bereichVon(f) === bereich)
226 .slice(ab, ab + anzahl);
227 }
228
229 /** Bogen mit genau dieser Zahl richtiger und falscher Antworten (alle aus I.1). */
230 function bogen(richtig: number, falsch: number): Pruefungsantwort[] {
231 return mcAus('I.1', richtig + falsch).map((frage, i) => antwort(frage, i < richtig));
232 }
233
234 function auswerten(
235 profilKennung: string,
236 antworten: Pruefungsantwort[],
237 dauerMs = 60_000,
238 ): ReturnType<Pruefung['auswerten']> {
239 return pruefung.auswerten(
240 profilId,
241 auftrag({ profilId: profilKennung }),
242 antworten,
243 dauerMs,
244 false,
245 );
246 }
247
248 beforeEach(() => {
249 verbindungen = [];
250 starten();
251 });
252
253 afterEach(() => {
254 for (const verbindung of verbindungen) {
255 if (verbindung.open) {
256 verbindung.close();
257 }
258 }
259 });
260
261 // ─── Bogen zusammenstellen ──────────────────────────────────────────────────
262
263 describe('pruefung:starten', () => {
264 it('zieht so viele Fragen, wie das Profil vorsieht', () => {
265 expect(bogenZiehen(profilId, auftrag({ profilId: 'standard' }))).toHaveLength(80);
266 expect(bogenZiehen(profilId, auftrag({ profilId: 'dsb' }))).toHaveLength(100);
267 expect(bogenZiehen(profilId, auftrag({ profilId: 'fehlerpunkte' }))).toHaveLength(75);
268 expect(bogenZiehen(profilId, auftrag({ profilId: 'frei' }))).toHaveLength(40);
269 });
270
271 it('nimmt keine Frage zweimal in denselben Bogen', () => {
272 for (const kennung of ['standard', 'dsb', 'bdmp', 'fehlerpunkte']) {
273 const bogenFragen = bogenZiehen(profilId, auftrag({ profilId: kennung }));
274 const ids = new Set(bogenFragen.map((f) => f.frageId));
275
276 expect(ids.size).toBe(bogenFragen.length);
277 }
278 });
279
280 it('mischt die Antwortoptionen auf Wunsch, ohne eine wegzulassen', () => {
281 const bogenFragen = bogenZiehen(
282 profilId,
283 auftrag({ profilId: 'standard', optionenMischen: true }),
284 );
285
286 let mindestensEinmalGemischt = false;
287 for (const eintrag of bogenFragen) {
288 const frage = frageZu(KATALOG, eintrag.frageId);
289 const labels = (frage.optionen ?? []).map((o) => o.label);
290
291 expect([...eintrag.optionsReihenfolge].sort()).toEqual([...labels].sort());
292 if (eintrag.optionsReihenfolge.join('') !== labels.join('')) {
293 mindestensEinmalGemischt = true;
294 }
295 }
296
297 expect(mindestensEinmalGemischt).toBe(true);
298 });
299
300 it('lässt die Antwortoptionen auf Wunsch in Katalogreihenfolge', () => {
301 /* Die Einstellung gilt auch in der Simulation: Wer die Antworten über
302 ihre Stelle behalten muss, bräuchte sie sonst ausgerechnet hier
303 nicht. */
304 const bogenFragen = bogenZiehen(
305 profilId,
306 auftrag({ profilId: 'standard', optionenMischen: false }),
307 );
308
309 for (const eintrag of bogenFragen) {
310 const frage = frageZu(KATALOG, eintrag.frageId);
311
312 expect(eintrag.optionsReihenfolge).toEqual((frage.optionen ?? []).map((o) => o.label));
313 }
314 });
315
316 it('zieht ohne ausdrückliches optionenMischen einen Bogen in Katalogreihenfolge', () => {
317 /* Der Standard ist „nicht mischen“ – gerade die Simulation soll dem
318 gedruckten Bogen nahekommen, und sie zeigt keine Ziffernspalte, die
319 eine verdrehte Buchstabenfolge abmildern könnte. Sichert den
320 Rückfallwert gegen ein späteres Zurückdrehen ab. */
321 const bogenFragen = bogenZiehen(profilId, auftrag({ profilId: 'standard' }));
322
323 for (const eintrag of bogenFragen) {
324 const frage = frageZu(KATALOG, eintrag.frageId);
325
326 expect(eintrag.optionsReihenfolge).toEqual((frage.optionen ?? []).map((o) => o.label));
327 }
328 });
329
330 it('weist ein optionenMischen zurück, das kein Wahrheitswert ist', () => {
331 expect(() =>
332 pruefung.starten(profilId, auftrag({ profilId: 'standard', optionenMischen: 'ja' as never })),
333 ).toThrow(/optionenMischen/u);
334 });
335
336 it('stellt ohne anteilOffen ausschließlich Multiple Choice zusammen', () => {
337 const bogenFragen = bogenZiehen(profilId, auftrag({ profilId: 'standard' }));
338 const typen = new Set(bogenFragen.map((f) => frageZu(KATALOG, f.frageId).typ));
339
340 expect([...typen]).toEqual(['mc']);
341 });
342
343 it('beachtet den Anteil offener Fragen', () => {
344 const bogenFragen = bogenZiehen(profilId, auftrag({ profilId: 'dsb' }));
345 const offene = bogenFragen.filter((f) => frageZu(KATALOG, f.frageId).typ !== 'mc');
346
347 // 20 Prozent von 100 Fragen, verteilt über die Themenquoten.
348 expect(offene).toHaveLength(20);
349 expect(warnungen).toEqual([]);
350 });
351
352 /*
353 „Anteil offener Fragen: 0“ heißt null – auch dann, wenn die Auswahlfragen
354 nicht reichen.
355
356 Der Katalog hat 471 Auswahlfragen und 104 offene. Bis Fassung 0.24.1 legte
357 der Kern bei 500 gewünschten Fragen stillschweigend 29 offene nach,
358 obwohl die Prüfungswahl unmittelbar davor zusagt: „Der Bogen besteht damit
359 ausschließlich aus Auswahlfragen.“ Nach der Abgabe sprang dann unerwartet
360 die Selbstbewertung dazwischen.
361 */
362 it('legt bei null Prozent offenen Fragen keine offenen nach', () => {
363 const bogenFragen = bogenZiehen(
364 profilId,
365 auftrag({ profilId: 'frei', fragenAnzahl: 500, anteilOffen: 0 }),
366 );
367 const offene = bogenFragen.filter((f) => frageZu(KATALOG, f.frageId).typ !== 'mc');
368
369 expect(offene).toEqual([]);
370 expect(warnungen.join(' ')).toMatch(/fehlt|fehlen/u);
371 });
372
373 /*
374 Das frei einstellbare Profil trägt selbst keinen `anteilOffen`. Ohne diese
375 Kette bliebe der Regler in der Oberfläche wirkungslos: Der Kern bildet das
376 wirksame Profil neu und übernahm bis Fassung 0.16.0 nur Fragenzahl, Zeit
377 und Bestehensgrenze.
378 */
379 it('übernimmt den Anteil offener Fragen aus dem Auftrag', () => {
380 const bogenFragen = bogenZiehen(
381 profilId,
382 auftrag({ profilId: 'frei', fragenAnzahl: 20, anteilOffen: 0.25 }),
383 );
384 const offene = bogenFragen.filter((f) => frageZu(KATALOG, f.frageId).typ !== 'mc');
385
386 expect(bogenFragen).toHaveLength(20);
387 expect(offene).toHaveLength(5);
388 expect(warnungen).toEqual([]);
389 });
390
391 it('füllt mit Auswahlfragen auf, wenn der Wunsch den Vorrat übersteigt', () => {
392 /* Der Wunsch ist eine Bitte, keine Zusage – ein Bogen ganz aus offenen
393 Fragen ist im echten Katalog nicht zu haben. Zurechtgebogen wird der
394 Wert trotzdem nicht; stattdessen wird es gesagt. */
395 const bogenFragen = bogenZiehen(
396 profilId,
397 // 8 Bereiche à 8 offene Fragen sind 64 – ein Bogen von 80 ganz aus
398 // offenen Fragen ist damit nicht zu haben.
399 auftrag({ profilId: 'frei', fragenAnzahl: 80, anteilOffen: 1 }),
400 );
401 const offene = bogenFragen.filter((f) => frageZu(KATALOG, f.frageId).typ !== 'mc');
402
403 expect(bogenFragen).toHaveLength(80);
404 expect(offene).toHaveLength(64);
405 expect(warnungen.some((w) => w.includes('durch 16 Auswahlfragen ersetzt'))).toBe(true);
406 });
407
408 /*
409 Die Warnungen gehen nicht mehr nur ins Protokoll: Sie hängen am Bogen und
410 landen damit auf dem Bildschirm (Befund 7.10). Bis Fassung 0.20.0 hielt
411 jeder, der simulierte, seinen Bogen für profilgetreu.
412 */
413 it('reicht dieselben Warnungen mit dem Bogen heraus', () => {
414 const bogen = pruefung.starten(
415 profilId,
416 auftrag({ profilId: 'frei', fragenAnzahl: 80, anteilOffen: 1 }),
417 );
418
419 expect(bogen.fragen).toHaveLength(80);
420 expect(bogen.warnungen).toEqual(warnungen);
421 expect(bogen.warnungen.some((w) => w.includes('durch 16 Auswahlfragen ersetzt'))).toBe(true);
422 });
423
424 it('gibt einen unauffälligen Bogen ohne Warnungen heraus', () => {
425 const bogen = pruefung.starten(profilId, auftrag({ profilId: 'standard' }));
426
427 expect(bogen.fragen).toHaveLength(80);
428 expect(bogen.warnungen).toEqual([]);
429 });
430
431 /* Die Sätze stehen auf dem Bildschirm. Ein Feldname aus dem Vertrag wäre
432 dort eine Zumutung, „Frage(n)“ ebenso. */
433 it('formuliert die Warnungen ohne Feldnamen und ohne Klammerformen', () => {
434 const bogen = pruefung.starten(
435 profilId,
436 auftrag({ profilId: 'standard', anteilOffen: 0.5, fragenAnzahl: 12 }),
437 );
438
439 expect(bogen.warnungen.length).toBeGreaterThan(0);
440 const alles = bogen.warnungen.join(' ');
441
442 expect(alles).toContain('der Anteil offener Fragen');
443 expect(alles).toContain('die Fragenzahl');
444 expect(alles).not.toContain('anteilOffen');
445 expect(alles).not.toContain('fragenAnzahl');
446 expect(alles).not.toContain('(n)');
447 });
448
449 /* Der Bogen wird je Themenbereich gezogen; ohne Entdoppelung stünde
450 derselbe Satz achtmal untereinander auf dem Bildschirm. */
451 it('sammelt gleichlautende Warnungen nur einmal', () => {
452 const knapp = katalogBauen(5, 0);
453 starten(knapp, 3);
454
455 const bogen = pruefung.starten(profilId, auftrag({ profilId: 'dsb', anteilOffen: 0.5 }));
456
457 expect(bogen.warnungen.length).toBe(new Set(bogen.warnungen).size);
458 });
459
460 it('weist einen Anteil außerhalb von 0 bis 1 ab', () => {
461 expect(() =>
462 pruefung.starten(profilId, auftrag({ profilId: 'frei', anteilOffen: 1.5 })),
463 ).toThrow(/anteilOffen/u);
464 expect(() =>
465 pruefung.starten(profilId, auftrag({ profilId: 'frei', anteilOffen: -0.1 })),
466 ).toThrow(/anteilOffen/u);
467 expect(() =>
468 pruefung.starten(profilId, auftrag({ profilId: 'frei', anteilOffen: 'viel' as never })),
469 ).toThrow(/anteilOffen/u);
470 });
471
472 it('übernimmt den Anteil nicht bei festen Profilen und sagt es', () => {
473 const bogenFragen = bogenZiehen(profilId, auftrag({ profilId: 'standard', anteilOffen: 0.5 }));
474 const typen = new Set(bogenFragen.map((f) => frageZu(KATALOG, f.frageId).typ));
475
476 expect([...typen]).toEqual(['mc']);
477 expect(warnungen.some((w) => w.includes('der Anteil offener Fragen'))).toBe(true);
478 });
479
480 it('hält die Themenquoten des Profils ein', () => {
481 const quoten = profilFinden('dsb')?.themenquoten ?? {};
482 const bogenFragen = bogenZiehen(profilId, auftrag({ profilId: 'dsb' }));
483
484 const gezaehlt = new Map<string, number>();
485 for (const eintrag of bogenFragen) {
486 const bereich = bereichVon(frageZu(KATALOG, eintrag.frageId));
487 gezaehlt.set(bereich, (gezaehlt.get(bereich) ?? 0) + 1);
488 }
489
490 expect(Object.fromEntries(gezaehlt)).toEqual(quoten);
491 });
492
493 it('füllt einen zu kleinen Themenbereich aus den übrigen auf und protokolliert das', () => {
494 // Nur 6 Fragen je Bereich – die DSB-Quote von 20 für „I.2“ ist damit
495 // nicht zu erfüllen.
496 const knapp = katalogBauen(5, 1);
497 starten(knapp, 3);
498
499 const bogenFragen = bogenZiehen(profilId, auftrag({ profilId: 'dsb' }));
500 const ids = new Set(bogenFragen.map((f) => f.frageId));
501
502 // 8 Bereiche à 6 Fragen = 48; mehr gibt der Katalog nicht her.
503 expect(bogenFragen).toHaveLength(48);
504 expect(ids.size).toBe(48);
505 /* Mit Titel statt nur mit der Kennung: „I.2“ allein sagt niemandem etwas,
506 der nicht den Aufbau des Katalogs im Kopf hat. */
507 expect(warnungen.some((w) => w.includes('Themenbereich „I.2 – Rechte und Pflichten“'))).toBe(
508 true,
509 );
510 expect(warnungen.some((w) => w.includes('stehen aber nur 48'))).toBe(true);
511 });
512
513 it('lässt ausgeschlossene Kapitel weg', () => {
514 const bogenFragen = bogenZiehen(
515 profilId,
516 auftrag({ profilId: 'standard', kapitelAusschluss: ['IV'] }),
517 );
518 const kapitel = new Set(bogenFragen.map((f) => frageZu(KATALOG, f.frageId).kapitel));
519
520 expect(bogenFragen).toHaveLength(80);
521 expect(kapitel.has('IV')).toBe(false);
522 });
523
524 it('übernimmt Überschreibungen nur bei anpassbaren Profilen', () => {
525 const frei = bogenZiehen(profilId, auftrag({ profilId: 'frei', fragenAnzahl: 12 }));
526
527 expect(frei).toHaveLength(12);
528 expect(warnungen).toEqual([]);
529
530 const fest = bogenZiehen(profilId, auftrag({ profilId: 'standard', fragenAnzahl: 12 }));
531
532 expect(fest).toHaveLength(80);
533 expect(warnungen.some((w) => w.includes('hat feste Vorgaben'))).toBe(true);
534 });
535
536 it('weist unbekannte Profile, Zeitmodi, Kapitel und Lernprofile ab', () => {
537 expect(() => pruefung.starten(profilId, auftrag({ profilId: 'phantasie' }))).toThrow(
538 /Unbekanntes Prüfungsprofil/u,
539 );
540 expect(() =>
541 pruefung.starten(profilId, { profilId: 'standard', zeitmodus: 'schneller' as never }),
542 ).toThrow(/zeitmodus/u);
543 expect(() =>
544 pruefung.starten(profilId, auftrag({ profilId: 'standard', kapitelAusschluss: ['XI'] })),
545 ).toThrow(/Unbekanntes Kapitel/u);
546 expect(() => pruefung.starten(999, auftrag({ profilId: 'standard' }))).toThrow(
547 /Unbekanntes Profil/u,
548 );
549 });
550
551 it('weist einen Auftrag ab, der den ganzen Katalog abwählt', () => {
552 expect(() =>
553 pruefung.starten(
554 profilId,
555 auftrag({ profilId: 'standard', kapitelAusschluss: ['I', 'II', 'III', 'IV'] }),
556 ),
557 ).toThrow(/keine einzige Frage/u);
558 });
559 });
560
561 // ─── Auswertung ─────────────────────────────────────────────────────────────
562
563 describe('Bogen und Auswertung gehören zusammen', () => {
564 /*
565 Ohne Abgleich nähme der Kern jede beliebige Antwortliste an: Ein Lauf mit
566 einer einzigen richtigen Antwort landete als „bestanden“ im Verlauf,
567 obwohl der Bogen 80 Fragen hatte.
568 */
569 it('weist eine Antwortliste ab, die nicht zum ausgegebenen Bogen passt', () => {
570 const bogenAusgabe = bogenZiehen(profilId, auftrag({ profilId: 'standard' }));
571 expect(bogenAusgabe.length).toBeGreaterThan(1);
572
573 const eine = KATALOG.fragen.find((f) => f.id === bogenAusgabe[0]?.frageId)!;
574
575 expect(() =>
576 pruefung.auswerten(
577 profilId,
578 auftrag({ profilId: 'standard' }),
579 [antwort(eine, true)],
580 60_000,
581 false,
582 ),
583 ).toThrow(/umfasste .* Fragen/u);
584 });
585
586 it('weist eine Frage ab, die nicht im Bogen stand', () => {
587 const bogenAusgabe = bogenZiehen(profilId, auftrag({ profilId: 'frei', fragenAnzahl: 3 }));
588 const imBogen = new Set(bogenAusgabe.map((f) => f.frageId));
589 const fremd = KATALOG.fragen.find((f) => !imBogen.has(f.id) && f.typ === 'mc')!;
590
591 const antworten = bogenAusgabe.slice(0, 2).map((eintrag) => {
592 const frage = KATALOG.fragen.find((f) => f.id === eintrag.frageId)!;
593 return antwort(frage, true);
594 });
595 antworten.push(antwort(fremd, true));
596
597 expect(() =>
598 pruefung.auswerten(
599 profilId,
600 auftrag({ profilId: 'frei', fragenAnzahl: 3 }),
601 antworten,
602 1,
603 false,
604 ),
605 ).toThrow(/nicht Teil des Bogens/u);
606 });
607
608 it('nimmt den passenden Bogen an und wertet ihn genau einmal aus', () => {
609 const bogenAusgabe = bogenZiehen(profilId, auftrag({ profilId: 'frei', fragenAnzahl: 3 }));
610 const antworten = bogenAusgabe.map((eintrag) => {
611 const frage = KATALOG.fragen.find((f) => f.id === eintrag.frageId)!;
612 return antwort(frage, true);
613 });
614
615 const ergebnis = pruefung.auswerten(
616 profilId,
617 auftrag({ profilId: 'frei', fragenAnzahl: 3 }),
618 antworten,
619 1_000,
620 false,
621 );
622 expect(ergebnis.gesamt).toBe(3);
623
624 // Ein zweiter Versuch mit demselben Bogen läuft in den Warnpfad, nicht in
625 // eine erneute Prüfung – der Bogen ist verbraucht.
626 expect(() =>
627 pruefung.auswerten(
628 profilId,
629 auftrag({ profilId: 'frei', fragenAnzahl: 3 }),
630 antworten,
631 1_000,
632 false,
633 ),
634 ).not.toThrow();
635 });
636 });
637
638 describe('pruefung:auswerten', () => {
639 it('rechnet Multiple Choice selbst nach und glaubt der Selbsteinschätzung nicht', () => {
640 const frage = mcAus('I.1', 1)[0]!;
641 const ergebnis = auswerten('standard', [
642 { frageId: frage.id, auswahl: falscheAuswahl(frage), selbstAlsRichtig: true },
643 ]);
644
645 expect(ergebnis.richtig).toBe(0);
646 expect(ergebnis.falsch).toBe(1);
647 expect(ergebnis.fehlerIds).toEqual([frage.id]);
648 });
649
650 it('wertet eine unvollständige Mehrfachauswahl als falsch', () => {
651 const mehrfach = KATALOG.fragen.find((f) => f.typ === 'mc' && richtigeLabels(f).length > 1);
652 const soll = richtigeLabels(mehrfach!);
653 const ergebnis = auswerten('standard', [
654 { frageId: mehrfach!.id, auswahl: [soll[0]!] },
655 { frageId: mcAus('I.2', 1)[0]!.id, auswahl: richtigeLabels(mcAus('I.2', 1)[0]!) },
656 ]);
657
658 expect(soll.length).toBeGreaterThan(1);
659 expect(ergebnis.richtig).toBe(1);
660 expect(ergebnis.fehlerIds).toEqual([mehrfach!.id]);
661 expect(ergebnis.unbeantwortet).toBe(0);
662 });
663
664 it('zählt zu viel Angekreuztes als falsch', () => {
665 const einfach = mcAus('I.1', 1)[0]!;
666 const zuViel = [...richtigeLabels(einfach), ...falscheAuswahl(einfach)];
667 const ergebnis = auswerten('standard', [{ frageId: einfach.id, auswahl: zuViel }]);
668
669 expect(ergebnis.richtig).toBe(0);
670 });
671
672 it('weist unbeantwortete Fragen getrennt aus, ohne sie in „falsch“ mitzuzählen', () => {
673 /*
674 Die drei Zahlen bilden eine Zerlegung von `gesamt`. Steckten die
675 unbeantworteten zusätzlich in `falsch`, zeigte die Auswertung mehr
676 Fragen an, als der Bogen hat. Für die Wertung bleiben sie dennoch
677 nicht richtig – und in `fehlerIds` gehören sie, damit man sie üben kann.
678 */
679 const mc = mcAus('I.1', 3);
680 const offen = KATALOG.fragen.find((f) => f.typ !== 'mc')!;
681 const ergebnis = auswerten('standard', [
682 antwort(mc[0]!, true),
683 antwort(mc[1]!, false),
684 { frageId: mc[2]!.id, auswahl: [] },
685 { frageId: offen.id, auswahl: [] },
686 ]);
687
688 expect(ergebnis.gesamt).toBe(4);
689 expect(ergebnis.richtig).toBe(1);
690 expect(ergebnis.falsch).toBe(1);
691 expect(ergebnis.unbeantwortet).toBe(2);
692 // Die Zerlegung geht auf.
693 expect(ergebnis.richtig + ergebnis.falsch + ergebnis.unbeantwortet).toBe(ergebnis.gesamt);
694 expect(ergebnis.fehlerIds).toEqual([mc[1]!.id, mc[2]!.id, offen.id]);
695 });
696
697 it('wertet offene Fragen nach der Selbsteinschätzung', () => {
698 const offene = KATALOG.fragen.filter((f) => f.typ !== 'mc').slice(0, 2);
699 const ergebnis = auswerten('dsb', [antwort(offene[0]!, true), antwort(offene[1]!, false)]);
700
701 expect(ergebnis.richtig).toBe(1);
702 expect(ergebnis.unbeantwortet).toBe(0);
703 });
704
705 it('füllt die Bereiche je Abschnitt bzw. Kapitel in Katalogreihenfolge', () => {
706 const ergebnis = auswerten('standard', [
707 ...mcAus('I.2', 2).map((f) => antwort(f, true)),
708 ...mcAus('I.1', 2).map((f) => antwort(f, false)),
709 ...mcAus('III', 1).map((f) => antwort(f, true)),
710 ]);
711
712 expect(ergebnis.bereiche).toEqual([
713 { bereich: 'I.1', titel: 'Begriffe des Waffenrechts', gesamt: 2, richtig: 0 },
714 { bereich: 'I.2', titel: 'Rechte und Pflichten', gesamt: 2, richtig: 2 },
715 { bereich: 'III', titel: 'Handhabung von Schusswaffen', gesamt: 1, richtig: 1 },
716 ]);
717 });
718
719 it('lässt das K.-o.-Kriterium unabhängig vom Gesamtergebnis greifen', () => {
720 // Profil „fehlerpunkte“: höchstens 15 Fehler, aber höchstens 2 in I.5.
721 const antworten = [
722 ...mcAus('I.1', 20).map((f) => antwort(f, true)),
723 ...mcAus('I.5', 3).map((f) => antwort(f, false)),
724 ];
725 const ergebnis = auswerten('fehlerpunkte', antworten);
726
727 expect(ergebnis.falsch).toBe(3);
728 expect(ergebnis.urteil).toBe('nicht_bestanden');
729 expect(ergebnis.verletzteKriterien).toEqual([
730 'Notwehr und Notstand (3 Fehler, höchstens 2 zulässig)',
731 ]);
732 expect(ergebnis.begruendung).toContain('Notwehr und Notstand');
733 });
734
735 it('lässt zwei Fehler in Notwehr und Notstand durchgehen', () => {
736 const ergebnis = auswerten('fehlerpunkte', [
737 ...mcAus('I.1', 20).map((f) => antwort(f, true)),
738 ...mcAus('I.5', 2).map((f) => antwort(f, false)),
739 ]);
740
741 expect(ergebnis.verletzteKriterien).toEqual([]);
742 expect(ergebnis.urteil).toBe('bestanden');
743 });
744
745 it.each([
746 ['standard', 16, 4, 'bestanden'],
747 ['standard', 15, 5, 'nicht_bestanden'],
748 ['bdmp', 14, 6, 'bestanden'],
749 ['bdmp', 13, 7, 'nicht_bestanden'],
750 ['dsb', 15, 5, 'bestanden'],
751 ['dsb', 13, 7, 'nachpruefung'],
752 ['dsb', 12, 8, 'nachpruefung'],
753 ['dsb', 11, 9, 'nicht_bestanden'],
754 ['fehlerpunkte', 15, 5, 'bestanden'],
755 ['fehlerpunkte', 4, 16, 'nicht_bestanden'],
756 ])('bildet für %s bei %i von 20 richtigen das Urteil %s', (kennung, richtig, falsch, urteil) => {
757 const ergebnis = auswerten(kennung, bogen(richtig, falsch));
758
759 expect(ergebnis.gesamt).toBe(20);
760 expect(ergebnis.richtig).toBe(richtig);
761 expect(ergebnis.quote).toBeCloseTo(richtig / 20, 10);
762 expect(ergebnis.urteil).toBe(urteil);
763 });
764
765 it('baut die Begründung aus Trefferzahlen, nicht aus gerundeten Prozenten', () => {
766 /* 149 von 200 sind 74,5 Prozent – kaufmännisch gerundet 75. Aus
767 Prozentwerten gebaut lautete die Anzeige „Nicht bestanden. 75 Prozent
768 richtig, nötig waren 75 Prozent." Ein Satz, der sich selbst
769 widerspricht. */
770 const knapp = urteilBilden(profilFinden('frei')!, 149, 200, []);
771 expect(knapp.urteil).toBe('nicht_bestanden');
772 expect(knapp.begruendung).toBe('149 von 200 richtig, nötig waren 150.');
773
774 const gerade = urteilBilden(profilFinden('frei')!, 150, 200, []);
775 expect(gerade.urteil).toBe('bestanden');
776 expect(gerade.begruendung).toBe('150 von 200 richtig, nötig waren 150.');
777 });
778
779 it('rundet die nötige Trefferzahl nicht durch Gleitkomma hoch', () => {
780 // 0,8 * 80 kann als 64,000000000000006 herauskommen – aufgerundet 65.
781 const ergebnis = urteilBilden(profilFinden('standard')!, 64, 80, []);
782
783 expect(ergebnis.urteil).toBe('bestanden');
784 expect(ergebnis.begruendung).toBe('64 von 80 richtig, nötig waren 64.');
785 });
786
787 it('nennt die Grauzone des DSB-Profils in der Begründung', () => {
788 const ergebnis = auswerten('dsb', bogen(13, 7));
789
790 expect(ergebnis.urteil).toBe('nachpruefung');
791 expect(ergebnis.begruendung).toContain('mündliche Nachprüfung');
792 });
793
794 it('rechnet die Bestehensgrenze eines anpassbaren Profils nach', () => {
795 const streng = pruefung.auswerten(
796 profilId,
797 auftrag({ profilId: 'frei', bestehensQuote: 0.9 }),
798 bogen(16, 4),
799 1000,
800 false,
801 );
802
803 expect(streng.urteil).toBe('nicht_bestanden');
804
805 const milde = pruefung.auswerten(
806 profilId,
807 auftrag({ profilId: 'frei', bestehensQuote: 0.5 }),
808 bogen(16, 4),
809 1000,
810 false,
811 );
812
813 expect(milde.urteil).toBe('bestanden');
814 });
815
816 it('übernimmt Dauer und Zeitangabe unverändert', () => {
817 const ergebnis = pruefung.auswerten(
818 profilId,
819 auftrag({ profilId: 'standard' }),
820 bogen(10, 10),
821 1_234_567,
822 true,
823 );
824
825 expect(ergebnis.dauerMs).toBe(1_234_567);
826 expect(ergebnis.zeitAbgelaufen).toBe(true);
827 expect(ergebnis.zeitpunkt).toBe(START);
828 expect(ergebnis.profilId).toBe('standard');
829 });
830
831 it('weist unsaubere Nutzlasten ab', () => {
832 const frage = mcAus('I.1', 1)[0]!;
833
834 expect(() => auswerten('standard', [])).toThrow(/mindestens eine Antwort/u);
835 expect(() => auswerten('standard', [antwort(frage, true), antwort(frage, true)])).toThrow(
836 /mehrfach im Bogen/u,
837 );
838 expect(() => auswerten('standard', [{ frageId: 'gibt-es-nicht', auswahl: [] }])).toThrow(
839 /Unbekannte Frage-ID/u,
840 );
841 expect(() => auswerten('standard', [{ frageId: frage.id, auswahl: ['z'] }])).toThrow(
842 /keine Antwortoption/u,
843 );
844 expect(() =>
845 pruefung.auswerten(profilId, auftrag({ profilId: 'standard' }), bogen(1, 0), -5, false),
846 ).toThrow(/dauerMs/u);
847 expect(() =>
848 pruefung.auswerten(profilId, auftrag({ profilId: 'standard' }), bogen(1, 0), 'lange', false),
849 ).toThrow(/dauerMs/u);
850 });
851
852 it('lässt die Wiedervorlage nie aufgeschlagener Fragen unberührt', () => {
853 /*
854 Läuft die Zeit ab, schickt die Oberfläche auch für nie gezeigte Fragen
855 einen Eintrag mit leerer Auswahl. Diese gehören in die Historie – der
856 Bogen enthielt sie ja –, dürfen den Lernstand aber nicht zurückstufen:
857 Eine viermal sicher gewusste Frage wäre sonst allein deshalb wieder
858 fällig, weil ein Prüfungslauf nicht bis zu ihr kam.
859 */
860 const [gezeigt, nieGezeigt] = mcAus('I.1', 2);
861 if (!gezeigt || !nieGezeigt) {
862 throw new Error('Zwei Fragen erwartet');
863 }
864
865 // Die zweite Frage viermal sicher beantworten, damit sie weit hinausrückt.
866 for (let i = 0; i < 4; i += 1) {
867 lernstand.antworten(profilId, {
868 frageId: nieGezeigt.id,
869 auswahl: nieGezeigt.optionen?.filter((o) => o.korrekt).map((o) => o.label) ?? [],
870 richtig: true,
871 bewertung: 'leicht',
872 dauerMs: 1_000,
873 });
874 }
875 const vorher = lernstand.frageStand(profilId, nieGezeigt.id);
876 expect(vorher.faelligAb).not.toBeNull();
877
878 pruefung.auswerten(
879 profilId,
880 auftrag({ profilId: 'standard' }),
881 [antwort(gezeigt, true), { frageId: nieGezeigt.id, auswahl: [] }],
882 60_000,
883 true,
884 );
885
886 const nachher = lernstand.frageStand(profilId, nieGezeigt.id);
887 expect(nachher.faelligAb).toBe(vorher.faelligAb);
888 expect(nachher.letzteBewertung).toBe('leicht');
889 expect(nachher.versuche).toBe(vorher.versuche);
890 });
891
892 it('kappt eine übergroße Dauer, statt den Lauf zu verwerfen', () => {
893 /*
894 Wer ohne Zeitbegrenzung übt – der Nachteilsausgleich für alle, die mehr
895 Zeit brauchen – lässt das Fenster womöglich über Nacht offen. Diesen
896 Lauf zu verwerfen träfe genau die Gruppe, für die die Einstellung da
897 ist. Der Lauf wird gespeichert, die Dauer auf 24 Stunden gedeckelt.
898 */
899 const ergebnis = pruefung.auswerten(
900 profilId,
901 auftrag({ profilId: 'standard' }),
902 bogen(3, 1),
903 90_000_000,
904 false,
905 );
906
907 expect(ergebnis.dauerMs).toBe(86_400_000);
908 expect(ergebnis.richtig).toBe(3);
909
910 const gespeichert = pruefung.verlauf(profilId);
911 expect(gespeichert).toHaveLength(1);
912 expect(gespeichert[0]?.gesamt).toBe(4);
913 });
914
915 it('rundet eine gemessene Dauer mit Nachkommastellen, statt sie abzuweisen', () => {
916 const ergebnis = pruefung.auswerten(
917 profilId,
918 auftrag({ profilId: 'standard' }),
919 bogen(1, 0),
920 1234.6,
921 false,
922 );
923
924 expect(ergebnis.dauerMs).toBe(1235);
925 });
926 });
927
928 // ─── Fortschreibung des Lernstands ──────────────────────────────────────────
929
930 describe('Prüfungslauf im Lernstand', () => {
931 it('protokolliert jede Antwort wie eine Lernantwort', () => {
932 const antworten = bogen(3, 2);
933 auswerten('standard', antworten, 50_000);
934
935 const anzahl = db
936 .prepare<[number], { n: number }>('SELECT COUNT(*) AS n FROM antwort_log WHERE profil_id = ?')
937 .get(profilId);
938
939 expect(anzahl?.n).toBe(5);
940 expect(lernstand.uebersicht(profilId).beantwortet).toBe(5);
941 expect(lernstand.frageStand(profilId, antworten[0]!.frageId)).toMatchObject({
942 versuche: 1,
943 richtige: 1,
944 letzteBewertung: 'gut',
945 });
946 expect(lernstand.frageStand(profilId, antworten[4]!.frageId)).toMatchObject({
947 versuche: 1,
948 richtige: 0,
949 letzteBewertung: 'nochmal',
950 });
951 });
952
953 it('verteilt die gemessene Gesamtdauer gleichmäßig auf die Fragen', () => {
954 auswerten('standard', bogen(2, 2), 40_000);
955
956 const dauern = db
957 .prepare<[number], { dauer_ms: number }>(
958 'SELECT dauer_ms FROM antwort_log WHERE profil_id = ?',
959 )
960 .all(profilId)
961 .map((z) => z.dauer_ms);
962
963 expect(dauern).toEqual([10_000, 10_000, 10_000, 10_000]);
964 });
965
966 it('speichert nichts, wenn die Auswertung abbricht', () => {
967 const frage = mcAus('I.1', 1)[0]!;
968
969 expect(() => auswerten('standard', [{ frageId: frage.id, auswahl: ['z'] }])).toThrow();
970 expect(pruefung.verlauf(profilId)).toEqual([]);
971 expect(lernstand.uebersicht(profilId).beantwortet).toBe(0);
972 });
973 });
974
975 // ─── Verlauf ────────────────────────────────────────────────────────────────
976
977 // ─── Unterbrochener Lauf ────────────────────────────────────────────────────
978
979 describe('Unterbrochener Lauf', () => {
980 /** Vollständige Antwortliste zum gerade gestarteten Bogen. */
981 function bogenAntworten(bogenListe: readonly { frageId: string }[]): Pruefungsantwort[] {
982 return bogenListe.map((eintrag) => {
983 const frage = KATALOG.fragen.find((f) => f.id === eintrag.frageId)!;
984 return antwort(frage, true);
985 });
986 }
987
988 function offeneZeilen(): number {
989 return (db.prepare('SELECT COUNT(*) AS n FROM pruefung_offen').get() as { n: number }).n;
990 }
991
992 it('legt den Bogen schon beim Starten ab, nicht erst beim ersten Sichern', () => {
993 /* Stürbe das Programm in der ersten Sekunde, wäre der Bogen sonst trotz
994 aller Vorsorge weg. */
995 const gezogen = bogenZiehen(profilId, auftrag({ profilId: 'standard' }));
996
997 const offen = pruefung.offenerBogen(profilId);
998 expect(offen).not.toBeNull();
999 expect(offen?.bogen.map((e) => e.frageId)).toEqual(gezogen.map((e) => e.frageId));
1000 expect(offen?.position).toBe(0);
1001 expect(offen?.verbrauchtMs).toBe(0);
1002 expect(offen?.phase).toBe('bearbeiten');
1003 expect(offen?.profil.id).toBe('standard');
1004 });
1005
1006 it('sichert Eingaben, Position, Phase und verbrauchte Zeit', () => {
1007 const gezogen = bogenZiehen(profilId, auftrag({ profilId: 'standard' }));
1008 const erste = gezogen[0]!.frageId;
1009
1010 expect(
1011 pruefung.sichern(profilId, {
1012 eingaben: { [erste]: { auswahl: ['a'], freitext: '' } },
1013 position: 5,
1014 phase: 'bearbeiten',
1015 zeitAbgelaufen: false,
1016 verbrauchtMs: 90_000,
1017 }),
1018 ).toBe(true);
1019
1020 const offen = pruefung.offenerBogen(profilId);
1021 expect(offen?.eingaben[erste]).toEqual({ auswahl: ['a'], freitext: '' });
1022 expect(offen?.position).toBe(5);
1023 expect(offen?.verbrauchtMs).toBe(90_000);
1024 });
1025
1026 it('unterscheidet nicht bewertet von als falsch bewertet', () => {
1027 /* Ein Ersatzwert `false` schriebe eine unbewertete offene Frage beim
1028 Fortsetzen als falsch fest. */
1029 const gezogen = bogenZiehen(profilId, auftrag({ profilId: 'dsb' }));
1030 const a = gezogen[0]!.frageId;
1031 const b = gezogen[1]!.frageId;
1032
1033 pruefung.sichern(profilId, {
1034 eingaben: {
1035 [a]: { auswahl: [], freitext: 'Text', selbst: false },
1036 [b]: { auswahl: [], freitext: 'Text' },
1037 },
1038 position: 0,
1039 phase: 'nachbewertung',
1040 zeitAbgelaufen: false,
1041 verbrauchtMs: 0,
1042 });
1043
1044 const offen = pruefung.offenerBogen(profilId);
1045 expect(offen?.eingaben[a]?.selbst).toBe(false);
1046 expect(offen?.eingaben[b]).not.toHaveProperty('selbst');
1047 expect(offen?.phase).toBe('nachbewertung');
1048 });
1049
1050 it('löscht die Zeile beim Auswerten – in derselben Transaktion', () => {
1051 const gezogen = bogenZiehen(profilId, auftrag({ profilId: 'standard' }));
1052 expect(offeneZeilen()).toBe(1);
1053
1054 auswerten('standard', bogenAntworten(gezogen));
1055
1056 expect(offeneZeilen()).toBe(0);
1057 expect(pruefung.offenerBogen(profilId)).toBeNull();
1058 });
1059
1060 it('lässt eine verspätete Sicherung den gewerteten Lauf nicht wiederbeleben', () => {
1061 /*
1062 Der schwerste Fall: Das Sichern ist entprellt. Feuert ein Nachzügler
1063 nach der Abgabe, dürfte er die eben gelöschte Zeile nicht wieder
1064 anlegen – sonst böte die Anwendung beim nächsten Start dieselbe,
1065 bereits gewertete Prüfung zum Fortsetzen an, und jede Antwort ginge
1066 ein zweites Mal in die Wiedervorlage.
1067 */
1068 const gezogen = bogenZiehen(profilId, auftrag({ profilId: 'standard' }));
1069 auswerten('standard', bogenAntworten(gezogen));
1070
1071 const angekommen = pruefung.sichern(profilId, {
1072 eingaben: {},
1073 position: 3,
1074 phase: 'bearbeiten',
1075 zeitAbgelaufen: false,
1076 verbrauchtMs: 120_000,
1077 });
1078
1079 expect(angekommen).toBe(false);
1080 expect(offeneZeilen()).toBe(0);
1081 });
1082
1083 it('hinterlässt nach der Auswertung genau einen Verlaufseintrag', () => {
1084 const gezogen = bogenZiehen(profilId, auftrag({ profilId: 'standard' }));
1085 auswerten('standard', bogenAntworten(gezogen));
1086
1087 expect(pruefung.offenerBogen(profilId)).toBeNull();
1088 expect((db.prepare('SELECT COUNT(*) AS n FROM pruefung_lauf').get() as { n: number }).n).toBe(
1089 1,
1090 );
1091 });
1092
1093 it('sichert nicht in einen Lauf, der inzwischen ein anderer ist', () => {
1094 /*
1095 Dafuer ist die Lauf-Kennung da, und nur dafuer: Die Zeile existiert
1096 noch, gehoert aber schon zum naechsten Bogen. Ein Nachzuegler aus dem
1097 alten Lauf duerfte dessen Position und Eingaben nicht in den neuen
1098 schreiben.
1099 */
1100 pruefung.starten(profilId, auftrag({ profilId: 'standard' }));
1101 const alteKennung = (
1102 db.prepare('SELECT lauf_id FROM pruefung_offen').get() as { lauf_id: string }
1103 ).lauf_id;
1104
1105 const zweiter = bogenZiehen(profilId, auftrag({ profilId: 'bdmp' }));
1106 const neueKennung = (
1107 db.prepare('SELECT lauf_id FROM pruefung_offen').get() as { lauf_id: string }
1108 ).lauf_id;
1109 expect(neueKennung).not.toBe(alteKennung);
1110
1111 pruefung.sichern(profilId, {
1112 eingaben: { [zweiter[0]!.frageId]: { auswahl: ['a'], freitext: '' } },
1113 position: 1,
1114 phase: 'bearbeiten',
1115 zeitAbgelaufen: false,
1116 verbrauchtMs: 1000,
1117 });
1118
1119 // Der neue Lauf nimmt seine eigene Sicherung an – die Kennung stimmt.
1120 expect(pruefung.offenerBogen(profilId)?.position).toBe(1);
1121 });
1122
1123 it('verwirft die Zeile auf ausdrücklichen Wunsch', () => {
1124 pruefung.starten(profilId, auftrag({ profilId: 'standard' }));
1125
1126 pruefung.verwerfen(profilId);
1127
1128 expect(pruefung.offenerBogen(profilId)).toBeNull();
1129 expect(offeneZeilen()).toBe(0);
1130 });
1131
1132 it('führt je Profil höchstens einen offenen Lauf', () => {
1133 pruefung.starten(profilId, auftrag({ profilId: 'standard' }));
1134 const zweiter = bogenZiehen(profilId, auftrag({ profilId: 'bdmp' }));
1135
1136 expect(offeneZeilen()).toBe(1);
1137 expect(pruefung.offenerBogen(profilId)?.bogen.map((e) => e.frageId)).toEqual(
1138 zweiter.map((e) => e.frageId),
1139 );
1140 });
1141
1142 it('hält die Läufe verschiedener Profile auseinander', () => {
1143 const zweites = lernstand.profilAnlegen('Zweitprüfling');
1144 pruefung.starten(profilId, auftrag({ profilId: 'standard' }));
1145 pruefung.starten(zweites.id, auftrag({ profilId: 'bdmp' }));
1146
1147 expect(pruefung.offenerBogen(profilId)?.profil.id).toBe('standard');
1148 expect(pruefung.offenerBogen(zweites.id)?.profil.id).toBe('bdmp');
1149 });
1150
1151 it('nimmt das gelöschte Profil mit', () => {
1152 const zweites = lernstand.profilAnlegen('Zweitprüfling');
1153 pruefung.starten(zweites.id, auftrag({ profilId: 'standard' }));
1154
1155 lernstand.profilLoeschen(zweites.id);
1156
1157 expect(offeneZeilen()).toBe(0);
1158 });
1159
1160 it('verschwindet beim vollständigen Zurücksetzen', () => {
1161 /* „Von vorn" und „die halbe Prüfung von gestern liegt noch da" passen
1162 nicht zusammen. */
1163 pruefung.starten(profilId, auftrag({ profilId: 'standard' }));
1164
1165 lernstand.zuruecksetzen(profilId, null);
1166
1167 expect(offeneZeilen()).toBe(0);
1168 });
1169
1170 it('bleibt beim kapitelweisen Zurücksetzen stehen', () => {
1171 /* Ein Bogen geht über den ganzen Katalog und lässt sich nicht
1172 kapitelweise herausrechnen. */
1173 pruefung.starten(profilId, auftrag({ profilId: 'standard' }));
1174
1175 lernstand.zuruecksetzen(profilId, 'I');
1176
1177 expect(offeneZeilen()).toBe(1);
1178 });
1179
1180 it('verwirft einen Bogen, dessen Fragen es nicht mehr gibt', () => {
1181 /* Katalogwechsel zwischen Unterbrechung und Fortsetzen: Eine fehlende
1182 Frage ließe eine Lücke, und die Auswertung zählte gegen eine andere
1183 Gesamtzahl als die Anzeige. */
1184 pruefung.starten(profilId, auftrag({ profilId: 'standard' }));
1185 db.prepare('UPDATE pruefung_offen SET bogen = ?').run(
1186 '[{"frageId":"gibt-es-nicht","optionsReihenfolge":["a"]}]',
1187 );
1188
1189 expect(pruefung.offenerBogen(profilId)).toBeNull();
1190 expect(offeneZeilen()).toBe(0);
1191 expect(warnungen.join(' ')).toMatch(/gibt-es-nicht/u);
1192 });
1193
1194 it('verwirft eine Zeile mit ungültigem Auftrag', () => {
1195 /* Die Datei liegt im userData-Verzeichnis und ist von außen
1196 beschreibbar; ein erfundenes Profil darf nicht in die Wertung. */
1197 pruefung.starten(profilId, auftrag({ profilId: 'standard' }));
1198 db.prepare('UPDATE pruefung_offen SET auftrag = ?').run('{"profilId":"gibt-es-nicht"}');
1199
1200 expect(pruefung.offenerBogen(profilId)).toBeNull();
1201 expect(offeneZeilen()).toBe(0);
1202 });
1203
1204 it('nimmt beim Auswerten den gespeicherten Auftrag, nicht den eingereichten', () => {
1205 /*
1206 Sonst ließe sich ein fortgesetzter Bogen mit einem anderen Profil
1207 abgeben: Bogen und Urteilsregeln kämen aus verschiedenen Läufen.
1208 */
1209 const gezogen = bogenZiehen(profilId, auftrag({ profilId: 'bdmp' }));
1210
1211 const ergebnis = pruefung.auswerten(
1212 profilId,
1213 auftrag({ profilId: 'standard' }),
1214 bogenAntworten(gezogen),
1215 60_000,
1216 false,
1217 );
1218
1219 expect(ergebnis.profilId).toBe('bdmp');
1220 });
1221
1222 it('prüft die eingereichte Liste auch nach einem Neustart', () => {
1223 /*
1224 Bisher galt: Ist der Bogen nicht bekannt – etwa nach einem Neustart –,
1225 wird jede Liste ungeprüft angenommen. Mit der gespeicherten Zeile lässt
1226 sich der Bogen wiederherstellen, und die Prüfung greift wieder.
1227 */
1228 const gezogen = bogenZiehen(profilId, auftrag({ profilId: 'standard' }));
1229 const antworten = bogenAntworten(gezogen);
1230
1231 // Neustart nachbilden: neue Instanz, leerer Speicher, dieselbe Datenbank.
1232 const nachNeustart = new Pruefung(lernstand, KATALOG, {
1233 jetzt: () => uhr,
1234 zufall: zufallsfolge(7),
1235 warnen: (meldung) => warnungen.push(meldung),
1236 });
1237 nachNeustart.offenerBogen(profilId);
1238
1239 expect(() =>
1240 nachNeustart.auswerten(
1241 profilId,
1242 auftrag({ profilId: 'standard' }),
1243 antworten.slice(0, 3),
1244 1000,
1245 false,
1246 ),
1247 ).toThrow(/Bogen umfasste/u);
1248 });
1249
1250 it('weist eine Position außerhalb des Bogens ab', () => {
1251 pruefung.starten(profilId, auftrag({ profilId: 'standard' }));
1252
1253 expect(() =>
1254 pruefung.sichern(profilId, {
1255 eingaben: {},
1256 position: 999,
1257 phase: 'bearbeiten',
1258 zeitAbgelaufen: false,
1259 verbrauchtMs: 0,
1260 }),
1261 ).toThrow(/position/u);
1262 });
1263
1264 it('weist eine Eingabe zu einer fremden Frage ab', () => {
1265 pruefung.starten(profilId, auftrag({ profilId: 'standard' }));
1266
1267 expect(() =>
1268 pruefung.sichern(profilId, {
1269 eingaben: { 'gibt-es-nicht': { auswahl: ['a'], freitext: '' } },
1270 position: 0,
1271 phase: 'bearbeiten',
1272 zeitAbgelaufen: false,
1273 verbrauchtMs: 0,
1274 }),
1275 ).toThrow(/gehört nicht zu diesem Bogen/u);
1276 });
1277
1278 it('kappt einen übergroßen Freitext und eine unsinnige Dauer', () => {
1279 const gezogen = bogenZiehen(profilId, auftrag({ profilId: 'standard' }));
1280 const erste = gezogen[0]!.frageId;
1281
1282 pruefung.sichern(profilId, {
1283 eingaben: { [erste]: { auswahl: [], freitext: 'x'.repeat(9000) } },
1284 position: 0,
1285 phase: 'bearbeiten',
1286 zeitAbgelaufen: false,
1287 verbrauchtMs: 99 * 24 * 60 * 60 * 1000,
1288 });
1289
1290 const offen = pruefung.offenerBogen(profilId);
1291 expect(offen?.eingaben[erste]?.freitext).toHaveLength(4000);
1292 expect(offen?.verbrauchtMs).toBe(24 * 60 * 60 * 1000);
1293 });
1294 });
1295
1296 describe('pruefung:verlauf', () => {
1297 it('liefert die Läufe mit dem neuesten zuerst', () => {
1298 auswerten('standard', bogen(16, 4));
1299 uhr = new Date(new Date(START).getTime() + 3_600_000);
1300 auswerten('dsb', bogen(10, 10));
1301
1302 const verlauf = pruefung.verlauf(profilId);
1303
1304 expect(verlauf.map((l) => l.profilId)).toEqual(['dsb', 'standard']);
1305 expect(verlauf[0]).toMatchObject({
1306 profilName: 'Nach Art des Deutschen Schützenbundes',
1307 gesamt: 20,
1308 richtig: 10,
1309 quote: 0.5,
1310 urteil: 'nicht_bestanden',
1311 });
1312 expect(verlauf[1]).toMatchObject({
1313 profilName: 'Standard',
1314 richtig: 16,
1315 urteil: 'bestanden',
1316 });
1317 expect(verlauf[0]!.id).not.toBe(verlauf[1]!.id);
1318 });
1319
1320 it('hält die Läufe verschiedener Lernprofile auseinander', () => {
1321 const zweites = lernstand.profilAnlegen('Zweite Person');
1322 auswerten('standard', bogen(16, 4));
1323
1324 expect(pruefung.verlauf(profilId)).toHaveLength(1);
1325 expect(pruefung.verlauf(zweites.id)).toEqual([]);
1326 });
1327
1328 it('weist ein unbekanntes Lernprofil ab', () => {
1329 expect(() => pruefung.verlauf(4711)).toThrow(/Unbekanntes Profil/u);
1330 });
1331
1332 it('liefert die Dauer mit, die es seit jeher speichert', () => {
1333 // Die Spalte gibt es seit Schema-Version 2, `verlauf()` las sie bis 0.22.0
1334 // nur nicht aus. Zeitnot ist ein eigenes Durchfallrisiko.
1335 auswerten('standard', bogen(16, 4), 71 * 60_000);
1336
1337 expect(pruefung.verlauf(profilId)[0]?.dauerMs).toBe(71 * 60_000);
1338 });
1339
1340 it('hält fest, wie viele Fragen offen blieben und ob die Zeit ablief', () => {
1341 /* Ohne diese beiden Angaben sähe ein abgelaufener Lauf im Vergleich wie
1342 ein Wissenseinbruch aus: `quote` zählt Unbeantwortete wie falsch
1343 beantwortete. */
1344 const fragen = mcAus('I.1', 4);
1345 pruefung.auswerten(
1346 profilId,
1347 auftrag({ profilId: 'standard' }),
1348 [
1349 antwort(fragen[0]!, true),
1350 antwort(fragen[1]!, true),
1351 { frageId: fragen[2]!.id, auswahl: [] },
1352 { frageId: fragen[3]!.id, auswahl: [] },
1353 ],
1354 60_000,
1355 true,
1356 );
1357
1358 expect(pruefung.verlauf(profilId)[0]).toMatchObject({
1359 unbeantwortet: 2,
1360 zeitAbgelaufen: true,
1361 quote: 0.5,
1362 });
1363 });
1364
1365 it('speichert die Themenanalyse des Laufs mit', () => {
1366 const fragen = [...mcAus('I.1', 2), ...mcAus('I.2', 2)];
1367 pruefung.auswerten(
1368 profilId,
1369 auftrag({ profilId: 'standard' }),
1370 [
1371 antwort(fragen[0]!, true),
1372 antwort(fragen[1]!, false),
1373 antwort(fragen[2]!, true),
1374 antwort(fragen[3]!, true),
1375 ],
1376 60_000,
1377 false,
1378 );
1379
1380 const bereiche = pruefung.verlauf(profilId)[0]?.bereiche;
1381
1382 expect(bereiche).toHaveLength(2);
1383 expect(bereiche?.[0]).toMatchObject({ bereich: 'I.1', gesamt: 2, richtig: 1 });
1384 expect(bereiche?.[1]).toMatchObject({ bereich: 'I.2', gesamt: 2, richtig: 2 });
1385 /* Der Titel reist mit: Der Verlauf muss ihn auch dann noch nennen können,
1386 wenn der Katalog den Abschnitt später anders benennt. */
1387 expect(bereiche?.[0]?.titel).toBe(KATALOG.kapitel[0]?.abschnitte[0]?.titel);
1388 });
1389
1390 it('speichert die Bestehensgrenze des Laufs, nicht die des Vertrags', () => {
1391 /* Beim frei eingestellten Profil sind die gewählten Werte nach dem Lauf
1392 fort. Holte die Anzeige die Grenze hinterher aus dem Vertrag, bekämen
1393 genau diese Läufe eine falsche. */
1394 auswerten('standard', bogen(16, 4));
1395 expect(pruefung.verlauf(profilId)[0]?.bestehensQuote).toBeCloseTo(0.8);
1396
1397 uhr = new Date(new Date(START).getTime() + 3_600_000);
1398 pruefung.auswerten(
1399 profilId,
1400 auftrag({ profilId: 'frei', bestehensQuote: 0.5 }),
1401 bogen(2, 2),
1402 60_000,
1403 false,
1404 );
1405 expect(pruefung.verlauf(profilId)[0]?.bestehensQuote).toBeCloseTo(0.5);
1406 });
1407
1408 it('rechnet eine Fehlerpunktegrenze in eine Quote um', () => {
1409 // 75 Fragen bei höchstens 15 Fehlern sind 60 von 75 – dieselbe Bedingung,
1410 // anders geschrieben. Ein Bogen von 20 Fragen ergibt entsprechend 5 von 20.
1411 pruefung.auswerten(
1412 profilId,
1413 auftrag({ profilId: 'fehlerpunkte' }),
1414 bogen(16, 4),
1415 60_000,
1416 false,
1417 );
1418
1419 expect(pruefung.verlauf(profilId)[0]?.bestehensQuote).toBeCloseTo(0.25);
1420 });
1421
1422 it('nennt die Kennung des gespeicherten Laufs', () => {
1423 // Der Verlauf enthält den eben beendeten Lauf bereits. Ohne die Kennung
1424 // verglichen ihn Auswertung und Anzeige mit sich selbst.
1425 const ergebnis = auswerten('standard', bogen(16, 4));
1426
1427 expect(ergebnis.laufId).toBe(pruefung.verlauf(profilId)[0]?.id);
1428 });
1429
1430 it('übersteht eine beschädigte Themenanalyse, ohne den Verlauf zu verlieren', () => {
1431 /* Die Datei liegt im `userData`-Verzeichnis. Was nicht als Eintrag lesbar
1432 ist, fällt weg – schlimmstenfalls fehlt einem Lauf die Aufschlüsselung,
1433 statt dass die Anzeige ausfällt. */
1434 auswerten('standard', bogen(16, 4));
1435 db.prepare("UPDATE pruefung_lauf SET bereiche = '{kaputt'").run();
1436
1437 const verlauf = pruefung.verlauf(profilId);
1438
1439 expect(verlauf).toHaveLength(1);
1440 expect(verlauf[0]?.bereiche).toBeNull();
1441 });
1442
1443 it('nimmt aus einer halb lesbaren Analyse nur die gültigen Einträge', () => {
1444 auswerten('standard', bogen(16, 4));
1445 db.prepare(
1446 `UPDATE pruefung_lauf SET bereiche = '[
1447 {"bereich":"I.1","titel":"Begriffe","gesamt":4,"richtig":3},
1448 {"bereich":"I.2","titel":"Rechte","gesamt":2,"richtig":5},
1449 {"bereich":"I.3","gesamt":2,"richtig":1},
1450 "unfug"
1451 ]'`,
1452 ).run();
1453
1454 // Mehr richtige als gestellte Fragen, fehlender Titel, kein Objekt: weg.
1455 expect(pruefung.verlauf(profilId)[0]?.bereiche).toEqual([
1456 { bereich: 'I.1', titel: 'Begriffe', gesamt: 4, richtig: 3 },
1457 ]);
1458 });
1459 });
1460
1461 // ─── Migration ──────────────────────────────────────────────────────────────
1462
1463 describe('Migration auf Schema 2', () => {
1464 function tabellen(verbindung: Database.Database): string[] {
1465 return verbindung
1466 .prepare<[], { name: string }>("SELECT name FROM sqlite_master WHERE type = 'table'")
1467 .all()
1468 .map((z) => z.name);
1469 }
1470
1471 it('ergänzt pruefung_lauf, ohne einen bestehenden Lernstand zu verlieren', () => {
1472 const alt = new Database(':memory:');
1473 verbindungen.push(alt);
1474
1475 const vorher = new Lernstand(alt, KATALOG, { jetzt: () => uhr });
1476 const pid = vorher.profile()[0]!.id;
1477 const gelernt = mcAus('I.2', 1)[0]!;
1478 vorher.antworten(pid, {
1479 frageId: gelernt.id,
1480 auswahl: richtigeLabels(gelernt),
1481 richtig: true,
1482 bewertung: 'gut',
1483 dauerMs: 1200,
1484 });
1485 vorher.merken(pid, mcAus('I.3', 1)[0]!.id, true);
1486
1487 // Stand einer Installation mit Schema-Version 1 herstellen.
1488 alt.exec('DROP TABLE pruefung_lauf');
1489 alt.prepare('DELETE FROM schema_version WHERE version > 1').run();
1490
1491 expect(tabellen(alt)).not.toContain('pruefung_lauf');
1492
1493 const nachher = new Lernstand(alt, KATALOG, { jetzt: () => uhr });
1494
1495 expect(tabellen(alt)).toContain('pruefung_lauf');
1496 expect(
1497 alt
1498 .prepare<[], { version: number }>('SELECT MAX(version) AS version FROM schema_version')
1499 .get()?.version,
1500 ).toBe(SCHEMA_VERSION);
1501
1502 // Der bestehende Lernstand ist unversehrt.
1503 expect(nachher.profile()).toHaveLength(1);
1504 expect(nachher.frageStand(pid, gelernt.id)).toMatchObject({ versuche: 1, richtige: 1 });
1505 expect(nachher.frageStand(pid, mcAus('I.3', 1)[0]!.id).gemerkt).toBe(true);
1506 expect(nachher.uebersicht(pid).beantwortet).toBe(1);
1507
1508 // Und die Simulation lässt sich sofort benutzen.
1509 const migriert = new Pruefung(nachher, KATALOG, { jetzt: () => uhr });
1510 migriert.auswerten(pid, auftrag({ profilId: 'standard' }), bogen(16, 4), 1000, false);
1511
1512 expect(migriert.verlauf(pid)).toHaveLength(1);
1513 });
1514
1515 it('ergänzt die Zeitstufe und lässt ältere Läufe ohne sie stehen', () => {
1516 /* Die Zeitstufe eines Laufs von vor Schema-Version 4 ist nicht mehr zu
1517 ermitteln. Sie zu raten wäre schlechter, als sie offen zu lassen –
1518 sonst stünde in der Tabelle eine Angabe, die niemand gemessen hat. */
1519 const alt = new Database(':memory:');
1520 verbindungen.push(alt);
1521
1522 const vorher = new Lernstand(alt, KATALOG, { jetzt: () => uhr });
1523 const pid = vorher.profile()[0]!.id;
1524 const simulation = new Pruefung(vorher, KATALOG, { jetzt: () => uhr });
1525 simulation.auswerten(pid, auftrag({ profilId: 'standard' }), bogen(16, 4), 1000, false);
1526
1527 // Stand vor der Umstellung nachbilden: Spalte leeren, Version zurück.
1528 alt.prepare('UPDATE pruefung_lauf SET zeitmodus = NULL').run();
1529 alt.prepare('DELETE FROM schema_version WHERE version > 3').run();
1530
1531 const nachher = new Lernstand(alt, KATALOG, { jetzt: () => uhr });
1532 const spalten = alt
1533 .prepare<[], { name: string }>('PRAGMA table_info(pruefung_lauf)')
1534 .all()
1535 .map((z) => z.name);
1536 expect(spalten).toContain('zeitmodus');
1537
1538 const migriert = new Pruefung(nachher, KATALOG, { jetzt: () => uhr });
1539 expect(migriert.verlauf(pid)[0]?.zeitmodus).toBeNull();
1540
1541 // Ein neuer Lauf trägt sie dagegen.
1542 migriert.auswerten(
1543 pid,
1544 auftrag({ profilId: 'standard', zeitmodus: 'aus' }),
1545 bogen(16, 4),
1546 1000,
1547 false,
1548 );
1549 expect(migriert.verlauf(pid)[0]?.zeitmodus).toBe('aus');
1550 });
1551
1552 it('ergänzt die Kennzahlen aus Fassung 10 und erfindet sie für alte Läufe nicht', () => {
1553 /* `unbeantwortet = 0` hieße „es blieb nichts offen“ – eine Behauptung über
1554 einen Lauf, von dem niemand weiß, wie er endete. `NULL` heißt „nicht
1555 festgehalten“, und die Oberfläche sagt genau das. */
1556 const alt = new Database(':memory:');
1557 verbindungen.push(alt);
1558
1559 const vorher = new Lernstand(alt, KATALOG, { jetzt: () => uhr });
1560 const pid = vorher.profile()[0]!.id;
1561 const simulation = new Pruefung(vorher, KATALOG, { jetzt: () => uhr });
1562 simulation.auswerten(pid, auftrag({ profilId: 'standard' }), bogen(16, 4), 1000, false);
1563
1564 // Stand vor der Umstellung nachbilden: Spalten leeren, Version zurück.
1565 alt
1566 .prepare(
1567 `UPDATE pruefung_lauf
1568 SET unbeantwortet = NULL, zeit_abgelaufen = NULL,
1569 bereiche = NULL, bestehens_quote = NULL`,
1570 )
1571 .run();
1572 alt.prepare('DELETE FROM schema_version WHERE version > 9').run();
1573
1574 const nachher = new Lernstand(alt, KATALOG, { jetzt: () => uhr });
1575 const migriert = new Pruefung(nachher, KATALOG, { jetzt: () => uhr });
1576
1577 expect(migriert.verlauf(pid)[0]).toMatchObject({
1578 unbeantwortet: null,
1579 zeitAbgelaufen: null,
1580 bereiche: null,
1581 bestehensQuote: null,
1582 // Die Dauer stand schon in Fassung 2 in der Tabelle.
1583 dauerMs: 1000,
1584 });
1585
1586 // Ein neuer Lauf trägt sie dagegen.
1587 uhr = new Date(new Date(START).getTime() + 3_600_000);
1588 migriert.auswerten(pid, auftrag({ profilId: 'standard' }), bogen(16, 4), 1000, false);
1589
1590 expect(migriert.verlauf(pid)[0]).toMatchObject({
1591 unbeantwortet: 0,
1592 zeitAbgelaufen: false,
1593 bestehensQuote: 0.8,
1594 });
1595 expect(migriert.verlauf(pid)[0]?.bereiche).not.toBeNull();
1596 });
1597
1598 it('legt die Spalten aus Fassung 10 auch auf einer Datenbank ohne sie an', () => {
1599 /* Der Schritt muss auch dann durchlaufen, wenn `pruefung_lauf` noch die
1600 alte Gestalt hat – sonst startet die Anwendung überhaupt nicht mehr. */
1601 const alt = new Database(':memory:');
1602 verbindungen.push(alt);
1603
1604 const vorher = new Lernstand(alt, KATALOG, { jetzt: () => uhr });
1605 const pid = vorher.profile()[0]!.id;
1606
1607 alt.exec('DROP TABLE pruefung_lauf');
1608 alt.prepare('DELETE FROM schema_version WHERE version > 1').run();
1609
1610 const nachher = new Lernstand(alt, KATALOG, { jetzt: () => uhr });
1611 const spalten = alt
1612 .prepare<[], { name: string }>('PRAGMA table_info(pruefung_lauf)')
1613 .all()
1614 .map((z) => z.name);
1615
1616 expect(spalten).toEqual(
1617 expect.arrayContaining(['unbeantwortet', 'zeit_abgelaufen', 'bereiche', 'bestehens_quote']),
1618 );
1619
1620 const migriert = new Pruefung(nachher, KATALOG, { jetzt: () => uhr });
1621 migriert.auswerten(pid, auftrag({ profilId: 'standard' }), bogen(16, 4), 1000, false);
1622
1623 expect(migriert.verlauf(pid)[0]?.bestehensQuote).toBeCloseTo(0.8);
1624 });
1625 });