test_slice_dispatch_progress.py 3.3 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109
  1. """Tests for SliceDispatchService.set_progress.
  2. The dispatcher exposes set_progress so the slice-route's parallel poller
  3. (spawned alongside the blocking sidecar slice request) can publish
  4. ``{stage, total_percent, plate_index, plate_count}`` snapshots that the
  5. status-poll endpoint surfaces to the UI's persistent progress toast.
  6. """
  7. from __future__ import annotations
  8. import asyncio
  9. import pytest
  10. from backend.app.services.slice_dispatch import SliceDispatchService
  11. @pytest.mark.asyncio
  12. async def test_set_progress_attaches_snapshot_to_running_job():
  13. dispatcher = SliceDispatchService()
  14. started = asyncio.Event()
  15. release = asyncio.Event()
  16. async def runner(job_id: int) -> dict:
  17. started.set()
  18. # Hold the job in the running state until the test releases it.
  19. await release.wait()
  20. return {"library_file_id": 1}
  21. job = await dispatcher.enqueue(
  22. kind="library_file",
  23. source_id=1,
  24. source_name="x.stl",
  25. run=runner,
  26. )
  27. await started.wait()
  28. # Without progress published yet, the job's progress is None.
  29. assert dispatcher.get(job.id) is not None
  30. assert dispatcher.get(job.id).progress is None
  31. # First snapshot lands on the job.
  32. dispatcher.set_progress(
  33. job.id,
  34. {"stage": "Detecting perimeters", "total_percent": 12},
  35. )
  36. snap = dispatcher.get(job.id).progress
  37. assert snap == {"stage": "Detecting perimeters", "total_percent": 12}
  38. # Second snapshot replaces, doesn't merge — the dispatcher just
  39. # holds the latest frame; the sidecar's pipe protocol always emits
  40. # the full set, so partial-frame merging would be wrong.
  41. dispatcher.set_progress(
  42. job.id,
  43. {"stage": "Generating G-code", "total_percent": 75, "plate_index": 1},
  44. )
  45. snap = dispatcher.get(job.id).progress
  46. assert snap == {
  47. "stage": "Generating G-code",
  48. "total_percent": 75,
  49. "plate_index": 1,
  50. }
  51. # Release the runner so the job completes and the test cleans up.
  52. release.set()
  53. # Yield to the event loop so the runner's completion settles.
  54. await asyncio.sleep(0)
  55. await asyncio.sleep(0)
  56. @pytest.mark.asyncio
  57. async def test_set_progress_silently_ignores_unknown_job_id():
  58. """A late poll after retention sweep mustn't crash the polling task."""
  59. dispatcher = SliceDispatchService()
  60. # Should be a no-op, not an exception.
  61. dispatcher.set_progress(99999, {"stage": "x", "total_percent": 50})
  62. @pytest.mark.asyncio
  63. async def test_set_progress_can_clear_to_none():
  64. """Allow clearing — useful when the slice transitions to a final
  65. state and we want the toast to revert to the elapsed-time fallback
  66. on subsequent polls."""
  67. dispatcher = SliceDispatchService()
  68. started = asyncio.Event()
  69. release = asyncio.Event()
  70. async def runner(job_id: int) -> dict:
  71. started.set()
  72. await release.wait()
  73. return {"library_file_id": 1}
  74. job = await dispatcher.enqueue(
  75. kind="library_file",
  76. source_id=1,
  77. source_name="x.stl",
  78. run=runner,
  79. )
  80. await started.wait()
  81. dispatcher.set_progress(job.id, {"stage": "x", "total_percent": 50})
  82. assert dispatcher.get(job.id).progress is not None
  83. dispatcher.set_progress(job.id, None)
  84. assert dispatcher.get(job.id).progress is None
  85. release.set()
  86. await asyncio.sleep(0)
  87. await asyncio.sleep(0)