/** * Verhaltenstests des Bereichs „Über diese Software“. * * Die Lizenzangaben sind keine Höflichkeit: MIT, BSD und Apache verlangen, * dass Lizenztext und Urheberrechtsvermerk mitgeliefert werden, und der * amtliche Fragenkatalog darf nur mit Quellenangabe wiedergegeben werden. * Geprüft wird deshalb, dass diese Angaben sichtbar sind – auch dann, wenn der * Anwendungskern den Kanal `lizenzen:lesen` nicht anbietet. */ import { render, screen, waitFor, within } from '@testing-library/react'; import userEvent from '@testing-library/user-event'; import axe from 'axe-core'; import { jsdomOptionen } from '../src/shared/wcag'; import { afterEach, describe, expect, it, vi } from 'vitest'; import { App } from '../src/renderer/src/App'; import { erklaerungenZwischenspeicherLeeren } from '../src/renderer/src/hooks/useErklaerungen'; import { KONTAKT } from '../src/shared/kontakt'; import { UNTERSTUETZUNG_KNOPF, UNTERSTUETZUNG_TITEL, UNTERSTUETZUNG_URL, unterstuetzungsziel, } from '../src/shared/unterstuetzung'; import { EINSTELLUNGEN_STANDARD, type AnwendungsInfo, type LernAppBridge } from '../src/shared/ipc'; import type { Datenschutzerklaerung } from '../src/shared/datenschutz'; import type { Erklaerungen } from '../src/shared/erklaerungen'; import type { Glossar } from '../src/shared/glossar'; import type { Lernplan } from '../src/shared/lernplan'; import type { Katalog, RichText } from '../src/shared/katalog'; import type { FrageStand, Lernuebersicht, Profil } from '../src/shared/lernstand'; import { EUPL_HINWEIS, EUPL_KENNUNG, KATALOG_HINWEIS, type Lizenzangaben, } from '../src/shared/lizenzen'; import type { Pruefungsergebnis } from '../src/shared/pruefung'; import { NORMTEXTE_LEER } from '../src/shared/normtexte'; import { THEMEN_LEER } from '../src/shared/themen'; type Nutzer = ReturnType; // ─── Attrappen ────────────────────────────────────────────────────────── function nurText(text: string): RichText { return { text, segmente: [{ t: text }] }; } const KATALOG: Katalog = { meta: { titel: 'Fragenkatalog für die Sachkundeprüfung (gemäß § 7 WaffG)', herausgeber: 'Bundesverwaltungsamt', stand: '2024-12-16', quellenangabe: 'Amtlicher Fragenkatalog für die Sachkundeprüfung des Bundesverwaltungsamtes, Stand 16.12.2024.', quelle_url: 'https://www.bva.bund.de/', quelldatei_sha256: '7e5e41fa', fragen_gesamt: 575, }, kapitel: [{ id: 'I', titel: 'Waffenrecht', abschnitte: [{ id: 'I.1', titel: 'Begriffe' }] }], bilder: [], fragen: [ { id: 'I.1-87', amtliche_nummer: '1.87', kapitel: 'I', abschnitt: 'I.1', typ: 'mc', seite: 23, frage: nurText('Welcher Gegenstand ist eine Anscheinswaffe?'), bilder: [], optionen: [ { label: 'a', inhalt: nurText('Sportgewehr'), korrekt: false, bilder: [] }, { label: 'b', inhalt: nurText('Spielzeugpistole'), korrekt: true, bilder: [] }, ], }, ], }; const PROFIL: Profil = { id: 1, name: 'Testprofil', pruefungstermin: null, kapitelAusschluss: [], erstelltAm: '2026-01-01T08:00:00.000Z', }; const UEBERSICHT: Lernuebersicht = { fragenGesamt: 575, beantwortet: 0, belegt: 0, reifegrad: 0 / 575, stufe: 'ohne_beleg', deckelnd: [], faellig: 0, gemerkt: 0, offen: 0, fehler: 0, heuteRichtig: 0, heuteFalsch: 0, heuteBearbeitet: 0, tageSeitLetzterAntwort: null, bereiche: [], themengruppen: [], }; const STAND: FrageStand = { frageId: 'I.1-87', versuche: 0, richtige: 0, zuletztBeantwortet: null, faelligAb: null, gemerkt: false, letzteBewertung: null, }; const INFO: AnwendungsInfo = { anwendungsVersion: '0.0.0-attrappe', baukennung: 'abc1234', baustand: '2026-08-21', electronVersion: '43.4.1', chromeVersion: '140.0.0.0', nodeVersion: '22.19.1', plattform: 'win32', datenbank: { verfuegbar: true, sqliteVersion: '3.50.2', meldung: 'SQLite 3.50.2 bereit.' }, }; const ERGEBNIS: Pruefungsergebnis = { profilId: 'standard', gesamt: 0, richtig: 0, falsch: 0, unbeantwortet: 0, quote: 0, urteil: 'nicht_bestanden', begruendung: '0 Prozent richtig, nötig waren 80 Prozent.', verletzteKriterien: [], bereiche: [], fehlerIds: [], dauerMs: 0, zeitAbgelaufen: false, zeitpunkt: '2026-01-01T08:00:00.000Z', }; const EUPL_VOLLTEXT = [ 'EUROPÄISCHE UNION – ÖFFENTLICHE LIZENZ v. 1.2', '', 'Diese europäische Union Public Licence (die „EUPL“) gilt für das Werk, das', 'nachstehend definiert wird und das unter den Bedingungen dieser Lizenz zur', 'Verfügung gestellt wird.', ].join('\n'); const MIT_TEXT = [ 'MIT License', '', 'Copyright (c) Meta Platforms, Inc. and affiliates.', '', 'Permission is hereby granted, free of charge, to any person obtaining a copy', 'of this software and associated documentation files (the "Software"), to deal', 'in the Software without restriction.', ].join('\n'); /** * Eine kleine, aber echte Erklärung. * * Bewusst nicht die ausgelieferte: Diese Datei prüft die Oberfläche, nicht * den Inhalt der Erklärung. Ob die mitgelieferte Fassung zur Quelle passt, * hält `tests/datenschutz.test.ts` fest. */ const ERKLAERUNG: Datenschutzerklaerung = { quellpruefsumme: 'a'.repeat(64), fassung: '1.5', stand: '30.08.2026', bloecke: [ { art: 'ueberschrift', ebene: 2, text: '1. Wer verantwortlich ist', kennung: 'wer' }, { art: 'absatz', teile: [{ art: 'text', text: 'Ein Satz aus der Erklärung.' }] }, ], }; const LIZENZEN: Lizenzangaben = { eigene: { kennung: EUPL_KENNUNG, name: 'European Union Public Licence, Version 1.2', hinweis: EUPL_HINWEIS, text: EUPL_VOLLTEXT, }, katalog: { quellenangabe: KATALOG.meta.quellenangabe, quelleUrl: KATALOG.meta.quelle_url, hinweis: KATALOG_HINWEIS, }, dritte: [ { name: 'React', version: '19.2.8', lizenz: 'MIT', url: 'https://react.dev/', lizenztext: MIT_TEXT, }, { name: 'better-sqlite3', version: '13.0.3', lizenz: 'MIT', url: null, lizenztext: MIT_TEXT, }, { name: 'Chromium und Electron', version: null, lizenz: 'BSD-3-Clause und weitere', url: null, lizenztext: null, hinweis: 'Sammelposten: Electron liefert Chromium und Node.js mit eigenen Lizenzen mit.', }, { // Ohne Text UND ohne Hinweis – hier muss die Ansicht es aussprechen. name: 'Beispielpaket', version: '1.0.0', lizenz: 'ISC', url: null, lizenztext: null, }, ], }; /** Lernplan für die Attrappe – ohne Termin, damit nichts vom Datum abhängt. */ const LERNPLAN: Lernplan = { termin: null, tageBisTermin: null, gesamtFragen: 8, nieBeantwortet: 6, faellig: 1, zielquote: 0.9, prognoseHeute: 0.25, prognoseAmTermin: null, pensum: { neu: 0, wiederholung: 1, gesamt: 1, minuten: 1 }, machbarkeit: 'kein_termin', sekundenProFrage: 25, }; /** Erklaerungen fuer die Attrappe – ein Eintrag genuegt fuer die Anzeige. */ const ERKLAERUNGEN: Erklaerungen = { meta: { version: 1, stand: '2026-08-20', gesetzesstand: { WaffG: 'Pruefstand' }, hinweis: 'Eigener redaktioneller Inhalt, nicht Teil des amtlichen Katalogs.', }, zuFrage: { 'I.1-01': { kurz: 'Das Waffengesetz regelt den Umgang mit Waffen und Munition.', text: 'Massgeblich ist § 1 Abs. 1 WaffG: Gegenstand ist der Umgang, Zweck die oeffentliche Sicherheit und Ordnung.', fundstellen: [{ gesetz: 'WaffG', norm: '§ 1', absatz: '1' }], merksatz: 'Gegenstand: der Umgang. Zweck: die Sicherheit.', }, }, }; /** Glossar fuer die Attrappe – zwei Eintraege genuegen fuer die Anzeige. */ const GLOSSAR: Glossar = { meta: { version: 1, stand: '2026-08-21', gesetzesstand: { WaffG: 'Pruefstand' }, hinweis: 'Eigener redaktioneller Inhalt.', }, eintraege: [ { begriff: 'Führen', art: 'begriff', kurz: 'Die tatsächliche Gewalt über eine Waffe außerhalb der eigenen Wohnung ausüben.', varianten: ['führt', 'führen', 'geführt'], fundstellen: [{ gesetz: 'WaffG', norm: 'Anlage 1', stelle: 'Abschnitt 2 Nr. 4' }], siehe: ['WaffG'], }, { begriff: 'WaffG', art: 'abkuerzung', kurz: 'Waffengesetz.', varianten: ['WaffG', 'Waffengesetzes', 'Waffengesetz'], fundstellen: [], ohneFundstelleGrund: 'Die amtliche Kurzbezeichnung eines Gesetzes steht nicht im Gesetz selbst.', }, ], }; function attrappe(ueberschreibungen: Partial = {}): LernAppBridge { return { anwendungsInfoLesen: vi.fn(() => Promise.resolve(INFO)), anzeigegroesseSetzen: vi.fn((prozent: number) => Promise.resolve(prozent)), anzeigegroesseBeobachten: vi.fn(() => () => undefined), hilfsmittelAktiv: vi.fn(() => Promise.resolve(false)), hilfsmittelBeobachten: vi.fn(() => () => undefined), menuebefehlBeobachten: vi.fn(() => () => undefined), /* Die Erststart-Frage nach dem abwaehlbaren Kapitel ist beantwortet: Diese Pruefungen sehen den Startbildschirm an, nicht das Tor davor. */ einstellungenLesen: vi.fn(() => Promise.resolve({ ...EINSTELLUNGEN_STANDARD, zuschnittGefragt: true }), ), einstellungenSchreiben: vi.fn(() => Promise.resolve(EINSTELLUNGEN_STANDARD)), katalogLaden: vi.fn(() => Promise.resolve(KATALOG)), katalogBild: vi.fn(() => Promise.resolve('data:image/png;base64,')), erklaerungenLaden: vi.fn(() => Promise.resolve(ERKLAERUNGEN)), glossarLaden: vi.fn(() => Promise.resolve(GLOSSAR)), /* Ohne Normtexte bleibt es beim Zitat – genau wie vor 0.22.0. Diese Pruefungen sehen die Fundstellen an, nicht den Gesetzestext; den prueft tests/normstelle.test.tsx an eigenen Faellen. */ normtexteLaden: vi.fn(() => Promise.resolve(NORMTEXTE_LEER)), themenLaden: vi.fn(() => Promise.resolve(THEMEN_LEER)), profilListe: vi.fn(() => Promise.resolve([PROFIL])), profilAnlegen: vi.fn(() => Promise.resolve(PROFIL)), profilAktualisieren: vi.fn(() => Promise.resolve(PROFIL)), profilLoeschen: vi.fn(() => Promise.resolve([PROFIL])), lernSitzung: vi.fn(() => Promise.resolve([])), lernAntworten: vi.fn(() => Promise.resolve(STAND)), lernMerken: vi.fn(() => Promise.resolve(STAND)), lernUebersicht: vi.fn(() => Promise.resolve(UEBERSICHT)), lernPlan: vi.fn(() => Promise.resolve(LERNPLAN)), lernZuruecksetzen: vi.fn(() => Promise.resolve(UEBERSICHT)), pruefungStarten: vi.fn(() => Promise.resolve({ fragen: [], warnungen: [] })), pruefungAuswerten: vi.fn(() => Promise.resolve(ERGEBNIS)), pruefungVerlauf: vi.fn(() => Promise.resolve([])), pruefungSichern: vi.fn(() => Promise.resolve(true)), pruefungOffen: vi.fn(() => Promise.resolve(null)), pruefungVerwerfen: vi.fn(() => Promise.resolve()), pruefungLaeuft: vi.fn(() => Promise.resolve()), sicherungUebernehmen: vi.fn(() => Promise.resolve({ art: 'abgelaufen' as const })), fehlerprotokollDrucken: vi.fn(() => Promise.resolve({ gespeichert: false, pfad: null, bytes: 0 }), ), fragenlisteDrucken: vi.fn(() => Promise.resolve({ gespeichert: false, pfad: null, bytes: 0 })), lernberichtDrucken: vi.fn(() => Promise.resolve({ gespeichert: true, pfad: 'C:/Doks/Lernbericht.pdf', bytes: 4096 }), ), /* Die Erklärung selbst prüft `renderer-ueber.test.tsx`; hier genügt, dass die Brücke den Kanal kennt. */ datenschutzLesen: vi.fn(() => Promise.resolve({ erklaerung: ERKLAERUNG, warnung: null })), lizenzenLesen: vi.fn(() => Promise.resolve(LIZENZEN)), /* Der Kanal nach außen. Er gehört in die Attrappe, weil das Unterstützungsangebot ohne ihn ohnehin entfällt – sonst prüfte der Test unten den falschen Grund für dessen Fehlen. */ unterstuetzungOeffnen: vi.fn(() => Promise.resolve(true)), ...ueberschreibungen, }; } /** Bridge-Sicht, in der einzelne Kanäle fehlen dürfen. */ type LueckenhafteBruecke = { -readonly [K in keyof LernAppBridge]?: LernAppBridge[K] | undefined; }; /** Bridge ohne einen bestimmten Kanal – der Anwendungskern entsteht parallel. */ function ohneKanal(kanal: keyof LernAppBridge): LueckenhafteBruecke { const bruecke: LueckenhafteBruecke = { ...attrappe() }; bruecke[kanal] = undefined; return bruecke; } /** Rendert die Anwendung und öffnet „Über diese Software“. */ async function ueberOeffnen(bruecke: LueckenhafteBruecke = attrappe()): Promise { vi.stubGlobal('lernApp', bruecke); const nutzer = userEvent.setup(); render(); const knopf = await screen.findByRole('button', { name: 'Lizenzen und Herkunft anzeigen' }); await nutzer.click(knopf); await screen.findByRole('heading', { name: 'Über diese Software', level: 1 }); return nutzer; } /** Der Bereich selbst – der Startbildschirm trägt denselben Abschnittstitel. */ function ueberbereich(): HTMLElement { return screen.getByRole('region', { name: 'Über diese Software' }); } afterEach(() => { vi.unstubAllGlobals(); /* Die Erklaerungen liegen in einem Modul-weiten Zwischenspeicher – ohne Leeren wuerde der naechste Test die Antwort des vorigen sehen. */ erklaerungenZwischenspeicherLeeren(); }); // ─── Tests ────────────────────────────────────────────────────────────── describe('Über diese Software – Rückmeldung und Kontakt', () => { /* Prüfschritt 9.2.2 verlangt einen Rückmeldeweg, der vorhanden UND bedient ist. „Vorhanden" lässt sich hier prüfen, „bedient" nicht – deshalb prüfen diese Tests, dass die Zusage überhaupt dasteht und dass der Kanal ohne Maus und ohne Telefon erreichbar ist. */ it('nennt einen Meldeweg, eine Frist und wofür er da ist', async () => { await ueberOeffnen(); const block = screen .getByRole('heading', { name: 'Rückmeldung und Kontakt', level: 2 }) .closest('div') as HTMLElement; expect(within(block).getByText(KONTAKT.epost)).toBeInTheDocument(); expect(within(block).getByText(KONTAKT.name)).toBeInTheDocument(); expect(within(block).getByText(/Antwort innerhalb eines Monats/u)).toBeInTheDocument(); // Ohne benanntes Anliegen ist ein Kanal kein Rückmeldeweg, sondern eine Adresse. expect(within(block).getAllByRole('listitem').length).toBeGreaterThanOrEqual(3); expect(within(block).getByText(/Barrieren in der Bedienung melden/u)).toBeInTheDocument(); }); it('zeigt weder eine Rufnummer noch eine Anschrift', async () => { /* Telefon und Ort sind aus `KONTAKT` entfernt – 9.2.3 verlangt einen Kanal, der nicht ausschließlich Telefon ist, und E-Mail allein erfüllt ihn. Geprüft wird am gerenderten Block und nicht an der Konstanten: Als Festtext ließen sich die Zeilen hier wieder einsetzen, ohne `KONTAKT` je anzufassen. Zugleich hält der Test fest, dass die Aufzählung keine Beschriftung ohne Inhalt zurücklässt. */ await ueberOeffnen(); const block = screen .getByRole('heading', { name: 'Rückmeldung und Kontakt', level: 2 }) .closest('div') as HTMLElement; const text = block.textContent; expect(text).toContain(KONTAKT.epost); expect(text).not.toMatch(/Telefon|Anschrift/u); expect(text).not.toMatch(/\b0\d{3,4}[ /-]\d{5,9}\b/u); expect(text).not.toMatch(/\b\d{5}\s+[A-ZÄÖÜ][a-zäöüß]/u); const liste = block.querySelector('.ueber__angaben')!; const beschriftungen = [...liste.querySelectorAll('dt')].map((dt) => dt.textContent); const angaben = [...liste.querySelectorAll('dd')].map((dd) => dd.textContent.trim()); expect(beschriftungen).toEqual(['Verantwortlich', 'E-Mail']); expect(angaben).toEqual([KONTAKT.name, KONTAKT.epost]); }); it('steht vor den Lizenzblöcken', async () => { /* Wer hierherkommt, weil er nicht weiterkommt, soll den Weg hinaus nicht hinter vier Rechtsblöcken suchen müssen. */ await ueberOeffnen(); const ueberschriften = screen.getAllByRole('heading', { level: 2 }).map((h) => h.textContent); expect(ueberschriften[0]).toBe('Rückmeldung und Kontakt'); }); it('hält die Zusage, von sich aus keine Verbindung zu öffnen', async () => { /* Kein mailto:, kein tel: – die Anwendung startet kein fremdes Programm. Die Angaben stehen als auswählbarer Text da. */ await ueberOeffnen(); for (const verweis of screen.queryAllByRole('link')) { expect(verweis.getAttribute('href') ?? '').not.toMatch(/^(mailto|tel):/u); } }); it('macht die Angaben nicht unkopierbar', async () => { /* Es lag der Wunsch vor, die Kontaktdaten unkopierbar zu zeigen. Das ist bewusst nicht umgesetzt: Als Bild verstieße es gegen WCAG 1.4.5 (Stufe AA), und `user-select: none` sperrt ausgerechnet jene aus, die eine lange Adresse nicht abtippen können – im Block, der Barrieren melden soll. Dieser Test hält die Entscheidung fest. */ await ueberOeffnen(); const adresse = screen.getByText(KONTAKT.epost); expect(adresse.tagName).not.toBe('IMG'); expect(adresse.closest('[style*="user-select"]')).toBeNull(); expect(adresse).toHaveTextContent(KONTAKT.epost); }); }); describe('Über diese Software – Datenschutz', () => { it('führt einen eigenen Abschnitt mit Fassung und Stand', async () => { /* Bis Fassung 0.24.2 kam das Wort „Datenschutzerklärung“ in der ganzen Oberfläche kein einziges Mal als sichtbarer Text vor. Zugleich sagte dieser Bereich zu, die Anwendung übertrage keine Daten – eine Zusage, deren Beleg der Nutzende nicht erreichen konnte. */ await ueberOeffnen(); const bereich = ueberbereich(); expect( within(bereich).getByRole('heading', { name: 'Datenschutz', level: 2 }), ).toBeInTheDocument(); expect(within(bereich).getByText(/Fassung 1\.5, Stand 30\.08\.2026/u)).toBeInTheDocument(); }); it('klappt die Erklärung erst auf Anforderung auf', async () => { /* 144 Blöcke ausgeklappt schöben alles darunter um mehrere Bildschirmhöhen nach unten; wer per Tabulator arbeitet, käme nur noch durch das ganze Rechtsdokument dorthin. */ const nutzer = await ueberOeffnen(); const bereich = ueberbereich(); const schalter = within(bereich).getByRole('button', { name: 'Datenschutzerklärung lesen' }); expect(schalter).toHaveAttribute('aria-expanded', 'false'); expect(within(bereich).queryByText('Ein Satz aus der Erklärung.')).not.toBeInTheDocument(); await nutzer.click(schalter); expect( within(bereich).getByRole('button', { name: 'Datenschutzerklärung einklappen' }), ).toHaveAttribute('aria-expanded', 'true'); expect(within(bereich).getByText('Ein Satz aus der Erklärung.')).toBeInTheDocument(); }); it('bietet aufgeklappt ein Sprungziel je Abschnitt', async () => { const nutzer = await ueberOeffnen(); const bereich = ueberbereich(); await nutzer.click(within(bereich).getByRole('button', { name: 'Datenschutzerklärung lesen' })); const verweis = within(bereich).getByRole('link', { name: '1. Wer verantwortlich ist' }); expect(verweis).toHaveAttribute('href', '#datenschutz-wer'); /* Und das Ziel gibt es auch – ein Sprungziel ins Leere ist schlimmer als keines, weil der Fokus dann stehen bleibt, ohne dass es auffällt. */ expect(bereich.querySelector('#datenschutz-wer')).not.toBeNull(); }); it('springt in der Überschriftenfolge nicht', async () => { /* Der Bereich führt h1 und h2; die Erklärung beginnt intern bei h1 und wird deshalb ab h3 gezeigt. Ein Sprung wäre ein Verstoß gegen WCAG 1.3.1 – und die Ebenen sind das Verzeichnis, das ein Bildschirmleser wirklich benutzt. */ const nutzer = await ueberOeffnen(); const bereich = ueberbereich(); await nutzer.click(within(bereich).getByRole('button', { name: 'Datenschutzerklärung lesen' })); const ebenen = [...bereich.querySelectorAll('h1, h2, h3, h4, h5, h6')].map((k) => Number(k.tagName.slice(1)), ); for (let i = 1; i < ebenen.length; i++) { expect( (ebenen[i] ?? 0) - (ebenen[i - 1] ?? 0), `Sprung von h${String(ebenen[i - 1])} auf h${String(ebenen[i])}`, ).toBeLessThanOrEqual(1); } }); }); describe('Über diese Software – Lizenz der Anwendung', () => { it('nennt Kennung und EUPL-Hinweis im Klartext', async () => { await ueberOeffnen(); const bereich = ueberbereich(); expect( within(bereich).getByRole('heading', { name: 'Lizenz dieser Anwendung', level: 2 }), ).toBeInTheDocument(); expect( within(bereich).getByText('European Union Public Licence, Version 1.2 (EUPL-1.2)'), ).toBeInTheDocument(); expect(within(bereich).getByText(EUPL_HINWEIS)).toBeInTheDocument(); }); it('hält den Lizenzvolltext in einem aufklappbaren, scrollbaren Bereich bereit', async () => { const nutzer = await ueberOeffnen(); const bereich = ueberbereich(); const schalter = within(bereich).getByText('Volltext der Lizenz anzeigen'); const aufklapper = schalter.closest('details'); expect(aufklapper).not.toBeNull(); expect(aufklapper).not.toHaveAttribute('open'); await nutzer.click(schalter); expect(aufklapper).toHaveAttribute('open'); const textbereich = within(bereich).getByRole('group', { name: 'Volltext der Lizenz' }); expect(textbereich).toHaveTextContent('EUROPÄISCHE UNION – ÖFFENTLICHE LIZENZ v. 1.2'); // Scrollbare Bereiche müssen den Fokus annehmen können (WCAG 2.1.1). expect(textbereich).toHaveAttribute('tabindex', '0'); }); }); describe('Über diese Software – Fragenkatalog', () => { it('zeigt Rechtslage, Quellenangabe und Katalogstand', async () => { await ueberOeffnen(); const bereich = ueberbereich(); expect( within(bereich).getByRole('heading', { name: 'Rechtslage des Fragenkatalogs', level: 2 }), ).toBeInTheDocument(); expect(within(bereich).getByText(KATALOG_HINWEIS)).toBeInTheDocument(); expect(within(bereich).getByText(KATALOG.meta.quellenangabe)).toBeInTheDocument(); expect(within(bereich).getByText('https://www.bva.bund.de/')).toBeInTheDocument(); expect(within(bereich).getByText('16.12.2024')).toBeInTheDocument(); expect(within(bereich).getByText('Bundesverwaltungsamt')).toBeInTheDocument(); }); }); describe('Über diese Software – Fremdkomponenten', () => { it('führt jede Komponente mit Version und Lizenz auf', async () => { await ueberOeffnen(); const bereich = ueberbereich(); const liste = within(bereich).getByRole('list', { name: 'Mitgelieferte Fremdkomponenten' }); const eintraege = within(liste).getAllByRole('listitem'); expect(eintraege).toHaveLength(4); expect(within(liste).getByText('React 19.2.8 – MIT')).toBeInTheDocument(); expect(within(liste).getByText('better-sqlite3 13.0.3 – MIT')).toBeInTheDocument(); expect( within(liste).getByText('Chromium und Electron – BSD-3-Clause und weitere'), ).toBeInTheDocument(); expect(within(liste).getByText('Beispielpaket 1.0.0 – ISC')).toBeInTheDocument(); expect(within(liste).getByText(/Sammelposten/u)).toBeInTheDocument(); }); it('nennt die Quellenangabe auch bei kaputtem Katalog', async () => { /* Lädt der Katalog nicht, liefert der Kern leere Zeichenketten. Eine leere Zeile unter „Quellenangabe" wäre die schlechteste Antwort – die Pflichtangabe steht als Festtext ohnehin bereit. */ const kaputt: Lizenzangaben = { ...LIZENZEN, katalog: { ...LIZENZEN.katalog, quellenangabe: '', quelleUrl: '' }, }; await ueberOeffnen(attrappe({ lizenzenLesen: vi.fn(() => Promise.resolve(kaputt)) })); const bereich = ueberbereich(); expect(within(bereich).getByText(/Amtlicher Fragenkatalog/u)).toBeInTheDocument(); expect(within(bereich).getByText(/bva\.bund\.de/u)).toBeInTheDocument(); }); it('schweigt, wo ein Hinweis den fehlenden Text bereits erklärt', async () => { /* Beim Sammelposten Chromium steht, dass der Lizenztext der Installation als eigene Datei beiliegt. Ein „kein Lizenztext" darunter widerspräche dem unmittelbar. */ await ueberOeffnen(); const bereich = ueberbereich(); expect(within(bereich).getByText(/Sammelposten: Electron liefert/u)).toBeInTheDocument(); expect( within(bereich).getAllByText('In dieser Ansicht ist kein Lizenztext hinterlegt.'), ).toHaveLength(1); }); it('macht jeden Lizenztext einzeln aufklappbar', async () => { await ueberOeffnen(); const bereich = ueberbereich(); expect(within(bereich).getByRole('group', { name: 'Lizenztext von React' })).toHaveTextContent( 'Permission is hereby granted', ); expect( within(bereich).getByRole('group', { name: 'Lizenztext von better-sqlite3' }), ).toBeInTheDocument(); // Ohne mitgelieferten Text wird das ausdrücklich gesagt, statt zu schweigen. expect( within(bereich).getByText('In dieser Ansicht ist kein Lizenztext hinterlegt.'), ).toBeInTheDocument(); }); }); describe('Über diese Software – Ausfall des Anwendungskerns', () => { it('zeigt die Pflichtangaben auch ohne den Kanal „lizenzen:lesen“', async () => { await ueberOeffnen(ohneKanal('lizenzenLesen')); const bereich = ueberbereich(); expect(within(bereich).getByText(/lizenzen:lesen/u)).toBeInTheDocument(); expect(within(bereich).getByText(EUPL_HINWEIS)).toBeInTheDocument(); expect(within(bereich).getByText(KATALOG_HINWEIS)).toBeInTheDocument(); expect(within(bereich).getByText(/EUPL-1\.2/u)).toBeInTheDocument(); // Der Katalogstand stammt aus dem Katalog, nicht aus den Lizenzangaben. expect(within(bereich).getByText('16.12.2024')).toBeInTheDocument(); }); it('meldet einen Fehler des Kanals, ohne die Ansicht zu verlieren', async () => { await ueberOeffnen( attrappe({ lizenzenLesen: vi.fn(() => Promise.reject(new Error('Datei fehlt'))) }), ); await waitFor(() => { expect(screen.getByText(/Datei fehlt/u)).toBeInTheDocument(); }); expect(within(ueberbereich()).getByText(EUPL_HINWEIS)).toBeInTheDocument(); }); }); describe('Über diese Software – Unterstützungsangebot', () => { /* Bewusst an den heutigen Wert von `UNTERSTUETZUNG_URL` gebunden statt an „leer“: Der Eintrag der Adresse ist eine Einzeiler-Änderung und soll keinen roten Lauf auslösen. Geprüft wird deshalb die Verdrahtung – dass die Ansicht genau dann etwas zeigt, wenn eine gültige Adresse eingetragen ist, und in beiden Fällen nichts halbes stehen lässt. Das Verhalten des Blocks selbst prüft `tests/renderer-unterstuetzung.test.tsx` in beiden Zuständen. */ const ziel = unterstuetzungsziel(UNTERSTUETZUNG_URL); it('zeigt das Angebot genau dann, wenn eine Adresse eingetragen ist', async () => { await ueberOeffnen(); const bereich = ueberbereich(); if (ziel === null) { expect(within(bereich).queryByRole('heading', { name: UNTERSTUETZUNG_TITEL })).toBeNull(); expect(within(bereich).queryByRole('button', { name: UNTERSTUETZUNG_KNOPF })).toBeNull(); // Auch kein Rest: keine Überschrift ohne Block, kein Wort ohne Knopf. expect(bereich.textContent).not.toContain(UNTERSTUETZUNG_TITEL); return; } expect( within(bereich).getByRole('heading', { name: UNTERSTUETZUNG_TITEL, level: 2 }), ).toBeInTheDocument(); expect(within(bereich).getByRole('button', { name: UNTERSTUETZUNG_KNOPF })).toBeInTheDocument(); expect(within(bereich).getByText(ziel)).toBeInTheDocument(); }); it('stellt die Bitte hinter den Rückmeldeweg und hinter die Pflichtangaben', async () => { /* Die Reihenfolge ist die eigentliche Entscheidung. Wer diese Ansicht öffnet, weil er auf eine Barriere gestoßen ist, darf nicht zuerst um Geld gebeten werden; und die Lizenzhinweise sind geschuldet, diese Bitte ist es nicht. Deshalb: Rückmeldung zuerst, Unterstützung zuletzt. */ await ueberOeffnen(); const ueberschriften = screen.getAllByRole('heading', { level: 2 }).map((h) => h.textContent); expect(ueberschriften[0]).toBe('Rückmeldung und Kontakt'); if (ziel !== null) { expect(ueberschriften[ueberschriften.length - 1]).toBe(UNTERSTUETZUNG_TITEL); } }); }); describe('Über diese Software – Bedienung', () => { it('führt über eine Schaltfläche zurück zum Start', async () => { const nutzer = await ueberOeffnen(); await nutzer.click( within(screen.getByRole('navigation', { name: 'Bereichswechsel' })).getByRole('button', { name: 'Zum Start', }), ); expect(await screen.findByRole('heading', { name: 'Heute lernen' })).toBeInTheDocument(); }); it('meldet keine Verstöße gegen WCAG 2.1 A/AA (axe-core)', async () => { const nutzer = await ueberOeffnen(); await nutzer.click(screen.getByText('Volltext der Lizenz anzeigen')); const ergebnis = await axe.run(document.body, jsdomOptionen()); expect(ergebnis.violations.map((verstoss) => `${verstoss.id}: ${verstoss.help}`)).toEqual([]); }); });