// @vitest-environment node
// Der Lernstand 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.

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

import { Lernstand } from '../src/main/lernstand';
import { SCHEMA_VERSION } from '../src/main/schema';
import type { Antwortoption, Frage, Katalog } from '../src/shared/katalog';
import type { Bewertung } from '../src/shared/lernstand';

const TAG_MS = 86_400_000;

// ─── Prüfkatalog ────────────────────────────────────────────────────────────

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,
  labels: readonly string[],
  korrekt: readonly string[],
): Frage {
  return {
    id,
    amtliche_nummer: id,
    kapitel,
    abschnitt,
    typ: 'mc',
    seite: 1,
    frage: { text: `Frage ${id}`, segmente: [{ t: `Frage ${id}` }] },
    bilder: [],
    optionen: labels.map((label) => option(label, korrekt.includes(label))),
  };
}

function freitextFrage(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 }] },
  };
}

const FRAGEN: readonly Frage[] = [
  mcFrage('I.1-01', 'I', 'I.1', ['a', 'b', 'c'], ['a', 'c']),
  mcFrage('I.1-02', 'I', 'I.1', ['a', 'b'], ['b']),
  freitextFrage('I.1-03', 'I', 'I.1'),
  mcFrage('I.2-01', 'I', 'I.2', ['a', 'b', 'c', 'd'], ['d']),
  freitextFrage('I.2-02', 'I', 'I.2'),
  mcFrage('II-01', 'II', null, ['a', 'b'], ['a']),
  mcFrage('II-02', 'II', null, ['a', 'b', 'c'], ['b']),
  freitextFrage('II-03', 'II', null),
  /* Kapitel IV steht hier ausschließlich für die dauerhafte Abwahl. Ohne es
     ließe sich gar nichts abwählen, und jeder Test dazu bestünde scheinbar
     und prüfte nichts. */
  mcFrage('IV-01', 'IV', null, ['a', 'b'], ['a']),
  mcFrage('IV-02', 'IV', null, ['a', 'b'], ['b']),
];

const KATALOG: Katalog = {
  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',
      abschnitte: [
        { id: 'I.1', titel: 'Begriffe des Waffenrechts' },
        { id: 'I.2', titel: 'Rechte und Pflichten' },
      ],
    },
    { id: 'II', titel: 'Waffentechnik', abschnitte: [] },
    { id: 'IV', titel: 'Not- und Seenotsignalmittel', abschnitte: [] },
  ],
  bilder: [],
  fragen: FRAGEN,
};

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

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

let uhr: Date;
let db: Database.Database;
let lernstand: Lernstand;
let profilId: number;
/** Alle in einem Testfall geöffneten Verbindungen, damit keine offen bleibt. */
let verbindungen: Database.Database[] = [];

/** Verschiebt die Uhr um ganze Tage; alle Fälligkeiten werden dagegen gerechnet. */
function tageWeiter(tage: number): void {
  uhr = new Date(uhr.getTime() + tage * TAG_MS);
}

/** Startet einen frischen Lernstand. Darf innerhalb eines Tests erneut laufen. */
function starten(zufall?: () => number): void {
  uhr = new Date(START);
  db = new Database(':memory:');
  verbindungen.push(db);
  lernstand = new Lernstand(db, KATALOG, {
    jetzt: () => uhr,
    ...(zufall === undefined ? {} : { zufall }),
  });
  profilId = lernstand.profile()[0]!.id;
}

function antworten(
  frageId: string,
  bewertung: Bewertung,
  auswahl: readonly string[] = [],
  richtig = true,
): ReturnType<Lernstand['antworten']> {
  return lernstand.antworten(profilId, {
    frageId,
    auswahl,
    richtig,
    bewertung,
    dauerMs: 1500,
  });
}

/** Abstand zwischen Antwortzeitpunkt und Wiedervorlage in Tagen. */
function faelligInTagen(faelligAb: string | null): number {
  return (new Date(faelligAb ?? 0).getTime() - uhr.getTime()) / TAG_MS;
}

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

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

// ─── Profile ────────────────────────────────────────────────────────────────

describe('Profile', () => {
  it('legt beim ersten Start automatisch das Profil „Standard“ an', () => {
    const profile = lernstand.profile();

    expect(profile).toHaveLength(1);
    expect(profile[0]?.name).toBe('Standard');
    expect(profile[0]?.pruefungstermin).toBeNull();
    expect(profile[0]?.erstelltAm).toBe(START);
  });

  it('legt weitere Profile an und behält die Reihenfolge', () => {
    lernstand.profilAnlegen('  Olaf  ');
    lernstand.profilAnlegen('Zweitprüfling');

    expect(lernstand.profile().map((p) => p.name)).toEqual(['Standard', 'Olaf', 'Zweitprüfling']);
  });

  it('weist einen doppelten Profilnamen zurück', () => {
    lernstand.profilAnlegen('Olaf');

    expect(() => lernstand.profilAnlegen('Olaf')).toThrow(/bereits ein Profil/u);
  });

  it.each([[''], ['   '], [null], [42], [{ name: 'Olaf' }]])(
    'weist den ungültigen Profilnamen %o zurück',
    (name) => {
      expect(() => lernstand.profilAnlegen(name)).toThrow();
    },
  );

  it('weist einen zu langen Profilnamen zurück', () => {
    expect(() => lernstand.profilAnlegen('x'.repeat(61))).toThrow(/höchstens 60 Zeichen/u);
  });

  it('setzt und löscht den Prüfungstermin', () => {
    expect(
      lernstand.profilAktualisieren({ id: profilId, pruefungstermin: '2026-09-30' }),
    ).toMatchObject({
      pruefungstermin: '2026-09-30',
    });

    expect(lernstand.profilAktualisieren({ id: profilId, pruefungstermin: null })).toMatchObject({
      pruefungstermin: null,
      kapitelAusschluss: [],
    });
  });

  it('lässt den Prüfungstermin unangetastet, wenn er nicht mitgeschickt wird', () => {
    lernstand.profilAktualisieren({ id: profilId, pruefungstermin: '2026-09-30' });

    const nachher = lernstand.profilAktualisieren({ id: profilId, name: 'Olaf' });

    expect(nachher).toMatchObject({ name: 'Olaf', pruefungstermin: '2026-09-30' });
  });

  it.each([
    ['30.09.2026'],
    ['2026-9-30'],
    ['morgen'],
    [20260930],
    /* Tage, die es nicht gibt. Sie sehen wie gültige ISO-Daten aus, und
       `Date.parse` rechnet sie stillschweigend in den Folgemonat um – ein
       Prüfungstermin, der sich beim Speichern verschiebt, wäre schlimmer
       als eine Abweisung. */
    ['2026-02-30'],
    ['2025-02-29'],
    ['2026-04-31'],
    ['2026-13-01'],
  ])('weist den ungültigen Prüfungstermin %o zurück', (termin) => {
    expect(() => lernstand.profilAktualisieren({ id: profilId, pruefungstermin: termin })).toThrow(
      /Prüfungstermin/u,
    );
  });

  it('nimmt einen echten Schalttag an', () => {
    // Die Korrektur darf den 29. Februar eines Schaltjahres nicht mitreißen.
    expect(
      lernstand.profilAktualisieren({ id: profilId, pruefungstermin: '2024-02-29' }),
    ).toMatchObject({ pruefungstermin: '2024-02-29' });
  });

  it.each([[999], [0], [-1], ['1'], [1.5], [null]])(
    'weist die unbekannte oder ungültige Profil-ID %o zurück',
    (id) => {
      expect(() => lernstand.uebersicht(id)).toThrow();
    },
  );
});

// ─── Antworten protokollieren ───────────────────────────────────────────────

describe('Antworten protokollieren', () => {
  it('schreibt das Protokoll und schreibt den Fragenstand fort', () => {
    const stand = antworten('I.1-01', 'gut', ['a', 'c']);

    expect(stand).toMatchObject({
      frageId: 'I.1-01',
      versuche: 1,
      richtige: 1,
      zuletztBeantwortet: START,
      gemerkt: false,
      letzteBewertung: 'gut',
    });

    const zeilen = db
      .prepare('SELECT frage_id, richtig, bewertung, dauer_ms, auswahl, freitext FROM antwort_log')
      .all() as {
      frage_id: string;
      richtig: number;
      bewertung: string;
      dauer_ms: number;
      auswahl: string;
      freitext: string | null;
    }[];

    expect(zeilen).toHaveLength(1);
    expect(zeilen[0]).toEqual({
      frage_id: 'I.1-01',
      richtig: 1,
      bewertung: 'gut',
      dauer_ms: 1500,
      auswahl: '["a","c"]',
      freitext: null,
    });
  });

  it('zählt Versuche und richtige Antworten über mehrere Durchgänge', () => {
    antworten('I.1-01', 'nochmal', ['a']);
    antworten('I.1-01', 'schwer', ['a', 'c']);
    const stand = antworten('I.1-01', 'gut', ['a', 'c']);

    expect(stand).toMatchObject({ versuche: 3, richtige: 2 });
    expect(db.prepare('SELECT COUNT(*) AS n FROM antwort_log').get()).toEqual({ n: 3 });
  });

  it('rechnet Multiple Choice selbst nach und glaubt dem Renderer nicht', () => {
    // Der Renderer behauptet „richtig“, obwohl die Auswahl unvollständig ist.
    const stand = antworten('I.1-01', 'gut', ['a'], true);

    expect(stand.richtige).toBe(0);
    expect(db.prepare('SELECT richtig FROM antwort_log').get()).toEqual({ richtig: 0 });
  });

  it('wertet eine Auswahl mit zu vielen Optionen als falsch', () => {
    expect(antworten('I.1-02', 'gut', ['a', 'b'], true).richtige).toBe(0);
    expect(antworten('I.1-02', 'gut', ['b'], false).richtige).toBe(1);
  });

  it('übernimmt bei offenen Fragen die Selbsteinschätzung', () => {
    expect(antworten('I.1-03', 'gut', [], true).richtige).toBe(1);
    expect(antworten('I.2-02', 'nochmal', [], false).richtige).toBe(0);
  });

  it('speichert den Freitext und kürzt ihn auf ein vernünftiges Maß', () => {
    lernstand.antworten(profilId, {
      frageId: 'I.1-03',
      auswahl: [],
      freitext: 'x'.repeat(5000),
      richtig: true,
      bewertung: 'gut',
      dauerMs: 10,
    });

    const zeile = db.prepare('SELECT freitext FROM antwort_log').get() as { freitext: string };
    expect(zeile.freitext).toHaveLength(4000);
  });

  it.each([
    [
      { frageId: 'gibt-es-nicht', auswahl: [], richtig: true, bewertung: 'gut', dauerMs: 1 },
      /Unbekannte Frage-ID/u,
    ],
    [
      { frageId: 'I.1-01', auswahl: ['z'], richtig: true, bewertung: 'gut', dauerMs: 1 },
      /keine Antwortoption/u,
    ],
    [
      { frageId: 'I.1-01', auswahl: [], richtig: true, bewertung: 'super', dauerMs: 1 },
      /bewertung/u,
    ],
    [{ frageId: 'I.1-01', auswahl: [], richtig: 'ja', bewertung: 'gut', dauerMs: 1 }, /richtig/u],
    [{ frageId: 'I.1-01', auswahl: [], richtig: true, bewertung: 'gut', dauerMs: -5 }, /dauerMs/u],
    [{ frageId: 'I.1-01', auswahl: 'a', richtig: true, bewertung: 'gut', dauerMs: 1 }, /auswahl/u],
    ['kein Objekt', /Antwortprotokoll/u],
  ])('weist das ungültige Protokoll %o zurück', (protokoll, muster) => {
    expect(() => lernstand.antworten(profilId, protokoll)).toThrow(muster);
  });

  it('schreibt bei einem ungültigen Protokoll überhaupt nichts', () => {
    expect(() =>
      lernstand.antworten(profilId, {
        frageId: 'I.1-01',
        auswahl: ['z'],
        richtig: true,
        bewertung: 'gut',
        dauerMs: 1,
      }),
    ).toThrow();

    expect(db.prepare('SELECT COUNT(*) AS n FROM antwort_log').get()).toEqual({ n: 0 });
    expect(db.prepare('SELECT COUNT(*) AS n FROM frage_stand').get()).toEqual({ n: 0 });
  });

  it('trennt die Lernstände verschiedener Profile', () => {
    const zweites = lernstand.profilAnlegen('Zweitprüfling');
    antworten('I.1-01', 'gut', ['a', 'c']);

    expect(lernstand.frageStand(zweites.id, 'I.1-01').versuche).toBe(0);
    expect(lernstand.frageStand(profilId, 'I.1-01').versuche).toBe(1);
  });
});

// ─── Wiedervorlage ──────────────────────────────────────────────────────────

describe('Wiedervorlage nach FSRS', () => {
  /*
   * Die Sollwerte stammen aus `data-pipeline/fsrs_referenz.py` – derselben
   * Referenzrechnung, gegen die auch `tests/fsrs.test.ts` prüft. Hier wird
   * nicht die Formel geprüft (das tut jene Datei), sondern dass der Lernstand
   * sie tatsächlich anwendet und das Ergebnis richtig in `faellig_ab`
   * umsetzt.
   */

  it.each([
    ['nochmal', 0],
    ['schwer', 1],
    ['gut', 2],
    ['leicht', 8],
  ] as const)('setzt bei „%s“ die erste Wiedervorlage auf %i Tage', (bewertung, tage) => {
    const stand = antworten('I.1-01', bewertung, ['a', 'c']);

    expect(faelligInTagen(stand.faelligAb)).toBe(tage);
    expect(stand.letzteBewertung).toBe(bewertung);
  });

  it('dehnt die Abstände, wenn jeweils zum Termin wiederholt wird', () => {
    // Der eigentliche Zweck des Verfahrens: Wer den Stoff hält, sieht ihn
    // seltener. Gerechnet wird mit dem tatsächlichen Abstand, nicht mit der
    // Zahl der Versuche.
    for (const erwartet of [2, 11, 46, 163, 180]) {
      const stand = antworten('I.1-01', 'gut', ['a', 'c']);
      expect(faelligInTagen(stand.faelligAb)).toBe(erwartet);
      tageWeiter(erwartet);
    }
  });

  it('dehnt bei „leicht“ deutlich schneller als bei „schwer“', () => {
    const leicht: number[] = [];
    for (let i = 0; i < 3; i += 1) {
      const tage = faelligInTagen(antworten('I.1-01', 'leicht', ['a', 'c']).faelligAb);
      leicht.push(tage);
      tageWeiter(tage);
    }

    starten();
    const schwer: number[] = [];
    for (let i = 0; i < 3; i += 1) {
      const tage = faelligInTagen(antworten('I.1-01', 'schwer', ['a', 'c']).faelligAb);
      schwer.push(tage);
      tageWeiter(tage);
    }

    expect(leicht).toEqual([8, 66, 180]);
    expect(schwer).toEqual([1, 3, 7]);
  });

  it('gewinnt durch Wiederholung am selben Tag nichts dazu', () => {
    /* Der wichtigste Unterschied zum früheren Verdopplungsschema: Dreimal
       hintereinander „gut“ innerhalb einer Minute ist kein Lernfortschritt,
       und das Intervall wächst deshalb auch nicht. */
    expect(faelligInTagen(antworten('I.1-01', 'gut', ['a', 'c']).faelligAb)).toBe(2);
    expect(faelligInTagen(antworten('I.1-01', 'gut', ['a', 'c']).faelligAb)).toBe(2);
    expect(faelligInTagen(antworten('I.1-01', 'gut', ['a', 'c']).faelligAb)).toBe(2);
  });

  it('macht eine Frage bei „nochmal“ sofort wieder fällig', () => {
    antworten('I.1-01', 'leicht', ['a', 'c']);
    tageWeiter(8);

    expect(faelligInTagen(antworten('I.1-01', 'nochmal', ['a']).faelligAb)).toBe(0);
  });

  it('behält nach einem Fehler den früheren Fortschritt teilweise', () => {
    /* Beide Fragen haben dieselbe jüngste Historie: erst „nochmal“, einen Tag
       später „gut“. Sie unterscheiden sich nur darin, dass die erste vorher
       schon einmal fest saß. Genau das ist der Gewinn gegenüber einem
       Verfahren, das bei einem Fehler den Zähler auf null setzt. */
    antworten('I.1-01', 'leicht', ['a', 'c']);
    tageWeiter(8);
    antworten('I.1-01', 'nochmal', ['a'], false);
    tageWeiter(1);
    const vorbelastet = faelligInTagen(antworten('I.1-01', 'gut', ['a', 'c']).faelligAb);

    starten();
    antworten('I.1-01', 'nochmal', ['a'], false);
    tageWeiter(1);
    const frisch = faelligInTagen(antworten('I.1-01', 'gut', ['a', 'c']).faelligAb);

    expect(vorbelastet).toBe(4);
    expect(frisch).toBe(2);
  });

  it('verkürzt die Abstände, wenn der Prüfungstermin nahe ist', () => {
    // Dieselbe Antwort auf dieselbe Frage – nur der Termin unterscheidet sich.
    lernstand.profilAktualisieren({ id: profilId, pruefungstermin: '2026-03-08' });
    const knapp = faelligInTagen(antworten('I.1-01', 'gut', ['a', 'c']).faelligAb);

    starten();
    const ohneTermin = faelligInTagen(antworten('I.1-01', 'gut', ['a', 'c']).faelligAb);

    expect(knapp).toBe(1);
    expect(ohneTermin).toBe(2);
  });

  it('lässt einen weit entfernten Termin die Abstände unberührt', () => {
    // Ein Termin in einem halben Jahr darf nicht dazu führen, dass von Anfang
    // an übermäßig oft wiederholt wird.
    lernstand.profilAktualisieren({ id: profilId, pruefungstermin: '2026-09-01' });

    expect(faelligInTagen(antworten('I.1-01', 'gut', ['a', 'c']).faelligAb)).toBe(2);
  });

  it('deckelt die Wiedervorlage bei 180 Tagen', () => {
    for (let i = 0; i < 8; i += 1) {
      const tage = faelligInTagen(antworten('I.1-01', 'leicht', ['a', 'c']).faelligAb);
      expect(tage).toBeLessThanOrEqual(180);
      tageWeiter(tage);
    }
  });
});

// ─── Merkliste ──────────────────────────────────────────────────────────────

describe('Merkliste', () => {
  it('merkt eine Frage vor, ohne einen Versuch zu zählen', () => {
    const stand = lernstand.merken(profilId, 'II-01', true);

    expect(stand).toMatchObject({ frageId: 'II-01', gemerkt: true, versuche: 0, faelligAb: null });
    expect(lernstand.uebersicht(profilId).gemerkt).toBe(1);
  });

  it('nimmt eine Frage wieder von der Merkliste', () => {
    lernstand.merken(profilId, 'II-01', true);

    expect(lernstand.merken(profilId, 'II-01', false).gemerkt).toBe(false);
    expect(lernstand.uebersicht(profilId).gemerkt).toBe(0);
  });

  it('behält den Antwortstand beim Merken bei', () => {
    antworten('II-01', 'gut', ['a']);

    const stand = lernstand.merken(profilId, 'II-01', true);

    expect(stand).toMatchObject({ gemerkt: true, versuche: 1, richtige: 1 });
  });

  it.each([['gibt-es-nicht'], [''], [null], [7]])('weist die Frage-ID %o zurück', (frageId) => {
    expect(() => lernstand.merken(profilId, frageId, true)).toThrow();
  });

  it('weist einen nicht-booleschen Merkwert zurück', () => {
    expect(() => lernstand.merken(profilId, 'II-01', 'ja')).toThrow(/gemerkt/u);
  });

  /*
    Der Merkzustand muss mit der Sitzung mitkommen, sonst weiß die Oberfläche
    ihn erst, nachdem die Frage beantwortet wurde. Bis 0.24.1 war das so: Der
    Stern stand zu Sitzungsbeginn an jeder Frage auf „nicht gemerkt“ – auch in
    einer Sitzung „nur Gemerkte“, in der jede einzelne Frage gemerkt ist.
  */
  it('gibt den Merkzustand mit der Sitzung heraus', () => {
    lernstand.merken(profilId, 'II-01', true);

    const sitzung = lernstand.sitzung(profilId, { mischen: false });

    expect(sitzung.find((s) => s.frageId === 'II-01')?.gemerkt).toBe(true);
    expect(sitzung.find((s) => s.frageId === 'I.1-01')?.gemerkt).toBe(false);
  });

  it('meldet in einer Sitzung „nur Gemerkte“ jede Frage als gemerkt', () => {
    lernstand.merken(profilId, 'II-01', true);
    lernstand.merken(profilId, 'I.1-01', true);

    const sitzung = lernstand.sitzung(profilId, { nurGemerkte: true, mischen: false });

    expect(sitzung.length).toBeGreaterThan(0);
    expect(sitzung.every((s) => s.gemerkt)).toBe(true);
  });
});

// ─── Übersicht ──────────────────────────────────────────────────────────────

describe('Übersicht', () => {
  it('meldet auf einem leeren Lernstand überall null', () => {
    const uebersicht = lernstand.uebersicht(profilId);

    expect(uebersicht).toMatchObject({
      fragenGesamt: 10,
      beantwortet: 0,
      belegt: 0,
      reifegrad: 0 / 10,
      stufe: 'ohne_beleg',
      faellig: 0,
      gemerkt: 0,
      /* Kein Nullwert: `offen` zählt den Katalog, nicht den Lernstand. Der
         Prüfkatalog enthält drei auszuformulierende Fragen. */
      offen: 3,
      fehler: 0,
      heuteRichtig: 0,
      heuteFalsch: 0,
      heuteBearbeitet: 0,
      tageSeitLetzterAntwort: null,
    });
  });

  it('zählt bearbeitete und fällige Fragen', () => {
    antworten('I.1-01', 'gut', ['a', 'c']); // richtig, fällig in 2 Tagen
    antworten('I.1-02', 'nochmal', ['a']); // falsch, sofort fällig
    antworten('II-01', 'leicht', ['a']); // richtig, fällig in 4 Tagen

    const uebersicht = lernstand.uebersicht(profilId);

    expect(uebersicht).toMatchObject({ beantwortet: 3, faellig: 1 });
    /* Belegt ist noch nichts: Alle drei sind zum ersten Mal aufgetaucht. */
    expect(uebersicht.belegt).toBe(0);
    expect(uebersicht.stufe).toBe('ohne_beleg');
  });

  it('belegt eine Frage erst beim Wiedersehen nach mindestens einem Tag', () => {
    /* Der Kern der Umstellung, an der echten Datenbank. Die erste richtige
       Antwort beweist nichts über das Behalten – sie kann geraten sein, und
       Wiedererkennen ist kein Erinnern. */
    antworten('I.1-01', 'gut', ['a', 'c']);
    expect(lernstand.uebersicht(profilId).belegt).toBe(0);

    // Noch am selben Tag: kein Beleg, aber auch kein Schaden.
    antworten('I.1-01', 'gut', ['a', 'c']);
    expect(lernstand.uebersicht(profilId).belegt).toBe(0);

    tageWeiter(2);
    antworten('I.1-01', 'gut', ['a', 'c']);
    expect(lernstand.uebersicht(profilId).belegt).toBe(1);
  });

  /*
    Eine als geraten eingestandene Antwort belegt nichts.

    Bis 0.22.0 hing der Beleg allein an `richtig` – und `richtig` rechnet der
    Kern aus der Auswahl nach, ohne zu wissen, ob jemand die Antwort wusste
    oder traf. Wer über „Ich hatte geraten“ die Selbsteinschätzung nachreicht,
    sagt genau das: angekreuzt war das Richtige, gewusst war es nicht. Ein
    Beleg dafür wäre eine Reifezahl auf einem Zufallstreffer – gemessen an
    einem nachgestellten Rater fiel er an 40 Prozent der Tage zu Unrecht
    (`docs/entscheidung-ratewahrscheinlichkeit.md`).
  */
  it('belegt eine richtige Antwort nicht, wenn sie als geraten gilt', () => {
    antworten('I.1-01', 'gut', ['a', 'c']);
    tageWeiter(2);

    /* Richtig angekreuzt, aber als „nicht gewusst“ eingestanden. */
    antworten('I.1-01', 'nochmal', ['a', 'c']);

    expect(lernstand.uebersicht(profilId).belegt).toBe(0);
  });

  it('nimmt einen vorhandenen Beleg dabei nicht weg', () => {
    /* Die Antwort war richtig – sie widerlegt nichts. Ein früherer Beleg
       bleibt deshalb stehen, anders als bei einer falschen Antwort. */
    antworten('I.1-01', 'gut', ['a', 'c']);
    tageWeiter(2);
    antworten('I.1-01', 'gut', ['a', 'c']);
    expect(lernstand.uebersicht(profilId).belegt).toBe(1);

    tageWeiter(2);
    antworten('I.1-01', 'nochmal', ['a', 'c']);

    expect(lernstand.uebersicht(profilId).belegt).toBe(1);
  });

  it('nimmt den Beleg bei einer falschen Antwort wieder weg', () => {
    /* Der Beleg ist eine Aussage über das Jetzt, kein Orden für früher.
       Daraus folgt die Eigenschaft, an der der erste Entwurf gescheitert
       war: Die Zahl kann durch eine falsche Antwort nie steigen. */
    antworten('I.1-01', 'gut', ['a', 'c']);
    tageWeiter(2);
    antworten('I.1-01', 'gut', ['a', 'c']);
    expect(lernstand.uebersicht(profilId).belegt).toBe(1);

    tageWeiter(20);
    const vorher = lernstand.uebersicht(profilId).reifegrad;
    antworten('I.1-01', 'nochmal', ['b'], false);
    const nachher = lernstand.uebersicht(profilId);

    expect(nachher.belegt).toBe(0);
    expect(nachher.reifegrad).toBeLessThanOrEqual(vorher);
  });

  it('lässt den Beleg mit der Zeit verfallen, ohne ihn zu löschen', () => {
    /* Die Zahl darf fallen, ohne dass jemand etwas falsch gemacht hat – das
       ist der ganze Punkt eines Gedächtnismodells. Der Beleg selbst bleibt
       aber stehen: Wer die Frage wiedersieht, fängt nicht von vorn an. */
    antworten('I.1-01', 'gut', ['a', 'c']);
    tageWeiter(2);
    antworten('I.1-01', 'gut', ['a', 'c']);
    const frisch = lernstand.uebersicht(profilId).reifegrad;

    tageWeiter(120);
    const alt = lernstand.uebersicht(profilId).reifegrad;

    expect(alt).toBeLessThan(frisch);
    expect(alt).toBeGreaterThan(0);
  });

  it('führt die Tagesbilanz nur für den heutigen Kalendertag', () => {
    antworten('I.1-01', 'gut', ['a', 'c']);
    antworten('I.1-02', 'nochmal', ['a']);

    expect(lernstand.uebersicht(profilId)).toMatchObject({ heuteRichtig: 1, heuteFalsch: 1 });

    tageWeiter(1);
    expect(lernstand.uebersicht(profilId)).toMatchObject({ heuteRichtig: 0, heuteFalsch: 0 });

    antworten('II-01', 'gut', ['a']);
    expect(lernstand.uebersicht(profilId)).toMatchObject({ heuteRichtig: 1, heuteFalsch: 0 });
  });

  it('gliedert Kapitel mit Abschnitten nach Abschnitt, andere nach Kapitel', () => {
    const bereiche = lernstand.uebersicht(profilId).bereiche;

    expect(bereiche.map((b) => b.id)).toEqual(['I.1', 'I.2', 'II', 'IV']);
    expect(bereiche.map((b) => b.titel)).toEqual([
      'Begriffe des Waffenrechts',
      'Rechte und Pflichten',
      'Waffentechnik',
      'Not- und Seenotsignalmittel',
    ]);
    expect(bereiche.map((b) => b.fragenGesamt)).toEqual([3, 2, 3, 2]);
  });

  it('rechnet den Reifegrad je Bereich aus derselben Summe wie die Gesamtzahl', () => {
    antworten('I.1-01', 'gut', ['a', 'c']);
    antworten('I.1-02', 'nochmal', ['a'], false);
    tageWeiter(2);
    antworten('I.1-01', 'gut', ['a', 'c']);

    const uebersicht = lernstand.uebersicht(profilId);
    const bereich = uebersicht.bereiche.find((b) => b.id === 'I.1');

    expect(bereich).toMatchObject({ fragenGesamt: 3, beantwortet: 2, belegt: 1 });

    /* Bereichswerte und Gesamtwert sind nicht bloß aufeinander abgestimmt,
       sondern dieselbe Summe, nur anders gruppiert. */
    const ausBereichen = uebersicht.bereiche.reduce(
      (summe, b) => summe + b.reifegrad * b.fragenGesamt,
      0,
    );
    expect(ausBereichen / uebersicht.fragenGesamt).toBeCloseTo(uebersicht.reifegrad, 10);
  });
});

// ─── Zurücksetzen ───────────────────────────────────────────────────────────

describe('Profile löschen', () => {
  it('löscht alles, was am Profil hängt', () => {
    /*
      Die eine Zeile `DELETE FROM profil` verlässt sich darauf, dass SQLite
      kaskadiert. Geprüft war bisher nur, dass das Pragma auf 1 steht – nicht,
      dass die Kindzeilen wirklich mitgehen. Genau darauf beruht aber das
      Löschen.
    */
    const zweites = lernstand.profilAnlegen('Zweitprofil');
    antworten('I.1-01', 'gut', ['a']);
    lernstand.merken(profilId, 'II-02', true);
    db.prepare(
      `INSERT INTO pruefung_lauf
         (profil_id, pruefungsprofil, zeitpunkt, gesamt, richtig, quote, urteil, dauer_ms)
       VALUES (?, 'standard', '2026-08-01T10:00:00.000Z', 10, 8, 0.8, 'bestanden', 1000)`,
    ).run(profilId);

    const zaehle = (tabelle: string, id: number): number =>
      (
        db.prepare(`SELECT COUNT(*) AS n FROM ${tabelle} WHERE profil_id = ?`).get(id) as {
          n: number;
        }
      ).n;

    expect(zaehle('antwort_log', profilId)).toBeGreaterThan(0);
    expect(zaehle('frage_stand', profilId)).toBeGreaterThan(0);
    expect(zaehle('pruefung_lauf', profilId)).toBe(1);

    const uebrig = lernstand.profilLoeschen(profilId);

    expect(uebrig.map((p) => p.id)).toEqual([zweites.id]);
    expect(zaehle('antwort_log', profilId)).toBe(0);
    expect(zaehle('frage_stand', profilId)).toBe(0);
    expect(zaehle('pruefung_lauf', profilId)).toBe(0);
  });

  it('lässt das letzte Profil stehen', () => {
    /* Ohne Profil hätte die Anwendung keinen Ort für Antworten mehr, und ein
       neues entstünde erst beim nächsten Start. */
    expect(lernstand.profile()).toHaveLength(1);

    expect(() => lernstand.profilLoeschen(profilId)).toThrow(/letzte Profil/u);
    expect(lernstand.profile()).toHaveLength(1);
  });

  it('lässt die Daten der übrigen Profile unberührt', () => {
    const zweites = lernstand.profilAnlegen('Zweitprofil');
    lernstand.merken(zweites.id, 'II-02', true);
    antworten('I.1-01', 'gut', ['a']);

    lernstand.profilLoeschen(profilId);

    expect(lernstand.frageStand(zweites.id, 'II-02').gemerkt).toBe(true);
  });

  it('weist eine unbekannte Profil-ID ab, statt still nichts zu tun', () => {
    /* Ein Löschauftrag ins Leere ist ein Fehler des Aufrufers, kein
       Erfolg. Die Prüfung liegt schon in profilIdPruefen. */
    lernstand.profilAnlegen('Zweitprofil');

    expect(() => lernstand.profilLoeschen(9999)).toThrow(/Unbekanntes Profil/u);
    expect(lernstand.profile()).toHaveLength(2);
  });
});

describe('Profile anlegen', () => {
  it('unterscheidet Namen nicht nach Groß- und Kleinschreibung', () => {
    /* In einer Liste, aus der jemand sein Profil wiedererkennen soll, sind
       „Olaf“ und „olaf“ keine Unterscheidung, sondern eine Falle. */
    lernstand.profilAnlegen('Olaf');

    expect(() => lernstand.profilAnlegen('olaf')).toThrow(/bereits ein Profil/u);
    expect(() => lernstand.profilAnlegen('OLAF')).toThrow(/bereits ein Profil/u);
  });

  it('legt beim Umbenennen denselben Maßstab an wie beim Anlegen', () => {
    /* Der Vergleich beim Umbenennen war buchstabengenau, der beim Anlegen
       nicht. Über den Umweg „anlegen, dann umbenennen" ließ sich also
       herstellen, was das Anlegen abweist. */
    lernstand.profilAnlegen('Olaf');
    const zweites = lernstand.profilAnlegen('Anna');

    expect(() => lernstand.profilAktualisieren({ id: zweites.id, name: 'olaf' })).toThrow(
      /bereits ein Profil/u,
    );
    expect(lernstand.profile().map((profil) => profil.name)).toContain('Anna');
  });

  it('lässt ein Profil auf seinen eigenen Namen umbenennen', () => {
    /* Sonst scheiterte das Ändern der Groß- und Kleinschreibung am eigenen
       Eintrag: „olaf" zu „Olaf" wäre ein Konflikt mit sich selbst. */
    const profil = lernstand.profilAnlegen('olaf');

    expect(lernstand.profilAktualisieren({ id: profil.id, name: 'Olaf' })).toMatchObject({
      name: 'Olaf',
    });
  });

  it('begrenzt die Zahl der Profile', () => {
    /* Keine technische Grenze, sondern eine gegen Versehen. */
    for (let i = lernstand.profile().length; i < 20; i += 1) {
      lernstand.profilAnlegen(`Profil ${String(i)}`);
    }

    expect(lernstand.profile()).toHaveLength(20);
    expect(() => lernstand.profilAnlegen('Eins zu viel')).toThrow(/höchstens 20/u);
  });
});

describe('Zurücksetzen', () => {
  beforeEach(() => {
    antworten('I.1-01', 'gut', ['a', 'c']);
    antworten('I.2-01', 'gut', ['d']);
    antworten('II-01', 'gut', ['a']);
    lernstand.merken(profilId, 'II-02', true);
  });

  it('löscht Stand und Historie vollständig', () => {
    const uebersicht = lernstand.zuruecksetzen(profilId, null);

    expect(uebersicht).toMatchObject({ beantwortet: 0, belegt: 0, gemerkt: 0, heuteRichtig: 0 });
    expect(db.prepare('SELECT COUNT(*) AS n FROM antwort_log').get()).toEqual({ n: 0 });
    expect(db.prepare('SELECT COUNT(*) AS n FROM frage_stand').get()).toEqual({ n: 0 });
  });

  it('löscht auch den Prüfungsverlauf', () => {
    /*
      Wer von vorn anfangen will, meint von vorn. Bliebe der Verlauf stehen,
      stünden alte Simulationsergebnisse weiter in der Auswertung und im
      Lernbericht, während der Lernstand bei null ist – zwei Zahlen, die
      sich widersprechen.
    */
    db.prepare(
      `INSERT INTO pruefung_lauf
         (profil_id, pruefungsprofil, zeitpunkt, gesamt, richtig, quote, urteil, dauer_ms)
       VALUES (?, 'standard', '2026-08-01T10:00:00.000Z', 10, 8, 0.8, 'bestanden', 1000)`,
    ).run(profilId);
    expect(db.prepare('SELECT COUNT(*) AS n FROM pruefung_lauf').get()).toEqual({ n: 1 });

    lernstand.zuruecksetzen(profilId, null);

    expect(db.prepare('SELECT COUNT(*) AS n FROM pruefung_lauf').get()).toEqual({ n: 0 });
  });

  it('lässt den Prüfungsverlauf stehen, wenn nur ein Kapitel zurückgesetzt wird', () => {
    /* Ein Lauf geht über den ganzen Bogen und lässt sich nicht kapitelweise
       herausrechnen. Ihn dann mitzulöschen wäre mehr, als verlangt war. */
    db.prepare(
      `INSERT INTO pruefung_lauf
         (profil_id, pruefungsprofil, zeitpunkt, gesamt, richtig, quote, urteil, dauer_ms)
       VALUES (?, 'standard', '2026-08-01T10:00:00.000Z', 10, 8, 0.8, 'bestanden', 1000)`,
    ).run(profilId);

    lernstand.zuruecksetzen(profilId, 'I');

    expect(db.prepare('SELECT COUNT(*) AS n FROM pruefung_lauf').get()).toEqual({ n: 1 });
  });

  it('löscht auf Wunsch nur ein Kapitel', () => {
    const uebersicht = lernstand.zuruecksetzen(profilId, 'I');

    expect(uebersicht.beantwortet).toBe(1);
    expect(lernstand.frageStand(profilId, 'I.1-01').versuche).toBe(0);
    expect(lernstand.frageStand(profilId, 'II-01').versuche).toBe(1);
    expect(lernstand.frageStand(profilId, 'II-02').gemerkt).toBe(true);
  });

  it('behält beim Kapitel-Zurücksetzen die Merkliste dieses Kapitels', () => {
    /*
      `gemerkt` steht in derselben Tabelle wie der Fortschritt. Die Zeile
      einfach zu löschen nahm deshalb auch die Markierung mit – beim
      vollständigen Zurücksetzen gewollt und angesagt, kapitelweise weder
      das eine noch das andere. Wer eine Frage als schwer markiert hat,
      will sie wiederfinden, gerade wenn er das Kapitel neu lernt.

      Der vorhandene Test „löscht auf Wunsch nur ein Kapitel" prüft dazu
      nichts: Die dort gemerkte Frage II-02 liegt außerhalb von Kapitel I
      und wurde nie angefasst.
    */
    lernstand.merken(profilId, 'I.1-01', true);
    expect(lernstand.uebersicht(profilId).gemerkt).toBe(2);

    const uebersicht = lernstand.zuruecksetzen(profilId, 'I');

    const stand = lernstand.frageStand(profilId, 'I.1-01');
    expect(stand.gemerkt).toBe(true);
    expect(stand).toMatchObject({
      versuche: 0,
      richtige: 0,
      zuletztBeantwortet: null,
      faelligAb: null,
      letzteBewertung: null,
    });

    // Die Kennzahl zählt sie weiter mit, der Fortschritt nicht.
    expect(uebersicht.gemerkt).toBe(2);
    expect(uebersicht.beantwortet).toBe(1);
  });

  it('lässt die gemerkte Frage danach in der Merkliste stehen', () => {
    /* Zahl und Liste müssen dasselbe sagen: Was in „Gemerkt: n Fragen"
       mitzählt, muss in der Sitzung „Gemerkte Fragen" auch auftauchen. */
    lernstand.merken(profilId, 'I.1-01', true);

    lernstand.zuruecksetzen(profilId, 'I');

    const sitzung = lernstand.sitzung(profilId, { nurGemerkte: true, mischen: false });
    expect(sitzung.map((f) => f.frageId)).toContain('I.1-01');
  });

  it('räumt die Zeilen der nicht gemerkten Fragen ganz weg', () => {
    /* Sonst sammelte die Tabelle bei jedem Zurücksetzen leere Zeilen an. */
    lernstand.merken(profilId, 'I.1-01', true);

    lernstand.zuruecksetzen(profilId, 'I');

    expect(
      db.prepare("SELECT COUNT(*) AS n FROM frage_stand WHERE frage_id LIKE 'I.%'").get(),
    ).toEqual({ n: 1 });
  });

  it('zurückgesetzt sieht aus wie nie beantwortet', () => {
    /*
      Der eigentliche Wächter dieser Änderung, und zwar ohne Spaltenliste
      im Test: Verglichen wird die zurückgesetzte Zeile mit einer, die nur
      gemerkt und nie beantwortet wurde. Beide müssen über `SELECT *` Feld
      für Feld gleich sein.

      Kommt `frage_stand` später eine Spalte hinzu, die das Antworten füllt
      und das Zurücksetzen vergisst, laufen die beiden Zeilen auseinander
      und dieser Test fällt um – ohne dass jemand daran gedacht hätte, ihn
      zu erweitern.
    */
    lernstand.merken(profilId, 'I.1-02', true); // Vergleichszeile: nie beantwortet
    antworten('I.1-01', 'schwer', ['a'], false);
    lernstand.merken(profilId, 'I.1-01', true);
    tageWeiter(3);

    lernstand.zuruecksetzen(profilId, 'I');

    const zeilen = db
      .prepare<[number], Record<string, unknown>>(
        `SELECT * FROM frage_stand
          WHERE profil_id = ? AND frage_id IN ('I.1-01', 'I.1-02')
          ORDER BY frage_id`,
      )
      .all(profilId);
    expect(zeilen).toHaveLength(2);

    const ohneKennung = (zeile: Record<string, unknown>): Record<string, unknown> => {
      const { frage_id: _weg, ...rest } = zeile;
      return rest;
    };
    expect(ohneKennung(zeilen[0]!)).toEqual(ohneKennung(zeilen[1]!));
  });

  it('räumt beim vollständigen Zurücksetzen auch die Merkliste', () => {
    /* Gegenprobe zum Kapitel-Fall: Hier ist das Mitlöschen gewollt, und
       die Oberfläche sagt es auch an (Neuanfang.tsx). */
    lernstand.merken(profilId, 'I.1-01', true);

    const uebersicht = lernstand.zuruecksetzen(profilId, null);

    expect(uebersicht.gemerkt).toBe(0);
    expect(lernstand.frageStand(profilId, 'I.1-01').gemerkt).toBe(false);
    expect(db.prepare('SELECT COUNT(*) AS n FROM frage_stand').get()).toEqual({ n: 0 });
  });

  it('lässt sich beliebig oft wiederholen', () => {
    for (let i = 0; i < 5; i += 1) {
      expect(lernstand.zuruecksetzen(profilId, null).beantwortet).toBe(0);
      expect(lernstand.zuruecksetzen(profilId, 'I').beantwortet).toBe(0);
    }
  });

  it('lässt andere Profile unberührt', () => {
    const zweites = lernstand.profilAnlegen('Zweitprüfling');
    lernstand.antworten(zweites.id, {
      frageId: 'I.1-01',
      auswahl: ['a', 'c'],
      richtig: true,
      bewertung: 'gut',
      dauerMs: 10,
    });

    lernstand.zuruecksetzen(profilId, null);

    expect(lernstand.frageStand(zweites.id, 'I.1-01').versuche).toBe(1);
  });

  it('weist ein unbekanntes Kapitel zurück, ohne etwas zu löschen', () => {
    expect(() => lernstand.zuruecksetzen(profilId, 'IX')).toThrow(/Unbekanntes Kapitel/u);
    expect(lernstand.uebersicht(profilId).beantwortet).toBe(3);
  });
});

// ─── Sitzungszusammenstellung ───────────────────────────────────────────────

describe('Sitzungszusammenstellung', () => {
  it('liefert ohne Filter alle Fragen in Katalogreihenfolge', () => {
    const sitzung = lernstand.sitzung(profilId, { mischen: false });

    expect(sitzung.map((s) => s.frageId)).toEqual(FRAGEN.map((f) => f.id));
  });

  it('filtert nach Kapitel', () => {
    const sitzung = lernstand.sitzung(profilId, { kapitel: ['II'], mischen: false });

    expect(sitzung.map((s) => s.frageId)).toEqual(['II-01', 'II-02', 'II-03']);
  });

  it('filtert nach Abschnitt', () => {
    const sitzung = lernstand.sitzung(profilId, { abschnitte: ['I.2'], mischen: false });

    expect(sitzung.map((s) => s.frageId)).toEqual(['I.2-01', 'I.2-02']);
  });

  it('verknüpft Kapitel- und Abschnittsfilter mit UND', () => {
    expect(
      lernstand.sitzung(profilId, { kapitel: ['I'], abschnitte: ['I.1'], mischen: false }),
    ).toHaveLength(3);
    expect(
      lernstand.sitzung(profilId, { kapitel: ['II'], abschnitte: ['I.1'], mischen: false }),
    ).toHaveLength(0);
  });

  it('liefert mit „nurGemerkte“ nur die Merkliste', () => {
    lernstand.merken(profilId, 'II-02', true);
    lernstand.merken(profilId, 'I.2-01', true);

    const sitzung = lernstand.sitzung(profilId, { nurGemerkte: true, mischen: false });

    expect(sitzung.map((s) => s.frageId)).toEqual(['I.2-01', 'II-02']);
  });

  it('liefert mit „nurNeue“ nur nie beantwortete Fragen', () => {
    antworten('I.1-01', 'gut', ['a', 'c']);
    antworten('II-01', 'gut', ['a']);

    const sitzung = lernstand.sitzung(profilId, { nurNeue: true, mischen: false });

    expect(sitzung.map((s) => s.frageId)).toEqual([
      'I.1-02',
      'I.1-03',
      'I.2-01',
      'I.2-02',
      'II-02',
      'II-03',
      'IV-01',
      'IV-02',
    ]);
  });

  /*
    Der einzige Filter, der den Katalog befragt statt den Lernstand: Der
    Fragetyp ist eine Eigenschaft der Frage, keine des Lernenden. Deshalb
    wirkt er auch auf noch nie beantwortete Fragen und braucht keine Zeile
    in `frage_stand`.
  */
  it('liefert mit „nurOffene“ nur auszuformulierende Fragen', () => {
    const sitzung = lernstand.sitzung(profilId, { nurOffene: true, mischen: false });

    expect(sitzung.map((s) => s.frageId)).toEqual(['I.1-03', 'I.2-02', 'II-03']);
  });

  it('lässt „nurOffene“ mit der Kapitelabwahl zusammenwirken', () => {
    /* Beides muss zugleich gelten – sonst legte ausgerechnet der neue Weg
       die dauerhaft abgewählten Fragen wieder vor, wie es „nur Fehler“
       früher tat. Kapitel IV enthält hier keine offene Frage; geprüft wird
       deshalb die Gegenrichtung: Ein Kapitelfilter darf den Typfilter nicht
       aushebeln. */
    const sitzung = lernstand.sitzung(profilId, {
      nurOffene: true,
      kapitel: ['I'],
      mischen: false,
    });

    expect(sitzung.map((s) => s.frageId)).toEqual(['I.1-03', 'I.2-02']);
  });

  /*
    Der Text lag seit jeher in `antwort_log.freitext` und wurde von keiner
    Abfrage gelesen – geschrieben, aufbewahrt, nie gezeigt. Sein Wert liegt
    im Wiedersehen: Wer dieselbe Frage nach zwei Wochen wiederbekommt, sieht,
    ob er heute genauer ist als damals.
  */
  it('gibt die zuletzt geschriebene Freitextantwort mit der Sitzung heraus', () => {
    lernstand.antworten(profilId, {
      frageId: 'I.1-03',
      auswahl: [],
      freitext: 'Meine erste Fassung.',
      richtig: true,
      bewertung: 'gut',
      dauerMs: 5000,
    });

    const sitzung = lernstand.sitzung(profilId, { nurOffene: true, mischen: false });
    const eintrag = sitzung.find((s) => s.frageId === 'I.1-03');

    expect(eintrag?.letzterFreitext?.text).toBe('Meine erste Fassung.');
    expect(eintrag?.letzterFreitext?.zeitpunkt).toBeTruthy();
  });

  it('nennt die jüngste Fassung, nicht die erste', () => {
    for (const text of ['Erster Versuch.', 'Zweiter Versuch.', 'Dritter Versuch.']) {
      lernstand.antworten(profilId, {
        frageId: 'I.1-03',
        auswahl: [],
        freitext: text,
        richtig: true,
        bewertung: 'gut',
        dauerMs: 5000,
      });
    }

    const sitzung = lernstand.sitzung(profilId, { nurOffene: true, mischen: false });

    expect(sitzung.find((s) => s.frageId === 'I.1-03')?.letzterFreitext?.text).toBe(
      'Dritter Versuch.',
    );
  });

  it('lässt leere Eingaben weg – das Feld darf leer bleiben', () => {
    /* Ein Kasten „Beim letzten Mal schrieben Sie“ über einer leeren Zeile
       wäre eine Vorhaltung ohne Inhalt. */
    lernstand.antworten(profilId, {
      frageId: 'I.1-03',
      auswahl: [],
      freitext: '   ',
      richtig: false,
      bewertung: 'nochmal',
      dauerMs: 1000,
    });

    const sitzung = lernstand.sitzung(profilId, { nurOffene: true, mischen: false });

    expect(sitzung.find((s) => s.frageId === 'I.1-03')?.letzterFreitext).toBeUndefined();
  });

  it('hängt keinen Freitext an Auswahlfragen', () => {
    const sitzung = lernstand.sitzung(profilId, { mischen: false, anzahl: 999 });
    const auswahlfragen = sitzung.filter((s) => s.frageId.includes('mc') || s.frageId === 'I.1-01');

    for (const eintrag of auswahlfragen) {
      expect(eintrag.letzterFreitext).toBeUndefined();
    }
  });

  it('zählt die offenen Fragen in der Übersicht mit', () => {
    /* Die Zahl an der Schaltfläche und die Sitzung dahinter müssen dasselbe
       sagen. Sie stammen aus zwei verschiedenen Wegen durch den Kern. */
    const uebersicht = lernstand.uebersicht(profilId);
    const sitzung = lernstand.sitzung(profilId, { nurOffene: true, anzahl: 999 });

    expect(uebersicht.offen).toBe(3);
    expect(sitzung).toHaveLength(uebersicht.offen);
  });

  it('liefert mit „nurFehler“ nur zuletzt falsch beantwortete Fragen', () => {
    antworten('I.1-01', 'nochmal', ['b']); // falsch
    antworten('I.1-02', 'gut', ['b']); // richtig
    antworten('II-01', 'nochmal', ['b']); // falsch
    antworten('II-01', 'gut', ['a']); // danach richtig – zählt nicht mehr

    const sitzung = lernstand.sitzung(profilId, { nurFehler: true, mischen: false });

    expect(sitzung.map((s) => s.frageId)).toEqual(['I.1-01']);
  });

  it('begrenzt die Sitzung auf die gewünschte Anzahl', () => {
    expect(lernstand.sitzung(profilId, { anzahl: 3, mischen: false })).toHaveLength(3);
    expect(lernstand.sitzung(profilId, { anzahl: 1, mischen: false })).toHaveLength(1);
  });

  it('schneidet ohne Angabe von „anzahl“ nichts ab', () => {
    expect(lernstand.sitzung(profilId, { mischen: false })).toHaveLength(FRAGEN.length);
    expect(lernstand.sitzung(profilId, { anzahl: 100, mischen: false })).toHaveLength(
      FRAGEN.length,
    );
  });

  it('stellt fällige und noch nie beantwortete Fragen nach vorn', () => {
    // I.1-01 liegt vier Tage in der Zukunft, I.1-02 wird wieder fällig.
    antworten('I.1-01', 'leicht', ['a', 'c']);
    antworten('I.1-02', 'leicht', ['b']);
    antworten('I.1-03', 'leicht', [], true);
    tageWeiter(2);
    antworten('I.1-02', 'nochmal', ['a']); // sofort wieder fällig

    const reihenfolge = lernstand.sitzung(profilId, { mischen: false }).map((s) => s.frageId);

    // Zuerst die fällige Frage und die sechs neuen, danach die beiden, die
    // noch nicht wieder dran sind.
    expect(reihenfolge.slice(0, 7)).toEqual([
      'I.1-02',
      'I.2-01',
      'I.2-02',
      'II-01',
      'II-02',
      'II-03',
      'IV-01',
    ]);
    expect(reihenfolge.slice(7)).toEqual(['IV-02', 'I.1-01', 'I.1-03']);
  });

  it('legt fällige Wiederholungen vor die neuen Fragen', () => {
    /* II-01 steht im Katalog hinter allen I-Fragen. Bis Fassung 0.20.0
       bildeten Fällige und Neue eine gemeinsame Gruppe in Katalogreihenfolge –
       die fällige Frage wäre erst an sechster Stelle gekommen. */
    antworten('II-01', 'nochmal', ['b'], false); // sofort wieder fällig

    const reihenfolge = lernstand.sitzung(profilId, { mischen: false }).map((s) => s.frageId);

    expect(reihenfolge).toEqual([
      'II-01',
      'I.1-01',
      'I.1-02',
      'I.1-03',
      'I.2-01',
      'I.2-02',
      'II-02',
      'II-03',
      'IV-01',
      'IV-02',
    ]);
  });

  it('deckelt neue Fragen auf dieselbe Rate, die der Lernplan anzeigt', () => {
    // Fünf Fragen beantwortet, fünf nie gesehen, Termin in vier Tagen:
    // Rate = ceil(5 / 4) = 2 – im Plan wie in der Sitzung.
    antworten('I.1-01', 'gut', ['a', 'c']);
    antworten('I.1-02', 'gut', ['b']);
    antworten('I.1-03', 'gut');
    antworten('I.2-01', 'gut', ['d']);
    antworten('I.2-02', 'gut');
    lernstand.profilAktualisieren({ id: profilId, pruefungstermin: '2026-03-05' });

    const plan = lernstand.lernplan(profilId);
    const sitzung = lernstand
      .sitzung(profilId, { anzahl: 5, mischen: false })
      .map((s) => s.frageId);

    expect(plan.pensum.neu).toBe(2);
    // Keine fälligen: erst die zwei neuen der Tagesration, dann bereits
    // Beantwortetes – nicht weitere neue Fragen.
    expect(sitzung).toEqual(['II-01', 'II-02', 'I.1-01', 'I.1-02', 'I.1-03']);
  });

  it('verhält sich ohne Termin wie bisher: neue Fragen bis zum Sitzungsumfang vorn', () => {
    /* Die dokumentierte Voreinstellung: Ohne Termin liegt die Rate beim
       Sitzungsumfang (20). Für die übliche 20er-Sitzung ist das kein Deckel –
       alle neuen Fragen stehen vor dem bereits Beantworteten. */
    antworten('I.1-01', 'gut', ['a', 'c']); // beantwortet, nicht fällig

    const reihenfolge = lernstand.sitzung(profilId, { mischen: false }).map((s) => s.frageId);

    expect(reihenfolge).toEqual([
      'I.1-02',
      'I.1-03',
      'I.2-01',
      'I.2-02',
      'II-01',
      'II-02',
      'II-03',
      'IV-01',
      'IV-02',
      'I.1-01',
    ]);
  });

  it('stellt am Prüfungstag neue Fragen ganz nach hinten, ohne sie zu verstecken', () => {
    /* Rate 0 am Prüfungstag (siehe `einfuehrungsrate`): Fälliges und bereits
       Beantwortetes zuerst, die neuen Fragen bilden die letzte Gruppe. Der
       Deckel ordnet, er versteckt nicht – die Sitzung bleibt vollständig. */
    antworten('IV-01', 'nochmal', ['b'], false); // sofort wieder fällig
    antworten('IV-02', 'gut', ['b']); // beantwortet, nicht fällig
    lernstand.profilAktualisieren({ id: profilId, pruefungstermin: '2026-03-01' });

    const reihenfolge = lernstand.sitzung(profilId, { mischen: false }).map((s) => s.frageId);

    expect(reihenfolge).toEqual([
      'IV-01',
      'IV-02',
      'I.1-01',
      'I.1-02',
      'I.1-03',
      'I.2-01',
      'I.2-02',
      'II-01',
      'II-02',
      'II-03',
    ]);
  });

  it('lässt „nur Neue“ auch am Prüfungstag vollständig üben', () => {
    // Wer ausdrücklich neue Fragen anfordert, bekommt sie – die Rate ordnet
    // nur die gemischte Sitzung, sie beschneidet keine ausdrückliche Wahl.
    lernstand.profilAktualisieren({ id: profilId, pruefungstermin: '2026-03-01' });

    expect(lernstand.sitzung(profilId, { nurNeue: true, mischen: false })).toHaveLength(
      FRAGEN.length,
    );
  });

  it('behält die Fragenmenge bei, wenn gemischt wird', () => {
    const ungemischt = lernstand.sitzung(profilId, { mischen: false }).map((s) => s.frageId);

    starten(() => 0); // deterministische Zufallsquelle
    const gemischt = lernstand.sitzung(profilId, { mischen: true }).map((s) => s.frageId);

    expect(gemischt).toHaveLength(ungemischt.length);
    expect([...gemischt].sort()).toEqual([...ungemischt].sort());
    expect(gemischt).not.toEqual(ungemischt);
  });

  it('mischt nur innerhalb der Gruppen, nie über ihre Grenzen', () => {
    starten(() => 0); // deterministische Zufallsquelle
    antworten('I.1-01', 'leicht', ['a', 'c']); // vier Tage Pause → Restgruppe

    const reihenfolge = lernstand.sitzung(profilId, { mischen: true }).map((s) => s.frageId);

    // Die pausierte Frage bleibt trotz Mischen hinter allen neuen.
    expect(reihenfolge.at(-1)).toBe('I.1-01');
    expect(reihenfolge).toHaveLength(10);
  });

  it('liefert die Antwortlabels in Katalogreihenfolge, wenn nicht gemischt wird', () => {
    const sitzung = lernstand.sitzung(profilId, { mischen: false });

    expect(sitzung.find((s) => s.frageId === 'I.2-01')?.optionsReihenfolge).toEqual([
      'a',
      'b',
      'c',
      'd',
    ]);
    expect(sitzung.find((s) => s.frageId === 'I.1-03')?.optionsReihenfolge).toEqual([]);
  });

  it('permutiert die Antwortlabels beim Mischen, ohne welche zu verlieren', () => {
    starten(() => 0);
    const sitzung = lernstand.sitzung(profilId, { mischen: true, optionenMischen: true });
    const labels = sitzung.find((s) => s.frageId === 'I.2-01')?.optionsReihenfolge ?? [];

    expect([...labels].sort()).toEqual(['a', 'b', 'c', 'd']);
    expect(labels).not.toEqual(['a', 'b', 'c', 'd']);
  });

  /*
    `optionenMischen` trennt die Antwortreihenfolge von der Fragenreihenfolge.
    Wer sich Antworten über ihre Stelle merkt, braucht sie fest – aber
    deswegen nicht auch noch immer dieselben Fragen zuerst.
  */
  it('lässt die Antwortlabels stehen, während die Fragen gemischt werden', () => {
    starten(() => 0);
    const sitzung = lernstand.sitzung(profilId, { mischen: true, optionenMischen: false });

    expect(sitzung.find((s) => s.frageId === 'I.2-01')?.optionsReihenfolge).toEqual([
      'a',
      'b',
      'c',
      'd',
    ]);
  });

  it('mischt die Antwortlabels, während die Fragen in Katalogreihenfolge bleiben', () => {
    starten(() => 0);
    const sitzung = lernstand.sitzung(profilId, { mischen: false, optionenMischen: true });
    const labels = sitzung.find((s) => s.frageId === 'I.2-01')?.optionsReihenfolge ?? [];

    expect([...labels].sort()).toEqual(['a', 'b', 'c', 'd']);
    expect(labels).not.toEqual(['a', 'b', 'c', 'd']);
  });

  it('bleibt ohne eigene Angabe in Katalogreihenfolge – auch beim Fragenmischen', () => {
    /* Der eigentliche Regressionsschutz. Früher fiel `optionenMischen` auf
       `mischen` zurück: Wer nur die Fragen mischen wollte, mischte die
       Antworten stillschweigend mit – die Ursache des Befundes, der zu
       dieser Umstellung geführt hat. Beide Fälle müssen jetzt dieselbe,
       amtliche Reihenfolge liefern. */
    starten(() => 0);

    for (const filter of [{ mischen: false }, { mischen: true }]) {
      const ohne = lernstand.sitzung(profilId, filter);

      expect(ohne.find((s) => s.frageId === 'I.2-01')?.optionsReihenfolge).toEqual([
        'a',
        'b',
        'c',
        'd',
      ]);
    }
  });

  it.each([
    [{ kapitel: ['IX'] }, /Unbekanntes Kapitel/u],
    [{ abschnitte: ['I.9'] }, /Unbekannter Abschnitt/u],
    [{ kapitel: 'I' }, /kapitel/u],
    [{ anzahl: 0 }, /anzahl/u],
    [{ anzahl: 5000 }, /anzahl/u],
    [{ anzahl: 2.5 }, /anzahl/u],
    [{ nurNeue: 'ja' }, /nurNeue/u],
    ['kein Objekt', /Filter/u],
  ])('weist den ungültigen Filter %o zurück', (filter, muster) => {
    expect(() => lernstand.sitzung(profilId, filter)).toThrow(muster);
  });

  it('nimmt einen fehlenden Filter als „alles, gemischt“ an', () => {
    expect(lernstand.sitzung(profilId, undefined)).toHaveLength(10);
    expect(lernstand.sitzung(profilId, {})).toHaveLength(10);
  });
});

// ─── Migration auf Schema 3 ─────────────────────────────────────────────────

describe('Migration auf den Gedächtnisstand', () => {
  /** Spaltennamen einer Tabelle. */
  function spalten(verbindung: Database.Database, tabelle: string): string[] {
    return verbindung
      .prepare<[], { name: string }>(`PRAGMA table_info(${tabelle})`)
      .all()
      .map((z) => z.name);
  }

  /**
   * Versetzt eine geöffnete Datenbank in den Stand vor der FSRS-Umstellung:
   * ohne die beiden Spalten und mit vermerkter Schema-Version 2.
   */
  function aufVersionZwei(verbindung: Database.Database): void {
    /* SQLite kann Spalten erst seit 3.35 löschen – vorhanden, aber der
       ausdrückliche Weg über eine neue Tabelle ist hier ohnehin näher an dem,
       was eine echte alte Installation enthält. */
    verbindung.exec(`
      CREATE TABLE frage_stand_alt (
        profil_id           INTEGER NOT NULL,
        frage_id            TEXT    NOT NULL,
        versuche            INTEGER NOT NULL DEFAULT 0,
        richtige            INTEGER NOT NULL DEFAULT 0,
        zuletzt_beantwortet TEXT,
        faellig_ab          TEXT,
        gemerkt             INTEGER NOT NULL DEFAULT 0,
        letzte_bewertung    TEXT,
        intervall_tage      REAL    NOT NULL DEFAULT 0,
        PRIMARY KEY (profil_id, frage_id)
      );
      INSERT INTO frage_stand_alt
        SELECT profil_id, frage_id, versuche, richtige, zuletzt_beantwortet,
               faellig_ab, gemerkt, letzte_bewertung, intervall_tage
          FROM frage_stand;
      DROP TABLE frage_stand;
      ALTER TABLE frage_stand_alt RENAME TO frage_stand;
    `);
    verbindung.prepare('DELETE FROM schema_version WHERE version > 2').run();
  }

  it('ergänzt die Spalten, ohne einen bestehenden Lernstand zu verlieren', () => {
    antworten('I.1-01', 'gut', ['a', 'c']);
    lernstand.merken(profilId, 'I.1-02', true);
    const vorher = lernstand.frageStand(profilId, 'I.1-01');

    aufVersionZwei(db);
    expect(spalten(db, 'frage_stand')).not.toContain('stabilitaet');

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

    expect(spalten(db, 'frage_stand')).toContain('stabilitaet');
    expect(spalten(db, 'frage_stand')).toContain('schwierigkeit');
    expect(
      db
        .prepare<[], { version: number }>('SELECT MAX(version) AS version FROM schema_version')
        .get()?.version,
    ).toBe(SCHEMA_VERSION);

    expect(nachher.frageStand(profilId, 'I.1-01')).toMatchObject({
      versuche: vorher.versuche,
      richtige: vorher.richtige,
      faelligAb: vorher.faelligAb,
    });
    expect(nachher.frageStand(profilId, 'I.1-02').gemerkt).toBe(true);
  });

  it('nimmt eine Frage ohne Gedächtnisstand als neu, ohne den Zähler zu verlieren', () => {
    // Eine Frage aus der Zeit vor der Umstellung hat Versuche, aber keine
    // Stabilität. Sie darf deshalb nicht als „nie beantwortet“ verschwinden.
    antworten('I.1-01', 'leicht', ['a', 'c']);
    aufVersionZwei(db);

    const nachher = new Lernstand(db, KATALOG, { jetzt: () => uhr });
    const stand = nachher.antworten(profilId, {
      frageId: 'I.1-01',
      auswahl: ['a', 'c'],
      richtig: true,
      bewertung: 'gut',
      dauerMs: 1000,
    });

    expect(stand.versuche).toBe(2);
    expect(stand.richtige).toBe(2);
    // Anfangsstabilität für „gut“ sind 2,3 Tage – aufgerundet zwei.
    expect(faelligInTagen(stand.faelligAb)).toBe(2);
  });

  it('lässt sich beliebig oft öffnen, auch wenn die Version zurückgesetzt wurde', () => {
    /* Ein Schritt, der beim zweiten Lauf scheitert, würde die Anwendung beim
       Start abstürzen lassen – ohne Weg zurück für den Nutzer. */
    antworten('I.1-01', 'gut', ['a', 'c']);

    for (let i = 0; i < 3; i += 1) {
      db.prepare('DELETE FROM schema_version WHERE version > 1').run();
      expect(() => new Lernstand(db, KATALOG, { jetzt: () => uhr })).not.toThrow();
    }

    expect(new Lernstand(db, KATALOG, { jetzt: () => uhr }).uebersicht(profilId).beantwortet).toBe(
      1,
    );
  });

  it('weist eine Datenbank aus einer neueren Programmversion ab', () => {
    db.prepare('INSERT OR IGNORE INTO schema_version (version, angewendet_am) VALUES (?, ?)').run(
      SCHEMA_VERSION + 1,
      new Date().toISOString(),
    );

    expect(() => new Lernstand(db, KATALOG, { jetzt: () => uhr })).toThrow(
      /neueren Programmversion/u,
    );
  });

  it('fasst einen Lernstand aus neuerer Version gar nicht erst an', () => {
    /* Die neuere Fassung muss ihn danach noch öffnen können. Würde erst das
       Schema angewandt und dann abgewiesen, hätte die ältere Fassung bereits
       hineingeschrieben – WAL-Modus eingeschaltet, Tabellen angelegt. */
    const fremd = new Database(':memory:');
    verbindungen.push(fremd);
    fremd.exec(`
      CREATE TABLE schema_version (version INTEGER NOT NULL PRIMARY KEY, angewendet_am TEXT);
      INSERT INTO schema_version VALUES (${String(SCHEMA_VERSION + 1)}, '2026-01-01');
    `);

    expect(() => new Lernstand(fremd, KATALOG, { jetzt: () => uhr })).toThrow(
      /neueren Programmversion/u,
    );

    const tabellen = fremd
      .prepare<[], { name: string }>("SELECT name FROM sqlite_master WHERE type = 'table'")
      .all()
      .map((z) => z.name);
    expect(tabellen).toEqual(['schema_version']);
  });

  it('nimmt eine noch leere Datei an, in der es nichts zu schützen gibt', () => {
    const leer = new Database(':memory:');
    verbindungen.push(leer);

    expect(() => new Lernstand(leer, KATALOG, { jetzt: () => uhr })).not.toThrow();
  });
});

// ─── Katalogstand-Abgleich (Schema 9) ───────────────────────────────────────

describe('Katalogstand-Abgleich', () => {
  /*
    Eine neue BVA-Fassung kann Fragen umnummerieren oder streichen; Zeilen in
    `frage_stand` und `antwort_log` zeigten dann still ins Leere. Ein echter
    Migrationspfad ist ohne die künftige Fassung nicht baubar – was sich bauen
    lässt, ist Ehrlichkeit: erkennen, beziffern, melden, nichts löschen.
  */

  /** Derselbe Katalog mit anderem Stand und (wahlweise) anderer Fragenliste. */
  function katalogFassung(stand: string, fragen: readonly Frage[] = FRAGEN): Katalog {
    return {
      ...KATALOG,
      meta: { ...KATALOG.meta, stand, fragen_gesamt: fragen.length },
      fragen,
    };
  }

  it('schweigt, solange der Stand unverändert ist', () => {
    expect(lernstand.katalogwechsel).toBeNull();

    const zweite = new Lernstand(db, KATALOG, { jetzt: () => uhr });

    expect(zweite.katalogwechsel).toBeNull();
    /* Und es entsteht kein zweiter Vermerk – die Tabelle wüchse sonst mit
       jedem Start, und ihr Verlauf sagte nichts mehr. */
    expect(db.prepare<[], { n: number }>('SELECT COUNT(*) AS n FROM katalog_stand').get()?.n).toBe(
      1,
    );
  });

  it('meldet einen Wechsel und zählt die verwaisten Zeilen', () => {
    /* Zwei Antworten auf I.1-01, eine auf II-01, eine Merkzeile zu IV-01. */
    antworten('I.1-01', 'gut', ['a', 'c']);
    tageWeiter(1);
    antworten('I.1-01', 'gut', ['a', 'c']);
    antworten('II-01', 'gut', ['a']);
    lernstand.merken(profilId, 'IV-01', true);

    /* Die neue Fassung kennt I.1-01 und IV-01 nicht mehr. */
    const neueFragen = FRAGEN.filter((f) => f.id !== 'I.1-01' && f.id !== 'IV-01');
    const zweite = new Lernstand(db, katalogFassung('2025-06-01', neueFragen), {
      jetzt: () => uhr,
    });

    expect(zweite.katalogwechsel).toEqual({
      vorher: '2024-12-16',
      nachher: '2025-06-01',
      // I.1-01 (beantwortet) und IV-01 (nur gemerkt) – je eine Standzeile.
      verwaisteStaende: 2,
      // Die beiden Protokollzeilen zu I.1-01; II-01 gibt es weiterhin.
      verwaisteAntworten: 2,
    });
  });

  it('löscht die verwaisten Zeilen nicht', () => {
    antworten('I.1-01', 'gut', ['a', 'c']);

    const neueFragen = FRAGEN.filter((f) => f.id !== 'I.1-01');
    void new Lernstand(db, katalogFassung('2025-06-01', neueFragen), { jetzt: () => uhr });

    /* Eine spätere Programmfassung kann die Zeilen vielleicht noch zuordnen –
       gelöscht kann sie es sicher nicht mehr. */
    expect(
      db
        .prepare<[], { n: number }>(
          "SELECT COUNT(*) AS n FROM antwort_log WHERE frage_id = 'I.1-01'",
        )
        .get()?.n,
    ).toBe(1);
    expect(
      db
        .prepare<[], { n: number }>(
          "SELECT COUNT(*) AS n FROM frage_stand WHERE frage_id = 'I.1-01'",
        )
        .get()?.n,
    ).toBe(1);
  });

  it('übernimmt den neuen Stand nach dem Befund und meldet ihn danach nicht erneut', () => {
    const fassung = katalogFassung('2025-06-01');

    const zweite = new Lernstand(db, fassung, { jetzt: () => uhr });
    expect(zweite.katalogwechsel).not.toBeNull();

    /* Der Wechsel ist jetzt vermerkt: Der nächste Start findet Gleichstand
       vor und schweigt – die Meldung kommt genau einmal. */
    const dritte = new Lernstand(db, fassung, { jetzt: () => uhr });
    expect(dritte.katalogwechsel).toBeNull();
  });

  it('meldet auch einen Wechsel, bei dem nichts verwaist', () => {
    /* Gleiche Fragen, neuer Stand – etwa eine Fassung, die nur Druckfehler
       berichtigt. Der Wechsel bleibt meldenswert, die Zählungen sagen dann
       ehrlich: nichts betroffen. */
    antworten('I.1-01', 'gut', ['a', 'c']);

    const zweite = new Lernstand(db, katalogFassung('2025-06-01'), { jetzt: () => uhr });

    expect(zweite.katalogwechsel).toEqual({
      vorher: '2024-12-16',
      nachher: '2025-06-01',
      verwaisteStaende: 0,
      verwaisteAntworten: 0,
    });
  });
});

describe('Dauerhaft abgewählte Kapitel', () => {
  /*
    Kapitel IV („Not- und Seenotsignalmittel") prüft nicht jede
    Prüfungsstelle. Wer es nie braucht, schleppte 89 der 575 Fragen durch jede
    Zahl der Anwendung: Fortschritt, Tagespensum, Prognose, Prüfungsreife.

    Die Kartierung vor dem Bau hat gewarnt, dass eine grüne Suite hier fast
    nichts beweist – die meisten Stellen mit „575" sind Attrappenwerte. Diese
    Tests laufen deshalb durch den echten Kern, gegen eine echte Datenbank.
  */
  it('beginnt ohne Abwahl', () => {
    expect(lernstand.profile()[0]?.kapitelAusschluss).toEqual([]);
    expect(lernstand.uebersicht(profilId).fragenGesamt).toBe(10);
  });

  it('nimmt abgewählte Fragen aus jeder Lernsitzung', () => {
    lernstand.profilAktualisieren({ id: profilId, kapitelAusschluss: ['IV'] });

    const alle = lernstand.sitzung(profilId, { mischen: false }).map((s) => s.frageId);
    expect(alle).toHaveLength(8);
    expect(alle.some((id) => id.startsWith('IV-'))).toBe(false);
  });

  it('nimmt sie auch aus „nur neue" und „nur gemerkte"', () => {
    lernstand.merken(profilId, 'IV-01', true);
    lernstand.profilAktualisieren({ id: profilId, kapitelAusschluss: ['IV'] });

    expect(lernstand.sitzung(profilId, { nurNeue: true }).map((s) => s.frageId)).not.toContain(
      'IV-01',
    );
    expect(lernstand.sitzung(profilId, { nurGemerkte: true })).toEqual([]);
  });

  it('nimmt sie aus „nur Fehler" – gerade dort ist es nötig', () => {
    /*
      Die Quelle von „nur Fehler" ist antwort_log, und dort landen auch
      Antworten aus Prüfungsläufen, die Kapitel IV enthielten. Ohne
      Vorfilterung legte ausgerechnet dieser Weg die abgewählten Fragen wieder
      vor – und zwar genau die, die man zwangsläufig falsch hatte.
    */
    antworten('IV-01', 'nochmal', ['b'], false);
    expect(lernstand.sitzung(profilId, { nurFehler: true }).map((s) => s.frageId)).toContain(
      'IV-01',
    );

    lernstand.profilAktualisieren({ id: profilId, kapitelAusschluss: ['IV'] });
    expect(lernstand.sitzung(profilId, { nurFehler: true })).toEqual([]);
  });

  it('rechnet die Übersicht ohne sie – Nenner UND Zähler', () => {
    /* Nur den Nenner zu filtern wäre der teure Fehler: Ein Bildschirmleser
       läse dann „10 von 8 Fragen sicher". */
    antworten('IV-01', 'leicht', ['a'], true);
    tageWeiter(2);
    antworten('IV-01', 'leicht', ['a'], true);
    expect(lernstand.uebersicht(profilId)).toMatchObject({ fragenGesamt: 10, belegt: 1 });

    lernstand.profilAktualisieren({ id: profilId, kapitelAusschluss: ['IV'] });
    const nachher = lernstand.uebersicht(profilId);
    expect(nachher.fragenGesamt).toBe(8);
    expect(nachher.belegt).toBe(0);
    expect(nachher.beantwortet).toBe(0);
  });

  it('lässt das Kapitel aus der Bereichsaufschlüsselung verschwinden', () => {
    /* Bliebe es mit 0 stehen, summierten sich die Bereiche zu 10, während
       fragenGesamt 8 sagt – zwei Zahlen auf einem Bildschirm, die einander
       widersprechen. */
    lernstand.profilAktualisieren({ id: profilId, kapitelAusschluss: ['IV'] });
    const bereiche = lernstand.uebersicht(profilId).bereiche;

    expect(bereiche.map((b) => b.id)).toEqual(['I.1', 'I.2', 'II']);
    expect(bereiche.reduce((summe, b) => summe + b.fragenGesamt, 0)).toBe(
      lernstand.uebersicht(profilId).fragenGesamt,
    );
  });

  it('rechnet den Lernplan ohne sie', () => {
    const vorher = lernstand.lernplan(profilId);
    expect(vorher.gesamtFragen).toBe(10);

    lernstand.profilAktualisieren({ id: profilId, kapitelAusschluss: ['IV'] });
    const nachher = lernstand.lernplan(profilId);

    /* ENTFERNT, nicht neutralisiert: Wer die Fragen mit stabilitaet null
       durchreichte, ließe sie im Nenner von reifegradVon stehen – die
       Prognose bliebe exakt gleich, und die Zahl sähe trotzdem plausibel aus. */
    expect(nachher.gesamtFragen).toBe(8);
    expect(nachher.nieBeantwortet).toBe(8);
  });

  it('blendet aus, statt zu löschen – und alles kehrt zurück', () => {
    antworten('IV-01', 'leicht', ['a'], true);
    tageWeiter(2);
    antworten('IV-01', 'leicht', ['a'], true);
    lernstand.merken(profilId, 'IV-02', true);

    lernstand.profilAktualisieren({ id: profilId, kapitelAusschluss: ['IV'] });
    expect(lernstand.uebersicht(profilId).belegt).toBe(0);

    lernstand.profilAktualisieren({ id: profilId, kapitelAusschluss: [] });
    const zurueck = lernstand.uebersicht(profilId);
    expect(zurueck.fragenGesamt).toBe(10);
    expect(zurueck.belegt).toBe(1);
    expect(zurueck.gemerkt).toBe(1);
  });

  it('gilt je Profil, nicht für die Anwendung', () => {
    const zweites = lernstand.profilAnlegen('Wassersportlerin');

    lernstand.profilAktualisieren({ id: profilId, kapitelAusschluss: ['IV'] });

    expect(lernstand.uebersicht(profilId).fragenGesamt).toBe(8);
    expect(lernstand.uebersicht(zweites.id).fragenGesamt).toBe(10);
  });

  it('weist ein unbekanntes Kapitel ab', () => {
    expect(() =>
      lernstand.profilAktualisieren({ id: profilId, kapitelAusschluss: ['XI'] }),
    ).toThrow(/Unbekanntes Kapitel/u);
    expect(() => lernstand.profilAktualisieren({ id: profilId, kapitelAusschluss: 'IV' })).toThrow(
      /muss eine Liste sein/u,
    );
  });

  it('speichert in Katalogreihenfolge und ohne Doppelte', () => {
    /* Was gespeichert wird, soll nicht davon abhängen, in welcher Reihenfolge
       jemand geklickt hat. */
    const profil = lernstand.profilAktualisieren({
      id: profilId,
      kapitelAusschluss: ['IV', 'II', 'IV'],
    });
    expect(profil.kapitelAusschluss).toEqual(['II', 'IV']);
  });

  it('rührt Zurücksetzen und Tagesbilanz nicht an', () => {
    /* Die Tagesbilanz ist ein Protokoll dessen, was jemand getan hat – keine
       Bestandszahl. Wer heute eine Kapitel-IV-Frage beantwortet und danach
       abwählt, sieht sie dort weiterhin. */
    antworten('IV-01', 'leicht', ['a'], true);
    lernstand.profilAktualisieren({ id: profilId, kapitelAusschluss: ['IV'] });

    expect(lernstand.uebersicht(profilId).heuteRichtig).toBe(1);
    expect(() => lernstand.zuruecksetzen(profilId, 'IV')).not.toThrow();
  });
});

describe('Tagesbilanz und Tageszahlen', () => {
  /*
    Die Tagesbilanz war verzerrt: Lief in der Prüfungssimulation die Zeit ab,
    buchte `abgeben` auch die nie aufgeschlagenen Fragen in die Historie – als
    falsch. Sie gehören dorthin, der Bogen enthielt sie ja; eine Antwort sind
    sie nicht.
  */
  it('zählt nie aufgeschlagene Prüfungsfragen nicht als heute falsch beantwortet', () => {
    antworten('I.1-01', 'gut', ['a', 'c']);

    /* So bucht `Pruefung.abgeben` eine Frage, die im Bogen stand, aber nie
       gezeigt wurde. */
    lernstand.protokollieren(profilId, {
      frageId: 'I.1-02',
      auswahl: [],
      richtig: false,
      bewertung: 'nochmal',
      dauerMs: 1000,
    });

    const uebersicht = lernstand.uebersicht(profilId);

    expect(uebersicht.heuteRichtig).toBe(1);
    expect(uebersicht.heuteFalsch).toBe(0);
  });

  /*
    Dieselbe Verunreinigung, drei Stellen weiter: `letzteAntwortKarte` filterte
    `nur_historie` nicht heraus. Die Folge war eine Zahl, ein Filter und ein
    gedrucktes Blatt, die alle dasselbe behaupteten – der Lernende habe eine
    Frage falsch beantwortet, die er nie gesehen hat. Ein 80-Fragen-Bogen mit
    Zeitablauf nach Frage 20 erzeugte so 60 Fehler.
  */
  it('stellt eine heute Abend fällige Frage unter „heute“, nicht unter „morgen“', () => {
    /*
      Die Vorschau beschriftet ihre Spalten als Kalendertage („heute“,
      „morgen“, danach Wochentag und Datum). Bis 0.24.1 rechnete sie in
      Vierundzwanzig-Stunden-Blöcken: Wer abends lernte und morgens plante,
      sah jeden Tag die Last des Vortages.

      Hier nachgestellt: Antwort am Abend, Wiedervorlage am Folgetag zur
      selben Stunde – geplant wird am Morgen dieses Folgetags.
    */
    uhr = new Date('2026-03-02T20:00:00.000Z');
    const stand = antworten('I.1-01', 'gut', ['a', 'c']);
    const termin = new Date(stand.faelligAb ?? '');
    expect(Number.isNaN(termin.getTime())).toBe(false);

    /* Am Morgen des Fälligkeitstages: Die Frage wird heute Abend fällig. */
    uhr = new Date(termin.getFullYear(), termin.getMonth(), termin.getDate(), 8, 0, 0);
    expect(uhr.getTime()).toBeLessThan(termin.getTime());
    const plan = lernstand.lernplan(profilId);

    expect(plan.vorschau?.[0], 'Die heute fällige Frage steht nicht in der Spalte „heute“.').toBe(
      1,
    );
    expect(plan.vorschau?.[1]).toBe(0);
  });

  it('lässt nie aufgeschlagene Prüfungsfragen aus der Tempo-Schätzung heraus', () => {
    /*
      `dauer_ms` einer Historienzeile ist keine gemessene Zeit, sondern
      Gesamtdauer geteilt durch Fragenzahl. Bis 0.24.1 ging sie in den Median
      ein und verschob die Zeitschätzung des Tagespensums.
    */
    for (const frage of ['I.1-01', 'I.1-02', 'I.1-03', 'I.2-01', 'I.2-02']) {
      lernstand.antworten(profilId, {
        frageId: frage,
        auswahl: [],
        richtig: true,
        bewertung: 'gut',
        dauerMs: 10_000,
      });
    }
    const echtesTempo = lernstand.lernplan(profilId).sekundenProFrage;

    /* Sechzig erfundene Werte von je einer Sekunde – so sieht ein
       abgelaufener Bogen aus. */
    for (let i = 0; i < 60; i++) {
      lernstand.protokollieren(profilId, {
        frageId: 'II-01',
        auswahl: [],
        richtig: false,
        bewertung: 'nochmal',
        dauerMs: 1000,
      });
    }

    expect(lernstand.lernplan(profilId).sekundenProFrage).toBe(echtesTempo);
  });

  it('zählt nie aufgeschlagene Prüfungsfragen nicht als „zuletzt falsch beantwortet“', () => {
    antworten('I.1-01', 'nochmal', ['b']); // wirklich falsch beantwortet

    lernstand.protokollieren(profilId, {
      frageId: 'I.1-02',
      auswahl: [],
      richtig: false,
      bewertung: 'nochmal',
      dauerMs: 1000,
    });

    expect(lernstand.uebersicht(profilId).fehler).toBe(1);
    expect(
      lernstand.sitzung(profilId, { nurFehler: true, mischen: false }).map((s) => s.frageId),
    ).toEqual(['I.1-01']);
  });

  it('lässt eine echte spätere Antwort auf dieselbe Frage weiterhin gelten', () => {
    /* Die Gegenprobe: Der Ausschluss darf nur Historienzeilen treffen, nicht
       die Frage stumm schalten. */
    lernstand.protokollieren(profilId, {
      frageId: 'I.1-01',
      auswahl: [],
      richtig: false,
      bewertung: 'nochmal',
      dauerMs: 1000,
    });
    antworten('I.1-01', 'nochmal', ['b']);

    expect(
      lernstand.sitzung(profilId, { nurFehler: true, mischen: false }).map((s) => s.frageId),
    ).toEqual(['I.1-01']);
  });

  it('lässt die Historie trotzdem vollständig', () => {
    /* Der Eintrag verschwindet nicht – er zählt nur nicht als Antwort des
       Tages. Die Historie ist die Grundlage für Statistik und für ein
       späteres Nachtrainieren der FSRS-Parameter. */
    lernstand.protokollieren(profilId, {
      frageId: 'I.1-02',
      auswahl: [],
      richtig: false,
      bewertung: 'nochmal',
      dauerMs: 1000,
    });

    const zeilen = db
      .prepare<[], { anzahl: number }>('SELECT COUNT(*) AS anzahl FROM antwort_log')
      .get();
    expect(zeilen?.anzahl).toBe(1);
  });

  it('zählt heute bearbeitete Fragen einmal, nicht je Antwort', () => {
    /*
      Eine mit „Nicht gewusst“ bewertete Frage ist sofort wieder fällig und
      wird in derselben Sitzung noch einmal beantwortet. Als Zeilen im
      Protokoll wären das zwei; bearbeitet wurde eine Frage.
    */
    antworten('I.1-01', 'nochmal', ['b'], false);
    antworten('I.1-01', 'gut', ['a', 'c']);
    antworten('I.1-02', 'gut', ['a']);

    expect(lernstand.uebersicht(profilId).heuteBearbeitet).toBe(2);
  });

  it('rechnet den Abstand in Kalendertagen, nicht in Stunden', () => {
    /* Wer gestern abend und heute früh lernt, hat nicht zwei Tage Abstand. */
    antworten('I.1-01', 'gut', ['a', 'c']);
    expect(lernstand.uebersicht(profilId).tageSeitLetzterAntwort).toBe(0);

    tageWeiter(1);
    expect(lernstand.uebersicht(profilId).tageSeitLetzterAntwort).toBe(1);

    tageWeiter(13);
    expect(lernstand.uebersicht(profilId).tageSeitLetzterAntwort).toBe(14);
    expect(lernstand.uebersicht(profilId).heuteBearbeitet).toBe(0);
  });

  it('meldet „nie beantwortet“ als null und nicht als null Tage', () => {
    /* Der Unterschied zwischen „heute gelernt“ und „noch nie gelernt“ darf
       nicht verlorengehen – beides wäre sonst die Zahl 0. */
    expect(lernstand.uebersicht(profilId).tageSeitLetzterAntwort).toBeNull();
  });
});
