diff options
| author | Matthias Andreas Benkard <code@mail.matthias.benkard.de> | 2026-07-13 21:32:53 +0200 |
|---|---|---|
| committer | Matthias Andreas Benkard <code@mail.matthias.benkard.de> | 2026-07-13 21:32:53 +0200 |
| commit | 8ae49729ee18a102d503324f2be49c35694b244a (patch) | |
| tree | 1712c78dbf321a0c671425cfbbd4c9a7a5a7106b /src/test/java | |
| parent | 99962fb4aff2a11074b7179b71ed5a5f0cdbc8e4 (diff) | |
Support the digital BGBl format and bill drafts as inputs.
The Bundesgesetzblatt has been published digitally via recht.bund.de
since 2023 in a new single-column layout, and pending amendment acts
are only available as Referenten-/Regierungsentwuerfe or Bundestag
printed papers. Both now work as patch inputs:
* PDF extraction filters out small print by font size (two-pass
PDFTextStripper): in the new BGBl format, footnote blocks and
superscript footnote markers would otherwise land in the middle of
the statutory text, even inside quoted passages.
* TextBereiniger recognizes the new page headers (BGBl "Seite N von
M", draft page markers " - N - ", Bundestag printed-paper headers)
and joins markerless end-of-line hyphenation (BT-Drs PDFs break
words without a hyphen character; regular wraps carry a trailing
space, so its absence is the signal -- gated on the source using
the trailing-space convention at all, protecting hand-written
plain-text inputs). BMJV draft templates draw the hanging opening
quote after the paragraph marker ("(1) „" / "§ 19„"); this
inversion is repaired.
* Drafts embed the statutory text between a cover sheet and a
Begruendung section; article scanning now stops at the Begruendung
heading. Articles without numbered items (single-command articles
like ProdHaftG-RegE Artikel 2) are parsed from the preamble rest.
* Target-law matching is declension-tolerant ("Das Allgemeine
Gleichbehandlungsgesetz" matches "Allgemeines
Gleichbehandlungsgesetz") via rough word-stem comparison.
* New command form StrukturErsetzung ("§ 2 Absatz 2 wird durch die
folgenden Absätze 2 und 3 ersetzt"); "durch die folgende
Überschrift/den folgenden § N ersetzt" map to Neufassung; plural
insertions ("die folgenden Absätze 6 und 7"), triple-letter outline
markers (aaa), the compound punctuation replacement ("durch ein
Komma und die Wörter ... ersetzt"), and Inhaltsuebersicht-Angaben
inside a context frame are recognized.
End-to-end smoke tests cover the four new datasets: UWG (new BGBl
format, footnote-filter assertion), GEG 2023 ("Heizungsgesetz",
121 commands), AGG (BT-Drs draft against an unconsolidated base --
the first dataset with real diffs, spot-checked against the official
BMJV synopsis), and ProdHaftG (draft whose Artikel 1 is a replacement
law and only Artikel 2 amends the base).
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Change-Id: Id1b12bc0bee4178bd1a5c55b3a83f6e13944af69
Diffstat (limited to 'src/test/java')
4 files changed, 307 insertions, 4 deletions
diff --git a/src/test/java/eu/mulk/aendggner/EndToEndTest.java b/src/test/java/eu/mulk/aendggner/EndToEndTest.java index 57e745f..5254ff2 100644 --- a/src/test/java/eu/mulk/aendggner/EndToEndTest.java +++ b/src/test/java/eu/mulk/aendggner/EndToEndTest.java @@ -25,10 +25,9 @@ import org.junit.jupiter.api.Test; */ 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"); + private static final Path SAMPLEDATA = Path.of("src/main/resources/sampledata"); + private static final Path STAMMGESETZ = SAMPLEDATA.resolve("IfSG/BJNR104510000.xml"); + private static final Path AENDERUNGSGESETZ = SAMPLEDATA.resolve("IfSG/bgbl120s2397_78991.pdf"); @Test void gesamtePipelineAufIfSgBeispiel() throws Exception { @@ -69,4 +68,108 @@ class EndToEndTest { assertThat(html).contains("Manuell prüfen"); assertThat(html).containsAnyOf("<del>", "<ins>"); } + + /** Neues digitales BGBl-Format (recht.bund.de, ab 2023): 3. UWGÄndG 2026. */ + @Test + void uwgNeuesBgblFormat() throws Exception { + var xml = SAMPLEDATA.resolve("UWG/BJNR141400004.xml"); + var pdf = SAMPLEDATA.resolve("UWG/bgbl126s0043_regelungstext.pdf"); + assumeTrue(Files.exists(xml) && Files.exists(pdf), "UWG-Beispieldaten fehlen"); + + var gesetz = new GiiXmlLoader().load(xml); + var text = TextBereiniger.bereinige(new PatchTextExtraktor().extrahiere(pdf)); + + // Der Fontgrößen-Filter muss die Fußnotenblöcke entfernt haben, die im neuen Format sonst + // mitten im Gesetzestext landen. + assertThat(text).doesNotContain("Dieses Gesetz dient der Umsetzung der Richtlinie (EU)"); + assertThat(text).contains("wird wie folgt geändert"); + + var parseErgebnis = new AenderungsgesetzParser().parse(text, gesetz, null); + assertThat(parseErgebnis.artikel()).containsExactly("1"); + assertThat(parseErgebnis.befehle().size()).isGreaterThanOrEqualTo(10); + var typisiert = + parseErgebnis.befehle().stream().filter(b -> !(b instanceof UnbekannterBefehl)).count(); + assertThat(typisiert).isGreaterThan(parseErgebnis.befehle().size() / 2); + + var anwendung = BefehlAnwender.anwenden(gesetz, parseErgebnis.befehle()); + var synopse = SynopseBuilder.baue(gesetz, anwendung, parseErgebnis.warnungen(), false); + assertThat(HtmlRenderer.rendere(synopse, "E2E-Test")).contains("Neue Fassung"); + } + + /** Stresstest: GEG-Novelle 2023 („Heizungsgesetz“, 26 Seiten, >100 Befehle). */ + @Test + void gegGrossesAenderungsgesetz() throws Exception { + var xml = SAMPLEDATA.resolve("GEG/BJNR172810020.xml"); + var pdf = SAMPLEDATA.resolve("GEG/bgbl123s0280_regelungstext.pdf"); + assumeTrue(Files.exists(xml) && Files.exists(pdf), "GEG-Beispieldaten fehlen"); + + var gesetz = new GiiXmlLoader().load(xml); + var text = TextBereiniger.bereinige(new PatchTextExtraktor().extrahiere(pdf)); + var parseErgebnis = new AenderungsgesetzParser().parse(text, gesetz, null); + + assertThat(parseErgebnis.artikel()).contains("1"); + assertThat(parseErgebnis.befehle().size()).isGreaterThanOrEqualTo(100); + + // Das XML ist bereits konsolidiert; entscheidend ist, dass die Anwendung sauber terminiert + // und jeden Befehl protokolliert. + var anwendung = BefehlAnwender.anwenden(gesetz, parseErgebnis.befehle()); + assertThat(anwendung.protokoll()).hasSameSizeAs(parseErgebnis.befehle()); + var synopse = SynopseBuilder.baue(gesetz, anwendung, parseErgebnis.warnungen(), false); + assertThat(HtmlRenderer.rendere(synopse, "E2E-Test")).contains("Manuell prüfen"); + } + + /** + * Gesetzentwurf (Bundestags-Drucksache mit markerloser Silbentrennung). Das AGG-XML ist NICHT + * konsolidiert (Stand 2023, Entwurf von 2026) — hier entstehen echte Diffs. + */ + @Test + void aggRegierungsentwurfMitEchtenDiffs() throws Exception { + var xml = SAMPLEDATA.resolve("AGG/BJNR189710006.xml"); + var pdf = SAMPLEDATA.resolve("AGG/BT-Drs-21-6178_Regierungsentwurf.pdf"); + assumeTrue(Files.exists(xml) && Files.exists(pdf), "AGG-Beispieldaten fehlen"); + + var gesetz = new GiiXmlLoader().load(xml); + var text = TextBereiniger.bereinige(new PatchTextExtraktor().extrahiere(pdf)); + + // Markerlose Silbentrennung („Schwel|lenwertes“) muss zusammengezogen sein. + assertThat(text).contains("Schwellenwertes"); + + var parseErgebnis = new AenderungsgesetzParser().parse(text, gesetz, null); + assertThat(parseErgebnis.artikel()).containsExactly("1"); + // Der Begründungsteil darf keine Befehle erzeugen. + assertThat(parseErgebnis.befehle().size()).isBetween(15, 40); + + var anwendung = BefehlAnwender.anwenden(gesetz, parseErgebnis.befehle()); + assertThat(anwendung.anzahlAngewandt()).isGreaterThan(10); + + var synopse = SynopseBuilder.baue(gesetz, anwendung, parseErgebnis.warnungen(), false); + var html = HtmlRenderer.rendere(synopse, "E2E-Test"); + assertThat(html).contains("<del>").contains("<ins>"); + // Stichprobe aus Artikel 1 Nummer 1: „Alters“ → „Lebensalters“ in § 1. + assertThat(html).contains("<ins>Lebensalters</ins>"); + } + + /** + * Regierungsentwurf, dessen Artikel 1 ein Ablösegesetz ist; nur Artikel 2 ändert das ProdHaftG. + */ + @Test + void prodHaftGRegierungsentwurf() throws Exception { + var xml = SAMPLEDATA.resolve("ProdHaftG/BJNR021980989.xml"); + var pdf = SAMPLEDATA.resolve("ProdHaftG/RegE_Produkthaftungsrecht_2025-12-17.pdf"); + assumeTrue(Files.exists(xml) && Files.exists(pdf), "ProdHaftG-Beispieldaten fehlen"); + + var gesetz = new GiiXmlLoader().load(xml); + var text = TextBereiniger.bereinige(new PatchTextExtraktor().extrahiere(pdf)); + var parseErgebnis = new AenderungsgesetzParser().parse(text, gesetz, null); + + // Artikel 1 (neues Produkthaftungsgesetz) betrifft das Stammgesetz nicht; Artikel 2 schon. + assertThat(parseErgebnis.artikel()).containsExactly("2"); + assertThat(parseErgebnis.befehle()).hasSize(1); + + var anwendung = BefehlAnwender.anwenden(gesetz, parseErgebnis.befehle()); + assertThat(anwendung.anzahlAngewandt()).isEqualTo(1); + var neueNorm = anwendung.neu().norm("§ 19").orElseThrow(); + assertThat(neueNorm.titel()).isEqualTo("Außerkrafttreten"); + assertThat(neueNorm.gesamtText()).contains("9. Dezember 2026 außer Kraft"); + } } 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 c8f9cb8..f0282c5 100644 --- a/src/test/java/eu/mulk/aendggner/aenderung/parse/BefehlErkennerTest.java +++ b/src/test/java/eu/mulk/aendggner/aenderung/parse/BefehlErkennerTest.java @@ -247,6 +247,106 @@ class BefehlErkennerTest { } @Test + void erkenntStrukturErsetzungEinesAbsatzes() { + // Neues BGBl-Format (3. UWGÄndG 2026, Artikel 1 Nummer 1). + var befehl = + erkenne( + "§ 2 Absatz 2 wird durch die folgenden Absätze 2 und 3 ersetzt: „(2) Im Sinne" + + " dieses Gesetzes ist … (3) Weiteres.“", + Stelle.LEER); + + assertThat(befehl).containsInstanceOf(Aenderungsbefehl.StrukturErsetzung.class); + var ersetzung = (Aenderungsbefehl.StrukturErsetzung) befehl.orElseThrow(); + assertThat(ersetzung.stelle().anzeigeText()).isEqualTo("§ 2 Absatz 2"); + assertThat(ersetzung.ebene()).isEqualTo(Ebene.ABSATZ); + assertThat(ersetzung.text()).startsWith("(2) Im Sinne"); + } + + @Test + void erkenntStrukturErsetzungEinesSatzes() { + var befehl = + erkenne( + "Satz 3 wird durch die folgenden Sätze ersetzt: „Erster neuer Satz. Zweiter" + + " neuer Satz.“", + new Stelle(List.of(new Stelle.Paragraph("13"), new Stelle.AbsatzNr("3")))); + + assertThat(befehl).containsInstanceOf(Aenderungsbefehl.StrukturErsetzung.class); + var ersetzung = (Aenderungsbefehl.StrukturErsetzung) befehl.orElseThrow(); + assertThat(ersetzung.stelle().anzeigeText()).isEqualTo("§ 13 Absatz 3 Satz 3"); + assertThat(ersetzung.ebene()).isEqualTo(Ebene.SATZ); + } + + @Test + void erkenntUeberschriftErsetzungAlsNeufassung() { + // AGG-Regierungsentwurf 2026, Artikel 1 Nummer 4 Buchstabe a. + var befehl = + erkenne( + "Die Überschrift wird durch die folgende Überschrift ersetzt: „§ 10 Zulässige" + + " unterschiedliche Behandlung wegen des Lebensalters“.", + new Stelle(List.of(new Stelle.Paragraph("10")))); + + assertThat(befehl).containsInstanceOf(Neufassung.class); + assertThat(befehl.orElseThrow().stelle().betrifftUeberschrift()).isTrue(); + } + + @Test + void erkenntParagraphErsetzungAlsNeufassung() { + // ProdHaftG-Regierungsentwurf 2025, Artikel 2. + var befehl = + erkenne( + "§ 19 wird durch den folgenden § 19 ersetzt: „§ 19 Außerkrafttreten Dieses" + + " Gesetz tritt am 9. Dezember 2026 außer Kraft.“", + Stelle.LEER); + + assertThat(befehl).containsInstanceOf(Neufassung.class); + assertThat(befehl.orElseThrow().stelle().anzeigeText()).isEqualTo("§ 19"); + } + + @Test + void erkenntPluralEinfuegung() { + // AGG-Regierungsentwurf 2026: „die folgenden Absätze 6 und 7“. + var befehl = + erkenne( + "Nach Absatz 5 werden die folgenden Absätze 6 und 7 eingefügt: „(6) Neu." + + " (7) Auch neu.“", + new Stelle(List.of(new Stelle.Paragraph("27")))); + + assertThat(befehl).containsInstanceOf(StrukturEinfuegung.class); + var einfuegung = (StrukturEinfuegung) befehl.orElseThrow(); + assertThat(einfuegung.ebene()).isEqualTo(Ebene.ABSATZ); + assertThat(einfuegung.bezeichnung()).isNull(); + } + + @Test + void erkenntSatzzeichenErsetzungMitWoertern() { + // GEG-Novelle 2023: „durch ein Komma und die Wörter … ersetzt“. + var befehl = + erkenne( + "In Satz 2 wird der Punkt am Ende durch ein Komma und die Wörter „sowie neue" + + " Anforderungen“ ersetzt.", + new Stelle(List.of(new Stelle.Paragraph("47"), new Stelle.AbsatzNr("1")))); + + assertThat(befehl).containsInstanceOf(Ersetzung.class); + var ersetzung = (Ersetzung) befehl.orElseThrow(); + assertThat(ersetzung.alt()).isEqualTo("."); + assertThat(ersetzung.neu()).isEqualTo(", sowie neue Anforderungen"); + assertThat(ersetzung.amEnde()).isTrue(); + } + + @Test + void erkenntAngabeEinfuegungImInhaltsuebersichtsKontext() { + // GEG-Novelle 2023: verschachtelt unter „Die Inhaltsübersicht wird wie folgt geändert:“. + var kontext = new Stelle(List.of(new Stelle.Inhaltsuebersicht())); + var befehl = + erkenne( + "Nach der Angabe zu § 9 wird folgende Angabe eingefügt: „§ 9a" + " Länderregelung“.", + kontext); + + assertThat(befehl).containsInstanceOf(WoerterEinfuegung.class); + assertThat(befehl.orElseThrow().stelle().betrifftInhaltsuebersicht()).isTrue(); + } + + @Test void faelltBeiBereichsbefehlenAufUnbekanntZurueck() { // Bereichs- und Mehrfachbefehle sind in v1 bewusst nicht unterstützt. assertThat( 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 d8c5a03..98dc1d3 100644 --- a/src/test/java/eu/mulk/aendggner/aenderung/parse/TextBereinigerTest.java +++ b/src/test/java/eu/mulk/aendggner/aenderung/parse/TextBereinigerTest.java @@ -46,4 +46,65 @@ class TextBereinigerTest { assertThat(TextBereiniger.bereinige("Preisbildung und -\ngestaltung")) .isEqualTo("Preisbildung und -\ngestaltung"); } + + @Test + void entferntKopfzeilenDesNeuenBgblFormats() { + var roh = + "Erster Satz. \n" + + "Seite 2 von 5 Bundesgesetzblatt Jahrgang 2026 Teil I Nr. 43, ausgegeben zu" + + " Bonn am 19. Februar 2026 \n" + + "Zweiter Satz. "; + + assertThat(TextBereiniger.bereinige(roh)).isEqualTo("Erster Satz.\nZweiter Satz."); + } + + @Test + void entferntSeitenmarkerVonEntwuerfen() { + assertThat(TextBereiniger.bereinige("Erster Satz. \n - 10 - \nZweiter Satz. ")) + .isEqualTo("Erster Satz.\nZweiter Satz."); + } + + @Test + void entferntDrucksachenKopfzeilen() { + var roh = + "Erster Satz. \n" + + "Drucksache 21/6178 – 2 – Deutscher Bundestag – 21. Wahlperiode \n" + + "Deutscher Bundestag – 21. Wahlperiode – 3 – Drucksache 21/6178 \n" + + "Zweiter Satz. "; + + assertThat(TextBereiniger.bereinige(roh)).isEqualTo("Erster Satz.\nZweiter Satz."); + } + + @Test + void ziehtMarkerloseSilbentrennungZusammen() { + // Bundestags-Drucksachen: reguläre Umbrüche enden mit Leerzeichen, Trennungen nicht. + var roh = "unterhalb eines Schwel\nlenwertes von 50 \nWohnungen. "; + + assertThat(TextBereiniger.bereinige(roh)) + .isEqualTo("unterhalb eines Schwellenwertes von 50\nWohnungen."); + } + + @Test + void ziehtOhneTrailingSpaceKonventionKeineMarkerlosenTrennungenZusammen() { + // Handgeschriebene Klartextdateien: kein Trailing-Space-Signal → kein Join. + var roh = "das zuletzt geändert worden\nist, wird wie folgt geändert:"; + + assertThat(TextBereiniger.bereinige(roh)).isEqualTo(roh); + } + + @Test + void ziehtMarkerlosNichtVorKonjunktionenZusammen() { + var roh = "die Wirk\nund Hilfsstoffe sind wichtig. \nZweite Zeile endet mit Leerzeichen. "; + + assertThat(TextBereiniger.bereinige(roh)).startsWith("die Wirk\nund Hilfsstoffe"); + } + + @Test + void repariertInvertierteZitatzeichenAnAbsatzmarkern() { + // BMJV-Vorlagen zeichnen das hängende „ nach dem Absatzmarker bzw. der Paragraphenangabe. + assertThat(TextBereiniger.bereinige("(1) „ Ungeachtet des § 8 gilt.“")) + .isEqualTo("„(1) Ungeachtet des § 8 gilt.“"); + assertThat(TextBereiniger.bereinige("§ 19„\nAußerkrafttreten")) + .isEqualTo("„§ 19\nAußerkrafttreten"); + } } diff --git a/src/test/java/eu/mulk/aendggner/anwendung/BefehlAnwenderTest.java b/src/test/java/eu/mulk/aendggner/anwendung/BefehlAnwenderTest.java index 26557d1..beae519 100644 --- a/src/test/java/eu/mulk/aendggner/anwendung/BefehlAnwenderTest.java +++ b/src/test/java/eu/mulk/aendggner/anwendung/BefehlAnwenderTest.java @@ -9,6 +9,7 @@ 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.StrukturErsetzung; import eu.mulk.aendggner.aenderung.Aenderungsbefehl.Umnummerierung; import eu.mulk.aendggner.aenderung.Aenderungsbefehl.UnbekannterBefehl; import eu.mulk.aendggner.aenderung.Aenderungsbefehl.WoerterEinfuegung; @@ -309,6 +310,44 @@ class BefehlAnwenderTest { } @Test + void ersetztAbsatzDurchMehrereAbsaetze() { + var befehl = + new StrukturErsetzung( + stelle(new Stelle.Paragraph("1"), new Stelle.AbsatzNr("1")), + Ebene.ABSATZ, + "(1) Zweck ist die Erprobung. (1a) Die Erprobung ist wichtig.", + 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()).hasSize(3); + assertThat(norm.absaetze().get(0).nummer()).isEqualTo("1"); + assertThat(norm.absaetze().get(1).nummer()).isEqualTo("1a"); + assertThat(norm.absaetze().get(1).text()).isEqualTo("Die Erprobung ist wichtig."); + assertThat(norm.absaetze().get(2).nummer()).isEqualTo("2"); + } + + @Test + void ersetztSatzDurchMehrereSaetze() { + var befehl = + new StrukturErsetzung( + stelle(new Stelle.Paragraph("2"), new Stelle.SatzNr("2")), + Ebene.SATZ, + "Die Prüfung erfolgt gewissenhaft. Sie wird dokumentiert.", + 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 Prüfung erfolgt gewissenhaft." + + " Sie wird dokumentiert. Sie endet mit einem Bericht."); + } + + @Test void fuegtSatzNachSatzEin() { var befehl = new StrukturEinfuegung( |
