waffensachkunde

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

/ app tests normstelle.test.tsx

8,2 KB Rohdatei
app/tests/normstelle.test.tsx — 226 Zeilen
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 });