Files
cmbot/tests/test_ai_outfit_panel.py
T

354 lines
14 KiB
Python

"""Smoke tests for AI outfit panel defaults (offscreen Qt)."""
import os
import shutil
import sys
import tempfile
import time
import unittest
from pathlib import Path
os.environ.setdefault("QT_QPA_PLATFORM", "offscreen")
sys.path.insert(0, str(Path(__file__).parent.parent / "src"))
import services.file_service as fs
from services.config_service import DEFAULT_CONFIG
class TestAiOutfitPanelDefaults(unittest.TestCase):
def setUp(self):
self.tmp = Path(tempfile.mkdtemp())
self._env = os.environ.get("CMBOT_DATA_DIR")
os.environ["CMBOT_DATA_DIR"] = str(self.tmp / "data")
self.app_dir = self.tmp / "app"
self.app_dir.mkdir(parents=True)
self._orig_get_app_dir = fs.get_app_dir
fs.get_app_dir = lambda: self.app_dir
self.app = self._app()
def tearDown(self):
fs.get_app_dir = self._orig_get_app_dir
if self._env is None:
os.environ.pop("CMBOT_DATA_DIR", None)
else:
os.environ["CMBOT_DATA_DIR"] = self._env
shutil.rmtree(str(self.tmp), ignore_errors=True)
def _app(self):
from PySide6.QtWidgets import QApplication
app = QApplication.instance()
if app is None:
app = QApplication([])
return app
def _panel(self):
from app.widgets.ai_outfit_panel import AiOutfitPanel
return AiOutfitPanel()
def test_empty_config_defaults_to_outfit_output_dir(self):
panel = self._panel()
cfg = dict(DEFAULT_CONFIG)
cfg["outfit_output_dir"] = ""
panel.apply_config(cfg)
self.assertEqual(Path(panel._output_edit.text()), self.app_dir / "穿搭图片")
def test_saved_outfit_output_dir_takes_precedence(self):
panel = self._panel()
saved = self.tmp / "custom-output"
cfg = dict(DEFAULT_CONFIG)
cfg["outfit_output_dir"] = str(saved)
panel.apply_config(cfg)
self.assertEqual(Path(panel._output_edit.text()), saved)
def test_generation_setting_label_is_image_concurrency(self):
from PySide6.QtWidgets import QLabel
panel = self._panel()
labels = [label.text() for label in panel.findChildren(QLabel)]
self.assertIn("图片并发数", labels)
def test_detail_table_compacts_short_columns_and_stretches_title(self):
from PySide6.QtWidgets import QHeaderView
panel = self._panel()
header = panel._table.horizontalHeader()
self.assertEqual(header.sectionResizeMode(0), QHeaderView.Fixed)
self.assertEqual(header.sectionResizeMode(2), QHeaderView.Fixed)
self.assertEqual(header.sectionResizeMode(4), QHeaderView.Fixed)
self.assertEqual(header.sectionResizeMode(1), QHeaderView.Stretch)
self.assertLessEqual(panel._table.columnWidth(0), 56)
self.assertLessEqual(panel._table.columnWidth(2), 100)
self.assertLessEqual(panel._table.columnWidth(4), 90)
# §19.26: 衣服图 / 结果·原因 再收窄(仍可手拖)
self.assertLessEqual(panel._table.columnWidth(3), 90)
self.assertLessEqual(panel._table.columnWidth(5), 130)
def test_title_group_present_no_dropdown_preview_removed(self):
"""标题生成组存在;无标题模型下拉(配置定名 §17.3);预览块已移除。"""
from PySide6.QtWidgets import QLabel
panel = self._panel()
panel.apply_config(dict(DEFAULT_CONFIG))
labels = [label.text() for label in panel.findChildren(QLabel)]
self.assertIn("标题生成提示词", labels)
self.assertNotIn("标题模型", labels) # 下拉已去掉(§17.3 配置定名)
self.assertNotIn("最终生成要求预览", labels)
self.assertFalse(hasattr(panel, "_title_model_combo"))
self.assertFalse(hasattr(panel, "_preview_view"))
self.assertFalse(hasattr(panel, "_sample_combo"))
self.assertTrue(hasattr(panel, "_title_btn"))
def test_title_prompt_defaults_loaded(self):
from services.config_service import DEFAULT_TITLE_PROMPT
panel = self._panel()
panel.apply_config(dict(DEFAULT_CONFIG))
self.assertEqual(panel._title_prompt_edit.toPlainText(), DEFAULT_TITLE_PROMPT)
def test_title_wait_label_defaults_hidden(self):
panel = self._panel()
self.assertTrue(hasattr(panel, "_title_wait_label"))
self.assertTrue(panel._title_wait_label.isHidden())
self.assertEqual(panel._title_wait_label.text(), "")
def test_title_wait_timer_formats_elapsed_and_limit(self):
panel = self._panel()
panel._start_title_wait_timer(600)
panel._title_wait_started_at = time.monotonic() - 95
panel._refresh_title_wait_label()
self.assertFalse(panel._title_wait_label.isHidden())
self.assertEqual(panel._title_wait_label.text(), "等待中 01:35 / 10:00")
self.assertTrue(panel._title_wait_timer.isActive())
panel._reset_title_wait_label()
def test_title_wait_limit_uses_model_timeout_override(self):
from services.ai_image_service import AiModelConfig
panel = self._panel()
default_cfg = AiModelConfig(
url="https://r/v1/chat/completions", model="m", api_key="sk",
api_type="chat", timeout_seconds=0, connect_timeout_seconds=30)
override_cfg = AiModelConfig(
url="https://r/v1/chat/completions", model="m", api_key="sk",
api_type="chat", timeout_seconds=120, connect_timeout_seconds=30)
self.assertEqual(panel._title_wait_limit_for_model(default_cfg), 600)
self.assertEqual(panel._title_wait_limit_for_model(override_cfg), 120)
def test_title_progress_switches_wait_label_to_writing(self):
from core.models import OutfitTask, TitleResult
panel = self._panel()
task = OutfitTask(row_index=2, title="", product_id="SKU", garment_path="a.png")
panel._populate_table([task])
panel._start_title_wait_timer(600)
panel._on_title_progress(
1, 3, TitleResult(task=task, success=True, generated_title="新标题"))
self.assertFalse(panel._title_wait_timer.isActive())
self.assertFalse(panel._title_wait_label.isHidden())
self.assertEqual(panel._title_wait_label.text(), "写入中 1/3")
panel._reset_title_wait_label()
def test_prompt_buttons_save_row_insert_title_and_preview(self):
"""§19.20: 按钮行含「保存」;编辑框下方保留「插入标题」+「预览最终提示词」。"""
from PySide6.QtWidgets import QPushButton
panel = self._panel()
panel.apply_config(dict(DEFAULT_CONFIG))
texts = [b.text() for b in panel.findChildren(QPushButton)]
self.assertIn("保存", texts)
self.assertIn("插入标题", texts)
self.assertIn("预览最终提示词", texts)
self.assertNotIn("保存话术", texts) # 旧按钮已改名上移
def test_preview_dialog_renders_selected_row(self):
"""§7.3: 预览弹窗按选中数据行替换 {title} 并附加输出要求。"""
from openpyxl import Workbook
from app.widgets.ai_outfit_panel import _OutfitPreviewDialog
xlsx = self.tmp / "rows.xlsx"
wb = Workbook(); ws = wb.active
ws.append(["标题", "货号", "原始图片路径", "结果", "状态", "原因"])
ws.append(["纯棉短袖", "TY001", r"D:\img\a.png", "", "", ""])
wb.save(str(xlsx)); wb.close()
panel = self._panel()
panel.apply_config(dict(DEFAULT_CONFIG))
panel._excel_edit.setText(str(xlsx))
panel._prompt_edit.setPlainText("为 {title} 生成")
panel._set_combo(panel._resolution, "1K")
dialog = _OutfitPreviewDialog(panel) # __init__ fills rows + refresh
# Row dropdown picked up the valid row; pick it.
self.assertEqual(dialog._combo.count(), 1)
dialog._combo.setCurrentIndex(0)
text = dialog._view.toPlainText()
self.assertTrue(text.startswith("为 纯棉短袖 生成"))
self.assertIn("批量生成输出要求", text)
self.assertIn("参考解析度:1K", text)
dialog.deleteLater()
def test_find_title_model_resolves_by_config_name(self):
"""title_model 名字命中 ai_models.json → 返回 AiModelConfig。"""
from services.ai_image_service import AiModelConfig
panel = self._panel()
panel._models = [
{"name": "GPT-5.5 文本", "url": "https://r/v1/chat/completions",
"model": "gpt-5.5", "api_key": "sk-x", "api_type": "chat"},
{"name": "图片模型", "url": "https://r/v1/images/edits",
"model": "img", "api_key": "sk-y", "api_type": "images_edits"},
]
panel._title_model_name = "GPT-5.5 文本"
config, error = panel._find_title_model()
self.assertIsNone(error)
self.assertIsInstance(config, AiModelConfig)
self.assertEqual(config.model, "gpt-5.5")
def test_image_model_combo_filters_configured_title_model(self):
"""§19.29: 图片 AI 模型下拉不显示 app_config.title_model 同名模型。"""
panel = self._panel()
panel._models = [
{"name": "GPT-5.5 文本", "url": "https://r/v1/chat/completions",
"model": "gpt-5.5", "api_key": "sk-x", "api_type": "chat"},
{"name": "GPT Image 2", "url": "https://r/v1/images/edits",
"model": "gpt-image", "api_key": "sk-y", "api_type": "images_edits"},
{"name": "Nano Banana 2", "url": "https://r/v1/chat/completions",
"model": "nano", "api_key": "sk-z", "api_type": "auto"},
]
panel._title_model_name = "GPT-5.5 文本"
panel._fill_model_combo(panel._model_combo, "GPT-5.5 文本")
names = [panel._model_combo.itemText(i) for i in range(panel._model_combo.count())]
self.assertNotIn("GPT-5.5 文本", names)
self.assertEqual(names, ["GPT Image 2", "Nano Banana 2"])
self.assertEqual(panel._model_combo.currentText(), "GPT Image 2")
self.assertTrue(panel._model_combo.isEnabled())
self.assertEqual(panel._model_combo.currentData()["name"], "GPT Image 2")
def test_selected_model_config_uses_filtered_combo_data(self):
"""Filtered combo indices no longer match self._models; use item data."""
panel = self._panel()
panel._models = [
{"name": "GPT-5.5 文本", "url": "https://r/v1/chat/completions",
"model": "gpt-5.5", "api_key": "sk-x", "api_type": "chat"},
{"name": "GPT Image 2", "url": "https://r/v1/images/edits",
"model": "gpt-image", "api_key": "sk-y", "api_type": "images_edits"},
{"name": "Nano Banana 2", "url": "https://r/v1/chat/completions",
"model": "nano", "api_key": "sk-z", "api_type": "auto"},
]
panel._title_model_name = "GPT-5.5 文本"
panel._fill_model_combo(panel._model_combo, "Nano Banana 2")
config = panel._selected_model_config()
self.assertEqual(config.model, "nano")
def test_image_model_combo_keeps_title_model_available_for_title_generation(self):
"""Filtering the image dropdown must not remove the title model from self._models."""
from services.ai_image_service import AiModelConfig
panel = self._panel()
panel._models = [
{"name": "GPT-5.5 文本", "url": "https://r/v1/chat/completions",
"model": "gpt-5.5", "api_key": "sk-x", "api_type": "chat"},
{"name": "GPT Image 2", "url": "https://r/v1/images/edits",
"model": "gpt-image", "api_key": "sk-y", "api_type": "images_edits"},
]
panel._title_model_name = "GPT-5.5 文本"
panel._fill_model_combo(panel._model_combo, "GPT Image 2")
config, error = panel._find_title_model()
self.assertIsNone(error)
self.assertIsInstance(config, AiModelConfig)
self.assertEqual(config.model, "gpt-5.5")
def test_image_model_combo_only_title_model_shows_placeholder(self):
panel = self._panel()
panel._models = [
{"name": "GPT-5.5 文本", "url": "https://r/v1/chat/completions",
"model": "gpt-5.5", "api_key": "sk-x", "api_type": "chat"},
]
panel._title_model_name = "GPT-5.5 文本"
panel._fill_model_combo(panel._model_combo, "")
self.assertEqual(panel._model_combo.count(), 1)
self.assertIn("未配置可用图片模型", panel._model_combo.currentText())
self.assertFalse(panel._model_combo.isEnabled())
self.assertIsNone(panel._model_combo.currentData())
def test_find_title_model_missing_name_errors(self):
panel = self._panel()
panel._models = [{"name": "别的模型", "url": "https://r/v1/chat/completions",
"model": "m", "api_key": "sk-x", "api_type": "chat"}]
panel._title_model_name = "GPT-5.5 文本"
config, error = panel._find_title_model()
self.assertIsNone(config)
self.assertIn("GPT-5.5 文本", error)
def test_find_title_model_no_models_errors(self):
panel = self._panel()
panel._models = []
panel._title_model_name = "GPT-5.5 文本"
config, error = panel._find_title_model()
self.assertIsNone(config)
self.assertTrue(error)
def test_find_title_model_image_model_rejected_upfront(self):
"""§19.21: title_model 命中图片模型(images_edits)→ 开跑前报错。"""
panel = self._panel()
panel._models = [
{"name": "GPT-5.5 文本", "url": "https://r/v1/images/edits",
"model": "gpt-image", "api_key": "sk-x", "api_type": "images_edits"},
]
panel._title_model_name = "GPT-5.5 文本"
config, error = panel._find_title_model()
self.assertIsNone(config)
self.assertIn("图片模型", error)
def test_find_title_model_image_model_by_url_autodetect_rejected(self):
"""api_type=auto 但 URL 是 images 端点 → 仍按图片模型拦截。"""
panel = self._panel()
panel._models = [
{"name": "标题模型", "url": "https://r/v1/images/generations",
"model": "x", "api_key": "sk-x", "api_type": "auto"},
]
panel._title_model_name = "标题模型"
config, error = panel._find_title_model()
self.assertIsNone(config)
self.assertIn("图片模型", error)
if __name__ == "__main__":
unittest.main()