feat(product-suite): add prompt template settings
This commit is contained in:
@@ -32,6 +32,9 @@ class PackagingTests(unittest.TestCase):
|
||||
self.assertIn("datas=default_prompt_datas", normalized)
|
||||
self.assertIn("COLLECT(", spec)
|
||||
self.assertIn('name="cmshopee"', normalized)
|
||||
suite_prompt = self.read_text("app/default_prompts/product_suite/base.txt")
|
||||
self.assertIn("{套图名称}", suite_prompt)
|
||||
self.assertIn("{尺寸与长图规则}", suite_prompt)
|
||||
|
||||
def test_build_script_blocks_user_data_in_release_output(self):
|
||||
script = self.read_text("scripts/build_exe.ps1")
|
||||
|
||||
@@ -5,7 +5,7 @@ from types import SimpleNamespace
|
||||
|
||||
sys.path.insert(0, os.path.dirname(__file__))
|
||||
|
||||
from app import product_suite
|
||||
from app import product_suite, prompts
|
||||
|
||||
|
||||
class ProductSuiteTests(unittest.TestCase):
|
||||
@@ -88,6 +88,7 @@ class ProductSuiteTests(unittest.TestCase):
|
||||
"40小时续航,适合通勤",
|
||||
settings,
|
||||
"51100639510",
|
||||
template_text=prompts.load_default_product_suite_prompt(),
|
||||
)
|
||||
|
||||
self.assertEqual(3, len(specs))
|
||||
@@ -97,9 +98,81 @@ class ProductSuiteTests(unittest.TestCase):
|
||||
self.assertIn("平台:Shopee", spec["prompt"])
|
||||
self.assertIn("国家地区:中国台湾", spec["prompt"])
|
||||
self.assertIn("输出语言:繁体中文", spec["prompt"])
|
||||
self.assertIn("图片比例:4:3", spec["prompt"])
|
||||
self.assertIn("商品ID:51100639510", spec["prompt"])
|
||||
self.assertIn("本次生成比例:4:3", spec["prompt"])
|
||||
self.assertNotIn("商品ID:51100639510", spec["prompt"])
|
||||
self.assertIn("40小时续航", spec["prompt"])
|
||||
self.assertIn("禁止海报长图、详情页长图和多宫格拼接版面", spec["prompt"])
|
||||
self.assertIn("禁止在画面中出现任何国旗", spec["prompt"])
|
||||
self.assertIn("当前上传图片是本任务唯一主参考图", spec["prompt"])
|
||||
self.assertTrue(
|
||||
spec["prompt"].splitlines()[-1].startswith("本次生成比例:4:3")
|
||||
)
|
||||
|
||||
self.assertIn("套图名称:白底图,白底主图", specs[0]["prompt"])
|
||||
self.assertIn("套图名称:场景图,生活化场景", specs[1]["prompt"])
|
||||
white_prompt = specs[0]["prompt"]
|
||||
self.assertLess(white_prompt.index("重要尺寸要求"), white_prompt.index("重要禁用内容"))
|
||||
self.assertLess(white_prompt.index("重要禁用内容"), white_prompt.index("价格信息规则"))
|
||||
self.assertLess(white_prompt.index("价格信息规则"), white_prompt.index("尺码信息规则"))
|
||||
self.assertLess(white_prompt.index("尺码信息规则"), white_prompt.index("参考图规则"))
|
||||
self.assertLess(white_prompt.index("参考图规则"), white_prompt.index("商品卖点与要求"))
|
||||
|
||||
def test_product_suite_template_validation_and_custom_category_rendering(self):
|
||||
default_text = prompts.load_default_product_suite_prompt()
|
||||
self.assertEqual([], product_suite.product_suite_prompt_errors(default_text))
|
||||
|
||||
unknown = default_text + "\n{未知变量}"
|
||||
self.assertIn("未知变量", ";".join(product_suite.product_suite_prompt_errors(unknown)))
|
||||
|
||||
missing = default_text.replace("{图片比例}", "")
|
||||
self.assertIn(
|
||||
"缺少必需变量",
|
||||
";".join(product_suite.product_suite_prompt_errors(missing)),
|
||||
)
|
||||
|
||||
inline_rule = default_text.replace(
|
||||
"{价格信息规则}",
|
||||
"价格:{价格信息规则}",
|
||||
)
|
||||
self.assertIn(
|
||||
"只读规则变量必须独占一行",
|
||||
";".join(product_suite.product_suite_prompt_errors(inline_rule)),
|
||||
)
|
||||
|
||||
literal_brace = default_text + "\n普通内容{"
|
||||
self.assertIn(
|
||||
"不支持的字面花括号",
|
||||
";".join(product_suite.product_suite_prompt_errors(literal_brace)),
|
||||
)
|
||||
|
||||
settings = product_suite.default_suite_settings()
|
||||
context = product_suite.product_suite_prompt_context(
|
||||
"突出轻量材质",
|
||||
settings,
|
||||
"尺寸图",
|
||||
"draft_hidden",
|
||||
)
|
||||
rendered = product_suite.render_product_suite_prompt(default_text, context)
|
||||
self.assertIn("套图名称:尺寸图", rendered)
|
||||
self.assertNotIn("draft_", rendered)
|
||||
self.assertNotIn("白底主图", rendered)
|
||||
|
||||
def test_product_suite_optional_item_and_reference_variables(self):
|
||||
template = prompts.load_default_product_suite_prompt() + (
|
||||
"\n商品ID:{商品ID}\n参考图序号:{主参考图序号}"
|
||||
)
|
||||
context = product_suite.product_suite_prompt_context(
|
||||
"卖点",
|
||||
product_suite.default_suite_settings(),
|
||||
"卖点图",
|
||||
"",
|
||||
source_index=2,
|
||||
)
|
||||
|
||||
rendered = product_suite.render_product_suite_prompt(template, context)
|
||||
|
||||
self.assertIn("商品ID:未绑定商品", rendered)
|
||||
self.assertIn("参考图序号:2", rendered)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
|
||||
@@ -8,14 +8,14 @@ sys.path.insert(0, os.path.dirname(__file__))
|
||||
|
||||
from _helpers import TempDirMixin
|
||||
|
||||
from app import accounts, appconfig, image_studio, image_studio_images
|
||||
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
|
||||
from PySide6.QtGui import QIcon, QImage, QPixmap, QTextCursor
|
||||
from PySide6.QtTest import QTest
|
||||
from PySide6.QtWidgets import QApplication, QLabel, QListWidgetItem, QPushButton
|
||||
|
||||
@@ -26,6 +26,7 @@ from app.gui.tabs.product_suite import (
|
||||
ProductSuiteTab,
|
||||
SuiteResultCard,
|
||||
)
|
||||
from app.gui.product_suite_prompt_dialog import ProductSuitePromptDialog
|
||||
|
||||
|
||||
class ProductSuiteGuiTests(TempDirMixin, unittest.TestCase):
|
||||
@@ -150,6 +151,19 @@ class ProductSuiteGuiTests(TempDirMixin, unittest.TestCase):
|
||||
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)]
|
||||
@@ -171,6 +185,139 @@ class ProductSuiteGuiTests(TempDirMixin, unittest.TestCase):
|
||||
|
||||
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, "_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)
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import os
|
||||
import sys
|
||||
import unittest
|
||||
from unittest import mock
|
||||
|
||||
sys.path.insert(0, os.path.dirname(__file__))
|
||||
|
||||
@@ -183,6 +184,85 @@ class PromptTests(TempDirMixin, unittest.TestCase):
|
||||
rendered,
|
||||
)
|
||||
|
||||
def test_product_suite_prompt_seed_save_and_restore_are_isolated(self):
|
||||
with self.make_temp_dir() as temp_dir:
|
||||
path = os.path.join(temp_dir, "prompts", "product_suite", "base.txt")
|
||||
default_text = prompts.load_default_product_suite_prompt()
|
||||
|
||||
self.assertEqual(default_text, prompts.ensure_default_product_suite_prompt(path))
|
||||
self.assertEqual(default_text, prompts.load_product_suite_prompt(path))
|
||||
|
||||
custom_text = "自定义说明\n" + default_text
|
||||
prompts.save_product_suite_prompt(custom_text, path)
|
||||
self.assertEqual(custom_text, prompts.ensure_default_product_suite_prompt(path))
|
||||
self.assertEqual(custom_text, prompts.load_product_suite_prompt(path))
|
||||
|
||||
self.assertEqual(default_text, prompts.restore_default_product_suite_prompt(path))
|
||||
self.assertEqual(default_text, prompts.load_product_suite_prompt(path))
|
||||
|
||||
self.assert_removed(temp_dir)
|
||||
|
||||
def test_product_suite_prompt_invalid_user_file_is_preserved(self):
|
||||
with self.make_temp_dir() as temp_dir:
|
||||
path = os.path.join(temp_dir, "prompts", "product_suite", "base.txt")
|
||||
os.makedirs(os.path.dirname(path), exist_ok=True)
|
||||
invalid = "坏模板{未知变量}"
|
||||
with open(path, "w", encoding="utf-8") as handle:
|
||||
handle.write(invalid)
|
||||
|
||||
with self.assertRaisesRegex(prompts.PromptError, "套图提示词模板无效"):
|
||||
prompts.ensure_default_product_suite_prompt(path)
|
||||
with open(path, "r", encoding="utf-8") as handle:
|
||||
self.assertEqual(invalid, handle.read())
|
||||
|
||||
self.assert_removed(temp_dir)
|
||||
|
||||
def test_product_suite_prompt_unreadable_utf8_is_not_overwritten(self):
|
||||
with self.make_temp_dir() as temp_dir:
|
||||
path = os.path.join(temp_dir, "prompts", "product_suite", "base.txt")
|
||||
os.makedirs(os.path.dirname(path), exist_ok=True)
|
||||
raw = b"\xff\xfe\x00\x80"
|
||||
with open(path, "wb") as handle:
|
||||
handle.write(raw)
|
||||
|
||||
with self.assertRaisesRegex(prompts.PromptError, "读取失败"):
|
||||
prompts.ensure_default_product_suite_prompt(path)
|
||||
with open(path, "rb") as handle:
|
||||
self.assertEqual(raw, handle.read())
|
||||
|
||||
self.assert_removed(temp_dir)
|
||||
|
||||
def test_product_suite_prompt_invalid_packaged_default_does_not_create_file(self):
|
||||
with self.make_temp_dir() as temp_dir:
|
||||
path = os.path.join(temp_dir, "prompts", "product_suite", "base.txt")
|
||||
with mock.patch.object(
|
||||
prompts,
|
||||
"load_default_product_suite_prompt",
|
||||
side_effect=prompts.PromptError(prompts.PRODUCT_SUITE_DEFAULT_ERROR),
|
||||
):
|
||||
with self.assertRaisesRegex(prompts.PromptError, "内置套图提示词模板无效"):
|
||||
prompts.ensure_default_product_suite_prompt(path)
|
||||
self.assertFalse(os.path.exists(path))
|
||||
|
||||
self.assert_removed(temp_dir)
|
||||
|
||||
def test_product_suite_prompt_atomic_save_failure_keeps_original(self):
|
||||
with self.make_temp_dir() as temp_dir:
|
||||
path = os.path.join(temp_dir, "prompts", "product_suite", "base.txt")
|
||||
default_text = prompts.ensure_default_product_suite_prompt(path)
|
||||
custom_text = "自定义说明\n" + default_text
|
||||
|
||||
with mock.patch("app.prompts.os.replace", side_effect=OSError("文件占用")):
|
||||
with self.assertRaisesRegex(prompts.PromptError, "保存失败"):
|
||||
prompts.save_product_suite_prompt(custom_text, path)
|
||||
|
||||
self.assertEqual(default_text, prompts.load_product_suite_prompt(path))
|
||||
self.assertFalse(
|
||||
any(name.startswith(".prompt-") for name in os.listdir(os.path.dirname(path)))
|
||||
)
|
||||
|
||||
self.assert_removed(temp_dir)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
|
||||
Reference in New Issue
Block a user