test: add composer and template test suites (43 tests, all passing)
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -473,19 +473,19 @@
|
||||
|
||||
任务:
|
||||
|
||||
- [ ] 编写 `tests/test_composer.py`
|
||||
- [ ] 编写 `tests/test_templates.py`
|
||||
- [ ] 测试透明 PNG 合成
|
||||
- [ ] 测试缩放参数
|
||||
- [ ] 测试旋转中心
|
||||
- [ ] 测试文件扫描
|
||||
- [ ] 测试模板读写
|
||||
- [ ] 测试批量任务生成
|
||||
- [x] 编写 `tests/test_composer.py`
|
||||
- [x] 编写 `tests/test_templates.py`
|
||||
- [x] 测试透明 PNG 合成
|
||||
- [x] 测试缩放参数
|
||||
- [x] 测试旋转中心
|
||||
- [x] 测试文件扫描
|
||||
- [x] 测试模板读写
|
||||
- [x] 测试批量任务生成
|
||||
|
||||
验收:
|
||||
|
||||
- [ ] 核心逻辑测试不依赖 GUI
|
||||
- [ ] 测试可在 Python 3.7 环境运行
|
||||
- [x] 核心逻辑测试不依赖 GUI
|
||||
- [x] 测试可在 Python 3.7 环境运行
|
||||
|
||||
## 16. 打包
|
||||
|
||||
|
||||
@@ -0,0 +1,211 @@
|
||||
"""Tests for core.composer — no GUI dependency."""
|
||||
import shutil
|
||||
import sys
|
||||
import tempfile
|
||||
import unittest
|
||||
from pathlib import Path
|
||||
|
||||
from PIL import Image
|
||||
|
||||
sys.path.insert(0, str(Path(__file__).parent.parent / "src"))
|
||||
|
||||
from core.composer import compose
|
||||
from core.models import ExportOptions, TransformState
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# helpers
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def _rgba(w, h, color=(200, 200, 200, 255)):
|
||||
return Image.new("RGBA", (w, h), color)
|
||||
|
||||
|
||||
class _TmpDir:
|
||||
def setUp(self):
|
||||
self.tmp = Path(tempfile.mkdtemp())
|
||||
|
||||
def tearDown(self):
|
||||
shutil.rmtree(str(self.tmp), ignore_errors=True)
|
||||
|
||||
def _save(self, img: Image.Image, name: str) -> Path:
|
||||
p = self.tmp / name
|
||||
img.save(str(p))
|
||||
return p
|
||||
|
||||
def _compose(self, garment, print_img, state, fmt="PNG", quality=90):
|
||||
ext = ".jpg" if fmt.upper() in ("JPG", "JPEG") else ".png"
|
||||
g = self._save(garment, "garment.png") # garment always PNG
|
||||
p = self._save(print_img, "print.png")
|
||||
out = self.tmp / ("out" + ext)
|
||||
opts = ExportOptions(output_format=fmt, quality=quality)
|
||||
return compose(g, p, state, opts, out), out
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Transparency tests
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
class TestTransparency(_TmpDir, unittest.TestCase):
|
||||
def test_fully_transparent_print_leaves_garment_unchanged(self):
|
||||
"""An all-alpha-0 print must not modify the garment pixels."""
|
||||
garment = _rgba(100, 100, (200, 200, 200, 255))
|
||||
print_img = Image.new("RGBA", (40, 40), (255, 0, 0, 0)) # fully transparent
|
||||
|
||||
state = TransformState(x=30, y=30, width=40, height=40)
|
||||
result, out_path = self._compose(garment, print_img, state)
|
||||
|
||||
self.assertTrue(result.success, result.error)
|
||||
out = Image.open(str(out_path)).convert("RGBA")
|
||||
r, g, b, _ = out.getpixel((50, 50))
|
||||
self.assertEqual((r, g, b), (200, 200, 200))
|
||||
|
||||
def test_opaque_print_overwrites_garment_pixels(self):
|
||||
"""Fully opaque blue print should cover garment in the print area."""
|
||||
garment = _rgba(100, 100, (200, 200, 200, 255))
|
||||
print_img = _rgba(20, 20, (0, 0, 255, 255))
|
||||
|
||||
state = TransformState(x=40, y=40, width=20, height=20)
|
||||
result, out_path = self._compose(garment, print_img, state)
|
||||
|
||||
self.assertTrue(result.success, result.error)
|
||||
out = Image.open(str(out_path)).convert("RGBA")
|
||||
r, g, b, _ = out.getpixel((50, 50)) # center of print area
|
||||
self.assertEqual((r, g, b), (0, 0, 255))
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Scaling tests
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
class TestScaling(_TmpDir, unittest.TestCase):
|
||||
def test_print_is_scaled_to_state_dimensions(self):
|
||||
"""A 10×10 print scaled to 60×80 should fill that region."""
|
||||
garment = _rgba(200, 200)
|
||||
print_img = _rgba(10, 10, (255, 0, 0, 255))
|
||||
|
||||
state = TransformState(x=70, y=60, width=60, height=80)
|
||||
result, out_path = self._compose(garment, print_img, state)
|
||||
|
||||
self.assertTrue(result.success, result.error)
|
||||
out = Image.open(str(out_path)).convert("RGBA")
|
||||
# Inside print area → red
|
||||
r, g, b, _ = out.getpixel((100, 100))
|
||||
self.assertEqual((r, g, b), (255, 0, 0))
|
||||
# Outside print area → garment gray
|
||||
r2, g2, b2, _ = out.getpixel((10, 10))
|
||||
self.assertEqual((r2, g2, b2), (200, 200, 200))
|
||||
|
||||
def test_output_dimensions_match_garment(self):
|
||||
"""Output size must always equal the garment size, regardless of print size."""
|
||||
gw, gh = 320, 480
|
||||
garment = _rgba(gw, gh)
|
||||
print_img = _rgba(10, 10, (0, 255, 0, 255))
|
||||
|
||||
state = TransformState(x=100, y=100, width=80, height=80)
|
||||
result, out_path = self._compose(garment, print_img, state)
|
||||
|
||||
self.assertTrue(result.success, result.error)
|
||||
out = Image.open(str(out_path))
|
||||
self.assertEqual(out.size, (gw, gh))
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Rotation centre tests
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
class TestRotation(_TmpDir, unittest.TestCase):
|
||||
def test_rotation_centre_fixed_for_square(self):
|
||||
"""After 90° CW rotation of a square print, its centre stays at (cx, cy)."""
|
||||
garment = _rgba(200, 200)
|
||||
print_img = _rgba(20, 20, (255, 0, 0, 255))
|
||||
|
||||
# Centre = (90+10, 90+10) = (100, 100)
|
||||
state = TransformState(x=90, y=90, width=20, height=20, rotation=90)
|
||||
result, out_path = self._compose(garment, print_img, state)
|
||||
|
||||
self.assertTrue(result.success, result.error)
|
||||
out = Image.open(str(out_path)).convert("RGBA")
|
||||
r, g, b, _ = out.getpixel((100, 100))
|
||||
self.assertEqual((r, g, b), (255, 0, 0), "Centre pixel must be from the print")
|
||||
|
||||
def test_zero_rotation_same_as_no_rotation(self):
|
||||
"""rotation=0 should produce the same result as not specifying rotation."""
|
||||
garment = _rgba(100, 100)
|
||||
print_img = _rgba(20, 20, (0, 0, 255, 255))
|
||||
|
||||
state = TransformState(x=40, y=40, width=20, height=20, rotation=0)
|
||||
result, out_path = self._compose(garment, print_img, state)
|
||||
|
||||
self.assertTrue(result.success, result.error)
|
||||
out = Image.open(str(out_path)).convert("RGBA")
|
||||
r, g, b, _ = out.getpixel((50, 50))
|
||||
self.assertEqual((r, g, b), (0, 0, 255))
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Edge cases
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
class TestEdgeCases(_TmpDir, unittest.TestCase):
|
||||
def test_print_partially_outside_canvas_no_error(self):
|
||||
"""Print whose bounding box extends beyond the canvas should not raise."""
|
||||
garment = _rgba(50, 50)
|
||||
print_img = _rgba(20, 20, (255, 0, 0, 255))
|
||||
|
||||
state = TransformState(x=45, y=45, width=20, height=20)
|
||||
result, _ = self._compose(garment, print_img, state)
|
||||
self.assertTrue(result.success, result.error)
|
||||
|
||||
def test_invalid_zero_dimensions_returns_failure(self):
|
||||
"""width=0 or height=0 must return a ComposeResult with success=False."""
|
||||
garment = _rgba(100, 100)
|
||||
print_img = _rgba(10, 10)
|
||||
|
||||
g = self._save(garment, "g.png")
|
||||
p = self._save(print_img, "p.png")
|
||||
out = self.tmp / "out.png"
|
||||
|
||||
state = TransformState(x=0, y=0, width=0, height=0)
|
||||
result = compose(g, p, state, ExportOptions(), out)
|
||||
|
||||
self.assertFalse(result.success)
|
||||
self.assertTrue(result.error)
|
||||
|
||||
def test_missing_garment_file_returns_failure(self):
|
||||
"""Non-existent garment path must return failure without raising."""
|
||||
p = self._save(_rgba(10, 10), "p.png")
|
||||
out = self.tmp / "out.png"
|
||||
|
||||
state = TransformState(x=0, y=0, width=10, height=10)
|
||||
result = compose(self.tmp / "no_such_file.png", p, state, ExportOptions(), out)
|
||||
|
||||
self.assertFalse(result.success)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# JPG output
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
class TestJpgOutput(_TmpDir, unittest.TestCase):
|
||||
def test_jpg_export_creates_file(self):
|
||||
garment = _rgba(100, 100)
|
||||
print_img = _rgba(30, 30, (0, 200, 0, 255))
|
||||
|
||||
state = TransformState(x=35, y=35, width=30, height=30)
|
||||
result, out_path = self._compose(garment, print_img, state, fmt="JPG")
|
||||
|
||||
self.assertTrue(result.success, result.error)
|
||||
self.assertTrue(out_path.exists())
|
||||
|
||||
def test_jpg_export_no_alpha_error(self):
|
||||
"""JPG export must not raise due to alpha channel handling."""
|
||||
garment = _rgba(80, 80, (255, 255, 255, 255))
|
||||
print_img = Image.new("RGBA", (20, 20), (255, 0, 0, 128)) # semi-transparent
|
||||
|
||||
state = TransformState(x=30, y=30, width=20, height=20)
|
||||
result, _ = self._compose(garment, print_img, state, fmt="JPG")
|
||||
self.assertTrue(result.success, result.error)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -0,0 +1,374 @@
|
||||
"""Tests for template service, file scanning, and batch pair generation.
|
||||
No GUI dependency — runs in pure Python 3.7.
|
||||
"""
|
||||
import json
|
||||
import shutil
|
||||
import sys
|
||||
import tempfile
|
||||
import unittest
|
||||
from pathlib import Path
|
||||
|
||||
sys.path.insert(0, str(Path(__file__).parent.parent / "src"))
|
||||
|
||||
import services.file_service as _file_service
|
||||
|
||||
|
||||
def _patch_config_dir(tmpdir):
|
||||
"""Return a context-manager that redirects get_config_path to tmpdir."""
|
||||
class _Ctx:
|
||||
def __enter__(self):
|
||||
self._orig = _file_service.get_config_path
|
||||
_file_service.get_config_path = lambda name: Path(tmpdir) / name
|
||||
return self
|
||||
|
||||
def __exit__(self, *_):
|
||||
_file_service.get_config_path = self._orig
|
||||
|
||||
return _Ctx()
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Template service
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
class TestBuiltinTemplates(unittest.TestCase):
|
||||
def test_builtins_are_present(self):
|
||||
from services.template_service import get_builtin_templates
|
||||
builtins = get_builtin_templates()
|
||||
self.assertGreater(len(builtins), 0)
|
||||
|
||||
def test_builtins_marked_as_builtin(self):
|
||||
from services.template_service import get_builtin_templates
|
||||
for t in get_builtin_templates():
|
||||
self.assertEqual(t.type, "builtin")
|
||||
|
||||
def test_builtins_have_valid_ratios(self):
|
||||
from services.template_service import get_builtin_templates
|
||||
for t in get_builtin_templates():
|
||||
self.assertGreater(t.width_ratio, 0)
|
||||
self.assertGreater(t.height_ratio, 0)
|
||||
|
||||
|
||||
class TestCustomTemplates(unittest.TestCase):
|
||||
def setUp(self):
|
||||
self.tmp = Path(tempfile.mkdtemp())
|
||||
self._ctx = _patch_config_dir(self.tmp)
|
||||
self._ctx.__enter__()
|
||||
|
||||
def tearDown(self):
|
||||
self._ctx.__exit__(None, None, None)
|
||||
shutil.rmtree(str(self.tmp), ignore_errors=True)
|
||||
|
||||
def _make_template(self, name, **kwargs):
|
||||
from core.models import Template
|
||||
defaults = dict(x_ratio=0.1, y_ratio=0.1, width_ratio=0.3, height_ratio=0.3,
|
||||
rotation=0.0, type="custom")
|
||||
defaults.update(kwargs)
|
||||
return Template(name=name, **defaults)
|
||||
|
||||
def test_missing_file_returns_empty(self):
|
||||
from services.template_service import load_custom_templates
|
||||
self.assertEqual(load_custom_templates(), [])
|
||||
|
||||
def test_add_and_reload(self):
|
||||
from services.template_service import add_template, load_custom_templates
|
||||
t = self._make_template("模板A", x_ratio=0.2, y_ratio=0.3,
|
||||
width_ratio=0.4, height_ratio=0.4, rotation=15.0)
|
||||
add_template(t)
|
||||
|
||||
loaded = load_custom_templates()
|
||||
self.assertEqual(len(loaded), 1)
|
||||
lt = loaded[0]
|
||||
self.assertEqual(lt.name, "模板A")
|
||||
self.assertAlmostEqual(lt.x_ratio, 0.2)
|
||||
self.assertAlmostEqual(lt.y_ratio, 0.3)
|
||||
self.assertAlmostEqual(lt.rotation, 15.0)
|
||||
self.assertEqual(lt.type, "custom")
|
||||
|
||||
def test_add_overwrites_same_name(self):
|
||||
from services.template_service import add_template, load_custom_templates
|
||||
add_template(self._make_template("重名", x_ratio=0.1))
|
||||
add_template(self._make_template("重名", x_ratio=0.9))
|
||||
|
||||
loaded = load_custom_templates()
|
||||
self.assertEqual(len(loaded), 1)
|
||||
self.assertAlmostEqual(loaded[0].x_ratio, 0.9)
|
||||
|
||||
def test_get_all_templates_order(self):
|
||||
from services.template_service import add_template, get_all_templates, get_builtin_templates
|
||||
builtin_count = len(get_builtin_templates())
|
||||
add_template(self._make_template("自定义"))
|
||||
|
||||
all_t = get_all_templates()
|
||||
self.assertEqual(len(all_t), builtin_count + 1)
|
||||
for t in all_t[:builtin_count]:
|
||||
self.assertEqual(t.type, "builtin")
|
||||
self.assertEqual(all_t[-1].type, "custom")
|
||||
|
||||
def test_rename_template(self):
|
||||
from services.template_service import add_template, rename_template, load_custom_templates
|
||||
add_template(self._make_template("旧名"))
|
||||
rename_template("旧名", "新名")
|
||||
loaded = load_custom_templates()
|
||||
self.assertEqual(loaded[0].name, "新名")
|
||||
|
||||
def test_rename_nonexistent_raises_value_error(self):
|
||||
from services.template_service import rename_template
|
||||
with self.assertRaises(ValueError):
|
||||
rename_template("不存在", "随便")
|
||||
|
||||
def test_rename_empty_name_raises_value_error(self):
|
||||
from services.template_service import add_template, rename_template
|
||||
add_template(self._make_template("有名"))
|
||||
with self.assertRaises(ValueError):
|
||||
rename_template("有名", " ")
|
||||
|
||||
def test_delete_template(self):
|
||||
from services.template_service import add_template, delete_template, load_custom_templates
|
||||
add_template(self._make_template("删我"))
|
||||
delete_template("删我")
|
||||
self.assertEqual(load_custom_templates(), [])
|
||||
|
||||
def test_delete_nonexistent_raises_value_error(self):
|
||||
from services.template_service import delete_template
|
||||
with self.assertRaises(ValueError):
|
||||
delete_template("不存在")
|
||||
|
||||
def test_corrupt_entry_is_skipped_others_load(self):
|
||||
"""A malformed entry in templates.json must not block valid entries."""
|
||||
templates_file = self.tmp / "templates.json"
|
||||
payload = {
|
||||
"templates": [
|
||||
{"name": "好的", "x_ratio": 0.1, "y_ratio": 0.1,
|
||||
"width_ratio": 0.3, "height_ratio": 0.3},
|
||||
{"name": "坏的"}, # missing width_ratio / height_ratio
|
||||
{"name": ""}, # empty name
|
||||
{"name": "零比例", "x_ratio": 0.0, "y_ratio": 0.0,
|
||||
"width_ratio": 0.0, "height_ratio": 0.0}, # zero ratios
|
||||
]
|
||||
}
|
||||
templates_file.parent.mkdir(parents=True, exist_ok=True)
|
||||
templates_file.write_text(json.dumps(payload), encoding="utf-8")
|
||||
|
||||
from services.template_service import load_custom_templates
|
||||
loaded = load_custom_templates()
|
||||
self.assertEqual(len(loaded), 1)
|
||||
self.assertEqual(loaded[0].name, "好的")
|
||||
|
||||
def test_corrupt_json_returns_empty(self):
|
||||
"""Completely invalid JSON in templates.json should return empty list."""
|
||||
templates_file = self.tmp / "templates.json"
|
||||
templates_file.parent.mkdir(parents=True, exist_ok=True)
|
||||
templates_file.write_text("{not valid json", encoding="utf-8")
|
||||
|
||||
from services.template_service import load_custom_templates
|
||||
self.assertEqual(load_custom_templates(), [])
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Template model: to_transform_state
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
class TestTemplateToTransformState(unittest.TestCase):
|
||||
def test_basic_conversion(self):
|
||||
from core.models import Template
|
||||
t = Template(
|
||||
name="t", x_ratio=0.25, y_ratio=0.25,
|
||||
width_ratio=0.5, height_ratio=0.5, rotation=30.0,
|
||||
)
|
||||
state = t.to_transform_state(400, 600)
|
||||
self.assertAlmostEqual(state.x, 100.0)
|
||||
self.assertAlmostEqual(state.y, 150.0)
|
||||
self.assertAlmostEqual(state.width, 200.0)
|
||||
self.assertAlmostEqual(state.height, 300.0)
|
||||
self.assertAlmostEqual(state.rotation, 30.0)
|
||||
|
||||
def test_zero_rotation(self):
|
||||
from core.models import Template
|
||||
t = Template(name="t", x_ratio=0.0, y_ratio=0.0,
|
||||
width_ratio=1.0, height_ratio=1.0, rotation=0.0)
|
||||
state = t.to_transform_state(100, 200)
|
||||
self.assertAlmostEqual(state.rotation, 0.0)
|
||||
self.assertAlmostEqual(state.width, 100.0)
|
||||
self.assertAlmostEqual(state.height, 200.0)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# File scanning
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
class TestFileScan(unittest.TestCase):
|
||||
def setUp(self):
|
||||
self.tmp = Path(tempfile.mkdtemp())
|
||||
|
||||
def tearDown(self):
|
||||
shutil.rmtree(str(self.tmp), ignore_errors=True)
|
||||
|
||||
def _png(self, path: Path):
|
||||
from PIL import Image
|
||||
path.parent.mkdir(parents=True, exist_ok=True)
|
||||
Image.new("RGB", (1, 1)).save(str(path), "PNG")
|
||||
|
||||
def test_scans_flat_folder(self):
|
||||
from services.file_service import scan_image_folder
|
||||
self._png(self.tmp / "a.png")
|
||||
self._png(self.tmp / "b.png")
|
||||
assets = scan_image_folder(self.tmp)
|
||||
self.assertEqual(len(assets), 2)
|
||||
|
||||
def test_recurses_into_subfolders(self):
|
||||
from services.file_service import scan_image_folder
|
||||
self._png(self.tmp / "root.png")
|
||||
self._png(self.tmp / "sub" / "child.png")
|
||||
self._png(self.tmp / "sub" / "deep" / "deeper.png")
|
||||
assets = scan_image_folder(self.tmp)
|
||||
self.assertEqual(len(assets), 3)
|
||||
|
||||
def test_skips_unsupported_formats(self):
|
||||
from services.file_service import scan_image_folder
|
||||
self._png(self.tmp / "ok.png")
|
||||
(self.tmp / "ignored.txt").write_text("text")
|
||||
(self.tmp / "ignored.pdf").write_bytes(b"%PDF")
|
||||
assets = scan_image_folder(self.tmp)
|
||||
self.assertEqual(len(assets), 1)
|
||||
|
||||
def test_supports_jpg_and_webp_extensions(self):
|
||||
from services.file_service import scan_image_folder
|
||||
from PIL import Image
|
||||
Image.new("RGB", (1, 1)).save(str(self.tmp / "img.jpg"), "JPEG")
|
||||
Image.new("RGB", (1, 1)).save(str(self.tmp / "img.jpeg"), "JPEG")
|
||||
# .webp: just check extension recognition; create a renamed png
|
||||
self._png(self.tmp / "img.png")
|
||||
assets = scan_image_folder(self.tmp)
|
||||
self.assertEqual(len(assets), 3)
|
||||
|
||||
def test_assets_selected_by_default(self):
|
||||
from services.file_service import scan_image_folder
|
||||
self._png(self.tmp / "img.png")
|
||||
assets = scan_image_folder(self.tmp)
|
||||
self.assertTrue(assets[0].selected)
|
||||
|
||||
def test_nonexistent_dir_raises_oserror(self):
|
||||
from services.file_service import scan_image_folder
|
||||
with self.assertRaises(OSError):
|
||||
scan_image_folder(self.tmp / "nonexistent")
|
||||
|
||||
def test_file_path_raises_oserror(self):
|
||||
from services.file_service import scan_image_folder
|
||||
f = self.tmp / "f.png"
|
||||
self._png(f)
|
||||
with self.assertRaises(OSError):
|
||||
scan_image_folder(f) # path is a file, not a directory
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Batch pair generation (core.batch._build_pairs)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
class TestBatchPairs(unittest.TestCase):
|
||||
def setUp(self):
|
||||
from core.models import BatchMode
|
||||
from core.batch import _build_pairs
|
||||
self.build = _build_pairs
|
||||
self.Mode = BatchMode
|
||||
self.paths = lambda *names: [Path(n) for n in names]
|
||||
|
||||
def test_full_combo_all_combinations(self):
|
||||
g = self.paths("g1", "g2")
|
||||
p = self.paths("p1", "p2", "p3")
|
||||
pairs = self.build(g, p, self.Mode.FULL_COMBO)
|
||||
self.assertEqual(len(pairs), 6) # 2 × 3
|
||||
|
||||
def test_full_combo_order(self):
|
||||
g = self.paths("g1", "g2")
|
||||
p = self.paths("p1", "p2")
|
||||
pairs = self.build(g, p, self.Mode.FULL_COMBO)
|
||||
expected = [(Path("g1"), Path("p1")), (Path("g1"), Path("p2")),
|
||||
(Path("g2"), Path("p1")), (Path("g2"), Path("p2"))]
|
||||
self.assertEqual(pairs, expected)
|
||||
|
||||
def test_many_garments_uses_first_print(self):
|
||||
g = self.paths("g1", "g2", "g3")
|
||||
p = self.paths("p1", "p2")
|
||||
pairs = self.build(g, p, self.Mode.MANY_GARMENTS)
|
||||
self.assertEqual(len(pairs), 3)
|
||||
self.assertTrue(all(pr[1] == Path("p1") for pr in pairs))
|
||||
|
||||
def test_many_garments_empty_prints_returns_empty(self):
|
||||
g = self.paths("g1", "g2")
|
||||
pairs = self.build(g, [], self.Mode.MANY_GARMENTS)
|
||||
self.assertEqual(pairs, [])
|
||||
|
||||
def test_many_prints_uses_first_garment(self):
|
||||
g = self.paths("g1", "g2")
|
||||
p = self.paths("p1", "p2", "p3")
|
||||
pairs = self.build(g, p, self.Mode.MANY_PRINTS)
|
||||
self.assertEqual(len(pairs), 3)
|
||||
self.assertTrue(all(pr[0] == Path("g1") for pr in pairs))
|
||||
|
||||
def test_many_prints_empty_garments_returns_empty(self):
|
||||
p = self.paths("p1", "p2")
|
||||
pairs = self.build([], p, self.Mode.MANY_PRINTS)
|
||||
self.assertEqual(pairs, [])
|
||||
|
||||
def test_one_to_one_zips_lists(self):
|
||||
g = self.paths("g1", "g2", "g3")
|
||||
p = self.paths("p1", "p2")
|
||||
pairs = self.build(g, p, self.Mode.ONE_TO_ONE)
|
||||
self.assertEqual(len(pairs), 2) # zip stops at shorter
|
||||
self.assertEqual(pairs[0], (Path("g1"), Path("p1")))
|
||||
self.assertEqual(pairs[1], (Path("g2"), Path("p2")))
|
||||
|
||||
def test_empty_inputs_return_empty(self):
|
||||
pairs = self.build([], [], self.Mode.FULL_COMBO)
|
||||
self.assertEqual(pairs, [])
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Batch result summary (run_batch, single-failure resilience)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
class TestBatchRunSingleFailure(unittest.TestCase):
|
||||
"""Verify that run_batch continues even when one item fails."""
|
||||
|
||||
def setUp(self):
|
||||
self.tmp = Path(tempfile.mkdtemp())
|
||||
|
||||
def tearDown(self):
|
||||
shutil.rmtree(str(self.tmp), ignore_errors=True)
|
||||
|
||||
def _png(self, name, color=(200, 200, 200)):
|
||||
from PIL import Image
|
||||
p = self.tmp / name
|
||||
Image.new("RGB", (50, 50), color).save(str(p), "PNG")
|
||||
return p
|
||||
|
||||
def test_single_bad_file_does_not_stop_batch(self):
|
||||
from core.batch import run_batch
|
||||
from core.models import BatchMode, BatchOptions, ExportOptions, TransformState
|
||||
|
||||
good1 = self._png("g1.png", (200, 200, 200))
|
||||
good2 = self._png("g2.png", (100, 100, 100))
|
||||
print_img = self._png("p.png", (255, 0, 0))
|
||||
bad_file = self.tmp / "nonexistent.png"
|
||||
|
||||
garments = [good1, bad_file, good2]
|
||||
prints = [print_img]
|
||||
transform = TransformState(x=10, y=10, width=20, height=20)
|
||||
opts = BatchOptions(
|
||||
mode=BatchMode.MANY_GARMENTS,
|
||||
export_options=ExportOptions(
|
||||
output_dir=str(self.tmp / "out"),
|
||||
output_format="PNG",
|
||||
),
|
||||
)
|
||||
result = run_batch(garments, prints, transform, opts)
|
||||
|
||||
self.assertEqual(result.total, 3)
|
||||
self.assertEqual(result.success_count, 2)
|
||||
self.assertEqual(result.failure_count, 1)
|
||||
self.assertFalse(result.results[1].success) # bad_file entry failed
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
Reference in New Issue
Block a user