feat(client): add safe polling session UI
This commit is contained in:
@@ -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)
|
||||
Reference in New Issue
Block a user