feat(generate): confirm product status scope
This commit is contained in:
+312
-5
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user