Files
cmbot/tests/test_templates.py
T
adminandClaude Opus 4.8 8a34fe93ff feat: thumbnail grid, aspect-aware templates, and UI hierarchy polish
Material list:
- Switch asset list from one-per-row to a wrapping thumbnail grid
  (IconMode cards: thumbnail + corner checkbox + elided name, subfolder
  moved to tooltip).

Templates:
- Template.to_transform_state now contains-fits the print inside the
  target box by the print's native aspect ratio (no stretch); falls back
  to the box when print size is unknown.
- Feed print native size into template_panel for aspect-aware fitting.
- Shrink built-in template boxes to realistic print proportions.

UI hierarchy:
- Promote 打开文件夹 buttons to primary (filled accent).
- Add inline 归零 (reset angle) and a 重置位置/尺寸/角度 button to the
  transform panel; wire it to re-apply the selected template.
- Hide 保存 for built-in templates instead of leaving it greyed.

Docs: record template box/contain behavior (PRD) and the grid layout,
button hierarchy, and reset/save-visibility rules (UI design).
Tests: add 3 aspect-fit cases for to_transform_state.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-16 15:11:56 +08:00

417 lines
17 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)
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()