feat(firmware): support rocking-horse workbench delivery

This commit is contained in:
Miayui
2026-09-22 11:15:13 +08:00
parent ba5dfcd6da
commit 4d99702acd
13 changed files with 250 additions and 29 deletions
+33 -2
View File
@@ -1,12 +1,17 @@
from __future__ import annotations
import io
import json
import tempfile
import threading
import time
import urllib.request
import zipfile
from http.server import ThreadingHTTPServer
from pathlib import Path
from types import SimpleNamespace
import pytest
from cognitive_agent.application.firmware_workbench import FirmwareWorkbenchService
from cognitive_agent.auth import AuthConfig, AuthRuntime
@@ -75,8 +80,14 @@ class TestFirmwareWorkbenchApi:
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 = "增加位置控制、座椅通风和座椅加热功能"
@pytest.mark.parametrize("instruction,capabilities", [
("增加位置控制、座椅通风和座椅加热功能", ["时间控制", "位置控制", "座椅通风", "座椅加热"]),
("增加动态木马功能", ["时间控制", "位置控制", "动态木马"]),
("增加动态木马、座椅通风和座椅加热功能", ["时间控制", "位置控制", "座椅通风", "座椅加热", "动态木马"]),
])
def test_real_http_task_publish_detail_and_download_flow(
self, instruction: str, capabilities: list[str], monkeypatch
) -> None:
status, created = self.post(
"/api/firmware-workbench/tasks", {"instruction": instruction}
)
@@ -135,6 +146,26 @@ class TestFirmwareWorkbenchApi:
assert response.headers["Content-Type"] == "application/zip"
assert "attachment" in response.headers["Content-Disposition"]
assert len(package) == artifact["size_bytes"]
assert artifact["capabilities"] == capabilities
with zipfile.ZipFile(io.BytesIO(package)) as archive:
assert json.loads(archive.read("manifest.json"))["capabilities"] == capabilities
frames = []
def publish(frame):
frames.append(frame)
return {"status": "published"}
monkeypatch.setattr(
SeatDemoHandler, "service", SimpleNamespace(publish_firmware_notification=publish)
)
notify_status, notified = self.post(
f"/api/firmware-workbench/artifacts/{artifact_id}/notify", {}
)
assert notify_status == 200
assert notified["published_count"] == 3
assert [frame["sequence"] for frame in frames] == [1, 2, 3]
assert all(frame["firmware"]["release_id"] == release_id for frame in frames)
delete_request = urllib.request.Request(
f"{self.base_url}/api/firmware-workbench/artifacts/{artifact_id}",
+88
View File
@@ -7,10 +7,14 @@ import time
import zipfile
from pathlib import Path
import pytest
from cognitive_agent.application.firmware_workbench import (
FINAL_RELEASE_MESSAGE,
FirmwareWorkbenchError,
FirmwareWorkbenchService,
)
from cognitive_agent.infrastructure.can.gateway import CanGatewayError
ROOT = Path(__file__).resolve().parents[2]
@@ -213,6 +217,8 @@ def test_position_instruction_merges_with_time_only_baseline(tmp_path: Path) ->
assert artifact["capability_diff"]["added"] == ["位置控制"]
assert "vent" not in artifact["firmware_filename"]
assert "heat" not in artifact["firmware_filename"]
profile_path = tmp_path / "workbench" / "tasks" / task["id"] / "feature_profile.json"
assert json.loads(profile_path.read_text("utf-8"))["rocking_horse"] is False
service.close()
@@ -251,3 +257,85 @@ def test_running_pipeline_can_be_canceled_and_retried(tmp_path: Path) -> None:
completed = wait_for_terminal(service, task_id)
assert completed["status"] == "success"
service.close()
@pytest.mark.parametrize("instruction", [
"增加动态木马功能",
"增加木马功能",
"Add rocking_horse",
"Add Rocking Horse",
"Add rocking-horse",
])
def test_rocking_horse_package_and_notification(tmp_path: Path, instruction: str) -> None:
image = make_image(tmp_path)
profile = {
"position_control": True, "ventilation": False,
"heating": False, "rocking_horse": True,
}
def build_runner(task_id: str, log: object, cancel: threading.Event) -> Path:
workspace = tmp_path / "workbench" / "tasks" / task_id
assert json.loads((workspace / "feature_profile.json").read_text("utf-8")) == profile
return image
service = FirmwareWorkbenchService(
ROOT, artifact_root=tmp_path / "workbench", ota_root=tmp_path / "ota",
build_runner=build_runner,
)
try:
started = service.start_task(instruction)
task = wait_for_terminal(service, started["task"]["id"])
assert task["status"] == "success", task.get("failure_reason")
assert [stage["status"] for stage in task["stages"]] == ["success"] * 8
assert task["target_capabilities"] == ["时间控制", "位置控制", "动态木马"]
assert any(item["name"] == "动态木马控制与停止保护" for item in task["test_results"])
artifact = service.artifact(task["artifact_id"])
assert artifact["capability_diff"]["added"] == ["位置控制", "动态木马"]
assert "rocking-horse" in artifact["firmware_filename"]
package, _ = service.resolve_release_download(task["release_id"])
with zipfile.ZipFile(package) as archive:
assert json.loads(archive.read("manifest.json"))["capabilities"] == task["target_capabilities"]
assert json.loads(archive.read("feature_profile.json")) == profile
assert json.loads(archive.read("requirements.json"))["feature_profile"] == profile
frames = []
def publish(frame):
frames.append(frame)
return {"status": "published"}
result = service.notify_artifact(artifact["id"], publish)
assert result["published_count"] == 3
assert [frame["sequence"] for frame in frames] == [1, 2, 3]
assert len({frame["message_id"] for frame in frames}) == 1
assert all(frame["firmware"]["artifact_name"] == artifact["firmware_filename"] for frame in frames)
assert all(frame["firmware"]["sha256"] == artifact["sha256"] for frame in frames)
assert all(frame["auto_upgrade"] is False for frame in frames)
def unavailable(frame):
raise CanGatewayError("broker unavailable")
with pytest.raises(FirmwareWorkbenchError, match="0/3") as error:
service.notify_artifact(artifact["id"], unavailable)
assert error.value.code == "notification_publish_failed"
assert service.artifact(artifact["id"])["status"] == "published"
finally:
service.close()
def test_rocking_horse_build_failure_does_not_publish(tmp_path: Path) -> None:
def fail_build(task_id: str, log: object, cancel: threading.Event) -> Path:
raise RuntimeError("IAR build failed")
service = FirmwareWorkbenchService(
ROOT, artifact_root=tmp_path / "workbench", ota_root=tmp_path / "ota",
build_runner=fail_build,
)
try:
started = service.start_task("增加动态木马功能")
task = wait_for_terminal(service, started["task"]["id"])
assert task["status"] == "failed"
assert task["failure_reason"] == "IAR build failed"
assert service.snapshot()["releases"] == []
assert all(item.get("is_baseline") for item in service.snapshot()["artifacts"])
finally:
service.close()
@@ -58,8 +58,8 @@ def test_workbench_exposes_artifact_actions_and_always_starts_a_new_task() -> No
"/firmware-workbench-entry.js?v=3",
"/firmware-workbench.html",
"/firmware-workbench-theme.js?v=1",
"/firmware-workbench.css?v=7",
"/firmware-workbench.js?v=4",
"/firmware-workbench.css?v=8",
"/firmware-workbench.js?v=5",
):
assert f'"{asset}"' in worker
for runtime_file in (