Files
cmbot/tests/test_ai_outfit.py
T

353 lines
12 KiB
Python

"""Tests for single-row AI outfit generation core."""
import os
import shutil
import sys
import tempfile
import threading
import time
import unittest
from io import BytesIO
from pathlib import Path
from PIL import Image
sys.path.insert(0, str(Path(__file__).parent.parent / "src"))
class TestAiOutfitCore(unittest.TestCase):
def setUp(self):
self.tmp = Path(tempfile.mkdtemp())
def tearDown(self):
shutil.rmtree(str(self.tmp), ignore_errors=True)
def _task(self, product_id="TY001"):
from core.models import OutfitTask
return OutfitTask(
row_index=2,
title="纯棉短袖",
product_id=product_id,
garment_path=str(self.tmp / "garment.png"),
)
def _image_bytes(self, size=(640, 480), color=(200, 120, 80)):
img = Image.new("RGB", size, color)
buffer = BytesIO()
img.save(buffer, format="PNG")
return buffer.getvalue()
def test_render_prompt_replaces_placeholders(self):
from core.ai_outfit import render_prompt
prompt = render_prompt("商品 {title} / {product_id}", self._task())
# No resolution -> no appended requirements tail.
self.assertEqual(prompt, "商品 纯棉短袖 / TY001")
def test_render_prompt_appends_requirements_with_resolution(self):
from core.ai_outfit import render_prompt
prompt = render_prompt("话术 {title}", self._task(), resolution="2K")
self.assertTrue(prompt.startswith("话术 纯棉短袖"))
self.assertIn("批量生成输出要求:", prompt)
self.assertIn("参考解析度:2K", prompt)
self.assertIn("不可跑版", prompt)
def test_build_output_requirements_empty_and_filled(self):
from core.ai_outfit import build_output_requirements
self.assertEqual(build_output_requirements(""), "")
self.assertEqual(build_output_requirements(None), "")
filled = build_output_requirements("4K")
self.assertIn("参考解析度:4K", filled)
self.assertIn("固定 1:1 正方形主图", filled)
def test_safe_product_filename_replaces_invalid_chars(self):
from core.ai_outfit import safe_product_filename
self.assertEqual(safe_product_filename('TY:00/1*?"'), "TY_00_1___")
def test_make_output_path_avoids_overwrite(self):
from core.ai_outfit import make_outfit_output_path
first = make_outfit_output_path(self.tmp, "TY001")
first.write_text("exists")
second = make_outfit_output_path(self.tmp, "TY001")
self.assertEqual(second.name, "TY001_1.jpg")
def test_save_jpg_under_limit_outputs_square_jpg(self):
from core.ai_outfit import save_jpg_under_limit
out = self.tmp / "out.jpg"
save_jpg_under_limit(self._image_bytes(size=(640, 480)), out, quality=85, max_bytes=200000)
self.assertTrue(out.exists())
self.assertLessEqual(out.stat().st_size, 200000)
with Image.open(str(out)) as img:
self.assertEqual(img.format, "JPEG")
self.assertEqual(img.size[0], img.size[1])
def test_generate_outfit_image_success(self):
from core.ai_outfit import generate_outfit_image
client = _FakeClient(self._image_bytes())
result = generate_outfit_image(
self._task(),
"为 {title} 生成 {product_id}",
self.tmp,
model_config={"url": "https://api", "model": "m", "api_key": "k"},
api_client=client,
)
self.assertTrue(result.success, result.error)
self.assertTrue(Path(result.output_path).exists())
# generate passes resolution -> rendered prompt + appended requirements.
self.assertTrue(client.prompt.startswith("为 纯棉短袖 生成 TY001"))
self.assertIn("批量生成输出要求:", client.prompt)
self.assertTrue(client.image_path.endswith("garment.png"))
def test_generate_outfit_image_failure(self):
from core.ai_outfit import generate_outfit_image
with self.assertLogs("core.ai_outfit", level="ERROR"):
result = generate_outfit_image(
self._task(),
"prompt",
self.tmp,
model_config={},
api_client=_FailingClient(),
)
self.assertFalse(result.success)
self.assertIn("boom", result.error)
def test_generate_single_file_empty_product_id_uses_source_name(self):
from core.ai_outfit import generate_outfit_image
from core.models import OutfitTask
garment = self.tmp / "shirt.png"
self._make_image_file(garment)
task = OutfitTask(row_index=2, title="款", product_id="",
garment_path=str(garment))
result = generate_outfit_image(
task, "穿 {title}", self.tmp / "o",
model_config={}, api_client=_RecordingClient(self._image_bytes()))
self.assertTrue(result.success, result.error)
self.assertEqual(Path(result.output_path).name, "shirt.jpg")
# -- directory rows (docs/11 §4.1 / §9.1) ---------------------------
def _make_image_file(self, path, color=(10, 20, 30)):
Image.new("RGB", (64, 64), color).save(str(path), format="PNG")
def _dir_task(self, garment_path, product_id="DIRA"):
from core.models import OutfitTask
return OutfitTask(row_index=3, title="目录款", product_id=product_id,
garment_path=str(garment_path))
def _make_dir_with_images(self, name="a", files=("img1.png", "img2.png", "img3.png")):
d = self.tmp / name
d.mkdir()
for fname in files:
self._make_image_file(d / fname)
return d
def test_list_directory_images_filters_sorts_ignores_subdirs(self):
from core.ai_outfit import list_directory_images
d = self.tmp / "imgs"
d.mkdir()
self._make_image_file(d / "b.png")
self._make_image_file(d / "a.jpg")
(d / "note.txt").write_text("x", encoding="utf-8")
(d / "sub").mkdir()
self._make_image_file(d / "sub" / "c.png")
images = list_directory_images(d)
self.assertEqual([p.name for p in images], ["a.jpg", "b.png"])
def test_make_outfit_subdir_path_sanitizes_without_suffix(self):
from core.ai_outfit import make_outfit_subdir_path
p = make_outfit_subdir_path(self.tmp, "a:b", "img/1")
self.assertEqual(p.parent.name, "a_b")
self.assertEqual(p.name, "img_1.jpg")
def test_generate_directory_fans_out_to_named_subdir(self):
from core.ai_outfit import generate_outfit_image
d = self._make_dir_with_images("a")
out = self.tmp / "穿搭图片"
client = _RecordingClient(self._image_bytes())
result = generate_outfit_image(
self._dir_task(d), "话术 {title}", out,
model_config={}, api_client=client,
)
self.assertTrue(result.success, result.error)
self.assertEqual(client.calls, 3)
self.assertEqual(Path(result.output_path), out / "a")
self.assertEqual(len(result.output_paths), 3)
names = sorted(p.name for p in (out / "a").iterdir())
self.assertEqual(names, ["img1.jpg", "img2.jpg", "img3.jpg"])
def test_generate_directory_skips_existing_outputs_on_retry(self):
from core.ai_outfit import generate_outfit_image
d = self._make_dir_with_images("a")
out = self.tmp / "out"
first = generate_outfit_image(
self._dir_task(d), "x {title}", out,
model_config={}, api_client=_RecordingClient(self._image_bytes()))
self.assertTrue(first.success)
# Re-run: every output already exists -> no API calls, still success.
again_client = _RecordingClient(self._image_bytes())
again = generate_outfit_image(
self._dir_task(d), "x {title}", out,
model_config={}, api_client=again_client)
self.assertTrue(again.success)
self.assertEqual(again_client.calls, 0)
self.assertEqual(len(again.output_paths), 3)
def test_generate_directory_empty_fails(self):
from core.ai_outfit import generate_outfit_image
d = self.tmp / "empty"
d.mkdir()
result = generate_outfit_image(
self._dir_task(d), "x", self.tmp / "out",
model_config={}, api_client=_RecordingClient(self._image_bytes()))
self.assertFalse(result.success)
self.assertIn("没有图片", result.error)
def test_generate_directory_missing_fails(self):
from core.ai_outfit import generate_outfit_image
# Trailing separator marks it as a directory even though it doesn't exist.
missing = str(self.tmp / "nope") + os.sep
result = generate_outfit_image(
self._dir_task(missing), "x", self.tmp / "out",
model_config={}, api_client=_RecordingClient(self._image_bytes()))
self.assertFalse(result.success)
self.assertIn("目录不存在", result.error)
def test_generate_directory_partial_failure_aggregates(self):
from core.ai_outfit import generate_outfit_image
d = self._make_dir_with_images("a")
out = self.tmp / "out"
client = _FailOnClient(self._image_bytes(), fail_name="img2.png")
result = generate_outfit_image(
self._dir_task(d), "x", out, model_config={}, api_client=client)
self.assertFalse(result.success)
self.assertIn("3 张中 1 张失败", result.error)
self.assertIn("img2.png", result.error)
# The two that succeeded are still written (and listed for thumbnails).
self.assertEqual(len(result.output_paths), 2)
self.assertFalse((out / "a" / "img2.jpg").exists())
def test_generate_directory_uses_image_concurrency(self):
from core.ai_outfit import generate_outfit_image
d = self._make_dir_with_images("a", files=("img1.png", "img2.png", "img3.png", "img4.png"))
out = self.tmp / "out"
client = _ConcurrentRecordingClient(self._image_bytes())
result = generate_outfit_image(
self._dir_task(d), "x", out, model_config={}, api_client=client,
image_concurrency=2)
self.assertTrue(result.success, result.error)
self.assertEqual(client.calls, 4)
self.assertGreaterEqual(client.max_active, 2)
self.assertEqual(len(result.output_paths), 4)
class _FakeClient:
def __init__(self, image_bytes):
self._image_bytes = image_bytes
self.prompt = None
self.image_path = None
def generate(self, prompt, image_path, resolution="1K"):
self.prompt = prompt
self.image_path = str(image_path)
return self._image_bytes
class _FailingClient:
def generate(self, prompt, image_path, resolution="1K"):
raise RuntimeError("boom")
class _RecordingClient:
"""Records every generate() call (count + image paths) for directory tests."""
def __init__(self, image_bytes):
self._image_bytes = image_bytes
self.calls = 0
self.image_paths = []
def generate(self, prompt, image_path, resolution="1K"):
self.calls += 1
self.image_paths.append(str(image_path))
return self._image_bytes
class _FailOnClient:
"""Fails only for the source image whose filename ends with *fail_name*."""
def __init__(self, image_bytes, fail_name):
self._image_bytes = image_bytes
self._fail_name = fail_name
self.calls = 0
def generate(self, prompt, image_path, resolution="1K"):
self.calls += 1
if str(image_path).endswith(self._fail_name):
raise RuntimeError("bad image")
return self._image_bytes
class _ConcurrentRecordingClient:
"""Thread-safe fake client that records concurrent generate() calls."""
def __init__(self, image_bytes):
self._image_bytes = image_bytes
self.calls = 0
self.active = 0
self.max_active = 0
self._lock = threading.Lock()
def generate(self, prompt, image_path, resolution="1K"):
with self._lock:
self.calls += 1
self.active += 1
self.max_active = max(self.max_active, self.active)
time.sleep(0.05)
with self._lock:
self.active -= 1
return self._image_bytes
if __name__ == "__main__":
unittest.main()