feat(client): add safe polling session UI
This commit is contained in:
@@ -0,0 +1 @@
|
||||
"""轮询协调器测试。"""
|
||||
@@ -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)
|
||||
@@ -22,3 +22,36 @@ class ApplicationArgumentsTests(unittest.TestCase):
|
||||
def test_none_uses_process_arguments(self) -> None:
|
||||
with mock.patch("cmbuyer_client.app.sys.argv", ["process-name", "--process-option"]):
|
||||
self.assertEqual(["process-name", "--process-option"], select_application_argv(None))
|
||||
|
||||
def test_standalone_entry_does_not_construct_http_or_device_capabilities(self) -> None:
|
||||
source = (CLIENT_ROOT / "src" / "cmbuyer_client" / "app.py").read_text(encoding="utf-8")
|
||||
for forbidden in (
|
||||
"HttpTaskSource",
|
||||
"HttpTransport",
|
||||
"DurableClientGateway",
|
||||
"import uiautomator2",
|
||||
".device",
|
||||
".pdd",
|
||||
):
|
||||
with self.subTest(forbidden=forbidden):
|
||||
self.assertNotIn(forbidden, source)
|
||||
self.assertIn("gateway_factory=None", source)
|
||||
self.assertIn("consumer=None", source)
|
||||
|
||||
def test_t304_ui_has_no_hidden_connection_probe_or_irreversible_capability_import(self) -> None:
|
||||
paths = [
|
||||
*(CLIENT_ROOT / "src" / "cmbuyer_client" / "ui").glob("*.py"),
|
||||
*(CLIENT_ROOT / "src" / "cmbuyer_client" / "polling").glob("*.py"),
|
||||
]
|
||||
source = "\n".join(path.read_text(encoding="utf-8") for path in paths)
|
||||
for forbidden in (
|
||||
"uiautomator2",
|
||||
"cmbuyer_client.pdd",
|
||||
"cmbuyer_client.device",
|
||||
"submit_order",
|
||||
"payment",
|
||||
"http.client",
|
||||
"subprocess",
|
||||
):
|
||||
with self.subTest(forbidden=forbidden):
|
||||
self.assertNotIn(forbidden, source)
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
"""原生 Qt Widgets UI 测试。"""
|
||||
@@ -0,0 +1,228 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
import tempfile
|
||||
import unittest
|
||||
|
||||
os.environ.setdefault("QT_QPA_PLATFORM", "offscreen")
|
||||
|
||||
from PySide6.QtCore import QObject, Qt, Signal
|
||||
from PySide6.QtTest import QTest
|
||||
from PySide6.QtWidgets import QApplication
|
||||
|
||||
from cmbuyer_client.polling.coordinator import PollingState, RecoveryStatus
|
||||
from cmbuyer_client.ui.execution import ExecutionPage
|
||||
from cmbuyer_client.ui.main_window import PurchaseToolWindow
|
||||
from cmbuyer_client.ui.records import PurchaseRecord
|
||||
|
||||
|
||||
class FakeCoordinator(QObject):
|
||||
state_changed = Signal(object, str, int)
|
||||
claim_visible = Signal(object)
|
||||
settled = Signal()
|
||||
recovery_status_changed = Signal(object)
|
||||
configuration_freeze_changed = Signal(bool)
|
||||
|
||||
def __init__(self) -> None:
|
||||
super().__init__()
|
||||
self.state = PollingState.BLOCKED
|
||||
self.reason = "单趟执行能力尚未接入,不能领取真实任务。"
|
||||
self.consecutive_failures = 0
|
||||
self.can_start = False
|
||||
self.operation_in_flight = False
|
||||
self.recovery_status = RecoveryStatus(False, False, False, False, False)
|
||||
self.starts = 0
|
||||
self.stops = 0
|
||||
|
||||
def start(self) -> None:
|
||||
self.starts += 1
|
||||
|
||||
def stop(self) -> None:
|
||||
self.stops += 1
|
||||
|
||||
def update_profile_settings(self, settings) -> None:
|
||||
pass
|
||||
|
||||
|
||||
class FakeStore:
|
||||
def __init__(self) -> None:
|
||||
self.calls = 0
|
||||
|
||||
def save_profile(self, settings, token) -> None:
|
||||
self.calls += 1
|
||||
|
||||
|
||||
def records() -> list[PurchaseRecord]:
|
||||
return [
|
||||
PurchaseRecord("old", "旧记录", "失败", "2026-08-04T09:00:00Z", "旧文字", "旧结果"),
|
||||
PurchaseRecord("new", "新记录", "待付款", "2026-08-04T10:00:00Z", "新文字", "新结果"),
|
||||
]
|
||||
|
||||
|
||||
class ExecutionPageTests(unittest.TestCase):
|
||||
@classmethod
|
||||
def setUpClass(cls) -> None:
|
||||
cls.app = QApplication.instance() or QApplication([])
|
||||
|
||||
def setUp(self) -> None:
|
||||
self.coordinator = FakeCoordinator()
|
||||
self.page = ExecutionPage(self.coordinator)
|
||||
self.page.resize(1100, 700)
|
||||
self.page.show()
|
||||
QTest.qWait(20)
|
||||
|
||||
def tearDown(self) -> None:
|
||||
self.page.close()
|
||||
|
||||
def test_standalone_start_is_disabled_with_nearby_reason_and_empty_real_model(self) -> None:
|
||||
self.assertFalse(self.page.poll_button.isEnabled())
|
||||
self.assertIn("尚未接入", self.page.banner.text())
|
||||
self.assertEqual(self.page.record_model.rowCount(), 0)
|
||||
self.assertEqual(self.page.record_model.columnCount(), 2)
|
||||
self.assertEqual(self.coordinator.starts, 0)
|
||||
|
||||
def test_records_are_newest_first_and_open_via_action_then_escape_restores_focus(self) -> None:
|
||||
self.page.set_records(records())
|
||||
self.assertEqual(self.page.record_model.data(self.page.record_model.index(0, 0)), "新记录")
|
||||
index = self.page.record_model.index(0, 0)
|
||||
self.page.record_view.setCurrentIndex(index)
|
||||
self.page.view_record_action.trigger()
|
||||
self.assertEqual(self.page.left_stack.currentIndex(), self.page.DETAIL_PAGE)
|
||||
self.assertEqual(self.page.detail_title.text(), "新记录")
|
||||
QTest.keyClick(self.page.return_button, Qt.Key.Key_Escape)
|
||||
QTest.qWait(20)
|
||||
self.assertEqual(self.page.left_stack.currentIndex(), self.page.LIVE_PAGE)
|
||||
self.assertEqual(self.page.record_view.currentIndex().row(), 0)
|
||||
self.assertTrue(self.page.record_view.hasFocus())
|
||||
|
||||
def test_detail_selection_updates_in_place_without_modal(self) -> None:
|
||||
self.page.set_records(records())
|
||||
self.page.record_view.setCurrentIndex(self.page.record_model.index(0, 0))
|
||||
self.page.open_selected_record()
|
||||
second = self.page.record_model.index(1, 0)
|
||||
self.page.record_view.setCurrentIndex(second)
|
||||
self.page._on_record_selected(second)
|
||||
self.assertEqual(self.page.detail_title.text(), "旧记录")
|
||||
self.assertEqual(self.page.detail_result.toPlainText(), "旧结果")
|
||||
|
||||
def test_double_click_enter_and_visible_action_share_inline_detail_command(self) -> None:
|
||||
self.page.set_records(records())
|
||||
index = self.page.record_model.index(0, 0)
|
||||
self.page.record_view.setCurrentIndex(index)
|
||||
rect = self.page.record_view.visualRect(index)
|
||||
QTest.mouseDClick(self.page.record_view.viewport(), Qt.MouseButton.LeftButton, pos=rect.center())
|
||||
self.assertEqual(self.page.left_stack.currentIndex(), self.page.DETAIL_PAGE)
|
||||
self.assertIsNone(QApplication.activeModalWidget())
|
||||
self.page.return_to_live()
|
||||
self.page.record_view.setFocus()
|
||||
QTest.keyClick(self.page.record_view, Qt.Key.Key_Return)
|
||||
self.assertEqual(self.page.left_stack.currentIndex(), self.page.DETAIL_PAGE)
|
||||
self.page.return_to_live()
|
||||
self.page.view_record_action.trigger()
|
||||
self.assertEqual(self.page.left_stack.currentIndex(), self.page.DETAIL_PAGE)
|
||||
|
||||
def test_responsive_resize_keeps_model_selection_and_inline_detail(self) -> None:
|
||||
self.page.set_records(records())
|
||||
model = self.page.record_model
|
||||
self.page.record_view.setCurrentIndex(model.index(0, 0))
|
||||
self.page.open_selected_record()
|
||||
for width in (700, 900, 1200):
|
||||
self.page.resize(width, 700)
|
||||
QTest.qWait(10)
|
||||
self.assertIs(self.page.record_view.model(), model)
|
||||
self.assertEqual(self.page.record_view.currentIndex().row(), 0)
|
||||
self.assertEqual(self.page.left_stack.currentIndex(), self.page.DETAIL_PAGE)
|
||||
|
||||
def test_record_model_detail_and_log_redact_bare_token_sentinel(self) -> None:
|
||||
token = "e" * 64
|
||||
record = PurchaseRecord("secret", "标题 " + token, "失败", "2026-08-04T10:00:00Z", token, token)
|
||||
self.page.set_records([record])
|
||||
self.page.record_view.setCurrentIndex(self.page.record_model.index(0, 0))
|
||||
self.page.open_selected_record()
|
||||
self.page.log_view.append_event("Bearer " + token)
|
||||
visible = "\n".join(
|
||||
(
|
||||
str(self.page.record_model.data(self.page.record_model.index(0, 0))),
|
||||
self.page.detail_original.toPlainText(),
|
||||
self.page.detail_result.toPlainText(),
|
||||
self.page.log_view.toPlainText(),
|
||||
)
|
||||
)
|
||||
self.assertNotIn(token, visible)
|
||||
self.assertIn("已隐藏", visible)
|
||||
|
||||
|
||||
class MainWindowTests(unittest.TestCase):
|
||||
@classmethod
|
||||
def setUpClass(cls) -> None:
|
||||
cls.app = QApplication.instance() or QApplication([])
|
||||
|
||||
def test_fixed_default_tabs_and_close_do_not_start_or_stop_blocked_session(self) -> None:
|
||||
coordinator = FakeCoordinator()
|
||||
window = PurchaseToolWindow(
|
||||
store=FakeStore(),
|
||||
coordinator=coordinator,
|
||||
profile_settings=None,
|
||||
has_stored_device_token=False,
|
||||
)
|
||||
window.show()
|
||||
QTest.qWait(10)
|
||||
self.assertEqual(window.tabs.count(), 2)
|
||||
self.assertEqual([window.tabs.tabText(i) for i in range(2)], ["采购执行", "配置"])
|
||||
self.assertIs(window.tabs.currentWidget(), window.execution_page)
|
||||
self.assertFalse(window.tabs.tabsClosable())
|
||||
window.close()
|
||||
self.assertEqual(coordinator.starts, 0)
|
||||
self.assertEqual(coordinator.stops, 0)
|
||||
|
||||
def test_close_waits_for_inflight_settlement_instead_of_terminating(self) -> None:
|
||||
coordinator = FakeCoordinator()
|
||||
coordinator.state = PollingState.CLAIMING
|
||||
coordinator.operation_in_flight = True
|
||||
window = PurchaseToolWindow(
|
||||
store=FakeStore(),
|
||||
coordinator=coordinator,
|
||||
profile_settings=None,
|
||||
has_stored_device_token=False,
|
||||
)
|
||||
window.show()
|
||||
self.assertFalse(window.close())
|
||||
self.assertTrue(window.isVisible())
|
||||
self.assertEqual(coordinator.stops, 1)
|
||||
coordinator.operation_in_flight = False
|
||||
coordinator.state = PollingState.STOPPED
|
||||
coordinator.settled.emit()
|
||||
QTest.qWait(20)
|
||||
self.assertFalse(window.isVisible())
|
||||
|
||||
def test_recovery_status_freezes_and_unfreezes_non_token_settings(self) -> None:
|
||||
coordinator = FakeCoordinator()
|
||||
window = PurchaseToolWindow(
|
||||
store=FakeStore(),
|
||||
coordinator=coordinator,
|
||||
profile_settings=None,
|
||||
has_stored_device_token=False,
|
||||
)
|
||||
coordinator.recovery_status_changed.emit(RecoveryStatus(True, False, True, False, False))
|
||||
self.assertFalse(window.settings_page.device_id.isEnabled())
|
||||
self.assertTrue(window.settings_page.device_token.isEnabled())
|
||||
coordinator.recovery_status_changed.emit(RecoveryStatus(False, False, False, False, False))
|
||||
self.assertTrue(window.settings_page.device_id.isEnabled())
|
||||
window.close()
|
||||
|
||||
|
||||
class PurchaseRecordTimestampTests(unittest.TestCase):
|
||||
def test_records_sort_by_real_rfc3339_nanoseconds_not_raw_text(self) -> None:
|
||||
precise = [
|
||||
PurchaseRecord("later", "稍后", "完成", "2026-08-04T10:00:00.9Z"),
|
||||
PurchaseRecord("earlier", "稍早", "完成", "2026-08-04T10:00:00.11Z"),
|
||||
]
|
||||
from cmbuyer_client.ui.records import PurchaseRecordModel
|
||||
|
||||
model = PurchaseRecordModel(precise)
|
||||
self.assertEqual(model.record_at(0).record_id, "later")
|
||||
|
||||
def test_record_timestamp_rejects_offset_and_noncanonical_trailing_zero(self) -> None:
|
||||
for timestamp in ("2026-08-04T10:00:00+08:00", "2026-08-04T10:00:00.10Z"):
|
||||
with self.subTest(timestamp=timestamp), self.assertRaises(ValueError):
|
||||
PurchaseRecord("id", "标题", "完成", timestamp)
|
||||
@@ -0,0 +1,144 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
from pathlib import Path
|
||||
import tempfile
|
||||
import unittest
|
||||
|
||||
os.environ.setdefault("QT_QPA_PLATFORM", "offscreen")
|
||||
|
||||
from PySide6.QtTest import QTest
|
||||
from PySide6.QtWidgets import QApplication, QPushButton
|
||||
|
||||
from cmbuyer_client.localstate.models import ProfileSettings
|
||||
from cmbuyer_client.ui.settings import SettingsPage
|
||||
|
||||
|
||||
DEVICE_ID = "f3c9f507-7473-4fa6-8d71-8786c34c6301"
|
||||
TOKEN = "b" * 64
|
||||
|
||||
|
||||
class FakeStore:
|
||||
def __init__(self, error: Exception | None = None) -> None:
|
||||
self.calls: list[tuple[ProfileSettings, object]] = []
|
||||
self.error = error
|
||||
|
||||
def save_profile(self, settings: ProfileSettings, token: object) -> None:
|
||||
self.calls.append((settings, token))
|
||||
if self.error is not None:
|
||||
raise self.error
|
||||
|
||||
|
||||
class SettingsPageTests(unittest.TestCase):
|
||||
@classmethod
|
||||
def setUpClass(cls) -> None:
|
||||
cls.app = QApplication.instance() or QApplication([])
|
||||
|
||||
def setUp(self) -> None:
|
||||
self.directory = tempfile.TemporaryDirectory()
|
||||
self.adb = Path(self.directory.name) / "adb.exe"
|
||||
self.adb.touch()
|
||||
|
||||
def tearDown(self) -> None:
|
||||
self.directory.cleanup()
|
||||
|
||||
def fill(self, page: SettingsPage) -> None:
|
||||
page.device_id.setText(DEVICE_ID)
|
||||
page.adb_path.setText(str(self.adb))
|
||||
page.adb_serial.setText("device-serial")
|
||||
|
||||
def existing_settings(self) -> ProfileSettings:
|
||||
return ProfileSettings(
|
||||
"default",
|
||||
"http://127.0.0.1:8080",
|
||||
DEVICE_ID,
|
||||
str(self.adb),
|
||||
"device-serial",
|
||||
"usb",
|
||||
)
|
||||
|
||||
def test_first_save_requires_token_and_performs_zero_store_write(self) -> None:
|
||||
store = FakeStore()
|
||||
page = SettingsPage(store)
|
||||
page.show()
|
||||
QTest.qWait(10)
|
||||
self.fill(page)
|
||||
page.save()
|
||||
self.assertEqual(store.calls, [])
|
||||
self.assertTrue(page.device_token.hasFocus())
|
||||
self.assertIn("首次保存", page.feedback.text())
|
||||
|
||||
def test_visual_order_focuses_invalid_device_before_missing_token_or_adb(self) -> None:
|
||||
store = FakeStore()
|
||||
page = SettingsPage(store)
|
||||
page.show()
|
||||
QTest.qWait(10)
|
||||
page.device_id.setText("not-a-uuid")
|
||||
page.save()
|
||||
self.assertEqual(store.calls, [])
|
||||
self.assertTrue(page.device_id.hasFocus())
|
||||
self.assertIn("UUIDv4", page.feedback.text())
|
||||
|
||||
def test_existing_blank_token_passes_none_and_success_never_refills(self) -> None:
|
||||
store = FakeStore()
|
||||
page = SettingsPage(store, settings=self.existing_settings(), has_stored_device_token=True)
|
||||
page.save()
|
||||
self.assertEqual(len(store.calls), 1)
|
||||
self.assertIsNone(store.calls[0][1])
|
||||
self.assertEqual(page.device_token.text(), "")
|
||||
self.assertEqual(page.token_status.text(), "已保存")
|
||||
|
||||
def test_nonempty_token_replaces_once_then_is_cleared(self) -> None:
|
||||
store = FakeStore()
|
||||
page = SettingsPage(store, settings=self.existing_settings(), has_stored_device_token=True)
|
||||
page.device_token.setText(TOKEN)
|
||||
page.save()
|
||||
self.assertEqual(len(store.calls), 1)
|
||||
self.assertEqual(store.calls[0][1].value, TOKEN)
|
||||
self.assertEqual(page.device_token.text(), "")
|
||||
self.assertNotIn(TOKEN, page.feedback.text())
|
||||
self.assertNotIn(TOKEN, page.token_status.text())
|
||||
|
||||
def test_failed_save_preserves_token_and_focuses_secret_field_without_echo(self) -> None:
|
||||
store = FakeStore(RuntimeError("must-not-appear-" + TOKEN))
|
||||
page = SettingsPage(store, settings=self.existing_settings(), has_stored_device_token=True)
|
||||
page.show()
|
||||
QTest.qWait(10)
|
||||
page.device_token.setText(TOKEN)
|
||||
page.save()
|
||||
self.assertEqual(page.device_token.text(), TOKEN)
|
||||
self.assertTrue(page.device_token.hasFocus())
|
||||
self.assertNotIn(TOKEN, page.feedback.text())
|
||||
|
||||
def test_pending_or_active_freezes_every_non_token_field_and_preserves_exact_settings(self) -> None:
|
||||
original = self.existing_settings()
|
||||
store = FakeStore()
|
||||
page = SettingsPage(
|
||||
store,
|
||||
settings=original,
|
||||
has_stored_device_token=True,
|
||||
identity_frozen=True,
|
||||
)
|
||||
for field in (
|
||||
page.device_id,
|
||||
page.adb_path,
|
||||
page.adb_serial,
|
||||
page.transport,
|
||||
page.poll_interval,
|
||||
page.failure_threshold,
|
||||
page.http_timeout,
|
||||
page.step_timeout,
|
||||
):
|
||||
self.assertFalse(field.isEnabled())
|
||||
self.assertTrue(page.device_token.isEnabled())
|
||||
page.device_token.setText(TOKEN)
|
||||
page.save()
|
||||
self.assertEqual(store.calls[0][0], original)
|
||||
|
||||
def test_page_has_no_connection_probe_command_and_explains_deferred_validation(self) -> None:
|
||||
page = SettingsPage(FakeStore())
|
||||
button_texts = [button.text() for button in page.findChildren(QPushButton)]
|
||||
self.assertEqual(button_texts, ["保存配置"])
|
||||
self.assertIn("首次真实领取", page.validation_hint.text())
|
||||
self.assertEqual(page.service_url.text(), "http://127.0.0.1:8080")
|
||||
self.assertTrue(page.service_url.isReadOnly())
|
||||
Reference in New Issue
Block a user