feat(k1): wire canonical control session to UI
This commit is contained in:
@@ -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