Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions README/WHATS_NEW_zh-CN.md
Original file line number Diff line number Diff line change
@@ -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)。
Expand Down
6 changes: 6 additions & 0 deletions README/WHATS_NEW_zh-TW.md
Original file line number Diff line number Diff line change
@@ -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)。
Expand Down
6 changes: 6 additions & 0 deletions WHATS_NEW.md
Original file line number Diff line number Diff line change
@@ -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).
Expand Down
39 changes: 39 additions & 0 deletions docs/source/Eng/doc/new_features/v148_features_doc.rst
Original file line number Diff line number Diff line change
@@ -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**.
1 change: 1 addition & 0 deletions docs/source/Eng/eng_index.rst
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
34 changes: 34 additions & 0 deletions docs/source/Zh/doc/new_features/v148_features_doc.rst
Original file line number Diff line number Diff line change
@@ -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**
分類下的命令提供。
1 change: 1 addition & 0 deletions docs/source/Zh/zh_index.rst
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
3 changes: 3 additions & 0 deletions je_auto_control/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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",
Expand Down
10 changes: 10 additions & 0 deletions je_auto_control/gui/script_builder/command_schema.py
Original file line number Diff line number Diff line change
Expand Up @@ -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=(
Expand Down
32 changes: 32 additions & 0 deletions je_auto_control/utils/executor/action_executor.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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,
Expand Down
22 changes: 21 additions & 1 deletion je_auto_control/utils/mcp_server/tools/_factories.py
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand Down Expand Up @@ -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,
Expand Down
5 changes: 5 additions & 0 deletions je_auto_control/utils/mcp_server/tools/_handlers.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
4 changes: 4 additions & 0 deletions je_auto_control/utils/soft_assert/__init__.py
Original file line number Diff line number Diff line change
@@ -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"]
58 changes: 58 additions & 0 deletions je_auto_control/utils/soft_assert/soft_assert.py
Original file line number Diff line number Diff line change
@@ -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
71 changes: 71 additions & 0 deletions test/unit_test/headless/test_soft_assert_batch.py
Original file line number Diff line number Diff line change
@@ -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__
Loading