/** * Prüfungssimulation – Bogen zusammenstellen, auswerten, Verlauf führen. * * Die Regeln stehen im Vertrag (`src/shared/pruefung.ts`) und werden hier * nicht noch einmal formuliert: welche Profile es gibt, wie das Zeitlimit * gerechnet wird und wann bestanden ist, entscheiden `PRUEFUNGSPROFILE`, * `zeitInMinuten` und `urteilBilden`. Dieses Modul zieht Fragen, zählt * Ergebnisse und schreibt sie fort. * * Zwei Grundsätze: * * 1. **Der Renderer wird nicht geglaubt.** Ob eine Multiple-Choice-Antwort * richtig ist, rechnet `bewerteAuswahl` aus dem Katalog nach. Nur bei * offenen Fragen gibt es keine maschinelle Wahrheit – dort zählt die * Selbsteinschätzung des Prüflings. * 2. **Der Lernstand bleibt Eigentümer der Datenbank.** {@link Pruefung} * bekommt ihn übergeben, nutzt seine Verbindung und protokolliert jede * Antwort über `Lernstand.antworten` – damit fließt ein Simulationslauf * in dieselbe Statistik und dieselbe Wiedervorlage ein wie das Lernen. * * Die Klasse kennt Electron nicht und ist deshalb gegen eine * `:memory:`-Datenbank prüfbar. */ import type BetterSqlite3 from 'better-sqlite3'; import { bewerteAuswahl, type Frage, type Katalog } from '../shared/katalog'; import type { Bewertung } from '../shared/lernstand'; import { gewuenschteTypen, grenzquote, profilFinden, urteilBilden, URTEIL_BEZEICHNUNG, ZEITMODUS_BEZEICHNUNG, type BereichErgebnis, type Bestehensurteil, type GesicherteEingabe, type OffenerLauf, type Pruefungsauftrag, type Pruefungsbogen, type Pruefungsergebnis, type Pruefungsfrage, type Pruefungsprofil, type Pruefungsverlauf, type Zeitmodus, } from '../shared/pruefung'; import { abweisen, endlicheZahl, entschaerft, ganzeZahl, gemischt, nutzlast, textliste, wahrheitswert, } from './eingaben'; import type { Lernstand } from './lernstand'; /** * Obergrenze für die Bogengröße. Liegt über der Katalogröße, damit sich auch * der gesamte Katalog anfordern lässt; alles darüber ist ein Eingabefehler. */ const MAX_BOGENGROESSE = 1000; /** Plausible Obergrenze für ein Zeitlimit: 24 Stunden. */ const MAX_ZEIT_MINUTEN = 24 * 60; /** Plausible Obergrenze für die Dauer eines Laufs: 24 Stunden. */ const MAX_DAUER_MS = 24 * 60 * 60 * 1000; const MAX_FREITEXT_LAENGE = 4000; /** * Höchstzahl der Läufe, die der Verlauf liefert. Die Anzeige zeigt eine * Historie, kein Archiv – gespeichert bleibt alles. */ const VERLAUF_GRENZE = 200; // ─── Prüfhilfen ───────────────────────────────────────────────────────────── function istZeitmodus(wert: unknown): wert is Zeitmodus { return typeof wert === 'string' && Object.hasOwn(ZEITMODUS_BEZEICHNUNG, wert); } function istUrteil(wert: unknown): wert is Bestehensurteil { return typeof wert === 'string' && Object.hasOwn(URTEIL_BEZEICHNUNG, wert); } /** Offen sind alle Fragen, die nicht Multiple Choice sind (auch der Lückentext). */ function istOffen(frage: Frage): boolean { return frage.typ !== 'mc'; } // ─── Klartext für die Warnungen ───────────────────────────────────────────── // // Die Warnungen des Ziehens gehen nicht mehr nur ins Protokoll, sondern über // den Bogen an den Bildschirm (bis Fassung 0.20.0 sah sie niemand). Sie werden // deshalb hier fertig formuliert – die Oberfläche gibt sie unverändert aus. Das heißt: // keine Feldnamen aus dem Vertrag, keine Formen wie „Frage(n)“, und die Beugung // stimmt auch bei genau einer Frage. /** „1 Frage“ / „7 Fragen“. */ function fragenZahl(anzahl: number): string { return `${String(anzahl)} ${anzahl === 1 ? 'Frage' : 'Fragen'}`; } /** „1 offene Frage“ / „7 offene Fragen“. */ function offeneZahl(anzahl: number): string { return `${String(anzahl)} ${anzahl === 1 ? 'offene Frage' : 'offene Fragen'}`; } /** „eine Auswahlfrage“ / „7 Auswahlfragen“. */ function auswahlZahl(anzahl: number): string { return anzahl === 1 ? 'eine Auswahlfrage' : `${String(anzahl)} Auswahlfragen`; } function sindIst(anzahl: number): string { return anzahl === 1 ? 'ist' : 'sind'; } /** Aufzählung im Klartext: „a“, „a und b“, „a, b und c“. */ function aufzaehlen(teile: readonly string[]): string { if (teile.length <= 1) { return teile[0] ?? ''; } return `${teile.slice(0, -1).join(', ')} und ${teile[teile.length - 1] ?? ''}`; } /** * Lesbare Namen der überschreibbaren Profilwerte. * * Ohne diese Zuordnung stünde „anteilOffen“ auf dem Bildschirm – ein Feldname * aus dem Vertrag, den außerhalb des Quelltextes niemand kennt. */ const WERT_BEZEICHNUNG: Readonly> = Object.freeze({ fragenAnzahl: 'die Fragenzahl', bestehensQuote: 'die Bestehensgrenze', zeitMinuten: 'die Bearbeitungszeit', anteilOffen: 'der Anteil offener Fragen', }); /** * Gehört die Frage zu diesem Bereich? * * Bereichsschlüssel sind entweder Abschnitts-IDs („I.1“) oder Kapitel-IDs * („II“). Beides wird zugelassen, damit Themenquoten und K.-o.-Kriterien * dieselbe Schreibweise benutzen können wie der Katalog. */ function gehoertZu(frage: Frage, bereich: string): boolean { return frage.abschnitt === bereich || frage.kapitel === bereich; } // ─── Zeilentypen ──────────────────────────────────────────────────────────── interface LaufZeile { readonly id: number; readonly pruefungsprofil: string; readonly zeitpunkt: string; readonly gesamt: number; readonly richtig: number; readonly quote: number; readonly urteil: string; readonly dauer_ms: number; readonly zeitmodus: string | null; /* Die vier aus Schema-Version 10. `null` heißt „nicht festgehalten“ und wird als solches weitergereicht – siehe `laufKennzahlenSpalten`. */ readonly unbeantwortet: number | null; readonly zeit_abgelaufen: number | null; readonly bereiche: string | null; readonly bestehens_quote: number | null; } // ─── Geprüfte Eingaben ────────────────────────────────────────────────────── /** Auftrag mit aufgelösten Vorgaben; `profil` enthält bereits die Überschreibungen. */ interface GepruefterAuftrag { readonly profil: Pruefungsprofil; readonly zeitmodus: Zeitmodus; readonly kapitelAusschluss: readonly string[]; readonly optionenMischen: boolean; /** * Die geprüfte Nutzlast, aus der dieser Auftrag entstand. * * Wird beim Start als offener Lauf abgelegt, damit ein fortgesetzter Lauf * unter denselben Vorgaben zu Ende geht, unter denen er begonnen hat. Ohne * sie ließe sich ein fortgesetzter Standardbogen mit dem Fehlerpunkte-Profil * abgeben. */ readonly roh: Record; } /** Zeile aus `pruefung_offen`, roh wie sie in der Datenbank steht. */ interface OffeneZeile { readonly lauf_id: string; readonly auftrag: string; readonly profil: string; readonly bogen: string; readonly eingaben: string; readonly position: number; readonly phase: string; readonly zeit_abgelaufen: number; readonly verbraucht_ms: number; readonly begonnen_am: string; readonly gesichert_am: string; } /** Geprüfter Zwischenstand, wie ihn der Renderer schickt. */ interface GepruefterStand { readonly eingaben: Readonly>; readonly position: number; readonly phase: 'bearbeiten' | 'nachbewertung'; readonly zeitAbgelaufen: boolean; readonly verbrauchtMs: number; } /** JSON aus der Datenbank – `null`, wenn es sich nicht lesen lässt. */ function leseJson(text: string): unknown { try { return JSON.parse(text) as unknown; } catch { return null; } } /** * Die gespeicherte Themenanalyse eines Laufs. * * Wie bei den gesicherten Eingaben gilt: Was nicht als gültiger Eintrag lesbar * ist, fällt weg. Ein halb lesbares Feld darf die Verlaufsanzeige nicht zu * Fall bringen – schlimmstenfalls fehlt einem alten Lauf die Aufschlüsselung, * und die Oberfläche sagt das ohnehin für jeden Lauf vor Fassung 10. * * Ergibt sich kein einziger gültiger Eintrag, wird `null` zurückgegeben und * nicht etwa eine leere Liste: „keine Bereiche“ und „nicht festgehalten“ sind * zwei verschiedene Aussagen, und nur die zweite trifft hier zu. */ function bereicheLesen(text: string | null): BereichErgebnis[] | null { if (text === null) { return null; } const roh = leseJson(text); if (!Array.isArray(roh)) { return null; } const bereiche: BereichErgebnis[] = []; for (const eintrag of roh) { if (typeof eintrag !== 'object' || eintrag === null) { continue; } const werte = eintrag as Record; const bereich = werte['bereich']; const titel = werte['titel']; const gesamt = werte['gesamt']; const richtig = werte['richtig']; if ( typeof bereich === 'string' && typeof titel === 'string' && typeof gesamt === 'number' && typeof richtig === 'number' && Number.isInteger(gesamt) && Number.isInteger(richtig) && gesamt > 0 && richtig >= 0 && richtig <= gesamt ) { bereiche.push({ bereich, titel, gesamt, richtig }); } } return bereiche.length > 0 ? bereiche : null; } /** * Gesicherte Eingaben aus der Datenbank. * * Nachlässig gelesen wäre hier ein Einfallstor: Die Datei liegt im * `userData`-Verzeichnis. Was nicht als Eingabe lesbar ist, fällt weg – * schlimmstenfalls fehlt eine Antwort, statt dass etwas Erfundenes gewertet * wird. */ function leseEingaben(text: string): Readonly> { const roh = leseJson(text); if (typeof roh !== 'object' || roh === null || Array.isArray(roh)) { return {}; } const eingaben: Record = {}; for (const [frageId, wert] of Object.entries(roh as Record)) { if (typeof wert !== 'object' || wert === null) { continue; } const eintrag = wert as Record; const auswahlRoh = eintrag['auswahl']; const auswahl = Array.isArray(auswahlRoh) ? auswahlRoh.filter((l): l is string => typeof l === 'string') : []; const freitextRoh = eintrag['freitext']; const selbstRoh = eintrag['selbst']; eingaben[frageId] = { auswahl, freitext: typeof freitextRoh === 'string' ? freitextRoh : '', ...(typeof selbstRoh === 'boolean' ? { selbst: selbstRoh } : {}), }; } return eingaben; } /** * Der zuletzt ausgegebene Bogen eines Profils – und ob er schon gewertet ist. * * Das zweite Feld ist der Riegel gegen die doppelte Auswertung: Bis 0.27.2 * wurde der Eintrag nach der Wertung gelöscht, und der nächste Aufruf lief * damit in den Zweig „Bogen nicht bekannt“, der alles annimmt. */ interface Merkposten { readonly ids: ReadonlySet; readonly ausgewertet: boolean; } /** Eine geprüfte Antwort; `richtig` ist bereits amtlich nachgerechnet. */ interface GepruefteAntwort { readonly frage: Frage; readonly auswahl: readonly string[]; readonly freitext: string | null; readonly richtig: boolean; readonly unbeantwortet: boolean; } export interface PruefungOptionen { /** Zeitgeber – in Tests überschreibbar. */ readonly jetzt?: () => Date; /** Zufallsquelle für das Ziehen und Mischen – in Tests überschreibbar. */ readonly zufall?: () => number; /** Ziel für Warnungen; standardmäßig das Protokoll des Main-Prozesses. */ readonly warnen?: (meldung: string) => void; } // ─── Prüfungssimulation ───────────────────────────────────────────────────── export class Pruefung { private readonly db: BetterSqlite3.Database; private readonly katalog: Katalog; private readonly lernstand: Lernstand; private readonly fragen: ReadonlyMap; private readonly kapitelIds: ReadonlySet; private readonly bereichTitel: ReadonlyMap; private readonly jetzt: () => Date; private readonly zufall: () => number; private readonly warnAusgabe: (meldung: string) => void; /** * Sammelstelle für die Warnungen des gerade entstehenden Bogens. * * `null`, solange kein Bogen gezogen wird. Warnungen, die außerhalb davon * entstehen – etwa beim Lesen eines unbrauchbaren offenen Laufs –, gehören * zu keinem Bogen und bleiben im Protokoll: Die Oberfläche erfährt von * diesem Fall bereits daran, dass die Karte des unterbrochenen Laufs * ausbleibt. */ private warnungsSammler: string[] | null = null; /** * Der zuletzt ausgegebene Bogen je Profil. * * `starten` gibt den Bogen aus, `auswerten` prüft die eingehende * Antwortliste dagegen. Ohne diese Merkung nähme der Kern jede beliebige * Liste an – ein Lauf mit einer einzigen richtigen Antwort landete als * „bestanden" im Verlauf, obwohl der Bogen 80 Fragen hatte. * * Bewusst nur im Speicher: Nach einem Neustart ist der Bogen unbekannt, * und dann wird die Liste angenommen statt den Lauf zu verwerfen. Eine * abgebrochene Sitzung soll niemandem seine Arbeit kosten. */ private letzterBogen = new Map(); /** * Kennung des offenen Laufs je Profil. * * Der Renderer bekommt sie nie zu sehen und kann sie deshalb nicht * erfinden. Zusammen mit derselben Kennung in der `WHERE`-Bedingung des * Sicherns ergibt das zwei unabhaengige Riegel gegen eine verspaetete * Sicherung, die einen bereits gewerteten Lauf wiederbelebt. */ private offenerLauf = new Map(); constructor(lernstand: Lernstand, katalog: Katalog, optionen: PruefungOptionen = {}) { this.lernstand = lernstand; this.db = lernstand.datenbank; this.katalog = katalog; this.fragen = new Map(katalog.fragen.map((f) => [f.id, f])); this.kapitelIds = new Set(katalog.kapitel.map((k) => k.id)); const titel = new Map(); for (const kapitel of katalog.kapitel) { titel.set(kapitel.id, kapitel.titel); for (const abschnitt of kapitel.abschnitte) { titel.set(abschnitt.id, abschnitt.titel); } } this.bereichTitel = titel; this.jetzt = optionen.jetzt ?? ((): Date => new Date()); this.zufall = optionen.zufall ?? Math.random; this.warnAusgabe = optionen.warnen ?? ((meldung: string): void => { console.warn(`[pruefung] ${meldung}`); }); } /** * Meldet eine Abweichung von den Vorgaben. * * Zwei Ziele, und beide werden gebraucht: das Protokoll des Hauptprozesses * für die Fehlersuche und – solange ein Bogen entsteht – der Bogen selbst, * damit die Oberfläche die Abweichung anzeigen kann. * * Gleichlautendes wird nur einmal gesammelt: {@link zieheTeil} läuft je * Themenbereich und sagte demselben Prüfling sonst achtmal denselben Satz. */ private warnen(meldung: string): void { const sammler = this.warnungsSammler; if (sammler !== null && !sammler.includes(meldung)) { sammler.push(meldung); } this.warnAusgabe(meldung); } /** Bereich mit seinem Titel, sofern der Katalog einen führt. */ private bereichName(bereich: string): string { const titel = this.bereichTitel.get(bereich); return titel === undefined || titel === bereich ? `Themenbereich „${bereich}“` : `Themenbereich „${bereich} – ${titel}“`; } // ── Auftrag ────────────────────────────────────────────────────────── private auftragPruefen(wertRoh: unknown): GepruefterAuftrag { const roh = nutzlast(wertRoh, 'Der Prüfungsauftrag'); const profilId = roh['profilId']; if (typeof profilId !== 'string') { abweisen('Ungültige Anfrage: profilId muss eine Zeichenkette sein.'); } const basis = profilFinden(profilId); if (basis === undefined) { abweisen(`Unbekanntes Prüfungsprofil: „${entschaerft(profilId)}“.`); } const zeitmodus = roh['zeitmodus']; if (!istZeitmodus(zeitmodus)) { abweisen( `Ungültige Anfrage: zeitmodus muss einer von ${Object.keys(ZEITMODUS_BEZEICHNUNG).join(', ')} sein, ` + `war aber „${entschaerft(zeitmodus)}“.`, ); } const kapitelAusschluss = textliste(roh['kapitelAusschluss'], 'kapitelAusschluss'); for (const id of kapitelAusschluss) { if (!this.kapitelIds.has(id)) { abweisen(`Unbekanntes Kapitel: „${entschaerft(id)}“.`); } } const optionenMischenRoh = roh['optionenMischen']; if (optionenMischenRoh !== undefined && typeof optionenMischenRoh !== 'boolean') { abweisen('Ungültige Anfrage: optionenMischen muss ein Wahrheitswert sein.'); } return { profil: this.effektivesProfil(basis, roh), zeitmodus, kapitelAusschluss, /* Ohne ausdrückliche Angabe wird nicht gemischt – wie im Lernmodus. Gerade die Simulation soll dem gedruckten Bogen nahekommen, und sie zeigt keine Ziffernspalte, die eine verdrehte Buchstabenfolge abmildern könnte. */ optionenMischen: optionenMischenRoh ?? false, roh, }; } /** * Wendet die Überschreibungen des Auftrags an. * * Nur anpassbare Profile lassen sich überschreiben – sonst hätte die * Angabe „nach Art des DSB“ keinen Aussagewert mehr. Eine ignorierte * Überschreibung wird protokolliert statt stillschweigend verworfen. */ private effektivesProfil(basis: Pruefungsprofil, roh: Record): Pruefungsprofil { const anzahlRoh = roh['fragenAnzahl']; const quoteRoh = roh['bestehensQuote']; const zeitRoh = roh['zeitMinuten']; const offenRoh = roh['anteilOffen']; if (basis.anpassbar !== true) { const ueberschrieben = [ anzahlRoh !== undefined && anzahlRoh !== basis.fragenAnzahl ? 'fragenAnzahl' : null, quoteRoh !== undefined && quoteRoh !== basis.bestehensQuote ? 'bestehensQuote' : null, zeitRoh !== undefined && zeitRoh !== basis.zeitMinuten ? 'zeitMinuten' : null, offenRoh !== undefined && offenRoh !== basis.anteilOffen ? 'anteilOffen' : null, ].filter((name): name is string => name !== null); if (ueberschrieben.length > 0) { const namen = aufzaehlen(ueberschrieben.map((feld) => WERT_BEZEICHNUNG[feld] ?? feld)); this.warnen( `Das Profil „${basis.name}“ hat feste Vorgaben: ${namen} ` + `${ueberschrieben.length === 1 ? 'wurde' : 'wurden'} nicht übernommen. ` + 'Es gilt, was das Profil vorsieht.', ); } return basis; } const fragenAnzahl = anzahlRoh === undefined || anzahlRoh === null ? basis.fragenAnzahl : ganzeZahl(anzahlRoh, 'fragenAnzahl', 1, MAX_BOGENGROESSE); const bestehensQuote = quoteRoh === undefined || quoteRoh === null ? basis.bestehensQuote : endlicheZahl(quoteRoh, 'bestehensQuote', 0, 1); const zeitMinuten = zeitRoh === undefined ? basis.zeitMinuten : zeitRoh === null ? null : ganzeZahl(zeitRoh, 'zeitMinuten', 1, MAX_ZEIT_MINUTEN); /* Der Anteil offener Fragen ist ein Wunsch, keine Zusage: Der Katalog hat 104 offene Fragen von 575, und sie liegen ungleich – Kapitel III hat eine einzige von 49. Was sich nicht ziehen lässt, meldet `ziehen` als Warnung. Er wird deshalb hier nur geprüft, nicht zurechtgebogen. */ const anteilOffen = offenRoh === undefined || offenRoh === null ? basis.anteilOffen : endlicheZahl(offenRoh, 'anteilOffen', 0, 1); // `exactOptionalPropertyTypes`: optionale Felder nur setzen, wenn sie // wirklich einen Wert haben. return { ...basis, fragenAnzahl, ...(bestehensQuote === undefined ? {} : { bestehensQuote }), ...(anteilOffen === undefined ? {} : { anteilOffen }), zeitMinuten, }; } // ── Bogen zusammenstellen ──────────────────────────────────────────── /** * Zieht `anzahl` Fragen aus dem Vorrat und legt sie in `gewaehlt` ab. * * Der Vorrat ist bereits gemischt, deshalb genügt es, von vorn zu nehmen. * `wunschOffen` gibt vor, wie viele davon offene Fragen sein sollen; der * Rest ist Multiple Choice. Reicht der Vorrat einer Art nicht, füllt die * andere auf. Jede Abweichung wird protokolliert, damit sie nicht unbemerkt * bleibt. * * Die Zahl kommt von außen statt aus einem Anteil, weil sie sich nur * bogenweit sinnvoll bestimmen lässt: Bereiche mit wenigen offenen Fragen * müssen von Bereichen mit vielen ausgeglichen werden – siehe * {@link offeneJeBereich}. * * @param ort Wo das geschieht, als Satzanfang: „Im Bogen“ oder * „Im Themenbereich …“. Die Warnungen erscheinen so, wie sie hier * entstehen, auf dem Bildschirm. */ private zieheTeil( vorrat: readonly Frage[], anzahl: number, wunschOffen: number, gewaehlt: Frage[], benutzt: Set, ort: string, ): void { if (anzahl <= 0) { return; } const offene = vorrat.filter((f) => istOffen(f)); const mc = vorrat.filter((f) => !istOffen(f)); const nimm = (liste: readonly Frage[], wieviele: number): number => { let genommen = 0; for (const frage of liste) { if (genommen >= wieviele) { break; } if (benutzt.has(frage.id)) { continue; } benutzt.add(frage.id); gewaehlt.push(frage); genommen += 1; } return genommen; }; const offenGenommen = nimm(offene, wunschOffen); if (offenGenommen < wunschOffen) { const fehlend = wunschOffen - offenGenommen; this.warnen( `${ort}: ${offeneZahl(fehlend)} ${fehlend === 1 ? 'wurde' : 'wurden'} durch ` + `${auswahlZahl(fehlend)} ersetzt – so viele offene Fragen hat der Katalog dort nicht.`, ); } const wunschMc = anzahl - offenGenommen; const mcGenommen = nimm(mc, wunschMc); if (mcGenommen < wunschMc) { const rest = wunschMc - mcGenommen; /* Nachgelegt wird nur, wo offene Fragen überhaupt erwünscht sind. Wer im frei eingestellten Profil „Anteil offener Fragen: 0“ wählt, liest in der Prüfungswahl: „Der Bogen besteht damit ausschließlich aus Auswahlfragen.“ Bis Fassung 0.24.1 legte der Kern trotzdem offene Fragen nach, sobald die Auswahlfragen nicht reichten – bei 500 gewünschten Fragen kamen 471 Auswahlfragen und 29 offene, ohne ein Wort. Nach der Abgabe sprang dann unerwartet die Selbstbewertung mit 29 Einträgen dazwischen. Der Ersatzweg im Renderer (`bogen.ts`) hielt die Zusage ein; beide Wege widersprachen sich. */ const nachgelegt = wunschOffen > 0 ? nimm(offene, rest) : 0; if (nachgelegt < rest) { const fehlend = rest - nachgelegt; this.warnen( `${ort}: ${fragenZahl(fehlend)} ${fehlend === 1 ? 'fehlt' : 'fehlen'}; ` + 'der Bogen wird aus den übrigen Bereichen aufgefüllt.', ); } else if (nachgelegt > 0) { /* Aufgefüllt wurde, und der Bogen sieht damit anders aus als bestellt. Das gehört gesagt – bis 0.24.1 geschah es stumm. */ this.warnen( `${ort}: ${auswahlZahl(nachgelegt)} ${nachgelegt === 1 ? 'wurde' : 'wurden'} durch ` + `${offeneZahl(nachgelegt)} ersetzt – so viele Auswahlfragen hat der Katalog dort nicht.`, ); } } } /** * Verteilt die gewünschte Zahl offener Fragen auf die Themenbereiche. * * Der Anteil gilt für den **Bogen**, nicht für jeden Bereich einzeln. Wird * er stur bereichsweise angewandt, verfällt der Fehlbetrag dort, wo der * Katalog wenige offene Fragen hat: Beim DSB-Profil enthalten I.4 und III * je genau eine, und der Bogen kam reproduzierbar auf 18 statt 20 offene * Fragen – bei einer Profilbeschreibung, die „höchstens 80 Prozent * Multiple Choice" zusagt. * * Deshalb wird zuerst bereichsweise angesetzt und der Rest anschließend * dorthin gelegt, wo noch offene Fragen übrig sind. Die Themenquoten * bleiben unberührt – verschoben wird nur die Typmischung innerhalb eines * Bereichs. * * @param teile Bereiche mit ihrer Quote und ihrem Vorrat. * @param gesamt Gewünschte Zahl offener Fragen im ganzen Bogen. * @returns Je Bereich die Zahl der offenen Fragen, in derselben Reihenfolge. */ private static offeneJeBereich( teile: readonly { readonly anzahl: number; readonly offenVerfuegbar: number }[], gesamt: number, ): number[] { const obergrenze = teile.map((t) => Math.min(t.anzahl, t.offenVerfuegbar)); const summeMoeglich = obergrenze.reduce((a, b) => a + b, 0); const ziel = Math.min(gesamt, summeMoeglich); // Erster Ansatz: der Anteil, den der Bereich seiner Größe nach trägt. const gesamtQuote = teile.reduce((a, t) => a + t.anzahl, 0); const verteilt = teile.map((teil, i) => Math.min( obergrenze[i] ?? 0, gesamtQuote > 0 ? Math.floor((ziel * teil.anzahl) / gesamtQuote) : 0, ), ); /* Rest auffüllen: immer dort, wo noch Luft ist. Der Reihe nach statt zufällig – der Vorrat selbst ist bereits gemischt, und eine feste Reihenfolge macht das Ergebnis nachvollziehbar. */ let rest = ziel - verteilt.reduce((a, b) => a + b, 0); while (rest > 0) { const index = verteilt.findIndex((wert, i) => wert < (obergrenze[i] ?? 0)); if (index === -1) { break; // nirgends mehr Platz } verteilt[index] = (verteilt[index] ?? 0) + 1; rest -= 1; } return verteilt; } /** Stellt die Fragen eines Bogens zusammen – ohne Dubletten. */ private ziehen(auftrag: GepruefterAuftrag): Frage[] { const profil = auftrag.profil; const ausgeschlossen = new Set(auftrag.kapitelAusschluss); const vorrat = gemischt( this.katalog.fragen.filter((f) => !ausgeschlossen.has(f.kapitel)), this.zufall, ); if (vorrat.length === 0) { abweisen('Es bleibt keine einzige Frage übrig. Bitte weniger Kapitel abwählen.'); } const ziel = Math.min(profil.fragenAnzahl, vorrat.length); if (vorrat.length < profil.fragenAnzahl) { this.warnen( `Das Profil „${profil.name}“ sieht ${fragenZahl(profil.fragenAnzahl)} vor, ` + `zur Auswahl stehen aber nur ${String(vorrat.length)}. ` + `Der Bogen umfasst deshalb ${fragenZahl(ziel)}.`, ); } // Ohne `anteilOffen` besteht der Bogen ausschließlich aus Multiple Choice. const anteilOffen = gewuenschteTypen(profil).includes('freitext') ? (profil.anteilOffen ?? 0) : 0; const gewaehlt: Frage[] = []; const benutzt = new Set(); /* Themenquoten zuerst: sie sind die härtere Vorgabe. Wie viele offene Fragen je Bereich gezogen werden, entscheidet sich aber bogenweit – sonst verfällt der Fehlbetrag in Bereichen mit wenigen offenen Fragen (siehe offeneJeBereich). */ const quoten = profil.themenquoten; if (quoten !== undefined) { const teile = Object.entries(quoten).map(([bereich, anzahl]) => { const teilVorrat = vorrat.filter((f) => gehoertZu(f, bereich)); return { bereich, anzahl: Math.min(anzahl, teilVorrat.length), gewuenscht: anzahl, teilVorrat, offenVerfuegbar: teilVorrat.filter((f) => istOffen(f)).length, }; }); const offenZiel = Math.round(teile.reduce((summe, t) => summe + t.anzahl, 0) * anteilOffen); const offenJeTeil = Pruefung.offeneJeBereich(teile, offenZiel); teile.forEach((teil, i) => { const ort = `Im ${this.bereichName(teil.bereich)}`; if (teil.teilVorrat.length < teil.gewuenscht) { this.warnen( `${ort}: Vorgesehen ${sindIst(teil.gewuenscht)} ${fragenZahl(teil.gewuenscht)}, ` + `verfügbar ${sindIst(teil.teilVorrat.length)} ${fragenZahl(teil.teilVorrat.length)}. ` + 'Die fehlenden Fragen kommen aus den übrigen Bereichen.', ); } this.zieheTeil( teil.teilVorrat.filter((f) => !benutzt.has(f.id)), teil.anzahl, offenJeTeil[i] ?? 0, gewaehlt, benutzt, ort, ); }); } // Auffüllen: ohne Quoten der ganze Bogen, mit Quoten nur, was fehlt. const fehlend = ziel - gewaehlt.length; if (fehlend > 0) { /* Was bisher an offenen Fragen zusammenkam, wird angerechnet – sonst läge der Anteil am Ende über dem Ziel. */ const bisherOffen = gewaehlt.filter((f) => istOffen(f)).length; const nochOffen = Math.max(0, Math.round(ziel * anteilOffen) - bisherOffen); this.zieheTeil( vorrat.filter((f) => !benutzt.has(f.id)), fehlend, Math.min(nochOffen, fehlend), gewaehlt, benutzt, 'Im Bogen', ); } // Erst mischen, dann kürzen: liegt die Summe der Quoten über der // Bogengröße, fällt nicht immer derselbe Bereich hinten herunter. return gemischt(gewaehlt, this.zufall).slice(0, ziel); } /** * Stellt einen Prüfungsbogen zusammen. * * Die Reihenfolge der Antwortoptionen folgt dem Katalog, sofern der * Auftrag nicht ausdrücklich `optionenMischen` verlangt. * * Der Bogen wird zugleich als offener Lauf festgehalten (`pruefung_offen`). * Nicht erst mit der ersten Sicherung: Stürbe das Programm in der ersten * Sekunde, wäre der Bogen trotz aller Vorsorge weg. Ein bereits offener * Lauf desselben Profils wird dabei ersetzt – die Oberfläche fragt vorher, * ob er verworfen werden darf. * * Zurück kommt ein {@link Pruefungsbogen} und keine reine Fragenliste: Jede * Abweichung von den Vorgaben steht in `warnungen` und geht damit an den * Bildschirm statt nur ins Protokoll. */ starten(profilIdRoh: unknown, auftragRoh: unknown): Pruefungsbogen { const profilId = this.lernstand.profilIdPruefen(profilIdRoh); /* Die Sammlung beginnt vor dem Prüfen des Auftrags: Die Meldung über eine nicht übernommene Überschreibung entsteht dort und gehört genauso an den Bildschirm wie die drei Meldungen des Ziehens. Das `finally` schließt die Sammlung auch dann, wenn ein ungültiger Auftrag abgewiesen wird – sonst liefe der nächste Bogen in eine fremde Liste. */ const warnungen: string[] = []; this.warnungsSammler = warnungen; let auftrag: GepruefterAuftrag; let gezogen: Frage[]; try { auftrag = this.auftragPruefen(auftragRoh); gezogen = this.ziehen(auftrag); } finally { this.warnungsSammler = null; } this.letzterBogen.set(profilId, { ids: new Set(gezogen.map((frage) => frage.id)), ausgewertet: false, }); const fragen = gezogen.map((frage) => { const labels = (frage.optionen ?? []).map((o) => o.label); return { frageId: frage.id, optionsReihenfolge: auftrag.optionenMischen ? gemischt(labels, this.zufall) : labels, }; }); this.offenenLaufAnlegen(profilId, auftrag, fragen); return { fragen, warnungen }; } // ── Offener Lauf ───────────────────────────────────────────────────── /** * Legt den gerade gezogenen Bogen als offenen Lauf ab. * * Geschrieben wird hier, nicht im Renderer: `auftrag`, `profil` und `bogen` * entstehen in diesem Modul, und nur so kann sie niemand unterwegs * austauschen. Der Renderer sichert später allein, was ihm gehört. */ private offenenLaufAnlegen( profilId: number, auftrag: GepruefterAuftrag, bogen: readonly Pruefungsfrage[], ): void { const laufId = this.laufKennung(); const jetzt = this.jetzt().toISOString(); this.db .prepare<{ profil_id: number; lauf_id: string; auftrag: string; profil: string; bogen: string; begonnen_am: string; }>( `INSERT INTO pruefung_offen (profil_id, lauf_id, auftrag, profil, bogen, eingaben, position, phase, zeit_abgelaufen, verbraucht_ms, begonnen_am, gesichert_am) VALUES (@profil_id, @lauf_id, @auftrag, @profil, @bogen, '{}', 0, 'bearbeiten', 0, 0, @begonnen_am, @begonnen_am) ON CONFLICT (profil_id) DO UPDATE SET lauf_id = excluded.lauf_id, auftrag = excluded.auftrag, profil = excluded.profil, bogen = excluded.bogen, eingaben = '{}', position = 0, phase = 'bearbeiten', zeit_abgelaufen = 0, verbraucht_ms = 0, begonnen_am = excluded.begonnen_am, gesichert_am = excluded.begonnen_am`, ) .run({ profil_id: profilId, lauf_id: laufId, auftrag: JSON.stringify(auftrag.roh), profil: JSON.stringify(auftrag.profil), bogen: JSON.stringify(bogen), begonnen_am: jetzt, }); this.offenerLauf.set(profilId, laufId); } /** * Kennung eines Laufs. * * Braucht keine kryptografische Güte – sie unterscheidet zwei Läufe * desselben Profils, mehr nicht. Zeitpunkt und Zufall zusammen genügen * dafür auch dann, wenn die Systemuhr steht. */ private laufKennung(): string { const zeit = this.jetzt().getTime().toString(36); const zufall = Math.floor(this.zufall() * 0xffffff) .toString(36) .padStart(5, '0'); return `${zeit}-${zufall}`; } /** * Sichert den Zwischenstand eines laufenden Bogens. * * Ausschließlich `UPDATE`, niemals `INSERT`: Das Sichern ist entprellt, und * ein verspäteter Nachzügler darf die Zeile nicht wiederauferstehen lassen, * die die Auswertung gerade gelöscht hat – sonst ließe sich derselbe Lauf * ein zweites Mal abgeben und jede Antwort ginge ein zweites Mal in die * Wiedervorlage. Findet das `UPDATE` keine Zeile, ist der Lauf vorbei und * die Sicherung verfällt still. * * Zwei unabhängige Riegel: die Lauf-Kennung im Speicher (die der Renderer * nie zu sehen bekommt und deshalb nicht erfinden kann) und dieselbe * Kennung in der `WHERE`-Bedingung. */ sichern(profilIdRoh: unknown, standRoh: unknown): boolean { const profilId = this.lernstand.profilIdPruefen(profilIdRoh); const laufId = this.offenerLauf.get(profilId); if (laufId === undefined) { return false; } const zeile = this.offeneZeile(profilId); if (zeile?.lauf_id !== laufId) { this.offenerLauf.delete(profilId); return false; } const bogen = this.bogenAusZeile(zeile); if (bogen === null) { return false; } const stand = this.standPruefen(standRoh, bogen); const ergebnis = this.db .prepare<{ eingaben: string; position: number; phase: string; zeit_abgelaufen: number; verbraucht_ms: number; gesichert_am: string; profil_id: number; lauf_id: string; }>( `UPDATE pruefung_offen SET eingaben = @eingaben, position = @position, phase = @phase, zeit_abgelaufen = @zeit_abgelaufen, verbraucht_ms = @verbraucht_ms, gesichert_am = @gesichert_am WHERE profil_id = @profil_id AND lauf_id = @lauf_id`, ) .run({ eingaben: JSON.stringify(stand.eingaben), position: stand.position, phase: stand.phase, zeit_abgelaufen: stand.zeitAbgelaufen ? 1 : 0, verbraucht_ms: stand.verbrauchtMs, gesichert_am: this.jetzt().toISOString(), profil_id: profilId, lauf_id: laufId, }); return ergebnis.changes > 0; } /** * Der offene Lauf eines Profils, oder `null`. * * Die Datei liegt im `userData`-Verzeichnis und ist von außen beschreibbar; * gelesen wird sie deshalb mit derselben Strenge wie eine Nutzlast aus dem * Renderer. Was sich nicht als gültiger Lauf lesen lässt, wird verworfen * statt geraten – ein halb verstandener Bogen wäre schlimmer als keiner. * * Nebenwirkung mit Absicht: Ein gelesener Bogen füllt `letzterBogen` wieder. * Damit prüft die Auswertung die eingereichte Antwortliste auch nach einem * Neustart, statt sie ungeprüft anzunehmen. */ offenerBogen(profilIdRoh: unknown): OffenerLauf | null { const profilId = this.lernstand.profilIdPruefen(profilIdRoh); const zeile = this.offeneZeile(profilId); if (zeile === undefined) { return null; } const bogen = this.bogenAusZeile(zeile); if (bogen === null) { this.verwerfenIntern(profilId); return null; } /* Der Auftrag wird beim Lesen erneut vollständig geprüft, nicht nur geparst: Die Datei liegt im `userData`-Verzeichnis, und ein von Hand veränderter Auftrag brächte sonst ein erfundenes Profil in die Wertung. Was hier durchfällt, wird verworfen statt geraten. */ const auftragRoh = leseJson(zeile.auftrag); let auftrag: GepruefterAuftrag; try { auftrag = this.auftragPruefen(auftragRoh); } catch { this.warnen('Offener Lauf ohne gültigen Auftrag – die Zeile wird verworfen.'); this.verwerfenIntern(profilId); return null; } this.letzterBogen.set(profilId, { ids: new Set(bogen.map((eintrag) => eintrag.frageId)), ausgewertet: false, }); this.offenerLauf.set(profilId, zeile.lauf_id); return { auftrag: auftragRoh as Pruefungsauftrag, /* Das wirksame Profil kommt aus dem geprüften Auftrag, nicht aus der gespeicherten Spalte: So kann eine veränderte Datei keine eigene Bestehensgrenze einschmuggeln. Die Spalte bleibt als Beleg dafür, unter welchen Vorgaben der Lauf begann. */ profil: auftrag.profil, bogen, eingaben: leseEingaben(zeile.eingaben), position: Math.min(Math.max(0, zeile.position), Math.max(0, bogen.length - 1)), phase: zeile.phase === 'nachbewertung' ? 'nachbewertung' : 'bearbeiten', zeitAbgelaufen: zeile.zeit_abgelaufen === 1, verbrauchtMs: Math.min(MAX_DAUER_MS, Math.max(0, zeile.verbraucht_ms)), begonnenAm: zeile.begonnen_am, gesichertAm: zeile.gesichert_am, }; } /** Verwirft den offenen Lauf – nur auf ausdrücklichen Wunsch. */ verwerfen(profilIdRoh: unknown): void { this.verwerfenIntern(this.lernstand.profilIdPruefen(profilIdRoh)); } private verwerfenIntern(profilId: number): void { this.db.prepare<[number]>('DELETE FROM pruefung_offen WHERE profil_id = ?').run(profilId); this.offenerLauf.delete(profilId); } private offeneZeile(profilId: number): OffeneZeile | undefined { return this.db .prepare<[number], OffeneZeile>( `SELECT lauf_id, auftrag, profil, bogen, eingaben, position, phase, zeit_abgelaufen, verbraucht_ms, begonnen_am, gesichert_am FROM pruefung_offen WHERE profil_id = ?`, ) .get(profilId); } /** * Der gespeicherte Bogen, oder `null`, wenn er nicht mehr zum Katalog passt. * * Ein Katalogwechsel zwischen Unterbrechung und Fortsetzen ist der Fall, * an dem das schiefgeht: Eine Frage, die es nicht mehr gibt, ließe eine * Lücke im Bogen, und die Auswertung zählte gegen eine andere Gesamtzahl * als die Anzeige. */ private bogenAusZeile(zeile: OffeneZeile): readonly Pruefungsfrage[] | null { const roh = leseJson(zeile.bogen); if (!Array.isArray(roh) || roh.length === 0) { this.warnen('Offener Lauf ohne lesbaren Bogen – die Zeile wird verworfen.'); return null; } const bogen: Pruefungsfrage[] = []; for (const eintrag of roh) { if (typeof eintrag !== 'object' || eintrag === null) { return null; } const kandidat = eintrag as Record; const frageId = kandidat['frageId']; const reihenfolge = kandidat['optionsReihenfolge']; if (typeof frageId !== 'string' || !this.fragen.has(frageId)) { this.warnen( `Der offene Lauf nennt die Frage „${entschaerft(frageId)}“, die es im ` + 'Katalog nicht mehr gibt – die Zeile wird verworfen.', ); return null; } if (!Array.isArray(reihenfolge) || reihenfolge.some((l) => typeof l !== 'string')) { return null; } bogen.push({ frageId, optionsReihenfolge: reihenfolge as string[] }); } if (bogen.length > MAX_BOGENGROESSE) { return null; } return bogen; } /** * Prüft, was der Renderer zu sichern schickt. * * Derselbe Maßstab wie bei der Auswertung: Die Nutzlast ist unbekannt, bis * sie geprüft wurde. Gemessene Größen werden gekappt statt abgewiesen – wer * ohne Zeitbegrenzung übt, soll seinen Lauf nicht wegen einer unplausiblen * Zahl verlieren. */ private standPruefen(wertRoh: unknown, bogen: readonly Pruefungsfrage[]): GepruefterStand { const roh = nutzlast(wertRoh, 'Der Zwischenstand'); const position = ganzeZahl(roh['position'] ?? 0, 'position', 0, Math.max(0, bogen.length - 1)); const verbrauchtMs = Math.min( MAX_DAUER_MS, Math.round( endlicheZahl(roh['verbrauchtMs'] ?? 0, 'verbrauchtMs', 0, Number.MAX_SAFE_INTEGER), ), ); const phaseRoh = roh['phase']; if (phaseRoh !== 'bearbeiten' && phaseRoh !== 'nachbewertung') { abweisen('Ungültige Anfrage: phase muss „bearbeiten“ oder „nachbewertung“ sein.'); } const zeitAbgelaufen = wahrheitswert(roh['zeitAbgelaufen'], 'zeitAbgelaufen', false); const erlaubt = new Set(bogen.map((eintrag) => eintrag.frageId)); const eingabenRoh = nutzlast(roh['eingaben'] ?? {}, 'Die Eingaben'); const eingaben: Record = {}; for (const [frageId, wert] of Object.entries(eingabenRoh)) { if (!erlaubt.has(frageId)) { abweisen(`Die Frage „${entschaerft(frageId)}“ gehört nicht zu diesem Bogen.`); } const eintrag = nutzlast(wert, `Die Eingabe zu „${entschaerft(frageId)}“`); const auswahl = textliste(eintrag['auswahl'] ?? [], 'auswahl'); const freitextRoh = eintrag['freitext']; const freitext = typeof freitextRoh === 'string' ? freitextRoh.slice(0, MAX_FREITEXT_LAENGE) : ''; /* Dreiwertig, und das mit Absicht: „noch nicht bewertet“ ist etwas anderes als „als falsch bewertet“. Ein Ersatzwert `false` würde eine unbewertete offene Frage beim Fortsetzen als falsch festschreiben. */ const selbstRoh = eintrag['selbst']; const selbst = selbstRoh === undefined || selbstRoh === null ? undefined : wahrheitswert(selbstRoh, 'selbst', false); eingaben[frageId] = { auswahl, freitext, ...(selbst === undefined ? {} : { selbst }), }; } return { eingaben, position, phase: phaseRoh, zeitAbgelaufen, verbrauchtMs }; } // ── Auswertung ─────────────────────────────────────────────────────── private fragePruefen(wert: unknown): Frage { if (typeof wert !== 'string' || wert.length === 0) { abweisen('Ungültige Anfrage: Die Frage-ID muss eine nicht-leere Zeichenkette sein.'); } const frage = this.fragen.get(wert); if (frage === undefined) { abweisen(`Unbekannte Frage-ID: „${entschaerft(wert)}“.`); } return frage; } /** * Vergleicht die eingereichten Antworten mit dem ausgegebenen Bogen. * * Ohne diese Prüfung nähme der Kern jede beliebige Liste an: Ein Lauf mit * einer einzigen richtigen Antwort landete als „bestanden" im Verlauf, * obwohl der Bogen 80 Fragen hatte. Der Verlauf soll aber abbilden, was * tatsächlich bearbeitet wurde. * * Ist kein Bogen bekannt – etwa nach einem Neustart mitten im Lauf –, wird * die Liste angenommen und nur vermerkt. Eine unterbrochene Sitzung soll * niemandem seine Arbeit kosten. * * **Aber nur, solange der Lauf überhaupt noch offen ist.** Bis 0.27.2 nahm * dieser Zweig alles an, und das traf vor allem einen Fall, für den er nie * gedacht war: den **zweiten** Aufruf für denselben Lauf. Nach der ersten * Auswertung ist der Merkposten geleert und die Zeile gelöscht – die Liste * ging ein zweites Mal durch. Gemessen an einem Bogen mit 80 Fragen: * `versuche` 80 → 160, ein zweiter Verlaufseintrag, und der trug das Profil * aus dem Auftrag des Renderers statt das des gezogenen Bogens. * * Der Neustart-Fall bleibt unberührt: Er hat seine Zeile noch, und * `offenerBogen()` füllt den Merkposten beim Fortsetzen ohnehin wieder. * Genau das ist der Unterschied, an dem sich beide Lagen trennen lassen. * * `sichern()` hält gegen dieselbe Verdopplung zwei Riegel. Dies ist der * dritte, an der Stelle, an der sie wirklich geschieht. */ private bogenPruefen(profilId: number, antworten: readonly GepruefteAntwort[]): void { const merkposten = this.letzterBogen.get(profilId); if (merkposten?.ausgewertet === true) { abweisen( 'Dieser Prüfungslauf wurde bereits ausgewertet. Er wird nicht ein zweites Mal ' + 'gewertet – sonst stünde er doppelt im Verlauf und jede Antwort ginge ein ' + 'zweites Mal in die Wiedervorlage.', ); } const bogen = merkposten?.ids; if (bogen === undefined) { this.warnen( 'Prüfungsbogen nicht bekannt (vermutlich Neustart während des Laufs) – ' + 'die eingereichten Antworten werden ungeprüft übernommen.', ); return; } if (antworten.length !== bogen.size) { abweisen( `Der Bogen umfasste ${String(bogen.size)} Fragen, eingereicht wurden ` + `${String(antworten.length)}.`, ); } for (const antwort of antworten) { if (!bogen.has(antwort.frage.id)) { abweisen(`Die Frage „${entschaerft(antwort.frage.id)}“ war nicht Teil des Bogens.`); } } /* Ein Bogen wird genau einmal ausgewertet – und das wird **vermerkt**, nicht vergessen. Gelöscht führte der nächste Aufruf in den Zweig „Bogen nicht bekannt“ und käme damit durch; genau das war der Befund. */ this.letzterBogen.set(profilId, { ids: bogen, ausgewertet: true }); } /** * Prüft die Antworten eines Laufs. * * Der Renderer schickt für jede Frage des Bogens einen Eintrag – auch für * die unbeantworteten, dann mit leerer Auswahl. Nur so lässt sich * „unbeantwortet“ von „gar nicht gestellt“ unterscheiden. */ private antwortenPruefen(wertRoh: unknown): GepruefteAntwort[] { if (!Array.isArray(wertRoh)) { abweisen('Ungültige Anfrage: antworten muss eine Liste sein.'); } const roh = wertRoh as readonly unknown[]; if (roh.length === 0) { abweisen('Ungültige Anfrage: Es wurde mindestens eine Antwort erwartet.'); } if (roh.length > MAX_BOGENGROESSE) { abweisen( `Ungültige Anfrage: Ein Bogen umfasst höchstens ${String(MAX_BOGENGROESSE)} Fragen.`, ); } const gesehen = new Set(); return roh.map((eintragRoh, i) => { const eintrag = nutzlast(eintragRoh, `antworten[${String(i)}]`); const frage = this.fragePruefen(eintrag['frageId']); if (gesehen.has(frage.id)) { abweisen(`Ungültige Anfrage: Die Frage „${frage.id}“ kommt mehrfach im Bogen vor.`); } gesehen.add(frage.id); const erlaubteLabels = new Set((frage.optionen ?? []).map((o) => o.label)); const auswahlRoh = textliste(eintrag['auswahl'], `antworten[${String(i)}].auswahl`); for (const label of auswahlRoh) { if (!erlaubteLabels.has(label)) { abweisen( `Ungültige Anfrage: „${entschaerft(label)}“ ist keine Antwortoption der Frage „${frage.id}“.`, ); } } const auswahl = [...new Set(auswahlRoh)].sort(); const freitextRoh = eintrag['freitext']; if (freitextRoh !== undefined && freitextRoh !== null && typeof freitextRoh !== 'string') { abweisen( `Ungültige Anfrage: antworten[${String(i)}].freitext muss eine Zeichenkette sein.`, ); } const freitext = typeof freitextRoh === 'string' ? freitextRoh.slice(0, MAX_FREITEXT_LAENGE) : null; const selbstRoh = eintrag['selbstAlsRichtig']; const selbstAlsRichtig = wahrheitswert( selbstRoh, `antworten[${String(i)}].selbstAlsRichtig`, false, ); // Multiple Choice wird nachgerechnet, die Meldung des Renderers zählt // nicht. Bei offenen Fragen gibt es keine maschinelle Wahrheit. const mc = frage.typ === 'mc'; const richtig = mc ? bewerteAuswahl(frage, auswahl).richtig : selbstAlsRichtig; const unbeantwortet = mc ? auswahl.length === 0 : (freitext ?? '').trim().length === 0 && (selbstRoh === undefined || selbstRoh === null); return { frage, auswahl, freitext, richtig, unbeantwortet }; }); } /** Bereichsstatistik in Katalogreihenfolge; leere Bereiche bleiben weg. */ private bereicheBilden(antworten: readonly GepruefteAntwort[]): BereichErgebnis[] { interface Eimer { gesamt: number; richtig: number; } const eimer = new Map(); for (const antwort of antworten) { const id = antwort.frage.abschnitt ?? antwort.frage.kapitel; let eintrag = eimer.get(id); if (eintrag === undefined) { eintrag = { gesamt: 0, richtig: 0 }; eimer.set(id, eintrag); } eintrag.gesamt += 1; if (antwort.richtig) { eintrag.richtig += 1; } } const reihenfolge: string[] = []; for (const kapitel of this.katalog.kapitel) { reihenfolge.push(...kapitel.abschnitte.map((a) => a.id), kapitel.id); } return reihenfolge .filter((id) => eimer.has(id)) .map((id) => { const werte = eimer.get(id) ?? { gesamt: 0, richtig: 0 }; return { bereich: id, titel: this.bereichTitel.get(id) ?? id, gesamt: werte.gesamt, richtig: werte.richtig, }; }); } /** Verletzte K.-o.-Kriterien, jeweils mit Zahlen für die Begründung. */ private koKriterienPruefen( profil: Pruefungsprofil, antworten: readonly GepruefteAntwort[], ): string[] { const verletzt: string[] = []; for (const kriterium of profil.koKriterien ?? []) { const fehler = antworten.filter( (a) => !a.richtig && gehoertZu(a.frage, kriterium.bereich), ).length; if (fehler > kriterium.maxFehler) { verletzt.push( `${kriterium.bezeichnung} (${String(fehler)} Fehler, ` + `höchstens ${String(kriterium.maxFehler)} zulässig)`, ); } } return verletzt; } /** * Wertet einen Lauf aus, speichert ihn und protokolliert jede Antwort im * Lernstand. Beides gehört zusammen und läuft deshalb in einer Transaktion. */ auswerten( profilIdRoh: unknown, auftragRoh: unknown, antwortenRoh: unknown, dauerMsRoh: unknown, zeitAbgelaufenRoh: unknown, ): Pruefungsergebnis { const profilId = this.lernstand.profilIdPruefen(profilIdRoh); /* Liegt ein offener Lauf vor, gilt SEIN Auftrag – nicht der, den der Renderer mitschickt. Sonst ließe sich ein fortgesetzter Standardbogen mit dem Fehlerpunkte-Profil abgeben: Bogen und Urteilsregeln kämen aus verschiedenen Läufen. */ const gespeichert = this.offeneZeile(profilId); const gespeicherterAuftrag = gespeichert === undefined ? null : leseJson(gespeichert.auftrag); const auftrag = this.auftragPruefen( gespeicherterAuftrag !== null && typeof gespeicherterAuftrag === 'object' ? gespeicherterAuftrag : auftragRoh, ); const antworten = this.antwortenPruefen(antwortenRoh); this.bogenPruefen(profilId, antworten); /* Gerundet statt abgewiesen: der Vertrag sagt nur „number“, und eine mit `performance.now()` gemessene Dauer hat Nachkommastellen. Eine ganze Prüfung wegen einer halben Millisekunde zu verwerfen wäre unangemessen. Aus demselben Grund wird eine übergroße Dauer gekappt statt abgewiesen: Wer ohne Zeitbegrenzung übt – der Nachteilsausgleich für alle, die mehr Zeit brauchen – lässt das Fenster womöglich über Nacht offen. Diesen Lauf zu verwerfen träfe genau die Gruppe, für die die Einstellung da ist. Unsinnige Werte (kein `number`, NaN, negativ) bleiben abgewiesen. */ const dauerMs = Math.min( MAX_DAUER_MS, Math.round(endlicheZahl(dauerMsRoh ?? 0, 'dauerMs', 0, Number.MAX_SAFE_INTEGER)), ); const zeitAbgelaufen = wahrheitswert(zeitAbgelaufenRoh, 'zeitAbgelaufen', false); const profil = auftrag.profil; const gesamt = antworten.length; const richtig = antworten.filter((a) => a.richtig).length; const unbeantwortet = antworten.filter((a) => a.unbeantwortet).length; const quote = gesamt > 0 ? richtig / gesamt : 0; const verletzteKriterien = this.koKriterienPruefen(profil, antworten); const { urteil, begruendung } = urteilBilden(profil, richtig, gesamt, verletzteKriterien); const zeitpunkt = this.jetzt().toISOString(); const bereiche = this.bereicheBilden(antworten); // Die Simulation misst nur die Gesamtdauer. Für das Antwortprotokoll wird // sie gleichmäßig verteilt: das erhält die Summe und ist die einzige // Aussage, die die Messung wirklich hergibt. const dauerJeFrage = gesamt > 0 ? Math.round(dauerMs / gesamt) : 0; /* Wird in der Transaktion gesetzt. Ohne die Kennung könnte der Vergleich den eben gespeicherten Lauf nicht von den früheren unterscheiden – der Verlauf enthält ihn bereits, wenn die Auswertung ihn lädt. */ let laufId = 0; const speichern = this.db.transaction(() => { /* In derselben Transaktion wie der Verlaufseintrag, und das ist der Punkt: „ausgewertet" und „nicht mehr offen" müssen eine einzige Tatsache sein. Löschte der Renderer die Zeile über einen zweiten Aufruf, überlebte sie jeden Weg, der zwischen Festschreiben und zweitem Aufruf endet – und dieselbe Prüfung ließe sich beim nächsten Start ein zweites Mal abgeben. Jede Antwort ginge dann ein zweites Mal in die Wiedervorlage. Scheitert die Auswertung, macht SQLite auch das Löschen rückgängig und der Bogen bleibt erhalten. Genau das soll sein. */ this.db.prepare<[number]>('DELETE FROM pruefung_offen WHERE profil_id = ?').run(profilId); const eingefuegt = this.db .prepare<{ profil_id: number; pruefungsprofil: string; zeitpunkt: string; gesamt: number; richtig: number; quote: number; urteil: string; dauer_ms: number; zeitmodus: string; unbeantwortet: number; zeit_abgelaufen: number; bereiche: string; bestehens_quote: number | null; }>( `INSERT INTO pruefung_lauf (profil_id, pruefungsprofil, zeitpunkt, gesamt, richtig, quote, urteil, dauer_ms, zeitmodus, unbeantwortet, zeit_abgelaufen, bereiche, bestehens_quote) VALUES (@profil_id, @pruefungsprofil, @zeitpunkt, @gesamt, @richtig, @quote, @urteil, @dauer_ms, @zeitmodus, @unbeantwortet, @zeit_abgelaufen, @bereiche, @bestehens_quote)`, ) .run({ profil_id: profilId, pruefungsprofil: profil.id, zeitpunkt, gesamt, richtig, quote, urteil, dauer_ms: dauerMs, zeitmodus: auftrag.zeitmodus, unbeantwortet, zeit_abgelaufen: zeitAbgelaufen ? 1 : 0, /* Die Themenanalyse, wie sie in der Auswertung stand. Aus `antwort_log` wäre sie später nicht zu rekonstruieren: Dort steht nicht, welcher Lauf welche Zeile geschrieben hat. */ bereiche: JSON.stringify(bereiche), /* Der Maßstab dieses Laufs. Beim frei eingestellten Profil sind die gewählten Werte danach fort; ihn hinterher aus dem Vertrag zu holen ergäbe für genau diese Läufe eine falsche Grenze. */ bestehens_quote: grenzquote(profil, gesamt), }); /* better-sqlite3 liefert `bigint`, wenn die Kennung nicht mehr in eine Zahl passt. Bei Verlaufszeilen ist das unerreichbar; die Umwandlung steht trotzdem da, damit der Typ stimmt und nicht bloß behauptet wird. */ laufId = Number(eingefuegt.lastInsertRowid); /* Über den Lernstand statt direkt in `antwort_log`: so entstehen Protokolleintrag, Fragenstand und Wiedervorlage genau wie beim Lernen, und es gibt nur eine Stelle, die diese Regeln kennt. Ausnahme sind Fragen, die im Bogen standen, aber nie aufgeschlagen wurden – etwa wenn die Zeit ablief. Sie gehören in die Historie, dürfen den Lernstand aber nicht zurückstufen: Eine sicher gewusste Frage wäre sonst allein deshalb wieder fällig, weil ein Prüfungslauf nicht bis zu ihr kam. */ for (const antwort of antworten) { const eintrag = { frageId: antwort.frage.id, auswahl: antwort.auswahl, ...(antwort.freitext === null ? {} : { freitext: antwort.freitext }), richtig: antwort.richtig, bewertung: (antwort.richtig ? 'gut' : 'nochmal') as Bewertung, dauerMs: dauerJeFrage, }; if (antwort.unbeantwortet) { this.lernstand.protokollieren(profilId, eintrag); } else { this.lernstand.antworten(profilId, eintrag); } } }); speichern(); /* Ohne Kennung im Speicher verfällt jede verspätete Sicherung still. */ this.offenerLauf.delete(profilId); return { profilId: profil.id, gesamt, richtig, // Zerlegung, keine Überschneidung: richtig + falsch + unbeantwortet // ergibt gesamt (siehe Pruefungsergebnis.falsch). falsch: gesamt - richtig - unbeantwortet, unbeantwortet, quote, urteil, begruendung, verletzteKriterien, bereiche, fehlerIds: antworten.filter((a) => !a.richtig).map((a) => a.frage.id), dauerMs, zeitAbgelaufen, zeitpunkt, laufId, }; } // ── Verlauf ────────────────────────────────────────────────────────── /** Die gespeicherten Läufe, neueste zuerst. */ verlauf(profilIdRoh: unknown): Pruefungsverlauf[] { const profilId = this.lernstand.profilIdPruefen(profilIdRoh); return this.db .prepare<[number, number], LaufZeile>( `SELECT id, pruefungsprofil, zeitpunkt, gesamt, richtig, quote, urteil, dauer_ms, zeitmodus, unbeantwortet, zeit_abgelaufen, bereiche, bestehens_quote FROM pruefung_lauf WHERE profil_id = ? ORDER BY zeitpunkt DESC, id DESC LIMIT ?`, ) .all(profilId, VERLAUF_GRENZE) .map((zeile) => ({ id: zeile.id, profilId: zeile.pruefungsprofil, // Der Name kommt aus dem Vertrag. Wurde ein Profil zwischenzeitlich // entfernt, bleibt wenigstens seine Kennung lesbar. profilName: profilFinden(zeile.pruefungsprofil)?.name ?? zeile.pruefungsprofil, zeitpunkt: zeile.zeitpunkt, gesamt: zeile.gesamt, richtig: zeile.richtig, quote: zeile.quote, // Die Spalte ist über CHECK abgesichert; sollte doch etwas anderes // darin stehen, wird der Lauf nicht als bestanden ausgewiesen. urteil: istUrteil(zeile.urteil) ? zeile.urteil : 'nicht_bestanden', /* Gekappt wie beim Speichern: Eine Dauer jenseits der Obergrenze stammt nicht aus einer Bearbeitung, und der Verlauf soll sie nicht als eine ausweisen. */ dauerMs: Math.min(MAX_DAUER_MS, Math.max(0, zeile.dauer_ms)), /* Läufe von vor Schema-Version 4 haben keine Zeitstufe. Sie zu raten wäre schlechter, als sie offen zu lassen. */ zeitmodus: istZeitmodus(zeile.zeitmodus) ? zeile.zeitmodus : null, /* Und die vier aus Fassung 10: `null` heißt „nicht festgehalten“ und bleibt `null`. Ein Ersatzwert wäre eine Behauptung über einen Lauf, von dem niemand mehr weiß, wie er endete. */ unbeantwortet: typeof zeile.unbeantwortet === 'number' && zeile.unbeantwortet >= 0 ? zeile.unbeantwortet : null, zeitAbgelaufen: typeof zeile.zeit_abgelaufen === 'number' ? zeile.zeit_abgelaufen === 1 : null, bereiche: bereicheLesen(zeile.bereiche), bestehensQuote: typeof zeile.bestehens_quote === 'number' && zeile.bestehens_quote >= 0 && zeile.bestehens_quote <= 1 ? zeile.bestehens_quote : null, })); } } // ─── Instanz für den Main-Prozess ─────────────────────────────────────────── let instanz: Pruefung | null = null; let quelle: Lernstand | null = null; /** * Liefert die Prüfungssimulation zum übergebenen Lernstand. * * Wird der Lernstand ausgetauscht – etwa nach `lernstandSchliessen()` –, * entsteht automatisch eine neue Instanz. */ export function pruefungInstanz(lernstand: Lernstand, katalog: Katalog): Pruefung { if (instanz === null || quelle !== lernstand) { instanz = new Pruefung(lernstand, katalog); quelle = lernstand; } return instanz; } /** Gegenstück für Tests und sauberes Herunterfahren. */ export function pruefungZuruecksetzen(): void { instanz = null; quelle = null; }