Files
bambuddy/backend/tests/unit/services/test_label_renderer.py
T
maziggy 864e5c990e feat(inventory): printable PDF spool labels in 4 sizes (#809)
Closes the longest-standing inventory gap — finding a specific spool
  in a closet of 50 partials. Per-spool icon button on every inventory
  card and table row, plus a "Print labels..." header action that opens
  a multi-select picker pre-loaded with the currently filtered spools.

  Four pre-built templates: AMS holder (30 x 15 mm) for the popular
  Makerworld AMS Filament Label Holder, single box label (62 x 29 mm)
  for Brother PT/QL or Dymo small labels, Avery L7160 (A4, 21 per
  sheet), and Avery 5160 (US Letter, 30 per sheet). Each label carries
  the colour swatch (with multi-colour gradient stripes for spools
  with extra_colors set), brand, material, name, the *spool ID*
  (bsaunder's articulated user-need: telling 8 spools of "PLA White"
  apart, especially partials), and a QR code that deep-links to
  /inventory?spool=<id> for phone-scan round-trips. Box-label adds
  storage location; AMS-holder drops the QR — at 30 x 15 mm there is
  no room for swatch + text + QR without truncating away the spool ID,
  and AMS-bay identification is at arm's length where the swatch and
  ID are enough.

  Server-side rendering via ReportLab + qrcode (already a dep). Pure
  Python, no headless browser, no system libs. Output is byte-identical
  across browsers, Avery sheets align to <0.1 mm, and bulk export is
  one click for one PDF. Two endpoints — POST /inventory/labels (local
  DB) and POST /spoolman/labels (Spoolman-backed) — gated on
  INVENTORY_READ, capped at 500 spools per request, returning
  application/pdf via StreamingResponse. The renderer is decoupled
  from the SQLAlchemy model via a LabelData dataclass so the same code
  path serves both modes.

  Modal picker scales to large libraries: search (substring match
  across name / brand / #ID), material filter chips derived from the
  visible spools, additive Select-all-visible / Deselect-visible /
  Clear-all actions so selections survive filter changes. Restyled
  twice in development — first cut used generic Tailwind which clashed
  with the inventory's bambu-dark palette; second cut switched to
  bambu-dark-secondary / bambu-green / bambu-gray to match.

  Two render bugs found during visual inspection of generated PDFs and
  fixed before commit:

    1. AMS-30x15 template originally produced labels with only swatch
       + QR and no text at all — the side-by-side layout left <5 mm
       for the text column, so the renderer bailed without drawing
       anything. Layout split into tight (h<20mm) and roomy (h>=20mm)
       regimes; tight regime drops the QR and gives the right column
       to brand + material + a 13pt-bold spool ID.

    2. Box-62x29 template aggressively truncated text — swatch + QR
       each at ~14 mm on a 26mm-tall label squeezed the text column
       to ~16 mm, turning "Polymaker Ivory" into "Polymak..." and
       "Polymaker . PLA . Matte" into "Polymaker ...". Swatch capped
       at 16 mm, QR capped at 18 mm and constrained to ~20% of width,
       leaving the text column ~30 mm — full names render without
       truncation.

  Both bugs pinned by regression tests in test_label_renderer.py that
  render with pageCompression=0 so the resulting PDF bytes contain the
  text as ASCII and `assert b"Polymaker" in pdf` works.
2026-05-05 12:29:57 +02:00

226 lines
8.9 KiB
Python

"""Unit tests for the spool label renderer (#809)."""
from __future__ import annotations
import pytest
from backend.app.services.label_renderer import LabelData, render_labels
ALL_TEMPLATES = ("ams_30x15", "box_62x29", "avery_5160", "avery_l7160")
def _sample(spool_id: int = 1, **overrides) -> LabelData:
return LabelData(
spool_id=spool_id,
name=overrides.pop("name", "Polymaker Ivory"),
material=overrides.pop("material", "PLA"),
brand=overrides.pop("brand", "Polymaker"),
subtype=overrides.pop("subtype", "Matte"),
rgba=overrides.pop("rgba", "F5E6D3FF"),
extra_colors=overrides.pop("extra_colors", None),
storage_location=overrides.pop("storage_location", None),
deeplink_url=overrides.pop("deeplink_url", f"https://example.test/inventory?spool={spool_id}"),
)
@pytest.mark.parametrize("template", ALL_TEMPLATES)
def test_renders_valid_pdf_for_each_template(template):
pdf = render_labels(template, [_sample(7), _sample(8)])
assert pdf.startswith(b"%PDF"), f"{template} did not produce a PDF header"
assert pdf.endswith(b"%%EOF\n") or pdf.rstrip().endswith(b"%%EOF")
@pytest.mark.parametrize("template", ALL_TEMPLATES)
def test_empty_input_still_returns_valid_pdf(template):
"""Empty list is allowed; renderer returns a valid (mostly empty) PDF."""
pdf = render_labels(template, [])
assert pdf.startswith(b"%PDF")
def test_unknown_template_raises():
with pytest.raises(ValueError, match="Unknown label template"):
render_labels("not_a_template", [_sample()]) # type: ignore[arg-type]
def test_multi_color_swatch_does_not_crash():
data = [_sample(extra_colors=["FF0000", "00FF00", "0000FF", "FFFF00"])]
pdf = render_labels("box_62x29", data)
assert pdf.startswith(b"%PDF")
def test_missing_optional_fields_does_not_crash():
"""Brand/subtype/rgba/storage_location all None — should still render."""
data = [
LabelData(
spool_id=42,
name="Test",
material="PLA",
deeplink_url="https://example.test/inventory?spool=42",
)
]
pdf = render_labels("ams_30x15", data)
assert pdf.startswith(b"%PDF")
def test_malformed_rgba_falls_back_to_grey():
"""rgba="zzz" (invalid hex) must not raise — fallback colour used."""
data = [_sample(rgba="not-a-color")]
pdf = render_labels("avery_l7160", data)
assert pdf.startswith(b"%PDF")
def test_long_strings_are_truncated_not_overflowed():
"""Very long brand/name shouldn't blow up the layout or raise."""
long_brand = "A" * 200
long_name = "B" * 300
data = [_sample(brand=long_brand, name=long_name)]
pdf = render_labels("ams_30x15", data)
assert pdf.startswith(b"%PDF")
def test_sheet_template_paginates_when_count_exceeds_one_sheet():
"""Avery 5160 = 30 per sheet; 31 spools must paginate to 2 pages.
We can't easily count pages from raw PDF bytes, but we can at least
verify the output is meaningfully larger than a single-page rendering.
"""
one = render_labels("avery_5160", [_sample(i) for i in range(1, 31)])
two = render_labels("avery_5160", [_sample(i) for i in range(1, 32)])
assert len(two) > len(one)
def test_qr_payload_is_present_in_pdf_stream():
"""The QR encodes the deeplink URL via embedded PNG; we can at least
sanity-check that the PDF contains an image stream when a deeplink is set
and no image stream when the renderer skips QR generation for an empty URL.
"""
with_qr = render_labels("box_62x29", [_sample(deeplink_url="https://example.test/inventory?spool=1")])
without_qr = render_labels("box_62x29", [_sample(deeplink_url="")])
# PDFs with embedded raster images are noticeably larger than pure-vector ones.
assert len(with_qr) > len(without_qr) + 200, (
"Expected QR-bearing PDF to be substantially larger than QR-less version"
)
# ── Regression tests for the two render bugs found in the first cut ──
def _render_uncompressed(template, data):
"""Render with pageCompression=0 so the resulting PDF contains text as
ASCII bytes. Lets tests assert "X is on the label" by grepping the PDF.
Uses the same internal draw helpers as the real renderer; only the
page-level compression flag differs.
"""
import io as _io
from reportlab.lib.pagesizes import A4, letter
from reportlab.lib.units import mm as _mm
from reportlab.pdfgen import canvas as _rl_canvas
from backend.app.services.label_renderer import _draw_label # noqa: PLC0415
# Mirror the page-size choice from render_labels but force pageCompression=0.
if template in ("ams_30x15", "box_62x29"):
sizes = {"ams_30x15": (30.0, 15.0), "box_62x29": (62.0, 29.0)}
w_mm, h_mm = sizes[template]
page_w, page_h = w_mm * _mm, h_mm * _mm
buf = _io.BytesIO()
c = _rl_canvas.Canvas(buf, pagesize=(page_w, page_h), pageCompression=0)
for d in data:
_draw_label(c, 0, 0, page_w, page_h, d)
c.showPage()
c.save()
return buf.getvalue()
if template == "avery_5160":
page_size = letter
label_w_mm, label_h_mm = 66.675, 25.4
cols, rows = 3, 10
top_mm, left_mm, col_gap_mm = 12.7, 4.76, 3.175
else: # avery_l7160
page_size = A4
label_w_mm, label_h_mm = 63.5, 38.1
cols, rows = 3, 7
top_mm, left_mm, col_gap_mm = 15.15, 7.0, 2.5
buf = _io.BytesIO()
c = _rl_canvas.Canvas(buf, pagesize=page_size, pageCompression=0)
page_w, page_h = page_size
label_w, label_h = label_w_mm * _mm, label_h_mm * _mm
per_page = cols * rows
for page_start in range(0, len(data), per_page):
chunk = data[page_start : page_start + per_page]
for idx, d in enumerate(chunk):
row = idx // cols
col = idx % cols
x = left_mm * _mm + col * (label_w + col_gap_mm * _mm)
y = page_h - top_mm * _mm - (row + 1) * label_h
_draw_label(c, x, y, label_w, label_h, d)
c.showPage()
c.save()
return buf.getvalue()
def test_ams_template_actually_renders_text():
"""Regression: the first cut of the AMS-holder layout produced labels with
only swatch + QR and no text at all because the side-by-side layout left
<5 mm for the text column. The redesign drops the QR on this template and
gives the right side to brand + material + spool ID. This pins that the
rendered PDF contains all three fields.
"""
data = [
LabelData(
spool_id=42,
name="Test",
material="PLA",
brand="Polymaker",
subtype="Matte",
rgba="F5E6D3FF",
deeplink_url="https://example.test/inventory?spool=42",
)
]
pdf = _render_uncompressed("ams_30x15", data)
assert b"Polymaker" in pdf, "AMS template must render the brand"
assert b"PLA" in pdf, "AMS template must render the material"
# The bracketed-hash style is what the renderer uses for the spool ID;
# ReportLab's `#` is in the BaseFont, so it appears as literal `#` in the
# uncompressed stream alongside the digits.
assert b"#42" in pdf or (b"42" in pdf and b"#" in pdf), (
"AMS template must render the spool ID — that's the killer field"
)
def test_box_template_does_not_truncate_normal_brand_or_name():
"""Regression: the first cut of the box-label layout sized the swatch and
QR each at ~14 mm on a 26-mm-wide text column, leaving only ~16 mm for
text and aggressively truncating "Polymaker · PLA · Matte" to
"Polymaker …" and "Polymaker Ivory" to "Polymak…". The redesign caps the
swatch and QR widths so a typical brand + name renders without truncation.
"""
data = [
LabelData(
spool_id=7,
name="Polymaker Ivory",
material="PLA",
brand="Polymaker",
subtype="Matte",
rgba="F5E6D3FF",
storage_location="Shelf 3, slot B",
deeplink_url="https://example.test/inventory?spool=7",
)
]
pdf = _render_uncompressed("box_62x29", data)
# Brand on its own line — must not be truncated.
assert b"Polymaker" in pdf, "box template must render the brand"
# Material + subtype on its own line — must not be truncated.
assert b"Matte" in pdf, "box template must render the subtype"
# Spool name (bold) — must include both words. Truncation would have
# produced "Polymak\xe2\x80\xa6" in the original bug, so asserting the
# second word "Ivory" is on the label is the regression-pin.
assert b"Ivory" in pdf, (
"box template must render the spool name fully — earlier layout truncated 'Polymaker Ivory' to 'Polymak…'"
)
# Storage location (italic).
assert b"Shelf 3, slot B" in pdf, "box template must render the storage location"
# Big spool ID at bottom.
assert b"#7" in pdf or (b"7" in pdf and b"#" in pdf), "box template must render the spool ID"