Files
Cognitive_Agent/tests/unit/test_driver_seat_native_can.py

530 lines
21 KiB
Python

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()