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.entered = threading.Event() self.calls = 0 def claim_next(self, profile_id: str): self.calls += 1 self.entered.set() if self.gate is not None: if not self.gate.wait(5): raise RuntimeError("test_gateway_release_timeout") 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] = [] self.profiles: list[ProfileSettings] = [] def accept_claim(self, claimed: ClaimedTask, profile: ProfileSettings) -> None: self.claims.append(claimed) self.profiles.append(profile) def wait_until(predicate, timeout_ms: int = 2000) -> None: elapsed = 0 while not predicate() and elapsed < timeout_ms: # worker 仍通过真实 Qt 队列信号推进,因此测试必须持续处理事件; # 1 ms 量子既会让出 QThread,又不会把每次状态转换放大成 10 ms 等待。 QTest.qWait(1) elapsed += 1 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() 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() # CLAIMING 状态先于 queued worker 实际进入 claim_next;必须等待 # worker 入口,避免与 FakeGateway 的超时边界竞争而产生套件级偶发失败。 wait_until(lambda: coordinator.state == PollingState.CLAIMING and gateway.entered.is_set()) 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) self.assertEqual(gateway.calls, 1) self.assertFalse(coordinator._timer.isActive()) self.assertIsNone(coordinator._scheduled_epoch) 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_consumer_receives_start_snapshot_even_if_profile_changes_while_claiming(self) -> None: gate = threading.Event() store = FakeStore(snapshot()) gateway = FakeGateway([claimed_task()], gate) consumer = FakeConsumer() original = profile(http_timeout=10) coordinator, frozen = self.make_coordinator( store, gateway, consumer, settings=original, ) coordinator.start() wait_until(lambda: coordinator.state == PollingState.CLAIMING and gateway.calls == 1) changed = profile(http_timeout=27) coordinator.update_profile_settings(changed) gate.set() wait_until(lambda: coordinator.state == PollingState.ACTIVE) self.assertEqual(frozen, [original]) self.assertEqual(consumer.profiles, [original]) self.assertIsNot(consumer.profiles[0], changed) 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)