waffensachkunde

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

/ app src main pruefung.ts

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