/
/
/
1"""Tests for the player-queues config-entry builders (config.py)."""
2
3from __future__ import annotations
4
5from collections.abc import Iterable
6from typing import TYPE_CHECKING
7from unittest.mock import Mock
8
9from music_assistant_models.enums import CrossfadeMode, ProviderFeature
10
11from music_assistant.constants import (
12 CONF_CROSSFADE_DURATION,
13 CONF_CROSSFADE_MODE,
14 CONF_VALUE_DISABLED,
15 CONF_VALUE_ENABLED,
16 CONF_VALUE_GLOBAL,
17 CONF_VOLUME_NORMALIZATION,
18)
19from music_assistant.controllers.player_queues.config import (
20 CATEGORY_CLICK_ACTIONS,
21 CATEGORY_DEFAULT_ENQUEUE_OPTION,
22 CATEGORY_ITEMS_TO_SELECT,
23 core_config_entries,
24 queue_config_entries,
25)
26from music_assistant.controllers.player_queues.constants import (
27 CLICK_ACTION_BROWSE,
28 CLICK_ACTION_PLAY,
29 CONF_AUTOPLAY_MODE,
30 CONF_DEFAULT_CLICK_ACTION_ALBUM,
31 CONF_DEFAULT_CLICK_ACTION_ARTIST,
32 CONF_DEFAULT_CLICK_ACTION_GENRE,
33 CONF_DEFAULT_CLICK_ACTION_PLAYLIST,
34 CONF_DEFAULT_CLICK_ACTION_RADIO,
35 CONF_DEFAULT_CLICK_ACTION_TRACK,
36 CONF_DEFAULT_ENQUEUE_OPTION_ALBUM,
37 CONF_DEFAULT_ENQUEUE_OPTION_TRACK,
38 CONF_DEFAULT_ENQUEUE_SELECT_ARTIST,
39 CONF_DEFAULT_PLAY_ACTION_ALBUM_TRACK,
40 CONF_DEFAULT_PLAY_ACTION_PLAYLIST_TRACK,
41 CONF_SMART_SHUFFLE_ENABLED,
42 CONF_SMART_SHUFFLE_SONG_RECENCY,
43 PLAY_ACTION_PLAY_FROM_HERE,
44 PLAY_ACTION_PLAY_TRACK,
45)
46
47if TYPE_CHECKING:
48 from music_assistant_models.config_entries import ConfigEntry
49
50
51def _mass(*, similar_tracks: bool, smart_fades: bool) -> Mock:
52 """Build a mock MusicAssistant with the provider/smart-fades signals the builder reads."""
53 provider = Mock()
54 provider.supported_features = {ProviderFeature.SIMILAR_TRACKS} if similar_tracks else set()
55 mass = Mock()
56 mass.music.providers = [provider]
57 mass.streams.smart_fades_available = smart_fades
58 return mass
59
60
61def _by_key(entries: Iterable[ConfigEntry]) -> dict[str, ConfigEntry]:
62 return {entry.key: entry for entry in entries}
63
64
65def test_core_config_entries_enqueue_defaults() -> None:
66 """A track defaults to PLAY, other media types to REPLACE, in their tidied categories."""
67 by_key = _by_key(core_config_entries(_mass(similar_tracks=True, smart_fades=True)))
68 assert by_key[CONF_DEFAULT_ENQUEUE_OPTION_TRACK].default_value == "play"
69 assert by_key[CONF_DEFAULT_ENQUEUE_OPTION_ALBUM].default_value == "replace"
70 # the enqueue entries now live in their own dedicated categories
71 assert by_key[CONF_DEFAULT_ENQUEUE_SELECT_ARTIST].category == CATEGORY_ITEMS_TO_SELECT
72 assert by_key[CONF_DEFAULT_ENQUEUE_OPTION_TRACK].category == CATEGORY_DEFAULT_ENQUEUE_OPTION
73
74
75def test_core_config_entries_click_actions() -> None:
76 """
77 The click actions clients read: browsing by default, and a track playing on from where it sits.
78
79 This is a cross-client contract rather than something the server itself reads, so the keys,
80 their option values and their defaults are pinned here: a client resolving a value that is no
81 longer offered would silently fall back to its own behaviour.
82 """
83 by_key = _by_key(core_config_entries(_mass(similar_tracks=True, smart_fades=True)))
84 click_keys = (
85 CONF_DEFAULT_CLICK_ACTION_ARTIST,
86 CONF_DEFAULT_CLICK_ACTION_ALBUM,
87 CONF_DEFAULT_CLICK_ACTION_TRACK,
88 CONF_DEFAULT_CLICK_ACTION_GENRE,
89 CONF_DEFAULT_CLICK_ACTION_RADIO,
90 CONF_DEFAULT_CLICK_ACTION_PLAYLIST,
91 )
92 for key in click_keys:
93 assert by_key[key].default_value == CLICK_ACTION_BROWSE
94 assert {opt.value for opt in by_key[key].options} == {
95 CLICK_ACTION_BROWSE,
96 CLICK_ACTION_PLAY,
97 }
98 for key in (CONF_DEFAULT_PLAY_ACTION_ALBUM_TRACK, CONF_DEFAULT_PLAY_ACTION_PLAYLIST_TRACK):
99 assert by_key[key].default_value == PLAY_ACTION_PLAY_FROM_HERE
100 assert {opt.value for opt in by_key[key].options} == {
101 PLAY_ACTION_PLAY_FROM_HERE,
102 PLAY_ACTION_PLAY_TRACK,
103 }
104 for key in (
105 *click_keys,
106 CONF_DEFAULT_PLAY_ACTION_ALBUM_TRACK,
107 CONF_DEFAULT_PLAY_ACTION_PLAYLIST_TRACK,
108 ):
109 assert by_key[key].category == CATEGORY_CLICK_ACTIONS
110 # a hidden entry would never reach the settings UI these are configured from
111 assert by_key[key].hidden is False
112
113
114def test_core_config_entries_global_feature_defaults() -> None:
115 """Global (core) feature settings carry no 'global' option and keep the legacy defaults."""
116 by_key = _by_key(core_config_entries(_mass(similar_tracks=True, smart_fades=True)))
117 smart_shuffle = by_key[CONF_SMART_SHUFFLE_ENABLED]
118 assert smart_shuffle.default_value == CONF_VALUE_DISABLED
119 assert {opt.value for opt in smart_shuffle.options} == {CONF_VALUE_ENABLED, CONF_VALUE_DISABLED}
120 volume_normalization = by_key[CONF_VOLUME_NORMALIZATION]
121 assert volume_normalization.default_value == CONF_VALUE_ENABLED
122 assert CONF_VALUE_GLOBAL not in {opt.value for opt in volume_normalization.options}
123 # the global-only settings live only on the core schema
124 assert CONF_CROSSFADE_DURATION in by_key
125 assert CONF_SMART_SHUFFLE_SONG_RECENCY in by_key
126
127
128def test_queue_config_entries_offer_global_option() -> None:
129 """Each per-queue headline setting defaults to 'global'; global-only knobs are absent."""
130 by_key = _by_key(queue_config_entries(_mass(similar_tracks=True, smart_fades=True)))
131 for key in (
132 CONF_SMART_SHUFFLE_ENABLED,
133 CONF_VOLUME_NORMALIZATION,
134 CONF_CROSSFADE_MODE,
135 CONF_AUTOPLAY_MODE,
136 ):
137 entry = by_key[key]
138 assert entry.default_value == CONF_VALUE_GLOBAL
139 assert CONF_VALUE_GLOBAL in {opt.value for opt in entry.options}
140 # crossfade duration and the recency windows are global-only (not overridable per queue)
141 assert CONF_CROSSFADE_DURATION not in by_key
142 assert CONF_SMART_SHUFFLE_SONG_RECENCY not in by_key
143
144
145def test_queue_config_smart_crossfade_unavailable() -> None:
146 """Without smart fades the smart option is disabled and standard is the global default."""
147 mass = _mass(similar_tracks=True, smart_fades=False)
148 core = _by_key(core_config_entries(mass))
149 assert core[CONF_CROSSFADE_MODE].default_value == CrossfadeMode.STANDARD_CROSSFADE.value
150 queue_crossfade = _by_key(queue_config_entries(mass))[CONF_CROSSFADE_MODE]
151 smart = next(
152 opt for opt in queue_crossfade.options if opt.value == CrossfadeMode.SMART_CROSSFADE.value
153 )
154 assert smart.disabled is True
155
156
157def test_queue_config_smart_crossfade_available() -> None:
158 """With smart fades available, smart crossfade is the global default and stays enabled."""
159 mass = _mass(similar_tracks=True, smart_fades=True)
160 core = _by_key(core_config_entries(mass))
161 assert core[CONF_CROSSFADE_MODE].default_value == CrossfadeMode.SMART_CROSSFADE.value
162 queue_crossfade = _by_key(queue_config_entries(mass))[CONF_CROSSFADE_MODE]
163 smart = next(
164 opt for opt in queue_crossfade.options if opt.value == CrossfadeMode.SMART_CROSSFADE.value
165 )
166 assert smart.disabled is False
167
168
169def test_queue_config_similar_autoplay_disabled_without_provider() -> None:
170 """The 'similar' autoplay option is disabled when no provider can supply similar tracks."""
171 entries = queue_config_entries(_mass(similar_tracks=False, smart_fades=True))
172 autoplay = next(entry for entry in entries if entry.key == CONF_AUTOPLAY_MODE)
173 similar = next(opt for opt in autoplay.options if opt.value == "similar")
174 assert similar.disabled is True
175