aboutsummaryrefslogtreecommitdiff
path: root/src/test/java
diff options
context:
space:
mode:
Diffstat (limited to 'src/test/java')
-rw-r--r--src/test/java/eu/mulk/aendggner/EndToEndTest.java265
-rw-r--r--src/test/java/eu/mulk/aendggner/aenderung/parse/BefehlErkennerTest.java71
-rw-r--r--src/test/java/eu/mulk/aendggner/aenderung/parse/StellenParserTest.java14
-rw-r--r--src/test/java/eu/mulk/aendggner/anwendung/BefehlAnwenderTest.java293
-rw-r--r--src/test/java/eu/mulk/aendggner/gesetz/land/LandesRechtTextAusgeberTest.java69
5 files changed, 627 insertions, 85 deletions
diff --git a/src/test/java/eu/mulk/aendggner/EndToEndTest.java b/src/test/java/eu/mulk/aendggner/EndToEndTest.java
index 78a45e9..8cd867e 100644
--- a/src/test/java/eu/mulk/aendggner/EndToEndTest.java
+++ b/src/test/java/eu/mulk/aendggner/EndToEndTest.java
@@ -6,8 +6,11 @@ import static org.assertj.core.api.Assertions.assertThat;
import static org.junit.jupiter.api.Assumptions.assumeTrue;
import eu.mulk.aendggner.aenderung.Aenderungsbefehl;
+import eu.mulk.aendggner.aenderung.Aenderungsbefehl.Ersetzung;
+import eu.mulk.aendggner.aenderung.Aenderungsbefehl.Sammelbefehl;
import eu.mulk.aendggner.aenderung.Aenderungsbefehl.StrukturEinfuegung;
import eu.mulk.aendggner.aenderung.Aenderungsbefehl.UnbekannterBefehl;
+import eu.mulk.aendggner.aenderung.Aenderungsbefehl.VerweisenderBefehl;
import eu.mulk.aendggner.aenderung.Aenderungsbefehl.WortAnker;
import eu.mulk.aendggner.aenderung.parse.AenderungsantragParser;
import eu.mulk.aendggner.aenderung.parse.AenderungsgesetzParser;
@@ -17,11 +20,14 @@ import eu.mulk.aendggner.aenderung.parse.SuperskriptModus;
import eu.mulk.aendggner.aenderung.parse.TextBereiniger;
import eu.mulk.aendggner.aenderung.parse.ZitatExtraktor;
import eu.mulk.aendggner.anwendung.BefehlAnwender;
+import eu.mulk.aendggner.anwendung.Grund;
+import eu.mulk.aendggner.anwendung.Nachfassungsabgleich;
import eu.mulk.aendggner.gesetz.Absatz;
import eu.mulk.aendggner.gesetz.Gesetz;
import eu.mulk.aendggner.gesetz.gii.GiiXmlLoader;
import eu.mulk.aendggner.synopse.HtmlRenderer;
import eu.mulk.aendggner.synopse.SynopseBuilder;
+import java.nio.charset.StandardCharsets;
import java.nio.file.Files;
import java.nio.file.Path;
import java.time.LocalDate;
@@ -259,16 +265,17 @@ class EndToEndTest {
* auch eine Aufhebung als Räumende zählt, läuft sie vor der Umnummerierung, und § 108 Absatz 1
* trägt danach die lückenlose Folge der Nummern 1 bis 32.
*
- * <ol>
- * <li>{@code 40. a) hh)} „Die bisherige Nummer 18 wird Nummer 29 und nach der Angabe ‚Absatz 1‘
- * werden die Wörter ‚oder Absatz 4‘ eingefügt.“ — Die Begleitklausel löst norm-weit auf
- * (bewusst so, siehe {@code BefehlErkenner}) und trifft dort 21 Vorkommen. Gemeint ist die
- * soeben umnummerierte Einheit.
- * <li>{@code 43. b) aa)} „In der Überschrift werden die Wörter … gestrichen.“ — Ziel ist die
- * Überschrift der <em>Nummer 1 der Anlage 8</em>. Das ist dieselbe offene Modellfrage wie
- * bei Berlins Anlage: Die Nummern einer Anlage brauchen einen eigenen Rang zwischen Norm
- * und Absatz.
- * </ol>
+ * <p>Auch {@code 43. b) aa)} ist keiner mehr („In der Überschrift werden die Wörter … in den
+ * Fällen des § 69 und § 71 Absatz 1 … gestrichen.“). Ziel ist die Überschrift der <em>Nummer 1
+ * der Anlage 8</em>, und es brauchte dafür keinen eigenen Rang zwischen Norm und Absatz: Im
+ * gii-XML ist die Überschrift einer Anlagen-Nummer die Kopfzeile ihres Aufzählungsblocks. Der
+ * Fehler saß darin, dass der Überschrift-Zweig die feinere Angabe schweigend verwarf und stets
+ * auf den Normtitel griff.
+ *
+ * <p>Ein Rest bleibt: {@code 40. a) hh)} „Die bisherige Nummer 18 wird Nummer 29 und nach der
+ * Angabe ‚Absatz 1‘ werden die Wörter ‚oder Absatz 4‘ eingefügt.“ — Die Begleitklausel löst
+ * norm-weit auf (bewusst so, siehe {@code BefehlErkenner}) und trifft dort 21 Vorkommen. Gemeint
+ * ist die soeben umnummerierte Einheit.
*/
@Test
void gegGegenZeitrichtigenStamm() throws Exception {
@@ -290,8 +297,8 @@ class EndToEndTest {
.filter(a -> a.status() == BefehlAnwender.Status.MANUELL_PRUEFEN)
.map(a -> a.befehl().provenienz().gliederungsPfad())
.toList();
- assertThat(manuellPfade).containsExactly("40. a) hh)", "43. b) aa)");
- assertThat(anwendung.anzahlAngewandt()).isEqualTo(117);
+ assertThat(manuellPfade).isEmpty();
+ assertThat(anwendung.anzahlAngewandt()).isEqualTo(119);
// Die Kaskade des § 108 Absatz 1 geht lückenlos auf: 32 Nummern, keine doppelt, keine fehlend.
var nummern =
@@ -309,6 +316,22 @@ class EndToEndTest {
assertThat(nummern).hasSize(32).endsWith("32");
var neu = anwendung.neu();
+ // Die Begleitklausel der Nummer 18/29 hat allein dort gegriffen — der Wortlaut gleicht der
+ // amtlichen Nachfassung (BJNR172810020.xml, § 108 Absatz 1 Nummer 29).
+ var paragraph108 = neu.norm("§ 108").orElseThrow().absaetze().get(0).text();
+ assertThat(paragraph108)
+ .contains("29. entgegen § 96 Absatz 1 oder Absatz 4 eine Bestätigung nicht")
+ .contains("30. entgegen § 96 Absatz 5 Satz 2 eine Abrechnung nicht");
+
+ // Die Überschrift der Nummer 1 der Anlage 8 ist geändert — und nur sie: Der Titel der Anlage
+ // trägt seinen eigenen Wortlaut unversehrt, den Punkt 43. a) ihm gegeben hat.
+ var anlage8 = neu.norm("Anlage 8").orElseThrow();
+ assertThat(anlage8.gesamtText())
+ .contains("1. Wärmedämmung von Wärmeverteilungs- und Warmwasserleitungen sowie Armaturen")
+ .doesNotContain("sowie Armaturen in den Fällen des § 69 und § 71 Absatz 1");
+ assertThat(anlage8.titel())
+ .contains("Anforderungen an die Wärmedämmung von Rohrleitungen und Armaturen");
+
// Der neue § 9a steht im Gesetz und in der Inhaltsübersicht.
assertThat(neu.norm("§ 9a")).isPresent();
assertThat(neu.norm("Inhaltsübersicht").orElseThrow().gesamtText())
@@ -737,13 +760,13 @@ class EndToEndTest {
assumeTrue(Files.exists(pfad), "Nachfassung " + nachfassung + " fehlt");
var soll = new eu.mulk.aendggner.gesetz.land.LandesRechtLoader().load(pfad);
assertThat(soll.normen()).hasSameSizeAs(alt.normen());
- for (var normSoll : soll.normen()) {
- var normIst = anwendung.neu().norm(normSoll.enbez());
- assertThat(normIst).as("Norm %s fehlt", normSoll.enbez()).isPresent();
- assertThat(normIst.orElseThrow().gesamtText().replaceAll("\\s+", " ").strip())
- .as("Norm %s", normSoll.enbez())
- .isEqualTo(normSoll.gesamtText().replaceAll("\\s+", " ").strip());
- }
+
+ // Geprüft wird mit demselben Abgleich, den auch --nachfassung fährt: Werkzeug und Test messen
+ // an einem Maßstab, sonst geht der eine durch, wo der andere anschlüge.
+ var abgleich = Nachfassungsabgleich.vergleiche(soll, anwendung.neu());
+ assertThat(abgleich.fehlende()).isEmpty();
+ assertThat(abgleich.abweichungen()).as("Abweichungen gegen die amtliche Nachfassung").isEmpty();
+ assertThat(abgleich.gehtAuf()).as(abgleich.kurzbericht()).isTrue();
}
/**
@@ -1055,17 +1078,10 @@ class EndToEndTest {
var sollAsog = SAMPLEDATA.resolve("Berlin/ASOG-Bln-neu.txt");
assumeTrue(Files.exists(sollAsog), "ASOG-Nachfassung fehlt");
var soll = new eu.mulk.aendggner.gesetz.land.LandesRechtLoader().load(sollAsog);
- var abweichend = new java.util.ArrayList<String>();
- for (var normSoll : soll.normen()) {
- var normIst = anwendung.neu().norm(normSoll.enbez()).orElseThrow();
- if (!normIst
- .gesamtText()
- .replaceAll("\\s+", " ")
- .strip()
- .equals(normSoll.gesamtText().replaceAll("\\s+", " ").strip())) {
- abweichend.add(normSoll.enbez());
- }
- }
+ var abweichend =
+ Nachfassungsabgleich.vergleiche(soll, anwendung.neu()).abweichungen().stream()
+ .map(Nachfassungsabgleich.Abweichung::enbez)
+ .toList();
// Keine Abweichung mehr: Alle 171 Normen gleichen der amtlichen Nachfassung.
//
// § 67 trug die beiden zuvor benannten Abweichungen: Das Portal setzt in der neuen Fassung
@@ -1148,17 +1164,10 @@ class EndToEndTest {
var soll =
new eu.mulk.aendggner.gesetz.land.LandesRechtLoader()
.load(SAMPLEDATA.resolve("BadenWuerttemberg/KomWO-BW-neu.txt"));
- var abweichend = new java.util.ArrayList<String>();
- for (var normSoll : soll.normen()) {
- var normIst = anwendung.neu().norm(normSoll.enbez()).orElseThrow();
- if (!normIst
- .gesamtText()
- .replaceAll("\\s+", " ")
- .strip()
- .equals(normSoll.gesamtText().replaceAll("\\s+", " ").strip())) {
- abweichend.add(normSoll.enbez());
- }
- }
+ var abweichend =
+ Nachfassungsabgleich.vergleiche(soll, anwendung.neu()).abweichungen().stream()
+ .map(Nachfassungsabgleich.Abweichung::enbez)
+ .toList();
// § 20: Der Befehl ersetzt das Wort „Name“ durch „der vollständige Familienname“; im Zieltext
// steht davor bereits „der“, sodass es doppelt erscheint. Die amtliche Nachfassung räumt das
// auf, der Befehlswortlaut tut es nicht — ÄndGgner wendet den Wortlaut an.
@@ -1353,13 +1362,10 @@ class EndToEndTest {
assumeTrue(Files.exists(soll), "Hessische Nachfassung fehlt");
var amtlich = new eu.mulk.aendggner.gesetz.land.LandesRechtLoader().load(soll);
assertThat(amtlich.normen()).hasSize(52);
- for (var normSoll : amtlich.normen()) {
- var normIst = anwendung.neu().norm(normSoll.enbez());
- assertThat(normIst).as("Norm %s fehlt", normSoll.enbez()).isPresent();
- assertThat(normIst.orElseThrow().gesamtText().replaceAll("\\s+", " ").strip())
- .as("Norm %s", normSoll.enbez())
- .isEqualTo(normSoll.gesamtText().replaceAll("\\s+", " ").strip());
- }
+ var abgleich = Nachfassungsabgleich.vergleiche(amtlich, anwendung.neu());
+ assertThat(abgleich.fehlende()).isEmpty();
+ assertThat(abgleich.abweichungen()).isEmpty();
+ assertThat(abgleich.gleich()).isEqualTo(52);
}
/**
@@ -1527,8 +1533,8 @@ class EndToEndTest {
Files.exists(alt) && Files.exists(entwurfPdf) && Files.exists(antragPdf),
"BayJG-Beispieldaten fehlen");
- var ohne = Pipeline.erzeugeSynopse(alt, List.of(entwurfPdf), null, false);
- var mit = Pipeline.erzeugeSynopse(alt, List.of(entwurfPdf, antragPdf), null, false);
+ var ohne = Pipeline.erzeugeSynopse(Pipeline.Auftrag.von(alt, List.of(entwurfPdf)));
+ var mit = Pipeline.erzeugeSynopse(Pipeline.Auftrag.von(alt, List.of(entwurfPdf, antragPdf)));
assertThat(mit.anzahlAngewandt()).isEqualTo(ohne.anzahlAngewandt()).isEqualTo(154);
assertThat(mit.anzahlManuell()).isEqualTo(ohne.anzahlManuell()).isEqualTo(0);
@@ -1607,8 +1613,8 @@ class EndToEndTest {
Files.exists(xml) && Files.exists(empfehlung) && Files.exists(entwurf),
"GEG-Beispieldaten fehlen");
- var ausEmpfehlung = Pipeline.erzeugeSynopse(xml, List.of(empfehlung), null, false);
- var ausEntwurf = Pipeline.erzeugeSynopse(xml, List.of(entwurf), null, false);
+ var ausEmpfehlung = Pipeline.erzeugeSynopse(Pipeline.Auftrag.von(xml, List.of(empfehlung)));
+ var ausEntwurf = Pipeline.erzeugeSynopse(Pipeline.Auftrag.von(xml, List.of(entwurf)));
// 69 statt der früheren 68: Nennt der Rahmen dieselbe Gliederungseinheit wie der Befehl („…
// Teil 2 wird wie folgt geändert: … die Angabe zur Überschrift von Teil 2 Abschnitt 4 …“),
@@ -1635,7 +1641,7 @@ class EndToEndTest {
var pdf = SAMPLEDATA.resolve("IfSG/1924334.pdf");
assumeTrue(Files.exists(xml) && Files.exists(pdf), "IfSG-Beispieldaten fehlen");
- var ergebnis = Pipeline.erzeugeSynopse(xml, List.of(pdf), null, false);
+ var ergebnis = Pipeline.erzeugeSynopse(Pipeline.Auftrag.von(xml, List.of(pdf)));
// 51 statt der früheren 47: Die Satzzählung folgt jetzt der amtlichen — eine eingerückte
// Aufzählungsmarke beendet keinen Satz, und eine Ordnungszahl vor einem Gliederungswort („nach
@@ -1655,7 +1661,7 @@ class EndToEndTest {
var pdf = SAMPLEDATA.resolve("GEG/BT-Drs-21-7071_Beschlussempfehlung.pdf");
assumeTrue(Files.exists(xml) && Files.exists(pdf), "GEG-Beispieldaten fehlen");
- var ergebnis = Pipeline.erzeugeSynopse(xml, List.of(pdf), null, false);
+ var ergebnis = Pipeline.erzeugeSynopse(Pipeline.Auftrag.von(xml, List.of(pdf)));
assertThat(ergebnis.anzahlAngewandt()).isZero();
assertThat(ergebnis.html()).contains("keine Änderungsbefehle");
@@ -1682,7 +1688,7 @@ class EndToEndTest {
assumeTrue(Files.exists(xml) && Files.exists(pdf), "UWG-Beispieldaten fehlen");
// Ohne Stichtag bleibt es beim vollen Bestand — aber nicht stillschweigend.
- var ohne = Pipeline.erzeugeSynopse(xml, List.of(pdf), null, false);
+ var ohne = Pipeline.erzeugeSynopse(Pipeline.Auftrag.von(xml, List.of(pdf)));
assertThat(ohne.anzahlAngewandt()).isEqualTo(19);
assertThat(ohne.html())
.contains("Das Änderungsgesetz tritt gestaffelt in Kraft")
@@ -1690,7 +1696,9 @@ class EndToEndTest {
.contains("<dt>Inkrafttreten</dt><dd>27. September 2026 (gestaffelt, siehe unten)</dd>");
// Am 19. Juni 2026 galt genau ein Befehl: der Buchstabe c der Nummer 2.
- var frueh = Pipeline.erzeugeSynopse(xml, List.of(pdf), null, false, LocalDate.of(2026, 6, 19));
+ var frueh =
+ Pipeline.erzeugeSynopse(
+ Pipeline.Auftrag.von(xml, List.of(pdf)).mitStichtag(LocalDate.of(2026, 6, 19)));
assertThat(frueh.anzahlAngewandt()).isEqualTo(1);
assertThat(frueh.anzahlManuell()).isZero();
assertThat(frueh.html())
@@ -1698,12 +1706,16 @@ class EndToEndTest {
.contains("Am Stichtag noch nicht in Kraft")
.contains("Tritt erst am 27. September 2026 in Kraft");
// Der Tag davor ändert noch gar nichts.
- var davor = Pipeline.erzeugeSynopse(xml, List.of(pdf), null, false, LocalDate.of(2026, 6, 18));
+ var davor =
+ Pipeline.erzeugeSynopse(
+ Pipeline.Auftrag.von(xml, List.of(pdf)).mitStichtag(LocalDate.of(2026, 6, 18)));
assertThat(davor.anzahlAngewandt()).isZero();
// Am 27. September 2026 ist das Gesetz vollständig in Kraft; dann deckt sich die Fassung mit
// der ungefilterten, und der Abschnitt „noch nicht in Kraft“ entfällt.
- var spaet = Pipeline.erzeugeSynopse(xml, List.of(pdf), null, false, LocalDate.of(2026, 9, 27));
+ var spaet =
+ Pipeline.erzeugeSynopse(
+ Pipeline.Auftrag.von(xml, List.of(pdf)).mitStichtag(LocalDate.of(2026, 9, 27)));
assertThat(spaet.anzahlAngewandt()).isEqualTo(19);
assertThat(spaet.html()).doesNotContain("Am Stichtag noch nicht in Kraft");
}
@@ -1711,8 +1723,8 @@ class EndToEndTest {
/**
* Dasselbe am großen Fall, und zugleich die Probe auf die Schritt-Ordnung: Im GEG bleibt am 1.
* Januar 2024 allein der Befehl der Nummer 22 zurück. Er steht in keiner Umnummerierungs-Kaskade,
- * sodass die übrigen 115 unverändert durchlaufen — die drei liegengebliebenen Befehle sind
- * dieselben wie ohne Stichtag und werden von der Auswahl nicht vermehrt.
+ * sodass die übrigen 116 unverändert durchlaufen — die liegengebliebenen Befehle sind dieselben
+ * wie ohne Stichtag und werden von der Auswahl nicht vermehrt.
*/
@Test
void gegStichtagLaesstDieUebrigenBefehleUnberuehrt() throws Exception {
@@ -1720,21 +1732,23 @@ class EndToEndTest {
var pdf = SAMPLEDATA.resolve("GEG/bgbl123s0280_regelungstext.pdf");
assumeTrue(Files.exists(xml) && Files.exists(pdf), "GEG-Beispieldaten fehlen");
- var vollstaendig = Pipeline.erzeugeSynopse(xml, List.of(pdf), null, false);
- assertThat(vollstaendig.anzahlAngewandt()).isEqualTo(117);
- assertThat(vollstaendig.anzahlManuell()).isEqualTo(2);
+ var vollstaendig = Pipeline.erzeugeSynopse(Pipeline.Auftrag.von(xml, List.of(pdf)));
+ assertThat(vollstaendig.anzahlAngewandt()).isEqualTo(119);
+ assertThat(vollstaendig.anzahlManuell()).isZero();
var anfang2024 =
- Pipeline.erzeugeSynopse(xml, List.of(pdf), null, false, LocalDate.of(2024, 1, 1));
- assertThat(anfang2024.anzahlAngewandt()).isEqualTo(116);
- assertThat(anfang2024.anzahlManuell()).isEqualTo(2);
+ Pipeline.erzeugeSynopse(
+ Pipeline.Auftrag.von(xml, List.of(pdf)).mitStichtag(LocalDate.of(2024, 1, 1)));
+ assertThat(anfang2024.anzahlAngewandt()).isEqualTo(118);
+ assertThat(anfang2024.anzahlManuell()).isZero();
assertThat(anfang2024.html())
.contains("Am Stichtag noch nicht in Kraft")
.contains("Tritt erst am 1. Oktober 2024 in Kraft");
var oktober2024 =
- Pipeline.erzeugeSynopse(xml, List.of(pdf), null, false, LocalDate.of(2024, 10, 1));
- assertThat(oktober2024.anzahlAngewandt()).isEqualTo(117);
+ Pipeline.erzeugeSynopse(
+ Pipeline.Auftrag.von(xml, List.of(pdf)).mitStichtag(LocalDate.of(2024, 10, 1)));
+ assertThat(oktober2024.anzahlAngewandt()).isEqualTo(119);
}
/**
@@ -1753,7 +1767,8 @@ class EndToEndTest {
assumeTrue(Files.exists(xml) && Files.exists(pdf), "IfSG-Beispieldaten fehlen");
var novemberFassung =
- Pipeline.erzeugeSynopse(xml, List.of(pdf), null, false, LocalDate.of(2020, 11, 19));
+ Pipeline.erzeugeSynopse(
+ Pipeline.Auftrag.von(xml, List.of(pdf)).mitStichtag(LocalDate.of(2020, 11, 19)));
assertThat(novemberFassung.anzahlAngewandt()).isEqualTo(65);
assertThat(novemberFassung.html())
.contains("Am Stichtag noch nicht in Kraft")
@@ -1761,7 +1776,8 @@ class EndToEndTest {
// Am 1. April 2021 kommen die zehn Befehle des Artikels 2 und der Doppelbuchstabe hinzu.
var aprilFassung =
- Pipeline.erzeugeSynopse(xml, List.of(pdf), null, false, LocalDate.of(2021, 4, 1));
+ Pipeline.erzeugeSynopse(
+ Pipeline.Auftrag.von(xml, List.of(pdf)).mitStichtag(LocalDate.of(2021, 4, 1)));
assertThat(aprilFassung.anzahlAngewandt()).isEqualTo(75);
assertThat(aprilFassung.html()).doesNotContain("Am Stichtag noch nicht in Kraft");
}
@@ -1812,7 +1828,7 @@ class EndToEndTest {
assertThat(grundregel.wortlaut())
.isEqualTo("Dieses Gesetz tritt am Tag nach der Verkündung in Kraft.");
- var synopse = Pipeline.erzeugeSynopse(alt, List.of(pdf), null, false);
+ var synopse = Pipeline.erzeugeSynopse(Pipeline.Auftrag.von(alt, List.of(pdf)));
assertThat(synopse.anzahlAngewandt()).isEqualTo(4);
assertThat(synopse.html()).doesNotContain("tritt gestaffelt in Kraft");
}
@@ -1866,19 +1882,98 @@ class EndToEndTest {
.filter(b -> b.provenienz().originalText().contains("die Worte"))
.toList();
assertThat(wortbefehle).hasSizeGreaterThanOrEqualTo(7);
- assertThat(wortbefehle).filteredOn(b -> b instanceof UnbekannterBefehl).hasSize(1);
-
- // Drei benannte Idiom-Grenzen, alle im Wortlaut festgehalten:
- // 5. a) aa) — Satzzeichen-Ersetzung im Verbund mit einer Halbsatz-Anfügung („wird der Punkt
- // durch einen Strichpunkt ersetzt und folgender Halbsatz angefügt“).
- // 7. a) aa) — „In der Einleitung“ als Ziel, also der Chapeau eines Satzes mit Nummern.
- // 12. — die Verweisung auf einen anderen Punkt desselben Artikels („Die Inhaltsübersicht wird
- // entsprechend der vorstehenden Nummer 8 Buchst. a geändert“).
- var unerkannt =
- parseErgebnis.befehle().stream()
- .filter(b -> b instanceof UnbekannterBefehl)
- .map(b -> b.provenienz().gliederungsPfad())
- .toList();
- assertThat(unerkannt).containsExactly("5. a) aa)", "7. a) aa)", "12.");
+ assertThat(wortbefehle).noneMatch(b -> b instanceof UnbekannterBefehl);
+
+ // Der Strichpunkt-Verbund („wird der Punkt durch einen Strichpunkt ersetzt und folgender
+ // Halbsatz angefügt“) und der bezugspunktlose Chapeau („In der Einleitung“) werden gelesen;
+ // dass beide gleichwohl nicht greifen, liegt an der Quelle und nicht am Werkzeug: Der Stamm
+ // ist hier die Nachfassung, ihr Wortlaut trägt die Änderung bereits.
+ assertThat(befehlAn(parseErgebnis, "5. a) aa)")).isInstanceOf(Sammelbefehl.class);
+ assertThat(befehlAn(parseErgebnis, "7. a) aa)")).isInstanceOf(Ersetzung.class);
+
+ // Kein Befehl des Heftes bleibt mehr ungelesen.
+ assertThat(parseErgebnis.befehle()).noneMatch(b -> b instanceof UnbekannterBefehl);
+
+ // Die Verweisung auf einen anderen Punkt desselben Artikels wird gelesen, aber bewusst nicht
+ // ausgeführt. Der Unterschied ist kein kosmetischer: Die Synopse sagt fortan, dass das
+ // Erzeugnis hier die Grenze zieht, und nicht, dass die Vorlage unverständlich sei.
+ var verweisung = befehlAn(parseErgebnis, "12.");
+ assertThat(verweisung).isInstanceOf(VerweisenderBefehl.class);
+ assertThat(((VerweisenderBefehl) verweisung).verweis()).isEqualTo("Nummer 8 Buchst. a");
+
+ var anwendung = BefehlAnwender.anwenden(gesetz, List.of(verweisung));
+ assertThat(anwendung.protokoll().get(0).grund()).isEqualTo(Grund.NICHT_UNTERSTUETZT);
+ assertThat(anwendung.protokoll().get(0).begruendung())
+ .contains("verweist auf „Nummer 8 Buchst. a“");
+ }
+
+ private static Aenderungsbefehl befehlAn(
+ AenderungsgesetzParser.ParseErgebnis ergebnis, String gliederungsPfad) {
+ return ergebnis.befehle().stream()
+ .filter(b -> gliederungsPfad.equals(b.provenienz().gliederungsPfad()))
+ .findFirst()
+ .orElseThrow();
+ }
+
+ /**
+ * Der Abgleich mit der amtlichen Nachfassung ist fortan eine Leistung des Erzeugnisses und nicht
+ * bloß eine des Testcodes: {@code --nachfassung} nimmt dieselben Eingaben an wie das Stammgesetz
+ * und stellt das Ergebnis normweise dagegen. Berlin, Artikel 1: 171 von 171.
+ */
+ @Test
+ void nachfassungWirdNormweiseAbgeglichen() throws Exception {
+ var alt = SAMPLEDATA.resolve("Berlin/ASOG-Bln-alt.txt");
+ var neu = SAMPLEDATA.resolve("Berlin/ASOG-Bln-neu.txt");
+ var pdf = SAMPLEDATA.resolve("Berlin/GVBl-2026-17_ASOG-LAF-AendG.pdf");
+ assumeTrue(
+ Files.exists(alt) && Files.exists(neu) && Files.exists(pdf), "ASOG-Beispieldaten fehlen");
+
+ var ergebnis =
+ Pipeline.erzeugeSynopse(
+ Pipeline.Auftrag.von(alt, List.of(pdf))
+ .mitArtikel("1")
+ .mitNachfassung(Quelle.lies(neu)));
+
+ var abgleich = ergebnis.abgleich();
+ assertThat(abgleich).isNotNull();
+ assertThat(abgleich.gehtAuf()).as(abgleich.kurzbericht()).isTrue();
+ assertThat(abgleich.gleich()).isEqualTo(171);
+ assertThat(abgleich.geprueft()).isEqualTo(171);
+ // Und er steht in der Synopse, wo ihn auch findet, wer keine Befehlszeile liest.
+ assertThat(ergebnis.html())
+ .contains("Abgleich mit der amtlichen Nachfassung")
+ .contains("171 von 171 Normen gleich");
+ }
+
+ /**
+ * Die Kette steht und fällt damit, dass die ausgegebene Fassung wieder eingelesen dasselbe Gesetz
+ * ergibt. Geprüft wird das hier am <em>Bundesrecht</em> — der Rundlauf-Test des Textausgebers
+ * deckt nur die Klartext-Stammfassungen ab, und gerade der Weg vom gii-XML in den kanonischen
+ * Klartext ist der, den die Kette braucht: Wer ein zweites Heft auf das Ergebnis des ersten
+ * anwenden will, hat kein XML mehr, sondern nur diesen Text.
+ */
+ @Test
+ void dieAusgegebeneFassungLaesstSichWiederEinlesen() 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 erst = Pipeline.erzeugeSynopse(Pipeline.Auftrag.von(xml, List.of(pdf)));
+ assertThat(erst.anzahlAngewandt()).isEqualTo(19);
+
+ // Der zweite Lauf hält dasselbe Ergebnis gegen den Text, den der erste geschrieben hat. Geht
+ // der Abgleich auf, so trägt der Text die Fassung vollständig — und damit die Kette.
+ var zweit =
+ Pipeline.erzeugeSynopse(
+ Pipeline.Auftrag.von(xml, List.of(pdf))
+ .mitNachfassung(
+ new Quelle("UWG-neu.txt", erst.neufassung().getBytes(StandardCharsets.UTF_8))));
+
+ var abgleich = zweit.abgleich();
+ assertThat(abgleich).isNotNull();
+ assertThat(abgleich.fehlende()).isEmpty();
+ assertThat(abgleich.ueberzaehlige()).isEmpty();
+ assertThat(abgleich.abweichungen()).isEmpty();
+ assertThat(abgleich.gehtAuf()).as(abgleich.kurzbericht()).isTrue();
}
}
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 7742a3a..327c8db 100644
--- a/src/test/java/eu/mulk/aendggner/aenderung/parse/BefehlErkennerTest.java
+++ b/src/test/java/eu/mulk/aendggner/aenderung/parse/BefehlErkennerTest.java
@@ -15,6 +15,7 @@ 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.VerweisenderBefehl;
import eu.mulk.aendggner.aenderung.Aenderungsbefehl.WoerterEinfuegung;
import eu.mulk.aendggner.aenderung.Aenderungsbefehl.WortAnker;
import eu.mulk.aendggner.aenderung.Aenderungsbefehl.WortlautZuAbsatz;
@@ -1473,4 +1474,74 @@ class BefehlErkennerTest {
assertThat(BefehlErkenner.erkenne(text, Stelle.LEER, zitate, PROV))
.containsInstanceOf(Ersetzung.class);
}
+
+ /**
+ * Rheinland-Pfalz: „In Satz 2 wird der Punkt durch einen Strichpunkt ersetzt und folgender
+ * Halbsatz angefügt: „…““. Zwei Neuerungen in einem Satz — der Strichpunkt als deutsche Nebenform
+ * des Semikolons und der Halbsatz als Ebene der Anfügung. Die Verbundmechanik selbst bestand
+ * bereits.
+ */
+ @Test
+ void erkenntStrichpunktErsetzungMitHalbsatzAnfuegung() {
+ var befehl =
+ erkenne(
+ "In Satz 2 wird der Punkt durch einen Strichpunkt ersetzt und folgender Halbsatz"
+ + " angefügt: „die Bearbeitungszeit der Abschlussarbeit zählt zu den"
+ + " Fachstudien.“",
+ new Stelle(List.of(new Stelle.Paragraph("8"), new Stelle.AbsatzNr("1"))));
+
+ assertThat(befehl).containsInstanceOf(Sammelbefehl.class);
+ var teile = ((Sammelbefehl) befehl.orElseThrow()).teilbefehle();
+ assertThat(teile).hasSize(2);
+
+ assertThat(teile.get(0)).isInstanceOf(Ersetzung.class);
+ var ersetzung = (Ersetzung) teile.get(0);
+ assertThat(ersetzung.alt()).isEqualTo(".");
+ assertThat(ersetzung.neu()).isEqualTo(";");
+ assertThat(ersetzung.stelle().anzeigeText()).isEqualTo("§ 8 Absatz 1 Satz 2");
+
+ assertThat(teile.get(1)).isInstanceOf(Anfuegung.class);
+ var anfuegung = (Anfuegung) teile.get(1);
+ assertThat(anfuegung.ebene()).isEqualTo(Ebene.HALBSATZ);
+ assertThat(anfuegung.text()).contains("die Bearbeitungszeit der Abschlussarbeit");
+ }
+
+ /** Der Strichpunkt gilt auch als Subjekt („Der Strichpunkt wird durch ein Komma ersetzt“). */
+ @Test
+ void erkenntDenStrichpunktAlsSubjekt() {
+ var befehl = erkenne("Der Strichpunkt am Ende wird durch ein Komma ersetzt.", PARAGRAPH_5);
+ assertThat(befehl).containsInstanceOf(Ersetzung.class);
+ var ersetzung = (Ersetzung) befehl.orElseThrow();
+ assertThat(ersetzung.alt()).isEqualTo(";");
+ assertThat(ersetzung.neu()).isEqualTo(",");
+ }
+
+ /**
+ * Die Verweisung auf einen anderen Punkt desselben Artikels wird gelesen, aber nicht ausgeführt.
+ * Erkannt zu werden ist gleichwohl mehr als gar nichts: Die Rüge sagt dann, dass das Erzeugnis
+ * die Grenze zieht, und nicht, dass die Vorlage unverständlich sei.
+ */
+ @Test
+ void erkenntDieVerweisungAufEinenAnderenPunkt() {
+ var befehl =
+ erkenne(
+ "Die Inhaltsübersicht wird entsprechend der vorstehenden Nummer 8 Buchst. a geändert.",
+ Stelle.LEER);
+
+ assertThat(befehl).containsInstanceOf(VerweisenderBefehl.class);
+ var verweisung = (VerweisenderBefehl) befehl.orElseThrow();
+ assertThat(verweisung.verweis()).isEqualTo("Nummer 8 Buchst. a");
+ assertThat(verweisung.stelle().betrifftInhaltsuebersicht()).isTrue();
+ }
+
+ /** Ein bloß adverbiales „entsprechend“ wird davon nicht mitgerissen. */
+ @Test
+ void adverbialesEntsprechendIstKeineVerweisung() {
+ var befehl =
+ erkenne(
+ "In § 5 werden die Wörter „entsprechend der Anlage“ durch die Wörter „nach der"
+ + " Anlage“ ersetzt.",
+ Stelle.LEER);
+ assertThat(befehl).containsInstanceOf(Ersetzung.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 ee7e5c7..13e4442 100644
--- a/src/test/java/eu/mulk/aendggner/aenderung/parse/StellenParserTest.java
+++ b/src/test/java/eu/mulk/aendggner/aenderung/parse/StellenParserTest.java
@@ -138,4 +138,18 @@ class StellenParserTest {
StellenParser.parse("Absatz 1 in der Angabe vor Nummer 1").orElseThrow().anzeigeText())
.isEqualTo("Absatz 1");
}
+
+ /**
+ * „In der Einleitung“ (Rheinland-Pfalz) ist ein Chapeau-Lokator wie „im Satzteil vor Nummer 1“:
+ * Er nennt keinen Bezugspunkt und trägt deshalb keine eigene Komponente — die Operation läuft auf
+ * der Stelle, die der Rahmen bezeichnet.
+ */
+ @Test
+ void einleitungIstEinChapeauLokator() {
+ assertThat(StellenParser.istNurChapeau("In der Einleitung")).isTrue();
+ assertThat(StellenParser.istNurChapeau("Im Eingangssatz")).isTrue();
+ assertThat(StellenParser.istNurChapeau("in dem Einleitungssatz")).isTrue();
+ // Ein Wort, das bloß so anfängt, ist keiner.
+ assertThat(StellenParser.istNurChapeau("In der Einleitungsformel")).isFalse();
+ }
}
diff --git a/src/test/java/eu/mulk/aendggner/anwendung/BefehlAnwenderTest.java b/src/test/java/eu/mulk/aendggner/anwendung/BefehlAnwenderTest.java
index 629ec49..39c71cc 100644
--- a/src/test/java/eu/mulk/aendggner/anwendung/BefehlAnwenderTest.java
+++ b/src/test/java/eu/mulk/aendggner/anwendung/BefehlAnwenderTest.java
@@ -4,6 +4,7 @@ package eu.mulk.aendggner.anwendung;
import static org.assertj.core.api.Assertions.assertThat;
+import eu.mulk.aendggner.aenderung.Aenderungsbefehl;
import eu.mulk.aendggner.aenderung.Aenderungsbefehl.Anfuegung;
import eu.mulk.aendggner.aenderung.Aenderungsbefehl.Aufhebung;
import eu.mulk.aendggner.aenderung.Aenderungsbefehl.BereichsUmnummerierung;
@@ -1666,4 +1667,296 @@ class BefehlAnwenderTest {
assertThat(ergebnis.anzahlManuell()).isEqualTo(1);
assertThat(ergebnis.protokoll().get(0).begruendung()).contains("nicht so viele Einheiten");
}
+
+ /**
+ * „In der Überschrift der Nummer 1 …“ meint die Kopfzeile jenes Aufzählungsblocks, nicht den
+ * Titel der Norm. Zuvor verwarf der Überschrift-Zweig die Nummer schweigend und griff auf den
+ * Normtitel — stand der Zieltext zufällig dort, so änderte er die falsche Einheit.
+ */
+ @Test
+ void ueberschriftEinerNummerIstDieKopfzeileIhresBlocks() {
+ var anlage =
+ new Norm(
+ "Anlage 8",
+ "Anforderungen an die Wärmedämmung in den Fällen des § 69",
+ null,
+ List.of(
+ new Absatz(
+ null,
+ "1. Wärmedämmung von Leitungen in den Fällen des § 69\n"
+ + " a) Leitungen sind in den Fällen des § 69 zu dämmen.\n"
+ + "2. Sonstiges\n"
+ + " a) Im Übrigen gilt nichts.")),
+ false);
+ var gesetz = new Gesetz("TestG", "Testgesetz", "TestG", List.of(anlage));
+
+ var befehl =
+ new Streichung(
+ stelle(
+ new Stelle.Gliederungseinheit("Anlage", "8"),
+ new Stelle.NummerNr("1"),
+ new Stelle.Ueberschrift()),
+ " in den Fällen des § 69",
+ PROV);
+
+ var ergebnis = BefehlAnwender.anwenden(gesetz, List.of(befehl));
+ assertThat(ergebnis.protokoll()).allMatch(a -> a.status() == Status.ANGEWANDT);
+
+ var text = ergebnis.neu().norm("Anlage 8").orElseThrow();
+ // Die Kopfzeile ist gestutzt …
+ assertThat(text.absaetze().get(0).text()).startsWith("1. Wärmedämmung von Leitungen\n");
+ // … der Buchstabe a) darunter und der Titel der Anlage dagegen unberührt: Beide führen
+ // denselben Wortlaut, und eine norm-weite Streichung hätte sie mitgenommen.
+ assertThat(text.absaetze().get(0).text())
+ .contains("a) Leitungen sind in den Fällen des § 69 zu dämmen.");
+ assertThat(text.titel()).isEqualTo("Anforderungen an die Wärmedämmung in den Fällen des § 69");
+ }
+
+ /**
+ * Ein Aufzählungsglied aus einer einzigen Zeile hat keine Überschrift, sondern nur Text. Dort
+ * wird nichts angewandt, sondern gerügt: Andernfalls träfe die Operation den ganzen Wortlaut und
+ * gäbe vor, eine Überschrift geändert zu haben.
+ */
+ @Test
+ void einzeiligesAufzaehlungsgliedTraegtKeineUeberschrift() {
+ var anlage =
+ new Norm(
+ "Anlage 8",
+ "Anforderungen",
+ null,
+ List.of(new Absatz(null, "1. Kurz und bündig\n2. Ebenso kurz")),
+ false);
+ var gesetz = new Gesetz("TestG", "Testgesetz", "TestG", List.of(anlage));
+
+ var befehl =
+ new Streichung(
+ stelle(
+ new Stelle.Gliederungseinheit("Anlage", "8"),
+ new Stelle.NummerNr("1"),
+ new Stelle.Ueberschrift()),
+ " und bündig",
+ PROV);
+
+ var ergebnis = BefehlAnwender.anwenden(gesetz, List.of(befehl));
+ assertThat(ergebnis.protokoll()).hasSize(1);
+ assertThat(ergebnis.protokoll().get(0).status()).isEqualTo(Status.MANUELL_PRUEFEN);
+ assertThat(ergebnis.protokoll().get(0).grund()).isEqualTo(Grund.STELLE_NICHT_AUFLOESBAR);
+ assertThat(ergebnis.protokoll().get(0).begruendung())
+ .contains("trägt keine eigene Überschrift");
+ assertThat(ergebnis.neu().norm("Anlage 8").orElseThrow().absaetze().get(0).text())
+ .isEqualTo("1. Kurz und bündig\n2. Ebenso kurz");
+ }
+
+ /**
+ * Die Neufassung der Überschrift einer Aufzählungseinheit bleibt eine bewusst gezogene Grenze:
+ * Deren Kopfzeile trägt zugleich die Aufzählungsmarke, die eine Neufassung nicht mitliefert.
+ * Gerügt wird sie deshalb — nicht auf den Normtitel umgebogen.
+ */
+ @Test
+ void neufassungDerUeberschriftEinerNummerWirdGeruegt() {
+ var anlage =
+ new Norm(
+ "Anlage 8",
+ "Anforderungen",
+ null,
+ List.of(new Absatz(null, "1. Wärmedämmung\n a) Näheres.")),
+ false);
+
+ var gesetz = new Gesetz("TestG", "Testgesetz", "TestG", List.of(anlage));
+
+ var befehl =
+ new Neufassung(
+ stelle(
+ new Stelle.Gliederungseinheit("Anlage", "8"),
+ new Stelle.NummerNr("1"),
+ new Stelle.Ueberschrift()),
+ "Wärmedämmung und Kältedämmung",
+ PROV);
+
+ var ergebnis = BefehlAnwender.anwenden(gesetz, List.of(befehl));
+ assertThat(ergebnis.protokoll().get(0).status()).isEqualTo(Status.MANUELL_PRUEFEN);
+ assertThat(ergebnis.protokoll().get(0).grund()).isEqualTo(Grund.NICHT_UNTERSTUETZT);
+ assertThat(ergebnis.neu().norm("Anlage 8").orElseThrow().titel()).isEqualTo("Anforderungen");
+ }
+
+ private static Gesetz bussgeldkatalog() {
+ return new Gesetz(
+ "TestG",
+ "Testgesetz",
+ "TestG",
+ List.of(
+ new Norm(
+ "§ 108",
+ "Bußgeldvorschriften",
+ null,
+ List.of(
+ new Absatz(
+ "1",
+ "17. entgegen § 90 Absatz 1 etwas tut,\n"
+ + "18. entgegen § 96 Absatz 1 etwas unterlässt,\n"
+ + "19. entgegen § 97 Absatz 1 etwas anderes tut,")),
+ false)));
+ }
+
+ private static Sammelbefehl verbund(Aenderungsbefehl... teile) {
+ return new Sammelbefehl(List.of(teile));
+ }
+
+ /**
+ * „Die bisherige Nummer 18 wird Nummer 29 und nach der Angabe ‚Absatz 1‘ werden die Wörter ‚oder
+ * Absatz 4‘ eingefügt.“ — Der Anker steht in jeder Nummer; norm-weit ist er mehrdeutig. Erst dann
+ * entscheidet die soeben umnummerierte Einheit.
+ */
+ @Test
+ void mehrdeutigeBegleitklauselFaelltAufDieUmnummerierteEinheitZurueck() {
+ var rahmen = stelle(new Stelle.Paragraph("108"), new Stelle.AbsatzNr("1"));
+ var befehl =
+ verbund(
+ new Umnummerierung(
+ rahmen.plus(stelle(new Stelle.NummerNr("18"))),
+ stelle(new Stelle.NummerNr("29")),
+ PROV),
+ new WoerterEinfuegung(
+ rahmen, new WortAnker.NachWoertern("Absatz 1"), "oder Absatz 4", PROV));
+
+ var ergebnis = BefehlAnwender.anwenden(bussgeldkatalog(), List.of(befehl));
+ assertThat(ergebnis.protokoll()).allMatch(a -> a.status() == Status.ANGEWANDT);
+
+ var text = ergebnis.neu().norm("§ 108").orElseThrow().absaetze().get(0).text();
+ // Allein die umnummerierte Nummer hat die Wörter bekommen.
+ assertThat(text).contains("29. entgegen § 96 Absatz 1 oder Absatz 4 etwas unterlässt,");
+ assertThat(text).contains("17. entgegen § 90 Absatz 1 etwas tut,");
+ assertThat(text).contains("19. entgegen § 97 Absatz 1 etwas anderes tut,");
+ }
+
+ /**
+ * Der Rückfall greift nur bei Mehrdeutigkeit, nie bei fehlendem Zieltext: Wer seinen Anker
+ * nirgends findet, meint etwas anderes — ihn in die umnummerierte Einheit zu zwingen wäre eine
+ * Falschanwendung, keine Verfeinerung.
+ */
+ @Test
+ void fehlenderZieltextFaelltNichtZurueck() {
+ var rahmen = stelle(new Stelle.Paragraph("108"), new Stelle.AbsatzNr("1"));
+ var befehl =
+ verbund(
+ new Umnummerierung(
+ rahmen.plus(stelle(new Stelle.NummerNr("18"))),
+ stelle(new Stelle.NummerNr("29")),
+ PROV),
+ new WoerterEinfuegung(
+ rahmen, new WortAnker.NachWoertern("Paragraph 1"), "oder Absatz 4", PROV));
+
+ var ergebnis = BefehlAnwender.anwenden(bussgeldkatalog(), List.of(befehl));
+ assertThat(ergebnis.protokoll().get(0).status()).isEqualTo(Status.MANUELL_PRUEFEN);
+ assertThat(ergebnis.neu().norm("§ 108").orElseThrow().absaetze().get(0).text())
+ .doesNotContain("oder Absatz 4");
+ }
+
+ /**
+ * Bleibt der Anker auch in der umnummerierten Einheit mehrdeutig, so bleibt der Befehl liegen —
+ * und mit der Begründung des <em>ersten</em> Versuchs: Die Mehrdeutigkeit ist der wahre Befund.
+ */
+ @Test
+ void mehrdeutigBleibtMehrdeutig() {
+ var gesetz =
+ new Gesetz(
+ "TestG",
+ "Testgesetz",
+ "TestG",
+ List.of(
+ new Norm(
+ "§ 108",
+ null,
+ null,
+ List.of(
+ new Absatz(
+ "1",
+ "17. entgegen § 90 Absatz 1 etwas tut,\n"
+ + "18. entgegen § 96 Absatz 1 und § 97 Absatz 1 etwas unterlässt,")),
+ false)));
+ var rahmen = stelle(new Stelle.Paragraph("108"), new Stelle.AbsatzNr("1"));
+ var befehl =
+ verbund(
+ new Umnummerierung(
+ rahmen.plus(stelle(new Stelle.NummerNr("18"))),
+ stelle(new Stelle.NummerNr("29")),
+ PROV),
+ new WoerterEinfuegung(
+ rahmen, new WortAnker.NachWoertern("Absatz 1"), "oder Absatz 4", PROV));
+
+ var ergebnis = BefehlAnwender.anwenden(gesetz, List.of(befehl));
+ assertThat(ergebnis.protokoll().get(0).status()).isEqualTo(Status.MANUELL_PRUEFEN);
+ assertThat(ergebnis.protokoll().get(0).grund()).isEqualTo(Grund.MEHRDEUTIG);
+ assertThat(ergebnis.protokoll().get(0).begruendung()).contains("mehrdeutig");
+ assertThat(ergebnis.neu().norm("§ 108").orElseThrow().absaetze().get(0).text())
+ .doesNotContain("oder Absatz 4");
+ }
+
+ /**
+ * Ein Halbsatz setzt den Satz hinter dem Strichpunkt fort; er beginnt keinen neuen. Angefügt wird
+ * er deshalb wie ein Satz — aber nur hinter einem Strichpunkt. Endet der Zieltext auf einen
+ * Punkt, so wäre das Angefügte gerade kein Halbsatz, und der Befehl bleibt liegen.
+ */
+ @Test
+ void halbsatzWirdHinterDemStrichpunktAngefuegt() {
+ var gesetz =
+ new Gesetz(
+ "TestG",
+ "Testgesetz",
+ "TestG",
+ List.of(
+ new Norm(
+ "§ 8",
+ null,
+ null,
+ List.of(new Absatz("1", "Erster Satz. Der zweite Satz endet hier;")),
+ false)));
+
+ var befehl =
+ new Anfuegung(
+ stelle(new Stelle.Paragraph("8"), new Stelle.AbsatzNr("1")),
+ Ebene.HALBSATZ,
+ null,
+ "die Bearbeitungszeit zählt zu den Fachstudien.",
+ PROV);
+
+ var ergebnis = BefehlAnwender.anwenden(gesetz, List.of(befehl));
+ assertThat(ergebnis.protokoll().get(0).status()).isEqualTo(Status.ANGEWANDT);
+ assertThat(ergebnis.neu().norm("§ 8").orElseThrow().absaetze().get(0).text())
+ .isEqualTo(
+ "Erster Satz. Der zweite Satz endet hier; die Bearbeitungszeit zählt zu den"
+ + " Fachstudien.");
+ }
+
+ @Test
+ void halbsatzOhneStrichpunktBleibtLiegen() {
+ var gesetz =
+ new Gesetz(
+ "TestG",
+ "Testgesetz",
+ "TestG",
+ List.of(
+ new Norm(
+ "§ 8",
+ null,
+ null,
+ List.of(new Absatz("1", "Erster Satz. Der zweite Satz endet hier.")),
+ false)));
+
+ var befehl =
+ new Anfuegung(
+ stelle(new Stelle.Paragraph("8"), new Stelle.AbsatzNr("1")),
+ Ebene.HALBSATZ,
+ null,
+ "die Bearbeitungszeit zählt zu den Fachstudien.",
+ PROV);
+
+ var ergebnis = BefehlAnwender.anwenden(gesetz, List.of(befehl));
+ assertThat(ergebnis.protokoll().get(0).status()).isEqualTo(Status.MANUELL_PRUEFEN);
+ assertThat(ergebnis.protokoll().get(0).grund()).isEqualTo(Grund.BESTAND_WIDERSPRICHT);
+ assertThat(ergebnis.protokoll().get(0).begruendung())
+ .contains("setzt einen Strichpunkt voraus");
+ assertThat(ergebnis.neu().norm("§ 8").orElseThrow().absaetze().get(0).text())
+ .isEqualTo("Erster Satz. Der zweite Satz endet hier.");
+ }
}
diff --git a/src/test/java/eu/mulk/aendggner/gesetz/land/LandesRechtTextAusgeberTest.java b/src/test/java/eu/mulk/aendggner/gesetz/land/LandesRechtTextAusgeberTest.java
new file mode 100644
index 0000000..e620a03
--- /dev/null
+++ b/src/test/java/eu/mulk/aendggner/gesetz/land/LandesRechtTextAusgeberTest.java
@@ -0,0 +1,69 @@
+// SPDX-FileCopyrightText: 2026 Matthias Andreas Benkard <code@mail.matthias.benkard.de>
+// SPDX-License-Identifier: AGPL-3.0-or-later
+package eu.mulk.aendggner.gesetz.land;
+
+import static org.assertj.core.api.Assertions.assertThat;
+import static org.junit.jupiter.api.Assumptions.assumeTrue;
+
+import eu.mulk.aendggner.gesetz.Gesetz;
+import java.io.IOException;
+import java.nio.file.Files;
+import java.nio.file.Path;
+import java.util.List;
+import java.util.stream.Stream;
+import org.junit.jupiter.params.ParameterizedTest;
+import org.junit.jupiter.params.provider.MethodSource;
+
+/**
+ * Der Beleg des Textausgebers ist der Rundlauf, nicht der Augenschein: Was er schreibt, muss der
+ * Lader wieder zu demselben Gesetz lesen. Geprüft wird das an sämtlichen Klartext-Stammfassungen
+ * des Beispielkorpus — dreizehn Länder, jede Eigenheit des Formats mindestens einmal (Superskripte
+ * und „Art.“-Sigel in Bayern, titellose Paragraphen und ausgeschriebene Ordinale in Hessen, die
+ * Nummern einer Anlage als eigene Normen in Berlin, Zwischentitel, Fußnoten, Inhaltsübersicht).
+ */
+class LandesRechtTextAusgeberTest {
+
+ private static final Path SAMPLEDATA = Path.of("src/test/resources/sampledata");
+
+ static Stream<Path> klartextFassungen() throws IOException {
+ if (!Files.isDirectory(SAMPLEDATA)) {
+ return Stream.of();
+ }
+ try (var pfade = Files.walk(SAMPLEDATA)) {
+ return pfade
+ .filter(p -> p.getFileName().toString().endsWith(".txt"))
+ .sorted()
+ .toList()
+ .stream();
+ }
+ }
+
+ @ParameterizedTest(name = "{0}")
+ @MethodSource("klartextFassungen")
+ void rundlaufErhaeltDasGesetz(Path datei) throws IOException {
+ assumeTrue(Files.exists(datei), "Beispieldaten fehlen");
+ var gelesen = new LandesRechtLoader().load(datei);
+
+ var geschrieben = LandesRechtTextAusgeber.ausgeben(gelesen);
+ var wiederGelesen = LandesRechtTextParser.parse(geschrieben);
+
+ assertThat(wiederGelesen.langue()).isEqualTo(gelesen.langue());
+ assertThat(wiederGelesen.jurabk()).isEqualTo(gelesen.jurabk());
+ assertThat(wiederGelesen.kurzue()).isEqualTo(gelesen.kurzue());
+ assertThat(bezeichnungen(wiederGelesen)).isEqualTo(bezeichnungen(gelesen));
+ assertThat(wiederGelesen.gliederungen()).isEqualTo(gelesen.gliederungen());
+
+ for (var soll : gelesen.normen()) {
+ var ist = wiederGelesen.norm(soll.enbez()).orElseThrow();
+ assertThat(ist.titel()).as("Titel von %s", soll.enbez()).isEqualTo(soll.titel());
+ assertThat(ist.weggefallen())
+ .as("Wegfall von %s", soll.enbez())
+ .isEqualTo(soll.weggefallen());
+ assertThat(ist.absaetze()).as("Absätze von %s", soll.enbez()).isEqualTo(soll.absaetze());
+ }
+ }
+
+ private static List<String> bezeichnungen(Gesetz gesetz) {
+ return gesetz.normen().stream().map(n -> n.enbez()).toList();
+ }
+}