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, 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) -> dict[str, Any]: self.calls.append(("live", (host, duration_seconds))) 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": "secret", "extra": True}, ) ) assert service.calls == [] @pytest.mark.parametrize( ("action_id", "payload", "expected_call"), [ (ACTION_STREAM_START_LIVE, {"host": "192.168.1.20"}, "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