feat(ai-outfit): AI 穿搭 UI tab (§19.3) + config plumbing (§19.4)

- main_window: wrap workflow in a QStackedWidget (page 0 = print, page 1 =
  AI outfit); enable tab 2「AI 穿搭」, switch pages on tab change.
- app/widgets/ai_outfit_panel.py: three-column page per docs/11 §10 —
  left settings (Excel/output/model/prompt editor+save+insert+preview
  dialog/batch options), center (recent-results thumbnails + detail
  table), right (progress/stats/start/stop/export failures/log).
- Threading: QThread + _OutfitWorker(QObject) wraps OutfitBatchRunner;
  queued signals refresh UI, each row written back to Excel on the worker
  thread; finish summary + failure-list CSV export.
- config_service: load_ai_models()/load_outfit_prompt()/save_outfit_prompt()
  + outfit_* keys in app_config; panel persists via config_changed signal.
- tests/test_config_service.py: 8 cases for the new helpers. Full suite
  (12 files) green on Python 3.7; offscreen MainWindow smoke test passes.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
2026-06-22 08:52:37 +08:00
co-authored by Claude Opus 4.8
parent 2d408d9593
commit 1526254486
5 changed files with 874 additions and 12 deletions
+73
View File
@@ -0,0 +1,73 @@
"""Tests for AI-outfit config helpers in config_service (no GUI)."""
import json
import os
import shutil
import sys
import tempfile
import unittest
from pathlib import Path
sys.path.insert(0, str(Path(__file__).parent.parent / "src"))
import services.config_service as cs
class TestOutfitConfigHelpers(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)
self.config_dir = self.tmp / "config"
self.config_dir.mkdir(parents=True, exist_ok=True)
def tearDown(self):
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)
# -- ai_models.json -------------------------------------------------
def test_load_ai_models_missing_returns_empty(self):
self.assertEqual(cs.load_ai_models(), [])
def test_load_ai_models_object_with_models_list(self):
(self.config_dir / "ai_models.json").write_text(
json.dumps({"models": [{"name": "m1", "url": "https://x"}]}),
encoding="utf-8")
models = cs.load_ai_models()
self.assertEqual(len(models), 1)
self.assertEqual(models[0]["name"], "m1")
def test_load_ai_models_bare_list(self):
(self.config_dir / "ai_models.json").write_text(
json.dumps([{"name": "a"}, {"name": "b"}]), encoding="utf-8")
self.assertEqual(len(cs.load_ai_models()), 2)
def test_load_ai_models_corrupt_returns_empty(self):
(self.config_dir / "ai_models.json").write_text("{ not json", encoding="utf-8")
self.assertEqual(cs.load_ai_models(), [])
def test_load_ai_models_tolerates_bom(self):
(self.config_dir / "ai_models.json").write_bytes(
"".encode("utf-8") + json.dumps([{"name": "z"}]).encode("utf-8"))
self.assertEqual(cs.load_ai_models()[0]["name"], "z")
# -- outfit_prompt.txt ----------------------------------------------
def test_prompt_default_when_missing(self):
self.assertEqual(cs.load_outfit_prompt(), cs.DEFAULT_OUTFIT_PROMPT)
def test_prompt_save_then_load_roundtrip(self):
cs.save_outfit_prompt("hello {title} {product_id}")
self.assertEqual(cs.load_outfit_prompt(), "hello {title} {product_id}")
def test_prompt_saved_without_bom(self):
cs.save_outfit_prompt("abc")
raw = (self.config_dir / "outfit_prompt.txt").read_bytes()
self.assertFalse(raw.startswith(b"\xef\xbb\xbf"))
if __name__ == "__main__":
unittest.main()