test_timelapse_baseline_restart_recovery.py 6.5 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164
  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. from backend.app.services.bambu_ftp import FileListResult
  18. @pytest.fixture(autouse=True)
  19. def _clear_baselines():
  20. _timelapse_baselines.clear()
  21. yield
  22. _timelapse_baselines.clear()
  23. @pytest.mark.asyncio
  24. async def test_running_observed_captures_baseline_on_restart_recovery():
  25. """The handler must capture the printer's existing-videos snapshot so
  26. the completion-time scan has something to set-diff against. This is
  27. the case the in-the-field pwostran report (#1485) hits: pre-reboot
  28. baseline of 7 files lost on restart, post-reboot fallback baseline
  29. sees the 8 files (including the just-uploaded one) → no new file."""
  30. mock_printer = MagicMock()
  31. mock_printer.id = 1
  32. mock_printer.name = "TestP1S"
  33. mock_printer.ip_address = "192.168.1.100"
  34. mock_printer.access_code = "12345678"
  35. mock_printer.model = "P1S"
  36. existing_videos = [
  37. {"name": "earlier_a.mp4", "is_directory": False, "path": "/timelapse/earlier_a.mp4"},
  38. {"name": "earlier_b.mp4", "is_directory": False, "path": "/timelapse/earlier_b.mp4"},
  39. {"name": "earlier_c.mp4", "is_directory": False, "path": "/timelapse/earlier_c.mp4"},
  40. ]
  41. def execute_router(stmt, *args, **kwargs):
  42. return MagicMock(scalar_one_or_none=MagicMock(return_value=mock_printer))
  43. mock_session = AsyncMock()
  44. mock_session.__aenter__ = AsyncMock(return_value=mock_session)
  45. mock_session.__aexit__ = AsyncMock()
  46. mock_session.execute = AsyncMock(side_effect=execute_router)
  47. with (
  48. patch("backend.app.main.async_session") as mock_session_maker,
  49. patch(
  50. "backend.app.services.bambu_ftp.list_files_result_async",
  51. new=AsyncMock(return_value=FileListResult(existing_videos, True)),
  52. ),
  53. ):
  54. mock_session_maker.return_value = mock_session
  55. from backend.app.main import on_print_running_observed
  56. await on_print_running_observed(
  57. 1,
  58. {
  59. "filename": "/data/Metadata/test_print.gcode",
  60. "subtask_name": "Test_Print",
  61. "remaining_time": 3600,
  62. "raw_data": {},
  63. "ams_mapping": None,
  64. },
  65. )
  66. # Snapshot the dict state immediately after the handler returns —
  67. # don't rely on _timelapse_baselines surviving outside the patches.
  68. # CI intermittently saw the dict empty by the time a later top-level
  69. # assert ran (likely an xdist-parallel teardown race on the session-
  70. # scoped event_loop fixture in conftest.py). Capturing the value here
  71. # is what the test actually wants to verify anyway: the handler set
  72. # the baseline at the moment it returned.
  73. captured = _timelapse_baselines.get(1)
  74. assert captured == {"earlier_a.mp4", "earlier_b.mp4", "earlier_c.mp4"}, (
  75. "restart-recovery handler must capture the printer's existing-videos "
  76. "baseline so the completion-time scan can set-diff to find the new file"
  77. )
  78. @pytest.mark.asyncio
  79. async def test_running_observed_skips_when_baseline_already_present():
  80. """If on_print_start already ran in this Bambuddy process for the same
  81. printer (the realistic same-session race), a second capture would
  82. overwrite the correct pre-print baseline with one taken later — which
  83. could include the in-flight MP4. The archive lookup still has to run so
  84. restart recovery can restore durable print ownership for the kill switch."""
  85. _timelapse_baselines[1] = {"pre_existing_a.mp4", "pre_existing_b.mp4"}
  86. with (
  87. patch("backend.app.main.async_session") as mock_session_maker,
  88. patch("backend.app.main._read_timelapse_baseline", new=AsyncMock()) as mock_list,
  89. ):
  90. from backend.app.main import on_print_running_observed
  91. await on_print_running_observed(
  92. 1,
  93. {
  94. "filename": "/data/Metadata/test_print.gcode",
  95. "subtask_name": "Test_Print",
  96. "remaining_time": 3600,
  97. "raw_data": {},
  98. "ams_mapping": None,
  99. },
  100. )
  101. # Ownership reconciliation still consults the DB, but the expensive
  102. # timelapse scan remains one-shot.
  103. mock_session_maker.assert_called_once()
  104. mock_list.assert_not_called()
  105. # Original baseline preserved.
  106. assert _timelapse_baselines[1] == {"pre_existing_a.mp4", "pre_existing_b.mp4"}
  107. @pytest.mark.asyncio
  108. async def test_running_observed_skips_when_printer_row_missing():
  109. """If the printer was deleted between the MQTT push and this handler
  110. running, we can't capture anything — log and return without raising."""
  111. mock_session = AsyncMock()
  112. mock_session.__aenter__ = AsyncMock(return_value=mock_session)
  113. mock_session.__aexit__ = AsyncMock()
  114. mock_session.execute = AsyncMock(return_value=MagicMock(scalar_one_or_none=MagicMock(return_value=None)))
  115. with (
  116. patch("backend.app.main.async_session") as mock_session_maker,
  117. patch("backend.app.main._read_timelapse_baseline", new=AsyncMock()) as mock_list,
  118. ):
  119. mock_session_maker.return_value = mock_session
  120. from backend.app.main import on_print_running_observed
  121. # Should not raise.
  122. await on_print_running_observed(
  123. 999,
  124. {
  125. "filename": "/data/Metadata/test_print.gcode",
  126. "subtask_name": "Test_Print",
  127. "remaining_time": 3600,
  128. "raw_data": {},
  129. "ams_mapping": None,
  130. },
  131. )
  132. # FTP scan must not run if the printer row didn't resolve.
  133. mock_list.assert_not_called()
  134. assert 999 not in _timelapse_baselines