Kaynağa Gözat

fix(backend): re-shuffle playlists on each repeat cycle (#139)

Shuffle ran once before the playlist loop, so playlists in a repeat
mode played the same random order on every cycle: shuffle appeared to
work on the first pass and then froze.

Move the shuffle to the top of each cycle so every pass through a
repeating playlist gets a fresh order. Single-pass playlists still
shuffle exactly once. Adds unit coverage for both, and drops two
unused imports flagged by ruff in the touched test file.

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
Andrey 4 gün önce
ebeveyn
işleme
ea8477a2a3

+ 7 - 5
modules/core/pattern_manager.py

@@ -1539,11 +1539,6 @@ async def run_theta_rho_files(file_paths, pause_time=0, clear_pattern=None, run_
     if not progress_update_task:
     if not progress_update_task:
         progress_update_task = asyncio.create_task(broadcast_progress())
         progress_update_task = asyncio.create_task(broadcast_progress())
 
 
-    # Shuffle main patterns if requested (before starting)
-    if shuffle:
-        random.shuffle(file_paths)
-        logger.info("Playlist shuffled")
-
     # Store patterns in state only if not already set by caller.
     # Store patterns in state only if not already set by caller.
     # The caller (playlist_manager.run_playlist) sets this before creating the task.
     # The caller (playlist_manager.run_playlist) sets this before creating the task.
     if state.current_playlist is None:
     if state.current_playlist is None:
@@ -1551,6 +1546,13 @@ async def run_theta_rho_files(file_paths, pause_time=0, clear_pattern=None, run_
 
 
     try:
     try:
         while True:
         while True:
+            # Shuffle main patterns if requested. Re-shuffle at the start of
+            # every cycle so repeat modes play a fresh order each pass instead
+            # of replaying the first random order forever.
+            if shuffle and state.current_playlist:
+                random.shuffle(state.current_playlist)
+                logger.info("Playlist shuffled")
+
             # Load metadata cache once per playlist iteration (for adaptive clear patterns)
             # Load metadata cache once per playlist iteration (for adaptive clear patterns)
             cache_data = None
             cache_data = None
             if clear_pattern and clear_pattern in ['adaptive', 'clear_from_in', 'clear_from_out']:
             if clear_pattern and clear_pattern in ['adaptive', 'clear_from_in', 'clear_from_out']:

+ 55 - 0
tests/unit/test_pattern_manager.py

@@ -315,6 +315,61 @@ class TestGetStatus:
         assert status["playlist"]["name"] == "test_playlist"
         assert status["playlist"]["name"] == "test_playlist"
 
 
 
 
+class TestPlaylistShuffle:
+    """Tests for playlist shuffle behavior in run_theta_rho_files."""
+
+    async def test_shuffle_reshuffles_on_each_repeat_cycle(self, mock_state):
+        """With shuffle enabled, a repeating playlist must re-shuffle every
+        cycle instead of replaying the first random order forever."""
+        playlist = ["a.thr", "b.thr"]
+        executed = []
+        shuffle_calls = []
+
+        async def fake_run_pattern(file_path, **kwargs):
+            executed.append(file_path)
+            if len(executed) >= 3:
+                mock_state.stop_requested = True
+
+        with patch("modules.core.pattern_manager.state", mock_state), \
+             patch("modules.core.pattern_manager.run_theta_rho_file",
+                   AsyncMock(side_effect=fake_run_pattern)), \
+             patch("modules.core.pattern_manager.broadcast_progress", AsyncMock()), \
+             patch("modules.core.pattern_manager.start_idle_led_timeout", AsyncMock()), \
+             patch("modules.core.pattern_manager.random.shuffle",
+                   side_effect=lambda seq: shuffle_calls.append(list(seq))):
+            from modules.core.pattern_manager import run_theta_rho_files
+
+            await run_theta_rho_files(playlist, run_mode="indefinite", shuffle=True)
+
+        # The playlist restarted once (3 patterns executed from a 2-pattern
+        # playlist), so it must have been shuffled twice: once per cycle.
+        assert len(executed) == 3
+        assert len(shuffle_calls) == 2
+
+    async def test_no_shuffle_preserves_order(self, mock_state):
+        """Without shuffle, the playlist order is never touched."""
+        playlist = ["a.thr", "b.thr"]
+        executed = []
+        shuffle_calls = []
+
+        async def fake_run_pattern(file_path, **kwargs):
+            executed.append(file_path)
+
+        with patch("modules.core.pattern_manager.state", mock_state), \
+             patch("modules.core.pattern_manager.run_theta_rho_file",
+                   AsyncMock(side_effect=fake_run_pattern)), \
+             patch("modules.core.pattern_manager.broadcast_progress", AsyncMock()), \
+             patch("modules.core.pattern_manager.start_idle_led_timeout", AsyncMock()), \
+             patch("modules.core.pattern_manager.random.shuffle",
+                   side_effect=lambda seq: shuffle_calls.append(list(seq))):
+            from modules.core.pattern_manager import run_theta_rho_files
+
+            await run_theta_rho_files(playlist, run_mode="single", shuffle=False)
+
+        assert executed == ["a.thr", "b.thr"]
+        assert shuffle_calls == []
+
+
 class TestIsClearPattern:
 class TestIsClearPattern:
     """Tests for is_clear_pattern function."""
     """Tests for is_clear_pattern function."""