/**
 * 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<typeof userEvent.setup>;

// ─── 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: [],
};

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> = {}): 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<Nutzer> {
  vi.stubGlobal('lernApp', bruecke);
  const nutzer = userEvent.setup();
  render(<App />);

  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([]);
  });
});
