/** * Die aufklappbare Fundstelle. * * **Der Anlass.** Jede Erklärungstafel schloss mit „Im Gesetz nachlesen“ und * einer Liste von 1853 Fundstellen – reine Textzitate ohne Sprungziel. Eine * Software, die ausdrücklich vollständig offline arbeitet, forderte damit zu * etwas auf, das offline gerade nicht ging: Ein Normtext lag nicht bei. * * Geprüft wird hier vor allem das Verhalten an den Rändern: ohne * mitgelieferte Texte bleibt es beim Zitat, und eine Anlagenstelle, die sich * nicht eindeutig auflösen lässt, springt nirgendwohin, sondern sagt es. */ import { render, screen, within } from '@testing-library/react'; import userEvent from '@testing-library/user-event'; import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; import { Normstelle } from '../src/renderer/src/components/lernen/Normstelle'; import { normtexteZwischenspeicherLeeren } from '../src/renderer/src/hooks/useNormtexte'; import type { Normtexte } from '../src/shared/normtexte'; const BESTAND: Normtexte = { meta: { version: 1, stand: '2026-08-29', hinweis: 'Auszug', gesetzesstand: { WaffG: 'Zuletzt geändert am 20.3.2026' }, }, gesetze: { WaffG: { bezeichnung: 'Waffengesetz', stand: 'Zuletzt geändert am 20.3.2026', quelle: 'https://www.gesetze-im-internet.de/waffg_2002/', normen: { '§ 1': { titel: 'Gegenstand und Zweck des Gesetzes', istAnlage: false, absaetze: { '1': 'Dieses Gesetz regelt den Umgang mit Waffen oder Munition.' }, }, 'Anlage 1': { titel: '(zu § 1 Abs. 4)', istAnlage: true, bloecke: [ { marke: 'Abschnitt 1', pfad: [], text: 'Abschnitt 1:' }, { marke: '1.1', pfad: ['Abschnitt 1'], text: '1.1\nSchusswaffen sind Gegenstände, bei denen Geschosse getrieben werden.', }, ], }, }, }, }, }; /** * Wartet, bis die Normtexte geladen sind, und liefert den Aufklapper. * * Solange geladen wird, zeigt das Bauteil das reine Zitat – richtig so, aber * ohne diesen Umweg klickte der Test in genau diesen Zwischenzustand. Der * Zusatz „im Wortlaut anzeigen“ gibt es nur in der aufklappbaren Fassung. */ async function aufklapper(zitat: string): Promise { await screen.findAllByText(/im Wortlaut anzeigen/u); return screen.getByText(zitat); } function bruecke(normtexte: Normtexte | null): void { vi.stubGlobal( 'lernApp', normtexte === null ? {} : { normtexteLaden: vi.fn(() => Promise.resolve(normtexte)) }, ); } beforeEach(() => { normtexteZwischenspeicherLeeren(); }); afterEach(() => { vi.unstubAllGlobals(); normtexteZwischenspeicherLeeren(); }); describe('Normstelle', () => { it('bleibt beim Zitat, solange keine Normtexte vorliegen', async () => { /* Ein leerer Aufklapper verspräche etwas, das nicht da ist. Wer ohne die Datei arbeitet, sieht genau das, was er vor 0.22.0 sah. */ bruecke(null); render(); expect(await screen.findByText('§ 1 Abs. 1 WaffG')).toBeInTheDocument(); expect(screen.queryByRole('group')).not.toBeInTheDocument(); }); it('zeigt den Absatz im Wortlaut, wenn man aufklappt', async () => { bruecke(BESTAND); const nutzer = userEvent.setup(); render(); const schalter = await aufklapper('§ 1 Abs. 1 WaffG'); // Vor dem Aufklappen steht der Text nicht im Dokument: Eine Auswertung // führt bis zu 80 Fragen mit je mehreren Fundstellen auf. expect(screen.queryByText(/Dieses Gesetz regelt/u)).not.toBeInTheDocument(); await nutzer.click(schalter); expect(screen.getByText(/Dieses Gesetz regelt den Umgang/u)).toBeInTheDocument(); expect(screen.getByText('Gegenstand und Zweck des Gesetzes')).toBeInTheDocument(); }); it('nennt Gesetz und Änderungsstand beim Wortlaut', async () => { /* Ein Gesetzestext ohne Fassungsangabe ist eine Behauptung über heute, die morgen falsch sein kann. */ bruecke(BESTAND); const nutzer = userEvent.setup(); render(); await nutzer.click(await aufklapper('§ 1 Abs. 1 WaffG')); expect(screen.getByText(/Waffengesetz – Zuletzt geändert am 20\.3\.2026/u)).toBeInTheDocument(); expect(screen.getByText(/Amtlicher Wortlaut, unverändert übernommen/u)).toBeInTheDocument(); }); it('trifft bei einer Anlage den zitierten Block', async () => { bruecke(BESTAND); const nutzer = userEvent.setup(); render( , ); await nutzer.click(await aufklapper('Anlage 1 Abschnitt 1 Nr. 1.1 WaffG')); expect(screen.getByText(/Schusswaffen sind Gegenstände/u)).toBeInTheDocument(); expect(screen.getByText('Abschnitt 1')).toBeInTheDocument(); }); it('sagt bei einer Abbildung, warum kein Text dasteht', async () => { /* Anlage II der BeschussV besteht aus den Beschusszeichen; das amtliche XML führt sie nur als Bildunterschrift. Das zu sagen ist ehrlicher, als den Leser suchen zu lassen – und besser, als an eine womöglich falsche Stelle zu springen. */ bruecke(BESTAND); const nutzer = userEvent.setup(); render( , ); await nutzer.click(await aufklapper('Anlage 1 Abbildung 5b WaffG')); expect(screen.getByText(/ist eine Abbildung/u)).toBeInTheDocument(); expect(screen.getByText(/liegen dieser Anwendung nicht bei/u)).toBeInTheDocument(); // Die Anlage bleibt erreichbar – beschnitten wird nichts. expect(screen.getByText('Ganze Anlage anzeigen')).toBeInTheDocument(); }); it('zeigt die ganze Anlage, wenn die Stelle mehrdeutig ist', async () => { bruecke(BESTAND); const nutzer = userEvent.setup(); render( , ); await nutzer.click(await aufklapper('Anlage 1 Abschnitt 9 Nr. 4.2 WaffG')); expect(screen.getByText(/nicht eindeutig auffinden/u)).toBeInTheDocument(); const ganze = screen.getByText('Ganze Anlage anzeigen'); await nutzer.click(ganze); expect(screen.getByText(/Schusswaffen sind Gegenstände/u)).toBeInTheDocument(); }); it('meldet ehrlich, wenn zu einer Fundstelle nichts vorliegt', async () => { bruecke(BESTAND); const nutzer = userEvent.setup(); render(); await nutzer.click(await aufklapper('§ 173 StGB')); expect(screen.getByText(/liegt kein Text bei/u)).toBeInTheDocument(); }); it('trägt für Bildschirmleser die ausgeschriebene Form', async () => { bruecke(BESTAND); render(); const schalter = await aufklapper('§ 1 Abs. 1 WaffG'); const summary = schalter.closest('summary'); expect(summary).not.toBeNull(); expect( within(summary!).getByText(', Paragraf 1 Absatz 1 Waffengesetz, im Wortlaut anzeigen'), ).toBeInTheDocument(); }); /* WCAG 2.5.3 (Beschriftung im Namen): Wer die Anwendung mit der Stimme bedient, sagt, was er sieht. Bis 0.24.1 war das Zitat `aria-hidden`, und der zugängliche Name enthielt keinen einzigen sichtbaren Bestandteil – genau die Fehlerfigur F96. */ it('nennt im zugänglichen Namen auch den sichtbaren Wortlaut', async () => { bruecke(BESTAND); render( , ); const summary = (await aufklapper('§ 1 Abs. 1 Nr. 3 Buchst. b WaffG')).closest('summary'); expect(summary).not.toBeNull(); const name = summary?.textContent ?? ''; for (const teil of ['§ 1', 'Abs. 1', 'Nr. 3', 'Buchst. b', 'WaffG']) { expect(name).toContain(teil); } expect(summary?.querySelector('[aria-hidden="true"]')).toBeNull(); }); });