Jedes Paket zeigt jetzt dieselben Kennzahl-Kacheln wie die Gesamtübersicht (Testdateien, Testfälle, grün/rot/übersprungen, mit Docstring, geprüft, ohne Testbezug) statt eines Fließtexts. Ungetestete Funktionen werden per Docstring-Auswertung als "beabsichtigt" (Vermerk "von Test abgesehen, weil …" oder "Test ausgesetzt, zunächst Refactor") oder "ungeklärt" markiert - in Markdown als Anmerkung je Funktion, im Prüfstand als Pille mit Tooltip (Begründung). Der Abschnitt "Ungetestete Funktionen" ist im Prüfstand jetzt eine ausklappbare Karte wie eine Testdatei, mit eigener Kennzahl-Übersicht (Anzahl, ungeklärt, beabsichtigt) im Kopf - bewusst von den Filter-Chips ausgenommen (.gapcard), da diese sich auf Testfall-Status beziehen, nicht auf Quellcode-Funktionen. Neue Funktion absichtlicher_verzicht() plus 6 neue Tests in scripts/tests/test_testkatalog_quellfunktionen.py. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
1341 lines
65 KiB
Python
1341 lines
65 KiB
Python
#!/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 = ""
|
||
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 '<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)
|
||
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'<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_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 = '<p class="empty-note">Keine Lücken.</p>'
|
||
else:
|
||
zeilen = []
|
||
for f in luecken:
|
||
if f.absichtlich_grund:
|
||
status = f'<span class="pill pill-skip" title="{html.escape(f.absichtlich_grund)}">beabsichtigt</span>'
|
||
else:
|
||
status = '<span class="pill pill-warn">ungeklärt</span>'
|
||
zeilen.append(
|
||
f'<tr><td class="mono">{html.escape(f.datei)}</td><td class="mono">{html.escape(f.name)}</td>'
|
||
f"<td>{f.zeile}</td><td>{status}</td></tr>"
|
||
)
|
||
tabelle = (
|
||
'<table class="test-table"><thead><tr><th>Datei</th><th>Funktion/Methode</th><th>Zeile</th>'
|
||
"<th>Status</th></tr></thead>"
|
||
f"<tbody>{''.join(zeilen)}</tbody></table>"
|
||
)
|
||
beschreibung = (
|
||
'<p class="filedesc">Heuristik: Name kommt in keiner Testdatei des Pakets vor (Details siehe '
|
||
'<span class="mono">docs/tests/testkatalog.md</span>). <b>beabsichtigt</b> liest den Docstring der '
|
||
"Funktion – ein Vermerk wie „von Test abgesehen, weil …“ oder „Test ausgesetzt, zunächst Refactor“ "
|
||
"gilt als bewusste Entscheidung. <b>ungeklärt</b> hat weder Test noch Vermerk.</p>"
|
||
)
|
||
suchtext = html.escape(f"ungetestete funktionen {katalog.paket}".lower())
|
||
header = (
|
||
f'<button class="card-toggle" data-target="{fid}" aria-expanded="false">'
|
||
'<span class="card-path mono">Ungetestete Funktionen</span>'
|
||
'<span class="card-meta">'
|
||
f'<span class="count-total">{qf["gesamt"]} Funktionen</span>'
|
||
f'<span class="cpill cpill-warn">{qf["ungeklaert"]} ungeklärt</span>'
|
||
f'<span class="cpill cpill-skip">{qf["absichtlich"]} beabsichtigt</span>'
|
||
'<span class="chev" aria-hidden="true">▾</span></span></button>'
|
||
)
|
||
return (
|
||
f'<article class="filecard gapcard" data-search="{suchtext}">'
|
||
f"{header}"
|
||
f'<div class="card-body" id="body-{fid}">{beschreibung}{tabelle}</div>'
|
||
"</article>"
|
||
)
|
||
|
||
|
||
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 "<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)
|
||
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'<div class="stat stat-sm{" " + klasse if klasse else ""}"><span class="n">{wert}</span>'
|
||
f'<span class="l">{label}</span></div>'
|
||
for klasse, wert, label in kacheln
|
||
)
|
||
|
||
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></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="pkg-summary">{tiles}</div>
|
||
<div class="card-grid">{karten}</div>
|
||
{_html_luecken_fuer_paket(katalog)}
|
||
</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); }
|
||
.pkg-summary{ max-width:100%; margin:0 0 16px; display:grid; gap:8px; grid-template-columns:repeat(auto-fit,minmax(108px,1fr)); }
|
||
.stat-sm{ padding:8px 10px; }
|
||
.stat-sm .n{ font-size:17px; }
|
||
.stat-sm .l{ font-size:10px; }
|
||
.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:not(.gapcard)').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(.gapcard):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>')
|
||
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'<div class="stat warn"><span class="n">{qf_pct}%</span><span class="l">ohne Testbezug</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 (<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)
|
||
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())
|