waffensachkunde

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

/ app tests pruefung.test.ts

62,0 KB Rohdatei
app/tests/pruefung.test.ts — 1723 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 /*
625 Und wirklich genau einmal.
626
627 Bis 0.27.2 stand hier `not.toThrow()` – der Titel sagte „genau einmal“,
628 geprüft wurde das Gegenteil: dass ein zweiter Aufruf durchgeht. Er ging
629 durch, schrieb einen zweiten Verlaufseintrag und buchte jede Antwort ein
630 zweites Mal in die Wiedervorlage (gemessen: `versuche` 80 → 160).
631 */
632 expect(() =>
633 pruefung.auswerten(
634 profilId,
635 auftrag({ profilId: 'frei', fragenAnzahl: 3 }),
636 antworten,
637 1_000,
638 false,
639 ),
640 ).toThrow(/bereits ausgewertet/u);
641 expect((db.prepare('SELECT COUNT(*) AS n FROM pruefung_lauf').get() as { n: number }).n).toBe(
642 1,
643 );
644 });
645 });
646
647 describe('pruefung:auswerten', () => {
648 it('rechnet Multiple Choice selbst nach und glaubt der Selbsteinschätzung nicht', () => {
649 const frage = mcAus('I.1', 1)[0]!;
650 const ergebnis = auswerten('standard', [
651 { frageId: frage.id, auswahl: falscheAuswahl(frage), selbstAlsRichtig: true },
652 ]);
653
654 expect(ergebnis.richtig).toBe(0);
655 expect(ergebnis.falsch).toBe(1);
656 expect(ergebnis.fehlerIds).toEqual([frage.id]);
657 });
658
659 it('wertet eine unvollständige Mehrfachauswahl als falsch', () => {
660 const mehrfach = KATALOG.fragen.find((f) => f.typ === 'mc' && richtigeLabels(f).length > 1);
661 const soll = richtigeLabels(mehrfach!);
662 const ergebnis = auswerten('standard', [
663 { frageId: mehrfach!.id, auswahl: [soll[0]!] },
664 { frageId: mcAus('I.2', 1)[0]!.id, auswahl: richtigeLabels(mcAus('I.2', 1)[0]!) },
665 ]);
666
667 expect(soll.length).toBeGreaterThan(1);
668 expect(ergebnis.richtig).toBe(1);
669 expect(ergebnis.fehlerIds).toEqual([mehrfach!.id]);
670 expect(ergebnis.unbeantwortet).toBe(0);
671 });
672
673 it('zählt zu viel Angekreuztes als falsch', () => {
674 const einfach = mcAus('I.1', 1)[0]!;
675 const zuViel = [...richtigeLabels(einfach), ...falscheAuswahl(einfach)];
676 const ergebnis = auswerten('standard', [{ frageId: einfach.id, auswahl: zuViel }]);
677
678 expect(ergebnis.richtig).toBe(0);
679 });
680
681 it('weist unbeantwortete Fragen getrennt aus, ohne sie in „falsch“ mitzuzählen', () => {
682 /*
683 Die drei Zahlen bilden eine Zerlegung von `gesamt`. Steckten die
684 unbeantworteten zusätzlich in `falsch`, zeigte die Auswertung mehr
685 Fragen an, als der Bogen hat. Für die Wertung bleiben sie dennoch
686 nicht richtig – und in `fehlerIds` gehören sie, damit man sie üben kann.
687 */
688 const mc = mcAus('I.1', 3);
689 const offen = KATALOG.fragen.find((f) => f.typ !== 'mc')!;
690 const ergebnis = auswerten('standard', [
691 antwort(mc[0]!, true),
692 antwort(mc[1]!, false),
693 { frageId: mc[2]!.id, auswahl: [] },
694 { frageId: offen.id, auswahl: [] },
695 ]);
696
697 expect(ergebnis.gesamt).toBe(4);
698 expect(ergebnis.richtig).toBe(1);
699 expect(ergebnis.falsch).toBe(1);
700 expect(ergebnis.unbeantwortet).toBe(2);
701 // Die Zerlegung geht auf.
702 expect(ergebnis.richtig + ergebnis.falsch + ergebnis.unbeantwortet).toBe(ergebnis.gesamt);
703 expect(ergebnis.fehlerIds).toEqual([mc[1]!.id, mc[2]!.id, offen.id]);
704 });
705
706 it('wertet offene Fragen nach der Selbsteinschätzung', () => {
707 const offene = KATALOG.fragen.filter((f) => f.typ !== 'mc').slice(0, 2);
708 const ergebnis = auswerten('dsb', [antwort(offene[0]!, true), antwort(offene[1]!, false)]);
709
710 expect(ergebnis.richtig).toBe(1);
711 expect(ergebnis.unbeantwortet).toBe(0);
712 });
713
714 it('füllt die Bereiche je Abschnitt bzw. Kapitel in Katalogreihenfolge', () => {
715 const ergebnis = auswerten('standard', [
716 ...mcAus('I.2', 2).map((f) => antwort(f, true)),
717 ...mcAus('I.1', 2).map((f) => antwort(f, false)),
718 ...mcAus('III', 1).map((f) => antwort(f, true)),
719 ]);
720
721 expect(ergebnis.bereiche).toEqual([
722 { bereich: 'I.1', titel: 'Begriffe des Waffenrechts', gesamt: 2, richtig: 0 },
723 { bereich: 'I.2', titel: 'Rechte und Pflichten', gesamt: 2, richtig: 2 },
724 { bereich: 'III', titel: 'Handhabung von Schusswaffen', gesamt: 1, richtig: 1 },
725 ]);
726 });
727
728 it('lässt das K.-o.-Kriterium unabhängig vom Gesamtergebnis greifen', () => {
729 // Profil „fehlerpunkte“: höchstens 15 Fehler, aber höchstens 2 in I.5.
730 const antworten = [
731 ...mcAus('I.1', 20).map((f) => antwort(f, true)),
732 ...mcAus('I.5', 3).map((f) => antwort(f, false)),
733 ];
734 const ergebnis = auswerten('fehlerpunkte', antworten);
735
736 expect(ergebnis.falsch).toBe(3);
737 expect(ergebnis.urteil).toBe('nicht_bestanden');
738 expect(ergebnis.verletzteKriterien).toEqual([
739 'Notwehr und Notstand (3 Fehler, höchstens 2 zulässig)',
740 ]);
741 expect(ergebnis.begruendung).toContain('Notwehr und Notstand');
742 });
743
744 it('lässt zwei Fehler in Notwehr und Notstand durchgehen', () => {
745 const ergebnis = auswerten('fehlerpunkte', [
746 ...mcAus('I.1', 20).map((f) => antwort(f, true)),
747 ...mcAus('I.5', 2).map((f) => antwort(f, false)),
748 ]);
749
750 expect(ergebnis.verletzteKriterien).toEqual([]);
751 expect(ergebnis.urteil).toBe('bestanden');
752 });
753
754 it.each([
755 ['standard', 16, 4, 'bestanden'],
756 ['standard', 15, 5, 'nicht_bestanden'],
757 ['bdmp', 14, 6, 'bestanden'],
758 ['bdmp', 13, 7, 'nicht_bestanden'],
759 ['dsb', 15, 5, 'bestanden'],
760 ['dsb', 13, 7, 'nachpruefung'],
761 ['dsb', 12, 8, 'nachpruefung'],
762 ['dsb', 11, 9, 'nicht_bestanden'],
763 ['fehlerpunkte', 15, 5, 'bestanden'],
764 ['fehlerpunkte', 4, 16, 'nicht_bestanden'],
765 ])('bildet für %s bei %i von 20 richtigen das Urteil %s', (kennung, richtig, falsch, urteil) => {
766 const ergebnis = auswerten(kennung, bogen(richtig, falsch));
767
768 expect(ergebnis.gesamt).toBe(20);
769 expect(ergebnis.richtig).toBe(richtig);
770 expect(ergebnis.quote).toBeCloseTo(richtig / 20, 10);
771 expect(ergebnis.urteil).toBe(urteil);
772 });
773
774 it('baut die Begründung aus Trefferzahlen, nicht aus gerundeten Prozenten', () => {
775 /* 149 von 200 sind 74,5 Prozent – kaufmännisch gerundet 75. Aus
776 Prozentwerten gebaut lautete die Anzeige „Nicht bestanden. 75 Prozent
777 richtig, nötig waren 75 Prozent." Ein Satz, der sich selbst
778 widerspricht. */
779 const knapp = urteilBilden(profilFinden('frei')!, 149, 200, []);
780 expect(knapp.urteil).toBe('nicht_bestanden');
781 expect(knapp.begruendung).toBe('149 von 200 richtig, nötig waren 150.');
782
783 const gerade = urteilBilden(profilFinden('frei')!, 150, 200, []);
784 expect(gerade.urteil).toBe('bestanden');
785 expect(gerade.begruendung).toBe('150 von 200 richtig, nötig waren 150.');
786 });
787
788 it('rundet die nötige Trefferzahl nicht durch Gleitkomma hoch', () => {
789 // 0,8 * 80 kann als 64,000000000000006 herauskommen – aufgerundet 65.
790 const ergebnis = urteilBilden(profilFinden('standard')!, 64, 80, []);
791
792 expect(ergebnis.urteil).toBe('bestanden');
793 expect(ergebnis.begruendung).toBe('64 von 80 richtig, nötig waren 64.');
794 });
795
796 it('nennt die Grauzone des DSB-Profils in der Begründung', () => {
797 const ergebnis = auswerten('dsb', bogen(13, 7));
798
799 expect(ergebnis.urteil).toBe('nachpruefung');
800 expect(ergebnis.begruendung).toContain('mündliche Nachprüfung');
801 });
802
803 it('rechnet die Bestehensgrenze eines anpassbaren Profils nach', () => {
804 const streng = pruefung.auswerten(
805 profilId,
806 auftrag({ profilId: 'frei', bestehensQuote: 0.9 }),
807 bogen(16, 4),
808 1000,
809 false,
810 );
811
812 expect(streng.urteil).toBe('nicht_bestanden');
813
814 const milde = pruefung.auswerten(
815 profilId,
816 auftrag({ profilId: 'frei', bestehensQuote: 0.5 }),
817 bogen(16, 4),
818 1000,
819 false,
820 );
821
822 expect(milde.urteil).toBe('bestanden');
823 });
824
825 it('übernimmt Dauer und Zeitangabe unverändert', () => {
826 const ergebnis = pruefung.auswerten(
827 profilId,
828 auftrag({ profilId: 'standard' }),
829 bogen(10, 10),
830 1_234_567,
831 true,
832 );
833
834 expect(ergebnis.dauerMs).toBe(1_234_567);
835 expect(ergebnis.zeitAbgelaufen).toBe(true);
836 expect(ergebnis.zeitpunkt).toBe(START);
837 expect(ergebnis.profilId).toBe('standard');
838 });
839
840 it('weist unsaubere Nutzlasten ab', () => {
841 const frage = mcAus('I.1', 1)[0]!;
842
843 expect(() => auswerten('standard', [])).toThrow(/mindestens eine Antwort/u);
844 expect(() => auswerten('standard', [antwort(frage, true), antwort(frage, true)])).toThrow(
845 /mehrfach im Bogen/u,
846 );
847 expect(() => auswerten('standard', [{ frageId: 'gibt-es-nicht', auswahl: [] }])).toThrow(
848 /Unbekannte Frage-ID/u,
849 );
850 expect(() => auswerten('standard', [{ frageId: frage.id, auswahl: ['z'] }])).toThrow(
851 /keine Antwortoption/u,
852 );
853 expect(() =>
854 pruefung.auswerten(profilId, auftrag({ profilId: 'standard' }), bogen(1, 0), -5, false),
855 ).toThrow(/dauerMs/u);
856 expect(() =>
857 pruefung.auswerten(profilId, auftrag({ profilId: 'standard' }), bogen(1, 0), 'lange', false),
858 ).toThrow(/dauerMs/u);
859 });
860
861 it('lässt die Wiedervorlage nie aufgeschlagener Fragen unberührt', () => {
862 /*
863 Läuft die Zeit ab, schickt die Oberfläche auch für nie gezeigte Fragen
864 einen Eintrag mit leerer Auswahl. Diese gehören in die Historie – der
865 Bogen enthielt sie ja –, dürfen den Lernstand aber nicht zurückstufen:
866 Eine viermal sicher gewusste Frage wäre sonst allein deshalb wieder
867 fällig, weil ein Prüfungslauf nicht bis zu ihr kam.
868 */
869 const [gezeigt, nieGezeigt] = mcAus('I.1', 2);
870 if (!gezeigt || !nieGezeigt) {
871 throw new Error('Zwei Fragen erwartet');
872 }
873
874 // Die zweite Frage viermal sicher beantworten, damit sie weit hinausrückt.
875 for (let i = 0; i < 4; i += 1) {
876 lernstand.antworten(profilId, {
877 frageId: nieGezeigt.id,
878 auswahl: nieGezeigt.optionen?.filter((o) => o.korrekt).map((o) => o.label) ?? [],
879 richtig: true,
880 bewertung: 'leicht',
881 dauerMs: 1_000,
882 });
883 }
884 const vorher = lernstand.frageStand(profilId, nieGezeigt.id);
885 expect(vorher.faelligAb).not.toBeNull();
886
887 pruefung.auswerten(
888 profilId,
889 auftrag({ profilId: 'standard' }),
890 [antwort(gezeigt, true), { frageId: nieGezeigt.id, auswahl: [] }],
891 60_000,
892 true,
893 );
894
895 const nachher = lernstand.frageStand(profilId, nieGezeigt.id);
896 expect(nachher.faelligAb).toBe(vorher.faelligAb);
897 expect(nachher.letzteBewertung).toBe('leicht');
898 expect(nachher.versuche).toBe(vorher.versuche);
899 });
900
901 it('kappt eine übergroße Dauer, statt den Lauf zu verwerfen', () => {
902 /*
903 Wer ohne Zeitbegrenzung übt – der Nachteilsausgleich für alle, die mehr
904 Zeit brauchen – lässt das Fenster womöglich über Nacht offen. Diesen
905 Lauf zu verwerfen träfe genau die Gruppe, für die die Einstellung da
906 ist. Der Lauf wird gespeichert, die Dauer auf 24 Stunden gedeckelt.
907 */
908 const ergebnis = pruefung.auswerten(
909 profilId,
910 auftrag({ profilId: 'standard' }),
911 bogen(3, 1),
912 90_000_000,
913 false,
914 );
915
916 expect(ergebnis.dauerMs).toBe(86_400_000);
917 expect(ergebnis.richtig).toBe(3);
918
919 const gespeichert = pruefung.verlauf(profilId);
920 expect(gespeichert).toHaveLength(1);
921 expect(gespeichert[0]?.gesamt).toBe(4);
922 });
923
924 it('rundet eine gemessene Dauer mit Nachkommastellen, statt sie abzuweisen', () => {
925 const ergebnis = pruefung.auswerten(
926 profilId,
927 auftrag({ profilId: 'standard' }),
928 bogen(1, 0),
929 1234.6,
930 false,
931 );
932
933 expect(ergebnis.dauerMs).toBe(1235);
934 });
935 });
936
937 // ─── Fortschreibung des Lernstands ──────────────────────────────────────────
938
939 describe('Prüfungslauf im Lernstand', () => {
940 it('protokolliert jede Antwort wie eine Lernantwort', () => {
941 const antworten = bogen(3, 2);
942 auswerten('standard', antworten, 50_000);
943
944 const anzahl = db
945 .prepare<[number], { n: number }>('SELECT COUNT(*) AS n FROM antwort_log WHERE profil_id = ?')
946 .get(profilId);
947
948 expect(anzahl?.n).toBe(5);
949 expect(lernstand.uebersicht(profilId).beantwortet).toBe(5);
950 expect(lernstand.frageStand(profilId, antworten[0]!.frageId)).toMatchObject({
951 versuche: 1,
952 richtige: 1,
953 letzteBewertung: 'gut',
954 });
955 expect(lernstand.frageStand(profilId, antworten[4]!.frageId)).toMatchObject({
956 versuche: 1,
957 richtige: 0,
958 letzteBewertung: 'nochmal',
959 });
960 });
961
962 it('verteilt die gemessene Gesamtdauer gleichmäßig auf die Fragen', () => {
963 auswerten('standard', bogen(2, 2), 40_000);
964
965 const dauern = db
966 .prepare<[number], { dauer_ms: number }>(
967 'SELECT dauer_ms FROM antwort_log WHERE profil_id = ?',
968 )
969 .all(profilId)
970 .map((z) => z.dauer_ms);
971
972 expect(dauern).toEqual([10_000, 10_000, 10_000, 10_000]);
973 });
974
975 it('speichert nichts, wenn die Auswertung abbricht', () => {
976 const frage = mcAus('I.1', 1)[0]!;
977
978 expect(() => auswerten('standard', [{ frageId: frage.id, auswahl: ['z'] }])).toThrow();
979 expect(pruefung.verlauf(profilId)).toEqual([]);
980 expect(lernstand.uebersicht(profilId).beantwortet).toBe(0);
981 });
982 });
983
984 // ─── Verlauf ────────────────────────────────────────────────────────────────
985
986 // ─── Unterbrochener Lauf ────────────────────────────────────────────────────
987
988 describe('Unterbrochener Lauf', () => {
989 /** Vollständige Antwortliste zum gerade gestarteten Bogen. */
990 function bogenAntworten(bogenListe: readonly { frageId: string }[]): Pruefungsantwort[] {
991 return bogenListe.map((eintrag) => {
992 const frage = KATALOG.fragen.find((f) => f.id === eintrag.frageId)!;
993 return antwort(frage, true);
994 });
995 }
996
997 function offeneZeilen(): number {
998 return (db.prepare('SELECT COUNT(*) AS n FROM pruefung_offen').get() as { n: number }).n;
999 }
1000
1001 it('legt den Bogen schon beim Starten ab, nicht erst beim ersten Sichern', () => {
1002 /* Stürbe das Programm in der ersten Sekunde, wäre der Bogen sonst trotz
1003 aller Vorsorge weg. */
1004 const gezogen = bogenZiehen(profilId, auftrag({ profilId: 'standard' }));
1005
1006 const offen = pruefung.offenerBogen(profilId);
1007 expect(offen).not.toBeNull();
1008 expect(offen?.bogen.map((e) => e.frageId)).toEqual(gezogen.map((e) => e.frageId));
1009 expect(offen?.position).toBe(0);
1010 expect(offen?.verbrauchtMs).toBe(0);
1011 expect(offen?.phase).toBe('bearbeiten');
1012 expect(offen?.profil.id).toBe('standard');
1013 });
1014
1015 it('sichert Eingaben, Position, Phase und verbrauchte Zeit', () => {
1016 const gezogen = bogenZiehen(profilId, auftrag({ profilId: 'standard' }));
1017 const erste = gezogen[0]!.frageId;
1018
1019 expect(
1020 pruefung.sichern(profilId, {
1021 eingaben: { [erste]: { auswahl: ['a'], freitext: '' } },
1022 position: 5,
1023 phase: 'bearbeiten',
1024 zeitAbgelaufen: false,
1025 verbrauchtMs: 90_000,
1026 }),
1027 ).toBe(true);
1028
1029 const offen = pruefung.offenerBogen(profilId);
1030 expect(offen?.eingaben[erste]).toEqual({ auswahl: ['a'], freitext: '' });
1031 expect(offen?.position).toBe(5);
1032 expect(offen?.verbrauchtMs).toBe(90_000);
1033 });
1034
1035 it('unterscheidet nicht bewertet von als falsch bewertet', () => {
1036 /* Ein Ersatzwert `false` schriebe eine unbewertete offene Frage beim
1037 Fortsetzen als falsch fest. */
1038 const gezogen = bogenZiehen(profilId, auftrag({ profilId: 'dsb' }));
1039 const a = gezogen[0]!.frageId;
1040 const b = gezogen[1]!.frageId;
1041
1042 pruefung.sichern(profilId, {
1043 eingaben: {
1044 [a]: { auswahl: [], freitext: 'Text', selbst: false },
1045 [b]: { auswahl: [], freitext: 'Text' },
1046 },
1047 position: 0,
1048 phase: 'nachbewertung',
1049 zeitAbgelaufen: false,
1050 verbrauchtMs: 0,
1051 });
1052
1053 const offen = pruefung.offenerBogen(profilId);
1054 expect(offen?.eingaben[a]?.selbst).toBe(false);
1055 expect(offen?.eingaben[b]).not.toHaveProperty('selbst');
1056 expect(offen?.phase).toBe('nachbewertung');
1057 });
1058
1059 it('löscht die Zeile beim Auswerten – in derselben Transaktion', () => {
1060 const gezogen = bogenZiehen(profilId, auftrag({ profilId: 'standard' }));
1061 expect(offeneZeilen()).toBe(1);
1062
1063 auswerten('standard', bogenAntworten(gezogen));
1064
1065 expect(offeneZeilen()).toBe(0);
1066 expect(pruefung.offenerBogen(profilId)).toBeNull();
1067 });
1068
1069 it('lässt eine verspätete Sicherung den gewerteten Lauf nicht wiederbeleben', () => {
1070 /*
1071 Der schwerste Fall: Das Sichern ist entprellt. Feuert ein Nachzügler
1072 nach der Abgabe, dürfte er die eben gelöschte Zeile nicht wieder
1073 anlegen – sonst böte die Anwendung beim nächsten Start dieselbe,
1074 bereits gewertete Prüfung zum Fortsetzen an, und jede Antwort ginge
1075 ein zweites Mal in die Wiedervorlage.
1076 */
1077 const gezogen = bogenZiehen(profilId, auftrag({ profilId: 'standard' }));
1078 auswerten('standard', bogenAntworten(gezogen));
1079
1080 const angekommen = pruefung.sichern(profilId, {
1081 eingaben: {},
1082 position: 3,
1083 phase: 'bearbeiten',
1084 zeitAbgelaufen: false,
1085 verbrauchtMs: 120_000,
1086 });
1087
1088 expect(angekommen).toBe(false);
1089 expect(offeneZeilen()).toBe(0);
1090 });
1091
1092 it('hinterlässt nach der Auswertung genau einen Verlaufseintrag', () => {
1093 const gezogen = bogenZiehen(profilId, auftrag({ profilId: 'standard' }));
1094 auswerten('standard', bogenAntworten(gezogen));
1095
1096 expect(pruefung.offenerBogen(profilId)).toBeNull();
1097 expect((db.prepare('SELECT COUNT(*) AS n FROM pruefung_lauf').get() as { n: number }).n).toBe(
1098 1,
1099 );
1100 });
1101
1102 it('sichert nicht in einen Lauf, der inzwischen ein anderer ist', () => {
1103 /*
1104 Dafuer ist die Lauf-Kennung da, und nur dafuer: Die Zeile existiert
1105 noch, gehoert aber schon zum naechsten Bogen. Ein Nachzuegler aus dem
1106 alten Lauf duerfte dessen Position und Eingaben nicht in den neuen
1107 schreiben.
1108 */
1109 pruefung.starten(profilId, auftrag({ profilId: 'standard' }));
1110 const alteKennung = (
1111 db.prepare('SELECT lauf_id FROM pruefung_offen').get() as { lauf_id: string }
1112 ).lauf_id;
1113
1114 const zweiter = bogenZiehen(profilId, auftrag({ profilId: 'bdmp' }));
1115 const neueKennung = (
1116 db.prepare('SELECT lauf_id FROM pruefung_offen').get() as { lauf_id: string }
1117 ).lauf_id;
1118 expect(neueKennung).not.toBe(alteKennung);
1119
1120 pruefung.sichern(profilId, {
1121 eingaben: { [zweiter[0]!.frageId]: { auswahl: ['a'], freitext: '' } },
1122 position: 1,
1123 phase: 'bearbeiten',
1124 zeitAbgelaufen: false,
1125 verbrauchtMs: 1000,
1126 });
1127
1128 // Der neue Lauf nimmt seine eigene Sicherung an – die Kennung stimmt.
1129 expect(pruefung.offenerBogen(profilId)?.position).toBe(1);
1130 });
1131
1132 it('verwirft die Zeile auf ausdrücklichen Wunsch', () => {
1133 pruefung.starten(profilId, auftrag({ profilId: 'standard' }));
1134
1135 pruefung.verwerfen(profilId);
1136
1137 expect(pruefung.offenerBogen(profilId)).toBeNull();
1138 expect(offeneZeilen()).toBe(0);
1139 });
1140
1141 it('führt je Profil höchstens einen offenen Lauf', () => {
1142 pruefung.starten(profilId, auftrag({ profilId: 'standard' }));
1143 const zweiter = bogenZiehen(profilId, auftrag({ profilId: 'bdmp' }));
1144
1145 expect(offeneZeilen()).toBe(1);
1146 expect(pruefung.offenerBogen(profilId)?.bogen.map((e) => e.frageId)).toEqual(
1147 zweiter.map((e) => e.frageId),
1148 );
1149 });
1150
1151 it('hält die Läufe verschiedener Profile auseinander', () => {
1152 const zweites = lernstand.profilAnlegen('Zweitprüfling');
1153 pruefung.starten(profilId, auftrag({ profilId: 'standard' }));
1154 pruefung.starten(zweites.id, auftrag({ profilId: 'bdmp' }));
1155
1156 expect(pruefung.offenerBogen(profilId)?.profil.id).toBe('standard');
1157 expect(pruefung.offenerBogen(zweites.id)?.profil.id).toBe('bdmp');
1158 });
1159
1160 it('nimmt das gelöschte Profil mit', () => {
1161 const zweites = lernstand.profilAnlegen('Zweitprüfling');
1162 pruefung.starten(zweites.id, auftrag({ profilId: 'standard' }));
1163
1164 lernstand.profilLoeschen(zweites.id);
1165
1166 expect(offeneZeilen()).toBe(0);
1167 });
1168
1169 it('verschwindet beim vollständigen Zurücksetzen', () => {
1170 /* „Von vorn" und „die halbe Prüfung von gestern liegt noch da" passen
1171 nicht zusammen. */
1172 pruefung.starten(profilId, auftrag({ profilId: 'standard' }));
1173
1174 lernstand.zuruecksetzen(profilId, null);
1175
1176 expect(offeneZeilen()).toBe(0);
1177 });
1178
1179 it('bleibt beim kapitelweisen Zurücksetzen stehen', () => {
1180 /* Ein Bogen geht über den ganzen Katalog und lässt sich nicht
1181 kapitelweise herausrechnen. */
1182 pruefung.starten(profilId, auftrag({ profilId: 'standard' }));
1183
1184 lernstand.zuruecksetzen(profilId, 'I');
1185
1186 expect(offeneZeilen()).toBe(1);
1187 });
1188
1189 it('verwirft einen Bogen, dessen Fragen es nicht mehr gibt', () => {
1190 /* Katalogwechsel zwischen Unterbrechung und Fortsetzen: Eine fehlende
1191 Frage ließe eine Lücke, und die Auswertung zählte gegen eine andere
1192 Gesamtzahl als die Anzeige. */
1193 pruefung.starten(profilId, auftrag({ profilId: 'standard' }));
1194 db.prepare('UPDATE pruefung_offen SET bogen = ?').run(
1195 '[{"frageId":"gibt-es-nicht","optionsReihenfolge":["a"]}]',
1196 );
1197
1198 expect(pruefung.offenerBogen(profilId)).toBeNull();
1199 expect(offeneZeilen()).toBe(0);
1200 expect(warnungen.join(' ')).toMatch(/gibt-es-nicht/u);
1201 });
1202
1203 it('verwirft eine Zeile mit ungültigem Auftrag', () => {
1204 /* Die Datei liegt im userData-Verzeichnis und ist von außen
1205 beschreibbar; ein erfundenes Profil darf nicht in die Wertung. */
1206 pruefung.starten(profilId, auftrag({ profilId: 'standard' }));
1207 db.prepare('UPDATE pruefung_offen SET auftrag = ?').run('{"profilId":"gibt-es-nicht"}');
1208
1209 expect(pruefung.offenerBogen(profilId)).toBeNull();
1210 expect(offeneZeilen()).toBe(0);
1211 });
1212
1213 it('nimmt beim Auswerten den gespeicherten Auftrag, nicht den eingereichten', () => {
1214 /*
1215 Sonst ließe sich ein fortgesetzter Bogen mit einem anderen Profil
1216 abgeben: Bogen und Urteilsregeln kämen aus verschiedenen Läufen.
1217 */
1218 const gezogen = bogenZiehen(profilId, auftrag({ profilId: 'bdmp' }));
1219
1220 const ergebnis = pruefung.auswerten(
1221 profilId,
1222 auftrag({ profilId: 'standard' }),
1223 bogenAntworten(gezogen),
1224 60_000,
1225 false,
1226 );
1227
1228 expect(ergebnis.profilId).toBe('bdmp');
1229 });
1230
1231 it('prüft die eingereichte Liste auch nach einem Neustart', () => {
1232 /*
1233 Bisher galt: Ist der Bogen nicht bekannt – etwa nach einem Neustart –,
1234 wird jede Liste ungeprüft angenommen. Mit der gespeicherten Zeile lässt
1235 sich der Bogen wiederherstellen, und die Prüfung greift wieder.
1236 */
1237 const gezogen = bogenZiehen(profilId, auftrag({ profilId: 'standard' }));
1238 const antworten = bogenAntworten(gezogen);
1239
1240 // Neustart nachbilden: neue Instanz, leerer Speicher, dieselbe Datenbank.
1241 const nachNeustart = new Pruefung(lernstand, KATALOG, {
1242 jetzt: () => uhr,
1243 zufall: zufallsfolge(7),
1244 warnen: (meldung) => warnungen.push(meldung),
1245 });
1246 nachNeustart.offenerBogen(profilId);
1247
1248 expect(() =>
1249 nachNeustart.auswerten(
1250 profilId,
1251 auftrag({ profilId: 'standard' }),
1252 antworten.slice(0, 3),
1253 1000,
1254 false,
1255 ),
1256 ).toThrow(/Bogen umfasste/u);
1257 });
1258
1259 it('weist eine Position außerhalb des Bogens ab', () => {
1260 pruefung.starten(profilId, auftrag({ profilId: 'standard' }));
1261
1262 expect(() =>
1263 pruefung.sichern(profilId, {
1264 eingaben: {},
1265 position: 999,
1266 phase: 'bearbeiten',
1267 zeitAbgelaufen: false,
1268 verbrauchtMs: 0,
1269 }),
1270 ).toThrow(/position/u);
1271 });
1272
1273 it('weist eine Eingabe zu einer fremden Frage ab', () => {
1274 pruefung.starten(profilId, auftrag({ profilId: 'standard' }));
1275
1276 expect(() =>
1277 pruefung.sichern(profilId, {
1278 eingaben: { 'gibt-es-nicht': { auswahl: ['a'], freitext: '' } },
1279 position: 0,
1280 phase: 'bearbeiten',
1281 zeitAbgelaufen: false,
1282 verbrauchtMs: 0,
1283 }),
1284 ).toThrow(/gehört nicht zu diesem Bogen/u);
1285 });
1286
1287 it('kappt einen übergroßen Freitext und eine unsinnige Dauer', () => {
1288 const gezogen = bogenZiehen(profilId, auftrag({ profilId: 'standard' }));
1289 const erste = gezogen[0]!.frageId;
1290
1291 pruefung.sichern(profilId, {
1292 eingaben: { [erste]: { auswahl: [], freitext: 'x'.repeat(9000) } },
1293 position: 0,
1294 phase: 'bearbeiten',
1295 zeitAbgelaufen: false,
1296 verbrauchtMs: 99 * 24 * 60 * 60 * 1000,
1297 });
1298
1299 const offen = pruefung.offenerBogen(profilId);
1300 expect(offen?.eingaben[erste]?.freitext).toHaveLength(4000);
1301 expect(offen?.verbrauchtMs).toBe(24 * 60 * 60 * 1000);
1302 });
1303 });
1304
1305 describe('pruefung:verlauf', () => {
1306 it('liefert die Läufe mit dem neuesten zuerst', () => {
1307 auswerten('standard', bogen(16, 4));
1308 uhr = new Date(new Date(START).getTime() + 3_600_000);
1309 auswerten('dsb', bogen(10, 10));
1310
1311 const verlauf = pruefung.verlauf(profilId);
1312
1313 expect(verlauf.map((l) => l.profilId)).toEqual(['dsb', 'standard']);
1314 expect(verlauf[0]).toMatchObject({
1315 profilName: 'Nach Art des Deutschen Schützenbundes',
1316 gesamt: 20,
1317 richtig: 10,
1318 quote: 0.5,
1319 urteil: 'nicht_bestanden',
1320 });
1321 expect(verlauf[1]).toMatchObject({
1322 profilName: 'Standard',
1323 richtig: 16,
1324 urteil: 'bestanden',
1325 });
1326 expect(verlauf[0]!.id).not.toBe(verlauf[1]!.id);
1327 });
1328
1329 it('hält die Läufe verschiedener Lernprofile auseinander', () => {
1330 const zweites = lernstand.profilAnlegen('Zweite Person');
1331 auswerten('standard', bogen(16, 4));
1332
1333 expect(pruefung.verlauf(profilId)).toHaveLength(1);
1334 expect(pruefung.verlauf(zweites.id)).toEqual([]);
1335 });
1336
1337 it('weist ein unbekanntes Lernprofil ab', () => {
1338 expect(() => pruefung.verlauf(4711)).toThrow(/Unbekanntes Profil/u);
1339 });
1340
1341 it('liefert die Dauer mit, die es seit jeher speichert', () => {
1342 // Die Spalte gibt es seit Schema-Version 2, `verlauf()` las sie bis 0.22.0
1343 // nur nicht aus. Zeitnot ist ein eigenes Durchfallrisiko.
1344 auswerten('standard', bogen(16, 4), 71 * 60_000);
1345
1346 expect(pruefung.verlauf(profilId)[0]?.dauerMs).toBe(71 * 60_000);
1347 });
1348
1349 it('hält fest, wie viele Fragen offen blieben und ob die Zeit ablief', () => {
1350 /* Ohne diese beiden Angaben sähe ein abgelaufener Lauf im Vergleich wie
1351 ein Wissenseinbruch aus: `quote` zählt Unbeantwortete wie falsch
1352 beantwortete. */
1353 const fragen = mcAus('I.1', 4);
1354 pruefung.auswerten(
1355 profilId,
1356 auftrag({ profilId: 'standard' }),
1357 [
1358 antwort(fragen[0]!, true),
1359 antwort(fragen[1]!, true),
1360 { frageId: fragen[2]!.id, auswahl: [] },
1361 { frageId: fragen[3]!.id, auswahl: [] },
1362 ],
1363 60_000,
1364 true,
1365 );
1366
1367 expect(pruefung.verlauf(profilId)[0]).toMatchObject({
1368 unbeantwortet: 2,
1369 zeitAbgelaufen: true,
1370 quote: 0.5,
1371 });
1372 });
1373
1374 it('speichert die Themenanalyse des Laufs mit', () => {
1375 const fragen = [...mcAus('I.1', 2), ...mcAus('I.2', 2)];
1376 pruefung.auswerten(
1377 profilId,
1378 auftrag({ profilId: 'standard' }),
1379 [
1380 antwort(fragen[0]!, true),
1381 antwort(fragen[1]!, false),
1382 antwort(fragen[2]!, true),
1383 antwort(fragen[3]!, true),
1384 ],
1385 60_000,
1386 false,
1387 );
1388
1389 const bereiche = pruefung.verlauf(profilId)[0]?.bereiche;
1390
1391 expect(bereiche).toHaveLength(2);
1392 expect(bereiche?.[0]).toMatchObject({ bereich: 'I.1', gesamt: 2, richtig: 1 });
1393 expect(bereiche?.[1]).toMatchObject({ bereich: 'I.2', gesamt: 2, richtig: 2 });
1394 /* Der Titel reist mit: Der Verlauf muss ihn auch dann noch nennen können,
1395 wenn der Katalog den Abschnitt später anders benennt. */
1396 expect(bereiche?.[0]?.titel).toBe(KATALOG.kapitel[0]?.abschnitte[0]?.titel);
1397 });
1398
1399 it('speichert die Bestehensgrenze des Laufs, nicht die des Vertrags', () => {
1400 /* Beim frei eingestellten Profil sind die gewählten Werte nach dem Lauf
1401 fort. Holte die Anzeige die Grenze hinterher aus dem Vertrag, bekämen
1402 genau diese Läufe eine falsche. */
1403 auswerten('standard', bogen(16, 4));
1404 expect(pruefung.verlauf(profilId)[0]?.bestehensQuote).toBeCloseTo(0.8);
1405
1406 uhr = new Date(new Date(START).getTime() + 3_600_000);
1407 pruefung.auswerten(
1408 profilId,
1409 auftrag({ profilId: 'frei', bestehensQuote: 0.5 }),
1410 bogen(2, 2),
1411 60_000,
1412 false,
1413 );
1414 expect(pruefung.verlauf(profilId)[0]?.bestehensQuote).toBeCloseTo(0.5);
1415 });
1416
1417 it('rechnet eine Fehlerpunktegrenze in eine Quote um', () => {
1418 // 75 Fragen bei höchstens 15 Fehlern sind 60 von 75 – dieselbe Bedingung,
1419 // anders geschrieben. Ein Bogen von 20 Fragen ergibt entsprechend 5 von 20.
1420 pruefung.auswerten(
1421 profilId,
1422 auftrag({ profilId: 'fehlerpunkte' }),
1423 bogen(16, 4),
1424 60_000,
1425 false,
1426 );
1427
1428 expect(pruefung.verlauf(profilId)[0]?.bestehensQuote).toBeCloseTo(0.25);
1429 });
1430
1431 it('nennt die Kennung des gespeicherten Laufs', () => {
1432 // Der Verlauf enthält den eben beendeten Lauf bereits. Ohne die Kennung
1433 // verglichen ihn Auswertung und Anzeige mit sich selbst.
1434 const ergebnis = auswerten('standard', bogen(16, 4));
1435
1436 expect(ergebnis.laufId).toBe(pruefung.verlauf(profilId)[0]?.id);
1437 });
1438
1439 it('übersteht eine beschädigte Themenanalyse, ohne den Verlauf zu verlieren', () => {
1440 /* Die Datei liegt im `userData`-Verzeichnis. Was nicht als Eintrag lesbar
1441 ist, fällt weg – schlimmstenfalls fehlt einem Lauf die Aufschlüsselung,
1442 statt dass die Anzeige ausfällt. */
1443 auswerten('standard', bogen(16, 4));
1444 db.prepare("UPDATE pruefung_lauf SET bereiche = '{kaputt'").run();
1445
1446 const verlauf = pruefung.verlauf(profilId);
1447
1448 expect(verlauf).toHaveLength(1);
1449 expect(verlauf[0]?.bereiche).toBeNull();
1450 });
1451
1452 it('nimmt aus einer halb lesbaren Analyse nur die gültigen Einträge', () => {
1453 auswerten('standard', bogen(16, 4));
1454 db.prepare(
1455 `UPDATE pruefung_lauf SET bereiche = '[
1456 {"bereich":"I.1","titel":"Begriffe","gesamt":4,"richtig":3},
1457 {"bereich":"I.2","titel":"Rechte","gesamt":2,"richtig":5},
1458 {"bereich":"I.3","gesamt":2,"richtig":1},
1459 "unfug"
1460 ]'`,
1461 ).run();
1462
1463 // Mehr richtige als gestellte Fragen, fehlender Titel, kein Objekt: weg.
1464 expect(pruefung.verlauf(profilId)[0]?.bereiche).toEqual([
1465 { bereich: 'I.1', titel: 'Begriffe', gesamt: 4, richtig: 3 },
1466 ]);
1467 });
1468 });
1469
1470 // ─── Migration ──────────────────────────────────────────────────────────────
1471
1472 describe('Migration auf Schema 2', () => {
1473 function tabellen(verbindung: Database.Database): string[] {
1474 return verbindung
1475 .prepare<[], { name: string }>("SELECT name FROM sqlite_master WHERE type = 'table'")
1476 .all()
1477 .map((z) => z.name);
1478 }
1479
1480 it('ergänzt pruefung_lauf, ohne einen bestehenden Lernstand zu verlieren', () => {
1481 const alt = new Database(':memory:');
1482 verbindungen.push(alt);
1483
1484 const vorher = new Lernstand(alt, KATALOG, { jetzt: () => uhr });
1485 const pid = vorher.profile()[0]!.id;
1486 const gelernt = mcAus('I.2', 1)[0]!;
1487 vorher.antworten(pid, {
1488 frageId: gelernt.id,
1489 auswahl: richtigeLabels(gelernt),
1490 richtig: true,
1491 bewertung: 'gut',
1492 dauerMs: 1200,
1493 });
1494 vorher.merken(pid, mcAus('I.3', 1)[0]!.id, true);
1495
1496 // Stand einer Installation mit Schema-Version 1 herstellen.
1497 alt.exec('DROP TABLE pruefung_lauf');
1498 alt.prepare('DELETE FROM schema_version WHERE version > 1').run();
1499
1500 expect(tabellen(alt)).not.toContain('pruefung_lauf');
1501
1502 const nachher = new Lernstand(alt, KATALOG, { jetzt: () => uhr });
1503
1504 expect(tabellen(alt)).toContain('pruefung_lauf');
1505 expect(
1506 alt
1507 .prepare<[], { version: number }>('SELECT MAX(version) AS version FROM schema_version')
1508 .get()?.version,
1509 ).toBe(SCHEMA_VERSION);
1510
1511 // Der bestehende Lernstand ist unversehrt.
1512 expect(nachher.profile()).toHaveLength(1);
1513 expect(nachher.frageStand(pid, gelernt.id)).toMatchObject({ versuche: 1, richtige: 1 });
1514 expect(nachher.frageStand(pid, mcAus('I.3', 1)[0]!.id).gemerkt).toBe(true);
1515 expect(nachher.uebersicht(pid).beantwortet).toBe(1);
1516
1517 // Und die Simulation lässt sich sofort benutzen.
1518 const migriert = new Pruefung(nachher, KATALOG, { jetzt: () => uhr });
1519 migriert.auswerten(pid, auftrag({ profilId: 'standard' }), bogen(16, 4), 1000, false);
1520
1521 expect(migriert.verlauf(pid)).toHaveLength(1);
1522 });
1523
1524 it('ergänzt die Zeitstufe und lässt ältere Läufe ohne sie stehen', () => {
1525 /* Die Zeitstufe eines Laufs von vor Schema-Version 4 ist nicht mehr zu
1526 ermitteln. Sie zu raten wäre schlechter, als sie offen zu lassen –
1527 sonst stünde in der Tabelle eine Angabe, die niemand gemessen hat. */
1528 const alt = new Database(':memory:');
1529 verbindungen.push(alt);
1530
1531 const vorher = new Lernstand(alt, KATALOG, { jetzt: () => uhr });
1532 const pid = vorher.profile()[0]!.id;
1533 const simulation = new Pruefung(vorher, KATALOG, { jetzt: () => uhr });
1534 simulation.auswerten(pid, auftrag({ profilId: 'standard' }), bogen(16, 4), 1000, false);
1535
1536 // Stand vor der Umstellung nachbilden: Spalte leeren, Version zurück.
1537 alt.prepare('UPDATE pruefung_lauf SET zeitmodus = NULL').run();
1538 alt.prepare('DELETE FROM schema_version WHERE version > 3').run();
1539
1540 const nachher = new Lernstand(alt, KATALOG, { jetzt: () => uhr });
1541 const spalten = alt
1542 .prepare<[], { name: string }>('PRAGMA table_info(pruefung_lauf)')
1543 .all()
1544 .map((z) => z.name);
1545 expect(spalten).toContain('zeitmodus');
1546
1547 const migriert = new Pruefung(nachher, KATALOG, { jetzt: () => uhr });
1548 expect(migriert.verlauf(pid)[0]?.zeitmodus).toBeNull();
1549
1550 // Ein neuer Lauf trägt sie dagegen.
1551 migriert.auswerten(
1552 pid,
1553 auftrag({ profilId: 'standard', zeitmodus: 'aus' }),
1554 bogen(16, 4),
1555 1000,
1556 false,
1557 );
1558 expect(migriert.verlauf(pid)[0]?.zeitmodus).toBe('aus');
1559 });
1560
1561 it('ergänzt die Kennzahlen aus Fassung 10 und erfindet sie für alte Läufe nicht', () => {
1562 /* `unbeantwortet = 0` hieße „es blieb nichts offen“ – eine Behauptung über
1563 einen Lauf, von dem niemand weiß, wie er endete. `NULL` heißt „nicht
1564 festgehalten“, und die Oberfläche sagt genau das. */
1565 const alt = new Database(':memory:');
1566 verbindungen.push(alt);
1567
1568 const vorher = new Lernstand(alt, KATALOG, { jetzt: () => uhr });
1569 const pid = vorher.profile()[0]!.id;
1570 const simulation = new Pruefung(vorher, KATALOG, { jetzt: () => uhr });
1571 simulation.auswerten(pid, auftrag({ profilId: 'standard' }), bogen(16, 4), 1000, false);
1572
1573 // Stand vor der Umstellung nachbilden: Spalten leeren, Version zurück.
1574 alt
1575 .prepare(
1576 `UPDATE pruefung_lauf
1577 SET unbeantwortet = NULL, zeit_abgelaufen = NULL,
1578 bereiche = NULL, bestehens_quote = NULL`,
1579 )
1580 .run();
1581 alt.prepare('DELETE FROM schema_version WHERE version > 9').run();
1582
1583 const nachher = new Lernstand(alt, KATALOG, { jetzt: () => uhr });
1584 const migriert = new Pruefung(nachher, KATALOG, { jetzt: () => uhr });
1585
1586 expect(migriert.verlauf(pid)[0]).toMatchObject({
1587 unbeantwortet: null,
1588 zeitAbgelaufen: null,
1589 bereiche: null,
1590 bestehensQuote: null,
1591 // Die Dauer stand schon in Fassung 2 in der Tabelle.
1592 dauerMs: 1000,
1593 });
1594
1595 // Ein neuer Lauf trägt sie dagegen.
1596 uhr = new Date(new Date(START).getTime() + 3_600_000);
1597 migriert.auswerten(pid, auftrag({ profilId: 'standard' }), bogen(16, 4), 1000, false);
1598
1599 expect(migriert.verlauf(pid)[0]).toMatchObject({
1600 unbeantwortet: 0,
1601 zeitAbgelaufen: false,
1602 bestehensQuote: 0.8,
1603 });
1604 expect(migriert.verlauf(pid)[0]?.bereiche).not.toBeNull();
1605 });
1606
1607 it('legt die Spalten aus Fassung 10 auch auf einer Datenbank ohne sie an', () => {
1608 /* Der Schritt muss auch dann durchlaufen, wenn `pruefung_lauf` noch die
1609 alte Gestalt hat – sonst startet die Anwendung überhaupt nicht mehr. */
1610 const alt = new Database(':memory:');
1611 verbindungen.push(alt);
1612
1613 const vorher = new Lernstand(alt, KATALOG, { jetzt: () => uhr });
1614 const pid = vorher.profile()[0]!.id;
1615
1616 alt.exec('DROP TABLE pruefung_lauf');
1617 alt.prepare('DELETE FROM schema_version WHERE version > 1').run();
1618
1619 const nachher = new Lernstand(alt, KATALOG, { jetzt: () => uhr });
1620 const spalten = alt
1621 .prepare<[], { name: string }>('PRAGMA table_info(pruefung_lauf)')
1622 .all()
1623 .map((z) => z.name);
1624
1625 expect(spalten).toEqual(
1626 expect.arrayContaining(['unbeantwortet', 'zeit_abgelaufen', 'bereiche', 'bestehens_quote']),
1627 );
1628
1629 const migriert = new Pruefung(nachher, KATALOG, { jetzt: () => uhr });
1630 migriert.auswerten(pid, auftrag({ profilId: 'standard' }), bogen(16, 4), 1000, false);
1631
1632 expect(migriert.verlauf(pid)[0]?.bestehensQuote).toBeCloseTo(0.8);
1633 });
1634 });
1635
1636 describe('Ein Lauf wird genau einmal ausgewertet', () => {
1637 /*
1638 Befund der Prüfrunde zu 0.27.2. `bogenPruefen` nimmt eine Antwortliste
1639 ungeprüft an, wenn kein Bogen bekannt ist — gedacht für den Neustart
1640 mitten im Lauf. Diesen Fall gibt es dort aber gar nicht mehr:
1641 `offenerBogen()` füllt den Merkposten beim Fortsetzen wieder. Erreicht
1642 wird der Zweig praktisch nur noch nach einer bereits erfolgten
1643 Auswertung — und dann nahm er alles an.
1644
1645 Gemessen an einem Bogen mit 80 Fragen: `versuche` 80 → 160, Einträge in
1646 `pruefung_lauf` 1 → 2, und der zweite trug das Profil „fehlerpunkte“ mit
1647 80 Fragen, obwohl dieses Profil 75 vorsieht und der Bogen als „standard“
1648 gezogen wurde.
1649
1650 `sichern()` hat gegen genau diese Verdopplung zwei Riegel; `auswerten`
1651 hatte keinen.
1652 */
1653 function bogenAntworten(bogenListe: readonly { frageId: string }[]): Pruefungsantwort[] {
1654 return bogenListe.map((eintrag) => {
1655 const frage = KATALOG.fragen.find((f) => f.id === eintrag.frageId)!;
1656 return antwort(frage, true);
1657 });
1658 }
1659
1660 function versuche(): number {
1661 return (
1662 db
1663 .prepare<[number], { s: number }>(
1664 'SELECT COALESCE(SUM(versuche), 0) AS s FROM frage_stand WHERE profil_id = ?',
1665 )
1666 .get(profilId) as { s: number }
1667 ).s;
1668 }
1669
1670 it('weist einen zweiten Aufruf ab, statt alles ein zweites Mal zu buchen', () => {
1671 const gezogen = bogenZiehen(profilId, auftrag({ profilId: 'standard' }));
1672 const antworten = bogenAntworten(gezogen);
1673
1674 auswerten('standard', antworten);
1675 const nachErstem = versuche();
1676
1677 expect(() => auswerten('standard', antworten)).toThrow(/bereits ausgewertet/u);
1678 expect(versuche()).toBe(nachErstem);
1679 expect((db.prepare('SELECT COUNT(*) AS n FROM pruefung_lauf').get() as { n: number }).n).toBe(
1680 1,
1681 );
1682 });
1683
1684 it('lässt den zweiten Aufruf auch kein fremdes Profil einschmuggeln', () => {
1685 /* Die Verschärfung: Nach der ersten Auswertung ist auch der gespeicherte
1686 Auftrag fort, der zweite Aufruf zöge also den des Renderers heran — mit
1687 anderer Bestehensgrenze für denselben Bogen. */
1688 const gezogen = bogenZiehen(profilId, auftrag({ profilId: 'standard' }));
1689 const antworten = bogenAntworten(gezogen);
1690 auswerten('standard', antworten);
1691
1692 expect(() => auswerten('fehlerpunkte', antworten)).toThrow(/bereits ausgewertet/u);
1693 expect(
1694 db
1695 .prepare<[], { pruefungsprofil: string }>('SELECT pruefungsprofil FROM pruefung_lauf')
1696 .all()
1697 .map((zeile) => zeile.pruefungsprofil),
1698 ).toEqual(['standard']);
1699 });
1700
1701 it('wertet einen nach Neustart fortgesetzten Lauf weiterhin aus', () => {
1702 /*
1703 Die Gegenprobe, und sie ist die wichtigere: Der Riegel darf die
1704 unterbrochene Sitzung nicht treffen. „Eine unterbrochene Sitzung soll
1705 niemandem seine Arbeit kosten“ — die Zeile des offenen Laufs ist dann
1706 noch da, und `offenerBogen()` füllt den Merkposten wieder.
1707 */
1708 const gezogen = bogenZiehen(profilId, auftrag({ profilId: 'standard' }));
1709 const antworten = bogenAntworten(gezogen);
1710
1711 /* Neustart nachgestellt: neue Instanz auf derselben Datenbank. */
1712 const nachNeustart = new Pruefung(lernstand, KATALOG, {
1713 jetzt: () => uhr,
1714 zufall: zufallsfolge(7),
1715 warnen: (meldung) => warnungen.push(meldung),
1716 });
1717 expect(nachNeustart.offenerBogen(profilId)).not.toBeNull();
1718
1719 expect(() =>
1720 nachNeustart.auswerten(profilId, auftrag({ profilId: 'standard' }), antworten, 60_000, false),
1721 ).not.toThrow();
1722 });
1723 });