waffensachkunde
Waffensachkunde – Lernsoftware für die Sachkundeprüfung nach § 7 WaffG. Barrierefrei, offline, EUPL-1.2.
/ app tests normstelle.test.tsx
| 1 | /** |
| 2 | * Die aufklappbare Fundstelle. |
| 3 | * |
| 4 | * **Der Anlass.** Jede Erklärungstafel schloss mit „Im Gesetz nachlesen“ und |
| 5 | * einer Liste von 1853 Fundstellen – reine Textzitate ohne Sprungziel. Eine |
| 6 | * Software, die ausdrücklich vollständig offline arbeitet, forderte damit zu |
| 7 | * etwas auf, das offline gerade nicht ging: Ein Normtext lag nicht bei. |
| 8 | * |
| 9 | * Geprüft wird hier vor allem das Verhalten an den Rändern: ohne |
| 10 | * mitgelieferte Texte bleibt es beim Zitat, und eine Anlagenstelle, die sich |
| 11 | * nicht eindeutig auflösen lässt, springt nirgendwohin, sondern sagt es. |
| 12 | */ |
| 13 | |
| 14 | import { render, screen, within } from '@testing-library/react'; |
| 15 | import userEvent from '@testing-library/user-event'; |
| 16 | import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; |
| 17 | |
| 18 | import { Normstelle } from '../src/renderer/src/components/lernen/Normstelle'; |
| 19 | import { normtexteZwischenspeicherLeeren } from '../src/renderer/src/hooks/useNormtexte'; |
| 20 | import type { Normtexte } from '../src/shared/normtexte'; |
| 21 | |
| 22 | const BESTAND: Normtexte = { |
| 23 | meta: { |
| 24 | version: 1, |
| 25 | stand: '2026-08-29', |
| 26 | hinweis: 'Auszug', |
| 27 | gesetzesstand: { WaffG: 'Zuletzt geändert am 20.3.2026' }, |
| 28 | }, |
| 29 | gesetze: { |
| 30 | WaffG: { |
| 31 | bezeichnung: 'Waffengesetz', |
| 32 | stand: 'Zuletzt geändert am 20.3.2026', |
| 33 | quelle: 'https://www.gesetze-im-internet.de/waffg_2002/', |
| 34 | normen: { |
| 35 | '§ 1': { |
| 36 | titel: 'Gegenstand und Zweck des Gesetzes', |
| 37 | istAnlage: false, |
| 38 | absaetze: { '1': 'Dieses Gesetz regelt den Umgang mit Waffen oder Munition.' }, |
| 39 | }, |
| 40 | 'Anlage 1': { |
| 41 | titel: '(zu § 1 Abs. 4)', |
| 42 | istAnlage: true, |
| 43 | bloecke: [ |
| 44 | { marke: 'Abschnitt 1', pfad: [], text: 'Abschnitt 1:' }, |
| 45 | { |
| 46 | marke: '1.1', |
| 47 | pfad: ['Abschnitt 1'], |
| 48 | text: '1.1\nSchusswaffen sind Gegenstände, bei denen Geschosse getrieben werden.', |
| 49 | }, |
| 50 | ], |
| 51 | }, |
| 52 | }, |
| 53 | }, |
| 54 | }, |
| 55 | }; |
| 56 | |
| 57 | /** |
| 58 | * Wartet, bis die Normtexte geladen sind, und liefert den Aufklapper. |
| 59 | * |
| 60 | * Solange geladen wird, zeigt das Bauteil das reine Zitat – richtig so, aber |
| 61 | * ohne diesen Umweg klickte der Test in genau diesen Zwischenzustand. Der |
| 62 | * Zusatz „im Wortlaut anzeigen“ gibt es nur in der aufklappbaren Fassung. |
| 63 | */ |
| 64 | async function aufklapper(zitat: string): Promise<HTMLElement> { |
| 65 | await screen.findAllByText(/im Wortlaut anzeigen/u); |
| 66 | return screen.getByText(zitat); |
| 67 | } |
| 68 | |
| 69 | function bruecke(normtexte: Normtexte | null): void { |
| 70 | vi.stubGlobal( |
| 71 | 'lernApp', |
| 72 | normtexte === null ? {} : { normtexteLaden: vi.fn(() => Promise.resolve(normtexte)) }, |
| 73 | ); |
| 74 | } |
| 75 | |
| 76 | beforeEach(() => { |
| 77 | normtexteZwischenspeicherLeeren(); |
| 78 | }); |
| 79 | |
| 80 | afterEach(() => { |
| 81 | vi.unstubAllGlobals(); |
| 82 | normtexteZwischenspeicherLeeren(); |
| 83 | }); |
| 84 | |
| 85 | describe('Normstelle', () => { |
| 86 | it('bleibt beim Zitat, solange keine Normtexte vorliegen', async () => { |
| 87 | /* Ein leerer Aufklapper verspräche etwas, das nicht da ist. Wer ohne die |
| 88 | Datei arbeitet, sieht genau das, was er vor 0.22.0 sah. */ |
| 89 | bruecke(null); |
| 90 | render(<Normstelle fundstelle={{ gesetz: 'WaffG', norm: '§ 1', absatz: '1' }} />); |
| 91 | |
| 92 | expect(await screen.findByText('§ 1 Abs. 1 WaffG')).toBeInTheDocument(); |
| 93 | expect(screen.queryByRole('group')).not.toBeInTheDocument(); |
| 94 | }); |
| 95 | |
| 96 | it('zeigt den Absatz im Wortlaut, wenn man aufklappt', async () => { |
| 97 | bruecke(BESTAND); |
| 98 | const nutzer = userEvent.setup(); |
| 99 | render(<Normstelle fundstelle={{ gesetz: 'WaffG', norm: '§ 1', absatz: '1' }} />); |
| 100 | |
| 101 | const schalter = await aufklapper('§ 1 Abs. 1 WaffG'); |
| 102 | // Vor dem Aufklappen steht der Text nicht im Dokument: Eine Auswertung |
| 103 | // führt bis zu 80 Fragen mit je mehreren Fundstellen auf. |
| 104 | expect(screen.queryByText(/Dieses Gesetz regelt/u)).not.toBeInTheDocument(); |
| 105 | |
| 106 | await nutzer.click(schalter); |
| 107 | |
| 108 | expect(screen.getByText(/Dieses Gesetz regelt den Umgang/u)).toBeInTheDocument(); |
| 109 | expect(screen.getByText('Gegenstand und Zweck des Gesetzes')).toBeInTheDocument(); |
| 110 | }); |
| 111 | |
| 112 | it('nennt Gesetz und Änderungsstand beim Wortlaut', async () => { |
| 113 | /* Ein Gesetzestext ohne Fassungsangabe ist eine Behauptung über heute, |
| 114 | die morgen falsch sein kann. */ |
| 115 | bruecke(BESTAND); |
| 116 | const nutzer = userEvent.setup(); |
| 117 | render(<Normstelle fundstelle={{ gesetz: 'WaffG', norm: '§ 1', absatz: '1' }} />); |
| 118 | |
| 119 | await nutzer.click(await aufklapper('§ 1 Abs. 1 WaffG')); |
| 120 | |
| 121 | expect(screen.getByText(/Waffengesetz – Zuletzt geändert am 20\.3\.2026/u)).toBeInTheDocument(); |
| 122 | expect(screen.getByText(/Amtlicher Wortlaut, unverändert übernommen/u)).toBeInTheDocument(); |
| 123 | }); |
| 124 | |
| 125 | it('trifft bei einer Anlage den zitierten Block', async () => { |
| 126 | bruecke(BESTAND); |
| 127 | const nutzer = userEvent.setup(); |
| 128 | render( |
| 129 | <Normstelle |
| 130 | fundstelle={{ |
| 131 | gesetz: 'WaffG', |
| 132 | norm: 'Anlage 1', |
| 133 | stelle: 'Abschnitt 1 Nr. 1.1', |
| 134 | }} |
| 135 | />, |
| 136 | ); |
| 137 | |
| 138 | await nutzer.click(await aufklapper('Anlage 1 Abschnitt 1 Nr. 1.1 WaffG')); |
| 139 | |
| 140 | expect(screen.getByText(/Schusswaffen sind Gegenstände/u)).toBeInTheDocument(); |
| 141 | expect(screen.getByText('Abschnitt 1')).toBeInTheDocument(); |
| 142 | }); |
| 143 | |
| 144 | it('sagt bei einer Abbildung, warum kein Text dasteht', async () => { |
| 145 | /* Anlage II der BeschussV besteht aus den Beschusszeichen; das amtliche |
| 146 | XML führt sie nur als Bildunterschrift. Das zu sagen ist ehrlicher, als |
| 147 | den Leser suchen zu lassen – und besser, als an eine womöglich falsche |
| 148 | Stelle zu springen. */ |
| 149 | bruecke(BESTAND); |
| 150 | const nutzer = userEvent.setup(); |
| 151 | render( |
| 152 | <Normstelle fundstelle={{ gesetz: 'WaffG', norm: 'Anlage 1', stelle: 'Abbildung 5b' }} />, |
| 153 | ); |
| 154 | |
| 155 | await nutzer.click(await aufklapper('Anlage 1 Abbildung 5b WaffG')); |
| 156 | |
| 157 | expect(screen.getByText(/ist eine Abbildung/u)).toBeInTheDocument(); |
| 158 | expect(screen.getByText(/liegen dieser Anwendung nicht bei/u)).toBeInTheDocument(); |
| 159 | // Die Anlage bleibt erreichbar – beschnitten wird nichts. |
| 160 | expect(screen.getByText('Ganze Anlage anzeigen')).toBeInTheDocument(); |
| 161 | }); |
| 162 | |
| 163 | it('zeigt die ganze Anlage, wenn die Stelle mehrdeutig ist', async () => { |
| 164 | bruecke(BESTAND); |
| 165 | const nutzer = userEvent.setup(); |
| 166 | render( |
| 167 | <Normstelle |
| 168 | fundstelle={{ gesetz: 'WaffG', norm: 'Anlage 1', stelle: 'Abschnitt 9 Nr. 4.2' }} |
| 169 | />, |
| 170 | ); |
| 171 | |
| 172 | await nutzer.click(await aufklapper('Anlage 1 Abschnitt 9 Nr. 4.2 WaffG')); |
| 173 | |
| 174 | expect(screen.getByText(/nicht eindeutig auffinden/u)).toBeInTheDocument(); |
| 175 | const ganze = screen.getByText('Ganze Anlage anzeigen'); |
| 176 | await nutzer.click(ganze); |
| 177 | expect(screen.getByText(/Schusswaffen sind Gegenstände/u)).toBeInTheDocument(); |
| 178 | }); |
| 179 | |
| 180 | it('meldet ehrlich, wenn zu einer Fundstelle nichts vorliegt', async () => { |
| 181 | bruecke(BESTAND); |
| 182 | const nutzer = userEvent.setup(); |
| 183 | render(<Normstelle fundstelle={{ gesetz: 'StGB', norm: '§ 173' }} />); |
| 184 | |
| 185 | await nutzer.click(await aufklapper('§ 173 StGB')); |
| 186 | |
| 187 | expect(screen.getByText(/liegt kein Text bei/u)).toBeInTheDocument(); |
| 188 | }); |
| 189 | |
| 190 | it('trägt für Bildschirmleser die ausgeschriebene Form', async () => { |
| 191 | bruecke(BESTAND); |
| 192 | render(<Normstelle fundstelle={{ gesetz: 'WaffG', norm: '§ 1', absatz: '1' }} />); |
| 193 | |
| 194 | const schalter = await aufklapper('§ 1 Abs. 1 WaffG'); |
| 195 | const summary = schalter.closest('summary'); |
| 196 | |
| 197 | expect(summary).not.toBeNull(); |
| 198 | expect( |
| 199 | within(summary!).getByText(', Paragraf 1 Absatz 1 Waffengesetz, im Wortlaut anzeigen'), |
| 200 | ).toBeInTheDocument(); |
| 201 | }); |
| 202 | |
| 203 | /* |
| 204 | WCAG 2.5.3 (Beschriftung im Namen): Wer die Anwendung mit der Stimme |
| 205 | bedient, sagt, was er sieht. Bis 0.24.1 war das Zitat `aria-hidden`, und |
| 206 | der zugängliche Name enthielt keinen einzigen sichtbaren Bestandteil – |
| 207 | genau die Fehlerfigur F96. |
| 208 | */ |
| 209 | it('nennt im zugänglichen Namen auch den sichtbaren Wortlaut', async () => { |
| 210 | bruecke(BESTAND); |
| 211 | render( |
| 212 | <Normstelle |
| 213 | fundstelle={{ gesetz: 'WaffG', norm: '§ 1', absatz: '1', nummer: '3', buchstabe: 'b' }} |
| 214 | />, |
| 215 | ); |
| 216 | |
| 217 | const summary = (await aufklapper('§ 1 Abs. 1 Nr. 3 Buchst. b WaffG')).closest('summary'); |
| 218 | |
| 219 | expect(summary).not.toBeNull(); |
| 220 | const name = summary?.textContent ?? ''; |
| 221 | for (const teil of ['§ 1', 'Abs. 1', 'Nr. 3', 'Buchst. b', 'WaffG']) { |
| 222 | expect(name).toContain(teil); |
| 223 | } |
| 224 | expect(summary?.querySelector('[aria-hidden="true"]')).toBeNull(); |
| 225 | }); |
| 226 | }); |