test_timelapse_baseline_restart_recovery.py 5.8 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152
  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. assert _timelapse_baselines.get(1) == {"earlier_a.mp4", "earlier_b.mp4", "earlier_c.mp4"}, (
  66. "restart-recovery handler must capture the printer's existing-videos "
  67. "baseline so the completion-time scan can set-diff to find the new file"
  68. )
  69. @pytest.mark.asyncio
  70. async def test_running_observed_skips_when_baseline_already_present():
  71. """If on_print_start already ran in this Bambuddy process for the same
  72. printer (the realistic same-session race), a second capture would
  73. overwrite the correct pre-print baseline with one taken later — which
  74. could include the in-flight MP4. Skip when a baseline exists."""
  75. _timelapse_baselines[1] = {"pre_existing_a.mp4", "pre_existing_b.mp4"}
  76. with (
  77. patch("backend.app.main.async_session") as mock_session_maker,
  78. patch("backend.app.main._list_timelapse_videos", new=AsyncMock()) as mock_list,
  79. ):
  80. from backend.app.main import on_print_running_observed
  81. await on_print_running_observed(
  82. 1,
  83. {
  84. "filename": "/data/Metadata/test_print.gcode",
  85. "subtask_name": "Test_Print",
  86. "remaining_time": 3600,
  87. "raw_data": {},
  88. "ams_mapping": None,
  89. },
  90. )
  91. # Neither the DB lookup nor the FTP scan should have run.
  92. mock_session_maker.assert_not_called()
  93. mock_list.assert_not_called()
  94. # Original baseline preserved.
  95. assert _timelapse_baselines[1] == {"pre_existing_a.mp4", "pre_existing_b.mp4"}
  96. @pytest.mark.asyncio
  97. async def test_running_observed_skips_when_printer_row_missing():
  98. """If the printer was deleted between the MQTT push and this handler
  99. running, we can't capture anything — log and return without raising."""
  100. mock_session = AsyncMock()
  101. mock_session.__aenter__ = AsyncMock(return_value=mock_session)
  102. mock_session.__aexit__ = AsyncMock()
  103. mock_session.execute = AsyncMock(return_value=MagicMock(scalar_one_or_none=MagicMock(return_value=None)))
  104. with (
  105. patch("backend.app.main.async_session") as mock_session_maker,
  106. patch("backend.app.main._list_timelapse_videos", new=AsyncMock()) as mock_list,
  107. ):
  108. mock_session_maker.return_value = mock_session
  109. from backend.app.main import on_print_running_observed
  110. # Should not raise.
  111. await on_print_running_observed(
  112. 999,
  113. {
  114. "filename": "/data/Metadata/test_print.gcode",
  115. "subtask_name": "Test_Print",
  116. "remaining_time": 3600,
  117. "raw_data": {},
  118. "ams_mapping": None,
  119. },
  120. )
  121. # FTP scan must not run if the printer row didn't resolve.
  122. mock_list.assert_not_called()
  123. assert 999 not in _timelapse_baselines