from __future__ import annotations import hashlib import json import threading 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] 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"] 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() 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() @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()