import json import unittest import os import sys import threading import time from types import SimpleNamespace from unittest import mock os.environ.setdefault("QT_QPA_PLATFORM", "offscreen") sys.path.insert(0, os.path.dirname(__file__)) from _helpers import TempDirMixin from app import gui from app import ( accounts, ai, appconfig, db, image_paths, image_studio, product_status, prompts, update_check, update_installer, ) if gui.QT_IMPORT_ERROR is not None: raise unittest.SkipTest("PySide6 未安装") from PySide6.QtCore import QItemSelectionModel, QModelIndex, QRect, QSize, QTimer, Qt from PySide6.QtGui import QImage, QKeyEvent, QTextCursor from PySide6.QtTest import QTest from PySide6.QtWidgets import ( QAbstractItemView, QApplication, QComboBox, QLabel, QLineEdit, QListView, QPlainTextEdit, QProgressBar, QPushButton, QTableView, ) from app.gui import ( AccountDialog, AccountLoginCheckWorker, AccountsTab, AIModelTestWorker, CMHubSettingsWorker, ApplyTab, ApplyWorker, CollectWorker, CollectTab, GenerateWorker, GenerateTab, ForcedUpdateDialog, ImageStudioPreviewDialog, ImageStudioTab, MainWindow, ProductSuitePreviewDialog, ProductSuiteTab, SettingsTab, TAB_STYLE, TAB_TITLES, WriteBackWorker, UpdatePreparationWorker, ) from app.gui import file_manager import app.gui.workers as gui_workers from app.gui.tabs.collect import _format_collect_elapsed 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, **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() self.failed = DummySignal() self.finished = DummySignal() self.cancelled = DummySignal() FakeGenerateWorker.instances.append(self) class FakeThumbnailLoader: def __init__(self): self.submissions = [] self.cancelled = [] self.closed = False def submit(self, key, url, on_success=None, on_error=None): self.submissions.append((key, url, on_success, on_error)) return None def cancel(self, key=None): self.cancelled.append(key) def close(self): self.closed = True def succeed(self, index, image_bytes): _, _, on_success, _ = self.submissions[index] on_success(SimpleNamespace(image_bytes=image_bytes)) def fail(self, index): key, _, _, on_error = self.submissions[index] on_error(key, RuntimeError("缩略图请求失败")) class GuiTests(TempDirMixin, unittest.TestCase): @classmethod def setUpClass(cls): cls.app = QApplication.instance() or QApplication([]) def tearDown(self): for widget in QApplication.topLevelWidgets(): widget.close() widget.deleteLater() self.app.processEvents() def make_config(self, temp_dir): return { "chrome_path": "chrome.exe", "user_data_root": os.path.join(temp_dir, "chrome_user_data_dir"), "image_dir": os.path.join(temp_dir, "images"), "db_path": os.path.join(temp_dir, "cmshopee.db"), "debug_port_range": [9222, 9260], "ai_models_path": os.path.join(temp_dir, "ai_models.json"), "config_path": os.path.join(temp_dir, "config.json"), } def allow_shopee_update( self, cfg, item_id="51100639510", allow_cover=True, max_items=1, close_success_tab=False, max_parallel_accounts=1, ): cfg["shopee_update"] = { "test_item_id": item_id, "update_mode": "title_cover" if allow_cover else "title", "max_items_per_run": max_items, "dry_run": False, "max_parallel_accounts": max_parallel_accounts, } return cfg def write_test_image(self, path, width=32, height=32): os.makedirs(os.path.dirname(path), exist_ok=True) image = QImage(width, height, QImage.Format_RGB32) image.fill(0xFF336699) self.assertTrue(image.save(path)) return path def assert_cover_candidate_cards_do_not_overlap(self, dialog, check_horizontal=True): previous_right = None maximum_card_height = 0 for candidate_path in dialog.candidates: card = dialog.candidate_items[candidate_path] layout = card.layout() thumbnail = layout.itemAt(0).widget() radio = layout.itemAt(1).widget() meta = layout.itemAt(2).widget() spacing = max(0, layout.spacing()) self.assertGreaterEqual( radio.geometry().top(), thumbnail.geometry().bottom() + 1 + spacing, ) self.assertGreaterEqual( meta.geometry().top(), radio.geometry().bottom() + 1 + spacing, ) self.assertGreaterEqual(card.minimumHeight(), layout.sizeHint().height()) self.assertGreaterEqual(card.height(), meta.geometry().bottom() + 1) if check_horizontal and previous_right is not None: self.assertGreaterEqual( card.geometry().left(), previous_right + 1 + dialog.candidate_layout.spacing(), ) previous_right = card.geometry().right() maximum_card_height = max(maximum_card_height, card.height()) self.assertGreaterEqual(dialog.candidate_content.minimumHeight(), maximum_card_height) self.assertGreaterEqual(dialog.candidate_content.height(), maximum_card_height) 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 _cover_gallery_task_set(self, temp_dir, count=3): cfg = self.make_config(temp_dir) cfg.setdefault("ai", {})["generate_cover"] = True 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"]) rows = [] for index in range(count): rows.append( { "source_file_abs": os.path.join(temp_dir, "input.xlsx"), "source_sheet": "商品", "source_row": index + 2, "account_name": "Excel主店", "alias": "alias-a", "item_id": f"5110063951{index}", } ) db.insert_tasks(batch_id, rows, path=cfg["db_path"]) tasks = db.list_tasks(batch_id=batch_id, path=cfg["db_path"]) canonicals = [] archives = [] for index, task in enumerate(tasks): old_cover = self.write_test_image(os.path.join(temp_dir, f"old-{index}.jpg")) canonical = image_paths.task_image_path(cfg["image_dir"], task, account, "new") archive = os.path.splitext(canonical)[0] + "_20260709101000.jpg" self.write_test_image(canonical) self.write_test_image(archive) db.set_collected(task.id, f"旧标题{index}", old_cover, path=cfg["db_path"]) db.set_generated(task.id, f"新标题{index}", canonical, path=cfg["db_path"]) canonicals.append(canonical) archives.append(archive) return cfg, account, db.list_tasks(batch_id=batch_id, path=cfg["db_path"]), canonicals, archives def assert_foreground(self, model, row, column, color): value = model.data(model.index(row, column), gui.Qt.ForegroundRole) self.assertIsNotNone(value) self.assertEqual(color, value.name()) def set_batch_created_at(self, cfg, batch_id, created_at): conn = db.connect(cfg["db_path"]) try: with conn: conn.execute( "UPDATE batches SET created_at = ?, updated_at = ? WHERE id = ?", (created_at, created_at, batch_id), ) finally: conn.close() def make_fake_message_box(self, selected_label): boxes = [] class FakeMessageBox: AcceptRole = object() DestructiveRole = object() RejectRole = object() def __init__(self, parent=None): self.parent = parent self.title = "" self.text = "" self.buttons = {} self.default_button = None boxes.append(self) def setWindowTitle(self, title): self.title = title def setText(self, text): self.text = text def addButton(self, label, role): button = object() self.buttons[label] = button return button def setDefaultButton(self, button): self.default_button = button def exec(self): return 0 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 make_sequence_message_box(self, selected_labels): boxes = [] labels = list(selected_labels) class FakeMessageBox: AcceptRole = object() DestructiveRole = object() RejectRole = object() def __init__(self, parent=None): self.parent = parent self.title = "" self.text = "" self.buttons = {} self.default_button = None self.selected_label = labels.pop(0) boxes.append(self) def setWindowTitle(self, title): self.title = title def setText(self, text): self.text = text def addButton(self, label, role): button = object() self.buttons[label] = button return button def setDefaultButton(self, button): self.default_button = button def exec(self): return 0 def clickedButton(self): return self.buttons[self.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): account = SimpleNamespace(alias="papa", account_name="papa 店铺") invalid_task = SimpleNamespace( alias="papa", account_name="papa 店铺", item_id="25120403046", stage="imported", status="failed", last_error="商品失效:please input correct product id", ) generic_task = SimpleNamespace( alias="papa", account_name="papa 店铺", item_id="26887160467", stage="imported", status="failed", last_error="等待蝦皮商品编辑器就绪超时", ) model = gui.TaskTableModel() model.set_tasks([invalid_task, generic_task], [account]) self.assertEqual("商品失效", model.data(model.index(0, 3), gui.Qt.DisplayRole)) self.assertEqual("失败", model.data(model.index(1, 3), gui.Qt.DisplayRole)) self.assertIn( "please input correct product id", model.data(model.index(0, 3), gui.Qt.ToolTipRole), ) def test_collect_task_model_displays_product_statuses_and_snapshot_note(self): account = SimpleNamespace(alias="papa", account_name="papa 店铺") tasks = [ SimpleNamespace( alias="papa", account_name="papa 店铺", item_id="1001", stage="imported", status="pending", last_error="", product_status="normal", product_status_note="历史批次默认按架上商品处理(未实时检测)", product_status_at=None, ), SimpleNamespace( alias="papa", account_name="papa 店铺", item_id="1002", stage="imported", status="pending", last_error="", product_status="unlisted", product_status_note="商品未上架", product_status_at="2026-07-20T09:00:00", ), SimpleNamespace( alias="papa", account_name="papa 店铺", item_id="1003", stage="imported", status="pending", last_error="", product_status="reviewing", product_status_note="商品审核中", product_status_at=None, ), SimpleNamespace( alias="papa", account_name="papa 店铺", item_id="1004", stage="imported", status="pending", last_error="", product_status="unknown", product_status_note="", product_status_at=None, ), SimpleNamespace( alias="papa", account_name="papa 店铺", item_id="1005", stage="imported", status="pending", last_error="", product_status=None, product_status_note=None, product_status_at=None, ), ] model = gui.TaskTableModel() model.set_tasks(tasks, [account]) self.assertEqual(["账号", "别名", "商品ID", "阶段", "商品状态"], model.HEADERS) self.assertEqual( ["架上商品", "未上架", "审核中", "状态未知", "待检测"], [model.index(row, 4).data() for row in range(model.rowCount())], ) self.assert_foreground(model, 0, 4, gui.COLOR_SUCCESS) self.assert_foreground(model, 1, 4, gui.COLOR_DANGER) self.assert_foreground(model, 2, 4, gui.COLOR_WARNING) self.assert_foreground(model, 3, 4, gui.COLOR_MUTED) self.assert_foreground(model, 4, 4, gui.COLOR_MUTED) self.assertIn("未实时检测", model.index(0, 4).data(Qt.ToolTipRole)) self.assertIn("检测时间", model.index(1, 4).data(Qt.ToolTipRole)) self.assertEqual("尚未检测商品状态", model.index(4, 4).data(Qt.ToolTipRole)) def test_workflow_failed_tooltips_show_failed_step_and_reason(self): account = SimpleNamespace(alias="papa", account_name="papa 店铺") collect_task = SimpleNamespace( alias="papa", account_name="papa 店铺", item_id="25120403046", stage="imported", status="failed", last_error="读封面失败:图片超过大小上限", ) generate_task = SimpleNamespace( alias="papa", account_name="papa 店铺", item_id="26887160467", old_title="旧标题", new_title="", stage="collected", status="failed", last_error="请求生成标题失败:cmhub 上游生成失败", collect_attempts=1, generate_attempts=1, apply_attempts=0, committed=0, ) apply_task = SimpleNamespace( alias="papa", account_name="papa 店铺", item_id="28431952912", new_title="新标题", new_cover_path="new.jpg", stage="generated", status="failed", last_error="点击更新失败:按钮禁用", ) collect_model = gui.TaskTableModel() collect_model.set_tasks([collect_task], [account]) self.assertEqual( "读封面失败:图片超过大小上限", collect_model.data(collect_model.index(0, 3), gui.Qt.ToolTipRole), ) generate_model = gui.GenerateTaskTableModel() generate_model.set_tasks([generate_task], [account]) self.assertEqual( "请求生成标题失败:cmhub 上游生成失败", generate_model.data(generate_model.index(0, 5), gui.Qt.ToolTipRole), ) apply_model = gui.ApplyTaskTableModel() apply_model.set_tasks([apply_task], [account]) self.assertEqual( "点击更新失败:按钮禁用", apply_model.data(apply_model.index(0, 5), gui.Qt.ToolTipRole), ) def test_generate_table_marks_cover_reset_history_on_item_id_column(self): account = SimpleNamespace(alias="papa", account_name="papa 店铺") reset_task = SimpleNamespace( alias="papa", account_name="papa 店铺", item_id="26887160467", old_title="旧标题", new_title="新标题", new_cover_path="new.jpg", stage="generated", status="success", last_error="", collect_attempts=1, generate_attempts=1, apply_attempts=0, committed=0, cover_reset_count=2, cover_reset_at="2026-07-11T10:00:00", ) normal_task = SimpleNamespace( alias="papa", account_name="papa 店铺", item_id="28431952912", old_title="旧标题", new_title="新标题", new_cover_path="new.jpg", stage="generated", status="success", last_error="", collect_attempts=1, generate_attempts=1, apply_attempts=0, committed=0, cover_reset_count=0, cover_reset_at=None, ) model = gui.GenerateTaskTableModel() model.set_tasks([reset_task, normal_task], [account]) self.assert_foreground(model, 0, 1, gui.COLOR_WARNING) self.assertIsNone(model.data(model.index(1, 1), gui.Qt.ForegroundRole)) self.assertIn( "该商品封面已重置 2 次", model.data(model.index(0, 1), gui.Qt.ToolTipRole), ) self.assertIn( "双击可查看封面画廊", model.data(model.index(0, 1), gui.Qt.ToolTipRole), ) self.assert_foreground(model, 0, 4, gui.COLOR_SUCCESS) self.assert_foreground(model, 0, 5, gui.COLOR_SUCCESS) def test_main_window_has_workflow_tabs_in_order(self): with self.make_temp_dir() as temp_dir: window = MainWindow(config=self.make_config(temp_dir)) self.addCleanup(window.close) self.assertEqual(gui.display_name(), window.windowTitle()) self.assertEqual(6, window.tabs.count()) self.assertEqual( [ "① 导入采集", "② AI生成", "③ 更新蝦皮", "账号管理", "设置", "商品套图", ], TAB_TITLES, ) self.assertEqual( TAB_TITLES, [window.tabs.tabText(index) for index in range(window.tabs.count())], ) self.assertNotIn("AI工场", TAB_TITLES) self.assertEqual("就绪", window.statusBar().currentMessage()) self.assertEqual(gui.BUTTON_BASE_STYLE, window.styleSheet()) self.assertEqual(TAB_STYLE, window.tabs.styleSheet()) self.assertTrue(window.tabs.tabIcon(TAB_TITLES.index("③ 更新蝦皮")).isNull()) self.assertIn("QPushButton", window.styleSheet()) self.assertIn("border-radius: 4px", window.styleSheet()) self.assertIn("min-width: 128px", window.tabs.styleSheet()) self.assertIn("padding: 8px 18px", window.tabs.styleSheet()) self.assertIn("margin-right: 8px", window.tabs.styleSheet()) self.assertIsInstance(window.tabs.widget(0), CollectTab) self.assertIsInstance(window.tabs.widget(1), GenerateTab) self.assertIsInstance(window.tabs.widget(2), ApplyTab) self.assertIsInstance(window.tabs.widget(3), AccountsTab) self.assertIsInstance(window.tabs.widget(4), SettingsTab) self.assertIsInstance(window.tabs.widget(5), ProductSuiteTab) self.assertFalse( any( isinstance(window.tabs.widget(index), ImageStudioTab) for index in range(window.tabs.count()) ) ) self.assertEqual( "回写旧数据到 Excel", window.tabs.widget(0).write_back_button.text(), ) self.assertIn(gui.COLOR_MUTED, window.statusBar().styleSheet()) self.assert_removed(temp_dir) def test_main_window_status_bar_uses_semantic_colors_and_resets(self): with self.make_temp_dir() as temp_dir: window = MainWindow(config=self.make_config(temp_dir)) self.addCleanup(window.close) window.show_status("更新失败", level="danger") self.assertEqual("更新失败", window.statusBar().currentMessage()) self.assertIn(gui.COLOR_DANGER, window.statusBar().styleSheet()) window.show_status("设置已保存", level="success") self.assertIn(gui.COLOR_SUCCESS, window.statusBar().styleSheet()) window.show_status("就绪", level="muted") self.assertEqual("就绪", window.statusBar().currentMessage()) self.assertIn(gui.COLOR_MUTED, window.statusBar().styleSheet()) self.assertNotIn(gui.COLOR_DANGER, window.statusBar().styleSheet()) self.assert_removed(temp_dir) def test_image_studio_tab_builds_project_pool_and_template_controls(self): with self.make_temp_dir() as temp_dir: cfg = self.make_config(temp_dir) prompts_dir = os.path.join(temp_dir, "prompts", "image_studio") account = accounts.create_account("主店", "alias-a", debug_port=9222, config=cfg) prompts.save_image_studio_template("工场模板", "完整提示词", prompts_dir) thumbnail_loader = FakeThumbnailLoader() tab = ImageStudioTab( config=cfg, db_path=cfg["db_path"], prompts_dir=prompts_dir, thumbnail_loader=thumbnail_loader, ) self.addCleanup(tab.close) self.assertEqual("imageStudioTab", tab.objectName()) self.assertNotIn("AI工场", TAB_TITLES) self.assertGreaterEqual(tab.account_combo.count(), 1) self.assertEqual("alias-a", tab.account_combo.itemData(0)) self.assertNotIn( "导入本地图片", " ".join(button.text() for button in tab.findChildren(gui.QPushButton)), ) self.assertNotIn( "打开商品项目", " ".join(button.text() for button in tab.findChildren(gui.QPushButton)), ) self.assertEqual("拉取蝦皮主图", tab.pull_images_button.text()) self.assertEqual("删除项目", tab.delete_project_button.text()) self.assertEqual("导出到文件夹", tab.export_button.text()) self.assertIn("可部分导出", tab.export_hint_label.text()) self.assertIn("文件名按终选顺序连续", tab.export_hint_label.text()) self.assertEqual("未选择商品", tab.current_project_label.text()) self.assertIn("拉取蝦皮主图", tab.workspace_empty_label.text()) self.assertFalse(tab.open_folder_button.isEnabled()) self.assertFalse(tab.delete_project_button.isEnabled()) self.assertEqual("生成 4 张主图", tab.start_button.text()) tab.count_spin.setValue(3) tab.job_type_combo.setCurrentIndex(tab.job_type_combo.findData("detail")) self.assertEqual("生成 3 张详情图", tab.start_button.text()) label_texts = "\n".join(label.text() for label in tab.findChildren(gui.QLabel)) self.assertIn("商品列表", label_texts) self.assertIn("蝦皮原主图", label_texts) self.assertIn("照片池", label_texts) self.assertIn("生成设置", label_texts) self.assertIn("终选与排序", label_texts) self.assertEqual("完整提示词", prompts.load_image_studio_template("工场模板", prompts_dir)) template_index = tab.template_combo.findData("工场模板") self.assertGreaterEqual(template_index, 0) tab.template_combo.setCurrentIndex(template_index) tab.load_selected_template() self.assertEqual("完整提示词", tab.prompt_edit.toPlainText()) tab.resize(1280, 820) tab.show() self.app.processEvents() self.assertLessEqual( max( button.y() for button in ( tab.template_new_button, tab.template_rename_button, tab.template_save_button, tab.template_delete_button, ) ) - min( button.y() for button in ( tab.template_new_button, tab.template_rename_button, tab.template_save_button, tab.template_delete_button, ) ), 1, ) self.assertLessEqual( max(tab.job_type_combo.y(), tab.count_spin.y(), tab.aspect_combo.y()) - min(tab.job_type_combo.y(), tab.count_spin.y(), tab.aspect_combo.y()), 1, ) self.assertNotIn("当前源图\n单击照片池图片设为源图,双击查看大图", label_texts) project = image_studio.create_or_get_project( account, item_id="51100639510", path=cfg["db_path"], ) tab.refresh_projects() self.assertIsNotNone(tab.current_project) self.assertEqual("alias-a", tab.current_project.account_alias) self.assertEqual("51100639510", tab.current_project.item_id) self.assertEqual(project.id, tab.current_project.id) self.assertIn("主店", tab.current_project_label.text()) self.assertIn("51100639510", tab.current_project_label.text()) self.assertEqual("项目已自动保存", tab.autosave_label.text()) self.assertEqual(1, tab.project_table.rowCount()) self.assertEqual(["店铺", "商品ID", "更新时间"], [ tab.project_table.horizontalHeaderItem(column).text() for column in range(tab.project_table.columnCount()) ]) self.assertTrue(tab.open_folder_button.isEnabled()) self.assertTrue(tab.delete_project_button.isEnabled()) original = image_studio.sync_original_asset_urls( tab.current_project.id, [{"index": 1, "src": "https://susercontent.com/main-1.jpg"}], path=cfg["db_path"], )[0] tab.refresh_project_assets() self.assertEqual(1, tab.original_grid.count()) self.assertIn("主图 1", tab.original_grid.item(0).text()) self.assertIn("加载中", tab.original_grid.item(0).text()) self.assertFalse(tab.original_grid.item(0).icon().isNull()) self.assertEqual(1, len(thumbnail_loader.submissions)) self.assertEqual(0, tab.pool_grid.count()) thumbnail_loader.fail(0) self.app.processEvents() self.assertIn("加载失败", tab.original_grid.item(0).text()) self.assertIn("右键重新加载", tab.original_grid.item(0).toolTip()) tab._retry_original_thumbnail(original) self.assertEqual(2, len(thumbnail_loader.submissions)) self.assertIn("加载中", tab.original_grid.item(0).text()) thumbnail_path = self.write_test_image(os.path.join(temp_dir, "thumbnail.png")) with open(thumbnail_path, "rb") as image_file: thumbnail_loader.succeed(1, image_file.read()) self.app.processEvents() self.assertIn("可预览", tab.original_grid.item(0).text()) self.assertIsNone(image_studio.get_asset(original.id, path=cfg["db_path"]).local_path) local_original_path = self.write_test_image(os.path.join(temp_dir, "original.png")) image_studio.update_asset_local_path(original.id, local_original_path, path=cfg["db_path"]) tab.refresh_project_assets() self.assertEqual(1, tab.pool_grid.count()) pool_item = tab.pool_grid.item(0) self.assertIn("原图", pool_item.text()) self.assertEqual(original.id, pool_item.data(gui.Qt.UserRole)["asset_id"]) self.assert_removed(temp_dir) def test_image_studio_thumbnail_grids_keep_order_and_hide_remote_pool_duplicates(self): with self.make_temp_dir() as temp_dir: cfg = self.make_config(temp_dir) db.init_db(cfg["db_path"]) project = image_studio.create_or_get_project( account_alias="alias-a", account_slug="alias_a", item_id="51100639510", path=cfg["db_path"], ) image_studio.sync_original_asset_urls( project.id, [ {"index": index, "src": f"https://susercontent.com/main-{index}.jpg"} for index in range(1, 10) ], path=cfg["db_path"], ) thumbnail_loader = FakeThumbnailLoader() tab = ImageStudioTab( config=cfg, db_path=cfg["db_path"], thumbnail_loader=thumbnail_loader, ) self.addCleanup(tab.close) tab._select_project(project.id) self.assertEqual(9, tab.original_grid.count()) self.assertEqual( [f"主图 {index}" for index in range(1, 10)], [tab.original_grid.item(row).text().split("\n", 1)[0] for row in range(9)], ) self.assertEqual(9, len(thumbnail_loader.submissions)) self.assertEqual(0, tab.pool_grid.count()) self.assertEqual(QSize(58, 78), tab.original_grid.gridSize()) self.assertEqual(94, tab.original_grid.minimumHeight()) self.assertEqual(110, tab.original_grid.maximumHeight()) self.assertEqual(150, tab.project_table.minimumWidth()) self.assertEqual(QSize(108, 124), tab.pool_grid.gridSize()) self.assertGreaterEqual(tab.prompt_edit.minimumHeight(), 210) self.assert_removed(temp_dir) def test_image_studio_deletes_selected_project_only_after_safe_confirmation(self): with self.make_temp_dir() as temp_dir: cfg = self.make_config(temp_dir) db.init_db(cfg["db_path"]) first = image_studio.create_or_get_project( account_alias="alias-a", account_name="主店", account_slug="alias_a", item_id="51100639510", path=cfg["db_path"], ) second = image_studio.create_or_get_project( account_alias="alias-a", account_name="主店", account_slug="alias_a", item_id="51100639511", path=cfg["db_path"], ) tab = ImageStudioTab(config=cfg, db_path=cfg["db_path"]) self.addCleanup(tab.close) tab._select_project(first.id) confirmations = [] tab._confirm = lambda title, text: confirmations.append((title, text)) or False tab.delete_current_project() self.assertIsNotNone(image_studio.get_project(first.id, path=cfg["db_path"])) self.assertEqual(first.id, tab.current_project.id) self.assertIn("不会删除蝦皮商品", confirmations[0][1]) self.assertIn("不会删除本地图片", confirmations[0][1]) tab._confirm = lambda title, text: True tab.delete_current_project() self.assertIsNone(image_studio.get_project(first.id, path=cfg["db_path"])) deleted = image_studio.get_project(first.id, path=cfg["db_path"], include_deleted=True) self.assertIsNotNone(deleted) self.assertEqual("用户从AI工场商品列表删除", deleted.deleted_reason) self.assertIsNotNone(tab.current_project) self.assertEqual(second.id, tab.current_project.id) self.assertEqual(1, tab.project_table.rowCount()) self.assert_removed(temp_dir) def test_image_studio_blocks_deletion_with_active_remote_job(self): with self.make_temp_dir() as temp_dir: cfg = self.make_config(temp_dir) db.init_db(cfg["db_path"]) project = image_studio.create_or_get_project( account_alias="alias-a", account_name="主店", account_slug="alias_a", item_id="51100639510", path=cfg["db_path"], ) image_studio.create_job(project.id, path=cfg["db_path"]) tab = ImageStudioTab(config=cfg, db_path=cfg["db_path"]) self.addCleanup(tab.close) tab._select_project(project.id) messages = [] tab._message = lambda title, text: messages.append((title, text)) tab._confirm = lambda title, text: self.fail("有未完成任务时不应出现删除确认") tab.delete_current_project() self.assertIsNotNone(image_studio.get_project(project.id, path=cfg["db_path"])) self.assertEqual("暂不能删除项目", messages[0][0]) self.assertIn("未完成的图片任务", messages[0][1]) self.assert_removed(temp_dir) def test_image_studio_final_selection_order_and_guards(self): with self.make_temp_dir() as temp_dir: cfg = self.make_config(temp_dir) db.init_db(cfg["db_path"]) project = image_studio.create_or_get_project( account_alias="alias-a", account_slug="alias_a", item_id="51100639510", path=cfg["db_path"], ) first_path = self.write_test_image(os.path.join(temp_dir, "first.jpg")) second_path = self.write_test_image(os.path.join(temp_dir, "second.jpg")) first = image_studio.add_asset( project.id, "generated_main", local_path=first_path, aspect_ratio="1:1", path=cfg["db_path"], ) second = image_studio.add_asset( project.id, "generated_main", local_path=second_path, aspect_ratio="3:4", path=cfg["db_path"], ) tab = ImageStudioTab(config=cfg, db_path=cfg["db_path"]) self.addCleanup(tab.close) messages = [] tab._message = lambda title, text: messages.append((title, text)) tab._select_project(project.id) self.assertTrue(tab.add_asset_to_selection("main", first.id)) self.assertTrue(tab.add_asset_to_selection("main", second.id, insert_index=0)) main = image_studio.list_selections(project.id, "main", path=cfg["db_path"]) self.assertEqual([second.id, first.id], [selection.asset_id for selection in main]) self.assertEqual([second.id, first.id], [ tab.main_selection_list.item(row).data(gui.Qt.UserRole) for row in range(tab.main_selection_list.count()) if tab.main_selection_list.item(row).data(gui.Qt.UserRole) is not None ]) self.assertEqual(9, tab.main_selection_list.count()) self.assertIn("空位", tab.main_selection_list.item(2).toolTip()) self.assertEqual("#fff8c5", tab.main_selection_list.item(0).background().color().name()) self.assertFalse(tab.add_asset_to_selection("main", first.id)) self.assertIn("不能重复加入", messages[-1][0]) self.assertTrue(tab.add_asset_to_selection("detail", first.id)) detail = image_studio.list_selections(project.id, "detail", path=cfg["db_path"]) self.assertEqual([first.id], [selection.asset_id for selection in detail]) self.assertEqual(12, tab.detail_selection_list.count()) self.assertIn("空位", tab.detail_selection_list.item(1).toolTip()) self.assertTrue(tab.move_selection_asset("main", 1, 0)) main = image_studio.list_selections(project.id, "main", path=cfg["db_path"]) self.assertEqual([first.id, second.id], [selection.asset_id for selection in main]) self.assertTrue(tab.remove_asset_from_selection("main", first.id)) main = image_studio.list_selections(project.id, "main", path=cfg["db_path"]) self.assertEqual([second.id], [selection.asset_id for selection in main]) self.assert_removed(temp_dir) def test_image_studio_final_selection_uses_compact_single_row_rails(self): with self.make_temp_dir() as temp_dir: cfg = self.make_config(temp_dir) db.init_db(cfg["db_path"]) project = image_studio.create_or_get_project( account_alias="alias-a", account_slug="alias_a", item_id="51100639510", path=cfg["db_path"], ) tab = ImageStudioTab(config=cfg, db_path=cfg["db_path"]) self.addCleanup(tab.close) tab._select_project(project.id) tab.resize(1280, 820) tab.show() self.app.processEvents() self.assertGreaterEqual(tab.final_panel.minimumHeight(), 150) self.assertLessEqual(tab.final_panel.maximumHeight(), 170) self.assertLess(tab.main_selection_list.y(), tab.detail_selection_list.y()) self.assertEqual(QListView.IconMode, tab.main_selection_list.viewMode()) self.assertEqual(QListView.IconMode, tab.detail_selection_list.viewMode()) self.assertEqual(QListView.LeftToRight, tab.main_selection_list.flow()) self.assertEqual(QListView.LeftToRight, tab.detail_selection_list.flow()) self.assertFalse(tab.main_selection_list.isWrapping()) self.assertFalse(tab.detail_selection_list.isWrapping()) self.assertEqual(QSize(60, 62), tab.main_selection_list.gridSize()) self.assertEqual(QSize(50, 52), tab.detail_selection_list.gridSize()) self.assertEqual(9, tab.main_selection_list.count()) self.assertEqual(12, tab.detail_selection_list.count()) self.assertEqual( QAbstractItemView.ScrollPerPixel, tab.main_selection_list.horizontalScrollMode(), ) self.assertEqual(Qt.ScrollBarAlwaysOff, tab.detail_selection_list.verticalScrollBarPolicy()) self.assertEqual(Qt.ScrollBarAsNeeded, tab.detail_selection_list.horizontalScrollBarPolicy()) self.assertTrue(tab.export_button.isEnabled()) self.assertIn("当前未选择终选图片", tab.export_summary_label.text()) tab.detail_selection_list.setFixedWidth(240) self.app.processEvents() self.assertGreater(tab.detail_selection_list.horizontalScrollBar().maximum(), 0) tab._set_running(True) self.assertFalse(tab.main_selection_list.isEnabled()) self.assertFalse(tab.detail_selection_list.isEnabled()) self.assertFalse(tab.export_button.isEnabled()) tab._set_running(False) self.assert_removed(temp_dir) def test_image_studio_tab_shows_resume_and_job_billing_status(self): with self.make_temp_dir() as temp_dir: cfg = self.make_config(temp_dir) db.init_db(cfg["db_path"]) project = image_studio.create_or_get_project( account_alias="alias-a", account_slug="alias_a", item_id="51100639510", path=cfg["db_path"], ) source = image_studio.add_asset( project.id, "original", local_path=self.write_test_image(os.path.join(temp_dir, "source.jpg")), path=cfg["db_path"], ) job = image_studio.create_job( project.id, source_asset_id=source.id, path=cfg["db_path"], ) image_studio.set_job_submitted( job.id, "cmhub-task-1", call_id="call-1", points_cost=2, points_balance=88, path=cfg["db_path"], ) tab = ImageStudioTab(config=cfg, db_path=cfg["db_path"]) self.addCleanup(tab.close) tab._select_project(project.id) self.assertEqual("继续查询任务", tab.resume_button.text()) self.assertEqual(1, tab.pool_grid.count()) self.assertTrue( all( tab.pool_grid.item(row).data(gui.Qt.UserRole)["type"] == "asset" for row in range(tab.pool_grid.count()) ) ) self.assertFalse(tab.job_section.isHidden()) statuses = [ tab.job_grid.item(row).text() for row in range(tab.job_grid.count()) ] self.assertEqual(1, len(statuses)) self.assertIn("已提交", statuses[0]) self.assertIn("可继续查询", statuses[0]) self.assertIn("扣点2", statuses[0]) self.assertIn("余额88", statuses[0]) self.assertIn("call_id=call-1", statuses[0]) self.assert_removed(temp_dir) def test_image_studio_tab_separates_usable_assets_from_job_states(self): with self.make_temp_dir() as temp_dir: cfg = self.make_config(temp_dir) db.init_db(cfg["db_path"]) project = image_studio.create_or_get_project( account_alias="alias-a", account_slug="alias_a", item_id="51100639510", path=cfg["db_path"], ) other_project = image_studio.create_or_get_project( account_alias="alias-b", account_slug="alias_b", item_id="51100639511", path=cfg["db_path"], ) source = image_studio.add_asset( project.id, "original", local_path=self.write_test_image(os.path.join(temp_dir, "source.jpg")), path=cfg["db_path"], ) resumable = image_studio.create_job( project.id, source_asset_id=source.id, task_key="resume-job", path=cfg["db_path"], ) image_studio.set_job_submitted(resumable.id, "remote-resume", path=cfg["db_path"]) image_studio.update_job_status( resumable.id, "failed", error="下载失败:https://secret.example.com/api/v1/image?token=private", path=cfg["db_path"], ) terminal = image_studio.create_job( project.id, source_asset_id=source.id, task_key="terminal-job", path=cfg["db_path"], ) image_studio.set_job_submitted(terminal.id, "remote-terminal", path=cfg["db_path"]) image_studio.update_job_status( terminal.id, "expired", error="上游任务已过期", recovery_action=image_studio.JOB_RECOVERY_REGENERATE, path=cfg["db_path"], ) stopped = image_studio.create_job( project.id, source_asset_id=source.id, task_key="stopped-job", path=cfg["db_path"], ) image_studio.update_job_status( stopped.id, "cancelled", error="用户停止", recovery_action=image_studio.JOB_RECOVERY_REGENERATE, path=cfg["db_path"], ) tab = ImageStudioTab(config=cfg, db_path=cfg["db_path"]) self.addCleanup(tab.close) tab._select_project(project.id) self.assertEqual(1, tab.pool_grid.count()) self.assertTrue( all( tab.pool_grid.item(row).data(gui.Qt.UserRole)["type"] == "asset" for row in range(tab.pool_grid.count()) ) ) self.assertFalse(tab.job_section.isHidden()) self.assertEqual(3, tab.job_grid.count()) job_items = [tab.job_grid.item(row) for row in range(tab.job_grid.count())] self.assertTrue( all(item.data(gui.Qt.UserRole)["draggable"] is False for item in job_items) ) details = "\n".join(item.text() for item in job_items) tooltips = "\n".join(item.toolTip() for item in job_items) self.assertIn("生成失败", details) self.assertIn("任务过期", details) self.assertIn("已停止", details) self.assertIn("可继续查询", details) self.assertIn("需要重新生成,可能再次扣点", details) self.assertNotIn("https://", details) self.assertNotIn("secret.example", tooltips) failed_item = next(item for item in job_items if "生成失败" in item.text()) self.assertEqual("#ffebe9", failed_item.background().color().name()) tab._select_project(other_project.id) self.assertEqual(0, tab.job_grid.count()) self.assertTrue(tab.job_section.isHidden()) tab._select_project(project.id) output = image_studio.add_asset( project.id, "generated_main", parent_asset_id=source.id, local_path=self.write_test_image(os.path.join(temp_dir, "generated.jpg")), path=cfg["db_path"], ) image_studio.update_job_status( resumable.id, "succeeded", output_asset_id=output.id, path=cfg["db_path"], ) tab.refresh_project_assets() self.assertEqual(2, tab.pool_grid.count()) self.assertEqual(2, tab.job_grid.count()) self.assert_removed(temp_dir) def test_image_studio_generation_log_uses_cmhub_tier_summary(self): with self.make_temp_dir() as temp_dir: cfg = self.make_config(temp_dir) cfg["ai"] = appconfig.ai_config(cfg) cfg["ai"]["backend"] = "cmhub" cfg["ai"]["cmhub"]["image_alias"] = "image-hd" db.init_db(cfg["db_path"]) project = image_studio.create_or_get_project( account_alias="alias-a", account_slug="alias_a", item_id="51100639510", path=cfg["db_path"], ) source = image_studio.add_asset( project.id, "original", local_path=self.write_test_image(os.path.join(temp_dir, "source.jpg")), path=cfg["db_path"], ) tab = ImageStudioTab(config=cfg, db_path=cfg["db_path"]) self.addCleanup(tab.close) tab._select_project(project.id) tab._select_source_asset(source) tab.prompt_edit.setPlainText("生成商品主图") self.assertIn(f"原图 #{source.id}", tab.source_label.text()) self.assertEqual("", tab.source_preview_label.text()) self.assertFalse(tab.source_preview_label.pixmap().isNull()) class FakeStudioWorker: def __init__(self): self.progress = DummySignal() self.log = DummySignal() self.finished = DummySignal() self.failed = DummySignal() def cancel(self): pass fake_worker = FakeStudioWorker() with mock.patch( "app.gui.tabs.image_studio.ImageStudioGenerateJobsWorker", return_value=fake_worker, ), mock.patch("app.gui.tabs.image_studio.run_worker", return_value=FakeThread()): tab.start_generation() log_text = tab.log_view.toPlainText() self.assertIn("cmhub 托管高质量档", log_text) self.assertIn("生图别名 image-hd", log_text) tab._on_generate_progress({"points_balance": 66, "points_cost": 2}) self.assertIn("高质量档", tab.billing_label.text()) self.assertIn("余额 66", tab.billing_label.text()) self.assertIn("本张扣点 2", tab.billing_label.text()) self.assert_removed(temp_dir) def test_image_studio_generation_keeps_current_project_workspace_available(self): with self.make_temp_dir() as temp_dir: cfg = self.make_config(temp_dir) db.init_db(cfg["db_path"]) project = image_studio.create_or_get_project( account_alias="alias-a", account_slug="alias_a", item_id="51100639510", path=cfg["db_path"], ) source = image_studio.add_asset( project.id, "original", local_path=self.write_test_image(os.path.join(temp_dir, "source.jpg")), path=cfg["db_path"], ) tab = ImageStudioTab(config=cfg, db_path=cfg["db_path"]) self.addCleanup(tab.close) tab._select_project(project.id) tab._select_source_asset(source) tab.prompt_edit.setPlainText("第一轮提示词") class FakeStudioWorker: def __init__(self): self.progress = DummySignal() self.log = DummySignal() self.finished = DummySignal() self.failed = DummySignal() def cancel(self): pass fake_worker = FakeStudioWorker() fake_thread = FakeThread() with mock.patch( "app.gui.tabs.image_studio.ImageStudioGenerateJobsWorker", return_value=fake_worker, ) as worker_factory, mock.patch( "app.gui.tabs.image_studio.run_worker", return_value=fake_thread ): tab.start_generation() self.assertEqual("generate", tab._operation_kind) self.assertTrue(tab.pool_grid.isEnabled()) self.assertTrue(tab.main_selection_list.isEnabled()) self.assertTrue(tab.detail_selection_list.isEnabled()) self.assertTrue(tab.template_combo.isEnabled()) self.assertTrue(tab.prompt_edit.isEnabled()) self.assertTrue(tab.job_type_combo.isEnabled()) self.assertTrue(tab.count_spin.isEnabled()) self.assertTrue(tab.aspect_combo.isEnabled()) self.assertTrue(tab.open_folder_button.isEnabled()) self.assertFalse(tab.start_button.isEnabled()) self.assertFalse(tab.resume_button.isEnabled()) self.assertFalse(tab.pull_images_button.isEnabled()) self.assertFalse(tab.project_table.isEnabled()) self.assertFalse(tab.original_grid.isEnabled()) self.assertFalse(tab.export_button.isEnabled()) self.assertFalse(tab.delete_project_button.isEnabled()) self.assertIn("下一轮生成", tab.generation_guard_label.text()) tab.prompt_edit.setPlainText("下一轮提示词") tab.count_spin.setValue(3) tab.job_type_combo.setCurrentIndex(tab.job_type_combo.findData("detail")) tab.aspect_combo.setCurrentIndex(tab.aspect_combo.findData("3:4")) args = worker_factory.call_args.args self.assertEqual("第一轮提示词", args[2]) self.assertEqual(4, args[3]) self.assertEqual("main", worker_factory.call_args.kwargs["job_type"]) self.assertEqual("1:1", worker_factory.call_args.kwargs["aspect_ratio"]) tab._finish_worker() self.assertTrue(tab.start_button.isEnabled()) self.assertEqual("", tab._operation_kind) self.assertIn("不会在失败后", tab.generation_guard_label.text()) fake_thread.finished.emit() self.assertIsNone(tab._running_worker) self.assertIsNone(tab._running_thread) self.assert_removed(temp_dir) def test_image_studio_worker_waits_for_thread_cleanup_before_next_start(self): with self.make_temp_dir() as temp_dir: cfg = self.make_config(temp_dir) tab = ImageStudioTab(config=cfg, db_path=cfg["db_path"]) self.addCleanup(tab.close) first_worker = SimpleNamespace() second_worker = SimpleNamespace() first_thread = FakeThread() second_thread = FakeThread() with mock.patch( "app.gui.tabs.image_studio.run_worker", side_effect=[first_thread, second_thread], ) as run_worker: self.assertTrue( tab._start_worker( first_worker, "AI工场测试线程", operation_kind="generate", ) ) tab._finish_worker() self.assertFalse( tab._start_worker( second_worker, "AI工场测试线程", operation_kind="generate", ) ) self.assertEqual(1, run_worker.call_count) first_thread.finished.emit() self.assertTrue( tab._start_worker( second_worker, "AI工场测试线程", operation_kind="generate", ) ) self.assertEqual(2, run_worker.call_count) second_thread.finished.emit() self.assert_removed(temp_dir) def test_image_studio_worker_thread_is_kept_until_thread_finished(self): with self.make_temp_dir() as temp_dir: cfg = self.make_config(temp_dir) tab = ImageStudioTab(config=cfg, db_path=cfg["db_path"]) self.addCleanup(tab.close) worker = SimpleNamespace() fake_thread = FakeThread() with mock.patch("app.gui.tabs.image_studio.run_worker", return_value=fake_thread) as run_worker: tab._start_worker(worker, "AI工场测试线程") run_worker.assert_called_once_with(worker, thread_name="AI工场测试线程", start=False) self.assertIs(tab._running_worker, worker) self.assertIs(tab._running_thread, fake_thread) self.assertTrue(fake_thread.started) self.assertFalse(tab.pull_images_button.isEnabled()) tab._finish_worker() self.assertIs(tab._running_worker, worker) self.assertIs(tab._running_thread, fake_thread) self.assertTrue(tab.pull_images_button.isEnabled()) fake_thread.finished.emit() self.assertIsNone(tab._running_worker) self.assertIsNone(tab._running_thread) self.assert_removed(temp_dir) def test_image_studio_worker_failure_is_handled_once(self): with self.make_temp_dir() as temp_dir: cfg = self.make_config(temp_dir) tab = ImageStudioTab(config=cfg, db_path=cfg["db_path"]) self.addCleanup(tab.close) messages = [] tab._message = lambda title, text: messages.append((title, text)) worker = SimpleNamespace() fake_thread = FakeThread() with mock.patch("app.gui.tabs.image_studio.run_worker", return_value=fake_thread): tab._start_worker(worker, "AI工场测试线程") tab._on_worker_failed(-1, "第一次失败") tab._on_pull_finished({"ok": False, "error": "第二次失败"}) self.assertEqual([("AI工场任务失败", "第一次失败")], messages) log_text = tab.log_view.toPlainText() self.assertEqual(1, log_text.count("[AI工场] 失败")) self.assertIn("第一次失败", log_text) self.assertNotIn("第二次失败", log_text) self.assert_removed(temp_dir) def test_image_studio_pull_main_images_uses_current_item_input(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) old_project = image_studio.create_or_get_project( account_alias="alias-a", account_slug="alias_a", item_id="11111111111", path=cfg["db_path"], ) tab = ImageStudioTab(config=cfg, db_path=cfg["db_path"]) self.addCleanup(tab.close) tab._select_project(old_project.id) tab.item_id_edit.setText("22222222222") class FakePullWorker: instances = [] def __init__(self, account_alias, item_id, *, db_path=None, config=None): self.account_alias = account_alias self.item_id = item_id self.db_path = db_path self.config = config self.log = DummySignal() self.finished = DummySignal() self.failed = DummySignal() FakePullWorker.instances.append(self) fake_thread = FakeThread() with mock.patch( "app.gui.tabs.image_studio.ImageStudioPullImagesWorker", FakePullWorker, ), mock.patch("app.gui.tabs.image_studio.run_worker", return_value=fake_thread): tab.pull_main_images() self.assertEqual(1, len(FakePullWorker.instances)) worker = FakePullWorker.instances[0] self.assertEqual("alias-a", worker.account_alias) self.assertEqual("22222222222", worker.item_id) self.assertEqual("11111111111", old_project.item_id) self.assertTrue(fake_thread.started) self.assert_removed(temp_dir) def test_image_studio_repull_confirms_when_local_originals_exist(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) project = image_studio.create_or_get_project( account_alias="alias-a", account_slug="alias_a", item_id="51100639510", path=cfg["db_path"], ) original = image_studio.sync_original_asset_urls( project.id, [{"index": 1, "src": "https://susercontent.com/main-1.jpg"}], path=cfg["db_path"], )[0] image_studio.update_asset_local_path( original.id, self.write_test_image(os.path.join(temp_dir, "main-1.jpg")), path=cfg["db_path"], ) tab = ImageStudioTab(config=cfg, db_path=cfg["db_path"]) self.addCleanup(tab.close) tab._select_project(project.id) confirmations = [] tab._confirm = lambda title, text: confirmations.append((title, text)) or False with mock.patch("app.gui.tabs.image_studio.ImageStudioPullImagesWorker") as worker_factory: tab.pull_main_images() worker_factory.assert_not_called() self.assertEqual("重新拉取蝦皮主图", confirmations[0][0]) self.assertIn("本地已保存 1 张蝦皮原主图", confirmations[0][1]) self.assertIn("不会删除本地图片、生成记录或终选记录", confirmations[0][1]) self.assert_removed(temp_dir) def test_image_studio_original_download_queue_is_local_and_nonblocking(self): with self.make_temp_dir() as temp_dir: cfg = self.make_config(temp_dir) db.init_db(cfg["db_path"]) accounts.create_account("主店", "alias-a", debug_port=9222, config=cfg) project = image_studio.create_or_get_project( account_alias="alias-a", account_slug="alias_a", item_id="51100639510", path=cfg["db_path"], ) originals = image_studio.sync_original_asset_urls( project.id, [ {"index": index, "src": f"https://susercontent.com/main-{index}.jpg"} for index in range(1, 4) ], path=cfg["db_path"], ) tab = ImageStudioTab( config=cfg, db_path=cfg["db_path"], thumbnail_loader=FakeThumbnailLoader(), ) self.addCleanup(tab.close) tab._select_project(project.id) messages = [] tab._message = lambda title, text: messages.append((title, text)) class FakeDownloadWorker: instances = [] def __init__(self, asset_id, **kwargs): self.asset_id = asset_id self.open_after = kwargs.get("open_after", False) self.progress = DummySignal() self.log = DummySignal() self.finished = DummySignal() self.cancelled = DummySignal() self.cancel_requested = False FakeDownloadWorker.instances.append(self) def cancel(self): self.cancel_requested = True threads = [] def fake_run_worker(*args, **kwargs): thread = FakeThread() threads.append(thread) return thread with mock.patch( "app.gui.tabs.image_studio.ImageStudioDownloadOriginalWorker", FakeDownloadWorker, ), mock.patch("app.gui.tabs.image_studio.run_worker", side_effect=fake_run_worker): for original in originals: tab._ensure_original_in_pool(original) self.assertEqual(2, len(FakeDownloadWorker.instances)) self.assertEqual(1, len(tab._original_download_queue)) self.assertFalse(tab._operation_running) self.assertTrue(tab.prompt_edit.isEnabled()) self.assertTrue(tab.pool_grid.isEnabled()) self.assertFalse(tab.delete_project_button.isEnabled()) self.assertIn("等待下载", tab.original_grid.item(2).text()) self.assertIn("正在下载蝦皮原主图 #1、#2(2/2)", tab.original_download_label.text()) tab.delete_current_project() tab.pull_main_images() self.assertEqual("暂不能删除项目", messages[0][0]) self.assertEqual("原图下载尚未完成", messages[1][0]) first_worker = FakeDownloadWorker.instances[0] first_worker.progress.emit( {"state": "retry", "retry": 1, "max_retries": 2} ) self.assertIn("正在重试 1/2", tab.original_download_label.text()) self.assertIn("重试 1/2", tab.original_grid.item(0).text()) local_path = self.write_test_image(os.path.join(temp_dir, "downloaded.jpg")) downloaded = image_studio.update_asset_local_path( originals[0].id, local_path, path=cfg["db_path"], ) first_worker.finished.emit({"asset": downloaded, "open_after": False}) self.assertEqual(originals[0].id, tab.selected_source_asset_id) self.assertEqual(1, tab.pool_grid.count()) threads[0].finished.emit() self.assertEqual(3, len(FakeDownloadWorker.instances)) self.assertEqual(0, len(tab._original_download_queue)) self.assertEqual(2, len(tab._original_downloads)) third_worker = FakeDownloadWorker.instances[2] third_worker.finished.emit({"ok": False, "error": "英文网络错误"}) self.assertIn("下载失败", tab.original_grid.item(2).text()) self.assertIn("可再次点击图片重试", tab.original_download_label.text()) self.assertFalse(any("英文网络错误" in text for _title, text in messages)) tab.close() self.assertTrue(FakeDownloadWorker.instances[1].cancel_requested) self.assertTrue(FakeDownloadWorker.instances[2].cancel_requested) for thread in threads[1:]: thread.finished.emit() self.assert_removed(temp_dir) def test_image_studio_original_download_result_does_not_refresh_other_project(self): with self.make_temp_dir() as temp_dir: cfg = self.make_config(temp_dir) db.init_db(cfg["db_path"]) first = image_studio.create_or_get_project( account_alias="alias-a", account_slug="alias_a", item_id="51100639510", path=cfg["db_path"], ) second = image_studio.create_or_get_project( account_alias="alias-a", account_slug="alias_a", item_id="51100639511", path=cfg["db_path"], ) original = image_studio.sync_original_asset_urls( first.id, [{"index": 1, "src": "https://susercontent.com/main-1.jpg"}], path=cfg["db_path"], )[0] tab = ImageStudioTab( config=cfg, db_path=cfg["db_path"], thumbnail_loader=FakeThumbnailLoader(), ) self.addCleanup(tab.close) tab._select_project(first.id) class FakeDownloadWorker: def __init__(self, asset_id, **kwargs): self.asset_id = asset_id self.open_after = kwargs.get("open_after", False) self.progress = DummySignal() self.log = DummySignal() self.finished = DummySignal() self.cancelled = DummySignal() def cancel(self): return None thread = FakeThread() with mock.patch( "app.gui.tabs.image_studio.ImageStudioDownloadOriginalWorker", FakeDownloadWorker, ), mock.patch("app.gui.tabs.image_studio.run_worker", return_value=thread): tab._ensure_original_in_pool(original) tab._select_project(second.id) downloaded = image_studio.update_asset_local_path( original.id, self.write_test_image(os.path.join(temp_dir, "downloaded.jpg")), path=cfg["db_path"], ) next(iter(tab._original_downloads.values()))["worker"].finished.emit( {"asset": downloaded, "open_after": False} ) self.assertEqual(second.id, tab.current_project.id) self.assertEqual(0, tab.pool_grid.count()) self.assertEqual("", tab.original_download_label.text()) thread.finished.emit() self.assert_removed(temp_dir) def test_image_studio_event_log_hides_provider_urls(self): message = gui_workers._format_image_studio_event( { "step": "cover_poll", "result": "failed", "detail": "GET https://cmhub.example.com/api/v1/generate/image/tasks/t1 failed /generated/images/a.png", } ) self.assertIn("请求 cmhub", message) self.assertIn("[接口路径已隐藏]", message) self.assertNotIn("https://", message) self.assertNotIn("/api/v1", message) self.assertNotIn("/generated/images", message) def test_startup_update_gate_forced_blocks_and_uses_upgrade_dialog(self): dialogs = [] class FakeDialog: def __init__(self, result, parent=None): self.result = result self.parent = parent self.executed = False dialogs.append(self) def exec(self): self.executed = True return 0 result = update_check.UpdateCheckResult( current_version="1.0.0", checked=True, forced=True, latest_version="1.2.0", min_supported_version="1.1.0", download_url="https://cm.833729.com/cmshopee.zip", sha256="a" * 64, size_bytes=123, package_format="cmshopee-portable-v1", updater_protocol=1, min_updater_protocol=1, message="必须升级", ) allowed = gui._run_startup_update_gate( checker=lambda: result, dialog_factory=FakeDialog, ) self.assertFalse(allowed) self.assertEqual(1, len(dialogs)) self.assertTrue(dialogs[0].executed) self.assertIs(result, dialogs[0].result) def test_forced_update_dialog_prepares_and_launches_updater_without_blocking_ui(self): with self.make_temp_dir() as temp_dir: install_root = os.path.join(temp_dir, "install") staging_root = os.path.join(install_root, ".cmshopee-update", "staging", "1.2.0-test") os.makedirs(staging_root) result = update_check.UpdateCheckResult( current_version="1.0.0", checked=True, forced=True, latest_version="1.2.0", download_url="https://cm.833729.com/cmshopee.zip", sha256="a" * 64, size_bytes=100, package_format="cmshopee-portable-v1", updater_protocol=1, min_updater_protocol=1, message="修复并优化自动升级", ) staged = SimpleNamespace( version="1.2.0", staging_dir=os.path.abspath(staging_root), sha256="a" * 64, ) def prepare(_metadata, _install_root, **callbacks): callbacks["stage_callback"]("正在下载新版") callbacks["progress"](50, 100) return staged launched = [] def factory(info, root): return UpdatePreparationWorker(info, root, prepare=prepare) dialog = ForcedUpdateDialog( result, install_root=install_root, worker_factory=factory, updater_launcher=lambda updater, plan: launched.append((updater, plan)), ) self.addCleanup(dialog.close) with mock.patch( "app.gui.update_dialog.updater_entry.create_plan", return_value=os.path.join(temp_dir, "plan.json"), ): dialog.start_update() deadline = time.time() + 3 while dialog.thread is not None and time.time() < deadline: self.app.processEvents() time.sleep(0.01) self.assertIsNone(dialog.thread) self.assertTrue(dialog.update_started) self.assertEqual(100, dialog.progress_bar.value()) self.assertIn("50 B / 100 B", dialog.bytes_label.text()) self.assertEqual(1, len(launched)) self.assertIn("cmshopee-updater.exe", str(launched[0][0])) def test_forced_update_dialog_cancel_waits_for_worker_cleanup(self): with self.make_temp_dir() as temp_dir: result = update_check.UpdateCheckResult( current_version="1.0.0", checked=True, forced=True, latest_version="1.2.0", download_url="https://cm.833729.com/cmshopee.zip", sha256="a" * 64, size_bytes=100, package_format="cmshopee-portable-v1", updater_protocol=1, min_updater_protocol=1, ) def prepare(_metadata, _install_root, **callbacks): while not callbacks["cancelled"](): time.sleep(0.01) raise update_installer.UpdateCancelled("已取消下载新版") def factory(info, root): return UpdatePreparationWorker(info, root, prepare=prepare) dialog = ForcedUpdateDialog( result, install_root=temp_dir, worker_factory=factory, ) self.addCleanup(dialog.close) dialog.start_update() deadline = time.time() + 1 while dialog.worker is None and time.time() < deadline: self.app.processEvents() dialog.request_exit() deadline = time.time() + 3 while dialog.thread is not None and time.time() < deadline: self.app.processEvents() time.sleep(0.01) self.assertIsNone(dialog.thread) self.assertIsNone(dialog.worker) def test_forced_update_dialog_blocks_when_auto_install_metadata_is_incomplete(self): result = update_check.UpdateCheckResult( current_version="1.0.0", checked=True, forced=True, latest_version="1.2.0", download_url="https://cm.833729.com/cmshopee.zip", ) dialog = ForcedUpdateDialog(result) self.addCleanup(dialog.close) self.assertIn("校验值缺失", dialog.stage_label.text()) self.assertEqual("重试", dialog.action_button.text()) self.assertIsNone(dialog.thread) def test_forced_update_dialog_only_warns_for_plain_http_download(self): common = { "current_version": "1.0.0", "checked": True, "forced": True, "latest_version": "1.2.0", "sha256": "a" * 64, "size_bytes": 100, } http_dialog = ForcedUpdateDialog( update_check.UpdateCheckResult( download_url="http://external.example.test/cmshopee.zip", **common, ) ) https_dialog = ForcedUpdateDialog( update_check.UpdateCheckResult( download_url="https://external.example.test/cmshopee.zip", **common, ) ) self.addCleanup(http_dialog.close) self.addCleanup(https_dialog.close) self.assertFalse(http_dialog.http_warning_label.isHidden()) self.assertIn("临时 HTTP 通道", http_dialog.http_warning_label.text()) self.assertTrue(https_dialog.http_warning_label.isHidden()) def test_startup_update_gate_fuses_a_previously_failed_release(self): captured = [] class FakeDialog: def __init__(self, result, parent=None): captured.append(result) def exec(self): return 0 result = update_check.UpdateCheckResult( current_version="1.0.0", checked=True, forced=True, latest_version="1.2.0", download_url="https://cm.833729.com/cmshopee.zip", sha256="a" * 64, size_bytes=100, package_format="cmshopee-portable-v1", updater_protocol=1, ) with mock.patch( "app.gui.update_health.get_failed_release", return_value={"reason": "新版未就绪"}, ): allowed = gui._run_startup_update_gate( checker=lambda: result, dialog_factory=FakeDialog, ) self.assertFalse(allowed) self.assertIn("已停止重复安装", captured[0].automatic_update_error) def test_startup_update_gate_check_failure_allows_entry_and_logs(self): result = update_check.UpdateCheckResult( current_version="1.0.0", checked=True, forced=False, error="启动版本检查失败,已允许继续使用:网络超时", ) with mock.patch("app.gui.diagnostics.write_diagnostic_log") as write_log: allowed = gui._run_startup_update_gate(checker=lambda: result) self.assertTrue(allowed) write_log.assert_called_once() def test_status_callbacks_classify_success_warning_and_failure(self): with self.make_temp_dir() as temp_dir: statuses = [] def capture(message, level=None): statuses.append((message, level)) cfg = self.make_config(temp_dir) generate_tab = GenerateTab( config=cfg, status_callback=capture, title_prompt_path=os.path.join(temp_dir, "title_prompt.txt"), cover_prompts_dir=os.path.join(temp_dir, "cover_prompts"), ) self.addCleanup(generate_tab.close) statuses.clear() generate_tab.save_title_prompt() self.assertEqual(("标题提示词已保存", "success"), statuses[-1]) statuses.clear() generate_tab.start_generate() self.assertIn("当前筛选结果没有可生成的缺失内容", statuses[-1][0]) self.assertEqual("warning", statuses[-1][1]) model = gui.GenerateTaskTableModel(db_path=os.path.join(temp_dir, "missing_tables.sqlite"), status_callback=capture) editable_task = SimpleNamespace( id=999, alias="papa", account_name="papa 店铺", item_id="28431952912", old_title="旧标题", new_title="新标题", new_cover_path="", stage="generated", status="success", committed=0, last_error="", ) model.set_tasks([editable_task], []) statuses.clear() ok = model.setData(model.index(0, 3), "新标题 2") self.assertFalse(ok) self.assertIn("新标题修改失败", statuses[-1][0]) self.assertEqual("danger", statuses[-1][1]) self.assert_removed(temp_dir) def test_main_window_initial_fit_limits_size_on_small_screen(self): class FakeWindow: def __init__(self): self.size = None self.position = None def resize(self, width, height): self.size = (width, height) def move(self, x, y): self.position = (x, y) window = FakeWindow() _fit_and_center_window(window, available_geometry=QRect(0, 0, 900, 700)) self.assertEqual((820, 620), window.size) self.assertEqual((40, 40), window.position) def test_main_window_initial_fit_centers_preferred_size_on_large_screen(self): class FakeWindow: def __init__(self): self.size = None self.position = None def resize(self, width, height): self.size = (width, height) def move(self, x, y): self.position = (x, y) window = FakeWindow() _fit_and_center_window(window, available_geometry=QRect(100, 50, 1600, 900)) self.assertEqual((1180, 760), window.size) self.assertEqual((310, 120), window.position) def test_collect_tab_shows_empty_state_without_accounts(self): with self.make_temp_dir() as temp_dir: cfg = self.make_config(temp_dir) opened = [] tab = CollectTab(config=cfg, open_accounts_callback=lambda: opened.append(True)) self.addCleanup(tab.close) self.assertFalse(tab.empty_state_card.isHidden()) self.assertIn("账号管理", tab.empty_state_label.text()) self.assertFalse(tab.empty_state_button.isHidden()) tab.empty_state_button.click() self.assertEqual([True], opened) self.assert_removed(temp_dir) def test_generate_tab_shows_empty_state_for_first_steps(self): with self.make_temp_dir() as temp_dir: cfg = self.make_config(temp_dir) opened = [] tab = GenerateTab(config=cfg, open_accounts_callback=lambda: opened.append(True)) self.addCleanup(tab.close) self.assertFalse(tab.empty_state_card.isHidden()) self.assertIn("账号管理", tab.empty_state_label.text()) self.assertFalse(tab.empty_state_button.isHidden()) tab.empty_state_button.click() self.assertEqual([True], opened) accounts.create_account("主店", "alias", debug_port=9222, config=cfg) tab.refresh_tasks() self.assertFalse(tab.empty_state_card.isHidden()) self.assertIn("①导入采集", tab.empty_state_label.text()) self.assertTrue(tab.empty_state_button.isHidden()) self.assert_removed(temp_dir) def test_apply_tab_shows_empty_state_for_first_steps(self): with self.make_temp_dir() as temp_dir: cfg = self.make_config(temp_dir) opened = [] tab = ApplyTab(config=cfg, open_accounts_callback=lambda: opened.append(True)) self.addCleanup(tab.close) self.assertFalse(tab.empty_state_card.isHidden()) self.assertIn("账号管理", tab.empty_state_label.text()) self.assertFalse(tab.empty_state_button.isHidden()) tab.empty_state_button.click() self.assertEqual([True], opened) accounts.create_account("主店", "alias", debug_port=9222, config=cfg) tab.refresh_tasks() self.assertFalse(tab.empty_state_card.isHidden()) self.assertIn("②AI生成", tab.empty_state_label.text()) self.assertTrue(tab.empty_state_button.isHidden()) self.assert_removed(temp_dir) def test_workflow_tabs_show_batch_progress_overview(self): with self.make_temp_dir() as temp_dir: cfg = self.make_config(temp_dir) db.init_db(cfg["db_path"]) accounts.create_account("主店", "alias", debug_port=9222, config=cfg) batch_id = db.create_batch(["input.xlsx"], path=cfg["db_path"]) rows = [] for index in range(6): rows.append( { "source_file_abs": os.path.join(temp_dir, "input.xlsx"), "source_sheet": "待处理任务", "source_row": index + 2, "row_key": f"row-{index}", "account_name": "主店", "alias": "alias", "item_id": str(1000 + index), } ) db.insert_tasks(batch_id, rows, path=cfg["db_path"]) tasks = {task.item_id: task for task in db.list_tasks(batch_id=batch_id, path=cfg["db_path"])} db.set_collected(tasks["1001"].id, "旧标题", os.path.join(temp_dir, "old.jpg"), path=cfg["db_path"]) db.set_generated(tasks["1002"].id, "新标题", os.path.join(temp_dir, "new.jpg"), path=cfg["db_path"]) db.set_applied(tasks["1003"].id, committed=True, path=cfg["db_path"]) db.set_collected(tasks["1004"].id, "旧标题", os.path.join(temp_dir, "old-failed.jpg"), path=cfg["db_path"]) db.mark_failed(tasks["1004"].id, "generate", "生成失败", path=cfg["db_path"]) db.mark_skipped(tasks["1005"].id, "别名未匹配", path=cfg["db_path"]) active_other = db.create_batch(["other.xlsx"], path=cfg["db_path"]) db.insert_tasks( active_other, [ { "source_file_abs": os.path.join(temp_dir, "other.xlsx"), "source_sheet": "待处理任务", "source_row": 2, "row_key": "other-row", "account_name": "主店", "alias": "alias", "item_id": "2000", } ], path=cfg["db_path"], ) deleted_batch = db.create_batch(["deleted.xlsx"], path=cfg["db_path"]) db.insert_tasks( deleted_batch, [ { "source_file_abs": os.path.join(temp_dir, "deleted.xlsx"), "source_sheet": "待处理任务", "source_row": 2, "row_key": "deleted-row", "account_name": "主店", "alias": "alias", "item_id": "3000", } ], path=cfg["db_path"], ) db.delete_batch(deleted_batch, reason="测试软删除", path=cfg["db_path"]) expected = "批次进度:总数6 · 导入1 · 已采集1 · 已生成1 · 已更新1 · 失败1 · 略过1" for tab_class in (CollectTab, GenerateTab, ApplyTab): tab = tab_class(config=cfg) self.addCleanup(tab.close) self.assertEqual(-1, tab.batch_filter.findData(deleted_batch)) tab.batch_filter.setCurrentIndex(tab.batch_filter.findData(batch_id)) tab.refresh_tasks() self.assertFalse(tab.batch_progress_label.isHidden()) self.assertEqual(expected, tab.batch_progress_label.text()) self.assert_removed(temp_dir) def test_workflow_batch_filters_default_to_latest_batch_and_remember_selection(self): with self.make_temp_dir() as temp_dir: cfg = self.make_config(temp_dir) for tab_class in (CollectTab, GenerateTab, ApplyTab): tab = tab_class(config=cfg) self.addCleanup(tab.close) self.assertEqual(0, tab.batch_filter.currentIndex()) self.assertIsNone(tab.batch_filter.currentData()) accounts.create_account("主店", "alias-a", debug_port=9222, config=cfg) older_batch = db.create_batch(["older.xlsx"], path=cfg["db_path"]) newer_batch = db.create_batch(["newer.xlsx"], path=cfg["db_path"]) self.set_batch_created_at(cfg, older_batch, "2026-07-08T10:00:00") self.set_batch_created_at(cfg, newer_batch, "2026-07-08T11:00:00") for batch_id, item_id in ((older_batch, "1001"), (newer_batch, "2001")): db.insert_tasks( batch_id, [ { "source_file_abs": os.path.join(temp_dir, f"{item_id}.xlsx"), "source_sheet": "商品", "source_row": 2, "account_name": "Excel主店", "alias": "alias-a", "item_id": item_id, } ], path=cfg["db_path"], ) for tab_class in (CollectTab, GenerateTab, ApplyTab): tab = tab_class(config=cfg) self.addCleanup(tab.close) self.assertEqual(newer_batch, tab.batch_filter.currentData()) self.assertEqual(1, tab.batch_filter.currentIndex()) tab.batch_filter.setCurrentIndex(tab.batch_filter.findData(older_batch)) tab.refresh_tasks() self.assertEqual(older_batch, tab.batch_filter.currentData()) tab.batch_filter.setCurrentIndex(tab.batch_filter.findData(None)) tab.refresh_tasks() self.assertIsNone(tab.batch_filter.currentData()) self.assert_removed(temp_dir) def test_settings_tab_loads_ai_models_and_masks_key_field(self): with self.make_temp_dir() as temp_dir: cfg = self.make_config(temp_dir) models_path = cfg["ai_models_path"] appconfig.save_ai_models_config( { "models": [ { "name": "Text A", "category": "text", "enabled": True, "url": "https://example.invalid/text", "model": "text-model", "api_key": "sk-text-secret", "api_type": "chat", "connect_timeout_seconds": 11, "timeout_seconds": 0, "extra_body": {"temperature": 0}, }, { "name": "Image A", "category": "image", "enabled": True, "url": "https://example.invalid/image", "model": "image-model", "api_key": "sk-image-secret", "api_type": "auto", "connect_timeout_seconds": 22, "timeout_seconds": 0, "extra_body": {}, }, ] }, path=models_path, ) tab = SettingsTab(config=cfg, ai_models_path=models_path) self.addCleanup(tab.close) self.assertEqual(2, tab.model_combo.count()) self.assertEqual("Text A", tab.name_edit.text()) self.assertEqual("text", tab.category_combo.currentData()) self.assertEqual("chat", tab.api_type_combo.currentData()) self.assertEqual("text-model", tab.model_id_edit.text()) self.assertEqual("https://example.invalid/text", tab.url_edit.text()) self.assertEqual("sk-text-secret", tab.api_key_edit.text()) self.assertEqual(QLineEdit.Password, tab.api_key_edit.echoMode()) self.assertEqual(11, tab.connect_timeout_spin.value()) self.assertFalse(tab.delete_model_button.isEnabled()) self.assertFalse(hasattr(tab, "test_item_id_edit")) self.assertFalse(hasattr(tab, "dry_run_checkbox")) self.assertEqual( "蝦皮更新执行", tab.shopee_update_section_title.text(), ) self.assertEqual("基础设施(路径与端口)", tab.infrastructure_section_title.text()) self.assertLess( tab.settings_panel_layout.indexOf(tab.shopee_update_section_title), tab.settings_panel_layout.indexOf(tab.infrastructure_section_title), ) self.assertFalse(hasattr(tab, "allow_real_submit_checkbox")) self.assertFalse(hasattr(tab, "allow_cover_update_checkbox")) self.assertFalse(hasattr(tab, "close_success_tab_checkbox")) self.assertFalse(hasattr(tab, "parallel_accounts_checkbox")) self.assertFalse(hasattr(tab, "parallel_accounts_group")) self.assertFalse(hasattr(tab, "jpg_quality_spin")) self.assertEqual(1, tab.max_items_per_run_spin.value()) self.assertEqual(1, tab.max_parallel_accounts_spin.value()) self.assertEqual(1, tab.max_parallel_accounts_spin.minimum()) self.assertEqual(5, tab.max_parallel_accounts_spin.maximum()) def widget_position(layout, widget): for index in range(layout.count()): item = layout.itemAt(index) if item is not None and item.widget() is widget: return layout.getItemPosition(index) self.fail(f"Widget not found in layout: {widget.objectName()}") parallel_row, parallel_col, _row_span, parallel_col_span = widget_position( tab.shopee_update_form_layout, tab.max_parallel_accounts_spin, ) self.assertGreaterEqual(parallel_row, 0) self.assertGreaterEqual(parallel_col, 0) self.assertEqual(1, parallel_col_span) for hidden_widget in ( tab.user_data_root_edit, tab.image_dir_edit, tab.db_path_edit, ): self.assertTrue(hidden_widget.isHidden()) self.assertFalse(hidden_widget.isEnabled()) self.assertEqual(-1, tab.infrastructure_form_layout.indexOf(hidden_widget)) self.assertGreaterEqual( tab.infrastructure_form_layout.indexOf(tab.chrome_path_widget), 0, ) self.assertEqual("选择...", tab.chrome_path_browse_button.text()) self.assertEqual("chromePathBrowseButton", tab.chrome_path_browse_button.objectName()) self.assertEqual("自动检测", tab.chrome_path_detect_button.text()) self.assertEqual( "chromePathAutoDetectButton", tab.chrome_path_detect_button.objectName(), ) self.assertIs( tab.chrome_path_widget.layout().itemAt(0).widget(), tab.chrome_path_edit, ) self.assertIs( tab.chrome_path_widget.layout().itemAt(1).widget(), tab.chrome_path_browse_button, ) self.assertIs( tab.chrome_path_widget.layout().itemAt(2).widget(), tab.chrome_path_detect_button, ) self.assert_removed(temp_dir) def test_settings_tab_browses_chrome_path(self): with self.make_temp_dir() as temp_dir: cfg = self.make_config(temp_dir) statuses = [] tab = SettingsTab(config=cfg, status_callback=statuses.append) self.addCleanup(tab.close) selected_path = os.path.join(temp_dir, "Chrome", "chrome.exe") os.makedirs(os.path.dirname(selected_path), exist_ok=True) self.assertFalse(tab.is_dirty()) with mock.patch( "app.gui.tabs.settings.QFileDialog.getOpenFileName", return_value=(selected_path, "Chrome 程序 (chrome.exe)"), ) as browse: tab.chrome_path_browse_button.click() browse.assert_called_once() self.assertEqual("选择 Chrome 程序", browse.call_args[0][1]) self.assertEqual(selected_path, tab.chrome_path_edit.text()) self.assertTrue(tab.is_dirty()) self.assertFalse(tab.unsaved_changes_label.isHidden()) with mock.patch("app.gui.QMessageBox.information") as info: self.assertTrue(tab.save_app_settings()) info.assert_called_once_with(tab, "保存设置", "设置已保存") saved = appconfig.load_config(cfg["config_path"]) self.assertEqual(selected_path, saved["chrome_path"]) self.assertFalse(tab.is_dirty()) tab._set_dirty(False) previous_path = tab.chrome_path_edit.text() with mock.patch( "app.gui.tabs.settings.QFileDialog.getOpenFileName", return_value=("", ""), ): tab.chrome_path_browse_button.click() self.assertEqual(previous_path, tab.chrome_path_edit.text()) self.assertFalse(tab.is_dirty()) self.assert_removed(temp_dir) def test_settings_tab_auto_detects_chrome_without_saving(self): with self.make_temp_dir() as temp_dir: cfg = self.make_config(temp_dir) statuses = [] tab = SettingsTab(config=cfg, status_callback=statuses.append) self.addCleanup(tab.close) detected = os.path.join(temp_dir, "Chrome", "chrome.exe") with mock.patch( "app.gui.tabs.settings.chrome.detect_chrome_path", return_value=detected, ): tab.chrome_path_detect_button.click() self.assertEqual(detected, tab.chrome_path_edit.text()) self.assertTrue(tab.is_dirty()) self.assertFalse(os.path.exists(cfg["config_path"])) self.assertIn("已自动定位 Chrome", statuses[-1]) tab._set_dirty(False) previous = tab.chrome_path_edit.text() with mock.patch( "app.gui.tabs.settings.chrome.detect_chrome_path", return_value="", ): tab.chrome_path_detect_button.click() self.assertEqual(previous, tab.chrome_path_edit.text()) self.assertFalse(tab.is_dirty()) self.assertIn("未找到 Chrome", statuses[-1]) self.assert_removed(temp_dir) def test_main_window_keeps_startup_chrome_detection_status(self): with self.make_temp_dir() as temp_dir: message = "已自动定位 Chrome:C:\\Chrome\\chrome.exe" window = MainWindow( config=self.make_config(temp_dir), startup_status=message, ) self.addCleanup(window.close) self.assertEqual(message, window.statusBar().currentMessage()) self.assertIn(gui.COLOR_SUCCESS, window.statusBar().styleSheet()) self.assert_removed(temp_dir) def test_settings_tab_adds_saves_and_deletes_model(self): with self.make_temp_dir() as temp_dir: cfg = self.make_config(temp_dir) models_path = cfg["ai_models_path"] statuses = [] tab = SettingsTab( config=cfg, ai_models_path=models_path, status_callback=statuses.append, ) self.addCleanup(tab.close) tab.add_model() self.assertEqual("新文本模型", tab.current_model_name) self.assertEqual(3, tab.model_combo.count()) tab.name_edit.setText("Text Custom") tab.url_edit.setText("https://example.invalid/v1/chat/completions") tab.model_id_edit.setText("demo-text") tab.api_key_edit.setText("sk-custom-secret") tab.api_type_combo.setCurrentIndex(tab.api_type_combo.findData("chat")) tab.connect_timeout_spin.setValue(12) with mock.patch("app.gui.QMessageBox.warning") as warning: tab.save_model() warning.assert_called_once() self.assertIn("本地明文保存", warning.call_args[0][1]) self.assertIn("data/config/ai_models.json", warning.call_args[0][2]) saved = appconfig.get_model("Text Custom", path=models_path) self.assertEqual("text", saved["category"]) self.assertEqual("demo-text", saved["model"]) self.assertEqual("sk-custom-secret", saved["api_key"]) self.assertEqual(12, saved["connect_timeout_seconds"]) self.assertIn("AI 模型已保存:Text Custom", statuses[-1]) self.assertNotIn("sk-custom-secret", statuses[-1]) self.assertTrue(tab.delete_model_button.isEnabled()) with mock.patch("app.gui.QMessageBox.question", return_value=gui.QMessageBox.Yes): tab.delete_model() names = [model["name"] for model in appconfig.list_ai_models(path=models_path)] self.assertNotIn("Text Custom", names) self.assertIn("AI 模型已删除:Text Custom", statuses[-1]) self.assert_removed(temp_dir) def test_settings_tab_starts_connection_test_worker(self): with self.make_temp_dir() as temp_dir: cfg = self.make_config(temp_dir) models_path = cfg["ai_models_path"] statuses = [] tab = SettingsTab( config=cfg, ai_models_path=models_path, status_callback=statuses.append, ) self.addCleanup(tab.close) class FakeSignal: def __init__(self): self.callbacks = [] def connect(self, callback): self.callbacks.append(callback) class FakeThread: def __init__(self): self.finished = FakeSignal() self.started = False def start(self): self.started = True fake_thread = FakeThread() with mock.patch("app.gui.run_worker", return_value=fake_thread) as run_worker: tab.test_connection() run_worker.assert_called_once() self.assertIsInstance(tab.test_worker, AIModelTestWorker) self.assertIs(tab.test_thread, fake_thread) self.assertTrue(fake_thread.started) self.assertFalse(tab.test_connection_button.isEnabled()) self.assertIn("正在测试 AI 模型连接", statuses[-1]) self.assert_removed(temp_dir) def test_settings_tab_saves_role_generation_path_and_port_config(self): with self.make_temp_dir() as temp_dir: cfg = self.make_config(temp_dir) cfg["ai"] = appconfig.default_config()["ai"] cfg["ai"]["default_text_model"] = "Text A" cfg["ai"]["default_image_model"] = "Image A" cfg["ai"]["generate_cover"] = True cfg["user_data_root"] = "manual_profiles" cfg["image_dir"] = "manual_images" cfg["db_path"] = "manual\\cmshopee.db" models_path = cfg["ai_models_path"] config_path = cfg["config_path"] appconfig.save_ai_models_config( { "models": [ { "name": "Text A", "category": "text", "enabled": True, "url": "", "model": "", "api_key": "", "api_type": "chat", "connect_timeout_seconds": 30, "timeout_seconds": 0, "extra_body": {}, }, { "name": "Text B", "category": "text", "enabled": True, "url": "", "model": "", "api_key": "", "api_type": "chat", "connect_timeout_seconds": 30, "timeout_seconds": 0, "extra_body": {}, }, { "name": "Image A", "category": "image", "enabled": True, "url": "", "model": "", "api_key": "", "api_type": "auto", "connect_timeout_seconds": 30, "timeout_seconds": 0, "extra_body": {}, }, { "name": "Image B", "category": "image", "enabled": True, "url": "", "model": "", "api_key": "", "api_type": "auto", "connect_timeout_seconds": 30, "timeout_seconds": 0, "extra_body": {}, }, ] }, path=models_path, ) statuses = [] tab = SettingsTab( config=cfg, config_path=config_path, ai_models_path=models_path, status_callback=statuses.append, ) self.addCleanup(tab.close) self.assertEqual(1, tab.title_concurrency_spin.minimum()) self.assertEqual(5, tab.title_concurrency_spin.maximum()) self.assertEqual(1, tab.image_concurrency_spin.minimum()) self.assertEqual(5, tab.image_concurrency_spin.maximum()) self.assertEqual(0, tab.retry_spin.minimum()) self.assertEqual(10, tab.retry_spin.maximum()) text_roles = [ tab.default_text_model_combo.itemData(index) for index in range(tab.default_text_model_combo.count()) ] image_roles = [ tab.default_image_model_combo.itemData(index) for index in range(tab.default_image_model_combo.count()) ] self.assertEqual(["Text A", "Text B"], text_roles) self.assertEqual(["Image A", "Image B"], image_roles) tab.default_text_model_combo.setCurrentIndex( tab.default_text_model_combo.findData("Text B") ) tab.default_image_model_combo.setCurrentIndex( tab.default_image_model_combo.findData("Image B") ) tab.title_concurrency_spin.setValue(3) tab.image_concurrency_spin.setValue(2) tab.retry_spin.setValue(1) tab.resolution_combo.setCurrentIndex(tab.resolution_combo.findData("2k")) self.assertEqual("标题 600 秒 / 图片 900 秒", tab.response_timeout_label.text()) tab.chrome_path_edit.setText("D:\\Chrome\\chrome.exe") tab.default_debug_port_spin.setValue(9300) tab.debug_port_start_spin.setValue(9300) tab.debug_port_end_spin.setValue(9350) tab.cdp_ready_timeout_spin.setValue(45) tab.max_items_per_run_spin.setValue(2) tab.max_parallel_accounts_spin.setValue(3) with mock.patch("app.gui.QMessageBox.information") as info: tab.save_app_settings() info.assert_called_once_with(tab, "保存设置", "设置已保存") saved = appconfig.load_config(config_path) self.assertEqual("Text B", saved["ai"]["default_text_model"]) self.assertEqual("Image B", saved["ai"]["default_image_model"]) self.assertTrue(saved["ai"]["generate_cover"]) self.assertEqual(3, saved["ai"]["title_concurrency"]) self.assertEqual(2, saved["ai"]["image_concurrency"]) self.assertEqual(1, saved["ai"]["retry"]) self.assertEqual("2k", saved["ai"]["resolution"]) self.assertEqual(90, saved["ai"]["jpg_quality"]) self.assertEqual("D:\\Chrome\\chrome.exe", saved["chrome_path"]) self.assertEqual("manual_profiles", saved["user_data_root"]) self.assertEqual("manual_images", saved["image_dir"]) self.assertEqual("manual\\cmshopee.db", saved["db_path"]) self.assertEqual(9300, saved["default_debug_port"]) self.assertEqual([9300, 9350], saved["debug_port_range"]) self.assertEqual(45, saved["cdp_ready_timeout"]) self.assertEqual( { "test_item_id": "51100639510", "update_mode": "title", "max_items_per_run": 2, "dry_run": False, "max_parallel_accounts": 3, }, saved["shopee_update"], ) with open(config_path, "r", encoding="utf-8") as fh: persisted = json.load(fh) self.assertNotIn("ai_models_path", persisted) self.assertNotIn("config_path", persisted) self.assertNotIn("cmhub_config_path", persisted) self.assertNotIn("data_dir", persisted) self.assertIn("设置已保存", statuses[-1]) self.assert_removed(temp_dir) def test_settings_tab_direct_timeout_label_uses_resolution_mapping(self): with self.make_temp_dir() as temp_dir: cfg = self.make_config(temp_dir) cfg["ai"] = appconfig.default_config()["ai"] cfg["ai"]["backend"] = "direct" tab = SettingsTab( config=cfg, config_path=cfg["config_path"], ai_models_path=cfg["ai_models_path"], ) self.addCleanup(tab.close) tab.resolution_combo.setCurrentIndex(tab.resolution_combo.findData("2k")) self.assertEqual("360 秒", tab.response_timeout_label.text()) self.assert_removed(temp_dir) def test_settings_save_updates_apply_tab_shared_update_config(self): with self.make_temp_dir() as temp_dir: cfg = self.make_config(temp_dir) cfg["shopee_update"] = dict(appconfig.default_config()["shopee_update"]) cfg["shopee_update"]["test_item_id"] = "123456789" window = MainWindow( config=cfg, config_path=cfg["config_path"], ai_models_path=cfg["ai_models_path"], ) self.addCleanup(window.close) settings_tab = window.tabs.widget(TAB_TITLES.index("设置")) apply_tab = window.tabs.widget(TAB_TITLES.index("③ 更新蝦皮")) settings_tab.max_items_per_run_spin.setValue(3) settings_tab.max_parallel_accounts_spin.setValue(4) with mock.patch("app.gui.QMessageBox.information") as info: settings_tab.save_app_settings() info.assert_called_once_with(settings_tab, "保存设置", "设置已保存") safety_cfg = apply_tab._shopee_update_config() self.assertEqual("123456789", safety_cfg["test_item_id"]) self.assertNotIn("allow_real_submit", safety_cfg) self.assertNotIn("allow_cover_update", safety_cfg) self.assertEqual("title", safety_cfg["update_mode"]) self.assertEqual(3, safety_cfg["max_items_per_run"]) self.assertNotIn("close_success_tab", safety_cfg) self.assertFalse(safety_cfg["dry_run"]) self.assertNotIn("parallel_accounts", safety_cfg) self.assertEqual(4, safety_cfg["max_parallel_accounts"]) self.assert_removed(temp_dir) def test_settings_tab_rejects_invalid_port_range(self): with self.make_temp_dir() as temp_dir: cfg = self.make_config(temp_dir) tab = SettingsTab( config=cfg, config_path=cfg["config_path"], ai_models_path=cfg["ai_models_path"], ) self.addCleanup(tab.close) tab.debug_port_start_spin.setValue(9400) tab.debug_port_end_spin.setValue(9300) with mock.patch("app.gui.QMessageBox.warning") as warning: tab.save_app_settings() warning.assert_called_once() self.assertFalse(os.path.exists(cfg["config_path"])) self.assert_removed(temp_dir) def test_ai_model_test_worker_calls_appconfig(self): with self.make_temp_dir() as temp_dir: models_path = os.path.join(temp_dir, "ai_models.json") worker = AIModelTestWorker("Text A", ai_models_path=models_path) with mock.patch( "app.gui.appconfig.test_ai_model", return_value={"ok": True, "status": 200}, ) as test_ai_model: result = worker.execute() test_ai_model.assert_called_once_with("Text A", path=models_path) self.assertEqual({"ok": True, "status": 200, "name": "Text A"}, result) self.assert_removed(temp_dir) def test_ai_model_test_worker_sanitizes_secret_payload_fields(self): with self.make_temp_dir() as temp_dir: models_path = os.path.join(temp_dir, "ai_models.json") worker = AIModelTestWorker("Text A", ai_models_path=models_path) with mock.patch( "app.gui.appconfig.test_ai_model", return_value={ "ok": False, "api_key": "sk-worker-secret", "password": "worker-password", "error": "连接失败", }, ): result = worker.execute() self.assertEqual("Text A", result["name"]) self.assertEqual("sk-w***cret", result["api_key"]) self.assertEqual("work***word", result["password"]) self.assertEqual("连接失败", result["error"]) self.assert_removed(temp_dir) def test_cmhub_settings_worker_fetches_models_and_balance(self): worker = CMHubSettingsWorker( "https://cmhub.example.com", "sk-cmhub-secret", connect_timeout=7, include_balance=True, ) models = [ { "alias": "title-standard", "operation_type": "title", "pricing_status": "priced", } ] balance = { "user": "cmhub_user", "points_balance": 88, "account": {"username": "cmhub_user", "display_name": "主账号"}, } with mock.patch("app.gui.ai.fetch_cmhub_models", return_value=models) as fetch_models, \ mock.patch("app.gui.ai.fetch_cmhub_balance", return_value=balance) as fetch_balance: result = worker.execute() fetch_models.assert_called_once_with( "https://cmhub.example.com", "sk-cmhub-secret", connect_timeout=7, ) fetch_balance.assert_called_once_with( "https://cmhub.example.com", "sk-cmhub-secret", connect_timeout=7, ) self.assertTrue(result["ok"]) self.assertEqual(models, result["models"]) self.assertEqual(88, result["points_balance"]) self.assertEqual("cmhub_user", result["balance"]["user"]) self.assertEqual("主账号", result["balance"]["account"]["display_name"]) self.assertEqual("cmhub_user", result["balance"]["account"]["username"]) def test_cmhub_settings_worker_redacts_key_on_failure(self): worker = CMHubSettingsWorker( "https://cmhub.example.com", "sk-cmhub-secret", connect_timeout=7, include_balance=False, ) with mock.patch( "app.gui.ai.fetch_cmhub_models", side_effect=RuntimeError("bad key sk-cmhub-secret"), ): with self.assertRaises(RuntimeError) as raised: worker.execute() self.assertIn("bad key", str(raised.exception)) self.assertNotIn("sk-cmhub-secret", str(raised.exception)) def test_settings_tab_cmhub_backend_panel_saves_config_and_key(self): with self.make_temp_dir() as temp_dir: cfg = self.make_config(temp_dir) cfg["ai"] = appconfig.default_config()["ai"] cfg["ai"]["backend"] = "cmhub" cfg["ai"]["cmhub"] = { "base_url": "https://cmhub.old", "title_alias": "title-old", "image_alias": "image-old", "connect_timeout": 9, "check_balance_before_batch": False, } cfg["cmhub_config_path"] = os.path.join(temp_dir, "config", "cmhub.json") appconfig.save_cmhub_config( {"api_key": "sk-old-secret"}, path=cfg["cmhub_config_path"], ) appconfig.save_ai_models_config( { "models": [ { "name": "Text A", "category": "text", "enabled": True, "url": "", "model": "", "api_key": "", "api_type": "chat", "connect_timeout_seconds": 30, "timeout_seconds": 0, "extra_body": {}, }, { "name": "Image A", "category": "image", "enabled": True, "url": "", "model": "", "api_key": "", "api_type": "auto", "connect_timeout_seconds": 30, "timeout_seconds": 0, "extra_body": {}, }, ] }, path=cfg["ai_models_path"], ) tab = SettingsTab( config=cfg, config_path=cfg["config_path"], ai_models_path=cfg["ai_models_path"], ) self.addCleanup(tab.close) self.assertEqual("cmhub", tab.backend_combo.currentData()) self.assertTrue(tab.backend_combo.isHidden()) self.assertTrue(tab.model_picker_panel.isHidden()) self.assertTrue(tab.direct_role_panel.isHidden()) self.assertFalse(tab.cmhub_panel.isHidden()) self.assertIn("不要带 /api", tab.cmhub_base_url_hint_label.text()) self.assertEqual("https://cmhub.old", tab.cmhub_base_url_edit.text()) self.assertEqual("sk-old-secret", tab.cmhub_api_key_edit.text()) self.assertEqual(QLineEdit.Password, tab.cmhub_api_key_edit.echoMode()) self.assertEqual("title-old", tab.cmhub_title_alias_combo.currentData()) self.assertEqual("image-old", tab.cmhub_image_alias_combo.currentData()) self.assertEqual("vision-standard", tab.cmhub_vision_alias_combo.currentData()) tab.cmhub_base_url_edit.setText("https://cmhub.example.com/api/v1/") tab.cmhub_api_key_edit.setText("sk-new-secret") tab.cmhub_connect_timeout_spin.setValue(12) tab.cmhub_check_balance_checkbox.setChecked(True) tab._populate_cmhub_alias_combos( [ { "alias": "title-standard", "operation_type": "title", "requires_image": False, "pricing_status": "priced", "prices": [{"resolution": "1K", "points_cost": 1}], }, { "alias": "image-standard", "operation_type": "image", "requires_image": True, "pricing_status": "priced", "prices": [{"resolution": "1K", "points_cost": 5}], }, { "alias": "vision-standard", "operation_type": "vision", "requires_image": True, "pricing_status": "priced", "prices": [{"points_cost": 1}], }, ], title_selected="title-standard", image_selected="image-standard", vision_selected="vision-standard", ) with mock.patch("app.gui.QMessageBox.warning") as warning, \ mock.patch("app.gui.QMessageBox.information") as info: tab.save_app_settings() warning.assert_called_once() self.assertIn("data/config/cmhub.json", warning.call_args[0][2]) info.assert_called_once_with(tab, "保存设置", "设置已保存") saved = appconfig.load_config(cfg["config_path"]) self.assertEqual("cmhub", saved["ai"]["backend"]) self.assertEqual("https://cmhub.example.com", saved["ai"]["cmhub"]["base_url"]) self.assertEqual("https://cmhub.example.com", tab.cmhub_base_url_edit.text()) self.assertEqual("title-standard", saved["ai"]["cmhub"]["title_alias"]) self.assertEqual("image-standard", saved["ai"]["cmhub"]["image_alias"]) self.assertEqual("vision-standard", saved["ai"]["cmhub"]["vision_alias"]) self.assertEqual(12, saved["ai"]["cmhub"]["connect_timeout"]) self.assertTrue(saved["ai"]["cmhub"]["check_balance_before_batch"]) self.assertEqual( "sk-new-secret", appconfig.get_cmhub_api_key(path=cfg["cmhub_config_path"]), ) self.assertTrue(os.path.exists(cfg["ai_models_path"])) self.assert_removed(temp_dir) def test_settings_tab_cmhub_alias_refresh_filters_unpriced_and_keeps_saved(self): with self.make_temp_dir() as temp_dir: cfg = self.make_config(temp_dir) cfg["ai"] = appconfig.default_config()["ai"] cfg["ai"]["backend"] = "cmhub" cfg["ai"]["cmhub"] = { "base_url": "https://cmhub.example.com", "title_alias": "title-saved", "image_alias": "image-saved", "vision_alias": "vision-saved", "connect_timeout": 10, "check_balance_before_batch": False, } cfg["cmhub_config_path"] = os.path.join(temp_dir, "config", "cmhub.json") appconfig.save_cmhub_config({"api_key": "sk-cmhub-secret"}, path=cfg["cmhub_config_path"]) tab = SettingsTab(config=cfg, config_path=cfg["config_path"], ai_models_path=cfg["ai_models_path"]) self.addCleanup(tab.close) tab._on_cmhub_finished( { "ok": True, "points_balance": 55, "models": [ { "alias": "title-priced", "operation_type": "title", "requires_image": False, "pricing_status": "priced", "prices": [{"resolution": "512", "points_cost": 1}], }, { "alias": "title-free", "operation_type": "title", "pricing_status": "unpriced", "prices": [], }, { "alias": "image-priced", "operation_type": "image", "requires_image": True, "pricing_status": "priced", "prices": [{"resolution": "1K", "points_cost": 5}], }, { "alias": "vision-priced", "operation_type": "vision", "requires_image": True, "pricing_status": "priced", "prices": [{"points_cost": 1}], }, { "alias": "vision-without-image", "operation_type": "vision", "requires_image": False, "pricing_status": "priced", "prices": [{"points_cost": 1}], }, { "alias": "vision-free", "operation_type": "vision", "requires_image": True, "pricing_status": "unpriced", "prices": [], }, ], } ) title_aliases = [ tab.cmhub_title_alias_combo.itemData(index) for index in range(tab.cmhub_title_alias_combo.count()) ] image_labels = [ tab.cmhub_image_alias_combo.itemText(index) for index in range(tab.cmhub_image_alias_combo.count()) ] vision_aliases = [ tab.cmhub_vision_alias_combo.itemData(index) for index in range(tab.cmhub_vision_alias_combo.count()) ] self.assertIn("title-priced", title_aliases) self.assertIn("title-saved", title_aliases) self.assertNotIn("title-free", title_aliases) self.assertIn("默认档", tab.cmhub_title_alias_combo.itemText(0)) self.assertIn("512:1点", tab.cmhub_title_alias_combo.itemText(0)) self.assertTrue(any("需参考图" in label for label in image_labels)) self.assertTrue(any("默认档" in label for label in image_labels)) self.assertIn("vision-priced", vision_aliases) self.assertIn("vision-saved", vision_aliases) self.assertNotIn("vision-without-image", vision_aliases) self.assertNotIn("vision-free", vision_aliases) self.assertIn("余额 55", tab.cmhub_result_label.text()) self.assertIn("当前已保存值暂不可用", tab.cmhub_result_label.text()) self.assert_removed(temp_dir) def test_settings_tab_cmhub_success_message_shows_account_name(self): with self.make_temp_dir() as temp_dir: cfg = self.make_config(temp_dir) cfg["ai"] = appconfig.default_config()["ai"] cfg["ai"]["backend"] = "cmhub" cfg["ai"]["cmhub"] = { "base_url": "https://cmhub.example.com", "title_alias": "", "image_alias": "", "connect_timeout": 10, "check_balance_before_batch": False, } statuses = [] tab = SettingsTab( config=cfg, config_path=cfg["config_path"], ai_models_path=cfg["ai_models_path"], status_callback=statuses.append, ) self.addCleanup(tab.close) models = [ {"alias": "title-priced", "operation_type": "title", "pricing_status": "priced"}, {"alias": "image-priced", "operation_type": "image", "pricing_status": "priced"}, ] tab._on_cmhub_finished( { "ok": True, "models": models, "balance": { "user": "cmhub_user", "points_balance": 66, "account": {"username": "cmhub_user", "display_name": "主账号"}, }, "points_balance": 66, } ) self.assertIn("cmhub 账号「主账号」连接成功", tab.cmhub_result_label.text()) self.assertNotIn("cmhub 账号「cmhub_user」", tab.cmhub_result_label.text()) self.assertIn("余额 66", tab.cmhub_result_label.text()) self.assertEqual(tab.cmhub_result_label.text(), statuses[-1]) tab._on_cmhub_finished( { "ok": True, "models": models, "balance": {"user": {"name": "备用账号"}, "points_balance": 67}, "points_balance": 67, } ) self.assertIn("cmhub 账号「备用账号」连接成功", tab.cmhub_result_label.text()) tab._on_cmhub_finished( { "ok": True, "models": models, "balance": {"account": {"email": "owner@example.com"}, "points_balance": 77}, "points_balance": 77, } ) self.assertIn("cmhub 账号「o***r@example.com」连接成功", tab.cmhub_result_label.text()) self.assertNotIn("owner@example.com", tab.cmhub_result_label.text()) tab._on_cmhub_finished({"ok": True, "models": models, "points_balance": 88}) self.assertIn("cmhub 连接成功", tab.cmhub_result_label.text()) self.assertNotIn("cmhub 账号", tab.cmhub_result_label.text()) self.assert_removed(temp_dir) def test_settings_tab_tracks_dirty_state_and_programmatic_cmhub_refresh_is_clean(self): with self.make_temp_dir() as temp_dir: cfg = self.make_config(temp_dir) cfg["ai"] = appconfig.default_config()["ai"] cfg["ai"]["backend"] = "cmhub" cfg["ai"]["cmhub"] = { "base_url": "https://cmhub.old", "title_alias": "title-old", "image_alias": "image-old", "connect_timeout": 9, "check_balance_before_batch": False, } cfg["cmhub_config_path"] = os.path.join(temp_dir, "config", "cmhub.json") appconfig.save_cmhub_config({"api_key": "sk-old-secret"}, path=cfg["cmhub_config_path"]) tab = SettingsTab(config=cfg, config_path=cfg["config_path"], ai_models_path=cfg["ai_models_path"]) self.addCleanup(tab.close) self.assertFalse(tab.is_dirty()) self.assertTrue(tab.unsaved_changes_label.isHidden()) tab._on_cmhub_finished( { "ok": True, "models": [ { "alias": "title-fresh", "operation_type": "title", "pricing_status": "priced", "prices": [{"resolution": "1K", "points_cost": 1}], }, { "alias": "image-fresh", "operation_type": "image", "pricing_status": "priced", "requires_image": True, "prices": [{"resolution": "1K", "points_cost": 5}], }, ], } ) self.assertFalse(tab.is_dirty()) self.assertIn("记得点『保存设置』", tab.cmhub_result_label.text()) tab.cmhub_base_url_edit.setText("https://cmhub.example.com/api/v1/") self.assertTrue(tab.is_dirty()) self.assertFalse(tab.unsaved_changes_label.isHidden()) with mock.patch("app.gui.QMessageBox.information") as info: self.assertTrue(tab.save_app_settings()) info.assert_called_once_with(tab, "保存设置", "设置已保存") self.assertFalse(tab.is_dirty()) self.assertTrue(tab.unsaved_changes_label.isHidden()) self.assert_removed(temp_dir) def test_settings_tab_discard_unsaved_changes_reloads_saved_config_and_key(self): with self.make_temp_dir() as temp_dir: cfg = self.make_config(temp_dir) cfg["ai"] = appconfig.default_config()["ai"] cfg["ai"]["backend"] = "cmhub" cfg["ai"]["cmhub"] = { "base_url": "https://cmhub.saved", "title_alias": "title-saved", "image_alias": "image-saved", "connect_timeout": 11, "check_balance_before_batch": False, } cfg["cmhub_config_path"] = os.path.join(temp_dir, "config", "cmhub.json") appconfig.save_config( {key: value for key, value in cfg.items() if key not in {"config_path", "ai_models_path", "cmhub_config_path"}}, path=cfg["config_path"], ) appconfig.save_cmhub_config({"api_key": "sk-saved-secret"}, path=cfg["cmhub_config_path"]) tab = SettingsTab(config=cfg, config_path=cfg["config_path"], ai_models_path=cfg["ai_models_path"]) self.addCleanup(tab.close) tab.cmhub_base_url_edit.setText("https://cmhub.changed") tab.cmhub_api_key_edit.setText("sk-changed-secret") self.assertTrue(tab.is_dirty()) self.assertTrue(tab.discard_unsaved_changes()) self.assertFalse(tab.is_dirty()) self.assertEqual("https://cmhub.saved", tab.cmhub_base_url_edit.text()) self.assertEqual("sk-saved-secret", tab.cmhub_api_key_edit.text()) self.assertTrue(tab.unsaved_changes_label.isHidden()) self.assert_removed(temp_dir) def test_main_window_cancelled_tab_change_keeps_user_on_dirty_settings(self): with self.make_temp_dir() as temp_dir: window = MainWindow(config=self.make_config(temp_dir)) self.addCleanup(window.close) settings_index = TAB_TITLES.index("设置") window.tabs.setCurrentIndex(settings_index) settings_tab = window.tabs.widget(settings_index) settings_tab.cmhub_base_url_edit.setText("https://cmhub.changed") message_box, boxes = self.make_fake_message_box("取消") with mock.patch("app.gui.main_window.QMessageBox", message_box): window.tabs.setCurrentIndex(0) self.assertEqual(1, len(boxes)) self.assertEqual("未保存更改", boxes[0].title) self.assertEqual(["保存", "放弃", "取消"], list(boxes[0].buttons)) self.assertEqual(settings_index, window.tabs.currentIndex()) self.assertTrue(settings_tab.is_dirty()) settings_tab._set_dirty(False) self.assert_removed(temp_dir) def test_main_window_save_failure_keeps_user_on_dirty_settings(self): with self.make_temp_dir() as temp_dir: window = MainWindow(config=self.make_config(temp_dir)) self.addCleanup(window.close) settings_index = TAB_TITLES.index("设置") window.tabs.setCurrentIndex(settings_index) settings_tab = window.tabs.widget(settings_index) settings_tab.cmhub_base_url_edit.setText("https://cmhub.changed") message_box, _ = self.make_fake_message_box("保存") with mock.patch("app.gui.main_window.QMessageBox", message_box), \ mock.patch.object(settings_tab, "save_app_settings", return_value=False) as save_settings: window.tabs.setCurrentIndex(0) save_settings.assert_called_once_with() self.assertEqual(settings_index, window.tabs.currentIndex()) self.assertTrue(settings_tab.is_dirty()) settings_tab._set_dirty(False) self.assert_removed(temp_dir) def test_main_window_discard_tab_change_restores_saved_settings(self): with self.make_temp_dir() as temp_dir: cfg = self.make_config(temp_dir) cfg["ai"] = appconfig.default_config()["ai"] cfg["ai"]["backend"] = "cmhub" cfg["ai"]["cmhub"] = { "base_url": "https://cmhub.saved", "title_alias": "title-saved", "image_alias": "image-saved", "connect_timeout": 10, "check_balance_before_batch": False, } cfg["cmhub_config_path"] = os.path.join(temp_dir, "config", "cmhub.json") appconfig.save_config( {key: value for key, value in cfg.items() if key not in {"config_path", "ai_models_path", "cmhub_config_path"}}, path=cfg["config_path"], ) appconfig.save_cmhub_config({"api_key": "sk-saved-secret"}, path=cfg["cmhub_config_path"]) window = MainWindow(config=cfg, config_path=cfg["config_path"], ai_models_path=cfg["ai_models_path"]) self.addCleanup(window.close) settings_index = TAB_TITLES.index("设置") window.tabs.setCurrentIndex(settings_index) settings_tab = window.tabs.widget(settings_index) settings_tab.cmhub_base_url_edit.setText("https://cmhub.changed") settings_tab.cmhub_api_key_edit.setText("sk-changed-secret") message_box, _ = self.make_fake_message_box("放弃") with mock.patch("app.gui.main_window.QMessageBox", message_box): window.tabs.setCurrentIndex(0) self.assertEqual(0, window.tabs.currentIndex()) self.assertFalse(settings_tab.is_dirty()) self.assertEqual("https://cmhub.saved", settings_tab.cmhub_base_url_edit.text()) self.assertEqual("sk-saved-secret", settings_tab.cmhub_api_key_edit.text()) self.assert_removed(temp_dir) def test_main_window_close_cancel_ignores_dirty_settings_close(self): with self.make_temp_dir() as temp_dir: window = MainWindow(config=self.make_config(temp_dir)) self.addCleanup(window.close) settings_index = TAB_TITLES.index("设置") window.tabs.setCurrentIndex(settings_index) settings_tab = window.tabs.widget(settings_index) settings_tab.cmhub_base_url_edit.setText("https://cmhub.changed") event = SimpleNamespace(accepted=False, ignored=False) event.accept = lambda: setattr(event, "accepted", True) event.ignore = lambda: setattr(event, "ignored", True) message_box, _ = self.make_fake_message_box("取消") with mock.patch("app.gui.main_window.QMessageBox", message_box): window.closeEvent(event) self.assertFalse(event.accepted) self.assertTrue(event.ignored) settings_tab._set_dirty(False) self.assert_removed(temp_dir) def test_generate_tab_has_prompt_editors_and_task_table(self): with self.make_temp_dir() as temp_dir: title_prompt_path = os.path.join(temp_dir, "title_prompt.txt") title_templates_dir = os.path.join(temp_dir, "prompts", "title") cover_prompts_dir = os.path.join(temp_dir, "prompts", "cover") tab = GenerateTab( config=self.make_config(temp_dir), title_prompt_path=title_prompt_path, cover_prompts_dir=cover_prompts_dir, title_templates_dir=title_templates_dir, ) self.addCleanup(tab.close) self.assertIsInstance(tab.title_prompt_edit, QPlainTextEdit) self.assertIsInstance(tab.cover_prompt_edit, QPlainTextEdit) self.assertIsInstance(tab.task_table, QTableView) self.assertIsInstance(tab.generate_mode_combo, QComboBox) self.assertEqual("titleTemplateCombo", tab.title_template_combo.objectName()) self.assertEqual("newTitleTemplateButton", tab.new_title_template_button.objectName()) self.assertEqual("saveTitleTemplateButton", tab.save_title_template_button.objectName()) self.assertEqual("titleTemplateActionsButton", tab.title_template_actions_button.objectName()) self.assertEqual("标题提示词", tab.title_prompt_edit.placeholderText()) self.assertEqual("封面提示词", tab.cover_prompt_edit.placeholderText()) self.assertEqual("保存标题提示词", tab.save_title_button.text()) self.assertEqual("保存模板", tab.save_title_template_button.text()) self.assertEqual("插入旧标题", tab.insert_old_title_button.text()) self.assertEqual("开始生成", tab.generate_button.text()) self.assertEqual("停止", tab.stop_generate_button.text()) self.assertEqual("重置生成结果", tab.reset_generate_button.text()) self.assertEqual("打开图片文件夹", tab.open_image_dir_button.text()) self.assertEqual("openImageDirButton", tab.open_image_dir_button.objectName()) self.assertIn("当前批次文件夹", tab.open_image_dir_button.toolTip()) self.assertEqual("generateProductStatusFilter", tab.product_status_filter.objectName()) label_texts = [label.text() for label in tab.findChildren(QLabel)] self.assertIn("商品状态", label_texts) self.assertIn("生成状态", label_texts) self.assertEqual( ["只生成标题", "只生成封面", "生成标题和封面"], [tab.generate_mode_combo.itemText(index) for index in range(tab.generate_mode_combo.count())], ) self.assertEqual("title", tab.generate_mode_combo.currentData()) self.assertFalse(tab.stop_generate_button.isEnabled()) self.assertEqual("进度:标题0/0 · 图片0/0 · 失败0", tab.progress_label.text()) self.assertIsInstance(tab.title_progress_bar, QProgressBar) self.assertIsInstance(tab.cover_progress_bar, QProgressBar) self.assertEqual("标题 0/0", tab.title_progress_label.text()) self.assertEqual("图片 0/0", tab.cover_progress_label.text()) self.assertEqual("失败 0", tab.failed_progress_label.text()) self.assertTrue(tab.failed_progress_label.isHidden()) self.assertEqual("generateTitleElapsedLabel", tab.title_elapsed_label.objectName()) self.assertEqual("generateCoverElapsedLabel", tab.cover_elapsed_label.objectName()) self.assertEqual("生标题用时 0 秒", tab.title_elapsed_label.text()) self.assertEqual("生图用时 0 秒", tab.cover_elapsed_label.text()) self.assertEqual(tab.title_elapsed_label.width(), tab.cover_elapsed_label.width()) self.assertEqual("generateCmhubBalanceLabel", tab.cmhub_balance_label.objectName()) self.assertEqual("cmhub余额:未获取", tab.cmhub_balance_label.text()) self.assertTrue(tab.cmhub_balance_label.isHidden()) self.assertEqual(0, tab.title_progress_bar.value()) self.assertEqual(0, tab.cover_progress_bar.value()) self.assertEqual(1, tab.title_progress_bar.maximum()) self.assertEqual(1, tab.cover_progress_bar.maximum()) self.assertIn("蝦皮台灣站", tab.title_prompt_edit.toPlainText()) self.assertEqual("选择模板", tab.title_template_combo.currentText()) self.assertIn("默认", [tab.title_template_combo.itemText(index) for index in range(tab.title_template_combo.count())]) self.assertEqual("模板操作", tab.title_template_actions_button.text()) self.assertIs(tab.title_template_actions_menu, tab.title_template_actions_button.menu()) self.assertEqual( ["另存为", "重命名", "删除"], [action.text() for action in tab.title_template_actions_menu.actions()], ) self.assertEqual("cover_prompt", tab.cover_template_combo.currentText()) self.assertIn("商品标题:{新标题}", tab.cover_prompt_edit.toPlainText()) self.assertEqual("保存模板", tab.save_cover_template_button.text()) self.assertEqual("模板操作", tab.cover_template_actions_button.text()) self.assertIs(tab.cover_template_actions_menu, tab.cover_template_actions_button.menu()) self.assertEqual( ["另存为", "重命名", "删除"], [action.text() for action in tab.cover_template_actions_menu.actions()], ) self.assertFalse(hasattr(tab, "save_cover_template_as_button")) self.assertFalse(hasattr(tab, "rename_cover_template_button")) self.assertFalse(hasattr(tab, "delete_cover_template_button")) self.assertEqual( ["店铺", "商品ID", "旧标题", "新标题", "标题状态", "图片状态"], tab.model.HEADERS, ) self.assertEqual("任务 0/0 条", tab.summary_label.text()) self.assert_removed(temp_dir) def test_generate_table_splits_title_and_cover_statuses_and_widths(self): with self.make_temp_dir() as temp_dir: account = SimpleNamespace(alias="papa", account_name="papa 店铺") tasks = [ SimpleNamespace( alias="papa", account_name="papa 店铺", item_id="1001", old_title="旧标题1", new_title="", new_cover_path="", stage="imported", status="failed", last_error="读标题失败:页面错误", collect_attempts=1, generate_attempts=0, apply_attempts=0, committed=0, ), SimpleNamespace( alias="papa", account_name="papa 店铺", item_id="1002", old_title="旧标题2", new_title="", new_cover_path="", stage="collected", status="failed", last_error="请求生成标题失败:cmhub 上游失败", collect_attempts=1, generate_attempts=1, apply_attempts=0, committed=0, ), SimpleNamespace( alias="papa", account_name="papa 店铺", item_id="1003", old_title="旧标题3", new_title="新标题3", new_cover_path="", stage="generated", status="failed", last_error="请求生成封面失败:等待 cmhub 返回超时", collect_attempts=1, generate_attempts=2, apply_attempts=0, committed=0, ), SimpleNamespace( alias="papa", account_name="papa 店铺", item_id="1004", old_title="旧标题4", new_title="新标题4", new_cover_path="", stage="generated", status="success", last_error="", collect_attempts=1, generate_attempts=1, apply_attempts=0, committed=0, ), SimpleNamespace( alias="papa", account_name="papa 店铺", item_id="1005", old_title="旧标题5", new_title="新标题5", new_cover_path="new.jpg", stage="generated", status="success", last_error="", collect_attempts=1, generate_attempts=1, apply_attempts=0, committed=0, ), SimpleNamespace( alias="papa", account_name="papa 店铺", item_id="1006", old_title="旧标题6", new_title="", new_cover_path="cover-only.jpg", stage="generated", status="success", last_error="", collect_attempts=1, generate_attempts=1, apply_attempts=0, committed=0, ), ] model = gui.GenerateTaskTableModel() model.set_tasks(tasks, [account]) self.assertEqual("采集失败", model.index(0, 4).data()) self.assertEqual("采集失败", model.index(0, 5).data()) self.assertEqual("失败", model.index(1, 4).data()) self.assertEqual("未启用", model.index(1, 5).data()) self.assertEqual("已生成", model.index(2, 4).data()) self.assertEqual("失败", model.index(2, 5).data()) self.assertEqual("已生成", model.index(3, 4).data()) self.assertEqual("未启用", model.index(3, 5).data()) model.set_generate_cover_enabled(True) self.assertEqual("未开始", model.index(1, 5).data()) self.assertEqual("待生成", model.index(3, 5).data()) self.assertEqual("已生成", model.index(4, 5).data()) model.set_generate_mode("cover") self.assertEqual("待生成", model.index(1, 5).data()) self.assertEqual("待生成", model.index(3, 5).data()) self.assertEqual("已生成", model.index(4, 5).data()) self.assertEqual("待生成", model.index(5, 4).data()) self.assertEqual("已生成", model.index(5, 5).data()) tab = GenerateTab(config=self.make_config(temp_dir)) self.addCleanup(tab.close) tab.task_table.resize(600, 240) tab.task_table.show() QApplication.processEvents() tab._apply_task_table_column_widths() header = tab.task_table.horizontalHeader() self.assertLess(header.sectionSize(1), header.sectionSize(0)) self.assertAlmostEqual( header.sectionSize(4), header.sectionSize(5), delta=5, ) self.assertAlmostEqual( header.sectionSize(2), header.sectionSize(3), delta=5, ) self.assertLess(header.sectionSize(4), header.sectionSize(1)) self.assertGreater(header.sectionSize(2), header.sectionSize(0)) self.assert_removed(temp_dir) def test_generate_table_emits_preview_only_for_left_double_click(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, "account_name": "主店", "alias": "alias-a", "item_id": "51100639510", } ], path=cfg["db_path"], ) tab = GenerateTab(config=cfg) self.addCleanup(tab.close) tab.resize(1000, 600) tab.show() QApplication.processEvents() index = tab.model.index(0, 0) rect = tab.task_table.visualRect(index) self.assertTrue(rect.isValid()) previewed_rows = [] tab.task_table.leftDoubleClicked.connect( lambda clicked_index: previewed_rows.append(clicked_index.row()) ) with mock.patch( "app.gui.tabs.generate.CoverGalleryDialog.exec", return_value=0, ) as execute: QTest.mouseDClick(tab.task_table.viewport(), Qt.RightButton, Qt.NoModifier, rect.center()) QApplication.processEvents() self.assertEqual([], previewed_rows) execute.assert_not_called() QTest.mouseDClick(tab.task_table.viewport(), Qt.LeftButton, Qt.NoModifier, rect.center()) QApplication.processEvents() self.assertEqual([0], previewed_rows) execute.assert_called_once() self.assert_removed(temp_dir) def test_generate_tab_opens_current_row_image_directory(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) accounts.create_account("副店", "alias-b", debug_port=9223, 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-b", "item_id": "51100639511", }, ], path=cfg["db_path"], ) tasks = db.list_tasks(batch_id=batch_id, path=cfg["db_path"]) first_dir = os.path.join(temp_dir, "legacy-a") second_dir = os.path.join(temp_dir, "legacy-b") first_cover = self.write_test_image(os.path.join(first_dir, "old.jpg")) second_cover = self.write_test_image(os.path.join(second_dir, "new.jpg")) db.set_collected(tasks[0].id, "旧标题A", first_cover, path=cfg["db_path"]) db.set_collected(tasks[1].id, "旧标题B", "missing-old.jpg", path=cfg["db_path"]) db.set_generated(tasks[1].id, "新标题B", second_cover, path=cfg["db_path"]) tab = GenerateTab(config=cfg) self.addCleanup(tab.close) selection = tab.task_table.selectionModel() selection.select(tab.model.index(0, 0), QItemSelectionModel.Select | QItemSelectionModel.Rows) selection.select(tab.model.index(1, 0), QItemSelectionModel.Select | QItemSelectionModel.Rows) tab.task_table.setCurrentIndex(tab.model.index(1, 0)) with mock.patch( "app.gui.tabs.generate.file_manager.open_in_file_manager", return_value=second_dir, ) as open_dir: tab.open_image_directory() open_dir.assert_called_once_with(os.path.abspath(second_dir)) self.assert_removed(temp_dir) def test_generate_tab_open_image_directory_falls_back_to_canonical_account_dir(self): with self.make_temp_dir() as temp_dir: cfg = self.make_config(temp_dir) account = 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", }, ], path=cfg["db_path"], ) task = db.list_tasks(batch_id=batch_id, path=cfg["db_path"])[0] canonical_dir = os.path.dirname( image_paths.task_image_path(cfg["image_dir"], task, account=account, suffix="new") ) os.makedirs(canonical_dir, exist_ok=True) tab = GenerateTab(config=cfg) self.addCleanup(tab.close) tab.task_table.selectRow(0) tab.task_table.setCurrentIndex(tab.model.index(0, 0)) with mock.patch( "app.gui.tabs.generate.file_manager.open_in_file_manager", return_value=canonical_dir, ) as open_dir: tab.open_image_directory() open_dir.assert_called_once_with(os.path.abspath(canonical_dir)) self.assert_removed(temp_dir) def test_generate_tab_opens_batch_or_root_directory_without_selected_row(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"]) image_root = os.path.abspath(cfg["image_dir"]) batch_dir = os.path.join(image_root, batch_id) os.makedirs(batch_dir, exist_ok=True) tab = GenerateTab(config=cfg) self.addCleanup(tab.close) tab.batch_filter.setCurrentIndex(tab.batch_filter.findData(batch_id)) tab.task_table.clearSelection() tab.task_table.setCurrentIndex(QModelIndex()) with mock.patch( "app.gui.tabs.generate.file_manager.open_in_file_manager", return_value=batch_dir, ) as open_dir: tab.open_image_directory() open_dir.assert_called_once_with(os.path.abspath(batch_dir)) tab.batch_filter.setCurrentIndex(tab.batch_filter.findData(None)) tab.task_table.clearSelection() tab.task_table.setCurrentIndex(QModelIndex()) with mock.patch( "app.gui.tabs.generate.file_manager.open_in_file_manager", return_value=image_root, ) as open_dir: tab.open_image_directory() open_dir.assert_called_once_with(image_root) self.assert_removed(temp_dir) def test_generate_tab_open_image_directory_warns_without_creating_missing_dir(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"]) batch_dir = os.path.join(os.path.abspath(cfg["image_dir"]), batch_id) tab = GenerateTab(config=cfg) self.addCleanup(tab.close) tab.batch_filter.setCurrentIndex(tab.batch_filter.findData(batch_id)) tab.task_table.clearSelection() tab.task_table.setCurrentIndex(QModelIndex()) with mock.patch("app.gui.tabs.generate.QMessageBox.warning") as warning, mock.patch( "app.gui.tabs.generate.file_manager.open_in_file_manager" ) as open_dir: tab.open_image_directory() warning.assert_called_once() self.assertIn("该批次还没有图片", warning.call_args[0][2]) open_dir.assert_not_called() self.assertFalse(os.path.exists(batch_dir)) self.assert_removed(temp_dir) def test_generate_tab_open_image_directory_warns_when_selected_account_unmatched(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, "account_name": "Excel主店", "alias": "missing-alias", "item_id": "51100639510", }, ], path=cfg["db_path"], ) tab = GenerateTab(config=cfg) self.addCleanup(tab.close) tab.task_table.selectRow(0) tab.task_table.setCurrentIndex(tab.model.index(0, 0)) with mock.patch("app.gui.tabs.generate.QMessageBox.warning") as warning, mock.patch( "app.gui.tabs.generate.file_manager.open_in_file_manager" ) as open_dir: tab.open_image_directory() warning.assert_called_once() self.assertIn("选中任务没有匹配账号", warning.call_args[0][2]) open_dir.assert_not_called() self.assert_removed(temp_dir) def test_open_in_file_manager_uses_platform_file_manager(self): with self.make_temp_dir() as temp_dir: target = os.path.join(temp_dir, "images") os.makedirs(target) with mock.patch("app.gui.file_manager.sys.platform", "win32"), mock.patch( "app.gui.file_manager.os.startfile", create=True, ) as startfile: self.assertEqual(os.path.abspath(target), file_manager.open_in_file_manager(target)) startfile.assert_called_once_with(os.path.abspath(target)) with mock.patch("app.gui.file_manager.sys.platform", "darwin"), mock.patch( "app.gui.file_manager.subprocess.Popen" ) as popen: self.assertEqual(os.path.abspath(target), file_manager.open_in_file_manager(target)) popen.assert_called_once_with( ["open", os.path.abspath(target)], stdout=file_manager.subprocess.DEVNULL, stderr=file_manager.subprocess.DEVNULL, shell=False, ) with mock.patch("app.gui.file_manager.sys.platform", "linux"), mock.patch( "app.gui.file_manager.subprocess.Popen" ) as popen: self.assertEqual(os.path.abspath(target), file_manager.open_in_file_manager(target)) popen.assert_called_once_with( ["xdg-open", os.path.abspath(target)], stdout=file_manager.subprocess.DEVNULL, stderr=file_manager.subprocess.DEVNULL, shell=False, ) with self.assertRaises(FileNotFoundError): file_manager.open_in_file_manager(os.path.join(temp_dir, "missing")) self.assert_removed(temp_dir) def test_generate_elapsed_timer_tracks_title_then_cover(self): with self.make_temp_dir() as temp_dir: tab = GenerateTab(config=self.make_config(temp_dir)) self.addCleanup(tab.close) self.assertEqual("生标题用时 0 秒", tab.title_elapsed_label.text()) self.assertEqual("生图用时 0 秒", tab.cover_elapsed_label.text()) self.assertFalse(tab._generate_elapsed_timer.isActive()) with mock.patch("app.gui.tabs.generate.time.monotonic", return_value=100.0): tab._start_generation_elapsed(title_total=2, cover_total=1) self.assertTrue(tab._generate_elapsed_timer.isActive()) with mock.patch("app.gui.tabs.generate.time.monotonic", return_value=103.0): tab._refresh_elapsed_labels() self.assertEqual("生标题用时 3 秒", tab.title_elapsed_label.text()) self.assertEqual("生图用时 0 秒", tab.cover_elapsed_label.text()) with mock.patch("app.gui.tabs.generate.time.monotonic", return_value=105.0): tab._sync_generation_elapsed( { "total": 3, "title_total": 2, "title_done": 2, "cover_total": 1, "cover_done": 0, "generate_cover": True, } ) self.assertEqual("生标题用时 5 秒", tab.title_elapsed_label.text()) self.assertEqual("生图用时 0 秒", tab.cover_elapsed_label.text()) with mock.patch("app.gui.tabs.generate.time.monotonic", return_value=109.0): tab._refresh_elapsed_labels() self.assertEqual("生标题用时 5 秒", tab.title_elapsed_label.text()) self.assertEqual("生图用时 4 秒", tab.cover_elapsed_label.text()) with mock.patch("app.gui.tabs.generate.time.monotonic", return_value=112.0): tab._finish_generation_elapsed( { "total": 3, "title_total": 2, "title_done": 2, "cover_total": 1, "cover_done": 1, "generate_cover": True, } ) self.assertEqual("生标题用时 5 秒", tab.title_elapsed_label.text()) self.assertEqual("生图用时 7 秒", tab.cover_elapsed_label.text()) self.assertFalse(tab._generate_elapsed_timer.isActive()) self.assert_removed(temp_dir) def test_generate_cover_progress_bar_marks_failed_segment(self): with self.make_temp_dir() as temp_dir: tab = GenerateTab(config=self.make_config(temp_dir)) self.addCleanup(tab.close) tab._update_generate_progress( { "total": 13, "title_total": 13, "title_done": 13, "cover_total": 13, "cover_done": 11, "failed": 2, "generate_cover": True, } ) self.assertEqual("图片 11/13,失败 2", tab.cover_progress_label.text()) self.assertEqual((11, 2, 13), tab.cover_progress_bar.segments()) self.assertEqual(13, tab.cover_progress_bar.maximum()) self.assertEqual(13, tab.cover_progress_bar.value()) self.assert_removed(temp_dir) def test_generate_finished_shows_chinese_summary_for_failed_images(self): with self.make_temp_dir() as temp_dir: tab = GenerateTab(config=self.make_config(temp_dir)) self.addCleanup(tab.close) tab._title_elapsed_seconds = 120 tab._cover_elapsed_seconds = 372 payload = { "total": 13, "title_total": 13, "title_done": 13, "cover_total": 13, "cover_done": 11, "failed": 2, "generate_cover": True, } with mock.patch.object(tab, "refresh_tasks"), \ mock.patch("app.gui.tabs.generate.QMessageBox.warning") as warning, \ mock.patch("app.gui.tabs.generate.QMessageBox.information") as information: tab._on_generate_finished(payload) warning.assert_called_once() information.assert_not_called() self.assertEqual("AI生成完成,有失败任务", warning.call_args[0][1]) self.assertIn("标题 13/13", warning.call_args[0][2]) self.assertIn("图片 11/13", warning.call_args[0][2]) self.assertIn("失败 2", warning.call_args[0][2]) self.assertIn("用时 492 秒", warning.call_args[0][2]) self.assert_removed(temp_dir) def test_generate_cancelled_shows_chinese_summary_dialog(self): with self.make_temp_dir() as temp_dir: tab = GenerateTab(config=self.make_config(temp_dir)) self.addCleanup(tab.close) tab._title_elapsed_seconds = 80 tab._cover_elapsed_seconds = 100 payload = { "total": 13, "title_total": 13, "title_done": 8, "cover_total": 13, "cover_done": 5, "failed": 1, "generate_cover": True, "cancelled": True, } with mock.patch.object(tab, "refresh_tasks"), \ mock.patch("app.gui.tabs.generate.QMessageBox.warning") as warning: tab._on_generate_cancelled(payload) warning.assert_called_once() self.assertEqual("AI生成已停止", warning.call_args[0][1]) self.assertIn("标题 8/13", warning.call_args[0][2]) self.assertIn("图片 5/13", warning.call_args[0][2]) self.assertIn("未完成任务可再次点击开始生成继续处理", warning.call_args[0][2]) self.assert_removed(temp_dir) def test_generate_tab_shows_cmhub_balance_and_billing_error(self): with self.make_temp_dir() as temp_dir: cfg = self.make_config(temp_dir) cfg["ai"] = appconfig.ai_config(cfg) cfg["ai"]["backend"] = "cmhub" statuses = [] tab = GenerateTab(config=cfg, status_callback=statuses.append) self.addCleanup(tab.close) self.assertTrue(tab.cmhub_balance_label.isHidden()) self.assertEqual("cmhub余额:未获取", tab.cmhub_balance_label.text()) tab._on_generate_progress( { "total": 1, "title_done": 1, "cover_done": 0, "cover_total": 0, "failed": 0, "generate_cover": False, "points_balance": 88, } ) self.assertEqual("cmhub余额:88", tab.cmhub_balance_label.text()) self.assertTrue(tab.cmhub_balance_label.isHidden()) with mock.patch("app.gui.QMessageBox.warning") as warning: tab._on_generate_finished( { "total": 1, "title_done": 0, "cover_done": 0, "cover_total": 0, "failed": 1, "billing_error": { "code": "insufficient_points", "message": "点数不足,请先充值。本轮未开始任务将停止。", }, } ) warning.assert_called_once() self.assertIn("点数不足,请先充值", warning.call_args[0][2]) self.assertIn("AI 生成已中止", statuses[-1]) self.assert_removed(temp_dir) def test_generate_tab_persists_generate_mode_selection(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) self.assertFalse(appconfig.ai_config(cfg)["generate_cover"]) tab.generate_mode_combo.setCurrentIndex(tab.generate_mode_combo.findData("cover")) saved = appconfig.load_config(cfg["config_path"]) self.assertTrue(saved["ai"]["generate_cover"]) self.assertEqual("cover", saved["ai"]["generate_mode"]) self.assertTrue(cfg["ai"]["generate_cover"]) self.assertEqual("cover", cfg["ai"]["generate_mode"]) self.assertIn("只生成封面", statuses[-1]) self.assert_removed(temp_dir) def test_generate_tab_refreshes_to_generate_filter_when_mode_changes_to_cover(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", } ], 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_generated(task.id, "已有标题", None, path=cfg["db_path"]) tab = GenerateTab(config=cfg) self.addCleanup(tab.close) tab.status_filter.setCurrentIndex(tab.status_filter.findData("to_generate")) self.assertEqual(0, tab.model.rowCount()) tab.generate_mode_combo.setCurrentIndex(tab.generate_mode_combo.findData("cover")) self.assertEqual(1, tab.model.rowCount()) self.assertEqual("51100639510", tab.model.task_at(0).item_id) self.assert_removed(temp_dir) def test_generate_tab_seeds_default_prompts_for_new_user_data(self): with self.make_temp_dir() as temp_dir: cfg = self.make_config(temp_dir) title_prompt_path = os.path.join(temp_dir, "title_prompt.txt") title_templates_dir = os.path.join(temp_dir, "prompts", "title") cover_prompts_dir = os.path.join(temp_dir, "prompts", "cover") tab = GenerateTab( config=cfg, title_prompt_path=title_prompt_path, cover_prompts_dir=cover_prompts_dir, title_templates_dir=title_templates_dir, ) self.addCleanup(tab.close) self.assertIn("蝦皮台灣站", tab.title_prompt_edit.toPlainText()) self.assertEqual("cover_prompt", tab.cover_template_combo.currentText()) self.assertIn("商品标题:{新标题}", tab.cover_prompt_edit.toPlainText()) self.assertTrue(os.path.exists(title_prompt_path)) self.assertTrue(os.path.exists(os.path.join(title_templates_dir, "默认.txt"))) self.assertTrue(os.path.exists(os.path.join(cover_prompts_dir, "cover_prompt.txt"))) self.assert_removed(temp_dir) def test_generate_tab_manages_prompt_files_and_preview(self): with self.make_temp_dir() as temp_dir: cfg = self.make_config(temp_dir) title_prompt_path = os.path.join(temp_dir, "title_prompt.txt") title_templates_dir = os.path.join(temp_dir, "prompts", "title") cover_prompts_dir = os.path.join(temp_dir, "prompts", "cover") prompts.save_title_prompt("标题启动回显", title_prompt_path) prompts.save_title_template("标题基础", "模板标题 {旧标题}", title_templates_dir) prompts.save_cover_template( "基础", "把{旧标题}变成{新标题},商品{商品id},店铺{店铺}", cover_prompts_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", } ], 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_generated(task.id, "新标题", "new.jpg", path=cfg["db_path"]) statuses = [] tab = GenerateTab( config=cfg, status_callback=statuses.append, title_prompt_path=title_prompt_path, cover_prompts_dir=cover_prompts_dir, title_templates_dir=title_templates_dir, ) self.addCleanup(tab.close) self.assertEqual("标题启动回显", tab.title_prompt_edit.toPlainText()) self.assertEqual("选择模板", tab.title_template_combo.currentText()) self.assertIn("标题基础", prompts.list_title_templates(title_templates_dir)) self.assertEqual("基础", tab.cover_template_combo.currentText()) self.assertIn("{旧标题}", tab.cover_prompt_edit.toPlainText()) tab.title_prompt_edit.setPlainText("新标题提示词") tab.save_title_prompt() self.assertEqual("新标题提示词", prompts.load_title_prompt(title_prompt_path)) tab.title_prompt_edit.setPlainText("AB") cursor = tab.title_prompt_edit.textCursor() cursor.setPosition(1) tab.title_prompt_edit.setTextCursor(cursor) tab.insert_old_title_placeholder() self.assertEqual("A{旧标题}B", tab.title_prompt_edit.toPlainText()) self.assertEqual("新标题提示词", prompts.load_title_prompt(title_prompt_path)) tab.title_template_combo.setCurrentIndex(tab.title_template_combo.findData("标题基础")) self.assertEqual("模板标题 {旧标题}", tab.title_prompt_edit.toPlainText()) tab.title_prompt_edit.setPlainText("标题另存内容") with mock.patch("app.gui.QInputDialog.getText", return_value=("标题另存", True)): tab.save_title_template_as_action.trigger() self.assertEqual("标题另存", tab.title_template_combo.currentText()) self.assertEqual( "标题另存内容", prompts.load_title_template("标题另存", title_templates_dir), ) self.assertEqual("新标题提示词", prompts.load_title_prompt(title_prompt_path)) with mock.patch("app.gui.QInputDialog.getText", return_value=("标题改名", True)): tab.rename_title_template_action.trigger() self.assertEqual("标题改名", tab.title_template_combo.currentText()) self.assertIn("标题改名", prompts.list_title_templates(title_templates_dir)) tab.title_prompt_edit.setPlainText("标题模板保存") tab.save_title_template() self.assertEqual( "标题模板保存", prompts.load_title_template("标题改名", title_templates_dir), ) tab.cover_prompt_edit.setPlainText("另存模板 {新标题}") with mock.patch("app.gui.QInputDialog.getText", return_value=("另存", True)): tab.save_cover_template_as_action.trigger() self.assertEqual("另存", tab.cover_template_combo.currentText()) self.assertEqual("另存模板 {新标题}", prompts.load_cover_template("另存", cover_prompts_dir)) with mock.patch("app.gui.QInputDialog.getText", return_value=("改名", True)): tab.rename_cover_template_action.trigger() self.assertEqual("改名", tab.cover_template_combo.currentText()) self.assertIn("改名", prompts.list_cover_templates(cover_prompts_dir)) tab.cover_prompt_edit.setPlainText("预览 {旧标题} {新标题} {商品id} {店铺}") tab.task_table.selectRow(0) with mock.patch("app.gui.QMessageBox.information") as info: tab.preview_cover_prompt() self.assertIn("预览 旧标题 新标题 51100639510 主店", info.call_args[0][2]) with mock.patch("app.gui.QDialog.exec", return_value=0) as exec_dialog: tab.show_task_images(tab.model.index(0, 0)) exec_dialog.assert_called_once() tab.cover_prompt_edit.moveCursor(QTextCursor.End) tab.insert_title_placeholder() self.assertTrue(tab.cover_prompt_edit.toPlainText().endswith("{新标题}")) with mock.patch("app.gui.QMessageBox.question", return_value=gui.QMessageBox.Yes): tab.delete_cover_template_action.trigger() self.assertNotIn("改名", prompts.list_cover_templates(cover_prompts_dir)) with mock.patch("app.gui.QMessageBox.question", return_value=gui.QMessageBox.Yes): tab.delete_title_template_action.trigger() self.assertNotIn("标题改名", prompts.list_title_templates(title_templates_dir)) self.assert_removed(temp_dir) def test_generate_tab_cover_preview_uses_old_title_when_new_title_is_missing(self): with self.make_temp_dir() as temp_dir: cfg = self.make_config(temp_dir) cfg["ai"] = appconfig.ai_config(cfg) cfg["ai"]["generate_mode"] = "cover" cfg["ai"]["generate_cover"] = True 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", } ], 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"]) tab = GenerateTab( config=cfg, title_prompt_path=os.path.join(temp_dir, "title_prompt.txt"), cover_prompts_dir=os.path.join(temp_dir, "prompts", "cover"), title_templates_dir=os.path.join(temp_dir, "prompts", "title"), ) self.addCleanup(tab.close) tab.cover_prompt_edit.setPlainText("新:{新标题};旧:{旧标题}") tab.task_table.selectRow(0) with mock.patch("app.gui.QMessageBox.information") as information: tab.preview_cover_prompt() self.assertEqual("新:旧标题;旧:旧标题", information.call_args[0][2]) self.assert_removed(temp_dir) def test_cover_gallery_lists_candidates_and_selects_current_cover(self): with self.make_temp_dir() as temp_dir: cfg = self.make_config(temp_dir) 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") prefix = os.path.splitext(canonical)[0] archive_newer = self.write_test_image(f"{prefix}_20260709101000.jpg") archive_older = self.write_test_image(f"{prefix}_20260709094800.jpg") old_candidate = f"{prefix.replace('_new', '_old')}.jpg" self.write_test_image(canonical) self.write_test_image(old_candidate) db.set_collected(task.id, "旧标题", old_cover, path=cfg["db_path"]) db.set_generated(task.id, "新标题", canonical, path=cfg["db_path"]) task = db.get_task(task.id, path=cfg["db_path"]) dialog = CoverGalleryDialog(task, cfg["image_dir"], cfg["db_path"], account=account) self.addCleanup(dialog.close) self.assertEqual([canonical, archive_newer, archive_older], dialog.candidates) self.assertTrue(dialog.candidate_buttons[canonical].isChecked()) self.assertEqual(canonical, dialog.selected_path) self.assertNotIn(old_candidate, dialog.candidates) renamed_archive = archive_newer.replace(".jpg", "_renamed.jpg") os.rename(archive_newer, renamed_archive) self.assertTrue(os.path.exists(renamed_archive)) self.assert_removed(temp_dir) def test_cover_gallery_candidate_content_width_fits_multiple_candidates(self): with self.make_temp_dir() as temp_dir: cfg, account, tasks, canonicals, _archives = self._cover_gallery_task_set(temp_dir, count=1) prefix = os.path.splitext(canonicals[0])[0] self.write_test_image(f"{prefix}_20260709101100.jpg") self.write_test_image(f"{prefix}_20260709101200.jpg") dialog = CoverGalleryDialog(tasks[0], cfg["image_dir"], cfg["db_path"], account=account) self.addCleanup(dialog.close) self.assertGreaterEqual(len(dialog.candidates), 4) expected_width = ( len(dialog.candidates) * (dialog.candidate_thumbnail_size + 16) + (len(dialog.candidates) - 1) * dialog.candidate_layout.spacing() ) self.assertIs(dialog.candidate_scroll.widget(), dialog.candidate_content) self.assertGreaterEqual(dialog.candidate_content.minimumWidth(), expected_width) self.assertGreaterEqual(dialog.candidate_content.width(), expected_width) self.assert_removed(temp_dir) def test_cover_gallery_candidate_images_resize_with_viewport_and_keep_ratio(self): with self.make_temp_dir() as temp_dir: cfg, account, task, canonical = self._cover_gallery_task(temp_dir) self.write_test_image(canonical, width=240, height=120) dialog = CoverGalleryDialog(task, cfg["image_dir"], cfg["db_path"], account=account) self.addCleanup(dialog.close) dialog.show() dialog.resize(650, 460) QApplication.processEvents() dialog._candidate_resize_timer.stop() dialog._update_candidate_thumbnail_layout() small_size = dialog.candidate_thumbnail_size thumbnail = dialog.candidate_thumbnails[canonical] small_pixmap = thumbnail.pixmap() self.assertEqual(QSize(small_size, small_size), thumbnail.size()) self.assertIsNotNone(small_pixmap) self.assertAlmostEqual(2.0, small_pixmap.width() / small_pixmap.height(), places=1) dialog.resize(1100, 700) QApplication.processEvents() dialog._candidate_resize_timer.stop() dialog._update_candidate_thumbnail_layout() large_size = dialog.candidate_thumbnail_size large_pixmap = thumbnail.pixmap() self.assertGreater(large_size, small_size) self.assertLessEqual(large_size, dialog.MAX_THUMBNAIL_SIZE) self.assertEqual(QSize(large_size, large_size), thumbnail.size()) self.assertAlmostEqual(2.0, large_pixmap.width() / large_pixmap.height(), places=1) expected_width = len(dialog.candidates) * (large_size + 16) expected_width += max(0, len(dialog.candidates) - 1) * dialog.candidate_layout.spacing() self.assertGreaterEqual(dialog.candidate_content.minimumWidth(), expected_width) self.assert_removed(temp_dir) def test_cover_gallery_broken_candidate_keeps_failure_placeholder_after_resize(self): with self.make_temp_dir() as temp_dir: cfg, account, task, canonical = self._cover_gallery_task(temp_dir) with open(canonical, "wb") as fh: fh.write(b"not-an-image") dialog = CoverGalleryDialog(task, cfg["image_dir"], cfg["db_path"], account=account) self.addCleanup(dialog.close) dialog.resize(1000, 680) dialog._update_candidate_thumbnail_layout() thumbnail = dialog.candidate_thumbnails[canonical] self.assertTrue(dialog.candidate_images[canonical].isNull()) self.assertEqual("图片读取失败", thumbnail.text()) self.assertEqual( QSize(dialog.candidate_thumbnail_size, dialog.candidate_thumbnail_size), thumbnail.size(), ) self.assert_removed(temp_dir) def test_cover_gallery_switch_task_resyncs_candidate_content_width(self): with self.make_temp_dir() as temp_dir: cfg, account, tasks, canonicals, archives = self._cover_gallery_task_set(temp_dir, count=2) os.remove(archives[0]) prefix = os.path.splitext(canonicals[1])[0] self.write_test_image(f"{prefix}_20260709101100.jpg") self.write_test_image(f"{prefix}_20260709101200.jpg") dialog = CoverGalleryDialog( tasks[0], cfg["image_dir"], cfg["db_path"], account=account, visible_tasks=tasks, task_index=0, account_by_alias={"alias-a": account}, ) self.addCleanup(dialog.close) initial_width = dialog.candidate_content.minimumWidth() self.assertTrue(dialog.switch_task(1)) expected_width = ( len(dialog.candidates) * (dialog.candidate_thumbnail_size + 16) + (len(dialog.candidates) - 1) * dialog.candidate_layout.spacing() ) self.assertEqual(tasks[1].id, dialog.task.id) self.assertGreater(len(dialog.candidates), 2) self.assertGreater(dialog.candidate_content.minimumWidth(), initial_width) self.assertGreaterEqual(dialog.candidate_content.minimumWidth(), expected_width) self.assertGreaterEqual(dialog.candidate_content.width(), expected_width) self.assert_removed(temp_dir) def test_cover_gallery_switch_task_keeps_responsive_thumbnail_size(self): with self.make_temp_dir() as temp_dir: cfg, account, tasks, _canonicals, _archives = self._cover_gallery_task_set( temp_dir, count=2, ) dialog = CoverGalleryDialog( tasks[0], cfg["image_dir"], cfg["db_path"], account=account, visible_tasks=tasks, task_index=0, account_by_alias={"alias-a": account}, ) self.addCleanup(dialog.close) dialog.resize(1100, 700) dialog.show() QApplication.processEvents() dialog._candidate_resize_timer.stop() dialog._update_candidate_thumbnail_layout() responsive_size = dialog.candidate_thumbnail_size self.assertGreater(responsive_size, dialog.THUMBNAIL_SIZE) self.assertTrue(dialog.switch_task(1)) dialog._candidate_resize_timer.stop() dialog._update_candidate_thumbnail_layout() self.assertEqual(responsive_size, dialog.candidate_thumbnail_size) for thumbnail in dialog.candidate_thumbnails.values(): self.assertEqual(QSize(responsive_size, responsive_size), thumbnail.size()) self.assert_removed(temp_dir) def test_cover_gallery_arrow_switch_syncs_full_candidate_card_geometry(self): with self.make_temp_dir() as temp_dir: cfg, account, tasks, canonicals, archives = self._cover_gallery_task_set( temp_dir, count=2, ) os.remove(archives[0]) second_prefix = os.path.splitext(canonicals[1])[0] long_candidate = self.write_test_image( f"{second_prefix}_20260709101100_候选图片文件名很长用于验证自适应布局.jpg" ) self.write_test_image(f"{second_prefix}_20260709101200.jpg") broken_candidate = f"{second_prefix}_20260709101300.jpg" with open(broken_candidate, "wb") as file_handle: file_handle.write(b"not-an-image") dialog = CoverGalleryDialog( tasks[0], cfg["image_dir"], cfg["db_path"], account=account, visible_tasks=tasks, task_index=0, account_by_alias={"alias-a": account}, ) self.addCleanup(dialog.close) dialog.resize(1100, 700) dialog.show() QApplication.processEvents() dialog._candidate_resize_timer.stop() dialog._update_candidate_thumbnail_layout() initial_thumbnail_size = dialog.candidate_thumbnail_size dialog.next_button.setFocus() down_event = QKeyEvent(QKeyEvent.KeyPress, gui.Qt.Key_Down, gui.Qt.NoModifier) QApplication.sendEvent(dialog.next_button, down_event) self.assertTrue(down_event.isAccepted()) self.assertEqual(tasks[1].id, dialog.task.id) self.assertGreaterEqual(len(dialog.candidates), 5) self.assertLess(dialog.candidate_thumbnail_size, initial_thumbnail_size) self.assertEqual(0, dialog.candidate_scroll.horizontalScrollBar().value()) self.assert_cover_candidate_cards_do_not_overlap(dialog, check_horizontal=False) self.assertEqual("图片读取失败", dialog.candidate_thumbnails[broken_candidate].text()) self.assertNotEqual( os.path.basename(long_candidate), dialog.candidate_buttons[long_candidate].text(), ) self.assertIn( os.path.basename(long_candidate), dialog.candidate_buttons[long_candidate].toolTip(), ) QApplication.processEvents() dialog._candidate_resize_timer.stop() dialog._update_candidate_thumbnail_layout() QApplication.processEvents() self.assert_cover_candidate_cards_do_not_overlap(dialog) horizontal_bar = dialog.candidate_scroll.horizontalScrollBar() self.assertGreater(horizontal_bar.maximum(), 0) horizontal_bar.setValue(horizontal_bar.maximum()) self.assertGreater(horizontal_bar.value(), 0) self.assertTrue(dialog.switch_task(-1)) self.assertEqual(0, horizontal_bar.value()) self.assert_cover_candidate_cards_do_not_overlap(dialog, check_horizontal=False) QApplication.processEvents() self.assert_cover_candidate_cards_do_not_overlap(dialog) self.assert_removed(temp_dir) def test_cover_gallery_save_switches_current_cover(self): with self.make_temp_dir() as temp_dir: cfg = self.make_config(temp_dir) 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] canonical = image_paths.task_image_path(cfg["image_dir"], task, account, "new") selected = self.write_test_image(os.path.splitext(canonical)[0] + "_20260709101000.jpg") self.write_test_image(canonical) db.set_generated(task.id, "新标题", canonical, path=cfg["db_path"]) task = db.get_task(task.id, path=cfg["db_path"]) dialog = CoverGalleryDialog(task, cfg["image_dir"], cfg["db_path"], account=account) self.addCleanup(dialog.close) dialog.candidate_buttons[selected].setChecked(True) self.assertTrue(dialog.save_selection()) updated = db.get_task(task.id, path=cfg["db_path"]) self.assertEqual(os.path.abspath(selected), updated.new_cover_path) self.assertEqual("generated", updated.stage) self.assertEqual("pending", updated.status) self.assertTrue(dialog.changed) self.assertEqual(0, dialog.result()) self.assertEqual("关闭", dialog.cancel_button.text()) self.assertEqual("已保存为本次更新封面", dialog.save_hint_label.text()) self.assertEqual(os.path.abspath(selected), dialog.current_path) self.assertTrue(dialog.candidate_buttons[selected].isChecked()) self.assertEqual("当前生效", dialog.candidate_buttons[selected].text()) self.assert_removed(temp_dir) def test_cover_gallery_committed_cancel_does_not_write_db(self): with self.make_temp_dir() as temp_dir: cfg = self.make_config(temp_dir) 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] canonical = image_paths.task_image_path(cfg["image_dir"], task, account, "new") selected = self.write_test_image(os.path.splitext(canonical)[0] + "_20260709101000.jpg") self.write_test_image(canonical) db.set_generated(task.id, "新标题", canonical, path=cfg["db_path"]) db.set_applied(task.id, True, path=cfg["db_path"]) task = db.get_task(task.id, path=cfg["db_path"]) dialog = CoverGalleryDialog(task, cfg["image_dir"], cfg["db_path"], account=account) self.addCleanup(dialog.close) dialog.candidate_buttons[selected].setChecked(True) message_box, boxes = self.make_fake_message_box("取消") with mock.patch("app.gui.tabs.generate.QMessageBox", message_box): self.assertFalse(dialog.save_selection()) updated = db.get_task(task.id, path=cfg["db_path"]) self.assertEqual(canonical, updated.new_cover_path) self.assertEqual("applied", updated.stage) self.assertEqual(1, updated.committed) self.assertIn("重复更新会再次提交线上", boxes[0].text) self.assertFalse(dialog.changed) dialog.selected_path = dialog.current_path self.assert_removed(temp_dir) def test_cover_gallery_handles_missing_current_and_empty_candidates(self): with self.make_temp_dir() as temp_dir: cfg = self.make_config(temp_dir) 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", }, { "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"]) missing_current = os.path.join(temp_dir, "missing.jpg") db.set_generated(first.id, "新标题A", missing_current, path=cfg["db_path"]) first_candidate = image_paths.task_image_path(cfg["image_dir"], first, account, "new") self.write_test_image(first_candidate) first = db.get_task(first.id, path=cfg["db_path"]) missing_dialog = CoverGalleryDialog(first, cfg["image_dir"], cfg["db_path"], account=account) self.addCleanup(missing_dialog.close) self.assertIsNone(missing_dialog.selected_path) self.assertFalse(missing_dialog.save_button.isEnabled()) self.assertIn("当前生效封面文件不存在", missing_dialog.status_label.text()) db.set_generated(second.id, "新标题B", os.path.join(temp_dir, "none.jpg"), path=cfg["db_path"]) second = db.get_task(second.id, path=cfg["db_path"]) empty_dialog = CoverGalleryDialog(second, cfg["image_dir"], cfg["db_path"], account=account) self.addCleanup(empty_dialog.close) self.assertEqual([], empty_dialog.candidates) self.assertFalse(empty_dialog.save_button.isEnabled()) self.assertIn("暂无生成封面图片", empty_dialog.status_label.text()) self.assert_removed(temp_dir) def test_original_image_dialog_title_contains_resolution(self): with self.make_temp_dir() as temp_dir: image_path = self.write_test_image(os.path.join(temp_dir, "cover.jpg"), width=64, height=32) dialog = OriginalImageDialog(image_path) self.addCleanup(dialog.close) self.assertIn("cover.jpg", dialog.windowTitle()) self.assertIn("64x32", dialog.windowTitle()) self.assert_removed(temp_dir) def test_original_image_dialog_switches_between_fit_and_original_size(self): with self.make_temp_dir() as temp_dir: image_path = self.write_test_image( os.path.join(temp_dir, "large-cover.jpg"), width=1200, height=800, ) dialog = OriginalImageDialog(image_path) self.addCleanup(dialog.close) dialog.resize(600, 450) dialog.show() QApplication.processEvents() dialog._resize_timer.stop() dialog._update_image_display() fit_pixmap = dialog.image_label.pixmap() viewport_size = dialog.scroll.viewport().size() self.assertTrue(dialog.fit_to_window) self.assertEqual("原始尺寸", dialog.display_mode_button.text()) self.assertLessEqual(fit_pixmap.width(), viewport_size.width()) self.assertLessEqual(fit_pixmap.height(), viewport_size.height()) self.assertAlmostEqual(1.5, fit_pixmap.width() / fit_pixmap.height(), places=1) dialog.toggle_display_mode() original_pixmap = dialog.image_label.pixmap() self.assertFalse(dialog.fit_to_window) self.assertEqual("适应窗口", dialog.display_mode_button.text()) self.assertEqual(QSize(1200, 800), original_pixmap.size()) self.assertEqual(QSize(1200, 800), dialog.image_label.size()) dialog.toggle_display_mode() self.assertTrue(dialog.fit_to_window) self.assertEqual("原始尺寸", dialog.display_mode_button.text()) self.assertLessEqual(dialog.image_label.pixmap().width(), dialog.scroll.viewport().width()) self.assertLessEqual(dialog.image_label.pixmap().height(), dialog.scroll.viewport().height()) self.assert_removed(temp_dir) def test_original_image_dialog_keeps_chinese_failure_placeholder(self): with self.make_temp_dir() as temp_dir: dialog = OriginalImageDialog(os.path.join(temp_dir, "missing.jpg")) self.addCleanup(dialog.close) dialog.resize(520, 400) dialog._update_image_display() self.assertTrue(dialog.image.isNull()) self.assertEqual("图片读取失败", dialog.image_label.text()) self.assertFalse(dialog.display_mode_button.isEnabled()) self.assert_removed(temp_dir) def test_shared_image_preview_defaults_to_fit_and_keeps_fixed_zoom_on_resize(self): with self.make_temp_dir() as temp_dir: image_path = self.write_test_image( os.path.join(temp_dir, "suite-large.jpg"), width=1200, height=800, ) dialog = ProductSuitePreviewDialog(image_path, "商品原图预览") self.addCleanup(dialog.close) dialog.resize(600, 450) dialog.show() QApplication.processEvents() dialog._resize_timer.stop() dialog._render() fit_pixmap = dialog.image_label.pixmap() viewport = dialog.scroll.viewport().size() self.assertTrue(dialog.fit_to_window) self.assertEqual("适应窗口", dialog.zoom_label.text()) self.assertIn("1200x800", dialog.windowTitle()) self.assertLessEqual(fit_pixmap.width(), viewport.width()) self.assertLessEqual(fit_pixmap.height(), viewport.height()) self.assertAlmostEqual(1.5, fit_pixmap.width() / fit_pixmap.height(), places=1) dialog.show_actual_size() QApplication.processEvents() self.assertFalse(dialog.fit_to_window) self.assertEqual("100%", dialog.zoom_label.text()) self.assertEqual(QSize(1200, 800), dialog.image_label.pixmap().size()) self.assertEqual(QSize(1200, 800), dialog.image_label.size()) self.assertGreater(dialog.scroll.horizontalScrollBar().maximum(), 0) self.assertGreater(dialog.scroll.verticalScrollBar().maximum(), 0) dialog.resize(520, 360) QApplication.processEvents() dialog._resize_timer.stop() dialog._render() self.assertEqual(QSize(1200, 800), dialog.image_label.pixmap().size()) dialog.show_fit() self.assertTrue(dialog.fit_to_window) self.assertEqual("适应窗口", dialog.zoom_label.text()) self.assertLessEqual( dialog.image_label.pixmap().width(), dialog.scroll.viewport().width(), ) self.assertLessEqual( dialog.image_label.pixmap().height(), dialog.scroll.viewport().height(), ) self.assert_removed(temp_dir) def test_shared_image_preview_bounds_zoom_and_does_not_enlarge_small_fit_image(self): with self.make_temp_dir() as temp_dir: image_path = self.write_test_image( os.path.join(temp_dir, "suite-small.jpg"), width=200, height=100, ) dialog = ProductSuitePreviewDialog(image_path, "图片预览") self.addCleanup(dialog.close) dialog.resize(700, 500) dialog.show() QApplication.processEvents() dialog._resize_timer.stop() dialog._render() self.assertEqual(QSize(200, 100), dialog.image_label.pixmap().size()) for _index in range(20): dialog.zoom_out() self.assertEqual(0.25, dialog.zoom_factor) self.assertEqual("25%", dialog.zoom_label.text()) self.assertFalse(dialog.zoom_out_button.isEnabled()) for _index in range(20): dialog.zoom_in() self.assertEqual(4.0, dialog.zoom_factor) self.assertEqual("400%", dialog.zoom_label.text()) self.assertFalse(dialog.zoom_in_button.isEnabled()) self.assertEqual(QSize(800, 400), dialog.image_label.pixmap().size()) self.assert_removed(temp_dir) def test_shared_image_preview_legacy_wrapper_and_missing_file_state(self): with self.make_temp_dir() as temp_dir: image_path = self.write_test_image( os.path.join(temp_dir, "legacy.jpg"), width=320, height=240, ) asset = SimpleNamespace(id=7, kind="original", local_path=image_path) legacy = ImageStudioPreviewDialog(asset) self.addCleanup(legacy.close) legacy.show() QApplication.processEvents() legacy._resize_timer.stop() legacy._render() self.assertIn("AI工场图片预览", legacy.windowTitle()) self.assertIn("320x240", legacy.windowTitle()) self.assertEqual("适应窗口", legacy.zoom_label.text()) missing_path = os.path.join(temp_dir, "missing-private-name.jpg") missing = ProductSuitePreviewDialog(missing_path, "商品原图预览") self.addCleanup(missing.close) missing._render() self.assertEqual("图片文件不存在或无法读取", missing.image_label.text()) self.assertEqual("无法预览", missing.zoom_label.text()) self.assertFalse(missing.fit_button.isEnabled()) self.assertFalse(missing.actual_size_button.isEnabled()) self.assertNotIn(missing_path, missing.windowTitle()) self.assert_removed(temp_dir) def test_cover_gallery_previous_next_switch_visible_tasks_without_resizing(self): with self.make_temp_dir() as temp_dir: cfg, account, tasks, canonicals, _archives = self._cover_gallery_task_set(temp_dir, count=3) dialog = CoverGalleryDialog( tasks[1], cfg["image_dir"], cfg["db_path"], account=account, visible_tasks=tasks, task_index=1, account_by_alias={"alias-a": account}, ) self.addCleanup(dialog.close) dialog.resize(820, 520) dialog.move(25, 35) size = dialog.size() position = dialog.pos() self.assertTrue(dialog.previous_button.isEnabled()) self.assertTrue(dialog.next_button.isEnabled()) self.assertTrue(dialog.switch_task(1)) self.assertEqual(tasks[2].id, dialog.task.id) self.assertTrue(dialog.previous_button.isEnabled()) self.assertFalse(dialog.next_button.isEnabled()) self.assertEqual(canonicals[2], dialog.selected_path) self.assertEqual(size, dialog.size()) self.assertEqual(position, dialog.pos()) self.assert_removed(temp_dir) def test_cover_gallery_arrow_keys_switch_tasks_without_changing_radio_choice(self): with self.make_temp_dir() as temp_dir: cfg, account, tasks, canonicals, archives = self._cover_gallery_task_set(temp_dir, count=2) dialog = CoverGalleryDialog( tasks[0], cfg["image_dir"], cfg["db_path"], account=account, visible_tasks=tasks, task_index=0, account_by_alias={"alias-a": account}, ) self.addCleanup(dialog.close) dialog.candidate_buttons[canonicals[0]].setFocus() event = QKeyEvent(QKeyEvent.KeyPress, gui.Qt.Key_Down, gui.Qt.NoModifier) QApplication.sendEvent(dialog.candidate_buttons[canonicals[0]], event) self.assertTrue(event.isAccepted()) self.assertEqual(tasks[1].id, dialog.task.id) self.assertEqual(canonicals[1], dialog.selected_path) self.assertFalse(dialog.candidate_buttons[archives[1]].isChecked()) self.assert_removed(temp_dir) def test_cover_gallery_arrow_keys_switch_when_button_has_focus(self): with self.make_temp_dir() as temp_dir: cfg, account, tasks, canonicals, _archives = self._cover_gallery_task_set(temp_dir, count=2) dialog = CoverGalleryDialog( tasks[0], cfg["image_dir"], cfg["db_path"], account=account, visible_tasks=tasks, task_index=0, account_by_alias={"alias-a": account}, ) self.addCleanup(dialog.close) dialog.next_button.setFocus() event = QKeyEvent(QKeyEvent.KeyPress, gui.Qt.Key_Down, gui.Qt.NoModifier) QApplication.sendEvent(dialog.next_button, event) self.assertTrue(event.isAccepted()) self.assertEqual(tasks[1].id, dialog.task.id) self.assertEqual(canonicals[1], dialog.selected_path) self.assert_removed(temp_dir) def test_cover_gallery_arrow_keys_do_not_switch_when_text_input_has_focus(self): with self.make_temp_dir() as temp_dir: cfg, account, tasks, _canonicals, _archives = self._cover_gallery_task_set(temp_dir, count=2) dialog = CoverGalleryDialog( tasks[0], cfg["image_dir"], cfg["db_path"], account=account, visible_tasks=tasks, task_index=0, account_by_alias={"alias-a": account}, ) self.addCleanup(dialog.close) input_widget = QLineEdit(dialog) event = QKeyEvent(QKeyEvent.KeyPress, gui.Qt.Key_Down, gui.Qt.NoModifier) with mock.patch( "app.gui.tabs.generate.QApplication.focusWidget", return_value=input_widget, ): QApplication.sendEvent(input_widget, event) self.assertEqual(tasks[0].id, dialog.task.id) self.assert_removed(temp_dir) def test_cover_gallery_unsaved_switch_save_discard_and_cancel_paths(self): with self.make_temp_dir() as temp_dir: cfg, account, tasks, canonicals, archives = self._cover_gallery_task_set(temp_dir, count=2) save_dialog = CoverGalleryDialog( tasks[0], cfg["image_dir"], cfg["db_path"], account=account, visible_tasks=tasks, task_index=0, account_by_alias={"alias-a": account}, ) self.addCleanup(save_dialog.close) save_dialog.candidate_buttons[archives[0]].setChecked(True) message_box, _ = self.make_fake_message_box("保存") with mock.patch("app.gui.tabs.generate.QMessageBox", message_box): self.assertTrue(save_dialog.switch_task(1)) self.assertEqual(os.path.abspath(archives[0]), db.get_task(tasks[0].id, path=cfg["db_path"]).new_cover_path) self.assertEqual(tasks[1].id, save_dialog.task.id) db.update_generated_cover(tasks[0].id, canonicals[0], path=cfg["db_path"]) tasks = [db.get_task(task.id, path=cfg["db_path"]) for task in tasks] discard_dialog = CoverGalleryDialog( tasks[0], cfg["image_dir"], cfg["db_path"], account=account, visible_tasks=tasks, task_index=0, account_by_alias={"alias-a": account}, ) self.addCleanup(discard_dialog.close) discard_dialog.candidate_buttons[archives[0]].setChecked(True) message_box, _ = self.make_fake_message_box("不保存") with mock.patch("app.gui.tabs.generate.QMessageBox", message_box): self.assertTrue(discard_dialog.switch_task(1)) self.assertEqual(canonicals[0], db.get_task(tasks[0].id, path=cfg["db_path"]).new_cover_path) self.assertEqual(tasks[1].id, discard_dialog.task.id) cancel_dialog = CoverGalleryDialog( tasks[0], cfg["image_dir"], cfg["db_path"], account=account, visible_tasks=tasks, task_index=0, account_by_alias={"alias-a": account}, ) self.addCleanup(cancel_dialog.close) cancel_dialog.candidate_buttons[archives[0]].setChecked(True) message_box, _ = self.make_fake_message_box("取消") with mock.patch("app.gui.tabs.generate.QMessageBox", message_box): self.assertFalse(cancel_dialog.switch_task(1)) self.assertEqual(canonicals[0], db.get_task(tasks[0].id, path=cfg["db_path"]).new_cover_path) self.assertEqual(tasks[0].id, cancel_dialog.task.id) cancel_dialog.selected_path = cancel_dialog.current_path self.assert_removed(temp_dir) def test_cover_gallery_unsaved_close_uses_same_three_choice_guard(self): class FakeCloseEvent: def __init__(self): self.accepted = False self.ignored = False def accept(self): self.accepted = True def ignore(self): self.ignored = True with self.make_temp_dir() as temp_dir: cfg, account, tasks, canonicals, archives = self._cover_gallery_task_set(temp_dir, count=1) dialog = CoverGalleryDialog(tasks[0], cfg["image_dir"], cfg["db_path"], account=account) self.addCleanup(dialog.close) dialog.candidate_buttons[archives[0]].setChecked(True) event = FakeCloseEvent() message_box, _ = self.make_fake_message_box("取消") with mock.patch("app.gui.tabs.generate.QMessageBox", message_box): dialog.closeEvent(event) self.assertTrue(event.ignored) self.assertFalse(event.accepted) self.assertEqual(canonicals[0], db.get_task(tasks[0].id, path=cfg["db_path"]).new_cover_path) message_box, _ = self.make_fake_message_box("不保存") event = FakeCloseEvent() with mock.patch("app.gui.tabs.generate.QMessageBox", message_box): dialog.closeEvent(event) self.assertTrue(event.accepted) dialog.selected_path = dialog.current_path self.assert_removed(temp_dir) def test_cover_gallery_unsaved_save_for_committed_task_keeps_warning(self): with self.make_temp_dir() as temp_dir: cfg, account, tasks, _canonicals, archives = self._cover_gallery_task_set(temp_dir, count=2) db.set_applied(tasks[0].id, True, path=cfg["db_path"]) tasks = [db.get_task(task.id, path=cfg["db_path"]) for task in tasks] dialog = CoverGalleryDialog( tasks[0], cfg["image_dir"], cfg["db_path"], account=account, visible_tasks=tasks, task_index=0, account_by_alias={"alias-a": account}, ) self.addCleanup(dialog.close) dialog.candidate_buttons[archives[0]].setChecked(True) message_box, boxes = self.make_sequence_message_box(["保存", "确认保存"]) with mock.patch("app.gui.tabs.generate.QMessageBox", message_box): self.assertTrue(dialog.switch_task(1)) self.assertEqual(os.path.abspath(archives[0]), db.get_task(tasks[0].id, path=cfg["db_path"]).new_cover_path) self.assertIn("未保存", boxes[0].title) self.assertIn("不会回滚蝦皮", boxes[1].text) self.assertEqual(tasks[1].id, dialog.task.id) self.assert_removed(temp_dir) def test_cover_gallery_reset_image_clears_pointer_without_worker_or_confirmation(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, ) 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.gui.tabs.generate.GenerateWorker", FakeGenerateWorker): self.assertTrue(dialog.reset_cover_image()) updated = db.get_task(task.id, path=cfg["db_path"]) self.assertEqual("新标题", updated.new_title) self.assertIsNone(updated.new_cover_path) self.assertEqual("generated", updated.stage) self.assertEqual("success", updated.status) self.assertFalse(os.path.exists(canonical)) self.assertIsNone(dialog.current_path) self.assertIsNone(dialog.selected_path) self.assertFalse(dialog.save_button.isEnabled()) self.assertFalse(dialog._has_unsaved_selection()) self.assertTrue(dialog.candidates) self.assertIn("已重置图片", dialog.status_label.text()) self.assertEqual([], boxes) self.assertEqual([], FakeGenerateWorker.instances) self.assert_removed(temp_dir) def test_cover_gallery_reset_image_committed_cancel_does_not_write_db(self): with self.make_temp_dir() as temp_dir: cfg, account, task, canonical = self._cover_gallery_task(temp_dir) db.set_applied(task.id, True, path=cfg["db_path"]) task = db.get_task(task.id, path=cfg["db_path"]) dialog = CoverGalleryDialog( task, cfg["image_dir"], cfg["db_path"], account=account, ) self.addCleanup(dialog.close) message_box, boxes = self.make_fake_message_box("取消") with mock.patch("app.gui.tabs.generate.QMessageBox", message_box): self.assertFalse(dialog.reset_cover_image()) updated = db.get_task(task.id, path=cfg["db_path"]) self.assertEqual(canonical, updated.new_cover_path) self.assertEqual("applied", updated.stage) self.assertEqual(1, updated.committed) self.assertTrue(os.path.exists(canonical)) self.assertIn("本地重置不会回滚蝦皮", boxes[0].text) self.assert_removed(temp_dir) def test_cover_gallery_reset_image_committed_confirm_clears_pointer(self): with self.make_temp_dir() as temp_dir: cfg, account, task, canonical = self._cover_gallery_task(temp_dir) db.set_applied(task.id, True, path=cfg["db_path"]) task = db.get_task(task.id, path=cfg["db_path"]) refreshed = [] dialog = CoverGalleryDialog( task, cfg["image_dir"], cfg["db_path"], account=account, gallery_updated_callback=lambda task: refreshed.append(task.id), ) self.addCleanup(dialog.close) message_box, boxes = self.make_fake_message_box("确认重置") with mock.patch("app.gui.tabs.generate.QMessageBox", message_box): self.assertTrue(dialog.reset_cover_image()) updated = db.get_task(task.id, path=cfg["db_path"]) self.assertIsNone(updated.new_cover_path) self.assertEqual("generated", updated.stage) self.assertEqual(1, updated.committed) self.assertIn("再次提交线上", boxes[0].text) self.assertIn(task.id, refreshed) self.assert_removed(temp_dir) def test_cover_gallery_reset_image_keeps_archive_selectable_for_restore(self): with self.make_temp_dir() as temp_dir: cfg, account, tasks, canonicals, _archives = self._cover_gallery_task_set(temp_dir, count=2) dialog = CoverGalleryDialog( tasks[0], cfg["image_dir"], cfg["db_path"], account=account, visible_tasks=tasks, task_index=0, account_by_alias={"alias-a": account}, ) self.addCleanup(dialog.close) self.assertTrue(dialog.reset_cover_image()) self.assertTrue(dialog.candidates) archived = dialog.candidates[0] self.assertIsNone(db.get_task(tasks[0].id, path=cfg["db_path"]).new_cover_path) message_box, boxes = self.make_fake_message_box("取消") with mock.patch("app.gui.tabs.generate.QMessageBox", message_box): self.assertTrue(dialog.switch_task(1)) self.assertEqual([], boxes) self.assertEqual(tasks[1].id, dialog.task.id) self.assertTrue(dialog.switch_task(-1)) dialog.candidate_buttons[archived].setChecked(True) self.assertTrue(dialog.save_selection()) self.assertEqual(os.path.abspath(archived), db.get_task(tasks[0].id, path=cfg["db_path"]).new_cover_path) self.assert_removed(temp_dir) def test_cover_gallery_reset_image_archive_failure_keeps_db_pointer(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, ) 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.reset_cover_image()) 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_reset_image_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, bulk_running_checker=lambda: True, ) self.addCleanup(dialog.close) self.assertFalse(dialog.reset_cover_button.isEnabled()) self.assertIn("AI 生成正在进行", dialog.reset_cover_button.toolTip()) self.assertFalse(dialog.reset_cover_image()) self.assertEqual(canonical, db.get_task(task.id, path=cfg["db_path"]).new_cover_path) self.assert_removed(temp_dir) def test_generate_tab_allows_editing_generated_title_locally(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", } ], 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_generated(task.id, "AI标题", "new.jpg", path=cfg["db_path"]) db.mark_failed(task.id, "generate", "标题需微调", path=cfg["db_path"]) statuses = [] tab = GenerateTab(config=cfg, status_callback=statuses.append) self.addCleanup(tab.close) index = tab.model.index(0, 3) self.assertTrue(bool(tab.model.flags(index) & gui.Qt.ItemIsEditable)) self.assertTrue(tab.model.setData(index, "人工微调标题", gui.Qt.EditRole)) updated = db.get_task(task.id, path=cfg["db_path"]) self.assertEqual("generated", updated.stage) self.assertEqual("pending", updated.status) self.assertEqual("人工微调标题", updated.new_title) self.assertIsNone(updated.last_error) self.assertEqual("人工微调标题", tab.model.index(0, 3).data()) self.assertIn("已修改新标题", statuses[-1]) self.assert_removed(temp_dir) def test_generate_tab_resets_selected_generated_result_and_writes_reset_log(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", } ], path=cfg["db_path"], ) task = db.list_tasks(batch_id=batch_id, path=cfg["db_path"])[0] new_cover = os.path.join(temp_dir, "new.jpg") with open(new_cover, "wb") as fh: fh.write(b"jpeg") db.set_collected(task.id, "旧标题", "old.jpg", path=cfg["db_path"]) db.set_generated(task.id, "新标题", new_cover, path=cfg["db_path"]) statuses = [] tab = GenerateTab(config=cfg, status_callback=statuses.append) self.addCleanup(tab.close) tab.task_table.selectRow(0) message_box, boxes = self.make_fake_message_box("重置全部") with mock.patch("app.gui.tabs.generate.QMessageBox", message_box): tab.reset_generated_result() message = boxes[0].text self.assertIn("默认不删除本地新封面文件", message) self.assertIn("重置全部", message) updated = db.get_task(task.id, path=cfg["db_path"]) self.assertEqual("generated", updated.stage) self.assertEqual("success", updated.status) self.assertIsNone(updated.new_title) self.assertIsNone(updated.new_cover_path) self.assertFalse(os.path.exists(new_cover)) archived_covers = [ filename for filename in os.listdir(temp_dir) if filename.startswith("new_") and filename.endswith(".jpg") ] self.assertEqual(1, len(archived_covers)) self.assertTrue(os.path.exists(os.path.join(temp_dir, archived_covers[0]))) run_log = db.list_run_logs(limit=1, run_type="reset", path=cfg["db_path"])[0] self.assertEqual("done", run_log.status) self.assertEqual("reset_generated", run_log.options["action"]) self.assertEqual("all", run_log.options["mode"]) events = db.list_run_log_events(run_log.id, path=cfg["db_path"]) self.assertTrue(any("action=reset_generated" in event.message for event in events)) self.assertIn("已重置生成结果", statuses[-1]) self.assert_removed(temp_dir) def test_generate_tab_resets_titles_for_current_filtered_tasks(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"], ) tasks = db.list_tasks(batch_id=batch_id, path=cfg["db_path"]) for index, task in enumerate(tasks): db.set_collected(task.id, f"旧标题{index}", f"old-{index}.jpg", path=cfg["db_path"]) db.set_generated(task.id, f"新标题{index}", f"new-{index}.jpg", path=cfg["db_path"]) statuses = [] tab = GenerateTab(config=cfg, status_callback=statuses.append) self.addCleanup(tab.close) tab.task_table.clearSelection() message_box, boxes = self.make_fake_message_box("重置标题") with mock.patch("app.gui.tabs.generate.QMessageBox", message_box): tab.reset_generated_result() self.assertIn("当前筛选结果 2 条", boxes[0].text) updated = db.list_tasks(batch_id=batch_id, path=cfg["db_path"]) self.assertEqual([None, None], [task.new_title for task in updated]) self.assertEqual(["new-0.jpg", "new-1.jpg"], [task.new_cover_path for task in updated]) self.assertTrue(all(task.stage == "generated" for task in updated)) run_log = db.list_run_logs(limit=1, run_type="reset", path=cfg["db_path"])[0] self.assertEqual("filtered", run_log.options["scope"]) self.assertEqual("title", run_log.options["mode"]) self.assertTrue(run_log.options["reset_title"]) self.assertFalse(run_log.options["reset_cover"]) self.assertEqual(2, run_log.total) self.assertIn("已重置生成结果:2 条,内容:标题", statuses[-1]) self.assert_removed(temp_dir) def test_generate_tab_resets_selected_covers_and_keeps_manual_titles(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"], ) tasks = db.list_tasks(batch_id=batch_id, path=cfg["db_path"]) for index, task in enumerate(tasks): db.set_collected(task.id, f"旧标题{index}", f"old-{index}.jpg", path=cfg["db_path"]) db.set_generated(task.id, f"手动标题{index}", f"new-{index}.jpg", path=cfg["db_path"]) db.set_applied(tasks[0].id, True, path=cfg["db_path"]) tab = GenerateTab(config=cfg) self.addCleanup(tab.close) selection = tab.task_table.selectionModel() for row in [0, 1]: selection.select( tab.model.index(row, 0), QItemSelectionModel.Select | QItemSelectionModel.Rows, ) message_box, boxes = self.make_fake_message_box("重置封面") with mock.patch("app.gui.tabs.generate.QMessageBox", message_box): tab.reset_generated_result() message = boxes[0].text self.assertIn("选中任务 2 条", message) self.assertIn("已经提交过线上", message) self.assertIn("重生成后再更新会再次提交线上", message) updated = db.list_tasks(batch_id=batch_id, path=cfg["db_path"]) self.assertEqual(["手动标题0", "手动标题1"], [task.new_title for task in updated]) self.assertEqual([None, None], [task.new_cover_path for task in updated]) self.assertEqual("generated", updated[0].stage) self.assertEqual(1, updated[0].committed) run_log = db.list_run_logs(limit=1, run_type="reset", path=cfg["db_path"])[0] self.assertEqual("selected", run_log.options["scope"]) self.assertEqual("cover", run_log.options["mode"]) self.assertFalse(run_log.options["reset_title"]) self.assertTrue(run_log.options["reset_cover"]) self.assertEqual(1, run_log.options["committed_count"]) self.assert_removed(temp_dir) def test_generate_tab_disables_reset_while_generation_running(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_generate_running(True) self.assertFalse(tab.reset_generate_button.isEnabled()) self.assertFalse(tab.product_status_filter.isEnabled()) self.assertFalse(tab.title_template_combo.isEnabled()) self.assertFalse(tab.new_title_template_button.isEnabled()) self.assertFalse(tab.save_title_template_button.isEnabled()) self.assertFalse(tab.title_template_actions_button.isEnabled()) self.assertFalse(tab.save_title_template_as_action.isEnabled()) self.assertFalse(tab.rename_title_template_action.isEnabled()) self.assertFalse(tab.delete_title_template_action.isEnabled()) self.assertFalse(tab.cover_template_combo.isEnabled()) tab.generate_thread = object() tab.reset_generated_result() self.assertIn("AI 生成正在进行,不能重置", statuses[-1]) self.assert_removed(temp_dir) def test_generate_worker_calls_generate_batch_and_emits_signals(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" cfg["ai"]["generate_cover"] = True account = 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", } ], 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"]) tasks = db.list_tasks(batch_id=batch_id, path=cfg["db_path"]) progress = [] rows = [] logs = [] def fake_generate_batch(tasks_arg, prompt_values, ai_cfg=None, on_progress=None, should_stop=None): self.assertEqual(tasks, tasks_arg) self.assertEqual({"title": "标题提示", "cover": "封面提示"}, prompt_values) self.assertEqual(account, ai_cfg["account_by_alias"]["alias-a"]) self.assertEqual(cfg["db_path"], ai_cfg["db_path"]) self.assertIn("on_event", ai_cfg) self.assertIn("on_error", ai_cfg) self.assertFalse(should_stop()) on_progress({"total": 1, "title_done": 1, "cover_done": 0, "failed": 0}) ai_cfg["on_event"]( { "task": tasks[0], "phase": "cover", "step": "cover_submit", "result": "start", } ) ai_cfg["on_event"]( { "task": tasks[0], "phase": "cover", "step": "cover_request", "result": "retry", "attempt": 1, "attempts": 3, "detail": "timeout token=SECRET-TOKEN", "level": "warning", } ) ai_cfg["on_event"]( { "task": tasks[0], "phase": "cover", "step": "cover_request", "result": "success", "detail": "cmhub 已返回 image_url,耗时 91.2秒", } ) ai_cfg["on_event"]( { "task": tasks[0], "phase": "cover", "step": "cover_download", "result": "success", "detail": "下载完成,1.3MB,耗时 12.4秒", } ) ai_cfg["on_event"]( { "task": tasks[0], "phase": "cover", "step": "cover_download", "result": "warning", "detail": "图片下载较慢,已用 24.0秒,大小 1.3MB", "level": "warning", } ) ai_cfg["on_event"]( { "task": tasks[0], "phase": "cover", "step": "cover_image_url", "result": "debug", "detail": "cmhub 图片 URL:https://cdn.example.com/generated.png?token=SECRET-TOKEN", "level": "warning", "debug_only": True, } ) ai_cfg["on_event"]( { "task": tasks[0], "phase": "cover", "step": "cover_save", "result": "success", "detail": "JPEG 已保存,耗时 1.1秒,文件 220.0KB", } ) ai_cfg["on_task_update"](tasks[0].id, {"stage": "generated"}) return {"ok": True, "total": 1, "title_done": 1, "cover_done": 1, "failed": 0} worker = GenerateWorker( tasks, {"title": "标题提示", "cover": "封面提示"}, db_path=cfg["db_path"], config=cfg, ) worker.progress.connect(progress.append) worker.row_updated.connect(lambda task_id, fields: rows.append((task_id, fields))) worker.log.connect(logs.append) with mock.patch("app.gui.ai.generate_batch", side_effect=fake_generate_batch): summary = worker.execute() self.assertEqual(1, summary["cover_done"]) self.assertIsInstance(summary["run_id"], int) self.assertEqual([batch_id], summary["batch_ids"]) self.assertEqual([{"total": 1, "title_done": 1, "cover_done": 0, "failed": 0}], progress) self.assertEqual([(tasks[0].id, {"stage": "generated"})], rows) run_log = db.list_run_logs(limit=1, run_type="generate", path=cfg["db_path"])[0] self.assertEqual(summary["run_id"], run_log.id) joined_logs = "\n".join(logs) self.assertIn("[开始] 本轮生成 1 条", joined_logs) self.assertRegex(joined_logs, r"开始时间 \d{4}-\d{2}-\d{2} \d{2}:\d{2}:\d{2}") self.assertIn("[图片] 1/1 商品 51100639510", joined_logs) self.assertIn("准备重试 1/2", joined_logs) self.assertIn("cmhub 已返回图片,耗时 91.2秒", joined_logs) self.assertIn("下载完成,1.3MB,耗时 12.4秒", joined_logs) self.assertIn("图片下载较慢,已用 24.0秒,大小 1.3MB", joined_logs) self.assertIn("cmhub 图片 URL:https://cdn.example.com/generated.png?token=***", joined_logs) self.assertIn("本地保存完成,JPEG 已保存,耗时 1.1秒,文件 220.0KB", joined_logs) self.assertIn("token=***", joined_logs) self.assertNotIn("SECRET-TOKEN", joined_logs) self.assertIn("[完成] AI 生成完成:标题1/1,图片1/1,失败0", joined_logs) self.assertRegex(joined_logs, r"完成时间 \d{4}-\d{2}-\d{2} \d{2}:\d{2}:\d{2},总用时 \d+秒") events = db.list_run_log_events(summary["run_id"], path=cfg["db_path"]) event_messages = "\n".join(event.message for event in events) self.assertIn("[图片] 1/1 商品 51100639510", event_messages) self.assertIn("准备重试 1/2", event_messages) self.assertIn("下载完成,1.3MB,耗时 12.4秒", event_messages) self.assertIn("图片下载较慢,已用 24.0秒,大小 1.3MB", event_messages) self.assertNotIn("cmhub 图片 URL", event_messages) self.assert_removed(temp_dir) def test_generate_worker_user_log_hides_cmhub_request_urls(self): with self.make_temp_dir() as temp_dir: cfg = self.make_config(temp_dir) cfg["ai"] = appconfig.ai_config(cfg) cfg["ai"]["backend"] = "cmhub" 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", } ], 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"]) tasks = db.list_tasks(batch_id=batch_id, path=cfg["db_path"]) logs = [] def fake_generate_batch(tasks_arg, prompt_values, ai_cfg=None, on_progress=None, should_stop=None): ai_cfg["on_event"]( { "task": tasks_arg[0], "phase": "title", "step": "title_request", "result": "retry", "attempt": 1, "attempts": 3, "detail": ( "cmhub upstream_error: POST " "https://cmhub.example.com/api/v1/generate/title " "failed image_url=http://43.128.3.240:8080/generated/images/a.png?token=SECRET-TOKEN" ), "level": "warning", } ) ai_cfg["on_event"]( { "task": tasks_arg[0], "phase": "title", "step": "title_request", "result": "failed", "detail": ( "cmhub not_found: cmhub 接口不存在,请检查 Base URL " "或该实例是否已部署 /api/v1/models " "https://cmhub.example.com/api/v1/models" ), "level": "error", } ) return { "ok": False, "total": 1, "title_total": 1, "title_done": 0, "cover_done": 0, "cover_total": 0, "generated_done": 0, "failed": 1, "generate_cover": False, "error": ( "cmhub not_found: GET " "https://cmhub.example.com/api/v1/models?token=SECRET-TOKEN" ), } worker = GenerateWorker( tasks, {"title": "标题提示", "cover": "封面提示"}, db_path=cfg["db_path"], config=cfg, ) worker.log.connect(logs.append) with mock.patch("app.gui.ai.generate_batch", side_effect=fake_generate_batch): summary = worker.execute() self.assertFalse(summary["ok"]) joined_logs = "\n".join(logs) self.assertIn("cmhub 上游生成失败,请稍后重试", joined_logs) self.assertIn("cmhub 网关接口不可用,请检查设置中的 Base URL", joined_logs) self.assertRegex(joined_logs, r"失败时间 \d{4}-\d{2}-\d{2} \d{2}:\d{2}:\d{2},总用时 \d+秒") events = db.list_run_log_events(summary["run_id"], path=cfg["db_path"]) event_messages = "\n".join(event.message for event in events) combined = joined_logs + "\n" + event_messages for forbidden in [ "https://", "http://", "/api/v1/", "/generated/images/", "image_url", "SECRET-TOKEN", ]: self.assertNotIn(forbidden, combined) self.assert_removed(temp_dir) def test_generate_worker_records_cmhub_billing_metadata(self): with self.make_temp_dir() as temp_dir: cfg = self.make_config(temp_dir) cfg["ai"] = appconfig.ai_config(cfg) cfg["ai"]["backend"] = "cmhub" cfg["ai"]["generate_cover"] = True cfg["ai"]["image_concurrency"] = 10 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", } ], 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"]) tasks = db.list_tasks(batch_id=batch_id, path=cfg["db_path"]) progress = [] logs = [] def fake_generate_batch(tasks_arg, prompt_values, ai_cfg=None, on_progress=None, should_stop=None): on_progress({"total": 1, "title_done": 1, "cover_done": 0, "failed": 0}) ai_cfg["on_event"]( { "task": tasks_arg[0], "phase": "title", "step": "title_request", "result": "meta", "metadata": { "alias": "title-standard", "points_cost": 1, "points_balance": 88, "call_id": "call-1", }, } ) return { "ok": True, "total": 1, "title_done": 1, "cover_done": 1, "cover_total": 1, "generated_done": 1, "failed": 0, "generate_cover": True, } worker = GenerateWorker( tasks, {"title": "标题提示", "cover": "封面提示"}, db_path=cfg["db_path"], config=cfg, ) worker.progress.connect(progress.append) worker.log.connect(logs.append) with mock.patch("app.gui.ai.generate_batch", side_effect=fake_generate_batch): summary = worker.execute() self.assertEqual(88, summary["points_balance"]) self.assertEqual(88, progress[-1]["points_balance"]) joined_logs = "\n".join(logs) self.assertIn("图片并发10", joined_logs) self.assertIn("cmhub实际生图并发5", joined_logs) self.assertIn("下载并发5", joined_logs) self.assertIn("[计费] 商品 51100639510", joined_logs) self.assertIn("别名 title-standard", joined_logs) self.assertIn("扣点 1", joined_logs) self.assertIn("余额 88", joined_logs) self.assertIn("call_id=call-1", joined_logs) run_log = db.list_run_logs(limit=1, run_type="generate", path=cfg["db_path"])[0] self.assertEqual("cmhub", run_log.options["backend"]) events = db.list_run_log_events(summary["run_id"], path=cfg["db_path"]) messages = "\n".join(event.message for event in events) self.assertIn("[计费] 商品 51100639510", messages) self.assertIn("余额 88", messages) self.assert_removed(temp_dir) def test_generate_worker_stops_on_cmhub_insufficient_points(self): with self.make_temp_dir() as temp_dir: cfg = self.make_config(temp_dir) cfg["ai"] = appconfig.ai_config(cfg) cfg["ai"]["backend"] = "cmhub" 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", } ], 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"]) tasks = db.list_tasks(batch_id=batch_id, path=cfg["db_path"]) progress = [] logs = [] def fake_generate_batch(tasks_arg, prompt_values, ai_cfg=None, on_progress=None, should_stop=None): exc = ai.CMHubError("insufficient_points", "点数不足,请先充值", status=402) ai_cfg["on_error"]( { "task": tasks_arg[0], "phase": "title", "step": "title_request", "error": str(exc), "exception": exc, "code": exc.code, "status": exc.status, } ) self.assertTrue(should_stop()) return { "ok": False, "total": 1, "title_done": 0, "cover_done": 0, "cover_total": 0, "generated_done": 0, "failed": 1, "cancelled": should_stop(), "generate_cover": False, } worker = GenerateWorker( tasks, {"title": "标题提示", "cover": "封面提示"}, db_path=cfg["db_path"], config=cfg, ) worker.progress.connect(progress.append) worker.log.connect(logs.append) with mock.patch("app.gui.ai.generate_batch", side_effect=fake_generate_batch): summary = worker.execute() self.assertFalse(summary["ok"]) self.assertTrue(summary["cancelled"]) self.assertEqual("insufficient_points", summary["billing_error"]["code"]) self.assertIn("点数不足,请先充值", summary["billing_error"]["message"]) self.assertEqual("insufficient_points", progress[-1]["billing_error"]["code"]) joined_logs = "\n".join(logs) self.assertIn("[计费] 商品 51100639510", joined_logs) self.assertIn("点数不足,请先充值", joined_logs) run_log = db.list_run_logs(limit=1, run_type="generate", path=cfg["db_path"])[0] self.assertEqual("failed", run_log.status) self.assertEqual("insufficient_points", run_log.summary["billing_error"]["code"]) self.assert_removed(temp_dir) def test_generate_worker_writes_run_log_and_diagnostic_log_on_image_failure(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" cfg["ai"]["generate_cover"] = True 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", } ], path=cfg["db_path"], ) task = db.list_tasks(batch_id=batch_id, path=cfg["db_path"])[0] old_cover = os.path.join(temp_dir, "old.jpg") with open(old_cover, "wb") as fh: fh.write(b"jpeg") db.set_collected(task.id, "旧标题", old_cover, path=cfg["db_path"]) tasks = db.list_tasks(batch_id=batch_id, path=cfg["db_path"]) diagnostic_log_dir = os.path.join(temp_dir, "logs") def fake_title(title_prompt, old_title, **kwargs): callback = kwargs.get("on_step") if callback: callback("title_request") return "新标题" def fake_cover(cover_prompt, old_cover_path, out_path, **kwargs): callback = kwargs.get("on_step") if callback: callback("cover_request") raise RuntimeError("图片生成失败 token=SECRET-TOKEN") with mock.patch("app.ai.gen_title", side_effect=fake_title), \ mock.patch("app.ai.gen_cover", side_effect=fake_cover): summary = GenerateWorker( tasks, {"title": "标题提示", "cover": "封面提示"}, db_path=cfg["db_path"], config=cfg, diagnostic_log_dir=diagnostic_log_dir, ).execute() self.assertFalse(summary["ok"]) self.assertEqual(1, summary["title_done"]) self.assertEqual(0, summary["cover_done"]) self.assertEqual(1, summary["failed"]) failed_task = db.list_tasks(batch_id=batch_id, path=cfg["db_path"])[0] self.assertEqual("failed", failed_task.status) self.assertIn("图片生成失败", failed_task.last_error) self.assertNotIn("SECRET-TOKEN", failed_task.last_error) events = db.list_run_log_events(summary["run_id"], path=cfg["db_path"]) messages = "\n".join(event.message for event in events) self.assertIn("[失败] 商品 51100639510", messages) self.assertIn("图片生成失败", messages) self.assertIn("token=***", messages) self.assertNotIn("SECRET-TOKEN", messages) log_path = os.path.join(diagnostic_log_dir, "cmshopee.log") self.assertTrue(os.path.exists(log_path)) with open(log_path, "r", encoding="utf-8") as fh: raw_log = fh.read() self.assertIn("cover_request", raw_log) self.assertIn("AI生成任务失败", raw_log) self.assertIn("token=***", raw_log) self.assertNotIn("SECRET-TOKEN", raw_log) self.assert_removed(temp_dir) def test_generate_worker_can_generate_titles_without_covers(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", } ], 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"]) tasks = db.list_tasks(batch_id=batch_id, path=cfg["db_path"]) logs = [] progress = [] def fake_title(title_prompt, old_title, **kwargs): return "新标题" worker = GenerateWorker( tasks, {"title": "标题提示", "cover": "封面提示"}, db_path=cfg["db_path"], config=cfg, ) worker.log.connect(logs.append) worker.progress.connect(progress.append) with mock.patch("app.ai.gen_title", side_effect=fake_title), \ mock.patch("app.ai.gen_cover") as gen_cover: summary = worker.execute() self.assertTrue(summary["ok"]) self.assertFalse(summary["generate_cover"]) self.assertEqual(1, summary["title_done"]) self.assertEqual(0, summary["cover_done"]) self.assertEqual(0, summary["cover_total"]) self.assertEqual(1, summary["generated_done"]) self.assertEqual(0, summary["failed"]) gen_cover.assert_not_called() updated = db.list_tasks(batch_id=batch_id, path=cfg["db_path"])[0] self.assertEqual("generated", updated.stage) self.assertEqual("success", updated.status) self.assertEqual("新标题", updated.new_title) self.assertIsNone(updated.new_cover_path) self.assertEqual(0, progress[-1]["cover_total"]) self.assertEqual(1, progress[-1]["generated_done"]) joined_logs = "\n".join(logs) self.assertIn("本轮生成内容:只生成标题", joined_logs) self.assertIn("已保存,仅生成标题", joined_logs) self.assertIn("[完成] AI 生成完成:标题1/1,图片0/0,失败0", joined_logs) run_log = db.list_run_logs(limit=1, run_type="generate", path=cfg["db_path"])[0] self.assertEqual(1, run_log.done) self.assertEqual(1, run_log.success_count) self.assertTrue(run_log.options["generate_cover"] is False) self.assert_removed(temp_dir) def test_generate_worker_fills_missing_cover_without_title_call(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" cfg["ai"]["generate_mode"] = "cover" cfg["ai"]["generate_cover"] = True 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", } ], 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"]) tasks = db.list_tasks(batch_id=batch_id, path=cfg["db_path"]) logs = [] progress = [] def fake_cover(cover_prompt, old_cover_path, out_path, **kwargs): os.makedirs(os.path.dirname(out_path), exist_ok=True) with open(out_path, "wb") as fh: fh.write(b"jpeg") return out_path worker = GenerateWorker( tasks, {"title": "标题提示", "cover": "封面 {新标题}"}, db_path=cfg["db_path"], config=cfg, ) worker.log.connect(logs.append) worker.progress.connect(progress.append) with mock.patch("app.ai.gen_title") as gen_title, \ mock.patch("app.ai.gen_cover", side_effect=fake_cover) as gen_cover: summary = worker.execute() self.assertTrue(summary["ok"]) self.assertEqual(1, summary["total"]) self.assertEqual(0, summary["title_total"]) self.assertEqual(1, summary["cover_total"]) self.assertEqual(0, summary["title_done"]) self.assertEqual(1, summary["cover_done"]) self.assertEqual(1, summary["generated_done"]) gen_title.assert_not_called() gen_cover.assert_called_once() updated = db.list_tasks(batch_id=batch_id, path=cfg["db_path"])[0] self.assertIsNone(updated.new_title) self.assertTrue(os.path.exists(updated.new_cover_path)) self.assertEqual(0, progress[-1]["title_total"]) self.assertEqual(1, progress[-1]["cover_total"]) joined_logs = "\n".join(logs) self.assertIn("本轮生成内容:只生成封面", joined_logs) self.assertIn("标题0,图片1", joined_logs) self.assertIn("没有新标题,本轮使用旧标题作为封面参考", joined_logs) self.assertIn("[完成] AI 生成完成:标题0/0,图片1/1,失败0", joined_logs) self.assert_removed(temp_dir) def test_generate_tab_does_not_auto_mix_latest_generate_run_log(self): with self.make_temp_dir() as temp_dir: cfg = self.make_config(temp_dir) db.init_db(cfg["db_path"]) run_id = db.create_run_log("generate", total=1, path=cfg["db_path"]) db.add_run_log_event( run_id, "phase=cover step=cover_request result=failed detail=图片生成失败", level="error", path=cfg["db_path"], ) db.finish_run_log( run_id, status="done", done=1, failed_count=1, summary_json={"failed": 1}, path=cfg["db_path"], ) tab = GenerateTab(config=cfg) self.addCleanup(tab.close) text = tab.run_log_view.toPlainText() self.assertEqual("generateRunLogView", tab.run_log_view.objectName()) self.assertIn("本轮日志会在开始运行后显示", text) self.assertNotIn("phase=cover step=cover_request result=failed", text) self.assert_removed(temp_dir) def test_generate_tab_start_generate_resets_visible_log_for_current_run(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", } ], 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"], ) tab = GenerateTab(config=cfg) self.addCleanup(tab.close) tab.run_log_view.setPlainText("上一轮失败日志") class FakeSignal: def __init__(self): self.callbacks = [] def connect(self, callback): self.callbacks.append(callback) class FakeThread: def __init__(self): self.finished = FakeSignal() self.started = False def start(self): self.started = True fake_thread = FakeThread() 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() self.assertTrue(fake_thread.started) self.assertNotIn("上一轮失败日志", text) self.assertIn("本轮AI生成开始:任务 1 条", text) self.assertIn("生成内容:只生成标题", text) self.assert_removed(temp_dir) def test_generate_tab_starts_cover_only_run_for_missing_cover(self): with self.make_temp_dir() as temp_dir: cfg = self.make_config(temp_dir) cfg["ai"] = appconfig.ai_config(cfg) cfg["ai"]["generate_mode"] = "cover" cfg["ai"]["generate_cover"] = True 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", } ], 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"], ) tab = GenerateTab(config=cfg) self.addCleanup(tab.close) tab.status_filter.setCurrentIndex(tab.status_filter.findData("to_generate")) self.assertEqual(1, tab.model.rowCount()) class FakeSignal: def __init__(self): self.callbacks = [] def connect(self, callback): self.callbacks.append(callback) class FakeThread: def __init__(self): self.finished = FakeSignal() self.started = False def start(self): self.started = True fake_thread = FakeThread() 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) self.assertEqual("标题 0/0", tab.title_progress_label.text()) self.assertEqual("图片 0/1", tab.cover_progress_label.text()) text = tab.run_log_view.toPlainText() self.assertIn("本轮AI生成开始:任务 1 条", text) self.assertIn("生成内容:只生成封面", text) self.assert_removed(temp_dir) def test_generate_scope_dialog_uses_safe_default_and_warning_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() captured["minimum_width"] = box.minimumWidth() captured["normal_button_width"] = normal_button.width() captured["all_button_width"] = all_button.width() captured["normal_text_width"] = normal_button.fontMetrics().horizontalAdvance( normal_button.text() ) captured["all_text_width"] = all_button.fontMetrics().horizontalAdvance( all_button.text() ) self.assertIsNotNone(normal_button) self.assertIsNotNone(all_button) self.assertEqual("生成架上商品", normal_button.text()) self.assertEqual("生成全部商品", all_button.text()) self.assertIn("检测结果为正常", box.informativeText()) self.assertIn("未上架、审核中和状态未知", box.informativeText()) self.assertIn("可能额外消耗点数", box.informativeText()) 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.assertGreaterEqual(captured["minimum_width"], 520) self.assertGreaterEqual(captured["normal_button_width"], 480) self.assertGreaterEqual(captured["all_button_width"], 480) self.assertGreater(captured["normal_button_width"], captured["normal_text_width"]) self.assertGreater(captured["all_button_width"], captured["all_text_width"]) self.assertIn("#bc4c00", captured["all_style"]) self.assertNotIn("#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) 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", } ], path=cfg["db_path"], ) task = db.list_tasks(batch_id=batch_id, path=cfg["db_path"])[0] db.mark_failed(task.id, "collect", "采集失败", path=cfg["db_path"]) statuses = [] tab = GenerateTab(config=cfg, status_callback=statuses.append) self.addCleanup(tab.close) self.assertEqual("采集失败", tab.model.index(0, 4).data()) tab.start_generate() self.assertIsNone(tab.generate_worker) self.assertIn("没有可生成的缺失内容", statuses[-1]) self.assertIn("①导入采集", statuses[-1]) self.assert_removed(temp_dir) def test_apply_tab_does_not_auto_mix_latest_apply_run_log(self): with self.make_temp_dir() as temp_dir: cfg = self.make_config(temp_dir) db.init_db(cfg["db_path"]) run_id = db.create_run_log("apply", total=1, path=cfg["db_path"]) db.add_run_log_event( run_id, "step=apply_update result=failed detail=上一轮更新失败", level="error", path=cfg["db_path"], ) db.finish_run_log( run_id, status="done", done=1, failed_count=1, summary_json={"failed": 1}, path=cfg["db_path"], ) tab = ApplyTab(config=cfg) self.addCleanup(tab.close) text = tab.run_log_view.toPlainText() self.assertEqual("applyRunLogView", tab.run_log_view.objectName()) self.assertIn("本轮日志会在开始运行后显示", text) self.assertNotIn("step=apply_update result=failed", text) self.assert_removed(temp_dir) def test_apply_tab_lists_generated_tasks_and_filters_by_batch_shop_status(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) accounts.create_account("副店", "alias-b", debug_port=9223, config=cfg) batch_a = db.create_batch(["input-a.xlsx"], path=cfg["db_path"]) batch_b = db.create_batch(["input-b.xlsx"], path=cfg["db_path"]) db.insert_tasks( batch_a, [ { "source_file_abs": os.path.join(temp_dir, "input-a.xlsx"), "source_sheet": "商品", "source_row": 2, "account_name": "Excel主店", "alias": "alias-a", "item_id": "51100639510", }, { "source_file_abs": os.path.join(temp_dir, "input-a.xlsx"), "source_sheet": "商品", "source_row": 3, "account_name": "Excel副店", "alias": "alias-b", "item_id": "51100639511", }, { "source_file_abs": os.path.join(temp_dir, "input-a.xlsx"), "source_sheet": "商品", "source_row": 4, "account_name": "Excel主店", "alias": "alias-a", "item_id": "51100639512", }, { "source_file_abs": os.path.join(temp_dir, "input-a.xlsx"), "source_sheet": "商品", "source_row": 5, "account_name": "Excel主店", "alias": "alias-a", "item_id": "51100639513", }, ], path=cfg["db_path"], ) db.insert_tasks( batch_b, [ { "source_file_abs": os.path.join(temp_dir, "input-b.xlsx"), "source_sheet": "商品", "source_row": 2, "account_name": "Excel主店", "alias": "alias-a", "item_id": "51100639514", } ], path=cfg["db_path"], ) tasks_a = db.list_tasks(batch_id=batch_a, path=cfg["db_path"]) tasks_b = db.list_tasks(batch_id=batch_b, path=cfg["db_path"]) for task in tasks_a[:3] + tasks_b: db.set_collected(task.id, "旧标题" + task.item_id[-2:], "old.jpg", path=cfg["db_path"]) db.set_generated( task.id, "新标题" + task.item_id[-2:], os.path.join(temp_dir, task.item_id + "_new.jpg"), path=cfg["db_path"], ) db.mark_failed(tasks_a[2].id, "apply", "更新失败", path=cfg["db_path"]) db.set_applied(tasks_b[0].id, True, path=cfg["db_path"]) db.set_product_status(tasks_a[0].id, "normal", path=cfg["db_path"]) db.set_product_status(tasks_a[1].id, "unlisted", path=cfg["db_path"]) db.set_product_status(tasks_a[2].id, "reviewing", path=cfg["db_path"]) db.set_product_status(tasks_b[0].id, "normal", path=cfg["db_path"]) tab = ApplyTab(config=cfg) self.addCleanup(tab.close) tab.batch_filter.setCurrentIndex(tab.batch_filter.findData(None)) self.assertIsInstance(tab.task_table, QTableView) self.assertEqual(["店铺", "商品ID", "新标题", "新封面", "阶段", "结果"], tab.model.HEADERS) self.assertEqual("检查本轮更新", tab.preview_update_button.text()) self.assertEqual("开始更新", tab.start_update_button.text()) self.assertIsInstance(tab.update_mode_combo, QComboBox) self.assertEqual( ["只更新标题", "只更新封面", "更新标题和封面"], [tab.update_mode_combo.itemText(index) for index in range(tab.update_mode_combo.count())], ) self.assertEqual("applyItemFilter", tab.item_filter.objectName()) self.assertEqual("applyProductStatusFilter", tab.product_status_filter.objectName()) self.assertEqual( ["全部商品状态", "架上商品", "未上架", "审核中", "状态未知", "待检测"], [ tab.product_status_filter.itemText(index) for index in range(tab.product_status_filter.count()) ], ) label_texts = [label.text() for label in tab.findChildren(QLabel)] self.assertIn("商品状态", label_texts) self.assertIn("更新状态", label_texts) self.assertEqual("停止", tab.stop_update_button.text()) self.assertEqual("重置更新状态", tab.reset_update_button.text()) self.assertTrue(tab.reset_update_button.isHidden()) self.assertEqual(gui.Qt.CustomContextMenu, tab.task_table.contextMenuPolicy()) self.assertEqual("startUpdateButton", tab.start_update_button.objectName()) self.assertIn(gui.COLOR_WARNING, tab.start_update_button.styleSheet()) self.assertIn("border", tab.start_update_button.styleSheet()) self.assertNotIn("border-radius", tab.start_update_button.styleSheet()) self.assertNotIn("padding", tab.start_update_button.styleSheet()) self.assertFalse(tab.stop_update_button.isEnabled()) self.assertEqual(2, tab.model.rowCount()) self.assertEqual("主店", tab.model.index(0, 0).data()) self.assertEqual("51100639510", tab.model.index(0, 1).data()) self.assertEqual("新标题10", tab.model.index(0, 2).data()) self.assertEqual("51100639510_new.jpg", tab.model.index(0, 3).data()) self.assertEqual("待更新", tab.model.index(0, 4).data()) self.assert_foreground(tab.model, 0, 4, gui.COLOR_PENDING) self.assertEqual("待更新", tab.model.index(0, 5).data()) self.assert_foreground(tab.model, 0, 5, gui.COLOR_PENDING) self.assertEqual("任务 2/4 条", tab.summary_label.text()) tab.product_status_filter.setCurrentIndex( tab.product_status_filter.findData("normal") ) self.assertEqual(1, tab.model.rowCount()) self.assertEqual("51100639510", tab.model.index(0, 1).data()) tab.product_status_filter.setCurrentIndex( tab.product_status_filter.findData("unlisted") ) self.assertEqual(1, tab.model.rowCount()) self.assertEqual("51100639511", tab.model.index(0, 1).data()) with mock.patch("app.gui.QMessageBox.warning") as warning, mock.patch( "app.gui.run_worker" ) as run_worker: tab.preview_update() self.assertEqual("商品状态不允许更新", warning.call_args[0][1]) run_worker.assert_not_called() tab.product_status_filter.setCurrentIndex(tab.product_status_filter.findData("all")) tab.status_filter.setCurrentIndex(tab.status_filter.findData("failed")) self.assertEqual(1, tab.model.rowCount()) self.assertEqual("失败", tab.model.index(0, 5).data()) self.assert_foreground(tab.model, 0, 5, gui.COLOR_DANGER) self.assertEqual( "更新失败", tab.model.data(tab.model.index(0, 0), gui.Qt.ToolTipRole), ) tab.status_filter.setCurrentIndex(tab.status_filter.findData("generated")) tab.shop_filter.setCurrentIndex(tab.shop_filter.findData("alias-b")) self.assertEqual(1, tab.model.rowCount()) self.assertEqual("副店", tab.model.index(0, 0).data()) tab.shop_filter.setCurrentIndex(tab.shop_filter.findData(None)) tab.batch_filter.setCurrentIndex(tab.batch_filter.findData(batch_b)) tab.status_filter.setCurrentIndex(tab.status_filter.findData("applied")) self.assertEqual(1, tab.model.rowCount()) self.assertEqual("已更新", tab.model.index(0, 4).data()) self.assert_foreground(tab.model, 0, 4, gui.COLOR_SUCCESS) self.assertEqual("成功", tab.model.index(0, 5).data()) self.assert_foreground(tab.model, 0, 5, gui.COLOR_SUCCESS) tab.batch_filter.setCurrentIndex(tab.batch_filter.findData(None)) tab.status_filter.setCurrentIndex(tab.status_filter.findData("all")) tab.item_filter.setText("51100639512") self.assertEqual(1, tab.model.rowCount()) self.assertEqual("51100639512", tab.model.index(0, 1).data()) self.assertEqual("任务 1/4 条", tab.summary_label.text()) self.assertIn( "商品ID:51100639512", tab._confirmation_message(list(tab.model.tasks)), ) self.assertIn( "商品状态:全部商品状态", tab._confirmation_message(list(tab.model.tasks)), ) self.assertIn( "更新状态:全部状态", tab._confirmation_message(list(tab.model.tasks)), ) self.assert_removed(temp_dir) def test_apply_tab_resets_selected_apply_status_and_warns_committed_task(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", } ], 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_generated(task.id, "新标题", "new.jpg", path=cfg["db_path"]) db.set_applied(task.id, True, path=cfg["db_path"]) statuses = [] tab = ApplyTab(config=cfg, status_callback=statuses.append) self.addCleanup(tab.close) tab.status_filter.setCurrentIndex(tab.status_filter.findData("applied")) tab.task_table.selectRow(0) with mock.patch("app.gui.QMessageBox.question", return_value=gui.QMessageBox.Yes) as question: tab.reset_apply_status() message = question.call_args[0][2] self.assertIn("已经提交过线上", message) self.assertIn("本地重置不会回滚蝦皮", message) self.assertIn("重复更新会再次提交线上", message) updated = db.get_task(task.id, path=cfg["db_path"]) self.assertEqual("generated", updated.stage) self.assertEqual("pending", updated.status) self.assertEqual("新标题", updated.new_title) self.assertEqual("new.jpg", updated.new_cover_path) self.assertEqual(1, updated.committed) run_log = db.list_run_logs(limit=1, run_type="reset", path=cfg["db_path"])[0] self.assertEqual("done", run_log.status) self.assertEqual("reset_apply_status", run_log.options["action"]) events = db.list_run_log_events(run_log.id, path=cfg["db_path"]) self.assertTrue(any("action=reset_apply_status" in event.message for event in events)) self.assertIn("已重置更新状态", statuses[-1]) self.assert_removed(temp_dir) def test_apply_tab_start_update_requires_confirmation_before_starting_worker(self): with self.make_temp_dir() as temp_dir: cfg = self.make_config(temp_dir) self.allow_shopee_update(cfg) 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", } ], 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"], ) db.set_generated(task.id, "新标题", "new.jpg", path=cfg["db_path"]) statuses = [] tab = ApplyTab(config=cfg, status_callback=statuses.append) self.addCleanup(tab.close) tab.item_filter.setText("51100639510") with mock.patch( "app.gui.QMessageBox.question", return_value=gui.QMessageBox.No, ) as question, mock.patch("app.gui.editor.apply_task") as apply_task: tab.start_update() message = question.call_args[0][2] self.assertIn("任务数:1", message) self.assertIn("预计批次:1", message) self.assertIn("提交线上", message) self.assertIn("更新内容:更新标题和封面", message) self.assertIn("本轮将更新线上封面", message) self.assertIn("状态:已生成", message) self.assertIn("商品ID:51100639510", message) self.assertNotIn("测试商品ID", message) self.assertEqual("已取消开始更新", statuses[-1]) apply_task.assert_not_called() self.assert_removed(temp_dir) def test_apply_tab_start_update_starts_apply_worker_after_confirmation(self): with self.make_temp_dir() as temp_dir: cfg = self.make_config(temp_dir) self.allow_shopee_update(cfg) 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", } ], 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"], ) db.set_generated(task.id, "新标题", "new.jpg", path=cfg["db_path"]) statuses = [] tab = ApplyTab(config=cfg, status_callback=statuses.append) self.addCleanup(tab.close) tab.run_log_view.setPlainText("上一轮更新失败") class FakeSignal: def __init__(self): self.callbacks = [] def connect(self, callback): self.callbacks.append(callback) class FakeThread: def __init__(self): self.finished = FakeSignal() self.started = False def start(self): self.started = True fake_thread = FakeThread() with mock.patch( "app.gui.QMessageBox.question", return_value=gui.QMessageBox.Yes, ), mock.patch("app.gui.run_worker", return_value=fake_thread) as run_worker, \ mock.patch("app.gui.editor.apply_task") as apply_task: tab.start_update() run_worker.assert_called_once() self.assertIsInstance(tab.apply_worker, ApplyWorker) self.assertIs(tab.apply_thread, fake_thread) self.assertTrue(fake_thread.started) self.assertFalse(hasattr(tab.apply_worker, "close_success_tab")) self.assertFalse(tab.apply_worker.dry_run) self.assertEqual(1, tab.apply_worker.max_parallel_accounts) self.assertEqual(1, tab.apply_worker.batch_size) self.assertEqual("title_cover", tab.apply_worker.update_mode) log_text = tab.run_log_view.toPlainText() self.assertNotIn("上一轮更新失败", log_text) self.assertIn("本轮更新开始:任务 1 条,更新内容:更新标题和封面,每批 1 条", log_text) self.assertFalse(tab.preview_update_button.isEnabled()) self.assertFalse(tab.start_update_button.isEnabled()) self.assertTrue(tab.stop_update_button.isEnabled()) self.assertEqual("开始更新:1 条,按每批最多 1 条执行", statuses[-1]) apply_task.assert_not_called() unchanged = db.list_tasks(batch_id=batch_id, path=cfg["db_path"])[0] self.assertEqual("generated", unchanged.stage) self.assertEqual("success", unchanged.status) self.assert_removed(temp_dir) def test_apply_tab_preview_starts_without_real_submit_switch(self): with self.make_temp_dir() as temp_dir: cfg = self.make_config(temp_dir) cfg["shopee_update"] = { "test_item_id": "51100639510", "allow_real_submit": False, "allow_cover_update": False, "max_items_per_run": 1, "close_success_tab": False, "dry_run": False, "parallel_accounts": True, "max_parallel_accounts": 2, } 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", } ], 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"], ) db.set_generated(task.id, "新标题", "new.jpg", path=cfg["db_path"]) statuses = [] tab = ApplyTab(config=cfg, status_callback=statuses.append) self.addCleanup(tab.close) tab.run_log_view.setPlainText("上一轮检查失败") class FakeSignal: def __init__(self): self.callbacks = [] def connect(self, callback): self.callbacks.append(callback) class FakeThread: def __init__(self): self.finished = FakeSignal() self.started = False def start(self): self.started = True fake_thread = FakeThread() with mock.patch( "app.gui.QMessageBox.question", return_value=gui.QMessageBox.Yes, ), mock.patch("app.gui.QMessageBox.warning") as warning, \ mock.patch("app.gui.run_worker", return_value=fake_thread): tab.preview_update() warning.assert_not_called() self.assertTrue(tab.apply_worker.dry_run) self.assertEqual(2, tab.apply_worker.max_parallel_accounts) log_text = tab.run_log_view.toPlainText() self.assertNotIn("上一轮检查失败", log_text) self.assertIn("本轮检查开始:任务 1 条,更新内容:只更新标题,每批 1 条", log_text) self.assertEqual("开始检查本轮更新:1 条", statuses[-1]) self.assert_removed(temp_dir) def test_apply_tab_old_real_submit_switch_does_not_block_final_confirmation(self): with self.make_temp_dir() as temp_dir: cfg = self.make_config(temp_dir) cfg["shopee_update"] = { "allow_real_submit": False, "max_items_per_run": 1, } 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", } ], 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"], ) db.set_generated(task.id, "新标题", "new.jpg", path=cfg["db_path"]) statuses = [] tab = ApplyTab(config=cfg, status_callback=statuses.append) self.addCleanup(tab.close) tab.item_filter.setText("51100639510") class FakeSignal: def connect(self, callback): self.callback = callback class FakeThread: def __init__(self): self.finished = FakeSignal() self.started = False def start(self): self.started = True fake_thread = FakeThread() with mock.patch( "app.gui.QMessageBox.question", return_value=gui.QMessageBox.Yes, ) as question, mock.patch("app.gui.run_worker", return_value=fake_thread) as run_worker: tab.start_update() message = question.call_args[0][2] self.assertIn("即将按当前筛选结果分批更新蝦皮线上商品", message) self.assertIn("任务数:1", message) run_worker.assert_called_once() self.assertTrue(fake_thread.started) self.assertEqual("开始更新:1 条,按每批最多 1 条执行", statuses[-1]) self.assert_removed(temp_dir) def test_apply_tab_blocks_cover_update_when_cover_is_missing_for_selected_mode(self): with self.make_temp_dir() as temp_dir: cfg = self.make_config(temp_dir) self.allow_shopee_update(cfg, allow_cover=False) 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", } ], 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"], ) db.set_generated(task.id, "新标题", None, path=cfg["db_path"]) tab = ApplyTab(config=cfg) self.addCleanup(tab.close) tab.update_mode_combo.setCurrentIndex(tab.update_mode_combo.findData("cover")) with mock.patch("app.gui.QMessageBox.warning") as warning, \ mock.patch("app.gui.QMessageBox.question") as question, \ mock.patch("app.gui.run_worker") as run_worker: tab.start_update() warning.assert_called_once() self.assertEqual("更新内容未生成", warning.call_args[0][1]) message = warning.call_args[0][2] self.assertIn("缺少新封面", message) self.assertIn("只更新封面", message) self.assertIn("51100639510", message) question.assert_not_called() run_worker.assert_not_called() self.assert_removed(temp_dir) def test_apply_tab_partitions_missing_content_by_update_mode(self): with self.make_temp_dir() as temp_dir: tab = ApplyTab(config=self.make_config(temp_dir)) self.addCleanup(tab.close) tasks = [ SimpleNamespace(id=1, item_id="51100639510", new_title="新标题", new_cover_path="new.jpg"), SimpleNamespace(id=2, item_id="51100639511", new_title="新标题", new_cover_path=None), SimpleNamespace(id=3, item_id="51100639512", new_title=None, new_cover_path="new.jpg"), SimpleNamespace(id=4, item_id="51100639513", new_title=None, new_cover_path=None), ] title_plan = tab._partition_update_content_tasks(tasks, "title") self.assertEqual([1, 2], [task.id for task in title_plan["executable"]]) self.assertEqual([3, 4], [task.id for task in title_plan["missing_title"]]) self.assertEqual([], title_plan["missing_cover"]) cover_plan = tab._partition_update_content_tasks(tasks, "cover") self.assertEqual([1, 3], [task.id for task in cover_plan["executable"]]) self.assertEqual([], cover_plan["missing_title"]) self.assertEqual([2, 4], [task.id for task in cover_plan["missing_cover"]]) title_cover_plan = tab._partition_update_content_tasks(tasks, "title_cover") self.assertEqual([1], [task.id for task in title_cover_plan["executable"]]) self.assertEqual([3, 4], [task.id for task in title_cover_plan["missing_title"]]) self.assertEqual([2, 4], [task.id for task in title_cover_plan["missing_cover"]]) self.assert_removed(temp_dir) def test_apply_tab_blocks_all_abnormal_statuses_before_content_or_worker(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": index + 2, "alias": "alias-a", "item_id": str(51100639510 + index), } for index in range(3) ], path=cfg["db_path"], ) statuses = ["unlisted", "reviewing", None] 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"], ) db.set_generated(task.id, "新标题", "new.jpg", path=cfg["db_path"]) states = [] tab = ApplyTab(config=cfg, status_callback=states.append) self.addCleanup(tab.close) with mock.patch("app.gui.QMessageBox.warning") as warning, mock.patch( "app.gui.QMessageBox.question" ) as question, mock.patch("app.gui.run_worker") as run_worker: tab.preview_update() self.assertEqual("商品状态不允许更新", warning.call_args[0][1]) message = warning.call_args[0][2] self.assertIn("未上架:1 条", message) self.assertIn("审核中:1 条", message) self.assertIn("状态未知:1 条", message) self.assertNotIn("缺少新标题", message) question.assert_not_called() run_worker.assert_not_called() self.assertIn("状态正常且可更新", states[-1]) unchanged = db.list_tasks(batch_id=batch_id, path=cfg["db_path"]) self.assertTrue(all(task.stage == "generated" for task in unchanged)) self.assertTrue(all(task.status == "success" for task in unchanged)) self.assert_removed(temp_dir) def test_apply_tab_confirmation_combines_status_and_content_exclusions(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": index + 2, "alias": "alias-a", "item_id": str(51100639510 + index), } for index in range(3) ], path=cfg["db_path"], ) tasks = db.list_tasks(batch_id=batch_id, path=cfg["db_path"]) for task, status in zip(tasks, ["normal", "reviewing", "normal"]): db.set_collected( task.id, "旧标题", "old.jpg", product_status_value=status, path=cfg["db_path"], ) db.set_generated(tasks[0].id, "新标题", "new.jpg", path=cfg["db_path"]) db.set_generated(tasks[1].id, "新标题", "new.jpg", path=cfg["db_path"]) db.set_generated(tasks[2].id, None, "new.jpg", path=cfg["db_path"]) tab = ApplyTab(config=cfg) self.addCleanup(tab.close) thread = FakeThread() with mock.patch("app.gui.QMessageBox.question", return_value=gui.QMessageBox.Yes) as question, mock.patch( "app.gui.run_worker", return_value=thread, ): tab.preview_update() message = question.call_args[0][2] self.assertIn("商品状态异常记录", message) self.assertIn("审核中:1 条", message) self.assertIn("缺少新标题:1 条", message) self.assertEqual([tasks[0].id], [task.id for task in tab.apply_worker.tasks]) self.assertTrue(tab.apply_worker.dry_run) self.assertTrue(thread.started) self.assert_removed(temp_dir) def test_apply_tab_discards_plan_when_candidates_change_during_confirmation(self): with self.make_temp_dir() as temp_dir: states = [] tab = ApplyTab( config=self.make_config(temp_dir), status_callback=states.append, ) self.addCleanup(tab.close) before = SimpleNamespace( id=1, item_id="51100639510", updated_at="2026-07-18T10:00:00", product_status="normal", new_title="新标题", new_cover_path="new.jpg", ) after = SimpleNamespace( id=1, item_id="51100639510", updated_at="2026-07-18T10:01:00", product_status="normal", new_title="新标题", new_cover_path="new.jpg", ) with mock.patch.object(tab, "_update_candidates", side_effect=[[before], [after]]), mock.patch( "app.gui.QMessageBox.question", return_value=gui.QMessageBox.Yes, ), mock.patch("app.gui.run_worker") as run_worker: tab.preview_update() run_worker.assert_not_called() self.assertIn("当前任务数据已变化", states[-1]) self.assert_removed(temp_dir) def test_apply_tab_skips_missing_content_but_starts_eligible_update(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", }, { "source_file_abs": os.path.join(temp_dir, "input.xlsx"), "source_sheet": "商品", "source_row": 4, "account_name": "Excel主店", "alias": "alias-a", "item_id": "51100639512", }, ], path=cfg["db_path"], ) tasks = db.list_tasks(batch_id=batch_id, path=cfg["db_path"]) for task in tasks: db.set_collected( task.id, "旧标题", "old.jpg", product_status_value="normal", path=cfg["db_path"], ) db.set_generated(tasks[0].id, "新标题", "new.jpg", path=cfg["db_path"]) db.set_generated(tasks[1].id, "新标题", None, path=cfg["db_path"]) db.set_generated(tasks[2].id, None, "new.jpg", path=cfg["db_path"]) statuses = [] tab = ApplyTab(config=cfg, status_callback=statuses.append) self.addCleanup(tab.close) tab.update_mode_combo.setCurrentIndex(tab.update_mode_combo.findData("title_cover")) thread = FakeThread() with mock.patch("app.gui.QMessageBox.question", return_value=gui.QMessageBox.Yes) as question, \ mock.patch("app.gui.QMessageBox.warning") as warning, \ mock.patch("app.gui.run_worker", return_value=thread): tab.start_update() warning.assert_not_called() message = question.call_args[0][2] self.assertIn("任务数:1", message) self.assertIn("本轮将跳过 2 条缺少所选更新内容的记录", message) self.assertIn("缺少新标题:1 条(示例商品ID:51100639512)", message) self.assertIn("缺少新封面:1 条(示例商品ID:51100639511)", message) self.assertEqual([tasks[0].id], [task.id for task in tab.apply_worker.tasks]) self.assertTrue(thread.started) self.assertIn("跳过 2 条(缺标题 1 / 缺封面 1)", statuses[-1]) self.assertIn("缺失记录未进入本轮执行", tab.run_log_view.toPlainText()) unchanged = db.list_tasks(batch_id=batch_id, path=cfg["db_path"]) self.assertTrue(all(task.stage == "generated" for task in unchanged)) self.assertTrue(all(task.status == "success" for task in unchanged)) self.assert_removed(temp_dir) def test_apply_tab_preview_uses_same_missing_content_skip_rule(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"], ) tasks = db.list_tasks(batch_id=batch_id, path=cfg["db_path"]) for task in tasks: db.set_collected( task.id, "旧标题", "old.jpg", product_status_value="normal", path=cfg["db_path"], ) db.set_generated(tasks[0].id, "新标题", "new.jpg", path=cfg["db_path"]) db.set_generated(tasks[1].id, None, "new.jpg", path=cfg["db_path"]) statuses = [] tab = ApplyTab(config=cfg, status_callback=statuses.append) self.addCleanup(tab.close) tab.update_mode_combo.setCurrentIndex(tab.update_mode_combo.findData("title")) thread = FakeThread() with mock.patch("app.gui.QMessageBox.question", return_value=gui.QMessageBox.Yes) as question, \ mock.patch("app.gui.QMessageBox.warning") as warning, \ mock.patch("app.gui.run_worker", return_value=thread): tab.preview_update() warning.assert_not_called() message = question.call_args[0][2] self.assertIn("本轮将跳过 1 条缺少所选更新内容的记录", message) self.assertIn("缺少新标题:1 条(示例商品ID:51100639511)", message) self.assertEqual([tasks[0].id], [task.id for task in tab.apply_worker.tasks]) self.assertTrue(tab.apply_worker.dry_run) self.assertIn("开始检查本轮更新:1 条,跳过 1 条(缺标题 1 / 缺封面 0)", statuses[-1]) unchanged = db.list_tasks(batch_id=batch_id, path=cfg["db_path"]) self.assertTrue(all(task.stage == "generated" for task in unchanged)) self.assertTrue(all(task.status == "success" for task in unchanged)) self.assert_removed(temp_dir) def test_apply_tab_allows_cover_only_result_but_blocks_modes_that_need_title(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", } ], 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_generated_cover(task.id, "new-cover.jpg", path=cfg["db_path"]) generated = db.get_task(task.id, path=cfg["db_path"]) tab = ApplyTab(config=cfg) self.addCleanup(tab.close) self.assertIsNone(tab._update_content_error([generated], "cover")) self.assertIn("缺少新标题", tab._update_content_error([generated], "title")) self.assertIn("缺少新标题", tab._update_content_error([generated], "title_cover")) self.assert_removed(temp_dir) def test_apply_worker_applies_success_failure_and_unmatched_serially(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) accounts.create_account("副店", "alias-b", debug_port=9223, 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-b", "item_id": "51100639511", }, { "source_file_abs": os.path.join(temp_dir, "input.xlsx"), "source_sheet": "商品", "source_row": 4, "account_name": "Excel未知", "alias": "missing", "item_id": "51100639512", }, ], path=cfg["db_path"], ) for task in db.list_tasks(batch_id=batch_id, path=cfg["db_path"]): db.set_collected( task.id, "旧标题", "old.jpg", product_status_value="normal", path=cfg["db_path"], ) db.set_generated(task.id, "新标题", "new.jpg", path=cfg["db_path"]) tasks = db.list_tasks(batch_id=batch_id, path=cfg["db_path"]) applied_aliases = [] foreground_flags = [] progress = [] rows = [] def fake_apply(account, task, close_success_tab=False, on_step=None, bring_to_front=True, update_mode=None): applied_aliases.append(account.alias) foreground_flags.append(bring_to_front) if account.alias == "alias-a": return {"committed": True, "error": None} if account.alias == "alias-b": return {"committed": False, "error": "UPDATE_DISABLED"} raise AssertionError(account.alias) with mock.patch("app.gui.chrome.is_running", return_value=True), \ mock.patch( "app.gui.accounts.detect_login", return_value={"logged_in": True, "reason": None}, ), mock.patch("app.gui.editor.apply_task", side_effect=fake_apply): worker = ApplyWorker( tasks, db_path=cfg["db_path"], config=cfg, batch_size=1, ) worker.progress.connect(progress.append) worker.row_updated.connect(lambda task_id, fields: rows.append((task_id, fields))) summary = worker.execute() self.assertEqual(["alias-a", "alias-b"], applied_aliases) self.assertEqual([True, True], foreground_flags) self.assertFalse(summary["ok"]) self.assertEqual(3, summary["total"]) self.assertEqual(3, summary["done"]) self.assertEqual(1, summary["applied"]) self.assertEqual(1, summary["skipped"]) self.assertEqual(1, summary["failed"]) self.assertEqual([batch_id], summary["batch_ids"]) self.assertFalse(summary["dry_run"]) self.assertFalse(summary["account_parallel"]) self.assertEqual(1, summary["batch_size"]) self.assertEqual(3, summary["batch_count"]) self.assertIsNotNone(summary["run_id"]) self.assertEqual(3, progress[-1]["done"]) self.assertEqual(3, progress[-1]["total"]) self.assertEqual(1, progress[-1]["applied"]) self.assertEqual(1, progress[-1]["skipped"]) self.assertEqual(1, progress[-1]["failed"]) self.assertFalse(progress[-1]["dry_run"]) self.assertEqual(1, progress[-1]["batch_size"]) self.assertEqual(3, progress[-1]["batch_count"]) updated = db.list_tasks(batch_id=batch_id, path=cfg["db_path"]) by_alias = {task.alias: task for task in updated} self.assertEqual("applied", by_alias["alias-a"].stage) self.assertEqual("success", by_alias["alias-a"].status) self.assertEqual(1, by_alias["alias-a"].committed) self.assertEqual("generated", by_alias["alias-b"].stage) self.assertEqual("failed", by_alias["alias-b"].status) self.assertEqual("更新商品失败:UPDATE_DISABLED", by_alias["alias-b"].last_error) self.assertEqual(0, by_alias["alias-b"].committed) self.assertEqual("skipped", by_alias["missing"].status) self.assertEqual("别名未匹配账号", by_alias["missing"].last_error) self.assertTrue(any(fields.get("stage") == "applied" for _task_id, fields in rows)) self.assertTrue(any(fields.get("status") == "failed" for _task_id, fields in rows)) run_logs = db.list_run_logs(run_type="apply", path=cfg["db_path"]) self.assertEqual(1, len(run_logs)) self.assertEqual("done", run_logs[0].status) events = db.list_run_log_events(run_logs[0].id, path=cfg["db_path"]) self.assertGreaterEqual(len(events), 3) self.assertTrue(any("更新批次 1/3" in event.message for event in events)) self.assert_removed(temp_dir) def test_apply_worker_brings_every_product_to_front_across_batches(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"], ) for task in db.list_tasks(batch_id=batch_id, path=cfg["db_path"]): db.set_collected( task.id, "旧标题", "old.jpg", product_status_value="normal", path=cfg["db_path"], ) db.set_generated(task.id, "新标题", "new.jpg", path=cfg["db_path"]) tasks = db.list_tasks(batch_id=batch_id, path=cfg["db_path"]) foreground_flags = [] def fake_apply(account, task, close_success_tab=False, on_step=None, bring_to_front=True, update_mode=None): foreground_flags.append(bring_to_front) return {"committed": True, "error": None} with mock.patch("app.gui.chrome.is_running", return_value=True), \ mock.patch( "app.gui.accounts.detect_login", return_value={"logged_in": True, "reason": None}, ), mock.patch("app.gui.editor.apply_task", side_effect=fake_apply): summary = ApplyWorker( tasks, db_path=cfg["db_path"], config=cfg, batch_size=1, ).execute() self.assertTrue(summary["ok"]) self.assertEqual([True, True], foreground_flags) self.assertEqual(2, summary["batch_count"]) self.assert_removed(temp_dir) def test_apply_worker_dry_run_only_previews_and_logs_without_mutating_tasks(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": "missing", "item_id": "51100639511", }, ], path=cfg["db_path"], ) for task in db.list_tasks(batch_id=batch_id, path=cfg["db_path"]): db.set_collected( task.id, "旧标题", "old.jpg", product_status_value="normal", path=cfg["db_path"], ) db.set_generated(task.id, "新标题", "new.jpg", path=cfg["db_path"]) tasks = db.list_tasks(batch_id=batch_id, path=cfg["db_path"]) logs = [] with mock.patch("app.gui.chrome.is_running") as is_running, \ mock.patch("app.gui.accounts.detect_login") as detect_login, \ mock.patch("app.gui.editor.apply_task") as apply_task: worker = ApplyWorker( tasks, db_path=cfg["db_path"], config=cfg, dry_run=True, max_parallel_accounts=2, ) worker.log.connect(logs.append) summary = worker.execute() is_running.assert_not_called() detect_login.assert_not_called() apply_task.assert_not_called() self.assertTrue(summary["ok"]) self.assertTrue(summary["dry_run"]) self.assertEqual(2, summary["done"]) self.assertEqual(1, summary["applied"]) self.assertEqual(1, summary["skipped"]) unchanged = db.list_tasks(batch_id=batch_id, path=cfg["db_path"]) self.assertTrue(all(task.stage == "generated" for task in unchanged)) self.assertTrue(all(task.status == "success" for task in unchanged)) self.assertTrue(any("检查" in line for line in logs)) run_log = db.list_run_logs(run_type="apply", path=cfg["db_path"])[0] self.assertEqual(1, run_log.dry_run) self.assertEqual("done", run_log.status) events = db.list_run_log_events(run_log.id, path=cfg["db_path"]) self.assertTrue(any("将更新" in event.message for event in events)) self.assert_removed(temp_dir) def test_apply_worker_parallel_accounts_runs_different_accounts_concurrently(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) accounts.create_account("副店", "alias-b", debug_port=9223, 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-b", "item_id": "51100639511", }, ], path=cfg["db_path"], ) for task in db.list_tasks(batch_id=batch_id, path=cfg["db_path"]): db.set_collected( task.id, "旧标题", "old.jpg", product_status_value="normal", path=cfg["db_path"], ) db.set_generated(task.id, "新标题", "new.jpg", path=cfg["db_path"]) tasks = db.list_tasks(batch_id=batch_id, path=cfg["db_path"]) started = {"alias-a": threading.Event(), "alias-b": threading.Event()} thread_names = set() foreground_by_alias = {} def fake_apply(account, task, close_success_tab=False, on_step=None, bring_to_front=True, update_mode=None): thread_names.add(threading.current_thread().name) foreground_by_alias[account.alias] = bring_to_front started[account.alias].set() other = "alias-b" if account.alias == "alias-a" else "alias-a" self.assertTrue(started[other].wait(2)) return {"committed": True, "error": None} with mock.patch("app.gui.chrome.is_running", return_value=True), \ mock.patch( "app.gui.accounts.detect_login", return_value={"logged_in": True, "reason": None}, ), mock.patch("app.gui.editor.apply_task", side_effect=fake_apply): summary = ApplyWorker( tasks, db_path=cfg["db_path"], config=cfg, max_parallel_accounts=2, ).execute() self.assertTrue(summary["ok"]) self.assertTrue(summary["account_parallel"]) self.assertEqual(2, summary["applied"]) self.assertGreaterEqual(len(thread_names), 2) self.assertEqual({"alias-a": True, "alias-b": True}, foreground_by_alias) updated = db.list_tasks(batch_id=batch_id, path=cfg["db_path"]) self.assertTrue(all(task.stage == "applied" for task in updated)) self.assert_removed(temp_dir) def test_apply_worker_blocks_real_update_when_required_accounts_share_debug_port(self): with self.make_temp_dir() as temp_dir: cfg = self.make_config(temp_dir) db.init_db(cfg["db_path"]) db.add_account("主店", "alias-a", "seller.shopee.tw", 9222, path=cfg["db_path"]) db.add_account("副店", "alias-b", "seller.shopee.tw", 9222, path=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", }, { "source_file_abs": os.path.join(temp_dir, "input.xlsx"), "source_sheet": "商品", "source_row": 3, "account_name": "Excel副店", "alias": "alias-b", "item_id": "51100639511", }, ], path=cfg["db_path"], ) for task in db.list_tasks(batch_id=batch_id, path=cfg["db_path"]): db.set_collected( task.id, "旧标题", "old.jpg", product_status_value="normal", path=cfg["db_path"], ) db.set_generated(task.id, "新标题", "new.jpg", path=cfg["db_path"]) tasks = db.list_tasks(batch_id=batch_id, path=cfg["db_path"]) with mock.patch("app.gui.chrome.is_running") as is_running, \ mock.patch("app.gui.editor.apply_task") as apply_task: summary = ApplyWorker( tasks, db_path=cfg["db_path"], config=cfg, max_parallel_accounts=2, ).execute() self.assertTrue(summary["blocked"]) self.assertEqual("DUPLICATE_DEBUG_PORT", summary["reason"]) self.assertEqual(9222, summary["duplicate_ports"][0]["debug_port"]) self.assertEqual(["alias-a", "alias-b"], summary["duplicate_ports"][0]["aliases"]) is_running.assert_not_called() apply_task.assert_not_called() run_log = db.list_run_logs(run_type="apply", path=cfg["db_path"])[0] self.assertEqual("blocked", run_log.status) self.assert_removed(temp_dir) def test_apply_tab_auto_starts_result_write_back_and_shows_summary(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, "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] db.set_collected(task.id, "旧标题", "old.jpg", path=cfg["db_path"]) db.set_generated(task.id, "新标题", "new.jpg", path=cfg["db_path"]) db.set_applied(task.id, True, path=cfg["db_path"]) statuses = [] tab = ApplyTab(config=cfg, status_callback=statuses.append) self.addCleanup(tab.close) payload = { "done": 1, "total": 1, "applied": 1, "failed": 0, "skipped": 0, "batch_ids": [batch_id], } with mock.patch.object(tab, "_start_result_write_back", return_value=True) as start_write_back: tab._on_apply_finished(payload) start_write_back.assert_called_once_with( [batch_id], auto=True, apply_summary=payload, ) self.assertIn("正在自动回写结果到 Excel", statuses[-1]) self.assert_removed(temp_dir) def test_apply_tab_result_write_back_finished_shows_completion_popup(self): with self.make_temp_dir() as temp_dir: statuses = [] tab = ApplyTab(config=self.make_config(temp_dir), status_callback=statuses.append) self.addCleanup(tab.close) summary = {"applied": 2, "failed": 1, "skipped": 1} write_back_payload = {"ok": True, "files": 1, "rows": 4} with mock.patch("app.gui.QMessageBox.information") as info: tab._on_result_write_back_finished( write_back_payload, auto=True, apply_summary=summary, ) message = info.call_args[0][2] self.assertIn("成功:2,失败:1,略过:1", message) self.assertIn("Excel 回写:文件1,行4", message) self.assertIn("Excel 自动回写更新结果完成", statuses[-1]) self.assert_removed(temp_dir) def test_apply_tab_result_write_back_locked_file_message_points_to_manual_retry(self): with self.make_temp_dir() as temp_dir: statuses = [] tab = ApplyTab(config=self.make_config(temp_dir), status_callback=statuses.append) self.addCleanup(tab.close) summary = {"applied": 1, "failed": 0, "skipped": 0} with mock.patch("app.gui.QMessageBox.warning") as warning: tab._on_result_write_back_failed( -1, "Excel 文件被占用,请关闭后重试: input.xlsx", auto=True, apply_summary=summary, ) message = warning.call_args[0][2] self.assertIn("成功:1,失败:0,略过:0", message) self.assertIn("点击「回写结果到 Excel」手动重试", message) self.assertIn("手动重试", statuses[-1]) self.assert_removed(temp_dir) def test_apply_worker_preflight_blocks_when_chrome_not_running(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", } ], 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"], ) db.set_generated(task.id, "新标题", "new.jpg", path=cfg["db_path"]) tasks = db.list_tasks(batch_id=batch_id, path=cfg["db_path"]) with mock.patch("app.gui.chrome.is_running", return_value=False) as is_running, \ mock.patch("app.gui.accounts.detect_login") as detect_login, \ mock.patch("app.gui.accounts.launch_for_login") as launch_for_login, \ mock.patch("app.gui.editor.apply_task") as apply_task: summary = ApplyWorker(tasks, db_path=cfg["db_path"], config=cfg).execute() self.assertTrue(summary["blocked"]) self.assertEqual("ACCOUNT_NOT_READY", summary["reason"]) self.assertEqual("alias-a", summary["not_running"][0]["alias"]) is_running.assert_called_once_with(9222) detect_login.assert_not_called() launch_for_login.assert_not_called() apply_task.assert_not_called() unchanged = db.list_tasks(batch_id=batch_id, path=cfg["db_path"])[0] self.assertEqual("generated", unchanged.stage) self.assertEqual("success", unchanged.status) self.assertIsNone(unchanged.last_error) self.assert_removed(temp_dir) def test_apply_tab_blocked_preflight_guides_to_accounts_tab(self): with self.make_temp_dir() as temp_dir: opened = [] statuses = [] tab = ApplyTab( config=self.make_config(temp_dir), status_callback=statuses.append, open_accounts_callback=lambda: opened.append(True), ) self.addCleanup(tab.close) payload = { "blocked": True, "not_running": [ { "account_name": "主店", "alias": "alias-a", "reason": "CDP 端口未响应", } ], "logged_out": [], } with mock.patch("app.gui.QMessageBox.warning") as warning: tab._on_apply_finished(payload) message = warning.call_args[0][2] self.assertIn("Chrome 未启动", message) self.assertIn("本轮更新已中止", message) self.assertIn("不会自动打开账号 Chrome", message) self.assertIn("不会提交任何商品", message) self.assertIn("账号管理", message) self.assertEqual([True], opened) self.assertIn("本轮更新已中止", statuses[-1]) self.assertIn("账号管理", statuses[-1]) self.assert_removed(temp_dir) def test_generate_tab_lists_tasks_and_filters_by_shop_status_and_batch(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) accounts.create_account("副店", "alias-b", debug_port=9223, config=cfg) batch_a = db.create_batch(["input-a.xlsx"], path=cfg["db_path"]) batch_b = db.create_batch(["input-b.xlsx"], path=cfg["db_path"]) db.insert_tasks( batch_a, [ { "source_file_abs": os.path.join(temp_dir, "input-a.xlsx"), "source_sheet": "商品", "source_row": 2, "account_name": "Excel主店", "alias": "alias-a", "item_id": "51100639510", }, { "source_file_abs": os.path.join(temp_dir, "input-a.xlsx"), "source_sheet": "商品", "source_row": 3, "account_name": "Excel副店", "alias": "alias-b", "item_id": "51100639511", }, { "source_file_abs": os.path.join(temp_dir, "input-a.xlsx"), "source_sheet": "商品", "source_row": 4, "account_name": "Excel主店", "alias": "alias-a", "item_id": "51100639512", }, ], path=cfg["db_path"], ) db.insert_tasks( batch_b, [ { "source_file_abs": os.path.join(temp_dir, "input-b.xlsx"), "source_sheet": "商品", "source_row": 2, "account_name": "Excel主店", "alias": "alias-a", "item_id": "51100639513", } ], path=cfg["db_path"], ) tasks_a = db.list_tasks(batch_id=batch_a, path=cfg["db_path"]) tasks_b = db.list_tasks(batch_id=batch_b, path=cfg["db_path"]) db.set_collected(tasks_a[0].id, "旧标题A", "old-a.jpg", path=cfg["db_path"]) db.set_collected(tasks_a[1].id, "旧标题B", "old-b.jpg", path=cfg["db_path"]) db.set_collected(tasks_a[2].id, "旧标题C", "old-c.jpg", path=cfg["db_path"]) db.set_generated(tasks_a[2].id, "新标题C", "new-c.jpg", path=cfg["db_path"]) db.mark_failed(tasks_b[0].id, "generate", "生成失败", path=cfg["db_path"]) db.set_product_status(tasks_a[0].id, "normal", path=cfg["db_path"]) db.set_product_status(tasks_a[1].id, "unlisted", path=cfg["db_path"]) db.set_product_status(tasks_b[0].id, "unknown", path=cfg["db_path"]) tab = GenerateTab(config=cfg) self.addCleanup(tab.close) tab.batch_filter.setCurrentIndex(tab.batch_filter.findData(None)) self.assertEqual("generateItemFilter", tab.item_filter.objectName()) self.assertEqual("generateProductStatusFilter", tab.product_status_filter.objectName()) self.assertEqual( ["全部商品状态", "架上商品", "未上架", "审核中", "状态未知", "待检测"], [ tab.product_status_filter.itemText(index) for index in range(tab.product_status_filter.count()) ], ) self.assertEqual(4, tab.model.rowCount()) self.assertEqual("主店", tab.model.index(0, 0).data()) self.assertEqual("51100639510", tab.model.index(0, 1).data()) self.assertEqual("旧标题A", tab.model.index(0, 2).data()) self.assertEqual("", tab.model.index(0, 3).data()) self.assertEqual("待生成", tab.model.index(0, 4).data()) self.assert_foreground(tab.model, 0, 4, gui.COLOR_PENDING) self.assertEqual("已生成", tab.model.index(2, 4).data()) self.assert_foreground(tab.model, 2, 4, gui.COLOR_SUCCESS) tab.status_filter.setCurrentIndex(tab.status_filter.findData("to_generate")) self.assertEqual(2, tab.model.rowCount()) self.assertTrue(all(tab.model.index(row, 4).data() == "待生成" for row in range(2))) self.assertEqual("任务 2/4 条", tab.summary_label.text()) tab.product_status_filter.setCurrentIndex( tab.product_status_filter.findData("normal") ) self.assertEqual(1, tab.model.rowCount()) self.assertEqual("51100639510", tab.model.index(0, 1).data()) normal_plan = product_status.build_generation_plan( tab._generation_candidates("title"), "title", "normal_only", ) self.assertEqual(1, len(normal_plan["execution_tasks"])) tab.product_status_filter.setCurrentIndex( tab.product_status_filter.findData("unlisted") ) self.assertEqual(1, tab.model.rowCount()) unlisted_plan = product_status.build_generation_plan( tab._generation_candidates("title"), "title", "normal_only", ) self.assertEqual([], unlisted_plan["execution_tasks"]) tab.status_filter.setCurrentIndex(tab.status_filter.findData("all")) tab.product_status_filter.setCurrentIndex( tab.product_status_filter.findData("unchecked") ) self.assertEqual(1, tab.model.rowCount()) self.assertEqual("51100639512", tab.model.index(0, 1).data()) tab.product_status_filter.setCurrentIndex(tab.product_status_filter.findData("all")) tab.shop_filter.setCurrentIndex(tab.shop_filter.findData("alias-b")) self.assertEqual(1, tab.model.rowCount()) self.assertEqual("副店", tab.model.index(0, 0).data()) tab.status_filter.setCurrentIndex(tab.status_filter.findData("all")) tab.batch_filter.setCurrentIndex(tab.batch_filter.findData(batch_b)) self.assertEqual(1, tab.model.rowCount()) self.assertEqual("失败", tab.model.index(0, 4).data()) self.assert_foreground(tab.model, 0, 4, gui.COLOR_DANGER) self.assertEqual( "生成失败", tab.model.data(tab.model.index(0, 0), gui.Qt.ToolTipRole), ) tab.batch_filter.setCurrentIndex(tab.batch_filter.findData(None)) tab.status_filter.setCurrentIndex(tab.status_filter.findData("all")) tab.item_filter.setText("639512") self.assertEqual(1, tab.model.rowCount()) self.assertEqual("51100639512", tab.model.index(0, 1).data()) self.assertEqual("任务 1/4 条", tab.summary_label.text()) self.assert_removed(temp_dir) def test_tab_switch_updates_status_bar(self): with self.make_temp_dir() as temp_dir: window = MainWindow(config=self.make_config(temp_dir)) self.addCleanup(window.close) window.tabs.setCurrentIndex(2) self.assertEqual("③ 更新蝦皮", window.tabs.tabText(window.tabs.currentIndex())) self.assertEqual("当前:③ 更新蝦皮", window.statusBar().currentMessage()) self.assert_removed(temp_dir) def test_accounts_tab_lists_accounts_without_showing_password(self): with self.make_temp_dir() as temp_dir: cfg = self.make_config(temp_dir) accounts.create_account( "主店", "alias", "seller.shopee.tw", 9222, password="secret", config=cfg, ) window = MainWindow(config=cfg) self.addCleanup(window.close) tab = window.tabs.widget(TAB_TITLES.index("账号管理")) self.assertIsInstance(tab, AccountsTab) self.assertEqual(1, tab.table.rowCount()) self.assertEqual("主店", tab.table.item(0, 0).text()) self.assertEqual("alias", tab.table.item(0, 1).text()) self.assertEqual("● 未知", tab.table.item(0, 4).text()) self.assertEqual(gui.COLOR_MUTED, tab.table.item(0, 4).foreground().color().name()) self.assertEqual("deleteAccountButton", tab.delete_button.objectName()) self.assertIn(gui.COLOR_DANGER, tab.delete_button.styleSheet()) self.assertNotIn("border-radius", tab.delete_button.styleSheet()) self.assertNotIn("padding", tab.delete_button.styleSheet()) visible_values = [ tab.table.item(0, column).text() for column in range(tab.table.columnCount()) if tab.table.item(0, column) is not None ] self.assertNotIn("secret", visible_values) self.assert_removed(temp_dir) def test_account_dialog_masks_password_field(self): with self.make_temp_dir() as temp_dir: cfg = self.make_config(temp_dir) account = accounts.create_account( "主店", "alias", debug_port=9222, password="secret", config=cfg, ) dialog = AccountDialog(account=account, config=cfg) self.addCleanup(dialog.close) self.assertEqual(QLineEdit.Password, dialog.password_edit.echoMode()) self.assertEqual("secret", dialog.password_edit.text()) self.assertIn(account.slug, dialog.user_data_dir_edit.text()) self.assert_removed(temp_dir) def test_accounts_tab_warns_before_saving_plaintext_password(self): with self.make_temp_dir() as temp_dir: cfg = self.make_config(temp_dir) statuses = [] tab = AccountsTab(config=cfg, status_callback=statuses.append) self.addCleanup(tab.close) class FakeDialog: def __init__(self, *args, **kwargs): pass def exec(self): return gui.QDialog.Accepted def values(self): return { "account_name": "主店", "alias": "alias", "region_host": "seller.shopee.tw", "debug_port": 9222, "password": "plain-password", "note": "", } with mock.patch("app.gui.AccountDialog", FakeDialog), mock.patch( "app.gui.QMessageBox.warning" ) as warning, mock.patch( "app.gui.accounts.launch_for_login" ) as launch_for_login: tab.add_account() warning.assert_called_once() self.assertIn("本地明文保存", warning.call_args[0][1]) self.assertIn("SQLite", warning.call_args[0][2]) stored = accounts.get_account("alias", config=cfg) self.assertEqual("plain-password", stored.password) visible_values = [ tab.table.item(0, column).text() for column in range(tab.table.columnCount()) if tab.table.item(0, column) is not None ] self.assertNotIn("plain-password", visible_values) self.assertIn("账号已新增", statuses[-1]) self.assertNotIn("plain-password", statuses[-1]) launch_for_login.assert_not_called() self.assert_removed(temp_dir) def test_accounts_tab_updates_login_status_cell(self): with self.make_temp_dir() as temp_dir: cfg = self.make_config(temp_dir) accounts.create_account("主店", "alias", debug_port=9222, config=cfg) tab = AccountsTab(config=cfg) self.addCleanup(tab.close) tab._on_login_check_finished( {"alias": "alias", "status": {"logged_in": True, "reason": None}} ) self.assertEqual("● 已登录", tab.table.item(0, 4).text()) self.assertEqual(gui.COLOR_SUCCESS, tab.table.item(0, 4).foreground().color().name()) self.assert_removed(temp_dir) def test_accounts_tab_create_shortcut_for_selected_account(self): with self.make_temp_dir() as temp_dir: cfg = self.make_config(temp_dir) account = accounts.create_account("主店", "alias", debug_port=9222, config=cfg) statuses = [] tab = AccountsTab(config=cfg, status_callback=statuses.append) self.addCleanup(tab.close) tab.table.selectRow(0) shortcut_path = os.path.join(temp_dir, "Desktop", "主店.lnk") with mock.patch( "app.gui.accounts.create_shortcut", return_value=shortcut_path, ) as create_shortcut, mock.patch("app.gui.QMessageBox.information") as info: tab.create_shortcut() create_shortcut.assert_called_once_with(account, config=cfg) info.assert_called_once() self.assertIn(shortcut_path, statuses[-1]) self.assert_removed(temp_dir) def test_collect_tab_lists_tasks_with_unmatched_alias_as_skipped(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": "missing", "item_id": "51100639511", }, ], path=cfg["db_path"], ) tab = CollectTab(config=cfg) self.addCleanup(tab.close) self.assertIsInstance(tab.table, QTableView) self.assertEqual(2, tab.model.rowCount()) self.assertEqual("主店", tab.model.index(0, 0).data()) self.assertEqual("alias-a", tab.model.index(0, 1).data()) self.assertEqual("51100639510", tab.model.index(0, 2).data()) self.assertEqual("待采集", tab.model.index(0, 3).data()) self.assert_foreground(tab.model, 0, 3, gui.COLOR_PENDING) self.assertEqual("deleteBatchButton", tab.delete_batch_button.objectName()) self.assertIn(gui.COLOR_DANGER, tab.delete_batch_button.styleSheet()) self.assertNotIn("border-radius", tab.delete_batch_button.styleSheet()) self.assertNotIn("padding", tab.delete_batch_button.styleSheet()) self.assertEqual("Excel副店", tab.model.index(1, 0).data()) self.assertEqual("missing", tab.model.index(1, 1).data()) self.assertEqual("略过 · 别名未匹配", tab.model.index(1, 3).data()) self.assertEqual( "别名未匹配账号,采集时将略过", tab.model.index(1, 3).data(Qt.ToolTipRole), ) self.assert_foreground(tab.model, 1, 3, gui.COLOR_MUTED) self.assertIn("2 行", tab.summary_label.text()) self.assertIn("有效2/无效0", tab.summary_label.text()) self.assertIn("匹配1", tab.summary_label.text()) self.assertIn("未匹配1", tab.summary_label.text()) self.assertIn(gui.COLOR_DANGER, tab.summary_label.text()) self.assertIn(gui.COLOR_DANGER, tab.show_unmatched_button.styleSheet()) self.assertIn("border", tab.show_unmatched_button.styleSheet()) self.assertNotIn("border-radius", tab.show_unmatched_button.styleSheet()) self.assertNotIn("padding", tab.show_unmatched_button.styleSheet()) self.assertIn("主店1", tab.match_detail_label.text()) self.assertIn("未匹配", tab.empty_label.text()) self.assert_removed(temp_dir) def test_collect_tab_filters_by_shop_item_status_and_collect_scope(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) accounts.create_account("副店", "alias-b", debug_port=9223, 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": "1001", }, { "source_file_abs": os.path.join(temp_dir, "input.xlsx"), "source_sheet": "商品", "source_row": 3, "account_name": "Excel主店", "alias": "alias-a", "item_id": "1002", }, { "source_file_abs": os.path.join(temp_dir, "input.xlsx"), "source_sheet": "商品", "source_row": 4, "account_name": "Excel副店", "alias": "alias-b", "item_id": "2001", }, { "source_file_abs": os.path.join(temp_dir, "input.xlsx"), "source_sheet": "商品", "source_row": 5, "account_name": "Excel副店", "alias": "alias-b", "item_id": "2002", }, { "source_file_abs": os.path.join(temp_dir, "input.xlsx"), "source_sheet": "商品", "source_row": 6, "account_name": "Excel未知", "alias": "missing", "item_id": "3001", }, ], path=cfg["db_path"], ) tasks = {task.item_id: task for task in db.list_tasks(batch_id=batch_id, path=cfg["db_path"])} db.set_collected(tasks["1002"].id, "旧标题", os.path.join(temp_dir, "old.jpg"), path=cfg["db_path"]) db.mark_failed(tasks["2002"].id, "collect", "采集失败", path=cfg["db_path"]) db.set_product_status(tasks["1002"].id, "normal", path=cfg["db_path"]) db.set_product_status(tasks["2001"].id, "unlisted", path=cfg["db_path"]) db.set_product_status(tasks["2002"].id, "reviewing", path=cfg["db_path"]) db.set_product_status(tasks["3001"].id, "unknown", path=cfg["db_path"]) tab = CollectTab(config=cfg) self.addCleanup(tab.close) self.assertEqual("collectShopFilter", tab.shop_filter.objectName()) self.assertEqual("collectItemFilter", tab.item_filter.objectName()) self.assertEqual("collectProductStatusFilter", tab.product_status_filter.objectName()) self.assertEqual("collectStatusFilter", tab.status_filter.objectName()) self.assertEqual( ["全部商品状态", "架上商品", "未上架", "审核中", "状态未知", "待检测"], [ tab.product_status_filter.itemText(index) for index in range(tab.product_status_filter.count()) ], ) self.assertEqual(5, tab.model.rowCount()) self.assertEqual("待检测", tab.model.index(0, 4).data()) self.assertEqual("架上商品", tab.model.index(1, 4).data()) self.assertIn("5 行", tab.summary_label.text()) self.assertEqual("未匹配(1)", tab.show_unmatched_button.text()) self.assertIn("总数5", tab.batch_progress_label.text()) tab.product_status_filter.setCurrentIndex( tab.product_status_filter.findData("normal") ) self.assertEqual(1, tab.model.rowCount()) self.assertEqual("1002", tab.model.index(0, 2).data()) tab.status_filter.setCurrentIndex(tab.status_filter.findData("collected")) self.assertEqual(1, tab.model.rowCount()) tab.product_status_filter.setCurrentIndex( tab.product_status_filter.findData("unlisted") ) self.assertEqual(0, tab.model.rowCount()) tab.status_filter.setCurrentIndex(tab.status_filter.findData("all")) self.assertEqual(1, tab.model.rowCount()) self.assertEqual("2001", tab.model.index(0, 2).data()) tab.product_status_filter.setCurrentIndex(tab.product_status_filter.findData("all")) tab.shop_filter.setCurrentIndex(tab.shop_filter.findData("alias-a")) self.assertEqual(2, tab.model.rowCount()) self.assertEqual(["1001", "1002"], [tab.model.index(row, 2).data() for row in range(tab.model.rowCount())]) self.assertIn("5 行", tab.summary_label.text()) self.assertEqual("未匹配(1)", tab.show_unmatched_button.text()) tab.item_filter.setText("1002") self.assertEqual(1, tab.model.rowCount()) self.assertEqual("1002", tab.model.index(0, 2).data()) tab.status_filter.setCurrentIndex(tab.status_filter.findData("collected")) self.assertEqual(1, tab.model.rowCount()) self.assertEqual("已采集", tab.model.index(0, 3).data()) tab.shop_filter.setCurrentIndex(tab.shop_filter.findData(None)) tab.item_filter.clear() tab.status_filter.setCurrentIndex(tab.status_filter.findData("skipped")) self.assertEqual(1, tab.model.rowCount()) self.assertEqual("missing", tab.model.index(0, 1).data()) self.assertEqual("略过 · 别名未匹配", tab.model.index(0, 3).data()) tab.item_filter.setText("no-match") self.assertEqual(0, tab.model.rowCount()) self.assertIn("当前筛选没有匹配任务", tab.empty_label.text()) tab.shop_filter.setCurrentIndex(tab.shop_filter.findData("alias-a")) tab.item_filter.setText("1001") tab.status_filter.setCurrentIndex(tab.status_filter.findData("to_collect")) tab.show_all_tasks() self.assertEqual(1, tab.model.rowCount()) self.assertEqual("1001", tab.model.index(0, 2).data()) class FakeSignal: def __init__(self): self.callbacks = [] def connect(self, callback): self.callbacks.append(callback) class FakeWorker: def __init__(self, tasks, **kwargs): captured["tasks"] = tasks captured["kwargs"] = kwargs self.progress = FakeSignal() self.row_updated = FakeSignal() self.log = FakeSignal() self.failed = FakeSignal() self.finished = FakeSignal() self.cancelled = FakeSignal() def cancel(self): captured["cancelled"] = True class FakeThread: def __init__(self): self.finished = FakeSignal() def start(self): captured["started"] = True captured = {} with mock.patch.object( tab, "_choose_collect_scope", return_value="normal_only", ), mock.patch("app.gui.CollectWorker", FakeWorker), mock.patch( "app.gui.run_worker", return_value=FakeThread(), ): tab.collect_old_data() self.assertTrue(captured["started"]) self.assertEqual(["1001"], [task.item_id for task in captured["tasks"]]) self.assertEqual(cfg["db_path"], captured["kwargs"]["db_path"]) self.assertEqual("normal_only", captured["kwargs"]["collect_scope"]) self.assertFalse(tab.shop_filter.isEnabled()) self.assertFalse(tab.item_filter.isEnabled()) self.assertFalse(tab.product_status_filter.isEnabled()) self.assertFalse(tab.status_filter.isEnabled()) self.assert_removed(temp_dir) def test_collect_scope_dialog_uses_safe_default_and_warning_all_status_choice(self): with self.make_temp_dir() as temp_dir: tab = CollectTab(config=self.make_config(temp_dir)) self.addCleanup(tab.close) captured = {} def click_all_statuses(): box = QApplication.activeModalWidget() normal_button = box.findChild(QPushButton, "collectNormalOnlyButton") all_button = box.findChild(QPushButton, "collectAllStatusesButton") captured["default"] = box.defaultButton().objectName() captured["all_style"] = all_button.styleSheet() captured["minimum_width"] = box.minimumWidth() captured["normal_button_width"] = normal_button.width() captured["all_button_width"] = all_button.width() captured["normal_text_width"] = normal_button.fontMetrics().horizontalAdvance( normal_button.text() ) captured["all_text_width"] = all_button.fontMetrics().horizontalAdvance( all_button.text() ) self.assertIsNotNone(normal_button) self.assertIsNotNone(all_button) self.assertEqual("采集架上商品", normal_button.text()) self.assertEqual("采集全部商品", all_button.text()) self.assertIn("检测结果为正常", box.informativeText()) self.assertIn("未上架、审核中和状态未知", box.informativeText()) all_button.click() QTimer.singleShot(0, click_all_statuses) scope = tab._choose_collect_scope() self.assertEqual("all", scope) self.assertEqual("collectNormalOnlyButton", captured["default"]) self.assertGreaterEqual(captured["minimum_width"], 520) self.assertGreaterEqual(captured["normal_button_width"], 480) self.assertGreaterEqual(captured["all_button_width"], 480) self.assertGreater(captured["normal_button_width"], captured["normal_text_width"]) self.assertGreater(captured["all_button_width"], captured["all_text_width"]) self.assertIn("#bc4c00", captured["all_style"]) self.assertNotIn("#cf222e", captured["all_style"]) self.assert_removed(temp_dir) def test_collect_tab_does_not_show_legacy_status_recheck_entry(self): with self.make_temp_dir() as temp_dir: cfg = self.make_config(temp_dir) db.init_db(cfg["db_path"]) tab = CollectTab(config=cfg) self.addCleanup(tab.close) self.assertIsNone(tab.findChild(QPushButton, "statusRecheckButton")) self.assertFalse(hasattr(tab, "recheck_product_status")) self.assert_removed(temp_dir) def test_collect_scope_dialog_accepts_default_and_escape_cancels(self): with self.make_temp_dir() as temp_dir: tab = CollectTab(config=self.make_config(temp_dir)) self.addCleanup(tab.close) def press_return(): box = QApplication.activeModalWidget() button = box.findChild(QPushButton, "collectNormalOnlyButton") QTest.keyClick(button, Qt.Key_Return) QTimer.singleShot(0, press_return) self.assertEqual("normal_only", tab._choose_collect_scope()) def press_escape(): box = QApplication.activeModalWidget() QTest.keyClick(box, Qt.Key_Escape) QTimer.singleShot(0, press_escape) self.assertIsNone(tab._choose_collect_scope()) self.assert_removed(temp_dir) def test_collect_without_candidates_does_not_open_scope_dialog(self): with self.make_temp_dir() as temp_dir: statuses = [] tab = CollectTab(config=self.make_config(temp_dir), status_callback=statuses.append) self.addCleanup(tab.close) with mock.patch.object(tab, "_choose_collect_scope") as choose_scope: tab.collect_old_data() choose_scope.assert_not_called() self.assertEqual("没有可采集任务", statuses[-1]) self.assert_removed(temp_dir) def test_collect_activity_tracks_step_resets_each_task_and_freezes_on_stop(self): with self.make_temp_dir() as temp_dir: tab = CollectTab(config=self.make_config(temp_dir)) self.addCleanup(tab.close) self.assertEqual("collectActivityLabel", tab.collect_activity_label.objectName()) self.assertTrue(tab.collect_activity_label.isHidden()) self.assertEqual("1:01:01", _format_collect_elapsed(3661)) with mock.patch("app.gui.tabs.collect.time.monotonic", return_value=100.0): tab._start_collect_activity() self.assertTrue(tab._collect_elapsed_timer.isActive()) self.assertIn("正在检查账号", tab.collect_activity_label.text()) self.assertIn("00:00", tab.collect_activity_label.text()) with mock.patch("app.gui.tabs.collect.time.monotonic", return_value=105.0): tab._on_collect_activity( { "state": "preflight_started", "total": 2, "step": "preflight", } ) self.assertIn("正在检查账号", tab.collect_activity_label.text()) self.assertIn("00:05", tab.collect_activity_label.text()) with mock.patch("app.gui.tabs.collect.time.monotonic", return_value=110.0): tab._on_collect_activity( { "state": "task_started", "index": 1, "total": 2, "task_id": 1, "item_id": "51100639510", "alias": "alias-a", "step": "match_account", } ) self.assertEqual( "正在采集 1/2 · 匹配账号 · 本条 00:00", tab.collect_activity_label.text(), ) with mock.patch("app.gui.tabs.collect.time.monotonic", return_value=117.0): tab._on_collect_activity( { "state": "task_step", "index": 1, "total": 2, "task_id": 1, "item_id": "51100639510", "alias": "alias-a", "step": "wait_ready", } ) self.assertEqual( "正在采集 1/2 · 等待商品页加载 · 本条 00:07", tab.collect_activity_label.text(), ) self.assertIn("商品ID:51100639510", tab.collect_activity_label.toolTip()) self.assertIn("当前阶段:等待商品页加载", tab.collect_activity_label.toolTip()) with mock.patch("app.gui.tabs.collect.time.monotonic", return_value=120.0): tab._on_collect_activity( { "state": "task_started", "index": 2, "total": 2, "task_id": 2, "item_id": "51100639511", "alias": "alias-a", "step": "check_login", } ) self.assertEqual( "正在采集 2/2 · 检查账号登录 · 本条 00:00", tab.collect_activity_label.text(), ) with mock.patch("app.gui.tabs.collect.time.monotonic", return_value=125.0): tab._collect_stop_requested = True tab._refresh_collect_activity() self.assertEqual("正在停止 · 本条 00:05", tab.collect_activity_label.text()) with mock.patch("app.gui.tabs.collect.time.monotonic", return_value=130.0): tab._finish_collect_activity("cancelled") self.assertEqual("采集已停止 · 总用时 00:30", tab.collect_activity_label.text()) self.assertFalse(tab._collect_elapsed_timer.isActive()) frozen_text = tab.collect_activity_label.text() with mock.patch("app.gui.tabs.collect.time.monotonic", return_value=999.0): tab._refresh_collect_activity() self.assertEqual(frozen_text, tab.collect_activity_label.text()) self.assert_removed(temp_dir) def test_collect_activity_terminal_states_use_chinese_semantics(self): with self.make_temp_dir() as temp_dir: tab = CollectTab(config=self.make_config(temp_dir)) self.addCleanup(tab.close) with mock.patch("app.gui.tabs.collect.time.monotonic", return_value=10.0): tab._start_collect_activity() with mock.patch("app.gui.tabs.collect.time.monotonic", return_value=15.0): tab._finish_collect_activity("blocked") self.assertEqual("采集未开始 · 检查未通过", tab.collect_activity_label.text()) self.assertFalse(tab._collect_elapsed_timer.isActive()) with mock.patch("app.gui.tabs.collect.time.monotonic", return_value=20.0): tab._start_collect_activity() with mock.patch("app.gui.tabs.collect.time.monotonic", return_value=25.0): tab._finish_collect_activity("error", {"error": "后台线程异常"}) self.assertEqual("采集已结束 · 请查看运行日志", tab.collect_activity_label.text()) self.assertEqual("采集异常结束,请查看下方采集运行日志", tab.collect_activity_label.toolTip()) with mock.patch("app.gui.tabs.collect.time.monotonic", return_value=30.0): tab._start_collect_activity() with mock.patch("app.gui.tabs.collect.time.monotonic", return_value=40.0): tab._finish_collect_activity("finished") self.assertEqual("采集完成 · 总用时 00:10", tab.collect_activity_label.text()) self.assertFalse(tab._collect_elapsed_timer.isActive()) self.assert_removed(temp_dir) def test_collect_tab_soft_deletes_batch_and_refreshes_workflow_tabs(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_a = db.create_batch(["input-a.xlsx"], path=cfg["db_path"]) batch_b = db.create_batch(["input-b.xlsx"], path=cfg["db_path"]) db.insert_tasks( batch_a, [ { "source_file_abs": os.path.join(temp_dir, "input-a.xlsx"), "source_sheet": "商品", "source_row": 2, "account_name": "Excel主店", "alias": "alias-a", "item_id": "51100639510", } ], path=cfg["db_path"], ) db.insert_tasks( batch_b, [ { "source_file_abs": os.path.join(temp_dir, "input-b.xlsx"), "source_sheet": "商品", "source_row": 2, "account_name": "Excel主店", "alias": "alias-a", "item_id": "51100639511", } ], path=cfg["db_path"], ) deleted_task = db.list_tasks(batch_id=batch_a, path=cfg["db_path"])[0] db.set_collected(deleted_task.id, "旧标题", "old.jpg", path=cfg["db_path"]) db.set_generated(deleted_task.id, "新标题", "new.jpg", path=cfg["db_path"]) db.set_applied(deleted_task.id, True, path=cfg["db_path"]) statuses = [] refresh_calls = [] generate_tab = GenerateTab(config=cfg) apply_tab = ApplyTab(config=cfg) 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.batch_filter.setCurrentIndex(collect_tab.batch_filter.findData(batch_a)) with mock.patch( "app.gui.QMessageBox.question", return_value=gui.QMessageBox.Yes, ) as question, mock.patch("app.gui.QMessageBox.information") as info: collect_tab.delete_current_batch() deleted_batch = db.get_batch(batch_a, path=cfg["db_path"], include_deleted=True) self.assertIsNotNone(deleted_batch.deleted_at) self.assertIsNone(db.get_batch(batch_a, path=cfg["db_path"])) self.assertEqual([], db.list_tasks(batch_id=batch_a, path=cfg["db_path"])) self.assertEqual(1, len(db.list_tasks(batch_id=batch_a, path=cfg["db_path"], include_deleted=True))) self.assertEqual(-1, collect_tab.batch_filter.findData(batch_a)) self.assertEqual(-1, generate_tab.batch_filter.findData(batch_a)) self.assertEqual(-1, apply_tab.batch_filter.findData(batch_a)) self.assertTrue(all(task.batch_id != batch_a for task in collect_tab.model.all_tasks)) self.assertTrue(all(task.batch_id != batch_a for task in generate_tab.model.tasks)) self.assertTrue(all(task.batch_id != batch_a for task in apply_tab.model.tasks)) self.assertEqual([True], refresh_calls) self.assertIn("不会回滚蝦皮", question.call_args[0][2]) self.assertIn("已软删除批次", info.call_args[0][2]) self.assertIn("已软删除批次", statuses[-1]) 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) 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": "missing", "item_id": "51100639511", }, ], path=cfg["db_path"], ) tab = CollectTab(config=cfg) self.addCleanup(tab.close) tab.show_unmatched_tasks() self.assertEqual(1, tab.model.rowCount()) self.assertEqual("missing", tab.model.index(0, 1).data()) self.assertEqual("略过 · 别名未匹配", tab.model.index(0, 3).data()) tab.show_all_tasks() self.assertEqual(2, tab.model.rowCount()) self.assert_removed(temp_dir) def test_collect_task_model_shows_historical_skip_reasons_and_tooltips(self): model = gui.TaskTableModel() account = SimpleNamespace(alias="alias-a", account_name="主店") login_reason = "采集中途掉登录: 账号未登录: LOGIN_PAGE" tasks = [ SimpleNamespace( alias="alias-a", account_name="主店", item_id="1001", stage="imported", status="skipped", last_error=login_reason, ), SimpleNamespace( alias="alias-a", account_name="主店", item_id="1002", stage="imported", status="skipped", last_error="运营手动略过", ), ] model.set_tasks(tasks, [account]) self.assertEqual("略过 · 账号未登录", model.index(0, 3).data()) self.assertEqual(login_reason, model.index(0, 3).data(Qt.ToolTipRole)) self.assertEqual("略过 · 其他原因", model.index(1, 3).data()) self.assertEqual("运营手动略过", model.index(1, 3).data(Qt.ToolTipRole)) def test_collect_tab_import_button_imports_excel_and_refreshes_tasks(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) excel_path = os.path.join(temp_dir, "input.xlsx") statuses = [] tab = CollectTab(config=cfg, status_callback=statuses.append) self.addCleanup(tab.close) created = {} def fake_import(file_paths, path=None): batch_id = db.create_batch(file_paths, path=path) created["batch_id"] = batch_id db.insert_tasks( batch_id, [ { "source_file_abs": excel_path, "source_sheet": "商品", "source_row": 2, "account_name": "Excel主店", "alias": "alias-a", "item_id": "51100639510", } ], path=path, ) return { "batch_id": batch_id, "rows": [], "stats": { "files": 1, "total": 2, "valid": 1, "invalid": 1, "inserted": 1, }, } with mock.patch.object( tab, "_choose_excel_files", return_value=[excel_path], ), mock.patch("app.gui.excel.import_tasks", side_effect=fake_import) as import_tasks, mock.patch( "app.gui.tabs.collect.QTimer.singleShot" ) as single_shot, mock.patch("app.gui.QMessageBox") as message_box: tab.import_excel() import_tasks.assert_called_once_with([excel_path], path=cfg["db_path"]) single_shot.assert_not_called() message_box.warning.assert_not_called() self.assertEqual(1, tab.model.rowCount()) self.assertEqual("alias-a", tab.model.index(0, 1).data()) self.assertIn("1 文件", tab.summary_label.text()) self.assertIn("2 行", tab.summary_label.text()) self.assertIn("有效1/", tab.summary_label.text()) self.assertIn("无效1", tab.summary_label.text()) self.assertIn(gui.COLOR_DANGER, tab.summary_label.text()) self.assertIn("匹配1", tab.summary_label.text()) self.assertEqual("", tab.show_unmatched_button.styleSheet()) self.assertIn("入库1", statuses[-1]) self.assertEqual(created["batch_id"], tab.batch_filter.currentData()) self.assert_removed(temp_dir) def test_collect_tab_import_error_dialog_is_delayed_one_event_loop(self): with self.make_temp_dir() as temp_dir: cfg = self.make_config(temp_dir) excel_path = os.path.join(temp_dir, "input.xlsx") statuses = [] tab = CollectTab(config=cfg, status_callback=statuses.append) self.addCleanup(tab.close) with mock.patch.object(tab, "_choose_excel_files", return_value=[excel_path]), mock.patch( "app.gui.excel.import_tasks", side_effect=RuntimeError("Excel导入失败 token=SECRET"), ), mock.patch("app.gui.QMessageBox") as message_box, mock.patch( "app.gui.tabs.collect.QTimer.singleShot" ) as single_shot: tab.import_excel() single_shot.assert_called_once() self.assertEqual(0, single_shot.call_args[0][0]) delayed_callback = single_shot.call_args[0][1] message_box.warning.assert_not_called() self.assertIn("token=***", statuses[-1]) run_log = db.list_run_logs(limit=1, run_type="import", path=cfg["db_path"])[0] self.assertEqual("failed", run_log.status) self.assertEqual(1, run_log.failed_count) events = db.list_run_log_events(run_log.id, path=cfg["db_path"]) messages = "\n".join(event.message for event in events) self.assertIn("step=import result=failed", messages) self.assertNotIn("SECRET", messages) delayed_callback() message_box.warning.assert_called_once() self.assertEqual("导入采集", message_box.warning.call_args[0][1]) self.assertIn("Excel导入失败", message_box.warning.call_args[0][2]) self.assertIn("token=***", message_box.warning.call_args[0][2]) self.assert_removed(temp_dir) def test_collect_worker_collects_success_and_skips_unmatched_or_logged_out(self): with self.make_temp_dir() as temp_dir: cfg = self.make_config(temp_dir) logged = accounts.create_account("主店", "alias-a", debug_port=9222, config=cfg) logged_out = accounts.create_account("副店", "alias-b", debug_port=9223, 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-b", "item_id": "51100639511", }, { "source_file_abs": os.path.join(temp_dir, "input.xlsx"), "source_sheet": "商品", "source_row": 4, "account_name": "Excel未知", "alias": "missing", "item_id": "51100639512", }, ], path=cfg["db_path"], ) tasks = db.list_tasks(batch_id=batch_id, path=cfg["db_path"]) expected_old_cover = os.path.join(str(batch_id), logged.slug, f"{tasks[0].id}_51100639510_old.jpg") def fake_login(account, path=None, config=None): self.assertEqual(cfg["db_path"], path) if account.alias == logged.alias: return {"logged_in": True, "reason": None} if account.alias == logged_out.alias: return {"logged_in": False, "reason": "LOGIN_PAGE"} raise AssertionError(account.alias) def fake_collect(account, task, on_step=None): self.assertEqual(logged.alias, account.alias) self.assertEqual("51100639510", task["item_id"]) self.assertTrue(task["old_cover_path"].endswith(expected_old_cover)) self.assertTrue(callable(on_step)) on_step("download_cover") return { "old_title": "旧标题", "old_cover_path": task["old_cover_path"], "product_status": "normal", "product_status_note": None, } worker = CollectWorker( tasks, db_path=cfg["db_path"], config=cfg, preflight=False, ) activity_events = [] worker.activity.connect(activity_events.append) with mock.patch("app.gui.accounts.detect_login", side_effect=fake_login), \ mock.patch("app.gui.editor.collect", side_effect=fake_collect) as collect: summary = worker.execute() self.assertTrue(summary["ok"]) self.assertEqual(3, summary["total"]) self.assertEqual(3, summary["done"]) self.assertEqual(1, summary["collected"]) self.assertEqual(2, summary["skipped"]) self.assertEqual(0, summary["failed"]) self.assertEqual( {"alias_unmatched": 1, "login_required": 1, "other": 0}, summary["skip_reason_counts"], ) self.assertEqual([batch_id], summary["batch_ids"]) self.assertIsInstance(summary["run_id"], int) collect.assert_called_once() self.assertEqual("preflight_started", activity_events[0]["state"]) self.assertEqual(3, activity_events[0]["total"]) task_starts = [event for event in activity_events if event["state"] == "task_started"] self.assertEqual([1, 2, 3], [event["index"] for event in task_starts]) self.assertEqual( ["51100639510", "51100639511", "51100639512"], [event["item_id"] for event in task_starts], ) self.assertTrue( any( event["state"] == "task_step" and event.get("step") == "download_cover" for event in activity_events ) ) task_finishes = [event for event in activity_events if event["state"] == "task_finished"] self.assertEqual( ["success", "skipped", "skipped"], [event["result"] for event in task_finishes], ) updated = db.list_tasks(batch_id=batch_id, path=cfg["db_path"]) by_alias = {task.alias: task for task in updated} self.assertEqual("collected", by_alias["alias-a"].stage) self.assertEqual("success", by_alias["alias-a"].status) self.assertEqual("旧标题", by_alias["alias-a"].old_title) self.assertTrue(by_alias["alias-a"].old_cover_path.endswith(expected_old_cover)) self.assertEqual("normal", by_alias["alias-a"].product_status) self.assertIsNotNone(by_alias["alias-a"].product_status_at) self.assertEqual("imported", by_alias["alias-b"].stage) self.assertEqual("skipped", by_alias["alias-b"].status) self.assertIn("账号未登录", by_alias["alias-b"].last_error) self.assertEqual("skipped", by_alias["missing"].status) self.assertEqual("别名未匹配账号", by_alias["missing"].last_error) run_logs = db.list_run_logs(limit=1, run_type="collect", path=cfg["db_path"]) self.assertEqual(summary["run_id"], run_logs[0].id) self.assertEqual("done", run_logs[0].status) self.assertEqual(3, run_logs[0].done) self.assertEqual(1, run_logs[0].success_count) self.assertEqual(2, run_logs[0].skipped_count) events = db.list_run_log_events(summary["run_id"], path=cfg["db_path"]) messages = [event.message for event in events] self.assertTrue(any("step=download_cover result=start" in item for item in messages)) self.assertTrue(any("别名未匹配账号" in item for item in messages)) self.assert_removed(temp_dir) def test_collect_worker_writes_run_log_and_diagnostic_log_on_failure(self): with self.make_temp_dir() as temp_dir: cfg = self.make_config(temp_dir) logged = 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", } ], path=cfg["db_path"], ) tasks = db.list_tasks(batch_id=batch_id, path=cfg["db_path"]) diagnostic_log_dir = os.path.join(temp_dir, "logs") def fake_login(account, path=None, config=None): self.assertEqual(logged.alias, account.alias) return {"logged_in": True, "reason": None} def fake_collect(account, task, on_step=None): on_step("wait_ready") raise RuntimeError("页面未就绪 token=SECRET-TOKEN") with mock.patch("app.gui.accounts.detect_login", side_effect=fake_login), \ mock.patch("app.gui.editor.collect", side_effect=fake_collect): summary = CollectWorker( tasks, db_path=cfg["db_path"], config=cfg, preflight=False, diagnostic_log_dir=diagnostic_log_dir, ).execute() self.assertFalse(summary["ok"]) self.assertEqual(1, summary["failed"]) self.assertIsInstance(summary["run_id"], int) failed_task = db.list_tasks(batch_id=batch_id, path=cfg["db_path"])[0] self.assertEqual("failed", failed_task.status) self.assertIn("页面未就绪", failed_task.last_error) self.assertNotIn("SECRET-TOKEN", failed_task.last_error) run_log = db.list_run_logs(limit=1, run_type="collect", path=cfg["db_path"])[0] self.assertEqual(summary["run_id"], run_log.id) self.assertEqual("done", run_log.status) self.assertEqual(1, run_log.failed_count) events = db.list_run_log_events(summary["run_id"], path=cfg["db_path"]) messages = [event.message for event in events] joined_messages = "\n".join(messages) self.assertTrue(any("step=wait_ready result=failed" in item for item in messages)) self.assertTrue(any("页面未就绪" in item for item in messages)) self.assertNotIn("SECRET-TOKEN", joined_messages) self.assertIn("token=***", joined_messages) log_path = os.path.join(diagnostic_log_dir, "cmshopee.log") self.assertTrue(os.path.exists(log_path)) with open(log_path, "r", encoding="utf-8") as fh: raw_log = fh.read() self.assertNotIn("SECRET-TOKEN", raw_log) self.assertIn("token=***", raw_log) entry = json.loads(raw_log.strip().splitlines()[-1]) self.assertEqual("wait_ready", entry["step"]) self.assertEqual("alias-a", entry["alias"]) self.assertEqual("51100639510", entry["item_id"]) self.assertEqual("RuntimeError", entry["exception"]) self.assert_removed(temp_dir) def test_collect_tab_loads_latest_collect_run_log(self): with self.make_temp_dir() as temp_dir: cfg = self.make_config(temp_dir) db.init_db(cfg["db_path"]) run_id = db.create_run_log("collect", total=1, path=cfg["db_path"]) db.add_run_log_event( run_id, "step=download_cover result=failed detail=旧封面下载超时", level="error", path=cfg["db_path"], ) db.finish_run_log( run_id, status="done", done=1, failed_count=1, summary_json={"failed": 1}, path=cfg["db_path"], ) tab = CollectTab(config=cfg) self.addCleanup(tab.close) self.assertEqual("collectRunLogView", tab.run_log_view.objectName()) self.assertIn("step=download_cover result=failed", tab.run_log_view.toPlainText()) self.assertIn("旧封面下载超时", tab.run_log_view.toPlainText()) self.assert_removed(temp_dir) def test_collect_tab_auto_starts_write_back_after_collect_success(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, "account_name": "Excel主店", "alias": "alias-a", "item_id": "51100639510", } ], path=cfg["db_path"], ) statuses = [] tab = CollectTab(config=cfg, status_callback=statuses.append) self.addCleanup(tab.close) with mock.patch.object(tab, "_start_write_back", return_value=True) as start_write_back: tab._on_collect_finished({"collected": 1, "skipped": 0, "failed": 0}) start_write_back.assert_called_once_with(batch_id, auto=True) self.assertIn("正在自动回写 Excel", statuses[-1]) self.assert_removed(temp_dir) def test_collect_tab_does_not_auto_write_back_when_nothing_collected(self): with self.make_temp_dir() as temp_dir: cfg = self.make_config(temp_dir) statuses = [] tab = CollectTab(config=cfg, status_callback=statuses.append) self.addCleanup(tab.close) with mock.patch.object(tab, "_start_write_back") as start_write_back, \ mock.patch("app.gui.QMessageBox.information") as information: tab._on_collect_finished({"collected": 0, "skipped": 1, "failed": 0}) start_write_back.assert_not_called() self.assertEqual("采集完成:成功0,略过1,失败0", statuses[-1]) self.assertIn("略过1:其他原因1", information.call_args[0][2]) self.assert_removed(temp_dir) def test_auto_write_back_locked_file_message_points_to_manual_retry(self): with self.make_temp_dir() as temp_dir: statuses = [] tab = CollectTab(config=self.make_config(temp_dir), status_callback=statuses.append) self.addCleanup(tab.close) with mock.patch("app.gui.QMessageBox.warning") as warning: tab._on_write_back_failed( -1, "Excel 文件被占用,请关闭后重试: input.xlsx", auto=True, ) message = warning.call_args[0][2] self.assertIn("Excel 自动回写失败", message) self.assertIn("点击「回写旧数据到 Excel」手动重试", message) self.assertIn("手动重试", statuses[-1]) self.assert_removed(temp_dir) def test_collect_worker_preflight_blocks_when_no_accounts(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, "account_name": "Excel主店", "alias": "alias-a", "item_id": "51100639510", } ], path=cfg["db_path"], ) tasks = db.list_tasks(batch_id=batch_id, path=cfg["db_path"]) summary = CollectWorker(tasks, db_path=cfg["db_path"], config=cfg).execute() self.assertTrue(summary["blocked"]) self.assertTrue(summary["no_accounts"]) self.assertEqual("NO_ACCOUNTS", summary["reason"]) self.assert_removed(temp_dir) def test_collect_worker_preflight_launches_when_chrome_not_running(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", } ], path=cfg["db_path"], ) tasks = db.list_tasks(batch_id=batch_id, path=cfg["db_path"]) with mock.patch("app.gui.chrome.is_running", return_value=False) as is_running, \ mock.patch( "app.gui.accounts.launch_for_login", return_value={"ok": True, "launched": True, "reused": False}, ) as launch_for_login, \ mock.patch( "app.gui.accounts.detect_login", return_value={"logged_in": True, "reason": None}, ) as detect_login, \ mock.patch( "app.gui.editor.collect", return_value={"old_title": "旧标题", "old_cover_path": "old.jpg"}, ) as collect: summary = CollectWorker(tasks, db_path=cfg["db_path"], config=cfg).execute() self.assertTrue(summary["ok"]) self.assertEqual(1, summary["collected"]) self.assertEqual("alias-a", summary["launched_accounts"][0]["alias"]) is_running.assert_called_once_with(9222) launch_for_login.assert_called_once() self.assertEqual(2, detect_login.call_count) collect.assert_called_once() self.assert_removed(temp_dir) def test_collect_worker_preflight_blocks_when_chrome_launch_fails(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", } ], path=cfg["db_path"], ) tasks = db.list_tasks(batch_id=batch_id, path=cfg["db_path"]) with mock.patch("app.gui.chrome.is_running", return_value=False), \ mock.patch( "app.gui.accounts.launch_for_login", side_effect=RuntimeError("Chrome 路径错误"), ) as launch_for_login, \ mock.patch("app.gui.accounts.detect_login") as detect_login, \ mock.patch("app.gui.editor.collect") as collect: summary = CollectWorker(tasks, db_path=cfg["db_path"], config=cfg).execute() self.assertTrue(summary["blocked"]) self.assertEqual("CHROME_LAUNCH_FAILED", summary["reason"]) self.assertEqual("alias-a", summary["launch_failed"][0]["alias"]) self.assertIn("Chrome 启动失败", summary["launch_failed"][0]["reason"]) launch_for_login.assert_called_once() detect_login.assert_not_called() collect.assert_not_called() self.assert_removed(temp_dir) def test_collect_worker_preflight_skips_logged_out_account(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", } ], path=cfg["db_path"], ) tasks = db.list_tasks(batch_id=batch_id, path=cfg["db_path"]) with mock.patch("app.gui.chrome.is_running", return_value=True), \ mock.patch( "app.gui.accounts.detect_login", return_value={"logged_in": False, "reason": "LOGIN_PAGE"}, ), mock.patch("app.gui.editor.collect") as collect: summary = CollectWorker(tasks, db_path=cfg["db_path"], config=cfg).execute() self.assertFalse(summary.get("blocked")) self.assertTrue(summary["ok"]) self.assertEqual(1, summary["skipped"]) self.assertEqual( {"alias_unmatched": 0, "login_required": 1, "other": 0}, summary["skip_reason_counts"], ) self.assertEqual("alias-a", summary["logged_out"][0]["alias"]) self.assertIn("账号未登录", summary["logged_out"][0]["reason"]) self.assertEqual("alias-a", summary["login_required_accounts"][0]["alias"]) collect.assert_not_called() blocked_task = db.list_tasks(batch_id=batch_id, path=cfg["db_path"])[0] self.assertEqual("imported", blocked_task.stage) self.assertEqual("skipped", blocked_task.status) self.assertIn("账号未登录", blocked_task.last_error) self.assert_removed(temp_dir) def test_collect_worker_skips_remaining_tasks_when_login_drops_midrun(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) accounts.create_account("副店", "alias-b", debug_port=9223, 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", }, { "source_file_abs": os.path.join(temp_dir, "input.xlsx"), "source_sheet": "商品", "source_row": 4, "account_name": "Excel副店", "alias": "alias-b", "item_id": "51100639512", }, ], path=cfg["db_path"], ) tasks = db.list_tasks(batch_id=batch_id, path=cfg["db_path"]) login_calls = [] def fake_login(account, path=None, config=None): login_calls.append(account.alias) if login_calls == ["alias-a"]: return {"logged_in": True, "reason": None} if login_calls == ["alias-a", "alias-b"]: return {"logged_in": True, "reason": None} if account.alias == "alias-a": return {"logged_in": False, "reason": "LOGIN_PAGE"} return {"logged_in": True, "reason": None} def fake_collect(account, task, on_step=None): self.assertEqual("alias-b", account.alias) return {"old_title": "副店旧标题", "old_cover_path": "old-b.jpg"} with mock.patch("app.gui.chrome.is_running", return_value=True), \ mock.patch("app.gui.accounts.detect_login", side_effect=fake_login) as detect_login, \ mock.patch("app.gui.editor.collect", side_effect=fake_collect) as collect: summary = CollectWorker(tasks, db_path=cfg["db_path"], config=cfg).execute() self.assertTrue(summary["ok"]) self.assertEqual(3, summary["done"]) self.assertEqual(1, summary["collected"]) self.assertEqual(2, summary["skipped"]) self.assertEqual( {"alias_unmatched": 0, "login_required": 2, "other": 0}, summary["skip_reason_counts"], ) self.assertEqual("alias-a", summary["login_required_accounts"][0]["alias"]) self.assertIn("采集中途掉登录", summary["login_required_accounts"][0]["reason"]) self.assertEqual(["alias-a", "alias-b", "alias-a", "alias-b"], login_calls) self.assertEqual(4, detect_login.call_count) collect.assert_called_once() updated = db.list_tasks(batch_id=batch_id, path=cfg["db_path"]) by_item = {task.item_id: task for task in updated} self.assertEqual("skipped", by_item["51100639510"].status) self.assertEqual("skipped", by_item["51100639511"].status) self.assertIn("采集中途掉登录", by_item["51100639510"].last_error) self.assertIn("采集中途掉登录", by_item["51100639511"].last_error) self.assertEqual("collected", by_item["51100639512"].stage) self.assertEqual("success", by_item["51100639512"].status) self.assert_removed(temp_dir) def test_collect_worker_retries_midrun_no_session_cookie_then_collects(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"], ) tasks = db.list_tasks(batch_id=batch_id, path=cfg["db_path"]) responses = [ {"logged_in": False, "reason": "NO_SESSION_COOKIE", "url": "https://seller.shopee.tw/"}, {"logged_in": True, "reason": None, "url": "https://seller.shopee.tw/portal/"}, {"logged_in": True, "reason": None, "url": "https://seller.shopee.tw/portal/"}, ] collected_items = [] def fake_collect(account, task, on_step=None): collected_items.append(task["item_id"]) return {"old_title": f"旧标题{task['item_id']}", "old_cover_path": "old.jpg"} with mock.patch("app.gui.accounts.detect_login", side_effect=responses) as detect_login, \ mock.patch("app.gui.editor.collect", side_effect=fake_collect) as collect, \ mock.patch("app.gui.workers.time.sleep") as sleep: summary = CollectWorker( tasks, db_path=cfg["db_path"], config=cfg, preflight=False, diagnostic_log_dir=os.path.join(temp_dir, "logs"), ).execute() self.assertTrue(summary["ok"]) self.assertEqual(2, summary["collected"]) self.assertEqual(0, summary["skipped"]) self.assertEqual( {"alias_unmatched": 0, "login_required": 0, "other": 0}, summary["skip_reason_counts"], ) self.assertEqual([], summary["login_required_accounts"]) self.assertEqual(["51100639510", "51100639511"], collected_items) self.assertEqual(3, detect_login.call_count) self.assertEqual(1, sleep.call_count) self.assertEqual(2, collect.call_count) events = db.list_run_log_events(summary["run_id"], path=cfg["db_path"]) messages = "\n".join(event.message for event in events) self.assertIn( "step=login_check result=retry detail=任务 " f"{tasks[0].id} 商品 51100639510", messages, ) self.assertIn( "step=login_check result=recovered detail=任务 " f"{tasks[0].id} 商品 51100639510", messages, ) updated = db.list_tasks(batch_id=batch_id, path=cfg["db_path"]) self.assertTrue(all(task.stage == "collected" for task in updated)) self.assertTrue(all(task.status == "success" for task in updated)) self.assert_removed(temp_dir) def test_collect_worker_repeated_midrun_no_session_cookie_does_not_skip(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", } ], path=cfg["db_path"], ) tasks = db.list_tasks(batch_id=batch_id, path=cfg["db_path"]) status = { "logged_in": False, "reason": "NO_SESSION_COOKIE", "url": "https://seller.shopee.tw/portal/", "cookie_names": [], } with mock.patch("app.gui.accounts.detect_login", return_value=status) as detect_login, \ mock.patch( "app.gui.editor.collect", return_value={ "old_title": "旧标题", "old_cover_path": "old.jpg", "close_target_confirmed": False, }, ) as collect, \ mock.patch("app.gui.workers.time.sleep") as sleep: summary = CollectWorker( tasks, db_path=cfg["db_path"], config=cfg, preflight=False, diagnostic_log_dir=os.path.join(temp_dir, "logs"), ).execute() self.assertTrue(summary["ok"]) self.assertEqual(1, summary["collected"]) self.assertEqual(0, summary["skipped"]) self.assertEqual( {"alias_unmatched": 0, "login_required": 0, "other": 0}, summary["skip_reason_counts"], ) self.assertEqual([], summary["login_required_accounts"]) self.assertEqual(3, detect_login.call_count) self.assertEqual(2, sleep.call_count) collect.assert_called_once() task = db.list_tasks(batch_id=batch_id, path=cfg["db_path"])[0] self.assertEqual("collected", task.stage) self.assertEqual("success", task.status) events = db.list_run_log_events(summary["run_id"], path=cfg["db_path"]) messages = "\n".join(event.message for event in events) self.assertIn("登录状态检测暂时不稳定", messages) self.assertIn( "step=close_product result=uncertain detail=任务 " f"{tasks[0].id} 商品 51100639510", messages, ) self.assertNotIn("采集中途掉登录", messages) self.assert_removed(temp_dir) def test_collect_worker_midrun_login_check_failed_does_not_skip_remaining(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"], ) tasks = db.list_tasks(batch_id=batch_id, path=cfg["db_path"]) responses = [ RuntimeError("CDP 读取失败 token=SECRET-TOKEN"), RuntimeError("CDP 读取失败 token=SECRET-TOKEN"), RuntimeError("CDP 读取失败 token=SECRET-TOKEN"), {"logged_in": True, "reason": None}, ] with mock.patch("app.gui.accounts.detect_login", side_effect=responses), \ mock.patch( "app.gui.editor.collect", return_value={"old_title": "旧标题", "old_cover_path": "old.jpg"}, ) as collect, \ mock.patch("app.gui.workers.time.sleep"): summary = CollectWorker( tasks, db_path=cfg["db_path"], config=cfg, preflight=False, ).execute() self.assertTrue(summary["ok"]) self.assertEqual(2, summary["collected"]) self.assertEqual(0, summary["skipped"]) self.assertEqual([], summary["login_required_accounts"]) self.assertEqual(2, collect.call_count) events = db.list_run_log_events(summary["run_id"], path=cfg["db_path"]) messages = "\n".join(event.message for event in events) self.assertIn("登录检测调用失败", messages) self.assertNotIn("LOGIN_CHECK_FAILED", messages) self.assertNotIn("SECRET-TOKEN", messages) self.assert_removed(temp_dir) def test_collect_worker_preflight_no_session_cookie_does_not_skip_account(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", } ], path=cfg["db_path"], ) tasks = db.list_tasks(batch_id=batch_id, path=cfg["db_path"]) responses = [ {"logged_in": False, "reason": "NO_SESSION_COOKIE", "url": "https://seller.shopee.tw/"}, {"logged_in": False, "reason": "NO_SESSION_COOKIE", "url": "https://seller.shopee.tw/"}, {"logged_in": False, "reason": "NO_SESSION_COOKIE", "url": "https://seller.shopee.tw/"}, {"logged_in": True, "reason": None, "url": "https://seller.shopee.tw/portal/"}, ] with mock.patch("app.gui.chrome.is_running", return_value=True), \ mock.patch("app.gui.accounts.detect_login", side_effect=responses) as detect_login, \ mock.patch( "app.gui.editor.collect", return_value={"old_title": "旧标题", "old_cover_path": "old.jpg"}, ) as collect, \ mock.patch("app.gui.workers.time.sleep") as sleep: summary = CollectWorker(tasks, db_path=cfg["db_path"], config=cfg).execute() self.assertTrue(summary["ok"]) self.assertEqual(1, summary["collected"]) self.assertEqual(0, summary["skipped"]) self.assertEqual([], summary["logged_out"]) self.assertEqual([], summary["login_required_accounts"]) self.assertEqual(4, detect_login.call_count) self.assertEqual(2, sleep.call_count) collect.assert_called_once() self.assert_removed(temp_dir) def test_collect_tab_blocked_preflight_guides_to_accounts_tab(self): with self.make_temp_dir() as temp_dir: opened = [] statuses = [] tab = CollectTab( config=self.make_config(temp_dir), status_callback=statuses.append, open_accounts_callback=lambda: opened.append(True), ) self.addCleanup(tab.close) payload = { "blocked": True, "not_running": [ { "account_name": "主店", "alias": "alias-a", "reason": "CDP 端口未响应", } ], "logged_out": [], } with mock.patch("app.gui.QMessageBox.warning") as warning: tab._on_collect_finished(payload) message = warning.call_args[0][2] self.assertIn("Chrome 未启动", message) self.assertIn("本轮采集已中止", message) self.assertIn("账号管理", message) self.assertEqual([True], opened) self.assertIn("本轮采集已中止", statuses[-1]) self.assertIn("账号管理", statuses[-1]) self.assert_removed(temp_dir) def test_collect_tab_completion_shows_login_summary_and_keep_chrome_hint(self): with self.make_temp_dir() as temp_dir: tab = CollectTab( config=self.make_config(temp_dir), status_callback=lambda _message: None, ) self.addCleanup(tab.close) payload = { "collected": 0, "skipped": 2, "failed": 0, "skip_reason_counts": { "alias_unmatched": 1, "login_required": 1, "other": 0, }, "login_required_accounts": [ { "account_name": "主店", "alias": "alias-a", "reason": "采集中途掉登录: 账号未登录: LOGIN_PAGE", } ], "launched_accounts": [ { "account_name": "副店", "alias": "alias-b", "reason": "已启动", } ], } with mock.patch("app.gui.QMessageBox.warning") as warning: tab._on_collect_finished(payload) message = warning.call_args[0][2] self.assertIn("以下账号需要补登录", message) self.assertIn("主店(alias-a)", message) self.assertIn("已自动启动账号 Chrome", message) self.assertIn("不会自动关闭账号 Chrome", message) self.assertIn("略过2:别名未匹配1,账号未登录1", message) self.assertIn("检查 Excel 别名", message) self.assertIn("账号管理", message) self.assert_removed(temp_dir) def test_write_back_worker_calls_excel_write_back(self): with self.make_temp_dir() as temp_dir: db_path = os.path.join(temp_dir, "db.sqlite") with mock.patch( "app.gui.excel.write_back", return_value={"ok": True, "batch_id": "batch-1", "files": 1, "rows": 2}, ) as write_back: summary = WriteBackWorker("batch-1", db_path=db_path).execute() self.assertEqual({"ok": True, "batch_id": "batch-1", "files": 1, "rows": 2}, summary) write_back.assert_called_once_with( "batch-1", excel_path=None, path=db_path, ) self.assert_removed(temp_dir) def test_write_back_worker_calls_result_write_back(self): with self.make_temp_dir() as temp_dir: db_path = os.path.join(temp_dir, "db.sqlite") with mock.patch( "app.gui.excel.write_back_results", return_value={"ok": True, "batch_id": "batch-1", "files": 1, "rows": 2}, ) as write_back_results: summary = WriteBackWorker("batch-1", db_path=db_path, mode="results").execute() self.assertEqual({"ok": True, "batch_id": "batch-1", "files": 1, "rows": 2}, summary) write_back_results.assert_called_once_with( "batch-1", excel_path=None, path=db_path, ) self.assert_removed(temp_dir) def test_collect_tab_import_excel_writes_diagnostic_run_log(self): with self.make_temp_dir() as temp_dir: cfg = self.make_config(temp_dir) excel_path = os.path.join(temp_dir, "input.xlsx") statuses = [] tab = CollectTab(config=cfg, status_callback=statuses.append) self.addCleanup(tab.close) def fake_import(file_paths, path=None): self.assertEqual([excel_path], file_paths) self.assertEqual(cfg["db_path"], path) return { "batch_id": None, "rows": [], "stats": { "files": 1, "total": 2, "valid": 0, "invalid": 1, "inserted": 0, "file_errors": [ { "file": excel_path, "sheet": "商品", "error": "缺少必需列", "missing_columns": ["别名"], } ], "row_errors": [ { "file": excel_path, "sheet": "商品", "row": 3, "error": "商品id必须是数字 token=SECRET", } ], }, } with mock.patch.object(tab, "_choose_excel_files", return_value=[excel_path]), \ mock.patch("app.gui.excel.import_tasks", side_effect=fake_import): tab.import_excel() run_log = db.list_run_logs(limit=1, run_type="import", path=cfg["db_path"])[0] self.assertEqual("done", run_log.status) self.assertEqual(1, run_log.done) self.assertEqual(2, run_log.failed_count) events = db.list_run_log_events(run_log.id, path=cfg["db_path"]) messages = "\n".join(event.message for event in events) self.assertIn("step=parse_file result=failed", messages) self.assertIn("missing=别名", messages) self.assertIn("step=row_validate result=failed", messages) self.assertIn("step=db_insert result=success", messages) self.assertNotIn("SECRET", messages) self.assertIn("token=***", messages) self.assertIn("入库0", statuses[-1]) self.assert_removed(temp_dir) def test_write_back_worker_writes_run_log_and_diagnostic_on_failure(self): with self.make_temp_dir() as temp_dir: cfg = self.make_config(temp_dir) db.init_db(cfg["db_path"]) diagnostic_log_dir = os.path.join(temp_dir, "logs") with mock.patch( "app.gui.excel.write_back", side_effect=RuntimeError("Excel 文件被占用 token=SECRET"), ): worker = WriteBackWorker( "batch-1", db_path=cfg["db_path"], diagnostic_log_dir=diagnostic_log_dir, ) with self.assertRaises(RuntimeError): worker.execute() run_log = db.list_run_logs(limit=1, run_type="write_back", path=cfg["db_path"])[0] self.assertEqual("failed", run_log.status) self.assertEqual(1, run_log.failed_count) events = db.list_run_log_events(run_log.id, path=cfg["db_path"]) messages = "\n".join(event.message for event in events) self.assertIn("step=write_excel result=failed", messages) self.assertNotIn("SECRET", messages) self.assertIn("token=***", messages) log_path = os.path.join(diagnostic_log_dir, "cmshopee.log") self.assertTrue(os.path.exists(log_path)) with open(log_path, "r", encoding="utf-8") as fh: raw_log = fh.read() self.assertNotIn("SECRET", raw_log) entry = json.loads(raw_log.strip().splitlines()[-1]) self.assertEqual("write_excel", entry["step"]) self.assertEqual("RuntimeError", entry["exception"]) self.assert_removed(temp_dir) def test_apply_worker_writes_step_run_log_and_diagnostic_on_failure(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", } ], 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"], ) db.set_generated(task.id, "新标题", "new.jpg", path=cfg["db_path"]) task = db.list_tasks(batch_id=batch_id, path=cfg["db_path"])[0] diagnostic_log_dir = os.path.join(temp_dir, "logs") def fake_apply(account, task, close_success_tab=False, on_step=None, bring_to_front=True, update_mode=None): on_step({"step": "open_product", "result": "start"}) on_step({"step": "replace_cover", "result": "failed", "detail": "token=SECRET"}) return { "committed": False, "error": "新封面上传失败 token=SECRET", "cover": {"ok": False, "error": "token=SECRET"}, } with mock.patch("app.gui.editor.apply_task", side_effect=fake_apply): summary = ApplyWorker( [task], db_path=cfg["db_path"], config=cfg, preflight=False, diagnostic_log_dir=diagnostic_log_dir, ).execute() self.assertFalse(summary["ok"]) self.assertEqual(1, summary["failed"]) run_log = db.list_run_logs(limit=1, run_type="apply", path=cfg["db_path"])[0] events = db.list_run_log_events(run_log.id, path=cfg["db_path"]) messages = "\n".join(event.message for event in events) self.assertIn("step=open_product result=start", messages) self.assertIn("step=replace_cover result=failed", messages) self.assertIn("step=db_write result=success", messages) self.assertNotIn("SECRET", messages) self.assertIn("token=***", messages) log_path = os.path.join(diagnostic_log_dir, "cmshopee.log") self.assertTrue(os.path.exists(log_path)) with open(log_path, "r", encoding="utf-8") as fh: raw_log = fh.read() self.assertNotIn("SECRET", raw_log) entry = json.loads(raw_log.strip().splitlines()[-1]) self.assertEqual("replace_cover", entry["step"]) self.assertEqual("alias-a", entry["alias"]) self.assertEqual("51100639510", entry["item_id"]) self.assert_removed(temp_dir) def test_account_login_check_worker_writes_run_log(self): with self.make_temp_dir() as temp_dir: cfg = self.make_config(temp_dir) account = accounts.create_account("主店", "alias-a", debug_port=9222, config=cfg) diagnostic_log_dir = os.path.join(temp_dir, "logs") with mock.patch( "app.gui.accounts.detect_login", return_value={"logged_in": False, "reason": "LOGIN_PAGE token=SECRET"}, ) as detect_login: result = AccountLoginCheckWorker( account, db_path=cfg["db_path"], config=cfg, diagnostic_log_dir=diagnostic_log_dir, ).execute() detect_login.assert_called_once_with( account, timeout=8, path=cfg["db_path"], config=cfg, ) self.assertEqual("alias-a", result["alias"]) self.assertFalse(result["status"]["logged_in"]) run_log = db.list_run_logs(limit=1, run_type="login_check", path=cfg["db_path"])[0] self.assertEqual("done", run_log.status) self.assertEqual(1, run_log.failed_count) events = db.list_run_log_events(run_log.id, path=cfg["db_path"]) messages = "\n".join(event.message for event in events) self.assertIn("step=detect_login result=start", messages) self.assertIn("step=detect_login result=failed", messages) self.assertNotIn("SECRET", messages) self.assertIn("token=***", messages) self.assert_removed(temp_dir) def test_ai_model_test_worker_writes_run_log(self): with self.make_temp_dir() as temp_dir: cfg = self.make_config(temp_dir) db.init_db(cfg["db_path"]) models_path = os.path.join(temp_dir, "ai_models.json") worker = AIModelTestWorker( "Text A", ai_models_path=models_path, db_path=cfg["db_path"], diagnostic_log_dir=os.path.join(temp_dir, "logs"), ) with mock.patch( "app.gui.appconfig.test_ai_model", return_value={"ok": False, "status": 401, "error": "api_key=SECRET"}, ): result = worker.execute() self.assertFalse(result["ok"]) self.assertEqual("Text A", result["name"]) run_log = db.list_run_logs(limit=1, run_type="ai_model_test", path=cfg["db_path"])[0] self.assertEqual("done", run_log.status) self.assertEqual(1, run_log.failed_count) events = db.list_run_log_events(run_log.id, path=cfg["db_path"]) messages = "\n".join(event.message for event in events) self.assertIn("step=test_connection result=start", messages) self.assertIn("step=test_connection result=failed", messages) self.assertNotIn("SECRET", messages) self.assertIn("api_key=***", messages) self.assert_removed(temp_dir) def test_accounts_tab_launch_login_writes_chrome_launch_run_log(self): with self.make_temp_dir() as temp_dir: cfg = self.make_config(temp_dir) account = accounts.create_account("主店", "alias-a", debug_port=9222, config=cfg) statuses = [] tab = AccountsTab(config=cfg, status_callback=statuses.append) self.addCleanup(tab.close) tab.table.selectRow(0) with mock.patch( "app.gui.accounts.launch_for_login", return_value={ "action": "launched", "pid": 1234, "target_id": "target-new", "url": "https://seller.shopee.tw/portal/", "created_tab": False, "startup_target_reused": True, "startup_page_navigated": False, "page_target_count": 1, }, ) as launch_for_login: tab.launch_login() launch_for_login.assert_called_once_with(account, config=cfg) self.assertEqual("已启动", tab.login_statuses["alias-a"]) self.assertIn("Chrome 已启动", statuses[-1]) run_log = db.list_run_logs(limit=1, run_type="chrome_launch", path=cfg["db_path"])[0] self.assertEqual("done", run_log.status) self.assertEqual(1, run_log.success_count) events = db.list_run_log_events(run_log.id, path=cfg["db_path"]) messages = "\n".join(event.message for event in events) self.assertIn("step=launch_chrome result=start", messages) self.assertIn("step=launch_chrome result=launched", messages) self.assertIn("pid=1234", messages) self.assertIn("target_id=target-new", messages) self.assertIn("created_tab=0", messages) self.assertIn("startup_target_reused=1", messages) self.assertTrue(tab.table.isEnabled()) self.assertTrue(tab.add_button.isEnabled()) self.assert_removed(temp_dir) def test_accounts_tab_launch_login_reuse_writes_chrome_launch_run_log(self): with self.make_temp_dir() as temp_dir: cfg = self.make_config(temp_dir) account = accounts.create_account("主店", "alias-a", debug_port=9222, config=cfg) statuses = [] tab = AccountsTab(config=cfg, status_callback=statuses.append) self.addCleanup(tab.close) tab.table.selectRow(0) with mock.patch( "app.gui.accounts.launch_for_login", return_value={ "action": "reused", "pid": None, "target_id": "target-existing", "url": "https://seller.shopee.tw/portal/", }, ) as launch_for_login: tab.launch_login() tab.table.selectRow(0) tab.launch_login() launch_for_login.assert_called_once_with(account, config=cfg) self.assertEqual("已启动", tab.login_statuses["alias-a"]) self.assertTrue(any("已复用现有窗口" in message for message in statuses)) run_log = db.list_run_logs(limit=1, run_type="chrome_launch", path=cfg["db_path"])[0] self.assertEqual("done", run_log.status) self.assertEqual(1, run_log.success_count) events = db.list_run_log_events(run_log.id, path=cfg["db_path"]) messages = "\n".join(event.message for event in events) self.assertIn("step=launch_chrome result=start", messages) self.assertIn("step=launch_chrome result=reused", messages) self.assertIn("target_id=target-existing", messages) self.assertIn("url=https://seller.shopee.tw/portal/", messages) self.assertIn("result=ignored", messages) self.assert_removed(temp_dir) def test_accounts_tab_ignores_reentrant_launch_and_restores_controls(self): with self.make_temp_dir() as temp_dir: cfg = self.make_config(temp_dir) account = accounts.create_account("主店", "alias-a", debug_port=9222, config=cfg) statuses = [] tab = AccountsTab(config=cfg, status_callback=statuses.append) self.addCleanup(tab.close) tab.table.selectRow(0) def launch_once(selected_account, config=None): self.assertEqual(account, selected_account) self.assertEqual(cfg, config) self.assertFalse(tab.table.isEnabled()) self.assertFalse(tab.add_button.isEnabled()) self.assertFalse(tab.launch_button.isEnabled()) tab.launch_login() return { "action": "launched", "pid": 1234, "target_id": "target-initial", "url": "https://seller.shopee.tw/portal/", "created_tab": False, "startup_target_reused": True, "page_target_count": 1, } with mock.patch( "app.gui.accounts.launch_for_login", side_effect=launch_once, ) as launch_for_login: tab.launch_login() launch_for_login.assert_called_once_with(account, config=cfg) self.assertTrue(tab.table.isEnabled()) self.assertTrue(tab.add_button.isEnabled()) run_logs = db.list_run_logs( limit=10, run_type="chrome_launch", path=cfg["db_path"], ) self.assertEqual(1, len(run_logs)) events = db.list_run_log_events(run_logs[0].id, path=cfg["db_path"]) messages = "\n".join(event.message for event in events) self.assertIn("result=ignored", messages) self.assertIn("reason=launch_in_progress", messages) self.assertTrue(any("正在启动" in message for message in statuses)) self.assert_removed(temp_dir) def test_accounts_tab_launch_failure_restores_controls(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) tab = AccountsTab(config=cfg) self.addCleanup(tab.close) tab.table.selectRow(0) with mock.patch( "app.gui.accounts.launch_for_login", side_effect=RuntimeError("启动失败"), ), mock.patch("app.gui.QMessageBox.warning"): tab.launch_login() self.assertTrue(tab.table.isEnabled()) self.assertTrue(tab.add_button.isEnabled()) self.assertTrue(tab.launch_button.isEnabled()) run_log = db.list_run_logs( limit=1, run_type="chrome_launch", path=cfg["db_path"], )[0] self.assertEqual("failed", run_log.status) if __name__ == "__main__": unittest.main()