diff --git a/README/WHATS_NEW_zh-CN.md b/README/WHATS_NEW_zh-CN.md index d2d9607f..1c049791 100644 --- a/README/WHATS_NEW_zh-CN.md +++ b/README/WHATS_NEW_zh-CN.md @@ -1,5 +1,11 @@ # 本次更新 — AutoControl +## 本次更新 (2026-06-23) — 等待窗口标题(正则) + +阻塞直到窗口标题符合正则(或消失)。完整参考:[`docs/source/Zh/doc/new_features/v126_features_doc.rst`](../docs/source/Zh/doc/new_features/v126_features_doc.rst)。 + +- **`wait_until_window_title`**(`AC_wait_window_title`):`wait_for_window` 以子字符串比对且仅等*出现*;`wait_until_window_closed` 为子字符串消失。本功能默认以正则表达式比对(`regex=False` 改子字符串),并可等待标题消失(`present=False`)——例如等标签页导览至 `r".*— Checkout$"`。标题来源可注入、无头可测。 + ## 本次更新 (2026-06-23) — 表格 / 网格单元格定位 依(行、列)从单元格边界框定位表格单元格。完整参考:[`docs/source/Zh/doc/new_features/v125_features_doc.rst`](../docs/source/Zh/doc/new_features/v125_features_doc.rst)。 diff --git a/README/WHATS_NEW_zh-TW.md b/README/WHATS_NEW_zh-TW.md index 81837914..4f13f6e8 100644 --- a/README/WHATS_NEW_zh-TW.md +++ b/README/WHATS_NEW_zh-TW.md @@ -1,5 +1,11 @@ # 本次更新 — AutoControl +## 本次更新 (2026-06-23) — 等待視窗標題(正則) + +阻塞直到視窗標題符合正則(或消失)。完整參考:[`docs/source/Zh/doc/new_features/v126_features_doc.rst`](../docs/source/Zh/doc/new_features/v126_features_doc.rst)。 + +- **`wait_until_window_title`**(`AC_wait_window_title`):`wait_for_window` 以子字串比對且僅等*出現*;`wait_until_window_closed` 為子字串消失。本功能預設以正則表達式比對(`regex=False` 改子字串),並可等待標題消失(`present=False`)——例如等分頁導覽至 `r".*— Checkout$"`。標題來源可注入、無頭可測。 + ## 本次更新 (2026-06-23) — 表格 / 格線儲存格定位 依(列、欄)從儲存格邊界框定位表格儲存格。完整參考:[`docs/source/Zh/doc/new_features/v125_features_doc.rst`](../docs/source/Zh/doc/new_features/v125_features_doc.rst)。 diff --git a/WHATS_NEW.md b/WHATS_NEW.md index 41b21564..9d63c13d 100644 --- a/WHATS_NEW.md +++ b/WHATS_NEW.md @@ -1,5 +1,11 @@ # What's New — AutoControl +## What's new (2026-06-23) — Wait for Window Title (Regex) + +Block until a window title matches a regex (or vanishes). Full reference: [`docs/source/Eng/doc/new_features/v126_features_doc.rst`](docs/source/Eng/doc/new_features/v126_features_doc.rst). + +- **`wait_until_window_title`** (`AC_wait_window_title`): `wait_for_window` matches a title substring and only waits for *appear*; `wait_until_window_closed` is substring vanish. This matches a regular expression by default (`regex=False` for substring) and can wait for the title to vanish (`present=False`) — e.g. wait for a tab to navigate to `r".*— Checkout$"`. Injectable title source, headless-testable. + ## What's new (2026-06-23) — Grid / Table Cell Addressing Address a table cell by (row, column) from cell bounding boxes. Full reference: [`docs/source/Eng/doc/new_features/v125_features_doc.rst`](docs/source/Eng/doc/new_features/v125_features_doc.rst). diff --git a/docs/source/Eng/doc/new_features/v126_features_doc.rst b/docs/source/Eng/doc/new_features/v126_features_doc.rst new file mode 100644 index 00000000..bf223832 --- /dev/null +++ b/docs/source/Eng/doc/new_features/v126_features_doc.rst @@ -0,0 +1,35 @@ +Wait for Window Title (Regex) +============================= + +``wait_for_window`` matches a window title by *substring* and only waits for it to +*appear*; ``wait_until_window_closed`` is the substring vanish. Neither supports a +regular-expression title or "wait until the active window's title matches P" — +e.g. waiting for a browser tab to finish navigating to ``r".*— Checkout$"``. This +adds a regex title wait to the ``smart_waits`` family. + +The title source is injectable, so the loop is headless-testable without real +windows. Imports no ``PySide6``. + +Headless API +------------ + +.. code-block:: python + + from je_auto_control import wait_until_window_title + + wait_until_window_title(r".*— Checkout$", timeout_s=20) # tab navigated + wait_until_window_title("Updating", present=False) # dialog gone + wait_until_window_title("Checkout", regex=False) # substring mode + +By default ``pattern`` is a regular expression (``re.search``); pass +``regex=False`` for a plain substring test. ``present=False`` waits for the title +to *vanish*. The result is a ``WaitOutcome`` (``succeeded`` / ``reason`` / +``elapsed_s`` / ``samples_taken``); ``title_lister`` is injectable for tests. + +Executor commands +----------------- + +``AC_wait_window_title`` takes ``pattern`` plus ``present`` / ``regex`` / +``timeout_s`` / ``poll_interval_s`` and returns the ``WaitOutcome`` dict. It is +exposed as the MCP tool ``ac_wait_window_title`` and as a Script Builder command +under **Flow**. diff --git a/docs/source/Eng/eng_index.rst b/docs/source/Eng/eng_index.rst index 55dee8ae..b0ebbbc7 100644 --- a/docs/source/Eng/eng_index.rst +++ b/docs/source/Eng/eng_index.rst @@ -148,6 +148,7 @@ Comprehensive guides for all AutoControl features. doc/new_features/v123_features_doc doc/new_features/v124_features_doc doc/new_features/v125_features_doc + doc/new_features/v126_features_doc doc/ocr_backends/ocr_backends_doc doc/observability/observability_doc doc/operations_layer/operations_layer_doc diff --git a/docs/source/Zh/doc/new_features/v126_features_doc.rst b/docs/source/Zh/doc/new_features/v126_features_doc.rst new file mode 100644 index 00000000..9f75b10c --- /dev/null +++ b/docs/source/Zh/doc/new_features/v126_features_doc.rst @@ -0,0 +1,30 @@ +等待視窗標題(正則) +================== + +``wait_for_window`` 以*子字串*比對視窗標題且僅等待其*出現*;``wait_until_window_closed`` 為子字串消失。兩者都 +不支援正則表達式標題或「等到使用中視窗標題符合 P」——例如等待瀏覽器分頁導覽至 ``r".*— Checkout$"``。本功能 +為 ``smart_waits`` 家族加入正則標題等待。 + +標題來源可注入,因此迴圈可在無真實視窗下做無頭測試。不匯入 ``PySide6``。 + +無頭 API +-------- + +.. code-block:: python + + from je_auto_control import wait_until_window_title + + wait_until_window_title(r".*— Checkout$", timeout_s=20) # 分頁已導覽 + wait_until_window_title("Updating", present=False) # 對話框消失 + wait_until_window_title("Checkout", regex=False) # 子字串模式 + +預設 ``pattern`` 為正則表達式(``re.search``);傳入 ``regex=False`` 改用純子字串比對。``present=False`` 等待 +標題*消失*。結果為 ``WaitOutcome``(``succeeded`` / ``reason`` / ``elapsed_s`` / ``samples_taken``); +``title_lister`` 可注入以供測試。 + +執行器命令 +---------- + +``AC_wait_window_title`` 接受 ``pattern`` 以及 ``present`` / ``regex`` / ``timeout_s`` / ``poll_interval_s``, +並回傳 ``WaitOutcome`` dict。它以 MCP 工具 ``ac_wait_window_title`` 以及 Script Builder 中 **Flow** 分類下的命令 +提供。 diff --git a/docs/source/Zh/zh_index.rst b/docs/source/Zh/zh_index.rst index dedefc4a..cc6842f4 100644 --- a/docs/source/Zh/zh_index.rst +++ b/docs/source/Zh/zh_index.rst @@ -148,6 +148,7 @@ AutoControl 所有功能的完整使用指南。 doc/new_features/v123_features_doc doc/new_features/v124_features_doc doc/new_features/v125_features_doc + doc/new_features/v126_features_doc doc/ocr_backends/ocr_backends_doc doc/observability/observability_doc doc/operations_layer/operations_layer_doc diff --git a/je_auto_control/__init__.py b/je_auto_control/__init__.py index 8422955c..9959357b 100644 --- a/je_auto_control/__init__.py +++ b/je_auto_control/__init__.py @@ -624,7 +624,7 @@ wait_until_file, wait_until_gone, wait_until_image_gone, wait_until_pixel_changes, wait_until_port, wait_until_process, wait_until_region_idle, wait_until_screen_stable, wait_until_text_gone, - wait_until_window_closed, + wait_until_window_closed, wait_until_window_title, ) # Visual regression (golden-image comparison) from je_auto_control.utils.visual_regression import ( @@ -1261,7 +1261,7 @@ def start_autocontrol_gui(*args, **kwargs): "wait_until_clipboard_changes", "wait_until_window_closed", "wait_until_file", "wait_until_port", "wait_until_process", "wait_until_gone", "wait_until_image_gone", "wait_until_text_gone", - "wait_until_color", + "wait_until_color", "wait_until_window_title", # Visual regression + state machine "take_golden", "compare_to_golden", "image_difference", "DiffResult", "MaskRegion", diff --git a/je_auto_control/gui/script_builder/command_schema.py b/je_auto_control/gui/script_builder/command_schema.py index 53349035..28443251 100644 --- a/je_auto_control/gui/script_builder/command_schema.py +++ b/je_auto_control/gui/script_builder/command_schema.py @@ -512,6 +512,18 @@ def _add_flow_specs(specs: List[CommandSpec]) -> None: ), description="Block until a colour fills (or leaves) a screen region.", )) + specs.append(CommandSpec( + "AC_wait_window_title", "Flow", "Wait for Window Title", + fields=( + FieldSpec("pattern", FieldType.STRING, placeholder="Checkout$"), + FieldSpec("present", FieldType.BOOL, optional=True, default=True), + FieldSpec("regex", FieldType.BOOL, optional=True, default=True), + FieldSpec("timeout_s", FieldType.FLOAT, optional=True, default=10.0), + FieldSpec("poll_interval_s", FieldType.FLOAT, optional=True, + default=0.2, min_value=0.01), + ), + description="Block until a window title matches a regex (or vanishes).", + )) specs.append(CommandSpec( "AC_loop", "Flow", "Loop (N times)", fields=( diff --git a/je_auto_control/utils/executor/action_executor.py b/je_auto_control/utils/executor/action_executor.py index 9220e20f..9d5e800e 100644 --- a/je_auto_control/utils/executor/action_executor.py +++ b/je_auto_control/utils/executor/action_executor.py @@ -384,6 +384,17 @@ def _wait_text_gone(text: str, timeout_s: float = 10.0, ).to_dict() +def _wait_window_title(pattern: str, present: bool = True, regex: bool = True, + timeout_s: float = 10.0, + poll_interval_s: float = 0.2) -> Dict[str, Any]: + """Executor adapter: wait for a window title (regex) to appear / vanish.""" + from je_auto_control.utils.smart_waits import wait_until_window_title + return wait_until_window_title( + pattern, present=bool(present), regex=bool(regex), + timeout_s=float(timeout_s), poll_interval_s=float(poll_interval_s), + ).to_dict() + + def _wait_color(target_rgb: Any, region: Any = None, tolerance: int = 10, min_fraction: float = 0.5, present: bool = True, timeout_s: float = 10.0, @@ -5080,6 +5091,7 @@ def __init__(self): "AC_wait_image_gone": _wait_image_gone, "AC_wait_text_gone": _wait_text_gone, "AC_wait_color": _wait_color, + "AC_wait_window_title": _wait_window_title, "AC_wait_window_closed": _wait_window_closed, # Cost telemetry (LLM token + USD tracking) diff --git a/je_auto_control/utils/mcp_server/tools/_factories.py b/je_auto_control/utils/mcp_server/tools/_factories.py index c22c806c..eefd80c2 100644 --- a/je_auto_control/utils/mcp_server/tools/_factories.py +++ b/je_auto_control/utils/mcp_server/tools/_factories.py @@ -1323,6 +1323,21 @@ def smart_wait_tools() -> List[MCPTool]: handler=h.wait_color, annotations=READ_ONLY, ), + MCPTool( + name="ac_wait_window_title", + description=("Block until a window title matches 'pattern' (a regex " + "by default; regex=False for substring) — or vanishes " + "with present=False. e.g. r'.*— Checkout$'."), + input_schema=schema({ + "pattern": {"type": "string"}, + "present": {"type": "boolean"}, + "regex": {"type": "boolean"}, + "timeout_s": {"type": "number"}, + "poll_interval_s": {"type": "number"}}, + required=["pattern"]), + handler=h.wait_window_title, + annotations=READ_ONLY, + ), MCPTool( name="ac_wait_screen_stable", description=("Block until the screen stops moving (consecutive " diff --git a/je_auto_control/utils/mcp_server/tools/_handlers.py b/je_auto_control/utils/mcp_server/tools/_handlers.py index 7350d385..a46b281c 100644 --- a/je_auto_control/utils/mcp_server/tools/_handlers.py +++ b/je_auto_control/utils/mcp_server/tools/_handlers.py @@ -2465,6 +2465,13 @@ def wait_color(target_rgb, region=None, tolerance=10, min_fraction=0.5, present, timeout_s, poll_interval_s) +def wait_window_title(pattern, present=True, regex=True, timeout_s=10.0, + poll_interval_s=0.2): + from je_auto_control.utils.executor.action_executor import _wait_window_title + return _wait_window_title(pattern, present, regex, timeout_s, + poll_interval_s) + + def wait_for_file(path: str, timeout_s: float = 30.0, poll_interval_s: float = 0.25, stable_for_s: float = 1.0, diff --git a/je_auto_control/utils/smart_waits/__init__.py b/je_auto_control/utils/smart_waits/__init__.py index 2b179962..8c3315ae 100644 --- a/je_auto_control/utils/smart_waits/__init__.py +++ b/je_auto_control/utils/smart_waits/__init__.py @@ -9,20 +9,21 @@ """ from je_auto_control.utils.smart_waits.waits import ( ClipboardReader, FileStatReader, Frame, PortConnector, ProcessLister, - ScreenSampler, WaitOutcome, WindowFinder, wait_until_clipboard_changes, - wait_until_color, wait_until_file, wait_until_gone, wait_until_image_gone, - wait_until_pixel_changes, wait_until_port, wait_until_process, - wait_until_region_idle, wait_until_screen_stable, wait_until_text_gone, - wait_until_window_closed, + ScreenSampler, WaitOutcome, WindowFinder, WindowTitleLister, + wait_until_clipboard_changes, wait_until_color, wait_until_file, + wait_until_gone, wait_until_image_gone, wait_until_pixel_changes, + wait_until_port, wait_until_process, wait_until_region_idle, + wait_until_screen_stable, wait_until_text_gone, wait_until_window_closed, + wait_until_window_title, ) __all__ = [ "ClipboardReader", "FileStatReader", "Frame", "PortConnector", "ProcessLister", "ScreenSampler", "WaitOutcome", "WindowFinder", - "wait_until_clipboard_changes", "wait_until_color", "wait_until_file", - "wait_until_gone", "wait_until_image_gone", "wait_until_pixel_changes", - "wait_until_port", "wait_until_process", "wait_until_region_idle", - "wait_until_screen_stable", "wait_until_text_gone", - "wait_until_window_closed", + "WindowTitleLister", "wait_until_clipboard_changes", "wait_until_color", + "wait_until_file", "wait_until_gone", "wait_until_image_gone", + "wait_until_pixel_changes", "wait_until_port", "wait_until_process", + "wait_until_region_idle", "wait_until_screen_stable", "wait_until_text_gone", + "wait_until_window_closed", "wait_until_window_title", ] diff --git a/je_auto_control/utils/smart_waits/waits.py b/je_auto_control/utils/smart_waits/waits.py index 3a7cd7fd..fd597096 100644 --- a/je_auto_control/utils/smart_waits/waits.py +++ b/je_auto_control/utils/smart_waits/waits.py @@ -18,6 +18,7 @@ """ from __future__ import annotations +import re import time from dataclasses import asdict, dataclass from typing import Any, Callable, Dict, List, Optional, Sequence, Tuple @@ -238,6 +239,52 @@ def _default_window_finder(title: str, case_sensitive: bool) -> bool: return find_window(title, case_sensitive=case_sensitive) is not None +WindowTitleLister = Callable[[], List[str]] + + +def _title_matches(titles: List[str], pattern: str, + compiled: Optional["re.Pattern"]) -> bool: + if compiled is not None: + return any(compiled.search(title) for title in titles) + return any(pattern in title for title in titles) + + +def wait_until_window_title(pattern: str, *, present: bool = True, + regex: bool = True, timeout_s: float = 10.0, + poll_interval_s: float = 0.2, + title_lister: Optional[WindowTitleLister] = None + ) -> WaitOutcome: + """Wait until a window whose title matches ``pattern`` appears (or vanishes). + + Unlike ``wait_for_window`` (substring, appear only) this matches a regular + expression by default (``regex=False`` falls back to a substring test) and + can wait for the title to *vanish* with ``present=False`` — e.g. wait for a + browser tab to navigate to ``r".*— Checkout$"``. ``title_lister() -> [titles]`` + is injectable for tests. + """ + if timeout_s <= 0: + raise ValueError(_TIMEOUT_POSITIVE) + if poll_interval_s <= 0: + raise ValueError(_POLL_POSITIVE) + titles_of = title_lister or _default_title_lister + compiled = re.compile(pattern) if regex else None + started = time.monotonic() + deadline = started + float(timeout_s) + samples = 0 + while time.monotonic() < deadline: + samples += 1 + if _title_matches(titles_of(), pattern, compiled) == bool(present): + return _finish(True, "window title condition met", started, samples) + time.sleep(float(poll_interval_s)) + return _finish(False, "timeout while waiting for window title", + started, samples) + + +def _default_title_lister() -> List[str]: + from je_auto_control.wrapper.auto_control_window import list_windows + return [title for _id, title in list_windows()] + + FileStatReader = Callable[[str], Optional[int]] diff --git a/test/unit_test/headless/test_wait_window_title_batch.py b/test/unit_test/headless/test_wait_window_title_batch.py new file mode 100644 index 00000000..65a7c6da --- /dev/null +++ b/test/unit_test/headless/test_wait_window_title_batch.py @@ -0,0 +1,67 @@ +"""Headless tests for wait-until-window-title. No Qt.""" +import je_auto_control as ac +from je_auto_control.utils.smart_waits import ( + WaitOutcome, wait_until_window_title, +) + + +def test_regex_title_appears(): + # title list flips to include a match on the 2nd poll + state = {"polls": 0} + + def lister(): + state["polls"] += 1 + return ["Editor"] if state["polls"] < 2 else ["Shop — Checkout"] + + outcome = wait_until_window_title(r".*— Checkout$", timeout_s=5.0, + poll_interval_s=0.001, title_lister=lister) + assert isinstance(outcome, WaitOutcome) and outcome.succeeded is True + + +def test_substring_mode(): + outcome = wait_until_window_title( + "Checkout", regex=False, timeout_s=5.0, poll_interval_s=0.001, + title_lister=lambda: ["My Shop Checkout Page"]) + assert outcome.succeeded is True + + +def test_wait_for_vanish(): + outcome = wait_until_window_title( + "Loading", present=False, timeout_s=5.0, poll_interval_s=0.001, + title_lister=lambda: ["Done"]) + assert outcome.succeeded is True # no title matches -> vanished + + +def test_timeout_when_never_matches(): + outcome = wait_until_window_title( + "Nope", timeout_s=0.03, poll_interval_s=0.001, + title_lister=lambda: ["Editor", "Browser"]) + assert outcome.succeeded is False and "timeout" in outcome.reason + + +def test_validation(): + for bad in ({"timeout_s": 0}, {"poll_interval_s": 0}): + try: + wait_until_window_title("x", title_lister=lambda: [], **bad) + except ValueError: + pass + else: # pragma: no cover + raise AssertionError("expected ValueError") + + +# --- wiring --------------------------------------------------------------- + +def test_wiring(): + known = ac.executor.known_commands() + assert "AC_wait_window_title" in set(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_window_title" in names + from je_auto_control.gui.script_builder.command_schema import _build_specs + specs = {s.command for s in _build_specs()} + assert "AC_wait_window_title" in specs + + +def test_facade_exports(): + assert hasattr(ac, "wait_until_window_title") + assert "wait_until_window_title" in ac.__all__