358 lines
12 KiB
Python
358 lines
12 KiB
Python
from __future__ import annotations
|
|
|
|
import asyncio
|
|
import json
|
|
import threading
|
|
from collections.abc import Mapping
|
|
from pathlib import Path
|
|
from typing import Any
|
|
|
|
import pytest
|
|
from pydantic import ValidationError
|
|
|
|
import k1link.web.device_plugin_composition as plugin_composition
|
|
from k1link.web.device_plugin_composition import (
|
|
DevicePluginCompositionError,
|
|
InstalledDevicePluginEnvironment,
|
|
load_installed_device_plugins,
|
|
)
|
|
from k1link.web.plugin_catalog import DevicePluginCatalog
|
|
from k1link.web.plugin_runtime import (
|
|
DevicePluginDispatcher,
|
|
DevicePluginRuntimeContribution,
|
|
PluginActionNotFoundError,
|
|
PluginNotFoundError,
|
|
)
|
|
from k1link.web.xgrids_k1_facade import (
|
|
ACTION_DISCOVERY_SCAN,
|
|
ACTION_NETWORK_PROVISION,
|
|
ACTION_STREAM_START_LIVE,
|
|
ACTION_STREAM_START_REPLAY,
|
|
ACTION_STREAM_STOP,
|
|
ACTION_VIEWER_SETTINGS_UPDATE,
|
|
XGRIDS_K1_PLUGIN_ID,
|
|
CompatibilityAttestationRequest,
|
|
ConnectRequest,
|
|
ViewerSettingsRequest,
|
|
XgridsK1PluginFacade,
|
|
)
|
|
|
|
|
|
class FakeXgridsService:
|
|
def __init__(self) -> None:
|
|
self.calls: list[tuple[str, object]] = []
|
|
|
|
def state(self) -> dict[str, Any]:
|
|
self.calls.append(("state", None))
|
|
return {"phase": "idle"}
|
|
|
|
async def scan_ble(self, duration_seconds: float) -> dict[str, Any]:
|
|
self.calls.append(("scan", duration_seconds))
|
|
return {"phase": "idle", "devices": []}
|
|
|
|
async def connect(self, request: ConnectRequest) -> dict[str, Any]:
|
|
self.calls.append(("connect", request))
|
|
return {"phase": "connected", "k1_ip": "192.168.1.20"}
|
|
|
|
def start_live(
|
|
self,
|
|
host: str | None,
|
|
duration_seconds: float,
|
|
compatibility_attestation: CompatibilityAttestationRequest,
|
|
) -> dict[str, Any]:
|
|
self.calls.append(("live", (host, duration_seconds, compatibility_attestation)))
|
|
return {"phase": "live"}
|
|
|
|
def start_replay(self, path: str, speed: float, loop: bool) -> dict[str, Any]:
|
|
self.calls.append(("replay", (path, speed, loop)))
|
|
return {"phase": "replay"}
|
|
|
|
def stop(self) -> dict[str, Any]:
|
|
self.calls.append(("stop", None))
|
|
return {"phase": "idle"}
|
|
|
|
def update_viewer_settings(self, request: ViewerSettingsRequest) -> dict[str, Any]:
|
|
self.calls.append(("viewer", request))
|
|
return {"phase": "idle", "viewer_settings": request.model_dump()}
|
|
|
|
|
|
def test_manifest_and_runtime_facade_declare_identical_actions() -> None:
|
|
repository_root = Path(__file__).resolve().parents[1]
|
|
manifest = next(
|
|
candidate
|
|
for candidate in DevicePluginCatalog(repository_root).manifests()
|
|
if candidate.metadata.id == XGRIDS_K1_PLUGIN_ID
|
|
)
|
|
|
|
assert {action.id for action in manifest.spec.actions} == XgridsK1PluginFacade.action_ids
|
|
|
|
|
|
def test_repository_runtime_composition_exactly_matches_catalog() -> None:
|
|
repository_root = Path(__file__).resolve().parents[1]
|
|
environment = load_installed_device_plugins(repository_root)
|
|
try:
|
|
assert set(environment.dispatcher.action_declarations) == {
|
|
manifest.metadata.id for manifest in environment.catalog.manifests()
|
|
}
|
|
finally:
|
|
environment.close()
|
|
|
|
|
|
def test_composition_closes_a_runtime_that_does_not_match_its_manifest(
|
|
monkeypatch: pytest.MonkeyPatch,
|
|
) -> None:
|
|
repository_root = Path(__file__).resolve().parents[1]
|
|
manifest = next(
|
|
candidate
|
|
for candidate in DevicePluginCatalog(repository_root).manifests()
|
|
if candidate.metadata.id == XGRIDS_K1_PLUGIN_ID
|
|
)
|
|
mismatched_manifest = manifest.model_copy(
|
|
update={"metadata": manifest.metadata.model_copy(update={"id": "example.other-plugin"})}
|
|
)
|
|
closed: list[bool] = []
|
|
contribution = DevicePluginRuntimeContribution(
|
|
adapter=XgridsK1PluginFacade(FakeXgridsService()),
|
|
close=lambda: closed.append(True),
|
|
)
|
|
monkeypatch.setattr(
|
|
plugin_composition,
|
|
"_load_factory",
|
|
lambda _: lambda __: contribution,
|
|
)
|
|
|
|
with pytest.raises(DevicePluginCompositionError, match="id mismatch"):
|
|
plugin_composition._load_contribution(repository_root, mismatched_manifest)
|
|
|
|
assert closed == [True]
|
|
|
|
|
|
def test_composition_loads_and_dispatches_two_synthetic_plugins(
|
|
monkeypatch: pytest.MonkeyPatch,
|
|
tmp_path: Path,
|
|
) -> None:
|
|
class SyntheticAdapter:
|
|
action_ids = frozenset({"state.read"})
|
|
|
|
def __init__(self, plugin_id: str) -> None:
|
|
self.plugin_id = plugin_id
|
|
|
|
async def invoke(
|
|
self,
|
|
action_id: str,
|
|
payload: Mapping[str, Any],
|
|
) -> dict[str, Any]:
|
|
assert action_id == "state.read"
|
|
assert payload == {}
|
|
return {"plugin_id": self.plugin_id}
|
|
|
|
closed: list[str] = []
|
|
contributions = {
|
|
f"synthetic:{suffix}": DevicePluginRuntimeContribution(
|
|
adapter=SyntheticAdapter(f"example.{suffix}"),
|
|
close=lambda suffix=suffix: closed.append(suffix),
|
|
)
|
|
for suffix in ("first", "second")
|
|
}
|
|
|
|
for suffix in ("first", "second"):
|
|
document = {
|
|
"apiVersion": "missioncore.nodedc/v1alpha1",
|
|
"kind": "DevicePlugin",
|
|
"metadata": {
|
|
"id": f"example.{suffix}",
|
|
"version": "0.1.0",
|
|
"displayName": f"Example {suffix}",
|
|
},
|
|
"spec": {
|
|
"hostApiRange": "v1alpha1",
|
|
"runtime": {
|
|
"backendEntrypoint": f"synthetic:{suffix}",
|
|
"isolation": "transitional-in-process",
|
|
},
|
|
"permissions": [],
|
|
"actions": [{"id": "state.read", "mutating": False, "secretFields": []}],
|
|
"models": [
|
|
{
|
|
"id": f"example.{suffix}-model",
|
|
"vendor": "Example",
|
|
"displayName": f"Example {suffix}",
|
|
"category": "Synthetic",
|
|
"description": "Synthetic runtime composition fixture.",
|
|
"verified": False,
|
|
"capabilities": [],
|
|
"ui": {
|
|
"slot": "device.connection",
|
|
"componentKey": f"example.{suffix}.connection",
|
|
},
|
|
}
|
|
],
|
|
},
|
|
}
|
|
manifest_path = tmp_path / "plugins" / suffix / "plugin.manifest.json"
|
|
manifest_path.parent.mkdir(parents=True)
|
|
manifest_path.write_text(json.dumps(document), encoding="utf-8")
|
|
|
|
monkeypatch.setattr(
|
|
plugin_composition,
|
|
"_load_factory",
|
|
lambda entrypoint: lambda _: contributions[entrypoint],
|
|
)
|
|
environment = load_installed_device_plugins(tmp_path)
|
|
try:
|
|
for suffix in ("first", "second"):
|
|
state = asyncio.run(
|
|
environment.dispatcher.invoke(f"example.{suffix}", "state.read", {})
|
|
)
|
|
assert state == {"plugin_id": f"example.{suffix}"}
|
|
finally:
|
|
environment.close()
|
|
|
|
assert closed == ["second", "first"]
|
|
|
|
|
|
def test_environment_shutdown_attempts_every_plugin_after_close_failure(
|
|
tmp_path: Path,
|
|
) -> None:
|
|
closed: list[str] = []
|
|
|
|
def failing_close() -> None:
|
|
closed.append("failing")
|
|
raise RuntimeError("synthetic close failure")
|
|
|
|
environment = InstalledDevicePluginEnvironment(
|
|
catalog=DevicePluginCatalog(tmp_path),
|
|
dispatcher=DevicePluginDispatcher([]),
|
|
legacy_routers=(),
|
|
_contributions=(
|
|
DevicePluginRuntimeContribution(
|
|
adapter=XgridsK1PluginFacade(FakeXgridsService()),
|
|
close=lambda: closed.append("healthy"),
|
|
),
|
|
DevicePluginRuntimeContribution(
|
|
adapter=XgridsK1PluginFacade(FakeXgridsService()),
|
|
close=failing_close,
|
|
),
|
|
),
|
|
)
|
|
|
|
with pytest.raises(DevicePluginCompositionError, match="failed during shutdown") as raised:
|
|
environment.close()
|
|
|
|
assert closed == ["failing", "healthy"]
|
|
assert "synthetic close failure" in " ".join(raised.value.__notes__)
|
|
|
|
|
|
def test_dispatcher_routes_allowlisted_action_to_xgrids_facade() -> None:
|
|
service = FakeXgridsService()
|
|
dispatcher = DevicePluginDispatcher([XgridsK1PluginFacade(service)])
|
|
|
|
state = asyncio.run(
|
|
dispatcher.invoke(
|
|
XGRIDS_K1_PLUGIN_ID,
|
|
ACTION_DISCOVERY_SCAN,
|
|
{"duration_seconds": 6},
|
|
)
|
|
)
|
|
|
|
assert state == {"phase": "idle", "devices": []}
|
|
assert service.calls == [("scan", 6.0)]
|
|
|
|
|
|
def test_dispatcher_rejects_unknown_plugin_and_action() -> None:
|
|
dispatcher = DevicePluginDispatcher([XgridsK1PluginFacade(FakeXgridsService())])
|
|
|
|
with pytest.raises(PluginNotFoundError):
|
|
asyncio.run(dispatcher.invoke("missing.plugin", ACTION_STREAM_STOP, {}))
|
|
with pytest.raises(PluginActionNotFoundError):
|
|
asyncio.run(dispatcher.invoke(XGRIDS_K1_PLUGIN_ID, "unknown.action", {}))
|
|
|
|
|
|
def test_facade_validates_payload_before_calling_service() -> None:
|
|
service = FakeXgridsService()
|
|
dispatcher = DevicePluginDispatcher([XgridsK1PluginFacade(service)])
|
|
|
|
with pytest.raises(ValidationError):
|
|
asyncio.run(
|
|
dispatcher.invoke(
|
|
XGRIDS_K1_PLUGIN_ID,
|
|
ACTION_NETWORK_PROVISION,
|
|
{
|
|
"device_id": "id",
|
|
"ssid": "network",
|
|
"password": "x" * 24,
|
|
"extra": True,
|
|
},
|
|
)
|
|
)
|
|
|
|
assert service.calls == []
|
|
|
|
|
|
@pytest.mark.parametrize(
|
|
("action_id", "payload", "expected_call"),
|
|
[
|
|
(
|
|
ACTION_STREAM_START_LIVE,
|
|
{
|
|
"host": "192.168.1.20",
|
|
"compatibility_attestation": {
|
|
"firmware_version": "3.0.2",
|
|
"topology": "direct-lan",
|
|
"operator_confirmed": True,
|
|
},
|
|
},
|
|
"live",
|
|
),
|
|
(
|
|
ACTION_STREAM_START_REPLAY,
|
|
{"path": "sessions/capture.k1mqtt", "speed": 1, "loop": False},
|
|
"replay",
|
|
),
|
|
(ACTION_STREAM_STOP, {}, "stop"),
|
|
(
|
|
ACTION_VIEWER_SETTINGS_UPDATE,
|
|
{
|
|
"point_size": 3,
|
|
"color_mode": "height",
|
|
"palette": "viridis",
|
|
"custom_color": "#102030",
|
|
"accumulation_seconds": 12,
|
|
"show_points": True,
|
|
"show_trajectory": True,
|
|
"show_grid": False,
|
|
},
|
|
"viewer",
|
|
),
|
|
],
|
|
)
|
|
def test_facade_preserves_existing_runtime_operations(
|
|
action_id: str,
|
|
payload: dict[str, object],
|
|
expected_call: str,
|
|
) -> None:
|
|
service = FakeXgridsService()
|
|
dispatcher = DevicePluginDispatcher([XgridsK1PluginFacade(service)])
|
|
|
|
asyncio.run(dispatcher.invoke(XGRIDS_K1_PLUGIN_ID, action_id, payload))
|
|
|
|
assert service.calls[0][0] == expected_call
|
|
|
|
|
|
def test_sync_runtime_actions_run_outside_the_api_event_loop() -> None:
|
|
class ThreadAwareService(FakeXgridsService):
|
|
thread_id: int | None = None
|
|
|
|
def stop(self) -> dict[str, Any]:
|
|
self.thread_id = threading.get_ident()
|
|
return super().stop()
|
|
|
|
service = ThreadAwareService()
|
|
dispatcher = DevicePluginDispatcher([XgridsK1PluginFacade(service)])
|
|
event_loop_thread = threading.get_ident()
|
|
|
|
asyncio.run(dispatcher.invoke(XGRIDS_K1_PLUGIN_ID, ACTION_STREAM_STOP, {}))
|
|
|
|
assert service.thread_id is not None
|
|
assert service.thread_id != event_loop_thread
|