2026-06-27 09:56:53 +08:00
|
|
|
import unittest
|
|
|
|
|
import os
|
|
|
|
|
import sys
|
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-06-29 08:45:04 +08:00
|
|
|
from app import accounts, 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-06-27 15:56:55 +08:00
|
|
|
from PySide6.QtWidgets import QApplication, QLineEdit, QPlainTextEdit, QTableView
|
2026-06-27 09:56:53 +08:00
|
|
|
|
2026-06-27 11:29:48 +08:00
|
|
|
from app.gui import (
|
|
|
|
|
AccountDialog,
|
|
|
|
|
AccountsTab,
|
2026-06-29 08:45:04 +08:00
|
|
|
AIModelTestWorker,
|
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"),
|
2026-06-29 08:58:39 +08:00
|
|
|
"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-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())
|
|
|
|
|
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-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-06-29 09:18:03 +08:00
|
|
|
self.assertEqual("51100639510", tab.test_item_id_edit.text())
|
|
|
|
|
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 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)
|
|
|
|
|
tab.save_model()
|
|
|
|
|
|
|
|
|
|
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.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)
|
|
|
|
|
|
2026-06-29 08:58:39 +08:00
|
|
|
def test_settings_tab_saves_role_generation_path_and_port_config(self):
|
|
|
|
|
with self.make_temp_dir() as temp_dir:
|
|
|
|
|
cfg = self.make_config(temp_dir)
|
|
|
|
|
cfg["ai"] = appconfig.default_config()["ai"]
|
|
|
|
|
cfg["ai"]["default_text_model"] = "Text A"
|
|
|
|
|
cfg["ai"]["default_image_model"] = "Image A"
|
|
|
|
|
models_path = cfg["ai_models_path"]
|
|
|
|
|
config_path = cfg["config_path"]
|
|
|
|
|
appconfig.save_ai_models_config(
|
|
|
|
|
{
|
|
|
|
|
"models": [
|
|
|
|
|
{
|
|
|
|
|
"name": "Text A",
|
|
|
|
|
"category": "text",
|
|
|
|
|
"enabled": True,
|
|
|
|
|
"url": "",
|
|
|
|
|
"model": "",
|
|
|
|
|
"api_key": "",
|
|
|
|
|
"api_type": "chat",
|
|
|
|
|
"connect_timeout_seconds": 30,
|
|
|
|
|
"timeout_seconds": 0,
|
|
|
|
|
"extra_body": {},
|
|
|
|
|
},
|
|
|
|
|
{
|
|
|
|
|
"name": "Text B",
|
|
|
|
|
"category": "text",
|
|
|
|
|
"enabled": True,
|
|
|
|
|
"url": "",
|
|
|
|
|
"model": "",
|
|
|
|
|
"api_key": "",
|
|
|
|
|
"api_type": "chat",
|
|
|
|
|
"connect_timeout_seconds": 30,
|
|
|
|
|
"timeout_seconds": 0,
|
|
|
|
|
"extra_body": {},
|
|
|
|
|
},
|
|
|
|
|
{
|
|
|
|
|
"name": "Image A",
|
|
|
|
|
"category": "image",
|
|
|
|
|
"enabled": True,
|
|
|
|
|
"url": "",
|
|
|
|
|
"model": "",
|
|
|
|
|
"api_key": "",
|
|
|
|
|
"api_type": "auto",
|
|
|
|
|
"connect_timeout_seconds": 30,
|
|
|
|
|
"timeout_seconds": 0,
|
|
|
|
|
"extra_body": {},
|
|
|
|
|
},
|
|
|
|
|
{
|
|
|
|
|
"name": "Image B",
|
|
|
|
|
"category": "image",
|
|
|
|
|
"enabled": True,
|
|
|
|
|
"url": "",
|
|
|
|
|
"model": "",
|
|
|
|
|
"api_key": "",
|
|
|
|
|
"api_type": "auto",
|
|
|
|
|
"connect_timeout_seconds": 30,
|
|
|
|
|
"timeout_seconds": 0,
|
|
|
|
|
"extra_body": {},
|
|
|
|
|
},
|
|
|
|
|
]
|
|
|
|
|
},
|
|
|
|
|
path=models_path,
|
|
|
|
|
)
|
|
|
|
|
statuses = []
|
|
|
|
|
tab = SettingsTab(
|
|
|
|
|
config=cfg,
|
|
|
|
|
config_path=config_path,
|
|
|
|
|
ai_models_path=models_path,
|
|
|
|
|
status_callback=statuses.append,
|
|
|
|
|
)
|
|
|
|
|
self.addCleanup(tab.close)
|
|
|
|
|
|
|
|
|
|
text_roles = [
|
|
|
|
|
tab.default_text_model_combo.itemData(index)
|
|
|
|
|
for index in range(tab.default_text_model_combo.count())
|
|
|
|
|
]
|
|
|
|
|
image_roles = [
|
|
|
|
|
tab.default_image_model_combo.itemData(index)
|
|
|
|
|
for index in range(tab.default_image_model_combo.count())
|
|
|
|
|
]
|
|
|
|
|
self.assertEqual(["Text A", "Text B"], text_roles)
|
|
|
|
|
self.assertEqual(["Image A", "Image B"], image_roles)
|
|
|
|
|
|
|
|
|
|
tab.default_text_model_combo.setCurrentIndex(
|
|
|
|
|
tab.default_text_model_combo.findData("Text B")
|
|
|
|
|
)
|
|
|
|
|
tab.default_image_model_combo.setCurrentIndex(
|
|
|
|
|
tab.default_image_model_combo.findData("Image B")
|
|
|
|
|
)
|
|
|
|
|
tab.title_concurrency_spin.setValue(3)
|
|
|
|
|
tab.image_concurrency_spin.setValue(2)
|
|
|
|
|
tab.retry_spin.setValue(1)
|
|
|
|
|
tab.resolution_combo.setCurrentIndex(tab.resolution_combo.findData("2k"))
|
|
|
|
|
self.assertEqual("360 秒", tab.response_timeout_label.text())
|
|
|
|
|
tab.jpg_quality_spin.setValue(86)
|
|
|
|
|
tab.chrome_path_edit.setText("D:\\Chrome\\chrome.exe")
|
|
|
|
|
tab.user_data_root_edit.setText("profiles")
|
|
|
|
|
tab.image_dir_edit.setText("pictures")
|
|
|
|
|
tab.db_path_edit.setText("data\\cmshopee.db")
|
|
|
|
|
tab.default_debug_port_spin.setValue(9300)
|
|
|
|
|
tab.debug_port_start_spin.setValue(9300)
|
|
|
|
|
tab.debug_port_end_spin.setValue(9350)
|
|
|
|
|
tab.cdp_ready_timeout_spin.setValue(45)
|
2026-06-29 09:18:03 +08:00
|
|
|
tab.test_item_id_edit.setText("51100639510")
|
|
|
|
|
tab.allow_real_submit_checkbox.setChecked(True)
|
|
|
|
|
tab.allow_cover_update_checkbox.setChecked(True)
|
|
|
|
|
tab.max_items_per_run_spin.setValue(2)
|
|
|
|
|
tab.close_success_tab_checkbox.setChecked(True)
|
2026-06-29 08:58:39 +08:00
|
|
|
|
|
|
|
|
tab.save_app_settings()
|
|
|
|
|
|
|
|
|
|
saved = appconfig.load_config(config_path)
|
|
|
|
|
self.assertEqual("Text B", saved["ai"]["default_text_model"])
|
|
|
|
|
self.assertEqual("Image B", saved["ai"]["default_image_model"])
|
|
|
|
|
self.assertEqual(3, saved["ai"]["title_concurrency"])
|
|
|
|
|
self.assertEqual(2, saved["ai"]["image_concurrency"])
|
|
|
|
|
self.assertEqual(1, saved["ai"]["retry"])
|
|
|
|
|
self.assertEqual("2k", saved["ai"]["resolution"])
|
|
|
|
|
self.assertEqual(86, saved["ai"]["jpg_quality"])
|
|
|
|
|
self.assertEqual("D:\\Chrome\\chrome.exe", saved["chrome_path"])
|
|
|
|
|
self.assertEqual("profiles", saved["user_data_root"])
|
|
|
|
|
self.assertEqual("pictures", saved["image_dir"])
|
|
|
|
|
self.assertEqual("data\\cmshopee.db", saved["db_path"])
|
|
|
|
|
self.assertEqual(9300, saved["default_debug_port"])
|
|
|
|
|
self.assertEqual([9300, 9350], saved["debug_port_range"])
|
|
|
|
|
self.assertEqual(45, saved["cdp_ready_timeout"])
|
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,
|
|
|
|
|
},
|
|
|
|
|
saved["shopee_update"],
|
|
|
|
|
)
|
2026-06-29 08:58:39 +08:00
|
|
|
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)
|
|
|
|
|
window = MainWindow(
|
|
|
|
|
config=cfg,
|
|
|
|
|
config_path=cfg["config_path"],
|
|
|
|
|
ai_models_path=cfg["ai_models_path"],
|
|
|
|
|
)
|
|
|
|
|
self.addCleanup(window.close)
|
|
|
|
|
settings_tab = window.tabs.widget(TAB_TITLES.index("⑤ 设置"))
|
|
|
|
|
apply_tab = window.tabs.widget(TAB_TITLES.index("③ 更新shopee"))
|
|
|
|
|
|
|
|
|
|
settings_tab.test_item_id_edit.setText("123456789")
|
|
|
|
|
settings_tab.allow_real_submit_checkbox.setChecked(True)
|
|
|
|
|
settings_tab.allow_cover_update_checkbox.setChecked(True)
|
|
|
|
|
settings_tab.max_items_per_run_spin.setValue(3)
|
|
|
|
|
settings_tab.close_success_tab_checkbox.setChecked(True)
|
|
|
|
|
settings_tab.save_app_settings()
|
|
|
|
|
|
|
|
|
|
safety_cfg = apply_tab._shopee_update_config()
|
|
|
|
|
self.assertEqual("123456789", safety_cfg["test_item_id"])
|
|
|
|
|
self.assertTrue(safety_cfg["allow_real_submit"])
|
|
|
|
|
self.assertTrue(safety_cfg["allow_cover_update"])
|
|
|
|
|
self.assertEqual(3, safety_cfg["max_items_per_run"])
|
|
|
|
|
self.assertTrue(safety_cfg["close_success_tab"])
|
|
|
|
|
|
|
|
|
|
self.assert_removed(temp_dir)
|
|
|
|
|
|
2026-06-29 08:58:39 +08:00
|
|
|
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)
|
|
|
|
|
|
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)
|
|
|
|
|
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())
|
|
|
|
|
self.assertFalse(tab.stop_generate_button.isEnabled())
|
|
|
|
|
self.assertEqual("进度:标题0/0 · 封面0/0 · 失败0", tab.progress_label.text())
|
2026-06-27 16:10:50 +08:00
|
|
|
self.assertEqual("默认", tab.cover_template_combo.currentText())
|
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-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)):
|
|
|
|
|
tab.save_cover_template_as()
|
|
|
|
|
self.assertEqual("另存", tab.cover_template_combo.currentText())
|
|
|
|
|
self.assertEqual("另存模板 {新标题}", prompts.load_cover_template("另存", cover_prompts_dir))
|
|
|
|
|
|
|
|
|
|
with mock.patch("app.gui.QInputDialog.getText", return_value=("改名", True)):
|
|
|
|
|
tab.rename_cover_template()
|
|
|
|
|
self.assertEqual("改名", tab.cover_template_combo.currentText())
|
|
|
|
|
self.assertIn("改名", prompts.list_cover_templates(cover_prompts_dir))
|
|
|
|
|
|
|
|
|
|
tab.cover_prompt_edit.setPlainText("预览 {旧标题} {新标题} {商品id} {店铺}")
|
|
|
|
|
tab.task_table.selectRow(0)
|
|
|
|
|
with mock.patch("app.gui.QMessageBox.information") as info:
|
|
|
|
|
tab.preview_cover_prompt()
|
|
|
|
|
self.assertIn("预览 旧标题 新标题 51100639510 主店", info.call_args[0][2])
|
|
|
|
|
|
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):
|
|
|
|
|
tab.delete_cover_template()
|
|
|
|
|
self.assertNotIn("改名", prompts.list_cover_templates(cover_prompts_dir))
|
|
|
|
|
|
|
|
|
|
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)
|
|
|
|
|
account = accounts.create_account("主店", "alias-a", debug_port=9222, config=cfg)
|
|
|
|
|
batch_id = db.create_batch(["input.xlsx"], path=cfg["db_path"])
|
|
|
|
|
db.insert_tasks(
|
|
|
|
|
batch_id,
|
|
|
|
|
[
|
|
|
|
|
{
|
|
|
|
|
"source_file_abs": os.path.join(temp_dir, "input.xlsx"),
|
|
|
|
|
"source_sheet": "商品",
|
|
|
|
|
"source_row": 2,
|
|
|
|
|
"account_name": "Excel主店",
|
|
|
|
|
"alias": "alias-a",
|
|
|
|
|
"item_id": "51100639510",
|
|
|
|
|
}
|
|
|
|
|
],
|
|
|
|
|
path=cfg["db_path"],
|
|
|
|
|
)
|
|
|
|
|
task = db.list_tasks(batch_id=batch_id, path=cfg["db_path"])[0]
|
|
|
|
|
db.set_collected(task.id, "旧标题", "old.jpg", path=cfg["db_path"])
|
|
|
|
|
tasks = db.list_tasks(batch_id=batch_id, path=cfg["db_path"])
|
|
|
|
|
progress = []
|
|
|
|
|
rows = []
|
|
|
|
|
|
|
|
|
|
def fake_generate_batch(tasks_arg, prompt_values, ai_cfg=None, on_progress=None, should_stop=None):
|
|
|
|
|
self.assertEqual(tasks, tasks_arg)
|
|
|
|
|
self.assertEqual({"title": "标题提示", "cover": "封面提示"}, prompt_values)
|
|
|
|
|
self.assertEqual(account, ai_cfg["account_by_alias"]["alias-a"])
|
|
|
|
|
self.assertEqual(cfg["db_path"], ai_cfg["db_path"])
|
|
|
|
|
self.assertFalse(should_stop())
|
|
|
|
|
on_progress({"total": 1, "title_done": 1, "cover_done": 0, "failed": 0})
|
|
|
|
|
ai_cfg["on_task_update"](tasks[0].id, {"stage": "generated"})
|
|
|
|
|
return {"ok": True, "total": 1, "title_done": 1, "cover_done": 1, "failed": 0}
|
|
|
|
|
|
|
|
|
|
worker = GenerateWorker(
|
|
|
|
|
tasks,
|
|
|
|
|
{"title": "标题提示", "cover": "封面提示"},
|
|
|
|
|
db_path=cfg["db_path"],
|
|
|
|
|
config=cfg,
|
|
|
|
|
)
|
|
|
|
|
worker.progress.connect(progress.append)
|
|
|
|
|
worker.row_updated.connect(lambda task_id, fields: rows.append((task_id, fields)))
|
|
|
|
|
|
|
|
|
|
with mock.patch("app.gui.ai.generate_batch", side_effect=fake_generate_batch):
|
|
|
|
|
summary = worker.execute()
|
|
|
|
|
|
|
|
|
|
self.assertEqual(1, summary["cover_done"])
|
|
|
|
|
self.assertEqual([{"total": 1, "title_done": 1, "cover_done": 0, "failed": 0}], progress)
|
|
|
|
|
self.assertEqual([(tasks[0].id, {"stage": "generated"})], rows)
|
|
|
|
|
|
|
|
|
|
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)
|
|
|
|
|
self.assertEqual("开始更新", tab.start_update_button.text())
|
|
|
|
|
self.assertEqual("停止", tab.stop_update_button.text())
|
|
|
|
|
self.assertFalse(tab.stop_update_button.isEnabled())
|
|
|
|
|
self.assertEqual(2, tab.model.rowCount())
|
|
|
|
|
self.assertEqual("主店", tab.model.index(0, 0).data())
|
|
|
|
|
self.assertEqual("51100639510", tab.model.index(0, 1).data())
|
|
|
|
|
self.assertEqual("新标题10", tab.model.index(0, 2).data())
|
|
|
|
|
self.assertEqual("51100639510_new.jpg", tab.model.index(0, 3).data())
|
|
|
|
|
self.assertEqual("待更新", tab.model.index(0, 4).data())
|
|
|
|
|
self.assertEqual("待更新", tab.model.index(0, 5).data())
|
|
|
|
|
self.assertEqual("任务 2/4 条", tab.summary_label.text())
|
|
|
|
|
|
|
|
|
|
tab.status_filter.setCurrentIndex(tab.status_filter.findData("failed"))
|
|
|
|
|
self.assertEqual(1, tab.model.rowCount())
|
|
|
|
|
self.assertEqual("失败", tab.model.index(0, 5).data())
|
|
|
|
|
self.assertEqual(
|
|
|
|
|
"更新失败",
|
|
|
|
|
tab.model.data(tab.model.index(0, 0), gui.Qt.ToolTipRole),
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
tab.status_filter.setCurrentIndex(tab.status_filter.findData("generated"))
|
|
|
|
|
tab.shop_filter.setCurrentIndex(tab.shop_filter.findData("alias-b"))
|
|
|
|
|
self.assertEqual(1, tab.model.rowCount())
|
|
|
|
|
self.assertEqual("副店", tab.model.index(0, 0).data())
|
|
|
|
|
|
|
|
|
|
tab.shop_filter.setCurrentIndex(tab.shop_filter.findData(None))
|
|
|
|
|
tab.batch_filter.setCurrentIndex(tab.batch_filter.findData(batch_b))
|
|
|
|
|
tab.status_filter.setCurrentIndex(tab.status_filter.findData("applied"))
|
|
|
|
|
self.assertEqual(1, tab.model.rowCount())
|
|
|
|
|
self.assertEqual("已更新", tab.model.index(0, 4).data())
|
|
|
|
|
self.assertEqual("成功", tab.model.index(0, 5).data())
|
|
|
|
|
|
|
|
|
|
self.assert_removed(temp_dir)
|
|
|
|
|
|
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)
|
|
|
|
|
|
|
|
|
|
with mock.patch(
|
|
|
|
|
"app.gui.QMessageBox.question",
|
|
|
|
|
return_value=gui.QMessageBox.No,
|
|
|
|
|
) as question, mock.patch("app.gui.editor.apply_task") as apply_task:
|
|
|
|
|
tab.start_update()
|
|
|
|
|
|
|
|
|
|
message = question.call_args[0][2]
|
|
|
|
|
self.assertIn("任务数:1", message)
|
|
|
|
|
self.assertIn("提交线上", message)
|
|
|
|
|
self.assertIn("状态:已生成", message)
|
2026-06-29 09:18:03 +08:00
|
|
|
self.assertIn("测试商品ID=51100639510", 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)
|
|
|
|
|
|
|
|
|
|
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-27 17:13:18 +08:00
|
|
|
self.assertFalse(tab.start_update_button.isEnabled())
|
|
|
|
|
self.assertTrue(tab.stop_update_button.isEnabled())
|
|
|
|
|
self.assertEqual("开始更新: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-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)
|
|
|
|
|
|
|
|
|
|
with mock.patch("app.gui.QMessageBox.warning") as warning, \
|
|
|
|
|
mock.patch("app.gui.QMessageBox.question") as question, \
|
|
|
|
|
mock.patch("app.gui.run_worker") as run_worker:
|
|
|
|
|
tab.start_update()
|
|
|
|
|
|
|
|
|
|
message = warning.call_args[0][2]
|
|
|
|
|
self.assertIn("允许真实提交线上商品", message)
|
|
|
|
|
question.assert_not_called()
|
|
|
|
|
run_worker.assert_not_called()
|
|
|
|
|
self.assertIn("已阻止本次更新", statuses[-1])
|
|
|
|
|
|
|
|
|
|
self.assert_removed(temp_dir)
|
|
|
|
|
|
|
|
|
|
def test_apply_tab_blocks_cover_update_when_cover_switch_is_off(self):
|
|
|
|
|
with self.make_temp_dir() as temp_dir:
|
|
|
|
|
cfg = self.make_config(temp_dir)
|
|
|
|
|
self.allow_shopee_update(cfg, allow_cover=False)
|
|
|
|
|
accounts.create_account("主店", "alias-a", debug_port=9222, config=cfg)
|
|
|
|
|
batch_id = db.create_batch(["input.xlsx"], path=cfg["db_path"])
|
|
|
|
|
db.insert_tasks(
|
|
|
|
|
batch_id,
|
|
|
|
|
[
|
|
|
|
|
{
|
|
|
|
|
"source_file_abs": os.path.join(temp_dir, "input.xlsx"),
|
|
|
|
|
"source_sheet": "商品",
|
|
|
|
|
"source_row": 2,
|
|
|
|
|
"account_name": "Excel主店",
|
|
|
|
|
"alias": "alias-a",
|
|
|
|
|
"item_id": "51100639510",
|
|
|
|
|
}
|
|
|
|
|
],
|
|
|
|
|
path=cfg["db_path"],
|
|
|
|
|
)
|
|
|
|
|
task = db.list_tasks(batch_id=batch_id, path=cfg["db_path"])[0]
|
|
|
|
|
db.set_collected(task.id, "旧标题", "old.jpg", path=cfg["db_path"])
|
|
|
|
|
db.set_generated(task.id, "新标题", "new.jpg", path=cfg["db_path"])
|
|
|
|
|
tab = ApplyTab(config=cfg)
|
|
|
|
|
self.addCleanup(tab.close)
|
|
|
|
|
|
|
|
|
|
with mock.patch("app.gui.QMessageBox.warning") as warning, \
|
|
|
|
|
mock.patch("app.gui.QMessageBox.question") as question, \
|
|
|
|
|
mock.patch("app.gui.run_worker") as run_worker:
|
|
|
|
|
tab.start_update()
|
|
|
|
|
|
|
|
|
|
self.assertIn("允许更新封面", warning.call_args[0][2])
|
|
|
|
|
question.assert_not_called()
|
|
|
|
|
run_worker.assert_not_called()
|
|
|
|
|
|
|
|
|
|
self.assert_removed(temp_dir)
|
|
|
|
|
|
|
|
|
|
def test_apply_tab_safety_error_limits_max_count_and_test_item(self):
|
|
|
|
|
with self.make_temp_dir() as temp_dir:
|
|
|
|
|
cfg = self.make_config(temp_dir)
|
|
|
|
|
self.allow_shopee_update(cfg, item_id="51100639510", max_items=1)
|
|
|
|
|
tab = ApplyTab(config=cfg)
|
|
|
|
|
self.addCleanup(tab.close)
|
|
|
|
|
|
|
|
|
|
class Task:
|
|
|
|
|
def __init__(self, item_id, new_cover_path=None):
|
|
|
|
|
self.item_id = item_id
|
|
|
|
|
self.new_cover_path = new_cover_path
|
|
|
|
|
|
|
|
|
|
max_error = tab._update_safety_error(
|
|
|
|
|
[
|
|
|
|
|
Task("51100639510"),
|
|
|
|
|
Task("51100639510"),
|
|
|
|
|
]
|
|
|
|
|
)
|
|
|
|
|
item_error = tab._update_safety_error([Task("51100639511")])
|
|
|
|
|
|
|
|
|
|
self.assertIn("超过单次最大更新条数", max_error)
|
|
|
|
|
self.assertIn("非测试商品ID", item_error)
|
|
|
|
|
|
|
|
|
|
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 = []
|
|
|
|
|
|
2026-06-29 09:18:03 +08:00
|
|
|
def fake_apply(account, task, close_success_tab=False):
|
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-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-27 17:13:18 +08:00
|
|
|
self.assertEqual(
|
2026-06-27 17:28:33 +08:00
|
|
|
{
|
|
|
|
|
"ok": False,
|
|
|
|
|
"total": 3,
|
|
|
|
|
"done": 3,
|
|
|
|
|
"applied": 1,
|
|
|
|
|
"skipped": 1,
|
|
|
|
|
"failed": 1,
|
|
|
|
|
"batch_ids": [batch_id],
|
|
|
|
|
},
|
2026-06-27 17:13:18 +08:00
|
|
|
summary,
|
|
|
|
|
)
|
|
|
|
|
self.assertEqual(
|
|
|
|
|
{"done": 3, "total": 3, "applied": 1, "skipped": 1, "failed": 1},
|
|
|
|
|
progress[-1],
|
|
|
|
|
)
|
|
|
|
|
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))
|
|
|
|
|
|
|
|
|
|
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, \
|
|
|
|
|
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()
|
|
|
|
|
apply_task.assert_not_called()
|
|
|
|
|
unchanged = db.list_tasks(batch_id=batch_id, path=cfg["db_path"])[0]
|
|
|
|
|
self.assertEqual("generated", unchanged.stage)
|
|
|
|
|
self.assertEqual("success", unchanged.status)
|
|
|
|
|
self.assertIsNone(unchanged.last_error)
|
|
|
|
|
|
|
|
|
|
self.assert_removed(temp_dir)
|
|
|
|
|
|
|
|
|
|
def test_apply_tab_blocked_preflight_guides_to_accounts_tab(self):
|
|
|
|
|
with self.make_temp_dir() as temp_dir:
|
|
|
|
|
opened = []
|
|
|
|
|
statuses = []
|
|
|
|
|
tab = ApplyTab(
|
|
|
|
|
config=self.make_config(temp_dir),
|
|
|
|
|
status_callback=statuses.append,
|
|
|
|
|
open_accounts_callback=lambda: opened.append(True),
|
|
|
|
|
)
|
|
|
|
|
self.addCleanup(tab.close)
|
|
|
|
|
payload = {
|
|
|
|
|
"blocked": True,
|
|
|
|
|
"not_running": [
|
|
|
|
|
{
|
|
|
|
|
"account_name": "主店",
|
|
|
|
|
"alias": "alias-a",
|
|
|
|
|
"reason": "CDP 端口未响应",
|
|
|
|
|
}
|
|
|
|
|
],
|
|
|
|
|
"logged_out": [],
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
with mock.patch("app.gui.QMessageBox.warning") as warning:
|
|
|
|
|
tab._on_apply_finished(payload)
|
|
|
|
|
|
|
|
|
|
message = warning.call_args[0][2]
|
|
|
|
|
self.assertIn("Chrome 未启动", message)
|
|
|
|
|
self.assertIn("④ 账号管理", message)
|
|
|
|
|
self.assertEqual([True], opened)
|
|
|
|
|
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(4, tab.model.rowCount())
|
|
|
|
|
self.assertEqual("主店", tab.model.index(0, 0).data())
|
|
|
|
|
self.assertEqual("51100639510", tab.model.index(0, 1).data())
|
|
|
|
|
self.assertEqual("旧标题A", tab.model.index(0, 2).data())
|
|
|
|
|
self.assertEqual("", tab.model.index(0, 3).data())
|
|
|
|
|
self.assertEqual("待生成", tab.model.index(0, 4).data())
|
|
|
|
|
self.assertEqual("已生成", tab.model.index(2, 4).data())
|
|
|
|
|
|
|
|
|
|
tab.status_filter.setCurrentIndex(tab.status_filter.findData("to_generate"))
|
|
|
|
|
self.assertEqual(2, tab.model.rowCount())
|
|
|
|
|
self.assertTrue(all(tab.model.index(row, 4).data() == "待生成" for row in range(2)))
|
|
|
|
|
self.assertEqual("任务 2/4 条", tab.summary_label.text())
|
|
|
|
|
|
|
|
|
|
tab.shop_filter.setCurrentIndex(tab.shop_filter.findData("alias-b"))
|
|
|
|
|
self.assertEqual(1, tab.model.rowCount())
|
|
|
|
|
self.assertEqual("副店", tab.model.index(0, 0).data())
|
|
|
|
|
|
|
|
|
|
tab.status_filter.setCurrentIndex(tab.status_filter.findData("all"))
|
|
|
|
|
tab.batch_filter.setCurrentIndex(tab.batch_filter.findData(batch_b))
|
|
|
|
|
self.assertEqual(1, tab.model.rowCount())
|
|
|
|
|
self.assertEqual("失败", tab.model.index(0, 4).data())
|
|
|
|
|
self.assertEqual(
|
|
|
|
|
"生成失败",
|
|
|
|
|
tab.model.data(tab.model.index(0, 0), gui.Qt.ToolTipRole),
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
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())
|
|
|
|
|
self.assertEqual("未知", tab.table.item(0, 4).text())
|
|
|
|
|
visible_values = [
|
|
|
|
|
tab.table.item(0, column).text()
|
|
|
|
|
for column in range(tab.table.columnCount())
|
|
|
|
|
if tab.table.item(0, column) is not None
|
|
|
|
|
]
|
|
|
|
|
self.assertNotIn("secret", visible_values)
|
|
|
|
|
|
|
|
|
|
self.assert_removed(temp_dir)
|
|
|
|
|
|
|
|
|
|
def test_account_dialog_masks_password_field(self):
|
|
|
|
|
with self.make_temp_dir() as temp_dir:
|
|
|
|
|
cfg = self.make_config(temp_dir)
|
|
|
|
|
account = accounts.create_account(
|
|
|
|
|
"主店",
|
|
|
|
|
"alias",
|
|
|
|
|
debug_port=9222,
|
|
|
|
|
password="secret",
|
|
|
|
|
config=cfg,
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
dialog = AccountDialog(account=account, config=cfg)
|
|
|
|
|
self.addCleanup(dialog.close)
|
|
|
|
|
|
|
|
|
|
self.assertEqual(QLineEdit.Password, dialog.password_edit.echoMode())
|
|
|
|
|
self.assertEqual("secret", dialog.password_edit.text())
|
|
|
|
|
self.assertIn(account.slug, dialog.user_data_dir_edit.text())
|
|
|
|
|
|
|
|
|
|
self.assert_removed(temp_dir)
|
|
|
|
|
|
|
|
|
|
def test_accounts_tab_updates_login_status_cell(self):
|
|
|
|
|
with self.make_temp_dir() as temp_dir:
|
|
|
|
|
cfg = self.make_config(temp_dir)
|
|
|
|
|
accounts.create_account("主店", "alias", debug_port=9222, config=cfg)
|
|
|
|
|
tab = AccountsTab(config=cfg)
|
|
|
|
|
self.addCleanup(tab.close)
|
|
|
|
|
|
|
|
|
|
tab._on_login_check_finished(
|
|
|
|
|
{"alias": "alias", "status": {"logged_in": True, "reason": None}}
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
self.assertEqual("已登录", tab.table.item(0, 4).text())
|
|
|
|
|
|
|
|
|
|
self.assert_removed(temp_dir)
|
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())
|
|
|
|
|
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-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())
|
|
|
|
|
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)
|
|
|
|
|
|
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())
|
|
|
|
|
self.assertIn("有效1/无效1", tab.summary_label.text())
|
|
|
|
|
self.assertIn("匹配1", tab.summary_label.text())
|
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"])
|
|
|
|
|
|
|
|
|
|
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):
|
|
|
|
|
self.assertEqual(logged.alias, account.alias)
|
|
|
|
|
self.assertEqual("51100639510", task["item_id"])
|
|
|
|
|
self.assertTrue(task["old_cover_path"].endswith(
|
|
|
|
|
os.path.join(logged.slug, "51100639510_old.jpg")
|
|
|
|
|
))
|
|
|
|
|
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.assertEqual({"ok": True, "total": 3, "done": 3, "collected": 1, "skipped": 2, "failed": 0}, summary)
|
|
|
|
|
collect.assert_called_once()
|
|
|
|
|
updated = db.list_tasks(batch_id=batch_id, path=cfg["db_path"])
|
|
|
|
|
by_alias = {task.alias: task for task in updated}
|
|
|
|
|
self.assertEqual("collected", by_alias["alias-a"].stage)
|
|
|
|
|
self.assertEqual("success", by_alias["alias-a"].status)
|
|
|
|
|
self.assertEqual("旧标题", by_alias["alias-a"].old_title)
|
|
|
|
|
self.assertTrue(by_alias["alias-a"].old_cover_path.endswith("51100639510_old.jpg"))
|
|
|
|
|
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)
|
|
|
|
|
|
|
|
|
|
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()
|
2026-06-27 15:28:33 +08:00
|
|
|
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)
|
|
|
|
|
self.assertIn("④ 账号管理", message)
|
|
|
|
|
self.assertEqual([True], opened)
|
|
|
|
|
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
|
|
|
|
|
|
|
|
if __name__ == "__main__":
|
|
|
|
|
unittest.main()
|