219 lines
6.6 KiB
Python
219 lines
6.6 KiB
Python
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",
|
|
]
|