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 | // Ein zweiter Versuch mit demselben Bogen läuft in den Warnpfad, nicht in |
| 625 | // eine erneute Prüfung – der Bogen ist verbraucht. |
| 626 | expect(() => |
| 627 | pruefung.auswerten( |
| 628 | profilId, |
| 629 | auftrag({ profilId: 'frei', fragenAnzahl: 3 }), |
| 630 | antworten, |
| 631 | 1_000, |
| 632 | false, |
| 633 | ), |
| 634 | ).not.toThrow(); |
| 635 | }); |
| 636 | }); |
| 637 | |
| 638 | describe('pruefung:auswerten', () => { |
| 639 | it('rechnet Multiple Choice selbst nach und glaubt der Selbsteinschätzung nicht', () => { |
| 640 | const frage = mcAus('I.1', 1)[0]!; |
| 641 | const ergebnis = auswerten('standard', [ |
| 642 | { frageId: frage.id, auswahl: falscheAuswahl(frage), selbstAlsRichtig: true }, |
| 643 | ]); |
| 644 | |
| 645 | expect(ergebnis.richtig).toBe(0); |
| 646 | expect(ergebnis.falsch).toBe(1); |
| 647 | expect(ergebnis.fehlerIds).toEqual([frage.id]); |
| 648 | }); |
| 649 | |
| 650 | it('wertet eine unvollständige Mehrfachauswahl als falsch', () => { |
| 651 | const mehrfach = KATALOG.fragen.find((f) => f.typ === 'mc' && richtigeLabels(f).length > 1); |
| 652 | const soll = richtigeLabels(mehrfach!); |
| 653 | const ergebnis = auswerten('standard', [ |
| 654 | { frageId: mehrfach!.id, auswahl: [soll[0]!] }, |
| 655 | { frageId: mcAus('I.2', 1)[0]!.id, auswahl: richtigeLabels(mcAus('I.2', 1)[0]!) }, |
| 656 | ]); |
| 657 | |
| 658 | expect(soll.length).toBeGreaterThan(1); |
| 659 | expect(ergebnis.richtig).toBe(1); |
| 660 | expect(ergebnis.fehlerIds).toEqual([mehrfach!.id]); |
| 661 | expect(ergebnis.unbeantwortet).toBe(0); |
| 662 | }); |
| 663 | |
| 664 | it('zählt zu viel Angekreuztes als falsch', () => { |
| 665 | const einfach = mcAus('I.1', 1)[0]!; |
| 666 | const zuViel = [...richtigeLabels(einfach), ...falscheAuswahl(einfach)]; |
| 667 | const ergebnis = auswerten('standard', [{ frageId: einfach.id, auswahl: zuViel }]); |
| 668 | |
| 669 | expect(ergebnis.richtig).toBe(0); |
| 670 | }); |
| 671 | |
| 672 | it('weist unbeantwortete Fragen getrennt aus, ohne sie in „falsch“ mitzuzählen', () => { |
| 673 | /* |
| 674 | Die drei Zahlen bilden eine Zerlegung von `gesamt`. Steckten die |
| 675 | unbeantworteten zusätzlich in `falsch`, zeigte die Auswertung mehr |
| 676 | Fragen an, als der Bogen hat. Für die Wertung bleiben sie dennoch |
| 677 | nicht richtig – und in `fehlerIds` gehören sie, damit man sie üben kann. |
| 678 | */ |
| 679 | const mc = mcAus('I.1', 3); |
| 680 | const offen = KATALOG.fragen.find((f) => f.typ !== 'mc')!; |
| 681 | const ergebnis = auswerten('standard', [ |
| 682 | antwort(mc[0]!, true), |
| 683 | antwort(mc[1]!, false), |
| 684 | { frageId: mc[2]!.id, auswahl: [] }, |
| 685 | { frageId: offen.id, auswahl: [] }, |
| 686 | ]); |
| 687 | |
| 688 | expect(ergebnis.gesamt).toBe(4); |
| 689 | expect(ergebnis.richtig).toBe(1); |
| 690 | expect(ergebnis.falsch).toBe(1); |
| 691 | expect(ergebnis.unbeantwortet).toBe(2); |
| 692 | // Die Zerlegung geht auf. |
| 693 | expect(ergebnis.richtig + ergebnis.falsch + ergebnis.unbeantwortet).toBe(ergebnis.gesamt); |
| 694 | expect(ergebnis.fehlerIds).toEqual([mc[1]!.id, mc[2]!.id, offen.id]); |
| 695 | }); |
| 696 | |
| 697 | it('wertet offene Fragen nach der Selbsteinschätzung', () => { |
| 698 | const offene = KATALOG.fragen.filter((f) => f.typ !== 'mc').slice(0, 2); |
| 699 | const ergebnis = auswerten('dsb', [antwort(offene[0]!, true), antwort(offene[1]!, false)]); |
| 700 | |
| 701 | expect(ergebnis.richtig).toBe(1); |
| 702 | expect(ergebnis.unbeantwortet).toBe(0); |
| 703 | }); |
| 704 | |
| 705 | it('füllt die Bereiche je Abschnitt bzw. Kapitel in Katalogreihenfolge', () => { |
| 706 | const ergebnis = auswerten('standard', [ |
| 707 | ...mcAus('I.2', 2).map((f) => antwort(f, true)), |
| 708 | ...mcAus('I.1', 2).map((f) => antwort(f, false)), |
| 709 | ...mcAus('III', 1).map((f) => antwort(f, true)), |
| 710 | ]); |
| 711 | |
| 712 | expect(ergebnis.bereiche).toEqual([ |
| 713 | { bereich: 'I.1', titel: 'Begriffe des Waffenrechts', gesamt: 2, richtig: 0 }, |
| 714 | { bereich: 'I.2', titel: 'Rechte und Pflichten', gesamt: 2, richtig: 2 }, |
| 715 | { bereich: 'III', titel: 'Handhabung von Schusswaffen', gesamt: 1, richtig: 1 }, |
| 716 | ]); |
| 717 | }); |
| 718 | |
| 719 | it('lässt das K.-o.-Kriterium unabhängig vom Gesamtergebnis greifen', () => { |
| 720 | // Profil „fehlerpunkte“: höchstens 15 Fehler, aber höchstens 2 in I.5. |
| 721 | const antworten = [ |
| 722 | ...mcAus('I.1', 20).map((f) => antwort(f, true)), |
| 723 | ...mcAus('I.5', 3).map((f) => antwort(f, false)), |
| 724 | ]; |
| 725 | const ergebnis = auswerten('fehlerpunkte', antworten); |
| 726 | |
| 727 | expect(ergebnis.falsch).toBe(3); |
| 728 | expect(ergebnis.urteil).toBe('nicht_bestanden'); |
| 729 | expect(ergebnis.verletzteKriterien).toEqual([ |
| 730 | 'Notwehr und Notstand (3 Fehler, höchstens 2 zulässig)', |
| 731 | ]); |
| 732 | expect(ergebnis.begruendung).toContain('Notwehr und Notstand'); |
| 733 | }); |
| 734 | |
| 735 | it('lässt zwei Fehler in Notwehr und Notstand durchgehen', () => { |
| 736 | const ergebnis = auswerten('fehlerpunkte', [ |
| 737 | ...mcAus('I.1', 20).map((f) => antwort(f, true)), |
| 738 | ...mcAus('I.5', 2).map((f) => antwort(f, false)), |
| 739 | ]); |
| 740 | |
| 741 | expect(ergebnis.verletzteKriterien).toEqual([]); |
| 742 | expect(ergebnis.urteil).toBe('bestanden'); |
| 743 | }); |
| 744 | |
| 745 | it.each([ |
| 746 | ['standard', 16, 4, 'bestanden'], |
| 747 | ['standard', 15, 5, 'nicht_bestanden'], |
| 748 | ['bdmp', 14, 6, 'bestanden'], |
| 749 | ['bdmp', 13, 7, 'nicht_bestanden'], |
| 750 | ['dsb', 15, 5, 'bestanden'], |
| 751 | ['dsb', 13, 7, 'nachpruefung'], |
| 752 | ['dsb', 12, 8, 'nachpruefung'], |
| 753 | ['dsb', 11, 9, 'nicht_bestanden'], |
| 754 | ['fehlerpunkte', 15, 5, 'bestanden'], |
| 755 | ['fehlerpunkte', 4, 16, 'nicht_bestanden'], |
| 756 | ])('bildet für %s bei %i von 20 richtigen das Urteil %s', (kennung, richtig, falsch, urteil) => { |
| 757 | const ergebnis = auswerten(kennung, bogen(richtig, falsch)); |
| 758 | |
| 759 | expect(ergebnis.gesamt).toBe(20); |
| 760 | expect(ergebnis.richtig).toBe(richtig); |
| 761 | expect(ergebnis.quote).toBeCloseTo(richtig / 20, 10); |
| 762 | expect(ergebnis.urteil).toBe(urteil); |
| 763 | }); |
| 764 | |
| 765 | it('baut die Begründung aus Trefferzahlen, nicht aus gerundeten Prozenten', () => { |
| 766 | /* 149 von 200 sind 74,5 Prozent – kaufmännisch gerundet 75. Aus |
| 767 | Prozentwerten gebaut lautete die Anzeige „Nicht bestanden. 75 Prozent |
| 768 | richtig, nötig waren 75 Prozent." Ein Satz, der sich selbst |
| 769 | widerspricht. */ |
| 770 | const knapp = urteilBilden(profilFinden('frei')!, 149, 200, []); |
| 771 | expect(knapp.urteil).toBe('nicht_bestanden'); |
| 772 | expect(knapp.begruendung).toBe('149 von 200 richtig, nötig waren 150.'); |
| 773 | |
| 774 | const gerade = urteilBilden(profilFinden('frei')!, 150, 200, []); |
| 775 | expect(gerade.urteil).toBe('bestanden'); |
| 776 | expect(gerade.begruendung).toBe('150 von 200 richtig, nötig waren 150.'); |
| 777 | }); |
| 778 | |
| 779 | it('rundet die nötige Trefferzahl nicht durch Gleitkomma hoch', () => { |
| 780 | // 0,8 * 80 kann als 64,000000000000006 herauskommen – aufgerundet 65. |
| 781 | const ergebnis = urteilBilden(profilFinden('standard')!, 64, 80, []); |
| 782 | |
| 783 | expect(ergebnis.urteil).toBe('bestanden'); |
| 784 | expect(ergebnis.begruendung).toBe('64 von 80 richtig, nötig waren 64.'); |
| 785 | }); |
| 786 | |
| 787 | it('nennt die Grauzone des DSB-Profils in der Begründung', () => { |
| 788 | const ergebnis = auswerten('dsb', bogen(13, 7)); |
| 789 | |
| 790 | expect(ergebnis.urteil).toBe('nachpruefung'); |
| 791 | expect(ergebnis.begruendung).toContain('mündliche Nachprüfung'); |
| 792 | }); |
| 793 | |
| 794 | it('rechnet die Bestehensgrenze eines anpassbaren Profils nach', () => { |
| 795 | const streng = pruefung.auswerten( |
| 796 | profilId, |
| 797 | auftrag({ profilId: 'frei', bestehensQuote: 0.9 }), |
| 798 | bogen(16, 4), |
| 799 | 1000, |
| 800 | false, |
| 801 | ); |
| 802 | |
| 803 | expect(streng.urteil).toBe('nicht_bestanden'); |
| 804 | |
| 805 | const milde = pruefung.auswerten( |
| 806 | profilId, |
| 807 | auftrag({ profilId: 'frei', bestehensQuote: 0.5 }), |
| 808 | bogen(16, 4), |
| 809 | 1000, |
| 810 | false, |
| 811 | ); |
| 812 | |
| 813 | expect(milde.urteil).toBe('bestanden'); |
| 814 | }); |
| 815 | |
| 816 | it('übernimmt Dauer und Zeitangabe unverändert', () => { |
| 817 | const ergebnis = pruefung.auswerten( |
| 818 | profilId, |
| 819 | auftrag({ profilId: 'standard' }), |
| 820 | bogen(10, 10), |
| 821 | 1_234_567, |
| 822 | true, |
| 823 | ); |
| 824 | |
| 825 | expect(ergebnis.dauerMs).toBe(1_234_567); |
| 826 | expect(ergebnis.zeitAbgelaufen).toBe(true); |
| 827 | expect(ergebnis.zeitpunkt).toBe(START); |
| 828 | expect(ergebnis.profilId).toBe('standard'); |
| 829 | }); |
| 830 | |
| 831 | it('weist unsaubere Nutzlasten ab', () => { |
| 832 | const frage = mcAus('I.1', 1)[0]!; |
| 833 | |
| 834 | expect(() => auswerten('standard', [])).toThrow(/mindestens eine Antwort/u); |
| 835 | expect(() => auswerten('standard', [antwort(frage, true), antwort(frage, true)])).toThrow( |
| 836 | /mehrfach im Bogen/u, |
| 837 | ); |
| 838 | expect(() => auswerten('standard', [{ frageId: 'gibt-es-nicht', auswahl: [] }])).toThrow( |
| 839 | /Unbekannte Frage-ID/u, |
| 840 | ); |
| 841 | expect(() => auswerten('standard', [{ frageId: frage.id, auswahl: ['z'] }])).toThrow( |
| 842 | /keine Antwortoption/u, |
| 843 | ); |
| 844 | expect(() => |
| 845 | pruefung.auswerten(profilId, auftrag({ profilId: 'standard' }), bogen(1, 0), -5, false), |
| 846 | ).toThrow(/dauerMs/u); |
| 847 | expect(() => |
| 848 | pruefung.auswerten(profilId, auftrag({ profilId: 'standard' }), bogen(1, 0), 'lange', false), |
| 849 | ).toThrow(/dauerMs/u); |
| 850 | }); |
| 851 | |
| 852 | it('lässt die Wiedervorlage nie aufgeschlagener Fragen unberührt', () => { |
| 853 | /* |
| 854 | Läuft die Zeit ab, schickt die Oberfläche auch für nie gezeigte Fragen |
| 855 | einen Eintrag mit leerer Auswahl. Diese gehören in die Historie – der |
| 856 | Bogen enthielt sie ja –, dürfen den Lernstand aber nicht zurückstufen: |
| 857 | Eine viermal sicher gewusste Frage wäre sonst allein deshalb wieder |
| 858 | fällig, weil ein Prüfungslauf nicht bis zu ihr kam. |
| 859 | */ |
| 860 | const [gezeigt, nieGezeigt] = mcAus('I.1', 2); |
| 861 | if (!gezeigt || !nieGezeigt) { |
| 862 | throw new Error('Zwei Fragen erwartet'); |
| 863 | } |
| 864 | |
| 865 | // Die zweite Frage viermal sicher beantworten, damit sie weit hinausrückt. |
| 866 | for (let i = 0; i < 4; i += 1) { |
| 867 | lernstand.antworten(profilId, { |
| 868 | frageId: nieGezeigt.id, |
| 869 | auswahl: nieGezeigt.optionen?.filter((o) => o.korrekt).map((o) => o.label) ?? [], |
| 870 | richtig: true, |
| 871 | bewertung: 'leicht', |
| 872 | dauerMs: 1_000, |
| 873 | }); |
| 874 | } |
| 875 | const vorher = lernstand.frageStand(profilId, nieGezeigt.id); |
| 876 | expect(vorher.faelligAb).not.toBeNull(); |
| 877 | |
| 878 | pruefung.auswerten( |
| 879 | profilId, |
| 880 | auftrag({ profilId: 'standard' }), |
| 881 | [antwort(gezeigt, true), { frageId: nieGezeigt.id, auswahl: [] }], |
| 882 | 60_000, |
| 883 | true, |
| 884 | ); |
| 885 | |
| 886 | const nachher = lernstand.frageStand(profilId, nieGezeigt.id); |
| 887 | expect(nachher.faelligAb).toBe(vorher.faelligAb); |
| 888 | expect(nachher.letzteBewertung).toBe('leicht'); |
| 889 | expect(nachher.versuche).toBe(vorher.versuche); |
| 890 | }); |
| 891 | |
| 892 | it('kappt eine übergroße Dauer, statt den Lauf zu verwerfen', () => { |
| 893 | /* |
| 894 | Wer ohne Zeitbegrenzung übt – der Nachteilsausgleich für alle, die mehr |
| 895 | Zeit brauchen – lässt das Fenster womöglich über Nacht offen. Diesen |
| 896 | Lauf zu verwerfen träfe genau die Gruppe, für die die Einstellung da |
| 897 | ist. Der Lauf wird gespeichert, die Dauer auf 24 Stunden gedeckelt. |
| 898 | */ |
| 899 | const ergebnis = pruefung.auswerten( |
| 900 | profilId, |
| 901 | auftrag({ profilId: 'standard' }), |
| 902 | bogen(3, 1), |
| 903 | 90_000_000, |
| 904 | false, |
| 905 | ); |
| 906 | |
| 907 | expect(ergebnis.dauerMs).toBe(86_400_000); |
| 908 | expect(ergebnis.richtig).toBe(3); |
| 909 | |
| 910 | const gespeichert = pruefung.verlauf(profilId); |
| 911 | expect(gespeichert).toHaveLength(1); |
| 912 | expect(gespeichert[0]?.gesamt).toBe(4); |
| 913 | }); |
| 914 | |
| 915 | it('rundet eine gemessene Dauer mit Nachkommastellen, statt sie abzuweisen', () => { |
| 916 | const ergebnis = pruefung.auswerten( |
| 917 | profilId, |
| 918 | auftrag({ profilId: 'standard' }), |
| 919 | bogen(1, 0), |
| 920 | 1234.6, |
| 921 | false, |
| 922 | ); |
| 923 | |
| 924 | expect(ergebnis.dauerMs).toBe(1235); |
| 925 | }); |
| 926 | }); |
| 927 | |
| 928 | // ─── Fortschreibung des Lernstands ────────────────────────────────────────── |
| 929 | |
| 930 | describe('Prüfungslauf im Lernstand', () => { |
| 931 | it('protokolliert jede Antwort wie eine Lernantwort', () => { |
| 932 | const antworten = bogen(3, 2); |
| 933 | auswerten('standard', antworten, 50_000); |
| 934 | |
| 935 | const anzahl = db |
| 936 | .prepare<[number], { n: number }>('SELECT COUNT(*) AS n FROM antwort_log WHERE profil_id = ?') |
| 937 | .get(profilId); |
| 938 | |
| 939 | expect(anzahl?.n).toBe(5); |
| 940 | expect(lernstand.uebersicht(profilId).beantwortet).toBe(5); |
| 941 | expect(lernstand.frageStand(profilId, antworten[0]!.frageId)).toMatchObject({ |
| 942 | versuche: 1, |
| 943 | richtige: 1, |
| 944 | letzteBewertung: 'gut', |
| 945 | }); |
| 946 | expect(lernstand.frageStand(profilId, antworten[4]!.frageId)).toMatchObject({ |
| 947 | versuche: 1, |
| 948 | richtige: 0, |
| 949 | letzteBewertung: 'nochmal', |
| 950 | }); |
| 951 | }); |
| 952 | |
| 953 | it('verteilt die gemessene Gesamtdauer gleichmäßig auf die Fragen', () => { |
| 954 | auswerten('standard', bogen(2, 2), 40_000); |
| 955 | |
| 956 | const dauern = db |
| 957 | .prepare<[number], { dauer_ms: number }>( |
| 958 | 'SELECT dauer_ms FROM antwort_log WHERE profil_id = ?', |
| 959 | ) |
| 960 | .all(profilId) |
| 961 | .map((z) => z.dauer_ms); |
| 962 | |
| 963 | expect(dauern).toEqual([10_000, 10_000, 10_000, 10_000]); |
| 964 | }); |
| 965 | |
| 966 | it('speichert nichts, wenn die Auswertung abbricht', () => { |
| 967 | const frage = mcAus('I.1', 1)[0]!; |
| 968 | |
| 969 | expect(() => auswerten('standard', [{ frageId: frage.id, auswahl: ['z'] }])).toThrow(); |
| 970 | expect(pruefung.verlauf(profilId)).toEqual([]); |
| 971 | expect(lernstand.uebersicht(profilId).beantwortet).toBe(0); |
| 972 | }); |
| 973 | }); |
| 974 | |
| 975 | // ─── Verlauf ──────────────────────────────────────────────────────────────── |
| 976 | |
| 977 | // ─── Unterbrochener Lauf ──────────────────────────────────────────────────── |
| 978 | |
| 979 | describe('Unterbrochener Lauf', () => { |
| 980 | /** Vollständige Antwortliste zum gerade gestarteten Bogen. */ |
| 981 | function bogenAntworten(bogenListe: readonly { frageId: string }[]): Pruefungsantwort[] { |
| 982 | return bogenListe.map((eintrag) => { |
| 983 | const frage = KATALOG.fragen.find((f) => f.id === eintrag.frageId)!; |
| 984 | return antwort(frage, true); |
| 985 | }); |
| 986 | } |
| 987 | |
| 988 | function offeneZeilen(): number { |
| 989 | return (db.prepare('SELECT COUNT(*) AS n FROM pruefung_offen').get() as { n: number }).n; |
| 990 | } |
| 991 | |
| 992 | it('legt den Bogen schon beim Starten ab, nicht erst beim ersten Sichern', () => { |
| 993 | /* Stürbe das Programm in der ersten Sekunde, wäre der Bogen sonst trotz |
| 994 | aller Vorsorge weg. */ |
| 995 | const gezogen = bogenZiehen(profilId, auftrag({ profilId: 'standard' })); |
| 996 | |
| 997 | const offen = pruefung.offenerBogen(profilId); |
| 998 | expect(offen).not.toBeNull(); |
| 999 | expect(offen?.bogen.map((e) => e.frageId)).toEqual(gezogen.map((e) => e.frageId)); |
| 1000 | expect(offen?.position).toBe(0); |
| 1001 | expect(offen?.verbrauchtMs).toBe(0); |
| 1002 | expect(offen?.phase).toBe('bearbeiten'); |
| 1003 | expect(offen?.profil.id).toBe('standard'); |
| 1004 | }); |
| 1005 | |
| 1006 | it('sichert Eingaben, Position, Phase und verbrauchte Zeit', () => { |
| 1007 | const gezogen = bogenZiehen(profilId, auftrag({ profilId: 'standard' })); |
| 1008 | const erste = gezogen[0]!.frageId; |
| 1009 | |
| 1010 | expect( |
| 1011 | pruefung.sichern(profilId, { |
| 1012 | eingaben: { [erste]: { auswahl: ['a'], freitext: '' } }, |
| 1013 | position: 5, |
| 1014 | phase: 'bearbeiten', |
| 1015 | zeitAbgelaufen: false, |
| 1016 | verbrauchtMs: 90_000, |
| 1017 | }), |
| 1018 | ).toBe(true); |
| 1019 | |
| 1020 | const offen = pruefung.offenerBogen(profilId); |
| 1021 | expect(offen?.eingaben[erste]).toEqual({ auswahl: ['a'], freitext: '' }); |
| 1022 | expect(offen?.position).toBe(5); |
| 1023 | expect(offen?.verbrauchtMs).toBe(90_000); |
| 1024 | }); |
| 1025 | |
| 1026 | it('unterscheidet nicht bewertet von als falsch bewertet', () => { |
| 1027 | /* Ein Ersatzwert `false` schriebe eine unbewertete offene Frage beim |
| 1028 | Fortsetzen als falsch fest. */ |
| 1029 | const gezogen = bogenZiehen(profilId, auftrag({ profilId: 'dsb' })); |
| 1030 | const a = gezogen[0]!.frageId; |
| 1031 | const b = gezogen[1]!.frageId; |
| 1032 | |
| 1033 | pruefung.sichern(profilId, { |
| 1034 | eingaben: { |
| 1035 | [a]: { auswahl: [], freitext: 'Text', selbst: false }, |
| 1036 | [b]: { auswahl: [], freitext: 'Text' }, |
| 1037 | }, |
| 1038 | position: 0, |
| 1039 | phase: 'nachbewertung', |
| 1040 | zeitAbgelaufen: false, |
| 1041 | verbrauchtMs: 0, |
| 1042 | }); |
| 1043 | |
| 1044 | const offen = pruefung.offenerBogen(profilId); |
| 1045 | expect(offen?.eingaben[a]?.selbst).toBe(false); |
| 1046 | expect(offen?.eingaben[b]).not.toHaveProperty('selbst'); |
| 1047 | expect(offen?.phase).toBe('nachbewertung'); |
| 1048 | }); |
| 1049 | |
| 1050 | it('löscht die Zeile beim Auswerten – in derselben Transaktion', () => { |
| 1051 | const gezogen = bogenZiehen(profilId, auftrag({ profilId: 'standard' })); |
| 1052 | expect(offeneZeilen()).toBe(1); |
| 1053 | |
| 1054 | auswerten('standard', bogenAntworten(gezogen)); |
| 1055 | |
| 1056 | expect(offeneZeilen()).toBe(0); |
| 1057 | expect(pruefung.offenerBogen(profilId)).toBeNull(); |
| 1058 | }); |
| 1059 | |
| 1060 | it('lässt eine verspätete Sicherung den gewerteten Lauf nicht wiederbeleben', () => { |
| 1061 | /* |
| 1062 | Der schwerste Fall: Das Sichern ist entprellt. Feuert ein Nachzügler |
| 1063 | nach der Abgabe, dürfte er die eben gelöschte Zeile nicht wieder |
| 1064 | anlegen – sonst böte die Anwendung beim nächsten Start dieselbe, |
| 1065 | bereits gewertete Prüfung zum Fortsetzen an, und jede Antwort ginge |
| 1066 | ein zweites Mal in die Wiedervorlage. |
| 1067 | */ |
| 1068 | const gezogen = bogenZiehen(profilId, auftrag({ profilId: 'standard' })); |
| 1069 | auswerten('standard', bogenAntworten(gezogen)); |
| 1070 | |
| 1071 | const angekommen = pruefung.sichern(profilId, { |
| 1072 | eingaben: {}, |
| 1073 | position: 3, |
| 1074 | phase: 'bearbeiten', |
| 1075 | zeitAbgelaufen: false, |
| 1076 | verbrauchtMs: 120_000, |
| 1077 | }); |
| 1078 | |
| 1079 | expect(angekommen).toBe(false); |
| 1080 | expect(offeneZeilen()).toBe(0); |
| 1081 | }); |
| 1082 | |
| 1083 | it('hinterlässt nach der Auswertung genau einen Verlaufseintrag', () => { |
| 1084 | const gezogen = bogenZiehen(profilId, auftrag({ profilId: 'standard' })); |
| 1085 | auswerten('standard', bogenAntworten(gezogen)); |
| 1086 | |
| 1087 | expect(pruefung.offenerBogen(profilId)).toBeNull(); |
| 1088 | expect((db.prepare('SELECT COUNT(*) AS n FROM pruefung_lauf').get() as { n: number }).n).toBe( |
| 1089 | 1, |
| 1090 | ); |
| 1091 | }); |
| 1092 | |
| 1093 | it('sichert nicht in einen Lauf, der inzwischen ein anderer ist', () => { |
| 1094 | /* |
| 1095 | Dafuer ist die Lauf-Kennung da, und nur dafuer: Die Zeile existiert |
| 1096 | noch, gehoert aber schon zum naechsten Bogen. Ein Nachzuegler aus dem |
| 1097 | alten Lauf duerfte dessen Position und Eingaben nicht in den neuen |
| 1098 | schreiben. |
| 1099 | */ |
| 1100 | pruefung.starten(profilId, auftrag({ profilId: 'standard' })); |
| 1101 | const alteKennung = ( |
| 1102 | db.prepare('SELECT lauf_id FROM pruefung_offen').get() as { lauf_id: string } |
| 1103 | ).lauf_id; |
| 1104 | |
| 1105 | const zweiter = bogenZiehen(profilId, auftrag({ profilId: 'bdmp' })); |
| 1106 | const neueKennung = ( |
| 1107 | db.prepare('SELECT lauf_id FROM pruefung_offen').get() as { lauf_id: string } |
| 1108 | ).lauf_id; |
| 1109 | expect(neueKennung).not.toBe(alteKennung); |
| 1110 | |
| 1111 | pruefung.sichern(profilId, { |
| 1112 | eingaben: { [zweiter[0]!.frageId]: { auswahl: ['a'], freitext: '' } }, |
| 1113 | position: 1, |
| 1114 | phase: 'bearbeiten', |
| 1115 | zeitAbgelaufen: false, |
| 1116 | verbrauchtMs: 1000, |
| 1117 | }); |
| 1118 | |
| 1119 | // Der neue Lauf nimmt seine eigene Sicherung an – die Kennung stimmt. |
| 1120 | expect(pruefung.offenerBogen(profilId)?.position).toBe(1); |
| 1121 | }); |
| 1122 | |
| 1123 | it('verwirft die Zeile auf ausdrücklichen Wunsch', () => { |
| 1124 | pruefung.starten(profilId, auftrag({ profilId: 'standard' })); |
| 1125 | |
| 1126 | pruefung.verwerfen(profilId); |
| 1127 | |
| 1128 | expect(pruefung.offenerBogen(profilId)).toBeNull(); |
| 1129 | expect(offeneZeilen()).toBe(0); |
| 1130 | }); |
| 1131 | |
| 1132 | it('führt je Profil höchstens einen offenen Lauf', () => { |
| 1133 | pruefung.starten(profilId, auftrag({ profilId: 'standard' })); |
| 1134 | const zweiter = bogenZiehen(profilId, auftrag({ profilId: 'bdmp' })); |
| 1135 | |
| 1136 | expect(offeneZeilen()).toBe(1); |
| 1137 | expect(pruefung.offenerBogen(profilId)?.bogen.map((e) => e.frageId)).toEqual( |
| 1138 | zweiter.map((e) => e.frageId), |
| 1139 | ); |
| 1140 | }); |
| 1141 | |
| 1142 | it('hält die Läufe verschiedener Profile auseinander', () => { |
| 1143 | const zweites = lernstand.profilAnlegen('Zweitprüfling'); |
| 1144 | pruefung.starten(profilId, auftrag({ profilId: 'standard' })); |
| 1145 | pruefung.starten(zweites.id, auftrag({ profilId: 'bdmp' })); |
| 1146 | |
| 1147 | expect(pruefung.offenerBogen(profilId)?.profil.id).toBe('standard'); |
| 1148 | expect(pruefung.offenerBogen(zweites.id)?.profil.id).toBe('bdmp'); |
| 1149 | }); |
| 1150 | |
| 1151 | it('nimmt das gelöschte Profil mit', () => { |
| 1152 | const zweites = lernstand.profilAnlegen('Zweitprüfling'); |
| 1153 | pruefung.starten(zweites.id, auftrag({ profilId: 'standard' })); |
| 1154 | |
| 1155 | lernstand.profilLoeschen(zweites.id); |
| 1156 | |
| 1157 | expect(offeneZeilen()).toBe(0); |
| 1158 | }); |
| 1159 | |
| 1160 | it('verschwindet beim vollständigen Zurücksetzen', () => { |
| 1161 | /* „Von vorn" und „die halbe Prüfung von gestern liegt noch da" passen |
| 1162 | nicht zusammen. */ |
| 1163 | pruefung.starten(profilId, auftrag({ profilId: 'standard' })); |
| 1164 | |
| 1165 | lernstand.zuruecksetzen(profilId, null); |
| 1166 | |
| 1167 | expect(offeneZeilen()).toBe(0); |
| 1168 | }); |
| 1169 | |
| 1170 | it('bleibt beim kapitelweisen Zurücksetzen stehen', () => { |
| 1171 | /* Ein Bogen geht über den ganzen Katalog und lässt sich nicht |
| 1172 | kapitelweise herausrechnen. */ |
| 1173 | pruefung.starten(profilId, auftrag({ profilId: 'standard' })); |
| 1174 | |
| 1175 | lernstand.zuruecksetzen(profilId, 'I'); |
| 1176 | |
| 1177 | expect(offeneZeilen()).toBe(1); |
| 1178 | }); |
| 1179 | |
| 1180 | it('verwirft einen Bogen, dessen Fragen es nicht mehr gibt', () => { |
| 1181 | /* Katalogwechsel zwischen Unterbrechung und Fortsetzen: Eine fehlende |
| 1182 | Frage ließe eine Lücke, und die Auswertung zählte gegen eine andere |
| 1183 | Gesamtzahl als die Anzeige. */ |
| 1184 | pruefung.starten(profilId, auftrag({ profilId: 'standard' })); |
| 1185 | db.prepare('UPDATE pruefung_offen SET bogen = ?').run( |
| 1186 | '[{"frageId":"gibt-es-nicht","optionsReihenfolge":["a"]}]', |
| 1187 | ); |
| 1188 | |
| 1189 | expect(pruefung.offenerBogen(profilId)).toBeNull(); |
| 1190 | expect(offeneZeilen()).toBe(0); |
| 1191 | expect(warnungen.join(' ')).toMatch(/gibt-es-nicht/u); |
| 1192 | }); |
| 1193 | |
| 1194 | it('verwirft eine Zeile mit ungültigem Auftrag', () => { |
| 1195 | /* Die Datei liegt im userData-Verzeichnis und ist von außen |
| 1196 | beschreibbar; ein erfundenes Profil darf nicht in die Wertung. */ |
| 1197 | pruefung.starten(profilId, auftrag({ profilId: 'standard' })); |
| 1198 | db.prepare('UPDATE pruefung_offen SET auftrag = ?').run('{"profilId":"gibt-es-nicht"}'); |
| 1199 | |
| 1200 | expect(pruefung.offenerBogen(profilId)).toBeNull(); |
| 1201 | expect(offeneZeilen()).toBe(0); |
| 1202 | }); |
| 1203 | |
| 1204 | it('nimmt beim Auswerten den gespeicherten Auftrag, nicht den eingereichten', () => { |
| 1205 | /* |
| 1206 | Sonst ließe sich ein fortgesetzter Bogen mit einem anderen Profil |
| 1207 | abgeben: Bogen und Urteilsregeln kämen aus verschiedenen Läufen. |
| 1208 | */ |
| 1209 | const gezogen = bogenZiehen(profilId, auftrag({ profilId: 'bdmp' })); |
| 1210 | |
| 1211 | const ergebnis = pruefung.auswerten( |
| 1212 | profilId, |
| 1213 | auftrag({ profilId: 'standard' }), |
| 1214 | bogenAntworten(gezogen), |
| 1215 | 60_000, |
| 1216 | false, |
| 1217 | ); |
| 1218 | |
| 1219 | expect(ergebnis.profilId).toBe('bdmp'); |
| 1220 | }); |
| 1221 | |
| 1222 | it('prüft die eingereichte Liste auch nach einem Neustart', () => { |
| 1223 | /* |
| 1224 | Bisher galt: Ist der Bogen nicht bekannt – etwa nach einem Neustart –, |
| 1225 | wird jede Liste ungeprüft angenommen. Mit der gespeicherten Zeile lässt |
| 1226 | sich der Bogen wiederherstellen, und die Prüfung greift wieder. |
| 1227 | */ |
| 1228 | const gezogen = bogenZiehen(profilId, auftrag({ profilId: 'standard' })); |
| 1229 | const antworten = bogenAntworten(gezogen); |
| 1230 | |
| 1231 | // Neustart nachbilden: neue Instanz, leerer Speicher, dieselbe Datenbank. |
| 1232 | const nachNeustart = new Pruefung(lernstand, KATALOG, { |
| 1233 | jetzt: () => uhr, |
| 1234 | zufall: zufallsfolge(7), |
| 1235 | warnen: (meldung) => warnungen.push(meldung), |
| 1236 | }); |
| 1237 | nachNeustart.offenerBogen(profilId); |
| 1238 | |
| 1239 | expect(() => |
| 1240 | nachNeustart.auswerten( |
| 1241 | profilId, |
| 1242 | auftrag({ profilId: 'standard' }), |
| 1243 | antworten.slice(0, 3), |
| 1244 | 1000, |
| 1245 | false, |
| 1246 | ), |
| 1247 | ).toThrow(/Bogen umfasste/u); |
| 1248 | }); |
| 1249 | |
| 1250 | it('weist eine Position außerhalb des Bogens ab', () => { |
| 1251 | pruefung.starten(profilId, auftrag({ profilId: 'standard' })); |
| 1252 | |
| 1253 | expect(() => |
| 1254 | pruefung.sichern(profilId, { |
| 1255 | eingaben: {}, |
| 1256 | position: 999, |
| 1257 | phase: 'bearbeiten', |
| 1258 | zeitAbgelaufen: false, |
| 1259 | verbrauchtMs: 0, |
| 1260 | }), |
| 1261 | ).toThrow(/position/u); |
| 1262 | }); |
| 1263 | |
| 1264 | it('weist eine Eingabe zu einer fremden Frage ab', () => { |
| 1265 | pruefung.starten(profilId, auftrag({ profilId: 'standard' })); |
| 1266 | |
| 1267 | expect(() => |
| 1268 | pruefung.sichern(profilId, { |
| 1269 | eingaben: { 'gibt-es-nicht': { auswahl: ['a'], freitext: '' } }, |
| 1270 | position: 0, |
| 1271 | phase: 'bearbeiten', |
| 1272 | zeitAbgelaufen: false, |
| 1273 | verbrauchtMs: 0, |
| 1274 | }), |
| 1275 | ).toThrow(/gehört nicht zu diesem Bogen/u); |
| 1276 | }); |
| 1277 | |
| 1278 | it('kappt einen übergroßen Freitext und eine unsinnige Dauer', () => { |
| 1279 | const gezogen = bogenZiehen(profilId, auftrag({ profilId: 'standard' })); |
| 1280 | const erste = gezogen[0]!.frageId; |
| 1281 | |
| 1282 | pruefung.sichern(profilId, { |
| 1283 | eingaben: { [erste]: { auswahl: [], freitext: 'x'.repeat(9000) } }, |
| 1284 | position: 0, |
| 1285 | phase: 'bearbeiten', |
| 1286 | zeitAbgelaufen: false, |
| 1287 | verbrauchtMs: 99 * 24 * 60 * 60 * 1000, |
| 1288 | }); |
| 1289 | |
| 1290 | const offen = pruefung.offenerBogen(profilId); |
| 1291 | expect(offen?.eingaben[erste]?.freitext).toHaveLength(4000); |
| 1292 | expect(offen?.verbrauchtMs).toBe(24 * 60 * 60 * 1000); |
| 1293 | }); |
| 1294 | }); |
| 1295 | |
| 1296 | describe('pruefung:verlauf', () => { |
| 1297 | it('liefert die Läufe mit dem neuesten zuerst', () => { |
| 1298 | auswerten('standard', bogen(16, 4)); |
| 1299 | uhr = new Date(new Date(START).getTime() + 3_600_000); |
| 1300 | auswerten('dsb', bogen(10, 10)); |
| 1301 | |
| 1302 | const verlauf = pruefung.verlauf(profilId); |
| 1303 | |
| 1304 | expect(verlauf.map((l) => l.profilId)).toEqual(['dsb', 'standard']); |
| 1305 | expect(verlauf[0]).toMatchObject({ |
| 1306 | profilName: 'Nach Art des Deutschen Schützenbundes', |
| 1307 | gesamt: 20, |
| 1308 | richtig: 10, |
| 1309 | quote: 0.5, |
| 1310 | urteil: 'nicht_bestanden', |
| 1311 | }); |
| 1312 | expect(verlauf[1]).toMatchObject({ |
| 1313 | profilName: 'Standard', |
| 1314 | richtig: 16, |
| 1315 | urteil: 'bestanden', |
| 1316 | }); |
| 1317 | expect(verlauf[0]!.id).not.toBe(verlauf[1]!.id); |
| 1318 | }); |
| 1319 | |
| 1320 | it('hält die Läufe verschiedener Lernprofile auseinander', () => { |
| 1321 | const zweites = lernstand.profilAnlegen('Zweite Person'); |
| 1322 | auswerten('standard', bogen(16, 4)); |
| 1323 | |
| 1324 | expect(pruefung.verlauf(profilId)).toHaveLength(1); |
| 1325 | expect(pruefung.verlauf(zweites.id)).toEqual([]); |
| 1326 | }); |
| 1327 | |
| 1328 | it('weist ein unbekanntes Lernprofil ab', () => { |
| 1329 | expect(() => pruefung.verlauf(4711)).toThrow(/Unbekanntes Profil/u); |
| 1330 | }); |
| 1331 | |
| 1332 | it('liefert die Dauer mit, die es seit jeher speichert', () => { |
| 1333 | // Die Spalte gibt es seit Schema-Version 2, `verlauf()` las sie bis 0.22.0 |
| 1334 | // nur nicht aus. Zeitnot ist ein eigenes Durchfallrisiko. |
| 1335 | auswerten('standard', bogen(16, 4), 71 * 60_000); |
| 1336 | |
| 1337 | expect(pruefung.verlauf(profilId)[0]?.dauerMs).toBe(71 * 60_000); |
| 1338 | }); |
| 1339 | |
| 1340 | it('hält fest, wie viele Fragen offen blieben und ob die Zeit ablief', () => { |
| 1341 | /* Ohne diese beiden Angaben sähe ein abgelaufener Lauf im Vergleich wie |
| 1342 | ein Wissenseinbruch aus: `quote` zählt Unbeantwortete wie falsch |
| 1343 | beantwortete. */ |
| 1344 | const fragen = mcAus('I.1', 4); |
| 1345 | pruefung.auswerten( |
| 1346 | profilId, |
| 1347 | auftrag({ profilId: 'standard' }), |
| 1348 | [ |
| 1349 | antwort(fragen[0]!, true), |
| 1350 | antwort(fragen[1]!, true), |
| 1351 | { frageId: fragen[2]!.id, auswahl: [] }, |
| 1352 | { frageId: fragen[3]!.id, auswahl: [] }, |
| 1353 | ], |
| 1354 | 60_000, |
| 1355 | true, |
| 1356 | ); |
| 1357 | |
| 1358 | expect(pruefung.verlauf(profilId)[0]).toMatchObject({ |
| 1359 | unbeantwortet: 2, |
| 1360 | zeitAbgelaufen: true, |
| 1361 | quote: 0.5, |
| 1362 | }); |
| 1363 | }); |
| 1364 | |
| 1365 | it('speichert die Themenanalyse des Laufs mit', () => { |
| 1366 | const fragen = [...mcAus('I.1', 2), ...mcAus('I.2', 2)]; |
| 1367 | pruefung.auswerten( |
| 1368 | profilId, |
| 1369 | auftrag({ profilId: 'standard' }), |
| 1370 | [ |
| 1371 | antwort(fragen[0]!, true), |
| 1372 | antwort(fragen[1]!, false), |
| 1373 | antwort(fragen[2]!, true), |
| 1374 | antwort(fragen[3]!, true), |
| 1375 | ], |
| 1376 | 60_000, |
| 1377 | false, |
| 1378 | ); |
| 1379 | |
| 1380 | const bereiche = pruefung.verlauf(profilId)[0]?.bereiche; |
| 1381 | |
| 1382 | expect(bereiche).toHaveLength(2); |
| 1383 | expect(bereiche?.[0]).toMatchObject({ bereich: 'I.1', gesamt: 2, richtig: 1 }); |
| 1384 | expect(bereiche?.[1]).toMatchObject({ bereich: 'I.2', gesamt: 2, richtig: 2 }); |
| 1385 | /* Der Titel reist mit: Der Verlauf muss ihn auch dann noch nennen können, |
| 1386 | wenn der Katalog den Abschnitt später anders benennt. */ |
| 1387 | expect(bereiche?.[0]?.titel).toBe(KATALOG.kapitel[0]?.abschnitte[0]?.titel); |
| 1388 | }); |
| 1389 | |
| 1390 | it('speichert die Bestehensgrenze des Laufs, nicht die des Vertrags', () => { |
| 1391 | /* Beim frei eingestellten Profil sind die gewählten Werte nach dem Lauf |
| 1392 | fort. Holte die Anzeige die Grenze hinterher aus dem Vertrag, bekämen |
| 1393 | genau diese Läufe eine falsche. */ |
| 1394 | auswerten('standard', bogen(16, 4)); |
| 1395 | expect(pruefung.verlauf(profilId)[0]?.bestehensQuote).toBeCloseTo(0.8); |
| 1396 | |
| 1397 | uhr = new Date(new Date(START).getTime() + 3_600_000); |
| 1398 | pruefung.auswerten( |
| 1399 | profilId, |
| 1400 | auftrag({ profilId: 'frei', bestehensQuote: 0.5 }), |
| 1401 | bogen(2, 2), |
| 1402 | 60_000, |
| 1403 | false, |
| 1404 | ); |
| 1405 | expect(pruefung.verlauf(profilId)[0]?.bestehensQuote).toBeCloseTo(0.5); |
| 1406 | }); |
| 1407 | |
| 1408 | it('rechnet eine Fehlerpunktegrenze in eine Quote um', () => { |
| 1409 | // 75 Fragen bei höchstens 15 Fehlern sind 60 von 75 – dieselbe Bedingung, |
| 1410 | // anders geschrieben. Ein Bogen von 20 Fragen ergibt entsprechend 5 von 20. |
| 1411 | pruefung.auswerten( |
| 1412 | profilId, |
| 1413 | auftrag({ profilId: 'fehlerpunkte' }), |
| 1414 | bogen(16, 4), |
| 1415 | 60_000, |
| 1416 | false, |
| 1417 | ); |
| 1418 | |
| 1419 | expect(pruefung.verlauf(profilId)[0]?.bestehensQuote).toBeCloseTo(0.25); |
| 1420 | }); |
| 1421 | |
| 1422 | it('nennt die Kennung des gespeicherten Laufs', () => { |
| 1423 | // Der Verlauf enthält den eben beendeten Lauf bereits. Ohne die Kennung |
| 1424 | // verglichen ihn Auswertung und Anzeige mit sich selbst. |
| 1425 | const ergebnis = auswerten('standard', bogen(16, 4)); |
| 1426 | |
| 1427 | expect(ergebnis.laufId).toBe(pruefung.verlauf(profilId)[0]?.id); |
| 1428 | }); |
| 1429 | |
| 1430 | it('übersteht eine beschädigte Themenanalyse, ohne den Verlauf zu verlieren', () => { |
| 1431 | /* Die Datei liegt im `userData`-Verzeichnis. Was nicht als Eintrag lesbar |
| 1432 | ist, fällt weg – schlimmstenfalls fehlt einem Lauf die Aufschlüsselung, |
| 1433 | statt dass die Anzeige ausfällt. */ |
| 1434 | auswerten('standard', bogen(16, 4)); |
| 1435 | db.prepare("UPDATE pruefung_lauf SET bereiche = '{kaputt'").run(); |
| 1436 | |
| 1437 | const verlauf = pruefung.verlauf(profilId); |
| 1438 | |
| 1439 | expect(verlauf).toHaveLength(1); |
| 1440 | expect(verlauf[0]?.bereiche).toBeNull(); |
| 1441 | }); |
| 1442 | |
| 1443 | it('nimmt aus einer halb lesbaren Analyse nur die gültigen Einträge', () => { |
| 1444 | auswerten('standard', bogen(16, 4)); |
| 1445 | db.prepare( |
| 1446 | `UPDATE pruefung_lauf SET bereiche = '[ |
| 1447 | {"bereich":"I.1","titel":"Begriffe","gesamt":4,"richtig":3}, |
| 1448 | {"bereich":"I.2","titel":"Rechte","gesamt":2,"richtig":5}, |
| 1449 | {"bereich":"I.3","gesamt":2,"richtig":1}, |
| 1450 | "unfug" |
| 1451 | ]'`, |
| 1452 | ).run(); |
| 1453 | |
| 1454 | // Mehr richtige als gestellte Fragen, fehlender Titel, kein Objekt: weg. |
| 1455 | expect(pruefung.verlauf(profilId)[0]?.bereiche).toEqual([ |
| 1456 | { bereich: 'I.1', titel: 'Begriffe', gesamt: 4, richtig: 3 }, |
| 1457 | ]); |
| 1458 | }); |
| 1459 | }); |
| 1460 | |
| 1461 | // ─── Migration ────────────────────────────────────────────────────────────── |
| 1462 | |
| 1463 | describe('Migration auf Schema 2', () => { |
| 1464 | function tabellen(verbindung: Database.Database): string[] { |
| 1465 | return verbindung |
| 1466 | .prepare<[], { name: string }>("SELECT name FROM sqlite_master WHERE type = 'table'") |
| 1467 | .all() |
| 1468 | .map((z) => z.name); |
| 1469 | } |
| 1470 | |
| 1471 | it('ergänzt pruefung_lauf, ohne einen bestehenden Lernstand zu verlieren', () => { |
| 1472 | const alt = new Database(':memory:'); |
| 1473 | verbindungen.push(alt); |
| 1474 | |
| 1475 | const vorher = new Lernstand(alt, KATALOG, { jetzt: () => uhr }); |
| 1476 | const pid = vorher.profile()[0]!.id; |
| 1477 | const gelernt = mcAus('I.2', 1)[0]!; |
| 1478 | vorher.antworten(pid, { |
| 1479 | frageId: gelernt.id, |
| 1480 | auswahl: richtigeLabels(gelernt), |
| 1481 | richtig: true, |
| 1482 | bewertung: 'gut', |
| 1483 | dauerMs: 1200, |
| 1484 | }); |
| 1485 | vorher.merken(pid, mcAus('I.3', 1)[0]!.id, true); |
| 1486 | |
| 1487 | // Stand einer Installation mit Schema-Version 1 herstellen. |
| 1488 | alt.exec('DROP TABLE pruefung_lauf'); |
| 1489 | alt.prepare('DELETE FROM schema_version WHERE version > 1').run(); |
| 1490 | |
| 1491 | expect(tabellen(alt)).not.toContain('pruefung_lauf'); |
| 1492 | |
| 1493 | const nachher = new Lernstand(alt, KATALOG, { jetzt: () => uhr }); |
| 1494 | |
| 1495 | expect(tabellen(alt)).toContain('pruefung_lauf'); |
| 1496 | expect( |
| 1497 | alt |
| 1498 | .prepare<[], { version: number }>('SELECT MAX(version) AS version FROM schema_version') |
| 1499 | .get()?.version, |
| 1500 | ).toBe(SCHEMA_VERSION); |
| 1501 | |
| 1502 | // Der bestehende Lernstand ist unversehrt. |
| 1503 | expect(nachher.profile()).toHaveLength(1); |
| 1504 | expect(nachher.frageStand(pid, gelernt.id)).toMatchObject({ versuche: 1, richtige: 1 }); |
| 1505 | expect(nachher.frageStand(pid, mcAus('I.3', 1)[0]!.id).gemerkt).toBe(true); |
| 1506 | expect(nachher.uebersicht(pid).beantwortet).toBe(1); |
| 1507 | |
| 1508 | // Und die Simulation lässt sich sofort benutzen. |
| 1509 | const migriert = new Pruefung(nachher, KATALOG, { jetzt: () => uhr }); |
| 1510 | migriert.auswerten(pid, auftrag({ profilId: 'standard' }), bogen(16, 4), 1000, false); |
| 1511 | |
| 1512 | expect(migriert.verlauf(pid)).toHaveLength(1); |
| 1513 | }); |
| 1514 | |
| 1515 | it('ergänzt die Zeitstufe und lässt ältere Läufe ohne sie stehen', () => { |
| 1516 | /* Die Zeitstufe eines Laufs von vor Schema-Version 4 ist nicht mehr zu |
| 1517 | ermitteln. Sie zu raten wäre schlechter, als sie offen zu lassen – |
| 1518 | sonst stünde in der Tabelle eine Angabe, die niemand gemessen hat. */ |
| 1519 | const alt = new Database(':memory:'); |
| 1520 | verbindungen.push(alt); |
| 1521 | |
| 1522 | const vorher = new Lernstand(alt, KATALOG, { jetzt: () => uhr }); |
| 1523 | const pid = vorher.profile()[0]!.id; |
| 1524 | const simulation = new Pruefung(vorher, KATALOG, { jetzt: () => uhr }); |
| 1525 | simulation.auswerten(pid, auftrag({ profilId: 'standard' }), bogen(16, 4), 1000, false); |
| 1526 | |
| 1527 | // Stand vor der Umstellung nachbilden: Spalte leeren, Version zurück. |
| 1528 | alt.prepare('UPDATE pruefung_lauf SET zeitmodus = NULL').run(); |
| 1529 | alt.prepare('DELETE FROM schema_version WHERE version > 3').run(); |
| 1530 | |
| 1531 | const nachher = new Lernstand(alt, KATALOG, { jetzt: () => uhr }); |
| 1532 | const spalten = alt |
| 1533 | .prepare<[], { name: string }>('PRAGMA table_info(pruefung_lauf)') |
| 1534 | .all() |
| 1535 | .map((z) => z.name); |
| 1536 | expect(spalten).toContain('zeitmodus'); |
| 1537 | |
| 1538 | const migriert = new Pruefung(nachher, KATALOG, { jetzt: () => uhr }); |
| 1539 | expect(migriert.verlauf(pid)[0]?.zeitmodus).toBeNull(); |
| 1540 | |
| 1541 | // Ein neuer Lauf trägt sie dagegen. |
| 1542 | migriert.auswerten( |
| 1543 | pid, |
| 1544 | auftrag({ profilId: 'standard', zeitmodus: 'aus' }), |
| 1545 | bogen(16, 4), |
| 1546 | 1000, |
| 1547 | false, |
| 1548 | ); |
| 1549 | expect(migriert.verlauf(pid)[0]?.zeitmodus).toBe('aus'); |
| 1550 | }); |
| 1551 | |
| 1552 | it('ergänzt die Kennzahlen aus Fassung 10 und erfindet sie für alte Läufe nicht', () => { |
| 1553 | /* `unbeantwortet = 0` hieße „es blieb nichts offen“ – eine Behauptung über |
| 1554 | einen Lauf, von dem niemand weiß, wie er endete. `NULL` heißt „nicht |
| 1555 | festgehalten“, und die Oberfläche sagt genau das. */ |
| 1556 | const alt = new Database(':memory:'); |
| 1557 | verbindungen.push(alt); |
| 1558 | |
| 1559 | const vorher = new Lernstand(alt, KATALOG, { jetzt: () => uhr }); |
| 1560 | const pid = vorher.profile()[0]!.id; |
| 1561 | const simulation = new Pruefung(vorher, KATALOG, { jetzt: () => uhr }); |
| 1562 | simulation.auswerten(pid, auftrag({ profilId: 'standard' }), bogen(16, 4), 1000, false); |
| 1563 | |
| 1564 | // Stand vor der Umstellung nachbilden: Spalten leeren, Version zurück. |
| 1565 | alt |
| 1566 | .prepare( |
| 1567 | `UPDATE pruefung_lauf |
| 1568 | SET unbeantwortet = NULL, zeit_abgelaufen = NULL, |
| 1569 | bereiche = NULL, bestehens_quote = NULL`, |
| 1570 | ) |
| 1571 | .run(); |
| 1572 | alt.prepare('DELETE FROM schema_version WHERE version > 9').run(); |
| 1573 | |
| 1574 | const nachher = new Lernstand(alt, KATALOG, { jetzt: () => uhr }); |
| 1575 | const migriert = new Pruefung(nachher, KATALOG, { jetzt: () => uhr }); |
| 1576 | |
| 1577 | expect(migriert.verlauf(pid)[0]).toMatchObject({ |
| 1578 | unbeantwortet: null, |
| 1579 | zeitAbgelaufen: null, |
| 1580 | bereiche: null, |
| 1581 | bestehensQuote: null, |
| 1582 | // Die Dauer stand schon in Fassung 2 in der Tabelle. |
| 1583 | dauerMs: 1000, |
| 1584 | }); |
| 1585 | |
| 1586 | // Ein neuer Lauf trägt sie dagegen. |
| 1587 | uhr = new Date(new Date(START).getTime() + 3_600_000); |
| 1588 | migriert.auswerten(pid, auftrag({ profilId: 'standard' }), bogen(16, 4), 1000, false); |
| 1589 | |
| 1590 | expect(migriert.verlauf(pid)[0]).toMatchObject({ |
| 1591 | unbeantwortet: 0, |
| 1592 | zeitAbgelaufen: false, |
| 1593 | bestehensQuote: 0.8, |
| 1594 | }); |
| 1595 | expect(migriert.verlauf(pid)[0]?.bereiche).not.toBeNull(); |
| 1596 | }); |
| 1597 | |
| 1598 | it('legt die Spalten aus Fassung 10 auch auf einer Datenbank ohne sie an', () => { |
| 1599 | /* Der Schritt muss auch dann durchlaufen, wenn `pruefung_lauf` noch die |
| 1600 | alte Gestalt hat – sonst startet die Anwendung überhaupt nicht mehr. */ |
| 1601 | const alt = new Database(':memory:'); |
| 1602 | verbindungen.push(alt); |
| 1603 | |
| 1604 | const vorher = new Lernstand(alt, KATALOG, { jetzt: () => uhr }); |
| 1605 | const pid = vorher.profile()[0]!.id; |
| 1606 | |
| 1607 | alt.exec('DROP TABLE pruefung_lauf'); |
| 1608 | alt.prepare('DELETE FROM schema_version WHERE version > 1').run(); |
| 1609 | |
| 1610 | const nachher = new Lernstand(alt, KATALOG, { jetzt: () => uhr }); |
| 1611 | const spalten = alt |
| 1612 | .prepare<[], { name: string }>('PRAGMA table_info(pruefung_lauf)') |
| 1613 | .all() |
| 1614 | .map((z) => z.name); |
| 1615 | |
| 1616 | expect(spalten).toEqual( |
| 1617 | expect.arrayContaining(['unbeantwortet', 'zeit_abgelaufen', 'bereiche', 'bestehens_quote']), |
| 1618 | ); |
| 1619 | |
| 1620 | const migriert = new Pruefung(nachher, KATALOG, { jetzt: () => uhr }); |
| 1621 | migriert.auswerten(pid, auftrag({ profilId: 'standard' }), bogen(16, 4), 1000, false); |
| 1622 | |
| 1623 | expect(migriert.verlauf(pid)[0]?.bestehensQuote).toBeCloseTo(0.8); |
| 1624 | }); |
| 1625 | }); |