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:
@@ -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 |
|
| `Linkliste` | `linkliste.csv` → Gitea Action erzeugt XML/JSON/YAML |
|
||||||
| `Defaults` | Beispiel-Verfahrensdaten (JSON) und `styles/verfahrensgebiet.qml`; nicht vom Code referenziert |
|
| `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)
|
## Größte Dateien (Kandidaten für das spätere Refactoring)
|
||||||
|
|
||||||
| Datei | Zeilen |
|
| Datei | Zeilen |
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
# Tests: was geprüft wird, wie man es liest, was Pflicht ist
|
# 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.
|
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.
|
fachlich das Richtige prüft. Dafür muss man keinen Code lesen können.
|
||||||
|
|
||||||
## 1. Warum das hier ernster genommen wird als sonst
|
## 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`
|
Nach dem Hinzufügen von Tests den Katalog neu erzeugen und mit committen. `python scripts/testkatalog.py --pruefen`
|
||||||
meldet, wenn er veraltet ist.
|
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
|
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
|
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
|
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.
|
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)
|
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.**
|
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.
|
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.
|
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?**
|
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`.
|
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`
|
- [ ] `python scripts/run_tests.py <paket> --baseline` ohne `NEU ROT`
|
||||||
- [ ] Neue Funktionalität hat mindestens einen Test je neuem Verhalten
|
- [ ] Neue Funktionalität hat mindestens einen Test je neuem Verhalten
|
||||||
|
|||||||
+39
-34
File diff suppressed because one or more lines are too long
+415
-412
File diff suppressed because it is too large
Load Diff
@@ -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
|
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
|
(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)
|
`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)
|
- **`.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`)
|
## QGIS-Tests (geplant, siehe `docs/konzept/07_qgis_tests_plan.md`)
|
||||||
|
|||||||
@@ -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
@@ -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
|
Der Status je Testfall stammt aus docs/status/testbaseline.json; dort stehen
|
||||||
unter <paket>.rot Einträge der Form "<methode> (<modulpfad>.<Klasse>.<methode>)".
|
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
|
Determinismus: Die erzeugte Datei wird versioniert, darf also bei unverändertem
|
||||||
Eingang keine Diffs erzeugen. Deshalb steht im Kopf weder Uhrzeit noch
|
Eingang keine Diffs erzeugen. Deshalb steht im Kopf weder Uhrzeit noch
|
||||||
Erzeugungsdatum – auch ein reines Tagesdatum würde die Datei bei jedem Lauf an
|
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]
|
ROOT = Path(__file__).resolve().parents[1]
|
||||||
MANIFEST = ROOT / "workspace.toml"
|
MANIFEST = ROOT / "workspace.toml"
|
||||||
BASELINE_DEFAULT = ROOT / "docs" / "status" / "testbaseline.json"
|
BASELINE_DEFAULT = ROOT / "docs" / "status" / "testbaseline.json"
|
||||||
|
PRUEFUNGEN_DEFAULT = ROOT / "docs" / "status" / "testpruefungen.json"
|
||||||
AUSGABE_DEFAULT = ROOT / "docs" / "tests" / "testkatalog.md"
|
AUSGABE_DEFAULT = ROOT / "docs" / "tests" / "testkatalog.md"
|
||||||
HTML_AUSGABE_DEFAULT = ROOT / "docs" / "tests" / "testkatalog.html"
|
HTML_AUSGABE_DEFAULT = ROOT / "docs" / "tests" / "testkatalog.html"
|
||||||
|
|
||||||
@@ -87,6 +93,9 @@ class Testfall:
|
|||||||
grund: str = ""
|
grund: str = ""
|
||||||
erwarteter_fehlschlag: bool = False
|
erwarteter_fehlschlag: bool = False
|
||||||
status: str = "grün"
|
status: str = "grün"
|
||||||
|
quellhash: str = ""
|
||||||
|
pruefstatus: str = "nie geprüft"
|
||||||
|
pruefdatum: str = ""
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def bezeichnung(self) -> str:
|
def bezeichnung(self) -> str:
|
||||||
@@ -188,6 +197,22 @@ def lies_skip(dekoratoren: list[ast.expr]) -> tuple[bool, str, bool]:
|
|||||||
return uebersprungen, grund, erwartet
|
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:
|
def ist_testcase(klasse: ast.ClassDef) -> bool:
|
||||||
"""True für Klassen, die als unittest-TestCase gelten.
|
"""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:
|
def lies_testdatei(pfad: Path, paket: str) -> Testdatei:
|
||||||
"""Parst eine Testdatei per ast (ohne sie zu importieren)."""
|
"""Parst eine Testdatei per ast (ohne sie zu importieren)."""
|
||||||
quelle = pfad.read_text(encoding="utf-8")
|
quelle = pfad.read_text(encoding="utf-8")
|
||||||
|
zeilen = quelle.splitlines()
|
||||||
baum = ast.parse(quelle, filename=str(pfad))
|
baum = ast.parse(quelle, filename=str(pfad))
|
||||||
datei = Testdatei(
|
datei = Testdatei(
|
||||||
pfad=pfad,
|
pfad=pfad,
|
||||||
@@ -235,6 +261,7 @@ def lies_testdatei(pfad: Path, paket: str) -> Testdatei:
|
|||||||
uebersprungen=skip or klassen_skip,
|
uebersprungen=skip or klassen_skip,
|
||||||
grund=grund or (klassen_grund if klassen_skip else ""),
|
grund=grund or (klassen_grund if klassen_skip else ""),
|
||||||
erwarteter_fehlschlag=erwartet or klassen_erwartet,
|
erwarteter_fehlschlag=erwartet or klassen_erwartet,
|
||||||
|
quellhash=_funktionshash(zeilen, glied),
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
if gruppe.faelle:
|
if gruppe.faelle:
|
||||||
@@ -253,6 +280,11 @@ def sammle_paket(paket: str, repo: dict) -> Paketkatalog | None:
|
|||||||
return katalog
|
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]:
|
def lies_baseline(pfad: Path) -> tuple[dict[str, set[str]], str]:
|
||||||
"""Rote Tests je Paket als Menge '<modulpfad>.<Klasse>.<methode>' plus Kurzprüfsumme."""
|
"""Rote Tests je Paket als Menge '<modulpfad>.<Klasse>.<methode>' plus Kurzprüfsumme."""
|
||||||
if not pfad.exists():
|
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:
|
def setze_status(katalog: Paketkatalog, rot: set[str]) -> None:
|
||||||
for datei in katalog.dateien:
|
for datei in katalog.dateien:
|
||||||
for fall in datei.faelle:
|
for fall in datei.faelle:
|
||||||
voll = f"{datei.modulpfad}.{fall.bezeichnung}"
|
voll = voller_testpfad(datei, fall)
|
||||||
if fall.uebersprungen:
|
if fall.uebersprungen:
|
||||||
fall.status = "übersprungen"
|
fall.status = "übersprungen"
|
||||||
elif fall.erwarteter_fehlschlag:
|
elif fall.erwarteter_fehlschlag:
|
||||||
@@ -285,6 +317,27 @@ def setze_status(katalog: Paketkatalog, rot: set[str]) -> None:
|
|||||||
fall.status = "grün"
|
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]:
|
def zaehle(faelle: list[Testfall]) -> dict[str, int]:
|
||||||
return {
|
return {
|
||||||
"gesamt": len(faelle),
|
"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")),
|
"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"),
|
"erwartet": sum(1 for f in faelle if f.status == "erwarteter Fehlschlag"),
|
||||||
"mit_docstring": sum(1 for f in faelle if f.beschreibung),
|
"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
|
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]
|
alle = [f for k in kataloge for f in k.faelle]
|
||||||
summe = zaehle(alle)
|
summe = zaehle(alle)
|
||||||
baseline_anzeige = baseline_pfad.relative_to(ROOT).as_posix() if baseline_pfad.is_relative_to(ROOT) else str(baseline_pfad)
|
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: list[str] = []
|
||||||
z.append("# Testkatalog der sn_*-Plugins")
|
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}`)")
|
z.append(f"Stand der Baseline: `{baseline_anzeige}` (SHA-256 `{baseline_kurz}`)")
|
||||||
else:
|
else:
|
||||||
z.append(f"Stand der Baseline: `{baseline_anzeige}` **nicht gefunden** – alle Testfälle gelten als grün.")
|
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("")
|
||||||
z.append(
|
z.append(
|
||||||
f"Gesamt: {len(kataloge)} Pakete, {sum(len(k.dateien) for k in kataloge)} Testdateien, "
|
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."
|
f"{summe['uebersprungen']} übersprungen."
|
||||||
)
|
)
|
||||||
z.append(f"Mit Beschreibung (Docstring): {anteil(summe['mit_docstring'], summe['gesamt'])}.")
|
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("")
|
||||||
|
|
||||||
z.append("## Zusammenfassung")
|
z.append("## Zusammenfassung")
|
||||||
z.append("")
|
z.append("")
|
||||||
z.append("| Paket | Testdateien | Testfälle | grün | rot (Baseline) | übersprungen | mit Beschreibung |")
|
z.append("| Paket | Testdateien | Testfälle | grün | rot (Baseline) | übersprungen | mit Beschreibung | fachlich geprüft |")
|
||||||
z.append("| --- | ---: | ---: | ---: | ---: | ---: | ---: |")
|
z.append("| --- | ---: | ---: | ---: | ---: | ---: | ---: | ---: |")
|
||||||
for katalog in kataloge:
|
for katalog in kataloge:
|
||||||
k = zaehle(katalog.faelle)
|
k = zaehle(katalog.faelle)
|
||||||
z.append(
|
z.append(
|
||||||
f"| `{katalog.paket}` | {len(katalog.dateien)} | {k['gesamt']} | {k['gruen']} | "
|
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(
|
z.append(
|
||||||
f"| **Summe** | **{sum(len(k.dateien) for k in kataloge)}** | **{summe['gesamt']}** | "
|
f"| **Summe** | **{sum(len(k.dateien) for k in kataloge)}** | **{summe['gesamt']}** | "
|
||||||
f"**{summe['gruen']}** | **{summe['rot']}** | **{summe['uebersprungen']}** | "
|
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("")
|
z.append("")
|
||||||
if summe["erwartet"]:
|
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(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("- **ü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("- 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("")
|
z.append("")
|
||||||
|
|
||||||
for katalog in kataloge:
|
for katalog in kataloge:
|
||||||
@@ -399,10 +490,12 @@ def erzeuge_markdown(kataloge: list[Paketkatalog], baseline_pfad: Path, baseline
|
|||||||
for gruppe in datei.gruppen:
|
for gruppe in datei.gruppen:
|
||||||
z.append(f"- `{gruppe.name}` ({len(gruppe.faelle)} Testfälle): {zelle(gruppe.beschreibung)}")
|
z.append(f"- `{gruppe.name}` ({len(gruppe.faelle)} Testfälle): {zelle(gruppe.beschreibung)}")
|
||||||
z.append("")
|
z.append("")
|
||||||
z.append("| Testfall | prüft | Status |")
|
z.append("| Testfall | prüft | Status | geprüft |")
|
||||||
z.append("| --- | --- | --- |")
|
z.append("| --- | --- | --- | --- |")
|
||||||
for fall in datei.faelle:
|
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("")
|
||||||
|
|
||||||
z.append("## Lücken")
|
z.append("## Lücken")
|
||||||
@@ -440,6 +533,12 @@ STATUS_KLASSE = {
|
|||||||
"erwarteter Fehlschlag": "skip",
|
"erwarteter Fehlschlag": "skip",
|
||||||
}
|
}
|
||||||
|
|
||||||
|
PRUEF_KLASSE = {
|
||||||
|
"aktuell geprüft": "ok",
|
||||||
|
"veraltet": "warn",
|
||||||
|
"nie geprüft": "skip",
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
def _slug(text: str) -> str:
|
def _slug(text: str) -> str:
|
||||||
return re.sub(r"[^a-zA-Z0-9]+", "-", text).strip("-").lower()
|
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
|
zaehler[STATUS_KLASSE.get(fall.status, "ok")] += 1
|
||||||
mit_doc = sum(1 for f in faelle if f.beschreibung)
|
mit_doc = sum(1 for f in faelle if f.beschreibung)
|
||||||
doc_pct = round(100 * mit_doc / gesamt) if gesamt else 0
|
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:
|
if not datei.gruppen:
|
||||||
koerper = (
|
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."}
|
kurz = {"grün": "grün", "rot (Baseline)": "rot", "übersprungen": "übersp.", "erwarteter Fehlschlag": "übersp."}
|
||||||
for fall in faelle:
|
for fall in faelle:
|
||||||
klasse = STATUS_KLASSE.get(fall.status, "ok")
|
klasse = STATUS_KLASSE.get(fall.status, "ok")
|
||||||
|
pruef_klasse = PRUEF_KLASSE.get(fall.pruefstatus, "skip")
|
||||||
pruft = (
|
pruft = (
|
||||||
f'<span class="nodoc">— (Docstring fehlt)</span>'
|
f'<span class="nodoc">— (Docstring fehlt)</span>'
|
||||||
if not fall.beschreibung
|
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'<tr><td class="mono">{html.escape(fall.klasse)}<br>'
|
||||||
f'<span class="testname">{html.escape(fall.name)}</span></td>'
|
f'<span class="testname">{html.escape(fall.name)}</span></td>'
|
||||||
f"<td>{pruft}</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 = (
|
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>"
|
f"<tbody>{''.join(zeilen)}</tbody></table>"
|
||||||
)
|
)
|
||||||
diagramm = (
|
diagramm = (
|
||||||
@@ -536,12 +642,14 @@ def _html_karte_fuer_datei(datei: Testdatei) -> str:
|
|||||||
'<span class="card-meta">'
|
'<span class="card-meta">'
|
||||||
f'<span class="count-total">{gesamt} Testfälle</span>{pills}'
|
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="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>'
|
'<span class="chev" aria-hidden="true">▾</span></span></button>'
|
||||||
)
|
)
|
||||||
return (
|
return (
|
||||||
f'<article class="filecard" data-search="{suchtext}" '
|
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-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"{header}"
|
||||||
f'<div class="card-body" id="body-{fid}">{koerper}</div>'
|
f'<div class="card-body" id="body-{fid}">{koerper}</div>'
|
||||||
"</article>"
|
"</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{ 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); }
|
.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; }
|
.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; }
|
.chev{ color:var(--muted); transition:transform .15s ease; }
|
||||||
.filecard.open .chev{ transform:rotate(180deg); }
|
.filecard.open .chev{ transform:rotate(180deg); }
|
||||||
.card-body{ max-height:0; overflow:hidden; transition:max-height .22s ease; border-top:0 solid var(--line); }
|
.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; }
|
.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{ 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-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; }
|
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; }
|
[hidden]{ display:none !important; }
|
||||||
@media (max-width:640px){ .pkg-bar{ width:130px; } .controls{ position:static; } }
|
@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
|
"""Erzeugt das HTML-Dashboard 'Prüfstand' – dieselben Daten wie erzeuge_markdown, als
|
||||||
interaktive Seite mit Baumdiagramm (Mermaid) je Testdatei. Läuft lokal ohne Server."""
|
interaktive Seite mit Baumdiagramm (Mermaid) je Testdatei. Läuft lokal ohne Server."""
|
||||||
alle = [f for k in kataloge for f in k.faelle]
|
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_anzeige = (
|
||||||
baseline_pfad.relative_to(ROOT).as_posix() if baseline_pfad.is_relative_to(ROOT) else str(baseline_pfad)
|
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 = (
|
meta_zeile = (
|
||||||
f'Baseline: <span class="mono">{html.escape(baseline_anzeige)}</span> (SHA-256 {baseline_kurz})'
|
f'Baseline: <span class="mono">{html.escape(baseline_anzeige)}</span> (SHA-256 {baseline_kurz})'
|
||||||
if baseline_kurz
|
if baseline_kurz
|
||||||
else f'Baseline <span class="mono">{html.escape(baseline_anzeige)}</span> nicht gefunden'
|
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(
|
nav_links = "\n ".join(
|
||||||
f'<a href="#pkg-{html.escape(k.paket)}">{html.escape(k.paket)} <span class="mono">({len(k.faelle)})</span></a>'
|
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(
|
teile.append(
|
||||||
'<span class="tagline">Testkatalog der sn_*-Plugins — was die Suite heute prüft, auf einen Blick.</span></div>'
|
'<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('<div class="summary">')
|
||||||
teile.append(f'<div class="stat"><span class="n">{summe["gesamt"]}</span><span class="l">Testfälle</span></div>')
|
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 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>')
|
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
|
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">{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(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>")
|
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="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="hasskip" aria-pressed="false">Mit übersprungen</button>')
|
||||||
teile.append('<button class="chip" data-filter="nodoc" aria-pressed="false">Schwach beschrieben (<50%)</button>')
|
teile.append('<button class="chip" data-filter="nodoc" aria-pressed="false">Schwach beschrieben (<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>")
|
||||||
teile.append(
|
teile.append(
|
||||||
'<div class="searchbox"><svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" '
|
'<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)")
|
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("--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("--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()
|
args = ap.parse_args()
|
||||||
|
|
||||||
manifest = lade_manifest()
|
manifest = lade_manifest()
|
||||||
@@ -879,6 +1011,9 @@ def main() -> int:
|
|||||||
rot_je_paket, baseline_kurz = lies_baseline(args.baseline_datei)
|
rot_je_paket, baseline_kurz = lies_baseline(args.baseline_datei)
|
||||||
if not baseline_kurz:
|
if not baseline_kurz:
|
||||||
print(f"Keine Baseline unter {args.baseline_datei} – alle Testfälle werden als grün geführt.")
|
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] = []
|
kataloge: list[Paketkatalog] = []
|
||||||
for paket in reihenfolge:
|
for paket in reihenfolge:
|
||||||
@@ -890,6 +1025,7 @@ def main() -> int:
|
|||||||
print(f"[{paket}] kein Ordner {repos[paket]['name']}/tests – übersprungen")
|
print(f"[{paket}] kein Ordner {repos[paket]['name']}/tests – übersprungen")
|
||||||
continue
|
continue
|
||||||
setze_status(katalog, rot_je_paket.get(paket, set()))
|
setze_status(katalog, rot_je_paket.get(paket, set()))
|
||||||
|
setze_pruefstatus(katalog, pruefungen)
|
||||||
k = zaehle(katalog.faelle)
|
k = zaehle(katalog.faelle)
|
||||||
print(f"[{paket}] {len(katalog.dateien)} Testdateien, {k['gesamt']} Testfälle "
|
print(f"[{paket}] {len(katalog.dateien)} Testdateien, {k['gesamt']} Testfälle "
|
||||||
f"({k['gruen']} grün, {k['rot']} rot, {k['uebersprungen']} übersprungen, "
|
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.")
|
print("Keine Pakete mit Testdateien gefunden – nichts zu tun.")
|
||||||
return 2
|
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:
|
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
|
ergebnis = 0
|
||||||
for ziel, text in ausgaben:
|
for ziel, text in ausgaben:
|
||||||
|
|||||||
@@ -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()
|
||||||
Reference in New Issue
Block a user