feat: synchronize CH1 vehicle control and feedback
This commit is contained in:
@@ -0,0 +1,424 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import os
|
||||
from pathlib import Path
|
||||
import tempfile
|
||||
import unittest
|
||||
from unittest.mock import MagicMock, patch
|
||||
|
||||
from cognitive_agent.application.model_plan_adapter import ModelPlanAdapter
|
||||
from cognitive_agent.application.native_can_intent import merge_native_can_intent
|
||||
from cognitive_agent.application.seat_service import REPOSITORY_ROOT, SeatDemoService
|
||||
from cognitive_agent.application.self_extension import (
|
||||
SelfExtensionService,
|
||||
SimulationGateway,
|
||||
)
|
||||
|
||||
|
||||
HARDWARE_DISABLED_ENV = {
|
||||
"COGNITIVE_AGENT_CH1_HIL_ENABLED": "false",
|
||||
"COGNITIVE_AGENT_HARDWARE_EXECUTION_ENABLED": "false",
|
||||
"COGNITIVE_AGENT_VEHICLE_CAN_ENABLED": "false",
|
||||
}
|
||||
|
||||
|
||||
def _empty_intent() -> dict:
|
||||
return {
|
||||
"actions": [],
|
||||
"domain_actions": [],
|
||||
"native_can_actions": [],
|
||||
"deferred_requests": [],
|
||||
"model_requests": [],
|
||||
"preset_actions": [],
|
||||
"preset_events": [],
|
||||
"ambiguities": [],
|
||||
"detected_domains": [],
|
||||
"decision_summary": [],
|
||||
"confidence": 0.0,
|
||||
}
|
||||
|
||||
|
||||
class Ch1DefaultTargetTests(unittest.TestCase):
|
||||
def setUp(self) -> None:
|
||||
self.environment = patch.dict(os.environ, HARDWARE_DISABLED_ENV, clear=False)
|
||||
self.environment.start()
|
||||
self.addCleanup(self.environment.stop)
|
||||
self.temporary_directory = tempfile.TemporaryDirectory()
|
||||
self.addCleanup(self.temporary_directory.cleanup)
|
||||
self.service = SeatDemoService(REPOSITORY_ROOT)
|
||||
self.service.artifact_root = Path(self.temporary_directory.name)
|
||||
|
||||
def test_local_requests_use_driver_console_defaults_and_one_ch1_operation(self) -> None:
|
||||
cases = (
|
||||
(
|
||||
"车窗开启一半",
|
||||
"native_window",
|
||||
"window.set_position",
|
||||
"front_left",
|
||||
{"position_pct": 50},
|
||||
),
|
||||
(
|
||||
"打开车窗",
|
||||
"native_window",
|
||||
"window.set_motion",
|
||||
"front_left",
|
||||
{"motion": "open"},
|
||||
),
|
||||
(
|
||||
"空调调到21度",
|
||||
"native_climate",
|
||||
"climate.set_temperature",
|
||||
"driver",
|
||||
{"temperature_c": 21},
|
||||
),
|
||||
(
|
||||
"把风口左右30%上下40%",
|
||||
"native_outlet",
|
||||
"outlet.set_position_percent",
|
||||
"driver_left",
|
||||
{"horizontal_pct": 30, "vertical_pct": 40},
|
||||
),
|
||||
(
|
||||
"打开座椅加热",
|
||||
"native_seat",
|
||||
"seat.set_driver_heating_native",
|
||||
"driver",
|
||||
{"state": "on", "level": 2},
|
||||
),
|
||||
(
|
||||
"打开座椅通风",
|
||||
"native_seat",
|
||||
"seat.set_driver_ventilation_native",
|
||||
"driver",
|
||||
{"state": "on", "level": 2},
|
||||
),
|
||||
)
|
||||
for index, (utterance, config_key, capability, target, parameters) in enumerate(
|
||||
cases
|
||||
):
|
||||
with self.subTest(utterance=utterance):
|
||||
result = self.service.process(
|
||||
utterance,
|
||||
provider="local",
|
||||
conversation_id=f"conv-ch1-default-local-{index}",
|
||||
execution_mode="dry_run",
|
||||
)
|
||||
|
||||
self.assertEqual(result["status"], "succeeded")
|
||||
self.assertEqual(set(result["function_configs"]), {config_key})
|
||||
self.assertNotIn("seat", result["function_configs"])
|
||||
operations = result["function_configs"][config_key]["spec"][
|
||||
"operations"
|
||||
]
|
||||
self.assertEqual(len(operations), 1)
|
||||
self.assertEqual(operations[0]["capability"], capability)
|
||||
self.assertEqual(operations[0]["target"]["zone"], target)
|
||||
self.assertEqual(operations[0]["parameters"], parameters)
|
||||
commands = result["command_plans"][config_key]["spec"]["commands"]
|
||||
self.assertEqual(len(commands), 1)
|
||||
self.assertEqual(commands[0]["bus"], "body_can")
|
||||
summaries = " ".join(result["decision_summary"])
|
||||
self.assertNotIn("副驾座椅功能单域", summaries)
|
||||
self.assertNotIn("目标:副驾座椅", summaries)
|
||||
|
||||
def test_explicit_vehicle_targets_take_priority_over_defaults(self) -> None:
|
||||
cases = (
|
||||
("右前车窗开启一半", "native_window", "front_right", {"position_pct": 50}),
|
||||
("副驾空调调到21度", "native_climate", "passenger", {"temperature_c": 21}),
|
||||
)
|
||||
for index, (utterance, config_key, target, parameters) in enumerate(cases):
|
||||
with self.subTest(utterance=utterance):
|
||||
result = self.service.process(
|
||||
utterance,
|
||||
provider="local",
|
||||
conversation_id=f"conv-ch1-explicit-target-{index}",
|
||||
)
|
||||
operation = result["function_configs"][config_key]["spec"][
|
||||
"operations"
|
||||
][0]
|
||||
self.assertEqual(operation["target"]["zone"], target)
|
||||
self.assertEqual(operation["parameters"], parameters)
|
||||
|
||||
passenger = self.service.process(
|
||||
"打开副驾驶座椅加热",
|
||||
provider="local",
|
||||
conversation_id="conv-ch1-explicit-passenger-seat",
|
||||
)
|
||||
self.assertEqual(set(passenger["function_configs"]), {"seat"})
|
||||
self.assertNotIn("native_seat", passenger["function_configs"])
|
||||
self.assertEqual(
|
||||
passenger["function_configs"]["seat"]["spec"]["operations"][0][
|
||||
"target"
|
||||
]["zone"],
|
||||
"front_right",
|
||||
)
|
||||
|
||||
def test_group_or_non_driver_seat_scope_is_not_treated_as_unscoped(self) -> None:
|
||||
for index, utterance in enumerate(
|
||||
("打开前排座椅加热", "打开后排座椅加热", "打开所有座椅加热")
|
||||
):
|
||||
with self.subTest(utterance=utterance):
|
||||
result = self.service.process(
|
||||
utterance,
|
||||
provider="local",
|
||||
conversation_id=f"conv-ch1-non-driver-scope-{index}",
|
||||
)
|
||||
self.assertEqual(result["status"], "needs_confirmation")
|
||||
self.assertEqual(result["function_configs"], {})
|
||||
self.assertNotIn("native_seat", result["command_plans"])
|
||||
|
||||
def test_unresolved_unscoped_seat_request_never_falls_back_to_passenger_ch0(self) -> None:
|
||||
parsed = _empty_intent()
|
||||
parsed["actions"] = [
|
||||
{
|
||||
"action_type": "thermal",
|
||||
"target": "passenger",
|
||||
"function": "heating",
|
||||
"state": "on",
|
||||
}
|
||||
]
|
||||
parsed["model_requests"] = [
|
||||
{
|
||||
"domain": "seat",
|
||||
"capability": "seat.set_heating",
|
||||
"target_scope": "passenger",
|
||||
"operation": "set",
|
||||
"parameters": [{"name": "state", "value": "on"}],
|
||||
"reason": "model guessed passenger",
|
||||
}
|
||||
]
|
||||
|
||||
result = merge_native_can_intent(parsed, "座椅调舒服一点")
|
||||
|
||||
self.assertFalse(result["actions"])
|
||||
self.assertFalse(result["native_can_actions"])
|
||||
self.assertFalse(result["model_requests"])
|
||||
self.assertTrue(
|
||||
any("不会转入副驾 CH0" in item["question"] for item in result["ambiguities"])
|
||||
)
|
||||
|
||||
def test_unverified_driver_absolute_motion_is_not_degraded_to_a_timed_pulse(self) -> None:
|
||||
for index, utterance in enumerate(
|
||||
("座椅前后移动到50%", "靠背向后移动70度", "坐盆抬到最高")
|
||||
):
|
||||
with self.subTest(utterance=utterance):
|
||||
result = self.service.process(
|
||||
utterance,
|
||||
provider="local",
|
||||
conversation_id=f"conv-ch1-unsupported-absolute-{index}",
|
||||
)
|
||||
self.assertEqual(result["status"], "needs_confirmation")
|
||||
self.assertEqual(result["function_configs"], {})
|
||||
|
||||
timed = self.service.process(
|
||||
"座椅向前移动2秒",
|
||||
provider="local",
|
||||
conversation_id="conv-ch1-verified-timed-axis",
|
||||
)
|
||||
self.assertEqual(timed["status"], "succeeded")
|
||||
operation = timed["function_configs"]["native_seat"]["spec"][
|
||||
"operations"
|
||||
][0]
|
||||
self.assertEqual(operation["capability"], "seat.set_driver_axis_native")
|
||||
self.assertEqual(
|
||||
operation["parameters"],
|
||||
{"axis": "horizontal", "direction": "forward", "duration_ms": 2000},
|
||||
)
|
||||
|
||||
def test_model_adapter_overrides_only_implicit_targets_with_ch1_defaults(self) -> None:
|
||||
window = ModelPlanAdapter._apply_ch1_default_target(
|
||||
{
|
||||
"domain": "window",
|
||||
"capability": "window.set_position",
|
||||
"target_scope": "all",
|
||||
},
|
||||
"车窗开启一半",
|
||||
)
|
||||
climate = ModelPlanAdapter._apply_ch1_default_target(
|
||||
{
|
||||
"domain": "climate",
|
||||
"capability": "climate.set_temperature",
|
||||
"target_scope": "vehicle",
|
||||
},
|
||||
"空调调到21度",
|
||||
)
|
||||
seat = ModelPlanAdapter._apply_ch1_default_target(
|
||||
{
|
||||
"domain": "seat",
|
||||
"capability": "seat.set_heating",
|
||||
"target_scope": "passenger",
|
||||
},
|
||||
"打开座椅加热",
|
||||
)
|
||||
explicit_passenger = ModelPlanAdapter._apply_ch1_default_target(
|
||||
{
|
||||
"domain": "climate",
|
||||
"capability": "climate.set_temperature",
|
||||
"target_scope": "passenger",
|
||||
},
|
||||
"副驾空调调到21度",
|
||||
)
|
||||
|
||||
self.assertEqual(window["target_scope"], "front_left")
|
||||
self.assertEqual(climate["target_scope"], "driver")
|
||||
self.assertEqual(seat["capability"], "seat.set_driver_heating_native")
|
||||
self.assertEqual(seat["target_scope"], "driver")
|
||||
self.assertEqual(explicit_passenger["target_scope"], "passenger")
|
||||
|
||||
def test_gpt_provider_is_preserved_while_wrong_model_targets_are_replaced(self) -> None:
|
||||
cases = (
|
||||
(
|
||||
"车窗开启一半",
|
||||
{
|
||||
"domain": "window",
|
||||
"capability": "window.set_position",
|
||||
"target_scope": "all",
|
||||
"operation": "set",
|
||||
"parameters": [{"name": "position_pct", "value": 100}],
|
||||
"reason": "model chose every window",
|
||||
},
|
||||
"native_window",
|
||||
"front_left",
|
||||
{"position_pct": 50},
|
||||
),
|
||||
(
|
||||
"空调调到21度",
|
||||
{
|
||||
"domain": "climate",
|
||||
"capability": "climate.set_temperature",
|
||||
"target_scope": "vehicle",
|
||||
"operation": "set",
|
||||
"parameters": [{"name": "temperature_c", "value": 21}],
|
||||
"reason": "model chose the whole vehicle",
|
||||
},
|
||||
"native_climate",
|
||||
"driver",
|
||||
{"temperature_c": 21},
|
||||
),
|
||||
(
|
||||
"打开座椅加热",
|
||||
{
|
||||
"domain": "seat",
|
||||
"capability": "seat.set_heating",
|
||||
"target_scope": "passenger",
|
||||
"operation": "set",
|
||||
"parameters": [
|
||||
{"name": "state", "value": "on"},
|
||||
{"name": "level", "value": 3},
|
||||
],
|
||||
"reason": "model guessed the legacy passenger seat",
|
||||
},
|
||||
"native_seat",
|
||||
"driver",
|
||||
{"state": "on", "level": 2},
|
||||
),
|
||||
)
|
||||
self.service.openai.api_key = "offline-test-key"
|
||||
self.service.openai.model = "gpt-5.6-sol"
|
||||
for index, (utterance, request, config_key, target, parameters) in enumerate(
|
||||
cases
|
||||
):
|
||||
model_output = {
|
||||
"detected_domains": [request["domain"]],
|
||||
"requests": [request],
|
||||
"ambiguities": [],
|
||||
"decision_summary": ["GPT model output"],
|
||||
"confidence": 0.9,
|
||||
}
|
||||
with self.subTest(utterance=utterance):
|
||||
with patch.object(
|
||||
self.service.openai, "interpret", return_value=model_output
|
||||
) as interpret:
|
||||
result = self.service.process(
|
||||
utterance,
|
||||
provider="openai",
|
||||
conversation_id=f"conv-ch1-default-openai-{index}",
|
||||
)
|
||||
|
||||
interpret.assert_called_once()
|
||||
self.assertEqual(result["provider_used"], "openai:gpt-5.6-sol")
|
||||
self.assertEqual(result["status"], "succeeded")
|
||||
self.assertEqual(set(result["function_configs"]), {config_key})
|
||||
self.assertNotIn("seat", result["function_configs"])
|
||||
operation = result["function_configs"][config_key]["spec"][
|
||||
"operations"
|
||||
][0]
|
||||
self.assertEqual(operation["target"]["zone"], target)
|
||||
self.assertEqual(operation["parameters"], parameters)
|
||||
self.assertEqual(
|
||||
len(result["command_plans"][config_key]["spec"]["commands"]),
|
||||
1,
|
||||
)
|
||||
|
||||
def test_self_extension_vehicle_endpoint_uses_the_same_default_contract(self) -> None:
|
||||
extension = SelfExtensionService(SimulationGateway())
|
||||
extension.vehicle_provider = "local"
|
||||
extension.vehicle_execution_mode = "dry_run"
|
||||
extension.vehicle_id = "denza_n7"
|
||||
cases = (
|
||||
("车窗开启一半", "window", "front_left", {"position_pct": 50}),
|
||||
("空调调到21度", "climate", "driver", {"temperature_c": 21}),
|
||||
("打开座椅加热", "seat", "driver", {"state": "on", "level": 2}),
|
||||
)
|
||||
for index, (command, domain, target, parameters) in enumerate(cases):
|
||||
with self.subTest(command=command):
|
||||
result = extension.execute_vehicle(
|
||||
{
|
||||
"command": command,
|
||||
"conversation_id": f"conv-extension-ch1-default-{index}",
|
||||
},
|
||||
self.service,
|
||||
hardware_access_bypass=True,
|
||||
)
|
||||
|
||||
self.assertTrue(result["success"])
|
||||
self.assertEqual(result["status"], "succeeded")
|
||||
self.assertEqual(result["provider_used"], "local_rules")
|
||||
self.assertEqual(len(result["operation_results"]), 1)
|
||||
operation = result["operation_results"][0]
|
||||
self.assertEqual(operation["domain"], domain)
|
||||
self.assertEqual(operation["telemetry"]["target"], target)
|
||||
self.assertEqual(
|
||||
operation["telemetry"]["requested_parameters"], parameters
|
||||
)
|
||||
|
||||
def test_self_extension_gpt_prompt_limits_confirmation_to_toy_peripherals(self) -> None:
|
||||
response_plan = {
|
||||
"summary": "ok",
|
||||
"needs_confirmation": False,
|
||||
"question": "",
|
||||
"steps": [],
|
||||
}
|
||||
response = MagicMock()
|
||||
response.__enter__.return_value = response
|
||||
response.read.return_value = json.dumps(
|
||||
{"output_text": json.dumps(response_plan, ensure_ascii=False)},
|
||||
ensure_ascii=False,
|
||||
).encode("utf-8")
|
||||
|
||||
with patch.dict(os.environ, {"OPENAI_STREAM": "false"}, clear=False):
|
||||
extension = SelfExtensionService(SimulationGateway())
|
||||
with patch(
|
||||
"cognitive_agent.application.self_extension.urlopen",
|
||||
return_value=response,
|
||||
) as open_request:
|
||||
extension._request_gpt_plan(
|
||||
"车窗开启一半,空调调到21度",
|
||||
[],
|
||||
[{"label": "车窗"}, {"label": "空调"}],
|
||||
"offline-test-key",
|
||||
)
|
||||
|
||||
request_payload = json.loads(open_request.call_args.args[0].data.decode("utf-8"))
|
||||
prompt = request_payload["input"][0]["content"][0]["text"]
|
||||
self.assertIn("多台可匹配的外设玩具", prompt)
|
||||
self.assertIn("此追问规则不适用于", prompt)
|
||||
self.assertIn("window front-left position 50", prompt)
|
||||
self.assertIn("ac temp driver 21", prompt)
|
||||
self.assertIn("seat driver", prompt)
|
||||
self.assertIn("绝不能转入副驾/CH0", prompt)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -0,0 +1,96 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from contextlib import nullcontext
|
||||
import os
|
||||
from types import SimpleNamespace
|
||||
import unittest
|
||||
from unittest.mock import Mock, patch
|
||||
|
||||
from cognitive_agent.application.seat_service import REPOSITORY_ROOT, SeatDemoService
|
||||
from cognitive_agent.infrastructure.can.hardware import (
|
||||
HardwareSafetyError,
|
||||
VehicleCanSettings,
|
||||
)
|
||||
|
||||
|
||||
class Ch1VehicleSafetyTests(unittest.TestCase):
|
||||
def settings(self, channel: int) -> VehicleCanSettings:
|
||||
environment = {
|
||||
"COGNITIVE_AGENT_VEHICLE_CAN_ENABLED": "true",
|
||||
"COGNITIVE_AGENT_VEHICLE_CAN_CHANNEL": str(channel),
|
||||
"COGNITIVE_AGENT_VEHICLE_CAN_REQUIRED_CHANNEL": "1",
|
||||
"COGNITIVE_AGENT_HARDWARE_APPROVAL_TOKEN_REQUIRED": "true",
|
||||
"COGNITIVE_AGENT_HARDWARE_APPROVAL_TOKEN": "approved",
|
||||
}
|
||||
with patch.dict(os.environ, environment, clear=False):
|
||||
return VehicleCanSettings.from_env(REPOSITORY_ROOT)
|
||||
|
||||
def test_ch1_is_the_only_allowed_body_can_channel(self) -> None:
|
||||
settings = self.settings(1)
|
||||
self.assertTrue(settings.channel_is_allowed)
|
||||
self.assertEqual(settings.required_channel, 1)
|
||||
self.assertTrue(settings.configured)
|
||||
settings.authorize("approved")
|
||||
info = settings.public_info()
|
||||
self.assertEqual(info["target_policy"], "ch1_body_can_driver_only")
|
||||
self.assertTrue(info["channel_is_allowed"])
|
||||
|
||||
def test_ch0_is_rejected_before_adapter_open(self) -> None:
|
||||
settings = self.settings(0)
|
||||
self.assertFalse(settings.channel_is_allowed)
|
||||
self.assertFalse(settings.configured)
|
||||
with self.assertRaisesRegex(HardwareSafetyError, "CH1.*CH0 is forbidden"):
|
||||
settings.authorize("approved")
|
||||
|
||||
def test_policy_cannot_be_redirected_to_ch0(self) -> None:
|
||||
with patch.dict(
|
||||
os.environ,
|
||||
{
|
||||
"COGNITIVE_AGENT_VEHICLE_CAN_ENABLED": "true",
|
||||
"COGNITIVE_AGENT_VEHICLE_CAN_CHANNEL": "0",
|
||||
"COGNITIVE_AGENT_VEHICLE_CAN_REQUIRED_CHANNEL": "0",
|
||||
"COGNITIVE_AGENT_HARDWARE_APPROVAL_TOKEN_REQUIRED": "false",
|
||||
},
|
||||
clear=False,
|
||||
):
|
||||
settings = VehicleCanSettings.from_env(REPOSITORY_ROOT)
|
||||
self.assertFalse(settings.channel_is_allowed)
|
||||
with self.assertRaises(HardwareSafetyError):
|
||||
settings.authorize(None)
|
||||
|
||||
def test_driver_only_ch1_uses_deployment_authority_without_browser_lease(self) -> None:
|
||||
service = object.__new__(SeatDemoService)
|
||||
service.hardware_settings = SimpleNamespace(enabled=False)
|
||||
service.vehicle_can_settings = SimpleNamespace(
|
||||
configured=True,
|
||||
channel_is_allowed=True,
|
||||
channel=1,
|
||||
)
|
||||
service.hardware_execution = Mock()
|
||||
service.hardware_execution.reserve.return_value = nullcontext()
|
||||
service.hardware_access = Mock()
|
||||
service._process_once = Mock(return_value={"status": "succeeded"})
|
||||
|
||||
result = service.process(
|
||||
"ac off",
|
||||
execution_mode="hardware",
|
||||
hardware_access_bypass=False,
|
||||
)
|
||||
|
||||
self.assertEqual(result["status"], "succeeded")
|
||||
self.assertTrue(service.driver_only_ch1_execution_active)
|
||||
service.hardware_access.ensure_authorized.assert_not_called()
|
||||
|
||||
def test_browser_lease_remains_required_if_legacy_seat_path_is_enabled(self) -> None:
|
||||
service = object.__new__(SeatDemoService)
|
||||
service.hardware_settings = SimpleNamespace(enabled=True)
|
||||
service.vehicle_can_settings = SimpleNamespace(
|
||||
configured=True,
|
||||
channel_is_allowed=True,
|
||||
channel=1,
|
||||
)
|
||||
self.assertFalse(service.driver_only_ch1_execution_active)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -0,0 +1,529 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import unittest
|
||||
from pathlib import Path
|
||||
from unittest.mock import patch
|
||||
|
||||
from cognitive_agent.application.capability_registry import CapabilityRegistry
|
||||
from cognitive_agent.application.native_can_intent import merge_native_can_intent
|
||||
from cognitive_agent.application.native_can_runtime import (
|
||||
NativeCanCompileError,
|
||||
NativeCanExecutor,
|
||||
NativeCanRuntime,
|
||||
)
|
||||
from cognitive_agent.infrastructure.can.gateway import CanFrame
|
||||
|
||||
|
||||
REPOSITORY_ROOT = Path(__file__).resolve().parents[2]
|
||||
|
||||
|
||||
class _FakeBus:
|
||||
def __init__(
|
||||
self,
|
||||
received: list[CanFrame] | None = None,
|
||||
*,
|
||||
fail_on_calls: set[int] | None = None,
|
||||
) -> None:
|
||||
self.received = list(received or [])
|
||||
self.fail_on_calls = set(fail_on_calls or set())
|
||||
self.send_calls = 0
|
||||
self.sent: list[CanFrame] = []
|
||||
self.closed = False
|
||||
|
||||
def send(
|
||||
self,
|
||||
arbitration_id: int,
|
||||
data: bytes,
|
||||
*,
|
||||
is_fd: bool = False,
|
||||
bitrate_switch: bool = False,
|
||||
) -> CanFrame:
|
||||
self.send_calls += 1
|
||||
if self.send_calls in self.fail_on_calls:
|
||||
raise RuntimeError(f"injected send fault {self.send_calls}")
|
||||
frame = CanFrame(
|
||||
arbitration_id,
|
||||
bytes(data),
|
||||
channel=1,
|
||||
is_fd=is_fd,
|
||||
bitrate_switch=bitrate_switch,
|
||||
)
|
||||
self.sent.append(frame)
|
||||
return frame
|
||||
|
||||
def receive(
|
||||
self, timeout_ms: int, *, include_fd: bool = False
|
||||
) -> CanFrame | None:
|
||||
return self.received.pop(0) if self.received else None
|
||||
|
||||
def close(self) -> None:
|
||||
self.closed = True
|
||||
|
||||
|
||||
def _native_action(
|
||||
capability: str, parameters: dict, target: str = "driver"
|
||||
) -> dict:
|
||||
return {
|
||||
"domain": "seat",
|
||||
"capability": capability,
|
||||
"target": target,
|
||||
"operation": "set",
|
||||
"parameters": parameters,
|
||||
}
|
||||
|
||||
|
||||
class DriverSeatNativeCanTests(unittest.TestCase):
|
||||
def setUp(self) -> None:
|
||||
self.runtime = NativeCanRuntime(REPOSITORY_ROOT)
|
||||
|
||||
def _compile(
|
||||
self, capability: str, parameters: dict, target: str = "driver"
|
||||
) -> tuple[dict, dict]:
|
||||
config = self.runtime.build_function_config(
|
||||
"seat",
|
||||
[_native_action(capability, parameters, target)],
|
||||
"req-driver-seat",
|
||||
"local_rules",
|
||||
)
|
||||
return config, self.runtime.compile(config)
|
||||
|
||||
@staticmethod
|
||||
def _parsed_action(utterance: str) -> dict | None:
|
||||
parsed = merge_native_can_intent({}, utterance)
|
||||
actions = parsed.get("native_can_actions", [])
|
||||
return actions[0] if len(actions) == 1 else None
|
||||
|
||||
def test_catalog_exposes_only_driver_and_front_left_native_targets(self) -> None:
|
||||
snapshot = CapabilityRegistry(REPOSITORY_ROOT, {"seat"}).snapshot()
|
||||
native_seat = next(
|
||||
item
|
||||
for item in snapshot["domains"]
|
||||
if item["domain"] == "seat" and item["availability"] == "native_can"
|
||||
)
|
||||
capabilities = {item["name"]: item for item in native_seat["capabilities"]}
|
||||
self.assertEqual(
|
||||
set(capabilities),
|
||||
{
|
||||
"seat.set_driver_ventilation_native",
|
||||
"seat.set_driver_heating_native",
|
||||
"seat.set_driver_massage_native",
|
||||
"seat.set_driver_axis_native",
|
||||
},
|
||||
)
|
||||
for capability in capabilities.values():
|
||||
self.assertEqual(set(capability["target_scopes"]), {"driver", "front_left"})
|
||||
|
||||
def test_exact_commands_preserve_verified_driver_seat_parameters(self) -> None:
|
||||
cases = {
|
||||
"seat driver vent low": (
|
||||
"seat.set_driver_ventilation_native",
|
||||
{"state": "on", "level": 2},
|
||||
"driver",
|
||||
),
|
||||
"seat front-left heat high": (
|
||||
"seat.set_driver_heating_native",
|
||||
{"state": "on", "level": 1},
|
||||
"front_left",
|
||||
),
|
||||
"seat driver massage off": (
|
||||
"seat.set_driver_massage_native",
|
||||
{"action": "off"},
|
||||
"driver",
|
||||
),
|
||||
"seat driver massage strength strong": (
|
||||
"seat.set_driver_massage_native",
|
||||
{"action": "strength", "strength": 3},
|
||||
"driver",
|
||||
),
|
||||
"seat driver massage mode relax": (
|
||||
"seat.set_driver_massage_native",
|
||||
{"action": "mode", "mode": "relax"},
|
||||
"driver",
|
||||
),
|
||||
"seat driver backrest backward 800": (
|
||||
"seat.set_driver_axis_native",
|
||||
{"axis": "backrest", "direction": "backward", "duration_ms": 800},
|
||||
"driver",
|
||||
),
|
||||
"seat driver height stop": (
|
||||
"seat.set_driver_axis_native",
|
||||
{"axis": "height", "direction": "stop", "duration_ms": 500},
|
||||
"driver",
|
||||
),
|
||||
}
|
||||
for utterance, expected in cases.items():
|
||||
with self.subTest(utterance=utterance):
|
||||
action = self._parsed_action(utterance)
|
||||
self.assertIsNotNone(action)
|
||||
assert action is not None
|
||||
self.assertEqual(
|
||||
(action["capability"], action["parameters"], action["target"]),
|
||||
expected,
|
||||
)
|
||||
|
||||
def test_chinese_commands_require_explicit_driver_scope(self) -> None:
|
||||
cases = {
|
||||
"打开主驾座椅通风": (
|
||||
"seat.set_driver_ventilation_native",
|
||||
{"state": "on", "level": 2},
|
||||
),
|
||||
"主驾座椅加热高档": (
|
||||
"seat.set_driver_heating_native",
|
||||
{"state": "on", "level": 1},
|
||||
),
|
||||
"把驾驶位按摩调成波浪模式": (
|
||||
"seat.set_driver_massage_native",
|
||||
{"action": "mode", "mode": "wave"},
|
||||
),
|
||||
"主驾座椅按摩强度调高": (
|
||||
"seat.set_driver_massage_native",
|
||||
{"action": "strength", "strength": 3},
|
||||
),
|
||||
"主驾座椅向前移动500毫秒": (
|
||||
"seat.set_driver_axis_native",
|
||||
{"axis": "horizontal", "direction": "forward", "duration_ms": 500},
|
||||
),
|
||||
"主驾座椅靠背后仰800毫秒": (
|
||||
"seat.set_driver_axis_native",
|
||||
{"axis": "backrest", "direction": "backward", "duration_ms": 800},
|
||||
),
|
||||
"把主驾座椅坐盆降低": (
|
||||
"seat.set_driver_axis_native",
|
||||
{"axis": "basin", "direction": "down", "duration_ms": 500},
|
||||
),
|
||||
"主驾座椅升高": (
|
||||
"seat.set_driver_axis_native",
|
||||
{"axis": "height", "direction": "up", "duration_ms": 500},
|
||||
),
|
||||
}
|
||||
for utterance, expected in cases.items():
|
||||
with self.subTest(utterance=utterance):
|
||||
action = self._parsed_action(utterance)
|
||||
self.assertIsNotNone(action)
|
||||
assert action is not None
|
||||
self.assertEqual((action["capability"], action["parameters"]), expected)
|
||||
self.assertEqual(action["target"], "driver")
|
||||
|
||||
def test_parser_rejects_passenger_right_front_and_unsafe_guesses(self) -> None:
|
||||
utterances = (
|
||||
"seat passenger vent low",
|
||||
"seat front-right heat high",
|
||||
"打开副驾座椅通风",
|
||||
"打开右前座椅加热",
|
||||
"打开主驾和副驾座椅通风",
|
||||
"seat driver vent low, seat passenger vent low",
|
||||
"打开主驾座椅按摩",
|
||||
"主驾座椅向前移动50毫秒",
|
||||
"seat driver height forward 500",
|
||||
)
|
||||
for utterance in utterances:
|
||||
with self.subTest(utterance=utterance):
|
||||
parsed = merge_native_can_intent({}, utterance)
|
||||
self.assertFalse(parsed.get("native_can_actions"))
|
||||
|
||||
def test_deterministic_driver_parse_supersedes_deferred_model_copy(self) -> None:
|
||||
parsed = {
|
||||
"actions": [],
|
||||
"domain_actions": [],
|
||||
"native_can_actions": [],
|
||||
"deferred_requests": [
|
||||
{
|
||||
"domain": "seat",
|
||||
"capability": "seat.set_driver_ventilation_native",
|
||||
"target_scope": "driver",
|
||||
"parameters": {"state": "on", "level": 2},
|
||||
"execution_backend": "native_can",
|
||||
}
|
||||
],
|
||||
"model_requests": [
|
||||
{
|
||||
"domain": "seat",
|
||||
"capability": "seat.set_driver_ventilation_native",
|
||||
"target_scope": "driver",
|
||||
"operation": "set",
|
||||
"parameters": [
|
||||
{"name": "state", "value": "on"},
|
||||
{"name": "level", "value": 2},
|
||||
],
|
||||
"reason": "model",
|
||||
}
|
||||
],
|
||||
}
|
||||
merged = merge_native_can_intent(parsed, "打开主驾座椅通风")
|
||||
self.assertEqual(len(merged["native_can_actions"]), 1)
|
||||
self.assertEqual(
|
||||
merged["native_can_actions"][0]["capability"],
|
||||
"seat.set_driver_ventilation_native",
|
||||
)
|
||||
|
||||
def test_thermal_frames_match_cpp_whitelist_and_feedback_contract(self) -> None:
|
||||
cases = (
|
||||
(
|
||||
"seat.set_driver_ventilation_native",
|
||||
{"state": "on", "level": 1},
|
||||
"00 01 00 00 00 00 00 00",
|
||||
"driver_seat_ventilation:3",
|
||||
),
|
||||
(
|
||||
"seat.set_driver_ventilation_native",
|
||||
{"state": "off"},
|
||||
"00 03 00 00 00 00 00 00",
|
||||
"driver_seat_ventilation:1",
|
||||
),
|
||||
(
|
||||
"seat.set_driver_heating_native",
|
||||
{"state": "on", "level": 2},
|
||||
"00 20 00 00 00 00 00 00",
|
||||
"driver_seat_heating:2",
|
||||
),
|
||||
(
|
||||
"seat.set_driver_heating_native",
|
||||
{"state": "off"},
|
||||
"00 30 00 00 00 00 00 00",
|
||||
"driver_seat_heating:1",
|
||||
),
|
||||
)
|
||||
for capability, parameters, payload, expected in cases:
|
||||
with self.subTest(capability=capability, parameters=parameters):
|
||||
_, plan = self._compile(capability, parameters)
|
||||
command = plan["spec"]["commands"][0]
|
||||
self.assertEqual(command["signals"]["frame_id"], "0x431")
|
||||
self.assertEqual(command["signals"]["data_hex"], payload)
|
||||
self.assertTrue(command["signals"]["is_fd"])
|
||||
self.assertTrue(command["signals"]["bitrate_switch"])
|
||||
self.assertEqual(command["repeat"], {"count": 3, "period_ms": 100})
|
||||
self.assertEqual(command["signals"]["feedback_ids"], "0x4FA")
|
||||
self.assertEqual(command["signals"]["expected_feedback"], expected)
|
||||
|
||||
def test_massage_frames_match_cpp_whitelist(self) -> None:
|
||||
cases = (
|
||||
({"action": "off"}, "12 00 00 01 00 00 00 00", "driver_seat_massage_mode:1"),
|
||||
(
|
||||
{"action": "strength", "strength": 3},
|
||||
"12 03 00 00 00 00 00 00",
|
||||
"driver_seat_massage_strength:11",
|
||||
),
|
||||
(
|
||||
{"action": "mode", "mode": "relax"},
|
||||
"12 00 00 06 00 00 00 00",
|
||||
"driver_seat_massage_mode:6",
|
||||
),
|
||||
)
|
||||
for parameters, payload, expected in cases:
|
||||
with self.subTest(parameters=parameters):
|
||||
_, plan = self._compile("seat.set_driver_massage_native", parameters)
|
||||
command = plan["spec"]["commands"][0]
|
||||
self.assertEqual(command["signals"]["frame_id"], "0x4C1")
|
||||
self.assertEqual(command["signals"]["data_hex"], payload)
|
||||
self.assertEqual(command["repeat"], {"count": 3, "period_ms": 100})
|
||||
self.assertEqual(command["signals"]["feedback_ids"], "0x4FA")
|
||||
self.assertEqual(command["signals"]["expected_feedback"], expected)
|
||||
|
||||
def test_all_four_axes_compile_bounded_motion_and_forced_stop(self) -> None:
|
||||
cases = (
|
||||
("horizontal", "forward", "05 01 00 00 00 00 00 00"),
|
||||
("height", "down", "05 08 00 00 00 00 00 00"),
|
||||
("backrest", "forward", "05 10 00 00 00 00 00 00"),
|
||||
("basin", "down", "05 80 00 00 00 00 00 00"),
|
||||
)
|
||||
for axis, direction, payload in cases:
|
||||
with self.subTest(axis=axis):
|
||||
_, plan = self._compile(
|
||||
"seat.set_driver_axis_native",
|
||||
{"axis": axis, "direction": direction, "duration_ms": 500},
|
||||
)
|
||||
command = plan["spec"]["commands"][0]
|
||||
self.assertEqual(command["signals"]["frame_id"], "0x4C1")
|
||||
self.assertEqual(command["signals"]["data_hex"], payload)
|
||||
self.assertEqual(command["repeat"], {"count": 5, "period_ms": 100})
|
||||
self.assertEqual(command["signals"]["feedback_ids"], "0x494")
|
||||
self.assertEqual(command["signals"]["special_handler"], "driver_seat_axis")
|
||||
safety = json.loads(command["signals"]["special_parameters"])
|
||||
self.assertEqual(safety["stop_data_hex"], "05 00 00 00 00 00 00 00")
|
||||
self.assertEqual(safety["stop_repeat_count"], 3)
|
||||
self.assertEqual(safety["stop_period_ms"], 100)
|
||||
public = self.runtime.command_frame_public(command)
|
||||
self.assertTrue(public["safety"]["forced_stop"])
|
||||
|
||||
def test_explicit_axis_stop_is_three_fd_stop_frames(self) -> None:
|
||||
_, plan = self._compile(
|
||||
"seat.set_driver_axis_native",
|
||||
{"axis": "height", "direction": "stop"},
|
||||
)
|
||||
command = plan["spec"]["commands"][0]
|
||||
self.assertEqual(command["signals"]["data_hex"], "05 00 00 00 00 00 00 00")
|
||||
self.assertEqual(command["repeat"], {"count": 3, "period_ms": 100})
|
||||
self.assertNotIn("special_handler", command["signals"])
|
||||
|
||||
def test_compiler_fails_closed_for_wrong_seat_or_invalid_parameters(self) -> None:
|
||||
for target in ("passenger", "front_right"):
|
||||
action = _native_action(
|
||||
"seat.set_driver_ventilation_native",
|
||||
{"state": "on", "level": 2},
|
||||
target,
|
||||
)
|
||||
with self.subTest(target=target):
|
||||
self.assertFalse(self.runtime.supports(action))
|
||||
with self.assertRaises(NativeCanCompileError):
|
||||
self.runtime.build_function_config(
|
||||
"seat", [action], "req-reject", "local_rules"
|
||||
)
|
||||
self.assertNotIn("seat.set_ventilation", self.runtime.supported_capabilities)
|
||||
invalid = (
|
||||
(
|
||||
"seat.set_driver_ventilation_native",
|
||||
{"state": "on", "level": 3},
|
||||
),
|
||||
(
|
||||
"seat.set_driver_massage_native",
|
||||
{"action": "mode", "mode": "unknown"},
|
||||
),
|
||||
(
|
||||
"seat.set_driver_axis_native",
|
||||
{"axis": "height", "direction": "forward", "duration_ms": 500},
|
||||
),
|
||||
(
|
||||
"seat.set_driver_axis_native",
|
||||
{"axis": "horizontal", "direction": "forward", "duration_ms": 2001},
|
||||
),
|
||||
)
|
||||
for capability, parameters in invalid:
|
||||
with self.subTest(capability=capability, parameters=parameters):
|
||||
config = self.runtime.build_function_config(
|
||||
"seat",
|
||||
[_native_action(capability, parameters)],
|
||||
"req-invalid",
|
||||
"local_rules",
|
||||
)
|
||||
with self.assertRaises(NativeCanCompileError):
|
||||
self.runtime.compile(config)
|
||||
|
||||
def test_thermal_feedback_is_semantically_matched_and_decoded(self) -> None:
|
||||
config, plan = self._compile(
|
||||
"seat.set_driver_ventilation_native",
|
||||
{"state": "on", "level": 1},
|
||||
)
|
||||
feedback = CanFrame(
|
||||
0x4FA,
|
||||
bytes((0x0B, 0, 0, 0, 0, 0x81, 0, 0)),
|
||||
channel=1,
|
||||
is_fd=True,
|
||||
bitrate_switch=True,
|
||||
)
|
||||
bus = _FakeBus([feedback])
|
||||
with patch("cognitive_agent.application.native_can_runtime.time.sleep"):
|
||||
report = NativeCanExecutor(lambda: bus, feedback_timeout_ms=1).execute(
|
||||
self.runtime, plan, config
|
||||
)
|
||||
result = report["spec"]["results"][0]
|
||||
self.assertEqual(report["spec"]["status"], "succeeded")
|
||||
self.assertTrue(result["telemetry"]["feedback_match"])
|
||||
self.assertEqual(result["telemetry"]["feedback_state"]["ventilation"], "high")
|
||||
self.assertEqual(result["telemetry"]["feedback_state"]["heating"], "off")
|
||||
self.assertEqual(result["telemetry"]["feedback_state"]["massage_mode"], "off")
|
||||
self.assertTrue(bus.closed)
|
||||
|
||||
wrong_bus = _FakeBus(
|
||||
[CanFrame(0x4FA, bytes((0x0A, 0, 0, 0, 0, 0x81, 0, 0)), channel=1)]
|
||||
)
|
||||
with patch("cognitive_agent.application.native_can_runtime.time.sleep"):
|
||||
wrong_report = NativeCanExecutor(
|
||||
lambda: wrong_bus, feedback_timeout_ms=1
|
||||
).execute(self.runtime, plan, config)
|
||||
self.assertEqual(wrong_report["spec"]["status"], "failed")
|
||||
self.assertFalse(
|
||||
wrong_report["spec"]["results"][0]["telemetry"]["feedback_match"]
|
||||
)
|
||||
|
||||
def test_massage_feedback_matches_mode_and_strength_fields(self) -> None:
|
||||
cases = (
|
||||
(
|
||||
{"action": "mode", "mode": "relax"},
|
||||
bytes((1, 0, 0, 0, 0, 0x86, 0, 0)),
|
||||
"relax",
|
||||
"off",
|
||||
),
|
||||
(
|
||||
{"action": "strength", "strength": 3},
|
||||
bytes((1, 0, 0, 0, 0, 0xB1, 0, 0)),
|
||||
"off",
|
||||
"strong",
|
||||
),
|
||||
)
|
||||
for parameters, payload, mode, strength in cases:
|
||||
with self.subTest(parameters=parameters):
|
||||
config, plan = self._compile(
|
||||
"seat.set_driver_massage_native", parameters
|
||||
)
|
||||
bus = _FakeBus([CanFrame(0x4FA, payload, channel=1)])
|
||||
with patch("cognitive_agent.application.native_can_runtime.time.sleep"):
|
||||
report = NativeCanExecutor(
|
||||
lambda: bus, feedback_timeout_ms=1
|
||||
).execute(self.runtime, plan, config)
|
||||
telemetry = report["spec"]["results"][0]["telemetry"]
|
||||
self.assertEqual(report["spec"]["status"], "succeeded")
|
||||
self.assertTrue(telemetry["feedback_match"])
|
||||
self.assertEqual(telemetry["feedback_state"]["massage_mode"], mode)
|
||||
self.assertEqual(
|
||||
telemetry["feedback_state"]["massage_strength"], strength
|
||||
)
|
||||
|
||||
def test_axis_executor_sends_stop_and_reports_0x494_limitation(self) -> None:
|
||||
config, plan = self._compile(
|
||||
"seat.set_driver_axis_native",
|
||||
{"axis": "horizontal", "direction": "forward", "duration_ms": 500},
|
||||
)
|
||||
feedback = CanFrame(
|
||||
0x494,
|
||||
bytes((0, 0, 0, 0, 0, 0, 0, 0x3C)),
|
||||
channel=1,
|
||||
is_fd=True,
|
||||
bitrate_switch=True,
|
||||
)
|
||||
bus = _FakeBus([feedback])
|
||||
with patch("cognitive_agent.application.native_can_runtime.time.sleep"):
|
||||
report = NativeCanExecutor(lambda: bus, feedback_timeout_ms=1).execute(
|
||||
self.runtime, plan, config
|
||||
)
|
||||
# 0x494 proves only initialization and cannot confirm that the timed
|
||||
# motion physically occurred, so strict hardware feedback fails closed.
|
||||
self.assertEqual(report["spec"]["status"], "failed")
|
||||
self.assertEqual(len(bus.sent), 8)
|
||||
self.assertTrue(all(frame.is_fd and frame.bitrate_switch for frame in bus.sent))
|
||||
self.assertEqual(
|
||||
[frame.data for frame in bus.sent[-3:]],
|
||||
[bytes((0x05, 0, 0, 0, 0, 0, 0, 0))] * 3,
|
||||
)
|
||||
telemetry = report["spec"]["results"][0]["telemetry"]
|
||||
self.assertIsNone(telemetry["feedback_match"])
|
||||
self.assertTrue(telemetry["feedback_required"])
|
||||
self.assertTrue(telemetry["feedback_observed"])
|
||||
self.assertEqual(
|
||||
telemetry["feedback_state"]["execution_confirmation"],
|
||||
"status_frame_only",
|
||||
)
|
||||
self.assertTrue(all(telemetry["feedback_state"]["initialized"].values()))
|
||||
|
||||
def test_axis_executor_attempts_every_stop_frame_after_motion_fault(self) -> None:
|
||||
config, plan = self._compile(
|
||||
"seat.set_driver_axis_native",
|
||||
{"axis": "basin", "direction": "up", "duration_ms": 500},
|
||||
)
|
||||
bus = _FakeBus(fail_on_calls={2})
|
||||
with patch("cognitive_agent.application.native_can_runtime.time.sleep"):
|
||||
report = NativeCanExecutor(lambda: bus, feedback_timeout_ms=1).execute(
|
||||
self.runtime, plan, config
|
||||
)
|
||||
self.assertEqual(report["spec"]["status"], "failed")
|
||||
self.assertEqual(bus.send_calls, 5)
|
||||
self.assertEqual(
|
||||
[frame.data for frame in bus.sent[-3:]],
|
||||
[bytes((0x05, 0, 0, 0, 0, 0, 0, 0))] * 3,
|
||||
)
|
||||
self.assertIn("motion send failed", report["spec"]["results"][0]["message"])
|
||||
self.assertTrue(bus.closed)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -0,0 +1,291 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
from pathlib import Path
|
||||
import shutil
|
||||
import subprocess
|
||||
import unittest
|
||||
|
||||
from cognitive_agent.application.conversation_state import ConversationStateStore
|
||||
|
||||
|
||||
REPOSITORY_ROOT = Path(__file__).resolve().parents[2]
|
||||
APP_SOURCE_PATH = REPOSITORY_ROOT / "apps" / "web-console" / "app.js"
|
||||
EXTENSION_SOURCE_PATH = (
|
||||
REPOSITORY_ROOT / "apps" / "web-console" / "self-extension.js"
|
||||
)
|
||||
|
||||
|
||||
def function_source(source: str, signature: str, next_signature: str) -> str:
|
||||
start = source.index(signature)
|
||||
end = source.index(next_signature, start)
|
||||
return source[start:end]
|
||||
|
||||
|
||||
class FrontendVehicleStateSyncContractTests(unittest.TestCase):
|
||||
@classmethod
|
||||
def setUpClass(cls) -> None:
|
||||
cls.app_source = APP_SOURCE_PATH.read_text(encoding="utf-8")
|
||||
cls.extension_source = EXTENSION_SOURCE_PATH.read_text(encoding="utf-8")
|
||||
|
||||
def test_both_modules_use_the_same_browser_conversation(self) -> None:
|
||||
key = "cognitive-agent.conversation-id"
|
||||
self.assertIn(
|
||||
f'sessionStorage.getItem("{key}")',
|
||||
self.app_source,
|
||||
)
|
||||
self.assertIn(
|
||||
f'sessionStorage.setItem("{key}", conversationId)',
|
||||
self.app_source,
|
||||
)
|
||||
self.assertIn(
|
||||
f'const CONVERSATION_KEY = "{key}"',
|
||||
self.extension_source,
|
||||
)
|
||||
self.assertIn(
|
||||
"body: JSON.stringify({ command, conversation_id: sharedConversationId() })",
|
||||
self.extension_source,
|
||||
)
|
||||
|
||||
def test_lan_http_origin_does_not_require_crypto_random_uuid(self) -> None:
|
||||
id_source = function_source(
|
||||
self.app_source,
|
||||
"function createOpaqueId(prefix) {",
|
||||
"\nfunction createConversationId",
|
||||
)
|
||||
self.assertIn("globalThis.crypto?.randomUUID", id_source)
|
||||
self.assertIn("globalThis.crypto?.getRandomValues", id_source)
|
||||
self.assertIn("Math.random()", id_source)
|
||||
self.assertIn('return createOpaqueId("conv")', self.app_source)
|
||||
self.assertIn('createOpaqueId("hwc")', self.app_source)
|
||||
|
||||
def test_extension_accepts_only_explicit_physical_ch1_state(self) -> None:
|
||||
authority_source = function_source(
|
||||
self.extension_source,
|
||||
" function isAuthoritativeVehicleStatePayload(payload) {",
|
||||
"\n function renderAuthoritativeVehicleState",
|
||||
)
|
||||
self.assertIn(
|
||||
'payload.conversation_id !== sharedConversationId()', authority_source
|
||||
)
|
||||
self.assertIn(
|
||||
"containsAuthoritativePhysicalCh1State(payload.actuator_state)",
|
||||
authority_source,
|
||||
)
|
||||
self.assertIn(
|
||||
"containsAuthoritativePhysicalCh1State(payload.domain_state)",
|
||||
authority_source,
|
||||
)
|
||||
self.assertNotIn("physical_feedback_confirmed", authority_source)
|
||||
|
||||
summary_source = function_source(
|
||||
self.extension_source,
|
||||
" function renderAuthoritativeVehicleState(payload) {",
|
||||
"\n function setMode",
|
||||
)
|
||||
self.assertNotIn(
|
||||
"items.length === 1 && payload.physical_feedback_confirmed",
|
||||
summary_source,
|
||||
)
|
||||
for state_path in (
|
||||
"ventilation?.feedback_source",
|
||||
"heating?.feedback_source",
|
||||
"climate?.feedback_source",
|
||||
"roof?.feedback_source",
|
||||
"frontLeftWindow?.feedback_source",
|
||||
):
|
||||
self.assertIn(f'{state_path} === "physical_can_ch1"', summary_source)
|
||||
|
||||
def test_orchestration_renders_only_the_physical_ch1_patch(self) -> None:
|
||||
apply_source = function_source(
|
||||
self.app_source,
|
||||
"function applyAuthoritativeVehicleState(payload) {",
|
||||
'\nwindow.addEventListener("cognitive-agent:extension-vehicle-result"',
|
||||
)
|
||||
self.assertIn(
|
||||
"const actuatorPatch = physicalCh1StatePatch(payload.actuator_state)",
|
||||
apply_source,
|
||||
)
|
||||
self.assertIn(
|
||||
"const domainPatch = physicalCh1StatePatch(payload.domain_state)",
|
||||
apply_source,
|
||||
)
|
||||
self.assertIn("renderSeatState(actuatorPatch)", apply_source)
|
||||
self.assertIn("renderDomainState(domainPatch)", apply_source)
|
||||
self.assertIn("if (actuatorPatch?.driver)", apply_source)
|
||||
self.assertNotIn("renderSeatState(payload.actuator_state)", apply_source)
|
||||
self.assertNotIn("renderDomainState(payload.domain_state)", apply_source)
|
||||
|
||||
render_source = function_source(
|
||||
self.app_source,
|
||||
"function renderResult(result) {",
|
||||
"\nasync function submitRequest",
|
||||
)
|
||||
ch1_branch = function_source(
|
||||
render_source,
|
||||
" if (ch1HardwareResult) {",
|
||||
" updateTwinExplicitAngles",
|
||||
)
|
||||
self.assertIn("applyAuthoritativeVehicleState(result)", ch1_branch)
|
||||
self.assertIn("} else {", ch1_branch)
|
||||
self.assertIn("renderSeatState(result.actuator_state)", ch1_branch)
|
||||
self.assertIn("renderDomainState(result.domain_state)", ch1_branch)
|
||||
|
||||
def test_physical_patch_does_not_promote_unmarked_sibling_domains(self) -> None:
|
||||
node = shutil.which("node")
|
||||
if node is None:
|
||||
self.skipTest("Node.js is required to execute the frontend patch helper")
|
||||
patch_source = function_source(
|
||||
self.app_source,
|
||||
"function physicalCh1StatePatch(value) {",
|
||||
"\nfunction isAuthoritativeVehicleStatePayload",
|
||||
)
|
||||
state = {
|
||||
"climate": {
|
||||
"feedback_source": "physical_can_ch1",
|
||||
"power": "on",
|
||||
},
|
||||
"roof": {"shade_position_pct": 100},
|
||||
"window": {
|
||||
"positions": {"front_left": {"position_pct": 50}}
|
||||
},
|
||||
}
|
||||
script = (
|
||||
f"{patch_source}\n"
|
||||
f"const input = {json.dumps(state)};\n"
|
||||
"process.stdout.write(JSON.stringify(physicalCh1StatePatch(input)));"
|
||||
)
|
||||
completed = subprocess.run(
|
||||
[node, "-e", script],
|
||||
check=True,
|
||||
capture_output=True,
|
||||
text=True,
|
||||
)
|
||||
self.assertEqual(
|
||||
json.loads(completed.stdout),
|
||||
{"climate": state["climate"]},
|
||||
)
|
||||
|
||||
def test_ch1_mode_never_polls_the_legacy_ch0_seat_endpoint(self) -> None:
|
||||
sync_source = function_source(
|
||||
self.app_source,
|
||||
"async function synchronizePhysicalSeatState() {",
|
||||
"\nasync function loadAuthStatus()",
|
||||
)
|
||||
guard = sync_source.index(
|
||||
'target_policy === "ch1_body_can_driver_only"'
|
||||
)
|
||||
legacy_request = sync_source.index("/api/hardware/seat-state")
|
||||
self.assertLess(guard, legacy_request)
|
||||
self.assertIn("return false", sync_source[guard:legacy_request])
|
||||
|
||||
def test_vehicle_selection_cannot_replay_unfiltered_state_in_ch1_mode(self) -> None:
|
||||
select_source = function_source(
|
||||
self.app_source,
|
||||
"function selectVehicle(vehicleId, { silent = false } = {}) {",
|
||||
"\nfunction populateVehicleSelector",
|
||||
)
|
||||
driver_branch = select_source.index("if (isDriverOnlyCh1Runtime())")
|
||||
filtered_snapshot = select_source.index(
|
||||
"state.vehicleStateSnapshot?.actuatorState",
|
||||
driver_branch,
|
||||
)
|
||||
unfiltered_result = select_source.index("state.result?.actuator_state")
|
||||
self.assertLess(driver_branch, filtered_snapshot)
|
||||
self.assertLess(filtered_snapshot, unfiltered_result)
|
||||
self.assertIn("} else if (state.result?.actuator_state)", select_source)
|
||||
|
||||
def test_driver_only_ch1_has_no_hidden_browser_lease_gate(self) -> None:
|
||||
submit_source = function_source(
|
||||
self.app_source,
|
||||
"async function submitRequest(event) {",
|
||||
"\nasync function copyArtifact",
|
||||
)
|
||||
self.assertIn("const driverOnlyCh1 = isDriverOnlyCh1Runtime()", submit_source)
|
||||
self.assertIn("&& !driverOnlyCh1", submit_source)
|
||||
self.assertIn(
|
||||
'state.executionMode === "hardware" && !driverOnlyCh1',
|
||||
submit_source,
|
||||
)
|
||||
startup_source = self.app_source[
|
||||
self.app_source.index('document.addEventListener("DOMContentLoaded"') :
|
||||
]
|
||||
self.assertIn("if (!isDriverOnlyCh1Runtime())", startup_source)
|
||||
self.assertIn("loadHardwareAccessStatus", startup_source)
|
||||
|
||||
def test_state_results_flow_in_both_directions(self) -> None:
|
||||
self.assertIn(
|
||||
'window.addEventListener("cognitive-agent:extension-vehicle-result"',
|
||||
self.app_source,
|
||||
)
|
||||
self.assertIn(
|
||||
'window.addEventListener("cognitive-agent:result"',
|
||||
self.extension_source,
|
||||
)
|
||||
|
||||
|
||||
class PhysicalMarkerInvalidationContractTests(unittest.TestCase):
|
||||
def test_logical_door_and_window_actions_replace_stale_physical_roots(self) -> None:
|
||||
store = ConversationStateStore()
|
||||
conversation_id = "conv-frontend-marker-invalidation"
|
||||
store.synchronize_physical_ch1_feedback(
|
||||
conversation_id,
|
||||
[
|
||||
{
|
||||
"source": "physical_can_ch1",
|
||||
"channel": 1,
|
||||
"frame_id": "0x3B6",
|
||||
"feedback_frame_id": "0x3B6",
|
||||
"valid": True,
|
||||
"verification": {
|
||||
"authoritative": True,
|
||||
"channel": 1,
|
||||
"simulated": False,
|
||||
"feedback_required": True,
|
||||
"feedback_observed": True,
|
||||
"feedback_match": True,
|
||||
"frame_id": "0x3B6",
|
||||
},
|
||||
"domain_state": {
|
||||
"door": {"locks": {"rear_left": {"state": "lock"}}},
|
||||
"window": {
|
||||
"positions": {
|
||||
"front_left": {
|
||||
"position_pct": 20,
|
||||
"closed": False,
|
||||
}
|
||||
}
|
||||
},
|
||||
},
|
||||
}
|
||||
],
|
||||
)
|
||||
physical = store.snapshot(conversation_id)["domain_devices"]
|
||||
self.assertEqual(physical["door"]["feedback_source"], "physical_can_ch1")
|
||||
self.assertEqual(physical["window"]["feedback_source"], "physical_can_ch1")
|
||||
|
||||
updated = store.apply_successful_actions(
|
||||
conversation_id,
|
||||
"logical projection must not look physical",
|
||||
[
|
||||
{
|
||||
"domain": "door",
|
||||
"capability": "door.set_rear_lock",
|
||||
"target": "rear",
|
||||
"parameters": {"state": "unlock"},
|
||||
},
|
||||
{
|
||||
"domain": "window",
|
||||
"capability": "window.set_position",
|
||||
"target": "front_left",
|
||||
"parameters": {"position_pct": 60},
|
||||
},
|
||||
],
|
||||
[],
|
||||
)["domain_devices"]
|
||||
self.assertEqual(updated["door"]["feedback_source"], "virtual")
|
||||
self.assertEqual(updated["window"]["feedback_source"], "virtual")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -0,0 +1,154 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from copy import deepcopy
|
||||
import unittest
|
||||
|
||||
from cognitive_agent.application.native_can_feedback import (
|
||||
authoritative_physical_ch1_feedback_state,
|
||||
decode_feedback,
|
||||
feedback_matches,
|
||||
merge_state,
|
||||
)
|
||||
from cognitive_agent.infrastructure.can.gateway import CanFrame
|
||||
|
||||
|
||||
class NativeCanFeedbackTests(unittest.TestCase):
|
||||
def frame(self, frame_id: int, data_hex: str) -> CanFrame:
|
||||
return CanFrame(frame_id, bytes.fromhex(data_hex), channel=1, is_fd=True)
|
||||
|
||||
def test_passive_ch1_samples_decode_stationary_vehicle(self) -> None:
|
||||
gear = decode_feedback(self.frame(0x342, "00001fb1c1fff778"))
|
||||
speed = decode_feedback(self.frame(0x4EB, "0700f00001516766"))
|
||||
|
||||
self.assertEqual(gear["vehicle_safety"]["gear"], "P")
|
||||
self.assertEqual(speed["vehicle_safety"]["speed_kmh"], 0)
|
||||
self.assertEqual(gear["source"], "physical_can_ch1")
|
||||
self.assertEqual(gear["channel"], 1)
|
||||
|
||||
def test_climate_feedback_is_matched_by_signal_value(self) -> None:
|
||||
feedback = self.frame(0x404, "4369116c1818a2dd")
|
||||
decoded = decode_feedback(feedback)["domain_state"]["climate"]
|
||||
|
||||
self.assertEqual(decoded["power"], "on")
|
||||
self.assertEqual(decoded["zones"]["driver"]["temperature_c"], 24)
|
||||
self.assertTrue(feedback_matches("climate_power:on", feedback))
|
||||
self.assertTrue(feedback_matches("climate_temperature:driver:24", feedback))
|
||||
self.assertFalse(feedback_matches("climate_temperature:driver:25", feedback))
|
||||
|
||||
def test_window_position_match_uses_authoritative_0x388(self) -> None:
|
||||
feedback = self.frame(0x388, "0000320000005500")
|
||||
self.assertTrue(feedback_matches("window_position:front_left:50", feedback))
|
||||
self.assertFalse(feedback_matches("window_position:front_left:80", feedback))
|
||||
|
||||
def test_light_feedback_requires_target_value_not_only_frame_id(self) -> None:
|
||||
passive_feedback = self.frame(0x42E, "11140a03880200c6")
|
||||
self.assertEqual(
|
||||
decode_feedback(passive_feedback)["domain_state"]["interior_light"]["power"],
|
||||
"unknown",
|
||||
)
|
||||
feedback = self.frame(0x42E, "11140a03200200c6")
|
||||
decoded = decode_feedback(feedback)["domain_state"]
|
||||
self.assertEqual(decoded["interior_light"]["power"], "off")
|
||||
self.assertEqual(decoded["ambient_light"]["power"], "off")
|
||||
self.assertTrue(feedback_matches("indoor_light:off", feedback))
|
||||
self.assertFalse(feedback_matches("indoor_light:on", feedback))
|
||||
self.assertTrue(feedback_matches("ambient_power:off", feedback))
|
||||
|
||||
def test_driver_seat_feedback_uses_0x4fa_not_0x42a(self) -> None:
|
||||
passive_feedback = self.frame(0x4FA, "0980aa0000810000")
|
||||
passive_driver = decode_feedback(passive_feedback)["actuator_state"]["driver"]
|
||||
self.assertEqual(passive_driver["heating"], {"state": "off", "level": 0})
|
||||
feedback = self.frame(0x4FA, "1180aa0000810000")
|
||||
driver = decode_feedback(feedback)["actuator_state"]["driver"]
|
||||
self.assertEqual(driver["ventilation"], {"state": "off", "level": 0})
|
||||
self.assertEqual(driver["heating"], {"state": "on", "level": 1})
|
||||
self.assertTrue(feedback_matches("driver_seat_thermal:heating:low", feedback))
|
||||
self.assertFalse(feedback_matches("driver_seat_thermal:ventilation:high", feedback))
|
||||
|
||||
def test_driver_position_feedback_never_claims_absolute_position(self) -> None:
|
||||
decoded = decode_feedback(
|
||||
self.frame(0x494, "151515058c28203c")
|
||||
)["actuator_state"]["driver"]["position_feedback"]
|
||||
self.assertFalse(decoded["absolute_position_available"])
|
||||
self.assertEqual(
|
||||
set(decoded["initialized"]),
|
||||
{"horizontal", "backrest", "height", "basin"},
|
||||
)
|
||||
|
||||
def test_recursive_state_merge_preserves_other_devices(self) -> None:
|
||||
target = {"driver": {"heating": {"state": "off"}}, "passenger": {"safe": True}}
|
||||
update = {"driver": {"heating": {"state": "on", "level": 1}}}
|
||||
merge_state(target, update)
|
||||
self.assertEqual(target["driver"]["heating"]["state"], "on")
|
||||
self.assertEqual(target["passenger"], {"safe": True})
|
||||
|
||||
def test_authoritative_state_requires_one_complete_consistent_ch1_frame(
|
||||
self,
|
||||
) -> None:
|
||||
operation = {
|
||||
"status": "succeeded",
|
||||
"telemetry": {
|
||||
"channel": 1,
|
||||
"simulated": False,
|
||||
"feedback_required": True,
|
||||
"feedback_observed": True,
|
||||
"feedback_match": True,
|
||||
"feedback_frame": {
|
||||
"frame_id": "0x404",
|
||||
"payload_size": 8,
|
||||
"data_hex": "5B 65 35 6C 18 18 A2 5D",
|
||||
"timestamp": 12.5,
|
||||
"channel": 1,
|
||||
},
|
||||
"feedback_state": {
|
||||
"source": "physical_can_ch1",
|
||||
"channel": 1,
|
||||
"frame_id": "0x404",
|
||||
"feedback_frame_id": "0x404",
|
||||
"valid": True,
|
||||
"domain_state": {"climate": {"power": "on"}},
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
state = authoritative_physical_ch1_feedback_state(operation)
|
||||
self.assertIsNotNone(state)
|
||||
self.assertTrue(state["verification"]["authoritative"])
|
||||
self.assertEqual(state["verification"]["frame_id"], "0x404")
|
||||
|
||||
for label, mutate in (
|
||||
(
|
||||
"wrong frame channel",
|
||||
lambda item: item["telemetry"]["feedback_frame"].update(
|
||||
{"channel": 0}
|
||||
),
|
||||
),
|
||||
(
|
||||
"short payload",
|
||||
lambda item: item["telemetry"]["feedback_frame"].update(
|
||||
{"payload_size": 7}
|
||||
),
|
||||
),
|
||||
(
|
||||
"mismatched decoded id",
|
||||
lambda item: item["telemetry"]["feedback_state"].update(
|
||||
{"feedback_frame_id": "0x4FA"}
|
||||
),
|
||||
),
|
||||
(
|
||||
"empty decoded patch",
|
||||
lambda item: item["telemetry"]["feedback_state"].pop(
|
||||
"domain_state"
|
||||
),
|
||||
),
|
||||
):
|
||||
invalid = deepcopy(operation)
|
||||
mutate(invalid)
|
||||
with self.subTest(label=label):
|
||||
self.assertIsNone(
|
||||
authoritative_physical_ch1_feedback_state(invalid)
|
||||
)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -1,6 +1,7 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
import copy
|
||||
import unittest
|
||||
from pathlib import Path
|
||||
from unittest.mock import patch
|
||||
@@ -140,6 +141,62 @@ class NativeCanIntegrationTests(unittest.TestCase):
|
||||
).execute(self.runtime, plan, config)
|
||||
self.assertEqual(report["spec"]["status"], "failed")
|
||||
|
||||
def test_executor_ignores_stale_wrong_value_until_target_feedback_arrives(self) -> None:
|
||||
config, plan = self._compile(
|
||||
"climate",
|
||||
[
|
||||
{
|
||||
"domain": "climate",
|
||||
"capability": "climate.set_power",
|
||||
"target": "vehicle",
|
||||
"operation": "set",
|
||||
"parameters": {"state": "on"},
|
||||
}
|
||||
],
|
||||
)
|
||||
off = CanFrame(0x404, bytes((0, 2, 0, 0, 0, 0, 0, 0)), channel=1)
|
||||
on = CanFrame(0x404, bytes((0, 1, 0, 0, 0, 0, 0, 0)), channel=1)
|
||||
bus = _FakeBus([off, on])
|
||||
|
||||
with patch("cognitive_agent.application.native_can_runtime.time.sleep"):
|
||||
report = NativeCanExecutor(lambda: bus, feedback_timeout_ms=10).execute(
|
||||
self.runtime, plan, config
|
||||
)
|
||||
|
||||
telemetry = report["spec"]["results"][0]["telemetry"]
|
||||
self.assertEqual(report["spec"]["status"], "succeeded")
|
||||
self.assertTrue(telemetry["feedback_match"])
|
||||
self.assertEqual(telemetry["feedback_frame"]["data_hex"], "00 01 00 00 00 00 00 00")
|
||||
self.assertEqual(telemetry["channel"], 1)
|
||||
|
||||
def test_executor_refuses_to_send_without_value_match_contract(self) -> None:
|
||||
config, plan = self._compile(
|
||||
"climate",
|
||||
[
|
||||
{
|
||||
"domain": "climate",
|
||||
"capability": "climate.set_power",
|
||||
"target": "vehicle",
|
||||
"operation": "set",
|
||||
"parameters": {"state": "on"},
|
||||
}
|
||||
],
|
||||
)
|
||||
unsafe_plan = copy.deepcopy(plan)
|
||||
unsafe_plan["spec"]["commands"][0]["signals"]["expected_feedback"] = ""
|
||||
bus = _FakeBus()
|
||||
|
||||
report = NativeCanExecutor(lambda: bus, feedback_timeout_ms=1).execute(
|
||||
self.runtime, unsafe_plan, config
|
||||
)
|
||||
|
||||
self.assertEqual(report["spec"]["status"], "failed")
|
||||
self.assertFalse(bus.sent)
|
||||
self.assertIn(
|
||||
"target-value match contract",
|
||||
report["spec"]["results"][0]["message"],
|
||||
)
|
||||
|
||||
def test_one_sentence_builds_seat_and_vehicle_channel_plans(self) -> None:
|
||||
service = SeatDemoService(REPOSITORY_ROOT)
|
||||
result = service.process(
|
||||
@@ -154,6 +211,28 @@ class NativeCanIntegrationTests(unittest.TestCase):
|
||||
self.assertTrue(all(item["bus"] == "body_can" for item in body_commands))
|
||||
self.assertFalse(result["deferred_requests"])
|
||||
|
||||
def test_retired_vehicle_lock_intent_returns_explicit_unavailable_result(self) -> None:
|
||||
service = SeatDemoService(REPOSITORY_ROOT)
|
||||
for index, command in enumerate(("door vehicle lock", "door vehicle unlock")):
|
||||
with self.subTest(command=command):
|
||||
result = service.process(
|
||||
command,
|
||||
provider="local",
|
||||
conversation_id=f"conv-retired-door-lock-{index}",
|
||||
execution_mode="dry_run",
|
||||
)
|
||||
self.assertEqual(result["status"], "capability_unavailable")
|
||||
self.assertEqual(result["function_configs"], {})
|
||||
self.assertEqual(result["command_plans"], {})
|
||||
self.assertEqual(
|
||||
result["deferred_requests"][0]["rejection_code"],
|
||||
"CH1_DOOR_LOCK_CONTROL_UNSUPPORTED",
|
||||
)
|
||||
self.assertEqual(
|
||||
result["deferred_requests"][0]["execution_backend"],
|
||||
"unsupported",
|
||||
)
|
||||
|
||||
def test_vehicle_timing_contract_matches_requested_60mhz_values(self) -> None:
|
||||
settings = VehicleCanSettings.from_env(Path(REPOSITORY_ROOT))
|
||||
self.assertTrue(settings.timing_is_verified)
|
||||
|
||||
@@ -0,0 +1,230 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import unittest
|
||||
|
||||
from cognitive_agent.application.conversation_state import ConversationStateStore
|
||||
|
||||
|
||||
class NativeStateSyncTests(unittest.TestCase):
|
||||
def setUp(self) -> None:
|
||||
self.store = ConversationStateStore()
|
||||
|
||||
def apply(self, action: dict) -> dict:
|
||||
return self.store.apply_successful_actions(
|
||||
"conv-native-sync",
|
||||
"test",
|
||||
[{**action, "feedback_source": "physical_can_ch1"}],
|
||||
[],
|
||||
)
|
||||
|
||||
def test_driver_seat_native_feedback_never_updates_passenger(self) -> None:
|
||||
state = self.apply(
|
||||
{
|
||||
"domain": "seat",
|
||||
"capability": "seat.set_driver_heating_native",
|
||||
"target": "driver",
|
||||
"parameters": {"state": "low"},
|
||||
}
|
||||
)
|
||||
self.assertEqual(state["devices"]["driver"]["heating"]["state"], "on")
|
||||
self.assertEqual(state["devices"]["driver"]["heating"]["level"], 1)
|
||||
self.assertEqual(
|
||||
state["devices"]["driver"]["heating"]["feedback_source"],
|
||||
"physical_can_ch1",
|
||||
)
|
||||
self.assertEqual(state["devices"]["passenger"]["heating"]["state"], "off")
|
||||
|
||||
def test_window_feedback_updates_frontend_domain_state(self) -> None:
|
||||
state = self.apply(
|
||||
{
|
||||
"domain": "window",
|
||||
"capability": "window.set_position",
|
||||
"target": "front_left",
|
||||
"parameters": {"position_pct": 25},
|
||||
}
|
||||
)
|
||||
window = state["domain_devices"]["window"]["positions"]["front_left"]
|
||||
self.assertEqual(window["position_pct"], 25)
|
||||
self.assertFalse(window["closed"])
|
||||
self.assertEqual(window["feedback_source"], "physical_can_ch1")
|
||||
|
||||
def test_rear_lock_updates_only_rear_locks(self) -> None:
|
||||
state = self.apply(
|
||||
{
|
||||
"domain": "door",
|
||||
"capability": "door.set_rear_lock",
|
||||
"target": "rear",
|
||||
"parameters": {"state": "lock"},
|
||||
}
|
||||
)
|
||||
locks = state["domain_devices"]["door"]["locks"]
|
||||
self.assertEqual(locks["rear_left"]["state"], "lock")
|
||||
self.assertEqual(locks["rear_right"]["state"], "lock")
|
||||
self.assertEqual(locks["front_left"]["state"], "unknown")
|
||||
|
||||
def test_vehicle_lock_projection_updates_logical_and_all_door_states(self) -> None:
|
||||
state = self.apply(
|
||||
{
|
||||
"domain": "door",
|
||||
"capability": "door.set_vehicle_lock",
|
||||
"target": "vehicle",
|
||||
"parameters": {"state": "lock"},
|
||||
}
|
||||
)
|
||||
door = state["domain_devices"]["door"]
|
||||
self.assertEqual(door["vehicle_lock"]["state"], "lock")
|
||||
self.assertTrue(
|
||||
all(item["state"] == "lock" for item in door["locks"].values())
|
||||
)
|
||||
|
||||
def test_authoritative_ch1_feedback_overrides_requested_ui_projection(self) -> None:
|
||||
state = self.store.synchronize_physical_ch1_feedback(
|
||||
"conv-native-sync",
|
||||
[
|
||||
{
|
||||
"source": "physical_can_ch1",
|
||||
"channel": 1,
|
||||
"frame_id": "0x404",
|
||||
"feedback_frame_id": "0x404",
|
||||
"valid": True,
|
||||
"verification": {
|
||||
"authoritative": True,
|
||||
"channel": 1,
|
||||
"simulated": False,
|
||||
"feedback_required": True,
|
||||
"feedback_observed": True,
|
||||
"feedback_match": True,
|
||||
"frame_id": "0x404",
|
||||
"payload_size": 8,
|
||||
},
|
||||
"actuator_state": {
|
||||
"driver": {
|
||||
"ventilation": {"state": "on", "level": 3},
|
||||
"massage": {"mode": "relax", "strength": "weak"},
|
||||
}
|
||||
},
|
||||
"domain_state": {
|
||||
"climate": {
|
||||
"power": "on",
|
||||
"ac": "on",
|
||||
"control_mode": "manual",
|
||||
"cycle_mode": "internal",
|
||||
"fan_level": 3,
|
||||
"zones": {
|
||||
"driver": {
|
||||
"temperature_c": 24,
|
||||
"outlet_mode": "defrost",
|
||||
}
|
||||
},
|
||||
},
|
||||
"roof": {
|
||||
"shade_position_pct": 2,
|
||||
"motion_state": "stopped",
|
||||
},
|
||||
"ambient_light": {
|
||||
"power": "on",
|
||||
"front_brightness_level": 3,
|
||||
"rear_brightness_level": 3,
|
||||
"front_color_index": 32,
|
||||
"rear_color_index": 32,
|
||||
},
|
||||
"door": {
|
||||
"locks": {
|
||||
"rear_left": {"state": "lock"},
|
||||
"rear_right": {"state": "lock"},
|
||||
}
|
||||
},
|
||||
"window": {
|
||||
"positions": {
|
||||
"front_left": {
|
||||
"position_pct": 9,
|
||||
"closed": False,
|
||||
}
|
||||
}
|
||||
},
|
||||
},
|
||||
}
|
||||
],
|
||||
)
|
||||
|
||||
self.assertEqual(state["devices"]["driver"]["ventilation"]["level"], 3)
|
||||
self.assertEqual(
|
||||
state["devices"]["driver"]["ventilation"]["feedback_source"],
|
||||
"physical_can_ch1",
|
||||
)
|
||||
climate = state["domain_devices"]["climate"]
|
||||
self.assertEqual(climate["zones"]["driver"]["outlet_mode"], "defrost")
|
||||
self.assertEqual(climate["cycle_mode"], "internal")
|
||||
self.assertEqual(climate["feedback_source"], "physical_can_ch1")
|
||||
self.assertEqual(state["domain_devices"]["roof"]["shade_position_pct"], 2)
|
||||
self.assertEqual(state["domain_devices"]["ambient_light"]["brightness_pct"], 100)
|
||||
self.assertEqual(
|
||||
state["domain_devices"]["door"]["locks"]["rear_left"]["feedback_source"],
|
||||
"physical_can_ch1",
|
||||
)
|
||||
self.assertEqual(
|
||||
state["domain_devices"]["window"]["positions"]["front_left"]["position_pct"],
|
||||
9,
|
||||
)
|
||||
self.assertNotIn(
|
||||
"feedback_source",
|
||||
state["domain_devices"]["window"]["positions"]["rear_left"],
|
||||
)
|
||||
self.assertNotIn(
|
||||
"feedback_source",
|
||||
state["domain_devices"]["door"]["locks"]["front_left"],
|
||||
)
|
||||
|
||||
def test_non_authoritative_feedback_is_ignored(self) -> None:
|
||||
before = self.store.snapshot("conv-native-sync")
|
||||
after = self.store.synchronize_physical_ch1_feedback(
|
||||
"conv-native-sync",
|
||||
[
|
||||
{
|
||||
"source": "physical_can_ch0",
|
||||
"channel": 0,
|
||||
"valid": True,
|
||||
"domain_state": {"climate": {"power": "on"}},
|
||||
}
|
||||
],
|
||||
)
|
||||
self.assertEqual(after, before)
|
||||
|
||||
def test_unverified_or_empty_feedback_never_updates_state(self) -> None:
|
||||
before = self.store.snapshot("conv-native-sync")
|
||||
for feedback in (
|
||||
{
|
||||
"source": "physical_can_ch1",
|
||||
"channel": 1,
|
||||
"frame_id": "0x404",
|
||||
"feedback_frame_id": "0x404",
|
||||
"valid": True,
|
||||
"domain_state": {"climate": {"power": "on"}},
|
||||
},
|
||||
{
|
||||
"source": "physical_can_ch1",
|
||||
"channel": 1,
|
||||
"frame_id": "0x404",
|
||||
"feedback_frame_id": "0x404",
|
||||
"valid": True,
|
||||
"verification": {
|
||||
"authoritative": True,
|
||||
"channel": 1,
|
||||
"simulated": False,
|
||||
"feedback_required": True,
|
||||
"feedback_observed": True,
|
||||
"feedback_match": True,
|
||||
"frame_id": "0x404",
|
||||
"payload_size": 8,
|
||||
},
|
||||
},
|
||||
):
|
||||
with self.subTest(feedback=feedback):
|
||||
after = self.store.synchronize_physical_ch1_feedback(
|
||||
"conv-native-sync", [feedback]
|
||||
)
|
||||
self.assertEqual(after, before)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -1,20 +1,36 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from collections.abc import Callable
|
||||
from copy import deepcopy
|
||||
import json
|
||||
import os
|
||||
import socket
|
||||
import threading
|
||||
import time
|
||||
import unittest
|
||||
from unittest.mock import Mock, patch
|
||||
from unittest.mock import MagicMock, Mock, patch
|
||||
|
||||
from cognitive_agent.application.self_extension import (
|
||||
ExtensionIntegrationError,
|
||||
GatewayError,
|
||||
SelfExtensionService,
|
||||
SimulationGateway,
|
||||
TcpGateway,
|
||||
build_gateway_command,
|
||||
validate_slot,
|
||||
)
|
||||
|
||||
|
||||
class SelfExtensionTests(unittest.TestCase):
|
||||
@staticmethod
|
||||
def _wait_until(predicate: Callable[[], bool], timeout: float = 3.0) -> bool:
|
||||
deadline = time.monotonic() + timeout
|
||||
while time.monotonic() < deadline:
|
||||
if predicate():
|
||||
return True
|
||||
time.sleep(0.02)
|
||||
return predicate()
|
||||
|
||||
def test_gateway_command_contracts_and_slot_validation(self) -> None:
|
||||
command = build_gateway_command("learn", {"slot": 17})
|
||||
self.assertEqual(command["type"], "infrared")
|
||||
@@ -59,7 +75,7 @@ class SelfExtensionTests(unittest.TestCase):
|
||||
{
|
||||
"kind": "vehicle",
|
||||
"capability_id": "",
|
||||
"command": "打开全部阅读灯",
|
||||
"command": "light reading all on",
|
||||
"label": "车内灯光 · 阅读灯",
|
||||
},
|
||||
],
|
||||
@@ -82,21 +98,149 @@ class SelfExtensionTests(unittest.TestCase):
|
||||
"risk": "low",
|
||||
}
|
||||
],
|
||||
"vehicle_devices": [],
|
||||
"vehicle_devices": [
|
||||
{
|
||||
"name": "车内灯光",
|
||||
"actions": [
|
||||
{
|
||||
"name": "阅读灯",
|
||||
"command": "light reading all on",
|
||||
}
|
||||
],
|
||||
}
|
||||
],
|
||||
}
|
||||
)
|
||||
|
||||
self.assertEqual(len(result["steps"]), 2)
|
||||
self.assertEqual(result["steps"][0]["capability_id"], "cap-light-on")
|
||||
self.assertEqual(result["steps"][1]["command"], "打开全部阅读灯")
|
||||
self.assertEqual(result["steps"][1]["command"], "light reading all on")
|
||||
|
||||
def test_plan_rejects_vehicle_command_outside_current_catalog(self) -> None:
|
||||
service = SelfExtensionService(SimulationGateway())
|
||||
proposed = {
|
||||
"summary": "unsafe target",
|
||||
"needs_confirmation": False,
|
||||
"question": "",
|
||||
"steps": [
|
||||
{
|
||||
"kind": "vehicle",
|
||||
"capability_id": "",
|
||||
"command": "seat passenger vent high",
|
||||
"label": "wrong seat",
|
||||
"delay_ms": 0,
|
||||
}
|
||||
],
|
||||
}
|
||||
with patch.dict(os.environ, {"OPENAI_API_KEY": "test-key"}, clear=False):
|
||||
with patch.object(
|
||||
service,
|
||||
"_request_gpt_plan",
|
||||
return_value=json.dumps(proposed),
|
||||
):
|
||||
with self.assertRaisesRegex(ExtensionIntegrationError, "CH1"):
|
||||
service.plan(
|
||||
{
|
||||
"utterance": "打开座椅通风",
|
||||
"peripherals": [],
|
||||
"vehicle_devices": [
|
||||
{
|
||||
"name": "主驾座椅",
|
||||
"actions": [
|
||||
{
|
||||
"name": "通风高档",
|
||||
"command": "seat driver vent high",
|
||||
}
|
||||
],
|
||||
}
|
||||
],
|
||||
}
|
||||
)
|
||||
|
||||
def test_gpt_plan_honors_streaming_responses_configuration(self) -> None:
|
||||
proposed = json.dumps(
|
||||
{
|
||||
"summary": "关闭空调",
|
||||
"needs_confirmation": False,
|
||||
"question": "",
|
||||
"steps": [
|
||||
{
|
||||
"kind": "vehicle",
|
||||
"capability_id": "",
|
||||
"command": "ac off",
|
||||
"label": "关闭空调",
|
||||
}
|
||||
],
|
||||
},
|
||||
ensure_ascii=False,
|
||||
)
|
||||
stream_body = "\n".join(
|
||||
(
|
||||
"event: response.output_text.delta",
|
||||
"data: "
|
||||
+ json.dumps(
|
||||
{
|
||||
"type": "response.output_text.delta",
|
||||
"delta": proposed[:20],
|
||||
},
|
||||
ensure_ascii=False,
|
||||
),
|
||||
"event: response.output_text.done",
|
||||
"data: "
|
||||
+ json.dumps(
|
||||
{"type": "response.output_text.done", "text": proposed},
|
||||
ensure_ascii=False,
|
||||
),
|
||||
"data: [DONE]",
|
||||
)
|
||||
)
|
||||
response = MagicMock()
|
||||
response.__enter__.return_value = response
|
||||
response.read.return_value = stream_body.encode("utf-8")
|
||||
|
||||
with patch.dict(os.environ, {"OPENAI_STREAM": "true"}, clear=False):
|
||||
service = SelfExtensionService(SimulationGateway())
|
||||
with patch(
|
||||
"cognitive_agent.application.self_extension.urlopen",
|
||||
return_value=response,
|
||||
) as open_request:
|
||||
output = service._request_gpt_plan(
|
||||
"关闭空调",
|
||||
[],
|
||||
[
|
||||
{
|
||||
"name": "空调",
|
||||
"actions": [{"name": "关闭空调", "command": "ac off"}],
|
||||
}
|
||||
],
|
||||
"test-key",
|
||||
)
|
||||
|
||||
self.assertEqual(json.loads(output)["steps"][0]["command"], "ac off")
|
||||
request_payload = json.loads(
|
||||
open_request.call_args.args[0].data.decode("utf-8")
|
||||
)
|
||||
self.assertIs(request_payload["stream"], True)
|
||||
self.assertEqual(request_payload["model"], "gpt-5.6-sol")
|
||||
command_schema = request_payload["text"]["format"]["schema"]["properties"][
|
||||
"steps"
|
||||
]["items"]["properties"]["command"]
|
||||
self.assertEqual(command_schema["enum"], ["", "ac off"])
|
||||
|
||||
def test_vehicle_execution_reuses_the_same_orchestration_service(self) -> None:
|
||||
service = SelfExtensionService(SimulationGateway())
|
||||
with patch.dict(
|
||||
os.environ,
|
||||
{"COGNITIVE_AGENT_EXECUTION_MODE": "dry_run"},
|
||||
clear=False,
|
||||
):
|
||||
service = SelfExtensionService(SimulationGateway())
|
||||
vehicle_service = Mock()
|
||||
vehicle_service.process.return_value = {
|
||||
"status": "succeeded",
|
||||
"provider_used": "local_rules",
|
||||
"request_id": "req-extension-1",
|
||||
"conversation_id": "conv-extension-shared",
|
||||
"conversation_turn": 3,
|
||||
"decision_summary": ["打开阅读灯"],
|
||||
"validation": [],
|
||||
"execution_report": {"spec": {"results": [{"status": "succeeded"}]}},
|
||||
@@ -104,17 +248,542 @@ class SelfExtensionTests(unittest.TestCase):
|
||||
}
|
||||
|
||||
result = service.execute_vehicle(
|
||||
{"command": "打开全部阅读灯", "vehicle_id": "denza_n7"},
|
||||
{
|
||||
"command": "打开全部阅读灯",
|
||||
"conversation_id": "conv-extension-shared",
|
||||
"vehicle_id": "denza_n7",
|
||||
},
|
||||
vehicle_service,
|
||||
hardware_access_bypass=True,
|
||||
)
|
||||
|
||||
self.assertTrue(result["success"])
|
||||
self.assertEqual(result["request_id"], "req-extension-1")
|
||||
self.assertEqual(result["conversation_id"], "conv-extension-shared")
|
||||
self.assertEqual(result["conversation_turn"], 3)
|
||||
self.assertEqual(result["status"], "succeeded")
|
||||
self.assertEqual(
|
||||
result["execution_report"], vehicle_service.process.return_value["execution_report"]
|
||||
)
|
||||
self.assertEqual(result["operation_results"], [{"status": "succeeded"}])
|
||||
self.assertFalse(result["physical_feedback_required"])
|
||||
self.assertFalse(result["physical_feedback_confirmed"])
|
||||
vehicle_service.process.assert_called_once()
|
||||
self.assertEqual(vehicle_service.process.call_args.args[0], "打开全部阅读灯")
|
||||
self.assertEqual(
|
||||
vehicle_service.process.call_args.args[2], "conv-extension-shared"
|
||||
)
|
||||
self.assertTrue(vehicle_service.process.call_args.kwargs["hardware_access_bypass"])
|
||||
|
||||
def test_vehicle_execution_only_marks_exact_complete_status_as_success(self) -> None:
|
||||
service = SelfExtensionService(SimulationGateway())
|
||||
for status in (
|
||||
"validation_failed",
|
||||
"needs_confirmation",
|
||||
"capability_unavailable",
|
||||
"partially_succeeded",
|
||||
):
|
||||
with self.subTest(status=status):
|
||||
vehicle_service = Mock()
|
||||
vehicle_service.process.return_value = {
|
||||
"status": status,
|
||||
"provider_used": "local_rules",
|
||||
"request_id": f"req-{status}",
|
||||
"decision_summary": [],
|
||||
"validation": [],
|
||||
"execution_report": {
|
||||
"spec": {"results": [{"status": "succeeded"}]}
|
||||
},
|
||||
"deferred_requests": [],
|
||||
}
|
||||
|
||||
result = service.execute_vehicle(
|
||||
{"command": "打开全部阅读灯"},
|
||||
vehicle_service,
|
||||
hardware_access_bypass=True,
|
||||
)
|
||||
|
||||
self.assertFalse(result["success"])
|
||||
self.assertEqual(result["status"], status)
|
||||
|
||||
def test_vehicle_execution_rejects_failed_or_missing_operation_results(self) -> None:
|
||||
service = SelfExtensionService(SimulationGateway())
|
||||
for operation_results in (
|
||||
[],
|
||||
[{"status": "succeeded"}, {"status": "failed", "message": "timeout"}],
|
||||
):
|
||||
with self.subTest(operation_results=operation_results):
|
||||
vehicle_service = Mock()
|
||||
vehicle_service.process.return_value = {
|
||||
"status": "succeeded",
|
||||
"execution_report": {"spec": {"results": operation_results}},
|
||||
"domain_state": {"roof": {"shade_position_pct": 31}},
|
||||
"actuator_state": {"driver": {"heating": {"state": "off"}}},
|
||||
}
|
||||
|
||||
result = service.execute_vehicle(
|
||||
{"command": "打开遮阳帘"},
|
||||
vehicle_service,
|
||||
hardware_access_bypass=True,
|
||||
)
|
||||
|
||||
self.assertFalse(result["success"])
|
||||
self.assertEqual(
|
||||
result["domain_state"], {"roof": {"shade_position_pct": 31}}
|
||||
)
|
||||
self.assertEqual(
|
||||
result["actuator_state"],
|
||||
{"driver": {"heating": {"state": "off"}}},
|
||||
)
|
||||
|
||||
def test_hardware_vehicle_execution_requires_authoritative_feedback_per_item(
|
||||
self,
|
||||
) -> None:
|
||||
with patch.dict(
|
||||
os.environ,
|
||||
{"COGNITIVE_AGENT_EXECUTION_MODE": "hardware"},
|
||||
clear=False,
|
||||
):
|
||||
service = SelfExtensionService(SimulationGateway())
|
||||
vehicle_service = Mock()
|
||||
vehicle_service.process.return_value = {
|
||||
"status": "succeeded",
|
||||
"provider_used": "local_rules",
|
||||
"request_id": "req-extension-hardware",
|
||||
"execution_report": {
|
||||
"spec": {
|
||||
"status": "succeeded",
|
||||
"physical_state": {
|
||||
"source": "physical_can_ch1",
|
||||
"complete": True,
|
||||
},
|
||||
"results": [
|
||||
{
|
||||
"ref": "operation-1",
|
||||
"domain": "climate",
|
||||
"status": "succeeded",
|
||||
"telemetry": {
|
||||
"channel": 1,
|
||||
"simulated": False,
|
||||
"feedback_required": True,
|
||||
"feedback_observed": True,
|
||||
"feedback_match": True,
|
||||
"feedback_frame": {
|
||||
"frame_id": "0x404",
|
||||
"channel": 1,
|
||||
"payload_size": 8,
|
||||
"data_hex": "00 02 00 00 18 18 00 00",
|
||||
},
|
||||
"feedback_state": {
|
||||
"source": "physical_can_ch1",
|
||||
"channel": 1,
|
||||
"frame_id": "0x404",
|
||||
"feedback_frame_id": "0x404",
|
||||
"valid": True,
|
||||
"domain_state": {"climate": {"power": "on"}},
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
"ref": "operation-2",
|
||||
"domain": "seat",
|
||||
"status": "succeeded",
|
||||
"telemetry": {
|
||||
"channel": 1,
|
||||
"simulated": False,
|
||||
"feedback_required": True,
|
||||
"feedback_observed": True,
|
||||
"feedback_match": True,
|
||||
"feedback_frame": {
|
||||
"frame_id": "0x4FA",
|
||||
"channel": 1,
|
||||
"payload_size": 8,
|
||||
"data_hex": "02 00 00 00 00 00 00 00",
|
||||
},
|
||||
"feedback_state": {
|
||||
"source": "physical_can_ch1",
|
||||
"channel": 1,
|
||||
"frame_id": "0x4FA",
|
||||
"feedback_frame_id": "0x4FA",
|
||||
"valid": True,
|
||||
"actuator_state": {
|
||||
"driver": {
|
||||
"ventilation": {
|
||||
"state": "on",
|
||||
"level": 1,
|
||||
}
|
||||
}
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
],
|
||||
}
|
||||
},
|
||||
"domain_state": {"climate": {"power": "on"}},
|
||||
"actuator_state": {"driver": {"ventilation": {"state": "on"}}},
|
||||
}
|
||||
|
||||
confirmed = service.execute_vehicle(
|
||||
{"command": "打开空调"},
|
||||
vehicle_service,
|
||||
hardware_access_bypass=True,
|
||||
)
|
||||
|
||||
self.assertTrue(confirmed["success"])
|
||||
self.assertTrue(confirmed["physical_feedback_required"])
|
||||
self.assertTrue(confirmed["physical_feedback_confirmed"])
|
||||
self.assertEqual(confirmed["physical_state"]["source"], "physical_can_ch1")
|
||||
self.assertEqual(len(confirmed["physical_feedback"]), 2)
|
||||
self.assertTrue(all(item["confirmed"] for item in confirmed["physical_feedback"]))
|
||||
self.assertTrue(all(item["required"] for item in confirmed["physical_feedback"]))
|
||||
self.assertTrue(all(item["observed"] for item in confirmed["physical_feedback"]))
|
||||
self.assertTrue(all(item["match"] is True for item in confirmed["physical_feedback"]))
|
||||
self.assertTrue(all(item["channel"] == 1 for item in confirmed["physical_feedback"]))
|
||||
self.assertEqual(
|
||||
confirmed["physical_feedback"][0]["state"]["source"],
|
||||
"physical_can_ch1",
|
||||
)
|
||||
|
||||
operation = vehicle_service.process.return_value["execution_report"]["spec"][
|
||||
"results"
|
||||
][1]
|
||||
valid_telemetry = deepcopy(operation["telemetry"])
|
||||
operation["telemetry"] = {**valid_telemetry, "feedback_match": None}
|
||||
unconfirmed = service.execute_vehicle(
|
||||
{"command": "打开空调"},
|
||||
vehicle_service,
|
||||
hardware_access_bypass=True,
|
||||
)
|
||||
|
||||
self.assertFalse(unconfirmed["success"])
|
||||
self.assertFalse(unconfirmed["physical_feedback_confirmed"])
|
||||
self.assertIn("未逐项获得权威物理反馈", unconfirmed["message"])
|
||||
|
||||
invalid_cases: list[tuple[str, dict]] = []
|
||||
for field, value in (
|
||||
("channel", 0),
|
||||
("simulated", True),
|
||||
("feedback_required", False),
|
||||
("feedback_observed", False),
|
||||
("feedback_match", False),
|
||||
("feedback_frame", None),
|
||||
("feedback_state", None),
|
||||
):
|
||||
invalid_cases.append((field, {**valid_telemetry, field: value}))
|
||||
|
||||
wrong_frame_channel = deepcopy(valid_telemetry)
|
||||
wrong_frame_channel["feedback_frame"]["channel"] = 0
|
||||
invalid_cases.append(("feedback_frame.channel", wrong_frame_channel))
|
||||
missing_frame_payload = deepcopy(valid_telemetry)
|
||||
missing_frame_payload["feedback_frame"].pop("data_hex")
|
||||
invalid_cases.append(("feedback_frame.data_hex", missing_frame_payload))
|
||||
incomplete_frame_payload = deepcopy(valid_telemetry)
|
||||
incomplete_frame_payload["feedback_frame"]["payload_size"] = 7
|
||||
invalid_cases.append(
|
||||
("feedback_frame.payload_size", incomplete_frame_payload)
|
||||
)
|
||||
wrong_source = deepcopy(valid_telemetry)
|
||||
wrong_source["feedback_state"]["source"] = "physical_can_ch0"
|
||||
invalid_cases.append(("feedback_state.source", wrong_source))
|
||||
missing_decoded_state = deepcopy(valid_telemetry)
|
||||
missing_decoded_state["feedback_state"].pop("actuator_state")
|
||||
invalid_cases.append(("feedback_state.actuator_state", missing_decoded_state))
|
||||
mismatched_frame = deepcopy(valid_telemetry)
|
||||
mismatched_frame["feedback_state"]["frame_id"] = "0x404"
|
||||
invalid_cases.append(("feedback_state.frame_id", mismatched_frame))
|
||||
invalid_cases.append(
|
||||
(
|
||||
"legacy_ch0",
|
||||
{
|
||||
"channel": 0,
|
||||
"simulated": False,
|
||||
"hardware_feedback": True,
|
||||
"feedback_frame": {"frame_id": "0x302"},
|
||||
},
|
||||
)
|
||||
)
|
||||
|
||||
for label, insufficient_feedback in invalid_cases:
|
||||
with self.subTest(insufficient_feedback=label):
|
||||
operation["telemetry"] = insufficient_feedback
|
||||
rejected = service.execute_vehicle(
|
||||
{"command": "打开空调"},
|
||||
vehicle_service,
|
||||
hardware_access_bypass=True,
|
||||
)
|
||||
self.assertFalse(rejected["success"])
|
||||
self.assertFalse(rejected["physical_feedback_confirmed"])
|
||||
|
||||
def test_vehicle_execution_ignores_browser_routing_overrides(self) -> None:
|
||||
with patch.dict(
|
||||
os.environ,
|
||||
{
|
||||
"COGNITIVE_AGENT_EXECUTION_MODE": "dry_run",
|
||||
"COGNITIVE_AGENT_PROVIDER": "local_rules",
|
||||
"COGNITIVE_AGENT_VEHICLE_ID": "denza_n7",
|
||||
},
|
||||
clear=False,
|
||||
):
|
||||
service = SelfExtensionService(SimulationGateway())
|
||||
vehicle_service = Mock()
|
||||
vehicle_service.process.return_value = {
|
||||
"status": "succeeded",
|
||||
"execution_report": {"spec": {"results": [{"status": "succeeded"}]}},
|
||||
}
|
||||
|
||||
service.execute_vehicle(
|
||||
{
|
||||
"command": "seat driver vent low",
|
||||
"execution_mode": "hardware",
|
||||
"provider": "browser-selected-provider",
|
||||
"vehicle_id": "t2g_primary_seat",
|
||||
"simulation_fault": "adapter_disconnect",
|
||||
"channel": "0",
|
||||
},
|
||||
vehicle_service,
|
||||
hardware_access_bypass=True,
|
||||
)
|
||||
|
||||
args = vehicle_service.process.call_args.args
|
||||
kwargs = vehicle_service.process.call_args.kwargs
|
||||
self.assertEqual(args[1], "local_rules")
|
||||
self.assertEqual(args[3], "dry_run")
|
||||
self.assertEqual(kwargs["vehicle_id"], "denza_n7")
|
||||
self.assertEqual(kwargs["simulation_fault"], "normal")
|
||||
|
||||
def test_tcp_gateway_recovers_from_unavailable_address_and_tracks_peers(self) -> None:
|
||||
probe = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
|
||||
probe.bind(("127.0.0.1", 0))
|
||||
port = int(probe.getsockname()[1])
|
||||
probe.close()
|
||||
|
||||
class UnavailableAddressSocket:
|
||||
def setsockopt(self, *args: object) -> None:
|
||||
del args
|
||||
|
||||
def bind(self, address: object) -> None:
|
||||
del address
|
||||
raise OSError(10049, "address is not available")
|
||||
|
||||
def close(self) -> None:
|
||||
return
|
||||
|
||||
socket_attempts = 0
|
||||
|
||||
def new_server_socket() -> object:
|
||||
nonlocal socket_attempts
|
||||
socket_attempts += 1
|
||||
if socket_attempts == 1:
|
||||
return UnavailableAddressSocket()
|
||||
return socket.socket(socket.AF_INET, socket.SOCK_STREAM)
|
||||
|
||||
clients: list[socket.socket] = []
|
||||
with patch.dict(
|
||||
os.environ,
|
||||
{
|
||||
"GSCU_TCP_LISTEN_HOST": "127.0.0.1",
|
||||
"GSCU_TCP_PORT": str(port),
|
||||
"GSCU_TCP_BIND_RETRY_SECONDS": "0.05",
|
||||
},
|
||||
):
|
||||
gateway = TcpGateway()
|
||||
with patch.object(gateway, "_new_server_socket", side_effect=new_server_socket):
|
||||
gateway.start()
|
||||
try:
|
||||
self.assertTrue(
|
||||
self._wait_until(lambda: gateway.status()["listening"]),
|
||||
gateway.status(),
|
||||
)
|
||||
self.assertGreaterEqual(socket_attempts, 2)
|
||||
waiting = gateway.status()
|
||||
self.assertFalse(waiting["connected"])
|
||||
self.assertIn("等待设备接入", waiting["message"])
|
||||
waiting_health = SelfExtensionService(gateway).health()
|
||||
self.assertTrue(waiting_health["listening"])
|
||||
self.assertFalse(waiting_health["peer_connected"])
|
||||
|
||||
first = socket.create_connection(("127.0.0.1", port), timeout=1)
|
||||
clients.append(first)
|
||||
self.assertTrue(
|
||||
self._wait_until(lambda: gateway.status()["connected"]),
|
||||
gateway.status(),
|
||||
)
|
||||
first_peer = gateway.status()["peer"]
|
||||
self.assertTrue(str(first_peer).startswith("127.0.0.1:"))
|
||||
connected_health = SelfExtensionService(gateway).health()
|
||||
self.assertTrue(connected_health["connected"])
|
||||
self.assertEqual(connected_health["peer"], first_peer)
|
||||
|
||||
# Replacing an old peer must not let its reader mark the new
|
||||
# GSCU1B session offline when the old socket closes.
|
||||
second = socket.create_connection(("127.0.0.1", port), timeout=1)
|
||||
clients.append(second)
|
||||
self.assertTrue(
|
||||
self._wait_until(
|
||||
lambda: gateway.status()["peer"] not in {None, first_peer}
|
||||
),
|
||||
gateway.status(),
|
||||
)
|
||||
time.sleep(0.1)
|
||||
self.assertTrue(gateway.status()["connected"])
|
||||
|
||||
second.close()
|
||||
clients.remove(second)
|
||||
self.assertTrue(
|
||||
self._wait_until(lambda: not gateway.status()["connected"]),
|
||||
gateway.status(),
|
||||
)
|
||||
self.assertTrue(gateway.status()["listening"])
|
||||
|
||||
third = socket.create_connection(("127.0.0.1", port), timeout=1)
|
||||
clients.append(third)
|
||||
self.assertTrue(
|
||||
self._wait_until(lambda: gateway.status()["connected"]),
|
||||
gateway.status(),
|
||||
)
|
||||
finally:
|
||||
for client in clients:
|
||||
client.close()
|
||||
gateway.close()
|
||||
|
||||
stopped = gateway.status()
|
||||
self.assertFalse(stopped["listening"])
|
||||
self.assertFalse(stopped["connected"])
|
||||
|
||||
def test_tcp_gateway_recovers_after_port_conflict_is_released(self) -> None:
|
||||
blocker = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
|
||||
blocker.bind(("127.0.0.1", 0))
|
||||
blocker.listen(1)
|
||||
port = int(blocker.getsockname()[1])
|
||||
with patch.dict(
|
||||
os.environ,
|
||||
{
|
||||
"GSCU_TCP_LISTEN_HOST": "127.0.0.1",
|
||||
"GSCU_TCP_PORT": str(port),
|
||||
"GSCU_TCP_BIND_RETRY_SECONDS": "0.05",
|
||||
},
|
||||
):
|
||||
gateway = TcpGateway()
|
||||
gateway.start()
|
||||
try:
|
||||
self.assertTrue(
|
||||
self._wait_until(lambda: "监听失败" in gateway.status().get("message", "")),
|
||||
gateway.status(),
|
||||
)
|
||||
self.assertFalse(gateway.status()["listening"])
|
||||
blocker.close()
|
||||
self.assertTrue(
|
||||
self._wait_until(lambda: gateway.status()["listening"]),
|
||||
gateway.status(),
|
||||
)
|
||||
self.assertNotIn("失败", gateway.status()["message"])
|
||||
finally:
|
||||
blocker.close()
|
||||
gateway.close()
|
||||
|
||||
def test_tcp_gateway_can_restart_immediately_after_close(self) -> None:
|
||||
probe = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
|
||||
probe.bind(("127.0.0.1", 0))
|
||||
port = int(probe.getsockname()[1])
|
||||
probe.close()
|
||||
with patch.dict(
|
||||
os.environ,
|
||||
{
|
||||
"GSCU_TCP_LISTEN_HOST": "127.0.0.1",
|
||||
"GSCU_TCP_PORT": str(port),
|
||||
"GSCU_TCP_BIND_RETRY_SECONDS": "0.05",
|
||||
},
|
||||
):
|
||||
gateway = TcpGateway()
|
||||
|
||||
try:
|
||||
gateway.start()
|
||||
self.assertTrue(self._wait_until(lambda: gateway.status()["listening"]))
|
||||
gateway.close()
|
||||
gateway.start()
|
||||
self.assertTrue(
|
||||
self._wait_until(lambda: gateway.status()["listening"]),
|
||||
gateway.status(),
|
||||
)
|
||||
finally:
|
||||
gateway.close()
|
||||
|
||||
def test_tcp_gateway_rejects_peer_accepted_during_close(self) -> None:
|
||||
settimeout_entered = threading.Event()
|
||||
allow_settimeout_to_finish = threading.Event()
|
||||
accepted_socket_closed = threading.Event()
|
||||
unexpected_reader_started = threading.Event()
|
||||
allow_unexpected_reader_to_finish = threading.Event()
|
||||
|
||||
class AcceptedSocket:
|
||||
def settimeout(self, timeout: float) -> None:
|
||||
del timeout
|
||||
settimeout_entered.set()
|
||||
allow_settimeout_to_finish.wait(timeout=2)
|
||||
|
||||
def setsockopt(self, *args: object) -> None:
|
||||
del args
|
||||
|
||||
def recv(self, size: int) -> bytes:
|
||||
del size
|
||||
unexpected_reader_started.set()
|
||||
allow_unexpected_reader_to_finish.wait(timeout=2)
|
||||
return b""
|
||||
|
||||
def close(self) -> None:
|
||||
accepted_socket_closed.set()
|
||||
|
||||
accepted_socket = AcceptedSocket()
|
||||
|
||||
class ServerSocket:
|
||||
def setsockopt(self, *args: object) -> None:
|
||||
del args
|
||||
|
||||
def bind(self, address: object) -> None:
|
||||
del address
|
||||
|
||||
def listen(self, backlog: int) -> None:
|
||||
del backlog
|
||||
|
||||
def settimeout(self, timeout: float) -> None:
|
||||
del timeout
|
||||
|
||||
def accept(self) -> tuple[AcceptedSocket, tuple[str, int]]:
|
||||
return accepted_socket, ("127.0.0.1", 4444)
|
||||
|
||||
def close(self) -> None:
|
||||
return
|
||||
|
||||
with patch.dict(
|
||||
os.environ,
|
||||
{
|
||||
"GSCU_TCP_LISTEN_HOST": "127.0.0.1",
|
||||
"GSCU_TCP_PORT": "4444",
|
||||
"GSCU_TCP_BIND_RETRY_SECONDS": "0.05",
|
||||
},
|
||||
):
|
||||
gateway = TcpGateway()
|
||||
with patch.object(gateway, "_new_server_socket", return_value=ServerSocket()):
|
||||
gateway.start()
|
||||
self.assertTrue(settimeout_entered.wait(timeout=1))
|
||||
close_thread = threading.Thread(target=gateway.close)
|
||||
close_thread.start()
|
||||
try:
|
||||
self.assertTrue(self._wait_until(gateway._stop.is_set))
|
||||
finally:
|
||||
allow_settimeout_to_finish.set()
|
||||
close_thread.join(timeout=3)
|
||||
|
||||
try:
|
||||
self.assertFalse(close_thread.is_alive())
|
||||
self.assertTrue(accepted_socket_closed.is_set())
|
||||
self.assertFalse(unexpected_reader_started.is_set())
|
||||
self.assertFalse(gateway.status()["connected"])
|
||||
self.assertIsNone(gateway.status()["peer"])
|
||||
finally:
|
||||
allow_unexpected_reader_to_finish.set()
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
|
||||
@@ -0,0 +1,286 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import os
|
||||
import unittest
|
||||
from unittest.mock import MagicMock, patch
|
||||
|
||||
from cognitive_agent.application.self_extension import (
|
||||
ExtensionIntegrationError,
|
||||
MAX_PLAN_DELAY_MS,
|
||||
MAX_TOTAL_PLAN_DELAY_MS,
|
||||
SelfExtensionService,
|
||||
SimulationGateway,
|
||||
)
|
||||
|
||||
|
||||
class SelfExtensionDelayPlanTests(unittest.TestCase):
|
||||
def setUp(self) -> None:
|
||||
self.service = SelfExtensionService(SimulationGateway())
|
||||
|
||||
@staticmethod
|
||||
def _vehicle(command: str) -> dict[str, object]:
|
||||
return {
|
||||
"kind": "vehicle",
|
||||
"capability_id": "",
|
||||
"command": command,
|
||||
"label": command,
|
||||
"delay_ms": 0,
|
||||
}
|
||||
|
||||
@staticmethod
|
||||
def _delay(value: object) -> dict[str, object]:
|
||||
return {
|
||||
"kind": "delay",
|
||||
"capability_id": "",
|
||||
"command": "",
|
||||
"label": "wait",
|
||||
"delay_ms": value,
|
||||
}
|
||||
|
||||
def _plan(
|
||||
self,
|
||||
steps: list[object],
|
||||
*,
|
||||
peripherals: list[dict[str, object]] | None = None,
|
||||
) -> dict[str, object]:
|
||||
proposed = {
|
||||
"summary": "test plan",
|
||||
"needs_confirmation": False,
|
||||
"question": "",
|
||||
"steps": steps,
|
||||
}
|
||||
vehicle_commands = [
|
||||
str(step.get("command"))
|
||||
for step in steps
|
||||
if isinstance(step, dict)
|
||||
and step.get("kind") == "vehicle"
|
||||
and step.get("command")
|
||||
]
|
||||
with patch.dict(os.environ, {"OPENAI_API_KEY": "offline-test-key"}, clear=False):
|
||||
with patch.object(
|
||||
self.service,
|
||||
"_request_gpt_plan",
|
||||
return_value=json.dumps(proposed, ensure_ascii=False),
|
||||
):
|
||||
return self.service.plan(
|
||||
{
|
||||
"utterance": "test utterance",
|
||||
"peripherals": peripherals or [],
|
||||
"vehicle_devices": [
|
||||
{
|
||||
"name": "test vehicle device",
|
||||
"actions": [
|
||||
{"name": command, "command": command}
|
||||
for command in vehicle_commands
|
||||
],
|
||||
}
|
||||
],
|
||||
}
|
||||
)
|
||||
|
||||
def test_preserves_action_delay_action_order_and_canonicalizes_label(self) -> None:
|
||||
peripherals = [
|
||||
{
|
||||
"id": "cactus-on",
|
||||
"status": "learned",
|
||||
"device_name": "跳舞仙人掌",
|
||||
"action": "开启",
|
||||
"risk": "low",
|
||||
},
|
||||
{
|
||||
"id": "cactus-off",
|
||||
"status": "learned",
|
||||
"device_name": "跳舞仙人掌",
|
||||
"action": "关闭",
|
||||
"risk": "low",
|
||||
},
|
||||
]
|
||||
steps = [
|
||||
{
|
||||
"kind": "peripheral",
|
||||
"capability_id": "cactus-on",
|
||||
"command": "",
|
||||
"label": "ignored",
|
||||
"delay_ms": 0,
|
||||
},
|
||||
self._delay(5_000),
|
||||
{
|
||||
"kind": "peripheral",
|
||||
"capability_id": "cactus-off",
|
||||
"command": "",
|
||||
"label": "ignored",
|
||||
"delay_ms": 0,
|
||||
},
|
||||
]
|
||||
|
||||
result = self._plan(steps, peripherals=peripherals)
|
||||
|
||||
self.assertEqual(
|
||||
[step["kind"] for step in result["steps"]],
|
||||
["peripheral", "delay", "peripheral"],
|
||||
)
|
||||
self.assertEqual(result["steps"][1]["delay_ms"], 5_000)
|
||||
self.assertEqual(result["steps"][1]["label"], "等待 5 秒")
|
||||
self.assertEqual(result["steps"][1]["risk"], "low")
|
||||
|
||||
def test_rejects_non_integer_or_out_of_range_delay_values(self) -> None:
|
||||
invalid_values: tuple[object, ...] = (
|
||||
"5000",
|
||||
True,
|
||||
False,
|
||||
1.5,
|
||||
None,
|
||||
0,
|
||||
-1,
|
||||
MAX_PLAN_DELAY_MS + 1,
|
||||
)
|
||||
for value in invalid_values:
|
||||
with self.subTest(value=value):
|
||||
with self.assertRaises(ExtensionIntegrationError):
|
||||
self._plan(
|
||||
[
|
||||
self._vehicle("light ambient on"),
|
||||
self._delay(value),
|
||||
self._vehicle("light ambient off"),
|
||||
]
|
||||
)
|
||||
|
||||
def test_rejects_delay_at_plan_boundaries_or_next_to_another_delay(self) -> None:
|
||||
invalid_plans = (
|
||||
[self._delay(1), self._vehicle("ac on")],
|
||||
[self._vehicle("ac on"), self._delay(1)],
|
||||
[
|
||||
self._vehicle("ac on"),
|
||||
self._delay(1),
|
||||
self._delay(1),
|
||||
self._vehicle("ac off"),
|
||||
],
|
||||
)
|
||||
for steps in invalid_plans:
|
||||
with self.subTest(steps=steps):
|
||||
with self.assertRaises(ExtensionIntegrationError):
|
||||
self._plan(steps)
|
||||
|
||||
def test_rejects_delay_that_becomes_boundary_after_invalid_action_is_removed(self) -> None:
|
||||
with self.assertRaisesRegex(ExtensionIntegrationError, "两个可执行动作之间"):
|
||||
self._plan(
|
||||
[
|
||||
{
|
||||
"kind": "peripheral",
|
||||
"capability_id": "not-learned",
|
||||
"command": "",
|
||||
"label": "invalid",
|
||||
"delay_ms": 0,
|
||||
},
|
||||
self._delay(1_000),
|
||||
self._vehicle("ac off"),
|
||||
]
|
||||
)
|
||||
|
||||
def test_enforces_cumulative_delay_limit(self) -> None:
|
||||
accepted = self._plan(
|
||||
[
|
||||
self._vehicle("ac on"),
|
||||
self._delay(MAX_PLAN_DELAY_MS),
|
||||
self._vehicle("ac temp driver 24"),
|
||||
self._delay(MAX_PLAN_DELAY_MS),
|
||||
self._vehicle("ac off"),
|
||||
]
|
||||
)
|
||||
self.assertEqual(
|
||||
sum(
|
||||
step.get("delay_ms", 0)
|
||||
for step in accepted["steps"]
|
||||
if step["kind"] == "delay"
|
||||
),
|
||||
MAX_TOTAL_PLAN_DELAY_MS,
|
||||
)
|
||||
|
||||
with self.assertRaisesRegex(ExtensionIntegrationError, "累计延时"):
|
||||
self._plan(
|
||||
[
|
||||
self._vehicle("ac on"),
|
||||
self._delay(MAX_PLAN_DELAY_MS),
|
||||
self._vehicle("ac temp driver 24"),
|
||||
self._delay(MAX_PLAN_DELAY_MS),
|
||||
self._vehicle("ac fan 3"),
|
||||
self._delay(1),
|
||||
self._vehicle("ac off"),
|
||||
]
|
||||
)
|
||||
|
||||
def test_rejects_device_fields_on_delay_step(self) -> None:
|
||||
for field_name, value in (("command", "wait 5 seconds"), ("capability_id", "cap-1")):
|
||||
with self.subTest(field_name=field_name):
|
||||
delay_step = self._delay(5_000)
|
||||
delay_step[field_name] = value
|
||||
with self.assertRaisesRegex(ExtensionIntegrationError, "不能包含"):
|
||||
self._plan(
|
||||
[
|
||||
self._vehicle("ac on"),
|
||||
delay_step,
|
||||
self._vehicle("ac off"),
|
||||
]
|
||||
)
|
||||
|
||||
def test_rejects_implicit_or_unknown_delay_representation(self) -> None:
|
||||
non_delay_with_wait = self._vehicle("ac on")
|
||||
non_delay_with_wait["delay_ms"] = 5_000
|
||||
with self.assertRaisesRegex(ExtensionIntegrationError, "非延时步骤"):
|
||||
self._plan([non_delay_with_wait, self._vehicle("ac off")])
|
||||
|
||||
with self.assertRaisesRegex(ExtensionIntegrationError, "未知步骤类型"):
|
||||
self._plan(
|
||||
[
|
||||
self._vehicle("ac on"),
|
||||
{
|
||||
"kind": "wait",
|
||||
"capability_id": "",
|
||||
"command": "",
|
||||
"label": "wait",
|
||||
"delay_ms": 5_000,
|
||||
},
|
||||
self._vehicle("ac off"),
|
||||
]
|
||||
)
|
||||
|
||||
def test_gpt_schema_and_prompt_define_millisecond_delay_contract(self) -> None:
|
||||
response = MagicMock()
|
||||
response.__enter__.return_value = response
|
||||
response.read.return_value = json.dumps({"output_text": "{}"}).encode("utf-8")
|
||||
|
||||
with patch.object(self.service, "openai_streaming", False):
|
||||
with patch(
|
||||
"cognitive_agent.application.self_extension.urlopen",
|
||||
return_value=response,
|
||||
) as open_request:
|
||||
self.service._request_gpt_plan(
|
||||
"开启跳舞仙人掌,5s 后关闭", [], [], "test-key"
|
||||
)
|
||||
|
||||
request_payload = json.loads(open_request.call_args.args[0].data.decode("utf-8"))
|
||||
plan_format = request_payload["text"]["format"]
|
||||
step_schema = plan_format["schema"]["properties"]["steps"]["items"]
|
||||
self.assertTrue(plan_format["strict"])
|
||||
self.assertIn("delay", step_schema["properties"]["kind"]["enum"])
|
||||
self.assertIn("delay_ms", step_schema["required"])
|
||||
self.assertEqual(
|
||||
step_schema["properties"]["delay_ms"]["maximum"],
|
||||
MAX_PLAN_DELAY_MS,
|
||||
)
|
||||
prompt = "\n".join(
|
||||
content["text"]
|
||||
for message in request_payload["input"]
|
||||
if message.get("role") == "system"
|
||||
for content in message.get("content", [])
|
||||
if content.get("type") == "input_text"
|
||||
)
|
||||
self.assertIn("delay_ms=5000", prompt)
|
||||
self.assertIn(str(MAX_TOTAL_PLAN_DELAY_MS), prompt)
|
||||
self.assertIn("不能位于首尾", prompt)
|
||||
self.assertIn("绝不能把等待时间写进 command", prompt)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -0,0 +1,58 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from pathlib import Path
|
||||
import unittest
|
||||
|
||||
|
||||
REPOSITORY_ROOT = Path(__file__).resolve().parents[2]
|
||||
SOURCE_PATH = REPOSITORY_ROOT / "apps" / "web-console" / "self-extension.js"
|
||||
|
||||
|
||||
class SelfExtensionFrontendDelayContractTests(unittest.TestCase):
|
||||
@classmethod
|
||||
def setUpClass(cls) -> None:
|
||||
cls.source = SOURCE_PATH.read_text(encoding="utf-8")
|
||||
hydrate_start = cls.source.index(" function hydratePlan(steps) {")
|
||||
hydrate_end = cls.source.index("\n function setExecutionState", hydrate_start)
|
||||
cls.hydrate_source = cls.source[hydrate_start:hydrate_end]
|
||||
execute_start = cls.source.index(" async function executePlan(plan) {")
|
||||
execute_end = cls.source.index("\n function addHistory", execute_start)
|
||||
cls.execute_source = cls.source[execute_start:execute_end]
|
||||
|
||||
def test_hydration_recognizes_and_strictly_validates_delay_steps(self) -> None:
|
||||
self.assertIn('if (step.kind === "delay")', self.hydrate_source)
|
||||
self.assertIn("Number.isInteger(step.delay_ms)", self.hydrate_source)
|
||||
self.assertIn("step.delay_ms < MIN_PLAN_DELAY_MS", self.hydrate_source)
|
||||
self.assertIn("step.delay_ms > MAX_PLAN_DELAY_MS", self.hydrate_source)
|
||||
self.assertIn("totalDelayMs > MAX_TOTAL_PLAN_DELAY_MS", self.hydrate_source)
|
||||
self.assertIn("index === 0 || index === plan.length - 1", self.hydrate_source)
|
||||
self.assertIn('plan[index - 1].kind === "delay"', self.hydrate_source)
|
||||
self.assertIn("step.delay_ms !== undefined && step.delay_ms !== 0", self.hydrate_source)
|
||||
self.assertNotIn(".filter(Boolean)", self.hydrate_source)
|
||||
|
||||
def test_hydration_displays_a_canonical_five_second_label(self) -> None:
|
||||
self.assertIn("`等待 ${step.delay_ms / 1000} 秒`", self.hydrate_source)
|
||||
self.assertIn("delayMs: step.delay_ms", self.hydrate_source)
|
||||
|
||||
def test_execution_routes_delay_only_to_the_timer(self) -> None:
|
||||
delay_branch = self.execute_source.index('if (step.kind === "delay")')
|
||||
vehicle_branch = self.execute_source.index('else if (step.kind === "vehicle")')
|
||||
peripheral_branch = self.execute_source.index('await sendGatewayCommand("emit"')
|
||||
self.assertLess(delay_branch, vehicle_branch)
|
||||
self.assertLess(vehicle_branch, peripheral_branch)
|
||||
between_delay_and_vehicle = self.execute_source[delay_branch:vehicle_branch]
|
||||
self.assertIn("await delay(step.delayMs);", between_delay_and_vehicle)
|
||||
self.assertNotIn("executeVehicle", between_delay_and_vehicle)
|
||||
self.assertNotIn("sendGatewayCommand", between_delay_and_vehicle)
|
||||
|
||||
def test_explicit_delay_does_not_stack_with_default_action_spacing(self) -> None:
|
||||
self.assertIn("const nextStep = plan[index + 1];", self.execute_source)
|
||||
self.assertIn(
|
||||
'nextStep && step.kind !== "delay" && nextStep.kind !== "delay"',
|
||||
self.execute_source,
|
||||
)
|
||||
self.assertIn("await delay(state.settings.actionDelayMs);", self.execute_source)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -0,0 +1,141 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import unittest
|
||||
from unittest.mock import patch
|
||||
|
||||
from cognitive_agent.application.native_can_feedback import (
|
||||
VehicleSafetyPreflightError,
|
||||
observe_vehicle_safety,
|
||||
require_stationary_vehicle,
|
||||
)
|
||||
from cognitive_agent.application.seat_service import REPOSITORY_ROOT, SeatDemoService
|
||||
from cognitive_agent.infrastructure.can.gateway import CanFrame
|
||||
from cognitive_agent.infrastructure.can.hardware import HardwareSafetyError
|
||||
|
||||
|
||||
class _ReceiveOnlyBus:
|
||||
def __init__(self, frames: list[CanFrame]) -> None:
|
||||
self.frames = list(frames)
|
||||
self.send_called = False
|
||||
|
||||
def send(self, *args, **kwargs): # pragma: no cover - a safety tripwire
|
||||
self.send_called = True
|
||||
raise AssertionError("passive safety preflight must never transmit")
|
||||
|
||||
def receive(self, timeout_ms: int, *, include_fd: bool = False):
|
||||
return self.frames.pop(0) if self.frames else None
|
||||
|
||||
def close(self) -> None:
|
||||
return None
|
||||
|
||||
|
||||
class _Bridge:
|
||||
def __init__(self, bus: _ReceiveOnlyBus) -> None:
|
||||
self.bus = bus
|
||||
self.closed = False
|
||||
|
||||
def channel_bus(self, channel: int) -> _ReceiveOnlyBus:
|
||||
if channel != 1:
|
||||
raise AssertionError(f"unexpected channel {channel}")
|
||||
return self.bus
|
||||
|
||||
def close(self) -> None:
|
||||
self.closed = True
|
||||
|
||||
|
||||
def _gear(raw: int, *, channel: int = 1) -> CanFrame:
|
||||
data = bytearray(8)
|
||||
data[3] = raw & 0x0F
|
||||
return CanFrame(0x342, bytes(data), channel=channel)
|
||||
|
||||
|
||||
def _speed(raw: int, *, channel: int = 1) -> CanFrame:
|
||||
data = bytearray(8)
|
||||
data[0] = 0x07
|
||||
data[1] = raw & 0xFF
|
||||
data[2] = (raw >> 8) & 0x0F
|
||||
return CanFrame(0x4EB, bytes(data), channel=channel)
|
||||
|
||||
|
||||
class VehicleSafetyPreflightTests(unittest.TestCase):
|
||||
def test_fresh_ch1_p_and_zero_speed_pass_without_transmitting(self) -> None:
|
||||
bus = _ReceiveOnlyBus([_gear(1), _speed(0)])
|
||||
snapshot = observe_vehicle_safety(bus, timeout_ms=100)
|
||||
require_stationary_vehicle(snapshot)
|
||||
|
||||
self.assertTrue(snapshot["stationary"])
|
||||
self.assertEqual(snapshot["evidence"]["gear"]["frame_id"], "0x342")
|
||||
self.assertEqual(snapshot["evidence"]["speed"]["frame_id"], "0x4EB")
|
||||
self.assertFalse(bus.send_called)
|
||||
|
||||
def test_ch0_frames_cannot_satisfy_the_gate(self) -> None:
|
||||
snapshot = observe_vehicle_safety(
|
||||
_ReceiveOnlyBus([_gear(1, channel=0), _speed(0, channel=0)]),
|
||||
timeout_ms=100,
|
||||
)
|
||||
|
||||
self.assertFalse(snapshot["complete"])
|
||||
self.assertEqual(snapshot["ignored_wrong_channel"], 2)
|
||||
with self.assertRaisesRegex(
|
||||
VehicleSafetyPreflightError, "0x342 gear evidence"
|
||||
):
|
||||
require_stationary_vehicle(snapshot)
|
||||
|
||||
def test_drive_or_nonzero_speed_fails_closed(self) -> None:
|
||||
for frames, message in (
|
||||
([_gear(4), _speed(0)], "not confirmed in P"),
|
||||
([_gear(1), _speed(10)], "not confirmed at 0 km/h"),
|
||||
):
|
||||
with self.subTest(message=message):
|
||||
snapshot = observe_vehicle_safety(
|
||||
_ReceiveOnlyBus(frames), timeout_ms=100
|
||||
)
|
||||
with self.assertRaisesRegex(VehicleSafetyPreflightError, message):
|
||||
require_stationary_vehicle(snapshot)
|
||||
|
||||
def test_policy_cannot_be_redirected_to_ch0(self) -> None:
|
||||
with self.assertRaisesRegex(VehicleSafetyPreflightError, "only CH1"):
|
||||
observe_vehicle_safety(
|
||||
_ReceiveOnlyBus([_gear(1, channel=0), _speed(0, channel=0)]),
|
||||
timeout_ms=100,
|
||||
required_channel=0,
|
||||
)
|
||||
|
||||
def test_service_refuses_before_any_command_when_vehicle_is_not_in_p(self) -> None:
|
||||
service = SeatDemoService(REPOSITORY_ROOT)
|
||||
action = {
|
||||
"domain": "climate",
|
||||
"capability": "climate.set_power",
|
||||
"target": "vehicle",
|
||||
"operation": "set",
|
||||
"parameters": {"state": "on"},
|
||||
}
|
||||
config = service.native_can_runtime.build_function_config(
|
||||
"climate", [action], "req-safety-preflight", "local_rules"
|
||||
)
|
||||
plan = service.native_can_runtime.compile(config)
|
||||
bus = _ReceiveOnlyBus([_gear(4), _speed(0)])
|
||||
bridge = _Bridge(bus)
|
||||
|
||||
with patch.object(
|
||||
type(service.vehicle_can_settings),
|
||||
"bridge",
|
||||
return_value=bridge,
|
||||
):
|
||||
with self.assertRaisesRegex(HardwareSafetyError, "not confirmed in P"):
|
||||
service._execute_orchestrated_reports(
|
||||
{"native_climate": config},
|
||||
{"native_climate": plan},
|
||||
{"native_climate"},
|
||||
{},
|
||||
hardware_request=True,
|
||||
simulated_hardware_request=False,
|
||||
simulation_fault="normal",
|
||||
)
|
||||
|
||||
self.assertFalse(bus.send_called)
|
||||
self.assertTrue(bridge.closed)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -0,0 +1,608 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import unittest
|
||||
from unittest.mock import patch
|
||||
|
||||
from cognitive_agent.application.native_can_feedback import (
|
||||
decode_feedback,
|
||||
feedback_matches,
|
||||
)
|
||||
from cognitive_agent.application.native_can_intent import (
|
||||
_parse_exact_command,
|
||||
merge_native_can_intent,
|
||||
)
|
||||
from cognitive_agent.application.native_can_runtime import (
|
||||
NativeCanCompileError,
|
||||
NativeCanExecutor,
|
||||
NativeCanRuntime,
|
||||
)
|
||||
from cognitive_agent.application.seat_service import REPOSITORY_ROOT
|
||||
from cognitive_agent.infrastructure.can.gateway import CanFrame
|
||||
from cognitive_agent.runtime.contracts import ContractValidator
|
||||
|
||||
|
||||
class _FakeBus:
|
||||
def __init__(
|
||||
self,
|
||||
received: list[CanFrame] | None = None,
|
||||
*,
|
||||
fail_send_calls: set[int] | None = None,
|
||||
) -> None:
|
||||
self.received = list(received or [])
|
||||
self.fail_send_calls = set(fail_send_calls or set())
|
||||
self.send_attempts: list[tuple[int, bytes, bool, bool]] = []
|
||||
self.sent: list[CanFrame] = []
|
||||
self.events: list[str] = []
|
||||
self.closed = False
|
||||
|
||||
def send(
|
||||
self,
|
||||
arbitration_id: int,
|
||||
data: bytes,
|
||||
*,
|
||||
is_fd: bool = False,
|
||||
bitrate_switch: bool = False,
|
||||
) -> CanFrame:
|
||||
self.events.append("send")
|
||||
self.send_attempts.append(
|
||||
(arbitration_id, bytes(data), is_fd, bitrate_switch)
|
||||
)
|
||||
call_number = len(self.send_attempts)
|
||||
if call_number in self.fail_send_calls:
|
||||
raise RuntimeError(f"injected send failure {call_number}")
|
||||
frame = CanFrame(
|
||||
arbitration_id,
|
||||
bytes(data),
|
||||
channel=1,
|
||||
is_fd=is_fd,
|
||||
bitrate_switch=bitrate_switch,
|
||||
)
|
||||
self.sent.append(frame)
|
||||
return frame
|
||||
|
||||
def receive(self, timeout_ms: int, *, include_fd: bool = False) -> CanFrame | None:
|
||||
self.events.append("receive")
|
||||
return self.received.pop(0) if self.received else None
|
||||
|
||||
def close(self) -> None:
|
||||
self.closed = True
|
||||
|
||||
|
||||
class VerifiedBodyCanTests(unittest.TestCase):
|
||||
def setUp(self) -> None:
|
||||
self.runtime = NativeCanRuntime(REPOSITORY_ROOT)
|
||||
|
||||
def compile_action(
|
||||
self, domain: str, capability: str, target: str, parameters: dict
|
||||
) -> dict:
|
||||
config = self.runtime.build_function_config(
|
||||
domain,
|
||||
[
|
||||
{
|
||||
"domain": domain,
|
||||
"capability": capability,
|
||||
"target": target,
|
||||
"operation": "set",
|
||||
"parameters": parameters,
|
||||
}
|
||||
],
|
||||
"verified-body-can",
|
||||
"local_rules",
|
||||
)
|
||||
return self.runtime.compile(config)["spec"]["commands"][0]
|
||||
|
||||
@staticmethod
|
||||
def shade_feedback(position: int, motion: int = 1) -> CanFrame:
|
||||
data = bytearray(8)
|
||||
data[1] = position
|
||||
data[3] = motion << 3
|
||||
return CanFrame(0x41A, bytes(data), channel=1)
|
||||
|
||||
def assert_fd_brs_triplet(self, command: dict, frame_id: str) -> None:
|
||||
self.assertEqual(command["signals"]["frame_id"], frame_id)
|
||||
self.assertTrue(command["signals"]["is_fd"])
|
||||
self.assertTrue(command["signals"]["bitrate_switch"])
|
||||
self.assertEqual(command["repeat"], {"count": 3, "period_ms": 100})
|
||||
|
||||
def test_indoor_and_all_reading_lights_use_only_verified_4f5(self) -> None:
|
||||
cases = (
|
||||
("interior_light.set_indoor_power", "vehicle", "on", "00 00 03 FF FF 00 08 00"),
|
||||
("interior_light.set_indoor_power", "vehicle", "off", "00 00 03 FF FF 00 04 00"),
|
||||
("interior_light.set_reading_power", "all", "on", "00 00 03 FF FF 00 08 00"),
|
||||
("interior_light.set_reading_power", "all", "off", "00 00 03 FF FF 00 04 00"),
|
||||
)
|
||||
for capability, target, state, expected_hex in cases:
|
||||
with self.subTest(capability=capability, state=state):
|
||||
command = self.compile_action(
|
||||
"interior_light", capability, target, {"state": state}
|
||||
)
|
||||
self.assert_fd_brs_triplet(command, "0x4F5")
|
||||
self.assertEqual(command["signals"]["data_hex"], expected_hex)
|
||||
self.assertEqual(command["signals"]["feedback_ids"], "0x42E")
|
||||
self.assertEqual(
|
||||
command["signals"]["expected_feedback"],
|
||||
f"indoor_light:{state}",
|
||||
)
|
||||
|
||||
def test_unverified_light_controls_are_not_hardware_capabilities(self) -> None:
|
||||
rejected = (
|
||||
("interior_light.set_wall_power", "vehicle", {"state": "on"}),
|
||||
("ambient_light.set_mode", "vehicle", {"mode": "cinema"}),
|
||||
(
|
||||
"ambient_light.set_illumination",
|
||||
"vehicle",
|
||||
{"color_hex": "#203040", "brightness_pct": 40},
|
||||
),
|
||||
)
|
||||
for capability, target, parameters in rejected:
|
||||
action = {"capability": capability, "target": target, "parameters": parameters}
|
||||
with self.subTest(capability=capability):
|
||||
self.assertFalse(self.runtime.supports(action))
|
||||
with self.assertRaises(NativeCanCompileError):
|
||||
self.runtime.build_function_config(
|
||||
capability.split(".", 1)[0],
|
||||
[action],
|
||||
"reject-unverified-light",
|
||||
"local_rules",
|
||||
)
|
||||
|
||||
zoned = {
|
||||
"capability": "interior_light.set_reading_power",
|
||||
"target": "front_left",
|
||||
"parameters": {"state": "on"},
|
||||
}
|
||||
self.assertFalse(self.runtime.supports(zoned))
|
||||
with self.assertRaises(NativeCanCompileError):
|
||||
config = self.runtime.build_function_config(
|
||||
"interior_light", [zoned], "reject-zoned-reading", "local_rules"
|
||||
)
|
||||
self.runtime.compile(config)
|
||||
|
||||
def test_exact_parser_exposes_only_verified_light_vocabulary(self) -> None:
|
||||
self.assertEqual(_parse_exact_command("light wall on"), [])
|
||||
self.assertEqual(_parse_exact_command("light reading front-left on"), [])
|
||||
self.assertEqual(_parse_exact_command("light ambient scene cinema"), [])
|
||||
self.assertEqual(_parse_exact_command("light ambient rgb 32 64 96 40"), [])
|
||||
|
||||
color = _parse_exact_command("light ambient color 70")
|
||||
self.assertEqual(color[0]["capability"], "ambient_light.set_color_index")
|
||||
self.assertEqual(color[0]["parameters"], {"color_index": 70})
|
||||
brightness = _parse_exact_command("light ambient brightness 5")
|
||||
self.assertEqual(
|
||||
brightness[0]["capability"], "ambient_light.set_brightness_level"
|
||||
)
|
||||
self.assertEqual(brightness[0]["parameters"], {"brightness_level": 5})
|
||||
|
||||
def test_fully_exact_ac_power_is_the_only_executable_plan(self) -> None:
|
||||
for requested_state in ("on", "off"):
|
||||
opposite_state = "off" if requested_state == "on" else "on"
|
||||
for model_capability, model_state in (
|
||||
("climate.set_ac", requested_state),
|
||||
("climate.set_ac", opposite_state),
|
||||
("climate.set_power", opposite_state),
|
||||
):
|
||||
with self.subTest(
|
||||
requested_state=requested_state,
|
||||
model_capability=model_capability,
|
||||
model_state=model_state,
|
||||
):
|
||||
model_request = {
|
||||
"domain": "climate",
|
||||
"capability": model_capability,
|
||||
"target_scope": "vehicle",
|
||||
"operation": "set",
|
||||
"parameters": [{"name": "state", "value": model_state}],
|
||||
}
|
||||
routed_action = {
|
||||
"domain": "climate",
|
||||
"capability": model_capability,
|
||||
"target": "vehicle",
|
||||
"operation": "set",
|
||||
"parameters": {"state": model_state},
|
||||
}
|
||||
parsed = {
|
||||
"detected_domains": ["climate", "window", "seat"],
|
||||
"actions": [
|
||||
{
|
||||
"target": "passenger",
|
||||
"function": "ventilation",
|
||||
"state": "on",
|
||||
}
|
||||
],
|
||||
"model_requests": [model_request],
|
||||
"domain_actions": [
|
||||
routed_action,
|
||||
{
|
||||
"domain": "window",
|
||||
"capability": "window.set_motion",
|
||||
"target": "front_left",
|
||||
"operation": "set",
|
||||
"parameters": {"motion": "open"},
|
||||
},
|
||||
],
|
||||
"native_can_actions": [routed_action],
|
||||
"deferred_requests": [routed_action],
|
||||
"preset_actions": [{"target": "passenger"}],
|
||||
"preset_events": [{"action": "apply"}],
|
||||
"ambiguities": [
|
||||
{"field": "requests[0].target", "question": "hallucinated"}
|
||||
],
|
||||
}
|
||||
merged = merge_native_can_intent(
|
||||
parsed, f"ac {requested_state}"
|
||||
)
|
||||
|
||||
self.assertEqual(merged["actions"], [])
|
||||
self.assertEqual(merged["domain_actions"], [])
|
||||
self.assertEqual(merged["preset_actions"], [])
|
||||
self.assertEqual(merged["preset_events"], [])
|
||||
self.assertEqual(merged["ambiguities"], [])
|
||||
self.assertEqual(merged["detected_domains"], ["climate"])
|
||||
self.assertEqual(len(merged["model_requests"]), 1)
|
||||
self.assertEqual(len(merged["deferred_requests"]), 1)
|
||||
self.assertEqual(len(merged["native_can_actions"]), 1)
|
||||
for key in (
|
||||
"model_requests",
|
||||
"deferred_requests",
|
||||
"native_can_actions",
|
||||
):
|
||||
self.assertEqual(
|
||||
merged[key][0]["capability"], "climate.set_power"
|
||||
)
|
||||
parameters = merged[key][0]["parameters"]
|
||||
if isinstance(parameters, list):
|
||||
parameters = {
|
||||
item["name"]: item["value"] for item in parameters
|
||||
}
|
||||
self.assertEqual(parameters, {"state": requested_state})
|
||||
|
||||
natural_model_action = {
|
||||
"domain": "climate",
|
||||
"capability": "climate.set_ac",
|
||||
"target": "vehicle",
|
||||
"operation": "set",
|
||||
"parameters": {"state": "on"},
|
||||
}
|
||||
natural = merge_native_can_intent(
|
||||
{
|
||||
"model_requests": [natural_model_action],
|
||||
"domain_actions": [natural_model_action],
|
||||
"native_can_actions": [],
|
||||
"deferred_requests": [],
|
||||
},
|
||||
"打开空调并关闭遮阳帘",
|
||||
)
|
||||
self.assertIn(natural_model_action, natural["domain_actions"])
|
||||
self.assertGreaterEqual(len(natural["native_can_actions"]), 2)
|
||||
|
||||
mixed_shade_action = {
|
||||
"domain": "roof",
|
||||
"capability": "roof.set_shade_motion",
|
||||
"target": "vehicle",
|
||||
"operation": "set",
|
||||
"parameters": {"motion": "close"},
|
||||
}
|
||||
mixed = merge_native_can_intent(
|
||||
{
|
||||
"model_requests": [],
|
||||
"domain_actions": [mixed_shade_action],
|
||||
"native_can_actions": [],
|
||||
"deferred_requests": [],
|
||||
},
|
||||
"ac on,关闭遮阳帘",
|
||||
)
|
||||
self.assertIn(mixed_shade_action, mixed["domain_actions"])
|
||||
self.assertEqual(
|
||||
[item["capability"] for item in mixed["native_can_actions"]],
|
||||
["climate.set_power"],
|
||||
)
|
||||
|
||||
def test_vehicle_lock_is_observational_only_and_fails_closed(self) -> None:
|
||||
action = {
|
||||
"domain": "door",
|
||||
"capability": "door.set_vehicle_lock",
|
||||
"target": "vehicle",
|
||||
"operation": "set",
|
||||
"parameters": {"state": "lock"},
|
||||
}
|
||||
catalog_entry = self.runtime.capabilities[action["capability"]]
|
||||
self.assertEqual(catalog_entry["execution_support"], "observational_only")
|
||||
self.assertFalse(catalog_entry["physical_control_supported"])
|
||||
self.assertNotIn(action["capability"], self.runtime.supported_capabilities)
|
||||
self.assertFalse(self.runtime.supports(action))
|
||||
with self.assertRaisesRegex(
|
||||
NativeCanCompileError,
|
||||
r"unsupported.*current CH1.*0x496.*observational only",
|
||||
):
|
||||
self.runtime.build_function_config(
|
||||
"door", [action], "retired-vehicle-lock", "local_rules"
|
||||
)
|
||||
with self.assertRaisesRegex(
|
||||
NativeCanCompileError, r"unsupported.*current CH1.*observational only"
|
||||
):
|
||||
self.runtime._frame_specs({"capability": action["capability"]})
|
||||
|
||||
vehicle = _parse_exact_command("door vehicle lock")
|
||||
self.assertEqual(vehicle[0]["capability"], "door.set_vehicle_lock")
|
||||
self.assertEqual(vehicle[0]["target"], "vehicle")
|
||||
|
||||
def test_legacy_rear_lock_is_retired_before_compile(self) -> None:
|
||||
action = {
|
||||
"domain": "door",
|
||||
"capability": "door.set_rear_lock",
|
||||
"target": "rear",
|
||||
"operation": "set",
|
||||
"parameters": {"state": "lock"},
|
||||
}
|
||||
catalog_entry = self.runtime.capabilities[action["capability"]]
|
||||
self.assertEqual(catalog_entry["execution_support"], "observational_only")
|
||||
self.assertFalse(catalog_entry["physical_control_supported"])
|
||||
self.assertNotIn(action["capability"], self.runtime.supported_capabilities)
|
||||
self.assertFalse(self.runtime.supports(action))
|
||||
with self.assertRaisesRegex(
|
||||
NativeCanCompileError,
|
||||
r"unsupported.*current CH1.*0x32B.*observational only",
|
||||
):
|
||||
self.runtime.build_function_config(
|
||||
"door", [action], "retired-rear-lock", "local_rules"
|
||||
)
|
||||
with self.assertRaisesRegex(
|
||||
NativeCanCompileError, r"unsupported.*current CH1.*observational only"
|
||||
):
|
||||
self.runtime._frame_specs({"capability": action["capability"]})
|
||||
|
||||
self.assertEqual(_parse_exact_command("door rear lock"), [])
|
||||
|
||||
def test_ambient_controls_use_only_verified_3fc_layouts(self) -> None:
|
||||
cases = (
|
||||
("ambient_light.set_power", {"state": "on"}, "00 36 00 75 00 80 00 00", "ambient_power:on"),
|
||||
("ambient_light.set_power", {"state": "off"}, "00 31 00 75 00 80 00 00", "ambient_power:off"),
|
||||
("ambient_light.set_color_index", {"color_index": 70}, "46 30 00 75 00 00 00 00", "ambient_color:70"),
|
||||
("ambient_light.set_brightness_level", {"brightness_level": 1}, "00 32 00 75 00 00 00 00", "ambient_brightness:1"),
|
||||
("ambient_light.set_brightness_level", {"brightness_level": 5}, "00 36 00 75 00 00 00 00", "ambient_brightness:5"),
|
||||
)
|
||||
for capability, parameters, payload, expected_feedback in cases:
|
||||
with self.subTest(capability=capability, parameters=parameters):
|
||||
command = self.compile_action(
|
||||
"ambient_light", capability, "vehicle", parameters
|
||||
)
|
||||
self.assert_fd_brs_triplet(command, "0x3FC")
|
||||
self.assertEqual(command["signals"]["data_hex"], payload)
|
||||
self.assertEqual(command["signals"]["feedback_ids"], "0x42E")
|
||||
self.assertEqual(
|
||||
command["signals"]["expected_feedback"], expected_feedback
|
||||
)
|
||||
|
||||
def test_verified_ambient_function_configs_pass_domain_schema(self) -> None:
|
||||
validator = ContractValidator(REPOSITORY_ROOT)
|
||||
for capability, parameters in (
|
||||
("ambient_light.set_color_index", {"color_index": 70}),
|
||||
("ambient_light.set_brightness_level", {"brightness_level": 5}),
|
||||
):
|
||||
with self.subTest(capability=capability):
|
||||
config = self.runtime.build_function_config(
|
||||
"ambient_light",
|
||||
[
|
||||
{
|
||||
"domain": "ambient_light",
|
||||
"capability": capability,
|
||||
"target": "vehicle",
|
||||
"operation": "set",
|
||||
"parameters": parameters,
|
||||
}
|
||||
],
|
||||
"verified-ambient-schema",
|
||||
"local_rules",
|
||||
)
|
||||
self.assertEqual(validator.validate(config), [])
|
||||
|
||||
def test_ambient_ranges_and_parameter_shapes_fail_closed(self) -> None:
|
||||
for capability, parameters in (
|
||||
("ambient_light.set_color_index", {"color_index": 0}),
|
||||
("ambient_light.set_color_index", {"color_index": 128}),
|
||||
("ambient_light.set_brightness_level", {"brightness_level": 0}),
|
||||
("ambient_light.set_brightness_level", {"brightness_level": 6}),
|
||||
(
|
||||
"ambient_light.set_color_index",
|
||||
{"color_index": 70, "brightness_level": 3},
|
||||
),
|
||||
):
|
||||
with self.subTest(capability=capability, parameters=parameters):
|
||||
with self.assertRaises(NativeCanCompileError):
|
||||
self.compile_action("ambient_light", capability, "vehicle", parameters)
|
||||
|
||||
def test_shade_presets_and_arbitrary_position_never_use_4f5(self) -> None:
|
||||
for position, payload in (
|
||||
(0, "20 00 00 00 00 00 00 00"),
|
||||
(50, "30 00 00 00 00 00 00 00"),
|
||||
(100, "10 00 00 00 00 00 00 00"),
|
||||
):
|
||||
with self.subTest(position=position):
|
||||
command = self.compile_action(
|
||||
"roof", "roof.set_shade_position", "vehicle", {"position_pct": position}
|
||||
)
|
||||
self.assertEqual(command["signals"]["frame_id"], "0x431")
|
||||
self.assertEqual(command["signals"]["data_hex"], payload)
|
||||
self.assertFalse(command["signals"]["is_fd"])
|
||||
self.assertEqual(command["signals"]["feedback_ids"], "0x41A")
|
||||
self.assertEqual(command["signals"]["special_handler"], "shade_preset")
|
||||
|
||||
arbitrary = self.compile_action(
|
||||
"roof", "roof.set_shade_position", "vehicle", {"position_pct": 37}
|
||||
)
|
||||
self.assertEqual(arbitrary["signals"]["frame_id"], "0x431")
|
||||
self.assertNotEqual(arbitrary["signals"]["frame_id"], "0x4F5")
|
||||
self.assertEqual(arbitrary["signals"]["special_handler"], "shade_position")
|
||||
special = arbitrary["signals"]["special_parameters"]
|
||||
self.assertIn('"fresh_feedback_timeout_ms": 2500', special)
|
||||
self.assertIn('"stop_data_hex": "40 00 00 00 00 00 00 00"', special)
|
||||
|
||||
def test_shade_preset_executor_waits_for_authoritative_target(self) -> None:
|
||||
command = self.compile_action(
|
||||
"roof", "roof.set_shade_position", "vehicle", {"position_pct": 100}
|
||||
)
|
||||
reached = self.shade_feedback(99, motion=1)
|
||||
bus = _FakeBus([reached])
|
||||
executor = NativeCanExecutor(lambda: bus, feedback_timeout_ms=1)
|
||||
|
||||
sent, feedback = executor._execute_shade_preset(bus, command)
|
||||
|
||||
self.assertIs(feedback, reached)
|
||||
self.assertEqual(len(sent), 1)
|
||||
self.assertEqual(
|
||||
bus.send_attempts,
|
||||
[(0x431, bytes.fromhex("10 00 00 00 00 00 00 00"), False, False)],
|
||||
)
|
||||
|
||||
def test_shade_preset_forces_stop_when_target_feedback_is_still_moving(self) -> None:
|
||||
command = self.compile_action(
|
||||
"roof", "roof.set_shade_position", "vehicle", {"position_pct": 0}
|
||||
)
|
||||
reached_while_closing = self.shade_feedback(2, motion=3)
|
||||
stopped = self.shade_feedback(2, motion=1)
|
||||
bus = _FakeBus([reached_while_closing, stopped])
|
||||
executor = NativeCanExecutor(lambda: bus, feedback_timeout_ms=1)
|
||||
|
||||
sent, feedback = executor._execute_shade_preset(bus, command)
|
||||
|
||||
self.assertIs(feedback, stopped)
|
||||
self.assertEqual(len(sent), 2)
|
||||
self.assertEqual(
|
||||
[attempt[1] for attempt in bus.send_attempts],
|
||||
[
|
||||
bytes.fromhex("20 00 00 00 00 00 00 00"),
|
||||
bytes.fromhex("40 00 00 00 00 00 00 00"),
|
||||
],
|
||||
)
|
||||
|
||||
def test_arbitrary_shade_position_reads_fresh_state_then_stops(self) -> None:
|
||||
command = self.compile_action(
|
||||
"roof", "roof.set_shade_position", "vehicle", {"position_pct": 37}
|
||||
)
|
||||
before = self.shade_feedback(20, motion=1)
|
||||
reached = self.shade_feedback(38, motion=2)
|
||||
stopped = self.shade_feedback(38, motion=1)
|
||||
bus = _FakeBus([before, reached, stopped])
|
||||
executor = NativeCanExecutor(lambda: bus, feedback_timeout_ms=1)
|
||||
|
||||
sent, feedback = executor._execute_shade_position(bus, command)
|
||||
|
||||
self.assertIs(feedback, stopped)
|
||||
self.assertEqual(bus.events[0], "receive")
|
||||
self.assertEqual(
|
||||
[attempt[1] for attempt in bus.send_attempts],
|
||||
[
|
||||
bytes.fromhex("10 00 00 00 00 00 00 00"),
|
||||
bytes.fromhex("40 00 00 00 00 00 00 00"),
|
||||
],
|
||||
)
|
||||
self.assertTrue(all(not attempt[2] and not attempt[3] for attempt in bus.send_attempts))
|
||||
self.assertEqual(len(sent), 2)
|
||||
|
||||
def test_arbitrary_shade_position_always_stops_after_feedback_fault(self) -> None:
|
||||
command = self.compile_action(
|
||||
"roof", "roof.set_shade_position", "vehicle", {"position_pct": 37}
|
||||
)
|
||||
bus = _FakeBus()
|
||||
executor = NativeCanExecutor(lambda: bus, feedback_timeout_ms=1)
|
||||
before = self.shade_feedback(20, motion=1)
|
||||
|
||||
with patch.object(
|
||||
executor,
|
||||
"_wait_for_id",
|
||||
side_effect=[before, RuntimeError("injected receive failure")],
|
||||
):
|
||||
with self.assertRaisesRegex(RuntimeError, "injected receive failure"):
|
||||
executor._execute_shade_position(bus, command)
|
||||
|
||||
self.assertEqual(
|
||||
[attempt[1] for attempt in bus.send_attempts],
|
||||
[
|
||||
bytes.fromhex("10 00 00 00 00 00 00 00"),
|
||||
bytes.fromhex("40 00 00 00 00 00 00 00"),
|
||||
],
|
||||
)
|
||||
|
||||
def test_arbitrary_shade_start_failure_still_attempts_stop(self) -> None:
|
||||
command = self.compile_action(
|
||||
"roof", "roof.set_shade_position", "vehicle", {"position_pct": 37}
|
||||
)
|
||||
bus = _FakeBus([self.shade_feedback(20, motion=1)], fail_send_calls={1})
|
||||
executor = NativeCanExecutor(lambda: bus, feedback_timeout_ms=1)
|
||||
|
||||
with self.assertRaisesRegex(RuntimeError, "injected send failure"):
|
||||
executor._execute_shade_position(bus, command)
|
||||
|
||||
self.assertEqual(
|
||||
[attempt[1] for attempt in bus.send_attempts],
|
||||
[
|
||||
bytes.fromhex("10 00 00 00 00 00 00 00"),
|
||||
bytes.fromhex("40 00 00 00 00 00 00 00"),
|
||||
],
|
||||
)
|
||||
|
||||
def test_arbitrary_shade_missing_or_invalid_feedback_never_starts_motion(self) -> None:
|
||||
command = self.compile_action(
|
||||
"roof", "roof.set_shade_position", "vehicle", {"position_pct": 37}
|
||||
)
|
||||
executor = NativeCanExecutor(lambda: _FakeBus(), feedback_timeout_ms=1)
|
||||
for before in (None, self.shade_feedback(255, motion=1)):
|
||||
bus = _FakeBus()
|
||||
with self.subTest(before=before):
|
||||
with patch.object(executor, "_wait_for_id", return_value=before):
|
||||
with self.assertRaisesRegex(
|
||||
RuntimeError, "0x41A|新鲜|有效|fresh|valid"
|
||||
):
|
||||
executor._execute_shade_position(bus, command)
|
||||
self.assertEqual(bus.send_attempts, [])
|
||||
|
||||
def test_door_feedback_enums_and_shade_stop_match_reference(self) -> None:
|
||||
door_data = bytearray(8)
|
||||
# 0x407: front-left unlock, front-right lock, rear-left lock,
|
||||
# rear-right unlock; whole-vehicle logical state lock.
|
||||
door_data[0] = 0b01101001
|
||||
door_data[1] = 0b00001000
|
||||
decoded = decode_feedback(CanFrame(0x407, bytes(door_data), channel=1))
|
||||
locks = decoded["domain_state"]["door"]["locks"]
|
||||
self.assertEqual(locks["front_left"]["state"], "unlock")
|
||||
self.assertEqual(locks["front_right"]["state"], "lock")
|
||||
self.assertEqual(locks["rear_left"]["state"], "lock")
|
||||
self.assertEqual(locks["rear_right"]["state"], "unlock")
|
||||
self.assertEqual(
|
||||
decoded["domain_state"]["door"]["vehicle_lock"]["state"], "lock"
|
||||
)
|
||||
|
||||
all_locked = bytearray(8)
|
||||
all_locked[0] = 0xAA
|
||||
all_locked[1] = 0x08
|
||||
locked_frame = CanFrame(0x407, bytes(all_locked), channel=1)
|
||||
self.assertTrue(feedback_matches("vehicle_lock:lock", locked_frame))
|
||||
|
||||
logical_only = bytearray(all_locked)
|
||||
logical_only[0] = 0xA9
|
||||
self.assertFalse(
|
||||
feedback_matches(
|
||||
"vehicle_lock:lock",
|
||||
CanFrame(0x407, bytes(logical_only), channel=1),
|
||||
)
|
||||
)
|
||||
|
||||
all_unlocked = bytearray(8)
|
||||
all_unlocked[0] = 0x55
|
||||
all_unlocked[1] = 0x04
|
||||
self.assertTrue(
|
||||
feedback_matches(
|
||||
"vehicle_lock:unlock",
|
||||
CanFrame(0x407, bytes(all_unlocked), channel=1),
|
||||
)
|
||||
)
|
||||
|
||||
shade_data = bytearray(8)
|
||||
shade_data[1] = 37
|
||||
shade_data[3] = 1 << 3
|
||||
shade = CanFrame(0x41A, bytes(shade_data), channel=1)
|
||||
self.assertTrue(feedback_matches("shade_stopped", shade))
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
Reference in New Issue
Block a user