/** * Prüfungssimulation. * * Die AWaffV schreibt weder Fragenzahl noch Zeit oder Bestehensgrenze vor – * die reale Prüfung unterscheidet sich je nach Prüfungsstelle erheblich. * Deshalb gibt es hier Profile statt eines festen Modus. Die Werte stammen aus * veröffentlichten Prüfungsordnungen (siehe PLAN.md, Abschnitt 2.1). */ import type { Fragetyp } from './katalog'; /** Wie das Bestehen ermittelt wird. */ export type Wertungsart = /** Anteil richtiger Antworten muss die Grenze erreichen. */ | 'quote' /** Fehlerpunkte dürfen eine Obergrenze nicht überschreiten. */ | 'fehlerpunkte'; /** * Zusatzbedingung, die unabhängig vom Gesamtergebnis zum Nichtbestehen führt. * Manche Träger werten etwa mehr als zwei Fehler bei Notwehr und Notstand als * nicht bestanden, auch wenn die Gesamtquote stimmt. */ export interface KoKriterium { /** Abschnitts- oder Kapitel-ID, z. B. „I.5“. */ readonly bereich: string; readonly bezeichnung: string; readonly maxFehler: number; } export interface Pruefungsprofil { readonly id: string; readonly name: string; /** Kurze Erläuterung, woher die Werte stammen. */ readonly beschreibung: string; readonly fragenAnzahl: number; readonly wertung: Wertungsart; /** Bei `quote`: nötiger Anteil richtiger Antworten (0 bis 1). */ readonly bestehensQuote?: number; /** Bei `fehlerpunkte`: höchstzulässige Fehlerzahl. */ readonly maxFehler?: number; /** Vorgeschlagenes Zeitlimit in Minuten; `null` bedeutet ohne Zeitlimit. */ readonly zeitMinuten: number | null; /** * Untergrenze einer Grauzone: Wer darüber, aber unter der Bestehensgrenze * liegt, wird bei manchen Trägern mündlich nachgeprüft. */ readonly nachpruefungAb?: number; /** Anteil offener Fragen (0 bis 1); der Rest ist Multiple Choice. */ readonly anteilOffen?: number; /** Feste Fragenzahl je Bereich, wenn der Träger Themenquoten vorgibt. */ readonly themenquoten?: Readonly>; readonly koKriterien?: readonly KoKriterium[]; /** Frei einstellbares Profil – die Werte dürfen verändert werden. */ readonly anpassbar?: boolean; } /** * Vorgegebene Profile. * * Wichtig: Keines davon ist „die“ amtliche Prüfung. Die Software weist darauf * hin, dass allein der zuständige Prüfungsausschuss entscheidet. */ export const PRUEFUNGSPROFILE: readonly Pruefungsprofil[] = Object.freeze([ Object.freeze({ id: 'standard', name: 'Standard', beschreibung: '80 Fragen, 120 Minuten, 80 Prozent zum Bestehen. Verbreiteter Zuschnitt, ' + 'wie ihn auch gängige Online-Trainer verwenden.', fragenAnzahl: 80, wertung: 'quote', bestehensQuote: 0.8, zeitMinuten: 120, }), Object.freeze({ id: 'dsb', name: 'Nach Art des Deutschen Schützenbundes', beschreibung: '100 Fragen in festen Themenblöcken, 120 Minuten, 75 Prozent zum Bestehen. ' + 'Zwischen 60 und 74 Prozent ist eine mündliche Nachprüfung vorgesehen. ' + 'Höchstens 80 Prozent Multiple Choice, der Rest ist auszuformulieren.', fragenAnzahl: 100, wertung: 'quote', bestehensQuote: 0.75, nachpruefungAb: 0.6, zeitMinuten: 120, anteilOffen: 0.2, themenquoten: Object.freeze({ 'I.1': 10, 'I.2': 20, 'I.3': 10, 'I.4': 10, 'I.5': 10, II: 20, III: 10, IV: 10, }), }), Object.freeze({ id: 'bdmp', name: 'Nach Art des BDMP', beschreibung: 'Fragen aus dem amtlichen Katalog, bis 120 Minuten, 70 Prozent zum Bestehen ' + 'laut Prüfungsordnung des Verbands.', fragenAnzahl: 80, wertung: 'quote', bestehensQuote: 0.7, zeitMinuten: 120, }), Object.freeze({ id: 'fehlerpunkte', name: 'Nach Art privater Lehrgangsträger', beschreibung: '75 Fragen mit Fehlerpunktegrenze statt Quote. Mehr als zwei Fehler bei ' + 'Notwehr und Notstand führen bei manchen Trägern unabhängig vom ' + 'Gesamtergebnis zum Nichtbestehen.', fragenAnzahl: 75, wertung: 'fehlerpunkte', maxFehler: 15, zeitMinuten: 90, anteilOffen: 0.15, koKriterien: Object.freeze([ Object.freeze({ bereich: 'I.5', bezeichnung: 'Notwehr und Notstand', maxFehler: 2 }), ]), }), Object.freeze({ id: 'frei', name: 'Selbst einstellen', beschreibung: 'Fragenzahl, Zeit, Bestehensgrenze und Anteil offener Fragen frei wählen.', fragenAnzahl: 40, wertung: 'quote', bestehensQuote: 0.75, zeitMinuten: null, anpassbar: true, }), ]); /** Wie das Zeitlimit gehandhabt wird (WCAG 2.2.1 – Timing Adjustable). */ export type Zeitmodus = /** Ohne Zeitbegrenzung – immer verfügbar, auch in der Simulation. */ | 'aus' /** Vorgabe des Profils. */ | 'normal' /** Vorgabe plus 25 Prozent – üblicher Nachteilsausgleich. */ | 'plus25' /** Vorgabe plus 50 Prozent. */ | 'plus50'; export const ZEITMODUS_BEZEICHNUNG: Readonly> = Object.freeze({ aus: 'Ohne Zeitbegrenzung', normal: 'Vorgesehene Zeit', plus25: 'Vorgesehene Zeit plus 25 Prozent', plus50: 'Vorgesehene Zeit plus 50 Prozent', }); /** Errechnet die tatsächliche Bearbeitungszeit in Minuten. */ export function zeitInMinuten(profil: Pruefungsprofil, modus: Zeitmodus): number | null { if (modus === 'aus' || profil.zeitMinuten === null) { return null; } const faktor = modus === 'plus25' ? 1.25 : modus === 'plus50' ? 1.5 : 1; return Math.round(profil.zeitMinuten * faktor); } /** Einstellungen eines konkreten Simulationslaufs. */ export interface Pruefungsauftrag { readonly profilId: string; readonly zeitmodus: Zeitmodus; /** Überschreibt die Profilwerte, nur bei anpassbaren Profilen. */ readonly fragenAnzahl?: number; readonly bestehensQuote?: number; readonly zeitMinuten?: number | null; /** * Anteil offener Fragen (0 bis 1), nur bei anpassbaren Profilen. * * 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 der Anwendungskern als Warnung, statt es still * durch Auswahlfragen zu ersetzen. */ readonly anteilOffen?: number; /** * Kapitel, aus denen in dieser Simulation keine Frage vorkommt. * * Vorbelegt aus dem Lernprofil (`profil.kapitel_ausschluss`), je Simulation * aber änderbar. Die Vorbelegung ist keine Bequemlichkeit: Erststart-Frage * und Schalter unter „Ihr Lernplan“ sagen zu, dass abgewählte Fragen „in * keiner Sitzung und in keiner Zahl mehr“ vorkommen. Eine Simulation, die * sie ungefragt wieder mitzieht, bricht diese Zusage. */ readonly kapitelAusschluss?: readonly string[]; /** * Antwortmöglichkeiten mischen. Vorgabe ist `false`. * * Gilt auch hier und nicht nur im Lernmodus: Wer die Antworten über ihre * Stelle behalten muss, braucht das gerade in der Simulation – sonst * prüft sie eine Beeinträchtigung mit, nicht den Stoff. */ readonly optionenMischen?: boolean; } /** Eine Frage im Prüfungsbogen. */ export interface Pruefungsfrage { readonly frageId: string; readonly optionsReihenfolge: readonly string[]; } /** * Ein fertig zusammengestellter Bogen samt der Abweichungen dabei. * * `warnungen` ist der Grund für diesen eigenen Typ. Beim Ziehen kann der * Anwendungskern von den Vorgaben abweichen: ein Themenbereich hat zu wenige * Fragen, der Bogen fällt kürzer aus als das Profil vorsieht, ein festes * Profil nimmt eine Überschreibung nicht an, oder es fehlten offene Fragen und * wurde mit Auswahlfragen aufgefüllt. Bis Fassung 0.20.0 gingen diese vier * Meldungen ausschließlich über `console.warn` in das Protokoll des * Hauptprozesses – wer simulierte, hielt seinen Bogen für profilgetreu. * * Sie gehören an den Bildschirm, und zwar in fertigen Sätzen ohne Feldnamen: * Die Oberfläche gibt sie unverändert aus und formuliert nichts nach. */ export interface Pruefungsbogen { readonly fragen: readonly Pruefungsfrage[]; /** Leer, wenn der Bogen genau den Vorgaben entspricht. */ readonly warnungen: readonly string[]; } /** * Ablaufphase eines Laufs, soweit sie sich sichern lässt. * * „abgabefrage" wird auf „bearbeiten" abgebildet – eine offene Rückfrage * gehört nicht wiederhergestellt. „auswerten" wird nie gesichert: Ab dort * gehört der Lauf der Auswertung. */ export type GesichertePhase = 'bearbeiten' | 'nachbewertung'; /** Eingaben zu einer Frage, wie sie ein unterbrochener Lauf festhält. */ export interface GesicherteEingabe { readonly auswahl: readonly string[]; readonly freitext: string; /** * Selbstbewertung einer offenen Frage. * * Fehlt, solange nicht bewertet wurde – dreiwertig mit Absicht: „noch * nicht bewertet" ist etwas anderes als „als falsch bewertet". Ein * Ersatzwert `false` schriebe eine unbewertete Frage beim Fortsetzen als * falsch fest. */ readonly selbst?: boolean; } /** Was der Renderer an einem laufenden Bogen sichert – und nur das. */ export interface Zwischenstand { readonly eingaben: Readonly>; readonly position: number; readonly phase: GesichertePhase; readonly zeitAbgelaufen: boolean; /** * Verbrauchte Bearbeitungszeit in Millisekunden. * * Stets `Date.now() - startMs`, nie ein nebenher hochgezählter Sammelwert: * Restzeit und Bearbeitungsdauer müssen aus derselben Rechnung stammen, * sonst laufen Anzeige und Wertung auseinander. */ readonly verbrauchtMs: number; } /** * Ein unterbrochener Lauf, wie ihn die Oberfläche zum Fortsetzen bekommt. * * `auftrag`, `profil` und `bogen` stammen aus dem Anwendungskern, nicht aus * dem Renderer – sie wurden beim Starten dort abgelegt. */ export interface OffenerLauf { readonly auftrag: Pruefungsauftrag; /** Das wirksame Profil des Laufs, mit den tatsächlich gewählten Werten. */ readonly profil: Pruefungsprofil; readonly bogen: readonly Pruefungsfrage[]; readonly eingaben: Readonly>; readonly position: number; readonly phase: GesichertePhase; readonly zeitAbgelaufen: boolean; readonly verbrauchtMs: number; readonly begonnenAm: string; readonly gesichertAm: string; } /** Antwort auf eine Prüfungsfrage. */ export interface Pruefungsantwort { readonly frageId: string; readonly auswahl: readonly string[]; readonly freitext?: string; /** Bei offenen Fragen bewertet der Prüfling selbst. */ readonly selbstAlsRichtig?: boolean; } /** Ergebnis je Bereich, für die Themenanalyse der Auswertung. */ export interface BereichErgebnis { readonly bereich: string; readonly titel: string; readonly gesamt: number; readonly richtig: number; } export type Bestehensurteil = 'bestanden' | 'nachpruefung' | 'nicht_bestanden'; export interface Pruefungsergebnis { readonly profilId: string; readonly gesamt: number; readonly richtig: number; /** * Beantwortet, aber nicht richtig. * * Die drei Zahlen `richtig`, `falsch` und `unbeantwortet` bilden eine * Zerlegung von `gesamt` – sie summieren sich also darauf und überschneiden * sich nicht. Die Auswertung zeigt sie nebeneinander; würden die * unbeantworteten zusätzlich in `falsch` stecken, ergäbe die Anzeige mehr * Fragen als der Bogen hat. */ readonly falsch: number; readonly unbeantwortet: number; /** Anteil richtiger Antworten, 0 bis 1. */ readonly quote: number; readonly urteil: Bestehensurteil; /** Begründung in einem Satz, für die Anzeige. */ readonly begruendung: string; /** Ausgelöste K.-o.-Kriterien, falls vorhanden. */ readonly verletzteKriterien: readonly string[]; readonly bereiche: readonly BereichErgebnis[]; /** * IDs aller nicht richtig beantworteten Fragen – für „Fehler wiederholen“. * Anders als {@link falsch} zählen hier die unbeantworteten mit: Wer sie * nie gesehen hat, soll sie üben können. */ readonly fehlerIds: readonly string[]; readonly dauerMs: number; readonly zeitAbgelaufen: boolean; /** Zeitpunkt als ISO-Zeichenkette. */ readonly zeitpunkt: string; /** * Kennung der gespeicherten Verlaufszeile. * * Fehlt, wenn der Lauf **nicht** gespeichert wurde – das ist der Fall der * Ersatzauswertung im Renderer, wenn der Kanal ausfällt * (`renderer/src/pruefung/bewertung.ts`). Eine erfundene Kennung wäre dort * schlimmer als keine: Der Vergleich mit früheren Läufen schließt den * aktuellen über genau diese Kennung aus und würde sonst den falschen * ausschließen. */ readonly laufId?: number; } /** Ein gespeicherter Simulationslauf für die Verlaufsanzeige. */ export interface Pruefungsverlauf { readonly id: number; readonly profilId: string; readonly profilName: string; readonly zeitpunkt: string; readonly gesamt: number; readonly richtig: number; readonly quote: number; readonly urteil: Bestehensurteil; /** Gemessene Bearbeitungsdauer in Millisekunden. */ readonly dauerMs: number; /** * Gewählte Zeitstufe des Laufs. * * `null` bei Läufen aus der Zeit vor Schema-Version 4: Sie ist dort nicht * mehr zu ermitteln, und sie zu raten wäre schlechter, als sie offen zu * lassen. Ohne diese Angabe stünden ein Lauf ohne Uhr und einer unter * Zeitdruck in der Tabelle nebeneinander, als wären sie vergleichbar. */ readonly zeitmodus: Zeitmodus | null; /** * Wie viele Fragen des Bogens unbeantwortet blieben. * * `null` bei Läufen vor Schema-Version 10. Der Wert ist der Grund, warum es * diese Fassung gibt: `quote` zählt Unbeantwortete wie falsch beantwortete, * und ein Lauf, in dem die Zeit ablief, sähe im Vergleich sonst wie ein * Wissenseinbruch aus. */ readonly unbeantwortet: number | null; /** Ob die Bearbeitungszeit ablief. `null` bei Läufen vor Fassung 10. */ readonly zeitAbgelaufen: boolean | null; /** * Ergebnis je Bereich, so wie es die Auswertung des Laufs zeigte. * * `null` bei Läufen vor Fassung 10: Es wurde damals nicht gespeichert und * lässt sich aus `antwort_log` nicht zurückrechnen – dort steht nicht, * welcher Lauf welche Zeile schrieb. */ readonly bereiche: readonly BereichErgebnis[] | null; /** * Die Bestehensgrenze dieses Laufs als Anteil richtiger Antworten. * * `null` bei Läufen vor Fassung 10. Siehe {@link grenzquote} – der Wert wird * beim Speichern aus dem *wirksamen* Profil gebildet, weil er sich später * nicht mehr ermitteln lässt: Beim frei eingestellten Profil sind die * gewählten Werte nach dem Lauf fort. */ readonly bestehensQuote: number | null; } /** * Bereiche, in denen mindestens ein Profil ein K.-o.-Kriterium führt. * * Abgeleitet statt aufgezählt: Wer ein Kriterium ergänzt, ergänzt es an einer * Stelle. Die Prüfungsreife-Ampel deckelt daran ihr Gesamturteil – ein * zweiter, von Hand gepflegter Auszug wäre die nächste Zahl, die still * auseinanderläuft. */ export const KO_BEREICHE: readonly string[] = Object.freeze([ ...new Set( PRUEFUNGSPROFILE.flatMap((profil) => (profil.koKriterien ?? []).map((k) => k.bereich)), ), ]); /** * Liegt diese Frage in einem Bereich mit K.-o.-Kriterium? * * Geprüft werden Kapitel **und** Abschnitt, weil ein Kriterium beides * bezeichnen kann – „I.5“ ist heute ein Abschnitt. Dieselbe Regel wie in * `gehoertZuBereich` beim Zusammenstellen des Prüfungsbogens; sie steht hier * ein zweites Mal, weil der Anwendungskern nichts aus dem Renderer holen * darf und ein Umzug dieser vier Zeilen mehr Wege anfasste als er wert wäre. */ export function istKoFrage(kapitel: string, abschnitt: string | null): boolean { return KO_BEREICHE.some((bereich) => kapitel === bereich || abschnitt === bereich); } export function profilFinden(id: string): Pruefungsprofil | undefined { return PRUEFUNGSPROFILE.find((p) => p.id === id); } /** Menschlich lesbares Urteil. */ export const URTEIL_BEZEICHNUNG: Readonly> = Object.freeze({ bestanden: 'Bestanden', nachpruefung: 'Mündliche Nachprüfung', nicht_bestanden: 'Nicht bestanden', }); /** * Ermittelt, ob mit diesem Profil bestanden wurde. * * Reine Rechenfunktion ohne Seiteneffekte, damit sie in Anwendungskern und * Oberfläche dasselbe Ergebnis liefert und gut prüfbar bleibt. */ export function urteilBilden( profil: Pruefungsprofil, richtig: number, gesamt: number, verletzteKriterien: readonly string[], ): { urteil: Bestehensurteil; begruendung: string } { if (verletzteKriterien.length > 0) { return { urteil: 'nicht_bestanden', begruendung: `Nicht bestanden wegen: ${verletzteKriterien.join(', ')}.`, }; } if (profil.wertung === 'fehlerpunkte') { const fehler = gesamt - richtig; const grenze = profil.maxFehler ?? 0; return fehler <= grenze ? { urteil: 'bestanden', begruendung: `${String(fehler)} von höchstens ${String(grenze)} zulässigen Fehlern.`, } : { urteil: 'nicht_bestanden', begruendung: `${String(fehler)} Fehler bei höchstens ${String(grenze)} zulässigen.`, }; } const quote = gesamt > 0 ? richtig / gesamt : 0; const grenze = profil.bestehensQuote ?? 0.75; /* Die Begründung wird aus Trefferzahlen gebaut, nicht aus gerundeten Prozentwerten. Sonst entstünde ein Satz, der sich selbst widerspricht: 149 von 200 sind 74,5 Prozent – kaufmännisch gerundet 75 –, und die Anzeige lautete „Nicht bestanden. 75 Prozent richtig, nötig waren 75 Prozent." Mit Zahlen ist das eindeutig und rundungsfrei. */ const noetig = benoetigteTreffer(grenze, gesamt); if (quote >= grenze) { return { urteil: 'bestanden', begruendung: `${String(richtig)} von ${String(gesamt)} richtig, nötig waren ${String(noetig)}.`, }; } if (profil.nachpruefungAb !== undefined && quote >= profil.nachpruefungAb) { return { urteil: 'nachpruefung', begruendung: `${String(richtig)} von ${String(gesamt)} richtig. ` + `Ab ${String(benoetigteTreffer(profil.nachpruefungAb, gesamt))} richtigen Antworten sieht ` + `dieses Profil eine mündliche Nachprüfung vor, bestanden wäre ab ${String(noetig)}.`, }; } return { urteil: 'nicht_bestanden', begruendung: `${String(richtig)} von ${String(gesamt)} richtig, nötig waren ${String(noetig)}.`, }; } /** * Die Bestehensgrenze eines Laufs als Anteil richtiger Antworten. * * Nur die **Quotengrenze**: Ob ein Lauf bestanden ist, entscheidet * {@link urteilBilden}, und dort kann eine Zusatzbedingung ihn unabhängig von * jeder Quote zu Fall bringen. Dieser Wert dient dem Vergleich einzelner * Bereiche, nicht dem Urteil. * * Fehlerpunkte-Profile werden umgerechnet: 75 Fragen bei höchstens 15 Fehlern * sind 60 von 75, also 80 Prozent. Das ist keine Näherung, sondern dieselbe * Bedingung anders geschrieben. * * Der Ersatzwert 0,75 hält es mit {@link urteilBilden}: Ein Quotenprofil ohne * `bestehensQuote` ist ein Fehler im Vertrag, und beide Stellen müssen * denselben Ausweg nehmen, sonst begründet die Anwendung anders, als sie * rechnet. */ export function grenzquote(profil: Pruefungsprofil, gesamt: number): number | null { if (gesamt <= 0) { return null; } if (profil.wertung === 'fehlerpunkte') { return Math.max(0, (gesamt - (profil.maxFehler ?? 0)) / gesamt); } return profil.bestehensQuote ?? 0.75; } /** * Wie viele richtige Antworten eine Quote verlangt. * * Aufgerundet: Bei 75 Prozent von 200 Fragen sind 150 nötig, und 149 genügen * nicht – auch wenn 149/200 auf 75 Prozent gerundet gleich aussieht. */ function benoetigteTreffer(quote: number, gesamt: number): number { if (gesamt <= 0) { return 0; } const noetig = Math.ceil(quote * gesamt); /* Gleitkomma: 0,8 * 80 kann als 64,000000000000006 herauskommen und würde zu 65 aufgerundet. Ein Wert, der bis auf ein Millionstel eine ganze Zahl ist, wird deshalb als diese genommen. */ const genau = quote * gesamt; return Math.abs(genau - Math.round(genau)) < 1e-6 ? Math.round(genau) : noetig; } /** Fragetypen, die ein Profil enthalten soll. */ export function gewuenschteTypen(profil: Pruefungsprofil): readonly Fragetyp[] { return profil.anteilOffen && profil.anteilOffen > 0 ? (['mc', 'freitext', 'lueckentext'] as const) : (['mc'] as const); }