feat(k1): wire canonical control session to UI
This commit is contained in:
@@ -113,7 +113,7 @@ def test_xgrids_frontend_uses_semantic_acquisition_actions_and_stable_identity()
|
||||
assert 'id: "xgrids-k1-rerun-live"' not in runtime_source
|
||||
|
||||
|
||||
def test_xgrids_live_copy_does_not_claim_software_controls_the_physical_scanner() -> None:
|
||||
def test_xgrids_live_copy_exposes_only_explicit_canonical_control_steps() -> None:
|
||||
repository_root = Path(__file__).resolve().parents[1]
|
||||
connection_source = (
|
||||
repository_root
|
||||
@@ -125,8 +125,11 @@ def test_xgrids_live_copy_does_not_claim_software_controls_the_physical_scanner(
|
||||
/ "K1AcquisitionPipeline.tsx"
|
||||
).read_text("utf-8")
|
||||
|
||||
assert "Подготовить локальный приём данных" in connection_source
|
||||
assert "Программная команда запуска на K1 не отправляется" in connection_source
|
||||
assert "Подключить управление K1" in connection_source
|
||||
assert "Открыть рабочее пространство K1" in connection_source
|
||||
assert "Сохранить проект и подготовить локальный приём" in connection_source
|
||||
assert "Запустить сканирование K1" in connection_source
|
||||
assert "никаких временных автопереходов и повторов нет" in connection_source
|
||||
assert "Остановить локальный приём" in connection_source
|
||||
assert "Физическое состояние сканера остаётся неизвестным" in connection_source
|
||||
|
||||
|
||||
@@ -88,7 +88,7 @@ def test_repository_catalog_exposes_xgrids_model() -> None:
|
||||
item for item in plugins if item["metadata"]["id"] == "nodedc.device.xgrids-lixelkity-k1"
|
||||
)
|
||||
assert plugin["apiVersion"] == "missioncore.nodedc/v1alpha2"
|
||||
assert plugin["metadata"]["version"] == "0.4.0"
|
||||
assert plugin["metadata"]["version"] == "0.5.0"
|
||||
assert plugin["spec"]["hostApiRange"] == "v1alpha2"
|
||||
assert plugin["spec"]["compatibilityProfiles"] == [
|
||||
{
|
||||
@@ -119,10 +119,13 @@ def test_repository_catalog_exposes_xgrids_model() -> None:
|
||||
"application-control.shadow-state",
|
||||
"application-control.shadow-arm",
|
||||
"application-control.shadow-disarm",
|
||||
"application-control.session.open",
|
||||
"application-control.workspace.enter",
|
||||
"application-control.session.close",
|
||||
} <= action_ids
|
||||
assert next(item for item in models if item["id"] == "xgrids.lixelkity-k1") == {
|
||||
"pluginId": "nodedc.device.xgrids-lixelkity-k1",
|
||||
"pluginVersion": "0.4.0",
|
||||
"pluginVersion": "0.5.0",
|
||||
"id": "xgrids.lixelkity-k1",
|
||||
"vendor": "XGRIDS",
|
||||
"displayName": "XGRIDS LixelKity K1",
|
||||
|
||||
@@ -16,6 +16,7 @@ from k1link.device_plugins.xgrids_k1.facade import (
|
||||
CameraPreviewSelectRequest,
|
||||
CompatibilityAttestationRequest,
|
||||
ConnectRequest,
|
||||
OperatorPresenceRequest,
|
||||
PrepareAcquisitionRequest,
|
||||
ShadowApplicationControlArmRequest,
|
||||
StartAcquisitionRequest,
|
||||
@@ -113,6 +114,61 @@ class FakeVisualizationRuntime:
|
||||
self.stop()
|
||||
|
||||
|
||||
class FakeInteractiveControlSession:
|
||||
def __init__(self) -> None:
|
||||
self.state = "workspace-ready"
|
||||
self.start_projects: list[str] = []
|
||||
self.stop_calls = 0
|
||||
self.confirm_calls = 0
|
||||
|
||||
def snapshot(self) -> dict[str, object]:
|
||||
return {
|
||||
"state": self.state,
|
||||
"can_confirm_standby": self.state == "awaiting-standby-confirmation",
|
||||
}
|
||||
|
||||
def open_project_prompt(self) -> dict[str, object]:
|
||||
assert self.state == "workspace-ready"
|
||||
self.state = "project-ready"
|
||||
return self.snapshot()
|
||||
|
||||
def request_start(self, *, project_name: str, confirmation: object) -> dict[str, object]:
|
||||
assert confirmation is not None
|
||||
assert self.state == "project-ready"
|
||||
self.start_projects.append(project_name)
|
||||
self.state = "scanning"
|
||||
return self.snapshot()
|
||||
|
||||
def request_stop(self, *, confirmation: object) -> dict[str, object]:
|
||||
assert confirmation is not None
|
||||
assert self.state == "scanning"
|
||||
self.stop_calls += 1
|
||||
self.state = "awaiting-standby-confirmation"
|
||||
return self.snapshot()
|
||||
|
||||
def confirm_standby(self) -> dict[str, object]:
|
||||
assert self.state == "awaiting-standby-confirmation"
|
||||
self.confirm_calls += 1
|
||||
self.state = "completed"
|
||||
return self.snapshot()
|
||||
|
||||
def close_prestart(self) -> dict[str, object]:
|
||||
self.state = "closed"
|
||||
return self.snapshot()
|
||||
|
||||
def close(self) -> None:
|
||||
self.state = "closed"
|
||||
|
||||
|
||||
PHYSICAL_ACCEPTANCE = OperatorPresenceRequest(
|
||||
operator_present=True,
|
||||
owner_controlled_device=True,
|
||||
lixelgo_closed=True,
|
||||
battery_storage_confirmed=True,
|
||||
expected_physical_state_confirmed=True,
|
||||
)
|
||||
|
||||
|
||||
def service_with_fake_runtime(
|
||||
tmp_path: Path,
|
||||
) -> tuple[XgridsK1CompatibilityService, FakeVisualizationRuntime]:
|
||||
@@ -291,6 +347,68 @@ def test_operator_manual_start_is_confirmed_only_by_real_point_data(tmp_path: Pa
|
||||
assert acquiring["last_operation"]["result"]["confirmation"] == "point-frame"
|
||||
|
||||
|
||||
def test_plugin_commanded_acquisition_keeps_start_and_stop_as_explicit_actions(
|
||||
tmp_path: Path,
|
||||
) -> None:
|
||||
service, runtime = service_with_fake_runtime(tmp_path)
|
||||
control = FakeInteractiveControlSession()
|
||||
service._application_control_session = control # type: ignore[assignment] # noqa: SLF001
|
||||
service._k1_ip = "192.168.1.20" # noqa: SLF001
|
||||
service._compatibility_attestation = {"profile": "exact"} # noqa: SLF001
|
||||
|
||||
prepared = service.prepare_acquisition(
|
||||
PrepareAcquisitionRequest(
|
||||
project_name="TEST001",
|
||||
host="192.168.1.20",
|
||||
compatibility_attestation=ATTESTATION,
|
||||
)
|
||||
)
|
||||
acquisition_id = prepared["acquisition"]["acquisition_id"]
|
||||
assert prepared["acquisition"]["control_mode"] == "plugin-commanded"
|
||||
assert control.state == "project-ready"
|
||||
assert runtime.start_calls == []
|
||||
|
||||
with pytest.raises(ValueError, match="подтверждения присутствия"):
|
||||
service.start_acquisition(StartAcquisitionRequest(acquisition_id=acquisition_id))
|
||||
assert control.start_projects == []
|
||||
|
||||
service.start_acquisition(
|
||||
StartAcquisitionRequest(
|
||||
acquisition_id=acquisition_id,
|
||||
physical_acceptance=PHYSICAL_ACCEPTANCE,
|
||||
)
|
||||
)
|
||||
assert control.start_projects == ["TEST001"]
|
||||
runtime.mark_ready()
|
||||
runtime.pcl_frames = 1
|
||||
acquiring = service.state()
|
||||
assert acquiring["acquisition"]["state"] == "acquiring"
|
||||
|
||||
stopping = service.stop_acquisition(
|
||||
StopAcquisitionRequest(
|
||||
acquisition_id=acquisition_id,
|
||||
mode="graceful",
|
||||
physical_acceptance=PHYSICAL_ACCEPTANCE,
|
||||
)
|
||||
)
|
||||
stop_operation = stopping["last_operation"]
|
||||
assert control.stop_calls == 1
|
||||
assert stopping["acquisition"]["state"] == "awaiting_external_stop"
|
||||
|
||||
completed = service.stop_acquisition(
|
||||
StopAcquisitionRequest(
|
||||
acquisition_id=acquisition_id,
|
||||
mode="graceful",
|
||||
operator_confirmed=True,
|
||||
operation_id=stop_operation["operation_id"],
|
||||
)
|
||||
)
|
||||
assert control.stop_calls == 1
|
||||
assert control.confirm_calls == 1
|
||||
assert completed["acquisition"]["state"] == "completed"
|
||||
assert runtime.stop_calls == 1
|
||||
|
||||
|
||||
def test_second_start_conflict_does_not_tear_down_start_owner(tmp_path: Path) -> None:
|
||||
service, runtime = service_with_fake_runtime(tmp_path)
|
||||
prepared = service.prepare_acquisition(
|
||||
|
||||
@@ -0,0 +1,218 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import threading
|
||||
import time
|
||||
from dataclasses import dataclass
|
||||
from typing import Any
|
||||
|
||||
import pytest
|
||||
|
||||
from k1link.device_plugins.xgrids_k1.protocol import application_session as session_module
|
||||
from k1link.device_plugins.xgrids_k1.protocol.application_bootstrap import (
|
||||
ApplicationControlAuthority,
|
||||
LiveDeviceControlBinding,
|
||||
)
|
||||
from k1link.device_plugins.xgrids_k1.protocol.application_session import (
|
||||
InteractiveApplicationControlSession,
|
||||
OperatorPresenceConfirmation,
|
||||
)
|
||||
|
||||
APPLICATION_KEY = "00000000-0000-0000-0000-000000000000"
|
||||
|
||||
|
||||
class FakeAuthorityLoader:
|
||||
def __init__(self) -> None:
|
||||
self.calls = 0
|
||||
|
||||
def load(self) -> ApplicationControlAuthority:
|
||||
self.calls += 1
|
||||
return ApplicationControlAuthority(openapi_key=APPLICATION_KEY)
|
||||
|
||||
|
||||
@dataclass
|
||||
class FakeTransportSnapshot:
|
||||
state: str
|
||||
ready: bool
|
||||
|
||||
def as_dict(self) -> dict[str, object]:
|
||||
return {
|
||||
"state": self.state,
|
||||
"latest_device_session_state": "ready" if self.ready else "scanning",
|
||||
"latest_device_project_bound": not self.ready,
|
||||
"automatic_retry": False,
|
||||
"automatic_reconnect": False,
|
||||
}
|
||||
|
||||
|
||||
class FakeTransport:
|
||||
def __init__(self, host: str) -> None:
|
||||
self.host = host
|
||||
self.state = "new"
|
||||
self.ready = True
|
||||
|
||||
def open(self) -> FakeTransportSnapshot:
|
||||
self.state = "ready"
|
||||
return self.snapshot()
|
||||
|
||||
def close(self) -> None:
|
||||
self.state = "closed"
|
||||
|
||||
def snapshot(self) -> FakeTransportSnapshot:
|
||||
return FakeTransportSnapshot(self.state, self.ready)
|
||||
|
||||
|
||||
class FakeExecutor:
|
||||
records: list[str] = []
|
||||
|
||||
def __init__(self, transport: FakeTransport) -> None:
|
||||
self.transport = transport
|
||||
self.binding = LiveDeviceControlBinding(
|
||||
vendor_device_id="device-id",
|
||||
device_serial="serial-id",
|
||||
software_version="3.0.2",
|
||||
system_version="3.0.2",
|
||||
device_model="K1",
|
||||
device_type="scanner",
|
||||
is_activated=True,
|
||||
)
|
||||
|
||||
def run_connection_stage(self, _orchestrator: object) -> LiveDeviceControlBinding:
|
||||
self.records.append("connection:1-6")
|
||||
return self.binding
|
||||
|
||||
def wait_for_operator_checkpoint(
|
||||
self,
|
||||
event: str,
|
||||
observed: Any,
|
||||
) -> str:
|
||||
self.records.append(f"wait:{event}")
|
||||
deadline = time.monotonic() + 2.0
|
||||
while not observed():
|
||||
if time.monotonic() >= deadline:
|
||||
raise TimeoutError(f"test did not release {event}")
|
||||
threading.Event().wait(0.005)
|
||||
return event
|
||||
|
||||
def run_workspace_entry_stage(
|
||||
self,
|
||||
_orchestrator: object,
|
||||
_checkpoint: object,
|
||||
) -> LiveDeviceControlBinding:
|
||||
self.records.append("workspace:7")
|
||||
return self.binding
|
||||
|
||||
def run_project_prompt_stage(
|
||||
self,
|
||||
_orchestrator: object,
|
||||
_checkpoint: object,
|
||||
) -> LiveDeviceControlBinding:
|
||||
self.records.append("project:8-10")
|
||||
return self.binding
|
||||
|
||||
def execute_canonical_start(self, *_args: object, **_kwargs: object) -> object:
|
||||
self.records.append("start:11-14")
|
||||
self.transport.ready = False
|
||||
return object()
|
||||
|
||||
def maintain_active_until_stop_requested(self, observed: Any) -> None:
|
||||
self.records.append("wait:stop")
|
||||
while not observed():
|
||||
threading.Event().wait(0.005)
|
||||
|
||||
def execute_canonical_stop(self, *_args: object, **_kwargs: object) -> object:
|
||||
self.records.append("stop")
|
||||
self.transport.ready = True
|
||||
return object()
|
||||
|
||||
def maintain_post_stop_until_standby_confirmed(self, observed: Any) -> None:
|
||||
self.records.append("wait:steady-green")
|
||||
while not observed():
|
||||
threading.Event().wait(0.005)
|
||||
|
||||
def snapshot(self) -> dict[str, object]:
|
||||
return {"records": tuple(self.records), "automatic_retry": False}
|
||||
|
||||
|
||||
def _confirmation() -> OperatorPresenceConfirmation:
|
||||
return OperatorPresenceConfirmation(
|
||||
operator_present=True,
|
||||
owner_controlled_device=True,
|
||||
lixelgo_closed=True,
|
||||
battery_storage_confirmed=True,
|
||||
expected_physical_state_confirmed=True,
|
||||
)
|
||||
|
||||
|
||||
def _wait_phase(
|
||||
session: InteractiveApplicationControlSession,
|
||||
expected: str,
|
||||
) -> dict[str, object]:
|
||||
deadline = time.monotonic() + 2.0
|
||||
while time.monotonic() < deadline:
|
||||
snapshot = session.snapshot()
|
||||
if snapshot["state"] == expected:
|
||||
return snapshot
|
||||
threading.Event().wait(0.005)
|
||||
raise AssertionError(f"session did not reach {expected}: {session.snapshot()}")
|
||||
|
||||
|
||||
def test_every_canonical_stage_requires_a_separate_operator_event(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
FakeExecutor.records = []
|
||||
monkeypatch.setattr(
|
||||
session_module,
|
||||
"PhysicalAcceptanceDialogueExecutor",
|
||||
FakeExecutor,
|
||||
)
|
||||
loader = FakeAuthorityLoader()
|
||||
transport = FakeTransport("192.168.1.20")
|
||||
session = InteractiveApplicationControlSession(
|
||||
loader,
|
||||
transport_factory=lambda _host: transport, # type: ignore[arg-type]
|
||||
epoch_seconds=lambda: 1_752_680_000,
|
||||
)
|
||||
|
||||
session.open(
|
||||
host="192.168.1.20",
|
||||
timezone_name="Europe/Moscow",
|
||||
confirmation=_confirmation(),
|
||||
)
|
||||
_wait_phase(session, "connection-ready")
|
||||
threading.Event().wait(0.02)
|
||||
assert FakeExecutor.records == ["connection:1-6", "wait:workspace-entered"]
|
||||
|
||||
session.enter_workspace()
|
||||
_wait_phase(session, "workspace-ready")
|
||||
assert FakeExecutor.records[-1] == "wait:project-prompt-opened"
|
||||
|
||||
session.open_project_prompt()
|
||||
_wait_phase(session, "project-ready")
|
||||
assert FakeExecutor.records[-1] == "wait:start-confirmed"
|
||||
|
||||
session.request_start(project_name="TEST001", confirmation=_confirmation())
|
||||
_wait_phase(session, "scanning")
|
||||
assert FakeExecutor.records[-1] == "wait:stop"
|
||||
|
||||
session.request_stop(confirmation=_confirmation())
|
||||
standby = _wait_phase(session, "awaiting-standby-confirmation")
|
||||
assert standby["can_confirm_standby"] is True
|
||||
assert FakeExecutor.records[-1] == "wait:steady-green"
|
||||
|
||||
session.confirm_standby()
|
||||
completed = _wait_phase(session, "completed")
|
||||
assert loader.calls == 1
|
||||
assert completed["automatic_retry"] is False
|
||||
assert completed["scripted_transitions"] is False
|
||||
assert FakeExecutor.records == [
|
||||
"connection:1-6",
|
||||
"wait:workspace-entered",
|
||||
"workspace:7",
|
||||
"wait:project-prompt-opened",
|
||||
"project:8-10",
|
||||
"wait:start-confirmed",
|
||||
"start:11-14",
|
||||
"wait:stop",
|
||||
"stop",
|
||||
"wait:steady-green",
|
||||
]
|
||||
Reference in New Issue
Block a user