From b05e44a627b3bbdf6f8b2e920b963709fd348f5a Mon Sep 17 00:00:00 2001 From: Matthias Andreas Benkard Date: Mon, 13 Jul 2026 21:33:20 +0200 Subject: Support multi-target "jeweils" commands and range renumbering MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add a Sammelbefehl command that applies one operation to several "und"/"sowie"/comma-coordinated Stellen sharing a common prefix, and resolve "Die bisherigen Absätze X bis Y werden zu den Absätzen X' bis Y'" into descending single renumberings. Parsing gains StellenParser.parseMehrfach; the applier folds sub-commands into one log entry. Co-Authored-By: Claude Opus 4.8 Change-Id: Iaa2c7161066d40d0605c00d3b6ca9a08696070e7 --- src/test/java/eu/mulk/aendggner/EndToEndTest.java | 16 ++++- .../aenderung/parse/BefehlErkennerTest.java | 77 ++++++++++++++++++++++ .../aenderung/parse/StellenParserTest.java | 55 ++++++++++++++++ .../aendggner/anwendung/BefehlAnwenderTest.java | 53 +++++++++++++++ 4 files changed, 200 insertions(+), 1 deletion(-) create mode 100644 src/test/java/eu/mulk/aendggner/aenderung/parse/StellenParserTest.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 5254ff2..bd17d79 100644 --- a/src/test/java/eu/mulk/aendggner/EndToEndTest.java +++ b/src/test/java/eu/mulk/aendggner/EndToEndTest.java @@ -140,13 +140,27 @@ class EndToEndTest { assertThat(parseErgebnis.befehle().size()).isBetween(15, 40); var anwendung = BefehlAnwender.anwenden(gesetz, parseErgebnis.befehle()); - assertThat(anwendung.anzahlAngewandt()).isGreaterThan(10); + // Inkl. der Mehrfachziel-„jeweils“-Befehle (§ 3, § 20, § 30) werden hier ~22 Befehle + // angewandt; nur die zwei Sonderfälle (positionaler Lokator, Verbundbefehl) bleiben manuell. + assertThat(anwendung.anzahlAngewandt()).isGreaterThanOrEqualTo(20); + assertThat(anwendung.anzahlManuell()).isLessThanOrEqualTo(3); var synopse = SynopseBuilder.baue(gesetz, anwendung, parseErgebnis.warnungen(), false); var html = HtmlRenderer.rendere(synopse, "E2E-Test"); assertThat(html).contains("").contains(""); // Stichprobe aus Artikel 1 Nummer 1: „Alters“ → „Lebensalters“ in § 1. assertThat(html).contains("Lebensalters"); + // Mehrfachziel-Einfügung (§ 30 Absatz 2 Satz 1 und Absatz 3): „Bildung,“ vor „Familie“. + assertThat(html).contains("Bildung,"); + // Mehrfachziel-Streichung (§ 3 Absatz 1 Satz 2 und Absatz 4): gezielt entfernt — der + // untargetierte Absatz 5 behält die Angabe. + var norm3 = anwendung.neu().norm("§ 3").orElseThrow(); + var absatz1 = + norm3.absaetze().stream().filter(a -> "1".equals(a.nummer())).findFirst().orElseThrow(); + assertThat(absatz1.text()).doesNotContain("in Bezug auf § 2 Abs. 1 Nr. 1 bis 4"); + var absatz5 = + norm3.absaetze().stream().filter(a -> "5".equals(a.nummer())).findFirst().orElseThrow(); + assertThat(absatz5.text()).contains("in Bezug auf § 2 Abs. 1 Nr. 1 bis 4"); } /** 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 f0282c5..05055e0 100644 --- a/src/test/java/eu/mulk/aendggner/aenderung/parse/BefehlErkennerTest.java +++ b/src/test/java/eu/mulk/aendggner/aenderung/parse/BefehlErkennerTest.java @@ -8,6 +8,7 @@ import eu.mulk.aendggner.aenderung.Aenderungsbefehl.Aufhebung; import eu.mulk.aendggner.aenderung.Aenderungsbefehl.Ebene; import eu.mulk.aendggner.aenderung.Aenderungsbefehl.Ersetzung; import eu.mulk.aendggner.aenderung.Aenderungsbefehl.Neufassung; +import eu.mulk.aendggner.aenderung.Aenderungsbefehl.Sammelbefehl; import eu.mulk.aendggner.aenderung.Aenderungsbefehl.Streichung; import eu.mulk.aendggner.aenderung.Aenderungsbefehl.StrukturEinfuegung; import eu.mulk.aendggner.aenderung.Aenderungsbefehl.Umnummerierung; @@ -346,6 +347,82 @@ class BefehlErkennerTest { assertThat(befehl.orElseThrow().stelle().betrifftInhaltsuebersicht()).isTrue(); } + @Test + void erkenntMehrfachzielStreichung() { + var befehl = + erkenne( + "In § 3 Absatz 1 Satz 2 und Absatz 4 wird jeweils die Angabe „in Bezug auf § 2 Abs." + + " 1 Nr. 1 bis 4“ gestrichen.", + Stelle.LEER); + + assertThat(befehl).containsInstanceOf(Sammelbefehl.class); + var teile = ((Sammelbefehl) befehl.orElseThrow()).teilbefehle(); + assertThat(teile).hasSize(2).allMatch(t -> t instanceof Streichung); + assertThat(teile).extracting(t -> t.stelle().anzeigeText()) + .containsExactly("§ 3 Absatz 1 Satz 2", "§ 3 Absatz 4"); + assertThat(((Streichung) teile.get(0)).woerter()).isEqualTo("in Bezug auf § 2 Abs. 1 Nr. 1 bis 4"); + } + + @Test + void erkenntMehrfachzielErsetzung() { + var befehl = + erkenne( + "In § 20 Absatz 1 Satz 1 und Absatz 2 Satz 2 wird jeweils die Angabe „Alters“ durch" + + " die Angabe „Lebensalters“ ersetzt.", + Stelle.LEER); + + assertThat(befehl).containsInstanceOf(Sammelbefehl.class); + var teile = ((Sammelbefehl) befehl.orElseThrow()).teilbefehle(); + assertThat(teile).hasSize(2).allMatch(t -> t instanceof Ersetzung); + assertThat(teile).extracting(t -> t.stelle().anzeigeText()) + .containsExactly("§ 20 Absatz 1 Satz 1", "§ 20 Absatz 2 Satz 2"); + assertThat(((Ersetzung) teile.get(0)).neu()).isEqualTo("Lebensalters"); + } + + @Test + void erkenntMehrfachzielEinfuegung() { + var befehl = + erkenne( + "In § 30 Absatz 2 Satz 1 und Absatz 3 wird jeweils vor der Angabe „Familie“ die" + + " Angabe „Bildung,“ eingefügt.", + Stelle.LEER); + + assertThat(befehl).containsInstanceOf(Sammelbefehl.class); + var teile = ((Sammelbefehl) befehl.orElseThrow()).teilbefehle(); + assertThat(teile).hasSize(2).allMatch(t -> t instanceof WoerterEinfuegung); + assertThat(teile).extracting(t -> t.stelle().anzeigeText()) + .containsExactly("§ 30 Absatz 2 Satz 1", "§ 30 Absatz 3"); + } + + @Test + void erkenntBereichsUmnummerierungAbsteigend() { + var befehl = + erkenne( + "Die bisherigen Absätze 2 bis 4 werden zu den Absätzen 3 bis 5.", + new Stelle(List.of(new Stelle.Paragraph("5")))); + + assertThat(befehl).containsInstanceOf(Sammelbefehl.class); + var teile = ((Sammelbefehl) befehl.orElseThrow()).teilbefehle(); + assertThat(teile).hasSize(3).allMatch(t -> t instanceof Umnummerierung); + // Absteigend, damit die Anwendung keine Labels kollidieren lässt: 4→5, 3→4, 2→3. + assertThat(teile).extracting(t -> t.stelle().anzeigeText()) + .containsExactly("§ 5 Absatz 4", "§ 5 Absatz 3", "§ 5 Absatz 2"); + assertThat(teile).extracting(t -> ((Umnummerierung) t).neu().anzeigeText()) + .containsExactly("§ 5 Absatz 5", "§ 5 Absatz 4", "§ 5 Absatz 3"); + } + + @Test + void verbundZweierBefehleBleibtUnbekannt() { + // „… wird zu Absatz 2 und nach Satz 2 werden … eingefügt“ verbindet zwei verschiedene + // Befehle per „und“ — bewusst nicht unterstützt. + assertThat( + erkenne( + "Der bisherige Absatz 1 wird zu Absatz 2 und nach Satz 2 werden die folgenden" + + " Sätze eingefügt: „Ein Satz.“", + Stelle.LEER)) + .isEmpty(); + } + @Test void faelltBeiBereichsbefehlenAufUnbekanntZurueck() { // Bereichs- und Mehrfachbefehle sind in v1 bewusst nicht unterstützt. diff --git a/src/test/java/eu/mulk/aendggner/aenderung/parse/StellenParserTest.java b/src/test/java/eu/mulk/aendggner/aenderung/parse/StellenParserTest.java new file mode 100644 index 0000000..4c2c7ef --- /dev/null +++ b/src/test/java/eu/mulk/aendggner/aenderung/parse/StellenParserTest.java @@ -0,0 +1,55 @@ +package eu.mulk.aendggner.aenderung.parse; + +import static org.assertj.core.api.Assertions.assertThat; + +import eu.mulk.aendggner.aenderung.Stelle; +import org.junit.jupiter.api.Test; + +class StellenParserTest { + + @Test + void parstEinfacheStelle() { + assertThat(StellenParser.parse("§ 5a Absatz 2 Satz 1").orElseThrow().anzeigeText()) + .isEqualTo("§ 5a Absatz 2 Satz 1"); + } + + @Test + void lehntKoordinationInParseAb() { + // parse (einfach) bleibt bewusst streng: Koordination fällt durch. + assertThat(StellenParser.parse("§ 3 Absatz 1 und Absatz 4")).isEmpty(); + } + + @Test + void parstMehrfachMitGemeinsamemPraefix() { + // Zweites Segment erbt „§ 3“, ersetzt aber ab „Absatz“. + var stellen = StellenParser.parseMehrfach("§ 3 Absatz 1 Satz 2 und Absatz 4"); + assertThat(stellen).extracting(Stelle::anzeigeText) + .containsExactly("§ 3 Absatz 1 Satz 2", "§ 3 Absatz 4"); + } + + @Test + void parstMehrfachMitSatzTiefe() { + var stellen = StellenParser.parseMehrfach("§ 20 Absatz 1 Satz 1 und Absatz 2 Satz 2"); + assertThat(stellen).extracting(Stelle::anzeigeText) + .containsExactly("§ 20 Absatz 1 Satz 1", "§ 20 Absatz 2 Satz 2"); + } + + @Test + void parstMehrfachMitKomma() { + var stellen = StellenParser.parseMehrfach("§ 1, § 2 und § 3"); + assertThat(stellen).extracting(Stelle::anzeigeText) + .containsExactly("§ 1", "§ 2", "§ 3"); + } + + @Test + void einfacheStelleBleibtEinElementig() { + assertThat(StellenParser.parseMehrfach("§ 5 Absatz 2")) + .extracting(Stelle::anzeigeText) + .containsExactly("§ 5 Absatz 2"); + } + + @Test + void unparsbaresSegmentLiefertLeereListe() { + assertThat(StellenParser.parseMehrfach("§ 5 Absatz 2 und Kauderwelsch")).isEmpty(); + } +} diff --git a/src/test/java/eu/mulk/aendggner/anwendung/BefehlAnwenderTest.java b/src/test/java/eu/mulk/aendggner/anwendung/BefehlAnwenderTest.java index beae519..a854632 100644 --- a/src/test/java/eu/mulk/aendggner/anwendung/BefehlAnwenderTest.java +++ b/src/test/java/eu/mulk/aendggner/anwendung/BefehlAnwenderTest.java @@ -7,6 +7,7 @@ import eu.mulk.aendggner.aenderung.Aenderungsbefehl.Aufhebung; import eu.mulk.aendggner.aenderung.Aenderungsbefehl.Ebene; import eu.mulk.aendggner.aenderung.Aenderungsbefehl.Ersetzung; import eu.mulk.aendggner.aenderung.Aenderungsbefehl.Neufassung; +import eu.mulk.aendggner.aenderung.Aenderungsbefehl.Sammelbefehl; import eu.mulk.aendggner.aenderung.Aenderungsbefehl.Streichung; import eu.mulk.aendggner.aenderung.Aenderungsbefehl.StrukturEinfuegung; import eu.mulk.aendggner.aenderung.Aenderungsbefehl.StrukturErsetzung; @@ -369,6 +370,58 @@ class BefehlAnwenderTest { // --- Helfer -------------------------------------------------------------------------------- + @Test + void wendetSammelbefehlAlsEinenEintragAn() { + var teil1 = + new Ersetzung( + stelle(new Stelle.Paragraph("1"), new Stelle.AbsatzNr("1")), + "Erprobung", + "Prüfung", + false, + false, + PROV); + var teil2 = + new Ersetzung( + stelle(new Stelle.Paragraph("2")), "Erprobung", "Prüfung", false, false, PROV); + var sammel = new Sammelbefehl(List.of(teil1, teil2)); + + var ergebnis = BefehlAnwender.anwenden(gesetz(), List.of(sammel)); + + // Ein Befehl → genau ein Protokolleintrag, aber beide Teiledits wirken. + assertThat(ergebnis.protokoll()).hasSize(1); + assertThat(ergebnis.protokoll().get(0).status()).isEqualTo(Status.ANGEWANDT); + assertThat(ergebnis.protokoll().get(0).betroffeneEnbez()).containsExactlyInAnyOrder("§ 1", "§ 2"); + assertThat(absatzText(ergebnis.neu(), "§ 1", 0)) + .isEqualTo("Zweck dieses Gesetzes ist die Prüfung."); + assertThat(absatzText(ergebnis.neu(), "§ 2", 0)).startsWith("Prüfung ist die Prüfung"); + } + + @Test + void sammelbefehlMitFehlschlagendemTeilWirdManuell() { + var teil1 = + new Ersetzung( + stelle(new Stelle.Paragraph("1"), new Stelle.AbsatzNr("1")), + "Erprobung", + "Prüfung", + false, + false, + PROV); + var teil2 = + new Ersetzung( + stelle(new Stelle.Paragraph("2")), "gibt es nicht", "egal", false, false, PROV); + var sammel = new Sammelbefehl(List.of(teil1, teil2)); + + var ergebnis = BefehlAnwender.anwenden(gesetz(), List.of(sammel)); + + assertThat(ergebnis.protokoll()).hasSize(1); + var eintrag = ergebnis.protokoll().get(0); + assertThat(eintrag.status()).isEqualTo(Status.MANUELL_PRUEFEN); + assertThat(eintrag.begruendung()).contains("Teil 2"); + // Der gelungene Teil bleibt trotzdem wirksam. + assertThat(absatzText(ergebnis.neu(), "§ 1", 0)) + .isEqualTo("Zweck dieses Gesetzes ist die Prüfung."); + } + private static Gesetz gesetz() { return new Gesetz( "TestG", -- cgit v1.2.1