waffensachkunde

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

/ app tests hilfe.test.ts

14,8 KB Rohdatei
app/tests/hilfe.test.ts — 360 Zeilen
1 /**
2 * Das Handbuch als Datenbestand.
3 *
4 * Diese Tests sehen sich keine Oberfläche an. Sie halten den Inhalt selbst
5 * zusammen: Ein Handbuch, das ausläuft, sagt mit der Autorität eines
6 * Handbuchs etwas Falsches – schlimmer als gar keines. Deshalb prüfen sie
7 * nicht nur, dass Text dasteht, sondern dass er aus denselben Konstanten
8 * kommt wie die Anwendung.
9 */
10
11 import { describe, expect, it } from 'vitest';
12
13 import {
14 HILFE_EINLEITUNG,
15 HILFE_KAPITEL,
16 HILFE_TITEL,
17 HILFE_ZUR_ANSICHT,
18 TASTATURBEDIENUNG,
19 TASTENKUERZEL,
20 type Hilfeblock,
21 type Tastenzeile,
22 } from '../src/shared/hilfe';
23 import { ANZEIGEGROESSEN } from '../src/shared/ansicht';
24 import { BEWERTUNG_BEZEICHNUNG } from '../src/shared/lernstand';
25 import { STUFE_WORT } from '../src/shared/reife';
26 import { KONTAKT } from '../src/shared/kontakt';
27 import { SITZUNGSUMFANG } from '../src/shared/lernplan';
28 import { PRUEFUNGSPROFILE } from '../src/shared/pruefung';
29
30 /** Alle Textstellen eines Blocks, gleich welcher Art. */
31 function texte(block: Hilfeblock): readonly string[] {
32 switch (block.art) {
33 case 'absatz':
34 return [block.text];
35 case 'liste':
36 case 'schritte':
37 return block.punkte;
38 case 'tasten':
39 return block.zeilen.map((zeile) => zeile.wirkung);
40 }
41 }
42
43 /** Der gesamte Fließtext des Handbuchs – für Suchen nach Zahlen. */
44 const GESAMTTEXT = HILFE_KAPITEL.flatMap((kapitel) => kapitel.bloecke.flatMap(texte)).join('\n');
45
46 const ALLE_TASTENZEILEN: readonly Tastenzeile[] = [...TASTENKUERZEL, ...TASTATURBEDIENUNG];
47
48 describe('Handbuch – Aufbau', () => {
49 it('hat einen Titel und eine Einleitung', () => {
50 expect(HILFE_TITEL.trim().length).toBeGreaterThan(0);
51 expect(HILFE_EINLEITUNG.trim().length).toBeGreaterThan(0);
52 });
53
54 it('vergibt jede Kapitelkennung genau einmal', () => {
55 /* Die Kennungen sind Sprungziele des Inhaltsverzeichnisses. Zwei
56 gleiche hießen: Ein Eintrag führt woandershin, als er verspricht. */
57 const kennungen = HILFE_KAPITEL.map((kapitel) => kapitel.id);
58 expect(new Set(kennungen).size).toBe(kennungen.length);
59 });
60
61 it('gibt jedem Kapitel Kennung, Titel, Kurztext und Inhalt', () => {
62 for (const kapitel of HILFE_KAPITEL) {
63 expect(kapitel.id, `Kapitel ohne Kennung: ${kapitel.titel}`).toMatch(/^[a-z][a-z-]*$/u);
64 expect(kapitel.titel.trim().length, `${kapitel.id}: kein Titel`).toBeGreaterThan(0);
65 expect(kapitel.kurz.trim().length, `${kapitel.id}: kein Kurztext`).toBeGreaterThan(0);
66 expect(kapitel.bloecke.length, `${kapitel.id}: kein Inhalt`).toBeGreaterThan(0);
67 }
68 });
69
70 it('lässt nirgends eine leere Textstelle stehen', () => {
71 for (const kapitel of HILFE_KAPITEL) {
72 for (const block of kapitel.bloecke) {
73 for (const text of texte(block)) {
74 expect(text.trim().length, `${kapitel.id}: leere Textstelle`).toBeGreaterThan(0);
75 }
76 }
77 }
78 });
79
80 it('schreibt kein Markup in den Text', () => {
81 /*
82 Die Blöcke werden als reiner Text gerendert, nie über
83 `dangerouslySetInnerHTML`. Stünde hier eine spitze Klammer, erschiene
84 sie wörtlich auf dem Bildschirm – der Fehler fiele erst dort auf.
85 */
86 expect(GESAMTTEXT).not.toMatch(/<[a-z/]/iu);
87 });
88
89 /*
90 Dieselbe Falle, andere Zeichen — und sie hatte zugeschnappt. Bis Fassung
91 0.24.2 standen fünf Markdown-Auszeichnungen im sichtbaren Handbuchtext:
92 dreimal im Kapitel „Wiedervorlage und Lernplan“ (Lernbericht,
93 Fehlerprotokoll, Fragenliste) und zweimal in „Profile und Ihre Daten“
94 (Ersetzen, Ein Profil dazunehmen). `Hilfedialog.tsx` rendert
95 `<p>{block.text}</p>`; auf dem Bildschirm stand also wörtlich
96 „**Lernbericht**“.
97
98 Die Prüfung auf spitze Klammern darüber griff nicht — Markdown hat keine.
99 Wer eine Stelle hervorheben will, teilt den Satz oder setzt deutsche
100 Anführungszeichen, so wie das Handbuch es sonst überall tut.
101 */
102 it('schreibt auch kein Markdown in den Text', () => {
103 const auszeichnungen: readonly (readonly [RegExp, string])[] = [
104 [/\*\*[^*]+\*\*/u, 'fette Auszeichnung mit **'],
105 [/^\s*#{1,6}\s/mu, 'Überschrift mit Doppelkreuz'],
106 [/\[[^\]]+\]\([^)]+\)/u, 'Verweis in eckigen Klammern'],
107 [/`[^`]+`/u, 'Schreibmaschinenschrift mit Rückstrich'],
108 ];
109
110 const befunde = auszeichnungen
111 .filter(([muster]) => muster.test(GESAMTTEXT))
112 .map(([muster, name]) => `${name}: ${GESAMTTEXT.match(muster)?.[0] ?? ''}`);
113
114 expect(
115 befunde,
116 'Der Hilfedialog zeigt reinen Text. Was hier als Auszeichnung gemeint ist, ' +
117 'liest der Nutzende wörtlich.',
118 ).toEqual([]);
119 });
120
121 /*
122 Gegenprobe: Die Wache darüber muss auch wirklich anschlagen. Ohne diesen
123 Fall bliebe unbemerkt, wenn ein umgebautes Muster nie mehr trifft —
124 dieselbe Überlegung wie bei der Wache über die Offline-Zusage.
125 */
126 it('erkennt eine Auszeichnung, wenn eine dasteht', () => {
127 const fett = /\*\*[^*]+\*\*/u;
128 expect(fett.test('Der **Lernbericht** zeigt Ihren Stand.')).toBe(true);
129 expect(fett.test('Der „Lernbericht“ zeigt Ihren Stand.')).toBe(false);
130 });
131 });
132
133 describe('Handbuch – Tastenlisten', () => {
134 it('nennt zu jeder Taste eine Wirkung', () => {
135 for (const zeile of ALLE_TASTENZEILEN) {
136 expect(zeile.tasten.length).toBeGreaterThan(0);
137 expect(zeile.wirkung.trim().length).toBeGreaterThan(0);
138 }
139 });
140
141 it('sagt bei mehreren Tasten, wie sie zusammengehören', () => {
142 /*
143 „Strg + Plus“ heißt gleichzeitig, „1 bis 8“ heißt irgendeine davon.
144 Ohne diese Angabe stünden beide gleich da – ein Zwischenraum sagt
145 nichts, und Hörende bekämen ihn gar nicht mit.
146 */
147 for (const zeile of ALLE_TASTENZEILEN.filter((z) => z.tasten.length > 1)) {
148 expect(zeile.verbindung, `${zeile.tasten.join('/')}: keine Verbindung`).toBeDefined();
149 }
150 });
151
152 it('führt beide Tastenlisten im Kapitel zur Tastaturbedienung', () => {
153 /*
154 Der eigentliche Zweck: Die Kürzel standen früher nur als JSX in
155 `KuerzelHilfe`. Jetzt sind sie Daten, und dieser Test hält fest, dass
156 das Handbuch sie auch tatsächlich zeigt – sonst wäre die gemeinsame
157 Quelle zwar da, aber wirkungslos.
158 */
159 const kapitel = HILFE_KAPITEL.find((k) => k.id === 'tastatur');
160 expect(kapitel).toBeDefined();
161
162 const listen = (kapitel?.bloecke ?? [])
163 .filter((block) => block.art === 'tasten')
164 .map((block) => block.zeilen);
165
166 expect(listen).toContain(TASTENKUERZEL);
167 expect(listen).toContain(TASTATURBEDIENUNG);
168 });
169 });
170
171 describe('Handbuch – bleibt an der Anwendung', () => {
172 it('nennt den tatsächlichen Sitzungsumfang', () => {
173 /* Steht die Zahl als Text im Handbuch, läuft sie beim nächsten
174 Umstellen von SITZUNGSUMFANG auseinander. Dieser Test schlägt dann
175 fehl – und er schlägt auch fehl, wenn jemand die Zahl aus dem
176 Handbuch entfernt. */
177 expect(GESAMTTEXT).toContain(String(SITZUNGSUMFANG));
178 });
179
180 it('nennt die tatsächliche Zahl der Simulationsprofile', () => {
181 expect(GESAMTTEXT).toContain(String(PRUEFUNGSPROFILE.length));
182 });
183
184 it('nennt den Rückmeldeweg aus derselben Quelle wie „Über diese Software“', () => {
185 /* Zwei Stellen mit derselben Adresse laufen auseinander; eine davon
186 ist dann falsch, und niemand merkt es. Beide lesen aus KONTAKT. */
187 expect(GESAMTTEXT).toContain(KONTAKT.epost);
188 expect(GESAMTTEXT).toContain(KONTAKT.name);
189 expect(GESAMTTEXT).toContain(KONTAKT.frist);
190 });
191
192 it('führt ein Kapitel, das den Meldeweg erklärt', () => {
193 // Prüfschritt 9.2.2 verlangt einen auffindbaren Kanal – auch von hier aus.
194 expect(HILFE_KAPITEL.map((kapitel) => kapitel.id)).toContain('melden');
195 });
196
197 /*
198 Das Handbuch nannte drei der vier Ampelworte und ließ ausgerechnet das
199 aus, das jeder Anfänger als Erstes sieht. Der Fehler war unsichtbar, weil
200 die Worte als Zeichenkette dastanden statt aus `STUFE_WORT` zu kommen –
201 genau die Bauweise, die der Modulkopf ausschließt.
202 */
203 it('nennt alle vier Stufen der Prüfungsreife, und zwar aus der Quelle', () => {
204 for (const wort of Object.values(STUFE_WORT)) {
205 expect(GESAMTTEXT).toContain(wort);
206 }
207 });
208
209 it('nennt alle vier Stufen der Selbstbewertung aus derselben Quelle', () => {
210 for (const wort of Object.values(BEWERTUNG_BEZEICHNUNG)) {
211 expect(GESAMTTEXT).toContain(wort);
212 }
213 });
214
215 /*
216 Die Liste unter der Musterantwort hieß bis 0.17.0 „Diese Kernelemente
217 muss Ihre Antwort enthalten“. Das Handbuch darf die abgeschaffte
218 Behauptung nicht weitertragen – und muss sagen, was die Liste wirklich
219 ist, sonst liest sie jeder als Pflichtinhalt.
220 */
221 it('erklärt die unterstrichenen Stellen, ohne sie zur Vorgabe zu machen', () => {
222 expect(GESAMTTEXT).toContain('unterstrichen');
223 expect(GESAMTTEXT).not.toContain('muss Ihre Antwort enthalten');
224 });
225
226 it('nennt den Einstieg „Offene Fragen“', () => {
227 expect(GESAMTTEXT).toContain('Offene Fragen');
228 });
229
230 /*
231 Das Kapitel „Wenn etwas nicht funktioniert“ sagte bis 0.22.0, die
232 Anwendung gehe „bis auf das Doppelte“ – sie geht bis 400 Prozent. Die
233 Falschauskunft stand ausgerechnet dort, wo jemand mit zu kleiner Schrift
234 nachschlägt: Wem 200 Prozent nicht genügen, der las schwarz auf weiß,
235 dass mehr nicht geht. Der Satz kommt jetzt aus ANZEIGEGROESSEN, und
236 dieser Test hält beide Richtungen fest – die richtige Zahl muss dastehen,
237 und die alte Behauptung darf nicht zurückkehren.
238 */
239 it('nennt die tatsächliche Obergrenze der Anzeigegröße', () => {
240 const groesste = Math.max(...ANZEIGEGROESSEN);
241 expect(GESAMTTEXT).toContain(`${String(groesste)} Prozent`);
242 expect(GESAMTTEXT).not.toMatch(/bis auf das Doppelte/u);
243 });
244 });
245
246 /*
247 Die Software übt den theoretischen Teil der Sachkundeprüfung. Dass es
248 daneben einen praktischen gibt, stand bis 0.22.0 nirgends in der Oberfläche
249 – „praktisch“ kam im ganzen Handbuch nicht vor. Wer nur hiermit lernt,
250 konnte glauben, der Fragenbogen sei die ganze Prüfung. PLAN.md 5.4 verlangt
251 den Hinweis ausdrücklich; erfüllt war er nie.
252 */
253 describe('Handbuch – sagt, was die Anwendung nicht abdeckt', () => {
254 it('nennt den praktischen Teil der Prüfung und seine Rechtsgrundlage', () => {
255 expect(GESAMTTEXT).toContain('praktischen Teil');
256 expect(GESAMTTEXT).toContain('§ 2 Absatz 3');
257 });
258
259 it('sagt, dass der praktische Teil hier weder geübt noch geprüft wird', () => {
260 const ueberblick = HILFE_KAPITEL.find((kapitel) => kapitel.id === 'ueberblick');
261 expect(ueberblick).toBeDefined();
262 const text = (ueberblick?.bloecke ?? []).flatMap(texte).join('\n');
263 expect(text).toContain('praktische Teil');
264 expect(text).toMatch(/keine Software vermitteln/u);
265 });
266
267 /*
268 Die Ebene-1-Wache, und sie ist die billigste von allen.
269
270 Bis Fassung 0.24.2 prüfte in dieser Datei **keine einzige** Zusicherung,
271 ob eine Funktion im Handbuch überhaupt vorkommt. Nachgezählt fehlten
272 neunzehn Funktionen ganz, darunter mehrere, die eigens für die
273 Lernsteuerung gebaut worden waren: hartnäckige Fragen, verwandte Fragen,
274 „Ich hatte geraten“, die Kernpunkte, die aufklappbaren Normtexte und die
275 gesamte Prüfungsauswertung mit Themenanalyse und wiederkehrenden Lücken.
276 Ein Rückfall wäre niemandem aufgefallen.
277
278 Geprüft wird die **Beschriftung**, die auf dem Bildschirm steht — nicht
279 der interne Name. Wer eine Beschriftung ändert, muss das Handbuch
280 mitziehen; genau dafür ist diese Wache da. Die Liste ist bewusst kurz
281 gehalten: Sie führt die Bedienelemente, deren Kenntnis über den Lernerfolg
282 entscheidet, und nicht jede Zeichenkette der Oberfläche.
283 */
284 it('nennt die lernsteuernden Funktionen beim Namen', () => {
285 const beschriftungen = [
286 'Weiterlernen',
287 'Kapitel wählen',
288 'Nur Fehler',
289 'Gemerkte Fragen',
290 'Offene Fragen',
291 'Hartnäckige Fragen',
292 'Diese Fragen üben',
293 'Verwandte Fragen',
294 'Ich hatte geraten',
295 'Im Gesetz nachlesen',
296 'Ergebnis nach Themenbereichen',
297 'Wiederkehrende Lücken',
298 'Fehler wiederholen',
299 'Ihr Lernstand im Einzelnen',
300 'Ihr Lernplan',
301 ];
302
303 const fehlende = beschriftungen.filter((wort) => !GESAMTTEXT.includes(wort));
304
305 expect(
306 fehlende,
307 'Diese Bedienelemente steuern, was der Lernende als Nächstes tut. Was das ' +
308 'Handbuch nicht beim Namen nennt, findet er nicht — und die Oberfläche ' +
309 'erklärt nirgends, wozu es da ist.',
310 ).toEqual([]);
311 });
312
313 it('ordnet jeder genannten Ansicht ein Kapitel zu, das es gibt', () => {
314 /* Kontextsensitives F1: Wer mitten in der Simulation drückt, landete bis
315 0.22.0 am Anfang des Handbuchs und musste das passende von elf
316 Kapiteln selbst heraussuchen. Eine Zuordnung, die auf ein
317 umbenanntes Kapitel zeigt, fiele stillschweigend auf den Anfang
318 zurück – der Fehler sähe aus wie „keine Zuordnung“. */
319 const kennungen = new Set(HILFE_KAPITEL.map((kapitel) => kapitel.id));
320
321 for (const [ansicht, kapitel] of Object.entries(HILFE_ZUR_ANSICHT)) {
322 expect(kennungen, `Ansicht „${ansicht}“ zeigt auf kein Kapitel`).toContain(kapitel);
323 }
324 });
325
326 it('führt die Ansichten, in denen ein Kapitel wirklich hilft', () => {
327 /*
328 Bis Fassung 0.24.2 stand hier `expect(HILFE_ZUR_ANSICHT['start'])
329 .toBeUndefined()`, und die Begründung lautete: Auf dem Startbildschirm
330 sei das Inhaltsverzeichnis die richtige Antwort.
331
332 Diese Begründung stand und fiel mit ihrer Voraussetzung — dass es kein
333 Kapitel gibt, das die Frage des Startbildschirms beantwortet. Seit
334 0.25.0 gibt es „So kommen Sie durch“, und es beantwortet genau sie: was
335 als Nächstes zu tun ist. Wer dort F1 drückt, sucht diesen Text und nicht
336 ein Verzeichnis von zwölf Kapiteln.
337
338 Die Zusage bleibt in der Sache dieselbe: Eine Ansicht bekommt ein
339 Kapitel nur, wenn dieses Kapitel ihre Frage beantwortet.
340 */
341 expect(Object.keys(HILFE_ZUR_ANSICHT)).toEqual(
342 expect.arrayContaining(['start', 'sitzung', 'pruefung', 'pruefungswahl', 'suche']),
343 );
344 expect(HILFE_ZUR_ANSICHT['start']).toBe('weg');
345
346 /* Das Sitzungsende führte bis 0.24.2 nach „wiedervorlage“ – ein Kapitel,
347 das kein Wort über die dort angebotenen Anschlusswege verlor. */
348 expect(HILFE_ZUR_ANSICHT['auswertung']).toBe('lernsitzung');
349 });
350
351 it('wiederholt den Vorbehalt im Kapitel zur Simulation', () => {
352 /* Wer eine Simulation besteht, ist am ehesten in Versuchung, sich für
353 fertig zu halten – der Vorbehalt gehört deshalb auch dorthin, nicht
354 nur in den Überblick. */
355 const simulation = HILFE_KAPITEL.find((kapitel) => kapitel.id === 'simulation');
356 expect(simulation).toBeDefined();
357 const text = (simulation?.bloecke ?? []).flatMap(texte).join('\n');
358 expect(text).toContain('praktischer Teil');
359 });
360 });