/**
 * Verhaltenstests der Prüfungssimulation.
 *
 * Geprüft wird, was in einer Prüfung tatsächlich zählt: dass das gewählte
 * Profil unverändert im Auftrag landet, dass „Ohne Zeitbegrenzung“ wirklich
 * keine Uhr startet, dass es vor der Abgabe keinerlei Rückmeldung gibt, dass
 * Antworten bis zuletzt änderbar bleiben, dass ohne Rückfrage nichts
 * abgegeben wird – und dass die Auswertung ihr Urteil begründet.
 */

import { act, 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 { EINSTELLUNGEN_STANDARD, type AnwendungsInfo, type LernAppBridge } from '../src/shared/ipc';
import type { Erklaerungen } from '../src/shared/erklaerungen';
import type { Glossar } from '../src/shared/glossar';
import type { Lernplan } from '../src/shared/lernplan';
import type { Frage, Katalog, KatalogBild, RichText } from '../src/shared/katalog';
import type { FrageStand, Lernuebersicht, Profil, SitzungsFrage } from '../src/shared/lernstand';
import { restzeitGenau } from '../src/renderer/src/pruefung/zeit';
import { NORMTEXTE_LEER } from '../src/shared/normtexte';
import { THEMEN_LEER } from '../src/shared/themen';
import {
  EUPL_HINWEIS,
  EUPL_KENNUNG,
  KATALOG_HINWEIS,
  type Lizenzangaben,
} from '../src/shared/lizenzen';
import {
  profilFinden,
  type OffenerLauf,
  type Pruefungsbogen,
  type Pruefungsergebnis,
  type Pruefungsfrage,
  type Pruefungsverlauf,
} from '../src/shared/pruefung';

type Nutzer = ReturnType<typeof userEvent.setup>;

// ─── Katalogattrappe ────────────────────────────────────────────────────

function nurText(text: string): RichText {
  return { text, segmente: [{ t: text }] };
}

/*
  Ein Zeichen MIT hinterlegter Bedeutung: Der Prüfungslauf darf sie trotzdem
  nie zeigen – in der echten Prüfung gibt es auch für Sehende keine
  Erklärungen. Der Lernmodus-Test prüft die Gegenseite.
*/
const ZEICHEN_PTB: KatalogBild = {
  id: 'zeichen-ptb',
  datei: 'zeichen-ptb.png',
  breite: 73,
  hoehe: 73,
  alt: 'Rundes Zeichen mit der Buchstabenfolge PTB',
  beschreibung:
    'Zulassungszeichen der Physikalisch-Technischen Bundesanstalt für Schreckschuss-, Reizstoff- und Signalwaffen zugelassener Bauart.',
};

/** Multiple Choice mit genau einer richtigen Antwort. */
const FRAGE_EINFACH: Frage = {
  id: 'I.1-87',
  amtliche_nummer: '1.87',
  kapitel: 'I',
  abschnitt: 'I.1',
  typ: 'mc',
  seite: 23,
  frage: nurText('Welcher der genannten Gegenstände ist eine Anscheinswaffe?'),
  bilder: [],
  optionen: [
    {
      label: 'a',
      inhalt: nurText('Sportgewehr im Kaliber .223 Remington'),
      korrekt: false,
      bilder: [],
    },
    {
      label: 'b',
      inhalt: nurText('Schreckschusspistole mit Zulassungszeichen'),
      korrekt: false,
      bilder: ['zeichen-ptb'],
    },
    {
      label: 'c',
      inhalt: nurText('Spielzeugpistole ohne Unterscheidbarkeit'),
      korrekt: true,
      bilder: [],
    },
  ],
};

/** Multiple Choice mit zwei richtigen Antworten. */
const FRAGE_MEHRFACH: Frage = {
  id: 'I.2-10',
  amtliche_nummer: '2.10',
  kapitel: 'I',
  abschnitt: 'I.2',
  typ: 'mc',
  seite: 40,
  frage: nurText('Welche Pflichten treffen den Inhaber einer Waffenbesitzkarte?'),
  bilder: [],
  optionen: [
    {
      label: 'a',
      inhalt: nurText('Er muss die Waffe sorgfältig aufbewahren.'),
      korrekt: true,
      bilder: [],
    },
    {
      label: 'b',
      inhalt: nurText('Er muss den Erwerb unverzüglich anzeigen.'),
      korrekt: true,
      bilder: [],
    },
    {
      label: 'c',
      inhalt: nurText('Er muss die Waffe jährlich vorführen.'),
      korrekt: false,
      bilder: [],
    },
  ],
};

const FRAGE_NOTWEHR: Frage = {
  id: 'I.5-04',
  amtliche_nummer: '5.04',
  kapitel: 'I',
  abschnitt: 'I.5',
  typ: 'mc',
  seite: 88,
  frage: nurText('Wann liegt Notwehr vor?'),
  bilder: [],
  optionen: [
    {
      label: 'a',
      inhalt: nurText('Bei einem gegenwärtigen rechtswidrigen Angriff'),
      korrekt: true,
      bilder: [],
    },
    {
      label: 'b',
      inhalt: nurText('Bei einem bereits beendeten Angriff'),
      korrekt: false,
      bilder: [],
    },
  ],
};

/** Offene Frage mit Kernelementen – Grundlage der Nachbewertung. */
const FRAGE_FREITEXT: Frage = {
  id: 'I.1-01',
  amtliche_nummer: '1.01',
  kapitel: 'I',
  abschnitt: 'I.1',
  typ: 'freitext',
  seite: 6,
  frage: nurText('Was regelt das Waffengesetz?'),
  bilder: [],
  musterantwort: {
    text: 'Das Waffengesetz regelt den Umgang mit Waffen oder Munition.',
    segmente: [
      { t: 'Das Waffengesetz regelt den ' },
      { t: 'Umgang mit Waffen oder Munition', h: true },
      { t: '.' },
    ],
  },
};

/** Frage aus dem abwählbaren Kapitel IV. */
const FRAGE_KAPITEL_IV: Frage = {
  id: 'IV-12',
  amtliche_nummer: '4.12',
  kapitel: 'IV',
  abschnitt: null,
  typ: 'mc',
  seite: 210,
  frage: nurText('Welche Munition ist für die Jagd auf Schalenwild zulässig?'),
  bilder: [],
  optionen: [
    { label: 'a', inhalt: nurText('Deformationsgeschosse'), korrekt: true, bilder: [] },
    { label: 'b', inhalt: nurText('Vollmantelgeschosse'), korrekt: false, bilder: [] },
  ],
};

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: 5,
  },
  kapitel: [
    {
      id: 'I',
      titel: 'Waffenrecht und sonstige Rechtsvorschriften',
      abschnitte: [
        { id: 'I.1', titel: 'Begriffe des Waffenrechts' },
        { id: 'I.2', titel: 'Rechte und Pflichten' },
        { id: 'I.5', titel: 'Notwehr und Notstand' },
      ],
    },
    { id: 'IV', titel: 'Jagdrecht', abschnitte: [] },
  ],
  bilder: [ZEICHEN_PTB],
  fragen: [FRAGE_FREITEXT, FRAGE_EINFACH, FRAGE_MEHRFACH, FRAGE_NOTWEHR, FRAGE_KAPITEL_IV],
};

// ─── Bridge-Attrappe ────────────────────────────────────────────────────

const PROFIL: Profil = {
  id: 1,
  name: 'Testprofil',
  pruefungstermin: null,
  kapitelAusschluss: [],
  erstelltAm: '2026-01-01T08:00:00.000Z',
};

const UEBERSICHT: Lernuebersicht = {
  fragenGesamt: 575,
  beantwortet: 120,
  belegt: 48,
  reifegrad: 48 / 575,
  stufe: 'zurueck',
  deckelnd: [],
  faellig: 12,
  gemerkt: 5,
  offen: 0,
  fehler: 0,
  heuteRichtig: 8,
  heuteFalsch: 2,
  heuteBearbeitet: 0,
  tageSeitLetzterAntwort: null,
  bereiche: [],
};

const STAND: FrageStand = {
  frageId: 'I.1-87',
  versuche: 1,
  richtige: 1,
  zuletztBeantwortet: '2026-01-01T08:00:00.000Z',
  faelligAb: null,
  gemerkt: false,
  letzteBewertung: 'gut',
};

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 LIZENZEN: Lizenzangaben = {
  eigene: { kennung: EUPL_KENNUNG, name: 'EUPL 1.2', hinweis: EUPL_HINWEIS, text: null },
  katalog: {
    quellenangabe: KATALOG.meta.quellenangabe,
    quelleUrl: KATALOG.meta.quelle_url,
    hinweis: KATALOG_HINWEIS,
  },
  dritte: [],
};

/** Ergebnis, das die Attrappe des Anwendungskerns liefert. */
const ERGEBNIS: Pruefungsergebnis = {
  profilId: 'standard',
  gesamt: 3,
  richtig: 2,
  falsch: 1,
  unbeantwortet: 0,
  quote: 2 / 3,
  urteil: 'nachpruefung',
  begruendung: '67 Prozent richtig. Bestanden wäre ab 80 Prozent.',
  verletzteKriterien: ['Notwehr und Notstand: 3 Fehler bei höchstens 2 zulässigen'],
  bereiche: [
    { bereich: 'I.1', titel: 'Begriffe des Waffenrechts', gesamt: 1, richtig: 1 },
    { bereich: 'I.2', titel: 'Rechte und Pflichten', gesamt: 2, richtig: 1 },
  ],
  fehlerIds: ['I.2-10'],
  dauerMs: 95_000,
  zeitAbgelaufen: false,
  zeitpunkt: '2026-02-01T10:00:00.000Z',
};

const VERLAUF: Pruefungsverlauf[] = [
  {
    id: 7,
    profilId: 'standard',
    profilName: 'Standard',
    zeitpunkt: '2026-01-20T09:30:00.000Z',
    gesamt: 80,
    richtig: 70,
    quote: 0.875,
    urteil: 'bestanden',
    dauerMs: 71 * 60_000,
    zeitmodus: 'normal',
    unbeantwortet: 0,
    zeitAbgelaufen: false,
    bereiche: [
      { bereich: 'I.1', titel: 'Begriffe des Waffenrechts', gesamt: 10, richtig: 9 },
      { bereich: 'I.2', titel: 'Rechte und Pflichten', gesamt: 10, richtig: 4 },
    ],
    bestehensQuote: 0.8,
  },
  {
    // Ein Lauf aus der Zeit vor Schema-Version 4: Zeitstufe nicht vermerkt.
    id: 6,
    profilId: 'standard',
    profilName: 'Standard',
    zeitpunkt: '2026-01-19T09:30:00.000Z',
    gesamt: 80,
    richtig: 40,
    quote: 0.5,
    urteil: 'nicht_bestanden',
    dauerMs: 95 * 60_000,
    zeitmodus: null,
    // Und aus derselben Zeit stammen die Angaben aus Fassung 10 nicht:
    // „nicht festgehalten“ ist etwas anderes als „nichts offen geblieben“.
    unbeantwortet: null,
    zeitAbgelaufen: null,
    bereiche: null,
    bestehensQuote: null,
  },
];

function bogeneintrag(frage: Frage): Pruefungsfrage {
  return {
    frageId: frage.id,
    optionsReihenfolge: (frage.optionen ?? []).map((option) => option.label),
  };
}

/* Der Prüfungsbogen und die Lernsitzung sehen sich ähnlich, sind aber zwei
   Verträge: Nur die Lernsitzung führt den Merkzustand mit. */
function sitzungseintrag(frage: Frage): SitzungsFrage {
  return { ...bogeneintrag(frage), gemerkt: false };
}

const STANDARDFRAGEN: Pruefungsfrage[] = [
  bogeneintrag(FRAGE_EINFACH),
  bogeneintrag(FRAGE_MEHRFACH),
  bogeneintrag(FRAGE_NOTWEHR),
];

/** Ein Bogen ohne Abweichung – der Regelfall. */
const STANDARDBOGEN: Pruefungsbogen = { fragen: STANDARDFRAGEN, warnungen: [] };

/** 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.2-10': {
      kurz: 'Der Erwerb ist binnen zwei Wochen bei der Behoerde anzuzeigen.',
      text: 'Die Frist laeuft ab dem Erwerb, nicht ab dem Kaufvertrag. Versaeumt man sie, ist das eine Ordnungswidrigkeit - der Erwerb selbst bleibt davon unberuehrt.',
      fundstellen: [{ gesetz: 'WaffG', norm: '§ 10', absatz: '1' }],
    },
    '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([sitzungseintrag(FRAGE_EINFACH)])),
    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(STANDARDBOGEN)),
    pruefungAuswerten: vi.fn(() => Promise.resolve(ERGEBNIS)),
    pruefungVerlauf: vi.fn(() => Promise.resolve(VERLAUF)),
    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: null, warnung: null })),
    lizenzenLesen: vi.fn(() => Promise.resolve(LIZENZEN)),
    ...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 die Auswahlansicht der Prüfung. */
async function auswahlOeffnen(bruecke: LueckenhafteBruecke = attrappe()): Promise<Nutzer> {
  vi.stubGlobal('lernApp', bruecke);
  const nutzer = userEvent.setup();
  render(<App />);

  const knopf = await screen.findByRole('button', { name: /Prüfungssimulation vorbereiten/u });
  /* Der Knopf ist nie `disabled`, sondern meldet sich über `aria-disabled`
     (Projektregel, docs/stand.md 7.13) – `toBeEnabled()` wäre hier immer wahr
     und liefe dem Katalog nicht mehr hinterher. */
  await waitFor(() => {
    expect(knopf).toHaveAttribute('aria-disabled', 'false');
  });
  await nutzer.click(knopf);
  await screen.findByRole('heading', { name: 'Prüfungssimulation vorbereiten' });
  return nutzer;
}

/** Öffnet die Auswahl und startet sofort mit dem vorgegebenen Profil. */
async function pruefungOeffnen(bruecke: LueckenhafteBruecke = attrappe()): Promise<Nutzer> {
  const nutzer = await auswahlOeffnen(bruecke);
  await nutzer.click(screen.getByRole('button', { name: 'Simulation starten' }));
  await screen.findByRole('heading', { name: /Frage 1\.87, Frage \d+ von \d+$/u, level: 2 });
  return nutzer;
}

/**
 * Bildet `window.speechSynthesis` nach und gibt zurück, was gesprochen wird.
 *
 * `getVoices()` liefert bewusst nichts – genau so verhält sich Electron
 * unter Windows vor der ersten Äußerung.
 */
function sprachausgabeStellen(): string[] {
  const gesprochen: string[] = [];

  class AeusserungAttrappe {
    lang = '';
    voice: unknown = null;
    onend: (() => void) | null = null;
    onerror: ((ereignis: { error: string }) => void) | null = null;
    constructor(public readonly text: string) {}
  }

  vi.stubGlobal('SpeechSynthesisUtterance', AeusserungAttrappe);
  vi.stubGlobal('speechSynthesis', {
    speak: (aeusserung: AeusserungAttrappe) => gesprochen.push(aeusserung.text),
    cancel: () => undefined,
    getVoices: () => [],
    speaking: false,
    pending: false,
    paused: false,
  });

  return gesprochen;
}

/** Die Live-Region der Prüfung – nicht die des Themenwechsels. */
function ansagebereich(): HTMLElement {
  const bereich = screen.getByRole('region', { name: /Prüfungssimulation:/u });
  return within(bereich).getByRole('status');
}

afterEach(() => {
  vi.unstubAllGlobals();
  vi.useRealTimers();
  /* Die Erklaerungen liegen in einem Modul-weiten Zwischenspeicher – ohne
     Leeren wuerde der naechste Test die Antwort des vorigen sehen. */
  erklaerungenZwischenspeicherLeeren();
});

// ─── Auswahlansicht ─────────────────────────────────────────────────────

describe('Prüfungswahl – Profile', () => {
  it('stellt alle fünf Profile mit ihren Eckdaten zur Wahl', async () => {
    await auswahlOeffnen();

    const gruppe = screen.getByRole('group', { name: 'Prüfungsprofil' });
    expect(within(gruppe).getAllByRole('radio')).toHaveLength(5);
    expect(
      within(gruppe).getByRole('radio', {
        name: /Standard.*80 Fragen, 120 Minuten, bestanden ab 80 Prozent richtig/su,
      }),
    ).toBeChecked();
    expect(within(gruppe).getByRole('radio', { name: /höchstens 15 Fehler/u })).toBeInTheDocument();
    expect(screen.getByText(/Zwischen 60 und 74 Prozent/u)).toBeInTheDocument();
  });

  it('übergibt das gewählte Profil unverändert an den Anwendungskern', async () => {
    const pruefungStarten = vi.fn(() => Promise.resolve(STANDARDBOGEN));
    const nutzer = await auswahlOeffnen(attrappe({ pruefungStarten }));

    await nutzer.click(screen.getByRole('radio', { name: /Nach Art des BDMP/u }));
    await nutzer.click(screen.getByRole('button', { name: 'Simulation starten' }));
    await screen.findByRole('heading', { name: /Frage 1\.87, Frage \d+ von \d+$/u, level: 2 });

    expect(pruefungStarten).toHaveBeenCalledWith(1, {
      profilId: 'bdmp',
      zeitmodus: 'normal',
      kapitelAusschluss: [],
      /* Kommt nicht aus der Prüfungswahl, sondern aus den Einstellungen –
         und steht ab Werk auf „aus“: Die Simulation soll dem gedruckten
         Bogen nahekommen, und wer die Antworten über ihre Stelle behalten
         muss, braucht die feste Reihenfolge gerade hier. */
      optionenMischen: false,
    });
  });

  /*
    Die Sprachausgabe muss auch hier zur Verfügung stehen. Wer den Text
    hören muss, weil ihm Lesen schwerfällt, bräuchte die Hilfe sonst
    überall außer in der Prüfungsvorbereitung – also dort, wo es am meisten
    darauf ankommt. Vorgelesen wird nur die Frage: Eine Rückmeldung gibt es
    in der Simulation von niemandem.
  */
  it('liest die Frage auch in der Simulation vor', async () => {
    const gesprochen = sprachausgabeStellen();
    const nutzer = await pruefungOeffnen(
      attrappe({
        einstellungenLesen: vi.fn(() =>
          Promise.resolve({ ...EINSTELLUNGEN_STANDARD, zuschnittGefragt: true, vorlesen: true }),
        ),
      }),
    );

    await nutzer.click(screen.getByRole('button', { name: /Frage vorlesen/u }));

    expect(gesprochen.join(' ')).toContain('Anscheinswaffe');
    expect(screen.getByRole('button', { name: /Vorlesen beenden/u })).toBeInTheDocument();
  });

  it('schweigt, sobald geblättert wird', async () => {
    /*
      Gemeldet von einem Nutzer für die Lernsitzung; der Prüfungslauf hatte
      denselben Fehler. Hier wiegt er schwerer: Im Lauf lässt sich in beide
      Richtungen blättern, und wer die vorige Frage weiterhört, während die
      nächste dasteht, beantwortet unter Umständen die falsche.
    */
    sprachausgabeStellen();
    const nutzer = await pruefungOeffnen(
      attrappe({
        einstellungenLesen: vi.fn(() =>
          Promise.resolve({ ...EINSTELLUNGEN_STANDARD, zuschnittGefragt: true, vorlesen: true }),
        ),
      }),
    );

    await nutzer.click(screen.getByRole('button', { name: /Frage vorlesen/u }));
    expect(screen.getByRole('button', { name: /Vorlesen beenden/u })).toBeInTheDocument();

    await nutzer.click(screen.getByRole('button', { name: 'Nächste Frage' }));

    expect(screen.queryByRole('button', { name: /Vorlesen beenden/u })).toBeNull();
    expect(screen.getByRole('button', { name: /Frage vorlesen/u })).toBeInTheDocument();
  });

  it('schließt Kapitel IV auf Wunsch aus', async () => {
    const pruefungStarten = vi.fn(() => Promise.resolve(STANDARDBOGEN));
    const nutzer = await auswahlOeffnen(attrappe({ pruefungStarten }));

    await nutzer.click(screen.getByRole('checkbox', { name: /Kapitel IV – Jagdrecht/u }));
    await nutzer.click(screen.getByRole('button', { name: 'Simulation starten' }));
    await screen.findByRole('heading', { name: /Frage 1\.87, Frage \d+ von \d+$/u, level: 2 });

    expect(pruefungStarten).toHaveBeenCalledWith(
      1,
      expect.objectContaining({ kapitelAusschluss: ['IV'] }),
    );
  });

  /*
    Die Erststart-Frage sagt zu, dass abgewählte Fragen „in keiner Sitzung und
    in keiner Zahl mehr“ vorkommen. Bis Fassung 0.20.0 stand das Kästchen hier
    trotzdem fest auf „einbeziehen“: Wer Kapitel IV abgewählt hatte, musste 89
    von 575 Fragen in jeder Simulation von Hand wieder abwählen.
  */
  it('übernimmt den Kapitelausschluss des Lernprofils als Vorgabe', async () => {
    const pruefungStarten = vi.fn(() => Promise.resolve(STANDARDBOGEN));
    const nutzer = await auswahlOeffnen(
      attrappe({
        pruefungStarten,
        profilListe: vi.fn(() => Promise.resolve([{ ...PROFIL, kapitelAusschluss: ['IV'] }])),
      }),
    );

    const kaestchen = screen.getByRole('checkbox', { name: /Kapitel IV – Jagdrecht/u });

    expect(kaestchen).not.toBeChecked();
    /* Woher die Vorbelegung kommt, gehört zum Kästchen: Wer sich per Tabulator
       durchs Formular arbeitet, hört sonst nur „nicht angehakt“. */
    expect(kaestchen).toHaveAccessibleDescription(/unter „Ihr Lernplan“ abgewählt/u);

    await nutzer.click(screen.getByRole('button', { name: 'Simulation starten' }));
    await screen.findByRole('heading', { name: /Frage 1\.87, Frage \d+ von \d+$/u, level: 2 });

    expect(pruefungStarten).toHaveBeenCalledWith(
      1,
      expect.objectContaining({ kapitelAusschluss: ['IV'] }),
    );
  });

  /* Vorgabe heißt Vorgabe, nicht Sperre: Für eine einzelne Simulation darf
     das Kapitel zurückkommen – am Lernplan ändert das nichts. */
  it('lässt den Ausschluss für eine einzelne Simulation zurücknehmen', async () => {
    const pruefungStarten = vi.fn(() => Promise.resolve(STANDARDBOGEN));
    const profilAktualisieren = vi.fn(() => Promise.resolve(PROFIL));
    const nutzer = await auswahlOeffnen(
      attrappe({
        pruefungStarten,
        profilAktualisieren,
        profilListe: vi.fn(() => Promise.resolve([{ ...PROFIL, kapitelAusschluss: ['IV'] }])),
      }),
    );

    await nutzer.click(screen.getByRole('checkbox', { name: /Kapitel IV – Jagdrecht/u }));
    await nutzer.click(screen.getByRole('button', { name: 'Simulation starten' }));
    await screen.findByRole('heading', { name: /Frage 1\.87, Frage \d+ von \d+$/u, level: 2 });

    expect(pruefungStarten).toHaveBeenCalledWith(
      1,
      expect.objectContaining({ kapitelAusschluss: [] }),
    );
    expect(profilAktualisieren).not.toHaveBeenCalled();
  });

  /* Projektregel „aria-disabled statt disabled“ (docs/stand.md 7.13): Ein
     gesperrter Knopf fällt aus der Tabulatorreihenfolge, und der Fokus landet
     auf `<body>`. Der Startknopf trug bis Fassung 0.20.0 natives `disabled`. */
  it('sperrt den Startknopf nicht mit disabled', async () => {
    await auswahlOeffnen();

    const knopf = screen.getByRole('button', { name: 'Simulation starten' });

    expect(knopf).not.toBeDisabled();
    knopf.focus();
    expect(knopf).toHaveFocus();
  });

  it('übernimmt die frei eingestellten Werte in den Auftrag', async () => {
    const pruefungStarten = vi.fn(() => Promise.resolve(STANDARDBOGEN));
    const nutzer = await auswahlOeffnen(attrappe({ pruefungStarten }));

    await nutzer.click(screen.getByRole('radio', { name: /Selbst einstellen/u }));

    const fragenfeld = screen.getByRole('spinbutton', { name: 'Fragen im Bogen' });
    await nutzer.clear(fragenfeld);
    await nutzer.type(fragenfeld, '12');

    const zeitfeld = screen.getByRole('spinbutton', { name: 'Bearbeitungszeit' });
    await nutzer.clear(zeitfeld);
    await nutzer.type(zeitfeld, '30');

    const grenzfeld = screen.getByRole('spinbutton', { name: 'Bestehensgrenze' });
    await nutzer.clear(grenzfeld);
    await nutzer.type(grenzfeld, '70');

    const offenfeld = screen.getByRole('spinbutton', { name: 'Anteil offener Fragen' });
    await nutzer.clear(offenfeld);
    await nutzer.type(offenfeld, '25');

    await nutzer.click(screen.getByRole('button', { name: 'Simulation starten' }));
    await screen.findByRole('heading', { name: /Frage 1\.87, Frage \d+ von \d+$/u, level: 2 });

    expect(pruefungStarten).toHaveBeenCalledWith(1, {
      profilId: 'frei',
      zeitmodus: 'normal',
      kapitelAusschluss: [],
      optionenMischen: false,
      fragenAnzahl: 12,
      bestehensQuote: 0.7,
      zeitMinuten: 30,
      anteilOffen: 0.25,
    });
  });

  /*
    Der Regler ist ein Wunsch, keine Zusage. Damit das niemanden überrascht,
    muss die Wahl es vorher sagen: Die Warnung des Anwendungskerns kommt zwar
    inzwischen bis auf den Bildschirm, aber erst im laufenden Bogen.
  */
  it('rechnet den Anteil in Fragen um und nennt den Vorrat', async () => {
    const nutzer = await auswahlOeffnen(attrappe({}));
    await nutzer.click(screen.getByRole('radio', { name: /Selbst einstellen/u }));

    const fragenfeld = screen.getByRole('spinbutton', { name: 'Fragen im Bogen' });
    await nutzer.clear(fragenfeld);
    await nutzer.type(fragenfeld, '20');

    const offenfeld = screen.getByRole('spinbutton', { name: 'Anteil offener Fragen' });
    await nutzer.clear(offenfeld);
    await nutzer.type(offenfeld, '50');

    // 50 Prozent von 20 sind 10; der Testkatalog hat weniger.
    const hinweis = await screen.findByText(/Das ergibt 10 offene Fragen/u);
    expect(hinweis).toHaveTextContent(/mit Auswahlfragen aufgefüllt/u);
  });

  it('sagt bei null Prozent, dass der Bogen nur aus Auswahlfragen besteht', async () => {
    const nutzer = await auswahlOeffnen(attrappe({}));
    await nutzer.click(screen.getByRole('radio', { name: /Selbst einstellen/u }));

    expect(await screen.findByText(/ausschließlich aus Auswahlfragen/u)).toBeInTheDocument();
  });
});

describe('Prüfungswahl – Grenzwerte der freien Eingabe', () => {
  /*
    Werte außerhalb der Grenzen werden ohnehin gekappt. Sie stillschweigend zu
    korrigieren wäre aber irreführend: Im Feld stünde 900, gerechnet würde mit
    575. Der wirksame Wert muss deshalb dastehen (WCAG 3.3.1 und 3.3.3).
  */
  async function freiOeffnen(): Promise<Nutzer> {
    const nutzer = await auswahlOeffnen();
    await nutzer.click(screen.getByRole('radio', { name: /Selbst einstellen/u }));
    return nutzer;
  }

  it('meldet eine zu große Fragenzahl und nennt den wirksamen Wert', async () => {
    const nutzer = await freiOeffnen();
    const feld = screen.getByLabelText('Fragen im Bogen');

    await nutzer.clear(feld);
    await nutzer.type(feld, '900');

    expect(feld).toHaveAttribute('aria-invalid', 'true');
    expect(screen.getByText(/Derzeit wird mit 575 gerechnet/u)).toBeInTheDocument();
  });

  it('meldet ein leeres Feld und nennt den wirksamen Wert', async () => {
    const nutzer = await freiOeffnen();
    const feld = screen.getByLabelText('Fragen im Bogen');

    await nutzer.clear(feld);

    expect(feld).toHaveAttribute('aria-invalid', 'true');
    expect(screen.getByText(/Derzeit wird mit 5 gerechnet/u)).toBeInTheDocument();
  });

  it('meldet einen gültigen Wert nicht als Fehler', async () => {
    const nutzer = await freiOeffnen();
    const feld = screen.getByLabelText('Fragen im Bogen');

    await nutzer.clear(feld);
    await nutzer.type(feld, '25');

    expect(feld).toHaveAttribute('aria-invalid', 'false');
    expect(screen.queryByText(/Derzeit wird mit/u)).not.toBeInTheDocument();
  });
});

describe('Prüfungswahl – Zeitstufen (WCAG 2.2.1)', () => {
  it('bietet alle vier Stufen einschließlich „Ohne Zeitbegrenzung“ gleichrangig an', async () => {
    await auswahlOeffnen();

    const gruppe = screen.getByRole('group', { name: 'Zeitvorgabe' });
    const stufen = within(gruppe).getAllByRole('radio');
    expect(stufen.map((stufe) => stufe.getAttribute('value'))).toEqual([
      'aus',
      'normal',
      'plus25',
      'plus50',
    ]);

    expect(within(gruppe).getByRole('radio', { name: 'Ohne Zeitbegrenzung' })).toBeEnabled();
    expect(
      within(gruppe).getByRole('radio', { name: 'Vorgesehene Zeit – 120 Minuten' }),
    ).toBeChecked();
    expect(
      within(gruppe).getByRole('radio', {
        name: 'Vorgesehene Zeit plus 25 Prozent – 150 Minuten',
      }),
    ).toBeInTheDocument();
    expect(
      within(gruppe).getByRole('radio', {
        name: 'Vorgesehene Zeit plus 50 Prozent – 180 Minuten',
      }),
    ).toBeInTheDocument();
  });

  it('kündigt die automatische Abgabe vorab an', async () => {
    const nutzer = await auswahlOeffnen();

    expect(
      screen.getByText(/wird die Simulation automatisch abgegeben und ausgewertet/u),
    ).toBeInTheDocument();

    await nutzer.click(screen.getByRole('radio', { name: 'Ohne Zeitbegrenzung' }));
    expect(
      screen.getByText('Es läuft keine Uhr mit. Sie geben ab, wann Sie möchten.'),
    ).toBeInTheDocument();
  });
});

// ─── Prüfungsansicht ────────────────────────────────────────────────────

describe('Prüfungsansicht – Aufbau', () => {
  /*
    Der Sekundentakt der Restzeit ist der einzige Zeitgeber der Prüfung.
    Andere Intervalle – Testing Library prüft im 50-Millisekunden-Takt, ob eine
    Zusicherung schon zutrifft – bleiben außen vor.
  */
  function sekundentakte(aufrufe: readonly (readonly unknown[])[]): number {
    return aufrufe.filter((aufruf) => aufruf[1] === 1000).length;
  }

  it('meldet den Fortschritt als progressbar und als Text', async () => {
    await pruefungOeffnen();

    const balken = screen.getByRole('progressbar', { name: 'Fortschritt der Simulation' });
    expect(balken).toHaveAttribute('aria-valuenow', '1');
    expect(balken).toHaveAttribute('aria-valuemax', '3');
    expect(balken).toHaveAttribute('aria-valuetext', 'Frage 1 von 3');
    expect(screen.getByText('Frage 1 von 3')).toBeInTheDocument();
  });

  it('startet mit Zeitmodus „aus“ keinen Zeitgeber', async () => {
    const intervall = vi.spyOn(window, 'setInterval');
    const nutzer = await auswahlOeffnen();

    await nutzer.click(screen.getByRole('radio', { name: 'Ohne Zeitbegrenzung' }));
    await nutzer.click(screen.getByRole('button', { name: 'Simulation starten' }));
    await screen.findByRole('heading', { name: /Frage 1\.87, Frage \d+ von \d+$/u, level: 2 });

    expect(screen.getByText('Ohne Zeitbegrenzung')).toBeInTheDocument();
    expect(
      screen.getByText('Es läuft keine Uhr mit. Sie geben ab, wann Sie möchten.'),
    ).toBeInTheDocument();
    expect(sekundentakte(intervall.mock.calls)).toBe(0);
  });

  it('zeigt die Restzeit als vorlesbaren Text und lässt dafür die Uhr laufen', async () => {
    const intervall = vi.spyOn(window, 'setInterval');
    await pruefungOeffnen();

    expect(screen.getByText('Restzeit:')).toBeInTheDocument();
    expect(screen.getByText('2 Stunden')).toBeInTheDocument();
    expect(sekundentakte(intervall.mock.calls)).toBeGreaterThan(0);
  });
});

/*
  Befund 7.10: Der Anwendungskern meldete vier Abweichungen beim
  Zusammenstellen eines Bogens ausschließlich über `console.warn` in das
  Protokoll des Hauptprozesses. Wer simulierte, hielt seinen Bogen für
  profilgetreu. Sie hängen jetzt am Bogen und stehen über den Fragen.
*/
describe('Prüfungsansicht – Abweichungen des Bogens', () => {
  function mitWarnungen(warnungen: readonly string[]): LueckenhafteBruecke {
    return attrappe({
      pruefungStarten: vi.fn(() => Promise.resolve({ fragen: STANDARDFRAGEN, warnungen })),
    });
  }

  it('gibt eine einzelne Abweichung als angesagten Hinweis aus', async () => {
    await pruefungOeffnen(
      mitWarnungen([
        'Im Bogen: 2 offene Fragen wurden durch 2 Auswahlfragen ersetzt – so viele offene Fragen hat der Katalog dort nicht.',
      ]),
    );

    /* `role="alert"`, weil der Bogen erst nach dem Bildaufbau eintrifft:
       Ohne Ansage bliebe die Auskunft Sehenden vorbehalten. */
    const meldung = screen.getByRole('alert');

    expect(meldung).toHaveTextContent('Dieser Bogen weicht von den Vorgaben ab.');
    expect(meldung).toHaveTextContent('durch 2 Auswahlfragen ersetzt');
  });

  it('listet mehrere Abweichungen einzeln auf', async () => {
    await pruefungOeffnen(
      mitWarnungen([
        'Im Themenbereich „I.2 – Rechte und Pflichten“: Vorgesehen sind 20 Fragen, verfügbar sind 6.',
        'Das Profil „Standard“ hat feste Vorgaben: die Fragenzahl wurde nicht übernommen.',
      ]),
    );

    const meldung = screen.getByRole('alert');
    const punkte = within(meldung).getAllByRole('listitem');

    expect(punkte).toHaveLength(2);
    expect(punkte[0]).toHaveTextContent('Im Themenbereich „I.2 – Rechte und Pflichten“');
    expect(punkte[1]).toHaveTextContent('die Fragenzahl wurde nicht übernommen');
  });

  it('schweigt, wenn der Bogen den Vorgaben folgt', async () => {
    await pruefungOeffnen();

    expect(screen.queryByRole('alert')).toBeNull();
  });
});

/*
  Projektregel „aria-disabled statt disabled“ (docs/stand.md 7.13): An den
  Enden des Bogens verschwand der jeweilige Blätterknopf im letzten Klick unter
  dem Finger aus der Tabulatorreihenfolge, und der Fokus fiel auf `<body>`.
*/
describe('Prüfungsansicht – Blättern an den Enden des Bogens', () => {
  it('hält „Vorherige Frage“ bei Frage 1 erreichbar und nennt den Grund', async () => {
    const nutzer = await pruefungOeffnen();
    const zurueck = screen.getByRole('button', { name: 'Vorherige Frage' });

    expect(zurueck).not.toBeDisabled();
    expect(zurueck).toHaveAttribute('aria-disabled', 'true');
    expect(zurueck).toHaveAccessibleDescription(
      'Sie sind bei der ersten Frage; zurück geht es von hier nicht.',
    );

    zurueck.focus();
    expect(zurueck).toHaveFocus();

    await nutzer.click(zurueck);

    expect(screen.getByRole('progressbar', { name: 'Fortschritt der Simulation' })).toHaveAttribute(
      'aria-valuenow',
      '1',
    );
  });

  it('hält „Nächste Frage“ bei der letzten Frage erreichbar und nennt den Grund', async () => {
    const nutzer = await pruefungOeffnen();
    const weiter = screen.getByRole('button', { name: 'Nächste Frage' });

    expect(weiter).not.toHaveAttribute('aria-disabled', 'true');
    await nutzer.click(weiter);
    await nutzer.click(weiter);

    expect(weiter).toHaveAttribute('aria-disabled', 'true');
    expect(weiter).toHaveAccessibleDescription(
      'Sie sind bei der letzten Frage; weiter geht es von hier nicht.',
    );

    await nutzer.click(weiter);

    expect(screen.getByRole('progressbar', { name: 'Fortschritt der Simulation' })).toHaveAttribute(
      'aria-valuenow',
      '3',
    );
  });
});

describe('Prüfungsansicht – kein Feedback vor der Abgabe', () => {
  it('bewertet eine Antwort nicht und verrät die Lösung nicht', async () => {
    const nutzer = await pruefungOeffnen();

    await nutzer.click(screen.getByRole('checkbox', { name: /Sportgewehr/u }));

    expect(screen.queryByRole('group', { name: 'Richtig' })).not.toBeInTheDocument();
    expect(screen.queryByRole('group', { name: 'Falsch' })).not.toBeInTheDocument();
    expect(screen.queryByText(/Richtig – nicht gewählt/u)).not.toBeInTheDocument();
    expect(screen.queryByText(/Falsch – von Ihnen gewählt/u)).not.toBeInTheDocument();
    expect(screen.queryByRole('heading', { name: 'Musterantwort' })).not.toBeInTheDocument();
  });

  it('reicht im Prüfungslauf keine Bedeutung der Zeichen nach', async () => {
    /*
      Die zweite Stufe des Bildbeschreibungskonzepts gehört in den Lernmodus.
      In der echten Prüfung gibt es keine Erklärungen – also auch hier nicht,
      obwohl das Zeichen an Option b) eine Beschreibung hinterlegt hat. Der
      neutrale Alt-Text bleibt selbstverständlich stehen.
    */
    const nutzer = await pruefungOeffnen();

    expect(
      screen.getByRole('img', { name: 'Rundes Zeichen mit der Buchstabenfolge PTB' }),
    ).toBeInTheDocument();
    expect(screen.queryByText('Was dieses Zeichen bedeutet')).not.toBeInTheDocument();

    await nutzer.click(screen.getByRole('checkbox', { name: /Schreckschusspistole/u }));

    expect(screen.queryByText('Was dieses Zeichen bedeutet')).not.toBeInTheDocument();
    expect(screen.queryByText(/Physikalisch-Technischen Bundesanstalt/u)).not.toBeInTheDocument();
  });

  it('lässt die Antwort bis zur Abgabe ändern', async () => {
    const nutzer = await pruefungOeffnen();

    const erste = screen.getByRole('checkbox', { name: /Sportgewehr/u });
    const dritte = screen.getByRole('checkbox', { name: /Spielzeugpistole/u });

    await nutzer.click(erste);
    expect(erste).toBeChecked();

    // Abwählen ist möglich …
    await nutzer.click(erste);
    expect(erste).not.toBeChecked();

    // … und eine andere Option lässt sich stattdessen wählen.
    await nutzer.click(dritte);
    expect(dritte).toBeChecked();
  });

  it('sammelt die Auswahl auch bei Fragen mit nur einer richtigen Antwort', async () => {
    /*
      In der Prüfung steht nicht dabei, wie viele Antworten richtig sind. Würde
      die Oberfläche bei Einfachfragen die vorige Auswahl ersetzen, verriete
      schon dieses Verhalten die Lösung. Zu viel angekreuzt zu haben ist
      schlicht falsch – genau wie in der echten Prüfung.
    */
    const nutzer = await pruefungOeffnen();

    const erste = screen.getByRole('checkbox', { name: /Sportgewehr/u });
    const dritte = screen.getByRole('checkbox', { name: /Spielzeugpistole/u });

    await nutzer.click(erste);
    await nutzer.click(dritte);

    expect(erste).toBeChecked();
    expect(dritte).toBeChecked();
  });

  it('stellt Einfach- und Mehrfachfragen ununterscheidbar dar', async () => {
    /*
      Der Kern des Befunds: Weder die Art des Bedienelements noch die
      Beschriftung darf verraten, wie viele Antworten richtig sind. Die
      Beschriftung nennt nur, was auch im amtlichen Vorwort steht.
    */
    const nutzer = await pruefungOeffnen();

    // Erste Frage: genau eine richtige Antwort.
    expect(screen.queryAllByRole('radio')).toHaveLength(0);
    expect(
      screen.getByText(/Antwortmöglichkeiten – mindestens eine Antwort ist richtig/u),
    ).toBeInTheDocument();
    expect(screen.queryByText(/Mehrere Antworten sind richtig\./u)).not.toBeInTheDocument();

    // Zweite Frage: mehrere richtige Antworten – gleiche Darstellung.
    await nutzer.click(screen.getByRole('button', { name: 'Nächste Frage' }));

    expect(screen.queryAllByRole('radio')).toHaveLength(0);
    expect(
      screen.getByText(/Antwortmöglichkeiten – mindestens eine Antwort ist richtig/u),
    ).toBeInTheDocument();
    expect(screen.queryByText(/Mehrere Antworten sind richtig\./u)).not.toBeInTheDocument();
  });

  it('behält die Antwort beim Blättern bei', async () => {
    const nutzer = await pruefungOeffnen();

    await nutzer.click(screen.getByRole('checkbox', { name: /Spielzeugpistole/u }));
    await nutzer.click(screen.getByRole('button', { name: 'Nächste Frage' }));
    await screen.findByRole('heading', { name: /Frage 2\.10, Frage \d+ von \d+$/u, level: 2 });

    await nutzer.click(screen.getByRole('button', { name: 'Vorherige Frage' }));
    await screen.findByRole('heading', { name: /Frage 1\.87, Frage \d+ von \d+$/u, level: 2 });

    expect(screen.getByRole('checkbox', { name: /Spielzeugpistole/u })).toBeChecked();
  });
});

describe('Prüfungsansicht – Fragenübersicht', () => {
  it('unterscheidet beantwortet und offen im zugänglichen Namen, nicht nur farblich', async () => {
    const nutzer = await pruefungOeffnen();

    expect(
      screen.getByRole('button', { name: 'Frage 1 von 3, offen, aktuelle Frage' }),
    ).toBeInTheDocument();
    expect(screen.getByRole('button', { name: 'Frage 2 von 3, offen' })).toBeInTheDocument();
    expect(screen.getByText('0 von 3 Fragen beantwortet, 3 noch offen.')).toBeInTheDocument();

    await nutzer.click(screen.getByRole('checkbox', { name: /Spielzeugpistole/u }));

    expect(
      screen.getByRole('button', { name: 'Frage 1 von 3, beantwortet, aktuelle Frage' }),
    ).toHaveAttribute('aria-current', 'true');
    expect(screen.getByText('1 von 3 Fragen beantwortet, 2 noch offen.')).toBeInTheDocument();
  });

  it('springt frei zu einer anderen Frage', async () => {
    const nutzer = await pruefungOeffnen();

    await nutzer.click(screen.getByRole('button', { name: 'Frage 3 von 3, offen' }));

    expect(
      await screen.findByRole('heading', { name: /Frage 5\.04, Frage \d+ von \d+$/u, level: 2 }),
    ).toHaveFocus();
    expect(screen.getByText('Frage 3 von 3')).toBeInTheDocument();
  });
});

describe('Prüfungsansicht – Abgabe', () => {
  it('gibt erst nach ausdrücklicher Rückfrage ab und nennt die offenen Fragen', async () => {
    const pruefungAuswerten = vi.fn(() => Promise.resolve(ERGEBNIS));
    const nutzer = await pruefungOeffnen(attrappe({ pruefungAuswerten }));

    await nutzer.click(screen.getByRole('checkbox', { name: /Spielzeugpistole/u }));
    await nutzer.click(screen.getByRole('button', { name: 'Simulation abgeben' }));

    expect(pruefungAuswerten).not.toHaveBeenCalled();
    const rueckfrage = screen.getByRole('group', { name: 'Simulation wirklich abgeben?' });
    expect(rueckfrage).toHaveFocus();
    expect(
      within(rueckfrage).getByText(
        /Sie haben 1 von 3 Fragen beantwortet\. 2 Fragen sind noch offen/u,
      ),
    ).toBeInTheDocument();

    await nutzer.click(screen.getByRole('button', { name: 'Weiter bearbeiten' }));
    expect(pruefungAuswerten).not.toHaveBeenCalled();
    expect(
      screen.queryByRole('group', { name: 'Simulation wirklich abgeben?' }),
    ).not.toBeInTheDocument();
    /* Der Fokus gehört zurück auf den auslösenden Knopf. Landete er auf der
       Fragenüberschrift, müsste man sich durch alle Antwortoptionen
       zurückarbeiten, um wieder zu den Schaltflächen zu kommen. */
    expect(screen.getByRole('button', { name: 'Simulation abgeben' })).toHaveFocus();

    await nutzer.click(screen.getByRole('button', { name: 'Simulation abgeben' }));
    await nutzer.click(screen.getByRole('button', { name: 'Jetzt abgeben und auswerten' }));

    await screen.findByRole('heading', { name: 'Auswertung der Prüfungssimulation' });
    expect(pruefungAuswerten).toHaveBeenCalledTimes(1);
    expect(pruefungAuswerten).toHaveBeenCalledWith(
      1,
      expect.objectContaining({ profilId: 'standard' }),
      [
        { frageId: 'I.1-87', auswahl: ['c'], freitext: '' },
        { frageId: 'I.2-10', auswahl: [], freitext: '' },
        { frageId: 'I.5-04', auswahl: [], freitext: '' },
      ],
      expect.any(Number),
      false,
    );
  });

  it('lässt den Lauf jederzeit ohne Auswertung verwerfen', async () => {
    const pruefungAuswerten = vi.fn(() => Promise.resolve(ERGEBNIS));
    const nutzer = await pruefungOeffnen(attrappe({ pruefungAuswerten }));

    await nutzer.click(screen.getByRole('button', { name: 'Simulation abgeben' }));
    await nutzer.click(screen.getByRole('button', { name: 'Ohne Auswertung verwerfen' }));

    expect(await screen.findByRole('heading', { name: 'Heute lernen' })).toBeInTheDocument();
    expect(pruefungAuswerten).not.toHaveBeenCalled();
  });
});

describe('Prüfungsansicht – Zeitablauf', () => {
  /**
   * Startet mit dem frei einstellbaren Profil und der angegebenen Minutenzahl.
   *
   * `shouldAdvanceTime` ist hier nicht optional: Testing Library erkennt nur
   * Jests Zeitattrappe und wartet sonst auf echte Zeitgeber, die die Attrappe
   * angehalten hat – der Lauf bliebe stehen.
   */
  async function mitZeit(
    minuten: string,
    pruefungAuswerten: () => Promise<Pruefungsergebnis>,
  ): Promise<Nutzer> {
    vi.useFakeTimers({ shouldAdvanceTime: true, advanceTimeDelta: 10 });
    vi.stubGlobal('lernApp', attrappe({ pruefungAuswerten }));
    const nutzer = userEvent.setup({ advanceTimers: vi.advanceTimersByTime });
    render(<App />);

    const start = await screen.findByRole('button', { name: /Prüfungssimulation vorbereiten/u });
    /* Der Knopf ist nie `disabled`, sondern meldet sich über `aria-disabled`
       (Projektregel, docs/stand.md 7.13) – `toBeEnabled()` wäre hier immer
       wahr und liefe dem Katalog nicht mehr hinterher. */
    await waitFor(() => {
      expect(start).toHaveAttribute('aria-disabled', 'false');
    });
    await nutzer.click(start);
    await screen.findByRole('heading', { name: 'Prüfungssimulation vorbereiten' });

    await nutzer.click(screen.getByRole('radio', { name: /Selbst einstellen/u }));
    const zeitfeld = screen.getByRole('spinbutton', { name: 'Bearbeitungszeit' });
    await nutzer.clear(zeitfeld);
    await nutzer.type(zeitfeld, minuten);
    await nutzer.click(screen.getByRole('button', { name: 'Simulation starten' }));
    await screen.findByRole('heading', { name: /Frage 1\.87, Frage \d+ von \d+$/u, level: 2 });
    return nutzer;
  }

  it('weist fünf Minuten vor Schluss einmalig höflich auf die Abgabe hin', async () => {
    const pruefungAuswerten = vi.fn(() => Promise.resolve(ERGEBNIS));
    await mitZeit('10', pruefungAuswerten);

    const region = ansagebereich();
    expect(region).toHaveAttribute('aria-live', 'polite');
    expect(region).toHaveTextContent('');

    await act(async () => {
      await vi.advanceTimersByTimeAsync(5 * 60 * 1000 + 2000);
    });

    await waitFor(() => {
      expect(ansagebereich()).toHaveTextContent(
        'Noch fünf Minuten Restzeit. Danach wird die Simulation automatisch abgegeben und ausgewertet.',
      );
    });
    expect(pruefungAuswerten).not.toHaveBeenCalled();
  });

  it('gibt bei abgelaufener Zeit selbsttätig ab', async () => {
    const pruefungAuswerten = vi.fn(() => Promise.resolve(ERGEBNIS));
    await mitZeit('1', pruefungAuswerten);

    await act(async () => {
      await vi.advanceTimersByTimeAsync(61_000);
    });

    await waitFor(() => {
      expect(pruefungAuswerten).toHaveBeenCalledTimes(1);
    });
    expect(pruefungAuswerten).toHaveBeenCalledWith(
      1,
      expect.objectContaining({ profilId: 'frei' }),
      expect.any(Array),
      expect.any(Number),
      true,
    );
  });
});

// ─── Energiesparmodus ───────────────────────────────────────────────────

/*
  Die Uhr der Simulation hing bis 0.22.0 an der Wanduhr: `endeMs` und
  `verbrauchtMs` leiten sich beide aus `startMs` ab, und niemand fragte, ob
  der Rechner zwischendurch schlief. Wer mitten in einem Bogen den Laptop
  zuklappte, verbrauchte die Pause als Bearbeitungszeit – und war beim
  Aufwachen abgelaufen. Der Hauptprozess misst die Schlafdauer
  (`tests/energie.test.ts`), hier wird die andere Hälfte geprüft: dass die
  Oberfläche daraus wirklich eine stillstehende Uhr macht.
*/
describe('Prüfungsansicht – Energiesparmodus', () => {
  /** Startet einen Lauf und gibt die Hand auf die Schlafmeldung zurück. */
  async function mitSchlafmeldung(
    minuten: string,
    pruefungAuswerten: () => Promise<Pruefungsergebnis>,
  ): Promise<{ readonly nutzer: Nutzer; readonly schlafen: (ms: number) => void }> {
    let melden: ((schlafMs: number) => void) | null = null;

    vi.useFakeTimers({ shouldAdvanceTime: true, advanceTimeDelta: 10 });
    vi.stubGlobal(
      'lernApp',
      attrappe({
        pruefungAuswerten,
        energiesparenBeobachten: vi.fn((rueckruf: (schlafMs: number) => void) => {
          melden = rueckruf;
          return () => {
            melden = null;
          };
        }),
      }),
    );
    const nutzer = userEvent.setup({ advanceTimers: vi.advanceTimersByTime });
    render(<App />);

    const start = await screen.findByRole('button', { name: /Prüfungssimulation vorbereiten/u });
    await waitFor(() => {
      expect(start).toHaveAttribute('aria-disabled', 'false');
    });
    await nutzer.click(start);
    await screen.findByRole('heading', { name: 'Prüfungssimulation vorbereiten' });

    await nutzer.click(screen.getByRole('radio', { name: /Selbst einstellen/u }));
    const zeitfeld = screen.getByRole('spinbutton', { name: 'Bearbeitungszeit' });
    await nutzer.clear(zeitfeld);
    await nutzer.type(zeitfeld, minuten);
    await nutzer.click(screen.getByRole('button', { name: 'Simulation starten' }));
    await screen.findByRole('heading', { name: /Frage 1\.87, Frage \d+ von \d+$/u, level: 2 });

    /* Der Schlaf selbst: Die Wanduhr springt vor, ohne dass ein Zeitgeber
       liefe – genau wie bei einem zugeklappten Rechner –, und danach meldet
       der Hauptprozess die Dauer. */
    const schlafen = (ms: number): void => {
      vi.setSystemTime(new Date(Date.now() + ms));
      melden?.(ms);
    };

    return { nutzer, schlafen };
  }

  it('schreibt die Schlafzeit gut, statt sie zu verbrauchen', async () => {
    const pruefungAuswerten = vi.fn(() => Promise.resolve(ERGEBNIS));
    const { schlafen } = await mitSchlafmeldung('30', pruefungAuswerten);

    await act(async () => {
      await vi.advanceTimersByTimeAsync(2000);
    });

    await act(async () => {
      schlafen(20 * 60 * 1000);
      await vi.advanceTimersByTimeAsync(2000);
    });

    /* Ohne Gutschrift stünden hier zehn Minuten. */
    await waitFor(() => {
      expect(screen.getByText(/30 Minuten/u)).toBeInTheDocument();
    });
    expect(pruefungAuswerten).not.toHaveBeenCalled();
  });

  it('gibt nicht ab, wenn die Zeit erst im Schlaf ablief', async () => {
    /*
      Der eigentliche Schadensfall des Befunds: Beim Aufwachen holt Chromium
      den versäumten Takt nach und sieht eine abgelaufene Wanduhr – die
      Gutschrift ist da noch unterwegs. Ohne die Karenz in `useRestzeit`
      wäre der Bogen abgegeben, bevor die Uhr überhaupt zurückgestellt ist.
    */
    const pruefungAuswerten = vi.fn(() => Promise.resolve(ERGEBNIS));
    const { schlafen } = await mitSchlafmeldung('10', pruefungAuswerten);

    await act(async () => {
      await vi.advanceTimersByTimeAsync(2000);
    });

    // Erst der nachgeholte Takt, dann die Meldung – in dieser Reihenfolge.
    await act(async () => {
      vi.setSystemTime(new Date(Date.now() + 25 * 60 * 1000));
      await vi.advanceTimersByTimeAsync(1000);
    });
    expect(pruefungAuswerten).not.toHaveBeenCalled();

    await act(async () => {
      schlafen(0);
      await vi.advanceTimersByTimeAsync(0);
    });
    // Die Gutschrift kommt getrennt, weil `schlafen(0)` die Uhr nicht bewegt.
    await act(async () => {
      await vi.advanceTimersByTimeAsync(2000);
    });

    expect(pruefungAuswerten).not.toHaveBeenCalled();
  });

  it('gibt nach der Karenz doch ab, wenn keine Gutschrift kommt', async () => {
    /*
      Die Gegenprobe zum Test darüber – und der Grund, warum die Taktlücke
      nur aufschiebt und nie gutschreibt: Ein minimiertes Fenster erzeugt
      dieselbe Lücke wie ein schlafender Rechner. Ließe sich daraus Zeit
      gutschreiben, hielte man die Prüfungsuhr durch Minimieren an.
    */
    const pruefungAuswerten = vi.fn(() => Promise.resolve(ERGEBNIS));
    await mitSchlafmeldung('10', pruefungAuswerten);

    await act(async () => {
      vi.setSystemTime(new Date(Date.now() + 25 * 60 * 1000));
      await vi.advanceTimersByTimeAsync(5000);
    });

    await waitFor(() => {
      expect(pruefungAuswerten).toHaveBeenCalledTimes(1);
    });
  });

  it('sagt die Pause an, ohne die Restzeitwarnung zu verdrängen', async () => {
    const pruefungAuswerten = vi.fn(() => Promise.resolve(ERGEBNIS));
    const { schlafen } = await mitSchlafmeldung('30', pruefungAuswerten);

    await act(async () => {
      schlafen(20 * 60 * 1000);
      await vi.advanceTimersByTimeAsync(2000);
    });

    await waitFor(() => {
      expect(ansagebereich()).toHaveTextContent(/Der Rechner war im Energiesparmodus/u);
    });
    expect(ansagebereich()).toHaveTextContent(/20 Minuten/u);
  });
});

// ─── Offene Fragen ──────────────────────────────────────────────────────

describe('Prüfungsansicht – offene Fragen', () => {
  it('zeigt die Musterantwort erst nach der Abgabe und übernimmt die Selbstbewertung', async () => {
    const pruefungAuswerten = vi.fn(() => Promise.resolve(ERGEBNIS));
    vi.stubGlobal(
      'lernApp',
      attrappe({
        pruefungStarten: vi.fn(() =>
          Promise.resolve({ fragen: [bogeneintrag(FRAGE_FREITEXT)], warnungen: [] }),
        ),
        pruefungAuswerten,
      }),
    );
    const nutzer = userEvent.setup();
    render(<App />);

    const start = await screen.findByRole('button', { name: /Prüfungssimulation vorbereiten/u });
    /* Der Knopf ist nie `disabled`, sondern meldet sich über `aria-disabled`
       (Projektregel, docs/stand.md 7.13) – `toBeEnabled()` wäre hier immer
       wahr und liefe dem Katalog nicht mehr hinterher. */
    await waitFor(() => {
      expect(start).toHaveAttribute('aria-disabled', 'false');
    });
    await nutzer.click(start);
    await nutzer.click(await screen.findByRole('button', { name: 'Simulation starten' }));
    await screen.findByRole('heading', { name: /Frage 1\.01, Frage \d+ von \d+$/u, level: 2 });

    await nutzer.type(
      screen.getByRole('textbox', { name: 'Ihre Antwort' }),
      'Den Umgang mit Waffen.',
    );
    expect(screen.queryByRole('heading', { name: 'Musterantwort' })).not.toBeInTheDocument();

    await nutzer.click(screen.getByRole('button', { name: 'Simulation abgeben' }));
    await nutzer.click(screen.getByRole('button', { name: 'Jetzt abgeben und auswerten' }));

    expect(
      await screen.findByRole('heading', { name: 'Offene Fragen selbst bewerten' }),
    ).toHaveFocus();
    expect(screen.getByRole('heading', { name: 'Musterantwort', level: 3 })).toBeInTheDocument();
    expect(pruefungAuswerten).not.toHaveBeenCalled();

    await nutzer.click(screen.getByRole('radio', { name: 'Als richtig werten' }));
    await nutzer.click(screen.getByRole('button', { name: 'Auswertung anzeigen' }));

    await screen.findByRole('heading', { name: 'Auswertung der Prüfungssimulation' });
    expect(pruefungAuswerten).toHaveBeenCalledWith(
      1,
      expect.anything(),
      [
        {
          frageId: 'I.1-01',
          auswahl: [],
          freitext: 'Den Umgang mit Waffen.',
          selbstAlsRichtig: true,
        },
      ],
      expect.any(Number),
      false,
    );
  });
});

// ─── Auswertung ─────────────────────────────────────────────────────────

describe('Prüfungsansicht – Abbruch ohne Wertung', () => {
  /*
    Wer die Simulation nur ausprobiert, muss sie verlassen können, ohne einen
    Knopf zu drücken, der „abgeben“ heißt. Der Ausstieg lag zunächst hinter
    der Abgabe verborgen; diese Tests halten fest, dass er unmittelbar
    erreichbar bleibt und nichts auswertet.
  */
  it('bietet den Abbruch unmittelbar in der Bedienleiste an', async () => {
    await pruefungOeffnen();

    expect(screen.getByRole('button', { name: 'Simulation abbrechen' })).toBeInTheDocument();
  });

  it('fragt beim Abbruch mit passendem Wortlaut zurück und wertet nichts aus', async () => {
    const pruefungAuswerten = vi.fn(() => Promise.resolve(ERGEBNIS));
    const nutzer = await pruefungOeffnen(attrappe({ pruefungAuswerten }));

    await nutzer.click(screen.getByRole('checkbox', { name: /Spielzeugpistole/u }));
    await nutzer.click(screen.getByRole('button', { name: 'Simulation abbrechen' }));

    // Der Titel benennt den Abbruch, nicht die Abgabe.
    const rueckfrage = screen.getByRole('group', { name: 'Simulation wirklich abbrechen?' });
    expect(rueckfrage).toHaveFocus();
    expect(pruefungAuswerten).not.toHaveBeenCalled();

    await nutzer.click(screen.getByRole('button', { name: 'Abbrechen und verwerfen' }));

    // Zurück am Start, ohne Auswertung.
    await screen.findByRole('heading', { name: 'Heute lernen' });
    expect(pruefungAuswerten).not.toHaveBeenCalled();
    expect(
      screen.queryByRole('heading', { name: 'Auswertung der Prüfungssimulation' }),
    ).not.toBeInTheDocument();
  });

  it('lässt aus der Abbruch-Rückfrage zurück in die Prüfung', async () => {
    const nutzer = await pruefungOeffnen();

    await nutzer.click(screen.getByRole('button', { name: 'Simulation abbrechen' }));
    await nutzer.click(screen.getByRole('button', { name: 'Weiter bearbeiten' }));

    expect(
      screen.queryByRole('group', { name: 'Simulation wirklich abbrechen?' }),
    ).not.toBeInTheDocument();
    expect(
      screen.getByRole('progressbar', { name: 'Fortschritt der Simulation' }),
    ).toBeInTheDocument();
    // Auch hier zurück auf den Knopf, der die Rückfrage geöffnet hat.
    expect(screen.getByRole('button', { name: 'Simulation abbrechen' })).toHaveFocus();
  });

  it('nennt in der Fragenüberschrift auch die Position im Bogen', async () => {
    /* Beim Blättern wandert der Fokus auf die Überschrift. Angesagt würde
       sonst nur die amtliche Katalognummer, die nichts darüber sagt, wie weit
       man ist. Der sichtbare Text bleibt Bestandteil des zugänglichen Namens
       (WCAG 2.5.3). */
    const nutzer = await pruefungOeffnen();

    const erste = await screen.findByRole('heading', {
      name: /Frage 1\.87, Frage 1 von 3$/u,
      level: 2,
    });
    expect(erste).toHaveTextContent('Frage 1.87');

    await nutzer.click(screen.getByRole('button', { name: 'Nächste Frage' }));

    const zweite = await screen.findByRole('heading', {
      name: /Frage 2\.10, Frage 2 von 3$/u,
      level: 2,
    });
    expect(zweite).toHaveFocus();
  });

  it('fängt den Fokus in der Auswertungsphase auf', async () => {
    /* Zwischen Abgabe und Auswertung verschwindet die auslösende
       Schaltfläche. Ohne Anker fiele der Fokus auf `body` – bei Zeitablauf,
       wo gar kein Klick vorausging, ebenso. */
    const nutzer = await pruefungOeffnen(
      attrappe({
        pruefungAuswerten: vi.fn(
          () => new Promise<never>(() => undefined), // bleibt in der Wartephase
        ),
      }),
    );

    await nutzer.click(screen.getByRole('button', { name: 'Simulation abgeben' }));
    await nutzer.click(screen.getByRole('button', { name: 'Jetzt abgeben und auswerten' }));

    const warten = await screen.findByText('Ihre Simulation wird ausgewertet …');
    expect(warten).toHaveFocus();
  });
});

describe('Auswertung', () => {
  async function auswertungOeffnen(bruecke: LueckenhafteBruecke = attrappe()): Promise<Nutzer> {
    const nutzer = await pruefungOeffnen(bruecke);
    await nutzer.click(screen.getByRole('checkbox', { name: /Spielzeugpistole/u }));
    await nutzer.click(screen.getByRole('button', { name: 'Simulation abgeben' }));
    await nutzer.click(screen.getByRole('button', { name: 'Jetzt abgeben und auswerten' }));
    await screen.findByRole('heading', { name: 'Auswertung der Prüfungssimulation' });
    return nutzer;
  }

  it('zeigt Urteil, Begründung, Trefferquote und Themenanalyse', async () => {
    await auswertungOeffnen();

    expect(screen.getByText('Mündliche Nachprüfung')).toBeInTheDocument();
    expect(
      screen.getByText('67 Prozent richtig. Bestanden wäre ab 80 Prozent.'),
    ).toBeInTheDocument();
    expect(
      screen.getByText('2 von 3 Fragen richtig – Trefferquote 67 Prozent.'),
    ).toBeInTheDocument();
    expect(
      screen.getByText('Notwehr und Notstand: 3 Fehler bei höchstens 2 zulässigen'),
    ).toBeInTheDocument();

    const analyse = screen.getByRole('list', { name: 'Ergebnis nach Themenbereichen' });
    expect(within(analyse).getAllByRole('listitem')).toHaveLength(2);
    expect(
      within(analyse).getByRole('progressbar', { name: 'Ergebnis in Rechte und Pflichten' }),
    ).toHaveAttribute('aria-valuetext', '1 von 2 Fragen richtig');
  });

  it('nennt den Vorbehalt, dass der Prüfungsausschuss entscheidet', async () => {
    await auswertungOeffnen();

    expect(screen.getByText(/allein der zuständige Prüfungsausschuss/u)).toBeInTheDocument();
  });

  it('führt frühere Läufe auf', async () => {
    await auswertungOeffnen();

    // Nach dem Lauf wird der Lernstand neu geladen; der Verlauf folgt deshalb
    // erst, wenn das Lernprofil wieder feststeht.
    const tabelle = await screen.findByRole('table', {
      name: 'Ihre bisherigen Simulationen, neueste zuerst',
    });
    expect(within(tabelle).getByText('70 von 80')).toBeInTheDocument();
    expect(within(tabelle).getByText('88 Prozent')).toBeInTheDocument();
    expect(within(tabelle).getByText(/Bestanden/u)).toBeInTheDocument();
  });

  it('nennt zu jedem Lauf die Bearbeitungsdauer', async () => {
    /* Die Zahl wird seit Schema-Version 2 gespeichert, stand aber bis 0.22.0
       nicht in der Tabelle. Zeitnot ist ein eigenes Durchfallrisiko. */
    await auswertungOeffnen();

    const tabelle = await screen.findByRole('table', {
      name: 'Ihre bisherigen Simulationen, neueste zuerst',
    });

    expect(within(tabelle).getByRole('columnheader', { name: 'Dauer' })).toBeInTheDocument();
    expect(within(tabelle).getByText('71 Minuten 0 Sekunden')).toBeInTheDocument();
  });

  it('ordnet den Lauf gegenüber dem letzten vergleichbaren ein', async () => {
    await auswertungOeffnen();

    const satz = await screen.findByText(
      /Gegenüber Ihrer letzten Simulation mit demselben Profil und derselben Zeitvorgabe/u,
    );

    expect(satz).toHaveTextContent('21 Prozentpunkte schlechter');
    expect(satz).toHaveTextContent('69 Minuten schneller');
  });

  it('sagt beim Vergleich dazu, dass die Bogengrößen auseinandergingen', async () => {
    await auswertungOeffnen();

    expect(
      await screen.findByText('Der Bogen hatte damals 80 Fragen, dieser 3 Fragen.'),
    ).toBeInTheDocument();
  });

  it('vergleicht nicht mit einem Lauf anderer Zeitstufe', async () => {
    const nurAndereStufe = attrappe({
      pruefungVerlauf: vi.fn(() =>
        Promise.resolve([{ ...VERLAUF[0]!, id: 42, zeitmodus: 'plus50' as const }]),
      ),
    });
    await auswertungOeffnen(nurAndereStufe);
    await screen.findByRole('table', { name: 'Ihre bisherigen Simulationen, neueste zuerst' });

    // Ein Bogen ohne Uhr und einer unter Zeitdruck sind zwei verschiedene
    // Aufgaben; sie zu vergleichen verbuchte den Nachteilsausgleich als
    // Fortschritt. Lieber gar nichts sagen.
    expect(screen.queryByText(/Gegenüber Ihrer letzten Simulation/u)).not.toBeInTheDocument();
  });

  it('benennt Bereiche, die mehrfach unter der Grenze lagen', async () => {
    const mehrereLaeufe = attrappe({
      pruefungVerlauf: vi.fn(() =>
        Promise.resolve([
          { ...VERLAUF[0]!, id: 42 },
          {
            ...VERLAUF[0]!,
            id: 41,
            zeitpunkt: '2026-01-10T09:30:00.000Z',
            bereiche: [
              { bereich: 'I.1', titel: 'Begriffe des Waffenrechts', gesamt: 10, richtig: 8 },
              { bereich: 'I.2', titel: 'Rechte und Pflichten', gesamt: 10, richtig: 3 },
            ],
          },
        ]),
      ),
    });
    await auswertungOeffnen(mehrereLaeufe);

    const liste = await screen.findByRole('list', { name: 'Wiederkehrende Lücken' });
    const eintraege = within(liste).getAllByRole('listitem');

    // I.2 lag zweimal unter 80 Prozent, I.1 nur einmal – ein schlechter Tag
    // ist keine Lücke.
    expect(eintraege).toHaveLength(1);
    expect(eintraege[0]).toHaveTextContent('I.2 – Rechte und Pflichten');
    expect(eintraege[0]).toHaveTextContent('in 2 von 2 Simulationen unter der Grenze');
    expect(eintraege[0]).toHaveTextContent('zusammen 7 von 20 Fragen richtig');
  });

  it('schweigt über wiederkehrende Lücken, solange es keine gibt', async () => {
    await auswertungOeffnen();
    await screen.findByRole('table', { name: 'Ihre bisherigen Simulationen, neueste zuerst' });

    expect(
      screen.queryByRole('heading', { name: 'Wiederkehrende Lücken' }),
    ).not.toBeInTheDocument();
  });

  it('nennt zu jedem Lauf die Zeitvorgabe', async () => {
    /* Ohne diese Spalte stünden ein Lauf ohne Uhr und einer unter Zeitdruck
       nebeneinander, als wären sie vergleichbar – und die Tabelle lädt
       ausdrücklich zum Vergleich ein. */
    await auswertungOeffnen();

    const tabelle = await screen.findByRole('table', {
      name: 'Ihre bisherigen Simulationen, neueste zuerst',
    });

    expect(within(tabelle).getByRole('columnheader', { name: 'Zeitvorgabe' })).toBeInTheDocument();
    expect(within(tabelle).getByText('Vorgesehene Zeit')).toBeInTheDocument();
    // Läufe von vor Schema-Version 4 tragen keine – geraten wird nicht.
    expect(within(tabelle).getByText('nicht vermerkt')).toBeInTheDocument();
  });

  it('hält die Begründung zu jeder falschen Frage bereit, zugeklappt', async () => {
    /* Nach einem vollen Durchgang stehen hier bis zu 80 Fragen. Die
       Begründung darf die Liste deshalb nicht aufblähen – sie ist einen
       Klick entfernt, nicht sofort sichtbar. */
    const nutzer = await auswertungOeffnen();

    const liste = screen.getByRole('list', { name: 'Nicht richtig beantwortete Fragen' });
    const schalter = within(liste).getByText('Warum war das falsch?');

    /* Der Bezug zur Frage muss im Schalter stehen: „Warum war das falsch?"
       allein waere in einer Liste mit 80 Eintraegen nicht unterscheidbar. */
    expect(schalter.parentElement).toHaveTextContent('Frage 2.10');

    const aufklapper = schalter.closest('details');
    expect(aufklapper).not.toBeNull();
    expect(aufklapper).not.toHaveAttribute('open');

    await nutzer.click(schalter);

    expect(aufklapper).toHaveAttribute('open');
    expect(
      within(aufklapper!).getByRole('heading', { name: 'Warum das so ist' }),
    ).toBeInTheDocument();
    expect(within(aufklapper!).getByText(/binnen zwei Wochen/u)).toBeInTheDocument();
    expect(within(aufklapper!).getByText('§ 10 Abs. 1 WaffG')).toBeInTheDocument();
  });

  it('übernimmt die falsch beantworteten Fragen in eine Lernsitzung', async () => {
    const nutzer = await auswertungOeffnen();

    const liste = screen.getByRole('list', { name: 'Nicht richtig beantwortete Fragen' });
    const [eintrag] = within(liste).getAllByRole('listitem');
    expect(eintrag).toHaveTextContent('Frage 2.10');

    await nutzer.click(screen.getByRole('button', { name: 'Fehler wiederholen (1)' }));

    expect(
      await screen.findByRole('heading', { name: /Frage 2\.10/u, level: 1 }),
    ).toBeInTheDocument();
    expect(screen.getByText('Frage 1 von 1')).toBeInTheDocument();
  });
});

// ─── Ausfall des Anwendungskerns ────────────────────────────────────────

describe('Prüfung – Ausfall des Anwendungskerns', () => {
  it('stellt den Bogen ersatzweise aus dem Katalog zusammen und sagt das', async () => {
    const nutzer = await auswahlOeffnen(ohneKanal('pruefungStarten'));
    await nutzer.click(screen.getByRole('button', { name: 'Simulation starten' }));

    expect(
      await screen.findByText(/ersatzweise aus dem Fragenkatalog zusammengestellt/u),
    ).toBeInTheDocument();
    expect(screen.getAllByRole('heading', { level: 3 }).length).toBeGreaterThan(0);
  });

  it('rechnet das Ergebnis selbst aus, wenn der Auswertungskanal fehlt', async () => {
    const nutzer = await pruefungOeffnen(ohneKanal('pruefungAuswerten'));

    await nutzer.click(screen.getByRole('checkbox', { name: /Spielzeugpistole/u }));
    await nutzer.click(screen.getByRole('button', { name: 'Simulation abgeben' }));
    await nutzer.click(screen.getByRole('button', { name: 'Jetzt abgeben und auswerten' }));

    await screen.findByRole('heading', { name: 'Auswertung der Prüfungssimulation' });

    /* Gezielt im Urteilsblock suchen: In der Verlaufstabelle weiter unten
       steht dasselbe Urteil noch einmal.
       Trefferzahlen statt gerundeter Prozente – 80 Prozent von 3 sind 3. */
    const begruendung = screen.getByText('1 von 3 richtig, nötig waren 3.');
    const urteilBlock = begruendung.closest<HTMLElement>('.urteil');
    expect(urteilBlock).not.toBeNull();
    expect(within(urteilBlock!).getByText('Nicht bestanden')).toBeInTheDocument();

    expect(
      screen.getByText('1 von 3 Fragen richtig – Trefferquote 33 Prozent.'),
    ).toBeInTheDocument();
    expect(screen.getByText(/in der Oberfläche berechnet/u)).toBeInTheDocument();
  });
});

// ─── Barrierefreiheit ───────────────────────────────────────────────────

describe('Prüfung – Barrierefreiheits-Rauchtest (axe-core)', () => {
  it('meldet in der Prüfungsansicht keine Verstöße gegen WCAG 2.1 A/AA', async () => {
    const nutzer = await pruefungOeffnen();
    await nutzer.click(screen.getByRole('checkbox', { name: /Spielzeugpistole/u }));
    await nutzer.click(screen.getByRole('button', { name: 'Simulation abgeben' }));

    const ergebnis = await axe.run(document.body, jsdomOptionen());

    expect(ergebnis.violations.map((verstoss) => `${verstoss.id}: ${verstoss.help}`)).toEqual([]);
  });

  it('meldet in der Auswahlansicht keine Verstöße gegen WCAG 2.1 A/AA', async () => {
    const nutzer = await auswahlOeffnen();
    await nutzer.click(screen.getByRole('radio', { name: /Selbst einstellen/u }));

    const ergebnis = await axe.run(document.body, jsdomOptionen());

    expect(ergebnis.violations.map((verstoss) => `${verstoss.id}: ${verstoss.help}`)).toEqual([]);
  });

  it('meldet in der Auswertung keine Verstöße gegen WCAG 2.1 A/AA', async () => {
    /* Die längste Ansicht der Simulation und bis 0.22.0 die einzige ohne
       eigenen Rauchtest: Urteil, Zahlen, Vergleich, Themenanalyse,
       wiederkehrende Lücken, Fehlerliste und Verlaufstabelle auf einer Seite.
       Der Verlauf ist mit Absicht so gewählt, dass alle Blöcke dastehen. */
    const mitAllenBloecken = attrappe({
      pruefungVerlauf: vi.fn(() =>
        Promise.resolve([
          { ...VERLAUF[0]!, id: 42 },
          {
            ...VERLAUF[0]!,
            id: 41,
            zeitpunkt: '2026-01-10T09:30:00.000Z',
            unbeantwortet: 4,
            zeitAbgelaufen: true,
            bereiche: [
              { bereich: 'I.1', titel: 'Begriffe des Waffenrechts', gesamt: 10, richtig: 8 },
              { bereich: 'I.2', titel: 'Rechte und Pflichten', gesamt: 10, richtig: 3 },
            ],
          },
        ]),
      ),
    });
    const nutzer = await pruefungOeffnen(mitAllenBloecken);
    await nutzer.click(screen.getByRole('checkbox', { name: /Spielzeugpistole/u }));
    await nutzer.click(screen.getByRole('button', { name: 'Simulation abgeben' }));
    await nutzer.click(screen.getByRole('button', { name: 'Jetzt abgeben und auswerten' }));
    await screen.findByRole('heading', { name: 'Auswertung der Prüfungssimulation' });
    await screen.findByRole('list', { name: 'Wiederkehrende Lücken' });

    const ergebnis = await axe.run(document.body, jsdomOptionen());

    expect(ergebnis.violations.map((verstoss) => `${verstoss.id}: ${verstoss.help}`)).toEqual([]);
  });
});

// ─── Unterbrochene Simulation ───────────────────────────────────────────────

describe('Unterbrochene Simulation', () => {
  /** Ein unterbrochener Lauf über den Standardbogen. */
  function offenerLauf(teil: Partial<OffenerLauf> = {}): OffenerLauf {
    return {
      auftrag: { profilId: 'standard', zeitmodus: 'normal' },
      profil: profilFinden('standard')!,
      bogen: STANDARDFRAGEN,
      eingaben: { [FRAGE_EINFACH.id]: { auswahl: ['a'], freitext: '' } },
      position: 1,
      phase: 'bearbeiten',
      zeitAbgelaufen: false,
      verbrauchtMs: 30 * 60_000,
      begonnenAm: '2026-08-20T14:12:00.000Z',
      gesichertAm: '2026-08-20T14:42:00.000Z',
      ...teil,
    };
  }

  function mitLauf(lauf: OffenerLauf | null = offenerLauf()): LueckenhafteBruecke {
    return attrappe({ pruefungOffen: vi.fn(() => Promise.resolve(lauf)) });
  }

  async function startbildschirm(bruecke: LueckenhafteBruecke): Promise<Nutzer> {
    vi.stubGlobal('lernApp', bruecke);
    const nutzer = userEvent.setup();
    render(<App />);
    await screen.findByRole('heading', { name: 'Heute lernen' });
    return nutzer;
  }

  it('zeigt keine Karte, wenn nichts offensteht', async () => {
    await startbildschirm(mitLauf(null));

    expect(
      screen.queryByRole('heading', { name: 'Unterbrochene Simulation' }),
    ).not.toBeInTheDocument();
  });

  it('nennt Profil, Stand, Restzeit und Zeitpunkt', async () => {
    await startbildschirm(mitLauf());

    await screen.findByRole('heading', { name: 'Unterbrochene Simulation' });
    // 120 Minuten Vorgabe, 30 verbraucht.
    expect(screen.getByText('1 Stunde 30 Minuten')).toBeInTheDocument();
    expect(screen.getByText(/Frage 2 von 3, 1 beantwortet/u)).toBeInTheDocument();
    expect(screen.getByText(/Die Uhr stand still/u)).toBeInTheDocument();
  });

  it('warnt an der Prüfungskarte, dass ein neuer Start den Bogen verwirft', async () => {
    await startbildschirm(mitLauf());

    expect(await screen.findByText(/Eine neue zu starten verwirft sie/u)).toBeInTheDocument();
  });

  it('startet die Uhr nicht schon beim Klick auf „Fortsetzen“', async () => {
    /* Die Karte drängt sich zwischen die täglichen Schaltflächen; ein
       Fehlgriff darf nichts kosten. */
    const nutzer = await startbildschirm(mitLauf());
    await screen.findByRole('heading', { name: 'Unterbrochene Simulation' });

    await nutzer.click(screen.getByRole('button', { name: 'Simulation fortsetzen' }));

    expect(
      screen.getByRole('heading', { name: 'Simulation jetzt fortsetzen?' }),
    ).toBeInTheDocument();
    // Der Lauf ist noch nicht da.
    expect(screen.queryByRole('heading', { name: /Frage \d+ von 3$/u })).not.toBeInTheDocument();
  });

  it('setzt bei der gespeicherten Frage mit den gespeicherten Eingaben fort', async () => {
    const nutzer = await startbildschirm(mitLauf());
    await screen.findByRole('heading', { name: 'Unterbrochene Simulation' });

    await nutzer.click(screen.getByRole('button', { name: 'Simulation fortsetzen' }));
    await nutzer.click(screen.getByRole('button', { name: 'Jetzt fortsetzen' }));

    // position 1 heißt: die zweite Frage des Bogens.
    await screen.findByRole('heading', { name: /Frage 2 von 3$/u, level: 2 });

    // Die Antwort auf Frage 1 steht noch.
    await nutzer.click(screen.getByRole('button', { name: 'Vorherige Frage' }));
    await screen.findByRole('heading', { name: /Frage 1 von 3$/u, level: 2 });
    /* Im Pruefungsmodus sind alle Auswahlfragen Kontrollkaestchen - die
       Zahl der richtigen Antworten verraet die Simulation nicht. */
    expect(screen.getByRole('checkbox', { name: /Sportgewehr/u })).toBeChecked();
  });

  it('rechnet die verbrauchte Zeit auf die Restzeit an', async () => {
    const nutzer = await startbildschirm(mitLauf());
    await screen.findByRole('heading', { name: 'Unterbrochene Simulation' });

    await nutzer.click(screen.getByRole('button', { name: 'Simulation fortsetzen' }));
    await nutzer.click(screen.getByRole('button', { name: 'Jetzt fortsetzen' }));
    await screen.findByRole('heading', { name: /Frage 2 von 3$/u, level: 2 });

    /* Ohne Rückdatierung des Zeitursprungs stünden hier zwei Stunden. */
    expect(screen.getByText(/1 Stunde 30 Minuten/u)).toBeInTheDocument();
  });

  it('meldet dem Anwendungskern, dass geprüft wird', async () => {
    const laeuft = vi.fn(() => Promise.resolve());
    await pruefungOeffnen(attrappe({ pruefungLaeuft: laeuft }));

    await waitFor(() => {
      expect(laeuft).toHaveBeenCalledWith(true);
    });
  });

  it('verwirft den Bogen erst nach einer Rückfrage', async () => {
    const verwerfen = vi.fn(() => Promise.resolve());
    const nutzer = await startbildschirm(
      attrappe({
        pruefungOffen: vi.fn(() => Promise.resolve(offenerLauf())),
        pruefungVerwerfen: verwerfen,
      }),
    );
    await screen.findByRole('heading', { name: 'Unterbrochene Simulation' });

    await nutzer.click(screen.getByRole('button', { name: 'Simulation verwerfen' }));
    expect(
      screen.getByRole('heading', { name: 'Unterbrochene Simulation verwerfen?' }),
    ).toBeInTheDocument();
    expect(verwerfen).not.toHaveBeenCalled();

    await nutzer.click(screen.getByRole('button', { name: 'Nein, Simulation behalten' }));
    expect(verwerfen).not.toHaveBeenCalled();

    await nutzer.click(screen.getByRole('button', { name: 'Simulation verwerfen' }));
    await nutzer.click(screen.getByRole('button', { name: 'Ja, Simulation verwerfen' }));

    await waitFor(() => {
      expect(verwerfen).toHaveBeenCalledWith(1);
    });
    await waitFor(() => {
      expect(
        screen.queryByRole('heading', { name: 'Unterbrochene Simulation' }),
      ).not.toBeInTheDocument();
    });
  });

  it('gibt den Fokus nach dem Abbrechen der Rückfrage zurück', async () => {
    const nutzer = await startbildschirm(mitLauf());
    await screen.findByRole('heading', { name: 'Unterbrochene Simulation' });

    await nutzer.click(screen.getByRole('button', { name: 'Simulation verwerfen' }));
    await nutzer.click(screen.getByRole('button', { name: 'Nein, Simulation behalten' }));

    await waitFor(() => {
      expect(screen.getByRole('button', { name: 'Simulation verwerfen' })).toHaveFocus();
    });
  });

  it('sagt bei einem Lauf ohne Uhr nichts von Restzeit', async () => {
    await startbildschirm(
      mitLauf(offenerLauf({ auftrag: { profilId: 'standard', zeitmodus: 'aus' } })),
    );

    await screen.findByRole('heading', { name: 'Unterbrochene Simulation' });
    expect(screen.getByText('Ohne Zeitbegrenzung')).toBeInTheDocument();
    expect(screen.queryByText(/Die Uhr stand still/u)).not.toBeInTheDocument();
  });

  it('ist frei von automatisch prüfbaren Verstößen', async () => {
    await startbildschirm(mitLauf());
    await screen.findByRole('heading', { name: 'Unterbrochene Simulation' });

    const ergebnis = await axe.run(document.body, jsdomOptionen());
    expect(ergebnis.violations.map((verstoss) => verstoss.id)).toEqual([]);
  });
});

describe('Merken im Prüfungslauf', () => {
  /*
    Die Lücke, die das schließt.

    `merken()` ist im Kern seit jeher gebaut und geprüft – im Bogen fehlte
    allein der Weg dorthin. Wer im Lauf geraten hat oder unsicher war, konnte
    das nirgends festhalten: Nach der Abgabe zählt nur richtig oder falsch,
    und eine glücklich getroffene Frage hinterließ keine Spur.
  */
  it('bietet einen Merkknopf an, der zu Anfang nicht gedrückt ist', async () => {
    await pruefungOeffnen();

    const knopf = screen.getByRole('button', { name: /Frage merken/u });
    expect(knopf).toHaveAttribute('aria-pressed', 'false');
  });

  it('meldet den Merkwunsch an den Lernstand und zeigt ihn am Knopf', async () => {
    const lernMerken = vi.fn(() => Promise.resolve(STAND));
    const nutzer = await pruefungOeffnen({ ...attrappe(), lernMerken });

    await nutzer.click(screen.getByRole('button', { name: /Frage merken/u }));

    expect(lernMerken).toHaveBeenCalledWith(PROFIL.id, 'I.1-87', true);
    expect(screen.getByRole('button', { name: /Frage merken/u })).toHaveAttribute(
      'aria-pressed',
      'true',
    );
  });

  it('sagt das Merken an – sonst bliebe es Sehenden vorbehalten', async () => {
    const nutzer = await pruefungOeffnen();

    await nutzer.click(screen.getByRole('button', { name: /Frage merken/u }));

    expect(await screen.findByText('Frage gemerkt.')).toBeInTheDocument();
  });

  it('nimmt das Merkzeichen zurück, wenn es sich nicht speichern ließ', async () => {
    /* Ein Merkzeichen, das nach dem Lauf fehlt, wäre schlimmer als eines,
       das gar nicht erst erscheint. */
    const lernMerken = vi.fn(() => Promise.reject(new Error('kein Lernstand')));
    const nutzer = await pruefungOeffnen({ ...attrappe(), lernMerken });

    await nutzer.click(screen.getByRole('button', { name: /Frage merken/u }));

    expect(await screen.findByText('Das Merken ließ sich nicht speichern.')).toBeInTheDocument();
    expect(screen.getByRole('button', { name: /Frage merken/u })).toHaveAttribute(
      'aria-pressed',
      'false',
    );
  });

  it('holt den vorhandenen Merkstand nach, sobald die Frage erscheint', async () => {
    /* Anders als die Lernsitzung bringt der Bogen den Merkzustand nicht mit:
       Er entsteht im Renderer aus dem Katalog und kennt den Lernstand nicht. */
    const lernFragestand = vi.fn(() => Promise.resolve({ ...STAND, gemerkt: true }));
    await pruefungOeffnen({ ...attrappe(), lernFragestand });

    expect(lernFragestand).toHaveBeenCalledWith(PROFIL.id, 'I.1-87');
    expect(await screen.findByRole('button', { name: /Frage merken/u })).toHaveAttribute(
      'aria-pressed',
      'true',
    );
  });
});

describe('Restzeit auf Knopfdruck ansagen', () => {
  /*
    Die Lücke, die das schließt.

    Die Anzeige ist bewusst ruhig und ohne Live-Region – ein sekündlich
    sprechender Zähler machte die Simulation unbenutzbar. Die Warnung bei
    fünf Minuten wird angesagt, aber dazwischen gab es keinen Weg, die
    Restzeit zu erfahren, ohne sie zu **sehen**.
  */
  it('sagt die Restzeit auf die Sekunde genau an', async () => {
    const nutzer = await pruefungOeffnen();

    await nutzer.click(screen.getByRole('button', { name: 'Restzeit ansagen' }));

    const ansage = await screen.findByText(/^Restzeit: /u);
    expect(ansage).toHaveAttribute('role', 'status');
    expect(ansage.textContent).toMatch(/^Restzeit: .*(Minute|Sekunde)/u);
  });

  it('nennt Sekunden, damit zwei Fragen in derselben Minute verschieden lauten', () => {
    /* Ohne Sekunden stünde in der Live-Region zweimal dasselbe – und beim
       zweiten Mal bliebe sie stumm, weil sich nichts geändert hat. */
    expect(restzeitGenau(12 * 60_000 + 30_000)).toBe('12 Minuten und 30 Sekunden');
    expect(restzeitGenau(12 * 60_000 + 5_000)).toBe('12 Minuten und 5 Sekunden');
    expect(restzeitGenau(60_000)).toBe('1 Minute');
    expect(restzeitGenau(45_000)).toBe('45 Sekunden');
    expect(restzeitGenau(0)).toBe('0 Sekunden');
  });

  it('bietet den Knopf nicht an, wo keine Uhr läuft', async () => {
    /* Ohne Zeitbegrenzung gibt es nichts anzusagen – dort steht statt der
       Uhr der Satz, dass keine mitläuft. */
    const nutzer = await auswahlOeffnen();
    await nutzer.click(screen.getByRole('radio', { name: 'Ohne Zeitbegrenzung' }));

    expect(
      screen.getByText('Es läuft keine Uhr mit. Sie geben ab, wann Sie möchten.'),
    ).toBeInTheDocument();
    expect(screen.queryByRole('button', { name: 'Restzeit ansagen' })).not.toBeInTheDocument();
  });
});
