From 7235b34eeb6ff331c152116cbe4df846d7476110 Mon Sep 17 00:00:00 2001 From: Matthias Andreas Benkard Date: Fri, 14 Aug 2026 18:58:25 +0200 Subject: =?UTF-8?q?Quellformate=20jenseits=20des=20verk=C3=BCndeten=20Gese?= =?UTF-8?q?tzes?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Bisher nahm ÄndGgner nur verkündete Artikelgesetze und Gesetzentwürfe an, ohne den Unterschied zu kennen. Dokumente, die einen Entwurf ändern statt ein Gesetz, lieferten still null Befehle. Neu: * Dokumentart-Erkennung aus dem Rohtext (DokumentErkenner, DokumentArt, DokumentKopf) — vor der Bereinigung, weil gerade die Drucksachenköpfe als Kolumnentitel wegfallen. Erkannt wird nie am Dateinamen: Die Beispieldaten enthalten eine Datei namens Beschlussempfehlung, die in Wahrheit ein Entschließungsantrag ist. Dokumente ohne Befehle erzeugen jetzt eine benannte Warnung statt stiller Leere. * Änderungsanträge (AenderungsantragParser, EntwurfsPatcher): Der Antrag wird auf den Entwurfstext angewandt, der geänderte Entwurf danach wie gewohnt auf das Stammgesetz — eine komponierte Synopse. Drucksachen- und Gesetzesstellen bleiben getrennte Typen; die Zuordnung Antrag→Entwurf läuft über die Drucksachennummer, nicht über die Argumentreihenfolge. Der 1910 Zeilen große BefehlAnwender bleibt unberührt. * Beschlussempfehlungen werden erkannt und mit Begründung übergangen, die den brauchbaren Entwurf beim Namen nennt. Die Spaltentrennung der Zusammenstellung ist gebaut (FontgroessenFilter.Spalte, koordinatenbasiert am Steg) und belegt: Die linke Spalte ergibt Befehl für Befehl den Regierungsentwurf. Die rechte Spalte bleibt bewusst offen — sie vermerkt „unverändert“ auch zeilenweise innerhalb zitierter Blöcke, ihre Anführungszeichen gehen daher nicht auf; nötig ist die zeilenweise Zuordnung beider Spalten über die gemeinsame Grundlinie. Die Synopse kennzeichnet eine Entwurfsfassung als solche. 296 Tests grün (vorher 269); alle gepinnten Akzeptanzzahlen unverändert. Co-Authored-By: Claude Opus 5 Change-Id: I0001d25fcbd9969fc06eea675edc4326ce2e02f9 --- src/test/java/eu/mulk/aendggner/EndToEndTest.java | 168 +++++++++++++++++++-- .../parse/AenderungsantragParserTest.java | 81 ++++++++++ .../aenderung/parse/DokumentErkennerTest.java | 66 ++++++++ .../aenderung/parse/EntwurfsPatcherTest.java | 107 +++++++++++++ 4 files changed, 406 insertions(+), 16 deletions(-) create mode 100644 src/test/java/eu/mulk/aendggner/aenderung/parse/AenderungsantragParserTest.java create mode 100644 src/test/java/eu/mulk/aendggner/aenderung/parse/DokumentErkennerTest.java create mode 100644 src/test/java/eu/mulk/aendggner/aenderung/parse/EntwurfsPatcherTest.java (limited to 'src/test/java/eu') 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. * *

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 nicht nacheinander im Inhaltsstrom, sondern zeilenweise verschränkt; nur + * die Koordinaten trennen sie. + * + *

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"); + } +} -- cgit v1.2.1