test_label_renderer.py 16 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419
  1. """Unit tests for the spool label renderer (#809)."""
  2. from __future__ import annotations
  3. from unittest.mock import patch
  4. import pytest
  5. from reportlab.lib.pagesizes import letter
  6. from reportlab.lib.units import mm
  7. from backend.app.services.label_renderer import LabelData, render_labels
  8. ALL_TEMPLATES = (
  9. "ams_holder_74x33",
  10. "ams_holder_75x55",
  11. "box_40x30",
  12. "box_62x29",
  13. "avery_5160",
  14. "avery_l7160",
  15. )
  16. def _sample(spool_id: int = 1, **overrides) -> LabelData:
  17. return LabelData(
  18. spool_id=spool_id,
  19. name=overrides.pop("name", "Polymaker Ivory"),
  20. material=overrides.pop("material", "PLA"),
  21. brand=overrides.pop("brand", "Polymaker"),
  22. subtype=overrides.pop("subtype", "Matte"),
  23. rgba=overrides.pop("rgba", "F5E6D3FF"),
  24. extra_colors=overrides.pop("extra_colors", None),
  25. storage_location=overrides.pop("storage_location", None),
  26. deeplink_url=overrides.pop("deeplink_url", f"https://example.test/inventory?spool={spool_id}"),
  27. )
  28. @pytest.mark.parametrize("template", ALL_TEMPLATES)
  29. def test_renders_valid_pdf_for_each_template(template):
  30. pdf = render_labels(template, [_sample(7), _sample(8)])
  31. assert pdf.startswith(b"%PDF"), f"{template} did not produce a PDF header"
  32. assert pdf.endswith(b"%%EOF\n") or pdf.rstrip().endswith(b"%%EOF")
  33. @pytest.mark.parametrize("template", ALL_TEMPLATES)
  34. def test_empty_input_still_returns_valid_pdf(template):
  35. """Empty list is allowed; renderer returns a valid (mostly empty) PDF."""
  36. pdf = render_labels(template, [])
  37. assert pdf.startswith(b"%PDF")
  38. def test_unknown_template_raises():
  39. with pytest.raises(ValueError, match="Unknown label template"):
  40. render_labels("not_a_template", [_sample()]) # type: ignore[arg-type]
  41. def test_multi_color_swatch_does_not_crash():
  42. data = [_sample(extra_colors=["FF0000", "00FF00", "0000FF", "FFFF00"])]
  43. pdf = render_labels("box_62x29", data)
  44. assert pdf.startswith(b"%PDF")
  45. def test_missing_optional_fields_does_not_crash():
  46. """Brand/subtype/rgba/storage_location all None — should still render."""
  47. data = [
  48. LabelData(
  49. spool_id=42,
  50. name="Test",
  51. material="PLA",
  52. deeplink_url="https://example.test/inventory?spool=42",
  53. )
  54. ]
  55. pdf = render_labels("ams_holder_74x33", data)
  56. assert pdf.startswith(b"%PDF")
  57. def test_malformed_rgba_falls_back_to_grey():
  58. """rgba="zzz" (invalid hex) must not raise — fallback colour used."""
  59. data = [_sample(rgba="not-a-color")]
  60. pdf = render_labels("avery_l7160", data)
  61. assert pdf.startswith(b"%PDF")
  62. def test_long_strings_are_truncated_not_overflowed():
  63. """Very long brand/name shouldn't blow up the layout or raise."""
  64. long_brand = "A" * 200
  65. long_name = "B" * 300
  66. data = [_sample(brand=long_brand, name=long_name)]
  67. pdf = render_labels("ams_holder_74x33", data)
  68. assert pdf.startswith(b"%PDF")
  69. def test_sheet_template_paginates_when_count_exceeds_one_sheet():
  70. """Avery 5160 = 30 per sheet; 31 spools must paginate to 2 pages.
  71. We can't easily count pages from raw PDF bytes, but we can at least
  72. verify the output is meaningfully larger than a single-page rendering.
  73. """
  74. one = render_labels("avery_5160", [_sample(i) for i in range(1, 31)])
  75. two = render_labels("avery_5160", [_sample(i) for i in range(1, 32)])
  76. assert len(two) > len(one)
  77. def test_sheet_starting_position_offsets_first_label():
  78. with patch("backend.app.services.label_renderer._draw_label") as draw_label:
  79. render_labels("avery_5160", [_sample(1)], starting_position=8)
  80. first_call = draw_label.call_args_list[0].args
  81. assert first_call[1] == pytest.approx(4.76 * mm + 66.675 * mm + 3.175 * mm)
  82. assert first_call[2] == pytest.approx(letter[1] - 12.7 * mm - 3 * 25.4 * mm)
  83. def test_sheet_starting_position_resets_on_second_page():
  84. data = [_sample(i) for i in range(1, 25)]
  85. with patch("backend.app.services.label_renderer._draw_label") as draw_label:
  86. render_labels("avery_5160", data, starting_position=8)
  87. last_first_page_call = draw_label.call_args_list[22].args
  88. first_second_page_call = draw_label.call_args_list[23].args
  89. assert last_first_page_call[1] == pytest.approx(4.76 * mm + 2 * (66.675 * mm + 3.175 * mm))
  90. assert last_first_page_call[2] == pytest.approx(letter[1] - 12.7 * mm - 10 * 25.4 * mm)
  91. assert first_second_page_call[1] == pytest.approx(4.76 * mm)
  92. assert first_second_page_call[2] == pytest.approx(letter[1] - 12.7 * mm - 25.4 * mm)
  93. @pytest.mark.parametrize(
  94. ("template", "starting_position"),
  95. (("avery_5160", 0), ("avery_5160", 31), ("avery_l7160", 22), ("box_62x29", 2)),
  96. )
  97. def test_invalid_starting_position_raises(template, starting_position):
  98. with pytest.raises(ValueError, match="Starting position"):
  99. render_labels(template, [_sample()], starting_position=starting_position)
  100. def test_qr_payload_is_present_in_pdf_stream():
  101. """The QR encodes the deeplink URL via embedded PNG; we can at least
  102. sanity-check that the PDF contains an image stream when a deeplink is set
  103. and no image stream when the renderer skips QR generation for an empty URL.
  104. """
  105. with_qr = render_labels("box_62x29", [_sample(deeplink_url="https://example.test/inventory?spool=1")])
  106. without_qr = render_labels("box_62x29", [_sample(deeplink_url="")])
  107. # PDFs with embedded raster images are noticeably larger than pure-vector ones.
  108. assert len(with_qr) > len(without_qr) + 200, (
  109. "Expected QR-bearing PDF to be substantially larger than QR-less version"
  110. )
  111. # ── Regression tests for the two render bugs found in the first cut ──
  112. def _render_uncompressed(template, data, monochrome=False):
  113. """Render with pageCompression=0 so the resulting PDF contains text as
  114. ASCII bytes. Lets tests assert "X is on the label" by grepping the PDF.
  115. Uses the same internal draw helpers as the real renderer; only the
  116. page-level compression flag differs.
  117. """
  118. import io as _io
  119. from reportlab.lib.pagesizes import A4, letter
  120. from reportlab.lib.units import mm as _mm
  121. from reportlab.pdfgen import canvas as _rl_canvas
  122. from backend.app.services.label_renderer import _draw_label # noqa: PLC0415
  123. # Mirror the page-size choice from render_labels but force pageCompression=0.
  124. if template in ("ams_holder_74x33", "ams_holder_75x55", "box_40x30", "box_62x29"):
  125. sizes = {
  126. "ams_holder_74x33": (74.0, 33.0),
  127. "ams_holder_75x55": (75.0, 55.0),
  128. "box_40x30": (40.0, 30.0),
  129. "box_62x29": (62.0, 29.0),
  130. }
  131. w_mm, h_mm = sizes[template]
  132. page_w, page_h = w_mm * _mm, h_mm * _mm
  133. buf = _io.BytesIO()
  134. c = _rl_canvas.Canvas(buf, pagesize=(page_w, page_h), pageCompression=0)
  135. for d in data:
  136. _draw_label(c, 0, 0, page_w, page_h, d, monochrome)
  137. c.showPage()
  138. c.save()
  139. return buf.getvalue()
  140. if template == "avery_5160":
  141. page_size = letter
  142. label_w_mm, label_h_mm = 66.675, 25.4
  143. cols, rows = 3, 10
  144. top_mm, left_mm, col_gap_mm = 12.7, 4.76, 3.175
  145. else: # avery_l7160
  146. page_size = A4
  147. label_w_mm, label_h_mm = 63.5, 38.1
  148. cols, rows = 3, 7
  149. top_mm, left_mm, col_gap_mm = 15.15, 7.0, 2.5
  150. buf = _io.BytesIO()
  151. c = _rl_canvas.Canvas(buf, pagesize=page_size, pageCompression=0)
  152. page_w, page_h = page_size
  153. label_w, label_h = label_w_mm * _mm, label_h_mm * _mm
  154. per_page = cols * rows
  155. for page_start in range(0, len(data), per_page):
  156. chunk = data[page_start : page_start + per_page]
  157. for idx, d in enumerate(chunk):
  158. row = idx // cols
  159. col = idx % cols
  160. x = left_mm * _mm + col * (label_w + col_gap_mm * _mm)
  161. y = page_h - top_mm * _mm - (row + 1) * label_h
  162. _draw_label(c, x, y, label_w, label_h, d)
  163. c.showPage()
  164. c.save()
  165. return buf.getvalue()
  166. def test_transparent_swatch_does_not_apply_its_alpha_to_qr():
  167. pdf = _render_uncompressed(
  168. "box_62x29",
  169. [_sample(rgba="FF000000", deeplink_url="https://example.test/inventory?spool=1")],
  170. )
  171. alpha_start = pdf.index(b"/gRLs0 gs")
  172. qr_draw = pdf.index(b" Do", alpha_start)
  173. assert b"Q" in pdf[alpha_start:qr_draw]
  174. def test_ams_template_actually_renders_text():
  175. """Regression: the first cut of the AMS-holder layout produced labels with
  176. only swatch + QR and no text at all because the side-by-side layout left
  177. <5 mm for the text column. The current AMS templates use the roomy layout
  178. (swatch + QR + multi-line text); this pins that the rendered PDF contains
  179. brand + material + spool ID for the smaller AMS preset.
  180. """
  181. data = [
  182. LabelData(
  183. spool_id=42,
  184. name="Test",
  185. material="PLA",
  186. brand="Polymaker",
  187. subtype="Matte",
  188. rgba="F5E6D3FF",
  189. deeplink_url="https://example.test/inventory?spool=42",
  190. )
  191. ]
  192. pdf = _render_uncompressed("ams_holder_74x33", data)
  193. assert b"Polymaker" in pdf, "AMS template must render the brand"
  194. assert b"PLA" in pdf, "AMS template must render the material"
  195. # The bracketed-hash style is what the renderer uses for the spool ID;
  196. # ReportLab's `#` is in the BaseFont, so it appears as literal `#` in the
  197. # uncompressed stream alongside the digits.
  198. assert b"#42" in pdf or (b"42" in pdf and b"#" in pdf), (
  199. "AMS template must render the spool ID — that's the killer field"
  200. )
  201. def test_hex_color_code_rendered_when_rgba_set():
  202. """#809 follow-up: the colour hex code (#RRGGBB, alpha-stripped, uppercase)
  203. must appear on the rendered label so the user can tell near-identical
  204. spools apart at a glance.
  205. """
  206. data = [
  207. LabelData(
  208. spool_id=12,
  209. name="Polymaker Ivory",
  210. material="PLA",
  211. brand="Polymaker",
  212. subtype="Matte",
  213. rgba="f5e6d3FF",
  214. deeplink_url="https://example.test/inventory?spool=12",
  215. )
  216. ]
  217. pdf = _render_uncompressed("box_62x29", data)
  218. assert b"#F5E6D3" in pdf, "box label must render the hex colour code"
  219. pdf = _render_uncompressed("box_40x30", data)
  220. assert b"#F5E6D3" in pdf, "40x30 box label must render the hex colour code"
  221. def test_hex_color_code_skipped_when_rgba_invalid():
  222. """Malformed rgba must NOT render any '#' hex string apart from the spool
  223. ID — silently skipping the hex line is better than crashing or rendering
  224. garbage. The spool ID still uses '#' so we look for the specific shape.
  225. """
  226. data = [
  227. LabelData(
  228. spool_id=99,
  229. name="Test",
  230. material="PLA",
  231. brand="Polymaker",
  232. rgba="not-a-color",
  233. deeplink_url="https://example.test/inventory?spool=99",
  234. )
  235. ]
  236. pdf = _render_uncompressed("box_62x29", data)
  237. # No 6-hex-digit '#XXXXXX' substring should appear (only '#99' for the ID).
  238. import re
  239. matches = re.findall(rb"#[0-9A-F]{6}", pdf)
  240. assert matches == [], f"expected no hex code on label, found {matches!r}"
  241. def test_brand_rendered_in_bold_per_809_followup():
  242. """#809 follow-up: brand should render in Helvetica-Bold (not regular).
  243. Uncompressed PDFs include font-name tokens like '/F2' tied to a font
  244. resource; we can grep for the bold font's basename in the resource block.
  245. """
  246. data = [
  247. LabelData(
  248. spool_id=5,
  249. name="Acme PLA",
  250. material="PLA",
  251. brand="Polymaker",
  252. rgba="FF8800FF",
  253. deeplink_url="https://example.test/inventory?spool=5",
  254. )
  255. ]
  256. pdf = _render_uncompressed("box_62x29", data)
  257. # ReportLab references the bold variant of Helvetica via /Helvetica-Bold
  258. # in the font dictionary — both the spool ID (always bold) and the brand
  259. # (now bold per #809 follow-up) cause the resource to be embedded.
  260. assert b"Helvetica-Bold" in pdf, "label PDF must reference Helvetica-Bold for the brand line"
  261. def test_box_template_does_not_truncate_normal_brand_or_name():
  262. """Regression: the first cut of the box-label layout sized the swatch and
  263. QR each at ~14 mm on a 26-mm-wide text column, leaving only ~16 mm for
  264. text and aggressively truncating "Polymaker · PLA · Matte" to
  265. "Polymaker …" and "Polymaker Ivory" to "Polymak…". The redesign caps the
  266. swatch and QR widths so a typical brand + name renders without truncation.
  267. """
  268. data = [
  269. LabelData(
  270. spool_id=7,
  271. name="Polymaker Ivory",
  272. material="PLA",
  273. brand="Polymaker",
  274. subtype="Matte",
  275. rgba="F5E6D3FF",
  276. storage_location="Shelf 3, slot B",
  277. deeplink_url="https://example.test/inventory?spool=7",
  278. )
  279. ]
  280. pdf = _render_uncompressed("box_62x29", data)
  281. # Brand on its own line — must not be truncated.
  282. assert b"Polymaker" in pdf, "box template must render the brand"
  283. # Material + subtype on its own line — must not be truncated.
  284. assert b"Matte" in pdf, "box template must render the subtype"
  285. # Spool name (bold) — must include both words. Truncation would have
  286. # produced "Polymak\xe2\x80\xa6" in the original bug, so asserting the
  287. # second word "Ivory" is on the label is the regression-pin.
  288. assert b"Ivory" in pdf, (
  289. "box template must render the spool name fully — earlier layout truncated 'Polymaker Ivory' to 'Polymak…'"
  290. )
  291. # Storage location (italic).
  292. assert b"Shelf 3, slot B" in pdf, "box template must render the storage location"
  293. # Big spool ID at bottom.
  294. assert b"#7" in pdf or (b"7" in pdf and b"#" in pdf), "box template must render the spool ID"
  295. # ── #1870: low-res thermal-printer optimisations ──
  296. @pytest.mark.parametrize("template", ALL_TEMPLATES)
  297. def test_monochrome_renders_valid_pdf_for_each_template(template):
  298. """Monochrome mode must render a valid PDF for every template (#1870)."""
  299. pdf = render_labels(template, [_sample(7), _sample(8)], monochrome=True)
  300. assert pdf.startswith(b"%PDF"), f"{template} monochrome did not produce a PDF header"
  301. def test_monochrome_omits_colour_swatch():
  302. """Monochrome drops the colour swatch (useless grey block on a B&W printer)
  303. while the default keeps it (#1870, requested by @Geoff-S)."""
  304. from unittest.mock import patch
  305. import backend.app.services.label_renderer as lr
  306. with patch.object(lr, "_draw_swatch") as mock_swatch:
  307. render_labels("box_40x30", [_sample(1)], monochrome=True)
  308. assert mock_swatch.call_count == 0, "monochrome must not draw the colour swatch"
  309. with patch.object(lr, "_draw_swatch") as mock_swatch:
  310. render_labels("box_40x30", [_sample(1)], monochrome=False)
  311. assert mock_swatch.call_count == 1, "colour mode must draw the swatch"
  312. def test_monochrome_still_renders_text_and_hex():
  313. """Dropping the swatch must not lose the colour info — the hex code line and
  314. the text fields still render (the hex is how colour is conveyed in B&W)."""
  315. data = [
  316. LabelData(
  317. spool_id=42,
  318. name="Polymaker Ivory",
  319. material="PLA",
  320. brand="Polymaker",
  321. subtype="Matte",
  322. rgba="F5E6D3FF",
  323. deeplink_url="https://example.test/inventory?spool=42",
  324. )
  325. ]
  326. pdf = _render_uncompressed("box_40x30", data, monochrome=True)
  327. assert b"Polymaker" in pdf, "monochrome label must still render the brand"
  328. assert b"#F5E6D3" in pdf, "monochrome label must still render the hex colour code"
  329. assert b"#42" in pdf or (b"42" in pdf and b"#" in pdf), "monochrome label must render the spool ID"
  330. def test_roomy_qr_size_has_floor_for_narrow_labels():
  331. """#1870 regression: box_40x30's QR must not shrink below a scannable size.
  332. The pre-fix ``inner_w * 0.20`` gave ~7.5 mm on that label; the floor keeps
  333. it at 12 mm so each module clears ~3 dots on a 203 dpi thermal head.
  334. """
  335. from reportlab.lib.units import mm
  336. from backend.app.services.label_renderer import _roomy_qr_size
  337. pad = 1.2 * mm
  338. # box_40x30 inner dimensions.
  339. inner_w = 40 * mm - 2 * pad
  340. inner_h = 30 * mm - 2 * pad
  341. assert _roomy_qr_size(inner_w, inner_h) >= 12 * mm - 0.01
  342. # Larger templates are unaffected (already above the floor) and still capped.
  343. inner_w_big = 75 * mm - 2 * pad
  344. inner_h_big = 55 * mm - 2 * pad
  345. size_big = _roomy_qr_size(inner_w_big, inner_h_big)
  346. assert 12 * mm <= size_big <= 18 * mm