515 lines
21 KiB
Python
515 lines
21 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>)".
|
|||
|
|
|
|||
|
|
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).
|
|||
|
|
|
|||
|
|
Aufrufe (aus dem Workspace-Root):
|
|||
|
|
python scripts/testkatalog.py # docs/tests/testkatalog.md neu schreiben
|
|||
|
|
python scripts/testkatalog.py sn_basis # nur ein Paket
|
|||
|
|
python scripts/testkatalog.py --pruefen # nur prüfen, ob die Datei aktuell ist (CI, vor dem Commit)
|
|||
|
|
python scripts/testkatalog.py --ausgabe /tmp/k.md # anderes Ziel
|
|||
|
|
|
|||
|
|
Exit-Code: 0 = erfolgreich (mit --pruefen: Datei ist aktuell), 1 = Datei veraltet
|
|||
|
|
oder fehlt, 2 = Aufruffehler.
|
|||
|
|
"""
|
|||
|
|
|
|||
|
|
from __future__ import annotations
|
|||
|
|
|
|||
|
|
import argparse
|
|||
|
|
import ast
|
|||
|
|
import difflib
|
|||
|
|
import hashlib
|
|||
|
|
import json
|
|||
|
|
import re
|
|||
|
|
import tomllib
|
|||
|
|
from dataclasses import dataclass, field
|
|||
|
|
from pathlib import Path
|
|||
|
|
|
|||
|
|
ROOT = Path(__file__).resolve().parents[1]
|
|||
|
|
MANIFEST = ROOT / "workspace.toml"
|
|||
|
|
BASELINE_DEFAULT = ROOT / "docs" / "status" / "testbaseline.json"
|
|||
|
|
AUSGABE_DEFAULT = ROOT / "docs" / "tests" / "testkatalog.md"
|
|||
|
|
|
|||
|
|
FEHLT = "— (Docstring fehlt)"
|
|||
|
|
SPHINX_ROLLE = re.compile(r":(?:mod|func|class|meth|attr|data|obj|ref):`~?([^`]+)`")
|
|||
|
|
SKIP_DEKORATOREN = {"skip", "skipIf", "skipUnless"}
|
|||
|
|
ROLLENTEXT = {
|
|||
|
|
"plattform": "Plattform-Plugin",
|
|||
|
|
"fachplugin": "Fachplugin",
|
|||
|
|
"geruest": "Gerüst",
|
|||
|
|
"platzhalter": "Platzhalter",
|
|||
|
|
"infrastruktur": "Infrastruktur",
|
|||
|
|
"daten": "Daten",
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
|
|||
|
|
@dataclass
|
|||
|
|
class Testfall:
|
|||
|
|
"""Eine einzelne Testmethode (test_*) einer TestCase-Klasse."""
|
|||
|
|
|
|||
|
|
klasse: str
|
|||
|
|
name: str
|
|||
|
|
beschreibung: str
|
|||
|
|
uebersprungen: bool = False
|
|||
|
|
grund: str = ""
|
|||
|
|
erwarteter_fehlschlag: bool = False
|
|||
|
|
status: str = "grün"
|
|||
|
|
|
|||
|
|
@property
|
|||
|
|
def bezeichnung(self) -> str:
|
|||
|
|
return f"{self.klasse}.{self.name}"
|
|||
|
|
|
|||
|
|
|
|||
|
|
@dataclass
|
|||
|
|
class Testgruppe:
|
|||
|
|
"""Eine TestCase-Klasse mit ihren Testfällen."""
|
|||
|
|
|
|||
|
|
name: str
|
|||
|
|
beschreibung: str
|
|||
|
|
faelle: list[Testfall] = field(default_factory=list)
|
|||
|
|
|
|||
|
|
|
|||
|
|
@dataclass
|
|||
|
|
class Testdatei:
|
|||
|
|
"""Eine Datei tests/test_*.py mit allen darin gefundenen Testgruppen."""
|
|||
|
|
|
|||
|
|
pfad: Path
|
|||
|
|
anzeigepfad: str
|
|||
|
|
modulpfad: str
|
|||
|
|
beschreibung: str
|
|||
|
|
gruppen: list[Testgruppe] = field(default_factory=list)
|
|||
|
|
|
|||
|
|
@property
|
|||
|
|
def faelle(self) -> list[Testfall]:
|
|||
|
|
return [f for g in self.gruppen for f in g.faelle]
|
|||
|
|
|
|||
|
|
|
|||
|
|
@dataclass
|
|||
|
|
class Paketkatalog:
|
|||
|
|
"""Alle Testdateien eines Pakets samt Kennzahlen."""
|
|||
|
|
|
|||
|
|
paket: str
|
|||
|
|
ordner: str
|
|||
|
|
rolle: str
|
|||
|
|
dateien: list[Testdatei] = field(default_factory=list)
|
|||
|
|
|
|||
|
|
@property
|
|||
|
|
def faelle(self) -> list[Testfall]:
|
|||
|
|
return [f for d in self.dateien for f in d.faelle]
|
|||
|
|
|
|||
|
|
|
|||
|
|
def lade_manifest() -> dict:
|
|||
|
|
with MANIFEST.open("rb") as f:
|
|||
|
|
return tomllib.load(f)
|
|||
|
|
|
|||
|
|
|
|||
|
|
def erste_zeile(docstring: str | None, dateiname: str = "") -> str:
|
|||
|
|
"""Erste aussagekräftige Zeile eines Docstrings, sonst leer.
|
|||
|
|
|
|||
|
|
Viele Testdateien beginnen ihren Modul-Docstring mit dem eigenen Dateipfad
|
|||
|
|
("sn_basis/tests/test_x.py"). Diese Zeile beschreibt nichts und wird
|
|||
|
|
übersprungen.
|
|||
|
|
"""
|
|||
|
|
if not docstring:
|
|||
|
|
return ""
|
|||
|
|
for zeile in docstring.splitlines():
|
|||
|
|
text = " ".join(zeile.split())
|
|||
|
|
if not text:
|
|||
|
|
continue
|
|||
|
|
if dateiname and text.endswith(".py") and text.split("/")[-1] == dateiname:
|
|||
|
|
continue
|
|||
|
|
# Sphinx-Rollen wie :mod:`sn_basis.x` lesen sich für Fachleute schlecht.
|
|||
|
|
return SPHINX_ROLLE.sub(r"`\1`", text)
|
|||
|
|
return ""
|
|||
|
|
|
|||
|
|
|
|||
|
|
def dekorator_name(knoten: ast.expr) -> str:
|
|||
|
|
"""Punktierter Name eines Dekorators, z. B. 'unittest.skipUnless'."""
|
|||
|
|
ziel = knoten.func if isinstance(knoten, ast.Call) else knoten
|
|||
|
|
teile: list[str] = []
|
|||
|
|
while isinstance(ziel, ast.Attribute):
|
|||
|
|
teile.append(ziel.attr)
|
|||
|
|
ziel = ziel.value
|
|||
|
|
if isinstance(ziel, ast.Name):
|
|||
|
|
teile.append(ziel.id)
|
|||
|
|
return ".".join(reversed(teile))
|
|||
|
|
|
|||
|
|
|
|||
|
|
def lies_skip(dekoratoren: list[ast.expr]) -> tuple[bool, str, bool]:
|
|||
|
|
"""Wertet skip-Dekoratoren aus: (übersprungen, Grund, erwarteter Fehlschlag)."""
|
|||
|
|
uebersprungen = False
|
|||
|
|
erwartet = False
|
|||
|
|
grund = ""
|
|||
|
|
for dek in dekoratoren:
|
|||
|
|
kurz = dekorator_name(dek).rsplit(".", 1)[-1]
|
|||
|
|
if kurz == "expectedFailure":
|
|||
|
|
erwartet = True
|
|||
|
|
elif kurz in SKIP_DEKORATOREN:
|
|||
|
|
uebersprungen = True
|
|||
|
|
argumente = dek.args if isinstance(dek, ast.Call) else []
|
|||
|
|
texte = [a.value for a in argumente if isinstance(a, ast.Constant) and isinstance(a.value, str)]
|
|||
|
|
if texte and not grund:
|
|||
|
|
grund = " ".join(texte[0].split())
|
|||
|
|
if not grund:
|
|||
|
|
grund = f"@unittest.{kurz}"
|
|||
|
|
return uebersprungen, grund, erwartet
|
|||
|
|
|
|||
|
|
|
|||
|
|
def ist_testcase(klasse: ast.ClassDef) -> bool:
|
|||
|
|
"""True für Klassen, die als unittest-TestCase gelten.
|
|||
|
|
|
|||
|
|
Erkannt werden Basisklassen, deren Name auf 'TestCase' endet (unittest.TestCase,
|
|||
|
|
eigene Basisklassen), sowie – als Auffangnetz – Klassen mit Namen 'Test…', die
|
|||
|
|
mindestens eine test_*-Methode haben.
|
|||
|
|
"""
|
|||
|
|
for basis in klasse.bases:
|
|||
|
|
name = dekorator_name(basis)
|
|||
|
|
if name.rsplit(".", 1)[-1].endswith("TestCase"):
|
|||
|
|
return True
|
|||
|
|
hat_tests = any(
|
|||
|
|
isinstance(m, (ast.FunctionDef, ast.AsyncFunctionDef)) and m.name.startswith("test")
|
|||
|
|
for m in klasse.body
|
|||
|
|
)
|
|||
|
|
return klasse.name.startswith("Test") and hat_tests
|
|||
|
|
|
|||
|
|
|
|||
|
|
def lies_testdatei(pfad: Path, paket: str) -> Testdatei:
|
|||
|
|
"""Parst eine Testdatei per ast (ohne sie zu importieren)."""
|
|||
|
|
quelle = pfad.read_text(encoding="utf-8")
|
|||
|
|
baum = ast.parse(quelle, filename=str(pfad))
|
|||
|
|
datei = Testdatei(
|
|||
|
|
pfad=pfad,
|
|||
|
|
anzeigepfad=pfad.relative_to(ROOT).as_posix(),
|
|||
|
|
modulpfad=f"{paket}.tests.{pfad.stem}",
|
|||
|
|
beschreibung=erste_zeile(ast.get_docstring(baum), pfad.name),
|
|||
|
|
)
|
|||
|
|
for knoten in baum.body:
|
|||
|
|
if not isinstance(knoten, ast.ClassDef) or not ist_testcase(knoten):
|
|||
|
|
continue
|
|||
|
|
klassen_skip, klassen_grund, klassen_erwartet = lies_skip(knoten.decorator_list)
|
|||
|
|
gruppe = Testgruppe(name=knoten.name, beschreibung=erste_zeile(ast.get_docstring(knoten)))
|
|||
|
|
for glied in knoten.body:
|
|||
|
|
if not isinstance(glied, (ast.FunctionDef, ast.AsyncFunctionDef)):
|
|||
|
|
continue
|
|||
|
|
if not glied.name.startswith("test"):
|
|||
|
|
continue
|
|||
|
|
skip, grund, erwartet = lies_skip(glied.decorator_list)
|
|||
|
|
gruppe.faelle.append(
|
|||
|
|
Testfall(
|
|||
|
|
klasse=knoten.name,
|
|||
|
|
name=glied.name,
|
|||
|
|
beschreibung=erste_zeile(ast.get_docstring(glied)),
|
|||
|
|
uebersprungen=skip or klassen_skip,
|
|||
|
|
grund=grund or (klassen_grund if klassen_skip else ""),
|
|||
|
|
erwarteter_fehlschlag=erwartet or klassen_erwartet,
|
|||
|
|
)
|
|||
|
|
)
|
|||
|
|
if gruppe.faelle:
|
|||
|
|
datei.gruppen.append(gruppe)
|
|||
|
|
return datei
|
|||
|
|
|
|||
|
|
|
|||
|
|
def sammle_paket(paket: str, repo: dict) -> Paketkatalog | None:
|
|||
|
|
"""Liest alle tests/test_*.py eines Pakets; None, wenn kein tests/-Ordner da ist."""
|
|||
|
|
ordner = ROOT / repo["name"] / "tests"
|
|||
|
|
if not ordner.is_dir():
|
|||
|
|
return None
|
|||
|
|
katalog = Paketkatalog(paket=paket, ordner=repo["name"], rolle=repo.get("rolle", ""))
|
|||
|
|
for pfad in sorted(ordner.glob("test_*.py"), key=lambda p: p.name):
|
|||
|
|
katalog.dateien.append(lies_testdatei(pfad, paket))
|
|||
|
|
return katalog
|
|||
|
|
|
|||
|
|
|
|||
|
|
def lies_baseline(pfad: Path) -> tuple[dict[str, set[str]], str]:
|
|||
|
|
"""Rote Tests je Paket als Menge '<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 = f"{datei.modulpfad}.{fall.bezeichnung}"
|
|||
|
|
if fall.uebersprungen:
|
|||
|
|
fall.status = "übersprungen"
|
|||
|
|
elif fall.erwarteter_fehlschlag:
|
|||
|
|
fall.status = "erwarteter Fehlschlag"
|
|||
|
|
elif voll in rot:
|
|||
|
|
fall.status = "rot (Baseline)"
|
|||
|
|
else:
|
|||
|
|
fall.status = "grün"
|
|||
|
|
|
|||
|
|
|
|||
|
|
def zaehle(faelle: list[Testfall]) -> dict[str, int]:
|
|||
|
|
return {
|
|||
|
|
"gesamt": len(faelle),
|
|||
|
|
"gruen": sum(1 for f in faelle if f.status == "grün"),
|
|||
|
|
"rot": sum(1 for f in faelle if f.status == "rot (Baseline)"),
|
|||
|
|
"uebersprungen": sum(1 for f in faelle if f.status in ("übersprungen", "erwarteter Fehlschlag")),
|
|||
|
|
"erwartet": sum(1 for f in faelle if f.status == "erwarteter Fehlschlag"),
|
|||
|
|
"mit_docstring": sum(1 for f in faelle if f.beschreibung),
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
|
|||
|
|
def anteil(teil: int, ganz: int) -> str:
|
|||
|
|
if not ganz:
|
|||
|
|
return "0 von 0 (–)"
|
|||
|
|
return f"{teil} von {ganz} ({round(100 * teil / ganz)} %)"
|
|||
|
|
|
|||
|
|
|
|||
|
|
def zelle(text: str) -> str:
|
|||
|
|
"""Macht einen Text tabellensicher (Pipes maskieren, Zeilenumbrüche entfernen)."""
|
|||
|
|
return " ".join(text.split()).replace("|", "\\|") or FEHLT
|
|||
|
|
|
|||
|
|
|
|||
|
|
def status_text(fall: Testfall) -> str:
|
|||
|
|
if fall.status == "übersprungen" and fall.grund:
|
|||
|
|
return f"übersprungen – {zelle(fall.grund)}"
|
|||
|
|
return fall.status
|
|||
|
|
|
|||
|
|
|
|||
|
|
def erzeuge_markdown(kataloge: list[Paketkatalog], baseline_pfad: Path, baseline_kurz: str) -> str:
|
|||
|
|
alle = [f for k in kataloge for f in k.faelle]
|
|||
|
|
summe = zaehle(alle)
|
|||
|
|
baseline_anzeige = baseline_pfad.relative_to(ROOT).as_posix() if baseline_pfad.is_relative_to(ROOT) else str(baseline_pfad)
|
|||
|
|
|
|||
|
|
z: list[str] = []
|
|||
|
|
z.append("# Testkatalog der sn_*-Plugins")
|
|||
|
|
z.append("")
|
|||
|
|
z.append("> **Automatisch erzeugt von `scripts/testkatalog.py` – nicht von Hand bearbeiten.**")
|
|||
|
|
z.append("> Neu erzeugen: `python scripts/testkatalog.py`. Prüfen (CI, vor dem Commit): `python scripts/testkatalog.py --pruefen`.")
|
|||
|
|
z.append("")
|
|||
|
|
z.append("Dieser Katalog zeigt ohne Blick in den Quelltext, **was** die Testsuite prüft. Er ist die Grundlage")
|
|||
|
|
z.append("für die fachliche Frage, ob die Abdeckung sinnvoll ist. Die Angaben stammen aus den Testdateien")
|
|||
|
|
z.append("selbst (Docstrings) und aus der Testbaseline.")
|
|||
|
|
z.append("")
|
|||
|
|
if baseline_kurz:
|
|||
|
|
z.append(f"Stand der Baseline: `{baseline_anzeige}` (SHA-256 `{baseline_kurz}`)")
|
|||
|
|
else:
|
|||
|
|
z.append(f"Stand der Baseline: `{baseline_anzeige}` **nicht gefunden** – alle Testfälle gelten als grün.")
|
|||
|
|
z.append("")
|
|||
|
|
z.append(
|
|||
|
|
f"Gesamt: {len(kataloge)} Pakete, {sum(len(k.dateien) for k in kataloge)} Testdateien, "
|
|||
|
|
f"{summe['gesamt']} Testfälle – {summe['gruen']} grün, {summe['rot']} rot (Baseline), "
|
|||
|
|
f"{summe['uebersprungen']} übersprungen."
|
|||
|
|
)
|
|||
|
|
z.append(f"Mit Beschreibung (Docstring): {anteil(summe['mit_docstring'], summe['gesamt'])}.")
|
|||
|
|
z.append("")
|
|||
|
|
|
|||
|
|
z.append("## Zusammenfassung")
|
|||
|
|
z.append("")
|
|||
|
|
z.append("| Paket | Testdateien | Testfälle | grün | rot (Baseline) | übersprungen | mit Beschreibung |")
|
|||
|
|
z.append("| --- | ---: | ---: | ---: | ---: | ---: | ---: |")
|
|||
|
|
for katalog in kataloge:
|
|||
|
|
k = zaehle(katalog.faelle)
|
|||
|
|
z.append(
|
|||
|
|
f"| `{katalog.paket}` | {len(katalog.dateien)} | {k['gesamt']} | {k['gruen']} | "
|
|||
|
|
f"{k['rot']} | {k['uebersprungen']} | {anteil(k['mit_docstring'], k['gesamt'])} |"
|
|||
|
|
)
|
|||
|
|
z.append(
|
|||
|
|
f"| **Summe** | **{sum(len(k.dateien) for k in kataloge)}** | **{summe['gesamt']}** | "
|
|||
|
|
f"**{summe['gruen']}** | **{summe['rot']}** | **{summe['uebersprungen']}** | "
|
|||
|
|
f"**{anteil(summe['mit_docstring'], summe['gesamt'])}** |"
|
|||
|
|
)
|
|||
|
|
z.append("")
|
|||
|
|
if summe["erwartet"]:
|
|||
|
|
z.append(f"In Spalte „übersprungen“ sind {summe['erwartet']} Testfälle mit `@unittest.expectedFailure` enthalten.")
|
|||
|
|
z.append("")
|
|||
|
|
|
|||
|
|
z.append("### Legende")
|
|||
|
|
z.append("")
|
|||
|
|
z.append("- **prüft** – erste Zeile des Docstrings des Testfalls. Fehlt sie, steht dort `" + FEHLT + "`.")
|
|||
|
|
z.append("- **grün** – der Testfall lief im Baseline-Lauf durch.")
|
|||
|
|
z.append(f"- **rot (Baseline)** – der Testfall schlägt bereits fehl und ist in `{baseline_anzeige}` vermerkt; er darf nicht ignoriert, aber auch nicht als neuer Fehler gewertet werden.")
|
|||
|
|
z.append("- **übersprungen** – per `@unittest.skip`/`skipIf`/`skipUnless` abgeschaltet; der Grund steht dabei.")
|
|||
|
|
z.append("- Ein Testfall heißt `Testgruppe.test_name`; die Testgruppe fasst zusammengehörige Testfälle einer Datei zusammen.")
|
|||
|
|
z.append("")
|
|||
|
|
|
|||
|
|
for katalog in kataloge:
|
|||
|
|
k = zaehle(katalog.faelle)
|
|||
|
|
rolle = ROLLENTEXT.get(katalog.rolle, katalog.rolle or "Plugin")
|
|||
|
|
z.append(f"## Paket `{katalog.paket}`")
|
|||
|
|
z.append("")
|
|||
|
|
z.append(
|
|||
|
|
f"{rolle}, Ordner `{katalog.ordner}` – {len(katalog.dateien)} Testdateien mit {k['gesamt']} Testfällen "
|
|||
|
|
f"({k['gruen']} grün, {k['rot']} rot, {k['uebersprungen']} übersprungen)."
|
|||
|
|
)
|
|||
|
|
z.append("")
|
|||
|
|
if not katalog.dateien:
|
|||
|
|
z.append("Keine Testdateien gefunden.")
|
|||
|
|
z.append("")
|
|||
|
|
continue
|
|||
|
|
for datei in katalog.dateien:
|
|||
|
|
z.append(f"### `{datei.anzeigepfad}`")
|
|||
|
|
z.append("")
|
|||
|
|
z.append(f"Worum es in der Datei geht: {zelle(datei.beschreibung)}")
|
|||
|
|
z.append("")
|
|||
|
|
if not datei.gruppen:
|
|||
|
|
z.append("Keine Testfälle: Die Datei enthält keine `unittest.TestCase`-Klasse – sie ist eine reine "
|
|||
|
|
"Hilfsdatei, die beim Testlauf nur importiert wird.")
|
|||
|
|
z.append("")
|
|||
|
|
continue
|
|||
|
|
z.append("Testgruppen:")
|
|||
|
|
z.append("")
|
|||
|
|
for gruppe in datei.gruppen:
|
|||
|
|
z.append(f"- `{gruppe.name}` ({len(gruppe.faelle)} Testfälle): {zelle(gruppe.beschreibung)}")
|
|||
|
|
z.append("")
|
|||
|
|
z.append("| Testfall | prüft | Status |")
|
|||
|
|
z.append("| --- | --- | --- |")
|
|||
|
|
for fall in datei.faelle:
|
|||
|
|
z.append(f"| `{fall.bezeichnung}` | {zelle(fall.beschreibung)} | {status_text(fall)} |")
|
|||
|
|
z.append("")
|
|||
|
|
|
|||
|
|
z.append("## Lücken")
|
|||
|
|
z.append("")
|
|||
|
|
z.append("Arbeitsliste: Diese Testfälle haben keinen Docstring; was sie fachlich prüfen, lässt sich nur")
|
|||
|
|
z.append("aus dem Quelltext erschließen. Jeder Eintrag braucht eine einzeilige Beschreibung im Testfall,")
|
|||
|
|
z.append("dann verschwindet er hier von selbst.")
|
|||
|
|
z.append("")
|
|||
|
|
for katalog in kataloge:
|
|||
|
|
k = zaehle(katalog.faelle)
|
|||
|
|
offen = k["gesamt"] - k["mit_docstring"]
|
|||
|
|
z.append(f"### `{katalog.paket}` – {offen} von {k['gesamt']} Testfällen ohne Beschreibung")
|
|||
|
|
z.append("")
|
|||
|
|
if not offen:
|
|||
|
|
z.append("Keine Lücken.")
|
|||
|
|
z.append("")
|
|||
|
|
continue
|
|||
|
|
for datei in katalog.dateien:
|
|||
|
|
luecken = [f for f in datei.faelle if not f.beschreibung]
|
|||
|
|
if not luecken:
|
|||
|
|
continue
|
|||
|
|
hinweis = "" if datei.beschreibung else " – auch der Modul-Docstring fehlt"
|
|||
|
|
z.append(f"#### `{datei.anzeigepfad}` ({len(luecken)} von {len(datei.faelle)}){hinweis}")
|
|||
|
|
z.append("")
|
|||
|
|
for fall in luecken:
|
|||
|
|
z.append(f"- `{fall.bezeichnung}`")
|
|||
|
|
z.append("")
|
|||
|
|
return "\n".join(z) + "\n"
|
|||
|
|
|
|||
|
|
|
|||
|
|
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("--baseline-datei", type=Path, default=BASELINE_DEFAULT)
|
|||
|
|
args = ap.parse_args()
|
|||
|
|
|
|||
|
|
manifest = lade_manifest()
|
|||
|
|
repos = {r["paket"]: r for r in manifest["repos"] if r.get("paket")}
|
|||
|
|
testbare = [p for p, r in repos.items() if r.get("tests")]
|
|||
|
|
gewuenscht = args.pakete or testbare
|
|||
|
|
unbekannt = [p for p in gewuenscht if p not in repos]
|
|||
|
|
if unbekannt:
|
|||
|
|
print(f"Unbekannte Pakete: {unbekannt}. Bekannt: {sorted(repos)}")
|
|||
|
|
return 2
|
|||
|
|
|
|||
|
|
# Reihenfolge aus workspace.toml übernehmen – das hält die Ausgabe stabil.
|
|||
|
|
reihenfolge = [p for p in repos if p in set(gewuenscht)]
|
|||
|
|
rot_je_paket, baseline_kurz = lies_baseline(args.baseline_datei)
|
|||
|
|
if not baseline_kurz:
|
|||
|
|
print(f"Keine Baseline unter {args.baseline_datei} – alle Testfälle werden als grün geführt.")
|
|||
|
|
|
|||
|
|
kataloge: list[Paketkatalog] = []
|
|||
|
|
for paket in reihenfolge:
|
|||
|
|
if not repos[paket].get("tests"):
|
|||
|
|
print(f"[{paket}] laut workspace.toml ohne Tests – übersprungen")
|
|||
|
|
continue
|
|||
|
|
katalog = sammle_paket(paket, repos[paket])
|
|||
|
|
if katalog is None:
|
|||
|
|
print(f"[{paket}] kein Ordner {repos[paket]['name']}/tests – übersprungen")
|
|||
|
|
continue
|
|||
|
|
setze_status(katalog, rot_je_paket.get(paket, set()))
|
|||
|
|
k = zaehle(katalog.faelle)
|
|||
|
|
print(f"[{paket}] {len(katalog.dateien)} Testdateien, {k['gesamt']} Testfälle "
|
|||
|
|
f"({k['gruen']} grün, {k['rot']} rot, {k['uebersprungen']} übersprungen, "
|
|||
|
|
f"{k['mit_docstring']} mit Docstring)")
|
|||
|
|
if not katalog.dateien:
|
|||
|
|
print(f" Keine Datei tests/test_*.py gefunden.")
|
|||
|
|
kataloge.append(katalog)
|
|||
|
|
|
|||
|
|
if not kataloge:
|
|||
|
|
print("Keine Pakete mit Testdateien gefunden – nichts zu tun.")
|
|||
|
|
return 2
|
|||
|
|
|
|||
|
|
text = erzeuge_markdown(kataloge, args.baseline_datei, baseline_kurz)
|
|||
|
|
ziel = args.ausgabe
|
|||
|
|
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")
|
|||
|
|
return 1
|
|||
|
|
if ziel.read_bytes() == text.encode("utf-8"):
|
|||
|
|
print(f"{anzeige} ist aktuell.")
|
|||
|
|
return 0
|
|||
|
|
melde_unterschied(ziel.read_bytes(), text, anzeige)
|
|||
|
|
return 1
|
|||
|
|
|
|||
|
|
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 0
|
|||
|
|
|
|||
|
|
|
|||
|
|
if __name__ == "__main__":
|
|||
|
|
raise SystemExit(main())
|