/ firmware / tests / test_custom_runner.py
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          )