7518 lines
337 KiB
Python
7518 lines
337 KiB
Python
import json
|
||
import unittest
|
||
import os
|
||
import sys
|
||
import threading
|
||
from types import SimpleNamespace
|
||
from unittest import mock
|
||
|
||
os.environ.setdefault("QT_QPA_PLATFORM", "offscreen")
|
||
sys.path.insert(0, os.path.dirname(__file__))
|
||
|
||
from _helpers import TempDirMixin
|
||
|
||
from app import gui
|
||
from app import accounts, ai, appconfig, db, image_paths, image_studio, prompts, update_check
|
||
|
||
if gui.QT_IMPORT_ERROR is not None:
|
||
raise unittest.SkipTest("PySide6 未安装")
|
||
|
||
from PySide6.QtCore import QItemSelectionModel, QModelIndex, QRect
|
||
from PySide6.QtGui import QImage, QKeyEvent, QTextCursor
|
||
from PySide6.QtWidgets import QApplication, QComboBox, QLineEdit, QPlainTextEdit, QProgressBar, QTableView
|
||
|
||
from app.gui import (
|
||
AccountDialog,
|
||
AccountLoginCheckWorker,
|
||
AccountsTab,
|
||
AIModelTestWorker,
|
||
CMHubSettingsWorker,
|
||
ApplyTab,
|
||
ApplyWorker,
|
||
CollectWorker,
|
||
CollectTab,
|
||
GenerateWorker,
|
||
GenerateTab,
|
||
ImageStudioTab,
|
||
MainWindow,
|
||
SettingsTab,
|
||
TAB_STYLE,
|
||
TAB_TITLES,
|
||
WriteBackWorker,
|
||
)
|
||
from app.gui import file_manager
|
||
import app.gui.workers as gui_workers
|
||
from app.gui.tabs.generate import CoverGalleryDialog, OriginalImageDialog
|
||
from app.gui.main_window import _fit_and_center_window
|
||
|
||
|
||
class DummySignal:
|
||
def __init__(self):
|
||
self.callbacks = []
|
||
|
||
def connect(self, callback):
|
||
self.callbacks.append(callback)
|
||
|
||
def emit(self, *args):
|
||
for callback in list(self.callbacks):
|
||
callback(*args)
|
||
|
||
|
||
class FakeThread:
|
||
def __init__(self):
|
||
self.finished = DummySignal()
|
||
self.started = False
|
||
|
||
def start(self):
|
||
self.started = True
|
||
|
||
|
||
class FakeGenerateWorker:
|
||
instances = []
|
||
|
||
def __init__(self, tasks, prompt_values, db_path=None, config=None, diagnostic_log_dir=None):
|
||
self.tasks = list(tasks)
|
||
self.prompt_values = dict(prompt_values or {})
|
||
self.db_path = db_path
|
||
self.config = config
|
||
self.diagnostic_log_dir = diagnostic_log_dir
|
||
self.progress = DummySignal()
|
||
self.row_updated = DummySignal()
|
||
self.log = DummySignal()
|
||
self.failed = DummySignal()
|
||
self.finished = DummySignal()
|
||
self.cancelled = DummySignal()
|
||
FakeGenerateWorker.instances.append(self)
|
||
|
||
|
||
class GuiTests(TempDirMixin, unittest.TestCase):
|
||
@classmethod
|
||
def setUpClass(cls):
|
||
cls.app = QApplication.instance() or QApplication([])
|
||
|
||
def tearDown(self):
|
||
for widget in QApplication.topLevelWidgets():
|
||
widget.close()
|
||
widget.deleteLater()
|
||
self.app.processEvents()
|
||
|
||
def make_config(self, temp_dir):
|
||
return {
|
||
"chrome_path": "chrome.exe",
|
||
"user_data_root": os.path.join(temp_dir, "chrome_user_data_dir"),
|
||
"image_dir": os.path.join(temp_dir, "images"),
|
||
"db_path": os.path.join(temp_dir, "cmshopee.db"),
|
||
"debug_port_range": [9222, 9260],
|
||
"ai_models_path": os.path.join(temp_dir, "ai_models.json"),
|
||
"config_path": os.path.join(temp_dir, "config.json"),
|
||
}
|
||
|
||
def allow_shopee_update(
|
||
self,
|
||
cfg,
|
||
item_id="51100639510",
|
||
allow_cover=True,
|
||
max_items=1,
|
||
close_success_tab=False,
|
||
max_parallel_accounts=1,
|
||
):
|
||
cfg["shopee_update"] = {
|
||
"test_item_id": item_id,
|
||
"update_mode": "title_cover" if allow_cover else "title",
|
||
"max_items_per_run": max_items,
|
||
"dry_run": False,
|
||
"max_parallel_accounts": max_parallel_accounts,
|
||
}
|
||
return cfg
|
||
|
||
def write_test_image(self, path, width=32, height=32):
|
||
os.makedirs(os.path.dirname(path), exist_ok=True)
|
||
image = QImage(width, height, QImage.Format_RGB32)
|
||
image.fill(0xFF336699)
|
||
self.assertTrue(image.save(path))
|
||
return path
|
||
|
||
def _cover_gallery_task(self, temp_dir, generate_cover=True):
|
||
cfg = self.make_config(temp_dir)
|
||
cfg.setdefault("ai", {})["generate_cover"] = bool(generate_cover)
|
||
account = accounts.create_account("主店", "alias-a", debug_port=9222, config=cfg)
|
||
db.init_db(cfg["db_path"])
|
||
batch_id = db.create_batch(["input.xlsx"], path=cfg["db_path"])
|
||
db.insert_tasks(
|
||
batch_id,
|
||
[
|
||
{
|
||
"source_file_abs": os.path.join(temp_dir, "input.xlsx"),
|
||
"source_sheet": "商品",
|
||
"source_row": 2,
|
||
"account_name": "Excel主店",
|
||
"alias": "alias-a",
|
||
"item_id": "51100639510",
|
||
}
|
||
],
|
||
path=cfg["db_path"],
|
||
)
|
||
task = db.list_tasks(batch_id=batch_id, path=cfg["db_path"])[0]
|
||
old_cover = self.write_test_image(os.path.join(temp_dir, "old.jpg"))
|
||
canonical = image_paths.task_image_path(cfg["image_dir"], task, account, "new")
|
||
self.write_test_image(canonical)
|
||
db.set_collected(task.id, "旧标题", old_cover, path=cfg["db_path"])
|
||
db.set_generated(task.id, "新标题", canonical, path=cfg["db_path"])
|
||
return cfg, account, db.get_task(task.id, path=cfg["db_path"]), canonical
|
||
|
||
def _cover_gallery_task_set(self, temp_dir, count=3):
|
||
cfg = self.make_config(temp_dir)
|
||
cfg.setdefault("ai", {})["generate_cover"] = True
|
||
account = accounts.create_account("主店", "alias-a", debug_port=9222, config=cfg)
|
||
db.init_db(cfg["db_path"])
|
||
batch_id = db.create_batch(["input.xlsx"], path=cfg["db_path"])
|
||
rows = []
|
||
for index in range(count):
|
||
rows.append(
|
||
{
|
||
"source_file_abs": os.path.join(temp_dir, "input.xlsx"),
|
||
"source_sheet": "商品",
|
||
"source_row": index + 2,
|
||
"account_name": "Excel主店",
|
||
"alias": "alias-a",
|
||
"item_id": f"5110063951{index}",
|
||
}
|
||
)
|
||
db.insert_tasks(batch_id, rows, path=cfg["db_path"])
|
||
tasks = db.list_tasks(batch_id=batch_id, path=cfg["db_path"])
|
||
canonicals = []
|
||
archives = []
|
||
for index, task in enumerate(tasks):
|
||
old_cover = self.write_test_image(os.path.join(temp_dir, f"old-{index}.jpg"))
|
||
canonical = image_paths.task_image_path(cfg["image_dir"], task, account, "new")
|
||
archive = os.path.splitext(canonical)[0] + "_20260709101000.jpg"
|
||
self.write_test_image(canonical)
|
||
self.write_test_image(archive)
|
||
db.set_collected(task.id, f"旧标题{index}", old_cover, path=cfg["db_path"])
|
||
db.set_generated(task.id, f"新标题{index}", canonical, path=cfg["db_path"])
|
||
canonicals.append(canonical)
|
||
archives.append(archive)
|
||
return cfg, account, db.list_tasks(batch_id=batch_id, path=cfg["db_path"]), canonicals, archives
|
||
|
||
def assert_foreground(self, model, row, column, color):
|
||
value = model.data(model.index(row, column), gui.Qt.ForegroundRole)
|
||
self.assertIsNotNone(value)
|
||
self.assertEqual(color, value.name())
|
||
|
||
def set_batch_created_at(self, cfg, batch_id, created_at):
|
||
conn = db.connect(cfg["db_path"])
|
||
try:
|
||
with conn:
|
||
conn.execute(
|
||
"UPDATE batches SET created_at = ?, updated_at = ? WHERE id = ?",
|
||
(created_at, created_at, batch_id),
|
||
)
|
||
finally:
|
||
conn.close()
|
||
|
||
def make_fake_message_box(self, selected_label):
|
||
boxes = []
|
||
|
||
class FakeMessageBox:
|
||
AcceptRole = object()
|
||
DestructiveRole = object()
|
||
RejectRole = object()
|
||
|
||
def __init__(self, parent=None):
|
||
self.parent = parent
|
||
self.title = ""
|
||
self.text = ""
|
||
self.buttons = {}
|
||
self.default_button = None
|
||
boxes.append(self)
|
||
|
||
def setWindowTitle(self, title):
|
||
self.title = title
|
||
|
||
def setText(self, text):
|
||
self.text = text
|
||
|
||
def addButton(self, label, role):
|
||
button = object()
|
||
self.buttons[label] = button
|
||
return button
|
||
|
||
def setDefaultButton(self, button):
|
||
self.default_button = button
|
||
|
||
def exec(self):
|
||
return 0
|
||
|
||
def clickedButton(self):
|
||
return self.buttons[selected_label]
|
||
|
||
@staticmethod
|
||
def warning(parent, title, text):
|
||
box = FakeMessageBox(parent)
|
||
box.setWindowTitle(title)
|
||
box.setText(text)
|
||
return 0
|
||
|
||
return FakeMessageBox, boxes
|
||
|
||
def make_sequence_message_box(self, selected_labels):
|
||
boxes = []
|
||
labels = list(selected_labels)
|
||
|
||
class FakeMessageBox:
|
||
AcceptRole = object()
|
||
DestructiveRole = object()
|
||
RejectRole = object()
|
||
|
||
def __init__(self, parent=None):
|
||
self.parent = parent
|
||
self.title = ""
|
||
self.text = ""
|
||
self.buttons = {}
|
||
self.default_button = None
|
||
self.selected_label = labels.pop(0)
|
||
boxes.append(self)
|
||
|
||
def setWindowTitle(self, title):
|
||
self.title = title
|
||
|
||
def setText(self, text):
|
||
self.text = text
|
||
|
||
def addButton(self, label, role):
|
||
button = object()
|
||
self.buttons[label] = button
|
||
return button
|
||
|
||
def setDefaultButton(self, button):
|
||
self.default_button = button
|
||
|
||
def exec(self):
|
||
return 0
|
||
|
||
def clickedButton(self):
|
||
return self.buttons[self.selected_label]
|
||
|
||
@staticmethod
|
||
def warning(parent, title, text):
|
||
box = FakeMessageBox(parent)
|
||
box.setWindowTitle(title)
|
||
box.setText(text)
|
||
return 0
|
||
|
||
return FakeMessageBox, boxes
|
||
|
||
def test_collect_table_shows_product_unavailable_only_for_explicit_error(self):
|
||
account = SimpleNamespace(alias="papa", account_name="papa 店铺")
|
||
invalid_task = SimpleNamespace(
|
||
alias="papa",
|
||
account_name="papa 店铺",
|
||
item_id="25120403046",
|
||
stage="imported",
|
||
status="failed",
|
||
last_error="商品失效:please input correct product id",
|
||
)
|
||
generic_task = SimpleNamespace(
|
||
alias="papa",
|
||
account_name="papa 店铺",
|
||
item_id="26887160467",
|
||
stage="imported",
|
||
status="failed",
|
||
last_error="等待蝦皮商品编辑器就绪超时",
|
||
)
|
||
model = gui.TaskTableModel()
|
||
model.set_tasks([invalid_task, generic_task], [account])
|
||
|
||
self.assertEqual("商品失效", model.data(model.index(0, 3), gui.Qt.DisplayRole))
|
||
self.assertEqual("失败", model.data(model.index(1, 3), gui.Qt.DisplayRole))
|
||
self.assertIn(
|
||
"please input correct product id",
|
||
model.data(model.index(0, 3), gui.Qt.ToolTipRole),
|
||
)
|
||
|
||
def test_workflow_failed_tooltips_show_failed_step_and_reason(self):
|
||
account = SimpleNamespace(alias="papa", account_name="papa 店铺")
|
||
collect_task = SimpleNamespace(
|
||
alias="papa",
|
||
account_name="papa 店铺",
|
||
item_id="25120403046",
|
||
stage="imported",
|
||
status="failed",
|
||
last_error="读封面失败:图片超过大小上限",
|
||
)
|
||
generate_task = SimpleNamespace(
|
||
alias="papa",
|
||
account_name="papa 店铺",
|
||
item_id="26887160467",
|
||
old_title="旧标题",
|
||
new_title="",
|
||
stage="collected",
|
||
status="failed",
|
||
last_error="请求生成标题失败:cmhub 上游生成失败",
|
||
collect_attempts=1,
|
||
generate_attempts=1,
|
||
apply_attempts=0,
|
||
committed=0,
|
||
)
|
||
apply_task = SimpleNamespace(
|
||
alias="papa",
|
||
account_name="papa 店铺",
|
||
item_id="28431952912",
|
||
new_title="新标题",
|
||
new_cover_path="new.jpg",
|
||
stage="generated",
|
||
status="failed",
|
||
last_error="点击更新失败:按钮禁用",
|
||
)
|
||
|
||
collect_model = gui.TaskTableModel()
|
||
collect_model.set_tasks([collect_task], [account])
|
||
self.assertEqual(
|
||
"读封面失败:图片超过大小上限",
|
||
collect_model.data(collect_model.index(0, 3), gui.Qt.ToolTipRole),
|
||
)
|
||
|
||
generate_model = gui.GenerateTaskTableModel()
|
||
generate_model.set_tasks([generate_task], [account])
|
||
self.assertEqual(
|
||
"请求生成标题失败:cmhub 上游生成失败",
|
||
generate_model.data(generate_model.index(0, 5), gui.Qt.ToolTipRole),
|
||
)
|
||
|
||
apply_model = gui.ApplyTaskTableModel()
|
||
apply_model.set_tasks([apply_task], [account])
|
||
self.assertEqual(
|
||
"点击更新失败:按钮禁用",
|
||
apply_model.data(apply_model.index(0, 5), gui.Qt.ToolTipRole),
|
||
)
|
||
|
||
def test_generate_table_marks_cover_reset_history_on_item_id_column(self):
|
||
account = SimpleNamespace(alias="papa", account_name="papa 店铺")
|
||
reset_task = SimpleNamespace(
|
||
alias="papa",
|
||
account_name="papa 店铺",
|
||
item_id="26887160467",
|
||
old_title="旧标题",
|
||
new_title="新标题",
|
||
new_cover_path="new.jpg",
|
||
stage="generated",
|
||
status="success",
|
||
last_error="",
|
||
collect_attempts=1,
|
||
generate_attempts=1,
|
||
apply_attempts=0,
|
||
committed=0,
|
||
cover_reset_count=2,
|
||
cover_reset_at="2026-07-11T10:00:00",
|
||
)
|
||
normal_task = SimpleNamespace(
|
||
alias="papa",
|
||
account_name="papa 店铺",
|
||
item_id="28431952912",
|
||
old_title="旧标题",
|
||
new_title="新标题",
|
||
new_cover_path="new.jpg",
|
||
stage="generated",
|
||
status="success",
|
||
last_error="",
|
||
collect_attempts=1,
|
||
generate_attempts=1,
|
||
apply_attempts=0,
|
||
committed=0,
|
||
cover_reset_count=0,
|
||
cover_reset_at=None,
|
||
)
|
||
model = gui.GenerateTaskTableModel()
|
||
model.set_tasks([reset_task, normal_task], [account])
|
||
|
||
self.assert_foreground(model, 0, 1, gui.COLOR_WARNING)
|
||
self.assertIsNone(model.data(model.index(1, 1), gui.Qt.ForegroundRole))
|
||
self.assertIn(
|
||
"该商品封面已重置 2 次",
|
||
model.data(model.index(0, 1), gui.Qt.ToolTipRole),
|
||
)
|
||
self.assertIn(
|
||
"双击可查看封面画廊",
|
||
model.data(model.index(0, 1), gui.Qt.ToolTipRole),
|
||
)
|
||
self.assert_foreground(model, 0, 4, gui.COLOR_SUCCESS)
|
||
self.assert_foreground(model, 0, 5, gui.COLOR_SUCCESS)
|
||
|
||
def test_main_window_has_workflow_tabs_in_order(self):
|
||
with self.make_temp_dir() as temp_dir:
|
||
window = MainWindow(config=self.make_config(temp_dir))
|
||
self.addCleanup(window.close)
|
||
|
||
self.assertEqual(gui.display_name(), window.windowTitle())
|
||
self.assertEqual(6, window.tabs.count())
|
||
self.assertEqual(
|
||
TAB_TITLES,
|
||
[window.tabs.tabText(index) for index in range(window.tabs.count())],
|
||
)
|
||
self.assertEqual("就绪", window.statusBar().currentMessage())
|
||
self.assertEqual(gui.BUTTON_BASE_STYLE, window.styleSheet())
|
||
self.assertEqual(TAB_STYLE, window.tabs.styleSheet())
|
||
self.assertFalse(window.tabs.tabIcon(TAB_TITLES.index("③ 更新蝦皮")).isNull())
|
||
self.assertIn("QPushButton", window.styleSheet())
|
||
self.assertIn("border-radius: 4px", window.styleSheet())
|
||
self.assertIn("min-width: 128px", window.tabs.styleSheet())
|
||
self.assertIn("padding: 8px 18px", window.tabs.styleSheet())
|
||
self.assertIn("margin-right: 8px", window.tabs.styleSheet())
|
||
self.assertIsInstance(window.tabs.widget(0), CollectTab)
|
||
self.assertIsInstance(window.tabs.widget(1), GenerateTab)
|
||
self.assertIsInstance(window.tabs.widget(2), ApplyTab)
|
||
self.assertIsInstance(window.tabs.widget(4), SettingsTab)
|
||
self.assertIsInstance(window.tabs.widget(5), ImageStudioTab)
|
||
self.assertEqual(
|
||
"回写旧数据到 Excel",
|
||
window.tabs.widget(0).write_back_button.text(),
|
||
)
|
||
self.assertIn(gui.COLOR_MUTED, window.statusBar().styleSheet())
|
||
|
||
self.assert_removed(temp_dir)
|
||
|
||
def test_main_window_status_bar_uses_semantic_colors_and_resets(self):
|
||
with self.make_temp_dir() as temp_dir:
|
||
window = MainWindow(config=self.make_config(temp_dir))
|
||
self.addCleanup(window.close)
|
||
|
||
window.show_status("更新失败", level="danger")
|
||
self.assertEqual("更新失败", window.statusBar().currentMessage())
|
||
self.assertIn(gui.COLOR_DANGER, window.statusBar().styleSheet())
|
||
|
||
window.show_status("设置已保存", level="success")
|
||
self.assertIn(gui.COLOR_SUCCESS, window.statusBar().styleSheet())
|
||
|
||
window.show_status("就绪", level="muted")
|
||
self.assertEqual("就绪", window.statusBar().currentMessage())
|
||
self.assertIn(gui.COLOR_MUTED, window.statusBar().styleSheet())
|
||
self.assertNotIn(gui.COLOR_DANGER, window.statusBar().styleSheet())
|
||
|
||
self.assert_removed(temp_dir)
|
||
|
||
def test_image_studio_tab_builds_project_pool_and_template_controls(self):
|
||
with self.make_temp_dir() as temp_dir:
|
||
cfg = self.make_config(temp_dir)
|
||
prompts_dir = os.path.join(temp_dir, "prompts", "image_studio")
|
||
accounts.create_account("主店", "alias-a", debug_port=9222, config=cfg)
|
||
prompts.save_image_studio_template("工场模板", "完整提示词", prompts_dir)
|
||
|
||
tab = ImageStudioTab(
|
||
config=cfg,
|
||
db_path=cfg["db_path"],
|
||
prompts_dir=prompts_dir,
|
||
)
|
||
self.addCleanup(tab.close)
|
||
|
||
self.assertEqual("imageStudioTab", tab.objectName())
|
||
self.assertEqual("⑥ AI工场", TAB_TITLES[5])
|
||
self.assertGreaterEqual(tab.account_combo.count(), 1)
|
||
self.assertEqual("alias-a", tab.account_combo.itemData(0))
|
||
self.assertNotIn(
|
||
"导入本地图片",
|
||
" ".join(button.text() for button in tab.findChildren(gui.QPushButton)),
|
||
)
|
||
self.assertEqual("导出终选", tab.export_button.text())
|
||
self.assertIn("可部分导出", tab.export_hint_label.text())
|
||
self.assertEqual("完整提示词", prompts.load_image_studio_template("工场模板", prompts_dir))
|
||
template_index = tab.template_combo.findData("工场模板")
|
||
self.assertGreaterEqual(template_index, 0)
|
||
tab.template_combo.setCurrentIndex(template_index)
|
||
tab.load_selected_template()
|
||
self.assertEqual("完整提示词", tab.prompt_edit.toPlainText())
|
||
|
||
tab.item_id_edit.setText("51100639510")
|
||
tab.open_project()
|
||
self.assertIsNotNone(tab.current_project)
|
||
self.assertEqual("alias-a", tab.current_project.account_alias)
|
||
self.assertEqual("51100639510", tab.current_project.item_id)
|
||
self.assertEqual(1, tab.project_table.rowCount())
|
||
|
||
original = image_studio.sync_original_asset_urls(
|
||
tab.current_project.id,
|
||
[{"index": 1, "src": "https://susercontent.com/main-1.jpg"}],
|
||
path=cfg["db_path"],
|
||
)[0]
|
||
tab.refresh_project_assets()
|
||
|
||
self.assertEqual(1, tab.original_table.rowCount())
|
||
self.assertEqual("1", tab.original_table.item(0, 0).text())
|
||
self.assertEqual("远程待下载", tab.original_table.item(0, 1).text())
|
||
self.assertEqual(1, tab.pool_table.rowCount())
|
||
self.assertEqual("原图", tab.pool_table.item(0, 0).text())
|
||
self.assertEqual("远程待下载", tab.pool_table.item(0, 2).text())
|
||
self.assertEqual(original.id, tab.pool_table.item(0, 0).data(gui.Qt.UserRole)["asset_id"])
|
||
|
||
self.assert_removed(temp_dir)
|
||
|
||
def test_image_studio_final_selection_order_and_guards(self):
|
||
with self.make_temp_dir() as temp_dir:
|
||
cfg = self.make_config(temp_dir)
|
||
db.init_db(cfg["db_path"])
|
||
project = image_studio.create_or_get_project(
|
||
account_alias="alias-a",
|
||
account_slug="alias_a",
|
||
item_id="51100639510",
|
||
path=cfg["db_path"],
|
||
)
|
||
first_path = self.write_test_image(os.path.join(temp_dir, "first.jpg"))
|
||
second_path = self.write_test_image(os.path.join(temp_dir, "second.jpg"))
|
||
first = image_studio.add_asset(
|
||
project.id,
|
||
"generated_main",
|
||
local_path=first_path,
|
||
aspect_ratio="1:1",
|
||
path=cfg["db_path"],
|
||
)
|
||
second = image_studio.add_asset(
|
||
project.id,
|
||
"generated_main",
|
||
local_path=second_path,
|
||
aspect_ratio="3:4",
|
||
path=cfg["db_path"],
|
||
)
|
||
tab = ImageStudioTab(config=cfg, db_path=cfg["db_path"])
|
||
self.addCleanup(tab.close)
|
||
messages = []
|
||
tab._message = lambda title, text: messages.append((title, text))
|
||
tab._select_project(project.id)
|
||
|
||
self.assertTrue(tab.add_asset_to_selection("main", first.id))
|
||
self.assertTrue(tab.add_asset_to_selection("main", second.id, insert_index=0))
|
||
main = image_studio.list_selections(project.id, "main", path=cfg["db_path"])
|
||
self.assertEqual([second.id, first.id], [selection.asset_id for selection in main])
|
||
self.assertEqual([second.id, first.id], [
|
||
tab.main_selection_list.item(row).data(gui.Qt.UserRole)
|
||
for row in range(tab.main_selection_list.count())
|
||
])
|
||
self.assertEqual("#fff8c5", tab.main_selection_list.item(0).background().color().name())
|
||
|
||
self.assertFalse(tab.add_asset_to_selection("main", first.id))
|
||
self.assertIn("不能重复加入", messages[-1][0])
|
||
self.assertTrue(tab.add_asset_to_selection("detail", first.id))
|
||
detail = image_studio.list_selections(project.id, "detail", path=cfg["db_path"])
|
||
self.assertEqual([first.id], [selection.asset_id for selection in detail])
|
||
|
||
self.assertTrue(tab.move_selection_asset("main", 1, 0))
|
||
main = image_studio.list_selections(project.id, "main", path=cfg["db_path"])
|
||
self.assertEqual([first.id, second.id], [selection.asset_id for selection in main])
|
||
|
||
self.assertTrue(tab.remove_asset_from_selection("main", first.id))
|
||
main = image_studio.list_selections(project.id, "main", path=cfg["db_path"])
|
||
self.assertEqual([second.id], [selection.asset_id for selection in main])
|
||
|
||
self.assert_removed(temp_dir)
|
||
|
||
def test_image_studio_tab_shows_resume_and_job_billing_status(self):
|
||
with self.make_temp_dir() as temp_dir:
|
||
cfg = self.make_config(temp_dir)
|
||
db.init_db(cfg["db_path"])
|
||
project = image_studio.create_or_get_project(
|
||
account_alias="alias-a",
|
||
account_slug="alias_a",
|
||
item_id="51100639510",
|
||
path=cfg["db_path"],
|
||
)
|
||
source = image_studio.add_asset(
|
||
project.id,
|
||
"original",
|
||
local_path=self.write_test_image(os.path.join(temp_dir, "source.jpg")),
|
||
path=cfg["db_path"],
|
||
)
|
||
job = image_studio.create_job(
|
||
project.id,
|
||
source_asset_id=source.id,
|
||
path=cfg["db_path"],
|
||
)
|
||
image_studio.set_job_submitted(
|
||
job.id,
|
||
"cmhub-task-1",
|
||
call_id="call-1",
|
||
points_cost=2,
|
||
points_balance=88,
|
||
path=cfg["db_path"],
|
||
)
|
||
tab = ImageStudioTab(config=cfg, db_path=cfg["db_path"])
|
||
self.addCleanup(tab.close)
|
||
tab._select_project(project.id)
|
||
|
||
self.assertEqual("继续查询任务", tab.resume_button.text())
|
||
statuses = [
|
||
tab.pool_table.item(row, 2).text()
|
||
for row in range(tab.pool_table.rowCount())
|
||
if tab.pool_table.item(row, 0).text() == "任务"
|
||
]
|
||
self.assertEqual(1, len(statuses))
|
||
self.assertIn("已提交", statuses[0])
|
||
self.assertIn("扣点2", statuses[0])
|
||
self.assertIn("余额88", statuses[0])
|
||
self.assertIn("call_id=call-1", statuses[0])
|
||
|
||
self.assert_removed(temp_dir)
|
||
|
||
def test_image_studio_generation_log_uses_cmhub_tier_summary(self):
|
||
with self.make_temp_dir() as temp_dir:
|
||
cfg = self.make_config(temp_dir)
|
||
cfg["ai"] = appconfig.ai_config(cfg)
|
||
cfg["ai"]["backend"] = "cmhub"
|
||
cfg["ai"]["cmhub"]["image_alias"] = "image-hd"
|
||
db.init_db(cfg["db_path"])
|
||
project = image_studio.create_or_get_project(
|
||
account_alias="alias-a",
|
||
account_slug="alias_a",
|
||
item_id="51100639510",
|
||
path=cfg["db_path"],
|
||
)
|
||
source = image_studio.add_asset(
|
||
project.id,
|
||
"original",
|
||
local_path=self.write_test_image(os.path.join(temp_dir, "source.jpg")),
|
||
path=cfg["db_path"],
|
||
)
|
||
tab = ImageStudioTab(config=cfg, db_path=cfg["db_path"])
|
||
self.addCleanup(tab.close)
|
||
tab._select_project(project.id)
|
||
tab._select_source_asset(source)
|
||
tab.prompt_edit.setPlainText("生成商品主图")
|
||
|
||
class FakeStudioWorker:
|
||
def __init__(self):
|
||
self.progress = DummySignal()
|
||
self.log = DummySignal()
|
||
self.finished = DummySignal()
|
||
self.failed = DummySignal()
|
||
|
||
def cancel(self):
|
||
pass
|
||
|
||
fake_worker = FakeStudioWorker()
|
||
with mock.patch(
|
||
"app.gui.tabs.image_studio.ImageStudioGenerateJobsWorker",
|
||
return_value=fake_worker,
|
||
), mock.patch("app.gui.tabs.image_studio.run_worker", return_value=FakeThread()):
|
||
tab.start_generation()
|
||
|
||
log_text = tab.log_view.toPlainText()
|
||
self.assertIn("cmhub 托管高质量档", log_text)
|
||
self.assertIn("生图别名 image-hd", log_text)
|
||
tab._on_generate_progress({"points_balance": 66, "points_cost": 2})
|
||
self.assertIn("高质量档", tab.billing_label.text())
|
||
self.assertIn("余额 66", tab.billing_label.text())
|
||
self.assertIn("本张扣点 2", tab.billing_label.text())
|
||
|
||
self.assert_removed(temp_dir)
|
||
|
||
def test_image_studio_event_log_hides_provider_urls(self):
|
||
message = gui_workers._format_image_studio_event(
|
||
{
|
||
"step": "cover_poll",
|
||
"result": "failed",
|
||
"detail": "GET https://cmhub.example.com/api/v1/generate/image/tasks/t1 failed /generated/images/a.png",
|
||
}
|
||
)
|
||
|
||
self.assertIn("请求 cmhub", message)
|
||
self.assertIn("[接口路径已隐藏]", message)
|
||
self.assertNotIn("https://", message)
|
||
self.assertNotIn("/api/v1", message)
|
||
self.assertNotIn("/generated/images", message)
|
||
|
||
def test_startup_update_gate_forced_blocks_and_opens_download(self):
|
||
boxes = []
|
||
|
||
class FakeButton:
|
||
def __init__(self, label):
|
||
self.label = label
|
||
self.enabled = True
|
||
|
||
def setEnabled(self, enabled):
|
||
self.enabled = enabled
|
||
|
||
class FakeMessageBox:
|
||
Warning = object()
|
||
AcceptRole = object()
|
||
RejectRole = object()
|
||
|
||
def __init__(self, parent=None):
|
||
self.parent = parent
|
||
self.icon = None
|
||
self.title = ""
|
||
self.text = ""
|
||
self.informative_text = ""
|
||
self.buttons = {}
|
||
self.default_button = None
|
||
boxes.append(self)
|
||
|
||
def setIcon(self, icon):
|
||
self.icon = icon
|
||
|
||
def setWindowTitle(self, title):
|
||
self.title = title
|
||
|
||
def setText(self, text):
|
||
self.text = text
|
||
|
||
def setInformativeText(self, text):
|
||
self.informative_text = text
|
||
|
||
def addButton(self, label, role):
|
||
button = FakeButton(label)
|
||
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["下载新版"]
|
||
|
||
result = update_check.UpdateCheckResult(
|
||
current_version="1.0.0",
|
||
checked=True,
|
||
forced=True,
|
||
latest_version="1.2.0",
|
||
min_supported_version="1.1.0",
|
||
download_url="https://example.test/cmshopee.zip",
|
||
message="必须升级",
|
||
)
|
||
opened = []
|
||
|
||
with mock.patch("app.gui.QMessageBox", FakeMessageBox):
|
||
allowed = gui._run_startup_update_gate(
|
||
checker=lambda: result,
|
||
opener=opened.append,
|
||
)
|
||
|
||
self.assertFalse(allowed)
|
||
self.assertEqual(["https://example.test/cmshopee.zip"], opened)
|
||
self.assertEqual("必须升级", boxes[0].title)
|
||
self.assertIn("当前版本:1.0.0", boxes[0].informative_text)
|
||
self.assertIn("线上版本:1.2.0", boxes[0].informative_text)
|
||
self.assertIn("保留 data/ 目录", boxes[0].informative_text)
|
||
self.assertEqual(boxes[0].buttons["下载新版"], boxes[0].default_button)
|
||
|
||
def test_startup_update_gate_check_failure_allows_entry_and_logs(self):
|
||
result = update_check.UpdateCheckResult(
|
||
current_version="1.0.0",
|
||
checked=True,
|
||
forced=False,
|
||
error="启动版本检查失败,已允许继续使用:网络超时",
|
||
)
|
||
|
||
with mock.patch("app.gui.diagnostics.write_diagnostic_log") as write_log:
|
||
allowed = gui._run_startup_update_gate(checker=lambda: result)
|
||
|
||
self.assertTrue(allowed)
|
||
write_log.assert_called_once()
|
||
|
||
def test_status_callbacks_classify_success_warning_and_failure(self):
|
||
with self.make_temp_dir() as temp_dir:
|
||
statuses = []
|
||
|
||
def capture(message, level=None):
|
||
statuses.append((message, level))
|
||
|
||
cfg = self.make_config(temp_dir)
|
||
generate_tab = GenerateTab(
|
||
config=cfg,
|
||
status_callback=capture,
|
||
title_prompt_path=os.path.join(temp_dir, "title_prompt.txt"),
|
||
cover_prompts_dir=os.path.join(temp_dir, "cover_prompts"),
|
||
)
|
||
self.addCleanup(generate_tab.close)
|
||
|
||
statuses.clear()
|
||
generate_tab.save_title_prompt()
|
||
self.assertEqual(("标题提示词已保存", "success"), statuses[-1])
|
||
|
||
statuses.clear()
|
||
generate_tab.start_generate()
|
||
self.assertIn("当前筛选结果没有可生成的缺失内容", statuses[-1][0])
|
||
self.assertEqual("warning", statuses[-1][1])
|
||
|
||
model = gui.GenerateTaskTableModel(db_path=os.path.join(temp_dir, "missing_tables.sqlite"), status_callback=capture)
|
||
editable_task = SimpleNamespace(
|
||
id=999,
|
||
alias="papa",
|
||
account_name="papa 店铺",
|
||
item_id="28431952912",
|
||
old_title="旧标题",
|
||
new_title="新标题",
|
||
new_cover_path="",
|
||
stage="generated",
|
||
status="success",
|
||
committed=0,
|
||
last_error="",
|
||
)
|
||
model.set_tasks([editable_task], [])
|
||
statuses.clear()
|
||
ok = model.setData(model.index(0, 3), "新标题 2")
|
||
self.assertFalse(ok)
|
||
self.assertIn("新标题修改失败", statuses[-1][0])
|
||
self.assertEqual("danger", statuses[-1][1])
|
||
|
||
self.assert_removed(temp_dir)
|
||
|
||
def test_main_window_initial_fit_limits_size_on_small_screen(self):
|
||
class FakeWindow:
|
||
def __init__(self):
|
||
self.size = None
|
||
self.position = None
|
||
|
||
def resize(self, width, height):
|
||
self.size = (width, height)
|
||
|
||
def move(self, x, y):
|
||
self.position = (x, y)
|
||
|
||
window = FakeWindow()
|
||
_fit_and_center_window(window, available_geometry=QRect(0, 0, 900, 700))
|
||
|
||
self.assertEqual((820, 620), window.size)
|
||
self.assertEqual((40, 40), window.position)
|
||
|
||
def test_main_window_initial_fit_centers_preferred_size_on_large_screen(self):
|
||
class FakeWindow:
|
||
def __init__(self):
|
||
self.size = None
|
||
self.position = None
|
||
|
||
def resize(self, width, height):
|
||
self.size = (width, height)
|
||
|
||
def move(self, x, y):
|
||
self.position = (x, y)
|
||
|
||
window = FakeWindow()
|
||
_fit_and_center_window(window, available_geometry=QRect(100, 50, 1600, 900))
|
||
|
||
self.assertEqual((1180, 760), window.size)
|
||
self.assertEqual((310, 120), window.position)
|
||
|
||
def test_collect_tab_shows_empty_state_without_accounts(self):
|
||
with self.make_temp_dir() as temp_dir:
|
||
cfg = self.make_config(temp_dir)
|
||
opened = []
|
||
tab = CollectTab(config=cfg, open_accounts_callback=lambda: opened.append(True))
|
||
self.addCleanup(tab.close)
|
||
|
||
self.assertFalse(tab.empty_state_card.isHidden())
|
||
self.assertIn("④账号管理", tab.empty_state_label.text())
|
||
self.assertFalse(tab.empty_state_button.isHidden())
|
||
|
||
tab.empty_state_button.click()
|
||
self.assertEqual([True], opened)
|
||
|
||
self.assert_removed(temp_dir)
|
||
|
||
def test_generate_tab_shows_empty_state_for_first_steps(self):
|
||
with self.make_temp_dir() as temp_dir:
|
||
cfg = self.make_config(temp_dir)
|
||
opened = []
|
||
tab = GenerateTab(config=cfg, open_accounts_callback=lambda: opened.append(True))
|
||
self.addCleanup(tab.close)
|
||
|
||
self.assertFalse(tab.empty_state_card.isHidden())
|
||
self.assertIn("④账号管理", tab.empty_state_label.text())
|
||
self.assertFalse(tab.empty_state_button.isHidden())
|
||
tab.empty_state_button.click()
|
||
self.assertEqual([True], opened)
|
||
|
||
accounts.create_account("主店", "alias", debug_port=9222, config=cfg)
|
||
tab.refresh_tasks()
|
||
self.assertFalse(tab.empty_state_card.isHidden())
|
||
self.assertIn("①导入采集", tab.empty_state_label.text())
|
||
self.assertTrue(tab.empty_state_button.isHidden())
|
||
|
||
self.assert_removed(temp_dir)
|
||
|
||
def test_apply_tab_shows_empty_state_for_first_steps(self):
|
||
with self.make_temp_dir() as temp_dir:
|
||
cfg = self.make_config(temp_dir)
|
||
opened = []
|
||
tab = ApplyTab(config=cfg, open_accounts_callback=lambda: opened.append(True))
|
||
self.addCleanup(tab.close)
|
||
|
||
self.assertFalse(tab.empty_state_card.isHidden())
|
||
self.assertIn("④账号管理", tab.empty_state_label.text())
|
||
self.assertFalse(tab.empty_state_button.isHidden())
|
||
tab.empty_state_button.click()
|
||
self.assertEqual([True], opened)
|
||
|
||
accounts.create_account("主店", "alias", debug_port=9222, config=cfg)
|
||
tab.refresh_tasks()
|
||
self.assertFalse(tab.empty_state_card.isHidden())
|
||
self.assertIn("②AI生成", tab.empty_state_label.text())
|
||
self.assertTrue(tab.empty_state_button.isHidden())
|
||
|
||
self.assert_removed(temp_dir)
|
||
|
||
def test_workflow_tabs_show_batch_progress_overview(self):
|
||
with self.make_temp_dir() as temp_dir:
|
||
cfg = self.make_config(temp_dir)
|
||
db.init_db(cfg["db_path"])
|
||
accounts.create_account("主店", "alias", debug_port=9222, config=cfg)
|
||
batch_id = db.create_batch(["input.xlsx"], path=cfg["db_path"])
|
||
rows = []
|
||
for index in range(6):
|
||
rows.append(
|
||
{
|
||
"source_file_abs": os.path.join(temp_dir, "input.xlsx"),
|
||
"source_sheet": "待处理任务",
|
||
"source_row": index + 2,
|
||
"row_key": f"row-{index}",
|
||
"account_name": "主店",
|
||
"alias": "alias",
|
||
"item_id": str(1000 + index),
|
||
}
|
||
)
|
||
db.insert_tasks(batch_id, rows, path=cfg["db_path"])
|
||
tasks = {task.item_id: task for task in db.list_tasks(batch_id=batch_id, path=cfg["db_path"])}
|
||
db.set_collected(tasks["1001"].id, "旧标题", os.path.join(temp_dir, "old.jpg"), path=cfg["db_path"])
|
||
db.set_generated(tasks["1002"].id, "新标题", os.path.join(temp_dir, "new.jpg"), path=cfg["db_path"])
|
||
db.set_applied(tasks["1003"].id, committed=True, path=cfg["db_path"])
|
||
db.set_collected(tasks["1004"].id, "旧标题", os.path.join(temp_dir, "old-failed.jpg"), path=cfg["db_path"])
|
||
db.mark_failed(tasks["1004"].id, "generate", "生成失败", path=cfg["db_path"])
|
||
db.mark_skipped(tasks["1005"].id, "别名未匹配", path=cfg["db_path"])
|
||
|
||
active_other = db.create_batch(["other.xlsx"], path=cfg["db_path"])
|
||
db.insert_tasks(
|
||
active_other,
|
||
[
|
||
{
|
||
"source_file_abs": os.path.join(temp_dir, "other.xlsx"),
|
||
"source_sheet": "待处理任务",
|
||
"source_row": 2,
|
||
"row_key": "other-row",
|
||
"account_name": "主店",
|
||
"alias": "alias",
|
||
"item_id": "2000",
|
||
}
|
||
],
|
||
path=cfg["db_path"],
|
||
)
|
||
deleted_batch = db.create_batch(["deleted.xlsx"], path=cfg["db_path"])
|
||
db.insert_tasks(
|
||
deleted_batch,
|
||
[
|
||
{
|
||
"source_file_abs": os.path.join(temp_dir, "deleted.xlsx"),
|
||
"source_sheet": "待处理任务",
|
||
"source_row": 2,
|
||
"row_key": "deleted-row",
|
||
"account_name": "主店",
|
||
"alias": "alias",
|
||
"item_id": "3000",
|
||
}
|
||
],
|
||
path=cfg["db_path"],
|
||
)
|
||
db.delete_batch(deleted_batch, reason="测试软删除", path=cfg["db_path"])
|
||
|
||
expected = "批次进度:总数6 · 导入1 · 已采集1 · 已生成1 · 已更新1 · 失败1 · 略过1"
|
||
for tab_class in (CollectTab, GenerateTab, ApplyTab):
|
||
tab = tab_class(config=cfg)
|
||
self.addCleanup(tab.close)
|
||
self.assertEqual(-1, tab.batch_filter.findData(deleted_batch))
|
||
tab.batch_filter.setCurrentIndex(tab.batch_filter.findData(batch_id))
|
||
tab.refresh_tasks()
|
||
|
||
self.assertFalse(tab.batch_progress_label.isHidden())
|
||
self.assertEqual(expected, tab.batch_progress_label.text())
|
||
|
||
self.assert_removed(temp_dir)
|
||
|
||
def test_workflow_batch_filters_default_to_latest_batch_and_remember_selection(self):
|
||
with self.make_temp_dir() as temp_dir:
|
||
cfg = self.make_config(temp_dir)
|
||
|
||
for tab_class in (CollectTab, GenerateTab, ApplyTab):
|
||
tab = tab_class(config=cfg)
|
||
self.addCleanup(tab.close)
|
||
self.assertEqual(0, tab.batch_filter.currentIndex())
|
||
self.assertIsNone(tab.batch_filter.currentData())
|
||
|
||
accounts.create_account("主店", "alias-a", debug_port=9222, config=cfg)
|
||
older_batch = db.create_batch(["older.xlsx"], path=cfg["db_path"])
|
||
newer_batch = db.create_batch(["newer.xlsx"], path=cfg["db_path"])
|
||
self.set_batch_created_at(cfg, older_batch, "2026-07-08T10:00:00")
|
||
self.set_batch_created_at(cfg, newer_batch, "2026-07-08T11:00:00")
|
||
for batch_id, item_id in ((older_batch, "1001"), (newer_batch, "2001")):
|
||
db.insert_tasks(
|
||
batch_id,
|
||
[
|
||
{
|
||
"source_file_abs": os.path.join(temp_dir, f"{item_id}.xlsx"),
|
||
"source_sheet": "商品",
|
||
"source_row": 2,
|
||
"account_name": "Excel主店",
|
||
"alias": "alias-a",
|
||
"item_id": item_id,
|
||
}
|
||
],
|
||
path=cfg["db_path"],
|
||
)
|
||
|
||
for tab_class in (CollectTab, GenerateTab, ApplyTab):
|
||
tab = tab_class(config=cfg)
|
||
self.addCleanup(tab.close)
|
||
self.assertEqual(newer_batch, tab.batch_filter.currentData())
|
||
self.assertEqual(1, tab.batch_filter.currentIndex())
|
||
|
||
tab.batch_filter.setCurrentIndex(tab.batch_filter.findData(older_batch))
|
||
tab.refresh_tasks()
|
||
self.assertEqual(older_batch, tab.batch_filter.currentData())
|
||
|
||
tab.batch_filter.setCurrentIndex(tab.batch_filter.findData(None))
|
||
tab.refresh_tasks()
|
||
self.assertIsNone(tab.batch_filter.currentData())
|
||
|
||
self.assert_removed(temp_dir)
|
||
|
||
def test_settings_tab_loads_ai_models_and_masks_key_field(self):
|
||
with self.make_temp_dir() as temp_dir:
|
||
cfg = self.make_config(temp_dir)
|
||
models_path = cfg["ai_models_path"]
|
||
appconfig.save_ai_models_config(
|
||
{
|
||
"models": [
|
||
{
|
||
"name": "Text A",
|
||
"category": "text",
|
||
"enabled": True,
|
||
"url": "https://example.invalid/text",
|
||
"model": "text-model",
|
||
"api_key": "sk-text-secret",
|
||
"api_type": "chat",
|
||
"connect_timeout_seconds": 11,
|
||
"timeout_seconds": 0,
|
||
"extra_body": {"temperature": 0},
|
||
},
|
||
{
|
||
"name": "Image A",
|
||
"category": "image",
|
||
"enabled": True,
|
||
"url": "https://example.invalid/image",
|
||
"model": "image-model",
|
||
"api_key": "sk-image-secret",
|
||
"api_type": "auto",
|
||
"connect_timeout_seconds": 22,
|
||
"timeout_seconds": 0,
|
||
"extra_body": {},
|
||
},
|
||
]
|
||
},
|
||
path=models_path,
|
||
)
|
||
|
||
tab = SettingsTab(config=cfg, ai_models_path=models_path)
|
||
self.addCleanup(tab.close)
|
||
|
||
self.assertEqual(2, tab.model_combo.count())
|
||
self.assertEqual("Text A", tab.name_edit.text())
|
||
self.assertEqual("text", tab.category_combo.currentData())
|
||
self.assertEqual("chat", tab.api_type_combo.currentData())
|
||
self.assertEqual("text-model", tab.model_id_edit.text())
|
||
self.assertEqual("https://example.invalid/text", tab.url_edit.text())
|
||
self.assertEqual("sk-text-secret", tab.api_key_edit.text())
|
||
self.assertEqual(QLineEdit.Password, tab.api_key_edit.echoMode())
|
||
self.assertEqual(11, tab.connect_timeout_spin.value())
|
||
self.assertFalse(tab.delete_model_button.isEnabled())
|
||
self.assertFalse(hasattr(tab, "test_item_id_edit"))
|
||
self.assertFalse(hasattr(tab, "dry_run_checkbox"))
|
||
self.assertEqual(
|
||
"蝦皮更新执行",
|
||
tab.shopee_update_section_title.text(),
|
||
)
|
||
self.assertEqual("基础设施(路径与端口)", tab.infrastructure_section_title.text())
|
||
self.assertLess(
|
||
tab.settings_panel_layout.indexOf(tab.shopee_update_section_title),
|
||
tab.settings_panel_layout.indexOf(tab.infrastructure_section_title),
|
||
)
|
||
self.assertFalse(hasattr(tab, "allow_real_submit_checkbox"))
|
||
self.assertFalse(hasattr(tab, "allow_cover_update_checkbox"))
|
||
self.assertFalse(hasattr(tab, "close_success_tab_checkbox"))
|
||
self.assertFalse(hasattr(tab, "parallel_accounts_checkbox"))
|
||
self.assertFalse(hasattr(tab, "parallel_accounts_group"))
|
||
self.assertFalse(hasattr(tab, "jpg_quality_spin"))
|
||
self.assertEqual(1, tab.max_items_per_run_spin.value())
|
||
self.assertEqual(1, tab.max_parallel_accounts_spin.value())
|
||
self.assertEqual(1, tab.max_parallel_accounts_spin.minimum())
|
||
self.assertEqual(5, tab.max_parallel_accounts_spin.maximum())
|
||
|
||
def widget_position(layout, widget):
|
||
for index in range(layout.count()):
|
||
item = layout.itemAt(index)
|
||
if item is not None and item.widget() is widget:
|
||
return layout.getItemPosition(index)
|
||
self.fail(f"Widget not found in layout: {widget.objectName()}")
|
||
|
||
parallel_row, parallel_col, _row_span, parallel_col_span = widget_position(
|
||
tab.shopee_update_form_layout,
|
||
tab.max_parallel_accounts_spin,
|
||
)
|
||
self.assertGreaterEqual(parallel_row, 0)
|
||
self.assertGreaterEqual(parallel_col, 0)
|
||
self.assertEqual(1, parallel_col_span)
|
||
for hidden_widget in (
|
||
tab.user_data_root_edit,
|
||
tab.image_dir_edit,
|
||
tab.db_path_edit,
|
||
):
|
||
self.assertTrue(hidden_widget.isHidden())
|
||
self.assertFalse(hidden_widget.isEnabled())
|
||
self.assertEqual(-1, tab.infrastructure_form_layout.indexOf(hidden_widget))
|
||
self.assertGreaterEqual(
|
||
tab.infrastructure_form_layout.indexOf(tab.chrome_path_widget),
|
||
0,
|
||
)
|
||
self.assertEqual("选择...", tab.chrome_path_browse_button.text())
|
||
self.assertEqual("chromePathBrowseButton", tab.chrome_path_browse_button.objectName())
|
||
self.assertEqual("自动检测", tab.chrome_path_detect_button.text())
|
||
self.assertEqual(
|
||
"chromePathAutoDetectButton",
|
||
tab.chrome_path_detect_button.objectName(),
|
||
)
|
||
self.assertIs(
|
||
tab.chrome_path_widget.layout().itemAt(0).widget(),
|
||
tab.chrome_path_edit,
|
||
)
|
||
self.assertIs(
|
||
tab.chrome_path_widget.layout().itemAt(1).widget(),
|
||
tab.chrome_path_browse_button,
|
||
)
|
||
self.assertIs(
|
||
tab.chrome_path_widget.layout().itemAt(2).widget(),
|
||
tab.chrome_path_detect_button,
|
||
)
|
||
|
||
self.assert_removed(temp_dir)
|
||
|
||
def test_settings_tab_browses_chrome_path(self):
|
||
with self.make_temp_dir() as temp_dir:
|
||
cfg = self.make_config(temp_dir)
|
||
statuses = []
|
||
tab = SettingsTab(config=cfg, status_callback=statuses.append)
|
||
self.addCleanup(tab.close)
|
||
selected_path = os.path.join(temp_dir, "Chrome", "chrome.exe")
|
||
os.makedirs(os.path.dirname(selected_path), exist_ok=True)
|
||
|
||
self.assertFalse(tab.is_dirty())
|
||
with mock.patch(
|
||
"app.gui.tabs.settings.QFileDialog.getOpenFileName",
|
||
return_value=(selected_path, "Chrome 程序 (chrome.exe)"),
|
||
) as browse:
|
||
tab.chrome_path_browse_button.click()
|
||
|
||
browse.assert_called_once()
|
||
self.assertEqual("选择 Chrome 程序", browse.call_args[0][1])
|
||
self.assertEqual(selected_path, tab.chrome_path_edit.text())
|
||
self.assertTrue(tab.is_dirty())
|
||
self.assertFalse(tab.unsaved_changes_label.isHidden())
|
||
|
||
with mock.patch("app.gui.QMessageBox.information") as info:
|
||
self.assertTrue(tab.save_app_settings())
|
||
info.assert_called_once_with(tab, "保存设置", "设置已保存")
|
||
saved = appconfig.load_config(cfg["config_path"])
|
||
self.assertEqual(selected_path, saved["chrome_path"])
|
||
self.assertFalse(tab.is_dirty())
|
||
|
||
tab._set_dirty(False)
|
||
previous_path = tab.chrome_path_edit.text()
|
||
with mock.patch(
|
||
"app.gui.tabs.settings.QFileDialog.getOpenFileName",
|
||
return_value=("", ""),
|
||
):
|
||
tab.chrome_path_browse_button.click()
|
||
|
||
self.assertEqual(previous_path, tab.chrome_path_edit.text())
|
||
self.assertFalse(tab.is_dirty())
|
||
|
||
self.assert_removed(temp_dir)
|
||
|
||
def test_settings_tab_auto_detects_chrome_without_saving(self):
|
||
with self.make_temp_dir() as temp_dir:
|
||
cfg = self.make_config(temp_dir)
|
||
statuses = []
|
||
tab = SettingsTab(config=cfg, status_callback=statuses.append)
|
||
self.addCleanup(tab.close)
|
||
detected = os.path.join(temp_dir, "Chrome", "chrome.exe")
|
||
|
||
with mock.patch(
|
||
"app.gui.tabs.settings.chrome.detect_chrome_path",
|
||
return_value=detected,
|
||
):
|
||
tab.chrome_path_detect_button.click()
|
||
|
||
self.assertEqual(detected, tab.chrome_path_edit.text())
|
||
self.assertTrue(tab.is_dirty())
|
||
self.assertFalse(os.path.exists(cfg["config_path"]))
|
||
self.assertIn("已自动定位 Chrome", statuses[-1])
|
||
|
||
tab._set_dirty(False)
|
||
previous = tab.chrome_path_edit.text()
|
||
with mock.patch(
|
||
"app.gui.tabs.settings.chrome.detect_chrome_path",
|
||
return_value="",
|
||
):
|
||
tab.chrome_path_detect_button.click()
|
||
|
||
self.assertEqual(previous, tab.chrome_path_edit.text())
|
||
self.assertFalse(tab.is_dirty())
|
||
self.assertIn("未找到 Chrome", statuses[-1])
|
||
|
||
self.assert_removed(temp_dir)
|
||
|
||
def test_main_window_keeps_startup_chrome_detection_status(self):
|
||
with self.make_temp_dir() as temp_dir:
|
||
message = "已自动定位 Chrome:C:\\Chrome\\chrome.exe"
|
||
window = MainWindow(
|
||
config=self.make_config(temp_dir),
|
||
startup_status=message,
|
||
)
|
||
self.addCleanup(window.close)
|
||
|
||
self.assertEqual(message, window.statusBar().currentMessage())
|
||
self.assertIn(gui.COLOR_SUCCESS, window.statusBar().styleSheet())
|
||
|
||
self.assert_removed(temp_dir)
|
||
|
||
def test_settings_tab_adds_saves_and_deletes_model(self):
|
||
with self.make_temp_dir() as temp_dir:
|
||
cfg = self.make_config(temp_dir)
|
||
models_path = cfg["ai_models_path"]
|
||
statuses = []
|
||
tab = SettingsTab(
|
||
config=cfg,
|
||
ai_models_path=models_path,
|
||
status_callback=statuses.append,
|
||
)
|
||
self.addCleanup(tab.close)
|
||
|
||
tab.add_model()
|
||
self.assertEqual("新文本模型", tab.current_model_name)
|
||
self.assertEqual(3, tab.model_combo.count())
|
||
|
||
tab.name_edit.setText("Text Custom")
|
||
tab.url_edit.setText("https://example.invalid/v1/chat/completions")
|
||
tab.model_id_edit.setText("demo-text")
|
||
tab.api_key_edit.setText("sk-custom-secret")
|
||
tab.api_type_combo.setCurrentIndex(tab.api_type_combo.findData("chat"))
|
||
tab.connect_timeout_spin.setValue(12)
|
||
with mock.patch("app.gui.QMessageBox.warning") as warning:
|
||
tab.save_model()
|
||
|
||
warning.assert_called_once()
|
||
self.assertIn("本地明文保存", warning.call_args[0][1])
|
||
self.assertIn("data/config/ai_models.json", warning.call_args[0][2])
|
||
|
||
saved = appconfig.get_model("Text Custom", path=models_path)
|
||
self.assertEqual("text", saved["category"])
|
||
self.assertEqual("demo-text", saved["model"])
|
||
self.assertEqual("sk-custom-secret", saved["api_key"])
|
||
self.assertEqual(12, saved["connect_timeout_seconds"])
|
||
self.assertIn("AI 模型已保存:Text Custom", statuses[-1])
|
||
self.assertNotIn("sk-custom-secret", statuses[-1])
|
||
self.assertTrue(tab.delete_model_button.isEnabled())
|
||
|
||
with mock.patch("app.gui.QMessageBox.question", return_value=gui.QMessageBox.Yes):
|
||
tab.delete_model()
|
||
|
||
names = [model["name"] for model in appconfig.list_ai_models(path=models_path)]
|
||
self.assertNotIn("Text Custom", names)
|
||
self.assertIn("AI 模型已删除:Text Custom", statuses[-1])
|
||
|
||
self.assert_removed(temp_dir)
|
||
|
||
def test_settings_tab_starts_connection_test_worker(self):
|
||
with self.make_temp_dir() as temp_dir:
|
||
cfg = self.make_config(temp_dir)
|
||
models_path = cfg["ai_models_path"]
|
||
statuses = []
|
||
tab = SettingsTab(
|
||
config=cfg,
|
||
ai_models_path=models_path,
|
||
status_callback=statuses.append,
|
||
)
|
||
self.addCleanup(tab.close)
|
||
|
||
class FakeSignal:
|
||
def __init__(self):
|
||
self.callbacks = []
|
||
|
||
def connect(self, callback):
|
||
self.callbacks.append(callback)
|
||
|
||
class FakeThread:
|
||
def __init__(self):
|
||
self.finished = FakeSignal()
|
||
self.started = False
|
||
|
||
def start(self):
|
||
self.started = True
|
||
|
||
fake_thread = FakeThread()
|
||
with mock.patch("app.gui.run_worker", return_value=fake_thread) as run_worker:
|
||
tab.test_connection()
|
||
|
||
run_worker.assert_called_once()
|
||
self.assertIsInstance(tab.test_worker, AIModelTestWorker)
|
||
self.assertIs(tab.test_thread, fake_thread)
|
||
self.assertTrue(fake_thread.started)
|
||
self.assertFalse(tab.test_connection_button.isEnabled())
|
||
self.assertIn("正在测试 AI 模型连接", statuses[-1])
|
||
|
||
self.assert_removed(temp_dir)
|
||
|
||
def test_settings_tab_saves_role_generation_path_and_port_config(self):
|
||
with self.make_temp_dir() as temp_dir:
|
||
cfg = self.make_config(temp_dir)
|
||
cfg["ai"] = appconfig.default_config()["ai"]
|
||
cfg["ai"]["default_text_model"] = "Text A"
|
||
cfg["ai"]["default_image_model"] = "Image A"
|
||
cfg["ai"]["generate_cover"] = True
|
||
cfg["user_data_root"] = "manual_profiles"
|
||
cfg["image_dir"] = "manual_images"
|
||
cfg["db_path"] = "manual\\cmshopee.db"
|
||
models_path = cfg["ai_models_path"]
|
||
config_path = cfg["config_path"]
|
||
appconfig.save_ai_models_config(
|
||
{
|
||
"models": [
|
||
{
|
||
"name": "Text A",
|
||
"category": "text",
|
||
"enabled": True,
|
||
"url": "",
|
||
"model": "",
|
||
"api_key": "",
|
||
"api_type": "chat",
|
||
"connect_timeout_seconds": 30,
|
||
"timeout_seconds": 0,
|
||
"extra_body": {},
|
||
},
|
||
{
|
||
"name": "Text B",
|
||
"category": "text",
|
||
"enabled": True,
|
||
"url": "",
|
||
"model": "",
|
||
"api_key": "",
|
||
"api_type": "chat",
|
||
"connect_timeout_seconds": 30,
|
||
"timeout_seconds": 0,
|
||
"extra_body": {},
|
||
},
|
||
{
|
||
"name": "Image A",
|
||
"category": "image",
|
||
"enabled": True,
|
||
"url": "",
|
||
"model": "",
|
||
"api_key": "",
|
||
"api_type": "auto",
|
||
"connect_timeout_seconds": 30,
|
||
"timeout_seconds": 0,
|
||
"extra_body": {},
|
||
},
|
||
{
|
||
"name": "Image B",
|
||
"category": "image",
|
||
"enabled": True,
|
||
"url": "",
|
||
"model": "",
|
||
"api_key": "",
|
||
"api_type": "auto",
|
||
"connect_timeout_seconds": 30,
|
||
"timeout_seconds": 0,
|
||
"extra_body": {},
|
||
},
|
||
]
|
||
},
|
||
path=models_path,
|
||
)
|
||
statuses = []
|
||
tab = SettingsTab(
|
||
config=cfg,
|
||
config_path=config_path,
|
||
ai_models_path=models_path,
|
||
status_callback=statuses.append,
|
||
)
|
||
self.addCleanup(tab.close)
|
||
|
||
self.assertEqual(1, tab.title_concurrency_spin.minimum())
|
||
self.assertEqual(5, tab.title_concurrency_spin.maximum())
|
||
self.assertEqual(1, tab.image_concurrency_spin.minimum())
|
||
self.assertEqual(5, tab.image_concurrency_spin.maximum())
|
||
self.assertEqual(0, tab.retry_spin.minimum())
|
||
self.assertEqual(10, tab.retry_spin.maximum())
|
||
|
||
text_roles = [
|
||
tab.default_text_model_combo.itemData(index)
|
||
for index in range(tab.default_text_model_combo.count())
|
||
]
|
||
image_roles = [
|
||
tab.default_image_model_combo.itemData(index)
|
||
for index in range(tab.default_image_model_combo.count())
|
||
]
|
||
self.assertEqual(["Text A", "Text B"], text_roles)
|
||
self.assertEqual(["Image A", "Image B"], image_roles)
|
||
|
||
tab.default_text_model_combo.setCurrentIndex(
|
||
tab.default_text_model_combo.findData("Text B")
|
||
)
|
||
tab.default_image_model_combo.setCurrentIndex(
|
||
tab.default_image_model_combo.findData("Image B")
|
||
)
|
||
tab.title_concurrency_spin.setValue(3)
|
||
tab.image_concurrency_spin.setValue(2)
|
||
tab.retry_spin.setValue(1)
|
||
tab.resolution_combo.setCurrentIndex(tab.resolution_combo.findData("2k"))
|
||
self.assertEqual("标题 600 秒 / 图片 900 秒", tab.response_timeout_label.text())
|
||
tab.chrome_path_edit.setText("D:\\Chrome\\chrome.exe")
|
||
tab.default_debug_port_spin.setValue(9300)
|
||
tab.debug_port_start_spin.setValue(9300)
|
||
tab.debug_port_end_spin.setValue(9350)
|
||
tab.cdp_ready_timeout_spin.setValue(45)
|
||
tab.max_items_per_run_spin.setValue(2)
|
||
tab.max_parallel_accounts_spin.setValue(3)
|
||
|
||
with mock.patch("app.gui.QMessageBox.information") as info:
|
||
tab.save_app_settings()
|
||
|
||
info.assert_called_once_with(tab, "保存设置", "设置已保存")
|
||
saved = appconfig.load_config(config_path)
|
||
self.assertEqual("Text B", saved["ai"]["default_text_model"])
|
||
self.assertEqual("Image B", saved["ai"]["default_image_model"])
|
||
self.assertTrue(saved["ai"]["generate_cover"])
|
||
self.assertEqual(3, saved["ai"]["title_concurrency"])
|
||
self.assertEqual(2, saved["ai"]["image_concurrency"])
|
||
self.assertEqual(1, saved["ai"]["retry"])
|
||
self.assertEqual("2k", saved["ai"]["resolution"])
|
||
self.assertEqual(90, saved["ai"]["jpg_quality"])
|
||
self.assertEqual("D:\\Chrome\\chrome.exe", saved["chrome_path"])
|
||
self.assertEqual("manual_profiles", saved["user_data_root"])
|
||
self.assertEqual("manual_images", saved["image_dir"])
|
||
self.assertEqual("manual\\cmshopee.db", saved["db_path"])
|
||
self.assertEqual(9300, saved["default_debug_port"])
|
||
self.assertEqual([9300, 9350], saved["debug_port_range"])
|
||
self.assertEqual(45, saved["cdp_ready_timeout"])
|
||
self.assertEqual(
|
||
{
|
||
"test_item_id": "51100639510",
|
||
"update_mode": "title",
|
||
"max_items_per_run": 2,
|
||
"dry_run": False,
|
||
"max_parallel_accounts": 3,
|
||
},
|
||
saved["shopee_update"],
|
||
)
|
||
with open(config_path, "r", encoding="utf-8") as fh:
|
||
persisted = json.load(fh)
|
||
self.assertNotIn("ai_models_path", persisted)
|
||
self.assertNotIn("config_path", persisted)
|
||
self.assertNotIn("cmhub_config_path", persisted)
|
||
self.assertNotIn("data_dir", persisted)
|
||
self.assertIn("设置已保存", statuses[-1])
|
||
|
||
self.assert_removed(temp_dir)
|
||
|
||
def test_settings_tab_direct_timeout_label_uses_resolution_mapping(self):
|
||
with self.make_temp_dir() as temp_dir:
|
||
cfg = self.make_config(temp_dir)
|
||
cfg["ai"] = appconfig.default_config()["ai"]
|
||
cfg["ai"]["backend"] = "direct"
|
||
tab = SettingsTab(
|
||
config=cfg,
|
||
config_path=cfg["config_path"],
|
||
ai_models_path=cfg["ai_models_path"],
|
||
)
|
||
self.addCleanup(tab.close)
|
||
|
||
tab.resolution_combo.setCurrentIndex(tab.resolution_combo.findData("2k"))
|
||
|
||
self.assertEqual("360 秒", tab.response_timeout_label.text())
|
||
|
||
self.assert_removed(temp_dir)
|
||
|
||
def test_settings_save_updates_apply_tab_shared_update_config(self):
|
||
with self.make_temp_dir() as temp_dir:
|
||
cfg = self.make_config(temp_dir)
|
||
cfg["shopee_update"] = dict(appconfig.default_config()["shopee_update"])
|
||
cfg["shopee_update"]["test_item_id"] = "123456789"
|
||
window = MainWindow(
|
||
config=cfg,
|
||
config_path=cfg["config_path"],
|
||
ai_models_path=cfg["ai_models_path"],
|
||
)
|
||
self.addCleanup(window.close)
|
||
settings_tab = window.tabs.widget(TAB_TITLES.index("⑤ 设置"))
|
||
apply_tab = window.tabs.widget(TAB_TITLES.index("③ 更新蝦皮"))
|
||
|
||
settings_tab.max_items_per_run_spin.setValue(3)
|
||
settings_tab.max_parallel_accounts_spin.setValue(4)
|
||
with mock.patch("app.gui.QMessageBox.information") as info:
|
||
settings_tab.save_app_settings()
|
||
|
||
info.assert_called_once_with(settings_tab, "保存设置", "设置已保存")
|
||
safety_cfg = apply_tab._shopee_update_config()
|
||
self.assertEqual("123456789", safety_cfg["test_item_id"])
|
||
self.assertNotIn("allow_real_submit", safety_cfg)
|
||
self.assertNotIn("allow_cover_update", safety_cfg)
|
||
self.assertEqual("title", safety_cfg["update_mode"])
|
||
self.assertEqual(3, safety_cfg["max_items_per_run"])
|
||
self.assertNotIn("close_success_tab", safety_cfg)
|
||
self.assertFalse(safety_cfg["dry_run"])
|
||
self.assertNotIn("parallel_accounts", safety_cfg)
|
||
self.assertEqual(4, safety_cfg["max_parallel_accounts"])
|
||
|
||
self.assert_removed(temp_dir)
|
||
|
||
def test_settings_tab_rejects_invalid_port_range(self):
|
||
with self.make_temp_dir() as temp_dir:
|
||
cfg = self.make_config(temp_dir)
|
||
tab = SettingsTab(
|
||
config=cfg,
|
||
config_path=cfg["config_path"],
|
||
ai_models_path=cfg["ai_models_path"],
|
||
)
|
||
self.addCleanup(tab.close)
|
||
|
||
tab.debug_port_start_spin.setValue(9400)
|
||
tab.debug_port_end_spin.setValue(9300)
|
||
with mock.patch("app.gui.QMessageBox.warning") as warning:
|
||
tab.save_app_settings()
|
||
|
||
warning.assert_called_once()
|
||
self.assertFalse(os.path.exists(cfg["config_path"]))
|
||
|
||
self.assert_removed(temp_dir)
|
||
|
||
def test_ai_model_test_worker_calls_appconfig(self):
|
||
with self.make_temp_dir() as temp_dir:
|
||
models_path = os.path.join(temp_dir, "ai_models.json")
|
||
worker = AIModelTestWorker("Text A", ai_models_path=models_path)
|
||
|
||
with mock.patch(
|
||
"app.gui.appconfig.test_ai_model",
|
||
return_value={"ok": True, "status": 200},
|
||
) as test_ai_model:
|
||
result = worker.execute()
|
||
|
||
test_ai_model.assert_called_once_with("Text A", path=models_path)
|
||
self.assertEqual({"ok": True, "status": 200, "name": "Text A"}, result)
|
||
|
||
self.assert_removed(temp_dir)
|
||
|
||
def test_ai_model_test_worker_sanitizes_secret_payload_fields(self):
|
||
with self.make_temp_dir() as temp_dir:
|
||
models_path = os.path.join(temp_dir, "ai_models.json")
|
||
worker = AIModelTestWorker("Text A", ai_models_path=models_path)
|
||
|
||
with mock.patch(
|
||
"app.gui.appconfig.test_ai_model",
|
||
return_value={
|
||
"ok": False,
|
||
"api_key": "sk-worker-secret",
|
||
"password": "worker-password",
|
||
"error": "连接失败",
|
||
},
|
||
):
|
||
result = worker.execute()
|
||
|
||
self.assertEqual("Text A", result["name"])
|
||
self.assertEqual("sk-w***cret", result["api_key"])
|
||
self.assertEqual("work***word", result["password"])
|
||
self.assertEqual("连接失败", result["error"])
|
||
|
||
self.assert_removed(temp_dir)
|
||
|
||
def test_cmhub_settings_worker_fetches_models_and_balance(self):
|
||
worker = CMHubSettingsWorker(
|
||
"https://cmhub.example.com",
|
||
"sk-cmhub-secret",
|
||
connect_timeout=7,
|
||
include_balance=True,
|
||
)
|
||
models = [
|
||
{
|
||
"alias": "title-standard",
|
||
"operation_type": "title",
|
||
"pricing_status": "priced",
|
||
}
|
||
]
|
||
balance = {
|
||
"user": "cmhub_user",
|
||
"points_balance": 88,
|
||
"account": {"username": "cmhub_user", "display_name": "主账号"},
|
||
}
|
||
|
||
with mock.patch("app.gui.ai.fetch_cmhub_models", return_value=models) as fetch_models, \
|
||
mock.patch("app.gui.ai.fetch_cmhub_balance", return_value=balance) as fetch_balance:
|
||
result = worker.execute()
|
||
|
||
fetch_models.assert_called_once_with(
|
||
"https://cmhub.example.com",
|
||
"sk-cmhub-secret",
|
||
connect_timeout=7,
|
||
)
|
||
fetch_balance.assert_called_once_with(
|
||
"https://cmhub.example.com",
|
||
"sk-cmhub-secret",
|
||
connect_timeout=7,
|
||
)
|
||
self.assertTrue(result["ok"])
|
||
self.assertEqual(models, result["models"])
|
||
self.assertEqual(88, result["points_balance"])
|
||
self.assertEqual("cmhub_user", result["balance"]["user"])
|
||
self.assertEqual("主账号", result["balance"]["account"]["display_name"])
|
||
self.assertEqual("cmhub_user", result["balance"]["account"]["username"])
|
||
|
||
def test_cmhub_settings_worker_redacts_key_on_failure(self):
|
||
worker = CMHubSettingsWorker(
|
||
"https://cmhub.example.com",
|
||
"sk-cmhub-secret",
|
||
connect_timeout=7,
|
||
include_balance=False,
|
||
)
|
||
|
||
with mock.patch(
|
||
"app.gui.ai.fetch_cmhub_models",
|
||
side_effect=RuntimeError("bad key sk-cmhub-secret"),
|
||
):
|
||
with self.assertRaises(RuntimeError) as raised:
|
||
worker.execute()
|
||
|
||
self.assertIn("bad key", str(raised.exception))
|
||
self.assertNotIn("sk-cmhub-secret", str(raised.exception))
|
||
|
||
def test_settings_tab_cmhub_backend_panel_saves_config_and_key(self):
|
||
with self.make_temp_dir() as temp_dir:
|
||
cfg = self.make_config(temp_dir)
|
||
cfg["ai"] = appconfig.default_config()["ai"]
|
||
cfg["ai"]["backend"] = "cmhub"
|
||
cfg["ai"]["cmhub"] = {
|
||
"base_url": "https://cmhub.old",
|
||
"title_alias": "title-old",
|
||
"image_alias": "image-old",
|
||
"connect_timeout": 9,
|
||
"check_balance_before_batch": False,
|
||
}
|
||
cfg["cmhub_config_path"] = os.path.join(temp_dir, "config", "cmhub.json")
|
||
appconfig.save_cmhub_config(
|
||
{"api_key": "sk-old-secret"},
|
||
path=cfg["cmhub_config_path"],
|
||
)
|
||
appconfig.save_ai_models_config(
|
||
{
|
||
"models": [
|
||
{
|
||
"name": "Text A",
|
||
"category": "text",
|
||
"enabled": True,
|
||
"url": "",
|
||
"model": "",
|
||
"api_key": "",
|
||
"api_type": "chat",
|
||
"connect_timeout_seconds": 30,
|
||
"timeout_seconds": 0,
|
||
"extra_body": {},
|
||
},
|
||
{
|
||
"name": "Image A",
|
||
"category": "image",
|
||
"enabled": True,
|
||
"url": "",
|
||
"model": "",
|
||
"api_key": "",
|
||
"api_type": "auto",
|
||
"connect_timeout_seconds": 30,
|
||
"timeout_seconds": 0,
|
||
"extra_body": {},
|
||
},
|
||
]
|
||
},
|
||
path=cfg["ai_models_path"],
|
||
)
|
||
tab = SettingsTab(
|
||
config=cfg,
|
||
config_path=cfg["config_path"],
|
||
ai_models_path=cfg["ai_models_path"],
|
||
)
|
||
self.addCleanup(tab.close)
|
||
|
||
self.assertEqual("cmhub", tab.backend_combo.currentData())
|
||
self.assertTrue(tab.backend_combo.isHidden())
|
||
self.assertTrue(tab.model_picker_panel.isHidden())
|
||
self.assertTrue(tab.direct_role_panel.isHidden())
|
||
self.assertFalse(tab.cmhub_panel.isHidden())
|
||
self.assertIn("不要带 /api", tab.cmhub_base_url_hint_label.text())
|
||
self.assertEqual("https://cmhub.old", tab.cmhub_base_url_edit.text())
|
||
self.assertEqual("sk-old-secret", tab.cmhub_api_key_edit.text())
|
||
self.assertEqual(QLineEdit.Password, tab.cmhub_api_key_edit.echoMode())
|
||
self.assertEqual("title-old", tab.cmhub_title_alias_combo.currentData())
|
||
self.assertEqual("image-old", tab.cmhub_image_alias_combo.currentData())
|
||
|
||
tab.cmhub_base_url_edit.setText("https://cmhub.example.com/api/v1/")
|
||
tab.cmhub_api_key_edit.setText("sk-new-secret")
|
||
tab.cmhub_connect_timeout_spin.setValue(12)
|
||
tab.cmhub_check_balance_checkbox.setChecked(True)
|
||
tab._populate_cmhub_alias_combos(
|
||
[
|
||
{
|
||
"alias": "title-standard",
|
||
"operation_type": "title",
|
||
"requires_image": False,
|
||
"pricing_status": "priced",
|
||
"prices": [{"resolution": "1K", "points_cost": 1}],
|
||
},
|
||
{
|
||
"alias": "image-standard",
|
||
"operation_type": "image",
|
||
"requires_image": True,
|
||
"pricing_status": "priced",
|
||
"prices": [{"resolution": "1K", "points_cost": 5}],
|
||
},
|
||
],
|
||
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("data/config/cmhub.json", warning.call_args[0][2])
|
||
info.assert_called_once_with(tab, "保存设置", "设置已保存")
|
||
saved = appconfig.load_config(cfg["config_path"])
|
||
self.assertEqual("cmhub", saved["ai"]["backend"])
|
||
self.assertEqual("https://cmhub.example.com", saved["ai"]["cmhub"]["base_url"])
|
||
self.assertEqual("https://cmhub.example.com", tab.cmhub_base_url_edit.text())
|
||
self.assertEqual("title-standard", saved["ai"]["cmhub"]["title_alias"])
|
||
self.assertEqual("image-standard", saved["ai"]["cmhub"]["image_alias"])
|
||
self.assertEqual(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("默认档", tab.cmhub_title_alias_combo.itemText(0))
|
||
self.assertIn("512:1点", tab.cmhub_title_alias_combo.itemText(0))
|
||
self.assertTrue(any("需参考图" in label for label in image_labels))
|
||
self.assertTrue(any("默认档" in label for label in image_labels))
|
||
self.assertIn("余额 55", tab.cmhub_result_label.text())
|
||
|
||
self.assert_removed(temp_dir)
|
||
|
||
def test_settings_tab_cmhub_success_message_shows_account_name(self):
|
||
with self.make_temp_dir() as temp_dir:
|
||
cfg = self.make_config(temp_dir)
|
||
cfg["ai"] = appconfig.default_config()["ai"]
|
||
cfg["ai"]["backend"] = "cmhub"
|
||
cfg["ai"]["cmhub"] = {
|
||
"base_url": "https://cmhub.example.com",
|
||
"title_alias": "",
|
||
"image_alias": "",
|
||
"connect_timeout": 10,
|
||
"check_balance_before_batch": False,
|
||
}
|
||
statuses = []
|
||
tab = SettingsTab(
|
||
config=cfg,
|
||
config_path=cfg["config_path"],
|
||
ai_models_path=cfg["ai_models_path"],
|
||
status_callback=statuses.append,
|
||
)
|
||
self.addCleanup(tab.close)
|
||
models = [
|
||
{"alias": "title-priced", "operation_type": "title", "pricing_status": "priced"},
|
||
{"alias": "image-priced", "operation_type": "image", "pricing_status": "priced"},
|
||
]
|
||
|
||
tab._on_cmhub_finished(
|
||
{
|
||
"ok": True,
|
||
"models": models,
|
||
"balance": {
|
||
"user": "cmhub_user",
|
||
"points_balance": 66,
|
||
"account": {"username": "cmhub_user", "display_name": "主账号"},
|
||
},
|
||
"points_balance": 66,
|
||
}
|
||
)
|
||
self.assertIn("cmhub 账号「主账号」连接成功", tab.cmhub_result_label.text())
|
||
self.assertNotIn("cmhub 账号「cmhub_user」", tab.cmhub_result_label.text())
|
||
self.assertIn("余额 66", tab.cmhub_result_label.text())
|
||
self.assertEqual(tab.cmhub_result_label.text(), statuses[-1])
|
||
|
||
tab._on_cmhub_finished(
|
||
{
|
||
"ok": True,
|
||
"models": models,
|
||
"balance": {"user": {"name": "备用账号"}, "points_balance": 67},
|
||
"points_balance": 67,
|
||
}
|
||
)
|
||
self.assertIn("cmhub 账号「备用账号」连接成功", tab.cmhub_result_label.text())
|
||
|
||
tab._on_cmhub_finished(
|
||
{
|
||
"ok": True,
|
||
"models": models,
|
||
"balance": {"account": {"email": "owner@example.com"}, "points_balance": 77},
|
||
"points_balance": 77,
|
||
}
|
||
)
|
||
self.assertIn("cmhub 账号「o***r@example.com」连接成功", tab.cmhub_result_label.text())
|
||
self.assertNotIn("owner@example.com", tab.cmhub_result_label.text())
|
||
|
||
tab._on_cmhub_finished({"ok": True, "models": models, "points_balance": 88})
|
||
self.assertIn("cmhub 连接成功", tab.cmhub_result_label.text())
|
||
self.assertNotIn("cmhub 账号", tab.cmhub_result_label.text())
|
||
|
||
self.assert_removed(temp_dir)
|
||
def test_settings_tab_tracks_dirty_state_and_programmatic_cmhub_refresh_is_clean(self):
|
||
with self.make_temp_dir() as temp_dir:
|
||
cfg = self.make_config(temp_dir)
|
||
cfg["ai"] = appconfig.default_config()["ai"]
|
||
cfg["ai"]["backend"] = "cmhub"
|
||
cfg["ai"]["cmhub"] = {
|
||
"base_url": "https://cmhub.old",
|
||
"title_alias": "title-old",
|
||
"image_alias": "image-old",
|
||
"connect_timeout": 9,
|
||
"check_balance_before_batch": False,
|
||
}
|
||
cfg["cmhub_config_path"] = os.path.join(temp_dir, "config", "cmhub.json")
|
||
appconfig.save_cmhub_config({"api_key": "sk-old-secret"}, path=cfg["cmhub_config_path"])
|
||
tab = SettingsTab(config=cfg, config_path=cfg["config_path"], ai_models_path=cfg["ai_models_path"])
|
||
self.addCleanup(tab.close)
|
||
|
||
self.assertFalse(tab.is_dirty())
|
||
self.assertTrue(tab.unsaved_changes_label.isHidden())
|
||
|
||
tab._on_cmhub_finished(
|
||
{
|
||
"ok": True,
|
||
"models": [
|
||
{
|
||
"alias": "title-fresh",
|
||
"operation_type": "title",
|
||
"pricing_status": "priced",
|
||
"prices": [{"resolution": "1K", "points_cost": 1}],
|
||
},
|
||
{
|
||
"alias": "image-fresh",
|
||
"operation_type": "image",
|
||
"pricing_status": "priced",
|
||
"requires_image": True,
|
||
"prices": [{"resolution": "1K", "points_cost": 5}],
|
||
},
|
||
],
|
||
}
|
||
)
|
||
self.assertFalse(tab.is_dirty())
|
||
self.assertIn("记得点『保存设置』", tab.cmhub_result_label.text())
|
||
|
||
tab.cmhub_base_url_edit.setText("https://cmhub.example.com/api/v1/")
|
||
self.assertTrue(tab.is_dirty())
|
||
self.assertFalse(tab.unsaved_changes_label.isHidden())
|
||
|
||
with mock.patch("app.gui.QMessageBox.information") as info:
|
||
self.assertTrue(tab.save_app_settings())
|
||
|
||
info.assert_called_once_with(tab, "保存设置", "设置已保存")
|
||
self.assertFalse(tab.is_dirty())
|
||
self.assertTrue(tab.unsaved_changes_label.isHidden())
|
||
|
||
self.assert_removed(temp_dir)
|
||
|
||
def test_settings_tab_discard_unsaved_changes_reloads_saved_config_and_key(self):
|
||
with self.make_temp_dir() as temp_dir:
|
||
cfg = self.make_config(temp_dir)
|
||
cfg["ai"] = appconfig.default_config()["ai"]
|
||
cfg["ai"]["backend"] = "cmhub"
|
||
cfg["ai"]["cmhub"] = {
|
||
"base_url": "https://cmhub.saved",
|
||
"title_alias": "title-saved",
|
||
"image_alias": "image-saved",
|
||
"connect_timeout": 11,
|
||
"check_balance_before_batch": False,
|
||
}
|
||
cfg["cmhub_config_path"] = os.path.join(temp_dir, "config", "cmhub.json")
|
||
appconfig.save_config(
|
||
{key: value for key, value in cfg.items() if key not in {"config_path", "ai_models_path", "cmhub_config_path"}},
|
||
path=cfg["config_path"],
|
||
)
|
||
appconfig.save_cmhub_config({"api_key": "sk-saved-secret"}, path=cfg["cmhub_config_path"])
|
||
tab = SettingsTab(config=cfg, config_path=cfg["config_path"], ai_models_path=cfg["ai_models_path"])
|
||
self.addCleanup(tab.close)
|
||
|
||
tab.cmhub_base_url_edit.setText("https://cmhub.changed")
|
||
tab.cmhub_api_key_edit.setText("sk-changed-secret")
|
||
self.assertTrue(tab.is_dirty())
|
||
|
||
self.assertTrue(tab.discard_unsaved_changes())
|
||
|
||
self.assertFalse(tab.is_dirty())
|
||
self.assertEqual("https://cmhub.saved", tab.cmhub_base_url_edit.text())
|
||
self.assertEqual("sk-saved-secret", tab.cmhub_api_key_edit.text())
|
||
self.assertTrue(tab.unsaved_changes_label.isHidden())
|
||
|
||
self.assert_removed(temp_dir)
|
||
|
||
def test_main_window_cancelled_tab_change_keeps_user_on_dirty_settings(self):
|
||
with self.make_temp_dir() as temp_dir:
|
||
window = MainWindow(config=self.make_config(temp_dir))
|
||
self.addCleanup(window.close)
|
||
settings_index = TAB_TITLES.index("⑤ 设置")
|
||
window.tabs.setCurrentIndex(settings_index)
|
||
settings_tab = window.tabs.widget(settings_index)
|
||
settings_tab.cmhub_base_url_edit.setText("https://cmhub.changed")
|
||
|
||
message_box, boxes = self.make_fake_message_box("取消")
|
||
with mock.patch("app.gui.main_window.QMessageBox", message_box):
|
||
window.tabs.setCurrentIndex(0)
|
||
|
||
self.assertEqual(1, len(boxes))
|
||
self.assertEqual("未保存更改", boxes[0].title)
|
||
self.assertEqual(["保存", "放弃", "取消"], list(boxes[0].buttons))
|
||
self.assertEqual(settings_index, window.tabs.currentIndex())
|
||
self.assertTrue(settings_tab.is_dirty())
|
||
settings_tab._set_dirty(False)
|
||
|
||
self.assert_removed(temp_dir)
|
||
|
||
def test_main_window_save_failure_keeps_user_on_dirty_settings(self):
|
||
with self.make_temp_dir() as temp_dir:
|
||
window = MainWindow(config=self.make_config(temp_dir))
|
||
self.addCleanup(window.close)
|
||
settings_index = TAB_TITLES.index("⑤ 设置")
|
||
window.tabs.setCurrentIndex(settings_index)
|
||
settings_tab = window.tabs.widget(settings_index)
|
||
settings_tab.cmhub_base_url_edit.setText("https://cmhub.changed")
|
||
|
||
message_box, _ = self.make_fake_message_box("保存")
|
||
with mock.patch("app.gui.main_window.QMessageBox", message_box), \
|
||
mock.patch.object(settings_tab, "save_app_settings", return_value=False) as save_settings:
|
||
window.tabs.setCurrentIndex(0)
|
||
|
||
save_settings.assert_called_once_with()
|
||
self.assertEqual(settings_index, window.tabs.currentIndex())
|
||
self.assertTrue(settings_tab.is_dirty())
|
||
settings_tab._set_dirty(False)
|
||
|
||
self.assert_removed(temp_dir)
|
||
|
||
def test_main_window_discard_tab_change_restores_saved_settings(self):
|
||
with self.make_temp_dir() as temp_dir:
|
||
cfg = self.make_config(temp_dir)
|
||
cfg["ai"] = appconfig.default_config()["ai"]
|
||
cfg["ai"]["backend"] = "cmhub"
|
||
cfg["ai"]["cmhub"] = {
|
||
"base_url": "https://cmhub.saved",
|
||
"title_alias": "title-saved",
|
||
"image_alias": "image-saved",
|
||
"connect_timeout": 10,
|
||
"check_balance_before_batch": False,
|
||
}
|
||
cfg["cmhub_config_path"] = os.path.join(temp_dir, "config", "cmhub.json")
|
||
appconfig.save_config(
|
||
{key: value for key, value in cfg.items() if key not in {"config_path", "ai_models_path", "cmhub_config_path"}},
|
||
path=cfg["config_path"],
|
||
)
|
||
appconfig.save_cmhub_config({"api_key": "sk-saved-secret"}, path=cfg["cmhub_config_path"])
|
||
window = MainWindow(config=cfg, config_path=cfg["config_path"], ai_models_path=cfg["ai_models_path"])
|
||
self.addCleanup(window.close)
|
||
settings_index = TAB_TITLES.index("⑤ 设置")
|
||
window.tabs.setCurrentIndex(settings_index)
|
||
settings_tab = window.tabs.widget(settings_index)
|
||
settings_tab.cmhub_base_url_edit.setText("https://cmhub.changed")
|
||
settings_tab.cmhub_api_key_edit.setText("sk-changed-secret")
|
||
|
||
message_box, _ = self.make_fake_message_box("放弃")
|
||
with mock.patch("app.gui.main_window.QMessageBox", message_box):
|
||
window.tabs.setCurrentIndex(0)
|
||
|
||
self.assertEqual(0, window.tabs.currentIndex())
|
||
self.assertFalse(settings_tab.is_dirty())
|
||
self.assertEqual("https://cmhub.saved", settings_tab.cmhub_base_url_edit.text())
|
||
self.assertEqual("sk-saved-secret", settings_tab.cmhub_api_key_edit.text())
|
||
|
||
self.assert_removed(temp_dir)
|
||
|
||
def test_main_window_close_cancel_ignores_dirty_settings_close(self):
|
||
with self.make_temp_dir() as temp_dir:
|
||
window = MainWindow(config=self.make_config(temp_dir))
|
||
self.addCleanup(window.close)
|
||
settings_index = TAB_TITLES.index("⑤ 设置")
|
||
window.tabs.setCurrentIndex(settings_index)
|
||
settings_tab = window.tabs.widget(settings_index)
|
||
settings_tab.cmhub_base_url_edit.setText("https://cmhub.changed")
|
||
event = SimpleNamespace(accepted=False, ignored=False)
|
||
event.accept = lambda: setattr(event, "accepted", True)
|
||
event.ignore = lambda: setattr(event, "ignored", True)
|
||
|
||
message_box, _ = self.make_fake_message_box("取消")
|
||
with mock.patch("app.gui.main_window.QMessageBox", message_box):
|
||
window.closeEvent(event)
|
||
|
||
self.assertFalse(event.accepted)
|
||
self.assertTrue(event.ignored)
|
||
settings_tab._set_dirty(False)
|
||
|
||
self.assert_removed(temp_dir)
|
||
|
||
def test_generate_tab_has_prompt_editors_and_task_table(self):
|
||
with self.make_temp_dir() as temp_dir:
|
||
title_prompt_path = os.path.join(temp_dir, "title_prompt.txt")
|
||
title_templates_dir = os.path.join(temp_dir, "prompts", "title")
|
||
cover_prompts_dir = os.path.join(temp_dir, "prompts", "cover")
|
||
tab = GenerateTab(
|
||
config=self.make_config(temp_dir),
|
||
title_prompt_path=title_prompt_path,
|
||
cover_prompts_dir=cover_prompts_dir,
|
||
title_templates_dir=title_templates_dir,
|
||
)
|
||
self.addCleanup(tab.close)
|
||
|
||
self.assertIsInstance(tab.title_prompt_edit, QPlainTextEdit)
|
||
self.assertIsInstance(tab.cover_prompt_edit, QPlainTextEdit)
|
||
self.assertIsInstance(tab.task_table, QTableView)
|
||
self.assertIsInstance(tab.generate_mode_combo, QComboBox)
|
||
self.assertEqual("titleTemplateCombo", tab.title_template_combo.objectName())
|
||
self.assertEqual("newTitleTemplateButton", tab.new_title_template_button.objectName())
|
||
self.assertEqual("saveTitleTemplateButton", tab.save_title_template_button.objectName())
|
||
self.assertEqual("titleTemplateActionsButton", tab.title_template_actions_button.objectName())
|
||
self.assertEqual("标题提示词", tab.title_prompt_edit.placeholderText())
|
||
self.assertEqual("封面提示词", tab.cover_prompt_edit.placeholderText())
|
||
self.assertEqual("保存标题提示词", tab.save_title_button.text())
|
||
self.assertEqual("保存模板", tab.save_title_template_button.text())
|
||
self.assertEqual("插入旧标题", tab.insert_old_title_button.text())
|
||
self.assertEqual("开始生成", tab.generate_button.text())
|
||
self.assertEqual("停止", tab.stop_generate_button.text())
|
||
self.assertEqual("重置生成结果", tab.reset_generate_button.text())
|
||
self.assertEqual("打开图片文件夹", tab.open_image_dir_button.text())
|
||
self.assertEqual("openImageDirButton", tab.open_image_dir_button.objectName())
|
||
self.assertIn("当前批次文件夹", tab.open_image_dir_button.toolTip())
|
||
self.assertEqual(
|
||
["只生成标题", "只生成封面", "生成标题和封面"],
|
||
[tab.generate_mode_combo.itemText(index) for index in range(tab.generate_mode_combo.count())],
|
||
)
|
||
self.assertEqual("title", tab.generate_mode_combo.currentData())
|
||
self.assertFalse(tab.stop_generate_button.isEnabled())
|
||
self.assertEqual("进度:标题0/0 · 图片0/0 · 失败0", tab.progress_label.text())
|
||
self.assertIsInstance(tab.title_progress_bar, QProgressBar)
|
||
self.assertIsInstance(tab.cover_progress_bar, QProgressBar)
|
||
self.assertEqual("标题 0/0", tab.title_progress_label.text())
|
||
self.assertEqual("图片 0/0", tab.cover_progress_label.text())
|
||
self.assertEqual("失败 0", tab.failed_progress_label.text())
|
||
self.assertTrue(tab.failed_progress_label.isHidden())
|
||
self.assertEqual("generateTitleElapsedLabel", tab.title_elapsed_label.objectName())
|
||
self.assertEqual("generateCoverElapsedLabel", tab.cover_elapsed_label.objectName())
|
||
self.assertEqual("生标题用时 0 秒", tab.title_elapsed_label.text())
|
||
self.assertEqual("生图用时 0 秒", tab.cover_elapsed_label.text())
|
||
self.assertEqual(tab.title_elapsed_label.width(), tab.cover_elapsed_label.width())
|
||
self.assertEqual("generateCmhubBalanceLabel", tab.cmhub_balance_label.objectName())
|
||
self.assertEqual("cmhub余额:未获取", tab.cmhub_balance_label.text())
|
||
self.assertTrue(tab.cmhub_balance_label.isHidden())
|
||
self.assertEqual(0, tab.title_progress_bar.value())
|
||
self.assertEqual(0, tab.cover_progress_bar.value())
|
||
self.assertEqual(1, tab.title_progress_bar.maximum())
|
||
self.assertEqual(1, tab.cover_progress_bar.maximum())
|
||
self.assertIn("蝦皮台灣站", tab.title_prompt_edit.toPlainText())
|
||
self.assertEqual("选择模板", tab.title_template_combo.currentText())
|
||
self.assertIn("默认", [tab.title_template_combo.itemText(index) for index in range(tab.title_template_combo.count())])
|
||
self.assertEqual("模板操作", tab.title_template_actions_button.text())
|
||
self.assertIs(tab.title_template_actions_menu, tab.title_template_actions_button.menu())
|
||
self.assertEqual(
|
||
["另存为", "重命名", "删除"],
|
||
[action.text() for action in tab.title_template_actions_menu.actions()],
|
||
)
|
||
self.assertEqual("papa1", tab.cover_template_combo.currentText())
|
||
self.assertIn("商品标题:{新标题}", tab.cover_prompt_edit.toPlainText())
|
||
self.assertEqual("保存模板", tab.save_cover_template_button.text())
|
||
self.assertEqual("模板操作", tab.cover_template_actions_button.text())
|
||
self.assertIs(tab.cover_template_actions_menu, tab.cover_template_actions_button.menu())
|
||
self.assertEqual(
|
||
["另存为", "重命名", "删除"],
|
||
[action.text() for action in tab.cover_template_actions_menu.actions()],
|
||
)
|
||
self.assertFalse(hasattr(tab, "save_cover_template_as_button"))
|
||
self.assertFalse(hasattr(tab, "rename_cover_template_button"))
|
||
self.assertFalse(hasattr(tab, "delete_cover_template_button"))
|
||
self.assertEqual(
|
||
["店铺", "商品ID", "旧标题", "新标题", "标题状态", "图片状态"],
|
||
tab.model.HEADERS,
|
||
)
|
||
self.assertEqual("任务 0/0 条", tab.summary_label.text())
|
||
|
||
self.assert_removed(temp_dir)
|
||
|
||
def test_generate_table_splits_title_and_cover_statuses_and_widths(self):
|
||
with self.make_temp_dir() as temp_dir:
|
||
account = SimpleNamespace(alias="papa", account_name="papa 店铺")
|
||
tasks = [
|
||
SimpleNamespace(
|
||
alias="papa",
|
||
account_name="papa 店铺",
|
||
item_id="1001",
|
||
old_title="旧标题1",
|
||
new_title="",
|
||
new_cover_path="",
|
||
stage="imported",
|
||
status="failed",
|
||
last_error="读标题失败:页面错误",
|
||
collect_attempts=1,
|
||
generate_attempts=0,
|
||
apply_attempts=0,
|
||
committed=0,
|
||
),
|
||
SimpleNamespace(
|
||
alias="papa",
|
||
account_name="papa 店铺",
|
||
item_id="1002",
|
||
old_title="旧标题2",
|
||
new_title="",
|
||
new_cover_path="",
|
||
stage="collected",
|
||
status="failed",
|
||
last_error="请求生成标题失败:cmhub 上游失败",
|
||
collect_attempts=1,
|
||
generate_attempts=1,
|
||
apply_attempts=0,
|
||
committed=0,
|
||
),
|
||
SimpleNamespace(
|
||
alias="papa",
|
||
account_name="papa 店铺",
|
||
item_id="1003",
|
||
old_title="旧标题3",
|
||
new_title="新标题3",
|
||
new_cover_path="",
|
||
stage="generated",
|
||
status="failed",
|
||
last_error="请求生成封面失败:等待 cmhub 返回超时",
|
||
collect_attempts=1,
|
||
generate_attempts=2,
|
||
apply_attempts=0,
|
||
committed=0,
|
||
),
|
||
SimpleNamespace(
|
||
alias="papa",
|
||
account_name="papa 店铺",
|
||
item_id="1004",
|
||
old_title="旧标题4",
|
||
new_title="新标题4",
|
||
new_cover_path="",
|
||
stage="generated",
|
||
status="success",
|
||
last_error="",
|
||
collect_attempts=1,
|
||
generate_attempts=1,
|
||
apply_attempts=0,
|
||
committed=0,
|
||
),
|
||
SimpleNamespace(
|
||
alias="papa",
|
||
account_name="papa 店铺",
|
||
item_id="1005",
|
||
old_title="旧标题5",
|
||
new_title="新标题5",
|
||
new_cover_path="new.jpg",
|
||
stage="generated",
|
||
status="success",
|
||
last_error="",
|
||
collect_attempts=1,
|
||
generate_attempts=1,
|
||
apply_attempts=0,
|
||
committed=0,
|
||
),
|
||
]
|
||
model = gui.GenerateTaskTableModel()
|
||
model.set_tasks(tasks, [account])
|
||
|
||
self.assertEqual("采集失败", model.index(0, 4).data())
|
||
self.assertEqual("采集失败", model.index(0, 5).data())
|
||
self.assertEqual("失败", model.index(1, 4).data())
|
||
self.assertEqual("未启用", model.index(1, 5).data())
|
||
self.assertEqual("已生成", model.index(2, 4).data())
|
||
self.assertEqual("失败", model.index(2, 5).data())
|
||
self.assertEqual("已生成", model.index(3, 4).data())
|
||
self.assertEqual("未启用", model.index(3, 5).data())
|
||
|
||
model.set_generate_cover_enabled(True)
|
||
self.assertEqual("未开始", model.index(1, 5).data())
|
||
self.assertEqual("待生成", model.index(3, 5).data())
|
||
self.assertEqual("已生成", model.index(4, 5).data())
|
||
|
||
tab = GenerateTab(config=self.make_config(temp_dir))
|
||
self.addCleanup(tab.close)
|
||
tab.task_table.resize(600, 240)
|
||
tab.task_table.show()
|
||
QApplication.processEvents()
|
||
tab._apply_task_table_column_widths()
|
||
header = tab.task_table.horizontalHeader()
|
||
self.assertLess(header.sectionSize(1), header.sectionSize(0))
|
||
self.assertAlmostEqual(
|
||
header.sectionSize(4),
|
||
header.sectionSize(5),
|
||
delta=5,
|
||
)
|
||
self.assertAlmostEqual(
|
||
header.sectionSize(2),
|
||
header.sectionSize(3),
|
||
delta=5,
|
||
)
|
||
self.assertLess(header.sectionSize(4), header.sectionSize(1))
|
||
self.assertGreater(header.sectionSize(2), header.sectionSize(0))
|
||
|
||
self.assert_removed(temp_dir)
|
||
|
||
def test_generate_tab_opens_current_row_image_directory(self):
|
||
with self.make_temp_dir() as temp_dir:
|
||
cfg = self.make_config(temp_dir)
|
||
accounts.create_account("主店", "alias-a", debug_port=9222, config=cfg)
|
||
accounts.create_account("副店", "alias-b", debug_port=9223, config=cfg)
|
||
batch_id = db.create_batch(["input.xlsx"], path=cfg["db_path"])
|
||
db.insert_tasks(
|
||
batch_id,
|
||
[
|
||
{
|
||
"source_file_abs": os.path.join(temp_dir, "input.xlsx"),
|
||
"source_sheet": "商品",
|
||
"source_row": 2,
|
||
"account_name": "Excel主店",
|
||
"alias": "alias-a",
|
||
"item_id": "51100639510",
|
||
},
|
||
{
|
||
"source_file_abs": os.path.join(temp_dir, "input.xlsx"),
|
||
"source_sheet": "商品",
|
||
"source_row": 3,
|
||
"account_name": "Excel副店",
|
||
"alias": "alias-b",
|
||
"item_id": "51100639511",
|
||
},
|
||
],
|
||
path=cfg["db_path"],
|
||
)
|
||
tasks = db.list_tasks(batch_id=batch_id, path=cfg["db_path"])
|
||
first_dir = os.path.join(temp_dir, "legacy-a")
|
||
second_dir = os.path.join(temp_dir, "legacy-b")
|
||
first_cover = self.write_test_image(os.path.join(first_dir, "old.jpg"))
|
||
second_cover = self.write_test_image(os.path.join(second_dir, "new.jpg"))
|
||
db.set_collected(tasks[0].id, "旧标题A", first_cover, path=cfg["db_path"])
|
||
db.set_collected(tasks[1].id, "旧标题B", "missing-old.jpg", path=cfg["db_path"])
|
||
db.set_generated(tasks[1].id, "新标题B", second_cover, path=cfg["db_path"])
|
||
|
||
tab = GenerateTab(config=cfg)
|
||
self.addCleanup(tab.close)
|
||
selection = tab.task_table.selectionModel()
|
||
selection.select(tab.model.index(0, 0), QItemSelectionModel.Select | QItemSelectionModel.Rows)
|
||
selection.select(tab.model.index(1, 0), QItemSelectionModel.Select | QItemSelectionModel.Rows)
|
||
tab.task_table.setCurrentIndex(tab.model.index(1, 0))
|
||
|
||
with mock.patch(
|
||
"app.gui.tabs.generate.file_manager.open_in_file_manager",
|
||
return_value=second_dir,
|
||
) as open_dir:
|
||
tab.open_image_directory()
|
||
|
||
open_dir.assert_called_once_with(os.path.abspath(second_dir))
|
||
|
||
self.assert_removed(temp_dir)
|
||
|
||
def test_generate_tab_open_image_directory_falls_back_to_canonical_account_dir(self):
|
||
with self.make_temp_dir() as temp_dir:
|
||
cfg = self.make_config(temp_dir)
|
||
account = accounts.create_account("主店", "alias-a", debug_port=9222, config=cfg)
|
||
batch_id = db.create_batch(["input.xlsx"], path=cfg["db_path"])
|
||
db.insert_tasks(
|
||
batch_id,
|
||
[
|
||
{
|
||
"source_file_abs": os.path.join(temp_dir, "input.xlsx"),
|
||
"source_sheet": "商品",
|
||
"source_row": 2,
|
||
"account_name": "Excel主店",
|
||
"alias": "alias-a",
|
||
"item_id": "51100639510",
|
||
},
|
||
],
|
||
path=cfg["db_path"],
|
||
)
|
||
task = db.list_tasks(batch_id=batch_id, path=cfg["db_path"])[0]
|
||
canonical_dir = os.path.dirname(
|
||
image_paths.task_image_path(cfg["image_dir"], task, account=account, suffix="new")
|
||
)
|
||
os.makedirs(canonical_dir, exist_ok=True)
|
||
|
||
tab = GenerateTab(config=cfg)
|
||
self.addCleanup(tab.close)
|
||
tab.task_table.selectRow(0)
|
||
tab.task_table.setCurrentIndex(tab.model.index(0, 0))
|
||
|
||
with mock.patch(
|
||
"app.gui.tabs.generate.file_manager.open_in_file_manager",
|
||
return_value=canonical_dir,
|
||
) as open_dir:
|
||
tab.open_image_directory()
|
||
|
||
open_dir.assert_called_once_with(os.path.abspath(canonical_dir))
|
||
|
||
self.assert_removed(temp_dir)
|
||
|
||
def test_generate_tab_opens_batch_or_root_directory_without_selected_row(self):
|
||
with self.make_temp_dir() as temp_dir:
|
||
cfg = self.make_config(temp_dir)
|
||
db.init_db(cfg["db_path"])
|
||
batch_id = db.create_batch(["input.xlsx"], path=cfg["db_path"])
|
||
image_root = os.path.abspath(cfg["image_dir"])
|
||
batch_dir = os.path.join(image_root, batch_id)
|
||
os.makedirs(batch_dir, exist_ok=True)
|
||
tab = GenerateTab(config=cfg)
|
||
self.addCleanup(tab.close)
|
||
tab.batch_filter.setCurrentIndex(tab.batch_filter.findData(batch_id))
|
||
tab.task_table.clearSelection()
|
||
tab.task_table.setCurrentIndex(QModelIndex())
|
||
|
||
with mock.patch(
|
||
"app.gui.tabs.generate.file_manager.open_in_file_manager",
|
||
return_value=batch_dir,
|
||
) as open_dir:
|
||
tab.open_image_directory()
|
||
|
||
open_dir.assert_called_once_with(os.path.abspath(batch_dir))
|
||
|
||
tab.batch_filter.setCurrentIndex(tab.batch_filter.findData(None))
|
||
tab.task_table.clearSelection()
|
||
tab.task_table.setCurrentIndex(QModelIndex())
|
||
with mock.patch(
|
||
"app.gui.tabs.generate.file_manager.open_in_file_manager",
|
||
return_value=image_root,
|
||
) as open_dir:
|
||
tab.open_image_directory()
|
||
|
||
open_dir.assert_called_once_with(image_root)
|
||
|
||
self.assert_removed(temp_dir)
|
||
|
||
def test_generate_tab_open_image_directory_warns_without_creating_missing_dir(self):
|
||
with self.make_temp_dir() as temp_dir:
|
||
cfg = self.make_config(temp_dir)
|
||
db.init_db(cfg["db_path"])
|
||
batch_id = db.create_batch(["input.xlsx"], path=cfg["db_path"])
|
||
batch_dir = os.path.join(os.path.abspath(cfg["image_dir"]), batch_id)
|
||
tab = GenerateTab(config=cfg)
|
||
self.addCleanup(tab.close)
|
||
tab.batch_filter.setCurrentIndex(tab.batch_filter.findData(batch_id))
|
||
tab.task_table.clearSelection()
|
||
tab.task_table.setCurrentIndex(QModelIndex())
|
||
|
||
with mock.patch("app.gui.tabs.generate.QMessageBox.warning") as warning, mock.patch(
|
||
"app.gui.tabs.generate.file_manager.open_in_file_manager"
|
||
) as open_dir:
|
||
tab.open_image_directory()
|
||
|
||
warning.assert_called_once()
|
||
self.assertIn("该批次还没有图片", warning.call_args[0][2])
|
||
open_dir.assert_not_called()
|
||
self.assertFalse(os.path.exists(batch_dir))
|
||
|
||
self.assert_removed(temp_dir)
|
||
|
||
def test_generate_tab_open_image_directory_warns_when_selected_account_unmatched(self):
|
||
with self.make_temp_dir() as temp_dir:
|
||
cfg = self.make_config(temp_dir)
|
||
db.init_db(cfg["db_path"])
|
||
batch_id = db.create_batch(["input.xlsx"], path=cfg["db_path"])
|
||
db.insert_tasks(
|
||
batch_id,
|
||
[
|
||
{
|
||
"source_file_abs": os.path.join(temp_dir, "input.xlsx"),
|
||
"source_sheet": "商品",
|
||
"source_row": 2,
|
||
"account_name": "Excel主店",
|
||
"alias": "missing-alias",
|
||
"item_id": "51100639510",
|
||
},
|
||
],
|
||
path=cfg["db_path"],
|
||
)
|
||
tab = GenerateTab(config=cfg)
|
||
self.addCleanup(tab.close)
|
||
tab.task_table.selectRow(0)
|
||
tab.task_table.setCurrentIndex(tab.model.index(0, 0))
|
||
|
||
with mock.patch("app.gui.tabs.generate.QMessageBox.warning") as warning, mock.patch(
|
||
"app.gui.tabs.generate.file_manager.open_in_file_manager"
|
||
) as open_dir:
|
||
tab.open_image_directory()
|
||
|
||
warning.assert_called_once()
|
||
self.assertIn("选中任务没有匹配账号", warning.call_args[0][2])
|
||
open_dir.assert_not_called()
|
||
|
||
self.assert_removed(temp_dir)
|
||
|
||
def test_open_in_file_manager_uses_platform_file_manager(self):
|
||
with self.make_temp_dir() as temp_dir:
|
||
target = os.path.join(temp_dir, "images")
|
||
os.makedirs(target)
|
||
|
||
with mock.patch("app.gui.file_manager.sys.platform", "win32"), mock.patch(
|
||
"app.gui.file_manager.os.startfile",
|
||
create=True,
|
||
) as startfile:
|
||
self.assertEqual(os.path.abspath(target), file_manager.open_in_file_manager(target))
|
||
startfile.assert_called_once_with(os.path.abspath(target))
|
||
|
||
with mock.patch("app.gui.file_manager.sys.platform", "darwin"), mock.patch(
|
||
"app.gui.file_manager.subprocess.Popen"
|
||
) as popen:
|
||
self.assertEqual(os.path.abspath(target), file_manager.open_in_file_manager(target))
|
||
popen.assert_called_once_with(
|
||
["open", os.path.abspath(target)],
|
||
stdout=file_manager.subprocess.DEVNULL,
|
||
stderr=file_manager.subprocess.DEVNULL,
|
||
shell=False,
|
||
)
|
||
|
||
with mock.patch("app.gui.file_manager.sys.platform", "linux"), mock.patch(
|
||
"app.gui.file_manager.subprocess.Popen"
|
||
) as popen:
|
||
self.assertEqual(os.path.abspath(target), file_manager.open_in_file_manager(target))
|
||
popen.assert_called_once_with(
|
||
["xdg-open", os.path.abspath(target)],
|
||
stdout=file_manager.subprocess.DEVNULL,
|
||
stderr=file_manager.subprocess.DEVNULL,
|
||
shell=False,
|
||
)
|
||
|
||
with self.assertRaises(FileNotFoundError):
|
||
file_manager.open_in_file_manager(os.path.join(temp_dir, "missing"))
|
||
|
||
self.assert_removed(temp_dir)
|
||
|
||
def test_generate_elapsed_timer_tracks_title_then_cover(self):
|
||
with self.make_temp_dir() as temp_dir:
|
||
tab = GenerateTab(config=self.make_config(temp_dir))
|
||
self.addCleanup(tab.close)
|
||
|
||
self.assertEqual("生标题用时 0 秒", tab.title_elapsed_label.text())
|
||
self.assertEqual("生图用时 0 秒", tab.cover_elapsed_label.text())
|
||
self.assertFalse(tab._generate_elapsed_timer.isActive())
|
||
|
||
with mock.patch("app.gui.tabs.generate.time.monotonic", return_value=100.0):
|
||
tab._start_generation_elapsed(title_total=2, cover_total=1)
|
||
self.assertTrue(tab._generate_elapsed_timer.isActive())
|
||
|
||
with mock.patch("app.gui.tabs.generate.time.monotonic", return_value=103.0):
|
||
tab._refresh_elapsed_labels()
|
||
self.assertEqual("生标题用时 3 秒", tab.title_elapsed_label.text())
|
||
self.assertEqual("生图用时 0 秒", tab.cover_elapsed_label.text())
|
||
|
||
with mock.patch("app.gui.tabs.generate.time.monotonic", return_value=105.0):
|
||
tab._sync_generation_elapsed(
|
||
{
|
||
"total": 3,
|
||
"title_total": 2,
|
||
"title_done": 2,
|
||
"cover_total": 1,
|
||
"cover_done": 0,
|
||
"generate_cover": True,
|
||
}
|
||
)
|
||
self.assertEqual("生标题用时 5 秒", tab.title_elapsed_label.text())
|
||
self.assertEqual("生图用时 0 秒", tab.cover_elapsed_label.text())
|
||
|
||
with mock.patch("app.gui.tabs.generate.time.monotonic", return_value=109.0):
|
||
tab._refresh_elapsed_labels()
|
||
self.assertEqual("生标题用时 5 秒", tab.title_elapsed_label.text())
|
||
self.assertEqual("生图用时 4 秒", tab.cover_elapsed_label.text())
|
||
|
||
with mock.patch("app.gui.tabs.generate.time.monotonic", return_value=112.0):
|
||
tab._finish_generation_elapsed(
|
||
{
|
||
"total": 3,
|
||
"title_total": 2,
|
||
"title_done": 2,
|
||
"cover_total": 1,
|
||
"cover_done": 1,
|
||
"generate_cover": True,
|
||
}
|
||
)
|
||
self.assertEqual("生标题用时 5 秒", tab.title_elapsed_label.text())
|
||
self.assertEqual("生图用时 7 秒", tab.cover_elapsed_label.text())
|
||
self.assertFalse(tab._generate_elapsed_timer.isActive())
|
||
|
||
self.assert_removed(temp_dir)
|
||
|
||
def test_generate_cover_progress_bar_marks_failed_segment(self):
|
||
with self.make_temp_dir() as temp_dir:
|
||
tab = GenerateTab(config=self.make_config(temp_dir))
|
||
self.addCleanup(tab.close)
|
||
|
||
tab._update_generate_progress(
|
||
{
|
||
"total": 13,
|
||
"title_total": 13,
|
||
"title_done": 13,
|
||
"cover_total": 13,
|
||
"cover_done": 11,
|
||
"failed": 2,
|
||
"generate_cover": True,
|
||
}
|
||
)
|
||
|
||
self.assertEqual("图片 11/13,失败 2", tab.cover_progress_label.text())
|
||
self.assertEqual((11, 2, 13), tab.cover_progress_bar.segments())
|
||
self.assertEqual(13, tab.cover_progress_bar.maximum())
|
||
self.assertEqual(13, tab.cover_progress_bar.value())
|
||
|
||
self.assert_removed(temp_dir)
|
||
|
||
def test_generate_finished_shows_chinese_summary_for_failed_images(self):
|
||
with self.make_temp_dir() as temp_dir:
|
||
tab = GenerateTab(config=self.make_config(temp_dir))
|
||
self.addCleanup(tab.close)
|
||
tab._title_elapsed_seconds = 120
|
||
tab._cover_elapsed_seconds = 372
|
||
payload = {
|
||
"total": 13,
|
||
"title_total": 13,
|
||
"title_done": 13,
|
||
"cover_total": 13,
|
||
"cover_done": 11,
|
||
"failed": 2,
|
||
"generate_cover": True,
|
||
}
|
||
|
||
with mock.patch.object(tab, "refresh_tasks"), \
|
||
mock.patch("app.gui.tabs.generate.QMessageBox.warning") as warning, \
|
||
mock.patch("app.gui.tabs.generate.QMessageBox.information") as information:
|
||
tab._on_generate_finished(payload)
|
||
|
||
warning.assert_called_once()
|
||
information.assert_not_called()
|
||
self.assertEqual("AI生成完成,有失败任务", warning.call_args[0][1])
|
||
self.assertIn("标题 13/13", warning.call_args[0][2])
|
||
self.assertIn("图片 11/13", warning.call_args[0][2])
|
||
self.assertIn("失败 2", warning.call_args[0][2])
|
||
self.assertIn("用时 492 秒", warning.call_args[0][2])
|
||
|
||
self.assert_removed(temp_dir)
|
||
|
||
def test_generate_cancelled_shows_chinese_summary_dialog(self):
|
||
with self.make_temp_dir() as temp_dir:
|
||
tab = GenerateTab(config=self.make_config(temp_dir))
|
||
self.addCleanup(tab.close)
|
||
tab._title_elapsed_seconds = 80
|
||
tab._cover_elapsed_seconds = 100
|
||
payload = {
|
||
"total": 13,
|
||
"title_total": 13,
|
||
"title_done": 8,
|
||
"cover_total": 13,
|
||
"cover_done": 5,
|
||
"failed": 1,
|
||
"generate_cover": True,
|
||
"cancelled": True,
|
||
}
|
||
|
||
with mock.patch.object(tab, "refresh_tasks"), \
|
||
mock.patch("app.gui.tabs.generate.QMessageBox.warning") as warning:
|
||
tab._on_generate_cancelled(payload)
|
||
|
||
warning.assert_called_once()
|
||
self.assertEqual("AI生成已停止", warning.call_args[0][1])
|
||
self.assertIn("标题 8/13", warning.call_args[0][2])
|
||
self.assertIn("图片 5/13", warning.call_args[0][2])
|
||
self.assertIn("未完成任务可再次点击开始生成继续处理", warning.call_args[0][2])
|
||
|
||
self.assert_removed(temp_dir)
|
||
|
||
def test_generate_tab_shows_cmhub_balance_and_billing_error(self):
|
||
with self.make_temp_dir() as temp_dir:
|
||
cfg = self.make_config(temp_dir)
|
||
cfg["ai"] = appconfig.ai_config(cfg)
|
||
cfg["ai"]["backend"] = "cmhub"
|
||
statuses = []
|
||
tab = GenerateTab(config=cfg, status_callback=statuses.append)
|
||
self.addCleanup(tab.close)
|
||
|
||
self.assertTrue(tab.cmhub_balance_label.isHidden())
|
||
self.assertEqual("cmhub余额:未获取", tab.cmhub_balance_label.text())
|
||
|
||
tab._on_generate_progress(
|
||
{
|
||
"total": 1,
|
||
"title_done": 1,
|
||
"cover_done": 0,
|
||
"cover_total": 0,
|
||
"failed": 0,
|
||
"generate_cover": False,
|
||
"points_balance": 88,
|
||
}
|
||
)
|
||
self.assertEqual("cmhub余额:88", tab.cmhub_balance_label.text())
|
||
self.assertTrue(tab.cmhub_balance_label.isHidden())
|
||
|
||
with mock.patch("app.gui.QMessageBox.warning") as warning:
|
||
tab._on_generate_finished(
|
||
{
|
||
"total": 1,
|
||
"title_done": 0,
|
||
"cover_done": 0,
|
||
"cover_total": 0,
|
||
"failed": 1,
|
||
"billing_error": {
|
||
"code": "insufficient_points",
|
||
"message": "点数不足,请先充值。本轮未开始任务将停止。",
|
||
},
|
||
}
|
||
)
|
||
|
||
warning.assert_called_once()
|
||
self.assertIn("点数不足,请先充值", warning.call_args[0][2])
|
||
self.assertIn("AI 生成已中止", statuses[-1])
|
||
|
||
self.assert_removed(temp_dir)
|
||
|
||
def test_generate_tab_persists_generate_mode_selection(self):
|
||
with self.make_temp_dir() as temp_dir:
|
||
cfg = self.make_config(temp_dir)
|
||
statuses = []
|
||
tab = GenerateTab(config=cfg, status_callback=statuses.append)
|
||
self.addCleanup(tab.close)
|
||
|
||
self.assertFalse(appconfig.ai_config(cfg)["generate_cover"])
|
||
tab.generate_mode_combo.setCurrentIndex(tab.generate_mode_combo.findData("cover"))
|
||
|
||
saved = appconfig.load_config(cfg["config_path"])
|
||
self.assertTrue(saved["ai"]["generate_cover"])
|
||
self.assertEqual("cover", saved["ai"]["generate_mode"])
|
||
self.assertTrue(cfg["ai"]["generate_cover"])
|
||
self.assertEqual("cover", cfg["ai"]["generate_mode"])
|
||
self.assertIn("只生成封面", statuses[-1])
|
||
|
||
self.assert_removed(temp_dir)
|
||
|
||
def test_generate_tab_refreshes_to_generate_filter_when_mode_changes_to_cover(self):
|
||
with self.make_temp_dir() as temp_dir:
|
||
cfg = self.make_config(temp_dir)
|
||
accounts.create_account("主店", "alias-a", debug_port=9222, config=cfg)
|
||
batch_id = db.create_batch(["input.xlsx"], path=cfg["db_path"])
|
||
db.insert_tasks(
|
||
batch_id,
|
||
[
|
||
{
|
||
"source_file_abs": os.path.join(temp_dir, "input.xlsx"),
|
||
"source_sheet": "商品",
|
||
"source_row": 2,
|
||
"account_name": "Excel主店",
|
||
"alias": "alias-a",
|
||
"item_id": "51100639510",
|
||
}
|
||
],
|
||
path=cfg["db_path"],
|
||
)
|
||
task = db.list_tasks(batch_id=batch_id, path=cfg["db_path"])[0]
|
||
db.set_collected(task.id, "旧标题", "old.jpg", path=cfg["db_path"])
|
||
db.set_generated(task.id, "已有标题", None, path=cfg["db_path"])
|
||
tab = GenerateTab(config=cfg)
|
||
self.addCleanup(tab.close)
|
||
|
||
tab.status_filter.setCurrentIndex(tab.status_filter.findData("to_generate"))
|
||
self.assertEqual(0, tab.model.rowCount())
|
||
|
||
tab.generate_mode_combo.setCurrentIndex(tab.generate_mode_combo.findData("cover"))
|
||
|
||
self.assertEqual(1, tab.model.rowCount())
|
||
self.assertEqual("51100639510", tab.model.task_at(0).item_id)
|
||
|
||
self.assert_removed(temp_dir)
|
||
|
||
def test_generate_tab_seeds_default_prompts_for_new_user_data(self):
|
||
with self.make_temp_dir() as temp_dir:
|
||
cfg = self.make_config(temp_dir)
|
||
title_prompt_path = os.path.join(temp_dir, "title_prompt.txt")
|
||
title_templates_dir = os.path.join(temp_dir, "prompts", "title")
|
||
cover_prompts_dir = os.path.join(temp_dir, "prompts", "cover")
|
||
|
||
tab = GenerateTab(
|
||
config=cfg,
|
||
title_prompt_path=title_prompt_path,
|
||
cover_prompts_dir=cover_prompts_dir,
|
||
title_templates_dir=title_templates_dir,
|
||
)
|
||
self.addCleanup(tab.close)
|
||
|
||
self.assertIn("蝦皮台灣站", tab.title_prompt_edit.toPlainText())
|
||
self.assertEqual("papa1", tab.cover_template_combo.currentText())
|
||
self.assertIn("商品标题:{新标题}", tab.cover_prompt_edit.toPlainText())
|
||
self.assertTrue(os.path.exists(title_prompt_path))
|
||
self.assertTrue(os.path.exists(os.path.join(title_templates_dir, "默认.txt")))
|
||
self.assertTrue(os.path.exists(os.path.join(cover_prompts_dir, "papa1.txt")))
|
||
|
||
self.assert_removed(temp_dir)
|
||
|
||
def test_generate_tab_manages_prompt_files_and_preview(self):
|
||
with self.make_temp_dir() as temp_dir:
|
||
cfg = self.make_config(temp_dir)
|
||
title_prompt_path = os.path.join(temp_dir, "title_prompt.txt")
|
||
title_templates_dir = os.path.join(temp_dir, "prompts", "title")
|
||
cover_prompts_dir = os.path.join(temp_dir, "prompts", "cover")
|
||
prompts.save_title_prompt("标题启动回显", title_prompt_path)
|
||
prompts.save_title_template("标题基础", "模板标题 {旧标题}", title_templates_dir)
|
||
prompts.save_cover_template(
|
||
"基础",
|
||
"把{旧标题}变成{新标题},商品{商品id},店铺{店铺}",
|
||
cover_prompts_dir,
|
||
)
|
||
accounts.create_account("主店", "alias-a", debug_port=9222, config=cfg)
|
||
batch_id = db.create_batch(["input.xlsx"], path=cfg["db_path"])
|
||
db.insert_tasks(
|
||
batch_id,
|
||
[
|
||
{
|
||
"source_file_abs": os.path.join(temp_dir, "input.xlsx"),
|
||
"source_sheet": "商品",
|
||
"source_row": 2,
|
||
"account_name": "Excel主店",
|
||
"alias": "alias-a",
|
||
"item_id": "51100639510",
|
||
}
|
||
],
|
||
path=cfg["db_path"],
|
||
)
|
||
task = db.list_tasks(batch_id=batch_id, path=cfg["db_path"])[0]
|
||
db.set_collected(task.id, "旧标题", "old.jpg", path=cfg["db_path"])
|
||
db.set_generated(task.id, "新标题", "new.jpg", path=cfg["db_path"])
|
||
statuses = []
|
||
tab = GenerateTab(
|
||
config=cfg,
|
||
status_callback=statuses.append,
|
||
title_prompt_path=title_prompt_path,
|
||
cover_prompts_dir=cover_prompts_dir,
|
||
title_templates_dir=title_templates_dir,
|
||
)
|
||
self.addCleanup(tab.close)
|
||
|
||
self.assertEqual("标题启动回显", tab.title_prompt_edit.toPlainText())
|
||
self.assertEqual("选择模板", tab.title_template_combo.currentText())
|
||
self.assertIn("标题基础", prompts.list_title_templates(title_templates_dir))
|
||
self.assertEqual("基础", tab.cover_template_combo.currentText())
|
||
self.assertIn("{旧标题}", tab.cover_prompt_edit.toPlainText())
|
||
|
||
tab.title_prompt_edit.setPlainText("新标题提示词")
|
||
tab.save_title_prompt()
|
||
self.assertEqual("新标题提示词", prompts.load_title_prompt(title_prompt_path))
|
||
tab.title_prompt_edit.setPlainText("AB")
|
||
cursor = tab.title_prompt_edit.textCursor()
|
||
cursor.setPosition(1)
|
||
tab.title_prompt_edit.setTextCursor(cursor)
|
||
tab.insert_old_title_placeholder()
|
||
self.assertEqual("A{旧标题}B", tab.title_prompt_edit.toPlainText())
|
||
self.assertEqual("新标题提示词", prompts.load_title_prompt(title_prompt_path))
|
||
|
||
tab.title_template_combo.setCurrentIndex(tab.title_template_combo.findData("标题基础"))
|
||
self.assertEqual("模板标题 {旧标题}", tab.title_prompt_edit.toPlainText())
|
||
tab.title_prompt_edit.setPlainText("标题另存内容")
|
||
with mock.patch("app.gui.QInputDialog.getText", return_value=("标题另存", True)):
|
||
tab.save_title_template_as_action.trigger()
|
||
self.assertEqual("标题另存", tab.title_template_combo.currentText())
|
||
self.assertEqual(
|
||
"标题另存内容",
|
||
prompts.load_title_template("标题另存", title_templates_dir),
|
||
)
|
||
self.assertEqual("新标题提示词", prompts.load_title_prompt(title_prompt_path))
|
||
|
||
with mock.patch("app.gui.QInputDialog.getText", return_value=("标题改名", True)):
|
||
tab.rename_title_template_action.trigger()
|
||
self.assertEqual("标题改名", tab.title_template_combo.currentText())
|
||
self.assertIn("标题改名", prompts.list_title_templates(title_templates_dir))
|
||
tab.title_prompt_edit.setPlainText("标题模板保存")
|
||
tab.save_title_template()
|
||
self.assertEqual(
|
||
"标题模板保存",
|
||
prompts.load_title_template("标题改名", title_templates_dir),
|
||
)
|
||
|
||
tab.cover_prompt_edit.setPlainText("另存模板 {新标题}")
|
||
with mock.patch("app.gui.QInputDialog.getText", return_value=("另存", True)):
|
||
tab.save_cover_template_as_action.trigger()
|
||
self.assertEqual("另存", tab.cover_template_combo.currentText())
|
||
self.assertEqual("另存模板 {新标题}", prompts.load_cover_template("另存", cover_prompts_dir))
|
||
|
||
with mock.patch("app.gui.QInputDialog.getText", return_value=("改名", True)):
|
||
tab.rename_cover_template_action.trigger()
|
||
self.assertEqual("改名", tab.cover_template_combo.currentText())
|
||
self.assertIn("改名", prompts.list_cover_templates(cover_prompts_dir))
|
||
|
||
tab.cover_prompt_edit.setPlainText("预览 {旧标题} {新标题} {商品id} {店铺}")
|
||
tab.task_table.selectRow(0)
|
||
with mock.patch("app.gui.QMessageBox.information") as info:
|
||
tab.preview_cover_prompt()
|
||
self.assertIn("预览 旧标题 新标题 51100639510 主店", info.call_args[0][2])
|
||
|
||
with mock.patch("app.gui.QDialog.exec", return_value=0) as exec_dialog:
|
||
tab.show_task_images(tab.model.index(0, 0))
|
||
exec_dialog.assert_called_once()
|
||
|
||
tab.cover_prompt_edit.moveCursor(QTextCursor.End)
|
||
tab.insert_title_placeholder()
|
||
self.assertTrue(tab.cover_prompt_edit.toPlainText().endswith("{新标题}"))
|
||
|
||
with mock.patch("app.gui.QMessageBox.question", return_value=gui.QMessageBox.Yes):
|
||
tab.delete_cover_template_action.trigger()
|
||
self.assertNotIn("改名", prompts.list_cover_templates(cover_prompts_dir))
|
||
|
||
with mock.patch("app.gui.QMessageBox.question", return_value=gui.QMessageBox.Yes):
|
||
tab.delete_title_template_action.trigger()
|
||
self.assertNotIn("标题改名", prompts.list_title_templates(title_templates_dir))
|
||
|
||
self.assert_removed(temp_dir)
|
||
|
||
def test_cover_gallery_lists_candidates_and_selects_current_cover(self):
|
||
with self.make_temp_dir() as temp_dir:
|
||
cfg = self.make_config(temp_dir)
|
||
account = accounts.create_account("主店", "alias-a", debug_port=9222, config=cfg)
|
||
db.init_db(cfg["db_path"])
|
||
batch_id = db.create_batch(["input.xlsx"], path=cfg["db_path"])
|
||
db.insert_tasks(
|
||
batch_id,
|
||
[
|
||
{
|
||
"source_file_abs": os.path.join(temp_dir, "input.xlsx"),
|
||
"source_sheet": "商品",
|
||
"source_row": 2,
|
||
"account_name": "Excel主店",
|
||
"alias": "alias-a",
|
||
"item_id": "51100639510",
|
||
}
|
||
],
|
||
path=cfg["db_path"],
|
||
)
|
||
task = db.list_tasks(batch_id=batch_id, path=cfg["db_path"])[0]
|
||
old_cover = self.write_test_image(os.path.join(temp_dir, "old.jpg"))
|
||
canonical = image_paths.task_image_path(cfg["image_dir"], task, account, "new")
|
||
prefix = os.path.splitext(canonical)[0]
|
||
archive_newer = self.write_test_image(f"{prefix}_20260709101000.jpg")
|
||
archive_older = self.write_test_image(f"{prefix}_20260709094800.jpg")
|
||
old_candidate = f"{prefix.replace('_new', '_old')}.jpg"
|
||
self.write_test_image(canonical)
|
||
self.write_test_image(old_candidate)
|
||
db.set_collected(task.id, "旧标题", old_cover, path=cfg["db_path"])
|
||
db.set_generated(task.id, "新标题", canonical, path=cfg["db_path"])
|
||
task = db.get_task(task.id, path=cfg["db_path"])
|
||
|
||
dialog = CoverGalleryDialog(task, cfg["image_dir"], cfg["db_path"], account=account)
|
||
self.addCleanup(dialog.close)
|
||
|
||
self.assertEqual([canonical, archive_newer, archive_older], dialog.candidates)
|
||
self.assertTrue(dialog.candidate_buttons[canonical].isChecked())
|
||
self.assertEqual(canonical, dialog.selected_path)
|
||
self.assertNotIn(old_candidate, dialog.candidates)
|
||
renamed_archive = archive_newer.replace(".jpg", "_renamed.jpg")
|
||
os.rename(archive_newer, renamed_archive)
|
||
self.assertTrue(os.path.exists(renamed_archive))
|
||
|
||
self.assert_removed(temp_dir)
|
||
|
||
def test_cover_gallery_candidate_content_width_fits_multiple_candidates(self):
|
||
with self.make_temp_dir() as temp_dir:
|
||
cfg, account, tasks, canonicals, _archives = self._cover_gallery_task_set(temp_dir, count=1)
|
||
prefix = os.path.splitext(canonicals[0])[0]
|
||
self.write_test_image(f"{prefix}_20260709101100.jpg")
|
||
self.write_test_image(f"{prefix}_20260709101200.jpg")
|
||
|
||
dialog = CoverGalleryDialog(tasks[0], cfg["image_dir"], cfg["db_path"], account=account)
|
||
self.addCleanup(dialog.close)
|
||
|
||
self.assertGreaterEqual(len(dialog.candidates), 4)
|
||
expected_width = (
|
||
len(dialog.candidates) * (dialog.THUMBNAIL_SIZE + 16)
|
||
+ (len(dialog.candidates) - 1) * dialog.candidate_layout.spacing()
|
||
)
|
||
self.assertIs(dialog.candidate_scroll.widget(), dialog.candidate_content)
|
||
self.assertGreaterEqual(dialog.candidate_content.minimumWidth(), expected_width)
|
||
self.assertGreaterEqual(dialog.candidate_content.width(), expected_width)
|
||
|
||
self.assert_removed(temp_dir)
|
||
|
||
def test_cover_gallery_switch_task_resyncs_candidate_content_width(self):
|
||
with self.make_temp_dir() as temp_dir:
|
||
cfg, account, tasks, canonicals, archives = self._cover_gallery_task_set(temp_dir, count=2)
|
||
os.remove(archives[0])
|
||
prefix = os.path.splitext(canonicals[1])[0]
|
||
self.write_test_image(f"{prefix}_20260709101100.jpg")
|
||
self.write_test_image(f"{prefix}_20260709101200.jpg")
|
||
dialog = CoverGalleryDialog(
|
||
tasks[0],
|
||
cfg["image_dir"],
|
||
cfg["db_path"],
|
||
account=account,
|
||
visible_tasks=tasks,
|
||
task_index=0,
|
||
account_by_alias={"alias-a": account},
|
||
)
|
||
self.addCleanup(dialog.close)
|
||
initial_width = dialog.candidate_content.minimumWidth()
|
||
|
||
self.assertTrue(dialog.switch_task(1))
|
||
|
||
expected_width = (
|
||
len(dialog.candidates) * (dialog.THUMBNAIL_SIZE + 16)
|
||
+ (len(dialog.candidates) - 1) * dialog.candidate_layout.spacing()
|
||
)
|
||
self.assertEqual(tasks[1].id, dialog.task.id)
|
||
self.assertGreater(len(dialog.candidates), 2)
|
||
self.assertGreater(dialog.candidate_content.minimumWidth(), initial_width)
|
||
self.assertGreaterEqual(dialog.candidate_content.minimumWidth(), expected_width)
|
||
self.assertGreaterEqual(dialog.candidate_content.width(), expected_width)
|
||
|
||
self.assert_removed(temp_dir)
|
||
|
||
def test_cover_gallery_save_switches_current_cover(self):
|
||
with self.make_temp_dir() as temp_dir:
|
||
cfg = self.make_config(temp_dir)
|
||
account = accounts.create_account("主店", "alias-a", debug_port=9222, config=cfg)
|
||
db.init_db(cfg["db_path"])
|
||
batch_id = db.create_batch(["input.xlsx"], path=cfg["db_path"])
|
||
db.insert_tasks(
|
||
batch_id,
|
||
[
|
||
{
|
||
"source_file_abs": os.path.join(temp_dir, "input.xlsx"),
|
||
"source_sheet": "商品",
|
||
"source_row": 2,
|
||
"account_name": "Excel主店",
|
||
"alias": "alias-a",
|
||
"item_id": "51100639510",
|
||
}
|
||
],
|
||
path=cfg["db_path"],
|
||
)
|
||
task = db.list_tasks(batch_id=batch_id, path=cfg["db_path"])[0]
|
||
canonical = image_paths.task_image_path(cfg["image_dir"], task, account, "new")
|
||
selected = self.write_test_image(os.path.splitext(canonical)[0] + "_20260709101000.jpg")
|
||
self.write_test_image(canonical)
|
||
db.set_generated(task.id, "新标题", canonical, path=cfg["db_path"])
|
||
task = db.get_task(task.id, path=cfg["db_path"])
|
||
dialog = CoverGalleryDialog(task, cfg["image_dir"], cfg["db_path"], account=account)
|
||
self.addCleanup(dialog.close)
|
||
|
||
dialog.candidate_buttons[selected].setChecked(True)
|
||
self.assertTrue(dialog.save_selection())
|
||
|
||
updated = db.get_task(task.id, path=cfg["db_path"])
|
||
self.assertEqual(os.path.abspath(selected), updated.new_cover_path)
|
||
self.assertEqual("generated", updated.stage)
|
||
self.assertEqual("pending", updated.status)
|
||
self.assertTrue(dialog.changed)
|
||
self.assertEqual(0, dialog.result())
|
||
self.assertEqual("关闭", dialog.cancel_button.text())
|
||
self.assertEqual("已保存为本次更新封面", dialog.save_hint_label.text())
|
||
self.assertEqual(os.path.abspath(selected), dialog.current_path)
|
||
self.assertTrue(dialog.candidate_buttons[selected].isChecked())
|
||
self.assertEqual("当前生效", dialog.candidate_buttons[selected].text())
|
||
|
||
self.assert_removed(temp_dir)
|
||
|
||
def test_cover_gallery_committed_cancel_does_not_write_db(self):
|
||
with self.make_temp_dir() as temp_dir:
|
||
cfg = self.make_config(temp_dir)
|
||
account = accounts.create_account("主店", "alias-a", debug_port=9222, config=cfg)
|
||
db.init_db(cfg["db_path"])
|
||
batch_id = db.create_batch(["input.xlsx"], path=cfg["db_path"])
|
||
db.insert_tasks(
|
||
batch_id,
|
||
[
|
||
{
|
||
"source_file_abs": os.path.join(temp_dir, "input.xlsx"),
|
||
"source_sheet": "商品",
|
||
"source_row": 2,
|
||
"account_name": "Excel主店",
|
||
"alias": "alias-a",
|
||
"item_id": "51100639510",
|
||
}
|
||
],
|
||
path=cfg["db_path"],
|
||
)
|
||
task = db.list_tasks(batch_id=batch_id, path=cfg["db_path"])[0]
|
||
canonical = image_paths.task_image_path(cfg["image_dir"], task, account, "new")
|
||
selected = self.write_test_image(os.path.splitext(canonical)[0] + "_20260709101000.jpg")
|
||
self.write_test_image(canonical)
|
||
db.set_generated(task.id, "新标题", canonical, path=cfg["db_path"])
|
||
db.set_applied(task.id, True, path=cfg["db_path"])
|
||
task = db.get_task(task.id, path=cfg["db_path"])
|
||
dialog = CoverGalleryDialog(task, cfg["image_dir"], cfg["db_path"], account=account)
|
||
self.addCleanup(dialog.close)
|
||
|
||
dialog.candidate_buttons[selected].setChecked(True)
|
||
message_box, boxes = self.make_fake_message_box("取消")
|
||
with mock.patch("app.gui.tabs.generate.QMessageBox", message_box):
|
||
self.assertFalse(dialog.save_selection())
|
||
|
||
updated = db.get_task(task.id, path=cfg["db_path"])
|
||
self.assertEqual(canonical, updated.new_cover_path)
|
||
self.assertEqual("applied", updated.stage)
|
||
self.assertEqual(1, updated.committed)
|
||
self.assertIn("重复更新会再次提交线上", boxes[0].text)
|
||
self.assertFalse(dialog.changed)
|
||
dialog.selected_path = dialog.current_path
|
||
|
||
self.assert_removed(temp_dir)
|
||
|
||
def test_cover_gallery_handles_missing_current_and_empty_candidates(self):
|
||
with self.make_temp_dir() as temp_dir:
|
||
cfg = self.make_config(temp_dir)
|
||
account = accounts.create_account("主店", "alias-a", debug_port=9222, config=cfg)
|
||
db.init_db(cfg["db_path"])
|
||
batch_id = db.create_batch(["input.xlsx"], path=cfg["db_path"])
|
||
db.insert_tasks(
|
||
batch_id,
|
||
[
|
||
{
|
||
"source_file_abs": os.path.join(temp_dir, "input.xlsx"),
|
||
"source_sheet": "商品",
|
||
"source_row": 2,
|
||
"account_name": "Excel主店",
|
||
"alias": "alias-a",
|
||
"item_id": "51100639510",
|
||
},
|
||
{
|
||
"source_file_abs": os.path.join(temp_dir, "input.xlsx"),
|
||
"source_sheet": "商品",
|
||
"source_row": 3,
|
||
"account_name": "Excel主店",
|
||
"alias": "alias-a",
|
||
"item_id": "51100639511",
|
||
},
|
||
],
|
||
path=cfg["db_path"],
|
||
)
|
||
first, second = db.list_tasks(batch_id=batch_id, path=cfg["db_path"])
|
||
missing_current = os.path.join(temp_dir, "missing.jpg")
|
||
db.set_generated(first.id, "新标题A", missing_current, path=cfg["db_path"])
|
||
first_candidate = image_paths.task_image_path(cfg["image_dir"], first, account, "new")
|
||
self.write_test_image(first_candidate)
|
||
first = db.get_task(first.id, path=cfg["db_path"])
|
||
|
||
missing_dialog = CoverGalleryDialog(first, cfg["image_dir"], cfg["db_path"], account=account)
|
||
self.addCleanup(missing_dialog.close)
|
||
self.assertIsNone(missing_dialog.selected_path)
|
||
self.assertFalse(missing_dialog.save_button.isEnabled())
|
||
self.assertIn("当前生效封面文件不存在", missing_dialog.status_label.text())
|
||
|
||
db.set_generated(second.id, "新标题B", os.path.join(temp_dir, "none.jpg"), path=cfg["db_path"])
|
||
second = db.get_task(second.id, path=cfg["db_path"])
|
||
empty_dialog = CoverGalleryDialog(second, cfg["image_dir"], cfg["db_path"], account=account)
|
||
self.addCleanup(empty_dialog.close)
|
||
self.assertEqual([], empty_dialog.candidates)
|
||
self.assertFalse(empty_dialog.save_button.isEnabled())
|
||
self.assertIn("暂无生成封面图片", empty_dialog.status_label.text())
|
||
|
||
self.assert_removed(temp_dir)
|
||
|
||
def test_original_image_dialog_title_contains_resolution(self):
|
||
with self.make_temp_dir() as temp_dir:
|
||
image_path = self.write_test_image(os.path.join(temp_dir, "cover.jpg"), width=64, height=32)
|
||
|
||
dialog = OriginalImageDialog(image_path)
|
||
self.addCleanup(dialog.close)
|
||
|
||
self.assertIn("cover.jpg", dialog.windowTitle())
|
||
self.assertIn("64x32", dialog.windowTitle())
|
||
|
||
self.assert_removed(temp_dir)
|
||
|
||
def test_cover_gallery_previous_next_switch_visible_tasks_without_resizing(self):
|
||
with self.make_temp_dir() as temp_dir:
|
||
cfg, account, tasks, canonicals, _archives = self._cover_gallery_task_set(temp_dir, count=3)
|
||
dialog = CoverGalleryDialog(
|
||
tasks[1],
|
||
cfg["image_dir"],
|
||
cfg["db_path"],
|
||
account=account,
|
||
visible_tasks=tasks,
|
||
task_index=1,
|
||
account_by_alias={"alias-a": account},
|
||
)
|
||
self.addCleanup(dialog.close)
|
||
dialog.resize(820, 520)
|
||
dialog.move(25, 35)
|
||
size = dialog.size()
|
||
position = dialog.pos()
|
||
|
||
self.assertTrue(dialog.previous_button.isEnabled())
|
||
self.assertTrue(dialog.next_button.isEnabled())
|
||
self.assertTrue(dialog.switch_task(1))
|
||
|
||
self.assertEqual(tasks[2].id, dialog.task.id)
|
||
self.assertTrue(dialog.previous_button.isEnabled())
|
||
self.assertFalse(dialog.next_button.isEnabled())
|
||
self.assertEqual(canonicals[2], dialog.selected_path)
|
||
self.assertEqual(size, dialog.size())
|
||
self.assertEqual(position, dialog.pos())
|
||
|
||
self.assert_removed(temp_dir)
|
||
|
||
def test_cover_gallery_arrow_keys_switch_tasks_without_changing_radio_choice(self):
|
||
with self.make_temp_dir() as temp_dir:
|
||
cfg, account, tasks, canonicals, archives = self._cover_gallery_task_set(temp_dir, count=2)
|
||
dialog = CoverGalleryDialog(
|
||
tasks[0],
|
||
cfg["image_dir"],
|
||
cfg["db_path"],
|
||
account=account,
|
||
visible_tasks=tasks,
|
||
task_index=0,
|
||
account_by_alias={"alias-a": account},
|
||
)
|
||
self.addCleanup(dialog.close)
|
||
dialog.candidate_buttons[canonicals[0]].setFocus()
|
||
event = QKeyEvent(QKeyEvent.KeyPress, gui.Qt.Key_Down, gui.Qt.NoModifier)
|
||
|
||
QApplication.sendEvent(dialog.candidate_buttons[canonicals[0]], event)
|
||
|
||
self.assertTrue(event.isAccepted())
|
||
self.assertEqual(tasks[1].id, dialog.task.id)
|
||
self.assertEqual(canonicals[1], dialog.selected_path)
|
||
self.assertFalse(dialog.candidate_buttons[archives[1]].isChecked())
|
||
|
||
self.assert_removed(temp_dir)
|
||
|
||
def test_cover_gallery_arrow_keys_switch_when_button_has_focus(self):
|
||
with self.make_temp_dir() as temp_dir:
|
||
cfg, account, tasks, canonicals, _archives = self._cover_gallery_task_set(temp_dir, count=2)
|
||
dialog = CoverGalleryDialog(
|
||
tasks[0],
|
||
cfg["image_dir"],
|
||
cfg["db_path"],
|
||
account=account,
|
||
visible_tasks=tasks,
|
||
task_index=0,
|
||
account_by_alias={"alias-a": account},
|
||
)
|
||
self.addCleanup(dialog.close)
|
||
dialog.next_button.setFocus()
|
||
event = QKeyEvent(QKeyEvent.KeyPress, gui.Qt.Key_Down, gui.Qt.NoModifier)
|
||
|
||
QApplication.sendEvent(dialog.next_button, event)
|
||
|
||
self.assertTrue(event.isAccepted())
|
||
self.assertEqual(tasks[1].id, dialog.task.id)
|
||
self.assertEqual(canonicals[1], dialog.selected_path)
|
||
|
||
self.assert_removed(temp_dir)
|
||
|
||
def test_cover_gallery_arrow_keys_do_not_switch_when_text_input_has_focus(self):
|
||
with self.make_temp_dir() as temp_dir:
|
||
cfg, account, tasks, _canonicals, _archives = self._cover_gallery_task_set(temp_dir, count=2)
|
||
dialog = CoverGalleryDialog(
|
||
tasks[0],
|
||
cfg["image_dir"],
|
||
cfg["db_path"],
|
||
account=account,
|
||
visible_tasks=tasks,
|
||
task_index=0,
|
||
account_by_alias={"alias-a": account},
|
||
)
|
||
self.addCleanup(dialog.close)
|
||
input_widget = QLineEdit(dialog)
|
||
event = QKeyEvent(QKeyEvent.KeyPress, gui.Qt.Key_Down, gui.Qt.NoModifier)
|
||
|
||
with mock.patch(
|
||
"app.gui.tabs.generate.QApplication.focusWidget",
|
||
return_value=input_widget,
|
||
):
|
||
QApplication.sendEvent(input_widget, event)
|
||
|
||
self.assertEqual(tasks[0].id, dialog.task.id)
|
||
|
||
self.assert_removed(temp_dir)
|
||
|
||
def test_cover_gallery_unsaved_switch_save_discard_and_cancel_paths(self):
|
||
with self.make_temp_dir() as temp_dir:
|
||
cfg, account, tasks, canonicals, archives = self._cover_gallery_task_set(temp_dir, count=2)
|
||
|
||
save_dialog = CoverGalleryDialog(
|
||
tasks[0],
|
||
cfg["image_dir"],
|
||
cfg["db_path"],
|
||
account=account,
|
||
visible_tasks=tasks,
|
||
task_index=0,
|
||
account_by_alias={"alias-a": account},
|
||
)
|
||
self.addCleanup(save_dialog.close)
|
||
save_dialog.candidate_buttons[archives[0]].setChecked(True)
|
||
message_box, _ = self.make_fake_message_box("保存")
|
||
with mock.patch("app.gui.tabs.generate.QMessageBox", message_box):
|
||
self.assertTrue(save_dialog.switch_task(1))
|
||
self.assertEqual(os.path.abspath(archives[0]), db.get_task(tasks[0].id, path=cfg["db_path"]).new_cover_path)
|
||
self.assertEqual(tasks[1].id, save_dialog.task.id)
|
||
|
||
db.update_generated_cover(tasks[0].id, canonicals[0], path=cfg["db_path"])
|
||
tasks = [db.get_task(task.id, path=cfg["db_path"]) for task in tasks]
|
||
discard_dialog = CoverGalleryDialog(
|
||
tasks[0],
|
||
cfg["image_dir"],
|
||
cfg["db_path"],
|
||
account=account,
|
||
visible_tasks=tasks,
|
||
task_index=0,
|
||
account_by_alias={"alias-a": account},
|
||
)
|
||
self.addCleanup(discard_dialog.close)
|
||
discard_dialog.candidate_buttons[archives[0]].setChecked(True)
|
||
message_box, _ = self.make_fake_message_box("不保存")
|
||
with mock.patch("app.gui.tabs.generate.QMessageBox", message_box):
|
||
self.assertTrue(discard_dialog.switch_task(1))
|
||
self.assertEqual(canonicals[0], db.get_task(tasks[0].id, path=cfg["db_path"]).new_cover_path)
|
||
self.assertEqual(tasks[1].id, discard_dialog.task.id)
|
||
|
||
cancel_dialog = CoverGalleryDialog(
|
||
tasks[0],
|
||
cfg["image_dir"],
|
||
cfg["db_path"],
|
||
account=account,
|
||
visible_tasks=tasks,
|
||
task_index=0,
|
||
account_by_alias={"alias-a": account},
|
||
)
|
||
self.addCleanup(cancel_dialog.close)
|
||
cancel_dialog.candidate_buttons[archives[0]].setChecked(True)
|
||
message_box, _ = self.make_fake_message_box("取消")
|
||
with mock.patch("app.gui.tabs.generate.QMessageBox", message_box):
|
||
self.assertFalse(cancel_dialog.switch_task(1))
|
||
self.assertEqual(canonicals[0], db.get_task(tasks[0].id, path=cfg["db_path"]).new_cover_path)
|
||
self.assertEqual(tasks[0].id, cancel_dialog.task.id)
|
||
cancel_dialog.selected_path = cancel_dialog.current_path
|
||
|
||
self.assert_removed(temp_dir)
|
||
|
||
def test_cover_gallery_unsaved_close_uses_same_three_choice_guard(self):
|
||
class FakeCloseEvent:
|
||
def __init__(self):
|
||
self.accepted = False
|
||
self.ignored = False
|
||
|
||
def accept(self):
|
||
self.accepted = True
|
||
|
||
def ignore(self):
|
||
self.ignored = True
|
||
|
||
with self.make_temp_dir() as temp_dir:
|
||
cfg, account, tasks, canonicals, archives = self._cover_gallery_task_set(temp_dir, count=1)
|
||
dialog = CoverGalleryDialog(tasks[0], cfg["image_dir"], cfg["db_path"], account=account)
|
||
self.addCleanup(dialog.close)
|
||
dialog.candidate_buttons[archives[0]].setChecked(True)
|
||
event = FakeCloseEvent()
|
||
message_box, _ = self.make_fake_message_box("取消")
|
||
with mock.patch("app.gui.tabs.generate.QMessageBox", message_box):
|
||
dialog.closeEvent(event)
|
||
|
||
self.assertTrue(event.ignored)
|
||
self.assertFalse(event.accepted)
|
||
self.assertEqual(canonicals[0], db.get_task(tasks[0].id, path=cfg["db_path"]).new_cover_path)
|
||
|
||
message_box, _ = self.make_fake_message_box("不保存")
|
||
event = FakeCloseEvent()
|
||
with mock.patch("app.gui.tabs.generate.QMessageBox", message_box):
|
||
dialog.closeEvent(event)
|
||
self.assertTrue(event.accepted)
|
||
dialog.selected_path = dialog.current_path
|
||
|
||
self.assert_removed(temp_dir)
|
||
|
||
def test_cover_gallery_unsaved_save_for_committed_task_keeps_warning(self):
|
||
with self.make_temp_dir() as temp_dir:
|
||
cfg, account, tasks, _canonicals, archives = self._cover_gallery_task_set(temp_dir, count=2)
|
||
db.set_applied(tasks[0].id, True, path=cfg["db_path"])
|
||
tasks = [db.get_task(task.id, path=cfg["db_path"]) for task in tasks]
|
||
dialog = CoverGalleryDialog(
|
||
tasks[0],
|
||
cfg["image_dir"],
|
||
cfg["db_path"],
|
||
account=account,
|
||
visible_tasks=tasks,
|
||
task_index=0,
|
||
account_by_alias={"alias-a": account},
|
||
)
|
||
self.addCleanup(dialog.close)
|
||
dialog.candidate_buttons[archives[0]].setChecked(True)
|
||
message_box, boxes = self.make_sequence_message_box(["保存", "确认保存"])
|
||
with mock.patch("app.gui.tabs.generate.QMessageBox", message_box):
|
||
self.assertTrue(dialog.switch_task(1))
|
||
|
||
self.assertEqual(os.path.abspath(archives[0]), db.get_task(tasks[0].id, path=cfg["db_path"]).new_cover_path)
|
||
self.assertIn("未保存", boxes[0].title)
|
||
self.assertIn("不会回滚蝦皮", boxes[1].text)
|
||
self.assertEqual(tasks[1].id, dialog.task.id)
|
||
|
||
self.assert_removed(temp_dir)
|
||
|
||
def test_cover_gallery_reset_image_clears_pointer_without_worker_or_confirmation(self):
|
||
with self.make_temp_dir() as temp_dir:
|
||
cfg, account, task, canonical = self._cover_gallery_task(temp_dir)
|
||
dialog = CoverGalleryDialog(
|
||
task,
|
||
cfg["image_dir"],
|
||
cfg["db_path"],
|
||
account=account,
|
||
)
|
||
self.addCleanup(dialog.close)
|
||
message_box, boxes = self.make_fake_message_box("取消")
|
||
FakeGenerateWorker.instances.clear()
|
||
|
||
with mock.patch("app.gui.tabs.generate.QMessageBox", message_box), \
|
||
mock.patch("app.gui.tabs.generate.GenerateWorker", FakeGenerateWorker):
|
||
self.assertTrue(dialog.reset_cover_image())
|
||
|
||
updated = db.get_task(task.id, path=cfg["db_path"])
|
||
self.assertEqual("新标题", updated.new_title)
|
||
self.assertIsNone(updated.new_cover_path)
|
||
self.assertEqual("generated", updated.stage)
|
||
self.assertEqual("success", updated.status)
|
||
self.assertFalse(os.path.exists(canonical))
|
||
self.assertIsNone(dialog.current_path)
|
||
self.assertIsNone(dialog.selected_path)
|
||
self.assertFalse(dialog.save_button.isEnabled())
|
||
self.assertFalse(dialog._has_unsaved_selection())
|
||
self.assertTrue(dialog.candidates)
|
||
self.assertIn("已重置图片", dialog.status_label.text())
|
||
self.assertEqual([], boxes)
|
||
self.assertEqual([], FakeGenerateWorker.instances)
|
||
|
||
self.assert_removed(temp_dir)
|
||
|
||
def test_cover_gallery_reset_image_committed_cancel_does_not_write_db(self):
|
||
with self.make_temp_dir() as temp_dir:
|
||
cfg, account, task, canonical = self._cover_gallery_task(temp_dir)
|
||
db.set_applied(task.id, True, path=cfg["db_path"])
|
||
task = db.get_task(task.id, path=cfg["db_path"])
|
||
dialog = CoverGalleryDialog(
|
||
task,
|
||
cfg["image_dir"],
|
||
cfg["db_path"],
|
||
account=account,
|
||
)
|
||
self.addCleanup(dialog.close)
|
||
message_box, boxes = self.make_fake_message_box("取消")
|
||
|
||
with mock.patch("app.gui.tabs.generate.QMessageBox", message_box):
|
||
self.assertFalse(dialog.reset_cover_image())
|
||
|
||
updated = db.get_task(task.id, path=cfg["db_path"])
|
||
self.assertEqual(canonical, updated.new_cover_path)
|
||
self.assertEqual("applied", updated.stage)
|
||
self.assertEqual(1, updated.committed)
|
||
self.assertTrue(os.path.exists(canonical))
|
||
self.assertIn("本地重置不会回滚蝦皮", boxes[0].text)
|
||
|
||
self.assert_removed(temp_dir)
|
||
|
||
def test_cover_gallery_reset_image_committed_confirm_clears_pointer(self):
|
||
with self.make_temp_dir() as temp_dir:
|
||
cfg, account, task, canonical = self._cover_gallery_task(temp_dir)
|
||
db.set_applied(task.id, True, path=cfg["db_path"])
|
||
task = db.get_task(task.id, path=cfg["db_path"])
|
||
refreshed = []
|
||
dialog = CoverGalleryDialog(
|
||
task,
|
||
cfg["image_dir"],
|
||
cfg["db_path"],
|
||
account=account,
|
||
gallery_updated_callback=lambda task: refreshed.append(task.id),
|
||
)
|
||
self.addCleanup(dialog.close)
|
||
message_box, boxes = self.make_fake_message_box("确认重置")
|
||
|
||
with mock.patch("app.gui.tabs.generate.QMessageBox", message_box):
|
||
self.assertTrue(dialog.reset_cover_image())
|
||
|
||
updated = db.get_task(task.id, path=cfg["db_path"])
|
||
self.assertIsNone(updated.new_cover_path)
|
||
self.assertEqual("generated", updated.stage)
|
||
self.assertEqual(1, updated.committed)
|
||
self.assertIn("再次提交线上", boxes[0].text)
|
||
self.assertIn(task.id, refreshed)
|
||
|
||
self.assert_removed(temp_dir)
|
||
|
||
def test_cover_gallery_reset_image_keeps_archive_selectable_for_restore(self):
|
||
with self.make_temp_dir() as temp_dir:
|
||
cfg, account, tasks, canonicals, _archives = self._cover_gallery_task_set(temp_dir, count=2)
|
||
dialog = CoverGalleryDialog(
|
||
tasks[0],
|
||
cfg["image_dir"],
|
||
cfg["db_path"],
|
||
account=account,
|
||
visible_tasks=tasks,
|
||
task_index=0,
|
||
account_by_alias={"alias-a": account},
|
||
)
|
||
self.addCleanup(dialog.close)
|
||
|
||
self.assertTrue(dialog.reset_cover_image())
|
||
self.assertTrue(dialog.candidates)
|
||
archived = dialog.candidates[0]
|
||
self.assertIsNone(db.get_task(tasks[0].id, path=cfg["db_path"]).new_cover_path)
|
||
|
||
message_box, boxes = self.make_fake_message_box("取消")
|
||
with mock.patch("app.gui.tabs.generate.QMessageBox", message_box):
|
||
self.assertTrue(dialog.switch_task(1))
|
||
self.assertEqual([], boxes)
|
||
self.assertEqual(tasks[1].id, dialog.task.id)
|
||
|
||
self.assertTrue(dialog.switch_task(-1))
|
||
dialog.candidate_buttons[archived].setChecked(True)
|
||
self.assertTrue(dialog.save_selection())
|
||
self.assertEqual(os.path.abspath(archived), db.get_task(tasks[0].id, path=cfg["db_path"]).new_cover_path)
|
||
|
||
self.assert_removed(temp_dir)
|
||
|
||
def test_cover_gallery_reset_image_archive_failure_keeps_db_pointer(self):
|
||
with self.make_temp_dir() as temp_dir:
|
||
cfg, account, task, canonical = self._cover_gallery_task(temp_dir)
|
||
dialog = CoverGalleryDialog(
|
||
task,
|
||
cfg["image_dir"],
|
||
cfg["db_path"],
|
||
account=account,
|
||
)
|
||
self.addCleanup(dialog.close)
|
||
message_box, boxes = self.make_fake_message_box("取消")
|
||
FakeGenerateWorker.instances.clear()
|
||
|
||
with mock.patch("app.gui.tabs.generate.QMessageBox", message_box), \
|
||
mock.patch("app.db.os.rename", side_effect=PermissionError), \
|
||
mock.patch("app.gui.tabs.generate.GenerateWorker", FakeGenerateWorker):
|
||
self.assertFalse(dialog.reset_cover_image())
|
||
|
||
self.assertTrue(os.path.exists(canonical))
|
||
self.assertEqual(canonical, db.get_task(task.id, path=cfg["db_path"]).new_cover_path)
|
||
self.assertEqual([], FakeGenerateWorker.instances)
|
||
self.assertIn("请先关闭正在查看的封面图片再重置", boxes[-1].text)
|
||
|
||
self.assert_removed(temp_dir)
|
||
|
||
def test_cover_gallery_reset_image_disabled_while_bulk_generation_runs(self):
|
||
with self.make_temp_dir() as temp_dir:
|
||
cfg, account, task, canonical = self._cover_gallery_task(temp_dir)
|
||
dialog = CoverGalleryDialog(
|
||
task,
|
||
cfg["image_dir"],
|
||
cfg["db_path"],
|
||
account=account,
|
||
bulk_running_checker=lambda: True,
|
||
)
|
||
self.addCleanup(dialog.close)
|
||
|
||
self.assertFalse(dialog.reset_cover_button.isEnabled())
|
||
self.assertIn("AI 生成正在进行", dialog.reset_cover_button.toolTip())
|
||
self.assertFalse(dialog.reset_cover_image())
|
||
self.assertEqual(canonical, db.get_task(task.id, path=cfg["db_path"]).new_cover_path)
|
||
|
||
self.assert_removed(temp_dir)
|
||
|
||
|
||
def test_generate_tab_allows_editing_generated_title_locally(self):
|
||
with self.make_temp_dir() as temp_dir:
|
||
cfg = self.make_config(temp_dir)
|
||
accounts.create_account("主店", "alias-a", debug_port=9222, config=cfg)
|
||
batch_id = db.create_batch(["input.xlsx"], path=cfg["db_path"])
|
||
db.insert_tasks(
|
||
batch_id,
|
||
[
|
||
{
|
||
"source_file_abs": os.path.join(temp_dir, "input.xlsx"),
|
||
"source_sheet": "商品",
|
||
"source_row": 2,
|
||
"account_name": "Excel主店",
|
||
"alias": "alias-a",
|
||
"item_id": "51100639510",
|
||
}
|
||
],
|
||
path=cfg["db_path"],
|
||
)
|
||
task = db.list_tasks(batch_id=batch_id, path=cfg["db_path"])[0]
|
||
db.set_collected(task.id, "旧标题", "old.jpg", path=cfg["db_path"])
|
||
db.set_generated(task.id, "AI标题", "new.jpg", path=cfg["db_path"])
|
||
db.mark_failed(task.id, "generate", "标题需微调", path=cfg["db_path"])
|
||
statuses = []
|
||
tab = GenerateTab(config=cfg, status_callback=statuses.append)
|
||
self.addCleanup(tab.close)
|
||
index = tab.model.index(0, 3)
|
||
|
||
self.assertTrue(bool(tab.model.flags(index) & gui.Qt.ItemIsEditable))
|
||
self.assertTrue(tab.model.setData(index, "人工微调标题", gui.Qt.EditRole))
|
||
|
||
updated = db.get_task(task.id, path=cfg["db_path"])
|
||
self.assertEqual("generated", updated.stage)
|
||
self.assertEqual("pending", updated.status)
|
||
self.assertEqual("人工微调标题", updated.new_title)
|
||
self.assertIsNone(updated.last_error)
|
||
self.assertEqual("人工微调标题", tab.model.index(0, 3).data())
|
||
self.assertIn("已修改新标题", statuses[-1])
|
||
|
||
self.assert_removed(temp_dir)
|
||
def test_generate_tab_resets_selected_generated_result_and_writes_reset_log(self):
|
||
with self.make_temp_dir() as temp_dir:
|
||
cfg = self.make_config(temp_dir)
|
||
accounts.create_account("主店", "alias-a", debug_port=9222, config=cfg)
|
||
batch_id = db.create_batch(["input.xlsx"], path=cfg["db_path"])
|
||
db.insert_tasks(
|
||
batch_id,
|
||
[
|
||
{
|
||
"source_file_abs": os.path.join(temp_dir, "input.xlsx"),
|
||
"source_sheet": "商品",
|
||
"source_row": 2,
|
||
"account_name": "Excel主店",
|
||
"alias": "alias-a",
|
||
"item_id": "51100639510",
|
||
}
|
||
],
|
||
path=cfg["db_path"],
|
||
)
|
||
task = db.list_tasks(batch_id=batch_id, path=cfg["db_path"])[0]
|
||
new_cover = os.path.join(temp_dir, "new.jpg")
|
||
with open(new_cover, "wb") as fh:
|
||
fh.write(b"jpeg")
|
||
db.set_collected(task.id, "旧标题", "old.jpg", path=cfg["db_path"])
|
||
db.set_generated(task.id, "新标题", new_cover, path=cfg["db_path"])
|
||
statuses = []
|
||
tab = GenerateTab(config=cfg, status_callback=statuses.append)
|
||
self.addCleanup(tab.close)
|
||
tab.task_table.selectRow(0)
|
||
|
||
message_box, boxes = self.make_fake_message_box("重置全部")
|
||
with mock.patch("app.gui.tabs.generate.QMessageBox", message_box):
|
||
tab.reset_generated_result()
|
||
|
||
message = boxes[0].text
|
||
self.assertIn("默认不删除本地新封面文件", message)
|
||
self.assertIn("重置全部", message)
|
||
updated = db.get_task(task.id, path=cfg["db_path"])
|
||
self.assertEqual("generated", updated.stage)
|
||
self.assertEqual("success", updated.status)
|
||
self.assertIsNone(updated.new_title)
|
||
self.assertIsNone(updated.new_cover_path)
|
||
self.assertFalse(os.path.exists(new_cover))
|
||
archived_covers = [
|
||
filename
|
||
for filename in os.listdir(temp_dir)
|
||
if filename.startswith("new_") and filename.endswith(".jpg")
|
||
]
|
||
self.assertEqual(1, len(archived_covers))
|
||
self.assertTrue(os.path.exists(os.path.join(temp_dir, archived_covers[0])))
|
||
run_log = db.list_run_logs(limit=1, run_type="reset", path=cfg["db_path"])[0]
|
||
self.assertEqual("done", run_log.status)
|
||
self.assertEqual("reset_generated", run_log.options["action"])
|
||
self.assertEqual("all", run_log.options["mode"])
|
||
events = db.list_run_log_events(run_log.id, path=cfg["db_path"])
|
||
self.assertTrue(any("action=reset_generated" in event.message for event in events))
|
||
self.assertIn("已重置生成结果", statuses[-1])
|
||
|
||
self.assert_removed(temp_dir)
|
||
|
||
def test_generate_tab_resets_titles_for_current_filtered_tasks(self):
|
||
with self.make_temp_dir() as temp_dir:
|
||
cfg = self.make_config(temp_dir)
|
||
accounts.create_account("主店", "alias-a", debug_port=9222, config=cfg)
|
||
batch_id = db.create_batch(["input.xlsx"], path=cfg["db_path"])
|
||
db.insert_tasks(
|
||
batch_id,
|
||
[
|
||
{
|
||
"source_file_abs": os.path.join(temp_dir, "input.xlsx"),
|
||
"source_sheet": "商品",
|
||
"source_row": 2,
|
||
"account_name": "Excel主店",
|
||
"alias": "alias-a",
|
||
"item_id": "51100639510",
|
||
},
|
||
{
|
||
"source_file_abs": os.path.join(temp_dir, "input.xlsx"),
|
||
"source_sheet": "商品",
|
||
"source_row": 3,
|
||
"account_name": "Excel主店",
|
||
"alias": "alias-a",
|
||
"item_id": "51100639511",
|
||
},
|
||
],
|
||
path=cfg["db_path"],
|
||
)
|
||
tasks = db.list_tasks(batch_id=batch_id, path=cfg["db_path"])
|
||
for index, task in enumerate(tasks):
|
||
db.set_collected(task.id, f"旧标题{index}", f"old-{index}.jpg", path=cfg["db_path"])
|
||
db.set_generated(task.id, f"新标题{index}", f"new-{index}.jpg", path=cfg["db_path"])
|
||
statuses = []
|
||
tab = GenerateTab(config=cfg, status_callback=statuses.append)
|
||
self.addCleanup(tab.close)
|
||
tab.task_table.clearSelection()
|
||
|
||
message_box, boxes = self.make_fake_message_box("重置标题")
|
||
with mock.patch("app.gui.tabs.generate.QMessageBox", message_box):
|
||
tab.reset_generated_result()
|
||
|
||
self.assertIn("当前筛选结果 2 条", boxes[0].text)
|
||
updated = db.list_tasks(batch_id=batch_id, path=cfg["db_path"])
|
||
self.assertEqual([None, None], [task.new_title for task in updated])
|
||
self.assertEqual(["new-0.jpg", "new-1.jpg"], [task.new_cover_path for task in updated])
|
||
self.assertTrue(all(task.stage == "generated" for task in updated))
|
||
run_log = db.list_run_logs(limit=1, run_type="reset", path=cfg["db_path"])[0]
|
||
self.assertEqual("filtered", run_log.options["scope"])
|
||
self.assertEqual("title", run_log.options["mode"])
|
||
self.assertTrue(run_log.options["reset_title"])
|
||
self.assertFalse(run_log.options["reset_cover"])
|
||
self.assertEqual(2, run_log.total)
|
||
self.assertIn("已重置生成结果:2 条,内容:标题", statuses[-1])
|
||
|
||
self.assert_removed(temp_dir)
|
||
|
||
def test_generate_tab_resets_selected_covers_and_keeps_manual_titles(self):
|
||
with self.make_temp_dir() as temp_dir:
|
||
cfg = self.make_config(temp_dir)
|
||
accounts.create_account("主店", "alias-a", debug_port=9222, config=cfg)
|
||
batch_id = db.create_batch(["input.xlsx"], path=cfg["db_path"])
|
||
db.insert_tasks(
|
||
batch_id,
|
||
[
|
||
{
|
||
"source_file_abs": os.path.join(temp_dir, "input.xlsx"),
|
||
"source_sheet": "商品",
|
||
"source_row": 2,
|
||
"account_name": "Excel主店",
|
||
"alias": "alias-a",
|
||
"item_id": "51100639510",
|
||
},
|
||
{
|
||
"source_file_abs": os.path.join(temp_dir, "input.xlsx"),
|
||
"source_sheet": "商品",
|
||
"source_row": 3,
|
||
"account_name": "Excel主店",
|
||
"alias": "alias-a",
|
||
"item_id": "51100639511",
|
||
},
|
||
],
|
||
path=cfg["db_path"],
|
||
)
|
||
tasks = db.list_tasks(batch_id=batch_id, path=cfg["db_path"])
|
||
for index, task in enumerate(tasks):
|
||
db.set_collected(task.id, f"旧标题{index}", f"old-{index}.jpg", path=cfg["db_path"])
|
||
db.set_generated(task.id, f"手动标题{index}", f"new-{index}.jpg", path=cfg["db_path"])
|
||
db.set_applied(tasks[0].id, True, path=cfg["db_path"])
|
||
tab = GenerateTab(config=cfg)
|
||
self.addCleanup(tab.close)
|
||
selection = tab.task_table.selectionModel()
|
||
for row in [0, 1]:
|
||
selection.select(
|
||
tab.model.index(row, 0),
|
||
QItemSelectionModel.Select | QItemSelectionModel.Rows,
|
||
)
|
||
|
||
message_box, boxes = self.make_fake_message_box("重置封面")
|
||
with mock.patch("app.gui.tabs.generate.QMessageBox", message_box):
|
||
tab.reset_generated_result()
|
||
|
||
message = boxes[0].text
|
||
self.assertIn("选中任务 2 条", message)
|
||
self.assertIn("已经提交过线上", message)
|
||
self.assertIn("重生成后再更新会再次提交线上", message)
|
||
updated = db.list_tasks(batch_id=batch_id, path=cfg["db_path"])
|
||
self.assertEqual(["手动标题0", "手动标题1"], [task.new_title for task in updated])
|
||
self.assertEqual([None, None], [task.new_cover_path for task in updated])
|
||
self.assertEqual("generated", updated[0].stage)
|
||
self.assertEqual(1, updated[0].committed)
|
||
run_log = db.list_run_logs(limit=1, run_type="reset", path=cfg["db_path"])[0]
|
||
self.assertEqual("selected", run_log.options["scope"])
|
||
self.assertEqual("cover", run_log.options["mode"])
|
||
self.assertFalse(run_log.options["reset_title"])
|
||
self.assertTrue(run_log.options["reset_cover"])
|
||
self.assertEqual(1, run_log.options["committed_count"])
|
||
|
||
self.assert_removed(temp_dir)
|
||
|
||
def test_generate_tab_disables_reset_while_generation_running(self):
|
||
with self.make_temp_dir() as temp_dir:
|
||
cfg = self.make_config(temp_dir)
|
||
statuses = []
|
||
tab = GenerateTab(config=cfg, status_callback=statuses.append)
|
||
self.addCleanup(tab.close)
|
||
|
||
tab._set_generate_running(True)
|
||
self.assertFalse(tab.reset_generate_button.isEnabled())
|
||
self.assertFalse(tab.title_template_combo.isEnabled())
|
||
self.assertFalse(tab.new_title_template_button.isEnabled())
|
||
self.assertFalse(tab.save_title_template_button.isEnabled())
|
||
self.assertFalse(tab.title_template_actions_button.isEnabled())
|
||
self.assertFalse(tab.save_title_template_as_action.isEnabled())
|
||
self.assertFalse(tab.rename_title_template_action.isEnabled())
|
||
self.assertFalse(tab.delete_title_template_action.isEnabled())
|
||
self.assertFalse(tab.cover_template_combo.isEnabled())
|
||
tab.generate_thread = object()
|
||
tab.reset_generated_result()
|
||
|
||
self.assertIn("AI 生成正在进行,不能重置", statuses[-1])
|
||
|
||
self.assert_removed(temp_dir)
|
||
|
||
def test_generate_worker_calls_generate_batch_and_emits_signals(self):
|
||
with self.make_temp_dir() as temp_dir:
|
||
cfg = self.make_config(temp_dir)
|
||
cfg["ai"] = appconfig.ai_config(cfg)
|
||
cfg["ai"]["backend"] = "direct"
|
||
cfg["ai"]["generate_cover"] = True
|
||
account = accounts.create_account("主店", "alias-a", debug_port=9222, config=cfg)
|
||
batch_id = db.create_batch(["input.xlsx"], path=cfg["db_path"])
|
||
db.insert_tasks(
|
||
batch_id,
|
||
[
|
||
{
|
||
"source_file_abs": os.path.join(temp_dir, "input.xlsx"),
|
||
"source_sheet": "商品",
|
||
"source_row": 2,
|
||
"account_name": "Excel主店",
|
||
"alias": "alias-a",
|
||
"item_id": "51100639510",
|
||
}
|
||
],
|
||
path=cfg["db_path"],
|
||
)
|
||
task = db.list_tasks(batch_id=batch_id, path=cfg["db_path"])[0]
|
||
db.set_collected(task.id, "旧标题", "old.jpg", path=cfg["db_path"])
|
||
tasks = db.list_tasks(batch_id=batch_id, path=cfg["db_path"])
|
||
progress = []
|
||
rows = []
|
||
logs = []
|
||
|
||
def fake_generate_batch(tasks_arg, prompt_values, ai_cfg=None, on_progress=None, should_stop=None):
|
||
self.assertEqual(tasks, tasks_arg)
|
||
self.assertEqual({"title": "标题提示", "cover": "封面提示"}, prompt_values)
|
||
self.assertEqual(account, ai_cfg["account_by_alias"]["alias-a"])
|
||
self.assertEqual(cfg["db_path"], ai_cfg["db_path"])
|
||
self.assertIn("on_event", ai_cfg)
|
||
self.assertIn("on_error", ai_cfg)
|
||
self.assertFalse(should_stop())
|
||
on_progress({"total": 1, "title_done": 1, "cover_done": 0, "failed": 0})
|
||
ai_cfg["on_event"](
|
||
{
|
||
"task": tasks[0],
|
||
"phase": "cover",
|
||
"step": "cover_submit",
|
||
"result": "start",
|
||
}
|
||
)
|
||
ai_cfg["on_event"](
|
||
{
|
||
"task": tasks[0],
|
||
"phase": "cover",
|
||
"step": "cover_request",
|
||
"result": "retry",
|
||
"attempt": 1,
|
||
"attempts": 3,
|
||
"detail": "timeout token=SECRET-TOKEN",
|
||
"level": "warning",
|
||
}
|
||
)
|
||
ai_cfg["on_event"](
|
||
{
|
||
"task": tasks[0],
|
||
"phase": "cover",
|
||
"step": "cover_request",
|
||
"result": "success",
|
||
"detail": "cmhub 已返回 image_url,耗时 91.2秒",
|
||
}
|
||
)
|
||
ai_cfg["on_event"](
|
||
{
|
||
"task": tasks[0],
|
||
"phase": "cover",
|
||
"step": "cover_download",
|
||
"result": "success",
|
||
"detail": "下载完成,1.3MB,耗时 12.4秒",
|
||
}
|
||
)
|
||
ai_cfg["on_event"](
|
||
{
|
||
"task": tasks[0],
|
||
"phase": "cover",
|
||
"step": "cover_download",
|
||
"result": "warning",
|
||
"detail": "图片下载较慢,已用 24.0秒,大小 1.3MB",
|
||
"level": "warning",
|
||
}
|
||
)
|
||
ai_cfg["on_event"](
|
||
{
|
||
"task": tasks[0],
|
||
"phase": "cover",
|
||
"step": "cover_image_url",
|
||
"result": "debug",
|
||
"detail": "cmhub 图片 URL:https://cdn.example.com/generated.png?token=SECRET-TOKEN",
|
||
"level": "warning",
|
||
"debug_only": True,
|
||
}
|
||
)
|
||
ai_cfg["on_event"](
|
||
{
|
||
"task": tasks[0],
|
||
"phase": "cover",
|
||
"step": "cover_save",
|
||
"result": "success",
|
||
"detail": "JPEG 已保存,耗时 1.1秒,文件 220.0KB",
|
||
}
|
||
)
|
||
ai_cfg["on_task_update"](tasks[0].id, {"stage": "generated"})
|
||
return {"ok": True, "total": 1, "title_done": 1, "cover_done": 1, "failed": 0}
|
||
|
||
worker = GenerateWorker(
|
||
tasks,
|
||
{"title": "标题提示", "cover": "封面提示"},
|
||
db_path=cfg["db_path"],
|
||
config=cfg,
|
||
)
|
||
worker.progress.connect(progress.append)
|
||
worker.row_updated.connect(lambda task_id, fields: rows.append((task_id, fields)))
|
||
worker.log.connect(logs.append)
|
||
|
||
with mock.patch("app.gui.ai.generate_batch", side_effect=fake_generate_batch):
|
||
summary = worker.execute()
|
||
|
||
self.assertEqual(1, summary["cover_done"])
|
||
self.assertIsInstance(summary["run_id"], int)
|
||
self.assertEqual([batch_id], summary["batch_ids"])
|
||
self.assertEqual([{"total": 1, "title_done": 1, "cover_done": 0, "failed": 0}], progress)
|
||
self.assertEqual([(tasks[0].id, {"stage": "generated"})], rows)
|
||
run_log = db.list_run_logs(limit=1, run_type="generate", path=cfg["db_path"])[0]
|
||
self.assertEqual(summary["run_id"], run_log.id)
|
||
joined_logs = "\n".join(logs)
|
||
self.assertIn("[开始] 本轮生成 1 条", joined_logs)
|
||
self.assertRegex(joined_logs, r"开始时间 \d{4}-\d{2}-\d{2} \d{2}:\d{2}:\d{2}")
|
||
self.assertIn("[图片] 1/1 商品 51100639510", joined_logs)
|
||
self.assertIn("准备重试 1/2", joined_logs)
|
||
self.assertIn("cmhub 已返回图片,耗时 91.2秒", joined_logs)
|
||
self.assertIn("下载完成,1.3MB,耗时 12.4秒", joined_logs)
|
||
self.assertIn("图片下载较慢,已用 24.0秒,大小 1.3MB", joined_logs)
|
||
self.assertIn("cmhub 图片 URL:https://cdn.example.com/generated.png?token=***", joined_logs)
|
||
self.assertIn("本地保存完成,JPEG 已保存,耗时 1.1秒,文件 220.0KB", joined_logs)
|
||
self.assertIn("token=***", joined_logs)
|
||
self.assertNotIn("SECRET-TOKEN", joined_logs)
|
||
self.assertIn("[完成] AI 生成完成:标题1/1,图片1/1,失败0", joined_logs)
|
||
self.assertRegex(joined_logs, r"完成时间 \d{4}-\d{2}-\d{2} \d{2}:\d{2}:\d{2},总用时 \d+秒")
|
||
events = db.list_run_log_events(summary["run_id"], path=cfg["db_path"])
|
||
event_messages = "\n".join(event.message for event in events)
|
||
self.assertIn("[图片] 1/1 商品 51100639510", event_messages)
|
||
self.assertIn("准备重试 1/2", event_messages)
|
||
self.assertIn("下载完成,1.3MB,耗时 12.4秒", event_messages)
|
||
self.assertIn("图片下载较慢,已用 24.0秒,大小 1.3MB", event_messages)
|
||
self.assertNotIn("cmhub 图片 URL", event_messages)
|
||
|
||
self.assert_removed(temp_dir)
|
||
|
||
def test_generate_worker_user_log_hides_cmhub_request_urls(self):
|
||
with self.make_temp_dir() as temp_dir:
|
||
cfg = self.make_config(temp_dir)
|
||
cfg["ai"] = appconfig.ai_config(cfg)
|
||
cfg["ai"]["backend"] = "cmhub"
|
||
accounts.create_account("主店", "alias-a", debug_port=9222, config=cfg)
|
||
batch_id = db.create_batch(["input.xlsx"], path=cfg["db_path"])
|
||
db.insert_tasks(
|
||
batch_id,
|
||
[
|
||
{
|
||
"source_file_abs": os.path.join(temp_dir, "input.xlsx"),
|
||
"source_sheet": "商品",
|
||
"source_row": 2,
|
||
"account_name": "Excel主店",
|
||
"alias": "alias-a",
|
||
"item_id": "51100639510",
|
||
}
|
||
],
|
||
path=cfg["db_path"],
|
||
)
|
||
task = db.list_tasks(batch_id=batch_id, path=cfg["db_path"])[0]
|
||
db.set_collected(task.id, "旧标题", "old.jpg", path=cfg["db_path"])
|
||
tasks = db.list_tasks(batch_id=batch_id, path=cfg["db_path"])
|
||
logs = []
|
||
|
||
def fake_generate_batch(tasks_arg, prompt_values, ai_cfg=None, on_progress=None, should_stop=None):
|
||
ai_cfg["on_event"](
|
||
{
|
||
"task": tasks_arg[0],
|
||
"phase": "title",
|
||
"step": "title_request",
|
||
"result": "retry",
|
||
"attempt": 1,
|
||
"attempts": 3,
|
||
"detail": (
|
||
"cmhub upstream_error: POST "
|
||
"https://cmhub.example.com/api/v1/generate/title "
|
||
"failed image_url=http://43.128.3.240:8080/generated/images/a.png?token=SECRET-TOKEN"
|
||
),
|
||
"level": "warning",
|
||
}
|
||
)
|
||
ai_cfg["on_event"](
|
||
{
|
||
"task": tasks_arg[0],
|
||
"phase": "title",
|
||
"step": "title_request",
|
||
"result": "failed",
|
||
"detail": (
|
||
"cmhub not_found: cmhub 接口不存在,请检查 Base URL "
|
||
"或该实例是否已部署 /api/v1/models "
|
||
"https://cmhub.example.com/api/v1/models"
|
||
),
|
||
"level": "error",
|
||
}
|
||
)
|
||
return {
|
||
"ok": False,
|
||
"total": 1,
|
||
"title_total": 1,
|
||
"title_done": 0,
|
||
"cover_done": 0,
|
||
"cover_total": 0,
|
||
"generated_done": 0,
|
||
"failed": 1,
|
||
"generate_cover": False,
|
||
"error": (
|
||
"cmhub not_found: GET "
|
||
"https://cmhub.example.com/api/v1/models?token=SECRET-TOKEN"
|
||
),
|
||
}
|
||
|
||
worker = GenerateWorker(
|
||
tasks,
|
||
{"title": "标题提示", "cover": "封面提示"},
|
||
db_path=cfg["db_path"],
|
||
config=cfg,
|
||
)
|
||
worker.log.connect(logs.append)
|
||
|
||
with mock.patch("app.gui.ai.generate_batch", side_effect=fake_generate_batch):
|
||
summary = worker.execute()
|
||
|
||
self.assertFalse(summary["ok"])
|
||
joined_logs = "\n".join(logs)
|
||
self.assertIn("cmhub 上游生成失败,请稍后重试", joined_logs)
|
||
self.assertIn("cmhub 网关接口不可用,请检查⑤设置中的 Base URL", joined_logs)
|
||
self.assertRegex(joined_logs, r"失败时间 \d{4}-\d{2}-\d{2} \d{2}:\d{2}:\d{2},总用时 \d+秒")
|
||
events = db.list_run_log_events(summary["run_id"], path=cfg["db_path"])
|
||
event_messages = "\n".join(event.message for event in events)
|
||
combined = joined_logs + "\n" + event_messages
|
||
for forbidden in [
|
||
"https://",
|
||
"http://",
|
||
"/api/v1/",
|
||
"/generated/images/",
|
||
"image_url",
|
||
"SECRET-TOKEN",
|
||
]:
|
||
self.assertNotIn(forbidden, combined)
|
||
|
||
self.assert_removed(temp_dir)
|
||
|
||
def test_generate_worker_records_cmhub_billing_metadata(self):
|
||
with self.make_temp_dir() as temp_dir:
|
||
cfg = self.make_config(temp_dir)
|
||
cfg["ai"] = appconfig.ai_config(cfg)
|
||
cfg["ai"]["backend"] = "cmhub"
|
||
cfg["ai"]["generate_cover"] = True
|
||
cfg["ai"]["image_concurrency"] = 10
|
||
accounts.create_account("主店", "alias-a", debug_port=9222, config=cfg)
|
||
batch_id = db.create_batch(["input.xlsx"], path=cfg["db_path"])
|
||
db.insert_tasks(
|
||
batch_id,
|
||
[
|
||
{
|
||
"source_file_abs": os.path.join(temp_dir, "input.xlsx"),
|
||
"source_sheet": "商品",
|
||
"source_row": 2,
|
||
"account_name": "Excel主店",
|
||
"alias": "alias-a",
|
||
"item_id": "51100639510",
|
||
}
|
||
],
|
||
path=cfg["db_path"],
|
||
)
|
||
task = db.list_tasks(batch_id=batch_id, path=cfg["db_path"])[0]
|
||
db.set_collected(task.id, "旧标题", "old.jpg", path=cfg["db_path"])
|
||
tasks = db.list_tasks(batch_id=batch_id, path=cfg["db_path"])
|
||
progress = []
|
||
logs = []
|
||
|
||
def fake_generate_batch(tasks_arg, prompt_values, ai_cfg=None, on_progress=None, should_stop=None):
|
||
on_progress({"total": 1, "title_done": 1, "cover_done": 0, "failed": 0})
|
||
ai_cfg["on_event"](
|
||
{
|
||
"task": tasks_arg[0],
|
||
"phase": "title",
|
||
"step": "title_request",
|
||
"result": "meta",
|
||
"metadata": {
|
||
"alias": "title-standard",
|
||
"points_cost": 1,
|
||
"points_balance": 88,
|
||
"call_id": "call-1",
|
||
},
|
||
}
|
||
)
|
||
return {
|
||
"ok": True,
|
||
"total": 1,
|
||
"title_done": 1,
|
||
"cover_done": 1,
|
||
"cover_total": 1,
|
||
"generated_done": 1,
|
||
"failed": 0,
|
||
"generate_cover": True,
|
||
}
|
||
|
||
worker = GenerateWorker(
|
||
tasks,
|
||
{"title": "标题提示", "cover": "封面提示"},
|
||
db_path=cfg["db_path"],
|
||
config=cfg,
|
||
)
|
||
worker.progress.connect(progress.append)
|
||
worker.log.connect(logs.append)
|
||
|
||
with mock.patch("app.gui.ai.generate_batch", side_effect=fake_generate_batch):
|
||
summary = worker.execute()
|
||
|
||
self.assertEqual(88, summary["points_balance"])
|
||
self.assertEqual(88, progress[-1]["points_balance"])
|
||
joined_logs = "\n".join(logs)
|
||
self.assertIn("图片并发10", joined_logs)
|
||
self.assertIn("cmhub实际生图并发5", joined_logs)
|
||
self.assertIn("下载并发5", joined_logs)
|
||
self.assertIn("[计费] 商品 51100639510", joined_logs)
|
||
self.assertIn("别名 title-standard", joined_logs)
|
||
self.assertIn("扣点 1", joined_logs)
|
||
self.assertIn("余额 88", joined_logs)
|
||
self.assertIn("call_id=call-1", joined_logs)
|
||
run_log = db.list_run_logs(limit=1, run_type="generate", path=cfg["db_path"])[0]
|
||
self.assertEqual("cmhub", run_log.options["backend"])
|
||
events = db.list_run_log_events(summary["run_id"], path=cfg["db_path"])
|
||
messages = "\n".join(event.message for event in events)
|
||
self.assertIn("[计费] 商品 51100639510", messages)
|
||
self.assertIn("余额 88", messages)
|
||
|
||
self.assert_removed(temp_dir)
|
||
|
||
def test_generate_worker_stops_on_cmhub_insufficient_points(self):
|
||
with self.make_temp_dir() as temp_dir:
|
||
cfg = self.make_config(temp_dir)
|
||
cfg["ai"] = appconfig.ai_config(cfg)
|
||
cfg["ai"]["backend"] = "cmhub"
|
||
accounts.create_account("主店", "alias-a", debug_port=9222, config=cfg)
|
||
batch_id = db.create_batch(["input.xlsx"], path=cfg["db_path"])
|
||
db.insert_tasks(
|
||
batch_id,
|
||
[
|
||
{
|
||
"source_file_abs": os.path.join(temp_dir, "input.xlsx"),
|
||
"source_sheet": "商品",
|
||
"source_row": 2,
|
||
"account_name": "Excel主店",
|
||
"alias": "alias-a",
|
||
"item_id": "51100639510",
|
||
}
|
||
],
|
||
path=cfg["db_path"],
|
||
)
|
||
task = db.list_tasks(batch_id=batch_id, path=cfg["db_path"])[0]
|
||
db.set_collected(task.id, "旧标题", "old.jpg", path=cfg["db_path"])
|
||
tasks = db.list_tasks(batch_id=batch_id, path=cfg["db_path"])
|
||
progress = []
|
||
logs = []
|
||
|
||
def fake_generate_batch(tasks_arg, prompt_values, ai_cfg=None, on_progress=None, should_stop=None):
|
||
exc = ai.CMHubError("insufficient_points", "点数不足,请先充值", status=402)
|
||
ai_cfg["on_error"](
|
||
{
|
||
"task": tasks_arg[0],
|
||
"phase": "title",
|
||
"step": "title_request",
|
||
"error": str(exc),
|
||
"exception": exc,
|
||
"code": exc.code,
|
||
"status": exc.status,
|
||
}
|
||
)
|
||
self.assertTrue(should_stop())
|
||
return {
|
||
"ok": False,
|
||
"total": 1,
|
||
"title_done": 0,
|
||
"cover_done": 0,
|
||
"cover_total": 0,
|
||
"generated_done": 0,
|
||
"failed": 1,
|
||
"cancelled": should_stop(),
|
||
"generate_cover": False,
|
||
}
|
||
|
||
worker = GenerateWorker(
|
||
tasks,
|
||
{"title": "标题提示", "cover": "封面提示"},
|
||
db_path=cfg["db_path"],
|
||
config=cfg,
|
||
)
|
||
worker.progress.connect(progress.append)
|
||
worker.log.connect(logs.append)
|
||
|
||
with mock.patch("app.gui.ai.generate_batch", side_effect=fake_generate_batch):
|
||
summary = worker.execute()
|
||
|
||
self.assertFalse(summary["ok"])
|
||
self.assertTrue(summary["cancelled"])
|
||
self.assertEqual("insufficient_points", summary["billing_error"]["code"])
|
||
self.assertIn("点数不足,请先充值", summary["billing_error"]["message"])
|
||
self.assertEqual("insufficient_points", progress[-1]["billing_error"]["code"])
|
||
joined_logs = "\n".join(logs)
|
||
self.assertIn("[计费] 商品 51100639510", joined_logs)
|
||
self.assertIn("点数不足,请先充值", joined_logs)
|
||
run_log = db.list_run_logs(limit=1, run_type="generate", path=cfg["db_path"])[0]
|
||
self.assertEqual("failed", run_log.status)
|
||
self.assertEqual("insufficient_points", run_log.summary["billing_error"]["code"])
|
||
|
||
self.assert_removed(temp_dir)
|
||
|
||
def test_generate_worker_writes_run_log_and_diagnostic_log_on_image_failure(self):
|
||
with self.make_temp_dir() as temp_dir:
|
||
cfg = self.make_config(temp_dir)
|
||
cfg["ai"] = appconfig.ai_config(cfg)
|
||
cfg["ai"]["backend"] = "direct"
|
||
cfg["ai"]["generate_cover"] = True
|
||
accounts.create_account("主店", "alias-a", debug_port=9222, config=cfg)
|
||
batch_id = db.create_batch(["input.xlsx"], path=cfg["db_path"])
|
||
db.insert_tasks(
|
||
batch_id,
|
||
[
|
||
{
|
||
"source_file_abs": os.path.join(temp_dir, "input.xlsx"),
|
||
"source_sheet": "商品",
|
||
"source_row": 2,
|
||
"account_name": "Excel主店",
|
||
"alias": "alias-a",
|
||
"item_id": "51100639510",
|
||
}
|
||
],
|
||
path=cfg["db_path"],
|
||
)
|
||
task = db.list_tasks(batch_id=batch_id, path=cfg["db_path"])[0]
|
||
old_cover = os.path.join(temp_dir, "old.jpg")
|
||
with open(old_cover, "wb") as fh:
|
||
fh.write(b"jpeg")
|
||
db.set_collected(task.id, "旧标题", old_cover, path=cfg["db_path"])
|
||
tasks = db.list_tasks(batch_id=batch_id, path=cfg["db_path"])
|
||
diagnostic_log_dir = os.path.join(temp_dir, "logs")
|
||
|
||
def fake_title(title_prompt, old_title, **kwargs):
|
||
callback = kwargs.get("on_step")
|
||
if callback:
|
||
callback("title_request")
|
||
return "新标题"
|
||
|
||
def fake_cover(cover_prompt, old_cover_path, out_path, **kwargs):
|
||
callback = kwargs.get("on_step")
|
||
if callback:
|
||
callback("cover_request")
|
||
raise RuntimeError("图片生成失败 token=SECRET-TOKEN")
|
||
|
||
with mock.patch("app.ai.gen_title", side_effect=fake_title), \
|
||
mock.patch("app.ai.gen_cover", side_effect=fake_cover):
|
||
summary = GenerateWorker(
|
||
tasks,
|
||
{"title": "标题提示", "cover": "封面提示"},
|
||
db_path=cfg["db_path"],
|
||
config=cfg,
|
||
diagnostic_log_dir=diagnostic_log_dir,
|
||
).execute()
|
||
|
||
self.assertFalse(summary["ok"])
|
||
self.assertEqual(1, summary["title_done"])
|
||
self.assertEqual(0, summary["cover_done"])
|
||
self.assertEqual(1, summary["failed"])
|
||
failed_task = db.list_tasks(batch_id=batch_id, path=cfg["db_path"])[0]
|
||
self.assertEqual("failed", failed_task.status)
|
||
self.assertIn("图片生成失败", failed_task.last_error)
|
||
self.assertNotIn("SECRET-TOKEN", failed_task.last_error)
|
||
|
||
events = db.list_run_log_events(summary["run_id"], path=cfg["db_path"])
|
||
messages = "\n".join(event.message for event in events)
|
||
self.assertIn("[失败] 商品 51100639510", messages)
|
||
self.assertIn("图片生成失败", messages)
|
||
self.assertIn("token=***", messages)
|
||
self.assertNotIn("SECRET-TOKEN", messages)
|
||
|
||
log_path = os.path.join(diagnostic_log_dir, "cmshopee.log")
|
||
self.assertTrue(os.path.exists(log_path))
|
||
with open(log_path, "r", encoding="utf-8") as fh:
|
||
raw_log = fh.read()
|
||
self.assertIn("cover_request", raw_log)
|
||
self.assertIn("AI生成任务失败", raw_log)
|
||
self.assertIn("token=***", raw_log)
|
||
self.assertNotIn("SECRET-TOKEN", raw_log)
|
||
|
||
self.assert_removed(temp_dir)
|
||
|
||
def test_generate_worker_can_generate_titles_without_covers(self):
|
||
with self.make_temp_dir() as temp_dir:
|
||
cfg = self.make_config(temp_dir)
|
||
accounts.create_account("主店", "alias-a", debug_port=9222, config=cfg)
|
||
batch_id = db.create_batch(["input.xlsx"], path=cfg["db_path"])
|
||
db.insert_tasks(
|
||
batch_id,
|
||
[
|
||
{
|
||
"source_file_abs": os.path.join(temp_dir, "input.xlsx"),
|
||
"source_sheet": "商品",
|
||
"source_row": 2,
|
||
"account_name": "Excel主店",
|
||
"alias": "alias-a",
|
||
"item_id": "51100639510",
|
||
}
|
||
],
|
||
path=cfg["db_path"],
|
||
)
|
||
task = db.list_tasks(batch_id=batch_id, path=cfg["db_path"])[0]
|
||
db.set_collected(task.id, "旧标题", "old.jpg", path=cfg["db_path"])
|
||
tasks = db.list_tasks(batch_id=batch_id, path=cfg["db_path"])
|
||
logs = []
|
||
progress = []
|
||
|
||
def fake_title(title_prompt, old_title, **kwargs):
|
||
return "新标题"
|
||
|
||
worker = GenerateWorker(
|
||
tasks,
|
||
{"title": "标题提示", "cover": "封面提示"},
|
||
db_path=cfg["db_path"],
|
||
config=cfg,
|
||
)
|
||
worker.log.connect(logs.append)
|
||
worker.progress.connect(progress.append)
|
||
with mock.patch("app.ai.gen_title", side_effect=fake_title), \
|
||
mock.patch("app.ai.gen_cover") as gen_cover:
|
||
summary = worker.execute()
|
||
|
||
self.assertTrue(summary["ok"])
|
||
self.assertFalse(summary["generate_cover"])
|
||
self.assertEqual(1, summary["title_done"])
|
||
self.assertEqual(0, summary["cover_done"])
|
||
self.assertEqual(0, summary["cover_total"])
|
||
self.assertEqual(1, summary["generated_done"])
|
||
self.assertEqual(0, summary["failed"])
|
||
gen_cover.assert_not_called()
|
||
updated = db.list_tasks(batch_id=batch_id, path=cfg["db_path"])[0]
|
||
self.assertEqual("generated", updated.stage)
|
||
self.assertEqual("success", updated.status)
|
||
self.assertEqual("新标题", updated.new_title)
|
||
self.assertIsNone(updated.new_cover_path)
|
||
self.assertEqual(0, progress[-1]["cover_total"])
|
||
self.assertEqual(1, progress[-1]["generated_done"])
|
||
joined_logs = "\n".join(logs)
|
||
self.assertIn("本轮生成内容:只生成标题", joined_logs)
|
||
self.assertIn("已保存,仅生成标题", joined_logs)
|
||
self.assertIn("[完成] AI 生成完成:标题1/1,图片0/0,失败0", joined_logs)
|
||
run_log = db.list_run_logs(limit=1, run_type="generate", path=cfg["db_path"])[0]
|
||
self.assertEqual(1, run_log.done)
|
||
self.assertEqual(1, run_log.success_count)
|
||
self.assertTrue(run_log.options["generate_cover"] is False)
|
||
|
||
self.assert_removed(temp_dir)
|
||
|
||
def test_generate_worker_fills_missing_cover_without_title_call(self):
|
||
with self.make_temp_dir() as temp_dir:
|
||
cfg = self.make_config(temp_dir)
|
||
cfg["ai"] = appconfig.ai_config(cfg)
|
||
cfg["ai"]["backend"] = "direct"
|
||
cfg["ai"]["generate_mode"] = "cover"
|
||
cfg["ai"]["generate_cover"] = True
|
||
accounts.create_account("主店", "alias-a", debug_port=9222, config=cfg)
|
||
batch_id = db.create_batch(["input.xlsx"], path=cfg["db_path"])
|
||
db.insert_tasks(
|
||
batch_id,
|
||
[
|
||
{
|
||
"source_file_abs": os.path.join(temp_dir, "input.xlsx"),
|
||
"source_sheet": "商品",
|
||
"source_row": 2,
|
||
"account_name": "Excel主店",
|
||
"alias": "alias-a",
|
||
"item_id": "51100639510",
|
||
}
|
||
],
|
||
path=cfg["db_path"],
|
||
)
|
||
task = db.list_tasks(batch_id=batch_id, path=cfg["db_path"])[0]
|
||
db.set_collected(task.id, "旧标题", "old.jpg", path=cfg["db_path"])
|
||
db.set_generated(task.id, "手动标题", None, path=cfg["db_path"])
|
||
tasks = db.list_tasks(batch_id=batch_id, path=cfg["db_path"])
|
||
logs = []
|
||
progress = []
|
||
|
||
def fake_cover(cover_prompt, old_cover_path, out_path, **kwargs):
|
||
os.makedirs(os.path.dirname(out_path), exist_ok=True)
|
||
with open(out_path, "wb") as fh:
|
||
fh.write(b"jpeg")
|
||
return out_path
|
||
|
||
worker = GenerateWorker(
|
||
tasks,
|
||
{"title": "标题提示", "cover": "封面 {新标题}"},
|
||
db_path=cfg["db_path"],
|
||
config=cfg,
|
||
)
|
||
worker.log.connect(logs.append)
|
||
worker.progress.connect(progress.append)
|
||
with mock.patch("app.ai.gen_title") as gen_title, \
|
||
mock.patch("app.ai.gen_cover", side_effect=fake_cover) as gen_cover:
|
||
summary = worker.execute()
|
||
|
||
self.assertTrue(summary["ok"])
|
||
self.assertEqual(1, summary["total"])
|
||
self.assertEqual(0, summary["title_total"])
|
||
self.assertEqual(1, summary["cover_total"])
|
||
self.assertEqual(0, summary["title_done"])
|
||
self.assertEqual(1, summary["cover_done"])
|
||
self.assertEqual(1, summary["generated_done"])
|
||
gen_title.assert_not_called()
|
||
gen_cover.assert_called_once()
|
||
updated = db.list_tasks(batch_id=batch_id, path=cfg["db_path"])[0]
|
||
self.assertEqual("手动标题", updated.new_title)
|
||
self.assertTrue(os.path.exists(updated.new_cover_path))
|
||
self.assertEqual(0, progress[-1]["title_total"])
|
||
self.assertEqual(1, progress[-1]["cover_total"])
|
||
joined_logs = "\n".join(logs)
|
||
self.assertIn("本轮生成内容:只生成封面", joined_logs)
|
||
self.assertIn("标题0,图片1", joined_logs)
|
||
self.assertIn("已有标题,跳过生文", joined_logs)
|
||
self.assertIn("[完成] AI 生成完成:标题0/0,图片1/1,失败0", joined_logs)
|
||
|
||
self.assert_removed(temp_dir)
|
||
|
||
def test_generate_tab_does_not_auto_mix_latest_generate_run_log(self):
|
||
with self.make_temp_dir() as temp_dir:
|
||
cfg = self.make_config(temp_dir)
|
||
db.init_db(cfg["db_path"])
|
||
run_id = db.create_run_log("generate", total=1, path=cfg["db_path"])
|
||
db.add_run_log_event(
|
||
run_id,
|
||
"phase=cover step=cover_request result=failed detail=图片生成失败",
|
||
level="error",
|
||
path=cfg["db_path"],
|
||
)
|
||
db.finish_run_log(
|
||
run_id,
|
||
status="done",
|
||
done=1,
|
||
failed_count=1,
|
||
summary_json={"failed": 1},
|
||
path=cfg["db_path"],
|
||
)
|
||
|
||
tab = GenerateTab(config=cfg)
|
||
self.addCleanup(tab.close)
|
||
|
||
text = tab.run_log_view.toPlainText()
|
||
self.assertEqual("generateRunLogView", tab.run_log_view.objectName())
|
||
self.assertIn("本轮日志会在开始运行后显示", text)
|
||
self.assertNotIn("phase=cover step=cover_request result=failed", text)
|
||
|
||
self.assert_removed(temp_dir)
|
||
|
||
def test_generate_tab_start_generate_resets_visible_log_for_current_run(self):
|
||
with self.make_temp_dir() as temp_dir:
|
||
cfg = self.make_config(temp_dir)
|
||
accounts.create_account("主店", "alias-a", debug_port=9222, config=cfg)
|
||
batch_id = db.create_batch(["input.xlsx"], path=cfg["db_path"])
|
||
db.insert_tasks(
|
||
batch_id,
|
||
[
|
||
{
|
||
"source_file_abs": os.path.join(temp_dir, "input.xlsx"),
|
||
"source_sheet": "商品",
|
||
"source_row": 2,
|
||
"account_name": "Excel主店",
|
||
"alias": "alias-a",
|
||
"item_id": "51100639510",
|
||
}
|
||
],
|
||
path=cfg["db_path"],
|
||
)
|
||
task = db.list_tasks(batch_id=batch_id, path=cfg["db_path"])[0]
|
||
db.set_collected(task.id, "旧标题", "old.jpg", 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_starts_cover_only_run_for_missing_cover(self):
|
||
with self.make_temp_dir() as temp_dir:
|
||
cfg = self.make_config(temp_dir)
|
||
cfg["ai"] = appconfig.ai_config(cfg)
|
||
cfg["ai"]["generate_mode"] = "cover"
|
||
cfg["ai"]["generate_cover"] = True
|
||
accounts.create_account("主店", "alias-a", debug_port=9222, config=cfg)
|
||
batch_id = db.create_batch(["input.xlsx"], path=cfg["db_path"])
|
||
db.insert_tasks(
|
||
batch_id,
|
||
[
|
||
{
|
||
"source_file_abs": os.path.join(temp_dir, "input.xlsx"),
|
||
"source_sheet": "商品",
|
||
"source_row": 2,
|
||
"account_name": "Excel主店",
|
||
"alias": "alias-a",
|
||
"item_id": "51100639510",
|
||
}
|
||
],
|
||
path=cfg["db_path"],
|
||
)
|
||
task = db.list_tasks(batch_id=batch_id, path=cfg["db_path"])[0]
|
||
db.set_collected(task.id, "旧标题", "old.jpg", path=cfg["db_path"])
|
||
db.set_generated(task.id, "手动标题", None, path=cfg["db_path"])
|
||
tab = GenerateTab(config=cfg)
|
||
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):
|
||
tab.start_generate()
|
||
|
||
self.assertTrue(fake_thread.started)
|
||
self.assertEqual("标题 0/0", tab.title_progress_label.text())
|
||
self.assertEqual("图片 0/1", tab.cover_progress_label.text())
|
||
text = tab.run_log_view.toPlainText()
|
||
self.assertIn("本轮AI生成开始:任务 1 条", text)
|
||
self.assertIn("生成内容:只生成封面", text)
|
||
|
||
self.assert_removed(temp_dir)
|
||
|
||
def test_generate_tab_explains_collect_failed_records_are_not_generatable(self):
|
||
with self.make_temp_dir() as temp_dir:
|
||
cfg = self.make_config(temp_dir)
|
||
accounts.create_account("主店", "alias-a", debug_port=9222, config=cfg)
|
||
batch_id = db.create_batch(["input.xlsx"], path=cfg["db_path"])
|
||
db.insert_tasks(
|
||
batch_id,
|
||
[
|
||
{
|
||
"source_file_abs": os.path.join(temp_dir, "input.xlsx"),
|
||
"source_sheet": "商品",
|
||
"source_row": 2,
|
||
"account_name": "Excel主店",
|
||
"alias": "alias-a",
|
||
"item_id": "51100639510",
|
||
}
|
||
],
|
||
path=cfg["db_path"],
|
||
)
|
||
task = db.list_tasks(batch_id=batch_id, path=cfg["db_path"])[0]
|
||
db.mark_failed(task.id, "collect", "采集失败", path=cfg["db_path"])
|
||
statuses = []
|
||
tab = GenerateTab(config=cfg, status_callback=statuses.append)
|
||
self.addCleanup(tab.close)
|
||
|
||
self.assertEqual("采集失败", tab.model.index(0, 4).data())
|
||
tab.start_generate()
|
||
|
||
self.assertIsNone(tab.generate_worker)
|
||
self.assertIn("没有可生成的缺失内容", statuses[-1])
|
||
self.assertIn("①导入采集", statuses[-1])
|
||
|
||
self.assert_removed(temp_dir)
|
||
def test_apply_tab_does_not_auto_mix_latest_apply_run_log(self):
|
||
with self.make_temp_dir() as temp_dir:
|
||
cfg = self.make_config(temp_dir)
|
||
db.init_db(cfg["db_path"])
|
||
run_id = db.create_run_log("apply", total=1, path=cfg["db_path"])
|
||
db.add_run_log_event(
|
||
run_id,
|
||
"step=apply_update result=failed detail=上一轮更新失败",
|
||
level="error",
|
||
path=cfg["db_path"],
|
||
)
|
||
db.finish_run_log(
|
||
run_id,
|
||
status="done",
|
||
done=1,
|
||
failed_count=1,
|
||
summary_json={"failed": 1},
|
||
path=cfg["db_path"],
|
||
)
|
||
|
||
tab = ApplyTab(config=cfg)
|
||
self.addCleanup(tab.close)
|
||
|
||
text = tab.run_log_view.toPlainText()
|
||
self.assertEqual("applyRunLogView", tab.run_log_view.objectName())
|
||
self.assertIn("本轮日志会在开始运行后显示", text)
|
||
self.assertNotIn("step=apply_update result=failed", text)
|
||
|
||
self.assert_removed(temp_dir)
|
||
def test_apply_tab_lists_generated_tasks_and_filters_by_batch_shop_status(self):
|
||
with self.make_temp_dir() as temp_dir:
|
||
cfg = self.make_config(temp_dir)
|
||
accounts.create_account("主店", "alias-a", debug_port=9222, config=cfg)
|
||
accounts.create_account("副店", "alias-b", debug_port=9223, config=cfg)
|
||
batch_a = db.create_batch(["input-a.xlsx"], path=cfg["db_path"])
|
||
batch_b = db.create_batch(["input-b.xlsx"], path=cfg["db_path"])
|
||
db.insert_tasks(
|
||
batch_a,
|
||
[
|
||
{
|
||
"source_file_abs": os.path.join(temp_dir, "input-a.xlsx"),
|
||
"source_sheet": "商品",
|
||
"source_row": 2,
|
||
"account_name": "Excel主店",
|
||
"alias": "alias-a",
|
||
"item_id": "51100639510",
|
||
},
|
||
{
|
||
"source_file_abs": os.path.join(temp_dir, "input-a.xlsx"),
|
||
"source_sheet": "商品",
|
||
"source_row": 3,
|
||
"account_name": "Excel副店",
|
||
"alias": "alias-b",
|
||
"item_id": "51100639511",
|
||
},
|
||
{
|
||
"source_file_abs": os.path.join(temp_dir, "input-a.xlsx"),
|
||
"source_sheet": "商品",
|
||
"source_row": 4,
|
||
"account_name": "Excel主店",
|
||
"alias": "alias-a",
|
||
"item_id": "51100639512",
|
||
},
|
||
{
|
||
"source_file_abs": os.path.join(temp_dir, "input-a.xlsx"),
|
||
"source_sheet": "商品",
|
||
"source_row": 5,
|
||
"account_name": "Excel主店",
|
||
"alias": "alias-a",
|
||
"item_id": "51100639513",
|
||
},
|
||
],
|
||
path=cfg["db_path"],
|
||
)
|
||
db.insert_tasks(
|
||
batch_b,
|
||
[
|
||
{
|
||
"source_file_abs": os.path.join(temp_dir, "input-b.xlsx"),
|
||
"source_sheet": "商品",
|
||
"source_row": 2,
|
||
"account_name": "Excel主店",
|
||
"alias": "alias-a",
|
||
"item_id": "51100639514",
|
||
}
|
||
],
|
||
path=cfg["db_path"],
|
||
)
|
||
tasks_a = db.list_tasks(batch_id=batch_a, path=cfg["db_path"])
|
||
tasks_b = db.list_tasks(batch_id=batch_b, path=cfg["db_path"])
|
||
for task in tasks_a[:3] + tasks_b:
|
||
db.set_collected(task.id, "旧标题" + task.item_id[-2:], "old.jpg", path=cfg["db_path"])
|
||
db.set_generated(
|
||
task.id,
|
||
"新标题" + task.item_id[-2:],
|
||
os.path.join(temp_dir, task.item_id + "_new.jpg"),
|
||
path=cfg["db_path"],
|
||
)
|
||
db.mark_failed(tasks_a[2].id, "apply", "更新失败", path=cfg["db_path"])
|
||
db.set_applied(tasks_b[0].id, True, path=cfg["db_path"])
|
||
|
||
tab = ApplyTab(config=cfg)
|
||
self.addCleanup(tab.close)
|
||
tab.batch_filter.setCurrentIndex(tab.batch_filter.findData(None))
|
||
|
||
self.assertIsInstance(tab.task_table, QTableView)
|
||
self.assertEqual(["店铺", "商品ID", "新标题", "新封面", "阶段", "结果"], tab.model.HEADERS)
|
||
self.assertEqual("检查本轮更新", tab.preview_update_button.text())
|
||
self.assertEqual("开始更新", tab.start_update_button.text())
|
||
self.assertIsInstance(tab.update_mode_combo, QComboBox)
|
||
self.assertEqual(
|
||
["只更新标题", "只更新封面", "更新标题和封面"],
|
||
[tab.update_mode_combo.itemText(index) for index in range(tab.update_mode_combo.count())],
|
||
)
|
||
self.assertEqual("applyItemFilter", tab.item_filter.objectName())
|
||
self.assertEqual("停止", tab.stop_update_button.text())
|
||
self.assertEqual("重置更新状态", tab.reset_update_button.text())
|
||
self.assertTrue(tab.reset_update_button.isHidden())
|
||
self.assertEqual(gui.Qt.CustomContextMenu, tab.task_table.contextMenuPolicy())
|
||
self.assertEqual("startUpdateButton", tab.start_update_button.objectName())
|
||
self.assertIn(gui.COLOR_WARNING, tab.start_update_button.styleSheet())
|
||
self.assertIn("border", tab.start_update_button.styleSheet())
|
||
self.assertNotIn("border-radius", tab.start_update_button.styleSheet())
|
||
self.assertNotIn("padding", tab.start_update_button.styleSheet())
|
||
self.assertFalse(tab.stop_update_button.isEnabled())
|
||
self.assertEqual(2, tab.model.rowCount())
|
||
self.assertEqual("主店", tab.model.index(0, 0).data())
|
||
self.assertEqual("51100639510", tab.model.index(0, 1).data())
|
||
self.assertEqual("新标题10", tab.model.index(0, 2).data())
|
||
self.assertEqual("51100639510_new.jpg", tab.model.index(0, 3).data())
|
||
self.assertEqual("待更新", tab.model.index(0, 4).data())
|
||
self.assert_foreground(tab.model, 0, 4, gui.COLOR_PENDING)
|
||
self.assertEqual("待更新", tab.model.index(0, 5).data())
|
||
self.assert_foreground(tab.model, 0, 5, gui.COLOR_PENDING)
|
||
self.assertEqual("任务 2/4 条", tab.summary_label.text())
|
||
|
||
tab.status_filter.setCurrentIndex(tab.status_filter.findData("failed"))
|
||
self.assertEqual(1, tab.model.rowCount())
|
||
self.assertEqual("失败", tab.model.index(0, 5).data())
|
||
self.assert_foreground(tab.model, 0, 5, gui.COLOR_DANGER)
|
||
self.assertEqual(
|
||
"更新失败",
|
||
tab.model.data(tab.model.index(0, 0), gui.Qt.ToolTipRole),
|
||
)
|
||
|
||
tab.status_filter.setCurrentIndex(tab.status_filter.findData("generated"))
|
||
tab.shop_filter.setCurrentIndex(tab.shop_filter.findData("alias-b"))
|
||
self.assertEqual(1, tab.model.rowCount())
|
||
self.assertEqual("副店", tab.model.index(0, 0).data())
|
||
|
||
tab.shop_filter.setCurrentIndex(tab.shop_filter.findData(None))
|
||
tab.batch_filter.setCurrentIndex(tab.batch_filter.findData(batch_b))
|
||
tab.status_filter.setCurrentIndex(tab.status_filter.findData("applied"))
|
||
self.assertEqual(1, tab.model.rowCount())
|
||
self.assertEqual("已更新", tab.model.index(0, 4).data())
|
||
self.assert_foreground(tab.model, 0, 4, gui.COLOR_SUCCESS)
|
||
self.assertEqual("成功", tab.model.index(0, 5).data())
|
||
self.assert_foreground(tab.model, 0, 5, gui.COLOR_SUCCESS)
|
||
|
||
tab.batch_filter.setCurrentIndex(tab.batch_filter.findData(None))
|
||
tab.status_filter.setCurrentIndex(tab.status_filter.findData("all"))
|
||
tab.item_filter.setText("51100639512")
|
||
self.assertEqual(1, tab.model.rowCount())
|
||
self.assertEqual("51100639512", tab.model.index(0, 1).data())
|
||
self.assertEqual("任务 1/4 条", tab.summary_label.text())
|
||
self.assertIn(
|
||
"商品ID:51100639512",
|
||
tab._confirmation_message(list(tab.model.tasks)),
|
||
)
|
||
|
||
self.assert_removed(temp_dir)
|
||
|
||
|
||
def test_apply_tab_resets_selected_apply_status_and_warns_committed_task(self):
|
||
with self.make_temp_dir() as temp_dir:
|
||
cfg = self.make_config(temp_dir)
|
||
accounts.create_account("主店", "alias-a", debug_port=9222, config=cfg)
|
||
batch_id = db.create_batch(["input.xlsx"], path=cfg["db_path"])
|
||
db.insert_tasks(
|
||
batch_id,
|
||
[
|
||
{
|
||
"source_file_abs": os.path.join(temp_dir, "input.xlsx"),
|
||
"source_sheet": "商品",
|
||
"source_row": 2,
|
||
"account_name": "Excel主店",
|
||
"alias": "alias-a",
|
||
"item_id": "51100639510",
|
||
}
|
||
],
|
||
path=cfg["db_path"],
|
||
)
|
||
task = db.list_tasks(batch_id=batch_id, path=cfg["db_path"])[0]
|
||
db.set_collected(task.id, "旧标题", "old.jpg", path=cfg["db_path"])
|
||
db.set_generated(task.id, "新标题", "new.jpg", path=cfg["db_path"])
|
||
db.set_applied(task.id, True, path=cfg["db_path"])
|
||
statuses = []
|
||
tab = ApplyTab(config=cfg, status_callback=statuses.append)
|
||
self.addCleanup(tab.close)
|
||
tab.status_filter.setCurrentIndex(tab.status_filter.findData("applied"))
|
||
tab.task_table.selectRow(0)
|
||
|
||
with mock.patch("app.gui.QMessageBox.question", return_value=gui.QMessageBox.Yes) as question:
|
||
tab.reset_apply_status()
|
||
|
||
message = question.call_args[0][2]
|
||
self.assertIn("已经提交过线上", message)
|
||
self.assertIn("本地重置不会回滚蝦皮", message)
|
||
self.assertIn("重复更新会再次提交线上", message)
|
||
updated = db.get_task(task.id, path=cfg["db_path"])
|
||
self.assertEqual("generated", updated.stage)
|
||
self.assertEqual("pending", updated.status)
|
||
self.assertEqual("新标题", updated.new_title)
|
||
self.assertEqual("new.jpg", updated.new_cover_path)
|
||
self.assertEqual(1, updated.committed)
|
||
run_log = db.list_run_logs(limit=1, run_type="reset", path=cfg["db_path"])[0]
|
||
self.assertEqual("done", run_log.status)
|
||
self.assertEqual("reset_apply_status", run_log.options["action"])
|
||
events = db.list_run_log_events(run_log.id, path=cfg["db_path"])
|
||
self.assertTrue(any("action=reset_apply_status" in event.message for event in events))
|
||
self.assertIn("已重置更新状态", statuses[-1])
|
||
|
||
self.assert_removed(temp_dir)
|
||
|
||
def test_apply_tab_start_update_requires_confirmation_before_starting_worker(self):
|
||
with self.make_temp_dir() as temp_dir:
|
||
cfg = self.make_config(temp_dir)
|
||
self.allow_shopee_update(cfg)
|
||
accounts.create_account("主店", "alias-a", debug_port=9222, config=cfg)
|
||
batch_id = db.create_batch(["input.xlsx"], path=cfg["db_path"])
|
||
db.insert_tasks(
|
||
batch_id,
|
||
[
|
||
{
|
||
"source_file_abs": os.path.join(temp_dir, "input.xlsx"),
|
||
"source_sheet": "商品",
|
||
"source_row": 2,
|
||
"account_name": "Excel主店",
|
||
"alias": "alias-a",
|
||
"item_id": "51100639510",
|
||
}
|
||
],
|
||
path=cfg["db_path"],
|
||
)
|
||
task = db.list_tasks(batch_id=batch_id, path=cfg["db_path"])[0]
|
||
db.set_collected(task.id, "旧标题", "old.jpg", path=cfg["db_path"])
|
||
db.set_generated(task.id, "新标题", "new.jpg", path=cfg["db_path"])
|
||
statuses = []
|
||
tab = ApplyTab(config=cfg, status_callback=statuses.append)
|
||
self.addCleanup(tab.close)
|
||
tab.item_filter.setText("51100639510")
|
||
|
||
with mock.patch(
|
||
"app.gui.QMessageBox.question",
|
||
return_value=gui.QMessageBox.No,
|
||
) as question, mock.patch("app.gui.editor.apply_task") as apply_task:
|
||
tab.start_update()
|
||
|
||
message = question.call_args[0][2]
|
||
self.assertIn("任务数:1", message)
|
||
self.assertIn("预计批次:1", message)
|
||
self.assertIn("提交线上", message)
|
||
self.assertIn("更新内容:更新标题和封面", message)
|
||
self.assertIn("本轮将更新线上封面", message)
|
||
self.assertIn("状态:已生成", message)
|
||
self.assertIn("商品ID:51100639510", message)
|
||
self.assertNotIn("测试商品ID", message)
|
||
self.assertEqual("已取消开始更新", statuses[-1])
|
||
apply_task.assert_not_called()
|
||
|
||
self.assert_removed(temp_dir)
|
||
|
||
def test_apply_tab_start_update_starts_apply_worker_after_confirmation(self):
|
||
with self.make_temp_dir() as temp_dir:
|
||
cfg = self.make_config(temp_dir)
|
||
self.allow_shopee_update(cfg)
|
||
accounts.create_account("主店", "alias-a", debug_port=9222, config=cfg)
|
||
batch_id = db.create_batch(["input.xlsx"], path=cfg["db_path"])
|
||
db.insert_tasks(
|
||
batch_id,
|
||
[
|
||
{
|
||
"source_file_abs": os.path.join(temp_dir, "input.xlsx"),
|
||
"source_sheet": "商品",
|
||
"source_row": 2,
|
||
"account_name": "Excel主店",
|
||
"alias": "alias-a",
|
||
"item_id": "51100639510",
|
||
}
|
||
],
|
||
path=cfg["db_path"],
|
||
)
|
||
task = db.list_tasks(batch_id=batch_id, path=cfg["db_path"])[0]
|
||
db.set_collected(task.id, "旧标题", "old.jpg", path=cfg["db_path"])
|
||
db.set_generated(task.id, "新标题", "new.jpg", path=cfg["db_path"])
|
||
statuses = []
|
||
tab = ApplyTab(config=cfg, status_callback=statuses.append)
|
||
self.addCleanup(tab.close)
|
||
tab.run_log_view.setPlainText("上一轮更新失败")
|
||
|
||
class FakeSignal:
|
||
def __init__(self):
|
||
self.callbacks = []
|
||
|
||
def connect(self, callback):
|
||
self.callbacks.append(callback)
|
||
|
||
class FakeThread:
|
||
def __init__(self):
|
||
self.finished = FakeSignal()
|
||
self.started = False
|
||
|
||
def start(self):
|
||
self.started = True
|
||
|
||
fake_thread = FakeThread()
|
||
with mock.patch(
|
||
"app.gui.QMessageBox.question",
|
||
return_value=gui.QMessageBox.Yes,
|
||
), mock.patch("app.gui.run_worker", return_value=fake_thread) as run_worker, \
|
||
mock.patch("app.gui.editor.apply_task") as apply_task:
|
||
tab.start_update()
|
||
|
||
run_worker.assert_called_once()
|
||
self.assertIsInstance(tab.apply_worker, ApplyWorker)
|
||
self.assertIs(tab.apply_thread, fake_thread)
|
||
self.assertTrue(fake_thread.started)
|
||
self.assertFalse(hasattr(tab.apply_worker, "close_success_tab"))
|
||
self.assertFalse(tab.apply_worker.dry_run)
|
||
self.assertEqual(1, tab.apply_worker.max_parallel_accounts)
|
||
self.assertEqual(1, tab.apply_worker.batch_size)
|
||
self.assertEqual("title_cover", tab.apply_worker.update_mode)
|
||
log_text = tab.run_log_view.toPlainText()
|
||
self.assertNotIn("上一轮更新失败", log_text)
|
||
self.assertIn("本轮更新开始:任务 1 条,更新内容:更新标题和封面,每批 1 条", log_text)
|
||
self.assertFalse(tab.preview_update_button.isEnabled())
|
||
self.assertFalse(tab.start_update_button.isEnabled())
|
||
self.assertTrue(tab.stop_update_button.isEnabled())
|
||
self.assertEqual("开始更新:1 条,按每批最多 1 条执行", statuses[-1])
|
||
apply_task.assert_not_called()
|
||
unchanged = db.list_tasks(batch_id=batch_id, path=cfg["db_path"])[0]
|
||
self.assertEqual("generated", unchanged.stage)
|
||
self.assertEqual("success", unchanged.status)
|
||
|
||
self.assert_removed(temp_dir)
|
||
|
||
def test_apply_tab_preview_starts_without_real_submit_switch(self):
|
||
with self.make_temp_dir() as temp_dir:
|
||
cfg = self.make_config(temp_dir)
|
||
cfg["shopee_update"] = {
|
||
"test_item_id": "51100639510",
|
||
"allow_real_submit": False,
|
||
"allow_cover_update": False,
|
||
"max_items_per_run": 1,
|
||
"close_success_tab": False,
|
||
"dry_run": False,
|
||
"parallel_accounts": True,
|
||
"max_parallel_accounts": 2,
|
||
}
|
||
accounts.create_account("主店", "alias-a", debug_port=9222, config=cfg)
|
||
batch_id = db.create_batch(["input.xlsx"], path=cfg["db_path"])
|
||
db.insert_tasks(
|
||
batch_id,
|
||
[
|
||
{
|
||
"source_file_abs": os.path.join(temp_dir, "input.xlsx"),
|
||
"source_sheet": "商品",
|
||
"source_row": 2,
|
||
"account_name": "Excel主店",
|
||
"alias": "alias-a",
|
||
"item_id": "51100639510",
|
||
}
|
||
],
|
||
path=cfg["db_path"],
|
||
)
|
||
task = db.list_tasks(batch_id=batch_id, path=cfg["db_path"])[0]
|
||
db.set_collected(task.id, "旧标题", "old.jpg", path=cfg["db_path"])
|
||
db.set_generated(task.id, "新标题", "new.jpg", path=cfg["db_path"])
|
||
statuses = []
|
||
tab = ApplyTab(config=cfg, status_callback=statuses.append)
|
||
self.addCleanup(tab.close)
|
||
tab.run_log_view.setPlainText("上一轮检查失败")
|
||
|
||
class FakeSignal:
|
||
def __init__(self):
|
||
self.callbacks = []
|
||
|
||
def connect(self, callback):
|
||
self.callbacks.append(callback)
|
||
|
||
class FakeThread:
|
||
def __init__(self):
|
||
self.finished = FakeSignal()
|
||
self.started = False
|
||
|
||
def start(self):
|
||
self.started = True
|
||
|
||
fake_thread = FakeThread()
|
||
with mock.patch(
|
||
"app.gui.QMessageBox.question",
|
||
return_value=gui.QMessageBox.Yes,
|
||
), mock.patch("app.gui.QMessageBox.warning") as warning, \
|
||
mock.patch("app.gui.run_worker", return_value=fake_thread):
|
||
tab.preview_update()
|
||
|
||
warning.assert_not_called()
|
||
self.assertTrue(tab.apply_worker.dry_run)
|
||
self.assertEqual(2, tab.apply_worker.max_parallel_accounts)
|
||
log_text = tab.run_log_view.toPlainText()
|
||
self.assertNotIn("上一轮检查失败", log_text)
|
||
self.assertIn("本轮检查开始:任务 1 条,更新内容:只更新标题,每批 1 条", log_text)
|
||
self.assertEqual("开始检查本轮更新:1 条", statuses[-1])
|
||
|
||
self.assert_removed(temp_dir)
|
||
|
||
def test_apply_tab_old_real_submit_switch_does_not_block_final_confirmation(self):
|
||
with self.make_temp_dir() as temp_dir:
|
||
cfg = self.make_config(temp_dir)
|
||
cfg["shopee_update"] = {
|
||
"allow_real_submit": False,
|
||
"max_items_per_run": 1,
|
||
}
|
||
accounts.create_account("主店", "alias-a", debug_port=9222, config=cfg)
|
||
batch_id = db.create_batch(["input.xlsx"], path=cfg["db_path"])
|
||
db.insert_tasks(
|
||
batch_id,
|
||
[
|
||
{
|
||
"source_file_abs": os.path.join(temp_dir, "input.xlsx"),
|
||
"source_sheet": "商品",
|
||
"source_row": 2,
|
||
"account_name": "Excel主店",
|
||
"alias": "alias-a",
|
||
"item_id": "51100639510",
|
||
}
|
||
],
|
||
path=cfg["db_path"],
|
||
)
|
||
task = db.list_tasks(batch_id=batch_id, path=cfg["db_path"])[0]
|
||
db.set_collected(task.id, "旧标题", "old.jpg", path=cfg["db_path"])
|
||
db.set_generated(task.id, "新标题", "new.jpg", path=cfg["db_path"])
|
||
statuses = []
|
||
tab = ApplyTab(config=cfg, status_callback=statuses.append)
|
||
self.addCleanup(tab.close)
|
||
tab.item_filter.setText("51100639510")
|
||
|
||
class FakeSignal:
|
||
def connect(self, callback):
|
||
self.callback = callback
|
||
|
||
class FakeThread:
|
||
def __init__(self):
|
||
self.finished = FakeSignal()
|
||
self.started = False
|
||
|
||
def start(self):
|
||
self.started = True
|
||
|
||
fake_thread = FakeThread()
|
||
with mock.patch(
|
||
"app.gui.QMessageBox.question",
|
||
return_value=gui.QMessageBox.Yes,
|
||
) as question, mock.patch("app.gui.run_worker", return_value=fake_thread) as run_worker:
|
||
tab.start_update()
|
||
|
||
message = question.call_args[0][2]
|
||
self.assertIn("即将按当前筛选结果分批更新蝦皮线上商品", message)
|
||
self.assertIn("任务数:1", message)
|
||
run_worker.assert_called_once()
|
||
self.assertTrue(fake_thread.started)
|
||
self.assertEqual("开始更新:1 条,按每批最多 1 条执行", statuses[-1])
|
||
|
||
self.assert_removed(temp_dir)
|
||
|
||
def test_apply_tab_blocks_cover_update_when_cover_is_missing_for_selected_mode(self):
|
||
with self.make_temp_dir() as temp_dir:
|
||
cfg = self.make_config(temp_dir)
|
||
self.allow_shopee_update(cfg, allow_cover=False)
|
||
accounts.create_account("主店", "alias-a", debug_port=9222, config=cfg)
|
||
batch_id = db.create_batch(["input.xlsx"], path=cfg["db_path"])
|
||
db.insert_tasks(
|
||
batch_id,
|
||
[
|
||
{
|
||
"source_file_abs": os.path.join(temp_dir, "input.xlsx"),
|
||
"source_sheet": "商品",
|
||
"source_row": 2,
|
||
"account_name": "Excel主店",
|
||
"alias": "alias-a",
|
||
"item_id": "51100639510",
|
||
}
|
||
],
|
||
path=cfg["db_path"],
|
||
)
|
||
task = db.list_tasks(batch_id=batch_id, path=cfg["db_path"])[0]
|
||
db.set_collected(task.id, "旧标题", "old.jpg", path=cfg["db_path"])
|
||
db.set_generated(task.id, "新标题", None, path=cfg["db_path"])
|
||
tab = ApplyTab(config=cfg)
|
||
self.addCleanup(tab.close)
|
||
tab.update_mode_combo.setCurrentIndex(tab.update_mode_combo.findData("cover"))
|
||
|
||
with mock.patch("app.gui.QMessageBox.warning") as warning, \
|
||
mock.patch("app.gui.QMessageBox.question") as question, \
|
||
mock.patch("app.gui.run_worker") as run_worker:
|
||
tab.start_update()
|
||
|
||
warning.assert_called_once()
|
||
self.assertEqual("更新内容未生成", warning.call_args[0][1])
|
||
message = warning.call_args[0][2]
|
||
self.assertIn("缺少新封面", message)
|
||
self.assertIn("只更新封面", message)
|
||
self.assertIn("51100639510", message)
|
||
question.assert_not_called()
|
||
run_worker.assert_not_called()
|
||
|
||
self.assert_removed(temp_dir)
|
||
|
||
def test_apply_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 = []
|
||
foreground_flags = []
|
||
progress = []
|
||
rows = []
|
||
|
||
def fake_apply(account, task, close_success_tab=False, on_step=None, bring_to_front=True, update_mode=None):
|
||
applied_aliases.append(account.alias)
|
||
foreground_flags.append(bring_to_front)
|
||
if account.alias == "alias-a":
|
||
return {"committed": True, "error": None}
|
||
if account.alias == "alias-b":
|
||
return {"committed": False, "error": "UPDATE_DISABLED"}
|
||
raise AssertionError(account.alias)
|
||
|
||
with mock.patch("app.gui.chrome.is_running", return_value=True), \
|
||
mock.patch(
|
||
"app.gui.accounts.detect_login",
|
||
return_value={"logged_in": True, "reason": None},
|
||
), mock.patch("app.gui.editor.apply_task", side_effect=fake_apply):
|
||
worker = ApplyWorker(
|
||
tasks,
|
||
db_path=cfg["db_path"],
|
||
config=cfg,
|
||
batch_size=1,
|
||
)
|
||
worker.progress.connect(progress.append)
|
||
worker.row_updated.connect(lambda task_id, fields: rows.append((task_id, fields)))
|
||
summary = worker.execute()
|
||
|
||
self.assertEqual(["alias-a", "alias-b"], applied_aliases)
|
||
self.assertEqual([True, True], foreground_flags)
|
||
self.assertFalse(summary["ok"])
|
||
self.assertEqual(3, summary["total"])
|
||
self.assertEqual(3, summary["done"])
|
||
self.assertEqual(1, summary["applied"])
|
||
self.assertEqual(1, summary["skipped"])
|
||
self.assertEqual(1, summary["failed"])
|
||
self.assertEqual([batch_id], summary["batch_ids"])
|
||
self.assertFalse(summary["dry_run"])
|
||
self.assertFalse(summary["account_parallel"])
|
||
self.assertEqual(1, summary["batch_size"])
|
||
self.assertEqual(3, summary["batch_count"])
|
||
self.assertIsNotNone(summary["run_id"])
|
||
self.assertEqual(3, progress[-1]["done"])
|
||
self.assertEqual(3, progress[-1]["total"])
|
||
self.assertEqual(1, progress[-1]["applied"])
|
||
self.assertEqual(1, progress[-1]["skipped"])
|
||
self.assertEqual(1, progress[-1]["failed"])
|
||
self.assertFalse(progress[-1]["dry_run"])
|
||
self.assertEqual(1, progress[-1]["batch_size"])
|
||
self.assertEqual(3, progress[-1]["batch_count"])
|
||
updated = db.list_tasks(batch_id=batch_id, path=cfg["db_path"])
|
||
by_alias = {task.alias: task for task in updated}
|
||
self.assertEqual("applied", by_alias["alias-a"].stage)
|
||
self.assertEqual("success", by_alias["alias-a"].status)
|
||
self.assertEqual(1, by_alias["alias-a"].committed)
|
||
self.assertEqual("generated", by_alias["alias-b"].stage)
|
||
self.assertEqual("failed", by_alias["alias-b"].status)
|
||
self.assertEqual("更新商品失败:UPDATE_DISABLED", by_alias["alias-b"].last_error)
|
||
self.assertEqual(0, by_alias["alias-b"].committed)
|
||
self.assertEqual("skipped", by_alias["missing"].status)
|
||
self.assertEqual("别名未匹配账号", by_alias["missing"].last_error)
|
||
self.assertTrue(any(fields.get("stage") == "applied" for _task_id, fields in rows))
|
||
self.assertTrue(any(fields.get("status") == "failed" for _task_id, fields in rows))
|
||
run_logs = db.list_run_logs(run_type="apply", path=cfg["db_path"])
|
||
self.assertEqual(1, len(run_logs))
|
||
self.assertEqual("done", run_logs[0].status)
|
||
events = db.list_run_log_events(run_logs[0].id, path=cfg["db_path"])
|
||
self.assertGreaterEqual(len(events), 3)
|
||
self.assertTrue(any("更新批次 1/3" in event.message for event in events))
|
||
|
||
self.assert_removed(temp_dir)
|
||
|
||
def test_apply_worker_brings_each_account_to_front_only_once_across_batches(self):
|
||
with self.make_temp_dir() as temp_dir:
|
||
cfg = self.make_config(temp_dir)
|
||
accounts.create_account("主店", "alias-a", debug_port=9222, config=cfg)
|
||
batch_id = db.create_batch(["input.xlsx"], path=cfg["db_path"])
|
||
db.insert_tasks(
|
||
batch_id,
|
||
[
|
||
{
|
||
"source_file_abs": os.path.join(temp_dir, "input.xlsx"),
|
||
"source_sheet": "商品",
|
||
"source_row": 2,
|
||
"account_name": "Excel主店",
|
||
"alias": "alias-a",
|
||
"item_id": "51100639510",
|
||
},
|
||
{
|
||
"source_file_abs": os.path.join(temp_dir, "input.xlsx"),
|
||
"source_sheet": "商品",
|
||
"source_row": 3,
|
||
"account_name": "Excel主店",
|
||
"alias": "alias-a",
|
||
"item_id": "51100639511",
|
||
},
|
||
],
|
||
path=cfg["db_path"],
|
||
)
|
||
for task in db.list_tasks(batch_id=batch_id, path=cfg["db_path"]):
|
||
db.set_collected(task.id, "旧标题", "old.jpg", path=cfg["db_path"])
|
||
db.set_generated(task.id, "新标题", "new.jpg", path=cfg["db_path"])
|
||
tasks = db.list_tasks(batch_id=batch_id, path=cfg["db_path"])
|
||
foreground_flags = []
|
||
|
||
def fake_apply(account, task, close_success_tab=False, on_step=None, bring_to_front=True, update_mode=None):
|
||
foreground_flags.append(bring_to_front)
|
||
return {"committed": True, "error": None}
|
||
|
||
with mock.patch("app.gui.chrome.is_running", return_value=True), \
|
||
mock.patch(
|
||
"app.gui.accounts.detect_login",
|
||
return_value={"logged_in": True, "reason": None},
|
||
), mock.patch("app.gui.editor.apply_task", side_effect=fake_apply):
|
||
summary = ApplyWorker(
|
||
tasks,
|
||
db_path=cfg["db_path"],
|
||
config=cfg,
|
||
batch_size=1,
|
||
).execute()
|
||
|
||
self.assertTrue(summary["ok"])
|
||
self.assertEqual([True, False], foreground_flags)
|
||
self.assertEqual(2, summary["batch_count"])
|
||
|
||
self.assert_removed(temp_dir)
|
||
|
||
def test_apply_worker_dry_run_only_previews_and_logs_without_mutating_tasks(self):
|
||
with self.make_temp_dir() as temp_dir:
|
||
cfg = self.make_config(temp_dir)
|
||
accounts.create_account("主店", "alias-a", debug_port=9222, config=cfg)
|
||
batch_id = db.create_batch(["input.xlsx"], path=cfg["db_path"])
|
||
db.insert_tasks(
|
||
batch_id,
|
||
[
|
||
{
|
||
"source_file_abs": os.path.join(temp_dir, "input.xlsx"),
|
||
"source_sheet": "商品",
|
||
"source_row": 2,
|
||
"account_name": "Excel主店",
|
||
"alias": "alias-a",
|
||
"item_id": "51100639510",
|
||
},
|
||
{
|
||
"source_file_abs": os.path.join(temp_dir, "input.xlsx"),
|
||
"source_sheet": "商品",
|
||
"source_row": 3,
|
||
"account_name": "Excel未知",
|
||
"alias": "missing",
|
||
"item_id": "51100639511",
|
||
},
|
||
],
|
||
path=cfg["db_path"],
|
||
)
|
||
for task in db.list_tasks(batch_id=batch_id, path=cfg["db_path"]):
|
||
db.set_collected(task.id, "旧标题", "old.jpg", path=cfg["db_path"])
|
||
db.set_generated(task.id, "新标题", "new.jpg", path=cfg["db_path"])
|
||
tasks = db.list_tasks(batch_id=batch_id, path=cfg["db_path"])
|
||
logs = []
|
||
|
||
with mock.patch("app.gui.chrome.is_running") as is_running, \
|
||
mock.patch("app.gui.accounts.detect_login") as detect_login, \
|
||
mock.patch("app.gui.editor.apply_task") as apply_task:
|
||
worker = ApplyWorker(
|
||
tasks,
|
||
db_path=cfg["db_path"],
|
||
config=cfg,
|
||
dry_run=True,
|
||
max_parallel_accounts=2,
|
||
)
|
||
worker.log.connect(logs.append)
|
||
summary = worker.execute()
|
||
|
||
is_running.assert_not_called()
|
||
detect_login.assert_not_called()
|
||
apply_task.assert_not_called()
|
||
self.assertTrue(summary["ok"])
|
||
self.assertTrue(summary["dry_run"])
|
||
self.assertEqual(2, summary["done"])
|
||
self.assertEqual(1, summary["applied"])
|
||
self.assertEqual(1, summary["skipped"])
|
||
unchanged = db.list_tasks(batch_id=batch_id, path=cfg["db_path"])
|
||
self.assertTrue(all(task.stage == "generated" for task in unchanged))
|
||
self.assertTrue(all(task.status == "success" for task in unchanged))
|
||
self.assertTrue(any("检查" in line for line in logs))
|
||
run_log = db.list_run_logs(run_type="apply", path=cfg["db_path"])[0]
|
||
self.assertEqual(1, run_log.dry_run)
|
||
self.assertEqual("done", run_log.status)
|
||
events = db.list_run_log_events(run_log.id, path=cfg["db_path"])
|
||
self.assertTrue(any("将更新" in event.message for event in events))
|
||
|
||
self.assert_removed(temp_dir)
|
||
|
||
def test_apply_worker_parallel_accounts_runs_different_accounts_concurrently(self):
|
||
with self.make_temp_dir() as temp_dir:
|
||
cfg = self.make_config(temp_dir)
|
||
accounts.create_account("主店", "alias-a", debug_port=9222, config=cfg)
|
||
accounts.create_account("副店", "alias-b", debug_port=9223, config=cfg)
|
||
batch_id = db.create_batch(["input.xlsx"], path=cfg["db_path"])
|
||
db.insert_tasks(
|
||
batch_id,
|
||
[
|
||
{
|
||
"source_file_abs": os.path.join(temp_dir, "input.xlsx"),
|
||
"source_sheet": "商品",
|
||
"source_row": 2,
|
||
"account_name": "Excel主店",
|
||
"alias": "alias-a",
|
||
"item_id": "51100639510",
|
||
},
|
||
{
|
||
"source_file_abs": os.path.join(temp_dir, "input.xlsx"),
|
||
"source_sheet": "商品",
|
||
"source_row": 3,
|
||
"account_name": "Excel副店",
|
||
"alias": "alias-b",
|
||
"item_id": "51100639511",
|
||
},
|
||
],
|
||
path=cfg["db_path"],
|
||
)
|
||
for task in db.list_tasks(batch_id=batch_id, path=cfg["db_path"]):
|
||
db.set_collected(task.id, "旧标题", "old.jpg", path=cfg["db_path"])
|
||
db.set_generated(task.id, "新标题", "new.jpg", path=cfg["db_path"])
|
||
tasks = db.list_tasks(batch_id=batch_id, path=cfg["db_path"])
|
||
started = {"alias-a": threading.Event(), "alias-b": threading.Event()}
|
||
thread_names = set()
|
||
foreground_by_alias = {}
|
||
|
||
def fake_apply(account, task, close_success_tab=False, on_step=None, bring_to_front=True, update_mode=None):
|
||
thread_names.add(threading.current_thread().name)
|
||
foreground_by_alias[account.alias] = bring_to_front
|
||
started[account.alias].set()
|
||
other = "alias-b" if account.alias == "alias-a" else "alias-a"
|
||
self.assertTrue(started[other].wait(2))
|
||
return {"committed": True, "error": None}
|
||
|
||
with mock.patch("app.gui.chrome.is_running", return_value=True), \
|
||
mock.patch(
|
||
"app.gui.accounts.detect_login",
|
||
return_value={"logged_in": True, "reason": None},
|
||
), mock.patch("app.gui.editor.apply_task", side_effect=fake_apply):
|
||
summary = ApplyWorker(
|
||
tasks,
|
||
db_path=cfg["db_path"],
|
||
config=cfg,
|
||
max_parallel_accounts=2,
|
||
).execute()
|
||
|
||
self.assertTrue(summary["ok"])
|
||
self.assertTrue(summary["account_parallel"])
|
||
self.assertEqual(2, summary["applied"])
|
||
self.assertGreaterEqual(len(thread_names), 2)
|
||
self.assertEqual({"alias-a": True, "alias-b": True}, foreground_by_alias)
|
||
updated = db.list_tasks(batch_id=batch_id, path=cfg["db_path"])
|
||
self.assertTrue(all(task.stage == "applied" for task in updated))
|
||
|
||
self.assert_removed(temp_dir)
|
||
|
||
def test_apply_worker_blocks_real_update_when_required_accounts_share_debug_port(self):
|
||
with self.make_temp_dir() as temp_dir:
|
||
cfg = self.make_config(temp_dir)
|
||
db.init_db(cfg["db_path"])
|
||
db.add_account("主店", "alias-a", "seller.shopee.tw", 9222, path=cfg["db_path"])
|
||
db.add_account("副店", "alias-b", "seller.shopee.tw", 9222, path=cfg["db_path"])
|
||
batch_id = db.create_batch(["input.xlsx"], path=cfg["db_path"])
|
||
db.insert_tasks(
|
||
batch_id,
|
||
[
|
||
{
|
||
"source_file_abs": os.path.join(temp_dir, "input.xlsx"),
|
||
"source_sheet": "商品",
|
||
"source_row": 2,
|
||
"account_name": "Excel主店",
|
||
"alias": "alias-a",
|
||
"item_id": "51100639510",
|
||
},
|
||
{
|
||
"source_file_abs": os.path.join(temp_dir, "input.xlsx"),
|
||
"source_sheet": "商品",
|
||
"source_row": 3,
|
||
"account_name": "Excel副店",
|
||
"alias": "alias-b",
|
||
"item_id": "51100639511",
|
||
},
|
||
],
|
||
path=cfg["db_path"],
|
||
)
|
||
for task in db.list_tasks(batch_id=batch_id, path=cfg["db_path"]):
|
||
db.set_collected(task.id, "旧标题", "old.jpg", path=cfg["db_path"])
|
||
db.set_generated(task.id, "新标题", "new.jpg", path=cfg["db_path"])
|
||
tasks = db.list_tasks(batch_id=batch_id, path=cfg["db_path"])
|
||
|
||
with mock.patch("app.gui.chrome.is_running") as is_running, \
|
||
mock.patch("app.gui.editor.apply_task") as apply_task:
|
||
summary = ApplyWorker(
|
||
tasks,
|
||
db_path=cfg["db_path"],
|
||
config=cfg,
|
||
max_parallel_accounts=2,
|
||
).execute()
|
||
|
||
self.assertTrue(summary["blocked"])
|
||
self.assertEqual("DUPLICATE_DEBUG_PORT", summary["reason"])
|
||
self.assertEqual(9222, summary["duplicate_ports"][0]["debug_port"])
|
||
self.assertEqual(["alias-a", "alias-b"], summary["duplicate_ports"][0]["aliases"])
|
||
is_running.assert_not_called()
|
||
apply_task.assert_not_called()
|
||
run_log = db.list_run_logs(run_type="apply", path=cfg["db_path"])[0]
|
||
self.assertEqual("blocked", run_log.status)
|
||
|
||
self.assert_removed(temp_dir)
|
||
|
||
def test_apply_tab_auto_starts_result_write_back_and_shows_summary(self):
|
||
with self.make_temp_dir() as temp_dir:
|
||
cfg = self.make_config(temp_dir)
|
||
db.init_db(cfg["db_path"])
|
||
batch_id = db.create_batch(["input.xlsx"], path=cfg["db_path"])
|
||
db.insert_tasks(
|
||
batch_id,
|
||
[
|
||
{
|
||
"source_file_abs": os.path.join(temp_dir, "input.xlsx"),
|
||
"source_sheet": "商品",
|
||
"source_row": 2,
|
||
"account_name": "Excel主店",
|
||
"alias": "alias-a",
|
||
"item_id": "51100639510",
|
||
}
|
||
],
|
||
path=cfg["db_path"],
|
||
)
|
||
task = db.list_tasks(batch_id=batch_id, path=cfg["db_path"])[0]
|
||
db.set_collected(task.id, "旧标题", "old.jpg", path=cfg["db_path"])
|
||
db.set_generated(task.id, "新标题", "new.jpg", path=cfg["db_path"])
|
||
db.set_applied(task.id, True, path=cfg["db_path"])
|
||
statuses = []
|
||
tab = ApplyTab(config=cfg, status_callback=statuses.append)
|
||
self.addCleanup(tab.close)
|
||
payload = {
|
||
"done": 1,
|
||
"total": 1,
|
||
"applied": 1,
|
||
"failed": 0,
|
||
"skipped": 0,
|
||
"batch_ids": [batch_id],
|
||
}
|
||
|
||
with mock.patch.object(tab, "_start_result_write_back", return_value=True) as start_write_back:
|
||
tab._on_apply_finished(payload)
|
||
|
||
start_write_back.assert_called_once_with(
|
||
[batch_id],
|
||
auto=True,
|
||
apply_summary=payload,
|
||
)
|
||
self.assertIn("正在自动回写结果到 Excel", statuses[-1])
|
||
|
||
self.assert_removed(temp_dir)
|
||
|
||
def test_apply_tab_result_write_back_finished_shows_completion_popup(self):
|
||
with self.make_temp_dir() as temp_dir:
|
||
statuses = []
|
||
tab = ApplyTab(config=self.make_config(temp_dir), status_callback=statuses.append)
|
||
self.addCleanup(tab.close)
|
||
summary = {"applied": 2, "failed": 1, "skipped": 1}
|
||
write_back_payload = {"ok": True, "files": 1, "rows": 4}
|
||
|
||
with mock.patch("app.gui.QMessageBox.information") as info:
|
||
tab._on_result_write_back_finished(
|
||
write_back_payload,
|
||
auto=True,
|
||
apply_summary=summary,
|
||
)
|
||
|
||
message = info.call_args[0][2]
|
||
self.assertIn("成功:2,失败:1,略过:1", message)
|
||
self.assertIn("Excel 回写:文件1,行4", message)
|
||
self.assertIn("Excel 自动回写更新结果完成", statuses[-1])
|
||
|
||
self.assert_removed(temp_dir)
|
||
|
||
def test_apply_tab_result_write_back_locked_file_message_points_to_manual_retry(self):
|
||
with self.make_temp_dir() as temp_dir:
|
||
statuses = []
|
||
tab = ApplyTab(config=self.make_config(temp_dir), status_callback=statuses.append)
|
||
self.addCleanup(tab.close)
|
||
summary = {"applied": 1, "failed": 0, "skipped": 0}
|
||
|
||
with mock.patch("app.gui.QMessageBox.warning") as warning:
|
||
tab._on_result_write_back_failed(
|
||
-1,
|
||
"Excel 文件被占用,请关闭后重试: input.xlsx",
|
||
auto=True,
|
||
apply_summary=summary,
|
||
)
|
||
|
||
message = warning.call_args[0][2]
|
||
self.assertIn("成功:1,失败:0,略过:0", message)
|
||
self.assertIn("点击「回写结果到 Excel」手动重试", message)
|
||
self.assertIn("手动重试", statuses[-1])
|
||
|
||
self.assert_removed(temp_dir)
|
||
|
||
def test_apply_worker_preflight_blocks_when_chrome_not_running(self):
|
||
with self.make_temp_dir() as temp_dir:
|
||
cfg = self.make_config(temp_dir)
|
||
accounts.create_account("主店", "alias-a", debug_port=9222, config=cfg)
|
||
batch_id = db.create_batch(["input.xlsx"], path=cfg["db_path"])
|
||
db.insert_tasks(
|
||
batch_id,
|
||
[
|
||
{
|
||
"source_file_abs": os.path.join(temp_dir, "input.xlsx"),
|
||
"source_sheet": "商品",
|
||
"source_row": 2,
|
||
"account_name": "Excel主店",
|
||
"alias": "alias-a",
|
||
"item_id": "51100639510",
|
||
}
|
||
],
|
||
path=cfg["db_path"],
|
||
)
|
||
task = db.list_tasks(batch_id=batch_id, path=cfg["db_path"])[0]
|
||
db.set_collected(task.id, "旧标题", "old.jpg", path=cfg["db_path"])
|
||
db.set_generated(task.id, "新标题", "new.jpg", path=cfg["db_path"])
|
||
tasks = db.list_tasks(batch_id=batch_id, path=cfg["db_path"])
|
||
|
||
with mock.patch("app.gui.chrome.is_running", return_value=False) as is_running, \
|
||
mock.patch("app.gui.accounts.detect_login") as detect_login, \
|
||
mock.patch("app.gui.accounts.launch_for_login") as launch_for_login, \
|
||
mock.patch("app.gui.editor.apply_task") as apply_task:
|
||
summary = ApplyWorker(tasks, db_path=cfg["db_path"], config=cfg).execute()
|
||
|
||
self.assertTrue(summary["blocked"])
|
||
self.assertEqual("ACCOUNT_NOT_READY", summary["reason"])
|
||
self.assertEqual("alias-a", summary["not_running"][0]["alias"])
|
||
is_running.assert_called_once_with(9222)
|
||
detect_login.assert_not_called()
|
||
launch_for_login.assert_not_called()
|
||
apply_task.assert_not_called()
|
||
unchanged = db.list_tasks(batch_id=batch_id, path=cfg["db_path"])[0]
|
||
self.assertEqual("generated", unchanged.stage)
|
||
self.assertEqual("success", unchanged.status)
|
||
self.assertIsNone(unchanged.last_error)
|
||
|
||
self.assert_removed(temp_dir)
|
||
|
||
def test_apply_tab_blocked_preflight_guides_to_accounts_tab(self):
|
||
with self.make_temp_dir() as temp_dir:
|
||
opened = []
|
||
statuses = []
|
||
tab = ApplyTab(
|
||
config=self.make_config(temp_dir),
|
||
status_callback=statuses.append,
|
||
open_accounts_callback=lambda: opened.append(True),
|
||
)
|
||
self.addCleanup(tab.close)
|
||
payload = {
|
||
"blocked": True,
|
||
"not_running": [
|
||
{
|
||
"account_name": "主店",
|
||
"alias": "alias-a",
|
||
"reason": "CDP 端口未响应",
|
||
}
|
||
],
|
||
"logged_out": [],
|
||
}
|
||
|
||
with mock.patch("app.gui.QMessageBox.warning") as warning:
|
||
tab._on_apply_finished(payload)
|
||
|
||
message = warning.call_args[0][2]
|
||
self.assertIn("Chrome 未启动", message)
|
||
self.assertIn("本轮更新已中止", message)
|
||
self.assertIn("不会自动打开账号 Chrome", message)
|
||
self.assertIn("不会提交任何商品", message)
|
||
self.assertIn("④ 账号管理", message)
|
||
self.assertEqual([True], opened)
|
||
self.assertIn("本轮更新已中止", statuses[-1])
|
||
self.assertIn("④ 账号管理", statuses[-1])
|
||
|
||
self.assert_removed(temp_dir)
|
||
|
||
def test_generate_tab_lists_tasks_and_filters_by_shop_status_and_batch(self):
|
||
with self.make_temp_dir() as temp_dir:
|
||
cfg = self.make_config(temp_dir)
|
||
accounts.create_account("主店", "alias-a", debug_port=9222, config=cfg)
|
||
accounts.create_account("副店", "alias-b", debug_port=9223, config=cfg)
|
||
batch_a = db.create_batch(["input-a.xlsx"], path=cfg["db_path"])
|
||
batch_b = db.create_batch(["input-b.xlsx"], path=cfg["db_path"])
|
||
db.insert_tasks(
|
||
batch_a,
|
||
[
|
||
{
|
||
"source_file_abs": os.path.join(temp_dir, "input-a.xlsx"),
|
||
"source_sheet": "商品",
|
||
"source_row": 2,
|
||
"account_name": "Excel主店",
|
||
"alias": "alias-a",
|
||
"item_id": "51100639510",
|
||
},
|
||
{
|
||
"source_file_abs": os.path.join(temp_dir, "input-a.xlsx"),
|
||
"source_sheet": "商品",
|
||
"source_row": 3,
|
||
"account_name": "Excel副店",
|
||
"alias": "alias-b",
|
||
"item_id": "51100639511",
|
||
},
|
||
{
|
||
"source_file_abs": os.path.join(temp_dir, "input-a.xlsx"),
|
||
"source_sheet": "商品",
|
||
"source_row": 4,
|
||
"account_name": "Excel主店",
|
||
"alias": "alias-a",
|
||
"item_id": "51100639512",
|
||
},
|
||
],
|
||
path=cfg["db_path"],
|
||
)
|
||
db.insert_tasks(
|
||
batch_b,
|
||
[
|
||
{
|
||
"source_file_abs": os.path.join(temp_dir, "input-b.xlsx"),
|
||
"source_sheet": "商品",
|
||
"source_row": 2,
|
||
"account_name": "Excel主店",
|
||
"alias": "alias-a",
|
||
"item_id": "51100639513",
|
||
}
|
||
],
|
||
path=cfg["db_path"],
|
||
)
|
||
tasks_a = db.list_tasks(batch_id=batch_a, path=cfg["db_path"])
|
||
tasks_b = db.list_tasks(batch_id=batch_b, path=cfg["db_path"])
|
||
db.set_collected(tasks_a[0].id, "旧标题A", "old-a.jpg", path=cfg["db_path"])
|
||
db.set_collected(tasks_a[1].id, "旧标题B", "old-b.jpg", path=cfg["db_path"])
|
||
db.set_collected(tasks_a[2].id, "旧标题C", "old-c.jpg", path=cfg["db_path"])
|
||
db.set_generated(tasks_a[2].id, "新标题C", "new-c.jpg", path=cfg["db_path"])
|
||
db.mark_failed(tasks_b[0].id, "generate", "生成失败", path=cfg["db_path"])
|
||
|
||
tab = GenerateTab(config=cfg)
|
||
self.addCleanup(tab.close)
|
||
tab.batch_filter.setCurrentIndex(tab.batch_filter.findData(None))
|
||
|
||
self.assertEqual("generateItemFilter", tab.item_filter.objectName())
|
||
self.assertEqual(4, tab.model.rowCount())
|
||
self.assertEqual("主店", tab.model.index(0, 0).data())
|
||
self.assertEqual("51100639510", tab.model.index(0, 1).data())
|
||
self.assertEqual("旧标题A", tab.model.index(0, 2).data())
|
||
self.assertEqual("", tab.model.index(0, 3).data())
|
||
self.assertEqual("待生成", tab.model.index(0, 4).data())
|
||
self.assert_foreground(tab.model, 0, 4, gui.COLOR_PENDING)
|
||
self.assertEqual("已生成", tab.model.index(2, 4).data())
|
||
self.assert_foreground(tab.model, 2, 4, gui.COLOR_SUCCESS)
|
||
|
||
tab.status_filter.setCurrentIndex(tab.status_filter.findData("to_generate"))
|
||
self.assertEqual(2, tab.model.rowCount())
|
||
self.assertTrue(all(tab.model.index(row, 4).data() == "待生成" for row in range(2)))
|
||
self.assertEqual("任务 2/4 条", tab.summary_label.text())
|
||
|
||
tab.shop_filter.setCurrentIndex(tab.shop_filter.findData("alias-b"))
|
||
self.assertEqual(1, tab.model.rowCount())
|
||
self.assertEqual("副店", tab.model.index(0, 0).data())
|
||
|
||
tab.status_filter.setCurrentIndex(tab.status_filter.findData("all"))
|
||
tab.batch_filter.setCurrentIndex(tab.batch_filter.findData(batch_b))
|
||
self.assertEqual(1, tab.model.rowCount())
|
||
self.assertEqual("失败", tab.model.index(0, 4).data())
|
||
self.assert_foreground(tab.model, 0, 4, gui.COLOR_DANGER)
|
||
self.assertEqual(
|
||
"生成失败",
|
||
tab.model.data(tab.model.index(0, 0), gui.Qt.ToolTipRole),
|
||
)
|
||
|
||
tab.batch_filter.setCurrentIndex(tab.batch_filter.findData(None))
|
||
tab.status_filter.setCurrentIndex(tab.status_filter.findData("all"))
|
||
tab.item_filter.setText("639512")
|
||
self.assertEqual(1, tab.model.rowCount())
|
||
self.assertEqual("51100639512", tab.model.index(0, 1).data())
|
||
self.assertEqual("任务 1/4 条", tab.summary_label.text())
|
||
|
||
self.assert_removed(temp_dir)
|
||
|
||
def test_tab_switch_updates_status_bar(self):
|
||
with self.make_temp_dir() as temp_dir:
|
||
window = MainWindow(config=self.make_config(temp_dir))
|
||
self.addCleanup(window.close)
|
||
|
||
window.tabs.setCurrentIndex(2)
|
||
|
||
self.assertEqual("③ 更新蝦皮", window.tabs.tabText(window.tabs.currentIndex()))
|
||
self.assertEqual("当前:③ 更新蝦皮", window.statusBar().currentMessage())
|
||
|
||
self.assert_removed(temp_dir)
|
||
|
||
def test_accounts_tab_lists_accounts_without_showing_password(self):
|
||
with self.make_temp_dir() as temp_dir:
|
||
cfg = self.make_config(temp_dir)
|
||
accounts.create_account(
|
||
"主店",
|
||
"alias",
|
||
"seller.shopee.tw",
|
||
9222,
|
||
password="secret",
|
||
config=cfg,
|
||
)
|
||
|
||
window = MainWindow(config=cfg)
|
||
self.addCleanup(window.close)
|
||
tab = window.tabs.widget(TAB_TITLES.index("④ 账号管理"))
|
||
|
||
self.assertIsInstance(tab, AccountsTab)
|
||
self.assertEqual(1, tab.table.rowCount())
|
||
self.assertEqual("主店", tab.table.item(0, 0).text())
|
||
self.assertEqual("alias", tab.table.item(0, 1).text())
|
||
self.assertEqual("● 未知", tab.table.item(0, 4).text())
|
||
self.assertEqual(gui.COLOR_MUTED, tab.table.item(0, 4).foreground().color().name())
|
||
self.assertEqual("deleteAccountButton", tab.delete_button.objectName())
|
||
self.assertIn(gui.COLOR_DANGER, tab.delete_button.styleSheet())
|
||
self.assertNotIn("border-radius", tab.delete_button.styleSheet())
|
||
self.assertNotIn("padding", tab.delete_button.styleSheet())
|
||
visible_values = [
|
||
tab.table.item(0, column).text()
|
||
for column in range(tab.table.columnCount())
|
||
if tab.table.item(0, column) is not None
|
||
]
|
||
self.assertNotIn("secret", visible_values)
|
||
|
||
self.assert_removed(temp_dir)
|
||
|
||
def test_account_dialog_masks_password_field(self):
|
||
with self.make_temp_dir() as temp_dir:
|
||
cfg = self.make_config(temp_dir)
|
||
account = accounts.create_account(
|
||
"主店",
|
||
"alias",
|
||
debug_port=9222,
|
||
password="secret",
|
||
config=cfg,
|
||
)
|
||
|
||
dialog = AccountDialog(account=account, config=cfg)
|
||
self.addCleanup(dialog.close)
|
||
|
||
self.assertEqual(QLineEdit.Password, dialog.password_edit.echoMode())
|
||
self.assertEqual("secret", dialog.password_edit.text())
|
||
self.assertIn(account.slug, dialog.user_data_dir_edit.text())
|
||
|
||
self.assert_removed(temp_dir)
|
||
|
||
def test_accounts_tab_warns_before_saving_plaintext_password(self):
|
||
with self.make_temp_dir() as temp_dir:
|
||
cfg = self.make_config(temp_dir)
|
||
statuses = []
|
||
tab = AccountsTab(config=cfg, status_callback=statuses.append)
|
||
self.addCleanup(tab.close)
|
||
|
||
class FakeDialog:
|
||
def __init__(self, *args, **kwargs):
|
||
pass
|
||
|
||
def exec(self):
|
||
return gui.QDialog.Accepted
|
||
|
||
def values(self):
|
||
return {
|
||
"account_name": "主店",
|
||
"alias": "alias",
|
||
"region_host": "seller.shopee.tw",
|
||
"debug_port": 9222,
|
||
"password": "plain-password",
|
||
"note": "",
|
||
}
|
||
|
||
with mock.patch("app.gui.AccountDialog", FakeDialog), mock.patch(
|
||
"app.gui.QMessageBox.warning"
|
||
) as warning:
|
||
tab.add_account()
|
||
|
||
warning.assert_called_once()
|
||
self.assertIn("本地明文保存", warning.call_args[0][1])
|
||
self.assertIn("SQLite", warning.call_args[0][2])
|
||
stored = accounts.get_account("alias", config=cfg)
|
||
self.assertEqual("plain-password", stored.password)
|
||
visible_values = [
|
||
tab.table.item(0, column).text()
|
||
for column in range(tab.table.columnCount())
|
||
if tab.table.item(0, column) is not None
|
||
]
|
||
self.assertNotIn("plain-password", visible_values)
|
||
self.assertIn("账号已新增", statuses[-1])
|
||
self.assertNotIn("plain-password", statuses[-1])
|
||
|
||
self.assert_removed(temp_dir)
|
||
|
||
def test_accounts_tab_updates_login_status_cell(self):
|
||
with self.make_temp_dir() as temp_dir:
|
||
cfg = self.make_config(temp_dir)
|
||
accounts.create_account("主店", "alias", debug_port=9222, config=cfg)
|
||
tab = AccountsTab(config=cfg)
|
||
self.addCleanup(tab.close)
|
||
|
||
tab._on_login_check_finished(
|
||
{"alias": "alias", "status": {"logged_in": True, "reason": None}}
|
||
)
|
||
|
||
self.assertEqual("● 已登录", tab.table.item(0, 4).text())
|
||
self.assertEqual(gui.COLOR_SUCCESS, tab.table.item(0, 4).foreground().color().name())
|
||
|
||
self.assert_removed(temp_dir)
|
||
|
||
def test_accounts_tab_create_shortcut_for_selected_account(self):
|
||
with self.make_temp_dir() as temp_dir:
|
||
cfg = self.make_config(temp_dir)
|
||
account = accounts.create_account("主店", "alias", debug_port=9222, config=cfg)
|
||
statuses = []
|
||
tab = AccountsTab(config=cfg, status_callback=statuses.append)
|
||
self.addCleanup(tab.close)
|
||
tab.table.selectRow(0)
|
||
shortcut_path = os.path.join(temp_dir, "Desktop", "主店.lnk")
|
||
|
||
with mock.patch(
|
||
"app.gui.accounts.create_shortcut",
|
||
return_value=shortcut_path,
|
||
) as create_shortcut, mock.patch("app.gui.QMessageBox.information") as info:
|
||
tab.create_shortcut()
|
||
|
||
create_shortcut.assert_called_once_with(account, config=cfg)
|
||
info.assert_called_once()
|
||
self.assertIn(shortcut_path, statuses[-1])
|
||
|
||
self.assert_removed(temp_dir)
|
||
|
||
def test_collect_tab_lists_tasks_with_unmatched_alias_as_skipped(self):
|
||
with self.make_temp_dir() as temp_dir:
|
||
cfg = self.make_config(temp_dir)
|
||
accounts.create_account("主店", "alias-a", debug_port=9222, config=cfg)
|
||
batch_id = db.create_batch(["input.xlsx"], path=cfg["db_path"])
|
||
db.insert_tasks(
|
||
batch_id,
|
||
[
|
||
{
|
||
"source_file_abs": os.path.join(temp_dir, "input.xlsx"),
|
||
"source_sheet": "商品",
|
||
"source_row": 2,
|
||
"account_name": "Excel主店",
|
||
"alias": "alias-a",
|
||
"item_id": "51100639510",
|
||
},
|
||
{
|
||
"source_file_abs": os.path.join(temp_dir, "input.xlsx"),
|
||
"source_sheet": "商品",
|
||
"source_row": 3,
|
||
"account_name": "Excel副店",
|
||
"alias": "missing",
|
||
"item_id": "51100639511",
|
||
},
|
||
],
|
||
path=cfg["db_path"],
|
||
)
|
||
|
||
tab = CollectTab(config=cfg)
|
||
self.addCleanup(tab.close)
|
||
|
||
self.assertIsInstance(tab.table, QTableView)
|
||
self.assertEqual(2, tab.model.rowCount())
|
||
self.assertEqual("主店", tab.model.index(0, 0).data())
|
||
self.assertEqual("alias-a", tab.model.index(0, 1).data())
|
||
self.assertEqual("51100639510", tab.model.index(0, 2).data())
|
||
self.assertEqual("待采集", tab.model.index(0, 3).data())
|
||
self.assert_foreground(tab.model, 0, 3, gui.COLOR_PENDING)
|
||
self.assertEqual("deleteBatchButton", tab.delete_batch_button.objectName())
|
||
self.assertIn(gui.COLOR_DANGER, tab.delete_batch_button.styleSheet())
|
||
self.assertNotIn("border-radius", tab.delete_batch_button.styleSheet())
|
||
self.assertNotIn("padding", tab.delete_batch_button.styleSheet())
|
||
self.assertEqual("Excel副店", tab.model.index(1, 0).data())
|
||
self.assertEqual("missing", tab.model.index(1, 1).data())
|
||
self.assertEqual("略过", tab.model.index(1, 3).data())
|
||
self.assert_foreground(tab.model, 1, 3, gui.COLOR_MUTED)
|
||
self.assertIn("2 行", tab.summary_label.text())
|
||
self.assertIn("有效2/无效0", tab.summary_label.text())
|
||
self.assertIn("匹配1", tab.summary_label.text())
|
||
self.assertIn("未匹配1", tab.summary_label.text())
|
||
self.assertIn(gui.COLOR_DANGER, tab.summary_label.text())
|
||
self.assertIn(gui.COLOR_DANGER, tab.show_unmatched_button.styleSheet())
|
||
self.assertIn("border", tab.show_unmatched_button.styleSheet())
|
||
self.assertNotIn("border-radius", tab.show_unmatched_button.styleSheet())
|
||
self.assertNotIn("padding", tab.show_unmatched_button.styleSheet())
|
||
self.assertIn("主店1", tab.match_detail_label.text())
|
||
self.assertIn("未匹配", tab.empty_label.text())
|
||
|
||
self.assert_removed(temp_dir)
|
||
|
||
def test_collect_tab_filters_by_shop_item_status_and_collect_scope(self):
|
||
with self.make_temp_dir() as temp_dir:
|
||
cfg = self.make_config(temp_dir)
|
||
accounts.create_account("主店", "alias-a", debug_port=9222, config=cfg)
|
||
accounts.create_account("副店", "alias-b", debug_port=9223, config=cfg)
|
||
batch_id = db.create_batch(["input.xlsx"], path=cfg["db_path"])
|
||
db.insert_tasks(
|
||
batch_id,
|
||
[
|
||
{
|
||
"source_file_abs": os.path.join(temp_dir, "input.xlsx"),
|
||
"source_sheet": "商品",
|
||
"source_row": 2,
|
||
"account_name": "Excel主店",
|
||
"alias": "alias-a",
|
||
"item_id": "1001",
|
||
},
|
||
{
|
||
"source_file_abs": os.path.join(temp_dir, "input.xlsx"),
|
||
"source_sheet": "商品",
|
||
"source_row": 3,
|
||
"account_name": "Excel主店",
|
||
"alias": "alias-a",
|
||
"item_id": "1002",
|
||
},
|
||
{
|
||
"source_file_abs": os.path.join(temp_dir, "input.xlsx"),
|
||
"source_sheet": "商品",
|
||
"source_row": 4,
|
||
"account_name": "Excel副店",
|
||
"alias": "alias-b",
|
||
"item_id": "2001",
|
||
},
|
||
{
|
||
"source_file_abs": os.path.join(temp_dir, "input.xlsx"),
|
||
"source_sheet": "商品",
|
||
"source_row": 5,
|
||
"account_name": "Excel副店",
|
||
"alias": "alias-b",
|
||
"item_id": "2002",
|
||
},
|
||
{
|
||
"source_file_abs": os.path.join(temp_dir, "input.xlsx"),
|
||
"source_sheet": "商品",
|
||
"source_row": 6,
|
||
"account_name": "Excel未知",
|
||
"alias": "missing",
|
||
"item_id": "3001",
|
||
},
|
||
],
|
||
path=cfg["db_path"],
|
||
)
|
||
tasks = {task.item_id: task for task in db.list_tasks(batch_id=batch_id, path=cfg["db_path"])}
|
||
db.set_collected(tasks["1002"].id, "旧标题", os.path.join(temp_dir, "old.jpg"), path=cfg["db_path"])
|
||
db.mark_failed(tasks["2002"].id, "collect", "采集失败", path=cfg["db_path"])
|
||
|
||
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)
|
||
|
||
def test_collect_tab_soft_deletes_batch_and_refreshes_workflow_tabs(self):
|
||
with self.make_temp_dir() as temp_dir:
|
||
cfg = self.make_config(temp_dir)
|
||
accounts.create_account("主店", "alias-a", debug_port=9222, config=cfg)
|
||
batch_a = db.create_batch(["input-a.xlsx"], path=cfg["db_path"])
|
||
batch_b = db.create_batch(["input-b.xlsx"], path=cfg["db_path"])
|
||
db.insert_tasks(
|
||
batch_a,
|
||
[
|
||
{
|
||
"source_file_abs": os.path.join(temp_dir, "input-a.xlsx"),
|
||
"source_sheet": "商品",
|
||
"source_row": 2,
|
||
"account_name": "Excel主店",
|
||
"alias": "alias-a",
|
||
"item_id": "51100639510",
|
||
}
|
||
],
|
||
path=cfg["db_path"],
|
||
)
|
||
db.insert_tasks(
|
||
batch_b,
|
||
[
|
||
{
|
||
"source_file_abs": os.path.join(temp_dir, "input-b.xlsx"),
|
||
"source_sheet": "商品",
|
||
"source_row": 2,
|
||
"account_name": "Excel主店",
|
||
"alias": "alias-a",
|
||
"item_id": "51100639511",
|
||
}
|
||
],
|
||
path=cfg["db_path"],
|
||
)
|
||
deleted_task = db.list_tasks(batch_id=batch_a, path=cfg["db_path"])[0]
|
||
db.set_collected(deleted_task.id, "旧标题", "old.jpg", path=cfg["db_path"])
|
||
db.set_generated(deleted_task.id, "新标题", "new.jpg", path=cfg["db_path"])
|
||
db.set_applied(deleted_task.id, True, path=cfg["db_path"])
|
||
statuses = []
|
||
refresh_calls = []
|
||
generate_tab = GenerateTab(config=cfg)
|
||
apply_tab = ApplyTab(config=cfg)
|
||
|
||
def refresh_workflow():
|
||
refresh_calls.append(True)
|
||
generate_tab.refresh_tasks()
|
||
apply_tab.refresh_tasks()
|
||
|
||
collect_tab = CollectTab(
|
||
config=cfg,
|
||
status_callback=statuses.append,
|
||
refresh_workflow_callback=refresh_workflow,
|
||
)
|
||
self.addCleanup(collect_tab.close)
|
||
self.addCleanup(generate_tab.close)
|
||
self.addCleanup(apply_tab.close)
|
||
collect_tab.batch_filter.setCurrentIndex(collect_tab.batch_filter.findData(batch_a))
|
||
|
||
with mock.patch(
|
||
"app.gui.QMessageBox.question",
|
||
return_value=gui.QMessageBox.Yes,
|
||
) as question, mock.patch("app.gui.QMessageBox.information") as info:
|
||
collect_tab.delete_current_batch()
|
||
|
||
deleted_batch = db.get_batch(batch_a, path=cfg["db_path"], include_deleted=True)
|
||
self.assertIsNotNone(deleted_batch.deleted_at)
|
||
self.assertIsNone(db.get_batch(batch_a, path=cfg["db_path"]))
|
||
self.assertEqual([], db.list_tasks(batch_id=batch_a, path=cfg["db_path"]))
|
||
self.assertEqual(1, len(db.list_tasks(batch_id=batch_a, path=cfg["db_path"], include_deleted=True)))
|
||
self.assertEqual(-1, collect_tab.batch_filter.findData(batch_a))
|
||
self.assertEqual(-1, generate_tab.batch_filter.findData(batch_a))
|
||
self.assertEqual(-1, apply_tab.batch_filter.findData(batch_a))
|
||
self.assertTrue(all(task.batch_id != batch_a for task in collect_tab.model.all_tasks))
|
||
self.assertTrue(all(task.batch_id != batch_a for task in generate_tab.model.tasks))
|
||
self.assertTrue(all(task.batch_id != batch_a for task in apply_tab.model.tasks))
|
||
self.assertEqual([True], refresh_calls)
|
||
self.assertIn("不会回滚蝦皮", question.call_args[0][2])
|
||
self.assertIn("已软删除批次", info.call_args[0][2])
|
||
self.assertIn("已软删除批次", statuses[-1])
|
||
|
||
self.assert_removed(temp_dir)
|
||
|
||
def test_collect_tab_can_filter_unmatched_tasks_from_summary_bar(self):
|
||
with self.make_temp_dir() as temp_dir:
|
||
cfg = self.make_config(temp_dir)
|
||
accounts.create_account("主店", "alias-a", debug_port=9222, config=cfg)
|
||
batch_id = db.create_batch(["input.xlsx"], path=cfg["db_path"])
|
||
db.insert_tasks(
|
||
batch_id,
|
||
[
|
||
{
|
||
"source_file_abs": os.path.join(temp_dir, "input.xlsx"),
|
||
"source_sheet": "商品",
|
||
"source_row": 2,
|
||
"account_name": "Excel主店",
|
||
"alias": "alias-a",
|
||
"item_id": "51100639510",
|
||
},
|
||
{
|
||
"source_file_abs": os.path.join(temp_dir, "input.xlsx"),
|
||
"source_sheet": "商品",
|
||
"source_row": 3,
|
||
"account_name": "Excel副店",
|
||
"alias": "missing",
|
||
"item_id": "51100639511",
|
||
},
|
||
],
|
||
path=cfg["db_path"],
|
||
)
|
||
tab = CollectTab(config=cfg)
|
||
self.addCleanup(tab.close)
|
||
|
||
tab.show_unmatched_tasks()
|
||
|
||
self.assertEqual(1, tab.model.rowCount())
|
||
self.assertEqual("missing", tab.model.index(0, 1).data())
|
||
self.assertEqual("略过", tab.model.index(0, 3).data())
|
||
|
||
tab.show_all_tasks()
|
||
|
||
self.assertEqual(2, tab.model.rowCount())
|
||
|
||
self.assert_removed(temp_dir)
|
||
|
||
def test_collect_tab_import_button_imports_excel_and_refreshes_tasks(self):
|
||
with self.make_temp_dir() as temp_dir:
|
||
cfg = self.make_config(temp_dir)
|
||
accounts.create_account("主店", "alias-a", debug_port=9222, config=cfg)
|
||
excel_path = os.path.join(temp_dir, "input.xlsx")
|
||
statuses = []
|
||
tab = CollectTab(config=cfg, status_callback=statuses.append)
|
||
self.addCleanup(tab.close)
|
||
created = {}
|
||
|
||
def fake_import(file_paths, path=None):
|
||
batch_id = db.create_batch(file_paths, path=path)
|
||
created["batch_id"] = batch_id
|
||
db.insert_tasks(
|
||
batch_id,
|
||
[
|
||
{
|
||
"source_file_abs": excel_path,
|
||
"source_sheet": "商品",
|
||
"source_row": 2,
|
||
"account_name": "Excel主店",
|
||
"alias": "alias-a",
|
||
"item_id": "51100639510",
|
||
}
|
||
],
|
||
path=path,
|
||
)
|
||
return {
|
||
"batch_id": batch_id,
|
||
"rows": [],
|
||
"stats": {
|
||
"files": 1,
|
||
"total": 2,
|
||
"valid": 1,
|
||
"invalid": 1,
|
||
"inserted": 1,
|
||
},
|
||
}
|
||
|
||
with mock.patch.object(
|
||
tab,
|
||
"_choose_excel_files",
|
||
return_value=[excel_path],
|
||
), mock.patch("app.gui.excel.import_tasks", side_effect=fake_import) as import_tasks, mock.patch(
|
||
"app.gui.tabs.collect.QTimer.singleShot"
|
||
) as single_shot, mock.patch("app.gui.QMessageBox") as message_box:
|
||
tab.import_excel()
|
||
|
||
import_tasks.assert_called_once_with([excel_path], path=cfg["db_path"])
|
||
single_shot.assert_not_called()
|
||
message_box.warning.assert_not_called()
|
||
self.assertEqual(1, tab.model.rowCount())
|
||
self.assertEqual("alias-a", tab.model.index(0, 1).data())
|
||
self.assertIn("1 文件", tab.summary_label.text())
|
||
self.assertIn("2 行", tab.summary_label.text())
|
||
self.assertIn("有效1/", tab.summary_label.text())
|
||
self.assertIn("无效1", tab.summary_label.text())
|
||
self.assertIn(gui.COLOR_DANGER, tab.summary_label.text())
|
||
self.assertIn("匹配1", tab.summary_label.text())
|
||
self.assertEqual("", tab.show_unmatched_button.styleSheet())
|
||
self.assertIn("入库1", statuses[-1])
|
||
self.assertEqual(created["batch_id"], tab.batch_filter.currentData())
|
||
|
||
self.assert_removed(temp_dir)
|
||
|
||
def test_collect_tab_import_error_dialog_is_delayed_one_event_loop(self):
|
||
with self.make_temp_dir() as temp_dir:
|
||
cfg = self.make_config(temp_dir)
|
||
excel_path = os.path.join(temp_dir, "input.xlsx")
|
||
statuses = []
|
||
tab = CollectTab(config=cfg, status_callback=statuses.append)
|
||
self.addCleanup(tab.close)
|
||
|
||
with mock.patch.object(tab, "_choose_excel_files", return_value=[excel_path]), mock.patch(
|
||
"app.gui.excel.import_tasks",
|
||
side_effect=RuntimeError("Excel导入失败 token=SECRET"),
|
||
), mock.patch("app.gui.QMessageBox") as message_box, mock.patch(
|
||
"app.gui.tabs.collect.QTimer.singleShot"
|
||
) as single_shot:
|
||
tab.import_excel()
|
||
|
||
single_shot.assert_called_once()
|
||
self.assertEqual(0, single_shot.call_args[0][0])
|
||
delayed_callback = single_shot.call_args[0][1]
|
||
message_box.warning.assert_not_called()
|
||
self.assertIn("token=***", statuses[-1])
|
||
|
||
run_log = db.list_run_logs(limit=1, run_type="import", path=cfg["db_path"])[0]
|
||
self.assertEqual("failed", run_log.status)
|
||
self.assertEqual(1, run_log.failed_count)
|
||
events = db.list_run_log_events(run_log.id, path=cfg["db_path"])
|
||
messages = "\n".join(event.message for event in events)
|
||
self.assertIn("step=import result=failed", messages)
|
||
self.assertNotIn("SECRET", messages)
|
||
|
||
delayed_callback()
|
||
message_box.warning.assert_called_once()
|
||
self.assertEqual("导入采集", message_box.warning.call_args[0][1])
|
||
self.assertIn("Excel导入失败", message_box.warning.call_args[0][2])
|
||
self.assertIn("token=***", message_box.warning.call_args[0][2])
|
||
|
||
self.assert_removed(temp_dir)
|
||
|
||
def test_collect_worker_collects_success_and_skips_unmatched_or_logged_out(self):
|
||
with self.make_temp_dir() as temp_dir:
|
||
cfg = self.make_config(temp_dir)
|
||
logged = accounts.create_account("主店", "alias-a", debug_port=9222, config=cfg)
|
||
logged_out = accounts.create_account("副店", "alias-b", debug_port=9223, config=cfg)
|
||
batch_id = db.create_batch(["input.xlsx"], path=cfg["db_path"])
|
||
db.insert_tasks(
|
||
batch_id,
|
||
[
|
||
{
|
||
"source_file_abs": os.path.join(temp_dir, "input.xlsx"),
|
||
"source_sheet": "商品",
|
||
"source_row": 2,
|
||
"account_name": "Excel主店",
|
||
"alias": "alias-a",
|
||
"item_id": "51100639510",
|
||
},
|
||
{
|
||
"source_file_abs": os.path.join(temp_dir, "input.xlsx"),
|
||
"source_sheet": "商品",
|
||
"source_row": 3,
|
||
"account_name": "Excel副店",
|
||
"alias": "alias-b",
|
||
"item_id": "51100639511",
|
||
},
|
||
{
|
||
"source_file_abs": os.path.join(temp_dir, "input.xlsx"),
|
||
"source_sheet": "商品",
|
||
"source_row": 4,
|
||
"account_name": "Excel未知",
|
||
"alias": "missing",
|
||
"item_id": "51100639512",
|
||
},
|
||
],
|
||
path=cfg["db_path"],
|
||
)
|
||
tasks = db.list_tasks(batch_id=batch_id, path=cfg["db_path"])
|
||
expected_old_cover = os.path.join(str(batch_id), logged.slug, f"{tasks[0].id}_51100639510_old.jpg")
|
||
|
||
def fake_login(account, path=None, config=None):
|
||
self.assertEqual(cfg["db_path"], path)
|
||
if account.alias == logged.alias:
|
||
return {"logged_in": True, "reason": None}
|
||
if account.alias == logged_out.alias:
|
||
return {"logged_in": False, "reason": "LOGIN_PAGE"}
|
||
raise AssertionError(account.alias)
|
||
|
||
def fake_collect(account, task, on_step=None):
|
||
self.assertEqual(logged.alias, account.alias)
|
||
self.assertEqual("51100639510", task["item_id"])
|
||
self.assertTrue(task["old_cover_path"].endswith(expected_old_cover))
|
||
self.assertTrue(callable(on_step))
|
||
on_step("download_cover")
|
||
return {
|
||
"old_title": "旧标题",
|
||
"old_cover_path": task["old_cover_path"],
|
||
}
|
||
|
||
with mock.patch("app.gui.accounts.detect_login", side_effect=fake_login), \
|
||
mock.patch("app.gui.editor.collect", side_effect=fake_collect) as collect:
|
||
summary = CollectWorker(
|
||
tasks,
|
||
db_path=cfg["db_path"],
|
||
config=cfg,
|
||
preflight=False,
|
||
).execute()
|
||
|
||
self.assertTrue(summary["ok"])
|
||
self.assertEqual(3, summary["total"])
|
||
self.assertEqual(3, summary["done"])
|
||
self.assertEqual(1, summary["collected"])
|
||
self.assertEqual(2, summary["skipped"])
|
||
self.assertEqual(0, summary["failed"])
|
||
self.assertEqual([batch_id], summary["batch_ids"])
|
||
self.assertIsInstance(summary["run_id"], int)
|
||
collect.assert_called_once()
|
||
updated = db.list_tasks(batch_id=batch_id, path=cfg["db_path"])
|
||
by_alias = {task.alias: task for task in updated}
|
||
self.assertEqual("collected", by_alias["alias-a"].stage)
|
||
self.assertEqual("success", by_alias["alias-a"].status)
|
||
self.assertEqual("旧标题", by_alias["alias-a"].old_title)
|
||
self.assertTrue(by_alias["alias-a"].old_cover_path.endswith(expected_old_cover))
|
||
self.assertEqual("imported", by_alias["alias-b"].stage)
|
||
self.assertEqual("skipped", by_alias["alias-b"].status)
|
||
self.assertIn("账号未登录", by_alias["alias-b"].last_error)
|
||
self.assertEqual("skipped", by_alias["missing"].status)
|
||
self.assertEqual("别名未匹配账号", by_alias["missing"].last_error)
|
||
|
||
run_logs = db.list_run_logs(limit=1, run_type="collect", path=cfg["db_path"])
|
||
self.assertEqual(summary["run_id"], run_logs[0].id)
|
||
self.assertEqual("done", run_logs[0].status)
|
||
self.assertEqual(3, run_logs[0].done)
|
||
self.assertEqual(1, run_logs[0].success_count)
|
||
self.assertEqual(2, run_logs[0].skipped_count)
|
||
events = db.list_run_log_events(summary["run_id"], path=cfg["db_path"])
|
||
messages = [event.message for event in events]
|
||
self.assertTrue(any("step=download_cover result=start" in item for item in messages))
|
||
self.assertTrue(any("别名未匹配账号" in item for item in messages))
|
||
|
||
self.assert_removed(temp_dir)
|
||
|
||
def test_collect_worker_writes_run_log_and_diagnostic_log_on_failure(self):
|
||
with self.make_temp_dir() as temp_dir:
|
||
cfg = self.make_config(temp_dir)
|
||
logged = accounts.create_account("主店", "alias-a", debug_port=9222, config=cfg)
|
||
batch_id = db.create_batch(["input.xlsx"], path=cfg["db_path"])
|
||
db.insert_tasks(
|
||
batch_id,
|
||
[
|
||
{
|
||
"source_file_abs": os.path.join(temp_dir, "input.xlsx"),
|
||
"source_sheet": "商品",
|
||
"source_row": 2,
|
||
"account_name": "Excel主店",
|
||
"alias": "alias-a",
|
||
"item_id": "51100639510",
|
||
}
|
||
],
|
||
path=cfg["db_path"],
|
||
)
|
||
tasks = db.list_tasks(batch_id=batch_id, path=cfg["db_path"])
|
||
diagnostic_log_dir = os.path.join(temp_dir, "logs")
|
||
|
||
def fake_login(account, path=None, config=None):
|
||
self.assertEqual(logged.alias, account.alias)
|
||
return {"logged_in": True, "reason": None}
|
||
|
||
def fake_collect(account, task, on_step=None):
|
||
on_step("wait_ready")
|
||
raise RuntimeError("页面未就绪 token=SECRET-TOKEN")
|
||
|
||
with mock.patch("app.gui.accounts.detect_login", side_effect=fake_login), \
|
||
mock.patch("app.gui.editor.collect", side_effect=fake_collect):
|
||
summary = CollectWorker(
|
||
tasks,
|
||
db_path=cfg["db_path"],
|
||
config=cfg,
|
||
preflight=False,
|
||
diagnostic_log_dir=diagnostic_log_dir,
|
||
).execute()
|
||
|
||
self.assertFalse(summary["ok"])
|
||
self.assertEqual(1, summary["failed"])
|
||
self.assertIsInstance(summary["run_id"], int)
|
||
failed_task = db.list_tasks(batch_id=batch_id, path=cfg["db_path"])[0]
|
||
self.assertEqual("failed", failed_task.status)
|
||
self.assertIn("页面未就绪", failed_task.last_error)
|
||
self.assertNotIn("SECRET-TOKEN", failed_task.last_error)
|
||
|
||
run_log = db.list_run_logs(limit=1, run_type="collect", path=cfg["db_path"])[0]
|
||
self.assertEqual(summary["run_id"], run_log.id)
|
||
self.assertEqual("done", run_log.status)
|
||
self.assertEqual(1, run_log.failed_count)
|
||
events = db.list_run_log_events(summary["run_id"], path=cfg["db_path"])
|
||
messages = [event.message for event in events]
|
||
joined_messages = "\n".join(messages)
|
||
self.assertTrue(any("step=wait_ready result=failed" in item for item in messages))
|
||
self.assertTrue(any("页面未就绪" in item for item in messages))
|
||
self.assertNotIn("SECRET-TOKEN", joined_messages)
|
||
self.assertIn("token=***", joined_messages)
|
||
|
||
log_path = os.path.join(diagnostic_log_dir, "cmshopee.log")
|
||
self.assertTrue(os.path.exists(log_path))
|
||
with open(log_path, "r", encoding="utf-8") as fh:
|
||
raw_log = fh.read()
|
||
self.assertNotIn("SECRET-TOKEN", raw_log)
|
||
self.assertIn("token=***", raw_log)
|
||
entry = json.loads(raw_log.strip().splitlines()[-1])
|
||
self.assertEqual("wait_ready", entry["step"])
|
||
self.assertEqual("alias-a", entry["alias"])
|
||
self.assertEqual("51100639510", entry["item_id"])
|
||
self.assertEqual("RuntimeError", entry["exception"])
|
||
|
||
self.assert_removed(temp_dir)
|
||
|
||
def test_collect_tab_loads_latest_collect_run_log(self):
|
||
with self.make_temp_dir() as temp_dir:
|
||
cfg = self.make_config(temp_dir)
|
||
db.init_db(cfg["db_path"])
|
||
run_id = db.create_run_log("collect", total=1, path=cfg["db_path"])
|
||
db.add_run_log_event(
|
||
run_id,
|
||
"step=download_cover result=failed detail=旧封面下载超时",
|
||
level="error",
|
||
path=cfg["db_path"],
|
||
)
|
||
db.finish_run_log(
|
||
run_id,
|
||
status="done",
|
||
done=1,
|
||
failed_count=1,
|
||
summary_json={"failed": 1},
|
||
path=cfg["db_path"],
|
||
)
|
||
|
||
tab = CollectTab(config=cfg)
|
||
self.addCleanup(tab.close)
|
||
|
||
self.assertEqual("collectRunLogView", tab.run_log_view.objectName())
|
||
self.assertIn("step=download_cover result=failed", tab.run_log_view.toPlainText())
|
||
self.assertIn("旧封面下载超时", tab.run_log_view.toPlainText())
|
||
|
||
self.assert_removed(temp_dir)
|
||
|
||
def test_collect_tab_auto_starts_write_back_after_collect_success(self):
|
||
with self.make_temp_dir() as temp_dir:
|
||
cfg = self.make_config(temp_dir)
|
||
db.init_db(cfg["db_path"])
|
||
batch_id = db.create_batch(["input.xlsx"], path=cfg["db_path"])
|
||
db.insert_tasks(
|
||
batch_id,
|
||
[
|
||
{
|
||
"source_file_abs": os.path.join(temp_dir, "input.xlsx"),
|
||
"source_sheet": "商品",
|
||
"source_row": 2,
|
||
"account_name": "Excel主店",
|
||
"alias": "alias-a",
|
||
"item_id": "51100639510",
|
||
}
|
||
],
|
||
path=cfg["db_path"],
|
||
)
|
||
statuses = []
|
||
tab = CollectTab(config=cfg, status_callback=statuses.append)
|
||
self.addCleanup(tab.close)
|
||
|
||
with mock.patch.object(tab, "_start_write_back", return_value=True) as start_write_back:
|
||
tab._on_collect_finished({"collected": 1, "skipped": 0, "failed": 0})
|
||
|
||
start_write_back.assert_called_once_with(batch_id, auto=True)
|
||
self.assertIn("正在自动回写 Excel", statuses[-1])
|
||
|
||
self.assert_removed(temp_dir)
|
||
|
||
def test_collect_tab_does_not_auto_write_back_when_nothing_collected(self):
|
||
with self.make_temp_dir() as temp_dir:
|
||
cfg = self.make_config(temp_dir)
|
||
statuses = []
|
||
tab = CollectTab(config=cfg, status_callback=statuses.append)
|
||
self.addCleanup(tab.close)
|
||
|
||
with mock.patch.object(tab, "_start_write_back") as start_write_back:
|
||
tab._on_collect_finished({"collected": 0, "skipped": 1, "failed": 0})
|
||
|
||
start_write_back.assert_not_called()
|
||
self.assertEqual("采集完成:成功0,略过1,失败0", statuses[-1])
|
||
|
||
self.assert_removed(temp_dir)
|
||
|
||
def test_auto_write_back_locked_file_message_points_to_manual_retry(self):
|
||
with self.make_temp_dir() as temp_dir:
|
||
statuses = []
|
||
tab = CollectTab(config=self.make_config(temp_dir), status_callback=statuses.append)
|
||
self.addCleanup(tab.close)
|
||
|
||
with mock.patch("app.gui.QMessageBox.warning") as warning:
|
||
tab._on_write_back_failed(
|
||
-1,
|
||
"Excel 文件被占用,请关闭后重试: input.xlsx",
|
||
auto=True,
|
||
)
|
||
|
||
message = warning.call_args[0][2]
|
||
self.assertIn("Excel 自动回写失败", message)
|
||
self.assertIn("点击「回写旧数据到 Excel」手动重试", message)
|
||
self.assertIn("手动重试", statuses[-1])
|
||
|
||
self.assert_removed(temp_dir)
|
||
|
||
def test_collect_worker_preflight_blocks_when_no_accounts(self):
|
||
with self.make_temp_dir() as temp_dir:
|
||
cfg = self.make_config(temp_dir)
|
||
db.init_db(cfg["db_path"])
|
||
batch_id = db.create_batch(["input.xlsx"], path=cfg["db_path"])
|
||
db.insert_tasks(
|
||
batch_id,
|
||
[
|
||
{
|
||
"source_file_abs": os.path.join(temp_dir, "input.xlsx"),
|
||
"source_sheet": "商品",
|
||
"source_row": 2,
|
||
"account_name": "Excel主店",
|
||
"alias": "alias-a",
|
||
"item_id": "51100639510",
|
||
}
|
||
],
|
||
path=cfg["db_path"],
|
||
)
|
||
tasks = db.list_tasks(batch_id=batch_id, path=cfg["db_path"])
|
||
|
||
summary = CollectWorker(tasks, db_path=cfg["db_path"], config=cfg).execute()
|
||
|
||
self.assertTrue(summary["blocked"])
|
||
self.assertTrue(summary["no_accounts"])
|
||
self.assertEqual("NO_ACCOUNTS", summary["reason"])
|
||
|
||
self.assert_removed(temp_dir)
|
||
|
||
def test_collect_worker_preflight_launches_when_chrome_not_running(self):
|
||
with self.make_temp_dir() as temp_dir:
|
||
cfg = self.make_config(temp_dir)
|
||
accounts.create_account("主店", "alias-a", debug_port=9222, config=cfg)
|
||
batch_id = db.create_batch(["input.xlsx"], path=cfg["db_path"])
|
||
db.insert_tasks(
|
||
batch_id,
|
||
[
|
||
{
|
||
"source_file_abs": os.path.join(temp_dir, "input.xlsx"),
|
||
"source_sheet": "商品",
|
||
"source_row": 2,
|
||
"account_name": "Excel主店",
|
||
"alias": "alias-a",
|
||
"item_id": "51100639510",
|
||
}
|
||
],
|
||
path=cfg["db_path"],
|
||
)
|
||
tasks = db.list_tasks(batch_id=batch_id, path=cfg["db_path"])
|
||
|
||
with mock.patch("app.gui.chrome.is_running", return_value=False) as is_running, \
|
||
mock.patch(
|
||
"app.gui.accounts.launch_for_login",
|
||
return_value={"ok": True, "launched": True, "reused": False},
|
||
) as launch_for_login, \
|
||
mock.patch(
|
||
"app.gui.accounts.detect_login",
|
||
return_value={"logged_in": True, "reason": None},
|
||
) as detect_login, \
|
||
mock.patch(
|
||
"app.gui.editor.collect",
|
||
return_value={"old_title": "旧标题", "old_cover_path": "old.jpg"},
|
||
) as collect:
|
||
summary = CollectWorker(tasks, db_path=cfg["db_path"], config=cfg).execute()
|
||
|
||
self.assertTrue(summary["ok"])
|
||
self.assertEqual(1, summary["collected"])
|
||
self.assertEqual("alias-a", summary["launched_accounts"][0]["alias"])
|
||
is_running.assert_called_once_with(9222)
|
||
launch_for_login.assert_called_once()
|
||
self.assertEqual(2, detect_login.call_count)
|
||
collect.assert_called_once()
|
||
|
||
self.assert_removed(temp_dir)
|
||
|
||
def test_collect_worker_preflight_blocks_when_chrome_launch_fails(self):
|
||
with self.make_temp_dir() as temp_dir:
|
||
cfg = self.make_config(temp_dir)
|
||
accounts.create_account("主店", "alias-a", debug_port=9222, config=cfg)
|
||
batch_id = db.create_batch(["input.xlsx"], path=cfg["db_path"])
|
||
db.insert_tasks(
|
||
batch_id,
|
||
[
|
||
{
|
||
"source_file_abs": os.path.join(temp_dir, "input.xlsx"),
|
||
"source_sheet": "商品",
|
||
"source_row": 2,
|
||
"account_name": "Excel主店",
|
||
"alias": "alias-a",
|
||
"item_id": "51100639510",
|
||
}
|
||
],
|
||
path=cfg["db_path"],
|
||
)
|
||
tasks = db.list_tasks(batch_id=batch_id, path=cfg["db_path"])
|
||
|
||
with mock.patch("app.gui.chrome.is_running", return_value=False), \
|
||
mock.patch(
|
||
"app.gui.accounts.launch_for_login",
|
||
side_effect=RuntimeError("Chrome 路径错误"),
|
||
) as launch_for_login, \
|
||
mock.patch("app.gui.accounts.detect_login") as detect_login, \
|
||
mock.patch("app.gui.editor.collect") as collect:
|
||
summary = CollectWorker(tasks, db_path=cfg["db_path"], config=cfg).execute()
|
||
|
||
self.assertTrue(summary["blocked"])
|
||
self.assertEqual("CHROME_LAUNCH_FAILED", summary["reason"])
|
||
self.assertEqual("alias-a", summary["launch_failed"][0]["alias"])
|
||
self.assertIn("Chrome 启动失败", summary["launch_failed"][0]["reason"])
|
||
launch_for_login.assert_called_once()
|
||
detect_login.assert_not_called()
|
||
collect.assert_not_called()
|
||
|
||
self.assert_removed(temp_dir)
|
||
|
||
def test_collect_worker_preflight_skips_logged_out_account(self):
|
||
with self.make_temp_dir() as temp_dir:
|
||
cfg = self.make_config(temp_dir)
|
||
accounts.create_account("主店", "alias-a", debug_port=9222, config=cfg)
|
||
batch_id = db.create_batch(["input.xlsx"], path=cfg["db_path"])
|
||
db.insert_tasks(
|
||
batch_id,
|
||
[
|
||
{
|
||
"source_file_abs": os.path.join(temp_dir, "input.xlsx"),
|
||
"source_sheet": "商品",
|
||
"source_row": 2,
|
||
"account_name": "Excel主店",
|
||
"alias": "alias-a",
|
||
"item_id": "51100639510",
|
||
}
|
||
],
|
||
path=cfg["db_path"],
|
||
)
|
||
tasks = db.list_tasks(batch_id=batch_id, path=cfg["db_path"])
|
||
|
||
with mock.patch("app.gui.chrome.is_running", return_value=True), \
|
||
mock.patch(
|
||
"app.gui.accounts.detect_login",
|
||
return_value={"logged_in": False, "reason": "LOGIN_PAGE"},
|
||
), mock.patch("app.gui.editor.collect") as collect:
|
||
summary = CollectWorker(tasks, db_path=cfg["db_path"], config=cfg).execute()
|
||
|
||
self.assertFalse(summary.get("blocked"))
|
||
self.assertTrue(summary["ok"])
|
||
self.assertEqual(1, summary["skipped"])
|
||
self.assertEqual("alias-a", summary["logged_out"][0]["alias"])
|
||
self.assertIn("账号未登录", summary["logged_out"][0]["reason"])
|
||
self.assertEqual("alias-a", summary["login_required_accounts"][0]["alias"])
|
||
collect.assert_not_called()
|
||
blocked_task = db.list_tasks(batch_id=batch_id, path=cfg["db_path"])[0]
|
||
self.assertEqual("imported", blocked_task.stage)
|
||
self.assertEqual("skipped", blocked_task.status)
|
||
self.assertIn("账号未登录", blocked_task.last_error)
|
||
|
||
self.assert_removed(temp_dir)
|
||
|
||
def test_collect_worker_skips_remaining_tasks_when_login_drops_midrun(self):
|
||
with self.make_temp_dir() as temp_dir:
|
||
cfg = self.make_config(temp_dir)
|
||
accounts.create_account("主店", "alias-a", debug_port=9222, config=cfg)
|
||
accounts.create_account("副店", "alias-b", debug_port=9223, config=cfg)
|
||
batch_id = db.create_batch(["input.xlsx"], path=cfg["db_path"])
|
||
db.insert_tasks(
|
||
batch_id,
|
||
[
|
||
{
|
||
"source_file_abs": os.path.join(temp_dir, "input.xlsx"),
|
||
"source_sheet": "商品",
|
||
"source_row": 2,
|
||
"account_name": "Excel主店",
|
||
"alias": "alias-a",
|
||
"item_id": "51100639510",
|
||
},
|
||
{
|
||
"source_file_abs": os.path.join(temp_dir, "input.xlsx"),
|
||
"source_sheet": "商品",
|
||
"source_row": 3,
|
||
"account_name": "Excel主店",
|
||
"alias": "alias-a",
|
||
"item_id": "51100639511",
|
||
},
|
||
{
|
||
"source_file_abs": os.path.join(temp_dir, "input.xlsx"),
|
||
"source_sheet": "商品",
|
||
"source_row": 4,
|
||
"account_name": "Excel副店",
|
||
"alias": "alias-b",
|
||
"item_id": "51100639512",
|
||
},
|
||
],
|
||
path=cfg["db_path"],
|
||
)
|
||
tasks = db.list_tasks(batch_id=batch_id, path=cfg["db_path"])
|
||
login_calls = []
|
||
|
||
def fake_login(account, path=None, config=None):
|
||
login_calls.append(account.alias)
|
||
if login_calls == ["alias-a"]:
|
||
return {"logged_in": True, "reason": None}
|
||
if login_calls == ["alias-a", "alias-b"]:
|
||
return {"logged_in": True, "reason": None}
|
||
if account.alias == "alias-a":
|
||
return {"logged_in": False, "reason": "LOGIN_PAGE"}
|
||
return {"logged_in": True, "reason": None}
|
||
|
||
def fake_collect(account, task, on_step=None):
|
||
self.assertEqual("alias-b", account.alias)
|
||
return {"old_title": "副店旧标题", "old_cover_path": "old-b.jpg"}
|
||
|
||
with mock.patch("app.gui.chrome.is_running", return_value=True), \
|
||
mock.patch("app.gui.accounts.detect_login", side_effect=fake_login) as detect_login, \
|
||
mock.patch("app.gui.editor.collect", side_effect=fake_collect) as collect:
|
||
summary = CollectWorker(tasks, db_path=cfg["db_path"], config=cfg).execute()
|
||
|
||
self.assertTrue(summary["ok"])
|
||
self.assertEqual(3, summary["done"])
|
||
self.assertEqual(1, summary["collected"])
|
||
self.assertEqual(2, summary["skipped"])
|
||
self.assertEqual("alias-a", summary["login_required_accounts"][0]["alias"])
|
||
self.assertIn("采集中途掉登录", summary["login_required_accounts"][0]["reason"])
|
||
self.assertEqual(["alias-a", "alias-b", "alias-a", "alias-b"], login_calls)
|
||
self.assertEqual(4, detect_login.call_count)
|
||
collect.assert_called_once()
|
||
|
||
updated = db.list_tasks(batch_id=batch_id, path=cfg["db_path"])
|
||
by_item = {task.item_id: task for task in updated}
|
||
self.assertEqual("skipped", by_item["51100639510"].status)
|
||
self.assertEqual("skipped", by_item["51100639511"].status)
|
||
self.assertIn("采集中途掉登录", by_item["51100639510"].last_error)
|
||
self.assertIn("采集中途掉登录", by_item["51100639511"].last_error)
|
||
self.assertEqual("collected", by_item["51100639512"].stage)
|
||
self.assertEqual("success", by_item["51100639512"].status)
|
||
|
||
self.assert_removed(temp_dir)
|
||
|
||
def test_collect_worker_retries_midrun_no_session_cookie_then_collects(self):
|
||
with self.make_temp_dir() as temp_dir:
|
||
cfg = self.make_config(temp_dir)
|
||
accounts.create_account("主店", "alias-a", debug_port=9222, config=cfg)
|
||
batch_id = db.create_batch(["input.xlsx"], path=cfg["db_path"])
|
||
db.insert_tasks(
|
||
batch_id,
|
||
[
|
||
{
|
||
"source_file_abs": os.path.join(temp_dir, "input.xlsx"),
|
||
"source_sheet": "商品",
|
||
"source_row": 2,
|
||
"account_name": "Excel主店",
|
||
"alias": "alias-a",
|
||
"item_id": "51100639510",
|
||
},
|
||
{
|
||
"source_file_abs": os.path.join(temp_dir, "input.xlsx"),
|
||
"source_sheet": "商品",
|
||
"source_row": 3,
|
||
"account_name": "Excel主店",
|
||
"alias": "alias-a",
|
||
"item_id": "51100639511",
|
||
},
|
||
],
|
||
path=cfg["db_path"],
|
||
)
|
||
tasks = db.list_tasks(batch_id=batch_id, path=cfg["db_path"])
|
||
responses = [
|
||
{"logged_in": False, "reason": "NO_SESSION_COOKIE", "url": "https://seller.shopee.tw/"},
|
||
{"logged_in": True, "reason": None, "url": "https://seller.shopee.tw/portal/"},
|
||
{"logged_in": True, "reason": None, "url": "https://seller.shopee.tw/portal/"},
|
||
]
|
||
collected_items = []
|
||
|
||
def fake_collect(account, task, on_step=None):
|
||
collected_items.append(task["item_id"])
|
||
return {"old_title": f"旧标题{task['item_id']}", "old_cover_path": "old.jpg"}
|
||
|
||
with mock.patch("app.gui.accounts.detect_login", side_effect=responses) as detect_login, \
|
||
mock.patch("app.gui.editor.collect", side_effect=fake_collect) as collect, \
|
||
mock.patch("app.gui.workers.time.sleep") as sleep:
|
||
summary = CollectWorker(
|
||
tasks,
|
||
db_path=cfg["db_path"],
|
||
config=cfg,
|
||
preflight=False,
|
||
).execute()
|
||
|
||
self.assertTrue(summary["ok"])
|
||
self.assertEqual(2, summary["collected"])
|
||
self.assertEqual(0, summary["skipped"])
|
||
self.assertEqual([], summary["login_required_accounts"])
|
||
self.assertEqual(["51100639510", "51100639511"], collected_items)
|
||
self.assertEqual(3, detect_login.call_count)
|
||
self.assertEqual(1, sleep.call_count)
|
||
self.assertEqual(2, collect.call_count)
|
||
|
||
updated = db.list_tasks(batch_id=batch_id, path=cfg["db_path"])
|
||
self.assertTrue(all(task.stage == "collected" for task in updated))
|
||
self.assertTrue(all(task.status == "success" for task in updated))
|
||
|
||
self.assert_removed(temp_dir)
|
||
|
||
def test_collect_worker_repeated_midrun_no_session_cookie_does_not_skip(self):
|
||
with self.make_temp_dir() as temp_dir:
|
||
cfg = self.make_config(temp_dir)
|
||
accounts.create_account("主店", "alias-a", debug_port=9222, config=cfg)
|
||
batch_id = db.create_batch(["input.xlsx"], path=cfg["db_path"])
|
||
db.insert_tasks(
|
||
batch_id,
|
||
[
|
||
{
|
||
"source_file_abs": os.path.join(temp_dir, "input.xlsx"),
|
||
"source_sheet": "商品",
|
||
"source_row": 2,
|
||
"account_name": "Excel主店",
|
||
"alias": "alias-a",
|
||
"item_id": "51100639510",
|
||
}
|
||
],
|
||
path=cfg["db_path"],
|
||
)
|
||
tasks = db.list_tasks(batch_id=batch_id, path=cfg["db_path"])
|
||
status = {
|
||
"logged_in": False,
|
||
"reason": "NO_SESSION_COOKIE",
|
||
"url": "https://seller.shopee.tw/portal/",
|
||
"cookie_names": [],
|
||
}
|
||
|
||
with mock.patch("app.gui.accounts.detect_login", return_value=status) as detect_login, \
|
||
mock.patch(
|
||
"app.gui.editor.collect",
|
||
return_value={"old_title": "旧标题", "old_cover_path": "old.jpg"},
|
||
) as collect, \
|
||
mock.patch("app.gui.workers.time.sleep") as sleep:
|
||
summary = CollectWorker(
|
||
tasks,
|
||
db_path=cfg["db_path"],
|
||
config=cfg,
|
||
preflight=False,
|
||
).execute()
|
||
|
||
self.assertTrue(summary["ok"])
|
||
self.assertEqual(1, summary["collected"])
|
||
self.assertEqual(0, summary["skipped"])
|
||
self.assertEqual([], summary["login_required_accounts"])
|
||
self.assertEqual(3, detect_login.call_count)
|
||
self.assertEqual(2, sleep.call_count)
|
||
collect.assert_called_once()
|
||
task = db.list_tasks(batch_id=batch_id, path=cfg["db_path"])[0]
|
||
self.assertEqual("collected", task.stage)
|
||
self.assertEqual("success", task.status)
|
||
|
||
events = db.list_run_log_events(summary["run_id"], path=cfg["db_path"])
|
||
messages = "\n".join(event.message for event in events)
|
||
self.assertIn("登录状态检测暂时不稳定", messages)
|
||
self.assertNotIn("采集中途掉登录", messages)
|
||
|
||
self.assert_removed(temp_dir)
|
||
|
||
def test_collect_worker_midrun_login_check_failed_does_not_skip_remaining(self):
|
||
with self.make_temp_dir() as temp_dir:
|
||
cfg = self.make_config(temp_dir)
|
||
accounts.create_account("主店", "alias-a", debug_port=9222, config=cfg)
|
||
batch_id = db.create_batch(["input.xlsx"], path=cfg["db_path"])
|
||
db.insert_tasks(
|
||
batch_id,
|
||
[
|
||
{
|
||
"source_file_abs": os.path.join(temp_dir, "input.xlsx"),
|
||
"source_sheet": "商品",
|
||
"source_row": 2,
|
||
"account_name": "Excel主店",
|
||
"alias": "alias-a",
|
||
"item_id": "51100639510",
|
||
},
|
||
{
|
||
"source_file_abs": os.path.join(temp_dir, "input.xlsx"),
|
||
"source_sheet": "商品",
|
||
"source_row": 3,
|
||
"account_name": "Excel主店",
|
||
"alias": "alias-a",
|
||
"item_id": "51100639511",
|
||
},
|
||
],
|
||
path=cfg["db_path"],
|
||
)
|
||
tasks = db.list_tasks(batch_id=batch_id, path=cfg["db_path"])
|
||
responses = [
|
||
RuntimeError("CDP 读取失败 token=SECRET-TOKEN"),
|
||
RuntimeError("CDP 读取失败 token=SECRET-TOKEN"),
|
||
RuntimeError("CDP 读取失败 token=SECRET-TOKEN"),
|
||
{"logged_in": True, "reason": None},
|
||
]
|
||
|
||
with mock.patch("app.gui.accounts.detect_login", side_effect=responses), \
|
||
mock.patch(
|
||
"app.gui.editor.collect",
|
||
return_value={"old_title": "旧标题", "old_cover_path": "old.jpg"},
|
||
) as collect, \
|
||
mock.patch("app.gui.workers.time.sleep"):
|
||
summary = CollectWorker(
|
||
tasks,
|
||
db_path=cfg["db_path"],
|
||
config=cfg,
|
||
preflight=False,
|
||
).execute()
|
||
|
||
self.assertTrue(summary["ok"])
|
||
self.assertEqual(2, summary["collected"])
|
||
self.assertEqual(0, summary["skipped"])
|
||
self.assertEqual([], summary["login_required_accounts"])
|
||
self.assertEqual(2, collect.call_count)
|
||
|
||
events = db.list_run_log_events(summary["run_id"], path=cfg["db_path"])
|
||
messages = "\n".join(event.message for event in events)
|
||
self.assertIn("LOGIN_CHECK_FAILED", messages)
|
||
self.assertNotIn("SECRET-TOKEN", messages)
|
||
self.assertIn("token=***", messages)
|
||
|
||
self.assert_removed(temp_dir)
|
||
|
||
def test_collect_worker_preflight_no_session_cookie_does_not_skip_account(self):
|
||
with self.make_temp_dir() as temp_dir:
|
||
cfg = self.make_config(temp_dir)
|
||
accounts.create_account("主店", "alias-a", debug_port=9222, config=cfg)
|
||
batch_id = db.create_batch(["input.xlsx"], path=cfg["db_path"])
|
||
db.insert_tasks(
|
||
batch_id,
|
||
[
|
||
{
|
||
"source_file_abs": os.path.join(temp_dir, "input.xlsx"),
|
||
"source_sheet": "商品",
|
||
"source_row": 2,
|
||
"account_name": "Excel主店",
|
||
"alias": "alias-a",
|
||
"item_id": "51100639510",
|
||
}
|
||
],
|
||
path=cfg["db_path"],
|
||
)
|
||
tasks = db.list_tasks(batch_id=batch_id, path=cfg["db_path"])
|
||
responses = [
|
||
{"logged_in": False, "reason": "NO_SESSION_COOKIE", "url": "https://seller.shopee.tw/"},
|
||
{"logged_in": False, "reason": "NO_SESSION_COOKIE", "url": "https://seller.shopee.tw/"},
|
||
{"logged_in": False, "reason": "NO_SESSION_COOKIE", "url": "https://seller.shopee.tw/"},
|
||
{"logged_in": True, "reason": None, "url": "https://seller.shopee.tw/portal/"},
|
||
]
|
||
|
||
with mock.patch("app.gui.chrome.is_running", return_value=True), \
|
||
mock.patch("app.gui.accounts.detect_login", side_effect=responses) as detect_login, \
|
||
mock.patch(
|
||
"app.gui.editor.collect",
|
||
return_value={"old_title": "旧标题", "old_cover_path": "old.jpg"},
|
||
) as collect, \
|
||
mock.patch("app.gui.workers.time.sleep") as sleep:
|
||
summary = CollectWorker(tasks, db_path=cfg["db_path"], config=cfg).execute()
|
||
|
||
self.assertTrue(summary["ok"])
|
||
self.assertEqual(1, summary["collected"])
|
||
self.assertEqual(0, summary["skipped"])
|
||
self.assertEqual([], summary["logged_out"])
|
||
self.assertEqual([], summary["login_required_accounts"])
|
||
self.assertEqual(4, detect_login.call_count)
|
||
self.assertEqual(2, sleep.call_count)
|
||
collect.assert_called_once()
|
||
|
||
self.assert_removed(temp_dir)
|
||
|
||
def test_collect_tab_blocked_preflight_guides_to_accounts_tab(self):
|
||
with self.make_temp_dir() as temp_dir:
|
||
opened = []
|
||
statuses = []
|
||
tab = CollectTab(
|
||
config=self.make_config(temp_dir),
|
||
status_callback=statuses.append,
|
||
open_accounts_callback=lambda: opened.append(True),
|
||
)
|
||
self.addCleanup(tab.close)
|
||
payload = {
|
||
"blocked": True,
|
||
"not_running": [
|
||
{
|
||
"account_name": "主店",
|
||
"alias": "alias-a",
|
||
"reason": "CDP 端口未响应",
|
||
}
|
||
],
|
||
"logged_out": [],
|
||
}
|
||
|
||
with mock.patch("app.gui.QMessageBox.warning") as warning:
|
||
tab._on_collect_finished(payload)
|
||
|
||
message = warning.call_args[0][2]
|
||
self.assertIn("Chrome 未启动", message)
|
||
self.assertIn("本轮采集已中止", message)
|
||
self.assertIn("④ 账号管理", message)
|
||
self.assertEqual([True], opened)
|
||
self.assertIn("本轮采集已中止", statuses[-1])
|
||
self.assertIn("④ 账号管理", statuses[-1])
|
||
|
||
self.assert_removed(temp_dir)
|
||
|
||
def test_collect_tab_completion_shows_login_summary_and_keep_chrome_hint(self):
|
||
with self.make_temp_dir() as temp_dir:
|
||
tab = CollectTab(
|
||
config=self.make_config(temp_dir),
|
||
status_callback=lambda _message: None,
|
||
)
|
||
self.addCleanup(tab.close)
|
||
payload = {
|
||
"collected": 0,
|
||
"skipped": 2,
|
||
"failed": 0,
|
||
"login_required_accounts": [
|
||
{
|
||
"account_name": "主店",
|
||
"alias": "alias-a",
|
||
"reason": "采集中途掉登录: 账号未登录: LOGIN_PAGE",
|
||
}
|
||
],
|
||
"launched_accounts": [
|
||
{
|
||
"account_name": "副店",
|
||
"alias": "alias-b",
|
||
"reason": "已启动",
|
||
}
|
||
],
|
||
}
|
||
|
||
with mock.patch("app.gui.QMessageBox.warning") as warning:
|
||
tab._on_collect_finished(payload)
|
||
|
||
message = warning.call_args[0][2]
|
||
self.assertIn("以下账号需要补登录", message)
|
||
self.assertIn("主店(alias-a)", message)
|
||
self.assertIn("已自动启动账号 Chrome", message)
|
||
self.assertIn("不会自动关闭账号 Chrome", message)
|
||
|
||
self.assert_removed(temp_dir)
|
||
|
||
def test_write_back_worker_calls_excel_write_back(self):
|
||
with self.make_temp_dir() as temp_dir:
|
||
db_path = os.path.join(temp_dir, "db.sqlite")
|
||
with mock.patch(
|
||
"app.gui.excel.write_back",
|
||
return_value={"ok": True, "batch_id": "batch-1", "files": 1, "rows": 2},
|
||
) as write_back:
|
||
summary = WriteBackWorker("batch-1", db_path=db_path).execute()
|
||
|
||
self.assertEqual({"ok": True, "batch_id": "batch-1", "files": 1, "rows": 2}, summary)
|
||
write_back.assert_called_once_with(
|
||
"batch-1",
|
||
excel_path=None,
|
||
path=db_path,
|
||
)
|
||
|
||
self.assert_removed(temp_dir)
|
||
|
||
def test_write_back_worker_calls_result_write_back(self):
|
||
with self.make_temp_dir() as temp_dir:
|
||
db_path = os.path.join(temp_dir, "db.sqlite")
|
||
with mock.patch(
|
||
"app.gui.excel.write_back_results",
|
||
return_value={"ok": True, "batch_id": "batch-1", "files": 1, "rows": 2},
|
||
) as write_back_results:
|
||
summary = WriteBackWorker("batch-1", db_path=db_path, mode="results").execute()
|
||
|
||
self.assertEqual({"ok": True, "batch_id": "batch-1", "files": 1, "rows": 2}, summary)
|
||
write_back_results.assert_called_once_with(
|
||
"batch-1",
|
||
excel_path=None,
|
||
path=db_path,
|
||
)
|
||
|
||
self.assert_removed(temp_dir)
|
||
|
||
def test_collect_tab_import_excel_writes_diagnostic_run_log(self):
|
||
with self.make_temp_dir() as temp_dir:
|
||
cfg = self.make_config(temp_dir)
|
||
excel_path = os.path.join(temp_dir, "input.xlsx")
|
||
statuses = []
|
||
tab = CollectTab(config=cfg, status_callback=statuses.append)
|
||
self.addCleanup(tab.close)
|
||
|
||
def fake_import(file_paths, path=None):
|
||
self.assertEqual([excel_path], file_paths)
|
||
self.assertEqual(cfg["db_path"], path)
|
||
return {
|
||
"batch_id": None,
|
||
"rows": [],
|
||
"stats": {
|
||
"files": 1,
|
||
"total": 2,
|
||
"valid": 0,
|
||
"invalid": 1,
|
||
"inserted": 0,
|
||
"file_errors": [
|
||
{
|
||
"file": excel_path,
|
||
"sheet": "商品",
|
||
"error": "缺少必需列",
|
||
"missing_columns": ["别名"],
|
||
}
|
||
],
|
||
"row_errors": [
|
||
{
|
||
"file": excel_path,
|
||
"sheet": "商品",
|
||
"row": 3,
|
||
"error": "商品id必须是数字 token=SECRET",
|
||
}
|
||
],
|
||
},
|
||
}
|
||
|
||
with mock.patch.object(tab, "_choose_excel_files", return_value=[excel_path]), \
|
||
mock.patch("app.gui.excel.import_tasks", side_effect=fake_import):
|
||
tab.import_excel()
|
||
|
||
run_log = db.list_run_logs(limit=1, run_type="import", path=cfg["db_path"])[0]
|
||
self.assertEqual("done", run_log.status)
|
||
self.assertEqual(1, run_log.done)
|
||
self.assertEqual(2, run_log.failed_count)
|
||
events = db.list_run_log_events(run_log.id, path=cfg["db_path"])
|
||
messages = "\n".join(event.message for event in events)
|
||
self.assertIn("step=parse_file result=failed", messages)
|
||
self.assertIn("missing=别名", messages)
|
||
self.assertIn("step=row_validate result=failed", messages)
|
||
self.assertIn("step=db_insert result=success", messages)
|
||
self.assertNotIn("SECRET", messages)
|
||
self.assertIn("token=***", messages)
|
||
self.assertIn("入库0", statuses[-1])
|
||
|
||
self.assert_removed(temp_dir)
|
||
|
||
def test_write_back_worker_writes_run_log_and_diagnostic_on_failure(self):
|
||
with self.make_temp_dir() as temp_dir:
|
||
cfg = self.make_config(temp_dir)
|
||
db.init_db(cfg["db_path"])
|
||
diagnostic_log_dir = os.path.join(temp_dir, "logs")
|
||
|
||
with mock.patch(
|
||
"app.gui.excel.write_back",
|
||
side_effect=RuntimeError("Excel 文件被占用 token=SECRET"),
|
||
):
|
||
worker = WriteBackWorker(
|
||
"batch-1",
|
||
db_path=cfg["db_path"],
|
||
diagnostic_log_dir=diagnostic_log_dir,
|
||
)
|
||
with self.assertRaises(RuntimeError):
|
||
worker.execute()
|
||
|
||
run_log = db.list_run_logs(limit=1, run_type="write_back", path=cfg["db_path"])[0]
|
||
self.assertEqual("failed", run_log.status)
|
||
self.assertEqual(1, run_log.failed_count)
|
||
events = db.list_run_log_events(run_log.id, path=cfg["db_path"])
|
||
messages = "\n".join(event.message for event in events)
|
||
self.assertIn("step=write_excel result=failed", messages)
|
||
self.assertNotIn("SECRET", messages)
|
||
self.assertIn("token=***", messages)
|
||
|
||
log_path = os.path.join(diagnostic_log_dir, "cmshopee.log")
|
||
self.assertTrue(os.path.exists(log_path))
|
||
with open(log_path, "r", encoding="utf-8") as fh:
|
||
raw_log = fh.read()
|
||
self.assertNotIn("SECRET", raw_log)
|
||
entry = json.loads(raw_log.strip().splitlines()[-1])
|
||
self.assertEqual("write_excel", entry["step"])
|
||
self.assertEqual("RuntimeError", entry["exception"])
|
||
|
||
self.assert_removed(temp_dir)
|
||
|
||
def test_apply_worker_writes_step_run_log_and_diagnostic_on_failure(self):
|
||
with self.make_temp_dir() as temp_dir:
|
||
cfg = self.make_config(temp_dir)
|
||
accounts.create_account("主店", "alias-a", debug_port=9222, config=cfg)
|
||
batch_id = db.create_batch(["input.xlsx"], path=cfg["db_path"])
|
||
db.insert_tasks(
|
||
batch_id,
|
||
[
|
||
{
|
||
"source_file_abs": os.path.join(temp_dir, "input.xlsx"),
|
||
"source_sheet": "商品",
|
||
"source_row": 2,
|
||
"account_name": "Excel主店",
|
||
"alias": "alias-a",
|
||
"item_id": "51100639510",
|
||
}
|
||
],
|
||
path=cfg["db_path"],
|
||
)
|
||
task = db.list_tasks(batch_id=batch_id, path=cfg["db_path"])[0]
|
||
db.set_collected(task.id, "旧标题", "old.jpg", path=cfg["db_path"])
|
||
db.set_generated(task.id, "新标题", "new.jpg", path=cfg["db_path"])
|
||
task = db.list_tasks(batch_id=batch_id, path=cfg["db_path"])[0]
|
||
diagnostic_log_dir = os.path.join(temp_dir, "logs")
|
||
|
||
def fake_apply(account, task, close_success_tab=False, on_step=None, bring_to_front=True, update_mode=None):
|
||
on_step({"step": "open_product", "result": "start"})
|
||
on_step({"step": "replace_cover", "result": "failed", "detail": "token=SECRET"})
|
||
return {
|
||
"committed": False,
|
||
"error": "新封面上传失败 token=SECRET",
|
||
"cover": {"ok": False, "error": "token=SECRET"},
|
||
}
|
||
|
||
with mock.patch("app.gui.editor.apply_task", side_effect=fake_apply):
|
||
summary = ApplyWorker(
|
||
[task],
|
||
db_path=cfg["db_path"],
|
||
config=cfg,
|
||
preflight=False,
|
||
diagnostic_log_dir=diagnostic_log_dir,
|
||
).execute()
|
||
|
||
self.assertFalse(summary["ok"])
|
||
self.assertEqual(1, summary["failed"])
|
||
run_log = db.list_run_logs(limit=1, run_type="apply", path=cfg["db_path"])[0]
|
||
events = db.list_run_log_events(run_log.id, path=cfg["db_path"])
|
||
messages = "\n".join(event.message for event in events)
|
||
self.assertIn("step=open_product result=start", messages)
|
||
self.assertIn("step=replace_cover result=failed", messages)
|
||
self.assertIn("step=db_write result=success", messages)
|
||
self.assertNotIn("SECRET", messages)
|
||
self.assertIn("token=***", messages)
|
||
|
||
log_path = os.path.join(diagnostic_log_dir, "cmshopee.log")
|
||
self.assertTrue(os.path.exists(log_path))
|
||
with open(log_path, "r", encoding="utf-8") as fh:
|
||
raw_log = fh.read()
|
||
self.assertNotIn("SECRET", raw_log)
|
||
entry = json.loads(raw_log.strip().splitlines()[-1])
|
||
self.assertEqual("replace_cover", entry["step"])
|
||
self.assertEqual("alias-a", entry["alias"])
|
||
self.assertEqual("51100639510", entry["item_id"])
|
||
|
||
self.assert_removed(temp_dir)
|
||
def test_account_login_check_worker_writes_run_log(self):
|
||
with self.make_temp_dir() as temp_dir:
|
||
cfg = self.make_config(temp_dir)
|
||
account = accounts.create_account("主店", "alias-a", debug_port=9222, config=cfg)
|
||
diagnostic_log_dir = os.path.join(temp_dir, "logs")
|
||
|
||
with mock.patch(
|
||
"app.gui.accounts.detect_login",
|
||
return_value={"logged_in": False, "reason": "LOGIN_PAGE token=SECRET"},
|
||
) as detect_login:
|
||
result = AccountLoginCheckWorker(
|
||
account,
|
||
db_path=cfg["db_path"],
|
||
config=cfg,
|
||
diagnostic_log_dir=diagnostic_log_dir,
|
||
).execute()
|
||
|
||
detect_login.assert_called_once_with(
|
||
account,
|
||
timeout=8,
|
||
path=cfg["db_path"],
|
||
config=cfg,
|
||
)
|
||
self.assertEqual("alias-a", result["alias"])
|
||
self.assertFalse(result["status"]["logged_in"])
|
||
run_log = db.list_run_logs(limit=1, run_type="login_check", path=cfg["db_path"])[0]
|
||
self.assertEqual("done", run_log.status)
|
||
self.assertEqual(1, run_log.failed_count)
|
||
events = db.list_run_log_events(run_log.id, path=cfg["db_path"])
|
||
messages = "\n".join(event.message for event in events)
|
||
self.assertIn("step=detect_login result=start", messages)
|
||
self.assertIn("step=detect_login result=failed", messages)
|
||
self.assertNotIn("SECRET", messages)
|
||
self.assertIn("token=***", messages)
|
||
|
||
self.assert_removed(temp_dir)
|
||
|
||
def test_ai_model_test_worker_writes_run_log(self):
|
||
with self.make_temp_dir() as temp_dir:
|
||
cfg = self.make_config(temp_dir)
|
||
db.init_db(cfg["db_path"])
|
||
models_path = os.path.join(temp_dir, "ai_models.json")
|
||
worker = AIModelTestWorker(
|
||
"Text A",
|
||
ai_models_path=models_path,
|
||
db_path=cfg["db_path"],
|
||
diagnostic_log_dir=os.path.join(temp_dir, "logs"),
|
||
)
|
||
|
||
with mock.patch(
|
||
"app.gui.appconfig.test_ai_model",
|
||
return_value={"ok": False, "status": 401, "error": "api_key=SECRET"},
|
||
):
|
||
result = worker.execute()
|
||
|
||
self.assertFalse(result["ok"])
|
||
self.assertEqual("Text A", result["name"])
|
||
run_log = db.list_run_logs(limit=1, run_type="ai_model_test", path=cfg["db_path"])[0]
|
||
self.assertEqual("done", run_log.status)
|
||
self.assertEqual(1, run_log.failed_count)
|
||
events = db.list_run_log_events(run_log.id, path=cfg["db_path"])
|
||
messages = "\n".join(event.message for event in events)
|
||
self.assertIn("step=test_connection result=start", messages)
|
||
self.assertIn("step=test_connection result=failed", messages)
|
||
self.assertNotIn("SECRET", messages)
|
||
self.assertIn("api_key=***", messages)
|
||
|
||
self.assert_removed(temp_dir)
|
||
|
||
def test_accounts_tab_launch_login_writes_chrome_launch_run_log(self):
|
||
with self.make_temp_dir() as temp_dir:
|
||
cfg = self.make_config(temp_dir)
|
||
account = accounts.create_account("主店", "alias-a", debug_port=9222, config=cfg)
|
||
statuses = []
|
||
tab = AccountsTab(config=cfg, status_callback=statuses.append)
|
||
self.addCleanup(tab.close)
|
||
tab.table.selectRow(0)
|
||
|
||
with mock.patch(
|
||
"app.gui.accounts.launch_for_login",
|
||
return_value={
|
||
"action": "launched",
|
||
"pid": 1234,
|
||
"target_id": "target-new",
|
||
"url": "https://seller.shopee.tw/portal/",
|
||
},
|
||
) as launch_for_login:
|
||
tab.launch_login()
|
||
|
||
launch_for_login.assert_called_once_with(account, config=cfg)
|
||
self.assertEqual("已启动", tab.login_statuses["alias-a"])
|
||
self.assertIn("Chrome 已启动", statuses[-1])
|
||
run_log = db.list_run_logs(limit=1, run_type="chrome_launch", path=cfg["db_path"])[0]
|
||
self.assertEqual("done", run_log.status)
|
||
self.assertEqual(1, run_log.success_count)
|
||
events = db.list_run_log_events(run_log.id, path=cfg["db_path"])
|
||
messages = "\n".join(event.message for event in events)
|
||
self.assertIn("step=launch_chrome result=start", messages)
|
||
self.assertIn("step=launch_chrome result=launched", messages)
|
||
self.assertIn("pid=1234", messages)
|
||
self.assertIn("target_id=target-new", messages)
|
||
|
||
self.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)
|
||
|
||
self.assert_removed(temp_dir)
|
||
if __name__ == "__main__":
|
||
unittest.main()
|