waffensachkunde
Waffensachkunde – Lernsoftware für die Sachkundeprüfung nach § 7 WaffG. Barrierefrei, offline, EUPL-1.2.
/ app tests renderer-ueber.test.tsx
| 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 | }); |