Files
Cognitive_Agent/tests/unit/test_firmware_workbench.py

525 lines
21 KiB
Python

from __future__ import annotations
import hashlib
import json
import os
import threading
import time
import zipfile
from pathlib import Path
import pytest
from cognitive_agent.application.firmware_workbench import (
FINAL_RELEASE_MESSAGE,
FirmwareWorkbenchError,
FirmwareWorkbenchService,
_atomic_write_json,
)
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()
def test_atomic_state_write_retries_locks_without_touching_other_temporary_files(
tmp_path: Path, monkeypatch,
) -> None:
path = tmp_path / "state.json"
previous = '{"tasks": [{"id": "previous-task"}]}'
path.write_text(previous, encoding="utf-8")
legacy_temporary = tmp_path / "state.tmp"
legacy_temporary.write_text("another writer", encoding="utf-8")
original_replace = Path.replace
attempts = []
def blocked_replace(source, destination):
attempts.append(source)
assert path.read_text(encoding="utf-8") == previous
assert json.loads(source.read_text(encoding="utf-8")) == {"tasks": ["新任务"]}
if len(attempts) <= 2:
raise PermissionError(13, "file temporarily in use", str(destination))
return original_replace(source, destination)
monkeypatch.setattr(Path, "replace", blocked_replace)
_atomic_write_json(path, {"tasks": ["新任务"]})
assert len(attempts) == 3
assert attempts[0] != legacy_temporary
assert len(set(attempts)) == 1
assert legacy_temporary.read_text(encoding="utf-8") == "another writer"
assert json.loads(path.read_text(encoding="utf-8")) == {"tasks": ["新任务"]}
assert list(tmp_path.glob(".state.json.*.tmp")) == []
@pytest.mark.parametrize("error,expected_attempts", [
(PermissionError(13, "access denied"), 8),
(OSError(28, "disk full"), 1),
])
def test_atomic_write_failure_preserves_history_and_cleans_its_temporary_file(
tmp_path: Path, monkeypatch, error, expected_attempts,
) -> None:
path = tmp_path / "state.json"
previous = '{"tasks": [{"id": "preserved-task"}]}'
path.write_text(previous, encoding="utf-8")
attempts = []
def reject_replace(source, destination):
attempts.append(source)
raise error
monkeypatch.setattr(Path, "replace", reject_replace)
with pytest.raises(type(error)):
_atomic_write_json(path, {"tasks": []})
assert len(attempts) == expected_attempts
assert path.read_text(encoding="utf-8") == previous
assert list(tmp_path.glob(".state.json.*.tmp")) == []
def test_build_and_publish_survive_transient_state_and_manifest_locks(
tmp_path: Path, monkeypatch,
) -> None:
image = make_image(tmp_path)
building = threading.Event()
failures = {"state.json": 2, "latest.json": 2}
original_replace = Path.replace
def replace_with_locks(source, destination):
name = Path(destination).name
if building.is_set() and failures.get(name, 0):
failures[name] -= 1
raise PermissionError(13, "temporary Windows file lock", str(destination))
return original_replace(source, destination)
def build_runner(task_id, log, cancel):
building.set()
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,
)
monkeypatch.setattr(Path, "replace", replace_with_locks)
try:
task = wait_for_terminal(service, service.start_task("增加动态木马功能")["task"]["id"])
assert task["status"] == "success", task.get("failure_reason")
assert [stage["status"] for stage in task["stages"]] == ["success"] * 8
assert failures == {"state.json": 0, "latest.json": 0}
stored = json.loads((tmp_path / "workbench/state.json").read_text(encoding="utf-8"))
assert stored["tasks"][0]["status"] == "success"
latest = json.loads((tmp_path / "ota/latest.json").read_text(encoding="utf-8"))
assert latest["release_id"] == task["release_id"]
finally:
service.close()
def test_recovery_save_failure_does_not_reinitialize_existing_history(tmp_path: Path, monkeypatch):
path = tmp_path / "workbench/state.json"
path.parent.mkdir()
original = {"tasks": [{"id": "fw-task-recovery", "status": "running", "stages": []}],
"artifacts": [], "releases": []}
path.write_text(json.dumps(original), encoding="utf-8")
save = FirmwareWorkbenchService._save_locked
attempts = []
def fail_first_save(service):
attempts.append(True)
if len(attempts) == 1:
raise PermissionError(13, "state is locked")
save(service)
monkeypatch.setattr(FirmwareWorkbenchService, "_save_locked", fail_first_save)
with pytest.raises(PermissionError):
FirmwareWorkbenchService(ROOT, artifact_root=path.parent, ota_root=tmp_path / "ota")
assert len(attempts) == 1
assert json.loads(path.read_text(encoding="utf-8")) == original
def test_unreadable_state_is_not_overwritten_by_baseline_registration(tmp_path: Path, monkeypatch):
path = tmp_path / "workbench/state.json"
path.parent.mkdir()
previous = '{"tasks": [{"id": "preserved-task"}], "artifacts": [], "releases": []}'
path.write_text(previous, encoding="utf-8")
read = Path.read_text
def deny_state_read(source, *args, **kwargs):
if source == path:
raise PermissionError(13, "state read denied")
return read(source, *args, **kwargs)
monkeypatch.setattr(Path, "read_text", deny_state_read)
with pytest.raises(PermissionError):
FirmwareWorkbenchService(ROOT, artifact_root=path.parent, ota_root=tmp_path / "ota")
assert path.read_bytes() == previous.encode("utf-8")
@pytest.mark.skipif(os.name != "nt", reason="Requires a real Windows file-sharing lock")
def test_atomic_write_recovers_from_a_real_windows_reader_lock(tmp_path: Path, monkeypatch):
import ctypes
from ctypes import wintypes
kernel = ctypes.WinDLL("kernel32", use_last_error=True)
kernel.CreateFileW.argtypes = [wintypes.LPCWSTR, wintypes.DWORD, wintypes.DWORD,
wintypes.LPVOID, wintypes.DWORD, wintypes.DWORD, wintypes.HANDLE]
kernel.CreateFileW.restype = wintypes.HANDLE
kernel.CloseHandle.argtypes = [wintypes.HANDLE]
kernel.CloseHandle.restype = wintypes.BOOL
path = tmp_path / "state.json"
path.write_text('{"before": true}', encoding="utf-8")
# Permit read/write, but intentionally omit FILE_SHARE_DELETE to reproduce WinError 5/32.
handle = kernel.CreateFileW(str(path), 0x80000000, 0x00000003, None, 3, 0x80, None)
assert handle != ctypes.c_void_p(-1).value, ctypes.get_last_error()
blocked = threading.Event()
errors = []
observed_winerrors = []
replace = Path.replace
def observe_replace(source, destination):
try:
return replace(source, destination)
except PermissionError as exc:
observed_winerrors.append(exc.winerror)
blocked.set()
raise
def write():
try:
_atomic_write_json(path, {"after": True})
except Exception as exc:
errors.append(exc)
monkeypatch.setattr(Path, "replace", observe_replace)
worker = threading.Thread(target=write)
worker.start()
try:
assert blocked.wait(timeout=2), "Windows did not reproduce the sharing conflict"
assert json.loads(path.read_text(encoding="utf-8")) == {"before": True}
finally:
kernel.CloseHandle(handle)
worker.join(timeout=4)
assert not worker.is_alive()
assert not errors
assert observed_winerrors and set(observed_winerrors) <= {5, 32, 33}
assert json.loads(path.read_text(encoding="utf-8")) == {"after": True}