/
/
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 TestVolumeCommand:
346 """Test the volume command reaches the device."""
347
348 @pytest.mark.asyncio
349 async def test_volume_set_delegates_to_device(
350 self, mock_provider: MagicMock, mock_wiim_device: MagicMock
351 ) -> None:
352 """Setting the volume should reach the device and land in the player state."""
353 player = WiimPlayer(provider=mock_provider, player_id="uuid:test", device=mock_wiim_device)
354 player.update_state = MagicMock() # type: ignore[misc,method-assign]
355
356 async def _apply_volume(volume_level: int) -> None:
357 mock_wiim_device.volume = volume_level
358
359 mock_wiim_device.async_set_volume = AsyncMock(side_effect=_apply_volume)
360 await player.volume_set(42)
361
362 mock_wiim_device.async_set_volume.assert_awaited_once_with(42)
363 assert player._attr_volume_level == 42
364
365
366class TestErrorHandling:
367 """Test that command errors mark device unavailable."""
368
369 @pytest.mark.asyncio
370 async def test_play_error_refreshes_state(
371 self, mock_provider: MagicMock, mock_wiim_device: MagicMock
372 ) -> None:
373 """Play command error should refresh state without marking unavailable."""
374 mock_wiim_device.async_play.side_effect = WiimRequestException("timeout")
375 player = WiimPlayer(provider=mock_provider, player_id="uuid:test", device=mock_wiim_device)
376 player.update_state = MagicMock() # type: ignore[misc,method-assign]
377 await player.play()
378 assert player._attr_available is True
379 player.update_state.assert_called()
380
381 @pytest.mark.asyncio
382 async def test_volume_set_error_refreshes_state(
383 self, mock_provider: MagicMock, mock_wiim_device: MagicMock
384 ) -> None:
385 """Volume set error should refresh state without marking unavailable."""
386 mock_wiim_device.async_set_volume.side_effect = WiimDeviceException("disconnected")
387 player = WiimPlayer(provider=mock_provider, player_id="uuid:test", device=mock_wiim_device)
388 player.update_state = MagicMock() # type: ignore[misc,method-assign]
389 await player.volume_set(50)
390 assert player._attr_available is True
391 player.update_state.assert_called()
392
393 @pytest.mark.asyncio
394 async def test_volume_set_survives_invalid_data_from_device(
395 self, mock_provider: MagicMock, mock_wiim_device: MagicMock
396 ) -> None:
397 """A speaker answering a volume command with something other than OK must not throw."""
398 mock_wiim_device.async_set_volume = AsyncMock(
399 side_effect=WiimInvalidDataException("did not return 'OK'")
400 )
401 player = WiimPlayer(provider=mock_provider, player_id="uuid:test", device=mock_wiim_device)
402 player.update_state = MagicMock() # type: ignore[misc,method-assign]
403 await player.volume_set(42)
404 assert player._attr_available is True
405
406 @pytest.mark.asyncio
407 async def test_select_source_survives_invalid_data_from_device(
408 self, mock_provider: MagicMock, mock_wiim_device: MagicMock
409 ) -> None:
410 """A speaker rejecting a source change must not throw out of the command."""
411 mock_wiim_device.async_set_play_mode = AsyncMock(
412 side_effect=WiimInvalidDataException("did not return 'OK'")
413 )
414 player = WiimPlayer(provider=mock_provider, player_id="uuid:test", device=mock_wiim_device)
415 player.update_state = MagicMock() # type: ignore[misc,method-assign]
416 await player.select_source("bluetooth")
417 assert player._attr_available is True
418
419 @pytest.mark.asyncio
420 async def test_stop_error_refreshes_state(
421 self, mock_provider: MagicMock, mock_wiim_device: MagicMock
422 ) -> None:
423 """Stop command error should refresh state without marking unavailable."""
424 mock_wiim_device.async_stop.side_effect = WiimRequestException("connection lost")
425 player = WiimPlayer(provider=mock_provider, player_id="uuid:test", device=mock_wiim_device)
426 player.update_state = MagicMock() # type: ignore[misc,method-assign]
427 await player.stop()
428 assert player._attr_available is True
429 player.update_state.assert_called()
430
431 @pytest.mark.asyncio
432 async def test_pause_error_refreshes_state(
433 self, mock_provider: MagicMock, mock_wiim_device: MagicMock
434 ) -> None:
435 """Pause command error should refresh state without marking unavailable."""
436 mock_wiim_device.async_pause.side_effect = WiimDeviceException("timeout")
437 player = WiimPlayer(provider=mock_provider, player_id="uuid:test", device=mock_wiim_device)
438 player.update_state = MagicMock() # type: ignore[misc,method-assign]
439 await player.pause()
440 assert player._attr_available is True
441 player.update_state.assert_called()
442
443
444class TestStalePositionOnNewStream:
445 """A new stream handed to the device must not inherit the previous position."""
446
447 def _make_player(self, provider: MagicMock, device: MagicMock) -> WiimPlayer:
448 player = WiimPlayer(provider=provider, player_id="uuid:test", device=device)
449 player.update_state = MagicMock() # type: ignore[misc,method-assign]
450 return player
451
452 @pytest.mark.asyncio
453 async def test_play_media_resets_elapsed_time(
454 self, mock_provider: MagicMock, mock_wiim_device: MagicMock
455 ) -> None:
456 """play_media() must clear the stale elapsed_time anchor from prior content."""
457 stream_url = "http://192.168.1.80:8097/single/abc/queue/item/uuid:test.flac"
458 mock_provider.mass.streams.resolve_stream_url = AsyncMock(return_value=stream_url)
459 player = self._make_player(mock_provider, mock_wiim_device)
460 player._attr_elapsed_time = 273
461 player._attr_elapsed_time_last_updated = 1000.0
462
463 await player.play_media(PlayerMedia(uri="library://track/1", title="Some Track"))
464
465 assert player._attr_elapsed_time == 0
466 assert player._attr_elapsed_time_last_updated is not None
467 assert player._attr_elapsed_time_last_updated > 1000.0
468
469 @pytest.mark.asyncio
470 async def test_play_media_then_sync_position_end_to_end(
471 self, mock_provider: MagicMock, mock_wiim_device: MagicMock
472 ) -> None:
473 """
474 The device still reports the previous AirPlay content after play_media.
475
476 Its metadata makes MA rebuild _attr_current_media from the device's own
477 uri, so the position guard must not key off _attr_current_media.
478 """
479 stream_url = "http://192.168.1.80:8097/single/abc/queue/item/uuid:test.flac"
480 mock_provider.mass.streams.resolve_stream_url = AsyncMock(return_value=stream_url)
481
482 device_media = MagicMock()
483 device_media.uri = "wiimu_airplay"
484 device_media.title = "Previous Song"
485 device_media.artist = "Previous Artist"
486 device_media.album = "Previous Album"
487 device_media.position = 273
488 mock_wiim_device.play_mode = SOURCE_NETWORK
489 mock_wiim_device.current_media = device_media
490 mock_wiim_device.playing_status = PlayingStatus.PLAYING
491
492 player = self._make_player(mock_provider, mock_wiim_device)
493 player._attr_elapsed_time = 273
494
495 await player.play_media(PlayerMedia(uri="library://track/1", title="New Track"))
496 assert player._attr_elapsed_time == 0
497
498 await player._sync_position()
499
500 assert player._attr_elapsed_time == 0
501
502 @pytest.mark.asyncio
503 async def test_failed_play_media_releases_the_guard(
504 self, mock_provider: MagicMock, mock_wiim_device: MagicMock
505 ) -> None:
506 """A device that never took our stream must not stay guarded against its own position."""
507 stream_url = "http://192.168.1.80:8097/single/abc/queue/item/uuid:test.flac"
508 mock_provider.mass.streams.resolve_stream_url = AsyncMock(return_value=stream_url)
509 mock_wiim_device.async_play = AsyncMock(side_effect=WiimDeviceException("boom"))
510 player = self._make_player(mock_provider, mock_wiim_device)
511
512 await player.play_media(PlayerMedia(uri="library://track/1", title="New Track"))
513
514 device_media = MagicMock()
515 device_media.uri = "wiimu_airplay"
516 device_media.position = 42
517 mock_wiim_device.current_media = device_media
518
519 await player._sync_position()
520
521 assert player._attr_elapsed_time == 42
522
523 @pytest.mark.asyncio
524 async def test_sync_position_ignores_position_reported_without_uri(
525 self, mock_provider: MagicMock, mock_wiim_device: MagicMock
526 ) -> None:
527 """The device clears its uri mid-handover while still reporting the old position."""
528 stream_url = "http://192.168.1.80:8097/single/abc/queue/item/uuid:test.flac"
529 mock_provider.mass.streams.resolve_stream_url = AsyncMock(return_value=stream_url)
530
531 device_media = MagicMock()
532 device_media.uri = None
533 device_media.title = None
534 device_media.artist = None
535 device_media.album = None
536 device_media.position = 273
537 mock_wiim_device.play_mode = SOURCE_NETWORK
538 mock_wiim_device.current_media = device_media
539
540 player = self._make_player(mock_provider, mock_wiim_device)
541
542 await player.play_media(PlayerMedia(uri="library://track/1", title="New Track"))
543 await player._sync_position()
544
545 assert player._attr_elapsed_time == 0
546
547 @pytest.mark.asyncio
548 async def test_sync_position_ignores_foreign_uri(
549 self, mock_provider: MagicMock, mock_wiim_device: MagicMock
550 ) -> None:
551 """The device's position is rejected while it hasn't loaded MA's stream uri."""
552 stream_uri = "http://192.168.1.80:8097/single/abc/queue/item/uuid:test.flac"
553 player = self._make_player(mock_provider, mock_wiim_device)
554 player._ma_stream_uri = stream_uri
555 player._attr_elapsed_time = 0
556 player._attr_elapsed_time_last_updated = 1000.0
557
558 device_media = MagicMock()
559 device_media.uri = "wiimu_airplay"
560 device_media.position = 273
561 mock_wiim_device.current_media = device_media
562
563 await player._sync_position()
564
565 assert player._attr_elapsed_time == 0
566 assert player._attr_elapsed_time_last_updated == 1000.0
567
568 @pytest.mark.asyncio
569 async def test_sync_position_accepts_matching_uri_and_clears_guard(
570 self, mock_provider: MagicMock, mock_wiim_device: MagicMock
571 ) -> None:
572 """Once the device reports MA's own uri, its position is trusted and the guard lifts."""
573 stream_uri = "http://192.168.1.80:8097/single/abc/queue/item/uuid:test.flac"
574 mock_provider.mass.streams.resolve_stream_url = AsyncMock(return_value=stream_uri)
575 player = self._make_player(mock_provider, mock_wiim_device)
576
577 device_media = MagicMock()
578 device_media.uri = stream_uri
579 device_media.position = 12
580 mock_wiim_device.current_media = device_media
581
582 await player.play_media(PlayerMedia(uri="library://track/1", title="New Track"))
583 player._attr_elapsed_time_last_updated = 1000.0
584
585 await player._sync_position()
586
587 assert player._attr_elapsed_time == 12
588 assert player._attr_elapsed_time_last_updated > 1000.0
589 assert player._ma_stream_uri is None
590
591 # A later switch to an external source must not be blocked by a stale guard.
592 device_media.uri = "wiimu_airplay"
593 device_media.position = 55
594 await player._sync_position()
595
596 assert player._attr_elapsed_time == 55
597
598 @pytest.mark.asyncio
599 async def test_sync_position_accepts_device_when_ma_not_driving_playback(
600 self, mock_provider: MagicMock, mock_wiim_device: MagicMock
601 ) -> None:
602 """With no stream handed over by MA (external source), the device's position is authoritative."""
603 player = self._make_player(mock_provider, mock_wiim_device)
604 player._ma_stream_uri = None
605 player._attr_elapsed_time = 0
606 player._attr_elapsed_time_last_updated = 1000.0
607
608 device_media = MagicMock()
609 device_media.uri = "wiimu_airplay"
610 device_media.position = 42
611 mock_wiim_device.current_media = device_media
612
613 await player._sync_position()
614
615 assert player._attr_elapsed_time == 42
616 assert player._attr_elapsed_time_last_updated > 1000.0
617
618
619class TestPollRefreshesTransportState:
620 """Polling must correct state the device stopped pushing events for."""
621
622 def _make_player(self, provider: MagicMock, device: MagicMock) -> WiimPlayer:
623 player = WiimPlayer(provider=provider, player_id="uuid:test", device=device)
624 player.update_state = MagicMock() # type: ignore[misc,method-assign]
625 return player
626
627 @pytest.mark.asyncio
628 async def test_poll_fetches_device_status(
629 self, mock_provider: MagicMock, mock_wiim_device: MagicMock
630 ) -> None:
631 """A poll must ask the device for its transport state, not just its position."""
632 player = self._make_player(mock_provider, mock_wiim_device)
633
634 await player.poll()
635
636 mock_wiim_device.async_update_http_status.assert_awaited_once()
637
638 @pytest.mark.asyncio
639 async def test_poll_applies_stop_reported_after_missed_events(
640 self, mock_provider: MagicMock, mock_wiim_device: MagicMock
641 ) -> None:
642 """A device that went to stop while events were lost must no longer read as paused."""
643 player = self._make_player(mock_provider, mock_wiim_device)
644 player._attr_playback_state = PlaybackState.PAUSED
645 mock_wiim_device.play_mode = SOURCE_NETWORK
646
647 async def _report_stopped() -> None:
648 mock_wiim_device.playing_status = PlayingStatus.STOPPED
649
650 mock_wiim_device.async_update_http_status = AsyncMock(side_effect=_report_stopped)
651
652 await player.poll()
653
654 assert player._attr_playback_state == PlaybackState.IDLE
655
656 @pytest.mark.asyncio
657 async def test_poll_survives_invalid_data_from_device(
658 self, mock_provider: MagicMock, mock_wiim_device: MagicMock
659 ) -> None:
660 """A 'Failed' or unparsable status response must not escape the poll."""
661 mock_wiim_device.async_update_http_status = AsyncMock(
662 side_effect=WiimInvalidDataException("Command getStatusEx returned 'Failed'")
663 )
664 player = self._make_player(mock_provider, mock_wiim_device)
665
666 await player.poll()
667
668 mock_wiim_device.sync_device_duration_and_position.assert_awaited_once()
669
670 @pytest.mark.asyncio
671 async def test_poll_skips_status_of_unavailable_device(
672 self, mock_provider: MagicMock, mock_wiim_device: MagicMock
673 ) -> None:
674 """A device the SDK already gave up on must not be queried again by the poll."""
675 mock_wiim_device.available = False
676 player = self._make_player(mock_provider, mock_wiim_device)
677
678 await player.poll()
679
680 mock_wiim_device.async_update_http_status.assert_not_awaited()
681