From 6e9f116aff3ac8d7449d0b8abe96087b30402654 Mon Sep 17 00:00:00 2001 From: JeffreyChen Date: Wed, 24 Jun 2026 07:34:10 +0800 Subject: [PATCH] Add match_stability: pre-match settle gating + match persistence Matching mid-animation is a top flakiness source; wait_until_screen_stable gates a live loop with a boolean and can't score an injectable frame sequence or check a match held steady. region_stability scores consecutive-frame SSIM; match_persistence confirms a template is found in every frame with the centres agreeing within agree_px. Reuses ssim + visual_match + grounding_consensus. --- README/WHATS_NEW_zh-CN.md | 6 ++ README/WHATS_NEW_zh-TW.md | 6 ++ WHATS_NEW.md | 6 ++ .../doc/new_features/v180_features_doc.rst | 43 ++++++++++ docs/source/Eng/eng_index.rst | 1 + .../Zh/doc/new_features/v180_features_doc.rst | 41 ++++++++++ docs/source/Zh/zh_index.rst | 1 + je_auto_control/__init__.py | 6 ++ .../gui/script_builder/command_schema.py | 22 +++++ .../utils/executor/action_executor.py | 22 +++++ .../utils/match_stability/__init__.py | 6 ++ .../utils/match_stability/match_stability.py | 62 ++++++++++++++ .../utils/mcp_server/tools/_factories.py | 30 +++++++ .../utils/mcp_server/tools/_handlers.py | 10 +++ .../headless/test_match_stability_batch.py | 81 +++++++++++++++++++ 15 files changed, 343 insertions(+) create mode 100644 docs/source/Eng/doc/new_features/v180_features_doc.rst create mode 100644 docs/source/Zh/doc/new_features/v180_features_doc.rst create mode 100644 je_auto_control/utils/match_stability/__init__.py create mode 100644 je_auto_control/utils/match_stability/match_stability.py create mode 100644 test/unit_test/headless/test_match_stability_batch.py diff --git a/README/WHATS_NEW_zh-CN.md b/README/WHATS_NEW_zh-CN.md index 7fcf0b0b..d4c92bce 100644 --- a/README/WHATS_NEW_zh-CN.md +++ b/README/WHATS_NEW_zh-CN.md @@ -1,5 +1,11 @@ # 本次更新 — AutoControl +## 本次更新 (2026-06-24) — 匹配前安定门 + 命中稳定性 + +避免在动画进行中匹配,并确认命中跨帧维持稳定。完整参考:[`docs/source/Zh/doc/new_features/v180_features_doc.rst`](../docs/source/Zh/doc/new_features/v180_features_doc.rst)。 + +- **`region_stability` / `match_persistence`**(`AC_region_stability`、`AC_match_persistence`):`smart_waits.wait_until_screen_stable` 以布尔门控实时循环——无法对可注入帧序列评分稳定度,也无法检查某*命中*是否维持。`region_stability` 以相邻帧 SSIM 评分(`{stable, mean_ssim, min_ssim}`);`match_persistence` 确认 template 在*每一*帧都找到且中心于 `agree_px` 内一致(`{persisted, n_hits, jitter}`)。重用 `ssim` + `visual_match` + `grounding_consensus`;帧可注入;不导入 `PySide6`。 + ## 本次更新 (2026-06-24) — 色彩感知模板匹配(HSV) 区分形状相同的红色与绿色状态点。完整参考:[`docs/source/Zh/doc/new_features/v179_features_doc.rst`](../docs/source/Zh/doc/new_features/v179_features_doc.rst)。 diff --git a/README/WHATS_NEW_zh-TW.md b/README/WHATS_NEW_zh-TW.md index 86513731..bb67dab1 100644 --- a/README/WHATS_NEW_zh-TW.md +++ b/README/WHATS_NEW_zh-TW.md @@ -1,5 +1,11 @@ # 本次更新 — AutoControl +## 本次更新 (2026-06-24) — 比對前安定閘 + 命中穩定性 + +避免在動畫進行中比對,並確認命中跨幀維持穩定。完整參考:[`docs/source/Zh/doc/new_features/v180_features_doc.rst`](../docs/source/Zh/doc/new_features/v180_features_doc.rst)。 + +- **`region_stability` / `match_persistence`**(`AC_region_stability`、`AC_match_persistence`):`smart_waits.wait_until_screen_stable` 以布林閘控即時迴圈——無法對可注入幀序列評分穩定度,也無法檢查某*命中*是否維持。`region_stability` 以相鄰幀 SSIM 評分(`{stable, mean_ssim, min_ssim}`);`match_persistence` 確認 template 在*每一*幀都找到且中心於 `agree_px` 內一致(`{persisted, n_hits, jitter}`)。重用 `ssim` + `visual_match` + `grounding_consensus`;幀可注入;不匯入 `PySide6`。 + ## 本次更新 (2026-06-24) — 色彩感知樣板比對(HSV) 區分形狀相同的紅色與綠色狀態點。完整參考:[`docs/source/Zh/doc/new_features/v179_features_doc.rst`](../docs/source/Zh/doc/new_features/v179_features_doc.rst)。 diff --git a/WHATS_NEW.md b/WHATS_NEW.md index 47d1db4c..1104cf7a 100644 --- a/WHATS_NEW.md +++ b/WHATS_NEW.md @@ -1,5 +1,11 @@ # What's New — AutoControl +## What's new (2026-06-24) — Pre-Match Settle Gating + Match Persistence + +Avoid matching mid-animation, and confirm a hit holds steady across frames. Full reference: [`docs/source/Eng/doc/new_features/v180_features_doc.rst`](docs/source/Eng/doc/new_features/v180_features_doc.rst). + +- **`region_stability` / `match_persistence`** (`AC_region_stability`, `AC_match_persistence`): `smart_waits.wait_until_screen_stable` gates a live loop with a boolean — it can't score stability on an injectable frame sequence or check whether a *match* held steady. `region_stability` scores consecutive-frame SSIM (`{stable, mean_ssim, min_ssim}`); `match_persistence` confirms a template is found in *every* frame with the centres agreeing within `agree_px` (`{persisted, n_hits, jitter}`). Reuses `ssim` + `visual_match` + `grounding_consensus`; injectable frames; no `PySide6`. + ## What's new (2026-06-24) — Colour-Aware Template Matching (HSV) Tell a red status dot from a green one of identical shape. Full reference: [`docs/source/Eng/doc/new_features/v179_features_doc.rst`](docs/source/Eng/doc/new_features/v179_features_doc.rst). diff --git a/docs/source/Eng/doc/new_features/v180_features_doc.rst b/docs/source/Eng/doc/new_features/v180_features_doc.rst new file mode 100644 index 00000000..16f78134 --- /dev/null +++ b/docs/source/Eng/doc/new_features/v180_features_doc.rst @@ -0,0 +1,43 @@ +Pre-Match Settle Gating + Match Persistence +=========================================== + +Matching mid-animation is a top flakiness source: the spinner is still spinning, a transition +is half-done, and the matcher fires on a transient. ``smart_waits.wait_until_screen_stable`` +gates a *live polling loop* and returns a boolean — it cannot score stability on an *injectable* +sequence of frames, nor tell you whether a *match* held steady across them. ``match_stability`` +adds both, on injected frames so it is unit-testable: ``region_stability`` scores how settled a +frame sequence is (consecutive-frame SSIM), and ``match_persistence`` checks that a template's +hit holds at the same place across the frames (low jitter), not just in one lucky frame. + +It reuses ``ssim.ssim_compare``, ``visual_match.match_template`` and +``grounding_consensus.consensus_point`` — no new matching code. The frames are injectable +(ndarray / path / PIL). Imports no ``PySide6``. + +Headless API +------------ + +.. code-block:: python + + from je_auto_control import region_stability, match_persistence + + frames = [grab(), grab(), grab()] # a few snapshots of the same region + if region_stability(frames, settle_threshold=0.99)["stable"]: + do_the_match() + + result = match_persistence("button.png", frames, min_score=0.8, agree_px=8) + if result["persisted"]: + print("steady hit, jitter", result["jitter"]) + +``region_stability`` returns ``{stable, mean_ssim, min_ssim}`` — ``stable`` when the lowest +consecutive-frame SSIM clears ``settle_threshold``. ``match_persistence`` returns +``{persisted, n_hits, jitter}`` — ``persisted`` when the template is found in *every* frame and +the hit centres agree within ``agree_px`` (``jitter`` is their spread; 0 = rock steady). + +Executor commands +----------------- + +``AC_region_stability`` (``frames`` / ``settle_threshold`` → ``{stable, mean_ssim, min_ssim}``) +and ``AC_match_persistence`` (``template`` / ``frames`` / ``min_score`` / ``agree_px`` → +``{persisted, n_hits, jitter}``). They are exposed as the MCP tools ``ac_region_stability`` / +``ac_match_persistence`` (read-only) and as the Script Builder commands **Region Stability +(frames)** / **Match Persistence (frames)** under **Image**. diff --git a/docs/source/Eng/eng_index.rst b/docs/source/Eng/eng_index.rst index 15c8b6ed..e2c5eed5 100644 --- a/docs/source/Eng/eng_index.rst +++ b/docs/source/Eng/eng_index.rst @@ -202,6 +202,7 @@ Comprehensive guides for all AutoControl features. doc/new_features/v177_features_doc doc/new_features/v178_features_doc doc/new_features/v179_features_doc + doc/new_features/v180_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/v180_features_doc.rst b/docs/source/Zh/doc/new_features/v180_features_doc.rst new file mode 100644 index 00000000..f420a11a --- /dev/null +++ b/docs/source/Zh/doc/new_features/v180_features_doc.rst @@ -0,0 +1,41 @@ +比對前安定閘 + 命中穩定性 +========================== + +在動畫進行中比對是主要的不穩定來源:轉圈圖示還在轉、轉場做到一半,比對器命中暫態。 +``smart_waits.wait_until_screen_stable`` 閘控*即時輪詢迴圈*並回傳布林——它無法對*可注入*的 +幀序列評分穩定度,也無法告訴你某個*命中*是否跨幀維持。``match_stability`` 補上兩者,且作用於 +注入幀因此可單元測試:``region_stability`` 以相鄰幀 SSIM 評分幀序列的安定度,``match_persistence`` +檢查某 template 的命中是否跨幀維持在同一處(jitter 小),而非只在某一幸運幀。 + +本功能重用 ``ssim.ssim_compare``、``visual_match.match_template`` 與 +``grounding_consensus.consensus_point``——不新增比對程式。幀可注入(ndarray / 路徑 / PIL)。 +不匯入 ``PySide6``。 + +無頭 API +-------- + +.. code-block:: python + + from je_auto_control import region_stability, match_persistence + + frames = [grab(), grab(), grab()] # 同一區域的幾張快照 + if region_stability(frames, settle_threshold=0.99)["stable"]: + do_the_match() + + result = match_persistence("button.png", frames, min_score=0.8, agree_px=8) + if result["persisted"]: + print("穩定命中, jitter", result["jitter"]) + +``region_stability`` 回傳 ``{stable, mean_ssim, min_ssim}``——當最低相鄰幀 SSIM 超過 +``settle_threshold`` 時 ``stable``。``match_persistence`` 回傳 ``{persisted, n_hits, jitter}`` +——當 template 在*每一*幀都找到且命中中心於 ``agree_px`` 內一致時 ``persisted``(``jitter`` +為其離散度;0 = 穩如磐石)。 + +執行器指令 +---------- + +``AC_region_stability``(``frames`` / ``settle_threshold`` → ``{stable, mean_ssim, min_ssim}``) +與 ``AC_match_persistence``(``template`` / ``frames`` / ``min_score`` / ``agree_px`` → +``{persisted, n_hits, jitter}``)。兩者以 MCP 工具 ``ac_region_stability`` / +``ac_match_persistence``(唯讀)及 Script Builder 指令 **Region Stability (frames)** / +**Match Persistence (frames)**(位於 **Image** 分類下)形式提供。 diff --git a/docs/source/Zh/zh_index.rst b/docs/source/Zh/zh_index.rst index ac3b1012..261eedf0 100644 --- a/docs/source/Zh/zh_index.rst +++ b/docs/source/Zh/zh_index.rst @@ -202,6 +202,7 @@ AutoControl 所有功能的完整使用指南。 doc/new_features/v177_features_doc doc/new_features/v178_features_doc doc/new_features/v179_features_doc + doc/new_features/v180_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 cd7097fb..5a741775 100644 --- a/je_auto_control/__init__.py +++ b/je_auto_control/__init__.py @@ -299,6 +299,10 @@ from je_auto_control.utils.color_match import ( match_color, match_color_all, ) +# Pre-match settle gating + match persistence over a frame sequence +from je_auto_control.utils.match_stability import ( + match_persistence, region_stability, +) # Otsu auto-thresholding for template matching (no hand-tuned min_score) from je_auto_control.utils.match_autothresh import ( auto_threshold, match_auto, @@ -1286,6 +1290,8 @@ def start_autocontrol_gui(*args, **kwargs): "vote_centers", "match_color", "match_color_all", + "region_stability", + "match_persistence", "SubPixelMatch", "match_subpixel", "refine_peak", diff --git a/je_auto_control/gui/script_builder/command_schema.py b/je_auto_control/gui/script_builder/command_schema.py index d7039b96..c46132e9 100644 --- a/je_auto_control/gui/script_builder/command_schema.py +++ b/je_auto_control/gui/script_builder/command_schema.py @@ -458,6 +458,28 @@ def _add_image_specs(specs: List[CommandSpec]) -> None: ), description="Find every colour (HSV) match of a template (NMS-deduped).", )) + specs.append(CommandSpec( + "AC_region_stability", "Image", "Region Stability (frames)", + fields=( + FieldSpec("frames", FieldType.STRING, + placeholder='["f1.png", "f2.png", "f3.png"]'), + FieldSpec("settle_threshold", FieldType.FLOAT, optional=True, + default=0.99, min_value=0.0, max_value=1.0), + ), + description="Is a frame sequence settled? (consecutive-frame SSIM).", + )) + specs.append(CommandSpec( + "AC_match_persistence", "Image", "Match Persistence (frames)", + fields=( + FieldSpec("template", FieldType.FILE_PATH), + FieldSpec("frames", FieldType.STRING, + placeholder='["f1.png", "f2.png", "f3.png"]'), + FieldSpec("min_score", FieldType.FLOAT, optional=True, default=0.8, + min_value=0.0, max_value=1.0), + FieldSpec("agree_px", FieldType.INT, optional=True, default=8), + ), + description="Does a template match hold steady across frames? (jitter).", + )) specs.append(CommandSpec( "AC_grid_cells", "Image", "Grid Cells (coarse grounding)", fields=( diff --git a/je_auto_control/utils/executor/action_executor.py b/je_auto_control/utils/executor/action_executor.py index df506c60..3534e300 100644 --- a/je_auto_control/utils/executor/action_executor.py +++ b/je_auto_control/utils/executor/action_executor.py @@ -3459,6 +3459,26 @@ def _match_color_all(template: str, channels: Any = None, min_score: Any = 0.7, return {"count": len(matches), "matches": [m.to_dict() for m in matches]} +def _region_stability(frames: Any, settle_threshold: Any = 0.99) -> Dict[str, Any]: + """Adapter: how settled an injected frame sequence is (consecutive SSIM).""" + import json + from je_auto_control.utils.match_stability import region_stability + if isinstance(frames, str): + frames = json.loads(frames) + return region_stability(frames, settle_threshold=float(settle_threshold)) + + +def _match_persistence(template: str, frames: Any, min_score: Any = 0.8, + agree_px: Any = 8) -> Dict[str, Any]: + """Adapter: whether a template match holds steady across frames.""" + import json + from je_auto_control.utils.match_stability import match_persistence + if isinstance(frames, str): + frames = json.loads(frames) + return match_persistence(template, frames, min_score=float(min_score), + agree_px=float(agree_px)) + + def _region_arg(value: Any) -> Optional[List[int]]: """Coerce a JSON-string / list region arg into a list of ints, or None.""" import json @@ -6186,6 +6206,8 @@ def __init__(self): "AC_vote_centers": _vote_centers, "AC_match_color": _match_color, "AC_match_color_all": _match_color_all, + "AC_region_stability": _region_stability, + "AC_match_persistence": _match_persistence, "AC_grid_cells": _grid_cells, "AC_cell_for_point": _cell_for_point, "AC_point_for_cell": _point_for_cell, diff --git a/je_auto_control/utils/match_stability/__init__.py b/je_auto_control/utils/match_stability/__init__.py new file mode 100644 index 00000000..1d0bcc84 --- /dev/null +++ b/je_auto_control/utils/match_stability/__init__.py @@ -0,0 +1,6 @@ +"""Pre-match settle gating and match persistence over a sequence of frames.""" +from je_auto_control.utils.match_stability.match_stability import ( + match_persistence, region_stability, +) + +__all__ = ["region_stability", "match_persistence"] diff --git a/je_auto_control/utils/match_stability/match_stability.py b/je_auto_control/utils/match_stability/match_stability.py new file mode 100644 index 00000000..c7efe05d --- /dev/null +++ b/je_auto_control/utils/match_stability/match_stability.py @@ -0,0 +1,62 @@ +"""Pre-match settle gating and match persistence over a sequence of frames. + +Matching mid-animation is a top flakiness source: the spinner is still spinning, a transition +is half-done, and the matcher fires on a transient. ``smart_waits.wait_until_screen_stable`` +gates a *live polling loop* and returns a boolean — it cannot score stability on an *injectable* +sequence of frames, nor tell you whether a *match* held steady across them. ``match_stability`` +adds both, on injected frames so it is unit-testable: ``region_stability`` scores how settled a +frame sequence is (consecutive-frame SSIM), and ``match_persistence`` checks that a template's +hit holds at the same place across the frames (low jitter), not just in one lucky frame. + +Reuses ``ssim.ssim_compare``, ``visual_match.match_template`` and +``grounding_consensus.consensus_point``; no new matching code. The frames are injectable +(ndarray / path / PIL); OpenCV + NumPy arrive lazily via those modules. Imports no ``PySide6``. +""" +from typing import Any, Dict, List, Optional, Sequence + +ImageSource = Any + + +def region_stability(frames: Sequence[ImageSource], *, + settle_threshold: float = 0.99) -> Dict[str, Any]: + """Score how settled a frame sequence is via consecutive-frame SSIM. + + Returns ``{stable, mean_ssim, min_ssim}`` — ``stable`` is true when the lowest + consecutive-frame SSIM is at least ``settle_threshold`` (nothing moved between frames). + A sequence shorter than two frames is trivially stable. + """ + from je_auto_control.utils.ssim import ssim_compare + frame_list = list(frames) + if len(frame_list) < 2: + return {"stable": True, "mean_ssim": 1.0, "min_ssim": 1.0} + scores = [ssim_compare(frame_list[i], frame_list[i + 1]) + for i in range(len(frame_list) - 1)] + lowest = min(scores) + return {"stable": lowest >= float(settle_threshold), + "mean_ssim": round(sum(scores) / len(scores), 4), + "min_ssim": round(lowest, 4)} + + +def match_persistence(template: ImageSource, frames: Sequence[ImageSource], *, + min_score: float = 0.8, agree_px: float = 8) -> Dict[str, Any]: + """Check that ``template`` matches the same place across ``frames``. + + Returns ``{persisted, n_hits, jitter}`` — ``persisted`` is true when the template is + found in *every* frame and the hit centres agree (cluster within ``agree_px``); + ``jitter`` is the spread of the agreeing centres (0 = rock steady). + """ + from je_auto_control.utils.grounding_consensus import consensus_point + from je_auto_control.utils.visual_match import match_template + frame_list = list(frames) + centers: List[List[int]] = [] + for frame in frame_list: + match = match_template(template, haystack=frame, min_score=float(min_score)) + if match is not None: + centers.append(match.center) + if not centers: + return {"persisted": False, "n_hits": 0, "jitter": None} + result = consensus_point(centers, cluster_radius=float(agree_px)) + jitter: Optional[float] = result.spread if result else None + persisted = (len(centers) == len(frame_list) and result is not None + and result.agreement >= 0.9) + return {"persisted": persisted, "n_hits": len(centers), "jitter": jitter} diff --git a/je_auto_control/utils/mcp_server/tools/_factories.py b/je_auto_control/utils/mcp_server/tools/_factories.py index dcce606a..0db7d0a3 100644 --- a/je_auto_control/utils/mcp_server/tools/_factories.py +++ b/je_auto_control/utils/mcp_server/tools/_factories.py @@ -3913,6 +3913,36 @@ def rotated_match_tools() -> List[MCPTool]: handler=h.match_color_all, annotations=READ_ONLY, ), + MCPTool( + name="ac_region_stability", + description=("Score how settled a sequence of 'frames' (image paths) is by " + "consecutive-frame SSIM: {stable, mean_ssim, min_ssim}. " + "stable=true when nothing moved between frames " + "(min_ssim >= 'settle_threshold') - match only when stable to " + "avoid mid-animation hits."), + input_schema=schema({ + "frames": {"type": "array", "items": {"type": "string"}}, + "settle_threshold": {"type": "number"}}, + required=["frames"]), + handler=h.region_stability, + annotations=READ_ONLY, + ), + MCPTool( + name="ac_match_persistence", + description=("Check a 'template' matches the same place across 'frames' " + "(image paths): {persisted, n_hits, jitter}. persisted=true " + "when found in every frame and the centres agree within " + "'agree_px' - a steady match, not one lucky frame. " + "'min_score'."), + input_schema=schema({ + "template": {"type": "string"}, + "frames": {"type": "array", "items": {"type": "string"}}, + "min_score": {"type": "number"}, + "agree_px": {"type": "number"}}, + required=["template", "frames"]), + handler=h.match_persistence, + annotations=READ_ONLY, + ), ] diff --git a/je_auto_control/utils/mcp_server/tools/_handlers.py b/je_auto_control/utils/mcp_server/tools/_handlers.py index 7e382e88..5d7a8051 100644 --- a/je_auto_control/utils/mcp_server/tools/_handlers.py +++ b/je_auto_control/utils/mcp_server/tools/_handlers.py @@ -2164,6 +2164,16 @@ def match_color_all(template, channels=None, min_score=0.7, max_results=20, region) +def region_stability(frames, settle_threshold=0.99): + from je_auto_control.utils.executor.action_executor import _region_stability + return _region_stability(frames, settle_threshold) + + +def match_persistence(template, frames, min_score=0.8, agree_px=8): + from je_auto_control.utils.executor.action_executor import _match_persistence + return _match_persistence(template, frames, min_score, agree_px) + + def grid_cells(rows, cols, region=None): from je_auto_control.utils.executor.action_executor import _grid_cells return _grid_cells(rows, cols, region) diff --git a/test/unit_test/headless/test_match_stability_batch.py b/test/unit_test/headless/test_match_stability_batch.py new file mode 100644 index 00000000..92fe9f0b --- /dev/null +++ b/test/unit_test/headless/test_match_stability_batch.py @@ -0,0 +1,81 @@ +"""Headless tests for pre-match settle gating + match persistence.""" +import pytest + +import je_auto_control as ac + +np = pytest.importorskip("numpy") +cv2 = pytest.importorskip("cv2") + +from je_auto_control.utils.match_stability import ( # noqa: E402 + match_persistence, region_stability, +) + + +def _frame(extra=None): + img = np.zeros((120, 160), dtype=np.uint8) + cv2.rectangle(img, (30, 30), (90, 80), 255, 2) # stable content + if extra is not None: + cv2.circle(img, extra, 8, 200, -1) # moving element + return img + + +def _template(): + tmpl = np.zeros((24, 24), dtype=np.uint8) + tmpl[:, :12] = 200 + return tmpl + + +def _haystack(left): + hay = np.zeros((120, 160), dtype=np.uint8) + hay[40:64, left:left + 24] = _template() + return hay + + +def test_identical_frames_are_stable(): + frames = [_frame(), _frame(), _frame()] + result = region_stability(frames, settle_threshold=0.99) + assert result["stable"] is True + assert result["min_ssim"] >= 0.99 + + +def test_moving_element_is_unstable(): + frames = [_frame((10, 10)), _frame((60, 60)), _frame((110, 100))] + result = region_stability(frames, settle_threshold=0.99) + assert result["stable"] is False + assert result["min_ssim"] < 0.99 + + +def test_single_frame_is_trivially_stable(): + assert region_stability([_frame()])["stable"] is True + + +def test_match_persists_across_frames(): + frames = [_haystack(50), _haystack(51), _haystack(50)] # ~steady + result = match_persistence(_template(), frames, min_score=0.8, agree_px=8) + assert result["persisted"] is True + assert result["n_hits"] == 3 + + +def test_match_not_persistent_when_missing(): + frames = [_haystack(50), np.zeros((120, 160), np.uint8), _haystack(50)] + result = match_persistence(_template(), frames, min_score=0.8) + assert result["persisted"] is False + assert result["n_hits"] == 2 + + +# --- wiring --------------------------------------------------------------- + +def test_wiring(): + known = set(ac.executor.known_commands()) + assert {"AC_region_stability", "AC_match_persistence"} <= known + 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_region_stability", "ac_match_persistence"} <= names + from je_auto_control.gui.script_builder.command_schema import _build_specs + specs = {s.command for s in _build_specs()} + assert {"AC_region_stability", "AC_match_persistence"} <= specs + + +def test_facade_exports(): + for name in ("region_stability", "match_persistence"): + assert hasattr(ac, name) and name in ac.__all__