HTML-Dashboard 'Pruefstand' fuer den Testkatalog ergaenzt

scripts/testkatalog.py schreibt jetzt zusaetzlich zu testkatalog.md ein
eigenstaendiges, lokal oeffenbares HTML-Dashboard (docs/tests/testkatalog.html):
je Testdatei ein Mermaid-Baumdiagramm Datei -> Testgruppe -> Testfall,
Statusfarbe aus der Baseline, plus Filter (rot/uebersprungen/schwach
beschrieben) und Volltextsuche. Generiert direkt aus denselben
Paketkatalog-Dataclasses wie der Markdown-Katalog, kein zweiter Parse-Schritt.

--pruefen prueft jetzt beide Dateien; --ohne-html erzeugt/prueft nur die
Markdown-Datei; --html-ausgabe setzt ein anderes Ziel. Mermaid laedt von
cdnjs.cloudflare.com (Fallstrick dokumentiert: ohne Internetverbindung bleiben
nur die Diagramme leer).

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
2026-09-15 11:36:01 +02:00
co-authored by Claude Sonnet 5
parent 70360acf5f
commit f7aa8ca899
6 changed files with 1725 additions and 29 deletions
+8 -4
View File
@@ -46,12 +46,16 @@ Dialoge: `Pruefmanager(ui_modus="test")` liefert Standardantworten; `dialog_wrap
## Testkatalog ## Testkatalog
```bash ```bash
python scripts/testkatalog.py # erzeugt docs/tests/testkatalog.md python scripts/testkatalog.py # erzeugt docs/tests/testkatalog.md UND testkatalog.html
python scripts/testkatalog.py --pruefen # Exit 1, wenn die Datei veraltet ist python scripts/testkatalog.py --pruefen # Exit 1, wenn eine der beiden Dateien veraltet ist
python scripts/testkatalog.py --ohne-html # nur die Markdown-Datei
``` ```
Wird aus den Testdateien per `ast` erzeugt (kein Import, läuft ohne QGIS). **Nicht von Hand bearbeiten.** Wird aus den Testdateien per `ast` erzeugt (kein Import, läuft ohne QGIS). **Nicht von Hand bearbeiten.**
Nach jeder Änderung an Tests neu erzeugen und mitcommitten. Der Abschnitt „Lücken" listet Tests ohne Nach jeder Änderung an Tests neu erzeugen und mitcommitten. Der Abschnitt „Lücken" in `testkatalog.md`
Docstring – Stand 04.09.2026 haben nur 100 von 332 Tests einen, `sn_basis` keinen einzigen. listet Tests ohne Docstring – Stand 15.09.2026 haben nur 114 von 346 Tests einen, `sn_basis` fast keine
(7 von 94). `testkatalog.html` ist dasselbe als interaktives Dashboard „Prüfstand" (Baumdiagramm je
Testdatei per Mermaid, Filter, Suche) – lokal im Browser öffnen, kein Server nötig; Mermaid lädt von
`cdnjs.cloudflare.com`, ohne Internetverbindung bleiben nur die Diagramme leer.
## Testkonzept-Seiten ## Testkonzept-Seiten
Für fachlich wichtige Abläufe liegt in `docs/tests/<ablauf>.md` eine Seite nach `docs/vorlagen/testkonzept.md`: Für fachlich wichtige Abläufe liegt in `docs/tests/<ablauf>.md` eine Seite nach `docs/vorlagen/testkonzept.md`:
+1
View File
@@ -16,6 +16,7 @@
| `leitfaden/05_git_arbeitsweise.md` | **verbindlich** | Branches, Fix-Einstieg, Rückführung, Tags, was der Agent darf | | `leitfaden/05_git_arbeitsweise.md` | **verbindlich** | Branches, Fix-Einstieg, Rückführung, Tags, was der Agent darf |
| `leitfaden/06_tests.md` | **verbindlich** | Testebenen, Testpflicht, Katalog, wie man Tests fachlich beurteilt | | `leitfaden/06_tests.md` | **verbindlich** | Testebenen, Testpflicht, Katalog, wie man Tests fachlich beurteilt |
| `tests/testkatalog.md` | generiert | was die Suite prüft – erzeugt von `scripts/testkatalog.py` | | `tests/testkatalog.md` | generiert | was die Suite prüft – erzeugt von `scripts/testkatalog.py` |
| `tests/testkatalog.html` | generiert | dieselben Daten als lokal zu öffnendes Dashboard „Prüfstand“ (Baumdiagramm je Testdatei, Filter, Suche) – vom selben Lauf erzeugt |
| `tests/*.md` | lebend | Testkonzepte je Fachablauf, mit Mermaid; Vorlage in `vorlagen/` | | `tests/*.md` | lebend | Testkonzepte je Fachablauf, mit Mermaid; Vorlage in `vorlagen/` |
| `konzept/07_qgis_tests_plan.md` | Vorschlag | Stufenplan für Tests gegen echtes QGIS | | `konzept/07_qgis_tests_plan.md` | Vorschlag | Stufenplan für Tests gegen echtes QGIS |
| `konzept/09_alkis7_umstellung.md` | Prüfliste | ALKIS 6 ↔ 7 (GID 6.0/7.1), was in `sn_verfahrensgebiet` anzupassen ist | | `konzept/09_alkis7_umstellung.md` | Prüfliste | ALKIS 6 ↔ 7 (GID 6.0/7.1), was in `sn_verfahrensgebiet` anzupassen ist |
+2 -1
View File
@@ -38,7 +38,8 @@ spätere Phase und beginnt erst, wenn die Test-Baseline grün ist und das Projek
5. **Testbeschreibungen nachziehen.** Von 332 Testfällen haben nur 100 (30 %) einen Docstring — `sn_basis` 5. **Testbeschreibungen nachziehen.** Von 332 Testfällen haben nur 100 (30 %) einen Docstring — `sn_basis`
keinen einzigen von 87, `sn_plan41` einen von 44. Ohne Beschreibung ist von außen nicht erkennbar, was ein keinen einzigen von 87, `sn_plan41` einen von 44. Ohne Beschreibung ist von außen nicht erkennbar, was ein
Test prüft. Arbeitsliste: Abschnitt „Lücken" in `docs/tests/testkatalog.md`. Pflicht für neue und geänderte Test prüft. Arbeitsliste: Abschnitt „Lücken" in `docs/tests/testkatalog.md`. Pflicht für neue und geänderte
Tests gilt ab sofort (ADR 0009). Tests gilt ab sofort (ADR 0009). Seit 15.09.2026 zusätzlich als Dashboard `docs/tests/testkatalog.html`
(lokal im Browser öffnen) – zeigt Testabdeckung und -status auf einen Blick, ohne in den Code zu schauen.
6. **Testlücken beim Shape-Import schließen.** Beim Erstellen von `docs/tests/verfahrensgebiet-shape-import.md` 6. **Testlücken beim Shape-Import schließen.** Beim Erstellen von `docs/tests/verfahrensgebiet-shape-import.md`
gefunden und gegen `origin/unstable` geprüft: `_ermittle_gebietstyp_aus_fbschl` hat **keinen** Test — obwohl gefunden und gegen `origin/unstable` geprüft: `_ermittle_gebietstyp_aus_fbschl` hat **keinen** Test — obwohl
PR #44/#45 genau diese Funktion tolerant gemacht hat. Ebenso ungetestet: mehrere Shapefile-Sätze in einer PR #44/#45 genau diese Funktion tolerant gemacht hat. Ebenso ungetestet: mehrere Shapefile-Sätze in einer
File diff suppressed because one or more lines are too long
+4
View File
@@ -11,6 +11,10 @@ Kurz, konkret, mit Datum und Quelle. Neue Einträge oben in der passenden Rubrik
- **`openpyxl` ist eine harte Abhängigkeit** von `sn_basis` (Excel-Import) und unter Linux-QGIS oft nicht installiert (Issue Verfahrensgebiet #21). `requests` ist nur optionaler Fallback. Es gibt keine `requirements.txt`. (09/2026) - **`openpyxl` ist eine harte Abhängigkeit** von `sn_basis` (Excel-Import) und unter Linux-QGIS oft nicht installiert (Issue Verfahrensgebiet #21). `requests` ist nur optionaler Fallback. Es gibt keine `requirements.txt`. (09/2026)
- **QGIS lädt Plugins in nicht garantierter Reihenfolge.** Fachplugins dürfen in `initGui()` nicht davon ausgehen, dass `sn_basis` fertig ist; `sn_basis` ruft `initGui()` aller `sn_*` nach. (Code `main.py`) - **QGIS lädt Plugins in nicht garantierter Reihenfolge.** Fachplugins dürfen in `initGui()` nicht davon ausgehen, dass `sn_basis` fertig ist; `sn_basis` ruft `initGui()` aller `sn_*` nach. (Code `main.py`)
- **Exit-Crash unter QGIS 3.40** entstand durch nicht getrennte Projektsignale beim Unload. Regel: jeder Tab/Manager, der Signale verbindet, bietet `cleanup()`/`teardown_...()` und wird beim Schließen aufgerufen. (Basis, `assets/plan-qgis340ExitCrashFix.prompt.md`) - **Exit-Crash unter QGIS 3.40** entstand durch nicht getrennte Projektsignale beim Unload. Regel: jeder Tab/Manager, der Signale verbindet, bietet `cleanup()`/`teardown_...()` und wird beim Schließen aufgerufen. (Basis, `assets/plan-qgis340ExitCrashFix.prompt.md`)
- **`docs/tests/testkatalog.html` (Dashboard „Prüfstand") lädt Mermaid von `cdnjs.cloudflare.com`.** Ohne
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)
- **`.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`)
+431 -15
View File
@@ -26,14 +26,22 @@ Eingang ändert. Sortiert wird nach der Reihenfolge in workspace.toml (Pakete),
nach Dateinamen (Testdateien) und nach der Reihenfolge im Quelltext (Klassen nach Dateinamen (Testdateien) und nach der Reihenfolge im Quelltext (Klassen
und Testfälle). und Testfälle).
Aufrufe (aus dem Workspace-Root): Zusätzlich zum Markdown-Katalog schreibt der Lauf ein eigenständiges HTML-Dashboard
python scripts/testkatalog.py # docs/tests/testkatalog.md neu schreiben (docs/tests/testkatalog.html): dieselben Daten als Baumdiagramm je Testdatei
python scripts/testkatalog.py sn_basis # nur ein Paket (Mermaid, geladen von cdnjs – ohne Internetverbindung bleiben die Diagramme leer,
python scripts/testkatalog.py --pruefen # nur prüfen, ob die Datei aktuell ist (CI, vor dem Commit) Text und Tabellen bleiben lesbar), mit Filtern und Suche. Im Browser lokal öffnen,
python scripts/testkatalog.py --ausgabe /tmp/k.md # anderes Ziel kein Server nötig. Siehe docs/wissen/fallstricke.md.
Exit-Code: 0 = erfolgreich (mit --pruefen: Datei ist aktuell), 1 = Datei veraltet Aufrufe (aus dem Workspace-Root):
oder fehlt, 2 = Aufruffehler. python scripts/testkatalog.py # .md und .html neu schreiben
python scripts/testkatalog.py sn_basis # nur ein Paket
python scripts/testkatalog.py --pruefen # nur prüfen, ob beide Dateien aktuell sind (CI, vor dem Commit)
python scripts/testkatalog.py --ausgabe /tmp/k.md # anderes Ziel für den Markdown-Katalog
python scripts/testkatalog.py --html-ausgabe /tmp/k.html
python scripts/testkatalog.py --ohne-html # nur den Markdown-Katalog erzeugen/prüfen
Exit-Code: 0 = erfolgreich (mit --pruefen: Dateien sind aktuell), 1 = mindestens
eine Datei veraltet oder fehlt, 2 = Aufruffehler.
""" """
from __future__ import annotations from __future__ import annotations
@@ -42,6 +50,7 @@ import argparse
import ast import ast
import difflib import difflib
import hashlib import hashlib
import html
import json import json
import re import re
import tomllib import tomllib
@@ -52,6 +61,7 @@ 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"
AUSGABE_DEFAULT = ROOT / "docs" / "tests" / "testkatalog.md" AUSGABE_DEFAULT = ROOT / "docs" / "tests" / "testkatalog.md"
HTML_AUSGABE_DEFAULT = ROOT / "docs" / "tests" / "testkatalog.html"
FEHLT = "— (Docstring fehlt)" FEHLT = "— (Docstring fehlt)"
SPHINX_ROLLE = re.compile(r":(?:mod|func|class|meth|attr|data|obj|ref):`~?([^`]+)`") SPHINX_ROLLE = re.compile(r":(?:mod|func|class|meth|attr|data|obj|ref):`~?([^`]+)`")
@@ -423,6 +433,405 @@ def erzeuge_markdown(kataloge: list[Paketkatalog], baseline_pfad: Path, baseline
return "\n".join(z) + "\n" return "\n".join(z) + "\n"
STATUS_KLASSE = {
"grün": "ok",
"rot (Baseline)": "warn",
"übersprungen": "skip",
"erwarteter Fehlschlag": "skip",
}
def _slug(text: str) -> str:
return re.sub(r"[^a-zA-Z0-9]+", "-", text).strip("-").lower()
def _mermaid_fuer_datei(datei: Testdatei) -> str:
"""Baumdiagramm Datei -> Testgruppe -> Testfall, Farbe = Baseline-Status."""
zeilen = ["flowchart TD", f' root(["{datei.pfad.name}"])', " class root rootnode"]
klassenzeilen: list[str] = []
gi = 0
ti = 0
for gruppe in datei.gruppen:
gi += 1
gid = f"g{gi}"
zeilen.append(f' root --> {gid}["{gruppe.name} ({len(gruppe.faelle)})"]')
zeilen.append(f" class {gid} groupnode")
for fall in gruppe.faelle:
ti += 1
tid = f"t{ti}"
name = fall.name[5:] if fall.name.startswith("test_") else fall.name
zeilen.append(f' {gid} --> {tid}("{name}")')
klassenzeilen.append(f" class {tid} {STATUS_KLASSE.get(fall.status, 'ok')}")
zeilen.extend(klassenzeilen)
zeilen.append(" classDef rootnode fill:#1f4038,stroke:#16302a,stroke-width:1px,color:#f4f1e8,font-weight:600;")
zeilen.append(" classDef groupnode fill:#cfe0da,stroke:#5c8a7d,stroke-width:1px,color:#152420;")
zeilen.append(" classDef ok fill:#dcefdc,stroke:#3f7d4a,stroke-width:1px,color:#1c3a20;")
zeilen.append(" classDef warn fill:#f6ead9,stroke:#b5722a,stroke-width:1px,color:#4a2c08;")
zeilen.append(" classDef skip fill:#e6e6ee,stroke:#6f7285,stroke-width:1px,color:#2b2d38;")
return "\n".join(zeilen)
def _html_karte_fuer_datei(datei: Testdatei) -> str:
fid = _slug(datei.anzeigepfad)
faelle = datei.faelle
gesamt = len(faelle)
zaehler = {"ok": 0, "warn": 0, "skip": 0}
for fall in faelle:
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
if not datei.gruppen:
koerper = (
f'<p class="filedesc">{html.escape(datei.beschreibung) if datei.beschreibung else "— (Docstring fehlt)"}</p>'
'<p class="empty-note">Keine Testfälle: Die Datei enthält keine <span class="mono">unittest.TestCase</span>'
"-Klasse – sie ist eine reine Hilfsdatei, die beim Testlauf nur importiert wird.</p>"
)
pills = ""
else:
pills = "".join(
f'<span class="cpill cpill-{k}">{v}</span>' for k, v in zaehler.items() if v
)
gchips = "".join(
f'<span class="gchip" title="{html.escape(g.beschreibung)}">{html.escape(g.name)} '
f"<b>{len(g.faelle)}</b></span>"
for g in datei.gruppen
)
zeilen = []
kurz = {"grün": "grün", "rot (Baseline)": "rot", "übersprungen": "übersp.", "erwarteter Fehlschlag": "übersp."}
for fall in faelle:
klasse = STATUS_KLASSE.get(fall.status, "ok")
pruft = (
f'<span class="nodoc">— (Docstring fehlt)</span>'
if not fall.beschreibung
else html.escape(fall.beschreibung)
)
titel = f' title="{html.escape(fall.grund)}"' if fall.grund else ""
zeilen.append(
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>'
)
tabelle = (
'<table class="test-table"><thead><tr><th>Testfall</th><th>prüft</th><th>Status</th></tr></thead>'
f"<tbody>{''.join(zeilen)}</tbody></table>"
)
diagramm = (
'<div class="diagram-wrap"><pre class="mermaid">'
+ html.escape(_mermaid_fuer_datei(datei))
+ "</pre></div>"
)
koerper = (
f'<p class="filedesc">{html.escape(datei.beschreibung) if datei.beschreibung else "— (Docstring fehlt)"}</p>'
f'<div class="gchips">{gchips}</div>{diagramm}{tabelle}'
)
suchtext = html.escape(
(datei.anzeigepfad + " " + " ".join(f"{f.klasse} {f.name}" for f in faelle)).lower()
)
header = (
f'<button class="card-toggle" data-target="{fid}" aria-expanded="false">'
f'<span class="card-path mono">{html.escape(datei.anzeigepfad)}</span>'
'<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>'
'<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"{header}"
f'<div class="card-body" id="body-{fid}">{koerper}</div>'
"</article>"
)
def _html_abschnitt_fuer_paket(katalog: Paketkatalog) -> str:
k = zaehle(katalog.faelle)
rolle = ROLLENTEXT.get(katalog.rolle, katalog.rolle or "Plugin")
karten = "".join(_html_karte_fuer_datei(d) for d in katalog.dateien) or "<p>Keine Testdateien gefunden.</p>"
gesamt = k["gesamt"] or 1
ok_pct = round(100 * k["gruen"] / gesamt)
warn_pct = round(100 * k["rot"] / gesamt)
skip_pct = max(0, 100 - ok_pct - warn_pct)
return f"""
<section class="pkg-section" id="pkg-{html.escape(katalog.paket)}">
<div class="pkg-head">
<div>
<h2>{html.escape(katalog.paket)}</h2>
<p class="pkg-sub">{html.escape(rolle)} · Ordner <span class="mono">{html.escape(katalog.ordner)}</span> · {len(katalog.dateien)} Testdateien · {k['gesamt']} Testfälle</p>
</div>
<div class="pkg-bar" role="img" aria-label="{k['gruen']} grün, {k['rot']} rot, {k['uebersprungen']} übersprungen">
<span class="seg seg-ok" style="width:{ok_pct}%"></span><span class="seg seg-warn" style="width:{warn_pct}%"></span><span class="seg seg-skip" style="width:{skip_pct}%"></span>
</div>
</div>
<div class="card-grid">{karten}</div>
</section>
"""
_HTML_KOPF = """<title>Prüfstand</title>
<meta charset="utf-8">
<link rel="icon" href="data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 100 100'%3E%3Ctext y='.9em' font-size='90'%3E%F0%9F%93%90%3C/text%3E%3C/svg%3E">
<link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link rel="stylesheet" href="https://fonts.googleapis.com/css2?family=Archivo:wght@500;600;700;800&family=Source+Sans+3:wght@400;500;600;700&family=IBM+Plex+Mono:wght@400;500;600&display=swap">
<style>
:root{
--bg: #f3f0e6; --bg-plan: #f6f4ea; --surface: #fffdf7; --surface-2: #ece7d8;
--ink: #211f18; --ink-soft: #4a4739; --muted: #7a7460; --line: #dcd5c0; --line-strong: #c7bea3;
--accent: #2f5b52; --accent-strong: #1c3b34; --accent-tint: #e2ece7;
--ok: #3f7d4a; --ok-bg: #e5f1e2; --warn: #a8641f; --warn-bg: #f7ead6; --skip: #5d6072; --skip-bg: #e8e8ee;
--shadow: 0 1px 2px rgba(33,31,24,.06), 0 4px 14px rgba(33,31,24,.05); --focus: #1c3b34;
color-scheme: light dark;
}
@media (prefers-color-scheme: dark){
:root:not([data-theme="light"]){
--bg:#1a1a16; --bg-plan:#211f18; --surface:#232019; --surface-2:#2b2820; --ink:#efe9d8; --ink-soft:#cdc6b0;
--muted:#97917c; --line:#3a3628; --line-strong:#4c4732; --accent:#7fb3a2; --accent-strong:#9cc9ba;
--accent-tint:#263530; --ok:#7fb386; --ok-bg:#223226; --warn:#d6a05e; --warn-bg:#3a2d1c; --skip:#a8abbd;
--skip-bg:#2a2b35; --shadow:0 1px 2px rgba(0,0,0,.3),0 6px 18px rgba(0,0,0,.35); --focus:#cfe6dc;
}
}
:root[data-theme="dark"]{
--bg:#1a1a16; --bg-plan:#211f18; --surface:#232019; --surface-2:#2b2820; --ink:#efe9d8; --ink-soft:#cdc6b0;
--muted:#97917c; --line:#3a3628; --line-strong:#4c4732; --accent:#7fb3a2; --accent-strong:#9cc9ba;
--accent-tint:#263530; --ok:#7fb386; --ok-bg:#223226; --warn:#d6a05e; --warn-bg:#3a2d1c; --skip:#a8abbd;
--skip-bg:#2a2b35; --shadow:0 1px 2px rgba(0,0,0,.3),0 6px 18px rgba(0,0,0,.35); --focus:#cfe6dc;
}
*{ box-sizing:border-box; }
html,body{ margin:0; }
body{ background:var(--bg); color:var(--ink); font-family:"Source Sans 3",system-ui,-apple-system,sans-serif;
font-size:15px; line-height:1.5; padding-inline:20px; }
h1,h2,h3{ font-family:"Archivo",system-ui,sans-serif; text-wrap:balance; margin:0; }
.mono{ font-family:"IBM Plex Mono",ui-monospace,monospace; }
a{ color:var(--accent-strong); }
.topbar{ max-width:1180px; margin:0 auto; padding-block:28px 18px; display:flex; flex-wrap:wrap; gap:16px 28px;
align-items:flex-end; justify-content:space-between; border-bottom:2px solid var(--line-strong); }
.brand{ display:flex; align-items:baseline; gap:14px; flex-wrap:wrap; }
.brand h1{ font-size:clamp(28px,4vw,38px); font-weight:800; letter-spacing:-0.01em; color:var(--accent-strong); }
.brand .tagline{ color:var(--muted); font-size:14.5px; max-width:46ch; }
.meta-line{ color:var(--muted); font-size:12.5px; text-align:right; }
.meta-line b{ color:var(--ink-soft); font-weight:600; }
.summary{ max-width:1180px; margin:22px auto 0; display:grid; gap:14px; grid-template-columns:repeat(auto-fit,minmax(150px,1fr)); }
.stat{ background:var(--surface); border:1px solid var(--line); border-radius:10px; padding:14px 16px; box-shadow:var(--shadow); }
.stat .n{ font-family:"IBM Plex Mono",monospace; font-variant-numeric:tabular-nums; font-size:26px; font-weight:600; color:var(--ink); display:block; }
.stat .l{ font-size:12px; text-transform:uppercase; letter-spacing:.06em; color:var(--muted); }
.stat.ok .n{ color:var(--ok); } .stat.warn .n{ color:var(--warn); } .stat.skip .n{ color:var(--skip); }
.pkg-bars{ max-width:1180px; margin:16px auto 0; background:var(--surface); border:1px solid var(--line);
border-radius:10px; padding:14px 18px; box-shadow:var(--shadow); display:grid; gap:10px; }
.pkg-bars-title{ font-size:12px; text-transform:uppercase; letter-spacing:.06em; color:var(--muted); margin-bottom:2px; }
.pkg-bar-row{ display:grid; grid-template-columns:170px 1fr auto; gap:12px; align-items:center; }
.pkg-bar-row .name{ font-family:"IBM Plex Mono",monospace; font-size:13px; }
.pkg-bar-row .count{ font-family:"IBM Plex Mono",monospace; font-variant-numeric:tabular-nums; font-size:12.5px; color:var(--muted); white-space:nowrap; }
.minibar{ height:9px; border-radius:6px; overflow:hidden; display:flex; background:var(--surface-2); border:1px solid var(--line); }
.minibar .seg{ height:100%; display:block; }
.controls{ max-width:1180px; margin:20px auto 0; display:flex; flex-wrap:wrap; gap:10px 14px; align-items:center;
position:sticky; top:env(safe-area-inset-top,0px); z-index:5; background:var(--bg); padding-block:10px; }
.nav-links{ display:flex; gap:6px; flex-wrap:wrap; }
.nav-links a{ font-size:13px; text-decoration:none; padding:6px 11px; border-radius:999px; border:1px solid var(--line-strong);
color:var(--ink-soft); background:var(--surface); }
.nav-links a:hover{ border-color:var(--accent); color:var(--accent-strong); }
.filters{ display:flex; gap:6px; flex-wrap:wrap; margin-left:auto; }
.chip{ font-size:12.5px; padding:6px 11px; border-radius:999px; border:1px solid var(--line-strong);
background:var(--surface); color:var(--ink-soft); cursor:pointer; font-family:inherit; }
.chip[aria-pressed="true"]{ background:var(--accent-strong); border-color:var(--accent-strong); color:var(--bg-plan); }
.searchbox{ display:flex; align-items:center; gap:6px; border:1px solid var(--line-strong); border-radius:999px;
background:var(--surface); padding:5px 12px; min-width:190px; }
.searchbox input{ border:none; background:none; outline:none; font-family:inherit; font-size:13px; color:var(--ink); width:100%; }
.searchbox input::placeholder{ color:var(--muted); }
.pkg-section{ max-width:1180px; margin:34px auto 0; scroll-margin-top:64px; }
.pkg-head{ display:flex; flex-wrap:wrap; gap:12px 24px; align-items:flex-end; justify-content:space-between; margin-bottom:16px; }
.pkg-head h2{ font-family:"IBM Plex Mono",monospace; font-size:21px; font-weight:600; color:var(--accent-strong); }
.pkg-sub{ color:var(--muted); font-size:13px; margin-top:3px; }
.pkg-bar{ width:220px; height:9px; border-radius:6px; overflow:hidden; display:flex; background:var(--surface-2); border:1px solid var(--line); }
.pkg-bar .seg{ height:100%; display:block; }
.seg-ok{ background:var(--ok); } .seg-warn{ background:var(--warn); } .seg-skip{ background:var(--skip); }
.card-grid{ display:grid; gap:12px; }
.filecard{ background:var(--surface); border:1px solid var(--line); border-radius:12px; box-shadow:var(--shadow); overflow:hidden; }
.card-toggle{ width:100%; display:flex; flex-wrap:wrap; align-items:center; gap:8px 14px; justify-content:space-between;
padding:13px 16px; background:none; border:none; cursor:pointer; text-align:left; font-family:inherit; color:var(--ink); }
.card-toggle:hover{ background:var(--accent-tint); }
.card-toggle:focus-visible{ outline:2px solid var(--focus); outline-offset:-2px; }
.card-path{ font-size:13.5px; font-weight:500; color:var(--ink); }
.card-meta{ display:flex; align-items:center; gap:8px; flex-wrap:wrap; font-size:12px; }
.count-total{ color:var(--muted); font-variant-numeric:tabular-nums; }
.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; }
.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); }
.filecard.open .card-body{ border-top-width:1px; }
.card-body-inner{ padding:16px; }
.filedesc{ color:var(--ink-soft); margin:0 0 12px; max-width:70ch; }
.empty-note{ color:var(--muted); font-size:13.5px; margin:0; }
.gchips{ display:flex; flex-wrap:wrap; gap:6px; margin-bottom:14px; }
.gchip{ font-size:12px; background:var(--accent-tint); color:var(--accent-strong); border:1px solid var(--line); border-radius:7px; padding:3px 9px; }
.gchip b{ font-family:"IBM Plex Mono",monospace; font-weight:600; }
.diagram-wrap{ background:var(--bg-plan); border:1px solid var(--line-strong);
border-image: repeating-linear-gradient(45deg, var(--line-strong) 0 2px, transparent 2px 6px) 1;
border-radius:8px; padding:14px; margin-bottom:16px; overflow-x:auto; }
.diagram-wrap svg{ display:block; margin:0 auto; }
.test-table{ width:100%; border-collapse:collapse; font-size:13px; }
.test-table th{ text-align:left; font-size:11px; text-transform:uppercase; letter-spacing:.05em; color:var(--muted);
border-bottom:1px solid var(--line-strong); padding:6px 8px; }
.test-table td{ border-bottom:1px solid var(--line); padding:8px 8px; vertical-align:top; }
.test-table tr:last-child td{ border-bottom:none; }
.testname{ color:var(--muted); font-size:12px; }
.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); }
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; } }
</style>
"""
_HTML_SKRIPT = """
<script src="https://cdnjs.cloudflare.com/ajax/libs/mermaid/10.9.1/mermaid.min.js"></script>
<script>
(function(){
function initMermaid(){
try{
mermaid.initialize({ startOnLoad:false, theme:"base", securityLevel:"loose",
themeVariables:{ fontFamily:'"IBM Plex Mono", monospace', fontSize:'12.5px' } });
mermaid.run({ querySelector: 'pre.mermaid' }).then(setupToggles).catch(function(e){
console.error('mermaid render failed', e); setupToggles();
});
}catch(e){ console.error(e); setupToggles(); }
}
function setupToggles(){
document.querySelectorAll('.card-toggle').forEach(function(btn){
var card = btn.closest('.filecard');
var body = card.querySelector('.card-body');
if (!body.querySelector('.card-body-inner')){
var inner = document.createElement('div');
inner.className = 'card-body-inner';
while (body.firstChild) inner.appendChild(body.firstChild);
body.appendChild(inner);
}
btn.addEventListener('click', function(){
var isOpen = card.classList.toggle('open');
btn.setAttribute('aria-expanded', isOpen ? 'true':'false');
body.style.maxHeight = isOpen ? (body.scrollHeight + 40) + 'px' : '0px';
});
});
}
function applyFilters(){
var active = document.querySelector('.chip[aria-pressed="true"]').dataset.filter;
var q = document.getElementById('searchInput').value.trim().toLowerCase();
document.querySelectorAll('.filecard').forEach(function(card){
var passFilter = active === 'all' || card.dataset[active] === '1';
var passSearch = !q || card.dataset.search.indexOf(q) !== -1;
card.hidden = !(passFilter && passSearch);
});
document.querySelectorAll('.pkg-section').forEach(function(sec){
sec.hidden = !sec.querySelector('.filecard:not([hidden])');
});
}
document.querySelectorAll('.chip').forEach(function(chip){
chip.addEventListener('click', function(){
document.querySelectorAll('.chip').forEach(function(c){ c.setAttribute('aria-pressed','false'); });
chip.setAttribute('aria-pressed','true');
applyFilters();
});
});
document.getElementById('searchInput').addEventListener('input', applyFilters);
if (document.readyState === 'loading'){ document.addEventListener('DOMContentLoaded', initMermaid); }
else { initMermaid(); }
})();
</script>
"""
def erzeuge_html(kataloge: list[Paketkatalog], baseline_pfad: Path, baseline_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]
summe = zaehle(alle)
baseline_anzeige = (
baseline_pfad.relative_to(ROOT).as_posix() if baseline_pfad.is_relative_to(ROOT) else str(baseline_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'
)
nav_links = "\n ".join(
f'<a href="#pkg-{html.escape(k.paket)}">{html.escape(k.paket)} <span class="mono">({len(k.faelle)})</span></a>'
for k in kataloge
)
bar_zeilen = []
for k in kataloge:
z = zaehle(k.faelle)
gesamt = z["gesamt"] or 1
ok_pct = round(100 * z["gruen"] / gesamt)
warn_pct = round(100 * z["rot"] / gesamt)
skip_pct = max(0, 100 - ok_pct - warn_pct)
bar_zeilen.append(
f'<div class="pkg-bar-row"><span class="name">{html.escape(k.paket)}</span>'
f'<span class="minibar" role="img" aria-label="{z["gruen"]} grün, {z["rot"]} rot, {z["uebersprungen"]} übersprungen">'
f'<span class="seg seg-ok" style="width:{ok_pct}%"></span>'
f'<span class="seg seg-warn" style="width:{warn_pct}%"></span>'
f'<span class="seg seg-skip" style="width:{skip_pct}%"></span></span>'
f'<span class="count">{len(k.dateien)} Dateien · {z["gesamt"]} Testfälle</span></div>'
)
sections = "\n".join(_html_abschnitt_fuer_paket(k) for k in kataloge)
teile = [_HTML_KOPF]
teile.append('<div class="topbar"><div class="brand"><h1>Prüfstand</h1>')
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('<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 ok"><span class="n">{summe["gruen"]}</span><span class="l">grün</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>')
doc_pct = round(100 * summe["mit_docstring"] / 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">{sum(len(k.dateien) for k in kataloge)}</span><span class="l">Testdateien</span></div>')
teile.append("</div>")
teile.append('<div class="pkg-bars"><div class="pkg-bars-title">Verteilung je Paket</div>')
teile.append("\n ".join(bar_zeilen))
teile.append("</div>")
teile.append('<div class="controls"><nav class="nav-links">')
teile.append(nav_links)
teile.append('</nav><div class="filters">')
teile.append('<button class="chip" data-filter="all" aria-pressed="true">Alle</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="nodoc" aria-pressed="false">Schwach beschrieben (&lt;50%)</button>')
teile.append("</div>")
teile.append(
'<div class="searchbox"><svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" '
'stroke-width="2.5" aria-hidden="true"><circle cx="11" cy="11" r="7"/><line x1="21" y1="21" x2="16.65" y2="16.65"/></svg>'
'<input type="search" id="searchInput" placeholder="Testfall oder Datei suchen…" '
'aria-label="Testfall oder Datei suchen"></div></div>'
)
teile.append(sections)
teile.append(
"<footer>Erzeugt von <span class=\"mono\">python scripts/testkatalog.py</span> aus den "
"unittest-Suiten der sn_*-Plugins. Diagramme gruppieren Testfälle je Testdatei nach "
"<span class=\"mono\">unittest.TestCase</span>-Klasse; Farbe zeigt den Baseline-Status "
"(grün / rot / übersprungen), nicht die fachliche Wichtigkeit.</footer>"
)
teile.append(_HTML_SKRIPT)
return "\n".join(teile) + "\n"
def melde_unterschied(vorhanden: bytes, erzeugt: str, ziel: str) -> None: def melde_unterschied(vorhanden: bytes, erzeugt: str, ziel: str) -> None:
"""Kurzmeldung, worin sich Datei und erzeugter Stand unterscheiden.""" """Kurzmeldung, worin sich Datei und erzeugter Stand unterscheiden."""
alt = vorhanden.decode("utf-8", errors="replace").splitlines() alt = vorhanden.decode("utf-8", errors="replace").splitlines()
@@ -450,6 +859,9 @@ def main() -> int:
ap.add_argument("--pruefen", action="store_true", ap.add_argument("--pruefen", action="store_true",
help="nichts schreiben; Exit-Code 1, wenn die vorhandene Datei nicht dem erzeugten Stand entspricht") help="nichts schreiben; Exit-Code 1, wenn die vorhandene Datei nicht dem erzeugten Stand entspricht")
ap.add_argument("--ausgabe", type=Path, default=AUSGABE_DEFAULT, help="Zieldatei (Standard: docs/tests/testkatalog.md)") ap.add_argument("--ausgabe", type=Path, default=AUSGABE_DEFAULT, help="Zieldatei (Standard: docs/tests/testkatalog.md)")
ap.add_argument("--html-ausgabe", type=Path, default=HTML_AUSGABE_DEFAULT,
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("--baseline-datei", type=Path, default=BASELINE_DEFAULT)
args = ap.parse_args() args = ap.parse_args()
@@ -490,24 +902,28 @@ 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
text = erzeuge_markdown(kataloge, args.baseline_datei, baseline_kurz) ausgaben = [(args.ausgabe, erzeuge_markdown(kataloge, args.baseline_datei, baseline_kurz))]
ziel = args.ausgabe if not args.ohne_html:
anzeige = ziel.relative_to(ROOT).as_posix() if ziel.is_relative_to(ROOT) else str(ziel) ausgaben.append((args.html_ausgabe, erzeuge_html(kataloge, args.baseline_datei, baseline_kurz)))
ergebnis = 0
for ziel, text in ausgaben:
anzeige = ziel.relative_to(ROOT).as_posix() if ziel.is_relative_to(ROOT) else str(ziel)
if args.pruefen: if args.pruefen:
if not ziel.exists(): if not ziel.exists():
print(f"{anzeige} fehlt. Neu erzeugen mit: python scripts/testkatalog.py") print(f"{anzeige} fehlt. Neu erzeugen mit: python scripts/testkatalog.py")
return 1 ergebnis = 1
continue
if ziel.read_bytes() == text.encode("utf-8"): if ziel.read_bytes() == text.encode("utf-8"):
print(f"{anzeige} ist aktuell.") print(f"{anzeige} ist aktuell.")
return 0 continue
melde_unterschied(ziel.read_bytes(), text, anzeige) melde_unterschied(ziel.read_bytes(), text, anzeige)
return 1 ergebnis = 1
continue
ziel.parent.mkdir(parents=True, exist_ok=True) ziel.parent.mkdir(parents=True, exist_ok=True)
ziel.write_text(text, encoding="utf-8", newline="\n") ziel.write_text(text, encoding="utf-8", newline="\n")
print(f"Testkatalog geschrieben: {anzeige} ({len(text.splitlines())} Zeilen)") print(f"Testkatalog geschrieben: {anzeige} ({len(text.splitlines())} Zeilen)")
return 0 return ergebnis
if __name__ == "__main__": if __name__ == "__main__":