Skip to content

Commit c210b00

Browse files
committed
Add verify_field: read a field back and confirm the typed value
field_entry types and hopes — a slow IME, focus steal, input mask or auto-format can silently drop characters and nothing reads the field back. Distinct from action_effect (any near-target change) and postcondition.text_present (text anywhere). compare_field_value is the pure comparator (exact/trim/ci/normalized/contains); verify_field_value reads via an injectable reader; fill_and_verify types, reads back and retries until it matches.
1 parent dbf0ebb commit c210b00

11 files changed

Lines changed: 446 additions & 0 deletions

File tree

WHATS_NEW.md

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,12 @@
22

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

5+
### Verify a Field After Typing
6+
7+
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).
8+
9+
- **`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`.
10+
511
### Retry Budget — Deadline + Jitter
612

713
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).
Lines changed: 57 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,57 @@
1+
Verify a Field After Typing
2+
===========================
3+
4+
``field_entry`` types into a control and *hopes* it landed. A slow IME, a focus
5+
steal, an input mask or an auto-format can silently mangle or drop characters,
6+
and nothing reads the field back to notice. This is distinct from
7+
``action_effect`` (did *anything* change near the target?) and
8+
``postcondition.text_present`` (does the text appear *anywhere* on screen?) —
9+
neither confirms *this* field now equals *this* value. ``verify_field`` closes
10+
the read-back gap.
11+
12+
* :func:`compare_field_value` — pure: compare an expected and actual value under
13+
a match ``mode`` — ``exact`` / ``trim`` / ``ci`` (case-insensitive) /
14+
``normalized`` (Unicode NFKC + case-fold + whitespace) / ``contains``.
15+
* :func:`verify_field_value` — read the field through an injectable ``reader``
16+
and compare.
17+
* :func:`fill_and_verify` — type through an injectable ``filler``, read back, and
18+
retry (optionally clearing first) until it matches or attempts run out.
19+
20+
In the executor the reader is the native accessibility value, but every
21+
comparison and retry decision is pure and testable without a real control.
22+
Imports no ``PySide6``.
23+
24+
Headless API
25+
------------
26+
27+
.. code-block:: python
28+
29+
from je_auto_control import (
30+
compare_field_value, verify_field_value, fill_and_verify,
31+
)
32+
33+
compare_field_value("café", "café", mode="normalized")["match"] # True
34+
35+
# Read a control back and assert it took the value
36+
ok = verify_field_value("invoice.pdf",
37+
reader=lambda: read_control_value())["match"]
38+
39+
# Type, read back, and retry up to 3 times (clearing before each retry)
40+
fill_and_verify("2026-06-26", filler=type_into_field,
41+
reader=read_control_value, attempts=3, clear=select_all_del)
42+
43+
``fill_and_verify`` returns the final :func:`compare_field_value` result plus an
44+
``attempts`` count, so a flow can branch on a persistent mismatch instead of
45+
typing blind. ``filler`` / ``reader`` / ``clear`` are injectable, so the retry
46+
logic is fully unit-tested without a real field.
47+
48+
Executor commands
49+
-----------------
50+
51+
``AC_compare_field_value`` (``expected`` / ``actual`` / ``mode`` → ``{match,
52+
mode, expected, actual}``, pure) and ``AC_verify_field_value`` (``expected`` +
53+
``name`` / ``role`` / ``app_name`` / ``automation_id`` / ``mode`` → the match
54+
result, reading the control's value through the accessibility backend). They are
55+
the matching read-only ``ac_*`` MCP tools and Script Builder commands under
56+
**Flow**. :func:`fill_and_verify` (which wraps a typing callable) is the
57+
Python-API surface.
Lines changed: 49 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,49 @@
1+
輸入後驗證欄位
2+
==============
3+
4+
``field_entry`` 對控制項輸入後就*指望*它生效了。緩慢的 IME、焦點被搶、輸入遮罩或自動格式化都可能
5+
悄悄竄改或漏掉字元,而沒有任何東西讀回欄位來察覺。這有別於 ``action_effect``(目標附近是否有*任何*
6+
變化?)與 ``postcondition.text_present``(該文字是否出現在畫面*某處*?)——兩者都無法確認*這個*欄位
7+
現在等於*這個*值。``verify_field`` 補上讀回這道缺口。
8+
9+
* :func:`compare_field_value` ——純函式:在某個比對 ``mode`` 下比較預期與實際值——
10+
``exact`` / ``trim`` / ``ci``(不分大小寫)/ ``normalized``(Unicode NFKC + 大小寫摺疊 + 空白)/
11+
``contains``。
12+
* :func:`verify_field_value` ——透過可注入的 ``reader`` 讀回欄位並比較。
13+
* :func:`fill_and_verify` ——透過可注入的 ``filler`` 輸入、讀回、並重試(可選擇先清空),
14+
直到相符或用完次數。
15+
16+
在執行器中,reader 即原生無障礙值,但每個比較與重試決策都是純函式,可在沒有真實控制項的情況下測試。
17+
不匯入 ``PySide6``。
18+
19+
無頭 API
20+
--------
21+
22+
.. code-block:: python
23+
24+
from je_auto_control import (
25+
compare_field_value, verify_field_value, fill_and_verify,
26+
)
27+
28+
compare_field_value("café", "café", mode="normalized")["match"] # True
29+
30+
# 讀回控制項並斷言它取得了該值
31+
ok = verify_field_value("invoice.pdf",
32+
reader=lambda: read_control_value())["match"]
33+
34+
# 輸入、讀回、最多重試 3 次(每次重試前先清空)
35+
fill_and_verify("2026-06-26", filler=type_into_field,
36+
reader=read_control_value, attempts=3, clear=select_all_del)
37+
38+
``fill_and_verify`` 回傳最終的 :func:`compare_field_value` 結果加上 ``attempts`` 次數,
39+
讓流程能在持續不符時分支處理,而非盲目輸入。``filler`` / ``reader`` / ``clear`` 皆可注入,
40+
故重試邏輯能在沒有真實欄位的情況下完整測試。
41+
42+
執行器指令
43+
----------
44+
45+
``AC_compare_field_value``(``expected`` / ``actual`` / ``mode`` → ``{match,
46+
mode, expected, actual}``,純函式)與 ``AC_verify_field_value``(``expected`` 加上
47+
``name`` / ``role`` / ``app_name`` / ``automation_id`` / ``mode`` → 比對結果,
48+
透過無障礙後端讀取控制項的值)。皆以對應的唯讀 ``ac_*`` MCP 工具及 Script Builder 指令
49+
(位於 **Flow** 分類下)形式提供。:func:`fill_and_verify`(包裹一個輸入 callable)則是 Python API 介面。

je_auto_control/__init__.py

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -119,6 +119,10 @@
119119
from je_auto_control.utils.retry_budget import (
120120
RetryBudget, backoff_delay, jittered_delay, run_with_budget,
121121
)
122+
# Read a field back after typing and confirm the intended value landed
123+
from je_auto_control.utils.verify_field import (
124+
compare_field_value, fill_and_verify, verify_field_value,
125+
)
122126
# Rich clipboard formats — RTF + CSV/TSV codecs and Windows get / set
123127
from je_auto_control.utils.clipboard_rich_formats import (
124128
build_rtf, csv_to_rows, get_clipboard_csv, get_clipboard_rtf, rows_to_csv,
@@ -1739,6 +1743,7 @@ def start_autocontrol_gui(*args, **kwargs):
17391743
"ime_state", "is_composing", "wait_for_composition_commit",
17401744
"decode_conversion_mode",
17411745
"RetryBudget", "run_with_budget", "backoff_delay", "jittered_delay",
1746+
"compare_field_value", "verify_field_value", "fill_and_verify",
17421747
"build_rtf", "rtf_to_text", "rows_to_csv", "csv_to_rows",
17431748
"set_clipboard_rtf", "get_clipboard_rtf",
17441749
"set_clipboard_csv", "get_clipboard_csv",

je_auto_control/gui/script_builder/command_schema.py

Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4431,6 +4431,33 @@ def _add_work_queue_specs(specs: List[CommandSpec]) -> None:
44314431
),
44324432
description="The backoff delay schedule for the first N retries.",
44334433
))
4434+
specs.append(CommandSpec(
4435+
"AC_compare_field_value", "Flow", "Compare Field Value",
4436+
fields=(
4437+
FieldSpec("expected", FieldType.STRING, placeholder="expected"),
4438+
FieldSpec("actual", FieldType.STRING, placeholder="actual"),
4439+
FieldSpec("mode", FieldType.STRING, optional=True, default="exact",
4440+
placeholder="exact / trim / ci / normalized / contains"),
4441+
),
4442+
description="Compare expected vs actual field value under a mode.",
4443+
))
4444+
specs.append(CommandSpec(
4445+
"AC_verify_field_value", "Flow", "Verify Field Value",
4446+
fields=(
4447+
FieldSpec("expected", FieldType.STRING, placeholder="expected"),
4448+
FieldSpec("name", FieldType.STRING, optional=True,
4449+
placeholder="control name"),
4450+
FieldSpec("role", FieldType.STRING, optional=True,
4451+
placeholder="control role"),
4452+
FieldSpec("app_name", FieldType.STRING, optional=True,
4453+
placeholder="app name"),
4454+
FieldSpec("automation_id", FieldType.STRING, optional=True,
4455+
placeholder="automation id"),
4456+
FieldSpec("mode", FieldType.STRING, optional=True, default="exact",
4457+
placeholder="exact / trim / ci / normalized / contains"),
4458+
),
4459+
description="Read a control's value back and confirm it equals expected.",
4460+
))
44344461
specs.append(CommandSpec(
44354462
"AC_normalize_ext", "Shell", "Normalize Extension",
44364463
fields=(

je_auto_control/utils/executor/action_executor.py

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2761,6 +2761,28 @@ def _plan_retry_delays(attempts: Any, base: Any = 0.1, max_delay: Any = 5.0,
27612761
return {"delays": [float(d) for d in budget.plan(int(attempts))]}
27622762

27632763

2764+
def _compare_field_value(expected: Any, actual: Any,
2765+
mode: Any = "exact") -> Dict[str, Any]:
2766+
"""Adapter: compare an expected vs actual field value under a mode (pure)."""
2767+
from je_auto_control.utils.verify_field import compare_field_value
2768+
return compare_field_value(expected, actual, mode=str(mode))
2769+
2770+
2771+
def _verify_field_value(expected: Any, name: Optional[str] = None,
2772+
role: Optional[str] = None,
2773+
app_name: Optional[str] = None,
2774+
automation_id: Optional[str] = None,
2775+
mode: Any = "exact") -> Dict[str, Any]:
2776+
"""Adapter: read a native control's value back and compare to expected."""
2777+
from je_auto_control.utils.verify_field import verify_field_value
2778+
return verify_field_value(
2779+
expected,
2780+
reader=lambda: _control_get_value(name=name, role=role,
2781+
app_name=app_name,
2782+
automation_id=automation_id),
2783+
mode=str(mode))
2784+
2785+
27642786
def _normalize_ext(target: str) -> Dict[str, Any]:
27652787
"""Adapter: the lowercased extension of a path / bare ext (pure)."""
27662788
from je_auto_control.utils.file_assoc import normalize_ext
@@ -6785,6 +6807,8 @@ def __init__(self):
67856807
"AC_decode_conversion_mode": _decode_conversion_mode,
67866808
"AC_retry_delay": _retry_delay,
67876809
"AC_plan_retry_delays": _plan_retry_delays,
6810+
"AC_compare_field_value": _compare_field_value,
6811+
"AC_verify_field_value": _verify_field_value,
67886812
"AC_normalize_ext": _normalize_ext,
67896813
"AC_file_association": _file_association,
67906814
"AC_get_control_text": _get_control_text,

je_auto_control/utils/mcp_server/tools/_factories.py

Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1797,6 +1797,35 @@ def smart_wait_tools() -> List[MCPTool]:
17971797
handler=h.plan_retry_delays,
17981798
annotations=READ_ONLY,
17991799
),
1800+
MCPTool(
1801+
name="ac_compare_field_value",
1802+
description=("Compare an 'expected' vs 'actual' field value under a "
1803+
"match 'mode' (exact / trim / ci / normalized / "
1804+
"contains). Pure. Returns {match, mode, expected, "
1805+
"actual}."),
1806+
input_schema=schema({"expected": {"type": "string"},
1807+
"actual": {"type": "string"},
1808+
"mode": {"type": "string"}},
1809+
required=["expected", "actual"]),
1810+
handler=h.compare_field_value,
1811+
annotations=READ_ONLY,
1812+
),
1813+
MCPTool(
1814+
name="ac_verify_field_value",
1815+
description=("Read a native control's value back (accessibility) "
1816+
"and confirm it equals 'expected' under match 'mode'. "
1817+
"Identify the control by name / role / app_name / "
1818+
"automation_id. Returns {match, expected, actual}."),
1819+
input_schema=schema({"expected": {"type": "string"},
1820+
"name": {"type": "string"},
1821+
"role": {"type": "string"},
1822+
"app_name": {"type": "string"},
1823+
"automation_id": {"type": "string"},
1824+
"mode": {"type": "string"}},
1825+
required=["expected"]),
1826+
handler=h.verify_field_value,
1827+
annotations=READ_ONLY,
1828+
),
18001829
]
18011830

18021831

je_auto_control/utils/mcp_server/tools/_handlers.py

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -682,6 +682,22 @@ def plan_retry_delays(attempts, base=0.1, max_delay=5.0, multiplier=2.0,
682682
return _plan_retry_delays(attempts, base, max_delay, multiplier, jitter)
683683

684684

685+
def compare_field_value(expected, actual, mode="exact"):
686+
from je_auto_control.utils.executor.action_executor import (
687+
_compare_field_value,
688+
)
689+
return _compare_field_value(expected, actual, mode)
690+
691+
692+
def verify_field_value(expected, name=None, role=None, app_name=None,
693+
automation_id=None, mode="exact"):
694+
from je_auto_control.utils.executor.action_executor import (
695+
_verify_field_value,
696+
)
697+
return _verify_field_value(expected, name, role, app_name, automation_id,
698+
mode)
699+
700+
685701
def normalize_ext(target):
686702
from je_auto_control.utils.executor.action_executor import _normalize_ext
687703
return _normalize_ext(target)
Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,11 @@
1+
"""Read a field back after typing and confirm it holds the intended value."""
2+
from je_auto_control.utils.verify_field.verify_field import (
3+
MATCH_CI, MATCH_CONTAINS, MATCH_EXACT, MATCH_NORMALIZED, MATCH_TRIM,
4+
compare_field_value, fill_and_verify, verify_field_value,
5+
)
6+
7+
__all__ = [
8+
"compare_field_value", "verify_field_value", "fill_and_verify",
9+
"MATCH_EXACT", "MATCH_TRIM", "MATCH_CI", "MATCH_NORMALIZED",
10+
"MATCH_CONTAINS",
11+
]
Lines changed: 101 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,101 @@
1+
"""Read a field back after typing and confirm it holds the intended value.
2+
3+
``field_entry`` types into a control and *hopes* it landed: a slow IME, a focus
4+
steal, an input mask or an auto-format can silently mangle or drop characters,
5+
and nothing reads the field back to notice. This is distinct from
6+
``action_effect`` (did *anything* change near the target?) and
7+
``postcondition.text_present`` (does the text appear *anywhere* on screen?) —
8+
neither confirms *this* field now equals *this* value.
9+
10+
* :func:`compare_field_value` — pure: compare an expected and actual value under
11+
a match ``mode`` (``exact`` / ``trim`` / ``ci`` / ``normalized`` /
12+
``contains``).
13+
* :func:`verify_field_value` — read the field through an injectable ``reader``
14+
and compare.
15+
* :func:`fill_and_verify` — type through an injectable ``filler``, read back, and
16+
retry (optionally clearing first) until it matches or attempts run out.
17+
18+
The reader / filler seams default to the native accessibility value in the
19+
executor, but every comparison and retry decision is pure and testable without a
20+
real control. Imports no ``PySide6``.
21+
"""
22+
from typing import Any, Callable, Dict, Optional
23+
24+
# Match modes.
25+
MATCH_EXACT = "exact"
26+
MATCH_TRIM = "trim"
27+
MATCH_CI = "ci"
28+
MATCH_NORMALIZED = "normalized"
29+
MATCH_CONTAINS = "contains"
30+
31+
# A reader returns the field's current value; a filler types a value into it.
32+
FieldReader = Callable[[], Optional[str]]
33+
FieldFiller = Callable[[str], None]
34+
35+
36+
def _canonical(text: str, mode: str) -> str:
37+
"""Canonicalize ``text`` for comparison under ``mode`` (pure)."""
38+
if mode in (MATCH_TRIM, MATCH_CI, MATCH_CONTAINS):
39+
text = text.strip()
40+
if mode in (MATCH_CI, MATCH_CONTAINS):
41+
text = text.casefold()
42+
if mode == MATCH_NORMALIZED:
43+
from je_auto_control.utils.text_normalize import normalize_text
44+
return normalize_text(text)
45+
return text
46+
47+
48+
def _as_text(value: Any) -> str:
49+
"""Coerce a value to a string, treating ``None`` as empty."""
50+
return "" if value is None else str(value)
51+
52+
53+
def compare_field_value(expected: Any, actual: Any, *,
54+
mode: str = MATCH_EXACT) -> Dict[str, Any]:
55+
"""Compare ``expected`` against ``actual`` under ``mode`` (pure).
56+
57+
Returns ``{match, mode, expected, actual}``. ``contains`` is a (trimmed,
58+
case-insensitive) substring test; the others compare canonical equality.
59+
"""
60+
expected_text = _as_text(expected)
61+
actual_text = _as_text(actual)
62+
if mode == MATCH_CONTAINS:
63+
match = _canonical(expected_text, mode) in _canonical(actual_text, mode)
64+
else:
65+
match = _canonical(expected_text, mode) == _canonical(actual_text, mode)
66+
return {"match": bool(match), "mode": mode,
67+
"expected": expected_text, "actual": actual_text}
68+
69+
70+
def verify_field_value(expected: Any, *, reader: FieldReader,
71+
mode: str = MATCH_EXACT) -> Dict[str, Any]:
72+
"""Read the field via ``reader`` and compare it to ``expected``.
73+
74+
Returns the :func:`compare_field_value` result for the value read back.
75+
"""
76+
return compare_field_value(expected, reader(), mode=mode)
77+
78+
79+
def fill_and_verify(value: Any, *, filler: FieldFiller, reader: FieldReader,
80+
attempts: int = 2, mode: str = MATCH_EXACT,
81+
clear: Optional[Callable[[], None]] = None
82+
) -> Dict[str, Any]:
83+
"""Type ``value`` via ``filler``, read it back, and retry until it matches.
84+
85+
Up to ``attempts`` tries; before each retry (not the first) ``clear`` is
86+
called if supplied. Returns the final :func:`compare_field_value` result
87+
with an added ``attempts`` count.
88+
"""
89+
total = max(1, int(attempts))
90+
result: Dict[str, Any] = compare_field_value(value, None, mode=mode)
91+
used = 0
92+
for used in range(1, total + 1):
93+
if clear is not None and used > 1:
94+
clear()
95+
filler(value)
96+
result = compare_field_value(value, reader(), mode=mode)
97+
if result["match"]:
98+
break
99+
result = dict(result)
100+
result["attempts"] = used
101+
return result

0 commit comments

Comments
 (0)