diff --git a/Discovery/tests/report/__init__.py b/Discovery/tests/report/__init__.py new file mode 100644 index 0000000..da91696 --- /dev/null +++ b/Discovery/tests/report/__init__.py @@ -0,0 +1 @@ +"""The human end of a run.""" diff --git a/Discovery/tests/report/html.py b/Discovery/tests/report/html.py new file mode 100644 index 0000000..09b24fe --- /dev/null +++ b/Discovery/tests/report/html.py @@ -0,0 +1,149 @@ +"""The scorecard. + +The aggregate number is for tracking; the individual rows are what somebody +fixes. So every miss and every invention is listed by manifest id with the +evidence the collector recorded, rather than summarized into a percentage +that tells a reader only that something got worse. +""" + +from __future__ import annotations + +import html +from typing import Iterable, List, Optional + +from ..scoring.schema import Gate, to_dict +from ..scoring.score import Score + +STYLE = """ +:root { color-scheme: light dark; } +body { font: 15px/1.5 -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif; + margin: 0; padding: 2rem; background: Canvas; color: CanvasText; } +h1 { font-size: 1.4rem; margin: 0 0 .25rem; } +.sub { opacity: .7; font-size: .9rem; margin-bottom: 1.5rem; } +.verdict { display: inline-block; padding: .2rem .6rem; border-radius: .4rem; + font-weight: 600; font-size: .85rem; } +.pass { background: #1a7f37; color: #fff; } +.fail { background: #b62324; color: #fff; } +table { border-collapse: collapse; width: 100%; margin: .5rem 0 2rem; font-size: .9rem; } +th, td { text-align: left; padding: .4rem .6rem; border-bottom: 1px solid rgba(128,128,128,.3); } +th { font-weight: 600; opacity: .8; } +td.num { text-align: right; font-variant-numeric: tabular-nums; } +code { font-family: ui-monospace, SFMono-Regular, Menlo, monospace; font-size: .85em; } +.empty { opacity: .6; font-style: italic; } +""" + + +def render(score: Score, gate: Optional[Gate] = None) -> str: + """One self-contained page. No assets, because it travels as an artifact.""" + document = to_dict(score) + parts: List[str] = [ + "", "", + f"
{label}
") + if gate.reasons: + parts.append(_list("Why it failed", gate.reasons)) + + parts.append(_totals(score)) + parts.append(_categories(score)) + parts.append(_fields(score)) + parts.append(_cross_cutting(score, document)) + parts.append(_verdict_table("Misses — installed, never reported", score.misses, "reason")) + parts.append(_verdict_table("Inventions — reported, never installed", score.inventions, "detail")) + parts.append(_verdict_table("Duplicates — one install, several assets", score.duplicates, "detail")) + return "\n".join(parts) + "\n" + + +def _totals(score: Score) -> str: + counts = score.totals + rows = [ + ("true positives", counts.tp), ("false positives", counts.fp), + ("false negatives", counts.fn), ("duplicates", counts.dup), + ("recall", _ratio(counts.recall)), ("precision", _ratio(counts.precision)), + ] + body = "".join(f"{esc(installed)} installed of {esc(applicable)} applicable " + f"· unavailable {esc(manifest.get('unavailable', 0))} " + f"· failed {esc(manifest.get('failed', 0))}
" + f"Never pooled across OS: the denominators differ.
" \ + f"{esc(name)}Over true positives only, per field — an average " \ + "would hide a collector that always gets one field wrong.
" \ + f"None.
" + head = f"{esc(v.entry_id)}{esc(', '.join(v.assets))}