test_timelapse_baseline_restart_recovery.py 6.3 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161
  1. """Regression for #1485 follow-up: timelapse baseline on restart-recovery.
  2. When Bambuddy restarts mid-print, the first MQTT push has
  3. ``_previous_gcode_state = None`` which the #1304 guard treats as "first push
  4. after Bambuddy startup, don't fire on_print_start" — avoiding duplicate
  5. archive creation. But that path is also where ``_capture_timelapse_baseline_at_start``
  6. lives, so without a separate hook the baseline is never captured. The
  7. completion-time scan then falls into its "take baseline now" fallback
  8. that snapshots the SD card AFTER the in-flight MP4 has landed, the new
  9. file ends up in the baseline set, and no diff ever matches.
  10. bambu_mqtt.py:_process_message now fires a sibling ``on_print_running_observed``
  11. callback in this case. main.py wires it to ``on_print_running_observed``
  12. which captures the baseline. These tests verify that handler.
  13. """
  14. from unittest.mock import AsyncMock, MagicMock, patch
  15. import pytest
  16. from backend.app.main import _timelapse_baselines
  17. @pytest.fixture(autouse=True)
  18. def _clear_baselines():
  19. _timelapse_baselines.clear()
  20. yield
  21. _timelapse_baselines.clear()
  22. @pytest.mark.asyncio
  23. async def test_running_observed_captures_baseline_on_restart_recovery():
  24. """The handler must capture the printer's existing-videos snapshot so
  25. the completion-time scan has something to set-diff against. This is
  26. the case the in-the-field pwostran report (#1485) hits: pre-reboot
  27. baseline of 7 files lost on restart, post-reboot fallback baseline
  28. sees the 8 files (including the just-uploaded one) → no new file."""
  29. mock_printer = MagicMock()
  30. mock_printer.id = 1
  31. mock_printer.name = "TestP1S"
  32. mock_printer.ip_address = "192.168.1.100"
  33. mock_printer.access_code = "12345678"
  34. mock_printer.model = "P1S"
  35. existing_videos = [
  36. {"name": "earlier_a.mp4", "is_directory": False, "path": "/timelapse/earlier_a.mp4"},
  37. {"name": "earlier_b.mp4", "is_directory": False, "path": "/timelapse/earlier_b.mp4"},
  38. {"name": "earlier_c.mp4", "is_directory": False, "path": "/timelapse/earlier_c.mp4"},
  39. ]
  40. def execute_router(stmt, *args, **kwargs):
  41. return MagicMock(scalar_one_or_none=MagicMock(return_value=mock_printer))
  42. mock_session = AsyncMock()
  43. mock_session.__aenter__ = AsyncMock(return_value=mock_session)
  44. mock_session.__aexit__ = AsyncMock()
  45. mock_session.execute = AsyncMock(side_effect=execute_router)
  46. with (
  47. patch("backend.app.main.async_session") as mock_session_maker,
  48. patch(
  49. "backend.app.main._list_timelapse_videos",
  50. new=AsyncMock(return_value=(existing_videos, "/timelapse")),
  51. ),
  52. ):
  53. mock_session_maker.return_value = mock_session
  54. from backend.app.main import on_print_running_observed
  55. await on_print_running_observed(
  56. 1,
  57. {
  58. "filename": "/data/Metadata/test_print.gcode",
  59. "subtask_name": "Test_Print",
  60. "remaining_time": 3600,
  61. "raw_data": {},
  62. "ams_mapping": None,
  63. },
  64. )
  65. # Snapshot the dict state immediately after the handler returns —
  66. # don't rely on _timelapse_baselines surviving outside the patches.
  67. # CI intermittently saw the dict empty by the time a later top-level
  68. # assert ran (likely an xdist-parallel teardown race on the session-
  69. # scoped event_loop fixture in conftest.py). Capturing the value here
  70. # is what the test actually wants to verify anyway: the handler set
  71. # the baseline at the moment it returned.
  72. captured = _timelapse_baselines.get(1)
  73. assert captured == {"earlier_a.mp4", "earlier_b.mp4", "earlier_c.mp4"}, (
  74. "restart-recovery handler must capture the printer's existing-videos "
  75. "baseline so the completion-time scan can set-diff to find the new file"
  76. )
  77. @pytest.mark.asyncio
  78. async def test_running_observed_skips_when_baseline_already_present():
  79. """If on_print_start already ran in this Bambuddy process for the same
  80. printer (the realistic same-session race), a second capture would
  81. overwrite the correct pre-print baseline with one taken later — which
  82. could include the in-flight MP4. Skip when a baseline exists."""
  83. _timelapse_baselines[1] = {"pre_existing_a.mp4", "pre_existing_b.mp4"}
  84. with (
  85. patch("backend.app.main.async_session") as mock_session_maker,
  86. patch("backend.app.main._list_timelapse_videos", new=AsyncMock()) as mock_list,
  87. ):
  88. from backend.app.main import on_print_running_observed
  89. await on_print_running_observed(
  90. 1,
  91. {
  92. "filename": "/data/Metadata/test_print.gcode",
  93. "subtask_name": "Test_Print",
  94. "remaining_time": 3600,
  95. "raw_data": {},
  96. "ams_mapping": None,
  97. },
  98. )
  99. # Neither the DB lookup nor the FTP scan should have run.
  100. mock_session_maker.assert_not_called()
  101. mock_list.assert_not_called()
  102. # Original baseline preserved.
  103. assert _timelapse_baselines[1] == {"pre_existing_a.mp4", "pre_existing_b.mp4"}
  104. @pytest.mark.asyncio
  105. async def test_running_observed_skips_when_printer_row_missing():
  106. """If the printer was deleted between the MQTT push and this handler
  107. running, we can't capture anything — log and return without raising."""
  108. mock_session = AsyncMock()
  109. mock_session.__aenter__ = AsyncMock(return_value=mock_session)
  110. mock_session.__aexit__ = AsyncMock()
  111. mock_session.execute = AsyncMock(return_value=MagicMock(scalar_one_or_none=MagicMock(return_value=None)))
  112. with (
  113. patch("backend.app.main.async_session") as mock_session_maker,
  114. patch("backend.app.main._list_timelapse_videos", new=AsyncMock()) as mock_list,
  115. ):
  116. mock_session_maker.return_value = mock_session
  117. from backend.app.main import on_print_running_observed
  118. # Should not raise.
  119. await on_print_running_observed(
  120. 999,
  121. {
  122. "filename": "/data/Metadata/test_print.gcode",
  123. "subtask_name": "Test_Print",
  124. "remaining_time": 3600,
  125. "raw_data": {},
  126. "ams_mapping": None,
  127. },
  128. )
  129. # FTP scan must not run if the printer row didn't resolve.
  130. mock_list.assert_not_called()
  131. assert 999 not in _timelapse_baselines