// @vitest-environment node
// Die Prüfungssimulation läuft im Main-Prozess gegen echtes SQLite. Getestet
// wird gegen eine `:memory:`-Datenbank – dieselbe Bibliothek, dasselbe Schema,
// nur ohne Datei auf der Platte. Es wird nichts nachgebildet: gezogen wird aus
// einem echten Katalog, ausgewertet mit den echten Regeln des Vertrags.

import Database from 'better-sqlite3';
import { afterEach, beforeEach, describe, expect, it } from 'vitest';

import { Lernstand } from '../src/main/lernstand';
import { Pruefung } from '../src/main/pruefung';
import { SCHEMA_VERSION } from '../src/main/schema';
import {
  richtigeLabels,
  type Antwortoption,
  type Frage,
  type Katalog,
} from '../src/shared/katalog';
import {
  profilFinden,
  urteilBilden,
  type Pruefungsantwort,
  type Pruefungsauftrag,
  type Pruefungsfrage,
} from '../src/shared/pruefung';

// ─── Prüfkatalog ────────────────────────────────────────────────────────────
// Aufbau wie im amtlichen Katalog: Kapitel I mit fünf Abschnitten, die
// Kapitel II bis IV ohne Abschnitte. Groß genug für die Themenquoten des
// DSB-Profils (bis zu 20 Fragen je Bereich, davon ein Fünftel offen).

/** Bereiche als Paar aus Kapitel und Abschnitt; `null` heißt „ohne Abschnitt“. */
const BEREICHE: readonly (readonly [string, string | null])[] = [
  ['I', 'I.1'],
  ['I', 'I.2'],
  ['I', 'I.3'],
  ['I', 'I.4'],
  ['I', 'I.5'],
  ['II', null],
  ['III', null],
  ['IV', null],
];

const MC_JE_BEREICH = 30;
const OFFEN_JE_BEREICH = 8;

function option(label: string, korrekt: boolean): Antwortoption {
  return {
    label,
    inhalt: { text: `Antwort ${label}`, segmente: [{ t: `Antwort ${label}` }] },
    korrekt,
    bilder: [],
  };
}

function mcFrage(id: string, kapitel: string, abschnitt: string | null, nummer: number): Frage {
  // Jede dritte Frage hat zwei richtige Antworten – im amtlichen Katalog sind
  // es 114 von 471, die Mehrfachauswahl darf also kein Sonderfall sein.
  const korrekt = nummer % 3 === 0 ? ['a', 'c'] : ['b'];
  return {
    id,
    amtliche_nummer: id,
    kapitel,
    abschnitt,
    typ: 'mc',
    seite: 1,
    frage: { text: `Frage ${id}`, segmente: [{ t: `Frage ${id}` }] },
    bilder: [],
    optionen: ['a', 'b', 'c', 'd'].map((label) => option(label, korrekt.includes(label))),
  };
}

function offeneFrage(id: string, kapitel: string, abschnitt: string | null): Frage {
  return {
    id,
    amtliche_nummer: id,
    kapitel,
    abschnitt,
    typ: 'freitext',
    seite: 1,
    frage: { text: `Frage ${id}`, segmente: [{ t: `Frage ${id}` }] },
    bilder: [],
    musterantwort: { text: 'Musterantwort', segmente: [{ t: 'Musterantwort', h: true }] },
  };
}

function katalogBauen(mcJeBereich: number, offenJeBereich: number): Katalog {
  const fragen: Frage[] = [];
  for (const [kapitel, abschnitt] of BEREICHE) {
    const bereich = abschnitt ?? kapitel;
    for (let i = 1; i <= mcJeBereich; i += 1) {
      fragen.push(mcFrage(`${bereich}-mc-${String(i).padStart(2, '0')}`, kapitel, abschnitt, i));
    }
    for (let i = 1; i <= offenJeBereich; i += 1) {
      fragen.push(
        offeneFrage(`${bereich}-offen-${String(i).padStart(2, '0')}`, kapitel, abschnitt),
      );
    }
  }

  return {
    meta: {
      titel: 'Prüfkatalog',
      herausgeber: 'Bundesverwaltungsamt',
      stand: '2024-12-16',
      quellenangabe: 'Amtlicher Fragenkatalog, Stand 16.12.2024.',
      quelle_url: 'https://www.bva.bund.de/',
      quelldatei_sha256: 'a'.repeat(64),
      fragen_gesamt: fragen.length,
    },
    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.3', titel: 'Aufbewahrung' },
          { id: 'I.4', titel: 'Führen und Transport' },
          { id: 'I.5', titel: 'Notwehr und Notstand' },
        ],
      },
      { id: 'II', titel: 'Waffentechnik', abschnitte: [] },
      { id: 'III', titel: 'Handhabung von Schusswaffen', abschnitte: [] },
      { id: 'IV', titel: 'Not- und Seenotsignalmittel', abschnitte: [] },
    ],
    bilder: [],
    fragen,
  };
}

const KATALOG = katalogBauen(MC_JE_BEREICH, OFFEN_JE_BEREICH);

/** Bereich einer Frage, so wie die Auswertung ihn bildet. */
function bereichVon(frage: Frage): string {
  return frage.abschnitt ?? frage.kapitel;
}

function frageZu(katalog: Katalog, id: string): Frage {
  const treffer = katalog.fragen.find((f) => f.id === id);
  if (treffer === undefined) {
    throw new Error(`Prüfkatalog kennt die Frage „${id}“ nicht.`);
  }
  return treffer;
}

// ─── Prüfstand ──────────────────────────────────────────────────────────────

const START = '2026-03-01T10:00:00.000Z';

/**
 * Reproduzierbarer Zufall (mulberry32). Die Zusammenstellung soll zufällig
 * sein, ein Test aber trotzdem immer dasselbe Ergebnis liefern.
 */
function zufallsfolge(saat: number): () => number {
  let zustand = saat >>> 0;
  return (): number => {
    zustand = (zustand + 0x6d2b79f5) >>> 0;
    let t = zustand;
    t = Math.imul(t ^ (t >>> 15), t | 1);
    t ^= t + Math.imul(t ^ (t >>> 7), t | 61);
    return ((t ^ (t >>> 14)) >>> 0) / 4294967296;
  };
}

let uhr: Date;
let db: Database.Database;
let lernstand: Lernstand;
let pruefung: Pruefung;
let profilId: number;
let warnungen: string[];
let verbindungen: Database.Database[] = [];

function starten(katalog: Katalog = KATALOG, saat = 7): void {
  uhr = new Date(START);
  db = new Database(':memory:');
  verbindungen.push(db);
  warnungen = [];
  lernstand = new Lernstand(db, katalog, { jetzt: () => uhr });
  profilId = lernstand.profile()[0]!.id;
  pruefung = new Pruefung(lernstand, katalog, {
    jetzt: () => uhr,
    zufall: zufallsfolge(saat),
    warnen: (meldung) => warnungen.push(meldung),
  });
}

function auftrag(teil: Partial<Pruefungsauftrag> & { profilId: string }): Pruefungsauftrag {
  return { zeitmodus: 'aus', ...teil };
}

/**
 * Zieht einen Bogen und gibt nur seinen Fragensatz zurück.
 *
 * `starten` liefert seit dem Warnungsfeld einen {@link Pruefungsbogen}; die
 * Warnungen prüfen die Fälle, die sie betreffen, ausdrücklich über
 * `pruefung.starten(...).warnungen`.
 */
function bogenZiehen(profil: number, auftragWert: Pruefungsauftrag): readonly Pruefungsfrage[] {
  return pruefung.starten(profil, auftragWert).fragen;
}

/** Antwortoption, die bei dieser Frage falsch ist. */
function falscheAuswahl(frage: Frage): string[] {
  const soll = new Set(richtigeLabels(frage));
  const daneben = (frage.optionen ?? []).map((o) => o.label).find((label) => !soll.has(label));
  return daneben === undefined ? [] : [daneben];
}

function antwort(frage: Frage, richtig: boolean): Pruefungsantwort {
  if (frage.typ === 'mc') {
    return { frageId: frage.id, auswahl: richtig ? richtigeLabels(frage) : falscheAuswahl(frage) };
  }
  return {
    frageId: frage.id,
    auswahl: [],
    freitext: 'Sinngemäß richtig formuliert.',
    selbstAlsRichtig: richtig,
  };
}

/** Multiple-Choice-Fragen eines Bereichs in Katalogreihenfolge. */
function mcAus(bereich: string, anzahl: number, ab = 0): Frage[] {
  return KATALOG.fragen
    .filter((f) => f.typ === 'mc' && bereichVon(f) === bereich)
    .slice(ab, ab + anzahl);
}

/** Bogen mit genau dieser Zahl richtiger und falscher Antworten (alle aus I.1). */
function bogen(richtig: number, falsch: number): Pruefungsantwort[] {
  return mcAus('I.1', richtig + falsch).map((frage, i) => antwort(frage, i < richtig));
}

function auswerten(
  profilKennung: string,
  antworten: Pruefungsantwort[],
  dauerMs = 60_000,
): ReturnType<Pruefung['auswerten']> {
  return pruefung.auswerten(
    profilId,
    auftrag({ profilId: profilKennung }),
    antworten,
    dauerMs,
    false,
  );
}

beforeEach(() => {
  verbindungen = [];
  starten();
});

afterEach(() => {
  for (const verbindung of verbindungen) {
    if (verbindung.open) {
      verbindung.close();
    }
  }
});

// ─── Bogen zusammenstellen ──────────────────────────────────────────────────

describe('pruefung:starten', () => {
  it('zieht so viele Fragen, wie das Profil vorsieht', () => {
    expect(bogenZiehen(profilId, auftrag({ profilId: 'standard' }))).toHaveLength(80);
    expect(bogenZiehen(profilId, auftrag({ profilId: 'dsb' }))).toHaveLength(100);
    expect(bogenZiehen(profilId, auftrag({ profilId: 'fehlerpunkte' }))).toHaveLength(75);
    expect(bogenZiehen(profilId, auftrag({ profilId: 'frei' }))).toHaveLength(40);
  });

  it('nimmt keine Frage zweimal in denselben Bogen', () => {
    for (const kennung of ['standard', 'dsb', 'bdmp', 'fehlerpunkte']) {
      const bogenFragen = bogenZiehen(profilId, auftrag({ profilId: kennung }));
      const ids = new Set(bogenFragen.map((f) => f.frageId));

      expect(ids.size).toBe(bogenFragen.length);
    }
  });

  it('mischt die Antwortoptionen auf Wunsch, ohne eine wegzulassen', () => {
    const bogenFragen = bogenZiehen(
      profilId,
      auftrag({ profilId: 'standard', optionenMischen: true }),
    );

    let mindestensEinmalGemischt = false;
    for (const eintrag of bogenFragen) {
      const frage = frageZu(KATALOG, eintrag.frageId);
      const labels = (frage.optionen ?? []).map((o) => o.label);

      expect([...eintrag.optionsReihenfolge].sort()).toEqual([...labels].sort());
      if (eintrag.optionsReihenfolge.join('') !== labels.join('')) {
        mindestensEinmalGemischt = true;
      }
    }

    expect(mindestensEinmalGemischt).toBe(true);
  });

  it('lässt die Antwortoptionen auf Wunsch in Katalogreihenfolge', () => {
    /* Die Einstellung gilt auch in der Simulation: Wer die Antworten über
       ihre Stelle behalten muss, bräuchte sie sonst ausgerechnet hier
       nicht. */
    const bogenFragen = bogenZiehen(
      profilId,
      auftrag({ profilId: 'standard', optionenMischen: false }),
    );

    for (const eintrag of bogenFragen) {
      const frage = frageZu(KATALOG, eintrag.frageId);

      expect(eintrag.optionsReihenfolge).toEqual((frage.optionen ?? []).map((o) => o.label));
    }
  });

  it('zieht ohne ausdrückliches optionenMischen einen Bogen in Katalogreihenfolge', () => {
    /* Der Standard ist „nicht mischen“ – gerade die Simulation soll dem
       gedruckten Bogen nahekommen, und sie zeigt keine Ziffernspalte, die
       eine verdrehte Buchstabenfolge abmildern könnte. Sichert den
       Rückfallwert gegen ein späteres Zurückdrehen ab. */
    const bogenFragen = bogenZiehen(profilId, auftrag({ profilId: 'standard' }));

    for (const eintrag of bogenFragen) {
      const frage = frageZu(KATALOG, eintrag.frageId);

      expect(eintrag.optionsReihenfolge).toEqual((frage.optionen ?? []).map((o) => o.label));
    }
  });

  it('weist ein optionenMischen zurück, das kein Wahrheitswert ist', () => {
    expect(() =>
      pruefung.starten(profilId, auftrag({ profilId: 'standard', optionenMischen: 'ja' as never })),
    ).toThrow(/optionenMischen/u);
  });

  it('stellt ohne anteilOffen ausschließlich Multiple Choice zusammen', () => {
    const bogenFragen = bogenZiehen(profilId, auftrag({ profilId: 'standard' }));
    const typen = new Set(bogenFragen.map((f) => frageZu(KATALOG, f.frageId).typ));

    expect([...typen]).toEqual(['mc']);
  });

  it('beachtet den Anteil offener Fragen', () => {
    const bogenFragen = bogenZiehen(profilId, auftrag({ profilId: 'dsb' }));
    const offene = bogenFragen.filter((f) => frageZu(KATALOG, f.frageId).typ !== 'mc');

    // 20 Prozent von 100 Fragen, verteilt über die Themenquoten.
    expect(offene).toHaveLength(20);
    expect(warnungen).toEqual([]);
  });

  /*
    „Anteil offener Fragen: 0“ heißt null – auch dann, wenn die Auswahlfragen
    nicht reichen.

    Der Katalog hat 471 Auswahlfragen und 104 offene. Bis Fassung 0.24.1 legte
    der Kern bei 500 gewünschten Fragen stillschweigend 29 offene nach,
    obwohl die Prüfungswahl unmittelbar davor zusagt: „Der Bogen besteht damit
    ausschließlich aus Auswahlfragen.“ Nach der Abgabe sprang dann unerwartet
    die Selbstbewertung dazwischen.
  */
  it('legt bei null Prozent offenen Fragen keine offenen nach', () => {
    const bogenFragen = bogenZiehen(
      profilId,
      auftrag({ profilId: 'frei', fragenAnzahl: 500, anteilOffen: 0 }),
    );
    const offene = bogenFragen.filter((f) => frageZu(KATALOG, f.frageId).typ !== 'mc');

    expect(offene).toEqual([]);
    expect(warnungen.join(' ')).toMatch(/fehlt|fehlen/u);
  });

  /*
    Das frei einstellbare Profil trägt selbst keinen `anteilOffen`. Ohne diese
    Kette bliebe der Regler in der Oberfläche wirkungslos: Der Kern bildet das
    wirksame Profil neu und übernahm bis Fassung 0.16.0 nur Fragenzahl, Zeit
    und Bestehensgrenze.
  */
  it('übernimmt den Anteil offener Fragen aus dem Auftrag', () => {
    const bogenFragen = bogenZiehen(
      profilId,
      auftrag({ profilId: 'frei', fragenAnzahl: 20, anteilOffen: 0.25 }),
    );
    const offene = bogenFragen.filter((f) => frageZu(KATALOG, f.frageId).typ !== 'mc');

    expect(bogenFragen).toHaveLength(20);
    expect(offene).toHaveLength(5);
    expect(warnungen).toEqual([]);
  });

  it('füllt mit Auswahlfragen auf, wenn der Wunsch den Vorrat übersteigt', () => {
    /* Der Wunsch ist eine Bitte, keine Zusage – ein Bogen ganz aus offenen
       Fragen ist im echten Katalog nicht zu haben. Zurechtgebogen wird der
       Wert trotzdem nicht; stattdessen wird es gesagt. */
    const bogenFragen = bogenZiehen(
      profilId,
      // 8 Bereiche à 8 offene Fragen sind 64 – ein Bogen von 80 ganz aus
      // offenen Fragen ist damit nicht zu haben.
      auftrag({ profilId: 'frei', fragenAnzahl: 80, anteilOffen: 1 }),
    );
    const offene = bogenFragen.filter((f) => frageZu(KATALOG, f.frageId).typ !== 'mc');

    expect(bogenFragen).toHaveLength(80);
    expect(offene).toHaveLength(64);
    expect(warnungen.some((w) => w.includes('durch 16 Auswahlfragen ersetzt'))).toBe(true);
  });

  /*
    Die Warnungen gehen nicht mehr nur ins Protokoll: Sie hängen am Bogen und
    landen damit auf dem Bildschirm (Befund 7.10). Bis Fassung 0.20.0 hielt
    jeder, der simulierte, seinen Bogen für profilgetreu.
  */
  it('reicht dieselben Warnungen mit dem Bogen heraus', () => {
    const bogen = pruefung.starten(
      profilId,
      auftrag({ profilId: 'frei', fragenAnzahl: 80, anteilOffen: 1 }),
    );

    expect(bogen.fragen).toHaveLength(80);
    expect(bogen.warnungen).toEqual(warnungen);
    expect(bogen.warnungen.some((w) => w.includes('durch 16 Auswahlfragen ersetzt'))).toBe(true);
  });

  it('gibt einen unauffälligen Bogen ohne Warnungen heraus', () => {
    const bogen = pruefung.starten(profilId, auftrag({ profilId: 'standard' }));

    expect(bogen.fragen).toHaveLength(80);
    expect(bogen.warnungen).toEqual([]);
  });

  /* Die Sätze stehen auf dem Bildschirm. Ein Feldname aus dem Vertrag wäre
     dort eine Zumutung, „Frage(n)“ ebenso. */
  it('formuliert die Warnungen ohne Feldnamen und ohne Klammerformen', () => {
    const bogen = pruefung.starten(
      profilId,
      auftrag({ profilId: 'standard', anteilOffen: 0.5, fragenAnzahl: 12 }),
    );

    expect(bogen.warnungen.length).toBeGreaterThan(0);
    const alles = bogen.warnungen.join(' ');

    expect(alles).toContain('der Anteil offener Fragen');
    expect(alles).toContain('die Fragenzahl');
    expect(alles).not.toContain('anteilOffen');
    expect(alles).not.toContain('fragenAnzahl');
    expect(alles).not.toContain('(n)');
  });

  /* Der Bogen wird je Themenbereich gezogen; ohne Entdoppelung stünde
     derselbe Satz achtmal untereinander auf dem Bildschirm. */
  it('sammelt gleichlautende Warnungen nur einmal', () => {
    const knapp = katalogBauen(5, 0);
    starten(knapp, 3);

    const bogen = pruefung.starten(profilId, auftrag({ profilId: 'dsb', anteilOffen: 0.5 }));

    expect(bogen.warnungen.length).toBe(new Set(bogen.warnungen).size);
  });

  it('weist einen Anteil außerhalb von 0 bis 1 ab', () => {
    expect(() =>
      pruefung.starten(profilId, auftrag({ profilId: 'frei', anteilOffen: 1.5 })),
    ).toThrow(/anteilOffen/u);
    expect(() =>
      pruefung.starten(profilId, auftrag({ profilId: 'frei', anteilOffen: -0.1 })),
    ).toThrow(/anteilOffen/u);
    expect(() =>
      pruefung.starten(profilId, auftrag({ profilId: 'frei', anteilOffen: 'viel' as never })),
    ).toThrow(/anteilOffen/u);
  });

  it('übernimmt den Anteil nicht bei festen Profilen und sagt es', () => {
    const bogenFragen = bogenZiehen(profilId, auftrag({ profilId: 'standard', anteilOffen: 0.5 }));
    const typen = new Set(bogenFragen.map((f) => frageZu(KATALOG, f.frageId).typ));

    expect([...typen]).toEqual(['mc']);
    expect(warnungen.some((w) => w.includes('der Anteil offener Fragen'))).toBe(true);
  });

  it('hält die Themenquoten des Profils ein', () => {
    const quoten = profilFinden('dsb')?.themenquoten ?? {};
    const bogenFragen = bogenZiehen(profilId, auftrag({ profilId: 'dsb' }));

    const gezaehlt = new Map<string, number>();
    for (const eintrag of bogenFragen) {
      const bereich = bereichVon(frageZu(KATALOG, eintrag.frageId));
      gezaehlt.set(bereich, (gezaehlt.get(bereich) ?? 0) + 1);
    }

    expect(Object.fromEntries(gezaehlt)).toEqual(quoten);
  });

  it('füllt einen zu kleinen Themenbereich aus den übrigen auf und protokolliert das', () => {
    // Nur 6 Fragen je Bereich – die DSB-Quote von 20 für „I.2“ ist damit
    // nicht zu erfüllen.
    const knapp = katalogBauen(5, 1);
    starten(knapp, 3);

    const bogenFragen = bogenZiehen(profilId, auftrag({ profilId: 'dsb' }));
    const ids = new Set(bogenFragen.map((f) => f.frageId));

    // 8 Bereiche à 6 Fragen = 48; mehr gibt der Katalog nicht her.
    expect(bogenFragen).toHaveLength(48);
    expect(ids.size).toBe(48);
    /* Mit Titel statt nur mit der Kennung: „I.2“ allein sagt niemandem etwas,
       der nicht den Aufbau des Katalogs im Kopf hat. */
    expect(warnungen.some((w) => w.includes('Themenbereich „I.2 – Rechte und Pflichten“'))).toBe(
      true,
    );
    expect(warnungen.some((w) => w.includes('stehen aber nur 48'))).toBe(true);
  });

  it('lässt ausgeschlossene Kapitel weg', () => {
    const bogenFragen = bogenZiehen(
      profilId,
      auftrag({ profilId: 'standard', kapitelAusschluss: ['IV'] }),
    );
    const kapitel = new Set(bogenFragen.map((f) => frageZu(KATALOG, f.frageId).kapitel));

    expect(bogenFragen).toHaveLength(80);
    expect(kapitel.has('IV')).toBe(false);
  });

  it('übernimmt Überschreibungen nur bei anpassbaren Profilen', () => {
    const frei = bogenZiehen(profilId, auftrag({ profilId: 'frei', fragenAnzahl: 12 }));

    expect(frei).toHaveLength(12);
    expect(warnungen).toEqual([]);

    const fest = bogenZiehen(profilId, auftrag({ profilId: 'standard', fragenAnzahl: 12 }));

    expect(fest).toHaveLength(80);
    expect(warnungen.some((w) => w.includes('hat feste Vorgaben'))).toBe(true);
  });

  it('weist unbekannte Profile, Zeitmodi, Kapitel und Lernprofile ab', () => {
    expect(() => pruefung.starten(profilId, auftrag({ profilId: 'phantasie' }))).toThrow(
      /Unbekanntes Prüfungsprofil/u,
    );
    expect(() =>
      pruefung.starten(profilId, { profilId: 'standard', zeitmodus: 'schneller' as never }),
    ).toThrow(/zeitmodus/u);
    expect(() =>
      pruefung.starten(profilId, auftrag({ profilId: 'standard', kapitelAusschluss: ['XI'] })),
    ).toThrow(/Unbekanntes Kapitel/u);
    expect(() => pruefung.starten(999, auftrag({ profilId: 'standard' }))).toThrow(
      /Unbekanntes Profil/u,
    );
  });

  it('weist einen Auftrag ab, der den ganzen Katalog abwählt', () => {
    expect(() =>
      pruefung.starten(
        profilId,
        auftrag({ profilId: 'standard', kapitelAusschluss: ['I', 'II', 'III', 'IV'] }),
      ),
    ).toThrow(/keine einzige Frage/u);
  });
});

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

describe('Bogen und Auswertung gehören zusammen', () => {
  /*
    Ohne Abgleich nähme der Kern jede beliebige Antwortliste an: Ein Lauf mit
    einer einzigen richtigen Antwort landete als „bestanden“ im Verlauf,
    obwohl der Bogen 80 Fragen hatte.
  */
  it('weist eine Antwortliste ab, die nicht zum ausgegebenen Bogen passt', () => {
    const bogenAusgabe = bogenZiehen(profilId, auftrag({ profilId: 'standard' }));
    expect(bogenAusgabe.length).toBeGreaterThan(1);

    const eine = KATALOG.fragen.find((f) => f.id === bogenAusgabe[0]?.frageId)!;

    expect(() =>
      pruefung.auswerten(
        profilId,
        auftrag({ profilId: 'standard' }),
        [antwort(eine, true)],
        60_000,
        false,
      ),
    ).toThrow(/umfasste .* Fragen/u);
  });

  it('weist eine Frage ab, die nicht im Bogen stand', () => {
    const bogenAusgabe = bogenZiehen(profilId, auftrag({ profilId: 'frei', fragenAnzahl: 3 }));
    const imBogen = new Set(bogenAusgabe.map((f) => f.frageId));
    const fremd = KATALOG.fragen.find((f) => !imBogen.has(f.id) && f.typ === 'mc')!;

    const antworten = bogenAusgabe.slice(0, 2).map((eintrag) => {
      const frage = KATALOG.fragen.find((f) => f.id === eintrag.frageId)!;
      return antwort(frage, true);
    });
    antworten.push(antwort(fremd, true));

    expect(() =>
      pruefung.auswerten(
        profilId,
        auftrag({ profilId: 'frei', fragenAnzahl: 3 }),
        antworten,
        1,
        false,
      ),
    ).toThrow(/nicht Teil des Bogens/u);
  });

  it('nimmt den passenden Bogen an und wertet ihn genau einmal aus', () => {
    const bogenAusgabe = bogenZiehen(profilId, auftrag({ profilId: 'frei', fragenAnzahl: 3 }));
    const antworten = bogenAusgabe.map((eintrag) => {
      const frage = KATALOG.fragen.find((f) => f.id === eintrag.frageId)!;
      return antwort(frage, true);
    });

    const ergebnis = pruefung.auswerten(
      profilId,
      auftrag({ profilId: 'frei', fragenAnzahl: 3 }),
      antworten,
      1_000,
      false,
    );
    expect(ergebnis.gesamt).toBe(3);

    // Ein zweiter Versuch mit demselben Bogen läuft in den Warnpfad, nicht in
    // eine erneute Prüfung – der Bogen ist verbraucht.
    expect(() =>
      pruefung.auswerten(
        profilId,
        auftrag({ profilId: 'frei', fragenAnzahl: 3 }),
        antworten,
        1_000,
        false,
      ),
    ).not.toThrow();
  });
});

describe('pruefung:auswerten', () => {
  it('rechnet Multiple Choice selbst nach und glaubt der Selbsteinschätzung nicht', () => {
    const frage = mcAus('I.1', 1)[0]!;
    const ergebnis = auswerten('standard', [
      { frageId: frage.id, auswahl: falscheAuswahl(frage), selbstAlsRichtig: true },
    ]);

    expect(ergebnis.richtig).toBe(0);
    expect(ergebnis.falsch).toBe(1);
    expect(ergebnis.fehlerIds).toEqual([frage.id]);
  });

  it('wertet eine unvollständige Mehrfachauswahl als falsch', () => {
    const mehrfach = KATALOG.fragen.find((f) => f.typ === 'mc' && richtigeLabels(f).length > 1);
    const soll = richtigeLabels(mehrfach!);
    const ergebnis = auswerten('standard', [
      { frageId: mehrfach!.id, auswahl: [soll[0]!] },
      { frageId: mcAus('I.2', 1)[0]!.id, auswahl: richtigeLabels(mcAus('I.2', 1)[0]!) },
    ]);

    expect(soll.length).toBeGreaterThan(1);
    expect(ergebnis.richtig).toBe(1);
    expect(ergebnis.fehlerIds).toEqual([mehrfach!.id]);
    expect(ergebnis.unbeantwortet).toBe(0);
  });

  it('zählt zu viel Angekreuztes als falsch', () => {
    const einfach = mcAus('I.1', 1)[0]!;
    const zuViel = [...richtigeLabels(einfach), ...falscheAuswahl(einfach)];
    const ergebnis = auswerten('standard', [{ frageId: einfach.id, auswahl: zuViel }]);

    expect(ergebnis.richtig).toBe(0);
  });

  it('weist unbeantwortete Fragen getrennt aus, ohne sie in „falsch“ mitzuzählen', () => {
    /*
      Die drei Zahlen bilden eine Zerlegung von `gesamt`. Steckten die
      unbeantworteten zusätzlich in `falsch`, zeigte die Auswertung mehr
      Fragen an, als der Bogen hat. Für die Wertung bleiben sie dennoch
      nicht richtig – und in `fehlerIds` gehören sie, damit man sie üben kann.
    */
    const mc = mcAus('I.1', 3);
    const offen = KATALOG.fragen.find((f) => f.typ !== 'mc')!;
    const ergebnis = auswerten('standard', [
      antwort(mc[0]!, true),
      antwort(mc[1]!, false),
      { frageId: mc[2]!.id, auswahl: [] },
      { frageId: offen.id, auswahl: [] },
    ]);

    expect(ergebnis.gesamt).toBe(4);
    expect(ergebnis.richtig).toBe(1);
    expect(ergebnis.falsch).toBe(1);
    expect(ergebnis.unbeantwortet).toBe(2);
    // Die Zerlegung geht auf.
    expect(ergebnis.richtig + ergebnis.falsch + ergebnis.unbeantwortet).toBe(ergebnis.gesamt);
    expect(ergebnis.fehlerIds).toEqual([mc[1]!.id, mc[2]!.id, offen.id]);
  });

  it('wertet offene Fragen nach der Selbsteinschätzung', () => {
    const offene = KATALOG.fragen.filter((f) => f.typ !== 'mc').slice(0, 2);
    const ergebnis = auswerten('dsb', [antwort(offene[0]!, true), antwort(offene[1]!, false)]);

    expect(ergebnis.richtig).toBe(1);
    expect(ergebnis.unbeantwortet).toBe(0);
  });

  it('füllt die Bereiche je Abschnitt bzw. Kapitel in Katalogreihenfolge', () => {
    const ergebnis = auswerten('standard', [
      ...mcAus('I.2', 2).map((f) => antwort(f, true)),
      ...mcAus('I.1', 2).map((f) => antwort(f, false)),
      ...mcAus('III', 1).map((f) => antwort(f, true)),
    ]);

    expect(ergebnis.bereiche).toEqual([
      { bereich: 'I.1', titel: 'Begriffe des Waffenrechts', gesamt: 2, richtig: 0 },
      { bereich: 'I.2', titel: 'Rechte und Pflichten', gesamt: 2, richtig: 2 },
      { bereich: 'III', titel: 'Handhabung von Schusswaffen', gesamt: 1, richtig: 1 },
    ]);
  });

  it('lässt das K.-o.-Kriterium unabhängig vom Gesamtergebnis greifen', () => {
    // Profil „fehlerpunkte“: höchstens 15 Fehler, aber höchstens 2 in I.5.
    const antworten = [
      ...mcAus('I.1', 20).map((f) => antwort(f, true)),
      ...mcAus('I.5', 3).map((f) => antwort(f, false)),
    ];
    const ergebnis = auswerten('fehlerpunkte', antworten);

    expect(ergebnis.falsch).toBe(3);
    expect(ergebnis.urteil).toBe('nicht_bestanden');
    expect(ergebnis.verletzteKriterien).toEqual([
      'Notwehr und Notstand (3 Fehler, höchstens 2 zulässig)',
    ]);
    expect(ergebnis.begruendung).toContain('Notwehr und Notstand');
  });

  it('lässt zwei Fehler in Notwehr und Notstand durchgehen', () => {
    const ergebnis = auswerten('fehlerpunkte', [
      ...mcAus('I.1', 20).map((f) => antwort(f, true)),
      ...mcAus('I.5', 2).map((f) => antwort(f, false)),
    ]);

    expect(ergebnis.verletzteKriterien).toEqual([]);
    expect(ergebnis.urteil).toBe('bestanden');
  });

  it.each([
    ['standard', 16, 4, 'bestanden'],
    ['standard', 15, 5, 'nicht_bestanden'],
    ['bdmp', 14, 6, 'bestanden'],
    ['bdmp', 13, 7, 'nicht_bestanden'],
    ['dsb', 15, 5, 'bestanden'],
    ['dsb', 13, 7, 'nachpruefung'],
    ['dsb', 12, 8, 'nachpruefung'],
    ['dsb', 11, 9, 'nicht_bestanden'],
    ['fehlerpunkte', 15, 5, 'bestanden'],
    ['fehlerpunkte', 4, 16, 'nicht_bestanden'],
  ])('bildet für %s bei %i von 20 richtigen das Urteil %s', (kennung, richtig, falsch, urteil) => {
    const ergebnis = auswerten(kennung, bogen(richtig, falsch));

    expect(ergebnis.gesamt).toBe(20);
    expect(ergebnis.richtig).toBe(richtig);
    expect(ergebnis.quote).toBeCloseTo(richtig / 20, 10);
    expect(ergebnis.urteil).toBe(urteil);
  });

  it('baut die Begründung aus Trefferzahlen, nicht aus gerundeten Prozenten', () => {
    /* 149 von 200 sind 74,5 Prozent – kaufmännisch gerundet 75. Aus
       Prozentwerten gebaut lautete die Anzeige „Nicht bestanden. 75 Prozent
       richtig, nötig waren 75 Prozent." Ein Satz, der sich selbst
       widerspricht. */
    const knapp = urteilBilden(profilFinden('frei')!, 149, 200, []);
    expect(knapp.urteil).toBe('nicht_bestanden');
    expect(knapp.begruendung).toBe('149 von 200 richtig, nötig waren 150.');

    const gerade = urteilBilden(profilFinden('frei')!, 150, 200, []);
    expect(gerade.urteil).toBe('bestanden');
    expect(gerade.begruendung).toBe('150 von 200 richtig, nötig waren 150.');
  });

  it('rundet die nötige Trefferzahl nicht durch Gleitkomma hoch', () => {
    // 0,8 * 80 kann als 64,000000000000006 herauskommen – aufgerundet 65.
    const ergebnis = urteilBilden(profilFinden('standard')!, 64, 80, []);

    expect(ergebnis.urteil).toBe('bestanden');
    expect(ergebnis.begruendung).toBe('64 von 80 richtig, nötig waren 64.');
  });

  it('nennt die Grauzone des DSB-Profils in der Begründung', () => {
    const ergebnis = auswerten('dsb', bogen(13, 7));

    expect(ergebnis.urteil).toBe('nachpruefung');
    expect(ergebnis.begruendung).toContain('mündliche Nachprüfung');
  });

  it('rechnet die Bestehensgrenze eines anpassbaren Profils nach', () => {
    const streng = pruefung.auswerten(
      profilId,
      auftrag({ profilId: 'frei', bestehensQuote: 0.9 }),
      bogen(16, 4),
      1000,
      false,
    );

    expect(streng.urteil).toBe('nicht_bestanden');

    const milde = pruefung.auswerten(
      profilId,
      auftrag({ profilId: 'frei', bestehensQuote: 0.5 }),
      bogen(16, 4),
      1000,
      false,
    );

    expect(milde.urteil).toBe('bestanden');
  });

  it('übernimmt Dauer und Zeitangabe unverändert', () => {
    const ergebnis = pruefung.auswerten(
      profilId,
      auftrag({ profilId: 'standard' }),
      bogen(10, 10),
      1_234_567,
      true,
    );

    expect(ergebnis.dauerMs).toBe(1_234_567);
    expect(ergebnis.zeitAbgelaufen).toBe(true);
    expect(ergebnis.zeitpunkt).toBe(START);
    expect(ergebnis.profilId).toBe('standard');
  });

  it('weist unsaubere Nutzlasten ab', () => {
    const frage = mcAus('I.1', 1)[0]!;

    expect(() => auswerten('standard', [])).toThrow(/mindestens eine Antwort/u);
    expect(() => auswerten('standard', [antwort(frage, true), antwort(frage, true)])).toThrow(
      /mehrfach im Bogen/u,
    );
    expect(() => auswerten('standard', [{ frageId: 'gibt-es-nicht', auswahl: [] }])).toThrow(
      /Unbekannte Frage-ID/u,
    );
    expect(() => auswerten('standard', [{ frageId: frage.id, auswahl: ['z'] }])).toThrow(
      /keine Antwortoption/u,
    );
    expect(() =>
      pruefung.auswerten(profilId, auftrag({ profilId: 'standard' }), bogen(1, 0), -5, false),
    ).toThrow(/dauerMs/u);
    expect(() =>
      pruefung.auswerten(profilId, auftrag({ profilId: 'standard' }), bogen(1, 0), 'lange', false),
    ).toThrow(/dauerMs/u);
  });

  it('lässt die Wiedervorlage nie aufgeschlagener Fragen unberührt', () => {
    /*
      Läuft die Zeit ab, schickt die Oberfläche auch für nie gezeigte Fragen
      einen Eintrag mit leerer Auswahl. Diese gehören in die Historie – der
      Bogen enthielt sie ja –, dürfen den Lernstand aber nicht zurückstufen:
      Eine viermal sicher gewusste Frage wäre sonst allein deshalb wieder
      fällig, weil ein Prüfungslauf nicht bis zu ihr kam.
    */
    const [gezeigt, nieGezeigt] = mcAus('I.1', 2);
    if (!gezeigt || !nieGezeigt) {
      throw new Error('Zwei Fragen erwartet');
    }

    // Die zweite Frage viermal sicher beantworten, damit sie weit hinausrückt.
    for (let i = 0; i < 4; i += 1) {
      lernstand.antworten(profilId, {
        frageId: nieGezeigt.id,
        auswahl: nieGezeigt.optionen?.filter((o) => o.korrekt).map((o) => o.label) ?? [],
        richtig: true,
        bewertung: 'leicht',
        dauerMs: 1_000,
      });
    }
    const vorher = lernstand.frageStand(profilId, nieGezeigt.id);
    expect(vorher.faelligAb).not.toBeNull();

    pruefung.auswerten(
      profilId,
      auftrag({ profilId: 'standard' }),
      [antwort(gezeigt, true), { frageId: nieGezeigt.id, auswahl: [] }],
      60_000,
      true,
    );

    const nachher = lernstand.frageStand(profilId, nieGezeigt.id);
    expect(nachher.faelligAb).toBe(vorher.faelligAb);
    expect(nachher.letzteBewertung).toBe('leicht');
    expect(nachher.versuche).toBe(vorher.versuche);
  });

  it('kappt eine übergroße Dauer, statt den Lauf zu verwerfen', () => {
    /*
      Wer ohne Zeitbegrenzung übt – der Nachteilsausgleich für alle, die mehr
      Zeit brauchen – lässt das Fenster womöglich über Nacht offen. Diesen
      Lauf zu verwerfen träfe genau die Gruppe, für die die Einstellung da
      ist. Der Lauf wird gespeichert, die Dauer auf 24 Stunden gedeckelt.
    */
    const ergebnis = pruefung.auswerten(
      profilId,
      auftrag({ profilId: 'standard' }),
      bogen(3, 1),
      90_000_000,
      false,
    );

    expect(ergebnis.dauerMs).toBe(86_400_000);
    expect(ergebnis.richtig).toBe(3);

    const gespeichert = pruefung.verlauf(profilId);
    expect(gespeichert).toHaveLength(1);
    expect(gespeichert[0]?.gesamt).toBe(4);
  });

  it('rundet eine gemessene Dauer mit Nachkommastellen, statt sie abzuweisen', () => {
    const ergebnis = pruefung.auswerten(
      profilId,
      auftrag({ profilId: 'standard' }),
      bogen(1, 0),
      1234.6,
      false,
    );

    expect(ergebnis.dauerMs).toBe(1235);
  });
});

// ─── Fortschreibung des Lernstands ──────────────────────────────────────────

describe('Prüfungslauf im Lernstand', () => {
  it('protokolliert jede Antwort wie eine Lernantwort', () => {
    const antworten = bogen(3, 2);
    auswerten('standard', antworten, 50_000);

    const anzahl = db
      .prepare<[number], { n: number }>('SELECT COUNT(*) AS n FROM antwort_log WHERE profil_id = ?')
      .get(profilId);

    expect(anzahl?.n).toBe(5);
    expect(lernstand.uebersicht(profilId).beantwortet).toBe(5);
    expect(lernstand.frageStand(profilId, antworten[0]!.frageId)).toMatchObject({
      versuche: 1,
      richtige: 1,
      letzteBewertung: 'gut',
    });
    expect(lernstand.frageStand(profilId, antworten[4]!.frageId)).toMatchObject({
      versuche: 1,
      richtige: 0,
      letzteBewertung: 'nochmal',
    });
  });

  it('verteilt die gemessene Gesamtdauer gleichmäßig auf die Fragen', () => {
    auswerten('standard', bogen(2, 2), 40_000);

    const dauern = db
      .prepare<[number], { dauer_ms: number }>(
        'SELECT dauer_ms FROM antwort_log WHERE profil_id = ?',
      )
      .all(profilId)
      .map((z) => z.dauer_ms);

    expect(dauern).toEqual([10_000, 10_000, 10_000, 10_000]);
  });

  it('speichert nichts, wenn die Auswertung abbricht', () => {
    const frage = mcAus('I.1', 1)[0]!;

    expect(() => auswerten('standard', [{ frageId: frage.id, auswahl: ['z'] }])).toThrow();
    expect(pruefung.verlauf(profilId)).toEqual([]);
    expect(lernstand.uebersicht(profilId).beantwortet).toBe(0);
  });
});

// ─── Verlauf ────────────────────────────────────────────────────────────────

// ─── Unterbrochener Lauf ────────────────────────────────────────────────────

describe('Unterbrochener Lauf', () => {
  /** Vollständige Antwortliste zum gerade gestarteten Bogen. */
  function bogenAntworten(bogenListe: readonly { frageId: string }[]): Pruefungsantwort[] {
    return bogenListe.map((eintrag) => {
      const frage = KATALOG.fragen.find((f) => f.id === eintrag.frageId)!;
      return antwort(frage, true);
    });
  }

  function offeneZeilen(): number {
    return (db.prepare('SELECT COUNT(*) AS n FROM pruefung_offen').get() as { n: number }).n;
  }

  it('legt den Bogen schon beim Starten ab, nicht erst beim ersten Sichern', () => {
    /* Stürbe das Programm in der ersten Sekunde, wäre der Bogen sonst trotz
       aller Vorsorge weg. */
    const gezogen = bogenZiehen(profilId, auftrag({ profilId: 'standard' }));

    const offen = pruefung.offenerBogen(profilId);
    expect(offen).not.toBeNull();
    expect(offen?.bogen.map((e) => e.frageId)).toEqual(gezogen.map((e) => e.frageId));
    expect(offen?.position).toBe(0);
    expect(offen?.verbrauchtMs).toBe(0);
    expect(offen?.phase).toBe('bearbeiten');
    expect(offen?.profil.id).toBe('standard');
  });

  it('sichert Eingaben, Position, Phase und verbrauchte Zeit', () => {
    const gezogen = bogenZiehen(profilId, auftrag({ profilId: 'standard' }));
    const erste = gezogen[0]!.frageId;

    expect(
      pruefung.sichern(profilId, {
        eingaben: { [erste]: { auswahl: ['a'], freitext: '' } },
        position: 5,
        phase: 'bearbeiten',
        zeitAbgelaufen: false,
        verbrauchtMs: 90_000,
      }),
    ).toBe(true);

    const offen = pruefung.offenerBogen(profilId);
    expect(offen?.eingaben[erste]).toEqual({ auswahl: ['a'], freitext: '' });
    expect(offen?.position).toBe(5);
    expect(offen?.verbrauchtMs).toBe(90_000);
  });

  it('unterscheidet nicht bewertet von als falsch bewertet', () => {
    /* Ein Ersatzwert `false` schriebe eine unbewertete offene Frage beim
       Fortsetzen als falsch fest. */
    const gezogen = bogenZiehen(profilId, auftrag({ profilId: 'dsb' }));
    const a = gezogen[0]!.frageId;
    const b = gezogen[1]!.frageId;

    pruefung.sichern(profilId, {
      eingaben: {
        [a]: { auswahl: [], freitext: 'Text', selbst: false },
        [b]: { auswahl: [], freitext: 'Text' },
      },
      position: 0,
      phase: 'nachbewertung',
      zeitAbgelaufen: false,
      verbrauchtMs: 0,
    });

    const offen = pruefung.offenerBogen(profilId);
    expect(offen?.eingaben[a]?.selbst).toBe(false);
    expect(offen?.eingaben[b]).not.toHaveProperty('selbst');
    expect(offen?.phase).toBe('nachbewertung');
  });

  it('löscht die Zeile beim Auswerten – in derselben Transaktion', () => {
    const gezogen = bogenZiehen(profilId, auftrag({ profilId: 'standard' }));
    expect(offeneZeilen()).toBe(1);

    auswerten('standard', bogenAntworten(gezogen));

    expect(offeneZeilen()).toBe(0);
    expect(pruefung.offenerBogen(profilId)).toBeNull();
  });

  it('lässt eine verspätete Sicherung den gewerteten Lauf nicht wiederbeleben', () => {
    /*
      Der schwerste Fall: Das Sichern ist entprellt. Feuert ein Nachzügler
      nach der Abgabe, dürfte er die eben gelöschte Zeile nicht wieder
      anlegen – sonst böte die Anwendung beim nächsten Start dieselbe,
      bereits gewertete Prüfung zum Fortsetzen an, und jede Antwort ginge
      ein zweites Mal in die Wiedervorlage.
    */
    const gezogen = bogenZiehen(profilId, auftrag({ profilId: 'standard' }));
    auswerten('standard', bogenAntworten(gezogen));

    const angekommen = pruefung.sichern(profilId, {
      eingaben: {},
      position: 3,
      phase: 'bearbeiten',
      zeitAbgelaufen: false,
      verbrauchtMs: 120_000,
    });

    expect(angekommen).toBe(false);
    expect(offeneZeilen()).toBe(0);
  });

  it('hinterlässt nach der Auswertung genau einen Verlaufseintrag', () => {
    const gezogen = bogenZiehen(profilId, auftrag({ profilId: 'standard' }));
    auswerten('standard', bogenAntworten(gezogen));

    expect(pruefung.offenerBogen(profilId)).toBeNull();
    expect((db.prepare('SELECT COUNT(*) AS n FROM pruefung_lauf').get() as { n: number }).n).toBe(
      1,
    );
  });

  it('sichert nicht in einen Lauf, der inzwischen ein anderer ist', () => {
    /*
      Dafuer ist die Lauf-Kennung da, und nur dafuer: Die Zeile existiert
      noch, gehoert aber schon zum naechsten Bogen. Ein Nachzuegler aus dem
      alten Lauf duerfte dessen Position und Eingaben nicht in den neuen
      schreiben.
    */
    pruefung.starten(profilId, auftrag({ profilId: 'standard' }));
    const alteKennung = (
      db.prepare('SELECT lauf_id FROM pruefung_offen').get() as { lauf_id: string }
    ).lauf_id;

    const zweiter = bogenZiehen(profilId, auftrag({ profilId: 'bdmp' }));
    const neueKennung = (
      db.prepare('SELECT lauf_id FROM pruefung_offen').get() as { lauf_id: string }
    ).lauf_id;
    expect(neueKennung).not.toBe(alteKennung);

    pruefung.sichern(profilId, {
      eingaben: { [zweiter[0]!.frageId]: { auswahl: ['a'], freitext: '' } },
      position: 1,
      phase: 'bearbeiten',
      zeitAbgelaufen: false,
      verbrauchtMs: 1000,
    });

    // Der neue Lauf nimmt seine eigene Sicherung an – die Kennung stimmt.
    expect(pruefung.offenerBogen(profilId)?.position).toBe(1);
  });

  it('verwirft die Zeile auf ausdrücklichen Wunsch', () => {
    pruefung.starten(profilId, auftrag({ profilId: 'standard' }));

    pruefung.verwerfen(profilId);

    expect(pruefung.offenerBogen(profilId)).toBeNull();
    expect(offeneZeilen()).toBe(0);
  });

  it('führt je Profil höchstens einen offenen Lauf', () => {
    pruefung.starten(profilId, auftrag({ profilId: 'standard' }));
    const zweiter = bogenZiehen(profilId, auftrag({ profilId: 'bdmp' }));

    expect(offeneZeilen()).toBe(1);
    expect(pruefung.offenerBogen(profilId)?.bogen.map((e) => e.frageId)).toEqual(
      zweiter.map((e) => e.frageId),
    );
  });

  it('hält die Läufe verschiedener Profile auseinander', () => {
    const zweites = lernstand.profilAnlegen('Zweitprüfling');
    pruefung.starten(profilId, auftrag({ profilId: 'standard' }));
    pruefung.starten(zweites.id, auftrag({ profilId: 'bdmp' }));

    expect(pruefung.offenerBogen(profilId)?.profil.id).toBe('standard');
    expect(pruefung.offenerBogen(zweites.id)?.profil.id).toBe('bdmp');
  });

  it('nimmt das gelöschte Profil mit', () => {
    const zweites = lernstand.profilAnlegen('Zweitprüfling');
    pruefung.starten(zweites.id, auftrag({ profilId: 'standard' }));

    lernstand.profilLoeschen(zweites.id);

    expect(offeneZeilen()).toBe(0);
  });

  it('verschwindet beim vollständigen Zurücksetzen', () => {
    /* „Von vorn" und „die halbe Prüfung von gestern liegt noch da" passen
       nicht zusammen. */
    pruefung.starten(profilId, auftrag({ profilId: 'standard' }));

    lernstand.zuruecksetzen(profilId, null);

    expect(offeneZeilen()).toBe(0);
  });

  it('bleibt beim kapitelweisen Zurücksetzen stehen', () => {
    /* Ein Bogen geht über den ganzen Katalog und lässt sich nicht
       kapitelweise herausrechnen. */
    pruefung.starten(profilId, auftrag({ profilId: 'standard' }));

    lernstand.zuruecksetzen(profilId, 'I');

    expect(offeneZeilen()).toBe(1);
  });

  it('verwirft einen Bogen, dessen Fragen es nicht mehr gibt', () => {
    /* Katalogwechsel zwischen Unterbrechung und Fortsetzen: Eine fehlende
       Frage ließe eine Lücke, und die Auswertung zählte gegen eine andere
       Gesamtzahl als die Anzeige. */
    pruefung.starten(profilId, auftrag({ profilId: 'standard' }));
    db.prepare('UPDATE pruefung_offen SET bogen = ?').run(
      '[{"frageId":"gibt-es-nicht","optionsReihenfolge":["a"]}]',
    );

    expect(pruefung.offenerBogen(profilId)).toBeNull();
    expect(offeneZeilen()).toBe(0);
    expect(warnungen.join(' ')).toMatch(/gibt-es-nicht/u);
  });

  it('verwirft eine Zeile mit ungültigem Auftrag', () => {
    /* Die Datei liegt im userData-Verzeichnis und ist von außen
       beschreibbar; ein erfundenes Profil darf nicht in die Wertung. */
    pruefung.starten(profilId, auftrag({ profilId: 'standard' }));
    db.prepare('UPDATE pruefung_offen SET auftrag = ?').run('{"profilId":"gibt-es-nicht"}');

    expect(pruefung.offenerBogen(profilId)).toBeNull();
    expect(offeneZeilen()).toBe(0);
  });

  it('nimmt beim Auswerten den gespeicherten Auftrag, nicht den eingereichten', () => {
    /*
      Sonst ließe sich ein fortgesetzter Bogen mit einem anderen Profil
      abgeben: Bogen und Urteilsregeln kämen aus verschiedenen Läufen.
    */
    const gezogen = bogenZiehen(profilId, auftrag({ profilId: 'bdmp' }));

    const ergebnis = pruefung.auswerten(
      profilId,
      auftrag({ profilId: 'standard' }),
      bogenAntworten(gezogen),
      60_000,
      false,
    );

    expect(ergebnis.profilId).toBe('bdmp');
  });

  it('prüft die eingereichte Liste auch nach einem Neustart', () => {
    /*
      Bisher galt: Ist der Bogen nicht bekannt – etwa nach einem Neustart –,
      wird jede Liste ungeprüft angenommen. Mit der gespeicherten Zeile lässt
      sich der Bogen wiederherstellen, und die Prüfung greift wieder.
    */
    const gezogen = bogenZiehen(profilId, auftrag({ profilId: 'standard' }));
    const antworten = bogenAntworten(gezogen);

    // Neustart nachbilden: neue Instanz, leerer Speicher, dieselbe Datenbank.
    const nachNeustart = new Pruefung(lernstand, KATALOG, {
      jetzt: () => uhr,
      zufall: zufallsfolge(7),
      warnen: (meldung) => warnungen.push(meldung),
    });
    nachNeustart.offenerBogen(profilId);

    expect(() =>
      nachNeustart.auswerten(
        profilId,
        auftrag({ profilId: 'standard' }),
        antworten.slice(0, 3),
        1000,
        false,
      ),
    ).toThrow(/Bogen umfasste/u);
  });

  it('weist eine Position außerhalb des Bogens ab', () => {
    pruefung.starten(profilId, auftrag({ profilId: 'standard' }));

    expect(() =>
      pruefung.sichern(profilId, {
        eingaben: {},
        position: 999,
        phase: 'bearbeiten',
        zeitAbgelaufen: false,
        verbrauchtMs: 0,
      }),
    ).toThrow(/position/u);
  });

  it('weist eine Eingabe zu einer fremden Frage ab', () => {
    pruefung.starten(profilId, auftrag({ profilId: 'standard' }));

    expect(() =>
      pruefung.sichern(profilId, {
        eingaben: { 'gibt-es-nicht': { auswahl: ['a'], freitext: '' } },
        position: 0,
        phase: 'bearbeiten',
        zeitAbgelaufen: false,
        verbrauchtMs: 0,
      }),
    ).toThrow(/gehört nicht zu diesem Bogen/u);
  });

  it('kappt einen übergroßen Freitext und eine unsinnige Dauer', () => {
    const gezogen = bogenZiehen(profilId, auftrag({ profilId: 'standard' }));
    const erste = gezogen[0]!.frageId;

    pruefung.sichern(profilId, {
      eingaben: { [erste]: { auswahl: [], freitext: 'x'.repeat(9000) } },
      position: 0,
      phase: 'bearbeiten',
      zeitAbgelaufen: false,
      verbrauchtMs: 99 * 24 * 60 * 60 * 1000,
    });

    const offen = pruefung.offenerBogen(profilId);
    expect(offen?.eingaben[erste]?.freitext).toHaveLength(4000);
    expect(offen?.verbrauchtMs).toBe(24 * 60 * 60 * 1000);
  });
});

describe('pruefung:verlauf', () => {
  it('liefert die Läufe mit dem neuesten zuerst', () => {
    auswerten('standard', bogen(16, 4));
    uhr = new Date(new Date(START).getTime() + 3_600_000);
    auswerten('dsb', bogen(10, 10));

    const verlauf = pruefung.verlauf(profilId);

    expect(verlauf.map((l) => l.profilId)).toEqual(['dsb', 'standard']);
    expect(verlauf[0]).toMatchObject({
      profilName: 'Nach Art des Deutschen Schützenbundes',
      gesamt: 20,
      richtig: 10,
      quote: 0.5,
      urteil: 'nicht_bestanden',
    });
    expect(verlauf[1]).toMatchObject({
      profilName: 'Standard',
      richtig: 16,
      urteil: 'bestanden',
    });
    expect(verlauf[0]!.id).not.toBe(verlauf[1]!.id);
  });

  it('hält die Läufe verschiedener Lernprofile auseinander', () => {
    const zweites = lernstand.profilAnlegen('Zweite Person');
    auswerten('standard', bogen(16, 4));

    expect(pruefung.verlauf(profilId)).toHaveLength(1);
    expect(pruefung.verlauf(zweites.id)).toEqual([]);
  });

  it('weist ein unbekanntes Lernprofil ab', () => {
    expect(() => pruefung.verlauf(4711)).toThrow(/Unbekanntes Profil/u);
  });

  it('liefert die Dauer mit, die es seit jeher speichert', () => {
    // Die Spalte gibt es seit Schema-Version 2, `verlauf()` las sie bis 0.22.0
    // nur nicht aus. Zeitnot ist ein eigenes Durchfallrisiko.
    auswerten('standard', bogen(16, 4), 71 * 60_000);

    expect(pruefung.verlauf(profilId)[0]?.dauerMs).toBe(71 * 60_000);
  });

  it('hält fest, wie viele Fragen offen blieben und ob die Zeit ablief', () => {
    /* Ohne diese beiden Angaben sähe ein abgelaufener Lauf im Vergleich wie
       ein Wissenseinbruch aus: `quote` zählt Unbeantwortete wie falsch
       beantwortete. */
    const fragen = mcAus('I.1', 4);
    pruefung.auswerten(
      profilId,
      auftrag({ profilId: 'standard' }),
      [
        antwort(fragen[0]!, true),
        antwort(fragen[1]!, true),
        { frageId: fragen[2]!.id, auswahl: [] },
        { frageId: fragen[3]!.id, auswahl: [] },
      ],
      60_000,
      true,
    );

    expect(pruefung.verlauf(profilId)[0]).toMatchObject({
      unbeantwortet: 2,
      zeitAbgelaufen: true,
      quote: 0.5,
    });
  });

  it('speichert die Themenanalyse des Laufs mit', () => {
    const fragen = [...mcAus('I.1', 2), ...mcAus('I.2', 2)];
    pruefung.auswerten(
      profilId,
      auftrag({ profilId: 'standard' }),
      [
        antwort(fragen[0]!, true),
        antwort(fragen[1]!, false),
        antwort(fragen[2]!, true),
        antwort(fragen[3]!, true),
      ],
      60_000,
      false,
    );

    const bereiche = pruefung.verlauf(profilId)[0]?.bereiche;

    expect(bereiche).toHaveLength(2);
    expect(bereiche?.[0]).toMatchObject({ bereich: 'I.1', gesamt: 2, richtig: 1 });
    expect(bereiche?.[1]).toMatchObject({ bereich: 'I.2', gesamt: 2, richtig: 2 });
    /* Der Titel reist mit: Der Verlauf muss ihn auch dann noch nennen können,
       wenn der Katalog den Abschnitt später anders benennt. */
    expect(bereiche?.[0]?.titel).toBe(KATALOG.kapitel[0]?.abschnitte[0]?.titel);
  });

  it('speichert die Bestehensgrenze des Laufs, nicht die des Vertrags', () => {
    /* Beim frei eingestellten Profil sind die gewählten Werte nach dem Lauf
       fort. Holte die Anzeige die Grenze hinterher aus dem Vertrag, bekämen
       genau diese Läufe eine falsche. */
    auswerten('standard', bogen(16, 4));
    expect(pruefung.verlauf(profilId)[0]?.bestehensQuote).toBeCloseTo(0.8);

    uhr = new Date(new Date(START).getTime() + 3_600_000);
    pruefung.auswerten(
      profilId,
      auftrag({ profilId: 'frei', bestehensQuote: 0.5 }),
      bogen(2, 2),
      60_000,
      false,
    );
    expect(pruefung.verlauf(profilId)[0]?.bestehensQuote).toBeCloseTo(0.5);
  });

  it('rechnet eine Fehlerpunktegrenze in eine Quote um', () => {
    // 75 Fragen bei höchstens 15 Fehlern sind 60 von 75 – dieselbe Bedingung,
    // anders geschrieben. Ein Bogen von 20 Fragen ergibt entsprechend 5 von 20.
    pruefung.auswerten(
      profilId,
      auftrag({ profilId: 'fehlerpunkte' }),
      bogen(16, 4),
      60_000,
      false,
    );

    expect(pruefung.verlauf(profilId)[0]?.bestehensQuote).toBeCloseTo(0.25);
  });

  it('nennt die Kennung des gespeicherten Laufs', () => {
    // Der Verlauf enthält den eben beendeten Lauf bereits. Ohne die Kennung
    // verglichen ihn Auswertung und Anzeige mit sich selbst.
    const ergebnis = auswerten('standard', bogen(16, 4));

    expect(ergebnis.laufId).toBe(pruefung.verlauf(profilId)[0]?.id);
  });

  it('übersteht eine beschädigte Themenanalyse, ohne den Verlauf zu verlieren', () => {
    /* Die Datei liegt im `userData`-Verzeichnis. Was nicht als Eintrag lesbar
       ist, fällt weg – schlimmstenfalls fehlt einem Lauf die Aufschlüsselung,
       statt dass die Anzeige ausfällt. */
    auswerten('standard', bogen(16, 4));
    db.prepare("UPDATE pruefung_lauf SET bereiche = '{kaputt'").run();

    const verlauf = pruefung.verlauf(profilId);

    expect(verlauf).toHaveLength(1);
    expect(verlauf[0]?.bereiche).toBeNull();
  });

  it('nimmt aus einer halb lesbaren Analyse nur die gültigen Einträge', () => {
    auswerten('standard', bogen(16, 4));
    db.prepare(
      `UPDATE pruefung_lauf SET bereiche = '[
         {"bereich":"I.1","titel":"Begriffe","gesamt":4,"richtig":3},
         {"bereich":"I.2","titel":"Rechte","gesamt":2,"richtig":5},
         {"bereich":"I.3","gesamt":2,"richtig":1},
         "unfug"
       ]'`,
    ).run();

    // Mehr richtige als gestellte Fragen, fehlender Titel, kein Objekt: weg.
    expect(pruefung.verlauf(profilId)[0]?.bereiche).toEqual([
      { bereich: 'I.1', titel: 'Begriffe', gesamt: 4, richtig: 3 },
    ]);
  });
});

// ─── Migration ──────────────────────────────────────────────────────────────

describe('Migration auf Schema 2', () => {
  function tabellen(verbindung: Database.Database): string[] {
    return verbindung
      .prepare<[], { name: string }>("SELECT name FROM sqlite_master WHERE type = 'table'")
      .all()
      .map((z) => z.name);
  }

  it('ergänzt pruefung_lauf, ohne einen bestehenden Lernstand zu verlieren', () => {
    const alt = new Database(':memory:');
    verbindungen.push(alt);

    const vorher = new Lernstand(alt, KATALOG, { jetzt: () => uhr });
    const pid = vorher.profile()[0]!.id;
    const gelernt = mcAus('I.2', 1)[0]!;
    vorher.antworten(pid, {
      frageId: gelernt.id,
      auswahl: richtigeLabels(gelernt),
      richtig: true,
      bewertung: 'gut',
      dauerMs: 1200,
    });
    vorher.merken(pid, mcAus('I.3', 1)[0]!.id, true);

    // Stand einer Installation mit Schema-Version 1 herstellen.
    alt.exec('DROP TABLE pruefung_lauf');
    alt.prepare('DELETE FROM schema_version WHERE version > 1').run();

    expect(tabellen(alt)).not.toContain('pruefung_lauf');

    const nachher = new Lernstand(alt, KATALOG, { jetzt: () => uhr });

    expect(tabellen(alt)).toContain('pruefung_lauf');
    expect(
      alt
        .prepare<[], { version: number }>('SELECT MAX(version) AS version FROM schema_version')
        .get()?.version,
    ).toBe(SCHEMA_VERSION);

    // Der bestehende Lernstand ist unversehrt.
    expect(nachher.profile()).toHaveLength(1);
    expect(nachher.frageStand(pid, gelernt.id)).toMatchObject({ versuche: 1, richtige: 1 });
    expect(nachher.frageStand(pid, mcAus('I.3', 1)[0]!.id).gemerkt).toBe(true);
    expect(nachher.uebersicht(pid).beantwortet).toBe(1);

    // Und die Simulation lässt sich sofort benutzen.
    const migriert = new Pruefung(nachher, KATALOG, { jetzt: () => uhr });
    migriert.auswerten(pid, auftrag({ profilId: 'standard' }), bogen(16, 4), 1000, false);

    expect(migriert.verlauf(pid)).toHaveLength(1);
  });

  it('ergänzt die Zeitstufe und lässt ältere Läufe ohne sie stehen', () => {
    /* Die Zeitstufe eines Laufs von vor Schema-Version 4 ist nicht mehr zu
       ermitteln. Sie zu raten wäre schlechter, als sie offen zu lassen –
       sonst stünde in der Tabelle eine Angabe, die niemand gemessen hat. */
    const alt = new Database(':memory:');
    verbindungen.push(alt);

    const vorher = new Lernstand(alt, KATALOG, { jetzt: () => uhr });
    const pid = vorher.profile()[0]!.id;
    const simulation = new Pruefung(vorher, KATALOG, { jetzt: () => uhr });
    simulation.auswerten(pid, auftrag({ profilId: 'standard' }), bogen(16, 4), 1000, false);

    // Stand vor der Umstellung nachbilden: Spalte leeren, Version zurück.
    alt.prepare('UPDATE pruefung_lauf SET zeitmodus = NULL').run();
    alt.prepare('DELETE FROM schema_version WHERE version > 3').run();

    const nachher = new Lernstand(alt, KATALOG, { jetzt: () => uhr });
    const spalten = alt
      .prepare<[], { name: string }>('PRAGMA table_info(pruefung_lauf)')
      .all()
      .map((z) => z.name);
    expect(spalten).toContain('zeitmodus');

    const migriert = new Pruefung(nachher, KATALOG, { jetzt: () => uhr });
    expect(migriert.verlauf(pid)[0]?.zeitmodus).toBeNull();

    // Ein neuer Lauf trägt sie dagegen.
    migriert.auswerten(
      pid,
      auftrag({ profilId: 'standard', zeitmodus: 'aus' }),
      bogen(16, 4),
      1000,
      false,
    );
    expect(migriert.verlauf(pid)[0]?.zeitmodus).toBe('aus');
  });

  it('ergänzt die Kennzahlen aus Fassung 10 und erfindet sie für alte Läufe nicht', () => {
    /* `unbeantwortet = 0` hieße „es blieb nichts offen“ – eine Behauptung über
       einen Lauf, von dem niemand weiß, wie er endete. `NULL` heißt „nicht
       festgehalten“, und die Oberfläche sagt genau das. */
    const alt = new Database(':memory:');
    verbindungen.push(alt);

    const vorher = new Lernstand(alt, KATALOG, { jetzt: () => uhr });
    const pid = vorher.profile()[0]!.id;
    const simulation = new Pruefung(vorher, KATALOG, { jetzt: () => uhr });
    simulation.auswerten(pid, auftrag({ profilId: 'standard' }), bogen(16, 4), 1000, false);

    // Stand vor der Umstellung nachbilden: Spalten leeren, Version zurück.
    alt
      .prepare(
        `UPDATE pruefung_lauf
            SET unbeantwortet = NULL, zeit_abgelaufen = NULL,
                bereiche = NULL, bestehens_quote = NULL`,
      )
      .run();
    alt.prepare('DELETE FROM schema_version WHERE version > 9').run();

    const nachher = new Lernstand(alt, KATALOG, { jetzt: () => uhr });
    const migriert = new Pruefung(nachher, KATALOG, { jetzt: () => uhr });

    expect(migriert.verlauf(pid)[0]).toMatchObject({
      unbeantwortet: null,
      zeitAbgelaufen: null,
      bereiche: null,
      bestehensQuote: null,
      // Die Dauer stand schon in Fassung 2 in der Tabelle.
      dauerMs: 1000,
    });

    // Ein neuer Lauf trägt sie dagegen.
    uhr = new Date(new Date(START).getTime() + 3_600_000);
    migriert.auswerten(pid, auftrag({ profilId: 'standard' }), bogen(16, 4), 1000, false);

    expect(migriert.verlauf(pid)[0]).toMatchObject({
      unbeantwortet: 0,
      zeitAbgelaufen: false,
      bestehensQuote: 0.8,
    });
    expect(migriert.verlauf(pid)[0]?.bereiche).not.toBeNull();
  });

  it('legt die Spalten aus Fassung 10 auch auf einer Datenbank ohne sie an', () => {
    /* Der Schritt muss auch dann durchlaufen, wenn `pruefung_lauf` noch die
       alte Gestalt hat – sonst startet die Anwendung überhaupt nicht mehr. */
    const alt = new Database(':memory:');
    verbindungen.push(alt);

    const vorher = new Lernstand(alt, KATALOG, { jetzt: () => uhr });
    const pid = vorher.profile()[0]!.id;

    alt.exec('DROP TABLE pruefung_lauf');
    alt.prepare('DELETE FROM schema_version WHERE version > 1').run();

    const nachher = new Lernstand(alt, KATALOG, { jetzt: () => uhr });
    const spalten = alt
      .prepare<[], { name: string }>('PRAGMA table_info(pruefung_lauf)')
      .all()
      .map((z) => z.name);

    expect(spalten).toEqual(
      expect.arrayContaining(['unbeantwortet', 'zeit_abgelaufen', 'bereiche', 'bestehens_quote']),
    );

    const migriert = new Pruefung(nachher, KATALOG, { jetzt: () => uhr });
    migriert.auswerten(pid, auftrag({ profilId: 'standard' }), bogen(16, 4), 1000, false);

    expect(migriert.verlauf(pid)[0]?.bestehensQuote).toBeCloseTo(0.8);
  });
});
