waffensachkunde

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

/ app tests verlaufsvergleich.test.ts

13,1 KB Rohdatei
app/tests/verlaufsvergleich.test.ts — 373 Zeilen
1 /**
2 * Einordnung eines Laufs gegenüber den früheren.
3 *
4 * Der Schwerpunkt liegt auf dem, was die Gegenprüfung als Falle benannt hat:
5 * `quote` zählt unbeantwortete Fragen wie falsch beantwortete, und ein Lauf,
6 * in dem die Zeit ablief, darf deshalb nicht als Wissenseinbruch dastehen.
7 */
8
9 import { describe, expect, it } from 'vitest';
10
11 import type { BereichErgebnis, Pruefungsverlauf } from '../src/shared/pruefung';
12 import {
13 laufVergleichen,
14 wiederkehrendeSchwaechen,
15 type Laufkennzahlen,
16 } from '../src/shared/verlaufsvergleich';
17
18 function lauf(werte: Partial<Pruefungsverlauf> = {}): Pruefungsverlauf {
19 return {
20 id: 1,
21 profilId: 'standard',
22 profilName: 'Standard',
23 zeitpunkt: '2026-08-01T10:00:00.000Z',
24 gesamt: 80,
25 richtig: 60,
26 quote: 0.75,
27 urteil: 'nicht_bestanden',
28 dauerMs: 60 * 60_000,
29 zeitmodus: 'normal',
30 unbeantwortet: 0,
31 zeitAbgelaufen: false,
32 bereiche: null,
33 bestehensQuote: 0.8,
34 ...werte,
35 };
36 }
37
38 function aktuell(werte: Partial<Laufkennzahlen> = {}): Laufkennzahlen {
39 return {
40 profilId: 'standard',
41 zeitmodus: 'normal',
42 gesamt: 80,
43 quote: 0.8,
44 dauerMs: 50 * 60_000,
45 unbeantwortet: 0,
46 zeitAbgelaufen: false,
47 laufId: 99,
48 ...werte,
49 };
50 }
51
52 /** Datum unverändert durchreichen: Der Test prüft Sätze, nicht `Intl`. */
53 const roh = (iso: string): string => iso;
54
55 describe('laufVergleichen', () => {
56 it('nennt Prozentpunkte und Minuten', () => {
57 const vergleich = laufVergleichen(aktuell(), [lauf()], roh);
58
59 expect(vergleich?.punkte).toBe(5);
60 expect(vergleich?.minuten).toBe(10);
61 expect(vergleich?.satz).toContain('5 Prozentpunkte besser');
62 expect(vergleich?.satz).toContain('10 Minuten schneller');
63 });
64
65 it('nennt eine Verschlechterung genauso deutlich', () => {
66 const vergleich = laufVergleichen(aktuell({ quote: 0.6, dauerMs: 75 * 60_000 }), [lauf()], roh);
67
68 expect(vergleich?.satz).toContain('15 Prozentpunkte schlechter');
69 expect(vergleich?.satz).toContain('15 Minuten langsamer');
70 });
71
72 it('sagt bei einem einzelnen Punkt „Prozentpunkt“', () => {
73 const vergleich = laufVergleichen(aktuell({ quote: 0.76 }), [lauf()], roh);
74
75 expect(vergleich?.satz).toContain('1 Prozentpunkt besser');
76 expect(vergleich?.satz).not.toContain('1 Prozentpunkte');
77 });
78
79 it('behauptet keinen Unterschied, wo keiner ist', () => {
80 const vergleich = laufVergleichen(
81 aktuell({ quote: 0.75, dauerMs: 60 * 60_000 + 10_000 }),
82 [lauf()],
83 roh,
84 );
85
86 expect(vergleich?.satz).toContain('gleichauf in der Trefferquote');
87 expect(vergleich?.satz).toContain('bei praktisch gleicher Bearbeitungsdauer');
88 expect(vergleich?.minuten).toBeNull();
89 });
90
91 it('rundet oberhalb der Schwelle nie auf null Minuten', () => {
92 // 45 Sekunden liegen über der Schwelle, wären aber auf 1 Minute gerundet
93 // knapp – „0 Minuten schneller“ wäre ein Satz, der sich selbst widerspricht.
94 const vergleich = laufVergleichen(aktuell({ dauerMs: 60 * 60_000 - 45_000 }), [lauf()], roh);
95
96 expect(vergleich?.minuten).toBe(1);
97 expect(vergleich?.satz).toContain('1 Minute schneller');
98 });
99
100 it('vergleicht nicht mit sich selbst', () => {
101 // Der eben gespeicherte Lauf steht bereits im Verlauf. Ohne den Ausschluss
102 // über die Kennung meldete die Anwendung jedem Prüfling ein „gleichauf“.
103 expect(laufVergleichen(aktuell({ laufId: 7 }), [lauf({ id: 7 })], roh)).toBeNull();
104 });
105
106 it('vergleicht nur mit demselben Profil und derselben Zeitstufe', () => {
107 const fremd = [
108 lauf({ id: 2, profilId: 'dsb' }),
109 lauf({ id: 3, zeitmodus: 'plus50' }),
110 // Ohne vermerkte Zeitstufe ist nicht bekannt, was zutrifft.
111 lauf({ id: 4, zeitmodus: null }),
112 ];
113
114 expect(laufVergleichen(aktuell(), fremd, roh)).toBeNull();
115 });
116
117 it('nimmt den jüngsten passenden Lauf, nicht den ersten überhaupt', () => {
118 const verlauf = [
119 lauf({ id: 5, profilId: 'dsb' }),
120 lauf({ id: 4, zeitpunkt: '2026-07-20T10:00:00.000Z' }),
121 lauf({ id: 3, zeitpunkt: '2026-07-01T10:00:00.000Z' }),
122 ];
123
124 expect(laufVergleichen(aktuell(), verlauf, roh)?.frueher.id).toBe(4);
125 });
126
127 it('sagt dazu, wenn diesmal die Zeit ablief', () => {
128 const vergleich = laufVergleichen(
129 aktuell({ quote: 0.4, unbeantwortet: 23, zeitAbgelaufen: true }),
130 [lauf()],
131 roh,
132 );
133
134 expect(vergleich?.vorbehalte).toContain(
135 'Diesmal blieben 23 Fragen unbeantwortet, weil die Zeit ablief.',
136 );
137 expect(vergleich?.vorbehalte.join(' ')).toContain(
138 'zählen in der Trefferquote wie falsch beantwortete',
139 );
140 });
141
142 it('sagt dazu, wenn damals die Zeit ablief', () => {
143 const vergleich = laufVergleichen(
144 aktuell(),
145 [lauf({ unbeantwortet: 12, zeitAbgelaufen: true })],
146 roh,
147 );
148
149 expect(vergleich?.vorbehalte).toContain(
150 'Damals blieben 12 Fragen unbeantwortet, weil die Zeit ablief.',
151 );
152 });
153
154 it('unterscheidet „nicht festgehalten“ von „nichts offen geblieben“', () => {
155 const vergleich = laufVergleichen(
156 aktuell(),
157 [lauf({ unbeantwortet: null, zeitAbgelaufen: null })],
158 roh,
159 );
160
161 expect(vergleich?.vorbehalte.join(' ')).toContain('nicht festgehalten');
162 // Der allgemeine Satz gehört nur dahin, wo tatsächlich etwas offen blieb.
163 expect(vergleich?.vorbehalte.join(' ')).not.toContain('wie falsch beantwortete');
164 });
165
166 it('schweigt über Unbeantwortete, wenn es keine gab', () => {
167 /* Bis 0.26.4 stand hier `toEqual([])`. Das war zu weit gefasst: Die
168 Standardwerte dieser Datei – 80 Fragen, 75 gegen 80 Prozent – sind
169 selbst ein Rauschfall, und seit dem Vorbehalt zur Ziehung steht dort
170 zu Recht ein Satz. Geprüft wird jetzt, was der Name verspricht. */
171 const vergleich = laufVergleichen(aktuell(), [lauf()], roh);
172
173 expect(vergleich?.vorbehalte.join(' ')).not.toContain('unbeantwortet');
174 expect(vergleich?.vorbehalte.join(' ')).not.toContain('wie falsch beantwortete');
175 });
176
177 /*
178 Der Vorbehalt, der gefehlt hat.
179
180 Ein Bogen zieht 75 bis 100 Fragen aus 575. Zwei Läufe messen deshalb zwei
181 Stichproben und nicht denselben Bestand; auch ohne jede Änderung am
182 Wissensstand fallen sie auseinander. Ohne diesen Satz meldet die
183 Anwendung „5 Prozentpunkte besser“ – vier Fragen – als Fortschritt, und
184 der Prüfling richtet sein Lernen danach aus.
185 */
186 it('nennt den Vorbehalt, wenn der Unterschied unter der Streuung der Ziehung liegt', () => {
187 const vergleich = laufVergleichen(aktuell(), [lauf()], roh);
188
189 expect(vergleich?.punkte).toBe(5);
190 const text = vergleich?.vorbehalte.join(' ') ?? '';
191 expect(text).toContain('5 Prozentpunkten entspricht 4 Fragen');
192 expect(text).toContain('durch die Ziehung');
193 expect(text).toContain('keine Aussage');
194 });
195
196 it('schweigt, sobald der Unterschied über der Streuung liegt', () => {
197 const vergleich = laufVergleichen(aktuell({ quote: 0.95 }), [lauf({ quote: 0.6 })], roh);
198
199 expect(vergleich?.punkte).toBe(35);
200 expect(vergleich?.vorbehalte.join(' ')).not.toContain('durch die Ziehung');
201 });
202
203 it('schweigt bei Gleichstand – dort behauptet niemand einen Unterschied', () => {
204 const vergleich = laufVergleichen(aktuell({ quote: 0.75 }), [lauf({ quote: 0.75 })], roh);
205
206 expect(vergleich?.punkte).toBe(0);
207 expect(vergleich?.vorbehalte.join(' ')).not.toContain('durch die Ziehung');
208 });
209
210 it('rechnet die Streuung aus beiden Bogengrößen, nicht aus einer', () => {
211 /* Ein kleiner Bogen streut stärker. Bei 20 gegen 20 Fragen sind selbst
212 10 Prozentpunkte noch Rauschen, bei 200 gegen 200 nicht mehr. */
213 const klein = laufVergleichen(
214 aktuell({ gesamt: 20, quote: 0.85 }),
215 [lauf({ gesamt: 20, quote: 0.75 })],
216 roh,
217 );
218 const gross = laufVergleichen(
219 aktuell({ gesamt: 200, quote: 0.85 }),
220 [lauf({ gesamt: 200, quote: 0.75 })],
221 roh,
222 );
223
224 expect(klein?.punkte).toBe(10);
225 expect(gross?.punkte).toBe(10);
226 expect(klein?.vorbehalte.join(' ')).toContain('durch die Ziehung');
227 expect(gross?.vorbehalte.join(' ')).not.toContain('durch die Ziehung');
228 });
229
230 it('nennt eine abweichende Bogengröße', () => {
231 const vergleich = laufVergleichen(aktuell({ gesamt: 40 }), [lauf({ gesamt: 80 })], roh);
232
233 expect(vergleich?.vorbehalte).toContain('Der Bogen hatte damals 80 Fragen, dieser 40 Fragen.');
234 });
235
236 it('trägt das Datum des Vergleichslaufs in den Satz', () => {
237 const vergleich = laufVergleichen(
238 aktuell(),
239 [lauf({ zeitpunkt: '2026-07-04T08:00:00.000Z' })],
240 roh,
241 );
242
243 expect(vergleich?.satz).toContain('(2026-07-04T08:00:00.000Z)');
244 });
245
246 it('vergleicht auch, wenn der Lauf gar nicht gespeichert wurde', () => {
247 // Ersatzauswertung ohne Kanal: Es gibt keine Kennung, weil keine Zeile
248 // geschrieben wurde – dann ist auch nichts auszuschließen.
249 const { laufId: _weg, ...ohneKennung } = aktuell();
250
251 expect(laufVergleichen(ohneKennung, [lauf()], roh)?.frueher.id).toBe(1);
252 });
253 });
254
255 function bereich(id: string, gesamt: number, richtig: number): BereichErgebnis {
256 return { bereich: id, titel: `Titel ${id}`, gesamt, richtig };
257 }
258
259 describe('wiederkehrendeSchwaechen', () => {
260 it('meldet einen Bereich, der zweimal unter der Grenze lag', () => {
261 const verlauf = [
262 lauf({ id: 2, bereiche: [bereich('I.4', 10, 4), bereich('I.1', 10, 9)] }),
263 lauf({ id: 1, bereiche: [bereich('I.4', 10, 5), bereich('I.1', 10, 10)] }),
264 ];
265
266 const befund = wiederkehrendeSchwaechen(verlauf);
267
268 expect(befund.grundlage).toBe(2);
269 expect(befund.bereiche).toHaveLength(1);
270 expect(befund.bereiche[0]).toMatchObject({
271 bereich: 'I.4',
272 titel: 'Titel I.4',
273 laeufe: 2,
274 unterGrenze: 2,
275 gesamt: 20,
276 richtig: 9,
277 });
278 });
279
280 it('macht aus einem schlechten Tag keine Lücke', () => {
281 const verlauf = [
282 lauf({ id: 2, bereiche: [bereich('I.4', 10, 3)] }),
283 lauf({ id: 1, bereiche: [bereich('I.4', 10, 10)] }),
284 ];
285
286 expect(wiederkehrendeSchwaechen(verlauf).bereiche).toEqual([]);
287 });
288
289 it('zählt Bereiche mit zu wenigen Fragen gar nicht erst', () => {
290 // Bei zwei Fragen entscheidet ein Fehler über 50 Prozentpunkte.
291 const verlauf = [
292 lauf({ id: 2, bereiche: [bereich('III', 2, 0)] }),
293 lauf({ id: 1, bereiche: [bereich('III', 2, 0)] }),
294 ];
295
296 expect(wiederkehrendeSchwaechen(verlauf).bereiche).toEqual([]);
297 });
298
299 it('misst an der Grenze des jeweiligen Laufs', () => {
300 // Sieben von zehn: unter einer Grenze von 80, über einer von 60 Prozent.
301 const verlauf = [
302 lauf({ id: 2, bestehensQuote: 0.8, bereiche: [bereich('I.4', 10, 7)] }),
303 lauf({ id: 1, bestehensQuote: 0.6, bereiche: [bereich('I.4', 10, 7)] }),
304 ];
305
306 const befund = wiederkehrendeSchwaechen(verlauf);
307
308 expect(befund.bereiche).toEqual([]);
309 // Gezählt wurde er trotzdem in beiden Läufen – nur eben einmal als Treffer.
310 const verlauf2 = [
311 lauf({ id: 2, bestehensQuote: 0.8, bereiche: [bereich('I.4', 10, 7)] }),
312 lauf({ id: 1, bestehensQuote: 0.8, bereiche: [bereich('I.4', 10, 7)] }),
313 ];
314 expect(wiederkehrendeSchwaechen(verlauf2).bereiche[0]).toMatchObject({
315 laeufe: 2,
316 unterGrenze: 2,
317 });
318 });
319
320 it('lässt Läufe ohne gespeicherte Bereiche außen vor', () => {
321 const verlauf = [
322 lauf({ id: 3, bereiche: null }),
323 lauf({ id: 2, bereiche: [bereich('I.4', 10, 2)] }),
324 lauf({ id: 1, bereiche: [bereich('I.4', 10, 2)] }),
325 ];
326
327 // Grundlage sind zwei Läufe, nicht drei: Der alte trägt nichts bei, und
328 // ihn mitzuzählen behauptete eine breitere Grundlage, als es gibt.
329 expect(wiederkehrendeSchwaechen(verlauf).grundlage).toBe(2);
330 });
331
332 it('lässt Läufe ohne gespeicherte Grenze außen vor', () => {
333 const verlauf = [
334 lauf({ id: 2, bestehensQuote: null, bereiche: [bereich('I.4', 10, 1)] }),
335 lauf({ id: 1, bestehensQuote: null, bereiche: [bereich('I.4', 10, 1)] }),
336 ];
337
338 expect(wiederkehrendeSchwaechen(verlauf)).toEqual({ grundlage: 0, bereiche: [] });
339 });
340
341 it('betrachtet höchstens zehn Läufe', () => {
342 const verlauf = Array.from({ length: 14 }, (_unbenutzt, i) =>
343 lauf({ id: 100 - i, bereiche: [bereich('I.4', 10, 4)] }),
344 );
345
346 const befund = wiederkehrendeSchwaechen(verlauf);
347
348 expect(befund.grundlage).toBe(10);
349 expect(befund.bereiche[0]).toMatchObject({ laeufe: 10, gesamt: 100 });
350 });
351
352 it('stellt den schwächsten Bereich nach vorn', () => {
353 const verlauf = [
354 lauf({ id: 2, bereiche: [bereich('I.4', 10, 6), bereich('II', 10, 2)] }),
355 lauf({ id: 1, bereiche: [bereich('I.4', 10, 6), bereich('II', 10, 2)] }),
356 ];
357
358 expect(wiederkehrendeSchwaechen(verlauf).bereiche.map((b) => b.bereich)).toEqual(['II', 'I.4']);
359 });
360
361 it('nennt höchstens fünf Bereiche', () => {
362 const bereiche = ['I.1', 'I.2', 'I.3', 'I.4', 'I.5', 'II', 'III'].map((id) =>
363 bereich(id, 10, 1),
364 );
365 const verlauf = [lauf({ id: 2, bereiche }), lauf({ id: 1, bereiche })];
366
367 expect(wiederkehrendeSchwaechen(verlauf).bereiche).toHaveLength(5);
368 });
369
370 it('sagt bei leerem Verlauf nichts', () => {
371 expect(wiederkehrendeSchwaechen([])).toEqual({ grundlage: 0, bereiche: [] });
372 });
373 });