waffensachkunde
Waffensachkunde – Lernsoftware für die Sachkundeprüfung nach § 7 WaffG. Barrierefrei, offline, EUPL-1.2.
| 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 | }); |