342 lines
13 KiB
Python
342 lines
13 KiB
Python
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()
|