Files
cmshoppe/tests/test_product_suite_gui.py
T

2356 lines
98 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import os
import sys
import time
import unittest
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 accounts, appconfig, image_studio, image_studio_images, product_suite, prompts
from app import gui
if gui.QT_IMPORT_ERROR is not None:
raise unittest.SkipTest("PySide6 未安装")
from PySide6.QtCore import QMimeData, Qt, QUrl
from PySide6.QtGui import QIcon, QImage, QPixmap, QTextCursor
from PySide6.QtTest import QTest
from PySide6.QtWidgets import QApplication, QLabel, QListWidgetItem, QPushButton
from app.gui.tabs.product_suite import (
AutoHeightPlainTextEdit,
ORIGINAL_CHECK_STATE_ROLE,
ProductOriginalDelegate,
ProductOriginalList,
ProductSuiteTab,
SuiteResultCard,
)
from app.gui.product_suite_prompt_dialog import ProductSuitePromptDialog
class ProductSuiteGuiTests(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 _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],
"config_path": os.path.join(temp_dir, "config.json"),
"cmhub_config_path": os.path.join(temp_dir, "cmhub.json"),
}
def _write_image(self, path):
image = QImage(40, 30, QImage.Format_RGB32)
image.fill(0xFF336699)
self.assertTrue(image.save(path))
def _create_project_with_assets(self, temp_dir, config, count, item_id="51100639510"):
account = accounts.create_account(
"主店",
"alias-a",
debug_port=9222,
config=config,
)
project = image_studio.create_or_get_project(
account,
item_id=item_id,
path=config["db_path"],
)
assets = []
for index in range(count):
source_path = os.path.join(temp_dir, "%s-%02d.png" % (item_id, index + 1))
self._write_image(source_path)
assets.append(
image_studio.add_asset(
project.id,
image_studio.ASSET_KIND_ORIGINAL,
local_path=source_path,
source_order=index + 1,
path=config["db_path"],
)
)
return project, assets
def test_tab_builds_suite_controls_without_old_detail_workspace(self):
with self.make_temp_dir() as temp_dir:
config = self._config(temp_dir)
accounts.create_account("主店", "alias-a", debug_port=9222, config=config)
tab = ProductSuiteTab(config=config, db_path=config["db_path"])
self.addCleanup(tab.close)
tab.resize(1180, 760)
tab.show()
self.app.processEvents()
self.assertEqual("productSuiteTab", tab.objectName())
self.assertEqual(1, tab.task_tabs.count())
self.assertEqual("套图任务 1", tab.task_tabs.tabText(0))
self.assertEqual("alias-a", tab.account_combo.currentData())
self.assertEqual("Shopee", tab.platform_combo.currentData())
self.assertEqual("中国台湾", tab.country_combo.currentData())
self.assertEqual("繁体中文", tab.language_combo.currentData())
self.assertEqual("1:1", tab.ratio_combo.currentData())
self.assertEqual("Shopee", tab.platform_combo.currentText())
self.assertEqual("中国台湾", tab.country_combo.currentText())
self.assertEqual("繁体中文", tab.language_combo.currentText())
self.assertEqual("1:1", tab.ratio_combo.currentText())
self.assertEqual("平台", tab.platform_label.text())
self.assertEqual("站点", tab.country_label.text())
self.assertEqual("语言", tab.language_label.text())
self.assertEqual("比例", tab.ratio_label.text())
for column, combo in enumerate(
(
tab.platform_combo,
tab.country_combo,
tab.language_combo,
tab.ratio_combo,
)
):
self.assertIs(combo, tab.settings_grid.itemAtPosition(1, column).widget())
self.assertEqual(
1,
len(
{
tab.platform_combo.width(),
tab.country_combo.width(),
tab.language_combo.width(),
tab.ratio_combo.width(),
}
),
)
self.assertLessEqual(tab.task_tabs.maximumHeight(), 36)
self.assertEqual(120, tab.account_combo.minimumWidth())
self.assertEqual(160, tab.account_combo.maximumWidth())
self.assertGreaterEqual(tab.item_id_edit.minimumWidth(), 120)
self.assertLessEqual(tab.item_id_edit.maximumWidth(), 140)
self.assertLess(
tab.context_bar_layout.indexOf(tab.history_button),
tab.context_bar_layout.indexOf(tab.account_combo),
)
self.assertLess(
tab.context_bar_layout.indexOf(tab.open_folder_button),
tab.context_bar_layout.indexOf(tab.account_combo),
)
self.assertLess(
tab.context_bar_layout.indexOf(tab.add_images_button),
tab.context_bar_layout.indexOf(tab.account_combo),
)
self.assertEqual(-1, tab.results_toolbar_layout.indexOf(tab.history_button))
self.assertEqual(-1, tab.results_toolbar_layout.indexOf(tab.open_folder_button))
self.assertEqual("打开结果文件夹", tab.open_folder_button.text())
self.assertEqual("合计 5 张", tab.category_total_label.text())
self.assertEqual("生成套图(5)", tab.generate_button.text())
self.assertLess(
tab.prompt_title_layout.indexOf(tab.prompt_title_label),
tab.prompt_title_layout.indexOf(tab.ai_write_button),
)
self.assertLess(
tab.prompt_title_layout.indexOf(tab.ai_write_button),
tab.prompt_title_layout.indexOf(tab.ai_cancel_button),
)
self.assertLess(
tab.prompt_title_layout.indexOf(tab.ai_cancel_button),
tab.prompt_title_layout.indexOf(tab.prompt_settings_button),
)
self.assertEqual("提示词设置", tab.prompt_settings_button.text())
visible_text = " ".join(
[widget.text() for widget in tab.findChildren(QLabel)]
+ [widget.text() for widget in tab.findChildren(QPushButton)]
)
self.assertNotIn("详情图", visible_text)
self.assertNotIn("AI工场", visible_text)
self.assertIn("白底图", visible_text)
self.assertIn("场景图", visible_text)
self.assertIn("卖点图", visible_text)
tab.add_custom_category()
self.assertFalse(tab.custom_category_edit.isHidden())
tab.custom_category_edit.setText("尺寸图")
tab._commit_custom_category()
self.assertTrue(tab.custom_category_edit.isHidden())
self.assertIn("尺寸图", tab._displayed_state.settings["categories"])
self.assertEqual(1, tab.category_rows["尺寸图"].count())
self.assert_removed(temp_dir)
def test_prompt_settings_dialog_previews_validates_saves_and_restores(self):
with self.make_temp_dir() as temp_dir:
config = self._config(temp_dir)
prompt_path = appconfig.product_suite_prompt_path(config)
prompts.ensure_default_product_suite_prompt(prompt_path)
dialog = ProductSuitePromptDialog(
prompt_path=prompt_path,
base_prompt="40小时续航,适合通勤",
settings=product_suite.default_suite_settings(),
item_id="51100639510",
)
self.addCleanup(dialog.close)
dialog.show()
self.app.processEvents()
self.assertEqual("白底图", dialog.category_combo.currentData())
self.assertTrue(dialog.preview_edit.isReadOnly())
self.assertIn("套图名称:白底图,白底主图", dialog.preview_edit.toPlainText())
self.assertIn("40小时续航", dialog.preview_edit.toPlainText())
sizes = dialog.splitter.sizes()
self.assertLessEqual(abs(sizes[0] - sizes[1]), 12)
cursor = dialog.template_edit.textCursor()
cursor.movePosition(QTextCursor.End)
dialog.template_edit.setTextCursor(cursor)
dialog.insert_variable("商品ID")
self.assertTrue(dialog.template_edit.toPlainText().endswith("{商品ID}"))
default_text = prompts.load_default_product_suite_prompt()
invalid_text = default_text.replace("{图片比例}", "")
dialog.template_edit.setPlainText(invalid_text)
QTest.qWait(230)
self.assertFalse(dialog.save_button.isEnabled())
self.assertIn("缺少必需变量", dialog.validation_label.text())
custom_text = "自定义规则\n" + default_text
dialog.template_edit.setPlainText(custom_text)
QTest.qWait(230)
self.assertTrue(dialog.save_button.isEnabled())
self.assertTrue(dialog.save_template())
self.assertEqual(custom_text, prompts.load_product_suite_prompt(prompt_path))
self.assertFalse(dialog.is_dirty())
dialog.template_edit.setPlainText("临时未保存\n" + custom_text)
with mock.patch.object(dialog, "_confirm_restore", return_value=True):
self.assertTrue(dialog.restore_default())
self.assertEqual(default_text, dialog.template_edit.toPlainText())
self.assertEqual(default_text, prompts.load_product_suite_prompt(prompt_path))
self.assertFalse(dialog.is_dirty())
self.assert_removed(temp_dir)
def test_prompt_settings_dialog_unsaved_close_uses_chinese_three_way_action(self):
with self.make_temp_dir() as temp_dir:
config = self._config(temp_dir)
prompt_path = appconfig.product_suite_prompt_path(config)
prompts.ensure_default_product_suite_prompt(prompt_path)
dialog = ProductSuitePromptDialog(
prompt_path=prompt_path,
base_prompt="卖点",
settings=product_suite.default_suite_settings(),
item_id="",
)
dialog.show()
self.app.processEvents()
dialog.template_edit.setPlainText(
"未保存修改\n" + prompts.load_default_product_suite_prompt()
)
self.assertTrue(dialog.is_dirty())
with mock.patch.object(dialog, "_unsaved_action", return_value="cancel"):
dialog.reject()
self.assertTrue(dialog.isVisible())
with mock.patch.object(dialog, "_unsaved_action", return_value="discard"):
dialog.reject()
self.assertFalse(dialog.isVisible())
self.assert_removed(temp_dir)
def test_invalid_product_suite_template_blocks_before_project_or_worker(self):
with self.make_temp_dir() as temp_dir:
config = self._config(temp_dir)
accounts.create_account("主店", "alias-a", debug_port=9222, config=config)
tab = ProductSuiteTab(config=config, db_path=config["db_path"])
self.addCleanup(tab.close)
with open(tab.product_suite_prompt_path, "w", encoding="utf-8") as handle:
handle.write("无效模板{未知变量}")
message = mock.Mock()
with mock.patch.object(tab, "_message", message), mock.patch.object(
tab,
"_start_thread",
) as start_thread:
result = tab.start_generation(tab._displayed_state)
self.assertFalse(result)
self.assertIsNone(tab._displayed_state.project_id)
self.assertEqual([], image_studio.list_projects(path=config["db_path"]))
start_thread.assert_not_called()
self.assertEqual("套图提示词模板无效", message.call_args.args[0])
self.assert_removed(temp_dir)
def test_prompt_preview_matches_frozen_generation_job_prompt(self):
with self.make_temp_dir() as temp_dir:
config = self._config(temp_dir)
project, _ = self._create_project_with_assets(temp_dir, config, 1)
tab = ProductSuiteTab(config=config, db_path=config["db_path"])
self.addCleanup(tab.close)
state = tab._displayed_state
state.account_alias = "alias-a"
state.item_id = "51100639510"
state.project_id = project.id
state.prompt = "40小时续航,适合通勤"
tab._load_state(state)
dialog = ProductSuitePromptDialog(
prompt_path=tab.product_suite_prompt_path,
base_prompt=state.prompt,
settings=state.settings,
item_id=state.item_id,
)
self.addCleanup(dialog.close)
expected = dialog.preview_edit.toPlainText()
with mock.patch.object(
tab,
"_confirm",
return_value=True,
), mock.patch.object(tab, "_start_thread", return_value=object()):
self.assertTrue(tab.start_generation(state))
self.assertEqual(expected, state.worker.job_specs[0]["prompt"])
state.worker = None
state.thread = None
self.assert_removed(temp_dir)
def test_category_rows_are_vertical_with_helpers_and_independent_counters(self):
with self.make_temp_dir() as temp_dir:
config = self._config(temp_dir)
tab = ProductSuiteTab(config=config, db_path=config["db_path"])
self.addCleanup(tab.close)
tab.resize(1180, 760)
tab.show()
self.app.processEvents()
expected = {
"白底图": (1, "白底主图,多角度呈现商品细节"),
"场景图": (2, "生活化场景展示商品使用方式"),
"卖点图": (2, "突出核心卖点和差异化优势"),
}
self.assertEqual(list(expected), list(tab.category_rows))
for row_index, (name, (count, helper)) in enumerate(expected.items()):
row = tab.category_rows[name]
layout_index = tab.category_grid.indexOf(row)
self.assertEqual(
(row_index, 0, 1, 1),
tab.category_grid.getItemPosition(layout_index),
)
self.assertEqual(name, row.name_label.text())
self.assertEqual(count, row.count())
self.assertEqual(helper, row.helper_label.text())
self.assertIsNone(row.rename_button)
self.assertIsNone(row.delete_button)
scene_row = tab.category_rows["场景图"]
scene_row.plus_button.click()
self.assertIs(scene_row, tab.category_rows["场景图"])
self.assertEqual(3, scene_row.count())
self.assertEqual(1, tab.category_rows["白底图"].count())
self.assertEqual(2, tab.category_rows["卖点图"].count())
self.assertEqual("合计 6 张", tab.category_total_label.text())
self.assertEqual("生成套图(6)", tab.generate_button.text())
white_row = tab.category_rows["白底图"]
white_row.minus_button.click()
self.assertEqual(0, white_row.count())
self.assertFalse(white_row.minus_button.isEnabled())
white_row.minus_button.click()
self.assertEqual(0, tab._displayed_state.settings["categories"]["白底图"])
self.assertEqual("合计 5 张", tab.category_total_label.text())
tab._displayed_state.worker = object()
tab._apply_running_state(tab._displayed_state)
self.assertTrue(
all(
not row.plus_button.isEnabled()
for row in tab.category_rows.values()
)
)
tab._displayed_state.worker = None
tab._apply_running_state(tab._displayed_state)
self.assert_removed(temp_dir)
def test_custom_category_row_preserves_count_and_order_when_renamed(self):
with self.make_temp_dir() as temp_dir:
config = self._config(temp_dir)
tab = ProductSuiteTab(config=config, db_path=config["db_path"])
self.addCleanup(tab.close)
fixed_counts = {
name: tab.category_rows[name].count()
for name in ("白底图", "场景图", "卖点图")
}
tab.add_custom_category()
tab.custom_category_edit.setText("尺寸图")
tab._commit_custom_category()
custom_row = tab.category_rows["尺寸图"]
self.assertEqual(1, custom_row.count())
self.assertIsNone(custom_row.helper_label)
self.assertIsNotNone(custom_row.rename_button)
self.assertIsNotNone(custom_row.delete_button)
custom_row.plus_button.click()
self.assertEqual(2, custom_row.count())
with mock.patch(
"app.gui.tabs.product_suite.QInputDialog.getText",
return_value=("规格图", True),
):
custom_row.rename_button.click()
self.assertNotIn("尺寸图", tab.category_rows)
self.assertEqual(2, tab.category_rows["规格图"].count())
self.assertEqual(
["规格图"],
tab._displayed_state.settings["custom_category_order"],
)
tab.category_rows["规格图"].delete_button.click()
self.assertNotIn("规格图", tab.category_rows)
self.assertEqual([], tab._displayed_state.settings["custom_category_order"])
self.assertEqual(
fixed_counts,
{
name: tab.category_rows[name].count()
for name in ("白底图", "场景图", "卖点图")
},
)
self.assert_removed(temp_dir)
def test_category_counts_restore_after_task_switch_and_project_reload(self):
with self.make_temp_dir() as temp_dir:
config = self._config(temp_dir)
account = accounts.create_account(
"主店",
"alias-a",
debug_port=9222,
config=config,
)
project = image_studio.create_or_get_project(
account,
item_id="51100639510",
path=config["db_path"],
)
first = ProductSuiteTab(config=config, db_path=config["db_path"])
state = first._displayed_state
state.account_alias = "alias-a"
state.item_id = "51100639510"
state.project_id = project.id
first.change_category_count("场景图", 1)
first.add_custom_category()
first.custom_category_edit.setText("细节图")
first._commit_custom_category()
first.category_rows["细节图"].plus_button.click()
first.add_task(inherit=False)
self.assertEqual(2, first.category_rows["场景图"].count())
first.task_tabs.setCurrentIndex(0)
self.app.processEvents()
self.assertEqual(3, first.category_rows["场景图"].count())
self.assertEqual(2, first.category_rows["细节图"].count())
first.close()
first.deleteLater()
self.app.processEvents()
second = ProductSuiteTab(config=config, db_path=config["db_path"])
self.addCleanup(second.close)
reloaded = second._displayed_state
reloaded.account_alias = "alias-a"
reloaded.item_id = "51100639510"
second._bind_project(reloaded, load_existing=True)
self.assertEqual(3, second.category_rows["场景图"].count())
self.assertEqual(2, second.category_rows["细节图"].count())
self.assertEqual(
["细节图"],
reloaded.settings["custom_category_order"],
)
self.assert_removed(temp_dir)
def test_recent_dropdown_settings_restore_after_restart_and_project_wins(self):
with self.make_temp_dir() as temp_dir:
config_path = os.path.join(temp_dir, "config.json")
config = appconfig.save_config(self._config(temp_dir), path=config_path)
account = accounts.create_account(
"主店",
"alias-a",
debug_port=9222,
config=config,
)
project = image_studio.create_or_get_project(
account,
item_id="51100639510",
path=config["db_path"],
)
project_settings = image_studio.project_suite_settings(project)
project_settings["ratio"] = "4:3"
image_studio.update_project_suite_settings(
project.id,
project_settings,
path=config["db_path"],
)
first = ProductSuiteTab(
config=config,
config_path=config_path,
db_path=config["db_path"],
)
first.ratio_combo.setCurrentIndex(first.ratio_combo.findData("16:9"))
self.assertEqual(
"16:9",
appconfig.product_suite_last_settings(
appconfig.load_config(config_path)
)["ratio"],
)
first.close()
first.deleteLater()
self.app.processEvents()
reloaded = appconfig.load_config(config_path)
second = ProductSuiteTab(
config=reloaded,
config_path=config_path,
db_path=reloaded["db_path"],
)
self.addCleanup(second.close)
self.assertEqual("16:9", second.ratio_combo.currentData())
state = second._displayed_state
state.account_alias = "alias-a"
state.item_id = "51100639510"
second._bind_project(state, load_existing=True)
self.assertEqual("4:3", second.ratio_combo.currentData())
self.assertEqual("4:3", state.settings["ratio"])
self.assert_removed(temp_dir)
def test_recent_account_restores_and_missing_account_falls_back(self):
with self.make_temp_dir() as temp_dir:
config_path = os.path.join(temp_dir, "config.json")
config = appconfig.save_config(self._config(temp_dir), path=config_path)
accounts.create_account("主店", "alias-a", debug_port=9222, config=config)
accounts.create_account("副店", "alias-b", debug_port=9223, config=config)
first = ProductSuiteTab(
config=config,
config_path=config_path,
db_path=config["db_path"],
)
first.account_combo.setCurrentIndex(
first.account_combo.findData("alias-b")
)
self.app.processEvents()
self.assertEqual(
"alias-b",
appconfig.product_suite_last_account_alias(
appconfig.load_config(config_path)
),
)
first.close()
first.deleteLater()
self.app.processEvents()
second_config = appconfig.load_config(config_path)
second = ProductSuiteTab(
config=second_config,
config_path=config_path,
db_path=second_config["db_path"],
)
self.assertEqual("alias-b", second.account_combo.currentData())
second.close()
second.deleteLater()
self.app.processEvents()
accounts.delete_account("alias-b", config=second_config)
third_config = appconfig.load_config(config_path)
third = ProductSuiteTab(
config=third_config,
config_path=config_path,
db_path=third_config["db_path"],
)
self.addCleanup(third.close)
self.assertEqual("alias-a", third.account_combo.currentData())
self.assertEqual(
"alias-a",
appconfig.product_suite_last_account_alias(
appconfig.load_config(config_path)
),
)
self.assert_removed(temp_dir)
def test_task_tabs_keep_independent_prompt_and_settings(self):
with self.make_temp_dir() as temp_dir:
config = self._config(temp_dir)
accounts.create_account("主店", "alias-a", debug_port=9222, config=config)
tab = ProductSuiteTab(config=config, db_path=config["db_path"])
self.addCleanup(tab.close)
tab.prompt_edit.setPlainText("任务一卖点")
first_state = tab._displayed_state
tab.ratio_combo.setCurrentIndex(tab.ratio_combo.findData("3:4"))
second_state = tab.add_task(inherit=True)
self.assertEqual(2, tab.task_tabs.count())
self.assertEqual("任务一卖点", second_state.prompt)
self.assertEqual("3:4", second_state.settings["ratio"])
tab.prompt_edit.setPlainText("任务二卖点")
tab.ratio_combo.setCurrentIndex(tab.ratio_combo.findData("16:9"))
tab.task_tabs.setCurrentIndex(0)
self.assertIs(first_state, tab._displayed_state)
self.assertEqual("任务一卖点", tab.prompt_edit.toPlainText())
self.assertEqual("3:4", tab.ratio_combo.currentData())
self.assert_removed(temp_dir)
def test_prompt_autosaves_after_debounce_and_ignores_unchanged_text(self):
with self.make_temp_dir() as temp_dir:
config = self._config(temp_dir)
account = accounts.create_account(
"主店",
"alias-a",
debug_port=9222,
config=config,
)
project = image_studio.create_or_get_project(
account,
item_id="51100639510",
draft_prompt="原卖点",
path=config["db_path"],
)
tab = ProductSuiteTab(config=config, db_path=config["db_path"])
self.addCleanup(tab.close)
state = tab._displayed_state
state.account_alias = "alias-a"
state.item_id = project.item_id
state.project_id = project.id
state.project_binding_state = project.binding_state
state.prompt = project.draft_prompt
state.last_saved_prompt = project.draft_prompt
tab._load_state(state)
original_update = image_studio.update_project_prompt
with mock.patch(
"app.gui.tabs.product_suite.image_studio.update_project_prompt",
wraps=original_update,
) as update_prompt:
tab.prompt_edit.setPlainText("第一版卖点")
tab.prompt_edit.setPlainText("最终卖点")
QTest.qWait(650)
self.app.processEvents()
stored = image_studio.get_project(project.id, path=config["db_path"])
self.assertEqual("最终卖点", stored.draft_prompt)
self.assertEqual(1, update_prompt.call_count)
QTest.qWait(600)
self.app.processEvents()
self.assertEqual(1, update_prompt.call_count)
self.assert_removed(temp_dir)
def test_prompt_switch_and_close_flush_to_the_correct_projects(self):
with self.make_temp_dir() as temp_dir:
config = self._config(temp_dir)
account = accounts.create_account(
"主店",
"alias-a",
debug_port=9222,
config=config,
)
first_project = image_studio.create_or_get_project(
account,
item_id="51100639510",
path=config["db_path"],
)
second_project = image_studio.create_or_get_project(
account,
item_id="51100639511",
path=config["db_path"],
)
tab = ProductSuiteTab(config=config, db_path=config["db_path"])
first_state = tab._displayed_state
first_state.account_alias = "alias-a"
first_state.item_id = first_project.item_id
first_state.project_id = first_project.id
first_state.project_binding_state = first_project.binding_state
tab._load_state(first_state)
tab.prompt_edit.setPlainText("商品一卖点")
second_state = tab.add_task(inherit=False)
second_state.account_alias = "alias-a"
second_state.item_id = second_project.item_id
second_state.project_id = second_project.id
second_state.project_binding_state = second_project.binding_state
tab._load_state(second_state)
tab.prompt_edit.setPlainText("商品二卖点")
tab.task_tabs.setCurrentIndex(0)
self.app.processEvents()
self.assertEqual(
"商品一卖点",
image_studio.get_project(
first_project.id,
path=config["db_path"],
).draft_prompt,
)
self.assertEqual(
"商品二卖点",
image_studio.get_project(
second_project.id,
path=config["db_path"],
).draft_prompt,
)
tab.prompt_edit.setPlainText("商品一关闭任务前卖点")
tab.close_task(0)
self.app.processEvents()
self.assertEqual(
"商品一关闭任务前卖点",
image_studio.get_project(
first_project.id,
path=config["db_path"],
).draft_prompt,
)
self.assertIs(second_state, tab._displayed_state)
tab.prompt_edit.setPlainText("商品二关闭程序前卖点")
tab.close()
self.app.processEvents()
self.assertEqual(
"商品二关闭程序前卖点",
image_studio.get_project(
second_project.id,
path=config["db_path"],
).draft_prompt,
)
self.assert_removed(temp_dir)
def test_prompt_without_project_creates_and_restores_draft(self):
with self.make_temp_dir() as temp_dir:
config = self._config(temp_dir)
accounts.create_account("主店", "alias-a", debug_port=9222, config=config)
tab = ProductSuiteTab(config=config, db_path=config["db_path"])
state = tab._displayed_state
tab.prompt_edit.setPlainText("尚未建立项目的卖点")
QTest.qWait(650)
self.app.processEvents()
projects = image_studio.list_projects(path=config["db_path"])
self.assertEqual(1, len(projects))
draft = projects[0]
self.assertIsNotNone(draft)
stored = image_studio.get_project(draft.id, path=config["db_path"])
self.assertEqual("尚未建立项目的卖点", stored.draft_prompt)
self.assertEqual("尚未建立项目的卖点", state.last_saved_prompt)
self.assertEqual(
[draft.id],
[
project.id
for project in image_studio.list_recoverable_draft_projects(
path=config["db_path"]
)
],
)
tab.close()
tab.deleteLater()
self.app.processEvents()
restored = ProductSuiteTab(config=config, db_path=config["db_path"])
self.addCleanup(restored.close)
restored_state = next(
value
for value in restored._states.values()
if value.project_id == draft.id
)
self.assertEqual("尚未建立项目的卖点", restored_state.prompt)
new_state = restored.add_task(inherit=False)
self.assertEqual("", new_state.prompt)
self.assert_removed(temp_dir)
def test_blank_prompt_without_project_does_not_create_draft(self):
with self.make_temp_dir() as temp_dir:
config = self._config(temp_dir)
accounts.create_account("主店", "alias-a", debug_port=9222, config=config)
tab = ProductSuiteTab(config=config, db_path=config["db_path"])
self.addCleanup(tab.close)
tab.prompt_edit.setPlainText(" ")
QTest.qWait(650)
self.app.processEvents()
self.assertEqual([], image_studio.list_projects(path=config["db_path"]))
self.assert_removed(temp_dir)
def test_prompt_autosave_failure_keeps_memory_text_and_reports_chinese_error(self):
with self.make_temp_dir() as temp_dir:
config = self._config(temp_dir)
account = accounts.create_account(
"主店",
"alias-a",
debug_port=9222,
config=config,
)
project = image_studio.create_or_get_project(
account,
item_id="51100639510",
path=config["db_path"],
)
status = mock.Mock()
tab = ProductSuiteTab(
config=config,
db_path=config["db_path"],
status_callback=status,
)
self.addCleanup(tab.close)
state = tab._displayed_state
state.account_alias = "alias-a"
state.item_id = project.item_id
state.project_id = project.id
state.project_binding_state = project.binding_state
tab._load_state(state)
with mock.patch(
"app.gui.tabs.product_suite.image_studio.update_project_prompt",
side_effect=OSError("disk unavailable"),
):
tab.prompt_edit.setPlainText("保存失败仍保留")
QTest.qWait(650)
self.app.processEvents()
self.assertEqual("保存失败仍保留", state.prompt)
self.assertEqual("", state.last_saved_prompt)
self.assertTrue(
any(
"商品卖点自动保存失败" in str(call.args[0])
for call in status.call_args_list
)
)
self.assertTrue(
any(call.kwargs.get("level") == "danger" for call in status.call_args_list)
)
self.assert_removed(temp_dir)
def test_ai_write_result_saves_prompt_without_an_extra_user_action(self):
with self.make_temp_dir() as temp_dir:
config = self._config(temp_dir)
account = accounts.create_account(
"主店",
"alias-a",
debug_port=9222,
config=config,
)
project = image_studio.create_or_get_project(
account,
item_id="51100639510",
draft_prompt="原卖点",
path=config["db_path"],
)
tab = ProductSuiteTab(config=config, db_path=config["db_path"])
self.addCleanup(tab.close)
state = tab._displayed_state
state.account_alias = "alias-a"
state.item_id = project.item_id
state.project_id = project.id
state.project_binding_state = project.binding_state
state.prompt = project.draft_prompt
state.last_saved_prompt = project.draft_prompt
state.ai_prompt_snapshot = project.draft_prompt
tab._load_state(state)
tab._on_ai_write_finished(
state,
{"ok": True, "cancelled": False, "text": "AI生成的新卖点"},
)
stored = image_studio.get_project(project.id, path=config["db_path"])
self.assertEqual("AI生成的新卖点", stored.draft_prompt)
self.assertEqual("AI生成的新卖点", state.last_saved_prompt)
self.assertEqual("AI生成的新卖点", tab.prompt_edit.toPlainText())
self.assert_removed(temp_dir)
def test_prompt_edit_expands_shrinks_and_reflows_without_internal_scrollbars(self):
edit = AutoHeightPlainTextEdit()
self.addCleanup(edit.close)
edit.resize(420, 96)
edit.show()
self.app.processEvents()
self.assertEqual(Qt.ScrollBarAlwaysOff, edit.horizontalScrollBarPolicy())
self.assertEqual(Qt.ScrollBarAlwaysOff, edit.verticalScrollBarPolicy())
self.assertGreaterEqual(edit.height(), 96)
minimum_height = edit.height()
edit.setPlainText("\n".join("第%d行商品卖点" % index for index in range(1, 13)))
QTest.qWait(50)
self.app.processEvents()
expanded_height = edit.height()
self.assertGreater(expanded_height, minimum_height)
edit.clear()
QTest.qWait(50)
self.app.processEvents()
self.assertEqual(minimum_height, edit.height())
edit.setPlainText("这是一段用于测试窗口变窄后自动换行的商品卖点内容。" * 16)
edit.setFixedWidth(420)
QTest.qWait(50)
self.app.processEvents()
wide_height = edit.height()
edit.setFixedWidth(180)
QTest.qWait(50)
self.app.processEvents()
self.assertGreater(edit.height(), wide_height)
def test_generation_terminal_watchdog_finalizes_once_and_restores_button(self):
with self.make_temp_dir() as temp_dir:
config = self._config(temp_dir)
project, assets = self._create_project_with_assets(temp_dir, config, 1)
jobs = [
image_studio.create_job(
project.id,
source_asset_id=assets[0].id,
job_type="白底图",
prompt="终态看门狗测试",
path=config["db_path"],
)
for _ in range(2)
]
for job in jobs:
image_studio.update_job_status(
job.id,
"succeeded",
path=config["db_path"],
)
tab = ProductSuiteTab(config=config, db_path=config["db_path"])
self.addCleanup(tab.close)
state = tab._displayed_state
state.account_alias = "alias-a"
state.item_id = project.item_id
state.project_id = project.id
state.project_binding_state = project.binding_state
state.worker = mock.Mock()
state.thread = mock.Mock()
state.generation_run_token = "watchdog-run"
state.current_job_ids = [job.id for job in jobs]
state.generation_job_ids = [job.id for job in jobs]
state.total = len(jobs)
state.started_at = time.monotonic()
tab._generation_run_states["watchdog-run"] = state.key
tab._load_state(state)
messages = []
tab._message = lambda title, message, **kwargs: messages.append(
(title, message)
)
tab._check_generation_watchdogs()
self.assertIsNotNone(state.worker)
tab._check_generation_watchdogs()
self.assertIsNone(state.worker)
self.assertIsNone(state.thread)
self.assertEqual("", state.generation_run_token)
self.assertTrue(tab.generate_button.text().startswith("生成套图"))
self.assertEqual(1, len(messages))
self.assertEqual("商品套图生成完成", messages[0][0])
self.assertFalse(
tab._finalize_generation(
state,
"watchdog-run",
{"total": 2, "success": 2},
source="worker",
)
)
self.assertEqual(1, len(messages))
self.assert_removed(temp_dir)
def test_generation_real_qthread_completion_restores_gui_state(self):
with self.make_temp_dir() as temp_dir:
config = self._config(temp_dir)
project, assets = self._create_project_with_assets(temp_dir, config, 1)
tab = ProductSuiteTab(config=config, db_path=config["db_path"])
self.addCleanup(tab.close)
state = tab._displayed_state
state.account_alias = "alias-a"
state.item_id = project.item_id
state.project_id = project.id
state.project_binding_state = project.binding_state
state.prompt = "真实线程完成测试"
tab._load_state(state)
messages = []
tab._message = lambda title, message, **kwargs: messages.append(
(title, message)
)
def fake_run_jobs(jobs, **kwargs):
job_list = list(jobs)
for job in job_list:
image_studio.update_job_status(
job.id,
"succeeded",
path=config["db_path"],
)
return {
"total": len(job_list),
"success": len(job_list),
"failed": 0,
"cancelled": 0,
"jobs": [],
}
with mock.patch(
"app.gui.workers.image_studio_generation.run_jobs",
side_effect=fake_run_jobs,
):
self.assertTrue(
tab.start_generation(
state,
specs=[
{
"source_asset_id": assets[0].id,
"job_type": "白底图",
"prompt": "真实线程完成测试",
}
],
)
)
deadline = time.monotonic() + 3
while state.worker is not None and time.monotonic() < deadline:
QTest.qWait(20)
self.app.processEvents()
self.assertIsNone(state.worker)
self.assertIsNone(state.thread)
self.assertTrue(tab.generate_button.text().startswith("生成套图"))
self.assertEqual(1, len(messages))
self.assertEqual("商品套图生成完成", messages[0][0])
self.assert_removed(temp_dir)
def test_generation_thread_finished_reconciles_nonterminal_job(self):
with self.make_temp_dir() as temp_dir:
config = self._config(temp_dir)
project, assets = self._create_project_with_assets(temp_dir, config, 1)
job = image_studio.create_job(
project.id,
source_asset_id=assets[0].id,
job_type="场景图",
prompt="线程结束兜底测试",
path=config["db_path"],
)
tab = ProductSuiteTab(config=config, db_path=config["db_path"])
self.addCleanup(tab.close)
state = tab._displayed_state
state.account_alias = "alias-a"
state.item_id = project.item_id
state.project_id = project.id
state.project_binding_state = project.binding_state
state.worker = mock.Mock()
state.thread = mock.Mock()
state.generation_run_token = "thread-fallback"
state.current_job_ids = [job.id]
state.generation_job_ids = [job.id]
state.total = 1
state.started_at = time.monotonic()
tab._generation_run_states["thread-fallback"] = state.key
tab._load_state(state)
messages = []
tab._message = lambda title, message, **kwargs: messages.append(
(title, message)
)
tab._handle_generation_thread_finished("thread-fallback")
stored = image_studio.get_job(job.id, path=config["db_path"])
self.assertEqual("cancelled", stored.status)
self.assertEqual(
image_studio.JOB_RECOVERY_REGENERATE,
stored.recovery_action,
)
self.assertIsNone(state.worker)
self.assertEqual("商品套图生成未完整结束", messages[0][0])
self.assertIn("稍后继续查询", messages[0][1])
self.assert_removed(temp_dir)
def test_generation_old_run_token_and_repeated_stop_are_ignored(self):
with self.make_temp_dir() as temp_dir:
config = self._config(temp_dir)
accounts.create_account("主店", "alias-a", debug_port=9222, config=config)
statuses = []
tab = ProductSuiteTab(
config=config,
db_path=config["db_path"],
status_callback=lambda message, level=None: statuses.append(
(message, level)
),
)
self.addCleanup(tab.close)
state = tab._displayed_state
state.worker = mock.Mock()
state.thread = mock.Mock()
state.generation_run_token = "current-run"
state.generation_stop_requested = True
state.total = 2
tab._generation_run_states["current-run"] = state.key
original_worker = state.worker
self.assertFalse(
tab._finalize_generation(
state,
"old-run",
{"total": 2, "success": 2},
source="worker",
)
)
self.assertIs(original_worker, state.worker)
confirm = mock.Mock(return_value=True)
with mock.patch.object(tab, "_confirm", confirm):
tab.toggle_generation()
confirm.assert_not_called()
original_worker.cancel.assert_not_called()
self.assertEqual(("正在停止当前套图任务", "warning"), statuses[-1])
self.assert_removed(temp_dir)
def test_generation_immediate_stop_before_job_creation_finishes_cleanly(self):
with self.make_temp_dir() as temp_dir:
config = self._config(temp_dir)
accounts.create_account("主店", "alias-a", debug_port=9222, config=config)
tab = ProductSuiteTab(config=config, db_path=config["db_path"])
self.addCleanup(tab.close)
state = tab._displayed_state
state.worker = mock.Mock()
state.thread = mock.Mock()
state.generation_run_token = "immediate-stop"
state.generation_stop_requested = True
state.total = 3
state.started_at = time.monotonic()
tab._generation_run_states["immediate-stop"] = state.key
messages = []
tab._message = lambda title, message, **kwargs: messages.append(
(title, message)
)
tab._on_generation_finished_signal(
{
"run_token": "immediate-stop",
"cancelled": True,
}
)
self.assertIsNone(state.worker)
self.assertEqual(3, state.done)
self.assertEqual("商品套图生成已停止", messages[0][0])
self.assertIn("停止3张", messages[0][1])
self.assert_removed(temp_dir)
def test_project_settings_and_result_history_use_existing_backend(self):
with self.make_temp_dir() as temp_dir:
config = self._config(temp_dir)
account = accounts.create_account("主店", "alias-a", debug_port=9222, config=config)
project = image_studio.create_or_get_project(
account,
item_id="51100639510",
path=config["db_path"],
)
source_path = os.path.join(temp_dir, "source.png")
self._write_image(source_path)
source = image_studio_images.import_original_files(
project.id,
[source_path],
path=config["db_path"],
config=config,
)["assets"][0]
job = image_studio.create_job(
project.id,
source_asset_id=source.id,
job_type="场景图",
prompt="场景卖点",
path=config["db_path"],
)
image_studio.update_job_status(
job.id,
"failed",
error="上游超时 https://example.invalid/private",
path=config["db_path"],
)
tab = ProductSuiteTab(config=config, db_path=config["db_path"])
self.addCleanup(tab.close)
state = tab._displayed_state
state.account_alias = "alias-a"
state.item_id = "51100639510"
state.project_id = project.id
state.current_job_ids = [job.id]
tab._load_state(state)
self.assertEqual([source.id], tab.original_list.asset_ids())
self.assertEqual("共 1 张 · 成功 0 张", tab.result_summary_label.text())
cards = tab.findChildren(SuiteResultCard)
self.assertEqual(1, len(cards))
self.assertNotIn(
"https://",
" ".join(label.text() for label in cards[0].findChildren(QLabel)),
)
state.settings["ratio"] = "4:3"
state.prompt = "持久化卖点"
tab._persist_state(state)
stored = image_studio.get_project(project.id, path=config["db_path"])
self.assertEqual("持久化卖点", stored.draft_prompt)
self.assertEqual("4:3", image_studio.project_suite_settings(stored)["ratio"])
self.assert_removed(temp_dir)
def test_failed_job_retry_replaces_current_slot_and_keeps_history(self):
with self.make_temp_dir() as temp_dir:
config = self._config(temp_dir)
project, sources = self._create_project_with_assets(temp_dir, config, 1)
source = sources[0]
success_path = os.path.join(temp_dir, "success.jpg")
retry_path = os.path.join(temp_dir, "retry.jpg")
self._write_image(success_path)
self._write_image(retry_path)
success_asset = image_studio.add_asset(
project.id,
"generated_main",
local_path=success_path,
parent_asset_id=source.id,
path=config["db_path"],
)
success_job = image_studio.create_job(
project.id,
source_asset_id=source.id,
job_type="白底图",
prompt="成功图",
path=config["db_path"],
)
success_job = image_studio.update_job_status(
success_job.id,
"succeeded",
output_asset_id=success_asset.id,
path=config["db_path"],
)
failed_job = image_studio.create_job(
project.id,
source_asset_id=source.id,
job_type="场景图",
prompt="失败图",
path=config["db_path"],
)
failed_job = image_studio.update_job_status(
failed_job.id,
"failed",
error="上游生成失败",
path=config["db_path"],
)
tab = ProductSuiteTab(config=config, db_path=config["db_path"])
self.addCleanup(tab.close)
state = tab._displayed_state
state.account_alias = "alias-a"
state.item_id = project.item_id
state.project_id = project.id
state.project_binding_state = project.binding_state
state.current_job_ids = [success_job.id, failed_job.id]
tab._load_state(state)
messages = []
tab._message = lambda title, message, **kwargs: messages.append(
(title, message)
)
def fake_run_jobs(jobs, **kwargs):
job = list(jobs)[0]
retry_asset = image_studio.add_asset(
project.id,
"generated_main",
local_path=retry_path,
parent_asset_id=source.id,
path=config["db_path"],
)
image_studio.update_job_status(
job.id,
"succeeded",
output_asset_id=retry_asset.id,
path=config["db_path"],
)
return {
"total": 1,
"success": 1,
"failed": 0,
"cancelled": 0,
"jobs": [],
}
with mock.patch(
"app.gui.workers.image_studio_generation.run_jobs",
side_effect=fake_run_jobs,
):
tab.retry_job(failed_job)
generation_thread = state.thread
deadline = time.monotonic() + 3
while state.worker is not None and time.monotonic() < deadline:
QTest.qWait(20)
self.app.processEvents()
while generation_thread is not None and time.monotonic() < deadline:
try:
running = generation_thread.isRunning()
except RuntimeError:
generation_thread = None
break
if not running:
break
QTest.qWait(20)
self.app.processEvents()
if generation_thread is not None:
self.assertFalse(generation_thread.isRunning())
all_jobs = image_studio.list_jobs(project.id, path=config["db_path"])
retry_jobs = [
job
for job in all_jobs
if job.id not in {success_job.id, failed_job.id}
]
self.assertEqual(1, len(retry_jobs))
retry_job = retry_jobs[0]
self.assertEqual(
[success_job.id, retry_job.id],
state.current_job_ids,
)
self.assertEqual([], state.generation_job_ids)
self.assertEqual(
[success_job.id, retry_job.id],
[job.id for job in tab._jobs_for_state(state)],
)
self.assertEqual("图片重试成功", messages[-1][0])
state.show_history = True
history_ids = {job.id for job in tab._jobs_for_state(state)}
self.assertEqual(
{success_job.id, failed_job.id, retry_job.id},
history_ids,
)
self.assert_removed(temp_dir)
def test_retry_tracks_only_new_job_and_preserves_history_view(self):
with self.make_temp_dir() as temp_dir:
config = self._config(temp_dir)
project, sources = self._create_project_with_assets(temp_dir, config, 1)
source = sources[0]
success_job = image_studio.create_job(
project.id,
source_asset_id=source.id,
job_type="白底图",
prompt="成功图",
path=config["db_path"],
)
success_job = image_studio.update_job_status(
success_job.id,
"succeeded",
path=config["db_path"],
)
failed_job = image_studio.create_job(
project.id,
source_asset_id=source.id,
job_type="场景图",
prompt="失败图",
path=config["db_path"],
)
failed_job = image_studio.update_job_status(
failed_job.id,
"failed",
path=config["db_path"],
)
retry_job = image_studio.create_job(
project.id,
source_asset_id=source.id,
job_type="场景图",
prompt="重试图",
path=config["db_path"],
)
tab = ProductSuiteTab(config=config, db_path=config["db_path"])
self.addCleanup(tab.close)
state = tab._displayed_state
state.account_alias = "alias-a"
state.item_id = project.item_id
state.project_id = project.id
state.project_binding_state = project.binding_state
state.current_job_ids = [success_job.id, failed_job.id]
state.generation_mode = "retry"
state.generation_retry_job_id = failed_job.id
state.total = 1
tab._set_generation_job_ids(state, [retry_job.id])
self.assertEqual(
[success_job.id, retry_job.id],
state.current_job_ids,
)
self.assertEqual([retry_job.id], state.generation_job_ids)
snapshot = tab._generation_job_snapshot(state)
self.assertEqual(1, snapshot["job_ids"])
self.assertEqual(1, snapshot["active"])
image_studio.update_job_status(
retry_job.id,
"failed",
error="重试仍失败",
path=config["db_path"],
)
state.worker = mock.Mock()
state.thread = mock.Mock()
state.generation_run_token = "retry-failed"
state.started_at = time.monotonic()
tab._generation_run_states["retry-failed"] = state.key
messages = []
tab._message = lambda title, message, **kwargs: messages.append(
(title, message)
)
self.assertTrue(
tab._finalize_generation(
state,
"retry-failed",
{"total": 1, "success": 0, "failed": 1},
source="worker",
)
)
self.assertEqual("图片重试失败", messages[-1][0])
retry_cards = [
card
for card in tab.findChildren(SuiteResultCard)
if card.job.id == retry_job.id
]
self.assertEqual(1, len(retry_cards))
self.assertTrue(
any(
button.text() == "重试"
for button in retry_cards[0].findChildren(QPushButton)
)
)
state.show_history = True
tab._load_state(state)
with mock.patch.object(
tab,
"_start_thread",
return_value=mock.Mock(),
):
self.assertTrue(
tab.start_generation(
state,
specs=[
{
"source_asset_id": source.id,
"job_type": failed_job.job_type,
"prompt": failed_job.prompt,
}
],
retry_job_id=failed_job.id,
)
)
self.assertTrue(state.show_history)
self.assertTrue(tab.history_button.isChecked())
state.worker = None
state.thread = None
state.generation_run_token = ""
self.assert_removed(temp_dir)
def test_original_list_expands_without_internal_scrollbars(self):
with self.make_temp_dir() as temp_dir:
config = self._config(temp_dir)
project, assets = self._create_project_with_assets(temp_dir, config, 0)
tab = ProductSuiteTab(config=config, db_path=config["db_path"])
self.addCleanup(tab.close)
state = tab._displayed_state
state.account_alias = "alias-a"
state.item_id = "51100639510"
state.project_id = project.id
tab.resize(1180, 760)
tab.show()
tab._load_state(state)
self.app.processEvents()
self.assertEqual(Qt.ScrollBarAlwaysOff, tab.original_list.horizontalScrollBarPolicy())
self.assertEqual(Qt.ScrollBarAlwaysOff, tab.original_list.verticalScrollBarPolicy())
self.assertEqual(0, tab.original_list.count())
self.assertEqual([], tab.original_list.asset_ids())
self.assertEqual("0/16", tab.original_count_label.text())
self.assertEqual("已选 0 张", tab.original_selected_label.text())
self.assertFalse(tab.select_all_originals_button.isEnabled())
self.assertFalse(tab.invert_originals_button.isEnabled())
self.assertEqual(1, tab.original_list.content_row_count())
empty_height = tab.original_list.height()
for target_count in (1, 2, 5, 6, 7, 16):
for index in range(len(assets), target_count):
source_path = os.path.join(temp_dir, "added-%02d.png" % (index + 1))
self._write_image(source_path)
assets.append(
image_studio.add_asset(
project.id,
image_studio.ASSET_KIND_ORIGINAL,
local_path=source_path,
source_order=index + 1,
path=config["db_path"],
)
)
tab._refresh_originals(state)
self.app.processEvents()
self.assertEqual(target_count, tab.original_list.count())
self.assertEqual(target_count, len(tab.original_list.asset_ids()))
columns = tab.original_list.content_column_count()
expected_rows = (target_count + columns - 1) // columns
self.assertEqual(expected_rows, tab.original_list.content_row_count())
self.assertEqual("%d/16" % target_count, tab.original_count_label.text())
if target_count == 1:
self.assertEqual(empty_height, tab.original_list.height())
self.assertTrue(tab.add_images_button.isEnabled())
wide_height = tab.original_list.height()
tab.original_list.setFixedWidth(250)
self.app.processEvents()
self.assertEqual(2, tab.original_list.content_column_count())
self.assertEqual(8, tab.original_list.content_row_count())
self.assertGreater(tab.original_list.height(), wide_height)
self.assertFalse(tab.add_images_button.isEnabled())
self.assertEqual("已达到16张商品原图上限", tab.add_images_button.toolTip())
self.assert_removed(temp_dir)
def test_empty_original_list_accepts_file_drop_and_clipboard_image(self):
original_list = ProductOriginalList()
self.addCleanup(original_list.close)
original_list.setFixedSize(360, 110)
original_list.show()
self.app.processEvents()
self.assertEqual(0, original_list.count())
self.assertEqual("暂无商品原图", original_list.EMPTY_STATE_TEXT)
self.assertEqual(1, original_list.content_row_count())
dropped = []
pasted = []
original_list.filesDropped.connect(dropped.append)
original_list.clipboardImage.connect(pasted.append)
class DropEvent:
def __init__(self, mime_data):
self._mime_data = mime_data
self.accepted = False
def mimeData(self):
return self._mime_data
def acceptProposedAction(self):
self.accepted = True
mime_data = QMimeData()
expected_path = os.path.abspath("待导入图片.png")
mime_data.setUrls([QUrl.fromLocalFile(expected_path)])
drop_event = DropEvent(mime_data)
original_list.dropEvent(drop_event)
self.assertTrue(drop_event.accepted)
self.assertEqual(
os.path.normcase(os.path.normpath(expected_path)),
os.path.normcase(os.path.normpath(dropped[0][0])),
)
clipboard_image = QImage(24, 18, QImage.Format_RGB32)
clipboard_image.fill(0xFF336699)
QApplication.clipboard().setImage(clipboard_image)
original_list.setFocus()
QTest.keyClick(original_list, Qt.Key_V, Qt.ControlModifier)
self.app.processEvents()
QApplication.clipboard().clear()
self.assertEqual(1, len(pasted))
self.assertTrue(pasted[0].startswith(b"\x89PNG\r\n\x1a\n"))
def test_add_images_button_tracks_capacity_import_and_generation(self):
with self.make_temp_dir() as temp_dir:
config = self._config(temp_dir)
project, _ = self._create_project_with_assets(temp_dir, config, 1)
tab = ProductSuiteTab(config=config, db_path=config["db_path"])
self.addCleanup(tab.close)
state = tab._displayed_state
state.account_alias = "alias-a"
state.item_id = "51100639510"
state.project_id = project.id
tab._load_state(state)
style = tab.add_images_button.styleSheet()
for color in ("#0969da", "#eef4ff", "#dbeafe", "#c7ddff", "#f6f8fa"):
self.assertIn(color, style)
self.assertTrue(tab.add_images_button.isEnabled())
self.assertEqual("添加本地商品原图", tab.add_images_button.toolTip())
state.import_worker = object()
tab._refresh_add_images_action(state)
self.assertFalse(tab.add_images_button.isEnabled())
self.assertEqual("正在添加商品原图", tab.add_images_button.toolTip())
state.import_thread = object()
tab._on_import_failed(state, "测试导入失败")
self.assertIsNone(state.import_worker)
self.assertIsNone(state.import_thread)
self.assertTrue(tab.add_images_button.isEnabled())
state.worker = object()
tab._refresh_add_images_action(state)
self.assertFalse(tab.add_images_button.isEnabled())
self.assertEqual("生成中不能添加商品原图", tab.add_images_button.toolTip())
state.worker = None
tab._refresh_add_images_action(state)
self.assertTrue(tab.add_images_button.isEnabled())
def test_original_checks_preserve_on_refresh_and_clear_on_task_switch(self):
with self.make_temp_dir() as temp_dir:
config = self._config(temp_dir)
project, assets = self._create_project_with_assets(temp_dir, config, 2)
tab = ProductSuiteTab(config=config, db_path=config["db_path"])
self.addCleanup(tab.close)
state = tab._displayed_state
state.account_alias = "alias-a"
state.item_id = "51100639510"
state.project_id = project.id
tab._load_state(state)
self.assertEqual(2, tab.original_list.count())
self.assertTrue(tab.select_all_originals_button.isEnabled())
self.assertTrue(tab.invert_originals_button.isEnabled())
for row in range(tab.original_list.count()):
self.assertIsNotNone(tab.original_list.item(row).data(ORIGINAL_CHECK_STATE_ROLE))
tab.select_all_originals_button.click()
self.assertEqual([asset.id for asset in assets], tab.original_list.checked_asset_ids())
self.assertEqual("已选 2 张", tab.original_selected_label.text())
tab.invert_originals_button.click()
self.assertEqual([], tab.original_list.checked_asset_ids())
tab.original_list.set_checked_asset_ids([assets[1].id])
tab._refresh_originals(state)
self.assertEqual([assets[1].id], tab.original_list.checked_asset_ids())
tab.reorder_originals([assets[1].id, assets[0].id])
self.assertEqual([assets[1].id], tab.original_list.checked_asset_ids())
tab.add_task(inherit=True)
self.assertEqual([], tab.original_list.checked_asset_ids())
self.assertFalse(tab.select_all_originals_button.isEnabled())
tab.task_tabs.setCurrentIndex(0)
self.assertEqual([], tab.original_list.checked_asset_ids())
self.assertEqual("已选 0 张", tab.original_selected_label.text())
self.assert_removed(temp_dir)
def test_temporary_draft_allows_local_work_but_blocks_shopee_pull_and_recovers(self):
with self.make_temp_dir() as temp_dir:
config = self._config(temp_dir)
accounts.create_account("主店", "alias-a", debug_port=9222, config=config)
tab = ProductSuiteTab(config=config, db_path=config["db_path"])
self.addCleanup(tab.close)
state = tab._displayed_state
self.assertTrue(tab.add_images_button.isEnabled())
self.assertFalse(tab.item_id_hint_label.isHidden())
self.assertEqual("请输入正确的商品ID", tab.item_id_hint_label.text())
with mock.patch(
"app.gui.tabs.product_suite.QFileDialog.getOpenFileNames",
return_value=([], ""),
):
tab.choose_images()
self.assertEqual([], image_studio.list_projects(path=config["db_path"]))
source_path = os.path.join(temp_dir, "draft-source.png")
self._write_image(source_path)
with mock.patch.object(tab, "_start_thread", return_value=object()):
tab._start_import(file_paths=[source_path])
worker = state.import_worker
draft = image_studio.get_project(state.project_id, path=config["db_path"])
self.assertTrue(image_studio.is_draft_project(draft))
self.assertEqual("", state.item_id)
self.assertIn("临时草稿", tab.task_tabs.tabText(tab.task_tabs.currentIndex()))
self.assertFalse(tab.item_id_hint_label.isHidden())
self.assertTrue(tab.pull_button.isEnabled())
self.assertEqual("需要先绑定正式商品ID", tab.pull_button.toolTip())
tab._on_import_finished(state, worker.execute())
self.assertEqual(1, len(image_studio.list_assets(draft.id, path=config["db_path"])))
captured = {}
class _Signal:
def connect(self, callback):
self.callback = callback
class _AiWriteWorker:
def __init__(self, instruction, context, **kwargs):
captured["instruction"] = instruction
captured["context"] = context
self.finished = _Signal()
self.cancelled = _Signal()
self.failed = _Signal()
def cancel(self):
pass
with mock.patch(
"app.gui.tabs.product_suite.ProductSuiteAiWriteWorker",
_AiWriteWorker,
), mock.patch.object(tab, "_start_thread", return_value=object()):
tab.start_ai_write()
self.assertIn("未绑定商品", captured["context"])
self.assertNotIn("draft_", captured["context"])
state.ai_worker = None
state.ai_thread = None
state.ai_started_at = None
tab._apply_running_state(state)
pull_message = mock.Mock()
with mock.patch.object(tab, "_message", pull_message):
tab.pull_main_images()
self.assertIsNone(state.pull_worker)
self.assertEqual("无法拉取蝦皮主图", pull_message.call_args.args[0])
self.assertIn("当前为临时项目", pull_message.call_args.args[1])
tab.item_id_edit.setText("51100639510")
with mock.patch.object(tab, "_confirm", return_value=True):
tab._on_item_finished()
bound = image_studio.get_project(draft.id, path=config["db_path"])
self.assertEqual("51100639510", bound.item_id)
self.assertEqual(image_studio.PROJECT_BINDING_BOUND, bound.binding_state)
self.assertTrue(tab.item_id_hint_label.isHidden())
self.assertIn("套图任务", tab.task_tabs.tabText(tab.task_tabs.currentIndex()))
draft_state = tab.add_task(inherit=False)
draft = tab._create_draft_project(draft_state)
image_studio.add_asset(
draft.id,
image_studio.ASSET_KIND_ORIGINAL,
local_path=source_path,
path=config["db_path"],
)
draft_state.ai_worker = mock.Mock()
with mock.patch.object(tab, "_draft_close_action", return_value="cancel"):
tab.close_task(tab.task_tabs.currentIndex())
draft_state.ai_worker.cancel.assert_not_called()
self.assertIn(draft_state.key, tab._states)
draft_state.ai_worker = None
with mock.patch.object(tab, "_draft_close_action", return_value="keep"):
tab.close_task(tab.task_tabs.currentIndex())
self.assertIsNotNone(image_studio.get_project(draft.id, path=config["db_path"]))
tab.close()
restored = ProductSuiteTab(config=config, db_path=config["db_path"])
self.addCleanup(restored.close)
restored_state = next(
state for state in restored._states.values() if state.project_id == draft.id
)
self.assertEqual("", restored_state.item_id)
self.assertTrue(restored._is_draft_state(restored_state))
restored_index = next(
index
for index in range(restored.task_tabs.count())
if restored.task_tabs.tabData(index) == restored_state.key
)
self.assertIn("临时草稿", restored.task_tabs.tabText(restored_index))
self.assert_removed(temp_dir)
def test_first_failed_import_discards_new_empty_temporary_draft(self):
with self.make_temp_dir() as temp_dir:
config = self._config(temp_dir)
accounts.create_account("主店", "alias-a", debug_port=9222, config=config)
tab = ProductSuiteTab(config=config, db_path=config["db_path"])
self.addCleanup(tab.close)
bad_path = os.path.join(temp_dir, "not-an-image.png")
with open(bad_path, "wb") as handle:
handle.write(b"not an image")
state = tab._displayed_state
with mock.patch.object(tab, "_start_thread", return_value=object()):
tab._start_import(file_paths=[bad_path])
worker = state.import_worker
draft_id = state.project_id
with mock.patch.object(tab, "_message"):
tab._on_import_finished(state, worker.execute())
discarded = image_studio.get_project(
draft_id,
path=config["db_path"],
include_deleted=True,
)
self.assertIsNotNone(discarded.deleted_at)
self.assertIsNone(state.project_id)
self.assertEqual([], image_studio.list_recoverable_draft_projects(path=config["db_path"]))
self.assert_removed(temp_dir)
def test_pull_confirmation_always_shows_account_item_and_existing_count(self):
with self.make_temp_dir() as temp_dir:
config = self._config(temp_dir)
account = accounts.create_account(
"主店",
"alias-a",
debug_port=9222,
config=config,
)
tab = ProductSuiteTab(config=config, db_path=config["db_path"])
self.addCleanup(tab.close)
state = tab._displayed_state
tab.item_id_edit.setText("51100639510")
confirmations = []
tab._confirm = lambda title, message, **kwargs: confirmations.append(
(title, message, kwargs)
) and False
tab.pull_main_images()
self.assertIsNone(state.pull_worker)
self.assertEqual("确认拉取蝦皮主图", confirmations[0][0])
self.assertIn("主店(alias-a)", confirmations[0][1])
self.assertIn("商品ID:51100639510", confirmations[0][1])
self.assertIn("当前可用商品原图:0张", confirmations[0][1])
self.assertIn("不会修改蝦皮线上商品", confirmations[0][1])
self.assertEqual("确认拉取", confirmations[0][2]["confirm_text"])
project = image_studio.create_or_get_project(
account,
item_id="51100639510",
path=config["db_path"],
)
source_path = os.path.join(temp_dir, "existing.png")
self._write_image(source_path)
image_studio.add_asset(
project.id,
image_studio.ASSET_KIND_ORIGINAL,
local_path=source_path,
path=config["db_path"],
)
state.project_id = project.id
state.project_binding_state = project.binding_state
state.item_id = project.item_id
tab.item_id_edit.setText(project.item_id)
tab._refresh_originals(state)
tab.pull_main_images()
self.assertIsNone(state.pull_worker)
self.assertIn("当前可用商品原图:1张", confirmations[1][1])
self.assertIn("本地手动添加图片会保留", confirmations[1][1])
self.assert_removed(temp_dir)
def test_pull_button_stops_with_confirmation_and_repeated_click_is_ignored(self):
with self.make_temp_dir() as temp_dir:
config = self._config(temp_dir)
accounts.create_account("主店", "alias-a", debug_port=9222, config=config)
tab = ProductSuiteTab(config=config, db_path=config["db_path"])
self.addCleanup(tab.close)
state = tab._displayed_state
tab.item_id_edit.setText("51100639510")
with mock.patch.object(tab, "_confirm", return_value=True), \
mock.patch.object(tab, "_start_thread", return_value=mock.Mock()):
tab.pull_main_images()
self.assertTrue(state.pull_running())
self.assertEqual("停止拉取蝦皮", tab.pull_button.text())
self.assertTrue(tab.pull_button.isEnabled())
worker = state.pull_worker
with mock.patch.object(tab, "_pull_stop_action", return_value="continue"):
tab.pull_main_images()
self.assertFalse(worker.is_cancelled())
self.assertFalse(state.pull_stop_requested)
with mock.patch.object(tab, "_pull_stop_action", return_value="keep"):
tab.pull_main_images()
self.assertTrue(worker.is_cancelled())
self.assertTrue(state.pull_stop_requested)
self.assertEqual("正在停止...", tab.pull_button.text())
stop_action = mock.Mock(return_value="clear_current")
with mock.patch.object(tab, "_pull_stop_action", stop_action):
tab.pull_main_images()
stop_action.assert_not_called()
state.pull_worker = None
state.pull_thread = None
tab._pull_run_states.clear()
state.pull_run_token = ""
self.assert_removed(temp_dir)
def test_stopping_pull_cancels_only_current_pull_downloads(self):
with self.make_temp_dir() as temp_dir:
config = self._config(temp_dir)
accounts.create_account("主店", "alias-a", debug_port=9222, config=config)
tab = ProductSuiteTab(config=config, db_path=config["db_path"])
self.addCleanup(tab.close)
state = tab._displayed_state
pull_worker = mock.Mock()
manual_worker = mock.Mock()
state.download_queue = [11, 12]
state.downloads = {
13: (pull_worker, mock.Mock()),
14: (manual_worker, mock.Mock()),
}
state.download_tokens = {
11: "pull-token",
12: "",
13: "pull-token",
14: "",
}
state.pull_download_asset_ids = {11, 13}
tab._cancel_pull_downloads(state)
self.assertEqual([12], state.download_queue)
self.assertEqual({13}, state.pull_download_asset_ids)
self.assertNotIn(11, state.download_tokens)
pull_worker.cancel.assert_called_once()
manual_worker.cancel.assert_not_called()
self.assert_removed(temp_dir)
def test_clear_stopped_pull_removes_only_new_unreferenced_remote_assets(self):
with self.make_temp_dir() as temp_dir:
config = self._config(temp_dir)
account = accounts.create_account(
"主店",
"alias-a",
debug_port=9222,
config=config,
)
project = image_studio.create_or_get_project(
account,
item_id="51100639510",
path=config["db_path"],
)
existing_remote = image_studio.sync_original_asset_urls(
project.id,
[{"index": 1, "src": "https://susercontent.com/existing.jpg"}],
path=config["db_path"],
)[0]
local_path = os.path.join(temp_dir, "local.png")
self._write_image(local_path)
local_asset = image_studio.add_asset(
project.id,
image_studio.ASSET_KIND_ORIGINAL,
local_path=local_path,
source_order=2,
path=config["db_path"],
)
before_assets = image_studio.list_assets(
project.id,
kind=image_studio.ASSET_KIND_ORIGINAL,
path=config["db_path"],
)
synced = image_studio.sync_original_asset_urls(
project.id,
[
{"index": 1, "src": "https://susercontent.com/new-one.jpg"},
{"index": 2, "src": "https://susercontent.com/new-two.jpg"},
],
path=config["db_path"],
)
new_assets = [
asset
for asset in synced
if asset.remote_url
and asset.remote_url.endswith(("new-one.jpg", "new-two.jpg"))
]
referenced = next(
asset for asset in new_assets if asset.remote_url.endswith("new-two.jpg")
)
image_studio.create_job(
project.id,
source_asset_id=referenced.id,
path=config["db_path"],
)
tab = ProductSuiteTab(config=config, db_path=config["db_path"])
self.addCleanup(tab.close)
state = tab._displayed_state
state.account_alias = "alias-a"
state.item_id = project.item_id
state.project_id = project.id
state.project_binding_state = project.binding_state
state.pull_run_token = "clear-pull"
state.pull_stop_requested = True
state.pull_cleanup_mode = "clear_current"
state.pull_before_asset_ids = {int(asset.id) for asset in before_assets}
state.pull_before_asset_states = [
{
"id": int(asset.id),
"status": asset.status,
"source_order": int(asset.source_order),
}
for asset in before_assets
]
state.pull_asset_ids = {int(asset.id) for asset in new_assets}
state.pull_started_at = time.monotonic()
tab._pull_run_states["clear-pull"] = state.key
messages = []
tab._message = lambda title, message, **kwargs: messages.append(
(title, message)
)
self.assertTrue(tab._finalize_pull(state, "clear-pull"))
remaining = {
asset.id: asset
for asset in image_studio.list_assets(
project.id,
kind=image_studio.ASSET_KIND_ORIGINAL,
path=config["db_path"],
)
}
removed = next(
asset for asset in new_assets if asset.id != referenced.id
)
self.assertNotIn(removed.id, remaining)
self.assertIn(referenced.id, remaining)
self.assertEqual(
image_studio.ASSET_STATUS_AVAILABLE,
remaining[existing_remote.id].status,
)
self.assertEqual(1, remaining[existing_remote.id].source_order)
self.assertIn(local_asset.id, remaining)
self.assertGreater(
remaining[referenced.id].source_order,
remaining[local_asset.id].source_order,
)
self.assertEqual("拉取蝦皮主图已停止", messages[-1][0])
self.assertIn("清理1张", messages[-1][1])
self.assertIn("因引用保留1张", messages[-1][1])
self.assert_removed(temp_dir)
def test_old_pull_result_does_not_override_current_run(self):
with self.make_temp_dir() as temp_dir:
config = self._config(temp_dir)
accounts.create_account("主店", "alias-a", debug_port=9222, config=config)
tab = ProductSuiteTab(config=config, db_path=config["db_path"])
self.addCleanup(tab.close)
state = tab._displayed_state
state.pull_run_token = "current-pull"
state.pull_worker = mock.Mock()
tab._pull_run_states["current-pull"] = state.key
tab._on_pull_finished(
"old-pull",
{"project": None, "assets": [], "count": 0},
)
self.assertEqual("current-pull", state.pull_run_token)
self.assertIsNotNone(state.pull_worker)
state.pull_worker = None
state.pull_run_token = ""
tab._pull_run_states.clear()
self.assert_removed(temp_dir)
def test_pull_thread_finished_fallback_finalizes_requested_stop(self):
with self.make_temp_dir() as temp_dir:
config = self._config(temp_dir)
accounts.create_account("主店", "alias-a", debug_port=9222, config=config)
tab = ProductSuiteTab(config=config, db_path=config["db_path"])
self.addCleanup(tab.close)
state = tab._displayed_state
state.pull_run_token = "pull-fallback"
state.pull_stop_requested = True
state.pull_worker = mock.Mock()
state.pull_thread = mock.Mock()
state.pull_started_at = time.monotonic()
tab._pull_run_states["pull-fallback"] = state.key
messages = []
tab._message = lambda title, message, **kwargs: messages.append(
(title, message)
)
tab._handle_pull_thread_finished("pull-fallback")
self.assertEqual("", state.pull_run_token)
self.assertIsNone(state.pull_worker)
self.assertEqual("拉取蝦皮主图已停止", messages[-1][0])
self.assertEqual("拉取蝦皮主图", tab.pull_button.text())
self.assert_removed(temp_dir)
def test_pull_real_qthread_completion_restores_button_once(self):
with self.make_temp_dir() as temp_dir:
config = self._config(temp_dir)
account = accounts.create_account(
"主店",
"alias-a",
debug_port=9222,
config=config,
)
project = image_studio.create_or_get_project(
account,
item_id="51100639510",
path=config["db_path"],
)
tab = ProductSuiteTab(config=config, db_path=config["db_path"])
self.addCleanup(tab.close)
state = tab._displayed_state
tab.item_id_edit.setText(project.item_id)
with mock.patch.object(tab, "_confirm", return_value=True), \
mock.patch(
"app.gui.workers.image_studio.pull_remote_main_image_urls",
return_value={
"project": project,
"assets": [],
"account": account,
},
):
tab.pull_main_images()
pull_thread = state.pull_thread
deadline = time.monotonic() + 3
while state.pull_running() and time.monotonic() < deadline:
QTest.qWait(20)
self.app.processEvents()
while pull_thread is not None and time.monotonic() < deadline:
try:
running = pull_thread.isRunning()
except RuntimeError:
pull_thread = None
break
if not running:
break
QTest.qWait(20)
self.app.processEvents()
self.assertFalse(state.pull_running())
self.assertIsNone(state.pull_worker)
self.assertEqual("拉取蝦皮主图", tab.pull_button.text())
if pull_thread is not None:
self.assertFalse(pull_thread.isRunning())
self.assert_removed(temp_dir)
def test_generation_confirmation_prevents_job_creation_and_retry_bypasses_it(self):
with self.make_temp_dir() as temp_dir:
config = self._config(temp_dir)
account = accounts.create_account(
"主店",
"alias-a",
debug_port=9222,
config=config,
)
project = image_studio.create_or_get_project(
account,
item_id="51100639510",
path=config["db_path"],
)
source_path = os.path.join(temp_dir, "source.png")
self._write_image(source_path)
source = image_studio.add_asset(
project.id,
image_studio.ASSET_KIND_ORIGINAL,
local_path=source_path,
path=config["db_path"],
)
tab = ProductSuiteTab(config=config, db_path=config["db_path"])
self.addCleanup(tab.close)
state = tab._displayed_state
state.account_alias = "alias-a"
state.item_id = project.item_id
state.project_id = project.id
state.project_binding_state = project.binding_state
state.prompt = "轻便耐用,适合日常使用"
state.settings["per_image_primary"] = True
tab._load_state(state)
confirmations = []
tab._confirm = lambda title, message, **kwargs: confirmations.append(
(title, message, kwargs)
) and False
self.assertFalse(tab.start_generation(state))
self.assertEqual([], image_studio.list_jobs(project.id, path=config["db_path"]))
self.assertEqual("确认生成商品套图", confirmations[0][0])
self.assertIn("主店(alias-a)", confirmations[0][1])
self.assertIn("商品ID:51100639510", confirmations[0][1])
self.assertIn("可用商品原图:1张", confirmations[0][1])
self.assertIn("每张上传图分别作为主图生成:是", confirmations[0][1])
self.assertIn("图片比例:1:1", confirmations[0][1])
self.assertIn("生成总数:", confirmations[0][1])
self.assertIn("消耗 cmhub 点数", confirmations[0][1])
self.assertEqual("确认生成", confirmations[0][2]["confirm_text"])
self.assertEqual("返回修改", confirmations[0][2]["cancel_text"])
failed_job = image_studio.create_job(
project.id,
source_asset_id=source.id,
job_type="场景图",
prompt="失败重试",
path=config["db_path"],
)
failed_job = image_studio.update_job_status(
failed_job.id,
"failed",
path=config["db_path"],
)
confirmations.clear()
with mock.patch.object(tab, "_start_thread", return_value=mock.Mock()):
self.assertTrue(
tab.start_generation(
state,
specs=[
{
"source_asset_id": source.id,
"job_type": failed_job.job_type,
"prompt": failed_job.prompt,
}
],
retry_job_id=failed_job.id,
)
)
self.assertEqual([], confirmations)
state.worker = None
state.thread = None
state.generation_run_token = ""
tab._generation_run_states.clear()
self.assert_removed(temp_dir)
def test_original_checkbox_click_and_keyboard_delete_keep_actions_separate(self):
original_list = ProductOriginalList()
self.addCleanup(original_list.close)
original_list.setFixedSize(240, 210)
for asset_id in (11, 12):
item = QListWidgetItem("原图%d" % asset_id)
item.setData(Qt.UserRole, asset_id)
item.setData(ORIGINAL_CHECK_STATE_ROLE, Qt.Unchecked)
pixmap = QPixmap(82, 64)
pixmap.fill(0xFF336699)
item.setIcon(QIcon(pixmap))
original_list.addItem(item)
original_list.show()
self.app.processEvents()
clicked = []
double_clicked = []
batch_deleted = []
single_deleted = []
original_list.itemClicked.connect(lambda item: clicked.append(item.data(Qt.UserRole)))
original_list.itemDoubleClicked.connect(
lambda item: double_clicked.append(item.data(Qt.UserRole))
)
original_list.deleteAssetsRequested.connect(lambda ids: batch_deleted.append(ids))
original_list.deleteRequested.connect(lambda asset_id: single_deleted.append(asset_id))
first_rect = original_list.visualItemRect(original_list.item(0))
check_point = ProductOriginalDelegate.checkbox_hit_rect(first_rect).center()
QTest.mouseClick(original_list.viewport(), Qt.LeftButton, pos=check_point)
self.assertEqual([11], original_list.checked_asset_ids())
self.assertEqual([], clicked)
QTest.mouseDClick(original_list.viewport(), Qt.LeftButton, pos=check_point)
self.assertEqual([], double_clicked)
QTest.mouseClick(original_list.viewport(), Qt.LeftButton, pos=first_rect.center())
self.assertEqual([11], clicked)
original_list.set_checked_asset_ids([11, 12])
self.assertEqual(
[("删除选中的2张图片…", [11, 12])],
original_list.context_delete_options(11),
)
original_list.set_checked_asset_ids([12])
self.assertEqual(
[("删除这张图片…", [11]), ("删除选中图片…", [12])],
original_list.context_delete_options(11),
)
original_list.setFocus()
original_list.set_checked_asset_ids([11, 12])
QTest.keyClick(original_list, Qt.Key_Delete)
self.assertEqual([[11, 12]], batch_deleted)
original_list.clear_checks()
original_list.setCurrentRow(1)
QTest.keyClick(original_list, Qt.Key_Backspace)
self.assertEqual([12], single_deleted)
def test_batch_delete_confirms_main_image_and_blocks_running_or_downloading(self):
with self.make_temp_dir() as temp_dir:
config = self._config(temp_dir)
project, assets = self._create_project_with_assets(temp_dir, config, 3)
statuses = []
tab = ProductSuiteTab(
config=config,
db_path=config["db_path"],
status_callback=lambda message, level=None: statuses.append((message, level)),
)
self.addCleanup(tab.close)
state = tab._displayed_state
state.account_alias = "alias-a"
state.item_id = "51100639510"
state.project_id = project.id
tab._load_state(state)
confirmations = []
tab._confirm = lambda title, message, **kwargs: confirmations.append(
(title, message, kwargs)
) or True
tab.original_list.set_checked_asset_ids([assets[0].id, assets[1].id])
tab.delete_originals(tab.original_list.checked_asset_ids())
self.assertEqual([assets[2].id], tab.original_list.asset_ids())
self.assertEqual([], tab.original_list.checked_asset_ids())
self.assertIn("选中的2张", confirmations[0][1])
self.assertIn("下一张图片将成为主图", confirmations[0][1])
self.assertIn("不会删除蝦皮线上图片", confirmations[0][1])
self.assertEqual(("已移除2张商品原图", "success"), statuses[-1])
messages = []
tab._message = lambda title, message, **kwargs: messages.append((title, message))
state.download_queue = [assets[2].id]
tab.delete_originals([assets[2].id])
self.assertIn("仍在后台下载", messages[-1][1])
self.assertIsNotNone(image_studio.get_asset(assets[2].id, path=config["db_path"]))
class RunningWorker:
def cancel(self):
return None
state.download_queue = []
state.worker = RunningWorker()
tab.delete_originals([assets[2].id])
self.assertEqual(("生成中不能删除当前任务的商品原图", "warning"), statuses[-1])
self.assertIsNotNone(image_studio.get_asset(assets[2].id, path=config["db_path"]))
state.worker = None
self.assert_removed(temp_dir)
if __name__ == "__main__":
unittest.main()