Files
cmbot/tests/test_templates.py
T

375 lines
14 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""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()