/
/
/
1"""Tests for the announcement relay via Home Assistant on ESPHome-backed Sendspin players."""
2
3from __future__ import annotations
4
5from types import SimpleNamespace
6from typing import TYPE_CHECKING, cast
7from unittest.mock import AsyncMock, Mock
8
9import pytest
10from music_assistant_models.enums import PlayerFeature
11from music_assistant_models.errors import PlayerCommandFailed
12from music_assistant_models.player import DeviceInfo, PlayerMedia
13
14from music_assistant.providers.sendspin.player import SendspinPlayer
15from music_assistant.providers.sendspin.provider import SendspinProvider
16
17if TYPE_CHECKING:
18 from music_assistant_models.config_entries import PlayerConfig
19
20 from music_assistant.mass import MusicAssistant
21
22MAC = "aa:bb:cc:dd:ee:ff"
23ENTITY_ID = "media_player.kitchen_speaker"
24
25
26def _player(*, manufacturer: str = "ESPHome", player_id: str = MAC) -> SendspinPlayer:
27 player = SendspinPlayer.__new__(SendspinPlayer)
28 player._player_id = player_id
29 player._cache = {}
30 player._attr_supported_features = set()
31 player._attr_device_info = DeviceInfo(manufacturer=manufacturer)
32 player._attr_name = "hello name"
33 player._config = cast("PlayerConfig", SimpleNamespace(name=None, default_name=None))
34 player.logger = Mock()
35 return player
36
37
38def _provider(hass_provider: object | None) -> SendspinProvider:
39 provider = SendspinProvider.__new__(SendspinProvider)
40 provider.mass = cast(
41 "MusicAssistant", SimpleNamespace(get_provider=lambda _domain: hass_provider)
42 )
43 provider.logger = Mock()
44 return provider
45
46
47def _hass_provider(device_infos: dict[str, dict[str, str | None]]) -> SimpleNamespace:
48 return SimpleNamespace(
49 available=True,
50 get_media_player_device_infos=AsyncMock(return_value=device_infos),
51 play_announcement_on_entity=AsyncMock(),
52 )
53
54
55def test_set_hass_announce_entity_adds_feature() -> None:
56 """Setting the HA announce entity adds the PLAY_ANNOUNCEMENT feature."""
57 player = _player()
58 player.set_hass_announce_entity(ENTITY_ID)
59 assert player._hass_announce_entity_id == ENTITY_ID
60 assert PlayerFeature.PLAY_ANNOUNCEMENT in player.supported_features
61
62
63def test_clear_hass_announce_entity_removes_feature() -> None:
64 """Clearing the HA announce entity removes the PLAY_ANNOUNCEMENT feature."""
65 player = _player()
66 player.set_hass_announce_entity(ENTITY_ID)
67 player.set_hass_announce_entity(None)
68 assert player._hass_announce_entity_id is None
69 assert PlayerFeature.PLAY_ANNOUNCEMENT not in player.supported_features
70
71
72async def test_enrichment_applies_name_and_announce_entity() -> None:
73 """An ESPHome player picks up its HA name and announce entity."""
74 hass = _hass_provider({MAC: {"name": "Kitchen Speaker", "announce_entity_id": ENTITY_ID}})
75 player = _player()
76 await _provider(hass)._apply_hass_esphome_enrichment([player])
77 assert player._attr_name == "Kitchen Speaker"
78 assert player._hass_announce_entity_id == ENTITY_ID
79 assert PlayerFeature.PLAY_ANNOUNCEMENT in player.supported_features
80 hass.get_media_player_device_infos.assert_awaited_once_with([MAC], platform="esphome")
81
82
83async def test_enrichment_ignores_non_esphome_players() -> None:
84 """Players not backed by ESPHome are left alone entirely."""
85 hass = _hass_provider({})
86 player = _player(manufacturer="Music Assistant")
87 await _provider(hass)._apply_hass_esphome_enrichment([player])
88 hass.get_media_player_device_infos.assert_not_awaited()
89 assert player._attr_name == "hello name"
90
91
92@pytest.mark.parametrize("hass_provider", [None, SimpleNamespace(available=False)])
93async def test_enrichment_clears_announce_entity_without_hass(hass_provider: object) -> None:
94 """The announce relay is dropped when the hass plugin is not available."""
95 player = _player()
96 player.set_hass_announce_entity(ENTITY_ID)
97 await _provider(hass_provider)._apply_hass_esphome_enrichment([player])
98 assert player._hass_announce_entity_id is None
99 assert PlayerFeature.PLAY_ANNOUNCEMENT not in player.supported_features
100
101
102async def test_enrichment_clears_announce_entity_for_unknown_device() -> None:
103 """A device no longer known to HA loses the announce relay but keeps its name."""
104 player = _player()
105 player.set_hass_announce_entity(ENTITY_ID)
106 await _provider(_hass_provider({}))._apply_hass_esphome_enrichment([player])
107 assert player._hass_announce_entity_id is None
108 assert player._attr_name == "hello name"
109
110
111async def test_enrichment_keeps_state_on_hass_error() -> None:
112 """A transient HA failure keeps the current enrichment instead of dropping it."""
113 hass = SimpleNamespace(
114 available=True,
115 get_media_player_device_infos=AsyncMock(side_effect=RuntimeError("connection lost")),
116 )
117 player = _player()
118 player.set_hass_announce_entity(ENTITY_ID)
119 provider = _provider(hass)
120 await provider._apply_hass_esphome_enrichment([player])
121 assert player._hass_announce_entity_id == ENTITY_ID
122 cast("Mock", provider.logger).warning.assert_called_once()
123
124
125async def test_play_announcement_relays_via_hass() -> None:
126 """An announcement is relayed to the HA entity, ignoring the volume level."""
127 hass = _hass_provider({})
128 player = _player()
129 player.mass = cast("MusicAssistant", SimpleNamespace(get_provider=lambda _domain: hass))
130 player.set_hass_announce_entity(ENTITY_ID)
131 announcement = PlayerMedia(uri="http://mass.local/announcement.mp3", duration=4)
132 await player.play_announcement(announcement, volume_level=50)
133 hass.play_announcement_on_entity.assert_awaited_once_with(ENTITY_ID, announcement)
134
135
136@pytest.mark.parametrize("hass_provider", [None, SimpleNamespace(available=False)])
137async def test_play_announcement_requires_hass(hass_provider: object) -> None:
138 """An announcement fails cleanly when the hass plugin is gone."""
139 player = _player()
140 player.mass = cast(
141 "MusicAssistant", SimpleNamespace(get_provider=lambda _domain: hass_provider)
142 )
143 player.set_hass_announce_entity(ENTITY_ID)
144 with pytest.raises(PlayerCommandFailed):
145 await player.play_announcement(PlayerMedia(uri="http://mass.local/announcement.mp3"))
146
147
148async def test_play_announcement_requires_resolved_entity() -> None:
149 """An announcement fails cleanly when no HA entity was resolved."""
150 player = _player()
151 player.mass = cast(
152 "MusicAssistant", SimpleNamespace(get_provider=lambda _domain: _hass_provider({}))
153 )
154 with pytest.raises(PlayerCommandFailed):
155 await player.play_announcement(PlayerMedia(uri="http://mass.local/announcement.mp3"))
156