/
/
/
1"""Tests for protocol player linking and universal player creation."""
2
3import logging
4from unittest.mock import MagicMock
5
6import pytest
7from music_assistant_models.enums import (
8 IdentifierType,
9 PlayerFeature,
10 PlayerType,
11)
12from music_assistant_models.player import OutputProtocol
13
14from music_assistant.controllers.players import PlayerController
15from music_assistant.helpers.throttle_retry import Throttler
16from music_assistant.models.player import DeviceInfo, Player
17from music_assistant.providers.universal_player.provider import UniversalPlayerProvider
18
19
20def create_mock_config(name: str) -> MagicMock:
21 """Create a mock player config with the given name."""
22 config = MagicMock()
23 config.name = None # No custom name, use default
24 config.default_name = name
25 return config
26
27
28def create_mock_universal_provider(mock_mass: MagicMock) -> UniversalPlayerProvider:
29 """Create a mock UniversalPlayerProvider for testing."""
30 # Create a mock manifest
31 manifest = MagicMock()
32 manifest.domain = "universal_player"
33 manifest.name = "Universal Player"
34
35 # Create provider with the mock manifest
36 provider = UniversalPlayerProvider.__new__(UniversalPlayerProvider)
37 provider.mass = mock_mass
38 provider.manifest = manifest
39 provider.logger = logging.getLogger("test.universal_player")
40 return provider
41
42
43class MockProvider:
44 """Mock player provider for testing."""
45
46 def __init__(
47 self, domain: str, instance_id: str = "test_instance", mass: MagicMock | None = None
48 ) -> None:
49 """Initialize the mock provider."""
50 self.domain = domain
51 self.instance_id = instance_id
52 self.name = f"Mock {domain.title()}"
53 self.manifest = MagicMock()
54 self.manifest.name = f"Mock {domain} Provider"
55 self.mass = mass or MagicMock()
56 self.logger = logging.getLogger(f"test.{domain}")
57
58
59class MockPlayer(Player):
60 """Mock player for testing."""
61
62 def __init__(
63 self,
64 provider: MockProvider,
65 player_id: str,
66 name: str,
67 player_type: PlayerType = PlayerType.PLAYER,
68 identifiers: dict[IdentifierType, str] | None = None,
69 ) -> None:
70 """Initialize the mock player."""
71 # Set up the mock config before calling super().__init__
72 # because the parent __init__ accesses config
73 provider.mass.config.get_base_player_config.return_value = create_mock_config(name)
74
75 super().__init__(provider, player_id) # type: ignore[arg-type]
76 self._attr_name = name
77 # Set type as instance attribute (overrides class attribute)
78 self._attr_type = player_type
79 self._attr_available = True
80 self._attr_powered = True
81 self._attr_supported_features = {PlayerFeature.VOLUME_SET}
82 self._attr_can_group_with = set()
83
84 # Set up device info with identifiers
85 self._attr_device_info = DeviceInfo(
86 model="Test Model",
87 manufacturer="Test Manufacturer",
88 )
89 if identifiers:
90 for conn_type, value in identifiers.items():
91 self._attr_device_info.add_identifier(conn_type, value)
92
93 # Clear cached properties after modifying attributes
94 self._cache.clear()
95
96 # Update state to reflect the modified attributes
97 self.update_state(signal_event=False)
98
99 async def stop(self) -> None:
100 """Stop playback - required abstract method."""
101
102
103@pytest.fixture
104def mock_mass() -> MagicMock:
105 """Create a mock MusicAssistant instance."""
106 mass = MagicMock()
107 mass.closing = False
108 mass.config = MagicMock()
109 mass.config.get = MagicMock(return_value=[])
110 mass.config.get_raw_player_config_value = MagicMock(return_value="auto")
111 # Return "GLOBAL" for log level config (standard default)
112 mass.config.get_raw_core_config_value = MagicMock(return_value="GLOBAL")
113 mass.config.set = MagicMock()
114 mass.signal_event = MagicMock()
115 mass.get_providers = MagicMock(return_value=[])
116 return mass
117
118
119class TestIdentifiersMatch:
120 """Tests for identifier matching logic."""
121
122 def test_mac_address_match(self, mock_mass: MagicMock) -> None:
123 """Test that MAC addresses match correctly."""
124 controller = PlayerController(mock_mass)
125
126 provider = MockProvider("test")
127 player_a = MockPlayer(
128 provider,
129 "player_a",
130 "Player A",
131 identifiers={IdentifierType.MAC_ADDRESS: "AA:BB:CC:DD:EE:FF"},
132 )
133 player_b = MockPlayer(
134 provider,
135 "player_b",
136 "Player B",
137 identifiers={IdentifierType.MAC_ADDRESS: "aa:bb:cc:dd:ee:ff"}, # lowercase
138 )
139
140 assert controller._identifiers_match(player_a, player_b) is True
141
142 def test_mac_address_no_match(self, mock_mass: MagicMock) -> None:
143 """Test that different MAC addresses don't match."""
144 controller = PlayerController(mock_mass)
145
146 provider = MockProvider("test")
147 player_a = MockPlayer(
148 provider,
149 "player_a",
150 "Player A",
151 identifiers={IdentifierType.MAC_ADDRESS: "AA:BB:CC:DD:EE:FF"},
152 )
153 player_b = MockPlayer(
154 provider,
155 "player_b",
156 "Player B",
157 identifiers={IdentifierType.MAC_ADDRESS: "11:22:33:44:55:66"},
158 )
159
160 assert controller._identifiers_match(player_a, player_b) is False
161
162 def test_ip_address_no_match(self, mock_mass: MagicMock) -> None:
163 """Test that IP addresses don't match (IP is excluded as it's not stable)."""
164 controller = PlayerController(mock_mass)
165
166 provider = MockProvider("test")
167 player_a = MockPlayer(
168 provider,
169 "player_a",
170 "Player A",
171 identifiers={IdentifierType.IP_ADDRESS: "192.168.1.100"},
172 )
173 player_b = MockPlayer(
174 provider,
175 "player_b",
176 "Player B",
177 identifiers={IdentifierType.IP_ADDRESS: "192.168.1.100"},
178 )
179
180 # IP address matching is intentionally disabled to prevent false matches
181 assert controller._identifiers_match(player_a, player_b) is False
182
183 def test_sonos_uuid_dlna_suffix_match(self, mock_mass: MagicMock) -> None:
184 """Test Sonos UUID matching with DLNA _MR suffix."""
185 controller = PlayerController(mock_mass)
186
187 provider = MockProvider("test")
188 # Sonos native player
189 player_a = MockPlayer(
190 provider,
191 "player_a",
192 "Sonos Player",
193 identifiers={IdentifierType.UUID: "RINCON_000E58123456"},
194 )
195 # DLNA player with _MR suffix
196 player_b = MockPlayer(
197 provider,
198 "player_b",
199 "DLNA Player",
200 identifiers={IdentifierType.UUID: "RINCON_000E58123456_MR"},
201 )
202
203 assert controller._identifiers_match(player_a, player_b) is True
204
205 def test_no_identifiers_no_match(self, mock_mass: MagicMock) -> None:
206 """Test that players without identifiers don't match."""
207 controller = PlayerController(mock_mass)
208
209 provider = MockProvider("test")
210 player_a = MockPlayer(provider, "player_a", "Player A")
211 player_b = MockPlayer(provider, "player_b", "Player B")
212
213 assert controller._identifiers_match(player_a, player_b) is False
214
215
216class TestProtocolPlayerDetection:
217 """Tests for protocol player type detection."""
218
219 def test_is_protocol_player_true(self, mock_mass: MagicMock) -> None:
220 """Test that PlayerType.PROTOCOL is correctly detected."""
221 controller = PlayerController(mock_mass)
222
223 provider = MockProvider("airplay")
224 player = MockPlayer(
225 provider,
226 "ap_123456",
227 "Samsung TV (AirPlay)",
228 player_type=PlayerType.PROTOCOL,
229 )
230
231 assert controller._is_protocol_player(player) is True
232
233 def test_is_protocol_player_false(self, mock_mass: MagicMock) -> None:
234 """Test that PlayerType.PLAYER is not detected as protocol."""
235 controller = PlayerController(mock_mass)
236
237 provider = MockProvider("airplay")
238 player = MockPlayer(
239 provider,
240 "ap_123456",
241 "HomePod",
242 player_type=PlayerType.PLAYER, # Apple device with native support
243 )
244
245 assert controller._is_protocol_player(player) is False
246
247
248class TestFindMatchingProtocolPlayers:
249 """Tests for finding matching protocol players."""
250
251 def test_find_matching_by_mac(self, mock_mass: MagicMock) -> None:
252 """Test finding matching protocol players by MAC address."""
253 controller = PlayerController(mock_mass)
254
255 # Set up providers
256 airplay_provider = MockProvider("airplay")
257 chromecast_provider = MockProvider("chromecast")
258
259 # Create matching protocol players (same device, different protocols)
260 airplay_player = MockPlayer(
261 airplay_provider,
262 "ap_aabbccddee",
263 "Samsung TV (AirPlay)",
264 player_type=PlayerType.PROTOCOL,
265 identifiers={IdentifierType.MAC_ADDRESS: "AA:BB:CC:DD:EE:FF"},
266 )
267 chromecast_player = MockPlayer(
268 chromecast_provider,
269 "cc_aabbccddee",
270 "Samsung TV (Chromecast)",
271 player_type=PlayerType.PROTOCOL,
272 identifiers={IdentifierType.MAC_ADDRESS: "AA:BB:CC:DD:EE:FF"},
273 )
274
275 # Register players
276 controller._players = {
277 "ap_aabbccddee": airplay_player,
278 "cc_aabbccddee": chromecast_player,
279 }
280 controller._player_throttlers = {
281 "ap_aabbccddee": Throttler(1, 0.05),
282 "cc_aabbccddee": Throttler(1, 0.05),
283 }
284
285 # Find matching players for AirPlay player
286 matches = controller._find_matching_protocol_players(airplay_player)
287
288 assert len(matches) == 2
289 assert airplay_player in matches
290 assert chromecast_player in matches
291
292
293class TestGetDeviceKeyFromPlayers:
294 """Tests for device key generation."""
295
296 def test_device_key_from_mac(self, mock_mass: MagicMock) -> None:
297 """Test device key generation from MAC address."""
298 universal_provider = create_mock_universal_provider(mock_mass)
299
300 provider = MockProvider("airplay")
301 player = MockPlayer(
302 provider,
303 "ap_123456",
304 "Test Player",
305 identifiers={IdentifierType.MAC_ADDRESS: "AA:BB:CC:DD:EE:FF"},
306 )
307
308 device_key = universal_provider._get_device_key_from_players([player])
309
310 assert device_key == "aabbccddeeff"
311
312 def test_device_key_from_uuid_fallback(self, mock_mass: MagicMock) -> None:
313 """Test device key generation falls back to UUID when no MAC available."""
314 universal_provider = create_mock_universal_provider(mock_mass)
315
316 provider = MockProvider("dlna")
317 player = MockPlayer(
318 provider,
319 "dlna_123456",
320 "Test Player",
321 identifiers={IdentifierType.UUID: "uuid:12345678-1234-1234-1234-123456789abc"},
322 )
323
324 device_key = universal_provider._get_device_key_from_players([player])
325
326 assert device_key == "uuid12345678123412341234123456789abc"
327
328 def test_device_key_from_ip_falls_back_to_player_id(self, mock_mass: MagicMock) -> None:
329 """Test that device key falls back to player_id for IP-only players (IP not used)."""
330 universal_provider = create_mock_universal_provider(mock_mass)
331
332 provider = MockProvider("airplay")
333 player = MockPlayer(
334 provider,
335 "ap_123456",
336 "Test Player",
337 identifiers={IdentifierType.IP_ADDRESS: "192.168.1.100"},
338 )
339
340 device_key = universal_provider._get_device_key_from_players([player])
341
342 # IP address is not used for device key - falls back to player_id
343 # This allows protocol players without MAC/UUID to still get a UniversalPlayer
344 assert device_key == "ap_123456"
345
346 def test_device_key_from_no_identifiers_falls_back_to_player_id(
347 self, mock_mass: MagicMock
348 ) -> None:
349 """Test that device key falls back to player_id when no identifiers at all."""
350 universal_provider = create_mock_universal_provider(mock_mass)
351
352 provider = MockProvider("sendspin")
353 player = MockPlayer(
354 provider,
355 "sendspin-device-abc",
356 "Test Player",
357 # No identifiers at all (like Sendspin protocol players)
358 )
359
360 device_key = universal_provider._get_device_key_from_players([player])
361
362 # Falls back to player_id when no MAC/UUID identifiers
363 assert device_key == "sendspindeviceabc"
364
365
366class TestGetCleanPlayerName:
367 """Tests for player name selection."""
368
369 def test_prefers_chromecast_name(self, mock_mass: MagicMock) -> None:
370 """Test that Chromecast names are preferred over other protocols."""
371 universal_provider = create_mock_universal_provider(mock_mass)
372
373 airplay_provider = MockProvider("airplay")
374 chromecast_provider = MockProvider("chromecast")
375
376 airplay_player = MockPlayer(
377 airplay_provider,
378 "ap_123456",
379 "Samsung TV",
380 player_type=PlayerType.PROTOCOL,
381 )
382 chromecast_player = MockPlayer(
383 chromecast_provider,
384 "cc_123456",
385 "Living Room Speaker",
386 player_type=PlayerType.PROTOCOL,
387 )
388
389 # Chromecast should be preferred (priority 1)
390 clean_name = universal_provider._get_clean_player_name([airplay_player, chromecast_player])
391 assert clean_name == "Living Room Speaker"
392
393 def test_filters_mac_address_names(self, mock_mass: MagicMock) -> None:
394 """Test that MAC address-like names are filtered out."""
395 universal_provider = create_mock_universal_provider(mock_mass)
396
397 squeezelite_provider = MockProvider("squeezelite")
398 airplay_provider = MockProvider("airplay")
399
400 # Squeezelite with MAC address as name
401 sq_player = MockPlayer(
402 squeezelite_provider,
403 "sq_123456",
404 "AA:BB:CC:DD:EE:FF",
405 player_type=PlayerType.PROTOCOL,
406 )
407 # AirPlay with proper name
408 ap_player = MockPlayer(
409 airplay_provider,
410 "ap_123456",
411 "Kitchen Speaker",
412 player_type=PlayerType.PROTOCOL,
413 )
414
415 # Should prefer Kitchen Speaker over MAC address
416 clean_name = universal_provider._get_clean_player_name([sq_player, ap_player])
417 assert clean_name == "Kitchen Speaker"
418
419 def test_filters_player_id_names(self, mock_mass: MagicMock) -> None:
420 """Test that player ID-like names are filtered out."""
421 universal_provider = create_mock_universal_provider(mock_mass)
422
423 sendspin_provider = MockProvider("sendspin")
424 dlna_provider = MockProvider("dlna")
425
426 # SendSpin with player ID as name
427 ss_player = MockPlayer(
428 sendspin_provider,
429 "sendspin_123456",
430 "sendspin_device_abc",
431 player_type=PlayerType.PROTOCOL,
432 )
433 # DLNA with proper name
434 dlna_player = MockPlayer(
435 dlna_provider,
436 "dlna_123456",
437 "Bedroom TV",
438 player_type=PlayerType.PROTOCOL,
439 )
440
441 # Should prefer Bedroom TV over player ID
442 clean_name = universal_provider._get_clean_player_name([ss_player, dlna_player])
443 assert clean_name == "Bedroom TV"
444
445 def test_valid_name_unchanged(self, mock_mass: MagicMock) -> None:
446 """Test that valid names are returned unchanged."""
447 universal_provider = create_mock_universal_provider(mock_mass)
448
449 provider = MockProvider("airplay")
450 player = MockPlayer(
451 provider,
452 "ap_123456",
453 "HomePod Mini",
454 player_type=PlayerType.PLAYER,
455 )
456
457 clean_name = universal_provider._get_clean_player_name([player])
458 assert clean_name == "HomePod Mini"
459
460
461class TestCachedProtocolParentRestore:
462 """Tests for restoring cached protocol parent links."""
463
464 def test_protocol_parent_id_restored_from_config(self, mock_mass: MagicMock) -> None:
465 """Test that cached protocol_parent_id is loaded and used for immediate linking."""
466 controller = PlayerController(mock_mass)
467
468 # Mock config to return cached parent_id when queried
469 def mock_config_get(key: str, default: str | None = None) -> str | None:
470 if "protocol_parent_id" in str(key):
471 return "native_player_id"
472 return default
473
474 mock_mass.config.get.side_effect = mock_config_get
475
476 # Create native player
477 native_provider = MockProvider("sonos", mass=mock_mass)
478 native_player = MockPlayer(
479 native_provider,
480 "native_player_id",
481 "Sonos Speaker",
482 identifiers={IdentifierType.MAC_ADDRESS: "AA:BB:CC:DD:EE:FF"},
483 )
484
485 # Create protocol player
486 dlna_provider = MockProvider("dlna", mass=mock_mass)
487 protocol_player = MockPlayer(
488 dlna_provider,
489 "uuid:RINCON_AABBCCDDEEFF_MR",
490 "Sonos DLNA",
491 player_type=PlayerType.PROTOCOL,
492 )
493
494 # Register native player
495 controller._players = {"native_player_id": native_player}
496 controller._player_throttlers = {"native_player_id": Throttler(1, 0.05)}
497
498 # Try to link protocol to native - should load cached parent_id
499 controller._try_link_protocol_to_native(protocol_player)
500
501 # Verify protocol_parent_id was set
502 assert protocol_player.protocol_parent_id == "native_player_id"
503
504 # Verify protocol was linked to native player
505 assert any(
506 link.output_protocol_id == protocol_player.player_id
507 for link in native_player.linked_output_protocols
508 )
509
510 def test_protocol_parent_id_prevents_universal_player_creation(
511 self, mock_mass: MagicMock
512 ) -> None:
513 """Test that cached protocol_parent_id prevents creating universal player."""
514 controller = PlayerController(mock_mass)
515
516 # Mock config to return cached parent_id (parent not yet registered)
517 def mock_config_get(key: str, default: str | None = None) -> str | None:
518 if "protocol_parent_id" in str(key):
519 return "native_player_id"
520 return default
521
522 mock_mass.config.get.side_effect = mock_config_get
523
524 # Create protocol player
525 dlna_provider = MockProvider("dlna", mass=mock_mass)
526 protocol_player = MockPlayer(
527 dlna_provider,
528 "uuid:RINCON_AABBCCDDEEFF_MR",
529 "Sonos DLNA",
530 player_type=PlayerType.PROTOCOL,
531 )
532
533 # No native player registered yet
534 controller._players = {}
535
536 # Try to link protocol - should set parent_id and skip evaluation
537 controller._try_link_protocol_to_native(protocol_player)
538
539 # Verify protocol_parent_id was set
540 assert protocol_player.protocol_parent_id == "native_player_id"
541
542 # Since parent_id is set, delayed evaluation won't create a universal player
543
544
545class TestSelectBestOutputProtocol:
546 """Tests for output protocol selection logic."""
547
548 def test_select_native_when_preferred_is_native(self, mock_mass: MagicMock) -> None:
549 """Test that native protocol is selected when user prefers native."""
550 # Mock config to return "native" as preferred
551 mock_mass.config.get_raw_player_config_value = MagicMock(return_value="native")
552
553 controller = PlayerController(mock_mass)
554 provider = MockProvider("sonos", mass=mock_mass)
555
556 # Create native player with PLAY_MEDIA support
557 native_player = MockPlayer(
558 provider,
559 "sonos_123",
560 "Kantoor",
561 identifiers={IdentifierType.MAC_ADDRESS: "AA:BB:CC:DD:EE:FF"},
562 )
563 native_player._attr_supported_features.add(PlayerFeature.PLAY_MEDIA)
564
565 # Wire up mock_mass.players to controller
566 mock_mass.players = controller
567
568 # Register players
569 controller._players = {"sonos_123": native_player}
570 controller._player_throttlers = {"sonos_123": Throttler(1, 0.05)}
571
572 # Select protocol
573 selected_player, output_protocol = controller._select_best_output_protocol(native_player)
574
575 # Should select native player
576 assert selected_player == native_player
577 assert output_protocol is None # None means native playback
578
579 def test_select_dlna_when_preferred_is_dlna(self, mock_mass: MagicMock) -> None:
580 """Test that DLNA protocol is selected when user prefers DLNA."""
581 # Mock config to return the full player ID as preferred
582 mock_mass.config.get_raw_player_config_value = MagicMock(return_value="dlna_AABBCCDDEEFF")
583
584 controller = PlayerController(mock_mass)
585
586 # Create native player with linked protocols
587 sonos_provider = MockProvider("sonos", mass=mock_mass)
588 native_player = MockPlayer(
589 sonos_provider,
590 "sonos_123",
591 "Kantoor",
592 identifiers={IdentifierType.MAC_ADDRESS: "AA:BB:CC:DD:EE:FF"},
593 )
594 native_player._attr_supported_features.add(PlayerFeature.PLAY_MEDIA)
595
596 # Create DLNA protocol player
597 dlna_provider = MockProvider("dlna", mass=mock_mass)
598 dlna_player = MockPlayer(
599 dlna_provider,
600 "dlna_AABBCCDDEEFF",
601 "Kantoor DLNA",
602 player_type=PlayerType.PROTOCOL,
603 identifiers={IdentifierType.MAC_ADDRESS: "AA:BB:CC:DD:EE:FF"},
604 )
605
606 # Register players
607 controller._players = {
608 "sonos_123": native_player,
609 "dlna_AABBCCDDEEFF": dlna_player,
610 }
611 controller._player_throttlers = {
612 "sonos_123": Throttler(1, 0.05),
613 "dlna_AABBCCDDEEFF": Throttler(1, 0.05),
614 }
615
616 # Link DLNA protocol to native player
617 native_player.set_linked_output_protocols(
618 [
619 OutputProtocol(
620 output_protocol_id="dlna_AABBCCDDEEFF",
621 name="DLNA",
622 protocol_domain="dlna",
623 priority=30,
624 )
625 ]
626 )
627
628 # Select protocol
629 selected_player, output_protocol = controller._select_best_output_protocol(native_player)
630
631 # Should select DLNA player, not native
632 assert selected_player == dlna_player
633 assert output_protocol is not None
634 assert output_protocol.output_protocol_id == "dlna_AABBCCDDEEFF"
635
636 def test_select_airplay_when_preferred_is_airplay(self, mock_mass: MagicMock) -> None:
637 """Test that AirPlay protocol is selected when user prefers AirPlay."""
638 # Mock config to return the full player ID as preferred
639 mock_mass.config.get_raw_player_config_value = MagicMock(
640 return_value="airplay_AABBCCDDEEFF"
641 )
642
643 controller = PlayerController(mock_mass)
644
645 # Create native player
646 sonos_provider = MockProvider("sonos", mass=mock_mass)
647 native_player = MockPlayer(
648 sonos_provider,
649 "sonos_123",
650 "Kantoor",
651 identifiers={IdentifierType.MAC_ADDRESS: "AA:BB:CC:DD:EE:FF"},
652 )
653 native_player._attr_supported_features.add(PlayerFeature.PLAY_MEDIA)
654
655 # Create AirPlay and DLNA protocol players
656 airplay_provider = MockProvider("airplay", mass=mock_mass)
657 airplay_player = MockPlayer(
658 airplay_provider,
659 "airplay_AABBCCDDEEFF",
660 "Kantoor AirPlay",
661 player_type=PlayerType.PROTOCOL,
662 identifiers={IdentifierType.MAC_ADDRESS: "AA:BB:CC:DD:EE:FF"},
663 )
664
665 dlna_provider = MockProvider("dlna", mass=mock_mass)
666 dlna_player = MockPlayer(
667 dlna_provider,
668 "dlna_AABBCCDDEEFF",
669 "Kantoor DLNA",
670 player_type=PlayerType.PROTOCOL,
671 identifiers={IdentifierType.MAC_ADDRESS: "AA:BB:CC:DD:EE:FF"},
672 )
673
674 # Register players
675 controller._players = {
676 "sonos_123": native_player,
677 "airplay_AABBCCDDEEFF": airplay_player,
678 "dlna_AABBCCDDEEFF": dlna_player,
679 }
680 controller._player_throttlers = {
681 "sonos_123": Throttler(1, 0.05),
682 "airplay_AABBCCDDEEFF": Throttler(1, 0.05),
683 "dlna_AABBCCDDEEFF": Throttler(1, 0.05),
684 }
685
686 # Link protocols to native player
687 native_player.set_linked_output_protocols(
688 [
689 OutputProtocol(
690 output_protocol_id="airplay_AABBCCDDEEFF",
691 name="AirPlay",
692 protocol_domain="airplay",
693 priority=10,
694 ),
695 OutputProtocol(
696 output_protocol_id="dlna_AABBCCDDEEFF",
697 name="DLNA",
698 protocol_domain="dlna",
699 priority=30,
700 ),
701 ]
702 )
703
704 # Select protocol
705 selected_player, output_protocol = controller._select_best_output_protocol(native_player)
706
707 # Should select AirPlay player (even though DLNA has lower priority value),
708 # because user preference overrides priority
709 assert selected_player == airplay_player
710 assert output_protocol is not None
711 assert output_protocol.output_protocol_id == "airplay_AABBCCDDEEFF"
712
713 def test_fallback_to_native_when_auto(self, mock_mass: MagicMock) -> None:
714 """Test that native playback is used when preference is auto."""
715 # Mock config to return "auto" as preferred
716 mock_mass.config.get_raw_player_config_value = MagicMock(return_value="auto")
717
718 controller = PlayerController(mock_mass)
719 provider = MockProvider("sonos", mass=mock_mass)
720
721 native_player = MockPlayer(
722 provider,
723 "sonos_123",
724 "Kantoor",
725 identifiers={IdentifierType.MAC_ADDRESS: "AA:BB:CC:DD:EE:FF"},
726 )
727 native_player._attr_supported_features.add(PlayerFeature.PLAY_MEDIA)
728
729 controller._players = {"sonos_123": native_player}
730 controller._player_throttlers = {"sonos_123": Throttler(1, 0.05)}
731
732 # Select protocol with auto preference
733 selected_player, output_protocol = controller._select_best_output_protocol(native_player)
734
735 # Should select native player
736 assert selected_player == native_player
737 assert output_protocol is None # None means native playback
738
739
740class TestPlayerGrouping:
741 """Tests for player grouping scenarios."""
742
743 def test_native_to_native_grouping(self, mock_mass: MagicMock) -> None:
744 """Test that native players from same provider can group together."""
745 controller = PlayerController(mock_mass)
746
747 sonos_provider = MockProvider("sonos", mass=mock_mass)
748
749 # Create two Sonos players
750 player_a = MockPlayer(
751 sonos_provider,
752 "sonos_123",
753 "Living Room",
754 identifiers={IdentifierType.MAC_ADDRESS: "AA:BB:CC:DD:EE:01"},
755 )
756 player_a._attr_supported_features.add(PlayerFeature.SET_MEMBERS)
757 player_a._attr_supported_features.add(PlayerFeature.PLAY_MEDIA)
758 player_a._attr_can_group_with = {"sonos_456"}
759 player_a._cache.clear() # Clear cached properties after modifying attributes
760
761 player_b = MockPlayer(
762 sonos_provider,
763 "sonos_456",
764 "Kitchen",
765 identifiers={IdentifierType.MAC_ADDRESS: "AA:BB:CC:DD:EE:02"},
766 )
767 player_b._attr_supported_features.add(PlayerFeature.PLAY_MEDIA)
768 player_b._cache.clear()
769
770 controller._players = {
771 "sonos_123": player_a,
772 "sonos_456": player_b,
773 }
774 controller._player_throttlers = {
775 "sonos_123": Throttler(1, 0.05),
776 "sonos_456": Throttler(1, 0.05),
777 }
778
779 # Translate members for native grouping
780 protocol_members, native_members, _, _ = controller._translate_members_for_protocols(
781 parent_player=player_a,
782 player_ids=["sonos_456"],
783 parent_protocol_player=None,
784 parent_protocol_domain=None,
785 )
786
787 # Should use native grouping (same provider)
788 assert len(native_members) == 1
789 assert "sonos_456" in native_members
790 assert len(protocol_members) == 0
791
792 def test_protocol_to_protocol_grouping(self, mock_mass: MagicMock) -> None:
793 """Test that protocol players can group via shared protocol."""
794 controller = PlayerController(mock_mass)
795
796 # Create two players with AirPlay protocol support
797 sonos_provider = MockProvider("sonos", mass=mock_mass)
798 wiim_provider = MockProvider("wiim", mass=mock_mass)
799 airplay_provider = MockProvider("airplay", mass=mock_mass)
800
801 # Sonos player
802 sonos_player = MockPlayer(
803 sonos_provider,
804 "sonos_123",
805 "Living Room",
806 identifiers={IdentifierType.MAC_ADDRESS: "AA:BB:CC:DD:EE:01"},
807 )
808 sonos_player._attr_supported_features.add(PlayerFeature.PLAY_MEDIA)
809 sonos_player._cache.clear()
810
811 # WiiM player
812 wiim_player = MockPlayer(
813 wiim_provider,
814 "wiim_456",
815 "Bedroom",
816 identifiers={IdentifierType.MAC_ADDRESS: "AA:BB:CC:DD:EE:02"},
817 )
818 wiim_player._attr_supported_features.add(PlayerFeature.PLAY_MEDIA)
819 wiim_player._cache.clear()
820
821 # AirPlay protocol players
822 sonos_airplay = MockPlayer(
823 airplay_provider,
824 "airplay_sonos",
825 "Living Room (AirPlay)",
826 player_type=PlayerType.PROTOCOL,
827 identifiers={IdentifierType.MAC_ADDRESS: "AA:BB:CC:DD:EE:01"},
828 )
829 sonos_airplay._attr_supported_features.add(PlayerFeature.SET_MEMBERS)
830 sonos_airplay._attr_can_group_with = {"airplay_wiim"}
831 sonos_airplay._cache.clear()
832 sonos_airplay.update_state(signal_event=False)
833
834 wiim_airplay = MockPlayer(
835 airplay_provider,
836 "airplay_wiim",
837 "Bedroom (AirPlay)",
838 player_type=PlayerType.PROTOCOL,
839 identifiers={IdentifierType.MAC_ADDRESS: "AA:BB:CC:DD:EE:02"},
840 )
841
842 # Link protocol players to native players
843 sonos_player.set_linked_output_protocols(
844 [
845 OutputProtocol(
846 output_protocol_id="airplay_sonos",
847 name="AirPlay",
848 protocol_domain="airplay",
849 priority=10,
850 available=True,
851 )
852 ]
853 )
854 wiim_player.set_linked_output_protocols(
855 [
856 OutputProtocol(
857 output_protocol_id="airplay_wiim",
858 name="AirPlay",
859 protocol_domain="airplay",
860 priority=10,
861 available=True,
862 )
863 ]
864 )
865
866 controller._players = {
867 "sonos_123": sonos_player,
868 "wiim_456": wiim_player,
869 "airplay_sonos": sonos_airplay,
870 "airplay_wiim": wiim_airplay,
871 }
872 controller._player_throttlers = {
873 "sonos_123": Throttler(1, 0.05),
874 "wiim_456": Throttler(1, 0.05),
875 "airplay_sonos": Throttler(1, 0.05),
876 "airplay_wiim": Throttler(1, 0.05),
877 }
878
879 # Translate members for protocol grouping (via AirPlay)
880 protocol_members, native_members, protocol_player, _ = (
881 controller._translate_members_for_protocols(
882 parent_player=sonos_player,
883 player_ids=["wiim_456"],
884 parent_protocol_player=sonos_airplay,
885 parent_protocol_domain="airplay",
886 )
887 )
888
889 # Should use protocol grouping (AirPlay)
890 assert len(protocol_members) == 1
891 assert "airplay_wiim" in protocol_members
892 assert len(native_members) == 0
893 assert protocol_player == sonos_airplay
894
895 def test_hybrid_grouping(self, mock_mass: MagicMock) -> None:
896 """Test hybrid grouping: native + protocol players in same group."""
897 controller = PlayerController(mock_mass)
898
899 # Create Sonos players (native grouping capability)
900 sonos_provider = MockProvider("sonos", instance_id="sonos_instance", mass=mock_mass)
901 sonos_a = MockPlayer(
902 sonos_provider,
903 "sonos_123",
904 "Living Room",
905 identifiers={IdentifierType.MAC_ADDRESS: "AA:BB:CC:DD:EE:01"},
906 )
907 sonos_a._attr_supported_features.add(PlayerFeature.SET_MEMBERS)
908 sonos_a._attr_supported_features.add(PlayerFeature.PLAY_MEDIA)
909 sonos_a._attr_can_group_with = {"sonos_456"}
910 sonos_a._cache.clear()
911
912 sonos_b = MockPlayer(
913 sonos_provider,
914 "sonos_456",
915 "Kitchen",
916 identifiers={IdentifierType.MAC_ADDRESS: "AA:BB:CC:DD:EE:02"},
917 )
918 sonos_b._attr_supported_features.add(PlayerFeature.PLAY_MEDIA)
919 sonos_b._cache.clear()
920
921 # Create WiiM player with AirPlay protocol
922 wiim_provider = MockProvider("wiim", instance_id="wiim_instance", mass=mock_mass)
923 airplay_provider = MockProvider("airplay", instance_id="airplay_instance", mass=mock_mass)
924
925 wiim_player = MockPlayer(
926 wiim_provider,
927 "wiim_789",
928 "Bedroom",
929 identifiers={IdentifierType.MAC_ADDRESS: "AA:BB:CC:DD:EE:03"},
930 )
931 wiim_player._attr_supported_features.add(PlayerFeature.PLAY_MEDIA)
932 wiim_player._cache.clear()
933
934 # AirPlay protocol players
935 sonos_airplay = MockPlayer(
936 airplay_provider,
937 "airplay_sonos",
938 "Living Room (AirPlay)",
939 player_type=PlayerType.PROTOCOL,
940 identifiers={IdentifierType.MAC_ADDRESS: "AA:BB:CC:DD:EE:01"},
941 )
942 sonos_airplay._attr_supported_features.add(PlayerFeature.SET_MEMBERS)
943 sonos_airplay._attr_can_group_with = {"airplay_wiim"}
944 sonos_airplay._cache.clear()
945 sonos_airplay.update_state(signal_event=False)
946
947 wiim_airplay = MockPlayer(
948 airplay_provider,
949 "airplay_wiim",
950 "Bedroom (AirPlay)",
951 player_type=PlayerType.PROTOCOL,
952 identifiers={IdentifierType.MAC_ADDRESS: "AA:BB:CC:DD:EE:03"},
953 )
954
955 # Link AirPlay to Sonos A
956 sonos_a.set_linked_output_protocols(
957 [
958 OutputProtocol(
959 output_protocol_id="airplay_sonos",
960 name="AirPlay",
961 protocol_domain="airplay",
962 priority=10,
963 available=True,
964 )
965 ]
966 )
967 wiim_player.set_linked_output_protocols(
968 [
969 OutputProtocol(
970 output_protocol_id="airplay_wiim",
971 name="AirPlay",
972 protocol_domain="airplay",
973 priority=10,
974 available=True,
975 )
976 ]
977 )
978 wiim_player.set_active_output_protocol("airplay_wiim")
979 wiim_player.set_protocol_parent_id("airplay_wiim")
980
981 # Wire up mock_mass.players to controller so get_linked_protocol works
982 mock_mass.players = controller
983
984 controller._players = {
985 "sonos_123": sonos_a,
986 "sonos_456": sonos_b,
987 "wiim_789": wiim_player,
988 "airplay_sonos": sonos_airplay,
989 "airplay_wiim": wiim_airplay,
990 }
991 controller._player_throttlers = {
992 "sonos_123": Throttler(1, 0.05),
993 "sonos_456": Throttler(1, 0.05),
994 "wiim_789": Throttler(1, 0.05),
995 "airplay_sonos": Throttler(1, 0.05),
996 "airplay_wiim": Throttler(1, 0.05),
997 }
998
999 # Group Sonos B (native) + WiiM (via AirPlay) to Sonos A
1000 protocol_members, native_members, _protocol_player, _ = (
1001 controller._translate_members_for_protocols(
1002 parent_player=sonos_a,
1003 player_ids=["sonos_456", "wiim_789"],
1004 parent_protocol_player=sonos_airplay,
1005 parent_protocol_domain="airplay",
1006 )
1007 )
1008
1009 # Should have hybrid group: native Sonos B + protocol WiiM
1010 assert len(native_members) == 1
1011 assert "sonos_456" in native_members
1012 assert len(protocol_members) == 1
1013 assert "airplay_wiim" in protocol_members
1014
1015 def test_protocol_selection_requires_set_members(self, mock_mass: MagicMock) -> None:
1016 """Test that only protocols with SET_MEMBERS support are selected for grouping."""
1017 controller = PlayerController(mock_mass)
1018
1019 sonos_provider = MockProvider("sonos", mass=mock_mass)
1020 wiim_provider = MockProvider("wiim", mass=mock_mass)
1021 dlna_provider = MockProvider("dlna", mass=mock_mass)
1022 airplay_provider = MockProvider("airplay", mass=mock_mass)
1023
1024 # Sonos player
1025 sonos_player = MockPlayer(
1026 sonos_provider,
1027 "sonos_123",
1028 "Living Room",
1029 identifiers={IdentifierType.MAC_ADDRESS: "AA:BB:CC:DD:EE:01"},
1030 )
1031 sonos_player._attr_supported_features.add(PlayerFeature.PLAY_MEDIA)
1032 sonos_player._cache.clear()
1033
1034 # WiiM player
1035 wiim_player = MockPlayer(
1036 wiim_provider,
1037 "wiim_456",
1038 "Bedroom",
1039 identifiers={IdentifierType.MAC_ADDRESS: "AA:BB:CC:DD:EE:02"},
1040 )
1041 wiim_player._attr_supported_features.add(PlayerFeature.PLAY_MEDIA)
1042 wiim_player._cache.clear()
1043
1044 # DLNA protocol (does NOT support SET_MEMBERS)
1045 sonos_dlna = MockPlayer(
1046 dlna_provider,
1047 "dlna_sonos",
1048 "Living Room (DLNA)",
1049 player_type=PlayerType.PROTOCOL,
1050 identifiers={IdentifierType.MAC_ADDRESS: "AA:BB:CC:DD:EE:01"},
1051 )
1052 # Note: NO SET_MEMBERS feature
1053
1054 wiim_dlna = MockPlayer(
1055 dlna_provider,
1056 "dlna_wiim",
1057 "Bedroom (DLNA)",
1058 player_type=PlayerType.PROTOCOL,
1059 identifiers={IdentifierType.MAC_ADDRESS: "AA:BB:CC:DD:EE:02"},
1060 )
1061
1062 # AirPlay protocol (DOES support SET_MEMBERS)
1063 sonos_airplay = MockPlayer(
1064 airplay_provider,
1065 "airplay_sonos",
1066 "Living Room (AirPlay)",
1067 player_type=PlayerType.PROTOCOL,
1068 identifiers={IdentifierType.MAC_ADDRESS: "AA:BB:CC:DD:EE:01"},
1069 )
1070 sonos_airplay._attr_supported_features.add(PlayerFeature.SET_MEMBERS)
1071 sonos_airplay._attr_can_group_with = {"airplay_wiim"}
1072 sonos_airplay._cache.clear()
1073
1074 wiim_airplay = MockPlayer(
1075 airplay_provider,
1076 "airplay_wiim",
1077 "Bedroom (AirPlay)",
1078 player_type=PlayerType.PROTOCOL,
1079 identifiers={IdentifierType.MAC_ADDRESS: "AA:BB:CC:DD:EE:02"},
1080 )
1081 wiim_airplay._attr_supported_features.add(PlayerFeature.SET_MEMBERS)
1082 wiim_airplay._attr_can_group_with = {"airplay_sonos"}
1083 wiim_airplay._cache.clear()
1084
1085 # Link protocols (DLNA has lower priority than AirPlay)
1086 sonos_player.set_linked_output_protocols(
1087 [
1088 OutputProtocol(
1089 output_protocol_id="dlna_sonos",
1090 name="DLNA",
1091 protocol_domain="dlna",
1092 priority=30, # Lower priority (higher number)
1093 available=True,
1094 ),
1095 OutputProtocol(
1096 output_protocol_id="airplay_sonos",
1097 name="AirPlay",
1098 protocol_domain="airplay",
1099 priority=10, # Higher priority (lower number)
1100 available=True,
1101 ),
1102 ]
1103 )
1104 wiim_player.set_linked_output_protocols(
1105 [
1106 OutputProtocol(
1107 output_protocol_id="dlna_wiim",
1108 name="DLNA",
1109 protocol_domain="dlna",
1110 priority=30,
1111 available=True,
1112 ),
1113 OutputProtocol(
1114 output_protocol_id="airplay_wiim",
1115 name="AirPlay",
1116 protocol_domain="airplay",
1117 priority=10,
1118 available=True,
1119 ),
1120 ]
1121 )
1122
1123 controller._players = {
1124 "sonos_123": sonos_player,
1125 "wiim_456": wiim_player,
1126 "dlna_sonos": sonos_dlna,
1127 "dlna_wiim": wiim_dlna,
1128 "airplay_sonos": sonos_airplay,
1129 "airplay_wiim": wiim_airplay,
1130 }
1131 controller._player_throttlers = {
1132 "sonos_123": Throttler(1, 0.05),
1133 "wiim_456": Throttler(1, 0.05),
1134 "dlna_sonos": Throttler(1, 0.05),
1135 "dlna_wiim": Throttler(1, 0.05),
1136 "airplay_sonos": Throttler(1, 0.05),
1137 "airplay_wiim": Throttler(1, 0.05),
1138 }
1139
1140 # Update state after modifying attributes
1141 sonos_airplay.update_state(signal_event=False)
1142 wiim_airplay.update_state(signal_event=False)
1143
1144 # Translate members - should skip DLNA (no SET_MEMBERS) and select AirPlay
1145 protocol_members, _native_members, protocol_player, protocol_domain = (
1146 controller._translate_members_for_protocols(
1147 parent_player=sonos_player,
1148 player_ids=["wiim_456"],
1149 parent_protocol_player=None,
1150 parent_protocol_domain=None,
1151 )
1152 )
1153
1154 # Should select AirPlay (supports SET_MEMBERS) not DLNA
1155 assert len(protocol_members) == 1
1156 assert "airplay_wiim" in protocol_members
1157 assert protocol_domain == "airplay"
1158 assert protocol_player == sonos_airplay
1159
1160
1161class TestCanGroupWith:
1162 """Tests for can_group_with property with three scenarios."""
1163
1164 def test_scenario_1_native_active_only_native_players(self, mock_mass: MagicMock) -> None:
1165 """Test Scenario 1: Native playback active -> all protocols shown (new behavior)."""
1166 controller = PlayerController(mock_mass)
1167
1168 sonos_provider = MockProvider("sonos", instance_id="sonos_instance", mass=mock_mass)
1169 airplay_provider = MockProvider("airplay", instance_id="airplay_instance", mass=mock_mass)
1170
1171 # Create Sonos player with native and AirPlay support
1172 sonos_player = MockPlayer(
1173 sonos_provider,
1174 "sonos_123",
1175 "Living Room",
1176 identifiers={IdentifierType.MAC_ADDRESS: "AA:BB:CC:DD:EE:01"},
1177 )
1178 sonos_player._attr_supported_features.add(PlayerFeature.PLAY_MEDIA)
1179 sonos_player._attr_supported_features.add(PlayerFeature.SET_MEMBERS)
1180 sonos_player._attr_can_group_with = {"sonos_456"}
1181 sonos_player._cache.clear()
1182 sonos_player.set_active_output_protocol("native")
1183
1184 # Create another Sonos player
1185 sonos_player_b = MockPlayer(
1186 sonos_provider,
1187 "sonos_456",
1188 "Kitchen",
1189 identifiers={IdentifierType.MAC_ADDRESS: "AA:BB:CC:DD:EE:02"},
1190 )
1191
1192 # Create AirPlay protocol player
1193 sonos_airplay = MockPlayer(
1194 airplay_provider,
1195 "airplay_sonos",
1196 "Living Room (AirPlay)",
1197 player_type=PlayerType.PROTOCOL,
1198 identifiers={IdentifierType.MAC_ADDRESS: "AA:BB:CC:DD:EE:01"},
1199 )
1200 sonos_airplay._attr_supported_features.add(PlayerFeature.SET_MEMBERS)
1201 sonos_airplay._attr_can_group_with = {"airplay_other"}
1202 sonos_airplay._cache.clear()
1203 sonos_airplay.set_protocol_parent_id("sonos_123")
1204
1205 sonos_player.set_linked_output_protocols(
1206 [
1207 OutputProtocol(
1208 output_protocol_id="airplay_sonos",
1209 name="AirPlay",
1210 protocol_domain="airplay",
1211 priority=10,
1212 available=True,
1213 )
1214 ]
1215 )
1216
1217 # Wire up mock_mass.players to controller so get_linked_protocol works
1218 mock_mass.players = controller
1219
1220 controller._players = {
1221 "sonos_123": sonos_player,
1222 "sonos_456": sonos_player_b,
1223 "airplay_sonos": sonos_airplay,
1224 }
1225 controller._player_throttlers = {
1226 "sonos_123": Throttler(1, 0.05),
1227 "sonos_456": Throttler(1, 0.05),
1228 "airplay_sonos": Throttler(1, 0.05),
1229 }
1230
1231 # Update state after modifying attributes and registering with controller
1232 sonos_player.update_state(signal_event=False)
1233 sonos_player_b.update_state(signal_event=False)
1234 sonos_airplay.update_state(signal_event=False)
1235
1236 # Get can_group_with while native is active
1237 groupable = sonos_player.state.can_group_with
1238
1239 # NEW BEHAVIOR: Should show both native AND protocol players
1240 # even when native protocol is active
1241 assert "sonos_456" in groupable # Native Sonos player
1242 # Note: airplay_other is not registered in controller._players, so it won't appear
1243 # But the logic should still allow showing AirPlay options if they were registered
1244
1245 def test_scenario_2_protocol_active_hybrid_groups(self, mock_mass: MagicMock) -> None:
1246 """Test Scenario 2: Protocol active -> show all protocols (new behavior)."""
1247 controller = PlayerController(mock_mass)
1248
1249 sonos_provider = MockProvider("sonos", instance_id="sonos_instance", mass=mock_mass)
1250 airplay_provider = MockProvider("airplay", instance_id="airplay_instance", mass=mock_mass)
1251
1252 # Create Sonos player with AirPlay active
1253 sonos_player = MockPlayer(
1254 sonos_provider,
1255 "sonos_123",
1256 "Living Room",
1257 identifiers={IdentifierType.MAC_ADDRESS: "AA:BB:CC:DD:EE:01"},
1258 )
1259 sonos_player._attr_supported_features.add(PlayerFeature.PLAY_MEDIA)
1260 sonos_player._attr_supported_features.add(PlayerFeature.SET_MEMBERS)
1261 sonos_player._attr_can_group_with = {"sonos_456"}
1262 sonos_player._cache.clear()
1263
1264 # Create another Sonos player
1265 sonos_player_b = MockPlayer(
1266 sonos_provider,
1267 "sonos_456",
1268 "Kitchen",
1269 identifiers={IdentifierType.MAC_ADDRESS: "AA:BB:CC:DD:EE:02"},
1270 )
1271
1272 # Create AirPlay protocol player
1273 sonos_airplay = MockPlayer(
1274 airplay_provider,
1275 "airplay_sonos",
1276 "Living Room (AirPlay)",
1277 player_type=PlayerType.PROTOCOL,
1278 identifiers={IdentifierType.MAC_ADDRESS: "AA:BB:CC:DD:EE:01"},
1279 )
1280 sonos_airplay._attr_supported_features.add(PlayerFeature.SET_MEMBERS)
1281 sonos_airplay._attr_can_group_with = {"airplay_other"}
1282 sonos_airplay._cache.clear()
1283 sonos_airplay.set_protocol_parent_id("sonos_123")
1284
1285 # Create another device with AirPlay
1286 wiim_player = MockPlayer(
1287 sonos_provider,
1288 "wiim_789",
1289 "Bedroom",
1290 identifiers={IdentifierType.MAC_ADDRESS: "AA:BB:CC:DD:EE:03"},
1291 )
1292
1293 airplay_other = MockPlayer(
1294 airplay_provider,
1295 "airplay_other",
1296 "Bedroom (AirPlay)",
1297 player_type=PlayerType.PROTOCOL,
1298 identifiers={IdentifierType.MAC_ADDRESS: "AA:BB:CC:DD:EE:03"},
1299 )
1300 airplay_other._attr_supported_features.add(PlayerFeature.SET_MEMBERS)
1301 airplay_other._attr_can_group_with = {"airplay_sonos"}
1302 airplay_other._cache.clear()
1303 airplay_other.set_protocol_parent_id("wiim_789")
1304
1305 wiim_player.set_linked_output_protocols(
1306 [
1307 OutputProtocol(
1308 output_protocol_id="airplay_other",
1309 name="AirPlay",
1310 protocol_domain="airplay",
1311 priority=10,
1312 available=True,
1313 ),
1314 ]
1315 )
1316
1317 sonos_player.set_linked_output_protocols(
1318 [
1319 OutputProtocol(
1320 output_protocol_id="airplay_sonos",
1321 name="AirPlay",
1322 protocol_domain="airplay",
1323 priority=10,
1324 available=True,
1325 )
1326 ]
1327 )
1328 sonos_player.set_active_output_protocol("airplay_sonos")
1329
1330 # Wire up mock_mass.players to controller so get_linked_protocol works
1331 mock_mass.players = controller
1332
1333 controller._players = {
1334 "sonos_123": sonos_player,
1335 "sonos_456": sonos_player_b,
1336 "wiim_789": wiim_player,
1337 "airplay_sonos": sonos_airplay,
1338 "airplay_other": airplay_other,
1339 }
1340 controller._player_throttlers = {
1341 "sonos_123": Throttler(1, 0.05),
1342 "sonos_456": Throttler(1, 0.05),
1343 "wiim_789": Throttler(1, 0.05),
1344 "airplay_sonos": Throttler(1, 0.05),
1345 "airplay_other": Throttler(1, 0.05),
1346 }
1347
1348 # Clear cache after setting linked protocols
1349 sonos_player._cache.clear()
1350 wiim_player._cache.clear()
1351
1352 # Update state after modifying attributes and registering with controller
1353 # IMPORTANT: Update protocol players FIRST, then parent players
1354 sonos_airplay.update_state(signal_event=False)
1355 airplay_other.update_state(signal_event=False)
1356 sonos_player.update_state(signal_event=False)
1357 sonos_player_b.update_state(signal_event=False)
1358 wiim_player.update_state(signal_event=False)
1359
1360 # Get can_group_with while AirPlay is active
1361 groupable = sonos_player.state.can_group_with
1362
1363 # NEW BEHAVIOR: Should show ALL protocols + native players
1364 # regardless of which protocol is active
1365 assert "sonos_456" in groupable # Native Sonos player
1366 assert "wiim_789" in groupable # Via airplay_other protocol
1367
1368 def test_scenario_3_no_active_output_all_protocols_shown(self, mock_mass: MagicMock) -> None:
1369 """Test Scenario 3: No active output -> show all compatible protocols + native."""
1370 controller = PlayerController(mock_mass)
1371
1372 sonos_provider = MockProvider("sonos", instance_id="sonos_instance", mass=mock_mass)
1373 airplay_provider = MockProvider("airplay", instance_id="airplay_instance", mass=mock_mass)
1374 dlna_provider = MockProvider("dlna", instance_id="dlna_instance", mass=mock_mass)
1375
1376 # Create Sonos player (no active protocol)
1377 sonos_player = MockPlayer(
1378 sonos_provider,
1379 "sonos_123",
1380 "Living Room",
1381 identifiers={IdentifierType.MAC_ADDRESS: "AA:BB:CC:DD:EE:01"},
1382 )
1383 sonos_player._attr_supported_features.add(PlayerFeature.PLAY_MEDIA)
1384 sonos_player._attr_supported_features.add(PlayerFeature.SET_MEMBERS)
1385 sonos_player._attr_can_group_with = {"sonos_456"}
1386 sonos_player._cache.clear()
1387 # No active output protocol set
1388
1389 # Create another Sonos player
1390 sonos_player_b = MockPlayer(
1391 sonos_provider,
1392 "sonos_456",
1393 "Kitchen",
1394 identifiers={IdentifierType.MAC_ADDRESS: "AA:BB:CC:DD:EE:02"},
1395 )
1396
1397 # Create AirPlay protocol player (supports SET_MEMBERS)
1398 sonos_airplay = MockPlayer(
1399 airplay_provider,
1400 "airplay_sonos",
1401 "Living Room (AirPlay)",
1402 player_type=PlayerType.PROTOCOL,
1403 identifiers={IdentifierType.MAC_ADDRESS: "AA:BB:CC:DD:EE:01"},
1404 )
1405 sonos_airplay._attr_supported_features.add(PlayerFeature.SET_MEMBERS)
1406 sonos_airplay._attr_can_group_with = {"airplay_other"}
1407 sonos_airplay._cache.clear()
1408 sonos_airplay.set_protocol_parent_id("sonos_123")
1409
1410 # Create DLNA protocol player (does NOT support SET_MEMBERS)
1411 sonos_dlna = MockPlayer(
1412 dlna_provider,
1413 "dlna_sonos",
1414 "Living Room (DLNA)",
1415 player_type=PlayerType.PROTOCOL,
1416 identifiers={IdentifierType.MAC_ADDRESS: "AA:BB:CC:DD:EE:01"},
1417 )
1418 # No SET_MEMBERS support
1419 sonos_dlna._attr_can_group_with = {"dlna_other"}
1420 sonos_dlna.set_protocol_parent_id("sonos_123")
1421
1422 # Another device
1423 wiim_player = MockPlayer(
1424 sonos_provider,
1425 "wiim_789",
1426 "Bedroom",
1427 identifiers={IdentifierType.MAC_ADDRESS: "AA:BB:CC:DD:EE:03"},
1428 )
1429
1430 airplay_other = MockPlayer(
1431 airplay_provider,
1432 "airplay_other",
1433 "Bedroom (AirPlay)",
1434 player_type=PlayerType.PROTOCOL,
1435 identifiers={IdentifierType.MAC_ADDRESS: "AA:BB:CC:DD:EE:03"},
1436 )
1437 airplay_other._attr_supported_features.add(PlayerFeature.SET_MEMBERS)
1438 airplay_other._attr_can_group_with = {"airplay_sonos"}
1439 airplay_other._cache.clear()
1440 airplay_other.set_protocol_parent_id("wiim_789")
1441
1442 sonos_player.set_linked_output_protocols(
1443 [
1444 OutputProtocol(
1445 output_protocol_id="airplay_sonos",
1446 name="AirPlay",
1447 protocol_domain="airplay",
1448 priority=10,
1449 available=True,
1450 ),
1451 OutputProtocol(
1452 output_protocol_id="dlna_sonos",
1453 name="DLNA",
1454 protocol_domain="dlna",
1455 priority=30,
1456 available=True,
1457 ),
1458 ]
1459 )
1460
1461 wiim_player.set_linked_output_protocols(
1462 [
1463 OutputProtocol(
1464 output_protocol_id="airplay_other",
1465 name="AirPlay",
1466 protocol_domain="airplay",
1467 priority=10,
1468 available=True,
1469 ),
1470 ]
1471 )
1472
1473 # Clear cache after setting linked protocols (output_protocols is cached)
1474 sonos_player._cache.clear()
1475 wiim_player._cache.clear()
1476
1477 # Wire up mock_mass.players to controller so get_linked_protocol works
1478 mock_mass.players = controller
1479
1480 controller._players = {
1481 "sonos_123": sonos_player,
1482 "sonos_456": sonos_player_b,
1483 "wiim_789": wiim_player,
1484 "airplay_sonos": sonos_airplay,
1485 "airplay_other": airplay_other,
1486 "dlna_sonos": sonos_dlna,
1487 }
1488 controller._player_throttlers = {
1489 "sonos_123": Throttler(1, 0.05),
1490 "sonos_456": Throttler(1, 0.05),
1491 "wiim_789": Throttler(1, 0.05),
1492 "airplay_sonos": Throttler(1, 0.05),
1493 "airplay_other": Throttler(1, 0.05),
1494 "dlna_sonos": Throttler(1, 0.05),
1495 }
1496
1497 # Update state after modifying attributes and registering with controller
1498 # Note: set_linked_output_protocols calls trigger_player_update, but since mass.players
1499 # is a MagicMock, we need to manually call update_state
1500 # IMPORTANT: Update protocol players FIRST, then parent players, because parent players
1501 # access protocol_player.state.can_group_with during their update_state()
1502 sonos_airplay.update_state(signal_event=False)
1503 airplay_other.update_state(signal_event=False)
1504 sonos_dlna.update_state(signal_event=False)
1505 sonos_player.update_state(signal_event=False)
1506 sonos_player_b.update_state(signal_event=False)
1507 wiim_player.update_state(signal_event=False)
1508
1509 # Get can_group_with with no active protocol
1510 groupable = sonos_player.state.can_group_with
1511
1512 # Should show native players + AirPlay players (supports SET_MEMBERS)
1513 # but NOT DLNA players (no SET_MEMBERS support)
1514 assert "sonos_456" in groupable
1515 assert "wiim_789" in groupable # Via AirPlay protocol
1516 # DLNA players should not be shown since DLNA doesn't support SET_MEMBERS
1517
1518
1519class TestProtocolSwitchingDuringPlayback:
1520 """Tests for dynamic protocol switching when group members change during playback."""
1521
1522 async def test_no_protocol_set_during_grouping_without_playback(
1523 self, mock_mass: MagicMock
1524 ) -> None:
1525 """Test that no protocol is set when grouping players without active playback."""
1526 controller = PlayerController(mock_mass)
1527
1528 sonos_provider = MockProvider("sonos", instance_id="sonos_instance", mass=mock_mass)
1529 airplay_provider = MockProvider("airplay", instance_id="airplay_instance", mass=mock_mass)
1530
1531 # Create Sonos player with AirPlay support
1532 sonos_player = MockPlayer(
1533 sonos_provider,
1534 "sonos_123",
1535 "Living Room",
1536 identifiers={IdentifierType.MAC_ADDRESS: "AA:BB:CC:DD:EE:01"},
1537 )
1538 sonos_player._attr_supported_features.add(PlayerFeature.PLAY_MEDIA)
1539 sonos_player._attr_supported_features.add(PlayerFeature.SET_MEMBERS)
1540 sonos_player._attr_can_group_with = {"sonos_456"}
1541
1542 # Create another Sonos player
1543 sonos_player_b = MockPlayer(
1544 sonos_provider,
1545 "sonos_456",
1546 "Kitchen",
1547 identifiers={IdentifierType.MAC_ADDRESS: "AA:BB:CC:DD:EE:02"},
1548 )
1549 sonos_player_b._attr_supported_features.add(PlayerFeature.SET_MEMBERS)
1550
1551 # Create AirPlay protocol player
1552 sonos_airplay = MockPlayer(
1553 airplay_provider,
1554 "airplay_sonos",
1555 "Living Room (AirPlay)",
1556 player_type=PlayerType.PROTOCOL,
1557 identifiers={IdentifierType.MAC_ADDRESS: "AA:BB:CC:DD:EE:01"},
1558 )
1559 sonos_airplay._attr_supported_features.add(PlayerFeature.SET_MEMBERS)
1560 sonos_airplay.set_protocol_parent_id("sonos_123")
1561
1562 sonos_player.set_linked_output_protocols(
1563 [
1564 OutputProtocol(
1565 output_protocol_id="airplay_sonos",
1566 name="AirPlay",
1567 protocol_domain="airplay",
1568 priority=10,
1569 available=True,
1570 )
1571 ]
1572 )
1573
1574 mock_mass.players = controller
1575 controller._players = {
1576 "sonos_123": sonos_player,
1577 "sonos_456": sonos_player_b,
1578 "airplay_sonos": sonos_airplay,
1579 }
1580 controller._player_throttlers = {
1581 "sonos_123": Throttler(1, 0.05),
1582 "sonos_456": Throttler(1, 0.05),
1583 "airplay_sonos": Throttler(1, 0.05),
1584 }
1585
1586 # Group players via protocol (simulate grouping through AirPlay)
1587 # This should NOT set active_output_protocol anymore
1588 await controller._forward_protocol_set_members(
1589 parent_player=sonos_player,
1590 parent_protocol_player=sonos_airplay,
1591 protocol_members_to_add=["airplay_other"], # Add a protocol member
1592 protocol_members_to_remove=[],
1593 )
1594
1595 # NEW BEHAVIOR: Protocol should NOT be set during grouping without playback
1596 # After grouping, protocol should not be activated until playback starts
1597 assert sonos_player.active_output_protocol is None
1598
1599 async def test_protocol_selected_at_playback_time(self, mock_mass: MagicMock) -> None:
1600 """Test that protocol is selected when playback starts, not during grouping."""
1601 controller = PlayerController(mock_mass)
1602
1603 sonos_provider = MockProvider("sonos", instance_id="sonos_instance", mass=mock_mass)
1604 airplay_provider = MockProvider("airplay", instance_id="airplay_instance", mass=mock_mass)
1605
1606 # Create Sonos player with AirPlay support
1607 sonos_player = MockPlayer(
1608 sonos_provider,
1609 "sonos_123",
1610 "Living Room",
1611 identifiers={IdentifierType.MAC_ADDRESS: "AA:BB:CC:DD:EE:01"},
1612 )
1613 sonos_player._attr_supported_features.add(PlayerFeature.PLAY_MEDIA)
1614 sonos_player._attr_supported_features.add(PlayerFeature.SET_MEMBERS)
1615
1616 # Create AirPlay protocol player with group members
1617 sonos_airplay = MockPlayer(
1618 airplay_provider,
1619 "airplay_sonos",
1620 "Living Room (AirPlay)",
1621 player_type=PlayerType.PROTOCOL,
1622 identifiers={IdentifierType.MAC_ADDRESS: "AA:BB:CC:DD:EE:01"},
1623 )
1624 sonos_airplay._attr_supported_features.add(PlayerFeature.SET_MEMBERS)
1625 sonos_airplay._attr_supported_features.add(PlayerFeature.PLAY_MEDIA)
1626 sonos_airplay.set_protocol_parent_id("sonos_123")
1627 # Simulate that AirPlay protocol has group members (needs >1 for grouping check)
1628 sonos_airplay._attr_group_members = ["airplay_sonos", "airplay_other"]
1629
1630 sonos_player.set_linked_output_protocols(
1631 [
1632 OutputProtocol(
1633 output_protocol_id="airplay_sonos",
1634 name="AirPlay",
1635 protocol_domain="airplay",
1636 priority=10,
1637 available=True,
1638 )
1639 ]
1640 )
1641
1642 mock_mass.players = controller
1643 controller._players = {
1644 "sonos_123": sonos_player,
1645 "airplay_sonos": sonos_airplay,
1646 }
1647
1648 # Update state to apply group members to state
1649 sonos_airplay.update_state(signal_event=False)
1650 sonos_player.update_state(signal_event=False)
1651
1652 # Protocol should not be set yet
1653 assert sonos_player.active_output_protocol is None
1654
1655 # Select protocol for playback
1656 selected_player, output_protocol = controller._select_best_output_protocol(sonos_player)
1657
1658 # Should select AirPlay protocol because it has group members (Priority 1)
1659 assert selected_player == sonos_airplay
1660 assert output_protocol is not None
1661 assert output_protocol.output_protocol_id == "airplay_sonos"
1662