test_custom_runner.py
1 """Microvisor Test Runner — Ward-inspired BDD renderer for PlatformIO + Unity.""" 2 3 from __future__ import annotations 4 5 import re 6 from collections import Counter 7 from typing import Final, NamedTuple 8 9 import click 10 from platformio.test.runners.unity import UnityTestRunner 11 12 ANSI_RE: Final = re.compile(r"\x1b\[[0-9;]*m") 13 MODULE_PREFIX: Final = "[MODULE]" 14 INDENT: Final = " " 15 BAR_WIDTH: Final = 40 16 HEADER_WIDTH: Final = 60 17 18 19 class BddStyle(NamedTuple): 20 color: str 21 depth: int 22 23 24 BDD_STYLES: Final[dict[str, BddStyle]] = { 25 "[GIVEN]": BddStyle("cyan", 1), 26 "[WHEN]": BddStyle("blue", 2), 27 "[THEN]": BddStyle("magenta", 3), 28 "[AND]": BddStyle("magenta", 3), 29 } 30 31 VERDICT_COLORS: Final[dict[str, str]] = { 32 "PASSED": "bright_green", 33 "SKIPPED": "bright_yellow", 34 "FAILED": "bright_red", 35 } 36 37 38 def _styled_bar(count: int, total: int, color: str) -> str: 39 width = round(BAR_WIDTH * count / total) if total else 0 40 return click.style(" " * width, bg=color) if width else "" 41 42 43 def _summary_row(label: str, count: int, total: int, color: str) -> str: 44 num = click.style(f"{count:3d}", fg=color, bold=True) 45 bar = _styled_bar(count, total, color) 46 pct = 100.0 * count / total if total else 0.0 47 return f" {num} {label:<8s} {bar:<40s} {pct:5.1f}%" 48 49 50 class CustomTestRunner(UnityTestRunner): 51 52 def __init__(self, *args, **kwargs): 53 super().__init__(*args, **kwargs) 54 self._current_module: str | None = None 55 self._scenario_depth: int = 0 56 57 # ── PlatformIO hook ────────────────────────────────────────────────── 58 59 def on_testing_line_output(self, line: str) -> None: 60 line = ANSI_RE.sub("", line or "").strip() 61 if not line: 62 return 63 64 if test_case := self.parse_test_case(line): 65 self._render_verdict(test_case) 66 self._scenario_depth = 0 67 elif ":INFO:" in line: 68 self._render_info(line.split(":INFO:", 1)[-1].strip()) 69 elif self.options.verbose: 70 click.echo(click.style(line, fg="white", dim=True)) 71 72 if all(s in line for s in ("Tests", "Failures", "Ignored")): 73 self._render_summary() 74 self.test_suite.on_finish() 75 76 # ── Rendering ──────────────────────────────────────────────────────── 77 78 def _render_info(self, msg: str) -> None: 79 if msg.startswith(MODULE_PREFIX): 80 self._render_module_header(msg[len(MODULE_PREFIX):].strip()) 81 return 82 83 if match := self._match_bdd_prefix(msg): 84 prefix, style = match 85 effective = min(style.depth, self._scenario_depth + 1) 86 self._scenario_depth = max(self._scenario_depth, effective) 87 tag = click.style(prefix, fg="black", bg=style.color, bold=True) 88 text = click.style(msg[len(prefix):], fg=style.color) 89 click.echo(f"{INDENT * effective}{tag}{text}") 90 else: 91 depth = max(self._scenario_depth, 1) 92 click.echo(INDENT * depth + click.style(msg, fg="white")) 93 94 def _render_module_header(self, name: str) -> None: 95 self._current_module = name 96 pad = max(0, HEADER_WIDTH - len(name) - 2) // 2 97 click.echo() 98 click.echo(click.style(f"{'═' * pad} {name} {'═' * pad}", fg="white", bold=True)) 99 100 def _render_verdict(self, test_case) -> None: 101 self.test_suite.add_case(test_case) 102 name = test_case.name.replace("_", " ").removeprefix("test ") 103 status = test_case.status.name 104 bg = VERDICT_COLORS.get(status, "white") 105 tag = click.style(f"[{status}]", fg="black", bg=bg, bold=True) 106 styled_name = click.style(name, fg=bg.removeprefix("bright_")) 107 108 dur = getattr(test_case, "duration", 0) or 0 109 duration = click.style(f" {int(dur * 1000)} ms", fg="white", dim=True) if dur > 0 else "" 110 111 click.echo(f"{tag} {styled_name}{duration}") 112 113 def _render_summary(self) -> None: 114 cases = self.test_suite.cases 115 total = len(cases) 116 if not total: 117 return 118 119 counts = Counter(c.status.name for c in cases) 120 divider = click.style("═" * HEADER_WIDTH, fg="white", bold=True) 121 122 click.echo() 123 click.echo(divider) 124 click.echo(click.style(" Results", fg="white", bold=True)) 125 click.echo(divider) 126 click.echo(f" {total:3d} total") 127 for label, color in VERDICT_COLORS.items(): 128 click.echo(_summary_row(label.lower(), counts.get(label, 0), total, color)) 129 click.echo(divider) 130 131 # ── Helpers ────────────────────────────────────────────────────────── 132 133 @staticmethod 134 def _match_bdd_prefix(msg: str) -> tuple[str, BddStyle] | None: 135 return next( 136 ((prefix, style) for prefix, style in BDD_STYLES.items() if msg.startswith(prefix)), 137 None, 138 )