diff --git a/runner/harness/card.py b/runner/harness/card.py index 44cf4ae..6f44d2a 100644 --- a/runner/harness/card.py +++ b/runner/harness/card.py @@ -79,10 +79,44 @@ def render_badge_svg(label: str, message: str, color: str) -> str: ) -def level_badge_svg(level: int, cap_reason: str = "") -> str: - """Per-recipe/-run LEVEL badge: 'cc-ci | level N'. Colour by level (R6).""" - msg = f"level {int(level)}" - return render_badge_svg("cc-ci", msg, level_color(level)) +# Third-segment colours for the level badge: amber = an UNINTENTIONAL skip (a rung skipped but not +# in the recipe's intentional list — likely missing coverage) capped the climb; muted = an +# INTENTIONAL skip (declared in recipe_meta.EXPECTED_NA — nothing to fix). Font-safe text labels +# (no emoji) so the SVG renders anywhere. +GAP_COLOR = "#d29922" +EXPECT_COLOR = "#6e7681" + + +def level_badge_svg(level: int, cap_reason: str = "", cap_skip: str = "") -> str: + """Per-recipe/-run LEVEL badge: 'cc-ci | level N' coloured by level (R6), with a THIRD segment + that differentiates *why* the climb stopped when a SKIP capped it (`cap_skip`): + - "unintentional" (a rung skipped but not in the recipe's intentional list): amber 'gap?'. + - "intentional" (a skip declared in recipe_meta.EXPECTED_NA): muted 'expected'. + - "" (clean cap / full climb / a real failure): no third segment (the level + card carry it). + The badge never inflates — it only annotates the cap the level already reflects.""" + label, msg = "cc-ci", f"level {int(level)}" + lw, mw = _text_width(label), _text_width(msg) + third: tuple[str, str] | None = None + if cap_skip == "unintentional": + third = ("gap?", GAP_COLOR) + elif cap_skip == "intentional": + third = ("expected", EXPECT_COLOR) + if third is None: + return render_badge_svg(label, msg, level_color(level)) + txt, tcolor = third + tw = _text_width(txt) + w = lw + mw + tw + return ( + f'' + f'' + f'' + f'' + f'' + f'{html.escape(label)}' + f'{html.escape(msg)}' + f'{html.escape(txt)}' + ) def _stage_rows(stages: list[dict]) -> str: @@ -107,6 +141,41 @@ def _stage_rows(stages: list[dict]) -> str: return "\n".join(rows) or 'no stages' +# Friendly rung labels for the skip rows (the four essential rungs). +RUNG_LABEL = { + "install": "install", + "upgrade": "upgrade", + "backup_restore": "backup/restore", + "functional": "functional", +} +SKIP_GREEN = "#57ab5a" # muted green — an intentional skip reads like a pass (but labelled, never inflating) + + +def _skip_rows(skips: dict) -> str: + """Render SKIPPED rungs as stage-like rows. An intentional (declared) skip looks like a pass row + but its status says 'INTENTIONAL SKIP' (muted green) with the declared reason on the line below; + an unintentional skip is amber 'UNINTENTIONAL SKIP' with a prompt to add a test or declare it.""" + rows = [] + for rung, reason in (skips.get("intentional") or {}).items(): + rows.append( + f'' + f'{html.escape(RUNG_LABEL.get(rung, rung))}' + f'intentional skip' + ) + rows.append(f'{html.escape(reason)}') + for rung in skips.get("unintentional") or []: + rows.append( + f'' + f'{html.escape(RUNG_LABEL.get(rung, rung))}' + f'unintentional skip' + ) + rows.append( + 'not declared in EXPECTED_NA — add the ' + "missing test/label, or declare the skip with a reason" + ) + return "\n".join(rows) + + def render_card_html(data: dict, screenshot_rel: str | None = "screenshot.png") -> str: """Build the summary-card HTML from a results.json dict. `screenshot_rel` is the relative path to the screenshot PNG (same dir as the card) — omitted from the card if None / absent. @@ -116,7 +185,9 @@ def render_card_html(data: dict, screenshot_rel: str | None = "screenshot.png") recipe = html.escape(str(data.get("recipe", "?"))) version = html.escape(str(data.get("version") or data.get("ref") or "")) level = int(data.get("level", 0)) - cap = html.escape(str(data.get("level_cap_reason") or "")) + cap_reason = str(data.get("level_cap_reason") or "") + cap = html.escape(cap_reason) + sk = data.get("skips", {}) or {} color = level_color(level) flags = data.get("flags", {}) or {} flag_bits = [] @@ -132,7 +203,7 @@ def render_card_html(data: dict, screenshot_rel: str | None = "screenshot.png") if show_shot else '
no screenshot
' ) - rows = _stage_rows(data.get("stages", [])) + rows = _stage_rows(data.get("stages", [])) + "\n" + _skip_rows(sk) return f"""