Files
cmautobuy/client/test/test_pdd_ui_event.py
T

1901 lines
67 KiB
Python
Raw Normal View History

"""PDD 任务列表接入 SQLite 的离屏测试。"""
import os
import tempfile
import threading
import time
import unittest
from pathlib import Path
from unittest.mock import patch
os.environ.setdefault("QT_QPA_PLATFORM", "offscreen")
from PyQt5.QtCore import Qt
from PyQt5.QtWidgets import QApplication
from qfluentwidgets import InfoBarPosition
from src.adb_runtime import BundledAdbError
from src.android_device_service import AndroidDeviceSearchError
from src.db import open_database
from src.pdd_ui import PDDTaskPage
2026-08-09 10:54:18 +08:00
from src.admin_gateway import AdminGatewayError, AdminTask, SubmissionReceipt
from src.pdd_ui_event import (
ERROR_FEEDBACK_BUTTON_MIN_HEIGHT,
ERROR_FEEDBACK_BUTTON_MIN_WIDTH,
ERROR_FEEDBACK_DURATION_MS,
ERROR_FEEDBACK_MIN_HEIGHT,
ERROR_FEEDBACK_MIN_WIDTH,
PURCHASE_RECONCILE_DELAY_MS,
PDDTaskPageEvent,
admin_task_to_new_claimed_task,
summary_to_row,
)
2026-08-06 17:57:49 +08:00
from src.mock_admin_gateway import MockAdminGateway
from src.pdd_purchase_adapter import PddPurchaseAdapter, PurchasePageState
2026-08-10 09:08:54 +08:00
from src.pdd_collect_service import PddCollectError
from src.pdd_u2_purchase_adapter import create_u2_purchase_adapter
2026-08-06 17:57:49 +08:00
from src.settings_repository import SettingsRepository
from src.task_models import (
NewClaimedTask,
OutboxEventType,
TaskStatus,
TaskSummary,
TaskType,
)
from src.task_repository import TaskRepository
from src.ui_main import MainWindow, ui_main
class BrokenRepository:
"""模拟无法读取数据库的 Repository。"""
def list_tasks(self, filters=None, limit=50, offset=0):
raise RuntimeError("database is unavailable")
class BrokenSaveRepository(BrokenRepository):
"""模拟任务已经在 Admin 领取,但本地写入失败。"""
def add_claimed_task(self, _task):
raise RuntimeError("disk is full")
def next_pending_outbox(self):
return None
def next_collect_task(self):
return None
def next_runnable_task(self, *, include_purchase):
return None
def next_purchase_task(self):
2026-08-10 00:27:52 +08:00
return None
def next_purchase_reconcile_task(self):
return None
class RecordingClaimGateway:
"""记录领取参数并返回预设结果。"""
def __init__(self, response=None):
self.response = response
self.calls = []
self.thread_ids = []
def claim_next(self, client, capabilities):
self.calls.append((client, capabilities))
self.thread_ids.append(threading.get_ident())
return self.response
def submit_result(self, task_id, idempotency_key, result):
return SubmissionReceipt(True, "RESULT-001", "2026-08-07T08:00:00Z")
def submit_failure(self, task_id, idempotency_key, failure):
return SubmissionReceipt(True, "FAILURE-001", "2026-08-07T08:00:00Z")
class SlowClaimGateway(RecordingClaimGateway):
"""让关闭测试能稳定发生在 HTTP 返回之前。"""
def __init__(self, response):
super().__init__(response)
self.started = threading.Event()
def claim_next(self, client, capabilities):
self.started.set()
time.sleep(0.1)
return super().claim_next(client, capabilities)
2026-08-09 10:54:18 +08:00
class SequenceClaimGateway(RecordingClaimGateway):
"""依次返回多条任务,并记录是否出现并发领取。"""
def __init__(self, responses):
super().__init__()
self.responses = list(responses)
self.active_calls = 0
self.max_active_calls = 0
def claim_next(self, client, capabilities):
self.active_calls += 1
self.max_active_calls = max(self.max_active_calls, self.active_calls)
try:
time.sleep(0.01)
response = self.responses.pop(0) if self.responses else None
self.response = response
return super().claim_next(client, capabilities)
finally:
self.active_calls -= 1
class RetryableClaimGateway(RecordingClaimGateway):
"""模拟 Admin 暂时不可用。"""
def claim_next(self, client, capabilities):
self.calls.append((client, capabilities))
raise AdminGatewayError("ADMIN_UNAVAILABLE", "Admin 暂时不可用", True)
class RecordingResubmitGateway(RecordingClaimGateway):
"""记录结果重新上报,并可按任务编号模拟失败。"""
def __init__(self, failures=None, delay=0.0):
super().__init__()
self.failures = dict(failures or {})
self.delay = delay
self.submit_calls = []
self.failure_calls = []
def submit_result(self, task_id, idempotency_key, result):
self.submit_calls.append((task_id, idempotency_key, result))
if self.delay:
time.sleep(self.delay)
error = self.failures.get(task_id)
if error is not None:
raise error
return SubmissionReceipt(True, f"RESULT-{task_id}", "2026-08-10T08:00:00Z")
def submit_failure(self, task_id, idempotency_key, failure):
self.failure_calls.append((task_id, idempotency_key, failure))
if self.delay:
time.sleep(self.delay)
error = self.failures.get(task_id)
if error is not None:
raise error
return SubmissionReceipt(True, f"FAILURE-{task_id}", "2026-08-10T08:00:00Z")
class FakeCollectResult:
def to_pdd_data(self):
return {
"schema_version": 1,
"goods_id": "737116531267",
"title": "测试商品",
"shop_name": "测试店铺",
"price_granularity": "color",
"dimensions": [],
"skus": [{"price_cent": 990}],
}
class FakeCollector:
def collect(self, _task):
return FakeCollectResult()
class BlockingCollector:
"""让测试能在新任务落库后、采集结束前检查界面。"""
def __init__(self, started, release):
self.started = started
self.release = release
def collect(self, _task):
self.started.set()
if not self.release.wait(timeout=3):
raise RuntimeError("测试没有及时结束阻塞采集")
return FakeCollectResult()
def fake_collect_factory(*_args):
return FakeCollector()
def collect_admin_task(task_id="COL-001"):
return AdminTask(
task_id=task_id,
task_type=TaskType.COLLECT,
version=1,
priority=0,
payload={
"goods_id": "737116531267",
"goods_url": (
"https://mobile.yangkeduo.com/goods.html"
"?goods_id=737116531267"
),
},
created_at="2026-08-07T03:19:49Z",
updated_at="2026-08-07T03:19:49Z",
)
def purchase_admin_task(task_id="PUR-001"):
return AdminTask(
task_id=task_id,
task_type=TaskType.PURCHASE,
version=1,
priority=10,
payload={
"goods_id": "737116531267",
"goods_url": "https://example.test/737116531267",
"options": {"color": "黑色", "size": "L"},
"quantity": 2,
"max_price_cent": 1200,
},
created_at="2026-08-09T08:00:00Z",
updated_at="2026-08-09T08:00:00Z",
)
class FakePurchaseAdapter(PddPurchaseAdapter):
def __init__(self):
self.options = {}
self.quantity = 0
self.page_kind = "goods"
def open_goods(self, _goods_url):
pass
def read_state(self):
return PurchasePageState(
self.page_kind,
"737116531267",
dict(self.options),
self.quantity,
990,
1,
)
def select_options(self, options):
self.options = dict(options)
def set_quantity(self, quantity):
self.quantity = quantity
def enter_confirmation(self):
self.page_kind = "order_confirmation"
def stop_before_submit(self):
pass
def close(self):
pass
def fake_purchase_factory(_address, _cancelled):
return FakePurchaseAdapter()
def wait_until(application, predicate, timeout=3.0):
deadline = time.monotonic() + timeout
while time.monotonic() < deadline:
application.processEvents()
if predicate():
application.processEvents()
return True
time.sleep(0.01)
application.processEvents()
return predicate()
class PDDTaskPageEventTest(unittest.TestCase):
@classmethod
def setUpClass(cls):
cls.app = QApplication.instance() or QApplication([])
def setUp(self):
self.temp_directory = tempfile.TemporaryDirectory()
self.db_path = Path(self.temp_directory.name) / "client.db"
self.repository = TaskRepository(self.db_path)
self.device_checker_patch = patch(
"src.pdd_ui_event.AndroidDeviceService.require_connected",
autospec=True,
)
self.device_checker = self.device_checker_patch.start()
def tearDown(self):
self.device_checker_patch.stop()
self.temp_directory.cleanup()
def _add_task(
self,
number: int,
task_type: TaskType = TaskType.COLLECT,
title: str = "测试商品",
2026-08-18 09:55:32 +08:00
execution_mode: str = "dry_run",
) -> None:
self.repository.add_claimed_task(
NewClaimedTask(
remote_task_id=f"PDD-{number:03d}",
task_type=task_type,
goods_url=f"pdd://goods/{number}",
goods_id=str(number),
title=title,
target_color="黑色",
target_size="M",
price_cent=3990,
quantity=1 if task_type is TaskType.PURCHASE else None,
2026-08-18 09:55:32 +08:00
execution_mode=execution_mode,
),
received_at="2026-08-06T08:00:00Z",
)
def _saved_settings(self):
settings = SettingsRepository(self.db_path)
settings.set_many(
{
"admin.client_id": "CLIENT-001",
"admin.client_name": "办公室电脑",
"android.selected_serial": "USB-001",
}
)
return settings
def test_initial_load_and_fetch_next_page(self):
for number in range(51):
self._add_task(number)
page = PDDTaskPage()
events = PDDTaskPageEvent(page, self.repository)
events.load_initial_tasks()
self.assertEqual(page.taskModel.data_row_count(), 50)
self.assertEqual(page.taskModel.row_at(0).remote_task_id, "PDD-050")
self.assertTrue(page.taskModel.canFetchMore())
page.taskModel.fetchMore()
self.assertEqual(page.taskModel.data_row_count(), 51)
self.assertEqual(page.taskModel.row_at(50).remote_task_id, "PDD-000")
self.assertFalse(page.taskModel.canFetchMore())
page.deleteLater()
def test_search_uses_type_status_and_keyword_together(self):
self._add_task(1, TaskType.COLLECT, "目标短袖")
self._add_task(2, TaskType.PURCHASE, "目标短袖")
self._add_task(3, TaskType.COLLECT, "其他商品")
page = PDDTaskPage()
events = PDDTaskPageEvent(page, self.repository)
page.searchRequested.emit(
{"task_type": "采集", "status": "待执行", "keyword": "目标"}
)
self.assertEqual(page.taskModel.data_row_count(), 1)
row = page.taskModel.row_at(0)
self.assertEqual(row.remote_task_id, "PDD-001")
self.assertEqual(row.task_type, "采集")
self.assertEqual(row.status, "待执行")
self.assertEqual(row.price_cents, 3990)
page.deleteLater()
def test_rerun_buttons_allow_multiple_checked_tasks(self):
self._add_task(1)
self._add_task(2)
page = PDDTaskPage()
events = PDDTaskPageEvent(page, self.repository)
events.load_initial_tasks()
self.assertTrue(page.rerunButton.isEnabled())
self.assertTrue(page.purchaseRerunButton.isEnabled())
page.taskModel.setData(
page.taskModel.index(0, 0), Qt.Checked, Qt.CheckStateRole
)
self.app.processEvents()
self.assertTrue(page.rerunButton.isEnabled())
self.assertTrue(page.purchaseRerunButton.isEnabled())
page.taskModel.setData(
page.taskModel.index(1, 0), Qt.Checked, Qt.CheckStateRole
)
self.app.processEvents()
self.assertTrue(page.rerunButton.isEnabled())
self.assertTrue(page.purchaseRerunButton.isEnabled())
events.shutdown()
page.deleteLater()
def test_batch_resubmit_reuses_outbox_and_keeps_skipped_checked(self):
for number in range(1, 4):
self._add_task(number)
expected = {}
for task_id in ("PDD-001", "PDD-002"):
started = self.repository.start_collect_run(task_id, "USB-001")
event = self.repository.save_collect_result(
task_id, started.attempt_id, FakeCollectResult().to_pdd_data()
)
self.repository.mark_outbox_sent(event.id)
expected[task_id] = (event.idempotency_key, event.payload_json)
page = PDDTaskPage()
gateway = RecordingResubmitGateway()
events = PDDTaskPageEvent(
page,
self.repository,
claim_gateway=gateway,
settings_repository=self._saved_settings(),
)
events.load_initial_tasks()
for row in range(3):
page.taskModel.setData(
page.taskModel.index(row, 0), Qt.Checked, Qt.CheckStateRole
)
with patch("src.pdd_ui_event.MessageBox") as message_box:
message_box.return_value.exec.return_value = True
page.resubmitButton.click()
self.assertTrue(
wait_until(self.app, lambda: not events._resubmit_busy),
"重新上报线程没有按时结束",
)
calls = {
task_id: (key, payload)
for task_id, key, payload in gateway.submit_calls
}
self.assertEqual(calls, expected)
self.assertEqual(page.taskModel.checked_task_ids(), ("PDD-003",))
self.assertIn("成功 2 条,失败 0 条,跳过 1 条", page.statusLabel.text())
self.device_checker.assert_not_called()
events.shutdown()
page.deleteLater()
def test_batch_resubmit_records_retryable_and_permanent_failures(self):
for number in range(1, 3):
self._add_task(number)
event_ids = {}
for task_id in ("PDD-001", "PDD-002"):
started = self.repository.start_collect_run(task_id, "USB-001")
event = self.repository.save_collect_result(
task_id, started.attempt_id, FakeCollectResult().to_pdd_data()
)
self.repository.mark_outbox_sent(event.id)
event_ids[task_id] = event.id
gateway = RecordingResubmitGateway(
{
"PDD-001": AdminGatewayError("TEMP", "暂时不可用", True),
"PDD-002": AdminGatewayError("REJECTED", "结果被拒绝", False),
}
)
page = PDDTaskPage()
events = PDDTaskPageEvent(page, self.repository, claim_gateway=gateway)
events.load_initial_tasks()
for row in range(2):
page.taskModel.setData(
page.taskModel.index(row, 0), Qt.Checked, Qt.CheckStateRole
)
with patch("src.pdd_ui_event.MessageBox") as message_box:
message_box.return_value.exec.return_value = True
page.resubmitButton.click()
self.assertTrue(wait_until(self.app, lambda: not events._resubmit_busy))
self.assertEqual(
self.repository.get_outbox_event(event_ids["PDD-001"]).status.value,
"pending",
)
self.assertEqual(
self.repository.get_outbox_event(event_ids["PDD-002"]).status.value,
"failed",
)
self.assertEqual(
set(page.taskModel.checked_task_ids()), {"PDD-001", "PDD-002"}
)
self.assertIn("失败 2 条", page.statusLabel.text())
events.shutdown()
page.deleteLater()
def test_resubmit_prioritizes_pending_failure_and_repairs_task_status(self):
self._add_task(1)
first = self.repository.start_collect_run("PDD-001", "USB-001")
result = self.repository.save_collect_result(
"PDD-001", first.attempt_id, FakeCollectResult().to_pdd_data()
)
self.repository.mark_outbox_sent(result.id)
self.repository.prepare_collect_rerun("PDD-001")
second = self.repository.start_collect_run("PDD-001", "USB-001")
failure = self.repository.save_collect_failure(
"PDD-001",
second.attempt_id,
TaskStatus.FAILED,
"SKU_PANEL_NOT_FOUND",
"规格面板加载超时",
False,
)
connection = open_database(self.db_path)
try:
with connection:
connection.execute(
"UPDATE pdd_tasks SET status = 'succeeded',"
" current_step = 'completed' WHERE remote_task_id = 'PDD-001'"
)
finally:
connection.close()
page = PDDTaskPage()
gateway = RecordingResubmitGateway()
events = PDDTaskPageEvent(page, self.repository, claim_gateway=gateway)
events.load_initial_tasks()
page.taskModel.setData(
page.taskModel.index(0, 0), Qt.Checked, Qt.CheckStateRole
)
with patch("src.pdd_ui_event.MessageBox") as message_box:
message_box.return_value.exec.return_value = True
page.resubmitButton.click()
self.assertTrue(wait_until(self.app, lambda: not events._resubmit_busy))
self.assertEqual(gateway.submit_calls, [])
self.assertEqual(len(gateway.failure_calls), 1)
task_id, idempotency_key, payload = gateway.failure_calls[0]
self.assertEqual(task_id, "PDD-001")
self.assertEqual(idempotency_key, failure.idempotency_key)
self.assertEqual(payload, failure.payload_json)
self.assertEqual(payload["status"], TaskStatus.FAILED.value)
self.assertEqual(
self.repository.get_outbox_event(failure.id).event_type,
OutboxEventType.TASK_FAILURE,
)
repaired = self.repository.get_task("PDD-001")
self.assertEqual(repaired.status, TaskStatus.FAILED)
self.assertEqual(repaired.last_error_code, "SKU_PANEL_NOT_FOUND")
self.device_checker.assert_not_called()
events.shutdown()
page.deleteLater()
def test_resubmit_confirmation_cancel_does_not_submit(self):
self._add_task(1)
page = PDDTaskPage()
gateway = RecordingResubmitGateway()
events = PDDTaskPageEvent(page, self.repository, claim_gateway=gateway)
events.load_initial_tasks()
page.taskModel.setData(
page.taskModel.index(0, 0), Qt.Checked, Qt.CheckStateRole
)
with patch("src.pdd_ui_event.MessageBox") as message_box:
message_box.return_value.exec.return_value = False
page.resubmitButton.click()
self.assertEqual(gateway.submit_calls, [])
self.assertFalse(events._resubmit_busy)
events.shutdown()
page.deleteLater()
def test_resubmit_rejects_duplicate_start_and_shutdown_waits_worker(self):
self._add_task(1)
started = self.repository.start_collect_run("PDD-001", "USB-001")
event = self.repository.save_collect_result(
"PDD-001", started.attempt_id, FakeCollectResult().to_pdd_data()
)
self.repository.mark_outbox_sent(event.id)
page = PDDTaskPage()
gateway = RecordingResubmitGateway(delay=0.1)
events = PDDTaskPageEvent(page, self.repository, claim_gateway=gateway)
events.load_initial_tasks()
page.taskModel.setData(
page.taskModel.index(0, 0), Qt.Checked, Qt.CheckStateRole
)
with patch("src.pdd_ui_event.MessageBox") as message_box:
message_box.return_value.exec.return_value = True
page.resubmitButton.click()
events.request_resubmit(("PDD-001",))
self.assertTrue(
wait_until(self.app, lambda: bool(gateway.submit_calls))
)
events.shutdown()
self.assertEqual(len(gateway.submit_calls), 1)
self.assertFalse(
events._resubmit_thread and events._resubmit_thread.isRunning()
)
page.deleteLater()
def test_confirmed_rerun_executes_selected_terminal_collect_task(self):
self._add_task(1)
first = self.repository.start_collect_run("PDD-001", "USB-001")
event = self.repository.save_collect_result(
"PDD-001", first.attempt_id, FakeCollectResult().to_pdd_data()
)
self.repository.mark_outbox_sent(event.id)
page = PDDTaskPage()
gateway = RecordingClaimGateway()
events = PDDTaskPageEvent(
page,
self.repository,
claim_gateway=gateway,
settings_repository=self._saved_settings(),
collect_service_factory=fake_collect_factory,
)
with patch("src.pdd_ui_event.MessageBox") as message_box:
message_box.return_value.exec.return_value = True
page.rerunRequested.emit("PDD-001")
self.assertTrue(
wait_until(self.app, lambda: not events._claim_busy),
"重新采集线程没有按时结束",
)
detail = self.repository.get_task("PDD-001")
self.assertEqual(detail.status, TaskStatus.SUCCEEDED)
self.assertIn("采集完成", page.statusLabel.text())
self.assertEqual(gateway.calls, [])
events.shutdown()
page.deleteLater()
2026-08-10 17:23:24 +08:00
def test_remove_confirmation_cancel_keeps_local_task(self):
self._add_task(1)
connection = open_database(self.db_path)
try:
with connection:
connection.execute(
"UPDATE pdd_tasks SET status = 'succeeded'"
" WHERE remote_task_id = 'PDD-001'"
)
finally:
connection.close()
page = PDDTaskPage()
gateway = RecordingClaimGateway()
events = PDDTaskPageEvent(page, self.repository, claim_gateway=gateway)
events.load_initial_tasks()
page.taskModel.setData(
page.taskModel.index(0, 0), Qt.Checked, Qt.CheckStateRole
)
with patch("src.pdd_ui_event.MessageBox") as message_box:
dialog = message_box.return_value
dialog.exec.return_value = False
page.removeButton.click()
message_box.assert_called_once()
self.assertIs(message_box.call_args.args[2], page.window())
dialog.cancelButton.setFocus.assert_called_once()
self.assertEqual(self.repository.count_tasks(), 1)
self.assertFalse(events._remove_busy)
self.assertEqual(gateway.calls, [])
events.shutdown()
page.deleteLater()
def test_remove_terminal_task_refreshes_list_and_keeps_audit_data(self):
self._add_task(1)
connection = open_database(self.db_path)
try:
with connection:
connection.execute(
"UPDATE pdd_tasks SET status = 'failed'"
" WHERE remote_task_id = 'PDD-001'"
)
finally:
connection.close()
page = PDDTaskPage()
gateway = RecordingClaimGateway()
events = PDDTaskPageEvent(page, self.repository, claim_gateway=gateway)
events.load_initial_tasks()
page.taskModel.setData(
page.taskModel.index(0, 0), Qt.Checked, Qt.CheckStateRole
)
with patch("src.pdd_ui_event.MessageBox") as message_box:
message_box.return_value.exec.return_value = True
page.removeButton.click()
self.assertTrue(wait_until(self.app, lambda: not events._remove_busy))
self.assertEqual(page.taskModel.data_row_count(), 0)
self.assertEqual(page.taskModel.checked_task_ids(), ())
self.assertIsNotNone(self.repository.get_task("PDD-001"))
self.assertIn("已从列表删除 1 条", page.statusLabel.text())
self.assertEqual(gateway.calls, [])
self.device_checker.assert_not_called()
events.shutdown()
page.deleteLater()
def test_remove_mixed_batch_reports_reason_and_changes_nothing(self):
self._add_task(1)
self._add_task(2)
connection = open_database(self.db_path)
try:
with connection:
connection.execute(
"UPDATE pdd_tasks SET status = 'cancelled'"
" WHERE remote_task_id = 'PDD-001'"
)
finally:
connection.close()
page = PDDTaskPage()
events = PDDTaskPageEvent(
page, self.repository, claim_gateway=RecordingClaimGateway()
)
events.load_initial_tasks()
for row in range(2):
page.taskModel.setData(
page.taskModel.index(row, 0), Qt.Checked, Qt.CheckStateRole
)
with patch("src.pdd_ui_event.MessageBox") as message_box:
message_box.return_value.exec.return_value = True
page.removeButton.click()
self.assertTrue(wait_until(self.app, lambda: not events._remove_busy))
self.assertEqual(self.repository.count_tasks(), 2)
self.assertEqual(len(page.taskModel.checked_task_ids()), 2)
self.assertIn("尚未结束", page.statusLabel.text())
events.shutdown()
page.deleteLater()
2026-08-10 09:08:54 +08:00
def test_rerun_confirmation_has_clear_safe_cancel_action(self):
self._add_task(1)
first = self.repository.start_collect_run("PDD-001", "USB-001")
event = self.repository.save_collect_result(
"PDD-001", first.attempt_id, FakeCollectResult().to_pdd_data()
)
self.repository.mark_outbox_sent(event.id)
page = PDDTaskPage()
events = PDDTaskPageEvent(
page,
self.repository,
claim_gateway=RecordingClaimGateway(),
settings_repository=self._saved_settings(),
collect_service_factory=fake_collect_factory,
)
with patch("src.pdd_ui_event.MessageBox") as message_box:
message_box.return_value.exec.return_value = False
page.rerunRequested.emit("PDD-001")
message_box.return_value.cancelButton.setText.assert_called_once_with(
"取消"
2026-08-10 09:08:54 +08:00
)
message_box.return_value.cancelButton.setFocus.assert_called_once_with()
self.assertFalse(events._claim_busy)
self.assertEqual(
self.repository.get_task("PDD-001").status, TaskStatus.SUCCEEDED
)
events.shutdown()
page.deleteLater()
2026-08-18 09:55:32 +08:00
def test_purchase_rerun_confirmation_routes_reconcile_before_purchase(self):
self._add_task(
1,
TaskType.PURCHASE,
execution_mode="live",
)
self._add_task(
2,
TaskType.PURCHASE,
execution_mode="live",
)
started = self.repository.start_purchase_run("PDD-001", "USB-001")
self.repository.mark_purchase_irreversible(
"PDD-001",
started.attempt_id,
{
"options": {"color": "黑色", "size": "M"},
"quantity": 1,
"unit_price_cent": 3990,
"total_price_cent": 3990,
},
)
self.repository.move_purchase_to_reconcile(
"PDD-001", started.attempt_id
)
self.repository.save_purchase_reconciliation(
"PDD-001",
started.attempt_id,
"unknown",
{"error_code": "RECONCILE_READ_FAILED"},
)
connection = open_database(self.db_path)
try:
with connection:
connection.execute(
"UPDATE pdd_tasks SET status = 'failed'"
" WHERE remote_task_id = 'PDD-002'"
)
finally:
connection.close()
page = PDDTaskPage()
events = PDDTaskPageEvent(
page,
self.repository,
claim_gateway=RecordingClaimGateway(),
settings_repository=self._saved_settings(),
)
with (
patch("src.pdd_ui_event.MessageBox") as message_box,
patch.object(events, "_start_rerun_worker") as start_worker,
):
message_box.return_value.exec.return_value = True
page.purchaseRerunRequested.emit(("PDD-002", "PDD-001"))
detail = message_box.call_args.args[1]
self.assertIn("可重新采购 1 条", detail)
self.assertIn("只继续核单 1 条", detail)
message_box.return_value.yesButton.setText.assert_called_once_with(
"继续处理"
)
command = start_worker.call_args.args[0]
self.assertEqual(command.task_ids, ("PDD-001", "PDD-002"))
self.assertEqual(command.reconcile_task_ids, ("PDD-001",))
events.shutdown()
page.deleteLater()
2026-08-10 09:08:54 +08:00
def test_running_rerun_can_request_cooperative_cancel_once(self):
self._add_task(1)
first = self.repository.start_collect_run("PDD-001", "USB-001")
event = self.repository.save_collect_result(
"PDD-001", first.attempt_id, FakeCollectResult().to_pdd_data()
)
self.repository.mark_outbox_sent(event.id)
started = threading.Event()
def cancellable_factory(_address, _client_id, cancelled):
class CancellableCollector:
def collect(self, _task):
started.set()
while not cancelled():
time.sleep(0.01)
raise PddCollectError("PDD_CANCELLED", "采集任务已安全取消")
return CancellableCollector()
page = PDDTaskPage()
events = PDDTaskPageEvent(
page,
self.repository,
claim_gateway=RecordingClaimGateway(),
settings_repository=self._saved_settings(),
collect_service_factory=cancellable_factory,
)
with patch("src.pdd_ui_event.MessageBox") as message_box:
message_box.return_value.exec.return_value = True
page.rerunRequested.emit("PDD-001")
self.assertTrue(started.wait(1.0), "重新采集没有启动")
self.assertTrue(
wait_until(
self.app,
lambda: page.rerunButton.text() == "停止重新采集",
)
)
self.assertTrue(page.rerunButton.isEnabled())
page.rerunButton.click()
self.app.processEvents()
self.assertEqual(page.rerunButton.text(), "正在停止…")
self.assertFalse(page.rerunButton.isEnabled())
self.assertIn("等待手机当前操作结束", page.statusLabel.text())
self.assertTrue(
wait_until(self.app, lambda: not events._claim_busy),
"取消后工作线程没有按时结束",
)
self.assertEqual(page.rerunButton.text(), "重新采集")
2026-08-10 09:08:54 +08:00
self.assertIn("停止请求已处理", page.statusLabel.text())
events.shutdown()
page.deleteLater()
def test_rerun_feedback_has_close_button_and_replaces_previous(self):
page = PDDTaskPage()
events = PDDTaskPageEvent(page, self.repository)
events._show_rerun_warning("暂时不能重新执行", "测试警告")
first = events._rerun_feedback
self.assertIsNotNone(first)
2026-08-10 10:16:11 +08:00
self.assertEqual(first.position, InfoBarPosition.TOP)
2026-08-10 09:08:54 +08:00
events._show_rerun_error("重新采集失败", "测试错误")
second = events._rerun_feedback
self.assertIsNot(first, second)
self.assertEqual(second.position, InfoBarPosition.TOP)
self.assertEqual(second.duration, ERROR_FEEDBACK_DURATION_MS)
2026-08-10 09:08:54 +08:00
close_buttons = [
button
for button in second.findChildren(type(page.rerunButton))
if button.text() == "关闭提示"
]
self.assertEqual(len(close_buttons), 1)
self.assertGreaterEqual(second.minimumWidth(), ERROR_FEEDBACK_MIN_WIDTH)
self.assertGreaterEqual(second.minimumHeight(), ERROR_FEEDBACK_MIN_HEIGHT)
self.assertGreaterEqual(
close_buttons[0].minimumWidth(),
ERROR_FEEDBACK_BUTTON_MIN_WIDTH,
)
self.assertGreaterEqual(
close_buttons[0].minimumHeight(),
ERROR_FEEDBACK_BUTTON_MIN_HEIGHT,
)
2026-08-10 09:08:54 +08:00
close_buttons[0].click()
self.app.processEvents()
self.assertIsNone(events._rerun_feedback)
events.shutdown()
page.deleteLater()
def test_claim_error_is_large_closable_and_replaces_previous(self):
page = PDDTaskPage()
events = PDDTaskPageEvent(page, self.repository)
events._show_claim_error("采集失败", "第一条错误")
first = events._claim_feedback
events._show_claim_error("采购失败", "第二条错误")
second = events._claim_feedback
self.assertIsNot(first, second)
self.assertEqual(second.position, InfoBarPosition.TOP)
self.assertEqual(second.duration, ERROR_FEEDBACK_DURATION_MS)
self.assertGreaterEqual(second.minimumWidth(), ERROR_FEEDBACK_MIN_WIDTH)
self.assertGreaterEqual(second.minimumHeight(), ERROR_FEEDBACK_MIN_HEIGHT)
close_buttons = [
button
for button in second.findChildren(type(page.rerunButton))
if button.text() == "关闭提示"
]
self.assertEqual(len(close_buttons), 1)
self.assertGreaterEqual(
close_buttons[0].minimumWidth(),
ERROR_FEEDBACK_BUTTON_MIN_WIDTH,
)
self.assertGreaterEqual(
close_buttons[0].minimumHeight(),
ERROR_FEEDBACK_BUTTON_MIN_HEIGHT,
)
close_buttons[0].click()
self.app.processEvents()
self.assertIsNone(events._claim_feedback)
events.shutdown()
page.deleteLater()
2026-08-10 09:08:54 +08:00
def test_late_rerun_success_does_not_override_cancel_status(self):
page = PDDTaskPage()
events = PDDTaskPageEvent(page, self.repository)
events._rerun_cancel_requested = True
events._on_rerun_outcome("succeeded", "采集成功", "PDD-001")
self.assertNotIn("采集成功", page.statusLabel.text())
self.assertIn("停止请求已处理", page.statusLabel.text())
events.shutdown()
page.deleteLater()
def test_disconnected_device_stops_auto_fetch_before_claim(self):
page = PDDTaskPage()
gateway = RecordingClaimGateway(collect_admin_task())
checker_threads = []
def disconnected(_serial):
checker_threads.append(threading.get_ident())
raise AndroidDeviceSearchError("USB Android 设备 USB-001 未连接")
events = PDDTaskPageEvent(
page,
self.repository,
claim_gateway=gateway,
settings_repository=self._saved_settings(),
collect_service_factory=fake_collect_factory,
device_connection_checker=disconnected,
)
page.autoFetchRequested.emit()
self.assertTrue(wait_until(self.app, lambda: not events._claim_busy))
self.assertEqual(gateway.calls, [])
self.assertEqual(self.repository.count_tasks(), 0)
self.assertEqual(len(checker_threads), 1)
self.assertNotEqual(checker_threads[0], threading.get_ident())
self.assertIn("Android 设备不可用", page.statusLabel.text())
self.assertIsNotNone(events._device_feedback)
events.shutdown()
page.deleteLater()
def test_disconnected_rerun_keeps_existing_task_unchanged(self):
self._add_task(1)
first = self.repository.start_collect_run("PDD-001", "USB-001")
event = self.repository.save_collect_result(
"PDD-001", first.attempt_id, FakeCollectResult().to_pdd_data()
)
self.repository.mark_outbox_sent(event.id)
before = self.repository.get_task("PDD-001")
before_run = self.repository.latest_task_run("PDD-001")
page = PDDTaskPage()
def disconnected(_serial):
raise AndroidDeviceSearchError(
"USB Android 设备 USB-001 未连接"
)
events = PDDTaskPageEvent(
page,
self.repository,
claim_gateway=RecordingClaimGateway(),
settings_repository=self._saved_settings(),
collect_service_factory=fake_collect_factory,
device_connection_checker=disconnected,
)
with patch("src.pdd_ui_event.MessageBox") as message_box:
message_box.return_value.exec.return_value = True
page.rerunRequested.emit("PDD-001")
self.assertTrue(wait_until(self.app, lambda: not events._claim_busy))
after = self.repository.get_task("PDD-001")
after_run = self.repository.latest_task_run("PDD-001")
self.assertEqual(after.status, TaskStatus.SUCCEEDED)
self.assertEqual(after.pdd_data, before.pdd_data)
self.assertEqual(after_run.attempt_id, before_run.attempt_id)
self.assertIn("重新采集未开始", page.statusLabel.text())
events.shutdown()
page.deleteLater()
def test_device_feedback_opens_settings_closes_and_does_not_stack(self):
page = PDDTaskPage()
events = PDDTaskPageEvent(page, self.repository)
opened = []
page.openSettingsRequested.connect(lambda: opened.append(True))
events._show_device_unavailable("第一条")
first = events._device_feedback
events._show_device_unavailable("第二条")
second = events._device_feedback
self.assertIsNot(first, second)
self.assertEqual(second.position, InfoBarPosition.TOP)
self.assertEqual(second.duration, ERROR_FEEDBACK_DURATION_MS)
self.assertGreaterEqual(second.minimumWidth(), ERROR_FEEDBACK_MIN_WIDTH)
self.assertGreaterEqual(second.minimumHeight(), ERROR_FEEDBACK_MIN_HEIGHT)
buttons = {
button.text(): button
for button in second.findChildren(type(page.rerunButton))
}
self.assertIn("打开设置", buttons)
self.assertIn("关闭提示", buttons)
for button in buttons.values():
self.assertGreaterEqual(
button.minimumWidth(),
ERROR_FEEDBACK_BUTTON_MIN_WIDTH,
)
self.assertGreaterEqual(
button.minimumHeight(),
ERROR_FEEDBACK_BUTTON_MIN_HEIGHT,
)
buttons["打开设置"].click()
self.app.processEvents()
self.assertEqual(opened, [True])
self.assertIsNone(events._device_feedback)
events.shutdown()
page.deleteLater()
def test_query_failure_shows_readable_error(self):
page = PDDTaskPage()
events = PDDTaskPageEvent(page, BrokenRepository())
events.load_initial_tasks()
self.assertFalse(page.emptyStateCard.isHidden())
self.assertEqual(page.emptyTitleLabel.text(), "任务加载失败")
self.assertIn("检查数据库", page.emptyMessageLabel.text())
self.assertFalse(page.taskModel.canFetchMore())
page.deleteLater()
2026-08-08 09:46:58 +08:00
def test_detail_signal_opens_one_reusable_non_modal_window(self):
self._add_task(1)
started = self.repository.start_collect_run("PDD-001", "USB-001")
self.repository.save_collect_result(
"PDD-001",
started.attempt_id,
{
"goods_id": "1",
"title": "测试商品",
"dimensions": [
{
"key": "color",
"name": "颜色",
"values": [{"text": "黑色", "available": True}],
},
{
"key": "size",
"name": "尺码",
"values": [{"text": "M", "available": True}],
},
],
"skus": [
{
"options": {"color": "黑色", "size": "M"},
"price_cent": 3990,
}
],
},
)
page = PDDTaskPage()
events = PDDTaskPageEvent(page, self.repository)
page.detailRequested.emit("PDD-001")
self.app.processEvents()
first = events._detail_windows["PDD-001"]
self.assertTrue(first.isVisible())
self.assertFalse(first.isModal())
page.detailRequested.emit("PDD-001")
self.app.processEvents()
self.assertIs(events._detail_windows["PDD-001"], first)
first.close()
self.app.processEvents()
self.assertNotIn("PDD-001", events._detail_windows)
events.shutdown()
page.deleteLater()
def test_missing_detail_does_not_open_empty_window(self):
page = PDDTaskPage()
events = PDDTaskPageEvent(page, self.repository)
page.detailRequested.emit("PDD-NOT-FOUND")
self.app.processEvents()
self.assertEqual(events._detail_windows, {})
events.shutdown()
page.deleteLater()
def test_summary_to_row_does_not_copy_detail_json(self):
summary = TaskSummary(
id=1,
remote_task_id="PDD-001",
task_type=TaskType.PURCHASE,
goods_id=None,
title=None,
target_color=None,
target_size=None,
price_cent=None,
quantity=2,
status=TaskStatus.MANUAL_REVIEW,
updated_at="2026-08-06T08:00:00Z",
order_no="ORDER-001",
)
row = summary_to_row(summary)
self.assertEqual(row.task_type, "采购")
self.assertEqual(row.status, "需要人工处理")
self.assertEqual(row.goods_id, "")
self.assertEqual(row.order_no, "ORDER-001")
self.assertFalse(hasattr(row, "pdd_data"))
def test_summary_to_row_uses_task_specific_running_text(self):
common = dict(
id=1,
goods_id=None,
title=None,
target_color=None,
target_size=None,
price_cent=None,
quantity=None,
status=TaskStatus.RUNNING,
updated_at="2026-08-11T08:00:00Z",
)
collect = summary_to_row(
TaskSummary(
remote_task_id="COL-RUNNING",
task_type=TaskType.COLLECT,
**common,
)
)
purchase = summary_to_row(
TaskSummary(
remote_task_id="PUR-RUNNING",
task_type=TaskType.PURCHASE,
**common,
)
)
self.assertEqual(collect.status, "采集中")
self.assertEqual(purchase.status, "采购中")
def test_summary_to_row_uses_task_specific_failure_text(self):
common = dict(
id=1,
goods_id=None,
title=None,
target_color=None,
target_size=None,
price_cent=None,
quantity=None,
updated_at="2026-08-11T08:00:00Z",
)
collect = summary_to_row(
TaskSummary(
remote_task_id="COL-FAILED",
task_type=TaskType.COLLECT,
status=TaskStatus.RETRY_WAIT,
**common,
)
)
purchase = summary_to_row(
TaskSummary(
remote_task_id="PUR-FAILED",
task_type=TaskType.PURCHASE,
status=TaskStatus.FAILED,
**common,
)
)
self.assertEqual(collect.status, "采集失败")
self.assertEqual(purchase.status, "采购失败")
def test_main_window_keeps_event_object_alive(self):
2026-08-06 17:57:49 +08:00
window = MainWindow(
task_repository=self.repository,
settings_repository=SettingsRepository(self.db_path),
admin_gateway=MockAdminGateway(),
)
self.assertIsInstance(window.pddTaskPageEvent, PDDTaskPageEvent)
self.assertIs(
window.pddTaskPageEvent._purchase_adapter_factory,
create_u2_purchase_adapter,
)
self.assertEqual(window.pddTaskPage.taskModel.data_row_count(), 0)
window.pddTaskPage.openSettingsRequested.emit()
self.app.processEvents()
self.assertIs(window.stackedWidget.currentWidget(), window.settingsPage)
window.close()
window.deleteLater()
def test_application_startup_shows_main_window_maximized(self):
class FakeApplication:
@staticmethod
def setAttribute(*_args):
pass
def __init__(self, _args):
pass
def exec_(self):
return 0
class FakeWindow:
def __init__(self):
self.maximized = False
def showMaximized(self):
self.maximized = True
window = FakeWindow()
with patch("src.ui_main.QApplication", FakeApplication), patch(
"src.ui_main.MainWindow", return_value=window
), patch("src.ui_main.setTheme"), patch(
"src.ui_main.mark_current_version_healthy"
):
result = ui_main()
self.assertEqual(result, 0)
self.assertTrue(window.maximized)
def test_application_startup_shows_dialog_when_bundled_adb_is_missing(self):
class FakeApplication:
@staticmethod
def setAttribute(*_args):
pass
def __init__(self, _args):
pass
def exec_(self):
return 0
class FakeWindow:
def showMaximized(self):
pass
window = FakeWindow()
message = "内置 ADB 文件缺失:adb.exe。请重新安装完整的软件包。"
with patch("src.ui_main.QApplication", FakeApplication), patch(
"src.ui_main.MainWindow", return_value=window
), patch("src.ui_main.setTheme"), patch(
"src.ui_main.mark_current_version_healthy"
), patch(
"src.ui_main.configure_bundled_adb_environment",
side_effect=BundledAdbError(message),
), patch(
"src.ui_main.QTimer.singleShot",
side_effect=lambda _delay, callback: callback(),
), patch("src.ui_main._show_adb_missing_dialog") as show_dialog:
result = ui_main()
self.assertEqual(result, 0)
show_dialog.assert_called_once_with(window, message)
def test_main_window_marks_and_clears_update_navigation(self):
window = MainWindow(
task_repository=self.repository,
settings_repository=SettingsRepository(self.db_path),
admin_gateway=MockAdminGateway(),
)
window._set_update_navigation_reminder(True, "0.1.0", "0.2.0")
self.assertIn("有更新", window.settingsNavigationItem.text())
self.assertIn("0.2.0", window.settingsNavigationItem.toolTip())
window._set_update_navigation_reminder(False, "0.2.0", "0.2.0")
self.assertNotIn("有更新", window.settingsNavigationItem.text())
window.close()
window.deleteLater()
def test_admin_base_url_change_reaches_persistent_claim_worker(self):
settings = self._saved_settings()
page = PDDTaskPage()
events = PDDTaskPageEvent(
page,
self.repository,
settings_repository=settings,
)
worker = events._ensure_claim_executor()
events.update_admin_base_url("http://192.168.0.9:8080/")
self.assertTrue(
wait_until(
self.app,
lambda: getattr(worker._gateway, "_base_url", "")
== "http://192.168.0.9:8080",
)
)
self.assertEqual(
getattr(events._claim_gateway, "_base_url", ""),
"http://192.168.0.9:8080",
)
events.shutdown()
page.deleteLater()
def test_admin_task_mapping_uses_explicit_real_field_names(self):
local_task = admin_task_to_new_claimed_task(
collect_admin_task("COL-8020a8729f111c15")
)
self.assertEqual(local_task.remote_task_id, "COL-8020a8729f111c15")
self.assertEqual(local_task.task_type, TaskType.COLLECT)
self.assertEqual(local_task.goods_id, "737116531267")
self.assertEqual(
local_task.admin_payload["payload"]["goods_id"],
"737116531267",
)
2026-08-09 10:54:18 +08:00
def test_start_claims_task_and_keeps_auto_fetch_running(self):
gateway = RecordingClaimGateway(collect_admin_task())
page = PDDTaskPage()
events = PDDTaskPageEvent(
page,
self.repository,
claim_gateway=gateway,
settings_repository=self._saved_settings(),
collect_service_factory=fake_collect_factory,
)
page.autoFetchRequested.emit()
self.assertTrue(
wait_until(self.app, lambda: not events._claim_busy)
)
self.assertEqual(len(gateway.calls), 1)
self.assertNotEqual(gateway.thread_ids[0], threading.get_ident())
_, capabilities = gateway.calls[0]
self.assertEqual(
[task_type.value for task_type in capabilities.supported_types],
["collect"],
)
self.assertEqual(self.repository.count_tasks(), 1)
self.assertEqual(page.taskModel.data_row_count(), 1)
self.assertEqual(page.taskModel.row_at(0).remote_task_id, "COL-001")
2026-08-09 10:54:18 +08:00
self.assertEqual(page.autoFetchButton.text(), "停止自动获取")
self.assertIn("COL-001", page.statusLabel.text())
events.shutdown()
page.deleteLater()
def test_auto_fetch_reuses_one_fixed_worker_thread_across_cycles(self):
gateway = RecordingClaimGateway()
page = PDDTaskPage()
events = PDDTaskPageEvent(
page,
self.repository,
claim_gateway=gateway,
settings_repository=self._saved_settings(),
collect_service_factory=fake_collect_factory,
no_task_delay_ms=10,
)
page.autoFetchRequested.emit()
self.assertTrue(
wait_until(self.app, lambda: len(gateway.thread_ids) >= 2),
"自动获取没有完成两轮串行调度",
)
executor_thread = events._claim_thread
page.autoFetchRequested.emit()
self.assertTrue(wait_until(self.app, lambda: not events._claim_busy))
self.assertIs(events._claim_thread, executor_thread)
self.assertTrue(
executor_thread is not None and executor_thread.isRunning()
)
self.assertEqual(len(set(gateway.thread_ids)), 1)
events.shutdown()
self.assertFalse(executor_thread.isRunning())
page.deleteLater()
def test_new_claim_refreshes_table_before_collection_finishes(self):
gateway = RecordingClaimGateway(collect_admin_task("COL-EARLY"))
collect_started = threading.Event()
release_collect = threading.Event()
page = PDDTaskPage()
events = PDDTaskPageEvent(
page,
self.repository,
claim_gateway=gateway,
settings_repository=self._saved_settings(),
collect_service_factory=(
lambda *_args: BlockingCollector(
collect_started, release_collect
)
),
)
page.autoFetchRequested.emit()
try:
self.assertTrue(wait_until(self.app, collect_started.is_set))
self.assertTrue(events._claim_busy)
self.assertTrue(
wait_until(
self.app,
lambda: page.taskModel.data_row_count() == 1,
)
)
self.assertEqual(
page.taskModel.row_at(0).remote_task_id, "COL-EARLY"
)
finally:
release_collect.set()
self.assertTrue(wait_until(self.app, lambda: not events._claim_busy))
events.shutdown()
page.deleteLater()
def test_ready_runtime_claims_and_dispatches_purchase_in_worker(self):
gateway = RecordingClaimGateway(purchase_admin_task())
page = PDDTaskPage()
events = PDDTaskPageEvent(
page,
self.repository,
claim_gateway=gateway,
settings_repository=self._saved_settings(),
collect_service_factory=fake_collect_factory,
purchase_adapter_factory=fake_purchase_factory,
)
page.autoFetchRequested.emit()
self.assertTrue(wait_until(self.app, lambda: not events._claim_busy))
_, capabilities = gateway.calls[0]
self.assertEqual(
capabilities.supported_types,
(TaskType.COLLECT, TaskType.PURCHASE),
)
self.assertEqual(capabilities.purchase_mode, "dry_run")
detail = self.repository.get_task("PUR-001")
assert detail is not None
self.assertEqual(detail.task_type, TaskType.PURCHASE)
self.assertEqual(detail.status, TaskStatus.SUCCEEDED)
self.assertIn("演练完成", page.statusLabel.text())
events.shutdown()
page.deleteLater()
def test_ready_runtime_automatically_declares_live_capability(self):
gateway = RecordingClaimGateway(None)
page = PDDTaskPage()
events = PDDTaskPageEvent(
page,
self.repository,
claim_gateway=gateway,
settings_repository=self._saved_settings(),
purchase_adapter_factory=fake_purchase_factory,
live_purchase_adapter_factory=lambda *_args: None,
)
page.autoFetchRequested.emit()
self.assertTrue(wait_until(self.app, lambda: not events._claim_busy))
_, capabilities = gateway.calls[0]
self.assertEqual(capabilities.purchase_mode, "live")
self.assertIn(TaskType.PURCHASE, capabilities.supported_types)
events.shutdown()
page.deleteLater()
2026-08-09 10:54:18 +08:00
def test_no_task_schedules_next_claim_and_stop_cancels_timer(self):
gateway = RecordingClaimGateway(None)
page = PDDTaskPage()
events = PDDTaskPageEvent(
page,
self.repository,
claim_gateway=gateway,
settings_repository=self._saved_settings(),
collect_service_factory=fake_collect_factory,
)
page.autoFetchRequested.emit()
self.assertTrue(wait_until(self.app, lambda: not events._claim_busy))
self.assertEqual(len(gateway.calls), 1)
2026-08-09 10:54:18 +08:00
self.assertIn("5 秒后再次领取", page.statusLabel.text())
self.assertTrue(events._next_cycle_timer.isActive())
self.assertEqual(self.repository.count_tasks(), 0)
2026-08-09 10:54:18 +08:00
page.autoFetchRequested.emit()
self.assertFalse(events._next_cycle_timer.isActive())
self.assertEqual(page.autoFetchButton.text(), "自动获取")
events.shutdown()
page.deleteLater()
2026-08-09 10:54:18 +08:00
def test_second_click_while_claiming_requests_safe_stop(self):
gateway = SlowClaimGateway(None)
page = PDDTaskPage()
events = PDDTaskPageEvent(
page,
self.repository,
claim_gateway=gateway,
settings_repository=self._saved_settings(),
collect_service_factory=fake_collect_factory,
)
page.autoFetchRequested.emit()
2026-08-09 10:54:18 +08:00
self.assertTrue(wait_until(self.app, gateway.started.is_set))
page.autoFetchRequested.emit()
self.assertFalse(page.autoFetchButton.isEnabled())
self.assertTrue(wait_until(self.app, lambda: not events._claim_busy))
self.assertEqual(len(gateway.calls), 1)
self.assertEqual(page.autoFetchButton.text(), "自动获取")
2026-08-09 10:54:18 +08:00
events.shutdown()
page.deleteLater()
def test_one_start_processes_multiple_tasks_strictly_in_sequence(self):
gateway = SequenceClaimGateway(
[collect_admin_task("COL-001"), collect_admin_task("COL-002")]
)
page = PDDTaskPage()
events = PDDTaskPageEvent(
page,
self.repository,
claim_gateway=gateway,
settings_repository=self._saved_settings(),
collect_service_factory=fake_collect_factory,
next_task_delay_ms=10,
no_task_delay_ms=1_000,
)
page.autoFetchRequested.emit()
self.assertTrue(
wait_until(self.app, lambda: self.repository.count_tasks() == 2)
)
self.assertEqual(gateway.max_active_calls, 1)
self.assertEqual(page.autoFetchButton.text(), "停止自动获取")
page.autoFetchRequested.emit()
self.assertTrue(wait_until(self.app, lambda: not events._claim_busy))
self.assertEqual(page.autoFetchButton.text(), "自动获取")
2026-08-09 10:54:18 +08:00
events.shutdown()
page.deleteLater()
def test_retryable_admin_error_schedules_backoff(self):
gateway = RetryableClaimGateway()
page = PDDTaskPage()
events = PDDTaskPageEvent(
page,
self.repository,
claim_gateway=gateway,
settings_repository=self._saved_settings(),
retry_delays_ms=(20, 40, 80, 100),
)
page.autoFetchRequested.emit()
self.assertTrue(wait_until(self.app, lambda: not events._claim_busy))
self.assertEqual(events._retry_count, 1)
self.assertEqual(events._next_cycle_timer.interval(), 20)
self.assertIn("退避等待", page.statusLabel.text())
page.autoFetchRequested.emit()
self.assertFalse(events._next_cycle_timer.isActive())
events.shutdown()
page.deleteLater()
def test_retry_delay_grows_to_cap_and_success_resets_it(self):
page = PDDTaskPage()
events = PDDTaskPageEvent(
page,
self.repository,
claim_gateway=RecordingClaimGateway(None),
settings_repository=self._saved_settings(),
retry_delays_ms=(5_000, 10_000, 20_000, 30_000),
)
observed = []
for _ in range(5):
events._on_claim_retryable_failed("Admin 暂时不可用")
observed.append(events._cycle_next_delay_ms)
self.assertEqual(observed, [5_000, 10_000, 20_000, 30_000, 30_000])
events._on_collect_outcome("succeeded", "任务完成", "COL-001")
self.assertEqual(events._retry_count, 0)
events.shutdown()
page.deleteLater()
def test_historical_retry_wait_no_longer_promises_automatic_retry(self):
self._add_task(1)
started = self.repository.start_collect_run("PDD-001", "USB-001")
event = self.repository.save_collect_failure(
"PDD-001",
started.attempt_id,
TaskStatus.RETRY_WAIT,
"DEVICE_OFFLINE",
"设备离线",
True,
)
self.repository.mark_outbox_sent(event.id)
page = PDDTaskPage()
events = PDDTaskPageEvent(
page,
self.repository,
claim_gateway=RecordingClaimGateway(None),
settings_repository=self._saved_settings(),
)
events._on_collect_outcome(
"failed",
"任务 PDD-001 采集未完成",
"PDD-001",
)
self.assertEqual(events._stop_status, "任务 PDD-001 采集未完成")
self.assertNotIn("重试", events._stop_status)
events.shutdown()
page.deleteLater()
def test_task_failure_continues_auto_fetch_after_current_task(self):
self._add_task(1)
started = self.repository.start_collect_run("PDD-001", "USB-001")
event = self.repository.save_collect_failure(
"PDD-001",
started.attempt_id,
TaskStatus.FAILED,
"GOODS_OFF_SHELF",
"商品已下架",
False,
)
self.repository.mark_outbox_sent(event.id)
page = PDDTaskPage()
events = PDDTaskPageEvent(
page,
self.repository,
claim_gateway=RecordingClaimGateway(None),
settings_repository=self._saved_settings(),
)
events._auto_fetch_running = True
events._on_collect_outcome("task_failed", "采集失败:商品已下架", "PDD-001")
self.assertTrue(events._auto_fetch_running)
self.assertFalse(events._stop_requested)
self.assertEqual(events._next_task_delay_ms, events._cycle_next_delay_ms)
self.assertIn("采集失败:商品已下架", page.statusLabel.text())
events.shutdown()
page.deleteLater()
def test_global_failure_stops_auto_fetch(self):
page = PDDTaskPage()
events = PDDTaskPageEvent(
page,
self.repository,
claim_gateway=RecordingClaimGateway(None),
settings_repository=self._saved_settings(),
)
events._auto_fetch_running = True
events._on_collect_outcome(
"global_failed", "PDD 登录失效,需要人工登录", "COL-001"
)
self.assertFalse(events._auto_fetch_running)
self.assertTrue(events._stop_requested)
self.assertIn("PDD 登录失效", events._stop_status)
events.shutdown()
page.deleteLater()
def test_unavailable_goods_continues_auto_fetch_after_current_task(self):
page = PDDTaskPage()
events = PDDTaskPageEvent(
page,
self.repository,
claim_gateway=RecordingClaimGateway(None),
settings_repository=self._saved_settings(),
next_task_delay_ms=20,
)
events._auto_fetch_running = True
events._on_collect_outcome(
"business_failed",
"商品链接已失效,PDD 无法打开商品详情页并返回了首页",
"PDD-001",
)
self.assertEqual(20, events._cycle_next_delay_ms)
self.assertTrue(events._auto_fetch_running)
self.assertFalse(events._stop_requested)
self.assertIn("商品链接已失效", page.statusLabel.text())
events.shutdown()
page.deleteLater()
def test_live_submit_schedules_read_only_reconcile_after_five_seconds(self):
page = PDDTaskPage()
events = PDDTaskPageEvent(
page,
self.repository,
claim_gateway=RecordingClaimGateway(None),
settings_repository=self._saved_settings(),
)
events._auto_fetch_running = True
events._on_collect_outcome(
"reconcile_pending",
"任务 PUR-001 订单提交点击已执行,结果待只读核对",
"PUR-001",
)
self.assertEqual(
events._cycle_next_delay_ms, PURCHASE_RECONCILE_DELAY_MS
)
self.assertTrue(events._auto_fetch_running)
self.assertFalse(events._stop_requested)
self.assertIn("5 秒后自动核对订单", page.statusLabel.text())
events.shutdown()
page.deleteLater()
def test_stop_during_reconcile_wait_cancels_next_cycle(self):
page = PDDTaskPage()
events = PDDTaskPageEvent(
page,
self.repository,
claim_gateway=RecordingClaimGateway(None),
settings_repository=self._saved_settings(),
)
events._auto_fetch_running = True
events._on_collect_outcome(
"reconcile_pending", "等待只读核对", "PUR-001"
)
events._on_claim_thread_finished()
self.assertTrue(events._next_cycle_timer.isActive())
self.assertEqual(
events._next_cycle_timer.interval(), PURCHASE_RECONCILE_DELAY_MS
)
events._request_stop_auto_fetch()
self.assertFalse(events._next_cycle_timer.isActive())
self.assertFalse(events._auto_fetch_running)
events.shutdown()
page.deleteLater()
def test_duplicate_task_is_normal_status(self):
task = collect_admin_task()
self.repository.add_claimed_task(admin_task_to_new_claimed_task(task))
gateway = RecordingClaimGateway(task)
page = PDDTaskPage()
events = PDDTaskPageEvent(
page,
self.repository,
claim_gateway=gateway,
settings_repository=self._saved_settings(),
collect_service_factory=fake_collect_factory,
)
page.autoFetchRequested.emit()
self.assertTrue(wait_until(self.app, lambda: not events._claim_busy))
self.assertIn("采集完成", page.statusLabel.text())
self.assertEqual(gateway.calls, [])
self.assertEqual(self.repository.count_tasks(), 1)
events.shutdown()
page.deleteLater()
def test_local_save_failure_status_contains_claimed_task_id(self):
gateway = RecordingClaimGateway(collect_admin_task("COL-LOST"))
page = PDDTaskPage()
events = PDDTaskPageEvent(
page,
BrokenSaveRepository(),
claim_gateway=gateway,
settings_repository=self._saved_settings(),
collect_service_factory=fake_collect_factory,
)
page.autoFetchRequested.emit()
self.assertTrue(wait_until(self.app, lambda: not events._claim_busy))
self.assertIn("COL-LOST", page.statusLabel.text())
self.assertIn("本地保存失败", page.statusLabel.text())
self.assertIn("disk is full", page.statusLabel.text())
events.shutdown()
page.deleteLater()
def test_missing_client_settings_do_not_call_admin(self):
gateway = RecordingClaimGateway(collect_admin_task())
page = PDDTaskPage()
events = PDDTaskPageEvent(
page,
self.repository,
claim_gateway=gateway,
settings_repository=SettingsRepository(self.db_path),
)
page.autoFetchRequested.emit()
self.assertTrue(wait_until(self.app, lambda: not events._claim_busy))
self.assertEqual(gateway.calls, [])
self.assertIn("设置页", page.statusLabel.text())
2026-08-09 10:54:18 +08:00
self.assertFalse(events._auto_fetch_running)
self.assertEqual(page.autoFetchButton.text(), "自动获取")
events.shutdown()
page.deleteLater()
2026-08-09 10:54:18 +08:00
def test_shutdown_cancels_scheduled_next_claim(self):
gateway = RecordingClaimGateway(None)
page = PDDTaskPage()
events = PDDTaskPageEvent(
page,
self.repository,
claim_gateway=gateway,
settings_repository=self._saved_settings(),
no_task_delay_ms=20,
)
page.autoFetchRequested.emit()
self.assertTrue(wait_until(self.app, lambda: not events._claim_busy))
self.assertTrue(events._next_cycle_timer.isActive())
events.shutdown()
deadline = time.monotonic() + 0.08
while time.monotonic() < deadline:
self.app.processEvents()
time.sleep(0.005)
self.assertEqual(len(gateway.calls), 1)
page.deleteLater()
def test_shutdown_after_claim_started_still_saves_task_without_ui_callback(self):
gateway = SlowClaimGateway(collect_admin_task("COL-CLOSE"))
page = PDDTaskPage()
events = PDDTaskPageEvent(
page,
self.repository,
claim_gateway=gateway,
settings_repository=self._saved_settings(),
collect_service_factory=fake_collect_factory,
)
page.autoFetchRequested.emit()
self.assertTrue(wait_until(self.app, gateway.started.is_set))
status_before_close = page.statusLabel.text()
events.shutdown()
self.app.processEvents()
self.assertIsNotNone(self.repository.get_task("COL-CLOSE"))
self.assertEqual(page.statusLabel.text(), status_before_close)
page.deleteLater()
if __name__ == "__main__":
unittest.main()