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 | }; |
| 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 | }); |