waffensachkunde

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

/ app tests renderer-ueber.test.tsx

28,9 KB Rohdatei
app/tests/renderer-ueber.test.tsx — 754 Zeilen
1 /**
2 * Verhaltenstests des Bereichs „Über diese Software“.
3 *
4 * Die Lizenzangaben sind keine Höflichkeit: MIT, BSD und Apache verlangen,
5 * dass Lizenztext und Urheberrechtsvermerk mitgeliefert werden, und der
6 * amtliche Fragenkatalog darf nur mit Quellenangabe wiedergegeben werden.
7 * Geprüft wird deshalb, dass diese Angaben sichtbar sind – auch dann, wenn der
8 * Anwendungskern den Kanal `lizenzen:lesen` nicht anbietet.
9 */
10
11 import { render, screen, waitFor, within } from '@testing-library/react';
12 import userEvent from '@testing-library/user-event';
13 import axe from 'axe-core';
14 import { jsdomOptionen } from '../src/shared/wcag';
15 import { afterEach, describe, expect, it, vi } from 'vitest';
16
17 import { App } from '../src/renderer/src/App';
18 import { erklaerungenZwischenspeicherLeeren } from '../src/renderer/src/hooks/useErklaerungen';
19 import { KONTAKT } from '../src/shared/kontakt';
20 import {
21 UNTERSTUETZUNG_KNOPF,
22 UNTERSTUETZUNG_TITEL,
23 UNTERSTUETZUNG_URL,
24 unterstuetzungsziel,
25 } from '../src/shared/unterstuetzung';
26 import { EINSTELLUNGEN_STANDARD, type AnwendungsInfo, type LernAppBridge } from '../src/shared/ipc';
27 import type { Datenschutzerklaerung } from '../src/shared/datenschutz';
28 import type { Erklaerungen } from '../src/shared/erklaerungen';
29 import type { Glossar } from '../src/shared/glossar';
30 import type { Lernplan } from '../src/shared/lernplan';
31 import type { Katalog, RichText } from '../src/shared/katalog';
32 import type { FrageStand, Lernuebersicht, Profil } from '../src/shared/lernstand';
33 import {
34 EUPL_HINWEIS,
35 EUPL_KENNUNG,
36 KATALOG_HINWEIS,
37 type Lizenzangaben,
38 } from '../src/shared/lizenzen';
39 import type { Pruefungsergebnis } from '../src/shared/pruefung';
40 import { NORMTEXTE_LEER } from '../src/shared/normtexte';
41 import { THEMEN_LEER } from '../src/shared/themen';
42
43 type Nutzer = ReturnType<typeof userEvent.setup>;
44
45 // ─── Attrappen ──────────────────────────────────────────────────────────
46
47 function nurText(text: string): RichText {
48 return { text, segmente: [{ t: text }] };
49 }
50
51 const KATALOG: Katalog = {
52 meta: {
53 titel: 'Fragenkatalog für die Sachkundeprüfung (gemäß § 7 WaffG)',
54 herausgeber: 'Bundesverwaltungsamt',
55 stand: '2024-12-16',
56 quellenangabe:
57 'Amtlicher Fragenkatalog für die Sachkundeprüfung des Bundesverwaltungsamtes, Stand 16.12.2024.',
58 quelle_url: 'https://www.bva.bund.de/',
59 quelldatei_sha256: '7e5e41fa',
60 fragen_gesamt: 575,
61 },
62 kapitel: [{ id: 'I', titel: 'Waffenrecht', abschnitte: [{ id: 'I.1', titel: 'Begriffe' }] }],
63 bilder: [],
64 fragen: [
65 {
66 id: 'I.1-87',
67 amtliche_nummer: '1.87',
68 kapitel: 'I',
69 abschnitt: 'I.1',
70 typ: 'mc',
71 seite: 23,
72 frage: nurText('Welcher Gegenstand ist eine Anscheinswaffe?'),
73 bilder: [],
74 optionen: [
75 { label: 'a', inhalt: nurText('Sportgewehr'), korrekt: false, bilder: [] },
76 { label: 'b', inhalt: nurText('Spielzeugpistole'), korrekt: true, bilder: [] },
77 ],
78 },
79 ],
80 };
81
82 const PROFIL: Profil = {
83 id: 1,
84 name: 'Testprofil',
85 pruefungstermin: null,
86 kapitelAusschluss: [],
87 erstelltAm: '2026-01-01T08:00:00.000Z',
88 };
89
90 const UEBERSICHT: Lernuebersicht = {
91 fragenGesamt: 575,
92 beantwortet: 0,
93 belegt: 0,
94 reifegrad: 0 / 575,
95 stufe: 'ohne_beleg',
96 deckelnd: [],
97 faellig: 0,
98 gemerkt: 0,
99 offen: 0,
100 fehler: 0,
101 heuteRichtig: 0,
102 heuteFalsch: 0,
103 heuteBearbeitet: 0,
104 tageSeitLetzterAntwort: null,
105 bereiche: [],
106 themengruppen: [],
107 };
108
109 const STAND: FrageStand = {
110 frageId: 'I.1-87',
111 versuche: 0,
112 richtige: 0,
113 zuletztBeantwortet: null,
114 faelligAb: null,
115 gemerkt: false,
116 letzteBewertung: null,
117 };
118
119 const INFO: AnwendungsInfo = {
120 anwendungsVersion: '0.0.0-attrappe',
121 baukennung: 'abc1234',
122 baustand: '2026-08-21',
123 electronVersion: '43.4.1',
124 chromeVersion: '140.0.0.0',
125 nodeVersion: '22.19.1',
126 plattform: 'win32',
127 datenbank: { verfuegbar: true, sqliteVersion: '3.50.2', meldung: 'SQLite 3.50.2 bereit.' },
128 };
129
130 const ERGEBNIS: Pruefungsergebnis = {
131 profilId: 'standard',
132 gesamt: 0,
133 richtig: 0,
134 falsch: 0,
135 unbeantwortet: 0,
136 quote: 0,
137 urteil: 'nicht_bestanden',
138 begruendung: '0 Prozent richtig, nötig waren 80 Prozent.',
139 verletzteKriterien: [],
140 bereiche: [],
141 fehlerIds: [],
142 dauerMs: 0,
143 zeitAbgelaufen: false,
144 zeitpunkt: '2026-01-01T08:00:00.000Z',
145 };
146
147 const EUPL_VOLLTEXT = [
148 'EUROPÄISCHE UNION – ÖFFENTLICHE LIZENZ v. 1.2',
149 '',
150 'Diese europäische Union Public Licence (die „EUPL“) gilt für das Werk, das',
151 'nachstehend definiert wird und das unter den Bedingungen dieser Lizenz zur',
152 'Verfügung gestellt wird.',
153 ].join('\n');
154
155 const MIT_TEXT = [
156 'MIT License',
157 '',
158 'Copyright (c) Meta Platforms, Inc. and affiliates.',
159 '',
160 'Permission is hereby granted, free of charge, to any person obtaining a copy',
161 'of this software and associated documentation files (the "Software"), to deal',
162 'in the Software without restriction.',
163 ].join('\n');
164
165 /**
166 * Eine kleine, aber echte Erklärung.
167 *
168 * Bewusst nicht die ausgelieferte: Diese Datei prüft die Oberfläche, nicht
169 * den Inhalt der Erklärung. Ob die mitgelieferte Fassung zur Quelle passt,
170 * hält `tests/datenschutz.test.ts` fest.
171 */
172 const ERKLAERUNG: Datenschutzerklaerung = {
173 quellpruefsumme: 'a'.repeat(64),
174 fassung: '1.5',
175 stand: '30.08.2026',
176 bloecke: [
177 { art: 'ueberschrift', ebene: 2, text: '1. Wer verantwortlich ist', kennung: 'wer' },
178 { art: 'absatz', teile: [{ art: 'text', text: 'Ein Satz aus der Erklärung.' }] },
179 ],
180 };
181
182 const LIZENZEN: Lizenzangaben = {
183 eigene: {
184 kennung: EUPL_KENNUNG,
185 name: 'European Union Public Licence, Version 1.2',
186 hinweis: EUPL_HINWEIS,
187 text: EUPL_VOLLTEXT,
188 },
189 katalog: {
190 quellenangabe: KATALOG.meta.quellenangabe,
191 quelleUrl: KATALOG.meta.quelle_url,
192 hinweis: KATALOG_HINWEIS,
193 },
194 dritte: [
195 {
196 name: 'React',
197 version: '19.2.8',
198 lizenz: 'MIT',
199 url: 'https://react.dev/',
200 lizenztext: MIT_TEXT,
201 },
202 {
203 name: 'better-sqlite3',
204 version: '13.0.3',
205 lizenz: 'MIT',
206 url: null,
207 lizenztext: MIT_TEXT,
208 },
209 {
210 name: 'Chromium und Electron',
211 version: null,
212 lizenz: 'BSD-3-Clause und weitere',
213 url: null,
214 lizenztext: null,
215 hinweis: 'Sammelposten: Electron liefert Chromium und Node.js mit eigenen Lizenzen mit.',
216 },
217 {
218 // Ohne Text UND ohne Hinweis – hier muss die Ansicht es aussprechen.
219 name: 'Beispielpaket',
220 version: '1.0.0',
221 lizenz: 'ISC',
222 url: null,
223 lizenztext: null,
224 },
225 ],
226 };
227
228 /** Lernplan für die Attrappe – ohne Termin, damit nichts vom Datum abhängt. */
229 const LERNPLAN: Lernplan = {
230 termin: null,
231 tageBisTermin: null,
232 gesamtFragen: 8,
233 nieBeantwortet: 6,
234 faellig: 1,
235 zielquote: 0.9,
236 prognoseHeute: 0.25,
237 prognoseAmTermin: null,
238 pensum: { neu: 0, wiederholung: 1, gesamt: 1, minuten: 1 },
239 machbarkeit: 'kein_termin',
240 sekundenProFrage: 25,
241 };
242
243 /** Erklaerungen fuer die Attrappe – ein Eintrag genuegt fuer die Anzeige. */
244 const ERKLAERUNGEN: Erklaerungen = {
245 meta: {
246 version: 1,
247 stand: '2026-08-20',
248 gesetzesstand: { WaffG: 'Pruefstand' },
249 hinweis: 'Eigener redaktioneller Inhalt, nicht Teil des amtlichen Katalogs.',
250 },
251 zuFrage: {
252 'I.1-01': {
253 kurz: 'Das Waffengesetz regelt den Umgang mit Waffen und Munition.',
254 text: 'Massgeblich ist § 1 Abs. 1 WaffG: Gegenstand ist der Umgang, Zweck die oeffentliche Sicherheit und Ordnung.',
255 fundstellen: [{ gesetz: 'WaffG', norm: '§ 1', absatz: '1' }],
256 merksatz: 'Gegenstand: der Umgang. Zweck: die Sicherheit.',
257 },
258 },
259 };
260
261 /** Glossar fuer die Attrappe – zwei Eintraege genuegen fuer die Anzeige. */
262 const GLOSSAR: Glossar = {
263 meta: {
264 version: 1,
265 stand: '2026-08-21',
266 gesetzesstand: { WaffG: 'Pruefstand' },
267 hinweis: 'Eigener redaktioneller Inhalt.',
268 },
269 eintraege: [
270 {
271 begriff: 'Führen',
272 art: 'begriff',
273 kurz: 'Die tatsächliche Gewalt über eine Waffe außerhalb der eigenen Wohnung ausüben.',
274 varianten: ['führt', 'führen', 'geführt'],
275 fundstellen: [{ gesetz: 'WaffG', norm: 'Anlage 1', stelle: 'Abschnitt 2 Nr. 4' }],
276 siehe: ['WaffG'],
277 },
278 {
279 begriff: 'WaffG',
280 art: 'abkuerzung',
281 kurz: 'Waffengesetz.',
282 varianten: ['WaffG', 'Waffengesetzes', 'Waffengesetz'],
283 fundstellen: [],
284 ohneFundstelleGrund:
285 'Die amtliche Kurzbezeichnung eines Gesetzes steht nicht im Gesetz selbst.',
286 },
287 ],
288 };
289
290 function attrappe(ueberschreibungen: Partial<LernAppBridge> = {}): LernAppBridge {
291 return {
292 anwendungsInfoLesen: vi.fn(() => Promise.resolve(INFO)),
293 anzeigegroesseSetzen: vi.fn((prozent: number) => Promise.resolve(prozent)),
294 anzeigegroesseBeobachten: vi.fn(() => () => undefined),
295 hilfsmittelAktiv: vi.fn(() => Promise.resolve(false)),
296 hilfsmittelBeobachten: vi.fn(() => () => undefined),
297 menuebefehlBeobachten: vi.fn(() => () => undefined),
298 /* Die Erststart-Frage nach dem abwaehlbaren Kapitel ist beantwortet:
299 Diese Pruefungen sehen den Startbildschirm an, nicht das Tor davor. */
300 einstellungenLesen: vi.fn(() =>
301 Promise.resolve({ ...EINSTELLUNGEN_STANDARD, zuschnittGefragt: true }),
302 ),
303 einstellungenSchreiben: vi.fn(() => Promise.resolve(EINSTELLUNGEN_STANDARD)),
304 katalogLaden: vi.fn(() => Promise.resolve(KATALOG)),
305 katalogBild: vi.fn(() => Promise.resolve('data:image/png;base64,')),
306 erklaerungenLaden: vi.fn(() => Promise.resolve(ERKLAERUNGEN)),
307 glossarLaden: vi.fn(() => Promise.resolve(GLOSSAR)),
308 /* Ohne Normtexte bleibt es beim Zitat – genau wie vor 0.22.0.
309 Diese Pruefungen sehen die Fundstellen an, nicht den Gesetzestext;
310 den prueft tests/normstelle.test.tsx an eigenen Faellen. */
311 normtexteLaden: vi.fn(() => Promise.resolve(NORMTEXTE_LEER)),
312 themenLaden: vi.fn(() => Promise.resolve(THEMEN_LEER)),
313 profilListe: vi.fn(() => Promise.resolve([PROFIL])),
314 profilAnlegen: vi.fn(() => Promise.resolve(PROFIL)),
315 profilAktualisieren: vi.fn(() => Promise.resolve(PROFIL)),
316 profilLoeschen: vi.fn(() => Promise.resolve([PROFIL])),
317 lernSitzung: vi.fn(() => Promise.resolve([])),
318 lernAntworten: vi.fn(() => Promise.resolve(STAND)),
319 lernMerken: vi.fn(() => Promise.resolve(STAND)),
320 lernUebersicht: vi.fn(() => Promise.resolve(UEBERSICHT)),
321 lernPlan: vi.fn(() => Promise.resolve(LERNPLAN)),
322 lernZuruecksetzen: vi.fn(() => Promise.resolve(UEBERSICHT)),
323 pruefungStarten: vi.fn(() => Promise.resolve({ fragen: [], warnungen: [] })),
324 pruefungAuswerten: vi.fn(() => Promise.resolve(ERGEBNIS)),
325 pruefungVerlauf: vi.fn(() => Promise.resolve([])),
326 pruefungSichern: vi.fn(() => Promise.resolve(true)),
327 pruefungOffen: vi.fn(() => Promise.resolve(null)),
328 pruefungVerwerfen: vi.fn(() => Promise.resolve()),
329 pruefungLaeuft: vi.fn(() => Promise.resolve()),
330 sicherungUebernehmen: vi.fn(() => Promise.resolve({ art: 'abgelaufen' as const })),
331 fehlerprotokollDrucken: vi.fn(() =>
332 Promise.resolve({ gespeichert: false, pfad: null, bytes: 0 }),
333 ),
334 fragenlisteDrucken: vi.fn(() => Promise.resolve({ gespeichert: false, pfad: null, bytes: 0 })),
335 lernberichtDrucken: vi.fn(() =>
336 Promise.resolve({ gespeichert: true, pfad: 'C:/Doks/Lernbericht.pdf', bytes: 4096 }),
337 ),
338 /* Die Erklärung selbst prüft `renderer-ueber.test.tsx`; hier genügt,
339 dass die Brücke den Kanal kennt. */
340 datenschutzLesen: vi.fn(() => Promise.resolve({ erklaerung: ERKLAERUNG, warnung: null })),
341 lizenzenLesen: vi.fn(() => Promise.resolve(LIZENZEN)),
342 /* Der Kanal nach außen. Er gehört in die Attrappe, weil das
343 Unterstützungsangebot ohne ihn ohnehin entfällt – sonst prüfte der Test
344 unten den falschen Grund für dessen Fehlen. */
345 unterstuetzungOeffnen: vi.fn(() => Promise.resolve(true)),
346 ...ueberschreibungen,
347 };
348 }
349
350 /** Bridge-Sicht, in der einzelne Kanäle fehlen dürfen. */
351 type LueckenhafteBruecke = {
352 -readonly [K in keyof LernAppBridge]?: LernAppBridge[K] | undefined;
353 };
354
355 /** Bridge ohne einen bestimmten Kanal – der Anwendungskern entsteht parallel. */
356 function ohneKanal(kanal: keyof LernAppBridge): LueckenhafteBruecke {
357 const bruecke: LueckenhafteBruecke = { ...attrappe() };
358 bruecke[kanal] = undefined;
359 return bruecke;
360 }
361
362 /** Rendert die Anwendung und öffnet „Über diese Software“. */
363 async function ueberOeffnen(bruecke: LueckenhafteBruecke = attrappe()): Promise<Nutzer> {
364 vi.stubGlobal('lernApp', bruecke);
365 const nutzer = userEvent.setup();
366 render(<App />);
367
368 const knopf = await screen.findByRole('button', { name: 'Lizenzen und Herkunft anzeigen' });
369 await nutzer.click(knopf);
370 await screen.findByRole('heading', { name: 'Über diese Software', level: 1 });
371 return nutzer;
372 }
373
374 /** Der Bereich selbst – der Startbildschirm trägt denselben Abschnittstitel. */
375 function ueberbereich(): HTMLElement {
376 return screen.getByRole('region', { name: 'Über diese Software' });
377 }
378
379 afterEach(() => {
380 vi.unstubAllGlobals();
381 /* Die Erklaerungen liegen in einem Modul-weiten Zwischenspeicher – ohne
382 Leeren wuerde der naechste Test die Antwort des vorigen sehen. */
383 erklaerungenZwischenspeicherLeeren();
384 });
385
386 // ─── Tests ──────────────────────────────────────────────────────────────
387
388 describe('Über diese Software – Rückmeldung und Kontakt', () => {
389 /*
390 Prüfschritt 9.2.2 verlangt einen Rückmeldeweg, der vorhanden UND bedient
391 ist. „Vorhanden" lässt sich hier prüfen, „bedient" nicht – deshalb prüfen
392 diese Tests, dass die Zusage überhaupt dasteht und dass der Kanal ohne
393 Maus und ohne Telefon erreichbar ist.
394 */
395
396 it('nennt einen Meldeweg, eine Frist und wofür er da ist', async () => {
397 await ueberOeffnen();
398
399 const block = screen
400 .getByRole('heading', { name: 'Rückmeldung und Kontakt', level: 2 })
401 .closest('div') as HTMLElement;
402
403 expect(within(block).getByText(KONTAKT.epost)).toBeInTheDocument();
404 expect(within(block).getByText(KONTAKT.name)).toBeInTheDocument();
405 expect(within(block).getByText(/Antwort innerhalb eines Monats/u)).toBeInTheDocument();
406 // Ohne benanntes Anliegen ist ein Kanal kein Rückmeldeweg, sondern eine Adresse.
407 expect(within(block).getAllByRole('listitem').length).toBeGreaterThanOrEqual(3);
408 expect(within(block).getByText(/Barrieren in der Bedienung melden/u)).toBeInTheDocument();
409 });
410
411 it('zeigt weder eine Rufnummer noch eine Anschrift', async () => {
412 /*
413 Telefon und Ort sind aus `KONTAKT` entfernt – 9.2.3 verlangt einen
414 Kanal, der nicht ausschließlich Telefon ist, und E-Mail allein erfüllt
415 ihn. Geprüft wird am gerenderten Block und nicht an der Konstanten:
416 Als Festtext ließen sich die Zeilen hier wieder einsetzen, ohne
417 `KONTAKT` je anzufassen. Zugleich hält der Test fest, dass die
418 Aufzählung keine Beschriftung ohne Inhalt zurücklässt.
419 */
420 await ueberOeffnen();
421
422 const block = screen
423 .getByRole('heading', { name: 'Rückmeldung und Kontakt', level: 2 })
424 .closest('div') as HTMLElement;
425 const text = block.textContent;
426
427 expect(text).toContain(KONTAKT.epost);
428 expect(text).not.toMatch(/Telefon|Anschrift/u);
429 expect(text).not.toMatch(/\b0\d{3,4}[ /-]\d{5,9}\b/u);
430 expect(text).not.toMatch(/\b\d{5}\s+[A-ZÄÖÜ][a-zäöüß]/u);
431
432 const liste = block.querySelector('.ueber__angaben')!;
433 const beschriftungen = [...liste.querySelectorAll('dt')].map((dt) => dt.textContent);
434 const angaben = [...liste.querySelectorAll('dd')].map((dd) => dd.textContent.trim());
435
436 expect(beschriftungen).toEqual(['Verantwortlich', 'E-Mail']);
437 expect(angaben).toEqual([KONTAKT.name, KONTAKT.epost]);
438 });
439
440 it('steht vor den Lizenzblöcken', async () => {
441 /* Wer hierherkommt, weil er nicht weiterkommt, soll den Weg hinaus nicht
442 hinter vier Rechtsblöcken suchen müssen. */
443 await ueberOeffnen();
444
445 const ueberschriften = screen.getAllByRole('heading', { level: 2 }).map((h) => h.textContent);
446
447 expect(ueberschriften[0]).toBe('Rückmeldung und Kontakt');
448 });
449
450 it('hält die Zusage, von sich aus keine Verbindung zu öffnen', async () => {
451 /* Kein mailto:, kein tel: – die Anwendung startet kein fremdes Programm.
452 Die Angaben stehen als auswählbarer Text da. */
453 await ueberOeffnen();
454
455 for (const verweis of screen.queryAllByRole('link')) {
456 expect(verweis.getAttribute('href') ?? '').not.toMatch(/^(mailto|tel):/u);
457 }
458 });
459
460 it('macht die Angaben nicht unkopierbar', async () => {
461 /*
462 Es lag der Wunsch vor, die Kontaktdaten unkopierbar zu zeigen. Das ist
463 bewusst nicht umgesetzt: Als Bild verstieße es gegen WCAG 1.4.5 (Stufe
464 AA), und `user-select: none` sperrt ausgerechnet jene aus, die eine
465 lange Adresse nicht abtippen können – im Block, der Barrieren melden
466 soll. Dieser Test hält die Entscheidung fest.
467 */
468 await ueberOeffnen();
469
470 const adresse = screen.getByText(KONTAKT.epost);
471
472 expect(adresse.tagName).not.toBe('IMG');
473 expect(adresse.closest('[style*="user-select"]')).toBeNull();
474 expect(adresse).toHaveTextContent(KONTAKT.epost);
475 });
476 });
477
478 describe('Über diese Software – Datenschutz', () => {
479 it('führt einen eigenen Abschnitt mit Fassung und Stand', async () => {
480 /*
481 Bis Fassung 0.24.2 kam das Wort „Datenschutzerklärung“ in der ganzen
482 Oberfläche kein einziges Mal als sichtbarer Text vor. Zugleich sagte
483 dieser Bereich zu, die Anwendung übertrage keine Daten – eine Zusage,
484 deren Beleg der Nutzende nicht erreichen konnte.
485 */
486 await ueberOeffnen();
487 const bereich = ueberbereich();
488
489 expect(
490 within(bereich).getByRole('heading', { name: 'Datenschutz', level: 2 }),
491 ).toBeInTheDocument();
492 expect(within(bereich).getByText(/Fassung 1\.5, Stand 30\.08\.2026/u)).toBeInTheDocument();
493 });
494
495 it('klappt die Erklärung erst auf Anforderung auf', async () => {
496 /* 144 Blöcke ausgeklappt schöben alles darunter um mehrere
497 Bildschirmhöhen nach unten; wer per Tabulator arbeitet, käme nur noch
498 durch das ganze Rechtsdokument dorthin. */
499 const nutzer = await ueberOeffnen();
500 const bereich = ueberbereich();
501
502 const schalter = within(bereich).getByRole('button', { name: 'Datenschutzerklärung lesen' });
503 expect(schalter).toHaveAttribute('aria-expanded', 'false');
504 expect(within(bereich).queryByText('Ein Satz aus der Erklärung.')).not.toBeInTheDocument();
505
506 await nutzer.click(schalter);
507
508 expect(
509 within(bereich).getByRole('button', { name: 'Datenschutzerklärung einklappen' }),
510 ).toHaveAttribute('aria-expanded', 'true');
511 expect(within(bereich).getByText('Ein Satz aus der Erklärung.')).toBeInTheDocument();
512 });
513
514 it('bietet aufgeklappt ein Sprungziel je Abschnitt', async () => {
515 const nutzer = await ueberOeffnen();
516 const bereich = ueberbereich();
517 await nutzer.click(within(bereich).getByRole('button', { name: 'Datenschutzerklärung lesen' }));
518
519 const verweis = within(bereich).getByRole('link', { name: '1. Wer verantwortlich ist' });
520 expect(verweis).toHaveAttribute('href', '#datenschutz-wer');
521 /* Und das Ziel gibt es auch – ein Sprungziel ins Leere ist schlimmer als
522 keines, weil der Fokus dann stehen bleibt, ohne dass es auffällt. */
523 expect(bereich.querySelector('#datenschutz-wer')).not.toBeNull();
524 });
525
526 it('springt in der Überschriftenfolge nicht', async () => {
527 /* Der Bereich führt h1 und h2; die Erklärung beginnt intern bei h1 und
528 wird deshalb ab h3 gezeigt. Ein Sprung wäre ein Verstoß gegen
529 WCAG 1.3.1 – und die Ebenen sind das Verzeichnis, das ein
530 Bildschirmleser wirklich benutzt. */
531 const nutzer = await ueberOeffnen();
532 const bereich = ueberbereich();
533 await nutzer.click(within(bereich).getByRole('button', { name: 'Datenschutzerklärung lesen' }));
534
535 const ebenen = [...bereich.querySelectorAll('h1, h2, h3, h4, h5, h6')].map((k) =>
536 Number(k.tagName.slice(1)),
537 );
538 for (let i = 1; i < ebenen.length; i++) {
539 expect(
540 (ebenen[i] ?? 0) - (ebenen[i - 1] ?? 0),
541 `Sprung von h${String(ebenen[i - 1])} auf h${String(ebenen[i])}`,
542 ).toBeLessThanOrEqual(1);
543 }
544 });
545 });
546
547 describe('Über diese Software – Lizenz der Anwendung', () => {
548 it('nennt Kennung und EUPL-Hinweis im Klartext', async () => {
549 await ueberOeffnen();
550 const bereich = ueberbereich();
551
552 expect(
553 within(bereich).getByRole('heading', { name: 'Lizenz dieser Anwendung', level: 2 }),
554 ).toBeInTheDocument();
555 expect(
556 within(bereich).getByText('European Union Public Licence, Version 1.2 (EUPL-1.2)'),
557 ).toBeInTheDocument();
558 expect(within(bereich).getByText(EUPL_HINWEIS)).toBeInTheDocument();
559 });
560
561 it('hält den Lizenzvolltext in einem aufklappbaren, scrollbaren Bereich bereit', async () => {
562 const nutzer = await ueberOeffnen();
563 const bereich = ueberbereich();
564
565 const schalter = within(bereich).getByText('Volltext der Lizenz anzeigen');
566 const aufklapper = schalter.closest('details');
567 expect(aufklapper).not.toBeNull();
568 expect(aufklapper).not.toHaveAttribute('open');
569
570 await nutzer.click(schalter);
571 expect(aufklapper).toHaveAttribute('open');
572
573 const textbereich = within(bereich).getByRole('group', { name: 'Volltext der Lizenz' });
574 expect(textbereich).toHaveTextContent('EUROPÄISCHE UNION – ÖFFENTLICHE LIZENZ v. 1.2');
575 // Scrollbare Bereiche müssen den Fokus annehmen können (WCAG 2.1.1).
576 expect(textbereich).toHaveAttribute('tabindex', '0');
577 });
578 });
579
580 describe('Über diese Software – Fragenkatalog', () => {
581 it('zeigt Rechtslage, Quellenangabe und Katalogstand', async () => {
582 await ueberOeffnen();
583 const bereich = ueberbereich();
584
585 expect(
586 within(bereich).getByRole('heading', { name: 'Rechtslage des Fragenkatalogs', level: 2 }),
587 ).toBeInTheDocument();
588 expect(within(bereich).getByText(KATALOG_HINWEIS)).toBeInTheDocument();
589 expect(within(bereich).getByText(KATALOG.meta.quellenangabe)).toBeInTheDocument();
590 expect(within(bereich).getByText('https://www.bva.bund.de/')).toBeInTheDocument();
591 expect(within(bereich).getByText('16.12.2024')).toBeInTheDocument();
592 expect(within(bereich).getByText('Bundesverwaltungsamt')).toBeInTheDocument();
593 });
594 });
595
596 describe('Über diese Software – Fremdkomponenten', () => {
597 it('führt jede Komponente mit Version und Lizenz auf', async () => {
598 await ueberOeffnen();
599 const bereich = ueberbereich();
600
601 const liste = within(bereich).getByRole('list', { name: 'Mitgelieferte Fremdkomponenten' });
602 const eintraege = within(liste).getAllByRole('listitem');
603 expect(eintraege).toHaveLength(4);
604
605 expect(within(liste).getByText('React 19.2.8 – MIT')).toBeInTheDocument();
606 expect(within(liste).getByText('better-sqlite3 13.0.3 – MIT')).toBeInTheDocument();
607 expect(
608 within(liste).getByText('Chromium und Electron – BSD-3-Clause und weitere'),
609 ).toBeInTheDocument();
610 expect(within(liste).getByText('Beispielpaket 1.0.0 – ISC')).toBeInTheDocument();
611 expect(within(liste).getByText(/Sammelposten/u)).toBeInTheDocument();
612 });
613
614 it('nennt die Quellenangabe auch bei kaputtem Katalog', async () => {
615 /* Lädt der Katalog nicht, liefert der Kern leere Zeichenketten. Eine
616 leere Zeile unter „Quellenangabe" wäre die schlechteste Antwort – die
617 Pflichtangabe steht als Festtext ohnehin bereit. */
618 const kaputt: Lizenzangaben = {
619 ...LIZENZEN,
620 katalog: { ...LIZENZEN.katalog, quellenangabe: '', quelleUrl: '' },
621 };
622 await ueberOeffnen(attrappe({ lizenzenLesen: vi.fn(() => Promise.resolve(kaputt)) }));
623 const bereich = ueberbereich();
624
625 expect(within(bereich).getByText(/Amtlicher Fragenkatalog/u)).toBeInTheDocument();
626 expect(within(bereich).getByText(/bva\.bund\.de/u)).toBeInTheDocument();
627 });
628
629 it('schweigt, wo ein Hinweis den fehlenden Text bereits erklärt', async () => {
630 /* Beim Sammelposten Chromium steht, dass der Lizenztext der Installation
631 als eigene Datei beiliegt. Ein „kein Lizenztext" darunter widerspräche
632 dem unmittelbar. */
633 await ueberOeffnen();
634 const bereich = ueberbereich();
635
636 expect(within(bereich).getByText(/Sammelposten: Electron liefert/u)).toBeInTheDocument();
637 expect(
638 within(bereich).getAllByText('In dieser Ansicht ist kein Lizenztext hinterlegt.'),
639 ).toHaveLength(1);
640 });
641
642 it('macht jeden Lizenztext einzeln aufklappbar', async () => {
643 await ueberOeffnen();
644 const bereich = ueberbereich();
645
646 expect(within(bereich).getByRole('group', { name: 'Lizenztext von React' })).toHaveTextContent(
647 'Permission is hereby granted',
648 );
649 expect(
650 within(bereich).getByRole('group', { name: 'Lizenztext von better-sqlite3' }),
651 ).toBeInTheDocument();
652
653 // Ohne mitgelieferten Text wird das ausdrücklich gesagt, statt zu schweigen.
654 expect(
655 within(bereich).getByText('In dieser Ansicht ist kein Lizenztext hinterlegt.'),
656 ).toBeInTheDocument();
657 });
658 });
659
660 describe('Über diese Software – Ausfall des Anwendungskerns', () => {
661 it('zeigt die Pflichtangaben auch ohne den Kanal „lizenzen:lesen“', async () => {
662 await ueberOeffnen(ohneKanal('lizenzenLesen'));
663 const bereich = ueberbereich();
664
665 expect(within(bereich).getByText(/lizenzen:lesen/u)).toBeInTheDocument();
666 expect(within(bereich).getByText(EUPL_HINWEIS)).toBeInTheDocument();
667 expect(within(bereich).getByText(KATALOG_HINWEIS)).toBeInTheDocument();
668 expect(within(bereich).getByText(/EUPL-1\.2/u)).toBeInTheDocument();
669 // Der Katalogstand stammt aus dem Katalog, nicht aus den Lizenzangaben.
670 expect(within(bereich).getByText('16.12.2024')).toBeInTheDocument();
671 });
672
673 it('meldet einen Fehler des Kanals, ohne die Ansicht zu verlieren', async () => {
674 await ueberOeffnen(
675 attrappe({ lizenzenLesen: vi.fn(() => Promise.reject(new Error('Datei fehlt'))) }),
676 );
677
678 await waitFor(() => {
679 expect(screen.getByText(/Datei fehlt/u)).toBeInTheDocument();
680 });
681 expect(within(ueberbereich()).getByText(EUPL_HINWEIS)).toBeInTheDocument();
682 });
683 });
684
685 describe('Über diese Software – Unterstützungsangebot', () => {
686 /*
687 Bewusst an den heutigen Wert von `UNTERSTUETZUNG_URL` gebunden statt an
688 „leer“: Der Eintrag der Adresse ist eine Einzeiler-Änderung und soll keinen
689 roten Lauf auslösen. Geprüft wird deshalb die Verdrahtung – dass die
690 Ansicht genau dann etwas zeigt, wenn eine gültige Adresse eingetragen ist,
691 und in beiden Fällen nichts halbes stehen lässt. Das Verhalten des Blocks
692 selbst prüft `tests/renderer-unterstuetzung.test.tsx` in beiden Zuständen.
693 */
694 const ziel = unterstuetzungsziel(UNTERSTUETZUNG_URL);
695
696 it('zeigt das Angebot genau dann, wenn eine Adresse eingetragen ist', async () => {
697 await ueberOeffnen();
698 const bereich = ueberbereich();
699
700 if (ziel === null) {
701 expect(within(bereich).queryByRole('heading', { name: UNTERSTUETZUNG_TITEL })).toBeNull();
702 expect(within(bereich).queryByRole('button', { name: UNTERSTUETZUNG_KNOPF })).toBeNull();
703 // Auch kein Rest: keine Überschrift ohne Block, kein Wort ohne Knopf.
704 expect(bereich.textContent).not.toContain(UNTERSTUETZUNG_TITEL);
705 return;
706 }
707
708 expect(
709 within(bereich).getByRole('heading', { name: UNTERSTUETZUNG_TITEL, level: 2 }),
710 ).toBeInTheDocument();
711 expect(within(bereich).getByRole('button', { name: UNTERSTUETZUNG_KNOPF })).toBeInTheDocument();
712 expect(within(bereich).getByText(ziel)).toBeInTheDocument();
713 });
714
715 it('stellt die Bitte hinter den Rückmeldeweg und hinter die Pflichtangaben', async () => {
716 /*
717 Die Reihenfolge ist die eigentliche Entscheidung. Wer diese Ansicht
718 öffnet, weil er auf eine Barriere gestoßen ist, darf nicht zuerst um Geld
719 gebeten werden; und die Lizenzhinweise sind geschuldet, diese Bitte ist
720 es nicht. Deshalb: Rückmeldung zuerst, Unterstützung zuletzt.
721 */
722 await ueberOeffnen();
723
724 const ueberschriften = screen.getAllByRole('heading', { level: 2 }).map((h) => h.textContent);
725
726 expect(ueberschriften[0]).toBe('Rückmeldung und Kontakt');
727 if (ziel !== null) {
728 expect(ueberschriften[ueberschriften.length - 1]).toBe(UNTERSTUETZUNG_TITEL);
729 }
730 });
731 });
732
733 describe('Über diese Software – Bedienung', () => {
734 it('führt über eine Schaltfläche zurück zum Start', async () => {
735 const nutzer = await ueberOeffnen();
736
737 await nutzer.click(
738 within(screen.getByRole('navigation', { name: 'Bereichswechsel' })).getByRole('button', {
739 name: 'Zum Start',
740 }),
741 );
742
743 expect(await screen.findByRole('heading', { name: 'Heute lernen' })).toBeInTheDocument();
744 });
745
746 it('meldet keine Verstöße gegen WCAG 2.1 A/AA (axe-core)', async () => {
747 const nutzer = await ueberOeffnen();
748 await nutzer.click(screen.getByText('Volltext der Lizenz anzeigen'));
749
750 const ergebnis = await axe.run(document.body, jsdomOptionen());
751
752 expect(ergebnis.violations.map((verstoss) => `${verstoss.id}: ${verstoss.help}`)).toEqual([]);
753 });
754 });