"""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.assertIn("#0078d4", panel._title_wait_label.styleSheet()) self.assertTrue(panel._title_wait_timer.isActive()) panel._reset_title_wait_label() self.assertEqual(panel._title_wait_label.styleSheet(), "") 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") self.assertIn("#107c10", panel._title_wait_label.styleSheet()) panel._reset_title_wait_label() def test_title_wait_finish_uses_success_and_error_colors(self): panel = self._panel() panel._finish_title_wait("完成 3 条", "success") self.assertFalse(panel._title_wait_label.isHidden()) self.assertIn("#107c10", panel._title_wait_label.styleSheet()) panel._finish_title_wait("生成失败", "error") self.assertIn("#c42b1c", panel._title_wait_label.styleSheet()) 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()