feat: introduce device plugin runtime boundary
This commit is contained in:
@@ -0,0 +1,92 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from pathlib import Path
|
||||
|
||||
|
||||
def test_mission_core_frontend_has_single_plugin_composition_root() -> None:
|
||||
repository_root = Path(__file__).resolve().parents[1]
|
||||
source_root = repository_root / "apps" / "control-station" / "src"
|
||||
composition = source_root / "composition" / "devicePlugins.ts"
|
||||
forbidden_import = "device-plugins/xgrids-k1"
|
||||
|
||||
imports = [
|
||||
path.relative_to(source_root).as_posix()
|
||||
for path in source_root.rglob("*.ts*")
|
||||
if forbidden_import in path.read_text("utf-8")
|
||||
]
|
||||
|
||||
assert imports == [composition.relative_to(source_root).as_posix()]
|
||||
|
||||
|
||||
def test_mission_core_shell_does_not_know_xgrids_wire_fields() -> None:
|
||||
repository_root = Path(__file__).resolve().parents[1]
|
||||
source_root = repository_root / "apps" / "control-station" / "src"
|
||||
core_paths = [
|
||||
source_root / "App.tsx",
|
||||
source_root / "presentation.ts",
|
||||
source_root / "productModel.ts",
|
||||
source_root / "components",
|
||||
source_root / "core",
|
||||
source_root / "workspaces",
|
||||
]
|
||||
forbidden_tokens = ("k1_ip", "likely_k1", "useK1Console", "rerun_grpc_url")
|
||||
|
||||
checked_files: list[Path] = []
|
||||
for path in core_paths:
|
||||
if path.is_dir():
|
||||
checked_files.extend(path.rglob("*.ts*"))
|
||||
else:
|
||||
checked_files.append(path)
|
||||
|
||||
violations = {
|
||||
path.relative_to(source_root).as_posix(): token
|
||||
for path in checked_files
|
||||
for token in forbidden_tokens
|
||||
if token in path.read_text("utf-8")
|
||||
}
|
||||
|
||||
assert violations == {}
|
||||
|
||||
|
||||
def test_xgrids_client_uses_manifest_identity_and_plugin_scoped_events() -> None:
|
||||
repository_root = Path(__file__).resolve().parents[1]
|
||||
plugin_root = (
|
||||
repository_root / "apps" / "control-station" / "src" / "device-plugins" / "xgrids-k1"
|
||||
)
|
||||
api_source = (plugin_root / "api.ts").read_text("utf-8")
|
||||
manifest_source = (plugin_root / "manifest.ts").read_text("utf-8")
|
||||
|
||||
assert "nodedc.device.xgrids-lixelkity-k1" not in api_source
|
||||
assert 'new URL("/api/events"' not in api_source
|
||||
assert "/api/v1/device-plugins/" in api_source
|
||||
assert "parseDevicePluginManifest" in manifest_source
|
||||
|
||||
|
||||
def test_backend_host_has_no_concrete_xgrids_imports() -> None:
|
||||
repository_root = Path(__file__).resolve().parents[1]
|
||||
app_source = (repository_root / "src" / "k1link" / "web" / "app.py").read_text("utf-8")
|
||||
|
||||
assert "xgrids" not in app_source.lower()
|
||||
assert "k1_ip" not in app_source
|
||||
assert "Bleak" not in app_source
|
||||
|
||||
|
||||
def test_model_switch_is_guarded_by_plugin_deactivation() -> None:
|
||||
repository_root = Path(__file__).resolve().parents[1]
|
||||
frontend_root = repository_root / "apps" / "control-station" / "src"
|
||||
host_source = (frontend_root / "core" / "device-plugins" / "DevicePluginHost.tsx").read_text(
|
||||
"utf-8"
|
||||
)
|
||||
xgrids_runtime = (
|
||||
frontend_root / "device-plugins" / "xgrids-k1" / "runtimeContext.tsx"
|
||||
).read_text("utf-8")
|
||||
|
||||
deactivation_guard = "if (!(await deactivate()))"
|
||||
assert "if (current && !deactivate)" in host_source
|
||||
assert deactivation_guard in host_source
|
||||
assert host_source.index(deactivation_guard) < host_source.index(
|
||||
"setSelectedModelId(nextModelId)"
|
||||
)
|
||||
assert "catch" in host_source
|
||||
assert "selectionTransitionError" in host_source
|
||||
assert "return controller.stop();" in xgrids_runtime
|
||||
@@ -0,0 +1,178 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
import pytest
|
||||
|
||||
from k1link.web.plugin_catalog import DevicePluginCatalog, PluginCatalogError
|
||||
|
||||
|
||||
def _manifest(plugin_id: str, model_id: str) -> dict[str, Any]:
|
||||
return {
|
||||
"apiVersion": "missioncore.nodedc/v1alpha1",
|
||||
"kind": "DevicePlugin",
|
||||
"metadata": {
|
||||
"id": plugin_id,
|
||||
"version": "0.1.0",
|
||||
"displayName": plugin_id,
|
||||
},
|
||||
"spec": {
|
||||
"hostApiRange": "v1alpha1",
|
||||
"runtime": {
|
||||
"backendEntrypoint": "example.plugin:adapter",
|
||||
"isolation": "transitional-in-process",
|
||||
},
|
||||
"permissions": [],
|
||||
"actions": [{"id": "state.read", "mutating": False, "secretFields": []}],
|
||||
"models": [
|
||||
{
|
||||
"id": model_id,
|
||||
"vendor": "Example",
|
||||
"displayName": model_id,
|
||||
"category": "Synthetic",
|
||||
"description": "Synthetic contract fixture.",
|
||||
"verified": False,
|
||||
"capabilities": [],
|
||||
"ui": {
|
||||
"slot": "device.connection",
|
||||
"componentKey": "synthetic.connection",
|
||||
},
|
||||
}
|
||||
],
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
def _write_manifest(root: Path, directory: str, document: dict[str, Any]) -> None:
|
||||
target = root / "plugins" / directory / "plugin.manifest.json"
|
||||
target.parent.mkdir(parents=True)
|
||||
target.write_text(json.dumps(document), encoding="utf-8")
|
||||
|
||||
|
||||
def test_repository_catalog_exposes_xgrids_model() -> None:
|
||||
repository_root = Path(__file__).resolve().parents[1]
|
||||
catalog = DevicePluginCatalog(repository_root)
|
||||
|
||||
plugins = catalog.plugin_documents()
|
||||
models = catalog.model_documents()
|
||||
|
||||
assert any(item["metadata"]["id"] == "nodedc.device.xgrids-lixelkity-k1" for item in plugins)
|
||||
assert next(item for item in models if item["id"] == "xgrids.lixelkity-k1") == {
|
||||
"pluginId": "nodedc.device.xgrids-lixelkity-k1",
|
||||
"pluginVersion": "0.1.0",
|
||||
"id": "xgrids.lixelkity-k1",
|
||||
"vendor": "XGRIDS",
|
||||
"displayName": "XGRIDS LixelKity K1",
|
||||
"category": "Мобильный лидарный сканер",
|
||||
"description": (
|
||||
"Проверенный локальный профиль: BLE-настройка Wi-Fi, MQTT-приём, "
|
||||
"облако точек, поза и raw-first запись."
|
||||
),
|
||||
"verified": True,
|
||||
"capabilities": [
|
||||
{"id": "device.discovery.ble", "label": "Поиск BLE"},
|
||||
{
|
||||
"id": "device.provisioning.wifi-over-ble",
|
||||
"label": "Wi-Fi через BLE",
|
||||
},
|
||||
{"id": "spatial.point-cloud.live", "label": "Облако точек"},
|
||||
{"id": "spatial.pose.live", "label": "Траектория"},
|
||||
{"id": "evidence.raw-capture", "label": "Исходная запись"},
|
||||
{"id": "evidence.replay", "label": "Повтор записи"},
|
||||
],
|
||||
"ui": {
|
||||
"slot": "device.connection",
|
||||
"componentKey": "xgrids-k1.connection",
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
def test_catalog_accepts_multiple_distinct_plugins_and_models(tmp_path: Path) -> None:
|
||||
_write_manifest(tmp_path, "first", _manifest("example.first", "example.first-model"))
|
||||
_write_manifest(tmp_path, "second", _manifest("example.second", "example.second-model"))
|
||||
|
||||
catalog = DevicePluginCatalog(tmp_path)
|
||||
|
||||
assert {item["metadata"]["id"] for item in catalog.plugin_documents()} == {
|
||||
"example.first",
|
||||
"example.second",
|
||||
}
|
||||
assert {item["id"] for item in catalog.model_documents()} == {
|
||||
"example.first-model",
|
||||
"example.second-model",
|
||||
}
|
||||
|
||||
|
||||
def test_catalog_rejects_duplicate_model_ids(tmp_path: Path) -> None:
|
||||
_write_manifest(tmp_path, "first", _manifest("example.first", "example.model"))
|
||||
_write_manifest(tmp_path, "second", _manifest("example.second", "example.model"))
|
||||
|
||||
with pytest.raises(PluginCatalogError, match="Duplicate device-model id"):
|
||||
DevicePluginCatalog(tmp_path).manifests()
|
||||
|
||||
|
||||
def test_catalog_rejects_unknown_contract_version(tmp_path: Path) -> None:
|
||||
document = _manifest("example.invalid", "example.invalid-model")
|
||||
document["apiVersion"] = "missioncore.nodedc/v999"
|
||||
_write_manifest(tmp_path, "invalid", document)
|
||||
|
||||
with pytest.raises(PluginCatalogError, match="Invalid device-plugin manifest"):
|
||||
DevicePluginCatalog(tmp_path).manifests()
|
||||
|
||||
|
||||
def test_v1alpha_catalog_rejects_multiple_models_and_process_isolation(
|
||||
tmp_path: Path,
|
||||
) -> None:
|
||||
multiple_models = _manifest("example.multi", "example.model-a")
|
||||
model_a = multiple_models["spec"]["models"][0]
|
||||
multiple_models["spec"]["models"].append({**model_a, "id": "example.model-b"})
|
||||
_write_manifest(tmp_path, "multi", multiple_models)
|
||||
|
||||
with pytest.raises(PluginCatalogError, match="Invalid device-plugin manifest"):
|
||||
DevicePluginCatalog(tmp_path).manifests()
|
||||
|
||||
process_root = tmp_path / "process"
|
||||
process_manifest = _manifest("example.process", "example.process-model")
|
||||
process_manifest["spec"]["runtime"]["isolation"] = "process"
|
||||
_write_manifest(process_root, "process", process_manifest)
|
||||
|
||||
with pytest.raises(PluginCatalogError, match="Invalid device-plugin manifest"):
|
||||
DevicePluginCatalog(process_root).manifests()
|
||||
|
||||
|
||||
def test_catalog_requires_safe_state_read_action(tmp_path: Path) -> None:
|
||||
document = _manifest("example.unsafe", "example.unsafe-model")
|
||||
document["spec"]["actions"] = [{"id": "state.read", "mutating": True, "secretFields": []}]
|
||||
_write_manifest(tmp_path, "unsafe", document)
|
||||
|
||||
with pytest.raises(PluginCatalogError, match="must declare safe state.read"):
|
||||
DevicePluginCatalog(tmp_path).manifests()
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("mutate", "invalid_value"),
|
||||
[
|
||||
(lambda document, value: document["spec"]["permissions"].append(value), " "),
|
||||
(
|
||||
lambda document, value: document["spec"]["actions"][0]["secretFields"].append(value),
|
||||
"",
|
||||
),
|
||||
(
|
||||
lambda document, value: document["spec"]["models"][0].update({"description": value}),
|
||||
"x" * 1025,
|
||||
),
|
||||
],
|
||||
)
|
||||
def test_catalog_rejects_strings_that_frontend_manifest_parser_rejects(
|
||||
tmp_path: Path,
|
||||
mutate: Any,
|
||||
invalid_value: str,
|
||||
) -> None:
|
||||
document = _manifest("example.invalid", "example.invalid-model")
|
||||
mutate(document, invalid_value)
|
||||
_write_manifest(tmp_path, "invalid", document)
|
||||
|
||||
with pytest.raises(PluginCatalogError, match="Invalid device-plugin manifest"):
|
||||
DevicePluginCatalog(tmp_path).manifests()
|
||||
@@ -0,0 +1,335 @@
|
||||
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
|
||||
@@ -60,9 +60,7 @@ def test_legacy_points_and_pose_are_logged_to_rerun() -> None:
|
||||
point_payload = struct.pack("<III", 16, 0, 0) + struct.pack(
|
||||
"<fffBBBB", 1.0, -2.0, 3.0, 10, 20, 30, 40
|
||||
)
|
||||
pose_payload = struct.pack(
|
||||
"<ffffffff", 1.0, 2.0, 3.0, 99.0, 0.9, 0.1, 0.2, 0.3
|
||||
)
|
||||
pose_payload = struct.pack("<ffffffff", 1.0, 2.0, 3.0, 99.0, 0.9, 0.1, 0.2, 0.3)
|
||||
bridge.process(_message("RealtimePointcloud", point_payload))
|
||||
bridge.process(_message("RealtimePath", pose_payload, sequence=8))
|
||||
|
||||
@@ -155,9 +153,7 @@ def test_runtime_exposes_rerun_url_and_stops_cleanly(tmp_path: Path) -> None:
|
||||
point_payload = struct.pack("<III", 16, 0, 0) + struct.pack(
|
||||
"<fffBBBB", 1.0, -2.0, 3.0, 10, 20, 30, 40
|
||||
)
|
||||
pose_payload = struct.pack(
|
||||
"<ffffffff", 1.0, 2.0, 3.0, 99.0, 0.9, 0.1, 0.2, 0.3
|
||||
)
|
||||
pose_payload = struct.pack("<ffffffff", 1.0, 2.0, 3.0, 99.0, 0.9, 0.1, 0.2, 0.3)
|
||||
frames = bytearray(RAW_MAGIC)
|
||||
for topic, payload in ((point_topic, point_payload), (pose_topic, pose_payload)):
|
||||
topic_raw = topic.encode()
|
||||
@@ -236,9 +232,7 @@ def test_close_during_blocked_factory_closes_the_late_bridge(tmp_path: Path) ->
|
||||
payload = struct.pack("<III", 16, 0, 0) + struct.pack(
|
||||
"<fffBBBB", 1.0, -2.0, 3.0, 10, 20, 30, 40
|
||||
)
|
||||
capture.write_bytes(
|
||||
RAW_MAGIC + FRAME_HEADER.pack(len(topic), len(payload)) + topic + payload
|
||||
)
|
||||
capture.write_bytes(RAW_MAGIC + FRAME_HEADER.pack(len(topic), len(payload)) + topic + payload)
|
||||
(tmp_path / "mqtt.metadata.jsonl").write_text(
|
||||
json.dumps(
|
||||
{
|
||||
@@ -279,3 +273,48 @@ def test_close_during_blocked_factory_closes_the_late_bridge(tmp_path: Path) ->
|
||||
assert runtime.snapshot()["rerun_grpc_url"] is None
|
||||
with pytest.raises(RuntimeError, match="runtime завершён"):
|
||||
runtime.start_replay(capture, speed=0.0)
|
||||
|
||||
|
||||
def test_stop_fails_closed_when_runtime_thread_misses_deadline(tmp_path: Path) -> None:
|
||||
capture = tmp_path / "mqtt.raw.k1mqtt"
|
||||
topic = b"RealtimePointcloud"
|
||||
payload = struct.pack("<III", 16, 0, 0) + struct.pack(
|
||||
"<fffBBBB", 1.0, -2.0, 3.0, 10, 20, 30, 40
|
||||
)
|
||||
capture.write_bytes(RAW_MAGIC + FRAME_HEADER.pack(len(topic), len(payload)) + topic + payload)
|
||||
(tmp_path / "mqtt.metadata.jsonl").write_text(
|
||||
json.dumps(
|
||||
{
|
||||
"record_type": "message",
|
||||
"sequence": 1,
|
||||
"received_at_epoch_ns": 1_000_000_000,
|
||||
"received_monotonic_ns": 1_000_000_000,
|
||||
}
|
||||
)
|
||||
+ "\n",
|
||||
encoding="utf-8",
|
||||
)
|
||||
|
||||
factory_entered = threading.Event()
|
||||
release_factory = threading.Event()
|
||||
|
||||
def blocked_factory(**kwargs: object) -> RerunBridge:
|
||||
factory_entered.set()
|
||||
assert release_factory.wait(timeout=5.0)
|
||||
return RerunBridge(
|
||||
recording_factory=lambda _: FakeRecording(), # type: ignore[arg-type]
|
||||
**kwargs, # type: ignore[arg-type]
|
||||
)
|
||||
|
||||
runtime = VisualizationRuntime(bridge_factory=blocked_factory)
|
||||
runtime.start_replay(capture, speed=0.0)
|
||||
assert factory_entered.wait(timeout=2.0)
|
||||
|
||||
with pytest.raises(RuntimeError, match="не завершился"):
|
||||
runtime.stop(wait_seconds=0.01)
|
||||
assert runtime.snapshot()["phase"] == "stopping"
|
||||
|
||||
release_factory.set()
|
||||
runtime.stop(wait_seconds=2.0)
|
||||
assert runtime.snapshot()["source_mode"] == "idle"
|
||||
runtime.close()
|
||||
|
||||
@@ -6,7 +6,7 @@ from typing import Any
|
||||
|
||||
from pytest import MonkeyPatch
|
||||
|
||||
import k1link.web.app as web_app
|
||||
import k1link.web.xgrids_k1_facade as xgrids_backend
|
||||
|
||||
|
||||
def test_ble_scan_exposes_every_device_and_only_labels_likely_k1(
|
||||
@@ -34,8 +34,8 @@ def test_ble_scan_exposes_every_device_and_only_labels_likely_k1(
|
||||
]
|
||||
}
|
||||
|
||||
monkeypatch.setattr(web_app, "scan", fake_scan)
|
||||
service = web_app.ConsoleService(tmp_path)
|
||||
monkeypatch.setattr(xgrids_backend, "scan", fake_scan)
|
||||
service = xgrids_backend.XgridsK1CompatibilityService(tmp_path)
|
||||
|
||||
state = asyncio.run(service.scan_ble(6.0))
|
||||
|
||||
@@ -48,8 +48,8 @@ def test_ble_scan_exposes_every_device_and_only_labels_likely_k1(
|
||||
|
||||
|
||||
def test_viewer_settings_are_validated_and_exposed(tmp_path: Path) -> None:
|
||||
service = web_app.ConsoleService(tmp_path)
|
||||
request = web_app.ViewerSettingsRequest(
|
||||
service = xgrids_backend.XgridsK1CompatibilityService(tmp_path)
|
||||
request = xgrids_backend.ViewerSettingsRequest(
|
||||
point_size=4.0,
|
||||
color_mode="height",
|
||||
palette="viridis",
|
||||
|
||||
Reference in New Issue
Block a user