/
/
/
1"""Tests for WiiM player provider."""
2
3from unittest.mock import AsyncMock, MagicMock
4
5import pytest
6from music_assistant_models.enums import PlaybackState, PlayerFeature
7from music_assistant_models.errors import PlayerCommandFailed, UnsupportedFeaturedException
8from wiim import PlayingStatus
9from wiim.exceptions import (
10 WiimDeviceException,
11 WiimException,
12 WiimInvalidDataException,
13 WiimRequestException,
14)
15
16from music_assistant.models.player import PlayerMedia
17from music_assistant.providers.wiim.constants import (
18 BACKEND_GENERIC,
19 BACKEND_OFFICIAL,
20 PLAYER_ID_PREFIX,
21 SOURCE_NETWORK,
22)
23from music_assistant.providers.wiim.player import SDK_TO_MA_STATE, WiimPlayer
24
25
26@pytest.fixture
27def mock_wiim_device() -> MagicMock:
28 """Create a mock WiimDevice."""
29 device = MagicMock()
30 device.name = "Test WiiM Pro"
31 device.udn = "uuid:test-wiim-001"
32 device.available = True
33 device.volume = 50
34 device.is_muted = False
35 device.playing_status = None
36 device.play_mode = None
37 device.current_media = None
38 device.model_name = "WiiM Pro"
39 device.manufacturer = "Linkplay"
40 device.firmware_version = "4.8.1"
41 device.ip_address = "192.168.1.100"
42 device.supports_http_api = True
43 device.supported_input_modes = ("Network", "Bluetooth", "Line In", "Optical In")
44 device.async_play = AsyncMock()
45 device.async_pause = AsyncMock()
46 device.async_stop = AsyncMock()
47 device.async_set_volume = AsyncMock()
48 device.async_set_mute = AsyncMock()
49 device.async_set_play_mode = AsyncMock()
50 device.sync_device_duration_and_position = AsyncMock()
51 device.async_update_http_status = AsyncMock()
52 device.disconnect = AsyncMock()
53 device.ensure_subscriptions = AsyncMock()
54 device.general_event_callback = None
55 device.rendering_control_event_callback = None
56 device.av_transport_event_callback = None
57 device.play_queue_event_callback = None
58 return device
59
60
61@pytest.fixture
62def mock_controller() -> MagicMock:
63 """Create a mock WiimController."""
64 controller = MagicMock()
65 snapshot = MagicMock()
66 snapshot.role = "standalone"
67 snapshot.leader_udn = "uuid:test-wiim-001"
68 snapshot.member_udns = ("uuid:test-wiim-001",)
69 controller.get_group_snapshot.return_value = snapshot
70 controller.get_group_members.return_value = []
71 controller.get_device.return_value = MagicMock()
72 controller.async_join_group = AsyncMock()
73 controller.async_ungroup_device = AsyncMock()
74 return controller
75
76
77@pytest.fixture
78def mock_provider(mock_controller: MagicMock) -> MagicMock:
79 """Create a mock WiimProvider."""
80 provider = MagicMock()
81 provider.wiim_controller = mock_controller
82 provider.instance_id = "wiim_test"
83 provider.domain = "wiim"
84 provider.manifest = MagicMock()
85 provider.manifest.domain = "wiim"
86 provider.mass = MagicMock()
87 provider.mass.players = MagicMock()
88
89 config = MagicMock()
90 config.name = None
91 config.default_name = "Test WiiM Pro"
92 config.enabled = True
93 config.player_type = None
94 config.get_value = MagicMock(return_value=None)
95 provider.mass.config.get_base_player_config.return_value = config
96 return provider
97
98
99class TestSDKStateMapping:
100 """Test SDK to MA state mapping."""
101
102 def test_playing_maps_to_playing(self) -> None:
103 """PLAYING should map to PlaybackState.PLAYING."""
104 assert SDK_TO_MA_STATE[PlayingStatus.PLAYING] == PlaybackState.PLAYING
105
106 def test_paused_maps_to_paused(self) -> None:
107 """PAUSED should map to PlaybackState.PAUSED."""
108 assert SDK_TO_MA_STATE[PlayingStatus.PAUSED] == PlaybackState.PAUSED
109
110 def test_stopped_maps_to_idle(self) -> None:
111 """STOPPED should map to PlaybackState.IDLE."""
112 assert SDK_TO_MA_STATE[PlayingStatus.STOPPED] == PlaybackState.IDLE
113
114 def test_loading_maps_to_playing(self) -> None:
115 """LOADING should map to PlaybackState.PLAYING."""
116 assert SDK_TO_MA_STATE[PlayingStatus.LOADING] == PlaybackState.PLAYING
117
118 def test_all_sdk_states_mapped(self) -> None:
119 """All non-UNKNOWN SDK states should have a mapping."""
120 for status in PlayingStatus:
121 if status != PlayingStatus.UNKNOWN:
122 assert status in SDK_TO_MA_STATE, f"{status} not mapped"
123
124
125class TestFalsePlayingFilter:
126 """A uri-less PLAYING report in network mode must not become PLAYING state."""
127
128 def _make_player(self, provider: MagicMock, device: MagicMock) -> WiimPlayer:
129 player = WiimPlayer(provider=provider, player_id="uuid:test", device=device)
130 player.update_state = MagicMock() # type: ignore[misc,method-assign]
131 return player
132
133 def test_false_playing_ack_is_suppressed(
134 self, mock_provider: MagicMock, mock_wiim_device: MagicMock
135 ) -> None:
136 """
137 The transient PLAYING ack without media loaded must keep the previous state.
138
139 The device acks (group) transport commands with a short false PLAYING
140 report before any track is loaded; propagating it causes a
141 PLAYING->IDLE->PLAYING flicker downstream.
142 """
143 mock_wiim_device.play_mode = SOURCE_NETWORK
144 mock_wiim_device.current_media = None
145 mock_wiim_device.playing_status = PlayingStatus.PLAYING
146 player = self._make_player(mock_provider, mock_wiim_device)
147 player._attr_playback_state = PlaybackState.IDLE
148
149 player._update_ma_state_from_sdk_cache()
150
151 assert player._attr_playback_state == PlaybackState.IDLE
152
153 def test_loading_without_uri_is_suppressed(
154 self, mock_provider: MagicMock, mock_wiim_device: MagicMock
155 ) -> None:
156 """LOADING maps to PLAYING and gets the same uri-less filter."""
157 mock_wiim_device.play_mode = SOURCE_NETWORK
158 mock_wiim_device.current_media = None
159 mock_wiim_device.playing_status = PlayingStatus.LOADING
160 player = self._make_player(mock_provider, mock_wiim_device)
161 player._attr_playback_state = PlaybackState.IDLE
162
163 player._update_ma_state_from_sdk_cache()
164
165 assert player._attr_playback_state == PlaybackState.IDLE
166
167 def test_playing_kept_when_uri_drops_mid_playback(
168 self, mock_provider: MagicMock, mock_wiim_device: MagicMock
169 ) -> None:
170 """The filter keeps the previous state; it never forces a playing player idle."""
171 mock_wiim_device.play_mode = SOURCE_NETWORK
172 mock_wiim_device.current_media = None
173 mock_wiim_device.playing_status = PlayingStatus.PLAYING
174 player = self._make_player(mock_provider, mock_wiim_device)
175 player._attr_playback_state = PlaybackState.PLAYING
176
177 player._update_ma_state_from_sdk_cache()
178
179 assert player._attr_playback_state == PlaybackState.PLAYING
180
181 def test_playing_accepted_once_uri_present(
182 self, mock_provider: MagicMock, mock_wiim_device: MagicMock
183 ) -> None:
184 """A PLAYING report with media loaded is a real start and passes through."""
185 media = MagicMock()
186 media.uri = "http://192.168.1.80:8097/single/abc/queue/item/uuid:test.flac"
187 mock_wiim_device.play_mode = SOURCE_NETWORK
188 mock_wiim_device.current_media = media
189 mock_wiim_device.playing_status = PlayingStatus.PLAYING
190 player = self._make_player(mock_provider, mock_wiim_device)
191 player._attr_playback_state = PlaybackState.IDLE
192
193 player._update_ma_state_from_sdk_cache()
194
195 assert player._attr_playback_state == PlaybackState.PLAYING
196
197 def test_external_input_playing_without_uri_accepted(
198 self, mock_provider: MagicMock, mock_wiim_device: MagicMock
199 ) -> None:
200 """External inputs legitimately play without a URI and must not be filtered."""
201 mock_wiim_device.play_mode = "Line In"
202 mock_wiim_device.current_media = None
203 mock_wiim_device.playing_status = PlayingStatus.PLAYING
204 player = self._make_player(mock_provider, mock_wiim_device)
205 player._attr_playback_state = PlaybackState.IDLE
206
207 player._update_ma_state_from_sdk_cache()
208
209 assert player._attr_playback_state == PlaybackState.PLAYING
210
211 def test_unknown_play_mode_trusts_device(
212 self, mock_provider: MagicMock, mock_wiim_device: MagicMock
213 ) -> None:
214 """Without a known play mode the device report is trusted (no suppression)."""
215 mock_wiim_device.play_mode = None
216 mock_wiim_device.current_media = None
217 mock_wiim_device.playing_status = PlayingStatus.PLAYING
218 player = self._make_player(mock_provider, mock_wiim_device)
219 player._attr_playback_state = PlaybackState.IDLE
220
221 player._update_ma_state_from_sdk_cache()
222
223 assert player._attr_playback_state == PlaybackState.PLAYING
224
225 def test_stopped_report_unaffected(
226 self, mock_provider: MagicMock, mock_wiim_device: MagicMock
227 ) -> None:
228 """The filter only guards PLAYING-mapped reports; STOPPED passes through."""
229 mock_wiim_device.play_mode = SOURCE_NETWORK
230 mock_wiim_device.current_media = None
231 mock_wiim_device.playing_status = PlayingStatus.STOPPED
232 player = self._make_player(mock_provider, mock_wiim_device)
233 player._attr_playback_state = PlaybackState.PLAYING
234
235 player._update_ma_state_from_sdk_cache()
236
237 assert player._attr_playback_state == PlaybackState.IDLE
238
239
240class TestSupportedFeatures:
241 """Test that required features are declared."""
242
243 def test_play_media_in_features(
244 self, mock_provider: MagicMock, mock_wiim_device: MagicMock
245 ) -> None:
246 """PLAY_MEDIA should be in supported features."""
247 player = WiimPlayer(provider=mock_provider, player_id="uuid:test", device=mock_wiim_device)
248 assert PlayerFeature.PLAY_MEDIA in player._attr_supported_features
249
250 def test_volume_features(self, mock_provider: MagicMock, mock_wiim_device: MagicMock) -> None:
251 """VOLUME_SET and VOLUME_MUTE should be in supported features."""
252 player = WiimPlayer(provider=mock_provider, player_id="uuid:test", device=mock_wiim_device)
253 assert PlayerFeature.VOLUME_SET in player._attr_supported_features
254 assert PlayerFeature.VOLUME_MUTE in player._attr_supported_features
255
256 def test_select_source_in_features(
257 self, mock_provider: MagicMock, mock_wiim_device: MagicMock
258 ) -> None:
259 """SELECT_SOURCE should be in supported features."""
260 player = WiimPlayer(provider=mock_provider, player_id="uuid:test", device=mock_wiim_device)
261 assert PlayerFeature.SELECT_SOURCE in player._attr_supported_features
262
263
264class TestGroupMembers:
265 """Test group member state handling."""
266
267 def test_unmanaged_group_members_are_ignored(
268 self, mock_provider: MagicMock, mock_wiim_device: MagicMock
269 ) -> None:
270 """Only group members registered in Music Assistant should be reported."""
271 managed_udn = "uuid:test-wiim-002"
272 unmanaged_udn = "uuid:test-linkplay-001"
273 leader_player_id = f"{PLAYER_ID_PREFIX}{mock_wiim_device.udn}"
274 managed_player_id = f"{PLAYER_ID_PREFIX}{managed_udn}"
275 snapshot = mock_provider.wiim_controller.get_group_snapshot.return_value
276 snapshot.role = "leader"
277 snapshot.member_udns = (mock_wiim_device.udn, managed_udn, unmanaged_udn)
278 mock_provider.wiim_controller.get_group_members.side_effect = ValueError(
279 f"Device {unmanaged_udn} is not managed by the controller"
280 )
281 mock_provider.mass.players.get_player.side_effect = {managed_player_id: MagicMock()}.get
282 player = WiimPlayer(
283 provider=mock_provider,
284 player_id=leader_player_id,
285 device=mock_wiim_device,
286 )
287 player.update_state = MagicMock() # type: ignore[misc,method-assign]
288
289 player._update_ma_state_from_sdk_cache()
290
291 assert player._attr_group_members == [leader_player_id, managed_player_id]
292 mock_provider.wiim_controller.get_group_members.assert_not_called()
293
294 def test_only_unmanaged_group_members_reports_no_group(
295 self, mock_provider: MagicMock, mock_wiim_device: MagicMock
296 ) -> None:
297 """A group without any MA-managed followers should not be reported."""
298 snapshot = mock_provider.wiim_controller.get_group_snapshot.return_value
299 snapshot.role = "leader"
300 snapshot.member_udns = (mock_wiim_device.udn, "uuid:test-linkplay-001")
301 mock_provider.mass.players.get_player.return_value = None
302 player = WiimPlayer(
303 provider=mock_provider,
304 player_id=f"{PLAYER_ID_PREFIX}{mock_wiim_device.udn}",
305 device=mock_wiim_device,
306 )
307 player.update_state = MagicMock() # type: ignore[misc,method-assign]
308
309 player._update_ma_state_from_sdk_cache()
310
311 assert player._attr_group_members == []
312
313
314class TestMixedGroupReadOnly:
315 """An externally-created mixed group is read-only from the official side (until layer 2)."""
316
317 def _leader_with_member(
318 self,
319 mock_provider: MagicMock,
320 mock_wiim_device: MagicMock,
321 member_udn: str,
322 member_backend: str,
323 ) -> WiimPlayer:
324 leader_player_id = f"{PLAYER_ID_PREFIX}{mock_wiim_device.udn}"
325 member_player_id = f"{PLAYER_ID_PREFIX}{member_udn}"
326 snapshot = mock_provider.wiim_controller.get_group_snapshot.return_value
327 snapshot.role = "leader"
328 snapshot.member_udns = (mock_wiim_device.udn, member_udn)
329 member = MagicMock(linkplay_backend=member_backend)
330 mock_provider.mass.players.get_player.side_effect = {member_player_id: member}.get
331 player = WiimPlayer(
332 provider=mock_provider, player_id=leader_player_id, device=mock_wiim_device
333 )
334 player.update_state = MagicMock() # type: ignore[misc,method-assign]
335 # the shared mixed-group predicate scans registered provider players
336 mock_provider.players = [player]
337 return player
338
339 def test_generic_member_makes_group_mixed_and_read_only(
340 self, mock_provider: MagicMock, mock_wiim_device: MagicMock
341 ) -> None:
342 """A generic LinkPlay member makes the group mixed; grouping is withdrawn."""
343 player = self._leader_with_member(
344 mock_provider, mock_wiim_device, "uuid:test-linkplay-001", BACKEND_GENERIC
345 )
346 player._update_ma_state_from_sdk_cache()
347 assert player._in_mixed_group is True
348 # the mixed group is still represented read-only (leader first)
349 assert player._attr_group_members == [
350 f"{PLAYER_ID_PREFIX}{mock_wiim_device.udn}",
351 f"{PLAYER_ID_PREFIX}uuid:test-linkplay-001",
352 ]
353 assert PlayerFeature.SET_MEMBERS not in player.supported_features
354 assert player.can_group_with == set()
355
356 def test_official_member_group_is_not_mixed(
357 self, mock_provider: MagicMock, mock_wiim_device: MagicMock
358 ) -> None:
359 """An all-official group is not mixed; grouping stays available."""
360 player = self._leader_with_member(
361 mock_provider, mock_wiim_device, "uuid:test-wiim-002", BACKEND_OFFICIAL
362 )
363 player._update_ma_state_from_sdk_cache()
364 assert player._in_mixed_group is False
365 assert PlayerFeature.SET_MEMBERS in player.supported_features
366
367 def test_mixed_to_same_backend_transition_restores_grouping(
368 self, mock_provider: MagicMock, mock_wiim_device: MagicMock
369 ) -> None:
370 """Leaving a mixed group restores the grouping capability on the next update."""
371 player = self._leader_with_member(
372 mock_provider, mock_wiim_device, "uuid:test-linkplay-001", BACKEND_GENERIC
373 )
374 player._update_ma_state_from_sdk_cache()
375 assert PlayerFeature.SET_MEMBERS not in player.supported_features
376 # the device becomes standalone again
377 snapshot = mock_provider.wiim_controller.get_group_snapshot.return_value
378 snapshot.role = "standalone"
379 snapshot.member_udns = (mock_wiim_device.udn,)
380 player._update_ma_state_from_sdk_cache()
381 assert PlayerFeature.SET_MEMBERS in player.supported_features
382
383 async def test_set_members_rejected_while_mixed(
384 self, mock_provider: MagicMock, mock_wiim_device: MagicMock
385 ) -> None:
386 """A direct set_members on a mixed group is rejected, not partially applied."""
387 player = self._leader_with_member(
388 mock_provider, mock_wiim_device, "uuid:test-linkplay-001", BACKEND_GENERIC
389 )
390 player._update_ma_state_from_sdk_cache() # populates a mixed group
391 with pytest.raises(UnsupportedFeaturedException):
392 await player.set_members(player_ids_to_add=["wiim_uuid:other"])
393
394 def test_official_follower_of_generic_leader_is_mixed(
395 self, mock_provider: MagicMock, mock_wiim_device: MagicMock
396 ) -> None:
397 """An official device a generic leader lists as a follower is read-only too."""
398 player = WiimPlayer(
399 provider=mock_provider,
400 player_id=f"{PLAYER_ID_PREFIX}{mock_wiim_device.udn}",
401 device=mock_wiim_device,
402 )
403 player.update_state = MagicMock() # type: ignore[misc,method-assign]
404 player._attr_group_members = [] # a follower manages nothing itself
405 generic_leader = MagicMock(
406 player_id="wiim_uuid:generic-leader", linkplay_backend=BACKEND_GENERIC
407 )
408 generic_leader._attr_group_members = [generic_leader.player_id, player.player_id]
409 mock_provider.players = [player, generic_leader]
410 assert PlayerFeature.SET_MEMBERS not in player.supported_features
411 assert player.can_group_with == set()
412
413 def test_can_group_with_excludes_official_peer_in_mixed_group(
414 self, mock_provider: MagicMock, mock_wiim_device: MagicMock
415 ) -> None:
416 """An official peer locked in a generic-led mixed group is not offered as a target."""
417 leader = WiimPlayer(
418 provider=mock_provider,
419 player_id=f"{PLAYER_ID_PREFIX}{mock_wiim_device.udn}",
420 device=mock_wiim_device,
421 )
422 leader.update_state = MagicMock() # type: ignore[misc,method-assign]
423 leader._attr_available = True
424 # an official peer that is a follower in a generic-led mixed group
425 peer = WiimPlayer(
426 provider=mock_provider,
427 player_id="wiim_uuid:official-peer",
428 device=mock_wiim_device,
429 )
430 peer.update_state = MagicMock() # type: ignore[misc,method-assign]
431 peer._attr_available = True
432 peer._attr_group_members = []
433 generic_leader = MagicMock(
434 player_id="wiim_uuid:generic-leader", linkplay_backend=BACKEND_GENERIC
435 )
436 generic_leader._attr_group_members = [generic_leader.player_id, peer.player_id]
437 mock_provider.players = [leader, peer, generic_leader]
438 assert peer._in_mixed_group is True
439 assert peer.player_id not in leader.can_group_with
440
441
442class TestSourceList:
443 """Test dynamic source list construction."""
444
445 @pytest.mark.asyncio
446 async def test_setup_adds_device_input_modes(
447 self, mock_provider: MagicMock, mock_wiim_device: MagicMock
448 ) -> None:
449 """setup() should add sources for device-supported input modes."""
450 player = WiimPlayer(provider=mock_provider, player_id="uuid:test", device=mock_wiim_device)
451 await player.setup()
452 source_ids = [s.id for s in player._attr_source_list]
453 assert "bluetooth" in source_ids
454 assert "line_in" in source_ids
455 assert "optical" in source_ids
456
457 @pytest.mark.asyncio
458 async def test_setup_adds_passive_sources(
459 self, mock_provider: MagicMock, mock_wiim_device: MagicMock
460 ) -> None:
461 """setup() should add passive sources (AirPlay, Spotify)."""
462 player = WiimPlayer(provider=mock_provider, player_id="uuid:test", device=mock_wiim_device)
463 await player.setup()
464 source_ids = [s.id for s in player._attr_source_list]
465 assert "airplay" in source_ids
466 assert "spotify" in source_ids
467
468 @pytest.mark.asyncio
469 async def test_setup_skips_unknown_input_modes(
470 self, mock_provider: MagicMock, mock_wiim_device: MagicMock
471 ) -> None:
472 """setup() should skip input modes not in INPUT_MODE_SOURCES."""
473 mock_wiim_device.supported_input_modes = ("Network", "FutureMode")
474 player = WiimPlayer(provider=mock_provider, player_id="uuid:test", device=mock_wiim_device)
475 await player.setup()
476 source_ids = [s.id for s in player._attr_source_list]
477 assert "futuremode" not in source_ids
478
479
480class TestVolumeCommand:
481 """Test the volume command reaches the device."""
482
483 @pytest.mark.asyncio
484 async def test_volume_set_delegates_to_device(
485 self, mock_provider: MagicMock, mock_wiim_device: MagicMock
486 ) -> None:
487 """Setting the volume should reach the device and land in the player state."""
488 player = WiimPlayer(provider=mock_provider, player_id="uuid:test", device=mock_wiim_device)
489 player.update_state = MagicMock() # type: ignore[misc,method-assign]
490
491 async def _apply_volume(volume_level: int) -> None:
492 mock_wiim_device.volume = volume_level
493
494 mock_wiim_device.async_set_volume = AsyncMock(side_effect=_apply_volume)
495 await player.volume_set(42)
496
497 mock_wiim_device.async_set_volume.assert_awaited_once_with(42)
498 assert player._attr_volume_level == 42
499
500
501class TestErrorHandling:
502 """Test that command errors mark device unavailable."""
503
504 @pytest.mark.asyncio
505 async def test_play_error_refreshes_state(
506 self, mock_provider: MagicMock, mock_wiim_device: MagicMock
507 ) -> None:
508 """Play command error should refresh state without marking unavailable."""
509 mock_wiim_device.async_play.side_effect = WiimRequestException("timeout")
510 player = WiimPlayer(provider=mock_provider, player_id="uuid:test", device=mock_wiim_device)
511 player.update_state = MagicMock() # type: ignore[misc,method-assign]
512 await player.play()
513 assert player._attr_available is True
514 player.update_state.assert_called()
515
516 @pytest.mark.asyncio
517 async def test_volume_set_error_refreshes_state(
518 self, mock_provider: MagicMock, mock_wiim_device: MagicMock
519 ) -> None:
520 """Volume set error should refresh state without marking unavailable."""
521 mock_wiim_device.async_set_volume.side_effect = WiimDeviceException("disconnected")
522 player = WiimPlayer(provider=mock_provider, player_id="uuid:test", device=mock_wiim_device)
523 player.update_state = MagicMock() # type: ignore[misc,method-assign]
524 await player.volume_set(50)
525 assert player._attr_available is True
526 player.update_state.assert_called()
527
528 @pytest.mark.asyncio
529 async def test_volume_set_survives_invalid_data_from_device(
530 self, mock_provider: MagicMock, mock_wiim_device: MagicMock
531 ) -> None:
532 """A speaker answering a volume command with something other than OK must not throw."""
533 mock_wiim_device.async_set_volume = AsyncMock(
534 side_effect=WiimInvalidDataException("did not return 'OK'")
535 )
536 player = WiimPlayer(provider=mock_provider, player_id="uuid:test", device=mock_wiim_device)
537 player.update_state = MagicMock() # type: ignore[misc,method-assign]
538 await player.volume_set(42)
539 assert player._attr_available is True
540
541 @pytest.mark.asyncio
542 async def test_select_source_survives_invalid_data_from_device(
543 self, mock_provider: MagicMock, mock_wiim_device: MagicMock
544 ) -> None:
545 """A speaker rejecting a source change must not throw out of the command."""
546 mock_wiim_device.async_set_play_mode = AsyncMock(
547 side_effect=WiimInvalidDataException("did not return 'OK'")
548 )
549 player = WiimPlayer(provider=mock_provider, player_id="uuid:test", device=mock_wiim_device)
550 player.update_state = MagicMock() # type: ignore[misc,method-assign]
551 await player.select_source("bluetooth")
552 assert player._attr_available is True
553
554 @pytest.mark.asyncio
555 async def test_stop_error_refreshes_state(
556 self, mock_provider: MagicMock, mock_wiim_device: MagicMock
557 ) -> None:
558 """Stop command error should refresh state without marking unavailable."""
559 mock_wiim_device.async_stop.side_effect = WiimRequestException("connection lost")
560 player = WiimPlayer(provider=mock_provider, player_id="uuid:test", device=mock_wiim_device)
561 player.update_state = MagicMock() # type: ignore[misc,method-assign]
562 await player.stop()
563 assert player._attr_available is True
564 player.update_state.assert_called()
565
566 @pytest.mark.asyncio
567 async def test_pause_error_refreshes_state(
568 self, mock_provider: MagicMock, mock_wiim_device: MagicMock
569 ) -> None:
570 """Pause command error should refresh state without marking unavailable."""
571 mock_wiim_device.async_pause.side_effect = WiimDeviceException("timeout")
572 player = WiimPlayer(provider=mock_provider, player_id="uuid:test", device=mock_wiim_device)
573 player.update_state = MagicMock() # type: ignore[misc,method-assign]
574 await player.pause()
575 assert player._attr_available is True
576 player.update_state.assert_called()
577
578
579class TestStalePositionOnNewStream:
580 """A new stream handed to the device must not inherit the previous position."""
581
582 def _make_player(self, provider: MagicMock, device: MagicMock) -> WiimPlayer:
583 player = WiimPlayer(provider=provider, player_id="uuid:test", device=device)
584 player.update_state = MagicMock() # type: ignore[misc,method-assign]
585 return player
586
587 @pytest.mark.asyncio
588 async def test_play_media_resets_elapsed_time(
589 self, mock_provider: MagicMock, mock_wiim_device: MagicMock
590 ) -> None:
591 """play_media() must clear the stale elapsed_time anchor from prior content."""
592 stream_url = "http://192.168.1.80:8097/single/abc/queue/item/uuid:test.flac"
593 mock_provider.mass.streams.resolve_stream_url = AsyncMock(return_value=stream_url)
594 player = self._make_player(mock_provider, mock_wiim_device)
595 player._attr_elapsed_time = 273
596 player._attr_elapsed_time_last_updated = 1000.0
597
598 await player.play_media(PlayerMedia(uri="library://track/1", title="Some Track"))
599
600 assert player._attr_elapsed_time == 0
601 assert player._attr_elapsed_time_last_updated is not None
602 assert player._attr_elapsed_time_last_updated > 1000.0
603
604 @pytest.mark.asyncio
605 async def test_play_media_then_sync_position_end_to_end(
606 self, mock_provider: MagicMock, mock_wiim_device: MagicMock
607 ) -> None:
608 """
609 The device still reports the previous AirPlay content after play_media.
610
611 Its metadata makes MA rebuild _attr_current_media from the device's own
612 uri, so the position guard must not key off _attr_current_media.
613 """
614 stream_url = "http://192.168.1.80:8097/single/abc/queue/item/uuid:test.flac"
615 mock_provider.mass.streams.resolve_stream_url = AsyncMock(return_value=stream_url)
616
617 device_media = MagicMock()
618 device_media.uri = "wiimu_airplay"
619 device_media.title = "Previous Song"
620 device_media.artist = "Previous Artist"
621 device_media.album = "Previous Album"
622 device_media.position = 273
623 mock_wiim_device.play_mode = SOURCE_NETWORK
624 mock_wiim_device.current_media = device_media
625 mock_wiim_device.playing_status = PlayingStatus.PLAYING
626
627 player = self._make_player(mock_provider, mock_wiim_device)
628 player._attr_elapsed_time = 273
629
630 await player.play_media(PlayerMedia(uri="library://track/1", title="New Track"))
631 assert player._attr_elapsed_time == 0
632
633 await player._sync_position()
634
635 assert player._attr_elapsed_time == 0
636
637 @pytest.mark.asyncio
638 async def test_failed_play_media_releases_the_guard(
639 self, mock_provider: MagicMock, mock_wiim_device: MagicMock
640 ) -> None:
641 """A device that never took our stream must not stay guarded against its own position."""
642 stream_url = "http://192.168.1.80:8097/single/abc/queue/item/uuid:test.flac"
643 mock_provider.mass.streams.resolve_stream_url = AsyncMock(return_value=stream_url)
644 mock_wiim_device.async_play = AsyncMock(side_effect=WiimDeviceException("boom"))
645 player = self._make_player(mock_provider, mock_wiim_device)
646
647 await player.play_media(PlayerMedia(uri="library://track/1", title="New Track"))
648
649 device_media = MagicMock()
650 device_media.uri = "wiimu_airplay"
651 device_media.position = 42
652 mock_wiim_device.current_media = device_media
653
654 await player._sync_position()
655
656 assert player._attr_elapsed_time == 42
657
658 @pytest.mark.asyncio
659 async def test_sync_position_ignores_position_reported_without_uri(
660 self, mock_provider: MagicMock, mock_wiim_device: MagicMock
661 ) -> None:
662 """The device clears its uri mid-handover while still reporting the old position."""
663 stream_url = "http://192.168.1.80:8097/single/abc/queue/item/uuid:test.flac"
664 mock_provider.mass.streams.resolve_stream_url = AsyncMock(return_value=stream_url)
665
666 device_media = MagicMock()
667 device_media.uri = None
668 device_media.title = None
669 device_media.artist = None
670 device_media.album = None
671 device_media.position = 273
672 mock_wiim_device.play_mode = SOURCE_NETWORK
673 mock_wiim_device.current_media = device_media
674
675 player = self._make_player(mock_provider, mock_wiim_device)
676
677 await player.play_media(PlayerMedia(uri="library://track/1", title="New Track"))
678 await player._sync_position()
679
680 assert player._attr_elapsed_time == 0
681
682 @pytest.mark.asyncio
683 async def test_sync_position_ignores_foreign_uri(
684 self, mock_provider: MagicMock, mock_wiim_device: MagicMock
685 ) -> None:
686 """The device's position is rejected while it hasn't loaded MA's stream uri."""
687 stream_uri = "http://192.168.1.80:8097/single/abc/queue/item/uuid:test.flac"
688 player = self._make_player(mock_provider, mock_wiim_device)
689 player._ma_stream_uri = stream_uri
690 player._attr_elapsed_time = 0
691 player._attr_elapsed_time_last_updated = 1000.0
692
693 device_media = MagicMock()
694 device_media.uri = "wiimu_airplay"
695 device_media.position = 273
696 mock_wiim_device.current_media = device_media
697
698 await player._sync_position()
699
700 assert player._attr_elapsed_time == 0
701 assert player._attr_elapsed_time_last_updated == 1000.0
702
703 @pytest.mark.asyncio
704 async def test_sync_position_accepts_matching_uri_and_clears_guard(
705 self, mock_provider: MagicMock, mock_wiim_device: MagicMock
706 ) -> None:
707 """Once the device reports MA's own uri, its position is trusted and the guard lifts."""
708 stream_uri = "http://192.168.1.80:8097/single/abc/queue/item/uuid:test.flac"
709 mock_provider.mass.streams.resolve_stream_url = AsyncMock(return_value=stream_uri)
710 player = self._make_player(mock_provider, mock_wiim_device)
711
712 device_media = MagicMock()
713 device_media.uri = stream_uri
714 device_media.position = 12
715 mock_wiim_device.current_media = device_media
716
717 await player.play_media(PlayerMedia(uri="library://track/1", title="New Track"))
718 player._attr_elapsed_time_last_updated = 1000.0
719
720 await player._sync_position()
721
722 assert player._attr_elapsed_time == 12
723 assert player._attr_elapsed_time_last_updated > 1000.0
724 assert player._ma_stream_uri is None
725
726 # A later switch to an external source must not be blocked by a stale guard.
727 device_media.uri = "wiimu_airplay"
728 device_media.position = 55
729 await player._sync_position()
730
731 assert player._attr_elapsed_time == 55
732
733 @pytest.mark.asyncio
734 async def test_sync_position_accepts_device_when_ma_not_driving_playback(
735 self, mock_provider: MagicMock, mock_wiim_device: MagicMock
736 ) -> None:
737 """With no stream handed over by MA (external source), the device's position is authoritative."""
738 player = self._make_player(mock_provider, mock_wiim_device)
739 player._ma_stream_uri = None
740 player._attr_elapsed_time = 0
741 player._attr_elapsed_time_last_updated = 1000.0
742
743 device_media = MagicMock()
744 device_media.uri = "wiimu_airplay"
745 device_media.position = 42
746 mock_wiim_device.current_media = device_media
747
748 await player._sync_position()
749
750 assert player._attr_elapsed_time == 42
751 assert player._attr_elapsed_time_last_updated > 1000.0
752
753
754class TestPollRefreshesTransportState:
755 """Polling must correct state the device stopped pushing events for."""
756
757 def _make_player(self, provider: MagicMock, device: MagicMock) -> WiimPlayer:
758 player = WiimPlayer(provider=provider, player_id="uuid:test", device=device)
759 player.update_state = MagicMock() # type: ignore[misc,method-assign]
760 return player
761
762 @pytest.mark.asyncio
763 async def test_poll_fetches_device_status(
764 self, mock_provider: MagicMock, mock_wiim_device: MagicMock
765 ) -> None:
766 """A poll must ask the device for its transport state, not just its position."""
767 player = self._make_player(mock_provider, mock_wiim_device)
768
769 await player.poll()
770
771 mock_wiim_device.async_update_http_status.assert_awaited_once()
772
773 @pytest.mark.asyncio
774 async def test_poll_applies_stop_reported_after_missed_events(
775 self, mock_provider: MagicMock, mock_wiim_device: MagicMock
776 ) -> None:
777 """A device that went to stop while events were lost must no longer read as paused."""
778 player = self._make_player(mock_provider, mock_wiim_device)
779 player._attr_playback_state = PlaybackState.PAUSED
780 mock_wiim_device.play_mode = SOURCE_NETWORK
781
782 async def _report_stopped() -> None:
783 mock_wiim_device.playing_status = PlayingStatus.STOPPED
784
785 mock_wiim_device.async_update_http_status = AsyncMock(side_effect=_report_stopped)
786
787 await player.poll()
788
789 assert player._attr_playback_state == PlaybackState.IDLE
790
791 @pytest.mark.asyncio
792 async def test_poll_survives_invalid_data_from_device(
793 self, mock_provider: MagicMock, mock_wiim_device: MagicMock
794 ) -> None:
795 """A 'Failed' or unparsable status response must not escape the poll."""
796 mock_wiim_device.async_update_http_status = AsyncMock(
797 side_effect=WiimInvalidDataException("Command getStatusEx returned 'Failed'")
798 )
799 player = self._make_player(mock_provider, mock_wiim_device)
800
801 await player.poll()
802
803 mock_wiim_device.sync_device_duration_and_position.assert_awaited_once()
804
805 @pytest.mark.asyncio
806 async def test_poll_skips_status_of_unavailable_device(
807 self, mock_provider: MagicMock, mock_wiim_device: MagicMock
808 ) -> None:
809 """A device the SDK already gave up on must not be queried again by the poll."""
810 mock_wiim_device.available = False
811 player = self._make_player(mock_provider, mock_wiim_device)
812
813 await player.poll()
814
815 mock_wiim_device.async_update_http_status.assert_not_awaited()
816
817
818class TestSetMembersTypeSafety:
819 """Official grouping must reject cross-backend members instead of crashing."""
820
821 async def test_add_generic_member_raises_unsupported(
822 self, mock_provider: MagicMock, mock_wiim_device: MagicMock
823 ) -> None:
824 """Adding a non-WiimPlayer member raises a typed error rather than AttributeError."""
825 leader = WiimPlayer(
826 provider=mock_provider,
827 player_id=f"{PLAYER_ID_PREFIX}{mock_wiim_device.udn}",
828 device=mock_wiim_device,
829 )
830 # a registered generic LinkPlay player (no .device attribute)
831 mock_provider.mass.players.get_player.return_value = MagicMock(spec=[])
832
833 with pytest.raises(UnsupportedFeaturedException):
834 await leader.set_members(player_ids_to_add=["wiim_uuid:generic"])
835
836 async def test_add_same_backend_member_joins(
837 self, mock_provider: MagicMock, mock_wiim_device: MagicMock
838 ) -> None:
839 """Adding another official WiiM player joins it via the controller."""
840 leader = WiimPlayer(
841 provider=mock_provider,
842 player_id=f"{PLAYER_ID_PREFIX}{mock_wiim_device.udn}",
843 device=mock_wiim_device,
844 )
845 follower = MagicMock(spec=WiimPlayer)
846 follower.player_id = "wiim_uuid:follower"
847 follower.device = MagicMock()
848 follower.device.udn = "uuid:follower"
849 follower._in_mixed_group = False
850 mock_provider.mass.players.get_player.return_value = follower
851
852 await leader.set_members(player_ids_to_add=["wiim_uuid:follower"])
853
854 mock_provider.wiim_controller.async_join_group.assert_awaited_once_with(
855 mock_wiim_device.udn, ["uuid:follower"]
856 )
857
858 def _leader(self, mock_provider: MagicMock, mock_wiim_device: MagicMock) -> WiimPlayer:
859 return WiimPlayer(
860 provider=mock_provider,
861 player_id=f"{PLAYER_ID_PREFIX}{mock_wiim_device.udn}",
862 device=mock_wiim_device,
863 )
864
865 def _follower(self, player_id: str, udn: str, *, mixed: bool = False) -> MagicMock:
866 follower = MagicMock(spec=WiimPlayer)
867 follower.player_id = player_id
868 follower.device = MagicMock(udn=udn)
869 follower._in_mixed_group = mixed
870 return follower
871
872 async def test_add_remove_conflict_rejected(
873 self, mock_provider: MagicMock, mock_wiim_device: MagicMock
874 ) -> None:
875 """Adding and removing the same member in one request is rejected before any call."""
876 leader = self._leader(mock_provider, mock_wiim_device)
877 with pytest.raises(PlayerCommandFailed):
878 await leader.set_members(
879 player_ids_to_add=["wiim_uuid:x"], player_ids_to_remove=["wiim_uuid:x"]
880 )
881 mock_provider.wiim_controller.async_join_group.assert_not_awaited()
882 mock_provider.wiim_controller.async_ungroup_device.assert_not_awaited()
883
884 async def test_remove_current_member_ungroups(
885 self, mock_provider: MagicMock, mock_wiim_device: MagicMock
886 ) -> None:
887 """Removing a current member ungroups it via the controller."""
888 leader = self._leader(mock_provider, mock_wiim_device)
889 follower = self._follower("wiim_uuid:follower", "uuid:follower")
890 mock_provider.mass.players.get_player.return_value = follower
891 snapshot = mock_provider.wiim_controller.get_group_snapshot.return_value
892 snapshot.member_udns = (mock_wiim_device.udn, "uuid:follower")
893 await leader.set_members(player_ids_to_remove=["wiim_uuid:follower"])
894 mock_provider.wiim_controller.async_ungroup_device.assert_awaited_once_with("uuid:follower")
895
896 async def test_valid_batch_removes_all(
897 self, mock_provider: MagicMock, mock_wiim_device: MagicMock
898 ) -> None:
899 """A fully valid multi-member removal ungroups every member."""
900 leader = self._leader(mock_provider, mock_wiim_device)
901 members = {
902 "wiim_uuid:a": self._follower("wiim_uuid:a", "uuid:a"),
903 "wiim_uuid:b": self._follower("wiim_uuid:b", "uuid:b"),
904 }
905 mock_provider.mass.players.get_player.side_effect = lambda pid, *_a, **_k: members.get(pid)
906 snapshot = mock_provider.wiim_controller.get_group_snapshot.return_value
907 snapshot.member_udns = (mock_wiim_device.udn, "uuid:a", "uuid:b")
908 await leader.set_members(player_ids_to_remove=["wiim_uuid:a", "wiim_uuid:b"])
909 assert mock_provider.wiim_controller.async_ungroup_device.await_count == 2
910
911 async def test_remove_moved_member_untouched(
912 self, mock_provider: MagicMock, mock_wiim_device: MagicMock
913 ) -> None:
914 """A removal target the leader no longer owns is rejected without any ungroup."""
915 leader = self._leader(mock_provider, mock_wiim_device)
916 follower = self._follower("wiim_uuid:follower", "uuid:follower")
917 mock_provider.mass.players.get_player.return_value = follower
918 # the leader's freshest snapshot no longer lists the follower (it moved elsewhere)
919 snapshot = mock_provider.wiim_controller.get_group_snapshot.return_value
920 snapshot.member_udns = (mock_wiim_device.udn,)
921 with pytest.raises(PlayerCommandFailed):
922 await leader.set_members(player_ids_to_remove=["wiim_uuid:follower"])
923 mock_provider.wiim_controller.async_ungroup_device.assert_not_awaited()
924
925 async def test_remove_member_in_mixed_group_rejected(
926 self, mock_provider: MagicMock, mock_wiim_device: MagicMock
927 ) -> None:
928 """A member that has moved into a read-only mixed group is not ungrouped."""
929 leader = self._leader(mock_provider, mock_wiim_device)
930 follower = self._follower("wiim_uuid:follower", "uuid:follower", mixed=True)
931 mock_provider.mass.players.get_player.return_value = follower
932 snapshot = mock_provider.wiim_controller.get_group_snapshot.return_value
933 snapshot.member_udns = (mock_wiim_device.udn, "uuid:follower")
934 with pytest.raises(PlayerCommandFailed):
935 await leader.set_members(player_ids_to_remove=["wiim_uuid:follower"])
936 mock_provider.wiim_controller.async_ungroup_device.assert_not_awaited()
937
938 async def test_later_invalid_removal_aborts_batch(
939 self, mock_provider: MagicMock, mock_wiim_device: MagicMock
940 ) -> None:
941 """A later invalid removal target aborts the batch before any ungroup runs."""
942 leader = self._leader(mock_provider, mock_wiim_device)
943 members = {
944 "wiim_uuid:good": self._follower("wiim_uuid:good", "uuid:good"),
945 "wiim_uuid:bad": self._follower("wiim_uuid:bad", "uuid:bad"),
946 }
947 mock_provider.mass.players.get_player.side_effect = lambda pid, *_a, **_k: members.get(pid)
948 # only 'good' is a current member; 'bad' has moved away
949 snapshot = mock_provider.wiim_controller.get_group_snapshot.return_value
950 snapshot.member_udns = (mock_wiim_device.udn, "uuid:good")
951 with pytest.raises(PlayerCommandFailed):
952 await leader.set_members(player_ids_to_remove=["wiim_uuid:good", "wiim_uuid:bad"])
953 mock_provider.wiim_controller.async_ungroup_device.assert_not_awaited()
954
955 async def test_join_sdk_error_propagates_typed(
956 self, mock_provider: MagicMock, mock_wiim_device: MagicMock
957 ) -> None:
958 """A controller error while joining surfaces as a typed failure, not a false success."""
959 leader = self._leader(mock_provider, mock_wiim_device)
960 mock_provider.mass.players.get_player.return_value = self._follower(
961 "wiim_uuid:follower", "uuid:follower"
962 )
963 cause = WiimException("join boom")
964 mock_provider.wiim_controller.async_join_group.side_effect = cause
965 with pytest.raises(PlayerCommandFailed) as excinfo:
966 await leader.set_members(player_ids_to_add=["wiim_uuid:follower"])
967 assert excinfo.value.__cause__ is cause
968
969 async def test_remove_sdk_error_propagates_typed(
970 self, mock_provider: MagicMock, mock_wiim_device: MagicMock
971 ) -> None:
972 """A controller error while ungrouping surfaces as a typed failure with its cause."""
973 leader = self._leader(mock_provider, mock_wiim_device)
974 mock_provider.mass.players.get_player.return_value = self._follower(
975 "wiim_uuid:follower", "uuid:follower"
976 )
977 snapshot = mock_provider.wiim_controller.get_group_snapshot.return_value
978 snapshot.member_udns = (mock_wiim_device.udn, "uuid:follower")
979 cause = WiimException("ungroup boom")
980 mock_provider.wiim_controller.async_ungroup_device.side_effect = cause
981 with pytest.raises(PlayerCommandFailed) as excinfo:
982 await leader.set_members(player_ids_to_remove=["wiim_uuid:follower"])
983 assert excinfo.value.__cause__ is cause
984