From f4387aa9e71ef7d9a6007ffd9b61ecd524d5ca0e Mon Sep 17 00:00:00 2001 From: JeffreyChen Date: Mon, 22 Jun 2026 03:53:48 +0800 Subject: [PATCH 1/2] Add typed configuration schema validation assets._coerce coerces one value and json_schema validates JSON structure, but nothing bound a resolved config dict into a typed object with required-field enforcement and choice constraints. Add ConfigSchema / ConfigField / validate_config / coerce: a stdlib pydantic-settings analog that coerces types, applies defaults, enforces required/choices, and returns {ok, config, errors}. Wired through facade, executor (AC_validate_config), MCP, and the Script Builder with a headless test batch and EN/Zh docs. --- README.md | 7 ++ README/README_zh-CN.md | 7 ++ README/README_zh-TW.md | 7 ++ .../Eng/doc/new_features/v95_features_doc.rst | 41 +++++++ docs/source/Eng/eng_index.rst | 1 + .../Zh/doc/new_features/v95_features_doc.rst | 35 ++++++ docs/source/Zh/zh_index.rst | 1 + je_auto_control/__init__.py | 5 + .../gui/script_builder/command_schema.py | 10 ++ .../utils/config_schema/__init__.py | 6 + .../utils/config_schema/config_schema.py | 103 ++++++++++++++++++ .../utils/executor/action_executor.py | 12 ++ .../utils/mcp_server/tools/_factories.py | 18 ++- .../utils/mcp_server/tools/_handlers.py | 5 + .../headless/test_config_schema_batch.py | 75 +++++++++++++ 15 files changed, 332 insertions(+), 1 deletion(-) create mode 100644 docs/source/Eng/doc/new_features/v95_features_doc.rst create mode 100644 docs/source/Zh/doc/new_features/v95_features_doc.rst create mode 100644 je_auto_control/utils/config_schema/__init__.py create mode 100644 je_auto_control/utils/config_schema/config_schema.py create mode 100644 test/unit_test/headless/test_config_schema_batch.py diff --git a/README.md b/README.md index 862990b8..84636cc9 100644 --- a/README.md +++ b/README.md @@ -13,6 +13,7 @@ ## Table of Contents +- [What's new (2026-06-22) — Typed Configuration Schema](#whats-new-2026-06-22--typed-configuration-schema) - [What's new (2026-06-22) — OTLP/JSON Span Export](#whats-new-2026-06-22--otlpjson-span-export) - [What's new (2026-06-22) — Canonical Log Lines & Structured Logging](#whats-new-2026-06-22--canonical-log-lines--structured-logging) - [What's new (2026-06-22) — Conditional HTTP Requests & Cache Validators](#whats-new-2026-06-22--conditional-http-requests--cache-validators) @@ -147,6 +148,12 @@ --- +## What's new (2026-06-22) — Typed Configuration Schema + +Validate config into a typed object. Full reference: [`docs/source/Eng/doc/new_features/v95_features_doc.rst`](docs/source/Eng/doc/new_features/v95_features_doc.rst). + +- **`ConfigSchema` / `ConfigField` / `validate_config` / `coerce`** (`AC_validate_config`): `assets._coerce` coerces one value and `json_schema` validates structure, but nothing bound a resolved config dict into a typed object with required-field enforcement and choice constraints. This coerces types (`str`/`int`/`float`/`bool`), applies defaults, enforces required/choices, and returns `{ok, config, errors}` — a stdlib pydantic-settings analog. Pure-stdlib, deterministic. + ## What's new (2026-06-22) — OTLP/JSON Span Export Export spans the way a collector ingests them. Full reference: [`docs/source/Eng/doc/new_features/v94_features_doc.rst`](docs/source/Eng/doc/new_features/v94_features_doc.rst). diff --git a/README/README_zh-CN.md b/README/README_zh-CN.md index 51654d30..36882ba5 100644 --- a/README/README_zh-CN.md +++ b/README/README_zh-CN.md @@ -12,6 +12,7 @@ ## 目录 +- [本次更新 (2026-06-22) — 具类型的配置结构](#本次更新-2026-06-22--具类型的配置结构) - [本次更新 (2026-06-22) — OTLP/JSON Span 导出](#本次更新-2026-06-22--otlpjson-span-导出) - [本次更新 (2026-06-22) — 标准日志行与结构化日志](#本次更新-2026-06-22--标准日志行与结构化日志) - [本次更新 (2026-06-22) — 条件式 HTTP 请求与缓存验证子](#本次更新-2026-06-22--条件式-http-请求与缓存验证子) @@ -146,6 +147,12 @@ --- +## 本次更新 (2026-06-22) — 具类型的配置结构 + +把配置验证成具类型的对象。完整参考:[`docs/source/Zh/doc/new_features/v95_features_doc.rst`](../docs/source/Zh/doc/new_features/v95_features_doc.rst)。 + +- **`ConfigSchema` / `ConfigField` / `validate_config` / `coerce`**(`AC_validate_config`):`assets._coerce` 只转换单一值,`json_schema` 只验证结构,但没有东西把已解析配置 dict 绑定成具类型对象并做必填强制与选项约束。本功能转换类型(`str`/`int`/`float`/`bool`)、应用默认、强制必填/选项,返回 `{ok, config, errors}` —— 标准库版 pydantic-settings。纯标准库、确定。 + ## 本次更新 (2026-06-22) — OTLP/JSON Span 导出 以 collector 摄取的格式导出 span。完整参考:[`docs/source/Zh/doc/new_features/v94_features_doc.rst`](../docs/source/Zh/doc/new_features/v94_features_doc.rst)。 diff --git a/README/README_zh-TW.md b/README/README_zh-TW.md index ef1469a0..0d470395 100644 --- a/README/README_zh-TW.md +++ b/README/README_zh-TW.md @@ -12,6 +12,7 @@ ## 目錄 +- [本次更新 (2026-06-22) — 具型別的設定結構](#本次更新-2026-06-22--具型別的設定結構) - [本次更新 (2026-06-22) — OTLP/JSON Span 匯出](#本次更新-2026-06-22--otlpjson-span-匯出) - [本次更新 (2026-06-22) — 標準日誌行與結構化日誌](#本次更新-2026-06-22--標準日誌行與結構化日誌) - [本次更新 (2026-06-22) — 條件式 HTTP 請求與快取驗證子](#本次更新-2026-06-22--條件式-http-請求與快取驗證子) @@ -146,6 +147,12 @@ --- +## 本次更新 (2026-06-22) — 具型別的設定結構 + +把設定驗證成具型別的物件。完整參考:[`docs/source/Zh/doc/new_features/v95_features_doc.rst`](../docs/source/Zh/doc/new_features/v95_features_doc.rst)。 + +- **`ConfigSchema` / `ConfigField` / `validate_config` / `coerce`**(`AC_validate_config`):`assets._coerce` 只轉換單一值,`json_schema` 只驗證結構,但沒有東西把已解析設定 dict 綁定成具型別物件並做必填強制與選項約束。本功能轉換型別(`str`/`int`/`float`/`bool`)、套用預設、強制必填/選項,回傳 `{ok, config, errors}` —— 標準函式庫版 pydantic-settings。純標準函式庫、具決定性。 + ## 本次更新 (2026-06-22) — OTLP/JSON Span 匯出 以 collector 攝取的格式匯出 span。完整參考:[`docs/source/Zh/doc/new_features/v94_features_doc.rst`](../docs/source/Zh/doc/new_features/v94_features_doc.rst)。 diff --git a/docs/source/Eng/doc/new_features/v95_features_doc.rst b/docs/source/Eng/doc/new_features/v95_features_doc.rst new file mode 100644 index 00000000..11be57bb --- /dev/null +++ b/docs/source/Eng/doc/new_features/v95_features_doc.rst @@ -0,0 +1,41 @@ +Typed Configuration Schema +========================== + +``assets._coerce`` coerces a single value and ``json_schema`` validates JSON +structure, but nothing bound a resolved config dict into a typed object with +required-field enforcement and choice constraints. This validates a mapping +against declared fields, coercing types and reporting actionable errors — a +stdlib analog of pydantic-settings. + +Pure standard library (``dataclasses``); imports no ``PySide6``. Validation is a +pure function (mapping in, report out), so it is fully deterministic in CI. + +Headless API +------------ + +.. code-block:: python + + from je_auto_control import ConfigSchema, ConfigField, validate_config + + schema = ConfigSchema({ + "port": ConfigField("int", required=True), + "env": ConfigField("str", default="dev", choices=["dev", "prod"]), + "debug": ConfigField("bool", default=False), + }) + report = schema.validate({"port": "8080", "debug": "yes"}) + # {"ok": True, "config": {"port": 8080, "env": "dev", "debug": True}, "errors": []} + +``ConfigField`` declares a ``type`` (``str`` / ``int`` / ``float`` / ``bool``), +optional ``default``, ``required`` flag, ``choices``, and an ``env`` hint. +``ConfigSchema.validate`` coerces each present value, applies defaults, enforces +required fields and choices, and returns ``{ok, config, errors}`` (errors as +``{field, error}``). ``ConfigSchema.from_dict`` builds a schema from a plain +spec, ``validate_config`` does spec-plus-mapping in one call, and ``coerce`` +exposes the value coercion (booleans accept ``true``/``yes``/``on`` etc.). + +Executor command +---------------- + +``AC_validate_config`` validates a ``config`` mapping against a ``schema`` spec +and returns ``{ok, config, errors}``. It is exposed as the MCP tool +``ac_validate_config`` and as a Script Builder command under **Data**. diff --git a/docs/source/Eng/eng_index.rst b/docs/source/Eng/eng_index.rst index d64f0020..b7ea188e 100644 --- a/docs/source/Eng/eng_index.rst +++ b/docs/source/Eng/eng_index.rst @@ -117,6 +117,7 @@ Comprehensive guides for all AutoControl features. doc/new_features/v92_features_doc doc/new_features/v93_features_doc doc/new_features/v94_features_doc + doc/new_features/v95_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/v95_features_doc.rst b/docs/source/Zh/doc/new_features/v95_features_doc.rst new file mode 100644 index 00000000..d472cc01 --- /dev/null +++ b/docs/source/Zh/doc/new_features/v95_features_doc.rst @@ -0,0 +1,35 @@ +具型別的設定結構 +============== + +``assets._coerce`` 只轉換單一值,``json_schema`` 只驗證 JSON 結構,但沒有任何東西能把已解析的設定 dict +綁定成具型別的物件,並做必填欄位強制與選項約束。本功能依宣告的欄位驗證一個 mapping,轉換型別並回報可 +據以行動的錯誤 —— 標準函式庫版的 pydantic-settings。 + +純標準函式庫(``dataclasses``);不匯入 ``PySide6``。驗證為純函式(輸入 mapping、輸出報告),因此在 CI +中完全具決定性。 + +無頭 API +-------- + +.. code-block:: python + + from je_auto_control import ConfigSchema, ConfigField, validate_config + + schema = ConfigSchema({ + "port": ConfigField("int", required=True), + "env": ConfigField("str", default="dev", choices=["dev", "prod"]), + "debug": ConfigField("bool", default=False), + }) + report = schema.validate({"port": "8080", "debug": "yes"}) + # {"ok": True, "config": {"port": 8080, "env": "dev", "debug": True}, "errors": []} + +``ConfigField`` 宣告 ``type``(``str`` / ``int`` / ``float`` / ``bool``)、選用的 ``default``、``required`` +旗標、``choices`` 與 ``env`` 提示。``ConfigSchema.validate`` 轉換每個存在的值、套用預設、強制必填與選項, +回傳 ``{ok, config, errors}``(錯誤為 ``{field, error}``)。``ConfigSchema.from_dict`` 從純 spec 建立結構, +``validate_config`` 一次完成 spec 加 mapping,``coerce`` 公開值轉換(布林接受 ``true``/``yes``/``on`` 等)。 + +執行器命令 +---------- + +``AC_validate_config`` 依 ``schema`` spec 驗證 ``config`` mapping,回傳 ``{ok, config, errors}``。它以 MCP +工具 ``ac_validate_config`` 以及 Script Builder 中 **Data** 分類下的命令提供。 diff --git a/docs/source/Zh/zh_index.rst b/docs/source/Zh/zh_index.rst index 5649932b..7b689af9 100644 --- a/docs/source/Zh/zh_index.rst +++ b/docs/source/Zh/zh_index.rst @@ -117,6 +117,7 @@ AutoControl 所有功能的完整使用指南。 doc/new_features/v92_features_doc doc/new_features/v93_features_doc doc/new_features/v94_features_doc + doc/new_features/v95_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 6521e415..42aebd56 100644 --- a/je_auto_control/__init__.py +++ b/je_auto_control/__init__.py @@ -290,6 +290,10 @@ from je_auto_control.utils.layered_config import ( LayeredConfig, SourceTrace, deep_merge, ) +# Typed config schema validation (coerce + required + choices) +from je_auto_control.utils.config_schema import ( + ConfigField, ConfigSchema, coerce, validate_config, +) # URI-scheme secret/value reference resolver (env:// / file:// / secret://) from je_auto_control.utils.secret_ref import ( RefResolver, SecretRefError, is_ref, resolve_ref, resolve_refs_in, @@ -921,6 +925,7 @@ def start_autocontrol_gui(*args, **kwargs): "Asset", "AssetStore", "AssetValue", "active_environment", "dotenv_values", "dump_dotenv", "load_dotenv", "parse_dotenv", "LayeredConfig", "SourceTrace", "deep_merge", + "ConfigField", "ConfigSchema", "coerce", "validate_config", "RefResolver", "SecretRefError", "is_ref", "resolve_ref", "resolve_refs_in", "redact_config", "redact_secret_text", "EventEmitter", "post_cloudevent", "to_cloudevent", diff --git a/je_auto_control/gui/script_builder/command_schema.py b/je_auto_control/gui/script_builder/command_schema.py index 740a3d8b..f510ac8f 100644 --- a/je_auto_control/gui/script_builder/command_schema.py +++ b/je_auto_control/gui/script_builder/command_schema.py @@ -1676,6 +1676,16 @@ def _add_resilience_specs(specs: List[CommandSpec]) -> None: ), description="Recursively resolve references inside a JSON structure.", )) + specs.append(CommandSpec( + "AC_validate_config", "Data", "Config Schema: Validate", + fields=( + FieldSpec("schema", FieldType.STRING, + placeholder='{"port": {"type": "int", "required": true}}'), + FieldSpec("config", FieldType.STRING, + placeholder='{"port": "8080"}'), + ), + description="Validate a config mapping against a typed schema spec.", + )) specs.append(CommandSpec( "AC_redact_config", "Security", "Redaction: Redact Config", fields=( diff --git a/je_auto_control/utils/config_schema/__init__.py b/je_auto_control/utils/config_schema/__init__.py new file mode 100644 index 00000000..d917587f --- /dev/null +++ b/je_auto_control/utils/config_schema/__init__.py @@ -0,0 +1,6 @@ +"""Typed configuration schema validation for AutoControl.""" +from je_auto_control.utils.config_schema.config_schema import ( + ConfigField, ConfigSchema, coerce, validate_config, +) + +__all__ = ["ConfigField", "ConfigSchema", "coerce", "validate_config"] diff --git a/je_auto_control/utils/config_schema/config_schema.py b/je_auto_control/utils/config_schema/config_schema.py new file mode 100644 index 00000000..d67bf6e7 --- /dev/null +++ b/je_auto_control/utils/config_schema/config_schema.py @@ -0,0 +1,103 @@ +"""Typed, schema-validated configuration (a stdlib pydantic-settings analog). + +``assets._coerce`` coerces a single value and ``json_schema`` validates JSON +structure, but nothing bound a resolved config dict into a typed object with +required-field enforcement and choice constraints. This validates a mapping +against declared fields, coercing types and reporting actionable errors. + +Pure standard library (``dataclasses``); imports no ``PySide6``. Validation is a +pure function (mapping in, report out), so it is fully deterministic in CI. +""" +from dataclasses import dataclass, field as dataclass_field +from typing import Any, Dict, List, Mapping, Optional, Sequence + +_MISSING = object() + + +@dataclass +class ConfigField: + """A single typed configuration field.""" + + type: str = "str" + default: Any = _MISSING + required: bool = False + choices: Optional[Sequence[Any]] = None + env: Optional[str] = None + + +def _to_bool(value: Any) -> bool: + if isinstance(value, bool): + return value + text = str(value).strip().lower() + if text in ("true", "1", "yes", "on"): + return True + if text in ("false", "0", "no", "off"): + return False + raise ValueError(f"not a boolean: {value!r}") + + +def coerce(value: Any, kind: str) -> Any: + """Coerce ``value`` to ``kind`` (``str`` / ``int`` / ``float`` / ``bool``).""" + if kind == "int": + return int(value) + if kind == "float": + return float(value) + if kind == "bool": + return _to_bool(value) + if kind == "str": + return str(value) + return value + + +@dataclass +class ConfigSchema: + """A set of named :class:`ConfigField` definitions.""" + + fields: Dict[str, ConfigField] = dataclass_field(default_factory=dict) + + @classmethod + def from_dict(cls, spec: Mapping[str, Mapping[str, Any]]) -> "ConfigSchema": + """Build a schema from a ``{name: {type, default, required, ...}}`` spec.""" + fields = {name: ConfigField( + type=raw.get("type", "str"), + default=raw.get("default", _MISSING), + required=bool(raw.get("required", False)), + choices=raw.get("choices"), + env=raw.get("env")) for name, raw in spec.items()} + return cls(fields) + + def _resolve_field(self, name: str, definition: ConfigField, + mapping: Mapping[str, Any]) -> Any: + """Return ``(status, payload)``: ``ok``/value, ``error``/dict, ``skip``.""" + if name in mapping: + try: + value = coerce(mapping[name], definition.type) + except (ValueError, TypeError): + return "error", {"field": name, + "error": f"cannot coerce to {definition.type}"} + if definition.choices is not None and value not in definition.choices: + return "error", {"field": name, "error": "not an allowed choice"} + return "ok", value + if definition.default is not _MISSING: + return "ok", definition.default + if definition.required: + return "error", {"field": name, "error": "required"} + return "skip", None + + def validate(self, mapping: Mapping[str, Any]) -> Dict[str, Any]: + """Validate ``mapping``; return ``{ok, config, errors}``.""" + config: Dict[str, Any] = {} + errors: List[Dict[str, str]] = [] + for name, definition in self.fields.items(): + status, payload = self._resolve_field(name, definition, mapping) + if status == "ok": + config[name] = payload + elif status == "error": + errors.append(payload) + return {"ok": not errors, "config": config, "errors": errors} + + +def validate_config(spec: Mapping[str, Mapping[str, Any]], + mapping: Mapping[str, Any]) -> Dict[str, Any]: + """Validate ``mapping`` against a schema ``spec`` dict in one call.""" + return ConfigSchema.from_dict(spec).validate(mapping) diff --git a/je_auto_control/utils/executor/action_executor.py b/je_auto_control/utils/executor/action_executor.py index ed82360f..b10fed60 100644 --- a/je_auto_control/utils/executor/action_executor.py +++ b/je_auto_control/utils/executor/action_executor.py @@ -3000,6 +3000,17 @@ def _trace_extract(headers: Any) -> Dict[str, Any]: return {"context": ctx.to_dict() if ctx is not None else None} +def _validate_config(schema: Any, config: Any) -> Dict[str, Any]: + """Adapter: validate a config mapping against a schema spec.""" + import json + from je_auto_control.utils.config_schema import validate_config + if isinstance(schema, str): + schema = json.loads(schema) + if isinstance(config, str): + config = json.loads(config) + return validate_config(schema, config) + + def _resolve_ref(ref: str) -> Dict[str, Any]: """Adapter: resolve an env:// / file:// / secret:// reference.""" from je_auto_control.utils.secret_ref import resolve_ref @@ -4401,6 +4412,7 @@ def __init__(self): "AC_baggage_format": _baggage_format, "AC_canonical_log": _canonical_log, "AC_spans_to_otlp": _spans_to_otlp, + "AC_validate_config": _validate_config, "AC_resolve_ref": _resolve_ref, "AC_resolve_refs": _resolve_refs, "AC_redact_config": _redact_config, diff --git a/je_auto_control/utils/mcp_server/tools/_factories.py b/je_auto_control/utils/mcp_server/tools/_factories.py index acd027d4..98b731b0 100644 --- a/je_auto_control/utils/mcp_server/tools/_factories.py +++ b/je_auto_control/utils/mcp_server/tools/_factories.py @@ -3710,6 +3710,22 @@ def config_redaction_tools() -> List[MCPTool]: ] +def config_schema_tools() -> List[MCPTool]: + return [ + MCPTool( + name="ac_validate_config", + description=("Validate a 'config' mapping against a 'schema' spec " + "({name: {type, default, required, choices}}); coerces " + "types. Returns {ok, config, errors}."), + input_schema=schema( + {"schema": {"type": "object"}, "config": {"type": "object"}}, + ["schema", "config"]), + handler=h.validate_config, + annotations=READ_ONLY, + ), + ] + + def secret_ref_tools() -> List[MCPTool]: return [ MCPTool( @@ -5345,7 +5361,7 @@ def media_assert_tools() -> List[MCPTool]: feature_flag_tools, provenance_tools, json_contract_tools, chaos_tools, slo_tools, percentiles_tools, bulkhead_tools, http_cassette_tools, trace_context_tools, baggage_tools, canonical_log_tools, otlp_export_tools, - secret_ref_tools, config_redaction_tools, + secret_ref_tools, config_schema_tools, config_redaction_tools, data_profile_tools, http_problem_tools, dotenv_tools, sse_client_tools, layered_config_tools, data_drift_tools, dataset_diff_tools, referential_tools, link_header_tools, multipart_tools, diff --git a/je_auto_control/utils/mcp_server/tools/_handlers.py b/je_auto_control/utils/mcp_server/tools/_handlers.py index aa22266d..e15e7b16 100644 --- a/je_auto_control/utils/mcp_server/tools/_handlers.py +++ b/je_auto_control/utils/mcp_server/tools/_handlers.py @@ -1746,6 +1746,11 @@ def spans_to_otlp(spans, resource_attrs=None): return _spans_to_otlp(spans, resource_attrs) +def validate_config(schema, config): + from je_auto_control.utils.executor.action_executor import _validate_config + return _validate_config(schema, config) + + def resolve_ref(ref): from je_auto_control.utils.executor.action_executor import _resolve_ref return _resolve_ref(ref) diff --git a/test/unit_test/headless/test_config_schema_batch.py b/test/unit_test/headless/test_config_schema_batch.py new file mode 100644 index 00000000..f4df1590 --- /dev/null +++ b/test/unit_test/headless/test_config_schema_batch.py @@ -0,0 +1,75 @@ +"""Headless tests for typed config schema validation. Pure stdlib, no Qt.""" +import json + +import je_auto_control as ac +from je_auto_control.utils.config_schema import ( + ConfigField, ConfigSchema, coerce, validate_config, +) + + +def test_coerce_types(): + assert coerce("8080", "int") == 8080 + assert coerce("1.5", "float") == 1.5 + assert coerce("yes", "bool") is True + assert coerce("off", "bool") is False + assert coerce(5, "str") == "5" + + +def test_validate_coerces_and_passes(): + schema = ConfigSchema({"port": ConfigField("int", required=True), + "debug": ConfigField("bool", default=False)}) + report = schema.validate({"port": "8080"}) + assert report["ok"] is True + assert report["config"] == {"port": 8080, "debug": False} + + +def test_required_missing_is_error(): + schema = ConfigSchema({"port": ConfigField("int", required=True)}) + report = schema.validate({}) + assert report["ok"] is False + assert report["errors"] == [{"field": "port", "error": "required"}] + + +def test_coercion_failure_reported(): + schema = ConfigSchema({"port": ConfigField("int")}) + report = schema.validate({"port": "not-a-number"}) + assert report["ok"] is False + assert report["errors"][0]["error"] == "cannot coerce to int" + + +def test_choices_constraint(): + schema = ConfigSchema.from_dict( + {"env": {"type": "str", "choices": ["dev", "prod"]}}) + assert schema.validate({"env": "dev"})["ok"] is True + bad = schema.validate({"env": "staging"}) + assert bad["errors"][0]["error"] == "not an allowed choice" + + +def test_from_dict_and_defaults(): + report = validate_config( + {"timeout": {"type": "float", "default": 1.0}}, {}) + assert report["config"] == {"timeout": 1.0} + + +# --- wiring --------------------------------------------------------------- + +def test_executor_round_trip(): + rec = ac.execute_action([[ + "AC_validate_config", + {"schema": json.dumps({"port": {"type": "int", "required": True}}), + "config": json.dumps({"port": "9090"})}]]) + report = next(v for v in rec.values() if isinstance(v, dict)) + assert report["ok"] is True and report["config"] == {"port": 9090} + + +def test_wiring(): + assert "AC_validate_config" in ac.executor.known_commands() + from je_auto_control.utils.mcp_server.tools import build_default_tool_registry + assert "ac_validate_config" in {t.name for t in build_default_tool_registry()} + from je_auto_control.gui.script_builder.command_schema import _build_specs + assert "AC_validate_config" in {s.command for s in _build_specs()} + + +def test_facade_exports(): + for attr in ("ConfigField", "ConfigSchema", "coerce", "validate_config"): + assert hasattr(ac, attr) and attr in ac.__all__ From f68abc954e44e6c4088ef0fc175b08e563e9db25 Mon Sep 17 00:00:00 2001 From: JeffreyChen Date: Mon, 22 Jun 2026 04:01:24 +0800 Subject: [PATCH 2/2] Use pytest.approx for float comparisons in config-schema test (Sonar S1244) --- test/unit_test/headless/test_config_schema_batch.py | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/test/unit_test/headless/test_config_schema_batch.py b/test/unit_test/headless/test_config_schema_batch.py index f4df1590..b568934f 100644 --- a/test/unit_test/headless/test_config_schema_batch.py +++ b/test/unit_test/headless/test_config_schema_batch.py @@ -1,6 +1,8 @@ """Headless tests for typed config schema validation. Pure stdlib, no Qt.""" import json +import pytest + import je_auto_control as ac from je_auto_control.utils.config_schema import ( ConfigField, ConfigSchema, coerce, validate_config, @@ -9,7 +11,7 @@ def test_coerce_types(): assert coerce("8080", "int") == 8080 - assert coerce("1.5", "float") == 1.5 + assert coerce("1.5", "float") == pytest.approx(1.5) assert coerce("yes", "bool") is True assert coerce("off", "bool") is False assert coerce(5, "str") == "5" @@ -48,7 +50,7 @@ def test_choices_constraint(): def test_from_dict_and_defaults(): report = validate_config( {"timeout": {"type": "float", "default": 1.0}}, {}) - assert report["config"] == {"timeout": 1.0} + assert report["config"]["timeout"] == pytest.approx(1.0) # --- wiring ---------------------------------------------------------------