test_label_renderer.py 11 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297
  1. """Unit tests for the spool label renderer (#809)."""
  2. from __future__ import annotations
  3. import pytest
  4. from backend.app.services.label_renderer import LabelData, render_labels
  5. ALL_TEMPLATES = ("ams_30x15", "box_40x30", "box_62x29", "avery_5160", "avery_l7160")
  6. def _sample(spool_id: int = 1, **overrides) -> LabelData:
  7. return LabelData(
  8. spool_id=spool_id,
  9. name=overrides.pop("name", "Polymaker Ivory"),
  10. material=overrides.pop("material", "PLA"),
  11. brand=overrides.pop("brand", "Polymaker"),
  12. subtype=overrides.pop("subtype", "Matte"),
  13. rgba=overrides.pop("rgba", "F5E6D3FF"),
  14. extra_colors=overrides.pop("extra_colors", None),
  15. storage_location=overrides.pop("storage_location", None),
  16. deeplink_url=overrides.pop("deeplink_url", f"https://example.test/inventory?spool={spool_id}"),
  17. )
  18. @pytest.mark.parametrize("template", ALL_TEMPLATES)
  19. def test_renders_valid_pdf_for_each_template(template):
  20. pdf = render_labels(template, [_sample(7), _sample(8)])
  21. assert pdf.startswith(b"%PDF"), f"{template} did not produce a PDF header"
  22. assert pdf.endswith(b"%%EOF\n") or pdf.rstrip().endswith(b"%%EOF")
  23. @pytest.mark.parametrize("template", ALL_TEMPLATES)
  24. def test_empty_input_still_returns_valid_pdf(template):
  25. """Empty list is allowed; renderer returns a valid (mostly empty) PDF."""
  26. pdf = render_labels(template, [])
  27. assert pdf.startswith(b"%PDF")
  28. def test_unknown_template_raises():
  29. with pytest.raises(ValueError, match="Unknown label template"):
  30. render_labels("not_a_template", [_sample()]) # type: ignore[arg-type]
  31. def test_multi_color_swatch_does_not_crash():
  32. data = [_sample(extra_colors=["FF0000", "00FF00", "0000FF", "FFFF00"])]
  33. pdf = render_labels("box_62x29", data)
  34. assert pdf.startswith(b"%PDF")
  35. def test_missing_optional_fields_does_not_crash():
  36. """Brand/subtype/rgba/storage_location all None — should still render."""
  37. data = [
  38. LabelData(
  39. spool_id=42,
  40. name="Test",
  41. material="PLA",
  42. deeplink_url="https://example.test/inventory?spool=42",
  43. )
  44. ]
  45. pdf = render_labels("ams_30x15", data)
  46. assert pdf.startswith(b"%PDF")
  47. def test_malformed_rgba_falls_back_to_grey():
  48. """rgba="zzz" (invalid hex) must not raise — fallback colour used."""
  49. data = [_sample(rgba="not-a-color")]
  50. pdf = render_labels("avery_l7160", data)
  51. assert pdf.startswith(b"%PDF")
  52. def test_long_strings_are_truncated_not_overflowed():
  53. """Very long brand/name shouldn't blow up the layout or raise."""
  54. long_brand = "A" * 200
  55. long_name = "B" * 300
  56. data = [_sample(brand=long_brand, name=long_name)]
  57. pdf = render_labels("ams_30x15", data)
  58. assert pdf.startswith(b"%PDF")
  59. def test_sheet_template_paginates_when_count_exceeds_one_sheet():
  60. """Avery 5160 = 30 per sheet; 31 spools must paginate to 2 pages.
  61. We can't easily count pages from raw PDF bytes, but we can at least
  62. verify the output is meaningfully larger than a single-page rendering.
  63. """
  64. one = render_labels("avery_5160", [_sample(i) for i in range(1, 31)])
  65. two = render_labels("avery_5160", [_sample(i) for i in range(1, 32)])
  66. assert len(two) > len(one)
  67. def test_qr_payload_is_present_in_pdf_stream():
  68. """The QR encodes the deeplink URL via embedded PNG; we can at least
  69. sanity-check that the PDF contains an image stream when a deeplink is set
  70. and no image stream when the renderer skips QR generation for an empty URL.
  71. """
  72. with_qr = render_labels("box_62x29", [_sample(deeplink_url="https://example.test/inventory?spool=1")])
  73. without_qr = render_labels("box_62x29", [_sample(deeplink_url="")])
  74. # PDFs with embedded raster images are noticeably larger than pure-vector ones.
  75. assert len(with_qr) > len(without_qr) + 200, (
  76. "Expected QR-bearing PDF to be substantially larger than QR-less version"
  77. )
  78. # ── Regression tests for the two render bugs found in the first cut ──
  79. def _render_uncompressed(template, data):
  80. """Render with pageCompression=0 so the resulting PDF contains text as
  81. ASCII bytes. Lets tests assert "X is on the label" by grepping the PDF.
  82. Uses the same internal draw helpers as the real renderer; only the
  83. page-level compression flag differs.
  84. """
  85. import io as _io
  86. from reportlab.lib.pagesizes import A4, letter
  87. from reportlab.lib.units import mm as _mm
  88. from reportlab.pdfgen import canvas as _rl_canvas
  89. from backend.app.services.label_renderer import _draw_label # noqa: PLC0415
  90. # Mirror the page-size choice from render_labels but force pageCompression=0.
  91. if template in ("ams_30x15", "box_40x30", "box_62x29"):
  92. sizes = {
  93. "ams_30x15": (30.0, 15.0),
  94. "box_40x30": (40.0, 30.0),
  95. "box_62x29": (62.0, 29.0),
  96. }
  97. w_mm, h_mm = sizes[template]
  98. page_w, page_h = w_mm * _mm, h_mm * _mm
  99. buf = _io.BytesIO()
  100. c = _rl_canvas.Canvas(buf, pagesize=(page_w, page_h), pageCompression=0)
  101. for d in data:
  102. _draw_label(c, 0, 0, page_w, page_h, d)
  103. c.showPage()
  104. c.save()
  105. return buf.getvalue()
  106. if template == "avery_5160":
  107. page_size = letter
  108. label_w_mm, label_h_mm = 66.675, 25.4
  109. cols, rows = 3, 10
  110. top_mm, left_mm, col_gap_mm = 12.7, 4.76, 3.175
  111. else: # avery_l7160
  112. page_size = A4
  113. label_w_mm, label_h_mm = 63.5, 38.1
  114. cols, rows = 3, 7
  115. top_mm, left_mm, col_gap_mm = 15.15, 7.0, 2.5
  116. buf = _io.BytesIO()
  117. c = _rl_canvas.Canvas(buf, pagesize=page_size, pageCompression=0)
  118. page_w, page_h = page_size
  119. label_w, label_h = label_w_mm * _mm, label_h_mm * _mm
  120. per_page = cols * rows
  121. for page_start in range(0, len(data), per_page):
  122. chunk = data[page_start : page_start + per_page]
  123. for idx, d in enumerate(chunk):
  124. row = idx // cols
  125. col = idx % cols
  126. x = left_mm * _mm + col * (label_w + col_gap_mm * _mm)
  127. y = page_h - top_mm * _mm - (row + 1) * label_h
  128. _draw_label(c, x, y, label_w, label_h, d)
  129. c.showPage()
  130. c.save()
  131. return buf.getvalue()
  132. def test_ams_template_actually_renders_text():
  133. """Regression: the first cut of the AMS-holder layout produced labels with
  134. only swatch + QR and no text at all because the side-by-side layout left
  135. <5 mm for the text column. The redesign drops the QR on this template and
  136. gives the right side to brand + material + spool ID. This pins that the
  137. rendered PDF contains all three fields.
  138. """
  139. data = [
  140. LabelData(
  141. spool_id=42,
  142. name="Test",
  143. material="PLA",
  144. brand="Polymaker",
  145. subtype="Matte",
  146. rgba="F5E6D3FF",
  147. deeplink_url="https://example.test/inventory?spool=42",
  148. )
  149. ]
  150. pdf = _render_uncompressed("ams_30x15", data)
  151. assert b"Polymaker" in pdf, "AMS template must render the brand"
  152. assert b"PLA" in pdf, "AMS template must render the material"
  153. # The bracketed-hash style is what the renderer uses for the spool ID;
  154. # ReportLab's `#` is in the BaseFont, so it appears as literal `#` in the
  155. # uncompressed stream alongside the digits.
  156. assert b"#42" in pdf or (b"42" in pdf and b"#" in pdf), (
  157. "AMS template must render the spool ID — that's the killer field"
  158. )
  159. def test_hex_color_code_rendered_when_rgba_set():
  160. """#809 follow-up: the colour hex code (#RRGGBB, alpha-stripped, uppercase)
  161. must appear on the rendered label so the user can tell near-identical
  162. spools apart at a glance.
  163. """
  164. data = [
  165. LabelData(
  166. spool_id=12,
  167. name="Polymaker Ivory",
  168. material="PLA",
  169. brand="Polymaker",
  170. subtype="Matte",
  171. rgba="f5e6d3FF",
  172. deeplink_url="https://example.test/inventory?spool=12",
  173. )
  174. ]
  175. pdf = _render_uncompressed("box_62x29", data)
  176. assert b"#F5E6D3" in pdf, "box label must render the hex colour code"
  177. pdf = _render_uncompressed("box_40x30", data)
  178. assert b"#F5E6D3" in pdf, "40x30 box label must render the hex colour code"
  179. def test_hex_color_code_skipped_when_rgba_invalid():
  180. """Malformed rgba must NOT render any '#' hex string apart from the spool
  181. ID — silently skipping the hex line is better than crashing or rendering
  182. garbage. The spool ID still uses '#' so we look for the specific shape.
  183. """
  184. data = [
  185. LabelData(
  186. spool_id=99,
  187. name="Test",
  188. material="PLA",
  189. brand="Polymaker",
  190. rgba="not-a-color",
  191. deeplink_url="https://example.test/inventory?spool=99",
  192. )
  193. ]
  194. pdf = _render_uncompressed("box_62x29", data)
  195. # No 6-hex-digit '#XXXXXX' substring should appear (only '#99' for the ID).
  196. import re
  197. matches = re.findall(rb"#[0-9A-F]{6}", pdf)
  198. assert matches == [], f"expected no hex code on label, found {matches!r}"
  199. def test_brand_rendered_in_bold_per_809_followup():
  200. """#809 follow-up: brand should render in Helvetica-Bold (not regular).
  201. Uncompressed PDFs include font-name tokens like '/F2' tied to a font
  202. resource; we can grep for the bold font's basename in the resource block.
  203. """
  204. data = [
  205. LabelData(
  206. spool_id=5,
  207. name="Acme PLA",
  208. material="PLA",
  209. brand="Polymaker",
  210. rgba="FF8800FF",
  211. deeplink_url="https://example.test/inventory?spool=5",
  212. )
  213. ]
  214. pdf = _render_uncompressed("box_62x29", data)
  215. # ReportLab references the bold variant of Helvetica via /Helvetica-Bold
  216. # in the font dictionary — both the spool ID (always bold) and the brand
  217. # (now bold per #809 follow-up) cause the resource to be embedded.
  218. assert b"Helvetica-Bold" in pdf, "label PDF must reference Helvetica-Bold for the brand line"
  219. def test_box_template_does_not_truncate_normal_brand_or_name():
  220. """Regression: the first cut of the box-label layout sized the swatch and
  221. QR each at ~14 mm on a 26-mm-wide text column, leaving only ~16 mm for
  222. text and aggressively truncating "Polymaker · PLA · Matte" to
  223. "Polymaker …" and "Polymaker Ivory" to "Polymak…". The redesign caps the
  224. swatch and QR widths so a typical brand + name renders without truncation.
  225. """
  226. data = [
  227. LabelData(
  228. spool_id=7,
  229. name="Polymaker Ivory",
  230. material="PLA",
  231. brand="Polymaker",
  232. subtype="Matte",
  233. rgba="F5E6D3FF",
  234. storage_location="Shelf 3, slot B",
  235. deeplink_url="https://example.test/inventory?spool=7",
  236. )
  237. ]
  238. pdf = _render_uncompressed("box_62x29", data)
  239. # Brand on its own line — must not be truncated.
  240. assert b"Polymaker" in pdf, "box template must render the brand"
  241. # Material + subtype on its own line — must not be truncated.
  242. assert b"Matte" in pdf, "box template must render the subtype"
  243. # Spool name (bold) — must include both words. Truncation would have
  244. # produced "Polymak\xe2\x80\xa6" in the original bug, so asserting the
  245. # second word "Ivory" is on the label is the regression-pin.
  246. assert b"Ivory" in pdf, (
  247. "box template must render the spool name fully — earlier layout truncated 'Polymaker Ivory' to 'Polymak…'"
  248. )
  249. # Storage location (italic).
  250. assert b"Shelf 3, slot B" in pdf, "box template must render the storage location"
  251. # Big spool ID at bottom.
  252. assert b"#7" in pdf or (b"7" in pdf and b"#" in pdf), "box template must render the spool ID"