status zeigt je Treffer "<Datei>:<Zeile>" (in Editor-Terminals anklickbar) und optional per --code den Testquelltext im Terminal. Der Prüfstand selbst bleibt ohne Quelltext, um Diff-Lärm bei jeder Codeänderung zu vermeiden. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
205 lines
8.6 KiB
Python
205 lines
8.6 KiB
Python
#!/usr/bin/env python3
|
||
"""Test-Audit: zeigt und pflegt, wann ein Testfall zuletzt fachlich geprüft wurde.
|
||
|
||
Der Testkatalog (scripts/testkatalog.py) beantwortet "was prüft die Suite" und "läuft der Test
|
||
durch" (Baseline). Er beantwortet NICHT "hat das ein Mensch fachlich bestätigt" – ein Agent kann
|
||
einen überzeugenden, aber fachlich falschen Test schreiben, und weder Docstring noch Baseline
|
||
zeigen das. Dieses Skript schließt genau diese Lücke: es liest/schreibt
|
||
docs/status/testpruefungen.json (Datum + Kurzprüfsumme der Testfunktion je Testfall) und macht so
|
||
sichtbar, welche Tests nie geprüft wurden oder sich seit der letzten Prüfung geändert haben.
|
||
|
||
Reine Standardbibliothek, kein Import von sn_basis/QGIS nötig, da nur mit ast geparst wird
|
||
(genau wie testkatalog.py, dessen Funktionen hier wiederverwendet werden).
|
||
|
||
`status` zeigt je Treffer "<datei>:<zeile>" – in den meisten Terminals/Editoren (z. B. VS Code) anklickbar,
|
||
um den Test direkt zum Bearbeiten zu öffnen. `--code` gibt zusätzlich den Testquelltext im Terminal aus, zum
|
||
schnellen fachlichen Lesen ohne Editor. Bearbeitet wird die Testdatei ganz normal über die Konsole/den Editor –
|
||
dieses Skript schreibt nur `docs/status/testpruefungen.json`, nie Testdateien.
|
||
|
||
Aufrufe (aus dem Workspace-Root):
|
||
python scripts/test_audit.py status # alle Pakete
|
||
python scripts/test_audit.py status sn_verfahrensgebiet # nur ein Paket
|
||
python scripts/test_audit.py status sn_basis --nur ungeprueft # Filter auf Prüfstatus
|
||
python scripts/test_audit.py status --muster grenzpunkt # Filter auf Bezeichner
|
||
python scripts/test_audit.py status sn_basis --muster Test_x --code # mit Quelltext (zum Lesen, nicht editierbar)
|
||
python scripts/test_audit.py markieren sn_basis TestHallo.test_x # als heute geprüft markieren
|
||
python scripts/test_audit.py markieren sn_basis "TestX.*" --trocken # nur anzeigen, nichts schreiben
|
||
python scripts/test_audit.py markieren sn_basis TestX.test_x --datum 2026-08-01 # Nacherfassung
|
||
|
||
Exit-Code: 0 = in Ordnung, 1 = kein Treffer beim Markieren, 2 = falsche Angabe (unbekanntes Paket,
|
||
ungültiges Datum).
|
||
"""
|
||
|
||
from __future__ import annotations
|
||
|
||
import argparse
|
||
import datetime
|
||
import fnmatch
|
||
import json
|
||
import sys
|
||
from pathlib import Path
|
||
|
||
import testkatalog as tk
|
||
|
||
ROOT = tk.ROOT
|
||
|
||
|
||
def lade_pakete(manifest: dict) -> dict[str, dict]:
|
||
return {r["paket"]: r for r in manifest["repos"] if r.get("paket")}
|
||
|
||
|
||
def waehle_pakete(gewuenscht: list[str], repos: dict[str, dict]) -> list[str] | None:
|
||
"""Löst die gewünschten Paketnamen auf; leer bedeutet alle mit tests=true. None bei unbekanntem Namen."""
|
||
testbare = [p for p, r in repos.items() if r.get("tests")]
|
||
ziel = gewuenscht or testbare
|
||
unbekannt = [p for p in ziel if p not in repos]
|
||
if unbekannt:
|
||
print(f"Unbekannte Pakete: {unbekannt}. Bekannt: {sorted(repos)}")
|
||
return None
|
||
return ziel
|
||
|
||
|
||
def sammle(paket: str, repos: dict[str, dict]) -> tk.Paketkatalog | None:
|
||
repo = repos[paket]
|
||
if not repo.get("tests"):
|
||
print(f"[{paket}] laut workspace.toml ohne Tests – übersprungen")
|
||
return None
|
||
katalog = tk.sammle_paket(paket, repo)
|
||
if katalog is None:
|
||
print(f"[{paket}] kein Ordner {repo['name']}/tests – übersprungen")
|
||
return katalog
|
||
|
||
|
||
NUR_ZU_PRUEFSTATUS = {
|
||
"ungeprueft": "nie geprüft",
|
||
"veraltet": "veraltet",
|
||
"aktuell": "aktuell geprüft",
|
||
}
|
||
|
||
|
||
def befehl_status(args: argparse.Namespace) -> int:
|
||
manifest = tk.lade_manifest()
|
||
repos = lade_pakete(manifest)
|
||
pakete = waehle_pakete(args.pakete, repos)
|
||
if pakete is None:
|
||
return 2
|
||
|
||
rot_je_paket, _ = tk.lies_baseline(args.baseline_datei)
|
||
pruefungen, _ = tk.lies_pruefungen(args.pruefungsdatei)
|
||
gesucht_status = NUR_ZU_PRUEFSTATUS.get(args.nur) if args.nur else None
|
||
|
||
for paket in pakete:
|
||
katalog = sammle(paket, repos)
|
||
if katalog is None:
|
||
continue
|
||
tk.setze_status(katalog, rot_je_paket.get(paket, set()))
|
||
tk.setze_pruefstatus(katalog, pruefungen)
|
||
k = tk.zaehle(katalog.faelle)
|
||
print(
|
||
f"[{paket}] {k['gesamt']} Testfälle – {k['aktuell_geprueft']} aktuell geprüft, "
|
||
f"{k['veraltet_geprueft']} veraltet, {k['nie_geprueft']} nie geprüft"
|
||
)
|
||
for datei in katalog.dateien:
|
||
for fall in datei.faelle:
|
||
if args.muster and args.muster.lower() not in fall.bezeichnung.lower():
|
||
continue
|
||
if gesucht_status and fall.pruefstatus != gesucht_status:
|
||
continue
|
||
print(
|
||
f" {datei.anzeigepfad}:{fall.zeile} {fall.bezeichnung} -> {tk.pruef_text(fall)}"
|
||
)
|
||
if args.code:
|
||
for zeile in fall.quelltext.splitlines():
|
||
print(f" {zeile}")
|
||
return 0
|
||
|
||
|
||
def schreibe_pruefungen(pfad: Path, pruefungen: dict) -> None:
|
||
"""Schreibt die Prüf-Historie deterministisch (sortierte Schlüssel, feste Formatierung)."""
|
||
pfad.parent.mkdir(parents=True, exist_ok=True)
|
||
pfad.write_text(
|
||
json.dumps(pruefungen, indent=2, ensure_ascii=False, sort_keys=True) + "\n",
|
||
encoding="utf-8",
|
||
newline="\n",
|
||
)
|
||
|
||
|
||
def befehl_markieren(args: argparse.Namespace) -> int:
|
||
try:
|
||
datum = datetime.date.fromisoformat(args.datum).isoformat()
|
||
except ValueError:
|
||
print(f"Ungültiges Datum: {args.datum} (erwartet JJJJ-MM-TT)")
|
||
return 2
|
||
|
||
manifest = tk.lade_manifest()
|
||
repos = lade_pakete(manifest)
|
||
if args.paket not in repos:
|
||
print(f"Unbekanntes Paket: {args.paket}. Bekannt: {sorted(repos)}")
|
||
return 2
|
||
|
||
katalog = sammle(args.paket, repos)
|
||
if katalog is None:
|
||
return 2
|
||
|
||
treffer: list[tuple[tk.Testdatei, tk.Testfall]] = []
|
||
gesehen: set[str] = set()
|
||
for datei in katalog.dateien:
|
||
for fall in datei.faelle:
|
||
voll = tk.voller_testpfad(datei, fall)
|
||
if voll in gesehen:
|
||
continue
|
||
if any(fnmatch.fnmatchcase(fall.bezeichnung, muster) for muster in args.bezeichner):
|
||
treffer.append((datei, fall))
|
||
gesehen.add(voll)
|
||
|
||
if not treffer:
|
||
print(f"Kein Testfall in {args.paket} passt zu {args.bezeichner}.")
|
||
return 1
|
||
|
||
pruefungen, _ = tk.lies_pruefungen(args.pruefungsdatei)
|
||
tk.setze_pruefstatus(katalog, pruefungen)
|
||
for datei, fall in treffer:
|
||
print(f"{datei.anzeigepfad}:{fall.zeile} {fall.bezeichnung} (vorher: {tk.pruef_text(fall)})")
|
||
|
||
if args.trocken:
|
||
print(f"Trockenlauf – nichts geschrieben. {len(treffer)} Treffer.")
|
||
return 0
|
||
|
||
for datei, fall in treffer:
|
||
pruefungen[tk.voller_testpfad(datei, fall)] = {"datum": datum, "hash": fall.quellhash}
|
||
|
||
schreibe_pruefungen(args.pruefungsdatei, pruefungen)
|
||
print(f"{len(treffer)} Testfälle als geprüft am {datum} markiert: {args.pruefungsdatei}")
|
||
return 0
|
||
|
||
|
||
def main() -> int:
|
||
ap = argparse.ArgumentParser(description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter)
|
||
unter = ap.add_subparsers(dest="befehl", required=True)
|
||
|
||
p_status = unter.add_parser("status", help="Prüfstatus je Testfall anzeigen")
|
||
p_status.add_argument("pakete", nargs="*", help="Paketnamen (Standard: alle mit tests=true)")
|
||
p_status.add_argument("--muster", help="nur Testfälle, deren Bezeichner diesen Text enthält (Teilstring)")
|
||
p_status.add_argument("--nur", choices=sorted(NUR_ZU_PRUEFSTATUS), help="nur diesen Prüfstatus anzeigen")
|
||
p_status.add_argument("--code", action="store_true", help="Quelltext jeder passenden Testmethode mit ausgeben")
|
||
p_status.add_argument("--baseline-datei", type=Path, default=tk.BASELINE_DEFAULT)
|
||
p_status.add_argument("--pruefungsdatei", type=Path, default=tk.PRUEFUNGEN_DEFAULT)
|
||
p_status.set_defaults(func=befehl_status)
|
||
|
||
p_markieren = unter.add_parser("markieren", help="Testfälle als heute geprüft markieren")
|
||
p_markieren.add_argument("paket", help="Paketname, z. B. sn_basis")
|
||
p_markieren.add_argument(
|
||
"bezeichner", nargs="+", help="Klasse.methode, exakt oder als fnmatch-Muster (z. B. 'TestX.*')"
|
||
)
|
||
p_markieren.add_argument("--datum", default=datetime.date.today().isoformat(), help="Standard: heute")
|
||
p_markieren.add_argument("--trocken", action="store_true", help="nur anzeigen, nichts schreiben")
|
||
p_markieren.add_argument("--pruefungsdatei", type=Path, default=tk.PRUEFUNGEN_DEFAULT)
|
||
p_markieren.set_defaults(func=befehl_markieren)
|
||
|
||
args = ap.parse_args()
|
||
return args.func(args)
|
||
|
||
|
||
if __name__ == "__main__":
|
||
sys.exit(main())
|