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`
|
- [x] 编写 `tests/test_composer.py`
|
||||||
- [ ] 编写 `tests/test_templates.py`
|
- [x] 编写 `tests/test_templates.py`
|
||||||
- [ ] 测试透明 PNG 合成
|
- [x] 测试透明 PNG 合成
|
||||||
- [ ] 测试缩放参数
|
- [x] 测试缩放参数
|
||||||
- [ ] 测试旋转中心
|
- [x] 测试旋转中心
|
||||||
- [ ] 测试文件扫描
|
- [x] 测试文件扫描
|
||||||
- [ ] 测试模板读写
|
- [x] 测试模板读写
|
||||||
- [ ] 测试批量任务生成
|
- [x] 测试批量任务生成
|
||||||
|
|
||||||
验收:
|
验收:
|
||||||
|
|
||||||
- [ ] 核心逻辑测试不依赖 GUI
|
- [x] 核心逻辑测试不依赖 GUI
|
||||||
- [ ] 测试可在 Python 3.7 环境运行
|
- [x] 测试可在 Python 3.7 环境运行
|
||||||
|
|
||||||
## 16. 打包
|
## 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