/
/
/
1"""Tests for WiiM player provider."""
2
3from typing import cast
4from unittest.mock import AsyncMock, MagicMock
5
6import pytest
7from music_assistant_models.enums import PlaybackState, PlayerFeature
8from wiim import PlayingStatus
9from wiim.exceptions import (
10 WiimDeviceException,
11 WiimInvalidDataException,
12 WiimRequestException,
13)
14
15from music_assistant.models.player import PlayerMedia
16from music_assistant.providers.wiim.constants import (
17 PLAYER_ID_PREFIX,
18 SOURCE_NETWORK,
19 SOURCE_UNKNOWN,
20)
21from music_assistant.providers.wiim.grouping import NativeGroupRole
22from music_assistant.providers.wiim.player import SDK_TO_MA_STATE, WiimPlayer
23
24
25def _mock_native_groups() -> MagicMock:
26 """Create a coordinator mock that reports a standalone topology by default."""
27 groups = MagicMock()
28 groups.role_of.return_value = NativeGroupRole.STANDALONE
29 groups.members_of.return_value = []
30 groups.can_group_with.return_value = set()
31 groups.refresh_leader = AsyncMock()
32 groups.reconcile = AsyncMock()
33 groups.set_members = AsyncMock()
34 groups.schedule_reconcile = MagicMock()
35 groups.schedule_republish = MagicMock()
36 groups.unregister = MagicMock()
37 groups.is_unknown_leader_follower = MagicMock(return_value=False)
38 groups.set_self_role = MagicMock(return_value=False)
39 return groups
40
41
42@pytest.fixture
43def mock_wiim_device() -> MagicMock:
44 """Create a mock WiimDevice."""
45 device = MagicMock()
46 device.name = "Test WiiM Pro"
47 device.udn = "uuid:test-wiim-001"
48 device.available = True
49 device.volume = 50
50 device.is_muted = False
51 device.playing_status = None
52 device.play_mode = None
53 device.current_media = None
54 device.model_name = "WiiM Pro"
55 device.manufacturer = "Linkplay"
56 device.firmware_version = "4.8.1"
57 device.ip_address = "192.168.1.100"
58 device.supports_http_api = True
59 device.supported_input_modes = ("Network", "Bluetooth", "Line In", "Optical In")
60 device.async_play = AsyncMock()
61 device.async_pause = AsyncMock()
62 device.async_stop = AsyncMock()
63 device.async_set_volume = AsyncMock()
64 device.async_set_mute = AsyncMock()
65 device.async_set_play_mode = AsyncMock()
66 device.sync_device_duration_and_position = AsyncMock()
67 device.async_update_http_status = AsyncMock()
68 device.disconnect = AsyncMock()
69 device.ensure_subscriptions = AsyncMock()
70 device.general_event_callback = None
71 device.rendering_control_event_callback = None
72 device.av_transport_event_callback = None
73 device.play_queue_event_callback = None
74 return device
75
76
77@pytest.fixture
78def mock_controller() -> MagicMock:
79 """Create a mock WiimController."""
80 controller = MagicMock()
81 snapshot = MagicMock()
82 snapshot.role = "standalone"
83 snapshot.leader_udn = "uuid:test-wiim-001"
84 snapshot.member_udns = ("uuid:test-wiim-001",)
85 controller.get_group_snapshot.return_value = snapshot
86 controller.get_group_members.return_value = []
87 controller.get_device.return_value = MagicMock()
88 controller.async_join_group = AsyncMock()
89 controller.async_ungroup_device = AsyncMock()
90 return controller
91
92
93@pytest.fixture
94def mock_provider(mock_controller: MagicMock) -> MagicMock:
95 """Create a mock WiimProvider."""
96 provider = MagicMock()
97 provider.wiim_controller = mock_controller
98 provider.instance_id = "wiim_test"
99 provider.domain = "wiim"
100 provider.manifest = MagicMock()
101 provider.manifest.domain = "wiim"
102 provider.mass = MagicMock()
103 provider.mass.players = MagicMock()
104 provider.players = []
105 provider.native_groups = _mock_native_groups()
106
107 config = MagicMock()
108 config.name = None
109 config.default_name = "Test WiiM Pro"
110 config.enabled = True
111 config.player_type = None
112 config.get_value = MagicMock(return_value=None)
113 provider.mass.config.get_base_player_config.return_value = config
114 return provider
115
116
117class TestSDKStateMapping:
118 """Test SDK to MA state mapping."""
119
120 def test_playing_maps_to_playing(self) -> None:
121 """PLAYING should map to PlaybackState.PLAYING."""
122 assert SDK_TO_MA_STATE[PlayingStatus.PLAYING] == PlaybackState.PLAYING
123
124 def test_paused_maps_to_paused(self) -> None:
125 """PAUSED should map to PlaybackState.PAUSED."""
126 assert SDK_TO_MA_STATE[PlayingStatus.PAUSED] == PlaybackState.PAUSED
127
128 def test_stopped_maps_to_idle(self) -> None:
129 """STOPPED should map to PlaybackState.IDLE."""
130 assert SDK_TO_MA_STATE[PlayingStatus.STOPPED] == PlaybackState.IDLE
131
132 def test_loading_maps_to_playing(self) -> None:
133 """LOADING should map to PlaybackState.PLAYING."""
134 assert SDK_TO_MA_STATE[PlayingStatus.LOADING] == PlaybackState.PLAYING
135
136 def test_all_sdk_states_mapped(self) -> None:
137 """All non-UNKNOWN SDK states should have a mapping."""
138 for status in PlayingStatus:
139 if status != PlayingStatus.UNKNOWN:
140 assert status in SDK_TO_MA_STATE, f"{status} not mapped"
141
142
143class TestFalsePlayingFilter:
144 """A uri-less PLAYING report in network mode must not become PLAYING state."""
145
146 def _make_player(self, provider: MagicMock, device: MagicMock) -> WiimPlayer:
147 player = WiimPlayer(provider=provider, player_id="uuid:test", device=device)
148 player.update_state = MagicMock() # type: ignore[misc,method-assign]
149 return player
150
151 def test_false_playing_ack_is_suppressed(
152 self, mock_provider: MagicMock, mock_wiim_device: MagicMock
153 ) -> None:
154 """
155 The transient PLAYING ack without media loaded must keep the previous state.
156
157 The device acks (group) transport commands with a short false PLAYING
158 report before any track is loaded; propagating it causes a
159 PLAYING->IDLE->PLAYING flicker downstream.
160 """
161 mock_wiim_device.play_mode = SOURCE_NETWORK
162 mock_wiim_device.current_media = None
163 mock_wiim_device.playing_status = PlayingStatus.PLAYING
164 player = self._make_player(mock_provider, mock_wiim_device)
165 player._attr_playback_state = PlaybackState.IDLE
166
167 player._update_ma_state_from_sdk_cache()
168
169 assert player._attr_playback_state == PlaybackState.IDLE
170
171 def test_loading_without_uri_is_suppressed(
172 self, mock_provider: MagicMock, mock_wiim_device: MagicMock
173 ) -> None:
174 """LOADING maps to PLAYING and gets the same uri-less filter."""
175 mock_wiim_device.play_mode = SOURCE_NETWORK
176 mock_wiim_device.current_media = None
177 mock_wiim_device.playing_status = PlayingStatus.LOADING
178 player = self._make_player(mock_provider, mock_wiim_device)
179 player._attr_playback_state = PlaybackState.IDLE
180
181 player._update_ma_state_from_sdk_cache()
182
183 assert player._attr_playback_state == PlaybackState.IDLE
184
185 def test_playing_kept_when_uri_drops_mid_playback(
186 self, mock_provider: MagicMock, mock_wiim_device: MagicMock
187 ) -> None:
188 """The filter keeps the previous state; it never forces a playing player idle."""
189 mock_wiim_device.play_mode = SOURCE_NETWORK
190 mock_wiim_device.current_media = None
191 mock_wiim_device.playing_status = PlayingStatus.PLAYING
192 player = self._make_player(mock_provider, mock_wiim_device)
193 player._attr_playback_state = PlaybackState.PLAYING
194
195 player._update_ma_state_from_sdk_cache()
196
197 assert player._attr_playback_state == PlaybackState.PLAYING
198
199 def test_playing_accepted_once_uri_present(
200 self, mock_provider: MagicMock, mock_wiim_device: MagicMock
201 ) -> None:
202 """A PLAYING report with media loaded is a real start and passes through."""
203 media = MagicMock()
204 media.uri = "http://192.168.1.80:8097/single/abc/queue/item/uuid:test.flac"
205 mock_wiim_device.play_mode = SOURCE_NETWORK
206 mock_wiim_device.current_media = media
207 mock_wiim_device.playing_status = PlayingStatus.PLAYING
208 player = self._make_player(mock_provider, mock_wiim_device)
209 player._attr_playback_state = PlaybackState.IDLE
210
211 player._update_ma_state_from_sdk_cache()
212
213 assert player._attr_playback_state == PlaybackState.PLAYING
214
215 def test_external_input_playing_without_uri_accepted(
216 self, mock_provider: MagicMock, mock_wiim_device: MagicMock
217 ) -> None:
218 """External inputs legitimately play without a URI and must not be filtered."""
219 mock_wiim_device.play_mode = "Line In"
220 mock_wiim_device.current_media = None
221 mock_wiim_device.playing_status = PlayingStatus.PLAYING
222 player = self._make_player(mock_provider, mock_wiim_device)
223 player._attr_playback_state = PlaybackState.IDLE
224
225 player._update_ma_state_from_sdk_cache()
226
227 assert player._attr_playback_state == PlaybackState.PLAYING
228
229 def test_unknown_play_mode_trusts_device(
230 self, mock_provider: MagicMock, mock_wiim_device: MagicMock
231 ) -> None:
232 """Without a known play mode the device report is trusted (no suppression)."""
233 mock_wiim_device.play_mode = None
234 mock_wiim_device.current_media = None
235 mock_wiim_device.playing_status = PlayingStatus.PLAYING
236 player = self._make_player(mock_provider, mock_wiim_device)
237 player._attr_playback_state = PlaybackState.IDLE
238
239 player._update_ma_state_from_sdk_cache()
240
241 assert player._attr_playback_state == PlaybackState.PLAYING
242
243 def test_stopped_report_unaffected(
244 self, mock_provider: MagicMock, mock_wiim_device: MagicMock
245 ) -> None:
246 """The filter only guards PLAYING-mapped reports; STOPPED passes through."""
247 mock_wiim_device.play_mode = SOURCE_NETWORK
248 mock_wiim_device.current_media = None
249 mock_wiim_device.playing_status = PlayingStatus.STOPPED
250 player = self._make_player(mock_provider, mock_wiim_device)
251 player._attr_playback_state = PlaybackState.PLAYING
252
253 player._update_ma_state_from_sdk_cache()
254
255 assert player._attr_playback_state == PlaybackState.IDLE
256
257
258class TestActiveSourceMapping:
259 """Network mode must resolve an active source with or without a registered queue."""
260
261 def test_state_completes_before_the_queue_exists(
262 self, mock_provider: MagicMock, mock_wiim_device: MagicMock
263 ) -> None:
264 """
265 A poll landing before the PlayerQueue is registered must still publish state.
266
267 The player is initialised a moment before its queue, so the first poll after
268 a restart finds no queue; aborting there left the player with no active
269 source and no state update at all.
270 """
271 mock_provider.mass.player_queues.get.return_value = None
272 mock_wiim_device.play_mode = SOURCE_NETWORK
273 player = WiimPlayer(provider=mock_provider, player_id="uuid:test", device=mock_wiim_device)
274 player.update_state = MagicMock() # type: ignore[misc,method-assign]
275
276 player._update_ma_state_from_sdk_cache()
277
278 assert player._attr_active_source == SOURCE_UNKNOWN
279 player.update_state.assert_called_once()
280
281 def test_queue_with_current_item_is_the_active_source(
282 self, mock_provider: MagicMock, mock_wiim_device: MagicMock
283 ) -> None:
284 """A queue that holds a current item makes the player itself the source."""
285 mock_provider.mass.player_queues.get.return_value.current_item = MagicMock()
286 mock_wiim_device.play_mode = SOURCE_NETWORK
287 player = WiimPlayer(provider=mock_provider, player_id="uuid:test", device=mock_wiim_device)
288 player.update_state = MagicMock() # type: ignore[misc,method-assign]
289
290 player._update_ma_state_from_sdk_cache()
291
292 assert player._attr_active_source == player.player_id
293
294 def test_idle_queue_falls_back_to_unknown(
295 self, mock_provider: MagicMock, mock_wiim_device: MagicMock
296 ) -> None:
297 """A registered queue with nothing loaded is not the active source."""
298 mock_provider.mass.player_queues.get.return_value.current_item = None
299 mock_wiim_device.play_mode = SOURCE_NETWORK
300 player = WiimPlayer(provider=mock_provider, player_id="uuid:test", device=mock_wiim_device)
301 player.update_state = MagicMock() # type: ignore[misc,method-assign]
302
303 player._update_ma_state_from_sdk_cache()
304
305 assert player._attr_active_source == SOURCE_UNKNOWN
306
307
308class TestSupportedFeatures:
309 """Test that required features are declared."""
310
311 def test_play_media_in_features(
312 self, mock_provider: MagicMock, mock_wiim_device: MagicMock
313 ) -> None:
314 """PLAY_MEDIA should be in supported features."""
315 player = WiimPlayer(provider=mock_provider, player_id="uuid:test", device=mock_wiim_device)
316 assert PlayerFeature.PLAY_MEDIA in player._attr_supported_features
317
318 def test_volume_features(self, mock_provider: MagicMock, mock_wiim_device: MagicMock) -> None:
319 """VOLUME_SET and VOLUME_MUTE should be in supported features."""
320 player = WiimPlayer(provider=mock_provider, player_id="uuid:test", device=mock_wiim_device)
321 assert PlayerFeature.VOLUME_SET in player._attr_supported_features
322 assert PlayerFeature.VOLUME_MUTE in player._attr_supported_features
323
324 def test_select_source_in_features(
325 self, mock_provider: MagicMock, mock_wiim_device: MagicMock
326 ) -> None:
327 """SELECT_SOURCE should be in supported features."""
328 player = WiimPlayer(provider=mock_provider, player_id="uuid:test", device=mock_wiim_device)
329 assert PlayerFeature.SELECT_SOURCE in player._attr_supported_features
330
331
332class TestGroupMembers:
333 """Group membership is published from the coordinator's resolved topology."""
334
335 def test_leader_publishes_coordinator_members(
336 self, mock_provider: MagicMock, mock_wiim_device: MagicMock
337 ) -> None:
338 """A leader publishes exactly the members the coordinator resolved for it."""
339 leader_player_id = f"{PLAYER_ID_PREFIX}{mock_wiim_device.udn}"
340 managed_player_id = f"{PLAYER_ID_PREFIX}uuid:test-wiim-002"
341 mock_provider.native_groups.members_of.return_value = [
342 leader_player_id,
343 managed_player_id,
344 ]
345 player = WiimPlayer(
346 provider=mock_provider,
347 player_id=leader_player_id,
348 device=mock_wiim_device,
349 )
350 player.update_state = MagicMock() # type: ignore[misc,method-assign]
351
352 player._update_ma_state_from_sdk_cache()
353
354 assert player._attr_group_members == [leader_player_id, managed_player_id]
355
356 def test_follower_publishes_no_members(
357 self, mock_provider: MagicMock, mock_wiim_device: MagicMock
358 ) -> None:
359 """A follower manages no members and clears its own delegated playback state."""
360 mock_provider.native_groups.role_of.return_value = NativeGroupRole.FOLLOWER
361 player = WiimPlayer(
362 provider=mock_provider,
363 player_id=f"{PLAYER_ID_PREFIX}{mock_wiim_device.udn}",
364 device=mock_wiim_device,
365 )
366 player.update_state = MagicMock() # type: ignore[misc,method-assign]
367 player._attr_group_members = ["stale"]
368 pre_group_state: PlaybackState = PlaybackState.PLAYING
369 player._attr_playback_state = pre_group_state
370 pre_group_media: PlayerMedia | None = cast("PlayerMedia", MagicMock())
371 player._attr_current_media = pre_group_media
372
373 player._update_ma_state_from_sdk_cache()
374
375 assert player._attr_group_members == []
376 assert player._attr_playback_state == PlaybackState.IDLE
377 assert player._attr_current_media is None
378 assert player._attr_active_source is None
379
380 def test_unknown_leader_follower_locks_grouping(
381 self, mock_provider: MagicMock, mock_wiim_device: MagicMock
382 ) -> None:
383 """A follower of an undiscovered group withdraws grouping so it is not regrouped."""
384 mock_provider.native_groups.is_unknown_leader_follower.return_value = True
385 player = WiimPlayer(
386 provider=mock_provider,
387 player_id=f"{PLAYER_ID_PREFIX}{mock_wiim_device.udn}",
388 device=mock_wiim_device,
389 )
390 mock_provider.native_groups.is_unknown_leader_follower.return_value = True
391 locked_when_unknown = player.grouping_locked
392 mock_provider.native_groups.is_unknown_leader_follower.return_value = False
393 locked_when_known = player.grouping_locked
394 assert locked_when_unknown is True
395 assert locked_when_known is False
396
397 def test_becoming_follower_clears_active_output_protocol(
398 self, mock_provider: MagicMock, mock_wiim_device: MagicMock
399 ) -> None:
400 """An official device drops a still-active output when it becomes a native follower."""
401 mock_provider.native_groups.role_of.return_value = NativeGroupRole.FOLLOWER
402 player = WiimPlayer(
403 provider=mock_provider,
404 player_id=f"{PLAYER_ID_PREFIX}{mock_wiim_device.udn}",
405 device=mock_wiim_device,
406 )
407 player.update_state = MagicMock() # type: ignore[misc,method-assign]
408 player.set_active_output_protocol("airplay_x")
409
410 player._update_ma_state_from_sdk_cache()
411
412 assert player.active_output_protocol is None
413 assert player._attr_playback_state == PlaybackState.IDLE
414
415 def test_leaving_follower_keeps_output_cleared(
416 self, mock_provider: MagicMock, mock_wiim_device: MagicMock
417 ) -> None:
418 """The dropped output is not restored once the device leaves the group."""
419 mock_provider.native_groups.role_of.return_value = NativeGroupRole.FOLLOWER
420 player = WiimPlayer(
421 provider=mock_provider,
422 player_id=f"{PLAYER_ID_PREFIX}{mock_wiim_device.udn}",
423 device=mock_wiim_device,
424 )
425 player.update_state = MagicMock() # type: ignore[misc,method-assign]
426 player.set_active_output_protocol("airplay_x")
427 player._update_ma_state_from_sdk_cache()
428 assert player.active_output_protocol is None
429
430 mock_provider.native_groups.role_of.return_value = NativeGroupRole.STANDALONE
431 player._update_ma_state_from_sdk_cache()
432
433 assert player.active_output_protocol is None
434
435
436class TestSourceList:
437 """Test dynamic source list construction."""
438
439 @pytest.mark.asyncio
440 async def test_setup_adds_device_input_modes(
441 self, mock_provider: MagicMock, mock_wiim_device: MagicMock
442 ) -> None:
443 """setup() should add sources for device-supported input modes."""
444 player = WiimPlayer(provider=mock_provider, player_id="uuid:test", device=mock_wiim_device)
445 await player.setup()
446 source_ids = [s.id for s in player._attr_source_list]
447 assert "bluetooth" in source_ids
448 assert "line_in" in source_ids
449 assert "optical" in source_ids
450
451 @pytest.mark.asyncio
452 async def test_setup_adds_passive_sources(
453 self, mock_provider: MagicMock, mock_wiim_device: MagicMock
454 ) -> None:
455 """setup() should add passive sources (AirPlay, Spotify)."""
456 player = WiimPlayer(provider=mock_provider, player_id="uuid:test", device=mock_wiim_device)
457 await player.setup()
458 source_ids = [s.id for s in player._attr_source_list]
459 assert "airplay" in source_ids
460 assert "spotify" in source_ids
461
462 @pytest.mark.asyncio
463 async def test_setup_skips_unknown_input_modes(
464 self, mock_provider: MagicMock, mock_wiim_device: MagicMock
465 ) -> None:
466 """setup() should skip input modes not in INPUT_MODE_SOURCES."""
467 mock_wiim_device.supported_input_modes = ("Network", "FutureMode")
468 player = WiimPlayer(provider=mock_provider, player_id="uuid:test", device=mock_wiim_device)
469 await player.setup()
470 source_ids = [s.id for s in player._attr_source_list]
471 assert "futuremode" not in source_ids
472
473
474class TestVolumeCommand:
475 """Test the volume command reaches the device."""
476
477 @pytest.mark.asyncio
478 async def test_volume_set_delegates_to_device(
479 self, mock_provider: MagicMock, mock_wiim_device: MagicMock
480 ) -> None:
481 """Setting the volume should reach the device and land in the player state."""
482 player = WiimPlayer(provider=mock_provider, player_id="uuid:test", device=mock_wiim_device)
483 player.update_state = MagicMock() # type: ignore[misc,method-assign]
484
485 async def _apply_volume(volume_level: int) -> None:
486 mock_wiim_device.volume = volume_level
487
488 mock_wiim_device.async_set_volume = AsyncMock(side_effect=_apply_volume)
489 await player.volume_set(42)
490
491 mock_wiim_device.async_set_volume.assert_awaited_once_with(42)
492 assert player._attr_volume_level == 42
493
494
495class TestErrorHandling:
496 """Test that command errors mark device unavailable."""
497
498 @pytest.mark.asyncio
499 async def test_play_error_refreshes_state(
500 self, mock_provider: MagicMock, mock_wiim_device: MagicMock
501 ) -> None:
502 """Play command error should refresh state without marking unavailable."""
503 mock_wiim_device.async_play.side_effect = WiimRequestException("timeout")
504 player = WiimPlayer(provider=mock_provider, player_id="uuid:test", device=mock_wiim_device)
505 player.update_state = MagicMock() # type: ignore[misc,method-assign]
506 await player.play()
507 assert player._attr_available is True
508 player.update_state.assert_called()
509
510 @pytest.mark.asyncio
511 async def test_volume_set_error_refreshes_state(
512 self, mock_provider: MagicMock, mock_wiim_device: MagicMock
513 ) -> None:
514 """Volume set error should refresh state without marking unavailable."""
515 mock_wiim_device.async_set_volume.side_effect = WiimDeviceException("disconnected")
516 player = WiimPlayer(provider=mock_provider, player_id="uuid:test", device=mock_wiim_device)
517 player.update_state = MagicMock() # type: ignore[misc,method-assign]
518 await player.volume_set(50)
519 assert player._attr_available is True
520 player.update_state.assert_called()
521
522 @pytest.mark.asyncio
523 async def test_volume_set_survives_invalid_data_from_device(
524 self, mock_provider: MagicMock, mock_wiim_device: MagicMock
525 ) -> None:
526 """A speaker answering a volume command with something other than OK must not throw."""
527 mock_wiim_device.async_set_volume = AsyncMock(
528 side_effect=WiimInvalidDataException("did not return 'OK'")
529 )
530 player = WiimPlayer(provider=mock_provider, player_id="uuid:test", device=mock_wiim_device)
531 player.update_state = MagicMock() # type: ignore[misc,method-assign]
532 await player.volume_set(42)
533 assert player._attr_available is True
534
535 @pytest.mark.asyncio
536 async def test_select_source_survives_invalid_data_from_device(
537 self, mock_provider: MagicMock, mock_wiim_device: MagicMock
538 ) -> None:
539 """A speaker rejecting a source change must not throw out of the command."""
540 mock_wiim_device.async_set_play_mode = AsyncMock(
541 side_effect=WiimInvalidDataException("did not return 'OK'")
542 )
543 player = WiimPlayer(provider=mock_provider, player_id="uuid:test", device=mock_wiim_device)
544 player.update_state = MagicMock() # type: ignore[misc,method-assign]
545 await player.select_source("bluetooth")
546 assert player._attr_available is True
547
548 @pytest.mark.asyncio
549 async def test_stop_error_refreshes_state(
550 self, mock_provider: MagicMock, mock_wiim_device: MagicMock
551 ) -> None:
552 """Stop command error should refresh state without marking unavailable."""
553 mock_wiim_device.async_stop.side_effect = WiimRequestException("connection lost")
554 player = WiimPlayer(provider=mock_provider, player_id="uuid:test", device=mock_wiim_device)
555 player.update_state = MagicMock() # type: ignore[misc,method-assign]
556 await player.stop()
557 assert player._attr_available is True
558 player.update_state.assert_called()
559
560 @pytest.mark.asyncio
561 async def test_pause_error_refreshes_state(
562 self, mock_provider: MagicMock, mock_wiim_device: MagicMock
563 ) -> None:
564 """Pause command error should refresh state without marking unavailable."""
565 mock_wiim_device.async_pause.side_effect = WiimDeviceException("timeout")
566 player = WiimPlayer(provider=mock_provider, player_id="uuid:test", device=mock_wiim_device)
567 player.update_state = MagicMock() # type: ignore[misc,method-assign]
568 await player.pause()
569 assert player._attr_available is True
570 player.update_state.assert_called()
571
572
573class TestStalePositionOnNewStream:
574 """A new stream handed to the device must not inherit the previous position."""
575
576 def _make_player(self, provider: MagicMock, device: MagicMock) -> WiimPlayer:
577 player = WiimPlayer(provider=provider, player_id="uuid:test", device=device)
578 player.update_state = MagicMock() # type: ignore[misc,method-assign]
579 return player
580
581 @pytest.mark.asyncio
582 async def test_play_media_resets_elapsed_time(
583 self, mock_provider: MagicMock, mock_wiim_device: MagicMock
584 ) -> None:
585 """play_media() must clear the stale elapsed_time anchor from prior content."""
586 stream_url = "http://192.168.1.80:8097/single/abc/queue/item/uuid:test.flac"
587 mock_provider.mass.streams.resolve_stream_url = AsyncMock(return_value=stream_url)
588 player = self._make_player(mock_provider, mock_wiim_device)
589 player._attr_elapsed_time = 273
590 player._attr_elapsed_time_last_updated = 1000.0
591
592 await player.play_media(PlayerMedia(uri="library://track/1", title="Some Track"))
593
594 assert player._attr_elapsed_time == 0
595 assert player._attr_elapsed_time_last_updated is not None
596 assert player._attr_elapsed_time_last_updated > 1000.0
597
598 @pytest.mark.asyncio
599 async def test_play_media_then_sync_position_end_to_end(
600 self, mock_provider: MagicMock, mock_wiim_device: MagicMock
601 ) -> None:
602 """
603 The device still reports the previous AirPlay content after play_media.
604
605 Its metadata makes MA rebuild _attr_current_media from the device's own
606 uri, so the position guard must not key off _attr_current_media.
607 """
608 stream_url = "http://192.168.1.80:8097/single/abc/queue/item/uuid:test.flac"
609 mock_provider.mass.streams.resolve_stream_url = AsyncMock(return_value=stream_url)
610
611 device_media = MagicMock()
612 device_media.uri = "wiimu_airplay"
613 device_media.title = "Previous Song"
614 device_media.artist = "Previous Artist"
615 device_media.album = "Previous Album"
616 device_media.position = 273
617 mock_wiim_device.play_mode = SOURCE_NETWORK
618 mock_wiim_device.current_media = device_media
619 mock_wiim_device.playing_status = PlayingStatus.PLAYING
620
621 player = self._make_player(mock_provider, mock_wiim_device)
622 player._attr_elapsed_time = 273
623
624 await player.play_media(PlayerMedia(uri="library://track/1", title="New Track"))
625 assert player._attr_elapsed_time == 0
626
627 await player._sync_position()
628
629 assert player._attr_elapsed_time == 0
630
631 @pytest.mark.asyncio
632 async def test_failed_play_media_releases_the_guard(
633 self, mock_provider: MagicMock, mock_wiim_device: MagicMock
634 ) -> None:
635 """A device that never took our stream must not stay guarded against its own position."""
636 stream_url = "http://192.168.1.80:8097/single/abc/queue/item/uuid:test.flac"
637 mock_provider.mass.streams.resolve_stream_url = AsyncMock(return_value=stream_url)
638 mock_wiim_device.async_play = AsyncMock(side_effect=WiimDeviceException("boom"))
639 player = self._make_player(mock_provider, mock_wiim_device)
640
641 await player.play_media(PlayerMedia(uri="library://track/1", title="New Track"))
642
643 device_media = MagicMock()
644 device_media.uri = "wiimu_airplay"
645 device_media.position = 42
646 mock_wiim_device.current_media = device_media
647
648 await player._sync_position()
649
650 assert player._attr_elapsed_time == 42
651
652 @pytest.mark.asyncio
653 async def test_sync_position_ignores_position_reported_without_uri(
654 self, mock_provider: MagicMock, mock_wiim_device: MagicMock
655 ) -> None:
656 """The device clears its uri mid-handover while still reporting the old position."""
657 stream_url = "http://192.168.1.80:8097/single/abc/queue/item/uuid:test.flac"
658 mock_provider.mass.streams.resolve_stream_url = AsyncMock(return_value=stream_url)
659
660 device_media = MagicMock()
661 device_media.uri = None
662 device_media.title = None
663 device_media.artist = None
664 device_media.album = None
665 device_media.position = 273
666 mock_wiim_device.play_mode = SOURCE_NETWORK
667 mock_wiim_device.current_media = device_media
668
669 player = self._make_player(mock_provider, mock_wiim_device)
670
671 await player.play_media(PlayerMedia(uri="library://track/1", title="New Track"))
672 await player._sync_position()
673
674 assert player._attr_elapsed_time == 0
675
676 @pytest.mark.asyncio
677 async def test_sync_position_ignores_foreign_uri(
678 self, mock_provider: MagicMock, mock_wiim_device: MagicMock
679 ) -> None:
680 """The device's position is rejected while it hasn't loaded MA's stream uri."""
681 stream_uri = "http://192.168.1.80:8097/single/abc/queue/item/uuid:test.flac"
682 player = self._make_player(mock_provider, mock_wiim_device)
683 player._ma_stream_uri = stream_uri
684 player._attr_elapsed_time = 0
685 player._attr_elapsed_time_last_updated = 1000.0
686
687 device_media = MagicMock()
688 device_media.uri = "wiimu_airplay"
689 device_media.position = 273
690 mock_wiim_device.current_media = device_media
691
692 await player._sync_position()
693
694 assert player._attr_elapsed_time == 0
695 assert player._attr_elapsed_time_last_updated == 1000.0
696
697 @pytest.mark.asyncio
698 async def test_sync_position_accepts_matching_uri_and_clears_guard(
699 self, mock_provider: MagicMock, mock_wiim_device: MagicMock
700 ) -> None:
701 """Once the device reports MA's own uri, its position is trusted and the guard lifts."""
702 stream_uri = "http://192.168.1.80:8097/single/abc/queue/item/uuid:test.flac"
703 mock_provider.mass.streams.resolve_stream_url = AsyncMock(return_value=stream_uri)
704 player = self._make_player(mock_provider, mock_wiim_device)
705
706 device_media = MagicMock()
707 device_media.uri = stream_uri
708 device_media.position = 12
709 mock_wiim_device.current_media = device_media
710
711 await player.play_media(PlayerMedia(uri="library://track/1", title="New Track"))
712 player._attr_elapsed_time_last_updated = 1000.0
713
714 await player._sync_position()
715
716 assert player._attr_elapsed_time == 12
717 assert player._attr_elapsed_time_last_updated > 1000.0
718 assert player._ma_stream_uri is None
719
720 # A later switch to an external source must not be blocked by a stale guard.
721 device_media.uri = "wiimu_airplay"
722 device_media.position = 55
723 await player._sync_position()
724
725 assert player._attr_elapsed_time == 55
726
727 @pytest.mark.asyncio
728 async def test_sync_position_accepts_device_when_ma_not_driving_playback(
729 self, mock_provider: MagicMock, mock_wiim_device: MagicMock
730 ) -> None:
731 """With no stream handed over by MA (external source), the device's position is authoritative."""
732 player = self._make_player(mock_provider, mock_wiim_device)
733 player._ma_stream_uri = None
734 player._attr_elapsed_time = 0
735 player._attr_elapsed_time_last_updated = 1000.0
736
737 device_media = MagicMock()
738 device_media.uri = "wiimu_airplay"
739 device_media.position = 42
740 mock_wiim_device.current_media = device_media
741
742 await player._sync_position()
743
744 assert player._attr_elapsed_time == 42
745 assert player._attr_elapsed_time_last_updated > 1000.0
746
747
748class TestPollRefreshesTransportState:
749 """Polling must correct state the device stopped pushing events for."""
750
751 def _make_player(self, provider: MagicMock, device: MagicMock) -> WiimPlayer:
752 player = WiimPlayer(provider=provider, player_id="uuid:test", device=device)
753 player.update_state = MagicMock() # type: ignore[misc,method-assign]
754 return player
755
756 @pytest.mark.asyncio
757 async def test_poll_fetches_device_status(
758 self, mock_provider: MagicMock, mock_wiim_device: MagicMock
759 ) -> None:
760 """A poll must ask the device for its transport state, not just its position."""
761 player = self._make_player(mock_provider, mock_wiim_device)
762
763 await player.poll()
764
765 mock_wiim_device.async_update_http_status.assert_awaited_once()
766
767 @pytest.mark.asyncio
768 async def test_poll_applies_stop_reported_after_missed_events(
769 self, mock_provider: MagicMock, mock_wiim_device: MagicMock
770 ) -> None:
771 """A device that went to stop while events were lost must no longer read as paused."""
772 player = self._make_player(mock_provider, mock_wiim_device)
773 player._attr_playback_state = PlaybackState.PAUSED
774 mock_wiim_device.play_mode = SOURCE_NETWORK
775
776 async def _report_stopped() -> None:
777 mock_wiim_device.playing_status = PlayingStatus.STOPPED
778
779 mock_wiim_device.async_update_http_status = AsyncMock(side_effect=_report_stopped)
780
781 await player.poll()
782
783 assert player._attr_playback_state == PlaybackState.IDLE
784
785 @pytest.mark.asyncio
786 async def test_poll_survives_invalid_data_from_device(
787 self, mock_provider: MagicMock, mock_wiim_device: MagicMock
788 ) -> None:
789 """A 'Failed' or unparsable status response must not escape the poll."""
790 mock_wiim_device.async_update_http_status = AsyncMock(
791 side_effect=WiimInvalidDataException("Command getStatusEx returned 'Failed'")
792 )
793 player = self._make_player(mock_provider, mock_wiim_device)
794
795 await player.poll()
796
797 mock_wiim_device.sync_device_duration_and_position.assert_awaited_once()
798
799 @pytest.mark.asyncio
800 async def test_poll_skips_status_of_unavailable_device(
801 self, mock_provider: MagicMock, mock_wiim_device: MagicMock
802 ) -> None:
803 """A device the SDK already gave up on must not be queried again by the poll."""
804 mock_wiim_device.available = False
805 player = self._make_player(mock_provider, mock_wiim_device)
806
807 await player.poll()
808
809 mock_wiim_device.async_update_http_status.assert_not_awaited()
810
811
812class TestSetMembersDelegation:
813 """The official player delegates grouping to the shared coordinator."""
814
815 async def test_set_members_delegates_to_coordinator(
816 self, mock_provider: MagicMock, mock_wiim_device: MagicMock
817 ) -> None:
818 """set_members forwards the add/remove batch to the coordinator unchanged."""
819 leader = WiimPlayer(
820 provider=mock_provider,
821 player_id=f"{PLAYER_ID_PREFIX}{mock_wiim_device.udn}",
822 device=mock_wiim_device,
823 )
824
825 await leader.set_members(
826 player_ids_to_add=["wiim_uuid:add"], player_ids_to_remove=["wiim_uuid:remove"]
827 )
828
829 mock_provider.native_groups.set_members.assert_awaited_once_with(
830 leader, ["wiim_uuid:add"], ["wiim_uuid:remove"]
831 )
832
833
834class TestAvailabilityRepublish:
835 """A native-availability flip re-publishes peers so their candidate sets don't go stale."""
836
837 def test_availability_flip_republishes_peers(
838 self, mock_provider: MagicMock, mock_wiim_device: MagicMock
839 ) -> None:
840 """When the device becomes unavailable, every native peer is re-published."""
841 player = WiimPlayer(
842 provider=mock_provider,
843 player_id=f"{PLAYER_ID_PREFIX}{mock_wiim_device.udn}",
844 device=mock_wiim_device,
845 )
846 player.update_state = MagicMock() # type: ignore[misc,method-assign]
847 player._attr_available = True
848 mock_wiim_device.available = False
849
850 player._update_ma_state_from_sdk_cache()
851
852 mock_provider.native_groups.schedule_republish.assert_called()
853
854
855class TestTopologyRefreshDebounce:
856 """A RenderingControl Slave event burst is coalesced into one forced topology refresh."""
857
858 def test_schedule_topology_refresh_dedups_via_task_id(
859 self, mock_provider: MagicMock, mock_wiim_device: MagicMock
860 ) -> None:
861 """The refresh is a leading-edge throttle: a shared task_id, without cancelling a run."""
862 player = WiimPlayer(
863 provider=mock_provider,
864 player_id=f"{PLAYER_ID_PREFIX}{mock_wiim_device.udn}",
865 device=mock_wiim_device,
866 )
867
868 player._schedule_topology_refresh()
869
870 mock_provider.mass.create_task.assert_called_once()
871 kwargs = mock_provider.mass.create_task.call_args.kwargs
872 assert kwargs["task_id"] == f"wiim_topology_{player.player_id}"
873 assert kwargs["force"] is True
874 assert kwargs.get("abort_existing", False) is False
875