#!/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 .rot Einträge der Form " (..)". 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" 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" @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 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") 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, ) ) 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 lies_baseline(pfad: Path) -> tuple[dict[str, set[str]], str]: """Rote Tests je Paket als Menge '..' 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\S+)\s+\((?P[\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 = f"{datei.modulpfad}.{fall.bezeichnung}" 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 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), } 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 erzeuge_markdown(kataloge: list[Paketkatalog], baseline_pfad: Path, baseline_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) 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.") 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("") z.append("## Zusammenfassung") z.append("") z.append("| Paket | Testdateien | Testfälle | grün | rot (Baseline) | übersprungen | mit Beschreibung |") 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'])} |" ) 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'])}** |" ) 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("") 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 |") z.append("| --- | --- | --- |") for fall in datei.faelle: z.append(f"| `{fall.bezeichnung}` | {zelle(fall.beschreibung)} | {status_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", } 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 if not datei.gruppen: koerper = ( f'

{html.escape(datei.beschreibung) if datei.beschreibung else "— (Docstring fehlt)"}

' '

Keine Testfälle: Die Datei enthält keine unittest.TestCase' "-Klasse – sie ist eine reine Hilfsdatei, die beim Testlauf nur importiert wird.

" ) pills = "" else: pills = "".join( f'{v}' for k, v in zaehler.items() if v ) gchips = "".join( f'{html.escape(g.name)} ' f"{len(g.faelle)}" 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") pruft = ( f'— (Docstring fehlt)' if not fall.beschreibung else html.escape(fall.beschreibung) ) titel = f' title="{html.escape(fall.grund)}"' if fall.grund else "" zeilen.append( f'{html.escape(fall.klasse)}
' f'{html.escape(fall.name)}' f"{pruft}" f'{kurz.get(fall.status, fall.status)}' ) tabelle = ( '' f"{''.join(zeilen)}
TestfallprüftStatus
" ) diagramm = ( '
'
            + html.escape(_mermaid_fuer_datei(datei))
            + "
" ) koerper = ( f'

{html.escape(datei.beschreibung) if datei.beschreibung else "— (Docstring fehlt)"}

' f'
{gchips}
{diagramm}{tabelle}' ) suchtext = html.escape( (datei.anzeigepfad + " " + " ".join(f"{f.klasse} {f.name}" for f in faelle)).lower() ) header = ( f'' ) return ( f'
' f"{header}" f'
{koerper}
' "
" ) 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 "

Keine Testdateien gefunden.

" 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"""

{html.escape(katalog.paket)}

{html.escape(rolle)} · Ordner {html.escape(katalog.ordner)} · {len(katalog.dateien)} Testdateien · {k['gesamt']} Testfälle

{karten}
""" _HTML_KOPF = """Prüfstand """ _HTML_SKRIPT = """ """ def erzeuge_html(kataloge: list[Paketkatalog], baseline_pfad: Path, baseline_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) ) meta_zeile = ( f'Baseline: {html.escape(baseline_anzeige)} (SHA-256 {baseline_kurz})' if baseline_kurz else f'Baseline {html.escape(baseline_anzeige)} nicht gefunden' ) nav_links = "\n ".join( f'{html.escape(k.paket)} ({len(k.faelle)})' 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'
{html.escape(k.paket)}' f'' f'' f'' f'' f'{len(k.dateien)} Dateien · {z["gesamt"]} Testfälle
' ) sections = "\n".join(_html_abschnitt_fuer_paket(k) for k in kataloge) teile = [_HTML_KOPF] teile.append('

Prüfstand

') teile.append( 'Testkatalog der sn_*-Plugins — was die Suite heute prüft, auf einen Blick.
' ) teile.append(f'
Quelle: docs/tests/testkatalog.md
{meta_zeile}
') teile.append('
') teile.append(f'
{summe["gesamt"]}Testfälle
') teile.append(f'
{summe["gruen"]}grün
') teile.append(f'
{summe["rot"]}rot (Baseline)
') teile.append(f'') doc_pct = round(100 * summe["mit_docstring"] / summe["gesamt"]) if summe["gesamt"] else 0 teile.append(f'
{doc_pct}%mit Docstring
') teile.append(f'
{sum(len(k.dateien) for k in kataloge)}Testdateien
') teile.append("
") teile.append('
Verteilung je Paket
') teile.append("\n ".join(bar_zeilen)) teile.append("
") teile.append('
') teile.append('') teile.append('') teile.append('') teile.append('') teile.append("
") teile.append( '
' ) teile.append(sections) teile.append( "" ) 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) 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.") 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())) 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))] if not args.ohne_html: ausgaben.append((args.html_ausgabe, erzeuge_html(kataloge, args.baseline_datei, baseline_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())