waffensachkunde

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

/ app src main pruefung.ts

61,6 KB Rohdatei
app/src/main/pruefung.ts — 1623 Zeilen
1 /**
2 * Prüfungssimulation – Bogen zusammenstellen, auswerten, Verlauf führen.
3 *
4 * Die Regeln stehen im Vertrag (`src/shared/pruefung.ts`) und werden hier
5 * nicht noch einmal formuliert: welche Profile es gibt, wie das Zeitlimit
6 * gerechnet wird und wann bestanden ist, entscheiden `PRUEFUNGSPROFILE`,
7 * `zeitInMinuten` und `urteilBilden`. Dieses Modul zieht Fragen, zählt
8 * Ergebnisse und schreibt sie fort.
9 *
10 * Zwei Grundsätze:
11 *
12 * 1. **Der Renderer wird nicht geglaubt.** Ob eine Multiple-Choice-Antwort
13 * richtig ist, rechnet `bewerteAuswahl` aus dem Katalog nach. Nur bei
14 * offenen Fragen gibt es keine maschinelle Wahrheit – dort zählt die
15 * Selbsteinschätzung des Prüflings.
16 * 2. **Der Lernstand bleibt Eigentümer der Datenbank.** {@link Pruefung}
17 * bekommt ihn übergeben, nutzt seine Verbindung und protokolliert jede
18 * Antwort über `Lernstand.antworten` – damit fließt ein Simulationslauf
19 * in dieselbe Statistik und dieselbe Wiedervorlage ein wie das Lernen.
20 *
21 * Die Klasse kennt Electron nicht und ist deshalb gegen eine
22 * `:memory:`-Datenbank prüfbar.
23 */
24
25 import type BetterSqlite3 from 'better-sqlite3';
26
27 import { bewerteAuswahl, type Frage, type Katalog } from '../shared/katalog';
28 import type { Bewertung } from '../shared/lernstand';
29 import {
30 gewuenschteTypen,
31 grenzquote,
32 profilFinden,
33 urteilBilden,
34 URTEIL_BEZEICHNUNG,
35 ZEITMODUS_BEZEICHNUNG,
36 type BereichErgebnis,
37 type Bestehensurteil,
38 type GesicherteEingabe,
39 type OffenerLauf,
40 type Pruefungsauftrag,
41 type Pruefungsbogen,
42 type Pruefungsergebnis,
43 type Pruefungsfrage,
44 type Pruefungsprofil,
45 type Pruefungsverlauf,
46 type Zeitmodus,
47 } from '../shared/pruefung';
48 import {
49 abweisen,
50 endlicheZahl,
51 entschaerft,
52 ganzeZahl,
53 gemischt,
54 nutzlast,
55 textliste,
56 wahrheitswert,
57 } from './eingaben';
58 import type { Lernstand } from './lernstand';
59
60 /**
61 * Obergrenze für die Bogengröße. Liegt über der Katalogröße, damit sich auch
62 * der gesamte Katalog anfordern lässt; alles darüber ist ein Eingabefehler.
63 */
64 const MAX_BOGENGROESSE = 1000;
65
66 /** Plausible Obergrenze für ein Zeitlimit: 24 Stunden. */
67 const MAX_ZEIT_MINUTEN = 24 * 60;
68
69 /** Plausible Obergrenze für die Dauer eines Laufs: 24 Stunden. */
70 const MAX_DAUER_MS = 24 * 60 * 60 * 1000;
71
72 const MAX_FREITEXT_LAENGE = 4000;
73
74 /**
75 * Höchstzahl der Läufe, die der Verlauf liefert. Die Anzeige zeigt eine
76 * Historie, kein Archiv – gespeichert bleibt alles.
77 */
78 const VERLAUF_GRENZE = 200;
79
80 // ─── Prüfhilfen ─────────────────────────────────────────────────────────────
81
82 function istZeitmodus(wert: unknown): wert is Zeitmodus {
83 return typeof wert === 'string' && Object.hasOwn(ZEITMODUS_BEZEICHNUNG, wert);
84 }
85
86 function istUrteil(wert: unknown): wert is Bestehensurteil {
87 return typeof wert === 'string' && Object.hasOwn(URTEIL_BEZEICHNUNG, wert);
88 }
89
90 /** Offen sind alle Fragen, die nicht Multiple Choice sind (auch der Lückentext). */
91 function istOffen(frage: Frage): boolean {
92 return frage.typ !== 'mc';
93 }
94
95 // ─── Klartext für die Warnungen ─────────────────────────────────────────────
96 //
97 // Die Warnungen des Ziehens gehen nicht mehr nur ins Protokoll, sondern über
98 // den Bogen an den Bildschirm (bis Fassung 0.20.0 sah sie niemand). Sie werden
99 // deshalb hier fertig formuliert – die Oberfläche gibt sie unverändert aus. Das heißt:
100 // keine Feldnamen aus dem Vertrag, keine Formen wie „Frage(n)“, und die Beugung
101 // stimmt auch bei genau einer Frage.
102
103 /** „1 Frage“ / „7 Fragen“. */
104 function fragenZahl(anzahl: number): string {
105 return `${String(anzahl)} ${anzahl === 1 ? 'Frage' : 'Fragen'}`;
106 }
107
108 /** „1 offene Frage“ / „7 offene Fragen“. */
109 function offeneZahl(anzahl: number): string {
110 return `${String(anzahl)} ${anzahl === 1 ? 'offene Frage' : 'offene Fragen'}`;
111 }
112
113 /** „eine Auswahlfrage“ / „7 Auswahlfragen“. */
114 function auswahlZahl(anzahl: number): string {
115 return anzahl === 1 ? 'eine Auswahlfrage' : `${String(anzahl)} Auswahlfragen`;
116 }
117
118 function sindIst(anzahl: number): string {
119 return anzahl === 1 ? 'ist' : 'sind';
120 }
121
122 /** Aufzählung im Klartext: „a“, „a und b“, „a, b und c“. */
123 function aufzaehlen(teile: readonly string[]): string {
124 if (teile.length <= 1) {
125 return teile[0] ?? '';
126 }
127 return `${teile.slice(0, -1).join(', ')} und ${teile[teile.length - 1] ?? ''}`;
128 }
129
130 /**
131 * Lesbare Namen der überschreibbaren Profilwerte.
132 *
133 * Ohne diese Zuordnung stünde „anteilOffen“ auf dem Bildschirm – ein Feldname
134 * aus dem Vertrag, den außerhalb des Quelltextes niemand kennt.
135 */
136 const WERT_BEZEICHNUNG: Readonly<Record<string, string>> = Object.freeze({
137 fragenAnzahl: 'die Fragenzahl',
138 bestehensQuote: 'die Bestehensgrenze',
139 zeitMinuten: 'die Bearbeitungszeit',
140 anteilOffen: 'der Anteil offener Fragen',
141 });
142
143 /**
144 * Gehört die Frage zu diesem Bereich?
145 *
146 * Bereichsschlüssel sind entweder Abschnitts-IDs („I.1“) oder Kapitel-IDs
147 * („II“). Beides wird zugelassen, damit Themenquoten und K.-o.-Kriterien
148 * dieselbe Schreibweise benutzen können wie der Katalog.
149 */
150 function gehoertZu(frage: Frage, bereich: string): boolean {
151 return frage.abschnitt === bereich || frage.kapitel === bereich;
152 }
153
154 // ─── Zeilentypen ────────────────────────────────────────────────────────────
155
156 interface LaufZeile {
157 readonly id: number;
158 readonly pruefungsprofil: string;
159 readonly zeitpunkt: string;
160 readonly gesamt: number;
161 readonly richtig: number;
162 readonly quote: number;
163 readonly urteil: string;
164 readonly dauer_ms: number;
165 readonly zeitmodus: string | null;
166 /* Die vier aus Schema-Version 10. `null` heißt „nicht festgehalten“ und
167 wird als solches weitergereicht – siehe `laufKennzahlenSpalten`. */
168 readonly unbeantwortet: number | null;
169 readonly zeit_abgelaufen: number | null;
170 readonly bereiche: string | null;
171 readonly bestehens_quote: number | null;
172 }
173
174 // ─── Geprüfte Eingaben ──────────────────────────────────────────────────────
175
176 /** Auftrag mit aufgelösten Vorgaben; `profil` enthält bereits die Überschreibungen. */
177 interface GepruefterAuftrag {
178 readonly profil: Pruefungsprofil;
179 readonly zeitmodus: Zeitmodus;
180 readonly kapitelAusschluss: readonly string[];
181 readonly optionenMischen: boolean;
182 /**
183 * Die geprüfte Nutzlast, aus der dieser Auftrag entstand.
184 *
185 * Wird beim Start als offener Lauf abgelegt, damit ein fortgesetzter Lauf
186 * unter denselben Vorgaben zu Ende geht, unter denen er begonnen hat. Ohne
187 * sie ließe sich ein fortgesetzter Standardbogen mit dem Fehlerpunkte-Profil
188 * abgeben.
189 */
190 readonly roh: Record<string, unknown>;
191 }
192
193 /** Zeile aus `pruefung_offen`, roh wie sie in der Datenbank steht. */
194 interface OffeneZeile {
195 readonly lauf_id: string;
196 readonly auftrag: string;
197 readonly profil: string;
198 readonly bogen: string;
199 readonly eingaben: string;
200 readonly position: number;
201 readonly phase: string;
202 readonly zeit_abgelaufen: number;
203 readonly verbraucht_ms: number;
204 readonly begonnen_am: string;
205 readonly gesichert_am: string;
206 }
207
208 /** Geprüfter Zwischenstand, wie ihn der Renderer schickt. */
209 interface GepruefterStand {
210 readonly eingaben: Readonly<Record<string, GesicherteEingabe>>;
211 readonly position: number;
212 readonly phase: 'bearbeiten' | 'nachbewertung';
213 readonly zeitAbgelaufen: boolean;
214 readonly verbrauchtMs: number;
215 }
216
217 /** JSON aus der Datenbank – `null`, wenn es sich nicht lesen lässt. */
218 function leseJson(text: string): unknown {
219 try {
220 return JSON.parse(text) as unknown;
221 } catch {
222 return null;
223 }
224 }
225
226 /**
227 * Die gespeicherte Themenanalyse eines Laufs.
228 *
229 * Wie bei den gesicherten Eingaben gilt: Was nicht als gültiger Eintrag lesbar
230 * ist, fällt weg. Ein halb lesbares Feld darf die Verlaufsanzeige nicht zu
231 * Fall bringen – schlimmstenfalls fehlt einem alten Lauf die Aufschlüsselung,
232 * und die Oberfläche sagt das ohnehin für jeden Lauf vor Fassung 10.
233 *
234 * Ergibt sich kein einziger gültiger Eintrag, wird `null` zurückgegeben und
235 * nicht etwa eine leere Liste: „keine Bereiche“ und „nicht festgehalten“ sind
236 * zwei verschiedene Aussagen, und nur die zweite trifft hier zu.
237 */
238 function bereicheLesen(text: string | null): BereichErgebnis[] | null {
239 if (text === null) {
240 return null;
241 }
242 const roh = leseJson(text);
243 if (!Array.isArray(roh)) {
244 return null;
245 }
246
247 const bereiche: BereichErgebnis[] = [];
248 for (const eintrag of roh) {
249 if (typeof eintrag !== 'object' || eintrag === null) {
250 continue;
251 }
252 const werte = eintrag as Record<string, unknown>;
253 const bereich = werte['bereich'];
254 const titel = werte['titel'];
255 const gesamt = werte['gesamt'];
256 const richtig = werte['richtig'];
257 if (
258 typeof bereich === 'string' &&
259 typeof titel === 'string' &&
260 typeof gesamt === 'number' &&
261 typeof richtig === 'number' &&
262 Number.isInteger(gesamt) &&
263 Number.isInteger(richtig) &&
264 gesamt > 0 &&
265 richtig >= 0 &&
266 richtig <= gesamt
267 ) {
268 bereiche.push({ bereich, titel, gesamt, richtig });
269 }
270 }
271 return bereiche.length > 0 ? bereiche : null;
272 }
273
274 /**
275 * Gesicherte Eingaben aus der Datenbank.
276 *
277 * Nachlässig gelesen wäre hier ein Einfallstor: Die Datei liegt im
278 * `userData`-Verzeichnis. Was nicht als Eingabe lesbar ist, fällt weg –
279 * schlimmstenfalls fehlt eine Antwort, statt dass etwas Erfundenes gewertet
280 * wird.
281 */
282 function leseEingaben(text: string): Readonly<Record<string, GesicherteEingabe>> {
283 const roh = leseJson(text);
284 if (typeof roh !== 'object' || roh === null || Array.isArray(roh)) {
285 return {};
286 }
287
288 const eingaben: Record<string, GesicherteEingabe> = {};
289 for (const [frageId, wert] of Object.entries(roh as Record<string, unknown>)) {
290 if (typeof wert !== 'object' || wert === null) {
291 continue;
292 }
293 const eintrag = wert as Record<string, unknown>;
294 const auswahlRoh = eintrag['auswahl'];
295 const auswahl = Array.isArray(auswahlRoh)
296 ? auswahlRoh.filter((l): l is string => typeof l === 'string')
297 : [];
298 const freitextRoh = eintrag['freitext'];
299 const selbstRoh = eintrag['selbst'];
300
301 eingaben[frageId] = {
302 auswahl,
303 freitext: typeof freitextRoh === 'string' ? freitextRoh : '',
304 ...(typeof selbstRoh === 'boolean' ? { selbst: selbstRoh } : {}),
305 };
306 }
307 return eingaben;
308 }
309
310 /**
311 * Der zuletzt ausgegebene Bogen eines Profils – und ob er schon gewertet ist.
312 *
313 * Das zweite Feld ist der Riegel gegen die doppelte Auswertung: Bis 0.27.2
314 * wurde der Eintrag nach der Wertung gelöscht, und der nächste Aufruf lief
315 * damit in den Zweig „Bogen nicht bekannt“, der alles annimmt.
316 */
317 interface Merkposten {
318 readonly ids: ReadonlySet<string>;
319 readonly ausgewertet: boolean;
320 }
321
322 /** Eine geprüfte Antwort; `richtig` ist bereits amtlich nachgerechnet. */
323 interface GepruefteAntwort {
324 readonly frage: Frage;
325 readonly auswahl: readonly string[];
326 readonly freitext: string | null;
327 readonly richtig: boolean;
328 readonly unbeantwortet: boolean;
329 }
330
331 export interface PruefungOptionen {
332 /** Zeitgeber – in Tests überschreibbar. */
333 readonly jetzt?: () => Date;
334 /** Zufallsquelle für das Ziehen und Mischen – in Tests überschreibbar. */
335 readonly zufall?: () => number;
336 /** Ziel für Warnungen; standardmäßig das Protokoll des Main-Prozesses. */
337 readonly warnen?: (meldung: string) => void;
338 }
339
340 // ─── Prüfungssimulation ─────────────────────────────────────────────────────
341
342 export class Pruefung {
343 private readonly db: BetterSqlite3.Database;
344 private readonly katalog: Katalog;
345 private readonly lernstand: Lernstand;
346 private readonly fragen: ReadonlyMap<string, Frage>;
347 private readonly kapitelIds: ReadonlySet<string>;
348 private readonly bereichTitel: ReadonlyMap<string, string>;
349 private readonly jetzt: () => Date;
350 private readonly zufall: () => number;
351 private readonly warnAusgabe: (meldung: string) => void;
352 /**
353 * Sammelstelle für die Warnungen des gerade entstehenden Bogens.
354 *
355 * `null`, solange kein Bogen gezogen wird. Warnungen, die außerhalb davon
356 * entstehen – etwa beim Lesen eines unbrauchbaren offenen Laufs –, gehören
357 * zu keinem Bogen und bleiben im Protokoll: Die Oberfläche erfährt von
358 * diesem Fall bereits daran, dass die Karte des unterbrochenen Laufs
359 * ausbleibt.
360 */
361 private warnungsSammler: string[] | null = null;
362 /**
363 * Der zuletzt ausgegebene Bogen je Profil.
364 *
365 * `starten` gibt den Bogen aus, `auswerten` prüft die eingehende
366 * Antwortliste dagegen. Ohne diese Merkung nähme der Kern jede beliebige
367 * Liste an – ein Lauf mit einer einzigen richtigen Antwort landete als
368 * „bestanden" im Verlauf, obwohl der Bogen 80 Fragen hatte.
369 *
370 * Bewusst nur im Speicher: Nach einem Neustart ist der Bogen unbekannt,
371 * und dann wird die Liste angenommen statt den Lauf zu verwerfen. Eine
372 * abgebrochene Sitzung soll niemandem seine Arbeit kosten.
373 */
374 private letzterBogen = new Map<number, Merkposten>();
375
376 /**
377 * Kennung des offenen Laufs je Profil.
378 *
379 * Der Renderer bekommt sie nie zu sehen und kann sie deshalb nicht
380 * erfinden. Zusammen mit derselben Kennung in der `WHERE`-Bedingung des
381 * Sicherns ergibt das zwei unabhaengige Riegel gegen eine verspaetete
382 * Sicherung, die einen bereits gewerteten Lauf wiederbelebt.
383 */
384 private offenerLauf = new Map<number, string>();
385
386 constructor(lernstand: Lernstand, katalog: Katalog, optionen: PruefungOptionen = {}) {
387 this.lernstand = lernstand;
388 this.db = lernstand.datenbank;
389 this.katalog = katalog;
390 this.fragen = new Map(katalog.fragen.map((f) => [f.id, f]));
391 this.kapitelIds = new Set(katalog.kapitel.map((k) => k.id));
392
393 const titel = new Map<string, string>();
394 for (const kapitel of katalog.kapitel) {
395 titel.set(kapitel.id, kapitel.titel);
396 for (const abschnitt of kapitel.abschnitte) {
397 titel.set(abschnitt.id, abschnitt.titel);
398 }
399 }
400 this.bereichTitel = titel;
401
402 this.jetzt = optionen.jetzt ?? ((): Date => new Date());
403 this.zufall = optionen.zufall ?? Math.random;
404 this.warnAusgabe =
405 optionen.warnen ??
406 ((meldung: string): void => {
407 console.warn(`[pruefung] ${meldung}`);
408 });
409 }
410
411 /**
412 * Meldet eine Abweichung von den Vorgaben.
413 *
414 * Zwei Ziele, und beide werden gebraucht: das Protokoll des Hauptprozesses
415 * für die Fehlersuche und – solange ein Bogen entsteht – der Bogen selbst,
416 * damit die Oberfläche die Abweichung anzeigen kann.
417 *
418 * Gleichlautendes wird nur einmal gesammelt: {@link zieheTeil} läuft je
419 * Themenbereich und sagte demselben Prüfling sonst achtmal denselben Satz.
420 */
421 private warnen(meldung: string): void {
422 const sammler = this.warnungsSammler;
423 if (sammler !== null && !sammler.includes(meldung)) {
424 sammler.push(meldung);
425 }
426 this.warnAusgabe(meldung);
427 }
428
429 /** Bereich mit seinem Titel, sofern der Katalog einen führt. */
430 private bereichName(bereich: string): string {
431 const titel = this.bereichTitel.get(bereich);
432 return titel === undefined || titel === bereich
433 ? `Themenbereich „${bereich}“`
434 : `Themenbereich „${bereich} – ${titel}“`;
435 }
436
437 // ── Auftrag ──────────────────────────────────────────────────────────
438
439 private auftragPruefen(wertRoh: unknown): GepruefterAuftrag {
440 const roh = nutzlast(wertRoh, 'Der Prüfungsauftrag');
441
442 const profilId = roh['profilId'];
443 if (typeof profilId !== 'string') {
444 abweisen('Ungültige Anfrage: profilId muss eine Zeichenkette sein.');
445 }
446 const basis = profilFinden(profilId);
447 if (basis === undefined) {
448 abweisen(`Unbekanntes Prüfungsprofil: „${entschaerft(profilId)}“.`);
449 }
450
451 const zeitmodus = roh['zeitmodus'];
452 if (!istZeitmodus(zeitmodus)) {
453 abweisen(
454 `Ungültige Anfrage: zeitmodus muss einer von ${Object.keys(ZEITMODUS_BEZEICHNUNG).join(', ')} sein, ` +
455 `war aber „${entschaerft(zeitmodus)}“.`,
456 );
457 }
458
459 const kapitelAusschluss = textliste(roh['kapitelAusschluss'], 'kapitelAusschluss');
460 for (const id of kapitelAusschluss) {
461 if (!this.kapitelIds.has(id)) {
462 abweisen(`Unbekanntes Kapitel: „${entschaerft(id)}“.`);
463 }
464 }
465
466 const optionenMischenRoh = roh['optionenMischen'];
467 if (optionenMischenRoh !== undefined && typeof optionenMischenRoh !== 'boolean') {
468 abweisen('Ungültige Anfrage: optionenMischen muss ein Wahrheitswert sein.');
469 }
470
471 return {
472 profil: this.effektivesProfil(basis, roh),
473 zeitmodus,
474 kapitelAusschluss,
475 /* Ohne ausdrückliche Angabe wird nicht gemischt – wie im
476 Lernmodus. Gerade die Simulation soll dem gedruckten Bogen
477 nahekommen, und sie zeigt keine Ziffernspalte, die eine
478 verdrehte Buchstabenfolge abmildern könnte. */
479 optionenMischen: optionenMischenRoh ?? false,
480 roh,
481 };
482 }
483
484 /**
485 * Wendet die Überschreibungen des Auftrags an.
486 *
487 * Nur anpassbare Profile lassen sich überschreiben – sonst hätte die
488 * Angabe „nach Art des DSB“ keinen Aussagewert mehr. Eine ignorierte
489 * Überschreibung wird protokolliert statt stillschweigend verworfen.
490 */
491 private effektivesProfil(basis: Pruefungsprofil, roh: Record<string, unknown>): Pruefungsprofil {
492 const anzahlRoh = roh['fragenAnzahl'];
493 const quoteRoh = roh['bestehensQuote'];
494 const zeitRoh = roh['zeitMinuten'];
495 const offenRoh = roh['anteilOffen'];
496
497 if (basis.anpassbar !== true) {
498 const ueberschrieben = [
499 anzahlRoh !== undefined && anzahlRoh !== basis.fragenAnzahl ? 'fragenAnzahl' : null,
500 quoteRoh !== undefined && quoteRoh !== basis.bestehensQuote ? 'bestehensQuote' : null,
501 zeitRoh !== undefined && zeitRoh !== basis.zeitMinuten ? 'zeitMinuten' : null,
502 offenRoh !== undefined && offenRoh !== basis.anteilOffen ? 'anteilOffen' : null,
503 ].filter((name): name is string => name !== null);
504 if (ueberschrieben.length > 0) {
505 const namen = aufzaehlen(ueberschrieben.map((feld) => WERT_BEZEICHNUNG[feld] ?? feld));
506 this.warnen(
507 `Das Profil „${basis.name}“ hat feste Vorgaben: ${namen} ` +
508 `${ueberschrieben.length === 1 ? 'wurde' : 'wurden'} nicht übernommen. ` +
509 'Es gilt, was das Profil vorsieht.',
510 );
511 }
512 return basis;
513 }
514
515 const fragenAnzahl =
516 anzahlRoh === undefined || anzahlRoh === null
517 ? basis.fragenAnzahl
518 : ganzeZahl(anzahlRoh, 'fragenAnzahl', 1, MAX_BOGENGROESSE);
519
520 const bestehensQuote =
521 quoteRoh === undefined || quoteRoh === null
522 ? basis.bestehensQuote
523 : endlicheZahl(quoteRoh, 'bestehensQuote', 0, 1);
524
525 const zeitMinuten =
526 zeitRoh === undefined
527 ? basis.zeitMinuten
528 : zeitRoh === null
529 ? null
530 : ganzeZahl(zeitRoh, 'zeitMinuten', 1, MAX_ZEIT_MINUTEN);
531
532 /*
533 Der Anteil offener Fragen ist ein Wunsch, keine Zusage: Der Katalog hat
534 104 offene Fragen von 575, und sie liegen ungleich – Kapitel III hat eine
535 einzige von 49. Was sich nicht ziehen lässt, meldet `ziehen` als Warnung.
536 Er wird deshalb hier nur geprüft, nicht zurechtgebogen.
537 */
538 const anteilOffen =
539 offenRoh === undefined || offenRoh === null
540 ? basis.anteilOffen
541 : endlicheZahl(offenRoh, 'anteilOffen', 0, 1);
542
543 // `exactOptionalPropertyTypes`: optionale Felder nur setzen, wenn sie
544 // wirklich einen Wert haben.
545 return {
546 ...basis,
547 fragenAnzahl,
548 ...(bestehensQuote === undefined ? {} : { bestehensQuote }),
549 ...(anteilOffen === undefined ? {} : { anteilOffen }),
550 zeitMinuten,
551 };
552 }
553
554 // ── Bogen zusammenstellen ────────────────────────────────────────────
555
556 /**
557 * Zieht `anzahl` Fragen aus dem Vorrat und legt sie in `gewaehlt` ab.
558 *
559 * Der Vorrat ist bereits gemischt, deshalb genügt es, von vorn zu nehmen.
560 * `wunschOffen` gibt vor, wie viele davon offene Fragen sein sollen; der
561 * Rest ist Multiple Choice. Reicht der Vorrat einer Art nicht, füllt die
562 * andere auf. Jede Abweichung wird protokolliert, damit sie nicht unbemerkt
563 * bleibt.
564 *
565 * Die Zahl kommt von außen statt aus einem Anteil, weil sie sich nur
566 * bogenweit sinnvoll bestimmen lässt: Bereiche mit wenigen offenen Fragen
567 * müssen von Bereichen mit vielen ausgeglichen werden – siehe
568 * {@link offeneJeBereich}.
569 *
570 * @param ort Wo das geschieht, als Satzanfang: „Im Bogen“ oder
571 * „Im Themenbereich …“. Die Warnungen erscheinen so, wie sie hier
572 * entstehen, auf dem Bildschirm.
573 */
574 private zieheTeil(
575 vorrat: readonly Frage[],
576 anzahl: number,
577 wunschOffen: number,
578 gewaehlt: Frage[],
579 benutzt: Set<string>,
580 ort: string,
581 ): void {
582 if (anzahl <= 0) {
583 return;
584 }
585
586 const offene = vorrat.filter((f) => istOffen(f));
587 const mc = vorrat.filter((f) => !istOffen(f));
588
589 const nimm = (liste: readonly Frage[], wieviele: number): number => {
590 let genommen = 0;
591 for (const frage of liste) {
592 if (genommen >= wieviele) {
593 break;
594 }
595 if (benutzt.has(frage.id)) {
596 continue;
597 }
598 benutzt.add(frage.id);
599 gewaehlt.push(frage);
600 genommen += 1;
601 }
602 return genommen;
603 };
604
605 const offenGenommen = nimm(offene, wunschOffen);
606 if (offenGenommen < wunschOffen) {
607 const fehlend = wunschOffen - offenGenommen;
608 this.warnen(
609 `${ort}: ${offeneZahl(fehlend)} ${fehlend === 1 ? 'wurde' : 'wurden'} durch ` +
610 `${auswahlZahl(fehlend)} ersetzt – so viele offene Fragen hat der Katalog dort nicht.`,
611 );
612 }
613
614 const wunschMc = anzahl - offenGenommen;
615 const mcGenommen = nimm(mc, wunschMc);
616 if (mcGenommen < wunschMc) {
617 const rest = wunschMc - mcGenommen;
618 /*
619 Nachgelegt wird nur, wo offene Fragen überhaupt erwünscht sind.
620
621 Wer im frei eingestellten Profil „Anteil offener Fragen: 0“ wählt,
622 liest in der Prüfungswahl: „Der Bogen besteht damit ausschließlich aus
623 Auswahlfragen.“ Bis Fassung 0.24.1 legte der Kern trotzdem offene
624 Fragen nach, sobald die Auswahlfragen nicht reichten – bei 500
625 gewünschten Fragen kamen 471 Auswahlfragen und 29 offene, ohne ein
626 Wort. Nach der Abgabe sprang dann unerwartet die Selbstbewertung mit
627 29 Einträgen dazwischen. Der Ersatzweg im Renderer (`bogen.ts`) hielt
628 die Zusage ein; beide Wege widersprachen sich.
629 */
630 const nachgelegt = wunschOffen > 0 ? nimm(offene, rest) : 0;
631 if (nachgelegt < rest) {
632 const fehlend = rest - nachgelegt;
633 this.warnen(
634 `${ort}: ${fragenZahl(fehlend)} ${fehlend === 1 ? 'fehlt' : 'fehlen'}; ` +
635 'der Bogen wird aus den übrigen Bereichen aufgefüllt.',
636 );
637 } else if (nachgelegt > 0) {
638 /* Aufgefüllt wurde, und der Bogen sieht damit anders aus als
639 bestellt. Das gehört gesagt – bis 0.24.1 geschah es stumm. */
640 this.warnen(
641 `${ort}: ${auswahlZahl(nachgelegt)} ${nachgelegt === 1 ? 'wurde' : 'wurden'} durch ` +
642 `${offeneZahl(nachgelegt)} ersetzt – so viele Auswahlfragen hat der Katalog dort nicht.`,
643 );
644 }
645 }
646 }
647
648 /**
649 * Verteilt die gewünschte Zahl offener Fragen auf die Themenbereiche.
650 *
651 * Der Anteil gilt für den **Bogen**, nicht für jeden Bereich einzeln. Wird
652 * er stur bereichsweise angewandt, verfällt der Fehlbetrag dort, wo der
653 * Katalog wenige offene Fragen hat: Beim DSB-Profil enthalten I.4 und III
654 * je genau eine, und der Bogen kam reproduzierbar auf 18 statt 20 offene
655 * Fragen – bei einer Profilbeschreibung, die „höchstens 80 Prozent
656 * Multiple Choice" zusagt.
657 *
658 * Deshalb wird zuerst bereichsweise angesetzt und der Rest anschließend
659 * dorthin gelegt, wo noch offene Fragen übrig sind. Die Themenquoten
660 * bleiben unberührt – verschoben wird nur die Typmischung innerhalb eines
661 * Bereichs.
662 *
663 * @param teile Bereiche mit ihrer Quote und ihrem Vorrat.
664 * @param gesamt Gewünschte Zahl offener Fragen im ganzen Bogen.
665 * @returns Je Bereich die Zahl der offenen Fragen, in derselben Reihenfolge.
666 */
667 private static offeneJeBereich(
668 teile: readonly { readonly anzahl: number; readonly offenVerfuegbar: number }[],
669 gesamt: number,
670 ): number[] {
671 const obergrenze = teile.map((t) => Math.min(t.anzahl, t.offenVerfuegbar));
672 const summeMoeglich = obergrenze.reduce((a, b) => a + b, 0);
673 const ziel = Math.min(gesamt, summeMoeglich);
674
675 // Erster Ansatz: der Anteil, den der Bereich seiner Größe nach trägt.
676 const gesamtQuote = teile.reduce((a, t) => a + t.anzahl, 0);
677 const verteilt = teile.map((teil, i) =>
678 Math.min(
679 obergrenze[i] ?? 0,
680 gesamtQuote > 0 ? Math.floor((ziel * teil.anzahl) / gesamtQuote) : 0,
681 ),
682 );
683
684 /* Rest auffüllen: immer dort, wo noch Luft ist. Der Reihe nach statt
685 zufällig – der Vorrat selbst ist bereits gemischt, und eine feste
686 Reihenfolge macht das Ergebnis nachvollziehbar. */
687 let rest = ziel - verteilt.reduce((a, b) => a + b, 0);
688 while (rest > 0) {
689 const index = verteilt.findIndex((wert, i) => wert < (obergrenze[i] ?? 0));
690 if (index === -1) {
691 break; // nirgends mehr Platz
692 }
693 verteilt[index] = (verteilt[index] ?? 0) + 1;
694 rest -= 1;
695 }
696
697 return verteilt;
698 }
699
700 /** Stellt die Fragen eines Bogens zusammen – ohne Dubletten. */
701 private ziehen(auftrag: GepruefterAuftrag): Frage[] {
702 const profil = auftrag.profil;
703 const ausgeschlossen = new Set(auftrag.kapitelAusschluss);
704 const vorrat = gemischt(
705 this.katalog.fragen.filter((f) => !ausgeschlossen.has(f.kapitel)),
706 this.zufall,
707 );
708
709 if (vorrat.length === 0) {
710 abweisen('Es bleibt keine einzige Frage übrig. Bitte weniger Kapitel abwählen.');
711 }
712
713 const ziel = Math.min(profil.fragenAnzahl, vorrat.length);
714 if (vorrat.length < profil.fragenAnzahl) {
715 this.warnen(
716 `Das Profil „${profil.name}“ sieht ${fragenZahl(profil.fragenAnzahl)} vor, ` +
717 `zur Auswahl stehen aber nur ${String(vorrat.length)}. ` +
718 `Der Bogen umfasst deshalb ${fragenZahl(ziel)}.`,
719 );
720 }
721
722 // Ohne `anteilOffen` besteht der Bogen ausschließlich aus Multiple Choice.
723 const anteilOffen = gewuenschteTypen(profil).includes('freitext')
724 ? (profil.anteilOffen ?? 0)
725 : 0;
726
727 const gewaehlt: Frage[] = [];
728 const benutzt = new Set<string>();
729
730 /* Themenquoten zuerst: sie sind die härtere Vorgabe. Wie viele offene
731 Fragen je Bereich gezogen werden, entscheidet sich aber bogenweit –
732 sonst verfällt der Fehlbetrag in Bereichen mit wenigen offenen Fragen
733 (siehe offeneJeBereich). */
734 const quoten = profil.themenquoten;
735 if (quoten !== undefined) {
736 const teile = Object.entries(quoten).map(([bereich, anzahl]) => {
737 const teilVorrat = vorrat.filter((f) => gehoertZu(f, bereich));
738 return {
739 bereich,
740 anzahl: Math.min(anzahl, teilVorrat.length),
741 gewuenscht: anzahl,
742 teilVorrat,
743 offenVerfuegbar: teilVorrat.filter((f) => istOffen(f)).length,
744 };
745 });
746
747 const offenZiel = Math.round(teile.reduce((summe, t) => summe + t.anzahl, 0) * anteilOffen);
748 const offenJeTeil = Pruefung.offeneJeBereich(teile, offenZiel);
749
750 teile.forEach((teil, i) => {
751 const ort = `Im ${this.bereichName(teil.bereich)}`;
752 if (teil.teilVorrat.length < teil.gewuenscht) {
753 this.warnen(
754 `${ort}: Vorgesehen ${sindIst(teil.gewuenscht)} ${fragenZahl(teil.gewuenscht)}, ` +
755 `verfügbar ${sindIst(teil.teilVorrat.length)} ${fragenZahl(teil.teilVorrat.length)}. ` +
756 'Die fehlenden Fragen kommen aus den übrigen Bereichen.',
757 );
758 }
759 this.zieheTeil(
760 teil.teilVorrat.filter((f) => !benutzt.has(f.id)),
761 teil.anzahl,
762 offenJeTeil[i] ?? 0,
763 gewaehlt,
764 benutzt,
765 ort,
766 );
767 });
768 }
769
770 // Auffüllen: ohne Quoten der ganze Bogen, mit Quoten nur, was fehlt.
771 const fehlend = ziel - gewaehlt.length;
772 if (fehlend > 0) {
773 /* Was bisher an offenen Fragen zusammenkam, wird angerechnet – sonst
774 läge der Anteil am Ende über dem Ziel. */
775 const bisherOffen = gewaehlt.filter((f) => istOffen(f)).length;
776 const nochOffen = Math.max(0, Math.round(ziel * anteilOffen) - bisherOffen);
777
778 this.zieheTeil(
779 vorrat.filter((f) => !benutzt.has(f.id)),
780 fehlend,
781 Math.min(nochOffen, fehlend),
782 gewaehlt,
783 benutzt,
784 'Im Bogen',
785 );
786 }
787
788 // Erst mischen, dann kürzen: liegt die Summe der Quoten über der
789 // Bogengröße, fällt nicht immer derselbe Bereich hinten herunter.
790 return gemischt(gewaehlt, this.zufall).slice(0, ziel);
791 }
792
793 /**
794 * Stellt einen Prüfungsbogen zusammen.
795 *
796 * Die Reihenfolge der Antwortoptionen folgt dem Katalog, sofern der
797 * Auftrag nicht ausdrücklich `optionenMischen` verlangt.
798 *
799 * Der Bogen wird zugleich als offener Lauf festgehalten (`pruefung_offen`).
800 * Nicht erst mit der ersten Sicherung: Stürbe das Programm in der ersten
801 * Sekunde, wäre der Bogen trotz aller Vorsorge weg. Ein bereits offener
802 * Lauf desselben Profils wird dabei ersetzt – die Oberfläche fragt vorher,
803 * ob er verworfen werden darf.
804 *
805 * Zurück kommt ein {@link Pruefungsbogen} und keine reine Fragenliste: Jede
806 * Abweichung von den Vorgaben steht in `warnungen` und geht damit an den
807 * Bildschirm statt nur ins Protokoll.
808 */
809 starten(profilIdRoh: unknown, auftragRoh: unknown): Pruefungsbogen {
810 const profilId = this.lernstand.profilIdPruefen(profilIdRoh);
811
812 /* Die Sammlung beginnt vor dem Prüfen des Auftrags: Die Meldung über eine
813 nicht übernommene Überschreibung entsteht dort und gehört genauso an den
814 Bildschirm wie die drei Meldungen des Ziehens. Das `finally` schließt
815 die Sammlung auch dann, wenn ein ungültiger Auftrag abgewiesen wird –
816 sonst liefe der nächste Bogen in eine fremde Liste. */
817 const warnungen: string[] = [];
818 this.warnungsSammler = warnungen;
819 let auftrag: GepruefterAuftrag;
820 let gezogen: Frage[];
821 try {
822 auftrag = this.auftragPruefen(auftragRoh);
823 gezogen = this.ziehen(auftrag);
824 } finally {
825 this.warnungsSammler = null;
826 }
827
828 this.letzterBogen.set(profilId, {
829 ids: new Set(gezogen.map((frage) => frage.id)),
830 ausgewertet: false,
831 });
832
833 const fragen = gezogen.map((frage) => {
834 const labels = (frage.optionen ?? []).map((o) => o.label);
835 return {
836 frageId: frage.id,
837 optionsReihenfolge: auftrag.optionenMischen ? gemischt(labels, this.zufall) : labels,
838 };
839 });
840
841 this.offenenLaufAnlegen(profilId, auftrag, fragen);
842 return { fragen, warnungen };
843 }
844
845 // ── Offener Lauf ─────────────────────────────────────────────────────
846
847 /**
848 * Legt den gerade gezogenen Bogen als offenen Lauf ab.
849 *
850 * Geschrieben wird hier, nicht im Renderer: `auftrag`, `profil` und `bogen`
851 * entstehen in diesem Modul, und nur so kann sie niemand unterwegs
852 * austauschen. Der Renderer sichert später allein, was ihm gehört.
853 */
854 private offenenLaufAnlegen(
855 profilId: number,
856 auftrag: GepruefterAuftrag,
857 bogen: readonly Pruefungsfrage[],
858 ): void {
859 const laufId = this.laufKennung();
860 const jetzt = this.jetzt().toISOString();
861
862 this.db
863 .prepare<{
864 profil_id: number;
865 lauf_id: string;
866 auftrag: string;
867 profil: string;
868 bogen: string;
869 begonnen_am: string;
870 }>(
871 `INSERT INTO pruefung_offen
872 (profil_id, lauf_id, auftrag, profil, bogen, eingaben, position, phase,
873 zeit_abgelaufen, verbraucht_ms, begonnen_am, gesichert_am)
874 VALUES
875 (@profil_id, @lauf_id, @auftrag, @profil, @bogen, '{}', 0, 'bearbeiten',
876 0, 0, @begonnen_am, @begonnen_am)
877 ON CONFLICT (profil_id) DO UPDATE SET
878 lauf_id = excluded.lauf_id, auftrag = excluded.auftrag,
879 profil = excluded.profil, bogen = excluded.bogen,
880 eingaben = '{}', position = 0, phase = 'bearbeiten',
881 zeit_abgelaufen = 0, verbraucht_ms = 0,
882 begonnen_am = excluded.begonnen_am, gesichert_am = excluded.begonnen_am`,
883 )
884 .run({
885 profil_id: profilId,
886 lauf_id: laufId,
887 auftrag: JSON.stringify(auftrag.roh),
888 profil: JSON.stringify(auftrag.profil),
889 bogen: JSON.stringify(bogen),
890 begonnen_am: jetzt,
891 });
892
893 this.offenerLauf.set(profilId, laufId);
894 }
895
896 /**
897 * Kennung eines Laufs.
898 *
899 * Braucht keine kryptografische Güte – sie unterscheidet zwei Läufe
900 * desselben Profils, mehr nicht. Zeitpunkt und Zufall zusammen genügen
901 * dafür auch dann, wenn die Systemuhr steht.
902 */
903 private laufKennung(): string {
904 const zeit = this.jetzt().getTime().toString(36);
905 const zufall = Math.floor(this.zufall() * 0xffffff)
906 .toString(36)
907 .padStart(5, '0');
908 return `${zeit}-${zufall}`;
909 }
910
911 /**
912 * Sichert den Zwischenstand eines laufenden Bogens.
913 *
914 * Ausschließlich `UPDATE`, niemals `INSERT`: Das Sichern ist entprellt, und
915 * ein verspäteter Nachzügler darf die Zeile nicht wiederauferstehen lassen,
916 * die die Auswertung gerade gelöscht hat – sonst ließe sich derselbe Lauf
917 * ein zweites Mal abgeben und jede Antwort ginge ein zweites Mal in die
918 * Wiedervorlage. Findet das `UPDATE` keine Zeile, ist der Lauf vorbei und
919 * die Sicherung verfällt still.
920 *
921 * Zwei unabhängige Riegel: die Lauf-Kennung im Speicher (die der Renderer
922 * nie zu sehen bekommt und deshalb nicht erfinden kann) und dieselbe
923 * Kennung in der `WHERE`-Bedingung.
924 */
925 sichern(profilIdRoh: unknown, standRoh: unknown): boolean {
926 const profilId = this.lernstand.profilIdPruefen(profilIdRoh);
927 const laufId = this.offenerLauf.get(profilId);
928 if (laufId === undefined) {
929 return false;
930 }
931
932 const zeile = this.offeneZeile(profilId);
933 if (zeile?.lauf_id !== laufId) {
934 this.offenerLauf.delete(profilId);
935 return false;
936 }
937
938 const bogen = this.bogenAusZeile(zeile);
939 if (bogen === null) {
940 return false;
941 }
942
943 const stand = this.standPruefen(standRoh, bogen);
944
945 const ergebnis = this.db
946 .prepare<{
947 eingaben: string;
948 position: number;
949 phase: string;
950 zeit_abgelaufen: number;
951 verbraucht_ms: number;
952 gesichert_am: string;
953 profil_id: number;
954 lauf_id: string;
955 }>(
956 `UPDATE pruefung_offen
957 SET eingaben = @eingaben, position = @position, phase = @phase,
958 zeit_abgelaufen = @zeit_abgelaufen, verbraucht_ms = @verbraucht_ms,
959 gesichert_am = @gesichert_am
960 WHERE profil_id = @profil_id AND lauf_id = @lauf_id`,
961 )
962 .run({
963 eingaben: JSON.stringify(stand.eingaben),
964 position: stand.position,
965 phase: stand.phase,
966 zeit_abgelaufen: stand.zeitAbgelaufen ? 1 : 0,
967 verbraucht_ms: stand.verbrauchtMs,
968 gesichert_am: this.jetzt().toISOString(),
969 profil_id: profilId,
970 lauf_id: laufId,
971 });
972
973 return ergebnis.changes > 0;
974 }
975
976 /**
977 * Der offene Lauf eines Profils, oder `null`.
978 *
979 * Die Datei liegt im `userData`-Verzeichnis und ist von außen beschreibbar;
980 * gelesen wird sie deshalb mit derselben Strenge wie eine Nutzlast aus dem
981 * Renderer. Was sich nicht als gültiger Lauf lesen lässt, wird verworfen
982 * statt geraten – ein halb verstandener Bogen wäre schlimmer als keiner.
983 *
984 * Nebenwirkung mit Absicht: Ein gelesener Bogen füllt `letzterBogen` wieder.
985 * Damit prüft die Auswertung die eingereichte Antwortliste auch nach einem
986 * Neustart, statt sie ungeprüft anzunehmen.
987 */
988 offenerBogen(profilIdRoh: unknown): OffenerLauf | null {
989 const profilId = this.lernstand.profilIdPruefen(profilIdRoh);
990 const zeile = this.offeneZeile(profilId);
991 if (zeile === undefined) {
992 return null;
993 }
994
995 const bogen = this.bogenAusZeile(zeile);
996 if (bogen === null) {
997 this.verwerfenIntern(profilId);
998 return null;
999 }
1000
1001 /* Der Auftrag wird beim Lesen erneut vollständig geprüft, nicht nur
1002 geparst: Die Datei liegt im `userData`-Verzeichnis, und ein von Hand
1003 veränderter Auftrag brächte sonst ein erfundenes Profil in die
1004 Wertung. Was hier durchfällt, wird verworfen statt geraten. */
1005 const auftragRoh = leseJson(zeile.auftrag);
1006 let auftrag: GepruefterAuftrag;
1007 try {
1008 auftrag = this.auftragPruefen(auftragRoh);
1009 } catch {
1010 this.warnen('Offener Lauf ohne gültigen Auftrag – die Zeile wird verworfen.');
1011 this.verwerfenIntern(profilId);
1012 return null;
1013 }
1014
1015 this.letzterBogen.set(profilId, {
1016 ids: new Set(bogen.map((eintrag) => eintrag.frageId)),
1017 ausgewertet: false,
1018 });
1019 this.offenerLauf.set(profilId, zeile.lauf_id);
1020
1021 return {
1022 auftrag: auftragRoh as Pruefungsauftrag,
1023 /* Das wirksame Profil kommt aus dem geprüften Auftrag, nicht aus der
1024 gespeicherten Spalte: So kann eine veränderte Datei keine eigene
1025 Bestehensgrenze einschmuggeln. Die Spalte bleibt als Beleg dafür,
1026 unter welchen Vorgaben der Lauf begann. */
1027 profil: auftrag.profil,
1028 bogen,
1029 eingaben: leseEingaben(zeile.eingaben),
1030 position: Math.min(Math.max(0, zeile.position), Math.max(0, bogen.length - 1)),
1031 phase: zeile.phase === 'nachbewertung' ? 'nachbewertung' : 'bearbeiten',
1032 zeitAbgelaufen: zeile.zeit_abgelaufen === 1,
1033 verbrauchtMs: Math.min(MAX_DAUER_MS, Math.max(0, zeile.verbraucht_ms)),
1034 begonnenAm: zeile.begonnen_am,
1035 gesichertAm: zeile.gesichert_am,
1036 };
1037 }
1038
1039 /** Verwirft den offenen Lauf – nur auf ausdrücklichen Wunsch. */
1040 verwerfen(profilIdRoh: unknown): void {
1041 this.verwerfenIntern(this.lernstand.profilIdPruefen(profilIdRoh));
1042 }
1043
1044 private verwerfenIntern(profilId: number): void {
1045 this.db.prepare<[number]>('DELETE FROM pruefung_offen WHERE profil_id = ?').run(profilId);
1046 this.offenerLauf.delete(profilId);
1047 }
1048
1049 private offeneZeile(profilId: number): OffeneZeile | undefined {
1050 return this.db
1051 .prepare<[number], OffeneZeile>(
1052 `SELECT lauf_id, auftrag, profil, bogen, eingaben, position, phase,
1053 zeit_abgelaufen, verbraucht_ms, begonnen_am, gesichert_am
1054 FROM pruefung_offen WHERE profil_id = ?`,
1055 )
1056 .get(profilId);
1057 }
1058
1059 /**
1060 * Der gespeicherte Bogen, oder `null`, wenn er nicht mehr zum Katalog passt.
1061 *
1062 * Ein Katalogwechsel zwischen Unterbrechung und Fortsetzen ist der Fall,
1063 * an dem das schiefgeht: Eine Frage, die es nicht mehr gibt, ließe eine
1064 * Lücke im Bogen, und die Auswertung zählte gegen eine andere Gesamtzahl
1065 * als die Anzeige.
1066 */
1067 private bogenAusZeile(zeile: OffeneZeile): readonly Pruefungsfrage[] | null {
1068 const roh = leseJson(zeile.bogen);
1069 if (!Array.isArray(roh) || roh.length === 0) {
1070 this.warnen('Offener Lauf ohne lesbaren Bogen – die Zeile wird verworfen.');
1071 return null;
1072 }
1073
1074 const bogen: Pruefungsfrage[] = [];
1075 for (const eintrag of roh) {
1076 if (typeof eintrag !== 'object' || eintrag === null) {
1077 return null;
1078 }
1079 const kandidat = eintrag as Record<string, unknown>;
1080 const frageId = kandidat['frageId'];
1081 const reihenfolge = kandidat['optionsReihenfolge'];
1082 if (typeof frageId !== 'string' || !this.fragen.has(frageId)) {
1083 this.warnen(
1084 `Der offene Lauf nennt die Frage „${entschaerft(frageId)}“, die es im ` +
1085 'Katalog nicht mehr gibt – die Zeile wird verworfen.',
1086 );
1087 return null;
1088 }
1089 if (!Array.isArray(reihenfolge) || reihenfolge.some((l) => typeof l !== 'string')) {
1090 return null;
1091 }
1092 bogen.push({ frageId, optionsReihenfolge: reihenfolge as string[] });
1093 }
1094
1095 if (bogen.length > MAX_BOGENGROESSE) {
1096 return null;
1097 }
1098 return bogen;
1099 }
1100
1101 /**
1102 * Prüft, was der Renderer zu sichern schickt.
1103 *
1104 * Derselbe Maßstab wie bei der Auswertung: Die Nutzlast ist unbekannt, bis
1105 * sie geprüft wurde. Gemessene Größen werden gekappt statt abgewiesen – wer
1106 * ohne Zeitbegrenzung übt, soll seinen Lauf nicht wegen einer unplausiblen
1107 * Zahl verlieren.
1108 */
1109 private standPruefen(wertRoh: unknown, bogen: readonly Pruefungsfrage[]): GepruefterStand {
1110 const roh = nutzlast(wertRoh, 'Der Zwischenstand');
1111
1112 const position = ganzeZahl(roh['position'] ?? 0, 'position', 0, Math.max(0, bogen.length - 1));
1113 const verbrauchtMs = Math.min(
1114 MAX_DAUER_MS,
1115 Math.round(
1116 endlicheZahl(roh['verbrauchtMs'] ?? 0, 'verbrauchtMs', 0, Number.MAX_SAFE_INTEGER),
1117 ),
1118 );
1119 const phaseRoh = roh['phase'];
1120 if (phaseRoh !== 'bearbeiten' && phaseRoh !== 'nachbewertung') {
1121 abweisen('Ungültige Anfrage: phase muss „bearbeiten“ oder „nachbewertung“ sein.');
1122 }
1123 const zeitAbgelaufen = wahrheitswert(roh['zeitAbgelaufen'], 'zeitAbgelaufen', false);
1124
1125 const erlaubt = new Set(bogen.map((eintrag) => eintrag.frageId));
1126 const eingabenRoh = nutzlast(roh['eingaben'] ?? {}, 'Die Eingaben');
1127 const eingaben: Record<string, GesicherteEingabe> = {};
1128
1129 for (const [frageId, wert] of Object.entries(eingabenRoh)) {
1130 if (!erlaubt.has(frageId)) {
1131 abweisen(`Die Frage „${entschaerft(frageId)}“ gehört nicht zu diesem Bogen.`);
1132 }
1133 const eintrag = nutzlast(wert, `Die Eingabe zu „${entschaerft(frageId)}“`);
1134 const auswahl = textliste(eintrag['auswahl'] ?? [], 'auswahl');
1135 const freitextRoh = eintrag['freitext'];
1136 const freitext =
1137 typeof freitextRoh === 'string' ? freitextRoh.slice(0, MAX_FREITEXT_LAENGE) : '';
1138
1139 /* Dreiwertig, und das mit Absicht: „noch nicht bewertet“ ist etwas
1140 anderes als „als falsch bewertet“. Ein Ersatzwert `false` würde eine
1141 unbewertete offene Frage beim Fortsetzen als falsch festschreiben. */
1142 const selbstRoh = eintrag['selbst'];
1143 const selbst =
1144 selbstRoh === undefined || selbstRoh === null
1145 ? undefined
1146 : wahrheitswert(selbstRoh, 'selbst', false);
1147
1148 eingaben[frageId] = {
1149 auswahl,
1150 freitext,
1151 ...(selbst === undefined ? {} : { selbst }),
1152 };
1153 }
1154
1155 return { eingaben, position, phase: phaseRoh, zeitAbgelaufen, verbrauchtMs };
1156 }
1157
1158 // ── Auswertung ───────────────────────────────────────────────────────
1159
1160 private fragePruefen(wert: unknown): Frage {
1161 if (typeof wert !== 'string' || wert.length === 0) {
1162 abweisen('Ungültige Anfrage: Die Frage-ID muss eine nicht-leere Zeichenkette sein.');
1163 }
1164 const frage = this.fragen.get(wert);
1165 if (frage === undefined) {
1166 abweisen(`Unbekannte Frage-ID: „${entschaerft(wert)}“.`);
1167 }
1168 return frage;
1169 }
1170
1171 /**
1172 * Vergleicht die eingereichten Antworten mit dem ausgegebenen Bogen.
1173 *
1174 * Ohne diese Prüfung nähme der Kern jede beliebige Liste an: Ein Lauf mit
1175 * einer einzigen richtigen Antwort landete als „bestanden" im Verlauf,
1176 * obwohl der Bogen 80 Fragen hatte. Der Verlauf soll aber abbilden, was
1177 * tatsächlich bearbeitet wurde.
1178 *
1179 * Ist kein Bogen bekannt – etwa nach einem Neustart mitten im Lauf –, wird
1180 * die Liste angenommen und nur vermerkt. Eine unterbrochene Sitzung soll
1181 * niemandem seine Arbeit kosten.
1182 *
1183 * **Aber nur, solange der Lauf überhaupt noch offen ist.** Bis 0.27.2 nahm
1184 * dieser Zweig alles an, und das traf vor allem einen Fall, für den er nie
1185 * gedacht war: den **zweiten** Aufruf für denselben Lauf. Nach der ersten
1186 * Auswertung ist der Merkposten geleert und die Zeile gelöscht – die Liste
1187 * ging ein zweites Mal durch. Gemessen an einem Bogen mit 80 Fragen:
1188 * `versuche` 80 → 160, ein zweiter Verlaufseintrag, und der trug das Profil
1189 * aus dem Auftrag des Renderers statt das des gezogenen Bogens.
1190 *
1191 * Der Neustart-Fall bleibt unberührt: Er hat seine Zeile noch, und
1192 * `offenerBogen()` füllt den Merkposten beim Fortsetzen ohnehin wieder.
1193 * Genau das ist der Unterschied, an dem sich beide Lagen trennen lassen.
1194 *
1195 * `sichern()` hält gegen dieselbe Verdopplung zwei Riegel. Dies ist der
1196 * dritte, an der Stelle, an der sie wirklich geschieht.
1197 */
1198 private bogenPruefen(profilId: number, antworten: readonly GepruefteAntwort[]): void {
1199 const merkposten = this.letzterBogen.get(profilId);
1200 if (merkposten?.ausgewertet === true) {
1201 abweisen(
1202 'Dieser Prüfungslauf wurde bereits ausgewertet. Er wird nicht ein zweites Mal ' +
1203 'gewertet – sonst stünde er doppelt im Verlauf und jede Antwort ginge ein ' +
1204 'zweites Mal in die Wiedervorlage.',
1205 );
1206 }
1207 const bogen = merkposten?.ids;
1208 if (bogen === undefined) {
1209 this.warnen(
1210 'Prüfungsbogen nicht bekannt (vermutlich Neustart während des Laufs) – ' +
1211 'die eingereichten Antworten werden ungeprüft übernommen.',
1212 );
1213 return;
1214 }
1215
1216 if (antworten.length !== bogen.size) {
1217 abweisen(
1218 `Der Bogen umfasste ${String(bogen.size)} Fragen, eingereicht wurden ` +
1219 `${String(antworten.length)}.`,
1220 );
1221 }
1222 for (const antwort of antworten) {
1223 if (!bogen.has(antwort.frage.id)) {
1224 abweisen(`Die Frage „${entschaerft(antwort.frage.id)}“ war nicht Teil des Bogens.`);
1225 }
1226 }
1227
1228 /* Ein Bogen wird genau einmal ausgewertet – und das wird **vermerkt**,
1229 nicht vergessen. Gelöscht führte der nächste Aufruf in den Zweig
1230 „Bogen nicht bekannt“ und käme damit durch; genau das war der Befund. */
1231 this.letzterBogen.set(profilId, { ids: bogen, ausgewertet: true });
1232 }
1233
1234 /**
1235 * Prüft die Antworten eines Laufs.
1236 *
1237 * Der Renderer schickt für jede Frage des Bogens einen Eintrag – auch für
1238 * die unbeantworteten, dann mit leerer Auswahl. Nur so lässt sich
1239 * „unbeantwortet“ von „gar nicht gestellt“ unterscheiden.
1240 */
1241 private antwortenPruefen(wertRoh: unknown): GepruefteAntwort[] {
1242 if (!Array.isArray(wertRoh)) {
1243 abweisen('Ungültige Anfrage: antworten muss eine Liste sein.');
1244 }
1245 const roh = wertRoh as readonly unknown[];
1246 if (roh.length === 0) {
1247 abweisen('Ungültige Anfrage: Es wurde mindestens eine Antwort erwartet.');
1248 }
1249 if (roh.length > MAX_BOGENGROESSE) {
1250 abweisen(
1251 `Ungültige Anfrage: Ein Bogen umfasst höchstens ${String(MAX_BOGENGROESSE)} Fragen.`,
1252 );
1253 }
1254
1255 const gesehen = new Set<string>();
1256 return roh.map((eintragRoh, i) => {
1257 const eintrag = nutzlast(eintragRoh, `antworten[${String(i)}]`);
1258 const frage = this.fragePruefen(eintrag['frageId']);
1259
1260 if (gesehen.has(frage.id)) {
1261 abweisen(`Ungültige Anfrage: Die Frage „${frage.id}“ kommt mehrfach im Bogen vor.`);
1262 }
1263 gesehen.add(frage.id);
1264
1265 const erlaubteLabels = new Set((frage.optionen ?? []).map((o) => o.label));
1266 const auswahlRoh = textliste(eintrag['auswahl'], `antworten[${String(i)}].auswahl`);
1267 for (const label of auswahlRoh) {
1268 if (!erlaubteLabels.has(label)) {
1269 abweisen(
1270 `Ungültige Anfrage: „${entschaerft(label)}“ ist keine Antwortoption der Frage „${frage.id}“.`,
1271 );
1272 }
1273 }
1274 const auswahl = [...new Set(auswahlRoh)].sort();
1275
1276 const freitextRoh = eintrag['freitext'];
1277 if (freitextRoh !== undefined && freitextRoh !== null && typeof freitextRoh !== 'string') {
1278 abweisen(
1279 `Ungültige Anfrage: antworten[${String(i)}].freitext muss eine Zeichenkette sein.`,
1280 );
1281 }
1282 const freitext =
1283 typeof freitextRoh === 'string' ? freitextRoh.slice(0, MAX_FREITEXT_LAENGE) : null;
1284
1285 const selbstRoh = eintrag['selbstAlsRichtig'];
1286 const selbstAlsRichtig = wahrheitswert(
1287 selbstRoh,
1288 `antworten[${String(i)}].selbstAlsRichtig`,
1289 false,
1290 );
1291
1292 // Multiple Choice wird nachgerechnet, die Meldung des Renderers zählt
1293 // nicht. Bei offenen Fragen gibt es keine maschinelle Wahrheit.
1294 const mc = frage.typ === 'mc';
1295 const richtig = mc ? bewerteAuswahl(frage, auswahl).richtig : selbstAlsRichtig;
1296 const unbeantwortet = mc
1297 ? auswahl.length === 0
1298 : (freitext ?? '').trim().length === 0 && (selbstRoh === undefined || selbstRoh === null);
1299
1300 return { frage, auswahl, freitext, richtig, unbeantwortet };
1301 });
1302 }
1303
1304 /** Bereichsstatistik in Katalogreihenfolge; leere Bereiche bleiben weg. */
1305 private bereicheBilden(antworten: readonly GepruefteAntwort[]): BereichErgebnis[] {
1306 interface Eimer {
1307 gesamt: number;
1308 richtig: number;
1309 }
1310 const eimer = new Map<string, Eimer>();
1311
1312 for (const antwort of antworten) {
1313 const id = antwort.frage.abschnitt ?? antwort.frage.kapitel;
1314 let eintrag = eimer.get(id);
1315 if (eintrag === undefined) {
1316 eintrag = { gesamt: 0, richtig: 0 };
1317 eimer.set(id, eintrag);
1318 }
1319 eintrag.gesamt += 1;
1320 if (antwort.richtig) {
1321 eintrag.richtig += 1;
1322 }
1323 }
1324
1325 const reihenfolge: string[] = [];
1326 for (const kapitel of this.katalog.kapitel) {
1327 reihenfolge.push(...kapitel.abschnitte.map((a) => a.id), kapitel.id);
1328 }
1329
1330 return reihenfolge
1331 .filter((id) => eimer.has(id))
1332 .map((id) => {
1333 const werte = eimer.get(id) ?? { gesamt: 0, richtig: 0 };
1334 return {
1335 bereich: id,
1336 titel: this.bereichTitel.get(id) ?? id,
1337 gesamt: werte.gesamt,
1338 richtig: werte.richtig,
1339 };
1340 });
1341 }
1342
1343 /** Verletzte K.-o.-Kriterien, jeweils mit Zahlen für die Begründung. */
1344 private koKriterienPruefen(
1345 profil: Pruefungsprofil,
1346 antworten: readonly GepruefteAntwort[],
1347 ): string[] {
1348 const verletzt: string[] = [];
1349 for (const kriterium of profil.koKriterien ?? []) {
1350 const fehler = antworten.filter(
1351 (a) => !a.richtig && gehoertZu(a.frage, kriterium.bereich),
1352 ).length;
1353 if (fehler > kriterium.maxFehler) {
1354 verletzt.push(
1355 `${kriterium.bezeichnung} (${String(fehler)} Fehler, ` +
1356 `höchstens ${String(kriterium.maxFehler)} zulässig)`,
1357 );
1358 }
1359 }
1360 return verletzt;
1361 }
1362
1363 /**
1364 * Wertet einen Lauf aus, speichert ihn und protokolliert jede Antwort im
1365 * Lernstand. Beides gehört zusammen und läuft deshalb in einer Transaktion.
1366 */
1367 auswerten(
1368 profilIdRoh: unknown,
1369 auftragRoh: unknown,
1370 antwortenRoh: unknown,
1371 dauerMsRoh: unknown,
1372 zeitAbgelaufenRoh: unknown,
1373 ): Pruefungsergebnis {
1374 const profilId = this.lernstand.profilIdPruefen(profilIdRoh);
1375 /*
1376 Liegt ein offener Lauf vor, gilt SEIN Auftrag – nicht der, den der
1377 Renderer mitschickt. Sonst ließe sich ein fortgesetzter Standardbogen
1378 mit dem Fehlerpunkte-Profil abgeben: Bogen und Urteilsregeln kämen aus
1379 verschiedenen Läufen.
1380 */
1381 const gespeichert = this.offeneZeile(profilId);
1382 const gespeicherterAuftrag = gespeichert === undefined ? null : leseJson(gespeichert.auftrag);
1383 const auftrag = this.auftragPruefen(
1384 gespeicherterAuftrag !== null && typeof gespeicherterAuftrag === 'object'
1385 ? gespeicherterAuftrag
1386 : auftragRoh,
1387 );
1388 const antworten = this.antwortenPruefen(antwortenRoh);
1389 this.bogenPruefen(profilId, antworten);
1390 /* Gerundet statt abgewiesen: der Vertrag sagt nur „number“, und eine mit
1391 `performance.now()` gemessene Dauer hat Nachkommastellen. Eine ganze
1392 Prüfung wegen einer halben Millisekunde zu verwerfen wäre unangemessen.
1393
1394 Aus demselben Grund wird eine übergroße Dauer gekappt statt abgewiesen:
1395 Wer ohne Zeitbegrenzung übt – der Nachteilsausgleich für alle, die mehr
1396 Zeit brauchen – lässt das Fenster womöglich über Nacht offen. Diesen
1397 Lauf zu verwerfen träfe genau die Gruppe, für die die Einstellung da
1398 ist. Unsinnige Werte (kein `number`, NaN, negativ) bleiben abgewiesen. */
1399 const dauerMs = Math.min(
1400 MAX_DAUER_MS,
1401 Math.round(endlicheZahl(dauerMsRoh ?? 0, 'dauerMs', 0, Number.MAX_SAFE_INTEGER)),
1402 );
1403 const zeitAbgelaufen = wahrheitswert(zeitAbgelaufenRoh, 'zeitAbgelaufen', false);
1404
1405 const profil = auftrag.profil;
1406 const gesamt = antworten.length;
1407 const richtig = antworten.filter((a) => a.richtig).length;
1408 const unbeantwortet = antworten.filter((a) => a.unbeantwortet).length;
1409 const quote = gesamt > 0 ? richtig / gesamt : 0;
1410
1411 const verletzteKriterien = this.koKriterienPruefen(profil, antworten);
1412 const { urteil, begruendung } = urteilBilden(profil, richtig, gesamt, verletzteKriterien);
1413
1414 const zeitpunkt = this.jetzt().toISOString();
1415 const bereiche = this.bereicheBilden(antworten);
1416
1417 // Die Simulation misst nur die Gesamtdauer. Für das Antwortprotokoll wird
1418 // sie gleichmäßig verteilt: das erhält die Summe und ist die einzige
1419 // Aussage, die die Messung wirklich hergibt.
1420 const dauerJeFrage = gesamt > 0 ? Math.round(dauerMs / gesamt) : 0;
1421
1422 /* Wird in der Transaktion gesetzt. Ohne die Kennung könnte der Vergleich
1423 den eben gespeicherten Lauf nicht von den früheren unterscheiden – der
1424 Verlauf enthält ihn bereits, wenn die Auswertung ihn lädt. */
1425 let laufId = 0;
1426
1427 const speichern = this.db.transaction(() => {
1428 /*
1429 In derselben Transaktion wie der Verlaufseintrag, und das ist der
1430 Punkt: „ausgewertet" und „nicht mehr offen" müssen eine einzige
1431 Tatsache sein. Löschte der Renderer die Zeile über einen zweiten
1432 Aufruf, überlebte sie jeden Weg, der zwischen Festschreiben und
1433 zweitem Aufruf endet – und dieselbe Prüfung ließe sich beim nächsten
1434 Start ein zweites Mal abgeben. Jede Antwort ginge dann ein zweites
1435 Mal in die Wiedervorlage.
1436
1437 Scheitert die Auswertung, macht SQLite auch das Löschen rückgängig
1438 und der Bogen bleibt erhalten. Genau das soll sein.
1439 */
1440 this.db.prepare<[number]>('DELETE FROM pruefung_offen WHERE profil_id = ?').run(profilId);
1441
1442 const eingefuegt = this.db
1443 .prepare<{
1444 profil_id: number;
1445 pruefungsprofil: string;
1446 zeitpunkt: string;
1447 gesamt: number;
1448 richtig: number;
1449 quote: number;
1450 urteil: string;
1451 dauer_ms: number;
1452 zeitmodus: string;
1453 unbeantwortet: number;
1454 zeit_abgelaufen: number;
1455 bereiche: string;
1456 bestehens_quote: number | null;
1457 }>(
1458 `INSERT INTO pruefung_lauf
1459 (profil_id, pruefungsprofil, zeitpunkt, gesamt, richtig, quote, urteil,
1460 dauer_ms, zeitmodus, unbeantwortet, zeit_abgelaufen, bereiche,
1461 bestehens_quote)
1462 VALUES
1463 (@profil_id, @pruefungsprofil, @zeitpunkt, @gesamt, @richtig, @quote, @urteil,
1464 @dauer_ms, @zeitmodus, @unbeantwortet, @zeit_abgelaufen, @bereiche,
1465 @bestehens_quote)`,
1466 )
1467 .run({
1468 profil_id: profilId,
1469 pruefungsprofil: profil.id,
1470 zeitpunkt,
1471 gesamt,
1472 richtig,
1473 quote,
1474 urteil,
1475 dauer_ms: dauerMs,
1476 zeitmodus: auftrag.zeitmodus,
1477 unbeantwortet,
1478 zeit_abgelaufen: zeitAbgelaufen ? 1 : 0,
1479 /* Die Themenanalyse, wie sie in der Auswertung stand. Aus
1480 `antwort_log` wäre sie später nicht zu rekonstruieren: Dort steht
1481 nicht, welcher Lauf welche Zeile geschrieben hat. */
1482 bereiche: JSON.stringify(bereiche),
1483 /* Der Maßstab dieses Laufs. Beim frei eingestellten Profil sind die
1484 gewählten Werte danach fort; ihn hinterher aus dem Vertrag zu
1485 holen ergäbe für genau diese Läufe eine falsche Grenze. */
1486 bestehens_quote: grenzquote(profil, gesamt),
1487 });
1488 /* better-sqlite3 liefert `bigint`, wenn die Kennung nicht mehr in eine
1489 Zahl passt. Bei Verlaufszeilen ist das unerreichbar; die Umwandlung
1490 steht trotzdem da, damit der Typ stimmt und nicht bloß behauptet wird. */
1491 laufId = Number(eingefuegt.lastInsertRowid);
1492
1493 /* Über den Lernstand statt direkt in `antwort_log`: so entstehen
1494 Protokolleintrag, Fragenstand und Wiedervorlage genau wie beim
1495 Lernen, und es gibt nur eine Stelle, die diese Regeln kennt.
1496
1497 Ausnahme sind Fragen, die im Bogen standen, aber nie aufgeschlagen
1498 wurden – etwa wenn die Zeit ablief. Sie gehören in die Historie,
1499 dürfen den Lernstand aber nicht zurückstufen: Eine sicher gewusste
1500 Frage wäre sonst allein deshalb wieder fällig, weil ein Prüfungslauf
1501 nicht bis zu ihr kam. */
1502 for (const antwort of antworten) {
1503 const eintrag = {
1504 frageId: antwort.frage.id,
1505 auswahl: antwort.auswahl,
1506 ...(antwort.freitext === null ? {} : { freitext: antwort.freitext }),
1507 richtig: antwort.richtig,
1508 bewertung: (antwort.richtig ? 'gut' : 'nochmal') as Bewertung,
1509 dauerMs: dauerJeFrage,
1510 };
1511 if (antwort.unbeantwortet) {
1512 this.lernstand.protokollieren(profilId, eintrag);
1513 } else {
1514 this.lernstand.antworten(profilId, eintrag);
1515 }
1516 }
1517 });
1518 speichern();
1519 /* Ohne Kennung im Speicher verfällt jede verspätete Sicherung still. */
1520 this.offenerLauf.delete(profilId);
1521
1522 return {
1523 profilId: profil.id,
1524 gesamt,
1525 richtig,
1526 // Zerlegung, keine Überschneidung: richtig + falsch + unbeantwortet
1527 // ergibt gesamt (siehe Pruefungsergebnis.falsch).
1528 falsch: gesamt - richtig - unbeantwortet,
1529 unbeantwortet,
1530 quote,
1531 urteil,
1532 begruendung,
1533 verletzteKriterien,
1534 bereiche,
1535 fehlerIds: antworten.filter((a) => !a.richtig).map((a) => a.frage.id),
1536 dauerMs,
1537 zeitAbgelaufen,
1538 zeitpunkt,
1539 laufId,
1540 };
1541 }
1542
1543 // ── Verlauf ──────────────────────────────────────────────────────────
1544
1545 /** Die gespeicherten Läufe, neueste zuerst. */
1546 verlauf(profilIdRoh: unknown): Pruefungsverlauf[] {
1547 const profilId = this.lernstand.profilIdPruefen(profilIdRoh);
1548
1549 return this.db
1550 .prepare<[number, number], LaufZeile>(
1551 `SELECT id, pruefungsprofil, zeitpunkt, gesamt, richtig, quote, urteil,
1552 dauer_ms, zeitmodus, unbeantwortet, zeit_abgelaufen, bereiche,
1553 bestehens_quote
1554 FROM pruefung_lauf
1555 WHERE profil_id = ?
1556 ORDER BY zeitpunkt DESC, id DESC
1557 LIMIT ?`,
1558 )
1559 .all(profilId, VERLAUF_GRENZE)
1560 .map((zeile) => ({
1561 id: zeile.id,
1562 profilId: zeile.pruefungsprofil,
1563 // Der Name kommt aus dem Vertrag. Wurde ein Profil zwischenzeitlich
1564 // entfernt, bleibt wenigstens seine Kennung lesbar.
1565 profilName: profilFinden(zeile.pruefungsprofil)?.name ?? zeile.pruefungsprofil,
1566 zeitpunkt: zeile.zeitpunkt,
1567 gesamt: zeile.gesamt,
1568 richtig: zeile.richtig,
1569 quote: zeile.quote,
1570 // Die Spalte ist über CHECK abgesichert; sollte doch etwas anderes
1571 // darin stehen, wird der Lauf nicht als bestanden ausgewiesen.
1572 urteil: istUrteil(zeile.urteil) ? zeile.urteil : 'nicht_bestanden',
1573 /* Gekappt wie beim Speichern: Eine Dauer jenseits der Obergrenze
1574 stammt nicht aus einer Bearbeitung, und der Verlauf soll sie nicht
1575 als eine ausweisen. */
1576 dauerMs: Math.min(MAX_DAUER_MS, Math.max(0, zeile.dauer_ms)),
1577 /* Läufe von vor Schema-Version 4 haben keine Zeitstufe. Sie zu raten
1578 wäre schlechter, als sie offen zu lassen. */
1579 zeitmodus: istZeitmodus(zeile.zeitmodus) ? zeile.zeitmodus : null,
1580 /* Und die vier aus Fassung 10: `null` heißt „nicht festgehalten“ und
1581 bleibt `null`. Ein Ersatzwert wäre eine Behauptung über einen Lauf,
1582 von dem niemand mehr weiß, wie er endete. */
1583 unbeantwortet:
1584 typeof zeile.unbeantwortet === 'number' && zeile.unbeantwortet >= 0
1585 ? zeile.unbeantwortet
1586 : null,
1587 zeitAbgelaufen:
1588 typeof zeile.zeit_abgelaufen === 'number' ? zeile.zeit_abgelaufen === 1 : null,
1589 bereiche: bereicheLesen(zeile.bereiche),
1590 bestehensQuote:
1591 typeof zeile.bestehens_quote === 'number' &&
1592 zeile.bestehens_quote >= 0 &&
1593 zeile.bestehens_quote <= 1
1594 ? zeile.bestehens_quote
1595 : null,
1596 }));
1597 }
1598 }
1599
1600 // ─── Instanz für den Main-Prozess ───────────────────────────────────────────
1601
1602 let instanz: Pruefung | null = null;
1603 let quelle: Lernstand | null = null;
1604
1605 /**
1606 * Liefert die Prüfungssimulation zum übergebenen Lernstand.
1607 *
1608 * Wird der Lernstand ausgetauscht – etwa nach `lernstandSchliessen()` –,
1609 * entsteht automatisch eine neue Instanz.
1610 */
1611 export function pruefungInstanz(lernstand: Lernstand, katalog: Katalog): Pruefung {
1612 if (instanz === null || quelle !== lernstand) {
1613 instanz = new Pruefung(lernstand, katalog);
1614 quelle = lernstand;
1615 }
1616 return instanz;
1617 }
1618
1619 /** Gegenstück für Tests und sauberes Herunterfahren. */
1620 export function pruefungZuruecksetzen(): void {
1621 instanz = null;
1622 quelle = null;
1623 }