Test-Audit einführen: Prüfstatus je Testfall im Prüfstand sichtbar machen

Ticket #16. docs/status/testpruefungen.json hält je Testfall Datum und
Kurzprüfsumme der letzten fachlichen Prüfung fest; scripts/testkatalog.py
liest das und zeigt es als eigene Spalte/Pille (nie geprüft / geprüft am …
/ veraltet seit Änderung …), unabhängig vom Baseline-Status. Neues Skript
scripts/test_audit.py (status/markieren) pflegt die Datei; erste Tests für
scripts/ unter scripts/tests/.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
2026-09-16 10:23:45 +02:00
co-authored by Claude Sonnet 5
parent a0bb40aeee
commit d9d2542a20
8 changed files with 1079 additions and 467 deletions
+6
View File
@@ -124,6 +124,12 @@ Dient als **minimales Muster** für ein neues Fachplugin (Skill `sn-neues-fachpl
| `Linkliste` | `linkliste.csv` → Gitea Action erzeugt XML/JSON/YAML |
| `Defaults` | Beispiel-Verfahrensdaten (JSON) und `styles/verfahrensgebiet.qml`; nicht vom Code referenziert |
## Workspace-Skripte (dieses Repository, `scripts/`)
| Pfad | Zweck |
|---|---|
| `scripts/test_audit.py` | Prüf-Audit: zeigt (`status`) und markiert (`markieren`) je Testfall, wann er zuletzt fachlich geprüft wurde; schreibt `docs/status/testpruefungen.json`. Nutzt die Katalog-Logik aus `scripts/testkatalog.py`, siehe `docs/leitfaden/06_tests.md` Abschnitt 7. |
## Größte Dateien (Kandidaten für das spätere Refactoring)
| Datei | Zeilen |
+32 -5
View File
@@ -1,7 +1,7 @@
# Tests: was geprüft wird, wie man es liest, was Pflicht ist
Stand 04.09.2026. Für alle, die an den LNO-Plugins mitarbeiten — auch für Fachleute ohne Programmiererfahrung.
Der Abschnitt 7 ist ausdrücklich für Sie geschrieben: dort geht es darum, zu **beurteilen**, ob eine Testsuite
Der Abschnitt 8 ist ausdrücklich für Sie geschrieben: dort geht es darum, zu **beurteilen**, ob eine Testsuite
fachlich das Richtige prüft. Dafür muss man keinen Code lesen können.
## 1. Warum das hier ernster genommen wird als sonst
@@ -116,7 +116,34 @@ vollständig lesbar zu machen.
Nach dem Hinzufügen von Tests den Katalog neu erzeugen und mit committen. `python scripts/testkatalog.py --pruefen`
meldet, wenn er veraltet ist.
## 7. Testkonzepte: prüfen, ob die Tests fachlich sinnvoll sind
## 7. Test-Audit: wann wurde ein Test zuletzt fachlich geprüft?
Der Testkatalog beantwortet „was prüft die Suite" und „läuft der Test durch" (Baseline, Abschnitt 8). Er
beantwortet **nicht** „hat das ein Mensch fachlich bestätigt". Ein Agent kann einen überzeugenden, aber
fachlich falschen Test schreiben (Abschnitt 1) — weder Docstring noch Baseline zeigen das. Das Test-Audit
schließt diese Lücke: `docs/status/testpruefungen.json` hält je Testfall Datum und Kurzprüfsumme der letzten
Prüfung fest. Ändert sich der Testcode danach, erkennt der Prüfstand das automatisch als **veraltet** — die
Prüfsumme stimmt nicht mehr überein.
Im Prüfstand (`docs/tests/testkatalog.html`) erscheint das als eigene Spalte/Pille je Testfall (unabhängig vom
Baseline-Status: ein grüner Test kann nie geprüft sein, ein roter Baseline-Test kann bereits geprüft sein) und
als eigene Filter-Chips „Nie geprüft" / „Veraltet geprüft".
```bash
python scripts/test_audit.py status sn_verfahrensgebiet # Prüfstatus je Testfall anzeigen
python scripts/test_audit.py status --nur ungeprueft # nur ungeprüfte Testfälle
python scripts/test_audit.py markieren sn_basis TestX.test_y # als heute geprüft markieren
```
`markieren` nimmt exakte Bezeichner oder `fnmatch`-Muster (z. B. `"TestX.*"`) entgegen; `--trocken` zeigt die
Treffer, ohne zu schreiben — vor einem Muster mit vielen Treffern empfohlen. Das Skript importiert die
Katalog-Logik aus `scripts/testkatalog.py`, statt sie zu duplizieren.
Abgrenzung zu Abschnitt 8 (Testkonzepte): Das Audit bestätigt, dass **vorhandene** Tests noch fachlich
zutreffen. Ob überhaupt genug getestet wird, zeigen die Testkonzept-Seiten — das ist der nächste, gesonderte
Schritt, kein Ersatz für das Audit.
## 8. Testkonzepte: prüfen, ob die Tests fachlich sinnvoll sind
Der Katalog beantwortet „was wird getestet". Er beantwortet **nicht** „ist das genug". Dafür gibt es je
wichtigem Fachablauf eine eigene Seite in [`docs/tests/`](../tests/), nach der Vorlage
@@ -143,7 +170,7 @@ Ein Beispiel liegt in [`docs/tests/verfahrensgebiet-shape-import.md`](../tests/v
Wenn Sie eine Lücke finden: Issue aufmachen (`python scripts/gitea.py entwurf issue Verfahrensgebiet` legt einen
Entwurf an). Eine gefundene Testlücke ist ein vollwertiges Ticket.
## 8. Die Baseline
## 9. Die Baseline
Nicht alle Tests sind grün. Der Ist-Stand steht in [`docs/status/testbaseline.json`](../status/testbaseline.json)
und wirkt als Sperrklinke: **bekannte rote Tests dürfen bleiben, neue nicht dazukommen.**
@@ -159,7 +186,7 @@ python scripts/run_tests.py sn_verfahrensgebiet --baseline
Die Baseline ändert **nur ein Maintainer** mit `--update-baseline`, nach Absprache. Ein Agent fasst sie nicht an.
Das Grünmachen der bekannten roten Tests läuft über eigene Issues, nicht nebenbei.
## 9. Wenn ein Test rot ist
## 10. Wenn ein Test rot ist
Erst die Frage beantworten: **Ist der Test veraltet oder ist der Code falsch?**
@@ -177,7 +204,7 @@ das ist das Verstecken des Problems vor dem nächsten Leser.
Details zum Deuten einzelner Fehler: Skill `sn-tests`.
## 10. Vor dem Pull Request
## 11. Vor dem Pull Request
- [ ] `python scripts/run_tests.py <paket> --baseline` ohne `NEU ROT`
- [ ] Neue Funktionalität hat mindestens einen Test je neuem Verhalten
File diff suppressed because one or more lines are too long
+415 -412
View File
File diff suppressed because it is too large Load Diff
+15
View File
@@ -15,6 +15,21 @@ Kurz, konkret, mit Datum und Quelle. Neue Einträge oben in der passenden Rubrik
Internetverbindung beim Öffnen bleiben die Baumdiagramme leer, Text/Tabellen/Filter funktionieren trotzdem
(kein Server nötig, einfach lokal im Browser öffnen). Erzeugt zusammen mit `testkatalog.md` durch
`python scripts/testkatalog.py`; beide Dateien müssen zueinander passen (`--pruefen` prüft beide). (09/2026)
- **Test-Audit (`scripts/test_audit.py`, `docs/status/testpruefungen.json`) reagiert auf jede inhaltliche
Änderung am Testkörper, aber nicht auf reines Trailing Whitespace.** Der Hash je Testfall wird über den
`rstrip()`-normalisierten Quelltext gebildet. Eine größere Reformatierung (Docstring-Text, Einrückung,
Assertions) macht bestehende Prüfungen also bewusst „veraltet" – das ist gewollt, sollte aber vor einer
größeren Reformatierungs-PR angekündigt werden, damit niemand über plötzlich viele „veraltet"-Einträge
stolpert. (09/2026, Ticket #16)
- **`scripts/test_audit.py` importiert `scripts/testkatalog.py` als Modul** (`import testkatalog`). Das
funktioniert nur, wenn es als `python scripts/test_audit.py …` aufgerufen wird – Python setzt dabei
automatisch das Verzeichnis des Skripts als ersten Sucheintrag. Ein Aufruf aus einem anderen Arbeitsverzeichnis
über einen vollen Pfad funktioniert genauso; ein Import von `test_audit` als Paket ohne diesen Mechanismus
(z. B. `python -c "import test_audit"` von anderswo) schlägt fehl. (09/2026)
- **`docs/status/testpruefungen.json` wird nur von `scripts/test_audit.py markieren` geschrieben.**
`scripts/testkatalog.py` liest sie ausschließlich (wie bei `testbaseline.json`); die Datei fehlt zunächst und
entsteht erst mit dem ersten `markieren`-Aufruf – der Prüfstand zeigt bis dahin alle Testfälle als „nie
geprüft". (09/2026)
- **`.vscode/` gehört nicht ins Repository.** Im Projektwissen wurde er am 04.09.2026 entfernt; brauchbare Einstellungen stehen jetzt in `docs/leitfaden/04_werkzeuge_einrichten.md` Abschnitt 4.2 zum Kopieren in die Benutzereinstellungen. In `Plugin_SN_Basis`, `Plugin_SN_Plan41`, `Plugin_SN_Verfahrensgebiet` und `Plugin_SN_Widmung` liegt er noch und enthält Pfade eines einzelnen Arbeitsplatzes (OSGeo-Installationspfad, persönliches Benutzerprofil) – nicht als Vorgabe verstehen. (09/2026)
## QGIS-Tests (geplant, siehe `docs/konzept/07_qgis_tests_plan.md`)
+192
View File
@@ -0,0 +1,192 @@
#!/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).
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 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.bezeichnung} -> {tk.pruef_text(fall)}")
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.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("--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())
+158 -16
View File
@@ -16,6 +16,11 @@ 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>)".
Eine dritte Datenquelle ist docs/status/testpruefungen.json: sie hält je Testfall fest, wann er zuletzt von
einem Menschen fachlich geprüft wurde (Datum plus Kurzprüfsumme der Testfunktion, damit eine Änderung am
Testcode die Prüfung automatisch als veraltet kennzeichnet). Diese Datei wird hier nur gelesen; geschrieben
wird sie von scripts/test_audit.py.
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
@@ -60,6 +65,7 @@ from pathlib import Path
ROOT = Path(__file__).resolve().parents[1]
MANIFEST = ROOT / "workspace.toml"
BASELINE_DEFAULT = ROOT / "docs" / "status" / "testbaseline.json"
PRUEFUNGEN_DEFAULT = ROOT / "docs" / "status" / "testpruefungen.json"
AUSGABE_DEFAULT = ROOT / "docs" / "tests" / "testkatalog.md"
HTML_AUSGABE_DEFAULT = ROOT / "docs" / "tests" / "testkatalog.html"
@@ -87,6 +93,9 @@ class Testfall:
grund: str = ""
erwarteter_fehlschlag: bool = False
status: str = "grün"
quellhash: str = ""
pruefstatus: str = "nie geprüft"
pruefdatum: str = ""
@property
def bezeichnung(self) -> str:
@@ -188,6 +197,22 @@ def lies_skip(dekoratoren: list[ast.expr]) -> tuple[bool, str, bool]:
return uebersprungen, grund, erwartet
def _funktionsquelle(zeilen: list[str], knoten: ast.FunctionDef | ast.AsyncFunctionDef) -> str:
"""Quelltext einer Testmethode inkl. eigener Dekoratoren, Trailing Whitespace entfernt.
Trailing Whitespace wird bewusst ignoriert, damit reine Whitespace-Nacharbeit keine Prüfung
veraltet macht. Einrückung und sonstiger Inhalt bleiben signifikant.
"""
start = min([knoten.lineno] + [d.lineno for d in knoten.decorator_list])
ende = knoten.end_lineno or knoten.lineno
return "\n".join(zeile.rstrip() for zeile in zeilen[start - 1:ende])
def _funktionshash(zeilen: list[str], knoten: ast.FunctionDef | ast.AsyncFunctionDef) -> str:
"""SHA-256-Kurzprüfsumme (12 Zeichen) einer Testmethode, für den Abgleich in testpruefungen.json."""
return hashlib.sha256(_funktionsquelle(zeilen, knoten).encode("utf-8")).hexdigest()[:12]
def ist_testcase(klasse: ast.ClassDef) -> bool:
"""True für Klassen, die als unittest-TestCase gelten.
@@ -209,6 +234,7 @@ def ist_testcase(klasse: ast.ClassDef) -> bool:
def lies_testdatei(pfad: Path, paket: str) -> Testdatei:
"""Parst eine Testdatei per ast (ohne sie zu importieren)."""
quelle = pfad.read_text(encoding="utf-8")
zeilen = quelle.splitlines()
baum = ast.parse(quelle, filename=str(pfad))
datei = Testdatei(
pfad=pfad,
@@ -235,6 +261,7 @@ def lies_testdatei(pfad: Path, paket: str) -> Testdatei:
uebersprungen=skip or klassen_skip,
grund=grund or (klassen_grund if klassen_skip else ""),
erwarteter_fehlschlag=erwartet or klassen_erwartet,
quellhash=_funktionshash(zeilen, glied),
)
)
if gruppe.faelle:
@@ -253,6 +280,11 @@ def sammle_paket(paket: str, repo: dict) -> Paketkatalog | None:
return katalog
def voller_testpfad(datei: Testdatei, fall: Testfall) -> str:
"""Voller Schlüssel eines Testfalls – identisch zum Format in testbaseline.json/testpruefungen.json."""
return f"{datei.modulpfad}.{fall.bezeichnung}"
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():
@@ -274,7 +306,7 @@ def lies_baseline(pfad: Path) -> tuple[dict[str, set[str]], str]:
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}"
voll = voller_testpfad(datei, fall)
if fall.uebersprungen:
fall.status = "übersprungen"
elif fall.erwarteter_fehlschlag:
@@ -285,6 +317,27 @@ def setze_status(katalog: Paketkatalog, rot: set[str]) -> None:
fall.status = "grün"
def lies_pruefungen(pfad: Path) -> tuple[dict[str, dict], str]:
"""Prüf-Historie je Testfall plus Kurzprüfsumme der Datei; ({}, "") wenn sie fehlt."""
if not pfad.exists():
return {}, ""
rohdaten = pfad.read_bytes()
kurz = hashlib.sha256(rohdaten).hexdigest()[:12]
return json.loads(rohdaten.decode("utf-8")), kurz
def setze_pruefstatus(katalog: Paketkatalog, pruefungen: dict[str, dict]) -> None:
for datei in katalog.dateien:
for fall in datei.faelle:
eintrag = pruefungen.get(voller_testpfad(datei, fall))
if eintrag is None:
fall.pruefstatus, fall.pruefdatum = "nie geprüft", ""
elif eintrag.get("hash") == fall.quellhash:
fall.pruefstatus, fall.pruefdatum = "aktuell geprüft", eintrag.get("datum", "")
else:
fall.pruefstatus, fall.pruefdatum = "veraltet", eintrag.get("datum", "")
def zaehle(faelle: list[Testfall]) -> dict[str, int]:
return {
"gesamt": len(faelle),
@@ -293,6 +346,9 @@ def zaehle(faelle: list[Testfall]) -> dict[str, int]:
"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),
"nie_geprueft": sum(1 for f in faelle if f.pruefstatus == "nie geprüft"),
"aktuell_geprueft": sum(1 for f in faelle if f.pruefstatus == "aktuell geprüft"),
"veraltet_geprueft": sum(1 for f in faelle if f.pruefstatus == "veraltet"),
}
@@ -313,10 +369,28 @@ def status_text(fall: Testfall) -> str:
return fall.status
def erzeuge_markdown(kataloge: list[Paketkatalog], baseline_pfad: Path, baseline_kurz: str) -> str:
def pruef_text(fall: Testfall) -> str:
"""Anzeigetext für den Prüfstatus (Prüfstand-Spalte 'geprüft')."""
if fall.pruefstatus == "aktuell geprüft":
return f"geprüft am {fall.pruefdatum}"
if fall.pruefstatus == "veraltet":
return f"veraltet seit Änderung – zuletzt geprüft am {fall.pruefdatum}"
return "nie geprüft"
def erzeuge_markdown(
kataloge: list[Paketkatalog],
baseline_pfad: Path,
baseline_kurz: str,
pruefungen_pfad: Path,
pruefungen_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)
pruefungen_anzeige = (
pruefungen_pfad.relative_to(ROOT).as_posix() if pruefungen_pfad.is_relative_to(ROOT) else str(pruefungen_pfad)
)
z: list[str] = []
z.append("# Testkatalog der sn_*-Plugins")
@@ -332,6 +406,12 @@ def erzeuge_markdown(kataloge: list[Paketkatalog], baseline_pfad: Path, baseline
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.")
if pruefungen_kurz:
z.append(f"Stand des Prüf-Audits: `{pruefungen_anzeige}` (SHA-256 `{pruefungen_kurz}`)")
else:
z.append(
f"Stand des Prüf-Audits: `{pruefungen_anzeige}` **nicht gefunden** – alle Testfälle gelten als nie geprüft."
)
z.append("")
z.append(
f"Gesamt: {len(kataloge)} Pakete, {sum(len(k.dateien) for k in kataloge)} Testdateien, "
@@ -339,22 +419,28 @@ def erzeuge_markdown(kataloge: list[Paketkatalog], baseline_pfad: Path, baseline
f"{summe['uebersprungen']} übersprungen."
)
z.append(f"Mit Beschreibung (Docstring): {anteil(summe['mit_docstring'], summe['gesamt'])}.")
z.append(
f"Fachlich geprüft: {anteil(summe['aktuell_geprueft'], summe['gesamt'])} aktuell, "
f"{summe['veraltet_geprueft']} veraltet seit Codeänderung, {summe['nie_geprueft']} nie geprüft."
)
z.append("")
z.append("## Zusammenfassung")
z.append("")
z.append("| Paket | Testdateien | Testfälle | grün | rot (Baseline) | übersprungen | mit Beschreibung |")
z.append("| --- | ---: | ---: | ---: | ---: | ---: | ---: |")
z.append("| Paket | Testdateien | Testfälle | grün | rot (Baseline) | übersprungen | mit Beschreibung | fachlich geprüft |")
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'])} |"
f"{k['rot']} | {k['uebersprungen']} | {anteil(k['mit_docstring'], k['gesamt'])} | "
f"{anteil(k['aktuell_geprueft'], 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'])}** |"
f"**{anteil(summe['mit_docstring'], summe['gesamt'])}** | "
f"**{anteil(summe['aktuell_geprueft'], summe['gesamt'])}** |"
)
z.append("")
if summe["erwartet"]:
@@ -368,6 +454,11 @@ def erzeuge_markdown(kataloge: list[Paketkatalog], baseline_pfad: Path, baseline
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(
"- **geprüft** – Prüf-Audit (`scripts/test_audit.py`): **nie geprüft** kein Eintrag in "
f"`{pruefungen_anzeige}`; **geprüft am …** ein Mensch hat den Testfall bestätigt und der Testcode hat sich "
"seither nicht geändert; **veraltet seit Änderung …** der Testcode wurde nach der letzten Prüfung geändert."
)
z.append("")
for katalog in kataloge:
@@ -399,10 +490,12 @@ def erzeuge_markdown(kataloge: list[Paketkatalog], baseline_pfad: Path, baseline
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("| --- | --- | --- |")
z.append("| Testfall | prüft | Status | geprüft |")
z.append("| --- | --- | --- | --- |")
for fall in datei.faelle:
z.append(f"| `{fall.bezeichnung}` | {zelle(fall.beschreibung)} | {status_text(fall)} |")
z.append(
f"| `{fall.bezeichnung}` | {zelle(fall.beschreibung)} | {status_text(fall)} | {pruef_text(fall)} |"
)
z.append("")
z.append("## Lücken")
@@ -440,6 +533,12 @@ STATUS_KLASSE = {
"erwarteter Fehlschlag": "skip",
}
PRUEF_KLASSE = {
"aktuell geprüft": "ok",
"veraltet": "warn",
"nie geprüft": "skip",
}
def _slug(text: str) -> str:
return re.sub(r"[^a-zA-Z0-9]+", "-", text).strip("-").lower()
@@ -480,6 +579,10 @@ def _html_karte_fuer_datei(datei: Testdatei) -> str:
zaehler[STATUS_KLASSE.get(fall.status, "ok")] += 1
mit_doc = sum(1 for f in faelle if f.beschreibung)
doc_pct = round(100 * mit_doc / gesamt) if gesamt else 0
aktuell_geprueft = sum(1 for f in faelle if f.pruefstatus == "aktuell geprüft")
audit_pct = round(100 * aktuell_geprueft / gesamt) if gesamt else 0
hat_ungeprueft = any(f.pruefstatus == "nie geprüft" for f in faelle)
hat_veraltet = any(f.pruefstatus == "veraltet" for f in faelle)
if not datei.gruppen:
koerper = (
@@ -501,6 +604,7 @@ def _html_karte_fuer_datei(datei: Testdatei) -> str:
kurz = {"grün": "grün", "rot (Baseline)": "rot", "übersprungen": "übersp.", "erwarteter Fehlschlag": "übersp."}
for fall in faelle:
klasse = STATUS_KLASSE.get(fall.status, "ok")
pruef_klasse = PRUEF_KLASSE.get(fall.pruefstatus, "skip")
pruft = (
f'<span class="nodoc">— (Docstring fehlt)</span>'
if not fall.beschreibung
@@ -511,10 +615,12 @@ def _html_karte_fuer_datei(datei: Testdatei) -> str:
f'<tr><td class="mono">{html.escape(fall.klasse)}<br>'
f'<span class="testname">{html.escape(fall.name)}</span></td>'
f"<td>{pruft}</td>"
f'<td><span class="pill pill-{klasse}"{titel}>{kurz.get(fall.status, fall.status)}</span></td></tr>'
f'<td><span class="pill pill-{klasse}"{titel}>{kurz.get(fall.status, fall.status)}</span></td>'
f'<td><span class="pill pill-pr-{pruef_klasse}">{html.escape(pruef_text(fall))}</span></td></tr>'
)
tabelle = (
'<table class="test-table"><thead><tr><th>Testfall</th><th>prüft</th><th>Status</th></tr></thead>'
'<table class="test-table"><thead><tr><th>Testfall</th><th>prüft</th><th>Status</th>'
"<th>geprüft</th></tr></thead>"
f"<tbody>{''.join(zeilen)}</tbody></table>"
)
diagramm = (
@@ -536,12 +642,14 @@ def _html_karte_fuer_datei(datei: Testdatei) -> str:
'<span class="card-meta">'
f'<span class="count-total">{gesamt} Testfälle</span>{pills}'
f'<span class="docbadge" title="Anteil mit Docstring">{doc_pct}% beschrieben</span>'
f'<span class="auditbadge" title="Anteil fachlich aktuell geprüft">{audit_pct}% geprüft</span>'
'<span class="chev" aria-hidden="true">▾</span></span></button>'
)
return (
f'<article class="filecard" data-search="{suchtext}" '
f'data-hasred="{1 if zaehler["warn"] else 0}" data-hasskip="{1 if zaehler["skip"] else 0}" '
f'data-nodoc="{1 if doc_pct < 50 else 0}">'
f'data-nodoc="{1 if doc_pct < 50 else 0}" '
f'data-ungeprueft="{1 if hat_ungeprueft else 0}" data-veraltet="{1 if hat_veraltet else 0}">'
f"{header}"
f'<div class="card-body" id="body-{fid}">{koerper}</div>'
"</article>"
@@ -661,6 +769,7 @@ _HTML_KOPF = """<title>Prüfstand</title>
.cpill{ font-family:"IBM Plex Mono",monospace; font-variant-numeric:tabular-nums; font-size:11.5px; padding:2px 7px; border-radius:999px; font-weight:600; }
.cpill-ok{ background:var(--ok-bg); color:var(--ok); } .cpill-warn{ background:var(--warn-bg); color:var(--warn); } .cpill-skip{ background:var(--skip-bg); color:var(--skip); }
.docbadge{ color:var(--muted); border:1px solid var(--line-strong); border-radius:999px; padding:2px 8px; }
.auditbadge{ color:var(--muted); border:1px solid var(--line-strong); border-radius:999px; padding:2px 8px; }
.chev{ color:var(--muted); transition:transform .15s ease; }
.filecard.open .chev{ transform:rotate(180deg); }
.card-body{ max-height:0; overflow:hidden; transition:max-height .22s ease; border-top:0 solid var(--line); }
@@ -684,6 +793,7 @@ _HTML_KOPF = """<title>Prüfstand</title>
.nodoc{ color:var(--muted); font-style:italic; }
.pill{ font-size:11.5px; padding:2px 8px; border-radius:999px; font-weight:600; white-space:nowrap; }
.pill-ok{ background:var(--ok-bg); color:var(--ok); } .pill-warn{ background:var(--warn-bg); color:var(--warn); } .pill-skip{ background:var(--skip-bg); color:var(--skip); }
.pill-pr-ok{ background:var(--ok-bg); color:var(--ok); } .pill-pr-warn{ background:var(--warn-bg); color:var(--warn); } .pill-pr-skip{ background:var(--skip-bg); color:var(--skip); }
footer{ max-width:1180px; margin:40px auto 30px; color:var(--muted); font-size:12px; border-top:1px solid var(--line); padding-top:14px; }
[hidden]{ display:none !important; }
@media (max-width:640px){ .pkg-bar{ width:130px; } .controls{ position:static; } }
@@ -747,7 +857,13 @@ _HTML_SKRIPT = """
"""
def erzeuge_html(kataloge: list[Paketkatalog], baseline_pfad: Path, baseline_kurz: str) -> str:
def erzeuge_html(
kataloge: list[Paketkatalog],
baseline_pfad: Path,
baseline_kurz: str,
pruefungen_pfad: Path,
pruefungen_kurz: str,
) -> str:
"""Erzeugt das HTML-Dashboard 'Prüfstand' – dieselben Daten wie erzeuge_markdown, als
interaktive Seite mit Baumdiagramm (Mermaid) je Testdatei. Läuft lokal ohne Server."""
alle = [f for k in kataloge for f in k.faelle]
@@ -755,11 +871,19 @@ def erzeuge_html(kataloge: list[Paketkatalog], baseline_pfad: Path, baseline_kur
baseline_anzeige = (
baseline_pfad.relative_to(ROOT).as_posix() if baseline_pfad.is_relative_to(ROOT) else str(baseline_pfad)
)
pruefungen_anzeige = (
pruefungen_pfad.relative_to(ROOT).as_posix() if pruefungen_pfad.is_relative_to(ROOT) else str(pruefungen_pfad)
)
meta_zeile = (
f'Baseline: <span class="mono">{html.escape(baseline_anzeige)}</span> (SHA-256 {baseline_kurz})'
if baseline_kurz
else f'Baseline <span class="mono">{html.escape(baseline_anzeige)}</span> nicht gefunden'
)
meta_zeile_audit = (
f'Prüf-Audit: <span class="mono">{html.escape(pruefungen_anzeige)}</span> (SHA-256 {pruefungen_kurz})'
if pruefungen_kurz
else f'Prüf-Audit <span class="mono">{html.escape(pruefungen_anzeige)}</span> nicht gefunden'
)
nav_links = "\n ".join(
f'<a href="#pkg-{html.escape(k.paket)}">{html.escape(k.paket)} <span class="mono">({len(k.faelle)})</span></a>'
@@ -789,7 +913,9 @@ def erzeuge_html(kataloge: list[Paketkatalog], baseline_pfad: Path, baseline_kur
teile.append(
'<span class="tagline">Testkatalog der sn_*-Plugins — was die Suite heute prüft, auf einen Blick.</span></div>'
)
teile.append(f'<div class="meta-line">Quelle: <b>docs/tests/testkatalog.md</b><br>{meta_zeile}</div></div>')
teile.append(
f'<div class="meta-line">Quelle: <b>docs/tests/testkatalog.md</b><br>{meta_zeile}<br>{meta_zeile_audit}</div></div>'
)
teile.append('<div class="summary">')
teile.append(f'<div class="stat"><span class="n">{summe["gesamt"]}</span><span class="l">Testfälle</span></div>')
@@ -797,7 +923,9 @@ def erzeuge_html(kataloge: list[Paketkatalog], baseline_pfad: Path, baseline_kur
teile.append(f'<div class="stat warn"><span class="n">{summe["rot"]}</span><span class="l">rot (Baseline)</span></div>')
teile.append(f'<div class="stat skip"><span class="n">{summe["uebersprungen"]}</span><span class="l">übersprungen</span></div>')
doc_pct = round(100 * summe["mit_docstring"] / summe["gesamt"]) if summe["gesamt"] else 0
audit_pct = round(100 * summe["aktuell_geprueft"] / summe["gesamt"]) if summe["gesamt"] else 0
teile.append(f'<div class="stat"><span class="n">{doc_pct}%</span><span class="l">mit Docstring</span></div>')
teile.append(f'<div class="stat"><span class="n">{audit_pct}%</span><span class="l">aktuell geprüft</span></div>')
teile.append(f'<div class="stat"><span class="n">{sum(len(k.dateien) for k in kataloge)}</span><span class="l">Testdateien</span></div>')
teile.append("</div>")
@@ -812,6 +940,8 @@ def erzeuge_html(kataloge: list[Paketkatalog], baseline_pfad: Path, baseline_kur
teile.append('<button class="chip" data-filter="hasred" aria-pressed="false">Mit rot</button>')
teile.append('<button class="chip" data-filter="hasskip" aria-pressed="false">Mit übersprungen</button>')
teile.append('<button class="chip" data-filter="nodoc" aria-pressed="false">Schwach beschrieben (&lt;50%)</button>')
teile.append('<button class="chip" data-filter="ungeprueft" aria-pressed="false">Nie geprüft</button>')
teile.append('<button class="chip" data-filter="veraltet" aria-pressed="false">Veraltet geprüft</button>')
teile.append("</div>")
teile.append(
'<div class="searchbox"><svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" '
@@ -863,6 +993,8 @@ def main() -> int:
help="Zieldatei für das HTML-Dashboard (Standard: docs/tests/testkatalog.html)")
ap.add_argument("--ohne-html", action="store_true", help="kein HTML-Dashboard erzeugen/prüfen")
ap.add_argument("--baseline-datei", type=Path, default=BASELINE_DEFAULT)
ap.add_argument("--pruefungsdatei", type=Path, default=PRUEFUNGEN_DEFAULT,
help="Zieldatei für den Prüfstatus (Standard: docs/status/testpruefungen.json)")
args = ap.parse_args()
manifest = lade_manifest()
@@ -879,6 +1011,9 @@ def main() -> int:
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.")
pruefungen, pruefungen_kurz = lies_pruefungen(args.pruefungsdatei)
if not pruefungen_kurz:
print(f"Kein Prüf-Audit unter {args.pruefungsdatei} – alle Testfälle gelten als nie geprüft.")
kataloge: list[Paketkatalog] = []
for paket in reihenfolge:
@@ -890,6 +1025,7 @@ def main() -> int:
print(f"[{paket}] kein Ordner {repos[paket]['name']}/tests – übersprungen")
continue
setze_status(katalog, rot_je_paket.get(paket, set()))
setze_pruefstatus(katalog, pruefungen)
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, "
@@ -902,9 +1038,15 @@ def main() -> int:
print("Keine Pakete mit Testdateien gefunden – nichts zu tun.")
return 2
ausgaben = [(args.ausgabe, erzeuge_markdown(kataloge, args.baseline_datei, baseline_kurz))]
ausgaben = [(
args.ausgabe,
erzeuge_markdown(kataloge, args.baseline_datei, baseline_kurz, args.pruefungsdatei, pruefungen_kurz),
)]
if not args.ohne_html:
ausgaben.append((args.html_ausgabe, erzeuge_html(kataloge, args.baseline_datei, baseline_kurz)))
ausgaben.append((
args.html_ausgabe,
erzeuge_html(kataloge, args.baseline_datei, baseline_kurz, args.pruefungsdatei, pruefungen_kurz),
))
ergebnis = 0
for ziel, text in ausgaben:
+222
View File
@@ -0,0 +1,222 @@
"""Tests für scripts/test_audit.py und die Prüf-Audit-Funktionen in scripts/testkatalog.py.
Reines unittest, ohne QGIS/sn_basis-Abhängigkeit. Arbeitet auf einem eigenen, temporären
Mini-Workspace (nicht auf den echten Plugin-Repos), damit diese Tests unabhängig vom Inhalt der
sn_*-Testsuiten bleiben.
Ausführung (aus dem Workspace-Root): python -m unittest discover -s scripts/tests -p "test_*.py"
"""
from __future__ import annotations
import ast
import contextlib
import io
import sys
import tempfile
import unittest
from pathlib import Path
from unittest import mock
sys.path.insert(0, str(Path(__file__).resolve().parents[1])) # scripts/
import test_audit # noqa: E402
import testkatalog as tk # noqa: E402
WORKSPACE_TOML = """
[workspace]
name = "Test"
arbeitsbranch = "main"
[[repos]]
name = "Testpaket"
paket = "testpaket"
rolle = "infrastruktur"
branch = "main"
tests = true
abhaengigkeiten = []
"""
TESTDATEI_INHALT = '''"""Beispieltestdatei für den Test-Audit-Selbsttest."""
import unittest
class TestBeispiel(unittest.TestCase):
def test_eins(self):
"""Prüft, dass eins gleich eins ist."""
self.assertEqual(1, 1)
def test_zwei(self):
"""Prüft, dass zwei gleich zwei ist."""
self.assertEqual(2, 2)
'''
def _knoten(quelltext: str) -> tuple[list[str], ast.FunctionDef]:
"""Parst eine einzelne Funktionsdefinition; liefert ihre Zeilen und den ast-Knoten."""
zeilen = quelltext.splitlines()
baum = ast.parse(quelltext)
return zeilen, baum.body[0]
class HashTests(unittest.TestCase):
def test_gleicher_quelltext_ergibt_gleichen_hash(self):
"""Prüft, dass zwei identische Testfunktionen denselben Hash ergeben."""
quelltext = "def test_x(self):\n self.assertEqual(1, 1)\n"
h1 = tk._funktionshash(*_knoten(quelltext))
h2 = tk._funktionshash(*_knoten(quelltext))
self.assertEqual(h1, h2)
def test_geaenderter_testkoerper_aendert_hash(self):
"""Prüft, dass eine inhaltliche Änderung am Testkörper den Hash ändert."""
original = "def test_x(self):\n self.assertEqual(1, 1)\n"
geaendert = "def test_x(self):\n self.assertEqual(1, 2)\n"
self.assertNotEqual(tk._funktionshash(*_knoten(original)), tk._funktionshash(*_knoten(geaendert)))
def test_trailing_whitespace_aendert_hash_nicht(self):
"""Prüft, dass reines Trailing Whitespace am Zeilenende den Hash nicht ändert."""
ohne = "def test_x(self):\n self.assertEqual(1, 1)\n"
mit_leerzeichen = "def test_x(self): \n self.assertEqual(1, 1) \n"
self.assertEqual(tk._funktionshash(*_knoten(ohne)), tk._funktionshash(*_knoten(mit_leerzeichen)))
class LiesPruefungenTests(unittest.TestCase):
def test_lies_pruefungen_ohne_datei_gibt_leeres_ergebnis(self):
"""Prüft, dass eine fehlende Prüfungsdatei ein leeres Ergebnis ohne Prüfsumme liefert."""
with tempfile.TemporaryDirectory() as tmp:
daten, kurz = tk.lies_pruefungen(Path(tmp) / "nicht_vorhanden.json")
self.assertEqual(daten, {})
self.assertEqual(kurz, "")
def test_lies_pruefungen_liefert_kurzpruefsumme(self):
"""Prüft, dass eine vorhandene Prüfungsdatei eine 12-stellige Kurzprüfsumme liefert."""
with tempfile.TemporaryDirectory() as tmp:
pfad = Path(tmp) / "testpruefungen.json"
pfad.write_text('{"a": {"datum": "2026-01-01", "hash": "abc123"}}', encoding="utf-8")
daten, kurz = tk.lies_pruefungen(pfad)
self.assertEqual(daten["a"]["hash"], "abc123")
self.assertEqual(len(kurz), 12)
class SetzePruefstatusTests(unittest.TestCase):
def test_setze_pruefstatus_erkennt_nie_geprueft_aktuell_und_veraltet(self):
"""Prüft, dass setze_pruefstatus zwischen nie geprüft, aktuell geprüft und veraltet unterscheidet."""
with tempfile.TemporaryDirectory() as tmp:
pfad = Path(tmp) / "test_beispiel.py"
pfad.write_text(TESTDATEI_INHALT, encoding="utf-8")
with mock.patch.object(tk, "ROOT", Path(tmp)):
datei = tk.lies_testdatei(pfad, "testpaket")
faelle = {f.name: f for f in datei.faelle}
pruefungen = {
tk.voller_testpfad(datei, faelle["test_zwei"]): {
"datum": "2026-01-01",
"hash": faelle["test_zwei"].quellhash,
},
}
katalog = tk.Paketkatalog(paket="testpaket", ordner="Testpaket", rolle="infrastruktur", dateien=[datei])
tk.setze_pruefstatus(katalog, pruefungen)
self.assertEqual(faelle["test_eins"].pruefstatus, "nie geprüft")
self.assertEqual(faelle["test_zwei"].pruefstatus, "aktuell geprüft")
pruefungen[tk.voller_testpfad(datei, faelle["test_zwei"])]["hash"] = "veraltet-hash"
tk.setze_pruefstatus(katalog, pruefungen)
self.assertEqual(faelle["test_zwei"].pruefstatus, "veraltet")
def test_pruef_text_formatiert_alle_drei_zustaende(self):
"""Prüft, dass pruef_text für jeden Prüfstatus den erwarteten Anzeigetext liefert."""
nie = tk.Testfall(klasse="T", name="test_a", beschreibung="")
aktuell = tk.Testfall(klasse="T", name="test_b", beschreibung="", pruefstatus="aktuell geprüft", pruefdatum="2026-01-01")
veraltet = tk.Testfall(klasse="T", name="test_c", beschreibung="", pruefstatus="veraltet", pruefdatum="2026-01-01")
self.assertEqual(tk.pruef_text(nie), "nie geprüft")
self.assertEqual(tk.pruef_text(aktuell), "geprüft am 2026-01-01")
self.assertEqual(tk.pruef_text(veraltet), "veraltet seit Änderung – zuletzt geprüft am 2026-01-01")
class SchreibePruefungenTests(unittest.TestCase):
def test_markieren_schreibt_deterministisch(self):
"""Prüft, dass schreibe_pruefungen unabhängig von der Einfügereihenfolge dieselbe Datei erzeugt."""
eintrag_a = {"datum": "2026-01-01", "hash": "aaa"}
eintrag_b = {"datum": "2026-01-02", "hash": "bbb"}
with tempfile.TemporaryDirectory() as tmp:
pfad1 = Path(tmp) / "eins.json"
pfad2 = Path(tmp) / "zwei.json"
test_audit.schreibe_pruefungen(pfad1, {"a": eintrag_a, "b": eintrag_b})
test_audit.schreibe_pruefungen(pfad2, {"b": eintrag_b, "a": eintrag_a})
self.assertEqual(pfad1.read_bytes(), pfad2.read_bytes())
class WorkspaceGestuetzteTests(unittest.TestCase):
"""Basisklasse mit einem eigenen Mini-Workspace (ein Paket, eine Testdatei)."""
def setUp(self):
self._tmp = tempfile.TemporaryDirectory()
self.addCleanup(self._tmp.cleanup)
self.root = Path(self._tmp.name)
(self.root / "Testpaket" / "tests").mkdir(parents=True)
(self.root / "Testpaket" / "tests" / "test_beispiel.py").write_text(TESTDATEI_INHALT, encoding="utf-8")
(self.root / "workspace.toml").write_text(WORKSPACE_TOML, encoding="utf-8")
for patch in (
mock.patch.object(tk, "ROOT", self.root),
mock.patch.object(tk, "MANIFEST", self.root / "workspace.toml"),
):
patch.start()
self.addCleanup(patch.stop)
self.pruefungsdatei = self.root / "testpruefungen.json"
def _stdout(self, aufruf) -> tuple[int, str]:
puffer = io.StringIO()
with contextlib.redirect_stdout(puffer):
code = aufruf()
return code, puffer.getvalue()
def test_status_befehl_zeigt_ungeprueften_test_an(self):
"""Prüft, dass der status-Befehl einen noch nie geprüften Testfall als solchen anzeigt."""
import argparse
args = argparse.Namespace(
pakete=[], muster=None, nur=None,
baseline_datei=self.root / "keine_baseline.json",
pruefungsdatei=self.pruefungsdatei,
)
code, ausgabe = self._stdout(lambda: test_audit.befehl_status(args))
self.assertEqual(code, 0)
self.assertIn("test_eins", ausgabe)
self.assertIn("nie geprüft", ausgabe)
def test_markieren_ohne_treffer_gibt_exitcode_1(self):
"""Prüft, dass ein Muster ohne Treffer Exit-Code 1 liefert und keine Datei schreibt."""
import argparse
args = argparse.Namespace(
paket="testpaket", bezeichner=["NichtVorhanden.*"], datum="2026-01-01",
trocken=False, pruefungsdatei=self.pruefungsdatei,
)
code, _ = self._stdout(lambda: test_audit.befehl_markieren(args))
self.assertEqual(code, 1)
self.assertFalse(self.pruefungsdatei.exists())
def test_markieren_schreibt_treffer_und_status_zeigt_es_danach_an(self):
"""Prüft, dass ein markierter Testfall im Status anschließend als aktuell geprüft erscheint."""
import argparse
markieren_args = argparse.Namespace(
paket="testpaket", bezeichner=["TestBeispiel.test_eins"], datum="2026-01-01",
trocken=False, pruefungsdatei=self.pruefungsdatei,
)
code, _ = self._stdout(lambda: test_audit.befehl_markieren(markieren_args))
self.assertEqual(code, 0)
self.assertTrue(self.pruefungsdatei.exists())
status_args = argparse.Namespace(
pakete=[], muster="test_eins", nur=None,
baseline_datei=self.root / "keine_baseline.json",
pruefungsdatei=self.pruefungsdatei,
)
_, ausgabe = self._stdout(lambda: test_audit.befehl_status(status_args))
self.assertIn("geprüft am 2026-01-01", ausgabe)
if __name__ == "__main__":
unittest.main()