from __future__ import annotations import json import tempfile import time from pathlib import Path from typing import Any from unittest.mock import patch import pytest from cognitive_agent.application.extension_control import ( ExtensionControlAuthorizationError, ExtensionControlConsumer, ExtensionControlError, authorize_extension_control_actions, parse_extension_control_payload, ) from cognitive_agent.application.self_extension import ( ExtensionIntegrationError, SelfExtensionService, SimulationGateway, ) def wait_until(predicate, timeout: float = 1.0) -> bool: deadline = time.monotonic() + timeout while time.monotonic() < deadline: if predicate(): return True time.sleep(0.005) return bool(predicate()) def action( slot: int, action_name: str, *, target_scope: str = "toy-light-001", at_ms: int = 0, **extra: Any, ) -> dict[str, Any]: return { "type": "infrared_emit", "at_ms": at_ms, "domain": "self_extension", "capability": "infrared.emit", "target_scope": target_scope, "operation": "emit", "parameters": {"action": action_name, "slot": slot}, **extra, } def request(*actions: dict[str, Any]) -> dict[str, Any]: return { "domain": "self_extension", "operation_count": len(actions), "actions": list(actions), } def capability( capability_id: str, slot: int, action_name: str, *, device_id: str = "toy-light-001", enabled: bool = True, card_uid: str = "E113B203", ) -> dict[str, Any]: return { "id": capability_id, "status": "learned", "enabled": enabled, "device_id": device_id, "device_name": "闪烁灯", "card_uid": card_uid, "action": action_name, "slot": slot, "risk": "low", } def encoded(payload: dict[str, Any]) -> bytes: return json.dumps(payload, ensure_ascii=False).encode("utf-8") def test_parser_accepts_scene_action_shape_and_strips_compatibility_fields() -> None: payload = request( action( 2, "打开", target={"device": "toy-light-001", "card_uid": "E113B203"}, operation_ref="toy-light-on", ) ) parsed = parse_extension_control_payload(encoded(payload)) assert parsed == request(action(2, "打开")) assert "target" not in parsed["actions"][0] assert "operation_ref" not in parsed["actions"][0] @pytest.mark.parametrize( ("payload", "message"), [ ( { "domain": "self_extension", "operation_count": 2, "actions": [action(2, "打开")], }, "must equal", ), (request({**action(2, "打开"), "domain": "seat"}), "self_extension"), (request({**action(2, "打开"), "operation": "set"}), "must be emit"), (request(action(2, "打开", target_scope="闪烁灯")), "English device id"), ], ) def test_parser_rejects_invalid_control_contract( payload: dict[str, Any], message: str, ) -> None: with pytest.raises(ExtensionControlError, match=message): parse_extension_control_payload(encoded(payload)) def test_parser_rejects_duplicate_json_keys() -> None: raw = ( b'{"domain":"self_extension","domain":"self_extension",' b'"operation_count":1,"actions":[]}' ) with pytest.raises(ExtensionControlError, match="duplicate key"): parse_extension_control_payload(raw) def test_authorization_requires_exact_enabled_learned_bound_ability() -> None: parsed = parse_extension_control_payload(encoded(request(action(2, "打开")))) valid = capability("light-on", 2, "打开") [authorized] = authorize_extension_control_actions(parsed, [valid]) assert authorized["capability_id"] == "light-on" assert authorized["action"] == action(2, "打开") for disallowed in ( [{**valid, "enabled": False}], [{**valid, "status": "draft"}], [{**valid, "card_uid": ""}], [{**valid, "device_id": "another-device"}], [{**valid, "action": "关闭"}], [{**valid, "slot": 3}], ): with pytest.raises(ExtensionControlAuthorizationError, match="not authorized"): authorize_extension_control_actions(parsed, disallowed) def test_consumer_validates_entire_request_then_executes_by_at_ms() -> None: catalog = [ capability("light-on", 2, "打开"), capability("light-red", 3, "切换为红色"), ] executions: list[tuple[float, dict[str, Any]]] = [] def execute(value: dict[str, Any]) -> dict[str, Any]: executions.append((time.monotonic(), value)) return {"success": True, "transport": "tcp", "gateway_acknowledged": True} consumer = ExtensionControlConsumer( catalog_provider=lambda: catalog, executor=execute, adjacent_emit_interval_s=0, ) consumer.start() try: consumer.submit( encoded( request( action(2, "打开", at_ms=30), action(3, "切换为红色", at_ms=0), ) ) ) assert wait_until(lambda: consumer.snapshot()["state"] == "completed") assert [item[1]["parameters"]["slot"] for item in executions] == [3, 2] status = consumer.snapshot() assert status["processed_count"] == 1 assert status["executed_action_count"] == 2 assert [item["slot"] for item in status["last_results"]] == [3, 2] assert [item["slot"] for item in status["action_statuses"]] == [3, 2] assert [item["state"] for item in status["action_statuses"]] == [ "submitted_unverified", "submitted_unverified", ] assert all( item["state_label"] == "已下发 · 物理效果未确认" for item in status["action_statuses"] ) assert status["source_label"] == "车端 MQTT" assert status["active_action_index"] is None assert status["last_error"] is None finally: consumer.close() def test_consumer_rejects_whole_request_before_first_tcp_command() -> None: executions: list[dict[str, Any]] = [] consumer = ExtensionControlConsumer( catalog_provider=lambda: [capability("light-on", 2, "打开")], executor=lambda value: executions.append(value) or {"success": True}, adjacent_emit_interval_s=0, ) consumer.start() try: consumer.submit( encoded( request( action(2, "打开"), action(3, "切换为红色"), ) ) ) assert wait_until(lambda: consumer.snapshot()["state"] == "rejected") assert executions == [] assert consumer.snapshot()["rejected_count"] == 1 assert "not authorized" in consumer.snapshot()["last_error"] finally: consumer.close() def test_consumer_aborts_remaining_actions_after_tcp_failure() -> None: catalog = [ capability("light-on", 2, "打开"), capability("light-red", 3, "切换为红色"), capability("light-blue", 5, "切换为蓝色"), ] attempted_slots: list[int] = [] def execute(value: dict[str, Any]) -> dict[str, Any]: slot = int(value["parameters"]["slot"]) attempted_slots.append(slot) if slot == 3: raise RuntimeError("TCP disconnected") return {"success": True, "transport": "tcp"} consumer = ExtensionControlConsumer( catalog_provider=lambda: catalog, executor=execute, adjacent_emit_interval_s=0, ) consumer.start() try: consumer.submit( encoded( request( action(2, "打开"), action(3, "切换为红色"), action(5, "切换为蓝色"), ) ) ) assert wait_until(lambda: consumer.snapshot()["state"] == "failed") assert attempted_slots == [2, 3] status = consumer.snapshot() assert status["failed_count"] == 1 assert status["executed_action_count"] == 1 assert [item["state"] for item in status["action_statuses"]] == [ "submitted_unverified", "failed", "aborted", ] assert status["last_error"] == "TCP disconnected" finally: consumer.close() def test_consumer_projects_mqtt_transport_rejection_without_execution() -> None: executions: list[dict[str, Any]] = [] consumer = ExtensionControlConsumer( catalog_provider=list, executor=lambda value: executions.append(value) or {"success": True}, ) consumer.start() try: consumer.reject_transport_message( "retained extension/ctl messages are not executable", "retained", ) status = consumer.snapshot() assert status["state"] == "rejected" assert status["request_count"] == 1 assert status["processed_count"] == 1 assert status["rejected_count"] == 1 assert status["last_rejection_kind"] == "retained" assert status["last_latency_ms"] == 0 assert status["action_statuses"] == [] assert executions == [] finally: consumer.close() def test_self_extension_reauthorizes_immediately_before_gateway_emit() -> None: with tempfile.TemporaryDirectory() as directory: service = SelfExtensionService( SimulationGateway(), catalog_db_path=Path(directory, "infrared.sqlite3"), ) service.sync_peripheral_catalog([capability("light-on", 2, "打开")]) with patch.object( service, "execute_gateway", return_value={ "success": True, "transport": "tcp", "gateway_acknowledged": True, }, ) as execute_gateway: result = service.execute_bound_infrared_action(action(2, "打开")) execute_gateway.assert_called_once_with("emit", {"slot": 2}) assert result["authorization"]["capability_id"] == "light-on" with pytest.raises(ExtensionIntegrationError, match="未通过"): service.execute_bound_infrared_action(action(3, "切换为红色")) assert execute_gateway.call_count == 1 service.close() def test_server_wires_extension_control_subscription_and_health() -> None: root = Path(__file__).resolve().parents[2] server = (root / "src" / "cognitive_agent" / "server.py").read_text( encoding="utf-8" ) seat_service = ( root / "src" / "cognitive_agent" / "application" / "seat_service.py" ).read_text(encoding="utf-8") console = (root / "apps" / "web-console" / "console-v2.js").read_text( encoding="utf-8" ) assert "extension_control = ExtensionControlConsumer(" in server assert "executor=extension.execute_bound_infrared_action" in server assert "service.set_extension_control_handler(extension_control.submit)" in server assert "extension_control.reject_transport_message" in server assert '"extension_control": self.extension_control.snapshot()' in server assert 'path == "/api/extension/control/status"' in server assert "self.extension_control.wait_for_snapshot(" in server assert "SeatDemoHandler.extension_control.start()" in server assert "SeatDemoHandler.extension_control.close()" in server assert "def set_extension_control_handler" in seat_service assert "def set_extension_control_rejection_handler" in seat_service assert 'fetch(`/api/extension/control/status?${query}`' in console assert "function applyExtensionControlSnapshot" in console assert "if (revision < extensionControlSync.revision)" in console assert 'vehicle_mqtt_extension: "车端 MQTT"' in console assert 'delivery_transport: "车端 MQTT → 红外 TCP"' in console assert 'semantic_instruction: `${extensionName} · ${actionName}`' in console assert ( 'category: `${extensionName} · target_scope=${targetScope} · at_ms=${atMs}`' in console )