diff --git a/app/gui/tabs/generate.py b/app/gui/tabs/generate.py index 001f755..225b350 100644 --- a/app/gui/tabs/generate.py +++ b/app/gui/tabs/generate.py @@ -73,18 +73,40 @@ class CoverGalleryDialog(QDialog): THUMBNAIL_SIZE = 180 OLD_THUMBNAIL_SIZE = 300 - def __init__(self, task, image_root, db_path, account=None, parent=None): + def __init__( + self, + task, + image_root, + db_path, + account=None, + parent=None, + config=None, + prompt_values=None, + diagnostic_log_dir=None, + bulk_running_checker=None, + regenerate_running_callback=None, + gallery_updated_callback=None, + ): super().__init__(parent) self.task = task self.image_root = image_root self.db_path = db_path self.account = account + self.config = config or {} + self.prompt_values = dict(prompt_values or {}) + self.diagnostic_log_dir = diagnostic_log_dir + self.bulk_running_checker = bulk_running_checker + self.regenerate_running_callback = regenerate_running_callback + self.gallery_updated_callback = gallery_updated_callback self.current_path = _normalize_file_path(getattr(task, "new_cover_path", None)) self.selected_path = None self.changed = False self.candidate_buttons = {} self.button_group = QButtonGroup(self) self.button_group.setExclusive(True) + self.regenerate_worker = None + self.regenerate_thread = None + self._regenerate_last_error = "" self.candidates = image_paths.list_task_cover_candidates(image_root, task, account=account) self.setWindowTitle(f"封面画廊:{getattr(task, 'item_id', '')}") @@ -95,6 +117,9 @@ class CoverGalleryDialog(QDialog): layout.addLayout(body_layout, 1) button_layout = QHBoxLayout() + self.regenerate_button = QPushButton("重新生成") + self.regenerate_button.clicked.connect(self.regenerate_cover) + button_layout.addWidget(self.regenerate_button) button_layout.addStretch(1) self.save_button = QPushButton("保存") self.cancel_button = QPushButton("取消") @@ -106,6 +131,7 @@ class CoverGalleryDialog(QDialog): self._sync_initial_selection() self._update_status() + self._update_regenerate_button_state() self._fit_to_screen() def _old_cover_panel(self): @@ -247,6 +273,67 @@ class CoverGalleryDialog(QDialog): self.accept() return True + def regenerate_cover(self, checked=False): + if self.regenerate_thread is not None: + return False + if self._bulk_generate_running(): + self.status_label.setText("②批量生成正在运行,完成后才能重新生成单个封面") + self._update_regenerate_button_state() + return False + if not str(getattr(self.task, "new_title", "") or "").strip(): + QMessageBox.warning(self, "重新生成封面", "当前任务缺少新标题,不能单独重新生成封面") + return False + if not self._confirm_regenerate(): + return False + try: + db.reset_generated( + self.task.id, + reset_title=False, + reset_cover=True, + path=self.db_path, + ) + fresh_task = db.get_task(self.task.id, path=self.db_path) + except Exception as exc: + QMessageBox.warning(self, "重新生成封面", str(exc)) + self.status_label.setText(f"重新生成封面失败:{exc}") + return False + if fresh_task is None: + QMessageBox.warning(self, "重新生成封面", "任务不存在或已删除") + self.status_label.setText("重新生成封面失败:任务不存在或已删除") + return False + self._reload_task_and_candidates(fresh_task) + self.status_label.setText(f"开始重新生成商品 {getattr(fresh_task, 'item_id', '')} 的封面图片") + worker = GenerateWorker( + [fresh_task], + self.prompt_values, + db_path=self.db_path, + config=_cover_regenerate_config(self.config), + diagnostic_log_dir=self.diagnostic_log_dir, + ) + worker.progress.connect(self._on_regenerate_progress) + worker.row_updated.connect(self._on_regenerate_row_updated) + worker.log.connect(self._on_regenerate_log) + worker.failed.connect(self._on_regenerate_failed) + worker.finished.connect(self._on_regenerate_finished) + worker.cancelled.connect(self._on_regenerate_cancelled) + thread = run_worker(worker, thread_name="CoverRegenerateWorker", start=False) + thread.finished.connect(lambda: self._forget_regenerate_thread(thread)) + self.regenerate_worker = worker + self.regenerate_thread = thread + self._set_regenerate_running(True) + thread.start() + return True + + def _confirm_regenerate(self): + box = QMessageBox(self) + box.setWindowTitle("重新生成封面") + box.setText("将重新生成当前商品封面,可能消耗 cmhub 点数。") + confirm_button = box.addButton("重新生成", QMessageBox.AcceptRole) + cancel_button = box.addButton("取消", QMessageBox.RejectRole) + box.setDefaultButton(cancel_button) + box.exec() + return box.clickedButton() is confirm_button + def _needs_committed_confirmation(self): return int(getattr(self.task, "committed", 0) or 0) == 1 or getattr(self.task, "stage", None) == "applied" @@ -266,6 +353,105 @@ class CoverGalleryDialog(QDialog): dialog = OriginalImageDialog(image_path, self) dialog.exec() + def _bulk_generate_running(self): + return bool(self.bulk_running_checker and self.bulk_running_checker()) + + def _set_regenerate_running(self, running): + self.save_button.setEnabled(not running and self.selected_path is not None) + self.regenerate_button.setEnabled(not running and not self._bulk_generate_running()) + self.regenerate_button.setToolTip( + "②批量生成正在运行,完成后才能重新生成单个封面" + if self._bulk_generate_running() + else ("正在重新生成当前商品封面" if running else "") + ) + if self.regenerate_running_callback is not None: + self.regenerate_running_callback(bool(running)) + + def _update_regenerate_button_state(self): + running = self.regenerate_thread is not None + self.regenerate_button.setEnabled(not running and not self._bulk_generate_running()) + self.regenerate_button.setToolTip( + "②批量生成正在运行,完成后才能重新生成单个封面" + if self._bulk_generate_running() + else ("正在重新生成当前商品封面" if running else "") + ) + + def _reload_task_and_candidates(self, task=None): + if task is not None: + self.task = task + self.current_path = _normalize_file_path(getattr(self.task, "new_cover_path", None)) + self.selected_path = None + self.candidates = image_paths.list_task_cover_candidates(self.image_root, self.task, account=self.account) + self._rebuild_candidate_items() + self._sync_initial_selection() + self._update_status() + + def _rebuild_candidate_items(self): + _clear_layout(self.candidate_layout) + self.candidate_buttons = {} + self.button_group = QButtonGroup(self) + self.button_group.setExclusive(True) + if self.candidates: + for index, candidate_path in enumerate(self.candidates): + self.candidate_layout.addWidget(self._candidate_item(candidate_path, index)) + self.candidate_layout.addStretch(1) + else: + empty_label = QLabel("暂无生成封面图片") + empty_label.setObjectName("coverGalleryEmptyLabel") + empty_label.setMinimumSize(320, self.THUMBNAIL_SIZE) + empty_label.setAlignment(Qt.AlignCenter) + self.candidate_layout.addWidget(empty_label) + + def _on_regenerate_progress(self, payload): + if payload.get("failed"): + self.status_label.setText("封面重新生成失败,历史候选仍可选择保存") + + def _on_regenerate_row_updated(self, task_id, fields): + if self.gallery_updated_callback is not None: + self.gallery_updated_callback(self.task) + + def _on_regenerate_log(self, message): + text = str(message or "").strip() + if not text: + return + if text.startswith("[失败]"): + self._regenerate_last_error = text + self.status_label.setText(text) + elif "成功" in text or "完成" in text: + self.status_label.setText(text) + + def _on_regenerate_failed(self, task_id, error): + self._regenerate_last_error = str(error or "未知错误") + self.status_label.setText(f"封面重新生成失败:{self._regenerate_last_error}") + + def _on_regenerate_finished(self, summary): + self._finish_regenerate(summary, cancelled=False) + + def _on_regenerate_cancelled(self, summary): + self._finish_regenerate(summary, cancelled=True) + + def _finish_regenerate(self, summary, cancelled=False): + fresh_task = db.get_task(self.task.id, path=self.db_path) + if fresh_task is not None: + self._reload_task_and_candidates(fresh_task) + self._set_regenerate_running(False) + if self.gallery_updated_callback is not None: + self.gallery_updated_callback(self.task) + if cancelled or summary.get("cancelled"): + self.status_label.setText("封面重新生成已停止,历史候选仍可选择保存") + return + if summary.get("ok") and summary.get("cover_done", 0) > 0: + self.status_label.setText("封面重新生成完成") + return + detail = self._regenerate_last_error or summary.get("error") or "请稍后重试" + self.status_label.setText(f"封面重新生成失败:{detail}") + + def _forget_regenerate_thread(self, thread): + if self.regenerate_thread is thread: + self.regenerate_thread = None + self.regenerate_worker = None + self._set_regenerate_running(False) + def _fit_to_screen(self): available = _available_geometry() if available is None: @@ -281,6 +467,23 @@ def _load_image(image_path): return QImage(str(image_path or "")) +def _clear_layout(layout): + while layout.count(): + item = layout.takeAt(0) + widget = item.widget() + if widget is not None: + widget.setParent(None) + widget.deleteLater() + + +def _cover_regenerate_config(config): + runtime_config = dict(config or {}) + ai_settings = appconfig.ai_config(runtime_config) + ai_settings["generate_cover"] = True + runtime_config["ai"] = ai_settings + return runtime_config + + def _build_image_label(image_path, size, empty_text): label = QLabel() label.setAlignment(Qt.AlignCenter) @@ -391,6 +594,8 @@ class GenerateTab(QWidget): self.current_cover_template = None self.generate_worker = None self.generate_thread = None + self.generate_running = False + self.cover_gallery_regenerate_running = False self._title_elapsed_start = None self._cover_elapsed_start = None self._title_elapsed_seconds = 0 @@ -948,6 +1153,9 @@ class GenerateTab(QWidget): self._set_status("封面提示词预览已生成") def start_generate(self, checked=False): + if self.cover_gallery_regenerate_running: + self._set_status("封面画廊正在重新生成,完成后再开始批量生成") + return if self.generate_thread is not None: self._set_status("AI 生成正在进行...") return @@ -1219,6 +1427,15 @@ class GenerateTab(QWidget): db_path=self.db_path, account=account, parent=self, + config=self.config, + prompt_values={ + "title": self.title_prompt_edit.toPlainText(), + "cover": self.cover_prompt_edit.toPlainText(), + }, + diagnostic_log_dir=diagnostics.DEFAULT_LOG_DIR, + bulk_running_checker=lambda: self.generate_thread is not None, + regenerate_running_callback=self._set_cover_gallery_regenerate_running, + gallery_updated_callback=self._on_cover_gallery_updated, ) if dialog.exec() == QDialog.Accepted: self.refresh_tasks() @@ -1228,9 +1445,10 @@ class GenerateTab(QWidget): self._set_status(f"当前新封面未变更:{task.item_id}") def _set_generate_running(self, running): - self.generate_button.setEnabled(not running) + self.generate_running = bool(running) + self.generate_button.setEnabled(not running and not self.cover_gallery_regenerate_running) self.stop_generate_button.setEnabled(running) - self.reset_generate_button.setEnabled(not running) + self.reset_generate_button.setEnabled(not running and not self.cover_gallery_regenerate_running) self.refresh_button.setEnabled(not running) self.batch_filter.setEnabled(not running) self.shop_filter.setEnabled(not running) @@ -1244,6 +1462,24 @@ class GenerateTab(QWidget): self.rename_cover_template_action.setEnabled(not running) self.delete_cover_template_action.setEnabled(not running) self.generate_cover_checkbox.setEnabled(not running) + self._sync_cover_gallery_regenerate_guard() + + def _set_cover_gallery_regenerate_running(self, running): + self.cover_gallery_regenerate_running = bool(running) + self._sync_cover_gallery_regenerate_guard() + + def _sync_cover_gallery_regenerate_guard(self): + if self.generate_thread is not None or self.generate_running: + return + blocked = bool(self.cover_gallery_regenerate_running) + self.generate_button.setEnabled(not blocked) + self.reset_generate_button.setEnabled(not blocked) + tooltip = "封面画廊正在重新生成,完成后再开始批量生成" if blocked else "" + self.generate_button.setToolTip(tooltip) + self.reset_generate_button.setToolTip(tooltip) + + def _on_cover_gallery_updated(self, task): + self.refresh_tasks() def _forget_generate_thread(self, thread): if self.generate_thread is thread: diff --git a/docs/tasks/T-568.md b/docs/tasks/T-568.md index 2fa8a19..25b9c33 100644 --- a/docs/tasks/T-568.md +++ b/docs/tasks/T-568.md @@ -3,7 +3,7 @@ id: T-568 title: ②封面画廊单任务重新生成与并发守卫 phase: 7 deps: [T-567] -status: TODO +status: DONE created: 2026-07-09 --- @@ -73,4 +73,19 @@ T-567 只提供候选封面查看与保存。运营在对比时仍需要对当 ## 执行记录 -(做完在这里写:改了什么文件、跑了什么验证命令及结果、遇到的阻塞、关键决策。) +- 2026-07-09 完成 T-568。 +- 修改 `app/gui/tabs/generate.py`: + - 在 T-567 封面画廊底部新增「重新生成」按钮;点击后先弹中文确认,说明会重新生成当前商品封面并可能消耗 cmhub 点数。 + - 确认后调用 `db.reset_generated(task_id, reset_title=False, reset_cover=True)`,由 T-566 归档当前新封面并清空封面指针;随后重新 `db.get_task()` 获取 fresh task,再启动单任务 `GenerateWorker([fresh_task])`,避免旧 task 残留 `new_cover_path` 导致跳过生图。 + - 单任务重生使用当前②页面传入的标题/封面提示词和配置,但只在 worker 配置副本中强制 `ai.generate_cover=True`,不保存到设置,确保只补缺失封面、不重生标题。 + - worker 完成后刷新②列表与画廊候选;成功时新 `_new.jpg` 默认选中;失败时保留已归档旧图并允许选择历史候选保存;归档改名失败时弹中文错误并不启动 worker。 + - 增加并发守卫:②批量生成运行中时画廊「重新生成」禁用并显示 tooltip;画廊单任务重生运行中时②主界面「开始生成」和「重置生成结果」禁用/阻断。 +- 修改 `tests/test_gui.py`: + - 用 fake `GenerateWorker`/thread 覆盖重新生成确认取消、fresh task 启动、临时强制 `generate_cover=True`、成功刷新并选中新图、失败后历史候选可保存、归档失败不启动 worker、批量生成与单任务重生互斥、主界面启动阻断。 +- 验证通过: + - `py -3.10 -m unittest tests.test_gui` + - `py -3.10 -m unittest tests.test_gui tests.test_db` + - `python -m ruff check app tests main.py` + - `py -3.10 -m compileall app main.py` + - `py -3.10 -m unittest discover -s tests`(297 tests;PySide6 字体目录警告不影响结果) + - `git diff --check` diff --git a/tests/test_gui.py b/tests/test_gui.py index 4068c92..99457cd 100644 --- a/tests/test_gui.py +++ b/tests/test_gui.py @@ -43,6 +43,45 @@ from app.gui.tabs.generate import CoverGalleryDialog, OriginalImageDialog from app.gui.main_window import _fit_and_center_window +class DummySignal: + def __init__(self): + self.callbacks = [] + + def connect(self, callback): + self.callbacks.append(callback) + + def emit(self, *args): + for callback in list(self.callbacks): + callback(*args) + + +class FakeThread: + def __init__(self): + self.finished = DummySignal() + self.started = False + + def start(self): + self.started = True + + +class FakeGenerateWorker: + instances = [] + + def __init__(self, tasks, prompt_values, db_path=None, config=None, diagnostic_log_dir=None): + 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.progress = DummySignal() + self.row_updated = DummySignal() + self.log = DummySignal() + self.failed = DummySignal() + self.finished = DummySignal() + self.cancelled = DummySignal() + FakeGenerateWorker.instances.append(self) + + class GuiTests(TempDirMixin, unittest.TestCase): @classmethod def setUpClass(cls): @@ -89,6 +128,34 @@ class GuiTests(TempDirMixin, unittest.TestCase): self.assertTrue(image.save(path)) return path + def _cover_gallery_task(self, temp_dir, generate_cover=True): + cfg = self.make_config(temp_dir) + cfg.setdefault("ai", {})["generate_cover"] = bool(generate_cover) + account = accounts.create_account("主店", "alias-a", debug_port=9222, config=cfg) + 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, + "account_name": "Excel主店", + "alias": "alias-a", + "item_id": "51100639510", + } + ], + path=cfg["db_path"], + ) + task = db.list_tasks(batch_id=batch_id, path=cfg["db_path"])[0] + old_cover = self.write_test_image(os.path.join(temp_dir, "old.jpg")) + canonical = image_paths.task_image_path(cfg["image_dir"], task, account, "new") + self.write_test_image(canonical) + db.set_collected(task.id, "旧标题", old_cover, path=cfg["db_path"]) + db.set_generated(task.id, "新标题", canonical, path=cfg["db_path"]) + return cfg, account, db.get_task(task.id, path=cfg["db_path"]), canonical + def assert_foreground(self, model, row, column, color): value = model.data(model.index(row, column), gui.Qt.ForegroundRole) self.assertIsNotNone(value) @@ -141,6 +208,13 @@ class GuiTests(TempDirMixin, unittest.TestCase): def clickedButton(self): return self.buttons[selected_label] + @staticmethod + def warning(parent, title, text): + box = FakeMessageBox(parent) + box.setWindowTitle(title) + box.setText(text) + return 0 + return FakeMessageBox, boxes def test_collect_table_shows_product_unavailable_only_for_explicit_error(self): @@ -2269,6 +2343,203 @@ class GuiTests(TempDirMixin, unittest.TestCase): self.assert_removed(temp_dir) + def test_cover_gallery_regenerate_cancel_has_no_side_effect(self): + with self.make_temp_dir() as temp_dir: + cfg, account, task, canonical = self._cover_gallery_task(temp_dir) + dialog = CoverGalleryDialog( + task, + cfg["image_dir"], + cfg["db_path"], + account=account, + config=cfg, + prompt_values={"title": "标题", "cover": "封面"}, + ) + self.addCleanup(dialog.close) + message_box, _ = self.make_fake_message_box("取消") + FakeGenerateWorker.instances.clear() + + with mock.patch("app.gui.tabs.generate.QMessageBox", message_box), \ + mock.patch("app.gui.tabs.generate.GenerateWorker", FakeGenerateWorker): + self.assertFalse(dialog.regenerate_cover()) + + self.assertEqual(canonical, db.get_task(task.id, path=cfg["db_path"]).new_cover_path) + self.assertTrue(os.path.exists(canonical)) + self.assertEqual([], FakeGenerateWorker.instances) + + self.assert_removed(temp_dir) + + def test_cover_gallery_regenerate_uses_fresh_task_and_forces_cover_generation(self): + with self.make_temp_dir() as temp_dir: + cfg, account, task, canonical = self._cover_gallery_task(temp_dir, generate_cover=False) + fake_thread = FakeThread() + running_states = [] + dialog = CoverGalleryDialog( + task, + cfg["image_dir"], + cfg["db_path"], + account=account, + config=cfg, + prompt_values={"title": "标题提示", "cover": "封面提示 {新标题}"}, + regenerate_running_callback=running_states.append, + ) + self.addCleanup(dialog.close) + message_box, _ = self.make_fake_message_box("重新生成") + FakeGenerateWorker.instances.clear() + + with mock.patch("app.gui.tabs.generate.QMessageBox", message_box), \ + mock.patch("app.gui.tabs.generate.GenerateWorker", FakeGenerateWorker), \ + mock.patch("app.gui.tabs.generate.run_worker", return_value=fake_thread): + self.assertTrue(dialog.regenerate_cover()) + + self.assertTrue(fake_thread.started) + self.assertEqual([True], running_states) + worker = FakeGenerateWorker.instances[0] + self.assertIsNone(worker.tasks[0].new_cover_path) + self.assertEqual("新标题", worker.tasks[0].new_title) + self.assertTrue(worker.config["ai"]["generate_cover"]) + self.assertFalse(appconfig.ai_config(cfg).get("generate_cover", False)) + self.assertFalse(os.path.exists(canonical)) + self.assertTrue(any(path.endswith(".jpg") for path in dialog.candidates)) + self.assertIn("开始重新生成商品", dialog.status_label.text()) + + self.assert_removed(temp_dir) + + def test_cover_gallery_regenerate_success_refreshes_candidates_and_current_selection(self): + with self.make_temp_dir() as temp_dir: + cfg, account, task, canonical = self._cover_gallery_task(temp_dir) + fake_thread = FakeThread() + refreshed = [] + dialog = CoverGalleryDialog( + task, + cfg["image_dir"], + cfg["db_path"], + account=account, + config=cfg, + prompt_values={"title": "标题", "cover": "封面"}, + regenerate_running_callback=lambda running: None, + gallery_updated_callback=lambda task: refreshed.append(task.id), + ) + self.addCleanup(dialog.close) + message_box, _ = self.make_fake_message_box("重新生成") + FakeGenerateWorker.instances.clear() + + with mock.patch("app.gui.tabs.generate.QMessageBox", message_box), \ + mock.patch("app.gui.tabs.generate.GenerateWorker", FakeGenerateWorker), \ + mock.patch("app.gui.tabs.generate.run_worker", return_value=fake_thread): + self.assertTrue(dialog.regenerate_cover()) + worker = FakeGenerateWorker.instances[0] + self.write_test_image(canonical) + db.set_generated(task.id, "新标题", canonical, path=cfg["db_path"]) + + worker.finished.emit({"ok": True, "cover_done": 1, "failed": 0}) + + self.assertEqual(canonical, db.get_task(task.id, path=cfg["db_path"]).new_cover_path) + self.assertEqual(canonical, dialog.selected_path) + self.assertTrue(dialog.candidate_buttons[canonical].isChecked()) + self.assertEqual("封面重新生成完成", dialog.status_label.text()) + self.assertIn(task.id, refreshed) + + self.assert_removed(temp_dir) + + def test_cover_gallery_regenerate_failure_keeps_archived_candidate_selectable(self): + with self.make_temp_dir() as temp_dir: + cfg, account, task, canonical = self._cover_gallery_task(temp_dir) + fake_thread = FakeThread() + dialog = CoverGalleryDialog( + task, + cfg["image_dir"], + cfg["db_path"], + account=account, + config=cfg, + prompt_values={"title": "标题", "cover": "封面"}, + ) + self.addCleanup(dialog.close) + message_box, _ = self.make_fake_message_box("重新生成") + FakeGenerateWorker.instances.clear() + + with mock.patch("app.gui.tabs.generate.QMessageBox", message_box), \ + mock.patch("app.gui.tabs.generate.GenerateWorker", FakeGenerateWorker), \ + mock.patch("app.gui.tabs.generate.run_worker", return_value=fake_thread): + self.assertTrue(dialog.regenerate_cover()) + worker = FakeGenerateWorker.instances[0] + worker.log.emit("[失败] 商品 51100639510 图片生成失败:测试失败") + worker.finished.emit({"ok": False, "cover_done": 0, "failed": 1}) + + self.assertFalse(os.path.exists(canonical)) + self.assertIsNone(db.get_task(task.id, path=cfg["db_path"]).new_cover_path) + self.assertTrue(dialog.candidates) + archived = dialog.candidates[0] + dialog.candidate_buttons[archived].setChecked(True) + self.assertTrue(dialog.save_selection()) + self.assertEqual(os.path.abspath(archived), db.get_task(task.id, path=cfg["db_path"]).new_cover_path) + + self.assert_removed(temp_dir) + + def test_cover_gallery_regenerate_archive_failure_does_not_start_worker(self): + with self.make_temp_dir() as temp_dir: + cfg, account, task, canonical = self._cover_gallery_task(temp_dir) + dialog = CoverGalleryDialog( + task, + cfg["image_dir"], + cfg["db_path"], + account=account, + config=cfg, + prompt_values={"title": "标题", "cover": "封面"}, + ) + self.addCleanup(dialog.close) + message_box, boxes = self.make_fake_message_box("重新生成") + FakeGenerateWorker.instances.clear() + + with mock.patch("app.gui.tabs.generate.QMessageBox", message_box), \ + mock.patch("app.db.os.rename", side_effect=PermissionError), \ + mock.patch("app.gui.tabs.generate.GenerateWorker", FakeGenerateWorker): + self.assertFalse(dialog.regenerate_cover()) + + self.assertTrue(os.path.exists(canonical)) + self.assertEqual(canonical, db.get_task(task.id, path=cfg["db_path"]).new_cover_path) + self.assertEqual([], FakeGenerateWorker.instances) + self.assertIn("请先关闭正在查看的封面图片再重置", boxes[-1].text) + + self.assert_removed(temp_dir) + + def test_cover_gallery_regenerate_disabled_while_bulk_generation_runs(self): + with self.make_temp_dir() as temp_dir: + cfg, account, task, canonical = self._cover_gallery_task(temp_dir) + dialog = CoverGalleryDialog( + task, + cfg["image_dir"], + cfg["db_path"], + account=account, + config=cfg, + prompt_values={"title": "标题", "cover": "封面"}, + bulk_running_checker=lambda: True, + ) + self.addCleanup(dialog.close) + + self.assertFalse(dialog.regenerate_button.isEnabled()) + self.assertIn("批量生成正在运行", dialog.regenerate_button.toolTip()) + self.assertFalse(dialog.regenerate_cover()) + self.assertEqual(canonical, db.get_task(task.id, path=cfg["db_path"]).new_cover_path) + + self.assert_removed(temp_dir) + + def test_generate_tab_blocks_batch_generate_while_gallery_regenerates(self): + with self.make_temp_dir() as temp_dir: + cfg = self.make_config(temp_dir) + statuses = [] + tab = GenerateTab(config=cfg, status_callback=statuses.append) + self.addCleanup(tab.close) + + tab._set_cover_gallery_regenerate_running(True) + tab.start_generate() + + self.assertFalse(tab.generate_button.isEnabled()) + self.assertIn("封面画廊正在重新生成", statuses[-1]) + tab._set_cover_gallery_regenerate_running(False) + self.assertTrue(tab.generate_button.isEnabled()) + + self.assert_removed(temp_dir) + def test_generate_tab_allows_editing_generated_title_locally(self): with self.make_temp_dir() as temp_dir: