diff --git a/README/WHATS_NEW_zh-CN.md b/README/WHATS_NEW_zh-CN.md index 8e404b1b..4e70958d 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/v148_features_doc.rst`](../docs/source/Zh/doc/new_features/v148_features_doc.rst)。 + +- **`SoftAssertions`**(`AC_soft_assert`):`assert_all` 接受事先建好的规格列表——没有可随处调用 `check()`、并在区块退出时一次抛出全部的作用域累加器(JUnit5 `assertAll` / Playwright `expect.soft`)。`with SoftAssertions() as soft: soft.check(...)` 记录通过/失败(区块中永不抛出、返回布尔值可分支),退出时一次抛出列出每个失败——且永不遮蔽已在传播的异常。执行器命令汇整 JSON `checks` 列表(eq/ne/gt/lt/contains/truthy)。纯标准库、可无头测试。 + ## 本次更新 (2026-06-23) — 窗口 Z-order(置顶 / 最前 / 最后) 把窗口钉在最上层、移到最前、或推到后面。完整参考:[`docs/source/Zh/doc/new_features/v147_features_doc.rst`](../docs/source/Zh/doc/new_features/v147_features_doc.rst)。 diff --git a/README/WHATS_NEW_zh-TW.md b/README/WHATS_NEW_zh-TW.md index 2b99f10d..b23336e5 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/v148_features_doc.rst`](../docs/source/Zh/doc/new_features/v148_features_doc.rst)。 + +- **`SoftAssertions`**(`AC_soft_assert`):`assert_all` 接受事先建好的規格清單——沒有可隨處呼叫 `check()`、並在區塊退出時一次拋出全部的作用域累加器(JUnit5 `assertAll` / Playwright `expect.soft`)。`with SoftAssertions() as soft: soft.check(...)` 記錄通過/失敗(區塊中永不拋出、回傳布林值可分支),退出時一次拋出列出每個失敗——且永不遮蔽已在傳播的例外。執行器命令彙整 JSON `checks` 清單(eq/ne/gt/lt/contains/truthy)。純標準函式庫、可無頭測試。 + ## 本次更新 (2026-06-23) — 視窗 Z-order(置頂 / 最前 / 最後) 把視窗釘在最上層、移到最前、或推到後面。完整參考:[`docs/source/Zh/doc/new_features/v147_features_doc.rst`](../docs/source/Zh/doc/new_features/v147_features_doc.rst)。 diff --git a/WHATS_NEW.md b/WHATS_NEW.md index 2fab21c0..6e664e2d 100644 --- a/WHATS_NEW.md +++ b/WHATS_NEW.md @@ -1,5 +1,11 @@ # What's New — AutoControl +## What's new (2026-06-23) — Soft Assertions (Aggregate Failures) + +Verify many things, report every failure at once. Full reference: [`docs/source/Eng/doc/new_features/v148_features_doc.rst`](docs/source/Eng/doc/new_features/v148_features_doc.rst). + +- **`SoftAssertions`** (`AC_soft_assert`): `assert_all` takes a pre-built spec list up front — there was no scoped accumulator you sprinkle `check()` calls into that raises everything on block exit (JUnit5 `assertAll` / Playwright `expect.soft`). `with SoftAssertions() as soft: soft.check(...)` records pass/fail (never raising mid-block, returns the bool to branch on), then raises once on exit listing every failure — and never masks an exception already propagating. The executor command aggregates a JSON `checks` list (eq/ne/gt/lt/contains/truthy). Pure-stdlib, headless-testable. + ## What's new (2026-06-23) — Window Z-Order (Always-On-Top / Front / Back) Pin a window on top, raise it, or push it behind. Full reference: [`docs/source/Eng/doc/new_features/v147_features_doc.rst`](docs/source/Eng/doc/new_features/v147_features_doc.rst). diff --git a/docs/source/Eng/doc/new_features/v148_features_doc.rst b/docs/source/Eng/doc/new_features/v148_features_doc.rst new file mode 100644 index 00000000..db96db9d --- /dev/null +++ b/docs/source/Eng/doc/new_features/v148_features_doc.rst @@ -0,0 +1,39 @@ +Soft Assertions — Aggregate Failures at Block End +================================================= + +``assertion.assert_all`` takes a **pre-built list of spec dicts up front**. There is no +*scoped accumulator* you sprinkle ``check()`` calls into across interleaved actions and +that raises everything at once on exit — the JUnit5 ``assertAll`` / Playwright +``expect.soft`` / AssertJ ``SoftAssertions`` pattern, the standard ergonomics for +verifying many fields of a form without stopping at the first failure. + +Pure-stdlib context manager; imports no ``PySide6``. + +Headless API +------------ + +.. code-block:: python + + from je_auto_control import SoftAssertions + + with SoftAssertions() as soft: + soft.check(title == "Invoice", "wrong title") + soft.check_equal(total, "$42.00", "wrong total") + soft.check(date_field_is_visible(), "date field missing") + # on exit, raises once listing EVERY failed check (or nothing if all passed) + +``check(condition, message)`` records a pass/fail and never raises (it returns the +bool, so you can branch on it); ``check_equal(actual, expected, message)`` is the +equality shortcut. ``failures`` lists the failed messages, ``passed`` counts the +passes, and ``assert_all()`` raises ``AutoControlActionException`` aggregating them. +The context manager calls ``assert_all`` on a clean exit (and never masks an exception +already propagating). Pass ``raise_on_exit=False`` to collect without auto-raising. + +Executor command +---------------- + +``AC_soft_assert`` evaluates a list of ``checks`` (each ``{value, op, expected, +message}`` with ``op`` = ``eq`` / ``ne`` / ``gt`` / ``lt`` / ``contains`` / +``truthy``) and returns ``{ok, passed, failures}`` — reporting *all* failures, not +just the first; set ``raise_on_fail`` to raise instead. It is exposed as the MCP tool +``ac_soft_assert`` 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 41045d39..cec95c77 100644 --- a/docs/source/Eng/eng_index.rst +++ b/docs/source/Eng/eng_index.rst @@ -170,6 +170,7 @@ Comprehensive guides for all AutoControl features. doc/new_features/v145_features_doc doc/new_features/v146_features_doc doc/new_features/v147_features_doc + doc/new_features/v148_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/v148_features_doc.rst b/docs/source/Zh/doc/new_features/v148_features_doc.rst new file mode 100644 index 00000000..4e19bba4 --- /dev/null +++ b/docs/source/Zh/doc/new_features/v148_features_doc.rst @@ -0,0 +1,34 @@ +軟性斷言——區塊結束時彙整所有失敗 +==================================== + +``assertion.assert_all`` 接受**事先建好的規格字典清單**。沒有一個可以在交錯動作之間隨處呼叫 ``check()``、並在退出時 +一次拋出全部的*作用域累加器*——也就是 JUnit5 ``assertAll`` / Playwright ``expect.soft`` / AssertJ ``SoftAssertions`` +模式,是驗證表單眾多欄位而不在第一個失敗就停下的標準寫法。 + +純標準函式庫的 context manager;不匯入 ``PySide6``。 + +無頭 API +-------- + +.. code-block:: python + + from je_auto_control import SoftAssertions + + with SoftAssertions() as soft: + soft.check(title == "Invoice", "wrong title") + soft.check_equal(total, "$42.00", "wrong total") + soft.check(date_field_is_visible(), "date field missing") + # 退出時一次拋出列出每一個失敗的檢查(全部通過則不拋) + +``check(condition, message)`` 記錄通過/失敗且永不拋出(回傳布林值,可據以分支);``check_equal(actual, expected, +message)`` 是相等捷徑。``failures`` 列出失敗訊息、``passed`` 計算通過數、``assert_all()`` 彙整後丟出 +``AutoControlActionException``。context manager 在乾淨退出時呼叫 ``assert_all``(且永不遮蔽已在傳播的例外)。 +傳入 ``raise_on_exit=False`` 可只收集不自動拋出。 + +執行器命令 +---------- + +``AC_soft_assert`` 評估一串 ``checks``(每個為 ``{value, op, expected, message}``,``op`` = +``eq`` / ``ne`` / ``gt`` / ``lt`` / ``contains`` / ``truthy``)並回傳 ``{ok, passed, failures}``——回報*所有*失敗, +不只第一個;設 ``raise_on_fail`` 則改為拋出。它以 MCP 工具 ``ac_soft_assert`` 以及 Script Builder 中 **Flow** +分類下的命令提供。 diff --git a/docs/source/Zh/zh_index.rst b/docs/source/Zh/zh_index.rst index fb1dbf0b..ea787926 100644 --- a/docs/source/Zh/zh_index.rst +++ b/docs/source/Zh/zh_index.rst @@ -170,6 +170,7 @@ AutoControl 所有功能的完整使用指南。 doc/new_features/v145_features_doc doc/new_features/v146_features_doc doc/new_features/v147_features_doc + doc/new_features/v148_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 4c36f5b4..7c23de29 100644 --- a/je_auto_control/__init__.py +++ b/je_auto_control/__init__.py @@ -351,6 +351,8 @@ from je_auto_control.utils.window_zorder import ( bring_to_front, plan_zorder, send_to_back, set_topmost, ) +# Soft assertions (accumulate checks, raise the aggregate at block end) +from je_auto_control.utils.soft_assert import SoftAssertions # CI workflow annotations (GitHub Actions) from je_auto_control.utils.ci_annotations import ( emit_annotations, format_annotation, @@ -1217,6 +1219,7 @@ def start_autocontrol_gui(*args, **kwargs): "set_topmost", "bring_to_front", "send_to_back", + "SoftAssertions", "emit_annotations", "format_annotation", "ClipboardHistory", "default_clipboard_history", "analyze_heal_log", "heal_stats", "scan_secrets", diff --git a/je_auto_control/gui/script_builder/command_schema.py b/je_auto_control/gui/script_builder/command_schema.py index 00d83a68..2821e82c 100644 --- a/je_auto_control/gui/script_builder/command_schema.py +++ b/je_auto_control/gui/script_builder/command_schema.py @@ -862,6 +862,16 @@ def _add_flow_specs(specs: List[CommandSpec]) -> None: ), description="Re-run an action until a key of its result matches.", )) + specs.append(CommandSpec( + "AC_soft_assert", "Flow", "Soft Assert (aggregate)", + fields=( + FieldSpec("checks", FieldType.STRING, + placeholder='[{"value":5,"op":"gt","expected":3}]'), + FieldSpec("raise_on_fail", FieldType.BOOL, optional=True, + default=False), + ), + description="Aggregate many checks and report all failures (not just first).", + )) specs.append(CommandSpec( "AC_wait_pixel", "Flow", "Wait for Pixel", fields=( diff --git a/je_auto_control/utils/executor/action_executor.py b/je_auto_control/utils/executor/action_executor.py index 3bbec21e..2bf082da 100644 --- a/je_auto_control/utils/executor/action_executor.py +++ b/je_auto_control/utils/executor/action_executor.py @@ -3753,6 +3753,37 @@ def _send_to_back(title: str) -> Dict[str, Any]: return {"applied": send_to_back(title)} +def _eval_check(op: str, value: Any, expected: Any) -> bool: + """Evaluate one soft-assert check by operator name.""" + table = {"eq": lambda: value == expected, + "ne": lambda: value != expected, + "gt": lambda: value > expected, + "lt": lambda: value < expected, + "contains": lambda: expected in value, + "truthy": lambda: bool(value)} + if op not in table: + raise AutoControlActionException(f"unknown soft-assert op: {op!r}") + return bool(table[op]()) + + +def _soft_assert(checks: Any, raise_on_fail: Any = False) -> Dict[str, Any]: + """Adapter: aggregate a list of {value, op, expected, message} checks.""" + import json + from je_auto_control.utils.soft_assert import SoftAssertions + if isinstance(checks, str): + checks = json.loads(checks) + soft = SoftAssertions(raise_on_exit=False) + for check in checks or (): + op = str(check.get("op", "truthy")) + ok = _eval_check(op, check.get("value"), check.get("expected")) + soft.check(ok, check.get("message", "") + or f"{check.get('value')!r} {op} {check.get('expected')!r}") + if raise_on_fail: + soft.assert_all() + return {"ok": not soft.failures, "passed": soft.passed, + "failures": soft.failures} + + def _with_modifiers(modifiers: Any, actions: Any) -> Dict[str, Any]: """Adapter: run nested actions while modifier keys are held down.""" import json @@ -5503,6 +5534,7 @@ def __init__(self): "AC_set_topmost": _set_topmost, "AC_bring_to_front": _bring_to_front, "AC_send_to_back": _send_to_back, + "AC_soft_assert": _soft_assert, "AC_tile_rect": _tile_rect, "AC_grid_rects": _grid_rects, "AC_cascade_rects": _cascade_rects, diff --git a/je_auto_control/utils/mcp_server/tools/_factories.py b/je_auto_control/utils/mcp_server/tools/_factories.py index d24ca817..35b69ba5 100644 --- a/je_auto_control/utils/mcp_server/tools/_factories.py +++ b/je_auto_control/utils/mcp_server/tools/_factories.py @@ -3190,6 +3190,25 @@ def window_zorder_tools() -> List[MCPTool]: ] +def soft_assert_tools() -> List[MCPTool]: + return [ + MCPTool( + name="ac_soft_assert", + description=("Evaluate a list of 'checks' and aggregate ALL failures " + "(don't stop at the first). Each is {value, op, expected, " + "message}; op = eq/ne/gt/lt/contains/truthy. Returns " + "{ok, passed, failures}; set 'raise_on_fail' to raise on " + "any failure."), + input_schema=schema({ + "checks": {"type": "array", "items": {"type": "object"}}, + "raise_on_fail": {"type": "boolean"}}, + required=["checks"]), + handler=h.soft_assert, + annotations=READ_ONLY, + ), + ] + + def ssim_tools() -> List[MCPTool]: return [ MCPTool( @@ -6696,7 +6715,8 @@ def media_assert_tools() -> List[MCPTool]: monitor_layout_tools, actionability_tools, element_parse_tools, hsv_segment_tools, text_regions_tools, edge_lines_tools, expect_poll_tools, locator_chain_tools, rich_clipboard_tools, img_histogram_tools, - motion_regions_tools, window_zorder_tools, plugin_sdk_tools, governance_tools, + motion_regions_tools, window_zorder_tools, soft_assert_tools, + plugin_sdk_tools, governance_tools, credential_lease_tools, egress_tools, approval_testing_tools, trajectory_eval_tools, compliance_tools, agent_trace_tools, video_report_tools, fuzzy_tools, artifact_store_tools, image_dedup_tools, diff --git a/je_auto_control/utils/mcp_server/tools/_handlers.py b/je_auto_control/utils/mcp_server/tools/_handlers.py index f783770b..fd16d66d 100644 --- a/je_auto_control/utils/mcp_server/tools/_handlers.py +++ b/je_auto_control/utils/mcp_server/tools/_handlers.py @@ -2283,6 +2283,11 @@ def send_to_back(title): return _send_to_back(title) +def soft_assert(checks, raise_on_fail=False): + from je_auto_control.utils.executor.action_executor import _soft_assert + return _soft_assert(checks, raise_on_fail) + + def detect_drift(reference, current, threshold=0.25, bins=10): from je_auto_control.utils.executor.action_executor import _detect_drift return _detect_drift(reference, current, threshold, bins) diff --git a/je_auto_control/utils/soft_assert/__init__.py b/je_auto_control/utils/soft_assert/__init__.py new file mode 100644 index 00000000..1a6b6b9c --- /dev/null +++ b/je_auto_control/utils/soft_assert/__init__.py @@ -0,0 +1,4 @@ +"""Soft assertions — accumulate checks and raise the aggregate at block end.""" +from je_auto_control.utils.soft_assert.soft_assert import SoftAssertions + +__all__ = ["SoftAssertions"] diff --git a/je_auto_control/utils/soft_assert/soft_assert.py b/je_auto_control/utils/soft_assert/soft_assert.py new file mode 100644 index 00000000..cbf58a0b --- /dev/null +++ b/je_auto_control/utils/soft_assert/soft_assert.py @@ -0,0 +1,58 @@ +"""Soft assertions — accumulate checks across a block, raise them all at the end. + +``assertion.assert_all`` takes a **pre-built list of spec dicts up front**. There is no +*scoped accumulator* you sprinkle ``check()`` calls into across interleaved actions and +that raises everything at once on exit — the JUnit5 ``assertAll`` / Playwright +``expect.soft`` / AssertJ ``SoftAssertions`` pattern, the standard ergonomics for +verifying many fields of a form without stopping at the first failure. + +Pure-stdlib context manager; imports no ``PySide6``. +""" +from typing import Any, List + +from je_auto_control.utils.exception.exceptions import AutoControlActionException + + +class SoftAssertions: + """A scope that records pass/fail checks and raises the aggregate on exit.""" + + def __init__(self, raise_on_exit: bool = True): + self._results: List[tuple] = [] + self._raise_on_exit = bool(raise_on_exit) + + def check(self, condition: Any, message: str = "") -> bool: + """Record a truthy/falsy ``condition`` (never raises); return its bool.""" + ok = bool(condition) + self._results.append((ok, str(message) or "assertion failed")) + return ok + + def check_equal(self, actual: Any, expected: Any, message: str = "") -> bool: + """Record that ``actual == expected``.""" + return self.check(actual == expected, + message or f"expected {expected!r}, got {actual!r}") + + @property + def failures(self) -> List[str]: + """The messages of every failed check, in order.""" + return [message for ok, message in self._results if not ok] + + @property + def passed(self) -> int: + """How many checks passed.""" + return sum(1 for ok, _message in self._results if ok) + + def assert_all(self) -> None: + """Raise ``AutoControlActionException`` if any recorded check failed.""" + failures = self.failures + if failures: + raise AutoControlActionException( + f"{len(failures)} soft assertion(s) failed: " + + "; ".join(failures)) + + def __enter__(self) -> "SoftAssertions": + return self + + def __exit__(self, exc_type, _exc, _tb) -> bool: + if exc_type is None and self._raise_on_exit: + self.assert_all() + return False diff --git a/test/unit_test/headless/test_soft_assert_batch.py b/test/unit_test/headless/test_soft_assert_batch.py new file mode 100644 index 00000000..45fa272b --- /dev/null +++ b/test/unit_test/headless/test_soft_assert_batch.py @@ -0,0 +1,71 @@ +"""Headless tests for soft assertions. No Qt.""" +import pytest + +import je_auto_control as ac +from je_auto_control.utils.exception.exceptions import AutoControlActionException +from je_auto_control.utils.soft_assert import SoftAssertions + + +def test_all_pass_does_not_raise(): + with SoftAssertions() as soft: + soft.check(2 > 1, "one") + soft.check_equal("ok", "ok") + assert soft.passed == 2 and soft.failures == [] + + +def test_aggregates_failures_on_exit(): + with pytest.raises(AutoControlActionException) as excinfo: + with SoftAssertions() as soft: + soft.check(True, "a") + soft.check(False, "b failed") + soft.check_equal(1, 2, "c failed") + message = str(excinfo.value) + assert "b failed" in message and "c failed" in message + assert "2 soft assertion" in message + + +def test_check_returns_bool_and_records(): + soft = SoftAssertions(raise_on_exit=False) + assert soft.check(True) is True + assert soft.check(False, "nope") is False + assert soft.passed == 1 and soft.failures == ["nope"] + + +def test_exit_does_not_mask_existing_exception(): + with pytest.raises(KeyError): + with SoftAssertions() as soft: + soft.check(False, "would-fail") + raise KeyError("real error") # must propagate, not aggregated + + +def test_manual_assert_all(): + soft = SoftAssertions(raise_on_exit=False) + soft.check(False, "x") + with pytest.raises(AutoControlActionException): + soft.assert_all() + + +# --- wiring --------------------------------------------------------------- + +def test_wiring(): + assert "AC_soft_assert" 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_soft_assert" in names + from je_auto_control.gui.script_builder.command_schema import _build_specs + specs = {s.command for s in _build_specs()} + assert "AC_soft_assert" in specs + + +def test_executor_aggregates_checks(): + from je_auto_control.utils.executor.action_executor import _soft_assert + result = _soft_assert([ + {"value": 5, "op": "gt", "expected": 3, "message": "five>three"}, + {"value": "abc", "op": "contains", "expected": "z", "message": "no z"}, + {"value": 0, "op": "truthy", "message": "zero falsy"}]) + assert result["ok"] is False and result["passed"] == 1 + assert "no z" in result["failures"] and "zero falsy" in result["failures"] + + +def test_facade_exports(): + assert hasattr(ac, "SoftAssertions") and "SoftAssertions" in ac.__all__