From f370336f102d7d2304e74085e096a84ecab0c70a Mon Sep 17 00:00:00 2001 From: JeffreyChen Date: Fri, 19 Jun 2026 01:17:24 +0800 Subject: [PATCH 1/2] Wire visual regression and the state-machine engine through all layers Two headless cores existed but were never exposed past their package, in violation of the project's "every feature ships headless + AC_ + GUI" rule. Complete them: - Visual regression: facade re-export of take_golden / compare_to_golden / DiffResult / MaskRegion; AC_take_golden and AC_assert_visual executor commands (assert auto-creates the baseline on first run, saves a diff on mismatch, raises like other AC_assert_*); ac_take_golden / ac_assert_visual MCP tools; Script Builder entries. - State machine: facade re-export of run_state_machine / StateMachine / StateMachineError; AC_run_state_machine command; ac_run_state_machine MCP tool; Script Builder entry. Adds headless tests (PIL images / specs injected) and a v6 new-features reference page (EN + Traditional Chinese) plus README sections. --- README.md | 11 +++ README/README_zh-CN.md | 9 ++ README/README_zh-TW.md | 9 ++ .../Eng/doc/new_features/v6_features_doc.rst | 70 +++++++++++++++ docs/source/Eng/eng_index.rst | 1 + .../Zh/doc/new_features/v6_features_doc.rst | 65 ++++++++++++++ docs/source/Zh/zh_index.rst | 1 + je_auto_control/__init__.py | 12 +++ .../gui/script_builder/command_schema.py | 23 +++++ .../utils/executor/action_executor.py | 46 ++++++++++ .../utils/mcp_server/tools/_factories.py | 57 +++++++++++- .../utils/mcp_server/tools/_handlers.py | 28 ++++++ .../headless/test_state_machine_actions.py | 51 +++++++++++ .../test_visual_regression_actions.py | 90 +++++++++++++++++++ 14 files changed, 472 insertions(+), 1 deletion(-) create mode 100644 docs/source/Eng/doc/new_features/v6_features_doc.rst create mode 100644 docs/source/Zh/doc/new_features/v6_features_doc.rst create mode 100644 test/unit_test/headless/test_state_machine_actions.py create mode 100644 test/unit_test/headless/test_visual_regression_actions.py diff --git a/README.md b/README.md index 92f11326..22716e8d 100644 --- a/README.md +++ b/README.md @@ -13,6 +13,7 @@ ## Table of Contents +- [What's new (2026-06-19)](#whats-new-2026-06-19) - [What's new (2026-06-18)](#whats-new-2026-06-18) - [What's new (2026-06-17)](#whats-new-2026-06-17) - [What's new (2026-06)](#whats-new-2026-06) @@ -58,6 +59,16 @@ --- +## What's new (2026-06-19) + +Two headless cores that shipped without the rest of their stack are now +first-class. Both gain a facade re-export, an `AC_*` executor command, an +MCP tool, and a Script Builder entry, with headless tests. Full reference: +[`docs/source/Eng/doc/new_features/v6_features_doc.rst`](docs/source/Eng/doc/new_features/v6_features_doc.rst). + +- **Visual regression (golden images)** — `take_golden` / `compare_to_golden` (`AC_take_golden` / `AC_assert_visual`): capture a baseline screenshot and fail when the screen drifts beyond a pixel tolerance, with a highlighted diff image and mask regions. `AC_assert_visual` auto-creates the baseline on first run. PIL-only. +- **Finite-state machine** — `run_state_machine` (`AC_run_state_machine`): drive a script as a declarative `{initial, states}` spec whose `on_enter` actions run through the executor and whose transitions fire on `after` / `if_var_eq` / predicate guards, bounded by `max_steps` / `global_timeout_s`. + ## What's new (2026-06-18) Eight headless capabilities that round out scripting, integration, and CI diff --git a/README/README_zh-CN.md b/README/README_zh-CN.md index 037d66e2..17653d13 100644 --- a/README/README_zh-CN.md +++ b/README/README_zh-CN.md @@ -12,6 +12,7 @@ ## 目录 +- [本次更新 (2026-06-19)](#本次更新-2026-06-19) - [本次更新 (2026-06-18)](#本次更新-2026-06-18) - [本次更新 (2026-06-17)](#本次更新-2026-06-17) - [本次更新 (2026-06)](#本次更新-2026-06) @@ -57,6 +58,14 @@ --- +## 本次更新 (2026-06-19) + +两个早已存在、却没接上其余各层的 headless 核心,现在成为一级功能。两者都新增 facade re-export、`AC_*` 执行器指令、MCP 工具与 Script Builder 项目,并有 headless 测试。完整参考: +[`docs/source/Eng/doc/new_features/v6_features_doc.rst`](../docs/source/Eng/doc/new_features/v6_features_doc.rst)。 + +- **视觉回归(黄金图像)** — `take_golden` / `compare_to_golden`(`AC_take_golden` / `AC_assert_visual`):捕获基准截图,画面偏离超过像素容差时判失败,并输出高亮差异图与遮罩区域。`AC_assert_visual` 首跑会自动建立基准。纯 PIL。 +- **有限状态机** — `run_state_machine`(`AC_run_state_machine`):把脚本当成声明式 `{initial, states}` spec 驱动,`on_enter` 动作经执行器执行,transition 依 `after` / `if_var_eq` / predicate 触发,并以 `max_steps` / `global_timeout_s` 限制。 + ## 本次更新 (2026-06-18) 八项 headless 能力,补齐脚本化、集成与 CI 场景:真正的命令行界面、把录制转成代码,以及一级的 HTTP / SQL / Email / PDF / 等待步骤。每项都附带 headless API、`AC_*` 执行器指令、MCP 工具与可视化脚本构建器项目,并有 headless 测试(网络 / SMTP / PDF 后端均注入,不接触外部系统)。完整参考页: diff --git a/README/README_zh-TW.md b/README/README_zh-TW.md index 97e00192..36391dcb 100644 --- a/README/README_zh-TW.md +++ b/README/README_zh-TW.md @@ -12,6 +12,7 @@ ## 目錄 +- [本次更新 (2026-06-19)](#本次更新-2026-06-19) - [本次更新 (2026-06-18)](#本次更新-2026-06-18) - [本次更新 (2026-06-17)](#本次更新-2026-06-17) - [本次更新 (2026-06)](#本次更新-2026-06) @@ -57,6 +58,14 @@ --- +## 本次更新 (2026-06-19) + +兩個早已存在、卻沒接上其餘各層的 headless 核心,現在成為一級功能。兩者都新增 facade re-export、`AC_*` 執行器指令、MCP 工具與 Script Builder 項目,並有 headless 測試。完整參考: +[`docs/source/Zh/doc/new_features/v6_features_doc.rst`](../docs/source/Zh/doc/new_features/v6_features_doc.rst)。 + +- **視覺回歸(黃金影像)** — `take_golden` / `compare_to_golden`(`AC_take_golden` / `AC_assert_visual`):擷取基準截圖,畫面偏離超過像素容差時判失敗,並輸出標示差異圖與遮罩區域。`AC_assert_visual` 首跑會自動建立基準。純 PIL。 +- **有限狀態機** — `run_state_machine`(`AC_run_state_machine`):把腳本當成宣告式 `{initial, states}` spec 驅動,`on_enter` 動作經執行器執行,transition 依 `after` / `if_var_eq` / predicate 觸發,並以 `max_steps` / `global_timeout_s` 限制。 + ## 本次更新 (2026-06-18) 八項 headless 能力,補齊腳本化、整合與 CI 情境:真正的命令列介面、把錄製轉成程式碼,以及一級的 HTTP / SQL / Email / PDF / 等待步驟。每項都附帶 headless API、`AC_*` 執行器指令、MCP 工具與視覺化腳本建構器項目,並有 headless 測試(網路 / SMTP / PDF 後端皆注入,不碰外部系統)。完整參考頁: diff --git a/docs/source/Eng/doc/new_features/v6_features_doc.rst b/docs/source/Eng/doc/new_features/v6_features_doc.rst new file mode 100644 index 00000000..a3ba0970 --- /dev/null +++ b/docs/source/Eng/doc/new_features/v6_features_doc.rst @@ -0,0 +1,70 @@ +========================================================= +New Features (2026-06-19) — Visual Regression & FSM +========================================================= + +Two headless cores that already existed but were never wired through the +rest of the stack are now first-class: **golden-image visual regression** +and a **declarative finite-state-machine** runner. Both ship a facade +re-export, an ``AC_*`` executor command, an MCP tool, and a Script Builder +entry, with headless tests (PIL images / specs are injected, so nothing +needs a real screen). + +.. contents:: + :local: + :depth: 2 + + +Visual regression (golden images) +================================= + +Capture a baseline image and later fail a run when the screen drifts from +it — the screenshot equivalent of a snapshot test:: + + from je_auto_control import take_golden, compare_to_golden + + take_golden("goldens/login.png") # establish baseline + result = compare_to_golden("goldens/login.png", tolerance=0.5) + if not result.matched: + result.write_diff("goldens/login.diff.png") + print(result.summary) + +``compare_to_golden`` returns a ``DiffResult`` (``matched``, ``diff_pct``, +``differing_pixels``, a highlighted ``diff_image``); ``tolerance`` is the +percentage of pixels allowed to differ and ``per_pixel_threshold`` ignores +small per-channel noise. ``MaskRegion`` excludes animated / volatile areas. +PIL-only — no OpenCV / SciPy dependency. + +Executor commands: + +* ``AC_take_golden`` — capture and save a baseline (optional ``region``). +* ``AC_assert_visual`` — compare the screen to a golden and raise on + mismatch (saving an optional ``diff_path``). On the **first run** (golden + missing) it captures the baseline and passes, unless + ``create_if_missing`` is false. + + +Finite-state machine +==================== + +Drive a script as a declarative state machine — clearer than nested +loops / ifs for screen-flow automation:: + + from je_auto_control import run_state_machine + + spec = { + "initial": "login", + "states": { + "login": {"on_enter": [["AC_click_text", {"text": "Sign in"}]], + "transitions": [{"go_to": "home", "after": 1.0}]}, + "home": {"final": True}, + }, + } + result = run_state_machine(spec) # {final_state, steps, elapsed_s} + +Each state's ``on_enter`` actions run through the executor; transitions +fire on guards (``after`` a delay, ``if_var_eq``, or a caller predicate). +``max_steps`` and ``global_timeout_s`` bound the run so it can't loop +forever. + +Executor command: ``AC_run_state_machine`` (the ``spec`` dict travels in +JSON action files / the socket server / MCP unchanged). diff --git a/docs/source/Eng/eng_index.rst b/docs/source/Eng/eng_index.rst index 95cf59e2..2e5be35f 100644 --- a/docs/source/Eng/eng_index.rst +++ b/docs/source/Eng/eng_index.rst @@ -28,6 +28,7 @@ Comprehensive guides for all AutoControl features. doc/new_features/v3_features_doc doc/new_features/v4_features_doc doc/new_features/v5_features_doc + doc/new_features/v6_features_doc doc/ocr_backends/ocr_backends_doc doc/observability/observability_doc doc/operations_layer/operations_layer_doc diff --git a/docs/source/Zh/doc/new_features/v6_features_doc.rst b/docs/source/Zh/doc/new_features/v6_features_doc.rst new file mode 100644 index 00000000..c8b32360 --- /dev/null +++ b/docs/source/Zh/doc/new_features/v6_features_doc.rst @@ -0,0 +1,65 @@ +============================================ +新功能 (2026-06-19) — 視覺回歸與狀態機 +============================================ + +兩個早已存在、卻從未接上其餘各層的 headless 核心,現在成為一級功能: +**黃金影像視覺回歸**與**宣告式有限狀態機**執行器。兩者都提供 facade +re-export、``AC_*`` 執行器指令、MCP 工具與 Script Builder 項目,並有 +headless 測試(PIL 影像 / spec 以注入方式提供,完全不需真實螢幕)。 + +.. contents:: + :local: + :depth: 2 + + +視覺回歸(黃金影像) +==================== + +擷取基準圖,之後在畫面偏離時讓該次執行失敗——等同於截圖版的 snapshot +測試:: + + from je_auto_control import take_golden, compare_to_golden + + take_golden("goldens/login.png") # 建立基準 + result = compare_to_golden("goldens/login.png", tolerance=0.5) + if not result.matched: + result.write_diff("goldens/login.diff.png") + print(result.summary) + +``compare_to_golden`` 回傳 ``DiffResult``(``matched``、``diff_pct``、 +``differing_pixels``、標示差異的 ``diff_image``);``tolerance`` 是允許 +差異的像素百分比,``per_pixel_threshold`` 可忽略各通道的細微雜訊。 +``MaskRegion`` 可排除動畫 / 易變區域。純 PIL——不需 OpenCV / SciPy。 + +執行器指令: + +* ``AC_take_golden`` — 擷取並存基準圖(可選 ``region``)。 +* ``AC_assert_visual`` — 把畫面與黃金影像比對,不符即拋例外(可存 + ``diff_path``)。**首跑**(基準不存在)時會擷取基準並通過,除非 + ``create_if_missing`` 設為 false。 + + +有限狀態機 +========== + +把腳本當成宣告式狀態機來驅動——對於畫面流程自動化,比巢狀 loop / if +更清楚:: + + from je_auto_control import run_state_machine + + spec = { + "initial": "login", + "states": { + "login": {"on_enter": [["AC_click_text", {"text": "Sign in"}]], + "transitions": [{"go_to": "home", "after": 1.0}]}, + "home": {"final": True}, + }, + } + result = run_state_machine(spec) # {final_state, steps, elapsed_s} + +每個狀態的 ``on_enter`` 動作會透過執行器執行;transition 依 guard 觸發 +(延遲 ``after``、``if_var_eq`` 或呼叫端 predicate)。``max_steps`` 與 +``global_timeout_s`` 限制執行,使其不會無限迴圈。 + +執行器指令:``AC_run_state_machine``(``spec`` dict 可原樣經 JSON 動作檔 / +socket server / MCP 傳遞)。 diff --git a/docs/source/Zh/zh_index.rst b/docs/source/Zh/zh_index.rst index eef64e83..8d9b8988 100644 --- a/docs/source/Zh/zh_index.rst +++ b/docs/source/Zh/zh_index.rst @@ -28,6 +28,7 @@ AutoControl 所有功能的完整使用指南。 doc/new_features/v3_features_doc doc/new_features/v4_features_doc doc/new_features/v5_features_doc + doc/new_features/v6_features_doc doc/ocr_backends/ocr_backends_doc doc/observability/observability_doc doc/operations_layer/operations_layer_doc diff --git a/je_auto_control/__init__.py b/je_auto_control/__init__.py index 39ec3034..3fd0b9fe 100644 --- a/je_auto_control/__init__.py +++ b/je_auto_control/__init__.py @@ -152,6 +152,14 @@ wait_until_pixel_changes, wait_until_port, wait_until_region_idle, wait_until_screen_stable, wait_until_window_closed, ) +# Visual regression (golden-image comparison) +from je_auto_control.utils.visual_regression import ( + DiffResult, MaskRegion, compare_to_golden, image_difference, take_golden, +) +# Declarative finite-state-machine engine for action JSON +from je_auto_control.utils.state_machine import ( + StateMachine, StateMachineError, run_state_machine, +) # Assertion DSL (verify screen state; raise on mismatch) from je_auto_control.utils.assertion import ( AssertionResult, GroupAssertionResult, assert_all, assert_any, @@ -570,6 +578,10 @@ def start_autocontrol_gui(*args, **kwargs): "wait_until_region_idle", "wait_until_screen_stable", "wait_until_clipboard_changes", "wait_until_window_closed", "wait_until_file", "wait_until_port", + # Visual regression + state machine + "take_golden", "compare_to_golden", "image_difference", + "DiffResult", "MaskRegion", + "run_state_machine", "StateMachine", "StateMachineError", # Assertion DSL "AssertionResult", "assert_image", "assert_pixel", "assert_text", "assert_window", "assert_clipboard", "assert_process", diff --git a/je_auto_control/gui/script_builder/command_schema.py b/je_auto_control/gui/script_builder/command_schema.py index 1f400733..3ed0e7ab 100644 --- a/je_auto_control/gui/script_builder/command_schema.py +++ b/je_auto_control/gui/script_builder/command_schema.py @@ -562,6 +562,29 @@ def _add_misc_specs(specs: List[CommandSpec]) -> None: "AC_shell_command", "Shell", "Shell Command", fields=(FieldSpec("shell_command", FieldType.STRING),), )) + specs.append(CommandSpec( + "AC_take_golden", "Report", "Capture Golden Image", + fields=(FieldSpec("path", FieldType.FILE_PATH),), + description="Capture and save a baseline image for visual regression.", + )) + specs.append(CommandSpec( + "AC_assert_visual", "Report", "Assert Visual (Golden)", + fields=( + FieldSpec("golden_path", FieldType.FILE_PATH), + FieldSpec("tolerance", FieldType.FLOAT, optional=True, default=0.0, + min_value=0.0), + FieldSpec("per_pixel_threshold", FieldType.INT, optional=True, + default=16, min_value=0), + FieldSpec("diff_path", FieldType.FILE_PATH, optional=True), + ), + description=("Compare the screen to a golden image; first run creates " + "the baseline. Use the JSON view for a region / masks."), + )) + specs.append(CommandSpec( + "AC_run_state_machine", "Flow", "Run State Machine", + description=("Run a finite-state-machine; configure the 'spec' " + "{initial, states} dict in the JSON view."), + )) specs.append(CommandSpec( "AC_shell_to_var", "Shell", "Shell Output into Variable", fields=( diff --git a/je_auto_control/utils/executor/action_executor.py b/je_auto_control/utils/executor/action_executor.py index 67e21a71..8709abf0 100644 --- a/je_auto_control/utils/executor/action_executor.py +++ b/je_auto_control/utils/executor/action_executor.py @@ -2159,6 +2159,49 @@ def _assert_pdf_text(path: str, text: str, present: bool = True, raise_on_fail=bool(raise_on_fail)) +def _take_golden(path: str, region: Optional[List[int]] = None) -> str: + """Adapter: capture and save a golden/baseline image.""" + from je_auto_control.utils.visual_regression import take_golden + return str(take_golden(path, region=region)) + + +def _assert_visual(golden_path: str, region: Optional[List[int]] = None, + tolerance: float = 0.0, per_pixel_threshold: int = 16, + diff_path: Optional[str] = None, + create_if_missing: bool = True, + raise_on_fail: bool = True) -> Dict[str, Any]: + """Adapter: compare the screen to a golden image (first run creates it).""" + import os + from je_auto_control.utils.exception.exceptions import ( + AutoControlAssertionException, + ) + from je_auto_control.utils.visual_regression import ( + compare_to_golden, take_golden, + ) + if create_if_missing and not os.path.exists( + os.path.expanduser(str(golden_path))): + take_golden(golden_path, region=region) + return {"created": True, "matched": True, "golden": str(golden_path)} + result = compare_to_golden( + golden_path, region=region, tolerance=float(tolerance), + per_pixel_threshold=int(per_pixel_threshold)) + if diff_path and result.diff_image is not None: + result.write_diff(diff_path) + data = {"matched": result.matched, "diff_pct": result.diff_pct, + "differing_pixels": result.differing_pixels, + "total_pixels": result.total_pixels, + "tolerance_pct": result.tolerance_pct} + if not result.matched and raise_on_fail: + raise AutoControlAssertionException(result.summary) + return data + + +def _run_state_machine(spec: Any) -> Dict[str, Any]: + """Adapter: run a finite-state-machine spec through the executor.""" + from je_auto_control.utils.state_machine import run_state_machine + return run_state_machine(spec) + + class Executor: """ Executor @@ -2224,6 +2267,9 @@ def __init__(self): "AC_generate_code": _generate_code, "AC_send_email": _send_email, "AC_assert_pdf_text": _assert_pdf_text, + "AC_take_golden": _take_golden, + "AC_assert_visual": _assert_visual, + "AC_run_state_machine": _run_state_machine, "AC_http_request": http_request, # Record 錄製 diff --git a/je_auto_control/utils/mcp_server/tools/_factories.py b/je_auto_control/utils/mcp_server/tools/_factories.py index 0fbc2a7f..60685cdb 100644 --- a/je_auto_control/utils/mcp_server/tools/_factories.py +++ b/je_auto_control/utils/mcp_server/tools/_factories.py @@ -2265,6 +2265,60 @@ def http_tools() -> List[MCPTool]: ] +def visual_regression_tools() -> List[MCPTool]: + return [ + MCPTool( + name="ac_take_golden", + description=("Capture and save a golden/baseline image of the " + "screen (or a [x, y, w, h] region) for later visual " + "regression checks."), + input_schema=schema({ + "path": {"type": "string"}, + "region": {"type": "array", "items": {"type": "integer"}}, + }, required=["path"]), + handler=h.take_golden, + annotations=SIDE_EFFECT_ONLY, + ), + MCPTool( + name="ac_assert_visual", + description=("Compare the screen (or a region) against a golden " + "image; fail when more than 'tolerance' percent of " + "pixels differ beyond per_pixel_threshold. On the " + "first run (golden missing) it captures the baseline " + "and passes unless create_if_missing=false. Pass " + "diff_path to save a highlighted diff on mismatch."), + input_schema=schema({ + "golden_path": {"type": "string"}, + "region": {"type": "array", "items": {"type": "integer"}}, + "tolerance": {"type": "number"}, + "per_pixel_threshold": {"type": "integer"}, + "diff_path": {"type": "string"}, + "create_if_missing": {"type": "boolean"}, + "raise_on_fail": {"type": "boolean"}, + }, required=["golden_path"]), + handler=h.assert_visual, + annotations=READ_ONLY, + ), + ] + + +def state_machine_tools() -> List[MCPTool]: + return [ + MCPTool( + name="ac_run_state_machine", + description=("Run a declarative finite-state-machine 'spec' " + "{initial, states:{name:{on_enter:[...], " + "transitions:[{go_to, after?/if_var_eq?}], final?}}}. " + "on_enter actions run through the executor; returns " + "{final_state, steps, elapsed_s}."), + input_schema=schema({"spec": {"type": "object"}}, + required=["spec"]), + handler=h.run_state_machine, + annotations=SIDE_EFFECT_ONLY, + ), + ] + + def codegen_tools() -> List[MCPTool]: return [ MCPTool( @@ -2484,7 +2538,8 @@ def media_assert_tools() -> List[MCPTool]: scheduler_tools, trigger_tools, hotkey_tools, screen_record_tools, process_and_shell_tools, remote_desktop_tools, gamepad_tools, usb_passthrough_tools, assertion_tools, data_source_tools, - sql_tools, http_tools, email_tools, pdf_tools, codegen_tools, + sql_tools, http_tools, email_tools, pdf_tools, + visual_regression_tools, state_machine_tools, codegen_tools, flakiness_tools, suite_tools, quarantine_tools, a11y_audit_tools, device_matrix_tools, media_assert_tools, ) diff --git a/je_auto_control/utils/mcp_server/tools/_handlers.py b/je_auto_control/utils/mcp_server/tools/_handlers.py index e1fad24d..9cb75615 100644 --- a/je_auto_control/utils/mcp_server/tools/_handlers.py +++ b/je_auto_control/utils/mcp_server/tools/_handlers.py @@ -1850,6 +1850,34 @@ def generate_code(source: Any, target: str = "pytest", return {"code": _gen(actions, target=target, name=name, style=style)} +# --- Visual regression + state machine ------------------------------------- + +def take_golden(path: str, region: Optional[List[int]] = None) -> str: + from je_auto_control.utils.visual_regression import ( + take_golden as _take, + ) + return str(_take(path, region=region)) + + +def assert_visual(golden_path: str, region: Optional[List[int]] = None, + tolerance: float = 0.0, per_pixel_threshold: int = 16, + diff_path: Optional[str] = None, + create_if_missing: bool = True, + raise_on_fail: bool = True) -> Dict[str, Any]: + from je_auto_control.utils.executor.action_executor import _assert_visual + return _assert_visual( + golden_path, region=region, tolerance=tolerance, + per_pixel_threshold=per_pixel_threshold, diff_path=diff_path, + create_if_missing=create_if_missing, raise_on_fail=raise_on_fail) + + +def run_state_machine(spec: Dict[str, Any]) -> Dict[str, Any]: + from je_auto_control.utils.state_machine import ( + run_state_machine as _run, + ) + return _run(spec) + + # --- Flaky-test detection -------------------------------------------------- def flaky_report(limit: int = 500, diff --git a/test/unit_test/headless/test_state_machine_actions.py b/test/unit_test/headless/test_state_machine_actions.py new file mode 100644 index 00000000..6e0bfe17 --- /dev/null +++ b/test/unit_test/headless/test_state_machine_actions.py @@ -0,0 +1,51 @@ +"""Headless tests for the state-machine action wiring (AC_run_state_machine).""" +import pytest + +import je_auto_control as ac +from je_auto_control.utils.state_machine import StateMachineError, run_state_machine + + +def _spec(extra=None): + spec = { + "initial": "start", + "states": { + "start": {"transitions": [{"go_to": "done"}]}, + "done": {"final": True}, + }, + } + if extra: + spec.update(extra) + return spec + + +def test_run_reaches_final_state(): + result = run_state_machine(_spec()) + assert result["final_state"] == "done" + assert result["steps"] == 1 + + +def test_invalid_spec_raises(): + with pytest.raises(StateMachineError): + run_state_machine({"states": {}}) # missing 'initial' + + +def test_no_fireable_transition_raises(): + spec = {"initial": "a", + "states": {"a": {"transitions": [{"go_to": "a", + "after": 999}]}}} + with pytest.raises(StateMachineError): + run_state_machine({**spec, "max_steps": 1}) + + +def test_facade_and_executor_wiring(): + assert ac.run_state_machine is run_state_machine + assert "AC_run_state_machine" in ac.executor.known_commands() + record = ac.execute_action( + [["AC_run_state_machine", {"spec": _spec()}]]) + assert any("done" in str(v) for v in record.values()) + + +def test_mcp_tool_registered(): + from je_auto_control.utils.mcp_server.tools import build_default_tool_registry + names = {t.name for t in build_default_tool_registry()} + assert "ac_run_state_machine" in names diff --git a/test/unit_test/headless/test_visual_regression_actions.py b/test/unit_test/headless/test_visual_regression_actions.py new file mode 100644 index 00000000..e3b7d14e --- /dev/null +++ b/test/unit_test/headless/test_visual_regression_actions.py @@ -0,0 +1,90 @@ +"""Headless tests for the visual-regression action wiring. + +PIL images are passed directly (no real screen); the AC_/MCP paths +monkeypatch the screen grab so they stay deterministic and offline. +""" +import pytest +from PIL import Image + +import je_auto_control as ac +from je_auto_control.utils.visual_regression import compare as vr + + +def _img(color, size=(8, 8)): + return Image.new("RGB", size, color) + + +def test_take_golden_and_match(tmp_path): + golden = tmp_path / "g.png" + ac.take_golden(str(golden), source=_img((10, 20, 30))) + assert golden.exists() + result = ac.compare_to_golden(str(golden), actual=_img((10, 20, 30))) + assert result.matched is True + assert result.diff_pct == 0.0 + + +def test_mismatch_reports_diff(tmp_path): + golden = tmp_path / "g.png" + ac.take_golden(str(golden), source=_img((0, 0, 0))) + result = ac.compare_to_golden(str(golden), actual=_img((255, 255, 255))) + assert result.matched is False + assert result.diff_pct == 100.0 + assert result.diff_image is not None + + +def test_tolerance_allows_small_diff(tmp_path): + golden = tmp_path / "g.png" + base = _img((0, 0, 0), size=(10, 10)) + ac.take_golden(str(golden), source=base) + changed = base.copy() + changed.putpixel((0, 0), (255, 255, 255)) # 1 / 100 px = 1% + assert ac.compare_to_golden(str(golden), actual=changed, + tolerance=2.0).matched is True + assert ac.compare_to_golden(str(golden), actual=changed, + tolerance=0.0).matched is False + + +def test_ac_assert_visual_first_run_creates_baseline(tmp_path, monkeypatch): + monkeypatch.setattr(vr, "_grab", lambda region: _img((5, 5, 5))) + golden = tmp_path / "screen.png" + record = ac.execute_action( + [["AC_assert_visual", {"golden_path": str(golden)}]]) + assert golden.exists() + assert any("created" in str(v) for v in record.values()) + + +def test_ac_assert_visual_passes_then_fails(tmp_path, monkeypatch): + from je_auto_control.utils.exception.exceptions import ( + AutoControlAssertionException, + ) + golden = tmp_path / "screen.png" + monkeypatch.setattr(vr, "_grab", lambda region: _img((5, 5, 5))) + ac.take_golden(str(golden), source=_img((5, 5, 5))) + # same screen -> matches + rec_ok = ac.execute_action( + [["AC_assert_visual", {"golden_path": str(golden)}]]) + assert any("'matched': True" in str(v) for v in rec_ok.values()) + # different screen -> assertion raises (like every other AC_assert_*) + monkeypatch.setattr(vr, "_grab", lambda region: _img((200, 0, 0))) + with pytest.raises(AutoControlAssertionException): + ac.execute_action([["AC_assert_visual", {"golden_path": str(golden)}]]) + # raise_on_fail=False returns a dict instead of raising + rec_soft = ac.execute_action( + [["AC_assert_visual", {"golden_path": str(golden), + "raise_on_fail": False}]]) + assert any("'matched': False" in str(v) for v in rec_soft.values()) + + +def test_ac_take_golden_command(tmp_path, monkeypatch): + monkeypatch.setattr(vr, "_grab", lambda region: _img((1, 2, 3))) + golden = tmp_path / "out.png" + ac.execute_action([["AC_take_golden", {"path": str(golden)}]]) + assert golden.exists() + + +def test_facade_and_mcp_wiring(): + assert "AC_take_golden" in ac.executor.known_commands() + assert "AC_assert_visual" in ac.executor.known_commands() + from je_auto_control.utils.mcp_server.tools import build_default_tool_registry + names = {t.name for t in build_default_tool_registry()} + assert {"ac_take_golden", "ac_assert_visual"} <= names From 1636e95b37d8e92915801938d1126acc71759feb Mon Sep 17 00:00:00 2001 From: JeffreyChen Date: Fri, 19 Jun 2026 01:21:43 +0800 Subject: [PATCH 2/2] Use pytest.approx for diff_pct comparisons (S1244) Compare the floating-point diff_pct via pytest.approx instead of exact equality so SonarCloud's reliability gate stays green. --- test/unit_test/headless/test_visual_regression_actions.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/test/unit_test/headless/test_visual_regression_actions.py b/test/unit_test/headless/test_visual_regression_actions.py index e3b7d14e..1023be4d 100644 --- a/test/unit_test/headless/test_visual_regression_actions.py +++ b/test/unit_test/headless/test_visual_regression_actions.py @@ -20,7 +20,7 @@ def test_take_golden_and_match(tmp_path): assert golden.exists() result = ac.compare_to_golden(str(golden), actual=_img((10, 20, 30))) assert result.matched is True - assert result.diff_pct == 0.0 + assert result.diff_pct == pytest.approx(0.0) def test_mismatch_reports_diff(tmp_path): @@ -28,7 +28,7 @@ def test_mismatch_reports_diff(tmp_path): ac.take_golden(str(golden), source=_img((0, 0, 0))) result = ac.compare_to_golden(str(golden), actual=_img((255, 255, 255))) assert result.matched is False - assert result.diff_pct == 100.0 + assert result.diff_pct == pytest.approx(100.0) assert result.diff_image is not None