feat: add seat firmware workbench and OTA delivery
This commit is contained in:
@@ -75,6 +75,65 @@ async function main() {
|
||||
await page.locator('[data-execution-mode="dry_run"]').click();
|
||||
assert.equal(await page.evaluate(() => window.CognitiveAgentConsole.getExecutionMode()), "dry_run");
|
||||
|
||||
async function submitDraft(text) {
|
||||
const responsePromise = responseFor("/api/assistant/stream");
|
||||
await page.locator("#utterance").fill(text);
|
||||
await page.locator("#utterance").press("Enter");
|
||||
const response = await responsePromise;
|
||||
const body = responses.filter((item) => item.pathname === "/api/assistant/stream").at(-1).text;
|
||||
assert.equal(response.status(), 200, `${text}: ${body}`);
|
||||
const events = body.trim().split("\n").map((line) => JSON.parse(line));
|
||||
await waitIdle();
|
||||
const payload = events.find((event) => event.payload?.mode_profile_draft)?.payload;
|
||||
assert.ok(payload, `Missing draft for ${text}: ${body}`);
|
||||
assert.equal(payload.status, "succeeded");
|
||||
assert.equal(payload.execution_mode, "dry_run");
|
||||
const draft = payload.mode_profile_draft;
|
||||
const card = page.locator(`[data-assistant-mode-draft="${draft.source_request_id}"]`);
|
||||
await card.waitFor({ state: "visible" });
|
||||
assert.equal(await card.locator(".assistant-mode-draft-row").count(), draft.operation_count);
|
||||
return { payload, draft, card, request: posted.filter((item) => item.pathname === "/api/assistant/stream").at(-1).payload };
|
||||
}
|
||||
|
||||
// The original failing two-turn request must revise the server-owned
|
||||
// draft, not attempt immediate hardware execution or replace its seats.
|
||||
const recline = await submitDraft("构建一个躺平模式");
|
||||
const originalActions = recline.payload.actions;
|
||||
await recline.card.scrollIntoViewIfNeeded();
|
||||
await page.screenshot({ path: path.join(evidenceDir, "mode-device-addition-before.png") });
|
||||
const expanded = await submitDraft("在躺平模式中加入空调、氛围灯、阅读灯、车窗等设备动作");
|
||||
assert.deepEqual(expanded.request.mode_profile_draft_context, {
|
||||
name: recline.draft.name,
|
||||
source_request_id: recline.draft.source_request_id,
|
||||
vehicle_id: recline.draft.vehicle_id,
|
||||
});
|
||||
assert.notEqual(expanded.draft.source_request_id, recline.draft.source_request_id);
|
||||
assert.equal(expanded.draft.draft_revision, 2);
|
||||
const actionKey = (action) => `${action.capability}:${action.target?.instance || action.target?.zone || ""}`;
|
||||
const expandedActions = new Map(expanded.payload.actions.map((action) => [actionKey(action), action]));
|
||||
for (const original of originalActions) {
|
||||
assert.deepEqual(expandedActions.get(actionKey(original))?.parameters, original.parameters,
|
||||
"adding devices must preserve all existing action values");
|
||||
}
|
||||
for (const capability of ["climate.set_temperature", "interior_light.set_reading_power", "window.set_position"]) {
|
||||
assert.ok(expanded.payload.actions.some((action) => action.capability === capability), capability);
|
||||
}
|
||||
assert.ok(expanded.payload.actions.some((action) => action.domain === "ambient_light"));
|
||||
assert.ok(expanded.payload.actions.every((action) => action.domain !== "seat"
|
||||
|| (action.target?.instance || action.target?.zone) === "passenger"));
|
||||
assert.ok(expanded.draft.operation_count > recline.draft.operation_count);
|
||||
assert.equal(await recline.card.getAttribute("data-superseded"), "true");
|
||||
assert.equal(await recline.card.locator(".assistant-mode-draft-save").isDisabled(), true);
|
||||
assert.equal(posted.filter((item) => item.pathname === "/api/mode-profiles").length, 0);
|
||||
await expanded.card.scrollIntoViewIfNeeded();
|
||||
await page.screenshot({ path: path.join(evidenceDir, "mode-device-addition-after.png") });
|
||||
const temperatureEdit = await submitDraft("把躺平模式的空调温度调整到26度");
|
||||
assert.equal(temperatureEdit.request.mode_profile_draft_context.source_request_id, expanded.draft.source_request_id);
|
||||
assert.equal(temperatureEdit.draft.draft_revision, 3);
|
||||
assert.equal(temperatureEdit.draft.operation_count, expanded.draft.operation_count);
|
||||
assert.ok(temperatureEdit.payload.actions.some((action) => action.capability === "climate.set_temperature"
|
||||
&& action.parameters.temperature_c === 26));
|
||||
|
||||
// Exercise the same real assistant endpoint and rendered cards for both
|
||||
// implicit passenger selection and an explicit driver request. These
|
||||
// previews stay unsaved, so the timeline save/trial tests remain isolated.
|
||||
|
||||
@@ -0,0 +1,112 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import hashlib
|
||||
import json
|
||||
import threading
|
||||
import urllib.request
|
||||
from http.server import ThreadingHTTPServer
|
||||
from pathlib import Path
|
||||
|
||||
from cognitive_agent.firmware_ota_server import FirmwareOtaHandler, FirmwareOtaRepository
|
||||
|
||||
|
||||
class TestFirmwareOtaServer:
|
||||
def test_latest_download_head_and_resume_are_read_only(self, tmp_path: Path) -> None:
|
||||
release_id = "fw-rel-123456789abc"
|
||||
filename = "PassengerSeat_test.zip"
|
||||
package = (b"vehicle-firmware-package" * 128) + b"end"
|
||||
release_root = tmp_path / "releases" / release_id
|
||||
release_root.mkdir(parents=True)
|
||||
package_path = release_root / filename
|
||||
package_path.write_bytes(package)
|
||||
manifest = {
|
||||
"schema": "n7-ota-release/v1",
|
||||
"release_id": release_id,
|
||||
"firmware_filename": filename,
|
||||
"size_bytes": len(package),
|
||||
"sha256": hashlib.sha256(package).hexdigest(),
|
||||
"capabilities": ["时间控制", "座椅加热"],
|
||||
"published_at": "2026-09-16T13:17:17+08:00",
|
||||
"status": "published",
|
||||
"download_url": "http://stale-address.invalid/download",
|
||||
}
|
||||
(release_root / "release.json").write_text(
|
||||
json.dumps(manifest, ensure_ascii=False), encoding="utf-8"
|
||||
)
|
||||
(tmp_path / "latest.json").write_text(
|
||||
json.dumps(manifest, ensure_ascii=False), encoding="utf-8"
|
||||
)
|
||||
older_release_id = "fw-rel-000000000001"
|
||||
older_root = tmp_path / "releases" / older_release_id
|
||||
older_root.mkdir(parents=True)
|
||||
older_filename = "PassengerSeat_older.zip"
|
||||
older_package = b"older-firmware-package"
|
||||
(older_root / older_filename).write_bytes(older_package)
|
||||
older_manifest = {
|
||||
**manifest,
|
||||
"release_id": older_release_id,
|
||||
"firmware_filename": older_filename,
|
||||
"size_bytes": len(older_package),
|
||||
"sha256": hashlib.sha256(older_package).hexdigest(),
|
||||
"published_at": "2026-09-15T13:17:17+08:00",
|
||||
}
|
||||
(older_root / "release.json").write_text(
|
||||
json.dumps(older_manifest, ensure_ascii=False), encoding="utf-8"
|
||||
)
|
||||
|
||||
class Handler(FirmwareOtaHandler):
|
||||
repository = FirmwareOtaRepository(
|
||||
tmp_path, "https://n7-ota.example.ts.net/api/firmware-workbench/releases"
|
||||
)
|
||||
|
||||
server = ThreadingHTTPServer(("127.0.0.1", 0), Handler)
|
||||
thread = threading.Thread(target=server.serve_forever, daemon=True)
|
||||
thread.start()
|
||||
base_url = f"http://127.0.0.1:{server.server_port}"
|
||||
try:
|
||||
with urllib.request.urlopen(
|
||||
f"{base_url}/api/firmware-workbench/releases/latest", timeout=3
|
||||
) as response:
|
||||
latest = json.loads(response.read().decode("utf-8"))
|
||||
assert latest["download_url"] == (
|
||||
f"https://n7-ota.example.ts.net/api/firmware-workbench/releases/"
|
||||
f"{release_id}/download"
|
||||
)
|
||||
|
||||
with urllib.request.urlopen(
|
||||
f"{base_url}/api/firmware-workbench/releases", timeout=3
|
||||
) as response:
|
||||
releases = json.loads(response.read().decode("utf-8"))
|
||||
assert releases["schema"] == "n7-ota-release-list/v1"
|
||||
assert releases["count"] == 2
|
||||
assert releases["latest_release_id"] == release_id
|
||||
assert [item["release_id"] for item in releases["releases"]] == [
|
||||
release_id,
|
||||
older_release_id,
|
||||
]
|
||||
assert releases["releases"][1]["download_url"].endswith(
|
||||
f"/{older_release_id}/download"
|
||||
)
|
||||
|
||||
request = urllib.request.Request(
|
||||
f"{base_url}/api/firmware-workbench/releases/{release_id}/download",
|
||||
headers={"Range": "bytes=8-31"},
|
||||
)
|
||||
with urllib.request.urlopen(request, timeout=3) as response:
|
||||
assert response.status == 206
|
||||
assert response.headers["Content-Range"] == f"bytes 8-31/{len(package)}"
|
||||
assert response.headers["X-Firmware-SHA256"] == manifest["sha256"]
|
||||
assert response.read() == package[8:32]
|
||||
|
||||
head = urllib.request.Request(
|
||||
f"{base_url}/api/firmware-workbench/releases/{release_id}/download",
|
||||
method="HEAD",
|
||||
)
|
||||
with urllib.request.urlopen(head, timeout=3) as response:
|
||||
assert response.status == 200
|
||||
assert int(response.headers["Content-Length"]) == len(package)
|
||||
assert response.read() == b""
|
||||
finally:
|
||||
server.shutdown()
|
||||
server.server_close()
|
||||
thread.join(timeout=3)
|
||||
@@ -0,0 +1,167 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import tempfile
|
||||
import threading
|
||||
import time
|
||||
import urllib.request
|
||||
from http.server import ThreadingHTTPServer
|
||||
from pathlib import Path
|
||||
|
||||
from cognitive_agent.application.firmware_workbench import FirmwareWorkbenchService
|
||||
from cognitive_agent.auth import AuthConfig, AuthRuntime
|
||||
from cognitive_agent.server import SeatDemoHandler
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[2]
|
||||
|
||||
|
||||
class TestFirmwareWorkbenchApi:
|
||||
@classmethod
|
||||
def setup_class(cls) -> None:
|
||||
cls.temporary_directory = tempfile.TemporaryDirectory()
|
||||
root = Path(cls.temporary_directory.name)
|
||||
image = root / "Seat_Controller_demo.hex"
|
||||
image.write_bytes(b":020000040000FA\n" + b":100000000102030405060708090A0B0C0D0E0F1068\n" * 80)
|
||||
|
||||
def build_runner(task_id: str, log: object, cancel: threading.Event) -> Path:
|
||||
log("info", f"IAR task {task_id}: 0 errors, 0 warnings")
|
||||
return image
|
||||
|
||||
cls.workbench = FirmwareWorkbenchService(
|
||||
ROOT,
|
||||
artifact_root=root / "workbench",
|
||||
ota_root=root / "ota",
|
||||
build_runner=build_runner,
|
||||
)
|
||||
cls.original_workbench = SeatDemoHandler.firmware_workbench
|
||||
cls.original_auth = SeatDemoHandler.auth
|
||||
SeatDemoHandler.firmware_workbench = cls.workbench
|
||||
SeatDemoHandler.auth = AuthRuntime(
|
||||
AuthConfig(
|
||||
enabled=False,
|
||||
username="",
|
||||
password_hash="",
|
||||
session_secret=b"t" * 64,
|
||||
session_ttl_seconds=3600,
|
||||
login_attempts=5,
|
||||
login_window_seconds=300,
|
||||
requests_per_minute=120,
|
||||
trust_proxy_headers=False,
|
||||
)
|
||||
)
|
||||
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.workbench.close()
|
||||
SeatDemoHandler.firmware_workbench = cls.original_workbench
|
||||
SeatDemoHandler.auth = cls.original_auth
|
||||
cls.temporary_directory.cleanup()
|
||||
|
||||
@classmethod
|
||||
def post(cls, path: str, payload: dict[str, object]) -> tuple[int, dict[str, object]]:
|
||||
request = urllib.request.Request(
|
||||
f"{cls.base_url}{path}",
|
||||
method="POST",
|
||||
headers={"Content-Type": "application/json"},
|
||||
data=json.dumps(payload, ensure_ascii=False).encode(),
|
||||
)
|
||||
with urllib.request.urlopen(request, timeout=5) as response:
|
||||
return response.status, json.loads(response.read().decode())
|
||||
|
||||
def test_real_http_task_publish_detail_and_download_flow(self) -> None:
|
||||
instruction = "增加位置控制、座椅通风和座椅加热功能"
|
||||
status, created = self.post(
|
||||
"/api/firmware-workbench/tasks", {"instruction": instruction}
|
||||
)
|
||||
assert status == 202
|
||||
task_id = str(created["task"]["id"])
|
||||
|
||||
deadline = time.monotonic() + 5
|
||||
while time.monotonic() < deadline:
|
||||
with urllib.request.urlopen(
|
||||
f"{self.base_url}/api/firmware-workbench/tasks/{task_id}", timeout=5
|
||||
) as response:
|
||||
task = json.loads(response.read().decode())
|
||||
if task["status"] not in {"waiting", "running", "pending_confirmation"}:
|
||||
break
|
||||
time.sleep(0.03)
|
||||
assert task["status"] == "success"
|
||||
assert task["message"] == "已发布,待车机端下载升级"
|
||||
with urllib.request.urlopen(
|
||||
f"{self.base_url}/api/firmware-workbench/releases/latest", timeout=5
|
||||
) as response:
|
||||
latest = json.loads(response.read().decode())
|
||||
assert latest["release_id"] == task["release_id"]
|
||||
assert latest["download_url"].endswith(f"/{task['release_id']}/download")
|
||||
|
||||
repeated_status, repeated = self.post(
|
||||
"/api/firmware-workbench/tasks", {"instruction": instruction}
|
||||
)
|
||||
assert repeated_status == 202
|
||||
assert repeated["task"]["id"] != task_id
|
||||
repeated_task_id = str(repeated["task"]["id"])
|
||||
deadline = time.monotonic() + 5
|
||||
while time.monotonic() < deadline:
|
||||
with urllib.request.urlopen(
|
||||
f"{self.base_url}/api/firmware-workbench/tasks/{repeated_task_id}", timeout=5
|
||||
) as response:
|
||||
repeated_task = json.loads(response.read().decode())
|
||||
if repeated_task["status"] not in {"waiting", "running", "pending_confirmation"}:
|
||||
break
|
||||
time.sleep(0.03)
|
||||
assert repeated_task["status"] == "success"
|
||||
|
||||
artifact_id = task["artifact_id"]
|
||||
with urllib.request.urlopen(
|
||||
f"{self.base_url}/api/firmware-workbench/artifacts/{artifact_id}", timeout=5
|
||||
) as response:
|
||||
artifact = json.loads(response.read().decode())
|
||||
assert artifact["status"] == "published"
|
||||
assert artifact["sha256"]
|
||||
|
||||
release_id = task["release_id"]
|
||||
with urllib.request.urlopen(
|
||||
f"{self.base_url}/api/firmware-workbench/releases/{release_id}/download",
|
||||
timeout=5,
|
||||
) as response:
|
||||
package = response.read()
|
||||
assert response.headers["Content-Type"] == "application/zip"
|
||||
assert "attachment" in response.headers["Content-Disposition"]
|
||||
assert len(package) == artifact["size_bytes"]
|
||||
|
||||
delete_request = urllib.request.Request(
|
||||
f"{self.base_url}/api/firmware-workbench/artifacts/{artifact_id}",
|
||||
method="DELETE",
|
||||
)
|
||||
with urllib.request.urlopen(delete_request, timeout=5) as response:
|
||||
deleted = json.loads(response.read().decode())
|
||||
assert deleted["deleted"] is True
|
||||
repeated_artifact_id = str(repeated_task["artifact_id"])
|
||||
repeated_delete_request = urllib.request.Request(
|
||||
f"{self.base_url}/api/firmware-workbench/artifacts/{repeated_artifact_id}",
|
||||
method="DELETE",
|
||||
)
|
||||
with urllib.request.urlopen(repeated_delete_request, timeout=5) as response:
|
||||
assert json.loads(response.read().decode())["deleted"] is True
|
||||
with urllib.request.urlopen(
|
||||
f"{self.base_url}/api/firmware-workbench/state", timeout=5
|
||||
) as response:
|
||||
state = json.loads(response.read().decode())
|
||||
assert [item["id"] for item in state["artifacts"]] == [
|
||||
"fw-art-time-only-baseline"
|
||||
]
|
||||
|
||||
with urllib.request.urlopen(
|
||||
f"{self.base_url}/firmware-workbench.html", timeout=5
|
||||
) as response:
|
||||
page = response.read().decode()
|
||||
assert response.headers["X-Frame-Options"] == "SAMEORIGIN"
|
||||
assert "frame-ancestors 'self'" in response.headers["Content-Security-Policy"]
|
||||
assert "座椅固件开发工作台" in page
|
||||
@@ -89,6 +89,13 @@ class TestMutationRequestSecurity:
|
||||
except urllib.error.HTTPError as exc:
|
||||
return exc.code, json.loads(exc.read().decode("utf-8"))
|
||||
|
||||
def get(self, path: str) -> tuple[int, dict[str, object]]:
|
||||
try:
|
||||
with urllib.request.urlopen(f"{self.base_url}{path}", timeout=3) as response:
|
||||
return response.status, json.loads(response.read().decode("utf-8"))
|
||||
except urllib.error.HTTPError as exc:
|
||||
return exc.code, json.loads(exc.read().decode("utf-8"))
|
||||
|
||||
def test_same_origin_json_assistant_request_is_accepted(self) -> None:
|
||||
status, body = self.post(
|
||||
"/api/assistant/stream",
|
||||
@@ -147,6 +154,29 @@ class TestMutationRequestSecurity:
|
||||
assert status == 200
|
||||
assert MutationSecurityHandler.last_hardware_bypass is False
|
||||
|
||||
def test_vehicle_ota_download_is_public_but_workbench_state_remains_private(self) -> None:
|
||||
MutationSecurityHandler.auth = _auth(enabled=True)
|
||||
try:
|
||||
download_status, download_body = self.get(
|
||||
"/api/firmware-workbench/releases/fw-rel-missing1234/download"
|
||||
)
|
||||
latest_status, latest_body = self.get(
|
||||
"/api/firmware-workbench/releases/latest"
|
||||
)
|
||||
state_status, state_body = self.get("/api/firmware-workbench/state")
|
||||
finally:
|
||||
MutationSecurityHandler.auth = _auth(enabled=False)
|
||||
|
||||
assert download_status == 404
|
||||
assert download_body["error"] == "not_found"
|
||||
assert latest_status in {200, 404}
|
||||
if latest_status == 404:
|
||||
assert latest_body["error"] == "not_found"
|
||||
else:
|
||||
assert latest_body["download_url"]
|
||||
assert state_status == 401
|
||||
assert state_body["error"] == "authentication_required"
|
||||
|
||||
def test_hardware_bypass_requires_auth_enabled_and_direct_loopback(self) -> None:
|
||||
handler = object.__new__(SeatDemoHandler)
|
||||
handler.client_address = ("127.0.0.1", 12345)
|
||||
|
||||
@@ -734,7 +734,7 @@ operations:
|
||||
) as response:
|
||||
extension_styles = response.read().decode("utf-8").replace("\r\n", "\n")
|
||||
with self.opener.open(
|
||||
f"{self.base_url}/assistant-chat.js?v=codex-assistant-29", timeout=3
|
||||
f"{self.base_url}/assistant-chat.js?v=codex-assistant-30", timeout=3
|
||||
) as response:
|
||||
assistant_script = response.read().decode("utf-8")
|
||||
with self.opener.open(
|
||||
@@ -796,8 +796,8 @@ operations:
|
||||
self.assertNotIn("滚动查看更多", html)
|
||||
self.assertIn('aria-label="发送消息"', html)
|
||||
self.assertIn('id="assistantTranscript"', html)
|
||||
self.assertIn('href="/assistant-chat.css?v=codex-assistant-29"', html)
|
||||
self.assertIn('src="/assistant-chat.js?v=codex-assistant-29"', html)
|
||||
self.assertIn('href="/assistant-chat.css?v=codex-assistant-30"', html)
|
||||
self.assertIn('src="/assistant-chat.js?v=codex-assistant-30"', html)
|
||||
self.assertIn('href="/energy-monitor.css?v=energy-monitor-4"', html)
|
||||
self.assertIn('src="/energy-monitor.js?v=energy-monitor-4"', html)
|
||||
self.assertNotIn('"admin"', console_script)
|
||||
@@ -1075,11 +1075,11 @@ operations:
|
||||
self.assertIn("CAN 指令已提交,等待车辆状态确认", console_script)
|
||||
self.assertIn('canvas.addEventListener("webglcontextlost"', app_script)
|
||||
self.assertIn('"cognitive-agent:module-change"', app_script)
|
||||
self.assertIn('const CACHE_VERSION = "cognitive-agent-pad-v283"', service_worker)
|
||||
self.assertIn('const CACHE_VERSION = "cognitive-agent-pad-v284"', service_worker)
|
||||
self.assertIn('/console-v2.css?v=delivery-state-68', service_worker)
|
||||
self.assertIn('/console-v2.js?v=local-first-66', service_worker)
|
||||
self.assertIn('/assistant-chat.css?v=codex-assistant-29', service_worker)
|
||||
self.assertIn('/assistant-chat.js?v=codex-assistant-29', service_worker)
|
||||
self.assertIn('/assistant-chat.css?v=codex-assistant-30', service_worker)
|
||||
self.assertIn('/assistant-chat.js?v=codex-assistant-30', service_worker)
|
||||
self.assertIn('/energy-monitor.css?v=energy-monitor-4', service_worker)
|
||||
self.assertIn('/energy-monitor.js?v=energy-monitor-4', service_worker)
|
||||
self.assertIn('"/app.js?v=dual-seat-133"', service_worker)
|
||||
|
||||
@@ -0,0 +1,321 @@
|
||||
"""A mode device list is an additive configuration edit, never live vehicle control."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import hashlib
|
||||
import json
|
||||
from copy import deepcopy
|
||||
from unittest.mock import MagicMock, patch
|
||||
|
||||
import pytest
|
||||
|
||||
from cognitive_agent.application.assistant_capabilities import AssistantCapabilityCatalog
|
||||
from cognitive_agent.application.assistant_plan_guard import MAX_PLAN_REQUESTS
|
||||
from cognitive_agent.application.codex_assistant import (
|
||||
CodexAssistantService,
|
||||
_authorize_vehicle_request,
|
||||
)
|
||||
from cognitive_agent.application.mode_profile import ModeProfileRegistry
|
||||
from cognitive_agent.application.seat_service import REPOSITORY_ROOT, SeatDemoService
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def addition_service(tmp_path, monkeypatch):
|
||||
for variable in (
|
||||
"COGNITIVE_AGENT_HARDWARE_EXECUTION_ENABLED", "COGNITIVE_AGENT_VEHICLE_CAN_ENABLED",
|
||||
"COGNITIVE_AGENT_WIRED_PASSENGER_CAN_ENABLED", "COGNITIVE_AGENT_VEHICLE_MQTT_ENABLED",
|
||||
):
|
||||
monkeypatch.setenv(variable, "false")
|
||||
service = SeatDemoService(REPOSITORY_ROOT)
|
||||
service.artifact_root = tmp_path / "runs"
|
||||
user_root = tmp_path / "user-modes"
|
||||
service.mode_profiles = ModeProfileRegistry(REPOSITORY_ROOT / "config" / "modes", user_root=user_root)
|
||||
try:
|
||||
with (
|
||||
patch.object(service.hardware_execution, "reserve", side_effect=AssertionError("mode edit reached real CAN")) as reserve,
|
||||
patch.object(service._vehicle_mqtt_transport, "bus", side_effect=AssertionError("mode edit reached MQTT")) as mqtt,
|
||||
patch.object(service.openai, "interpret", side_effect=AssertionError("deterministic mode edit contacted a model")),
|
||||
):
|
||||
yield service, AssistantCapabilityCatalog(service), user_root
|
||||
reserve.assert_not_called()
|
||||
mqtt.assert_not_called()
|
||||
finally:
|
||||
service.close()
|
||||
|
||||
|
||||
def _preview(service, text, *, draft=None, plan=None, conversation="conv-device-additions"):
|
||||
return service.preview_native_can(
|
||||
text, provider="local", conversation_id=conversation, vehicle_id="denza_n7",
|
||||
execution_mode="hardware", mode_profile_draft_context=deepcopy(draft), assistant_plan=plan,
|
||||
)
|
||||
|
||||
|
||||
def _actions(result):
|
||||
return (result.get("intent_ir") or {}).get("spec", {}).get("actions", [])
|
||||
|
||||
|
||||
def _action_map(result):
|
||||
return {
|
||||
(action["capability"], action["target"].get("instance") or action["target"].get("zone")):
|
||||
action["parameters"] for action in _actions(result)
|
||||
}
|
||||
|
||||
|
||||
def _profile_map(profile):
|
||||
return {(op.capability, op.target_scope): op.parameters for op in profile.operations}
|
||||
|
||||
|
||||
def _seat_actions(mapping):
|
||||
return {key: value for key, value in mapping.items() if key[0].startswith("seat.")}
|
||||
|
||||
|
||||
def _reference(result):
|
||||
return {key: result["mode_profile_draft"][key] for key in ("name", "source_request_id", "vehicle_id")}
|
||||
|
||||
|
||||
def _hashes(root):
|
||||
return {str(path): hashlib.sha256(path.read_bytes()).hexdigest() for path in root.rglob("*.yaml")}
|
||||
|
||||
|
||||
def _seed_old_seat_only_draft(service, *, scope="passenger", name="躺平模式", extra=""):
|
||||
# A deliberately explicit, seat-only request represents an older legitimate
|
||||
# preview without relying on ignored developer artifacts or a model stub.
|
||||
settings = (
|
||||
"副驾座椅水平向前调整到20%,副驾靠背调整到45%,副驾座椅坐盆调整到30%,副驾腿托调整到70%"
|
||||
if scope == "passenger" else "主驾座椅通风调整到低档,主驾座椅加热调整到低档"
|
||||
)
|
||||
result = _preview(service, f"构建一个{name},{settings}{extra}")
|
||||
_assert_draft(result)
|
||||
assert {target for _, target in _seat_actions(_action_map(result))} == {scope}
|
||||
return result
|
||||
|
||||
|
||||
def _assert_draft(result):
|
||||
assert result["status"] == "succeeded", (result.get("mode_creation_failure"), result.get("validation"))
|
||||
assert result["orchestration_route"] == "mode_profile_create_preview"
|
||||
assert result["execution_mode"] == "dry_run"
|
||||
assert result["hardware_execution_allowed"] is False
|
||||
assert result.get("mode_profile_draft")
|
||||
assert len(_actions(result)) == result["mode_profile_draft"]["operation_count"]
|
||||
assert len(_actions(result)) <= MAX_PLAN_REQUESTS
|
||||
assert len(_action_map(result)) == len(_actions(result)), "append duplicated an existing action key"
|
||||
|
||||
|
||||
def _assistant(service, catalog, message, *, draft=None):
|
||||
client = MagicMock()
|
||||
client.available = False
|
||||
assistant = CodexAssistantService(client)
|
||||
snapshot = catalog.snapshot(vehicle_id="denza_n7")
|
||||
fast = catalog.fast_answer(message, snapshot)
|
||||
raw = []
|
||||
|
||||
def execute(instruction, progress, assistant_plan=None):
|
||||
result = _preview(service, instruction, draft=draft, plan=assistant_plan)
|
||||
raw.append(result)
|
||||
return result
|
||||
|
||||
answer = assistant.stream(
|
||||
message, conversation_id="conv-device-additions", principal="device-additions-test", history=[],
|
||||
emit=lambda _event: True, execute_control=execute,
|
||||
authoritative_answer=fast.answer if fast else None,
|
||||
capability_snapshot=snapshot, capability_context=catalog.compact_context(snapshot),
|
||||
scene_capability_context=catalog.scene_composition_context(snapshot),
|
||||
control_terms=catalog.control_terms(snapshot), mode_profile_draft_context=draft,
|
||||
)
|
||||
assert len(raw) == 1, answer
|
||||
client.start_structured_turn.assert_not_called()
|
||||
client.start_turn.assert_not_called()
|
||||
return answer, raw[0]
|
||||
|
||||
|
||||
@pytest.mark.parametrize("message", ["构建一个躺平模式", "帮我构建一个躺平模式吧", "构建一个主驾躺平模式"])
|
||||
def test_flat_mode_fast_template_includes_requested_cabin_domains(addition_service, message):
|
||||
service, catalog, user_root = addition_service
|
||||
answer, result = _assistant(service, catalog, message)
|
||||
assert answer["success"] is True, answer
|
||||
assert answer["route"] == "scene_template"
|
||||
_assert_draft(result)
|
||||
assert {"seat", "climate", "ambient_light", "interior_light", "window"} <= {
|
||||
action["domain"] for action in _actions(result)
|
||||
}
|
||||
assert {target for _, target in _seat_actions(_action_map(result))} == (
|
||||
{"driver"} if "主驾" in message else {"passenger"}
|
||||
)
|
||||
assert not _hashes(user_root)
|
||||
|
||||
|
||||
@pytest.mark.parametrize("message", [
|
||||
"在躺平模式中加入空调、氛围灯、阅读灯、车窗等设备动作",
|
||||
"帮我给躺平模式加上空调、氛围灯、阅读灯和车窗",
|
||||
"给躺平模式增加空调、氛围灯、阅读灯、车窗的联动",
|
||||
"躺平模式中补充空调、氛围灯、阅读灯和车窗动作",
|
||||
"调整躺平模式,加入空调、氛围灯、阅读灯、车窗",
|
||||
])
|
||||
@pytest.mark.parametrize("scope", ["passenger", "driver"])
|
||||
def test_device_list_addition_revises_old_draft_without_execution_or_losing_seat_parameters(addition_service, message, scope):
|
||||
service, catalog, user_root = addition_service
|
||||
original = _seed_old_seat_only_draft(service, scope=scope)
|
||||
before = deepcopy(_action_map(original))
|
||||
reference = _reference(original)
|
||||
authorization = _authorize_vehicle_request(message, mode_profile_draft_context=reference)
|
||||
assert authorization.reason == "explicit_mode_profile_draft_update"
|
||||
answer, revised = _assistant(service, catalog, message, draft=reference)
|
||||
assert answer["success"] is True, answer
|
||||
_assert_draft(revised)
|
||||
assert _seat_actions(_action_map(revised)) == _seat_actions(before)
|
||||
assert {"climate", "ambient_light", "interior_light", "window"} <= {
|
||||
action["domain"] for action in _actions(revised)
|
||||
}
|
||||
assert revised["mode_profile_draft"]["revises_source_request_id"] == original["request_id"]
|
||||
assert revised["mode_profile_draft"]["draft_revision"] == 2
|
||||
assert _action_map(original) == before
|
||||
assert not _hashes(user_root)
|
||||
|
||||
|
||||
def test_repeated_device_list_is_idempotent_and_keeps_existing_custom_values(addition_service):
|
||||
service, _, user_root = addition_service
|
||||
original = _seed_old_seat_only_draft(service, extra=",空调调整到26度,关闭氛围灯")
|
||||
message = "在躺平模式中加入空调、氛围灯、阅读灯、车窗等设备动作"
|
||||
first = _preview(service, message, draft=_reference(original))
|
||||
_assert_draft(first)
|
||||
for key, params in _action_map(original).items():
|
||||
assert _action_map(first)[key] == params
|
||||
second = _preview(service, message, draft=_reference(first))
|
||||
_assert_draft(second)
|
||||
assert _action_map(second) == _action_map(first)
|
||||
assert second["mode_profile_draft"]["draft_revision"] == 3
|
||||
assert not _hashes(user_root)
|
||||
|
||||
|
||||
@pytest.mark.parametrize("scope", ["passenger", "driver"])
|
||||
def test_saved_mode_addition_changes_only_selected_profile_and_can_be_replayed(addition_service, scope):
|
||||
service, _, user_root = addition_service
|
||||
original = _seed_old_seat_only_draft(service, scope=scope)
|
||||
draft = original["mode_profile_draft"]
|
||||
saved = service.create_mode_profile_card(draft["name"], draft["instruction"], draft["source_request_id"], draft["vehicle_id"])
|
||||
mode_id = saved["mode_profile"]["id"]
|
||||
unrelated = service.mode_profiles.save_mode("不会改变的模式", [{
|
||||
"domain": "climate", "capability": "climate.set_temperature", "target_scope": "vehicle",
|
||||
"parameters": {"temperature_c": 23},
|
||||
}], vehicle_id="denza_n7")
|
||||
unrelated_revision = service.mode_profiles.profile_revision(unrelated)
|
||||
before = _profile_map(service.mode_profiles.get_profile(mode_id, "denza_n7"))
|
||||
updated = _preview(service, "在躺平模式中加入空调、氛围灯、阅读灯、车窗等设备动作")
|
||||
assert updated["status"] == "succeeded", updated.get("validation")
|
||||
assert updated["orchestration_route"] == "mode_profile_update"
|
||||
assert updated["execution_mode"] == "dry_run"
|
||||
assert updated["hardware_execution_allowed"] is False
|
||||
after = service.mode_profiles.get_profile(mode_id, "denza_n7")
|
||||
assert _seat_actions(_profile_map(after)) == _seat_actions(before)
|
||||
assert {"climate", "ambient_light", "interior_light", "window"} <= {op.domain for op in after.operations}
|
||||
assert service.mode_profiles.profile_revision(service.mode_profiles.get_profile(unrelated.id, "denza_n7")) == unrelated_revision
|
||||
assert len(_hashes(user_root)) == 2
|
||||
replayed = service.execute_mode_profile_card(mode_id, execution_mode="dry_run", vehicle_id="denza_n7")
|
||||
assert replayed["status"] == "succeeded"
|
||||
assert _seat_actions(_action_map(replayed)) == _seat_actions(before)
|
||||
|
||||
|
||||
@pytest.mark.parametrize("mutation", ["request_id", "vehicle", "name", "conversation"])
|
||||
def test_invalid_or_stale_context_never_becomes_live_control_or_changes_saved_mode(addition_service, mutation):
|
||||
service, _, user_root = addition_service
|
||||
original = _seed_old_seat_only_draft(service)
|
||||
reference = _reference(original)
|
||||
conversation = "conv-device-additions"
|
||||
if mutation == "request_id":
|
||||
reference["source_request_id"] = "req-missing-old-source"
|
||||
elif mutation == "vehicle":
|
||||
reference["vehicle_id"] = "other_vehicle"
|
||||
elif mutation == "name":
|
||||
reference["name"] = "其他模式"
|
||||
else:
|
||||
conversation = "conv-another-conversation"
|
||||
try:
|
||||
result = _preview(service, "在躺平模式中加入空调和车窗", draft=reference, conversation=conversation)
|
||||
except ValueError:
|
||||
pass
|
||||
else:
|
||||
assert result["status"] != "succeeded"
|
||||
assert not result.get("mode_profile_draft")
|
||||
assert result["execution_mode"] == "dry_run"
|
||||
assert result["hardware_execution_allowed"] is False
|
||||
assert not _actions(result)
|
||||
assert not result.get("command_plans")
|
||||
assert not _hashes(user_root)
|
||||
|
||||
|
||||
@pytest.mark.parametrize("message", [
|
||||
"在躺平模式中加入空调和后备箱动作",
|
||||
"在躺平模式中加入阅读灯和天窗动作",
|
||||
"在躺平模式中加入空调并把温度调整到99度",
|
||||
])
|
||||
def test_unsupported_or_out_of_range_addition_rejects_all_changes_atomically(addition_service, message):
|
||||
service, _, user_root = addition_service
|
||||
original = _seed_old_seat_only_draft(service)
|
||||
try:
|
||||
result = _preview(service, message, draft=_reference(original))
|
||||
except ValueError:
|
||||
pass
|
||||
else:
|
||||
assert result["status"] != "succeeded"
|
||||
assert not result.get("mode_profile_draft")
|
||||
assert not _actions(result)
|
||||
assert not result.get("command_plans")
|
||||
assert result["hardware_execution_allowed"] is False
|
||||
assert not _hashes(user_root)
|
||||
|
||||
|
||||
def test_adding_devices_to_full_twelve_action_draft_never_truncates_original_actions(addition_service):
|
||||
service, _, user_root = addition_service
|
||||
operations = [
|
||||
("climate.set_power", "vehicle", {"state": "on"}),
|
||||
("climate.set_temperature", "vehicle", {"temperature_c": 24}),
|
||||
("climate.set_fan_level", "vehicle", {"level": 2}),
|
||||
("climate.set_cycle_mode", "vehicle", {"mode": "internal"}),
|
||||
("climate.set_control_mode", "vehicle", {"mode": "auto"}),
|
||||
("climate.set_front_defrost", "vehicle", {"state": "off"}),
|
||||
("roof.set_shade_position", "vehicle", {"position_pct": 0}),
|
||||
("window.set_position", "front_left", {"position_pct": 0}),
|
||||
("window.set_position", "front_right", {"position_pct": 0}),
|
||||
("ambient_light.set_power", "vehicle", {"state": "off"}),
|
||||
("interior_light.set_indoor_power", "vehicle", {"state": "off"}),
|
||||
("interior_light.set_reading_power", "all", {"state": "off"}),
|
||||
]
|
||||
plan = {
|
||||
"detected_domains": sorted({cap.split(".", 1)[0] for cap, _, _ in operations}),
|
||||
"requests": [{
|
||||
"domain": cap.split(".", 1)[0], "capability": cap, "target_scope": target,
|
||||
"operation": "set", "parameters": [{"name": name, "value": value} for name, value in params.items()],
|
||||
"duration_ms": None, "reason": "固定目标边界回归。",
|
||||
} for cap, target, params in operations],
|
||||
"ambiguities": [], "confidence": 0.95, "decision_summary": ["验证12项原动作保留。"],
|
||||
}
|
||||
original = _preview(service, "构建一个满载参数模式", plan=plan)
|
||||
_assert_draft(original)
|
||||
assert len(_actions(original)) == 12
|
||||
source_path = service.artifact_root / original["request_id"] / "result.json"
|
||||
source_hash = hashlib.sha256(source_path.read_bytes()).hexdigest()
|
||||
try:
|
||||
result = _preview(service, "在满载参数模式中加入副驾座椅通风动作", draft=_reference(original))
|
||||
except ValueError:
|
||||
pass
|
||||
else:
|
||||
assert result["status"] != "succeeded"
|
||||
assert not result.get("mode_profile_draft")
|
||||
assert not _actions(result)
|
||||
assert hashlib.sha256(source_path.read_bytes()).hexdigest() == source_hash
|
||||
assert not _hashes(user_root)
|
||||
|
||||
|
||||
def test_previous_artifact_without_new_metadata_remains_an_editable_server_owned_draft(addition_service):
|
||||
service, _, user_root = addition_service
|
||||
original = _seed_old_seat_only_draft(service)
|
||||
legacy = deepcopy(original)
|
||||
legacy["mode_profile_draft"].pop("draft_revision", None)
|
||||
legacy["mode_profile_draft"].pop("generation_source", None)
|
||||
service._persist(legacy)
|
||||
result = _preview(service, "在躺平模式中加入空调、氛围灯、阅读灯、车窗", draft=_reference(legacy))
|
||||
_assert_draft(result)
|
||||
assert result["mode_profile_draft"]["draft_revision"] == 2
|
||||
assert _seat_actions(_action_map(result)) == _seat_actions(_action_map(original))
|
||||
assert not _hashes(user_root)
|
||||
assert json.loads((service.artifact_root / original["request_id"] / "result.json").read_text(encoding="utf-8"))["request_id"] == original["request_id"]
|
||||
@@ -552,6 +552,58 @@ test("the change-into predicate carries only a same-name opaque draft reference"
|
||||
assert.equal(Object.hasOwn(anotherMode, "mode_profile_draft_context"), false);
|
||||
});
|
||||
|
||||
for (const phrase of [
|
||||
"在午休模式中加入空调、氛围灯、阅读灯、车窗等设备动作",
|
||||
"请帮我在午休模式里添加空调和车窗动作",
|
||||
"给午休模式增加氛围灯",
|
||||
"为午休模式补充阅读灯",
|
||||
"往午休模式内加入空调,温度26度",
|
||||
"向午休模式中加上关闭车窗",
|
||||
"午休模式中再加入空调温度26度",
|
||||
"午休模式再补充车窗和阅读灯",
|
||||
"把关闭车窗加入到午休模式中",
|
||||
"请将空调26度和柔和氛围灯添加到午休模式",
|
||||
]) {
|
||||
test(`device additions carry only the same-name opaque draft: ${phrase}`, async () => {
|
||||
const events = modeDraftEvents("req-draft-add-devices", 24);
|
||||
const harness = createHarness(events);
|
||||
harness.input.value = "构建一个午休模式";
|
||||
await harness.sandbox.CognitiveAgentAssistant.submit();
|
||||
events.splice(0, events.length, { type: "answer.completed", answer: "已收到草稿补充请求" });
|
||||
harness.input.value = phrase;
|
||||
await harness.sandbox.CognitiveAgentAssistant.submit();
|
||||
const request = JSON.parse(harness.requestCalls.at(-1).options.body);
|
||||
assert.equal(request.message, phrase);
|
||||
assert.deepEqual(request.mode_profile_draft_context, {
|
||||
name: "午休模式", source_request_id: "req-draft-add-devices", vehicle_id: "demo-vehicle",
|
||||
});
|
||||
assert.equal(harness.requestCalls.every((call) => call.url === "/api/assistant/stream"), true);
|
||||
});
|
||||
}
|
||||
|
||||
for (const phrase of [
|
||||
"在露营模式中加入空调和车窗",
|
||||
"把空调添加到露营模式中",
|
||||
"在午休模式中加入空调可以吗?",
|
||||
"午休模式中加入哪些设备合适?",
|
||||
"在午休模式中不要加入空调",
|
||||
"在午休模式中先别添加车窗",
|
||||
"打开空调",
|
||||
"设置空调出风模式为吹脚",
|
||||
]) {
|
||||
test(`non-update or other-mode additions do not borrow a draft: ${phrase}`, async () => {
|
||||
const events = modeDraftEvents("req-draft-add-isolation", 24);
|
||||
const harness = createHarness(events);
|
||||
harness.input.value = "构建一个午休模式";
|
||||
await harness.sandbox.CognitiveAgentAssistant.submit();
|
||||
events.splice(0, events.length, { type: "answer.completed", answer: "已收到" });
|
||||
harness.input.value = phrase;
|
||||
await harness.sandbox.CognitiveAgentAssistant.submit();
|
||||
const request = JSON.parse(harness.requestCalls.at(-1).options.body);
|
||||
assert.equal(Object.hasOwn(request, "mode_profile_draft_context"), false);
|
||||
});
|
||||
}
|
||||
|
||||
test("shade position rows explain the target opening at semantic boundaries", async () => {
|
||||
const positions = [0, 30, 50, 100];
|
||||
const events = [
|
||||
|
||||
@@ -13,15 +13,15 @@ def test_console_contains_ai_assistant_surface():
|
||||
assert 'aria-label="AI助手对话"' in html
|
||||
assert 'placeholder="向 AI助手提问或交代工作"' in html
|
||||
assert 'aria-label="发送消息"' in html
|
||||
assert 'href="/assistant-chat.css?v=codex-assistant-29"' in html
|
||||
assert 'src="/assistant-chat.js?v=codex-assistant-29"' in html
|
||||
assert 'href="/assistant-chat.css?v=codex-assistant-30"' in html
|
||||
assert 'src="/assistant-chat.js?v=codex-assistant-30"' in html
|
||||
|
||||
|
||||
def test_codex_assistant_assets_are_versioned_together_in_the_service_worker():
|
||||
service_worker = (ROOT / "apps/web-console/service-worker.js").read_text(encoding="utf-8")
|
||||
assert 'const CACHE_VERSION = "cognitive-agent-pad-v283"' in service_worker
|
||||
assert '"/assistant-chat.css?v=codex-assistant-29"' in service_worker
|
||||
assert '"/assistant-chat.js?v=codex-assistant-29"' in service_worker
|
||||
assert 'const CACHE_VERSION = "cognitive-agent-pad-v284"' in service_worker
|
||||
assert '"/assistant-chat.css?v=codex-assistant-30"' in service_worker
|
||||
assert '"/assistant-chat.js?v=codex-assistant-30"' in service_worker
|
||||
|
||||
|
||||
def test_codex_frontend_uses_one_streaming_endpoint_and_agent_work_events():
|
||||
@@ -214,7 +214,7 @@ def test_assistant_carries_only_the_active_mode_draft_reference_to_follow_up_tur
|
||||
assert "rememberModeDraftContext(draft)" in assistant
|
||||
assert "activeModeDraftContext = loadActiveModeDraftContext()" in assistant
|
||||
assert "function isModeDraftUpdateRequest(message, context)" in assistant
|
||||
assert "normalizeModeName(matched[1]) === normalizeModeName(context.name)" in assistant
|
||||
assert "normalizeModeName(matched[nameIndex]) === normalizeModeName(context.name)" in assistant
|
||||
|
||||
|
||||
def test_latest_same_name_mode_draft_retires_stale_card_and_lifecycle_clears_context():
|
||||
|
||||
@@ -35,10 +35,10 @@ def test_energy_monitor_assets_are_versioned_and_cached() -> None:
|
||||
):
|
||||
assert asset in html
|
||||
assert f'"{asset}"' in service_worker
|
||||
assert html.index("/assistant-chat.js?v=codex-assistant-29") < html.index(
|
||||
assert html.index("/assistant-chat.js?v=codex-assistant-30") < html.index(
|
||||
"/energy-monitor.js?v=energy-monitor-4"
|
||||
)
|
||||
assert 'const CACHE_VERSION = "cognitive-agent-pad-v283"' in service_worker
|
||||
assert 'const CACHE_VERSION = "cognitive-agent-pad-v288"' in service_worker
|
||||
|
||||
|
||||
def test_energy_monitor_uses_the_fifth_shared_dock_icon_and_starts_closed() -> None:
|
||||
|
||||
@@ -0,0 +1,253 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import hashlib
|
||||
import json
|
||||
import threading
|
||||
import time
|
||||
import zipfile
|
||||
from pathlib import Path
|
||||
|
||||
from cognitive_agent.application.firmware_workbench import (
|
||||
FINAL_RELEASE_MESSAGE,
|
||||
FirmwareWorkbenchService,
|
||||
)
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[2]
|
||||
|
||||
|
||||
def wait_for_terminal(service: FirmwareWorkbenchService, task_id: str) -> dict[str, object]:
|
||||
deadline = time.monotonic() + 5
|
||||
while time.monotonic() < deadline:
|
||||
task = service.task(task_id)
|
||||
if task["status"] not in {"waiting", "running", "pending_confirmation"}:
|
||||
return task
|
||||
time.sleep(0.02)
|
||||
raise AssertionError("firmware pipeline did not reach a terminal state")
|
||||
|
||||
|
||||
def make_image(tmp_path: Path) -> Path:
|
||||
image = tmp_path / "Seat_Controller_demo.hex"
|
||||
image.write_bytes(b":020000040000FA\n" + b":100000000102030405060708090A0B0C0D0E0F1068\n" * 80)
|
||||
return image
|
||||
|
||||
|
||||
def test_pipeline_publishes_a_downloadable_ota_package(
|
||||
tmp_path: Path, monkeypatch
|
||||
) -> None:
|
||||
monkeypatch.setenv(
|
||||
"FIRMWARE_WORKBENCH_OTA_PUBLIC_BASE_URL",
|
||||
"http://192.168.43.130:8000/api/firmware-workbench/releases",
|
||||
)
|
||||
image = make_image(tmp_path)
|
||||
|
||||
def build_runner(
|
||||
_task_id: str, log: object, _cancel: threading.Event
|
||||
) -> Path:
|
||||
log("info", "IAR build: 0 errors, 0 warnings")
|
||||
return image
|
||||
|
||||
service = FirmwareWorkbenchService(
|
||||
ROOT,
|
||||
artifact_root=tmp_path / "workbench",
|
||||
ota_root=tmp_path / "ota",
|
||||
build_runner=build_runner,
|
||||
)
|
||||
instruction = "增加支持座椅位置参数控制的能力,增加座椅通风功能,增加座椅加热功能"
|
||||
|
||||
first = service.start_task(instruction)
|
||||
task = wait_for_terminal(service, str(first["task"]["id"]))
|
||||
assert task["status"] == "success"
|
||||
assert task["message"] == FINAL_RELEASE_MESSAGE
|
||||
assert [stage["status"] for stage in task["stages"]] == ["success"] * 8
|
||||
assert len(task["test_results"]) == 7
|
||||
|
||||
snapshot = service.snapshot()
|
||||
assert snapshot["baselines"] == [
|
||||
{
|
||||
"id": "time_only",
|
||||
"name": "时间控制基线",
|
||||
"capabilities": ["时间控制"],
|
||||
}
|
||||
]
|
||||
assert len(snapshot["artifacts"]) == 2
|
||||
assert len(snapshot["releases"]) == 1
|
||||
artifact = next(item for item in snapshot["artifacts"] if item["task_id"] == task["id"])
|
||||
baseline = next(item for item in snapshot["artifacts"] if item.get("is_baseline"))
|
||||
release = snapshot["releases"][0]
|
||||
assert baseline["capabilities"] == ["时间控制"]
|
||||
assert baseline["protected"] is True
|
||||
assert artifact["capabilities"] == ["时间控制", "位置控制", "座椅通风", "座椅加热"]
|
||||
assert artifact["status"] == "published"
|
||||
assert release["download_url"] == (
|
||||
f"http://192.168.43.130:8000/api/firmware-workbench/releases/"
|
||||
f"{release['id']}/download"
|
||||
)
|
||||
assert service.latest_release()["download_url"] == release["download_url"]
|
||||
latest_manifest = json.loads((tmp_path / "ota" / "latest.json").read_text("utf-8"))
|
||||
assert latest_manifest["download_url"] == release["download_url"]
|
||||
package, filename = service.resolve_release_download(str(release["id"]))
|
||||
assert filename == artifact["firmware_filename"]
|
||||
assert hashlib.sha256(package.read_bytes()).hexdigest() == artifact["sha256"]
|
||||
with zipfile.ZipFile(package) as archive:
|
||||
assert {
|
||||
"Seat_Controller_demo.hex",
|
||||
"manifest.json",
|
||||
"requirements.json",
|
||||
"validation.json",
|
||||
"feature_profile.json",
|
||||
} <= set(archive.namelist())
|
||||
assert (tmp_path / "ota" / "latest.json").is_file()
|
||||
assert service.publish_artifact(str(artifact["id"]))["id"] == release["id"]
|
||||
service.close()
|
||||
|
||||
monkeypatch.setenv(
|
||||
"FIRMWARE_WORKBENCH_OTA_PUBLIC_BASE_URL",
|
||||
"http://192.168.43.131:8000/api/firmware-workbench/releases",
|
||||
)
|
||||
migrated = FirmwareWorkbenchService(
|
||||
ROOT,
|
||||
artifact_root=tmp_path / "workbench",
|
||||
ota_root=tmp_path / "ota",
|
||||
build_runner=build_runner,
|
||||
)
|
||||
migrated_release = migrated.latest_release()
|
||||
assert migrated_release["download_url"].startswith("http://192.168.43.131:8000/")
|
||||
migrated_latest = json.loads((tmp_path / "ota" / "latest.json").read_text("utf-8"))
|
||||
assert migrated_latest["download_url"] == migrated_release["download_url"]
|
||||
migrated.close()
|
||||
|
||||
|
||||
def test_repeated_instruction_always_creates_and_executes_a_new_task(tmp_path: Path) -> None:
|
||||
image = make_image(tmp_path)
|
||||
|
||||
def build_runner(
|
||||
_task_id: str, log: object, _cancel: threading.Event
|
||||
) -> Path:
|
||||
log("info", "IAR build: 0 errors, 0 warnings")
|
||||
return image
|
||||
|
||||
service = FirmwareWorkbenchService(
|
||||
ROOT,
|
||||
artifact_root=tmp_path / "workbench",
|
||||
ota_root=tmp_path / "ota",
|
||||
build_runner=build_runner,
|
||||
)
|
||||
first = service.start_task("增加座椅加热功能。")
|
||||
first_task = wait_for_terminal(service, str(first["task"]["id"]))
|
||||
second = service.start_task("增加座椅加热功能。")
|
||||
|
||||
assert first["task"]["id"] != second["task"]["id"]
|
||||
assert "duplicate" not in first
|
||||
assert "duplicate" not in second
|
||||
second_task = wait_for_terminal(service, str(second["task"]["id"]))
|
||||
assert first_task["status"] == "success"
|
||||
assert second_task["status"] == "success"
|
||||
assert first_task["target_capabilities"] == ["时间控制", "座椅加热"]
|
||||
assert second_task["target_capabilities"] == ["时间控制", "座椅加热"]
|
||||
service.close()
|
||||
|
||||
|
||||
def test_non_baseline_artifact_can_be_deleted_but_baseline_is_protected(tmp_path: Path) -> None:
|
||||
image = make_image(tmp_path)
|
||||
|
||||
def build_runner(
|
||||
_task_id: str, log: object, _cancel: threading.Event
|
||||
) -> Path:
|
||||
log("info", "IAR build: 0 errors, 0 warnings")
|
||||
return image
|
||||
|
||||
service = FirmwareWorkbenchService(
|
||||
ROOT,
|
||||
artifact_root=tmp_path / "workbench",
|
||||
ota_root=tmp_path / "ota",
|
||||
build_runner=build_runner,
|
||||
)
|
||||
started = service.start_task("增加座椅通风功能")
|
||||
task = wait_for_terminal(service, str(started["task"]["id"]))
|
||||
package_path, _ = service.resolve_artifact_download(str(task["artifact_id"]))
|
||||
release_path, _ = service.resolve_release_download(str(task["release_id"]))
|
||||
|
||||
result = service.delete_artifact(str(task["artifact_id"]))
|
||||
assert result["deleted"] is True
|
||||
assert not package_path.exists()
|
||||
assert not release_path.exists()
|
||||
snapshot = service.snapshot()
|
||||
assert [artifact["id"] for artifact in snapshot["artifacts"]] == [
|
||||
"fw-art-time-only-baseline"
|
||||
]
|
||||
assert snapshot["releases"] == []
|
||||
|
||||
try:
|
||||
service.delete_artifact("fw-art-time-only-baseline")
|
||||
except Exception as exc: # noqa: BLE001 - public error type is asserted below
|
||||
assert getattr(exc, "code", None) == "baseline_protected"
|
||||
else:
|
||||
raise AssertionError("time-only baseline must not be deletable")
|
||||
service.close()
|
||||
|
||||
|
||||
def test_position_instruction_merges_with_time_only_baseline(tmp_path: Path) -> None:
|
||||
image = make_image(tmp_path)
|
||||
|
||||
def build_runner(
|
||||
_task_id: str, log: object, _cancel: threading.Event
|
||||
) -> Path:
|
||||
log("info", "IAR build: profile position=1 ventilation=0 heating=0")
|
||||
return image
|
||||
|
||||
service = FirmwareWorkbenchService(
|
||||
ROOT,
|
||||
artifact_root=tmp_path / "workbench",
|
||||
ota_root=tmp_path / "ota",
|
||||
build_runner=build_runner,
|
||||
)
|
||||
started = service.start_task("增加支持座椅位置参数控制的能力。", "time_only")
|
||||
task = wait_for_terminal(service, str(started["task"]["id"]))
|
||||
|
||||
assert task["status"] == "success"
|
||||
assert task["baseline_capabilities"] == ["时间控制"]
|
||||
assert task["requested_capabilities"] == ["位置控制"]
|
||||
assert task["target_capabilities"] == ["时间控制", "位置控制"]
|
||||
artifact = service.snapshot()["artifacts"][0]
|
||||
assert artifact["capabilities"] == ["时间控制", "位置控制"]
|
||||
assert artifact["capability_diff"]["added"] == ["位置控制"]
|
||||
assert "vent" not in artifact["firmware_filename"]
|
||||
assert "heat" not in artifact["firmware_filename"]
|
||||
service.close()
|
||||
|
||||
|
||||
def test_running_pipeline_can_be_canceled_and_retried(tmp_path: Path) -> None:
|
||||
image = make_image(tmp_path)
|
||||
allow_build = threading.Event()
|
||||
|
||||
def build_runner(
|
||||
_task_id: str, log: object, cancel: threading.Event
|
||||
) -> Path:
|
||||
log("info", "IAR build started")
|
||||
while not allow_build.is_set():
|
||||
if cancel.wait(0.01):
|
||||
raise InterruptedError
|
||||
return image
|
||||
|
||||
service = FirmwareWorkbenchService(
|
||||
ROOT,
|
||||
artifact_root=tmp_path / "workbench",
|
||||
ota_root=tmp_path / "ota",
|
||||
build_runner=build_runner,
|
||||
)
|
||||
started = service.start_task("增加座椅通风功能")
|
||||
task_id = str(started["task"]["id"])
|
||||
deadline = time.monotonic() + 3
|
||||
while service.task(task_id)["current_stage"] != "build" and time.monotonic() < deadline:
|
||||
time.sleep(0.01)
|
||||
service.cancel_task(task_id)
|
||||
canceled = wait_for_terminal(service, task_id)
|
||||
assert canceled["status"] == "canceled"
|
||||
assert any(stage["status"] == "canceled" for stage in canceled["stages"])
|
||||
|
||||
allow_build.set()
|
||||
retried = service.retry_task(task_id)
|
||||
assert retried["id"] == task_id
|
||||
completed = wait_for_terminal(service, task_id)
|
||||
assert completed["status"] == "success"
|
||||
service.close()
|
||||
@@ -0,0 +1,62 @@
|
||||
from pathlib import Path
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[2]
|
||||
WEB = ROOT / "apps" / "web-console"
|
||||
|
||||
|
||||
def source(name: str) -> str:
|
||||
return (WEB / name).read_text(encoding="utf-8")
|
||||
|
||||
|
||||
def test_workbench_is_a_standalone_development_surface() -> None:
|
||||
html = source("firmware-workbench.html")
|
||||
script = source("firmware-workbench.js")
|
||||
|
||||
assert "座椅固件开发工作台" in html
|
||||
assert '<textarea id="instructionInput"' in html
|
||||
assert 'id="startTaskButton"' in html
|
||||
assert 'id="cancelTaskButton"' in html
|
||||
assert 'id="pipeline"' in html
|
||||
assert 'id="logStream"' in html
|
||||
assert 'id="artifactTableBody"' in html
|
||||
assert 'id="baselineSelect"' in html
|
||||
assert 'id="targetCapabilities"' in html
|
||||
assert "执行摘要" not in html
|
||||
assert "单屏工作台" not in html
|
||||
assert "相同指令只会复用同一任务" not in html
|
||||
assert "固件交付流水线</h2>" not in html
|
||||
assert html.count("<option") == 1
|
||||
assert '<option value="time_only">时间控制</option>' in html
|
||||
for english_label in ("DEVELOPMENT BRIEF", "DELIVERY PIPELINE", "LIVE EXECUTION", "SOFTWARE ARTIFACTS"):
|
||||
assert english_label not in html
|
||||
assert "普通聊天" not in html
|
||||
for stage in ("指令输入", "需求解析", "基线检查", "代码生成", "编译构建", "测试验证", "固件打包", "发布服务器"):
|
||||
assert stage in script or stage in html
|
||||
for status in ("等待", "运行中", "待确认", "成功", "失败", "取消", "阻塞"):
|
||||
assert status in script or status in html
|
||||
|
||||
|
||||
def test_workbench_exposes_artifact_actions_and_always_starts_a_new_task() -> None:
|
||||
html = source("firmware-workbench.html")
|
||||
script = source("firmware-workbench.js")
|
||||
entry = source("firmware-workbench-entry.js")
|
||||
index = source("index.html")
|
||||
worker = source("service-worker.js")
|
||||
|
||||
for label in ("查看详情", "能力差异", "代码差异", "验证记录", "下载固件", "发布到服务器", "删除固件产物"):
|
||||
assert label in script
|
||||
assert "检测到相同指令" not in script
|
||||
assert "result.duplicate" not in script
|
||||
assert "已发布,待车机端下载升级" not in script
|
||||
assert 'id = "firmwareWorkbenchToggle"' in entry
|
||||
assert 'className = "firmware-workbench-layer"' in entry
|
||||
assert "/firmware-workbench-entry.js?v=2" in index
|
||||
assert "/firmware-workbench-entry.css?v=1" in index
|
||||
for asset in (
|
||||
"/firmware-workbench-entry.css?v=1",
|
||||
"/firmware-workbench-entry.js?v=2",
|
||||
"/firmware-workbench.html",
|
||||
"/firmware-workbench.css?v=5",
|
||||
"/firmware-workbench.js?v=3",
|
||||
):
|
||||
assert f'"{asset}"' in worker
|
||||
@@ -47,7 +47,7 @@ def test_theme_asset_versions_are_consistent_with_the_pwa_shell() -> None:
|
||||
):
|
||||
assert asset in html
|
||||
assert f'"{asset}"' in service_worker
|
||||
assert 'const CACHE_VERSION = "cognitive-agent-pad-v283"' in service_worker
|
||||
assert 'const CACHE_VERSION = "cognitive-agent-pad-v288"' in service_worker
|
||||
|
||||
|
||||
def test_vehicle_twin_icons_and_nested_overlays_use_semantic_tokens() -> None:
|
||||
|
||||
@@ -0,0 +1,123 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from copy import deepcopy
|
||||
|
||||
import pytest
|
||||
|
||||
from cognitive_agent.application.codex_assistant import (
|
||||
CodexAssistantService,
|
||||
_authorize_vehicle_request,
|
||||
_validated_mode_profile_draft_context,
|
||||
)
|
||||
from cognitive_agent.application.mode_profile_intent import (
|
||||
is_mode_profile_update_shape,
|
||||
parse_mode_profile_update,
|
||||
)
|
||||
|
||||
DRAFT_REFERENCE = {
|
||||
"name": "躺平模式", "source_request_id": "req-augmentation-source-123456", "vehicle_id": "denza_n7",
|
||||
}
|
||||
CONTROL_TERMS = ("空调", "氛围灯", "阅读灯", "车窗", "座椅")
|
||||
|
||||
|
||||
@pytest.mark.parametrize("prefix", ["在", "给", "为", "往", "向", ""])
|
||||
@pytest.mark.parametrize("verb", ["加入", "添加", "增加", "补充", "加上", "追加", "新增"])
|
||||
def test_named_mode_device_augmentation_is_recognized(prefix, verb):
|
||||
message = f"{prefix}躺平模式中{verb}空调、氛围灯、阅读灯、车窗等设备动作"
|
||||
assert parse_mode_profile_update(message) == ("躺平模式", f"{verb}空调、氛围灯、阅读灯、车窗等设备动作")
|
||||
assert _validated_mode_profile_draft_context(DRAFT_REFERENCE, message=message) == DRAFT_REFERENCE
|
||||
authorization = _authorize_vehicle_request(message, CONTROL_TERMS, DRAFT_REFERENCE)
|
||||
assert authorization.instruction == message
|
||||
assert authorization.reason == "explicit_mode_profile_draft_update"
|
||||
|
||||
|
||||
@pytest.mark.parametrize(("message", "change"), [
|
||||
("请在躺平模式里补充空调", "补充空调"),
|
||||
("帮我给躺平模式添加空调", "添加空调"),
|
||||
("为躺平模式新增阅读灯", "新增阅读灯"),
|
||||
("躺平模式再补充车窗", "补充车窗"),
|
||||
("躺平模式中再加入阅读灯", "再加入阅读灯"),
|
||||
("在躺平模式中加入空调24度、阅读灯打开", "加入空调24度、阅读灯打开"),
|
||||
("把空调24度、阅读灯打开加入到躺平模式中", "加入空调24度、阅读灯打开"),
|
||||
("将车窗、氛围灯追加进躺平模式", "追加车窗、氛围灯"),
|
||||
("请把空调加入躺平模式吧", "加入空调"),
|
||||
("把副驾空调温度25.5度补充到躺平模式", "补充副驾空调温度25.5度"),
|
||||
("在“躺平模式”中添加空调", "添加空调"),
|
||||
])
|
||||
def test_append_word_order_preserves_device_parameters_and_scoped_name(message, change):
|
||||
parsed = parse_mode_profile_update(message)
|
||||
assert parsed is not None and parsed[0] == "躺平模式"
|
||||
# A leading 再 modifier is harmless; neither parameters nor target words
|
||||
# may be lost when the reverse-order request is normalized.
|
||||
assert parsed[1].removeprefix("再") == change.removeprefix("再")
|
||||
|
||||
|
||||
@pytest.mark.parametrize("message", [
|
||||
"不要在躺平模式中加入空调", "在躺平模式中不要加入空调",
|
||||
"躺平模式中先不要补充阅读灯", "给躺平模式添加空调,但不要执行",
|
||||
"给躺平模式添加空调,算了", "给躺平模式添加空调,撤销",
|
||||
"躺平模式中取消加入空调", "如何在躺平模式中加入空调?",
|
||||
"在躺平模式中加入空调好吗?", "在躺平模式中能否加入空调",
|
||||
"把空调加入到躺平模式会怎样?", "解释在躺平模式中加入空调这句话",
|
||||
"在躺平模式中加入哪些设备?", "引用在躺平模式中加入空调",
|
||||
"在空调出风模式中加入阅读灯", "在副驾按摩模式中添加车窗",
|
||||
"在躺平模式中加入", "给躺平模式补充",
|
||||
])
|
||||
def test_negated_read_only_or_device_property_requests_do_not_authorize_augmentation(message):
|
||||
assert parse_mode_profile_update(message) is None
|
||||
assert _validated_mode_profile_draft_context(DRAFT_REFERENCE, message=message) is None
|
||||
authorization = _authorize_vehicle_request(message, CONTROL_TERMS, DRAFT_REFERENCE)
|
||||
assert authorization.reason not in {"explicit_mode_profile_draft_update", "explicit_mode_profile_update"}
|
||||
|
||||
|
||||
def test_other_named_mode_never_borrows_the_selected_draft_reference():
|
||||
message = "在阅读模式中加入空调"
|
||||
assert parse_mode_profile_update(message) == ("阅读模式", "加入空调")
|
||||
assert _validated_mode_profile_draft_context(DRAFT_REFERENCE, message=message) is None
|
||||
assert _authorize_vehicle_request(message, CONTROL_TERMS, DRAFT_REFERENCE).reason == "explicit_mode_profile_update"
|
||||
|
||||
|
||||
def test_augmentation_reference_cannot_include_browser_supplied_device_actions():
|
||||
reference = {**DRAFT_REFERENCE, "operations": [{"capability": "seat.anything"}]}
|
||||
with pytest.raises(ValueError, match="fields are invalid"):
|
||||
_validated_mode_profile_draft_context(reference, message="在躺平模式中加入空调")
|
||||
|
||||
|
||||
def test_speech_update_shape_preserves_named_augmentation_even_when_negated():
|
||||
assert is_mode_profile_update_shape("在躺平模式中加入空调,但不要执行")
|
||||
assert parse_mode_profile_update("在躺平模式中加入空调,但不要执行") is None
|
||||
|
||||
|
||||
def test_device_augmentation_bypasses_general_agent_and_partial_control_paths():
|
||||
client = type("UnavailableClient", (), {"available": False})()
|
||||
assistant = CodexAssistantService(client)
|
||||
message = "在躺平模式中加入空调、氛围灯、阅读灯、车窗等设备动作"
|
||||
calls = []
|
||||
events = []
|
||||
|
||||
def execute_control(instruction, _progress):
|
||||
calls.append(instruction)
|
||||
return {
|
||||
"request_id": "req-revised-123456", "status": "succeeded", "execution_mode": "dry_run",
|
||||
"orchestration_route": "mode_profile_create_preview",
|
||||
"intent_ir": {"spec": {"actions": [{"domain": "climate"}]}},
|
||||
"mode_profile_draft": {
|
||||
"name": "躺平模式", "source_request_id": "req-revised-123456", "vehicle_id": "denza_n7",
|
||||
"instruction": "进入躺平模式", "operation_count": 1,
|
||||
"generation_source": "draft_revision", "draft_revision": 2,
|
||||
},
|
||||
}
|
||||
|
||||
reference = deepcopy(DRAFT_REFERENCE)
|
||||
result = assistant.stream(
|
||||
message, conversation_id="conv-augmentation-intent-123456", principal="test",
|
||||
history=[], emit=events.append, execute_control=execute_control,
|
||||
preview_control=lambda *_args: pytest.fail("augmentation must not run partial hardware controls"),
|
||||
supplement_control=lambda *_args: pytest.fail("augmentation must not become a vehicle supplement"),
|
||||
mode_profile_draft_context=reference, control_terms=CONTROL_TERMS,
|
||||
)
|
||||
assert calls == [message]
|
||||
assert result["success"] is True
|
||||
assert result["result"]["mode_profile_draft"]["name"] == "躺平模式"
|
||||
assert reference == DRAFT_REFERENCE
|
||||
assert events[0]["route"] == "vehicle_control"
|
||||
Reference in New Issue
Block a user