test_print_start_name_match_ap2.py 9.9 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241
  1. """A print matched to a 'printing' archive by name only is a new print when
  2. the subtask ids show it.
  3. An archive can be left in 'printing' when its completion was missed. Started
  4. again within two hours, the same file used to be folded into that archive:
  5. no new history entry, no timelapse, no cost estimate. Two different subtask
  6. ids are two prints, unless a queue job of this printer is running that
  7. archive: a Bambuddy reprint mints a fresh id for the same row (#1807).
  8. """
  9. from contextlib import ExitStack
  10. from datetime import datetime, timedelta, timezone
  11. from unittest.mock import AsyncMock, MagicMock, patch
  12. import pytest
  13. from sqlalchemy import select
  14. from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker
  15. from backend.app.main import (
  16. _active_prints,
  17. _expected_print_creators,
  18. _expected_print_registered_at,
  19. _expected_prints,
  20. _print_ams_mappings,
  21. )
  22. from backend.app.models.archive import PrintArchive
  23. from backend.app.models.print_queue import PrintQueueItem
  24. DISPATCH = "/data/Metadata/plate_1.gcode"
  25. SUBTASK = "Bracket_plate_1"
  26. @pytest.fixture(autouse=True)
  27. def _clear_print_state():
  28. dicts = (
  29. _expected_prints,
  30. _expected_print_registered_at,
  31. _expected_print_creators,
  32. _print_ams_mappings,
  33. _active_prints,
  34. )
  35. for d in dicts:
  36. d.clear()
  37. yield
  38. for d in dicts:
  39. d.clear()
  40. async def _start(test_engine, printer_id: int, subtask_id: str, last_dispatch: str | None = None) -> None:
  41. session_maker = async_sessionmaker(test_engine, class_=AsyncSession, expire_on_commit=False)
  42. state = MagicMock(
  43. current_project_url=f"ftp://{SUBTASK}.gcode.3mf",
  44. sdcard=True,
  45. sdcard_reported=True,
  46. subtask_id=subtask_id,
  47. progress=0.0,
  48. )
  49. patches = [
  50. patch("backend.app.main.async_session", session_maker),
  51. patch("backend.app.core.database.async_session", session_maker),
  52. patch("backend.app.main.download_file_async", new=AsyncMock(return_value=False)),
  53. patch("backend.app.main.download_file_try_paths_async", new=AsyncMock(return_value=None)),
  54. patch("backend.app.main.get_cached_3mf", return_value=None),
  55. patch("backend.app.main.cache_3mf_download"),
  56. patch("backend.app.main.peek_plate_index_in_3mf", return_value=None),
  57. patch("backend.app.services.bambu_ftp.list_files_async", new=AsyncMock(return_value=[])),
  58. patch("backend.app.main.ftps_handshake_blocked", return_value=False),
  59. patch("backend.app.main.get_ftp_retry_settings", new=AsyncMock(return_value=(False, 3, 2.0, 30))),
  60. patch("backend.app.main._record_energy_start", new_callable=AsyncMock),
  61. patch("backend.app.main._send_print_start_notification", new_callable=AsyncMock),
  62. patch("backend.app.main._maybe_start_layer_timelapse"),
  63. patch("backend.app.main._capture_timelapse_baseline_at_start", new_callable=AsyncMock),
  64. patch("backend.app.main._schedule_fallback_3mf_retry"),
  65. patch("backend.app.main._store_spoolman_print_data", new_callable=AsyncMock),
  66. patch("backend.app.main._load_objects_from_archive"),
  67. patch("backend.app.services.usage_tracker.on_print_start", new_callable=AsyncMock),
  68. ]
  69. with ExitStack() as stack:
  70. for p in patches:
  71. stack.enter_context(p)
  72. notif = stack.enter_context(patch("backend.app.main.notification_service"))
  73. plug = stack.enter_context(patch("backend.app.main.smart_plug_manager"))
  74. ws = stack.enter_context(patch("backend.app.main.ws_manager"))
  75. relay = stack.enter_context(patch("backend.app.main.mqtt_relay"))
  76. pm = stack.enter_context(patch("backend.app.main.printer_manager"))
  77. notif.on_print_start = AsyncMock()
  78. plug.on_print_start = AsyncMock()
  79. ws.send_print_start = AsyncMock()
  80. ws.send_archive_created = AsyncMock()
  81. ws.send_archive_updated = AsyncMock()
  82. relay.on_print_start = AsyncMock()
  83. relay.on_archive_created = AsyncMock()
  84. pm.get_status = MagicMock(return_value=state)
  85. pm.get_printer = MagicMock(return_value=MagicMock(serial_number="TESTAP2"))
  86. pm.get_client = MagicMock(
  87. return_value=MagicMock(last_dispatch_subtask_id=last_dispatch) if last_dispatch else None
  88. )
  89. from backend.app.main import on_print_start
  90. await on_print_start(
  91. printer_id, {"filename": DISPATCH, "subtask_name": SUBTASK, "raw_data": {"subtask_id": subtask_id}}
  92. )
  93. async def _stuck_archive(db_session, printer_id: int, subtask_id: str | None) -> int:
  94. archive = PrintArchive(
  95. printer_id=printer_id,
  96. filename=f"{SUBTASK}.gcode.3mf",
  97. file_path=f"archives/test/{SUBTASK}.gcode.3mf",
  98. file_size=2048,
  99. print_name=SUBTASK,
  100. status="printing",
  101. subtask_id=subtask_id,
  102. started_at=datetime.now(timezone.utc) - timedelta(minutes=30),
  103. )
  104. db_session.add(archive)
  105. await db_session.commit()
  106. return archive.id
  107. async def _archives(db_session, printer_id: int) -> list[PrintArchive]:
  108. db_session.expire_all()
  109. result = await db_session.execute(
  110. select(PrintArchive).where(PrintArchive.printer_id == printer_id).order_by(PrintArchive.id)
  111. )
  112. return list(result.scalars().all())
  113. @pytest.mark.asyncio
  114. @pytest.mark.integration
  115. async def test_another_subtask_id_is_a_new_print(test_engine, db_session, printer_factory):
  116. printer = await printer_factory()
  117. stuck_id = await _stuck_archive(db_session, printer.id, "111")
  118. _active_prints[(printer.id, f"{SUBTASK}.gcode.3mf")] = stuck_id
  119. _active_prints[(printer.id, "old/plate_1.gcode")] = stuck_id
  120. await _start(test_engine, printer.id, "222")
  121. rows = await _archives(db_session, printer.id)
  122. stuck = next(a for a in rows if a.id == stuck_id)
  123. assert (stuck.status, stuck.failure_reason) == ("cancelled", "noStatusUpdate")
  124. new = [a for a in rows if a.id != stuck_id]
  125. assert len(new) == 1 and new[0].subtask_id == "222"
  126. assert stuck_id not in _active_prints.values()
  127. @pytest.mark.asyncio
  128. @pytest.mark.integration
  129. async def test_a_queue_reprint_of_the_archive_resumes_it(test_engine, db_session, printer_factory):
  130. printer = await printer_factory()
  131. stuck_id = await _stuck_archive(db_session, printer.id, "111")
  132. db_session.add(PrintQueueItem(printer_id=printer.id, archive_id=stuck_id, status="printing"))
  133. await db_session.commit()
  134. await _start(test_engine, printer.id, "222")
  135. rows = await _archives(db_session, printer.id)
  136. assert [(a.id, a.status, a.subtask_id) for a in rows] == [(stuck_id, "printing", "222")]
  137. @pytest.mark.asyncio
  138. @pytest.mark.integration
  139. async def test_an_archive_without_a_subtask_id_is_resumed(test_engine, db_session, printer_factory):
  140. printer = await printer_factory()
  141. stuck_id = await _stuck_archive(db_session, printer.id, None)
  142. await _start(test_engine, printer.id, "222")
  143. rows = await _archives(db_session, printer.id)
  144. assert [(a.id, a.status, a.subtask_id) for a in rows] == [(stuck_id, "printing", "222")]
  145. @pytest.mark.asyncio
  146. @pytest.mark.integration
  147. async def test_a_cancelled_archive_with_the_same_subtask_id_is_not_reopened(test_engine, db_session, printer_factory):
  148. """Completion already charged and logged it; reopening would do it twice."""
  149. printer = await printer_factory()
  150. old_id = await _stuck_archive(db_session, printer.id, "333")
  151. old = await db_session.get(PrintArchive, old_id)
  152. old.status, old.failure_reason = "cancelled", "noStatusUpdate"
  153. await db_session.commit()
  154. await _start(test_engine, printer.id, "333")
  155. rows = await _archives(db_session, printer.id)
  156. assert next(a for a in rows if a.id == old_id).status == "cancelled"
  157. assert [a.subtask_id for a in rows if a.id != old_id] == ["333"]
  158. assert old_id not in _active_prints.values()
  159. async def _queue_item(db_session, printer_id: int, archive_id: int, status: str = "printing") -> None:
  160. db_session.add(PrintQueueItem(printer_id=printer_id, archive_id=archive_id, status=status))
  161. await db_session.commit()
  162. @pytest.mark.asyncio
  163. @pytest.mark.integration
  164. async def test_a_queue_job_left_printing_does_not_take_another_print(test_engine, db_session, printer_factory):
  165. """The archive's queue job stayed 'printing' with it, but Bambuddy's last
  166. dispatch to this printer was another job id: this print is not that run."""
  167. printer = await printer_factory()
  168. stuck_id = await _stuck_archive(db_session, printer.id, "111")
  169. await _queue_item(db_session, printer.id, stuck_id)
  170. await _start(test_engine, printer.id, "222", last_dispatch="111")
  171. rows = await _archives(db_session, printer.id)
  172. assert next(a for a in rows if a.id == stuck_id).status == "cancelled"
  173. assert [a.subtask_id for a in rows if a.id != stuck_id] == ["222"]
  174. @pytest.mark.asyncio
  175. @pytest.mark.integration
  176. async def test_the_dispatched_job_id_resumes_without_a_queue_lookup(test_engine, db_session, printer_factory):
  177. printer = await printer_factory()
  178. stuck_id = await _stuck_archive(db_session, printer.id, "111")
  179. await _queue_item(db_session, printer.id, stuck_id)
  180. await _start(test_engine, printer.id, "222", last_dispatch="222")
  181. rows = await _archives(db_session, printer.id)
  182. assert [(a.id, a.status, a.subtask_id) for a in rows] == [(stuck_id, "printing", "222")]
  183. @pytest.mark.asyncio
  184. @pytest.mark.integration
  185. @pytest.mark.parametrize("elsewhere", ["other_printer", "finished"])
  186. async def test_only_this_printers_running_queue_job_exempts(test_engine, db_session, printer_factory, elsewhere):
  187. printer = await printer_factory()
  188. stuck_id = await _stuck_archive(db_session, printer.id, "111")
  189. if elsewhere == "other_printer":
  190. other = await printer_factory()
  191. await _queue_item(db_session, other.id, stuck_id)
  192. else:
  193. await _queue_item(db_session, printer.id, stuck_id, status="completed")
  194. await _start(test_engine, printer.id, "222")
  195. rows = await _archives(db_session, printer.id)
  196. assert next(a for a in rows if a.id == stuck_id).status == "cancelled"