diff --git a/README/WHATS_NEW_zh-CN.md b/README/WHATS_NEW_zh-CN.md index 0a082e6b..342fe7de 100644 --- a/README/WHATS_NEW_zh-CN.md +++ b/README/WHATS_NEW_zh-CN.md @@ -1,5 +1,11 @@ # 本次更新 — AutoControl +## 本次更新 (2026-06-24) — 声明式动作后置条件 + +以 JSON 规格断言动作的预期结果,并对照 before 帧做差异。完整参考:[`docs/source/Zh/doc/new_features/v169_features_doc.rst`](../docs/source/Zh/doc/new_features/v169_features_doc.rst)。 + +- **`check_postcondition` / `compile_postcondition`**(`AC_check_postcondition`):`expect_poll`/`assert_eventually` 轮询单一条件,没有与动作绑定的规格、也没有 before 基准(因此无法表达「一个*新*对话框出现了」);`trajectory_eval` 是整条轨迹层级。本功能对 after 观测评估一个小型 JSON 子句规格——`appears`/`disappears`(对照 `before`)、`enabled`/`disabled`、`text_present`/`text_absent`、`count`——返回逐子句的 `{ok, clauses, failed}` 报告。`compile_postcondition` 把规格转成 `after -> bool` 判定函数以供 `expect_poll` 使用。纯标准库;不导入 `PySide6`。 + ## 本次更新 (2026-06-24) — 边缘形状(Chamfer)模板匹配 以轮廓定位扁平图标,对填充 / 主题 / 抗锯齿稳健。完整参考:[`docs/source/Zh/doc/new_features/v168_features_doc.rst`](../docs/source/Zh/doc/new_features/v168_features_doc.rst)。 diff --git a/README/WHATS_NEW_zh-TW.md b/README/WHATS_NEW_zh-TW.md index c4c17a1e..2f531b98 100644 --- a/README/WHATS_NEW_zh-TW.md +++ b/README/WHATS_NEW_zh-TW.md @@ -1,5 +1,11 @@ # 本次更新 — AutoControl +## 本次更新 (2026-06-24) — 宣告式動作後置條件 + +以 JSON 規格斷言動作的預期結果,並對照 before 幀做差異。完整參考:[`docs/source/Zh/doc/new_features/v169_features_doc.rst`](../docs/source/Zh/doc/new_features/v169_features_doc.rst)。 + +- **`check_postcondition` / `compile_postcondition`**(`AC_check_postcondition`):`expect_poll`/`assert_eventually` 輪詢單一條件,沒有與動作綁定的規格、也沒有 before 基準(因此無法表達「一個*新*對話框出現了」);`trajectory_eval` 是整條軌跡層級。本功能對 after 觀測評估一個小型 JSON 子句規格——`appears`/`disappears`(對照 `before`)、`enabled`/`disabled`、`text_present`/`text_absent`、`count`——回傳逐子句的 `{ok, clauses, failed}` 報告。`compile_postcondition` 把規格轉成 `after -> bool` 判定函式以供 `expect_poll` 使用。純標準函式庫;不匯入 `PySide6`。 + ## 本次更新 (2026-06-24) — 邊緣形狀(Chamfer)樣板比對 以輪廓定位扁平圖示,對填充 / 主題 / 抗鋸齒穩健。完整參考:[`docs/source/Zh/doc/new_features/v168_features_doc.rst`](../docs/source/Zh/doc/new_features/v168_features_doc.rst)。 diff --git a/WHATS_NEW.md b/WHATS_NEW.md index 1ab6e0c2..a8721c9e 100644 --- a/WHATS_NEW.md +++ b/WHATS_NEW.md @@ -1,5 +1,11 @@ # What's New — AutoControl +## What's new (2026-06-24) — Declarative Action Postconditions + +Assert an action's expected outcome as a JSON spec, diffed against the before-frame. Full reference: [`docs/source/Eng/doc/new_features/v169_features_doc.rst`](docs/source/Eng/doc/new_features/v169_features_doc.rst). + +- **`check_postcondition` / `compile_postcondition`** (`AC_check_postcondition`): `expect_poll`/`assert_eventually` poll a single condition with no action-bound spec and no before-baseline (so they can't express "a *new* dialog appeared"); `trajectory_eval` is whole-trajectory. This evaluates a small JSON spec of clauses — `appears`/`disappears` (diffed vs `before`), `enabled`/`disabled`, `text_present`/`text_absent`, `count` — against the after-observation, returning a per-clause `{ok, clauses, failed}` report. `compile_postcondition` turns a spec into an `after -> bool` predicate for `expect_poll`. Pure-stdlib; no `PySide6`. + ## What's new (2026-06-24) — Edge-Shape (Chamfer) Template Matching Locate flat icons by outline, robust to fill / theme / anti-aliasing. Full reference: [`docs/source/Eng/doc/new_features/v168_features_doc.rst`](docs/source/Eng/doc/new_features/v168_features_doc.rst). diff --git a/docs/source/Eng/doc/new_features/v169_features_doc.rst b/docs/source/Eng/doc/new_features/v169_features_doc.rst new file mode 100644 index 00000000..86c98f0c --- /dev/null +++ b/docs/source/Eng/doc/new_features/v169_features_doc.rst @@ -0,0 +1,45 @@ +Declarative Action Postconditions +================================= + +After an action an agent (or a replay harness) usually has a concrete expectation: "a dialog +saying 'Saved' should appear AND the Submit button should disable". ``expect_poll`` / +``assert_eventually`` poll a *single condition* but have no notion of an action-bound +*postcondition spec*, and they don't diff against a *before* baseline (so they cannot express +"a NEW dialog appeared" — only "a dialog exists"). ``trajectory_eval`` rubrics are +whole-trajectory, not per-step screen state. ``postcondition`` fills the gap: a small JSON spec +of clauses evaluated against the after-observation (optionally diffed against the +before-observation), returning a per-clause pass/fail report. + +Clauses: ``appears`` / ``disappears`` (diffed against ``before``), ``enabled`` / ``disabled``, +``text_present`` / ``text_absent``, and ``count`` (``equals`` / ``min``). Pure-stdlib over +element dicts; the spec is plain JSON so it rides into action files / MCP / the scheduler. +Imports no ``PySide6``. + +Headless API +------------ + +.. code-block:: python + + from je_auto_control import check_postcondition, compile_postcondition + + spec = {"appears": {"role": "dialog", "name": "Saved"}, + "disabled": {"name": "Submit"}} + report = check_postcondition(after_elements, spec, before=before_elements) + if not report.ok: + print("failed clauses:", report.failed) + + # turn a spec into a predicate to drive expect_poll + predicate = compile_postcondition({"text_present": "Saved"}) + +``check_postcondition`` returns a ``PostconditionReport`` (``ok`` / ``clauses`` — +``[{type, ok, detail}]`` — / ``failed``). ``appears`` succeeds only when the element is in +``after`` and *not* in ``before`` (a genuinely new element); ``disappears`` requires a +``before`` frame. ``compile_postcondition`` returns an ``after -> bool`` predicate for pairing +with ``expect_poll`` / ``assert_eventually``. + +Executor command +---------------- + +``AC_check_postcondition`` (``after`` / ``spec`` / ``before`` → +``{ok, clauses, failed}``) is exposed as the MCP tool ``ac_check_postcondition`` (read-only) +and as the Script Builder command **Check Postcondition** under **Native UI**. diff --git a/docs/source/Eng/eng_index.rst b/docs/source/Eng/eng_index.rst index 2bec5e7c..cc5733ed 100644 --- a/docs/source/Eng/eng_index.rst +++ b/docs/source/Eng/eng_index.rst @@ -191,6 +191,7 @@ Comprehensive guides for all AutoControl features. doc/new_features/v166_features_doc doc/new_features/v167_features_doc doc/new_features/v168_features_doc + doc/new_features/v169_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/v169_features_doc.rst b/docs/source/Zh/doc/new_features/v169_features_doc.rst new file mode 100644 index 00000000..81723684 --- /dev/null +++ b/docs/source/Zh/doc/new_features/v169_features_doc.rst @@ -0,0 +1,41 @@ +宣告式動作後置條件 +================== + +動作之後,代理(或重播框架)通常有具體的預期:「應出現寫著『Saved』的對話框,且 Submit +按鈕應停用」。``expect_poll`` / ``assert_eventually`` 輪詢*單一條件*,卻沒有與動作綁定的 +*後置條件規格*概念,也不對照 *before* 基準做差異(因此無法表達「一個*新*對話框出現了」 +——只能表達「存在對話框」)。``trajectory_eval`` 的評分準則是整條軌跡層級,而非每步畫面 +狀態。``postcondition`` 補上這個缺口:用一個小型 JSON 子句規格,對照 after 觀測(可選擇與 +before 觀測做差異)評估,回傳逐子句的通過 / 失敗報告。 + +子句:``appears`` / ``disappears``(對照 ``before``)、``enabled`` / ``disabled``、 +``text_present`` / ``text_absent``,以及 ``count``(``equals`` / ``min``)。純標準函式庫, +作用於元素字典;規格為純 JSON,可帶入 action 檔 / MCP / 排程器。不匯入 ``PySide6``。 + +無頭 API +-------- + +.. code-block:: python + + from je_auto_control import check_postcondition, compile_postcondition + + spec = {"appears": {"role": "dialog", "name": "Saved"}, + "disabled": {"name": "Submit"}} + report = check_postcondition(after_elements, spec, before=before_elements) + if not report.ok: + print("失敗子句:", report.failed) + + # 把規格轉成判定函式以驅動 expect_poll + predicate = compile_postcondition({"text_present": "Saved"}) + +``check_postcondition`` 回傳 ``PostconditionReport``(``ok`` / ``clauses`` — +``[{type, ok, detail}]`` — / ``failed``)。``appears`` 只有在元素位於 ``after`` 且*不*在 +``before``(確為新元素)時才成功;``disappears`` 需要 ``before`` 幀。``compile_postcondition`` +回傳 ``after -> bool`` 判定函式,可與 ``expect_poll`` / ``assert_eventually`` 搭配。 + +執行器指令 +---------- + +``AC_check_postcondition``(``after`` / ``spec`` / ``before`` → ``{ok, clauses, failed}``) +以 MCP 工具 ``ac_check_postcondition``(唯讀)及 Script Builder 指令 **Check Postcondition** +(位於 **Native UI** 分類下)形式提供。 diff --git a/docs/source/Zh/zh_index.rst b/docs/source/Zh/zh_index.rst index 5991fd02..24df00fd 100644 --- a/docs/source/Zh/zh_index.rst +++ b/docs/source/Zh/zh_index.rst @@ -191,6 +191,7 @@ AutoControl 所有功能的完整使用指南。 doc/new_features/v166_features_doc doc/new_features/v167_features_doc doc/new_features/v168_features_doc + doc/new_features/v169_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 bb1231bc..56b0c9c5 100644 --- a/je_auto_control/__init__.py +++ b/je_auto_control/__init__.py @@ -319,6 +319,10 @@ from je_auto_control.utils.action_effect import ( EffectVerdict, classify_effect, effect_near_point, is_no_op, ) +# Declarative action postconditions (expected outcome vs before/after) +from je_auto_control.utils.postcondition import ( + PostconditionReport, check_postcondition, compile_postcondition, +) # Locate on-screen regions by colour (mask + connected components) from je_auto_control.utils.color_region import ( find_color_region, find_color_regions, @@ -1260,6 +1264,9 @@ def start_autocontrol_gui(*args, **kwargs): "classify_effect", "effect_near_point", "is_no_op", + "PostconditionReport", + "check_postcondition", + "compile_postcondition", "find_color_region", "find_color_regions", "ssim_compare", diff --git a/je_auto_control/gui/script_builder/command_schema.py b/je_auto_control/gui/script_builder/command_schema.py index f9501265..a7114075 100644 --- a/je_auto_control/gui/script_builder/command_schema.py +++ b/je_auto_control/gui/script_builder/command_schema.py @@ -3192,6 +3192,19 @@ def _add_set_of_marks_specs(specs: List[CommandSpec]) -> None: ), description="Did any before/after change land within radius of a point?", )) + specs.append(CommandSpec( + "AC_check_postcondition", "Native UI", "Check Postcondition", + fields=( + FieldSpec("after", FieldType.STRING, + placeholder='[{"role":"dialog","name":"Saved"}]'), + FieldSpec("spec", FieldType.STRING, + placeholder='{"appears":{"role":"dialog"},' + '"disabled":{"name":"Submit"}}'), + FieldSpec("before", FieldType.STRING, optional=True, + placeholder='[{"role":"button","name":"Submit"}]'), + ), + description="Check expected outcome clauses against after/before frames.", + )) specs.append(CommandSpec( "AC_validate_action", "Native UI", "Validate / Snap Action", fields=( diff --git a/je_auto_control/utils/executor/action_executor.py b/je_auto_control/utils/executor/action_executor.py index e5435797..06feb5a2 100644 --- a/je_auto_control/utils/executor/action_executor.py +++ b/je_auto_control/utils/executor/action_executor.py @@ -4131,6 +4131,19 @@ def _effect_near_point(before: Any, after: Any, point: Any, return {"near": effect_near_point(before, after, point, radius=int(radius))} +def _check_postcondition(after: Any, spec: Any, before: Any = None) -> Dict[str, Any]: + """Adapter: evaluate a declarative postcondition spec against after/before frames.""" + import json + from je_auto_control.utils.postcondition import check_postcondition + if isinstance(after, str): + after = json.loads(after) + if isinstance(spec, str): + spec = json.loads(spec) + if isinstance(before, str): + before = json.loads(before) if before.strip() else None + return check_postcondition(after, spec, before=before).to_dict() + + def _validate_action(action: Any, screen: Any = None, targets: Any = None) -> Dict[str, Any]: """Adapter: validate a coordinate action (bounds + optional snap-to-target).""" @@ -6011,6 +6024,7 @@ def __init__(self): "AC_delta_observation": _delta_observation, "AC_classify_effect": _classify_effect, "AC_effect_near_point": _effect_near_point, + "AC_check_postcondition": _check_postcondition, "AC_validate_action": _validate_action, "AC_replay_trace": _replay_trace, "AC_match_elements": _match_elements, diff --git a/je_auto_control/utils/mcp_server/tools/_factories.py b/je_auto_control/utils/mcp_server/tools/_factories.py index 74ac12e1..eb97cbbf 100644 --- a/je_auto_control/utils/mcp_server/tools/_factories.py +++ b/je_auto_control/utils/mcp_server/tools/_factories.py @@ -3379,6 +3379,22 @@ def observation_tools() -> List[MCPTool]: handler=h.effect_near_point, annotations=READ_ONLY, ), + MCPTool( + name="ac_check_postcondition", + description=("Check a declarative postcondition 'spec' against the " + "'after' element list (optionally diffed vs 'before'). " + "Clauses: appears / disappears / enabled / disabled / " + "text_present / text_absent / count. Returns {ok, clauses:" + "[{type,ok,detail}], failed}. e.g. spec {\"appears\": " + "{\"role\":\"dialog\"}, \"disabled\": {\"name\":\"Submit\"}}."), + input_schema=schema({ + "after": {"type": "array", "items": {"type": "object"}}, + "spec": {"type": "object"}, + "before": {"type": "array", "items": {"type": "object"}}}, + required=["after", "spec"]), + handler=h.check_postcondition, + annotations=READ_ONLY, + ), ] diff --git a/je_auto_control/utils/mcp_server/tools/_handlers.py b/je_auto_control/utils/mcp_server/tools/_handlers.py index a51fbd6b..e846e290 100644 --- a/je_auto_control/utils/mcp_server/tools/_handlers.py +++ b/je_auto_control/utils/mcp_server/tools/_handlers.py @@ -2433,6 +2433,11 @@ def effect_near_point(before, after, point, radius=64): return _effect_near_point(before, after, point, radius) +def check_postcondition(after, spec, before=None): + from je_auto_control.utils.executor.action_executor import _check_postcondition + return _check_postcondition(after, spec, before) + + def validate_action(action, screen=None, targets=None): from je_auto_control.utils.executor.action_executor import _validate_action return _validate_action(action, screen, targets) diff --git a/je_auto_control/utils/postcondition/__init__.py b/je_auto_control/utils/postcondition/__init__.py new file mode 100644 index 00000000..9f298068 --- /dev/null +++ b/je_auto_control/utils/postcondition/__init__.py @@ -0,0 +1,6 @@ +"""Declarative expected-outcome specs for an action, checked against the screen.""" +from je_auto_control.utils.postcondition.postcondition import ( + PostconditionReport, check_postcondition, compile_postcondition, +) + +__all__ = ["PostconditionReport", "check_postcondition", "compile_postcondition"] diff --git a/je_auto_control/utils/postcondition/postcondition.py b/je_auto_control/utils/postcondition/postcondition.py new file mode 100644 index 00000000..0a1e469b --- /dev/null +++ b/je_auto_control/utils/postcondition/postcondition.py @@ -0,0 +1,132 @@ +"""Declarative expected-outcome specs for an action, checked against the screen. + +After an action an agent (or a replay harness) usually has a concrete expectation: "a dialog +saying 'Saved' should appear AND the Submit button should disable". ``expect_poll`` / +``assert_eventually`` poll a *single condition* but have no notion of an action-bound +*postcondition spec*, and they don't diff against a *before* baseline (so they cannot express +"a NEW dialog appeared" — only "a dialog exists"). ``trajectory_eval`` rubrics are +whole-trajectory, not per-step screen state. ``postcondition`` fills the gap: a small JSON spec +of clauses — ``appears`` / ``disappears`` / ``enabled`` / ``disabled`` / ``text_present`` / +``text_absent`` / ``count`` — evaluated against the after-observation (optionally diffed against +the before-observation), returning a per-clause pass/fail report. + +Pure-stdlib over element dicts; deterministic and unit-testable with no device. The spec is +plain JSON so it rides into action files / MCP / the scheduler. Imports no ``PySide6``. +""" +from dataclasses import asdict, dataclass +from typing import Any, Callable, Dict, List, Optional, Sequence, Tuple + +Element = Dict[str, Any] +Report = Tuple[bool, str] + + +@dataclass(frozen=True) +class PostconditionReport: + """The result of evaluating a postcondition spec: overall ok + per-clause detail.""" + + ok: bool + clauses: List[Dict[str, Any]] + failed: List[str] + + def to_dict(self) -> Dict[str, Any]: + """Return the report as a plain dict.""" + return asdict(self) + + +def _matches(element: Element, criteria: Dict[str, Any]) -> bool: + """Whether an element matches a ``{role?, name?, name_contains?}`` criteria dict.""" + if "role" in criteria and element.get("role") != criteria["role"]: + return False + if "name" in criteria and element.get("name") != criteria["name"]: + return False + contains = criteria.get("name_contains") + return not contains or contains in str(element.get("name", "")) + + +def _appears(after: Sequence[Element], before: Optional[Sequence[Element]], + param: Dict[str, Any]) -> Report: + in_after = any(_matches(e, param) for e in after) + in_before = before is not None and any(_matches(e, param) for e in before) + ok = in_after and not in_before + return ok, "appeared" if ok else "not a new appearance" + + +def _disappears(after: Sequence[Element], before: Optional[Sequence[Element]], + param: Dict[str, Any]) -> Report: + if before is None: + return False, "needs a before frame" + ok = any(_matches(e, param) for e in before) and \ + not any(_matches(e, param) for e in after) + return ok, "disappeared" if ok else "still present or never there" + + +def _enabled_state(after: Sequence[Element], param: Dict[str, Any], + want: bool) -> Report: + for element in after: + if _matches(element, param): + return bool(element.get("enabled", True)) == want, \ + f"enabled={element.get('enabled', True)}" + return False, "element not found" + + +def _enabled(after, before, param): + return _enabled_state(after, param, True) + + +def _disabled(after, before, param): + return _enabled_state(after, param, False) + + +def _text(after: Sequence[Element], text: Any, want_present: bool) -> Report: + found = any(str(text) in str(e.get("name", "")) for e in after) + return found == want_present, "present" if found else "absent" + + +def _text_present(after, before, param): + return _text(after, param, True) + + +def _text_absent(after, before, param): + return _text(after, param, False) + + +def _count(after, before, param: Dict[str, Any]) -> Report: + number = sum(1 for e in after if _matches(e, param.get("match", {}))) + if "equals" in param: + return number == int(param["equals"]), f"count={number}" + if "min" in param: + return number >= int(param["min"]), f"count={number}" + return False, "count clause needs 'equals' or 'min'" + + +_CLAUSES: Dict[str, Callable[[Sequence[Element], Optional[Sequence[Element]], + Dict[str, Any]], Report]] = { + "appears": _appears, "disappears": _disappears, + "enabled": _enabled, "disabled": _disabled, + "text_present": _text_present, "text_absent": _text_absent, "count": _count, +} + + +def check_postcondition(after: Sequence[Element], spec: Dict[str, Any], *, + before: Optional[Sequence[Element]] = None + ) -> PostconditionReport: + """Evaluate a postcondition ``spec`` against the after-frame (optional before-frame).""" + after_list = list(after) + before_list = list(before) if before is not None else None + clauses: List[Dict[str, Any]] = [] + for key, param in spec.items(): + checker = _CLAUSES.get(key) + if checker is None: + clauses.append({"type": key, "ok": False, "detail": "unknown clause"}) + continue + ok, detail = checker(after_list, before_list, param) + clauses.append({"type": key, "ok": ok, "detail": detail}) + failed = [clause["type"] for clause in clauses if not clause["ok"]] + return PostconditionReport(ok=not failed, clauses=clauses, failed=failed) + + +def compile_postcondition(spec: Dict[str, Any]) -> Callable[[Sequence[Element]], bool]: + """Return a predicate ``after -> bool`` for the spec (for use with ``expect_poll``).""" + def predicate(after: Sequence[Element]) -> bool: + return check_postcondition(after, spec).ok + return predicate diff --git a/test/unit_test/headless/test_postcondition_batch.py b/test/unit_test/headless/test_postcondition_batch.py new file mode 100644 index 00000000..4d2dfb24 --- /dev/null +++ b/test/unit_test/headless/test_postcondition_batch.py @@ -0,0 +1,80 @@ +"""Headless tests for declarative action postconditions (pure stdlib).""" +import je_auto_control as ac +from je_auto_control.utils.postcondition import ( + check_postcondition, compile_postcondition, +) + + +def _el(name, role="button", enabled=True): + return {"role": role, "name": name, "enabled": enabled, "x": 0, "y": 0, + "width": 10, "height": 10} + + +def test_new_dialog_appears_against_before(): + before = [_el("Save", role="button")] + after = [_el("Save"), _el("Saved", role="dialog")] + report = check_postcondition(after, {"appears": {"role": "dialog"}}, + before=before) + assert report.ok is True + + +def test_appears_fails_if_already_present(): + before = [_el("Saved", role="dialog")] + after = [_el("Saved", role="dialog")] + report = check_postcondition(after, {"appears": {"role": "dialog"}}, + before=before) + assert report.ok is False and "appears" in report.failed + + +def test_disabled_and_text_present_clauses(): + after = [_el("Submit", enabled=False), _el("Saved", role="dialog")] + report = check_postcondition(after, {"disabled": {"name": "Submit"}, + "text_present": "Saved"}) + assert report.ok is True + assert all(c["ok"] for c in report.clauses) + + +def test_count_clause(): + after = [_el(f"r{i}", role="row") for i in range(5)] + assert check_postcondition(after, {"count": {"match": {"role": "row"}, + "equals": 5}}).ok is True + assert check_postcondition(after, {"count": {"match": {"role": "row"}, + "min": 6}}).ok is False + + +def test_disappears_needs_before_and_works(): + before = [_el("Spinner", role="img")] + after = [_el("Done", role="dialog")] + assert check_postcondition(after, {"disappears": {"role": "img"}}, + before=before).ok is True + # without a before frame, disappears cannot be judged → fails + assert check_postcondition(after, {"disappears": {"role": "img"}}).ok is False + + +def test_unknown_clause_fails_cleanly(): + report = check_postcondition([_el("X")], {"levitates": {"name": "X"}}) + assert report.ok is False and "levitates" in report.failed + + +def test_compile_postcondition_predicate(): + predicate = compile_postcondition({"text_present": "OK"}) + assert predicate([_el("OK dialog", role="dialog")]) is True + assert predicate([_el("Nope")]) is False + + +# --- wiring --------------------------------------------------------------- + +def test_wiring(): + assert "AC_check_postcondition" in set(ac.executor.known_commands()) + 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_check_postcondition" in names + from je_auto_control.gui.script_builder.command_schema import _build_specs + specs = {s.command for s in _build_specs()} + assert "AC_check_postcondition" in specs + + +def test_facade_exports(): + for name in ("check_postcondition", "compile_postcondition", + "PostconditionReport"): + assert hasattr(ac, name) and name in ac.__all__