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:
+122
-27
@@ -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){
|
||||
|
||||
Reference in New Issue
Block a user