aboutsummaryrefslogtreecommitdiff
path: root/src/test/java
diff options
context:
space:
mode:
authorMatthias Andreas Benkard <code@mail.matthias.benkard.de>2026-07-28 07:51:09 +0200
committerMatthias Andreas Benkard <code@mail.matthias.benkard.de>2026-07-28 07:51:09 +0200
commitbac648b8fc06dfffaf67cf283f73fa2572e67575 (patch)
treee2e4387451deabb37b5f80ed42e06bd17b3127d9 /src/test/java
parent5f30f566873c313b554f087c0f7e132efa54d64a (diff)
Run the NRW mass test: WDR-Gesetz and LMG NRW
The 22nd Rundfunkänderungsgesetz amends five laws in one gazette issue. Article 3 (TMZ-Gesetz) was already an acceptance case; this adds the two big ones — Article 1 (WDR-Gesetz, 101 commands across 31 norms) and Article 2 (LMG NRW, 18 commands). Both stem versions come from recht.nrw.de, whose URLs address a version by its date of entry into force, so the results could be checked norm by norm against the official 1 April 2026 consolidations. Applying them turned up work at every layer: Order of application. Renumbering always refers to the original count, not to the state after the preceding items, so a cascade („Absatz 3 wird Absatz 4“, „Absatz 4 wird Absatz 5“, …) applied in document order leaves the law carrying two units of the same name. BefehlAnwender now derives, for each command, which designations it vacates and which it newly occupies, and pulls a vacating command ahead of the first one it collides with. That single rule subsumes the previous §-only special case, orders a cascade descending, and — inside a Sammelbefehl too — keeps a range renumbering from colliding with itself. Bayern's Art. 29a sequence now resolves as well (5 manual residues down to 3). Renumbering an enumeration item. Unlike an Absatz, a Nummer carries its designation as a marker in the text; the applier silently reported success without touching it. It now rewrites the marker and lets a struck placeholder with the target designation give way. Sentence splitting. Gazette citations („(BGBl. I S. 1982)“) and line-initial enumeration markers were splitting sentences, so Satz locators pointed into the wrong place; a sentence may also open with §. Command language: „wie folgt neu gefasst“ and the missing „wird“, a split location („In Absatz 2 wird Satz 1 wie folgt gefasst“), an insertion behind a location prefix, the standalone „Der Punkt am Ende wird durch … ersetzt“, „Dem Wortlaut des Absatzes 3 …“, the verb frame „Es werden ersetzt:“ whose items carry only the location, coordination at „sowie“ and before a punctuation clause, and a stray closing quote at the end of a sentence. Stem format: LandesRechtTextParser reads an „Inhaltsübersicht“ line as the norm of that name (which is what the Angabe commands address) and recognises keyword-less Roman section headings. Everything the two acceptance tests do not apply is named: one command in the WDR-Gesetz whose „Punkt am Ende des Satzes“ is not unambiguous in a two-sentence Nummer. The remaining differences from the official consolidations are places where the official text itself departs from the command wording; SOURCES lists them. Change-Id: I261cf89cd5820f7597bc5db734f322d94b84828e
Diffstat (limited to 'src/test/java')
-rw-r--r--src/test/java/eu/mulk/aendggner/EndToEndTest.java146
-rw-r--r--src/test/java/eu/mulk/aendggner/aenderung/parse/BefehlErkennerTest.java121
-rw-r--r--src/test/java/eu/mulk/aendggner/anwendung/BefehlAnwenderTest.java126
-rw-r--r--src/test/java/eu/mulk/aendggner/anwendung/SatzTeilerTest.java44
-rw-r--r--src/test/java/eu/mulk/aendggner/gesetz/land/LandesRechtTextParserTest.java61
5 files changed, 489 insertions, 9 deletions
diff --git a/src/test/java/eu/mulk/aendggner/EndToEndTest.java b/src/test/java/eu/mulk/aendggner/EndToEndTest.java
index c4ba894..959e664 100644
--- a/src/test/java/eu/mulk/aendggner/EndToEndTest.java
+++ b/src/test/java/eu/mulk/aendggner/EndToEndTest.java
@@ -257,15 +257,16 @@ class EndToEndTest {
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.
+ // 4. Anwenden auf die alte Fassung. Von 154 Befehlen bleiben genau drei als „manuell prüfen“
+ // stehen — Folgeänderungen innerhalb einer mehrschrittigen Umnummerierungssequenz, die
+ // ÄndGgner bewusst nicht automatisch auflöst (statt fehlerhaft zu raten):
// * 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.
+ // Die Sequenz in Art. 29a (§ 1 Nr. 23) löst sich dagegen seit der Kaskaden-Ordnung des
+ // BefehlAnwenders auf: „Der bisherige Abs. 4 wird Abs. 5“ läuft vor der Bereichs-
+ // Umnummerierung „Die bisherigen Abs. 1 bis 3 werden die Abs. 2 bis 4“, sodass die auf den
+ // neuen Abs. 5 zielenden Wort- und Satzbefehle ihren Alttext finden.
// 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());
@@ -275,9 +276,14 @@ class EndToEndTest {
.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)");
+ assertThat(manuellPfade).containsExactlyInAnyOrder("48. a) ee)", "48. a) gg)", "48. b) cc)");
+
+ // Art. 29a Abs. 5 trägt nach der Kaskade die neue Behördenbezeichnung und den eingefügten Satz.
+ var art29a = anwendung.neu().norm("Art. 29a").orElseThrow();
+ assertThat(art29a.absaetze()).hasSize(5);
+ assertThat(art29a.absaetze().get(4).text())
+ .startsWith("¹Die oberste Jagdbehörde wird ermächtigt")
+ .contains("²Die oberste Jagdbehörde kann zudem durch Rechtsverordnung");
// 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.
@@ -561,6 +567,128 @@ class EndToEndTest {
}
/**
+ * Nordrhein-Westfalen, Massentest: Artikel 1 desselben Heftes ändert das WDR-Gesetz — 101 Befehle
+ * an 31 Normen, darunter die Inhaltsübersicht, mehrere Umnummerierungs-Kaskaden über ganze
+ * Absatz- und Nummernfolgen und ein Sammelrahmen („Es werden ersetzt:“). Genau ein Befehl bleibt
+ * manuell; das Ergebnis wurde gegen die amtliche Fassung vom 1. April 2026 (recht.nrw.de)
+ * abgeglichen.
+ */
+ @Test
+ void wdrGesetzAcceptance() throws Exception {
+ var alt = SAMPLEDATA.resolve("NRW/WDR-Gesetz-alt.txt");
+ var pdf = SAMPLEDATA.resolve("NRW/GV-NRW-2026-S202_22-Rundfunkaenderungsgesetz.pdf");
+ assumeTrue(Files.exists(alt) && Files.exists(pdf), "WDR-Gesetz-Beispieldaten fehlen");
+
+ var gesetz = new eu.mulk.aendggner.gesetz.land.LandesRechtLoader().load(alt);
+ assertThat(gesetz.jurabk()).isEqualTo("WDR-Gesetz");
+ // Die Inhaltsübersicht ist eine eigene Norm — nur so greifen die Angabe-Befehle des Artikels 1.
+ assertThat(gesetz.norm("Inhaltsübersicht")).isPresent();
+
+ var text = TextBereiniger.bereinige(new PatchTextExtraktor().extrahiere(pdf));
+ var parseErgebnis = new AenderungsgesetzParser().parse(text, gesetz, null);
+ assertThat(parseErgebnis.artikel()).containsExactly("1");
+ assertThat(parseErgebnis.befehle()).noneMatch(b -> b instanceof UnbekannterBefehl);
+
+ var anwendung = BefehlAnwender.anwenden(gesetz, parseErgebnis.befehle());
+ var manuellPfade =
+ anwendung.protokoll().stream()
+ .filter(a -> a.status() == BefehlAnwender.Status.MANUELL_PRUEFEN)
+ .map(a -> a.befehl().provenienz().gliederungsPfad())
+ .toList();
+ // Einziges Residuum: „In Satz 2 Nummer 1 wird … der Punkt am Ende des Satzes durch ein
+ // Semikolon ersetzt …“ — die adressierte Nummer 1 trägt zwei Sätze und endet selbst auf ein
+ // Komma; welchen Punkt der Befehl meint, ist dem Wortlaut nicht sicher zu entnehmen.
+ assertThat(manuellPfade).containsExactly("13. e) aa)");
+ assertThat(anwendung.anzahlAngewandt()).isEqualTo(parseErgebnis.befehle().size() - 1);
+
+ var neu = anwendung.neu();
+ // 1. Umnummerierungs-Kaskade in § 3: Absatz 2 wird durch zwei Absätze ersetzt, die bisherigen
+ // Absätze 3 bis 11 rücken auf, ein neuer Absatz 13 kommt hinzu — 15 Absätze in der Reihe der
+ // amtlichen Nachfassung.
+ var paragraph3 = neu.norm("§ 3").orElseThrow();
+ assertThat(paragraph3.absaetze().stream().map(a -> a.nummer()).toList())
+ .containsExactly(
+ "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");
+
+ // 2. Nummern-Kaskade in § 15 Absatz 3: Nummer 22 entfällt, die folgenden rücken auf — die
+ // Aufzählungsmarken im Text sind mitgezogen.
+ var absatz3 = neu.norm("§ 15").orElseThrow().absaetze().get(2).text();
+ assertThat(absatz3)
+ .contains("22. den Landesbehindertenrat NRW e.V.")
+ .contains("25. die Allianz Deutscher Produzentinnen und Produzenten")
+ .doesNotContain("22. den Sozialverband VdK");
+
+ // 3. Sammelrahmen „Es werden ersetzt:“ — die Unterpunkte tragen die Fundstelle, nicht das Verb.
+ assertThat(neu.norm("§ 17").orElseThrow().gesamtText())
+ .contains("schriftlich oder elektronisch");
+ assertThat(neu.norm("§ 40").orElseThrow().gesamtText())
+ .contains("schriftliche oder elektronische");
+
+ // 4. Die Angabe-Befehle auf die Inhaltsübersicht sind angewandt.
+ assertThat(neu.norm("Inhaltsübersicht").orElseThrow().gesamtText())
+ .contains("§ 11 | (weggefallen)")
+ .contains("§ 57a | Übergangsregelung zu Amtszeiten, Entsendung und Hörfunkprogrammen");
+
+ // 5. „Dem Wortlaut des Absatzes 3 werden die folgenden Sätze vorangestellt“ trifft den Absatz,
+ // nicht die Norm.
+ assertThat(neu.norm("§ 24").orElseThrow().absaetze().get(0).text())
+ .startsWith("Die Intendantin oder der Intendant wird auf sechs Jahre gewählt");
+ assertThat(neu.norm("§ 24").orElseThrow().absaetze().get(2).text())
+ .startsWith("Die inhaltlichen Anforderungen an das Amt");
+ }
+
+ /**
+ * Nordrhein-Westfalen: Artikel 2 desselben Heftes ändert das Landesmediengesetz — 18 Befehle,
+ * *alle* angewandt. Das Ergebnis stimmt mit der amtlichen Fassung vom 1. April 2026 überein; nur
+ * § 93 Absatz 3 Nummer 11 weicht ab, weil die amtliche Nachfassung dort über den Befehlswortlaut
+ * hinaus auch die Wörter „Film (“ gestrichen hat.
+ */
+ @Test
+ void lmgNrwAcceptance() throws Exception {
+ var alt = SAMPLEDATA.resolve("NRW/LMG-NRW-alt.txt");
+ var pdf = SAMPLEDATA.resolve("NRW/GV-NRW-2026-S202_22-Rundfunkaenderungsgesetz.pdf");
+ assumeTrue(Files.exists(alt) && Files.exists(pdf), "LMG-NRW-Beispieldaten fehlen");
+
+ var gesetz = new eu.mulk.aendggner.gesetz.land.LandesRechtLoader().load(alt);
+ assertThat(gesetz.jurabk()).isEqualTo("LMG NRW");
+ // Die Inhaltsübersicht führt auch die Gliederungs-Überschriften („Abschnitt 1 …“); sie zerfällt
+ // daran nicht, sondern bleibt eine Norm bis zum ersten Normkopf.
+ var uebersicht = gesetz.norm("Inhaltsübersicht").orElseThrow();
+ assertThat(uebersicht.gesamtText())
+ .contains("Abschnitt 1 Allgemeine Vorschriften")
+ .contains("§ 127 | Übergangsregelung zur Neukonstituierung der Medienkommission");
+
+ var text = TextBereiniger.bereinige(new PatchTextExtraktor().extrahiere(pdf));
+ var parseErgebnis = new AenderungsgesetzParser().parse(text, gesetz, null);
+ assertThat(parseErgebnis.artikel()).containsExactly("2");
+ assertThat(parseErgebnis.befehle()).hasSize(18);
+ assertThat(parseErgebnis.befehle()).noneMatch(b -> b instanceof UnbekannterBefehl);
+
+ var anwendung = BefehlAnwender.anwenden(gesetz, parseErgebnis.befehle());
+ assertThat(anwendung.anzahlManuell()).isZero();
+
+ var neu = anwendung.neu();
+ // § 49 Absatz 2 Satz 1: Die Fundstellen-Abkürzung „BGBl.“ darf den Satz nicht teilen, sonst
+ // fände der zweite Teil des Befehls seinen Alttext nicht.
+ assertThat(neu.norm("§ 49").orElseThrow().absaetze().get(1).text())
+ .contains("der der Verordnung (EU) 2016/679")
+ .contains("Telekommunikation-Digitale-Dienste-Datenschutz-Gesetzes vom 23. Juni 2021")
+ .doesNotContain("Telekommunikation-Telemedien-Datenschutz-Gesetzes");
+ // § 127 ist neu gefasst — samt Überschrift in der Inhaltsübersicht.
+ assertThat(neu.norm("§ 127").orElseThrow().titel())
+ .isEqualTo("Übergangsregelung zu Amtszeiten und Entsendung");
+ assertThat(neu.norm("Inhaltsübersicht").orElseThrow().gesamtText())
+ .contains("§ 127 | Übergangsregelung zu Amtszeiten und Entsendung");
+ // § 88 Absatz 6 Satz 4 ist aufgehoben.
+ assertThat(neu.norm("§ 88").orElseThrow().absaetze().get(5).text())
+ .doesNotContain("Absatz 5 Satz 2 gilt entsprechend");
+ }
+
+ /**
* Berlin: Das GVBl. ist zweispaltig gesetzt (Wolters-Kluwer-Satz). Der Test hält das Ergebnis des
* Spaltenspikes fest — die Spaltenreihenfolge des Inhaltsstroms ist bereits die Lesereihenfolge,
* eine koordinatenbasierte Spaltenerkennung ist dafür nicht nötig.
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 95d15e1..8fcf3e5 100644
--- a/src/test/java/eu/mulk/aendggner/aenderung/parse/BefehlErkennerTest.java
+++ b/src/test/java/eu/mulk/aendggner/aenderung/parse/BefehlErkennerTest.java
@@ -26,6 +26,8 @@ class BefehlErkennerTest {
private static final Provenienz PROV = new Provenienz("1", "1.", "(Test)");
+ private static final Stelle PARAGRAPH_5 = new Stelle(List.of(new Stelle.Paragraph("5")));
+
/** Erkennt einen Befehl aus echtem Gesetzestext (mit „…“-Zitaten). */
private static Optional<Aenderungsbefehl> erkenne(String befehlsSatz, Stelle kontext) {
var zitate = ZitatExtraktor.extrahiere(befehlsSatz);
@@ -1204,4 +1206,123 @@ class BefehlErkennerTest {
var abs = erkenne("Abs. 3 wird Abs. 2.", ART_22);
assertThat(abs).containsInstanceOf(Umnummerierung.class);
}
+
+ // --- Idiome des nordrhein-westfälischen Gesetz- und Verordnungsblatts --------------------
+
+ /** „wird wie folgt neu gefasst“ und das im amtlichen Satz fehlende „wird“. */
+ @Test
+ void erkenntNeufassungMitNeuUndOhneVerb() {
+ assertThat(erkenne("§ 10 Absatz 2 wird wie folgt neu gefasst: „(2) Neuer Text.“", Stelle.LEER))
+ .containsInstanceOf(Neufassung.class);
+ assertThat(erkenne("Nummer 9 wie folgt gefasst: „9. Neuer Text,“.", Stelle.LEER))
+ .containsInstanceOf(Neufassung.class);
+ }
+
+ /** Geteilte Fundstelle: der Rahmen vor dem Verb, die adressierte Einheit dahinter. */
+ @Test
+ void erkenntNeufassungMitGeteilterStelle() {
+ var befehl = erkenne("In Absatz 2 wird Satz 1 wie folgt gefasst: „Neuer Satz.“", PARAGRAPH_5);
+
+ assertThat(befehl).containsInstanceOf(Neufassung.class);
+ assertThat(befehl.orElseThrow().stelle().anzeigeText()).isEqualTo("§ 5 Absatz 2 Satz 1");
+ }
+
+ /** Struktur-Einfügung mit vorangestelltem Fundstellen-Rahmen und Artikel vor dem Anker. */
+ @Test
+ void erkenntEinfuegungMitVorangestellterStelle() {
+ var befehl =
+ erkenne(
+ "In Absatz 1 wird nach dem Satz 1 folgender Satz eingefügt: „Neuer Satz.“",
+ PARAGRAPH_5);
+
+ assertThat(befehl).containsInstanceOf(StrukturEinfuegung.class);
+ assertThat(befehl.orElseThrow().stelle().anzeigeText()).isEqualTo("§ 5 Absatz 1 Satz 1");
+ assertThat(((StrukturEinfuegung) befehl.orElseThrow()).vorher()).isFalse();
+ }
+
+ /** Das Satzzeichen ist selbst das Subjekt; die Fundstelle liefert der Kontextrahmen. */
+ @Test
+ void erkenntSatzzeichenErsetzungOhneStelle() {
+ var befehl =
+ erkenne("Der Punkt am Ende wird durch die Angabe „(Gesellschaftsdialog).“ ersetzt.",
+ PARAGRAPH_5);
+
+ assertThat(befehl).containsInstanceOf(Ersetzung.class);
+ var ersetzung = (Ersetzung) befehl.orElseThrow();
+ assertThat(ersetzung.alt()).isEqualTo(".");
+ assertThat(ersetzung.neu()).isEqualTo("(Gesellschaftsdialog).");
+ assertThat(ersetzung.stelle().anzeigeText()).isEqualTo("§ 5");
+ }
+
+ /** Rahmen und Folgebefehl stehen in einem Punkt statt in Punkt und Unterpunkt. */
+ @Test
+ void erkenntRahmenMitFolgebefehlInEinemSatz() {
+ var befehl =
+ erkenne(
+ "Die bisherige Nummer 26 wird zu Nummer 25 und wird wie folgt geändert: Die Angabe"
+ + " „alt“ wird durch die Angabe „neu“ ersetzt.",
+ PARAGRAPH_5);
+
+ assertThat(befehl).containsInstanceOf(Sammelbefehl.class);
+ var teile = ((Sammelbefehl) befehl.orElseThrow()).teilbefehle();
+ assertThat(teile.get(0)).isInstanceOf(Umnummerierung.class);
+ assertThat(teile.get(1).stelle().anzeigeText()).isEqualTo("§ 5 Nummer 25");
+ }
+
+ /** Verb-Rahmen „Es werden ersetzt:“ — der Unterpunkt trägt nur die Fundstelle. */
+ @Test
+ void vervollstaendigtVerbRahmenPunkt() {
+ assertThat(BefehlErkenner.verbRahmen("Es werden ersetzt:")).contains("ersetzt");
+ var zitate =
+ ZitatExtraktor.extrahiere(
+ "in § 35 Absatz 3 sowie § 44 Absatz 1 die Angabe „schriftlichen“ jeweils durch die"
+ + " Angabe „schriftlichen oder elektronischen“,");
+ var satz =
+ BefehlErkenner.vervollstaendigeVerbRahmenPunkt(
+ zitate.text().replaceAll("\\s+", " ").strip(), "ersetzt");
+ assertThat(satz).isPresent();
+ var befehl = BefehlErkenner.erkenne(satz.orElseThrow(), Stelle.LEER, zitate, PROV);
+ assertThat(befehl).containsInstanceOf(Sammelbefehl.class);
+ var teile = ((Sammelbefehl) befehl.orElseThrow()).teilbefehle();
+ assertThat(teile.stream().map(t -> t.stelle().anzeigeText()))
+ .containsExactly("§ 35 Absatz 3", "§ 44 Absatz 1");
+ }
+
+ /** Nach einer Absatz-Umnummerierung erbt die lokative Folgeklausel die neue Bezeichnung. */
+ @Test
+ void erbtNeueAbsatzbezeichnungInFolgeklausel() {
+ var befehl =
+ erkenne(
+ "Der bisherige Absatz 8 wird Absatz 9 und in Satz 3 wird die Angabe „32a“ durch die"
+ + " Angabe „30b“ ersetzt.",
+ PARAGRAPH_5);
+
+ assertThat(befehl).containsInstanceOf(Sammelbefehl.class);
+ var teile = ((Sammelbefehl) befehl.orElseThrow()).teilbefehle();
+ assertThat(teile.get(1).stelle().anzeigeText()).isEqualTo("§ 5 Absatz 9 Satz 3");
+ }
+
+ /** „Dem Wortlaut des Absatzes 3 …“ nennt die Fundstelle selbst. */
+ @Test
+ void erkenntWortlautVoranstellungMitAbsatzangabe() {
+ var befehl =
+ erkenne(
+ "Dem Wortlaut des Absatzes 3 werden die folgenden Sätze vorangestellt: „Erster Satz.“",
+ PARAGRAPH_5);
+
+ assertThat(befehl).containsInstanceOf(Aenderungsbefehl.WortlautVoranstellung.class);
+ assertThat(befehl.orElseThrow().stelle().anzeigeText()).isEqualTo("§ 5 Absatz 3");
+ }
+
+ /** Ein überzähliges schließendes Anführungszeichen am Satzende ist Beiwerk. */
+ @Test
+ void toleriertUeberzaehligesSchlussAnfuehrungszeichen() {
+ var zitate =
+ ZitatExtraktor.extrahiere(
+ "In Absatz 7 Satz 3 wird die Angabe „schriftlich“ durch die Angabe „schriftlich oder"
+ + " elektronisch“ ersetzt.“");
+ var text = zitate.text().replaceAll("\\s+", " ").strip();
+ assertThat(BefehlErkenner.erkenne(text, Stelle.LEER, zitate, PROV))
+ .containsInstanceOf(Ersetzung.class);
+ }
}
diff --git a/src/test/java/eu/mulk/aendggner/anwendung/BefehlAnwenderTest.java b/src/test/java/eu/mulk/aendggner/anwendung/BefehlAnwenderTest.java
index 2706ac8..e9ecbcd 100644
--- a/src/test/java/eu/mulk/aendggner/anwendung/BefehlAnwenderTest.java
+++ b/src/test/java/eu/mulk/aendggner/anwendung/BefehlAnwenderTest.java
@@ -1066,6 +1066,132 @@ class BefehlAnwenderTest {
assertThat(ergebnis.neu().langue()).isEqualTo("Gesetz zur gründlichen Erprobung");
}
+ /**
+ * Aufsteigende Umnummerierungs-Kaskade: Beide Befehle meinen die ursprüngliche Zählung. In
+ * Dokumentreihenfolge angewandt entstünde ein zweiter Absatz 2; der Anwender zieht deshalb den
+ * räumenden Befehl vor.
+ */
+ @Test
+ void loestAufsteigendeUmnummerierungsKaskadeAuf() {
+ var eins =
+ new Umnummerierung(
+ stelle(new Stelle.Paragraph("1"), new Stelle.AbsatzNr("1")),
+ stelle(new Stelle.Paragraph("1"), new Stelle.AbsatzNr("2")),
+ PROV);
+ var zwei =
+ new Umnummerierung(
+ stelle(new Stelle.Paragraph("1"), new Stelle.AbsatzNr("2")),
+ stelle(new Stelle.Paragraph("1"), new Stelle.AbsatzNr("3")),
+ PROV);
+
+ var ergebnis = BefehlAnwender.anwenden(gesetz(), List.of(eins, zwei));
+
+ assertThat(ergebnis.anzahlManuell()).isZero();
+ assertThat(ergebnis.neu().norm("§ 1").orElseThrow().absaetze().stream().map(Absatz::nummer))
+ .containsExactly("2", "3");
+ // Das Protokoll bleibt in Dokumentreihenfolge, unabhängig von der Anwendungsreihenfolge.
+ assertThat(ergebnis.protokoll().get(0).befehl()).isSameAs(eins);
+ }
+
+ /** Eine Umnummerierung läuft vor der Einfügung, die ihre bisherige Bezeichnung neu vergibt. */
+ @Test
+ void raeumtBezeichnungVorEinfuegung() {
+ var einfuegung =
+ new StrukturEinfuegung(
+ stelle(new Stelle.Paragraph("1"), new Stelle.AbsatzNr("1")),
+ false,
+ Ebene.ABSATZ,
+ "2",
+ "(2) Neuer Absatz.",
+ PROV);
+ var umnummerierung =
+ new Umnummerierung(
+ stelle(new Stelle.Paragraph("1"), new Stelle.AbsatzNr("2")),
+ stelle(new Stelle.Paragraph("1"), new Stelle.AbsatzNr("3")),
+ PROV);
+
+ var ergebnis = BefehlAnwender.anwenden(gesetz(), List.of(einfuegung, umnummerierung));
+
+ assertThat(ergebnis.anzahlManuell()).isZero();
+ assertThat(ergebnis.neu().norm("§ 1").orElseThrow().absaetze().stream().map(Absatz::nummer))
+ .containsExactly("1", "2", "3");
+ assertThat(absatzText(ergebnis.neu(), "§ 1", 1)).isEqualTo("Neuer Absatz.");
+ }
+
+ /**
+ * Anders als ein Absatz trägt eine Aufzählungsnummer ihre Bezeichnung als Marke im Text; die
+ * Umnummerierung muss sie dort austauschen.
+ */
+ @Test
+ void nummeriertAufzaehlungsNummerImTextUm() {
+ var befehl =
+ new Umnummerierung(
+ stelle(
+ new Stelle.Paragraph("1"), new Stelle.AbsatzNr("2"), new Stelle.NummerNr("2")),
+ stelle(
+ new Stelle.Paragraph("1"), new Stelle.AbsatzNr("2"), new Stelle.NummerNr("4")),
+ PROV);
+
+ var ergebnis = BefehlAnwender.anwenden(gesetz(), List.of(befehl));
+
+ assertThat(ergebnis.protokoll().get(0).status()).isEqualTo(Status.ANGEWANDT);
+ assertThat(absatzText(ergebnis.neu(), "§ 1", 1))
+ .contains(" 4. die Anwendung von Befehlen und")
+ .doesNotContain(" 2. die Anwendung");
+ }
+
+ /** Der weggefallene Platzhalter mit der Zielbezeichnung weicht der Umnummerierung. */
+ @Test
+ void ueberschreibtWeggefalleneNummer() {
+ var mitLuecke =
+ new Gesetz(
+ "TestG",
+ "Gesetz zur Erprobung",
+ "Testgesetz",
+ List.of(
+ new Norm(
+ "§ 1",
+ "Zweck",
+ null,
+ List.of(
+ new Absatz(
+ "1",
+ "Die Erprobung umfasst\n"
+ + " 1. (weggefallen)\n"
+ + " 2. die Anwendung von Befehlen.")),
+ false)),
+ List.of());
+ var befehl =
+ new Umnummerierung(
+ stelle(
+ new Stelle.Paragraph("1"), new Stelle.AbsatzNr("1"), new Stelle.NummerNr("2")),
+ stelle(
+ new Stelle.Paragraph("1"), new Stelle.AbsatzNr("1"), new Stelle.NummerNr("1")),
+ PROV);
+
+ var ergebnis = BefehlAnwender.anwenden(mitLuecke, List.of(befehl));
+
+ assertThat(ergebnis.protokoll().get(0).status()).isEqualTo(Status.ANGEWANDT);
+ assertThat(absatzText(ergebnis.neu(), "§ 1", 0))
+ .isEqualTo("Die Erprobung umfasst\n 1. die Anwendung von Befehlen.");
+ }
+
+ /** Beginnt der Einschub mit einem Satzzeichen, entfällt das Leerzeichen hinter dem Anker. */
+ @Test
+ void fuegtSatzzeichenOhneVorangehendesLeerzeichenEin() {
+ var befehl =
+ new WoerterEinfuegung(
+ stelle(new Stelle.Paragraph("1"), new Stelle.AbsatzNr("1")),
+ new WortAnker.NachWoertern("die Erprobung"),
+ ", auch in Teilen,",
+ PROV);
+
+ var ergebnis = BefehlAnwender.anwenden(gesetz(), List.of(befehl));
+
+ assertThat(absatzText(ergebnis.neu(), "§ 1", 0))
+ .isEqualTo("Zweck dieses Gesetzes ist die Erprobung, auch in Teilen,.");
+ }
+
private static String absatzText(Gesetz gesetz, String enbez, int index) {
return gesetz.norm(enbez).orElseThrow().absaetze().get(index).text();
}
diff --git a/src/test/java/eu/mulk/aendggner/anwendung/SatzTeilerTest.java b/src/test/java/eu/mulk/aendggner/anwendung/SatzTeilerTest.java
index a97fc03..c0da1f3 100644
--- a/src/test/java/eu/mulk/aendggner/anwendung/SatzTeilerTest.java
+++ b/src/test/java/eu/mulk/aendggner/anwendung/SatzTeilerTest.java
@@ -74,4 +74,48 @@ class SatzTeilerTest {
assertThat(saetze.get(0)).contains("2. die Anwendung und");
assertThat(saetze.get(1)).isEqualTo("Weitere Einzelheiten regelt die Verordnung.");
}
+
+ @Test
+ void aufzaehlungsmarkeAmZeilenanfangBeendetKeinenSatz() {
+ // Anders als im Test darüber beginnen die Glieder großgeschrieben — erst die Marke am
+ // Zeilenanfang unterscheidet sie von einer Zahl am Satzende.
+ var text =
+ "Dem Rundfunkrat obliegen insbesondere folgende Aufgaben\n"
+ + "1. Erlaß von Satzungen,\n"
+ + "2. Wahl der Intendantin oder des Intendanten.\n"
+ + "Vor Beschlüssen ist Gelegenheit zur Stellungnahme zu geben.";
+ var saetze = SatzTeiler.teileTexte(text);
+
+ assertThat(saetze).hasSize(2);
+ assertThat(saetze.get(0)).contains("2. Wahl der Intendantin");
+ assertThat(saetze.get(1)).startsWith("Vor Beschlüssen");
+ }
+
+ @Test
+ void zahlAmSatzendeBeendetDenSatz() {
+ var saetze = SatzTeiler.teileTexte("Die Frist beträgt 30. Der Lauf beginnt am Folgetag.");
+
+ assertThat(saetze).containsExactly("Die Frist beträgt 30.", "Der Lauf beginnt am Folgetag.");
+ }
+
+ @Test
+ void fundstellenAbkuerzungTeiltDenSatzNicht() {
+ var saetze =
+ SatzTeiler.teileTexte(
+ "Es gilt das Gesetz vom 23. Juni 2021 (BGBl. I S. 1982) in der geltenden Fassung."
+ + " Näheres regelt die Satzung.");
+
+ assertThat(saetze).hasSize(2);
+ assertThat(saetze.get(0)).endsWith("in der geltenden Fassung.");
+ }
+
+ @Test
+ void paragraphenzeichenEroeffnetEinenSatz() {
+ var saetze =
+ SatzTeiler.teileTexte(
+ "Die Angebote können zusammengefasst werden. § 27 Absatz 2 bleibt unberührt.");
+
+ assertThat(saetze).containsExactly(
+ "Die Angebote können zusammengefasst werden.", "§ 27 Absatz 2 bleibt unberührt.");
+ }
}
diff --git a/src/test/java/eu/mulk/aendggner/gesetz/land/LandesRechtTextParserTest.java b/src/test/java/eu/mulk/aendggner/gesetz/land/LandesRechtTextParserTest.java
index b170c5f..77249f6 100644
--- a/src/test/java/eu/mulk/aendggner/gesetz/land/LandesRechtTextParserTest.java
+++ b/src/test/java/eu/mulk/aendggner/gesetz/land/LandesRechtTextParserTest.java
@@ -193,4 +193,65 @@ class LandesRechtTextParserTest {
assertThat(mitGedankenstrich.jurabk()).isEqualTo("GO");
assertThat(mitGedankenstrich.kurzue()).isEqualTo("Gemeindeordnung");
}
+
+ /**
+ * Die Inhaltsübersicht ist eine eigene Norm — nur unter diesem Namen finden sie die
+ * Angabe-Befehle. Sie führt auch die Gliederungs-Überschriften des Gesetzes mit; daran darf sie
+ * nicht zerfallen.
+ */
+ @Test
+ void liestInhaltsuebersichtAlsNorm() {
+ var gesetz =
+ LandesRechtTextParser.parse(
+ """
+ Landesmediengesetz Nordrhein-Westfalen
+ (LMG NRW)
+ Vom 2. Juli 2002
+ Inhaltsübersicht
+ Abschnitt 1 Allgemeine Vorschriften
+ § 1 | Geltungsbereich
+ Abschnitt 2 Zulassung
+ § 4 | Grundsätze
+ Abschnitt 1 Allgemeine Vorschriften
+ § 1 Geltungsbereich
+ (1) Dieses Gesetz gilt für die Veranstaltung von Rundfunk.
+ """);
+
+ var uebersicht = gesetz.norm("Inhaltsübersicht").orElseThrow();
+ assertThat(uebersicht.gesamtText())
+ .contains("Abschnitt 1 Allgemeine Vorschriften")
+ .contains("§ 1 | Geltungsbereich")
+ .contains("Abschnitt 2 Zulassung")
+ .contains("§ 4 | Grundsätze");
+ // Nur die Gliederung des Textteils zählt, nicht die zitierte in der Übersicht.
+ assertThat(gesetz.gliederungen()).hasSize(1);
+ assertThat(gesetz.norm("§ 1").orElseThrow().titel()).isEqualTo("Geltungsbereich");
+ }
+
+ /** Römische Gliederung ohne Schlüsselwort — sie ist nur an ihrer Stellung erkennbar. */
+ @Test
+ void erkenntRoemischeGliederungOhneSchluesselwort() {
+ var gesetz =
+ LandesRechtTextParser.parse(
+ """
+ Gesetz über den Westdeutschen Rundfunk Köln
+ (WDR-Gesetz)
+ Vom 25. April 1998
+ I. Rechtsform und Aufgaben
+ § 1 Name, Rechtsform
+ (1) Der WDR ist eine Anstalt des öffentlichen Rechts.
+ II. Organisation
+ 1. Der Rundfunkrat
+ § 15 Zusammensetzung
+ (1) Der Rundfunkrat besteht aus Mitgliedern.
+ """);
+
+ assertThat(gesetz.gliederungen())
+ .extracting(Gliederung::bezeichnung, Gliederung::titel)
+ .containsExactly(
+ org.assertj.core.groups.Tuple.tuple("I.", "Rechtsform und Aufgaben"),
+ org.assertj.core.groups.Tuple.tuple("II.", "Organisation"),
+ org.assertj.core.groups.Tuple.tuple("1.", "Der Rundfunkrat"));
+ assertThat(gesetz.norm("§ 15").orElseThrow().gliederung().titel()).isEqualTo("Der Rundfunkrat");
+ }
}