Files
cmbot/tests/test_templates.py
T

417 lines
17 KiB
Python
Raw Normal View History

"""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)
def test_aspect_fit_square_box_wide_print(self):
"""A wide print in a square box fits by width and stays undistorted."""
from core.models import Template
# Box: 200x200 at (100,100) on a 400x400 garment
t = Template(name="t", x_ratio=0.25, y_ratio=0.25,
width_ratio=0.5, height_ratio=0.5)
state = t.to_transform_state(400, 400, print_width=100, print_height=50)
# Wide print (2:1) → limited by width: 200 wide, 100 tall
self.assertAlmostEqual(state.width, 200.0)
self.assertAlmostEqual(state.height, 100.0)
# Aspect ratio preserved
self.assertAlmostEqual(state.width / state.height, 100 / 50)
# Centered in the box (centre at 200,200)
self.assertAlmostEqual(state.x + state.width / 2, 200.0)
self.assertAlmostEqual(state.y + state.height / 2, 200.0)
def test_aspect_fit_square_box_tall_print(self):
"""A tall print in a square box fits by height and stays undistorted."""
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)
state = t.to_transform_state(400, 400, print_width=50, print_height=100)
# Tall print (1:2) → limited by height: 100 wide, 200 tall
self.assertAlmostEqual(state.width, 100.0)
self.assertAlmostEqual(state.height, 200.0)
self.assertAlmostEqual(state.x + state.width / 2, 200.0)
self.assertAlmostEqual(state.y + state.height / 2, 200.0)
def test_aspect_fit_never_exceeds_box(self):
"""Fitted print must never exceed the template box bounds."""
from core.models import Template
t = Template(name="t", x_ratio=0.34, y_ratio=0.22,
width_ratio=0.32, height_ratio=0.32)
# Portrait garment, square print
state = t.to_transform_state(800, 1200, print_width=300, print_height=300)
box_w = 0.32 * 800
box_h = 0.32 * 1200
self.assertLessEqual(state.width, box_w + 1e-6)
self.assertLessEqual(state.height, box_h + 1e-6)
# Square print stays square (no stretch despite portrait garment)
self.assertAlmostEqual(state.width, state.height)
# ---------------------------------------------------------------------------
# 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()