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("<script>");
+ assertThat(html).contains("& 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>