waffensachkunde
Waffensachkunde – Lernsoftware für die Sachkundeprüfung nach § 7 WaffG. Barrierefrei, offline, EUPL-1.2.
/ app tests reifeverlauf.test.ts
| 1 | // @vitest-environment node |
| 2 | /** |
| 3 | * Der nachgerechnete Reifegrad-Verlauf. |
| 4 | * |
| 5 | * ## Die eine Zusicherung, um die es hier geht |
| 6 | * |
| 7 | * Der Verlauf entsteht aus dem Antwortprotokoll, die Ampel aus |
| 8 | * `frage_stand`. Das sind zwei Wege zu derselben Zahl – und zwei Wege zu |
| 9 | * derselben Zahl sind in diesem Projekt der Anfang eines Befundes, nicht ein |
| 10 | * Komfortmerkmal. Ein Verlauf, dessen letzter Punkt neben der Ampel eine |
| 11 | * andere Zahl zeigte, wäre schlimmer als gar keiner. |
| 12 | * |
| 13 | * Deshalb steht die Gleichheit hier an erster Stelle, und zwar nicht an einem |
| 14 | * ausgedachten Protokoll, sondern an einem, das dieselbe `antworten()`-Methode |
| 15 | * geschrieben hat, die auch im Betrieb schreibt. |
| 16 | */ |
| 17 | |
| 18 | import Database from 'better-sqlite3'; |
| 19 | import { afterEach, beforeEach, describe, expect, it } from 'vitest'; |
| 20 | |
| 21 | import { Lernstand } from '../src/main/lernstand'; |
| 22 | import type { Antwortoption, Frage, Katalog } from '../src/shared/katalog'; |
| 23 | import type { Bewertung } from '../src/shared/lernstand'; |
| 24 | import { reifeverlauf } from '../src/shared/reifeverlauf'; |
| 25 | |
| 26 | const TAG_MS = 86_400_000; |
| 27 | |
| 28 | function option(label: string, korrekt: boolean): Antwortoption { |
| 29 | return { |
| 30 | label, |
| 31 | inhalt: { text: `Antwort ${label}`, segmente: [{ t: `Antwort ${label}` }] }, |
| 32 | korrekt, |
| 33 | bilder: [], |
| 34 | }; |
| 35 | } |
| 36 | |
| 37 | function mcFrage(id: string, kapitel: string, abschnitt: string | null): Frage { |
| 38 | return { |
| 39 | id, |
| 40 | amtliche_nummer: id, |
| 41 | kapitel, |
| 42 | abschnitt, |
| 43 | typ: 'mc', |
| 44 | seite: 1, |
| 45 | frage: { text: `Frage ${id}`, segmente: [{ t: `Frage ${id}` }] }, |
| 46 | bilder: [], |
| 47 | optionen: [option('a', true), option('b', false)], |
| 48 | }; |
| 49 | } |
| 50 | |
| 51 | const FRAGEN: readonly Frage[] = [ |
| 52 | mcFrage('I.1-01', 'I', 'I.1'), |
| 53 | mcFrage('I.1-02', 'I', 'I.1'), |
| 54 | mcFrage('I.1-03', 'I', 'I.1'), |
| 55 | mcFrage('II-01', 'II', null), |
| 56 | mcFrage('II-02', 'II', null), |
| 57 | ]; |
| 58 | |
| 59 | const KATALOG: Katalog = { |
| 60 | meta: { |
| 61 | titel: 'Prüfkatalog', |
| 62 | herausgeber: 'Bundesverwaltungsamt', |
| 63 | stand: '2024-12-16', |
| 64 | quellenangabe: 'Amtlicher Fragenkatalog, Stand 16.12.2024.', |
| 65 | quelle_url: 'https://www.bva.bund.de/', |
| 66 | quelldatei_sha256: 'a'.repeat(64), |
| 67 | fragen_gesamt: FRAGEN.length, |
| 68 | }, |
| 69 | kapitel: [ |
| 70 | { id: 'I', titel: 'Waffenrecht', abschnitte: [{ id: 'I.1', titel: 'Begriffe' }] }, |
| 71 | { id: 'II', titel: 'Waffentechnik', abschnitte: [] }, |
| 72 | ], |
| 73 | bilder: [], |
| 74 | fragen: FRAGEN, |
| 75 | }; |
| 76 | |
| 77 | const START = '2026-03-01T10:00:00.000Z'; |
| 78 | |
| 79 | let uhr: Date; |
| 80 | let db: Database.Database; |
| 81 | let lernstand: Lernstand; |
| 82 | let profilId: number; |
| 83 | |
| 84 | beforeEach(() => { |
| 85 | uhr = new Date(START); |
| 86 | db = new Database(':memory:'); |
| 87 | lernstand = new Lernstand(db, KATALOG, { jetzt: () => uhr }); |
| 88 | profilId = lernstand.profile()[0]!.id; |
| 89 | }); |
| 90 | |
| 91 | afterEach(() => { |
| 92 | db.close(); |
| 93 | }); |
| 94 | |
| 95 | function antworten(frageId: string, bewertung: Bewertung, richtig = true): void { |
| 96 | lernstand.antworten(profilId, { |
| 97 | frageId, |
| 98 | auswahl: richtig ? ['a'] : ['b'], |
| 99 | richtig, |
| 100 | bewertung, |
| 101 | dauerMs: 1500, |
| 102 | }); |
| 103 | } |
| 104 | |
| 105 | function tageWeiter(tage: number): void { |
| 106 | uhr = new Date(uhr.getTime() + tage * TAG_MS); |
| 107 | } |
| 108 | |
| 109 | describe('Reifegrad-Verlauf', () => { |
| 110 | it('endet auf genau der Zahl, die die Ampel zeigt', () => { |
| 111 | /* |
| 112 | Die Zusicherung, an der die ganze Anzeige hängt. Das Protokoll entsteht |
| 113 | hier durch `antworten()` – also durch denselben Weg wie im Betrieb, mit |
| 114 | denselben Bewertungen, Abständen und Belegregeln. |
| 115 | */ |
| 116 | antworten('I.1-01', 'gut'); |
| 117 | antworten('I.1-02', 'schwer'); |
| 118 | antworten('II-01', 'nochmal', false); |
| 119 | tageWeiter(2); |
| 120 | antworten('I.1-01', 'gut'); |
| 121 | antworten('II-01', 'gut'); |
| 122 | tageWeiter(3); |
| 123 | antworten('I.1-02', 'leicht'); |
| 124 | antworten('I.1-03', 'gut'); |
| 125 | |
| 126 | const verlauf = lernstand.reifeverlauf(profilId, 14); |
| 127 | const letzter = verlauf[verlauf.length - 1]; |
| 128 | const uebersicht = lernstand.uebersicht(profilId); |
| 129 | |
| 130 | /* Zuerst: Es wird überhaupt etwas verglichen. Zwei Nullen wären |
| 131 | gleich und sagten nichts – genau so ist dieser Test beim ersten |
| 132 | Schreiben durchgelaufen, weil die Attrappe der Fragen eine falsche |
| 133 | Form hatte und keine Antwort als richtig zählte. */ |
| 134 | expect(uebersicht.reifegrad).toBeGreaterThan(0); |
| 135 | expect(uebersicht.belegt).toBeGreaterThan(0); |
| 136 | |
| 137 | expect(letzter?.reifegrad).toBeCloseTo(uebersicht.reifegrad, 12); |
| 138 | expect(letzter?.belegt).toBe(uebersicht.belegt); |
| 139 | expect(letzter?.beantwortet).toBe(uebersicht.beantwortet); |
| 140 | }); |
| 141 | |
| 142 | it('gibt für jeden angefragten Tag genau einen Punkt zurück', () => { |
| 143 | const verlauf = lernstand.reifeverlauf(profilId, 7); |
| 144 | expect(verlauf).toHaveLength(7); |
| 145 | expect(new Set(verlauf.map((punkt) => punkt.tag)).size).toBe(7); |
| 146 | }); |
| 147 | |
| 148 | it('steht auf null, solange nichts beantwortet wurde', () => { |
| 149 | const verlauf = lernstand.reifeverlauf(profilId, 5); |
| 150 | expect(verlauf.every((punkt) => punkt.reifegrad === 0)).toBe(true); |
| 151 | expect(verlauf.every((punkt) => punkt.beantwortet === 0)).toBe(true); |
| 152 | }); |
| 153 | |
| 154 | it('zeigt den Anstieg dort, wo er stattgefunden hat – und nicht früher', () => { |
| 155 | /* |
| 156 | Der eigentliche Zweck: Wer sieht, dass die Linie steigt, soll ihr |
| 157 | glauben können. Ein Beleg entsteht frühestens nach einem Tag Abstand |
| 158 | (`shared/reife.ts`); vorher muss der Verlauf null zeigen, auch wenn |
| 159 | schon geantwortet wurde. |
| 160 | */ |
| 161 | antworten('I.1-01', 'gut'); |
| 162 | antworten('I.1-02', 'gut'); |
| 163 | tageWeiter(2); |
| 164 | antworten('I.1-01', 'gut'); |
| 165 | antworten('I.1-02', 'gut'); |
| 166 | |
| 167 | const verlauf = lernstand.reifeverlauf(profilId, 5); |
| 168 | const [vorvorher, vorher, tag1, tag2, heute] = verlauf; |
| 169 | |
| 170 | expect(vorvorher?.reifegrad).toBe(0); |
| 171 | expect(vorher?.reifegrad).toBe(0); |
| 172 | /* Tag der ersten Antworten: beantwortet, aber nichts belegt. */ |
| 173 | expect(tag1?.beantwortet).toBe(2); |
| 174 | expect(tag1?.reifegrad).toBe(0); |
| 175 | expect(tag2?.reifegrad).toBe(0); |
| 176 | /* Erst die Wiederholung mit Abstand belegt. */ |
| 177 | expect(heute?.reifegrad).toBeGreaterThan(0); |
| 178 | }); |
| 179 | |
| 180 | it('fällt wieder, wenn eine belegte Frage falsch beantwortet wird', () => { |
| 181 | /* „Die Zahl kann durch eine falsche Antwort nie steigen“ – dieselbe |
| 182 | Eigenschaft, die `shared/reife.ts` für die Ampel zusichert. */ |
| 183 | antworten('I.1-01', 'gut'); |
| 184 | tageWeiter(2); |
| 185 | antworten('I.1-01', 'gut'); |
| 186 | |
| 187 | const vorher = lernstand.reifeverlauf(profilId, 3); |
| 188 | const hoehe = vorher[vorher.length - 1]?.reifegrad ?? 0; |
| 189 | expect(hoehe).toBeGreaterThan(0); |
| 190 | |
| 191 | antworten('I.1-01', 'nochmal', false); |
| 192 | const nachher = lernstand.reifeverlauf(profilId, 3); |
| 193 | expect(nachher[nachher.length - 1]?.reifegrad).toBeLessThan(hoehe); |
| 194 | }); |
| 195 | |
| 196 | it('übergeht Antworten auf Fragen außerhalb des Lernumfangs', () => { |
| 197 | /* Sonst zählte ein abgewähltes Kapitel im Verlauf mit und in der Ampel |
| 198 | nicht – zwei Zahlen, die einander widersprechen. */ |
| 199 | antworten('II-01', 'gut'); |
| 200 | tageWeiter(2); |
| 201 | antworten('II-01', 'gut'); |
| 202 | lernstand.profilAktualisieren({ id: profilId, kapitelAusschluss: ['II'] }); |
| 203 | |
| 204 | const verlauf = lernstand.reifeverlauf(profilId, 5); |
| 205 | expect(verlauf[verlauf.length - 1]?.reifegrad).toBe(0); |
| 206 | expect(verlauf[verlauf.length - 1]?.beantwortet).toBe(0); |
| 207 | expect(lernstand.uebersicht(profilId).reifegrad).toBe(0); |
| 208 | }); |
| 209 | |
| 210 | it('weist eine Tageszahl außerhalb des Bereichs ab', () => { |
| 211 | /* Der Wert kommt aus dem Renderer. Die Rechnung ist Stichtage mal |
| 212 | Fragen; ohne Grenze wäre sie der Weg, den Kern lahmzulegen. */ |
| 213 | expect(() => lernstand.reifeverlauf(profilId, 0)).toThrow(); |
| 214 | expect(() => lernstand.reifeverlauf(profilId, 10_000)).toThrow(); |
| 215 | expect(() => lernstand.reifeverlauf(profilId, 'viele')).toThrow(); |
| 216 | }); |
| 217 | |
| 218 | it('kommt ohne Lernumfang und ohne Tage zurecht', () => { |
| 219 | expect(reifeverlauf([], [], new Date(START), 5)).toEqual([]); |
| 220 | expect(reifeverlauf([], ['I.1-01'], new Date(START), 0)).toEqual([]); |
| 221 | }); |
| 222 | }); |