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", ]