aboutsummaryrefslogtreecommitdiff
path: root/src/test/java
diff options
context:
space:
mode:
Diffstat (limited to 'src/test/java')
-rw-r--r--src/test/java/eu/mulk/aendggner/EndToEndTest.java168
-rw-r--r--src/test/java/eu/mulk/aendggner/aenderung/parse/AenderungsantragParserTest.java81
-rw-r--r--src/test/java/eu/mulk/aendggner/aenderung/parse/DokumentErkennerTest.java66
-rw-r--r--src/test/java/eu/mulk/aendggner/aenderung/parse/EntwurfsPatcherTest.java107
4 files changed, 406 insertions, 16 deletions
diff --git a/src/test/java/eu/mulk/aendggner/EndToEndTest.java b/src/test/java/eu/mulk/aendggner/EndToEndTest.java
index 4e4d547..212e070 100644
--- a/src/test/java/eu/mulk/aendggner/EndToEndTest.java
+++ b/src/test/java/eu/mulk/aendggner/EndToEndTest.java
@@ -7,8 +7,11 @@ import eu.mulk.aendggner.aenderung.Aenderungsbefehl;
import eu.mulk.aendggner.aenderung.Aenderungsbefehl.StrukturEinfuegung;
import eu.mulk.aendggner.aenderung.Aenderungsbefehl.UnbekannterBefehl;
import eu.mulk.aendggner.aenderung.Aenderungsbefehl.WortAnker;
+import eu.mulk.aendggner.aenderung.parse.AenderungsantragParser;
import eu.mulk.aendggner.aenderung.parse.AenderungsgesetzParser;
+import eu.mulk.aendggner.aenderung.parse.EntwurfsPatcher;
import eu.mulk.aendggner.aenderung.parse.PatchTextExtraktor;
+import eu.mulk.aendggner.aenderung.parse.SuperskriptModus;
import eu.mulk.aendggner.aenderung.parse.TextBereiniger;
import eu.mulk.aendggner.aenderung.parse.ZitatExtraktor;
import eu.mulk.aendggner.anwendung.BefehlAnwender;
@@ -549,8 +552,9 @@ class EndToEndTest {
var absatz2 = neu.norm("§ 1").orElseThrow().absaetze().get(1).text();
assertThat(absatz2)
.contains("Verordnung (EU) 2016/679")
- .contains("Telekommunikation-Digitale-Dienste-Datenschutz-Gesetzes vom 23. Juni 2021 (BGBl."
- + " I S. 1982; 2022 I S. 1045)")
+ .contains(
+ "Telekommunikation-Digitale-Dienste-Datenschutz-Gesetzes vom 23. Juni 2021 (BGBl."
+ + " I S. 1982; 2022 I S. 1045)")
.doesNotContain("WDR-Rundfunkdatenschutzbeauftragte");
// 3./4. § 2: Fundstellen-Ersetzung im Satzteil vor Nummer 1 und Neufassung der Nummern 1 und 2.
var paragraph2 = neu.norm("§ 2").orElseThrow().gesamtText();
@@ -611,8 +615,7 @@ class EndToEndTest {
"1", "2", "3", "4", "5", "6", "7", "8", "9", "10", "11", "12", "13", "14", "15");
assertThat(paragraph3.absaetze().get(2).text())
.startsWith("Der WDR veranstaltet ein landesweites Fernsehprogramm");
- assertThat(paragraph3.absaetze().get(12).text())
- .startsWith("Der WDR strebt Partnerschaften");
+ assertThat(paragraph3.absaetze().get(12).text()).startsWith("Der WDR strebt Partnerschaften");
// 2. Nummern-Kaskade in § 15 Absatz 3: Nummer 22 entfällt, die folgenden rücken auf — die
// Aufzählungsmarken im Text sind mitgezogen.
@@ -694,11 +697,11 @@ class EndToEndTest {
* eine koordinatenbasierte Spaltenerkennung ist dafür nicht nötig.
*
* <p>Kein voller Akzeptanztest: gesetze.berlin.de ist wie das schleswig-holsteinische Portal eine
- * anmeldepflichtige juris-Anwendung, die Stammfassungen sind daraus nicht zu beschaffen. Artikel 1
- * ändert zudem eine *Anlage*; anlagenbezogene Befehle wendet ÄndGgner an (siehe GEG), doch der
- * {@link eu.mulk.aendggner.gesetz.land.LandesRechtTextParser} kennt nur „§“- und
- * „Art.“-Normköpfe — eine handgepflegte Stammfassung könnte diese Anlage nicht tragen (dieselbe
- * Grenze, an der Baden-Württemberg zurückgestellt wurde).
+ * anmeldepflichtige juris-Anwendung, die Stammfassungen sind daraus nicht zu beschaffen. Artikel
+ * 1 ändert zudem eine *Anlage*; anlagenbezogene Befehle wendet ÄndGgner an (siehe GEG), doch der
+ * {@link eu.mulk.aendggner.gesetz.land.LandesRechtTextParser} kennt nur „§“- und „Art.“-Normköpfe
+ * — eine handgepflegte Stammfassung könnte diese Anlage nicht tragen (dieselbe Grenze, an der
+ * Baden-Württemberg zurückgestellt wurde).
*/
@Test
void asogLafAendGBerlin() throws Exception {
@@ -737,8 +740,7 @@ class EndToEndTest {
// § 2 Absatz 4 Satz 1 wird wie folgt geändert“). Der Zusatz benennt die Anlage nur — er darf
// nicht als Ziel „§ 2“ gelesen werden; die Punkte erben „Anlage“ als Kontext.
var asog =
- new Gesetz(
- "ASOG Bln", "Allgemeines Sicherheits- und Ordnungsgesetz", null, List.of());
+ new Gesetz("ASOG Bln", "Allgemeines Sicherheits- und Ordnungsgesetz", null, List.of());
var artikel1 = new AenderungsgesetzParser().parse(text, asog, null);
assertThat(artikel1.artikel()).containsExactly("1");
assertThat(artikel1.befehle())
@@ -764,8 +766,7 @@ class EndToEndTest {
var einfuegung = (StrukturEinfuegung) bb;
assertThat(einfuegung.vorher()).isTrue();
assertThat(einfuegung.bezeichnung()).isEqualTo("5");
- assertThat(einfuegung.anker())
- .isEqualTo(new WortAnker.VorWoertern("Aus dem Bereich Verkehr:"));
+ assertThat(einfuegung.anker()).isEqualTo(new WortAnker.VorWoertern("Aus dem Bereich Verkehr:"));
// Bei diesem Punkt fehlt im amtlichen Satz das schließende Anführungszeichen. Ohne die Grenze
// am nächsten Aufzählungspunkt verschlänge das offene Zitat die Punkte cc) und c).
@@ -793,8 +794,8 @@ class EndToEndTest {
var gesetz = new eu.mulk.aendggner.gesetz.land.LandesRechtLoader().load(alt);
// Der Klammerzusatz führt hier nur eine (mehrteilige) Bezeichnung, keinen Kurztitel am
// Gedankenstrich; genau sie nennt der Einleitungssatz des Artikels 4.
- assertThat(gesetz.jurabk()).isEqualTo("Siebzehnter Rundfunkänderungsstaatsvertrag"
- + " Ausführungsgesetz");
+ assertThat(gesetz.jurabk())
+ .isEqualTo("Siebzehnter Rundfunkänderungsstaatsvertrag" + " Ausführungsgesetz");
assertThat(gesetz.kurzue()).isNull();
assertThat(gesetz.normen()).hasSize(3);
// Der alte § 1 besteht aus einem einzigen, unbezeichneten Absatz.
@@ -805,7 +806,8 @@ class EndToEndTest {
var parseErgebnis = new AenderungsgesetzParser().parse(text, gesetz, null);
// Nur Artikel 4 trifft dieses Gesetz — die Auswahl gelingt über die Bezeichnung allein.
assertThat(parseErgebnis.artikel()).containsExactly("4");
- // Der Artikel trägt keine nummerierten Punkte: Der Text nach der Änderungsformel ist der Befehl.
+ // Der Artikel trägt keine nummerierten Punkte: Der Text nach der Änderungsformel ist der
+ // Befehl.
assertThat(parseErgebnis.befehle()).hasSize(1);
assertThat(parseErgebnis.befehle().get(0)).isInstanceOf(Aenderungsbefehl.Neufassung.class);
assertThat(parseErgebnis.befehle().get(0).stelle().anzeigeText()).isEqualTo("§ 1");
@@ -920,6 +922,140 @@ class EndToEndTest {
.containsExactly("§ 14 Nummer 4", "§ 14 Nummer 3");
}
+ /**
+ * Änderungsantrag der GRÜNEN (Ltg-Drs. 19/10365) zum Landtags-Gesetzentwurf 19/9707: Er ändert
+ * nicht das Stammgesetz, sondern die Drucksache — genau den Befehl § 3 Nr. 22, der die Artenliste
+ * des neuen § 18 AVBayJG zitiert.
+ */
+ @Test
+ void bayJgAenderungsantragAendertDenEntwurf() throws Exception {
+ var entwurfPdf = SAMPLEDATA.resolve("BayJG/Ltg-Drs-19-9707_Gesetzentwurf.pdf");
+ var antragPdf = SAMPLEDATA.resolve("BayJG/Ltg-Drs-19-10365_Aenderungsantrag-Gruene.pdf");
+ assumeTrue(Files.exists(entwurfPdf) && Files.exists(antragPdf), "BayJG-Beispieldaten fehlen");
+
+ var extraktor = new PatchTextExtraktor(SuperskriptModus.BEHALTEN);
+ var entwurf = TextBereiniger.bereinige(extraktor.extrahiere(entwurfPdf));
+ var antrag = TextBereiniger.bereinige(extraktor.extrahiere(antragPdf));
+
+ // Der Entwurf stellt Wolf und Goldschakal nebeneinander unter Jagdrecht.
+ assertThat(entwurf).contains("1.29. Wolf (Canis lupus),");
+ assertThat(entwurf).contains("1.30. Goldschakal (Canis aureus);");
+
+ var parseErgebnis = AenderungsantragParser.parse(antrag);
+ assertThat(parseErgebnis.warnungen()).isEmpty();
+ assertThat(parseErgebnis.befehle()).hasSize(2);
+
+ var patch = EntwurfsPatcher.wendeAn(entwurf, parseErgebnis.befehle());
+ assertThat(patch.warnungen()).isEmpty();
+ assertThat(patch.angewandt()).isEqualTo(2);
+
+ // Der Goldschakal ist gestrichen, der Wolf schließt die Liste nun mit Semikolon ab.
+ assertThat(patch.text()).contains("1.29. Wolf (Canis lupus);");
+ assertThat(patch.text()).doesNotContain("Goldschakal (Canis aureus)");
+ // Die übrigen 154 Befehle des Entwurfs bleiben unangetastet.
+ assertThat(patch.text()).contains("1.28. Mink (Neovison vison),");
+ assertThat(patch.text()).contains("2. Federwild:");
+ }
+
+ /**
+ * Derselbe Antrag durch die volle Pipeline. Er zielt auf § 3 des Entwurfs, und der ändert die
+ * AVBayJG, nicht das BayJG — die Synopse des Stammgesetzes bleibt deshalb zu Recht dieselbe. Das
+ * ist die eigentliche Probe: Ein Antrag darf nicht auf das Stammgesetz durchschlagen, nur weil
+ * seine Stellenangaben zufällig auch dort passen könnten.
+ */
+ @Test
+ void bayJgAenderungsantragLaesstDasStammgesetzUnberuehrt() throws Exception {
+ var alt = SAMPLEDATA.resolve("BayJG/BayJG-alt.txt");
+ var entwurfPdf = SAMPLEDATA.resolve("BayJG/Ltg-Drs-19-9707_Gesetzentwurf.pdf");
+ var antragPdf = SAMPLEDATA.resolve("BayJG/Ltg-Drs-19-10365_Aenderungsantrag-Gruene.pdf");
+ assumeTrue(
+ Files.exists(alt) && Files.exists(entwurfPdf) && Files.exists(antragPdf),
+ "BayJG-Beispieldaten fehlen");
+
+ var ohne = Pipeline.erzeugeSynopse(alt, List.of(entwurfPdf), null, false);
+ var mit = Pipeline.erzeugeSynopse(alt, List.of(entwurfPdf, antragPdf), null, false);
+
+ assertThat(mit.anzahlAngewandt()).isEqualTo(ohne.anzahlAngewandt()).isEqualTo(151);
+ assertThat(mit.anzahlManuell()).isEqualTo(ohne.anzahlManuell()).isEqualTo(3);
+ // Beide Läufe zeigen eine Entwurfsfassung, nicht geltendes Recht.
+ assertThat(mit.html()).contains("Entwurfsfassung");
+ assertThat(mit.html()).contains("[Änderungsantrag Drs. 19/10365]");
+ }
+
+ /**
+ * Die Zusammenstellung einer Beschlussempfehlung (BT-Drs. 20/7619) steht zweispaltig: links der
+ * Entwurf, rechts die Ausschussfassung. Anders als beim alten BGBl und beim Berliner GVBl folgen
+ * die Spalten <em>nicht</em> nacheinander im Inhaltsstrom, sondern zeilenweise verschränkt; nur
+ * die Koordinaten trennen sie.
+ *
+ * <p>Die Probe aufs Exempel für die Spaltentrennung: Die linke Spalte muss Wort für Wort den
+ * Regierungsentwurf ergeben, aus dem die Zusammenstellung gebaut ist — dieselbe Befehlszahl wie
+ * aus BT-Drs. 20/6875, das als eigener Testfall danebensteht.
+ */
+ @Test
+ void zusammenstellungTrenntDieSpaltenSauber() throws Exception {
+ var xml = SAMPLEDATA.resolve("GEG/BJNR172810020.xml");
+ var empfehlung = SAMPLEDATA.resolve("GEG/BT-Drs-20-7619_Beschlussempfehlung.pdf");
+ var entwurf = SAMPLEDATA.resolve("GEG/BT-Drs-20-6875_Regierungsentwurf.pdf");
+ assumeTrue(
+ Files.exists(xml) && Files.exists(empfehlung) && Files.exists(entwurf),
+ "GEG-Beispieldaten fehlen");
+
+ var gesetz = new GiiXmlLoader().load(xml);
+ var extraktor = new PatchTextExtraktor();
+ var spalten = extraktor.extrahiereSpalten(empfehlung);
+
+ var ausEntwurfsspalte =
+ new AenderungsgesetzParser()
+ .parse(TextBereiniger.bereinige(spalten.links()), gesetz, null, true);
+ var ausDrucksache =
+ new AenderungsgesetzParser()
+ .parse(TextBereiniger.bereinige(extraktor.extrahiere(entwurf)), gesetz, null, true);
+
+ assertThat(ausEntwurfsspalte.artikel()).isEqualTo(ausDrucksache.artikel());
+ assertThat(ausEntwurfsspalte.befehle())
+ .as("die linke Spalte ist der Regierungsentwurf")
+ .hasSameSizeAs(ausDrucksache.befehle());
+ assertThat(ausDrucksache.befehle()).hasSize(117);
+
+ // Die rechte Spalte trägt überwiegend den Vermerk „unverändert“ — gesperrt gesetzt im PDF,
+ // vom TextBereiniger auf die Normalform gebracht.
+ assertThat(TextBereiniger.bereinige(spalten.rechts())).contains("unverändert");
+ }
+
+ /**
+ * Eine Beschlussempfehlung wird als solche erkannt und mit Begründung übergangen, statt eine halb
+ * aufgelöste Fassung auszugeben. Die Meldung nennt den Entwurf, der stattdessen taugt.
+ */
+ @Test
+ void beschlussempfehlungWirdMitBegruendungUebergangen() throws Exception {
+ var xml = SAMPLEDATA.resolve("GEG/BJNR172810020.xml");
+ var pdf = SAMPLEDATA.resolve("GEG/BT-Drs-20-7619_Beschlussempfehlung.pdf");
+ assumeTrue(Files.exists(xml) && Files.exists(pdf), "GEG-Beispieldaten fehlen");
+
+ var ergebnis = Pipeline.erzeugeSynopse(xml, List.of(pdf), null, false);
+
+ assertThat(ergebnis.anzahlAngewandt()).isZero();
+ assertThat(ergebnis.html()).contains("Beschlussempfehlung");
+ assertThat(ergebnis.html()).contains("Drs. 20/6875");
+ }
+
+ /**
+ * Ein Entschließungsantrag trägt keine Rechtsetzungsbefehle. Er wird als solcher erkannt,
+ * übergangen und gemeldet — nicht stillschweigend zu null Befehlen verarbeitet.
+ */
+ @Test
+ void entschliessungsantragWirdGemeldetStattStillUebergangen() throws Exception {
+ var xml = SAMPLEDATA.resolve("GEG/BJNR172810020.xml");
+ var pdf = SAMPLEDATA.resolve("GEG/BT-Drs-21-7071_Beschlussempfehlung.pdf");
+ assumeTrue(Files.exists(xml) && Files.exists(pdf), "GEG-Beispieldaten fehlen");
+
+ var ergebnis = Pipeline.erzeugeSynopse(xml, List.of(pdf), null, false);
+
+ assertThat(ergebnis.anzahlAngewandt()).isZero();
+ assertThat(ergebnis.html()).contains("keine Änderungsbefehle");
+ }
+
private static Aenderungsbefehl befehlZu(
AenderungsgesetzParser.ParseErgebnis ergebnis, String gliederungsPfad) {
return ergebnis.befehle().stream()
diff --git a/src/test/java/eu/mulk/aendggner/aenderung/parse/AenderungsantragParserTest.java b/src/test/java/eu/mulk/aendggner/aenderung/parse/AenderungsantragParserTest.java
new file mode 100644
index 0000000..fcb1c80
--- /dev/null
+++ b/src/test/java/eu/mulk/aendggner/aenderung/parse/AenderungsantragParserTest.java
@@ -0,0 +1,81 @@
+package eu.mulk.aendggner.aenderung.parse;
+
+import static org.assertj.core.api.Assertions.assertThat;
+
+import eu.mulk.aendggner.aenderung.Aenderungsbefehl;
+import org.junit.jupiter.api.Test;
+
+class AenderungsantragParserTest {
+
+ /** Der bayerische Beleg (Ltg-Drs. 19/10365), auf den Beschlussteil verkürzt. */
+ private static final String ANTRAG =
+ """
+ Änderungsantrag
+ der Abgeordneten Katharina Schulze und Fraktion (BÜNDNIS 90/DIE GRÜNEN)
+ hier: Goldschakal nicht ins Jagdrecht aufnehmen
+ (Drs. 19/9707)
+ Der Landtag wolle beschließen:
+ In § 3 Nr. 22 wird § 18 Nr. 1 wie folgt geändert:
+ 1. In Nr. 1.29 die Angabe „ ,“ am Ende durch die Angabe „ ;“ ersetzt.
+ 2. Nr. 1.30 aufgehoben.
+ Begründung:
+ Die Aufnahme des Goldschakals ins Jagdrecht ist nicht zielführend.
+ """;
+
+ @Test
+ void liestBeideBefehleAusDemBeschlussteil() {
+ var ergebnis = AenderungsantragParser.parse(ANTRAG);
+
+ assertThat(ergebnis.warnungen()).isEmpty();
+ assertThat(ergebnis.befehle()).hasSize(2);
+
+ var erster = ergebnis.befehle().get(0);
+ assertThat(erster.drucksachenStelle().container()).isEqualTo("§ 3");
+ assertThat(erster.drucksachenStelle().punktPfad()).containsExactly("22");
+ assertThat(erster.zitatStelle().anzeigeText()).isEqualTo("§ 18 Nummer 1");
+ // Die elliptische Antragsform („… ersetzt.“ ohne „wird“) wird zum vollständigen Satz ergänzt.
+ assertThat(erster.befehl()).isInstanceOf(Aenderungsbefehl.Ersetzung.class);
+ var ersetzung = (Aenderungsbefehl.Ersetzung) erster.befehl();
+ assertThat(ersetzung.alt()).isEqualTo(",");
+ assertThat(ersetzung.neu()).isEqualTo(";");
+ assertThat(ersetzung.amEnde()).isTrue();
+ assertThat(ersetzung.stelle().anzeigeText()).isEqualTo("Nummer 1.29");
+
+ var zweiter = ergebnis.befehle().get(1);
+ assertThat(zweiter.befehl()).isInstanceOf(Aenderungsbefehl.Aufhebung.class);
+ assertThat(zweiter.befehl().stelle().anzeigeText()).isEqualTo("Nummer 1.30");
+ }
+
+ /** Der Begründungsteil steht hinter den Befehlen und darf keine erzeugen. */
+ @Test
+ void begruendungErzeugtKeineBefehle() {
+ var mitLangerBegruendung =
+ ANTRAG + "1. Der Goldschakal ist in Anhang V aufgeführt.\n2. Er ist kein jagdbares Wild.\n";
+ assertThat(AenderungsantragParser.parse(mitLangerBegruendung).befehle()).hasSize(2);
+ }
+
+ @Test
+ void ohneBeschlussformelWirdGewarntStattStillGeschwiegen() {
+ var ergebnis = AenderungsantragParser.parse("Änderungsantrag\nIrgendein Fließtext.\n");
+
+ assertThat(ergebnis.befehle()).isEmpty();
+ assertThat(ergebnis.warnungen()).singleElement().asString().contains("wolle beschließen");
+ }
+
+ /** Die Bundestagsform des Rahmens: „In Artikel 1 Nummer 3 …“. */
+ @Test
+ void erkenntArtikelAlsDrucksachenContainer() {
+ var antrag =
+ """
+ Änderungsantrag
+ Der Bundestag wolle beschließen:
+ In Artikel 1 Nummer 3 wird § 9a Nr. 2 wie folgt geändert:
+ 1. Nr. 2.1 aufgehoben.
+ """;
+ var ergebnis = AenderungsantragParser.parse(antrag);
+
+ assertThat(ergebnis.befehle()).hasSize(1);
+ assertThat(ergebnis.befehle().get(0).drucksachenStelle().container()).isEqualTo("Artikel 1");
+ assertThat(ergebnis.befehle().get(0).drucksachenStelle().punktPfad()).containsExactly("3");
+ }
+}
diff --git a/src/test/java/eu/mulk/aendggner/aenderung/parse/DokumentErkennerTest.java b/src/test/java/eu/mulk/aendggner/aenderung/parse/DokumentErkennerTest.java
new file mode 100644
index 0000000..7271efb
--- /dev/null
+++ b/src/test/java/eu/mulk/aendggner/aenderung/parse/DokumentErkennerTest.java
@@ -0,0 +1,66 @@
+package eu.mulk.aendggner.aenderung.parse;
+
+import static org.assertj.core.api.Assertions.assertThat;
+import static org.junit.jupiter.api.Assumptions.assumeTrue;
+
+import eu.mulk.aendggner.aenderung.DokumentArt;
+import java.nio.file.Files;
+import java.nio.file.Path;
+import org.junit.jupiter.api.Test;
+import org.junit.jupiter.params.ParameterizedTest;
+import org.junit.jupiter.params.provider.CsvSource;
+
+class DokumentErkennerTest {
+
+ private static final Path SAMPLEDATA = Path.of("src/main/resources/sampledata");
+
+ /**
+ * Die Erkennung ist eine Heuristik auf Kopfzeilen; sie wird deshalb gegen wirkliche Dokumente
+ * festgenagelt, je eines pro Art und Herausgeber. Der Dateiname zählt dabei ausdrücklich nicht —
+ * {@code BT-Drs-21-7071_Beschlussempfehlung.pdf} heißt so, ist aber ein Entschließungsantrag.
+ */
+ @ParameterizedTest
+ @CsvSource({
+ "BayJG/Ltg-Drs-19-10365_Aenderungsantrag-Gruene.pdf, AENDERUNGSANTRAG",
+ "BayJG/Ltg-Drs-19-9707_Gesetzentwurf.pdf, GESETZENTWURF",
+ "BayJG/Plenarprotokoll-19-72_2te-Lesung.pdf, OHNE_BEFEHLE",
+ "BayJG/gvbl-2026-06.pdf, ARTIKELGESETZ",
+ "GEG/BT-Drs-20-7619_Beschlussempfehlung.pdf, BESCHLUSSEMPFEHLUNG",
+ "GEG/BT-Drs-21-7071_Beschlussempfehlung.pdf, OHNE_BEFEHLE",
+ "GEG/BT-Drs-20-6875_Regierungsentwurf.pdf, GESETZENTWURF",
+ "GEG/Referentenentwurf_GModG_2026-05-05.pdf, GESETZENTWURF",
+ "GEG/bgbl123s0280_regelungstext.pdf, ARTIKELGESETZ",
+ "IfSG/1924334.pdf, BESCHLUSSEMPFEHLUNG",
+ "NRW/GV-NRW-2026-S202_22-Rundfunkaenderungsgesetz.pdf, ARTIKELGESETZ",
+ "Sachsen/SaechsGVBl-2026-S134_AendG-SaechsBeamtVG_revosax.pdf, ARTIKELGESETZ",
+ })
+ void ordnetBeispieldokumenteRichtigEin(String datei, DokumentArt erwartet) throws Exception {
+ var pfad = SAMPLEDATA.resolve(datei.strip());
+ assumeTrue(Files.exists(pfad), "Beispieldatei fehlt: " + datei);
+
+ var kopf = DokumentErkenner.erkenne(new PatchTextExtraktor().extrahiere(pfad));
+
+ assertThat(kopf.art()).isEqualTo(erwartet);
+ }
+
+ /** Die Drucksachennummern stiften die Verbindung zwischen Antrag und Entwurf. */
+ @Test
+ void liestEigeneUndBezogeneDrucksachennummern() throws Exception {
+ var antrag = SAMPLEDATA.resolve("BayJG/Ltg-Drs-19-10365_Aenderungsantrag-Gruene.pdf");
+ assumeTrue(Files.exists(antrag), "BayJG-Beispieldaten fehlen");
+
+ var kopf = DokumentErkenner.erkenne(new PatchTextExtraktor().extrahiere(antrag));
+
+ assertThat(kopf.eigeneDrucksache()).isEqualTo("19/10365");
+ assertThat(kopf.bezugsDrucksachen()).containsExactly("19/9707");
+ assertThat(kopf.titel()).isEqualTo("Goldschakal nicht ins Jagdrecht aufnehmen");
+ }
+
+ @Test
+ void kennzeichnetEntwurfsfassungen() {
+ assertThat(DokumentArt.ARTIKELGESETZ.istEntwurfsfassung()).isFalse();
+ assertThat(DokumentArt.GESETZENTWURF.istEntwurfsfassung()).isTrue();
+ assertThat(DokumentArt.AENDERUNGSANTRAG.istEntwurfsfassung()).isTrue();
+ assertThat(DokumentArt.BESCHLUSSEMPFEHLUNG.istEntwurfsfassung()).isTrue();
+ }
+}
diff --git a/src/test/java/eu/mulk/aendggner/aenderung/parse/EntwurfsPatcherTest.java b/src/test/java/eu/mulk/aendggner/aenderung/parse/EntwurfsPatcherTest.java
new file mode 100644
index 0000000..5d76106
--- /dev/null
+++ b/src/test/java/eu/mulk/aendggner/aenderung/parse/EntwurfsPatcherTest.java
@@ -0,0 +1,107 @@
+package eu.mulk.aendggner.aenderung.parse;
+
+import static org.assertj.core.api.Assertions.assertThat;
+
+import org.junit.jupiter.api.Test;
+
+class EntwurfsPatcherTest {
+
+ /**
+ * Ein Entwurf im Kleinen: § 3 ändert eine Verordnung, sein 22. Befehl fasst § 18 neu und zitiert
+ * dabei eine Artenliste. Genau in dieses Zitat greift der Antrag hinein.
+ */
+ private static final String ENTWURF =
+ """
+ § 2
+ Änderung eines anderen Gesetzes
+ 1. § 4 wird aufgehoben.
+ § 3
+ Änderung der Verordnung zur Ausführung des Bayerischen Jagdgesetzes
+ Die Verordnung wird wie folgt geändert:
+ 21. § 17 wird aufgehoben.
+ 22. § 18 wird wie folgt gefasst:
+ „§ 18
+ Tierarten
+ Dem Jagdrecht unterliegen folgende Tierarten:
+ 1. Haarwild:
+ 1.28. Mink (Neovison vison), 1.29. Wolf (Canis lupus),
+ 1.30. Goldschakal (Canis aureus);
+ 2. Federwild:
+ 2.1. Rebhuhn (Perdix perdix).“
+ 23. § 19 wird aufgehoben.
+ § 4
+ Inkrafttreten
+ """;
+
+ private static final String ANTRAG =
+ """
+ Änderungsantrag
+ Der Landtag wolle beschließen:
+ In § 3 Nr. 22 wird § 18 Nr. 1 wie folgt geändert:
+ 1. In Nr. 1.29 die Angabe „ ,“ am Ende durch die Angabe „ ;“ ersetzt.
+ 2. Nr. 1.30 aufgehoben.
+ """;
+
+ @Test
+ void aendertGenauDasZitierteAufzaehlungsglied() {
+ var befehle = AenderungsantragParser.parse(ANTRAG).befehle();
+
+ var ergebnis = EntwurfsPatcher.wendeAn(ENTWURF, befehle);
+
+ assertThat(ergebnis.warnungen()).isEmpty();
+ assertThat(ergebnis.angewandt()).isEqualTo(2);
+ // Das Komma nach dem Wolf wird zum Semikolon, weil er nun das letzte Glied ist …
+ assertThat(ergebnis.text()).contains("1.29. Wolf (Canis lupus);");
+ // … und der Goldschakal verschwindet.
+ assertThat(ergebnis.text()).doesNotContain("Goldschakal");
+ // Alles andere bleibt unangetastet, insbesondere die Nachbarglieder und die Nachbarbefehle.
+ assertThat(ergebnis.text()).contains("1.28. Mink (Neovison vison),");
+ assertThat(ergebnis.text()).contains("2. Federwild:");
+ assertThat(ergebnis.text()).contains("21. § 17 wird aufgehoben.");
+ assertThat(ergebnis.text()).contains("23. § 19 wird aufgehoben.");
+ }
+
+ /**
+ * Die Aufzählung des Zitats („1. Haarwild:“) sieht aus wie ein Gliederungspunkt des Entwurfs. Der
+ * Patcher darf sie nicht dafür halten, sonst endete der Punkt 22 schon vor der Artenliste.
+ */
+ @Test
+ void verwechseltZitatAufzaehlungNichtMitEntwurfsGliederung() {
+ var befehle = AenderungsantragParser.parse(ANTRAG).befehle();
+ assertThat(EntwurfsPatcher.wendeAn(ENTWURF, befehle).angewandt()).isEqualTo(2);
+ }
+
+ @Test
+ void meldetEinenBefehlAufEineNichtVorhandeneStelle() {
+ var antrag =
+ """
+ Änderungsantrag
+ Der Landtag wolle beschließen:
+ In § 3 Nr. 99 wird § 18 Nr. 1 wie folgt geändert:
+ 1. Nr. 1.30 aufgehoben.
+ """;
+
+ var ergebnis = EntwurfsPatcher.wendeAn(ENTWURF, AenderungsantragParser.parse(antrag).befehle());
+
+ assertThat(ergebnis.angewandt()).isZero();
+ assertThat(ergebnis.text()).isEqualTo(ENTWURF);
+ assertThat(ergebnis.warnungen()).singleElement().asString().contains("§ 3 99");
+ }
+
+ /** Ein Befehl auf einen anderen Entwurfsparagraphen darf dessen Nachbarn nicht treffen. */
+ @Test
+ void bleibtImAngesprochenenParagraphen() {
+ var antrag =
+ """
+ Änderungsantrag
+ Der Landtag wolle beschließen:
+ In § 2 Nr. 22 wird § 18 Nr. 1 wie folgt geändert:
+ 1. Nr. 1.30 aufgehoben.
+ """;
+
+ var ergebnis = EntwurfsPatcher.wendeAn(ENTWURF, AenderungsantragParser.parse(antrag).befehle());
+
+ assertThat(ergebnis.angewandt()).isZero();
+ assertThat(ergebnis.text()).contains("Goldschakal");
+ }
+}