"""Run the fixed Origin plot acceptance suite on a dedicated Windows node.""" from __future__ import annotations import argparse import csv import hashlib import importlib.util import json import locale import os import platform import struct import subprocess import sys import time from datetime import datetime, timezone from importlib.metadata import PackageNotFoundError, version from pathlib import Path from typing import Any from uuid import NAMESPACE_URL, uuid5 ADAPTER_DIR = Path(__file__).resolve().parent WORKER_PATH = ADAPTER_DIR / "worker.py" _WORKER_SPEC = importlib.util.spec_from_file_location( "zcbot_origin_acceptance_worker", WORKER_PATH ) if _WORKER_SPEC is None or _WORKER_SPEC.loader is None: raise RuntimeError("ORIGIN_WORKER_IMPORT_FAILED") worker = importlib.util.module_from_spec(_WORKER_SPEC) _WORKER_SPEC.loader.exec_module(worker) REPORT_SCHEMA_VERSION = 1 OUTPUTS = [ {"key": "project", "type": "project", "format": "opju"}, {"key": "figure_png", "type": "figure", "format": "png", "options": {"dpi": 300}}, {"key": "figure_svg", "type": "figure", "format": "svg"}, {"key": "figure_pdf", "type": "figure", "format": "pdf"}, ] EXPECTED_ARTIFACTS = { "project", "figure_png", "figure_svg", "figure_pdf", "plot_spec", "provenance", } ORIGIN_PROCESS_PATTERN = r"^origin(?:\d+)?(?:_?\d+)?(?:64)?\.exe$" def _artifact_id(case_name: str, input_key: str) -> str: return str(uuid5(NAMESPACE_URL, f"zcbot-origin-acceptance:{case_name}:{input_key}")) def _input(case_name: str, key: str) -> dict[str, str]: return {"key": key, "artifact_id": _artifact_id(case_name, key)} def _cases() -> dict[str, dict[str, Any]]: annotations_csv = "x,y\n" + "\n".join( f"{x},{10 + 3 * x + (x % 3) * 2}" for x in range(11) ) + "\n" recipe_csv = "age,strength,modulus,porosity\n" + "\n".join( f"{age},{strength},{modulus},{porosity}" for age, strength, modulus, porosity in ( (1, 12, 18, 27), (3, 24, 23, 22), (7, 38, 28, 18), (28, 55, 33, 13) ) ) + "\n" grid_rows = [ (x, y, round(20 + 1.5 * x - 0.8 * y + 0.12 * x * y, 4)) for y in range(5) for x in range(7) ] grid_csv = "temperature,time,value\n" + "\n".join( f"{x},{y},{z}" for x, y, z in reversed(grid_rows) ) + "\n" stacked_rows = [(1, 3, 5, 7), (3, 4, 6, 8), (7, 5, 5, 7), (28, 8, 6, 8)] stacked_csv = "age,phase_a,phase_b,phase_c\n" + "\n".join( ",".join(str(value) for value in row) for row in stacked_rows ) + "\n" band_rows = [ (x, 80 - 4 * x, 76 - 4 * x, 84 - 4 * x) for x in range(11) ] band_csv = "time,center,lower,upper\n" + "\n".join( ",".join(str(value) for value in row) for row in band_rows ) + "\n" return { "annotations": { "files": {"sample": annotations_csv}, "request": { "schema_version": 2, "inputs": [_input("annotations", "sample")], "operation": {"plot": { "type": "line", "title": "中文标注与参考区域", "canvas": {"width_mm": 160, "height_mm": 100}, "series": [{ "input": "sample", "x": "x", "y": "y", "label": "试样 A", "style": {"color": "#3366CC", "line_width": 1.5}, }], "x_axis": { "title": "龄期", "unit": "d", "minor_ticks": 4, "reverse": True, "grid": "major_minor", }, "y_axis": {"title": "抗压强度", "unit": "MPa"}, "legend": {"enabled": True, "position": "top_left"}, "annotations": [ { "kind": "reference_line", "axis": "x", "value": 7, "color": "#CC0000", "line_style": "dash", "label": "关键龄期", }, { "kind": "reference_band", "axis": "y", "from": 25, "to": 35, "color": "#F4A261", "transparency": 80, "label": "目标区间", }, { "kind": "text", "text": "受控文字标注", "x": 2, "y": 42, "color": "#222222", "font_size": 10, "background": "white", }, ], }}, "outputs": OUTPUTS, }, "expect": { "minimum_graph_layers": 1, "minimum_plot_counts": [1], "minimum_worksheets": 1, "annotation_names": ["ZCBOT_ANN_001", "ZCBOT_ANN_002", "ZCBOT_ANN_003"], }, "oracle": {"reference_line_x": 7, "reference_band_y": [25, 35]}, }, "recipe_2x2": { "files": {"sample": recipe_csv}, "request": { "schema_version": 2, "inputs": [_input("recipe_2x2", "sample")], "operation": {"plot": { "type": "recipe", "recipe_version": 1, "title": "材料性能 2×2 组合图", "canvas": {"width_mm": 180, "height_mm": 150}, "layout": {"rows": 2, "columns": 2, "share_x": True}, "x_axis": {"title": "龄期", "unit": "d"}, "panels": [ { "key": "strength", "panel_label": "(a)", "title": "抗压强度", "y_axis": {"title": "强度", "unit": "MPa"}, "series": [{ "input": "sample", "x": "age", "y": "strength", "kind": "line_scatter", "label": "强度", }], }, { "key": "modulus", "panel_label": "(b)", "title": "弹性模量", "y_axis": {"title": "模量", "unit": "GPa"}, "series": [{ "input": "sample", "x": "age", "y": "modulus", "kind": "line_scatter", "label": "模量", }], }, { "key": "porosity", "panel_label": "(c)", "title": "显气孔率", "y_axis": {"title": "气孔率", "unit": "%"}, "series": [{ "input": "sample", "x": "age", "y": "porosity", "kind": "column", "label": "气孔率", }], }, { "key": "comparison", "panel_label": "(d)", "title": "归一化趋势", "y_axis": {"title": "指标值"}, "series": [ { "input": "sample", "x": "age", "y": "strength", "kind": "line", "label": "强度", }, { "input": "sample", "x": "age", "y": "modulus", "kind": "line", "label": "模量", }, ], }, ], }}, "outputs": OUTPUTS, }, "expect": { "minimum_graph_layers": 4, "minimum_plot_counts": [1, 1, 1, 2], "minimum_worksheets": 1, }, "oracle": {"panel_count": 4, "series_count": 5}, }, "heatmap": { "files": {"grid": grid_csv}, "request": { "schema_version": 2, "inputs": [_input("heatmap", "grid")], "operation": {"plot": { "type": "heatmap", "title": "烧结工艺窗口热图", "canvas": {"width_mm": 150, "height_mm": 110}, "series": [{ "input": "grid", "x": "temperature", "y": "time", "z": "value", }], "x_axis": {"title": "温度", "unit": "°C"}, "y_axis": {"title": "保温时间", "unit": "h"}, "z_axis": {"title": "抗压强度", "unit": "MPa"}, }}, "outputs": OUTPUTS, }, "expect": { "minimum_graph_layers": 1, "minimum_plot_counts": [1], "minimum_worksheets": 1, "minimum_matrices": 1, }, "oracle": {"grid_shape": [5, 7], "first_cell": grid_rows[0][2], "last_cell": grid_rows[-1][2]}, }, "surface_3d": { "files": {"grid": grid_csv}, "request": { "schema_version": 2, "inputs": [_input("surface_3d", "grid")], "operation": {"plot": { "type": "surface_3d", "title": "三维工艺响应面", "canvas": {"width_mm": 160, "height_mm": 120}, "series": [{ "input": "grid", "x": "temperature", "y": "time", "z": "value", }], "x_axis": {"title": "温度", "unit": "°C"}, "y_axis": {"title": "保温时间", "unit": "h"}, "z_axis": {"title": "性能", "unit": "MPa"}, "legend": {"enabled": True}, }}, "outputs": OUTPUTS, }, "expect": { "minimum_graph_layers": 1, "minimum_plot_counts": [1], "minimum_worksheets": 1, }, "oracle": {"point_count": len(grid_rows)}, }, "stacked": { "files": {"sample": stacked_csv}, "request": { "schema_version": 2, "inputs": [_input("stacked", "sample")], "operation": {"plot": { "type": "stacked_column", "title": "物相组成", "canvas": {"width_mm": 150, "height_mm": 100}, "series": [ {"input": "sample", "x": "age", "y": "phase_a", "label": "相 A"}, {"input": "sample", "x": "age", "y": "phase_b", "label": "相 B"}, {"input": "sample", "x": "age", "y": "phase_c", "label": "相 C"}, ], "x_axis": {"title": "龄期", "unit": "d"}, "y_axis": {"title": "相含量", "unit": "%"}, }}, "outputs": OUTPUTS, }, "expect": { "minimum_graph_layers": 1, "minimum_plot_counts": [3], "minimum_worksheets": 2, }, "oracle": {"stacked_totals": [sum(row[1:]) for row in stacked_rows]}, }, "band": { "files": {"sample": band_csv}, "request": { "schema_version": 2, "inputs": [_input("band", "sample")], "operation": {"plot": { "type": "band", "title": "耐久性能衰减区间", "canvas": {"width_mm": 150, "height_mm": 100}, "series": [{ "input": "sample", "x": "time", "y": "center", "lower": "lower", "upper": "upper", "label": "均值与区间", "style": {"color": "#2A9D8F", "transparency": 60}, }], "x_axis": {"title": "暴露时间", "unit": "月"}, "y_axis": {"title": "相对动弹模", "unit": "%"}, }}, "outputs": OUTPUTS, }, "expect": { "minimum_graph_layers": 1, "minimum_plot_counts": [3], "minimum_worksheets": 1, }, "oracle": { "lower_not_above_center": all(row[2] <= row[1] for row in band_rows), "center_not_above_upper": all(row[1] <= row[3] for row in band_rows), }, }, } def _canonical_request(request: dict[str, Any]) -> tuple[str, str]: encoded = json.dumps(request, ensure_ascii=False, sort_keys=True, separators=(",", ":")) return encoded, hashlib.sha256(encoded.encode("utf-8")).hexdigest() def _stage(root: Path, case_name: str, case: dict[str, Any]) -> Path: job_dir = root / case_name request_dir = job_dir / "request" request_dir.mkdir(parents=True) for input_key, content in case["files"].items(): input_dir = job_dir / "input" / input_key input_dir.mkdir(parents=True) (input_dir / f"{input_key}.csv").write_text(content, encoding="utf-8", newline="") _, digest = _canonical_request(case["request"]) record = { "job_id": str(uuid5(NAMESPACE_URL, f"zcbot-origin-acceptance-job:{case_name}")), "lease_id": str(uuid5(NAMESPACE_URL, f"zcbot-origin-acceptance-lease:{case_name}")), "request_digest": digest, "request": case["request"], } worker._atomic_json(request_dir / "request.json", record) return job_dir def _png_dimensions(path: Path) -> tuple[int, int]: data = path.read_bytes()[:24] if len(data) != 24 or not data.startswith(b"\x89PNG\r\n\x1a\n"): raise RuntimeError("PNG_EXPORT_INVALID") return struct.unpack(">II", data[16:24]) def _normalized_cell(value: Any) -> Any: if value is None or value == "": return None if isinstance(value, bool): return value try: number = float(value) except (TypeError, ValueError): return str(value) return int(number) if number.is_integer() else round(number, 12) def _csv_fixture(content: str) -> tuple[list[str], list[list[Any]]]: rows = list(csv.reader(content.splitlines())) return rows[0], [[_normalized_cell(value) for value in row] for row in rows[1:]] def _worksheet_snapshot(sheet: Any) -> dict[str, Any]: labels = list(sheet.get_labels("L")) rows = [list(row) for row in sheet.to_list2(c1=0, c2=max(0, len(labels) - 1))] while rows and all(value in (None, "") for value in rows[-1]): rows.pop() return { "name": str(sheet.lname), "labels": labels, "rows": [[_normalized_cell(value) for value in row[: len(labels)]] for row in rows], } def _validate_project_data( workbooks: list[Any], matrices: list[Any], case: dict[str, Any] ) -> dict[str, Any]: sheets = [_worksheet_snapshot(sheet) for book in workbooks for sheet in book] by_name = {item["name"]: item for item in sheets} for input_key, content in case["files"].items(): if input_key not in by_name: raise RuntimeError(f"OPJU_INPUT_SHEET_MISSING:{input_key}") headers, rows = _csv_fixture(content) actual = by_name[input_key] if actual["labels"][: len(headers)] != headers: raise RuntimeError(f"OPJU_INPUT_LABELS_MISMATCH:{input_key}") actual_rows = [row[: len(headers)] for row in actual["rows"][: len(rows)]] if actual_rows != rows: raise RuntimeError(f"OPJU_INPUT_VALUES_MISMATCH:{input_key}") oracle_validation: dict[str, Any] = {"input_sheets_match": True} if "stacked_totals" in case["oracle"]: staging = by_name.get("stacked_plot_data") if staging is None: raise RuntimeError("OPJU_STACKED_STAGING_MISSING") totals = [sum(float(value) for value in row[1:4]) for row in staging["rows"]] expected = [float(value) for value in case["oracle"]["stacked_totals"]] if totals != expected: raise RuntimeError("OPJU_STACKED_TOTALS_MISMATCH") oracle_validation["stacked_totals"] = totals if "grid_shape" in case["oracle"]: matrix_sheets = [sheet for book in matrices for sheet in book] if not matrix_sheets: raise RuntimeError("OPJU_HEATMAP_MATRIX_MISSING") matrix = matrix_sheets[-1].to_np2d() shape = [int(value) for value in matrix.shape] if shape != case["oracle"]["grid_shape"]: raise RuntimeError(f"OPJU_HEATMAP_MATRIX_SHAPE_INVALID:{shape}") first = _normalized_cell(matrix[0, 0]) last = _normalized_cell(matrix[-1, -1]) if first != case["oracle"]["first_cell"] or last != case["oracle"]["last_cell"]: raise RuntimeError("OPJU_HEATMAP_MATRIX_VALUES_MISMATCH") oracle_validation.update({"matrix_shape": shape, "matrix_corners": [first, last]}) return { "worksheet_count": len(sheets), "worksheets": [ {"name": item["name"], "columns": len(item["labels"]), "rows": len(item["rows"])} for item in sheets ], "oracle_validation": oracle_validation, } def _validate_manifest(job_dir: Path, request: dict[str, Any], terminal: dict[str, Any]) -> dict[str, Any]: if terminal.get("status") != "succeeded": raise RuntimeError(f"WORKER_FAILED:{terminal.get('error')}") manifest = terminal.get("artifact_manifest") or [] by_id = {item["artifact_id"]: item for item in manifest} if set(by_id) != EXPECTED_ARTIFACTS: raise RuntimeError(f"ARTIFACT_MANIFEST_MISMATCH:{sorted(by_id)}") output = job_dir / "output" paths = { "project": output / "project.opju", "figure_png": output / "figure.png", "figure_svg": output / "figure.svg", "figure_pdf": output / "figure.pdf", "plot_spec": output / "plot-spec.json", "provenance": output / "provenance.json", } extensions = { "project": "opju", "figure_png": "png", "figure_svg": "svg", "figure_pdf": "pdf" } for artifact_id, path in paths.items(): if not path.is_file(): raise RuntimeError(f"ARTIFACT_MISSING:{artifact_id}") if artifact_id in extensions: worker._validate_artifact(path, extensions[artifact_id]) item = by_id[artifact_id] if item["sha256"] != worker._file_sha256(path) or item["size_bytes"] != path.stat().st_size: raise RuntimeError(f"ARTIFACT_DIGEST_MISMATCH:{artifact_id}") if json.loads(paths["plot_spec"].read_text(encoding="utf-8")) != request: raise RuntimeError("PLOT_SPEC_MISMATCH") provenance = json.loads(paths["provenance"].read_text(encoding="utf-8")) if provenance.get("adapter_version") != worker.ADAPTER_VERSION: raise RuntimeError("PROVENANCE_ADAPTER_VERSION_MISMATCH") width, height = _png_dimensions(paths["figure_png"]) canvas = request["operation"]["plot"].get("canvas") or {"width_mm": 160} expected_width = round(300 * canvas["width_mm"] / 25.4) if abs(width - expected_width) > 1 or height <= 0: raise RuntimeError("PNG_DIMENSIONS_INVALID") return { "png_pixels": [width, height], "artifacts": { artifact_id: { "size_bytes": paths[artifact_id].stat().st_size, "sha256": worker._file_sha256(paths[artifact_id]), } for artifact_id in sorted(paths) }, } def _reopen_and_export(job_dir: Path, case: dict[str, Any]) -> dict[str, Any]: import originpro as op project = job_dir / "output" / "project.opju" reopen_dir = job_dir / "reopen" reopen_dir.mkdir() op.set_show(False) try: if not op.open(str(project), readonly=True): raise RuntimeError("OPJU_REOPEN_FAILED") graphs = list(op.pages("g")) workbooks = list(op.pages("w")) matrices = list(op.pages("m")) if not graphs: raise RuntimeError("OPJU_GRAPH_MISSING") graph = graphs[-1] layer_count = len(graph) plot_counts = [len(layer.plot_list()) for layer in graph] expected = case["expect"] if layer_count < expected["minimum_graph_layers"]: raise RuntimeError("OPJU_LAYER_COUNT_INVALID") for index, minimum in enumerate(expected.get("minimum_plot_counts") or []): if index >= len(plot_counts) or plot_counts[index] < minimum: raise RuntimeError(f"OPJU_PLOT_COUNT_INVALID:{index}") worksheet_count = sum(len(book) for book in workbooks) if worksheet_count < expected.get("minimum_worksheets", 0): raise RuntimeError("OPJU_WORKSHEET_COUNT_INVALID") matrix_count = sum(len(book) for book in matrices) if matrix_count < expected.get("minimum_matrices", 0): raise RuntimeError("OPJU_MATRIX_COUNT_INVALID") for name in expected.get("annotation_names") or []: if graph[0].label(name) is None: raise RuntimeError(f"OPJU_ANNOTATION_MISSING:{name}") project_data = _validate_project_data(workbooks, matrices, case) graph.activate() worker._configure_origin_session(op) svg = reopen_dir / "figure.svg" pdf = reopen_dir / "figure.pdf" exported_svg = Path(graph.save_fig(str(svg), type="svg", width=0, ratio=100)).resolve() if exported_svg != svg.resolve() or not svg.is_file(): raise RuntimeError("OPJU_REOPEN_SVG_EXPORT_FAILED") normalized_svg, _ = worker._strip_origin_svg_text_baselines(svg.read_bytes()) svg.write_bytes(normalized_svg) worker._validate_artifact(svg, "svg") exported_pdf = Path(graph.save_fig(str(pdf), type="pdf", width=0, ratio=100)).resolve() if exported_pdf != pdf.resolve() or not pdf.is_file(): raise RuntimeError("OPJU_REOPEN_PDF_EXPORT_FAILED") worker._strip_origin_pdf_text_baselines(pdf) worker._validate_artifact(pdf, "pdf") canvas = case["request"]["operation"]["plot"].get("canvas") or {"width_mm": 160} pixel_width = round(300 * canvas["width_mm"] / 25.4) png = reopen_dir / "figure.png" rendered_width, _ = worker._render_pdf_to_png(pdf, png, pixel_width) if abs(rendered_width - pixel_width) > 1: raise RuntimeError("OPJU_REOPEN_PNG_WIDTH_INVALID") worker._validate_artifact(png, "png") return { "graph_count": len(graphs), "workbook_count": len(workbooks), "worksheet_count": worksheet_count, "matrix_book_count": len(matrices), "matrix_count": matrix_count, "layer_count": layer_count, "plot_counts": plot_counts, "project_data": project_data, "exports": { path.name: {"size_bytes": path.stat().st_size, "sha256": worker._file_sha256(path)} for path in (png, svg, pdf) }, } finally: if op.oext: op.exit() def _environment_fingerprint() -> dict[str, Any]: try: originpro_version = version("originpro") except PackageNotFoundError: originpro_version = "unknown" origin_version = None try: import winreg origin_version = worker._registered_origin_version(winreg) except (ImportError, OSError): pass display: dict[str, int] = {} if sys.platform == "win32": try: import ctypes user32 = ctypes.windll.user32 display = { "width_pixels": int(user32.GetSystemMetrics(0)), "height_pixels": int(user32.GetSystemMetrics(1)), "system_dpi": int(user32.GetDpiForSystem()), } except (AttributeError, OSError): display = {} return { "adapter_version": worker.ADAPTER_VERSION, "origin_version": origin_version, "originpro_version": originpro_version, "python_version": platform.python_version(), "platform": platform.platform(), "locale": locale.getlocale(), "display": display, "execution_mode": "hidden", "configured_origin_executable": bool(os.environ.get("ZCBOT_ORIGIN_EXE")), } def _origin_processes() -> dict[int, str]: if sys.platform != "win32": return {} completed = subprocess.run( ["tasklist.exe", "/fo", "csv", "/nh"], check=True, capture_output=True, text=True, encoding="utf-8", errors="replace", ) processes: dict[int, str] = {} import re for row in csv.reader(completed.stdout.splitlines()): name = row[0].casefold() if row else "" if len(row) >= 2 and re.fullmatch(ORIGIN_PROCESS_PATTERN, name): processes[int(row[1])] = row[0] return processes def _wait_for_origin_release( baseline: dict[int, str], timeout_seconds: int ) -> dict[int, str]: deadline = time.monotonic() + timeout_seconds while True: remaining = { pid: name for pid, name in _origin_processes().items() if pid not in baseline } if not remaining: return {} if time.monotonic() >= deadline: return remaining time.sleep(2) def _run_case( root: Path, case_name: str, case: dict[str, Any], baseline_processes: dict[int, str], release_wait: int, ) -> dict[str, Any]: job_dir = _stage(root, case_name, case) started = time.monotonic() completed = subprocess.run( [sys.executable, str(WORKER_PATH), str(job_dir)], capture_output=True, text=True, encoding="utf-8", errors="replace", check=False, ) elapsed = time.monotonic() - started terminal_path = job_dir / "terminal.json" if not terminal_path.is_file(): raise RuntimeError(f"WORKER_TERMINAL_MISSING:{completed.stderr[-500:]}") terminal = json.loads(terminal_path.read_text(encoding="utf-8")) if completed.returncode != 0: raise RuntimeError(f"WORKER_PROCESS_FAILED:{completed.stderr[-500:]}") validation = _validate_manifest(job_dir, case["request"], terminal) reopened = _reopen_and_export(job_dir, case) remaining = _wait_for_origin_release(baseline_processes, release_wait) if remaining: raise RuntimeError(f"ORIGIN_PROCESS_REMAINS:{remaining}") _, digest = _canonical_request(case["request"]) return { "case": case_name, "elapsed_seconds": round(elapsed, 3), "request_digest": digest, "oracle": case["oracle"], "validation": validation, "reopen": reopened, "origin_processes_released": True, } def run_suite( root: Path, selected: list[str] | None = None, *, release_wait: int = 60, ) -> dict[str, Any]: cases = _cases() names = selected or list(cases) unknown = sorted(set(names) - cases.keys()) if unknown: raise ValueError(f"UNKNOWN_ACCEPTANCE_CASES:{','.join(unknown)}") root.mkdir(parents=True, exist_ok=False) baseline_processes = _origin_processes() report = { "schema_version": REPORT_SCHEMA_VERSION, "started_at": datetime.now(timezone.utc).isoformat(), "environment": _environment_fingerprint(), "baseline_origin_processes": baseline_processes, "selected_cases": names, "cases": [], "passed": False, } report_path = root / "acceptance-report.json" try: for index, name in enumerate(names, start=1): report["cases"].append( _run_case(root, name, cases[name], baseline_processes, release_wait) ) worker._atomic_json(report_path, report) print(f"[OK] Origin acceptance {index}/{len(names)}: {name}") report["passed"] = True return report except Exception as exc: report["failure"] = { "type": type(exc).__name__, "detail": str(exc)[:1000], "completed_cases": len(report["cases"]), } raise finally: report["completed_at"] = datetime.now(timezone.utc).isoformat() worker._atomic_json(report_path, report) def main() -> int: parser = argparse.ArgumentParser(description="Run fixed Origin plot acceptance cases.") parser.add_argument("--work-root", type=Path, required=True) parser.add_argument("--release-wait", type=int, default=60) parser.add_argument( "--case", action="append", choices=tuple(_cases()), dest="cases", help="Run only the named case; repeat the option to select multiple cases.", ) args = parser.parse_args() if sys.platform != "win32": raise RuntimeError("Origin acceptance requires Windows") if args.release_wait < 1: raise ValueError("release-wait must be positive") root = args.work_root.resolve() report = run_suite(root, args.cases, release_wait=args.release_wait) print(f"[OK] Origin acceptance passed. Report: {root / 'acceptance-report.json'}") print(f"[INFO] Cases: {len(report['cases'])}") return 0 if __name__ == "__main__": raise SystemExit(main())