diff --git a/README/WHATS_NEW_zh-CN.md b/README/WHATS_NEW_zh-CN.md index 21761eac..b05cd341 100644 --- a/README/WHATS_NEW_zh-CN.md +++ b/README/WHATS_NEW_zh-CN.md @@ -1,5 +1,11 @@ # 本次更新 — AutoControl +## 本次更新 (2026-06-23) — 重试式数值断言(expect.poll) + +重试*任意*值直到符合,不只限内建检查。完整参考:[`docs/source/Zh/doc/new_features/v142_features_doc.rst`](../docs/source/Zh/doc/new_features/v142_features_doc.rst)。 + +- **`expect_poll` / `assert_poll` + matchers**(`AC_expect_poll`):`assert_eventually` 只能轮询固定字典规格检查(文字/图像/像素/…)。本功能对任意零参数 `getter` 以任意 `matcher`(`to_equal` / `to_contain` / `to_be_greater_than` / `to_match_regex` / `to_be_truthy` / `to_be_stable`)轮询直到通过或超时——OCR 出的总额、行数稳定、自定义判断式皆可。可注入 `clock`/`sleep` → 具确定性,对应 Playwright 的 `expect.poll`。执行器命令会重复执行嵌套动作直到其结果某键符合。 + ## 本次更新 (2026-06-23) — 线条 / 网格 / 分隔线检测(Hough) 从原始像素找出表格网格线与 UI 分隔线。完整参考:[`docs/source/Zh/doc/new_features/v141_features_doc.rst`](../docs/source/Zh/doc/new_features/v141_features_doc.rst)。 diff --git a/README/WHATS_NEW_zh-TW.md b/README/WHATS_NEW_zh-TW.md index 8a3d40ea..01262a2a 100644 --- a/README/WHATS_NEW_zh-TW.md +++ b/README/WHATS_NEW_zh-TW.md @@ -1,5 +1,11 @@ # 本次更新 — AutoControl +## 本次更新 (2026-06-23) — 重試式數值斷言(expect.poll) + +重試*任意*值直到符合,不只限內建檢查。完整參考:[`docs/source/Zh/doc/new_features/v142_features_doc.rst`](../docs/source/Zh/doc/new_features/v142_features_doc.rst)。 + +- **`expect_poll` / `assert_poll` + matchers**(`AC_expect_poll`):`assert_eventually` 只能輪詢固定字典規格檢查(文字/影像/像素/…)。本功能對任意零參數 `getter` 以任意 `matcher`(`to_equal` / `to_contain` / `to_be_greater_than` / `to_match_regex` / `to_be_truthy` / `to_be_stable`)輪詢直到通過或逾時——OCR 出的總額、列數穩定、自訂判斷式皆可。可注入 `clock`/`sleep` → 具決定性,對應 Playwright 的 `expect.poll`。執行器命令會重複執行巢狀動作直到其結果某鍵符合。 + ## 本次更新 (2026-06-23) — 線條 / 網格 / 分隔線偵測(Hough) 從原始像素找出表格格線與 UI 分隔線。完整參考:[`docs/source/Zh/doc/new_features/v141_features_doc.rst`](../docs/source/Zh/doc/new_features/v141_features_doc.rst)。 diff --git a/WHATS_NEW.md b/WHATS_NEW.md index a3324391..89ebb91d 100644 --- a/WHATS_NEW.md +++ b/WHATS_NEW.md @@ -1,5 +1,11 @@ # What's New — AutoControl +## What's new (2026-06-23) — Retrying Value Assertions (expect.poll) + +Retry *any* value until it matches, not just the built-in checks. Full reference: [`docs/source/Eng/doc/new_features/v142_features_doc.rst`](docs/source/Eng/doc/new_features/v142_features_doc.rst). + +- **`expect_poll` / `assert_poll` + matchers** (`AC_expect_poll`): `assert_eventually` only polls the fixed dict-spec checks (text/image/pixel/…). This polls any zero-arg `getter` against any `matcher` (`to_equal` / `to_contain` / `to_be_greater_than` / `to_match_regex` / `to_be_truthy` / `to_be_stable`) until it passes or times out — an OCR'd total, a row count stabilising, a custom predicate. Injectable `clock`/`sleep` → deterministic, mirrors Playwright's `expect.poll`. The executor command re-runs a nested action until a key of its result matches. + ## What's new (2026-06-23) — Line / Grid / Separator Detection (Hough) Find table grid lines and UI dividers from raw pixels. Full reference: [`docs/source/Eng/doc/new_features/v141_features_doc.rst`](docs/source/Eng/doc/new_features/v141_features_doc.rst). diff --git a/docs/source/Eng/doc/new_features/v142_features_doc.rst b/docs/source/Eng/doc/new_features/v142_features_doc.rst new file mode 100644 index 00000000..3c7803c1 --- /dev/null +++ b/docs/source/Eng/doc/new_features/v142_features_doc.rst @@ -0,0 +1,47 @@ +Retrying Value Assertions (expect.poll) +======================================= + +``assert_eventually`` can only poll the framework's fixed dict-spec dispatch table +(text / image / pixel / window / clipboard / process / file / http). It cannot retry an +*arbitrary* value — an OCR'd total equalling ``"$42.00"``, a row count stabilising, a +custom predicate. ``expect_poll`` takes any zero-argument ``getter`` and any +``matcher`` predicate and polls until it passes or the timeout elapses, with injectable +``clock`` / ``sleep`` so it is deterministic in tests (the existing helper calls real +``time.sleep``). It mirrors Playwright's ``expect.poll`` / web-first retrying assertions. + +Pure-stdlib, imports no ``PySide6``. + +Headless API +------------ + +.. code-block:: python + + from je_auto_control import (expect_poll, assert_poll, to_equal, to_contain, + to_be_greater_than, to_match_regex, to_be_stable) + + # Poll an arbitrary getter until it matches. + result = expect_poll(lambda: read_cart_total(), to_equal("$42.00"), + timeout_s=8.0, interval_s=0.5) + if result.ok: + print("settled after", result.attempts, "tries") + + # Raise on failure (assertion style). + assert_poll(lambda: row_count(), to_be_greater_than(0)) + + # Wait for a value to stop changing. + expect_poll(lambda: ocr_value(), to_be_stable(3)) + +``expect_poll`` returns a ``PollResult`` (``ok``, ``value``, ``attempts``, +``waited_s``, ``description``); ``assert_poll`` raises ``AutoControlActionException`` +when it never matches. The matcher factories are ``to_equal``, ``to_contain``, +``to_be_greater_than``, ``to_match_regex``, ``to_be_truthy`` and ``to_be_stable(n)`` +(matches once the value repeats ``n`` times). + +Executor command +---------------- + +``AC_expect_poll`` re-runs a nested ``action`` (e.g. ``["AC_get_clipboard"]``) until a +``key`` of its result matches ``op`` (``truthy`` / ``equals`` / ``contains`` / ``gt`` +/ ``regex``) versus ``expected``, or ``timeout_s`` elapses — returning +``{ok, value, attempts, waited_s}``. It is exposed as the MCP tool ``ac_expect_poll`` +and as a Script Builder command under **Flow**. diff --git a/docs/source/Eng/eng_index.rst b/docs/source/Eng/eng_index.rst index 4885c344..eddbc9f7 100644 --- a/docs/source/Eng/eng_index.rst +++ b/docs/source/Eng/eng_index.rst @@ -164,6 +164,7 @@ Comprehensive guides for all AutoControl features. doc/new_features/v139_features_doc doc/new_features/v140_features_doc doc/new_features/v141_features_doc + doc/new_features/v142_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/v142_features_doc.rst b/docs/source/Zh/doc/new_features/v142_features_doc.rst new file mode 100644 index 00000000..211814b5 --- /dev/null +++ b/docs/source/Zh/doc/new_features/v142_features_doc.rst @@ -0,0 +1,41 @@ +重試式數值斷言(expect.poll) +============================== + +``assert_eventually`` 只能輪詢框架固定的字典規格分派表(文字 / 影像 / 像素 / 視窗 / 剪貼簿 / 行程 / 檔案 / http), +無法重試*任意*值——OCR 出的總額等於 ``"$42.00"``、列數穩定下來、或自訂判斷式。``expect_poll`` 接受任何零參數 +``getter`` 與任何 ``matcher`` 判斷式,輪詢直到通過或逾時,並可注入 ``clock`` / ``sleep`` 讓測試具決定性(既有 +輔助函式呼叫真實 ``time.sleep``)。它對應 Playwright 的 ``expect.poll`` / web-first 重試式斷言。 + +純標準函式庫,不匯入 ``PySide6``。 + +無頭 API +-------- + +.. code-block:: python + + from je_auto_control import (expect_poll, assert_poll, to_equal, to_contain, + to_be_greater_than, to_match_regex, to_be_stable) + + # 輪詢任意 getter 直到符合。 + result = expect_poll(lambda: read_cart_total(), to_equal("$42.00"), + timeout_s=8.0, interval_s=0.5) + if result.ok: + print("settled after", result.attempts, "tries") + + # 失敗時拋例外(斷言風格)。 + assert_poll(lambda: row_count(), to_be_greater_than(0)) + + # 等待某值不再變動。 + expect_poll(lambda: ocr_value(), to_be_stable(3)) + +``expect_poll`` 回傳 ``PollResult``(``ok``、``value``、``attempts``、``waited_s``、``description``); +``assert_poll`` 在始終不符時丟出 ``AutoControlActionException``。matcher 工廠有 ``to_equal``、``to_contain``、 +``to_be_greater_than``、``to_match_regex``、``to_be_truthy`` 與 ``to_be_stable(n)``(值重複 ``n`` 次後符合)。 + +執行器命令 +---------- + +``AC_expect_poll`` 重複執行巢狀 ``action``(例如 ``["AC_get_clipboard"]``),直到其結果的 ``key`` 以 ``op`` +(``truthy`` / ``equals`` / ``contains`` / ``gt`` / ``regex``)對 ``expected`` 符合,或 ``timeout_s`` 逾時—— +回傳 ``{ok, value, attempts, waited_s}``。它以 MCP 工具 ``ac_expect_poll`` 以及 Script Builder 中 **Flow** +分類下的命令提供。 diff --git a/docs/source/Zh/zh_index.rst b/docs/source/Zh/zh_index.rst index 5ef6cc1c..59788ffa 100644 --- a/docs/source/Zh/zh_index.rst +++ b/docs/source/Zh/zh_index.rst @@ -164,6 +164,7 @@ AutoControl 所有功能的完整使用指南。 doc/new_features/v139_features_doc doc/new_features/v140_features_doc doc/new_features/v141_features_doc + doc/new_features/v142_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 3db5d0e1..664437e4 100644 --- a/je_auto_control/__init__.py +++ b/je_auto_control/__init__.py @@ -328,6 +328,11 @@ from je_auto_control.utils.edge_lines import ( find_grid, find_lines, find_separators, ) +# Retry an arbitrary value until it matches (Playwright-style expect.poll) +from je_auto_control.utils.expect_poll import ( + PollResult, assert_poll, expect_poll, to_be_greater_than, to_be_stable, + to_be_truthy, to_contain, to_equal, to_match_regex, +) # CI workflow annotations (GitHub Actions) from je_auto_control.utils.ci_annotations import ( emit_annotations, format_annotation, @@ -1169,6 +1174,15 @@ def start_autocontrol_gui(*args, **kwargs): "find_lines", "find_grid", "find_separators", + "expect_poll", + "assert_poll", + "PollResult", + "to_equal", + "to_contain", + "to_be_greater_than", + "to_match_regex", + "to_be_truthy", + "to_be_stable", "emit_annotations", "format_annotation", "ClipboardHistory", "default_clipboard_history", "analyze_heal_log", "heal_stats", "scan_secrets", diff --git a/je_auto_control/gui/script_builder/command_schema.py b/je_auto_control/gui/script_builder/command_schema.py index d5bcdbd1..05cb35c9 100644 --- a/je_auto_control/gui/script_builder/command_schema.py +++ b/je_auto_control/gui/script_builder/command_schema.py @@ -768,6 +768,21 @@ def _add_flow_specs(specs: List[CommandSpec]) -> None: ), description="Wait until a target is visible + stable before acting.", )) + specs.append(CommandSpec( + "AC_expect_poll", "Flow", "Expect (poll until match)", + fields=( + FieldSpec("action", FieldType.STRING, + placeholder='["AC_get_clipboard"]'), + FieldSpec("key", FieldType.STRING, optional=True, + placeholder="result dict key, e.g. text"), + FieldSpec("op", FieldType.ENUM, optional=True, default="truthy", + choices=("truthy", "equals", "contains", "gt", "regex")), + FieldSpec("expected", FieldType.STRING, optional=True), + FieldSpec("timeout_s", FieldType.FLOAT, optional=True, default=5.0), + FieldSpec("interval_s", FieldType.FLOAT, optional=True, default=0.25), + ), + description="Re-run an action until a key of its result matches.", + )) specs.append(CommandSpec( "AC_wait_pixel", "Flow", "Wait for Pixel", fields=( diff --git a/je_auto_control/utils/executor/action_executor.py b/je_auto_control/utils/executor/action_executor.py index ad5a5402..68aebe1d 100644 --- a/je_auto_control/utils/executor/action_executor.py +++ b/je_auto_control/utils/executor/action_executor.py @@ -3595,6 +3595,36 @@ def _find_separators(axis: str = "horizontal", min_length: Any = 120, tol: Any = return {"count": len(coords), "axis": str(axis), "coordinates": coords} +def _expect_poll(action: Any, key: Any = None, op: str = "truthy", + expected: Any = None, timeout_s: Any = 5.0, + interval_s: Any = 0.25) -> Dict[str, Any]: + """Adapter: re-run a nested action until a key of its result matches.""" + import json + from je_auto_control.utils.expect_poll import ( + expect_poll, to_be_greater_than, to_be_truthy, to_contain, to_equal, + to_match_regex) + if isinstance(action, str): + action = json.loads(action) + builders = {"equals": lambda: to_equal(expected), + "contains": lambda: to_contain(expected), + "gt": lambda: to_be_greater_than(expected), + "regex": lambda: to_match_regex(str(expected)), + "truthy": to_be_truthy} + matcher = builders.get(str(op), to_be_truthy)() + + def getter(): + record = executor.execute_action([list(action)]) + value = next(iter(record.values()), None) + if key is not None and isinstance(value, dict): + return value.get(key) + return value + + result = expect_poll(getter, matcher, timeout_s=float(timeout_s), + interval_s=float(interval_s)) + return {"ok": result.ok, "value": result.value, "attempts": result.attempts, + "waited_s": result.waited_s} + + def _with_modifiers(modifiers: Any, actions: Any) -> Dict[str, Any]: """Adapter: run nested actions while modifier keys are held down.""" import json @@ -5334,6 +5364,7 @@ def __init__(self): "AC_find_lines": _find_lines, "AC_find_grid": _find_grid, "AC_find_separators": _find_separators, + "AC_expect_poll": _expect_poll, "AC_tile_rect": _tile_rect, "AC_grid_rects": _grid_rects, "AC_cascade_rects": _cascade_rects, diff --git a/je_auto_control/utils/expect_poll/__init__.py b/je_auto_control/utils/expect_poll/__init__.py new file mode 100644 index 00000000..b72e6ac7 --- /dev/null +++ b/je_auto_control/utils/expect_poll/__init__.py @@ -0,0 +1,9 @@ +"""Retry an arbitrary value until it matches (Playwright-style expect.poll).""" +from je_auto_control.utils.expect_poll.expect_poll import ( + PollResult, assert_poll, expect_poll, to_be_greater_than, to_be_stable, + to_be_truthy, to_contain, to_equal, to_match_regex, +) + +__all__ = ["PollResult", "assert_poll", "expect_poll", "to_be_greater_than", + "to_be_stable", "to_be_truthy", "to_contain", "to_equal", + "to_match_regex"] diff --git a/je_auto_control/utils/expect_poll/expect_poll.py b/je_auto_control/utils/expect_poll/expect_poll.py new file mode 100644 index 00000000..d442b2a0 --- /dev/null +++ b/je_auto_control/utils/expect_poll/expect_poll.py @@ -0,0 +1,110 @@ +"""Retry an arbitrary value until it matches — Playwright-style ``expect.poll``. + +``assert_eventually`` can only poll the framework's fixed dict-spec dispatch table +(text / image / pixel / window / clipboard / process / file / http). It cannot retry an +*arbitrary* value: an OCR'd total equalling ``"$42.00"``, a row count stabilising, a +custom predicate. ``expect_poll`` takes any zero-arg ``getter`` and any ``matcher`` +predicate and polls until it passes or the timeout elapses, with injectable +``clock`` / ``sleep`` so it is deterministic in tests (the existing helper calls real +``time.sleep``). Ships a small library of matcher factories. + +Pure-stdlib, imports no ``PySide6``. +""" +import time +from dataclasses import dataclass +from typing import Any, Callable + +from je_auto_control.utils.exception.exceptions import AutoControlActionException + +Matcher = Callable[[Any], bool] + + +@dataclass(frozen=True) +class PollResult: + """The outcome of a poll: whether it matched, the last value, and timing.""" + + ok: bool + value: Any + attempts: int + waited_s: float + description: str + + +def to_equal(expected: Any) -> Matcher: + """Match when the value equals ``expected``.""" + return lambda value: value == expected + + +def to_contain(item: Any) -> Matcher: + """Match when ``item`` is contained in the value.""" + return lambda value: item in value + + +def to_be_greater_than(threshold: Any) -> Matcher: + """Match when the value is greater than ``threshold``.""" + return lambda value: value > threshold + + +def to_match_regex(pattern: str) -> Matcher: + """Match when ``str(value)`` contains a match for ``pattern``.""" + import re + regex = re.compile(pattern) + return lambda value: bool(regex.search(str(value))) + + +def to_be_truthy() -> Matcher: + """Match when the value is truthy.""" + return bool + + +def to_be_stable(times: int = 3) -> Matcher: + """Match once the value has been equal across ``times`` consecutive polls.""" + state = {"last": object(), "count": 0} + + def matcher(value: Any) -> bool: + if value == state["last"]: + state["count"] += 1 + else: + state["last"], state["count"] = value, 1 + return state["count"] >= int(times) + + return matcher + + +def expect_poll(getter: Callable[[], Any], matcher: Matcher, *, + timeout_s: float = 5.0, interval_s: float = 0.25, + describe: Callable[[Any], str] = repr, + clock: Callable[[], float] = time.monotonic, + sleep: Callable[[float], None] = time.sleep) -> PollResult: + """Poll ``getter`` until ``matcher`` passes or ``timeout_s`` elapses. + + Returns a :class:`PollResult` with the final value, attempt count and elapsed + time. ``clock`` / ``sleep`` are injectable for deterministic tests. + """ + start = clock() + deadline = start + float(timeout_s) + attempts = 0 + value: Any = None + while True: + value = getter() + attempts += 1 + if matcher(value): + return PollResult(True, value, attempts, round(clock() - start, 4), + describe(value)) + if clock() >= deadline: + return PollResult(False, value, attempts, round(clock() - start, 4), + describe(value)) + sleep(float(interval_s)) + + +def assert_poll(getter: Callable[[], Any], matcher: Matcher, *, + timeout_s: float = 5.0, interval_s: float = 0.25, + describe: Callable[[Any], str] = repr) -> PollResult: + """Like :func:`expect_poll` but raise ``AutoControlActionException`` on failure.""" + result = expect_poll(getter, matcher, timeout_s=timeout_s, + interval_s=interval_s, describe=describe) + if not result.ok: + raise AutoControlActionException( + f"expect_poll failed after {result.attempts} attempt(s): " + f"{result.description}") + return result diff --git a/je_auto_control/utils/mcp_server/tools/_factories.py b/je_auto_control/utils/mcp_server/tools/_factories.py index 0fba6982..fb8ea509 100644 --- a/je_auto_control/utils/mcp_server/tools/_factories.py +++ b/je_auto_control/utils/mcp_server/tools/_factories.py @@ -3017,6 +3017,30 @@ def edge_lines_tools() -> List[MCPTool]: ] +def expect_poll_tools() -> List[MCPTool]: + return [ + MCPTool( + name="ac_expect_poll", + description=("Re-run a nested 'action' (e.g. [\"AC_get_clipboard\"]) " + "until a 'key' of its result dict matches 'op' " + "(truthy/equals/contains/gt/regex) vs 'expected', or " + "'timeout_s' elapses. Retries an ARBITRARY value, unlike " + "assert_eventually's fixed checks. Returns {ok, value, " + "attempts, waited_s}."), + input_schema=schema({ + "action": {"type": "array"}, + "key": {"type": "string"}, + "op": {"type": "string"}, + "expected": {}, + "timeout_s": {"type": "number"}, + "interval_s": {"type": "number"}}, + required=["action"]), + handler=h.expect_poll, + annotations=NON_DESTRUCTIVE, + ), + ] + + def ssim_tools() -> List[MCPTool]: return [ MCPTool( @@ -6521,8 +6545,8 @@ def media_assert_tools() -> List[MCPTool]: color_region_tools, ssim_tools, feature_match_tools, shape_locator_tools, window_layout_tools, window_arrange_tools, preprocess_tools, monitor_layout_tools, actionability_tools, element_parse_tools, - hsv_segment_tools, text_regions_tools, edge_lines_tools, plugin_sdk_tools, - governance_tools, + hsv_segment_tools, text_regions_tools, edge_lines_tools, expect_poll_tools, + plugin_sdk_tools, governance_tools, credential_lease_tools, egress_tools, approval_testing_tools, trajectory_eval_tools, compliance_tools, agent_trace_tools, video_report_tools, fuzzy_tools, artifact_store_tools, image_dedup_tools, diff --git a/je_auto_control/utils/mcp_server/tools/_handlers.py b/je_auto_control/utils/mcp_server/tools/_handlers.py index 5a261b73..219856f1 100644 --- a/je_auto_control/utils/mcp_server/tools/_handlers.py +++ b/je_auto_control/utils/mcp_server/tools/_handlers.py @@ -2226,6 +2226,12 @@ def find_separators(axis="horizontal", min_length=120, tol=10, region=None): return _find_separators(axis, min_length, tol, region) +def expect_poll(action, key=None, op="truthy", expected=None, timeout_s=5.0, + interval_s=0.25): + from je_auto_control.utils.executor.action_executor import _expect_poll + return _expect_poll(action, key, op, expected, timeout_s, interval_s) + + def detect_drift(reference, current, threshold=0.25, bins=10): from je_auto_control.utils.executor.action_executor import _detect_drift return _detect_drift(reference, current, threshold, bins) diff --git a/test/unit_test/headless/test_expect_poll_batch.py b/test/unit_test/headless/test_expect_poll_batch.py new file mode 100644 index 00000000..bc4148ad --- /dev/null +++ b/test/unit_test/headless/test_expect_poll_batch.py @@ -0,0 +1,86 @@ +"""Headless tests for retrying value assertions. No Qt; clock is injected.""" +import pytest + +import je_auto_control as ac +from je_auto_control.utils.expect_poll import ( + assert_poll, expect_poll, to_be_greater_than, to_be_stable, to_contain, + to_equal, to_match_regex, +) + + +class _Clock: + def __init__(self): + self.t = 0.0 + + def now(self): + return self.t + + def sleep(self, seconds): + self.t += seconds + + +def _cfg(): + clock = _Clock() + return {"timeout_s": 5.0, "interval_s": 0.25, "clock": clock.now, + "sleep": clock.sleep} + + +def test_matches_after_a_few_polls(): + seq = iter([1, 3, 5, 5]) + result = expect_poll(lambda: next(seq), to_equal(5), **_cfg()) + assert result.ok and result.attempts == 3 and result.value == 5 + + +def test_times_out_when_never_matching(): + result = expect_poll(lambda: 0, to_equal(9), **_cfg()) + assert result.ok is False and result.attempts == 21 # 20 sleeps + first + + +def test_contains_gt_regex_matchers(): + assert expect_poll(lambda: "done ok", to_contain("ok"), **_cfg()).ok + assert expect_poll(lambda: 42, to_be_greater_than(10), **_cfg()).ok + assert expect_poll(lambda: "total=42", to_match_regex(r"=\d+"), **_cfg()).ok + + +def test_to_be_stable_requires_repeats(): + vals = iter([7, 8, 8, 8, 8]) + # not stable until three equal in a row + result = expect_poll(lambda: next(vals), to_be_stable(3), **_cfg()) + assert result.ok and result.value == 8 + + +def test_assert_poll_raises_on_timeout(): + from je_auto_control.utils.exception.exceptions import AutoControlActionException + with pytest.raises(AutoControlActionException): + assert_poll(lambda: 0, to_equal(1), timeout_s=0) + + +def test_assert_poll_returns_result_on_success(): + result = assert_poll(lambda: "ready", to_equal("ready"), timeout_s=0) + assert result.ok and result.value == "ready" + + +# --- wiring --------------------------------------------------------------- + +def test_wiring(): + assert "AC_expect_poll" in set(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_expect_poll" in names + from je_auto_control.gui.script_builder.command_schema import _build_specs + specs = {s.command for s in _build_specs()} + assert "AC_expect_poll" in specs + + +def test_executor_polls_nested_action(): + # AC_fuse_elements with no args returns {count: 0, ...} — device-free + deterministic. + from je_auto_control.utils.executor.action_executor import _expect_poll + result = _expect_poll(["AC_fuse_elements"], key="count", op="equals", + expected=0, timeout_s=0.0) + assert result["ok"] is True and result["value"] == 0 + + +def test_facade_exports(): + for attr in ("expect_poll", "assert_poll", "to_equal", "to_contain", + "to_be_stable"): + assert hasattr(ac, attr) and attr in ac.__all__