test_finish_photo_moment_sync.py 6.8 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208
  1. """Regression tests for the #1790 producer-consumer synchronization.
  2. `on_finish_photo_moment` (producer) and `_background_finish_photo`
  3. (consumer) are dispatched back-to-back on the FINISH-state fallback path
  4. (`bambu_mqtt.py:3258-3297`). Before #1790, the consumer ran a single
  5. `pop()` on `_stage22_finish_frames` with no wait — racing past the
  6. producer with an empty result, then doing its own RTSP grab that
  7. collided with the producer's still-in-flight grab (Bambu printers allow
  8. one RTSP client). Net result: a captured frame was logged, the cache
  9. was populated ~1s later, but the notification went text-only.
  10. The fix is an `asyncio.Event` per printer registered in
  11. `_stage22_finish_in_flight` by the producer and awaited (with timeout)
  12. by the consumer. These tests pin the producer side of that contract.
  13. """
  14. import asyncio
  15. from contextlib import asynccontextmanager
  16. from types import SimpleNamespace
  17. from unittest.mock import AsyncMock
  18. import pytest
  19. from backend.app import main as main_module
  20. from backend.app.main import on_finish_photo_moment
  21. @asynccontextmanager
  22. async def _fake_session(printer):
  23. """Async-session stub that returns `printer` from scalar_one_or_none()."""
  24. result = SimpleNamespace(scalar_one_or_none=lambda: printer)
  25. session = SimpleNamespace(execute=AsyncMock(return_value=result))
  26. yield session
  27. @pytest.fixture
  28. def fake_printer():
  29. return SimpleNamespace(
  30. id=7,
  31. ip_address="192.0.2.7",
  32. access_code="x",
  33. model="X1C",
  34. external_camera_enabled=False,
  35. external_camera_url=None,
  36. external_camera_type=None,
  37. external_camera_snapshot_url=None,
  38. )
  39. @pytest.fixture(autouse=True)
  40. def _clean_state():
  41. """Don't leak event/cache dict entries across tests."""
  42. main_module._stage22_finish_in_flight.clear()
  43. main_module._stage22_finish_frames.clear()
  44. yield
  45. main_module._stage22_finish_in_flight.clear()
  46. main_module._stage22_finish_frames.clear()
  47. @pytest.fixture
  48. def patched_env(fake_printer, monkeypatch):
  49. monkeypatch.setattr(main_module, "async_session", lambda: _fake_session(fake_printer))
  50. async def _get_setting(_db, key):
  51. if key == "capture_finish_photo":
  52. return "true"
  53. return None
  54. monkeypatch.setattr(
  55. "backend.app.api.routes.settings.get_setting",
  56. _get_setting,
  57. )
  58. monkeypatch.setattr(
  59. "backend.app.api.routes.camera.get_buffered_frame",
  60. lambda _pid: None,
  61. )
  62. return fake_printer
  63. async def test_event_registered_before_first_await(patched_env, monkeypatch):
  64. """The consumer needs to find the event the moment it polls — that
  65. means registration must complete BEFORE any `await` yields control
  66. back to the loop."""
  67. # Slow the first await (DB session entry) so we can observe the dict
  68. # before the producer makes any real progress.
  69. seen_during_capture = {}
  70. async def _slow_capture(**_kwargs):
  71. seen_during_capture["registered"] = patched_env.id in main_module._stage22_finish_in_flight
  72. await asyncio.sleep(0)
  73. return b"\xff\xd8frame"
  74. monkeypatch.setattr(
  75. "backend.app.services.camera.capture_camera_frame_bytes",
  76. _slow_capture,
  77. )
  78. await on_finish_photo_moment(patched_env.id, {"trigger": "finish_state"})
  79. assert seen_during_capture["registered"] is True
  80. async def test_event_set_after_successful_capture(patched_env, monkeypatch):
  81. async def _capture(**_kwargs):
  82. return b"\xff\xd8frame"
  83. monkeypatch.setattr(
  84. "backend.app.services.camera.capture_camera_frame_bytes",
  85. _capture,
  86. )
  87. await on_finish_photo_moment(patched_env.id, {"trigger": "finish_state"})
  88. event = main_module._stage22_finish_in_flight[patched_env.id]
  89. assert event.is_set()
  90. assert main_module._stage22_finish_frames[patched_env.id] == b"\xff\xd8frame"
  91. async def test_event_set_when_capture_returns_no_frame(patched_env, monkeypatch):
  92. """Producer gives up (RTSP timeout, no buffered frame, no external
  93. camera) — consumer must NOT wait the full 20s for nothing."""
  94. async def _capture(**_kwargs):
  95. return None
  96. monkeypatch.setattr(
  97. "backend.app.services.camera.capture_camera_frame_bytes",
  98. _capture,
  99. )
  100. await on_finish_photo_moment(patched_env.id, {"trigger": "finish_state"})
  101. event = main_module._stage22_finish_in_flight[patched_env.id]
  102. assert event.is_set()
  103. assert patched_env.id not in main_module._stage22_finish_frames
  104. async def test_event_set_even_when_capture_raises(patched_env, monkeypatch):
  105. """Producer hit a bug or network error — `finally` still has to
  106. release the consumer."""
  107. async def _capture(**_kwargs):
  108. raise RuntimeError("camera went away")
  109. monkeypatch.setattr(
  110. "backend.app.services.camera.capture_camera_frame_bytes",
  111. _capture,
  112. )
  113. await on_finish_photo_moment(patched_env.id, {"trigger": "finish_state"})
  114. event = main_module._stage22_finish_in_flight[patched_env.id]
  115. assert event.is_set()
  116. async def test_no_event_when_timelapse_was_active(patched_env):
  117. """On the timelapse-on path the consumer takes the
  118. `_capture_finish_photo_from_timelapse` branch and shouldn't be
  119. blocked by a producer wait — the producer doesn't enter the
  120. lifecycle."""
  121. await on_finish_photo_moment(
  122. patched_env.id,
  123. {"trigger": "stage_22", "timelapse_was_active": True},
  124. )
  125. assert patched_env.id not in main_module._stage22_finish_in_flight
  126. async def test_event_set_when_capture_setting_disabled(patched_env, monkeypatch):
  127. """Even on the early-return-before-capture path, the event must be
  128. released so the consumer doesn't hang on a no-op producer."""
  129. async def _disabled_setting(_db, _key):
  130. return "false"
  131. monkeypatch.setattr(
  132. "backend.app.api.routes.settings.get_setting",
  133. _disabled_setting,
  134. )
  135. await on_finish_photo_moment(patched_env.id, {"trigger": "finish_state"})
  136. event = main_module._stage22_finish_in_flight[patched_env.id]
  137. assert event.is_set()
  138. async def test_consumer_wait_unblocked_when_producer_completes(patched_env, monkeypatch):
  139. """End-to-end sync check: a consumer-style waiter awaiting the
  140. event finishes promptly once the producer's finally fires."""
  141. async def _capture(**_kwargs):
  142. await asyncio.sleep(0.05)
  143. return b"\xff\xd8frame"
  144. monkeypatch.setattr(
  145. "backend.app.services.camera.capture_camera_frame_bytes",
  146. _capture,
  147. )
  148. producer = asyncio.create_task(on_finish_photo_moment(patched_env.id, {"trigger": "finish_state"}))
  149. await asyncio.sleep(0) # let the producer register
  150. event = main_module._stage22_finish_in_flight[patched_env.id]
  151. await asyncio.wait_for(event.wait(), timeout=1.0)
  152. assert main_module._stage22_finish_frames[patched_env.id] == b"\xff\xd8frame"
  153. await producer