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