Add tests, usage documentation, and .claude/ to .gitignore.

Unit tests for the gii-norm loader, text cleanup, quote extraction,
command recognition (literal sentences from the sample amendment act),
sentence splitting, and command application on a synthetic mini law;
an HTML renderer test including escaping; and an end-to-end smoke test
on the IfSG sample data that skips itself when the (untracked) sample
files are absent.  The README gains a German usage section.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Change-Id: I7f02ff349f9c3bd34fa90a2a84678b9e6fad697d
diff --git a/.gitignore b/.gitignore
index 90af5ea..8d936de 100644
--- a/.gitignore
+++ b/.gitignore
@@ -39,3 +39,6 @@
 
 # Unknown
 out/
+
+# Claude Code
+.claude/
diff --git a/README.adoc b/README.adoc
index 1e3252d..ad512f6 100644
--- a/README.adoc
+++ b/README.adoc
@@ -51,6 +51,46 @@
 ----
 
 
+[[usage]]
+== Usage
+
+ÄndGgner erzeugt aus einem Stammgesetz und einem Änderungsgesetz eine
+zweispaltige HTML-Synopse (alte Fassung links, neue rechts, Änderungen
+wortweise hervorgehoben).
+
+Eingaben:
+
+* Stammgesetz: XML im gii-norm-Format von
+  https://www.gesetze-im-internet.de/[gesetze-im-internet.de]
+* Änderungsgesetz: BGBl-PDF oder Klartext
+
+[source,shell script]
+----
+java -jar target/aendggner-0.1.0-SNAPSHOT.jar \
+  stammgesetz.xml aenderungsgesetz.pdf -o synopse.html
+----
+
+Wichtige Optionen:
+
+`-o, --output <file>`::
+  Ausgabedatei (Default `synopse.html`; `-` = Standardausgabe).
+`--vollstaendig`::
+  Auch unveränderte Normen in die Synopse aufnehmen.
+`--artikel <n>`::
+  Nur diesen Artikel des Änderungsgesetzes anwenden (Default: alle
+  Artikel, deren Einleitung das Stammgesetz nennt).
+`--extract-only`::
+  Nur den bereinigten Lineartext des Änderungsgesetzes ausgeben. Nützlich,
+  wenn die PDF-Extraktion fehlerhaft ist: Text prüfen, von Hand
+  korrigieren und als Klartextdatei wieder einspeisen.
+
+Erkannt werden die gebräuchlichsten Änderungsbefehle des Handbuchs der
+Rechtsförmlichkeit (Ersetzen, Neufassung, Einfügen, Anfügen, Aufheben,
+Streichen, Umnummerierung). Alles andere — etwa Bereichsbefehle wie
+„Die Absätze 2 bis 4 werden …“ — landet mit Begründung im Abschnitt
+*Manuell prüfen* der Synopse und wird niemals stillschweigend verworfen.
+
+
 == Running the Tests
 
 To build and run the tests:
diff --git a/src/test/java/eu/mulk/aendggner/EndToEndTest.java b/src/test/java/eu/mulk/aendggner/EndToEndTest.java
new file mode 100644
index 0000000..57e745f
--- /dev/null
+++ b/src/test/java/eu/mulk/aendggner/EndToEndTest.java
@@ -0,0 +1,72 @@
+package eu.mulk.aendggner;
+
+import static org.assertj.core.api.Assertions.assertThat;
+import static org.junit.jupiter.api.Assumptions.assumeTrue;
+
+import eu.mulk.aendggner.aenderung.Aenderungsbefehl.UnbekannterBefehl;
+import eu.mulk.aendggner.aenderung.parse.AenderungsgesetzParser;
+import eu.mulk.aendggner.aenderung.parse.PatchTextExtraktor;
+import eu.mulk.aendggner.aenderung.parse.TextBereiniger;
+import eu.mulk.aendggner.anwendung.BefehlAnwender;
+import eu.mulk.aendggner.gesetz.gii.GiiXmlLoader;
+import eu.mulk.aendggner.synopse.HtmlRenderer;
+import eu.mulk.aendggner.synopse.SynopseBuilder;
+import java.nio.file.Files;
+import java.nio.file.Path;
+import org.junit.jupiter.api.Test;
+
+/**
+ * Smoke-Test der Gesamtpipeline auf den IfSG-Beispieldaten (nicht eingecheckt; der Test wird
+ * übersprungen, wenn sie fehlen).
+ *
+ * <p>Achtung: Das Beispiel-XML ist bereits konsolidiert (Stand 18.11.2020, enthält die
+ * Art.-1-Änderungen des Beispiel-Änderungsgesetzes schon). Die Assertions prüfen deshalb
+ * Parse-Zahlen und sauberes Reporting, nicht die Diff-Korrektheit.
+ */
+class EndToEndTest {
+
+  private static final Path STAMMGESETZ =
+      Path.of("src/main/resources/sampledata/IfSG/BJNR104510000.xml");
+  private static final Path AENDERUNGSGESETZ =
+      Path.of("src/main/resources/sampledata/IfSG/bgbl120s2397_78991.pdf");
+
+  @Test
+  void gesamtePipelineAufIfSgBeispiel() throws Exception {
+    assumeTrue(Files.exists(STAMMGESETZ), "IfSG-Beispieldaten fehlen");
+    assumeTrue(Files.exists(AENDERUNGSGESETZ), "BGBl-Beispiel-PDF fehlt");
+
+    // 1. Stammgesetz laden.
+    var gesetz = new GiiXmlLoader().load(STAMMGESETZ);
+    assertThat(gesetz.jurabk()).isEqualTo("IfSG");
+    assertThat(gesetz.normen()).hasSize(86);
+    assertThat(gesetz.norm("§ 5").orElseThrow().absaetze()).hasSize(8);
+
+    // 2. Änderungsgesetz-Text extrahieren.
+    var text = TextBereiniger.bereinige(new PatchTextExtraktor().extrahiere(AENDERUNGSGESETZ));
+    assertThat(text).contains("wird wie folgt geändert");
+    assertThat(text).contains("Nach § 28 wird folgender § 28a eingefügt");
+
+    // 3. Befehle parsen: Artikel 1 und 2 betreffen das IfSG.
+    var parseErgebnis = new AenderungsgesetzParser().parse(text, gesetz, null);
+    assertThat(parseErgebnis.artikel()).containsExactly("1", "2");
+    assertThat(parseErgebnis.befehle().size()).isGreaterThanOrEqualTo(50);
+    var typisiert =
+        parseErgebnis.befehle().stream().filter(b -> !(b instanceof UnbekannterBefehl)).count();
+    assertThat(typisiert).isGreaterThanOrEqualTo(40);
+    // Das Original-BGBl enthält ein überzähliges Anführungszeichen (Artikel 3) — muss als
+    // Warnung gemeldet werden, nicht als Abbruch.
+    assertThat(parseErgebnis.warnungen()).isNotEmpty();
+
+    // 4. Anwenden: wirft nicht; jeder Befehl erhält einen Protokolleintrag.
+    var anwendung = BefehlAnwender.anwenden(gesetz, parseErgebnis.befehle());
+    assertThat(anwendung.protokoll()).hasSameSizeAs(parseErgebnis.befehle());
+    assertThat(anwendung.anzahlAngewandt()).isGreaterThan(0);
+
+    // 5. Synopse rendern.
+    var synopse = SynopseBuilder.baue(gesetz, anwendung, parseErgebnis.warnungen(), false);
+    var html = HtmlRenderer.rendere(synopse, "E2E-Test");
+    assertThat(html).contains("Alte Fassung").contains("Neue Fassung");
+    assertThat(html).contains("Manuell prüfen");
+    assertThat(html).containsAnyOf("<del>", "<ins>");
+  }
+}
diff --git a/src/test/java/eu/mulk/aendggner/aenderung/parse/BefehlErkennerTest.java b/src/test/java/eu/mulk/aendggner/aenderung/parse/BefehlErkennerTest.java
new file mode 100644
index 0000000..c8f9cb8
--- /dev/null
+++ b/src/test/java/eu/mulk/aendggner/aenderung/parse/BefehlErkennerTest.java
@@ -0,0 +1,266 @@
+package eu.mulk.aendggner.aenderung.parse;
+
+import static org.assertj.core.api.Assertions.assertThat;
+
+import eu.mulk.aendggner.aenderung.Aenderungsbefehl;
+import eu.mulk.aendggner.aenderung.Aenderungsbefehl.Anfuegung;
+import eu.mulk.aendggner.aenderung.Aenderungsbefehl.Aufhebung;
+import eu.mulk.aendggner.aenderung.Aenderungsbefehl.Ebene;
+import eu.mulk.aendggner.aenderung.Aenderungsbefehl.Ersetzung;
+import eu.mulk.aendggner.aenderung.Aenderungsbefehl.Neufassung;
+import eu.mulk.aendggner.aenderung.Aenderungsbefehl.Streichung;
+import eu.mulk.aendggner.aenderung.Aenderungsbefehl.StrukturEinfuegung;
+import eu.mulk.aendggner.aenderung.Aenderungsbefehl.Umnummerierung;
+import eu.mulk.aendggner.aenderung.Aenderungsbefehl.WoerterEinfuegung;
+import eu.mulk.aendggner.aenderung.Aenderungsbefehl.WortAnker;
+import eu.mulk.aendggner.aenderung.Provenienz;
+import eu.mulk.aendggner.aenderung.Stelle;
+import java.util.List;
+import java.util.Optional;
+import org.junit.jupiter.api.Test;
+
+class BefehlErkennerTest {
+
+  private static final Provenienz PROV = new Provenienz("1", "1.", "(Test)");
+
+  /** Erkennt einen Befehl aus echtem Gesetzestext (mit „…“-Zitaten). */
+  private static Optional<Aenderungsbefehl> erkenne(String befehlsSatz, Stelle kontext) {
+    var zitate = ZitatExtraktor.extrahiere(befehlsSatz);
+    var text = zitate.text().replaceAll("\\s+", " ").strip();
+    return BefehlErkenner.erkenne(text, kontext, zitate, PROV);
+  }
+
+  @Test
+  void erkenntErsetzung() {
+    var befehl =
+        erkenne(
+            "In § 20 Absatz 6 Satz 1 werden die Wörter „der Bevölkerung“ durch die Wörter"
+                + " „der Bevölkerung oder von Bevölkerungsteilen“ ersetzt.",
+            Stelle.LEER);
+
+    assertThat(befehl).containsInstanceOf(Ersetzung.class);
+    var ersetzung = (Ersetzung) befehl.orElseThrow();
+    assertThat(ersetzung.stelle().anzeigeText()).isEqualTo("§ 20 Absatz 6 Satz 1");
+    assertThat(ersetzung.alt()).isEqualTo("der Bevölkerung");
+    assertThat(ersetzung.neu()).isEqualTo("der Bevölkerung oder von Bevölkerungsteilen");
+    assertThat(ersetzung.jeweils()).isFalse();
+  }
+
+  @Test
+  void erkenntJeweilsErsetzung() {
+    var befehl =
+        erkenne(
+            "In § 5 werden jeweils die Wörter „epidemischen Lage“ durch die Wörter"
+                + " „epidemischen Lage von nationaler Tragweite“ ersetzt.",
+            Stelle.LEER);
+
+    assertThat(befehl).containsInstanceOf(Ersetzung.class);
+    assertThat(((Ersetzung) befehl.orElseThrow()).jeweils()).isTrue();
+  }
+
+  @Test
+  void erkenntSatzzeichenErsetzung() {
+    var befehl =
+        erkenne(
+            "In Nummer 16 wird der Punkt am Ende durch ein Komma ersetzt.",
+            new Stelle(List.of(new Stelle.Paragraph("2"))));
+
+    assertThat(befehl).containsInstanceOf(Ersetzung.class);
+    var ersetzung = (Ersetzung) befehl.orElseThrow();
+    assertThat(ersetzung.stelle().anzeigeText()).isEqualTo("§ 2 Nummer 16");
+    assertThat(ersetzung.alt()).isEqualTo(".");
+    assertThat(ersetzung.neu()).isEqualTo(",");
+    assertThat(ersetzung.amEnde()).isTrue();
+  }
+
+  @Test
+  void erkenntNeufassung() {
+    var befehl =
+        erkenne(
+            "§ 5 Absatz 1 wird wie folgt gefasst: „(1) Der Deutsche Bundestag kann eine"
+                + " epidemische Lage von nationaler Tragweite feststellen.“",
+            Stelle.LEER);
+
+    assertThat(befehl).containsInstanceOf(Neufassung.class);
+    var neufassung = (Neufassung) befehl.orElseThrow();
+    assertThat(neufassung.stelle().anzeigeText()).isEqualTo("§ 5 Absatz 1");
+    assertThat(neufassung.neuerText()).startsWith("(1) Der Deutsche Bundestag");
+  }
+
+  @Test
+  void erkenntParagraphEinfuegung() {
+    // Echter Befehl aus Art. 1 Nr. 17 des Dritten Bevölkerungsschutzgesetzes.
+    var befehl =
+        erkenne(
+            "Nach § 28 wird folgender § 28a eingefügt: „§ 28a Besondere Schutzmaßnahmen zur"
+                + " Verhinderung der Verbreitung der Coronavirus-Krankheit-2019 (COVID-19)"
+                + " (1) Notwendige Schutzmaßnahmen im Sinne des § 28 Absatz 1 Satz 1 und 2"
+                + " können … sein.“",
+            Stelle.LEER);
+
+    assertThat(befehl).containsInstanceOf(StrukturEinfuegung.class);
+    var einfuegung = (StrukturEinfuegung) befehl.orElseThrow();
+    assertThat(einfuegung.stelle().anzeigeText()).isEqualTo("§ 28");
+    assertThat(einfuegung.vorher()).isFalse();
+    assertThat(einfuegung.ebene()).isEqualTo(Ebene.PARAGRAPH);
+    assertThat(einfuegung.bezeichnung()).isEqualTo("28a");
+    assertThat(einfuegung.text()).startsWith("§ 28a Besondere Schutzmaßnahmen");
+  }
+
+  @Test
+  void erkenntSatzEinfuegung() {
+    var befehl =
+        erkenne(
+            "Nach Satz 1 wird folgender Satz eingefügt: „Das Robert Koch-Institut ist der"
+                + " Verantwortliche im Sinne des Datenschutzrechts.“",
+            new Stelle(List.of(new Stelle.Paragraph("14"), new Stelle.AbsatzNr("1"))));
+
+    assertThat(befehl).containsInstanceOf(StrukturEinfuegung.class);
+    var einfuegung = (StrukturEinfuegung) befehl.orElseThrow();
+    assertThat(einfuegung.stelle().anzeigeText()).isEqualTo("§ 14 Absatz 1 Satz 1");
+    assertThat(einfuegung.ebene()).isEqualTo(Ebene.SATZ);
+    assertThat(einfuegung.bezeichnung()).isNull();
+  }
+
+  @Test
+  void erkenntWoerterEinfuegung() {
+    var befehl =
+        erkenne(
+            "In Nummer 3 werden nach dem Wort „Kontaktdaten“ die Wörter „sowie die lebenslange"
+                + " Arztnummer (LANR)“ eingefügt.",
+            Stelle.LEER);
+
+    assertThat(befehl).containsInstanceOf(WoerterEinfuegung.class);
+    var einfuegung = (WoerterEinfuegung) befehl.orElseThrow();
+    assertThat(einfuegung.anker()).isEqualTo(new WortAnker.NachWoertern("Kontaktdaten"));
+    assertThat(einfuegung.woerter()).startsWith("sowie die lebenslange");
+  }
+
+  @Test
+  void erkenntWoerterEinfuegungVorKommaAmEnde() {
+    var befehl =
+        erkenne(
+            "In Nummer 2 werden vor dem Komma am Ende die Wörter „sowie Zahnärzte und"
+                + " Tierärzte“ eingefügt.",
+            Stelle.LEER);
+
+    assertThat(befehl).containsInstanceOf(WoerterEinfuegung.class);
+    assertThat(((WoerterEinfuegung) befehl.orElseThrow()).anker())
+        .isEqualTo(new WortAnker.VorKommaAmEnde());
+  }
+
+  @Test
+  void erkenntAnfuegung() {
+    var befehl =
+        erkenne(
+            "Folgender Absatz 8 wird angefügt: „(8) Aufgrund einer epidemischen"
+                + " Lage von nationaler Tragweite kann das Bundesministerium für Gesundheit Hilfe"
+                + " leisten.“",
+            new Stelle(List.of(new Stelle.Paragraph("5"))));
+
+    assertThat(befehl).containsInstanceOf(Anfuegung.class);
+    var anfuegung = (Anfuegung) befehl.orElseThrow();
+    assertThat(anfuegung.stelle().anzeigeText()).isEqualTo("§ 5");
+    assertThat(anfuegung.ebene()).isEqualTo(Ebene.ABSATZ);
+    assertThat(anfuegung.bezeichnung()).isEqualTo("8");
+  }
+
+  @Test
+  void erkenntSatzAnfuegungMitDativStelle() {
+    var befehl =
+        erkenne(
+            "Dem Absatz 6 wird folgender Satz angefügt: „Die Kontrolle obliegt dem"
+                + " Bundesbeauftragten.“",
+            new Stelle(List.of(new Stelle.Paragraph("14"))));
+
+    assertThat(befehl).containsInstanceOf(Anfuegung.class);
+    var anfuegung = (Anfuegung) befehl.orElseThrow();
+    assertThat(anfuegung.stelle().anzeigeText()).isEqualTo("§ 14 Absatz 6");
+    assertThat(anfuegung.ebene()).isEqualTo(Ebene.SATZ);
+  }
+
+  @Test
+  void erkenntAufhebungMitKontext() {
+    var kontext = new Stelle(List.of(new Stelle.Paragraph("56")));
+    var befehl = erkenne("Absatz 3 wird aufgehoben.", kontext);
+
+    assertThat(befehl).containsInstanceOf(Aufhebung.class);
+    assertThat(befehl.orElseThrow().stelle().anzeigeText()).isEqualTo("§ 56 Absatz 3");
+  }
+
+  @Test
+  void erkenntStreichung() {
+    var befehl =
+        erkenne(
+            "In Satz 2 werden die Wörter „bis zum Ende“ gestrichen.",
+            new Stelle(List.of(new Stelle.Paragraph("5"), new Stelle.AbsatzNr("1"))));
+
+    assertThat(befehl).containsInstanceOf(Streichung.class);
+    var streichung = (Streichung) befehl.orElseThrow();
+    assertThat(streichung.stelle().anzeigeText()).isEqualTo("§ 5 Absatz 1 Satz 2");
+    assertThat(streichung.woerter()).isEqualTo("bis zum Ende");
+  }
+
+  @Test
+  void erkenntUmnummerierung() {
+    var befehl =
+        erkenne("Absatz 4 wird Absatz 3.", new Stelle(List.of(new Stelle.Paragraph("10"))));
+
+    assertThat(befehl).containsInstanceOf(Umnummerierung.class);
+    var umnummerierung = (Umnummerierung) befehl.orElseThrow();
+    assertThat(umnummerierung.stelle().anzeigeText()).isEqualTo("§ 10 Absatz 4");
+    assertThat(umnummerierung.neu().anzeigeText()).isEqualTo("§ 10 Absatz 3");
+  }
+
+  @Test
+  void erkenntInhaltsuebersichtEinfuegung() {
+    var befehl =
+        erkenne(
+            "In der Inhaltsübersicht wird nach der Angabe zu § 28 folgende Angabe eingefügt:"
+                + " „§ 28a Besondere Schutzmaßnahmen“.",
+            Stelle.LEER);
+
+    assertThat(befehl).containsInstanceOf(WoerterEinfuegung.class);
+    assertThat(befehl.orElseThrow().stelle().betrifftInhaltsuebersicht()).isTrue();
+  }
+
+  @Test
+  void erkenntUeberschriftNeufassung() {
+    var befehl =
+        erkenne(
+            "Die Überschrift wird wie folgt gefasst: „§ 5 Epidemische Lage“.",
+            new Stelle(List.of(new Stelle.Paragraph("5"))));
+
+    assertThat(befehl).containsInstanceOf(Neufassung.class);
+    assertThat(befehl.orElseThrow().stelle().betrifftUeberschrift()).isTrue();
+  }
+
+  @Test
+  void erkenntKontextRahmen() {
+    assertThat(BefehlErkenner.kontextRahmen("§ 2 wird wie folgt geändert:"))
+        .contains(new Stelle(List.of(new Stelle.Paragraph("2"))));
+    assertThat(BefehlErkenner.kontextRahmen("Absatz 1 wird wie folgt geändert:"))
+        .contains(new Stelle(List.of(new Stelle.AbsatzNr("1"))));
+    assertThat(BefehlErkenner.kontextRahmen("§ 8 Absatz 1 wird wie folgt geändert:"))
+        .contains(new Stelle(List.of(new Stelle.Paragraph("8"), new Stelle.AbsatzNr("1"))));
+    assertThat(BefehlErkenner.kontextRahmen("Absatz 3 wird aufgehoben.")).isEmpty();
+  }
+
+  @Test
+  void faelltBeiBereichsbefehlenAufUnbekanntZurueck() {
+    // Bereichs- und Mehrfachbefehle sind in v1 bewusst nicht unterstützt.
+    assertThat(
+            erkenne(
+                "Die Absätze 2 bis 4 werden durch die folgenden Absätze 2 bis 6 ersetzt:"
+                    + " „(2) Text.“",
+                Stelle.LEER))
+        .isEmpty();
+    assertThat(erkenne("Die Nummern 1 bis 3 werden aufgehoben.", Stelle.LEER)).isEmpty();
+    assertThat(
+            erkenne(
+                "In Absatz 1 Satz 1 und 2 werden die Wörter „alt“ durch die Wörter „neu“"
+                    + " ersetzt.",
+                Stelle.LEER))
+        .isEmpty();
+  }
+}
diff --git a/src/test/java/eu/mulk/aendggner/aenderung/parse/TextBereinigerTest.java b/src/test/java/eu/mulk/aendggner/aenderung/parse/TextBereinigerTest.java
new file mode 100644
index 0000000..d8c5a03
--- /dev/null
+++ b/src/test/java/eu/mulk/aendggner/aenderung/parse/TextBereinigerTest.java
@@ -0,0 +1,49 @@
+package eu.mulk.aendggner.aenderung.parse;
+
+import static org.assertj.core.api.Assertions.assertThat;
+
+import org.junit.jupiter.api.Test;
+
+class TextBereinigerTest {
+
+  @Test
+  void entferntKolumnentitelUndSeitenzahlen() {
+    var roh =
+        """
+        Erster Satz.
+        2397 Bundesgesetzblatt Jahrgang 2020 Teil I Nr. 52, ausgegeben zu Bonn am 18. November 2020
+        Das Bundesgesetzblatt im Internet: www.bundesgesetzblatt.de | Ein Service des Bundesanzeiger Verlag
+        42
+        Zweiter Satz.""";
+
+    assertThat(TextBereiniger.bereinige(roh)).isEqualTo("Erster Satz.\nZweiter Satz.");
+  }
+
+  @Test
+  void ziehtSilbentrennungZusammen() {
+    assertThat(TextBereiniger.bereinige("die Bundes-\nregierung")).isEqualTo("die Bundesregierung");
+  }
+
+  @Test
+  void ziehtSilbentrennungUeberLeerzeilenZusammen() {
+    assertThat(TextBereiniger.bereinige("verhält-\n\n\nnismäßig")).isEqualTo("verhältnismäßig");
+  }
+
+  @Test
+  void verbindetUmbrocheneKompositaMitBindestrich() {
+    assertThat(TextBereiniger.bereinige("der Coronavirus-\nKrankheit-2019"))
+        .isEqualTo("der Coronavirus-Krankheit-2019");
+  }
+
+  @Test
+  void erhaeltSuspensivstricheVorKonjunktionen() {
+    assertThat(TextBereiniger.bereinige("Wirk-, Ausgangs-\nund Hilfsstoffe"))
+        .isEqualTo("Wirk-, Ausgangs-\nund Hilfsstoffe");
+  }
+
+  @Test
+  void erhaeltGedankenstricheOhneVorangehendenBuchstaben() {
+    assertThat(TextBereiniger.bereinige("Preisbildung und -\ngestaltung"))
+        .isEqualTo("Preisbildung und -\ngestaltung");
+  }
+}
diff --git a/src/test/java/eu/mulk/aendggner/aenderung/parse/ZitatExtraktorTest.java b/src/test/java/eu/mulk/aendggner/aenderung/parse/ZitatExtraktorTest.java
new file mode 100644
index 0000000..8194f6b
--- /dev/null
+++ b/src/test/java/eu/mulk/aendggner/aenderung/parse/ZitatExtraktorTest.java
@@ -0,0 +1,70 @@
+package eu.mulk.aendggner.aenderung.parse;
+
+import static org.assertj.core.api.Assertions.assertThat;
+
+import org.junit.jupiter.api.Test;
+
+class ZitatExtraktorTest {
+
+  @Test
+  void ersetztZitateDurchPlatzhalter() {
+    var ergebnis = ZitatExtraktor.extrahiere("Das Wort „alt“ wird durch das Wort „neu“ ersetzt.");
+
+    assertThat(ergebnis.text()).isEqualTo("Das Wort «0» wird durch das Wort «1» ersetzt.");
+    assertThat(ergebnis.zitat(0)).isEqualTo("alt");
+    assertThat(ergebnis.zitat(1)).isEqualTo("neu");
+    assertThat(ergebnis.warnungen()).isEmpty();
+  }
+
+  @Test
+  void schlaegtGeschachtelteZitateDemAeusserenZu() {
+    var ergebnis = ZitatExtraktor.extrahiere("Es wird gefasst: „Satz mit „innerem“ Zitat.“");
+
+    assertThat(ergebnis.text()).isEqualTo("Es wird gefasst: «0»");
+    assertThat(ergebnis.zitat(0)).isEqualTo("Satz mit „innerem“ Zitat.");
+  }
+
+  @Test
+  void laesstEinfacheZitateUnangetastet() {
+    var ergebnis = ZitatExtraktor.extrahiere("„Wort ‚einfach‘ zitiert“");
+
+    assertThat(ergebnis.zitat(0)).isEqualTo("Wort ‚einfach‘ zitiert");
+  }
+
+  @Test
+  void verkraftetMehrzeiligeZitate() {
+    var ergebnis =
+        ZitatExtraktor.extrahiere("Gefasst: „(1) Erster Absatz.\n\n(2) Zweiter Absatz.“ Ende.");
+
+    assertThat(ergebnis.text()).isEqualTo("Gefasst: «0» Ende.");
+    assertThat(ergebnis.zitat(0)).contains("(2) Zweiter Absatz.");
+  }
+
+  @Test
+  void meldetUeberzaehligesSchliessendesAnfuehrungszeichen() {
+    // Kommt in echten BGBl-Texten vor (z.B. BGBl. I 2020 S. 2397, Artikel 3).
+    var ergebnis = ZitatExtraktor.extrahiere("wird „alt“ ersetzt.“ Danach normal.");
+
+    assertThat(ergebnis.text()).isEqualTo("wird «0» ersetzt.“ Danach normal.");
+    assertThat(ergebnis.warnungen()).hasSize(1);
+    assertThat(ergebnis.warnungen().get(0)).contains("ohne öffnendes");
+  }
+
+  @Test
+  void meldetAmTextendeOffeneZitate() {
+    var ergebnis = ZitatExtraktor.extrahiere("Es wird gefasst: „nie geschlossen");
+
+    assertThat(ergebnis.text()).isEqualTo("Es wird gefasst: «0»");
+    assertThat(ergebnis.zitat(0)).isEqualTo("nie geschlossen");
+    assertThat(ergebnis.warnungen()).hasSize(1);
+    assertThat(ergebnis.warnungen().get(0)).contains("offen");
+  }
+
+  @Test
+  void stelltZitateWiederHer() {
+    var original = "Das Wort „alt“ wird durch das Wort „neu“ ersetzt.";
+    var ergebnis = ZitatExtraktor.extrahiere(original);
+
+    assertThat(ergebnis.stelleZitateWiederHer(ergebnis.text())).isEqualTo(original);
+  }
+}
diff --git a/src/test/java/eu/mulk/aendggner/anwendung/BefehlAnwenderTest.java b/src/test/java/eu/mulk/aendggner/anwendung/BefehlAnwenderTest.java
new file mode 100644
index 0000000..26557d1
--- /dev/null
+++ b/src/test/java/eu/mulk/aendggner/anwendung/BefehlAnwenderTest.java
@@ -0,0 +1,373 @@
+package eu.mulk.aendggner.anwendung;
+
+import static org.assertj.core.api.Assertions.assertThat;
+
+import eu.mulk.aendggner.aenderung.Aenderungsbefehl.Anfuegung;
+import eu.mulk.aendggner.aenderung.Aenderungsbefehl.Aufhebung;
+import eu.mulk.aendggner.aenderung.Aenderungsbefehl.Ebene;
+import eu.mulk.aendggner.aenderung.Aenderungsbefehl.Ersetzung;
+import eu.mulk.aendggner.aenderung.Aenderungsbefehl.Neufassung;
+import eu.mulk.aendggner.aenderung.Aenderungsbefehl.Streichung;
+import eu.mulk.aendggner.aenderung.Aenderungsbefehl.StrukturEinfuegung;
+import eu.mulk.aendggner.aenderung.Aenderungsbefehl.Umnummerierung;
+import eu.mulk.aendggner.aenderung.Aenderungsbefehl.UnbekannterBefehl;
+import eu.mulk.aendggner.aenderung.Aenderungsbefehl.WoerterEinfuegung;
+import eu.mulk.aendggner.aenderung.Aenderungsbefehl.WortAnker;
+import eu.mulk.aendggner.aenderung.Provenienz;
+import eu.mulk.aendggner.aenderung.Stelle;
+import eu.mulk.aendggner.anwendung.BefehlAnwender.Status;
+import eu.mulk.aendggner.gesetz.Absatz;
+import eu.mulk.aendggner.gesetz.Gesetz;
+import eu.mulk.aendggner.gesetz.Norm;
+import java.util.List;
+import org.junit.jupiter.api.Test;
+
+class BefehlAnwenderTest {
+
+  private static final Provenienz PROV = new Provenienz("1", "1.", "(Test)");
+
+  private static Stelle stelle(Stelle.Komponente... komponenten) {
+    return new Stelle(List.of(komponenten));
+  }
+
+  @Test
+  void wendetErsetzungAn() {
+    var befehl =
+        new Ersetzung(
+            stelle(new Stelle.Paragraph("1"), new Stelle.AbsatzNr("1")),
+            "die Erprobung",
+            "die gründliche Erprobung",
+            false,
+            false,
+            PROV);
+
+    var ergebnis = BefehlAnwender.anwenden(gesetz(), List.of(befehl));
+
+    assertThat(ergebnis.protokoll().get(0).status()).isEqualTo(Status.ANGEWANDT);
+    assertThat(absatzText(ergebnis.neu(), "§ 1", 0))
+        .isEqualTo("Zweck dieses Gesetzes ist die gründliche Erprobung.");
+  }
+
+  @Test
+  void meldetFehlendenAltTextAlsManuellPruefen() {
+    var befehl =
+        new Ersetzung(
+            stelle(new Stelle.Paragraph("1"), new Stelle.AbsatzNr("1")),
+            "gibt es nicht",
+            "egal",
+            false,
+            false,
+            PROV);
+
+    var ergebnis = BefehlAnwender.anwenden(gesetz(), List.of(befehl));
+
+    var eintrag = ergebnis.protokoll().get(0);
+    assertThat(eintrag.status()).isEqualTo(Status.MANUELL_PRUEFEN);
+    assertThat(eintrag.begruendung()).contains("kommt im Zieltext nicht vor");
+    // Das Gesetz bleibt unverändert.
+    assertThat(absatzText(ergebnis.neu(), "§ 1", 0))
+        .isEqualTo("Zweck dieses Gesetzes ist die Erprobung.");
+  }
+
+  @Test
+  void meldetMehrdeutigeErsetzungOhneJeweils() {
+    var befehl =
+        new Ersetzung(
+            stelle(new Stelle.Paragraph("2")), "Prüfung", "Untersuchung", false, false, PROV);
+
+    var ergebnis = BefehlAnwender.anwenden(gesetz(), List.of(befehl));
+
+    assertThat(ergebnis.protokoll().get(0).status()).isEqualTo(Status.MANUELL_PRUEFEN);
+    assertThat(ergebnis.protokoll().get(0).begruendung()).contains("mehrdeutig");
+  }
+
+  @Test
+  void wendetJeweilsErsetzungAufAlleVorkommenAn() {
+    var befehl =
+        new Ersetzung(
+            stelle(new Stelle.Paragraph("2")), "Prüfung", "Untersuchung", true, false, PROV);
+
+    var ergebnis = BefehlAnwender.anwenden(gesetz(), List.of(befehl));
+
+    assertThat(ergebnis.protokoll().get(0).status()).isEqualTo(Status.ANGEWANDT);
+    assertThat(absatzText(ergebnis.neu(), "§ 2", 0))
+        .contains("die Untersuchung der Tauglichkeit")
+        .contains("Die Untersuchung erfolgt");
+  }
+
+  @Test
+  void wendetErsetzungInSatzAn() {
+    var befehl =
+        new Ersetzung(
+            stelle(new Stelle.Paragraph("2"), new Stelle.SatzNr("2")),
+            "sorgfältig",
+            "gewissenhaft",
+            false,
+            false,
+            PROV);
+
+    var ergebnis = BefehlAnwender.anwenden(gesetz(), List.of(befehl));
+
+    assertThat(ergebnis.protokoll().get(0).status()).isEqualTo(Status.ANGEWANDT);
+    assertThat(absatzText(ergebnis.neu(), "§ 2", 0)).contains("erfolgt gewissenhaft");
+  }
+
+  @Test
+  void wendetErsetzungInNummerAn() {
+    var befehl =
+        new Ersetzung(
+            stelle(new Stelle.Paragraph("1"), new Stelle.AbsatzNr("2"), new Stelle.NummerNr("2")),
+            "Befehlen",
+            "Änderungsbefehlen",
+            false,
+            false,
+            PROV);
+
+    var ergebnis = BefehlAnwender.anwenden(gesetz(), List.of(befehl));
+
+    assertThat(ergebnis.protokoll().get(0).status()).isEqualTo(Status.ANGEWANDT);
+    assertThat(absatzText(ergebnis.neu(), "§ 1", 1))
+        .contains("2. die Anwendung von Änderungsbefehlen und");
+  }
+
+  @Test
+  void wendetPunktZuKommaErsetzungAmEndeAn() {
+    var befehl =
+        new Ersetzung(
+            stelle(new Stelle.Paragraph("1"), new Stelle.AbsatzNr("2"), new Stelle.NummerNr("3")),
+            ".",
+            ",",
+            false,
+            true,
+            PROV);
+
+    var ergebnis = BefehlAnwender.anwenden(gesetz(), List.of(befehl));
+
+    assertThat(ergebnis.protokoll().get(0).status()).isEqualTo(Status.ANGEWANDT);
+    assertThat(absatzText(ergebnis.neu(), "§ 1", 1)).contains("3. die Ausgabe von Synopsen,");
+  }
+
+  @Test
+  void wendetNeufassungEinesAbsatzesAn() {
+    var befehl =
+        new Neufassung(
+            stelle(new Stelle.Paragraph("1"), new Stelle.AbsatzNr("1")),
+            "(1) Zweck dieses Gesetzes ist die umfassende Erprobung.",
+            PROV);
+
+    var ergebnis = BefehlAnwender.anwenden(gesetz(), List.of(befehl));
+
+    assertThat(ergebnis.protokoll().get(0).status()).isEqualTo(Status.ANGEWANDT);
+    assertThat(absatzText(ergebnis.neu(), "§ 1", 0))
+        .isEqualTo("Zweck dieses Gesetzes ist die umfassende Erprobung.");
+  }
+
+  @Test
+  void wendetNeufassungEinesParagraphenAn() {
+    var befehl =
+        new Neufassung(
+            stelle(new Stelle.Paragraph("2")),
+            "§ 2 Begriffsbestimmungen (1) Erprobung ist die Untersuchung. (2) Bericht ist das"
+                + " Ergebnis.",
+            PROV);
+
+    var ergebnis = BefehlAnwender.anwenden(gesetz(), List.of(befehl));
+
+    assertThat(ergebnis.protokoll().get(0).status()).isEqualTo(Status.ANGEWANDT);
+    var norm = ergebnis.neu().norm("§ 2").orElseThrow();
+    assertThat(norm.titel()).isEqualTo("Begriffsbestimmungen");
+    assertThat(norm.absaetze()).hasSize(2);
+    assertThat(norm.absaetze().get(0).nummer()).isEqualTo("1");
+    assertThat(norm.absaetze().get(1).text()).isEqualTo("Bericht ist das Ergebnis.");
+  }
+
+  @Test
+  void fuegtParagraphEin() {
+    var befehl =
+        new StrukturEinfuegung(
+            stelle(new Stelle.Paragraph("2")),
+            false,
+            Ebene.PARAGRAPH,
+            "2a",
+            "§ 2a Verfahren (1) Das Verfahren ist einfach.",
+            PROV);
+
+    var ergebnis = BefehlAnwender.anwenden(gesetz(), List.of(befehl));
+
+    assertThat(ergebnis.protokoll().get(0).status()).isEqualTo(Status.ANGEWANDT);
+    var enbezListe = ergebnis.neu().normen().stream().map(Norm::enbez).toList();
+    assertThat(enbezListe).containsExactly("§ 1", "§ 2", "§ 2a", "§ 3");
+    var neueNorm = ergebnis.neu().norm("§ 2a").orElseThrow();
+    assertThat(neueNorm.titel()).isEqualTo("Verfahren");
+    assertThat(neueNorm.absaetze().get(0).text()).isEqualTo("Das Verfahren ist einfach.");
+  }
+
+  @Test
+  void meldetBereitsVorhandenenParagraphen() {
+    var befehl =
+        new StrukturEinfuegung(
+            stelle(new Stelle.Paragraph("1")), false, Ebene.PARAGRAPH, "2", "§ 2 Doppelt", PROV);
+
+    var ergebnis = BefehlAnwender.anwenden(gesetz(), List.of(befehl));
+
+    assertThat(ergebnis.protokoll().get(0).status()).isEqualTo(Status.MANUELL_PRUEFEN);
+    assertThat(ergebnis.protokoll().get(0).begruendung()).contains("existiert bereits");
+  }
+
+  @Test
+  void fuegtWoerterNachAnkerEin() {
+    var befehl =
+        new WoerterEinfuegung(
+            stelle(new Stelle.Paragraph("1"), new Stelle.AbsatzNr("1")),
+            new WortAnker.NachWoertern("Gesetzes"),
+            "und dieser Verordnung",
+            PROV);
+
+    var ergebnis = BefehlAnwender.anwenden(gesetz(), List.of(befehl));
+
+    assertThat(ergebnis.protokoll().get(0).status()).isEqualTo(Status.ANGEWANDT);
+    assertThat(absatzText(ergebnis.neu(), "§ 1", 0))
+        .isEqualTo("Zweck dieses Gesetzes und dieser Verordnung ist die Erprobung.");
+  }
+
+  @Test
+  void fuegtAbsatzAn() {
+    var befehl =
+        new Anfuegung(
+            stelle(new Stelle.Paragraph("3")),
+            Ebene.ABSATZ,
+            "2",
+            "(2) Es tritt nie außer Kraft.",
+            PROV);
+
+    var ergebnis = BefehlAnwender.anwenden(gesetz(), List.of(befehl));
+
+    assertThat(ergebnis.protokoll().get(0).status()).isEqualTo(Status.ANGEWANDT);
+    var norm = ergebnis.neu().norm("§ 3").orElseThrow();
+    assertThat(norm.absaetze()).hasSize(2);
+    assertThat(norm.absaetze().get(1).nummer()).isEqualTo("2");
+  }
+
+  @Test
+  void hebtParagraphenAuf() {
+    var befehl = new Aufhebung(stelle(new Stelle.Paragraph("2")), PROV);
+
+    var ergebnis = BefehlAnwender.anwenden(gesetz(), List.of(befehl));
+
+    assertThat(ergebnis.protokoll().get(0).status()).isEqualTo(Status.ANGEWANDT);
+    var norm = ergebnis.neu().norm("§ 2").orElseThrow();
+    assertThat(norm.weggefallen()).isTrue();
+    assertThat(norm.gesamtText()).isEqualTo("(weggefallen)");
+  }
+
+  @Test
+  void hebtAbsatzAuf() {
+    var befehl = new Aufhebung(stelle(new Stelle.Paragraph("1"), new Stelle.AbsatzNr("2")), PROV);
+
+    var ergebnis = BefehlAnwender.anwenden(gesetz(), List.of(befehl));
+
+    assertThat(ergebnis.protokoll().get(0).status()).isEqualTo(Status.ANGEWANDT);
+    assertThat(absatzText(ergebnis.neu(), "§ 1", 1)).isEqualTo("(weggefallen)");
+  }
+
+  @Test
+  void streichtWoerter() {
+    var befehl =
+        new Streichung(
+            stelle(new Stelle.Paragraph("2"), new Stelle.SatzNr("2")), "sorgfältig", PROV);
+
+    var ergebnis = BefehlAnwender.anwenden(gesetz(), List.of(befehl));
+
+    assertThat(ergebnis.protokoll().get(0).status()).isEqualTo(Status.ANGEWANDT);
+    assertThat(absatzText(ergebnis.neu(), "§ 2", 0)).contains("Die Prüfung erfolgt.");
+  }
+
+  @Test
+  void nummeriertAbsatzUm() {
+    var befehl =
+        new Umnummerierung(
+            stelle(new Stelle.Paragraph("1"), new Stelle.AbsatzNr("2")),
+            stelle(new Stelle.Paragraph("1"), new Stelle.AbsatzNr("3")),
+            PROV);
+
+    var ergebnis = BefehlAnwender.anwenden(gesetz(), List.of(befehl));
+
+    assertThat(ergebnis.protokoll().get(0).status()).isEqualTo(Status.ANGEWANDT);
+    var norm = ergebnis.neu().norm("§ 1").orElseThrow();
+    assertThat(norm.absaetze().get(1).nummer()).isEqualTo("3");
+  }
+
+  @Test
+  void meldetUnbekanntenBefehlAlsManuellPruefen() {
+    var befehl = new UnbekannterBefehl(Stelle.LEER, "Die Nummern 1 bis 3 werden aufgehoben.", PROV);
+
+    var ergebnis = BefehlAnwender.anwenden(gesetz(), List.of(befehl));
+
+    assertThat(ergebnis.protokoll().get(0).status()).isEqualTo(Status.MANUELL_PRUEFEN);
+    assertThat(ergebnis.anzahlManuell()).isEqualTo(1);
+    assertThat(ergebnis.anzahlAngewandt()).isEqualTo(0);
+  }
+
+  @Test
+  void fuegtSatzNachSatzEin() {
+    var befehl =
+        new StrukturEinfuegung(
+            stelle(new Stelle.Paragraph("2"), new Stelle.SatzNr("1")),
+            false,
+            Ebene.SATZ,
+            null,
+            "Die Tauglichkeit ist zu dokumentieren.",
+            PROV);
+
+    var ergebnis = BefehlAnwender.anwenden(gesetz(), List.of(befehl));
+
+    assertThat(ergebnis.protokoll().get(0).status()).isEqualTo(Status.ANGEWANDT);
+    assertThat(absatzText(ergebnis.neu(), "§ 2", 0))
+        .contains(
+            "Erprobung ist die Prüfung der Tauglichkeit. Die Tauglichkeit ist zu dokumentieren."
+                + " Die Prüfung erfolgt sorgfältig.");
+  }
+
+  // --- Helfer --------------------------------------------------------------------------------
+
+  private static Gesetz gesetz() {
+    return new Gesetz(
+        "TestG",
+        "Gesetz zur Erprobung",
+        "Testgesetz",
+        List.of(
+            new Norm(
+                "§ 1",
+                "Zweck",
+                null,
+                List.of(
+                    new Absatz("1", "Zweck dieses Gesetzes ist die Erprobung."),
+                    new Absatz(
+                        "2",
+                        "Die Erprobung umfasst\n"
+                            + "  1. das Einlesen von Gesetzen,\n"
+                            + "  2. die Anwendung von Befehlen und\n"
+                            + "  3. die Ausgabe von Synopsen.")),
+                false),
+            new Norm(
+                "§ 2",
+                "Begriffe",
+                null,
+                List.of(
+                    new Absatz(
+                        null,
+                        "Erprobung ist die Prüfung der Tauglichkeit. Die Prüfung erfolgt"
+                            + " sorgfältig. Sie endet mit einem Bericht.")),
+                false),
+            new Norm(
+                "§ 3",
+                "Schlussvorschriften",
+                null,
+                List.of(new Absatz("1", "Dieses Gesetz tritt am 1. Januar 2021 in Kraft.")),
+                false)));
+  }
+
+  private static String absatzText(Gesetz gesetz, String enbez, int index) {
+    return gesetz.norm(enbez).orElseThrow().absaetze().get(index).text();
+  }
+}
diff --git a/src/test/java/eu/mulk/aendggner/anwendung/SatzTeilerTest.java b/src/test/java/eu/mulk/aendggner/anwendung/SatzTeilerTest.java
new file mode 100644
index 0000000..7df8f37
--- /dev/null
+++ b/src/test/java/eu/mulk/aendggner/anwendung/SatzTeilerTest.java
@@ -0,0 +1,53 @@
+package eu.mulk.aendggner.anwendung;
+
+import static org.assertj.core.api.Assertions.assertThat;
+
+import org.junit.jupiter.api.Test;
+
+class SatzTeilerTest {
+
+  @Test
+  void teiltEinfacheSaetze() {
+    assertThat(SatzTeiler.teileTexte("Erster Satz. Zweiter Satz. Dritter Satz."))
+        .containsExactly("Erster Satz.", "Zweiter Satz.", "Dritter Satz.");
+  }
+
+  @Test
+  void teiltNichtBeiAbkuerzungen() {
+    assertThat(
+            SatzTeiler.teileTexte(
+                "Die Meldung erfolgt nach Abs. 2 und § 5 Nr. 3 des Gesetzes. Sie ist"
+                    + " schriftlich abzugeben."))
+        .containsExactly(
+            "Die Meldung erfolgt nach Abs. 2 und § 5 Nr. 3 des Gesetzes.",
+            "Sie ist schriftlich abzugeben.");
+  }
+
+  @Test
+  void teiltNichtBeiDatumsangaben() {
+    assertThat(
+            SatzTeiler.teileTexte(
+                "Die Regelung gilt bis zum 31. März 2021. Danach tritt sie außer Kraft."))
+        .containsExactly(
+            "Die Regelung gilt bis zum 31. März 2021.", "Danach tritt sie außer Kraft.");
+  }
+
+  @Test
+  void teiltVorZitatUndKlammer() {
+    assertThat(SatzTeiler.teileTexte("Es gilt Satz 1. „Zitat folgt.“ (Klammer folgt.)")).hasSize(3);
+  }
+
+  @Test
+  void behandeltAufzaehlungenAlsTeilDesSatzes() {
+    var text =
+        "Die Erprobung umfasst\n"
+            + "  1. das Einlesen,\n"
+            + "  2. die Anwendung und\n"
+            + "  3. die Ausgabe. Weitere Einzelheiten regelt die Verordnung.";
+    var saetze = SatzTeiler.teileTexte(text);
+
+    assertThat(saetze).hasSize(2);
+    assertThat(saetze.get(0)).contains("2. die Anwendung und");
+    assertThat(saetze.get(1)).isEqualTo("Weitere Einzelheiten regelt die Verordnung.");
+  }
+}
diff --git a/src/test/java/eu/mulk/aendggner/gesetz/gii/GiiXmlLoaderTest.java b/src/test/java/eu/mulk/aendggner/gesetz/gii/GiiXmlLoaderTest.java
new file mode 100644
index 0000000..b3e3f32
--- /dev/null
+++ b/src/test/java/eu/mulk/aendggner/gesetz/gii/GiiXmlLoaderTest.java
@@ -0,0 +1,83 @@
+package eu.mulk.aendggner.gesetz.gii;
+
+import static org.assertj.core.api.Assertions.assertThat;
+
+import java.nio.file.Path;
+import org.junit.jupiter.api.Test;
+
+class GiiXmlLoaderTest {
+
+  private static Path fixture() {
+    return Path.of("src/test/resources/eu/mulk/aendggner/mini-gii.xml");
+  }
+
+  @Test
+  void liestMetadatenUndNormen() throws Exception {
+    var gesetz = new GiiXmlLoader().load(fixture());
+
+    assertThat(gesetz.jurabk()).isEqualTo("TestG");
+    assertThat(gesetz.langue()).isEqualTo("Gesetz zur Erprobung des ÄndGgners");
+    assertThat(gesetz.kurzue()).isEqualTo("Testgesetz");
+    // Die Rahmen-Norm ohne enbez wird übersprungen.
+    assertThat(gesetz.normen()).hasSize(3);
+    assertThat(gesetz.normen().get(0).enbez()).isEqualTo("Inhaltsübersicht");
+    assertThat(gesetz.norm("§ 1")).isPresent();
+    assertThat(gesetz.norm("§ 3")).isEmpty();
+  }
+
+  @Test
+  void liestAbsaetzeMitNummern() throws Exception {
+    var gesetz = new GiiXmlLoader().load(fixture());
+    var paragraph1 = gesetz.norm("§ 1").orElseThrow();
+
+    assertThat(paragraph1.titel()).isEqualTo("Zweck des Gesetzes");
+    assertThat(paragraph1.gliederung()).isNotNull();
+    assertThat(paragraph1.gliederung().bezeichnung()).isEqualTo("1. Abschnitt");
+    assertThat(paragraph1.absaetze()).hasSize(2);
+    assertThat(paragraph1.absaetze().get(0).nummer()).isEqualTo("1");
+    assertThat(paragraph1.absaetze().get(0).text())
+        .isEqualTo("Zweck dieses Gesetzes ist die Erprobung. Die Erprobung erfolgt sorgfältig.");
+  }
+
+  @Test
+  void flattetAufzaehlungenAlsEingerueckteZeilen() throws Exception {
+    var gesetz = new GiiXmlLoader().load(fixture());
+    var absatz2 = gesetz.norm("§ 1").orElseThrow().absaetze().get(1);
+
+    assertThat(absatz2.nummer()).isEqualTo("2");
+    var zeilen = absatz2.text().lines().toList();
+    assertThat(zeilen.get(0)).isEqualTo("Die Erprobung umfasst");
+    assertThat(zeilen).anyMatch(z -> z.strip().startsWith("1. das Einlesen"));
+    assertThat(zeilen).anyMatch(z -> z.strip().startsWith("a) Ersetzungen"));
+    // Fließtext nach der Aufzählung bleibt erhalten.
+    assertThat(absatz2.text()).contains("Weitere Einzelheiten regelt die Rechtsverordnung.");
+    // Geschachtelte Aufzählung ist tiefer eingerückt als die äußere.
+    var aeussere =
+        zeilen.stream().filter(z -> z.strip().startsWith("1.")).findFirst().orElseThrow();
+    var innere = zeilen.stream().filter(z -> z.strip().startsWith("a)")).findFirst().orElseThrow();
+    assertThat(einrueckung(innere)).isGreaterThan(einrueckung(aeussere));
+  }
+
+  @Test
+  void flattetTabellenZuZeilen() throws Exception {
+    var gesetz = new GiiXmlLoader().load(fixture());
+    var inhaltsuebersicht = gesetz.norm("Inhaltsübersicht").orElseThrow();
+
+    assertThat(inhaltsuebersicht.absaetze()).hasSize(1);
+    assertThat(inhaltsuebersicht.absaetze().get(0).text())
+        .contains("§ 1 | Zweck des Gesetzes")
+        .contains("§ 2 | Begriffsbestimmungen");
+  }
+
+  @Test
+  void erkenntWeggefalleneNormen() throws Exception {
+    var gesetz = new GiiXmlLoader().load(fixture());
+
+    assertThat(gesetz.norm("§ 2").orElseThrow().weggefallen()).isTrue();
+    assertThat(gesetz.norm("§ 1").orElseThrow().weggefallen()).isFalse();
+  }
+
+  private static int einrueckung(String zeile) {
+    return zeile.length() - zeile.stripLeading().length();
+  }
+}
diff --git a/src/test/java/eu/mulk/aendggner/synopse/HtmlRendererTest.java b/src/test/java/eu/mulk/aendggner/synopse/HtmlRendererTest.java
new file mode 100644
index 0000000..b82778c
--- /dev/null
+++ b/src/test/java/eu/mulk/aendggner/synopse/HtmlRendererTest.java
@@ -0,0 +1,98 @@
+package eu.mulk.aendggner.synopse;
+
+import static org.assertj.core.api.Assertions.assertThat;
+
+import eu.mulk.aendggner.aenderung.Aenderungsbefehl.Ersetzung;
+import eu.mulk.aendggner.aenderung.Aenderungsbefehl.UnbekannterBefehl;
+import eu.mulk.aendggner.aenderung.Provenienz;
+import eu.mulk.aendggner.aenderung.Stelle;
+import eu.mulk.aendggner.anwendung.BefehlAnwender;
+import eu.mulk.aendggner.gesetz.Absatz;
+import eu.mulk.aendggner.gesetz.Gesetz;
+import eu.mulk.aendggner.gesetz.Norm;
+import java.util.List;
+import org.junit.jupiter.api.Test;
+
+class HtmlRendererTest {
+
+  private static final Provenienz PROV = new Provenienz("1", "1.", "In § 1 wird … ersetzt.");
+
+  private static Gesetz gesetz(String absatzText) {
+    return new Gesetz(
+        "TestG",
+        "Testgesetz",
+        "TestG",
+        List.of(new Norm("§ 1", "Zweck", null, List.of(new Absatz("1", absatzText)), false)));
+  }
+
+  private static Synopse synopse() {
+    var alt = gesetz("Der alte Wortlaut gilt.");
+    var befehl =
+        new Ersetzung(
+            new Stelle(List.of(new Stelle.Paragraph("1"))), "alte", "neue", false, false, PROV);
+    var anwendung = BefehlAnwender.anwenden(alt, List.of(befehl));
+    return SynopseBuilder.baue(alt, anwendung, List.of("Eine Testwarnung."), false);
+  }
+
+  @Test
+  void rendertBeideSpaltenMitDiffTags() {
+    var html = HtmlRenderer.rendere(synopse(), "test.xml + test.pdf");
+
+    assertThat(html).contains("Alte Fassung").contains("Neue Fassung");
+    assertThat(html).contains("<del>alte</del>").contains("<ins>neue</ins>");
+    assertThat(html).contains("§ 1 — Zweck");
+  }
+
+  @Test
+  void escapetHtmlInGesetzestexten() {
+    var alt = gesetz("Böser <script>alert('x')</script> Text & mehr.");
+    var anwendung = BefehlAnwender.anwenden(alt, List.of());
+    var synopse = SynopseBuilder.baue(alt, anwendung, List.of(), true);
+
+    var html = HtmlRenderer.rendere(synopse, "quelle");
+
+    assertThat(html).doesNotContain("<script>");
+    assertThat(html).contains("&lt;script&gt;");
+    assertThat(html).contains("&amp; mehr.");
+  }
+
+  @Test
+  void listetManuellZuPruefendeBefehleAuf() {
+    var alt = gesetz("Text.");
+    var prov = new Provenienz("1", "2.", "Die Nummern 1 bis 3 werden aufgehoben.");
+    var befehl = new UnbekannterBefehl(Stelle.LEER, "Die Nummern 1 bis 3 werden aufgehoben.", prov);
+    var anwendung = BefehlAnwender.anwenden(alt, List.of(befehl));
+    var synopse = SynopseBuilder.baue(alt, anwendung, List.of(), false);
+
+    var html = HtmlRenderer.rendere(synopse, "quelle");
+
+    assertThat(html).contains("Manuell prüfen");
+    assertThat(html).contains("Die Nummern 1 bis 3 werden aufgehoben.");
+  }
+
+  @Test
+  void zeigtWarnungenAn() {
+    var html = HtmlRenderer.rendere(synopse(), "quelle");
+
+    assertThat(html).contains("Eine Testwarnung.");
+  }
+
+  @Test
+  void markiertNeueNormenAlsEingefuegt() {
+    var alt = gesetz("Text.");
+    var neuGesetz =
+        alt.mitNormen(
+            List.of(
+                alt.normen().get(0),
+                new Norm("§ 1a", "Neu", null, List.of(new Absatz(null, "Neuer Inhalt.")), false)));
+    var anwendung = new BefehlAnwender.AnwendungsErgebnis(neuGesetz, List.of());
+    var synopse = SynopseBuilder.baue(alt, anwendung, List.of(), false);
+
+    var html = HtmlRenderer.rendere(synopse, "quelle");
+
+    assertThat(html).contains("§ 1a");
+    assertThat(html).contains("neu</span>");
+    assertThat(html).contains("(nicht vorhanden)");
+    assertThat(html).contains("<ins>Neuer Inhalt.</ins>");
+  }
+}
diff --git a/src/test/resources/eu/mulk/aendggner/mini-gii.xml b/src/test/resources/eu/mulk/aendggner/mini-gii.xml
new file mode 100644
index 0000000..866f81b
--- /dev/null
+++ b/src/test/resources/eu/mulk/aendggner/mini-gii.xml
@@ -0,0 +1,81 @@
+<?xml version="1.0" encoding="UTF-8"?>
+<!DOCTYPE dokumente SYSTEM "http://www.gesetze-im-internet.de/dtd/1.01/gii-norm.dtd">
+<dokumente builddate="20201120213007" doknr="BJNR000000000">
+  <norm builddate="20201120213007" doknr="BJNR000000000">
+    <metadaten>
+      <jurabk>TestG</jurabk>
+      <amtabk>TestG</amtabk>
+      <kurzue>Testgesetz</kurzue>
+      <langue>Gesetz zur Erprobung des ÄndGgners</langue>
+    </metadaten>
+    <textdaten>
+      <fussnoten>
+        <Content>
+          <P>Fußnote, die keine Einzelnorm ist.</P>
+        </Content>
+      </fussnoten>
+    </textdaten>
+  </norm>
+  <norm builddate="20201120213007" doknr="BJNR000000000BJNE000100000">
+    <metadaten>
+      <jurabk>TestG</jurabk>
+      <enbez>Inhaltsübersicht</enbez>
+    </metadaten>
+    <textdaten>
+      <text format="XML">
+        <Content>
+          <P>
+            <table frame="none">
+              <tgroup cols="2">
+                <tbody valign="top">
+                  <row>
+                    <entry colname="col1">§ 1</entry>
+                    <entry colname="col2">Zweck des Gesetzes</entry>
+                  </row>
+                  <row>
+                    <entry colname="col1">§ 2</entry>
+                    <entry colname="col2">Begriffsbestimmungen</entry>
+                  </row>
+                </tbody>
+              </tgroup>
+            </table>
+          </P>
+        </Content>
+      </text>
+    </textdaten>
+  </norm>
+  <norm builddate="20201120213007" doknr="BJNR000000000BJNE000200000">
+    <metadaten>
+      <jurabk>TestG</jurabk>
+      <gliederungseinheit>
+        <gliederungskennzahl>010</gliederungskennzahl>
+        <gliederungsbez>1. Abschnitt</gliederungsbez>
+        <gliederungstitel>Allgemeine Vorschriften</gliederungstitel>
+      </gliederungseinheit>
+      <enbez>§ 1</enbez>
+      <titel format="XML">Zweck des Gesetzes</titel>
+    </metadaten>
+    <textdaten>
+      <text format="XML">
+        <Content>
+          <P>(1) Zweck dieses Gesetzes ist die Erprobung. Die Erprobung erfolgt sorgfältig.</P>
+          <P>(2) Die Erprobung umfasst <DL Font="normal" Type="arabic"><DT>1.</DT><DD Font="normal"><LA Size="normal">das Einlesen von Gesetzen und</LA></DD><DT>2.</DT><DD Font="normal"><LA Size="normal">die Anwendung von Änderungsbefehlen, insbesondere<DL Font="normal" Type="alpha"><DT>a)</DT><DD Font="normal"><LA Size="normal">Ersetzungen und</LA></DD><DT>b)</DT><DD Font="normal"><LA Size="normal">Einfügungen.</LA></DD></DL></LA></DD></DL>Weitere Einzelheiten regelt die Rechtsverordnung.</P>
+        </Content>
+      </text>
+    </textdaten>
+  </norm>
+  <norm builddate="20201120213007" doknr="BJNR000000000BJNE000300000">
+    <metadaten>
+      <jurabk>TestG</jurabk>
+      <enbez>§ 2</enbez>
+      <titel format="XML">(weggefallen)</titel>
+    </metadaten>
+    <textdaten>
+      <text format="XML">
+        <Content>
+          <P>(weggefallen)</P>
+        </Content>
+      </text>
+    </textdaten>
+  </norm>
+</dokumente>