Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions README/WHATS_NEW_zh-CN.md
Original file line number Diff line number Diff line change
@@ -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)。
Expand Down
6 changes: 6 additions & 0 deletions README/WHATS_NEW_zh-TW.md
Original file line number Diff line number Diff line change
@@ -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)。
Expand Down
6 changes: 6 additions & 0 deletions WHATS_NEW.md
Original file line number Diff line number Diff line change
@@ -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).
Expand Down
47 changes: 47 additions & 0 deletions docs/source/Eng/doc/new_features/v142_features_doc.rst
Original file line number Diff line number Diff line change
@@ -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**.
1 change: 1 addition & 0 deletions docs/source/Eng/eng_index.rst
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
41 changes: 41 additions & 0 deletions docs/source/Zh/doc/new_features/v142_features_doc.rst
Original file line number Diff line number Diff line change
@@ -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**
分類下的命令提供。
1 change: 1 addition & 0 deletions docs/source/Zh/zh_index.rst
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
14 changes: 14 additions & 0 deletions je_auto_control/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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",
Expand Down
15 changes: 15 additions & 0 deletions je_auto_control/gui/script_builder/command_schema.py
Original file line number Diff line number Diff line change
Expand Up @@ -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=(
Expand Down
31 changes: 31 additions & 0 deletions je_auto_control/utils/executor/action_executor.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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,
Expand Down
9 changes: 9 additions & 0 deletions je_auto_control/utils/expect_poll/__init__.py
Original file line number Diff line number Diff line change
@@ -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"]
110 changes: 110 additions & 0 deletions je_auto_control/utils/expect_poll/expect_poll.py
Original file line number Diff line number Diff line change
@@ -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
28 changes: 26 additions & 2 deletions je_auto_control/utils/mcp_server/tools/_factories.py
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand Down Expand Up @@ -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,
Expand Down
Loading
Loading