feat: 增加任务批量勾选和重新上报 (#89)
This commit is contained in:
@@ -0,0 +1,85 @@
|
||||
"""PDD 任务表勾选模型的离屏测试。"""
|
||||
|
||||
import os
|
||||
import unittest
|
||||
|
||||
os.environ.setdefault("QT_QPA_PLATFORM", "offscreen")
|
||||
|
||||
from PyQt5.QtCore import Qt
|
||||
from PyQt5.QtWidgets import QApplication
|
||||
|
||||
from src.pdd_ui import CHECK_COLUMN, PDDTaskPage, TaskRow, TaskTableModel
|
||||
|
||||
|
||||
class TaskTableModelCheckTests(unittest.TestCase):
|
||||
@classmethod
|
||||
def setUpClass(cls):
|
||||
cls.app = QApplication.instance() or QApplication([])
|
||||
|
||||
def test_check_uses_stable_task_id_and_survives_reorder(self):
|
||||
model = TaskTableModel()
|
||||
model.set_tasks(
|
||||
[TaskRow("TASK-1", "采集"), TaskRow("TASK-2", "采集")]
|
||||
)
|
||||
|
||||
self.assertTrue(
|
||||
model.setData(
|
||||
model.index(1, CHECK_COLUMN), Qt.Checked, Qt.CheckStateRole
|
||||
)
|
||||
)
|
||||
model.set_tasks(
|
||||
[TaskRow("TASK-2", "采集"), TaskRow("TASK-1", "采集")]
|
||||
)
|
||||
|
||||
self.assertEqual(model.checked_task_ids(), ("TASK-2",))
|
||||
self.assertEqual(
|
||||
model.data(model.index(0, CHECK_COLUMN), Qt.CheckStateRole),
|
||||
Qt.Checked,
|
||||
)
|
||||
|
||||
def test_placeholder_is_not_checkable_and_removed_task_is_pruned(self):
|
||||
model = TaskTableModel()
|
||||
self.assertEqual(model.flags(model.index(0, CHECK_COLUMN)), Qt.NoItemFlags)
|
||||
model.set_tasks([TaskRow("TASK-1", "采集")])
|
||||
model.setData(
|
||||
model.index(0, CHECK_COLUMN), Qt.Checked, Qt.CheckStateRole
|
||||
)
|
||||
|
||||
model.set_tasks([])
|
||||
|
||||
self.assertEqual(model.checked_task_ids(), ())
|
||||
|
||||
def test_search_clears_checks_and_refresh_is_next_to_search(self):
|
||||
page = PDDTaskPage()
|
||||
page.set_tasks([TaskRow("TASK-1", "采集")])
|
||||
page.taskModel.setData(
|
||||
page.taskModel.index(0, CHECK_COLUMN), Qt.Checked, Qt.CheckStateRole
|
||||
)
|
||||
|
||||
page.resize(1300, 650)
|
||||
self.app.processEvents()
|
||||
layout = page.commandCard.layout()
|
||||
search_position = layout.getItemPosition(layout.indexOf(page.searchButton))
|
||||
refresh_position = layout.getItemPosition(layout.indexOf(page.refreshButton))
|
||||
rerun_position = layout.getItemPosition(layout.indexOf(page.rerunButton))
|
||||
resubmit_position = layout.getItemPosition(layout.indexOf(page.resubmitButton))
|
||||
self.assertEqual(refresh_position[1], search_position[1] + 1)
|
||||
self.assertEqual(resubmit_position[1], rerun_position[1] + 1)
|
||||
self.assertEqual(page.checkedCountLabel.text(), "已选 1 条")
|
||||
|
||||
page.resize(1000, 650)
|
||||
self.app.processEvents()
|
||||
compact_search = layout.getItemPosition(layout.indexOf(page.searchButton))
|
||||
compact_refresh = layout.getItemPosition(layout.indexOf(page.refreshButton))
|
||||
self.assertEqual(compact_search[0], 1)
|
||||
self.assertEqual(compact_refresh[1], compact_search[1] + 1)
|
||||
|
||||
page.searchButton.click()
|
||||
|
||||
self.assertEqual(page.taskModel.checked_task_ids(), ())
|
||||
self.assertEqual(page.checkedCountLabel.text(), "已选 0 条")
|
||||
page.deleteLater()
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -10,6 +10,7 @@ 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
|
||||
|
||||
@@ -127,6 +128,25 @@ class RetryableClaimGateway(RecordingClaimGateway):
|
||||
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 = []
|
||||
|
||||
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")
|
||||
|
||||
|
||||
class FakeCollectResult:
|
||||
def to_pdd_data(self):
|
||||
return {
|
||||
@@ -249,7 +269,7 @@ class PDDTaskPageEventTest(unittest.TestCase):
|
||||
"src.pdd_ui_event.AndroidDeviceService.require_connected",
|
||||
autospec=True,
|
||||
)
|
||||
self.device_checker_patch.start()
|
||||
self.device_checker = self.device_checker_patch.start()
|
||||
|
||||
def tearDown(self):
|
||||
self.device_checker_patch.stop()
|
||||
@@ -325,24 +345,167 @@ class PDDTaskPageEventTest(unittest.TestCase):
|
||||
self.assertEqual(row.price_cents, 3990)
|
||||
page.deleteLater()
|
||||
|
||||
def test_rerun_button_follows_current_row_selection(self):
|
||||
def test_rerun_button_requires_exactly_one_checked_task(self):
|
||||
self._add_task(1)
|
||||
self._add_task(2)
|
||||
page = PDDTaskPage()
|
||||
events = PDDTaskPageEvent(page, self.repository)
|
||||
events.load_initial_tasks()
|
||||
|
||||
page.taskTable.clearSelection()
|
||||
self.app.processEvents()
|
||||
self.assertFalse(page.rerunButton.isEnabled())
|
||||
page.taskTable.selectRow(0)
|
||||
page.taskModel.setData(
|
||||
page.taskModel.index(0, 0), Qt.Checked, Qt.CheckStateRole
|
||||
)
|
||||
self.app.processEvents()
|
||||
self.assertTrue(page.rerunButton.isEnabled())
|
||||
page.taskTable.clearSelection()
|
||||
page.taskModel.setData(
|
||||
page.taskModel.index(1, 0), Qt.Checked, Qt.CheckStateRole
|
||||
)
|
||||
self.app.processEvents()
|
||||
self.assertFalse(page.rerunButton.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_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")
|
||||
|
||||
@@ -209,6 +209,33 @@ class TaskRepositoryTests(unittest.TestCase):
|
||||
TaskStatus.SUCCEEDED,
|
||||
)
|
||||
|
||||
def test_latest_result_outbox_excludes_newer_failure_event(self):
|
||||
self.repository.add_claimed_task(self._task("TASK-REPORT"))
|
||||
first = self.repository.start_collect_run("TASK-REPORT", "USB-001")
|
||||
result = self.repository.save_collect_result(
|
||||
"TASK-REPORT",
|
||||
first.attempt_id,
|
||||
{"goods_id": "10001", "title": "测试商品", "skus": []},
|
||||
)
|
||||
self.repository.mark_outbox_sent(result.id)
|
||||
self.repository.prepare_collect_rerun("TASK-REPORT")
|
||||
second = self.repository.start_collect_run("TASK-REPORT", "USB-001")
|
||||
failure = self.repository.save_collect_failure(
|
||||
"TASK-REPORT",
|
||||
second.attempt_id,
|
||||
TaskStatus.FAILED,
|
||||
"TEST_FAILURE",
|
||||
"测试失败",
|
||||
False,
|
||||
)
|
||||
|
||||
latest = self.repository.latest_result_outbox("TASK-REPORT")
|
||||
|
||||
self.assertEqual(latest.id, result.id)
|
||||
self.assertEqual(latest.event_type, OutboxEventType.COLLECT_RESULT)
|
||||
self.assertNotEqual(latest.id, failure.id)
|
||||
self.assertIsNone(self.repository.latest_result_outbox("MISSING"))
|
||||
|
||||
def test_prepare_rerun_preserves_old_result_and_creates_new_attempt(self):
|
||||
self.repository.add_claimed_task(self._task("TASK-RERUN"))
|
||||
first = self.repository.start_collect_run("TASK-RERUN", "USB-001")
|
||||
|
||||
Reference in New Issue
Block a user