Files
Projektwissen/scripts/testkatalog.py
T
DanielandClaude Sonnet 5 d9d2542a20 Test-Audit einführen: Prüfstatus je Testfall im Prüfstand sichtbar machen
Ticket #16. docs/status/testpruefungen.json hält je Testfall Datum und
Kurzprüfsumme der letzten fachlichen Prüfung fest; scripts/testkatalog.py
liest das und zeigt es als eigene Spalte/Pille (nie geprüft / geprüft am …
/ veraltet seit Änderung …), unabhängig vom Baseline-Status. Neues Skript
scripts/test_audit.py (status/markieren) pflegt die Datei; erste Tests für
scripts/ unter scripts/tests/.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-16 10:23:45 +02:00

1073 lines
52 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
#!/usr/bin/env python3
"""Erzeugt aus den unittest-Suiten der sn_*-Plugins einen lesbaren Testkatalog.
Der Katalog (docs/tests/testkatalog.md) beantwortet die Frage "Was prüft die
Testsuite eigentlich?" – auch für Fachleute ohne Programmierkenntnisse. Er
listet je Testdatei jeden Testfall mit seiner Beschreibung (erste Docstring-
Zeile) und seinem Status auf und weist am Ende alle Tests ohne Beschreibung als
Arbeitsliste aus.
Die Testdateien werden mit dem ast-Modul der Standardbibliothek *geparst* und
bewusst NICHT importiert: sie importieren sn_basis und Geschwisterpakete, was
ohne den Verlinkungs-Trick von scripts/run_tests.py fehlschlägt, und optionale
Fremdpakete (z. B. openpyxl) sind nicht überall installiert. Dadurch läuft
dieses Skript mit jedem Python >= 3.11 ohne jede Abhängigkeit, auch ohne QGIS.
Der Status je Testfall stammt aus docs/status/testbaseline.json; dort stehen
unter <paket>.rot Einträge der Form "<methode> (<modulpfad>.<Klasse>.<methode>)".
Eine dritte Datenquelle ist docs/status/testpruefungen.json: sie hält je Testfall fest, wann er zuletzt von
einem Menschen fachlich geprüft wurde (Datum plus Kurzprüfsumme der Testfunktion, damit eine Änderung am
Testcode die Prüfung automatisch als veraltet kennzeichnet). Diese Datei wird hier nur gelesen; geschrieben
wird sie von scripts/test_audit.py.
Determinismus: Die erzeugte Datei wird versioniert, darf also bei unverändertem
Eingang keine Diffs erzeugen. Deshalb steht im Kopf weder Uhrzeit noch
Erzeugungsdatum – auch ein reines Tagesdatum würde die Datei bei jedem Lauf an
einem anderen Tag ändern, obwohl sich inhaltlich nichts geändert hat. Statt
dessen weist die Zeile "Stand der Baseline" die verwendete Baseline-Datei über
ihre SHA-256-Kurzprüfsumme aus: Sie ändert sich genau dann, wenn sich der
Eingang ändert. Sortiert wird nach der Reihenfolge in workspace.toml (Pakete),
nach Dateinamen (Testdateien) und nach der Reihenfolge im Quelltext (Klassen
und Testfälle).
Zusätzlich zum Markdown-Katalog schreibt der Lauf ein eigenständiges HTML-Dashboard
(docs/tests/testkatalog.html): dieselben Daten als Baumdiagramm je Testdatei
(Mermaid, geladen von cdnjs – ohne Internetverbindung bleiben die Diagramme leer,
Text und Tabellen bleiben lesbar), mit Filtern und Suche. Im Browser lokal öffnen,
kein Server nötig. Siehe docs/wissen/fallstricke.md.
Aufrufe (aus dem Workspace-Root):
python scripts/testkatalog.py # .md und .html neu schreiben
python scripts/testkatalog.py sn_basis # nur ein Paket
python scripts/testkatalog.py --pruefen # nur prüfen, ob beide Dateien aktuell sind (CI, vor dem Commit)
python scripts/testkatalog.py --ausgabe /tmp/k.md # anderes Ziel für den Markdown-Katalog
python scripts/testkatalog.py --html-ausgabe /tmp/k.html
python scripts/testkatalog.py --ohne-html # nur den Markdown-Katalog erzeugen/prüfen
Exit-Code: 0 = erfolgreich (mit --pruefen: Dateien sind aktuell), 1 = mindestens
eine Datei veraltet oder fehlt, 2 = Aufruffehler.
"""
from __future__ import annotations
import argparse
import ast
import difflib
import hashlib
import html
import json
import re
import tomllib
from dataclasses import dataclass, field
from pathlib import Path
ROOT = Path(__file__).resolve().parents[1]
MANIFEST = ROOT / "workspace.toml"
BASELINE_DEFAULT = ROOT / "docs" / "status" / "testbaseline.json"
PRUEFUNGEN_DEFAULT = ROOT / "docs" / "status" / "testpruefungen.json"
AUSGABE_DEFAULT = ROOT / "docs" / "tests" / "testkatalog.md"
HTML_AUSGABE_DEFAULT = ROOT / "docs" / "tests" / "testkatalog.html"
FEHLT = "— (Docstring fehlt)"
SPHINX_ROLLE = re.compile(r":(?:mod|func|class|meth|attr|data|obj|ref):`~?([^`]+)`")
SKIP_DEKORATOREN = {"skip", "skipIf", "skipUnless"}
ROLLENTEXT = {
"plattform": "Plattform-Plugin",
"fachplugin": "Fachplugin",
"geruest": "Gerüst",
"platzhalter": "Platzhalter",
"infrastruktur": "Infrastruktur",
"daten": "Daten",
}
@dataclass
class Testfall:
"""Eine einzelne Testmethode (test_*) einer TestCase-Klasse."""
klasse: str
name: str
beschreibung: str
uebersprungen: bool = False
grund: str = ""
erwarteter_fehlschlag: bool = False
status: str = "grün"
quellhash: str = ""
pruefstatus: str = "nie geprüft"
pruefdatum: str = ""
@property
def bezeichnung(self) -> str:
return f"{self.klasse}.{self.name}"
@dataclass
class Testgruppe:
"""Eine TestCase-Klasse mit ihren Testfällen."""
name: str
beschreibung: str
faelle: list[Testfall] = field(default_factory=list)
@dataclass
class Testdatei:
"""Eine Datei tests/test_*.py mit allen darin gefundenen Testgruppen."""
pfad: Path
anzeigepfad: str
modulpfad: str
beschreibung: str
gruppen: list[Testgruppe] = field(default_factory=list)
@property
def faelle(self) -> list[Testfall]:
return [f for g in self.gruppen for f in g.faelle]
@dataclass
class Paketkatalog:
"""Alle Testdateien eines Pakets samt Kennzahlen."""
paket: str
ordner: str
rolle: str
dateien: list[Testdatei] = field(default_factory=list)
@property
def faelle(self) -> list[Testfall]:
return [f for d in self.dateien for f in d.faelle]
def lade_manifest() -> dict:
with MANIFEST.open("rb") as f:
return tomllib.load(f)
def erste_zeile(docstring: str | None, dateiname: str = "") -> str:
"""Erste aussagekräftige Zeile eines Docstrings, sonst leer.
Viele Testdateien beginnen ihren Modul-Docstring mit dem eigenen Dateipfad
("sn_basis/tests/test_x.py"). Diese Zeile beschreibt nichts und wird
übersprungen.
"""
if not docstring:
return ""
for zeile in docstring.splitlines():
text = " ".join(zeile.split())
if not text:
continue
if dateiname and text.endswith(".py") and text.split("/")[-1] == dateiname:
continue
# Sphinx-Rollen wie :mod:`sn_basis.x` lesen sich für Fachleute schlecht.
return SPHINX_ROLLE.sub(r"`\1`", text)
return ""
def dekorator_name(knoten: ast.expr) -> str:
"""Punktierter Name eines Dekorators, z. B. 'unittest.skipUnless'."""
ziel = knoten.func if isinstance(knoten, ast.Call) else knoten
teile: list[str] = []
while isinstance(ziel, ast.Attribute):
teile.append(ziel.attr)
ziel = ziel.value
if isinstance(ziel, ast.Name):
teile.append(ziel.id)
return ".".join(reversed(teile))
def lies_skip(dekoratoren: list[ast.expr]) -> tuple[bool, str, bool]:
"""Wertet skip-Dekoratoren aus: (übersprungen, Grund, erwarteter Fehlschlag)."""
uebersprungen = False
erwartet = False
grund = ""
for dek in dekoratoren:
kurz = dekorator_name(dek).rsplit(".", 1)[-1]
if kurz == "expectedFailure":
erwartet = True
elif kurz in SKIP_DEKORATOREN:
uebersprungen = True
argumente = dek.args if isinstance(dek, ast.Call) else []
texte = [a.value for a in argumente if isinstance(a, ast.Constant) and isinstance(a.value, str)]
if texte and not grund:
grund = " ".join(texte[0].split())
if not grund:
grund = f"@unittest.{kurz}"
return uebersprungen, grund, erwartet
def _funktionsquelle(zeilen: list[str], knoten: ast.FunctionDef | ast.AsyncFunctionDef) -> str:
"""Quelltext einer Testmethode inkl. eigener Dekoratoren, Trailing Whitespace entfernt.
Trailing Whitespace wird bewusst ignoriert, damit reine Whitespace-Nacharbeit keine Prüfung
veraltet macht. Einrückung und sonstiger Inhalt bleiben signifikant.
"""
start = min([knoten.lineno] + [d.lineno for d in knoten.decorator_list])
ende = knoten.end_lineno or knoten.lineno
return "\n".join(zeile.rstrip() for zeile in zeilen[start - 1:ende])
def _funktionshash(zeilen: list[str], knoten: ast.FunctionDef | ast.AsyncFunctionDef) -> str:
"""SHA-256-Kurzprüfsumme (12 Zeichen) einer Testmethode, für den Abgleich in testpruefungen.json."""
return hashlib.sha256(_funktionsquelle(zeilen, knoten).encode("utf-8")).hexdigest()[:12]
def ist_testcase(klasse: ast.ClassDef) -> bool:
"""True für Klassen, die als unittest-TestCase gelten.
Erkannt werden Basisklassen, deren Name auf 'TestCase' endet (unittest.TestCase,
eigene Basisklassen), sowie – als Auffangnetz – Klassen mit Namen 'Test…', die
mindestens eine test_*-Methode haben.
"""
for basis in klasse.bases:
name = dekorator_name(basis)
if name.rsplit(".", 1)[-1].endswith("TestCase"):
return True
hat_tests = any(
isinstance(m, (ast.FunctionDef, ast.AsyncFunctionDef)) and m.name.startswith("test")
for m in klasse.body
)
return klasse.name.startswith("Test") and hat_tests
def lies_testdatei(pfad: Path, paket: str) -> Testdatei:
"""Parst eine Testdatei per ast (ohne sie zu importieren)."""
quelle = pfad.read_text(encoding="utf-8")
zeilen = quelle.splitlines()
baum = ast.parse(quelle, filename=str(pfad))
datei = Testdatei(
pfad=pfad,
anzeigepfad=pfad.relative_to(ROOT).as_posix(),
modulpfad=f"{paket}.tests.{pfad.stem}",
beschreibung=erste_zeile(ast.get_docstring(baum), pfad.name),
)
for knoten in baum.body:
if not isinstance(knoten, ast.ClassDef) or not ist_testcase(knoten):
continue
klassen_skip, klassen_grund, klassen_erwartet = lies_skip(knoten.decorator_list)
gruppe = Testgruppe(name=knoten.name, beschreibung=erste_zeile(ast.get_docstring(knoten)))
for glied in knoten.body:
if not isinstance(glied, (ast.FunctionDef, ast.AsyncFunctionDef)):
continue
if not glied.name.startswith("test"):
continue
skip, grund, erwartet = lies_skip(glied.decorator_list)
gruppe.faelle.append(
Testfall(
klasse=knoten.name,
name=glied.name,
beschreibung=erste_zeile(ast.get_docstring(glied)),
uebersprungen=skip or klassen_skip,
grund=grund or (klassen_grund if klassen_skip else ""),
erwarteter_fehlschlag=erwartet or klassen_erwartet,
quellhash=_funktionshash(zeilen, glied),
)
)
if gruppe.faelle:
datei.gruppen.append(gruppe)
return datei
def sammle_paket(paket: str, repo: dict) -> Paketkatalog | None:
"""Liest alle tests/test_*.py eines Pakets; None, wenn kein tests/-Ordner da ist."""
ordner = ROOT / repo["name"] / "tests"
if not ordner.is_dir():
return None
katalog = Paketkatalog(paket=paket, ordner=repo["name"], rolle=repo.get("rolle", ""))
for pfad in sorted(ordner.glob("test_*.py"), key=lambda p: p.name):
katalog.dateien.append(lies_testdatei(pfad, paket))
return katalog
def voller_testpfad(datei: Testdatei, fall: Testfall) -> str:
"""Voller Schlüssel eines Testfalls – identisch zum Format in testbaseline.json/testpruefungen.json."""
return f"{datei.modulpfad}.{fall.bezeichnung}"
def lies_baseline(pfad: Path) -> tuple[dict[str, set[str]], str]:
"""Rote Tests je Paket als Menge '<modulpfad>.<Klasse>.<methode>' plus Kurzprüfsumme."""
if not pfad.exists():
return {}, ""
rohdaten = pfad.read_bytes()
kurz = hashlib.sha256(rohdaten).hexdigest()[:12]
daten = json.loads(rohdaten.decode("utf-8"))
rot: dict[str, set[str]] = {}
muster = re.compile(r"^(?P<methode>\S+)\s+\((?P<pfad>[\w.]+)\)$")
for paket, eintrag in daten.items():
schluessel: set[str] = set()
for zeile in eintrag.get("rot", []):
treffer = muster.match(zeile.strip())
schluessel.add(treffer.group("pfad") if treffer else zeile.strip())
rot[paket] = schluessel
return rot, kurz
def setze_status(katalog: Paketkatalog, rot: set[str]) -> None:
for datei in katalog.dateien:
for fall in datei.faelle:
voll = voller_testpfad(datei, fall)
if fall.uebersprungen:
fall.status = "übersprungen"
elif fall.erwarteter_fehlschlag:
fall.status = "erwarteter Fehlschlag"
elif voll in rot:
fall.status = "rot (Baseline)"
else:
fall.status = "grün"
def lies_pruefungen(pfad: Path) -> tuple[dict[str, dict], str]:
"""Prüf-Historie je Testfall plus Kurzprüfsumme der Datei; ({}, "") wenn sie fehlt."""
if not pfad.exists():
return {}, ""
rohdaten = pfad.read_bytes()
kurz = hashlib.sha256(rohdaten).hexdigest()[:12]
return json.loads(rohdaten.decode("utf-8")), kurz
def setze_pruefstatus(katalog: Paketkatalog, pruefungen: dict[str, dict]) -> None:
for datei in katalog.dateien:
for fall in datei.faelle:
eintrag = pruefungen.get(voller_testpfad(datei, fall))
if eintrag is None:
fall.pruefstatus, fall.pruefdatum = "nie geprüft", ""
elif eintrag.get("hash") == fall.quellhash:
fall.pruefstatus, fall.pruefdatum = "aktuell geprüft", eintrag.get("datum", "")
else:
fall.pruefstatus, fall.pruefdatum = "veraltet", eintrag.get("datum", "")
def zaehle(faelle: list[Testfall]) -> dict[str, int]:
return {
"gesamt": len(faelle),
"gruen": sum(1 for f in faelle if f.status == "grün"),
"rot": sum(1 for f in faelle if f.status == "rot (Baseline)"),
"uebersprungen": sum(1 for f in faelle if f.status in ("übersprungen", "erwarteter Fehlschlag")),
"erwartet": sum(1 for f in faelle if f.status == "erwarteter Fehlschlag"),
"mit_docstring": sum(1 for f in faelle if f.beschreibung),
"nie_geprueft": sum(1 for f in faelle if f.pruefstatus == "nie geprüft"),
"aktuell_geprueft": sum(1 for f in faelle if f.pruefstatus == "aktuell geprüft"),
"veraltet_geprueft": sum(1 for f in faelle if f.pruefstatus == "veraltet"),
}
def anteil(teil: int, ganz: int) -> str:
if not ganz:
return "0 von 0 (–)"
return f"{teil} von {ganz} ({round(100 * teil / ganz)} %)"
def zelle(text: str) -> str:
"""Macht einen Text tabellensicher (Pipes maskieren, Zeilenumbrüche entfernen)."""
return " ".join(text.split()).replace("|", "\\|") or FEHLT
def status_text(fall: Testfall) -> str:
if fall.status == "übersprungen" and fall.grund:
return f"übersprungen – {zelle(fall.grund)}"
return fall.status
def pruef_text(fall: Testfall) -> str:
"""Anzeigetext für den Prüfstatus (Prüfstand-Spalte 'geprüft')."""
if fall.pruefstatus == "aktuell geprüft":
return f"geprüft am {fall.pruefdatum}"
if fall.pruefstatus == "veraltet":
return f"veraltet seit Änderung – zuletzt geprüft am {fall.pruefdatum}"
return "nie geprüft"
def erzeuge_markdown(
kataloge: list[Paketkatalog],
baseline_pfad: Path,
baseline_kurz: str,
pruefungen_pfad: Path,
pruefungen_kurz: str,
) -> str:
alle = [f for k in kataloge for f in k.faelle]
summe = zaehle(alle)
baseline_anzeige = baseline_pfad.relative_to(ROOT).as_posix() if baseline_pfad.is_relative_to(ROOT) else str(baseline_pfad)
pruefungen_anzeige = (
pruefungen_pfad.relative_to(ROOT).as_posix() if pruefungen_pfad.is_relative_to(ROOT) else str(pruefungen_pfad)
)
z: list[str] = []
z.append("# Testkatalog der sn_*-Plugins")
z.append("")
z.append("> **Automatisch erzeugt von `scripts/testkatalog.py` – nicht von Hand bearbeiten.**")
z.append("> Neu erzeugen: `python scripts/testkatalog.py`. Prüfen (CI, vor dem Commit): `python scripts/testkatalog.py --pruefen`.")
z.append("")
z.append("Dieser Katalog zeigt ohne Blick in den Quelltext, **was** die Testsuite prüft. Er ist die Grundlage")
z.append("für die fachliche Frage, ob die Abdeckung sinnvoll ist. Die Angaben stammen aus den Testdateien")
z.append("selbst (Docstrings) und aus der Testbaseline.")
z.append("")
if baseline_kurz:
z.append(f"Stand der Baseline: `{baseline_anzeige}` (SHA-256 `{baseline_kurz}`)")
else:
z.append(f"Stand der Baseline: `{baseline_anzeige}` **nicht gefunden** – alle Testfälle gelten als grün.")
if pruefungen_kurz:
z.append(f"Stand des Prüf-Audits: `{pruefungen_anzeige}` (SHA-256 `{pruefungen_kurz}`)")
else:
z.append(
f"Stand des Prüf-Audits: `{pruefungen_anzeige}` **nicht gefunden** – alle Testfälle gelten als nie geprüft."
)
z.append("")
z.append(
f"Gesamt: {len(kataloge)} Pakete, {sum(len(k.dateien) for k in kataloge)} Testdateien, "
f"{summe['gesamt']} Testfälle – {summe['gruen']} grün, {summe['rot']} rot (Baseline), "
f"{summe['uebersprungen']} übersprungen."
)
z.append(f"Mit Beschreibung (Docstring): {anteil(summe['mit_docstring'], summe['gesamt'])}.")
z.append(
f"Fachlich geprüft: {anteil(summe['aktuell_geprueft'], summe['gesamt'])} aktuell, "
f"{summe['veraltet_geprueft']} veraltet seit Codeänderung, {summe['nie_geprueft']} nie geprüft."
)
z.append("")
z.append("## Zusammenfassung")
z.append("")
z.append("| Paket | Testdateien | Testfälle | grün | rot (Baseline) | übersprungen | mit Beschreibung | fachlich geprüft |")
z.append("| --- | ---: | ---: | ---: | ---: | ---: | ---: | ---: |")
for katalog in kataloge:
k = zaehle(katalog.faelle)
z.append(
f"| `{katalog.paket}` | {len(katalog.dateien)} | {k['gesamt']} | {k['gruen']} | "
f"{k['rot']} | {k['uebersprungen']} | {anteil(k['mit_docstring'], k['gesamt'])} | "
f"{anteil(k['aktuell_geprueft'], k['gesamt'])} |"
)
z.append(
f"| **Summe** | **{sum(len(k.dateien) for k in kataloge)}** | **{summe['gesamt']}** | "
f"**{summe['gruen']}** | **{summe['rot']}** | **{summe['uebersprungen']}** | "
f"**{anteil(summe['mit_docstring'], summe['gesamt'])}** | "
f"**{anteil(summe['aktuell_geprueft'], summe['gesamt'])}** |"
)
z.append("")
if summe["erwartet"]:
z.append(f"In Spalte „übersprungen“ sind {summe['erwartet']} Testfälle mit `@unittest.expectedFailure` enthalten.")
z.append("")
z.append("### Legende")
z.append("")
z.append("- **prüft** – erste Zeile des Docstrings des Testfalls. Fehlt sie, steht dort `" + FEHLT + "`.")
z.append("- **grün** – der Testfall lief im Baseline-Lauf durch.")
z.append(f"- **rot (Baseline)** – der Testfall schlägt bereits fehl und ist in `{baseline_anzeige}` vermerkt; er darf nicht ignoriert, aber auch nicht als neuer Fehler gewertet werden.")
z.append("- **übersprungen** – per `@unittest.skip`/`skipIf`/`skipUnless` abgeschaltet; der Grund steht dabei.")
z.append("- Ein Testfall heißt `Testgruppe.test_name`; die Testgruppe fasst zusammengehörige Testfälle einer Datei zusammen.")
z.append(
"- **geprüft** – Prüf-Audit (`scripts/test_audit.py`): **nie geprüft** kein Eintrag in "
f"`{pruefungen_anzeige}`; **geprüft am …** ein Mensch hat den Testfall bestätigt und der Testcode hat sich "
"seither nicht geändert; **veraltet seit Änderung …** der Testcode wurde nach der letzten Prüfung geändert."
)
z.append("")
for katalog in kataloge:
k = zaehle(katalog.faelle)
rolle = ROLLENTEXT.get(katalog.rolle, katalog.rolle or "Plugin")
z.append(f"## Paket `{katalog.paket}`")
z.append("")
z.append(
f"{rolle}, Ordner `{katalog.ordner}` – {len(katalog.dateien)} Testdateien mit {k['gesamt']} Testfällen "
f"({k['gruen']} grün, {k['rot']} rot, {k['uebersprungen']} übersprungen)."
)
z.append("")
if not katalog.dateien:
z.append("Keine Testdateien gefunden.")
z.append("")
continue
for datei in katalog.dateien:
z.append(f"### `{datei.anzeigepfad}`")
z.append("")
z.append(f"Worum es in der Datei geht: {zelle(datei.beschreibung)}")
z.append("")
if not datei.gruppen:
z.append("Keine Testfälle: Die Datei enthält keine `unittest.TestCase`-Klasse – sie ist eine reine "
"Hilfsdatei, die beim Testlauf nur importiert wird.")
z.append("")
continue
z.append("Testgruppen:")
z.append("")
for gruppe in datei.gruppen:
z.append(f"- `{gruppe.name}` ({len(gruppe.faelle)} Testfälle): {zelle(gruppe.beschreibung)}")
z.append("")
z.append("| Testfall | prüft | Status | geprüft |")
z.append("| --- | --- | --- | --- |")
for fall in datei.faelle:
z.append(
f"| `{fall.bezeichnung}` | {zelle(fall.beschreibung)} | {status_text(fall)} | {pruef_text(fall)} |"
)
z.append("")
z.append("## Lücken")
z.append("")
z.append("Arbeitsliste: Diese Testfälle haben keinen Docstring; was sie fachlich prüfen, lässt sich nur")
z.append("aus dem Quelltext erschließen. Jeder Eintrag braucht eine einzeilige Beschreibung im Testfall,")
z.append("dann verschwindet er hier von selbst.")
z.append("")
for katalog in kataloge:
k = zaehle(katalog.faelle)
offen = k["gesamt"] - k["mit_docstring"]
z.append(f"### `{katalog.paket}` – {offen} von {k['gesamt']} Testfällen ohne Beschreibung")
z.append("")
if not offen:
z.append("Keine Lücken.")
z.append("")
continue
for datei in katalog.dateien:
luecken = [f for f in datei.faelle if not f.beschreibung]
if not luecken:
continue
hinweis = "" if datei.beschreibung else " – auch der Modul-Docstring fehlt"
z.append(f"#### `{datei.anzeigepfad}` ({len(luecken)} von {len(datei.faelle)}){hinweis}")
z.append("")
for fall in luecken:
z.append(f"- `{fall.bezeichnung}`")
z.append("")
return "\n".join(z) + "\n"
STATUS_KLASSE = {
"grün": "ok",
"rot (Baseline)": "warn",
"übersprungen": "skip",
"erwarteter Fehlschlag": "skip",
}
PRUEF_KLASSE = {
"aktuell geprüft": "ok",
"veraltet": "warn",
"nie geprüft": "skip",
}
def _slug(text: str) -> str:
return re.sub(r"[^a-zA-Z0-9]+", "-", text).strip("-").lower()
def _mermaid_fuer_datei(datei: Testdatei) -> str:
"""Baumdiagramm Datei -> Testgruppe -> Testfall, Farbe = Baseline-Status."""
zeilen = ["flowchart TD", f' root(["{datei.pfad.name}"])', " class root rootnode"]
klassenzeilen: list[str] = []
gi = 0
ti = 0
for gruppe in datei.gruppen:
gi += 1
gid = f"g{gi}"
zeilen.append(f' root --> {gid}["{gruppe.name} ({len(gruppe.faelle)})"]')
zeilen.append(f" class {gid} groupnode")
for fall in gruppe.faelle:
ti += 1
tid = f"t{ti}"
name = fall.name[5:] if fall.name.startswith("test_") else fall.name
zeilen.append(f' {gid} --> {tid}("{name}")')
klassenzeilen.append(f" class {tid} {STATUS_KLASSE.get(fall.status, 'ok')}")
zeilen.extend(klassenzeilen)
zeilen.append(" classDef rootnode fill:#1f4038,stroke:#16302a,stroke-width:1px,color:#f4f1e8,font-weight:600;")
zeilen.append(" classDef groupnode fill:#cfe0da,stroke:#5c8a7d,stroke-width:1px,color:#152420;")
zeilen.append(" classDef ok fill:#dcefdc,stroke:#3f7d4a,stroke-width:1px,color:#1c3a20;")
zeilen.append(" classDef warn fill:#f6ead9,stroke:#b5722a,stroke-width:1px,color:#4a2c08;")
zeilen.append(" classDef skip fill:#e6e6ee,stroke:#6f7285,stroke-width:1px,color:#2b2d38;")
return "\n".join(zeilen)
def _html_karte_fuer_datei(datei: Testdatei) -> str:
fid = _slug(datei.anzeigepfad)
faelle = datei.faelle
gesamt = len(faelle)
zaehler = {"ok": 0, "warn": 0, "skip": 0}
for fall in faelle:
zaehler[STATUS_KLASSE.get(fall.status, "ok")] += 1
mit_doc = sum(1 for f in faelle if f.beschreibung)
doc_pct = round(100 * mit_doc / gesamt) if gesamt else 0
aktuell_geprueft = sum(1 for f in faelle if f.pruefstatus == "aktuell geprüft")
audit_pct = round(100 * aktuell_geprueft / gesamt) if gesamt else 0
hat_ungeprueft = any(f.pruefstatus == "nie geprüft" for f in faelle)
hat_veraltet = any(f.pruefstatus == "veraltet" for f in faelle)
if not datei.gruppen:
koerper = (
f'<p class="filedesc">{html.escape(datei.beschreibung) if datei.beschreibung else "— (Docstring fehlt)"}</p>'
'<p class="empty-note">Keine Testfälle: Die Datei enthält keine <span class="mono">unittest.TestCase</span>'
"-Klasse – sie ist eine reine Hilfsdatei, die beim Testlauf nur importiert wird.</p>"
)
pills = ""
else:
pills = "".join(
f'<span class="cpill cpill-{k}">{v}</span>' for k, v in zaehler.items() if v
)
gchips = "".join(
f'<span class="gchip" title="{html.escape(g.beschreibung)}">{html.escape(g.name)} '
f"<b>{len(g.faelle)}</b></span>"
for g in datei.gruppen
)
zeilen = []
kurz = {"grün": "grün", "rot (Baseline)": "rot", "übersprungen": "übersp.", "erwarteter Fehlschlag": "übersp."}
for fall in faelle:
klasse = STATUS_KLASSE.get(fall.status, "ok")
pruef_klasse = PRUEF_KLASSE.get(fall.pruefstatus, "skip")
pruft = (
f'<span class="nodoc">— (Docstring fehlt)</span>'
if not fall.beschreibung
else html.escape(fall.beschreibung)
)
titel = f' title="{html.escape(fall.grund)}"' if fall.grund else ""
zeilen.append(
f'<tr><td class="mono">{html.escape(fall.klasse)}<br>'
f'<span class="testname">{html.escape(fall.name)}</span></td>'
f"<td>{pruft}</td>"
f'<td><span class="pill pill-{klasse}"{titel}>{kurz.get(fall.status, fall.status)}</span></td>'
f'<td><span class="pill pill-pr-{pruef_klasse}">{html.escape(pruef_text(fall))}</span></td></tr>'
)
tabelle = (
'<table class="test-table"><thead><tr><th>Testfall</th><th>prüft</th><th>Status</th>'
"<th>geprüft</th></tr></thead>"
f"<tbody>{''.join(zeilen)}</tbody></table>"
)
diagramm = (
'<div class="diagram-wrap"><pre class="mermaid">'
+ html.escape(_mermaid_fuer_datei(datei))
+ "</pre></div>"
)
koerper = (
f'<p class="filedesc">{html.escape(datei.beschreibung) if datei.beschreibung else "— (Docstring fehlt)"}</p>'
f'<div class="gchips">{gchips}</div>{diagramm}{tabelle}'
)
suchtext = html.escape(
(datei.anzeigepfad + " " + " ".join(f"{f.klasse} {f.name}" for f in faelle)).lower()
)
header = (
f'<button class="card-toggle" data-target="{fid}" aria-expanded="false">'
f'<span class="card-path mono">{html.escape(datei.anzeigepfad)}</span>'
'<span class="card-meta">'
f'<span class="count-total">{gesamt} Testfälle</span>{pills}'
f'<span class="docbadge" title="Anteil mit Docstring">{doc_pct}% beschrieben</span>'
f'<span class="auditbadge" title="Anteil fachlich aktuell geprüft">{audit_pct}% geprüft</span>'
'<span class="chev" aria-hidden="true">▾</span></span></button>'
)
return (
f'<article class="filecard" data-search="{suchtext}" '
f'data-hasred="{1 if zaehler["warn"] else 0}" data-hasskip="{1 if zaehler["skip"] else 0}" '
f'data-nodoc="{1 if doc_pct < 50 else 0}" '
f'data-ungeprueft="{1 if hat_ungeprueft else 0}" data-veraltet="{1 if hat_veraltet else 0}">'
f"{header}"
f'<div class="card-body" id="body-{fid}">{koerper}</div>'
"</article>"
)
def _html_abschnitt_fuer_paket(katalog: Paketkatalog) -> str:
k = zaehle(katalog.faelle)
rolle = ROLLENTEXT.get(katalog.rolle, katalog.rolle or "Plugin")
karten = "".join(_html_karte_fuer_datei(d) for d in katalog.dateien) or "<p>Keine Testdateien gefunden.</p>"
gesamt = k["gesamt"] or 1
ok_pct = round(100 * k["gruen"] / gesamt)
warn_pct = round(100 * k["rot"] / gesamt)
skip_pct = max(0, 100 - ok_pct - warn_pct)
return f"""
<section class="pkg-section" id="pkg-{html.escape(katalog.paket)}">
<div class="pkg-head">
<div>
<h2>{html.escape(katalog.paket)}</h2>
<p class="pkg-sub">{html.escape(rolle)} · Ordner <span class="mono">{html.escape(katalog.ordner)}</span> · {len(katalog.dateien)} Testdateien · {k['gesamt']} Testfälle</p>
</div>
<div class="pkg-bar" role="img" aria-label="{k['gruen']} grün, {k['rot']} rot, {k['uebersprungen']} übersprungen">
<span class="seg seg-ok" style="width:{ok_pct}%"></span><span class="seg seg-warn" style="width:{warn_pct}%"></span><span class="seg seg-skip" style="width:{skip_pct}%"></span>
</div>
</div>
<div class="card-grid">{karten}</div>
</section>
"""
_HTML_KOPF = """<title>Prüfstand</title>
<meta charset="utf-8">
<link rel="icon" href="data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 100 100'%3E%3Ctext y='.9em' font-size='90'%3E%F0%9F%93%90%3C/text%3E%3C/svg%3E">
<link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link rel="stylesheet" href="https://fonts.googleapis.com/css2?family=Archivo:wght@500;600;700;800&family=Source+Sans+3:wght@400;500;600;700&family=IBM+Plex+Mono:wght@400;500;600&display=swap">
<style>
:root{
--bg: #f3f0e6; --bg-plan: #f6f4ea; --surface: #fffdf7; --surface-2: #ece7d8;
--ink: #211f18; --ink-soft: #4a4739; --muted: #7a7460; --line: #dcd5c0; --line-strong: #c7bea3;
--accent: #2f5b52; --accent-strong: #1c3b34; --accent-tint: #e2ece7;
--ok: #3f7d4a; --ok-bg: #e5f1e2; --warn: #a8641f; --warn-bg: #f7ead6; --skip: #5d6072; --skip-bg: #e8e8ee;
--shadow: 0 1px 2px rgba(33,31,24,.06), 0 4px 14px rgba(33,31,24,.05); --focus: #1c3b34;
color-scheme: light dark;
}
@media (prefers-color-scheme: dark){
:root:not([data-theme="light"]){
--bg:#1a1a16; --bg-plan:#211f18; --surface:#232019; --surface-2:#2b2820; --ink:#efe9d8; --ink-soft:#cdc6b0;
--muted:#97917c; --line:#3a3628; --line-strong:#4c4732; --accent:#7fb3a2; --accent-strong:#9cc9ba;
--accent-tint:#263530; --ok:#7fb386; --ok-bg:#223226; --warn:#d6a05e; --warn-bg:#3a2d1c; --skip:#a8abbd;
--skip-bg:#2a2b35; --shadow:0 1px 2px rgba(0,0,0,.3),0 6px 18px rgba(0,0,0,.35); --focus:#cfe6dc;
}
}
:root[data-theme="dark"]{
--bg:#1a1a16; --bg-plan:#211f18; --surface:#232019; --surface-2:#2b2820; --ink:#efe9d8; --ink-soft:#cdc6b0;
--muted:#97917c; --line:#3a3628; --line-strong:#4c4732; --accent:#7fb3a2; --accent-strong:#9cc9ba;
--accent-tint:#263530; --ok:#7fb386; --ok-bg:#223226; --warn:#d6a05e; --warn-bg:#3a2d1c; --skip:#a8abbd;
--skip-bg:#2a2b35; --shadow:0 1px 2px rgba(0,0,0,.3),0 6px 18px rgba(0,0,0,.35); --focus:#cfe6dc;
}
*{ box-sizing:border-box; }
html,body{ margin:0; }
body{ background:var(--bg); color:var(--ink); font-family:"Source Sans 3",system-ui,-apple-system,sans-serif;
font-size:15px; line-height:1.5; padding-inline:20px; }
h1,h2,h3{ font-family:"Archivo",system-ui,sans-serif; text-wrap:balance; margin:0; }
.mono{ font-family:"IBM Plex Mono",ui-monospace,monospace; }
a{ color:var(--accent-strong); }
.topbar{ max-width:1180px; margin:0 auto; padding-block:28px 18px; display:flex; flex-wrap:wrap; gap:16px 28px;
align-items:flex-end; justify-content:space-between; border-bottom:2px solid var(--line-strong); }
.brand{ display:flex; align-items:baseline; gap:14px; flex-wrap:wrap; }
.brand h1{ font-size:clamp(28px,4vw,38px); font-weight:800; letter-spacing:-0.01em; color:var(--accent-strong); }
.brand .tagline{ color:var(--muted); font-size:14.5px; max-width:46ch; }
.meta-line{ color:var(--muted); font-size:12.5px; text-align:right; }
.meta-line b{ color:var(--ink-soft); font-weight:600; }
.summary{ max-width:1180px; margin:22px auto 0; display:grid; gap:14px; grid-template-columns:repeat(auto-fit,minmax(150px,1fr)); }
.stat{ background:var(--surface); border:1px solid var(--line); border-radius:10px; padding:14px 16px; box-shadow:var(--shadow); }
.stat .n{ font-family:"IBM Plex Mono",monospace; font-variant-numeric:tabular-nums; font-size:26px; font-weight:600; color:var(--ink); display:block; }
.stat .l{ font-size:12px; text-transform:uppercase; letter-spacing:.06em; color:var(--muted); }
.stat.ok .n{ color:var(--ok); } .stat.warn .n{ color:var(--warn); } .stat.skip .n{ color:var(--skip); }
.pkg-bars{ max-width:1180px; margin:16px auto 0; background:var(--surface); border:1px solid var(--line);
border-radius:10px; padding:14px 18px; box-shadow:var(--shadow); display:grid; gap:10px; }
.pkg-bars-title{ font-size:12px; text-transform:uppercase; letter-spacing:.06em; color:var(--muted); margin-bottom:2px; }
.pkg-bar-row{ display:grid; grid-template-columns:170px 1fr auto; gap:12px; align-items:center; }
.pkg-bar-row .name{ font-family:"IBM Plex Mono",monospace; font-size:13px; }
.pkg-bar-row .count{ font-family:"IBM Plex Mono",monospace; font-variant-numeric:tabular-nums; font-size:12.5px; color:var(--muted); white-space:nowrap; }
.minibar{ height:9px; border-radius:6px; overflow:hidden; display:flex; background:var(--surface-2); border:1px solid var(--line); }
.minibar .seg{ height:100%; display:block; }
.controls{ max-width:1180px; margin:20px auto 0; display:flex; flex-wrap:wrap; gap:10px 14px; align-items:center;
position:sticky; top:env(safe-area-inset-top,0px); z-index:5; background:var(--bg); padding-block:10px; }
.nav-links{ display:flex; gap:6px; flex-wrap:wrap; }
.nav-links a{ font-size:13px; text-decoration:none; padding:6px 11px; border-radius:999px; border:1px solid var(--line-strong);
color:var(--ink-soft); background:var(--surface); }
.nav-links a:hover{ border-color:var(--accent); color:var(--accent-strong); }
.filters{ display:flex; gap:6px; flex-wrap:wrap; margin-left:auto; }
.chip{ font-size:12.5px; padding:6px 11px; border-radius:999px; border:1px solid var(--line-strong);
background:var(--surface); color:var(--ink-soft); cursor:pointer; font-family:inherit; }
.chip[aria-pressed="true"]{ background:var(--accent-strong); border-color:var(--accent-strong); color:var(--bg-plan); }
.searchbox{ display:flex; align-items:center; gap:6px; border:1px solid var(--line-strong); border-radius:999px;
background:var(--surface); padding:5px 12px; min-width:190px; }
.searchbox input{ border:none; background:none; outline:none; font-family:inherit; font-size:13px; color:var(--ink); width:100%; }
.searchbox input::placeholder{ color:var(--muted); }
.pkg-section{ max-width:1180px; margin:34px auto 0; scroll-margin-top:64px; }
.pkg-head{ display:flex; flex-wrap:wrap; gap:12px 24px; align-items:flex-end; justify-content:space-between; margin-bottom:16px; }
.pkg-head h2{ font-family:"IBM Plex Mono",monospace; font-size:21px; font-weight:600; color:var(--accent-strong); }
.pkg-sub{ color:var(--muted); font-size:13px; margin-top:3px; }
.pkg-bar{ width:220px; height:9px; border-radius:6px; overflow:hidden; display:flex; background:var(--surface-2); border:1px solid var(--line); }
.pkg-bar .seg{ height:100%; display:block; }
.seg-ok{ background:var(--ok); } .seg-warn{ background:var(--warn); } .seg-skip{ background:var(--skip); }
.card-grid{ display:grid; gap:12px; }
.filecard{ background:var(--surface); border:1px solid var(--line); border-radius:12px; box-shadow:var(--shadow); overflow:hidden; }
.card-toggle{ width:100%; display:flex; flex-wrap:wrap; align-items:center; gap:8px 14px; justify-content:space-between;
padding:13px 16px; background:none; border:none; cursor:pointer; text-align:left; font-family:inherit; color:var(--ink); }
.card-toggle:hover{ background:var(--accent-tint); }
.card-toggle:focus-visible{ outline:2px solid var(--focus); outline-offset:-2px; }
.card-path{ font-size:13.5px; font-weight:500; color:var(--ink); }
.card-meta{ display:flex; align-items:center; gap:8px; flex-wrap:wrap; font-size:12px; }
.count-total{ color:var(--muted); font-variant-numeric:tabular-nums; }
.cpill{ font-family:"IBM Plex Mono",monospace; font-variant-numeric:tabular-nums; font-size:11.5px; padding:2px 7px; border-radius:999px; font-weight:600; }
.cpill-ok{ background:var(--ok-bg); color:var(--ok); } .cpill-warn{ background:var(--warn-bg); color:var(--warn); } .cpill-skip{ background:var(--skip-bg); color:var(--skip); }
.docbadge{ color:var(--muted); border:1px solid var(--line-strong); border-radius:999px; padding:2px 8px; }
.auditbadge{ color:var(--muted); border:1px solid var(--line-strong); border-radius:999px; padding:2px 8px; }
.chev{ color:var(--muted); transition:transform .15s ease; }
.filecard.open .chev{ transform:rotate(180deg); }
.card-body{ max-height:0; overflow:hidden; transition:max-height .22s ease; border-top:0 solid var(--line); }
.filecard.open .card-body{ border-top-width:1px; }
.card-body-inner{ padding:16px; }
.filedesc{ color:var(--ink-soft); margin:0 0 12px; max-width:70ch; }
.empty-note{ color:var(--muted); font-size:13.5px; margin:0; }
.gchips{ display:flex; flex-wrap:wrap; gap:6px; margin-bottom:14px; }
.gchip{ font-size:12px; background:var(--accent-tint); color:var(--accent-strong); border:1px solid var(--line); border-radius:7px; padding:3px 9px; }
.gchip b{ font-family:"IBM Plex Mono",monospace; font-weight:600; }
.diagram-wrap{ background:var(--bg-plan); border:1px solid var(--line-strong);
border-image: repeating-linear-gradient(45deg, var(--line-strong) 0 2px, transparent 2px 6px) 1;
border-radius:8px; padding:14px; margin-bottom:16px; overflow-x:auto; }
.diagram-wrap svg{ display:block; margin:0 auto; }
.test-table{ width:100%; border-collapse:collapse; font-size:13px; }
.test-table th{ text-align:left; font-size:11px; text-transform:uppercase; letter-spacing:.05em; color:var(--muted);
border-bottom:1px solid var(--line-strong); padding:6px 8px; }
.test-table td{ border-bottom:1px solid var(--line); padding:8px 8px; vertical-align:top; }
.test-table tr:last-child td{ border-bottom:none; }
.testname{ color:var(--muted); font-size:12px; }
.nodoc{ color:var(--muted); font-style:italic; }
.pill{ font-size:11.5px; padding:2px 8px; border-radius:999px; font-weight:600; white-space:nowrap; }
.pill-ok{ background:var(--ok-bg); color:var(--ok); } .pill-warn{ background:var(--warn-bg); color:var(--warn); } .pill-skip{ background:var(--skip-bg); color:var(--skip); }
.pill-pr-ok{ background:var(--ok-bg); color:var(--ok); } .pill-pr-warn{ background:var(--warn-bg); color:var(--warn); } .pill-pr-skip{ background:var(--skip-bg); color:var(--skip); }
footer{ max-width:1180px; margin:40px auto 30px; color:var(--muted); font-size:12px; border-top:1px solid var(--line); padding-top:14px; }
[hidden]{ display:none !important; }
@media (max-width:640px){ .pkg-bar{ width:130px; } .controls{ position:static; } }
</style>
"""
_HTML_SKRIPT = """
<script src="https://cdnjs.cloudflare.com/ajax/libs/mermaid/10.9.1/mermaid.min.js"></script>
<script>
(function(){
function initMermaid(){
try{
mermaid.initialize({ startOnLoad:false, theme:"base", securityLevel:"loose",
themeVariables:{ fontFamily:'"IBM Plex Mono", monospace', fontSize:'12.5px' } });
mermaid.run({ querySelector: 'pre.mermaid' }).then(setupToggles).catch(function(e){
console.error('mermaid render failed', e); setupToggles();
});
}catch(e){ console.error(e); setupToggles(); }
}
function setupToggles(){
document.querySelectorAll('.card-toggle').forEach(function(btn){
var card = btn.closest('.filecard');
var body = card.querySelector('.card-body');
if (!body.querySelector('.card-body-inner')){
var inner = document.createElement('div');
inner.className = 'card-body-inner';
while (body.firstChild) inner.appendChild(body.firstChild);
body.appendChild(inner);
}
btn.addEventListener('click', function(){
var isOpen = card.classList.toggle('open');
btn.setAttribute('aria-expanded', isOpen ? 'true':'false');
body.style.maxHeight = isOpen ? (body.scrollHeight + 40) + 'px' : '0px';
});
});
}
function applyFilters(){
var active = document.querySelector('.chip[aria-pressed="true"]').dataset.filter;
var q = document.getElementById('searchInput').value.trim().toLowerCase();
document.querySelectorAll('.filecard').forEach(function(card){
var passFilter = active === 'all' || card.dataset[active] === '1';
var passSearch = !q || card.dataset.search.indexOf(q) !== -1;
card.hidden = !(passFilter && passSearch);
});
document.querySelectorAll('.pkg-section').forEach(function(sec){
sec.hidden = !sec.querySelector('.filecard:not([hidden])');
});
}
document.querySelectorAll('.chip').forEach(function(chip){
chip.addEventListener('click', function(){
document.querySelectorAll('.chip').forEach(function(c){ c.setAttribute('aria-pressed','false'); });
chip.setAttribute('aria-pressed','true');
applyFilters();
});
});
document.getElementById('searchInput').addEventListener('input', applyFilters);
if (document.readyState === 'loading'){ document.addEventListener('DOMContentLoaded', initMermaid); }
else { initMermaid(); }
})();
</script>
"""
def erzeuge_html(
kataloge: list[Paketkatalog],
baseline_pfad: Path,
baseline_kurz: str,
pruefungen_pfad: Path,
pruefungen_kurz: str,
) -> str:
"""Erzeugt das HTML-Dashboard 'Prüfstand' – dieselben Daten wie erzeuge_markdown, als
interaktive Seite mit Baumdiagramm (Mermaid) je Testdatei. Läuft lokal ohne Server."""
alle = [f for k in kataloge for f in k.faelle]
summe = zaehle(alle)
baseline_anzeige = (
baseline_pfad.relative_to(ROOT).as_posix() if baseline_pfad.is_relative_to(ROOT) else str(baseline_pfad)
)
pruefungen_anzeige = (
pruefungen_pfad.relative_to(ROOT).as_posix() if pruefungen_pfad.is_relative_to(ROOT) else str(pruefungen_pfad)
)
meta_zeile = (
f'Baseline: <span class="mono">{html.escape(baseline_anzeige)}</span> (SHA-256 {baseline_kurz})'
if baseline_kurz
else f'Baseline <span class="mono">{html.escape(baseline_anzeige)}</span> nicht gefunden'
)
meta_zeile_audit = (
f'Prüf-Audit: <span class="mono">{html.escape(pruefungen_anzeige)}</span> (SHA-256 {pruefungen_kurz})'
if pruefungen_kurz
else f'Prüf-Audit <span class="mono">{html.escape(pruefungen_anzeige)}</span> nicht gefunden'
)
nav_links = "\n ".join(
f'<a href="#pkg-{html.escape(k.paket)}">{html.escape(k.paket)} <span class="mono">({len(k.faelle)})</span></a>'
for k in kataloge
)
bar_zeilen = []
for k in kataloge:
z = zaehle(k.faelle)
gesamt = z["gesamt"] or 1
ok_pct = round(100 * z["gruen"] / gesamt)
warn_pct = round(100 * z["rot"] / gesamt)
skip_pct = max(0, 100 - ok_pct - warn_pct)
bar_zeilen.append(
f'<div class="pkg-bar-row"><span class="name">{html.escape(k.paket)}</span>'
f'<span class="minibar" role="img" aria-label="{z["gruen"]} grün, {z["rot"]} rot, {z["uebersprungen"]} übersprungen">'
f'<span class="seg seg-ok" style="width:{ok_pct}%"></span>'
f'<span class="seg seg-warn" style="width:{warn_pct}%"></span>'
f'<span class="seg seg-skip" style="width:{skip_pct}%"></span></span>'
f'<span class="count">{len(k.dateien)} Dateien · {z["gesamt"]} Testfälle</span></div>'
)
sections = "\n".join(_html_abschnitt_fuer_paket(k) for k in kataloge)
teile = [_HTML_KOPF]
teile.append('<div class="topbar"><div class="brand"><h1>Prüfstand</h1>')
teile.append(
'<span class="tagline">Testkatalog der sn_*-Plugins — was die Suite heute prüft, auf einen Blick.</span></div>'
)
teile.append(
f'<div class="meta-line">Quelle: <b>docs/tests/testkatalog.md</b><br>{meta_zeile}<br>{meta_zeile_audit}</div></div>'
)
teile.append('<div class="summary">')
teile.append(f'<div class="stat"><span class="n">{summe["gesamt"]}</span><span class="l">Testfälle</span></div>')
teile.append(f'<div class="stat ok"><span class="n">{summe["gruen"]}</span><span class="l">grün</span></div>')
teile.append(f'<div class="stat warn"><span class="n">{summe["rot"]}</span><span class="l">rot (Baseline)</span></div>')
teile.append(f'<div class="stat skip"><span class="n">{summe["uebersprungen"]}</span><span class="l">übersprungen</span></div>')
doc_pct = round(100 * summe["mit_docstring"] / summe["gesamt"]) if summe["gesamt"] else 0
audit_pct = round(100 * summe["aktuell_geprueft"] / summe["gesamt"]) if summe["gesamt"] else 0
teile.append(f'<div class="stat"><span class="n">{doc_pct}%</span><span class="l">mit Docstring</span></div>')
teile.append(f'<div class="stat"><span class="n">{audit_pct}%</span><span class="l">aktuell geprüft</span></div>')
teile.append(f'<div class="stat"><span class="n">{sum(len(k.dateien) for k in kataloge)}</span><span class="l">Testdateien</span></div>')
teile.append("</div>")
teile.append('<div class="pkg-bars"><div class="pkg-bars-title">Verteilung je Paket</div>')
teile.append("\n ".join(bar_zeilen))
teile.append("</div>")
teile.append('<div class="controls"><nav class="nav-links">')
teile.append(nav_links)
teile.append('</nav><div class="filters">')
teile.append('<button class="chip" data-filter="all" aria-pressed="true">Alle</button>')
teile.append('<button class="chip" data-filter="hasred" aria-pressed="false">Mit rot</button>')
teile.append('<button class="chip" data-filter="hasskip" aria-pressed="false">Mit übersprungen</button>')
teile.append('<button class="chip" data-filter="nodoc" aria-pressed="false">Schwach beschrieben (&lt;50%)</button>')
teile.append('<button class="chip" data-filter="ungeprueft" aria-pressed="false">Nie geprüft</button>')
teile.append('<button class="chip" data-filter="veraltet" aria-pressed="false">Veraltet geprüft</button>')
teile.append("</div>")
teile.append(
'<div class="searchbox"><svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" '
'stroke-width="2.5" aria-hidden="true"><circle cx="11" cy="11" r="7"/><line x1="21" y1="21" x2="16.65" y2="16.65"/></svg>'
'<input type="search" id="searchInput" placeholder="Testfall oder Datei suchen…" '
'aria-label="Testfall oder Datei suchen"></div></div>'
)
teile.append(sections)
teile.append(
"<footer>Erzeugt von <span class=\"mono\">python scripts/testkatalog.py</span> aus den "
"unittest-Suiten der sn_*-Plugins. Diagramme gruppieren Testfälle je Testdatei nach "
"<span class=\"mono\">unittest.TestCase</span>-Klasse; Farbe zeigt den Baseline-Status "
"(grün / rot / übersprungen), nicht die fachliche Wichtigkeit.</footer>"
)
teile.append(_HTML_SKRIPT)
return "\n".join(teile) + "\n"
def melde_unterschied(vorhanden: bytes, erzeugt: str, ziel: str) -> None:
"""Kurzmeldung, worin sich Datei und erzeugter Stand unterscheiden."""
alt = vorhanden.decode("utf-8", errors="replace").splitlines()
neu = erzeugt.splitlines()
hinzu = entfernt = 0
erste = ""
for zeile in difflib.unified_diff(alt, neu, lineterm="", n=0):
if zeile.startswith("+++") or zeile.startswith("---") or zeile.startswith("@@"):
continue
if zeile.startswith("+"):
hinzu += 1
elif zeile.startswith("-"):
entfernt += 1
if not erste:
erste = zeile[:160]
print(f"{ziel} ist nicht aktuell: {hinzu} Zeilen neu, {entfernt} Zeilen entfallen.")
if erste:
print(f" erste Abweichung: {erste}")
print(" Neu erzeugen mit: python scripts/testkatalog.py")
def main() -> int:
ap = argparse.ArgumentParser(description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter)
ap.add_argument("pakete", nargs="*", help="Paketnamen (Standard: alle mit tests=true)")
ap.add_argument("--pruefen", action="store_true",
help="nichts schreiben; Exit-Code 1, wenn die vorhandene Datei nicht dem erzeugten Stand entspricht")
ap.add_argument("--ausgabe", type=Path, default=AUSGABE_DEFAULT, help="Zieldatei (Standard: docs/tests/testkatalog.md)")
ap.add_argument("--html-ausgabe", type=Path, default=HTML_AUSGABE_DEFAULT,
help="Zieldatei für das HTML-Dashboard (Standard: docs/tests/testkatalog.html)")
ap.add_argument("--ohne-html", action="store_true", help="kein HTML-Dashboard erzeugen/prüfen")
ap.add_argument("--baseline-datei", type=Path, default=BASELINE_DEFAULT)
ap.add_argument("--pruefungsdatei", type=Path, default=PRUEFUNGEN_DEFAULT,
help="Zieldatei für den Prüfstatus (Standard: docs/status/testpruefungen.json)")
args = ap.parse_args()
manifest = lade_manifest()
repos = {r["paket"]: r for r in manifest["repos"] if r.get("paket")}
testbare = [p for p, r in repos.items() if r.get("tests")]
gewuenscht = args.pakete or testbare
unbekannt = [p for p in gewuenscht if p not in repos]
if unbekannt:
print(f"Unbekannte Pakete: {unbekannt}. Bekannt: {sorted(repos)}")
return 2
# Reihenfolge aus workspace.toml übernehmen – das hält die Ausgabe stabil.
reihenfolge = [p for p in repos if p in set(gewuenscht)]
rot_je_paket, baseline_kurz = lies_baseline(args.baseline_datei)
if not baseline_kurz:
print(f"Keine Baseline unter {args.baseline_datei} – alle Testfälle werden als grün geführt.")
pruefungen, pruefungen_kurz = lies_pruefungen(args.pruefungsdatei)
if not pruefungen_kurz:
print(f"Kein Prüf-Audit unter {args.pruefungsdatei} – alle Testfälle gelten als nie geprüft.")
kataloge: list[Paketkatalog] = []
for paket in reihenfolge:
if not repos[paket].get("tests"):
print(f"[{paket}] laut workspace.toml ohne Tests – übersprungen")
continue
katalog = sammle_paket(paket, repos[paket])
if katalog is None:
print(f"[{paket}] kein Ordner {repos[paket]['name']}/tests – übersprungen")
continue
setze_status(katalog, rot_je_paket.get(paket, set()))
setze_pruefstatus(katalog, pruefungen)
k = zaehle(katalog.faelle)
print(f"[{paket}] {len(katalog.dateien)} Testdateien, {k['gesamt']} Testfälle "
f"({k['gruen']} grün, {k['rot']} rot, {k['uebersprungen']} übersprungen, "
f"{k['mit_docstring']} mit Docstring)")
if not katalog.dateien:
print(f" Keine Datei tests/test_*.py gefunden.")
kataloge.append(katalog)
if not kataloge:
print("Keine Pakete mit Testdateien gefunden – nichts zu tun.")
return 2
ausgaben = [(
args.ausgabe,
erzeuge_markdown(kataloge, args.baseline_datei, baseline_kurz, args.pruefungsdatei, pruefungen_kurz),
)]
if not args.ohne_html:
ausgaben.append((
args.html_ausgabe,
erzeuge_html(kataloge, args.baseline_datei, baseline_kurz, args.pruefungsdatei, pruefungen_kurz),
))
ergebnis = 0
for ziel, text in ausgaben:
anzeige = ziel.relative_to(ROOT).as_posix() if ziel.is_relative_to(ROOT) else str(ziel)
if args.pruefen:
if not ziel.exists():
print(f"{anzeige} fehlt. Neu erzeugen mit: python scripts/testkatalog.py")
ergebnis = 1
continue
if ziel.read_bytes() == text.encode("utf-8"):
print(f"{anzeige} ist aktuell.")
continue
melde_unterschied(ziel.read_bytes(), text, anzeige)
ergebnis = 1
continue
ziel.parent.mkdir(parents=True, exist_ok=True)
ziel.write_text(text, encoding="utf-8", newline="\n")
print(f"Testkatalog geschrieben: {anzeige} ({len(text.splitlines())} Zeilen)")
return ergebnis
if __name__ == "__main__":
raise SystemExit(main())