2026-06-16 09:38:06 +08:00
|
|
|
|
"""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"))
|
|
|
|
|
|
|
2026-06-17 16:42:20 +08:00
|
|
|
|
from core.composer import compose, resolve_transform
|
|
|
|
|
|
from core.models import ExportOptions, Template, TransformState
|
2026-06-16 09:38:06 +08:00
|
|
|
|
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
# 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)
|
|
|
|
|
|
|
|
|
|
|
|
|
2026-06-17 16:42:20 +08:00
|
|
|
|
class TestResolveTransform(unittest.TestCase):
|
|
|
|
|
|
"""resolve_transform: per-item placement for batch export."""
|
|
|
|
|
|
|
|
|
|
|
|
def _square_template(self):
|
|
|
|
|
|
# 25% box centred at (0.40,0.40)-(0.65,0.65); square print fills the box.
|
|
|
|
|
|
return Template(name="sq", x_ratio=0.40, y_ratio=0.40,
|
|
|
|
|
|
width_ratio=0.25, height_ratio=0.25)
|
|
|
|
|
|
|
|
|
|
|
|
def test_fine_tuned_transform_wins(self):
|
|
|
|
|
|
own = TransformState(x=1, y=2, width=3, height=4)
|
|
|
|
|
|
out = resolve_transform(self._square_template(), own, (1000, 1000), (50, 50))
|
|
|
|
|
|
self.assertIs(out, own)
|
|
|
|
|
|
|
|
|
|
|
|
def test_differently_sized_garments_get_different_pixels(self):
|
|
|
|
|
|
"""The core bug: one shared pixel transform mis-places other sizes.
|
|
|
|
|
|
|
|
|
|
|
|
Each garment must be laid out from its own dimensions."""
|
|
|
|
|
|
tpl = self._square_template()
|
|
|
|
|
|
small = resolve_transform(tpl, None, (1000, 1000), (100, 100))
|
|
|
|
|
|
large = resolve_transform(tpl, None, (2000, 3000), (100, 100))
|
|
|
|
|
|
|
|
|
|
|
|
# 1000px garment: 25% box = 250px, square print fills it, centred at 0.525.
|
|
|
|
|
|
self.assertAlmostEqual(small.width, 250.0)
|
|
|
|
|
|
self.assertAlmostEqual(small.x, 400.0)
|
|
|
|
|
|
# 2000x3000 garment: box is 500x750, square print contain-fits to 500,
|
|
|
|
|
|
# and x stays proportional — proving per-item recomputation.
|
|
|
|
|
|
self.assertAlmostEqual(large.width, 500.0)
|
|
|
|
|
|
self.assertAlmostEqual(large.x, 800.0)
|
|
|
|
|
|
self.assertNotAlmostEqual(small.x, large.x)
|
|
|
|
|
|
|
|
|
|
|
|
def test_none_template_returns_none(self):
|
|
|
|
|
|
self.assertIsNone(resolve_transform(None, None, (100, 100), (10, 10)))
|
|
|
|
|
|
|
|
|
|
|
|
def test_missing_sizes_return_none(self):
|
|
|
|
|
|
tpl = self._square_template()
|
|
|
|
|
|
self.assertIsNone(resolve_transform(tpl, None, None, (10, 10)))
|
|
|
|
|
|
self.assertIsNone(resolve_transform(tpl, None, (100, 100), None))
|
|
|
|
|
|
self.assertIsNone(resolve_transform(tpl, None, (0, 0), (10, 10)))
|
|
|
|
|
|
|
|
|
|
|
|
|
2026-06-16 09:38:06 +08:00
|
|
|
|
if __name__ == "__main__":
|
|
|
|
|
|
unittest.main()
|