waffensachkunde

Waffensachkunde – Lernsoftware für die Sachkundeprüfung nach § 7 WaffG. Barrierefrei, offline, EUPL-1.2.

/ app src shared pruefung.ts

20,1 KB Rohdatei
app/src/shared/pruefung.ts — 557 Zeilen
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 }