From 9852b1c9c160568f0805f5df1511b3b870536246 Mon Sep 17 00:00:00 2001 From: chengma Date: Mon, 20 Jul 2026 10:27:21 +0800 Subject: [PATCH] feat(collect): support soft deletion of one task --- app/db.py | 49 ++++++++++++++++++ app/gui/tabs/collect.py | 81 +++++++++++++++++++++++++++++ docs/04-architecture.md | 5 +- docs/api.md | 4 +- docs/routes.md | 1 + docs/tasks/T-670.md | 17 ++++--- tests/test_db.py | 77 +++++++++++++++++++++++++++- tests/test_gui.py | 109 ++++++++++++++++++++++++++++++++++++++++ 8 files changed, 333 insertions(+), 10 deletions(-) diff --git a/app/db.py b/app/db.py index 766c73e..e6f2447 100644 --- a/app/db.py +++ b/app/db.py @@ -161,6 +161,8 @@ class Task: generated_at: Optional[str] applied_at: Optional[str] updated_at: str + deleted_at: Optional[str] + deleted_reason: Optional[str] @dataclass(frozen=True) @@ -261,6 +263,8 @@ CREATE TABLE IF NOT EXISTS tasks ( generated_at TEXT, applied_at TEXT, updated_at TEXT NOT NULL, + deleted_at TEXT, + deleted_reason TEXT, UNIQUE(batch_id, source_file_abs, source_sheet, source_row) ); @@ -495,6 +499,7 @@ def init_db(path=None, conn=None) -> None: _ensure_task_image_task_columns(database) _ensure_task_cover_reset_columns(database) _ensure_task_product_status_columns(database) + _ensure_task_delete_columns(database) _migrate_legacy_product_status_defaults(database) _ensure_image_studio_project_suite_columns(database) _ensure_image_studio_project_draft_columns(database) @@ -537,6 +542,15 @@ def _ensure_task_product_status_columns(database): database.execute("ALTER TABLE tasks ADD COLUMN product_status_at TEXT") +def _ensure_task_delete_columns(database): + columns = {row["name"] for row in database.execute("PRAGMA table_info(tasks)").fetchall()} + if "deleted_at" not in columns: + database.execute("ALTER TABLE tasks ADD COLUMN deleted_at TEXT") + if "deleted_reason" not in columns: + database.execute("ALTER TABLE tasks ADD COLUMN deleted_reason TEXT") + database.execute("CREATE INDEX IF NOT EXISTS idx_tasks_deleted_at ON tasks(deleted_at)") + + def _migrate_legacy_product_status_defaults(database): """Treat pre-status-feature active batches as historically on-shelf once.""" @@ -548,6 +562,7 @@ def _migrate_legacy_product_status_defaults(database): product_status_at = NULL, updated_at = ? WHERE (product_status IS NULL OR TRIM(product_status) = '') + AND deleted_at IS NULL AND batch_id IN ( SELECT id FROM batches @@ -880,6 +895,7 @@ def list_tasks( params.append(value) if not include_deleted: clauses.append("b.deleted_at IS NULL") + clauses.append("t.deleted_at IS NULL") sql = "SELECT t.* FROM tasks t JOIN batches b ON b.id = t.batch_id" if clauses: sql += " WHERE " + " AND ".join(clauses) @@ -920,11 +936,44 @@ def delete_batch(batch_id, reason=None, path=None, conn=None) -> dict: "image_paths": image_paths, } + +def delete_task(task_id, reason=None, path=None, conn=None) -> dict: + """Soft delete one inactive task without touching its source files or images.""" + + task_id = int(task_id) + with _connection(conn, path) as database: + task = get_task(task_id, conn=database) + if task is None: + raise DbError(f"任务不存在或已删除: {task_id}") + if task.status == "running": + raise DbError("任务正在处理,不能删除") + now = _now() + with database: + cursor = database.execute( + """ + UPDATE tasks + SET deleted_at = ?, deleted_reason = ?, updated_at = ? + WHERE id = ? AND deleted_at IS NULL AND status <> 'running' + """, + (now, str(reason or ""), now, task_id), + ) + if cursor.rowcount != 1: + raise DbError("任务已删除或正在处理,不能删除") + return { + "task_id": task_id, + "batch_id": task.batch_id, + "item_id": task.item_id, + "committed": int(task.committed or 0), + "deleted_at": now, + } + + def get_task(task_id, path=None, conn=None, include_deleted=False): clauses = ["t.id = ?"] params = [int(task_id)] if not include_deleted: clauses.append("b.deleted_at IS NULL") + clauses.append("t.deleted_at IS NULL") sql = "SELECT t.* FROM tasks t JOIN batches b ON b.id = t.batch_id" sql += " WHERE " + " AND ".join(clauses) with _connection(conn, path) as database: diff --git a/app/gui/tabs/collect.py b/app/gui/tabs/collect.py index 4f612ac..406cdff 100644 --- a/app/gui/tabs/collect.py +++ b/app/gui/tabs/collect.py @@ -178,6 +178,7 @@ class CollectTab(QWidget): self.table.setSelectionBehavior(QAbstractItemView.SelectRows) self.table.setSelectionMode(QAbstractItemView.SingleSelection) self.table.setEditTriggers(QAbstractItemView.NoEditTriggers) + self.table.setContextMenuPolicy(Qt.CustomContextMenu) self.table.horizontalHeader().setSectionResizeMode(QHeaderView.Stretch) for column in (3, 4): self.table.horizontalHeader().setSectionResizeMode(column, QHeaderView.ResizeToContents) @@ -219,6 +220,7 @@ class CollectTab(QWidget): self.product_status_filter.currentIndexChanged.connect(self.refresh_tasks) self.status_filter.currentIndexChanged.connect(self.refresh_tasks) self.delete_batch_button.clicked.connect(self.delete_current_batch) + self.table.customContextMenuRequested.connect(self._show_task_context_menu) self.collect_button.clicked.connect(self.collect_old_data) self.stop_collect_button.clicked.connect(self.stop_collect) self.write_back_button.clicked.connect(self.write_back_old_data) @@ -682,6 +684,85 @@ class CollectTab(QWidget): running = bool(self.collect_thread or self.write_back_thread) self.delete_batch_button.setEnabled((not running) and bool(self._selected_batch_id())) + def _show_task_context_menu(self, position): + index = self.table.indexAt(position) + if not index.isValid(): + return + self.table.selectRow(index.row()) + self.table.setCurrentIndex(index) + task = self.model.task_at(index.row()) + if task is None: + return + menu = QMenu(self.table) + delete_action = menu.addAction("删除本条记录") + delete_action.setEnabled( + not bool(self.collect_thread or self.write_back_thread) + and getattr(task, "status", "") != "running" + ) + delete_action.triggered.connect(self.delete_selected_task) + menu.exec(self.table.viewport().mapToGlobal(position)) + + def delete_selected_task(self, checked=False): + if self.collect_thread or self.write_back_thread: + self._set_status("采集或回写正在进行,不能删除记录", level="warning") + return + index = self.table.currentIndex() + task = self.model.task_at(index.row()) if index.isValid() else None + if task is None: + self._set_status("请选择要删除的记录", level="warning") + return + if getattr(task, "status", "") == "running": + self._set_status("任务正在处理,不能删除", level="warning") + return + batch = db.get_batch(task.batch_id, path=self.db_path) + batch_text = self._batch_label(batch) if batch is not None else task.batch_id + lines = [ + "确定要删除本条本地记录吗?", + f"商品ID:{task.item_id}", + f"店铺:{task.account_name or task.alias}({task.alias})", + f"所属批次:{batch_text}", + ] + if int(getattr(task, "committed", 0) or 0) == 1: + lines.extend( + [ + "", + "该商品已提交过蝦皮。本次仅删除本地记录,不会回滚蝦皮线上商品。", + ] + ) + lines.extend( + [ + "", + "删除后,该记录不会出现在①、②、③的列表、筛选、采集、生成、更新或回写范围。", + "不会删除原 Excel、本地图片或账号 Chrome 数据。", + ] + ) + answer = QMessageBox.question( + self, + "删除本条记录", + "\n".join(lines), + QMessageBox.Yes | QMessageBox.No, + QMessageBox.No, + ) + if answer != QMessageBox.Yes: + self._set_status("已取消删除本条记录") + return + try: + result = db.delete_task( + task.id, + reason="用户在导入采集页删除单条记录", + path=self.db_path, + ) + except Exception as exc: + QMessageBox.warning(self, "删除本条记录", str(exc)) + self._set_status(f"删除本条记录失败:{exc}", level="danger") + return + self.refresh_tasks() + if self.refresh_workflow_callback is not None: + self.refresh_workflow_callback() + message = f"已删除本地记录:商品 {result['item_id']}" + self._set_status(message, level="success") + QMessageBox.information(self, "删除本条记录", message) + def delete_current_batch(self, checked=False): batch_id = self._selected_batch_id() if not batch_id: diff --git a/docs/04-architecture.md b/docs/04-architecture.md index c79b53b..8129b8d 100644 --- a/docs/04-architecture.md +++ b/docs/04-architecture.md @@ -292,12 +292,15 @@ CREATE TABLE tasks ( generated_at TEXT, applied_at TEXT, updated_at TEXT NOT NULL, + deleted_at TEXT, -- T-670 单条任务软删除时间;默认业务查询排除 + deleted_reason TEXT, -- 软删除原因/来源 UNIQUE(batch_id, source_file_abs, source_sheet, source_row) ); CREATE INDEX idx_tasks_batch_stage_status ON tasks(batch_id, stage, status); CREATE INDEX idx_tasks_alias ON tasks(alias); CREATE INDEX idx_tasks_item ON tasks(item_id); +CREATE INDEX idx_tasks_deleted_at ON tasks(deleted_at); ``` 运行日志(T-504): @@ -595,6 +598,6 @@ cmshopee/ - 别名是账号↔任务唯一关联键。 - 密码与 AI Key 本地明文保存,保存/变更时弹窗提示;UI 打码、不外传、不写日志/导出;不自动登录。 - AI 生成内容直接进入 ③ 更新候选;③ 批量确认后提交,新图本地留档 + 回写 Excel 以备追溯。 -- 批次删除是软删除:写 `batches.deleted_at/deleted_reason`,不物理删除 `batches/tasks`;默认 `list_batches/list_tasks/get_task` 与 ①/②/③ 页面和执行入口都排除已删除批次。 +- 批次删除是软删除:写 `batches.deleted_at/deleted_reason`,不物理删除 `batches/tasks`;默认 `list_batches/list_tasks/get_task` 与 ①/②/③ 页面和执行入口都排除已删除批次。T-670 后①还可对单条非运行任务写 `tasks.deleted_at/deleted_reason`;默认 `list_tasks/get_task` 和所有工作流候选同样排除该任务,不删除 Excel、图片或线上商品。 - 高风险模块先单独验证,再接入流水线。 - GUI 只通过 signal/slot 接收 worker 进度;禁止后台线程直接操作 Qt widget 或共享 SQLite connection。 diff --git a/docs/api.md b/docs/api.md index de95caa..49d76ac 100644 --- a/docs/api.md +++ b/docs/api.md @@ -102,10 +102,12 @@ delete_account(alias) -> None insert_tasks(batch_id, rows, path=None) -> int # 写输入列;rows 含 source_file_abs/source_sheet/source_row/row_key list_tasks(batch_id=None, stage=None, status=None, alias=None, path=None, include_deleted=False) -> list[Task] get_task(task_id, path=None, include_deleted=False) -> Task | None -# 默认排除已软删除批次;include_deleted 仅供内部诊断/测试使用 +# 默认排除已软删除批次和任务;include_deleted 仅供内部诊断/测试使用 delete_batch(batch_id, reason=None, path=None) -> dict # T-206 软删除:写 batches.deleted_at/deleted_reason,不物理删除 batches/tasks;默认业务列表和执行流程不可见/不可调用;返回任务数、committed 数和关联图片路径 +delete_task(task_id, reason=None, path=None) -> dict +# T-670 软删除单条非 running 任务:写 tasks.deleted_at/deleted_reason;不删除 Excel、图片或线上商品,默认业务查询和执行入口不可见/不可调用 mark_running(task_id, phase) -> None mark_failed(task_id, phase, error) -> None # status=failed,stage 不前进,对应 attempts+1 mark_skipped(task_id, reason) -> None # status=skipped,stage 不前进 diff --git a/docs/routes.md b/docs/routes.md index fc6bcf9..3486fd2 100644 --- a/docs/routes.md +++ b/docs/routes.md @@ -81,6 +81,7 @@ - 别名未匹配账号 → 该行 skipped 并记原因,不为未匹配别名启动 Chrome。账号预检或采集中途明确进入登录页 → 该账号剩余任务整组 skipped 并记原因,结束汇总列出需补登录的账号;`NO_SESSION_COOKIE` 等不确定检测结果只写日志和诊断信息,不触发整组 skipped。`T-207` 接入后,① 采集会像③更新一样写 `run_logs/run_log_events`,并把完整脱敏 traceback 写入本地 `data/logs/`,用于定位失败卡在哪个步骤。 - 「删除批次」位于①批次筛选旁,只能对当前选中的具体批次执行,不能在“全部批次”下执行;运行中禁用。删除是软删除:写本地批次删除标记,不物理删除 DB 记录,不删除原 Excel,不回滚蝦皮。删除后该批次不再出现在①/②/③任何批次下拉、任务列表、筛选、采集、生成、更新、回写入口中。确认框必须显示任务数、已上线任务数,并提示软删除只隐藏本地批次、不会回滚线上修改。 +- ①任务表右键先选中命中行并打开菜单,第一版仅提供「删除本条记录」;右键本身不弹确认框。点击后才确认商品 ID、店铺和批次;已提交线上时明确提示仅删除本地记录、不回滚蝦皮。单条删除写 `tasks.deleted_at/deleted_reason`,运行中禁用且数据库拒绝 `running` 任务;删除后该记录不再进入①/②/③列表、筛选、采集、生成、更新、回写和默认统计,不删除 Excel、本地图片或账号 Chrome 数据。 ## ② AI生成 左右布局:左侧约 1/4 放提示词,右侧放筛选 + 任务列表。 diff --git a/docs/tasks/T-670.md b/docs/tasks/T-670.md index 12898e0..c355f23 100644 --- a/docs/tasks/T-670.md +++ b/docs/tasks/T-670.md @@ -1,7 +1,7 @@ --- id: T-670 title: 导入采集单条任务软删除 -status: TODO +status: DONE phase: 7 deps: [] created: 2026-07-20 @@ -23,11 +23,11 @@ created: 2026-07-20 ## 验收标准 -- [ ] 右键任务行只弹菜单,点击「删除本条记录」后才弹确认框;取消不改变任何数据。 -- [ ] 确认后任务被软删除,立即不再出现在①、②、③列表、筛选、采集、生成、更新、回写和默认统计中;其他同批次任务保持不变。 -- [ ] 已提交线上任务的确认框说明本地删除不回滚线上;原 Excel、本地图片与任务历史字段均未被物理删除。 -- [ ] 已删除或正在处理任务不能被重复删除,相关 API 事务安全且不产生半删除状态。 -- [ ] `tests/test_db.py` 覆盖 schema 迁移、默认排除、单条读取和重复/运行中保护;`tests/test_gui.py` 覆盖右键选中、确认/取消、已提交提示和删除后刷新。 +- [x] 右键任务行只弹菜单,点击「删除本条记录」后才弹确认框;取消不改变任何数据。 +- [x] 确认后任务被软删除,立即不再出现在①、②、③列表、筛选、采集、生成、更新、回写和默认统计中;其他同批次任务保持不变。 +- [x] 已提交线上任务的确认框说明本地删除不回滚线上;原 Excel、本地图片与任务历史字段均未被物理删除。 +- [x] 已删除或正在处理任务不能被重复删除,相关 API 事务安全且不产生半删除状态。 +- [x] `tests/test_db.py` 覆盖 schema 迁移、默认排除、单条读取和重复/运行中保护;`tests/test_gui.py` 覆盖右键选中、确认/取消、已提交提示和删除后刷新。 ## 验证 @@ -48,4 +48,7 @@ git diff --check ## 执行记录 -- 待实现。 +- 为 `tasks` 增加 `deleted_at/deleted_reason` 兼容迁移和索引,新增事务化 `db.delete_task()`;默认 `list_tasks/get_task` 同时排除已删除批次和已删除任务,历史商品状态迁移也跳过已删除任务。 +- ①任务表新增右键「删除本条记录」菜单和确认框,已提交线上任务显示不回滚提示;成功后刷新①并通过既有回调刷新②/③。运行中入口禁用,数据库拒绝 `running` 或已删除任务。 +- 更新架构、API 和路由文档,明确单条软删除不删除 Excel、图片、账号 Chrome 数据或线上商品。 +- 验证通过:`py -3.10 -m unittest discover -s tests`(623 项)、`py -3.10 -m ruff check app tests main.py`、`py -3.10 -m compileall app main.py`、`git diff --check`。 diff --git a/tests/test_db.py b/tests/test_db.py index 204259c..fe2aca0 100644 --- a/tests/test_db.py +++ b/tests/test_db.py @@ -119,6 +119,8 @@ class DbTests(TempDirMixin, unittest.TestCase): self.assertIn("product_status", columns) self.assertIn("product_status_note", columns) self.assertIn("product_status_at", columns) + self.assertIn("deleted_at", columns) + self.assertIn("deleted_reason", columns) task = db.get_task(1, conn=conn) self.assertEqual(0, task.cover_reset_count) self.assertIsNone(task.cover_reset_at) @@ -146,6 +148,7 @@ class DbTests(TempDirMixin, unittest.TestCase): "boundary", "after", "deleted", + "task-deleted", ), start=1, ): @@ -170,13 +173,14 @@ class DbTests(TempDirMixin, unittest.TestCase): try: with conn: conn.execute( - "UPDATE batches SET created_at = ? WHERE id IN (?, ?, ?, ?)", + "UPDATE batches SET created_at = ? WHERE id IN (?, ?, ?, ?, ?)", ( "2026-07-18T16:34:36", batch_ids["legacy-null"], batch_ids["legacy-blank"], batch_ids["legacy-unknown"], batch_ids["deleted"], + batch_ids["task-deleted"], ), ) conn.execute( @@ -195,6 +199,10 @@ class DbTests(TempDirMixin, unittest.TestCase): batch_id=batch_ids["legacy-unknown"], conn=conn, )[0] + task_deleted = db.list_tasks( + batch_id=batch_ids["task-deleted"], + conn=conn, + )[0] conn.execute( "UPDATE tasks SET product_status = ' ' WHERE id = ?", (blank_task.id,), @@ -207,6 +215,10 @@ class DbTests(TempDirMixin, unittest.TestCase): "UPDATE batches SET deleted_at = ? WHERE id = ?", ("2026-07-19T00:00:00", batch_ids["deleted"]), ) + conn.execute( + "UPDATE tasks SET deleted_at = ? WHERE id = ?", + ("2026-07-19T00:00:00", task_deleted.id), + ) finally: conn.close() @@ -221,6 +233,11 @@ class DbTests(TempDirMixin, unittest.TestCase): path=db_path, include_deleted=True, )[0] + task_deleted = db.list_tasks( + batch_id=batch_ids["task-deleted"], + path=db_path, + include_deleted=True, + )[0] for task in (legacy_null, legacy_blank): self.assertEqual("normal", task.product_status) @@ -231,6 +248,7 @@ class DbTests(TempDirMixin, unittest.TestCase): self.assertIsNone(boundary.product_status) self.assertIsNone(after.product_status) self.assertIsNone(deleted.product_status) + self.assertIsNone(task_deleted.product_status) first_updated_at = legacy_null.updated_at db.init_db(db_path) @@ -1005,6 +1023,63 @@ class DbTests(TempDirMixin, unittest.TestCase): self.assert_removed(temp_dir) + def test_delete_task_soft_hides_only_one_inactive_task(self): + with self.make_temp_dir() as temp_dir: + db_path = os.path.join(temp_dir, "cmshopee.db") + db.init_db(db_path) + batch_id = db.create_batch(["input.xlsx"], path=db_path) + db.insert_tasks( + batch_id, + [ + { + "source_file_abs": os.path.join(temp_dir, "input.xlsx"), + "source_sheet": "Sheet1", + "source_row": 2, + "account_name": "shop", + "alias": "alias", + "item_id": "51100639510", + }, + { + "source_file_abs": os.path.join(temp_dir, "input.xlsx"), + "source_sheet": "Sheet1", + "source_row": 3, + "account_name": "shop", + "alias": "alias", + "item_id": "51100639511", + }, + ], + path=db_path, + ) + first, second = db.list_tasks(batch_id=batch_id, path=db_path) + db.set_collected(first.id, "旧标题", "old.jpg", path=db_path) + db.set_generated(first.id, "新标题", "new.jpg", path=db_path) + db.set_applied(first.id, True, path=db_path) + + result = db.delete_task(first.id, reason="导入了错误商品", path=db_path) + + self.assertEqual(first.id, result["task_id"]) + self.assertEqual(batch_id, result["batch_id"]) + self.assertEqual("51100639510", result["item_id"]) + self.assertEqual(1, result["committed"]) + self.assertEqual([second.id], [task.id for task in db.list_tasks(batch_id=batch_id, path=db_path)]) + self.assertIsNone(db.get_task(first.id, path=db_path)) + deleted = db.get_task(first.id, path=db_path, include_deleted=True) + self.assertIsNotNone(deleted.deleted_at) + self.assertEqual("导入了错误商品", deleted.deleted_reason) + self.assertEqual("旧标题", deleted.old_title) + self.assertEqual("新标题", deleted.new_title) + self.assertEqual("new.jpg", deleted.new_cover_path) + self.assertEqual(1, deleted.committed) + self.assertEqual([batch_id], [batch.id for batch in db.list_batches(path=db_path)]) + + with self.assertRaisesRegex(db.DbError, "不存在或已删除"): + db.delete_task(first.id, path=db_path) + db.mark_running(second.id, "collect", path=db_path) + with self.assertRaisesRegex(db.DbError, "正在处理"): + db.delete_task(second.id, path=db_path) + + self.assert_removed(temp_dir) + def test_duplicate_task_and_invalid_update_raise_clear_errors(self): with self.make_temp_dir() as temp_dir: db_path = os.path.join(temp_dir, "cmshopee.db") diff --git a/tests/test_gui.py b/tests/test_gui.py index 5b75667..511d2c6 100644 --- a/tests/test_gui.py +++ b/tests/test_gui.py @@ -8827,6 +8827,115 @@ class GuiTests(TempDirMixin, unittest.TestCase): self.assert_removed(temp_dir) + def test_collect_tab_soft_deletes_selected_task_from_context_menu(self): + with self.make_temp_dir() as temp_dir: + cfg = self.make_config(temp_dir) + accounts.create_account("主店", "alias-a", debug_port=9222, config=cfg) + batch_id = db.create_batch(["input.xlsx"], path=cfg["db_path"]) + db.insert_tasks( + batch_id, + [ + { + "source_file_abs": os.path.join(temp_dir, "input.xlsx"), + "source_sheet": "商品", + "source_row": 2, + "account_name": "Excel主店", + "alias": "alias-a", + "item_id": "51100639510", + }, + { + "source_file_abs": os.path.join(temp_dir, "input.xlsx"), + "source_sheet": "商品", + "source_row": 3, + "account_name": "Excel主店", + "alias": "alias-a", + "item_id": "51100639511", + }, + ], + path=cfg["db_path"], + ) + first, second = db.list_tasks(batch_id=batch_id, path=cfg["db_path"]) + db.set_collected(first.id, "旧标题", "old.jpg", path=cfg["db_path"]) + db.set_generated(first.id, "新标题", "new.jpg", path=cfg["db_path"]) + db.set_applied(first.id, True, path=cfg["db_path"]) + db.set_collected(second.id, "旧标题2", "old2.jpg", path=cfg["db_path"]) + db.set_generated(second.id, "新标题2", "new2.jpg", path=cfg["db_path"]) + statuses = [] + refresh_calls = [] + generate_tab = GenerateTab(config=cfg) + apply_tab = ApplyTab(config=cfg) + apply_tab.status_filter.setCurrentIndex(apply_tab.status_filter.findData("all")) + + def refresh_workflow(): + refresh_calls.append(True) + generate_tab.refresh_tasks() + apply_tab.refresh_tasks() + + collect_tab = CollectTab( + config=cfg, + status_callback=statuses.append, + refresh_workflow_callback=refresh_workflow, + ) + self.addCleanup(collect_tab.close) + self.addCleanup(generate_tab.close) + self.addCleanup(apply_tab.close) + collect_tab.resize(1000, 600) + collect_tab.show() + QApplication.processEvents() + first_index = collect_tab.model.index(0, 0) + first_rect = collect_tab.table.visualRect(first_index) + self.assertTrue(first_rect.isValid()) + + class FakeMenu: + def __init__(self, parent=None): + self.actions = [] + + def addAction(self, text): + action = SimpleNamespace( + text=text, + enabled=True, + triggered=DummySignal(), + ) + action.setEnabled = lambda enabled: setattr(action, "enabled", bool(enabled)) + self.actions.append(action) + return action + + def exec(self, position): + return None + + with mock.patch("app.gui.tabs.collect.QMenu", FakeMenu): + collect_tab._show_task_context_menu(first_rect.center()) + self.assertEqual(0, collect_tab.table.currentIndex().row()) + + with mock.patch( + "app.gui.QMessageBox.question", + return_value=gui.QMessageBox.No, + ) as question: + collect_tab.delete_selected_task() + self.assertIsNotNone(db.get_task(first.id, path=cfg["db_path"])) + self.assertIn("已取消删除本条记录", statuses[-1]) + self.assertIn("不会回滚蝦皮线上商品", question.call_args[0][2]) + + with mock.patch( + "app.gui.QMessageBox.question", + return_value=gui.QMessageBox.Yes, + ), mock.patch("app.gui.QMessageBox.information") as information: + collect_tab.delete_selected_task() + + self.assertIsNone(db.get_task(first.id, path=cfg["db_path"])) + self.assertEqual([second.id], [task.id for task in db.list_tasks(batch_id=batch_id, path=cfg["db_path"])]) + self.assertTrue(all(task.id != first.id for task in generate_tab.model.tasks)) + self.assertTrue(all(task.id != first.id for task in apply_tab.model.tasks)) + self.assertEqual([True], refresh_calls) + self.assertIn("已删除本地记录:商品 51100639510", statuses[-1]) + self.assertIn("已删除本地记录:商品 51100639510", information.call_args[0][2]) + + collect_tab.collect_thread = object() + collect_tab.delete_selected_task() + self.assertIn("采集或回写正在进行", statuses[-1]) + + self.assert_removed(temp_dir) + def test_collect_tab_can_filter_unmatched_tasks_from_summary_bar(self): with self.make_temp_dir() as temp_dir: cfg = self.make_config(temp_dir)