Prüfstand: Statistik-Kacheln je Paket, beabsichtigt/ungeklärt-Indikator

Jedes Paket zeigt jetzt dieselben Kennzahl-Kacheln wie die Gesamtübersicht
(Testdateien, Testfälle, grün/rot/übersprungen, mit Docstring, geprüft,
ohne Testbezug) statt eines Fließtexts.

Ungetestete Funktionen werden per Docstring-Auswertung als "beabsichtigt"
(Vermerk "von Test abgesehen, weil …" oder "Test ausgesetzt, zunächst
Refactor") oder "ungeklärt" markiert - in Markdown als Anmerkung je
Funktion, im Prüfstand als Pille mit Tooltip (Begründung).

Der Abschnitt "Ungetestete Funktionen" ist im Prüfstand jetzt eine
ausklappbare Karte wie eine Testdatei, mit eigener Kennzahl-Übersicht
(Anzahl, ungeklärt, beabsichtigt) im Kopf - bewusst von den Filter-Chips
ausgenommen (.gapcard), da diese sich auf Testfall-Status beziehen, nicht
auf Quellcode-Funktionen.

Neue Funktion absichtlicher_verzicht() plus 6 neue Tests in
scripts/tests/test_testkatalog_quellfunktionen.py.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
2026-09-18 13:26:01 +02:00
co-authored by Claude Sonnet 5
parent cb29620f6e
commit cd11e409a3
5 changed files with 339 additions and 356 deletions
+122 -27
View File
@@ -136,6 +136,7 @@ class Quellfunktion:
name: str
zeile: int
referenziert: bool = False
absichtlich_grund: str = ""
@dataclass
@@ -304,6 +305,26 @@ def _ist_dunder(name: str) -> bool:
return name.startswith("__") and name.endswith("__")
ABSICHTLICH_MARKER = ("von Test abgesehen", "Test ausgesetzt")
def absichtlicher_verzicht(docstring: str | None) -> str:
"""Docstring-Zeile, die einen bewusst dokumentierten Testverzicht enthält, sonst "".
Erkennt die beiden im Projekt verwendeten Vermerke: "von Test abgesehen, weil …" (bewusst
vernachlässigbar) und "Test ausgesetzt, zunächst Refactor" (zurückgestellt bis zu einer geplanten
Überarbeitung). Ein Namensabgleich wie bei `sammle_referenzierte_namen` – reicht der Vermerk nicht
wörtlich, wird die Funktion weiterhin als offen (ungeklärt) geführt.
"""
if not docstring:
return ""
for zeile in docstring.splitlines():
text = " ".join(zeile.split())
if text and any(marker in text for marker in ABSICHTLICH_MARKER):
return text
return ""
def sammle_quellcode(ordner: Path) -> list[Quellfunktion]:
"""Listet Top-Level-Funktionen und Methoden im Quellcode eines Pakets (ohne tests/).
@@ -323,12 +344,24 @@ def sammle_quellcode(ordner: Path) -> list[Quellfunktion]:
baum = ast.parse(pfad.read_text(encoding="utf-8"), filename=str(pfad))
for knoten in baum.body:
if isinstance(knoten, (ast.FunctionDef, ast.AsyncFunctionDef)):
ergebnisse.append(Quellfunktion(datei=anzeigepfad, name=knoten.name, zeile=knoten.lineno))
ergebnisse.append(
Quellfunktion(
datei=anzeigepfad,
name=knoten.name,
zeile=knoten.lineno,
absichtlich_grund=absichtlicher_verzicht(ast.get_docstring(knoten)),
)
)
elif isinstance(knoten, ast.ClassDef):
for glied in knoten.body:
if isinstance(glied, (ast.FunctionDef, ast.AsyncFunctionDef)) and not _ist_dunder(glied.name):
ergebnisse.append(
Quellfunktion(datei=anzeigepfad, name=f"{knoten.name}.{glied.name}", zeile=glied.lineno)
Quellfunktion(
datei=anzeigepfad,
name=f"{knoten.name}.{glied.name}",
zeile=glied.lineno,
absichtlich_grund=absichtlicher_verzicht(ast.get_docstring(glied)),
)
)
return ergebnisse
@@ -364,10 +397,13 @@ def setze_referenzstatus(katalog: Paketkatalog) -> None:
def zaehle_quellfunktionen(funktionen: list[Quellfunktion]) -> dict[str, int]:
ohne_bezug = [f for f in funktionen if not f.referenziert]
return {
"gesamt": len(funktionen),
"referenziert": sum(1 for f in funktionen if f.referenziert),
"ohne_bezug": sum(1 for f in funktionen if not f.referenziert),
"ohne_bezug": len(ohne_bezug),
"absichtlich": sum(1 for f in ohne_bezug if f.absichtlich_grund),
"ungeklaert": sum(1 for f in ohne_bezug if not f.absichtlich_grund),
}
@@ -565,7 +601,8 @@ def erzeuge_markdown(
"vorkommt (weder als Aufruf noch als `mock.patch(\"…\")`-Ziel). Ein Namensabgleich, kein Ausführungsnachweis "
"– siehe Abschnitt „Ungetestete Funktionen“ für die Grenzen dieser Heuristik. Das ist bewusst **keine** "
"Zeilen-Coverage in Prozent (siehe ADR 0009, „Verworfene Alternativen“), sondern eine Lückenliste wie bei "
"fehlenden Beschreibungen."
"fehlenden Beschreibungen. Davon **beabsichtigt** – der Docstring dokumentiert den Testverzicht; "
"**ungeklärt** – weder Test noch Vermerk, die eigentliche Arbeitsliste."
)
z.append("")
@@ -643,10 +680,18 @@ def erzeuge_markdown(
" Diese Liste ersetzt keine fachliche Prüfung, ob ein Test sinnvoll wäre – dafür braucht es einen"
" Menschen, der die Funktion kennt."
)
z.append(
"„beabsichtigt“ liest den Docstring der Funktion: Enthält er einen Vermerk wie „von Test abgesehen,"
" weil …“ oder „Test ausgesetzt, zunächst Refactor“, gilt der fehlende Test als bewusste Entscheidung."
" Alles andere ist „ungeklärt“ – die eigentliche Arbeitsliste."
)
z.append("")
for katalog in kataloge:
qf = zaehle_quellfunktionen(katalog.quellfunktionen)
z.append(f"### `{katalog.paket}` – {anteil(qf['ohne_bezug'], qf['gesamt'])} ohne Testbezug")
z.append(
f"### `{katalog.paket}` – {anteil(qf['ohne_bezug'], qf['gesamt'])} ohne Testbezug "
f"({qf['ungeklaert']} ungeklärt, {qf['absichtlich']} beabsichtigt)"
)
z.append("")
luecken_qf = [f for f in katalog.quellfunktionen if not f.referenziert]
if not luecken_qf:
@@ -660,7 +705,10 @@ def erzeuge_markdown(
z.append(f"#### `{datei}` ({len(funktionen)})")
z.append("")
for funktion in funktionen:
z.append(f"- `{funktion.name}` (Zeile {funktion.zeile})")
if funktion.absichtlich_grund:
z.append(f"- `{funktion.name}` (Zeile {funktion.zeile}) — **beabsichtigt:** {zelle(funktion.absichtlich_grund)}")
else:
z.append(f"- `{funktion.name}` (Zeile {funktion.zeile}) — ungeklärt")
z.append("")
return "\n".join(z) + "\n"
@@ -796,51 +844,94 @@ def _html_karte_fuer_datei(datei: Testdatei) -> str:
def _html_luecken_fuer_paket(katalog: Paketkatalog) -> str:
"""Statischer Block je Paket: Quellfunktionen ohne erkennbaren Testbezug."""
"""Ausklappbare Karte je Paket (wie eine Testdatei-Karte): Quellfunktionen ohne erkennbaren Testbezug."""
qf = zaehle_quellfunktionen(katalog.quellfunktionen)
if not qf["gesamt"]:
return ""
luecken = [f for f in katalog.quellfunktionen if not f.referenziert]
fid = _slug(f"{katalog.paket}-luecken")
luecken = sorted([f for f in katalog.quellfunktionen if not f.referenziert], key=lambda f: (f.datei, f.zeile))
if not luecken:
koerper = '<p class="empty-note">Keine Lücken.</p>'
tabelle = '<p class="empty-note">Keine Lücken.</p>'
else:
zeilen = "\n".join(
f'<tr><td class="mono">{html.escape(f.datei)}</td><td class="mono">{html.escape(f.name)}</td>'
f"<td>{f.zeile}</td></tr>"
for f in luecken
zeilen = []
for f in luecken:
if f.absichtlich_grund:
status = f'<span class="pill pill-skip" title="{html.escape(f.absichtlich_grund)}">beabsichtigt</span>'
else:
status = '<span class="pill pill-warn">ungeklärt</span>'
zeilen.append(
f'<tr><td class="mono">{html.escape(f.datei)}</td><td class="mono">{html.escape(f.name)}</td>'
f"<td>{f.zeile}</td><td>{status}</td></tr>"
)
tabelle = (
'<table class="test-table"><thead><tr><th>Datei</th><th>Funktion/Methode</th><th>Zeile</th>'
"<th>Status</th></tr></thead>"
f"<tbody>{''.join(zeilen)}</tbody></table>"
)
koerper = (
'<table class="test-table"><thead><tr><th>Datei</th><th>Funktion/Methode</th><th>Zeile</th></tr></thead>'
f"<tbody>{zeilen}</tbody></table>"
)
return f"""
<div class="luecken-block" style="margin-top:18px">
<h3 style="font-size:15px">Ungetestete Funktionen (strukturell) – {qf['ohne_bezug']} von {qf['gesamt']}</h3>
<p class="empty-note">Heuristik: Name kommt in keiner Testdatei des Pakets vor. Details siehe docs/tests/testkatalog.md.</p>
{koerper}
</div>
"""
beschreibung = (
'<p class="filedesc">Heuristik: Name kommt in keiner Testdatei des Pakets vor (Details siehe '
'<span class="mono">docs/tests/testkatalog.md</span>). <b>beabsichtigt</b> liest den Docstring der '
"Funktion – ein Vermerk wie „von Test abgesehen, weil …“ oder „Test ausgesetzt, zunächst Refactor“ "
"gilt als bewusste Entscheidung. <b>ungeklärt</b> hat weder Test noch Vermerk.</p>"
)
suchtext = html.escape(f"ungetestete funktionen {katalog.paket}".lower())
header = (
f'<button class="card-toggle" data-target="{fid}" aria-expanded="false">'
'<span class="card-path mono">Ungetestete Funktionen</span>'
'<span class="card-meta">'
f'<span class="count-total">{qf["gesamt"]} Funktionen</span>'
f'<span class="cpill cpill-warn">{qf["ungeklaert"]} ungeklärt</span>'
f'<span class="cpill cpill-skip">{qf["absichtlich"]} beabsichtigt</span>'
'<span class="chev" aria-hidden="true">▾</span></span></button>'
)
return (
f'<article class="filecard gapcard" data-search="{suchtext}">'
f"{header}"
f'<div class="card-body" id="body-{fid}">{beschreibung}{tabelle}</div>'
"</article>"
)
def _html_abschnitt_fuer_paket(katalog: Paketkatalog) -> str:
k = zaehle(katalog.faelle)
qf = zaehle_quellfunktionen(katalog.quellfunktionen)
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)
doc_pct = round(100 * k["mit_docstring"] / k["gesamt"]) if k["gesamt"] else 0
audit_pct = round(100 * k["aktuell_geprueft"] / k["gesamt"]) if k["gesamt"] else 0
kacheln = [
("", len(katalog.dateien), "Testdateien"),
("", k["gesamt"], "Testfälle"),
("ok", k["gruen"], "grün"),
("warn", k["rot"], "rot (Baseline)"),
("skip", k["uebersprungen"], "übersprungen"),
("", f"{doc_pct}%", "mit Docstring"),
("", f"{audit_pct}%", "geprüft"),
("warn" if qf["ungeklaert"] else "", qf["ohne_bezug"], "ohne Testbezug"),
]
tiles = "".join(
f'<div class="stat stat-sm{" " + klasse if klasse else ""}"><span class="n">{wert}</span>'
f'<span class="l">{label}</span></div>'
for klasse, wert, label in kacheln
)
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>
<p class="pkg-sub">{html.escape(rolle)} · Ordner <span class="mono">{html.escape(katalog.ordner)}</span></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="pkg-summary">{tiles}</div>
<div class="card-grid">{karten}</div>
{_html_luecken_fuer_paket(katalog)}
</section>
@@ -924,6 +1015,10 @@ _HTML_KOPF = """<title>Prüfstand</title>
.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); }
.pkg-summary{ max-width:100%; margin:0 0 16px; display:grid; gap:8px; grid-template-columns:repeat(auto-fit,minmax(108px,1fr)); }
.stat-sm{ padding:8px 10px; }
.stat-sm .n{ font-size:17px; }
.stat-sm .l{ font-size:10px; }
.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;
@@ -1000,13 +1095,13 @@ _HTML_SKRIPT = """
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){
document.querySelectorAll('.filecard:not(.gapcard)').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])');
sec.hidden = !sec.querySelector('.filecard:not(.gapcard):not([hidden])');
});
}
document.querySelectorAll('.chip').forEach(function(chip){