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>
196 lines
8.0 KiB
Python
196 lines
8.0 KiB
Python
#!/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())
|