diff --git a/WHATS_NEW.md b/WHATS_NEW.md index bb61e333..41e34e20 100644 --- a/WHATS_NEW.md +++ b/WHATS_NEW.md @@ -1,5 +1,11 @@ # What's New — AutoControl +## What's new (2026-06-25) — Move / Resize Elements + Window State (UIA Transform + Window) + +Move a floating panel, resize a control, and know if a window is modal-blocked. Full reference: [`docs/source/Eng/doc/new_features/v198_features_doc.rst`](docs/source/Eng/doc/new_features/v198_features_doc.rst). + +- **`move_element` / `resize_element` / `set_window_state` / `window_interaction_state`** (`AC_move_element`, `AC_resize_element`, `AC_set_window_state`, `AC_window_interaction_state`): this is UIA-**element-level**, not the HWND/title-level geometry in `window_layout`. `TransformPattern` moves/resizes a specific control or floating panel (dockable toolbars, MDI children, splitters) with no top-level window of its own; `WindowPattern` minimizes/maximizes a window and reports its **interaction state** (`ready` / `blocked_by_modal` / `not_responding`) — a reliable "is this window ready or modal-blocked?" signal pixel/title polling can't give. Dispatched through the injectable accessibility backend seam (headless-testable via a fake backend; real UIA in the Windows backend). No `PySide6`. + ## What's new (2026-06-25) — Table Headers + Cell Addressing (UIA TablePattern) Assert "the Status column of row 5 says Shipped" — by header, not by guessing indices. Full reference: [`docs/source/Eng/doc/new_features/v197_features_doc.rst`](docs/source/Eng/doc/new_features/v197_features_doc.rst). diff --git a/docs/source/Eng/doc/new_features/v198_features_doc.rst b/docs/source/Eng/doc/new_features/v198_features_doc.rst new file mode 100644 index 00000000..9743c2bd --- /dev/null +++ b/docs/source/Eng/doc/new_features/v198_features_doc.rst @@ -0,0 +1,46 @@ +Move / Resize Elements + Window State (UIA Transform + Window) +============================================================== + +This is **UIA-element-level**, not the HWND / title-level geometry in +``window_layout`` / ``window_geometry``. ``TransformPattern`` moves and resizes a +specific control or floating panel (dockable toolbars, MDI children, splitters) +that has no top-level window of its own; ``WindowPattern`` minimizes / maximizes a +window and — most usefully — reports its **interaction state**, a reliable "is +this window ready or modal-blocked?" signal that pixel or title polling can't give. + +* :func:`move_element` / :func:`resize_element` — TransformPattern, +* :func:`set_window_state` — minimize / maximize / restore, +* :func:`window_interaction_state` — the readiness / modal-blocked signal. + +Each is a thin dispatch onto the injectable ``accessibility.backends.get_backend()`` +seam — headless-testable via a fake backend; the real UIA calls live in the +Windows backend. Imports no ``PySide6``. + +Headless API +------------ + +.. code-block:: python + + from je_auto_control import (move_element, resize_element, + set_window_state, window_interaction_state) + + move_element(100, 200, name="Tool Palette") # reposition a floating panel + resize_element(640, 480, name="Preview") # resize a control + set_window_state("maximized", name="Editor") # normal / maximized / minimized + + if window_interaction_state(name="Editor") == "ready": + ... # not "blocked_by_modal" / "not_responding" — safe to drive + +The element / window is located by ``name`` / ``role`` / ``app_name`` / +``automation_id`` (same as the other native-control actions). The actions return +``bool``; ``window_interaction_state`` returns ``ready`` / ``blocked_by_modal`` / +``not_responding`` / ``running`` / ``closing`` (or ``None`` if not found). + +Executor commands +----------------- + +``AC_move_element`` (``x`` / ``y``), ``AC_resize_element`` (``width`` / +``height``), ``AC_set_window_state`` (``state``) and +``AC_window_interaction_state`` (``{state}``). They are exposed as the matching +``ac_*`` MCP tools (the actions destructive, the read read-only) and as Script +Builder commands under **Native UI**. diff --git a/docs/source/Zh/doc/new_features/v198_features_doc.rst b/docs/source/Zh/doc/new_features/v198_features_doc.rst new file mode 100644 index 00000000..265715de --- /dev/null +++ b/docs/source/Zh/doc/new_features/v198_features_doc.rst @@ -0,0 +1,41 @@ +移動 / 縮放元素 + 視窗狀態(UIA Transform + Window) +================================================== + +這是 **UIA 元素層級**,而非 ``window_layout`` / ``window_geometry`` 的 HWND / title 層級幾何。 +``TransformPattern`` 移動與縮放某個沒有自己頂層視窗的特定控制項或浮動面板(可停靠工具列、MDI +子視窗、分隔器);``WindowPattern`` 最小化 / 最大化視窗,並——最有用地——回報其 **interaction +state(互動狀態)**,這是像素或標題輪詢給不出的可靠「這視窗是否就緒 / 被 modal 擋住?」訊號。 + +* :func:`move_element` / :func:`resize_element` ——TransformPattern, +* :func:`set_window_state` ——最小化 / 最大化 / 還原, +* :func:`window_interaction_state` ——就緒 / 被 modal 擋住的訊號。 + +每個都是對可注入的 ``accessibility.backends.get_backend()`` 接縫的薄分派——可透過注入 fake +backend 進行無頭測試;真正的 UIA 呼叫位於 Windows 後端。不匯入 ``PySide6``。 + +無頭 API +-------- + +.. code-block:: python + + from je_auto_control import (move_element, resize_element, + set_window_state, window_interaction_state) + + move_element(100, 200, name="Tool Palette") # 重新定位浮動面板 + resize_element(640, 480, name="Preview") # 縮放控制項 + set_window_state("maximized", name="Editor") # normal / maximized / minimized + + if window_interaction_state(name="Editor") == "ready": + ... # 不是 "blocked_by_modal" / "not_responding"——可安全驅動 + +元素 / 視窗以 ``name`` / ``role`` / ``app_name`` / ``automation_id`` 定位(與其他原生控制動作 +相同)。動作回傳 ``bool``;``window_interaction_state`` 回傳 ``ready`` / ``blocked_by_modal`` / +``not_responding`` / ``running`` / ``closing``(找不到則為 ``None``)。 + +執行器指令 +---------- + +``AC_move_element``(``x`` / ``y``)、``AC_resize_element``(``width`` / ``height``)、 +``AC_set_window_state``(``state``)與 ``AC_window_interaction_state``(``{state}``)。皆以對應的 +``ac_*`` MCP 工具(動作類為破壞性、讀取類為唯讀)及 Script Builder 指令(位於 **Native UI** +分類下)形式提供。 diff --git a/je_auto_control/__init__.py b/je_auto_control/__init__.py index 6ae62724..88c0d772 100644 --- a/je_auto_control/__init__.py +++ b/je_auto_control/__init__.py @@ -76,6 +76,10 @@ from je_auto_control.utils.table_pattern import ( cell_by_header, table_cell, table_headers, ) +# Move/resize UIA elements + window state (Transform + Window patterns) +from je_auto_control.utils.transform_window import ( + move_element, resize_element, set_window_state, window_interaction_state, +) # 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, @@ -1679,6 +1683,8 @@ def start_autocontrol_gui(*args, **kwargs): "realize_item", "get_element_properties", "is_element_enabled", "table_headers", "table_cell", "cell_by_header", + "move_element", "resize_element", "set_window_state", + "window_interaction_state", "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 ebbaf161..b09e34ab 100644 --- a/je_auto_control/gui/script_builder/command_schema.py +++ b/je_auto_control/gui/script_builder/command_schema.py @@ -1643,6 +1643,29 @@ def _add_native_control_specs(specs: List[CommandSpec]) -> None: FieldSpec("column_header", FieldType.STRING)) + fields, description="Read the cell at (row, named column) — assert by header.", )) + specs.append(CommandSpec( + "AC_move_element", "Native UI", "Move Element (Transform)", + fields=(FieldSpec("x", FieldType.FLOAT), + FieldSpec("y", FieldType.FLOAT)) + fields, + description="Move a UIA element to (x, y) (TransformPattern).", + )) + specs.append(CommandSpec( + "AC_resize_element", "Native UI", "Resize Element (Transform)", + fields=(FieldSpec("width", FieldType.FLOAT), + FieldSpec("height", FieldType.FLOAT)) + fields, + description="Resize a UIA element (TransformPattern).", + )) + specs.append(CommandSpec( + "AC_set_window_state", "Native UI", "Set Window State", + fields=(FieldSpec("state", FieldType.ENUM, default="normal", + choices=("normal", "maximized", "minimized")),) + fields, + description="Minimize / maximize / restore a window (WindowPattern).", + )) + specs.append(CommandSpec( + "AC_window_interaction_state", "Native UI", "Window Interaction State", + fields=fields, + description="Read window readiness (ready/blocked_by_modal/...).", + )) specs.append(CommandSpec( "AC_get_control_text", "Native UI", "Get Control Text", fields=fields, diff --git a/je_auto_control/utils/accessibility/backends/base.py b/je_auto_control/utils/accessibility/backends/base.py index 2624d5d3..dc3a6722 100644 --- a/je_auto_control/utils/accessibility/backends/base.py +++ b/je_auto_control/utils/accessibility/backends/base.py @@ -181,6 +181,38 @@ def get_grid_cell(self, row: int = 0, column: int = 0, row_span, column_span}`` (GridPattern.GetItem + GridItemPattern).""" self._unsupported("get_grid_cell") + # --- transform + window patterns (UIA-element-level) -------------------- + + def move_element(self, x: float = 0.0, y: float = 0.0, + name: Optional[str] = None, role: Optional[str] = None, + app_name: Optional[str] = None, + automation_id: Optional[str] = None) -> bool: + """Move the matched element to ``(x, y)`` (TransformPattern); True on success.""" + self._unsupported("move_element") + + def resize_element(self, width: float = 0.0, height: float = 0.0, + name: Optional[str] = None, role: Optional[str] = None, + app_name: Optional[str] = None, + automation_id: Optional[str] = None) -> bool: + """Resize the matched element (TransformPattern); True on success.""" + self._unsupported("resize_element") + + def set_window_state(self, state: str = "normal", + name: Optional[str] = None, role: Optional[str] = None, + app_name: Optional[str] = None, + automation_id: Optional[str] = None) -> bool: + """Set a window's visual state ``normal`` / ``maximized`` / ``minimized``.""" + self._unsupported("set_window_state") + + def window_interaction_state(self, name: Optional[str] = None, + role: Optional[str] = None, + app_name: Optional[str] = None, + automation_id: Optional[str] = None, + ) -> Optional[str]: + """Return a window's interaction state — ``ready`` / ``blocked_by_modal`` / + ``not_responding`` / ``running`` / ``closing`` (WindowPattern), or None.""" + self._unsupported("window_interaction_state") + def _unsupported(self, operation: str): """Raise a clear error for an action this backend can't perform.""" raise AccessibilityNotAvailableError( diff --git a/je_auto_control/utils/accessibility/backends/windows_backend.py b/je_auto_control/utils/accessibility/backends/windows_backend.py index e46c69e9..8df71c30 100644 --- a/je_auto_control/utils/accessibility/backends/windows_backend.py +++ b/je_auto_control/utils/accessibility/backends/windows_backend.py @@ -34,8 +34,15 @@ _UIA_VIRTUALIZEDITEM_PATTERN_ID = 10020 _UIA_TABLE_PATTERN_ID = 10012 _UIA_GRIDITEM_PATTERN_ID = 10007 +_UIA_TRANSFORM_PATTERN_ID = 10016 +_UIA_WINDOW_PATTERN_ID = 10009 _UIA_AUTOMATIONID_PROPERTY = 30011 _EXPAND_STATES = {0: "collapsed", 1: "expanded", 2: "partial", 3: "leaf"} +_WINDOW_VISUAL_STATES = {"normal": 0, "maximized": 1, "minimized": 2} +_WINDOW_INTERACTION_STATES = { + 0: "running", 1: "closing", 2: "ready", 3: "blocked_by_modal", + 4: "not_responding", +} def _is_available() -> bool: @@ -307,6 +314,43 @@ def get_properties(self, name=None, role=None, app_name=None, return None return _read_properties(raw) + def move_element(self, x=0.0, y=0.0, name=None, role=None, app_name=None, + automation_id=None): + return self._invoke_pattern_method( + name, role, app_name, automation_id, _UIA_TRANSFORM_PATTERN_ID, + "IUIAutomationTransformPattern", + lambda pattern: pattern.Move(float(x), float(y))) + + def resize_element(self, width=0.0, height=0.0, name=None, role=None, + app_name=None, automation_id=None): + return self._invoke_pattern_method( + name, role, app_name, automation_id, _UIA_TRANSFORM_PATTERN_ID, + "IUIAutomationTransformPattern", + lambda pattern: pattern.Resize(float(width), float(height))) + + def set_window_state(self, state="normal", name=None, role=None, + app_name=None, automation_id=None): + visual = _WINDOW_VISUAL_STATES.get(str(state).lower()) + if visual is None: + return False + return self._invoke_pattern_method( + name, role, app_name, automation_id, _UIA_WINDOW_PATTERN_ID, + "IUIAutomationWindowPattern", + lambda pattern: pattern.SetWindowVisualState(visual)) + + def window_interaction_state(self, name=None, role=None, app_name=None, + automation_id=None) -> Optional[str]: + raw = self._find_raw(name, role, app_name, automation_id) + pattern = self._pattern(raw, _UIA_WINDOW_PATTERN_ID, + "IUIAutomationWindowPattern") if raw else None + if pattern is None: + return None + try: + return _WINDOW_INTERACTION_STATES.get( + int(pattern.CurrentWindowInteractionState)) + except (OSError, AttributeError, ValueError, TypeError): + return None + def get_table_headers(self, name=None, role=None, app_name=None, automation_id=None) -> Optional[Dict[str, Any]]: raw = self._find_raw(name, role, app_name, automation_id) diff --git a/je_auto_control/utils/executor/action_executor.py b/je_auto_control/utils/executor/action_executor.py index 14e5265b..cfb7ecb3 100644 --- a/je_auto_control/utils/executor/action_executor.py +++ b/je_auto_control/utils/executor/action_executor.py @@ -2516,6 +2516,44 @@ def _cell_by_header(row: Any, column_header: str, name: Optional[str] = None, return {"found": value is not None, "value": value} +def _move_element(x: Any, y: Any, name: Optional[str] = None, + role: Optional[str] = None, app_name: Optional[str] = None, + automation_id: Optional[str] = None) -> bool: + """Adapter: move a UIA element to (x, y) (TransformPattern).""" + from je_auto_control.utils.transform_window import move_element + return move_element(float(x), float(y), name=name, role=role, + app_name=app_name, automation_id=automation_id) + + +def _resize_element(width: Any, height: Any, name: Optional[str] = None, + role: Optional[str] = None, app_name: Optional[str] = None, + automation_id: Optional[str] = None) -> bool: + """Adapter: resize a UIA element (TransformPattern).""" + from je_auto_control.utils.transform_window import resize_element + return resize_element(float(width), float(height), name=name, role=role, + app_name=app_name, automation_id=automation_id) + + +def _set_window_state(state: str, name: Optional[str] = None, + role: Optional[str] = None, app_name: Optional[str] = None, + automation_id: Optional[str] = None) -> bool: + """Adapter: set a window's visual state normal/maximized/minimized.""" + from je_auto_control.utils.transform_window import set_window_state + return set_window_state(str(state), name=name, role=role, app_name=app_name, + automation_id=automation_id) + + +def _window_interaction_state(name: Optional[str] = None, + role: Optional[str] = None, + app_name: Optional[str] = None, + automation_id: Optional[str] = None) -> Dict[str, Any]: + """Adapter: a window's interaction state (ready/blocked/not_responding).""" + from je_auto_control.utils.transform_window import window_interaction_state + return {"state": window_interaction_state(name=name, role=role, + app_name=app_name, + automation_id=automation_id)} + + def _get_control_text(name: Optional[str] = None, role: Optional[str] = None, app_name: Optional[str] = None, automation_id: Optional[str] = None) -> Dict[str, Any]: @@ -6464,6 +6502,10 @@ def __init__(self): "AC_table_headers": _table_headers, "AC_table_cell": _table_cell, "AC_cell_by_header": _cell_by_header, + "AC_move_element": _move_element, + "AC_resize_element": _resize_element, + "AC_set_window_state": _set_window_state, + "AC_window_interaction_state": _window_interaction_state, "AC_get_control_text": _get_control_text, "AC_get_selected_text": _get_selected_text, "AC_get_visible_text": _get_visible_text, diff --git a/je_auto_control/utils/mcp_server/tools/_factories.py b/je_auto_control/utils/mcp_server/tools/_factories.py index 9ddc7998..36af99dd 100644 --- a/je_auto_control/utils/mcp_server/tools/_factories.py +++ b/je_auto_control/utils/mcp_server/tools/_factories.py @@ -1219,6 +1219,49 @@ def a11y_control_tools() -> List[MCPTool]: handler=h.cell_by_header, annotations=READ_ONLY, ), + MCPTool( + name="ac_move_element", + description=("Move a UIA element to ('x','y') via TransformPattern — " + "element-level, for panels / toolbars / MDI children " + "without their own window. Returns True on success."), + input_schema=schema({"x": {"type": "number"}, + "y": {"type": "number"}, **_M}, + required=["x", "y"]), + handler=h.move_element, + annotations=DESTRUCTIVE, + ), + MCPTool( + name="ac_resize_element", + description=("Resize a UIA element to 'width' x 'height' via " + "TransformPattern. Returns True on success."), + input_schema=schema({"width": {"type": "number"}, + "height": {"type": "number"}, **_M}, + required=["width", "height"]), + handler=h.resize_element, + annotations=DESTRUCTIVE, + ), + MCPTool( + name="ac_set_window_state", + description=("Set a window's visual state via WindowPattern: 'state' " + "= normal / maximized / minimized. Returns True on " + "success."), + input_schema=schema({ + "state": {"type": "string", + "enum": ["normal", "maximized", "minimized"]}, **_M}, + required=["state"]), + handler=h.set_window_state, + annotations=DESTRUCTIVE, + ), + MCPTool( + name="ac_window_interaction_state", + description=("Read a window's interaction state via WindowPattern: " + "{state: ready|blocked_by_modal|not_responding|running|" + "closing|null}. A reliable 'is this window ready / " + "modal-blocked?' signal."), + input_schema=schema(dict(_M)), + handler=h.window_interaction_state, + annotations=READ_ONLY, + ), MCPTool( name="ac_get_control_text", description=("Read a control's full text via TextPattern: " diff --git a/je_auto_control/utils/mcp_server/tools/_handlers.py b/je_auto_control/utils/mcp_server/tools/_handlers.py index 069f90eb..b3536bbc 100644 --- a/je_auto_control/utils/mcp_server/tools/_handlers.py +++ b/je_auto_control/utils/mcp_server/tools/_handlers.py @@ -827,6 +827,30 @@ def cell_by_header(row, column_header, name=None, role=None, app_name=None, automation_id) +def move_element(x, y, name=None, role=None, app_name=None, automation_id=None): + from je_auto_control.utils.executor.action_executor import _move_element + return _move_element(x, y, name, role, app_name, automation_id) + + +def resize_element(width, height, name=None, role=None, app_name=None, + automation_id=None): + from je_auto_control.utils.executor.action_executor import _resize_element + return _resize_element(width, height, name, role, app_name, automation_id) + + +def set_window_state(state, name=None, role=None, app_name=None, + automation_id=None): + from je_auto_control.utils.executor.action_executor import _set_window_state + return _set_window_state(state, name, role, app_name, automation_id) + + +def window_interaction_state(name=None, role=None, app_name=None, + automation_id=None): + from je_auto_control.utils.executor.action_executor import ( + _window_interaction_state) + return _window_interaction_state(name, role, app_name, automation_id) + + def get_selected_text(name=None, role=None, app_name=None, automation_id=None): from je_auto_control.utils.executor.action_executor import _get_selected_text return _get_selected_text(name, role, app_name, automation_id) diff --git a/je_auto_control/utils/transform_window/__init__.py b/je_auto_control/utils/transform_window/__init__.py new file mode 100644 index 00000000..e486f378 --- /dev/null +++ b/je_auto_control/utils/transform_window/__init__.py @@ -0,0 +1,9 @@ +"""Move/resize UIA elements + window state (Transform + Window patterns).""" +from je_auto_control.utils.transform_window.transform_window import ( + move_element, resize_element, set_window_state, window_interaction_state, +) + +__all__ = [ + "move_element", "resize_element", "set_window_state", + "window_interaction_state", +] diff --git a/je_auto_control/utils/transform_window/transform_window.py b/je_auto_control/utils/transform_window/transform_window.py new file mode 100644 index 00000000..6302a2c6 --- /dev/null +++ b/je_auto_control/utils/transform_window/transform_window.py @@ -0,0 +1,61 @@ +"""Move / resize UIA elements and drive window state (Transform + Window patterns). + +This is **UIA-element-level**, not the HWND/title-level geometry in +``window_layout`` / ``window_geometry``. ``TransformPattern`` moves and resizes a +specific control or floating panel (dockable toolbars, MDI children, splitters) +that has no top-level window of its own; ``WindowPattern`` minimizes / maximizes a +window and — most usefully — reports its **interaction state** (``ready`` / +``blocked_by_modal`` / ``not_responding``), a reliable "is this window ready or +modal-blocked?" signal that pixel or title polling can't give. + +* :func:`move_element` / :func:`resize_element` — TransformPattern, +* :func:`set_window_state` — minimize / maximize / restore, +* :func:`window_interaction_state` — the readiness / modal-blocked signal. + +Each is a thin dispatch onto the injectable ``accessibility.backends.get_backend()`` +seam — headless-testable via a fake backend; the real UIA calls live in the +Windows backend. Imports no ``PySide6``. +""" +from typing import Optional + + +def _backend(): + from je_auto_control.utils.accessibility.backends import get_backend + return get_backend() + + +def move_element(x: float, y: float, name: Optional[str] = None, + role: Optional[str] = None, app_name: Optional[str] = None, + automation_id: Optional[str] = None) -> bool: + """Move the matched element to ``(x, y)`` (TransformPattern); True on success.""" + return _backend().move_element(float(x), float(y), name=name, role=role, + app_name=app_name, automation_id=automation_id) + + +def resize_element(width: float, height: float, name: Optional[str] = None, + role: Optional[str] = None, app_name: Optional[str] = None, + automation_id: Optional[str] = None) -> bool: + """Resize the matched element to ``width`` x ``height`` (TransformPattern).""" + return _backend().resize_element(float(width), float(height), name=name, + role=role, app_name=app_name, + automation_id=automation_id) + + +def set_window_state(state: str, name: Optional[str] = None, + role: Optional[str] = None, app_name: Optional[str] = None, + automation_id: Optional[str] = None) -> bool: + """Set a window's visual state — ``normal`` / ``maximized`` / ``minimized``.""" + return _backend().set_window_state(str(state), name=name, role=role, + app_name=app_name, + automation_id=automation_id) + + +def window_interaction_state(name: Optional[str] = None, + role: Optional[str] = None, + app_name: Optional[str] = None, + automation_id: Optional[str] = None) -> Optional[str]: + """Return a window's interaction state — ``ready`` / ``blocked_by_modal`` / + ``not_responding`` / ``running`` / ``closing`` (WindowPattern), or None.""" + return _backend().window_interaction_state(name=name, role=role, + app_name=app_name, + automation_id=automation_id) diff --git a/test/unit_test/headless/test_transform_window_batch.py b/test/unit_test/headless/test_transform_window_batch.py new file mode 100644 index 00000000..a7a8d4ce --- /dev/null +++ b/test/unit_test/headless/test_transform_window_batch.py @@ -0,0 +1,112 @@ +"""Headless tests for Transform + Window patterns (fake backend seam).""" +import je_auto_control as ac +from je_auto_control.utils.accessibility.backends import base as backend_base +from je_auto_control.utils.transform_window import ( + move_element, resize_element, set_window_state, window_interaction_state, +) + + +class _FakeBackend(backend_base.AccessibilityBackend): + name = "fake" + available = True + + def __init__(self, interaction="ready"): + self.interaction = interaction + self.calls = [] + + def move_element(self, x=0.0, y=0.0, name=None, role=None, app_name=None, + automation_id=None): + self.calls.append(("move", x, y, name)) + return True + + def resize_element(self, width=0.0, height=0.0, name=None, role=None, + app_name=None, automation_id=None): + self.calls.append(("resize", width, height, name)) + return True + + def set_window_state(self, state="normal", name=None, role=None, + app_name=None, automation_id=None): + self.calls.append(("state", state, name)) + return state in ("normal", "maximized", "minimized") + + def window_interaction_state(self, name=None, role=None, app_name=None, + automation_id=None): + return self.interaction + + +def _inject(monkeypatch, backend): + import je_auto_control.utils.accessibility.backends as backends + monkeypatch.setattr(backends, "_cached_backend", backend, raising=False) + + +def test_move_and_resize_dispatch(monkeypatch): + fake = _FakeBackend() + _inject(monkeypatch, fake) + assert move_element(100, 200, name="Panel") is True + assert resize_element(640, 480, name="Panel") is True + assert ("move", 100.0, 200.0, "Panel") in fake.calls + assert ("resize", 640.0, 480.0, "Panel") in fake.calls + + +def test_set_window_state(monkeypatch): + fake = _FakeBackend() + _inject(monkeypatch, fake) + assert set_window_state("maximized", name="Editor") is True + assert ("state", "maximized", "Editor") in fake.calls + + +def test_window_interaction_state(monkeypatch): + _inject(monkeypatch, _FakeBackend(interaction="blocked_by_modal")) + assert window_interaction_state(name="Editor") == "blocked_by_modal" + + +def test_unsupported_backend_raises(monkeypatch): + from je_auto_control.utils.accessibility.element import ( + AccessibilityNotAvailableError) + _inject(monkeypatch, backend_base.AccessibilityBackend()) + try: + move_element(1, 2, name="x") + raised = False + except AccessibilityNotAvailableError: + raised = True + assert raised is True + + +def test_base_set_window_state_rejects_unknown(): + # the real Windows backend returns False for an unknown state; the base + # ABC default still routes through _unsupported, so verify the mapping guard + from je_auto_control.utils.accessibility.backends import windows_backend + backend = windows_backend.WindowsAccessibilityBackend.__new__( + windows_backend.WindowsAccessibilityBackend) + assert backend.set_window_state("not-a-state", name="x") is False + + +# --- wiring --------------------------------------------------------------- + +def test_executor_adapters(monkeypatch): + _inject(monkeypatch, _FakeBackend(interaction="not_responding")) + from je_auto_control.utils.executor.action_executor import ( + _move_element, _set_window_state, _window_interaction_state) + assert _move_element(1, 2, name="P") is True + assert _set_window_state("minimized", name="W") is True + assert _window_interaction_state(name="W") == {"state": "not_responding"} + + +def test_wiring(): + known = set(ac.executor.known_commands()) + assert {"AC_move_element", "AC_resize_element", "AC_set_window_state", + "AC_window_interaction_state"} <= 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_move_element", "ac_resize_element", "ac_set_window_state", + "ac_window_interaction_state"} <= names + from je_auto_control.gui.script_builder.command_schema import _build_specs + specs = {s.command for s in _build_specs()} + assert {"AC_move_element", "AC_resize_element", "AC_set_window_state", + "AC_window_interaction_state"} <= specs + + +def test_facade_exports(): + for name in ("move_element", "resize_element", "set_window_state", + "window_interaction_state"): + assert hasattr(ac, name) and name in ac.__all__