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