waffensachkunde
Waffensachkunde – Lernsoftware für die Sachkundeprüfung nach § 7 WaffG. Barrierefrei, offline, EUPL-1.2.
| 1 | /** |
| 2 | * Prüfungssimulation. |
| 3 | * |
| 4 | * Die AWaffV schreibt weder Fragenzahl noch Zeit oder Bestehensgrenze vor – |
| 5 | * die reale Prüfung unterscheidet sich je nach Prüfungsstelle erheblich. |
| 6 | * Deshalb gibt es hier Profile statt eines festen Modus. Die Werte stammen aus |
| 7 | * veröffentlichten Prüfungsordnungen (siehe PLAN.md, Abschnitt 2.1). |
| 8 | */ |
| 9 | |
| 10 | import type { Fragetyp } from './katalog'; |
| 11 | |
| 12 | /** Wie das Bestehen ermittelt wird. */ |
| 13 | export type Wertungsart = |
| 14 | /** Anteil richtiger Antworten muss die Grenze erreichen. */ |
| 15 | | 'quote' |
| 16 | /** Fehlerpunkte dürfen eine Obergrenze nicht überschreiten. */ |
| 17 | | 'fehlerpunkte'; |
| 18 | |
| 19 | /** |
| 20 | * Zusatzbedingung, die unabhängig vom Gesamtergebnis zum Nichtbestehen führt. |
| 21 | * Manche Träger werten etwa mehr als zwei Fehler bei Notwehr und Notstand als |
| 22 | * nicht bestanden, auch wenn die Gesamtquote stimmt. |
| 23 | */ |
| 24 | export interface KoKriterium { |
| 25 | /** Abschnitts- oder Kapitel-ID, z. B. „I.5“. */ |
| 26 | readonly bereich: string; |
| 27 | readonly bezeichnung: string; |
| 28 | readonly maxFehler: number; |
| 29 | } |
| 30 | |
| 31 | export interface Pruefungsprofil { |
| 32 | readonly id: string; |
| 33 | readonly name: string; |
| 34 | /** Kurze Erläuterung, woher die Werte stammen. */ |
| 35 | readonly beschreibung: string; |
| 36 | readonly fragenAnzahl: number; |
| 37 | readonly wertung: Wertungsart; |
| 38 | /** Bei `quote`: nötiger Anteil richtiger Antworten (0 bis 1). */ |
| 39 | readonly bestehensQuote?: number; |
| 40 | /** Bei `fehlerpunkte`: höchstzulässige Fehlerzahl. */ |
| 41 | readonly maxFehler?: number; |
| 42 | /** Vorgeschlagenes Zeitlimit in Minuten; `null` bedeutet ohne Zeitlimit. */ |
| 43 | readonly zeitMinuten: number | null; |
| 44 | /** |
| 45 | * Untergrenze einer Grauzone: Wer darüber, aber unter der Bestehensgrenze |
| 46 | * liegt, wird bei manchen Trägern mündlich nachgeprüft. |
| 47 | */ |
| 48 | readonly nachpruefungAb?: number; |
| 49 | /** Anteil offener Fragen (0 bis 1); der Rest ist Multiple Choice. */ |
| 50 | readonly anteilOffen?: number; |
| 51 | /** Feste Fragenzahl je Bereich, wenn der Träger Themenquoten vorgibt. */ |
| 52 | readonly themenquoten?: Readonly<Record<string, number>>; |
| 53 | readonly koKriterien?: readonly KoKriterium[]; |
| 54 | /** Frei einstellbares Profil – die Werte dürfen verändert werden. */ |
| 55 | readonly anpassbar?: boolean; |
| 56 | } |
| 57 | |
| 58 | /** |
| 59 | * Vorgegebene Profile. |
| 60 | * |
| 61 | * Wichtig: Keines davon ist „die“ amtliche Prüfung. Die Software weist darauf |
| 62 | * hin, dass allein der zuständige Prüfungsausschuss entscheidet. |
| 63 | */ |
| 64 | export const PRUEFUNGSPROFILE: readonly Pruefungsprofil[] = Object.freeze([ |
| 65 | Object.freeze({ |
| 66 | id: 'standard', |
| 67 | name: 'Standard', |
| 68 | beschreibung: |
| 69 | '80 Fragen, 120 Minuten, 80 Prozent zum Bestehen. Verbreiteter Zuschnitt, ' + |
| 70 | 'wie ihn auch gängige Online-Trainer verwenden.', |
| 71 | fragenAnzahl: 80, |
| 72 | wertung: 'quote', |
| 73 | bestehensQuote: 0.8, |
| 74 | zeitMinuten: 120, |
| 75 | }), |
| 76 | Object.freeze({ |
| 77 | id: 'dsb', |
| 78 | name: 'Nach Art des Deutschen Schützenbundes', |
| 79 | beschreibung: |
| 80 | '100 Fragen in festen Themenblöcken, 120 Minuten, 75 Prozent zum Bestehen. ' + |
| 81 | 'Zwischen 60 und 74 Prozent ist eine mündliche Nachprüfung vorgesehen. ' + |
| 82 | 'Höchstens 80 Prozent Multiple Choice, der Rest ist auszuformulieren.', |
| 83 | fragenAnzahl: 100, |
| 84 | wertung: 'quote', |
| 85 | bestehensQuote: 0.75, |
| 86 | nachpruefungAb: 0.6, |
| 87 | zeitMinuten: 120, |
| 88 | anteilOffen: 0.2, |
| 89 | themenquoten: Object.freeze({ |
| 90 | 'I.1': 10, |
| 91 | 'I.2': 20, |
| 92 | 'I.3': 10, |
| 93 | 'I.4': 10, |
| 94 | 'I.5': 10, |
| 95 | II: 20, |
| 96 | III: 10, |
| 97 | IV: 10, |
| 98 | }), |
| 99 | }), |
| 100 | Object.freeze({ |
| 101 | id: 'bdmp', |
| 102 | name: 'Nach Art des BDMP', |
| 103 | beschreibung: |
| 104 | 'Fragen aus dem amtlichen Katalog, bis 120 Minuten, 70 Prozent zum Bestehen ' + |
| 105 | 'laut Prüfungsordnung des Verbands.', |
| 106 | fragenAnzahl: 80, |
| 107 | wertung: 'quote', |
| 108 | bestehensQuote: 0.7, |
| 109 | zeitMinuten: 120, |
| 110 | }), |
| 111 | Object.freeze({ |
| 112 | id: 'fehlerpunkte', |
| 113 | name: 'Nach Art privater Lehrgangsträger', |
| 114 | beschreibung: |
| 115 | '75 Fragen mit Fehlerpunktegrenze statt Quote. Mehr als zwei Fehler bei ' + |
| 116 | 'Notwehr und Notstand führen bei manchen Trägern unabhängig vom ' + |
| 117 | 'Gesamtergebnis zum Nichtbestehen.', |
| 118 | fragenAnzahl: 75, |
| 119 | wertung: 'fehlerpunkte', |
| 120 | maxFehler: 15, |
| 121 | zeitMinuten: 90, |
| 122 | anteilOffen: 0.15, |
| 123 | koKriterien: Object.freeze([ |
| 124 | Object.freeze({ bereich: 'I.5', bezeichnung: 'Notwehr und Notstand', maxFehler: 2 }), |
| 125 | ]), |
| 126 | }), |
| 127 | Object.freeze({ |
| 128 | id: 'frei', |
| 129 | name: 'Selbst einstellen', |
| 130 | beschreibung: 'Fragenzahl, Zeit, Bestehensgrenze und Anteil offener Fragen frei wählen.', |
| 131 | fragenAnzahl: 40, |
| 132 | wertung: 'quote', |
| 133 | bestehensQuote: 0.75, |
| 134 | zeitMinuten: null, |
| 135 | anpassbar: true, |
| 136 | }), |
| 137 | ]); |
| 138 | |
| 139 | /** Wie das Zeitlimit gehandhabt wird (WCAG 2.2.1 – Timing Adjustable). */ |
| 140 | export type Zeitmodus = |
| 141 | /** Ohne Zeitbegrenzung – immer verfügbar, auch in der Simulation. */ |
| 142 | | 'aus' |
| 143 | /** Vorgabe des Profils. */ |
| 144 | | 'normal' |
| 145 | /** Vorgabe plus 25 Prozent – üblicher Nachteilsausgleich. */ |
| 146 | | 'plus25' |
| 147 | /** Vorgabe plus 50 Prozent. */ |
| 148 | | 'plus50'; |
| 149 | |
| 150 | export const ZEITMODUS_BEZEICHNUNG: Readonly<Record<Zeitmodus, string>> = Object.freeze({ |
| 151 | aus: 'Ohne Zeitbegrenzung', |
| 152 | normal: 'Vorgesehene Zeit', |
| 153 | plus25: 'Vorgesehene Zeit plus 25 Prozent', |
| 154 | plus50: 'Vorgesehene Zeit plus 50 Prozent', |
| 155 | }); |
| 156 | |
| 157 | /** Errechnet die tatsächliche Bearbeitungszeit in Minuten. */ |
| 158 | export function zeitInMinuten(profil: Pruefungsprofil, modus: Zeitmodus): number | null { |
| 159 | if (modus === 'aus' || profil.zeitMinuten === null) { |
| 160 | return null; |
| 161 | } |
| 162 | const faktor = modus === 'plus25' ? 1.25 : modus === 'plus50' ? 1.5 : 1; |
| 163 | return Math.round(profil.zeitMinuten * faktor); |
| 164 | } |
| 165 | |
| 166 | /** Einstellungen eines konkreten Simulationslaufs. */ |
| 167 | export interface Pruefungsauftrag { |
| 168 | readonly profilId: string; |
| 169 | readonly zeitmodus: Zeitmodus; |
| 170 | /** Überschreibt die Profilwerte, nur bei anpassbaren Profilen. */ |
| 171 | readonly fragenAnzahl?: number; |
| 172 | readonly bestehensQuote?: number; |
| 173 | readonly zeitMinuten?: number | null; |
| 174 | /** |
| 175 | * Anteil offener Fragen (0 bis 1), nur bei anpassbaren Profilen. |
| 176 | * |
| 177 | * Ein **Wunsch**, keine Zusage: Der Katalog hat 104 offene Fragen von 575, |
| 178 | * und sie liegen ungleich – Kapitel III hat eine einzige von 49. Was sich |
| 179 | * nicht ziehen lässt, meldet der Anwendungskern als Warnung, statt es still |
| 180 | * durch Auswahlfragen zu ersetzen. |
| 181 | */ |
| 182 | readonly anteilOffen?: number; |
| 183 | /** |
| 184 | * Kapitel, aus denen in dieser Simulation keine Frage vorkommt. |
| 185 | * |
| 186 | * Vorbelegt aus dem Lernprofil (`profil.kapitel_ausschluss`), je Simulation |
| 187 | * aber änderbar. Die Vorbelegung ist keine Bequemlichkeit: Erststart-Frage |
| 188 | * und Schalter unter „Ihr Lernplan“ sagen zu, dass abgewählte Fragen „in |
| 189 | * keiner Sitzung und in keiner Zahl mehr“ vorkommen. Eine Simulation, die |
| 190 | * sie ungefragt wieder mitzieht, bricht diese Zusage. |
| 191 | */ |
| 192 | readonly kapitelAusschluss?: readonly string[]; |
| 193 | /** |
| 194 | * Antwortmöglichkeiten mischen. Vorgabe ist `false`. |
| 195 | * |
| 196 | * Gilt auch hier und nicht nur im Lernmodus: Wer die Antworten über ihre |
| 197 | * Stelle behalten muss, braucht das gerade in der Simulation – sonst |
| 198 | * prüft sie eine Beeinträchtigung mit, nicht den Stoff. |
| 199 | */ |
| 200 | readonly optionenMischen?: boolean; |
| 201 | } |
| 202 | |
| 203 | /** Eine Frage im Prüfungsbogen. */ |
| 204 | export interface Pruefungsfrage { |
| 205 | readonly frageId: string; |
| 206 | readonly optionsReihenfolge: readonly string[]; |
| 207 | } |
| 208 | |
| 209 | /** |
| 210 | * Ein fertig zusammengestellter Bogen samt der Abweichungen dabei. |
| 211 | * |
| 212 | * `warnungen` ist der Grund für diesen eigenen Typ. Beim Ziehen kann der |
| 213 | * Anwendungskern von den Vorgaben abweichen: ein Themenbereich hat zu wenige |
| 214 | * Fragen, der Bogen fällt kürzer aus als das Profil vorsieht, ein festes |
| 215 | * Profil nimmt eine Überschreibung nicht an, oder es fehlten offene Fragen und |
| 216 | * wurde mit Auswahlfragen aufgefüllt. Bis Fassung 0.20.0 gingen diese vier |
| 217 | * Meldungen ausschließlich über `console.warn` in das Protokoll des |
| 218 | * Hauptprozesses – wer simulierte, hielt seinen Bogen für profilgetreu. |
| 219 | * |
| 220 | * Sie gehören an den Bildschirm, und zwar in fertigen Sätzen ohne Feldnamen: |
| 221 | * Die Oberfläche gibt sie unverändert aus und formuliert nichts nach. |
| 222 | */ |
| 223 | export interface Pruefungsbogen { |
| 224 | readonly fragen: readonly Pruefungsfrage[]; |
| 225 | /** Leer, wenn der Bogen genau den Vorgaben entspricht. */ |
| 226 | readonly warnungen: readonly string[]; |
| 227 | } |
| 228 | |
| 229 | /** |
| 230 | * Ablaufphase eines Laufs, soweit sie sich sichern lässt. |
| 231 | * |
| 232 | * „abgabefrage" wird auf „bearbeiten" abgebildet – eine offene Rückfrage |
| 233 | * gehört nicht wiederhergestellt. „auswerten" wird nie gesichert: Ab dort |
| 234 | * gehört der Lauf der Auswertung. |
| 235 | */ |
| 236 | export type GesichertePhase = 'bearbeiten' | 'nachbewertung'; |
| 237 | |
| 238 | /** Eingaben zu einer Frage, wie sie ein unterbrochener Lauf festhält. */ |
| 239 | export interface GesicherteEingabe { |
| 240 | readonly auswahl: readonly string[]; |
| 241 | readonly freitext: string; |
| 242 | /** |
| 243 | * Selbstbewertung einer offenen Frage. |
| 244 | * |
| 245 | * Fehlt, solange nicht bewertet wurde – dreiwertig mit Absicht: „noch |
| 246 | * nicht bewertet" ist etwas anderes als „als falsch bewertet". Ein |
| 247 | * Ersatzwert `false` schriebe eine unbewertete Frage beim Fortsetzen als |
| 248 | * falsch fest. |
| 249 | */ |
| 250 | readonly selbst?: boolean; |
| 251 | } |
| 252 | |
| 253 | /** Was der Renderer an einem laufenden Bogen sichert – und nur das. */ |
| 254 | export interface Zwischenstand { |
| 255 | readonly eingaben: Readonly<Record<string, GesicherteEingabe>>; |
| 256 | readonly position: number; |
| 257 | readonly phase: GesichertePhase; |
| 258 | readonly zeitAbgelaufen: boolean; |
| 259 | /** |
| 260 | * Verbrauchte Bearbeitungszeit in Millisekunden. |
| 261 | * |
| 262 | * Stets `Date.now() - startMs`, nie ein nebenher hochgezählter Sammelwert: |
| 263 | * Restzeit und Bearbeitungsdauer müssen aus derselben Rechnung stammen, |
| 264 | * sonst laufen Anzeige und Wertung auseinander. |
| 265 | */ |
| 266 | readonly verbrauchtMs: number; |
| 267 | } |
| 268 | |
| 269 | /** |
| 270 | * Ein unterbrochener Lauf, wie ihn die Oberfläche zum Fortsetzen bekommt. |
| 271 | * |
| 272 | * `auftrag`, `profil` und `bogen` stammen aus dem Anwendungskern, nicht aus |
| 273 | * dem Renderer – sie wurden beim Starten dort abgelegt. |
| 274 | */ |
| 275 | export interface OffenerLauf { |
| 276 | readonly auftrag: Pruefungsauftrag; |
| 277 | /** Das wirksame Profil des Laufs, mit den tatsächlich gewählten Werten. */ |
| 278 | readonly profil: Pruefungsprofil; |
| 279 | readonly bogen: readonly Pruefungsfrage[]; |
| 280 | readonly eingaben: Readonly<Record<string, GesicherteEingabe>>; |
| 281 | readonly position: number; |
| 282 | readonly phase: GesichertePhase; |
| 283 | readonly zeitAbgelaufen: boolean; |
| 284 | readonly verbrauchtMs: number; |
| 285 | readonly begonnenAm: string; |
| 286 | readonly gesichertAm: string; |
| 287 | } |
| 288 | |
| 289 | /** Antwort auf eine Prüfungsfrage. */ |
| 290 | export interface Pruefungsantwort { |
| 291 | readonly frageId: string; |
| 292 | readonly auswahl: readonly string[]; |
| 293 | readonly freitext?: string; |
| 294 | /** Bei offenen Fragen bewertet der Prüfling selbst. */ |
| 295 | readonly selbstAlsRichtig?: boolean; |
| 296 | } |
| 297 | |
| 298 | /** Ergebnis je Bereich, für die Themenanalyse der Auswertung. */ |
| 299 | export interface BereichErgebnis { |
| 300 | readonly bereich: string; |
| 301 | readonly titel: string; |
| 302 | readonly gesamt: number; |
| 303 | readonly richtig: number; |
| 304 | } |
| 305 | |
| 306 | export type Bestehensurteil = 'bestanden' | 'nachpruefung' | 'nicht_bestanden'; |
| 307 | |
| 308 | export interface Pruefungsergebnis { |
| 309 | readonly profilId: string; |
| 310 | readonly gesamt: number; |
| 311 | readonly richtig: number; |
| 312 | /** |
| 313 | * Beantwortet, aber nicht richtig. |
| 314 | * |
| 315 | * Die drei Zahlen `richtig`, `falsch` und `unbeantwortet` bilden eine |
| 316 | * Zerlegung von `gesamt` – sie summieren sich also darauf und überschneiden |
| 317 | * sich nicht. Die Auswertung zeigt sie nebeneinander; würden die |
| 318 | * unbeantworteten zusätzlich in `falsch` stecken, ergäbe die Anzeige mehr |
| 319 | * Fragen als der Bogen hat. |
| 320 | */ |
| 321 | readonly falsch: number; |
| 322 | readonly unbeantwortet: number; |
| 323 | /** Anteil richtiger Antworten, 0 bis 1. */ |
| 324 | readonly quote: number; |
| 325 | readonly urteil: Bestehensurteil; |
| 326 | /** Begründung in einem Satz, für die Anzeige. */ |
| 327 | readonly begruendung: string; |
| 328 | /** Ausgelöste K.-o.-Kriterien, falls vorhanden. */ |
| 329 | readonly verletzteKriterien: readonly string[]; |
| 330 | readonly bereiche: readonly BereichErgebnis[]; |
| 331 | /** |
| 332 | * IDs aller nicht richtig beantworteten Fragen – für „Fehler wiederholen“. |
| 333 | * Anders als {@link falsch} zählen hier die unbeantworteten mit: Wer sie |
| 334 | * nie gesehen hat, soll sie üben können. |
| 335 | */ |
| 336 | readonly fehlerIds: readonly string[]; |
| 337 | readonly dauerMs: number; |
| 338 | readonly zeitAbgelaufen: boolean; |
| 339 | /** Zeitpunkt als ISO-Zeichenkette. */ |
| 340 | readonly zeitpunkt: string; |
| 341 | /** |
| 342 | * Kennung der gespeicherten Verlaufszeile. |
| 343 | * |
| 344 | * Fehlt, wenn der Lauf **nicht** gespeichert wurde – das ist der Fall der |
| 345 | * Ersatzauswertung im Renderer, wenn der Kanal ausfällt |
| 346 | * (`renderer/src/pruefung/bewertung.ts`). Eine erfundene Kennung wäre dort |
| 347 | * schlimmer als keine: Der Vergleich mit früheren Läufen schließt den |
| 348 | * aktuellen über genau diese Kennung aus und würde sonst den falschen |
| 349 | * ausschließen. |
| 350 | */ |
| 351 | readonly laufId?: number; |
| 352 | } |
| 353 | |
| 354 | /** Ein gespeicherter Simulationslauf für die Verlaufsanzeige. */ |
| 355 | export interface Pruefungsverlauf { |
| 356 | readonly id: number; |
| 357 | readonly profilId: string; |
| 358 | readonly profilName: string; |
| 359 | readonly zeitpunkt: string; |
| 360 | readonly gesamt: number; |
| 361 | readonly richtig: number; |
| 362 | readonly quote: number; |
| 363 | readonly urteil: Bestehensurteil; |
| 364 | /** Gemessene Bearbeitungsdauer in Millisekunden. */ |
| 365 | readonly dauerMs: number; |
| 366 | /** |
| 367 | * Gewählte Zeitstufe des Laufs. |
| 368 | * |
| 369 | * `null` bei Läufen aus der Zeit vor Schema-Version 4: Sie ist dort nicht |
| 370 | * mehr zu ermitteln, und sie zu raten wäre schlechter, als sie offen zu |
| 371 | * lassen. Ohne diese Angabe stünden ein Lauf ohne Uhr und einer unter |
| 372 | * Zeitdruck in der Tabelle nebeneinander, als wären sie vergleichbar. |
| 373 | */ |
| 374 | readonly zeitmodus: Zeitmodus | null; |
| 375 | /** |
| 376 | * Wie viele Fragen des Bogens unbeantwortet blieben. |
| 377 | * |
| 378 | * `null` bei Läufen vor Schema-Version 10. Der Wert ist der Grund, warum es |
| 379 | * diese Fassung gibt: `quote` zählt Unbeantwortete wie falsch beantwortete, |
| 380 | * und ein Lauf, in dem die Zeit ablief, sähe im Vergleich sonst wie ein |
| 381 | * Wissenseinbruch aus. |
| 382 | */ |
| 383 | readonly unbeantwortet: number | null; |
| 384 | /** Ob die Bearbeitungszeit ablief. `null` bei Läufen vor Fassung 10. */ |
| 385 | readonly zeitAbgelaufen: boolean | null; |
| 386 | /** |
| 387 | * Ergebnis je Bereich, so wie es die Auswertung des Laufs zeigte. |
| 388 | * |
| 389 | * `null` bei Läufen vor Fassung 10: Es wurde damals nicht gespeichert und |
| 390 | * lässt sich aus `antwort_log` nicht zurückrechnen – dort steht nicht, |
| 391 | * welcher Lauf welche Zeile schrieb. |
| 392 | */ |
| 393 | readonly bereiche: readonly BereichErgebnis[] | null; |
| 394 | /** |
| 395 | * Die Bestehensgrenze dieses Laufs als Anteil richtiger Antworten. |
| 396 | * |
| 397 | * `null` bei Läufen vor Fassung 10. Siehe {@link grenzquote} – der Wert wird |
| 398 | * beim Speichern aus dem *wirksamen* Profil gebildet, weil er sich später |
| 399 | * nicht mehr ermitteln lässt: Beim frei eingestellten Profil sind die |
| 400 | * gewählten Werte nach dem Lauf fort. |
| 401 | */ |
| 402 | readonly bestehensQuote: number | null; |
| 403 | } |
| 404 | |
| 405 | /** |
| 406 | * Bereiche, in denen mindestens ein Profil ein K.-o.-Kriterium führt. |
| 407 | * |
| 408 | * Abgeleitet statt aufgezählt: Wer ein Kriterium ergänzt, ergänzt es an einer |
| 409 | * Stelle. Die Prüfungsreife-Ampel deckelt daran ihr Gesamturteil – ein |
| 410 | * zweiter, von Hand gepflegter Auszug wäre die nächste Zahl, die still |
| 411 | * auseinanderläuft. |
| 412 | */ |
| 413 | export const KO_BEREICHE: readonly string[] = Object.freeze([ |
| 414 | ...new Set( |
| 415 | PRUEFUNGSPROFILE.flatMap((profil) => (profil.koKriterien ?? []).map((k) => k.bereich)), |
| 416 | ), |
| 417 | ]); |
| 418 | |
| 419 | /** |
| 420 | * Liegt diese Frage in einem Bereich mit K.-o.-Kriterium? |
| 421 | * |
| 422 | * Geprüft werden Kapitel **und** Abschnitt, weil ein Kriterium beides |
| 423 | * bezeichnen kann – „I.5“ ist heute ein Abschnitt. Dieselbe Regel wie in |
| 424 | * `gehoertZuBereich` beim Zusammenstellen des Prüfungsbogens; sie steht hier |
| 425 | * ein zweites Mal, weil der Anwendungskern nichts aus dem Renderer holen |
| 426 | * darf und ein Umzug dieser vier Zeilen mehr Wege anfasste als er wert wäre. |
| 427 | */ |
| 428 | export function istKoFrage(kapitel: string, abschnitt: string | null): boolean { |
| 429 | return KO_BEREICHE.some((bereich) => kapitel === bereich || abschnitt === bereich); |
| 430 | } |
| 431 | |
| 432 | export function profilFinden(id: string): Pruefungsprofil | undefined { |
| 433 | return PRUEFUNGSPROFILE.find((p) => p.id === id); |
| 434 | } |
| 435 | |
| 436 | /** Menschlich lesbares Urteil. */ |
| 437 | export const URTEIL_BEZEICHNUNG: Readonly<Record<Bestehensurteil, string>> = Object.freeze({ |
| 438 | bestanden: 'Bestanden', |
| 439 | nachpruefung: 'Mündliche Nachprüfung', |
| 440 | nicht_bestanden: 'Nicht bestanden', |
| 441 | }); |
| 442 | |
| 443 | /** |
| 444 | * Ermittelt, ob mit diesem Profil bestanden wurde. |
| 445 | * |
| 446 | * Reine Rechenfunktion ohne Seiteneffekte, damit sie in Anwendungskern und |
| 447 | * Oberfläche dasselbe Ergebnis liefert und gut prüfbar bleibt. |
| 448 | */ |
| 449 | export function urteilBilden( |
| 450 | profil: Pruefungsprofil, |
| 451 | richtig: number, |
| 452 | gesamt: number, |
| 453 | verletzteKriterien: readonly string[], |
| 454 | ): { urteil: Bestehensurteil; begruendung: string } { |
| 455 | if (verletzteKriterien.length > 0) { |
| 456 | return { |
| 457 | urteil: 'nicht_bestanden', |
| 458 | begruendung: `Nicht bestanden wegen: ${verletzteKriterien.join(', ')}.`, |
| 459 | }; |
| 460 | } |
| 461 | |
| 462 | if (profil.wertung === 'fehlerpunkte') { |
| 463 | const fehler = gesamt - richtig; |
| 464 | const grenze = profil.maxFehler ?? 0; |
| 465 | return fehler <= grenze |
| 466 | ? { |
| 467 | urteil: 'bestanden', |
| 468 | begruendung: `${String(fehler)} von höchstens ${String(grenze)} zulässigen Fehlern.`, |
| 469 | } |
| 470 | : { |
| 471 | urteil: 'nicht_bestanden', |
| 472 | begruendung: `${String(fehler)} Fehler bei höchstens ${String(grenze)} zulässigen.`, |
| 473 | }; |
| 474 | } |
| 475 | |
| 476 | const quote = gesamt > 0 ? richtig / gesamt : 0; |
| 477 | const grenze = profil.bestehensQuote ?? 0.75; |
| 478 | |
| 479 | /* Die Begründung wird aus Trefferzahlen gebaut, nicht aus gerundeten |
| 480 | Prozentwerten. Sonst entstünde ein Satz, der sich selbst widerspricht: |
| 481 | 149 von 200 sind 74,5 Prozent – kaufmännisch gerundet 75 –, und die |
| 482 | Anzeige lautete „Nicht bestanden. 75 Prozent richtig, nötig waren 75 |
| 483 | Prozent." Mit Zahlen ist das eindeutig und rundungsfrei. */ |
| 484 | const noetig = benoetigteTreffer(grenze, gesamt); |
| 485 | |
| 486 | if (quote >= grenze) { |
| 487 | return { |
| 488 | urteil: 'bestanden', |
| 489 | begruendung: `${String(richtig)} von ${String(gesamt)} richtig, nötig waren ${String(noetig)}.`, |
| 490 | }; |
| 491 | } |
| 492 | if (profil.nachpruefungAb !== undefined && quote >= profil.nachpruefungAb) { |
| 493 | return { |
| 494 | urteil: 'nachpruefung', |
| 495 | begruendung: |
| 496 | `${String(richtig)} von ${String(gesamt)} richtig. ` + |
| 497 | `Ab ${String(benoetigteTreffer(profil.nachpruefungAb, gesamt))} richtigen Antworten sieht ` + |
| 498 | `dieses Profil eine mündliche Nachprüfung vor, bestanden wäre ab ${String(noetig)}.`, |
| 499 | }; |
| 500 | } |
| 501 | return { |
| 502 | urteil: 'nicht_bestanden', |
| 503 | begruendung: `${String(richtig)} von ${String(gesamt)} richtig, nötig waren ${String(noetig)}.`, |
| 504 | }; |
| 505 | } |
| 506 | |
| 507 | /** |
| 508 | * Die Bestehensgrenze eines Laufs als Anteil richtiger Antworten. |
| 509 | * |
| 510 | * Nur die **Quotengrenze**: Ob ein Lauf bestanden ist, entscheidet |
| 511 | * {@link urteilBilden}, und dort kann eine Zusatzbedingung ihn unabhängig von |
| 512 | * jeder Quote zu Fall bringen. Dieser Wert dient dem Vergleich einzelner |
| 513 | * Bereiche, nicht dem Urteil. |
| 514 | * |
| 515 | * Fehlerpunkte-Profile werden umgerechnet: 75 Fragen bei höchstens 15 Fehlern |
| 516 | * sind 60 von 75, also 80 Prozent. Das ist keine Näherung, sondern dieselbe |
| 517 | * Bedingung anders geschrieben. |
| 518 | * |
| 519 | * Der Ersatzwert 0,75 hält es mit {@link urteilBilden}: Ein Quotenprofil ohne |
| 520 | * `bestehensQuote` ist ein Fehler im Vertrag, und beide Stellen müssen |
| 521 | * denselben Ausweg nehmen, sonst begründet die Anwendung anders, als sie |
| 522 | * rechnet. |
| 523 | */ |
| 524 | export function grenzquote(profil: Pruefungsprofil, gesamt: number): number | null { |
| 525 | if (gesamt <= 0) { |
| 526 | return null; |
| 527 | } |
| 528 | if (profil.wertung === 'fehlerpunkte') { |
| 529 | return Math.max(0, (gesamt - (profil.maxFehler ?? 0)) / gesamt); |
| 530 | } |
| 531 | return profil.bestehensQuote ?? 0.75; |
| 532 | } |
| 533 | |
| 534 | /** |
| 535 | * Wie viele richtige Antworten eine Quote verlangt. |
| 536 | * |
| 537 | * Aufgerundet: Bei 75 Prozent von 200 Fragen sind 150 nötig, und 149 genügen |
| 538 | * nicht – auch wenn 149/200 auf 75 Prozent gerundet gleich aussieht. |
| 539 | */ |
| 540 | function benoetigteTreffer(quote: number, gesamt: number): number { |
| 541 | if (gesamt <= 0) { |
| 542 | return 0; |
| 543 | } |
| 544 | const noetig = Math.ceil(quote * gesamt); |
| 545 | /* Gleitkomma: 0,8 * 80 kann als 64,000000000000006 herauskommen und würde |
| 546 | zu 65 aufgerundet. Ein Wert, der bis auf ein Millionstel eine ganze Zahl |
| 547 | ist, wird deshalb als diese genommen. */ |
| 548 | const genau = quote * gesamt; |
| 549 | return Math.abs(genau - Math.round(genau)) < 1e-6 ? Math.round(genau) : noetig; |
| 550 | } |
| 551 | |
| 552 | /** Fragetypen, die ein Profil enthalten soll. */ |
| 553 | export function gewuenschteTypen(profil: Pruefungsprofil): readonly Fragetyp[] { |
| 554 | return profil.anteilOffen && profil.anteilOffen > 0 |
| 555 | ? (['mc', 'freitext', 'lueckentext'] as const) |
| 556 | : (['mc'] as const); |
| 557 | } |