fix(k1): stabilize repeated acquisition and live viewer recovery
This commit is contained in:
@@ -170,6 +170,7 @@ def test_canonical_stages_require_operator_events_but_device_standby_does_not(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
FakeExecutor.records = []
|
||||
scanning_observed = threading.Event()
|
||||
monkeypatch.setattr(
|
||||
session_module,
|
||||
"PhysicalAcceptanceDialogueExecutor",
|
||||
@@ -181,6 +182,7 @@ def test_canonical_stages_require_operator_events_but_device_standby_does_not(
|
||||
loader,
|
||||
transport_factory=lambda _host: transport, # type: ignore[arg-type]
|
||||
epoch_seconds=lambda: 1_752_680_000,
|
||||
scanning_observer=scanning_observed.set,
|
||||
)
|
||||
|
||||
session.open(
|
||||
@@ -202,6 +204,7 @@ def test_canonical_stages_require_operator_events_but_device_standby_does_not(
|
||||
|
||||
session.request_start(project_name="TEST001", confirmation=_confirmation())
|
||||
_wait_phase(session, "scanning")
|
||||
assert scanning_observed.wait(timeout=1.0)
|
||||
assert FakeExecutor.records[-1] == "wait:stop"
|
||||
|
||||
session.request_stop(confirmation=_confirmation())
|
||||
@@ -210,6 +213,7 @@ def test_canonical_stages_require_operator_events_but_device_standby_does_not(
|
||||
assert completed["pending_operator_action"] is None
|
||||
assert loader.calls == 1
|
||||
assert completed["automatic_retry"] is False
|
||||
assert completed["scanning_observer_errors"] == 0
|
||||
assert completed["scripted_transitions"] is False
|
||||
assert FakeExecutor.records == [
|
||||
"connection:1-6",
|
||||
@@ -562,6 +566,210 @@ def test_start_outcome_unknown_blocks_reopen_even_when_transport_is_closed(
|
||||
assert failed["can_open"] is False
|
||||
assert failed["failure"]["modeling_command_attempted"] is True # type: ignore[index]
|
||||
assert failed["failure"]["safe_to_retry"] is False # type: ignore[index]
|
||||
with pytest.raises(
|
||||
session_module.ApplicationAcceptanceError,
|
||||
match="cannot be retired",
|
||||
):
|
||||
session.retire_for_network_change()
|
||||
|
||||
|
||||
def test_acknowledged_stop_disconnect_allows_only_explicit_network_change(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
@dataclass
|
||||
class StopDisconnectTransportSnapshot:
|
||||
state: str
|
||||
|
||||
def as_dict(self) -> dict[str, object]:
|
||||
return {
|
||||
"state": self.state,
|
||||
"publish_attempts": 15,
|
||||
"qos2_completions": 15,
|
||||
"correlated_responses": 13,
|
||||
"ignored_known_responses": 304,
|
||||
"late_known_responses": 0,
|
||||
"device_status_reports": 52,
|
||||
"latest_device_session_state": "scan_stopping",
|
||||
"latest_device_project_bound": True,
|
||||
"latest_system_error_code": None,
|
||||
"last_loop_result_code": 7,
|
||||
"last_loop_result_name": "The connection was lost.",
|
||||
"last_loop_phase": "maintain-open",
|
||||
"automatic_retry": False,
|
||||
"automatic_reconnect": False,
|
||||
}
|
||||
|
||||
class StopDisconnectTransport(FakeTransport):
|
||||
def snapshot(self) -> StopDisconnectTransportSnapshot:
|
||||
return StopDisconnectTransportSnapshot(self.state)
|
||||
|
||||
class StopDisconnectExecutor(FakeExecutor):
|
||||
def __init__(self, transport: StopDisconnectTransport) -> None:
|
||||
super().__init__(transport)
|
||||
self.stop_attempted = False
|
||||
self.stop_complete = False
|
||||
|
||||
def execute_canonical_start(self, *_args: object, **_kwargs: object) -> object:
|
||||
self.records.append("start:11-14")
|
||||
return object()
|
||||
|
||||
def execute_canonical_stop(self, *_args: object, **_kwargs: object) -> object:
|
||||
self.records.append("stop")
|
||||
self.stop_attempted = True
|
||||
self.stop_complete = True
|
||||
return object()
|
||||
|
||||
def maintain_post_stop_until_standby(self) -> None:
|
||||
self.records.append("wait:device-standby")
|
||||
self.transport.state = "poisoned"
|
||||
raise session_module.ApplicationCommandOutcomeUnknown(
|
||||
"control MQTT network loop returned an error",
|
||||
reason_code="mqtt_network_loop_failed",
|
||||
)
|
||||
|
||||
def snapshot(self) -> dict[str, object]:
|
||||
return {
|
||||
"dialogue_stage": (
|
||||
"stop-acknowledged" if self.stop_complete else "test-stage"
|
||||
),
|
||||
"start_attempted": True,
|
||||
"start_complete": True,
|
||||
"stop_attempted": self.stop_attempted,
|
||||
"stop_complete": self.stop_complete,
|
||||
"response_evidence": [],
|
||||
"automatic_retry": False,
|
||||
}
|
||||
|
||||
FakeExecutor.records = []
|
||||
monkeypatch.setattr(
|
||||
session_module,
|
||||
"PhysicalAcceptanceDialogueExecutor",
|
||||
StopDisconnectExecutor,
|
||||
)
|
||||
session = InteractiveApplicationControlSession(
|
||||
FakeAuthorityLoader(),
|
||||
transport_factory=lambda host: StopDisconnectTransport(host), # type: ignore[arg-type]
|
||||
)
|
||||
|
||||
session.open(
|
||||
host="192.168.1.20",
|
||||
timezone_name="Europe/Moscow",
|
||||
confirmation=_confirmation(),
|
||||
)
|
||||
_wait_phase(session, "connection-ready")
|
||||
session.enter_workspace()
|
||||
_wait_phase(session, "workspace-ready")
|
||||
session.open_project_prompt()
|
||||
_wait_phase(session, "project-ready")
|
||||
session.request_start(project_name="TEST001", confirmation=_confirmation())
|
||||
_wait_phase(session, "scanning")
|
||||
session.request_stop(confirmation=_confirmation())
|
||||
failed = _wait_phase(session, "failed")
|
||||
failed_thread = session._thread # noqa: SLF001
|
||||
assert failed_thread is not None
|
||||
failed_thread.join(timeout=2.0)
|
||||
assert not failed_thread.is_alive()
|
||||
failed = session.snapshot()
|
||||
|
||||
assert failed["can_open"] is False
|
||||
assert failed["outcome_unknown"] is True
|
||||
failure = failed["failure"]
|
||||
assert isinstance(failure, dict)
|
||||
assert failure["safe_to_retry"] is False
|
||||
assert failure["network_change_admissible"] is True
|
||||
assert failure["network_change_reconciliation"] == {
|
||||
"device_session_state": "scan_stopping",
|
||||
"device_project_bound": True,
|
||||
"system_error_code": None,
|
||||
"stop_complete": True,
|
||||
"standby_confirmed": False,
|
||||
"decision": "explicit-network-change-only-after-acknowledged-stop",
|
||||
"automatic_retry": False,
|
||||
}
|
||||
|
||||
retired = session.retire_for_network_change()
|
||||
assert retired["state"] == "idle"
|
||||
assert retired["failure"] is None
|
||||
assert retired["can_open"] is True
|
||||
|
||||
|
||||
def test_prestart_loop_failure_allows_only_fresh_explicit_retry_after_ready_status(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
@dataclass
|
||||
class ReconciledTransportSnapshot:
|
||||
state: str
|
||||
|
||||
def as_dict(self) -> dict[str, object]:
|
||||
return {
|
||||
"state": self.state,
|
||||
"publish_attempts": 4,
|
||||
"qos2_completions": 4,
|
||||
"correlated_responses": 3,
|
||||
"ignored_known_responses": 0,
|
||||
"late_known_responses": 0,
|
||||
"device_status_reports": 1,
|
||||
"latest_device_session_state": "ready",
|
||||
"latest_device_project_bound": False,
|
||||
"latest_system_error_code": None,
|
||||
"last_loop_result_code": 7,
|
||||
"last_loop_result_name": "The connection was lost.",
|
||||
"last_loop_phase": "post-publish-drain",
|
||||
"automatic_retry": False,
|
||||
"automatic_reconnect": False,
|
||||
}
|
||||
|
||||
class ReconciledTransport(FakeTransport):
|
||||
def snapshot(self) -> ReconciledTransportSnapshot:
|
||||
return ReconciledTransportSnapshot(self.state)
|
||||
|
||||
class PrestartLoopFailureExecutor(FakeExecutor):
|
||||
def run_connection_stage(
|
||||
self,
|
||||
_orchestrator: object,
|
||||
) -> LiveDeviceControlBinding:
|
||||
raise session_module.ApplicationCommandOutcomeUnknown(
|
||||
"control MQTT network loop returned an error",
|
||||
reason_code="mqtt_network_loop_failed",
|
||||
)
|
||||
|
||||
monkeypatch.setattr(
|
||||
session_module,
|
||||
"PhysicalAcceptanceDialogueExecutor",
|
||||
PrestartLoopFailureExecutor,
|
||||
)
|
||||
session = InteractiveApplicationControlSession(
|
||||
FakeAuthorityLoader(),
|
||||
transport_factory=lambda host: ReconciledTransport(host), # type: ignore[arg-type]
|
||||
)
|
||||
|
||||
session.open(
|
||||
host="192.168.1.20",
|
||||
timezone_name="Europe/Moscow",
|
||||
confirmation=_confirmation(),
|
||||
)
|
||||
failed_thread = session._thread # noqa: SLF001
|
||||
assert failed_thread is not None
|
||||
failed_thread.join(timeout=2.0)
|
||||
assert not failed_thread.is_alive()
|
||||
failed = session.snapshot()
|
||||
|
||||
assert failed["state"] == "failed"
|
||||
assert failed["outcome_unknown"] is True
|
||||
assert failed["automatic_retry"] is False
|
||||
assert failed["can_open"] is True
|
||||
failure = failed["failure"]
|
||||
assert isinstance(failure, dict)
|
||||
assert failure["reason_code"] == "mqtt_network_loop_failed"
|
||||
assert failure["modeling_command_attempted"] is False
|
||||
assert failure["safe_to_retry"] is True
|
||||
assert failure["status_reconciliation"] == {
|
||||
"device_session_state": "ready",
|
||||
"device_project_bound": False,
|
||||
"system_error_code": None,
|
||||
"decision": "safe-explicit-prestart-retry",
|
||||
"automatic_retry": False,
|
||||
}
|
||||
|
||||
|
||||
def test_unavailable_transport_snapshot_after_publish_blocks_reopen(
|
||||
|
||||
Reference in New Issue
Block a user