/
/
/
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 wiim import PlayingStatus
8from wiim.exceptions import (
9 WiimDeviceException,
10 WiimInvalidDataException,
11 WiimRequestException,
12)
13
14from music_assistant.models.player import PlayerMedia
15from music_assistant.providers.wiim.constants import PLAYER_ID_PREFIX, SOURCE_NETWORK
16from music_assistant.providers.wiim.player import SDK_TO_MA_STATE, WiimPlayer
17
18
19@pytest.fixture
20def mock_wiim_device() -> MagicMock:
21 """Create a mock WiimDevice."""
22 device = MagicMock()
23 device.name = "Test WiiM Pro"
24 device.udn = "uuid:test-wiim-001"
25 device.available = True
26 device.volume = 50
27 device.is_muted = False
28 device.playing_status = None
29 device.play_mode = None
30 device.current_media = None
31 device.model_name = "WiiM Pro"
32 device.manufacturer = "Linkplay"
33 device.firmware_version = "4.8.1"
34 device.ip_address = "192.168.1.100"
35 device.supports_http_api = True
36 device.supported_input_modes = ("Network", "Bluetooth", "Line In", "Optical In")
37 device.async_play = AsyncMock()
38 device.async_pause = AsyncMock()
39 device.async_stop = AsyncMock()
40 device.async_set_volume = AsyncMock()
41 device.async_set_mute = AsyncMock()
42 device.async_set_play_mode = AsyncMock()
43 device.sync_device_duration_and_position = AsyncMock()
44 device.async_update_http_status = AsyncMock()
45 device.disconnect = AsyncMock()
46 device.ensure_subscriptions = AsyncMock()
47 device.general_event_callback = None
48 device.rendering_control_event_callback = None
49 device.av_transport_event_callback = None
50 device.play_queue_event_callback = None
51 return device
52
53
54@pytest.fixture
55def mock_controller() -> MagicMock:
56 """Create a mock WiimController."""
57 controller = MagicMock()
58 snapshot = MagicMock()
59 snapshot.role = "standalone"
60 snapshot.leader_udn = "uuid:test-wiim-001"
61 snapshot.member_udns = ("uuid:test-wiim-001",)
62 controller.get_group_snapshot.return_value = snapshot
63 controller.get_group_members.return_value = []
64 controller.get_device.return_value = MagicMock()
65 controller.async_join_group = AsyncMock()
66 controller.async_ungroup_device = AsyncMock()
67 return controller
68
69
70@pytest.fixture
71def mock_provider(mock_controller: MagicMock) -> MagicMock:
72 """Create a mock WiimProvider."""
73 provider = MagicMock()
74 provider.wiim_controller = mock_controller
75 provider.instance_id = "wiim_test"
76 provider.domain = "wiim"
77 provider.manifest = MagicMock()
78 provider.manifest.domain = "wiim"
79 provider.mass = MagicMock()
80 provider.mass.players = MagicMock()
81
82 config = MagicMock()
83 config.name = None
84 config.default_name = "Test WiiM Pro"
85 config.enabled = True
86 config.player_type = None
87 config.get_value = MagicMock(return_value=None)
88 provider.mass.config.get_base_player_config.return_value = config
89 return provider
90
91
92class TestSDKStateMapping:
93 """Test SDK to MA state mapping."""
94
95 def test_playing_maps_to_playing(self) -> None:
96 """PLAYING should map to PlaybackState.PLAYING."""
97 assert SDK_TO_MA_STATE[PlayingStatus.PLAYING] == PlaybackState.PLAYING
98
99 def test_paused_maps_to_paused(self) -> None:
100 """PAUSED should map to PlaybackState.PAUSED."""
101 assert SDK_TO_MA_STATE[PlayingStatus.PAUSED] == PlaybackState.PAUSED
102
103 def test_stopped_maps_to_idle(self) -> None:
104 """STOPPED should map to PlaybackState.IDLE."""
105 assert SDK_TO_MA_STATE[PlayingStatus.STOPPED] == PlaybackState.IDLE
106
107 def test_loading_maps_to_playing(self) -> None:
108 """LOADING should map to PlaybackState.PLAYING."""
109 assert SDK_TO_MA_STATE[PlayingStatus.LOADING] == PlaybackState.PLAYING
110
111 def test_all_sdk_states_mapped(self) -> None:
112 """All non-UNKNOWN SDK states should have a mapping."""
113 for status in PlayingStatus:
114 if status != PlayingStatus.UNKNOWN:
115 assert status in SDK_TO_MA_STATE, f"{status} not mapped"
116
117
118class TestFalsePlayingFilter:
119 """A uri-less PLAYING report in network mode must not become PLAYING state."""
120
121 def _make_player(self, provider: MagicMock, device: MagicMock) -> WiimPlayer:
122 player = WiimPlayer(provider=provider, player_id="uuid:test", device=device)
123 player.update_state = MagicMock() # type: ignore[misc,method-assign]
124 return player
125
126 def test_false_playing_ack_is_suppressed(
127 self, mock_provider: MagicMock, mock_wiim_device: MagicMock
128 ) -> None:
129 """
130 The transient PLAYING ack without media loaded must keep the previous state.
131
132 The device acks (group) transport commands with a short false PLAYING
133 report before any track is loaded; propagating it causes a
134 PLAYING->IDLE->PLAYING flicker downstream.
135 """
136 mock_wiim_device.play_mode = SOURCE_NETWORK
137 mock_wiim_device.current_media = None
138 mock_wiim_device.playing_status = PlayingStatus.PLAYING
139 player = self._make_player(mock_provider, mock_wiim_device)
140 player._attr_playback_state = PlaybackState.IDLE
141
142 player._update_ma_state_from_sdk_cache()
143
144 assert player._attr_playback_state == PlaybackState.IDLE
145
146 def test_loading_without_uri_is_suppressed(
147 self, mock_provider: MagicMock, mock_wiim_device: MagicMock
148 ) -> None:
149 """LOADING maps to PLAYING and gets the same uri-less filter."""
150 mock_wiim_device.play_mode = SOURCE_NETWORK
151 mock_wiim_device.current_media = None
152 mock_wiim_device.playing_status = PlayingStatus.LOADING
153 player = self._make_player(mock_provider, mock_wiim_device)
154 player._attr_playback_state = PlaybackState.IDLE
155
156 player._update_ma_state_from_sdk_cache()
157
158 assert player._attr_playback_state == PlaybackState.IDLE
159
160 def test_playing_kept_when_uri_drops_mid_playback(
161 self, mock_provider: MagicMock, mock_wiim_device: MagicMock
162 ) -> None:
163 """The filter keeps the previous state; it never forces a playing player idle."""
164 mock_wiim_device.play_mode = SOURCE_NETWORK
165 mock_wiim_device.current_media = None
166 mock_wiim_device.playing_status = PlayingStatus.PLAYING
167 player = self._make_player(mock_provider, mock_wiim_device)
168 player._attr_playback_state = PlaybackState.PLAYING
169
170 player._update_ma_state_from_sdk_cache()
171
172 assert player._attr_playback_state == PlaybackState.PLAYING
173
174 def test_playing_accepted_once_uri_present(
175 self, mock_provider: MagicMock, mock_wiim_device: MagicMock
176 ) -> None:
177 """A PLAYING report with media loaded is a real start and passes through."""
178 media = MagicMock()
179 media.uri = "http://192.168.1.80:8097/single/abc/queue/item/uuid:test.flac"
180 mock_wiim_device.play_mode = SOURCE_NETWORK
181 mock_wiim_device.current_media = media
182 mock_wiim_device.playing_status = PlayingStatus.PLAYING
183 player = self._make_player(mock_provider, mock_wiim_device)
184 player._attr_playback_state = PlaybackState.IDLE
185
186 player._update_ma_state_from_sdk_cache()
187
188 assert player._attr_playback_state == PlaybackState.PLAYING
189
190 def test_external_input_playing_without_uri_accepted(
191 self, mock_provider: MagicMock, mock_wiim_device: MagicMock
192 ) -> None:
193 """External inputs legitimately play without a URI and must not be filtered."""
194 mock_wiim_device.play_mode = "Line In"
195 mock_wiim_device.current_media = None
196 mock_wiim_device.playing_status = PlayingStatus.PLAYING
197 player = self._make_player(mock_provider, mock_wiim_device)
198 player._attr_playback_state = PlaybackState.IDLE
199
200 player._update_ma_state_from_sdk_cache()
201
202 assert player._attr_playback_state == PlaybackState.PLAYING
203
204 def test_unknown_play_mode_trusts_device(
205 self, mock_provider: MagicMock, mock_wiim_device: MagicMock
206 ) -> None:
207 """Without a known play mode the device report is trusted (no suppression)."""
208 mock_wiim_device.play_mode = None
209 mock_wiim_device.current_media = None
210 mock_wiim_device.playing_status = PlayingStatus.PLAYING
211 player = self._make_player(mock_provider, mock_wiim_device)
212 player._attr_playback_state = PlaybackState.IDLE
213
214 player._update_ma_state_from_sdk_cache()
215
216 assert player._attr_playback_state == PlaybackState.PLAYING
217
218 def test_stopped_report_unaffected(
219 self, mock_provider: MagicMock, mock_wiim_device: MagicMock
220 ) -> None:
221 """The filter only guards PLAYING-mapped reports; STOPPED passes through."""
222 mock_wiim_device.play_mode = SOURCE_NETWORK
223 mock_wiim_device.current_media = None
224 mock_wiim_device.playing_status = PlayingStatus.STOPPED
225 player = self._make_player(mock_provider, mock_wiim_device)
226 player._attr_playback_state = PlaybackState.PLAYING
227
228 player._update_ma_state_from_sdk_cache()
229
230 assert player._attr_playback_state == PlaybackState.IDLE
231
232
233class TestSupportedFeatures:
234 """Test that required features are declared."""
235
236 def test_play_media_in_features(
237 self, mock_provider: MagicMock, mock_wiim_device: MagicMock
238 ) -> None:
239 """PLAY_MEDIA should be in supported features."""
240 player = WiimPlayer(provider=mock_provider, player_id="uuid:test", device=mock_wiim_device)
241 assert PlayerFeature.PLAY_MEDIA in player._attr_supported_features
242
243 def test_volume_features(self, mock_provider: MagicMock, mock_wiim_device: MagicMock) -> None:
244 """VOLUME_SET and VOLUME_MUTE should be in supported features."""
245 player = WiimPlayer(provider=mock_provider, player_id="uuid:test", device=mock_wiim_device)
246 assert PlayerFeature.VOLUME_SET in player._attr_supported_features
247 assert PlayerFeature.VOLUME_MUTE in player._attr_supported_features
248
249 def test_select_source_in_features(
250 self, mock_provider: MagicMock, mock_wiim_device: MagicMock
251 ) -> None:
252 """SELECT_SOURCE should be in supported features."""
253 player = WiimPlayer(provider=mock_provider, player_id="uuid:test", device=mock_wiim_device)
254 assert PlayerFeature.SELECT_SOURCE in player._attr_supported_features
255
256
257class TestGroupMembers:
258 """Test group member state handling."""
259
260 def test_unmanaged_group_members_are_ignored(
261 self, mock_provider: MagicMock, mock_wiim_device: MagicMock
262 ) -> None:
263 """Only group members registered in Music Assistant should be reported."""
264 managed_udn = "uuid:test-wiim-002"
265 unmanaged_udn = "uuid:test-linkplay-001"
266 leader_player_id = f"{PLAYER_ID_PREFIX}{mock_wiim_device.udn}"
267 managed_player_id = f"{PLAYER_ID_PREFIX}{managed_udn}"
268 snapshot = mock_provider.wiim_controller.get_group_snapshot.return_value
269 snapshot.role = "leader"
270 snapshot.member_udns = (mock_wiim_device.udn, managed_udn, unmanaged_udn)
271 mock_provider.wiim_controller.get_group_members.side_effect = ValueError(
272 f"Device {unmanaged_udn} is not managed by the controller"
273 )
274 mock_provider.mass.players.get_player.side_effect = {managed_player_id: MagicMock()}.get
275 player = WiimPlayer(
276 provider=mock_provider,
277 player_id=leader_player_id,
278 device=mock_wiim_device,
279 )
280 player.update_state = MagicMock() # type: ignore[misc,method-assign]
281
282 player._update_ma_state_from_sdk_cache()
283
284 assert player._attr_group_members == [leader_player_id, managed_player_id]
285 mock_provider.wiim_controller.get_group_members.assert_not_called()
286
287 def test_only_unmanaged_group_members_reports_no_group(
288 self, mock_provider: MagicMock, mock_wiim_device: MagicMock
289 ) -> None:
290 """A group without any MA-managed followers should not be reported."""
291 snapshot = mock_provider.wiim_controller.get_group_snapshot.return_value
292 snapshot.role = "leader"
293 snapshot.member_udns = (mock_wiim_device.udn, "uuid:test-linkplay-001")
294 mock_provider.mass.players.get_player.return_value = None
295 player = WiimPlayer(
296 provider=mock_provider,
297 player_id=f"{PLAYER_ID_PREFIX}{mock_wiim_device.udn}",
298 device=mock_wiim_device,
299 )
300 player.update_state = MagicMock() # type: ignore[misc,method-assign]
301
302 player._update_ma_state_from_sdk_cache()
303
304 assert player._attr_group_members == []
305
306
307class TestSourceList:
308 """Test dynamic source list construction."""
309
310 @pytest.mark.asyncio
311 async def test_setup_adds_device_input_modes(
312 self, mock_provider: MagicMock, mock_wiim_device: MagicMock
313 ) -> None:
314 """setup() should add sources for device-supported input modes."""
315 player = WiimPlayer(provider=mock_provider, player_id="uuid:test", device=mock_wiim_device)
316 await player.setup()
317 source_ids = [s.id for s in player._attr_source_list]
318 assert "bluetooth" in source_ids
319 assert "line_in" in source_ids
320 assert "optical" in source_ids
321
322 @pytest.mark.asyncio
323 async def test_setup_adds_passive_sources(
324 self, mock_provider: MagicMock, mock_wiim_device: MagicMock
325 ) -> None:
326 """setup() should add passive sources (AirPlay, Spotify)."""
327 player = WiimPlayer(provider=mock_provider, player_id="uuid:test", device=mock_wiim_device)
328 await player.setup()
329 source_ids = [s.id for s in player._attr_source_list]
330 assert "airplay" in source_ids
331 assert "spotify" in source_ids
332
333 @pytest.mark.asyncio
334 async def test_setup_skips_unknown_input_modes(
335 self, mock_provider: MagicMock, mock_wiim_device: MagicMock
336 ) -> None:
337 """setup() should skip input modes not in INPUT_MODE_SOURCES."""
338 mock_wiim_device.supported_input_modes = ("Network", "FutureMode")
339 player = WiimPlayer(provider=mock_provider, player_id="uuid:test", device=mock_wiim_device)
340 await player.setup()
341 source_ids = [s.id for s in player._attr_source_list]
342 assert "futuremode" not in source_ids
343
344
345class TestErrorHandling:
346 """Test that command errors mark device unavailable."""
347
348 @pytest.mark.asyncio
349 async def test_play_error_refreshes_state(
350 self, mock_provider: MagicMock, mock_wiim_device: MagicMock
351 ) -> None:
352 """Play command error should refresh state without marking unavailable."""
353 mock_wiim_device.async_play.side_effect = WiimRequestException("timeout")
354 player = WiimPlayer(provider=mock_provider, player_id="uuid:test", device=mock_wiim_device)
355 player.update_state = MagicMock() # type: ignore[misc,method-assign]
356 await player.play()
357 assert player._attr_available is True
358 player.update_state.assert_called()
359
360 @pytest.mark.skip(
361 reason="volume_set inlines the HTTP fix from wiim PR#18 and no longer calls "
362 "async_set_volume; re-enable when wiim-sdk 0.1.5+ has been released"
363 )
364 @pytest.mark.asyncio
365 async def test_volume_set_error_refreshes_state(
366 self, mock_provider: MagicMock, mock_wiim_device: MagicMock
367 ) -> None:
368 """Volume set error should refresh state without marking unavailable."""
369 mock_wiim_device.async_set_volume.side_effect = WiimDeviceException("disconnected")
370 player = WiimPlayer(provider=mock_provider, player_id="uuid:test", device=mock_wiim_device)
371 player.update_state = MagicMock() # type: ignore[misc,method-assign]
372 await player.volume_set(50)
373 assert player._attr_available is True
374 player.update_state.assert_called()
375
376 @pytest.mark.asyncio
377 async def test_volume_set_survives_invalid_data_from_device(
378 self, mock_provider: MagicMock, mock_wiim_device: MagicMock
379 ) -> None:
380 """A speaker answering a volume command with something other than OK must not throw."""
381 mock_wiim_device._http_command_ok = AsyncMock(
382 side_effect=WiimInvalidDataException("did not return 'OK'")
383 )
384 player = WiimPlayer(provider=mock_provider, player_id="uuid:test", device=mock_wiim_device)
385 player.update_state = MagicMock() # type: ignore[misc,method-assign]
386 await player.volume_set(42)
387 assert player._attr_available is True
388
389 @pytest.mark.asyncio
390 async def test_select_source_survives_invalid_data_from_device(
391 self, mock_provider: MagicMock, mock_wiim_device: MagicMock
392 ) -> None:
393 """A speaker rejecting a source change must not throw out of the command."""
394 mock_wiim_device.async_set_play_mode = AsyncMock(
395 side_effect=WiimInvalidDataException("did not return 'OK'")
396 )
397 player = WiimPlayer(provider=mock_provider, player_id="uuid:test", device=mock_wiim_device)
398 player.update_state = MagicMock() # type: ignore[misc,method-assign]
399 await player.select_source("bluetooth")
400 assert player._attr_available is True
401
402 @pytest.mark.asyncio
403 async def test_stop_error_refreshes_state(
404 self, mock_provider: MagicMock, mock_wiim_device: MagicMock
405 ) -> None:
406 """Stop command error should refresh state without marking unavailable."""
407 mock_wiim_device.async_stop.side_effect = WiimRequestException("connection lost")
408 player = WiimPlayer(provider=mock_provider, player_id="uuid:test", device=mock_wiim_device)
409 player.update_state = MagicMock() # type: ignore[misc,method-assign]
410 await player.stop()
411 assert player._attr_available is True
412 player.update_state.assert_called()
413
414 @pytest.mark.asyncio
415 async def test_pause_error_refreshes_state(
416 self, mock_provider: MagicMock, mock_wiim_device: MagicMock
417 ) -> None:
418 """Pause command error should refresh state without marking unavailable."""
419 mock_wiim_device.async_pause.side_effect = WiimDeviceException("timeout")
420 player = WiimPlayer(provider=mock_provider, player_id="uuid:test", device=mock_wiim_device)
421 player.update_state = MagicMock() # type: ignore[misc,method-assign]
422 await player.pause()
423 assert player._attr_available is True
424 player.update_state.assert_called()
425
426
427class TestStalePositionOnNewStream:
428 """A new stream handed to the device must not inherit the previous position."""
429
430 def _make_player(self, provider: MagicMock, device: MagicMock) -> WiimPlayer:
431 player = WiimPlayer(provider=provider, player_id="uuid:test", device=device)
432 player.update_state = MagicMock() # type: ignore[misc,method-assign]
433 return player
434
435 @pytest.mark.asyncio
436 async def test_play_media_resets_elapsed_time(
437 self, mock_provider: MagicMock, mock_wiim_device: MagicMock
438 ) -> None:
439 """play_media() must clear the stale elapsed_time anchor from prior content."""
440 stream_url = "http://192.168.1.80:8097/single/abc/queue/item/uuid:test.flac"
441 mock_provider.mass.streams.resolve_stream_url = AsyncMock(return_value=stream_url)
442 player = self._make_player(mock_provider, mock_wiim_device)
443 player._attr_elapsed_time = 273
444 player._attr_elapsed_time_last_updated = 1000.0
445
446 await player.play_media(PlayerMedia(uri="library://track/1", title="Some Track"))
447
448 assert player._attr_elapsed_time == 0
449 assert player._attr_elapsed_time_last_updated is not None
450 assert player._attr_elapsed_time_last_updated > 1000.0
451
452 @pytest.mark.asyncio
453 async def test_play_media_then_sync_position_end_to_end(
454 self, mock_provider: MagicMock, mock_wiim_device: MagicMock
455 ) -> None:
456 """
457 The device still reports the previous AirPlay content after play_media.
458
459 Its metadata makes MA rebuild _attr_current_media from the device's own
460 uri, so the position guard must not key off _attr_current_media.
461 """
462 stream_url = "http://192.168.1.80:8097/single/abc/queue/item/uuid:test.flac"
463 mock_provider.mass.streams.resolve_stream_url = AsyncMock(return_value=stream_url)
464
465 device_media = MagicMock()
466 device_media.uri = "wiimu_airplay"
467 device_media.title = "Previous Song"
468 device_media.artist = "Previous Artist"
469 device_media.album = "Previous Album"
470 device_media.position = 273
471 mock_wiim_device.play_mode = SOURCE_NETWORK
472 mock_wiim_device.current_media = device_media
473 mock_wiim_device.playing_status = PlayingStatus.PLAYING
474
475 player = self._make_player(mock_provider, mock_wiim_device)
476 player._attr_elapsed_time = 273
477
478 await player.play_media(PlayerMedia(uri="library://track/1", title="New Track"))
479 assert player._attr_elapsed_time == 0
480
481 await player._sync_position()
482
483 assert player._attr_elapsed_time == 0
484
485 @pytest.mark.asyncio
486 async def test_failed_play_media_releases_the_guard(
487 self, mock_provider: MagicMock, mock_wiim_device: MagicMock
488 ) -> None:
489 """A device that never took our stream must not stay guarded against its own position."""
490 stream_url = "http://192.168.1.80:8097/single/abc/queue/item/uuid:test.flac"
491 mock_provider.mass.streams.resolve_stream_url = AsyncMock(return_value=stream_url)
492 mock_wiim_device.async_play = AsyncMock(side_effect=WiimDeviceException("boom"))
493 player = self._make_player(mock_provider, mock_wiim_device)
494
495 await player.play_media(PlayerMedia(uri="library://track/1", title="New Track"))
496
497 device_media = MagicMock()
498 device_media.uri = "wiimu_airplay"
499 device_media.position = 42
500 mock_wiim_device.current_media = device_media
501
502 await player._sync_position()
503
504 assert player._attr_elapsed_time == 42
505
506 @pytest.mark.asyncio
507 async def test_sync_position_ignores_position_reported_without_uri(
508 self, mock_provider: MagicMock, mock_wiim_device: MagicMock
509 ) -> None:
510 """The device clears its uri mid-handover while still reporting the old position."""
511 stream_url = "http://192.168.1.80:8097/single/abc/queue/item/uuid:test.flac"
512 mock_provider.mass.streams.resolve_stream_url = AsyncMock(return_value=stream_url)
513
514 device_media = MagicMock()
515 device_media.uri = None
516 device_media.title = None
517 device_media.artist = None
518 device_media.album = None
519 device_media.position = 273
520 mock_wiim_device.play_mode = SOURCE_NETWORK
521 mock_wiim_device.current_media = device_media
522
523 player = self._make_player(mock_provider, mock_wiim_device)
524
525 await player.play_media(PlayerMedia(uri="library://track/1", title="New Track"))
526 await player._sync_position()
527
528 assert player._attr_elapsed_time == 0
529
530 @pytest.mark.asyncio
531 async def test_sync_position_ignores_foreign_uri(
532 self, mock_provider: MagicMock, mock_wiim_device: MagicMock
533 ) -> None:
534 """The device's position is rejected while it hasn't loaded MA's stream uri."""
535 stream_uri = "http://192.168.1.80:8097/single/abc/queue/item/uuid:test.flac"
536 player = self._make_player(mock_provider, mock_wiim_device)
537 player._ma_stream_uri = stream_uri
538 player._attr_elapsed_time = 0
539 player._attr_elapsed_time_last_updated = 1000.0
540
541 device_media = MagicMock()
542 device_media.uri = "wiimu_airplay"
543 device_media.position = 273
544 mock_wiim_device.current_media = device_media
545
546 await player._sync_position()
547
548 assert player._attr_elapsed_time == 0
549 assert player._attr_elapsed_time_last_updated == 1000.0
550
551 @pytest.mark.asyncio
552 async def test_sync_position_accepts_matching_uri_and_clears_guard(
553 self, mock_provider: MagicMock, mock_wiim_device: MagicMock
554 ) -> None:
555 """Once the device reports MA's own uri, its position is trusted and the guard lifts."""
556 stream_uri = "http://192.168.1.80:8097/single/abc/queue/item/uuid:test.flac"
557 mock_provider.mass.streams.resolve_stream_url = AsyncMock(return_value=stream_uri)
558 player = self._make_player(mock_provider, mock_wiim_device)
559
560 device_media = MagicMock()
561 device_media.uri = stream_uri
562 device_media.position = 12
563 mock_wiim_device.current_media = device_media
564
565 await player.play_media(PlayerMedia(uri="library://track/1", title="New Track"))
566 player._attr_elapsed_time_last_updated = 1000.0
567
568 await player._sync_position()
569
570 assert player._attr_elapsed_time == 12
571 assert player._attr_elapsed_time_last_updated > 1000.0
572 assert player._ma_stream_uri is None
573
574 # A later switch to an external source must not be blocked by a stale guard.
575 device_media.uri = "wiimu_airplay"
576 device_media.position = 55
577 await player._sync_position()
578
579 assert player._attr_elapsed_time == 55
580
581 @pytest.mark.asyncio
582 async def test_sync_position_accepts_device_when_ma_not_driving_playback(
583 self, mock_provider: MagicMock, mock_wiim_device: MagicMock
584 ) -> None:
585 """With no stream handed over by MA (external source), the device's position is authoritative."""
586 player = self._make_player(mock_provider, mock_wiim_device)
587 player._ma_stream_uri = None
588 player._attr_elapsed_time = 0
589 player._attr_elapsed_time_last_updated = 1000.0
590
591 device_media = MagicMock()
592 device_media.uri = "wiimu_airplay"
593 device_media.position = 42
594 mock_wiim_device.current_media = device_media
595
596 await player._sync_position()
597
598 assert player._attr_elapsed_time == 42
599 assert player._attr_elapsed_time_last_updated > 1000.0
600
601
602class TestPollRefreshesTransportState:
603 """Polling must correct state the device stopped pushing events for."""
604
605 def _make_player(self, provider: MagicMock, device: MagicMock) -> WiimPlayer:
606 player = WiimPlayer(provider=provider, player_id="uuid:test", device=device)
607 player.update_state = MagicMock() # type: ignore[misc,method-assign]
608 return player
609
610 @pytest.mark.asyncio
611 async def test_poll_fetches_device_status(
612 self, mock_provider: MagicMock, mock_wiim_device: MagicMock
613 ) -> None:
614 """A poll must ask the device for its transport state, not just its position."""
615 player = self._make_player(mock_provider, mock_wiim_device)
616
617 await player.poll()
618
619 mock_wiim_device.async_update_http_status.assert_awaited_once()
620
621 @pytest.mark.asyncio
622 async def test_poll_applies_stop_reported_after_missed_events(
623 self, mock_provider: MagicMock, mock_wiim_device: MagicMock
624 ) -> None:
625 """A device that went to stop while events were lost must no longer read as paused."""
626 player = self._make_player(mock_provider, mock_wiim_device)
627 player._attr_playback_state = PlaybackState.PAUSED
628 mock_wiim_device.play_mode = SOURCE_NETWORK
629
630 async def _report_stopped() -> None:
631 mock_wiim_device.playing_status = PlayingStatus.STOPPED
632
633 mock_wiim_device.async_update_http_status = AsyncMock(side_effect=_report_stopped)
634
635 await player.poll()
636
637 assert player._attr_playback_state == PlaybackState.IDLE
638
639 @pytest.mark.asyncio
640 async def test_poll_survives_invalid_data_from_device(
641 self, mock_provider: MagicMock, mock_wiim_device: MagicMock
642 ) -> None:
643 """A 'Failed' or unparsable status response must not escape the poll."""
644 mock_wiim_device.async_update_http_status = AsyncMock(
645 side_effect=WiimInvalidDataException("Command getStatusEx returned 'Failed'")
646 )
647 player = self._make_player(mock_provider, mock_wiim_device)
648
649 await player.poll()
650
651 mock_wiim_device.sync_device_duration_and_position.assert_awaited_once()
652
653 @pytest.mark.asyncio
654 async def test_poll_skips_status_of_unavailable_device(
655 self, mock_provider: MagicMock, mock_wiim_device: MagicMock
656 ) -> None:
657 """A device the SDK already gave up on must not be queried again by the poll."""
658 mock_wiim_device.available = False
659 player = self._make_player(mock_provider, mock_wiim_device)
660
661 await player.poll()
662
663 mock_wiim_device.async_update_http_status.assert_not_awaited()
664