2026-09-16 10:23:45 +02:00
|
|
|
|
#!/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).
|
|
|
|
|
|
|
2026-09-16 12:45:12 +02:00
|
|
|
|
`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.
|
|
|
|
|
|
|
2026-09-16 10:23:45 +02:00
|
|
|
|
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
|
2026-09-16 12:45:12 +02:00
|
|
|
|
python scripts/test_audit.py status sn_basis --muster Test_x --code # mit Quelltext (zum Lesen, nicht editierbar)
|
2026-09-16 10:23:45 +02:00
|
|
|
|
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
|
2026-09-16 12:45:12 +02:00
|
|
|
|
print(
|
|
|
|
|
|
f" {datei.anzeigepfad}:{fall.zeile} {fall.bezeichnung} -> {tk.pruef_text(fall)}"
|
|
|
|
|
|
)
|
|
|
|
|
|
if args.code:
|
|
|
|
|
|
for zeile in fall.quelltext.splitlines():
|
|
|
|
|
|
print(f" {zeile}")
|
2026-09-16 10:23:45 +02:00
|
|
|
|
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:
|
2026-09-16 12:45:12 +02:00
|
|
|
|
print(f"{datei.anzeigepfad}:{fall.zeile} {fall.bezeichnung} (vorher: {tk.pruef_text(fall)})")
|
2026-09-16 10:23:45 +02:00
|
|
|
|
|
|
|
|
|
|
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")
|
2026-09-16 12:45:12 +02:00
|
|
|
|
p_status.add_argument("--code", action="store_true", help="Quelltext jeder passenden Testmethode mit ausgeben")
|
2026-09-16 10:23:45 +02:00
|
|
|
|
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())
|