feat(client): add safe polling session UI
This commit is contained in:
@@ -0,0 +1,349 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
import threading
|
||||
import unittest
|
||||
|
||||
os.environ.setdefault("QT_QPA_PLATFORM", "offscreen")
|
||||
|
||||
from PySide6.QtTest import QTest
|
||||
from PySide6.QtWidgets import QApplication
|
||||
|
||||
from cmbuyer_client.core.errors import (
|
||||
AmbiguousRemoteError,
|
||||
CredentialRemoteError,
|
||||
ManualRemoteError,
|
||||
ProtocolRemoteError,
|
||||
StateError,
|
||||
)
|
||||
from cmbuyer_client.core.models import ClaimRequest, ClaimedTask
|
||||
from cmbuyer_client.localstate.models import PollingSession, ProfileSettings, RecoverySnapshot
|
||||
from cmbuyer_client.polling.coordinator import (
|
||||
ClaimedTaskView,
|
||||
PollingCoordinator,
|
||||
PollingState,
|
||||
StartReadiness,
|
||||
)
|
||||
from tests.core.test_models import claim_wire
|
||||
|
||||
|
||||
PROFILE = "default"
|
||||
SESSION_ID = "13c9f507-7473-4fa6-8d71-8786c34c6301"
|
||||
REQUEST_ID = "23c9f507-7473-4fa6-8d71-8786c34c6301"
|
||||
SENTINEL_TOKEN = "e" * 64
|
||||
|
||||
|
||||
def profile(http_timeout: int = 10, failure_threshold: int = 3) -> ProfileSettings:
|
||||
return ProfileSettings(
|
||||
PROFILE,
|
||||
"http://127.0.0.1:8080",
|
||||
"f3c9f507-7473-4fa6-8d71-8786c34c6301",
|
||||
"D:/Portable/adb/adb.exe",
|
||||
"device-serial",
|
||||
"usb",
|
||||
failure_threshold=failure_threshold,
|
||||
http_timeout_seconds=http_timeout,
|
||||
)
|
||||
|
||||
|
||||
def claimed_task() -> ClaimedTask:
|
||||
wire = claim_wire()
|
||||
wire["attempt"]["claim_token"] = SENTINEL_TOKEN
|
||||
return ClaimedTask.from_wire(wire)
|
||||
|
||||
|
||||
def snapshot(
|
||||
*,
|
||||
accept_new: bool | None = None,
|
||||
pending: bool = False,
|
||||
active: bool = False,
|
||||
) -> RecoverySnapshot:
|
||||
session = None if accept_new is None else PollingSession(PROFILE, SESSION_ID, accept_new)
|
||||
request = ClaimRequest(SESSION_ID, REQUEST_ID) if pending else None
|
||||
return RecoverySnapshot(session, request, claimed_task() if active else None, None, ())
|
||||
|
||||
|
||||
class FakeStore:
|
||||
def __init__(self, current: RecoverySnapshot) -> None:
|
||||
self.current = current
|
||||
self.recovery_calls = 0
|
||||
self.start_calls = 0
|
||||
self.stop_calls = 0
|
||||
|
||||
def recovery_snapshot(self, profile_id: str) -> RecoverySnapshot:
|
||||
self.recovery_calls += 1
|
||||
return self.current
|
||||
|
||||
def start_or_resume_polling(self, profile_id: str) -> PollingSession:
|
||||
self.start_calls += 1
|
||||
session = self.current.session or PollingSession(PROFILE, SESSION_ID, True)
|
||||
session = PollingSession(PROFILE, session.session_id, True)
|
||||
self.current = RecoverySnapshot(
|
||||
session,
|
||||
self.current.pending_claim,
|
||||
self.current.active_claim,
|
||||
self.current.pending_renew,
|
||||
self.current.pending_evidence,
|
||||
)
|
||||
return session
|
||||
|
||||
def request_stop(self, profile_id: str) -> PollingSession:
|
||||
self.stop_calls += 1
|
||||
if self.current.session is None:
|
||||
raise StateError("polling_session_not_found")
|
||||
session = PollingSession(PROFILE, self.current.session.session_id, False)
|
||||
self.current = RecoverySnapshot(
|
||||
session,
|
||||
self.current.pending_claim,
|
||||
self.current.active_claim,
|
||||
self.current.pending_renew,
|
||||
self.current.pending_evidence,
|
||||
)
|
||||
return session
|
||||
|
||||
|
||||
class FakeGateway:
|
||||
def __init__(self, outcomes: list[object] | None = None, gate: threading.Event | None = None) -> None:
|
||||
self.outcomes = list(outcomes or [None])
|
||||
self.gate = gate
|
||||
self.calls = 0
|
||||
|
||||
def claim_next(self, profile_id: str):
|
||||
self.calls += 1
|
||||
if self.gate is not None:
|
||||
self.gate.wait(2)
|
||||
outcome = self.outcomes.pop(0) if self.outcomes else None
|
||||
if isinstance(outcome, Exception):
|
||||
raise outcome
|
||||
return outcome
|
||||
|
||||
|
||||
class FakeConsumer:
|
||||
def __init__(self) -> None:
|
||||
self.claims: list[ClaimedTask] = []
|
||||
|
||||
def accept_claim(self, claimed: ClaimedTask) -> None:
|
||||
self.claims.append(claimed)
|
||||
|
||||
|
||||
def wait_until(predicate, timeout_ms: int = 2000) -> None:
|
||||
elapsed = 0
|
||||
while not predicate() and elapsed < timeout_ms:
|
||||
QTest.qWait(10)
|
||||
elapsed += 10
|
||||
if not predicate():
|
||||
raise AssertionError("condition_not_reached")
|
||||
|
||||
|
||||
class PollingCoordinatorTests(unittest.TestCase):
|
||||
@classmethod
|
||||
def setUpClass(cls) -> None:
|
||||
cls.app = QApplication.instance() or QApplication([])
|
||||
|
||||
def make_coordinator(
|
||||
self,
|
||||
store: FakeStore,
|
||||
gateway: FakeGateway | None,
|
||||
consumer: FakeConsumer | None,
|
||||
*,
|
||||
readiness: StartReadiness | None = StartReadiness(True, "ready"),
|
||||
settings: ProfileSettings | None = None,
|
||||
threshold: int = 3,
|
||||
interval_ms: int = 5,
|
||||
) -> tuple[PollingCoordinator, list[ProfileSettings]]:
|
||||
frozen: list[ProfileSettings] = []
|
||||
|
||||
def factory(value: ProfileSettings):
|
||||
frozen.append(value)
|
||||
assert gateway is not None
|
||||
return gateway
|
||||
|
||||
coordinator = PollingCoordinator(
|
||||
profile_id=PROFILE,
|
||||
store=store,
|
||||
gateway_factory=None if gateway is None else factory,
|
||||
consumer=consumer,
|
||||
profile_settings=settings or profile(failure_threshold=threshold),
|
||||
readiness=readiness,
|
||||
failure_threshold=threshold,
|
||||
timer_interval_ms=interval_ms,
|
||||
)
|
||||
self.addCleanup(lambda: self.assertTrue(coordinator.shutdown()))
|
||||
wait_until(lambda: not coordinator.operation_in_flight)
|
||||
return coordinator, frozen
|
||||
|
||||
def test_restart_normalizes_waiting_pending_and_active_without_http(self) -> None:
|
||||
for name, current, expected in (
|
||||
("waiting", snapshot(accept_new=True), PollingState.STOPPED),
|
||||
("pending", snapshot(accept_new=True, pending=True), PollingState.STOPPED),
|
||||
("active", snapshot(accept_new=True, active=True), PollingState.RECOVERY_REQUIRED),
|
||||
):
|
||||
with self.subTest(name=name):
|
||||
store = FakeStore(current)
|
||||
gateway = FakeGateway()
|
||||
consumer = FakeConsumer()
|
||||
coordinator, frozen = self.make_coordinator(store, gateway, consumer)
|
||||
self.assertEqual(coordinator.state, expected)
|
||||
self.assertFalse(store.current.session.accept_new)
|
||||
self.assertEqual(store.stop_calls, 1)
|
||||
self.assertEqual(gateway.calls, 0)
|
||||
self.assertEqual(frozen, [])
|
||||
|
||||
def test_missing_consumer_direct_start_is_zero_gateway_and_zero_session_start(self) -> None:
|
||||
store = FakeStore(snapshot())
|
||||
gateway = FakeGateway()
|
||||
coordinator, frozen = self.make_coordinator(store, gateway, None)
|
||||
coordinator.start()
|
||||
QTest.qWait(20)
|
||||
self.assertEqual(coordinator.state, PollingState.BLOCKED)
|
||||
self.assertIn("尚未接入", coordinator.reason)
|
||||
self.assertEqual(store.start_calls, 0)
|
||||
self.assertEqual(gateway.calls, 0)
|
||||
self.assertEqual(frozen, [])
|
||||
|
||||
def test_readiness_is_rechecked_inside_start_handler(self) -> None:
|
||||
store = FakeStore(snapshot())
|
||||
gateway = FakeGateway()
|
||||
coordinator, frozen = self.make_coordinator(
|
||||
store,
|
||||
gateway,
|
||||
FakeConsumer(),
|
||||
readiness=StartReadiness(False, "设备尚未就绪"),
|
||||
)
|
||||
coordinator.start()
|
||||
self.assertEqual(coordinator.state, PollingState.BLOCKED)
|
||||
self.assertEqual(store.start_calls, 0)
|
||||
self.assertEqual(gateway.calls, 0)
|
||||
self.assertEqual(frozen, [])
|
||||
|
||||
def test_empty_waits_then_stop_persists_accept_new_false(self) -> None:
|
||||
store = FakeStore(snapshot())
|
||||
gateway = FakeGateway([None])
|
||||
coordinator, frozen = self.make_coordinator(store, gateway, FakeConsumer(), interval_ms=1000)
|
||||
coordinator.start()
|
||||
wait_until(lambda: coordinator.state == PollingState.WAITING and gateway.calls == 1)
|
||||
self.assertEqual(frozen[0].http_timeout_seconds, 10)
|
||||
coordinator.stop()
|
||||
wait_until(lambda: coordinator.state == PollingState.STOPPED and not coordinator.operation_in_flight)
|
||||
self.assertFalse(store.current.session.accept_new)
|
||||
self.assertEqual(gateway.calls, 1)
|
||||
|
||||
def test_explicit_start_that_observes_stale_accept_new_stops_without_http(self) -> None:
|
||||
store = FakeStore(snapshot())
|
||||
gateway = FakeGateway([None])
|
||||
coordinator, frozen = self.make_coordinator(store, gateway, FakeConsumer())
|
||||
store.current = snapshot(accept_new=True)
|
||||
coordinator.start()
|
||||
wait_until(lambda: coordinator.state == PollingState.STOPPED and not coordinator.operation_in_flight)
|
||||
self.assertFalse(store.current.session.accept_new)
|
||||
self.assertEqual(gateway.calls, 0)
|
||||
self.assertEqual(len(frozen), 1)
|
||||
self.assertIn("再次显式开始", coordinator.reason)
|
||||
|
||||
def test_stop_during_claim_commits_then_requires_recovery_without_consumer_delivery(self) -> None:
|
||||
gate = threading.Event()
|
||||
store = FakeStore(snapshot())
|
||||
gateway = FakeGateway([claimed_task()], gate)
|
||||
consumer = FakeConsumer()
|
||||
coordinator, _ = self.make_coordinator(store, gateway, consumer)
|
||||
visible: list[object] = []
|
||||
coordinator.claim_visible.connect(visible.append)
|
||||
coordinator.start()
|
||||
wait_until(lambda: coordinator.state == PollingState.CLAIMING)
|
||||
coordinator._begin_claim(coordinator._epoch)
|
||||
self.assertEqual(gateway.calls, 1)
|
||||
coordinator.stop()
|
||||
old_epoch = coordinator._epoch - 1
|
||||
coordinator._begin_claim(old_epoch)
|
||||
gate.set()
|
||||
wait_until(lambda: coordinator.state == PollingState.RECOVERY_REQUIRED and not coordinator.operation_in_flight)
|
||||
self.assertEqual(consumer.claims, [])
|
||||
self.assertEqual(gateway.calls, 1)
|
||||
self.assertFalse(store.current.session.accept_new)
|
||||
self.assertEqual(len(visible), 1)
|
||||
self.assertIsInstance(visible[0], ClaimedTaskView)
|
||||
self.assertNotIn(SENTINEL_TOKEN, repr(visible[0]))
|
||||
|
||||
def test_only_reason_whitelist_gets_automatic_same_gateway_retry(self) -> None:
|
||||
store = FakeStore(snapshot(accept_new=False, pending=True))
|
||||
gateway = FakeGateway(
|
||||
[AmbiguousRemoteError("http_result_unknown"), AmbiguousRemoteError("truncated_response")]
|
||||
)
|
||||
coordinator, _ = self.make_coordinator(store, gateway, FakeConsumer(), threshold=2)
|
||||
coordinator.start()
|
||||
wait_until(lambda: coordinator.state == PollingState.BLOCKED and not coordinator.operation_in_flight)
|
||||
self.assertEqual(gateway.calls, 2)
|
||||
self.assertEqual(store.current.pending_claim.claim_request_id, REQUEST_ID)
|
||||
self.assertFalse(store.current.session.accept_new)
|
||||
|
||||
def test_schema_ambiguity_blocks_after_one_call_and_preserves_pending(self) -> None:
|
||||
for reason in (
|
||||
"unknown_success_status",
|
||||
"invalid_claim_success_response",
|
||||
"invalid_claim_lease",
|
||||
"ambiguous_response_framing",
|
||||
"invalid_content_length",
|
||||
"response_too_large",
|
||||
):
|
||||
with self.subTest(reason=reason):
|
||||
store = FakeStore(snapshot(accept_new=False, pending=True))
|
||||
gateway = FakeGateway([AmbiguousRemoteError(reason)])
|
||||
coordinator, _ = self.make_coordinator(store, gateway, FakeConsumer())
|
||||
coordinator.start()
|
||||
wait_until(lambda: coordinator.state == PollingState.BLOCKED and not coordinator.operation_in_flight)
|
||||
self.assertEqual(gateway.calls, 1)
|
||||
self.assertEqual(store.current.pending_claim.claim_request_id, REQUEST_ID)
|
||||
|
||||
def test_credential_manual_and_local_failures_do_not_enter_timer_retry(self) -> None:
|
||||
for error, expected_frozen in (
|
||||
(CredentialRemoteError("invalid_device_credentials"), True),
|
||||
(ManualRemoteError("claim_requires_manual"), False),
|
||||
(ProtocolRemoteError("invalid_protocol"), False),
|
||||
(StateError("localstate_integrity_failed"), True),
|
||||
):
|
||||
with self.subTest(error=error.reason):
|
||||
store = FakeStore(snapshot(accept_new=False, pending=True))
|
||||
gateway = FakeGateway([error])
|
||||
coordinator, _ = self.make_coordinator(store, gateway, FakeConsumer())
|
||||
freeze_events: list[bool] = []
|
||||
coordinator.configuration_freeze_changed.connect(freeze_events.append)
|
||||
coordinator.start()
|
||||
wait_until(lambda: coordinator.state == PollingState.BLOCKED and not coordinator.operation_in_flight)
|
||||
QTest.qWait(30)
|
||||
self.assertEqual(gateway.calls, 1)
|
||||
self.assertFalse(store.current.session.accept_new)
|
||||
self.assertEqual(freeze_events[-1], expected_frozen)
|
||||
|
||||
def test_stop_latched_bootstrap_active_emits_settled_for_pending_close(self) -> None:
|
||||
store = FakeStore(snapshot())
|
||||
gateway = FakeGateway()
|
||||
coordinator, _ = self.make_coordinator(store, gateway, FakeConsumer())
|
||||
store.current = snapshot(accept_new=False, active=True)
|
||||
settled: list[bool] = []
|
||||
coordinator.settled.connect(lambda: settled.append(True))
|
||||
coordinator.start()
|
||||
coordinator.stop()
|
||||
wait_until(lambda: coordinator.state == PollingState.RECOVERY_REQUIRED)
|
||||
self.assertTrue(settled)
|
||||
self.assertEqual(gateway.calls, 0)
|
||||
|
||||
def test_each_explicit_start_freezes_profile_for_gateway_factory(self) -> None:
|
||||
store = FakeStore(snapshot())
|
||||
gateway = FakeGateway([None])
|
||||
coordinator, frozen = self.make_coordinator(store, gateway, FakeConsumer(), interval_ms=1000)
|
||||
changed = profile(http_timeout=27)
|
||||
coordinator.update_profile_settings(changed)
|
||||
coordinator.start()
|
||||
wait_until(lambda: gateway.calls == 1)
|
||||
self.assertEqual(frozen, [changed])
|
||||
coordinator.stop()
|
||||
wait_until(lambda: not coordinator.operation_in_flight)
|
||||
|
||||
def test_claim_view_redacts_sentinel_even_if_title_contains_it(self) -> None:
|
||||
wire = claim_wire()
|
||||
wire["task"]["title"] = "标题 " + SENTINEL_TOKEN
|
||||
wire["attempt"]["claim_token"] = SENTINEL_TOKEN
|
||||
claimed = ClaimedTask.from_wire(wire)
|
||||
view = ClaimedTaskView.from_claim(claimed)
|
||||
self.assertNotIn(SENTINEL_TOKEN, repr(view))
|
||||
self.assertIn("已隐藏", view.title)
|
||||
Reference in New Issue
Block a user