test_sd_cleanup_file_in_use_3009.py 4.6 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110
  1. """The post-print SD cleanup never deletes the file the printer is printing (#3009).
  2. The cleanup works out what to delete from the finished archive. When
  3. reconciliation closes an old archive because the printer has started a new
  4. job, and that job is the same file reprinted from the printer's screen, the
  5. cleanup deleted the file of the print that was running.
  6. """
  7. import logging
  8. from pathlib import PurePosixPath
  9. from types import SimpleNamespace
  10. from unittest.mock import AsyncMock, MagicMock, patch
  11. import pytest
  12. from backend.app.main import _cleanup_sd_card_after_print, _sd_files_in_use
  13. from backend.app.services.bambu_ftp import DeleteResult
  14. def _state(state="RUNNING", gcode_file="", subtask_name=""):
  15. return SimpleNamespace(state=state, gcode_file=gcode_file, subtask_name=subtask_name)
  16. class TestFilesInUse:
  17. @pytest.mark.parametrize("state", ["IDLE", "FINISH", "FAILED", "", "unknown"])
  18. def test_nothing_is_held_back_when_the_printer_is_not_busy(self, state):
  19. assert _sd_files_in_use(_state(state, "cube.3mf", "cube")) == set()
  20. def test_no_state(self):
  21. assert _sd_files_in_use(None) == set()
  22. @pytest.mark.parametrize("state", ["RUNNING", "PAUSE", "PREPARE", "SLICING"])
  23. def test_busy_states(self, state):
  24. assert "cube.3mf" in _sd_files_in_use(_state(state, "cube.3mf"))
  25. @pytest.mark.parametrize(
  26. "gcode_file",
  27. [
  28. "Cube_Part.3mf",
  29. "/Cube_Part.3mf",
  30. "/sdcard/Cube_Part.3mf",
  31. "ftp://Cube_Part.3mf",
  32. "file:///sdcard/Cube_Part.3mf",
  33. ],
  34. )
  35. def test_gcode_file_spellings(self, gcode_file):
  36. assert "cube_part.3mf" in _sd_files_in_use(_state(gcode_file=gcode_file))
  37. @pytest.mark.parametrize("name", ["Part #2.3mf", "What?.3mf", "ftp://[odd.3mf"])
  38. def test_awkward_names_are_kept_whole(self, name):
  39. assert PurePosixPath(name.split("://", 1)[-1]).name.lower() in _sd_files_in_use(_state(gcode_file=name))
  40. def test_unexpected_values_do_not_raise(self):
  41. assert _sd_files_in_use(SimpleNamespace(state="RUNNING", gcode_file=None, subtask_name=None)) == set()
  42. assert _sd_files_in_use(SimpleNamespace(state=MagicMock(), gcode_file=MagicMock(), subtask_name=1)) == set()
  43. def test_job_name_covers_firmware_that_reports_only_the_plate_gcode(self):
  44. in_use = _sd_files_in_use(_state(gcode_file="/data/Metadata/plate_1.gcode", subtask_name="My Cube"))
  45. assert {"my cube.3mf", "my_cube.3mf", "my cube.gcode", "my_cube.gcode"} <= in_use
  46. async def _run_cleanup(state, *, archive_filename, subtask_name):
  47. printer = SimpleNamespace(id=1, name="A1", ip_address="192.0.2.10", access_code="12345678", model="A1")
  48. db = AsyncMock()
  49. db.execute = AsyncMock(
  50. side_effect=[
  51. MagicMock(scalar_one_or_none=MagicMock(return_value=printer)),
  52. MagicMock(scalar_one_or_none=MagicMock(return_value=archive_filename)),
  53. ]
  54. )
  55. session_ctx = AsyncMock()
  56. session_ctx.__aenter__ = AsyncMock(return_value=db)
  57. session_ctx.__aexit__ = AsyncMock(return_value=False)
  58. manager = MagicMock()
  59. manager.get_status = MagicMock(return_value=state)
  60. delete = AsyncMock(return_value=DeleteResult.DELETED)
  61. with (
  62. patch("backend.app.main.async_session", MagicMock(return_value=session_ctx)),
  63. patch("backend.app.main.printer_manager", manager),
  64. patch("backend.app.services.bambu_ftp.delete_file_async", delete),
  65. ):
  66. await _cleanup_sd_card_after_print(1, subtask_name, 342, logging.getLogger("test"))
  67. return [call.args[2] for call in delete.await_args_list]
  68. @pytest.mark.asyncio
  69. async def test_reprint_from_the_printer_keeps_its_file():
  70. """The reporter's case: the A1 reprints the file from its screen, startup
  71. reconciliation closes the old archive and runs the cleanup."""
  72. state = _state("RUNNING", "A1-Siraya_Tech_TPU-64D-18JAN26.3mf", "A1-Siraya Tech TPU-64D-18JAN26")
  73. deleted = await _run_cleanup(
  74. state,
  75. archive_filename="A1-Siraya Tech TPU-64D-18JAN26.gcode.3mf",
  76. subtask_name="A1-Siraya Tech TPU-64D-18JAN26",
  77. )
  78. assert "/A1-Siraya_Tech_TPU-64D-18JAN26.3mf" not in deleted
  79. @pytest.mark.asyncio
  80. async def test_a_finished_print_is_still_deleted():
  81. state = _state("FINISH", "cube.3mf", "cube")
  82. deleted = await _run_cleanup(state, archive_filename="cube.gcode.3mf", subtask_name="cube")
  83. assert "/cube.3mf" in deleted
  84. @pytest.mark.asyncio
  85. async def test_a_different_running_job_does_not_protect_the_finished_file():
  86. state = _state("RUNNING", "other.3mf", "other")
  87. deleted = await _run_cleanup(state, archive_filename="cube.gcode.3mf", subtask_name="cube")
  88. assert "/cube.3mf" in deleted