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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions WHATS_NEW.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,12 @@

## What's new (2026-06-26)

### 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).
Expand Down
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.
49 changes: 49 additions & 0 deletions docs/source/Zh/doc/new_features/v210_features_doc.rst
Original file line number Diff line number Diff line change
@@ -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 介面。
5 changes: 5 additions & 0 deletions je_auto_control/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -119,6 +119,10 @@
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,
)
# 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,
Expand Down Expand Up @@ -1739,6 +1743,7 @@ def start_autocontrol_gui(*args, **kwargs):
"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",
"build_rtf", "rtf_to_text", "rows_to_csv", "csv_to_rows",
"set_clipboard_rtf", "get_clipboard_rtf",
"set_clipboard_csv", "get_clipboard_csv",
Expand Down
27 changes: 27 additions & 0 deletions je_auto_control/gui/script_builder/command_schema.py
Original file line number Diff line number Diff line change
Expand Up @@ -4431,6 +4431,33 @@ def _add_work_queue_specs(specs: List[CommandSpec]) -> None:
),
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_normalize_ext", "Shell", "Normalize Extension",
fields=(
Expand Down
24 changes: 24 additions & 0 deletions je_auto_control/utils/executor/action_executor.py
Original file line number Diff line number Diff line change
Expand Up @@ -2761,6 +2761,28 @@ def _plan_retry_delays(attempts: Any, base: Any = 0.1, max_delay: Any = 5.0,
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 _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
Expand Down Expand Up @@ -6785,6 +6807,8 @@ def __init__(self):
"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_normalize_ext": _normalize_ext,
"AC_file_association": _file_association,
"AC_get_control_text": _get_control_text,
Expand Down
29 changes: 29 additions & 0 deletions je_auto_control/utils/mcp_server/tools/_factories.py
Original file line number Diff line number Diff line change
Expand Up @@ -1797,6 +1797,35 @@ def smart_wait_tools() -> List[MCPTool]:
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,
),
]


Expand Down
16 changes: 16 additions & 0 deletions je_auto_control/utils/mcp_server/tools/_handlers.py
Original file line number Diff line number Diff line change
Expand Up @@ -682,6 +682,22 @@ def plan_retry_delays(attempts, base=0.1, max_delay=5.0, multiplier=2.0,
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 normalize_ext(target):
from je_auto_control.utils.executor.action_executor import _normalize_ext
return _normalize_ext(target)
Expand Down
11 changes: 11 additions & 0 deletions je_auto_control/utils/verify_field/__init__.py
Original file line number Diff line number Diff line change
@@ -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",
]
101 changes: 101 additions & 0 deletions je_auto_control/utils/verify_field/verify_field.py
Original file line number Diff line number Diff line change
@@ -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
Loading
Loading