test_kill_switch_owned_prints.py 9.1 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233
  1. """The printer kill switch acts on prints Bambuddy did not start.
  2. Two ways it did not:
  3. - The fast check counted every print in ``_active_prints`` as Bambuddy's, and
  4. ``on_print_start`` puts every print it archives there, those started from
  5. Bambu Studio, Handy or the printer's screen included. A print running when the
  6. switch was turned on, or one archived before the switch looked, was never
  7. stopped.
  8. - A LAN print reports ``subtask_id`` "0". The database check answered "not sure
  9. yet" for it and waited, every frame, until the print was archived and the
  10. first hole let it through.
  11. Bambuddy's own prints are now the ones it dispatched: an expected print, the
  12. print user it recorded, or an archive it adopted for a dispatch or that carries
  13. its dispatch marker. A "0" print is matched to Bambuddy's printing archive on
  14. that printer by file name; if there is none it waits only while a queue job of
  15. Bambuddy's is printing there.
  16. """
  17. from types import SimpleNamespace
  18. from unittest.mock import AsyncMock
  19. import pytest
  20. from backend.app import main as main_module
  21. from backend.app.main import register_expected_print
  22. from backend.app.models.archive import PrintArchive
  23. from backend.tests.integration.test_external_print_confirmation_1898 import ( # noqa: F401
  24. SUBTASK,
  25. _clear_print_state,
  26. _drive_print_start,
  27. )
  28. pytestmark = [pytest.mark.asyncio, pytest.mark.integration]
  29. @pytest.fixture(autouse=True)
  30. def _clear_owned(monkeypatch):
  31. main_module._owned_print_archive_ids.clear()
  32. monkeypatch.setattr(main_module.printer_manager, "get_current_print_user", lambda printer_id: None)
  33. yield
  34. main_module._owned_print_archive_ids.clear()
  35. def _status(subtask_id="0"):
  36. return SimpleNamespace(
  37. current_print=None, subtask_name=SUBTASK, subtask_id=subtask_id, gcode_file=f"{SUBTASK}.gcode.3mf"
  38. )
  39. async def test_a_print_bambuddy_did_not_start_stays_unauthorized_once_archived(test_engine, printer_factory):
  40. """The AP-1 scenario: archived by on_print_start, the print used to pass the
  41. fast check from then on."""
  42. printer_id = (await printer_factory()).id
  43. await _drive_print_start(test_engine, SimpleNamespace(id=printer_id), download_ok=True)
  44. assert any(key[0] == printer_id for key in main_module._active_prints)
  45. assert main_module._is_bambuddy_authorized_print_in_memory(printer_id, _status()) is False
  46. async def test_a_print_bambuddy_dispatched_is_its_own_once_adopted(test_engine, db_session, printer_factory):
  47. printer_id = (await printer_factory()).id
  48. archive = PrintArchive(
  49. printer_id=printer_id,
  50. filename=f"{SUBTASK}.gcode.3mf",
  51. file_path=f"archives/test/{SUBTASK}.gcode.3mf",
  52. file_size=1,
  53. print_name=SUBTASK,
  54. status="pending",
  55. )
  56. db_session.add(archive)
  57. await db_session.commit()
  58. register_expected_print(printer_id, f"{SUBTASK}.gcode.3mf", archive.id)
  59. await _drive_print_start(test_engine, SimpleNamespace(id=printer_id), download_ok=False)
  60. # Without the expected entries (print start consumes the names the printer
  61. # reported; the rest are evicted later), the print is still Bambuddy's.
  62. main_module._expected_prints.clear()
  63. assert archive.id in main_module._owned_print_archive_ids
  64. assert main_module._is_bambuddy_authorized_print_in_memory(printer_id, _status()) is True
  65. def _db(archive, dispatched_queue_item_id=None):
  66. """Both lookups: by subtask id (one row) and, for "0", Bambuddy's printing rows."""
  67. rows = [archive] if archive is not None else []
  68. return SimpleNamespace(
  69. execute=AsyncMock(return_value=SimpleNamespace(scalar_one_or_none=lambda: archive, scalars=lambda: rows)),
  70. scalar=AsyncMock(return_value=dispatched_queue_item_id),
  71. )
  72. def _archive(archive_id, filename, *, marker="run-1", print_name=None):
  73. return SimpleNamespace(
  74. id=archive_id, filename=filename, print_name=print_name, billing_run_id=marker, created_by_id=None
  75. )
  76. async def test_a_lan_print_with_no_dispatch_behind_it_is_unauthorized():
  77. assert await main_module._is_bambuddy_authorized_print(9, _status("0"), _db(None)) is False
  78. async def test_a_lan_print_matching_bambuddys_dispatched_archive_is_its_own():
  79. """After a restart: no id to match, so the file name and the dispatch marker."""
  80. archive = _archive(31, f"{SUBTASK}.gcode.3mf")
  81. assert await main_module._is_bambuddy_authorized_print(9, _status("0"), _db(archive)) is True
  82. assert 31 in main_module._owned_print_archive_ids
  83. async def test_a_lan_print_waits_while_bambuddy_has_a_job_printing_there():
  84. """Nothing of Bambuddy's by that name, but a queue job is printing there."""
  85. assert await main_module._is_bambuddy_authorized_print(9, _status("0"), _db(None, 401)) is None
  86. async def test_a_name_the_printer_echoes_with_underscores_still_matches():
  87. """Bambuddy uploads "My Part" and the printer reports "My_Part"."""
  88. archive = _archive(33, "My Part.gcode.3mf")
  89. state = SimpleNamespace(current_print=None, subtask_name="My_Part", subtask_id="0", gcode_file="My_Part.gcode.3mf")
  90. assert await main_module._is_bambuddy_authorized_print(9, state, _db(archive)) is True
  91. async def test_a_bambuddy_archive_of_another_name_does_not_cover_a_lan_print():
  92. other = _archive(34, "Other_job.gcode.3mf")
  93. assert await main_module._is_bambuddy_authorized_print(9, _status("0"), _db(other)) is False
  94. async def test_only_bambuddys_printing_archives_on_that_printer_are_read():
  95. from sqlalchemy.dialects import sqlite
  96. db = _db(None)
  97. await main_module._is_bambuddy_authorized_print(9, _status("0"), db)
  98. query = db.execute.await_args.args[0]
  99. sql = str(query.compile(dialect=sqlite.dialect(), compile_kwargs={"literal_binds": True}))
  100. assert "print_archives.printer_id = 9" in sql
  101. assert "print_archives.status = 'printing'" in sql
  102. assert "print_archives.billing_run_id IS NOT NULL" in sql
  103. async def test_a_lan_print_that_names_nothing_yet_waits():
  104. state = SimpleNamespace(current_print=None, subtask_name="", subtask_id="0", gcode_file="")
  105. db = _db(None)
  106. assert await main_module._is_bambuddy_authorized_print(9, state, db) is None
  107. db.execute.assert_not_awaited()
  108. async def test_the_printers_built_in_calibration_is_left_alone():
  109. """Bambuddy's Calibrate and the printer's screen play gcode from the
  110. printer's system partition (measured: /usr/etc/print/<model>/...)."""
  111. state = SimpleNamespace(
  112. current_print=None,
  113. subtask_name="auto_cali_for_user_param.gcode",
  114. subtask_id="0",
  115. gcode_file="/usr/etc/print/O1C2/auto_cali_for_user_param.gcode",
  116. )
  117. db = _db(None)
  118. assert await main_module._is_bambuddy_authorized_print(9, state, db) is True
  119. db.execute.assert_not_awaited()
  120. async def test_a_print_named_like_a_calibration_is_still_a_print():
  121. """Bambu Studio's pressure-advance print reports /data/Metadata/plate_1.gcode
  122. and subtask auto_pa_line_calib_mode (measured on an H2C), exactly as a user
  123. print given that name would."""
  124. state = SimpleNamespace(
  125. current_print=None,
  126. subtask_name="pa_line_calib_mode",
  127. subtask_id="0",
  128. gcode_file="/data/Metadata/plate_1.gcode",
  129. )
  130. assert await main_module._is_bambuddy_authorized_print(9, state, _db(None)) is False
  131. async def test_a_lan_print_is_not_covered_by_an_archive_that_has_its_own_id():
  132. from sqlalchemy.dialects import sqlite
  133. db = _db(None)
  134. await main_module._is_bambuddy_authorized_print(9, _status("0"), db)
  135. sql = str(db.execute.await_args.args[0].compile(dialect=sqlite.dialect(), compile_kwargs={"literal_binds": True}))
  136. assert "print_archives.subtask_id IS NULL" in sql
  137. async def test_a_missing_subtask_id_still_waits():
  138. """The first frames after a restart can arrive before the field is filled."""
  139. db = _db(None)
  140. assert await main_module._is_bambuddy_authorized_print(9, _status(None), db) is None
  141. db.execute.assert_not_awaited()
  142. async def test_a_finished_print_is_no_longer_owned():
  143. main_module._owned_print_archive_ids.add(41)
  144. main_module._active_prints[(9, f"{SUBTASK}.gcode.3mf")] = 41
  145. main_module._active_prints.clear()
  146. main_module._evict_stale_expected_prints()
  147. assert 41 not in main_module._owned_print_archive_ids
  148. @pytest.mark.parametrize(("marker", "owned"), [("run-7", True), (None, False)])
  149. async def test_a_print_resumed_after_a_restart_is_owned_only_with_bambuddys_marker(
  150. test_engine, db_session, printer_factory, marker, owned
  151. ):
  152. """on_print_start resumes the printing archive it finds by name; that
  153. archive is Bambuddy's only if Bambuddy dispatched it."""
  154. printer_id = (await printer_factory()).id
  155. archive = PrintArchive(
  156. printer_id=printer_id,
  157. filename=f"{SUBTASK}.gcode.3mf",
  158. file_path=f"archives/test/{SUBTASK}.gcode.3mf",
  159. file_size=1,
  160. print_name=SUBTASK,
  161. status="printing",
  162. billing_run_id=marker,
  163. )
  164. db_session.add(archive)
  165. await db_session.commit()
  166. await _drive_print_start(
  167. test_engine, SimpleNamespace(id=printer_id), download_ok=False, state_fields={"progress": 40}
  168. )
  169. assert (archive.id in main_module._owned_print_archive_ids) is owned