test_plate_thumbnail.py 8.4 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198
  1. """Unit tests for the plate-thumbnail injection service.
  2. The service backfills ``Metadata/plate_N.png`` when the sidecar CLI
  3. (BS or Orca) skipped it in --slice --export-3mf. Each test builds a
  4. synthetic sliced-3MF fixture: a trimesh-exported cube as
  5. ``3D/3dmodel.model`` plus dummy ``Metadata/plate_1.gcode`` so the
  6. inject function sees it as "plate 1, no thumbnail."
  7. """
  8. from __future__ import annotations
  9. import io
  10. import zipfile
  11. import pytest
  12. def _trimesh_available() -> bool:
  13. try:
  14. import trimesh # noqa: F401
  15. return True
  16. except ImportError:
  17. return False
  18. def _build_sliced_3mf(
  19. *,
  20. plate_ids: list[int],
  21. with_thumbnails: set[int] | None = None,
  22. with_model: bool = True,
  23. ) -> bytes:
  24. """Build a synthetic sliced .gcode.3mf for injection tests.
  25. - ``plate_ids``: which Metadata/plate_N.gcode entries to write
  26. - ``with_thumbnails``: subset of plate_ids that ALSO get plate_N.png +
  27. plate_N_small.png (simulates a desktop-Studio-style slice where the
  28. slicer did embed thumbnails)
  29. - ``with_model``: when True, embeds a trimesh-rendered cube as
  30. ``3D/3dmodel.model`` so the injector can reload + render it
  31. """
  32. import trimesh
  33. have_thumbs = with_thumbnails or set()
  34. buf = io.BytesIO()
  35. with zipfile.ZipFile(buf, "w", zipfile.ZIP_DEFLATED) as zf:
  36. if with_model:
  37. # trimesh's primitives.Box exports cleanly to 3MF.
  38. mesh = trimesh.creation.box(extents=(10.0, 10.0, 10.0))
  39. model_bytes = mesh.export(file_type="3mf")
  40. # trimesh.export(file_type='3mf') returns a full 3MF zip; we
  41. # want just the embedded 3D/3dmodel.model XML so we can place
  42. # it under the sliced-3MF layout.
  43. with zipfile.ZipFile(io.BytesIO(model_bytes), "r") as inner:
  44. model_xml = inner.read("3D/3dmodel.model")
  45. zf.writestr("3D/3dmodel.model", model_xml)
  46. for n in plate_ids:
  47. # Dummy gcode is enough for the injector — it only matches the
  48. # filename to detect plate slots, not the content.
  49. zf.writestr(f"Metadata/plate_{n}.gcode", b"; dummy gcode\n")
  50. if n in have_thumbs:
  51. # 1x1 transparent PNG — pre-existing thumb sentinel; the
  52. # injector should preserve its bytes verbatim.
  53. zf.writestr(f"Metadata/plate_{n}.png", _PIXEL_PNG)
  54. zf.writestr(f"Metadata/plate_{n}_small.png", _PIXEL_PNG)
  55. return buf.getvalue()
  56. # 1x1 transparent PNG used as a pre-existing thumbnail sentinel.
  57. _PIXEL_PNG = (
  58. b"\x89PNG\r\n\x1a\n"
  59. b"\x00\x00\x00\rIHDR"
  60. b"\x00\x00\x00\x01\x00\x00\x00\x01\x08\x06\x00\x00\x00"
  61. b"\x1f\x15\xc4\x89"
  62. b"\x00\x00\x00\x0dIDATx\x9cc\xfc\xff\xff?\x03\x00\x05\xfe\x02\xfe"
  63. b"\xdc\xccY\xe7"
  64. b"\x00\x00\x00\x00IEND\xaeB`\x82"
  65. )
  66. def _names_in_zip(blob: bytes) -> set[str]:
  67. with zipfile.ZipFile(io.BytesIO(blob), "r") as zf:
  68. return set(zf.namelist())
  69. @pytest.mark.skipif(not _trimesh_available(), reason="trimesh not installed")
  70. class TestInjectPlateThumbnails:
  71. """Behaviour around when the injector renders vs returns the input."""
  72. def test_returns_input_unchanged_when_all_plates_have_thumbnails(self):
  73. """Desktop-Studio path: every plate already has plate_N.png — no work."""
  74. from backend.app.services.plate_thumbnail import inject_plate_thumbnails_if_missing
  75. fixture = _build_sliced_3mf(plate_ids=[1], with_thumbnails={1})
  76. result = inject_plate_thumbnails_if_missing(fixture)
  77. # Same object identity — the fast path returns the input verbatim
  78. # so the SliceResult._replace upstream never pays for a copy on the
  79. # common already-embedded case.
  80. assert result is fixture
  81. def test_injects_both_sizes_when_thumbnail_missing(self):
  82. """BS/Orca sidecar path: plate_1.gcode present, plate_1.png absent."""
  83. from backend.app.services.plate_thumbnail import inject_plate_thumbnails_if_missing
  84. fixture = _build_sliced_3mf(plate_ids=[1], with_thumbnails=set())
  85. before = _names_in_zip(fixture)
  86. assert "Metadata/plate_1.png" not in before
  87. result = inject_plate_thumbnails_if_missing(fixture)
  88. after = _names_in_zip(result)
  89. assert "Metadata/plate_1.png" in after
  90. assert "Metadata/plate_1_small.png" in after
  91. def test_injected_pngs_have_expected_dimensions(self):
  92. """Sanity-check the render geometry — 512x512 + 128x128, RGBA PNG."""
  93. from backend.app.services.plate_thumbnail import inject_plate_thumbnails_if_missing
  94. fixture = _build_sliced_3mf(plate_ids=[1], with_thumbnails=set())
  95. result = inject_plate_thumbnails_if_missing(fixture)
  96. with zipfile.ZipFile(io.BytesIO(result), "r") as zf:
  97. large = zf.read("Metadata/plate_1.png")
  98. small = zf.read("Metadata/plate_1_small.png")
  99. assert large.startswith(b"\x89PNG\r\n\x1a\n")
  100. assert small.startswith(b"\x89PNG\r\n\x1a\n")
  101. # PNG IHDR dimensions live at byte offsets 16..23 (big-endian width,
  102. # then big-endian height). matplotlib's bbox_inches='tight' shaves a
  103. # few pixels off, so assert "close to" rather than exact.
  104. import struct
  105. large_w, large_h = struct.unpack(">II", large[16:24])
  106. small_w, small_h = struct.unpack(">II", small[16:24])
  107. assert 480 <= large_w <= 540 and 480 <= large_h <= 540
  108. assert 100 <= small_w <= 140 and 100 <= small_h <= 140
  109. def test_injected_thumbnail_is_shaded_not_flat(self, distinct_surface_tones):
  110. """Injected plate renders must be lit, same as library thumbnails (#2816).
  111. The archive card and the File Manager tile show the same model through
  112. two different renderers; if only one of them is lit they disagree.
  113. """
  114. from backend.app.services.plate_thumbnail import inject_plate_thumbnails_if_missing
  115. fixture = _build_sliced_3mf(plate_ids=[1], with_thumbnails=set())
  116. result = inject_plate_thumbnails_if_missing(fixture)
  117. with zipfile.ZipFile(io.BytesIO(result), "r") as zf:
  118. large = zf.read("Metadata/plate_1.png")
  119. # _build_sliced_3mf embeds a cube: three faces visible, three tones.
  120. assert distinct_surface_tones(large) >= 3
  121. def test_injects_for_every_missing_plate_in_multi_plate_3mf(self):
  122. """Three plates, plate_2 already has a thumbnail; only plates 1 + 3 get rendered."""
  123. from backend.app.services.plate_thumbnail import inject_plate_thumbnails_if_missing
  124. fixture = _build_sliced_3mf(plate_ids=[1, 2, 3], with_thumbnails={2})
  125. result = inject_plate_thumbnails_if_missing(fixture)
  126. after = _names_in_zip(result)
  127. for n in (1, 2, 3):
  128. assert f"Metadata/plate_{n}.png" in after
  129. assert f"Metadata/plate_{n}_small.png" in after
  130. # Plate 2 had a pre-existing thumbnail — the inject must NOT clobber
  131. # it. The sentinel _PIXEL_PNG bytes should survive verbatim.
  132. with zipfile.ZipFile(io.BytesIO(result), "r") as zf:
  133. assert zf.read("Metadata/plate_2.png") == _PIXEL_PNG
  134. assert zf.read("Metadata/plate_2_small.png") == _PIXEL_PNG
  135. def test_returns_input_when_no_model_file_in_3mf(self):
  136. """No 3D/3dmodel.model → render is impossible; degrade gracefully."""
  137. from backend.app.services.plate_thumbnail import inject_plate_thumbnails_if_missing
  138. fixture = _build_sliced_3mf(plate_ids=[1], with_thumbnails=set(), with_model=False)
  139. result = inject_plate_thumbnails_if_missing(fixture)
  140. # Same object identity — early-out before render.
  141. assert result is fixture
  142. def test_returns_input_when_not_a_zip(self):
  143. """Non-zip input must not crash — degrade to passthrough."""
  144. from backend.app.services.plate_thumbnail import inject_plate_thumbnails_if_missing
  145. garbage = b"not a zip"
  146. assert inject_plate_thumbnails_if_missing(garbage) is garbage
  147. def test_idempotent_on_second_pass(self):
  148. """Re-running on a previously-injected 3MF must be a no-op."""
  149. from backend.app.services.plate_thumbnail import inject_plate_thumbnails_if_missing
  150. fixture = _build_sliced_3mf(plate_ids=[1], with_thumbnails=set())
  151. once = inject_plate_thumbnails_if_missing(fixture)
  152. twice = inject_plate_thumbnails_if_missing(once)
  153. # Same object identity — second pass hits the no-op fast path
  154. # because every plate now has its plate_N.png.
  155. assert twice is once