diff --git a/README/WHATS_NEW_zh-CN.md b/README/WHATS_NEW_zh-CN.md index e86b3680..e7e3895f 100644 --- a/README/WHATS_NEW_zh-CN.md +++ b/README/WHATS_NEW_zh-CN.md @@ -1,5 +1,11 @@ # 本次更新 — AutoControl +## 本次更新 (2026-06-23) — 锚点序数与全部定位 + +挑选第 N 个锚点相对匹配,或列举全部。完整参考:[`docs/source/Zh/doc/new_features/v124_features_doc.rst`](../docs/source/Zh/doc/new_features/v124_features_doc.rst)。 + +- **`anchor_locate(..., ordinal=N)` / `anchor_locate_all`**(`AC_anchor_locate` ordinal、`AC_anchor_locate_all`):`anchor_locate` 总是返回单一最近的匹配——无法取「标题下方第 2 行」或列出每一行。本功能加入 1 起算的 `ordinal` 选择器(向后兼容;`ordinal=1` 即最近)与返回依距离排序所有匹配的 `anchor_locate_all`——表格/列表行选取的基础元件。纯排序核心、确定。 + ## 本次更新 (2026-06-23) — 在动作组中持续按住修饰键 在多个动作之间持续按住 ctrl/shift,即使出错也会放开。完整参考:[`docs/source/Zh/doc/new_features/v123_features_doc.rst`](../docs/source/Zh/doc/new_features/v123_features_doc.rst)。 diff --git a/README/WHATS_NEW_zh-TW.md b/README/WHATS_NEW_zh-TW.md index 3408beb7..321aafd6 100644 --- a/README/WHATS_NEW_zh-TW.md +++ b/README/WHATS_NEW_zh-TW.md @@ -1,5 +1,11 @@ # 本次更新 — AutoControl +## 本次更新 (2026-06-23) — 錨點序數與全部定位 + +挑選第 N 個錨點相對比對,或列舉全部。完整參考:[`docs/source/Zh/doc/new_features/v124_features_doc.rst`](../docs/source/Zh/doc/new_features/v124_features_doc.rst)。 + +- **`anchor_locate(..., ordinal=N)` / `anchor_locate_all`**(`AC_anchor_locate` ordinal、`AC_anchor_locate_all`):`anchor_locate` 總是回傳單一最近的比對——無法取「標題下方第 2 列」或列出每一列。本功能加入 1 起算的 `ordinal` 選擇器(向後相容;`ordinal=1` 即最近)與回傳依距離排序所有比對的 `anchor_locate_all`——表格/清單列選取的基礎元件。純排序核心、具決定性。 + ## 本次更新 (2026-06-23) — 在動作群組中持續按住修飾鍵 在多個動作之間持續按住 ctrl/shift,即使出錯也會放開。完整參考:[`docs/source/Zh/doc/new_features/v123_features_doc.rst`](../docs/source/Zh/doc/new_features/v123_features_doc.rst)。 diff --git a/WHATS_NEW.md b/WHATS_NEW.md index 7f6429b6..10b74df8 100644 --- a/WHATS_NEW.md +++ b/WHATS_NEW.md @@ -1,5 +1,11 @@ # What's New — AutoControl +## What's new (2026-06-23) — Anchor Ordinal & Locate-All + +Pick the Nth anchor-relative match, or enumerate them all. Full reference: [`docs/source/Eng/doc/new_features/v124_features_doc.rst`](docs/source/Eng/doc/new_features/v124_features_doc.rst). + +- **`anchor_locate(..., ordinal=N)` / `anchor_locate_all`** (`AC_anchor_locate` ordinal, `AC_anchor_locate_all`): `anchor_locate` always returned the single nearest match — no way to grab "the 2nd row below the header" or list every row. Adds a 1-based `ordinal` selector (backward-compatible; `ordinal=1` = nearest) and `anchor_locate_all` returning every match sorted by distance — the building block for table/list-row selection. Pure ranking core, deterministic. + ## What's new (2026-06-23) — Held Modifiers Across an Action Group Hold ctrl/shift down across several actions, released even on error. Full reference: [`docs/source/Eng/doc/new_features/v123_features_doc.rst`](docs/source/Eng/doc/new_features/v123_features_doc.rst). diff --git a/docs/source/Eng/doc/new_features/v124_features_doc.rst b/docs/source/Eng/doc/new_features/v124_features_doc.rst new file mode 100644 index 00000000..5d9f67d4 --- /dev/null +++ b/docs/source/Eng/doc/new_features/v124_features_doc.rst @@ -0,0 +1,39 @@ +Anchor Ordinal & Locate-All +=========================== + +``anchor_locate`` finds a target by spatial relation to an anchor but always +returned the single *nearest* match — there was no way to pick "the **2nd** row +below the header" or to enumerate every matching row. This adds an ``ordinal`` +selector and a list-returning :func:`anchor_locate_all`. + +Both build on a shared ranking helper (pure: filter by relation, sort by +distance) so the selection logic is unit-testable by injecting candidate boxes. +Headless and Qt-free. + +Headless API +------------ + +.. code-block:: python + + from je_auto_control import ( + anchor_locate, anchor_locate_all, ocr_locator, image_locator, + ) + + header = ocr_locator("Name") + row = image_locator("row_handle.png") + + anchor_locate(anchor=header, target=row, relation="below") # nearest + anchor_locate(anchor=header, target=row, relation="below", ordinal=2) # 2nd row + rows = anchor_locate_all(anchor=header, target=row, relation="below") # all rows + +``ordinal`` is 1-based (``ordinal=1`` is the nearest, the previous behaviour, so +this is backward-compatible); an out-of-range ordinal returns a not-found outcome. +``anchor_locate_all`` returns a list of found ``AnchorOutcome`` ordered by +distance — the building block for table / list-row selection. + +Executor commands +----------------- + +``AC_anchor_locate`` gains an ``ordinal`` parameter; ``AC_anchor_locate_all`` +returns ``{count, matches}``. Both are exposed as MCP tools (``ac_anchor_locate`` +with ``ordinal`` / ``ac_anchor_locate_all``). diff --git a/docs/source/Eng/eng_index.rst b/docs/source/Eng/eng_index.rst index 3638c676..525ceffb 100644 --- a/docs/source/Eng/eng_index.rst +++ b/docs/source/Eng/eng_index.rst @@ -146,6 +146,7 @@ Comprehensive guides for all AutoControl features. doc/new_features/v121_features_doc doc/new_features/v122_features_doc doc/new_features/v123_features_doc + doc/new_features/v124_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/v124_features_doc.rst b/docs/source/Zh/doc/new_features/v124_features_doc.rst new file mode 100644 index 00000000..7ece1c5d --- /dev/null +++ b/docs/source/Zh/doc/new_features/v124_features_doc.rst @@ -0,0 +1,33 @@ +錨點序數與全部定位 +================== + +``anchor_locate`` 依與錨點的空間關係尋找目標,但總是回傳單一*最近*的比對——先前無法選「標題下方的**第 2** 列」 +或列舉每一個符合的列。本功能加入 ``ordinal`` 選擇器與回傳清單的 :func:`anchor_locate_all`。 + +兩者皆建立在共用的排序輔助函式上(純函式:依關係過濾、依距離排序),因此選擇邏輯可藉由注入候選框做單元測試。 +無頭且不依賴 ``PySide6``。 + +無頭 API +-------- + +.. code-block:: python + + from je_auto_control import ( + anchor_locate, anchor_locate_all, ocr_locator, image_locator, + ) + + header = ocr_locator("Name") + row = image_locator("row_handle.png") + + anchor_locate(anchor=header, target=row, relation="below") # 最近 + anchor_locate(anchor=header, target=row, relation="below", ordinal=2) # 第 2 列 + rows = anchor_locate_all(anchor=header, target=row, relation="below") # 所有列 + +``ordinal`` 為 1 起算(``ordinal=1`` 即最近,與先前行為相同,故向後相容);超出範圍的序數回傳未找到的結果。 +``anchor_locate_all`` 回傳依距離排序的 found ``AnchorOutcome`` 清單——表格 / 清單列選取的基礎元件。 + +執行器命令 +---------- + +``AC_anchor_locate`` 新增 ``ordinal`` 參數;``AC_anchor_locate_all`` 回傳 ``{count, matches}``。兩者皆以 MCP +工具(``ac_anchor_locate`` 含 ``ordinal`` / ``ac_anchor_locate_all``)提供。 diff --git a/docs/source/Zh/zh_index.rst b/docs/source/Zh/zh_index.rst index 03877ac1..52a9f226 100644 --- a/docs/source/Zh/zh_index.rst +++ b/docs/source/Zh/zh_index.rst @@ -146,6 +146,7 @@ AutoControl 所有功能的完整使用指南。 doc/new_features/v121_features_doc doc/new_features/v122_features_doc doc/new_features/v123_features_doc + doc/new_features/v124_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 f6af54db..5f5a2ec5 100644 --- a/je_auto_control/__init__.py +++ b/je_auto_control/__init__.py @@ -607,8 +607,8 @@ # Anchor-based locators (spatial composition of locator backends) from je_auto_control.utils.anchor_locator import ( AnchorLocatorError, AnchorOutcome, Locator as AnchorLocator, - a11y_locator, anchor_locate, image_locator, ocr_locator, - vlm_locator, + a11y_locator, anchor_locate, anchor_locate_all, image_locator, + ocr_locator, vlm_locator, ) # Structured OCR (rows / tables / form fields) from je_auto_control.utils.ocr.structure import ( @@ -1245,7 +1245,8 @@ def start_autocontrol_gui(*args, **kwargs): "register_chatops_default_commands", # Anchor-based locator "AnchorLocator", "AnchorLocatorError", "AnchorOutcome", - "a11y_locator", "anchor_locate", "image_locator", "ocr_locator", + "a11y_locator", "anchor_locate", "anchor_locate_all", "image_locator", + "ocr_locator", "vlm_locator", # Structured OCR "OCRField", "OCRRow", "OCRTable", "StructuredOCR", diff --git a/je_auto_control/utils/anchor_locator/__init__.py b/je_auto_control/utils/anchor_locator/__init__.py index 9668da02..a6d281d5 100644 --- a/je_auto_control/utils/anchor_locator/__init__.py +++ b/je_auto_control/utils/anchor_locator/__init__.py @@ -22,8 +22,8 @@ AnchorLocatorError, AnchorOutcome, KIND_A11Y, KIND_IMAGE, KIND_OCR, KIND_VLM, Locator, REL_ABOVE, REL_BELOW, REL_LEFT_OF, REL_NEAR, REL_RIGHT_OF, - a11y_locator, anchor_locate, image_locator, ocr_locator, - vlm_locator, + a11y_locator, anchor_locate, anchor_locate_all, image_locator, + ocr_locator, vlm_locator, ) @@ -31,6 +31,6 @@ "AnchorLocatorError", "AnchorOutcome", "KIND_A11Y", "KIND_IMAGE", "KIND_OCR", "KIND_VLM", "Locator", "REL_ABOVE", "REL_BELOW", "REL_LEFT_OF", "REL_NEAR", "REL_RIGHT_OF", - "a11y_locator", "anchor_locate", "image_locator", "ocr_locator", - "vlm_locator", + "a11y_locator", "anchor_locate", "anchor_locate_all", "image_locator", + "ocr_locator", "vlm_locator", ] diff --git a/je_auto_control/utils/anchor_locator/locator.py b/je_auto_control/utils/anchor_locator/locator.py index 3190108e..feb156f8 100644 --- a/je_auto_control/utils/anchor_locator/locator.py +++ b/je_auto_control/utils/anchor_locator/locator.py @@ -137,7 +137,8 @@ def to_dict(self) -> Dict[str, Any]: def anchor_locate(*, anchor: Locator, target: Locator, relation: str = REL_NEAR, - max_distance_px: float = 200.0) -> AnchorOutcome: + max_distance_px: float = 200.0, + ordinal: int = 1) -> AnchorOutcome: """Find ``target`` near / above / below / beside ``anchor``. Strategy: @@ -147,8 +148,10 @@ def anchor_locate(*, anchor: Locator, target: Locator, 2. Resolve the target to a list of candidate bboxes. Image and OCR can enumerate; VLM / a11y always return one point so only that one candidate is considered. - 3. Filter candidates by the spatial relation; tie-break by the - smallest centre-to-centre distance. + 3. Filter candidates by the spatial relation and sort by the + centre-to-centre distance; return the ``ordinal``-th (1-based, + so ``ordinal=1`` is the nearest — "the 2nd row below the header" + is ``ordinal=2``). """ relation_norm = _normalise_relation(relation) anchor_point = _resolve_single(anchor) @@ -160,7 +163,6 @@ def anchor_locate(*, anchor: Locator, target: Locator, error="anchor not found", ) candidates = _resolve_candidates(target) - candidates_considered = len(candidates) if not candidates: return AnchorOutcome( found=False, target_coords=None, anchor_coords=anchor_point, @@ -168,26 +170,54 @@ def anchor_locate(*, anchor: Locator, target: Locator, target_kind=target.kind, anchor_kind=anchor.kind, candidates_considered=0, error="target not found", ) - chosen = _pick_best( - anchor_point, candidates, relation_norm, float(max_distance_px), - ) - if chosen is None: + ranked = _ranked(anchor_point, candidates, relation_norm, + float(max_distance_px)) + index = int(ordinal) - 1 + if index < 0 or index >= len(ranked): return AnchorOutcome( found=False, target_coords=None, anchor_coords=anchor_point, distance_px=None, relation=relation_norm, target_kind=target.kind, anchor_kind=anchor.kind, - candidates_considered=candidates_considered, - error=f"no candidate satisfies relation {relation_norm!r}", + candidates_considered=len(candidates), + error=f"no candidate at ordinal {ordinal} for relation " + f"{relation_norm!r}", ) - coords, distance = chosen + coords, distance = ranked[index] return AnchorOutcome( found=True, target_coords=coords, anchor_coords=anchor_point, distance_px=round(distance, 2), relation=relation_norm, target_kind=target.kind, anchor_kind=anchor.kind, - candidates_considered=candidates_considered, + candidates_considered=len(candidates), ) +def anchor_locate_all(*, anchor: Locator, target: Locator, + relation: str = REL_NEAR, + max_distance_px: float = 200.0) -> List[AnchorOutcome]: + """Return every target matching the relation, nearest-first. + + The building block for table / list-row selection: each result is a found + :class:`AnchorOutcome` ordered by distance from the anchor. Returns an empty + list when the anchor or all targets are missing. + """ + relation_norm = _normalise_relation(relation) + anchor_point = _resolve_single(anchor) + if anchor_point is None: + return [] + candidates = _resolve_candidates(target) + ranked = _ranked(anchor_point, candidates, relation_norm, + float(max_distance_px)) + return [ + AnchorOutcome( + found=True, target_coords=coords, anchor_coords=anchor_point, + distance_px=round(distance, 2), relation=relation_norm, + target_kind=target.kind, anchor_kind=anchor.kind, + candidates_considered=len(candidates), + ) + for coords, distance in ranked + ] + + def _normalise_relation(relation: str) -> str: normalised = (relation or "").strip().lower() if normalised not in _VALID_RELATIONS: @@ -221,21 +251,22 @@ def _resolve_candidates(locator: Locator) -> List[_Bbox]: return [] -def _pick_best(anchor_point: Tuple[int, int], - candidates: List[_Bbox], - relation: str, - max_distance: float, - ) -> Optional[Tuple[Tuple[int, int], float]]: - best: Optional[Tuple[Tuple[int, int], float]] = None +def _ranked(anchor_point: Tuple[int, int], + candidates: List[_Bbox], + relation: str, + max_distance: float, + ) -> List[Tuple[Tuple[int, int], float]]: + """Return (centre, distance) of matching candidates, nearest first.""" + matches: List[Tuple[Tuple[int, int], float]] = [] for bbox in candidates: if not _matches_relation(anchor_point, bbox, relation): continue distance = _euclid(anchor_point, bbox.center) if relation == REL_NEAR and distance > max_distance: continue - if best is None or distance < best[1]: - best = (bbox.center, distance) - return best + matches.append((bbox.center, distance)) + matches.sort(key=lambda item: item[1]) + return matches def _matches_relation(anchor_point: Tuple[int, int], @@ -352,5 +383,6 @@ def _a11y_point(locator: Locator) -> Optional[Tuple[int, int]]: "AnchorLocatorError", "AnchorOutcome", "KIND_A11Y", "KIND_IMAGE", "KIND_OCR", "KIND_VLM", "Locator", "REL_ABOVE", "REL_BELOW", "REL_LEFT_OF", "REL_NEAR", "REL_RIGHT_OF", "a11y_locator", - "anchor_locate", "image_locator", "ocr_locator", "vlm_locator", + "anchor_locate", "anchor_locate_all", "image_locator", "ocr_locator", + "vlm_locator", ] diff --git a/je_auto_control/utils/executor/action_executor.py b/je_auto_control/utils/executor/action_executor.py index e533a7ba..0648e6d3 100644 --- a/je_auto_control/utils/executor/action_executor.py +++ b/je_auto_control/utils/executor/action_executor.py @@ -478,8 +478,9 @@ def _ocr_read_structure(region: Optional[List[int]] = None, def _anchor_locate(anchor: Dict[str, Any], target: Dict[str, Any], relation: str = "near", - max_distance_px: float = 200.0) -> Dict[str, Any]: - """Executor adapter: anchor-based spatial locator.""" + max_distance_px: float = 200.0, + ordinal: Any = 1) -> Dict[str, Any]: + """Executor adapter: anchor-based spatial locator (Nth match via ordinal).""" from je_auto_control.utils.anchor_locator import ( Locator, anchor_locate, ) @@ -488,10 +489,23 @@ def _anchor_locate(anchor: Dict[str, Any], target: Dict[str, Any], outcome = anchor_locate( anchor=anchor_loc, target=target_loc, relation=relation, max_distance_px=float(max_distance_px), + ordinal=int(ordinal), ) return outcome.to_dict() +def _anchor_locate_all(anchor: Dict[str, Any], target: Dict[str, Any], + relation: str = "near", + max_distance_px: float = 200.0) -> Dict[str, Any]: + """Executor adapter: every anchor-relative match, nearest-first.""" + from je_auto_control.utils.anchor_locator import Locator, anchor_locate_all + outcomes = anchor_locate_all( + anchor=Locator(**anchor), target=Locator(**target), + relation=relation, max_distance_px=float(max_distance_px), + ) + return {"count": len(outcomes), "matches": [o.to_dict() for o in outcomes]} + + def _anchor_click(anchor: Dict[str, Any], target: Dict[str, Any], mouse_keycode: str = "mouse_left", relation: str = "near", @@ -5037,6 +5051,7 @@ def __init__(self): # Anchor-based locator (spatial composition of locator backends) "AC_anchor_locate": _anchor_locate, + "AC_anchor_locate_all": _anchor_locate_all, "AC_anchor_click": _anchor_click, # Structured OCR (rows / tables / form fields) diff --git a/je_auto_control/utils/mcp_server/tools/_factories.py b/je_auto_control/utils/mcp_server/tools/_factories.py index bdaf5517..8fc3e670 100644 --- a/je_auto_control/utils/mcp_server/tools/_factories.py +++ b/je_auto_control/utils/mcp_server/tools/_factories.py @@ -1472,10 +1472,27 @@ def anchor_locator_tools() -> List[MCPTool]: "enum": ["above", "below", "left_of", "right_of", "near"]}, "max_distance_px": {"type": "number"}, + "ordinal": {"type": "integer"}, }, required=["anchor", "target"]), handler=h.anchor_locate, annotations=READ_ONLY, ), + MCPTool( + name="ac_anchor_locate_all", + description=("Every target matching the spatial relation to the " + "anchor, nearest-first (for table / list-row " + "selection). Returns {count, matches}."), + input_schema=schema({ + "anchor": locator_schema, + "target": locator_schema, + "relation": {"type": "string", + "enum": ["above", "below", "left_of", + "right_of", "near"]}, + "max_distance_px": {"type": "number"}, + }, required=["anchor", "target"]), + handler=h.anchor_locate_all, + annotations=READ_ONLY, + ), MCPTool( name="ac_anchor_click", description="Anchor-locate then click the resolved target point.", diff --git a/je_auto_control/utils/mcp_server/tools/_handlers.py b/je_auto_control/utils/mcp_server/tools/_handlers.py index 09318b0a..e2238fea 100644 --- a/je_auto_control/utils/mcp_server/tools/_handlers.py +++ b/je_auto_control/utils/mcp_server/tools/_handlers.py @@ -2532,14 +2532,17 @@ def ocr_read_structure(region: Optional[List[int]] = None, def anchor_locate(anchor: Dict[str, Any], target: Dict[str, Any], relation: str = "near", - max_distance_px: float = 200.0) -> Dict[str, Any]: - from je_auto_control.utils.anchor_locator import ( - Locator, anchor_locate as _impl, - ) - return _impl( - anchor=Locator(**anchor), target=Locator(**target), - relation=relation, max_distance_px=float(max_distance_px), - ).to_dict() + max_distance_px: float = 200.0, + ordinal: int = 1) -> Dict[str, Any]: + from je_auto_control.utils.executor.action_executor import _anchor_locate + return _anchor_locate(anchor, target, relation, max_distance_px, ordinal) + + +def anchor_locate_all(anchor: Dict[str, Any], target: Dict[str, Any], + relation: str = "near", + max_distance_px: float = 200.0) -> Dict[str, Any]: + from je_auto_control.utils.executor.action_executor import _anchor_locate_all + return _anchor_locate_all(anchor, target, relation, max_distance_px) def anchor_click(anchor: Dict[str, Any], target: Dict[str, Any], diff --git a/test/unit_test/headless/test_anchor_ordinal_batch.py b/test/unit_test/headless/test_anchor_ordinal_batch.py new file mode 100644 index 00000000..7803f4a2 --- /dev/null +++ b/test/unit_test/headless/test_anchor_ordinal_batch.py @@ -0,0 +1,74 @@ +"""Headless tests for anchor ordinal / locate-all. No Qt.""" +import je_auto_control as ac +from je_auto_control.utils.anchor_locator import ( + anchor_locate, anchor_locate_all, image_locator, +) +from je_auto_control.utils.anchor_locator import locator as locator_mod + + +def _patch(monkeypatch, anchor_point, bboxes): + monkeypatch.setattr(locator_mod, "_resolve_single", lambda _l: anchor_point) + monkeypatch.setattr( + locator_mod, "_resolve_candidates", + lambda _l: [locator_mod._Bbox(*tup) for tup in bboxes]) + + +_ANCHOR = image_locator("a.png") +_TARGET = image_locator("b.png") +# three targets below the anchor at (0, 0), at increasing distance +_BELOW = [(0, 100, 10, 110), (0, 200, 10, 210), (0, 300, 10, 310)] + + +def test_ordinal_selects_nth_nearest(monkeypatch): + _patch(monkeypatch, (0, 0), _BELOW) + first = anchor_locate(anchor=_ANCHOR, target=_TARGET, relation="below") + assert first.found and first.target_coords == (5, 105) # nearest + second = anchor_locate(anchor=_ANCHOR, target=_TARGET, relation="below", + ordinal=2) + assert second.found and second.target_coords == (5, 205) + third = anchor_locate(anchor=_ANCHOR, target=_TARGET, relation="below", + ordinal=3) + assert third.found and third.target_coords == (5, 305) + + +def test_ordinal_out_of_range_not_found(monkeypatch): + _patch(monkeypatch, (0, 0), _BELOW) + outcome = anchor_locate(anchor=_ANCHOR, target=_TARGET, relation="below", + ordinal=9) + assert outcome.found is False and "ordinal" in outcome.error + + +def test_locate_all_returns_sorted(monkeypatch): + _patch(monkeypatch, (0, 0), _BELOW) + results = anchor_locate_all(anchor=_ANCHOR, target=_TARGET, relation="below") + assert [r.target_coords for r in results] == [(5, 105), (5, 205), (5, 305)] + assert all(r.found for r in results) + + +def test_locate_all_empty_when_anchor_missing(monkeypatch): + _patch(monkeypatch, None, _BELOW) + assert anchor_locate_all(anchor=_ANCHOR, target=_TARGET) == [] + + +def test_relation_filters_before_ordinal(monkeypatch): + # one above, two below -> "below" ordinal=2 is the farther below one + _patch(monkeypatch, (0, 150), + [(0, 50, 10, 60), (0, 200, 10, 210), (0, 300, 10, 310)]) + out = anchor_locate(anchor=_ANCHOR, target=_TARGET, relation="below", + ordinal=2) + assert out.target_coords == (5, 305) + + +# --- wiring --------------------------------------------------------------- + +def test_wiring(): + known = ac.executor.known_commands() + assert "AC_anchor_locate_all" 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_anchor_locate_all" in names + + +def test_facade_exports(): + assert hasattr(ac, "anchor_locate_all") + assert "anchor_locate_all" in ac.__all__