Files
cmbot/tests/test_ai_outfit.py
T
adminandClaude Opus 4.8 7313c8a443 fix(ai-outfit): 停止生成打断当前目录行 + 「停止中…」反馈 (§19.24)
修「开始生成→几秒后停止→两个按钮都灰几十秒像卡死」:非死锁,是温和停止
在等当前目录行收尾,而 _generate_directory_outfit 一次性 submit 全部图片、
shutdown(wait=True) 等全跑完,停止信号进不到该循环;且 _stop 后无反馈。

- outfit_batch:run() 经 _accepts_should_stop 给 generate_func 传 should_stop
  (= _stop_event.is_set),2-arg 才传、1-arg 仍兼容(含既有 mock)
- ai_outfit:generate_outfit_image/_generate_directory_outfit 增 should_stop;
  目录循环改有界提交——始终最多 image_concurrency 张在飞,每张完成后、提交
  下一张前查 should_stop,已停止则停止提交、在飞收尾即返回(结果注明
  「已停止,N 张未生成」,已生成的带 output_paths)
- 面板:_OutfitWorker.gen(task, should_stop) 透传;_stop() 把停止按钮改
  「停止中…」;_set_running(False) 复位「停止生成」

测试:目录行 should_stop 触发后只生成到停止点、返回「已停止」结果;2-arg
generate_func 收到 should_stop;既有用例(1-arg mock、目录并发/部分失败)保持绿。
全套 py37 通过(test_config_service 的 packaging 模板失败属并行 §19.13,无关)。
离屏冒烟:stop 截断 run 且 runner 及时返回;面板停止按钮「停止中…」→结束复位。

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-23 16:46:49 +08:00

375 lines
13 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_stops_remaining_on_should_stop(self):
"""§19.24: should_stop 触发后不再提交剩余图片,返回「已停止」结果。"""
from core.ai_outfit import generate_outfit_image
d = self._make_dir_with_images("a") # 3 images, image_concurrency=1
out = self.tmp / "out"
client = _RecordingClient(self._image_bytes())
# stop as soon as the first image has been generated
def should_stop():
return client.calls >= 1
result = generate_outfit_image(
self._dir_task(d), "话术", out, model_config={},
api_client=client, should_stop=should_stop)
self.assertFalse(result.success)
self.assertEqual(client.calls, 1) # only 1 generated; rest not submitted
self.assertIn("已停止", result.error)
self.assertIn("2 张未生成", result.error)
self.assertEqual(len(result.output_paths), 1)
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()