test_upload_progress_bridge.py 3.6 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113
  1. """Throttle contract for the scheduler's upload-progress bridge.
  2. The legacy bg-dispatch path (last seen in
  3. backend/app/services/background_dispatch.py before commit 61c8898b) used:
  4. - 200 ms time gate
  5. - 256 KB byte gate
  6. - always emit on first call and at uploaded >= total
  7. The scheduler-driven dispatch must feel identical to the pre-#1625 path,
  8. so the throttle here mirrors that 1:1.
  9. """
  10. from __future__ import annotations
  11. import asyncio
  12. import pytest
  13. from backend.app.services.print_scheduler import _UploadProgressBridge
  14. @pytest.mark.asyncio
  15. async def test_first_call_always_emits(monkeypatch):
  16. bridge = _UploadProgressBridge(user_id=1, queue_item_id=1)
  17. bridge._loop = asyncio.get_running_loop()
  18. calls: list[tuple[int, int]] = []
  19. def fake_run(coro, loop): # noqa: ARG001
  20. coro.close()
  21. calls.append((1, 1))
  22. monkeypatch.setattr("backend.app.services.print_scheduler.asyncio.run_coroutine_threadsafe", fake_run)
  23. # First chunk, tiny payload — must emit so the user sees something
  24. # even for sub-chunk-size files where the upload finishes inside the
  25. # very first FTP callback.
  26. bridge(8192, 16384)
  27. assert len(calls) == 1
  28. @pytest.mark.asyncio
  29. async def test_emit_at_completion_even_under_throttle_gates(monkeypatch):
  30. bridge = _UploadProgressBridge(user_id=1, queue_item_id=2)
  31. bridge._loop = asyncio.get_running_loop()
  32. calls: list[int] = []
  33. def fake_run(coro, loop): # noqa: ARG001
  34. coro.close()
  35. calls.append(1)
  36. monkeypatch.setattr("backend.app.services.print_scheduler.asyncio.run_coroutine_threadsafe", fake_run)
  37. # First call, force pretend-recent emit so neither time nor byte gate fires.
  38. bridge(50_000, 1_000_000)
  39. bridge._last_emit_monotonic = float("inf") * 0 + 1e18 # implausibly recent
  40. bridge._last_emit_bytes = 50_000
  41. # Mid-upload chunk well under both gates — would normally skip.
  42. bridge(60_000, 1_000_000)
  43. # Completion — must always emit so the bar locks at 100%.
  44. bridge(1_000_000, 1_000_000)
  45. # First + completion. Mid-upload chunk skipped (last_emit_monotonic is
  46. # in the future, byte step is only 10 KB).
  47. assert len(calls) == 2
  48. @pytest.mark.asyncio
  49. async def test_emit_after_256kb_step_even_under_time_gate(monkeypatch):
  50. bridge = _UploadProgressBridge(user_id=1, queue_item_id=3)
  51. bridge._loop = asyncio.get_running_loop()
  52. calls: list[int] = []
  53. def fake_run(coro, loop): # noqa: ARG001
  54. coro.close()
  55. calls.append(1)
  56. monkeypatch.setattr("backend.app.services.print_scheduler.asyncio.run_coroutine_threadsafe", fake_run)
  57. bridge(8192, 10_000_000) # first emit
  58. # Pretend time gate not met but byte gate IS met (256 KB further).
  59. bridge._last_emit_monotonic = 1e18
  60. bridge._last_emit_bytes = 8192
  61. bridge(8192 + 256 * 1024 + 1, 10_000_000)
  62. assert len(calls) == 2
  63. @pytest.mark.asyncio
  64. async def test_no_emit_when_total_bytes_zero(monkeypatch):
  65. bridge = _UploadProgressBridge(user_id=1, queue_item_id=4)
  66. bridge._loop = asyncio.get_running_loop()
  67. calls: list[int] = []
  68. def fake_run(coro, loop): # noqa: ARG001
  69. coro.close()
  70. calls.append(1)
  71. monkeypatch.setattr("backend.app.services.print_scheduler.asyncio.run_coroutine_threadsafe", fake_run)
  72. bridge(0, 0)
  73. bridge(100, 0)
  74. assert calls == []
  75. def test_silent_when_no_running_loop_captured():
  76. """Constructed outside an asyncio loop — the bridge captures None and
  77. every call is a no-op."""
  78. bridge = _UploadProgressBridge(user_id=1, queue_item_id=5)
  79. assert bridge._loop is None
  80. bridge(1, 100) # must not raise