/
/
/
1"""Unit tests for the native player-control config options being shown disabled, not omitted."""
2
3from __future__ import annotations
4
5from typing import TYPE_CHECKING
6from unittest.mock import MagicMock
7
8import pytest
9from music_assistant_models.constants import (
10 PLAYER_CONTROL_FAKE,
11 PLAYER_CONTROL_NATIVE,
12 PLAYER_CONTROL_NONE,
13)
14from music_assistant_models.enums import PlayerFeature, PlayerType
15
16from music_assistant.constants import (
17 CONF_MUTE_CONTROL,
18 CONF_POWER_CONTROL,
19 CONF_VOLUME_CONTROL,
20)
21from music_assistant.mass import MusicAssistant
22
23if TYPE_CHECKING:
24 from music_assistant_models.config_entries import ConfigEntry
25
26# each control select and the player feature that enables its native option
27_CONTROL_FEATURES = [
28 (CONF_POWER_CONTROL, PlayerFeature.POWER),
29 (CONF_VOLUME_CONTROL, PlayerFeature.VOLUME_SET),
30 (CONF_MUTE_CONTROL, PlayerFeature.VOLUME_MUTE),
31]
32_ALL_FEATURES = {feature for _conf_key, feature in _CONTROL_FEATURES}
33
34
35def _make_player(features: set[PlayerFeature]) -> MagicMock:
36 """Return a mock non-group player that supports exactly the given features."""
37 player = MagicMock()
38 player.state.type = PlayerType.PLAYER
39 player.linked_output_protocols = []
40 player.supports_feature.side_effect = lambda feature: feature in features
41 return player
42
43
44def _entries_by_key(
45 mass: MusicAssistant,
46 features: set[PlayerFeature],
47 protocol_player: MagicMock | None = None,
48) -> dict[str, ConfigEntry]:
49 """Build the player-control config entries for a player with the given features."""
50 player = _make_player(features)
51 if protocol_player is not None:
52 player.linked_output_protocols = [
53 MagicMock(output_protocol_id="protocol_player_1", protocol_domain="squeezelite")
54 ]
55 mass.players = MagicMock()
56 mass.players.player_controls.return_value = []
57 mass.players.get_player.return_value = protocol_player
58 entries = mass.config._create_player_control_config_entries(player)
59 return {entry.key: entry for entry in entries}
60
61
62@pytest.mark.parametrize(("conf_key", "feature"), _CONTROL_FEATURES)
63async def test_native_option_disabled_when_feature_unsupported(
64 mass_minimal: MusicAssistant, conf_key: str, feature: PlayerFeature
65) -> None:
66 """The native option is always offered, disabled when its feature is unsupported."""
67 # support every control except the one under test, to prove the gating is per-feature
68 entry = _entries_by_key(mass_minimal, _ALL_FEATURES - {feature})[conf_key]
69 native = next(option for option in entry.options if option.value == PLAYER_CONTROL_NATIVE)
70 assert native.disabled is True
71
72
73@pytest.mark.parametrize(("conf_key", "feature"), _CONTROL_FEATURES)
74async def test_native_option_enabled_and_default_when_supported(
75 mass_minimal: MusicAssistant, conf_key: str, feature: PlayerFeature
76) -> None:
77 """When the feature is supported the native option is enabled and picked as the default."""
78 entry = _entries_by_key(mass_minimal, {feature})[conf_key]
79 native = next(option for option in entry.options if option.value == PLAYER_CONTROL_NATIVE)
80 assert native.disabled is False
81 assert entry.default_value == PLAYER_CONTROL_NATIVE
82
83
84@pytest.mark.parametrize(("conf_key", "feature"), _CONTROL_FEATURES)
85async def test_default_is_never_a_disabled_option(
86 mass_minimal: MusicAssistant, conf_key: str, feature: PlayerFeature
87) -> None:
88 """The default must always resolve to a selectable option, even for required controls."""
89 entry = _entries_by_key(mass_minimal, _ALL_FEATURES - {feature})[conf_key]
90 default_option = next(option for option in entry.options if option.value == entry.default_value)
91 assert default_option.disabled is False
92 # with no native support the safe fallback is the always-present "none" control
93 assert entry.default_value == PLAYER_CONTROL_NONE
94
95
96async def test_fake_mute_offered_when_volume_provided_by_protocol_player(
97 mass_minimal: MusicAssistant,
98) -> None:
99 """
100 Fake mute is offered when the volume path comes from a linked protocol player.
101
102 Regression test: players whose volume is provided by a linked protocol player
103 (e.g. squeezelite wrapped by the universal player) don't report VOLUME_SET
104 natively, yet fake mute works fine for them as it drives the configured
105 volume control.
106 """
107 protocol_player = MagicMock()
108 protocol_player.available = True
109 protocol_player.available_for_playback = True
110 protocol_player.supports_feature.side_effect = lambda feature: (
111 feature == PlayerFeature.VOLUME_SET
112 )
113 entry = _entries_by_key(mass_minimal, set(), protocol_player)[CONF_MUTE_CONTROL]
114 fake = next(option for option in entry.options if option.value == PLAYER_CONTROL_FAKE)
115 assert fake.disabled is False
116
117
118async def test_fake_mute_not_offered_without_any_volume_path(
119 mass_minimal: MusicAssistant,
120) -> None:
121 """Fake mute stays hidden when neither the player nor a protocol player provides volume."""
122 entry = _entries_by_key(mass_minimal, {PlayerFeature.VOLUME_MUTE})[CONF_MUTE_CONTROL]
123 assert all(option.value != PLAYER_CONTROL_FAKE for option in entry.options)
124