test_label_renderer.py 12 KB

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