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
30 changes: 30 additions & 0 deletions WHATS_NEW.md
Original file line number Diff line number Diff line change
Expand Up @@ -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).
Expand Down
56 changes: 56 additions & 0 deletions docs/source/Eng/doc/new_features/v209_features_doc.rst
Original file line number Diff line number Diff line change
@@ -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.
57 changes: 57 additions & 0 deletions docs/source/Eng/doc/new_features/v210_features_doc.rst
Original file line number Diff line number Diff line change
@@ -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.
46 changes: 46 additions & 0 deletions docs/source/Eng/doc/new_features/v211_features_doc.rst
Original file line number Diff line number Diff line change
@@ -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**.
52 changes: 52 additions & 0 deletions docs/source/Eng/doc/new_features/v212_features_doc.rst
Original file line number Diff line number Diff line change
@@ -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.
48 changes: 48 additions & 0 deletions docs/source/Eng/doc/new_features/v213_features_doc.rst
Original file line number Diff line number Diff line change
@@ -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**.
49 changes: 49 additions & 0 deletions docs/source/Zh/doc/new_features/v209_features_doc.rst
Original file line number Diff line number Diff line change
@@ -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 介面。
Loading
Loading