287 lines
9.5 KiB
Python
287 lines
9.5 KiB
Python
from __future__ import annotations
|
|
|
|
import json
|
|
import time
|
|
from pathlib import Path
|
|
|
|
from cognitive_agent.application.self_repair.healing_model import (
|
|
PHASE_DIAGNOSED,
|
|
PHASE_INJECTED,
|
|
PHASE_PASSED,
|
|
SCENARIOS,
|
|
SelfHealingEngine,
|
|
public_scenarios,
|
|
)
|
|
from cognitive_agent.application.self_repair.service import SelfRepairService
|
|
from cognitive_agent.application.self_repair.can_runtime import (
|
|
StandaloneCanRuntimeManager,
|
|
)
|
|
from cognitive_agent.application.self_repair.signal_diagnostic import SignalDiagnosticModel
|
|
from cognitive_agent.application.self_repair.zcan_bridge import ZcanBridgeManager
|
|
from cognitive_agent.infrastructure.can.gateway import ZlgChannelConfig
|
|
|
|
|
|
def test_six_production_cases_complete_the_shadow_repair_loop() -> None:
|
|
assert len(SCENARIOS) == 6
|
|
assert len(public_scenarios()) == 6
|
|
|
|
for scenario in SCENARIOS:
|
|
engine = SelfHealingEngine(clock=lambda: 1000.0)
|
|
started = engine.start(scenario["id"], now=1000.0)
|
|
assert started["execution_mode"] == "shadow"
|
|
assert started["injection_scope"] == "SHADOW_TELEMETRY_ONLY"
|
|
assert engine.snapshot(1006.2)["phase"] == PHASE_INJECTED
|
|
assert engine.snapshot(1008.7)["phase"] == PHASE_DIAGNOSED
|
|
completed = engine.snapshot(1018.2)
|
|
assert completed["phase"] == PHASE_PASSED
|
|
assert completed["pass"] is True
|
|
assert completed["telemetry"]["safety_protection"] is True
|
|
assert completed["telemetry"]["task_restored"] is True
|
|
|
|
|
|
def test_connector_inventory_matches_physical_housings() -> None:
|
|
snapshot = SignalDiagnosticModel().analyze(None)
|
|
connectors = snapshot["connectors"]
|
|
|
|
def pin(pin_id: str) -> dict[str, object]:
|
|
connector, number = pin_id.split("-", maxsplit=1)
|
|
return next(
|
|
item
|
|
for item in connectors[connector]
|
|
if item["number"] == int(number)
|
|
)
|
|
|
|
assert [pin["number"] for pin in connectors["JA1"]] == list(range(1, 15))
|
|
assert [pin["number"] for pin in connectors["JB1"]] == list(range(1, 19))
|
|
assert [pin["number"] for pin in connectors["JC1"]] == list(range(1, 41))
|
|
assert sum(len(pins) for pins in connectors.values()) == 72
|
|
assert snapshot["connector_enabled_count"] == 21
|
|
assert pin("JA1-14")["enabled"] is False
|
|
assert pin("JC1-21")["enabled"] is True
|
|
|
|
|
|
def test_service_is_safe_without_machine_specific_tool_paths() -> None:
|
|
service = SelfRepairService()
|
|
health = service.health()
|
|
state = service.snapshot()
|
|
|
|
assert health["ok"] is True
|
|
assert health["service"] == "seat-self-repair"
|
|
assert health["configuration"]["diagnostic_config_available"] is True
|
|
assert health["configuration"]["bridge_script_available"] is True
|
|
assert len(state["scenarios"]) == 6
|
|
assert state["integration"]["auto_start_bridge"] is False
|
|
assert state["model"]["type"] == "PROCEDURAL_SEAT"
|
|
assert service.run_self_test() is True
|
|
|
|
|
|
def test_bridge_manager_does_not_arm_visible_tooling_by_default(tmp_path: Path) -> None:
|
|
manager = ZcanBridgeManager(
|
|
tmp_path / "bridge.json",
|
|
tmp_path / "bridge.py",
|
|
zcanpro_exe=tmp_path / "ZCANPRO.exe",
|
|
script_config_path=tmp_path / "zcanpro.ini",
|
|
)
|
|
|
|
snapshot = manager.snapshot()
|
|
assert snapshot["state"] == "IDLE"
|
|
assert snapshot["retry_armed"] is False
|
|
assert snapshot["attempts"] == 0
|
|
|
|
|
|
def test_bridge_manager_does_not_restart_a_live_busy_request(tmp_path: Path) -> None:
|
|
status_path = tmp_path / "bridge.json"
|
|
status_path.write_text(
|
|
json.dumps({
|
|
"heartbeat_epoch": time.time() - 5.0,
|
|
"bridge_state": "RUNNING",
|
|
"current_request_id": "SELF_HEAL_BENCH_1",
|
|
}),
|
|
encoding="utf-8",
|
|
)
|
|
manager = ZcanBridgeManager(
|
|
status_path,
|
|
tmp_path / "bridge.py",
|
|
heartbeat_stale_s=3.0,
|
|
busy_heartbeat_grace_s=45.0,
|
|
)
|
|
|
|
assert manager._heartbeat_online() is False
|
|
assert manager._bridge_busy_within_grace() is True
|
|
|
|
|
|
def test_standalone_runtime_opens_one_owner_for_seat_and_vehicle_channels(
|
|
tmp_path: Path,
|
|
) -> None:
|
|
required_version = "runtime-test-v1"
|
|
bridge_script = tmp_path / "seat_bridge.py"
|
|
bridge_script.write_text(
|
|
"BRIDGE_VERSION = 'runtime-test-v1'\n"
|
|
"def run_bridge(transport=None, stop_event=None):\n"
|
|
" stop_event.wait(2.0)\n",
|
|
encoding="utf-8",
|
|
)
|
|
python_executable = tmp_path / "python.exe"
|
|
zlg_bridge_script = tmp_path / "zlg_bridge.py"
|
|
dll_path = tmp_path / "zlgcan.dll"
|
|
for path in (python_executable, zlg_bridge_script, dll_path):
|
|
path.write_bytes(b"test")
|
|
|
|
owners = []
|
|
|
|
class FakeBus:
|
|
def __init__(self, channel, config):
|
|
self.channel = channel
|
|
self.config = config
|
|
|
|
def send(self, *args, **kwargs):
|
|
return None
|
|
|
|
def receive(self, *args, **kwargs):
|
|
return None
|
|
|
|
class FakeOwner:
|
|
def __init__(self, **kwargs):
|
|
self.kwargs = kwargs
|
|
self.closed = False
|
|
self.configs = {item.channel: item for item in kwargs["channels"]}
|
|
owners.append(self)
|
|
|
|
def channel_bus(self, channel):
|
|
return FakeBus(channel, self.configs[channel])
|
|
|
|
def close(self):
|
|
self.closed = True
|
|
|
|
manager = StandaloneCanRuntimeManager(
|
|
tmp_path / "status.json",
|
|
bridge_script,
|
|
required_version=required_version,
|
|
python_executable=python_executable,
|
|
zlg_bridge_script=zlg_bridge_script,
|
|
dll_path=dll_path,
|
|
device_type=41,
|
|
device_index=0,
|
|
channels=[
|
|
ZlgChannelConfig(channel=0, frame_format="classic_can"),
|
|
ZlgChannelConfig(channel=1, frame_format="can_fd"),
|
|
],
|
|
seat_channel=0,
|
|
poll_s=0.01,
|
|
process_factory=FakeOwner,
|
|
)
|
|
try:
|
|
assert manager.configured is True
|
|
assert manager.channel_bus(1, timeout_s=1.0).channel == 1
|
|
manager.ensure_async()
|
|
assert len(owners) == 1
|
|
assert [
|
|
item.frame_format for item in owners[0].kwargs["channels"]
|
|
] == ["classic_can", "can_fd"]
|
|
assert manager.snapshot()["backend"] == "standalone_zlg"
|
|
finally:
|
|
manager.stop()
|
|
|
|
assert owners[0].closed is True
|
|
|
|
|
|
def _qualified_runtime_status(*, build_id: int = 16, frame_age_s: float = 0.0):
|
|
now = time.time()
|
|
frame_epoch = now - frame_age_s
|
|
return {
|
|
"bridge_version": "runtime-test-v1",
|
|
"heartbeat_epoch": now,
|
|
"current_request_id": None,
|
|
"system": {"firmware_revision": 15},
|
|
"firmware_identity": {
|
|
"firmware_build_id": build_id,
|
|
"qualified": build_id == 16,
|
|
},
|
|
"frame_epochs": {
|
|
frame_id: frame_epoch
|
|
for frame_id in (
|
|
"0x301", "0x302", "0x303", "0x304", "0x305", "0x306", "0x307"
|
|
)
|
|
},
|
|
}
|
|
|
|
|
|
def test_standalone_runtime_rejects_stale_protocol_and_old_firmware_build(
|
|
tmp_path: Path,
|
|
) -> None:
|
|
manager = StandaloneCanRuntimeManager(
|
|
tmp_path / "status.json",
|
|
tmp_path / "seat_bridge.py",
|
|
required_version="runtime-test-v1",
|
|
python_executable=tmp_path / "python.exe",
|
|
zlg_bridge_script=tmp_path / "zlg_bridge.py",
|
|
dll_path=tmp_path / "zlgcan.dll",
|
|
device_type=41,
|
|
device_index=0,
|
|
channels=[ZlgChannelConfig(channel=0, frame_format="classic_can")],
|
|
seat_channel=0,
|
|
expected_firmware_revision=15,
|
|
expected_firmware_build_id=16,
|
|
)
|
|
|
|
manager.status_path.write_text(
|
|
json.dumps(_qualified_runtime_status(frame_age_s=5.0)), encoding="utf-8"
|
|
)
|
|
ready, reason, _ = manager._protocol_status()
|
|
assert ready is False
|
|
assert "过期" in reason
|
|
|
|
manager.status_path.write_text(
|
|
json.dumps(_qualified_runtime_status(build_id=15)), encoding="utf-8"
|
|
)
|
|
ready, reason, _ = manager._protocol_status()
|
|
assert ready is False
|
|
assert "build" in reason
|
|
|
|
|
|
def test_standalone_runtime_restores_repository_firmware_before_request(
|
|
tmp_path: Path,
|
|
) -> None:
|
|
class FakeProgrammer:
|
|
def __init__(self):
|
|
self.calls = 0
|
|
|
|
def program(self):
|
|
self.calls += 1
|
|
return {"programmed": True, "image_sha256": "f" * 64}
|
|
|
|
programmer = FakeProgrammer()
|
|
manager = StandaloneCanRuntimeManager(
|
|
tmp_path / "status.json",
|
|
tmp_path / "seat_bridge.py",
|
|
required_version="runtime-test-v1",
|
|
python_executable=tmp_path / "python.exe",
|
|
zlg_bridge_script=tmp_path / "zlg_bridge.py",
|
|
dll_path=tmp_path / "zlgcan.dll",
|
|
device_type=41,
|
|
device_index=0,
|
|
channels=[ZlgChannelConfig(channel=0, frame_format="classic_can")],
|
|
seat_channel=0,
|
|
firmware_programmer=programmer,
|
|
firmware_auto_flash=True,
|
|
expected_firmware_revision=15,
|
|
expected_firmware_build_id=16,
|
|
firmware_boot_wait_s=0,
|
|
protocol_probe_timeout_s=0.01,
|
|
startup_timeout_s=0.2,
|
|
)
|
|
manager.status_path.write_text(
|
|
json.dumps(_qualified_runtime_status(build_id=15)), encoding="utf-8"
|
|
)
|
|
manager.ensure_async = lambda: None
|
|
manager._stop_runtime_owner = lambda: None
|
|
manager._attempt_start = lambda: manager.status_path.write_text(
|
|
json.dumps(_qualified_runtime_status()), encoding="utf-8"
|
|
)
|
|
|
|
status = manager.ensure_seat_protocol()
|
|
|
|
assert status["firmware_identity"]["firmware_build_id"] == 16
|
|
assert programmer.calls == 1
|
|
assert manager.snapshot()["firmware_recovery_count"] == 1
|