From b7dfc4ad9d0f27f9cfcdb4d2826f4d7bbc7a0bc3 Mon Sep 17 00:00:00 2001 From: JeffreyChen Date: Sat, 20 Jun 2026 20:52:19 +0800 Subject: [PATCH] Add DMN-style decision tables --- README.md | 7 ++ README/README_zh-CN.md | 7 ++ README/README_zh-TW.md | 7 ++ .../Eng/doc/new_features/v53_features_doc.rst | 55 +++++++++ docs/source/Eng/eng_index.rst | 1 + .../Zh/doc/new_features/v53_features_doc.rst | 53 +++++++++ docs/source/Zh/zh_index.rst | 1 + je_auto_control/__init__.py | 5 + .../gui/script_builder/command_schema.py | 11 ++ .../utils/decision_table/__init__.py | 6 + .../utils/decision_table/decision_table.py | 97 ++++++++++++++++ .../utils/executor/action_executor.py | 12 ++ .../utils/mcp_server/tools/_factories.py | 20 +++- .../utils/mcp_server/tools/_handlers.py | 5 + .../headless/test_decision_table_batch.py | 106 ++++++++++++++++++ 15 files changed, 392 insertions(+), 1 deletion(-) create mode 100644 docs/source/Eng/doc/new_features/v53_features_doc.rst create mode 100644 docs/source/Zh/doc/new_features/v53_features_doc.rst create mode 100644 je_auto_control/utils/decision_table/__init__.py create mode 100644 je_auto_control/utils/decision_table/decision_table.py create mode 100644 test/unit_test/headless/test_decision_table_batch.py diff --git a/README.md b/README.md index ca7a81f1..654c10da 100644 --- a/README.md +++ b/README.md @@ -13,6 +13,7 @@ ## Table of Contents +- [What's new (2026-06-20) — DMN-Style Decision Tables](#whats-new-2026-06-20--dmn-style-decision-tables) - [What's new (2026-06-20) — Saga / Compensating Rollback](#whats-new-2026-06-20--saga--compensating-rollback) - [What's new (2026-06-20) — JSONPath Querying](#whats-new-2026-06-20--jsonpath-querying) - [What's new (2026-06-20) — Multi-Channel Webhook Notifications](#whats-new-2026-06-20--multi-channel-webhook-notifications) @@ -105,6 +106,12 @@ --- +## What's new (2026-06-20) — DMN-Style Decision Tables + +Externalize branching into reviewable rule tables. Full reference: [`docs/source/Eng/doc/new_features/v53_features_doc.rst`](docs/source/Eng/doc/new_features/v53_features_doc.rst). + +- **`evaluate_table` / `DecisionTable`** (`AC_decision_table`, `ac_decision_table`): replaces nested `AC_if_var` chains with rows of `conditions -> outputs` and a hit policy (`UNIQUE`/`FIRST`/`PRIORITY`/`COLLECT`). Cell conditions are wildcard / literal / `{op, value}` using the executor's standard comparators (reused, not duplicated). Pure-stdlib, fully testable; the DMN way to keep business rules data-driven. + ## What's new (2026-06-20) — Saga / Compensating Rollback Undo completed steps when a later one fails. Full reference: [`docs/source/Eng/doc/new_features/v52_features_doc.rst`](docs/source/Eng/doc/new_features/v52_features_doc.rst). diff --git a/README/README_zh-CN.md b/README/README_zh-CN.md index a66115df..036b9db9 100644 --- a/README/README_zh-CN.md +++ b/README/README_zh-CN.md @@ -12,6 +12,7 @@ ## 目录 +- [本次更新 (2026-06-20) — DMN 式决策表](#本次更新-2026-06-20--dmn-式决策表) - [本次更新 (2026-06-20) — Saga / 补偿回滚](#本次更新-2026-06-20--saga--补偿回滚) - [本次更新 (2026-06-20) — JSONPath 查询](#本次更新-2026-06-20--jsonpath-查询) - [本次更新 (2026-06-20) — 多通道 Webhook 通知](#本次更新-2026-06-20--多通道-webhook-通知) @@ -104,6 +105,12 @@ --- +## 本次更新 (2026-06-20) — DMN 式决策表 + +将分支外部化为可审查的规则表。完整参考:[`docs/source/Zh/doc/new_features/v53_features_doc.rst`](../docs/source/Zh/doc/new_features/v53_features_doc.rst)。 + +- **`evaluate_table` / `DecisionTable`**(`AC_decision_table`、`ac_decision_table`):以一列列的 `conditions -> outputs` 加命中政策(`UNIQUE`/`FIRST`/`PRIORITY`/`COLLECT`)取代嵌套 `AC_if_var` 链。单元格条件为通配符 / 字面值 / `{op, value}`,使用执行器标准比较子(重用,不重复)。纯标准库、可完整测试;DMN 让业务规则数据驱动的方式。 + ## 本次更新 (2026-06-20) — Saga / 补偿回滚 后续步骤失败时回滚已完成步骤。完整参考:[`docs/source/Zh/doc/new_features/v52_features_doc.rst`](../docs/source/Zh/doc/new_features/v52_features_doc.rst)。 diff --git a/README/README_zh-TW.md b/README/README_zh-TW.md index 022d08e0..873784f2 100644 --- a/README/README_zh-TW.md +++ b/README/README_zh-TW.md @@ -12,6 +12,7 @@ ## 目錄 +- [本次更新 (2026-06-20) — DMN 式決策表](#本次更新-2026-06-20--dmn-式決策表) - [本次更新 (2026-06-20) — Saga / 補償回溯](#本次更新-2026-06-20--saga--補償回溯) - [本次更新 (2026-06-20) — JSONPath 查詢](#本次更新-2026-06-20--jsonpath-查詢) - [本次更新 (2026-06-20) — 多通道 Webhook 通知](#本次更新-2026-06-20--多通道-webhook-通知) @@ -104,6 +105,12 @@ --- +## 本次更新 (2026-06-20) — DMN 式決策表 + +將分支外部化為可審查的規則表。完整參考:[`docs/source/Zh/doc/new_features/v53_features_doc.rst`](../docs/source/Zh/doc/new_features/v53_features_doc.rst)。 + +- **`evaluate_table` / `DecisionTable`**(`AC_decision_table`、`ac_decision_table`):以一列列的 `conditions -> outputs` 加命中政策(`UNIQUE`/`FIRST`/`PRIORITY`/`COLLECT`)取代巢狀 `AC_if_var` 鏈。儲存格條件為萬用字元 / 字面值 / `{op, value}`,使用執行器標準比較子(重用,不重複)。純標準函式庫、可完整測試;DMN 讓商業規則資料驅動的方式。 + ## 本次更新 (2026-06-20) — Saga / 補償回溯 後續步驟失敗時復原已完成步驟。完整參考:[`docs/source/Zh/doc/new_features/v52_features_doc.rst`](../docs/source/Zh/doc/new_features/v52_features_doc.rst)。 diff --git a/docs/source/Eng/doc/new_features/v53_features_doc.rst b/docs/source/Eng/doc/new_features/v53_features_doc.rst new file mode 100644 index 00000000..5135afc5 --- /dev/null +++ b/docs/source/Eng/doc/new_features/v53_features_doc.rst @@ -0,0 +1,55 @@ +DMN-Style Decision Tables +========================= + +Nested ``AC_if_var`` chains get unreadable fast. A decision table externalizes +branching into rows of ``conditions -> outputs`` evaluated by a **hit policy** — +the DMN way to keep business rules data-driven and reviewable. + +Each cell condition is a wildcard (``None`` / ``"-"`` / ``"*"``), a literal +(equality), or ``{"op": "ge", "value": 18}`` using the project's standard +comparators (``eq/ne/lt/le/gt/ge/contains/startswith/endswith`` — reused from the +executor, not duplicated). Pure standard library; imports no ``PySide6``. + +Hit policies +------------ + +================ =================================================== +Policy Behavior +================ =================================================== +``UNIQUE`` Exactly one rule may match (raises if more do). +``FIRST`` The first matching rule wins. +``PRIORITY`` Same as FIRST — first match in rule order. +``COLLECT`` All matching rules' outputs (a list). +================ =================================================== + +Headless API +------------ + +.. code-block:: python + + from je_auto_control import evaluate_table + + spec = { + "inputs": ["age", "country"], + "hit_policy": "FIRST", + "rules": [ + {"conditions": {"age": {"op": "lt", "value": 18}}, + "outputs": {"tier": "minor"}}, + {"conditions": {"age": {"op": "ge", "value": 18}, "country": "US"}, + "outputs": {"tier": "us-adult"}}, + {"conditions": {"age": {"op": "ge", "value": 18}}, + "outputs": {"tier": "adult"}}, + ], + } + evaluate_table(spec, {"age": 30, "country": "DE"}) # -> {"tier": "adult"} + +``evaluate_table`` returns the matched outputs dict (or ``{}`` if none) for +single-hit policies, and a list for ``COLLECT``. The ``DecisionTable`` class +(``from_dict`` / ``evaluate``) is available for reuse. + +Executor command +---------------- + +``AC_decision_table`` takes ``spec`` and ``context`` (each a dict or JSON +string) and returns ``{result}``. The same operation is exposed as the MCP tool +``ac_decision_table`` 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 ea387f61..41a89b55 100644 --- a/docs/source/Eng/eng_index.rst +++ b/docs/source/Eng/eng_index.rst @@ -75,6 +75,7 @@ Comprehensive guides for all AutoControl features. doc/new_features/v50_features_doc doc/new_features/v51_features_doc doc/new_features/v52_features_doc + doc/new_features/v53_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/v53_features_doc.rst b/docs/source/Zh/doc/new_features/v53_features_doc.rst new file mode 100644 index 00000000..a58c022d --- /dev/null +++ b/docs/source/Zh/doc/new_features/v53_features_doc.rst @@ -0,0 +1,53 @@ +DMN 式決策表 +============ + +巢狀的 ``AC_if_var`` 鏈很快就難以閱讀。決策表將分支外部化為一列列的 ``conditions -> +outputs``,並以**命中政策(hit policy)**評估 —— 這是 DMN 讓商業規則維持資料驅動且易於審 +查的方式。 + +每個儲存格條件為萬用字元(``None`` / ``"-"`` / ``"*"``)、字面值(相等)或 ``{"op": "ge", +"value": 18}``,使用本專案標準比較子(``eq/ne/lt/le/gt/ge/contains/startswith/endswith`` +—— 重用自執行器,不重複)。純標準函式庫;不匯入 ``PySide6``。 + +命中政策 +-------- + +================ =================================================== +政策 行為 +================ =================================================== +``UNIQUE`` 僅可有一條規則命中(多於一條則拋例外)。 +``FIRST`` 第一條命中的規則勝出。 +``PRIORITY`` 同 FIRST —— 依規則順序取第一個命中。 +``COLLECT`` 所有命中規則的 outputs(清單)。 +================ =================================================== + +無頭 API +-------- + +.. code-block:: python + + from je_auto_control import evaluate_table + + spec = { + "inputs": ["age", "country"], + "hit_policy": "FIRST", + "rules": [ + {"conditions": {"age": {"op": "lt", "value": 18}}, + "outputs": {"tier": "minor"}}, + {"conditions": {"age": {"op": "ge", "value": 18}, "country": "US"}, + "outputs": {"tier": "us-adult"}}, + {"conditions": {"age": {"op": "ge", "value": 18}}, + "outputs": {"tier": "adult"}}, + ], + } + evaluate_table(spec, {"age": 30, "country": "DE"}) # -> {"tier": "adult"} + +``evaluate_table`` 對單一命中政策回傳命中的 outputs dict(若無則 ``{}``),``COLLECT`` 則 +回傳清單。``DecisionTable`` 類別(``from_dict`` / ``evaluate``)亦可重用。 + +執行器指令 +---------- + +``AC_decision_table`` 接受 ``spec`` 與 ``context``(各為 dict 或 JSON 字串),回傳 +``{result}``。相同操作亦提供為 MCP 工具 ``ac_decision_table``,以及 Script Builder 中 +**Flow** 分類下的指令。 diff --git a/docs/source/Zh/zh_index.rst b/docs/source/Zh/zh_index.rst index 6271153d..cc5c06fe 100644 --- a/docs/source/Zh/zh_index.rst +++ b/docs/source/Zh/zh_index.rst @@ -75,6 +75,7 @@ AutoControl 所有功能的完整使用指南。 doc/new_features/v50_features_doc doc/new_features/v51_features_doc doc/new_features/v52_features_doc + doc/new_features/v53_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 838b5d14..c3defdc7 100644 --- a/je_auto_control/__init__.py +++ b/je_auto_control/__init__.py @@ -282,6 +282,10 @@ ) # Saga orchestrator: multi-step flow with compensating rollback from je_auto_control.utils.saga import Saga, SagaResult, run_saga +# DMN-style decision tables (rules + hit policy) +from je_auto_control.utils.decision_table import ( + DecisionTable, Rule, evaluate_table, +) # Background popup/interrupt watchdog (unattended automation) from je_auto_control.utils.watchdog import ( PopupWatchdog, WatchdogRule, default_popup_watchdog, @@ -746,6 +750,7 @@ def start_autocontrol_gui(*args, **kwargs): "WebhookChannel", "WebhookResult", "notify_webhook", "set_default_poster", "json_extract", "json_query", "json_query_one", "Saga", "SagaResult", "run_saga", + "DecisionTable", "Rule", "evaluate_table", # MCP server "AuditLogger", "HttpMCPServer", "MCPContent", "MCPPrompt", "MCPPromptArgument", "MCPResource", "MCPServer", "MCPTool", diff --git a/je_auto_control/gui/script_builder/command_schema.py b/je_auto_control/gui/script_builder/command_schema.py index be698dc1..414f26c0 100644 --- a/je_auto_control/gui/script_builder/command_schema.py +++ b/je_auto_control/gui/script_builder/command_schema.py @@ -1154,6 +1154,17 @@ def _add_misc_specs(specs: List[CommandSpec]) -> None: ), description="Run steps; on failure undo completed steps LIFO.", )) + specs.append(CommandSpec( + "AC_decision_table", "Flow", "Decision Table (DMN)", + fields=( + FieldSpec("spec", FieldType.STRING, + placeholder='{"inputs": ["age"], "hit_policy": "FIRST", ' + '"rules": [...]}'), + FieldSpec("context", FieldType.STRING, + placeholder='{"age": 30}'), + ), + description="Evaluate inputs against a rule table (hit policy).", + )) specs.append(CommandSpec( "AC_generate_sop", "Report", "Generate SOP Document", fields=( diff --git a/je_auto_control/utils/decision_table/__init__.py b/je_auto_control/utils/decision_table/__init__.py new file mode 100644 index 00000000..fa0fe60d --- /dev/null +++ b/je_auto_control/utils/decision_table/__init__.py @@ -0,0 +1,6 @@ +"""DMN-style decision tables: rules + hit policy for externalized branching.""" +from je_auto_control.utils.decision_table.decision_table import ( + DecisionTable, Rule, evaluate_table, +) + +__all__ = ["DecisionTable", "Rule", "evaluate_table"] diff --git a/je_auto_control/utils/decision_table/decision_table.py b/je_auto_control/utils/decision_table/decision_table.py new file mode 100644 index 00000000..8afb1316 --- /dev/null +++ b/je_auto_control/utils/decision_table/decision_table.py @@ -0,0 +1,97 @@ +"""Evaluate inputs against a table of rules (a DMN-style decision table). + +Nested ``AC_if_var`` chains get unreadable fast; a decision table externalizes +branching into rows of ``conditions -> outputs`` with a *hit policy*. Each cell +condition is a wildcard (``None`` / ``"-"`` / ``"*"``), a literal (equality), or +``{"op": "ge", "value": 18}`` using the project's standard comparators +(``eq/ne/lt/le/gt/ge/contains/startswith/endswith`` — reused from the executor, +not duplicated). + +Hit policies: ``UNIQUE`` (exactly one rule may match, else error), ``FIRST`` / +``PRIORITY`` (first matching rule wins), ``COLLECT`` (all matches). Pure standard +library; imports no ``PySide6``. +""" +from dataclasses import dataclass, field +from typing import Any, Dict, List, Mapping + +HIT_UNIQUE = "UNIQUE" +HIT_FIRST = "FIRST" +HIT_PRIORITY = "PRIORITY" +HIT_COLLECT = "COLLECT" +_WILDCARDS = (None, "", "-", "*") + + +@dataclass +class Rule: + """One table row: per-input conditions and the outputs to return.""" + + conditions: Dict[str, Any] = field(default_factory=dict) + outputs: Dict[str, Any] = field(default_factory=dict) + + +def _comparators() -> Dict[str, Any]: + from je_auto_control.utils.executor.flow_control import _COMPARATORS + return _COMPARATORS + + +def _cell_matches(value: Any, condition: Any) -> bool: + if condition in _WILDCARDS: + return True + if isinstance(condition, dict) and "op" in condition: + comparator = _comparators().get(condition["op"]) + if comparator is None: + raise ValueError(f"unknown decision op: {condition['op']!r}") + try: + return comparator(value, condition.get("value")) + except TypeError: + return False + return value == condition + + +def _rule_matches(rule: Rule, context: Mapping[str, Any]) -> bool: + return all(_cell_matches(context.get(name), cond) + for name, cond in rule.conditions.items()) + + +class DecisionTable: + """A list of rules evaluated against an input context by a hit policy.""" + + def __init__(self, inputs: List[str], rules: List[Rule], + hit_policy: str = HIT_UNIQUE) -> None: + """``inputs`` documents the input names; ``hit_policy`` selects matches.""" + self.inputs = list(inputs) + self.rules = list(rules) + self.hit_policy = hit_policy.upper() + + @classmethod + def from_dict(cls, spec: Mapping[str, Any]) -> "DecisionTable": + """Build a table from a JSON-style ``{inputs, hit_policy, rules}`` spec.""" + rules = [Rule(dict(r.get("conditions", {})), dict(r.get("outputs", {}))) + for r in spec.get("rules", [])] + return cls(spec.get("inputs", []), rules, + spec.get("hit_policy", HIT_UNIQUE)) + + def evaluate(self, context: Mapping[str, Any]) -> List[Dict[str, Any]]: + """Return the outputs of matching rules per the hit policy.""" + matches = [dict(rule.outputs) for rule in self.rules + if _rule_matches(rule, context)] + if self.hit_policy == HIT_UNIQUE and len(matches) > 1: + raise ValueError( + f"UNIQUE hit policy matched {len(matches)} rules") + if self.hit_policy in (HIT_FIRST, HIT_PRIORITY, HIT_UNIQUE): + return matches[:1] + return matches + + +def evaluate_table(spec: Mapping[str, Any], + context: Mapping[str, Any]) -> Any: + """Evaluate a table spec against a context. + + Returns a list for ``COLLECT``; otherwise the single matched outputs dict + (or ``{}`` when nothing matches). + """ + table = DecisionTable.from_dict(spec) + matches = table.evaluate(context) + if table.hit_policy == HIT_COLLECT: + return matches + return matches[0] if matches else {} diff --git a/je_auto_control/utils/executor/action_executor.py b/je_auto_control/utils/executor/action_executor.py index dafe8241..d83118c1 100644 --- a/je_auto_control/utils/executor/action_executor.py +++ b/je_auto_control/utils/executor/action_executor.py @@ -3328,6 +3328,17 @@ def _run_saga(steps: Any) -> Dict[str, Any]: "failed_step": result.failed_step, "error": result.error} +def _decision_table(spec: Any, context: Any) -> Dict[str, Any]: + """Adapter: evaluate a DMN-style decision table against a context.""" + import json + from je_auto_control.utils.decision_table import evaluate_table + if isinstance(spec, str): + spec = json.loads(spec) + if isinstance(context, str): + context = json.loads(context) + return {"result": evaluate_table(spec, context)} + + class Executor: """ Executor @@ -3611,6 +3622,7 @@ def __init__(self): "AC_json_query": _json_query, "AC_json_extract": _json_extract, "AC_run_saga": _run_saga, + "AC_decision_table": _decision_table, "AC_a11y_record_start": _a11y_record_start, "AC_a11y_record_stop": _a11y_record_stop, "AC_a11y_record_events": _a11y_record_events, diff --git a/je_auto_control/utils/mcp_server/tools/_factories.py b/je_auto_control/utils/mcp_server/tools/_factories.py index 4d762837..b31e8396 100644 --- a/je_auto_control/utils/mcp_server/tools/_factories.py +++ b/je_auto_control/utils/mcp_server/tools/_factories.py @@ -3244,6 +3244,24 @@ def saga_tools() -> List[MCPTool]: ] +def decision_table_tools() -> List[MCPTool]: + return [ + MCPTool( + name="ac_decision_table", + description=("Evaluate a DMN-style decision table 'spec' " + "({inputs, hit_policy: UNIQUE/FIRST/PRIORITY/COLLECT, " + "rules:[{conditions, outputs}]}) against a 'context'. " + "Conditions are wildcard/literal/{op,value}. Returns " + "{result} (outputs dict, or list for COLLECT)."), + input_schema=schema( + {"spec": {"type": "object"}, "context": {"type": "object"}}, + ["spec", "context"]), + handler=h.decision_table, + annotations=READ_ONLY, + ), + ] + + def unattended_tools() -> List[MCPTool]: return [ MCPTool( @@ -4307,7 +4325,7 @@ def media_assert_tools() -> List[MCPTool]: video_report_tools, fuzzy_tools, artifact_store_tools, image_dedup_tools, locale_tools, voice_tools, coordinate_space_tools, loop_guard_tools, process_mining_tools, asset_tools, events_tools, notify_channel_tools, - jsonpath_tools, saga_tools, + jsonpath_tools, saga_tools, decision_table_tools, screen_record_tools, process_and_shell_tools, remote_desktop_tools, gamepad_tools, usb_passthrough_tools, assertion_tools, data_source_tools, diff --git a/je_auto_control/utils/mcp_server/tools/_handlers.py b/je_auto_control/utils/mcp_server/tools/_handlers.py index 13203da7..3a34e8ac 100644 --- a/je_auto_control/utils/mcp_server/tools/_handlers.py +++ b/je_auto_control/utils/mcp_server/tools/_handlers.py @@ -1564,6 +1564,11 @@ def run_saga(steps): "failed_step": result.failed_step, "error": result.error} +def decision_table(spec, context): + from je_auto_control.utils.decision_table import evaluate_table + return {"result": evaluate_table(spec, context)} + + def vlm_locate(description: str, screen_region: Optional[List[int]] = None, model: Optional[str] = None) -> Optional[List[int]]: diff --git a/test/unit_test/headless/test_decision_table_batch.py b/test/unit_test/headless/test_decision_table_batch.py new file mode 100644 index 00000000..1bf92a52 --- /dev/null +++ b/test/unit_test/headless/test_decision_table_batch.py @@ -0,0 +1,106 @@ +"""Headless tests for the DMN-style decision table. Pure stdlib, no Qt imports.""" +import pytest + +import je_auto_control as ac +from je_auto_control.utils.decision_table import DecisionTable, evaluate_table + +SPEC = { + "inputs": ["age", "country"], + "hit_policy": "FIRST", + "rules": [ + {"conditions": {"age": {"op": "lt", "value": 18}}, + "outputs": {"tier": "minor"}}, + {"conditions": {"age": {"op": "ge", "value": 18}, "country": "US"}, + "outputs": {"tier": "us-adult"}}, + {"conditions": {"age": {"op": "ge", "value": 18}}, + "outputs": {"tier": "adult"}}, + ], +} + + +def test_first_match_wins(): + assert evaluate_table(SPEC, {"age": 10, "country": "US"}) == {"tier": "minor"} + assert evaluate_table(SPEC, {"age": 30, "country": "US"}) == \ + {"tier": "us-adult"} + assert evaluate_table(SPEC, {"age": 30, "country": "DE"}) == \ + {"tier": "adult"} + + +def test_no_match_returns_empty(): + spec = {"inputs": ["x"], "hit_policy": "FIRST", + "rules": [{"conditions": {"x": 1}, "outputs": {"y": 1}}]} + assert evaluate_table(spec, {"x": 99}) == {} + + +def test_collect_returns_all(): + collect = dict(SPEC, hit_policy="COLLECT") + result = evaluate_table(collect, {"age": 30, "country": "US"}) + assert result == [{"tier": "us-adult"}, {"tier": "adult"}] + + +def test_unique_multi_match_raises(): + spec = {"inputs": ["x"], "hit_policy": "UNIQUE", "rules": [ + {"conditions": {"x": 1}, "outputs": {"a": 1}}, + {"conditions": {"x": {"op": "ge", "value": 0}}, "outputs": {"a": 2}}, + ]} + with pytest.raises(ValueError): + evaluate_table(spec, {"x": 1}) + + +def test_wildcard_and_literal_conditions(): + spec = {"inputs": ["role", "active"], "hit_policy": "FIRST", "rules": [ + {"conditions": {"role": "admin", "active": None}, # active = wildcard + "outputs": {"allow": True}}, + {"conditions": {"role": "-", "active": True}, # role = wildcard + "outputs": {"allow": False}}, + ]} + assert evaluate_table(spec, {"role": "admin", "active": False}) == \ + {"allow": True} + assert evaluate_table(spec, {"role": "guest", "active": True}) == \ + {"allow": False} + + +def test_unknown_op_raises(): + spec = {"inputs": ["x"], "hit_policy": "FIRST", + "rules": [{"conditions": {"x": {"op": "between", "value": 1}}, + "outputs": {"y": 1}}]} + with pytest.raises(ValueError): + evaluate_table(spec, {"x": 1}) + + +def test_direct_class_api(): + from je_auto_control.utils.decision_table import Rule + table = DecisionTable(["n"], [Rule({"n": {"op": "gt", "value": 5}}, + {"big": True})], hit_policy="COLLECT") + assert table.evaluate({"n": 9}) == [{"big": True}] + assert table.evaluate({"n": 1}) == [] + + +# --- wiring --------------------------------------------------------------- + +def test_executor_round_trip(): + import json + rec = ac.execute_action([[ + "AC_decision_table", + {"spec": json.dumps(SPEC), "context": json.dumps({"age": 30, + "country": "DE"})}, + ]]) + out = next(v for v in rec.values() if isinstance(v, dict) and "result" in v) + assert out["result"] == {"tier": "adult"} + + +def test_wiring(): + assert "AC_decision_table" in 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_decision_table" in names + from je_auto_control.gui.script_builder.command_schema import _build_specs + cmds = {s.command for s in _build_specs()} + assert "AC_decision_table" in cmds + + +def test_facade_exports(): + for attr in ("DecisionTable", "Rule", "evaluate_table"): + assert hasattr(ac, attr) + assert attr in ac.__all__