From 49eeb2d99e4ab45f0bf0aa1f5004b6d0b888ad2d Mon Sep 17 00:00:00 2001 From: Marcel van der Veldt Date: Fri, 22 Nov 2024 20:09:20 +0100 Subject: [PATCH] Add test foundation to Music Assistant integration (#129534) --- .../music_assistant/media_player.py | 6 +- tests/components/music_assistant/common.py | 91 +++++ tests/components/music_assistant/conftest.py | 52 ++- .../fixtures/player_queues.json | 328 ++++++++++++++++++ .../music_assistant/fixtures/players.json | 149 ++++++++ .../snapshots/test_media_player.ambr | 190 ++++++++++ .../music_assistant/test_media_player.py | 263 ++++++++++++++ 7 files changed, 1076 insertions(+), 3 deletions(-) create mode 100644 tests/components/music_assistant/common.py create mode 100644 tests/components/music_assistant/fixtures/player_queues.json create mode 100644 tests/components/music_assistant/fixtures/players.json create mode 100644 tests/components/music_assistant/snapshots/test_media_player.ambr create mode 100644 tests/components/music_assistant/test_media_player.py diff --git a/homeassistant/components/music_assistant/media_player.py b/homeassistant/components/music_assistant/media_player.py index f0f3675ee32..d898322c293 100644 --- a/homeassistant/components/music_assistant/media_player.py +++ b/homeassistant/components/music_assistant/media_player.py @@ -152,6 +152,8 @@ class MusicAssistantPlayer(MusicAssistantEntity, MediaPlayerEntity): self._attr_supported_features = SUPPORTED_FEATURES if PlayerFeature.SYNC in self.player.supported_features: self._attr_supported_features |= MediaPlayerEntityFeature.GROUPING + if PlayerFeature.VOLUME_MUTE in self.player.supported_features: + self._attr_supported_features |= MediaPlayerEntityFeature.VOLUME_MUTE self._attr_device_class = MediaPlayerDeviceClass.SPEAKER self._prev_time: float = 0 @@ -219,7 +221,9 @@ class MusicAssistantPlayer(MusicAssistantEntity, MediaPlayerEntity): ) ) ] - self._attr_group_members = group_members_entity_ids + # NOTE: we sort the group_members for now, + # until the MA API returns them sorted (group_childs is now a set) + self._attr_group_members = sorted(group_members_entity_ids) self._attr_volume_level = ( player.volume_level / 100 if player.volume_level is not None else None ) diff --git a/tests/components/music_assistant/common.py b/tests/components/music_assistant/common.py new file mode 100644 index 00000000000..307a928f2cc --- /dev/null +++ b/tests/components/music_assistant/common.py @@ -0,0 +1,91 @@ +"""Provide common test tools.""" + +from __future__ import annotations + +from typing import Any +from unittest.mock import MagicMock + +from music_assistant_models.enums import EventType +from music_assistant_models.player import Player +from music_assistant_models.player_queue import PlayerQueue +from syrupy import SnapshotAssertion + +from homeassistant.const import Platform +from homeassistant.core import HomeAssistant +from homeassistant.helpers import entity_registry as er + +from tests.common import MockConfigEntry, load_json_object_fixture + +MASS_DOMAIN = "music_assistant" +MOCK_URL = "http://mock-music_assistant-server-url" + + +def load_and_parse_fixture(fixture: str) -> dict[str, Any]: + """Load and parse a fixture.""" + data = load_json_object_fixture(f"music_assistant/{fixture}.json") + return data[fixture] + + +async def setup_integration_from_fixtures( + hass: HomeAssistant, + music_assistant_client: MagicMock, +) -> None: + """Set up MusicAssistant integration with fixture data.""" + players = create_players_from_fixture() + music_assistant_client.players._players = {x.player_id: x for x in players} + player_queues = create_player_queues_from_fixture() + music_assistant_client.player_queues._queues = { + x.queue_id: x for x in player_queues + } + config_entry = MockConfigEntry( + domain=MASS_DOMAIN, + data={"url": MOCK_URL}, + unique_id=music_assistant_client.server_info.server_id, + ) + config_entry.add_to_hass(hass) + assert await hass.config_entries.async_setup(config_entry.entry_id) + await hass.async_block_till_done() + + +def create_players_from_fixture() -> list[Player]: + """Create MA Players from fixture.""" + fixture_data = load_and_parse_fixture("players") + return [Player.from_dict(player_data) for player_data in fixture_data] + + +def create_player_queues_from_fixture() -> list[Player]: + """Create MA PlayerQueues from fixture.""" + fixture_data = load_and_parse_fixture("player_queues") + return [ + PlayerQueue.from_dict(player_queue_data) for player_queue_data in fixture_data + ] + + +async def trigger_subscription_callback( + hass: HomeAssistant, + client: MagicMock, + event: EventType = EventType.PLAYER_UPDATED, + data: Any = None, +) -> None: + """Trigger a subscription callback.""" + # trigger callback on all subscribers + for sub in client.subscribe_events.call_args_list: + callback = sub.kwargs["callback"] + event_filter = sub.kwargs.get("event_filter") + if event_filter in (None, event): + callback(event, data) + await hass.async_block_till_done() + + +def snapshot_music_assistant_entities( + hass: HomeAssistant, + entity_registry: er.EntityRegistry, + snapshot: SnapshotAssertion, + platform: Platform, +) -> None: + """Snapshot MusicAssistant entities.""" + entities = hass.states.async_all(platform) + for entity_state in entities: + entity_entry = entity_registry.async_get(entity_state.entity_id) + assert entity_entry == snapshot(name=f"{entity_entry.entity_id}-entry") + assert entity_state == snapshot(name=f"{entity_entry.entity_id}-state") diff --git a/tests/components/music_assistant/conftest.py b/tests/components/music_assistant/conftest.py index b03a56ab4a6..2df43defe62 100644 --- a/tests/components/music_assistant/conftest.py +++ b/tests/components/music_assistant/conftest.py @@ -1,8 +1,12 @@ """Music Assistant test fixtures.""" -from collections.abc import Generator -from unittest.mock import patch +import asyncio +from collections.abc import AsyncGenerator, Generator +from unittest.mock import MagicMock, patch +from music_assistant_client.music import Music +from music_assistant_client.player_queues import PlayerQueues +from music_assistant_client.players import Players from music_assistant_models.api import ServerInfoMessage import pytest @@ -11,6 +15,8 @@ from homeassistant.components.music_assistant.const import DOMAIN from tests.common import AsyncMock, MockConfigEntry, load_fixture +MOCK_SERVER_ID = "1234" + @pytest.fixture def mock_get_server_info() -> Generator[AsyncMock]: @@ -24,6 +30,48 @@ def mock_get_server_info() -> Generator[AsyncMock]: yield mock_get_server_info +@pytest.fixture(name="music_assistant_client") +async def music_assistant_client_fixture() -> AsyncGenerator[MagicMock]: + """Fixture for a Music Assistant client.""" + with patch( + "homeassistant.components.music_assistant.MusicAssistantClient", autospec=True + ) as client_class: + client = client_class.return_value + + async def connect() -> None: + """Mock connect.""" + await asyncio.sleep(0) + + async def listen(init_ready: asyncio.Event | None) -> None: + """Mock listen.""" + if init_ready is not None: + init_ready.set() + listen_block = asyncio.Event() + await listen_block.wait() + pytest.fail("Listen was not cancelled!") + + client.connect = AsyncMock(side_effect=connect) + client.start_listening = AsyncMock(side_effect=listen) + client.server_info = ServerInfoMessage( + server_id=MOCK_SERVER_ID, + server_version="0.0.0", + schema_version=1, + min_supported_schema_version=1, + base_url="http://localhost:8095", + homeassistant_addon=False, + onboard_done=True, + ) + client.connection = MagicMock() + client.connection.connected = True + client.players = Players(client) + client.player_queues = PlayerQueues(client) + client.music = Music(client) + client.server_url = client.server_info.base_url + client.get_media_item_image_url = MagicMock(return_value=None) + + yield client + + @pytest.fixture def mock_config_entry() -> MockConfigEntry: """Mock a config entry.""" diff --git a/tests/components/music_assistant/fixtures/player_queues.json b/tests/components/music_assistant/fixtures/player_queues.json new file mode 100644 index 00000000000..5251560365c --- /dev/null +++ b/tests/components/music_assistant/fixtures/player_queues.json @@ -0,0 +1,328 @@ +{ + "player_queues": [ + { + "queue_id": "00:00:00:00:00:01", + "active": false, + "display_name": "Test Player 1", + "available": true, + "items": 0, + "shuffle_enabled": false, + "repeat_mode": "off", + "dont_stop_the_music_enabled": false, + "current_index": null, + "index_in_buffer": null, + "elapsed_time": 0, + "elapsed_time_last_updated": 1730118302.163217, + "state": "idle", + "current_item": null, + "next_item": null, + "radio_source": [], + "flow_mode": false, + "resume_pos": 0 + }, + { + "queue_id": "00:00:00:00:00:02", + "active": false, + "display_name": "My Super Test Player 2", + "available": true, + "items": 0, + "shuffle_enabled": false, + "repeat_mode": "off", + "dont_stop_the_music_enabled": false, + "current_index": null, + "index_in_buffer": null, + "elapsed_time": 0, + "elapsed_time_last_updated": 0, + "state": "idle", + "current_item": null, + "next_item": null, + "radio_source": [], + "flow_mode": false, + "resume_pos": 0 + }, + { + "queue_id": "test_group_player_1", + "active": true, + "display_name": "Test Group Player 1", + "available": true, + "items": 1094, + "shuffle_enabled": true, + "repeat_mode": "all", + "dont_stop_the_music_enabled": true, + "current_index": 26, + "index_in_buffer": 26, + "elapsed_time": 232.08810877799988, + "elapsed_time_last_updated": 1730313109.5659513, + "state": "playing", + "current_item": { + "queue_id": "test_group_player_1", + "queue_item_id": "5d95dc5be77e4f7eb4939f62cfef527b", + "name": "Guns N' Roses - November Rain", + "duration": 536, + "sort_index": 2109, + "streamdetails": { + "provider": "spotify", + "item_id": "3YRCqOhFifThpSRFJ1VWFM", + "audio_format": { + "content_type": "ogg", + "sample_rate": 44100, + "bit_depth": 16, + "channels": 2, + "output_format_str": "ogg", + "bit_rate": 0 + }, + "media_type": "track", + "stream_type": "custom", + "stream_title": null, + "duration": 536, + "size": null, + "can_seek": true, + "loudness": -12.47, + "loudness_album": null, + "prefer_album_loudness": false, + "volume_normalization_mode": "fallback_dynamic", + "target_loudness": -17, + "strip_silence_begin": false, + "strip_silence_end": true, + "stream_error": null + }, + "media_item": { + "item_id": "3YRCqOhFifThpSRFJ1VWFM", + "provider": "spotify", + "name": "November Rain", + "version": "", + "sort_name": "november rain", + "uri": "spotify://track/3YRCqOhFifThpSRFJ1VWFM", + "external_ids": [["isrc", "USGF19141510"]], + "media_type": "track", + "provider_mappings": [ + { + "item_id": "3YRCqOhFifThpSRFJ1VWFM", + "provider_domain": "spotify", + "provider_instance": "spotify", + "available": true, + "audio_format": { + "content_type": "ogg", + "sample_rate": 44100, + "bit_depth": 16, + "channels": 2, + "output_format_str": "ogg", + "bit_rate": 320 + }, + "url": "https://open.spotify.com/track/3YRCqOhFifThpSRFJ1VWFM", + "details": null + } + ], + "metadata": { + "description": null, + "review": null, + "explicit": false, + "images": [ + { + "type": "thumb", + "path": "https://i.scdn.co/image/ab67616d0000b273e44963b8bb127552ac761873", + "provider": "spotify", + "remotely_accessible": true + } + ], + "genres": null, + "mood": null, + "style": null, + "copyright": null, + "lyrics": null, + "label": null, + "links": null, + "chapters": null, + "performers": null, + "preview": "https://p.scdn.co/mp3-preview/98deb9c370bbaa350be058b3470fbe3bc1e28d9d?cid=2eb96f9b37494be1824999d58028a305", + "popularity": 77, + "last_refresh": null + }, + "favorite": false, + "position": 1372, + "duration": 536, + "artists": [ + { + "item_id": "3qm84nBOXUEQ2vnTfUTTFC", + "provider": "spotify", + "name": "Guns N' Roses", + "version": "", + "sort_name": "guns n' roses", + "uri": "spotify://artist/3qm84nBOXUEQ2vnTfUTTFC", + "external_ids": [], + "media_type": "artist", + "available": true, + "image": null + } + ], + "album": { + "item_id": "0CxPbTRARqKUYighiEY9Sz", + "provider": "spotify", + "name": "Use Your Illusion I", + "version": "", + "sort_name": "use your illusion i", + "uri": "spotify://album/0CxPbTRARqKUYighiEY9Sz", + "external_ids": [], + "media_type": "album", + "available": true, + "image": { + "type": "thumb", + "path": "https://i.scdn.co/image/ab67616d0000b273e44963b8bb127552ac761873", + "provider": "spotify", + "remotely_accessible": true + } + }, + "disc_number": 1, + "track_number": 10 + }, + "image": { + "type": "thumb", + "path": "https://i.scdn.co/image/ab67616d0000b273e44963b8bb127552ac761873", + "provider": "spotify", + "remotely_accessible": true + }, + "index": 0 + }, + "next_item": { + "queue_id": "test_group_player_1", + "queue_item_id": "990ae8f29cdf4fb588d679b115621f55", + "name": "The Stranglers - Golden Brown", + "duration": 207, + "sort_index": 1138, + "streamdetails": { + "provider": "qobuz", + "item_id": "1004735", + "audio_format": { + "content_type": "flac", + "sample_rate": 44100, + "bit_depth": 16, + "channels": 2, + "output_format_str": "flac", + "bit_rate": 0 + }, + "media_type": "track", + "stream_type": "http", + "stream_title": null, + "duration": 207, + "size": null, + "can_seek": true, + "loudness": -14.23, + "loudness_album": null, + "prefer_album_loudness": true, + "volume_normalization_mode": "fallback_dynamic", + "target_loudness": -17, + "strip_silence_begin": true, + "strip_silence_end": true, + "stream_error": null + }, + "media_item": { + "item_id": "1004735", + "provider": "qobuz", + "name": "Golden Brown", + "version": "", + "sort_name": "golden brown", + "uri": "qobuz://track/1004735", + "external_ids": [["isrc", "GBAYE8100053"]], + "media_type": "track", + "provider_mappings": [ + { + "item_id": "1004735", + "provider_domain": "qobuz", + "provider_instance": "qobuz", + "available": true, + "audio_format": { + "content_type": "flac", + "sample_rate": 44100, + "bit_depth": 16, + "channels": 2, + "output_format_str": "flac", + "bit_rate": 0 + }, + "url": "https://open.qobuz.com/track/1004735", + "details": null + } + ], + "metadata": { + "description": null, + "review": null, + "explicit": null, + "images": [ + { + "type": "thumb", + "path": "https://static.qobuz.com/images/covers/59/88/0724353468859_600.jpg", + "provider": "qobuz", + "remotely_accessible": true + } + ], + "genres": null, + "mood": null, + "style": null, + "copyright": "© 2001 Parlophone Records Ltd, a Warner Music Group Company ℗ 1981 Parlophone Records Ltd, a Warner Music Group Company", + "lyrics": null, + "label": null, + "links": null, + "chapters": null, + "performers": [ + "Dave Greenfield, Composer, Producer, Keyboards, Vocals", + "Jean", + "Hugh Cornwell, Composer, Producer, Guitar, Vocals", + "Jean Jacques Burnel, Producer, Bass Guitar, Vocals", + "Jet Black, Composer, Producer, Drums, Percussion", + "Jacques Burnell, Composer", + "The Stranglers, MainArtist" + ], + "preview": null, + "popularity": null, + "last_refresh": null + }, + "favorite": false, + "position": 183, + "duration": 207, + "artists": [ + { + "item_id": "26779", + "provider": "qobuz", + "name": "The Stranglers", + "version": "", + "sort_name": "stranglers, the", + "uri": "qobuz://artist/26779", + "external_ids": [], + "media_type": "artist", + "available": true, + "image": null + } + ], + "album": { + "item_id": "0724353468859", + "provider": "qobuz", + "name": "La Folie", + "version": "", + "sort_name": "folie, la", + "uri": "qobuz://album/0724353468859", + "external_ids": [["barcode", "0724353468859"]], + "media_type": "album", + "available": true, + "image": { + "type": "thumb", + "path": "https://static.qobuz.com/images/covers/59/88/0724353468859_600.jpg", + "provider": "qobuz", + "remotely_accessible": true + } + }, + "disc_number": 1, + "track_number": 9 + }, + "image": { + "type": "thumb", + "path": "https://static.qobuz.com/images/covers/59/88/0724353468859_600.jpg", + "provider": "qobuz", + "remotely_accessible": true + }, + "index": 0 + }, + "radio_source": [], + "flow_mode": false, + "resume_pos": 0 + } + ] +} diff --git a/tests/components/music_assistant/fixtures/players.json b/tests/components/music_assistant/fixtures/players.json new file mode 100644 index 00000000000..b7ff304a7ee --- /dev/null +++ b/tests/components/music_assistant/fixtures/players.json @@ -0,0 +1,149 @@ +{ + "players": [ + { + "player_id": "00:00:00:00:00:01", + "provider": "test", + "type": "player", + "name": "Test Player 1", + "available": true, + "powered": false, + "device_info": { + "model": "Test Model", + "address": "192.168.1.1", + "manufacturer": "Test Manufacturer" + }, + "supported_features": [ + "volume_set", + "volume_mute", + "pause", + "sync", + "power", + "enqueue" + ], + "elapsed_time": 0, + "elapsed_time_last_updated": 0, + "state": "idle", + "volume_level": 20, + "volume_muted": false, + "group_childs": [], + "active_source": "00:00:00:00:00:01", + "active_group": null, + "current_media": null, + "synced_to": null, + "enabled_by_default": true, + "needs_poll": false, + "poll_interval": 30, + "enabled": true, + "hidden": false, + "icon": "mdi-speaker", + "group_volume": 20, + "display_name": "Test Player 1", + "extra_data": {}, + "announcement_in_progress": false + }, + { + "player_id": "00:00:00:00:00:02", + "provider": "test", + "type": "player", + "name": "Test Player 2", + "available": true, + "powered": true, + "device_info": { + "model": "Test Model", + "address": "192.168.1.2", + "manufacturer": "Test Manufacturer" + }, + "supported_features": [ + "volume_set", + "volume_mute", + "pause", + "sync", + "power", + "enqueue" + ], + "elapsed_time": 0, + "elapsed_time_last_updated": 0, + "state": "playing", + "volume_level": 20, + "volume_muted": false, + "group_childs": [], + "active_source": "spotify", + "active_group": null, + "current_media": { + "uri": "spotify://track/5d95dc5be77e4f7eb4939f62cfef527b", + "media_type": "track", + "title": "Test Track", + "artist": "Test Artist", + "album": "Test Album", + "image_url": null, + "duration": 300, + "queue_id": null, + "queue_item_id": null, + "custom_data": null + }, + "synced_to": null, + "enabled_by_default": true, + "needs_poll": false, + "poll_interval": 30, + "enabled": true, + "hidden": false, + "icon": "mdi-speaker", + "group_volume": 20, + "display_name": "My Super Test Player 2", + "extra_data": {}, + "announcement_in_progress": false + }, + { + "player_id": "test_group_player_1", + "provider": "player_group", + "type": "group", + "name": "Test Group Player 1", + "available": true, + "powered": true, + "device_info": { + "model": "Sync Group", + "address": "", + "manufacturer": "Test" + }, + "supported_features": [ + "volume_set", + "volume_mute", + "pause", + "sync", + "power", + "enqueue" + ], + "elapsed_time": 0.0, + "elapsed_time_last_updated": 1730315437.9904983, + "state": "idle", + "volume_level": 6, + "volume_muted": false, + "group_childs": ["00:00:00:00:00:01", "00:00:00:00:00:02"], + "active_source": "test_group_player_1", + "active_group": null, + "current_media": { + "uri": "http://192.168.1.1:8097/single/test_group_player_1/5d95dc5be77e4f7eb4939f62cfef527b.flac?ts=1730313038", + "media_type": "unknown", + "title": null, + "artist": null, + "album": null, + "image_url": null, + "duration": null, + "queue_id": "test_group_player_1", + "queue_item_id": "5d95dc5be77e4f7eb4939f62cfef527b", + "custom_data": null + }, + "synced_to": null, + "enabled_by_default": true, + "needs_poll": true, + "poll_interval": 30, + "enabled": true, + "hidden": false, + "icon": "mdi-speaker-multiple", + "group_volume": 6, + "display_name": "Test Group Player 1", + "extra_data": {}, + "announcement_in_progress": false + } + ] +} diff --git a/tests/components/music_assistant/snapshots/test_media_player.ambr b/tests/components/music_assistant/snapshots/test_media_player.ambr new file mode 100644 index 00000000000..e3d7a4a0cbc --- /dev/null +++ b/tests/components/music_assistant/snapshots/test_media_player.ambr @@ -0,0 +1,190 @@ +# serializer version: 1 +# name: test_media_player[media_player.my_super_test_player_2-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'media_player', + 'entity_category': None, + 'entity_id': 'media_player.my_super_test_player_2', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': 'mdi:speaker', + 'original_name': None, + 'platform': 'music_assistant', + 'previous_unique_id': None, + 'supported_features': , + 'translation_key': None, + 'unique_id': '00:00:00:00:00:02', + 'unit_of_measurement': None, + }) +# --- +# name: test_media_player[media_player.my_super_test_player_2-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'active_queue': None, + 'app_id': 'spotify', + 'device_class': 'speaker', + 'entity_picture_local': None, + 'friendly_name': 'My Super Test Player 2', + 'group_members': list([ + ]), + 'icon': 'mdi:speaker', + 'is_volume_muted': False, + 'mass_player_type': 'player', + 'media_album_name': 'Test Album', + 'media_artist': 'Test Artist', + 'media_content_id': 'spotify://track/5d95dc5be77e4f7eb4939f62cfef527b', + 'media_content_type': , + 'media_duration': 300, + 'media_position': 0, + 'media_title': 'Test Track', + 'supported_features': , + 'volume_level': 0.2, + }), + 'context': , + 'entity_id': 'media_player.my_super_test_player_2', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'playing', + }) +# --- +# name: test_media_player[media_player.test_group_player_1-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'media_player', + 'entity_category': None, + 'entity_id': 'media_player.test_group_player_1', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': 'mdi:speaker-multiple', + 'original_name': None, + 'platform': 'music_assistant', + 'previous_unique_id': None, + 'supported_features': , + 'translation_key': None, + 'unique_id': 'test_group_player_1', + 'unit_of_measurement': None, + }) +# --- +# name: test_media_player[media_player.test_group_player_1-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'active_queue': 'test_group_player_1', + 'app_id': 'music_assistant', + 'device_class': 'speaker', + 'entity_picture_local': None, + 'friendly_name': 'Test Group Player 1', + 'group_members': list([ + 'media_player.my_super_test_player_2', + 'media_player.test_player_1', + ]), + 'icon': 'mdi:speaker-multiple', + 'is_volume_muted': False, + 'mass_player_type': 'group', + 'media_album_name': 'Use Your Illusion I', + 'media_artist': "Guns N' Roses", + 'media_content_id': 'spotify://track/3YRCqOhFifThpSRFJ1VWFM', + 'media_content_type': , + 'media_duration': 536, + 'media_position': 232, + 'media_position_updated_at': datetime.datetime(2024, 10, 30, 18, 31, 49, 565951, tzinfo=datetime.timezone.utc), + 'media_title': 'November Rain', + 'repeat': 'all', + 'shuffle': True, + 'supported_features': , + 'volume_level': 0.06, + }), + 'context': , + 'entity_id': 'media_player.test_group_player_1', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'idle', + }) +# --- +# name: test_media_player[media_player.test_player_1-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'media_player', + 'entity_category': None, + 'entity_id': 'media_player.test_player_1', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': 'mdi:speaker', + 'original_name': None, + 'platform': 'music_assistant', + 'previous_unique_id': None, + 'supported_features': , + 'translation_key': None, + 'unique_id': '00:00:00:00:00:01', + 'unit_of_measurement': None, + }) +# --- +# name: test_media_player[media_player.test_player_1-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'active_queue': '00:00:00:00:00:01', + 'device_class': 'speaker', + 'friendly_name': 'Test Player 1', + 'group_members': list([ + ]), + 'icon': 'mdi:speaker', + 'mass_player_type': 'player', + 'supported_features': , + }), + 'context': , + 'entity_id': 'media_player.test_player_1', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'off', + }) +# --- diff --git a/tests/components/music_assistant/test_media_player.py b/tests/components/music_assistant/test_media_player.py new file mode 100644 index 00000000000..2054ce1e6aa --- /dev/null +++ b/tests/components/music_assistant/test_media_player.py @@ -0,0 +1,263 @@ +"""Test Music Assistant media player entities.""" + +from unittest.mock import MagicMock, call + +from syrupy import SnapshotAssertion + +from homeassistant.components.media_player import ( + ATTR_MEDIA_REPEAT, + ATTR_MEDIA_SEEK_POSITION, + ATTR_MEDIA_SHUFFLE, + ATTR_MEDIA_VOLUME_LEVEL, + ATTR_MEDIA_VOLUME_MUTED, + DOMAIN as MEDIA_PLAYER_DOMAIN, + SERVICE_CLEAR_PLAYLIST, +) +from homeassistant.const import ( + ATTR_ENTITY_ID, + SERVICE_MEDIA_NEXT_TRACK, + SERVICE_MEDIA_PAUSE, + SERVICE_MEDIA_PLAY, + SERVICE_MEDIA_PREVIOUS_TRACK, + SERVICE_MEDIA_STOP, + SERVICE_REPEAT_SET, + SERVICE_SHUFFLE_SET, + SERVICE_TURN_OFF, + SERVICE_TURN_ON, + SERVICE_VOLUME_DOWN, + SERVICE_VOLUME_MUTE, + SERVICE_VOLUME_SET, + SERVICE_VOLUME_UP, + Platform, +) +from homeassistant.core import HomeAssistant +from homeassistant.helpers import entity_registry as er + +from .common import setup_integration_from_fixtures, snapshot_music_assistant_entities + + +async def test_media_player( + hass: HomeAssistant, + entity_registry: er.EntityRegistry, + snapshot: SnapshotAssertion, + music_assistant_client: MagicMock, +) -> None: + """Test media player.""" + await setup_integration_from_fixtures(hass, music_assistant_client) + snapshot_music_assistant_entities( + hass, entity_registry, snapshot, Platform.MEDIA_PLAYER + ) + + +async def test_media_player_basic_actions( + hass: HomeAssistant, + music_assistant_client: MagicMock, +) -> None: + """Test media_player entity basic actions (play/stop/pause etc.).""" + await setup_integration_from_fixtures(hass, music_assistant_client) + entity_id = "media_player.test_player_1" + mass_player_id = "00:00:00:00:00:01" + state = hass.states.get(entity_id) + assert state + for action, cmd in ( + (SERVICE_MEDIA_PLAY, "play"), + (SERVICE_MEDIA_PAUSE, "pause"), + (SERVICE_MEDIA_STOP, "stop"), + (SERVICE_MEDIA_PREVIOUS_TRACK, "previous"), + (SERVICE_MEDIA_NEXT_TRACK, "next"), + (SERVICE_VOLUME_UP, "volume_up"), + (SERVICE_VOLUME_DOWN, "volume_down"), + ): + await hass.services.async_call( + MEDIA_PLAYER_DOMAIN, + action, + { + ATTR_ENTITY_ID: entity_id, + }, + blocking=True, + ) + + assert music_assistant_client.send_command.call_count == 1 + assert music_assistant_client.send_command.call_args == call( + f"players/cmd/{cmd}", player_id=mass_player_id + ) + music_assistant_client.send_command.reset_mock() + + +async def test_media_player_seek_action( + hass: HomeAssistant, + music_assistant_client: MagicMock, +) -> None: + """Test media_player entity seek action.""" + await setup_integration_from_fixtures(hass, music_assistant_client) + entity_id = "media_player.test_player_1" + mass_player_id = "00:00:00:00:00:01" + state = hass.states.get(entity_id) + assert state + await hass.services.async_call( + MEDIA_PLAYER_DOMAIN, + "media_seek", + { + ATTR_ENTITY_ID: entity_id, + ATTR_MEDIA_SEEK_POSITION: 100, + }, + blocking=True, + ) + + assert music_assistant_client.send_command.call_count == 1 + assert music_assistant_client.send_command.call_args == call( + "players/cmd/seek", player_id=mass_player_id, position=100 + ) + + +async def test_media_player_volume_action( + hass: HomeAssistant, + music_assistant_client: MagicMock, +) -> None: + """Test media_player entity volume action.""" + await setup_integration_from_fixtures(hass, music_assistant_client) + entity_id = "media_player.test_player_1" + mass_player_id = "00:00:00:00:00:01" + state = hass.states.get(entity_id) + assert state + await hass.services.async_call( + MEDIA_PLAYER_DOMAIN, + SERVICE_VOLUME_SET, + { + ATTR_ENTITY_ID: entity_id, + ATTR_MEDIA_VOLUME_LEVEL: 0.5, + }, + blocking=True, + ) + assert music_assistant_client.send_command.call_count == 1 + assert music_assistant_client.send_command.call_args == call( + "players/cmd/volume_set", player_id=mass_player_id, volume_level=50 + ) + + +async def test_media_player_volume_mute_action( + hass: HomeAssistant, + music_assistant_client: MagicMock, +) -> None: + """Test media_player entity volume_mute action.""" + await setup_integration_from_fixtures(hass, music_assistant_client) + entity_id = "media_player.test_player_1" + mass_player_id = "00:00:00:00:00:01" + state = hass.states.get(entity_id) + assert state + await hass.services.async_call( + MEDIA_PLAYER_DOMAIN, + SERVICE_VOLUME_MUTE, + { + ATTR_ENTITY_ID: entity_id, + ATTR_MEDIA_VOLUME_MUTED: True, + }, + blocking=True, + ) + assert music_assistant_client.send_command.call_count == 1 + assert music_assistant_client.send_command.call_args == call( + "players/cmd/volume_mute", player_id=mass_player_id, muted=True + ) + + +async def test_media_player_turn_on_off_actions( + hass: HomeAssistant, + music_assistant_client: MagicMock, +) -> None: + """Test media_player entity turn_on/turn_off actions.""" + await setup_integration_from_fixtures(hass, music_assistant_client) + entity_id = "media_player.test_player_1" + mass_player_id = "00:00:00:00:00:01" + state = hass.states.get(entity_id) + assert state + for action, pwr in ( + (SERVICE_TURN_ON, True), + (SERVICE_TURN_OFF, False), + ): + await hass.services.async_call( + MEDIA_PLAYER_DOMAIN, + action, + { + ATTR_ENTITY_ID: entity_id, + }, + blocking=True, + ) + assert music_assistant_client.send_command.call_count == 1 + assert music_assistant_client.send_command.call_args == call( + "players/cmd/power", player_id=mass_player_id, powered=pwr + ) + music_assistant_client.send_command.reset_mock() + + +async def test_media_player_shuffle_set_action( + hass: HomeAssistant, + music_assistant_client: MagicMock, +) -> None: + """Test media_player entity shuffle_set action.""" + await setup_integration_from_fixtures(hass, music_assistant_client) + entity_id = "media_player.test_player_1" + mass_player_id = "00:00:00:00:00:01" + state = hass.states.get(entity_id) + assert state + await hass.services.async_call( + MEDIA_PLAYER_DOMAIN, + SERVICE_SHUFFLE_SET, + { + ATTR_ENTITY_ID: entity_id, + ATTR_MEDIA_SHUFFLE: True, + }, + blocking=True, + ) + assert music_assistant_client.send_command.call_count == 1 + assert music_assistant_client.send_command.call_args == call( + "player_queues/shuffle", queue_id=mass_player_id, shuffle_enabled=True + ) + + +async def test_media_player_repeat_set_action( + hass: HomeAssistant, + music_assistant_client: MagicMock, +) -> None: + """Test media_player entity repeat_set action.""" + await setup_integration_from_fixtures(hass, music_assistant_client) + entity_id = "media_player.test_player_1" + mass_player_id = "00:00:00:00:00:01" + state = hass.states.get(entity_id) + assert state + await hass.services.async_call( + MEDIA_PLAYER_DOMAIN, + SERVICE_REPEAT_SET, + { + ATTR_ENTITY_ID: entity_id, + ATTR_MEDIA_REPEAT: "one", + }, + blocking=True, + ) + assert music_assistant_client.send_command.call_count == 1 + assert music_assistant_client.send_command.call_args == call( + "player_queues/repeat", queue_id=mass_player_id, repeat_mode="one" + ) + + +async def test_media_player_clear_playlist_action( + hass: HomeAssistant, + music_assistant_client: MagicMock, +) -> None: + """Test media_player entity clear_playlist action.""" + await setup_integration_from_fixtures(hass, music_assistant_client) + entity_id = "media_player.test_player_1" + mass_player_id = "00:00:00:00:00:01" + state = hass.states.get(entity_id) + assert state + await hass.services.async_call( + MEDIA_PLAYER_DOMAIN, + SERVICE_CLEAR_PLAYLIST, + { + ATTR_ENTITY_ID: entity_id, + }, + blocking=True, + ) + assert music_assistant_client.send_command.call_count == 1 + assert music_assistant_client.send_command.call_args == call( + "player_queues/clear", queue_id=mass_player_id + )