350 lines
14 KiB
Python
350 lines
14 KiB
Python
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)
|