Files
cmshoppe/tests/test_gui.py
T

4349 lines
195 KiB
Python
Raw Normal View History

import json
2026-06-27 09:56:53 +08:00
import unittest
import os
import sys
2026-06-29 10:25:09 +08:00
import threading
from types import SimpleNamespace
2026-06-27 10:38:21 +08:00
from unittest import mock
2026-06-27 09:56:53 +08:00
os.environ.setdefault("QT_QPA_PLATFORM", "offscreen")
sys.path.insert(0, os.path.dirname(__file__))
2026-06-27 10:30:45 +08:00
from _helpers import TempDirMixin
2026-06-27 09:56:53 +08:00
from app import gui
2026-07-04 17:14:29 +08:00
from app import accounts, ai, appconfig, db, prompts
2026-06-27 09:56:53 +08:00
if gui.QT_IMPORT_ERROR is not None:
raise unittest.SkipTest("PySide6 未安装")
2026-06-27 16:10:50 +08:00
from PySide6.QtGui import QTextCursor
2026-07-02 15:17:11 +08:00
from PySide6.QtWidgets import QApplication, QCheckBox, QLineEdit, QPlainTextEdit, QProgressBar, QTableView
2026-06-27 09:56:53 +08:00
2026-06-27 11:29:48 +08:00
from app.gui import (
AccountDialog,
2026-07-01 15:32:27 +08:00
AccountLoginCheckWorker,
2026-06-27 11:29:48 +08:00
AccountsTab,
2026-06-29 08:45:04 +08:00
AIModelTestWorker,
2026-07-04 16:29:15 +08:00
CMHubSettingsWorker,
2026-06-27 16:59:46 +08:00
ApplyTab,
2026-06-27 17:13:18 +08:00
ApplyWorker,
2026-06-27 11:51:42 +08:00
CollectWorker,
2026-06-27 11:29:48 +08:00
CollectTab,
2026-06-27 16:26:08 +08:00
GenerateWorker,
2026-06-27 15:56:55 +08:00
GenerateTab,
2026-06-27 11:29:48 +08:00
MainWindow,
2026-06-29 08:45:04 +08:00
SettingsTab,
2026-06-27 11:29:48 +08:00
TAB_STYLE,
TAB_TITLES,
2026-06-27 14:37:58 +08:00
WriteBackWorker,
2026-06-27 11:29:48 +08:00
)
2026-06-27 09:56:53 +08:00
2026-06-27 10:30:45 +08:00
class GuiTests(TempDirMixin, unittest.TestCase):
2026-06-27 09:56:53 +08:00
@classmethod
def setUpClass(cls):
cls.app = QApplication.instance() or QApplication([])
2026-06-27 15:00:15 +08:00
def tearDown(self):
for widget in QApplication.topLevelWidgets():
widget.close()
widget.deleteLater()
self.app.processEvents()
2026-06-27 10:30:45 +08:00
def make_config(self, temp_dir):
return {
"chrome_path": "chrome.exe",
"user_data_root": os.path.join(temp_dir, "chrome_user_data_dir"),
2026-06-27 11:51:42 +08:00
"image_dir": os.path.join(temp_dir, "images"),
2026-06-27 10:30:45 +08:00
"db_path": os.path.join(temp_dir, "cmshopee.db"),
"debug_port_range": [9222, 9260],
2026-06-29 08:45:04 +08:00
"ai_models_path": os.path.join(temp_dir, "ai_models.json"),
"config_path": os.path.join(temp_dir, "config.json"),
2026-06-27 10:30:45 +08:00
}
2026-06-27 09:56:53 +08:00
2026-06-29 09:18:03 +08:00
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
2026-07-01 17:56:27 +08:00
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 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]
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="等待 Shopee 商品编辑器就绪超时",
)
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),
)
2026-06-27 10:30:45 +08:00
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())
2026-07-02 09:06:52 +08:00
self.assertFalse(window.tabs.tabIcon(TAB_TITLES.index("③ 更新shopee")).isNull())
2026-06-27 10:30:45 +08:00
self.assertIn("min-width: 128px", window.tabs.styleSheet())
self.assertIn("padding: 8px 18px", window.tabs.styleSheet())
self.assertIn("margin-right: 8px", window.tabs.styleSheet())
2026-06-27 11:29:48 +08:00
self.assertIsInstance(window.tabs.widget(0), CollectTab)
2026-06-27 15:56:55 +08:00
self.assertIsInstance(window.tabs.widget(1), GenerateTab)
2026-06-27 16:59:46 +08:00
self.assertIsInstance(window.tabs.widget(2), ApplyTab)
2026-06-29 08:45:04 +08:00
self.assertIsInstance(window.tabs.widget(4), SettingsTab)
2026-06-27 14:37:58 +08:00
self.assertEqual(
"回写旧数据到 Excel",
window.tabs.widget(0).write_back_button.text(),
)
2026-06-27 10:30:45 +08:00
self.assert_removed(temp_dir)
2026-06-27 09:56:53 +08:00
2026-07-02 09:57:06 +08:00
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)
2026-07-02 10:21:39 +08:00
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)
2026-06-29 08:45:04 +08:00
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())
2026-07-02 10:59:21 +08:00
self.assertFalse(hasattr(tab, "test_item_id_edit"))
self.assertFalse(hasattr(tab, "dry_run_checkbox"))
self.assertEqual(
"Shopee 更新安全 / 执行模式",
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),
)
2026-06-29 09:18:03 +08:00
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())
2026-06-29 10:25:09 +08:00
self.assertFalse(tab.parallel_accounts_checkbox.isChecked())
self.assertEqual(2, tab.max_parallel_accounts_spin.value())
2026-06-29 08:45:04 +08:00
2026-07-01 08:35:46 +08:00
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,
)
2026-06-29 08:45:04 +08:00
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])
2026-06-29 08:45:04 +08:00
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])
2026-06-29 08:45:04 +08:00
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"
2026-07-02 15:17:11 +08:00
cfg["ai"]["generate_cover"] = True
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)
2026-06-29 09:18:03 +08:00
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)
2026-06-29 10:25:09 +08:00
tab.parallel_accounts_checkbox.setChecked(True)
tab.max_parallel_accounts_spin.setValue(3)
2026-07-01 08:35:46 +08:00
with mock.patch("app.gui.QMessageBox.information") as info:
tab.save_app_settings()
2026-07-01 08:35:46 +08:00
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"])
2026-07-02 15:17:11 +08:00
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(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"])
2026-06-29 09:18:03 +08:00
self.assertEqual(
{
"test_item_id": "51100639510",
"allow_real_submit": True,
"allow_cover_update": True,
"max_items_per_run": 2,
"close_success_tab": True,
2026-07-01 08:35:46 +08:00
"dry_run": False,
2026-06-29 10:25:09 +08:00
"parallel_accounts": True,
"max_parallel_accounts": 3,
2026-06-29 09:18:03 +08:00
},
saved["shopee_update"],
)
self.assertNotIn("ai_models_path", saved)
self.assertNotIn("config_path", saved)
self.assertIn("设置已保存", statuses[-1])
self.assert_removed(temp_dir)
2026-06-29 09:18:03 +08:00
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)
2026-07-02 10:59:21 +08:00
cfg["shopee_update"] = dict(appconfig.default_config()["shopee_update"])
cfg["shopee_update"]["test_item_id"] = "123456789"
2026-06-29 09:18:03 +08:00
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.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)
2026-06-29 10:25:09 +08:00
settings_tab.parallel_accounts_checkbox.setChecked(True)
settings_tab.max_parallel_accounts_spin.setValue(4)
2026-07-01 08:35:46 +08:00
with mock.patch("app.gui.QMessageBox.information") as info:
settings_tab.save_app_settings()
2026-06-29 09:18:03 +08:00
2026-07-01 08:35:46 +08:00
info.assert_called_once_with(settings_tab, "保存设置", "设置已保存")
2026-06-29 09:18:03 +08:00
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"])
2026-07-01 08:35:46 +08:00
self.assertFalse(safety_cfg["dry_run"])
2026-06-29 10:25:09 +08:00
self.assertTrue(safety_cfg["parallel_accounts"])
self.assertEqual(4, safety_cfg["max_parallel_accounts"])
2026-06-29 09:18:03 +08:00
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)
2026-06-29 08:45:04 +08:00
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)
2026-07-04 16:29:15 +08:00
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": {"id": "u1"}, "points_balance": 88}
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"])
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())
2026-07-04 17:59:23 +08:00
self.assertTrue(tab.backend_combo.isHidden())
2026-07-04 16:29:15 +08:00
self.assertTrue(tab.model_picker_panel.isHidden())
2026-07-04 17:59:23 +08:00
self.assertTrue(tab.direct_role_panel.isHidden())
2026-07-04 16:29:15 +08:00
self.assertFalse(tab.cmhub_panel.isHidden())
self.assertIn("不要带 /api", tab.cmhub_base_url_hint_label.text())
2026-07-04 16:29:15 +08:00
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())
tab.cmhub_base_url_edit.setText("https://cmhub.example.com/api/v1/")
2026-07-04 16:29:15 +08:00
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}],
},
],
title_selected="title-standard",
image_selected="image-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("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())
2026-07-04 16:29:15 +08:00
self.assertEqual("title-standard", saved["ai"]["cmhub"]["title_alias"])
self.assertEqual("image-standard", saved["ai"]["cmhub"]["image_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",
"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}],
},
],
}
)
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())
]
self.assertIn("title-priced", title_aliases)
self.assertIn("title-saved", title_aliases)
self.assertNotIn("title-free", title_aliases)
self.assertIn("512:1点", tab.cmhub_title_alias_combo.itemText(0))
self.assertTrue(any("需参考图" in label for label in image_labels))
self.assertIn("余额 55", 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)
2026-06-27 15:56:55 +08:00
def test_generate_tab_has_prompt_editors_and_task_table(self):
with self.make_temp_dir() as temp_dir:
2026-06-27 16:10:50 +08:00
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,
)
2026-06-27 15:56:55 +08:00
self.addCleanup(tab.close)
self.assertIsInstance(tab.title_prompt_edit, QPlainTextEdit)
self.assertIsInstance(tab.cover_prompt_edit, QPlainTextEdit)
self.assertIsInstance(tab.task_table, QTableView)
2026-07-02 15:17:11 +08:00
self.assertIsInstance(tab.generate_cover_checkbox, QCheckBox)
2026-06-27 15:56:55 +08:00
self.assertEqual("标题提示词", tab.title_prompt_edit.placeholderText())
self.assertEqual("封面提示词", tab.cover_prompt_edit.placeholderText())
2026-06-27 16:10:50 +08:00
self.assertEqual("保存标题提示词", tab.save_title_button.text())
2026-06-27 16:26:08 +08:00
self.assertEqual("开始生成", tab.generate_button.text())
self.assertEqual("停止", tab.stop_generate_button.text())
2026-06-30 11:50:49 +08:00
self.assertEqual("重置生成结果", tab.reset_generate_button.text())
2026-07-02 15:17:11 +08:00
self.assertEqual("生成封面图片(成本较高)", tab.generate_cover_checkbox.text())
self.assertFalse(tab.generate_cover_checkbox.isChecked())
2026-06-27 16:26:08 +08:00
self.assertFalse(tab.stop_generate_button.isEnabled())
2026-07-02 15:17:11 +08:00
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())
2026-07-04 17:14:29 +08:00
self.assertEqual("generateCmhubBalanceLabel", tab.cmhub_balance_label.objectName())
self.assertEqual("cmhub余额:未获取", tab.cmhub_balance_label.text())
2026-07-04 17:59:23 +08:00
self.assertFalse(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())
2026-06-27 16:10:50 +08:00
self.assertEqual("默认", tab.cover_template_combo.currentText())
2026-07-02 11:26:03 +08:00
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"))
2026-06-27 15:56:55 +08:00
self.assertEqual(["店铺", "商品ID", "旧标题", "新标题", "状态"], tab.model.HEADERS)
self.assertEqual("任务 0/0 条", tab.summary_label.text())
self.assert_removed(temp_dir)
2026-07-04 17:14:29 +08:00
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.assertFalse(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())
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)
2026-07-02 15:17:11 +08:00
def test_generate_tab_persists_generate_cover_toggle(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_cover_checkbox.setChecked(True)
saved = appconfig.load_config(cfg["config_path"])
self.assertTrue(saved["ai"]["generate_cover"])
self.assertTrue(cfg["ai"]["generate_cover"])
self.assertIn("会同时生成封面图片", statuses[-1])
self.assert_removed(temp_dir)
2026-06-27 16:10:50 +08:00
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)):
2026-07-02 11:26:03 +08:00
tab.save_cover_template_as_action.trigger()
2026-06-27 16:10:50 +08:00
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)):
2026-07-02 11:26:03 +08:00
tab.rename_cover_template_action.trigger()
2026-06-27 16:10:50 +08:00
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])
2026-06-27 16:26:08 +08:00
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()
2026-06-27 16:10:50 +08:00
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):
2026-07-02 11:26:03 +08:00
tab.delete_cover_template_action.trigger()
2026-06-27 16:10:50 +08:00
self.assertNotIn("改名", prompts.list_cover_templates(cover_prompts_dir))
self.assert_removed(temp_dir)
2026-06-30 11:50:49 +08:00
2026-07-01 14:30:49 +08:00
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)
2026-06-30 11:50:49 +08:00
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)
2026-06-27 16:26:08 +08:00
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)
2026-07-02 15:17:11 +08:00
cfg["ai"] = appconfig.ai_config(cfg)
cfg["ai"]["generate_cover"] = True
2026-06-27 16:26:08 +08:00
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 = []
2026-06-27 16:26:08 +08:00
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)
2026-06-27 16:26:08 +08:00
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",
}
)
2026-06-27 16:26:08 +08:00
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)
2026-06-27 16:26:08 +08:00
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"])
2026-06-27 16:26:08 +08:00
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.assertIn("[图片] 1/1 商品 51100639510", joined_logs)
self.assertIn("准备重试 1/2", joined_logs)
self.assertIn("token=***", joined_logs)
self.assertNotIn("SECRET-TOKEN", joined_logs)
self.assertIn("[完成] AI 生成完成:标题1/1,图片1/1,失败0", joined_logs)
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.assert_removed(temp_dir)
2026-07-04 17:14:29 +08:00
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
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("[计费] 商品 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)
2026-07-02 15:17:11 +08:00
cfg["ai"] = appconfig.ai_config(cfg)
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)
2026-07-02 15:17:11 +08:00
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)
2026-07-02 17:32:51 +08:00
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)
2026-07-02 17:32:51 +08:00
text = tab.run_log_view.toPlainText()
self.assertEqual("generateRunLogView", tab.run_log_view.objectName())
2026-07-02 17:32:51 +08:00
self.assertIn("本轮日志会在开始运行后显示", text)
self.assertNotIn("phase=cover step=cover_request result=failed", text)
2026-06-27 16:26:08 +08:00
self.assert_removed(temp_dir)
2026-07-02 17:32:51 +08:00
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", 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("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_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)
2026-07-02 17:32:51 +08:00
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)
2026-06-27 16:59:46 +08:00
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)
2026-07-01 14:30:49 +08:00
self.assertEqual("检查本轮更新", tab.preview_update_button.text())
2026-06-27 16:59:46 +08:00
self.assertEqual("开始更新", tab.start_update_button.text())
self.assertEqual("applyItemFilter", tab.item_filter.objectName())
2026-06-27 16:59:46 +08:00
self.assertEqual("停止", tab.stop_update_button.text())
2026-06-30 11:50:49 +08:00
self.assertEqual("重置更新状态", tab.reset_update_button.text())
2026-07-01 14:30:49 +08:00
self.assertTrue(tab.reset_update_button.isHidden())
self.assertEqual(gui.Qt.CustomContextMenu, tab.task_table.contextMenuPolicy())
self.assertEqual("startUpdateButton", tab.start_update_button.objectName())
2026-07-01 17:56:27 +08:00
self.assertIn(gui.COLOR_WARNING, tab.start_update_button.styleSheet())
self.assertIn("border", tab.start_update_button.styleSheet())
2026-06-27 16:59:46 +08:00
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())
2026-07-01 17:56:27 +08:00
self.assert_foreground(tab.model, 0, 4, gui.COLOR_PENDING)
2026-06-27 16:59:46 +08:00
self.assertEqual("待更新", tab.model.index(0, 5).data())
2026-07-01 17:56:27 +08:00
self.assert_foreground(tab.model, 0, 5, gui.COLOR_PENDING)
2026-06-27 16:59:46 +08:00
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())
2026-07-01 17:56:27 +08:00
self.assert_foreground(tab.model, 0, 5, gui.COLOR_DANGER)
2026-06-27 16:59:46 +08:00
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())
2026-07-01 17:56:27 +08:00
self.assert_foreground(tab.model, 0, 4, gui.COLOR_SUCCESS)
2026-06-27 16:59:46 +08:00
self.assertEqual("成功", tab.model.index(0, 5).data())
2026-07-01 17:56:27 +08:00
self.assert_foreground(tab.model, 0, 5, gui.COLOR_SUCCESS)
2026-06-27 16:59:46 +08:00
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)),
)
2026-06-27 16:59:46 +08:00
self.assert_removed(temp_dir)
2026-06-30 11:50:49 +08:00
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)
2026-06-27 16:59:46 +08:00
2026-06-27 17:13:18 +08:00
def test_apply_tab_start_update_requires_confirmation_before_starting_worker(self):
2026-06-27 16:59:46 +08:00
with self.make_temp_dir() as temp_dir:
cfg = self.make_config(temp_dir)
2026-06-29 09:18:03 +08:00
self.allow_shopee_update(cfg)
2026-06-27 16:59:46 +08:00
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")
2026-06-27 16:59:46 +08:00
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)
2026-07-01 08:35:46 +08:00
self.assertIn("预计批次:1", message)
2026-06-27 16:59:46 +08:00
self.assertIn("提交线上", message)
self.assertIn("状态:已生成", message)
self.assertIn("商品ID:51100639510", message)
2026-07-01 08:35:46 +08:00
self.assertNotIn("测试商品ID", message)
2026-06-27 16:59:46 +08:00
self.assertEqual("已取消开始更新", statuses[-1])
apply_task.assert_not_called()
2026-06-27 17:13:18 +08:00
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)
2026-06-29 09:18:03 +08:00
self.allow_shopee_update(cfg, close_success_tab=True)
2026-06-27 17:13:18 +08:00
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)
2026-07-02 17:32:51 +08:00
tab.run_log_view.setPlainText("上一轮更新失败")
2026-06-27 17:13:18 +08:00
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()
2026-06-27 16:59:46 +08:00
with mock.patch(
"app.gui.QMessageBox.question",
return_value=gui.QMessageBox.Yes,
2026-06-27 17:13:18 +08:00
), mock.patch("app.gui.run_worker", return_value=fake_thread) as run_worker, \
mock.patch("app.gui.editor.apply_task") as apply_task:
2026-06-27 16:59:46 +08:00
tab.start_update()
2026-06-27 17:13:18 +08:00
run_worker.assert_called_once()
self.assertIsInstance(tab.apply_worker, ApplyWorker)
self.assertIs(tab.apply_thread, fake_thread)
self.assertTrue(fake_thread.started)
2026-06-29 09:18:03 +08:00
self.assertTrue(tab.apply_worker.close_success_tab)
2026-06-29 10:25:09 +08:00
self.assertFalse(tab.apply_worker.dry_run)
self.assertFalse(tab.apply_worker.parallel_accounts)
2026-07-01 08:35:46 +08:00
self.assertEqual(1, tab.apply_worker.batch_size)
2026-07-02 17:32:51 +08:00
log_text = tab.run_log_view.toPlainText()
self.assertNotIn("上一轮更新失败", log_text)
self.assertIn("本轮更新开始:任务 1 条,每批 1 条", log_text)
2026-07-01 08:35:46 +08:00
self.assertFalse(tab.preview_update_button.isEnabled())
2026-06-27 17:13:18 +08:00
self.assertFalse(tab.start_update_button.isEnabled())
self.assertTrue(tab.stop_update_button.isEnabled())
2026-07-01 08:35:46 +08:00
self.assertEqual("开始更新:1 条,按每批最多 1 条执行", statuses[-1])
2026-06-27 16:59:46 +08:00
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)
2026-07-01 08:35:46 +08:00
def test_apply_tab_preview_starts_without_real_submit_switch(self):
2026-06-29 10:25:09 +08:00
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,
2026-07-01 08:35:46 +08:00
"dry_run": False,
2026-06-29 10:25:09 +08:00
"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)
2026-07-02 17:32:51 +08:00
tab.run_log_view.setPlainText("上一轮检查失败")
2026-06-29 10:25:09 +08:00
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):
2026-07-01 08:35:46 +08:00
tab.preview_update()
2026-06-29 10:25:09 +08:00
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)
2026-07-02 17:32:51 +08:00
log_text = tab.run_log_view.toPlainText()
self.assertNotIn("上一轮检查失败", log_text)
self.assertIn("本轮检查开始:任务 1 条,每批 1 条", log_text)
2026-07-01 14:30:49 +08:00
self.assertEqual("开始检查本轮更新:1 条", statuses[-1])
2026-06-29 10:25:09 +08:00
self.assert_removed(temp_dir)
2026-06-29 09:18:03 +08:00
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")
2026-06-29 09:18:03 +08:00
2026-07-01 14:30:49 +08:00
with mock.patch.object(tab, "_show_update_safety_error") as safety_error, \
2026-06-29 09:18:03 +08:00
mock.patch("app.gui.QMessageBox.question") as question, \
mock.patch("app.gui.run_worker") as run_worker:
tab.start_update()
2026-07-01 14:30:49 +08:00
message = safety_error.call_args[0][0]
2026-06-29 09:18:03 +08:00
self.assertIn("允许真实提交线上商品", message)
2026-07-01 14:30:49 +08:00
self.assertIn("⑤设置", message)
2026-06-29 09:18:03 +08:00
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)
2026-07-01 14:30:49 +08:00
with mock.patch.object(tab, "_show_update_safety_error") as safety_error, \
2026-06-29 09:18:03 +08:00
mock.patch("app.gui.QMessageBox.question") as question, \
mock.patch("app.gui.run_worker") as run_worker:
tab.start_update()
2026-07-01 14:30:49 +08:00
message = safety_error.call_args[0][0]
self.assertIn("允许更新封面", message)
self.assertIn("⑤设置", message)
2026-06-29 09:18:03 +08:00
question.assert_not_called()
run_worker.assert_not_called()
self.assert_removed(temp_dir)
2026-07-01 08:35:46 +08:00
def test_apply_tab_safety_error_allows_more_than_batch_size_and_non_test_items(self):
2026-06-29 09:18:03 +08:00
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
2026-07-01 08:35:46 +08:00
count_error = tab._update_safety_error(
2026-06-29 09:18:03 +08:00
[
Task("51100639510"),
Task("51100639510"),
]
)
2026-07-01 08:35:46 +08:00
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")])
2026-06-29 09:18:03 +08:00
2026-07-01 08:35:46 +08:00
self.assertIsNone(count_error)
self.assertIsNone(non_test_error)
self.assertIsNone(missing_test_id_error)
2026-06-29 09:18:03 +08:00
self.assert_removed(temp_dir)
2026-07-01 14:30:49 +08:00
def test_apply_tab_safety_error_popup_can_open_settings_tab(self):
with self.make_temp_dir() as temp_dir:
opened = []
tab = ApplyTab(
config=self.make_config(temp_dir),
open_settings_callback=lambda: opened.append(True),
)
self.addCleanup(tab.close)
settings_button = object()
box = mock.Mock()
box.addButton.side_effect = [settings_button, object()]
box.clickedButton.return_value = settings_button
with mock.patch("app.gui.QMessageBox", return_value=box) as message_box:
tab._show_update_safety_error("设置未开启")
message_box.assert_called_once_with(tab)
box.setWindowTitle.assert_called_once_with("更新安全开关")
box.setText.assert_called_once_with("设置未开启")
self.assertEqual([True], opened)
self.assert_removed(temp_dir)
2026-06-27 17:13:18 +08:00
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 = []
2026-06-29 09:18:03 +08:00
close_flags = []
2026-06-27 17:13:18 +08:00
progress = []
rows = []
logs = []
2026-06-27 17:13:18 +08:00
2026-07-01 15:32:27 +08:00
def fake_apply(account, task, close_success_tab=False, on_step=None):
2026-06-27 17:13:18 +08:00
applied_aliases.append(account.alias)
2026-06-29 09:18:03 +08:00
close_flags.append(close_success_tab)
2026-06-27 17:13:18 +08:00
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):
2026-06-29 09:18:03 +08:00
worker = ApplyWorker(
tasks,
db_path=cfg["db_path"],
config=cfg,
close_success_tab=True,
2026-07-01 08:35:46 +08:00
batch_size=1,
2026-06-29 09:18:03 +08:00
)
2026-06-27 17:13:18 +08:00
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)
2026-06-29 09:18:03 +08:00
self.assertEqual([True, True], close_flags)
2026-06-29 10:25:09 +08:00
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"])
2026-07-01 08:35:46 +08:00
self.assertEqual(1, summary["batch_size"])
self.assertEqual(3, summary["batch_count"])
2026-06-29 10:25:09 +08:00
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"])
2026-07-01 08:35:46 +08:00
self.assertEqual(1, progress[-1]["batch_size"])
self.assertEqual(3, progress[-1]["batch_count"])
2026-06-27 17:13:18 +08:00
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))
2026-06-29 10:25:09 +08:00
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)
2026-07-01 08:35:46 +08:00
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))
2026-06-29 10:25:09 +08:00
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))
2026-07-01 14:30:49 +08:00
self.assertTrue(any("检查" in line for line in logs))
2026-06-29 10:25:09 +08:00
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()
2026-07-01 15:32:27 +08:00
def fake_apply(account, task, close_success_tab=False, on_step=None):
2026-06-29 10:25:09 +08:00
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)
2026-06-27 17:13:18 +08:00
self.assert_removed(temp_dir)
2026-06-27 17:28:33 +08:00
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)
2026-06-27 17:13:18 +08:00
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, \
2026-07-01 08:35:46 +08:00
mock.patch("app.gui.accounts.launch_for_login") as launch_for_login, \
2026-06-27 17:13:18 +08:00
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()
2026-07-01 08:35:46 +08:00
launch_for_login.assert_not_called()
2026-06-27 17:13:18 +08:00
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)
2026-07-01 08:35:46 +08:00
self.assertIn("本轮更新已中止", message)
self.assertIn("不会自动打开账号 Chrome", message)
self.assertIn("不会提交任何商品", message)
2026-06-27 17:13:18 +08:00
self.assertIn("④ 账号管理", message)
self.assertEqual([True], opened)
2026-07-01 08:35:46 +08:00
self.assertIn("本轮更新已中止", statuses[-1])
2026-06-27 17:13:18 +08:00
self.assertIn("④ 账号管理", statuses[-1])
self.assert_removed(temp_dir)
2026-06-27 15:56:55 +08:00
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())
2026-06-27 15:56:55 +08:00
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())
2026-07-01 17:56:27 +08:00
self.assert_foreground(tab.model, 0, 4, gui.COLOR_PENDING)
2026-06-27 15:56:55 +08:00
self.assertEqual("已生成", tab.model.index(2, 4).data())
2026-07-01 17:56:27 +08:00
self.assert_foreground(tab.model, 2, 4, gui.COLOR_SUCCESS)
2026-06-27 15:56:55 +08:00
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())
2026-07-01 17:56:27 +08:00
self.assert_foreground(tab.model, 0, 4, gui.COLOR_DANGER)
2026-06-27 15:56:55 +08:00
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())
2026-06-27 15:56:55 +08:00
self.assert_removed(temp_dir)
2026-06-27 09:56:53 +08:00
def test_tab_switch_updates_status_bar(self):
2026-06-27 10:30:45 +08:00
with self.make_temp_dir() as temp_dir:
window = MainWindow(config=self.make_config(temp_dir))
self.addCleanup(window.close)
2026-06-27 09:56:53 +08:00
2026-06-27 10:30:45 +08:00
window.tabs.setCurrentIndex(2)
2026-06-27 09:56:53 +08:00
2026-06-27 10:30:45 +08:00
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())
2026-07-02 09:06:52 +08:00
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())
2026-06-27 10:30:45 +08:00
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)
2026-06-27 10:30:45 +08:00
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}}
)
2026-07-02 09:06:52 +08:00
self.assertEqual("● 已登录", tab.table.item(0, 4).text())
self.assertEqual(gui.COLOR_SUCCESS, tab.table.item(0, 4).foreground().color().name())
2026-06-27 10:30:45 +08:00
self.assert_removed(temp_dir)
2026-06-27 09:56:53 +08:00
2026-06-27 10:38:21 +08:00
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)
2026-06-27 11:29:48 +08:00
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())
2026-07-01 17:56:27 +08:00
self.assert_foreground(tab.model, 0, 3, gui.COLOR_PENDING)
2026-07-02 09:06:52 +08:00
self.assertEqual("deleteBatchButton", tab.delete_batch_button.objectName())
self.assertIn(gui.COLOR_DANGER, tab.delete_batch_button.styleSheet())
2026-06-27 11:29:48 +08:00
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())
2026-07-01 17:56:27 +08:00
self.assert_foreground(tab.model, 1, 3, gui.COLOR_MUTED)
2026-06-27 11:40:51 +08:00
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())
2026-07-01 17:56:27 +08:00
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())
2026-06-27 11:40:51 +08:00
self.assertIn("主店1", tab.match_detail_label.text())
2026-06-27 11:29:48 +08:00
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"])
tab = CollectTab(config=cfg)
self.addCleanup(tab.close)
self.assertEqual("collectShopFilter", tab.shop_filter.objectName())
self.assertEqual("collectItemFilter", tab.item_filter.objectName())
self.assertEqual("collectStatusFilter", tab.status_filter.objectName())
self.assertEqual(5, tab.model.rowCount())
self.assertIn("5 行", tab.summary_label.text())
self.assertEqual("未匹配(1)", tab.show_unmatched_button.text())
self.assertIn("总数5", tab.batch_progress_label.text())
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("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.assertFalse(tab.shop_filter.isEnabled())
self.assertFalse(tab.item_filter.isEnabled())
self.assertFalse(tab.status_filter.isEnabled())
self.assert_removed(temp_dir)
2026-07-01 09:41:10 +08:00
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)
2026-06-27 11:40:51 +08:00
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)
2026-06-27 11:29:48 +08:00
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": [],
2026-06-27 11:40:51 +08:00
"stats": {
"files": 1,
"total": 2,
"valid": 1,
"invalid": 1,
"inserted": 1,
},
2026-06-27 11:29:48 +08:00
}
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())
2026-06-27 11:40:51 +08:00
self.assertIn("1 文件", tab.summary_label.text())
self.assertIn("2 行", tab.summary_label.text())
2026-07-01 17:56:27 +08:00
self.assertIn("有效1/", tab.summary_label.text())
self.assertIn("无效1", tab.summary_label.text())
self.assertIn(gui.COLOR_DANGER, tab.summary_label.text())
2026-06-27 11:40:51 +08:00
self.assertIn("匹配1", tab.summary_label.text())
2026-07-01 17:56:27 +08:00
self.assertEqual("", tab.show_unmatched_button.styleSheet())
2026-06-27 11:29:48 +08:00
self.assertIn("入库1", statuses[-1])
self.assert_removed(temp_dir)
2026-06-27 11:51:42 +08:00
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"])
2026-07-01 08:35:46 +08:00
expected_old_cover = os.path.join(str(batch_id), logged.slug, f"{tasks[0].id}_51100639510_old.jpg")
2026-06-27 11:51:42 +08:00
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):
2026-06-27 11:51:42 +08:00
self.assertEqual(logged.alias, account.alias)
self.assertEqual("51100639510", task["item_id"])
2026-07-01 08:35:46 +08:00
self.assertTrue(task["old_cover_path"].endswith(expected_old_cover))
self.assertTrue(callable(on_step))
on_step("download_cover")
2026-06-27 11:51:42 +08:00
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:
2026-06-27 15:00:15 +08:00
summary = CollectWorker(
tasks,
db_path=cfg["db_path"],
config=cfg,
preflight=False,
).execute()
2026-06-27 11:51:42 +08:00
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)
2026-06-27 11:51:42 +08:00
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)
2026-07-01 08:35:46 +08:00
self.assertTrue(by_alias["alias-a"].old_cover_path.endswith(expected_old_cover))
2026-06-27 11:51:42 +08:00
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())
2026-06-27 11:51:42 +08:00
self.assert_removed(temp_dir)
2026-06-27 14:47:08 +08:00
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)
2026-06-27 15:00:15 +08:00
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)
2026-06-27 15:00:15 +08:00
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)
2026-07-01 08:35:46 +08:00
self.assertIn("本轮采集已中止", message)
self.assertIn("不会自动打开账号 Chrome", message)
2026-06-27 15:00:15 +08:00
self.assertIn("④ 账号管理", message)
self.assertEqual([True], opened)
2026-07-01 08:35:46 +08:00
self.assertIn("本轮采集已中止", statuses[-1])
2026-06-27 15:00:15 +08:00
self.assertIn("④ 账号管理", statuses[-1])
self.assert_removed(temp_dir)
2026-06-27 14:37:58 +08:00
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",
)
2026-06-27 17:28:33 +08:00
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",
)
2026-06-27 09:56:53 +08:00
2026-07-01 15:32:27 +08:00
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", 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):
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",
2026-07-06 15:01:50 +08:00
return_value={
"action": "launched",
"pid": 1234,
"target_id": "target-new",
"url": "https://seller.shopee.tw/portal/",
},
2026-07-01 15:32:27 +08:00
) 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)
2026-07-06 15:01:50 +08:00
self.assertIn("step=launch_chrome result=launched", messages)
2026-07-01 15:32:27 +08:00
self.assertIn("pid=1234", messages)
2026-07-06 15:01:50 +08:00
self.assertIn("target_id=target-new", messages)
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()
launch_for_login.assert_called_once_with(account, config=cfg)
self.assertEqual("已启动", tab.login_statuses["alias-a"])
self.assertIn("已复用现有窗口", 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=reused", messages)
self.assertIn("target_id=target-existing", messages)
self.assertIn("url=https://seller.shopee.tw/portal/", messages)
2026-07-01 15:32:27 +08:00
self.assert_removed(temp_dir)
2026-06-27 09:56:53 +08:00
if __name__ == "__main__":
unittest.main()