"""Standalone CLI Benchmark Runner & Diagnostic Reporter (Requirement R2). Executable via: .venv/bin/python -m tests.benchmark.runner .venv/bin/python tests/benchmark/runner.py Computes per-domain pass/fail statistics, execution timing, and field diffs for any failed case. Renders a clean terminal summary table and exports tests/benchmark/benchmark_summary.json. """ from __future__ import annotations import argparse from datetime import datetime, timezone import json from pathlib import Path import sys from typing import Any, Dict, List, Optional # Ensure project root is in sys.path _PROJECT_ROOT = Path(__file__).resolve().parent.parent.parent if str(_PROJECT_ROOT) not in sys.path: sys.path.insert(0, str(_PROJECT_ROOT)) try: from tests.benchmark.benchmark_cases import ( BENCHMARK_CASES, BenchmarkCase, CaseResult, evaluate_benchmark_case, ) except ImportError: from benchmark_cases import ( # type: ignore BENCHMARK_CASES, BenchmarkCase, CaseResult, evaluate_benchmark_case, ) DEFAULT_JSON_PATH = Path(__file__).parent / "benchmark_summary.json" class BenchmarkRunner: """Orchestrates execution, diagnostic collection, and reporting for benchmark cases.""" def __init__( self, cases: Optional[List[BenchmarkCase]] = None, json_output_path: Path = DEFAULT_JSON_PATH, verbose: bool = False, show_diffs: bool = True, strict: bool = False, ): self.cases = cases or BENCHMARK_CASES self.json_output_path = json_output_path self.verbose = verbose self.show_diffs = show_diffs self.strict = strict self.results: List[CaseResult] = [] def run(self) -> Dict[str, Any]: """Execute all configured benchmark cases and collect results.""" self.results.clear() for case in self.cases: result = evaluate_benchmark_case(case) self.results.append(result) summary_data = self._build_summary_data() self._export_json(summary_data) return summary_data def _build_summary_data(self) -> Dict[str, Any]: total_cases = len(self.results) passed_cases = sum(1 for r in self.results if r.passed) failed_cases = total_cases - passed_cases pass_rate = (passed_cases / total_cases * 100.0) if total_cases else 0.0 total_duration_ms = sum(r.duration_ms for r in self.results) # Domain breakdown domain_stats: Dict[str, Dict[str, Any]] = {} for r in self.results: d = domain_stats.setdefault( r.domain, { "total": 0, "passed": 0, "failed": 0, "pass_rate_pct": 0.0, "total_duration_ms": 0.0, "avg_duration_ms": 0.0, }, ) d["total"] += 1 if r.passed: d["passed"] += 1 else: d["failed"] += 1 d["total_duration_ms"] += r.duration_ms for d in domain_stats.values(): if d["total"] > 0: d["pass_rate_pct"] = round(d["passed"] / d["total"] * 100.0, 1) d["avg_duration_ms"] = round(d["total_duration_ms"] / d["total"], 2) # Failures list failures = [ { "id": r.case_id, "domain": r.domain, "filename": r.filename, "edge_case_type": r.edge_case_type, "diffs": r.diffs, "actual_category": r.actual_category, "actual_title": r.actual_title, "actual_destination_subpath": r.actual_destination_subpath, "duration_ms": r.duration_ms, } for r in self.results if not r.passed ] # Serialized results all_results_data = [ { "id": r.case_id, "domain": r.domain, "filename": r.filename, "edge_case_type": r.edge_case_type, "passed": r.passed, "duration_ms": r.duration_ms, "diffs": r.diffs, "actual_category": r.actual_category, "actual_title": r.actual_title, "actual_destination_subpath": r.actual_destination_subpath, "exception": r.exception, } for r in self.results ] return { "metadata": { "timestamp": datetime.now(timezone.utc).isoformat(), "python_version": sys.version.split()[0], "total_duration_ms": round(total_duration_ms, 2), "runner": "MediaSorter Offline E2E Benchmark Runner", }, "summary": { "total_cases": total_cases, "passed_cases": passed_cases, "failed_cases": failed_cases, "pass_rate_pct": round(pass_rate, 2), }, "domains": domain_stats, "failures": failures, "all_results": all_results_data, } def _export_json(self, data: Dict[str, Any]) -> None: self.json_output_path.parent.mkdir(parents=True, exist_ok=True) with open(self.json_output_path, "w", encoding="utf-8") as f: json.dump(data, f, indent=2, ensure_ascii=False) def print_terminal_report(self, summary_data: Dict[str, Any]) -> None: """Render a styled terminal summary table and failure details.""" try: self._print_rich_report(summary_data) except Exception: self._print_plain_report(summary_data) def _print_rich_report(self, data: Dict[str, Any]) -> None: from rich.console import Console from rich.markup import escape from rich.panel import Panel from rich.table import Table console = Console() console.print() console.print( Panel.fit( "[bold cyan]Media Sorter E2E Benchmark Test Suite & Offline Runner[/bold cyan]\n" f"[dim]Timestamp: {data['metadata']['timestamp']} | Total Cases: {data['summary']['total_cases']}[/dim]", border_style="cyan", ) ) table = Table(title="Benchmark Results by Domain", show_footer=True) table.add_column("Domain", style="bold white", footer="Total / Overall") table.add_column("Total", justify="right", footer=str(data["summary"]["total_cases"])) table.add_column("Passed", justify="right", style="green", footer=str(data["summary"]["passed_cases"])) table.add_column("Failed", justify="right", style="red", footer=str(data["summary"]["failed_cases"])) table.add_column( "Pass Rate", justify="right", style="bold yellow", footer=f"{data['summary']['pass_rate_pct']:.1f}%", ) table.add_column( "Avg Time (ms)", justify="right", style="dim", footer=f"{data['metadata']['total_duration_ms']:.1f}ms total", ) for domain, stats in data["domains"].items(): rate = stats["pass_rate_pct"] rate_style = "green" if rate == 100.0 else ("yellow" if rate >= 50.0 else "red") table.add_row( domain, str(stats["total"]), str(stats["passed"]), str(stats["failed"]), f"[{rate_style}]{rate:.1f}%[/{rate_style}]", f"{stats['avg_duration_ms']:.2f}ms", ) console.print(table) console.print() # Print failures if requested if self.show_diffs and data["failures"]: console.print(f"[bold red]Diagnostic Gap Details ({len(data['failures'])} failures pending M2/M3):[/bold red]") for item in data["failures"]: esc_filename = escape(item["filename"]) console.print( f"\n [bold red]✖ [{item['id']}][/bold red] [bold white]{esc_filename}[/bold white] " f"([dim]{item['domain']} / {item['edge_case_type']}[/dim])" ) for field_name, diff in item["diffs"].items(): exp_val = escape(repr(diff["expected"])) act_val = escape(repr(diff["actual"])) console.print( f" [yellow]• {field_name}:[/yellow] " f"expected=[green]{exp_val}[/green], got=[red]{act_val}[/red]" ) console.print() console.print( f"[dim]Summary report exported to:[/dim] [cyan]{self.json_output_path.resolve()}[/cyan]\n" ) def _print_plain_report(self, data: Dict[str, Any]) -> None: print("=" * 80) print(" Media Sorter E2E Benchmark Test Suite & Offline Runner") print(f" Timestamp: {data['metadata']['timestamp']} | Total: {data['summary']['total_cases']}") print("=" * 80) print(f"{'Domain':<25} {'Total':>8} {'Passed':>8} {'Failed':>8} {'Pass %':>10} {'Avg (ms)':>10}") print("-" * 80) for domain, stats in data["domains"].items(): print( f"{domain:<25} {stats['total']:>8} {stats['passed']:>8} {stats['failed']:>8} " f"{stats['pass_rate_pct']:>9.1f}% {stats['avg_duration_ms']:>10.2f}" ) print("-" * 80) summ = data["summary"] print( f"{'Total / Overall':<25} {summ['total_cases']:>8} {summ['passed_cases']:>8} {summ['failed_cases']:>8} " f"{summ['pass_rate_pct']:>9.1f}% {data['metadata']['total_duration_ms']:>10.2f}ms" ) print("=" * 80) if self.show_diffs and data["failures"]: print(f"\nDiagnostic Gap Details ({len(data['failures'])} failures pending M2/M3):") for item in data["failures"]: print(f" * [{item['id']}] {item['filename']} ({item['domain']} / {item['edge_case_type']})") for k, v in item["diffs"].items(): print(f" - {k}: expected={v['expected']!r}, got={v['actual']!r}") print(f"\nSummary JSON exported to: {self.json_output_path.resolve()}\n") def main() -> int: parser = argparse.ArgumentParser( description="Offline E2E Benchmark Runner for Media Sorter Pattern Recognition." ) parser.add_argument( "--domain", type=str, default=None, help="Filter execution by specific domain (e.g. 'Anime', 'Standard TV', 'Movies').", ) parser.add_argument( "--case", type=str, default=None, help="Filter execution by specific BenchmarkCase ID (e.g. 'TV-01', 'ANIME-04').", ) parser.add_argument( "--json-output", type=str, default=str(DEFAULT_JSON_PATH), help="Output JSON summary destination path.", ) parser.add_argument( "--verbose", "-v", action="store_true", help="Enable verbose output reporting.", ) parser.add_argument( "--no-diffs", action="store_true", help="Suppress detailed failure diff output.", ) parser.add_argument( "--strict", action="store_true", help="Exit with non-zero status code if any benchmark case fails.", ) args = parser.parse_args() selected_cases = BENCHMARK_CASES if args.domain: selected_cases = [c for c in selected_cases if c.domain.lower() == args.domain.lower()] if not selected_cases: print(f"Error: No benchmark cases match domain '{args.domain}'", file=sys.stderr) return 2 if args.case: selected_cases = [c for c in selected_cases if c.id.upper() == args.case.upper()] if not selected_cases: print(f"Error: No benchmark case matches ID '{args.case}'", file=sys.stderr) return 2 runner = BenchmarkRunner( cases=selected_cases, json_output_path=Path(args.json_output), verbose=args.verbose, show_diffs=not args.no_diffs, strict=args.strict, ) summary_data = runner.run() runner.print_terminal_report(summary_data) if args.strict and summary_data["summary"]["failed_cases"] > 0: return 1 return 0 if __name__ == "__main__": sys.exit(main())