Files
Projektwissen/scripts/run_tests.py
T
AndreasTharangandClaude Sonnet 5 ab416fb34c Projektwissen: Git-Vorgaben, Ticketzugriff, Tests und Einstieg
Erster Stand des Repositories AG_QGIS/Projektwissen (bisher Arbeitstitel
"Workspace", ADR 0006). Der lokale Ordner heisst weiter AG_QGIS, weil darin
auch die Plugin-Klone liegen.

Git verbindlich (ADR 0007), aus dem bisherigen Wiki-Konzept entwickelt:
- docs/leitfaden/05_git_arbeitsweise.md mit Kanal-Kette, Branch-Namen und
  Gitea-Branch-Schutz.
- Der Einstiegspunkt eines Fixes richtet sich nach dem Kanal, in dem der
  Fehler sitzt; ein Fix an testing oder stable ist erst mit der
  Rueckfuehrung nach unten abgeschlossen. Das fehlte bisher.
- Der stabile Kanal heisst stable, nicht main: der Release-Workflow in
  Repository:hidden/workflows fuehrt git checkout "$CHANNEL" aus, der
  Branchname muss also dem Kanalnamen entsprechen. Er existiert in keinem
  Plugin-Repo und ist vom Maintainer anzulegen.
- Der Mensch drueckt ab: .claude/settings.json sperrt push, merge, tag.
- scripts/hallo.py und /hallo holen den Stand aller Klone, per
  Fast-Forward wo gefahrlos; nie mergen, stashen oder verwerfen.

Ticketzugriff:
- scripts/gitea.py liest Issues, Kommentare und PRs ueber die oeffentliche
  API, ohne Token, nur GET. Schreibendes nur als Entwurf in entwuerfe/.
- Skill sn-ticket.

Tests (ADR 0009):
- docs/leitfaden/06_tests.md: Testebenen, Testpflicht, und wie eine
  Fachperson beurteilt, ob Tests fachlich sinnvoll sind.
- scripts/testkatalog.py erzeugt docs/tests/testkatalog.md per ast-Parsing,
  ohne Import und ohne QGIS. Befund: nur 100 von 332 Tests haben einen
  Docstring, sn_basis keinen einzigen von 87.
- Vorlage und erste Testkonzept-Seite mit Mermaid. Dabei gefunden und gegen
  origin geprueft: _ermittle_gebietstyp_aus_fbschl hat keinen Test, obwohl
  PR #44/#45 genau diese Funktion tolerant gemacht hat.
- docs/konzept/07_qgis_tests_plan.md: Stufenplan fuer Tests gegen echtes
  QGIS. Dabei aufgefallen: QGIS 3.40 ist seit Januar 2026 End of Life,
  qgis_min steht aber noch darauf.

Werkzeuge (ADR 0008):
- 04_werkzeuge_einrichten.md nur noch Claude Code als CLI und in VS Code.
- .vscode/ entfernt, brauchbare Einstellungen als persoenliche Vorlage.

Ausserdem: README als Einstieg neu aufgebaut, Mermaid-Diagramm in
schnittstellen.md repariert (unmaskierte Klammern, rendert seither nicht).

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-04 11:57:26 +02:00

196 lines
8.0 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
#!/usr/bin/env python3
"""Führt die unittest-Suiten der sn_*-Plugins im Mock-Modus (ohne QGIS) aus.
Die Plugins werden unter ihrem Paketnamen (sn_basis, sn_plan41, ...) in ein
temporäres Verzeichnis verlinkt, damit die Importe wie im QGIS-Plugin-Ordner
funktionieren. Läuft auf Linux, macOS und Windows mit jedem Python >= 3.11.
Aufrufe (aus dem Workspace-Root):
python scripts/run_tests.py # alle Pakete mit Tests
python scripts/run_tests.py sn_basis # nur ein Paket
python scripts/run_tests.py sn_basis --verbose # volle unittest-Ausgabe
python scripts/run_tests.py --baseline # Vergleich mit docs/status/testbaseline.json
python scripts/run_tests.py --update-baseline # Baseline neu schreiben (nur nach Absprache)
Exit-Code: 0 = alle Suiten grün (bzw. mit --baseline: keine neuen roten Tests), sonst 1.
"""
from __future__ import annotations
import argparse
import json
import os
import re
import shutil
import subprocess
import sys
import tempfile
import tomllib
from pathlib import Path
ROOT = Path(__file__).resolve().parents[1]
MANIFEST = ROOT / "workspace.toml"
BASELINE_DEFAULT = ROOT / "docs" / "status" / "testbaseline.json"
def lade_manifest() -> dict:
with MANIFEST.open("rb") as f:
return tomllib.load(f)
def verlinke(quelle: Path, ziel: Path) -> str:
"""Verknüpft quelle als ziel (Symlink, Windows-Junction oder Kopie)."""
try:
os.symlink(quelle, ziel, target_is_directory=True)
return "symlink"
except (OSError, NotImplementedError):
pass
if os.name == "nt":
try:
import _winapi # type: ignore[attr-defined]
_winapi.CreateJunction(str(quelle), str(ziel))
return "junction"
except Exception:
pass
shutil.copytree(
quelle, ziel,
ignore=shutil.ignore_patterns(".git", "__pycache__", "*.pyc"),
)
return "kopie"
def parse_ergebnis(ausgabe: str) -> dict:
m = re.search(r"^Ran (\d+) tests?", ausgabe, re.M)
gesamt = int(m.group(1)) if m else 0
fail = err = skip = 0
m = re.search(r"^FAILED \((.*)\)", ausgabe, re.M)
if m:
for teil in m.group(1).split(","):
k, _, v = teil.strip().partition("=")
if k == "failures":
fail = int(v)
elif k == "errors":
err = int(v)
elif k == "skipped":
skip = int(v)
else:
m = re.search(r"^OK(?: \(skipped=(\d+)\))?", ausgabe, re.M)
if m and m.group(1):
skip = int(m.group(1))
rot = sorted(set(re.findall(r"^(?:FAIL|ERROR): (\S+ \([\w.]+\))", ausgabe, re.M)))
return {
"gesamt": gesamt, "fehlschlaege": fail, "fehler": err,
"uebersprungen": skip, "gruen": gesamt - fail - err - skip, "rot": rot,
}
def main() -> int:
ap = argparse.ArgumentParser(description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter)
ap.add_argument("pakete", nargs="*", help="Paketnamen (Standard: alle mit tests=true)")
ap.add_argument("--verbose", "-v", action="store_true", help="volle unittest-Ausgabe anzeigen")
ap.add_argument("--baseline", action="store_true", help="mit Baseline vergleichen")
ap.add_argument("--update-baseline", action="store_true", help="Baseline aus diesem Lauf schreiben")
ap.add_argument("--baseline-datei", type=Path, default=BASELINE_DEFAULT)
ap.add_argument("--json", type=Path, help="Ergebnis als JSON in diese Datei schreiben")
args = ap.parse_args()
manifest = lade_manifest()
repos = {r["paket"]: r for r in manifest["repos"] if r.get("paket")}
testbare = [p for p, r in repos.items() if r.get("tests")]
gewuenscht = args.pakete or testbare
unbekannt = [p for p in gewuenscht if p not in repos]
if unbekannt:
print(f"Unbekannte Pakete: {unbekannt}. Bekannt: {sorted(repos)}")
return 2
# Abhängigkeiten immer mit verlinken (sn_basis wird von allen importiert;
# sn_basis.DataGrabber importiert sn_plan41 lazy).
zu_verlinken: set[str] = set()
for p in gewuenscht:
zu_verlinken.add(p)
zu_verlinken.update(repos[p].get("abhaengigkeiten", []))
zu_verlinken.update(p for p in testbare)
fehlend = [p for p in zu_verlinken if not (ROOT / repos[p]["name"]).is_dir()]
if fehlend:
print(f"Repository-Ordner fehlen: {[repos[p]['name'] for p in fehlend]} "
f"-> zuerst 'python scripts/setup_workspace.py' ausführen.")
return 2
ergebnisse: dict[str, dict] = {}
rc = 0
with tempfile.TemporaryDirectory(prefix="sn_tests_") as tmp:
tmp_pfad = Path(tmp)
for p in sorted(zu_verlinken):
verlinke(ROOT / repos[p]["name"], tmp_pfad / p)
env = dict(os.environ, PYTHONDONTWRITEBYTECODE="1", PYTHONIOENCODING="utf-8", PYTHONPATH=str(tmp_pfad))
for p in gewuenscht:
if not repos[p].get("tests"):
print(f"[{p}] keine Tests vorhanden – übersprungen")
continue
cmd = [sys.executable, "-m", "unittest", "discover", "-s", f"{p}/tests", "-t", ".", "-p", "test_*.py"]
proz = subprocess.run(cmd, cwd=tmp_pfad, env=env, capture_output=True, text=True, errors="replace")
ausgabe = proz.stdout + proz.stderr
erg = parse_ergebnis(ausgabe)
ergebnisse[p] = erg
if args.verbose:
print(f"\n{'=' * 70}\n[{p}] {' '.join(cmd)}\n{'=' * 70}\n{ausgabe}")
status = "GRÜN" if not erg["rot"] and erg["gesamt"] else "ROT"
print(f"[{p}] {status}: {erg['gruen']}/{erg['gesamt']} bestanden, "
f"{erg['fehlschlaege']} Fehlschläge, {erg['fehler']} Fehler, {erg['uebersprungen']} übersprungen")
if erg["gesamt"] == 0:
print(f" Keine Tests gefunden oder Import-Fehler. Mit --verbose prüfen.")
rc = 1
# Baseline-Vergleich: verhindert, dass bereits rote Tests "vermehrt" werden.
if args.baseline or args.update_baseline:
alt: dict = {}
baseline_vorhanden = args.baseline_datei.exists()
if baseline_vorhanden:
alt = json.loads(args.baseline_datei.read_text(encoding="utf-8"))
elif not args.update_baseline:
print(f"Keine Baseline unter {args.baseline_datei.relative_to(ROOT)} – alle roten Tests gelten als neu.")
neu_rot_gesamt = 0
for p, erg in (ergebnisse.items() if (baseline_vorhanden or not args.update_baseline) else []):
alt_rot = set(alt.get(p, {}).get("rot", []))
neu_rot = sorted(set(erg["rot"]) - alt_rot)
neu_gruen = sorted(alt_rot - set(erg["rot"]))
if neu_rot:
neu_rot_gesamt += len(neu_rot)
print(f"[{p}] NEU ROT ({len(neu_rot)}):")
for t in neu_rot:
print(f" - {t}")
if neu_gruen:
print(f"[{p}] neu grün ({len(neu_gruen)}): {', '.join(neu_gruen)}")
if not neu_rot and not neu_gruen:
print(f"[{p}] unverändert gegenüber Baseline")
if args.update_baseline:
alt.update({p: {k: v for k, v in erg.items()} for p, erg in ergebnisse.items()})
args.baseline_datei.parent.mkdir(parents=True, exist_ok=True)
args.baseline_datei.write_text(json.dumps(alt, indent=2, ensure_ascii=False) + "\n", encoding="utf-8")
print(f"Baseline geschrieben: {args.baseline_datei.relative_to(ROOT)}")
rc = 1 if neu_rot_gesamt else rc
else:
for erg in ergebnisse.values():
if erg["rot"]:
rc = 1
if rc:
print("\nRote Tests (Kurzliste, Details mit --verbose):")
for p, erg in ergebnisses_sortiert(ergebnisse):
for t in erg["rot"]:
print(f" [{p}] {t}")
if args.json:
args.json.write_text(json.dumps(ergebnisse, indent=2, ensure_ascii=False) + "\n", encoding="utf-8")
return rc
def ergebnisses_sortiert(ergebnisse: dict[str, dict]):
return sorted(ergebnisse.items())
if __name__ == "__main__":
raise SystemExit(main())