import json import unittest import os import sys import threading 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, appconfig, db, prompts if gui.QT_IMPORT_ERROR is not None: raise unittest.SkipTest("PySide6 未安装") from PySide6.QtGui import QTextCursor from PySide6.QtWidgets import QApplication, QLineEdit, QPlainTextEdit, QTableView from app.gui import ( AccountDialog, AccountsTab, AIModelTestWorker, ApplyTab, ApplyWorker, CollectWorker, CollectTab, GenerateWorker, GenerateTab, MainWindow, SettingsTab, TAB_STYLE, TAB_TITLES, WriteBackWorker, ) 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, ): cfg["shopee_update"] = { "test_item_id": item_id, "allow_real_submit": True, "allow_cover_update": allow_cover, "max_items_per_run": max_items, "close_success_tab": close_success_tab, } return cfg def test_main_window_has_five_tabs_in_workflow_order(self): with self.make_temp_dir() as temp_dir: window = MainWindow(config=self.make_config(temp_dir)) self.addCleanup(window.close) self.assertEqual(5, window.tabs.count()) self.assertEqual( TAB_TITLES, [window.tabs.tabText(index) for index in range(window.tabs.count())], ) self.assertEqual("就绪", window.statusBar().currentMessage()) self.assertEqual(TAB_STYLE, window.tabs.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(4), SettingsTab) self.assertEqual( "回写旧数据到 Excel", window.tabs.widget(0).write_back_button.text(), ) 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.assertEqual("51100639510", tab.test_item_id_edit.text()) self.assertTrue(tab.test_item_id_edit.isHidden()) self.assertFalse(tab.allow_real_submit_checkbox.isChecked()) self.assertFalse(tab.allow_cover_update_checkbox.isChecked()) self.assertEqual(1, tab.max_items_per_run_spin.value()) self.assertFalse(tab.close_success_tab_checkbox.isChecked()) self.assertFalse(tab.dry_run_checkbox.isChecked()) self.assertTrue(tab.dry_run_checkbox.isHidden()) self.assertFalse(tab.parallel_accounts_checkbox.isChecked()) self.assertEqual(2, tab.max_parallel_accounts_spin.value()) 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()}") group_row, group_col, _row_span, group_col_span = widget_position( tab.shopee_update_form_layout, tab.parallel_accounts_group, ) cover_row, cover_col, _row_span, _col_span = widget_position( tab.shopee_update_form_layout, tab.allow_cover_update_checkbox, ) self.assertEqual(cover_row, group_row) self.assertGreater(group_col, cover_col) self.assertEqual(4, group_col_span) group_widgets = [ tab.parallel_accounts_group.layout().itemAt(index).widget() for index in range(tab.parallel_accounts_group.layout().count()) if tab.parallel_accounts_group.layout().itemAt(index).widget() is not None ] self.assertEqual( [ tab.parallel_accounts_checkbox, tab.max_parallel_accounts_label, tab.max_parallel_accounts_spin, ], group_widgets, ) 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("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" 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) 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("360 秒", tab.response_timeout_label.text()) tab.jpg_quality_spin.setValue(86) tab.chrome_path_edit.setText("D:\\Chrome\\chrome.exe") tab.user_data_root_edit.setText("profiles") tab.image_dir_edit.setText("pictures") tab.db_path_edit.setText("data\\cmshopee.db") 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.test_item_id_edit.setText("51100639510") tab.allow_real_submit_checkbox.setChecked(True) tab.allow_cover_update_checkbox.setChecked(True) tab.max_items_per_run_spin.setValue(2) tab.close_success_tab_checkbox.setChecked(True) tab.parallel_accounts_checkbox.setChecked(True) 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.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(86, saved["ai"]["jpg_quality"]) self.assertEqual("D:\\Chrome\\chrome.exe", saved["chrome_path"]) self.assertEqual("profiles", saved["user_data_root"]) self.assertEqual("pictures", saved["image_dir"]) self.assertEqual("data\\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", "allow_real_submit": True, "allow_cover_update": True, "max_items_per_run": 2, "close_success_tab": True, "dry_run": False, "parallel_accounts": True, "max_parallel_accounts": 3, }, saved["shopee_update"], ) self.assertNotIn("ai_models_path", saved) self.assertNotIn("config_path", saved) self.assertIn("设置已保存", statuses[-1]) self.assert_removed(temp_dir) def test_settings_save_updates_apply_tab_shared_safety_config(self): with self.make_temp_dir() as temp_dir: cfg = self.make_config(temp_dir) 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("③ 更新shopee")) settings_tab.test_item_id_edit.setText("123456789") settings_tab.allow_real_submit_checkbox.setChecked(True) settings_tab.allow_cover_update_checkbox.setChecked(True) settings_tab.max_items_per_run_spin.setValue(3) settings_tab.close_success_tab_checkbox.setChecked(True) settings_tab.parallel_accounts_checkbox.setChecked(True) 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.assertTrue(safety_cfg["allow_real_submit"]) self.assertTrue(safety_cfg["allow_cover_update"]) self.assertEqual(3, safety_cfg["max_items_per_run"]) self.assertTrue(safety_cfg["close_success_tab"]) self.assertFalse(safety_cfg["dry_run"]) self.assertTrue(safety_cfg["parallel_accounts"]) 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_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") 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, ) 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.assertEqual("标题提示词", tab.title_prompt_edit.placeholderText()) self.assertEqual("封面提示词", tab.cover_prompt_edit.placeholderText()) self.assertEqual("保存标题提示词", tab.save_title_button.text()) self.assertEqual("开始生成", tab.generate_button.text()) self.assertEqual("停止", tab.stop_generate_button.text()) self.assertEqual("重置生成结果", tab.reset_generate_button.text()) self.assertFalse(tab.stop_generate_button.isEnabled()) self.assertEqual("进度:标题0/0 · 封面0/0 · 失败0", tab.progress_label.text()) self.assertEqual("默认", tab.cover_template_combo.currentText()) self.assertEqual(["店铺", "商品ID", "旧标题", "新标题", "状态"], tab.model.HEADERS) self.assertEqual("任务 0/0 条", tab.summary_label.text()) 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") cover_prompts_dir = os.path.join(temp_dir, "prompts", "cover") prompts.save_title_prompt("标题启动回显", title_prompt_path) 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, ) self.addCleanup(tab.close) self.assertEqual("标题启动回显", tab.title_prompt_edit.toPlainText()) 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.cover_prompt_edit.setPlainText("另存模板 {新标题}") with mock.patch("app.gui.QInputDialog.getText", return_value=("另存", True)): tab.save_cover_template_as() 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() 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() self.assertNotIn("改名", prompts.list_cover_templates(cover_prompts_dir)) 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) with mock.patch("app.gui.QMessageBox.question", return_value=gui.QMessageBox.Yes) as question: tab.reset_generated_result() message = question.call_args[0][2] self.assertIn("默认不删除本地新封面文件", message) self.assertIn(new_cover, message) updated = db.get_task(task.id, path=cfg["db_path"]) self.assertEqual("collected", updated.stage) self.assertEqual("success", updated.status) self.assertIsNone(updated.new_title) self.assertIsNone(updated.new_cover_path) self.assertTrue(os.path.exists(new_cover)) 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"]) 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_worker_calls_generate_batch_and_emits_signals(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] 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 = [] 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_request", "result": "start", } ) 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))) 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) events = db.list_run_log_events(summary["run_id"], path=cfg["db_path"]) self.assertTrue(any("phase=cover step=cover_request result=start" in event.message for event in events)) 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) 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("phase=cover step=cover_request result=failed", 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_tab_loads_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) self.assertEqual("generateRunLogView", tab.run_log_view.objectName()) self.assertIn("phase=cover step=cover_request result=failed", tab.run_log_view.toPlainText()) 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"]) tab = ApplyTab(config=cfg) self.addCleanup(tab.close) 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.assertEqual("applyItemFilter", tab.item_filter.objectName()) self.assertEqual("停止", tab.stop_update_button.text()) self.assertEqual("重置更新状态", tab.reset_update_button.text()) 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.assertEqual("待更新", tab.model.index(0, 5).data()) self.assertEqual("任务 2/4 条", tab.summary_label.text()) tab.status_filter.setCurrentIndex(tab.status_filter.findData("failed")) self.assertEqual(1, tab.model.rowCount()) self.assertEqual("失败", tab.model.index(0, 5).data()) 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.assertEqual("成功", tab.model.index(0, 5).data()) 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.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("本地重置不会回滚 Shopee", 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", 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("商品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, close_success_tab=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"]) db.set_generated(task.id, "新标题", "new.jpg", path=cfg["db_path"]) statuses = [] tab = ApplyTab(config=cfg, 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.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.assertTrue(tab.apply_worker.close_success_tab) self.assertFalse(tab.apply_worker.dry_run) self.assertFalse(tab.apply_worker.parallel_accounts) self.assertEqual(1, tab.apply_worker.batch_size) 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", 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) 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.assertTrue(tab.apply_worker.parallel_accounts) self.assertEqual(2, tab.apply_worker.max_parallel_accounts) self.assertEqual("开始预览本轮更新:1 条", statuses[-1]) self.assert_removed(temp_dir) def test_apply_tab_blocks_update_when_real_submit_switch_is_off(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"]) statuses = [] tab = ApplyTab(config=cfg, status_callback=statuses.append) self.addCleanup(tab.close) tab.item_filter.setText("51100639510") 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() message = warning.call_args[0][2] self.assertIn("允许真实提交线上商品", message) question.assert_not_called() run_worker.assert_not_called() self.assertIn("已阻止本次更新", statuses[-1]) self.assert_removed(temp_dir) def test_apply_tab_blocks_cover_update_when_cover_switch_is_off(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", path=cfg["db_path"]) db.set_generated(task.id, "新标题", "new.jpg", path=cfg["db_path"]) tab = ApplyTab(config=cfg) 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.start_update() self.assertIn("允许更新封面", warning.call_args[0][2]) question.assert_not_called() run_worker.assert_not_called() self.assert_removed(temp_dir) def test_apply_tab_safety_error_allows_more_than_batch_size_and_non_test_items(self): with self.make_temp_dir() as temp_dir: cfg = self.make_config(temp_dir) self.allow_shopee_update(cfg, item_id="51100639510", max_items=1) tab = ApplyTab(config=cfg) self.addCleanup(tab.close) class Task: def __init__(self, item_id, new_cover_path=None): self.item_id = item_id self.new_cover_path = new_cover_path count_error = tab._update_safety_error( [ Task("51100639510"), Task("51100639510"), ] ) non_test_error = tab._update_safety_error([Task("51100639511")]) cfg["shopee_update"]["test_item_id"] = "" missing_test_id_error = tab._update_safety_error([Task("26887160467")]) self.assertIsNone(count_error) self.assertIsNone(non_test_error) self.assertIsNone(missing_test_id_error) 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", 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 = [] close_flags = [] progress = [] rows = [] def fake_apply(account, task, close_success_tab=False): applied_aliases.append(account.alias) close_flags.append(close_success_tab) 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, close_success_tab=True, 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], close_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["parallel_accounts"]) 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_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", 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, parallel_accounts=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", 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() def fake_apply(account, task, close_success_tab=False): thread_names.add(threading.current_thread().name) 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, parallel_accounts=True, max_parallel_accounts=2, ).execute() self.assertTrue(summary["ok"]) self.assertTrue(summary["parallel_accounts"]) self.assertEqual(2, summary["applied"]) self.assertGreaterEqual(len(thread_names), 2) 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", 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, parallel_accounts=True, 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", 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"]) tab = GenerateTab(config=cfg) self.addCleanup(tab.close) self.assertEqual("generateItemFilter", tab.item_filter.objectName()) 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.assertEqual("已生成", tab.model.index(2, 4).data()) 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.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.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("③ 更新shopee", window.tabs.tabText(window.tabs.currentIndex())) self.assertEqual("当前:③ 更新shopee", 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()) 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: 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]) 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.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.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.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("主店1", tab.match_detail_label.text()) self.assertIn("未匹配", tab.empty_label.text()) 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("不会回滚 Shopee", 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_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_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) def fake_import(file_paths, path=None): batch_id = db.create_batch(file_paths, path=path) 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: tab.import_excel() import_tasks.assert_called_once_with([excel_path], path=cfg["db_path"]) 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/无效1", tab.summary_label.text()) self.assertIn("匹配1", tab.summary_label.text()) self.assertIn("入库1", statuses[-1]) 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"], } 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 = CollectWorker( tasks, db_path=cfg["db_path"], config=cfg, preflight=False, ).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([batch_id], summary["batch_ids"]) self.assertIsInstance(summary["run_id"], int) collect.assert_called_once() 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("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: tab._on_collect_finished({"collected": 0, "skipped": 1, "failed": 0}) start_write_back.assert_not_called() self.assertEqual("采集完成:成功0,略过1,失败0", statuses[-1]) 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_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"], ) 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.editor.collect") as collect: summary = CollectWorker(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() collect.assert_not_called() self.assert_removed(temp_dir) def test_collect_worker_preflight_blocks_when_account_not_logged_in(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.assertTrue(summary["blocked"]) self.assertEqual("alias-a", summary["logged_out"][0]["alias"]) self.assertIn("账号未登录", summary["logged_out"][0]["reason"]) 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("pending", blocked_task.status) self.assertIsNone(blocked_task.last_error) 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("不会自动打开账号 Chrome", message) self.assertIn("④ 账号管理", message) self.assertEqual([True], opened) self.assertIn("本轮采集已中止", statuses[-1]) self.assertIn("④ 账号管理", statuses[-1]) self.assert_removed(temp_dir) def test_write_back_worker_calls_excel_write_back(self): 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.sqlite").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.sqlite", ) def test_write_back_worker_calls_result_write_back(self): 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.sqlite", 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.sqlite", ) if __name__ == "__main__": unittest.main()