waffensachkunde
Waffensachkunde – Lernsoftware für die Sachkundeprüfung nach § 7 WaffG. Barrierefrei, offline, EUPL-1.2.
| 1 | // @vitest-environment node |
| 2 | // Die Prüfungssimulation läuft im Main-Prozess gegen echtes SQLite. Getestet |
| 3 | // wird gegen eine `:memory:`-Datenbank – dieselbe Bibliothek, dasselbe Schema, |
| 4 | // nur ohne Datei auf der Platte. Es wird nichts nachgebildet: gezogen wird aus |
| 5 | // einem echten Katalog, ausgewertet mit den echten Regeln des Vertrags. |
| 6 | |
| 7 | import Database from 'better-sqlite3'; |
| 8 | import { afterEach, beforeEach, describe, expect, it } from 'vitest'; |
| 9 | |
| 10 | import { Lernstand } from '../src/main/lernstand'; |
| 11 | import { Pruefung } from '../src/main/pruefung'; |
| 12 | import { SCHEMA_VERSION } from '../src/main/schema'; |
| 13 | import { |
| 14 | richtigeLabels, |
| 15 | type Antwortoption, |
| 16 | type Frage, |
| 17 | type Katalog, |
| 18 | } from '../src/shared/katalog'; |
| 19 | import { |
| 20 | profilFinden, |
| 21 | urteilBilden, |
| 22 | type Pruefungsantwort, |
| 23 | type Pruefungsauftrag, |
| 24 | type Pruefungsfrage, |
| 25 | } from '../src/shared/pruefung'; |
| 26 | |
| 27 | // ─── Prüfkatalog ──────────────────────────────────────────────────────────── |
| 28 | // Aufbau wie im amtlichen Katalog: Kapitel I mit fünf Abschnitten, die |
| 29 | // Kapitel II bis IV ohne Abschnitte. Groß genug für die Themenquoten des |
| 30 | // DSB-Profils (bis zu 20 Fragen je Bereich, davon ein Fünftel offen). |
| 31 | |
| 32 | /** Bereiche als Paar aus Kapitel und Abschnitt; `null` heißt „ohne Abschnitt“. */ |
| 33 | const BEREICHE: readonly (readonly [string, string | null])[] = [ |
| 34 | ['I', 'I.1'], |
| 35 | ['I', 'I.2'], |
| 36 | ['I', 'I.3'], |
| 37 | ['I', 'I.4'], |
| 38 | ['I', 'I.5'], |
| 39 | ['II', null], |
| 40 | ['III', null], |
| 41 | ['IV', null], |
| 42 | ]; |
| 43 | |
| 44 | const MC_JE_BEREICH = 30; |
| 45 | const OFFEN_JE_BEREICH = 8; |
| 46 | |
| 47 | function option(label: string, korrekt: boolean): Antwortoption { |
| 48 | return { |
| 49 | label, |
| 50 | inhalt: { text: `Antwort ${label}`, segmente: [{ t: `Antwort ${label}` }] }, |
| 51 | korrekt, |
| 52 | bilder: [], |
| 53 | }; |
| 54 | } |
| 55 | |
| 56 | function mcFrage(id: string, kapitel: string, abschnitt: string | null, nummer: number): Frage { |
| 57 | // Jede dritte Frage hat zwei richtige Antworten – im amtlichen Katalog sind |
| 58 | // es 114 von 471, die Mehrfachauswahl darf also kein Sonderfall sein. |
| 59 | const korrekt = nummer % 3 === 0 ? ['a', 'c'] : ['b']; |
| 60 | return { |
| 61 | id, |
| 62 | amtliche_nummer: id, |
| 63 | kapitel, |
| 64 | abschnitt, |
| 65 | typ: 'mc', |
| 66 | seite: 1, |
| 67 | frage: { text: `Frage ${id}`, segmente: [{ t: `Frage ${id}` }] }, |
| 68 | bilder: [], |
| 69 | optionen: ['a', 'b', 'c', 'd'].map((label) => option(label, korrekt.includes(label))), |
| 70 | }; |
| 71 | } |
| 72 | |
| 73 | function offeneFrage(id: string, kapitel: string, abschnitt: string | null): Frage { |
| 74 | return { |
| 75 | id, |
| 76 | amtliche_nummer: id, |
| 77 | kapitel, |
| 78 | abschnitt, |
| 79 | typ: 'freitext', |
| 80 | seite: 1, |
| 81 | frage: { text: `Frage ${id}`, segmente: [{ t: `Frage ${id}` }] }, |
| 82 | bilder: [], |
| 83 | musterantwort: { text: 'Musterantwort', segmente: [{ t: 'Musterantwort', h: true }] }, |
| 84 | }; |
| 85 | } |
| 86 | |
| 87 | function katalogBauen(mcJeBereich: number, offenJeBereich: number): Katalog { |
| 88 | const fragen: Frage[] = []; |
| 89 | for (const [kapitel, abschnitt] of BEREICHE) { |
| 90 | const bereich = abschnitt ?? kapitel; |
| 91 | for (let i = 1; i <= mcJeBereich; i += 1) { |
| 92 | fragen.push(mcFrage(`${bereich}-mc-${String(i).padStart(2, '0')}`, kapitel, abschnitt, i)); |
| 93 | } |
| 94 | for (let i = 1; i <= offenJeBereich; i += 1) { |
| 95 | fragen.push( |
| 96 | offeneFrage(`${bereich}-offen-${String(i).padStart(2, '0')}`, kapitel, abschnitt), |
| 97 | ); |
| 98 | } |
| 99 | } |
| 100 | |
| 101 | return { |
| 102 | meta: { |
| 103 | titel: 'Prüfkatalog', |
| 104 | herausgeber: 'Bundesverwaltungsamt', |
| 105 | stand: '2024-12-16', |
| 106 | quellenangabe: 'Amtlicher Fragenkatalog, Stand 16.12.2024.', |
| 107 | quelle_url: 'https://www.bva.bund.de/', |
| 108 | quelldatei_sha256: 'a'.repeat(64), |
| 109 | fragen_gesamt: fragen.length, |
| 110 | }, |
| 111 | kapitel: [ |
| 112 | { |
| 113 | id: 'I', |
| 114 | titel: 'Waffenrecht und sonstige Rechtsvorschriften', |
| 115 | abschnitte: [ |
| 116 | { id: 'I.1', titel: 'Begriffe des Waffenrechts' }, |
| 117 | { id: 'I.2', titel: 'Rechte und Pflichten' }, |
| 118 | { id: 'I.3', titel: 'Aufbewahrung' }, |
| 119 | { id: 'I.4', titel: 'Führen und Transport' }, |
| 120 | { id: 'I.5', titel: 'Notwehr und Notstand' }, |
| 121 | ], |
| 122 | }, |
| 123 | { id: 'II', titel: 'Waffentechnik', abschnitte: [] }, |
| 124 | { id: 'III', titel: 'Handhabung von Schusswaffen', abschnitte: [] }, |
| 125 | { id: 'IV', titel: 'Not- und Seenotsignalmittel', abschnitte: [] }, |
| 126 | ], |
| 127 | bilder: [], |
| 128 | fragen, |
| 129 | }; |
| 130 | } |
| 131 | |
| 132 | const KATALOG = katalogBauen(MC_JE_BEREICH, OFFEN_JE_BEREICH); |
| 133 | |
| 134 | /** Bereich einer Frage, so wie die Auswertung ihn bildet. */ |
| 135 | function bereichVon(frage: Frage): string { |
| 136 | return frage.abschnitt ?? frage.kapitel; |
| 137 | } |
| 138 | |
| 139 | function frageZu(katalog: Katalog, id: string): Frage { |
| 140 | const treffer = katalog.fragen.find((f) => f.id === id); |
| 141 | if (treffer === undefined) { |
| 142 | throw new Error(`Prüfkatalog kennt die Frage „${id}“ nicht.`); |
| 143 | } |
| 144 | return treffer; |
| 145 | } |
| 146 | |
| 147 | // ─── Prüfstand ────────────────────────────────────────────────────────────── |
| 148 | |
| 149 | const START = '2026-03-01T10:00:00.000Z'; |
| 150 | |
| 151 | /** |
| 152 | * Reproduzierbarer Zufall (mulberry32). Die Zusammenstellung soll zufällig |
| 153 | * sein, ein Test aber trotzdem immer dasselbe Ergebnis liefern. |
| 154 | */ |
| 155 | function zufallsfolge(saat: number): () => number { |
| 156 | let zustand = saat >>> 0; |
| 157 | return (): number => { |
| 158 | zustand = (zustand + 0x6d2b79f5) >>> 0; |
| 159 | let t = zustand; |
| 160 | t = Math.imul(t ^ (t >>> 15), t | 1); |
| 161 | t ^= t + Math.imul(t ^ (t >>> 7), t | 61); |
| 162 | return ((t ^ (t >>> 14)) >>> 0) / 4294967296; |
| 163 | }; |
| 164 | } |
| 165 | |
| 166 | let uhr: Date; |
| 167 | let db: Database.Database; |
| 168 | let lernstand: Lernstand; |
| 169 | let pruefung: Pruefung; |
| 170 | let profilId: number; |
| 171 | let warnungen: string[]; |
| 172 | let verbindungen: Database.Database[] = []; |
| 173 | |
| 174 | function starten(katalog: Katalog = KATALOG, saat = 7): void { |
| 175 | uhr = new Date(START); |
| 176 | db = new Database(':memory:'); |
| 177 | verbindungen.push(db); |
| 178 | warnungen = []; |
| 179 | lernstand = new Lernstand(db, katalog, { jetzt: () => uhr }); |
| 180 | profilId = lernstand.profile()[0]!.id; |
| 181 | pruefung = new Pruefung(lernstand, katalog, { |
| 182 | jetzt: () => uhr, |
| 183 | zufall: zufallsfolge(saat), |
| 184 | warnen: (meldung) => warnungen.push(meldung), |
| 185 | }); |
| 186 | } |
| 187 | |
| 188 | function auftrag(teil: Partial<Pruefungsauftrag> & { profilId: string }): Pruefungsauftrag { |
| 189 | return { zeitmodus: 'aus', ...teil }; |
| 190 | } |
| 191 | |
| 192 | /** |
| 193 | * Zieht einen Bogen und gibt nur seinen Fragensatz zurück. |
| 194 | * |
| 195 | * `starten` liefert seit dem Warnungsfeld einen {@link Pruefungsbogen}; die |
| 196 | * Warnungen prüfen die Fälle, die sie betreffen, ausdrücklich über |
| 197 | * `pruefung.starten(...).warnungen`. |
| 198 | */ |
| 199 | function bogenZiehen(profil: number, auftragWert: Pruefungsauftrag): readonly Pruefungsfrage[] { |
| 200 | return pruefung.starten(profil, auftragWert).fragen; |
| 201 | } |
| 202 | |
| 203 | /** Antwortoption, die bei dieser Frage falsch ist. */ |
| 204 | function falscheAuswahl(frage: Frage): string[] { |
| 205 | const soll = new Set(richtigeLabels(frage)); |
| 206 | const daneben = (frage.optionen ?? []).map((o) => o.label).find((label) => !soll.has(label)); |
| 207 | return daneben === undefined ? [] : [daneben]; |
| 208 | } |
| 209 | |
| 210 | function antwort(frage: Frage, richtig: boolean): Pruefungsantwort { |
| 211 | if (frage.typ === 'mc') { |
| 212 | return { frageId: frage.id, auswahl: richtig ? richtigeLabels(frage) : falscheAuswahl(frage) }; |
| 213 | } |
| 214 | return { |
| 215 | frageId: frage.id, |
| 216 | auswahl: [], |
| 217 | freitext: 'Sinngemäß richtig formuliert.', |
| 218 | selbstAlsRichtig: richtig, |
| 219 | }; |
| 220 | } |
| 221 | |
| 222 | /** Multiple-Choice-Fragen eines Bereichs in Katalogreihenfolge. */ |
| 223 | function mcAus(bereich: string, anzahl: number, ab = 0): Frage[] { |
| 224 | return KATALOG.fragen |
| 225 | .filter((f) => f.typ === 'mc' && bereichVon(f) === bereich) |
| 226 | .slice(ab, ab + anzahl); |
| 227 | } |
| 228 | |
| 229 | /** Bogen mit genau dieser Zahl richtiger und falscher Antworten (alle aus I.1). */ |
| 230 | function bogen(richtig: number, falsch: number): Pruefungsantwort[] { |
| 231 | return mcAus('I.1', richtig + falsch).map((frage, i) => antwort(frage, i < richtig)); |
| 232 | } |
| 233 | |
| 234 | function auswerten( |
| 235 | profilKennung: string, |
| 236 | antworten: Pruefungsantwort[], |
| 237 | dauerMs = 60_000, |
| 238 | ): ReturnType<Pruefung['auswerten']> { |
| 239 | return pruefung.auswerten( |
| 240 | profilId, |
| 241 | auftrag({ profilId: profilKennung }), |
| 242 | antworten, |
| 243 | dauerMs, |
| 244 | false, |
| 245 | ); |
| 246 | } |
| 247 | |
| 248 | beforeEach(() => { |
| 249 | verbindungen = []; |
| 250 | starten(); |
| 251 | }); |
| 252 | |
| 253 | afterEach(() => { |
| 254 | for (const verbindung of verbindungen) { |
| 255 | if (verbindung.open) { |
| 256 | verbindung.close(); |
| 257 | } |
| 258 | } |
| 259 | }); |
| 260 | |
| 261 | // ─── Bogen zusammenstellen ────────────────────────────────────────────────── |
| 262 | |
| 263 | describe('pruefung:starten', () => { |
| 264 | it('zieht so viele Fragen, wie das Profil vorsieht', () => { |
| 265 | expect(bogenZiehen(profilId, auftrag({ profilId: 'standard' }))).toHaveLength(80); |
| 266 | expect(bogenZiehen(profilId, auftrag({ profilId: 'dsb' }))).toHaveLength(100); |
| 267 | expect(bogenZiehen(profilId, auftrag({ profilId: 'fehlerpunkte' }))).toHaveLength(75); |
| 268 | expect(bogenZiehen(profilId, auftrag({ profilId: 'frei' }))).toHaveLength(40); |
| 269 | }); |
| 270 | |
| 271 | it('nimmt keine Frage zweimal in denselben Bogen', () => { |
| 272 | for (const kennung of ['standard', 'dsb', 'bdmp', 'fehlerpunkte']) { |
| 273 | const bogenFragen = bogenZiehen(profilId, auftrag({ profilId: kennung })); |
| 274 | const ids = new Set(bogenFragen.map((f) => f.frageId)); |
| 275 | |
| 276 | expect(ids.size).toBe(bogenFragen.length); |
| 277 | } |
| 278 | }); |
| 279 | |
| 280 | it('mischt die Antwortoptionen auf Wunsch, ohne eine wegzulassen', () => { |
| 281 | const bogenFragen = bogenZiehen( |
| 282 | profilId, |
| 283 | auftrag({ profilId: 'standard', optionenMischen: true }), |
| 284 | ); |
| 285 | |
| 286 | let mindestensEinmalGemischt = false; |
| 287 | for (const eintrag of bogenFragen) { |
| 288 | const frage = frageZu(KATALOG, eintrag.frageId); |
| 289 | const labels = (frage.optionen ?? []).map((o) => o.label); |
| 290 | |
| 291 | expect([...eintrag.optionsReihenfolge].sort()).toEqual([...labels].sort()); |
| 292 | if (eintrag.optionsReihenfolge.join('') !== labels.join('')) { |
| 293 | mindestensEinmalGemischt = true; |
| 294 | } |
| 295 | } |
| 296 | |
| 297 | expect(mindestensEinmalGemischt).toBe(true); |
| 298 | }); |
| 299 | |
| 300 | it('lässt die Antwortoptionen auf Wunsch in Katalogreihenfolge', () => { |
| 301 | /* Die Einstellung gilt auch in der Simulation: Wer die Antworten über |
| 302 | ihre Stelle behalten muss, bräuchte sie sonst ausgerechnet hier |
| 303 | nicht. */ |
| 304 | const bogenFragen = bogenZiehen( |
| 305 | profilId, |
| 306 | auftrag({ profilId: 'standard', optionenMischen: false }), |
| 307 | ); |
| 308 | |
| 309 | for (const eintrag of bogenFragen) { |
| 310 | const frage = frageZu(KATALOG, eintrag.frageId); |
| 311 | |
| 312 | expect(eintrag.optionsReihenfolge).toEqual((frage.optionen ?? []).map((o) => o.label)); |
| 313 | } |
| 314 | }); |
| 315 | |
| 316 | it('zieht ohne ausdrückliches optionenMischen einen Bogen in Katalogreihenfolge', () => { |
| 317 | /* Der Standard ist „nicht mischen“ – gerade die Simulation soll dem |
| 318 | gedruckten Bogen nahekommen, und sie zeigt keine Ziffernspalte, die |
| 319 | eine verdrehte Buchstabenfolge abmildern könnte. Sichert den |
| 320 | Rückfallwert gegen ein späteres Zurückdrehen ab. */ |
| 321 | const bogenFragen = bogenZiehen(profilId, auftrag({ profilId: 'standard' })); |
| 322 | |
| 323 | for (const eintrag of bogenFragen) { |
| 324 | const frage = frageZu(KATALOG, eintrag.frageId); |
| 325 | |
| 326 | expect(eintrag.optionsReihenfolge).toEqual((frage.optionen ?? []).map((o) => o.label)); |
| 327 | } |
| 328 | }); |
| 329 | |
| 330 | it('weist ein optionenMischen zurück, das kein Wahrheitswert ist', () => { |
| 331 | expect(() => |
| 332 | pruefung.starten(profilId, auftrag({ profilId: 'standard', optionenMischen: 'ja' as never })), |
| 333 | ).toThrow(/optionenMischen/u); |
| 334 | }); |
| 335 | |
| 336 | it('stellt ohne anteilOffen ausschließlich Multiple Choice zusammen', () => { |
| 337 | const bogenFragen = bogenZiehen(profilId, auftrag({ profilId: 'standard' })); |
| 338 | const typen = new Set(bogenFragen.map((f) => frageZu(KATALOG, f.frageId).typ)); |
| 339 | |
| 340 | expect([...typen]).toEqual(['mc']); |
| 341 | }); |
| 342 | |
| 343 | it('beachtet den Anteil offener Fragen', () => { |
| 344 | const bogenFragen = bogenZiehen(profilId, auftrag({ profilId: 'dsb' })); |
| 345 | const offene = bogenFragen.filter((f) => frageZu(KATALOG, f.frageId).typ !== 'mc'); |
| 346 | |
| 347 | // 20 Prozent von 100 Fragen, verteilt über die Themenquoten. |
| 348 | expect(offene).toHaveLength(20); |
| 349 | expect(warnungen).toEqual([]); |
| 350 | }); |
| 351 | |
| 352 | /* |
| 353 | „Anteil offener Fragen: 0“ heißt null – auch dann, wenn die Auswahlfragen |
| 354 | nicht reichen. |
| 355 | |
| 356 | Der Katalog hat 471 Auswahlfragen und 104 offene. Bis Fassung 0.24.1 legte |
| 357 | der Kern bei 500 gewünschten Fragen stillschweigend 29 offene nach, |
| 358 | obwohl die Prüfungswahl unmittelbar davor zusagt: „Der Bogen besteht damit |
| 359 | ausschließlich aus Auswahlfragen.“ Nach der Abgabe sprang dann unerwartet |
| 360 | die Selbstbewertung dazwischen. |
| 361 | */ |
| 362 | it('legt bei null Prozent offenen Fragen keine offenen nach', () => { |
| 363 | const bogenFragen = bogenZiehen( |
| 364 | profilId, |
| 365 | auftrag({ profilId: 'frei', fragenAnzahl: 500, anteilOffen: 0 }), |
| 366 | ); |
| 367 | const offene = bogenFragen.filter((f) => frageZu(KATALOG, f.frageId).typ !== 'mc'); |
| 368 | |
| 369 | expect(offene).toEqual([]); |
| 370 | expect(warnungen.join(' ')).toMatch(/fehlt|fehlen/u); |
| 371 | }); |
| 372 | |
| 373 | /* |
| 374 | Das frei einstellbare Profil trägt selbst keinen `anteilOffen`. Ohne diese |
| 375 | Kette bliebe der Regler in der Oberfläche wirkungslos: Der Kern bildet das |
| 376 | wirksame Profil neu und übernahm bis Fassung 0.16.0 nur Fragenzahl, Zeit |
| 377 | und Bestehensgrenze. |
| 378 | */ |
| 379 | it('übernimmt den Anteil offener Fragen aus dem Auftrag', () => { |
| 380 | const bogenFragen = bogenZiehen( |
| 381 | profilId, |
| 382 | auftrag({ profilId: 'frei', fragenAnzahl: 20, anteilOffen: 0.25 }), |
| 383 | ); |
| 384 | const offene = bogenFragen.filter((f) => frageZu(KATALOG, f.frageId).typ !== 'mc'); |
| 385 | |
| 386 | expect(bogenFragen).toHaveLength(20); |
| 387 | expect(offene).toHaveLength(5); |
| 388 | expect(warnungen).toEqual([]); |
| 389 | }); |
| 390 | |
| 391 | it('füllt mit Auswahlfragen auf, wenn der Wunsch den Vorrat übersteigt', () => { |
| 392 | /* Der Wunsch ist eine Bitte, keine Zusage – ein Bogen ganz aus offenen |
| 393 | Fragen ist im echten Katalog nicht zu haben. Zurechtgebogen wird der |
| 394 | Wert trotzdem nicht; stattdessen wird es gesagt. */ |
| 395 | const bogenFragen = bogenZiehen( |
| 396 | profilId, |
| 397 | // 8 Bereiche à 8 offene Fragen sind 64 – ein Bogen von 80 ganz aus |
| 398 | // offenen Fragen ist damit nicht zu haben. |
| 399 | auftrag({ profilId: 'frei', fragenAnzahl: 80, anteilOffen: 1 }), |
| 400 | ); |
| 401 | const offene = bogenFragen.filter((f) => frageZu(KATALOG, f.frageId).typ !== 'mc'); |
| 402 | |
| 403 | expect(bogenFragen).toHaveLength(80); |
| 404 | expect(offene).toHaveLength(64); |
| 405 | expect(warnungen.some((w) => w.includes('durch 16 Auswahlfragen ersetzt'))).toBe(true); |
| 406 | }); |
| 407 | |
| 408 | /* |
| 409 | Die Warnungen gehen nicht mehr nur ins Protokoll: Sie hängen am Bogen und |
| 410 | landen damit auf dem Bildschirm (Befund 7.10). Bis Fassung 0.20.0 hielt |
| 411 | jeder, der simulierte, seinen Bogen für profilgetreu. |
| 412 | */ |
| 413 | it('reicht dieselben Warnungen mit dem Bogen heraus', () => { |
| 414 | const bogen = pruefung.starten( |
| 415 | profilId, |
| 416 | auftrag({ profilId: 'frei', fragenAnzahl: 80, anteilOffen: 1 }), |
| 417 | ); |
| 418 | |
| 419 | expect(bogen.fragen).toHaveLength(80); |
| 420 | expect(bogen.warnungen).toEqual(warnungen); |
| 421 | expect(bogen.warnungen.some((w) => w.includes('durch 16 Auswahlfragen ersetzt'))).toBe(true); |
| 422 | }); |
| 423 | |
| 424 | it('gibt einen unauffälligen Bogen ohne Warnungen heraus', () => { |
| 425 | const bogen = pruefung.starten(profilId, auftrag({ profilId: 'standard' })); |
| 426 | |
| 427 | expect(bogen.fragen).toHaveLength(80); |
| 428 | expect(bogen.warnungen).toEqual([]); |
| 429 | }); |
| 430 | |
| 431 | /* Die Sätze stehen auf dem Bildschirm. Ein Feldname aus dem Vertrag wäre |
| 432 | dort eine Zumutung, „Frage(n)“ ebenso. */ |
| 433 | it('formuliert die Warnungen ohne Feldnamen und ohne Klammerformen', () => { |
| 434 | const bogen = pruefung.starten( |
| 435 | profilId, |
| 436 | auftrag({ profilId: 'standard', anteilOffen: 0.5, fragenAnzahl: 12 }), |
| 437 | ); |
| 438 | |
| 439 | expect(bogen.warnungen.length).toBeGreaterThan(0); |
| 440 | const alles = bogen.warnungen.join(' '); |
| 441 | |
| 442 | expect(alles).toContain('der Anteil offener Fragen'); |
| 443 | expect(alles).toContain('die Fragenzahl'); |
| 444 | expect(alles).not.toContain('anteilOffen'); |
| 445 | expect(alles).not.toContain('fragenAnzahl'); |
| 446 | expect(alles).not.toContain('(n)'); |
| 447 | }); |
| 448 | |
| 449 | /* Der Bogen wird je Themenbereich gezogen; ohne Entdoppelung stünde |
| 450 | derselbe Satz achtmal untereinander auf dem Bildschirm. */ |
| 451 | it('sammelt gleichlautende Warnungen nur einmal', () => { |
| 452 | const knapp = katalogBauen(5, 0); |
| 453 | starten(knapp, 3); |
| 454 | |
| 455 | const bogen = pruefung.starten(profilId, auftrag({ profilId: 'dsb', anteilOffen: 0.5 })); |
| 456 | |
| 457 | expect(bogen.warnungen.length).toBe(new Set(bogen.warnungen).size); |
| 458 | }); |
| 459 | |
| 460 | it('weist einen Anteil außerhalb von 0 bis 1 ab', () => { |
| 461 | expect(() => |
| 462 | pruefung.starten(profilId, auftrag({ profilId: 'frei', anteilOffen: 1.5 })), |
| 463 | ).toThrow(/anteilOffen/u); |
| 464 | expect(() => |
| 465 | pruefung.starten(profilId, auftrag({ profilId: 'frei', anteilOffen: -0.1 })), |
| 466 | ).toThrow(/anteilOffen/u); |
| 467 | expect(() => |
| 468 | pruefung.starten(profilId, auftrag({ profilId: 'frei', anteilOffen: 'viel' as never })), |
| 469 | ).toThrow(/anteilOffen/u); |
| 470 | }); |
| 471 | |
| 472 | it('übernimmt den Anteil nicht bei festen Profilen und sagt es', () => { |
| 473 | const bogenFragen = bogenZiehen(profilId, auftrag({ profilId: 'standard', anteilOffen: 0.5 })); |
| 474 | const typen = new Set(bogenFragen.map((f) => frageZu(KATALOG, f.frageId).typ)); |
| 475 | |
| 476 | expect([...typen]).toEqual(['mc']); |
| 477 | expect(warnungen.some((w) => w.includes('der Anteil offener Fragen'))).toBe(true); |
| 478 | }); |
| 479 | |
| 480 | it('hält die Themenquoten des Profils ein', () => { |
| 481 | const quoten = profilFinden('dsb')?.themenquoten ?? {}; |
| 482 | const bogenFragen = bogenZiehen(profilId, auftrag({ profilId: 'dsb' })); |
| 483 | |
| 484 | const gezaehlt = new Map<string, number>(); |
| 485 | for (const eintrag of bogenFragen) { |
| 486 | const bereich = bereichVon(frageZu(KATALOG, eintrag.frageId)); |
| 487 | gezaehlt.set(bereich, (gezaehlt.get(bereich) ?? 0) + 1); |
| 488 | } |
| 489 | |
| 490 | expect(Object.fromEntries(gezaehlt)).toEqual(quoten); |
| 491 | }); |
| 492 | |
| 493 | it('füllt einen zu kleinen Themenbereich aus den übrigen auf und protokolliert das', () => { |
| 494 | // Nur 6 Fragen je Bereich – die DSB-Quote von 20 für „I.2“ ist damit |
| 495 | // nicht zu erfüllen. |
| 496 | const knapp = katalogBauen(5, 1); |
| 497 | starten(knapp, 3); |
| 498 | |
| 499 | const bogenFragen = bogenZiehen(profilId, auftrag({ profilId: 'dsb' })); |
| 500 | const ids = new Set(bogenFragen.map((f) => f.frageId)); |
| 501 | |
| 502 | // 8 Bereiche à 6 Fragen = 48; mehr gibt der Katalog nicht her. |
| 503 | expect(bogenFragen).toHaveLength(48); |
| 504 | expect(ids.size).toBe(48); |
| 505 | /* Mit Titel statt nur mit der Kennung: „I.2“ allein sagt niemandem etwas, |
| 506 | der nicht den Aufbau des Katalogs im Kopf hat. */ |
| 507 | expect(warnungen.some((w) => w.includes('Themenbereich „I.2 – Rechte und Pflichten“'))).toBe( |
| 508 | true, |
| 509 | ); |
| 510 | expect(warnungen.some((w) => w.includes('stehen aber nur 48'))).toBe(true); |
| 511 | }); |
| 512 | |
| 513 | it('lässt ausgeschlossene Kapitel weg', () => { |
| 514 | const bogenFragen = bogenZiehen( |
| 515 | profilId, |
| 516 | auftrag({ profilId: 'standard', kapitelAusschluss: ['IV'] }), |
| 517 | ); |
| 518 | const kapitel = new Set(bogenFragen.map((f) => frageZu(KATALOG, f.frageId).kapitel)); |
| 519 | |
| 520 | expect(bogenFragen).toHaveLength(80); |
| 521 | expect(kapitel.has('IV')).toBe(false); |
| 522 | }); |
| 523 | |
| 524 | it('übernimmt Überschreibungen nur bei anpassbaren Profilen', () => { |
| 525 | const frei = bogenZiehen(profilId, auftrag({ profilId: 'frei', fragenAnzahl: 12 })); |
| 526 | |
| 527 | expect(frei).toHaveLength(12); |
| 528 | expect(warnungen).toEqual([]); |
| 529 | |
| 530 | const fest = bogenZiehen(profilId, auftrag({ profilId: 'standard', fragenAnzahl: 12 })); |
| 531 | |
| 532 | expect(fest).toHaveLength(80); |
| 533 | expect(warnungen.some((w) => w.includes('hat feste Vorgaben'))).toBe(true); |
| 534 | }); |
| 535 | |
| 536 | it('weist unbekannte Profile, Zeitmodi, Kapitel und Lernprofile ab', () => { |
| 537 | expect(() => pruefung.starten(profilId, auftrag({ profilId: 'phantasie' }))).toThrow( |
| 538 | /Unbekanntes Prüfungsprofil/u, |
| 539 | ); |
| 540 | expect(() => |
| 541 | pruefung.starten(profilId, { profilId: 'standard', zeitmodus: 'schneller' as never }), |
| 542 | ).toThrow(/zeitmodus/u); |
| 543 | expect(() => |
| 544 | pruefung.starten(profilId, auftrag({ profilId: 'standard', kapitelAusschluss: ['XI'] })), |
| 545 | ).toThrow(/Unbekanntes Kapitel/u); |
| 546 | expect(() => pruefung.starten(999, auftrag({ profilId: 'standard' }))).toThrow( |
| 547 | /Unbekanntes Profil/u, |
| 548 | ); |
| 549 | }); |
| 550 | |
| 551 | it('weist einen Auftrag ab, der den ganzen Katalog abwählt', () => { |
| 552 | expect(() => |
| 553 | pruefung.starten( |
| 554 | profilId, |
| 555 | auftrag({ profilId: 'standard', kapitelAusschluss: ['I', 'II', 'III', 'IV'] }), |
| 556 | ), |
| 557 | ).toThrow(/keine einzige Frage/u); |
| 558 | }); |
| 559 | }); |
| 560 | |
| 561 | // ─── Auswertung ───────────────────────────────────────────────────────────── |
| 562 | |
| 563 | describe('Bogen und Auswertung gehören zusammen', () => { |
| 564 | /* |
| 565 | Ohne Abgleich nähme der Kern jede beliebige Antwortliste an: Ein Lauf mit |
| 566 | einer einzigen richtigen Antwort landete als „bestanden“ im Verlauf, |
| 567 | obwohl der Bogen 80 Fragen hatte. |
| 568 | */ |
| 569 | it('weist eine Antwortliste ab, die nicht zum ausgegebenen Bogen passt', () => { |
| 570 | const bogenAusgabe = bogenZiehen(profilId, auftrag({ profilId: 'standard' })); |
| 571 | expect(bogenAusgabe.length).toBeGreaterThan(1); |
| 572 | |
| 573 | const eine = KATALOG.fragen.find((f) => f.id === bogenAusgabe[0]?.frageId)!; |
| 574 | |
| 575 | expect(() => |
| 576 | pruefung.auswerten( |
| 577 | profilId, |
| 578 | auftrag({ profilId: 'standard' }), |
| 579 | [antwort(eine, true)], |
| 580 | 60_000, |
| 581 | false, |
| 582 | ), |
| 583 | ).toThrow(/umfasste .* Fragen/u); |
| 584 | }); |
| 585 | |
| 586 | it('weist eine Frage ab, die nicht im Bogen stand', () => { |
| 587 | const bogenAusgabe = bogenZiehen(profilId, auftrag({ profilId: 'frei', fragenAnzahl: 3 })); |
| 588 | const imBogen = new Set(bogenAusgabe.map((f) => f.frageId)); |
| 589 | const fremd = KATALOG.fragen.find((f) => !imBogen.has(f.id) && f.typ === 'mc')!; |
| 590 | |
| 591 | const antworten = bogenAusgabe.slice(0, 2).map((eintrag) => { |
| 592 | const frage = KATALOG.fragen.find((f) => f.id === eintrag.frageId)!; |
| 593 | return antwort(frage, true); |
| 594 | }); |
| 595 | antworten.push(antwort(fremd, true)); |
| 596 | |
| 597 | expect(() => |
| 598 | pruefung.auswerten( |
| 599 | profilId, |
| 600 | auftrag({ profilId: 'frei', fragenAnzahl: 3 }), |
| 601 | antworten, |
| 602 | 1, |
| 603 | false, |
| 604 | ), |
| 605 | ).toThrow(/nicht Teil des Bogens/u); |
| 606 | }); |
| 607 | |
| 608 | it('nimmt den passenden Bogen an und wertet ihn genau einmal aus', () => { |
| 609 | const bogenAusgabe = bogenZiehen(profilId, auftrag({ profilId: 'frei', fragenAnzahl: 3 })); |
| 610 | const antworten = bogenAusgabe.map((eintrag) => { |
| 611 | const frage = KATALOG.fragen.find((f) => f.id === eintrag.frageId)!; |
| 612 | return antwort(frage, true); |
| 613 | }); |
| 614 | |
| 615 | const ergebnis = pruefung.auswerten( |
| 616 | profilId, |
| 617 | auftrag({ profilId: 'frei', fragenAnzahl: 3 }), |
| 618 | antworten, |
| 619 | 1_000, |
| 620 | false, |
| 621 | ); |
| 622 | expect(ergebnis.gesamt).toBe(3); |
| 623 | |
| 624 | /* |
| 625 | Und wirklich genau einmal. |
| 626 | |
| 627 | Bis 0.27.2 stand hier `not.toThrow()` – der Titel sagte „genau einmal“, |
| 628 | geprüft wurde das Gegenteil: dass ein zweiter Aufruf durchgeht. Er ging |
| 629 | durch, schrieb einen zweiten Verlaufseintrag und buchte jede Antwort ein |
| 630 | zweites Mal in die Wiedervorlage (gemessen: `versuche` 80 → 160). |
| 631 | */ |
| 632 | expect(() => |
| 633 | pruefung.auswerten( |
| 634 | profilId, |
| 635 | auftrag({ profilId: 'frei', fragenAnzahl: 3 }), |
| 636 | antworten, |
| 637 | 1_000, |
| 638 | false, |
| 639 | ), |
| 640 | ).toThrow(/bereits ausgewertet/u); |
| 641 | expect((db.prepare('SELECT COUNT(*) AS n FROM pruefung_lauf').get() as { n: number }).n).toBe( |
| 642 | 1, |
| 643 | ); |
| 644 | }); |
| 645 | }); |
| 646 | |
| 647 | describe('pruefung:auswerten', () => { |
| 648 | it('rechnet Multiple Choice selbst nach und glaubt der Selbsteinschätzung nicht', () => { |
| 649 | const frage = mcAus('I.1', 1)[0]!; |
| 650 | const ergebnis = auswerten('standard', [ |
| 651 | { frageId: frage.id, auswahl: falscheAuswahl(frage), selbstAlsRichtig: true }, |
| 652 | ]); |
| 653 | |
| 654 | expect(ergebnis.richtig).toBe(0); |
| 655 | expect(ergebnis.falsch).toBe(1); |
| 656 | expect(ergebnis.fehlerIds).toEqual([frage.id]); |
| 657 | }); |
| 658 | |
| 659 | it('wertet eine unvollständige Mehrfachauswahl als falsch', () => { |
| 660 | const mehrfach = KATALOG.fragen.find((f) => f.typ === 'mc' && richtigeLabels(f).length > 1); |
| 661 | const soll = richtigeLabels(mehrfach!); |
| 662 | const ergebnis = auswerten('standard', [ |
| 663 | { frageId: mehrfach!.id, auswahl: [soll[0]!] }, |
| 664 | { frageId: mcAus('I.2', 1)[0]!.id, auswahl: richtigeLabels(mcAus('I.2', 1)[0]!) }, |
| 665 | ]); |
| 666 | |
| 667 | expect(soll.length).toBeGreaterThan(1); |
| 668 | expect(ergebnis.richtig).toBe(1); |
| 669 | expect(ergebnis.fehlerIds).toEqual([mehrfach!.id]); |
| 670 | expect(ergebnis.unbeantwortet).toBe(0); |
| 671 | }); |
| 672 | |
| 673 | it('zählt zu viel Angekreuztes als falsch', () => { |
| 674 | const einfach = mcAus('I.1', 1)[0]!; |
| 675 | const zuViel = [...richtigeLabels(einfach), ...falscheAuswahl(einfach)]; |
| 676 | const ergebnis = auswerten('standard', [{ frageId: einfach.id, auswahl: zuViel }]); |
| 677 | |
| 678 | expect(ergebnis.richtig).toBe(0); |
| 679 | }); |
| 680 | |
| 681 | it('weist unbeantwortete Fragen getrennt aus, ohne sie in „falsch“ mitzuzählen', () => { |
| 682 | /* |
| 683 | Die drei Zahlen bilden eine Zerlegung von `gesamt`. Steckten die |
| 684 | unbeantworteten zusätzlich in `falsch`, zeigte die Auswertung mehr |
| 685 | Fragen an, als der Bogen hat. Für die Wertung bleiben sie dennoch |
| 686 | nicht richtig – und in `fehlerIds` gehören sie, damit man sie üben kann. |
| 687 | */ |
| 688 | const mc = mcAus('I.1', 3); |
| 689 | const offen = KATALOG.fragen.find((f) => f.typ !== 'mc')!; |
| 690 | const ergebnis = auswerten('standard', [ |
| 691 | antwort(mc[0]!, true), |
| 692 | antwort(mc[1]!, false), |
| 693 | { frageId: mc[2]!.id, auswahl: [] }, |
| 694 | { frageId: offen.id, auswahl: [] }, |
| 695 | ]); |
| 696 | |
| 697 | expect(ergebnis.gesamt).toBe(4); |
| 698 | expect(ergebnis.richtig).toBe(1); |
| 699 | expect(ergebnis.falsch).toBe(1); |
| 700 | expect(ergebnis.unbeantwortet).toBe(2); |
| 701 | // Die Zerlegung geht auf. |
| 702 | expect(ergebnis.richtig + ergebnis.falsch + ergebnis.unbeantwortet).toBe(ergebnis.gesamt); |
| 703 | expect(ergebnis.fehlerIds).toEqual([mc[1]!.id, mc[2]!.id, offen.id]); |
| 704 | }); |
| 705 | |
| 706 | it('wertet offene Fragen nach der Selbsteinschätzung', () => { |
| 707 | const offene = KATALOG.fragen.filter((f) => f.typ !== 'mc').slice(0, 2); |
| 708 | const ergebnis = auswerten('dsb', [antwort(offene[0]!, true), antwort(offene[1]!, false)]); |
| 709 | |
| 710 | expect(ergebnis.richtig).toBe(1); |
| 711 | expect(ergebnis.unbeantwortet).toBe(0); |
| 712 | }); |
| 713 | |
| 714 | it('füllt die Bereiche je Abschnitt bzw. Kapitel in Katalogreihenfolge', () => { |
| 715 | const ergebnis = auswerten('standard', [ |
| 716 | ...mcAus('I.2', 2).map((f) => antwort(f, true)), |
| 717 | ...mcAus('I.1', 2).map((f) => antwort(f, false)), |
| 718 | ...mcAus('III', 1).map((f) => antwort(f, true)), |
| 719 | ]); |
| 720 | |
| 721 | expect(ergebnis.bereiche).toEqual([ |
| 722 | { bereich: 'I.1', titel: 'Begriffe des Waffenrechts', gesamt: 2, richtig: 0 }, |
| 723 | { bereich: 'I.2', titel: 'Rechte und Pflichten', gesamt: 2, richtig: 2 }, |
| 724 | { bereich: 'III', titel: 'Handhabung von Schusswaffen', gesamt: 1, richtig: 1 }, |
| 725 | ]); |
| 726 | }); |
| 727 | |
| 728 | it('lässt das K.-o.-Kriterium unabhängig vom Gesamtergebnis greifen', () => { |
| 729 | // Profil „fehlerpunkte“: höchstens 15 Fehler, aber höchstens 2 in I.5. |
| 730 | const antworten = [ |
| 731 | ...mcAus('I.1', 20).map((f) => antwort(f, true)), |
| 732 | ...mcAus('I.5', 3).map((f) => antwort(f, false)), |
| 733 | ]; |
| 734 | const ergebnis = auswerten('fehlerpunkte', antworten); |
| 735 | |
| 736 | expect(ergebnis.falsch).toBe(3); |
| 737 | expect(ergebnis.urteil).toBe('nicht_bestanden'); |
| 738 | expect(ergebnis.verletzteKriterien).toEqual([ |
| 739 | 'Notwehr und Notstand (3 Fehler, höchstens 2 zulässig)', |
| 740 | ]); |
| 741 | expect(ergebnis.begruendung).toContain('Notwehr und Notstand'); |
| 742 | }); |
| 743 | |
| 744 | it('lässt zwei Fehler in Notwehr und Notstand durchgehen', () => { |
| 745 | const ergebnis = auswerten('fehlerpunkte', [ |
| 746 | ...mcAus('I.1', 20).map((f) => antwort(f, true)), |
| 747 | ...mcAus('I.5', 2).map((f) => antwort(f, false)), |
| 748 | ]); |
| 749 | |
| 750 | expect(ergebnis.verletzteKriterien).toEqual([]); |
| 751 | expect(ergebnis.urteil).toBe('bestanden'); |
| 752 | }); |
| 753 | |
| 754 | it.each([ |
| 755 | ['standard', 16, 4, 'bestanden'], |
| 756 | ['standard', 15, 5, 'nicht_bestanden'], |
| 757 | ['bdmp', 14, 6, 'bestanden'], |
| 758 | ['bdmp', 13, 7, 'nicht_bestanden'], |
| 759 | ['dsb', 15, 5, 'bestanden'], |
| 760 | ['dsb', 13, 7, 'nachpruefung'], |
| 761 | ['dsb', 12, 8, 'nachpruefung'], |
| 762 | ['dsb', 11, 9, 'nicht_bestanden'], |
| 763 | ['fehlerpunkte', 15, 5, 'bestanden'], |
| 764 | ['fehlerpunkte', 4, 16, 'nicht_bestanden'], |
| 765 | ])('bildet für %s bei %i von 20 richtigen das Urteil %s', (kennung, richtig, falsch, urteil) => { |
| 766 | const ergebnis = auswerten(kennung, bogen(richtig, falsch)); |
| 767 | |
| 768 | expect(ergebnis.gesamt).toBe(20); |
| 769 | expect(ergebnis.richtig).toBe(richtig); |
| 770 | expect(ergebnis.quote).toBeCloseTo(richtig / 20, 10); |
| 771 | expect(ergebnis.urteil).toBe(urteil); |
| 772 | }); |
| 773 | |
| 774 | it('baut die Begründung aus Trefferzahlen, nicht aus gerundeten Prozenten', () => { |
| 775 | /* 149 von 200 sind 74,5 Prozent – kaufmännisch gerundet 75. Aus |
| 776 | Prozentwerten gebaut lautete die Anzeige „Nicht bestanden. 75 Prozent |
| 777 | richtig, nötig waren 75 Prozent." Ein Satz, der sich selbst |
| 778 | widerspricht. */ |
| 779 | const knapp = urteilBilden(profilFinden('frei')!, 149, 200, []); |
| 780 | expect(knapp.urteil).toBe('nicht_bestanden'); |
| 781 | expect(knapp.begruendung).toBe('149 von 200 richtig, nötig waren 150.'); |
| 782 | |
| 783 | const gerade = urteilBilden(profilFinden('frei')!, 150, 200, []); |
| 784 | expect(gerade.urteil).toBe('bestanden'); |
| 785 | expect(gerade.begruendung).toBe('150 von 200 richtig, nötig waren 150.'); |
| 786 | }); |
| 787 | |
| 788 | it('rundet die nötige Trefferzahl nicht durch Gleitkomma hoch', () => { |
| 789 | // 0,8 * 80 kann als 64,000000000000006 herauskommen – aufgerundet 65. |
| 790 | const ergebnis = urteilBilden(profilFinden('standard')!, 64, 80, []); |
| 791 | |
| 792 | expect(ergebnis.urteil).toBe('bestanden'); |
| 793 | expect(ergebnis.begruendung).toBe('64 von 80 richtig, nötig waren 64.'); |
| 794 | }); |
| 795 | |
| 796 | it('nennt die Grauzone des DSB-Profils in der Begründung', () => { |
| 797 | const ergebnis = auswerten('dsb', bogen(13, 7)); |
| 798 | |
| 799 | expect(ergebnis.urteil).toBe('nachpruefung'); |
| 800 | expect(ergebnis.begruendung).toContain('mündliche Nachprüfung'); |
| 801 | }); |
| 802 | |
| 803 | it('rechnet die Bestehensgrenze eines anpassbaren Profils nach', () => { |
| 804 | const streng = pruefung.auswerten( |
| 805 | profilId, |
| 806 | auftrag({ profilId: 'frei', bestehensQuote: 0.9 }), |
| 807 | bogen(16, 4), |
| 808 | 1000, |
| 809 | false, |
| 810 | ); |
| 811 | |
| 812 | expect(streng.urteil).toBe('nicht_bestanden'); |
| 813 | |
| 814 | const milde = pruefung.auswerten( |
| 815 | profilId, |
| 816 | auftrag({ profilId: 'frei', bestehensQuote: 0.5 }), |
| 817 | bogen(16, 4), |
| 818 | 1000, |
| 819 | false, |
| 820 | ); |
| 821 | |
| 822 | expect(milde.urteil).toBe('bestanden'); |
| 823 | }); |
| 824 | |
| 825 | it('übernimmt Dauer und Zeitangabe unverändert', () => { |
| 826 | const ergebnis = pruefung.auswerten( |
| 827 | profilId, |
| 828 | auftrag({ profilId: 'standard' }), |
| 829 | bogen(10, 10), |
| 830 | 1_234_567, |
| 831 | true, |
| 832 | ); |
| 833 | |
| 834 | expect(ergebnis.dauerMs).toBe(1_234_567); |
| 835 | expect(ergebnis.zeitAbgelaufen).toBe(true); |
| 836 | expect(ergebnis.zeitpunkt).toBe(START); |
| 837 | expect(ergebnis.profilId).toBe('standard'); |
| 838 | }); |
| 839 | |
| 840 | it('weist unsaubere Nutzlasten ab', () => { |
| 841 | const frage = mcAus('I.1', 1)[0]!; |
| 842 | |
| 843 | expect(() => auswerten('standard', [])).toThrow(/mindestens eine Antwort/u); |
| 844 | expect(() => auswerten('standard', [antwort(frage, true), antwort(frage, true)])).toThrow( |
| 845 | /mehrfach im Bogen/u, |
| 846 | ); |
| 847 | expect(() => auswerten('standard', [{ frageId: 'gibt-es-nicht', auswahl: [] }])).toThrow( |
| 848 | /Unbekannte Frage-ID/u, |
| 849 | ); |
| 850 | expect(() => auswerten('standard', [{ frageId: frage.id, auswahl: ['z'] }])).toThrow( |
| 851 | /keine Antwortoption/u, |
| 852 | ); |
| 853 | expect(() => |
| 854 | pruefung.auswerten(profilId, auftrag({ profilId: 'standard' }), bogen(1, 0), -5, false), |
| 855 | ).toThrow(/dauerMs/u); |
| 856 | expect(() => |
| 857 | pruefung.auswerten(profilId, auftrag({ profilId: 'standard' }), bogen(1, 0), 'lange', false), |
| 858 | ).toThrow(/dauerMs/u); |
| 859 | }); |
| 860 | |
| 861 | it('lässt die Wiedervorlage nie aufgeschlagener Fragen unberührt', () => { |
| 862 | /* |
| 863 | Läuft die Zeit ab, schickt die Oberfläche auch für nie gezeigte Fragen |
| 864 | einen Eintrag mit leerer Auswahl. Diese gehören in die Historie – der |
| 865 | Bogen enthielt sie ja –, dürfen den Lernstand aber nicht zurückstufen: |
| 866 | Eine viermal sicher gewusste Frage wäre sonst allein deshalb wieder |
| 867 | fällig, weil ein Prüfungslauf nicht bis zu ihr kam. |
| 868 | */ |
| 869 | const [gezeigt, nieGezeigt] = mcAus('I.1', 2); |
| 870 | if (!gezeigt || !nieGezeigt) { |
| 871 | throw new Error('Zwei Fragen erwartet'); |
| 872 | } |
| 873 | |
| 874 | // Die zweite Frage viermal sicher beantworten, damit sie weit hinausrückt. |
| 875 | for (let i = 0; i < 4; i += 1) { |
| 876 | lernstand.antworten(profilId, { |
| 877 | frageId: nieGezeigt.id, |
| 878 | auswahl: nieGezeigt.optionen?.filter((o) => o.korrekt).map((o) => o.label) ?? [], |
| 879 | richtig: true, |
| 880 | bewertung: 'leicht', |
| 881 | dauerMs: 1_000, |
| 882 | }); |
| 883 | } |
| 884 | const vorher = lernstand.frageStand(profilId, nieGezeigt.id); |
| 885 | expect(vorher.faelligAb).not.toBeNull(); |
| 886 | |
| 887 | pruefung.auswerten( |
| 888 | profilId, |
| 889 | auftrag({ profilId: 'standard' }), |
| 890 | [antwort(gezeigt, true), { frageId: nieGezeigt.id, auswahl: [] }], |
| 891 | 60_000, |
| 892 | true, |
| 893 | ); |
| 894 | |
| 895 | const nachher = lernstand.frageStand(profilId, nieGezeigt.id); |
| 896 | expect(nachher.faelligAb).toBe(vorher.faelligAb); |
| 897 | expect(nachher.letzteBewertung).toBe('leicht'); |
| 898 | expect(nachher.versuche).toBe(vorher.versuche); |
| 899 | }); |
| 900 | |
| 901 | it('kappt eine übergroße Dauer, statt den Lauf zu verwerfen', () => { |
| 902 | /* |
| 903 | Wer ohne Zeitbegrenzung übt – der Nachteilsausgleich für alle, die mehr |
| 904 | Zeit brauchen – lässt das Fenster womöglich über Nacht offen. Diesen |
| 905 | Lauf zu verwerfen träfe genau die Gruppe, für die die Einstellung da |
| 906 | ist. Der Lauf wird gespeichert, die Dauer auf 24 Stunden gedeckelt. |
| 907 | */ |
| 908 | const ergebnis = pruefung.auswerten( |
| 909 | profilId, |
| 910 | auftrag({ profilId: 'standard' }), |
| 911 | bogen(3, 1), |
| 912 | 90_000_000, |
| 913 | false, |
| 914 | ); |
| 915 | |
| 916 | expect(ergebnis.dauerMs).toBe(86_400_000); |
| 917 | expect(ergebnis.richtig).toBe(3); |
| 918 | |
| 919 | const gespeichert = pruefung.verlauf(profilId); |
| 920 | expect(gespeichert).toHaveLength(1); |
| 921 | expect(gespeichert[0]?.gesamt).toBe(4); |
| 922 | }); |
| 923 | |
| 924 | it('rundet eine gemessene Dauer mit Nachkommastellen, statt sie abzuweisen', () => { |
| 925 | const ergebnis = pruefung.auswerten( |
| 926 | profilId, |
| 927 | auftrag({ profilId: 'standard' }), |
| 928 | bogen(1, 0), |
| 929 | 1234.6, |
| 930 | false, |
| 931 | ); |
| 932 | |
| 933 | expect(ergebnis.dauerMs).toBe(1235); |
| 934 | }); |
| 935 | }); |
| 936 | |
| 937 | // ─── Fortschreibung des Lernstands ────────────────────────────────────────── |
| 938 | |
| 939 | describe('Prüfungslauf im Lernstand', () => { |
| 940 | it('protokolliert jede Antwort wie eine Lernantwort', () => { |
| 941 | const antworten = bogen(3, 2); |
| 942 | auswerten('standard', antworten, 50_000); |
| 943 | |
| 944 | const anzahl = db |
| 945 | .prepare<[number], { n: number }>('SELECT COUNT(*) AS n FROM antwort_log WHERE profil_id = ?') |
| 946 | .get(profilId); |
| 947 | |
| 948 | expect(anzahl?.n).toBe(5); |
| 949 | expect(lernstand.uebersicht(profilId).beantwortet).toBe(5); |
| 950 | expect(lernstand.frageStand(profilId, antworten[0]!.frageId)).toMatchObject({ |
| 951 | versuche: 1, |
| 952 | richtige: 1, |
| 953 | letzteBewertung: 'gut', |
| 954 | }); |
| 955 | expect(lernstand.frageStand(profilId, antworten[4]!.frageId)).toMatchObject({ |
| 956 | versuche: 1, |
| 957 | richtige: 0, |
| 958 | letzteBewertung: 'nochmal', |
| 959 | }); |
| 960 | }); |
| 961 | |
| 962 | it('verteilt die gemessene Gesamtdauer gleichmäßig auf die Fragen', () => { |
| 963 | auswerten('standard', bogen(2, 2), 40_000); |
| 964 | |
| 965 | const dauern = db |
| 966 | .prepare<[number], { dauer_ms: number }>( |
| 967 | 'SELECT dauer_ms FROM antwort_log WHERE profil_id = ?', |
| 968 | ) |
| 969 | .all(profilId) |
| 970 | .map((z) => z.dauer_ms); |
| 971 | |
| 972 | expect(dauern).toEqual([10_000, 10_000, 10_000, 10_000]); |
| 973 | }); |
| 974 | |
| 975 | it('speichert nichts, wenn die Auswertung abbricht', () => { |
| 976 | const frage = mcAus('I.1', 1)[0]!; |
| 977 | |
| 978 | expect(() => auswerten('standard', [{ frageId: frage.id, auswahl: ['z'] }])).toThrow(); |
| 979 | expect(pruefung.verlauf(profilId)).toEqual([]); |
| 980 | expect(lernstand.uebersicht(profilId).beantwortet).toBe(0); |
| 981 | }); |
| 982 | }); |
| 983 | |
| 984 | // ─── Verlauf ──────────────────────────────────────────────────────────────── |
| 985 | |
| 986 | // ─── Unterbrochener Lauf ──────────────────────────────────────────────────── |
| 987 | |
| 988 | describe('Unterbrochener Lauf', () => { |
| 989 | /** Vollständige Antwortliste zum gerade gestarteten Bogen. */ |
| 990 | function bogenAntworten(bogenListe: readonly { frageId: string }[]): Pruefungsantwort[] { |
| 991 | return bogenListe.map((eintrag) => { |
| 992 | const frage = KATALOG.fragen.find((f) => f.id === eintrag.frageId)!; |
| 993 | return antwort(frage, true); |
| 994 | }); |
| 995 | } |
| 996 | |
| 997 | function offeneZeilen(): number { |
| 998 | return (db.prepare('SELECT COUNT(*) AS n FROM pruefung_offen').get() as { n: number }).n; |
| 999 | } |
| 1000 | |
| 1001 | it('legt den Bogen schon beim Starten ab, nicht erst beim ersten Sichern', () => { |
| 1002 | /* Stürbe das Programm in der ersten Sekunde, wäre der Bogen sonst trotz |
| 1003 | aller Vorsorge weg. */ |
| 1004 | const gezogen = bogenZiehen(profilId, auftrag({ profilId: 'standard' })); |
| 1005 | |
| 1006 | const offen = pruefung.offenerBogen(profilId); |
| 1007 | expect(offen).not.toBeNull(); |
| 1008 | expect(offen?.bogen.map((e) => e.frageId)).toEqual(gezogen.map((e) => e.frageId)); |
| 1009 | expect(offen?.position).toBe(0); |
| 1010 | expect(offen?.verbrauchtMs).toBe(0); |
| 1011 | expect(offen?.phase).toBe('bearbeiten'); |
| 1012 | expect(offen?.profil.id).toBe('standard'); |
| 1013 | }); |
| 1014 | |
| 1015 | it('sichert Eingaben, Position, Phase und verbrauchte Zeit', () => { |
| 1016 | const gezogen = bogenZiehen(profilId, auftrag({ profilId: 'standard' })); |
| 1017 | const erste = gezogen[0]!.frageId; |
| 1018 | |
| 1019 | expect( |
| 1020 | pruefung.sichern(profilId, { |
| 1021 | eingaben: { [erste]: { auswahl: ['a'], freitext: '' } }, |
| 1022 | position: 5, |
| 1023 | phase: 'bearbeiten', |
| 1024 | zeitAbgelaufen: false, |
| 1025 | verbrauchtMs: 90_000, |
| 1026 | }), |
| 1027 | ).toBe(true); |
| 1028 | |
| 1029 | const offen = pruefung.offenerBogen(profilId); |
| 1030 | expect(offen?.eingaben[erste]).toEqual({ auswahl: ['a'], freitext: '' }); |
| 1031 | expect(offen?.position).toBe(5); |
| 1032 | expect(offen?.verbrauchtMs).toBe(90_000); |
| 1033 | }); |
| 1034 | |
| 1035 | it('unterscheidet nicht bewertet von als falsch bewertet', () => { |
| 1036 | /* Ein Ersatzwert `false` schriebe eine unbewertete offene Frage beim |
| 1037 | Fortsetzen als falsch fest. */ |
| 1038 | const gezogen = bogenZiehen(profilId, auftrag({ profilId: 'dsb' })); |
| 1039 | const a = gezogen[0]!.frageId; |
| 1040 | const b = gezogen[1]!.frageId; |
| 1041 | |
| 1042 | pruefung.sichern(profilId, { |
| 1043 | eingaben: { |
| 1044 | [a]: { auswahl: [], freitext: 'Text', selbst: false }, |
| 1045 | [b]: { auswahl: [], freitext: 'Text' }, |
| 1046 | }, |
| 1047 | position: 0, |
| 1048 | phase: 'nachbewertung', |
| 1049 | zeitAbgelaufen: false, |
| 1050 | verbrauchtMs: 0, |
| 1051 | }); |
| 1052 | |
| 1053 | const offen = pruefung.offenerBogen(profilId); |
| 1054 | expect(offen?.eingaben[a]?.selbst).toBe(false); |
| 1055 | expect(offen?.eingaben[b]).not.toHaveProperty('selbst'); |
| 1056 | expect(offen?.phase).toBe('nachbewertung'); |
| 1057 | }); |
| 1058 | |
| 1059 | it('löscht die Zeile beim Auswerten – in derselben Transaktion', () => { |
| 1060 | const gezogen = bogenZiehen(profilId, auftrag({ profilId: 'standard' })); |
| 1061 | expect(offeneZeilen()).toBe(1); |
| 1062 | |
| 1063 | auswerten('standard', bogenAntworten(gezogen)); |
| 1064 | |
| 1065 | expect(offeneZeilen()).toBe(0); |
| 1066 | expect(pruefung.offenerBogen(profilId)).toBeNull(); |
| 1067 | }); |
| 1068 | |
| 1069 | it('lässt eine verspätete Sicherung den gewerteten Lauf nicht wiederbeleben', () => { |
| 1070 | /* |
| 1071 | Der schwerste Fall: Das Sichern ist entprellt. Feuert ein Nachzügler |
| 1072 | nach der Abgabe, dürfte er die eben gelöschte Zeile nicht wieder |
| 1073 | anlegen – sonst böte die Anwendung beim nächsten Start dieselbe, |
| 1074 | bereits gewertete Prüfung zum Fortsetzen an, und jede Antwort ginge |
| 1075 | ein zweites Mal in die Wiedervorlage. |
| 1076 | */ |
| 1077 | const gezogen = bogenZiehen(profilId, auftrag({ profilId: 'standard' })); |
| 1078 | auswerten('standard', bogenAntworten(gezogen)); |
| 1079 | |
| 1080 | const angekommen = pruefung.sichern(profilId, { |
| 1081 | eingaben: {}, |
| 1082 | position: 3, |
| 1083 | phase: 'bearbeiten', |
| 1084 | zeitAbgelaufen: false, |
| 1085 | verbrauchtMs: 120_000, |
| 1086 | }); |
| 1087 | |
| 1088 | expect(angekommen).toBe(false); |
| 1089 | expect(offeneZeilen()).toBe(0); |
| 1090 | }); |
| 1091 | |
| 1092 | it('hinterlässt nach der Auswertung genau einen Verlaufseintrag', () => { |
| 1093 | const gezogen = bogenZiehen(profilId, auftrag({ profilId: 'standard' })); |
| 1094 | auswerten('standard', bogenAntworten(gezogen)); |
| 1095 | |
| 1096 | expect(pruefung.offenerBogen(profilId)).toBeNull(); |
| 1097 | expect((db.prepare('SELECT COUNT(*) AS n FROM pruefung_lauf').get() as { n: number }).n).toBe( |
| 1098 | 1, |
| 1099 | ); |
| 1100 | }); |
| 1101 | |
| 1102 | it('sichert nicht in einen Lauf, der inzwischen ein anderer ist', () => { |
| 1103 | /* |
| 1104 | Dafuer ist die Lauf-Kennung da, und nur dafuer: Die Zeile existiert |
| 1105 | noch, gehoert aber schon zum naechsten Bogen. Ein Nachzuegler aus dem |
| 1106 | alten Lauf duerfte dessen Position und Eingaben nicht in den neuen |
| 1107 | schreiben. |
| 1108 | */ |
| 1109 | pruefung.starten(profilId, auftrag({ profilId: 'standard' })); |
| 1110 | const alteKennung = ( |
| 1111 | db.prepare('SELECT lauf_id FROM pruefung_offen').get() as { lauf_id: string } |
| 1112 | ).lauf_id; |
| 1113 | |
| 1114 | const zweiter = bogenZiehen(profilId, auftrag({ profilId: 'bdmp' })); |
| 1115 | const neueKennung = ( |
| 1116 | db.prepare('SELECT lauf_id FROM pruefung_offen').get() as { lauf_id: string } |
| 1117 | ).lauf_id; |
| 1118 | expect(neueKennung).not.toBe(alteKennung); |
| 1119 | |
| 1120 | pruefung.sichern(profilId, { |
| 1121 | eingaben: { [zweiter[0]!.frageId]: { auswahl: ['a'], freitext: '' } }, |
| 1122 | position: 1, |
| 1123 | phase: 'bearbeiten', |
| 1124 | zeitAbgelaufen: false, |
| 1125 | verbrauchtMs: 1000, |
| 1126 | }); |
| 1127 | |
| 1128 | // Der neue Lauf nimmt seine eigene Sicherung an – die Kennung stimmt. |
| 1129 | expect(pruefung.offenerBogen(profilId)?.position).toBe(1); |
| 1130 | }); |
| 1131 | |
| 1132 | it('verwirft die Zeile auf ausdrücklichen Wunsch', () => { |
| 1133 | pruefung.starten(profilId, auftrag({ profilId: 'standard' })); |
| 1134 | |
| 1135 | pruefung.verwerfen(profilId); |
| 1136 | |
| 1137 | expect(pruefung.offenerBogen(profilId)).toBeNull(); |
| 1138 | expect(offeneZeilen()).toBe(0); |
| 1139 | }); |
| 1140 | |
| 1141 | it('führt je Profil höchstens einen offenen Lauf', () => { |
| 1142 | pruefung.starten(profilId, auftrag({ profilId: 'standard' })); |
| 1143 | const zweiter = bogenZiehen(profilId, auftrag({ profilId: 'bdmp' })); |
| 1144 | |
| 1145 | expect(offeneZeilen()).toBe(1); |
| 1146 | expect(pruefung.offenerBogen(profilId)?.bogen.map((e) => e.frageId)).toEqual( |
| 1147 | zweiter.map((e) => e.frageId), |
| 1148 | ); |
| 1149 | }); |
| 1150 | |
| 1151 | it('hält die Läufe verschiedener Profile auseinander', () => { |
| 1152 | const zweites = lernstand.profilAnlegen('Zweitprüfling'); |
| 1153 | pruefung.starten(profilId, auftrag({ profilId: 'standard' })); |
| 1154 | pruefung.starten(zweites.id, auftrag({ profilId: 'bdmp' })); |
| 1155 | |
| 1156 | expect(pruefung.offenerBogen(profilId)?.profil.id).toBe('standard'); |
| 1157 | expect(pruefung.offenerBogen(zweites.id)?.profil.id).toBe('bdmp'); |
| 1158 | }); |
| 1159 | |
| 1160 | it('nimmt das gelöschte Profil mit', () => { |
| 1161 | const zweites = lernstand.profilAnlegen('Zweitprüfling'); |
| 1162 | pruefung.starten(zweites.id, auftrag({ profilId: 'standard' })); |
| 1163 | |
| 1164 | lernstand.profilLoeschen(zweites.id); |
| 1165 | |
| 1166 | expect(offeneZeilen()).toBe(0); |
| 1167 | }); |
| 1168 | |
| 1169 | it('verschwindet beim vollständigen Zurücksetzen', () => { |
| 1170 | /* „Von vorn" und „die halbe Prüfung von gestern liegt noch da" passen |
| 1171 | nicht zusammen. */ |
| 1172 | pruefung.starten(profilId, auftrag({ profilId: 'standard' })); |
| 1173 | |
| 1174 | lernstand.zuruecksetzen(profilId, null); |
| 1175 | |
| 1176 | expect(offeneZeilen()).toBe(0); |
| 1177 | }); |
| 1178 | |
| 1179 | it('bleibt beim kapitelweisen Zurücksetzen stehen', () => { |
| 1180 | /* Ein Bogen geht über den ganzen Katalog und lässt sich nicht |
| 1181 | kapitelweise herausrechnen. */ |
| 1182 | pruefung.starten(profilId, auftrag({ profilId: 'standard' })); |
| 1183 | |
| 1184 | lernstand.zuruecksetzen(profilId, 'I'); |
| 1185 | |
| 1186 | expect(offeneZeilen()).toBe(1); |
| 1187 | }); |
| 1188 | |
| 1189 | it('verwirft einen Bogen, dessen Fragen es nicht mehr gibt', () => { |
| 1190 | /* Katalogwechsel zwischen Unterbrechung und Fortsetzen: Eine fehlende |
| 1191 | Frage ließe eine Lücke, und die Auswertung zählte gegen eine andere |
| 1192 | Gesamtzahl als die Anzeige. */ |
| 1193 | pruefung.starten(profilId, auftrag({ profilId: 'standard' })); |
| 1194 | db.prepare('UPDATE pruefung_offen SET bogen = ?').run( |
| 1195 | '[{"frageId":"gibt-es-nicht","optionsReihenfolge":["a"]}]', |
| 1196 | ); |
| 1197 | |
| 1198 | expect(pruefung.offenerBogen(profilId)).toBeNull(); |
| 1199 | expect(offeneZeilen()).toBe(0); |
| 1200 | expect(warnungen.join(' ')).toMatch(/gibt-es-nicht/u); |
| 1201 | }); |
| 1202 | |
| 1203 | it('verwirft eine Zeile mit ungültigem Auftrag', () => { |
| 1204 | /* Die Datei liegt im userData-Verzeichnis und ist von außen |
| 1205 | beschreibbar; ein erfundenes Profil darf nicht in die Wertung. */ |
| 1206 | pruefung.starten(profilId, auftrag({ profilId: 'standard' })); |
| 1207 | db.prepare('UPDATE pruefung_offen SET auftrag = ?').run('{"profilId":"gibt-es-nicht"}'); |
| 1208 | |
| 1209 | expect(pruefung.offenerBogen(profilId)).toBeNull(); |
| 1210 | expect(offeneZeilen()).toBe(0); |
| 1211 | }); |
| 1212 | |
| 1213 | it('nimmt beim Auswerten den gespeicherten Auftrag, nicht den eingereichten', () => { |
| 1214 | /* |
| 1215 | Sonst ließe sich ein fortgesetzter Bogen mit einem anderen Profil |
| 1216 | abgeben: Bogen und Urteilsregeln kämen aus verschiedenen Läufen. |
| 1217 | */ |
| 1218 | const gezogen = bogenZiehen(profilId, auftrag({ profilId: 'bdmp' })); |
| 1219 | |
| 1220 | const ergebnis = pruefung.auswerten( |
| 1221 | profilId, |
| 1222 | auftrag({ profilId: 'standard' }), |
| 1223 | bogenAntworten(gezogen), |
| 1224 | 60_000, |
| 1225 | false, |
| 1226 | ); |
| 1227 | |
| 1228 | expect(ergebnis.profilId).toBe('bdmp'); |
| 1229 | }); |
| 1230 | |
| 1231 | it('prüft die eingereichte Liste auch nach einem Neustart', () => { |
| 1232 | /* |
| 1233 | Bisher galt: Ist der Bogen nicht bekannt – etwa nach einem Neustart –, |
| 1234 | wird jede Liste ungeprüft angenommen. Mit der gespeicherten Zeile lässt |
| 1235 | sich der Bogen wiederherstellen, und die Prüfung greift wieder. |
| 1236 | */ |
| 1237 | const gezogen = bogenZiehen(profilId, auftrag({ profilId: 'standard' })); |
| 1238 | const antworten = bogenAntworten(gezogen); |
| 1239 | |
| 1240 | // Neustart nachbilden: neue Instanz, leerer Speicher, dieselbe Datenbank. |
| 1241 | const nachNeustart = new Pruefung(lernstand, KATALOG, { |
| 1242 | jetzt: () => uhr, |
| 1243 | zufall: zufallsfolge(7), |
| 1244 | warnen: (meldung) => warnungen.push(meldung), |
| 1245 | }); |
| 1246 | nachNeustart.offenerBogen(profilId); |
| 1247 | |
| 1248 | expect(() => |
| 1249 | nachNeustart.auswerten( |
| 1250 | profilId, |
| 1251 | auftrag({ profilId: 'standard' }), |
| 1252 | antworten.slice(0, 3), |
| 1253 | 1000, |
| 1254 | false, |
| 1255 | ), |
| 1256 | ).toThrow(/Bogen umfasste/u); |
| 1257 | }); |
| 1258 | |
| 1259 | it('weist eine Position außerhalb des Bogens ab', () => { |
| 1260 | pruefung.starten(profilId, auftrag({ profilId: 'standard' })); |
| 1261 | |
| 1262 | expect(() => |
| 1263 | pruefung.sichern(profilId, { |
| 1264 | eingaben: {}, |
| 1265 | position: 999, |
| 1266 | phase: 'bearbeiten', |
| 1267 | zeitAbgelaufen: false, |
| 1268 | verbrauchtMs: 0, |
| 1269 | }), |
| 1270 | ).toThrow(/position/u); |
| 1271 | }); |
| 1272 | |
| 1273 | it('weist eine Eingabe zu einer fremden Frage ab', () => { |
| 1274 | pruefung.starten(profilId, auftrag({ profilId: 'standard' })); |
| 1275 | |
| 1276 | expect(() => |
| 1277 | pruefung.sichern(profilId, { |
| 1278 | eingaben: { 'gibt-es-nicht': { auswahl: ['a'], freitext: '' } }, |
| 1279 | position: 0, |
| 1280 | phase: 'bearbeiten', |
| 1281 | zeitAbgelaufen: false, |
| 1282 | verbrauchtMs: 0, |
| 1283 | }), |
| 1284 | ).toThrow(/gehört nicht zu diesem Bogen/u); |
| 1285 | }); |
| 1286 | |
| 1287 | it('kappt einen übergroßen Freitext und eine unsinnige Dauer', () => { |
| 1288 | const gezogen = bogenZiehen(profilId, auftrag({ profilId: 'standard' })); |
| 1289 | const erste = gezogen[0]!.frageId; |
| 1290 | |
| 1291 | pruefung.sichern(profilId, { |
| 1292 | eingaben: { [erste]: { auswahl: [], freitext: 'x'.repeat(9000) } }, |
| 1293 | position: 0, |
| 1294 | phase: 'bearbeiten', |
| 1295 | zeitAbgelaufen: false, |
| 1296 | verbrauchtMs: 99 * 24 * 60 * 60 * 1000, |
| 1297 | }); |
| 1298 | |
| 1299 | const offen = pruefung.offenerBogen(profilId); |
| 1300 | expect(offen?.eingaben[erste]?.freitext).toHaveLength(4000); |
| 1301 | expect(offen?.verbrauchtMs).toBe(24 * 60 * 60 * 1000); |
| 1302 | }); |
| 1303 | }); |
| 1304 | |
| 1305 | describe('pruefung:verlauf', () => { |
| 1306 | it('liefert die Läufe mit dem neuesten zuerst', () => { |
| 1307 | auswerten('standard', bogen(16, 4)); |
| 1308 | uhr = new Date(new Date(START).getTime() + 3_600_000); |
| 1309 | auswerten('dsb', bogen(10, 10)); |
| 1310 | |
| 1311 | const verlauf = pruefung.verlauf(profilId); |
| 1312 | |
| 1313 | expect(verlauf.map((l) => l.profilId)).toEqual(['dsb', 'standard']); |
| 1314 | expect(verlauf[0]).toMatchObject({ |
| 1315 | profilName: 'Nach Art des Deutschen Schützenbundes', |
| 1316 | gesamt: 20, |
| 1317 | richtig: 10, |
| 1318 | quote: 0.5, |
| 1319 | urteil: 'nicht_bestanden', |
| 1320 | }); |
| 1321 | expect(verlauf[1]).toMatchObject({ |
| 1322 | profilName: 'Standard', |
| 1323 | richtig: 16, |
| 1324 | urteil: 'bestanden', |
| 1325 | }); |
| 1326 | expect(verlauf[0]!.id).not.toBe(verlauf[1]!.id); |
| 1327 | }); |
| 1328 | |
| 1329 | it('hält die Läufe verschiedener Lernprofile auseinander', () => { |
| 1330 | const zweites = lernstand.profilAnlegen('Zweite Person'); |
| 1331 | auswerten('standard', bogen(16, 4)); |
| 1332 | |
| 1333 | expect(pruefung.verlauf(profilId)).toHaveLength(1); |
| 1334 | expect(pruefung.verlauf(zweites.id)).toEqual([]); |
| 1335 | }); |
| 1336 | |
| 1337 | it('weist ein unbekanntes Lernprofil ab', () => { |
| 1338 | expect(() => pruefung.verlauf(4711)).toThrow(/Unbekanntes Profil/u); |
| 1339 | }); |
| 1340 | |
| 1341 | it('liefert die Dauer mit, die es seit jeher speichert', () => { |
| 1342 | // Die Spalte gibt es seit Schema-Version 2, `verlauf()` las sie bis 0.22.0 |
| 1343 | // nur nicht aus. Zeitnot ist ein eigenes Durchfallrisiko. |
| 1344 | auswerten('standard', bogen(16, 4), 71 * 60_000); |
| 1345 | |
| 1346 | expect(pruefung.verlauf(profilId)[0]?.dauerMs).toBe(71 * 60_000); |
| 1347 | }); |
| 1348 | |
| 1349 | it('hält fest, wie viele Fragen offen blieben und ob die Zeit ablief', () => { |
| 1350 | /* Ohne diese beiden Angaben sähe ein abgelaufener Lauf im Vergleich wie |
| 1351 | ein Wissenseinbruch aus: `quote` zählt Unbeantwortete wie falsch |
| 1352 | beantwortete. */ |
| 1353 | const fragen = mcAus('I.1', 4); |
| 1354 | pruefung.auswerten( |
| 1355 | profilId, |
| 1356 | auftrag({ profilId: 'standard' }), |
| 1357 | [ |
| 1358 | antwort(fragen[0]!, true), |
| 1359 | antwort(fragen[1]!, true), |
| 1360 | { frageId: fragen[2]!.id, auswahl: [] }, |
| 1361 | { frageId: fragen[3]!.id, auswahl: [] }, |
| 1362 | ], |
| 1363 | 60_000, |
| 1364 | true, |
| 1365 | ); |
| 1366 | |
| 1367 | expect(pruefung.verlauf(profilId)[0]).toMatchObject({ |
| 1368 | unbeantwortet: 2, |
| 1369 | zeitAbgelaufen: true, |
| 1370 | quote: 0.5, |
| 1371 | }); |
| 1372 | }); |
| 1373 | |
| 1374 | it('speichert die Themenanalyse des Laufs mit', () => { |
| 1375 | const fragen = [...mcAus('I.1', 2), ...mcAus('I.2', 2)]; |
| 1376 | pruefung.auswerten( |
| 1377 | profilId, |
| 1378 | auftrag({ profilId: 'standard' }), |
| 1379 | [ |
| 1380 | antwort(fragen[0]!, true), |
| 1381 | antwort(fragen[1]!, false), |
| 1382 | antwort(fragen[2]!, true), |
| 1383 | antwort(fragen[3]!, true), |
| 1384 | ], |
| 1385 | 60_000, |
| 1386 | false, |
| 1387 | ); |
| 1388 | |
| 1389 | const bereiche = pruefung.verlauf(profilId)[0]?.bereiche; |
| 1390 | |
| 1391 | expect(bereiche).toHaveLength(2); |
| 1392 | expect(bereiche?.[0]).toMatchObject({ bereich: 'I.1', gesamt: 2, richtig: 1 }); |
| 1393 | expect(bereiche?.[1]).toMatchObject({ bereich: 'I.2', gesamt: 2, richtig: 2 }); |
| 1394 | /* Der Titel reist mit: Der Verlauf muss ihn auch dann noch nennen können, |
| 1395 | wenn der Katalog den Abschnitt später anders benennt. */ |
| 1396 | expect(bereiche?.[0]?.titel).toBe(KATALOG.kapitel[0]?.abschnitte[0]?.titel); |
| 1397 | }); |
| 1398 | |
| 1399 | it('speichert die Bestehensgrenze des Laufs, nicht die des Vertrags', () => { |
| 1400 | /* Beim frei eingestellten Profil sind die gewählten Werte nach dem Lauf |
| 1401 | fort. Holte die Anzeige die Grenze hinterher aus dem Vertrag, bekämen |
| 1402 | genau diese Läufe eine falsche. */ |
| 1403 | auswerten('standard', bogen(16, 4)); |
| 1404 | expect(pruefung.verlauf(profilId)[0]?.bestehensQuote).toBeCloseTo(0.8); |
| 1405 | |
| 1406 | uhr = new Date(new Date(START).getTime() + 3_600_000); |
| 1407 | pruefung.auswerten( |
| 1408 | profilId, |
| 1409 | auftrag({ profilId: 'frei', bestehensQuote: 0.5 }), |
| 1410 | bogen(2, 2), |
| 1411 | 60_000, |
| 1412 | false, |
| 1413 | ); |
| 1414 | expect(pruefung.verlauf(profilId)[0]?.bestehensQuote).toBeCloseTo(0.5); |
| 1415 | }); |
| 1416 | |
| 1417 | it('rechnet eine Fehlerpunktegrenze in eine Quote um', () => { |
| 1418 | // 75 Fragen bei höchstens 15 Fehlern sind 60 von 75 – dieselbe Bedingung, |
| 1419 | // anders geschrieben. Ein Bogen von 20 Fragen ergibt entsprechend 5 von 20. |
| 1420 | pruefung.auswerten( |
| 1421 | profilId, |
| 1422 | auftrag({ profilId: 'fehlerpunkte' }), |
| 1423 | bogen(16, 4), |
| 1424 | 60_000, |
| 1425 | false, |
| 1426 | ); |
| 1427 | |
| 1428 | expect(pruefung.verlauf(profilId)[0]?.bestehensQuote).toBeCloseTo(0.25); |
| 1429 | }); |
| 1430 | |
| 1431 | it('nennt die Kennung des gespeicherten Laufs', () => { |
| 1432 | // Der Verlauf enthält den eben beendeten Lauf bereits. Ohne die Kennung |
| 1433 | // verglichen ihn Auswertung und Anzeige mit sich selbst. |
| 1434 | const ergebnis = auswerten('standard', bogen(16, 4)); |
| 1435 | |
| 1436 | expect(ergebnis.laufId).toBe(pruefung.verlauf(profilId)[0]?.id); |
| 1437 | }); |
| 1438 | |
| 1439 | it('übersteht eine beschädigte Themenanalyse, ohne den Verlauf zu verlieren', () => { |
| 1440 | /* Die Datei liegt im `userData`-Verzeichnis. Was nicht als Eintrag lesbar |
| 1441 | ist, fällt weg – schlimmstenfalls fehlt einem Lauf die Aufschlüsselung, |
| 1442 | statt dass die Anzeige ausfällt. */ |
| 1443 | auswerten('standard', bogen(16, 4)); |
| 1444 | db.prepare("UPDATE pruefung_lauf SET bereiche = '{kaputt'").run(); |
| 1445 | |
| 1446 | const verlauf = pruefung.verlauf(profilId); |
| 1447 | |
| 1448 | expect(verlauf).toHaveLength(1); |
| 1449 | expect(verlauf[0]?.bereiche).toBeNull(); |
| 1450 | }); |
| 1451 | |
| 1452 | it('nimmt aus einer halb lesbaren Analyse nur die gültigen Einträge', () => { |
| 1453 | auswerten('standard', bogen(16, 4)); |
| 1454 | db.prepare( |
| 1455 | `UPDATE pruefung_lauf SET bereiche = '[ |
| 1456 | {"bereich":"I.1","titel":"Begriffe","gesamt":4,"richtig":3}, |
| 1457 | {"bereich":"I.2","titel":"Rechte","gesamt":2,"richtig":5}, |
| 1458 | {"bereich":"I.3","gesamt":2,"richtig":1}, |
| 1459 | "unfug" |
| 1460 | ]'`, |
| 1461 | ).run(); |
| 1462 | |
| 1463 | // Mehr richtige als gestellte Fragen, fehlender Titel, kein Objekt: weg. |
| 1464 | expect(pruefung.verlauf(profilId)[0]?.bereiche).toEqual([ |
| 1465 | { bereich: 'I.1', titel: 'Begriffe', gesamt: 4, richtig: 3 }, |
| 1466 | ]); |
| 1467 | }); |
| 1468 | }); |
| 1469 | |
| 1470 | // ─── Migration ────────────────────────────────────────────────────────────── |
| 1471 | |
| 1472 | describe('Migration auf Schema 2', () => { |
| 1473 | function tabellen(verbindung: Database.Database): string[] { |
| 1474 | return verbindung |
| 1475 | .prepare<[], { name: string }>("SELECT name FROM sqlite_master WHERE type = 'table'") |
| 1476 | .all() |
| 1477 | .map((z) => z.name); |
| 1478 | } |
| 1479 | |
| 1480 | it('ergänzt pruefung_lauf, ohne einen bestehenden Lernstand zu verlieren', () => { |
| 1481 | const alt = new Database(':memory:'); |
| 1482 | verbindungen.push(alt); |
| 1483 | |
| 1484 | const vorher = new Lernstand(alt, KATALOG, { jetzt: () => uhr }); |
| 1485 | const pid = vorher.profile()[0]!.id; |
| 1486 | const gelernt = mcAus('I.2', 1)[0]!; |
| 1487 | vorher.antworten(pid, { |
| 1488 | frageId: gelernt.id, |
| 1489 | auswahl: richtigeLabels(gelernt), |
| 1490 | richtig: true, |
| 1491 | bewertung: 'gut', |
| 1492 | dauerMs: 1200, |
| 1493 | }); |
| 1494 | vorher.merken(pid, mcAus('I.3', 1)[0]!.id, true); |
| 1495 | |
| 1496 | // Stand einer Installation mit Schema-Version 1 herstellen. |
| 1497 | alt.exec('DROP TABLE pruefung_lauf'); |
| 1498 | alt.prepare('DELETE FROM schema_version WHERE version > 1').run(); |
| 1499 | |
| 1500 | expect(tabellen(alt)).not.toContain('pruefung_lauf'); |
| 1501 | |
| 1502 | const nachher = new Lernstand(alt, KATALOG, { jetzt: () => uhr }); |
| 1503 | |
| 1504 | expect(tabellen(alt)).toContain('pruefung_lauf'); |
| 1505 | expect( |
| 1506 | alt |
| 1507 | .prepare<[], { version: number }>('SELECT MAX(version) AS version FROM schema_version') |
| 1508 | .get()?.version, |
| 1509 | ).toBe(SCHEMA_VERSION); |
| 1510 | |
| 1511 | // Der bestehende Lernstand ist unversehrt. |
| 1512 | expect(nachher.profile()).toHaveLength(1); |
| 1513 | expect(nachher.frageStand(pid, gelernt.id)).toMatchObject({ versuche: 1, richtige: 1 }); |
| 1514 | expect(nachher.frageStand(pid, mcAus('I.3', 1)[0]!.id).gemerkt).toBe(true); |
| 1515 | expect(nachher.uebersicht(pid).beantwortet).toBe(1); |
| 1516 | |
| 1517 | // Und die Simulation lässt sich sofort benutzen. |
| 1518 | const migriert = new Pruefung(nachher, KATALOG, { jetzt: () => uhr }); |
| 1519 | migriert.auswerten(pid, auftrag({ profilId: 'standard' }), bogen(16, 4), 1000, false); |
| 1520 | |
| 1521 | expect(migriert.verlauf(pid)).toHaveLength(1); |
| 1522 | }); |
| 1523 | |
| 1524 | it('ergänzt die Zeitstufe und lässt ältere Läufe ohne sie stehen', () => { |
| 1525 | /* Die Zeitstufe eines Laufs von vor Schema-Version 4 ist nicht mehr zu |
| 1526 | ermitteln. Sie zu raten wäre schlechter, als sie offen zu lassen – |
| 1527 | sonst stünde in der Tabelle eine Angabe, die niemand gemessen hat. */ |
| 1528 | const alt = new Database(':memory:'); |
| 1529 | verbindungen.push(alt); |
| 1530 | |
| 1531 | const vorher = new Lernstand(alt, KATALOG, { jetzt: () => uhr }); |
| 1532 | const pid = vorher.profile()[0]!.id; |
| 1533 | const simulation = new Pruefung(vorher, KATALOG, { jetzt: () => uhr }); |
| 1534 | simulation.auswerten(pid, auftrag({ profilId: 'standard' }), bogen(16, 4), 1000, false); |
| 1535 | |
| 1536 | // Stand vor der Umstellung nachbilden: Spalte leeren, Version zurück. |
| 1537 | alt.prepare('UPDATE pruefung_lauf SET zeitmodus = NULL').run(); |
| 1538 | alt.prepare('DELETE FROM schema_version WHERE version > 3').run(); |
| 1539 | |
| 1540 | const nachher = new Lernstand(alt, KATALOG, { jetzt: () => uhr }); |
| 1541 | const spalten = alt |
| 1542 | .prepare<[], { name: string }>('PRAGMA table_info(pruefung_lauf)') |
| 1543 | .all() |
| 1544 | .map((z) => z.name); |
| 1545 | expect(spalten).toContain('zeitmodus'); |
| 1546 | |
| 1547 | const migriert = new Pruefung(nachher, KATALOG, { jetzt: () => uhr }); |
| 1548 | expect(migriert.verlauf(pid)[0]?.zeitmodus).toBeNull(); |
| 1549 | |
| 1550 | // Ein neuer Lauf trägt sie dagegen. |
| 1551 | migriert.auswerten( |
| 1552 | pid, |
| 1553 | auftrag({ profilId: 'standard', zeitmodus: 'aus' }), |
| 1554 | bogen(16, 4), |
| 1555 | 1000, |
| 1556 | false, |
| 1557 | ); |
| 1558 | expect(migriert.verlauf(pid)[0]?.zeitmodus).toBe('aus'); |
| 1559 | }); |
| 1560 | |
| 1561 | it('ergänzt die Kennzahlen aus Fassung 10 und erfindet sie für alte Läufe nicht', () => { |
| 1562 | /* `unbeantwortet = 0` hieße „es blieb nichts offen“ – eine Behauptung über |
| 1563 | einen Lauf, von dem niemand weiß, wie er endete. `NULL` heißt „nicht |
| 1564 | festgehalten“, und die Oberfläche sagt genau das. */ |
| 1565 | const alt = new Database(':memory:'); |
| 1566 | verbindungen.push(alt); |
| 1567 | |
| 1568 | const vorher = new Lernstand(alt, KATALOG, { jetzt: () => uhr }); |
| 1569 | const pid = vorher.profile()[0]!.id; |
| 1570 | const simulation = new Pruefung(vorher, KATALOG, { jetzt: () => uhr }); |
| 1571 | simulation.auswerten(pid, auftrag({ profilId: 'standard' }), bogen(16, 4), 1000, false); |
| 1572 | |
| 1573 | // Stand vor der Umstellung nachbilden: Spalten leeren, Version zurück. |
| 1574 | alt |
| 1575 | .prepare( |
| 1576 | `UPDATE pruefung_lauf |
| 1577 | SET unbeantwortet = NULL, zeit_abgelaufen = NULL, |
| 1578 | bereiche = NULL, bestehens_quote = NULL`, |
| 1579 | ) |
| 1580 | .run(); |
| 1581 | alt.prepare('DELETE FROM schema_version WHERE version > 9').run(); |
| 1582 | |
| 1583 | const nachher = new Lernstand(alt, KATALOG, { jetzt: () => uhr }); |
| 1584 | const migriert = new Pruefung(nachher, KATALOG, { jetzt: () => uhr }); |
| 1585 | |
| 1586 | expect(migriert.verlauf(pid)[0]).toMatchObject({ |
| 1587 | unbeantwortet: null, |
| 1588 | zeitAbgelaufen: null, |
| 1589 | bereiche: null, |
| 1590 | bestehensQuote: null, |
| 1591 | // Die Dauer stand schon in Fassung 2 in der Tabelle. |
| 1592 | dauerMs: 1000, |
| 1593 | }); |
| 1594 | |
| 1595 | // Ein neuer Lauf trägt sie dagegen. |
| 1596 | uhr = new Date(new Date(START).getTime() + 3_600_000); |
| 1597 | migriert.auswerten(pid, auftrag({ profilId: 'standard' }), bogen(16, 4), 1000, false); |
| 1598 | |
| 1599 | expect(migriert.verlauf(pid)[0]).toMatchObject({ |
| 1600 | unbeantwortet: 0, |
| 1601 | zeitAbgelaufen: false, |
| 1602 | bestehensQuote: 0.8, |
| 1603 | }); |
| 1604 | expect(migriert.verlauf(pid)[0]?.bereiche).not.toBeNull(); |
| 1605 | }); |
| 1606 | |
| 1607 | it('legt die Spalten aus Fassung 10 auch auf einer Datenbank ohne sie an', () => { |
| 1608 | /* Der Schritt muss auch dann durchlaufen, wenn `pruefung_lauf` noch die |
| 1609 | alte Gestalt hat – sonst startet die Anwendung überhaupt nicht mehr. */ |
| 1610 | const alt = new Database(':memory:'); |
| 1611 | verbindungen.push(alt); |
| 1612 | |
| 1613 | const vorher = new Lernstand(alt, KATALOG, { jetzt: () => uhr }); |
| 1614 | const pid = vorher.profile()[0]!.id; |
| 1615 | |
| 1616 | alt.exec('DROP TABLE pruefung_lauf'); |
| 1617 | alt.prepare('DELETE FROM schema_version WHERE version > 1').run(); |
| 1618 | |
| 1619 | const nachher = new Lernstand(alt, KATALOG, { jetzt: () => uhr }); |
| 1620 | const spalten = alt |
| 1621 | .prepare<[], { name: string }>('PRAGMA table_info(pruefung_lauf)') |
| 1622 | .all() |
| 1623 | .map((z) => z.name); |
| 1624 | |
| 1625 | expect(spalten).toEqual( |
| 1626 | expect.arrayContaining(['unbeantwortet', 'zeit_abgelaufen', 'bereiche', 'bestehens_quote']), |
| 1627 | ); |
| 1628 | |
| 1629 | const migriert = new Pruefung(nachher, KATALOG, { jetzt: () => uhr }); |
| 1630 | migriert.auswerten(pid, auftrag({ profilId: 'standard' }), bogen(16, 4), 1000, false); |
| 1631 | |
| 1632 | expect(migriert.verlauf(pid)[0]?.bestehensQuote).toBeCloseTo(0.8); |
| 1633 | }); |
| 1634 | }); |
| 1635 | |
| 1636 | describe('Ein Lauf wird genau einmal ausgewertet', () => { |
| 1637 | /* |
| 1638 | Befund der Prüfrunde zu 0.27.2. `bogenPruefen` nimmt eine Antwortliste |
| 1639 | ungeprüft an, wenn kein Bogen bekannt ist — gedacht für den Neustart |
| 1640 | mitten im Lauf. Diesen Fall gibt es dort aber gar nicht mehr: |
| 1641 | `offenerBogen()` füllt den Merkposten beim Fortsetzen wieder. Erreicht |
| 1642 | wird der Zweig praktisch nur noch nach einer bereits erfolgten |
| 1643 | Auswertung — und dann nahm er alles an. |
| 1644 | |
| 1645 | Gemessen an einem Bogen mit 80 Fragen: `versuche` 80 → 160, Einträge in |
| 1646 | `pruefung_lauf` 1 → 2, und der zweite trug das Profil „fehlerpunkte“ mit |
| 1647 | 80 Fragen, obwohl dieses Profil 75 vorsieht und der Bogen als „standard“ |
| 1648 | gezogen wurde. |
| 1649 | |
| 1650 | `sichern()` hat gegen genau diese Verdopplung zwei Riegel; `auswerten` |
| 1651 | hatte keinen. |
| 1652 | */ |
| 1653 | function bogenAntworten(bogenListe: readonly { frageId: string }[]): Pruefungsantwort[] { |
| 1654 | return bogenListe.map((eintrag) => { |
| 1655 | const frage = KATALOG.fragen.find((f) => f.id === eintrag.frageId)!; |
| 1656 | return antwort(frage, true); |
| 1657 | }); |
| 1658 | } |
| 1659 | |
| 1660 | function versuche(): number { |
| 1661 | return ( |
| 1662 | db |
| 1663 | .prepare<[number], { s: number }>( |
| 1664 | 'SELECT COALESCE(SUM(versuche), 0) AS s FROM frage_stand WHERE profil_id = ?', |
| 1665 | ) |
| 1666 | .get(profilId) as { s: number } |
| 1667 | ).s; |
| 1668 | } |
| 1669 | |
| 1670 | it('weist einen zweiten Aufruf ab, statt alles ein zweites Mal zu buchen', () => { |
| 1671 | const gezogen = bogenZiehen(profilId, auftrag({ profilId: 'standard' })); |
| 1672 | const antworten = bogenAntworten(gezogen); |
| 1673 | |
| 1674 | auswerten('standard', antworten); |
| 1675 | const nachErstem = versuche(); |
| 1676 | |
| 1677 | expect(() => auswerten('standard', antworten)).toThrow(/bereits ausgewertet/u); |
| 1678 | expect(versuche()).toBe(nachErstem); |
| 1679 | expect((db.prepare('SELECT COUNT(*) AS n FROM pruefung_lauf').get() as { n: number }).n).toBe( |
| 1680 | 1, |
| 1681 | ); |
| 1682 | }); |
| 1683 | |
| 1684 | it('lässt den zweiten Aufruf auch kein fremdes Profil einschmuggeln', () => { |
| 1685 | /* Die Verschärfung: Nach der ersten Auswertung ist auch der gespeicherte |
| 1686 | Auftrag fort, der zweite Aufruf zöge also den des Renderers heran — mit |
| 1687 | anderer Bestehensgrenze für denselben Bogen. */ |
| 1688 | const gezogen = bogenZiehen(profilId, auftrag({ profilId: 'standard' })); |
| 1689 | const antworten = bogenAntworten(gezogen); |
| 1690 | auswerten('standard', antworten); |
| 1691 | |
| 1692 | expect(() => auswerten('fehlerpunkte', antworten)).toThrow(/bereits ausgewertet/u); |
| 1693 | expect( |
| 1694 | db |
| 1695 | .prepare<[], { pruefungsprofil: string }>('SELECT pruefungsprofil FROM pruefung_lauf') |
| 1696 | .all() |
| 1697 | .map((zeile) => zeile.pruefungsprofil), |
| 1698 | ).toEqual(['standard']); |
| 1699 | }); |
| 1700 | |
| 1701 | it('wertet einen nach Neustart fortgesetzten Lauf weiterhin aus', () => { |
| 1702 | /* |
| 1703 | Die Gegenprobe, und sie ist die wichtigere: Der Riegel darf die |
| 1704 | unterbrochene Sitzung nicht treffen. „Eine unterbrochene Sitzung soll |
| 1705 | niemandem seine Arbeit kosten“ — die Zeile des offenen Laufs ist dann |
| 1706 | noch da, und `offenerBogen()` füllt den Merkposten wieder. |
| 1707 | */ |
| 1708 | const gezogen = bogenZiehen(profilId, auftrag({ profilId: 'standard' })); |
| 1709 | const antworten = bogenAntworten(gezogen); |
| 1710 | |
| 1711 | /* Neustart nachgestellt: neue Instanz auf derselben Datenbank. */ |
| 1712 | const nachNeustart = new Pruefung(lernstand, KATALOG, { |
| 1713 | jetzt: () => uhr, |
| 1714 | zufall: zufallsfolge(7), |
| 1715 | warnen: (meldung) => warnungen.push(meldung), |
| 1716 | }); |
| 1717 | expect(nachNeustart.offenerBogen(profilId)).not.toBeNull(); |
| 1718 | |
| 1719 | expect(() => |
| 1720 | nachNeustart.auswerten(profilId, auftrag({ profilId: 'standard' }), antworten, 60_000, false), |
| 1721 | ).not.toThrow(); |
| 1722 | }); |
| 1723 | }); |