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