From 65eff08b32a0f00ff0f5e4bda3fe5793f631ed64 Mon Sep 17 00:00:00 2001 From: Matthias Andreas Benkard Date: Sat, 18 Jul 2026 12:24:32 +0200 Subject: =?UTF-8?q?Support=20Bavarian=20state=20law=20(BayJG)=20with=20Art?= =?UTF-8?q?./=C2=A7=20inversion=20and=20superscripts?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Extend ÄndGgner from federal-only to Bavarian state law. Bavarian base laws are structured in Artikel while their amending acts are structured in Paragraphen (inverted from the Bund); official sentence numbers and footnote markers are carried as Unicode superscripts (¹²³, ⁶)). - Generalise Stelle.Paragraph to carry a sigil ("§" or "Art.") and route it through recogniser, applier and resolver (bit-identical Bund output). - Superscript pipeline: geometric detection in FontgroessenFilter (SuperskriptModus BEHALTEN), Superskript util, exact sentence splitting in SatzTeiler, label-based sentence resolution. - BayRechtLoader/BayRechtTextParser for gesetze-bayern.de PDF/plaintext. - §-structured amending acts, GVBl/Landtag column titles, non-breaking spaces, the GVBl continuation quote; Bavarian command forms (footnote aufhebung, Satznummerierung streichung, Wortlaut forms, Halbsatz, gapping chains). Acceptance (EndToEndTest.bayJgGvblAcceptance): the pre-2026 BayJG fassung (BayJG-alt.txt, reconstructed from Wayback single-article snapshots) with GVBl 6/2026 §§ 1-2 applied — 154 commands, 0 unknown, 149 applied automatically, 5 pinned residuals (follow-up edits inside two multi-step renumbering sequences in Art. 29a and Art. 56). Application-side fixes surfaced by the acceptance run (all Bund-safe): sentence-start superscript before §; sentence boundaries = {0} ∪ {each number ≥ 2}; gapping scope inheritance for bare word operations; footnote definition lines hidden from word operations; absatz aufhebung marks "(weggefallen)" keeping its number, and absatz renumbering overwrites an empty placeholder (weggefallen/gegenstandslos) target. 211 tests green (mvnw verify); federal reference numbers unchanged. Co-Authored-By: Claude Opus 4.8 Change-Id: Iebae4c17ca90755c5fd36251362042f3d5796fd0 --- src/test/java/eu/mulk/aendggner/EndToEndTest.java | 93 +++++++++++++ .../parse/AenderungsgesetzParserTest.java | 128 ++++++++++++++++++ .../aenderung/parse/BefehlErkennerTest.java | 117 ++++++++++++++++ .../aenderung/parse/StellenParserTest.java | 27 ++++ .../aenderung/parse/TextBereinigerTest.java | 30 +++++ .../aendggner/anwendung/BefehlAnwenderTest.java | 150 +++++++++++++++++++++ .../mulk/aendggner/anwendung/SatzTeilerTest.java | 24 ++++ .../gesetz/bayern/BayRechtTextParserTest.java | 120 +++++++++++++++++ 8 files changed, 689 insertions(+) create mode 100644 src/test/java/eu/mulk/aendggner/aenderung/parse/AenderungsgesetzParserTest.java create mode 100644 src/test/java/eu/mulk/aendggner/gesetz/bayern/BayRechtTextParserTest.java (limited to 'src/test') diff --git a/src/test/java/eu/mulk/aendggner/EndToEndTest.java b/src/test/java/eu/mulk/aendggner/EndToEndTest.java index 0b7820d..2e19090 100644 --- a/src/test/java/eu/mulk/aendggner/EndToEndTest.java +++ b/src/test/java/eu/mulk/aendggner/EndToEndTest.java @@ -217,4 +217,97 @@ class EndToEndTest { assertThat(neueNorm.titel()).isEqualTo("Außerkrafttreten"); assertThat(neueNorm.gesamtText()).contains("9. Dezember 2026 außer Kraft"); } + + /** + * Bayerisches Landesrecht (Akzeptanzfall der fünften Welle): altes BayJG (Klartext, aus der + * archivierten Fassung von gesetze-bayern.de abgeleitet) + GVBl-Heft 6/2026. §§ 1 und 2 des + * Änderungsgesetzes betreffen das BayJG; alle Befehle werden erkannt und angewandt. + */ + @Test + void bayJgGvblAcceptance() throws Exception { + var alt = SAMPLEDATA.resolve("BayJG/BayJG-alt.txt"); + var pdf = SAMPLEDATA.resolve("BayJG/gvbl-2026-06.pdf"); + assumeTrue(Files.exists(alt) && Files.exists(pdf), "BayJG-Beispieldaten fehlen"); + + // 1. Stammgesetz laden (Art.-gegliedert, amtliche Satznummern als Superskripte). + var gesetz = new eu.mulk.aendggner.gesetz.bayern.BayRechtLoader().load(alt); + assertThat(gesetz.jurabk()).isEqualTo("BayJG"); + assertThat(gesetz.langue()).isEqualTo("Bayerisches Jagdgesetz"); + assertThat(gesetz.norm("Art. 1").orElseThrow().absaetze().get(0).text()) + .startsWith("¹Die freilebende Tierwelt"); + + // 2. GVBl-Heft extrahieren (Superskript-Erhalt; Kolumnentitel entfernt). + var text = + TextBereiniger.bereinige( + new PatchTextExtraktor(eu.mulk.aendggner.aenderung.parse.SuperskriptModus.BEHALTEN) + .extrahiere(pdf)); + assertThat(text).doesNotContain("Gesetz- und Verordnungsblatt Nr. 6/2026 "); + assertThat(text).contains("für die kurzfristige Vermietung"); + + // 3. Parsen: §§ 1 und 2 betreffen das BayJG (nicht aber die AVBayJG-§§ 4 und 5, + // deren Titel das BayJG nur als Genitiv-Attribut nennt); alles wird erkannt. + var parseErgebnis = new AenderungsgesetzParser().parse(text, gesetz, null); + assertThat(parseErgebnis.artikel()).containsExactly("1", "2"); + assertThat(parseErgebnis.befehle()).hasSize(154); + assertThat(parseErgebnis.befehle()).noneMatch(b -> b instanceof UnbekannterBefehl); + + // 4. Anwenden auf die alte Fassung. Von 154 Befehlen bleiben genau fünf als „manuell prüfen“ + // stehen — allesamt Folgeänderungen innerhalb zweier mehrschrittiger Umnummerierungs- + // sequenzen, die ÄndGgner bewusst nicht automatisch auflöst (statt fehlerhaft zu raten): + // * Art. 29a (§ 1 Nr. 23): ein vorangestellter Absatz und die Bereichs-Umnummerierung + // „Die bisherigen Abs. 1 bis 3 werden die Abs. 2 bis 4“ verschieben die Absatzzählung; + // die auf den neuen Abs. 5 zielenden Wort-/Satzbefehle finden ihren Alttext nicht mehr. + // * Art. 56 (§ 1 Nr. 48): eine umfangreiche Neunummerierung des Bußgeldkatalogs + // (Einfügen der Nrn. 5–7 und 13, Verschieben von Nr. 6→9, 11→12 …) verschiebt die + // Zielnummern der begleitenden Buchstaben-/Wortänderungen. + // Diese Residuen sind exakt gepinnt; sie landen mit Begründung im Abschnitt „Manuell prüfen“ + // der Synopse und werden nie stillschweigend verworfen. + var anwendung = BefehlAnwender.anwenden(gesetz, parseErgebnis.befehle()); + assertThat(anwendung.protokoll()).hasSameSizeAs(parseErgebnis.befehle()); + var manuellPfade = + anwendung.protokoll().stream() + .filter(a -> a.status() == BefehlAnwender.Status.MANUELL_PRUEFEN) + .map(a -> a.befehl().provenienz().gliederungsPfad()) + .toList(); + assertThat(manuellPfade) + .containsExactlyInAnyOrder( + "23. c) aa)", "23. c) cc)", "48. a) ee)", "48. a) gg)", "48. b) cc)"); + + // 5. Stichproben: § 2 (in Kraft 1.1.2027) hebt Art. 28 Abs. 1 Satz 4 auf und nummeriert + // Satz 5 um; § 1 Nr. 16 stellt Art. 22a die Abs. 1 bis 4 voran. + var art28 = anwendung.neu().norm("Art. 28").orElseThrow(); + var abs1 = art28.absaetze().get(0); + assertThat(abs1.text()).doesNotContain("⁵"); + var art22a = anwendung.neu().norm("Art. 22a").orElseThrow(); + assertThat(art22a.absaetze().size()).isGreaterThanOrEqualTo(5); + assertThat(art22a.absaetze().get(0).nummer()).isEqualTo("1"); + + // 6. Synopse rendern. + var synopse = SynopseBuilder.baue(gesetz, anwendung, parseErgebnis.warnungen(), false); + var html = HtmlRenderer.rendere(synopse, "E2E-Test BayJG"); + assertThat(html).contains("Art. 29"); + } + + /** Landtags-Gesetzentwurf (Ltg-Drs. 19/9707) auf derselben alten Fassung. */ + @Test + void bayJgLandtagsEntwurf() throws Exception { + var alt = SAMPLEDATA.resolve("BayJG/BayJG-alt.txt"); + var pdf = SAMPLEDATA.resolve("BayJG/Ltg-Drs-19-9707_Gesetzentwurf.pdf"); + assumeTrue(Files.exists(alt) && Files.exists(pdf), "BayJG-Beispieldaten fehlen"); + + var gesetz = new eu.mulk.aendggner.gesetz.bayern.BayRechtLoader().load(alt); + var text = + TextBereiniger.bereinige( + new PatchTextExtraktor(eu.mulk.aendggner.aenderung.parse.SuperskriptModus.BEHALTEN) + .extrahiere(pdf)); + var parseErgebnis = new AenderungsgesetzParser().parse(text, gesetz, null); + + // Nur § 1 des Entwurfs ändert das BayJG; Vorblatt und Begründung erzeugen keine Befehle. + assertThat(parseErgebnis.artikel()).containsExactly("1"); + assertThat(parseErgebnis.befehle()).hasSize(154); + assertThat(parseErgebnis.befehle()).noneMatch(b -> b instanceof UnbekannterBefehl); + + var anwendung = BefehlAnwender.anwenden(gesetz, parseErgebnis.befehle()); + assertThat(anwendung.protokoll()).hasSameSizeAs(parseErgebnis.befehle()); + } } diff --git a/src/test/java/eu/mulk/aendggner/aenderung/parse/AenderungsgesetzParserTest.java b/src/test/java/eu/mulk/aendggner/aenderung/parse/AenderungsgesetzParserTest.java new file mode 100644 index 0000000..a8ec3aa --- /dev/null +++ b/src/test/java/eu/mulk/aendggner/aenderung/parse/AenderungsgesetzParserTest.java @@ -0,0 +1,128 @@ +package eu.mulk.aendggner.aenderung.parse; + +import static org.assertj.core.api.Assertions.assertThat; + +import eu.mulk.aendggner.aenderung.Aenderungsbefehl; +import eu.mulk.aendggner.gesetz.Gesetz; +import java.util.List; +import org.junit.jupiter.api.Test; + +class AenderungsgesetzParserTest { + + private static final Gesetz BAYJG = + new Gesetz("BayJG", "Bayerisches Jagdgesetz", null, List.of(), List.of()); + + private static final Gesetz UWG = + new Gesetz( + "UWG", "Gesetz gegen den unlauteren Wettbewerb", "UWG-Langtitel", List.of(), List.of()); + + @Test + void teiltParagraphenGegliedertesAenderungsgesetz() { + // Bayerischer Stil: das Änderungsgesetz gliedert sich in §§; § 1 und § 2 treffen dasselbe + // Stammgesetz, § 3 ein anderes; Inkrafttretens-§ ohne Änderungsformel wird nicht gewählt. + var text = + """ + § 1 + Änderung des + Bayerischen Jagdgesetzes + Das Bayerische Jagdgesetz (BayJG) in der in der Bayerischen Rechtssammlung (BayRS 792-1-W) veröffentlichten bereinigten Fassung, das zuletzt durch § 5 des Gesetzes vom 23. Juli 2024 (GVBl. S. 247) geändert worden ist, wird wie folgt geändert: + 1. Art. 1 wird wie folgt geändert: + a) In Abs. 2 Satzteil vor Nr. 1 wird die Angabe „Bundesjagdgesetz“ durch die Angabe „BJagdG“ ersetzt. + b) Fußnote 1 wird aufgehoben. + § 2 + Weitere Änderung des + Bayerischen Jagdgesetzes + Das Bayerische Jagdgesetz (BayJG) in der in der Bayerischen Rechtssammlung (BayRS 792-1-W) veröffentlichten bereinigten Fassung, das zuletzt durch § 1 dieses Gesetzes geändert worden ist, wird wie folgt geändert: + Art. 28 Abs. 1 wird wie folgt geändert: + 1. Satz 4 wird aufgehoben. + 2. Satz 5 wird Satz 4. + § 3 + Änderung des + Bayerischen Umweltinformationsgesetzes + Art. 7 Abs. 2 des Bayerischen Umweltinformationsgesetzes (BayUIG) vom 8. Dezember 2006 (GVBl. S. 933, BayRS 2129-1-4-U), das zuletzt durch § 10 des Gesetzes vom 23. Dezember 2024 (GVBl. S. 605) geändert worden ist, wird wie folgt geändert: + 1. Der Wortlaut wird Satz 1. + § 4 + Inkrafttreten + Dieses Gesetz tritt am 1. April 2026 in Kraft. + """; + + var ergebnis = new AenderungsgesetzParser().parse(text, BAYJG, null); + + assertThat(ergebnis.artikel()).containsExactly("1", "2"); + // § 2: Vorspann-Rahmen „Art. 28 Abs. 1“ wird Kontext der Punkte. + var aufhebung = + ergebnis.befehle().stream() + .filter(b -> b instanceof Aenderungsbefehl.Aufhebung) + .map(Aenderungsbefehl::stelle) + .map(s -> s.anzeigeText()) + .toList(); + assertThat(aufhebung).contains("Art. 28 Absatz 1 Satz 4"); + } + + @Test + void eingebettetesRahmenzielSetztKontext() { + var bayUig = + new Gesetz( + "BayUIG", "Bayerisches Umweltinformationsgesetz", null, List.of(), List.of()); + var text = + """ + § 3 + Änderung des + Bayerischen Umweltinformationsgesetzes + Art. 7 Abs. 2 des Bayerischen Umweltinformationsgesetzes (BayUIG) vom 8. Dezember 2006 (GVBl. S. 933, BayRS 2129-1-4-U), das zuletzt durch § 10 des Gesetzes vom 23. Dezember 2024 (GVBl. S. 605) geändert worden ist, wird wie folgt geändert: + 1. Der Wortlaut wird Satz 1. + 2. Folgender Satz 2 wird angefügt: + „²Die Voraussetzungen nach Satz 1 Nr. 2 sind insbesondere gegeben.“ + """; + + var ergebnis = new AenderungsgesetzParser().parse(text, bayUig, null); + + assertThat(ergebnis.artikel()).containsExactly("3"); + // Das eingebettete Ziel „Art. 7 Abs. 2“ ist Kontext aller Punkte — nicht etwa das „§ 10“ + // aus der Zitierkette der Änderungshistorie. + assertThat(ergebnis.befehle()) + .allSatisfy( + befehl -> + assertThat(befehl.stelle().anzeigeText()).startsWith("Art. 7 Absatz 2")); + } + + @Test + void artikelUeberschriftenUnterdrueckenParagraphenModus() { + // Ein Bundes-Änderungsgesetz mit unzitiert abgedrucktem Ablösegesetz enthält freistehende + // „§ N“-Zeilen — sie dürfen nicht als äußere Gliederung gedeutet werden. + var text = + """ + Artikel 1 + Das Gesetz gegen den unlauteren Wettbewerb vom 3. Juli 2004, das zuletzt geändert worden ist, wird wie folgt geändert: + 1. In § 5 wird die Angabe „alt“ durch die Angabe „neu“ ersetzt. + Artikel 2 + § 1 + Dieses Gesetz tritt am 1. Januar 2027 in Kraft. + """; + + var ergebnis = new AenderungsgesetzParser().parse(text, UWG, null); + + assertThat(ergebnis.artikel()).containsExactly("1"); + assertThat(ergebnis.befehle()).hasSize(1); + assertThat(ergebnis.befehle().get(0)).isInstanceOf(Aenderungsbefehl.Ersetzung.class); + } + + @Test + void artikelFilterImParagraphenModusVerlangtAenderungsformel() { + var text = + """ + § 1 + Das Bayerische Jagdgesetz (BayJG), das zuletzt geändert worden ist, wird wie folgt geändert: + 1. In Art. 1 wird die Angabe „alt“ durch die Angabe „neu“ ersetzt. + § 2 + Dieses Gesetz tritt am 1. April 2026 in Kraft. + """; + + // § 2 ist der Inkrafttretens-§ ohne Änderungsformel: der Filter wählt ihn nicht. + var ergebnis = new AenderungsgesetzParser().parse(text, BAYJG, "2"); + assertThat(ergebnis.artikel()).isEmpty(); + + var eins = new AenderungsgesetzParser().parse(text, BAYJG, "1"); + assertThat(eins.artikel()).containsExactly("1"); + } +} diff --git a/src/test/java/eu/mulk/aendggner/aenderung/parse/BefehlErkennerTest.java b/src/test/java/eu/mulk/aendggner/aenderung/parse/BefehlErkennerTest.java index f8cfe82..8cf9eea 100644 --- a/src/test/java/eu/mulk/aendggner/aenderung/parse/BefehlErkennerTest.java +++ b/src/test/java/eu/mulk/aendggner/aenderung/parse/BefehlErkennerTest.java @@ -1026,4 +1026,121 @@ class BefehlErkennerTest { assertThat(ersetzung.alt()).isEqualTo("einer Rechtsverordnung nach § 14,"); assertThat(ersetzung.neu()).isEqualTo("§ 14 Absatz 8,"); } + + // --- Bayerisches Landesrecht --------------------------------------------------------------- + + private static final Stelle ART_22 = new Stelle(List.of(new Stelle.Paragraph("22", "Art."))); + + @Test + void erkenntBayerischeErsetzungMitAbkuerzungen() { + var befehl = + erkenne( + "In Abs. 2 Satz 1 Nr. 1 wird die Angabe „des Bundesbaugesetzes“ durch die Angabe" + + " „des Baugesetzbuchs (BauGB)“ ersetzt.", + new Stelle(List.of(new Stelle.Paragraph("6", "Art.")))); + + assertThat(befehl).containsInstanceOf(Ersetzung.class); + var ersetzung = (Ersetzung) befehl.orElseThrow(); + assertThat(ersetzung.stelle().anzeigeText()).isEqualTo("Art. 6 Absatz 2 Satz 1 Nummer 1"); + } + + @Test + void erkenntFussnotenAufhebung() { + var einzeln = erkenne("Fußnote 1 wird aufgehoben.", ART_22); + assertThat(einzeln).containsInstanceOf(Aenderungsbefehl.FussnotenAufhebung.class); + assertThat(((Aenderungsbefehl.FussnotenAufhebung) einzeln.orElseThrow()).nummern()) + .containsExactly("1"); + + var mehrere = erkenne("Die Fußnoten 9 und 10 werden aufgehoben.", ART_22); + assertThat(mehrere).containsInstanceOf(Aenderungsbefehl.FussnotenAufhebung.class); + assertThat(((Aenderungsbefehl.FussnotenAufhebung) mehrere.orElseThrow()).nummern()) + .containsExactly("9", "10"); + } + + @Test + void erkenntSatznummerierungsStreichungImVerbund() { + // GVBl 2026 S. 113, Nr. 15 c) aa) — exakt die Verbundform des Belegdokuments. + var befehl = + erkenne( + "In Satz 1 wird die Satznummerierung „1“ gestrichen und die Angabe „Absätzen 1" + + " und 2“ wird durch die Angabe „Abs. 1 und 2“ ersetzt.", + ART_22); + + assertThat(befehl).containsInstanceOf(Sammelbefehl.class); + var teile = ((Sammelbefehl) befehl.orElseThrow()).teilbefehle(); + assertThat(teile.get(0)).isInstanceOf(Aenderungsbefehl.SatznummerierungStreichung.class); + var streichung = (Aenderungsbefehl.SatznummerierungStreichung) teile.get(0); + assertThat(streichung.nummer()).isEqualTo("1"); + assertThat(streichung.stelle().anzeigeText()).isEqualTo("Art. 22 Satz 1"); + assertThat(teile.get(1)).isInstanceOf(Ersetzung.class); + + var einfach = erkenne("In Satz 1 wird die Satznummerierung „1“ gestrichen.", ART_22); + assertThat(einfach).containsInstanceOf(Aenderungsbefehl.SatznummerierungStreichung.class); + } + + @Test + void erkenntBayerischeWortlautFormen() { + var zuSatz = erkenne("Der Wortlaut wird Satz 1.", ART_22); + assertThat(zuSatz).containsInstanceOf(Aenderungsbefehl.WortlautZuSatz.class); + + var zuAbsatz = erkenne("Der bisherige Wortlaut wird Abs. 5.", ART_22); + assertThat(zuAbsatz).containsInstanceOf(WortlautZuAbsatz.class); + assertThat(((WortlautZuAbsatz) zuAbsatz.orElseThrow()).nummer()).isEqualTo("5"); + + var voranstellung = + erkenne( + "Dem Wortlaut werden die folgenden Abs. 1 bis 4 vorangestellt: „(1) Erster." + + " (2) Zweiter. (3) Dritter. (4) Vierter.“", + ART_22); + assertThat(voranstellung).containsInstanceOf(Aenderungsbefehl.WortlautVoranstellung.class); + assertThat(((Aenderungsbefehl.WortlautVoranstellung) voranstellung.orElseThrow()).text()) + .contains("(3) Dritter."); + } + + @Test + void erkenntWortlautZuAbsatzMitFolgeklausel() { + // „Der bisherige Wortlaut wird Abs. 5 und in Halbsatz 1 wird …“ — die lokative Folgeklausel + // bezieht sich auf den soeben nummerierten Absatz. + var befehl = + erkenne( + "Der bisherige Wortlaut wird Abs. 5 und in Halbsatz 1 wird die Angabe „Das" + + " Staatsministerium“ durch die Angabe „Die oberste Jagdbehörde“ ersetzt.", + ART_22); + + assertThat(befehl).containsInstanceOf(Sammelbefehl.class); + var teile = ((Sammelbefehl) befehl.orElseThrow()).teilbefehle(); + assertThat(teile.get(0)).isInstanceOf(WortlautZuAbsatz.class); + assertThat(teile.get(1)).isInstanceOf(Ersetzung.class); + assertThat(((Ersetzung) teile.get(1)).stelle().anzeigeText()) + .isEqualTo("Art. 22 Absatz 5 Halbsatz 1"); + } + + @Test + void erkenntSatzUmnummerierungMitErsetzungsKlausel() { + // GVBl 2026 S. 113, Nr. 23 c) cc). + var befehl = + erkenne( + "Der bisherige Satz 2 wird Satz 3 und die Angabe „der Durchführung der Lehrgänge" + + " (Art. 28 Abs. 1 Satz 4),“ wird durch die Angabe „einer Durchführung von" + + " Lehrgängen für die Fallenjagd“ ersetzt.", + ART_22); + + assertThat(befehl).containsInstanceOf(Sammelbefehl.class); + var teile = ((Sammelbefehl) befehl.orElseThrow()).teilbefehle(); + assertThat(teile.get(0)).isInstanceOf(Umnummerierung.class); + assertThat(teile.get(1)).isInstanceOf(Ersetzung.class); + } + + @Test + void erkenntBayerischeUmnummerierungsVerbuende() { + // „Abs. 3 wird Abs. 2 und wird wie folgt geändert:“ läuft über rahmenMitBefehl; + // „Satz 5 wird Satz 4.“ ist eine gewöhnliche Umnummerierung. + var satz = erkenne("Satz 5 wird Satz 4.", ART_22); + assertThat(satz).containsInstanceOf(Umnummerierung.class); + assertThat(((Umnummerierung) satz.orElseThrow()).neu().anzeigeText()) + .isEqualTo("Art. 22 Satz 4"); + + var abs = erkenne("Abs. 3 wird Abs. 2.", ART_22); + assertThat(abs).containsInstanceOf(Umnummerierung.class); + } } diff --git a/src/test/java/eu/mulk/aendggner/aenderung/parse/StellenParserTest.java b/src/test/java/eu/mulk/aendggner/aenderung/parse/StellenParserTest.java index 9389534..f15b98f 100644 --- a/src/test/java/eu/mulk/aendggner/aenderung/parse/StellenParserTest.java +++ b/src/test/java/eu/mulk/aendggner/aenderung/parse/StellenParserTest.java @@ -82,6 +82,33 @@ class StellenParserTest { assertThat(stelle.anzeigeText()).isEqualTo("Überschrift Teil 3 Abschnitt 2"); } + @Test + void parstBayerischeArtikelStelle() { + // Bayerisches Landesrecht: Normen heißen „Art. N“, Unterkomponenten sind abgekürzt. + var stelle = StellenParser.parse("Art. 6 Abs. 2 Satz 1 Nr. 2").orElseThrow(); + assertThat(stelle.anzeigeText()).isEqualTo("Art. 6 Absatz 2 Satz 1 Nummer 2"); + assertThat(stelle.paragraph().orElseThrow().enbez()).isEqualTo("Art. 6"); + } + + @Test + void parstBayerischenBereichMitAbkuerzung() { + // „In den Abs. 4 und 5“ und Bereiche wie „Art. 4 bis 6“ mit erhaltenem Sigel. + var stellen = StellenParser.parseMehrfach("Art. 4 Abs. 3, Art. 5 Abs. 2 und Art. 11 Abs. 6"); + assertThat(stellen).extracting(Stelle::anzeigeText) + .containsExactly("Art. 4 Absatz 3", "Art. 5 Absatz 2", "Art. 11 Absatz 6"); + var bereich = StellenParser.parseMehrfach("Abs. 1 bis 3"); + assertThat(bereich).extracting(Stelle::anzeigeText) + .containsExactly("Absatz 1", "Absatz 2", "Absatz 3"); + } + + @Test + void ignoriertBayerischenChapeauZusatz() { + assertThat(StellenParser.parse("Abs. 2 Satzteil vor Nr. 1").orElseThrow().anzeigeText()) + .isEqualTo("Absatz 2"); + assertThat(StellenParser.parse("in dem Satzteil nach Nr. 3").isEmpty()).isTrue(); + assertThat(StellenParser.istNurChapeau("in dem Satzteil nach Nr. 3")).isTrue(); + } + @Test void ignoriertChapeauZusatz() { // „in der Angabe vor Nummer 1“ ist ein verfeinernder Zusatz ohne eigene Komponente. diff --git a/src/test/java/eu/mulk/aendggner/aenderung/parse/TextBereinigerTest.java b/src/test/java/eu/mulk/aendggner/aenderung/parse/TextBereinigerTest.java index 8a7cf12..755db46 100644 --- a/src/test/java/eu/mulk/aendggner/aenderung/parse/TextBereinigerTest.java +++ b/src/test/java/eu/mulk/aendggner/aenderung/parse/TextBereinigerTest.java @@ -47,6 +47,36 @@ class TextBereinigerTest { .isEqualTo("Preisbildung und -\ngestaltung"); } + @Test + void entferntGvblKolumnentitel() { + // Gerade Seiten kleben die Seitenzahl ohne Zwischenraum an die Jahreszahl („…/202676“); + // eine am Seitenwechsel unterbrochene Silbentrennung heilt nach der Kopfentfernung. + var roh = + "Einheiten über Online-Plattformen für die kurz-\n" + + "Bayerisches Gesetz- und Verordnungsblatt Nr. 6/202676\n" + + "fristige Vermietung von Unterkünften anbietet, \n" + + "Bayerisches Gesetz- und Verordnungsblatt Nr. 6/2026 77\n" + + "Zweiter Satz. "; + + assertThat(TextBereiniger.bereinige(roh)) + .isEqualTo( + "Einheiten über Online-Plattformen für die kurzfristige Vermietung von Unterkünften" + + " anbietet,\nZweiter Satz."); + } + + @Test + void entferntLandtagsKolumnentitel() { + var roh = + "Bayerischer Landtag \n" + + "19. Wahlperiode 28.01.2026 Drucksache 19/9707 \n" + + "Erster Satz. \n" + + "Drucksache 19/9707 Bayerischer Landtag 19. Wahlperiode Seite 2 \n" + + "Zweiter Satz. \n" + + "19. Wahlperiode Drucksache 19/9707 "; + + assertThat(TextBereiniger.bereinige(roh)).isEqualTo("Erster Satz.\nZweiter Satz."); + } + @Test void entferntKopfzeilenDesNeuenBgblFormats() { var roh = diff --git a/src/test/java/eu/mulk/aendggner/anwendung/BefehlAnwenderTest.java b/src/test/java/eu/mulk/aendggner/anwendung/BefehlAnwenderTest.java index aa658dd..a23f444 100644 --- a/src/test/java/eu/mulk/aendggner/anwendung/BefehlAnwenderTest.java +++ b/src/test/java/eu/mulk/aendggner/anwendung/BefehlAnwenderTest.java @@ -984,4 +984,154 @@ class BefehlAnwenderTest { private static String absatzText(Gesetz gesetz, String enbez, int index) { return gesetz.norm(enbez).orElseThrow().absaetze().get(index).text(); } + + // --- Bayerisches Landesrecht --------------------------------------------------------------- + + /** Ein Gesetz nach bayerischem Muster: Art.-Normen, amtliche Satznummern, Fußnoten. */ + private static Gesetz bayGesetz() { + return new Gesetz( + "BayTestG", + "Bayerisches Testgesetz", + null, + List.of( + new Norm( + "Art. 1", + "Zweck", + null, + List.of( + new Absatz( + "1", + "¹Die Erprobung ist Zweck dieses Gesetzes⁶). ²Sie erfolgt sorgfältig.\n" + + "⁶) [Amtl. Anm.:] BayRS 0-0-T"), + new Absatz("2", "¹Erster Satz. ²Zweiter Satz.")), + false), + new Norm( + "Art. 2", + "Begriffe", + null, + List.of( + new Absatz( + null, + "Erprobung ist die Prüfung der Tauglichkeit; sie endet mit einem" + + " Bericht.")), + false)), + List.of()); + } + + @Test + void wendetFussnotenAufhebungAn() { + var befehl = + new eu.mulk.aendggner.aenderung.Aenderungsbefehl.FussnotenAufhebung( + stelle(new Stelle.Paragraph("1", "Art.")), List.of("6"), PROV); + + var ergebnis = BefehlAnwender.anwenden(bayGesetz(), List.of(befehl)); + + assertThat(ergebnis.protokoll().get(0).status()).isEqualTo(Status.ANGEWANDT); + var text = absatzText(ergebnis.neu(), "Art. 1", 0); + assertThat(text).doesNotContain("⁶").doesNotContain("[Amtl. Anm.:]"); + assertThat(text).startsWith("¹Die Erprobung ist Zweck dieses Gesetzes."); + } + + @Test + void meldetFehlendeFussnoteAlsManuellPruefen() { + var befehl = + new eu.mulk.aendggner.aenderung.Aenderungsbefehl.FussnotenAufhebung( + stelle(new Stelle.Paragraph("1", "Art.")), List.of("7"), PROV); + + var ergebnis = BefehlAnwender.anwenden(bayGesetz(), List.of(befehl)); + + assertThat(ergebnis.protokoll().get(0).status()).isEqualTo(Status.MANUELL_PRUEFEN); + assertThat(ergebnis.protokoll().get(0).begruendung()).contains("Fußnote 7"); + } + + @Test + void wendetSatznummerierungsStreichungAn() { + var befehl = + new eu.mulk.aendggner.aenderung.Aenderungsbefehl.SatznummerierungStreichung( + stelle( + new Stelle.Paragraph("1", "Art."), + new Stelle.AbsatzNr("2"), + new Stelle.SatzNr("1")), + "1", + PROV); + + var ergebnis = BefehlAnwender.anwenden(bayGesetz(), List.of(befehl)); + + assertThat(ergebnis.protokoll().get(0).status()).isEqualTo(Status.ANGEWANDT); + assertThat(absatzText(ergebnis.neu(), "Art. 1", 1)).isEqualTo("Erster Satz. ²Zweiter Satz."); + } + + @Test + void satzUmnummerierungSchreibtSuperskriptUm() { + // „Satz 2 wird Satz 3.“ — die amtliche Satznummer im Text wird umgeschrieben. + var befehl = + new Umnummerierung( + stelle( + new Stelle.Paragraph("1", "Art."), + new Stelle.AbsatzNr("2"), + new Stelle.SatzNr("2")), + stelle( + new Stelle.Paragraph("1", "Art."), + new Stelle.AbsatzNr("2"), + new Stelle.SatzNr("3")), + PROV); + + var ergebnis = BefehlAnwender.anwenden(bayGesetz(), List.of(befehl)); + + assertThat(ergebnis.protokoll().get(0).status()).isEqualTo(Status.ANGEWANDT); + assertThat(absatzText(ergebnis.neu(), "Art. 1", 1)).isEqualTo("¹Erster Satz. ³Zweiter Satz."); + } + + @Test + void wendetWortlautZuSatzAn() { + var befehl = + new eu.mulk.aendggner.aenderung.Aenderungsbefehl.WortlautZuSatz( + stelle(new Stelle.Paragraph("2", "Art.")), "1", PROV); + + var ergebnis = BefehlAnwender.anwenden(bayGesetz(), List.of(befehl)); + + assertThat(ergebnis.protokoll().get(0).status()).isEqualTo(Status.ANGEWANDT); + assertThat(absatzText(ergebnis.neu(), "Art. 2", 0)).startsWith("¹Erprobung ist die Prüfung"); + } + + @Test + void wendetWortlautVoranstellungUndNummerierungAn() { + // Bayerische Folge: erst „Dem Wortlaut werden die folgenden Abs. 1 und 2 vorangestellt“, + // dann „Der bisherige Wortlaut wird Abs. 3“ — nur der unnummerierte Absatz erhält die Nummer. + var voranstellung = + new eu.mulk.aendggner.aenderung.Aenderungsbefehl.WortlautVoranstellung( + stelle(new Stelle.Paragraph("2", "Art.")), "(1) Erstens. (2) Zweitens.", PROV); + var nummerierung = + new eu.mulk.aendggner.aenderung.Aenderungsbefehl.WortlautZuAbsatz( + stelle(new Stelle.Paragraph("2", "Art.")), "3", PROV); + + var ergebnis = BefehlAnwender.anwenden(bayGesetz(), List.of(voranstellung, nummerierung)); + + assertThat(ergebnis.protokoll()) + .allSatisfy(eintrag -> assertThat(eintrag.status()).isEqualTo(Status.ANGEWANDT)); + var norm = ergebnis.neu().norm("Art. 2").orElseThrow(); + assertThat(norm.absaetze()).hasSize(3); + assertThat(norm.absaetze().get(0).nummer()).isEqualTo("1"); + assertThat(norm.absaetze().get(1).nummer()).isEqualTo("2"); + assertThat(norm.absaetze().get(2).nummer()).isEqualTo("3"); + assertThat(norm.absaetze().get(2).text()).startsWith("Erprobung ist"); + } + + @Test + void wendetHalbsatzErsetzungAn() { + var befehl = + new Ersetzung( + stelle(new Stelle.Paragraph("2", "Art."), new Stelle.HalbsatzNr("2")), + "sie endet", + "sie schließt", + false, + false, + PROV); + + var ergebnis = BefehlAnwender.anwenden(bayGesetz(), List.of(befehl)); + + assertThat(ergebnis.protokoll().get(0).status()).isEqualTo(Status.ANGEWANDT); + assertThat(absatzText(ergebnis.neu(), "Art. 2", 0)) + .isEqualTo("Erprobung ist die Prüfung der Tauglichkeit; sie schließt mit einem Bericht."); + } } diff --git a/src/test/java/eu/mulk/aendggner/anwendung/SatzTeilerTest.java b/src/test/java/eu/mulk/aendggner/anwendung/SatzTeilerTest.java index 7df8f37..a97fc03 100644 --- a/src/test/java/eu/mulk/aendggner/anwendung/SatzTeilerTest.java +++ b/src/test/java/eu/mulk/aendggner/anwendung/SatzTeilerTest.java @@ -37,6 +37,30 @@ class SatzTeilerTest { assertThat(SatzTeiler.teileTexte("Es gilt Satz 1. „Zitat folgt.“ (Klammer folgt.)")).hasSize(3); } + @Test + void teiltAnAmtlichenSatznummern() { + // Bayerisches Landesrecht: hochgestellte Satznummern sind exakte Satzgrenzen — auch dort, + // wo die Heuristik versagen würde (Abkürzung am Satzende). + var text = "¹Die Förderung erfolgt gem. Art. 26 und 27. ²Sie ist als Teil zu bewahren."; + assertThat(SatzTeiler.teileTexte(text)) + .containsExactly( + "¹Die Förderung erfolgt gem. Art. 26 und 27.", "²Sie ist als Teil zu bewahren."); + assertThat(SatzTeiler.nummerVonSatz("²Sie ist als Teil zu bewahren.")).isEqualTo(2); + assertThat(SatzTeiler.nummerVonSatz("Ohne Nummer.")).isNull(); + } + + @Test + void fussnotenmarkerIstKeinSatzanfang() { + // „Enteignung⁶)“ mitten im Satz und die Fußnotenzeile „⁶) …“ eröffnen keinen neuen Satz. + var text = + "¹Es gilt das Gesetz über die entschädigungspflichtige Enteignung⁶) entsprechend." + + " ²Näheres regelt die Verordnung.\n⁶) [Amtl. Anm.:] BayRS 2141-1-I"; + var saetze = SatzTeiler.teileTexte(text); + assertThat(saetze).hasSize(2); + assertThat(saetze.get(0)).contains("Enteignung⁶)"); + assertThat(saetze.get(1)).startsWith("²Näheres").contains("⁶) [Amtl. Anm.:]"); + } + @Test void behandeltAufzaehlungenAlsTeilDesSatzes() { var text = diff --git a/src/test/java/eu/mulk/aendggner/gesetz/bayern/BayRechtTextParserTest.java b/src/test/java/eu/mulk/aendggner/gesetz/bayern/BayRechtTextParserTest.java new file mode 100644 index 0000000..a5fc67a --- /dev/null +++ b/src/test/java/eu/mulk/aendggner/gesetz/bayern/BayRechtTextParserTest.java @@ -0,0 +1,120 @@ +package eu.mulk.aendggner.gesetz.bayern; + +import static org.assertj.core.api.Assertions.assertThat; + +import eu.mulk.aendggner.gesetz.Gliederung; +import org.junit.jupiter.api.Test; + +class BayRechtTextParserTest { + + private static final String BEISPIEL = + """ + BayJG: Bayerisches Jagdgesetz (BayJG) Vom 13. Oktober 1978 (BayRS V S. 595) BayRS 792-1-W (Art. + 1–64) + Bayerisches Jagdgesetz + (BayJG) + Vom 13. Oktober 1978 + (BayRS V S. 595) + BayRS 792-1-W + Vollzitat nach RedR: Bayerisches Jagdgesetz (BayJG) in der in der Bayerischen Rechtssammlung (BayRS + 792-1-L) veröffentlichten bereinigten Fassung + I. Abschnitt Grundsätze + Art. 1 Gesetzeszweck + + (1) ¹Die freilebende Tierwelt ist wesentlicher Bestandteil der heimischen Natur. ²Sie ist zu bewahren. + (2) Dieses Gesetz soll dazu dienen: + 1. einen artenreichen Wildbestand zu erhalten, + + 2. die Lebensgrundlagen des Wildes zu sichern. + + II. Abschnitt Jagdreviere, Hegegemeinschaften + 1. Allgemeine Vorschriften + Art. 3 Feststellung der Jagdreviere + + Bestand, Umfang und Grenzen eines Jagdreviers werden durch die + Jagdbehörde festgestellt. + 2. Jagdreviere + Art. 8 Eigenjagdreviere + + (1) ¹Die Mindestgröße beträgt 81,755 ha. ²Die Art. 4 Abs. 3, Art. 5 Abs. 2 und + Art. 11 Abs. 6 sind entsprechend anzuwenden. + Art. 59 Enteignende Maßnahmen + + (1) Es ist nach den Vorschriften des Gesetzes über die entschädigungspflichtige Enteignung⁶) Entschädigung zu leisten. + 6) [Amtl. Anm.:] BayRS 2141-1-I + Art. 60 (aufgehoben) + + Art. 63 (Änderungsbestimmung) + + Art. 64 Inkrafttreten; Aufhebung von Vorschriften + + (1) Dieses Gesetz tritt am 1. Januar 1979 in Kraft. + """; + + @Test + void parstTitelblock() { + var gesetz = BayRechtTextParser.parse(BEISPIEL); + assertThat(gesetz.jurabk()).isEqualTo("BayJG"); + assertThat(gesetz.langue()).isEqualTo("Bayerisches Jagdgesetz"); + assertThat(gesetz.kurzue()).isNull(); + } + + @Test + void parstNormenMitTitelnUndAbsaetzen() { + var gesetz = BayRechtTextParser.parse(BEISPIEL); + assertThat(gesetz.normen()) + .extracting(n -> n.enbez()) + .containsExactly("Art. 1", "Art. 3", "Art. 8", "Art. 59", "Art. 60", "Art. 63", "Art. 64"); + + var art1 = gesetz.norm("Art. 1").orElseThrow(); + assertThat(art1.titel()).isEqualTo("Gesetzeszweck"); + assertThat(art1.absaetze()).hasSize(2); + assertThat(art1.absaetze().get(0).nummer()).isEqualTo("1"); + assertThat(art1.absaetze().get(0).text()).startsWith("¹Die freilebende Tierwelt"); + assertThat(art1.absaetze().get(1).text()).contains("1. einen artenreichen"); + + // Unnummerierter Einzelabsatz. + var art3 = gesetz.norm("Art. 3").orElseThrow(); + assertThat(art3.absaetze()).hasSize(1); + assertThat(art3.absaetze().get(0).nummer()).isNull(); + } + + @Test + void querverweiseAmZeilenanfangEroeffnenKeineNorm() { + // „Art. 11 Abs. 6 …“ am Zeilenanfang in Art. 8 ist ein Querverweis, kein Normkopf. + var gesetz = BayRechtTextParser.parse(BEISPIEL); + var art8 = gesetz.norm("Art. 8").orElseThrow(); + assertThat(art8.absaetze().get(0).text()).contains("Art. 11 Abs. 6 sind entsprechend"); + assertThat(gesetz.norm("Art. 11")).isEmpty(); + } + + @Test + void parstGliederungenMitUnterUeberschriften() { + var gesetz = BayRechtTextParser.parse(BEISPIEL); + assertThat(gesetz.gliederungen()) + .extracting(Gliederung::anzeigeText) + .containsExactly( + "I. Abschnitt — Grundsätze", + "II. Abschnitt — Jagdreviere, Hegegemeinschaften", + "1. — Allgemeine Vorschriften", + "2. — Jagdreviere"); + // Kennzahlen sind eindeutig und hängen an der Eltern-Gliederung. + assertThat(gesetz.gliederungen()) + .extracting(Gliederung::kennzahl) + .containsExactly("001", "002", "002.1", "002.2"); + assertThat(gesetz.norm("Art. 8").orElseThrow().gliederung().kennzahl()).isEqualTo("002.2"); + } + + @Test + void erkenntWeggefalleneNormenUndFussnoten() { + var gesetz = BayRechtTextParser.parse(BEISPIEL); + assertThat(gesetz.norm("Art. 60").orElseThrow().weggefallen()).isTrue(); + assertThat(gesetz.norm("Art. 63").orElseThrow().weggefallen()).isFalse(); + + // Fußnotenzeile bleibt superskript-normalisiert im tragenden Absatz. + var art59 = gesetz.norm("Art. 59").orElseThrow(); + assertThat(art59.absaetze().get(0).text()) + .contains("Enteignung⁶)") + .contains("⁶) [Amtl. Anm.:] BayRS 2141-1-I"); + } +} -- cgit v1.2.1