test_scheduler_nozzle_mismatch.py 9.9 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248
  1. """Tests for the nozzle-diameter mismatch guard (#1899).
  2. A file sliced for one nozzle size dispatched to a printer with a different
  3. nozzle installed is rejected by the firmware with a cryptic HMS ("Failed to get
  4. AMS mapping table" 0700_8012). The scheduler catches this before upload and
  5. fails the queue item with an actionable message instead.
  6. These cover the two pure helpers that make the decision. The guard is fail-safe
  7. by construction: it only blocks on a POSITIVE mismatch, never on missing data.
  8. """
  9. from contextlib import ExitStack
  10. from pathlib import Path
  11. from types import SimpleNamespace
  12. from unittest.mock import AsyncMock, MagicMock, patch
  13. import pytest
  14. from sqlalchemy.ext.asyncio import async_sessionmaker, create_async_engine
  15. import backend.app.models # noqa: F401 - populate Base.metadata
  16. import backend.app.services.print_scheduler as scheduler_module
  17. from backend.app.core.database import Base
  18. from backend.app.models.archive import PrintArchive
  19. from backend.app.models.print_queue import PrintQueueItem
  20. from backend.app.models.printer import Printer
  21. from backend.app.services.print_scheduler import (
  22. PrintScheduler,
  23. _installed_nozzle_diameters,
  24. _nozzle_mismatch_message,
  25. )
  26. def _state(*diameters: str):
  27. """PrinterState-shaped namespace with the given nozzle diameter strings."""
  28. return SimpleNamespace(nozzles=[SimpleNamespace(nozzle_diameter=d) for d in diameters])
  29. # ---------------------------------------------------------------------------
  30. # _installed_nozzle_diameters
  31. # ---------------------------------------------------------------------------
  32. def test_installed_parses_single_nozzle():
  33. assert _installed_nozzle_diameters(_state("0.6")) == [0.6]
  34. def test_installed_parses_dual_nozzle():
  35. assert _installed_nozzle_diameters(_state("0.4", "0.6")) == [0.4, 0.6]
  36. def test_installed_skips_empty_default_stub():
  37. # Single-nozzle printers still emit a 2-entry array; the second is an
  38. # empty-string default until MQTT fills it in.
  39. assert _installed_nozzle_diameters(_state("0.4", "")) == [0.4]
  40. def test_installed_skips_unparseable_and_zero():
  41. assert _installed_nozzle_diameters(_state("", "abc", "0", "0.4")) == [0.4]
  42. def test_installed_handles_no_status_or_no_nozzles():
  43. assert _installed_nozzle_diameters(None) == []
  44. assert _installed_nozzle_diameters(SimpleNamespace()) == []
  45. assert _installed_nozzle_diameters(SimpleNamespace(nozzles=[])) == []
  46. # ---------------------------------------------------------------------------
  47. # _nozzle_mismatch_message
  48. # ---------------------------------------------------------------------------
  49. def test_mismatch_blocks_single_nozzle():
  50. msg = _nozzle_mismatch_message(0.6, [0.4])
  51. assert msg is not None
  52. assert "0.6mm" in msg
  53. assert "0.4mm" in msg
  54. def test_match_single_nozzle_passes():
  55. assert _nozzle_mismatch_message(0.4, [0.4]) is None
  56. def test_match_within_float_tolerance_passes():
  57. # 0.4 slice vs a 0.40000001 reported diameter must not trip.
  58. assert _nozzle_mismatch_message(0.4, [0.40000001]) is None
  59. def test_dual_nozzle_match_on_either_passes():
  60. # 0.6 slice on a printer with a 0.4 and a 0.6 hotend is fine.
  61. assert _nozzle_mismatch_message(0.6, [0.4, 0.6]) is None
  62. def test_dual_nozzle_mismatch_on_both_blocks():
  63. msg = _nozzle_mismatch_message(0.8, [0.4, 0.6])
  64. assert msg is not None
  65. assert "0.4mm / 0.6mm" in msg
  66. def test_no_sliced_diameter_is_failsafe_none():
  67. # Slice didn't declare a nozzle diameter → never block.
  68. assert _nozzle_mismatch_message(None, [0.4]) is None
  69. assert _nozzle_mismatch_message(0.0, [0.4]) is None
  70. def test_no_installed_nozzles_is_failsafe_none():
  71. # Printer hasn't reported nozzles → unknown, never block.
  72. assert _nozzle_mismatch_message(0.6, []) is None
  73. def test_adjacent_sizes_are_distinguished():
  74. # 0.2 gap between adjacent sizes stays well outside the 0.05 tolerance.
  75. assert _nozzle_mismatch_message(0.4, [0.6]) is not None
  76. assert _nozzle_mismatch_message(0.6, [0.8]) is not None
  77. # ---------------------------------------------------------------------------
  78. # End-to-end: the guard fires inside _start_print BEFORE upload
  79. # ---------------------------------------------------------------------------
  80. @pytest.fixture
  81. async def archive_case(tmp_path):
  82. """Build an archive-based queue item on a real in-memory DB + on-disk 3MF."""
  83. engine = create_async_engine("sqlite+aiosqlite:///:memory:", echo=False)
  84. async with engine.begin() as conn:
  85. await conn.run_sync(Base.metadata.create_all)
  86. session_maker = async_sessionmaker(engine, expire_on_commit=False)
  87. async def make_case(*, sliced_nozzle: float | None):
  88. base_dir = tmp_path / "case"
  89. base_dir.mkdir(exist_ok=True)
  90. archive_rel = Path("archives") / "job.3mf"
  91. archive_abs = base_dir / archive_rel
  92. archive_abs.parent.mkdir(parents=True, exist_ok=True)
  93. archive_abs.write_bytes(b"sliced 3mf")
  94. async with session_maker() as db:
  95. printer = Printer(
  96. name="H2S",
  97. serial_number="SN-H2S",
  98. ip_address="127.0.0.1",
  99. access_code="ac",
  100. model="H2S",
  101. )
  102. db.add(printer)
  103. await db.flush()
  104. archive = PrintArchive(
  105. printer_id=printer.id,
  106. filename="job.3mf",
  107. file_path=str(archive_rel),
  108. file_size=archive_abs.stat().st_size,
  109. nozzle_diameter=sliced_nozzle,
  110. status="completed",
  111. )
  112. db.add(archive)
  113. await db.flush()
  114. item = PrintQueueItem(
  115. printer_id=printer.id,
  116. archive_id=archive.id,
  117. status="pending",
  118. bed_levelling="on",
  119. flow_cali="off",
  120. vibration_cali=True,
  121. layer_inspect=False,
  122. timelapse=False,
  123. use_ams=True,
  124. nozzle_offset_cali="on",
  125. )
  126. db.add(item)
  127. await db.commit()
  128. return SimpleNamespace(
  129. session_maker=session_maker,
  130. base_dir=base_dir,
  131. archive_abs=archive_abs,
  132. printer_id=printer.id,
  133. queue_item_id=item.id,
  134. start_print=MagicMock(return_value=True),
  135. upload=AsyncMock(return_value=True),
  136. )
  137. try:
  138. yield make_case
  139. finally:
  140. await engine.dispose()
  141. async def _run_start_print(ctx, *, installed_nozzles):
  142. scheduler = PrintScheduler()
  143. status = SimpleNamespace(nozzles=[SimpleNamespace(nozzle_diameter=d) for d in installed_nozzles])
  144. # The mismatch case returns before the upload path; the match case drives it
  145. # to start_print, so mirror the post-guard dependency patches the
  146. # cleanup-library harness uses (get_ftp_retry_settings et al. open their own
  147. # DB session, not our in-memory one, so they must be stubbed).
  148. patches = [
  149. patch.object(scheduler_module.settings, "base_dir", ctx.base_dir),
  150. patch("backend.app.services.print_scheduler.printer_manager.is_connected", MagicMock(return_value=True)),
  151. patch("backend.app.services.print_scheduler.printer_manager.get_status", MagicMock(return_value=status)),
  152. patch("backend.app.services.print_scheduler.printer_manager.start_print", ctx.start_print),
  153. patch("backend.app.services.print_scheduler.printer_manager.set_awaiting_plate_clear", MagicMock()),
  154. patch("backend.app.services.print_scheduler.upload_file_async", ctx.upload),
  155. patch("backend.app.services.print_scheduler.delete_file_async", AsyncMock(return_value=True)),
  156. patch("backend.app.services.print_scheduler.cache_3mf_download", MagicMock()),
  157. patch("backend.app.services.print_scheduler.spawn_background_task", MagicMock()),
  158. patch(
  159. "backend.app.services.print_scheduler.get_ftp_retry_settings", AsyncMock(return_value=(False, 0, 0, 1.0))
  160. ),
  161. patch("backend.app.services.notification_service.notification_service.on_queue_job_started", AsyncMock()),
  162. patch("backend.app.services.notification_service.notification_service.on_queue_job_failed", AsyncMock()),
  163. patch("backend.app.services.mqtt_relay.mqtt_relay.on_queue_job_started", AsyncMock()),
  164. patch("backend.app.services.print_scheduler.ws_manager.send_queue_item_failed", AsyncMock()),
  165. patch.object(scheduler, "_preheat_and_soak", AsyncMock()),
  166. patch.object(scheduler, "_propagate_owner_to_printer_manager", AsyncMock()),
  167. patch.object(scheduler, "_power_off_if_needed", AsyncMock()),
  168. ]
  169. with ExitStack() as stack:
  170. for p in patches:
  171. stack.enter_context(p)
  172. async with ctx.session_maker() as db:
  173. item = await db.get(PrintQueueItem, ctx.queue_item_id)
  174. await scheduler._start_print(db, item)
  175. @pytest.mark.asyncio
  176. async def test_start_print_blocks_on_nozzle_mismatch_before_upload(archive_case):
  177. """0.6 slice on a 0.4-only printer: item fails with an actionable message,
  178. and neither upload nor start_print is reached."""
  179. ctx = await archive_case(sliced_nozzle=0.6)
  180. await _run_start_print(ctx, installed_nozzles=["0.4"])
  181. async with ctx.session_maker() as db:
  182. item = await db.get(PrintQueueItem, ctx.queue_item_id)
  183. assert item.status == "failed"
  184. assert "0.6mm" in item.error_message and "0.4mm" in item.error_message
  185. ctx.upload.assert_not_called()
  186. ctx.start_print.assert_not_called()
  187. @pytest.mark.asyncio
  188. async def test_start_print_proceeds_when_nozzle_matches(archive_case):
  189. """0.6 slice on a 0.6 printer: the guard is a no-op and dispatch proceeds
  190. (item leaves 'pending', start_print is reached)."""
  191. ctx = await archive_case(sliced_nozzle=0.6)
  192. await _run_start_print(ctx, installed_nozzles=["0.6"])
  193. async with ctx.session_maker() as db:
  194. item = await db.get(PrintQueueItem, ctx.queue_item_id)
  195. assert item.status != "failed"
  196. ctx.start_print.assert_called_once()