feat(generate): confirm product status scope

This commit is contained in:
chengma
2026-07-18 16:57:57 +08:00
parent 4658a1ca61
commit 3a92119187
9 changed files with 569 additions and 19 deletions
+312 -5
View File
@@ -97,12 +97,21 @@ class FakeThread:
class FakeGenerateWorker:
instances = []
def __init__(self, tasks, prompt_values, db_path=None, config=None, diagnostic_log_dir=None):
def __init__(
self,
tasks,
prompt_values,
db_path=None,
config=None,
diagnostic_log_dir=None,
**kwargs,
):
self.tasks = list(tasks)
self.prompt_values = dict(prompt_values or {})
self.db_path = db_path
self.config = config
self.diagnostic_log_dir = diagnostic_log_dir
self.kwargs = dict(kwargs)
self.progress = DummySignal()
self.row_updated = DummySignal()
self.log = DummySignal()
@@ -6008,7 +6017,13 @@ class GuiTests(TempDirMixin, unittest.TestCase):
path=cfg["db_path"],
)
task = db.list_tasks(batch_id=batch_id, path=cfg["db_path"])[0]
db.set_collected(task.id, "旧标题", "old.jpg", path=cfg["db_path"])
db.set_collected(
task.id,
"旧标题",
"old.jpg",
product_status_value="normal",
path=cfg["db_path"],
)
tab = GenerateTab(config=cfg)
self.addCleanup(tab.close)
tab.run_log_view.setPlainText("上一轮失败日志")
@@ -6029,7 +6044,11 @@ class GuiTests(TempDirMixin, unittest.TestCase):
self.started = True
fake_thread = FakeThread()
with mock.patch("app.gui.run_worker", return_value=fake_thread):
with mock.patch.object(
tab,
"_choose_generation_scope",
return_value="normal_only",
), mock.patch("app.gui.run_worker", return_value=fake_thread):
tab.start_generate()
text = tab.run_log_view.toPlainText()
@@ -6063,7 +6082,13 @@ class GuiTests(TempDirMixin, unittest.TestCase):
path=cfg["db_path"],
)
task = db.list_tasks(batch_id=batch_id, path=cfg["db_path"])[0]
db.set_collected(task.id, "旧标题", "old.jpg", path=cfg["db_path"])
db.set_collected(
task.id,
"旧标题",
"old.jpg",
product_status_value="normal",
path=cfg["db_path"],
)
tab = GenerateTab(config=cfg)
self.addCleanup(tab.close)
tab.status_filter.setCurrentIndex(tab.status_filter.findData("to_generate"))
@@ -6085,7 +6110,11 @@ class GuiTests(TempDirMixin, unittest.TestCase):
self.started = True
fake_thread = FakeThread()
with mock.patch("app.gui.run_worker", return_value=fake_thread):
with mock.patch.object(
tab,
"_choose_generation_scope",
return_value="normal_only",
), mock.patch("app.gui.run_worker", return_value=fake_thread):
tab.start_generate()
self.assertTrue(fake_thread.started)
@@ -6097,6 +6126,284 @@ class GuiTests(TempDirMixin, unittest.TestCase):
self.assert_removed(temp_dir)
def test_generate_scope_dialog_uses_safe_default_and_red_all_status_choice(self):
with self.make_temp_dir() as temp_dir:
tab = GenerateTab(config=self.make_config(temp_dir))
self.addCleanup(tab.close)
plan = {
"base_candidates": [object(), object()],
"status_counts": {
"normal": 1,
"unlisted": 0,
"reviewing": 0,
"unknown": 1,
},
}
captured = {}
def click_all_statuses():
box = QApplication.activeModalWidget()
normal_button = box.findChild(QPushButton, "generateNormalOnlyButton")
all_button = box.findChild(QPushButton, "generateAllStatusesButton")
captured["default"] = box.defaultButton().objectName()
captured["all_style"] = all_button.styleSheet()
captured["all_enabled"] = all_button.isEnabled()
captured["detail"] = box.informativeText()
self.assertIsNotNone(normal_button)
self.assertIsNotNone(all_button)
all_button.click()
QTimer.singleShot(0, click_all_statuses)
scope = tab._choose_generation_scope(plan)
self.assertEqual("all", scope)
self.assertEqual("generateNormalOnlyButton", captured["default"])
self.assertTrue(captured["all_enabled"])
self.assertIn("#cf222e", captured["all_style"])
self.assertIn("状态未知1", captured["detail"])
self.assert_removed(temp_dir)
def test_generate_scope_dialog_disables_all_statuses_without_abnormal_tasks(self):
with self.make_temp_dir() as temp_dir:
tab = GenerateTab(config=self.make_config(temp_dir))
self.addCleanup(tab.close)
plan = {
"base_candidates": [object()],
"status_counts": {
"normal": 1,
"unlisted": 0,
"reviewing": 0,
"unknown": 0,
},
}
captured = {}
def click_normal_only():
box = QApplication.activeModalWidget()
normal_button = box.findChild(QPushButton, "generateNormalOnlyButton")
all_button = box.findChild(QPushButton, "generateAllStatusesButton")
captured["all_enabled"] = all_button.isEnabled()
normal_button.click()
QTimer.singleShot(0, click_normal_only)
scope = tab._choose_generation_scope(plan)
self.assertEqual("normal_only", scope)
self.assertFalse(captured["all_enabled"])
self.assert_removed(temp_dir)
def test_generate_tab_normal_scope_passes_only_normal_tasks_to_worker(self):
with self.make_temp_dir() as temp_dir:
cfg = self.make_config(temp_dir)
db.init_db(cfg["db_path"])
batch_id = db.create_batch(["input.xlsx"], path=cfg["db_path"])
rows = [
{
"source_file_abs": os.path.join(temp_dir, "input.xlsx"),
"source_sheet": "商品",
"source_row": index + 2,
"alias": "alias-a",
"item_id": str(51100639510 + index),
}
for index in range(4)
]
db.insert_tasks(batch_id, rows, path=cfg["db_path"])
statuses = ["normal", "unlisted", "reviewing", "unknown"]
for task, status in zip(
db.list_tasks(batch_id=batch_id, path=cfg["db_path"]),
statuses,
):
db.set_collected(
task.id,
"旧标题",
"old.jpg",
product_status_value=status,
path=cfg["db_path"],
)
tab = GenerateTab(config=cfg)
self.addCleanup(tab.close)
FakeGenerateWorker.instances.clear()
fake_thread = FakeThread()
with mock.patch.object(
tab,
"_choose_generation_scope",
return_value="normal_only",
), mock.patch(
"app.gui.tabs.generate.GenerateWorker",
FakeGenerateWorker,
), mock.patch("app.gui.tabs.generate.run_worker", return_value=fake_thread):
tab.start_generate()
self.assertTrue(fake_thread.started)
worker = FakeGenerateWorker.instances[-1]
self.assertEqual(["51100639510"], [task.item_id for task in worker.tasks])
self.assertEqual("normal_only", worker.kwargs["generation_scope"])
self.assertEqual(3, worker.kwargs["status_scope_excluded"])
self.assertEqual(
{"normal": 1, "unlisted": 1, "reviewing": 1, "unknown": 1},
worker.kwargs["product_status_counts"],
)
self.assert_removed(temp_dir)
def test_generate_tab_cancelled_scope_does_not_start_worker(self):
with self.make_temp_dir() as temp_dir:
cfg = self.make_config(temp_dir)
db.init_db(cfg["db_path"])
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,
"alias": "alias-a",
"item_id": "51100639510",
}
],
path=cfg["db_path"],
)
task = db.list_tasks(batch_id=batch_id, path=cfg["db_path"])[0]
db.set_collected(
task.id,
"旧标题",
"old.jpg",
product_status_value="normal",
path=cfg["db_path"],
)
states = []
tab = GenerateTab(config=cfg, status_callback=states.append)
self.addCleanup(tab.close)
with mock.patch.object(
tab,
"_choose_generation_scope",
return_value=None,
), mock.patch("app.gui.tabs.generate.GenerateWorker") as worker:
tab.start_generate()
worker.assert_not_called()
self.assertIsNone(tab.generate_worker)
self.assertIn("已取消 AI 生成", states[-1])
self.assert_removed(temp_dir)
def test_generate_tab_discards_plan_when_candidates_change_during_confirmation(self):
with self.make_temp_dir() as temp_dir:
states = []
tab = GenerateTab(
config=self.make_config(temp_dir),
status_callback=states.append,
)
self.addCleanup(tab.close)
before = SimpleNamespace(
id=1,
updated_at="2026-07-18T10:00:00",
product_status="normal",
new_title=None,
new_cover_path=None,
)
after = SimpleNamespace(
id=1,
updated_at="2026-07-18T10:01:00",
product_status="normal",
new_title=None,
new_cover_path=None,
)
with mock.patch.object(tab, "_generation_candidates", side_effect=[[before], [after]]), mock.patch.object(
tab,
"_choose_generation_scope",
return_value="normal_only",
), mock.patch("app.gui.tabs.generate.GenerateWorker") as worker:
tab.start_generate()
worker.assert_not_called()
self.assertIn("当前任务数据已变化", states[-1])
self.assert_removed(temp_dir)
def test_generate_worker_normal_scope_defensively_filters_non_normal_tasks(self):
with self.make_temp_dir() as temp_dir:
cfg = self.make_config(temp_dir)
cfg["ai"] = appconfig.ai_config(cfg)
cfg["ai"]["backend"] = "direct"
db.init_db(cfg["db_path"])
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": index + 2,
"alias": "alias-a",
"item_id": str(51100639510 + index),
}
for index in range(2)
],
path=cfg["db_path"],
)
tasks = db.list_tasks(batch_id=batch_id, path=cfg["db_path"])
db.set_collected(
tasks[0].id,
"旧标题",
"old.jpg",
product_status_value="normal",
path=cfg["db_path"],
)
db.set_collected(
tasks[1].id,
"旧标题",
"old.jpg",
product_status_value="unknown",
path=cfg["db_path"],
)
tasks = db.list_tasks(batch_id=batch_id, path=cfg["db_path"])
worker = GenerateWorker(
tasks,
{"title": "标题提示", "cover": "封面提示"},
db_path=cfg["db_path"],
config=cfg,
generation_scope="normal_only",
product_status_counts={"normal": 1, "unknown": 1},
status_scope_excluded=1,
generation_plan_fingerprint="frozen-plan",
)
submitted = []
def fake_generate_batch(tasks_arg, *_args, **_kwargs):
submitted.extend(tasks_arg)
return {
"ok": True,
"total": len(tasks_arg),
"title_total": len(tasks_arg),
"title_done": 0,
"cover_total": 0,
"cover_done": 0,
"generated_done": 0,
"failed": 0,
"cancelled": False,
"generate_cover": False,
"generate_mode": "title",
}
with mock.patch("app.gui.ai.generate_batch", side_effect=fake_generate_batch):
summary = worker.execute()
self.assertEqual([tasks[0].id], [task.id for task in submitted])
self.assertEqual("normal_only", summary["generation_scope"])
self.assertEqual(1, summary["status_scope_excluded"])
run_log = db.list_run_logs(limit=1, run_type="generate", path=cfg["db_path"])[0]
self.assertEqual("normal_only", run_log.options["generation_scope"])
self.assertEqual(1, run_log.options["status_scope_excluded"])
self.assert_removed(temp_dir)
def test_generate_tab_explains_collect_failed_records_are_not_generatable(self):
with self.make_temp_dir() as temp_dir:
cfg = self.make_config(temp_dir)
+54
View File
@@ -78,3 +78,57 @@ class ProductStatusTests(unittest.TestCase):
self.assertEqual(product_status.STATUS_REVIEWING, result["product_status"])
self.assertLessEqual(len(result["product_status_note"]), 2000)
self.assertTrue(result["product_status_note"].startswith("标题:审核中;说明:"))
def test_generation_plan_filters_unknown_by_default_and_freezes_snapshot(self):
normal = SimpleNamespace(
id=1,
updated_at="2026-07-18T10:00:00",
product_status="normal",
new_title=None,
new_cover_path=None,
)
unlisted = {
"id": 2,
"updated_at": "2026-07-18T10:00:01",
"product_status": "unlisted",
"new_title": "已有标题",
"new_cover_path": None,
}
unknown = SimpleNamespace(
id=3,
updated_at="2026-07-18T10:00:02",
product_status=None,
new_title=None,
new_cover_path=None,
)
normal_only = product_status.build_generation_plan(
[normal, unlisted, unknown, normal],
"title_cover",
)
all_statuses = product_status.build_generation_plan(
[normal, unlisted, unknown],
"title_cover",
product_status.SCOPE_ALL,
)
changed = product_status.build_generation_plan(
[
normal,
{
**unlisted,
"updated_at": "2026-07-18T10:01:00",
},
unknown,
],
"title_cover",
)
self.assertEqual([1, 2, 3], [task.id if hasattr(task, "id") else task["id"] for task in normal_only["base_candidates"]])
self.assertEqual([1], [task.id for task in normal_only["execution_tasks"]])
self.assertEqual(2, normal_only["scope_excluded"])
self.assertEqual(
{"normal": 1, "unlisted": 1, "reviewing": 0, "unknown": 1},
normal_only["status_counts"],
)
self.assertEqual([1, 2, 3], [task.id if hasattr(task, "id") else task["id"] for task in all_statuses["execution_tasks"]])
self.assertNotEqual(normal_only["fingerprint"], changed["fingerprint"])