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:
+440
-24
@@ -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
|
||||
und Testfälle).
|
||||
|
||||
Aufrufe (aus dem Workspace-Root):
|
||||
python scripts/testkatalog.py # docs/tests/testkatalog.md neu schreiben
|
||||
python scripts/testkatalog.py sn_basis # nur ein Paket
|
||||
python scripts/testkatalog.py --pruefen # nur prüfen, ob die Datei aktuell ist (CI, vor dem Commit)
|
||||
python scripts/testkatalog.py --ausgabe /tmp/k.md # anderes Ziel
|
||||
Zusätzlich zum Markdown-Katalog schreibt der Lauf ein eigenständiges HTML-Dashboard
|
||||
(docs/tests/testkatalog.html): dieselben Daten als Baumdiagramm je Testdatei
|
||||
(Mermaid, geladen von cdnjs – ohne Internetverbindung bleiben die Diagramme leer,
|
||||
Text und Tabellen bleiben lesbar), mit Filtern und Suche. Im Browser lokal öffnen,
|
||||
kein Server nötig. Siehe docs/wissen/fallstricke.md.
|
||||
|
||||
Exit-Code: 0 = erfolgreich (mit --pruefen: Datei ist aktuell), 1 = Datei veraltet
|
||||
oder fehlt, 2 = Aufruffehler.
|
||||
Aufrufe (aus dem Workspace-Root):
|
||||
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
|
||||
@@ -42,6 +50,7 @@ import argparse
|
||||
import ast
|
||||
import difflib
|
||||
import hashlib
|
||||
import html
|
||||
import json
|
||||
import re
|
||||
import tomllib
|
||||
@@ -52,6 +61,7 @@ ROOT = Path(__file__).resolve().parents[1]
|
||||
MANIFEST = ROOT / "workspace.toml"
|
||||
BASELINE_DEFAULT = ROOT / "docs" / "status" / "testbaseline.json"
|
||||
AUSGABE_DEFAULT = ROOT / "docs" / "tests" / "testkatalog.md"
|
||||
HTML_AUSGABE_DEFAULT = ROOT / "docs" / "tests" / "testkatalog.html"
|
||||
|
||||
FEHLT = "— (Docstring fehlt)"
|
||||
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"
|
||||
|
||||
|
||||
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 (<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:
|
||||
"""Kurzmeldung, worin sich Datei und erzeugter Stand unterscheiden."""
|
||||
alt = vorhanden.decode("utf-8", errors="replace").splitlines()
|
||||
@@ -450,6 +859,9 @@ def main() -> int:
|
||||
ap.add_argument("--pruefen", action="store_true",
|
||||
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("--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)
|
||||
args = ap.parse_args()
|
||||
|
||||
@@ -490,24 +902,28 @@ def main() -> int:
|
||||
print("Keine Pakete mit Testdateien gefunden – nichts zu tun.")
|
||||
return 2
|
||||
|
||||
text = erzeuge_markdown(kataloge, args.baseline_datei, baseline_kurz)
|
||||
ziel = args.ausgabe
|
||||
anzeige = ziel.relative_to(ROOT).as_posix() if ziel.is_relative_to(ROOT) else str(ziel)
|
||||
ausgaben = [(args.ausgabe, erzeuge_markdown(kataloge, args.baseline_datei, baseline_kurz))]
|
||||
if not args.ohne_html:
|
||||
ausgaben.append((args.html_ausgabe, erzeuge_html(kataloge, args.baseline_datei, baseline_kurz)))
|
||||
|
||||
if args.pruefen:
|
||||
if not ziel.exists():
|
||||
print(f"{anzeige} fehlt. Neu erzeugen mit: python scripts/testkatalog.py")
|
||||
return 1
|
||||
if ziel.read_bytes() == text.encode("utf-8"):
|
||||
print(f"{anzeige} ist aktuell.")
|
||||
return 0
|
||||
melde_unterschied(ziel.read_bytes(), text, anzeige)
|
||||
return 1
|
||||
|
||||
ziel.parent.mkdir(parents=True, exist_ok=True)
|
||||
ziel.write_text(text, encoding="utf-8", newline="\n")
|
||||
print(f"Testkatalog geschrieben: {anzeige} ({len(text.splitlines())} Zeilen)")
|
||||
return 0
|
||||
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 not ziel.exists():
|
||||
print(f"{anzeige} fehlt. Neu erzeugen mit: python scripts/testkatalog.py")
|
||||
ergebnis = 1
|
||||
continue
|
||||
if ziel.read_bytes() == text.encode("utf-8"):
|
||||
print(f"{anzeige} ist aktuell.")
|
||||
continue
|
||||
melde_unterschied(ziel.read_bytes(), text, anzeige)
|
||||
ergebnis = 1
|
||||
continue
|
||||
ziel.parent.mkdir(parents=True, exist_ok=True)
|
||||
ziel.write_text(text, encoding="utf-8", newline="\n")
|
||||
print(f"Testkatalog geschrieben: {anzeige} ({len(text.splitlines())} Zeilen)")
|
||||
return ergebnis
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
|
||||
Reference in New Issue
Block a user