From ce8cc007cb041ad89c34072811d9cc0344f6bdb7 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Bj=C3=B6rn=20Strausmann?= Date: Mon, 11 May 2026 10:51:20 +0000 Subject: [PATCH 1/3] feat(label-renderer): Template schema + Pillow/qrcode renderer for 1-bit label bitmaps MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Implements Tasks 4.1 and 4.2 of Phase 4 (PR D1). - TemplateSchema + LayoutElement: frozen Pydantic models with per-type model_validator enforcing required fields (qr→data_field+size, text→field+font_size). App constrained to Literal["snipeit","grocy","spoolman"]. - LabelRenderer.render(): produces a 1-bit PIL Image sized by TAPE_HEIGHT_PX (300 DPI, Brother Raster Command Reference v1.02). Raises ValueError on unknown tape_mm. Stateless — safe for concurrent use. - _resolve_field: joins tuple/list secondary fields with ' | ' separator, returns "" for unknown fields (no crash on template/data mismatch). - _load_font: tries DejaVuSans.ttf, falls back to PIL bitmap default. - 13 new tests (7 schema + 6 renderer); total suite: 124 passed. Co-Authored-By: Claude Sonnet 4.6 --- backend/app/schemas/template.py | 62 +++++++++ backend/app/services/label_renderer.py | 112 ++++++++++++++++ .../unit/schemas/test_template_schema.py | 59 ++++++++ .../unit/services/test_label_renderer.py | 126 ++++++++++++++++++ 4 files changed, 359 insertions(+) create mode 100644 backend/app/schemas/template.py create mode 100644 backend/app/services/label_renderer.py create mode 100644 backend/tests/unit/schemas/test_template_schema.py create mode 100644 backend/tests/unit/services/test_label_renderer.py diff --git a/backend/app/schemas/template.py b/backend/app/schemas/template.py new file mode 100644 index 0000000..a67fa74 --- /dev/null +++ b/backend/app/schemas/template.py @@ -0,0 +1,62 @@ +"""Label-template schema describing the layout of a printable label. + +A `TemplateSchema` is a recipe for placing QR codes and text on the +printable area of a Brother tape. The renderer consumes a template plus a +`LabelData` payload and emits a 1-bit PIL Image ready for the printer. + +Templates are frozen at construction so they can be safely seeded as +module-level constants (see app/seed/templates.py in PR D2). +""" + +from __future__ import annotations + +from typing import Literal + +from pydantic import BaseModel, ConfigDict, model_validator + + +class LayoutElement(BaseModel): + """A single drawable element — either a QR code or a text run. + + The `type` field discriminates which subset of the optional fields + is required. `model_validator(mode="after")` enforces the contract + at construction time so the renderer can trust the shape. + """ + + model_config = ConfigDict(frozen=True) + + type: Literal["qr", "text"] + x: int + y: int + # qr-specific + size: int | None = None + data_field: str | None = None + # text-specific + field: str | None = None + font_size: int | None = None + + @model_validator(mode="after") + def _validate_per_type(self) -> LayoutElement: + if self.type == "qr": + if not self.data_field: + raise ValueError("qr element requires data_field") + if not self.size: + raise ValueError("qr element requires size") + else: # type == "text" + if not self.field: + raise ValueError("text element requires field") + if not self.font_size: + raise ValueError("text element requires font_size") + return self + + +class TemplateSchema(BaseModel): + """A complete label template — identity, target app, tape size, and layout.""" + + model_config = ConfigDict(frozen=True) + + id: str + name: str + app: Literal["snipeit", "grocy", "spoolman"] + tape_mm: int + elements: list[LayoutElement] diff --git a/backend/app/services/label_renderer.py b/backend/app/services/label_renderer.py new file mode 100644 index 0000000..d8bb56d --- /dev/null +++ b/backend/app/services/label_renderer.py @@ -0,0 +1,112 @@ +"""Compose a 1-bit PIL Image from a TemplateSchema + LabelData. + +The renderer is stateless — one instance can serve concurrent requests. +It does not know the printer or the queue; it only produces the bitmap. +The printer-backend plug-in (Phase 2 hardware tasks) converts the bitmap +to raster bytes for the specific Brother model. + +Coordinate system: top-left origin, pixels at 300 DPI (brother_ql native). +The print area is constrained by Brother's per-tape geometry tables — +see `TAPE_HEIGHT_PX` for the supported widths. +""" + +from __future__ import annotations + +from typing import Final + +import qrcode +import qrcode.constants +from PIL import Image, ImageDraw, ImageFont + +from app.schemas.label_data import LabelData +from app.schemas.template import LayoutElement, TemplateSchema + +# Tape-mm to printable-area pixel-height at 300 DPI (brother_ql native). +# Source: Brother Raster Command Reference v1.02. Extend as new tape widths +# are supported by the printer-model plugins. +TAPE_HEIGHT_PX: Final[dict[int, int]] = { + 12: 106, + 18: 165, + 24: 256, + 62: 696, # endless QL tape +} + +# Default label width in pixels — wide enough for typical asset/product labels. +# Real width is set later by the print job based on actual rendered content; +# this is just the canvas the renderer paints on. +DEFAULT_LABEL_WIDTH_PX: Final[int] = 600 + + +class LabelRenderer: + """Render a (LabelData, TemplateSchema) pair into a 1-bit PIL Image.""" + + def render(self, data: LabelData, template: TemplateSchema) -> Image.Image: + """Return a 1-bit image sized for the template's tape width. + + Raises: + ValueError: if `template.tape_mm` is not in TAPE_HEIGHT_PX. + """ + height = TAPE_HEIGHT_PX.get(template.tape_mm) + if height is None: + raise ValueError( + f"Unsupported tape_mm: {template.tape_mm}. " + f"Supported widths: {sorted(TAPE_HEIGHT_PX)}" + ) + + img = Image.new("1", (DEFAULT_LABEL_WIDTH_PX, height), color=1) + draw = ImageDraw.Draw(img) + + for element in template.elements: + if element.type == "qr": + self._draw_qr(img, element, data) + else: # element.type == "text" + self._draw_text(draw, element, data) + + return img + + def _draw_qr(self, img: Image.Image, element: LayoutElement, data: LabelData) -> None: + # Validation in LayoutElement.model_validator guarantees these are not None for type="qr". + assert element.data_field is not None + assert element.size is not None + + payload = self._resolve_field(data, element.data_field) + qr = qrcode.QRCode( + version=None, + error_correction=qrcode.constants.ERROR_CORRECT_M, + box_size=4, + border=1, + ) + qr.add_data(payload) + qr.make(fit=True) + qr_pil: Image.Image = qr.make_image(fill_color="black", back_color="white").convert("1") + qr_pil = qr_pil.resize((element.size, element.size)) + img.paste(qr_pil, (element.x, element.y)) + + def _draw_text( + self, + draw: ImageDraw.ImageDraw, + element: LayoutElement, + data: LabelData, + ) -> None: + assert element.field is not None + assert element.font_size is not None + + text = self._resolve_field(data, element.field) + font = self._load_font(element.font_size) + draw.text((element.x, element.y), text, fill=0, font=font) + + @staticmethod + def _resolve_field(data: LabelData, field: str) -> str: + """Read `field` off `data`, coercing tuples/lists to a single ' | '-joined string.""" + value = getattr(data, field, "") + if isinstance(value, (list, tuple)): + return " | ".join(str(v) for v in value) + return str(value) + + @staticmethod + def _load_font(size: int) -> ImageFont.FreeTypeFont | ImageFont.ImageFont: + """Load DejaVuSans at `size`px, fall back to PIL's bitmap default if unavailable.""" + try: + return ImageFont.truetype("DejaVuSans.ttf", size) + except OSError: + return ImageFont.load_default() diff --git a/backend/tests/unit/schemas/test_template_schema.py b/backend/tests/unit/schemas/test_template_schema.py new file mode 100644 index 0000000..9bd43d4 --- /dev/null +++ b/backend/tests/unit/schemas/test_template_schema.py @@ -0,0 +1,59 @@ +import pytest +from app.schemas.template import LayoutElement, TemplateSchema + + +def test_template_with_qr_and_text() -> None: + template = TemplateSchema( + id="snipeit-asset-24mm", + name="Snipe-IT 24mm", + app="snipeit", + tape_mm=24, + elements=[ + LayoutElement(type="qr", x=0, y=0, size=256, data_field="qr_payload"), + LayoutElement(type="text", x=270, y=10, field="title", font_size=24), + ], + ) + assert len(template.elements) == 2 + assert template.elements[0].type == "qr" + + +def test_template_qr_requires_data_field() -> None: + """QR element without data_field must fail validation.""" + with pytest.raises(ValueError, match="data_field"): + LayoutElement(type="qr", x=0, y=0, size=256) + + +def test_template_qr_requires_size() -> None: + """QR element without size must fail validation.""" + with pytest.raises(ValueError, match="size"): + LayoutElement(type="qr", x=0, y=0, data_field="qr_payload") + + +def test_template_text_requires_field() -> None: + with pytest.raises(ValueError, match="field"): + LayoutElement(type="text", x=0, y=0, font_size=24) + + +def test_template_text_requires_font_size() -> None: + with pytest.raises(ValueError, match="font_size"): + LayoutElement(type="text", x=0, y=0, field="title") + + +def test_template_app_must_be_one_of_known() -> None: + with pytest.raises(ValueError): + TemplateSchema( + id="t", + name="t", + app="unknown", # not in Literal + tape_mm=24, + elements=[], + ) + + +def test_template_schema_is_frozen() -> None: + """Templates are immutable after construction.""" + from pydantic_core import ValidationError + + template = TemplateSchema(id="t", name="t", app="snipeit", tape_mm=24, elements=[]) + with pytest.raises(ValidationError, match="frozen_instance"): + template.name = "different" # type: ignore[misc] diff --git a/backend/tests/unit/services/test_label_renderer.py b/backend/tests/unit/services/test_label_renderer.py new file mode 100644 index 0000000..1adcc85 --- /dev/null +++ b/backend/tests/unit/services/test_label_renderer.py @@ -0,0 +1,126 @@ +import pytest +from app.schemas.label_data import LabelData +from app.schemas.template import LayoutElement, TemplateSchema +from app.services.label_renderer import ( + DEFAULT_LABEL_WIDTH_PX, + TAPE_HEIGHT_PX, + LabelRenderer, +) +from PIL import Image + + +def test_render_produces_image_with_correct_height_24mm() -> None: + template = TemplateSchema( + id="t1", + name="Test", + app="snipeit", + tape_mm=24, + elements=[ + LayoutElement(type="text", x=10, y=10, field="title", font_size=24), + ], + ) + data = LabelData( + title="Hello", + primary_id="ID-1", + qr_payload="x", + source_app="snipeit", + ) + + img = LabelRenderer().render(data, template) + + assert isinstance(img, Image.Image) + assert img.height == TAPE_HEIGHT_PX[24] + assert img.mode == "1" + + +def test_render_produces_image_with_correct_height_12mm() -> None: + template = TemplateSchema( + id="t1", + name="Test", + app="snipeit", + tape_mm=12, + elements=[LayoutElement(type="text", x=5, y=5, field="title", font_size=16)], + ) + data = LabelData(title="x", primary_id="x", qr_payload="x", source_app="snipeit") + img = LabelRenderer().render(data, template) + assert img.height == TAPE_HEIGHT_PX[12] + + +def test_render_rejects_unsupported_tape_mm() -> None: + template = TemplateSchema( + id="t1", + name="Test", + app="snipeit", + tape_mm=99, + elements=[], + ) + data = LabelData(title="x", primary_id="x", qr_payload="x", source_app="snipeit") + with pytest.raises(ValueError, match="99"): + LabelRenderer().render(data, template) + + +def test_render_with_qr_element_includes_black_pixels() -> None: + """A QR element must produce a non-trivial number of black pixels in its bbox.""" + template = TemplateSchema( + id="t1", + name="Test", + app="snipeit", + tape_mm=24, + elements=[ + LayoutElement(type="qr", x=0, y=0, size=200, data_field="qr_payload"), + ], + ) + data = LabelData( + title="X", + primary_id="X", + qr_payload="https://example.com", + source_app="snipeit", + ) + + img = LabelRenderer().render(data, template) + qr_region = img.crop((0, 0, 200, 200)) + black_count = sum(1 for p in qr_region.getdata() if p == 0) + assert black_count > 100, f"Expected QR to produce many black pixels, got {black_count}" + + +def test_render_resolves_secondary_tuple_field() -> None: + """secondary is a tuple — renderer must join the entries when used as a text field.""" + template = TemplateSchema( + id="t1", + name="Test", + app="snipeit", + tape_mm=24, + elements=[ + LayoutElement(type="text", x=10, y=100, field="secondary", font_size=16), + ], + ) + data = LabelData( + title="X", + primary_id="X", + qr_payload="x", + source_app="snipeit", + secondary=("Color: Black", "Weight: 850g"), + ) + + img = LabelRenderer().render(data, template) + # The text region should not be entirely white (some pixels must be drawn). + region = img.crop((10, 100, DEFAULT_LABEL_WIDTH_PX, 120)) + black_count = sum(1 for p in region.getdata() if p == 0) + assert black_count > 0 + + +def test_render_with_missing_data_field_renders_empty_string() -> None: + """If a template references a field LabelData doesn't have, render empty (no crash).""" + template = TemplateSchema( + id="t1", + name="Test", + app="snipeit", + tape_mm=24, + elements=[ + LayoutElement(type="text", x=10, y=10, field="nonexistent_field", font_size=16), + ], + ) + data = LabelData(title="X", primary_id="X", qr_payload="x", source_app="snipeit") + # Must NOT raise — missing fields render as empty strings. + img = LabelRenderer().render(data, template) + assert img is not None From c54b48a815ff45b3df8c9727da34598160061856 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Bj=C3=B6rn=20Strausmann?= Date: Mon, 11 May 2026 10:56:56 +0000 Subject: [PATCH 2/3] refactor(label-renderer): tighter size validation + Pillow 14 forward-compat + empty-template test + clarifying comments - Fix A: Replace both getdata() calls with get_flattened_data() (Pillow 14 forward-compat) - Fix B: Validator now explicitly rejects size=0 and font_size=0 with clear error messages; add regression tests test_template_qr_rejects_zero_size and test_template_text_rejects_zero_font_size - Fix C: Add test_render_empty_template_produces_blank_image to cover the zero-element path - Fix D: Document DEFAULT_LABEL_WIDTH_PX with DPI/mm context - Fix E: Comment on separator choice in _resolve_field, with future-phase guidance - Fix F: Clarify assert intent in _draw_qr/_draw_text (invariant docs, not runtime guards) Co-Authored-By: Claude Sonnet 4.6 --- backend/app/schemas/template.py | 8 ++++---- backend/app/services/label_renderer.py | 17 +++++++++++++---- .../tests/unit/schemas/test_template_schema.py | 10 ++++++++++ .../tests/unit/services/test_label_renderer.py | 13 +++++++++++-- 4 files changed, 38 insertions(+), 10 deletions(-) diff --git a/backend/app/schemas/template.py b/backend/app/schemas/template.py index a67fa74..c606cee 100644 --- a/backend/app/schemas/template.py +++ b/backend/app/schemas/template.py @@ -40,13 +40,13 @@ def _validate_per_type(self) -> LayoutElement: if self.type == "qr": if not self.data_field: raise ValueError("qr element requires data_field") - if not self.size: - raise ValueError("qr element requires size") + if self.size is None or self.size <= 0: + raise ValueError("qr element requires a positive size (got None or 0)") else: # type == "text" if not self.field: raise ValueError("text element requires field") - if not self.font_size: - raise ValueError("text element requires font_size") + if self.font_size is None or self.font_size <= 0: + raise ValueError("text element requires a positive font_size (got None or 0)") return self diff --git a/backend/app/services/label_renderer.py b/backend/app/services/label_renderer.py index d8bb56d..753f03d 100644 --- a/backend/app/services/label_renderer.py +++ b/backend/app/services/label_renderer.py @@ -31,9 +31,10 @@ 62: 696, # endless QL tape } -# Default label width in pixels — wide enough for typical asset/product labels. -# Real width is set later by the print job based on actual rendered content; -# this is just the canvas the renderer paints on. +# Default label width in pixels — 600 px at 300 DPI ≈ 50.8mm, suitable for +# typical asset/product label lengths. The actual width the printer receives +# is determined by the print job; this is just the canvas the renderer +# paints on. DEFAULT_LABEL_WIDTH_PX: Final[int] = 600 @@ -65,7 +66,9 @@ def render(self, data: LabelData, template: TemplateSchema) -> Image.Image: return img def _draw_qr(self, img: Image.Image, element: LayoutElement, data: LabelData) -> None: - # Validation in LayoutElement.model_validator guarantees these are not None for type="qr". + # LayoutElement.model_validator guarantees these are non-None for type="qr". + # The asserts document the invariant for readers and mypy; they are NOT + # runtime guards (python -O strips them). assert element.data_field is not None assert element.size is not None @@ -88,6 +91,9 @@ def _draw_text( element: LayoutElement, data: LabelData, ) -> None: + # LayoutElement.model_validator guarantees these are non-None for type="text". + # The asserts document the invariant for readers and mypy; they are NOT + # runtime guards (python -O strips them). assert element.field is not None assert element.font_size is not None @@ -100,6 +106,9 @@ def _resolve_field(data: LabelData, field: str) -> str: """Read `field` off `data`, coercing tuples/lists to a single ' | '-joined string.""" value = getattr(data, field, "") if isinstance(value, (list, tuple)): + # Separator " | " chosen for single-line tape labels. If a future phase + # adds multi-line text fields, this should become a per-element + # `separator` attribute on LayoutElement. return " | ".join(str(v) for v in value) return str(value) diff --git a/backend/tests/unit/schemas/test_template_schema.py b/backend/tests/unit/schemas/test_template_schema.py index 9bd43d4..4470616 100644 --- a/backend/tests/unit/schemas/test_template_schema.py +++ b/backend/tests/unit/schemas/test_template_schema.py @@ -39,6 +39,16 @@ def test_template_text_requires_font_size() -> None: LayoutElement(type="text", x=0, y=0, field="title") +def test_template_qr_rejects_zero_size() -> None: + with pytest.raises(ValueError, match="positive size"): + LayoutElement(type="qr", x=0, y=0, size=0, data_field="qr_payload") + + +def test_template_text_rejects_zero_font_size() -> None: + with pytest.raises(ValueError, match="positive font_size"): + LayoutElement(type="text", x=0, y=0, field="title", font_size=0) + + def test_template_app_must_be_one_of_known() -> None: with pytest.raises(ValueError): TemplateSchema( diff --git a/backend/tests/unit/services/test_label_renderer.py b/backend/tests/unit/services/test_label_renderer.py index 1adcc85..5b9b485 100644 --- a/backend/tests/unit/services/test_label_renderer.py +++ b/backend/tests/unit/services/test_label_renderer.py @@ -79,7 +79,7 @@ def test_render_with_qr_element_includes_black_pixels() -> None: img = LabelRenderer().render(data, template) qr_region = img.crop((0, 0, 200, 200)) - black_count = sum(1 for p in qr_region.getdata() if p == 0) + black_count = sum(1 for p in qr_region.get_flattened_data() if p == 0) assert black_count > 100, f"Expected QR to produce many black pixels, got {black_count}" @@ -105,10 +105,19 @@ def test_render_resolves_secondary_tuple_field() -> None: img = LabelRenderer().render(data, template) # The text region should not be entirely white (some pixels must be drawn). region = img.crop((10, 100, DEFAULT_LABEL_WIDTH_PX, 120)) - black_count = sum(1 for p in region.getdata() if p == 0) + black_count = sum(1 for p in region.get_flattened_data() if p == 0) assert black_count > 0 +def test_render_empty_template_produces_blank_image() -> None: + """An empty template (no elements) must render a blank white canvas.""" + template = TemplateSchema(id="t", name="T", app="snipeit", tape_mm=24, elements=[]) + data = LabelData(title="X", primary_id="X", qr_payload="x", source_app="snipeit") + img = LabelRenderer().render(data, template) + # All pixels should be 1 (white background). + assert all(p == 1 for p in img.get_flattened_data()) + + def test_render_with_missing_data_field_renders_empty_string() -> None: """If a template references a field LabelData doesn't have, render empty (no crash).""" template = TemplateSchema( From 8bb1b351b618143dc3a25a103c7c7fd6954b64bf Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Bj=C3=B6rn=20Strausmann?= Date: Mon, 11 May 2026 11:04:55 +0000 Subject: [PATCH 3/3] refactor(label-renderer): tuple-immutable elements + tighter error messages + LRU-cached font loader - Fix A: TemplateSchema.elements changed from list to tuple[LayoutElement, ...] so frozen=True actually prevents mutation (same pattern as LabelData.secondary fix in PR #51). Pydantic v2 coerces list inputs automatically; callers are unaffected. - Fix B: LayoutElement validator messages now use !r to show the actual rejected value (None, 0, -5 etc.) instead of the generic "got None or 0" string. - Fix C: _load_font promoted from a per-call static method to a module-level @functools.lru_cache(maxsize=32) function, eliminating repeated disk I/O for multi-element templates. - Add 4 regression tests: immutability, negative size, negative font_size, cache identity. Co-Authored-By: Claude Sonnet 4.6 --- backend/app/schemas/template.py | 8 +++--- backend/app/services/label_renderer.py | 24 ++++++++++------- .../unit/schemas/test_template_schema.py | 26 +++++++++++++++++++ .../unit/services/test_label_renderer.py | 9 +++++++ 4 files changed, 55 insertions(+), 12 deletions(-) diff --git a/backend/app/schemas/template.py b/backend/app/schemas/template.py index c606cee..8b1d91e 100644 --- a/backend/app/schemas/template.py +++ b/backend/app/schemas/template.py @@ -41,12 +41,14 @@ def _validate_per_type(self) -> LayoutElement: if not self.data_field: raise ValueError("qr element requires data_field") if self.size is None or self.size <= 0: - raise ValueError("qr element requires a positive size (got None or 0)") + raise ValueError(f"qr element requires a positive size (got {self.size!r})") else: # type == "text" if not self.field: raise ValueError("text element requires field") if self.font_size is None or self.font_size <= 0: - raise ValueError("text element requires a positive font_size (got None or 0)") + raise ValueError( + f"text element requires a positive font_size (got {self.font_size!r})" + ) return self @@ -59,4 +61,4 @@ class TemplateSchema(BaseModel): name: str app: Literal["snipeit", "grocy", "spoolman"] tape_mm: int - elements: list[LayoutElement] + elements: tuple[LayoutElement, ...] diff --git a/backend/app/services/label_renderer.py b/backend/app/services/label_renderer.py index 753f03d..da3fbab 100644 --- a/backend/app/services/label_renderer.py +++ b/backend/app/services/label_renderer.py @@ -12,6 +12,7 @@ from __future__ import annotations +import functools from typing import Final import qrcode @@ -38,6 +39,19 @@ DEFAULT_LABEL_WIDTH_PX: Final[int] = 600 +@functools.lru_cache(maxsize=32) +def _load_font_cached(size: int) -> ImageFont.FreeTypeFont | ImageFont.ImageFont: + """Load DejaVuSans at `size`px (cached), fall back to PIL's bitmap default if unavailable. + + The cache is bounded at 32 entries — far more than any realistic template uses. + Repeated calls with the same size return the same font instance without disk I/O. + """ + try: + return ImageFont.truetype("DejaVuSans.ttf", size) + except OSError: + return ImageFont.load_default() + + class LabelRenderer: """Render a (LabelData, TemplateSchema) pair into a 1-bit PIL Image.""" @@ -98,7 +112,7 @@ def _draw_text( assert element.font_size is not None text = self._resolve_field(data, element.field) - font = self._load_font(element.font_size) + font = _load_font_cached(element.font_size) draw.text((element.x, element.y), text, fill=0, font=font) @staticmethod @@ -111,11 +125,3 @@ def _resolve_field(data: LabelData, field: str) -> str: # `separator` attribute on LayoutElement. return " | ".join(str(v) for v in value) return str(value) - - @staticmethod - def _load_font(size: int) -> ImageFont.FreeTypeFont | ImageFont.ImageFont: - """Load DejaVuSans at `size`px, fall back to PIL's bitmap default if unavailable.""" - try: - return ImageFont.truetype("DejaVuSans.ttf", size) - except OSError: - return ImageFont.load_default() diff --git a/backend/tests/unit/schemas/test_template_schema.py b/backend/tests/unit/schemas/test_template_schema.py index 4470616..44f338f 100644 --- a/backend/tests/unit/schemas/test_template_schema.py +++ b/backend/tests/unit/schemas/test_template_schema.py @@ -67,3 +67,29 @@ def test_template_schema_is_frozen() -> None: template = TemplateSchema(id="t", name="t", app="snipeit", tape_mm=24, elements=[]) with pytest.raises(ValidationError, match="frozen_instance"): template.name = "different" # type: ignore[misc] + + +def test_template_elements_is_immutable() -> None: + """elements is a tuple — appending must raise AttributeError, not silently mutate.""" + template = TemplateSchema( + id="t", + name="t", + app="snipeit", + tape_mm=24, + elements=[LayoutElement(type="text", x=0, y=0, field="title", font_size=12)], + ) + with pytest.raises(AttributeError): + template.elements.append( # type: ignore[attr-defined] + LayoutElement(type="text", x=10, y=10, field="primary_id", font_size=12) + ) + assert isinstance(template.elements, tuple) + + +def test_template_qr_rejects_negative_size() -> None: + with pytest.raises(ValueError, match="positive size"): + LayoutElement(type="qr", x=0, y=0, size=-10, data_field="qr_payload") + + +def test_template_text_rejects_negative_font_size() -> None: + with pytest.raises(ValueError, match="positive font_size"): + LayoutElement(type="text", x=0, y=0, field="title", font_size=-12) diff --git a/backend/tests/unit/services/test_label_renderer.py b/backend/tests/unit/services/test_label_renderer.py index 5b9b485..0201421 100644 --- a/backend/tests/unit/services/test_label_renderer.py +++ b/backend/tests/unit/services/test_label_renderer.py @@ -133,3 +133,12 @@ def test_render_with_missing_data_field_renders_empty_string() -> None: # Must NOT raise — missing fields render as empty strings. img = LabelRenderer().render(data, template) assert img is not None + + +def test_font_loader_is_cached() -> None: + """Same font_size returns the same font instance (LRU-cached).""" + from app.services.label_renderer import _load_font_cached + + a = _load_font_cached(24) + b = _load_font_cached(24) + assert a is b