diff --git a/WHATS_NEW.md b/WHATS_NEW.md index 13c1a794..2732b174 100644 --- a/WHATS_NEW.md +++ b/WHATS_NEW.md @@ -2,6 +2,36 @@ ## What's new (2026-06-26) +### Wait Until the App Is Idle + +Hold off the next click until the busy/wait cursor settles — don't act mid-churn. Full reference: [`docs/source/Eng/doc/new_features/v213_features_doc.rst`](docs/source/Eng/doc/new_features/v213_features_doc.rst). + +- **`wait_until_app_idle` / `idle_point`** (`AC_wait_until_app_idle`, `AC_idle_point`): a click fired while the app is still churning (busy cursor up, dialog mid-paint, long handler running) is dropped or mis-targeted. `smart_waits` watches *pixels* settle; this watches the app's *busy signal* settle, which is cheaper and survives animated-but-idle UI. It reuses `settle_detector.SettleTracker` — each poll feeds 1.0 when busy / 0.0 when idle, and returns once the app has read idle for `quiet_samples` polls in a row (a busy spike resets the run). `wait_until_app_idle` polls an injectable `busy_probe` (default = Windows busy/app-starting cursor) with injectable `clock`/`sleep`; `idle_point` is the pure analyser over a recorded busy/idle trace. Fully testable without an app. Fifth feature of the ROUND-15 input-fidelity lane. No `PySide6`. + +### Ensure a Control Is in the Desired State (Idempotent) + +Read-compare-act-verify instead of acting blind — don't double-toggle an already-checked box. Full reference: [`docs/source/Eng/doc/new_features/v212_features_doc.rst`](docs/source/Eng/doc/new_features/v212_features_doc.rst). + +- **`ensure_state` / `ensure_toggle`** (`AC_ensure_field_value`): automation that acts *unconditionally* double-toggles a box that was already checked or re-enters an already-correct field, and can't be safely re-run. The robust shape is read-compare-act-verify. `ensure_state` reads via an injectable `reader`, and only if it doesn't equal `desired` applies `setter` and re-reads (up to `attempts`); `ensure_toggle` is the boolean specialization that calls `toggle` only while the state differs. A control already in the desired state is left untouched (`changed=False`) — idempotent and safe to re-run, distinct from `idempotency` (a request-key replay cache) since this converges *device state*. The executor's `AC_ensure_field_value` idempotently sets a native control's value via the accessibility backend. Fourth feature of the ROUND-15 input-fidelity lane. No `PySide6`. + +### Adaptive Timeout from Observed Durations + +Stop guessing wait timeouts — learn them from how long the step actually takes. Full reference: [`docs/source/Eng/doc/new_features/v211_features_doc.rst`](docs/source/Eng/doc/new_features/v211_features_doc.rst). + +- **`recommend_timeout` / `timeout_stats`** (`AC_adaptive_timeout`, `AC_timeout_stats`): hard-coded waits are a perennial flakiness source — too short races a slow machine, too long makes every failure pay the full timeout. This learns the timeout from observed step durations: a high percentile (the slow-but-real case) scaled by a safety `factor`, clamped to a sane `[min_s, max_s]` band. `recommend_timeout` is the single number to feed a `wait_for_*` / actionability `GateConfig`; `timeout_stats` also exposes the percentiles and `floored`/`capped` flags for tuning. Both are pure and reuse `stats.percentile`; with no samples they fall back to `default_s`. Third feature of the ROUND-15 input-fidelity lane. No `PySide6`. + +### Verify a Field After Typing + +Read the field back and confirm the value actually landed — don't type and hope. Full reference: [`docs/source/Eng/doc/new_features/v210_features_doc.rst`](docs/source/Eng/doc/new_features/v210_features_doc.rst). + +- **`compare_field_value` / `verify_field_value` / `fill_and_verify`** (`AC_compare_field_value`, `AC_verify_field_value`): `field_entry` types into a control and *hopes* — a slow IME, focus steal, input mask or auto-format can silently mangle or drop characters, and nothing reads the field back. This is distinct from `action_effect` (did *anything* change near the target?) and `postcondition.text_present` (does the text appear *anywhere*?) — neither confirms *this* field equals *this* value. `compare_field_value` is the pure comparator (`exact`/`trim`/`ci`/`normalized` NFKC/`contains`); `verify_field_value` reads through an injectable `reader` (native accessibility value in the executor); `fill_and_verify` types via an injectable `filler`, reads back, and retries (optionally clearing first) until it matches or attempts run out. Every comparison and retry decision is pure and unit-tested without a real control. Second feature of the ROUND-15 input-fidelity lane. No `PySide6`. + +### Retry Budget — Deadline + Jitter + +Retry a flaky step bounded by a total time budget, with jittered backoff. Full reference: [`docs/source/Eng/doc/new_features/v209_features_doc.rst`](docs/source/Eng/doc/new_features/v209_features_doc.rst). + +- **`RetryBudget` / `run_with_budget` / `backoff_delay` / `jittered_delay`** (`AC_retry_delay`, `AC_plan_retry_delays`): `resilience.RetryPolicy` retries a fixed attempt count with plain exponential backoff — it can't express a *wall-clock deadline* ("give up after 30 s total, however many attempts that is") or *jitter* (randomized backoff so retrying workers don't resynchronize into a thundering herd). `RetryBudget` adds both: bounded by `max_attempts` *and/or* `deadline_s`, `run_with_budget` honours whichever is hit first and never sleeps past the deadline; delays use capped exponential backoff with a selectable `full`/`equal`/`none` jitter strategy. The randomness (`uniform`), clock and sleeper are all injectable, so every delay and giveup decision is deterministic in tests. First feature of the ROUND-15 input-fidelity lane. No `PySide6`. + ### Live IME State for Safe CJK Entry Wait for the input method to commit before reading a Japanese/Chinese/Korean field. Full reference: [`docs/source/Eng/doc/new_features/v208_features_doc.rst`](docs/source/Eng/doc/new_features/v208_features_doc.rst). diff --git a/docs/source/Eng/doc/new_features/v209_features_doc.rst b/docs/source/Eng/doc/new_features/v209_features_doc.rst new file mode 100644 index 00000000..94f2c369 --- /dev/null +++ b/docs/source/Eng/doc/new_features/v209_features_doc.rst @@ -0,0 +1,56 @@ +Retry Budget — Deadline + Jitter +================================ + +:class:`resilience.RetryPolicy` retries a fixed number of attempts with plain +exponential backoff. Two things it can't express are exactly what flaky, +contended UI automation needs: + +* a **wall-clock deadline** — "keep retrying, but give up after 30 s total", + independent of how many attempts that takes; and +* **jitter** — randomized backoff so many retrying workers don't resynchronize + into a thundering herd. + +``retry_budget`` adds both. :class:`RetryBudget` is bounded by ``max_attempts`` +*and / or* ``deadline_s``; :func:`run_with_budget` honours whichever is hit +first and never sleeps past the deadline. Delays use capped exponential backoff +with a selectable jitter strategy (``full`` / ``equal`` / ``none``). The +randomness source (``uniform``), the clock and the sleeper are all injectable, +so every delay and decision is deterministic in tests. Imports no ``PySide6``. + +Headless API +------------ + +.. code-block:: python + + from je_auto_control import RetryBudget, run_with_budget + + budget = RetryBudget(max_attempts=8, deadline_s=30.0, + base_delay_s=0.2, max_delay_s=5.0) + + # Retry the click until it lands, capped at 8 tries OR 30 seconds total + run_with_budget(lambda: click_and_verify("Save"), budget) + +``RetryBudget`` is bounded by attempts and / or a deadline — set either to +``None`` to bound only by the other. :func:`backoff_delay` (pure, no jitter) and +:meth:`RetryBudget.plan` give the delay schedule for inspection: + +.. code-block:: python + + RetryBudget(jitter="none").plan(4) # [0.1, 0.2, 0.4, 0.8] + +For deterministic tests inject ``uniform`` / ``clock`` / ``sleep``: + +.. code-block:: python + + run_with_budget(flaky, budget, clock=fake_clock, sleep=fake_sleep, + uniform=lambda lo, hi: lo) # always the low bound + +Executor commands +----------------- + +``AC_retry_delay`` (``attempt`` / ``base`` / ``max_delay`` / ``multiplier`` / +``jitter`` → ``{delay}``) and ``AC_plan_retry_delays`` (``attempts`` … → +``{delays}``) expose the pure backoff schedule (``jitter`` defaults to ``none`` +for a deterministic result). They are the matching read-only ``ac_*`` MCP tools +and Script Builder commands under **Flow**. :func:`run_with_budget` (which wraps +a callable) is the Python-API surface. diff --git a/docs/source/Eng/doc/new_features/v210_features_doc.rst b/docs/source/Eng/doc/new_features/v210_features_doc.rst new file mode 100644 index 00000000..0883308a --- /dev/null +++ b/docs/source/Eng/doc/new_features/v210_features_doc.rst @@ -0,0 +1,57 @@ +Verify a Field After Typing +=========================== + +``field_entry`` types into a control and *hopes* it landed. A slow IME, a focus +steal, an input mask or an auto-format can silently mangle or drop characters, +and nothing reads the field back to notice. This is distinct from +``action_effect`` (did *anything* change near the target?) and +``postcondition.text_present`` (does the text appear *anywhere* on screen?) — +neither confirms *this* field now equals *this* value. ``verify_field`` closes +the read-back gap. + +* :func:`compare_field_value` — pure: compare an expected and actual value under + a match ``mode`` — ``exact`` / ``trim`` / ``ci`` (case-insensitive) / + ``normalized`` (Unicode NFKC + case-fold + whitespace) / ``contains``. +* :func:`verify_field_value` — read the field through an injectable ``reader`` + and compare. +* :func:`fill_and_verify` — type through an injectable ``filler``, read back, and + retry (optionally clearing first) until it matches or attempts run out. + +In the executor the reader is the native accessibility value, but every +comparison and retry decision is pure and testable without a real control. +Imports no ``PySide6``. + +Headless API +------------ + +.. code-block:: python + + from je_auto_control import ( + compare_field_value, verify_field_value, fill_and_verify, + ) + + compare_field_value("café", "café", mode="normalized")["match"] # True + + # Read a control back and assert it took the value + ok = verify_field_value("invoice.pdf", + reader=lambda: read_control_value())["match"] + + # Type, read back, and retry up to 3 times (clearing before each retry) + fill_and_verify("2026-06-26", filler=type_into_field, + reader=read_control_value, attempts=3, clear=select_all_del) + +``fill_and_verify`` returns the final :func:`compare_field_value` result plus an +``attempts`` count, so a flow can branch on a persistent mismatch instead of +typing blind. ``filler`` / ``reader`` / ``clear`` are injectable, so the retry +logic is fully unit-tested without a real field. + +Executor commands +----------------- + +``AC_compare_field_value`` (``expected`` / ``actual`` / ``mode`` → ``{match, +mode, expected, actual}``, pure) and ``AC_verify_field_value`` (``expected`` + +``name`` / ``role`` / ``app_name`` / ``automation_id`` / ``mode`` → the match +result, reading the control's value through the accessibility backend). They are +the matching read-only ``ac_*`` MCP tools and Script Builder commands under +**Flow**. :func:`fill_and_verify` (which wraps a typing callable) is the +Python-API surface. diff --git a/docs/source/Eng/doc/new_features/v211_features_doc.rst b/docs/source/Eng/doc/new_features/v211_features_doc.rst new file mode 100644 index 00000000..4da02166 --- /dev/null +++ b/docs/source/Eng/doc/new_features/v211_features_doc.rst @@ -0,0 +1,46 @@ +Adaptive Timeout from Observed Durations +======================================== + +Hard-coded waits are a perennial source of flakiness: too short and a slow +machine races the UI; too long and every failure pays the full timeout. The +durable fix is to *learn* the timeout from how long a step has actually taken. +``adaptive_timeout`` turns a sample of observed durations into a robust timeout +— a high percentile (the slow-but-real case) scaled by a safety ``factor``, +then clamped to a sane ``[min_s, max_s]`` band. + +* :func:`recommend_timeout` — the single number to feed a wait or ``GateConfig``. +* :func:`timeout_stats` — the same with the percentiles and clamp flags exposed + for logging / tuning. + +Both are pure and reuse :func:`stats.percentile`; with no samples they fall back +to ``default_s`` (or ``min_s``). Imports no ``PySide6``. + +Headless API +------------ + +.. code-block:: python + + from je_auto_control import recommend_timeout, timeout_stats + + # The dialog has historically taken these seconds to appear: + seen = [0.8, 1.1, 0.9, 3.2, 1.0, 1.3] + + recommend_timeout(seen) # ~ p95 * 1.5, clamped to [1, 60] + recommend_timeout(seen, percentile_q=99.0, factor=2.0, max_s=30.0) + + timeout_stats(seen) + # {'n': 6, 'p50': 1.05, 'p_high': 2.7..., 'percentile_q': 95.0, + # 'recommended': 4.1..., 'floored': False, 'capped': False} + +Use the recommendation as the ``timeout_s`` for the next ``wait_for_*`` / +actionability gate, recomputing it as the duration sample grows. With no samples +yet, pass ``default_s`` for the cold-start value. + +Executor commands +----------------- + +``AC_adaptive_timeout`` (``durations`` + ``percentile_q`` / ``factor`` / +``min_s`` / ``max_s`` → ``{timeout_s}``) and ``AC_timeout_stats`` (same inputs → +``{n, p50, p_high, percentile_q, recommended, floored, capped}``). ``durations`` +accepts a JSON list. They are the matching read-only ``ac_*`` MCP tools and +Script Builder commands under **Flow**. diff --git a/docs/source/Eng/doc/new_features/v212_features_doc.rst b/docs/source/Eng/doc/new_features/v212_features_doc.rst new file mode 100644 index 00000000..e1833d8a --- /dev/null +++ b/docs/source/Eng/doc/new_features/v212_features_doc.rst @@ -0,0 +1,52 @@ +Ensure a Control Is in the Desired State (Idempotent) +===================================================== + +Automation that *acts unconditionally* — "click the checkbox", "type the value" +— double-toggles a box that was already checked, or re-enters a field that was +already correct, and can't be safely re-run. The robust shape is +read-compare-act-verify: look at the current state, do nothing if it already +matches, otherwise apply the change and confirm it took. ``ensure_state`` is +that primitive. + +* :func:`ensure_state` — generic: read via ``reader``, and if it doesn't equal + ``desired`` apply ``setter`` and re-read, up to ``attempts`` times. +* :func:`ensure_toggle` — the boolean specialization for a stateless flip: read + ``is_on`` and call ``toggle`` only while it differs from ``desired``. + +A control already in the desired state is left untouched (``changed=False``), so +the call is idempotent and safe to re-run. This is distinct from +:mod:`idempotency` (a request-key replay cache) — ``ensure_state`` converges +*device state*, not call results. The reader / setter / toggle seams are +injectable, so the logic is fully testable without a real control. Imports no +``PySide6``. + +Headless API +------------ + +.. code-block:: python + + from je_auto_control import ensure_state, ensure_toggle + + # Idempotently make a setting "on" — no write if it already is + ensure_state("on", reader=read_combo, setter=write_combo) + # -> {'ok': True, 'changed': False, 'value': 'on', 'attempts': 0} + + # Flip a checkbox to checked only if it isn't already + ensure_toggle(True, is_on=is_checked, toggle=click_checkbox) + +Both return ``{ok, changed, value, attempts}``: ``changed`` tells you whether an +action was actually performed (useful for "did I have to fix this?" reporting), +and ``ok`` whether the desired state was reached within ``attempts``. Pass a +custom ``equals`` to :func:`ensure_state` for case-insensitive or normalized +comparisons. + +Executor commands +----------------- + +``AC_ensure_field_value`` (``desired`` + ``name`` / ``role`` / ``app_name`` / +``automation_id`` / ``attempts`` → ``{ok, changed, value, attempts}``) +idempotently sets a native control's value through the accessibility backend — +reading first and doing nothing if it already matches. It is the matching +``ac_ensure_field_value`` MCP tool and a Script Builder command under **Flow**. +:func:`ensure_state` / :func:`ensure_toggle` (which take arbitrary callables) are +the Python-API surface. diff --git a/docs/source/Eng/doc/new_features/v213_features_doc.rst b/docs/source/Eng/doc/new_features/v213_features_doc.rst new file mode 100644 index 00000000..b2f17e7e --- /dev/null +++ b/docs/source/Eng/doc/new_features/v213_features_doc.rst @@ -0,0 +1,48 @@ +Wait Until the App Is Idle +========================== + +A click fired while the app is still churning — the busy / wait cursor is up, a +dialog is mid-paint, a long handler is running — is dropped or mis-targeted. +``smart_waits`` watches *pixels* settling; ``app_idle`` watches the app's *busy +signal* settle instead, which is cheaper and survives animated-but-idle UI. It +reuses :class:`settle_detector.SettleTracker`: each poll feeds ``1.0`` when busy +and ``0.0`` when idle, and the wait returns once the app has read idle for +``quiet_samples`` polls in a row (a fresh busy spike resets the run). + +* :func:`wait_until_app_idle` — poll a ``busy_probe`` until the app settles idle + or a timeout, with injectable ``clock`` / ``sleep`` / ``busy_probe``. +* :func:`idle_point` — pure: the index in a recorded busy/idle sample series at + which it first becomes settled-idle. + +The default probe reports the Windows busy / app-starting cursor; every wait and +settle decision runs through the injectable seam, so it is fully testable +without an app. Imports no ``PySide6``. + +Headless API +------------ + +.. code-block:: python + + from je_auto_control import wait_until_app_idle, idle_point + + # Launch something, then wait for its busy cursor to settle before clicking + start_exe("setup.exe") + if wait_until_app_idle(quiet_samples=3, timeout_s=30)["idle"]: + click_next() + + # Pure: analyse a recorded busy/idle trace + idle_point([True, True, False, False, False], quiet_samples=3) # 4 + +``wait_until_app_idle`` returns ``{idle, polls, quiet_run, elapsed_s}``. Pass a +custom ``busy_probe`` (a ``() -> bool``) to gate on any busy signal — a spinner +image match, a process-CPU threshold, an accessibility "busy" flag — not just the +cursor. + +Executor commands +----------------- + +``AC_wait_until_app_idle`` (``quiet_samples`` / ``timeout`` / ``interval`` → +``{idle, polls, quiet_run, elapsed_s}``, using the Windows busy cursor) and +``AC_idle_point`` (``busy_samples`` JSON list + ``quiet_samples`` → ``{index}``, +pure). They are the matching read-only ``ac_*`` MCP tools and Script Builder +commands under **Flow**. diff --git a/docs/source/Zh/doc/new_features/v209_features_doc.rst b/docs/source/Zh/doc/new_features/v209_features_doc.rst new file mode 100644 index 00000000..9f462234 --- /dev/null +++ b/docs/source/Zh/doc/new_features/v209_features_doc.rst @@ -0,0 +1,49 @@ +重試預算——截止時間 + 抖動 +========================== + +:class:`resilience.RetryPolicy` 以固定次數搭配單純指數退避重試。有兩件它無法表達的事,正是不穩定、 +高競爭的 UI 自動化所需: + +* **掛鐘截止時間**——「持續重試,但總共超過 30 秒就放棄」,與嘗試了幾次無關;以及 +* **抖動(jitter)**——隨機化退避,讓眾多重試中的工作者不會重新同步成驚群效應。 + +``retry_budget`` 兩者皆補上。:class:`RetryBudget` 由 ``max_attempts`` *與 / 或* ``deadline_s`` +界定;:func:`run_with_budget` 以先達到者為準,且絕不會睡過截止時間。延遲採用有上限的指數退避, +搭配可選的抖動策略(``full`` / ``equal`` / ``none``)。隨機來源(``uniform``)、時鐘與睡眠器 +皆可注入,故每個延遲與決策在測試中都是確定的。不匯入 ``PySide6``。 + +無頭 API +-------- + +.. code-block:: python + + from je_auto_control import RetryBudget, run_with_budget + + budget = RetryBudget(max_attempts=8, deadline_s=30.0, + base_delay_s=0.2, max_delay_s=5.0) + + # 重試點擊直到成功,上限為 8 次嘗試 或 總共 30 秒 + run_with_budget(lambda: click_and_verify("Save"), budget) + +``RetryBudget`` 由嘗試次數與 / 或截止時間界定——把其一設為 ``None`` 即只以另一者界定。 +:func:`backoff_delay`(純函式,無抖動)與 :meth:`RetryBudget.plan` 提供延遲排程以供檢視: + +.. code-block:: python + + RetryBudget(jitter="none").plan(4) # [0.1, 0.2, 0.4, 0.8] + +確定性測試可注入 ``uniform`` / ``clock`` / ``sleep``: + +.. code-block:: python + + run_with_budget(flaky, budget, clock=fake_clock, sleep=fake_sleep, + uniform=lambda lo, hi: lo) # 永遠取下界 + +執行器指令 +---------- + +``AC_retry_delay``(``attempt`` / ``base`` / ``max_delay`` / ``multiplier`` / +``jitter`` → ``{delay}``)與 ``AC_plan_retry_delays``(``attempts`` … → +``{delays}``)暴露純退避排程(``jitter`` 預設為 ``none`` 以得確定結果)。皆以對應的唯讀 +``ac_*`` MCP 工具及 Script Builder 指令(位於 **Flow** 分類下)形式提供。 +:func:`run_with_budget`(包裹一個 callable)則是 Python API 介面。 diff --git a/docs/source/Zh/doc/new_features/v210_features_doc.rst b/docs/source/Zh/doc/new_features/v210_features_doc.rst new file mode 100644 index 00000000..b62f6912 --- /dev/null +++ b/docs/source/Zh/doc/new_features/v210_features_doc.rst @@ -0,0 +1,49 @@ +輸入後驗證欄位 +============== + +``field_entry`` 對控制項輸入後就*指望*它生效了。緩慢的 IME、焦點被搶、輸入遮罩或自動格式化都可能 +悄悄竄改或漏掉字元,而沒有任何東西讀回欄位來察覺。這有別於 ``action_effect``(目標附近是否有*任何* +變化?)與 ``postcondition.text_present``(該文字是否出現在畫面*某處*?)——兩者都無法確認*這個*欄位 +現在等於*這個*值。``verify_field`` 補上讀回這道缺口。 + +* :func:`compare_field_value` ——純函式:在某個比對 ``mode`` 下比較預期與實際值—— + ``exact`` / ``trim`` / ``ci``(不分大小寫)/ ``normalized``(Unicode NFKC + 大小寫摺疊 + 空白)/ + ``contains``。 +* :func:`verify_field_value` ——透過可注入的 ``reader`` 讀回欄位並比較。 +* :func:`fill_and_verify` ——透過可注入的 ``filler`` 輸入、讀回、並重試(可選擇先清空), + 直到相符或用完次數。 + +在執行器中,reader 即原生無障礙值,但每個比較與重試決策都是純函式,可在沒有真實控制項的情況下測試。 +不匯入 ``PySide6``。 + +無頭 API +-------- + +.. code-block:: python + + from je_auto_control import ( + compare_field_value, verify_field_value, fill_and_verify, + ) + + compare_field_value("café", "café", mode="normalized")["match"] # True + + # 讀回控制項並斷言它取得了該值 + ok = verify_field_value("invoice.pdf", + reader=lambda: read_control_value())["match"] + + # 輸入、讀回、最多重試 3 次(每次重試前先清空) + fill_and_verify("2026-06-26", filler=type_into_field, + reader=read_control_value, attempts=3, clear=select_all_del) + +``fill_and_verify`` 回傳最終的 :func:`compare_field_value` 結果加上 ``attempts`` 次數, +讓流程能在持續不符時分支處理,而非盲目輸入。``filler`` / ``reader`` / ``clear`` 皆可注入, +故重試邏輯能在沒有真實欄位的情況下完整測試。 + +執行器指令 +---------- + +``AC_compare_field_value``(``expected`` / ``actual`` / ``mode`` → ``{match, +mode, expected, actual}``,純函式)與 ``AC_verify_field_value``(``expected`` 加上 +``name`` / ``role`` / ``app_name`` / ``automation_id`` / ``mode`` → 比對結果, +透過無障礙後端讀取控制項的值)。皆以對應的唯讀 ``ac_*`` MCP 工具及 Script Builder 指令 +(位於 **Flow** 分類下)形式提供。:func:`fill_and_verify`(包裹一個輸入 callable)則是 Python API 介面。 diff --git a/docs/source/Zh/doc/new_features/v211_features_doc.rst b/docs/source/Zh/doc/new_features/v211_features_doc.rst new file mode 100644 index 00000000..aad939d3 --- /dev/null +++ b/docs/source/Zh/doc/new_features/v211_features_doc.rst @@ -0,0 +1,40 @@ +依觀測時長自適應逾時 +==================== + +寫死的等待是長年的不穩定來源:太短則慢機器與 UI 競速;太長則每次失敗都得付滿整個逾時。可長久的修法是 +從某步驟*實際*花了多久來*學習*逾時。``adaptive_timeout`` 把一組觀測時長轉為穩健的逾時——取高百分位 +(慢但真實的情況)乘上安全 ``factor``,再夾到合理的 ``[min_s, max_s]`` 區間。 + +* :func:`recommend_timeout` ——餵給等待或 ``GateConfig`` 的單一數值。 +* :func:`timeout_stats` ——同上,但額外暴露百分位與夾值旗標以利記錄 / 調校。 + +兩者皆為純函式並重用 :func:`stats.percentile`;沒有樣本時退回 ``default_s``(或 ``min_s``)。 +不匯入 ``PySide6``。 + +無頭 API +-------- + +.. code-block:: python + + from je_auto_control import recommend_timeout, timeout_stats + + # 對話框歷來出現所花的秒數: + seen = [0.8, 1.1, 0.9, 3.2, 1.0, 1.3] + + recommend_timeout(seen) # 約 p95 * 1.5,夾到 [1, 60] + recommend_timeout(seen, percentile_q=99.0, factor=2.0, max_s=30.0) + + timeout_stats(seen) + # {'n': 6, 'p50': 1.05, 'p_high': 2.7..., 'percentile_q': 95.0, + # 'recommended': 4.1..., 'floored': False, 'capped': False} + +把建議值當作下一個 ``wait_for_*`` / actionability 閘的 ``timeout_s``,並隨樣本增長重新計算。 +尚無樣本時,以 ``default_s`` 作為冷啟動值。 + +執行器指令 +---------- + +``AC_adaptive_timeout``(``durations`` 加上 ``percentile_q`` / ``factor`` / +``min_s`` / ``max_s`` → ``{timeout_s}``)與 ``AC_timeout_stats``(同樣輸入 → +``{n, p50, p_high, percentile_q, recommended, floored, capped}``)。``durations`` +接受 JSON 清單。皆以對應的唯讀 ``ac_*`` MCP 工具及 Script Builder 指令(位於 **Flow** 分類下)形式提供。 diff --git a/docs/source/Zh/doc/new_features/v212_features_doc.rst b/docs/source/Zh/doc/new_features/v212_features_doc.rst new file mode 100644 index 00000000..65bf8a34 --- /dev/null +++ b/docs/source/Zh/doc/new_features/v212_features_doc.rst @@ -0,0 +1,42 @@ +確保控制項處於目標狀態(冪等) +============================== + +*無條件採取行動*的自動化——「點選核取方塊」、「輸入該值」——會把已勾選的方塊再次切換,或對已正確的 +欄位重新輸入,且無法安全地重跑。穩健的型態是讀取-比較-行動-驗證:看目前狀態,若已相符就什麼都不做, +否則套用變更並確認生效。``ensure_state`` 正是此原語。 + +* :func:`ensure_state` ——通用:透過 ``reader`` 讀取,若不等於 ``desired`` 就套用 ``setter`` 並 + 重讀,最多 ``attempts`` 次。 +* :func:`ensure_toggle` ——針對無狀態翻轉的布林特化:讀取 ``is_on``,僅在與 ``desired`` 不同時 + 呼叫 ``toggle``。 + +已處於目標狀態的控制項會保持不動(``changed=False``),故此呼叫是冪等且可安全重跑。這有別於 +:mod:`idempotency`(請求鍵重放快取)——``ensure_state`` 收斂的是*裝置狀態*,而非呼叫結果。 +reader / setter / toggle 接縫皆可注入,故邏輯能在沒有真實控制項的情況下完整測試。不匯入 ``PySide6``。 + +無頭 API +-------- + +.. code-block:: python + + from je_auto_control import ensure_state, ensure_toggle + + # 冪等地把某設定設為 "on" ——若已是則不寫入 + ensure_state("on", reader=read_combo, setter=write_combo) + # -> {'ok': True, 'changed': False, 'value': 'on', 'attempts': 0} + + # 僅在尚未勾選時把核取方塊翻為勾選 + ensure_toggle(True, is_on=is_checked, toggle=click_checkbox) + +兩者皆回傳 ``{ok, changed, value, attempts}``:``changed`` 告訴你是否實際執行了動作 +(對「我是否得修正它?」的報告很有用),``ok`` 則是是否在 ``attempts`` 內達到目標狀態。 +對 :func:`ensure_state` 傳入自訂 ``equals`` 可做不分大小寫或正規化比較。 + +執行器指令 +---------- + +``AC_ensure_field_value``(``desired`` 加上 ``name`` / ``role`` / ``app_name`` / +``automation_id`` / ``attempts`` → ``{ok, changed, value, attempts}``)透過無障礙後端 +冪等地設定原生控制項的值——先讀取,若已相符則不做任何事。以對應的 ``ac_ensure_field_value`` +MCP 工具及 Script Builder 指令(位於 **Flow** 分類下)形式提供。:func:`ensure_state` / +:func:`ensure_toggle`(接受任意 callable)則是 Python API 介面。 diff --git a/docs/source/Zh/doc/new_features/v213_features_doc.rst b/docs/source/Zh/doc/new_features/v213_features_doc.rst new file mode 100644 index 00000000..29b00cba --- /dev/null +++ b/docs/source/Zh/doc/new_features/v213_features_doc.rst @@ -0,0 +1,42 @@ +等待應用程式閒置 +================ + +在應用程式仍在忙碌時觸發的點擊——忙碌 / 等待游標出現、對話框正在繪製、長處理程序正在執行—— +會被丟棄或誤點。``smart_waits`` 看*像素*安定;``app_idle`` 改看應用程式的*忙碌訊號*安定, +這更省成本且能在「有動畫但已閒置」的 UI 下運作。它重用 :class:`settle_detector.SettleTracker`: +每次輪詢在忙碌時餵入 ``1.0``、閒置時餵入 ``0.0``,當應用程式連續 ``quiet_samples`` 次讀到閒置 +即返回(新的忙碌尖峰會重置該連續計數)。 + +* :func:`wait_until_app_idle` ——輪詢 ``busy_probe`` 直到應用程式安定閒置或逾時,``clock`` / + ``sleep`` / ``busy_probe`` 皆可注入。 +* :func:`idle_point` ——純函式:在已記錄的忙碌/閒置取樣序列中,首次變為安定閒置的索引。 + +預設 probe 回報 Windows 的忙碌 / 應用程式啟動游標;每個等待與安定決策都透過可注入接縫執行, +故能在沒有應用程式的情況下完整測試。不匯入 ``PySide6``。 + +無頭 API +-------- + +.. code-block:: python + + from je_auto_control import wait_until_app_idle, idle_point + + # 啟動某程式,點擊前先等其忙碌游標安定 + start_exe("setup.exe") + if wait_until_app_idle(quiet_samples=3, timeout_s=30)["idle"]: + click_next() + + # 純函式:分析已記錄的忙碌/閒置軌跡 + idle_point([True, True, False, False, False], quiet_samples=3) # 4 + +``wait_until_app_idle`` 回傳 ``{idle, polls, quiet_run, elapsed_s}``。傳入自訂 ``busy_probe`` +(一個 ``() -> bool``)可對任何忙碌訊號設閘——spinner 影像比對、行程 CPU 門檻、無障礙「忙碌」旗標—— +不限於游標。 + +執行器指令 +---------- + +``AC_wait_until_app_idle``(``quiet_samples`` / ``timeout`` / ``interval`` → +``{idle, polls, quiet_run, elapsed_s}``,使用 Windows 忙碌游標)與 +``AC_idle_point``(``busy_samples`` JSON 清單加上 ``quiet_samples`` → ``{index}``,純函式)。 +皆以對應的唯讀 ``ac_*`` MCP 工具及 Script Builder 指令(位於 **Flow** 分類下)形式提供。 diff --git a/je_auto_control/__init__.py b/je_auto_control/__init__.py index 5934b1b7..21dd9129 100644 --- a/je_auto_control/__init__.py +++ b/je_auto_control/__init__.py @@ -115,6 +115,22 @@ decode_conversion_mode, ime_state, is_composing, wait_for_composition_commit, ) +# Retry budget — deadline + jitter retries over a callable +from je_auto_control.utils.retry_budget import ( + RetryBudget, backoff_delay, jittered_delay, run_with_budget, +) +# Read a field back after typing and confirm the intended value landed +from je_auto_control.utils.verify_field import ( + compare_field_value, fill_and_verify, verify_field_value, +) +# Derive a wait timeout from observed step durations +from je_auto_control.utils.adaptive_timeout import ( + recommend_timeout, timeout_stats, +) +# Idempotently bring a control / setting to a desired state +from je_auto_control.utils.ensure_state import ensure_state, ensure_toggle +# Wait until an application stops being busy before the next step +from je_auto_control.utils.app_idle import idle_point, wait_until_app_idle # Rich clipboard formats — RTF + CSV/TSV codecs and Windows get / set from je_auto_control.utils.clipboard_rich_formats import ( build_rtf, csv_to_rows, get_clipboard_csv, get_clipboard_rtf, rows_to_csv, @@ -1734,6 +1750,11 @@ def start_autocontrol_gui(*args, **kwargs): "wait_for_lock", "classify_lock_transitions", "ime_state", "is_composing", "wait_for_composition_commit", "decode_conversion_mode", + "RetryBudget", "run_with_budget", "backoff_delay", "jittered_delay", + "compare_field_value", "verify_field_value", "fill_and_verify", + "recommend_timeout", "timeout_stats", + "ensure_state", "ensure_toggle", + "wait_until_app_idle", "idle_point", "build_rtf", "rtf_to_text", "rows_to_csv", "csv_to_rows", "set_clipboard_rtf", "get_clipboard_rtf", "set_clipboard_csv", "get_clipboard_csv", diff --git a/je_auto_control/gui/script_builder/command_schema.py b/je_auto_control/gui/script_builder/command_schema.py index a3fefd42..96092eb9 100644 --- a/je_auto_control/gui/script_builder/command_schema.py +++ b/je_auto_control/gui/script_builder/command_schema.py @@ -4401,6 +4401,127 @@ def _add_work_queue_specs(specs: List[CommandSpec]) -> None: ), description="Decode an IMM32 conversion bitmask into named flags.", )) + specs.append(CommandSpec( + "AC_retry_delay", "Flow", "Retry Backoff Delay", + fields=( + FieldSpec("attempt", FieldType.INT, default=1, + placeholder="1-based retry attempt"), + FieldSpec("base", FieldType.FLOAT, optional=True, default=0.1), + FieldSpec("max_delay", FieldType.FLOAT, optional=True, + default=5.0), + FieldSpec("multiplier", FieldType.FLOAT, optional=True, + default=2.0), + FieldSpec("jitter", FieldType.STRING, optional=True, + default="none", placeholder="none / full / equal"), + ), + description="Capped exponential backoff delay before a retry attempt.", + )) + specs.append(CommandSpec( + "AC_plan_retry_delays", "Flow", "Plan Retry Delays", + fields=( + FieldSpec("attempts", FieldType.INT, default=5, + placeholder="number of retries"), + FieldSpec("base", FieldType.FLOAT, optional=True, default=0.1), + FieldSpec("max_delay", FieldType.FLOAT, optional=True, + default=5.0), + FieldSpec("multiplier", FieldType.FLOAT, optional=True, + default=2.0), + FieldSpec("jitter", FieldType.STRING, optional=True, + default="none", placeholder="none / full / equal"), + ), + description="The backoff delay schedule for the first N retries.", + )) + specs.append(CommandSpec( + "AC_compare_field_value", "Flow", "Compare Field Value", + fields=( + FieldSpec("expected", FieldType.STRING, placeholder="expected"), + FieldSpec("actual", FieldType.STRING, placeholder="actual"), + FieldSpec("mode", FieldType.STRING, optional=True, default="exact", + placeholder="exact / trim / ci / normalized / contains"), + ), + description="Compare expected vs actual field value under a mode.", + )) + specs.append(CommandSpec( + "AC_verify_field_value", "Flow", "Verify Field Value", + fields=( + FieldSpec("expected", FieldType.STRING, placeholder="expected"), + FieldSpec("name", FieldType.STRING, optional=True, + placeholder="control name"), + FieldSpec("role", FieldType.STRING, optional=True, + placeholder="control role"), + FieldSpec("app_name", FieldType.STRING, optional=True, + placeholder="app name"), + FieldSpec("automation_id", FieldType.STRING, optional=True, + placeholder="automation id"), + FieldSpec("mode", FieldType.STRING, optional=True, default="exact", + placeholder="exact / trim / ci / normalized / contains"), + ), + description="Read a control's value back and confirm it equals expected.", + )) + specs.append(CommandSpec( + "AC_adaptive_timeout", "Flow", "Adaptive Timeout", + fields=( + FieldSpec("durations", FieldType.STRING, + placeholder="JSON list of durations (seconds)"), + FieldSpec("percentile_q", FieldType.FLOAT, optional=True, + default=95.0), + FieldSpec("factor", FieldType.FLOAT, optional=True, default=1.5), + FieldSpec("min_s", FieldType.FLOAT, optional=True, default=1.0), + FieldSpec("max_s", FieldType.FLOAT, optional=True, default=60.0), + ), + description="Recommend a wait timeout from observed step durations.", + )) + specs.append(CommandSpec( + "AC_timeout_stats", "Flow", "Timeout Stats", + fields=( + FieldSpec("durations", FieldType.STRING, + placeholder="JSON list of durations (seconds)"), + FieldSpec("percentile_q", FieldType.FLOAT, optional=True, + default=95.0), + FieldSpec("factor", FieldType.FLOAT, optional=True, default=1.5), + FieldSpec("min_s", FieldType.FLOAT, optional=True, default=1.0), + FieldSpec("max_s", FieldType.FLOAT, optional=True, default=60.0), + ), + description="Timeout recommendation plus percentiles and clamp flags.", + )) + specs.append(CommandSpec( + "AC_ensure_field_value", "Flow", "Ensure Field Value", + fields=( + FieldSpec("desired", FieldType.STRING, placeholder="desired value"), + FieldSpec("name", FieldType.STRING, optional=True, + placeholder="control name"), + FieldSpec("role", FieldType.STRING, optional=True, + placeholder="control role"), + FieldSpec("app_name", FieldType.STRING, optional=True, + placeholder="app name"), + FieldSpec("automation_id", FieldType.STRING, optional=True, + placeholder="automation id"), + FieldSpec("attempts", FieldType.INT, optional=True, default=2), + ), + description="Idempotently set a control's value (no-op if already set).", + )) + specs.append(CommandSpec( + "AC_wait_until_app_idle", "Flow", "Wait Until App Idle", + fields=( + FieldSpec("quiet_samples", FieldType.INT, optional=True, + default=3, placeholder="consecutive idle polls"), + FieldSpec("timeout", FieldType.FLOAT, optional=True, default=10.0, + placeholder="timeout seconds"), + FieldSpec("interval", FieldType.FLOAT, optional=True, default=0.1, + placeholder="poll interval seconds"), + ), + description="Block until the app's busy cursor settles idle or timeout.", + )) + specs.append(CommandSpec( + "AC_idle_point", "Flow", "Idle Point", + fields=( + FieldSpec("busy_samples", FieldType.STRING, + placeholder="JSON list of busy booleans"), + FieldSpec("quiet_samples", FieldType.INT, optional=True, + default=3), + ), + description="Index where a busy/idle series first settles idle.", + )) specs.append(CommandSpec( "AC_normalize_ext", "Shell", "Normalize Extension", fields=( diff --git a/je_auto_control/utils/adaptive_timeout/__init__.py b/je_auto_control/utils/adaptive_timeout/__init__.py new file mode 100644 index 00000000..f4a4cdc7 --- /dev/null +++ b/je_auto_control/utils/adaptive_timeout/__init__.py @@ -0,0 +1,6 @@ +"""Derive a wait timeout from observed step durations instead of guessing.""" +from je_auto_control.utils.adaptive_timeout.adaptive_timeout import ( + recommend_timeout, timeout_stats, +) + +__all__ = ["recommend_timeout", "timeout_stats"] diff --git a/je_auto_control/utils/adaptive_timeout/adaptive_timeout.py b/je_auto_control/utils/adaptive_timeout/adaptive_timeout.py new file mode 100644 index 00000000..5e8a7de9 --- /dev/null +++ b/je_auto_control/utils/adaptive_timeout/adaptive_timeout.py @@ -0,0 +1,78 @@ +"""Derive a wait timeout from observed step durations instead of guessing. + +Hard-coded waits are a perennial source of flakiness: too short and a slow +machine races the UI; too long and every failure pays the full timeout. The +durable fix is to *learn* the timeout from how long the step has actually taken. +``adaptive_timeout`` turns a sample of observed durations into a robust timeout: +a high percentile (the slow-but-real case) scaled by a safety ``factor``, then +clamped to a sane ``[min_s, max_s]`` band. + +* :func:`recommend_timeout` — the single number to feed a wait / ``GateConfig``. +* :func:`timeout_stats` — the same with the percentiles and clamp flags exposed + for logging / tuning. + +Both are pure and reuse :func:`stats.percentile`; with no samples they fall back +to ``default_s`` (or ``min_s``). Imports no ``PySide6``. +""" +from typing import Any, Dict, List, Optional, Sequence + +from je_auto_control.utils.stats.stats import percentile + + +def _clamp(value: float, min_s: float, max_s: Optional[float]) -> float: + """Clamp ``value`` to ``[min_s, max_s]`` (``max_s`` None = no upper cap).""" + bounded = max(float(min_s), float(value)) + if max_s is not None: + bounded = min(float(max_s), bounded) + return bounded + + +def _fallback(default_s: Optional[float], min_s: float) -> float: + """The timeout to use when there are no duration samples.""" + return float(default_s) if default_s is not None else float(min_s) + + +def recommend_timeout(durations: Sequence[float], *, percentile_q: float = 95.0, + factor: float = 1.5, min_s: float = 1.0, + max_s: Optional[float] = 60.0, + default_s: Optional[float] = None) -> float: + """Recommend a wait timeout (seconds) from observed ``durations``. + + Takes the ``percentile_q``-th percentile of the samples, scales it by + ``factor``, and clamps to ``[min_s, max_s]``. With no samples returns + ``default_s`` (or ``min_s``). + """ + samples = [float(d) for d in durations if d is not None] + if not samples: + return _fallback(default_s, min_s) + scaled = percentile(samples, float(percentile_q)) * float(factor) + return _clamp(scaled, min_s, max_s) + + +def timeout_stats(durations: Sequence[float], *, percentile_q: float = 95.0, + factor: float = 1.5, min_s: float = 1.0, + max_s: Optional[float] = 60.0, + default_s: Optional[float] = None) -> Dict[str, Any]: + """Recommend a timeout and expose the percentiles and clamp decisions. + + Returns ``{n, p50, p_high, percentile_q, recommended, floored, capped}``. + """ + samples: List[float] = [float(d) for d in durations if d is not None] + recommended = recommend_timeout( + samples, percentile_q=percentile_q, factor=factor, min_s=min_s, + max_s=max_s, default_s=default_s) + if not samples: + return {"n": 0, "p50": None, "p_high": None, + "percentile_q": float(percentile_q), + "recommended": recommended, "floored": False, "capped": False} + p_high = percentile(samples, float(percentile_q)) + scaled = p_high * float(factor) + return { + "n": len(samples), + "p50": percentile(samples, 50.0), + "p_high": p_high, + "percentile_q": float(percentile_q), + "recommended": recommended, + "floored": scaled < float(min_s), + "capped": max_s is not None and scaled > float(max_s), + } diff --git a/je_auto_control/utils/app_idle/__init__.py b/je_auto_control/utils/app_idle/__init__.py new file mode 100644 index 00000000..b6756650 --- /dev/null +++ b/je_auto_control/utils/app_idle/__init__.py @@ -0,0 +1,6 @@ +"""Wait until an application stops being busy before driving the next step.""" +from je_auto_control.utils.app_idle.app_idle import ( + idle_point, wait_until_app_idle, +) + +__all__ = ["wait_until_app_idle", "idle_point"] diff --git a/je_auto_control/utils/app_idle/app_idle.py b/je_auto_control/utils/app_idle/app_idle.py new file mode 100644 index 00000000..e42528ec --- /dev/null +++ b/je_auto_control/utils/app_idle/app_idle.py @@ -0,0 +1,102 @@ +"""Wait until an application stops being busy before driving the next step. + +A click fired while the app is still churning — the busy / wait cursor is up, a +dialog is mid-paint, a long handler is running — is dropped or mis-targeted. +``smart_waits`` watches *pixels* settling; this watches the app's *busy signal* +settle instead, which is cheaper and survives animated-but-idle UI. It reuses +:class:`settle_detector.SettleTracker`: each poll feeds ``1.0`` when busy and +``0.0`` when idle, and the wait returns once the app has read idle for +``quiet_samples`` polls in a row (a fresh busy spike resets the run). + +* :func:`wait_until_app_idle` — poll a ``busy_probe`` until the app settles idle + or a timeout, with injectable ``clock`` / ``sleep`` / ``busy_probe``. +* :func:`idle_point` — pure: the index in a recorded busy/idle sample series at + which it first becomes settled-idle. + +The default probe reports the Windows busy / app-starting cursor; every wait and +settle decision runs through the injectable seam, so it is fully testable +without an app. Imports no ``PySide6``. +""" +import sys +import time +from typing import Any, Callable, Dict, Optional, Sequence + +from je_auto_control.utils.settle_detector import SettleTracker, settle_point + +# A busy probe returns truthy while the application is busy. +BusyProbe = Callable[[], bool] + +# Windows busy cursors (IDC_WAIT / IDC_APPSTARTING). +_IDC_WAIT = 32514 +_IDC_APPSTARTING = 32650 + + +def idle_point(busy_samples: Sequence[Any], *, + quiet_samples: int = 3) -> Optional[int]: + """Index at which a busy/idle sample series first settles idle (pure). + + Each truthy sample is "busy"; the series settles once it has read idle for + ``quiet_samples`` in a row. Returns ``None`` if it never settles. + """ + churns = [1.0 if bool(sample) else 0.0 for sample in busy_samples] + return settle_point(churns, quiet_samples=int(quiet_samples), max_churn=0.0) + + +def wait_until_app_idle(*, busy_probe: Optional[BusyProbe] = None, + quiet_samples: int = 3, timeout_s: float = 10.0, + interval_s: float = 0.1, + clock: Callable[[], float] = time.monotonic, + sleep: Callable[[float], None] = time.sleep + ) -> Dict[str, Any]: + """Block until the app reads idle for ``quiet_samples`` polls, or timeout. + + Returns ``{idle, polls, quiet_run, elapsed_s}``. ``clock`` / ``sleep`` / + ``busy_probe`` are injectable for deterministic tests; the default probe + reports the Windows busy cursor. + """ + probe = busy_probe if busy_probe is not None else _default_busy_probe + tracker = SettleTracker(quiet_samples=int(quiet_samples), max_churn=0.0) + start = clock() + deadline = start + float(timeout_s) + polls = 0 + quiet_run = 0 + while True: + polls += 1 + state = tracker.update(1.0 if bool(probe()) else 0.0) + quiet_run = state.quiet_run + if state.settled: + return {"idle": True, "polls": polls, "quiet_run": quiet_run, + "elapsed_s": round(clock() - start, 4)} + if clock() >= deadline: + return {"idle": False, "polls": polls, "quiet_run": quiet_run, + "elapsed_s": round(clock() - start, 4)} + sleep(float(interval_s)) + + +def _cursor_is_busy() -> bool: + """Return True when the Windows global cursor is a busy / app-starting one.""" + import ctypes + + class _POINT(ctypes.Structure): + _fields_ = [("x", ctypes.c_long), ("y", ctypes.c_long)] + + class _CURSORINFO(ctypes.Structure): + _fields_ = [("cbSize", ctypes.c_uint), ("flags", ctypes.c_uint), + ("hCursor", ctypes.c_void_p), ("ptScreenPos", _POINT)] + + user32 = ctypes.windll.user32 + info = _CURSORINFO() + info.cbSize = ctypes.sizeof(_CURSORINFO) + if not user32.GetCursorInfo(ctypes.byref(info)): + return False + busy = {user32.LoadCursorW(None, _IDC_WAIT), + user32.LoadCursorW(None, _IDC_APPSTARTING)} + return info.hCursor in busy + + +def _default_busy_probe() -> bool: + """Default busy probe: the Windows busy cursor; raise on other platforms.""" + if not sys.platform.startswith("win"): + raise RuntimeError( + "app-idle has no OS busy probe on this platform; pass busy_probe=") + return _cursor_is_busy() diff --git a/je_auto_control/utils/ensure_state/__init__.py b/je_auto_control/utils/ensure_state/__init__.py new file mode 100644 index 00000000..68137be8 --- /dev/null +++ b/je_auto_control/utils/ensure_state/__init__.py @@ -0,0 +1,6 @@ +"""Idempotently bring a control or setting to a desired state.""" +from je_auto_control.utils.ensure_state.ensure_state import ( + ensure_state, ensure_toggle, +) + +__all__ = ["ensure_state", "ensure_toggle"] diff --git a/je_auto_control/utils/ensure_state/ensure_state.py b/je_auto_control/utils/ensure_state/ensure_state.py new file mode 100644 index 00000000..0a703590 --- /dev/null +++ b/je_auto_control/utils/ensure_state/ensure_state.py @@ -0,0 +1,66 @@ +"""Idempotently bring a control or setting to a desired state: read, act, verify. + +Automation that *acts unconditionally* — "click the checkbox", "type the value" +— double-toggles a box that was already checked, or re-enters a field that was +already correct, and can't be safely re-run. The robust shape is +read-compare-act-verify: look at the current state, do nothing if it already +matches, otherwise apply the change and confirm it took. ``ensure_state`` is +that primitive. + +* :func:`ensure_state` — generic: read via ``reader``, and if it doesn't equal + ``desired`` apply ``setter`` and re-read, up to ``attempts`` times. +* :func:`ensure_toggle` — the boolean specialization for a stateless flip: read + ``is_on`` and call ``toggle`` only while it differs from ``desired``. + +A control already in the desired state is left untouched (``changed=False``), +so the call is idempotent and safe to re-run. The reader / setter / toggle seams +are injectable, so the logic is fully testable without a real control. Distinct +from :mod:`idempotency` (a request-key replay cache) — this converges *device +state*, not call results. Imports no ``PySide6``. +""" +from typing import Any, Callable, Dict + +StateReader = Callable[[], Any] +StateSetter = Callable[[Any], None] +StateEquals = Callable[[Any, Any], bool] + + +def _default_equals(left: Any, right: Any) -> bool: + """Default equality used to decide whether the state already matches.""" + return left == right + + +def ensure_state(desired: Any, *, reader: StateReader, setter: StateSetter, + equals: StateEquals = _default_equals, + attempts: int = 2) -> Dict[str, Any]: + """Bring the state read by ``reader`` to ``desired`` via ``setter`` (idempotent). + + Reads the current state; if ``equals(current, desired)`` it returns + immediately with ``changed=False``. Otherwise it applies ``setter(desired)`` + and re-reads, up to ``attempts`` times. Returns + ``{ok, changed, value, attempts}``. + """ + current = reader() + if equals(current, desired): + return {"ok": True, "changed": False, "value": current, "attempts": 0} + used = 0 + for used in range(1, max(1, int(attempts)) + 1): + setter(desired) + current = reader() + if equals(current, desired): + return {"ok": True, "changed": True, "value": current, + "attempts": used} + return {"ok": False, "changed": True, "value": current, "attempts": used} + + +def ensure_toggle(desired: bool, *, is_on: Callable[[], bool], + toggle: Callable[[], None], + attempts: int = 2) -> Dict[str, Any]: + """Bring a boolean toggle to ``desired`` by flipping only while it differs. + + ``is_on`` reads the current boolean; ``toggle`` performs a stateless flip and + is called only when the state does not already match ``desired``. Returns + ``{ok, changed, value, attempts}``. + """ + return ensure_state(bool(desired), reader=lambda: bool(is_on()), + setter=lambda _desired: toggle(), attempts=attempts) diff --git a/je_auto_control/utils/executor/action_executor.py b/je_auto_control/utils/executor/action_executor.py index d061a072..56f4305b 100644 --- a/je_auto_control/utils/executor/action_executor.py +++ b/je_auto_control/utils/executor/action_executor.py @@ -2738,6 +2738,107 @@ def _decode_conversion_mode(flags: Any) -> Dict[str, Any]: return decode_conversion_mode(int(flags)) +def _make_retry_budget(base: Any, max_delay: Any, multiplier: Any, + jitter: Any) -> Any: + """Build a RetryBudget from executor scalars (helper for the adapters).""" + from je_auto_control.utils.retry_budget import RetryBudget + return RetryBudget(base_delay_s=float(base), max_delay_s=float(max_delay), + multiplier=float(multiplier), jitter=str(jitter)) + + +def _retry_delay(attempt: Any, base: Any = 0.1, max_delay: Any = 5.0, + multiplier: Any = 2.0, jitter: Any = "none") -> Dict[str, Any]: + """Adapter: the (jittered) backoff delay before a retry attempt (pure).""" + budget = _make_retry_budget(base, max_delay, multiplier, jitter) + return {"delay": float(budget.next_delay(int(attempt)))} + + +def _plan_retry_delays(attempts: Any, base: Any = 0.1, max_delay: Any = 5.0, + multiplier: Any = 2.0, jitter: Any = "none" + ) -> Dict[str, Any]: + """Adapter: the backoff delay schedule for the first N retries (pure).""" + budget = _make_retry_budget(base, max_delay, multiplier, jitter) + return {"delays": [float(d) for d in budget.plan(int(attempts))]} + + +def _compare_field_value(expected: Any, actual: Any, + mode: Any = "exact") -> Dict[str, Any]: + """Adapter: compare an expected vs actual field value under a mode (pure).""" + from je_auto_control.utils.verify_field import compare_field_value + return compare_field_value(expected, actual, mode=str(mode)) + + +def _verify_field_value(expected: Any, name: Optional[str] = None, + role: Optional[str] = None, + app_name: Optional[str] = None, + automation_id: Optional[str] = None, + mode: Any = "exact") -> Dict[str, Any]: + """Adapter: read a native control's value back and compare to expected.""" + from je_auto_control.utils.verify_field import verify_field_value + return verify_field_value( + expected, + reader=lambda: _control_get_value(name=name, role=role, + app_name=app_name, + automation_id=automation_id), + mode=str(mode)) + + +def _adaptive_timeout(durations: Any, percentile_q: Any = 95.0, + factor: Any = 1.5, min_s: Any = 1.0, + max_s: Any = 60.0) -> Dict[str, Any]: + """Adapter: recommend a wait timeout from observed durations (pure).""" + from je_auto_control.utils.adaptive_timeout import recommend_timeout + samples = [float(d) for d in _coerce_list(durations)] if durations else [] + timeout = recommend_timeout(samples, percentile_q=float(percentile_q), + factor=float(factor), min_s=float(min_s), + max_s=float(max_s)) + return {"timeout_s": float(timeout)} + + +def _timeout_stats(durations: Any, percentile_q: Any = 95.0, factor: Any = 1.5, + min_s: Any = 1.0, max_s: Any = 60.0) -> Dict[str, Any]: + """Adapter: timeout recommendation plus percentiles / clamp flags (pure).""" + from je_auto_control.utils.adaptive_timeout import timeout_stats + samples = [float(d) for d in _coerce_list(durations)] if durations else [] + return timeout_stats(samples, percentile_q=float(percentile_q), + factor=float(factor), min_s=float(min_s), + max_s=float(max_s)) + + +def _ensure_field_value(desired: Any, name: Optional[str] = None, + role: Optional[str] = None, + app_name: Optional[str] = None, + automation_id: Optional[str] = None, + attempts: Any = 2) -> Dict[str, Any]: + """Adapter: idempotently set a native control's value and verify (read-act).""" + from je_auto_control.utils.ensure_state import ensure_state + return ensure_state( + str(desired), + reader=lambda: _control_get_value(name=name, role=role, + app_name=app_name, + automation_id=automation_id), + setter=lambda value: _control_set_value(value, name=name, role=role, + app_name=app_name, + automation_id=automation_id), + attempts=int(attempts)) + + +def _wait_until_app_idle(quiet_samples: Any = 3, timeout: Any = 10.0, + interval: Any = 0.1) -> Dict[str, Any]: + """Adapter: block until the foreground app's busy cursor settles or timeout.""" + from je_auto_control.utils.app_idle import wait_until_app_idle + return wait_until_app_idle(quiet_samples=int(quiet_samples), + timeout_s=float(timeout), + interval_s=float(interval)) + + +def _idle_point(busy_samples: Any, quiet_samples: Any = 3) -> Dict[str, Any]: + """Adapter: index where a busy/idle sample series first settles idle (pure).""" + from je_auto_control.utils.app_idle import idle_point + samples = _coerce_list(busy_samples) if busy_samples else [] + return {"index": idle_point(samples, quiet_samples=int(quiet_samples))} + + def _normalize_ext(target: str) -> Dict[str, Any]: """Adapter: the lowercased extension of a path / bare ext (pure).""" from je_auto_control.utils.file_assoc import normalize_ext @@ -6760,6 +6861,15 @@ def __init__(self): "AC_is_composing": _is_composing, "AC_wait_for_composition_commit": _wait_for_composition_commit, "AC_decode_conversion_mode": _decode_conversion_mode, + "AC_retry_delay": _retry_delay, + "AC_plan_retry_delays": _plan_retry_delays, + "AC_compare_field_value": _compare_field_value, + "AC_verify_field_value": _verify_field_value, + "AC_adaptive_timeout": _adaptive_timeout, + "AC_timeout_stats": _timeout_stats, + "AC_ensure_field_value": _ensure_field_value, + "AC_wait_until_app_idle": _wait_until_app_idle, + "AC_idle_point": _idle_point, "AC_normalize_ext": _normalize_ext, "AC_file_association": _file_association, "AC_get_control_text": _get_control_text, diff --git a/je_auto_control/utils/mcp_server/tools/_factories.py b/je_auto_control/utils/mcp_server/tools/_factories.py index 4e0ce256..91e56b8b 100644 --- a/je_auto_control/utils/mcp_server/tools/_factories.py +++ b/je_auto_control/utils/mcp_server/tools/_factories.py @@ -1769,6 +1769,135 @@ def smart_wait_tools() -> List[MCPTool]: handler=h.wait_for_process, annotations=READ_ONLY, ), + MCPTool( + name="ac_retry_delay", + description=("Capped exponential backoff delay (seconds) before a " + "given 1-based retry 'attempt'. 'jitter' is none / " + "full / equal (default none). Returns {delay}."), + input_schema=schema({"attempt": {"type": "integer"}, + "base": {"type": "number"}, + "max_delay": {"type": "number"}, + "multiplier": {"type": "number"}, + "jitter": {"type": "string"}}, + required=["attempt"]), + handler=h.retry_delay, + annotations=READ_ONLY, + ), + MCPTool( + name="ac_plan_retry_delays", + description=("The backoff delay schedule (seconds) for the first " + "'attempts' retries. 'jitter' none / full / equal " + "(default none). Returns {delays}."), + input_schema=schema({"attempts": {"type": "integer"}, + "base": {"type": "number"}, + "max_delay": {"type": "number"}, + "multiplier": {"type": "number"}, + "jitter": {"type": "string"}}, + required=["attempts"]), + handler=h.plan_retry_delays, + annotations=READ_ONLY, + ), + MCPTool( + name="ac_compare_field_value", + description=("Compare an 'expected' vs 'actual' field value under a " + "match 'mode' (exact / trim / ci / normalized / " + "contains). Pure. Returns {match, mode, expected, " + "actual}."), + input_schema=schema({"expected": {"type": "string"}, + "actual": {"type": "string"}, + "mode": {"type": "string"}}, + required=["expected", "actual"]), + handler=h.compare_field_value, + annotations=READ_ONLY, + ), + MCPTool( + name="ac_verify_field_value", + description=("Read a native control's value back (accessibility) " + "and confirm it equals 'expected' under match 'mode'. " + "Identify the control by name / role / app_name / " + "automation_id. Returns {match, expected, actual}."), + input_schema=schema({"expected": {"type": "string"}, + "name": {"type": "string"}, + "role": {"type": "string"}, + "app_name": {"type": "string"}, + "automation_id": {"type": "string"}, + "mode": {"type": "string"}}, + required=["expected"]), + handler=h.verify_field_value, + annotations=READ_ONLY, + ), + MCPTool( + name="ac_adaptive_timeout", + description=("Recommend a wait timeout (seconds) from observed step " + "'durations': the 'percentile_q'-th percentile scaled " + "by 'factor', clamped to [min_s, max_s]. Returns " + "{timeout_s}."), + input_schema=schema({"durations": {"type": "array", + "items": {"type": "number"}}, + "percentile_q": {"type": "number"}, + "factor": {"type": "number"}, + "min_s": {"type": "number"}, + "max_s": {"type": "number"}}, + required=["durations"]), + handler=h.adaptive_timeout, + annotations=READ_ONLY, + ), + MCPTool( + name="ac_timeout_stats", + description=("Recommend a timeout and expose the percentiles and " + "clamp decisions. Returns {n, p50, p_high, " + "percentile_q, recommended, floored, capped}."), + input_schema=schema({"durations": {"type": "array", + "items": {"type": "number"}}, + "percentile_q": {"type": "number"}, + "factor": {"type": "number"}, + "min_s": {"type": "number"}, + "max_s": {"type": "number"}}, + required=["durations"]), + handler=h.timeout_stats, + annotations=READ_ONLY, + ), + MCPTool( + name="ac_ensure_field_value", + description=("Idempotently set a native control's value to " + "'desired' and verify: reads first and does nothing if " + "it already matches. Identify the control by name / " + "role / app_name / automation_id. Returns {ok, " + "changed, value, attempts}."), + input_schema=schema({"desired": {"type": "string"}, + "name": {"type": "string"}, + "role": {"type": "string"}, + "app_name": {"type": "string"}, + "automation_id": {"type": "string"}, + "attempts": {"type": "integer"}}, + required=["desired"]), + handler=h.ensure_field_value, + annotations=SIDE_EFFECT_ONLY, + ), + MCPTool( + name="ac_wait_until_app_idle", + description=("Block until the foreground app's busy / wait cursor " + "settles idle for 'quiet_samples' polls (or 'timeout' " + "seconds). Returns {idle, polls, quiet_run, " + "elapsed_s} (Windows)."), + input_schema=schema({"quiet_samples": {"type": "integer"}, + "timeout": {"type": "number"}, + "interval": {"type": "number"}}), + handler=h.wait_until_app_idle, + annotations=READ_ONLY, + ), + MCPTool( + name="ac_idle_point", + description=("Index in a recorded busy/idle 'busy_samples' series " + "at which it first settles idle for 'quiet_samples' in " + "a row (pure). Returns {index} (null if never)."), + input_schema=schema({"busy_samples": {"type": "array", + "items": {"type": "boolean"}}, + "quiet_samples": {"type": "integer"}}, + required=["busy_samples"]), + handler=h.idle_point, + 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 62b0f307..ef5debeb 100644 --- a/je_auto_control/utils/mcp_server/tools/_handlers.py +++ b/je_auto_control/utils/mcp_server/tools/_handlers.py @@ -668,6 +668,71 @@ def decode_conversion_mode(flags): return _decode_conversion_mode(flags) +def retry_delay(attempt, base=0.1, max_delay=5.0, multiplier=2.0, + jitter="none"): + from je_auto_control.utils.executor.action_executor import _retry_delay + return _retry_delay(attempt, base, max_delay, multiplier, jitter) + + +def plan_retry_delays(attempts, base=0.1, max_delay=5.0, multiplier=2.0, + jitter="none"): + from je_auto_control.utils.executor.action_executor import ( + _plan_retry_delays, + ) + return _plan_retry_delays(attempts, base, max_delay, multiplier, jitter) + + +def compare_field_value(expected, actual, mode="exact"): + from je_auto_control.utils.executor.action_executor import ( + _compare_field_value, + ) + return _compare_field_value(expected, actual, mode) + + +def verify_field_value(expected, name=None, role=None, app_name=None, + automation_id=None, mode="exact"): + from je_auto_control.utils.executor.action_executor import ( + _verify_field_value, + ) + return _verify_field_value(expected, name, role, app_name, automation_id, + mode) + + +def adaptive_timeout(durations, percentile_q=95.0, factor=1.5, min_s=1.0, + max_s=60.0): + from je_auto_control.utils.executor.action_executor import ( + _adaptive_timeout, + ) + return _adaptive_timeout(durations, percentile_q, factor, min_s, max_s) + + +def timeout_stats(durations, percentile_q=95.0, factor=1.5, min_s=1.0, + max_s=60.0): + from je_auto_control.utils.executor.action_executor import _timeout_stats + return _timeout_stats(durations, percentile_q, factor, min_s, max_s) + + +def ensure_field_value(desired, name=None, role=None, app_name=None, + automation_id=None, attempts=2): + from je_auto_control.utils.executor.action_executor import ( + _ensure_field_value, + ) + return _ensure_field_value(desired, name, role, app_name, automation_id, + attempts) + + +def wait_until_app_idle(quiet_samples=3, timeout=10.0, interval=0.1): + from je_auto_control.utils.executor.action_executor import ( + _wait_until_app_idle, + ) + return _wait_until_app_idle(quiet_samples, timeout, interval) + + +def idle_point(busy_samples, quiet_samples=3): + from je_auto_control.utils.executor.action_executor import _idle_point + return _idle_point(busy_samples, quiet_samples) + + def normalize_ext(target): from je_auto_control.utils.executor.action_executor import _normalize_ext return _normalize_ext(target) diff --git a/je_auto_control/utils/retry_budget/__init__.py b/je_auto_control/utils/retry_budget/__init__.py new file mode 100644 index 00000000..751c4565 --- /dev/null +++ b/je_auto_control/utils/retry_budget/__init__.py @@ -0,0 +1,10 @@ +"""Retry budget: bound retries by a wall-clock deadline and full jitter.""" +from je_auto_control.utils.retry_budget.retry_budget import ( + JITTER_EQUAL, JITTER_FULL, JITTER_NONE, RetryBudget, backoff_delay, + jittered_delay, run_with_budget, +) + +__all__ = [ + "RetryBudget", "run_with_budget", "backoff_delay", "jittered_delay", + "JITTER_FULL", "JITTER_EQUAL", "JITTER_NONE", +] diff --git a/je_auto_control/utils/retry_budget/retry_budget.py b/je_auto_control/utils/retry_budget/retry_budget.py new file mode 100644 index 00000000..dc7bcc67 --- /dev/null +++ b/je_auto_control/utils/retry_budget/retry_budget.py @@ -0,0 +1,137 @@ +"""Retry budget: bound retries by a wall-clock deadline and full jitter. + +:class:`resilience.RetryPolicy` retries a fixed number of attempts with plain +exponential backoff. Two things it can't express are exactly what flaky, +contended UI automation needs: + +* a **wall-clock deadline** — "keep retrying, but give up after 30 s total", + independent of how many attempts that takes; and +* **jitter** — randomized backoff so many retrying workers don't resynchronize + into a thundering herd. + +``retry_budget`` adds both. :class:`RetryBudget` is bounded by ``max_attempts`` +*and / or* ``deadline_s``; :func:`run_with_budget` honours whichever is hit +first and never sleeps past the deadline. Delays use capped exponential backoff +with a selectable jitter strategy. The randomness source (``uniform``), the +clock and the sleeper are all injectable, so every delay and decision is +deterministic in tests. Imports no ``PySide6``. +""" +import random +import time +from dataclasses import dataclass +from typing import Any, Callable, Dict, List, Optional, Tuple, Type + +# A uniform sampler: ``(low, high) -> float`` in ``[low, high)``. +Uniform = Callable[[float, float], float] + +# Jitter strategies. +JITTER_FULL = "full" +JITTER_EQUAL = "equal" +JITTER_NONE = "none" + + +def _default_uniform(low: float, high: float) -> float: + """Uniform sample in ``[low, high)`` for retry jitter (non-crypto).""" + return random.uniform(low, high) # nosec B311 # reason: non-crypto retry jitter + + +def backoff_delay(attempt: int, *, base: float, max_delay: float, + multiplier: float) -> float: + """Capped exponential backoff for a 1-based ``attempt`` (pure). + + ``base * multiplier ** (attempt - 1)``, clamped to ``[0, max_delay]``. + """ + if attempt < 1: + return 0.0 + raw = float(base) * (float(multiplier) ** (attempt - 1)) + return max(0.0, min(float(max_delay), raw)) + + +def jittered_delay(raw: float, jitter: str, *, + uniform: Uniform = _default_uniform) -> float: + """Apply a jitter strategy to a raw backoff delay (pure given ``uniform``). + + ``full`` (default) samples ``[0, raw)``; ``equal`` samples + ``[raw/2, raw)``; ``none`` returns ``raw`` unchanged. + """ + bounded = max(0.0, float(raw)) + if jitter == JITTER_NONE or bounded <= 0.0: + return bounded + if jitter == JITTER_EQUAL: + return bounded / 2.0 + uniform(0.0, bounded / 2.0) + return uniform(0.0, bounded) + + +@dataclass +class RetryBudget: + """A retry budget bounded by attempts and / or a wall-clock deadline.""" + + max_attempts: Optional[int] = 5 + deadline_s: Optional[float] = None + base_delay_s: float = 0.1 + max_delay_s: float = 5.0 + multiplier: float = 2.0 + jitter: str = JITTER_FULL + exceptions: Tuple[Type[BaseException], ...] = (Exception,) + + def raw_delay(self, attempt: int) -> float: + """Capped exponential backoff for ``attempt`` (no jitter; pure).""" + return backoff_delay(attempt, base=self.base_delay_s, + max_delay=self.max_delay_s, + multiplier=self.multiplier) + + def next_delay(self, attempt: int, *, + uniform: Uniform = _default_uniform) -> float: + """The jittered delay to wait before retry after ``attempt`` (pure).""" + return jittered_delay(self.raw_delay(attempt), self.jitter, + uniform=uniform) + + def plan(self, attempts: int, *, + uniform: Uniform = _default_uniform) -> List[float]: + """The jittered delay schedule for the first ``attempts`` retries.""" + count = max(0, int(attempts)) + return [self.next_delay(i, uniform=uniform) + for i in range(1, count + 1)] + + +def _sleep_before_retry(budget: RetryBudget, attempt: int, elapsed: float, + uniform: Uniform) -> Optional[float]: + """Return the delay before the next attempt, or ``None`` to stop retrying.""" + if budget.max_attempts is not None and attempt >= int(budget.max_attempts): + return None + delay = budget.next_delay(attempt, uniform=uniform) + if budget.deadline_s is None: + return max(0.0, delay) + remaining = float(budget.deadline_s) - elapsed + if remaining <= 0: + return None + return max(0.0, min(delay, remaining)) + + +def run_with_budget(func: Callable[..., Any], budget: RetryBudget, *, + args: Tuple[Any, ...] = (), + kwargs: Optional[Dict[str, Any]] = None, + clock: Callable[[], float] = time.monotonic, + sleep: Callable[[float], None] = time.sleep, + uniform: Uniform = _default_uniform) -> Any: + """Call ``func`` until it succeeds or the budget is spent; re-raise on giveup. + + Stops at whichever of ``max_attempts`` / ``deadline_s`` is hit first and + never sleeps past the deadline. Exceptions outside ``budget.exceptions`` + propagate immediately. ``clock`` / ``sleep`` / ``uniform`` are injectable. + """ + call_kwargs = kwargs if kwargs is not None else {} + start = clock() + attempt = 0 + last_error: Optional[BaseException] = None + while True: + attempt += 1 + try: + return func(*args, **call_kwargs) + except budget.exceptions as error: + last_error = error + wait = _sleep_before_retry(budget, attempt, clock() - start, uniform) + if wait is None: + raise last_error + if wait > 0: + sleep(wait) diff --git a/je_auto_control/utils/verify_field/__init__.py b/je_auto_control/utils/verify_field/__init__.py new file mode 100644 index 00000000..9eefae69 --- /dev/null +++ b/je_auto_control/utils/verify_field/__init__.py @@ -0,0 +1,11 @@ +"""Read a field back after typing and confirm it holds the intended value.""" +from je_auto_control.utils.verify_field.verify_field import ( + MATCH_CI, MATCH_CONTAINS, MATCH_EXACT, MATCH_NORMALIZED, MATCH_TRIM, + compare_field_value, fill_and_verify, verify_field_value, +) + +__all__ = [ + "compare_field_value", "verify_field_value", "fill_and_verify", + "MATCH_EXACT", "MATCH_TRIM", "MATCH_CI", "MATCH_NORMALIZED", + "MATCH_CONTAINS", +] diff --git a/je_auto_control/utils/verify_field/verify_field.py b/je_auto_control/utils/verify_field/verify_field.py new file mode 100644 index 00000000..73e27fa6 --- /dev/null +++ b/je_auto_control/utils/verify_field/verify_field.py @@ -0,0 +1,101 @@ +"""Read a field back after typing and confirm it holds the intended value. + +``field_entry`` types into a control and *hopes* it landed: a slow IME, a focus +steal, an input mask or an auto-format can silently mangle or drop characters, +and nothing reads the field back to notice. This is distinct from +``action_effect`` (did *anything* change near the target?) and +``postcondition.text_present`` (does the text appear *anywhere* on screen?) — +neither confirms *this* field now equals *this* value. + +* :func:`compare_field_value` — pure: compare an expected and actual value under + a match ``mode`` (``exact`` / ``trim`` / ``ci`` / ``normalized`` / + ``contains``). +* :func:`verify_field_value` — read the field through an injectable ``reader`` + and compare. +* :func:`fill_and_verify` — type through an injectable ``filler``, read back, and + retry (optionally clearing first) until it matches or attempts run out. + +The reader / filler seams default to the native accessibility value in the +executor, but every comparison and retry decision is pure and testable without a +real control. Imports no ``PySide6``. +""" +from typing import Any, Callable, Dict, Optional + +# Match modes. +MATCH_EXACT = "exact" +MATCH_TRIM = "trim" +MATCH_CI = "ci" +MATCH_NORMALIZED = "normalized" +MATCH_CONTAINS = "contains" + +# A reader returns the field's current value; a filler types a value into it. +FieldReader = Callable[[], Optional[str]] +FieldFiller = Callable[[str], None] + + +def _canonical(text: str, mode: str) -> str: + """Canonicalize ``text`` for comparison under ``mode`` (pure).""" + if mode in (MATCH_TRIM, MATCH_CI, MATCH_CONTAINS): + text = text.strip() + if mode in (MATCH_CI, MATCH_CONTAINS): + text = text.casefold() + if mode == MATCH_NORMALIZED: + from je_auto_control.utils.text_normalize import normalize_text + return normalize_text(text) + return text + + +def _as_text(value: Any) -> str: + """Coerce a value to a string, treating ``None`` as empty.""" + return "" if value is None else str(value) + + +def compare_field_value(expected: Any, actual: Any, *, + mode: str = MATCH_EXACT) -> Dict[str, Any]: + """Compare ``expected`` against ``actual`` under ``mode`` (pure). + + Returns ``{match, mode, expected, actual}``. ``contains`` is a (trimmed, + case-insensitive) substring test; the others compare canonical equality. + """ + expected_text = _as_text(expected) + actual_text = _as_text(actual) + if mode == MATCH_CONTAINS: + match = _canonical(expected_text, mode) in _canonical(actual_text, mode) + else: + match = _canonical(expected_text, mode) == _canonical(actual_text, mode) + return {"match": bool(match), "mode": mode, + "expected": expected_text, "actual": actual_text} + + +def verify_field_value(expected: Any, *, reader: FieldReader, + mode: str = MATCH_EXACT) -> Dict[str, Any]: + """Read the field via ``reader`` and compare it to ``expected``. + + Returns the :func:`compare_field_value` result for the value read back. + """ + return compare_field_value(expected, reader(), mode=mode) + + +def fill_and_verify(value: Any, *, filler: FieldFiller, reader: FieldReader, + attempts: int = 2, mode: str = MATCH_EXACT, + clear: Optional[Callable[[], None]] = None + ) -> Dict[str, Any]: + """Type ``value`` via ``filler``, read it back, and retry until it matches. + + Up to ``attempts`` tries; before each retry (not the first) ``clear`` is + called if supplied. Returns the final :func:`compare_field_value` result + with an added ``attempts`` count. + """ + total = max(1, int(attempts)) + result: Dict[str, Any] = compare_field_value(value, None, mode=mode) + used = 0 + for used in range(1, total + 1): + if clear is not None and used > 1: + clear() + filler(value) + result = compare_field_value(value, reader(), mode=mode) + if result["match"]: + break + result = dict(result) + result["attempts"] = used + return result diff --git a/test/unit_test/headless/test_adaptive_timeout_batch.py b/test/unit_test/headless/test_adaptive_timeout_batch.py new file mode 100644 index 00000000..91949bb4 --- /dev/null +++ b/test/unit_test/headless/test_adaptive_timeout_batch.py @@ -0,0 +1,104 @@ +"""Headless tests for adaptive_timeout (pure timeout recommendation).""" +import pytest + +import je_auto_control as ac +from je_auto_control.utils.adaptive_timeout import ( + recommend_timeout, timeout_stats, +) + + +# --- recommend_timeout ---------------------------------------------------- + +def test_recommend_scales_percentile_by_factor(): + # p95 of 1..10 ~ 9.55; * 1.0 factor, within [0.1, 100] -> ~9.55 + durations = list(range(1, 11)) + value = recommend_timeout(durations, percentile_q=95.0, factor=1.0, + min_s=0.1, max_s=100.0) + assert value == pytest.approx(9.55, abs=0.1) + + +def test_recommend_applies_factor(): + value = recommend_timeout([2.0, 2.0, 2.0], percentile_q=95.0, factor=2.0, + min_s=0.1, max_s=100.0) + assert value == pytest.approx(4.0) + + +def test_recommend_floors_to_min(): + value = recommend_timeout([0.01, 0.02], percentile_q=95.0, factor=1.0, + min_s=1.0, max_s=100.0) + assert value == pytest.approx(1.0) + + +def test_recommend_caps_to_max(): + value = recommend_timeout([100.0, 200.0], percentile_q=95.0, factor=2.0, + min_s=1.0, max_s=10.0) + assert value == pytest.approx(10.0) + + +def test_recommend_empty_uses_default_then_min(): + assert recommend_timeout([], default_s=7.0) == pytest.approx(7.0) + assert recommend_timeout([], min_s=3.0) == pytest.approx(3.0) + + +def test_recommend_ignores_none_samples(): + value = recommend_timeout([None, 2.0, None, 2.0], percentile_q=50.0, + factor=1.0, min_s=0.1, max_s=100.0) + assert value == pytest.approx(2.0) + + +# --- timeout_stats -------------------------------------------------------- + +def test_timeout_stats_exposes_percentiles_and_flags(): + stats = timeout_stats([1.0, 2.0, 3.0, 4.0], percentile_q=95.0, factor=1.0, + min_s=0.1, max_s=100.0) + assert stats["n"] == 4 + assert stats["p50"] == pytest.approx(2.5) + assert stats["floored"] is False + assert stats["capped"] is False + assert stats["recommended"] == pytest.approx(stats["p_high"]) + + +def test_timeout_stats_flags_capped(): + stats = timeout_stats([50.0, 60.0], percentile_q=95.0, factor=2.0, + min_s=1.0, max_s=10.0) + assert stats["capped"] is True + assert stats["recommended"] == pytest.approx(10.0) + + +def test_timeout_stats_empty(): + stats = timeout_stats([], default_s=5.0) + assert stats["n"] == 0 + assert stats["p50"] is None + assert stats["recommended"] == pytest.approx(5.0) + + +# --- wiring --------------------------------------------------------------- + +def test_executor_paths(): + from je_auto_control.utils.executor.action_executor import ( + _adaptive_timeout, _timeout_stats, + ) + out = _adaptive_timeout([2.0, 2.0, 2.0], 95.0, 2.0, 0.1, 100.0) + assert out["timeout_s"] == pytest.approx(4.0) + # accepts a JSON-list string (Script Builder text field) + out2 = _adaptive_timeout("[2.0, 2.0]", 50.0, 1.0, 0.1, 100.0) + assert out2["timeout_s"] == pytest.approx(2.0) + assert _timeout_stats([1.0, 2.0], 95.0, 1.0, 0.1, 100.0)["n"] == 2 + + +def test_wiring(): + known = set(ac.executor.known_commands()) + assert {"AC_adaptive_timeout", "AC_timeout_stats"} <= 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_adaptive_timeout", "ac_timeout_stats"} <= names + from je_auto_control.gui.script_builder.command_schema import _build_specs + specs = {s.command for s in _build_specs()} + assert {"AC_adaptive_timeout", "AC_timeout_stats"} <= specs + + +def test_facade_exports(): + for name in ("recommend_timeout", "timeout_stats"): + assert hasattr(ac, name) and name in ac.__all__ diff --git a/test/unit_test/headless/test_app_idle_batch.py b/test/unit_test/headless/test_app_idle_batch.py new file mode 100644 index 00000000..5db32bd0 --- /dev/null +++ b/test/unit_test/headless/test_app_idle_batch.py @@ -0,0 +1,88 @@ +"""Headless tests for app_idle (settle gate over an injected busy probe).""" +import je_auto_control as ac +from je_auto_control.utils.app_idle import idle_point, wait_until_app_idle + + +# --- pure idle_point ------------------------------------------------------ + +def test_idle_point_after_busy_run(): + # busy, busy, idle, idle, idle -> settles at index 4 (3 quiet in a row) + samples = [True, True, False, False, False] + assert idle_point(samples, quiet_samples=3) == 4 + + +def test_idle_point_resets_on_spike(): + # idle, idle, BUSY, idle, idle, idle -> the spike resets the quiet run + samples = [False, False, True, False, False, False] + assert idle_point(samples, quiet_samples=3) == 5 + + +def test_idle_point_never_settles(): + assert idle_point([True, True, True], quiet_samples=2) is None + + +def test_idle_point_immediate_when_quiet_one(): + assert idle_point([False], quiet_samples=1) == 0 + + +# --- wait_until_app_idle -------------------------------------------------- + +def _probe_sequence(values): + state = {"i": 0} + + def probe(): + i = min(state["i"], len(values) - 1) + state["i"] += 1 + return values[i] + + return probe + + +def test_wait_until_app_idle_settles(): + # busy then idle x3 -> idle=True + probe = _probe_sequence([True, False, False, False]) + clock = iter([0.0, 0.0, 1.0, 2.0, 3.0, 4.0]) + result = wait_until_app_idle(busy_probe=probe, quiet_samples=3, + timeout_s=100.0, interval_s=1.0, + clock=lambda: next(clock), + sleep=lambda _s: None) + assert result["idle"] is True + assert result["quiet_run"] >= 3 + + +def test_wait_until_app_idle_times_out_when_busy(): + probe = _probe_sequence([True]) # always busy + ticks = iter([0.0, 1.0, 2.0, 6.0, 7.0]) + result = wait_until_app_idle(busy_probe=probe, quiet_samples=3, + timeout_s=5.0, interval_s=1.0, + clock=lambda: next(ticks), + sleep=lambda _s: None) + assert result["idle"] is False + + +# --- wiring --------------------------------------------------------------- + +def test_executor_pure_idle_point(): + from je_auto_control.utils.executor.action_executor import _idle_point + out = _idle_point([True, False, False, False], 3) + assert out["index"] == 3 + # accepts a JSON-list string (Script Builder text field) + assert _idle_point("[false, false]", 2)["index"] == 1 + + +def test_wiring(): + known = set(ac.executor.known_commands()) + assert {"AC_wait_until_app_idle", "AC_idle_point"} <= 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_wait_until_app_idle", "ac_idle_point"} <= names + from je_auto_control.gui.script_builder.command_schema import _build_specs + specs = {s.command for s in _build_specs()} + assert {"AC_wait_until_app_idle", "AC_idle_point"} <= specs + + +def test_facade_exports(): + for name in ("wait_until_app_idle", "idle_point"): + assert hasattr(ac, name) and name in ac.__all__ diff --git a/test/unit_test/headless/test_ensure_state_batch.py b/test/unit_test/headless/test_ensure_state_batch.py new file mode 100644 index 00000000..487ce62b --- /dev/null +++ b/test/unit_test/headless/test_ensure_state_batch.py @@ -0,0 +1,112 @@ +"""Headless tests for ensure_state (injected reader / setter / toggle).""" +import je_auto_control as ac +from je_auto_control.utils.ensure_state import ensure_state, ensure_toggle + + +class Cell: + """A tiny mutable state holder for reader / setter seams.""" + + def __init__(self, value): + self.value = value + self.sets = 0 + + def read(self): + return self.value + + def write(self, new): + self.sets += 1 + self.value = new + + +# --- ensure_state --------------------------------------------------------- + +def test_ensure_state_noop_when_already_matches(): + cell = Cell("on") + result = ensure_state("on", reader=cell.read, setter=cell.write) + assert result == {"ok": True, "changed": False, "value": "on", + "attempts": 0} + assert cell.sets == 0 # idempotent: setter never called + + +def test_ensure_state_sets_when_different(): + cell = Cell("off") + result = ensure_state("on", reader=cell.read, setter=cell.write) + assert result["ok"] is True + assert result["changed"] is True + assert result["value"] == "on" + assert result["attempts"] == 1 + assert cell.sets == 1 + + +def test_ensure_state_gives_up_when_setter_ineffective(): + # setter that never actually changes the value + reads = {"n": 0} + + def stubborn_set(_value): + reads["n"] += 1 + + result = ensure_state("on", reader=lambda: "off", setter=stubborn_set, + attempts=3) + assert result["ok"] is False + assert result["changed"] is True + assert result["attempts"] == 3 + assert reads["n"] == 3 + + +def test_ensure_state_custom_equals(): + cell = Cell("ON") + # case-insensitive equality means "on" already matches "ON" + result = ensure_state("on", reader=cell.read, setter=cell.write, + equals=lambda a, b: a.lower() == b.lower()) + assert result["changed"] is False + assert cell.sets == 0 + + +# --- ensure_toggle -------------------------------------------------------- + +def test_ensure_toggle_noop_when_already_desired(): + state = {"on": True} + flips = {"n": 0} + + def toggle(): + flips["n"] += 1 + state["on"] = not state["on"] + + result = ensure_toggle(True, is_on=lambda: state["on"], toggle=toggle) + assert result["changed"] is False + assert flips["n"] == 0 + + +def test_ensure_toggle_flips_once_to_reach_desired(): + state = {"on": False} + flips = {"n": 0} + + def toggle(): + flips["n"] += 1 + state["on"] = not state["on"] + + result = ensure_toggle(True, is_on=lambda: state["on"], toggle=toggle) + assert result["ok"] is True + assert result["changed"] is True + assert result["value"] is True + assert flips["n"] == 1 + + +# --- wiring --------------------------------------------------------------- + +def test_wiring(): + known = set(ac.executor.known_commands()) + assert "AC_ensure_field_value" in 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_ensure_field_value" in names + from je_auto_control.gui.script_builder.command_schema import _build_specs + specs = {s.command for s in _build_specs()} + assert "AC_ensure_field_value" in specs + + +def test_facade_exports(): + for name in ("ensure_state", "ensure_toggle"): + assert hasattr(ac, name) and name in ac.__all__ diff --git a/test/unit_test/headless/test_retry_budget_batch.py b/test/unit_test/headless/test_retry_budget_batch.py new file mode 100644 index 00000000..c45091ed --- /dev/null +++ b/test/unit_test/headless/test_retry_budget_batch.py @@ -0,0 +1,170 @@ +"""Headless tests for retry_budget (injected uniform / clock / sleep).""" +import pytest + +import je_auto_control as ac +from je_auto_control.utils.retry_budget import ( + JITTER_EQUAL, JITTER_FULL, JITTER_NONE, RetryBudget, backoff_delay, + jittered_delay, run_with_budget, +) + + +# --- pure backoff / jitter ------------------------------------------------ + +def test_backoff_delay_exponential_capped(): + assert backoff_delay(1, base=0.1, max_delay=5.0, + multiplier=2.0) == pytest.approx(0.1) + assert backoff_delay(3, base=0.1, max_delay=5.0, + multiplier=2.0) == pytest.approx(0.4) + # capped + assert backoff_delay(10, base=0.1, max_delay=1.0, + multiplier=2.0) == pytest.approx(1.0) + assert backoff_delay(0, base=0.1, max_delay=5.0, + multiplier=2.0) == pytest.approx(0.0) + + +def test_jittered_delay_none_is_identity(): + assert jittered_delay(0.8, JITTER_NONE) == pytest.approx(0.8) + + +def test_jittered_delay_full_uses_uniform_in_bounds(): + # full jitter samples [0, raw); inject a uniform returning the high bound + assert jittered_delay(0.8, JITTER_FULL, + uniform=lambda lo, hi: hi) == pytest.approx(0.8) + assert jittered_delay(0.8, JITTER_FULL, + uniform=lambda lo, hi: lo) == pytest.approx(0.0) + + +def test_jittered_delay_equal_half_plus_sample(): + # equal jitter = raw/2 + uniform(0, raw/2); with uniform->low gives raw/2 + assert jittered_delay(1.0, JITTER_EQUAL, + uniform=lambda lo, hi: lo) == pytest.approx(0.5) + + +# --- RetryBudget schedule ------------------------------------------------- + +def test_budget_plan_deterministic_without_jitter(): + budget = RetryBudget(base_delay_s=0.1, max_delay_s=5.0, multiplier=2.0, + jitter=JITTER_NONE) + assert budget.plan(4) == pytest.approx([0.1, 0.2, 0.4, 0.8]) + + +def test_budget_next_delay_full_jitter_bounded(): + budget = RetryBudget(base_delay_s=1.0, jitter=JITTER_FULL) + high = budget.next_delay(1, uniform=lambda lo, hi: hi) + low = budget.next_delay(1, uniform=lambda lo, hi: lo) + assert high == pytest.approx(1.0) + assert low == pytest.approx(0.0) + + +# --- run_with_budget ------------------------------------------------------ + +def test_run_with_budget_returns_on_success(): + calls = {"n": 0} + + def flaky(): + calls["n"] += 1 + if calls["n"] < 3: + raise ValueError("boom") + return "ok" + + result = run_with_budget( + flaky, RetryBudget(max_attempts=5, jitter=JITTER_NONE), + clock=lambda: 0.0, sleep=lambda _s: None) + assert result == "ok" + assert calls["n"] == 3 + + +def test_run_with_budget_exhausts_attempts_and_reraises(): + attempts = {"n": 0} + + def always_fail(): + attempts["n"] += 1 + raise ValueError("nope") + + with pytest.raises(ValueError): + run_with_budget( + always_fail, RetryBudget(max_attempts=3, jitter=JITTER_NONE), + clock=lambda: 0.0, sleep=lambda _s: None) + assert attempts["n"] == 3 + + +def test_run_with_budget_respects_deadline(): + attempts = {"n": 0} + ticks = iter([0.0, 0.0, 10.0, 20.0, 30.0]) # 2nd elapsed check > deadline + + def always_fail(): + attempts["n"] += 1 + raise RuntimeError("slow") + + with pytest.raises(RuntimeError): + run_with_budget( + always_fail, + RetryBudget(max_attempts=None, deadline_s=5.0, jitter=JITTER_NONE), + clock=lambda: next(ticks), sleep=lambda _s: None) + # gave up on the deadline, not after many attempts + assert attempts["n"] == 2 + + +def test_run_with_budget_propagates_unlisted_exception(): + def boom(): + raise KeyError("uncaught") + + with pytest.raises(KeyError): + run_with_budget( + boom, RetryBudget(max_attempts=5, exceptions=(ValueError,)), + clock=lambda: 0.0, sleep=lambda _s: None) + + +def test_run_with_budget_caps_sleep_to_remaining_deadline(): + slept = [] + ticks = iter([0.0, 0.0, 0.0]) # elapsed stays 0 -> remaining = deadline + + run_args = {"n": 0} + + def fail_then_ok(): + run_args["n"] += 1 + if run_args["n"] == 1: + raise ValueError("x") + return "done" + + out = run_with_budget( + fail_then_ok, + RetryBudget(max_attempts=5, deadline_s=0.3, base_delay_s=10.0, + jitter=JITTER_NONE), + clock=lambda: next(ticks), sleep=slept.append) + assert out == "done" + # raw backoff was 10s but capped to the 0.3s remaining deadline + assert slept == pytest.approx([0.3]) + + +# --- wiring --------------------------------------------------------------- + +def test_executor_pure_paths(): + from je_auto_control.utils.executor.action_executor import ( + _plan_retry_delays, _retry_delay, + ) + assert _retry_delay(2, 0.1, 5.0, 2.0, "none")["delay"] == pytest.approx(0.2) + delays = _plan_retry_delays(3, 0.1, 5.0, 2.0, "none")["delays"] + assert delays == pytest.approx([0.1, 0.2, 0.4]) + # full jitter stays within [0, raw] + jittered = _retry_delay(1, 1.0, 5.0, 2.0, "full")["delay"] + assert 0.0 <= jittered <= 1.0 + + +def test_wiring(): + known = set(ac.executor.known_commands()) + assert {"AC_retry_delay", "AC_plan_retry_delays"} <= 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_retry_delay", "ac_plan_retry_delays"} <= names + from je_auto_control.gui.script_builder.command_schema import _build_specs + specs = {s.command for s in _build_specs()} + assert {"AC_retry_delay", "AC_plan_retry_delays"} <= specs + + +def test_facade_exports(): + for name in ("RetryBudget", "run_with_budget", "backoff_delay", + "jittered_delay"): + assert hasattr(ac, name) and name in ac.__all__ diff --git a/test/unit_test/headless/test_verify_field_batch.py b/test/unit_test/headless/test_verify_field_batch.py new file mode 100644 index 00000000..b00dc3ac --- /dev/null +++ b/test/unit_test/headless/test_verify_field_batch.py @@ -0,0 +1,121 @@ +"""Headless tests for verify_field (pure compare + injected reader / filler).""" +import unicodedata + +import je_auto_control as ac +from je_auto_control.utils.verify_field import ( + MATCH_CI, MATCH_CONTAINS, MATCH_EXACT, MATCH_NORMALIZED, MATCH_TRIM, + compare_field_value, fill_and_verify, verify_field_value, +) + + +# --- pure compare --------------------------------------------------------- + +def test_compare_exact(): + assert compare_field_value("abc", "abc")["match"] is True + assert compare_field_value("abc", "abd")["match"] is False + + +def test_compare_trim_and_ci(): + assert compare_field_value("hi", " hi ", mode=MATCH_TRIM)["match"] is True + assert compare_field_value("Hi", "hi", mode=MATCH_EXACT)["match"] is False + assert compare_field_value("Hi", " HI ", mode=MATCH_CI)["match"] is True + + +def test_compare_contains(): + assert compare_field_value("ell", "Hello", mode=MATCH_CONTAINS)["match"] + assert not compare_field_value("xyz", "Hello", + mode=MATCH_CONTAINS)["match"] + + +def test_compare_normalized_unicode(): + # Precomposed (U+00E9) vs decomposed (e + U+0301) "cafe" differ byte-wise + # but match once NFKC-normalized. + base = "café" + nfc = unicodedata.normalize("NFC", base) + nfd = unicodedata.normalize("NFD", base) + assert nfc != nfd + assert compare_field_value(nfc, nfd, mode=MATCH_NORMALIZED)["match"] is True + assert compare_field_value(nfc, nfd, mode=MATCH_EXACT)["match"] is False + + +def test_compare_none_is_empty(): + assert compare_field_value(None, "")["match"] is True + assert compare_field_value("", None)["match"] is True + result = compare_field_value("x", None) + assert result["match"] is False + assert result["actual"] == "" + + +# --- verify via injected reader ------------------------------------------- + +def test_verify_field_value_reads_back(): + result = verify_field_value("save.txt", reader=lambda: "save.txt") + assert result["match"] is True + assert result["actual"] == "save.txt" + + +def test_verify_field_value_mismatch(): + result = verify_field_value("save.txt", reader=lambda: "sve.txt") + assert result["match"] is False + + +# --- fill_and_verify retry ------------------------------------------------ + +def test_fill_and_verify_succeeds_first_try(): + typed = [] + result = fill_and_verify("hello", filler=typed.append, + reader=lambda: "hello") + assert result["match"] is True + assert result["attempts"] == 1 + assert typed == ["hello"] + + +def test_fill_and_verify_retries_until_match(): + reads = iter(["wrong", "hello"]) # first read fails, second succeeds + cleared = [] + typed = [] + result = fill_and_verify("hello", filler=typed.append, + reader=lambda: next(reads), + clear=lambda: cleared.append(True), attempts=3) + assert result["match"] is True + assert result["attempts"] == 2 + assert cleared == [True] # cleared once before the retry + assert typed == ["hello", "hello"] + + +def test_fill_and_verify_gives_up_after_attempts(): + typed = [] + result = fill_and_verify("hello", filler=typed.append, + reader=lambda: "nope", attempts=2) + assert result["match"] is False + assert result["attempts"] == 2 + assert len(typed) == 2 + + +# --- wiring --------------------------------------------------------------- + +def test_executor_pure_compare_path(): + from je_auto_control.utils.executor.action_executor import ( + _compare_field_value, + ) + out = _compare_field_value("a b", "a b", mode="normalized") + assert out["match"] is True + + +def test_wiring(): + known = set(ac.executor.known_commands()) + assert {"AC_compare_field_value", "AC_verify_field_value"} <= 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_compare_field_value", "ac_verify_field_value"} <= names + from je_auto_control.gui.script_builder.command_schema import _build_specs + specs = {s.command for s in _build_specs()} + assert {"AC_compare_field_value", "AC_verify_field_value"} <= specs + + +def test_facade_exports(): + for name in ("compare_field_value", "verify_field_value", + "fill_and_verify"): + assert hasattr(ac, name) and name in ac.__all__