feat: integrate calibration extension and self-repair modules
This commit is contained in:
+124
-12
@@ -14,6 +14,10 @@ from unittest.mock import patch
|
||||
|
||||
from cognitive_agent.auth import AuthConfig, AuthRuntime, hash_password
|
||||
from cognitive_agent.application.seat_service import REPOSITORY_ROOT, SeatDemoService
|
||||
from cognitive_agent.application.self_extension import (
|
||||
SelfExtensionService,
|
||||
SimulationGateway,
|
||||
)
|
||||
from cognitive_agent.infrastructure.llm.openai_responses import OpenAIIntentError
|
||||
from cognitive_agent.infrastructure.can.access import HardwareAccessController
|
||||
from cognitive_agent.server import SeatDemoHandler
|
||||
@@ -111,20 +115,32 @@ class SeatWebApiTests(unittest.TestCase):
|
||||
def test_static_console_and_runtime_endpoint(self) -> None:
|
||||
with self.opener.open(f"{self.base_url}/", timeout=3) as response:
|
||||
html = response.read().decode("utf-8")
|
||||
with self.opener.open(f"{self.base_url}/app.js?v=seat-hil-42", timeout=3) as response:
|
||||
with self.opener.open(f"{self.base_url}/app.js?v=seat-hil-43", timeout=3) as response:
|
||||
app_script = response.read().decode("utf-8")
|
||||
with self.opener.open(
|
||||
f"{self.base_url}/gesture-control.js?v=android-pad-5", timeout=3
|
||||
f"{self.base_url}/gesture-control.js?v=android-pad-6", timeout=3
|
||||
) as response:
|
||||
gesture_script = response.read().decode("utf-8")
|
||||
with self.opener.open(
|
||||
f"{self.base_url}/console-v2.js?v=desktop-refactor-16", timeout=3
|
||||
f"{self.base_url}/console-v2.js?v=desktop-refactor-28", timeout=3
|
||||
) as response:
|
||||
console_script = response.read().decode("utf-8")
|
||||
with self.opener.open(
|
||||
f"{self.base_url}/console-v2.css?v=desktop-refactor-56", timeout=3
|
||||
f"{self.base_url}/console-v2.css?v=desktop-refactor-68", timeout=3
|
||||
) as response:
|
||||
console_styles = response.read().decode("utf-8")
|
||||
with self.opener.open(
|
||||
f"{self.base_url}/self-extension.js?v=extension-6", timeout=3
|
||||
) as response:
|
||||
extension_script = response.read().decode("utf-8")
|
||||
with self.opener.open(
|
||||
f"{self.base_url}/self-extension.css?v=extension-9", timeout=3
|
||||
) as response:
|
||||
extension_styles = response.read().decode("utf-8")
|
||||
with self.opener.open(
|
||||
f"{self.base_url}/api/extension/health", timeout=3
|
||||
) as response:
|
||||
extension_health = json.loads(response.read().decode("utf-8"))
|
||||
with self.opener.open(f"{self.base_url}/api/runtime", timeout=3) as response:
|
||||
runtime = json.loads(response.read().decode("utf-8"))
|
||||
|
||||
@@ -134,19 +150,40 @@ class SeatWebApiTests(unittest.TestCase):
|
||||
self.assertIn("整车孪生界面", html)
|
||||
self.assertIn("<strong>阅读灯</strong>", html)
|
||||
self.assertNotIn("透明车体 · 实体设备", html)
|
||||
self.assertIn('src="/app.js?v=seat-hil-42"', html)
|
||||
self.assertIn('src="/gesture-control.js?v=android-pad-5"', html)
|
||||
self.assertIn('src="/app.js?v=seat-hil-43"', html)
|
||||
self.assertIn('src="/gesture-control.js?v=android-pad-6"', html)
|
||||
self.assertNotIn('class="vehicle-system-hud"', html)
|
||||
self.assertIn('src="/console-v2.js?v=desktop-refactor-16"', html)
|
||||
self.assertIn('href="/console-v2.css?v=desktop-refactor-56"', html)
|
||||
self.assertIn('src="/console-v2.js?v=desktop-refactor-28"', html)
|
||||
self.assertIn('href="/console-v2.css?v=desktop-refactor-68"', html)
|
||||
self.assertIn('src="/self-extension.js?v=extension-6"', html)
|
||||
self.assertIn('href="/self-extension.css?v=extension-9"', html)
|
||||
self.assertNotIn("滚动查看更多", html)
|
||||
self.assertIn('aria-label="执行车舱控制指令"', html)
|
||||
self.assertIn('sessionStorage.getItem("cognitive-agent.demo-role")', console_script)
|
||||
self.assertIn('id="v2SidebarToggle"', console_script)
|
||||
self.assertIn('class="field-label">手势模型</span>', console_script)
|
||||
self.assertIn('id = "v2ModuleSwitcher"', console_script)
|
||||
self.assertIn('label: "自编排", available: true', console_script)
|
||||
self.assertIn('id: "self-calibration"', console_script)
|
||||
self.assertIn('label: "自标定"', console_script)
|
||||
self.assertIn("adminOnly: true", console_script)
|
||||
self.assertIn(
|
||||
"$('.v2-module-tab[data-module=\"self-calibration\"]')",
|
||||
console_script,
|
||||
)
|
||||
self.assertIn('label: "自扩展", available: true', console_script)
|
||||
self.assertIn('aria-label="自扩展面向所有登录用户"', console_script)
|
||||
self.assertIn('<span>Hi,</span><strong>user</strong>', console_script)
|
||||
self.assertIn('id: "self-repair"', console_script)
|
||||
self.assertIn('label: "自修复"', console_script)
|
||||
self.assertIn('roleSelect.disabled = ui.activeModule === "self-repair"', console_script)
|
||||
self.assertNotIn('label: "自诊断"', console_script)
|
||||
self.assertIn('<small>手势模型</small>', console_script)
|
||||
self.assertIn('id="v2GestureModelValue">MediaPipe GR</strong>', console_script)
|
||||
self.assertIn('id="v2GestureModelNote">Tasks Vision · 0.10.14</small>', console_script)
|
||||
self.assertIn('cameraStage.append(cameraToggle)', console_script)
|
||||
self.assertIn('<span class="v2-field-caption">配置信息</span>', console_script)
|
||||
self.assertIn('state.id = "v2VehicleState"', console_script)
|
||||
self.assertIn('fetch("/api/extension/health"', console_script)
|
||||
self.assertIn('actionRail.append(cameraToggle)', console_script)
|
||||
self.assertIn("function enhanceConfiguredDropdowns()", console_script)
|
||||
self.assertIn("function updateModeEdgeHints()", console_script)
|
||||
self.assertIn('classList.toggle("v2-twin-active", definition.index === 0)', console_script)
|
||||
@@ -154,10 +191,23 @@ class SeatWebApiTests(unittest.TestCase):
|
||||
self.assertIn('userTargetText.textContent = "真实台架尚未就绪"', console_script)
|
||||
self.assertIn('grid-template-columns: clamp(245px, 18.5vw, 310px)', console_styles)
|
||||
self.assertIn('.v2-control-switch {', console_styles)
|
||||
self.assertIn('.v2-module-switcher {', console_styles)
|
||||
self.assertIn('grid-template-columns: repeat(4, minmax(0, 1fr))', console_styles)
|
||||
self.assertIn('margin-inline: calc(0px - var(--v2-main-pad))', console_styles)
|
||||
self.assertIn('background: color-mix(in srgb, var(--v2-accent) 30%, var(--v2-accent-soft))', console_styles)
|
||||
self.assertIn('width: clamp(48px, 45%, 72px)', console_styles)
|
||||
self.assertIn('font-weight: 800', console_styles)
|
||||
self.assertIn('width: clamp(300px, 28vw, 360px)', console_styles)
|
||||
self.assertIn('white-space: nowrap', console_styles)
|
||||
self.assertIn('.v2-gesture-model-field', console_styles)
|
||||
self.assertIn('.v2-inline-gesture .v2-camera-toggle', console_styles)
|
||||
self.assertIn('[data-demo-role="user"] .v2-inline-gesture .gesture-actions', console_styles)
|
||||
self.assertIn('.v2-inline-gesture .v2-gesture-action-rail', console_styles)
|
||||
self.assertIn('grid-template-rows: 42px 10px 42px', console_styles)
|
||||
self.assertIn('background: var(--v2-accent);', console_styles)
|
||||
self.assertIn('[data-demo-role="user"] .v2-inline-gesture #gestureArmButton svg', console_styles)
|
||||
self.assertIn('.v2-inline-gesture #gestureStopButton', console_styles)
|
||||
self.assertIn('.v2-inline-gesture .v2-gesture-result-stack', console_styles)
|
||||
self.assertIn('.v2-inline-gesture .v2-gesture-execution-slot', console_styles)
|
||||
self.assertIn('grid-template-columns: 1fr;', console_styles)
|
||||
self.assertIn('.v2-page-rail', console_styles)
|
||||
self.assertIn('.v2-dropdown-menu', console_styles)
|
||||
@@ -171,8 +221,12 @@ class SeatWebApiTests(unittest.TestCase):
|
||||
self.assertIn('.joint-twin-shell .domain-readouts', console_styles)
|
||||
self.assertIn('grid-template-columns: repeat(2, minmax(0, 1fr))', console_styles)
|
||||
self.assertIn('.v2-config-section > .v2-field-caption', console_styles)
|
||||
self.assertIn('.console-v2 .v2-config-card {', console_styles)
|
||||
self.assertIn('.console-v2.v2-sidebar-collapsed .workspace', console_styles)
|
||||
self.assertIn('id="hardwareAccessField"', html)
|
||||
self.assertNotIn('id="hardwareAccessField"', html)
|
||||
self.assertNotIn('id="hardwareClientLabel"', html)
|
||||
self.assertNotIn('id="requestHardwareAccess"', html)
|
||||
self.assertNotIn('id="releaseHardwareAccess"', html)
|
||||
self.assertIn('const dynamicModeOrder = ["rocking_horse", "dynamic_cradle"]', app_script)
|
||||
self.assertIn('divider.className = "mode-group-divider"', app_script)
|
||||
self.assertIn("function extractReferenceWindowPanes", app_script)
|
||||
@@ -189,6 +243,37 @@ class SeatWebApiTests(unittest.TestCase):
|
||||
self.assertIn("function styleReferencePassiveRearExterior", app_script)
|
||||
self.assertIn('twinRole: "reference_passive_rear_bumper"', app_script)
|
||||
self.assertIn('"cognitive-agent:demo-role-change"', console_script)
|
||||
self.assertIn('resultStack.id = "v2GestureResultStack"', console_script)
|
||||
self.assertIn('executionSlot.id = "v2GestureExecutionSlot"', console_script)
|
||||
self.assertIn('statusTarget.append(requestStatus)', console_script)
|
||||
self.assertIn('root.id = "v2SelfExtension"', extension_script)
|
||||
self.assertIn('data-extension-mode="study"', extension_script)
|
||||
self.assertIn('data-extension-mode="control"', extension_script)
|
||||
self.assertIn('外设玩具能力一览与学习', extension_script)
|
||||
self.assertIn('说一句,联动外设玩具与车端设备', extension_script)
|
||||
self.assertIn('id="extensionExecuteButton"', extension_script)
|
||||
self.assertIn('data-lucide="play"', extension_script)
|
||||
self.assertNotIn('id="extensionControlHealth"', extension_script)
|
||||
self.assertIn('data-extension-connected="${Boolean(selected)}"', extension_script)
|
||||
self.assertIn('<small>红外学习器</small>', extension_script)
|
||||
self.assertIn('id="extensionStudyHealthState"', extension_script)
|
||||
self.assertIn('id="extensionVehicleState">CHECK</span>', extension_script)
|
||||
self.assertIn('id="extensionResetButton"', extension_script)
|
||||
self.assertNotIn('id="extensionConnectionButton"', extension_script)
|
||||
self.assertNotIn('id="extensionStatusDialog"', extension_script)
|
||||
self.assertIn('/api/extension/${path}', extension_script)
|
||||
self.assertIn('[data-module="self-extension"]', extension_styles)
|
||||
self.assertIn('[data-module="self-extension"] .v2-role-select', extension_styles)
|
||||
self.assertIn('.v2-extension-composer .primary-action {', extension_styles)
|
||||
self.assertIn('--v2-accent: #0e7773', extension_styles)
|
||||
self.assertIn('.v2-sidebar-collapsed[data-module="self-extension"] .v2-extension-sidebar', extension_styles)
|
||||
self.assertIn('.v2-extension-capability span {', extension_styles)
|
||||
self.assertIn('.v2-extension-vehicle-card > div button {', extension_styles)
|
||||
self.assertIn('.v2-extension-type-card[data-extension-connected="true"]', extension_styles)
|
||||
self.assertIn('.v2-extension-type-card[data-extension-connected="false"]', extension_styles)
|
||||
self.assertIn('grid-template-columns: repeat(3, minmax(0, 1fr))', extension_styles)
|
||||
self.assertTrue(extension_health["success"])
|
||||
self.assertIn(extension_health["transport"], {"tcp", "mqtt", "simulation"})
|
||||
self.assertIn("function emphasizeReferenceFrontLampMeshes", app_script)
|
||||
self.assertIn("function createReferenceFrontLampHighlights", app_script)
|
||||
self.assertIn("referenceFrontLampMaterials", app_script)
|
||||
@@ -204,6 +289,7 @@ class SeatWebApiTests(unittest.TestCase):
|
||||
self.assertIn('case "OverconstrainedError":', gesture_script)
|
||||
self.assertIn("function sendSimulatedGesture()", gesture_script)
|
||||
self.assertIn('document.body.dataset.demoRole !== "admin"', gesture_script)
|
||||
self.assertIn("function syncGestureDebugRole", gesture_script)
|
||||
self.assertIn("HARDWARE_REQUEST_TIMEOUT_MS = 15 * 60 * 1000", app_script)
|
||||
self.assertIn(
|
||||
"requestTimeoutForExecutionMode(state.executionMode)", app_script
|
||||
@@ -296,6 +382,32 @@ class SeatWebApiTests(unittest.TestCase):
|
||||
all(item["target_scopes"] == ["passenger"] for item in seat_capabilities)
|
||||
)
|
||||
|
||||
def test_self_extension_gateway_uses_unified_authenticated_service(self) -> None:
|
||||
original_extension = SeatDemoHandler.extension
|
||||
SeatDemoHandler.extension = SelfExtensionService(SimulationGateway())
|
||||
try:
|
||||
with self.opener.open(
|
||||
f"{self.base_url}/api/extension/health", timeout=3
|
||||
) as response:
|
||||
health = json.loads(response.read().decode("utf-8"))
|
||||
request = urllib.request.Request(
|
||||
f"{self.base_url}/api/extension/gscu/emit",
|
||||
method="POST",
|
||||
headers={"Content-Type": "application/json"},
|
||||
data=json.dumps({"slot": 9}).encode("utf-8"),
|
||||
)
|
||||
with self.opener.open(request, timeout=3) as response:
|
||||
result = json.loads(response.read().decode("utf-8"))
|
||||
finally:
|
||||
SeatDemoHandler.extension = original_extension
|
||||
|
||||
self.assertTrue(health["success"])
|
||||
self.assertEqual(health["transport"], "simulation")
|
||||
self.assertTrue(health["connected"])
|
||||
self.assertTrue(result["success"])
|
||||
self.assertEqual(result["transport"], "simulation")
|
||||
self.assertFalse(result["physical_state_confirmed"])
|
||||
|
||||
def test_post_request_returns_complete_dry_run(self) -> None:
|
||||
request = urllib.request.Request(
|
||||
f"{self.base_url}/api/requests",
|
||||
|
||||
@@ -0,0 +1,163 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import tempfile
|
||||
import threading
|
||||
import urllib.error
|
||||
import urllib.request
|
||||
from http.server import ThreadingHTTPServer
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
from cognitive_agent.application.calibration.service import CalibrationService
|
||||
from cognitive_agent.auth import AuthConfig, AuthRuntime
|
||||
from cognitive_agent.server import REPOSITORY_ROOT, SeatDemoHandler
|
||||
|
||||
|
||||
def _auth_disabled() -> AuthRuntime:
|
||||
return AuthRuntime(
|
||||
AuthConfig(
|
||||
enabled=False,
|
||||
username="",
|
||||
password_hash="",
|
||||
session_secret=b"",
|
||||
session_ttl_seconds=3600,
|
||||
login_attempts=5,
|
||||
login_window_seconds=300,
|
||||
requests_per_minute=120,
|
||||
trust_proxy_headers=False,
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
class TestSelfCalibrationApi:
|
||||
@classmethod
|
||||
def setup_class(cls) -> None:
|
||||
cls.temporary_directory = tempfile.TemporaryDirectory()
|
||||
cls.calibration = CalibrationService(
|
||||
Path(cls.temporary_directory.name),
|
||||
repository_root=REPOSITORY_ROOT,
|
||||
)
|
||||
cls.calibration.start()
|
||||
cls.original_calibration = SeatDemoHandler.calibration
|
||||
cls.original_auth = SeatDemoHandler.auth
|
||||
SeatDemoHandler.calibration = cls.calibration
|
||||
SeatDemoHandler.auth = _auth_disabled()
|
||||
cls.server = ThreadingHTTPServer(("127.0.0.1", 0), SeatDemoHandler)
|
||||
cls.thread = threading.Thread(target=cls.server.serve_forever, daemon=True)
|
||||
cls.thread.start()
|
||||
cls.base_url = f"http://127.0.0.1:{cls.server.server_port}"
|
||||
|
||||
@classmethod
|
||||
def teardown_class(cls) -> None:
|
||||
cls.server.shutdown()
|
||||
cls.server.server_close()
|
||||
cls.thread.join(timeout=2)
|
||||
cls.calibration.close()
|
||||
SeatDemoHandler.calibration = cls.original_calibration
|
||||
SeatDemoHandler.auth = cls.original_auth
|
||||
cls.temporary_directory.cleanup()
|
||||
|
||||
def request(
|
||||
self,
|
||||
path: str,
|
||||
*,
|
||||
method: str = "GET",
|
||||
payload: dict[str, object] | None = None,
|
||||
role: str | None = "admin",
|
||||
) -> urllib.request.Request:
|
||||
headers = {"Accept": "application/json"}
|
||||
data = None
|
||||
if role is not None:
|
||||
headers["X-Cognitive-Agent-Role"] = role
|
||||
if payload is not None:
|
||||
headers["Content-Type"] = "application/json"
|
||||
data = json.dumps(payload).encode("utf-8")
|
||||
return urllib.request.Request(
|
||||
f"{self.base_url}{path}",
|
||||
method=method,
|
||||
headers=headers,
|
||||
data=data,
|
||||
)
|
||||
|
||||
def test_admin_gate_protects_calibration_api(self) -> None:
|
||||
with pytest.raises(urllib.error.HTTPError) as caught:
|
||||
urllib.request.urlopen(
|
||||
self.request("/api/calibration/state", role="user"),
|
||||
timeout=3,
|
||||
)
|
||||
assert caught.value.code == 403
|
||||
body = json.loads(caught.value.read().decode("utf-8"))
|
||||
assert body["error"] == "admin_required"
|
||||
|
||||
def test_simulation_connect_move_and_stop(self) -> None:
|
||||
with urllib.request.urlopen(
|
||||
self.request(
|
||||
"/api/calibration/can/connect",
|
||||
method="POST",
|
||||
payload={"can": {"mode": "simulation"}},
|
||||
),
|
||||
timeout=3,
|
||||
) as response:
|
||||
connected = json.loads(response.read().decode("utf-8"))["state"]
|
||||
assert connected["transport"]["state"] == "online"
|
||||
assert [axis["range"] for axis in connected["axes"]] == [480, 647, 917, 373]
|
||||
|
||||
with urllib.request.urlopen(
|
||||
self.request(
|
||||
"/api/calibration/move",
|
||||
method="POST",
|
||||
payload={"axis": 2, "permille": 650},
|
||||
),
|
||||
timeout=3,
|
||||
) as response:
|
||||
moving = json.loads(response.read().decode("utf-8"))["state"]
|
||||
assert moving["controller"]["armed"]
|
||||
|
||||
with urllib.request.urlopen(
|
||||
self.request("/api/calibration/stop", method="POST", payload={}),
|
||||
timeout=3,
|
||||
) as response:
|
||||
stopped = json.loads(response.read().decode("utf-8"))["state"]
|
||||
assert not stopped["controller"]["armed"]
|
||||
|
||||
def test_frontend_and_original_model_are_served_by_existing_service(self) -> None:
|
||||
with urllib.request.urlopen(f"{self.base_url}/", timeout=3) as response:
|
||||
html = response.read().decode("utf-8")
|
||||
with urllib.request.urlopen(
|
||||
f"{self.base_url}/self-calibration.js?v=calibration-2",
|
||||
timeout=3,
|
||||
) as response:
|
||||
script = response.read().decode("utf-8")
|
||||
with urllib.request.urlopen(
|
||||
f"{self.base_url}/self-calibration.css?v=calibration-4",
|
||||
timeout=3,
|
||||
) as response:
|
||||
styles = response.read().decode("utf-8")
|
||||
with urllib.request.urlopen(
|
||||
f"{self.base_url}/console-v2.js?v=desktop-refactor-28",
|
||||
timeout=3,
|
||||
) as response:
|
||||
console_script = response.read().decode("utf-8")
|
||||
with urllib.request.urlopen(
|
||||
f"{self.base_url}/assets/models/n7_seat.glb",
|
||||
timeout=3,
|
||||
) as response:
|
||||
model = response.read()
|
||||
content_type = response.headers["Content-Type"]
|
||||
content_security_policy = response.headers["Content-Security-Policy"]
|
||||
|
||||
assert 'href="/self-calibration.css?v=calibration-4"' in html
|
||||
assert 'src="/self-calibration.js?v=calibration-2"' in html
|
||||
assert "标定仿真台架" in script
|
||||
assert "X-Cognitive-Agent-Role" in script
|
||||
assert "--v2-sidebar: #dae3f5" in styles
|
||||
assert "--v2-sidebar-deep: #b6c7ea" in styles
|
||||
assert "--v2-accent-strong: #1e386b" in styles
|
||||
assert '["self-calibration", "self-repair"].includes(module)' in console_script
|
||||
assert 'setRole("admin")' in console_script
|
||||
assert len(model) == 845_992
|
||||
assert content_type.startswith("model/gltf-binary")
|
||||
assert "img-src 'self' data: blob:;" in content_security_policy
|
||||
assert "connect-src 'self' blob:;" in content_security_policy
|
||||
@@ -0,0 +1,174 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import threading
|
||||
import urllib.error
|
||||
import urllib.request
|
||||
from http.server import ThreadingHTTPServer
|
||||
|
||||
import pytest
|
||||
|
||||
from cognitive_agent.application.self_repair import SelfRepairService
|
||||
from cognitive_agent.auth import AuthConfig, AuthRuntime
|
||||
from cognitive_agent.server import SeatDemoHandler
|
||||
|
||||
|
||||
def _auth_disabled() -> AuthRuntime:
|
||||
return AuthRuntime(
|
||||
AuthConfig(
|
||||
enabled=False,
|
||||
username="",
|
||||
password_hash="",
|
||||
session_secret=b"",
|
||||
session_ttl_seconds=3600,
|
||||
login_attempts=5,
|
||||
login_window_seconds=300,
|
||||
requests_per_minute=120,
|
||||
trust_proxy_headers=False,
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
class TestSelfRepairApi:
|
||||
@classmethod
|
||||
def setup_class(cls) -> None:
|
||||
cls.repair = SelfRepairService()
|
||||
cls.original_repair = SeatDemoHandler.repair
|
||||
cls.original_auth = SeatDemoHandler.auth
|
||||
SeatDemoHandler.repair = cls.repair
|
||||
SeatDemoHandler.auth = _auth_disabled()
|
||||
cls.server = ThreadingHTTPServer(("127.0.0.1", 0), SeatDemoHandler)
|
||||
cls.thread = threading.Thread(target=cls.server.serve_forever, daemon=True)
|
||||
cls.thread.start()
|
||||
cls.base_url = f"http://127.0.0.1:{cls.server.server_port}"
|
||||
|
||||
@classmethod
|
||||
def teardown_class(cls) -> None:
|
||||
cls.server.shutdown()
|
||||
cls.server.server_close()
|
||||
cls.thread.join(timeout=2)
|
||||
SeatDemoHandler.repair = cls.original_repair
|
||||
SeatDemoHandler.auth = cls.original_auth
|
||||
|
||||
def request(
|
||||
self,
|
||||
path: str,
|
||||
*,
|
||||
method: str = "GET",
|
||||
payload: dict[str, object] | None = None,
|
||||
role: str | None = "admin",
|
||||
) -> urllib.request.Request:
|
||||
headers = {"Accept": "application/json"}
|
||||
data = None
|
||||
if role is not None:
|
||||
headers["X-Cognitive-Agent-Role"] = role
|
||||
if payload is not None:
|
||||
headers["Content-Type"] = "application/json"
|
||||
data = json.dumps(payload).encode("utf-8")
|
||||
return urllib.request.Request(
|
||||
f"{self.base_url}{path}", method=method, headers=headers, data=data
|
||||
)
|
||||
|
||||
def test_user_cannot_call_self_repair_api(self) -> None:
|
||||
for request in (
|
||||
self.request("/api/self-repair/state", role="user"),
|
||||
self.request(
|
||||
"/api/self-repair/healing/start",
|
||||
method="POST",
|
||||
payload={"case_id": "FI-FIELD-01", "execution_mode": "shadow"},
|
||||
role="user",
|
||||
),
|
||||
):
|
||||
with pytest.raises(urllib.error.HTTPError) as caught:
|
||||
urllib.request.urlopen(request, timeout=3)
|
||||
assert caught.value.code == 403
|
||||
body = json.loads(caught.value.read().decode("utf-8"))
|
||||
assert body["error"] == "admin_required"
|
||||
|
||||
def test_admin_can_read_and_start_shadow_repair(self) -> None:
|
||||
with urllib.request.urlopen(
|
||||
self.request("/api/self-repair/state"), timeout=3
|
||||
) as response:
|
||||
state = json.loads(response.read().decode("utf-8"))
|
||||
assert state["schema"] == "seat-self-healing-console-state-v1"
|
||||
assert len(state["scenarios"]) == 6
|
||||
assert state["hardware"]["connector_enabled_count"] == 21
|
||||
|
||||
with urllib.request.urlopen(
|
||||
self.request(
|
||||
"/api/self-repair/healing/start",
|
||||
method="POST",
|
||||
payload={"case_id": "FI-FIELD-01", "execution_mode": "shadow"},
|
||||
),
|
||||
timeout=3,
|
||||
) as response:
|
||||
started = json.loads(response.read().decode("utf-8"))["healing"]
|
||||
assert started["case_id"] == "FI-FIELD-01"
|
||||
assert started["execution_mode"] == "shadow"
|
||||
assert started["injection_scope"] == "SHADOW_TELEMETRY_ONLY"
|
||||
|
||||
with urllib.request.urlopen(
|
||||
self.request(
|
||||
"/api/self-repair/healing/reset",
|
||||
method="POST",
|
||||
payload={"execution_mode": "shadow"},
|
||||
),
|
||||
timeout=3,
|
||||
) as response:
|
||||
reset = json.loads(response.read().decode("utf-8"))
|
||||
assert reset["ok"] is True
|
||||
assert reset["healing"]["phase"] == "IDLE"
|
||||
|
||||
def test_rule_self_test_is_available_without_hardware(self) -> None:
|
||||
with urllib.request.urlopen(
|
||||
self.request(
|
||||
"/api/self-repair/hardware/self-test", method="POST", payload={}
|
||||
),
|
||||
timeout=5,
|
||||
) as response:
|
||||
result = json.loads(response.read().decode("utf-8"))
|
||||
assert result["ok"] is True
|
||||
assert result["self_test"]["result"] == "PASS"
|
||||
|
||||
def test_frontend_assets_and_role_transition_are_served(self) -> None:
|
||||
with urllib.request.urlopen(f"{self.base_url}/", timeout=3) as response:
|
||||
html = response.read().decode("utf-8")
|
||||
with urllib.request.urlopen(
|
||||
f"{self.base_url}/self-repair.js?v=repair-15", timeout=3
|
||||
) as response:
|
||||
script = response.read().decode("utf-8")
|
||||
with urllib.request.urlopen(
|
||||
f"{self.base_url}/self-repair.css?v=repair-15", timeout=3
|
||||
) as response:
|
||||
styles = response.read().decode("utf-8")
|
||||
with urllib.request.urlopen(
|
||||
f"{self.base_url}/self-repair-seat3d.js?v=repair-15", timeout=3
|
||||
) as response:
|
||||
seat3d = response.read().decode("utf-8")
|
||||
with urllib.request.urlopen(
|
||||
f"{self.base_url}/console-v2.js?v=desktop-refactor-28", timeout=3
|
||||
) as response:
|
||||
console_script = response.read().decode("utf-8")
|
||||
|
||||
assert 'href="/self-repair.css?v=repair-15"' in html
|
||||
assert 'src="/self-repair.js?v=repair-15"' in html
|
||||
assert 'src="/self-repair-seat3d.js?v=repair-15"' in html
|
||||
assert "量产故障用例" in script
|
||||
assert "connectorRows" in script
|
||||
assert "Polling refreshes data only" in script
|
||||
assert "renderRecords();\n showView(activeView);" not in script
|
||||
assert "--repair-deep-blue" in styles
|
||||
assert '.console-v2[data-module="self-repair"] .section-kicker' in styles
|
||||
assert 'grid-template-areas: "twin cases timeline"' in styles
|
||||
assert "background: color-mix(in srgb, var(--v2-accent-soft) 82%, white)" in styles
|
||||
assert ".v2-repair-toolbar button.primary:hover:not(:disabled)" in styles
|
||||
assert "color: #fff;" in styles
|
||||
assert "seat-healing-state" in seat3d
|
||||
assert "function backrestShape" in seat3d
|
||||
assert "new THREE.CapsuleGeometry" in seat3d
|
||||
assert 'cssThemeColor("--v2-accent", "#315eaa")' in seat3d
|
||||
assert 'viewer.addEventListener("pointerdown"' in seat3d
|
||||
assert 'window.addEventListener("pointermove"' in seat3d
|
||||
assert 'window.addEventListener("self-repair-seat-reset"' in seat3d
|
||||
assert '["self-calibration", "self-repair"].includes(module)' in console_script
|
||||
assert 'roleSelect.disabled = ui.activeModule === "self-repair"' in console_script
|
||||
@@ -0,0 +1,91 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import tempfile
|
||||
import time
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
from cognitive_agent.application.calibration import protocol
|
||||
from cognitive_agent.application.calibration.service import CalibrationService
|
||||
|
||||
|
||||
def test_protocol_matches_android_golden_vectors() -> None:
|
||||
assert protocol.crc8(b"123456789") == 0x4B
|
||||
vectors = (
|
||||
(
|
||||
protocol.command(0x00, protocol.OP_HELLO, protocol.AXIS_ALL),
|
||||
"A1 00 01 FF 00 00 00 55",
|
||||
),
|
||||
(
|
||||
protocol.command(0x01, protocol.OP_ARM, protocol.AXIS_ALL, 0x4E37),
|
||||
"A1 01 02 FF 37 4E 00 0A",
|
||||
),
|
||||
(
|
||||
protocol.command(0x23, protocol.OP_CALIBRATE, protocol.AXIS_ALL),
|
||||
"A1 23 10 FF 00 00 00 33",
|
||||
),
|
||||
(
|
||||
protocol.command(0x24, protocol.OP_MOVE_PERMILLE, 0, 1000),
|
||||
"A1 24 11 00 E8 03 00 78",
|
||||
),
|
||||
(
|
||||
protocol.command(0x25, protocol.OP_STOP, protocol.AXIS_ALL),
|
||||
"A1 25 12 FF 00 00 00 6E",
|
||||
),
|
||||
)
|
||||
for actual, expected in vectors:
|
||||
assert actual == bytes.fromhex(expected)
|
||||
|
||||
|
||||
def test_protocol_parses_status_and_rejects_bad_crc() -> None:
|
||||
ack = protocol.parse(0x621, bytes.fromhex("B1 22 10 02 00 07 01 A9"))
|
||||
assert isinstance(ack, protocol.Ack)
|
||||
assert ack.accepted
|
||||
|
||||
controller = protocol.parse(0x622, bytes.fromhex("C1 01 00 17 07 FF 02 4E"))
|
||||
assert isinstance(controller, protocol.ControllerStatus)
|
||||
assert controller.can_ready
|
||||
assert controller.motor_ready
|
||||
assert controller.armed
|
||||
|
||||
frame = bytearray.fromhex("D1 00 F0 00 E0 01 17 40")
|
||||
frame[2] ^= 1
|
||||
with pytest.raises(protocol.ProtocolError):
|
||||
protocol.parse(0x630, bytes(frame))
|
||||
|
||||
|
||||
def test_simulation_uses_real_wire_boundary_and_generates_pose() -> None:
|
||||
with tempfile.TemporaryDirectory() as directory:
|
||||
service = CalibrationService(Path(directory))
|
||||
service.start()
|
||||
try:
|
||||
initial = service.snapshot()
|
||||
assert initial["transport"] == {
|
||||
"mode": "hardware",
|
||||
"state": "disconnected",
|
||||
"detail": "尚未连接",
|
||||
}
|
||||
|
||||
connected = service.connect_can({"mode": "simulation"})
|
||||
assert connected["transport"]["state"] == "online"
|
||||
assert connected["controller"]["can_ready"]
|
||||
assert connected["controller"]["motor_ready"]
|
||||
assert [axis["range"] for axis in connected["axes"]] == [480, 647, 917, 373]
|
||||
|
||||
service.move(0, 800)
|
||||
time.sleep(0.2)
|
||||
moving = service.snapshot()
|
||||
assert moving["controller"]["armed"]
|
||||
assert moving["axes"][0]["moving"]
|
||||
|
||||
time.sleep(0.45)
|
||||
pose = service.snapshot()["mqtt"]
|
||||
assert pose["state"] == "simulated"
|
||||
assert pose["count"] >= 1
|
||||
assert "Seat_cushion" in pose["areas"]
|
||||
|
||||
stopped = service.stop()
|
||||
assert not stopped["controller"]["armed"]
|
||||
finally:
|
||||
service.close()
|
||||
@@ -0,0 +1,120 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import os
|
||||
import unittest
|
||||
from unittest.mock import Mock, patch
|
||||
|
||||
from cognitive_agent.application.self_extension import (
|
||||
GatewayError,
|
||||
SelfExtensionService,
|
||||
SimulationGateway,
|
||||
build_gateway_command,
|
||||
validate_slot,
|
||||
)
|
||||
|
||||
|
||||
class SelfExtensionTests(unittest.TestCase):
|
||||
def test_gateway_command_contracts_and_slot_validation(self) -> None:
|
||||
command = build_gateway_command("learn", {"slot": 17})
|
||||
self.assertEqual(command["type"], "infrared")
|
||||
self.assertEqual(command["action"], "learn")
|
||||
self.assertEqual(command["data"], {"no": 17})
|
||||
self.assertTrue(str(command["messageId"]).startswith("bridge-"))
|
||||
|
||||
self.assertEqual(validate_slot("248"), 248)
|
||||
with self.assertRaises(GatewayError):
|
||||
validate_slot(0)
|
||||
with self.assertRaises(GatewayError):
|
||||
build_gateway_command("erase", {"confirm": "wrong"})
|
||||
|
||||
def test_simulation_gateway_executes_without_physical_confirmation(self) -> None:
|
||||
service = SelfExtensionService(SimulationGateway())
|
||||
result = service.execute_gateway("emit", {"slot": 3, "repeat": 2})
|
||||
|
||||
self.assertTrue(result["success"])
|
||||
self.assertEqual(result["transport"], "simulation")
|
||||
self.assertFalse(result["physical_state_confirmed"])
|
||||
self.assertEqual(len(result["device_response"]), 2)
|
||||
|
||||
def test_plan_only_accepts_learned_catalog_capabilities(self) -> None:
|
||||
service = SelfExtensionService(SimulationGateway())
|
||||
proposed = {
|
||||
"summary": "先开玩具灯,再打开阅读灯",
|
||||
"needs_confirmation": False,
|
||||
"question": "",
|
||||
"steps": [
|
||||
{
|
||||
"kind": "peripheral",
|
||||
"capability_id": "cap-light-on",
|
||||
"command": "",
|
||||
"label": "ignored",
|
||||
},
|
||||
{
|
||||
"kind": "peripheral",
|
||||
"capability_id": "invented-capability",
|
||||
"command": "",
|
||||
"label": "ignored",
|
||||
},
|
||||
{
|
||||
"kind": "vehicle",
|
||||
"capability_id": "",
|
||||
"command": "打开全部阅读灯",
|
||||
"label": "车内灯光 · 阅读灯",
|
||||
},
|
||||
],
|
||||
}
|
||||
with patch.dict(os.environ, {"OPENAI_API_KEY": "test-key"}, clear=False):
|
||||
with patch.object(
|
||||
service,
|
||||
"_request_gpt_plan",
|
||||
return_value=json.dumps(proposed, ensure_ascii=False),
|
||||
):
|
||||
result = service.plan(
|
||||
{
|
||||
"utterance": "打开玩具灯和阅读灯",
|
||||
"peripherals": [
|
||||
{
|
||||
"id": "cap-light-on",
|
||||
"status": "learned",
|
||||
"device_name": "灯光测试玩具",
|
||||
"action": "开启",
|
||||
"risk": "low",
|
||||
}
|
||||
],
|
||||
"vehicle_devices": [],
|
||||
}
|
||||
)
|
||||
|
||||
self.assertEqual(len(result["steps"]), 2)
|
||||
self.assertEqual(result["steps"][0]["capability_id"], "cap-light-on")
|
||||
self.assertEqual(result["steps"][1]["command"], "打开全部阅读灯")
|
||||
|
||||
def test_vehicle_execution_reuses_the_same_orchestration_service(self) -> None:
|
||||
service = SelfExtensionService(SimulationGateway())
|
||||
vehicle_service = Mock()
|
||||
vehicle_service.process.return_value = {
|
||||
"status": "succeeded",
|
||||
"provider_used": "local_rules",
|
||||
"request_id": "req-extension-1",
|
||||
"decision_summary": ["打开阅读灯"],
|
||||
"validation": [],
|
||||
"execution_report": {"spec": {"results": [{"status": "succeeded"}]}},
|
||||
"deferred_requests": [],
|
||||
}
|
||||
|
||||
result = service.execute_vehicle(
|
||||
{"command": "打开全部阅读灯", "vehicle_id": "denza_n7"},
|
||||
vehicle_service,
|
||||
hardware_access_bypass=True,
|
||||
)
|
||||
|
||||
self.assertTrue(result["success"])
|
||||
self.assertEqual(result["request_id"], "req-extension-1")
|
||||
vehicle_service.process.assert_called_once()
|
||||
self.assertEqual(vehicle_service.process.call_args.args[0], "打开全部阅读灯")
|
||||
self.assertTrue(vehicle_service.process.call_args.kwargs["hardware_access_bypass"])
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -0,0 +1,83 @@
|
||||
from __future__ import annotations
|
||||
|
||||
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.signal_diagnostic import SignalDiagnosticModel
|
||||
from cognitive_agent.application.self_repair.zcan_bridge import ZcanBridgeManager
|
||||
|
||||
|
||||
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
|
||||
Reference in New Issue
Block a user