test_filament_deficit.py 11 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267
  1. """Unit tests for the filament-deficit pre-dispatch check (#1496).
  2. The check is the single source of truth that both ``POST /queue/{id}/start``
  3. and the dispatch scheduler call before sending a print to the printer. Pin
  4. the contract for the cases that matter:
  5. * Internal-inventory mode: shortfall + sufficient + no assignment.
  6. * AMS-mapping gating: a missing mapping means "not yet decided, skip".
  7. * Disabled-warnings setting + missing printer (model-based item) + no
  8. source 3MF all short-circuit to "no deficit".
  9. """
  10. from __future__ import annotations
  11. import json
  12. import zipfile
  13. from pathlib import Path
  14. from unittest.mock import patch
  15. import pytest
  16. from backend.app.models.archive import PrintArchive
  17. from backend.app.models.print_queue import PrintQueueItem
  18. from backend.app.models.settings import Settings
  19. from backend.app.models.spool import Spool
  20. from backend.app.models.spool_assignment import SpoolAssignment
  21. from backend.app.services.filament_deficit import (
  22. FilamentDeficit,
  23. compute_deficit_for_queue_item,
  24. )
  25. def _write_3mf(file_path: Path, filaments: list[dict]) -> None:
  26. """Minimal 3MF that ``extract_filament_requirements`` can parse (flat shape)."""
  27. body = "".join(
  28. f'<filament id="{f["id"]}" type="{f["type"]}" color="{f["color"]}" '
  29. f'used_g="{f["used_g"]}" tray_info_idx="{f.get("tray_info_idx", "")}"/>'
  30. for f in filaments
  31. )
  32. config = f'<?xml version="1.0" encoding="utf-8"?><config>{body}</config>'
  33. with zipfile.ZipFile(file_path, "w") as zf:
  34. zf.writestr("Metadata/slice_info.config", config)
  35. async def _setup_archive_3mf(db_session, tmp_path: Path, filaments: list[dict]) -> PrintArchive:
  36. """Create a 3MF on disk and a PrintArchive row pointing at it."""
  37. file_name = "model.3mf"
  38. file_path = tmp_path / file_name
  39. _write_3mf(file_path, filaments)
  40. archive = PrintArchive(
  41. filename=file_name,
  42. print_name="Test",
  43. # The helper resolves via app_settings.base_dir / file_path, but
  44. # storing the absolute path on the model also works because
  45. # ``Path / abs`` collapses to the absolute side.
  46. file_path=str(file_path),
  47. file_size=file_path.stat().st_size,
  48. status="completed",
  49. )
  50. db_session.add(archive)
  51. await db_session.commit()
  52. await db_session.refresh(archive)
  53. return archive
  54. async def _spool(db_session, *, label_weight: int, weight_used: float, color: str = "#000000") -> Spool:
  55. spool = Spool(
  56. material="PLA",
  57. label_weight=label_weight,
  58. weight_used=weight_used,
  59. rgba=color,
  60. )
  61. db_session.add(spool)
  62. await db_session.commit()
  63. await db_session.refresh(spool)
  64. return spool
  65. async def _assign(db_session, *, printer_id: int, spool_id: int, ams_id: int = 0, tray_id: int = 0) -> None:
  66. db_session.add(
  67. SpoolAssignment(
  68. spool_id=spool_id,
  69. printer_id=printer_id,
  70. ams_id=ams_id,
  71. tray_id=tray_id,
  72. )
  73. )
  74. await db_session.commit()
  75. async def _queue_item(
  76. db_session,
  77. *,
  78. printer_id: int | None,
  79. archive: PrintArchive | None,
  80. ams_mapping: list[int] | None,
  81. plate_id: int | None = None,
  82. ) -> PrintQueueItem:
  83. item = PrintQueueItem(
  84. printer_id=printer_id,
  85. archive_id=archive.id if archive else None,
  86. ams_mapping=json.dumps(ams_mapping) if ams_mapping is not None else None,
  87. plate_id=plate_id,
  88. status="pending",
  89. manual_start=True,
  90. )
  91. db_session.add(item)
  92. await db_session.commit()
  93. await db_session.refresh(item, ["archive", "library_file"])
  94. return item
  95. class TestFilamentDeficit:
  96. @pytest.mark.asyncio
  97. async def test_returns_deficit_when_spool_too_light(self, db_session, printer_factory, tmp_path):
  98. """Spool with 30g remaining for a 100g print → one deficit row."""
  99. printer = await printer_factory()
  100. archive = await _setup_archive_3mf(
  101. db_session,
  102. tmp_path,
  103. [{"id": "1", "type": "PLA", "color": "#FFFFFF", "used_g": "100.0"}],
  104. )
  105. spool = await _spool(db_session, label_weight=1000, weight_used=970.0) # 30g left
  106. await _assign(db_session, printer_id=printer.id, spool_id=spool.id, ams_id=0, tray_id=0)
  107. item = await _queue_item(db_session, printer_id=printer.id, archive=archive, ams_mapping=[0])
  108. with patch("backend.app.services.filament_deficit.app_settings.base_dir", Path("/")):
  109. deficit = await compute_deficit_for_queue_item(db_session, item)
  110. assert len(deficit) == 1
  111. assert isinstance(deficit[0], FilamentDeficit)
  112. assert deficit[0].slot_id == 1
  113. assert deficit[0].required_grams == 100.0
  114. assert deficit[0].remaining_grams == 30.0
  115. assert deficit[0].filament_type == "PLA"
  116. @pytest.mark.asyncio
  117. async def test_returns_empty_when_spool_has_enough(self, db_session, printer_factory, tmp_path):
  118. printer = await printer_factory()
  119. archive = await _setup_archive_3mf(
  120. db_session,
  121. tmp_path,
  122. [{"id": "1", "type": "PLA", "color": "#FFFFFF", "used_g": "100.0"}],
  123. )
  124. spool = await _spool(db_session, label_weight=1000, weight_used=200.0) # 800g left
  125. await _assign(db_session, printer_id=printer.id, spool_id=spool.id, ams_id=0, tray_id=0)
  126. item = await _queue_item(db_session, printer_id=printer.id, archive=archive, ams_mapping=[0])
  127. with patch("backend.app.services.filament_deficit.app_settings.base_dir", Path("/")):
  128. deficit = await compute_deficit_for_queue_item(db_session, item)
  129. assert deficit == []
  130. @pytest.mark.asyncio
  131. async def test_returns_empty_when_ams_mapping_missing(self, db_session, printer_factory, tmp_path):
  132. """No mapping yet = scheduler hasn't decided which slot maps where."""
  133. printer = await printer_factory()
  134. archive = await _setup_archive_3mf(
  135. db_session,
  136. tmp_path,
  137. [{"id": "1", "type": "PLA", "color": "#FFFFFF", "used_g": "100.0"}],
  138. )
  139. item = await _queue_item(db_session, printer_id=printer.id, archive=archive, ams_mapping=None)
  140. with patch("backend.app.services.filament_deficit.app_settings.base_dir", Path("/")):
  141. deficit = await compute_deficit_for_queue_item(db_session, item)
  142. assert deficit == []
  143. @pytest.mark.asyncio
  144. async def test_returns_empty_when_no_printer_assigned(self, db_session, tmp_path):
  145. """Model-based queue items with no resolved printer_id can't be checked."""
  146. archive = await _setup_archive_3mf(
  147. db_session,
  148. tmp_path,
  149. [{"id": "1", "type": "PLA", "color": "#FFFFFF", "used_g": "100.0"}],
  150. )
  151. item = await _queue_item(db_session, printer_id=None, archive=archive, ams_mapping=[0])
  152. with patch("backend.app.services.filament_deficit.app_settings.base_dir", Path("/")):
  153. deficit = await compute_deficit_for_queue_item(db_session, item)
  154. assert deficit == []
  155. @pytest.mark.asyncio
  156. async def test_returns_empty_when_warnings_disabled(self, db_session, printer_factory, tmp_path):
  157. """Honour the disable_filament_warnings setting (#720 toggle)."""
  158. printer = await printer_factory()
  159. archive = await _setup_archive_3mf(
  160. db_session,
  161. tmp_path,
  162. [{"id": "1", "type": "PLA", "color": "#FFFFFF", "used_g": "100.0"}],
  163. )
  164. spool = await _spool(db_session, label_weight=1000, weight_used=970.0)
  165. await _assign(db_session, printer_id=printer.id, spool_id=spool.id)
  166. item = await _queue_item(db_session, printer_id=printer.id, archive=archive, ams_mapping=[0])
  167. db_session.add(Settings(key="disable_filament_warnings", value="true"))
  168. await db_session.commit()
  169. with patch("backend.app.services.filament_deficit.app_settings.base_dir", Path("/")):
  170. deficit = await compute_deficit_for_queue_item(db_session, item)
  171. assert deficit == []
  172. @pytest.mark.asyncio
  173. async def test_returns_empty_when_no_assignment(self, db_session, printer_factory, tmp_path):
  174. """Mapping points at a slot with no spool assigned → silent, not blocked."""
  175. printer = await printer_factory()
  176. archive = await _setup_archive_3mf(
  177. db_session,
  178. tmp_path,
  179. [{"id": "1", "type": "PLA", "color": "#FFFFFF", "used_g": "100.0"}],
  180. )
  181. item = await _queue_item(db_session, printer_id=printer.id, archive=archive, ams_mapping=[0])
  182. with patch("backend.app.services.filament_deficit.app_settings.base_dir", Path("/")):
  183. deficit = await compute_deficit_for_queue_item(db_session, item)
  184. assert deficit == []
  185. @pytest.mark.asyncio
  186. async def test_returns_empty_when_3mf_missing(self, db_session, printer_factory):
  187. printer = await printer_factory()
  188. archive = PrintArchive(
  189. filename="ghost.3mf",
  190. file_path="/nonexistent/ghost.3mf",
  191. file_size=0,
  192. status="completed",
  193. )
  194. db_session.add(archive)
  195. await db_session.commit()
  196. await db_session.refresh(archive)
  197. item = await _queue_item(db_session, printer_id=printer.id, archive=archive, ams_mapping=[0])
  198. deficit = await compute_deficit_for_queue_item(db_session, item)
  199. assert deficit == []
  200. @pytest.mark.asyncio
  201. async def test_multi_slot_only_shorted_slot_returned(self, db_session, printer_factory, tmp_path):
  202. """One slot fine, one short — only the short slot is in the result."""
  203. printer = await printer_factory()
  204. archive = await _setup_archive_3mf(
  205. db_session,
  206. tmp_path,
  207. [
  208. {"id": "1", "type": "PLA", "color": "#FFFFFF", "used_g": "100.0"},
  209. {"id": "2", "type": "PETG", "color": "#000000", "used_g": "80.0"},
  210. ],
  211. )
  212. plenty = await _spool(db_session, label_weight=1000, weight_used=100.0) # 900g
  213. shorted = await _spool(db_session, label_weight=1000, weight_used=950.0) # 50g
  214. await _assign(db_session, printer_id=printer.id, spool_id=plenty.id, ams_id=0, tray_id=0)
  215. await _assign(db_session, printer_id=printer.id, spool_id=shorted.id, ams_id=0, tray_id=1)
  216. item = await _queue_item(
  217. db_session,
  218. printer_id=printer.id,
  219. archive=archive,
  220. ams_mapping=[0, 1], # slot 1 -> tray 0, slot 2 -> tray 1
  221. )
  222. with patch("backend.app.services.filament_deficit.app_settings.base_dir", Path("/")):
  223. deficit = await compute_deficit_for_queue_item(db_session, item)
  224. assert [d.slot_id for d in deficit] == [2]
  225. assert deficit[0].remaining_grams == 50.0
  226. assert deficit[0].required_grams == 80.0