feat(collect): support soft deletion of one task

This commit is contained in:
chengma
2026-07-20 10:27:21 +08:00
parent a92f1c86f4
commit 9852b1c9c1
8 changed files with 333 additions and 10 deletions
+49
View File
@@ -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:
+81
View File
@@ -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:
+4 -1
View File
@@ -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。
+3 -1
View File
@@ -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 不前进
+1
View File
@@ -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 放提示词,右侧放筛选 + 任务列表。
+10 -7
View File
@@ -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`。
+76 -1
View File
@@ -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")
+109
View File
@@ -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)