waffensachkunde

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

/ app tests reifeverlauf.test.ts

7,6 KB Rohdatei
app/tests/reifeverlauf.test.ts — 222 Zeilen
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 });