waffensachkunde
Waffensachkunde – Lernsoftware für die Sachkundeprüfung nach § 7 WaffG. Barrierefrei, offline, EUPL-1.2.
| 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 | } |