from __future__ import annotations import argparse import json import sys from collections import Counter from pathlib import Path from typing import Any, TextIO from .bench_connect_lcd import format_frame, label_frame, parse_frame def build_arg_parser() -> argparse.ArgumentParser: parser = argparse.ArgumentParser( description="Compare two serial_scenario result JSON files and highlight extra button/report traffic." ) parser.add_argument("baseline", type=Path, help="baseline result JSON, usually a no-button run") parser.add_argument("candidate", type=Path, help="candidate result JSON, usually a one-button run") parser.add_argument("--show-labels", action="store_true", help="also show label count deltas") return parser def main(argv: list[str] | None = None, *, stdout: TextIO = sys.stdout) -> int: args = build_arg_parser().parse_args(argv) baseline = _load_result(args.baseline) candidate = _load_result(args.candidate) print(format_comparison(baseline, candidate, show_labels=args.show_labels), file=stdout) return 0 def _load_result(path: Path) -> dict[str, Any]: with path.open("r", encoding="utf-8") as handle: result = json.load(handle) if not isinstance(result, dict): raise SystemExit(f"{path} is not a result JSON object") result["_path"] = str(path) return result def format_comparison( baseline: dict[str, Any], candidate: dict[str, Any], *, show_labels: bool = False, ) -> str: base_targets = Counter(_string_int_mapping(baseline.get("ack_targets", {}))) candidate_targets = Counter(_string_int_mapping(candidate.get("ack_targets", {}))) target_delta = candidate_targets - base_targets lines = [ "Serial scenario comparison", f"baseline={baseline.get('_path', '')}", f"candidate={candidate.get('_path', '')}", f"baseline_log={baseline.get('log', '')}", f"candidate_log={candidate.get('log', '')}", f"baseline_rx_frames={baseline.get('rx_frames', 0)} candidate_rx_frames={candidate.get('rx_frames', 0)}", f"baseline_ack_sent={baseline.get('ack_sent', 0)} candidate_ack_sent={candidate.get('ack_sent', 0)}", ] if target_delta: lines.append("extra ACK-target frames in candidate:") for frame_text, count in sorted(target_delta.items(), key=_frame_sort_key): lines.append(f" +{count:3d} {frame_text} {_describe_frame(frame_text)}") else: lines.append("no extra ACK-target frames found in candidate") missing = base_targets - candidate_targets if missing: lines.append("baseline ACK-target frames missing/decreased in candidate:") for frame_text, count in sorted(missing.items(), key=_frame_sort_key): lines.append(f" -{count:3d} {frame_text} {_describe_frame(frame_text)}") if show_labels: base_labels = Counter(_string_int_mapping(baseline.get("labels", {}))) candidate_labels = Counter(_string_int_mapping(candidate.get("labels", {}))) label_delta = candidate_labels - base_labels lines.append("label count increases:") if label_delta: for label, count in sorted(label_delta.items()): lines.append(f" +{count:3d} {label}") else: lines.append(" none") return "\n".join(lines) def _string_int_mapping(raw: Any) -> dict[str, int]: if not isinstance(raw, dict): return {} output: dict[str, int] = {} for key, value in raw.items(): try: output[str(key)] = int(value) except (TypeError, ValueError): continue return output def _describe_frame(frame_text: str) -> str: frame = parse_frame(frame_text) selector = ((frame[1] & 0x7F) << 7) | frame[2] value = (frame[3] << 8) | frame[4] label = label_frame(frame) or "checksum_ok_unlabeled" return f"cmd=0x{frame[0]:02X} selector=0x{selector:04X} value=0x{value:04X} label={label}" def _frame_sort_key(item: tuple[str, int]) -> tuple[int, int, int, str]: frame_text, _count = item try: frame = parse_frame(frame_text) except argparse.ArgumentTypeError: return (0xFFFF, 0xFFFF, 0xFFFF, frame_text) selector = ((frame[1] & 0x7F) << 7) | frame[2] value = (frame[3] << 8) | frame[4] return (selector, frame[0], value, frame_text) __all__ = [ "build_arg_parser", "format_comparison", "main", ]