#!/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 " (..)". 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 = "" zeile: int = 0 quelltext: 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 Quellfunktion: """Eine Top-Level-Funktion oder Klassenmethode im Quellcode (kein Test).""" datei: str name: str zeile: int referenziert: bool = False absichtlich_grund: str = "" @dataclass class Paketkatalog: """Alle Testdateien eines Pakets samt Kennzahlen.""" paket: str ordner: str rolle: str dateien: list[Testdatei] = field(default_factory=list) quellfunktionen: list[Quellfunktion] = 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), zeile=glied.lineno, quelltext=_funktionsquelle(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 AUSSCHLUSS_ORDNER = {"tests", "__pycache__"} AUSSCHLUSS_DATEIEN = {"main.py", "__init__.py"} WORTZEICHEN = re.compile(r"[A-Za-zÄÖÜäöüß_][A-Za-zÄÖÜäöüß0-9_]*") def _ist_dunder(name: str) -> bool: return name.startswith("__") and name.endswith("__") ABSICHTLICH_MARKER = ("von Test abgesehen", "Test ausgesetzt") def absichtlicher_verzicht(docstring: str | None) -> str: """Docstring-Zeile, die einen bewusst dokumentierten Testverzicht enthält, sonst "". Erkennt die beiden im Projekt verwendeten Vermerke: "von Test abgesehen, weil …" (bewusst vernachlässigbar) und "Test ausgesetzt, zunächst Refactor" (zurückgestellt bis zu einer geplanten Überarbeitung). Ein Namensabgleich wie bei `sammle_referenzierte_namen` – reicht der Vermerk nicht wörtlich, wird die Funktion weiterhin als offen (ungeklärt) geführt. """ if not docstring: return "" for zeile in docstring.splitlines(): text = " ".join(zeile.split()) if text and any(marker in text for marker in ABSICHTLICH_MARKER): return text return "" def sammle_quellcode(ordner: Path) -> list[Quellfunktion]: """Listet Top-Level-Funktionen und Methoden im Quellcode eines Pakets (ohne tests/). `main.py`/`__init__.py` bleiben außen vor: reine QGIS-Einstiegspunkte (`classFactory`, `initGui`, `unload`) lassen sich ohne laufendes QGIS nicht sinnvoll unit-testen. Dunder- Methoden (`__init__`, `__str__`, …) bleiben ebenfalls außen vor, sonst überwiegt Rauschen aus Standardmethoden die eigentlich interessanten Lücken. """ ergebnisse: list[Quellfunktion] = [] for pfad in sorted(ordner.rglob("*.py")): rel = pfad.relative_to(ordner) if AUSSCHLUSS_ORDNER & set(rel.parts[:-1]): continue if rel.name in AUSSCHLUSS_DATEIEN: continue anzeigepfad = rel.as_posix() baum = ast.parse(pfad.read_text(encoding="utf-8"), filename=str(pfad)) for knoten in baum.body: if isinstance(knoten, (ast.FunctionDef, ast.AsyncFunctionDef)): ergebnisse.append( Quellfunktion( datei=anzeigepfad, name=knoten.name, zeile=knoten.lineno, absichtlich_grund=absichtlicher_verzicht(ast.get_docstring(knoten)), ) ) elif isinstance(knoten, ast.ClassDef): for glied in knoten.body: if isinstance(glied, (ast.FunctionDef, ast.AsyncFunctionDef)) and not _ist_dunder(glied.name): ergebnisse.append( Quellfunktion( datei=anzeigepfad, name=f"{knoten.name}.{glied.name}", zeile=glied.lineno, absichtlich_grund=absichtlicher_verzicht(ast.get_docstring(glied)), ) ) return ergebnisse def sammle_referenzierte_namen(testdateien: list[Path]) -> set[str]: """Alle Bezeichner, die irgendwo in den Testdateien eines Pakets vorkommen. Erfasst werden Namen (Aufrufe, Importe), Attributzugriffe (`self.x.methode()`) und Wortbestandteile in Zeichenketten (deckt `mock.patch("modul.Klasse.methode")` ab). Bewusst großzügig: ein Treffer gilt schon als Bezug, auch wenn er nur zufällig denselben Namen trifft – lieber eine echte Lücke übersehen als eine vortäuschen, die keine ist. """ namen: set[str] = set() for pfad in testdateien: baum = ast.parse(pfad.read_text(encoding="utf-8"), filename=str(pfad)) for knoten in ast.walk(baum): if isinstance(knoten, ast.Name): namen.add(knoten.id) elif isinstance(knoten, ast.Attribute): namen.add(knoten.attr) elif isinstance(knoten, ast.Constant) and isinstance(knoten.value, str): namen.update(WORTZEICHEN.findall(knoten.value)) return namen def setze_referenzstatus(katalog: Paketkatalog) -> None: """Füllt katalog.quellfunktionen und markiert, welche in keiner Testdatei referenziert werden.""" katalog.quellfunktionen = sammle_quellcode(ROOT / katalog.ordner) referenziert = sammle_referenzierte_namen([d.pfad for d in katalog.dateien]) for funktion in katalog.quellfunktionen: bezeichner = funktion.name.rsplit(".", 1)[-1] funktion.referenziert = bezeichner in referenziert def zaehle_quellfunktionen(funktionen: list[Quellfunktion]) -> dict[str, int]: ohne_bezug = [f for f in funktionen if not f.referenziert] return { "gesamt": len(funktionen), "referenziert": sum(1 for f in funktionen if f.referenziert), "ohne_bezug": len(ohne_bezug), "absichtlich": sum(1 for f in ohne_bezug if f.absichtlich_grund), "ungeklaert": sum(1 for f in ohne_bezug if not f.absichtlich_grund), } 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 '..' 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 = 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) summe_qf = zaehle_quellfunktionen([f for k in kataloge for f in k.quellfunktionen]) 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( f"Strukturelle Lücken (heuristisch, Namensabgleich): {anteil(summe_qf['ohne_bezug'], summe_qf['gesamt'])} " "Funktionen/Methoden ohne erkennbaren Testbezug – Abschnitt „Ungetestete Funktionen“." ) z.append("") z.append("## Zusammenfassung") z.append("") z.append( "| Paket | Testdateien | Testfälle | grün | rot (Baseline) | übersprungen | mit Beschreibung | " "fachlich geprüft | ohne Testbezug |" ) z.append("| --- | ---: | ---: | ---: | ---: | ---: | ---: | ---: | ---: |") for katalog in kataloge: k = zaehle(katalog.faelle) qf = zaehle_quellfunktionen(katalog.quellfunktionen) 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'])} | {anteil(qf['ohne_bezug'], qf['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'])}** | " f"**{anteil(summe_qf['ohne_bezug'], summe_qf['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( "- **ohne Testbezug** – Funktionen/Methoden im Quellcode, deren Name in keiner Testdatei des Pakets " "vorkommt (weder als Aufruf noch als `mock.patch(\"…\")`-Ziel). Ein Namensabgleich, kein Ausführungsnachweis " "– siehe Abschnitt „Ungetestete Funktionen“ für die Grenzen dieser Heuristik. Das ist bewusst **keine** " "Zeilen-Coverage in Prozent (siehe ADR 0009, „Verworfene Alternativen“), sondern eine Lückenliste wie bei " "fehlenden Beschreibungen. Davon **beabsichtigt** – der Docstring dokumentiert den Testverzicht; " "**ungeklärt** – weder Test noch Vermerk, die eigentliche Arbeitsliste." ) 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("") z.append("## Ungetestete Funktionen (strukturelle Lücke)") z.append("") z.append( "Heuristik, kein Beweis: Diese Funktionen und Methoden kommen unter ihrem Namen in keiner Testdatei" " des Pakets vor. Das schließt nicht aus, dass sie indirekt über eine andere, getestete Funktion" " mitlaufen – umgekehrt gilt: Taucht der Name irgendwo in einer Testdatei auf, gilt die Funktion schon" " als referenziert, auch bei zufälliger Namensgleichheit mit etwas anderem. `main.py`/`__init__.py`" " (QGIS-Einstiegspunkte wie `classFactory`, `initGui`, `unload`) und Dunder-Methoden sind ausgenommen." " Diese Liste ersetzt keine fachliche Prüfung, ob ein Test sinnvoll wäre – dafür braucht es einen" " Menschen, der die Funktion kennt." ) z.append( "„beabsichtigt“ liest den Docstring der Funktion: Enthält er einen Vermerk wie „von Test abgesehen," " weil …“ oder „Test ausgesetzt, zunächst Refactor“, gilt der fehlende Test als bewusste Entscheidung." " Alles andere ist „ungeklärt“ – die eigentliche Arbeitsliste." ) z.append("") for katalog in kataloge: qf = zaehle_quellfunktionen(katalog.quellfunktionen) z.append( f"### `{katalog.paket}` – {anteil(qf['ohne_bezug'], qf['gesamt'])} ohne Testbezug " f"({qf['ungeklaert']} ungeklärt, {qf['absichtlich']} beabsichtigt)" ) z.append("") luecken_qf = [f for f in katalog.quellfunktionen if not f.referenziert] if not luecken_qf: z.append("Keine Lücken.") z.append("") continue je_datei: dict[str, list[Quellfunktion]] = {} for funktion in luecken_qf: je_datei.setdefault(funktion.datei, []).append(funktion) for datei, funktionen in je_datei.items(): z.append(f"#### `{datei}` ({len(funktionen)})") z.append("") for funktion in funktionen: if funktion.absichtlich_grund: z.append(f"- `{funktion.name}` (Zeile {funktion.zeile}) — **beabsichtigt:** {zelle(funktion.absichtlich_grund)}") else: z.append(f"- `{funktion.name}` (Zeile {funktion.zeile}) — ungeklärt") 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'

{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") pruef_klasse = PRUEF_KLASSE.get(fall.pruefstatus, "skip") 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)}' f'{html.escape(pruef_text(fall))}' ) tabelle = ( '' "" f"{''.join(zeilen)}
TestfallprüftStatusgeprüft
" ) 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_luecken_fuer_paket(katalog: Paketkatalog) -> str: """Ausklappbare Karte je Paket (wie eine Testdatei-Karte): Quellfunktionen ohne erkennbaren Testbezug.""" qf = zaehle_quellfunktionen(katalog.quellfunktionen) if not qf["gesamt"]: return "" fid = _slug(f"{katalog.paket}-luecken") luecken = sorted([f for f in katalog.quellfunktionen if not f.referenziert], key=lambda f: (f.datei, f.zeile)) if not luecken: tabelle = '

Keine Lücken.

' else: zeilen = [] for f in luecken: if f.absichtlich_grund: status = f'beabsichtigt' else: status = 'ungeklärt' zeilen.append( f'{html.escape(f.datei)}{html.escape(f.name)}' f"{f.zeile}{status}" ) tabelle = ( '' "" f"{''.join(zeilen)}
DateiFunktion/MethodeZeileStatus
" ) beschreibung = ( '

Heuristik: Name kommt in keiner Testdatei des Pakets vor (Details siehe ' 'docs/tests/testkatalog.md). beabsichtigt liest den Docstring der ' "Funktion – ein Vermerk wie „von Test abgesehen, weil …“ oder „Test ausgesetzt, zunächst Refactor“ " "gilt als bewusste Entscheidung. ungeklärt hat weder Test noch Vermerk.

" ) suchtext = html.escape(f"ungetestete funktionen {katalog.paket}".lower()) header = ( f'' ) return ( f'
' f"{header}" f'
{beschreibung}{tabelle}
' "
" ) def _html_abschnitt_fuer_paket(katalog: Paketkatalog) -> str: k = zaehle(katalog.faelle) qf = zaehle_quellfunktionen(katalog.quellfunktionen) 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) doc_pct = round(100 * k["mit_docstring"] / k["gesamt"]) if k["gesamt"] else 0 audit_pct = round(100 * k["aktuell_geprueft"] / k["gesamt"]) if k["gesamt"] else 0 kacheln = [ ("", len(katalog.dateien), "Testdateien"), ("", k["gesamt"], "Testfälle"), ("ok", k["gruen"], "grün"), ("warn", k["rot"], "rot (Baseline)"), ("skip", k["uebersprungen"], "übersprungen"), ("", f"{doc_pct}%", "mit Docstring"), ("", f"{audit_pct}%", "geprüft"), ("warn" if qf["ungeklaert"] else "", qf["ohne_bezug"], "ohne Testbezug"), ] tiles = "".join( f'
{wert}' f'{label}
' for klasse, wert, label in kacheln ) return f"""

{html.escape(katalog.paket)}

{html.escape(rolle)} · Ordner {html.escape(katalog.ordner)}

{tiles}
{karten}
{_html_luecken_fuer_paket(katalog)}
""" _HTML_KOPF = """Prüfstand """ _HTML_SKRIPT = """ """ 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: {html.escape(baseline_anzeige)} (SHA-256 {baseline_kurz})' if baseline_kurz else f'Baseline {html.escape(baseline_anzeige)} nicht gefunden' ) meta_zeile_audit = ( f'Prüf-Audit: {html.escape(pruefungen_anzeige)} (SHA-256 {pruefungen_kurz})' if pruefungen_kurz else f'Prüf-Audit {html.escape(pruefungen_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}
{meta_zeile_audit}
' ) 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 audit_pct = round(100 * summe["aktuell_geprueft"] / summe["gesamt"]) if summe["gesamt"] else 0 teile.append(f'
{doc_pct}%mit Docstring
') teile.append(f'
{audit_pct}%aktuell geprüft
') teile.append(f'
{sum(len(k.dateien) for k in kataloge)}Testdateien
') summe_qf = zaehle_quellfunktionen([f for k in kataloge for f in k.quellfunktionen]) qf_pct = round(100 * summe_qf["ohne_bezug"] / summe_qf["gesamt"]) if summe_qf["gesamt"] else 0 teile.append(f'
{qf_pct}%ohne Testbezug
') 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("
") 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) 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) setze_referenzstatus(katalog) k = zaehle(katalog.faelle) qf = zaehle_quellfunktionen(katalog.quellfunktionen) 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); {qf['ohne_bezug']} von {qf['gesamt']} " f"Quellfunktionen ohne Testbezug") 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())