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