diff options
| -rw-r--r-- | FASSUNGEN.txt | 97 | ||||
| -rw-r--r-- | README.md | 63 | ||||
| -rw-r--r-- | REUSE.toml | 7 | ||||
| -rwxr-xr-x | deploy/korpuslauf.sh | 55 | ||||
| -rw-r--r-- | src/main/java/eu/mulk/aendggner/AendGgner.java | 90 | ||||
| -rw-r--r-- | src/main/java/eu/mulk/aendggner/Pipeline.java | 30 | ||||
| -rw-r--r-- | src/main/java/eu/mulk/aendggner/bericht/Korpusbericht.java | 476 | ||||
| -rw-r--r-- | src/test/java/eu/mulk/aendggner/bericht/KorpusberichtTest.java | 134 | ||||
| -rw-r--r-- | src/test/resources/sampledata/korpus-grundlinie.tsv | 44 | ||||
| -rw-r--r-- | src/test/resources/sampledata/korpus.tsv | 46 |
10 files changed, 1031 insertions, 11 deletions
diff --git a/FASSUNGEN.txt b/FASSUNGEN.txt index 677db5d..17572b5 100644 --- a/FASSUNGEN.txt +++ b/FASSUNGEN.txt @@ -21,7 +21,102 @@ Vorbemerkung zur Führung dieses Verzeichnisses ════════════════════════════════════════════════════════════════════════════════ - Fassung vom 28. August 2026, + Fassung vom 28. August 2026 (zweite Fassung des Tages), + zuletzt geändert durch die am 28. August 2026 vorgenommenen Änderungen +════════════════════════════════════════════════════════════════════════════════ + +Der Befund über das Erzeugnis ruhte bislang auf handverlesenen Fällen. Rund drei +Dutzend Akzeptanzprüfungen sagen, dass diese Hefte aufgehen; sie sagen nicht, +woran die Reste im Ganzen liegen, und die Auszählung der Gründe blieb auf den +einzelnen Lauf beschränkt. Der Massenlauf beantwortet das für den Korpus: eine +Zeile Kennzahlen je Heft, und, eingecheckt, eine Grundlinie, hinter die kein +späterer Lauf zurückfallen darf. Der Korpus umfasst einunddreißig Aufträge; er +gibt 1413 Befehle her, von denen 1287 angewandt werden, und 632 von 641 mit der +amtlichen Nachfassung verglichenen Normen gehen auf. + + +Artikel 1 +Der Korpusbericht + +(1) Der Schalter „--korpus“ nimmt eine Auftragsliste entgegen und gibt statt einer + Synopse je Auftrag eine Zeile Kennzahlen aus (Korpusbericht). Die Liste ist + eine Tabulatortabelle mit den Spalten Bezeichnung, Stammfassung, Hefte, + Artikel, Nachfassung und Stichtag; es sind dieselben Angaben, die auch die + Befehlszeile entgegennimmt. Ein Auftrag ist nichts anderes als ein Lauf. + +(2) Der Bericht führt je Auftrag die erschlossenen, die angewandten, die liegen + gebliebenen und die am Stichtag zurückgestellten Befehle, sodann die gleichen, + geprüften, fehlenden, überzähligen und abweichenden Normen des Abgleichs und + zuletzt die Gründe nach Häufigkeit. + +(3) Der Schalter „--grundlinie“ hält den Lauf gegen einen früheren Bericht. Gerügt + wird allein, was fällt: weniger erschlossene oder angewandte Befehle, weniger + gleiche Normen, mehr liegengebliebene, fehlende, überzählige oder abweichende. + Ein Fortschritt ist kein Fehler, sondern der Zweck der Arbeit; er verlangt + allein, die Grundlinie fortzuschreiben. Der Rückschritt endet mit dem + Rückgabewert 3 wie ein nicht aufgehender Abgleich. + +(4) Der Schalter „--synopsen“ behält die Einzelsynopsen und legt eine + Übersichtsseite daneben, die auf jede von ihnen verweist. Ohne sie wäre der + Bericht eine Zahlenreihe, aus der niemand den Grund eines Restes erführe. + +(5) Ein Auftrag, der mit einem Fehler abbricht, hält den Lauf nicht an; seine + Zeile trägt den Fehlertext, und die Grundlinie rügt ihn. Der Grundsatz der + Nichtverwerfung gilt auch für den Massenlauf. + +(6) Die Auszählung der Gründe, die bisher in der Befehlszeilenfassung stand, ist + in die Pipeline gewandert (Pipeline.zaehleGruende) und wird von beiden + verwendet; das Ergebnis (Pipeline.Ergebnis) führt sie fortan mit. + + +Artikel 2 +Der Beispielkorpus + +(1) Unter „sampledata/korpus.tsv“ treten einunddreißig Aufträge hinzu, nämlich + dieselben, die die Akzeptanzprüfungen fahren, nebst den Entwürfen, den beiden + Beschlussempfehlungen und einem Stichtagsfall. + +(2) Drei Aufträge tragen mit Absicht hohe Reste: Bei den Beschlussempfehlungen und + bei Rheinland-Pfalz schreibt die Vorlage eine ältere Fassung fort als die + eingesetzte Stammfassung, weshalb deren Zieltexte nicht vorkommen. Sie sind + gleichwohl Wächter — steigen ihre Reste, so hat sich etwas verschlechtert. + +(3) Die Grundlinie („sampledata/korpus-grundlinie.tsv“) hält den Stand dieses + Tages fest. Sie und die Auftragsliste sind eigene Arbeit; ihr Vermerk steht in + der Zuordnungsdatei (REUSE.toml) und nicht als Kopfzeile in der Datei, weil + eine neu gesetzte Grundlinie ihn sonst verlöre. + +(4) Das Skript „deploy/korpuslauf.sh“ stellt das Erzeugnis her, falls es fehlt, + fährt den Lauf und legt Bericht, Synopsen und Übersicht in „target/korpus“ ab. + + +Artikel 3 +Prüfung + +(1) Der Korpuslauf wird mitgeprüft (KorpusberichtTest): Kein Auftrag darf + scheitern, und keine Kennzahl darf hinter die Grundlinie zurückfallen. + +(2) Die Rügeregel selbst wird für sich geprüft — dass der Fortschritt + ungerügt bleibt, der Rückschritt in jeder der vier Kennzahlen gerügt wird, ein + gescheiterter Auftrag auffällt und ein aus dem Lauf verschwundener ebenfalls. + +(3) Dass die Liste vollständig gelesen wird, ist eigens geprüft: Anmerkungszeilen + werden übergangen, „-“ heißt „nicht angegeben“, mehrere Hefte werden durch + Komma getrennt, und der Stichtag wird als Datum gelesen. + + +Schlussbestimmung + +Die vorstehenden Änderungen sind durch die vollständige Prüfung (mvnw verify, +vierhundertdreiundvierzig Testfälle) und durch die Lizenzprüfung (reuse lint, +zweihundertvierzehn von zweihundertvierzehn Dateien) bestätigt worden. Die +Rügeregel ist überdies am ganzen Korpus erprobt worden: Eine um eins verschärfte +Grundlinie führt zur Rüge „UWG-BGBl: angewandte Befehle 19 statt 20“ und zum +Rückgabewert 3. + + +════════════════════════════════════════════════════════════════════════════════ + Fassung vom 28. August 2026 (erste Fassung des Tages), zuletzt geändert durch die am 28. August 2026 vorgenommenen Änderungen ════════════════════════════════════════════════════════════════════════════════ @@ -42,6 +42,7 @@ durchgerechneten Beispiele sind der kürzeste Weg zum ersten Ergebnis - [§ 6a Die fortgeschriebene Fassung; die Kette](#-6a-die-fortgeschriebene-fassung-die-kette) - [§ 6b Abgleich mit der amtlichen Nachfassung](#-6b-abgleich-mit-der-amtlichen-nachfassung) - [§ 6c Probe auf die Inhaltsübersicht](#-6c-probe-auf-die-inhaltsübersicht) +- [§ 6d Der Massenlauf; der Korpusbericht](#-6d-der-massenlauf-der-korpusbericht) - [§ 7 Erkannte Änderungsbefehle](#-7-erkannte-änderungsbefehle) - [§ 8 Reihenfolge der Anwendung](#-8-reihenfolge-der-anwendung) - [§ 9 Aufbereitung der Druckwerke](#-9-aufbereitung-der-druckwerke) @@ -269,6 +270,11 @@ java -jar target/aendggner-0.1.0-SNAPSHOT.jar \ gelten dieselben Eingaben wie für das Stammgesetz, auch die Anschriften des § 5 Absatz 4. +**`--korpus <file>`** +: Statt einer Synopse den Massenlauf über eine Auftragsliste fahren und einen + Bericht der Kennzahlen ausgeben (§ 6d); `--grundlinie` hält ihn gegen einen + früheren, `--synopsen` behält die Einzelsynopsen. + **`--extract-only`** : Nur den bereinigten Lineartext des Änderungsdokuments ausgeben. Dies ist angezeigt, wenn die PDF-Aufbereitung (§ 9) fehlerhaft bleibt: Der Text ist zu @@ -395,6 +401,55 @@ Befehl fortzuschreiben hieße, Recht zu erfinden. amtliche Fassung selbst beides verschieden führt: Das Gebäudeenergiegesetz schreibt in der Überschrift des § 71k „Gas“ und in der Angabe dazu „Erdgas“. +## § 6d Der Massenlauf; der Korpusbericht + +(1) Eine Synopse sagt, was aus *einem* Heft geworden ist. Sie sagt nicht, woran die +Reste im Ganzen liegen — ob an der Befehlssprache eines Landes, an der Aufbereitung +des Druckwerks oder am Alter der eingesetzten Stammfassung. Der Schalter `--korpus` +arbeitet deshalb eine ganze Auftragsliste ab und gibt je Auftrag eine Zeile +Kennzahlen aus: + +```shell +java -jar target/aendggner-0.1.0-SNAPSHOT.jar \ + --korpus src/test/resources/sampledata/korpus.tsv \ + --grundlinie src/test/resources/sampledata/korpus-grundlinie.tsv \ + --synopsen target/korpus/synopsen -o target/korpus/bericht.tsv +# → 31 Aufträge, 1413 Befehle, 1287 angewandt, 116 manuell; 632 von 641 Normen gleich +``` + +Dasselbe fährt `deploy/korpuslauf.sh`; es stellt das Erzeugnis her, falls es fehlt, +und behält die Einzelsynopsen nebst einer Übersichtsseite, die auf sie verweist. + +(2) Die Auftragsliste ist eine Tabulatortabelle mit den Spalten Bezeichnung, +Stammfassung, Hefte (durch Komma), Artikel, Nachfassung und Stichtag; „`-`“ heißt +„nicht angegeben“, Zeilen mit „`#`“ sind Anmerkungen. Die Pfade beziehen sich auf +das Verzeichnis der Liste. Es sind dieselben Angaben, die auch die Befehlszeile +entgegennimmt — ein Auftrag der Liste ist nichts anderes als ein Lauf. + +(3) Der Bericht führt je Auftrag die erschlossenen, die angewandten, die +liegengebliebenen und die am Stichtag zurückgestellten Befehle, sodann die gleichen, +geprüften, fehlenden, überzähligen und abweichenden Normen des Abgleichs und +zuletzt die Gründe nach Häufigkeit (§ 1 Absatz 5). + +(4) Wird der Bericht eingecheckt, so ist er die **Grundlinie**. Der Schalter +`--grundlinie` hält den Lauf dagegen und rügt jede Kennzahl, die *gefallen* ist: +weniger erschlossene oder angewandte Befehle, weniger gleiche Normen, mehr +liegengebliebene, fehlende, überzählige oder abweichende. Ein Fortschritt ist kein +Fehler, sondern der Zweck der Arbeit; er verlangt allein, die Grundlinie +fortzuschreiben. Ein Rückschritt endet mit dem Rückgabewert 3 wie ein nicht +aufgehender Abgleich (§ 6b Absatz 3). + +(5) Ein Auftrag, der mit einem Fehler abbricht, hält den Lauf nicht an; seine Zeile +trägt den Fehlertext, und die Grundlinie rügt ihn. Der Grundsatz der +Nichtverwerfung (§ 1 Absatz 4) gilt auch hier. + +(6) Der mitgelieferte Korpus umfasst, was die Akzeptanzprüfungen fahren (§ 17 +Absatz 2), und wird von diesen mitgeprüft. Drei Aufträge tragen mit Absicht hohe +Reste: Bei den beiden Beschlussempfehlungen und bei Rheinland-Pfalz schreibt die +Vorlage eine ältere Fassung fort als die eingesetzte Stammfassung, weshalb deren +Zieltexte nicht vorkommen. Sie sind gleichwohl Wächter — steigen ihre Reste, so hat +sich etwas verschlechtert. + ## § 7 Erkannte Änderungsbefehle (1) Erkannt werden die gebräuchlichsten Änderungsbefehle des Handbuchs der @@ -984,6 +1039,14 @@ Ergebnis gegen die amtliche Nachfassung stellen — mit demselben Abgleich, den Beispieldaten nebst Herkunftsnachweis (`SOURCES`) liegen unter `src/test/resources/sampledata/`. +(2a) Über die Einzelfälle hinaus wird der **Korpuslauf** (§ 6d) mitgeprüft: Er fährt +sämtliche Aufträge der Liste `src/test/resources/sampledata/korpus.tsv` und hält das +Ergebnis gegen die eingecheckte Grundlinie +(`korpus-grundlinie.tsv`). Damit ist nicht nur gesichert, dass jedes einzelne Heft +aufgeht, sondern auch, dass keines hinter das zurückfällt, was es einmal hergegeben +hat — und zwar in einer Zahl, die sich fortschreiben lässt, statt in Dutzenden +verstreuter Behauptungen. + (3) Der Textausgeber (§ 6a) wird nicht am Augenschein geprüft, sondern am **Rundlauf**: Was er schreibt, muss der Lader wieder zu demselben Gesetz lesen. Geprüft wird das an sämtlichen Klartext-Stammfassungen des Beispielkorpus und @@ -215,7 +215,10 @@ SPDX-License-Identifier = "LicenseRef-AmtlichesWerk" # Die *-alt.txt tragen amtlichen Wortlaut in selbst hergestellter Aufbereitung # (Einrückung, Superskript-Satznummern, Gliederungsüberschriften); der Wortlaut ist # gemeinfrei, die Aufbereitung eigene Arbeit. Die SOURCES-Dateien sind vollständig -# eigener Text, stehen aber im selben Zusammenhang. +# eigener Text, stehen aber im selben Zusammenhang. Ebenso die Auftragsliste des +# Korpuslaufs und dessen Grundlinie: Sie werden vom Erzeugnis selbst geschrieben und +# stehen deshalb hier und nicht als Kopfzeile in der Datei — sonst verlöre eine neu +# gesetzte Grundlinie ihren Vermerk. [[annotations]] path = [ @@ -253,6 +256,8 @@ SPDX-License-Identifier = "AGPL-3.0-or-later AND LicenseRef-AmtlichesWerk" [[annotations]] path = [ + "src/test/resources/sampledata/korpus.tsv", + "src/test/resources/sampledata/korpus-grundlinie.tsv", "src/test/resources/sampledata/AGG/SOURCES", "src/test/resources/sampledata/BadenWuerttemberg/SOURCES", "src/test/resources/sampledata/BayJG/SOURCES", diff --git a/deploy/korpuslauf.sh b/deploy/korpuslauf.sh new file mode 100755 index 0000000..be0533a --- /dev/null +++ b/deploy/korpuslauf.sh @@ -0,0 +1,55 @@ +#!/bin/sh +# SPDX-FileCopyrightText: 2026 Matthias Andreas Benkard <code@mail.matthias.benkard.de> +# SPDX-License-Identifier: AGPL-3.0-or-later + +# Führt den Massenlauf über den Beispielkorpus (§ 6d des Handbuchs) und legt Bericht und +# Einzelsynopsen in einem Ausgabeverzeichnis ab. +# +# Anders als die Akzeptanzprüfungen, die dasselbe im Rahmen von „mvnw verify“ tun, dient +# dieses Skript dem Hinsehen: Es behält die Synopsen, damit sich jeder Rest an seinem +# eigenen Dokument nachschlagen lässt, und es nennt den Bericht in der Fassung, in der er +# auch als Grundlinie liegt. +# +# deploy/korpuslauf.sh [ausgabeverzeichnis] [auftragsliste] +# +# Voreinstellungen: target/korpus und src/test/resources/sampledata/korpus.tsv. +# Rückgabewert 3, wenn eine Kennzahl hinter die Grundlinie zurückfällt (wie beim Abgleich +# mit der amtlichen Nachfassung, § 6b Absatz 3). +# +# Ist eine neue Grundlinie zu setzen — weil eine Kennzahl gestiegen ist —, so tut das: +# cp target/korpus/bericht.tsv src/test/resources/sampledata/korpus-grundlinie.tsv +# nebst der Vorbemerkung, die in jener Datei steht. + +set -eu + +wurzel="$(CDPATH='' cd -- "$(dirname -- "$0")/.." && pwd)" +ziel="${1:-$wurzel/target/korpus}" +liste="${2:-$wurzel/src/test/resources/sampledata/korpus.tsv}" +grundlinie="$wurzel/src/test/resources/sampledata/korpus-grundlinie.tsv" +jar="$wurzel/target/aendggner-0.1.0-SNAPSHOT.jar" + +if [ ! -f "$liste" ]; then + echo "Die Auftragsliste $liste fehlt." >&2 + exit 1 +fi + +if [ ! -f "$jar" ]; then + echo "Das Erzeugnis fehlt; es wird hergestellt." + (cd "$wurzel" && ./mvnw -q package -DskipTests) +fi + +mkdir -p "$ziel" + +set +e +java -jar "$jar" \ + --korpus "$liste" \ + --grundlinie "$grundlinie" \ + --synopsen "$ziel/synopsen" \ + -o "$ziel/bericht.tsv" +ergebnis=$? +set -e + +echo +echo "Bericht: $ziel/bericht.tsv" +echo "Synopsen: $ziel/synopsen" +exit "$ergebnis" diff --git a/src/main/java/eu/mulk/aendggner/AendGgner.java b/src/main/java/eu/mulk/aendggner/AendGgner.java index d089f21..3067a13 100644 --- a/src/main/java/eu/mulk/aendggner/AendGgner.java +++ b/src/main/java/eu/mulk/aendggner/AendGgner.java @@ -8,6 +8,7 @@ import eu.mulk.aendggner.aenderung.parse.PatchTextExtraktor; import eu.mulk.aendggner.aenderung.parse.TextBereiniger; import eu.mulk.aendggner.anwendung.BefehlAnwender; import eu.mulk.aendggner.anwendung.Grund; +import eu.mulk.aendggner.bericht.Korpusbericht; import java.io.IOException; import java.nio.charset.StandardCharsets; import java.nio.file.Files; @@ -15,7 +16,6 @@ import java.nio.file.Path; import java.time.LocalDate; import java.time.format.DateTimeParseException; import java.util.ArrayList; -import java.util.EnumMap; import java.util.List; import java.util.concurrent.Callable; import java.util.logging.LogManager; @@ -36,6 +36,7 @@ public class AendGgner implements Callable<Integer> { @Parameters( index = "0", + arity = "0..1", paramLabel = "<base law>", description = "The base law: gii-norm XML from gesetze-im-internet.de, or — for state law — the" @@ -134,6 +135,28 @@ public class AendGgner implements Callable<Integer> { + "exit code 3.") private String nachfassung; + @Option( + names = "--korpus", + paramLabel = "<file>", + description = + "Run every job of the given tab-separated job list and write a report of key figures " + + "instead of a synopsis. Paths in the list are relative to the list's directory.") + private String korpus; + + @Option( + names = "--grundlinie", + paramLabel = "<file>", + description = + "With --korpus: hold the run against this earlier report and complain about every figure " + + "that has fallen. A regression yields exit code 3.") + private String grundlinie; + + @Option( + names = "--synopsen", + paramLabel = "<dir>", + description = "With --korpus: also write each job's synopsis into this directory.") + private String synopsen; + /** Die angegebenen Eingaben, jede über {@link Bezug} beschafft. */ private static List<Quelle> hole(List<String> angaben) throws IOException, InterruptedException { var quellen = new ArrayList<Quelle>(); @@ -154,6 +177,15 @@ public class AendGgner implements Callable<Integer> { log.debugf("Logging configured."); + if (korpus != null) { + return fuehreKorpusAus(); + } + + if (baseFile == null) { + System.err.println("Fehler: das Stammgesetz muss angegeben werden."); + return 1; + } + if (dumpGesetz) { var gesetz = Pipeline.ladeStammgesetz(Bezug.hole(baseFile)); System.out.printf( @@ -305,12 +337,7 @@ public class AendGgner implements Callable<Integer> { * jedem Befehl darüber. */ private static void haeufigkeitDerGruende(BefehlAnwender.AnwendungsErgebnis anwendung) { - var haeufigkeit = new EnumMap<Grund, Integer>(Grund.class); - for (var eintrag : anwendung.protokoll()) { - if (eintrag.status() == BefehlAnwender.Status.MANUELL_PRUEFEN && eintrag.grund() != null) { - haeufigkeit.merge(eintrag.grund(), 1, Integer::sum); - } - } + var haeufigkeit = Pipeline.zaehleGruende(anwendung.protokoll()); if (haeufigkeit.isEmpty()) { return; } @@ -320,6 +347,55 @@ public class AendGgner implements Callable<Integer> { .forEach(e -> System.out.printf(" %4d %s%n", e.getValue(), e.getKey().bezeichnung())); } + /** + * Der Massenlauf: die Aufträge der Liste nacheinander, eine Zeile Kennzahlen je Auftrag. + * + * <p>Der Bericht geht dahin, wohin sonst die Synopse ginge ({@code -o}). Ist eine Grundlinie + * angegeben, so wird der Lauf gegen sie gehalten; jede gefallene Kennzahl wird gerügt und der + * Lauf endet mit 3 — dieselbe Zahl, mit der ein nicht aufgehender Abgleich endet. + */ + private Integer fuehreKorpusAus() throws IOException { + var liste = Path.of(korpus); + var wurzel = liste.toAbsolutePath().getParent(); + var auftraege = Korpusbericht.liesListe(liste); + log.infof("Korpuslauf: %d Aufträge aus %s.", auftraege.size(), liste); + + var zeilen = + Korpusbericht.fuehreAus(auftraege, wurzel, synopsen == null ? null : Path.of(synopsen)); + var tsv = Korpusbericht.alsTsv(zeilen); + if (output == null || output.equals("-") || output.equals("synopse.html")) { + // Die Voreinstellung des Schalters meint die Synopse; ein Bericht gehört dann auf die + // Standardausgabe und nicht in eine Datei namens „synopse.html“. + System.out.print(tsv); + } else { + Files.writeString(Path.of(output), tsv, StandardCharsets.UTF_8); + System.out.println("Bericht geschrieben: " + output); + } + if (synopsen != null) { + var uebersicht = Path.of(synopsen).resolveSibling("uebersicht.html"); + Files.writeString( + uebersicht, + Korpusbericht.alsHtml(zeilen, Path.of(synopsen).getFileName().toString()), + StandardCharsets.UTF_8); + System.out.println("Übersicht geschrieben: " + uebersicht); + } + System.out.println(Korpusbericht.summe(zeilen)); + + if (grundlinie == null) { + return zeilen.stream().anyMatch(z -> z.fehler() != null) ? 3 : 0; + } + var ruegen = + Korpusbericht.gegenGrundlinie( + zeilen, Files.readString(Path.of(grundlinie), StandardCharsets.UTF_8)); + if (ruegen.isEmpty()) { + System.out.println("Die Grundlinie ist gehalten."); + return 0; + } + System.out.println("Rückschritte gegenüber der Grundlinie:"); + ruegen.forEach(r -> System.out.println(" " + r)); + return 3; + } + private static String kuerze(String text) { var einzeilig = text.replaceAll("\\s+", " "); return einzeilig.length() <= 160 ? einzeilig : einzeilig.substring(0, 157) + "…"; diff --git a/src/main/java/eu/mulk/aendggner/Pipeline.java b/src/main/java/eu/mulk/aendggner/Pipeline.java index e4ce9fb..483d456 100644 --- a/src/main/java/eu/mulk/aendggner/Pipeline.java +++ b/src/main/java/eu/mulk/aendggner/Pipeline.java @@ -30,7 +30,9 @@ import java.io.IOException; import java.nio.file.Path; import java.time.LocalDate; import java.util.ArrayList; +import java.util.EnumMap; import java.util.List; +import java.util.Map; import java.util.Set; import org.jspecify.annotations.Nullable; @@ -116,6 +118,11 @@ public final class Pipeline { * @param abgleich der normweise Vergleich mit der amtlichen Nachfassung; {@code null}, wenn der * Auftrag keine nannte. */ + /** + * @param gruende die Auszählung der Gründe, aus denen Befehle liegengeblieben sind. Sie steht + * schon in der Synopse; als Zahl herausgegeben trägt sie überdies den Korpusbericht (§ 6d), + * der über viele Hefte hinweg zeigt, woran die Reste liegen. + */ public record Ergebnis( String html, long anzahlAngewandt, @@ -123,7 +130,25 @@ public final class Pipeline { int anzahlGeaenderteNormen, int anzahlProtokollEintraege, String neufassung, - @Nullable Nachfassungsabgleich abgleich) {} + @Nullable Nachfassungsabgleich abgleich, + Map<Grund, Integer> gruende) { + + public Ergebnis { + gruende = Map.copyOf(gruende); + } + } + + /** Die Gründe der liegengebliebenen Befehle, nach Häufigkeit auszählbar. */ + public static Map<Grund, Integer> zaehleGruende( + List<BefehlAnwender.AngewandteAenderung> protokoll) { + var haeufigkeit = new EnumMap<Grund, Integer>(Grund.class); + for (var eintrag : protokoll) { + if (eintrag.status() == BefehlAnwender.Status.MANUELL_PRUEFEN && eintrag.grund() != null) { + haeufigkeit.merge(eintrag.grund(), 1, Integer::sum); + } + } + return haeufigkeit; + } /** * Ein eingespeistes Änderungsdokument samt erkannter Art und aufbereitetem Text. @@ -257,7 +282,8 @@ public final class Pipeline { synopse.eintraege().size(), protokoll.size(), LandesRechtTextAusgeber.ausgeben(gesetz), - abgleich); + abgleich, + zaehleGruende(protokoll)); } /** diff --git a/src/main/java/eu/mulk/aendggner/bericht/Korpusbericht.java b/src/main/java/eu/mulk/aendggner/bericht/Korpusbericht.java new file mode 100644 index 0000000..1daba42 --- /dev/null +++ b/src/main/java/eu/mulk/aendggner/bericht/Korpusbericht.java @@ -0,0 +1,476 @@ +// SPDX-FileCopyrightText: 2026 Matthias Andreas Benkard <code@mail.matthias.benkard.de> +// SPDX-License-Identifier: AGPL-3.0-or-later +package eu.mulk.aendggner.bericht; + +import eu.mulk.aendggner.Pipeline; +import eu.mulk.aendggner.Quelle; +import eu.mulk.aendggner.anwendung.Grund; +import java.io.IOException; +import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import java.nio.file.Path; +import java.time.LocalDate; +import java.util.ArrayList; +import java.util.EnumMap; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; +import org.jspecify.annotations.Nullable; + +/** + * Der Massenlauf: viele Hefte in einem Durchgang, eine Zeile Kennzahlen je Heft. + * + * <p>Wozu: Ein Belegfall sagt, dass <em>dieses</em> Heft aufgeht. Er sagt nicht, woran die Reste im + * Ganzen liegen — ob an der Befehlssprache eines Landes, an der Aufbereitung des Druckwerks oder am + * Alter der Stammfassung. Die Auszählung der Gründe (§ 1 Absatz 5 des Handbuchs) beantwortet das + * für ein Heft; über einen Korpus geführt beantwortet sie es für das Erzeugnis. + * + * <p>Der Bericht ist zugleich eine Grundlinie: Wird er eingecheckt, so schlägt jeder Lauf an, bei + * dem eine Kennzahl <em>fällt</em> — weniger erschlossene Befehle, weniger angewandte, weniger + * gleiche Normen. Steigt eine, ist die Grundlinie fortzuschreiben; das ist der Vermerk der Welle. + * + * <p>Verworfen wird auch hier nichts: Ein Auftrag, der mit einem Fehler abbricht, hinterlässt seine + * Zeile samt Fehlertext und hält den Lauf nicht an. + */ +public final class Korpusbericht { + + private Korpusbericht() {} + + /** Kein Wert — in der Liste wie im Bericht. */ + private static final String LEER = "-"; + + /** + * Ein Auftrag der Liste. + * + * @param bezeichnung der Name, unter dem der Auftrag im Bericht und in der Grundlinie steht. + * @param stamm die Stammfassung, relativ zum Verzeichnis der Liste. + * @param hefte die Änderungsdokumente, in der Reihenfolge der Liste. + * @param artikel nur diesen Artikel anwenden; {@code null} = alle betroffenen. + * @param nachfassung die amtliche Nachfassung; {@code null} = kein Abgleich, dann bleiben dessen + * Spalten leer. + * @param stichtag die Fassung dieses Tages; {@code null} = alle Befehle anwenden. + */ + public record Auftrag( + String bezeichnung, + String stamm, + List<String> hefte, + @Nullable String artikel, + @Nullable String nachfassung, + @Nullable LocalDate stichtag) { + + public Auftrag { + hefte = List.copyOf(hefte); + } + } + + /** + * Die Kennzahlen eines Laufes. + * + * @param fehler der Grund des Abbruchs; {@code null}, wenn der Auftrag durchgelaufen ist. + */ + public record Zeile( + String bezeichnung, + int befehle, + long angewandt, + long manuell, + long zurueckgestellt, + @Nullable Integer gleich, + @Nullable Integer geprueft, + @Nullable Integer fehlend, + @Nullable Integer ueberzaehlig, + @Nullable Integer abweichend, + Map<Grund, Integer> gruende, + @Nullable String fehler) { + + public Zeile { + gruende = Map.copyOf(gruende); + } + } + + /** Die Spaltenüberschriften des Berichts; sie stehen als Kommentarzeile obenan. */ + private static final List<String> SPALTEN = + List.of( + "Bezeichnung", + "Befehle", + "angewandt", + "manuell", + "zurückgestellt", + "gleich", + "geprüft", + "fehlend", + "überzählig", + "abweichend", + "Gründe"); + + // ---------------------------------------------------------------- Liste lesen + + /** + * Liest die Auftragsliste. Sie ist eine Tabulatortabelle mit den Spalten Bezeichnung, + * Stammfassung, Hefte (durch Komma getrennt), Artikel, Nachfassung, Stichtag; „-“ steht für + * „nicht angegeben“. Zeilen, die mit „#“ beginnen, und Leerzeilen werden übergangen. + */ + public static List<Auftrag> liesListe(Path liste) throws IOException { + var auftraege = new ArrayList<Auftrag>(); + int nummer = 0; + for (var zeile : Files.readAllLines(liste, StandardCharsets.UTF_8)) { + nummer++; + if (zeile.isBlank() || zeile.startsWith("#")) { + continue; + } + var felder = zeile.split("\t"); + if (felder.length < 3) { + throw new IOException( + "%s, Zeile %d: Ein Auftrag braucht wenigstens Bezeichnung, Stammfassung und Heft." + .formatted(liste, nummer)); + } + auftraege.add( + new Auftrag( + felder[0].strip(), + felder[1].strip(), + List.of(felder[2].strip().split("\\s*,\\s*")), + feld(felder, 3), + feld(felder, 4), + feld(felder, 5) == null ? null : LocalDate.parse(feld(felder, 5)))); + } + return auftraege; + } + + private static @Nullable String feld(String[] felder, int index) { + if (index >= felder.length) { + return null; + } + var wert = felder[index].strip(); + return wert.isEmpty() || wert.equals(LEER) ? null : wert; + } + + // ---------------------------------------------------------------- Lauf + + /** + * Führt alle Aufträge aus. + * + * @param wurzel das Verzeichnis, auf das sich die Pfade der Liste beziehen. + * @param synopsen wohin die Einzelsynopsen zu legen sind; {@code null} = keine ablegen. + */ + public static List<Zeile> fuehreAus( + List<Auftrag> auftraege, Path wurzel, @Nullable Path synopsen) { + var zeilen = new ArrayList<Zeile>(auftraege.size()); + for (var auftrag : auftraege) { + zeilen.add(fuehreAus(auftrag, wurzel, synopsen)); + } + return zeilen; + } + + public static Zeile fuehreAus(Auftrag auftrag, Path wurzel, @Nullable Path synopsen) { + try { + var hefte = new ArrayList<Quelle>(auftrag.hefte().size()); + for (var heft : auftrag.hefte()) { + hefte.add(Quelle.lies(wurzel.resolve(heft))); + } + var ergebnis = + Pipeline.erzeugeSynopse( + new Pipeline.Auftrag( + Quelle.lies(wurzel.resolve(auftrag.stamm())), + hefte, + auftrag.artikel(), + false, + auftrag.stichtag(), + auftrag.nachfassung() == null + ? null + : Quelle.lies(wurzel.resolve(auftrag.nachfassung())))); + if (synopsen != null) { + Files.createDirectories(synopsen); + Files.writeString( + synopsen.resolve(dateiname(auftrag.bezeichnung()) + ".html"), + ergebnis.html(), + StandardCharsets.UTF_8); + } + var abgleich = ergebnis.abgleich(); + return new Zeile( + auftrag.bezeichnung(), + ergebnis.anzahlProtokollEintraege(), + ergebnis.anzahlAngewandt(), + ergebnis.anzahlManuell(), + // Was am Stichtag noch nicht galt, ist weder angewandt noch liegengeblieben. + ergebnis.anzahlProtokollEintraege() + - ergebnis.anzahlAngewandt() + - ergebnis.anzahlManuell(), + abgleich == null ? null : abgleich.gleich(), + abgleich == null ? null : abgleich.geprueft(), + abgleich == null ? null : abgleich.fehlende().size(), + abgleich == null ? null : abgleich.ueberzaehlige().size(), + abgleich == null ? null : abgleich.abweichungen().size(), + ergebnis.gruende(), + null); + } catch (Exception e) { + // Ein gescheiterter Auftrag hält den Lauf nicht an; sein Fehler steht in seiner Zeile. + return new Zeile( + auftrag.bezeichnung(), + 0, + 0, + 0, + 0, + null, + null, + null, + null, + null, + Map.of(), + e.getClass().getSimpleName() + ": " + e.getMessage()); + } + } + + private static String dateiname(String bezeichnung) { + return bezeichnung.replaceAll("[^\\p{L}\\p{N}]+", "-"); + } + + // ---------------------------------------------------------------- Ausgabe + + /** Der Bericht als Tabulatortabelle — die Form, in der er auch als Grundlinie liegt. */ + public static String alsTsv(List<Zeile> zeilen) { + var sb = new StringBuilder("# ").append(String.join("\t", SPALTEN)).append('\n'); + for (var zeile : zeilen) { + sb.append(zeile.bezeichnung()) + .append('\t') + .append(zeile.befehle()) + .append('\t') + .append(zeile.angewandt()) + .append('\t') + .append(zeile.manuell()) + .append('\t') + .append(zeile.zurueckgestellt()) + .append('\t') + .append(zahl(zeile.gleich())) + .append('\t') + .append(zahl(zeile.geprueft())) + .append('\t') + .append(zahl(zeile.fehlend())) + .append('\t') + .append(zahl(zeile.ueberzaehlig())) + .append('\t') + .append(zahl(zeile.abweichend())) + .append('\t') + .append(gruendeText(zeile.gruende())); + if (zeile.fehler() != null) { + sb.append('\t').append(zeile.fehler().replace('\t', ' ').replace('\n', ' ')); + } + sb.append('\n'); + } + return sb.toString(); + } + + private static String zahl(@Nullable Integer wert) { + return wert == null ? LEER : String.valueOf(wert); + } + + /** „Zieltext nicht vorhanden:3; Befehl nicht erkannt:1“ — in der Reihenfolge der Gründe. */ + static String gruendeText(Map<Grund, Integer> gruende) { + if (gruende.isEmpty()) { + return LEER; + } + var geordnet = new EnumMap<>(gruende); + var sb = new StringBuilder(); + for (var eintrag : geordnet.entrySet()) { + if (sb.length() > 0) { + sb.append("; "); + } + sb.append(eintrag.getKey().bezeichnung()).append(':').append(eintrag.getValue()); + } + return sb.toString(); + } + + /** + * Der Bericht als Übersichtsseite. Sie ist kein Ersatz für die Tabulatortabelle, sondern deren + * Lesefassung: Jede Zeile verweist auf die Synopse ihres Auftrags, sodass sich ein Rest an seinem + * eigenen Dokument nachschlagen lässt. + * + * @param verzeichnis das Verzeichnis der Einzelsynopsen, relativ zur Übersicht; {@code null} = + * ohne Verweise. + */ + public static String alsHtml(List<Zeile> zeilen, @Nullable String verzeichnis) { + var sb = new StringBuilder(); + sb.append("<!DOCTYPE html>\n<html lang=\"de\"><head><meta charset=\"utf-8\">\n") + .append("<title>Korpusbericht — ÄndGgner</title>\n") + .append("<style>\n") + .append("body { font-family: system-ui, sans-serif; margin: 2rem; max-width: 70rem; }\n") + .append("table { border-collapse: collapse; width: 100%; font-size: 0.85rem; }\n") + .append("th, td { border: 1px solid #999; padding: 0.25rem 0.5rem; text-align: right; }\n") + .append("th:first-child, td:first-child { text-align: left; }\n") + .append("td.gruende { text-align: left; font-size: 0.8rem; }\n") + .append("tr.rest td { background: #fff6f2; }\n") + .append("caption { text-align: left; padding-bottom: 0.5rem; }\n") + .append("</style></head><body>\n") + .append("<h1>Korpusbericht</h1>\n<p>") + .append(esc(summe(zeilen))) + .append("</p>\n<table><thead><tr>"); + for (var spalte : SPALTEN) { + sb.append("<th>").append(esc(spalte)).append("</th>"); + } + sb.append("</tr></thead><tbody>\n"); + for (var zeile : zeilen) { + sb.append(zeile.manuell() > 0 || zeile.fehler() != null ? "<tr class=\"rest\">" : "<tr>"); + sb.append("<td>"); + if (verzeichnis == null) { + sb.append(esc(zeile.bezeichnung())); + } else { + sb.append("<a href=\"") + .append(esc(verzeichnis)) + .append('/') + .append(esc(dateiname(zeile.bezeichnung()))) + .append(".html\">") + .append(esc(zeile.bezeichnung())) + .append("</a>"); + } + sb.append("</td>"); + for (var wert : + List.of( + String.valueOf(zeile.befehle()), + String.valueOf(zeile.angewandt()), + String.valueOf(zeile.manuell()), + String.valueOf(zeile.zurueckgestellt()), + zahl(zeile.gleich()), + zahl(zeile.geprueft()), + zahl(zeile.fehlend()), + zahl(zeile.ueberzaehlig()), + zahl(zeile.abweichend()))) { + sb.append("<td>").append(esc(wert)).append("</td>"); + } + sb.append("<td class=\"gruende\">") + .append(esc(zeile.fehler() != null ? zeile.fehler() : gruendeText(zeile.gruende()))) + .append("</td></tr>\n"); + } + return sb.append("</tbody></table>\n</body></html>\n").toString(); + } + + private static String esc(String text) { + return text.replace("&", "&") + .replace("<", "<") + .replace(">", ">") + .replace("\"", """); + } + + /** Die Summe über den Korpus — die Zahl, die eine Welle vor und nach sich nennt. */ + public static String summe(List<Zeile> zeilen) { + long befehle = zeilen.stream().mapToLong(Zeile::befehle).sum(); + long angewandt = zeilen.stream().mapToLong(Zeile::angewandt).sum(); + long manuell = zeilen.stream().mapToLong(Zeile::manuell).sum(); + long gleich = zeilen.stream().filter(z -> z.gleich() != null).mapToLong(Zeile::gleich).sum(); + long geprueft = + zeilen.stream().filter(z -> z.geprueft() != null).mapToLong(Zeile::geprueft).sum(); + long gescheitert = zeilen.stream().filter(z -> z.fehler() != null).count(); + var sb = + new StringBuilder( + "%d Aufträge, %d Befehle, %d angewandt, %d manuell; %d von %d Normen gleich" + .formatted(zeilen.size(), befehle, angewandt, manuell, gleich, geprueft)); + if (gescheitert > 0) { + sb.append("; %d Aufträge gescheitert".formatted(gescheitert)); + } + return sb.toString(); + } + + // ---------------------------------------------------------------- Grundlinie + + /** + * Hält den Lauf gegen die Grundlinie und meldet jede Kennzahl, die <em>gefallen</em> ist. + * + * <p>Nur der Rückschritt wird gerügt. Ein Fortschritt ist kein Fehler, sondern der Zweck der + * Arbeit; er verlangt allein, die Grundlinie fortzuschreiben. + * + * @return die Rügen; leer, wenn nichts gefallen ist. + */ + public static List<String> gegenGrundlinie(List<Zeile> lauf, String grundlinie) { + var alt = liesGrundlinie(grundlinie); + var ruegen = new ArrayList<String>(); + for (var zeile : lauf) { + if (zeile.fehler() != null) { + ruegen.add("%s: gescheitert — %s".formatted(zeile.bezeichnung(), zeile.fehler())); + continue; + } + var frueher = alt.get(zeile.bezeichnung()); + if (frueher == null) { + // Ein neuer Auftrag ist kein Rückschritt; die Grundlinie ist bloß noch nicht + // fortgeschrieben. + continue; + } + pruefe( + ruegen, zeile.bezeichnung(), "erschlossene Befehle", zeile.befehle(), frueher[1], true); + pruefe( + ruegen, zeile.bezeichnung(), "angewandte Befehle", zeile.angewandt(), frueher[2], true); + pruefe( + ruegen, + zeile.bezeichnung(), + "liegengebliebene Befehle", + zeile.manuell(), + frueher[3], + false); + pruefe(ruegen, zeile.bezeichnung(), "gleiche Normen", wert(zeile.gleich()), frueher[5], true); + pruefe( + ruegen, zeile.bezeichnung(), "fehlende Normen", wert(zeile.fehlend()), frueher[7], false); + pruefe( + ruegen, + zeile.bezeichnung(), + "überzählige Normen", + wert(zeile.ueberzaehlig()), + frueher[8], + false); + pruefe( + ruegen, + zeile.bezeichnung(), + "abweichende Normen", + wert(zeile.abweichend()), + frueher[9], + false); + } + for (var bezeichnung : alt.keySet()) { + if (lauf.stream().noneMatch(z -> z.bezeichnung().equals(bezeichnung))) { + ruegen.add( + "%s: steht in der Grundlinie, ist im Lauf aber nicht vorgekommen." + .formatted(bezeichnung)); + } + } + return ruegen; + } + + private static long wert(@Nullable Integer zahl) { + return zahl == null ? Long.MIN_VALUE : zahl; + } + + /** + * @param hoeherIstBesser ob ein Fallen der Zahl den Rückschritt bedeutet (angewandte Befehle, + * gleiche Normen) oder ihr Steigen (liegengebliebene, abweichende). + */ + private static void pruefe( + List<String> ruegen, + String bezeichnung, + String kennzahl, + long jetzt, + String frueherText, + boolean hoeherIstBesser) { + if (frueherText.equals(LEER) || jetzt == Long.MIN_VALUE) { + return; + } + long frueher; + try { + frueher = Long.parseLong(frueherText); + } catch (NumberFormatException e) { + return; + } + boolean rueckschritt = hoeherIstBesser ? jetzt < frueher : jetzt > frueher; + if (rueckschritt) { + ruegen.add("%s: %s %d statt %d.".formatted(bezeichnung, kennzahl, jetzt, frueher)); + } + } + + private static Map<String, String[]> liesGrundlinie(String tsv) { + var zeilen = new LinkedHashMap<String, String[]>(); + for (var zeile : tsv.split("\n")) { + if (zeile.isBlank() || zeile.startsWith("#")) { + continue; + } + var felder = zeile.split("\t"); + if (felder.length >= 10) { + zeilen.put(felder[0].strip(), felder); + } + } + return zeilen; + } +} diff --git a/src/test/java/eu/mulk/aendggner/bericht/KorpusberichtTest.java b/src/test/java/eu/mulk/aendggner/bericht/KorpusberichtTest.java new file mode 100644 index 0000000..e709337 --- /dev/null +++ b/src/test/java/eu/mulk/aendggner/bericht/KorpusberichtTest.java @@ -0,0 +1,134 @@ +// SPDX-FileCopyrightText: 2026 Matthias Andreas Benkard <code@mail.matthias.benkard.de> +// SPDX-License-Identifier: AGPL-3.0-or-later +package eu.mulk.aendggner.bericht; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.junit.jupiter.api.Assumptions.assumeTrue; + +import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.List; +import java.util.Map; +import org.junit.jupiter.api.Test; + +/** + * Der Massenlauf über den Beispielkorpus, gehalten gegen die eingecheckte Grundlinie. + * + * <p>Die Einzelprüfungen sagen, dass ein bestimmtes Heft aufgeht. Diese sagt, dass keines der + * einunddreißig Hefte hinter das zurückfällt, was es einmal hergegeben hat — und zwar in einer + * Zahl, die sich fortschreiben lässt, statt in Dutzenden verstreuter Behauptungen. + */ +class KorpusberichtTest { + + private static final Path SAMPLEDATA = Path.of("src/test/resources/sampledata"); + private static final Path LISTE = SAMPLEDATA.resolve("korpus.tsv"); + private static final Path GRUNDLINIE = SAMPLEDATA.resolve("korpus-grundlinie.tsv"); + + @Test + void derKorpusFaelltNichtHinterDieGrundlinieZurueck() throws Exception { + assumeTrue(Files.exists(LISTE) && Files.exists(GRUNDLINIE), "Korpusliste fehlt"); + var auftraege = Korpusbericht.liesListe(LISTE); + assumeTrue( + auftraege.stream().allMatch(a -> Files.exists(SAMPLEDATA.resolve(a.stamm()))), + "Beispieldaten des Korpus fehlen"); + + var zeilen = Korpusbericht.fuehreAus(auftraege, SAMPLEDATA.toAbsolutePath(), null); + + // Ein gescheiterter Auftrag hält den Lauf nicht an — unbemerkt bleiben darf er aber nicht; + // sonst meldete der Bericht lauter Nullen und niemand sähe den Grund. + assertThat(zeilen).hasSameSizeAs(auftraege); + assertThat(zeilen).allSatisfy(z -> assertThat(z.fehler()).isNull()); + + var ruegen = + Korpusbericht.gegenGrundlinie(zeilen, Files.readString(GRUNDLINIE, StandardCharsets.UTF_8)); + + assertThat(ruegen) + .as( + "Rückschritte gegenüber der Grundlinie; steht dagegen ein Fortschritt darin, so ist" + + " korpus-grundlinie.tsv fortzuschreiben:\n%s\n\nLauf:\n%s", + String.join("\n", ruegen), Korpusbericht.alsTsv(zeilen)) + .isEmpty(); + } + + @Test + void dieListeWirdVollstaendigGelesen() throws Exception { + assumeTrue(Files.exists(LISTE), "Korpusliste fehlt"); + var auftraege = Korpusbericht.liesListe(LISTE); + + assertThat(auftraege).isNotEmpty(); + // Kommentar- und Leerzeilen sind übergangen, „-“ heißt „nicht angegeben“. + assertThat(auftraege).noneMatch(a -> a.bezeichnung().startsWith("#")); + assertThat(auftraege).allSatisfy(a -> assertThat(a.hefte()).isNotEmpty()); + var mitArtikel = + auftraege.stream().filter(a -> "GEG-BGBl-zeitrichtig".equals(a.bezeichnung())).findFirst(); + assertThat(mitArtikel).isPresent(); + assertThat(mitArtikel.orElseThrow().artikel()).isEqualTo("1"); + assertThat(mitArtikel.orElseThrow().nachfassung()).isNull(); + var mehrereHefte = + auftraege.stream() + .filter(a -> "BayJG-mit-Aenderungsantrag".equals(a.bezeichnung())) + .findFirst() + .orElseThrow(); + assertThat(mehrereHefte.hefte()).hasSize(2); + var mitStichtag = + auftraege.stream() + .filter(a -> "IfSG-Stichtag".equals(a.bezeichnung())) + .findFirst() + .orElseThrow(); + assertThat(mitStichtag.stichtag()).isNotNull(); + } + + @Test + void nurDerRueckschrittWirdGeruegt() { + var grundlinie = + """ + # Bezeichnung\tBefehle\tangewandt\tmanuell\tzurückgestellt\tgleich\tgeprüft\tfehlend\tüberzählig\tabweichend\tGründe + Fall\t10\t8\t2\t0\t20\t22\t1\t0\t1\t- + """; + + var besser = + List.of(new Korpusbericht.Zeile("Fall", 10, 9, 1, 0, 21, 22, 0, 0, 1, Map.of(), null)); + assertThat(Korpusbericht.gegenGrundlinie(besser, grundlinie)).isEmpty(); + + var schlechter = + List.of(new Korpusbericht.Zeile("Fall", 10, 7, 3, 0, 19, 22, 1, 0, 2, Map.of(), null)); + assertThat(Korpusbericht.gegenGrundlinie(schlechter, grundlinie)) + .anySatisfy(r -> assertThat(r).contains("angewandte Befehle 7 statt 8")) + .anySatisfy(r -> assertThat(r).contains("liegengebliebene Befehle 3 statt 2")) + .anySatisfy(r -> assertThat(r).contains("gleiche Normen 19 statt 20")) + .anySatisfy(r -> assertThat(r).contains("abweichende Normen 2 statt 1")); + } + + @Test + void einGescheiterterAuftragWirdGeruegtStattUebergangen() { + var grundlinie = + """ + # Bezeichnung\tBefehle\tangewandt\tmanuell\tzurückgestellt\tgleich\tgeprüft\tfehlend\tüberzählig\tabweichend\tGründe + Fall\t10\t8\t2\t0\t-\t-\t-\t-\t-\t- + """; + + var gescheitert = + List.of( + new Korpusbericht.Zeile( + "Fall", 0, 0, 0, 0, null, null, null, null, null, Map.of(), "IOException: weg")); + assertThat(Korpusbericht.gegenGrundlinie(gescheitert, grundlinie)) + .singleElement() + .asString() + .contains("gescheitert"); + } + + @Test + void einVerschwundenerAuftragFaelltAuf() { + var grundlinie = + """ + # Bezeichnung\tBefehle\tangewandt\tmanuell\tzurückgestellt\tgleich\tgeprüft\tfehlend\tüberzählig\tabweichend\tGründe + Fall\t10\t8\t2\t0\t-\t-\t-\t-\t-\t- + """; + + assertThat(Korpusbericht.gegenGrundlinie(List.of(), grundlinie)) + .singleElement() + .asString() + .contains("ist im Lauf aber nicht vorgekommen"); + } +} diff --git a/src/test/resources/sampledata/korpus-grundlinie.tsv b/src/test/resources/sampledata/korpus-grundlinie.tsv new file mode 100644 index 0000000..60da4f6 --- /dev/null +++ b/src/test/resources/sampledata/korpus-grundlinie.tsv @@ -0,0 +1,44 @@ +# Grundlinie des Korpuslaufs (§ 6d des Handbuchs), Stand 28. August 2026. +# +# Jede Zeile hält fest, was ein Auftrag der Liste „korpus.tsv“ zu diesem Zeitpunkt hergegeben hat. +# Der Lauf wird dagegen gehalten (KorpusberichtTest, „--grundlinie“): Gerügt wird allein, was +# FÄLLT — weniger erschlossene oder angewandte Befehle, weniger gleiche Normen, mehr +# liegengebliebene, fehlende, überzählige oder abweichende. Ein Fortschritt ist kein Fehler, +# sondern der Zweck der Arbeit; er verlangt allein, diese Datei fortzuschreiben. +# +# Die hohen Reste bei GEG-Beschlussempfehlung, IfSG-Beschlussempfehlung und RP-UKAPOGS beruhen +# darauf, dass die Vorlage eine ältere Fassung fortschreibt als die eingesetzte Stammfassung +# („Zieltext nicht vorhanden“); sie sind kein Mangel des Erzeugnisses, wohl aber ein Wächter: +# Steigen sie, hat sich etwas verschlechtert. +# Bezeichnung Befehle angewandt manuell zurückgestellt gleich geprüft fehlend überzählig abweichend Gründe +UWG-BGBl 19 19 0 0 - - - - - - +UWG-RegE 20 20 0 0 - - - - - - +AGG-RegE 24 23 1 0 - - - - - Stelle nicht auffindbar:1 +ProdHaftG-RegE 1 1 0 0 - - - - - - +GEG-BGBl-zeitrichtig 119 119 0 0 - - - - - - +IfSG-BGBl-zeitrichtig 75 75 0 0 - - - - - - +BayJG-GVBl 154 154 0 0 - - - - - - +SN-SaechsBeamtVG 5 5 0 0 - - - - - - +NI-NEFG 7 7 0 0 - - - - - - +SH-Gemeindeordnung 1 1 0 0 167 167 0 0 0 - +SH-Kreisordnung 1 1 0 0 86 86 0 0 0 - +NRW-WDR-Gesetz 101 101 0 0 - - - - - - +NRW-LMG 18 18 0 0 - - - - - - +NRW-TMZ-Gesetz 4 4 0 0 - - - - - - +NRW-17-RAEStV-AusfG 1 1 0 0 - - - - - - +BE-ASOG 6 6 0 0 171 171 0 0 0 - +BE-LAF-ErrG 2 2 0 0 5 5 0 0 0 - +BW-KomWO 37 31 6 0 91 93 0 0 2 Befehl nicht erkannt:6 +HE-StVRZustV 21 21 0 0 52 52 0 0 0 - +TH-KiGaFinVO 10 10 0 0 - - - - - - +BB-FraktG 4 4 0 0 24 24 0 0 0 - +HH-GutachterausschussVO 21 17 4 0 10 14 0 0 4 Befehl nicht erkannt:2; Stelle nicht auffindbar:1; Zieltext nicht vorhanden:1 +HB-BerufsfachschulVO 41 37 4 0 26 29 1 0 2 Befehl nicht erkannt:3; Fundstelle mehrdeutig:1 +MV-APOAmtsTA 24 20 4 0 - - - - - Befehl nicht erkannt:4 +RP-UKAPOGS 23 9 14 0 - - - - - Stelle nicht auffindbar:2; Zieltext nicht vorhanden:10; Bestand widerspricht dem Befehl:2 +GEG-GModG-RegE 97 87 10 0 - - - - - Befehl nicht erkannt:6; Stelle nicht auffindbar:2; Zieltext nicht vorhanden:2 +GEG-Beschlussempfehlung 119 70 49 0 - - - - - Befehl nicht erkannt:1; Stelle nicht auffindbar:3; Zieltext nicht vorhanden:40; Fundstelle mehrdeutig:1; Bestand widerspricht dem Befehl:4 +IfSG-Beschlussempfehlung 75 51 24 0 - - - - - Befehl nicht erkannt:1; Stelle nicht auffindbar:1; Zieltext nicht vorhanden:21; Bestand widerspricht dem Befehl:1 +BayJG-Landtagsentwurf 154 154 0 0 - - - - - - +BayJG-mit-Aenderungsantrag 154 154 0 0 - - - - - - +IfSG-Stichtag 75 65 0 10 - - - - - - diff --git a/src/test/resources/sampledata/korpus.tsv b/src/test/resources/sampledata/korpus.tsv new file mode 100644 index 0000000..2832539 --- /dev/null +++ b/src/test/resources/sampledata/korpus.tsv @@ -0,0 +1,46 @@ +# Auftragsliste des Korpuslaufs (§ 6d des Handbuchs). +# +# Spalten, durch Tabulator getrennt: +# Bezeichnung | Stammfassung | Hefte (durch Komma) | Artikel | Nachfassung | Stichtag +# „-“ heißt: nicht angegeben. Die Pfade beziehen sich auf das Verzeichnis dieser Datei. +# +# Aufgenommen ist, was die Akzeptanzprüfungen des EndToEndTest fahren. Deren Zahlen müssen hier +# unverändert herauskommen; weichen sie ab, liegt der Fehler an der Liste und nicht am Erzeugnis. +# +# Bezeichnung Stamm Hefte Artikel Nachfassung Stichtag +UWG-BGBl UWG/BJNR141400004.xml UWG/bgbl126s0043_regelungstext.pdf - - - +UWG-RegE UWG/BJNR141400004.xml UWG/BT-Drs-21-1855_Regierungsentwurf.pdf - - - +AGG-RegE AGG/BJNR189710006.xml AGG/BT-Drs-21-6178_Regierungsentwurf.pdf - - - +ProdHaftG-RegE ProdHaftG/BJNR021980989.xml ProdHaftG/RegE_Produkthaftungsrecht_2025-12-17.pdf - - - +GEG-BGBl-zeitrichtig GEG/BJNR172810020-2023.xml GEG/bgbl123s0280_regelungstext.pdf 1 - - +IfSG-BGBl-zeitrichtig IfSG/BJNR104510000-2020.xml IfSG/bgbl120s2397_78991.pdf - - - +BayJG-GVBl BayJG/BayJG-alt.txt BayJG/gvbl-2026-06.pdf - - - +SN-SaechsBeamtVG Sachsen/SaechsBeamtVG-alt.txt Sachsen/SaechsGVBl-2026-S134_AendG-SaechsBeamtVG_revosax.pdf - - - +NI-NEFG Niedersachsen/NEFG-alt.txt Niedersachsen/Nds-GVBl-2026-10_ELER-Foerdergesetz-AendG.pdf - - - +SH-Gemeindeordnung SchleswigHolstein/GO-SH-alt.txt SchleswigHolstein/GVOBl-2026-27_Kommunalrecht-AendG.pdf 1 SchleswigHolstein/GO-SH-neu.txt - +SH-Kreisordnung SchleswigHolstein/KrO-SH-alt.txt SchleswigHolstein/GVOBl-2026-27_Kommunalrecht-AendG.pdf 2 SchleswigHolstein/KrO-SH-neu.txt - +NRW-WDR-Gesetz NRW/WDR-Gesetz-alt.txt NRW/GV-NRW-2026-S202_22-Rundfunkaenderungsgesetz.pdf - - - +NRW-LMG NRW/LMG-NRW-alt.txt NRW/GV-NRW-2026-S202_22-Rundfunkaenderungsgesetz.pdf - - - +NRW-TMZ-Gesetz NRW/TMZ-Gesetz-alt.txt NRW/GV-NRW-2026-S202_22-Rundfunkaenderungsgesetz.pdf - - - +NRW-17-RAEStV-AusfG NRW/17-RAEStV-AusfG-alt.txt NRW/GV-NRW-2026-S202_22-Rundfunkaenderungsgesetz.pdf - - - +BE-ASOG Berlin/ASOG-Bln-alt.txt Berlin/GVBl-2026-17_ASOG-LAF-AendG.pdf 1 Berlin/ASOG-Bln-neu.txt - +BE-LAF-ErrG Berlin/LAF-ErrG-alt.txt Berlin/GVBl-2026-17_ASOG-LAF-AendG.pdf 2 Berlin/LAF-ErrG-neu.txt - +BW-KomWO BadenWuerttemberg/KomWO-BW-alt.txt BadenWuerttemberg/GBl-2026-26_KommunalwahlO-AendVO.pdf - BadenWuerttemberg/KomWO-BW-neu.txt - +HE-StVRZustV Hessen/StVRZustV-alt.txt Hessen/GVBl-2026-05_11-AendVO-verkehrsrechtl-Zustaendigkeiten.pdf - Hessen/StVRZustV-neu.txt - +TH-KiGaFinVO Thueringen/ThuerKigaFinVO-alt.txt Thueringen/GVBl-TH-2026-02_KiGaFinanzVO-AendVO-ua.pdf - - - +BB-FraktG Brandenburg/FraktG-alt.txt Brandenburg/GVBl-I-2026-12_FraktG-AendG.pdf - Brandenburg/FraktG-neu.txt - +HH-GutachterausschussVO Hamburg/GAusschV-HH-alt.txt Hamburg/HmbGVBl-2026-17_GutachterausschussVO-AendVO.pdf - Hamburg/GAusschV-HH-neu.txt - +HB-BerufsfachschulVO Bremen/AssBerFSchulV-BR-alt.txt Bremen/BremGBl-2026-87_BerufsfachschulVO-AendVO.pdf - Bremen/AssBerFSchulV-BR-neu.txt - +MV-APOAmtsTA MecklenburgVorpommern/APOAmtsTA-MV-alt.txt MecklenburgVorpommern/GVOBl-MV-2026-21_Besoldungsanpassung-ua.pdf 44 - - +# Rheinland-Pfalz führt keine Vorfassungen; angewandt wird deshalb auf die Nachfassung, und +# aussagekräftig ist allein die Zahl der erschlossenen Befehle (23). Die liegengebliebenen sind +# die Folge des falschen Standes, nicht eines Mangels — siehe § 13 Absatz 4 des Handbuchs. +RP-UKAPOGS RheinlandPfalz/UKAPOGS-E3-neu.txt RheinlandPfalz/GVBl-2026-21_UKAPOGS-E3-AendVO.pdf - - - +# Entwürfe und Zusammenstellungen: hier gibt es keine Nachfassung, wohl aber die Frage, wie viel +# eine Vorlage hergibt. +GEG-GModG-RegE GEG/BJNR172810020.xml GEG/Regierungsentwurf_GModG_2026-05-13.pdf - - - +GEG-Beschlussempfehlung GEG/BJNR172810020.xml GEG/BT-Drs-20-7619_Beschlussempfehlung.pdf - - - +IfSG-Beschlussempfehlung IfSG/BJNR104510000.xml IfSG/1924334.pdf - - - +BayJG-Landtagsentwurf BayJG/BayJG-alt.txt BayJG/Ltg-Drs-19-9707_Gesetzentwurf.pdf - - - +BayJG-mit-Aenderungsantrag BayJG/BayJG-alt.txt BayJG/Ltg-Drs-19-9707_Gesetzentwurf.pdf,BayJG/Ltg-Drs-19-10365_Aenderungsantrag-Gruene.pdf - - - +IfSG-Stichtag IfSG/BJNR104510000-2020.xml IfSG/bgbl120s2397_78991.pdf - - 2020-11-19 |
