Support multi-target "jeweils" commands and range renumbering
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 <noreply@anthropic.com>
Change-Id: Iaa2c7161066d40d0605c00d3b6ca9a08696070e7
diff --git a/src/main/java/eu/mulk/aendggner/aenderung/Aenderungsbefehl.java b/src/main/java/eu/mulk/aendggner/aenderung/Aenderungsbefehl.java
index 60f5971..6b7c093 100644
--- a/src/main/java/eu/mulk/aendggner/aenderung/Aenderungsbefehl.java
+++ b/src/main/java/eu/mulk/aendggner/aenderung/Aenderungsbefehl.java
@@ -88,6 +88,28 @@
record Umnummerierung(Stelle stelle, Stelle neu, Provenienz provenienz)
implements Aenderungsbefehl {}
+ /**
+ * „In A und B wird jeweils …“ — ein Befehl, der dieselbe Operation auf mehrere, per „und“
+ * koordinierte Stellen anwendet. Die Teilbefehle teilen sich Provenienz und Befehlszeile; der
+ * Applier fasst sie zu einem Protokolleintrag zusammen.
+ */
+ record Sammelbefehl(java.util.List<Aenderungsbefehl> teilbefehle) implements Aenderungsbefehl {
+
+ public Sammelbefehl {
+ teilbefehle = java.util.List.copyOf(teilbefehle);
+ }
+
+ @Override
+ public Stelle stelle() {
+ return teilbefehle.get(0).stelle();
+ }
+
+ @Override
+ public Provenienz provenienz() {
+ return teilbefehle.get(0).provenienz();
+ }
+ }
+
/** Fallback für alles, was der Parser nicht versteht — muss manuell geprüft werden. */
record UnbekannterBefehl(Stelle stelle, String originalText, Provenienz provenienz)
implements Aenderungsbefehl {}
diff --git a/src/main/java/eu/mulk/aendggner/aenderung/parse/BefehlErkenner.java b/src/main/java/eu/mulk/aendggner/aenderung/parse/BefehlErkenner.java
index 22c2976..ca59b12 100644
--- a/src/main/java/eu/mulk/aendggner/aenderung/parse/BefehlErkenner.java
+++ b/src/main/java/eu/mulk/aendggner/aenderung/parse/BefehlErkenner.java
@@ -6,6 +6,7 @@
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;
@@ -14,7 +15,10 @@
import eu.mulk.aendggner.aenderung.Aenderungsbefehl.WortAnker;
import eu.mulk.aendggner.aenderung.Provenienz;
import eu.mulk.aendggner.aenderung.Stelle;
+import java.util.ArrayList;
+import java.util.List;
import java.util.Optional;
+import java.util.function.Function;
import java.util.regex.Matcher;
import java.util.regex.Pattern;
import org.jspecify.annotations.Nullable;
@@ -84,7 +88,8 @@
private static final Pattern WOERTER_EINFUEGUNG =
Pattern.compile(
- "^(?:In )?(.+?) (?:wird|werden) (nach|vor) (?:dem Wort|den Wörtern|der Angabe|der Zahl) "
+ "^(?:In )?(.+?) (?:wird|werden) (?:jeweils )?(nach|vor) "
+ + "(?:dem Wort|den Wörtern|der Angabe|der Zahl) "
+ Z
+ " "
+ WOERTER
@@ -123,10 +128,18 @@
private static final Pattern AUFHEBUNG = Pattern.compile("^(.+?) (?:wird|werden) aufgehoben\\.$");
private static final Pattern STREICHUNG =
- Pattern.compile("^(?:In )?(.+?) (?:wird|werden) " + WOERTER + " " + Z + " gestrichen\\.$");
+ Pattern.compile(
+ "^(?:In )?(.+?) (?:wird|werden) (?:jeweils )?" + WOERTER + " " + Z + " gestrichen\\.$");
private static final Pattern UMNUMMERIERUNG =
- Pattern.compile("^(?:Der bisherige )?(.+?) wird (Absatz|Satz) (\\d+[a-z]?)\\.$");
+ Pattern.compile("^(?:Der bisherige )?(.+?) wird (?:zu )?(Absatz|Satz) (\\d+[a-z]?)\\.$");
+
+ // „Die bisherigen Absätze 2 bis 4 werden zu den Absätzen 3 bis 5.“ (Entwürfe) — Bereichs-
+ // Umnummerierung, die in einzelne Umnummerierungen aufgelöst wird.
+ private static final Pattern UMNUMMERIERUNG_BEREICH =
+ Pattern.compile(
+ "^Die bisherigen (?:Absätze|Sätze) (\\d+) bis (\\d+) "
+ + "werden zu den (Absätzen|Sätzen) (\\d+) bis (\\d+)\\.$");
private static final Pattern INHALTSUEBERSICHT_EINFUEGUNG =
Pattern.compile(
@@ -203,10 +216,8 @@
var jeweils = m.group(2) != null || text.contains(" jeweils durch ");
var alt = wortZitat(zitate, m.group(3));
var neu = wortZitat(zitate, m.group(4));
- var stelle = StellenParser.parse(m.group(1));
- var effektivesJeweils = jeweils;
- return stelle.map(
- s -> new Ersetzung(kontext.plus(s), alt, neu, effektivesJeweils, false, provenienz));
+ return ausStellen(
+ m.group(1), s -> new Ersetzung(kontext.plus(s), alt, neu, jeweils, false, provenienz));
}
if ((m = ERSETZUNG_OHNE_STELLE.matcher(text)).matches()) {
@@ -244,8 +255,8 @@
? new WortAnker.NachWoertern(ankerWoerter)
: new WortAnker.VorWoertern(ankerWoerter);
var woerter = wortZitat(zitate, m.group(4));
- return StellenParser.parse(m.group(1))
- .map(s -> new WoerterEinfuegung(kontext.plus(s), anker, woerter, provenienz));
+ return ausStellen(
+ m.group(1), s -> new WoerterEinfuegung(kontext.plus(s), anker, woerter, provenienz));
}
if ((m = WOERTER_EINFUEGUNG_VOR_KOMMA.matcher(text)).matches()) {
@@ -335,8 +346,12 @@
if ((m = STREICHUNG.matcher(text)).matches()) {
var woerter = wortZitat(zitate, m.group(2));
- return StellenParser.parse(m.group(1))
- .map(s -> new Streichung(kontext.plus(s), woerter, provenienz));
+ return ausStellen(m.group(1), s -> new Streichung(kontext.plus(s), woerter, provenienz));
+ }
+
+ if ((m = UMNUMMERIERUNG_BEREICH.matcher(text)).matches()) {
+ return bereichsUmnummerierung(
+ m.group(3), m.group(1), m.group(2), m.group(4), m.group(5), kontext, provenienz);
}
if ((m = UMNUMMERIERUNG.matcher(text)).matches()) {
@@ -345,14 +360,63 @@
.map(
alt ->
new Umnummerierung(
- kontext.plus(alt),
- kontext.plus(new Stelle(java.util.List.of(neu))),
- provenienz));
+ kontext.plus(alt), kontext.plus(new Stelle(List.of(neu))), provenienz));
}
return Optional.empty();
}
+ /**
+ * Löst „Die bisherigen Absätze X bis Y werden zu den Absätzen X′ bis Y′.“ in einzelne
+ * Umnummerierungen auf, angewandt in absteigender Reihenfolge (Y→Y′ zuerst), damit die
+ * sequenzielle Anwendung keine Labels kollidieren lässt.
+ */
+ private static Optional<Aenderungsbefehl> bereichsUmnummerierung(
+ String ebeneWort,
+ String altVon,
+ String altBis,
+ String neuVon,
+ String neuBis,
+ Stelle kontext,
+ Provenienz provenienz) {
+ var ebene = ebeneWort.equals("Absätzen") ? "Absatz" : "Satz";
+ int av = Integer.parseInt(altVon);
+ int ab = Integer.parseInt(altBis);
+ int nv = Integer.parseInt(neuVon);
+ int nb = Integer.parseInt(neuBis);
+ if (ab - av != nb - nv || ab < av) {
+ return Optional.empty();
+ }
+ var teile = new ArrayList<Aenderungsbefehl>();
+ for (int k = ab - av; k >= 0; k--) {
+ var alt = komponenteFuer(ebene, String.valueOf(av + k));
+ var neu = komponenteFuer(ebene, String.valueOf(nv + k));
+ teile.add(
+ new Umnummerierung(
+ kontext.plus(new Stelle(List.of(alt))),
+ kontext.plus(new Stelle(List.of(neu))),
+ provenienz));
+ }
+ return Optional.of(new Sammelbefehl(teile));
+ }
+
+ /**
+ * Wendet einen Stellen-basierten Befehlsbauer auf eine (ggf. koordinierte) Stellenangabe an: bei
+ * einer einzelnen Stelle das gewohnte Verhalten, bei mehreren per „und“ verbundenen Stellen ein
+ * {@link Sammelbefehl}, der die Operation auf jede Stelle anwendet.
+ */
+ private static Optional<Aenderungsbefehl> ausStellen(
+ String phrase, Function<Stelle, Aenderungsbefehl> bauer) {
+ var stellen = StellenParser.parseMehrfach(phrase);
+ if (stellen.isEmpty()) {
+ return Optional.empty();
+ }
+ if (stellen.size() == 1) {
+ return Optional.of(bauer.apply(stellen.get(0)));
+ }
+ return Optional.of(new Sammelbefehl(stellen.stream().map(bauer).toList()));
+ }
+
private record EbeneBezeichnung(Ebene ebene, String bezeichnung) {}
private static Optional<EbeneBezeichnung> ebeneUndBezeichnung(String phrase) {
diff --git a/src/main/java/eu/mulk/aendggner/aenderung/parse/StellenParser.java b/src/main/java/eu/mulk/aendggner/aenderung/parse/StellenParser.java
index ad794b5..07702b2 100644
--- a/src/main/java/eu/mulk/aendggner/aenderung/parse/StellenParser.java
+++ b/src/main/java/eu/mulk/aendggner/aenderung/parse/StellenParser.java
@@ -2,6 +2,7 @@
import eu.mulk.aendggner.aenderung.Stelle;
import java.util.ArrayList;
+import java.util.List;
import java.util.Optional;
import java.util.Set;
import java.util.regex.Pattern;
@@ -103,6 +104,74 @@
return Optional.of(new Stelle(komponenten));
}
+ private static final Pattern KOORDINATION = Pattern.compile(",\\s*|\\s+und\\s+|\\s+sowie\\s+");
+
+ /**
+ * Parst eine ggf. per „und“/„sowie“/Komma koordinierte Stellenangabe in eine Liste von Stellen.
+ * Nachfolgende Segmente teilen sich den gemeinsamen Präfix des vorigen Segments: „§ 3 Absatz 1
+ * Satz 2 und Absatz 4“ → [§ 3 Absatz 1 Satz 2, § 3 Absatz 4].
+ *
+ * <p>Für eine einfache (nicht koordinierte) Angabe liefert die Methode genau ein Element (bzw.
+ * eine leere Liste, wenn {@link #parse} sie ablehnt) und ist damit ein Drop-in für {@code
+ * parse(...).map(List::of)}. Kann ein Segment nicht geparst werden oder findet der Präfix-Merge
+ * keine Anknüpfung, wird eine leere Liste geliefert — der Aufrufer stuft den Befehl dann als
+ * unbekannt ein (konservativ: lieber manuell prüfen als falsch anwenden).
+ */
+ public static List<Stelle> parseMehrfach(String phrase) {
+ var segmente = KOORDINATION.split(phrase.strip());
+ if (segmente.length <= 1) {
+ return parse(phrase).map(List::of).orElseGet(List::of);
+ }
+
+ var ergebnis = new ArrayList<Stelle>();
+ Stelle vorige = null;
+ for (var segment : segmente) {
+ if (segment.isBlank()) {
+ return List.of();
+ }
+ var teil = parse(segment);
+ if (teil.isEmpty()) {
+ return List.of();
+ }
+ Stelle voll;
+ if (vorige == null) {
+ voll = teil.get();
+ } else {
+ var gemergt = mitGemeinsamemPraefix(vorige, teil.get());
+ if (gemergt.isEmpty()) {
+ return List.of();
+ }
+ voll = gemergt.get();
+ }
+ ergebnis.add(voll);
+ vorige = voll;
+ }
+ return ergebnis;
+ }
+
+ /**
+ * Ergänzt {@code segment} um die Präfix-Komponenten von {@code vorige}, die feiner-granular als
+ * die führende Komponente des Segments sind. Die führende Komponentenklasse des Segments wird in
+ * {@code vorige} gesucht; alle davor stehenden Komponenten bilden den gemeinsamen Präfix.
+ */
+ private static Optional<Stelle> mitGemeinsamemPraefix(Stelle vorige, Stelle segment) {
+ var fuehrende = segment.komponenten().get(0).getClass();
+ var vorKomp = vorige.komponenten();
+ int ankerIndex = -1;
+ for (int i = 0; i < vorKomp.size(); i++) {
+ if (vorKomp.get(i).getClass().equals(fuehrende)) {
+ ankerIndex = i;
+ break;
+ }
+ }
+ if (ankerIndex < 0) {
+ return Optional.empty();
+ }
+ var komponenten = new ArrayList<>(vorKomp.subList(0, ankerIndex));
+ komponenten.addAll(segment.komponenten());
+ return Optional.of(new Stelle(komponenten));
+ }
+
private static String naechstesWort(String[] woerter, int i) {
return i + 1 < woerter.length ? entfernePunktuation(woerter[i + 1]) : null;
}
diff --git a/src/main/java/eu/mulk/aendggner/anwendung/BefehlAnwender.java b/src/main/java/eu/mulk/aendggner/anwendung/BefehlAnwender.java
index 02be593..2c9a33e 100644
--- a/src/main/java/eu/mulk/aendggner/anwendung/BefehlAnwender.java
+++ b/src/main/java/eu/mulk/aendggner/anwendung/BefehlAnwender.java
@@ -5,6 +5,7 @@
import eu.mulk.aendggner.aenderung.Aenderungsbefehl.Aufhebung;
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;
@@ -86,6 +87,7 @@
case Anfuegung a -> wendeAnfuegungAn(normen, a);
case Aufhebung a -> wendeAufhebungAn(normen, a);
case Umnummerierung u -> wendeUmnummerierungAn(normen, u);
+ case Sammelbefehl s -> wendeSammelAn(normen, s);
case UnbekannterBefehl u -> manuell(befehl, "Befehl nicht erkannt.");
};
} catch (RuntimeException e) {
@@ -467,6 +469,31 @@
return angewandt(befehl, "(keine Textänderung nötig)");
}
+ /**
+ * Ein Mehrfachziel-Befehl („In A und B wird jeweils …“): wendet jeden Teilbefehl nacheinander an
+ * (jeder mutiert den fortlaufenden Zwischenstand) und fasst sie zu einem Protokolleintrag
+ * zusammen. Nur wenn alle Teile gelingen, gilt der Befehl als angewandt; sonst wird er zur
+ * manuellen Prüfung markiert (bereits angewandte Teile bleiben wirksam).
+ */
+ private static AngewandteAenderung wendeSammelAn(List<Norm> normen, Sammelbefehl befehl) {
+ var betroffene = new LinkedHashSet<String>();
+ var fehler = new ArrayList<String>();
+ int i = 1;
+ for (var teil : befehl.teilbefehle()) {
+ var ergebnis = wendeAn(normen, teil);
+ betroffene.addAll(ergebnis.betroffeneEnbez());
+ if (ergebnis.status() != Status.ANGEWANDT) {
+ fehler.add("Teil " + i + " (" + teil.stelle().anzeigeText() + "): " + ergebnis.begruendung());
+ }
+ i++;
+ }
+ if (fehler.isEmpty()) {
+ return new AngewandteAenderung(befehl, Status.ANGEWANDT, "", betroffene);
+ }
+ return new AngewandteAenderung(
+ befehl, Status.MANUELL_PRUEFEN, String.join(" ", fehler), betroffene);
+ }
+
// --- Gemeinsame Helfer ---------------------------------------------------------------------
private record TextErgebnis(@Nullable String text, @Nullable String fehler) {
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 @@
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("<del>").contains("<ins>");
// Stichprobe aus Artikel 1 Nummer 1: „Alters“ → „Lebensalters“ in § 1.
assertThat(html).contains("<ins>Lebensalters</ins>");
+ // Mehrfachziel-Einfügung (§ 30 Absatz 2 Satz 1 und Absatz 3): „Bildung,“ vor „Familie“.
+ assertThat(html).contains("<ins>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.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;
@@ -347,6 +348,82 @@
}
@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.
assertThat(
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.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 @@
// --- 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",