/
/
/
1"""
2Tests for the global autoplay/crossfade defaults and the per-queue overrides that pin them.
3
4A queue's effective autoplay/crossfade follows the matching global core config value
5(`autoplay_enabled`/`crossfade_enabled` on the `player_queues` core module) until its own toggle
6command (`set_autoplay`/`set_crossfade`) pins an explicit override. The wire
7`PlayerQueue.autoplay_enabled`/`crossfade_enabled` fields always carry that resolved effective
8value.
9"""
10
11from __future__ import annotations
12
13from types import SimpleNamespace
14from typing import TYPE_CHECKING, Any, cast
15from unittest.mock import MagicMock
16
17from music_assistant_models.enums import PlaybackState
18
19if TYPE_CHECKING:
20 from music_assistant_models.config_entries import CoreConfig
21from music_assistant_models.player_queue import PlayerQueue
22
23from music_assistant.controllers.player_queues.controller import PlayerQueuesController
24from music_assistant.controllers.player_queues.state import PlayerQueueData
25
26
27def _controller(*, global_autoplay: bool = True, global_crossfade: bool = False) -> Any:
28 """
29 Build a queue-controller stand-in with the real toggle resolver bound.
30
31 Spec'd to `PlayerQueuesController` (rather than a bare MagicMock) so `isinstance` checks -
32 like the zero-arg `super()` call inside `update_config` - resolve correctly.
33
34 :param global_autoplay: The stubbed global autoplay_enabled core config value.
35 :param global_crossfade: The stubbed global crossfade_enabled core config value.
36 """
37 queues = MagicMock(spec=PlayerQueuesController)
38 queues._queue_data = {}
39 queues.mass = MagicMock()
40 defaults = {"autoplay_enabled": global_autoplay, "crossfade_enabled": global_crossfade}
41 queues.mass.config.get_raw_core_config_value = MagicMock(
42 side_effect=lambda _core_module, key, _default: defaults[key]
43 )
44 # bind the real resolver so the methods under test exercise actual logic
45 queues._resolve_default_toggles = lambda queue_data: (
46 PlayerQueuesController._resolve_default_toggles(queues, queue_data)
47 )
48 return queues
49
50
51def _queue_data(
52 queue_id: str = "q1", *, playing: bool = False, **overrides: Any
53) -> PlayerQueueData:
54 """Build a PlayerQueueData, optionally set up as actively playing with a loaded next item."""
55 extra: dict[str, Any] = {}
56 if playing:
57 extra.update(state=PlaybackState.PLAYING, current_index=0, index_in_buffer=0)
58 queue = PlayerQueue(
59 queue_id=queue_id, active=True, display_name="Q", available=True, items=2, **extra
60 )
61 return PlayerQueueData(queue=queue, **overrides)
62
63
64async def test_set_autoplay_pins_override_unaffected_by_later_global_change() -> None:
65 """set_autoplay pins an explicit override that a later global default change can't undo."""
66 queues = _controller(global_autoplay=True)
67 queue_data = _queue_data()
68 queues._queue_data["q1"] = queue_data
69
70 PlayerQueuesController.set_autoplay(queues, "q1", False)
71
72 assert queue_data.autoplay_override is False
73 assert queue_data.queue.autoplay_enabled is False
74
75 # the global default flips, but the pinned override still wins on the next resolution
76 queues.mass.config.get_raw_core_config_value.side_effect = lambda _core_module, _key, _default: (
77 True
78 )
79 queues._resolve_default_toggles(queue_data)
80
81 assert queue_data.queue.autoplay_enabled is False
82
83
84async def test_set_crossfade_pins_override_unaffected_by_later_global_change() -> None:
85 """set_crossfade pins an explicit override that a later global default change can't undo."""
86 queues = _controller(global_crossfade=False)
87 queue_data = _queue_data()
88 queues._queue_data["q1"] = queue_data
89
90 PlayerQueuesController.set_crossfade(queues, "q1", True)
91
92 assert queue_data.crossfade_override is True
93 assert queue_data.queue.crossfade_enabled is True
94
95 queues.mass.config.get_raw_core_config_value.side_effect = lambda _core_module, _key, _default: (
96 False
97 )
98 queues._resolve_default_toggles(queue_data)
99
100 assert queue_data.queue.crossfade_enabled is True
101
102
103async def test_set_crossfade_pins_override_matching_the_global_default() -> None:
104 """Setting crossfade to the value it already effectively has still pins an explicit override."""
105 queues = _controller(global_crossfade=False)
106 queue_data = _queue_data() # crossfade_override=None, effective False (follows global)
107 queues._queue_data["q1"] = queue_data
108
109 PlayerQueuesController.set_crossfade(queues, "q1", False)
110
111 # explicitly pinned now, no longer None/"follow global"
112 assert queue_data.crossfade_override is False
113 assert queue_data.queue.crossfade_enabled is False
114 # the pin must persist even though the effective value didn't change
115 queues.signal_update.assert_called_once_with("q1")
116 # no audible change occurred, so no re-enqueue side effect
117 queues._enqueue_next_item.assert_not_called()
118
119
120async def test_update_config_re_resolves_following_queues_but_not_pinned_ones() -> None:
121 """A global default change reaches follow-global queues live; pinned queues keep their value."""
122 queues = _controller(global_autoplay=False, global_crossfade=False)
123 following = _queue_data("q1", playing=True)
124 pinned = _queue_data("q2", playing=True, autoplay_override=False, crossfade_override=False)
125 for queue_data in (following, pinned):
126 queues._queue_data[queue_data.queue.queue_id] = queue_data
127 # resolve against the initial global defaults (off) before the change under test
128 queues._resolve_default_toggles(queue_data)
129
130 # flip both global defaults on: only the follow-global queue's effective values change
131 queues.mass.config.get_raw_core_config_value.side_effect = lambda _core_module, _key, _default: (
132 True
133 )
134 config = cast("CoreConfig", SimpleNamespace(values={}))
135
136 await PlayerQueuesController.update_config(
137 queues, config, {"values/autoplay_enabled", "values/crossfade_enabled"}
138 )
139
140 assert following.queue.autoplay_enabled is True
141 assert following.queue.crossfade_enabled is True
142 assert pinned.queue.autoplay_enabled is False
143 assert pinned.queue.crossfade_enabled is False
144 # every queue is signalled; the audible effect lands on the next transition, so there is
145 # no immediate re-enqueue or refill kick
146 assert queues.signal_update.call_count == 2
147 queues._enqueue_next_item.assert_not_called()
148 queues.mass.call_later.assert_not_called()
149