diff options
| author | Matthias Andreas Benkard <code@mail.matthias.benkard.de> | 2026-08-25 22:33:11 +0200 |
|---|---|---|
| committer | Matthias Andreas Benkard <code@mail.matthias.benkard.de> | 2026-08-25 22:33:11 +0200 |
| commit | f3e9ae405ee64c98684d99966d75bb90367adc44 (patch) | |
| tree | 03d455fdde06b81611d81418b452416cba1d1f65 /src | |
| parent | 22f19c3afc4d51d7714647c5635bb068cf32d433 (diff) | |
Die Überschrift einer Nummer ist die Kopfzeile ihres Blocks
Für die Anlagen-Nummern des Bundesrechts war eine offene Modellfrage vermerkt: Ob
die Nummern einer Anlage im gii-XML ebenso zu eigenen Normen zu zerlegen seien wie
im Landesrecht. Sie war falsch gestellt. Der Stellenaufloeser probiert längst
beide Wege — die Nummer als eigene Norm und, wenn es die nicht gibt, als Marke im
Wortlaut —, und beide tragen.
Der Fehler saß anderswo, und er war schlimmer als eine Lücke. Der
Überschrift-Zweig verzweigte bei jeder Stelle, die eine Überschrift nennt,
unbedingt auf den Titel der Norm und verwarf dabei jede feinere Komponente
schweigend. „In der Überschrift der Nummer 1 der Anlage 8 werden die Wörter …
gestrichen“ arbeitete deshalb am Titel der Anlage 8. Dass es hier beim Rest blieb,
lag allein daran, dass der Zieltext dort zufällig nicht stand; stünde er dort, so
hätte das Werkzeug die falsche Einheit geändert, ohne dass es jemand erfahren
hätte.
Welche Überschrift gemeint ist, entscheidet fortan nicht der Wortlaut des
Befehls, sondern die Auflösung der Stelle ohne die Überschrift-Komponente. Löst
sie auf eine ganze Norm auf, so ist deren Titel gemeint — der Regelfall, und
ebenso dort, wo die Nummer einer Anlage eine eigene Norm ist. Löst sie auf einen
Textbereich auf, so ist dessen Kopfzeile gemeint: Sie trägt Marke und
Überschrift, die Kindzeilen sind ihr Inhalt. Ein Block aus einer einzigen Zeile
hat keine Überschrift, sondern nur Text; dort wird gerügt statt angewandt.
Die Neufassung einer solchen Kopfzeile bleibt eine bewusst gezogene Grenze und
wird nunmehr als solche gerügt: Die Kopfzeile trägt zugleich die
Aufzählungsmarke, die eine Neufassung nicht mitliefert. Bisher bog dieser Zweig
still auf den Normtitel um.
Geprüft: 378 Tests (zuvor 375). GEG 118 von 119 Befehlen (zuvor 117); die
Überschrift der Nummer 1 der Anlage 8 ist gestutzt, der Titel der Anlage und der
gleichlautende Buchstabe a) darunter sind es nicht.
Change-Id: I2b8a714b1e721d01053e314f52700ca95966fc4e
Diffstat (limited to 'src')
3 files changed, 252 insertions, 35 deletions
diff --git a/src/main/java/eu/mulk/aendggner/anwendung/BefehlAnwender.java b/src/main/java/eu/mulk/aendggner/anwendung/BefehlAnwender.java index e931901..a46defb 100644 --- a/src/main/java/eu/mulk/aendggner/anwendung/BefehlAnwender.java +++ b/src/main/java/eu/mulk/aendggner/anwendung/BefehlAnwender.java @@ -1001,6 +1001,19 @@ public final class BefehlAnwender { var stelle = befehl.stelle(); if (stelle.betrifftUeberschrift()) { + // Die Neufassung einer Überschrift trifft den Titel der Norm. Nennt die Stelle eine feinere + // Einheit („die Überschrift der Nummer 1 der Anlage 8“), so ist deren Kopfzeile gemeint — + // und die trägt außer der Überschrift auch die Aufzählungsmarke, die eine Neufassung nicht + // mitliefert. Was hier zu setzen wäre, steht deshalb nicht fest; gerügt wird es, statt die + // feinere Angabe zu verwerfen und den Normtitel zu überschreiben. + if (nenntFeinereEinheit(stelle)) { + return manuell( + befehl, + Grund.NICHT_UNTERSTUETZT, + "Die Neufassung der Überschrift von „" + + stelle.ohne(new Stelle.Ueberschrift()).anzeigeText() + + "“ ist nicht umgesetzt: Deren Kopfzeile trägt zugleich die Aufzählungsmarke."); + } var aufloesung = loeseNormAuf(normen, stelle); if (aufloesung.fehler() != null) { return manuell(befehl, aufloesung.grund(), aufloesung.fehler()); @@ -2145,23 +2158,14 @@ public final class BefehlAnwender { */ private static AngewandteAenderung bearbeiteText( List<Norm> normen, Aenderungsbefehl befehl, TextOperation operation) { - // „In der Überschrift …“: die Operation wirkt auf den Titel der Norm, nicht auf ihren Text. + // „In der Überschrift …“ meint nicht immer den Titel der Norm. Nennt die Stelle eine feinere + // Einheit („In Anlage 8 Nummer 1 werden in der Überschrift die Wörter … gestrichen“), so ist + // die Überschrift jener Einheit gemeint — im gii-XML die Kopfzeile ihres Aufzählungsblocks. + // Zuvor verwarf dieser Zweig die feinere Angabe schweigend und griff auf den Normtitel; stand + // der Zieltext zufällig dort, so wurde die falsche Einheit geändert, ohne dass es jemand + // erfuhr. if (befehl.stelle().betrifftUeberschrift()) { - var aufloesung = loeseNormAuf(normen, befehl.stelle()); - if (aufloesung.fehler() != null) { - return manuell(befehl, aufloesung.grund(), aufloesung.fehler()); - } - var norm = normen.get(aufloesung.normIndex()); - if (norm.titel() == null) { - return manuell( - befehl, Grund.BESTAND_WIDERSPRICHT, norm.enbez() + " hat keine Überschrift."); - } - var titelErgebnis = operation.wende(norm.titel()); - if (titelErgebnis.fehler() != null) { - return manuell(befehl, titelErgebnis.grund(), titelErgebnis.fehler()); - } - normen.set(aufloesung.normIndex(), norm.mitTitel(titelErgebnis.text())); - return angewandt(befehl, norm.enbez()); + return bearbeiteUeberschrift(normen, befehl, operation); } var ergebnis = StellenAufloeser.aufloese(gesetzAus(normen), befehl.stelle()); @@ -2213,6 +2217,98 @@ public final class BefehlAnwender { return angewandt(befehl, norm.enbez()); } + /** + * Wendet eine Textoperation auf eine Überschrift an. Welche Überschrift gemeint ist, entscheidet + * nicht der Wortlaut des Befehls, sondern die Auflösung der Stelle ohne die + * Überschrift-Komponente: + * + * <ul> + * <li>Löst sie auf eine ganze Norm auf, so ist deren Titel gemeint — der Regelfall („In der + * Überschrift des § 5 …“), und ebenso dort, wo die Nummer einer Anlage eine eigene Norm ist + * (Landesrecht seit Welle 20c). + * <li>Löst sie auf einen Textbereich auf, so ist dessen <em>Kopfzeile</em> gemeint. Im gii-XML + * des Bundes stehen die Nummern einer Anlage als Aufzählungsmarken im Wortlaut, und die + * Kopfzeile trägt Marke und Überschrift („1. Wärmedämmung von … in den Fällen des § 69 und + * § 71 Absatz 1“); die Kindzeilen sind ihr Inhalt. + * </ul> + * + * <p>Ein Block 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. + */ + private static AngewandteAenderung bearbeiteUeberschrift( + List<Norm> normen, Aenderungsbefehl befehl, TextOperation operation) { + var ohneUeberschrift = befehl.stelle().ohne(new Stelle.Ueberschrift()); + var ergebnis = StellenAufloeser.aufloese(gesetzAus(normen), ohneUeberschrift); + if (ergebnis instanceof StellenAufloeser.Ergebnis.NichtGefunden nicht) { + return manuell(befehl, nicht.grund(), nicht.begruendung()); + } + var fundstelle = ((StellenAufloeser.Ergebnis.Gefunden) ergebnis).fundstelle(); + var norm = normen.get(fundstelle.normIndex()); + + if (fundstelle.bereich() == null) { + if (norm.titel() == null) { + return manuell( + befehl, Grund.BESTAND_WIDERSPRICHT, norm.enbez() + " hat keine Überschrift."); + } + var titelErgebnis = operation.wende(norm.titel()); + if (titelErgebnis.fehler() != null) { + return manuell(befehl, titelErgebnis.grund(), titelErgebnis.fehler()); + } + normen.set(fundstelle.normIndex(), norm.mitTitel(titelErgebnis.text())); + return angewandt(befehl, norm.enbez()); + } + + var absaetze = new ArrayList<>(norm.absaetze()); + var absatz = absaetze.get(fundstelle.absatzIndex()); + var kopfzeile = kopfzeileVon(absatz.text(), fundstelle.bereich()); + if (kopfzeile == null) { + return manuell( + befehl, + Grund.STELLE_NICHT_AUFLOESBAR, + "„" + ohneUeberschrift.anzeigeText() + "“ trägt keine eigene Überschrift."); + } + var kopfErgebnis = operation.wende(absatz.text().substring(kopfzeile.von(), kopfzeile.bis())); + if (kopfErgebnis.fehler() != null) { + return manuell(befehl, kopfErgebnis.grund(), kopfErgebnis.fehler()); + } + var neuerText = + absatz.text().substring(0, kopfzeile.von()) + + kopfErgebnis.text() + + absatz.text().substring(kopfzeile.bis()); + absaetze.set(fundstelle.absatzIndex(), absatz.mitText(neuerText)); + normen.set(fundstelle.normIndex(), norm.mitAbsaetzen(absaetze)); + return angewandt(befehl, norm.enbez()); + } + + /** + * Nennt die Stelle eine Einheit unterhalb der Norm — einen Absatz, einen Satz, eine Nummer oder + * einen Buchstaben? Nur dann meint „die Überschrift“ etwas anderes als den Titel der Norm. + */ + private static boolean nenntFeinereEinheit(Stelle stelle) { + return stelle.komponenten().stream() + .anyMatch( + k -> + k instanceof Stelle.AbsatzNr + || k instanceof Stelle.SatzNr + || k instanceof Stelle.HalbsatzNr + || k instanceof Stelle.NummerNr + || k instanceof Stelle.BuchstabeNr); + } + + /** + * Die erste Zeile eines Textbereichs, sofern ihr wenigstens eine weitere folgt; sonst {@code + * null}. Der Zeilenumbruch selbst bleibt außerhalb. + */ + private static SatzTeiler.@Nullable SatzBereich kopfzeileVon( + String text, SatzTeiler.SatzBereich bereich) { + int umbruch = text.indexOf('\n', bereich.von()); + if (umbruch < 0 || umbruch >= bereich.bis()) { + return null; + } + return new SatzTeiler.SatzBereich(bereich.von(), umbruch); + } + /** Wie {@link #bearbeiteText}, aber die Operation braucht den konkreten Zeichenbereich. */ private static AngewandteAenderung bearbeiteBereich( List<Norm> normen, Aenderungsbefehl befehl, BereichsOperation operation) { diff --git a/src/test/java/eu/mulk/aendggner/EndToEndTest.java b/src/test/java/eu/mulk/aendggner/EndToEndTest.java index 91a0cca..7a6dcee 100644 --- a/src/test/java/eu/mulk/aendggner/EndToEndTest.java +++ b/src/test/java/eu/mulk/aendggner/EndToEndTest.java @@ -261,16 +261,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 { @@ -292,8 +293,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).containsExactly("40. a) hh)"); + assertThat(anwendung.anzahlAngewandt()).isEqualTo(118); // Die Kaskade des § 108 Absatz 1 geht lückenlos auf: 32 Nummern, keine doppelt, keine fehlend. var nummern = @@ -311,6 +312,15 @@ class EndToEndTest { assertThat(nummern).hasSize(32).endsWith("32"); var neu = anwendung.neu(); + // 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()) @@ -1702,8 +1712,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 { @@ -1712,14 +1722,14 @@ class EndToEndTest { assumeTrue(Files.exists(xml) && Files.exists(pdf), "GEG-Beispieldaten fehlen"); var vollstaendig = Pipeline.erzeugeSynopse(Pipeline.Auftrag.von(xml, List.of(pdf))); - assertThat(vollstaendig.anzahlAngewandt()).isEqualTo(117); - assertThat(vollstaendig.anzahlManuell()).isEqualTo(2); + assertThat(vollstaendig.anzahlAngewandt()).isEqualTo(118); + assertThat(vollstaendig.anzahlManuell()).isEqualTo(1); var anfang2024 = Pipeline.erzeugeSynopse( Pipeline.Auftrag.von(xml, List.of(pdf)).mitStichtag(LocalDate.of(2024, 1, 1))); - assertThat(anfang2024.anzahlAngewandt()).isEqualTo(116); - assertThat(anfang2024.anzahlManuell()).isEqualTo(2); + assertThat(anfang2024.anzahlAngewandt()).isEqualTo(117); + assertThat(anfang2024.anzahlManuell()).isEqualTo(1); assertThat(anfang2024.html()) .contains("Am Stichtag noch nicht in Kraft") .contains("Tritt erst am 1. Oktober 2024 in Kraft"); @@ -1727,7 +1737,7 @@ class EndToEndTest { var oktober2024 = Pipeline.erzeugeSynopse( Pipeline.Auftrag.von(xml, List.of(pdf)).mitStichtag(LocalDate.of(2024, 10, 1))); - assertThat(oktober2024.anzahlAngewandt()).isEqualTo(117); + assertThat(oktober2024.anzahlAngewandt()).isEqualTo(118); } /** diff --git a/src/test/java/eu/mulk/aendggner/anwendung/BefehlAnwenderTest.java b/src/test/java/eu/mulk/aendggner/anwendung/BefehlAnwenderTest.java index 629ec49..0e186ad 100644 --- a/src/test/java/eu/mulk/aendggner/anwendung/BefehlAnwenderTest.java +++ b/src/test/java/eu/mulk/aendggner/anwendung/BefehlAnwenderTest.java @@ -1666,4 +1666,115 @@ 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"); + } } |
