Files
Projektwissen/scripts/test_audit.py
T

205 lines
8.6 KiB
Python
Raw Normal View History

#!/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())