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