/
/
/
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_mac_address_locally_administered_bit_match(self, mock_mass: MagicMock) -> None:
163 """Test that MAC addresses differing only in locally-administered bit match.
164
165 Some protocols (like AirPlay) report a MAC with the locally-administered
166 bit set (bit 1 of first octet), while other protocols report the real
167 hardware MAC. These should match as the same device.
168
169 Example: 54:78:C9:E6:0D:A0 (hardware) vs 56:78:C9:E6:0D:A0 (AirPlay)
170 """
171 controller = PlayerController(mock_mass)
172
173 provider = MockProvider("test")
174 # Real hardware MAC (first byte 0x54 = 01010100, bit 1 = 0)
175 player_a = MockPlayer(
176 provider,
177 "player_a",
178 "WiiM Pro (DLNA)",
179 identifiers={IdentifierType.MAC_ADDRESS: "54:78:C9:E6:0D:A0"},
180 )
181 # AirPlay MAC with locally-administered bit set (first byte 0x56 = 01010110, bit 1 = 1)
182 player_b = MockPlayer(
183 provider,
184 "player_b",
185 "WiiM Pro (AirPlay)",
186 identifiers={IdentifierType.MAC_ADDRESS: "56:78:C9:E6:0D:A0"},
187 )
188
189 # These should match because they differ only in the locally-administered bit
190 assert controller._identifiers_match(player_a, player_b) is True
191
192 def test_mac_address_locally_administered_bit_different_devices_no_match(
193 self, mock_mass: MagicMock
194 ) -> None:
195 """Test that different devices with locally-administered MACs don't match.
196
197 Only the locally-administered bit should be ignored, not other differences.
198 """
199 controller = PlayerController(mock_mass)
200
201 provider = MockProvider("test")
202 player_a = MockPlayer(
203 provider,
204 "player_a",
205 "Device A",
206 identifiers={IdentifierType.MAC_ADDRESS: "54:78:C9:E6:0D:A0"},
207 )
208 player_b = MockPlayer(
209 provider,
210 "player_b",
211 "Device B",
212 identifiers={IdentifierType.MAC_ADDRESS: "56:78:C9:E6:0D:A1"}, # Different last byte
213 )
214
215 # These should NOT match - they differ in more than just the locally-administered bit
216 assert controller._identifiers_match(player_a, player_b) is False
217
218 def test_ip_address_no_match(self, mock_mass: MagicMock) -> None:
219 """Test that IP addresses don't match (IP is excluded as it's not stable)."""
220 controller = PlayerController(mock_mass)
221
222 provider = MockProvider("test")
223 player_a = MockPlayer(
224 provider,
225 "player_a",
226 "Player A",
227 identifiers={IdentifierType.IP_ADDRESS: "192.168.1.100"},
228 )
229 player_b = MockPlayer(
230 provider,
231 "player_b",
232 "Player B",
233 identifiers={IdentifierType.IP_ADDRESS: "192.168.1.100"},
234 )
235
236 # IP address matching is intentionally disabled to prevent false matches
237 assert controller._identifiers_match(player_a, player_b) is False
238
239 def test_sonos_uuid_dlna_suffix_match(self, mock_mass: MagicMock) -> None:
240 """Test Sonos UUID matching with DLNA _MR suffix."""
241 controller = PlayerController(mock_mass)
242
243 provider = MockProvider("test")
244 # Sonos native player
245 player_a = MockPlayer(
246 provider,
247 "player_a",
248 "Sonos Player",
249 identifiers={IdentifierType.UUID: "RINCON_000E58123456"},
250 )
251 # DLNA player with _MR suffix
252 player_b = MockPlayer(
253 provider,
254 "player_b",
255 "DLNA Player",
256 identifiers={IdentifierType.UUID: "RINCON_000E58123456_MR"},
257 )
258
259 assert controller._identifiers_match(player_a, player_b) is True
260
261 def test_no_identifiers_no_match(self, mock_mass: MagicMock) -> None:
262 """Test that players without identifiers don't match."""
263 controller = PlayerController(mock_mass)
264
265 provider = MockProvider("test")
266 player_a = MockPlayer(provider, "player_a", "Player A")
267 player_b = MockPlayer(provider, "player_b", "Player B")
268
269 assert controller._identifiers_match(player_a, player_b) is False
270
271
272class TestProtocolPlayerDetection:
273 """Tests for protocol player type detection."""
274
275 def test_is_protocol_player_true(self, mock_mass: MagicMock) -> None:
276 """Test that PlayerType.PROTOCOL is correctly detected."""
277 controller = PlayerController(mock_mass)
278
279 provider = MockProvider("airplay")
280 player = MockPlayer(
281 provider,
282 "ap_123456",
283 "Samsung TV (AirPlay)",
284 player_type=PlayerType.PROTOCOL,
285 )
286
287 assert controller._is_protocol_player(player) is True
288
289 def test_is_protocol_player_false(self, mock_mass: MagicMock) -> None:
290 """Test that PlayerType.PLAYER is not detected as protocol."""
291 controller = PlayerController(mock_mass)
292
293 provider = MockProvider("airplay")
294 player = MockPlayer(
295 provider,
296 "ap_123456",
297 "HomePod",
298 player_type=PlayerType.PLAYER, # Apple device with native support
299 )
300
301 assert controller._is_protocol_player(player) is False
302
303
304class TestFindMatchingProtocolPlayers:
305 """Tests for finding matching protocol players."""
306
307 def test_find_matching_by_mac(self, mock_mass: MagicMock) -> None:
308 """Test finding matching protocol players by MAC address."""
309 controller = PlayerController(mock_mass)
310
311 # Set up providers
312 airplay_provider = MockProvider("airplay")
313 chromecast_provider = MockProvider("chromecast")
314
315 # Create matching protocol players (same device, different protocols)
316 airplay_player = MockPlayer(
317 airplay_provider,
318 "ap_aabbccddee",
319 "Samsung TV (AirPlay)",
320 player_type=PlayerType.PROTOCOL,
321 identifiers={IdentifierType.MAC_ADDRESS: "AA:BB:CC:DD:EE:FF"},
322 )
323 chromecast_player = MockPlayer(
324 chromecast_provider,
325 "cc_aabbccddee",
326 "Samsung TV (Chromecast)",
327 player_type=PlayerType.PROTOCOL,
328 identifiers={IdentifierType.MAC_ADDRESS: "AA:BB:CC:DD:EE:FF"},
329 )
330
331 # Register players
332 controller._players = {
333 "ap_aabbccddee": airplay_player,
334 "cc_aabbccddee": chromecast_player,
335 }
336 controller._player_throttlers = {
337 "ap_aabbccddee": Throttler(1, 0.05),
338 "cc_aabbccddee": Throttler(1, 0.05),
339 }
340
341 # Find matching players for AirPlay player
342 matches = controller._find_matching_protocol_players(airplay_player)
343
344 assert len(matches) == 2
345 assert airplay_player in matches
346 assert chromecast_player in matches
347
348 def test_same_protocol_not_matched(self, mock_mass: MagicMock) -> None:
349 """Test that multiple players of same protocol on same host are NOT matched together."""
350 controller = PlayerController(mock_mass)
351
352 # Set up provider
353 snapcast_provider = MockProvider("snapcast")
354
355 # Create multiple Snapcast players on same host (same MAC/IP)
356 # This simulates multiple Snapcast clients running on the same server
357 snapcast_player_1 = MockPlayer(
358 snapcast_provider,
359 "snapcast_client_1",
360 "Snapcast Client 1",
361 player_type=PlayerType.PROTOCOL,
362 identifiers={
363 IdentifierType.MAC_ADDRESS: "AA:BB:CC:DD:EE:FF",
364 IdentifierType.IP_ADDRESS: "192.168.1.100",
365 },
366 )
367 snapcast_player_2 = MockPlayer(
368 snapcast_provider,
369 "snapcast_client_2",
370 "Snapcast Client 2",
371 player_type=PlayerType.PROTOCOL,
372 identifiers={
373 IdentifierType.MAC_ADDRESS: "AA:BB:CC:DD:EE:FF",
374 IdentifierType.IP_ADDRESS: "192.168.1.100",
375 },
376 )
377
378 # Register players
379 controller._players = {
380 "snapcast_client_1": snapcast_player_1,
381 "snapcast_client_2": snapcast_player_2,
382 }
383 controller._player_throttlers = {
384 "snapcast_client_1": Throttler(1, 0.05),
385 "snapcast_client_2": Throttler(1, 0.05),
386 }
387
388 # Find matching players for first Snapcast player
389 matches = controller._find_matching_protocol_players(snapcast_player_1)
390
391 # Should only match itself, NOT the other Snapcast player (same protocol domain)
392 assert len(matches) == 1
393 assert snapcast_player_1 in matches
394 assert snapcast_player_2 not in matches
395
396
397class TestGetDeviceKeyFromPlayers:
398 """Tests for device key generation."""
399
400 def test_device_key_from_mac(self, mock_mass: MagicMock) -> None:
401 """Test device key generation from MAC address.
402
403 Note: Device keys are normalized to clear the locally-administered bit
404 (bit 1 of first octet) to ensure consistent keys across protocols.
405 """
406 universal_provider = create_mock_universal_provider(mock_mass)
407
408 provider = MockProvider("airplay")
409 # Use a MAC without locally-administered bit set for cleaner test
410 # 00:BB:CC:DD:EE:FF has first byte 0x00, bit 1 = 0
411 player = MockPlayer(
412 provider,
413 "ap_123456",
414 "Test Player",
415 identifiers={IdentifierType.MAC_ADDRESS: "00:BB:CC:DD:EE:FF"},
416 )
417
418 device_key = universal_provider._get_device_key_from_players([player])
419
420 assert device_key == "00bbccddeeff"
421
422 def test_device_key_normalizes_locally_administered_mac(self, mock_mass: MagicMock) -> None:
423 """Test that device key normalizes locally-administered MACs.
424
425 A device with hardware MAC 54:78:C9:E6:0D:A0 and AirPlay MAC 56:78:C9:E6:0D:A0
426 should generate the same device key, allowing them to be merged into
427 the same universal player.
428 """
429 universal_provider = create_mock_universal_provider(mock_mass)
430
431 provider_dlna = MockProvider("dlna")
432 provider_airplay = MockProvider("airplay")
433
434 # DLNA player with real hardware MAC
435 player_dlna = MockPlayer(
436 provider_dlna,
437 "dlna_123456",
438 "WiiM Pro (DLNA)",
439 identifiers={IdentifierType.MAC_ADDRESS: "54:78:C9:E6:0D:A0"},
440 )
441
442 # AirPlay player with locally-administered MAC (bit 1 set)
443 player_airplay = MockPlayer(
444 provider_airplay,
445 "ap_123456",
446 "WiiM Pro (AirPlay)",
447 identifiers={IdentifierType.MAC_ADDRESS: "56:78:C9:E6:0D:A0"},
448 )
449
450 # Both should generate the same device key
451 key_dlna = universal_provider._get_device_key_from_players([player_dlna])
452 key_airplay = universal_provider._get_device_key_from_players([player_airplay])
453
454 # Keys should be identical (both normalized to clear locally-administered bit)
455 assert key_dlna == key_airplay
456 # The normalized MAC should have bit 1 cleared (0x54 not 0x56)
457 assert key_dlna == "5478c9e60da0"
458
459 def test_device_key_from_uuid_fallback(self, mock_mass: MagicMock) -> None:
460 """Test device key generation falls back to UUID when no MAC available."""
461 universal_provider = create_mock_universal_provider(mock_mass)
462
463 provider = MockProvider("dlna")
464 player = MockPlayer(
465 provider,
466 "dlna_123456",
467 "Test Player",
468 identifiers={IdentifierType.UUID: "uuid:12345678-1234-1234-1234-123456789abc"},
469 )
470
471 device_key = universal_provider._get_device_key_from_players([player])
472
473 assert device_key == "uuid12345678123412341234123456789abc"
474
475 def test_device_key_from_ip_falls_back_to_player_id(self, mock_mass: MagicMock) -> None:
476 """Test that device key falls back to player_id for IP-only players (IP not used)."""
477 universal_provider = create_mock_universal_provider(mock_mass)
478
479 provider = MockProvider("airplay")
480 player = MockPlayer(
481 provider,
482 "ap_123456",
483 "Test Player",
484 identifiers={IdentifierType.IP_ADDRESS: "192.168.1.100"},
485 )
486
487 device_key = universal_provider._get_device_key_from_players([player])
488
489 # IP address is not used for device key - falls back to player_id
490 # This allows protocol players without MAC/UUID to still get a UniversalPlayer
491 assert device_key == "ap_123456"
492
493 def test_device_key_from_no_identifiers_falls_back_to_player_id(
494 self, mock_mass: MagicMock
495 ) -> None:
496 """Test that device key falls back to player_id when no identifiers at all."""
497 universal_provider = create_mock_universal_provider(mock_mass)
498
499 provider = MockProvider("sendspin")
500 player = MockPlayer(
501 provider,
502 "sendspin-device-abc",
503 "Test Player",
504 # No identifiers at all (like Sendspin protocol players)
505 )
506
507 device_key = universal_provider._get_device_key_from_players([player])
508
509 # Falls back to player_id when no MAC/UUID identifiers
510 assert device_key == "sendspindeviceabc"
511
512
513class TestGetCleanPlayerName:
514 """Tests for player name selection."""
515
516 def test_prefers_chromecast_name(self, mock_mass: MagicMock) -> None:
517 """Test that Chromecast names are preferred over other protocols."""
518 universal_provider = create_mock_universal_provider(mock_mass)
519
520 airplay_provider = MockProvider("airplay")
521 chromecast_provider = MockProvider("chromecast")
522
523 airplay_player = MockPlayer(
524 airplay_provider,
525 "ap_123456",
526 "Samsung TV",
527 player_type=PlayerType.PROTOCOL,
528 )
529 chromecast_player = MockPlayer(
530 chromecast_provider,
531 "cc_123456",
532 "Living Room Speaker",
533 player_type=PlayerType.PROTOCOL,
534 )
535
536 # Chromecast should be preferred (priority 1)
537 clean_name = universal_provider._get_clean_player_name([airplay_player, chromecast_player])
538 assert clean_name == "Living Room Speaker"
539
540 def test_filters_mac_address_names(self, mock_mass: MagicMock) -> None:
541 """Test that MAC address-like names are filtered out."""
542 universal_provider = create_mock_universal_provider(mock_mass)
543
544 squeezelite_provider = MockProvider("squeezelite")
545 airplay_provider = MockProvider("airplay")
546
547 # Squeezelite with MAC address as name
548 sq_player = MockPlayer(
549 squeezelite_provider,
550 "sq_123456",
551 "AA:BB:CC:DD:EE:FF",
552 player_type=PlayerType.PROTOCOL,
553 )
554 # AirPlay with proper name
555 ap_player = MockPlayer(
556 airplay_provider,
557 "ap_123456",
558 "Kitchen Speaker",
559 player_type=PlayerType.PROTOCOL,
560 )
561
562 # Should prefer Kitchen Speaker over MAC address
563 clean_name = universal_provider._get_clean_player_name([sq_player, ap_player])
564 assert clean_name == "Kitchen Speaker"
565
566 def test_filters_player_id_names(self, mock_mass: MagicMock) -> None:
567 """Test that player ID-like names are filtered out."""
568 universal_provider = create_mock_universal_provider(mock_mass)
569
570 sendspin_provider = MockProvider("sendspin")
571 dlna_provider = MockProvider("dlna")
572
573 # SendSpin with player ID as name
574 ss_player = MockPlayer(
575 sendspin_provider,
576 "sendspin_123456",
577 "sendspin_device_abc",
578 player_type=PlayerType.PROTOCOL,
579 )
580 # DLNA with proper name
581 dlna_player = MockPlayer(
582 dlna_provider,
583 "dlna_123456",
584 "Bedroom TV",
585 player_type=PlayerType.PROTOCOL,
586 )
587
588 # Should prefer Bedroom TV over player ID
589 clean_name = universal_provider._get_clean_player_name([ss_player, dlna_player])
590 assert clean_name == "Bedroom TV"
591
592 def test_valid_name_unchanged(self, mock_mass: MagicMock) -> None:
593 """Test that valid names are returned unchanged."""
594 universal_provider = create_mock_universal_provider(mock_mass)
595
596 provider = MockProvider("airplay")
597 player = MockPlayer(
598 provider,
599 "ap_123456",
600 "HomePod Mini",
601 player_type=PlayerType.PLAYER,
602 )
603
604 clean_name = universal_provider._get_clean_player_name([player])
605 assert clean_name == "HomePod Mini"
606
607
608class TestCachedProtocolParentRestore:
609 """Tests for restoring cached protocol parent links."""
610
611 def test_protocol_parent_id_restored_from_config(self, mock_mass: MagicMock) -> None:
612 """Test that cached protocol_parent_id is loaded and used for immediate linking."""
613 controller = PlayerController(mock_mass)
614
615 # Mock config to return cached parent_id when queried
616 def mock_config_get(key: str, default: str | None = None) -> str | None:
617 if "protocol_parent_id" in str(key):
618 return "native_player_id"
619 return default
620
621 mock_mass.config.get.side_effect = mock_config_get
622
623 # Create native player
624 native_provider = MockProvider("sonos", mass=mock_mass)
625 native_player = MockPlayer(
626 native_provider,
627 "native_player_id",
628 "Sonos Speaker",
629 identifiers={IdentifierType.MAC_ADDRESS: "AA:BB:CC:DD:EE:FF"},
630 )
631
632 # Create protocol player
633 dlna_provider = MockProvider("dlna", mass=mock_mass)
634 protocol_player = MockPlayer(
635 dlna_provider,
636 "uuid:RINCON_AABBCCDDEEFF_MR",
637 "Sonos DLNA",
638 player_type=PlayerType.PROTOCOL,
639 )
640
641 # Register native player
642 controller._players = {"native_player_id": native_player}
643 controller._player_throttlers = {"native_player_id": Throttler(1, 0.05)}
644
645 # Try to link protocol to native - should load cached parent_id
646 controller._try_link_protocol_to_native(protocol_player)
647
648 # Verify protocol_parent_id was set
649 assert protocol_player.protocol_parent_id == "native_player_id"
650
651 # Verify protocol was linked to native player
652 assert any(
653 link.output_protocol_id == protocol_player.player_id
654 for link in native_player.linked_output_protocols
655 )
656
657 def test_protocol_parent_id_prevents_universal_player_creation(
658 self, mock_mass: MagicMock
659 ) -> None:
660 """Test that cached protocol_parent_id prevents creating universal player."""
661 controller = PlayerController(mock_mass)
662
663 # Mock config to return cached parent_id (parent not yet registered)
664 def mock_config_get(key: str, default: str | None = None) -> str | None:
665 if "protocol_parent_id" in str(key):
666 return "native_player_id"
667 return default
668
669 mock_mass.config.get.side_effect = mock_config_get
670
671 # Create protocol player
672 dlna_provider = MockProvider("dlna", mass=mock_mass)
673 protocol_player = MockPlayer(
674 dlna_provider,
675 "uuid:RINCON_AABBCCDDEEFF_MR",
676 "Sonos DLNA",
677 player_type=PlayerType.PROTOCOL,
678 )
679
680 # No native player registered yet
681 controller._players = {}
682
683 # Try to link protocol - should set parent_id and skip evaluation
684 controller._try_link_protocol_to_native(protocol_player)
685
686 # Verify protocol_parent_id was set
687 assert protocol_player.protocol_parent_id == "native_player_id"
688
689 # Since parent_id is set, delayed evaluation won't create a universal player
690
691
692class TestSelectBestOutputProtocol:
693 """Tests for output protocol selection logic."""
694
695 def test_select_native_when_preferred_is_native(self, mock_mass: MagicMock) -> None:
696 """Test that native protocol is selected when user prefers native."""
697 # Mock config to return "native" as preferred
698 mock_mass.config.get_raw_player_config_value = MagicMock(return_value="native")
699
700 controller = PlayerController(mock_mass)
701 provider = MockProvider("sonos", mass=mock_mass)
702
703 # Create native player with PLAY_MEDIA support
704 native_player = MockPlayer(
705 provider,
706 "sonos_123",
707 "Kantoor",
708 identifiers={IdentifierType.MAC_ADDRESS: "AA:BB:CC:DD:EE:FF"},
709 )
710 native_player._attr_supported_features.add(PlayerFeature.PLAY_MEDIA)
711
712 # Wire up mock_mass.players to controller
713 mock_mass.players = controller
714
715 # Register players
716 controller._players = {"sonos_123": native_player}
717 controller._player_throttlers = {"sonos_123": Throttler(1, 0.05)}
718
719 # Select protocol
720 selected_player, output_protocol = controller._select_best_output_protocol(native_player)
721
722 # Should select native player
723 assert selected_player == native_player
724 assert output_protocol is None # None means native playback
725
726 def test_select_dlna_when_preferred_is_dlna(self, mock_mass: MagicMock) -> None:
727 """Test that DLNA protocol is selected when user prefers DLNA."""
728 # Mock config to return the full player ID as preferred
729 mock_mass.config.get_raw_player_config_value = MagicMock(return_value="dlna_AABBCCDDEEFF")
730
731 controller = PlayerController(mock_mass)
732
733 # Create native player with linked protocols
734 sonos_provider = MockProvider("sonos", mass=mock_mass)
735 native_player = MockPlayer(
736 sonos_provider,
737 "sonos_123",
738 "Kantoor",
739 identifiers={IdentifierType.MAC_ADDRESS: "AA:BB:CC:DD:EE:FF"},
740 )
741 native_player._attr_supported_features.add(PlayerFeature.PLAY_MEDIA)
742
743 # Create DLNA protocol player
744 dlna_provider = MockProvider("dlna", mass=mock_mass)
745 dlna_player = MockPlayer(
746 dlna_provider,
747 "dlna_AABBCCDDEEFF",
748 "Kantoor DLNA",
749 player_type=PlayerType.PROTOCOL,
750 identifiers={IdentifierType.MAC_ADDRESS: "AA:BB:CC:DD:EE:FF"},
751 )
752
753 # Register players
754 controller._players = {
755 "sonos_123": native_player,
756 "dlna_AABBCCDDEEFF": dlna_player,
757 }
758 controller._player_throttlers = {
759 "sonos_123": Throttler(1, 0.05),
760 "dlna_AABBCCDDEEFF": Throttler(1, 0.05),
761 }
762
763 # Link DLNA protocol to native player
764 native_player.set_linked_output_protocols(
765 [
766 OutputProtocol(
767 output_protocol_id="dlna_AABBCCDDEEFF",
768 name="DLNA",
769 protocol_domain="dlna",
770 priority=30,
771 )
772 ]
773 )
774
775 # Select protocol
776 selected_player, output_protocol = controller._select_best_output_protocol(native_player)
777
778 # Should select DLNA player, not native
779 assert selected_player == dlna_player
780 assert output_protocol is not None
781 assert output_protocol.output_protocol_id == "dlna_AABBCCDDEEFF"
782
783 def test_select_airplay_when_preferred_is_airplay(self, mock_mass: MagicMock) -> None:
784 """Test that AirPlay protocol is selected when user prefers AirPlay."""
785 # Mock config to return the full player ID as preferred
786 mock_mass.config.get_raw_player_config_value = MagicMock(
787 return_value="airplay_AABBCCDDEEFF"
788 )
789
790 controller = PlayerController(mock_mass)
791
792 # Create native player
793 sonos_provider = MockProvider("sonos", mass=mock_mass)
794 native_player = MockPlayer(
795 sonos_provider,
796 "sonos_123",
797 "Kantoor",
798 identifiers={IdentifierType.MAC_ADDRESS: "AA:BB:CC:DD:EE:FF"},
799 )
800 native_player._attr_supported_features.add(PlayerFeature.PLAY_MEDIA)
801
802 # Create AirPlay and DLNA protocol players
803 airplay_provider = MockProvider("airplay", mass=mock_mass)
804 airplay_player = MockPlayer(
805 airplay_provider,
806 "airplay_AABBCCDDEEFF",
807 "Kantoor AirPlay",
808 player_type=PlayerType.PROTOCOL,
809 identifiers={IdentifierType.MAC_ADDRESS: "AA:BB:CC:DD:EE:FF"},
810 )
811
812 dlna_provider = MockProvider("dlna", mass=mock_mass)
813 dlna_player = MockPlayer(
814 dlna_provider,
815 "dlna_AABBCCDDEEFF",
816 "Kantoor DLNA",
817 player_type=PlayerType.PROTOCOL,
818 identifiers={IdentifierType.MAC_ADDRESS: "AA:BB:CC:DD:EE:FF"},
819 )
820
821 # Register players
822 controller._players = {
823 "sonos_123": native_player,
824 "airplay_AABBCCDDEEFF": airplay_player,
825 "dlna_AABBCCDDEEFF": dlna_player,
826 }
827 controller._player_throttlers = {
828 "sonos_123": Throttler(1, 0.05),
829 "airplay_AABBCCDDEEFF": Throttler(1, 0.05),
830 "dlna_AABBCCDDEEFF": Throttler(1, 0.05),
831 }
832
833 # Link protocols to native player
834 native_player.set_linked_output_protocols(
835 [
836 OutputProtocol(
837 output_protocol_id="airplay_AABBCCDDEEFF",
838 name="AirPlay",
839 protocol_domain="airplay",
840 priority=10,
841 ),
842 OutputProtocol(
843 output_protocol_id="dlna_AABBCCDDEEFF",
844 name="DLNA",
845 protocol_domain="dlna",
846 priority=30,
847 ),
848 ]
849 )
850
851 # Select protocol
852 selected_player, output_protocol = controller._select_best_output_protocol(native_player)
853
854 # Should select AirPlay player (even though DLNA has lower priority value),
855 # because user preference overrides priority
856 assert selected_player == airplay_player
857 assert output_protocol is not None
858 assert output_protocol.output_protocol_id == "airplay_AABBCCDDEEFF"
859
860 def test_fallback_to_native_when_auto(self, mock_mass: MagicMock) -> None:
861 """Test that native playback is used when preference is auto."""
862 # Mock config to return "auto" as preferred
863 mock_mass.config.get_raw_player_config_value = MagicMock(return_value="auto")
864
865 controller = PlayerController(mock_mass)
866 provider = MockProvider("sonos", mass=mock_mass)
867
868 native_player = MockPlayer(
869 provider,
870 "sonos_123",
871 "Kantoor",
872 identifiers={IdentifierType.MAC_ADDRESS: "AA:BB:CC:DD:EE:FF"},
873 )
874 native_player._attr_supported_features.add(PlayerFeature.PLAY_MEDIA)
875
876 controller._players = {"sonos_123": native_player}
877 controller._player_throttlers = {"sonos_123": Throttler(1, 0.05)}
878
879 # Select protocol with auto preference
880 selected_player, output_protocol = controller._select_best_output_protocol(native_player)
881
882 # Should select native player
883 assert selected_player == native_player
884 assert output_protocol is None # None means native playback
885
886
887class TestPlayerGrouping:
888 """Tests for player grouping scenarios."""
889
890 def test_native_to_native_grouping(self, mock_mass: MagicMock) -> None:
891 """Test that native players from same provider can group together."""
892 controller = PlayerController(mock_mass)
893
894 sonos_provider = MockProvider("sonos", mass=mock_mass)
895
896 # Create two Sonos players
897 player_a = MockPlayer(
898 sonos_provider,
899 "sonos_123",
900 "Living Room",
901 identifiers={IdentifierType.MAC_ADDRESS: "AA:BB:CC:DD:EE:01"},
902 )
903 player_a._attr_supported_features.add(PlayerFeature.SET_MEMBERS)
904 player_a._attr_supported_features.add(PlayerFeature.PLAY_MEDIA)
905 player_a._attr_can_group_with = {"sonos_456"}
906 player_a._cache.clear() # Clear cached properties after modifying attributes
907
908 player_b = MockPlayer(
909 sonos_provider,
910 "sonos_456",
911 "Kitchen",
912 identifiers={IdentifierType.MAC_ADDRESS: "AA:BB:CC:DD:EE:02"},
913 )
914 player_b._attr_supported_features.add(PlayerFeature.PLAY_MEDIA)
915 player_b._cache.clear()
916
917 controller._players = {
918 "sonos_123": player_a,
919 "sonos_456": player_b,
920 }
921 controller._player_throttlers = {
922 "sonos_123": Throttler(1, 0.05),
923 "sonos_456": Throttler(1, 0.05),
924 }
925
926 # Translate members for native grouping
927 protocol_members, native_members, _, _ = controller._translate_members_for_protocols(
928 parent_player=player_a,
929 player_ids=["sonos_456"],
930 parent_protocol_player=None,
931 parent_protocol_domain=None,
932 )
933
934 # Should use native grouping (same provider)
935 assert len(native_members) == 1
936 assert "sonos_456" in native_members
937 assert len(protocol_members) == 0
938
939 def test_protocol_to_protocol_grouping(self, mock_mass: MagicMock) -> None:
940 """Test that protocol players can group via shared protocol."""
941 controller = PlayerController(mock_mass)
942
943 # Create two players with AirPlay protocol support
944 sonos_provider = MockProvider("sonos", mass=mock_mass)
945 wiim_provider = MockProvider("wiim", mass=mock_mass)
946 airplay_provider = MockProvider("airplay", mass=mock_mass)
947
948 # Sonos player
949 sonos_player = MockPlayer(
950 sonos_provider,
951 "sonos_123",
952 "Living Room",
953 identifiers={IdentifierType.MAC_ADDRESS: "AA:BB:CC:DD:EE:01"},
954 )
955 sonos_player._attr_supported_features.add(PlayerFeature.PLAY_MEDIA)
956 sonos_player._cache.clear()
957
958 # WiiM player
959 wiim_player = MockPlayer(
960 wiim_provider,
961 "wiim_456",
962 "Bedroom",
963 identifiers={IdentifierType.MAC_ADDRESS: "AA:BB:CC:DD:EE:02"},
964 )
965 wiim_player._attr_supported_features.add(PlayerFeature.PLAY_MEDIA)
966 wiim_player._cache.clear()
967
968 # AirPlay protocol players
969 sonos_airplay = MockPlayer(
970 airplay_provider,
971 "airplay_sonos",
972 "Living Room (AirPlay)",
973 player_type=PlayerType.PROTOCOL,
974 identifiers={IdentifierType.MAC_ADDRESS: "AA:BB:CC:DD:EE:01"},
975 )
976 sonos_airplay._attr_supported_features.add(PlayerFeature.SET_MEMBERS)
977 sonos_airplay._attr_can_group_with = {"airplay_wiim"}
978 sonos_airplay._cache.clear()
979 sonos_airplay.update_state(signal_event=False)
980
981 wiim_airplay = MockPlayer(
982 airplay_provider,
983 "airplay_wiim",
984 "Bedroom (AirPlay)",
985 player_type=PlayerType.PROTOCOL,
986 identifiers={IdentifierType.MAC_ADDRESS: "AA:BB:CC:DD:EE:02"},
987 )
988
989 # Link protocol players to native players
990 sonos_player.set_linked_output_protocols(
991 [
992 OutputProtocol(
993 output_protocol_id="airplay_sonos",
994 name="AirPlay",
995 protocol_domain="airplay",
996 priority=10,
997 available=True,
998 )
999 ]
1000 )
1001 wiim_player.set_linked_output_protocols(
1002 [
1003 OutputProtocol(
1004 output_protocol_id="airplay_wiim",
1005 name="AirPlay",
1006 protocol_domain="airplay",
1007 priority=10,
1008 available=True,
1009 )
1010 ]
1011 )
1012
1013 controller._players = {
1014 "sonos_123": sonos_player,
1015 "wiim_456": wiim_player,
1016 "airplay_sonos": sonos_airplay,
1017 "airplay_wiim": wiim_airplay,
1018 }
1019 controller._player_throttlers = {
1020 "sonos_123": Throttler(1, 0.05),
1021 "wiim_456": Throttler(1, 0.05),
1022 "airplay_sonos": Throttler(1, 0.05),
1023 "airplay_wiim": Throttler(1, 0.05),
1024 }
1025
1026 # Translate members for protocol grouping (via AirPlay)
1027 protocol_members, native_members, protocol_player, _ = (
1028 controller._translate_members_for_protocols(
1029 parent_player=sonos_player,
1030 player_ids=["wiim_456"],
1031 parent_protocol_player=sonos_airplay,
1032 parent_protocol_domain="airplay",
1033 )
1034 )
1035
1036 # Should use protocol grouping (AirPlay)
1037 assert len(protocol_members) == 1
1038 assert "airplay_wiim" in protocol_members
1039 assert len(native_members) == 0
1040 assert protocol_player == sonos_airplay
1041
1042 def test_hybrid_grouping(self, mock_mass: MagicMock) -> None:
1043 """Test hybrid grouping: native + protocol players in same group."""
1044 controller = PlayerController(mock_mass)
1045
1046 # Create Sonos players (native grouping capability)
1047 sonos_provider = MockProvider("sonos", instance_id="sonos_instance", mass=mock_mass)
1048 sonos_a = MockPlayer(
1049 sonos_provider,
1050 "sonos_123",
1051 "Living Room",
1052 identifiers={IdentifierType.MAC_ADDRESS: "AA:BB:CC:DD:EE:01"},
1053 )
1054 sonos_a._attr_supported_features.add(PlayerFeature.SET_MEMBERS)
1055 sonos_a._attr_supported_features.add(PlayerFeature.PLAY_MEDIA)
1056 sonos_a._attr_can_group_with = {"sonos_456"}
1057 sonos_a._cache.clear()
1058
1059 sonos_b = MockPlayer(
1060 sonos_provider,
1061 "sonos_456",
1062 "Kitchen",
1063 identifiers={IdentifierType.MAC_ADDRESS: "AA:BB:CC:DD:EE:02"},
1064 )
1065 sonos_b._attr_supported_features.add(PlayerFeature.PLAY_MEDIA)
1066 sonos_b._cache.clear()
1067
1068 # Create WiiM player with AirPlay protocol
1069 wiim_provider = MockProvider("wiim", instance_id="wiim_instance", mass=mock_mass)
1070 airplay_provider = MockProvider("airplay", instance_id="airplay_instance", mass=mock_mass)
1071
1072 wiim_player = MockPlayer(
1073 wiim_provider,
1074 "wiim_789",
1075 "Bedroom",
1076 identifiers={IdentifierType.MAC_ADDRESS: "AA:BB:CC:DD:EE:03"},
1077 )
1078 wiim_player._attr_supported_features.add(PlayerFeature.PLAY_MEDIA)
1079 wiim_player._cache.clear()
1080
1081 # AirPlay protocol players
1082 sonos_airplay = MockPlayer(
1083 airplay_provider,
1084 "airplay_sonos",
1085 "Living Room (AirPlay)",
1086 player_type=PlayerType.PROTOCOL,
1087 identifiers={IdentifierType.MAC_ADDRESS: "AA:BB:CC:DD:EE:01"},
1088 )
1089 sonos_airplay._attr_supported_features.add(PlayerFeature.SET_MEMBERS)
1090 sonos_airplay._attr_can_group_with = {"airplay_wiim"}
1091 sonos_airplay._cache.clear()
1092 sonos_airplay.update_state(signal_event=False)
1093
1094 wiim_airplay = MockPlayer(
1095 airplay_provider,
1096 "airplay_wiim",
1097 "Bedroom (AirPlay)",
1098 player_type=PlayerType.PROTOCOL,
1099 identifiers={IdentifierType.MAC_ADDRESS: "AA:BB:CC:DD:EE:03"},
1100 )
1101
1102 # Link AirPlay to Sonos A
1103 sonos_a.set_linked_output_protocols(
1104 [
1105 OutputProtocol(
1106 output_protocol_id="airplay_sonos",
1107 name="AirPlay",
1108 protocol_domain="airplay",
1109 priority=10,
1110 available=True,
1111 )
1112 ]
1113 )
1114 wiim_player.set_linked_output_protocols(
1115 [
1116 OutputProtocol(
1117 output_protocol_id="airplay_wiim",
1118 name="AirPlay",
1119 protocol_domain="airplay",
1120 priority=10,
1121 available=True,
1122 )
1123 ]
1124 )
1125 wiim_player.set_active_output_protocol("airplay_wiim")
1126 wiim_player.set_protocol_parent_id("airplay_wiim")
1127
1128 # Wire up mock_mass.players to controller so get_linked_protocol works
1129 mock_mass.players = controller
1130
1131 controller._players = {
1132 "sonos_123": sonos_a,
1133 "sonos_456": sonos_b,
1134 "wiim_789": wiim_player,
1135 "airplay_sonos": sonos_airplay,
1136 "airplay_wiim": wiim_airplay,
1137 }
1138 controller._player_throttlers = {
1139 "sonos_123": Throttler(1, 0.05),
1140 "sonos_456": Throttler(1, 0.05),
1141 "wiim_789": Throttler(1, 0.05),
1142 "airplay_sonos": Throttler(1, 0.05),
1143 "airplay_wiim": Throttler(1, 0.05),
1144 }
1145
1146 # Group Sonos B (native) + WiiM (via AirPlay) to Sonos A
1147 protocol_members, native_members, _protocol_player, _ = (
1148 controller._translate_members_for_protocols(
1149 parent_player=sonos_a,
1150 player_ids=["sonos_456", "wiim_789"],
1151 parent_protocol_player=sonos_airplay,
1152 parent_protocol_domain="airplay",
1153 )
1154 )
1155
1156 # Should have hybrid group: native Sonos B + protocol WiiM
1157 assert len(native_members) == 1
1158 assert "sonos_456" in native_members
1159 assert len(protocol_members) == 1
1160 assert "airplay_wiim" in protocol_members
1161
1162 def test_protocol_selection_requires_set_members(self, mock_mass: MagicMock) -> None:
1163 """Test that only protocols with SET_MEMBERS support are selected for grouping."""
1164 controller = PlayerController(mock_mass)
1165
1166 sonos_provider = MockProvider("sonos", mass=mock_mass)
1167 wiim_provider = MockProvider("wiim", mass=mock_mass)
1168 dlna_provider = MockProvider("dlna", mass=mock_mass)
1169 airplay_provider = MockProvider("airplay", mass=mock_mass)
1170
1171 # Sonos player
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._cache.clear()
1180
1181 # WiiM player
1182 wiim_player = MockPlayer(
1183 wiim_provider,
1184 "wiim_456",
1185 "Bedroom",
1186 identifiers={IdentifierType.MAC_ADDRESS: "AA:BB:CC:DD:EE:02"},
1187 )
1188 wiim_player._attr_supported_features.add(PlayerFeature.PLAY_MEDIA)
1189 wiim_player._cache.clear()
1190
1191 # DLNA protocol (does NOT support SET_MEMBERS)
1192 sonos_dlna = MockPlayer(
1193 dlna_provider,
1194 "dlna_sonos",
1195 "Living Room (DLNA)",
1196 player_type=PlayerType.PROTOCOL,
1197 identifiers={IdentifierType.MAC_ADDRESS: "AA:BB:CC:DD:EE:01"},
1198 )
1199 # Note: NO SET_MEMBERS feature
1200
1201 wiim_dlna = MockPlayer(
1202 dlna_provider,
1203 "dlna_wiim",
1204 "Bedroom (DLNA)",
1205 player_type=PlayerType.PROTOCOL,
1206 identifiers={IdentifierType.MAC_ADDRESS: "AA:BB:CC:DD:EE:02"},
1207 )
1208
1209 # AirPlay protocol (DOES support SET_MEMBERS)
1210 sonos_airplay = MockPlayer(
1211 airplay_provider,
1212 "airplay_sonos",
1213 "Living Room (AirPlay)",
1214 player_type=PlayerType.PROTOCOL,
1215 identifiers={IdentifierType.MAC_ADDRESS: "AA:BB:CC:DD:EE:01"},
1216 )
1217 sonos_airplay._attr_supported_features.add(PlayerFeature.SET_MEMBERS)
1218 sonos_airplay._attr_can_group_with = {"airplay_wiim"}
1219 sonos_airplay._cache.clear()
1220
1221 wiim_airplay = MockPlayer(
1222 airplay_provider,
1223 "airplay_wiim",
1224 "Bedroom (AirPlay)",
1225 player_type=PlayerType.PROTOCOL,
1226 identifiers={IdentifierType.MAC_ADDRESS: "AA:BB:CC:DD:EE:02"},
1227 )
1228 wiim_airplay._attr_supported_features.add(PlayerFeature.SET_MEMBERS)
1229 wiim_airplay._attr_can_group_with = {"airplay_sonos"}
1230 wiim_airplay._cache.clear()
1231
1232 # Link protocols (DLNA has lower priority than AirPlay)
1233 sonos_player.set_linked_output_protocols(
1234 [
1235 OutputProtocol(
1236 output_protocol_id="dlna_sonos",
1237 name="DLNA",
1238 protocol_domain="dlna",
1239 priority=30, # Lower priority (higher number)
1240 available=True,
1241 ),
1242 OutputProtocol(
1243 output_protocol_id="airplay_sonos",
1244 name="AirPlay",
1245 protocol_domain="airplay",
1246 priority=10, # Higher priority (lower number)
1247 available=True,
1248 ),
1249 ]
1250 )
1251 wiim_player.set_linked_output_protocols(
1252 [
1253 OutputProtocol(
1254 output_protocol_id="dlna_wiim",
1255 name="DLNA",
1256 protocol_domain="dlna",
1257 priority=30,
1258 available=True,
1259 ),
1260 OutputProtocol(
1261 output_protocol_id="airplay_wiim",
1262 name="AirPlay",
1263 protocol_domain="airplay",
1264 priority=10,
1265 available=True,
1266 ),
1267 ]
1268 )
1269
1270 controller._players = {
1271 "sonos_123": sonos_player,
1272 "wiim_456": wiim_player,
1273 "dlna_sonos": sonos_dlna,
1274 "dlna_wiim": wiim_dlna,
1275 "airplay_sonos": sonos_airplay,
1276 "airplay_wiim": wiim_airplay,
1277 }
1278 controller._player_throttlers = {
1279 "sonos_123": Throttler(1, 0.05),
1280 "wiim_456": Throttler(1, 0.05),
1281 "dlna_sonos": Throttler(1, 0.05),
1282 "dlna_wiim": Throttler(1, 0.05),
1283 "airplay_sonos": Throttler(1, 0.05),
1284 "airplay_wiim": Throttler(1, 0.05),
1285 }
1286
1287 # Update state after modifying attributes
1288 sonos_airplay.update_state(signal_event=False)
1289 wiim_airplay.update_state(signal_event=False)
1290
1291 # Translate members - should skip DLNA (no SET_MEMBERS) and select AirPlay
1292 protocol_members, _native_members, protocol_player, protocol_domain = (
1293 controller._translate_members_for_protocols(
1294 parent_player=sonos_player,
1295 player_ids=["wiim_456"],
1296 parent_protocol_player=None,
1297 parent_protocol_domain=None,
1298 )
1299 )
1300
1301 # Should select AirPlay (supports SET_MEMBERS) not DLNA
1302 assert len(protocol_members) == 1
1303 assert "airplay_wiim" in protocol_members
1304 assert protocol_domain == "airplay"
1305 assert protocol_player == sonos_airplay
1306
1307
1308class TestCanGroupWith:
1309 """Tests for can_group_with property with three scenarios."""
1310
1311 def test_scenario_1_native_active_only_native_players(self, mock_mass: MagicMock) -> None:
1312 """Test Scenario 1: Native playback active -> all protocols shown (new behavior)."""
1313 controller = PlayerController(mock_mass)
1314
1315 sonos_provider = MockProvider("sonos", instance_id="sonos_instance", mass=mock_mass)
1316 airplay_provider = MockProvider("airplay", instance_id="airplay_instance", mass=mock_mass)
1317
1318 # Create Sonos player with native and AirPlay support
1319 sonos_player = MockPlayer(
1320 sonos_provider,
1321 "sonos_123",
1322 "Living Room",
1323 identifiers={IdentifierType.MAC_ADDRESS: "AA:BB:CC:DD:EE:01"},
1324 )
1325 sonos_player._attr_supported_features.add(PlayerFeature.PLAY_MEDIA)
1326 sonos_player._attr_supported_features.add(PlayerFeature.SET_MEMBERS)
1327 sonos_player._attr_can_group_with = {"sonos_456"}
1328 sonos_player._cache.clear()
1329 sonos_player.set_active_output_protocol("native")
1330
1331 # Create another Sonos player
1332 sonos_player_b = MockPlayer(
1333 sonos_provider,
1334 "sonos_456",
1335 "Kitchen",
1336 identifiers={IdentifierType.MAC_ADDRESS: "AA:BB:CC:DD:EE:02"},
1337 )
1338
1339 # Create AirPlay protocol player
1340 sonos_airplay = MockPlayer(
1341 airplay_provider,
1342 "airplay_sonos",
1343 "Living Room (AirPlay)",
1344 player_type=PlayerType.PROTOCOL,
1345 identifiers={IdentifierType.MAC_ADDRESS: "AA:BB:CC:DD:EE:01"},
1346 )
1347 sonos_airplay._attr_supported_features.add(PlayerFeature.SET_MEMBERS)
1348 sonos_airplay._attr_can_group_with = {"airplay_other"}
1349 sonos_airplay._cache.clear()
1350 sonos_airplay.set_protocol_parent_id("sonos_123")
1351
1352 sonos_player.set_linked_output_protocols(
1353 [
1354 OutputProtocol(
1355 output_protocol_id="airplay_sonos",
1356 name="AirPlay",
1357 protocol_domain="airplay",
1358 priority=10,
1359 available=True,
1360 )
1361 ]
1362 )
1363
1364 # Wire up mock_mass.players to controller so get_linked_protocol works
1365 mock_mass.players = controller
1366
1367 controller._players = {
1368 "sonos_123": sonos_player,
1369 "sonos_456": sonos_player_b,
1370 "airplay_sonos": sonos_airplay,
1371 }
1372 controller._player_throttlers = {
1373 "sonos_123": Throttler(1, 0.05),
1374 "sonos_456": Throttler(1, 0.05),
1375 "airplay_sonos": Throttler(1, 0.05),
1376 }
1377
1378 # Update state after modifying attributes and registering with controller
1379 sonos_player.update_state(signal_event=False)
1380 sonos_player_b.update_state(signal_event=False)
1381 sonos_airplay.update_state(signal_event=False)
1382
1383 # Get can_group_with while native is active
1384 groupable = sonos_player.state.can_group_with
1385
1386 # NEW BEHAVIOR: Should show both native AND protocol players
1387 # even when native protocol is active
1388 assert "sonos_456" in groupable # Native Sonos player
1389 # Note: airplay_other is not registered in controller._players, so it won't appear
1390 # But the logic should still allow showing AirPlay options if they were registered
1391
1392 def test_scenario_2_protocol_active_hybrid_groups(self, mock_mass: MagicMock) -> None:
1393 """Test Scenario 2: Protocol active -> show all protocols (new behavior)."""
1394 controller = PlayerController(mock_mass)
1395
1396 sonos_provider = MockProvider("sonos", instance_id="sonos_instance", mass=mock_mass)
1397 airplay_provider = MockProvider("airplay", instance_id="airplay_instance", mass=mock_mass)
1398
1399 # Create Sonos player with AirPlay active
1400 sonos_player = MockPlayer(
1401 sonos_provider,
1402 "sonos_123",
1403 "Living Room",
1404 identifiers={IdentifierType.MAC_ADDRESS: "AA:BB:CC:DD:EE:01"},
1405 )
1406 sonos_player._attr_supported_features.add(PlayerFeature.PLAY_MEDIA)
1407 sonos_player._attr_supported_features.add(PlayerFeature.SET_MEMBERS)
1408 sonos_player._attr_can_group_with = {"sonos_456"}
1409 sonos_player._cache.clear()
1410
1411 # Create another Sonos player
1412 sonos_player_b = MockPlayer(
1413 sonos_provider,
1414 "sonos_456",
1415 "Kitchen",
1416 identifiers={IdentifierType.MAC_ADDRESS: "AA:BB:CC:DD:EE:02"},
1417 )
1418
1419 # Create AirPlay protocol player
1420 sonos_airplay = MockPlayer(
1421 airplay_provider,
1422 "airplay_sonos",
1423 "Living Room (AirPlay)",
1424 player_type=PlayerType.PROTOCOL,
1425 identifiers={IdentifierType.MAC_ADDRESS: "AA:BB:CC:DD:EE:01"},
1426 )
1427 sonos_airplay._attr_supported_features.add(PlayerFeature.SET_MEMBERS)
1428 sonos_airplay._attr_can_group_with = {"airplay_other"}
1429 sonos_airplay._cache.clear()
1430 sonos_airplay.set_protocol_parent_id("sonos_123")
1431
1432 # Create another device with AirPlay
1433 wiim_player = MockPlayer(
1434 sonos_provider,
1435 "wiim_789",
1436 "Bedroom",
1437 identifiers={IdentifierType.MAC_ADDRESS: "AA:BB:CC:DD:EE:03"},
1438 )
1439
1440 airplay_other = MockPlayer(
1441 airplay_provider,
1442 "airplay_other",
1443 "Bedroom (AirPlay)",
1444 player_type=PlayerType.PROTOCOL,
1445 identifiers={IdentifierType.MAC_ADDRESS: "AA:BB:CC:DD:EE:03"},
1446 )
1447 airplay_other._attr_supported_features.add(PlayerFeature.SET_MEMBERS)
1448 airplay_other._attr_can_group_with = {"airplay_sonos"}
1449 airplay_other._cache.clear()
1450 airplay_other.set_protocol_parent_id("wiim_789")
1451
1452 wiim_player.set_linked_output_protocols(
1453 [
1454 OutputProtocol(
1455 output_protocol_id="airplay_other",
1456 name="AirPlay",
1457 protocol_domain="airplay",
1458 priority=10,
1459 available=True,
1460 ),
1461 ]
1462 )
1463
1464 sonos_player.set_linked_output_protocols(
1465 [
1466 OutputProtocol(
1467 output_protocol_id="airplay_sonos",
1468 name="AirPlay",
1469 protocol_domain="airplay",
1470 priority=10,
1471 available=True,
1472 )
1473 ]
1474 )
1475 sonos_player.set_active_output_protocol("airplay_sonos")
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 }
1487 controller._player_throttlers = {
1488 "sonos_123": Throttler(1, 0.05),
1489 "sonos_456": Throttler(1, 0.05),
1490 "wiim_789": Throttler(1, 0.05),
1491 "airplay_sonos": Throttler(1, 0.05),
1492 "airplay_other": Throttler(1, 0.05),
1493 }
1494
1495 # Clear cache after setting linked protocols
1496 sonos_player._cache.clear()
1497 wiim_player._cache.clear()
1498
1499 # Update state after modifying attributes and registering with controller
1500 # IMPORTANT: Update protocol players FIRST, then parent players
1501 sonos_airplay.update_state(signal_event=False)
1502 airplay_other.update_state(signal_event=False)
1503 sonos_player.update_state(signal_event=False)
1504 sonos_player_b.update_state(signal_event=False)
1505 wiim_player.update_state(signal_event=False)
1506
1507 # Get can_group_with while AirPlay is active
1508 groupable = sonos_player.state.can_group_with
1509
1510 # NEW BEHAVIOR: Should show ALL protocols + native players
1511 # regardless of which protocol is active
1512 assert "sonos_456" in groupable # Native Sonos player
1513 assert "wiim_789" in groupable # Via airplay_other protocol
1514
1515 def test_scenario_3_no_active_output_all_protocols_shown(self, mock_mass: MagicMock) -> None:
1516 """Test Scenario 3: No active output -> show all compatible protocols + native."""
1517 controller = PlayerController(mock_mass)
1518
1519 sonos_provider = MockProvider("sonos", instance_id="sonos_instance", mass=mock_mass)
1520 airplay_provider = MockProvider("airplay", instance_id="airplay_instance", mass=mock_mass)
1521 dlna_provider = MockProvider("dlna", instance_id="dlna_instance", mass=mock_mass)
1522
1523 # Create Sonos player (no active protocol)
1524 sonos_player = MockPlayer(
1525 sonos_provider,
1526 "sonos_123",
1527 "Living Room",
1528 identifiers={IdentifierType.MAC_ADDRESS: "AA:BB:CC:DD:EE:01"},
1529 )
1530 sonos_player._attr_supported_features.add(PlayerFeature.PLAY_MEDIA)
1531 sonos_player._attr_supported_features.add(PlayerFeature.SET_MEMBERS)
1532 sonos_player._attr_can_group_with = {"sonos_456"}
1533 sonos_player._cache.clear()
1534 # No active output protocol set
1535
1536 # Create another Sonos player
1537 sonos_player_b = MockPlayer(
1538 sonos_provider,
1539 "sonos_456",
1540 "Kitchen",
1541 identifiers={IdentifierType.MAC_ADDRESS: "AA:BB:CC:DD:EE:02"},
1542 )
1543
1544 # Create AirPlay protocol player (supports SET_MEMBERS)
1545 sonos_airplay = MockPlayer(
1546 airplay_provider,
1547 "airplay_sonos",
1548 "Living Room (AirPlay)",
1549 player_type=PlayerType.PROTOCOL,
1550 identifiers={IdentifierType.MAC_ADDRESS: "AA:BB:CC:DD:EE:01"},
1551 )
1552 sonos_airplay._attr_supported_features.add(PlayerFeature.SET_MEMBERS)
1553 sonos_airplay._attr_can_group_with = {"airplay_other"}
1554 sonos_airplay._cache.clear()
1555 sonos_airplay.set_protocol_parent_id("sonos_123")
1556
1557 # Create DLNA protocol player (does NOT support SET_MEMBERS)
1558 sonos_dlna = MockPlayer(
1559 dlna_provider,
1560 "dlna_sonos",
1561 "Living Room (DLNA)",
1562 player_type=PlayerType.PROTOCOL,
1563 identifiers={IdentifierType.MAC_ADDRESS: "AA:BB:CC:DD:EE:01"},
1564 )
1565 # No SET_MEMBERS support
1566 sonos_dlna._attr_can_group_with = {"dlna_other"}
1567 sonos_dlna.set_protocol_parent_id("sonos_123")
1568
1569 # Another device
1570 wiim_player = MockPlayer(
1571 sonos_provider,
1572 "wiim_789",
1573 "Bedroom",
1574 identifiers={IdentifierType.MAC_ADDRESS: "AA:BB:CC:DD:EE:03"},
1575 )
1576
1577 airplay_other = MockPlayer(
1578 airplay_provider,
1579 "airplay_other",
1580 "Bedroom (AirPlay)",
1581 player_type=PlayerType.PROTOCOL,
1582 identifiers={IdentifierType.MAC_ADDRESS: "AA:BB:CC:DD:EE:03"},
1583 )
1584 airplay_other._attr_supported_features.add(PlayerFeature.SET_MEMBERS)
1585 airplay_other._attr_can_group_with = {"airplay_sonos"}
1586 airplay_other._cache.clear()
1587 airplay_other.set_protocol_parent_id("wiim_789")
1588
1589 sonos_player.set_linked_output_protocols(
1590 [
1591 OutputProtocol(
1592 output_protocol_id="airplay_sonos",
1593 name="AirPlay",
1594 protocol_domain="airplay",
1595 priority=10,
1596 available=True,
1597 ),
1598 OutputProtocol(
1599 output_protocol_id="dlna_sonos",
1600 name="DLNA",
1601 protocol_domain="dlna",
1602 priority=30,
1603 available=True,
1604 ),
1605 ]
1606 )
1607
1608 wiim_player.set_linked_output_protocols(
1609 [
1610 OutputProtocol(
1611 output_protocol_id="airplay_other",
1612 name="AirPlay",
1613 protocol_domain="airplay",
1614 priority=10,
1615 available=True,
1616 ),
1617 ]
1618 )
1619
1620 # Clear cache after setting linked protocols (output_protocols is cached)
1621 sonos_player._cache.clear()
1622 wiim_player._cache.clear()
1623
1624 # Wire up mock_mass.players to controller so get_linked_protocol works
1625 mock_mass.players = controller
1626
1627 controller._players = {
1628 "sonos_123": sonos_player,
1629 "sonos_456": sonos_player_b,
1630 "wiim_789": wiim_player,
1631 "airplay_sonos": sonos_airplay,
1632 "airplay_other": airplay_other,
1633 "dlna_sonos": sonos_dlna,
1634 }
1635 controller._player_throttlers = {
1636 "sonos_123": Throttler(1, 0.05),
1637 "sonos_456": Throttler(1, 0.05),
1638 "wiim_789": Throttler(1, 0.05),
1639 "airplay_sonos": Throttler(1, 0.05),
1640 "airplay_other": Throttler(1, 0.05),
1641 "dlna_sonos": Throttler(1, 0.05),
1642 }
1643
1644 # Update state after modifying attributes and registering with controller
1645 # Note: set_linked_output_protocols calls trigger_player_update, but since mass.players
1646 # is a MagicMock, we need to manually call update_state
1647 # IMPORTANT: Update protocol players FIRST, then parent players, because parent players
1648 # access protocol_player.state.can_group_with during their update_state()
1649 sonos_airplay.update_state(signal_event=False)
1650 airplay_other.update_state(signal_event=False)
1651 sonos_dlna.update_state(signal_event=False)
1652 sonos_player.update_state(signal_event=False)
1653 sonos_player_b.update_state(signal_event=False)
1654 wiim_player.update_state(signal_event=False)
1655
1656 # Get can_group_with with no active protocol
1657 groupable = sonos_player.state.can_group_with
1658
1659 # Should show native players + AirPlay players (supports SET_MEMBERS)
1660 # but NOT DLNA players (no SET_MEMBERS support)
1661 assert "sonos_456" in groupable
1662 assert "wiim_789" in groupable # Via AirPlay protocol
1663 # DLNA players should not be shown since DLNA doesn't support SET_MEMBERS
1664
1665
1666class TestNativePlayerProtocolGrouping:
1667 """Tests for grouping between native PLAYER type and PROTOCOL type AirPlay players."""
1668
1669 def test_native_airplay_player_sees_protocol_players_as_visible_parents(
1670 self, mock_mass: MagicMock
1671 ) -> None:
1672 """Test that a native PLAYER type translates protocol players to visible parents."""
1673 controller = PlayerController(mock_mass)
1674
1675 airplay_provider = MockProvider("airplay", instance_id="airplay", mass=mock_mass)
1676 sonos_provider = MockProvider("sonos", instance_id="sonos", mass=mock_mass)
1677
1678 # HomePod: native AirPlay PLAYER (not PROTOCOL)
1679 homepod = MockPlayer(airplay_provider, "homepod_1", "Office")
1680 homepod._attr_supported_features.add(PlayerFeature.SET_MEMBERS)
1681 homepod._attr_can_group_with = {"airplay"} # Provider instance ID
1682 homepod._cache.clear()
1683
1684 # Sonos native player (visible to the user)
1685 sonos_player = MockPlayer(sonos_provider, "sonos_1", "Kitchen")
1686 sonos_player._attr_supported_features.add(PlayerFeature.SET_MEMBERS)
1687 sonos_player._cache.clear()
1688
1689 # AirPlay protocol player for the Sonos (hidden, linked to sonos_player)
1690 sonos_airplay = MockPlayer(
1691 airplay_provider,
1692 "airplay_sonos_1",
1693 "Kitchen (AirPlay)",
1694 player_type=PlayerType.PROTOCOL,
1695 )
1696 sonos_airplay._attr_supported_features.add(PlayerFeature.SET_MEMBERS)
1697 sonos_airplay._attr_can_group_with = {"airplay"}
1698 sonos_airplay._cache.clear()
1699 sonos_airplay.set_protocol_parent_id("sonos_1")
1700
1701 sonos_player.set_linked_output_protocols(
1702 [
1703 OutputProtocol(
1704 output_protocol_id="airplay_sonos_1",
1705 name="AirPlay",
1706 protocol_domain="airplay",
1707 priority=10,
1708 available=True,
1709 )
1710 ]
1711 )
1712
1713 mock_mass.players = controller
1714 mock_mass.get_provider = MagicMock(return_value=airplay_provider)
1715
1716 controller._players = {
1717 "homepod_1": homepod,
1718 "sonos_1": sonos_player,
1719 "airplay_sonos_1": sonos_airplay,
1720 }
1721 controller._player_throttlers = {
1722 "homepod_1": Throttler(1, 0.05),
1723 "sonos_1": Throttler(1, 0.05),
1724 "airplay_sonos_1": Throttler(1, 0.05),
1725 }
1726
1727 # Update protocol players first, then parents
1728 sonos_airplay.update_state(signal_event=False)
1729 sonos_player.update_state(signal_event=False)
1730 homepod.update_state(signal_event=False)
1731
1732 groupable = homepod.state.can_group_with
1733
1734 # HomePod should see Sonos's VISIBLE player, not the hidden protocol player
1735 assert "sonos_1" in groupable
1736 assert "airplay_sonos_1" not in groupable # Hidden protocol ID must NOT appear
1737
1738 def test_protocol_linked_player_sees_native_airplay_player(self, mock_mass: MagicMock) -> None:
1739 """Test that a player with linked AirPlay protocol sees native PLAYER type players."""
1740 controller = PlayerController(mock_mass)
1741
1742 airplay_provider = MockProvider("airplay", instance_id="airplay", mass=mock_mass)
1743 sonos_provider = MockProvider("sonos", instance_id="sonos", mass=mock_mass)
1744
1745 # HomePod: native AirPlay PLAYER
1746 homepod = MockPlayer(airplay_provider, "homepod_1", "Office")
1747 homepod._attr_supported_features.add(PlayerFeature.SET_MEMBERS)
1748 homepod._attr_can_group_with = {"airplay"}
1749 homepod._cache.clear()
1750
1751 # Sonos native player (visible to the user)
1752 sonos_player = MockPlayer(sonos_provider, "sonos_1", "Kitchen")
1753 sonos_player._attr_supported_features.add(PlayerFeature.PLAY_MEDIA)
1754 sonos_player._attr_supported_features.add(PlayerFeature.SET_MEMBERS)
1755 sonos_player._attr_can_group_with = set() # No native Sonos grouping peers
1756 sonos_player._cache.clear()
1757
1758 # AirPlay protocol player for the Sonos (hidden, linked to sonos_player)
1759 sonos_airplay = MockPlayer(
1760 airplay_provider,
1761 "airplay_sonos_1",
1762 "Kitchen (AirPlay)",
1763 player_type=PlayerType.PROTOCOL,
1764 )
1765 sonos_airplay._attr_supported_features.add(PlayerFeature.SET_MEMBERS)
1766 sonos_airplay._attr_can_group_with = {"airplay"} # Provider instance ID
1767 sonos_airplay._cache.clear()
1768 sonos_airplay.set_protocol_parent_id("sonos_1")
1769
1770 sonos_player.set_linked_output_protocols(
1771 [
1772 OutputProtocol(
1773 output_protocol_id="airplay_sonos_1",
1774 name="AirPlay",
1775 protocol_domain="airplay",
1776 priority=10,
1777 available=True,
1778 )
1779 ]
1780 )
1781
1782 mock_mass.players = controller
1783 mock_mass.get_provider = MagicMock(return_value=airplay_provider)
1784
1785 controller._players = {
1786 "homepod_1": homepod,
1787 "sonos_1": sonos_player,
1788 "airplay_sonos_1": sonos_airplay,
1789 }
1790 controller._player_throttlers = {
1791 "homepod_1": Throttler(1, 0.05),
1792 "sonos_1": Throttler(1, 0.05),
1793 "airplay_sonos_1": Throttler(1, 0.05),
1794 }
1795
1796 # Update protocol players first, then parents
1797 sonos_airplay.update_state(signal_event=False)
1798 homepod.update_state(signal_event=False)
1799 sonos_player.update_state(signal_event=False)
1800
1801 groupable = sonos_player.state.can_group_with
1802
1803 # Sonos should see HomePod via its linked AirPlay protocol's can_group_with
1804 assert "homepod_1" in groupable
1805
1806
1807class TestProtocolSwitchingDuringPlayback:
1808 """Tests for dynamic protocol switching when group members change during playback."""
1809
1810 async def test_no_protocol_set_during_grouping_without_playback(
1811 self, mock_mass: MagicMock
1812 ) -> None:
1813 """Test that no protocol is set when grouping players without active playback."""
1814 controller = PlayerController(mock_mass)
1815
1816 sonos_provider = MockProvider("sonos", instance_id="sonos_instance", mass=mock_mass)
1817 airplay_provider = MockProvider("airplay", instance_id="airplay_instance", mass=mock_mass)
1818
1819 # Create Sonos player with AirPlay support
1820 sonos_player = MockPlayer(
1821 sonos_provider,
1822 "sonos_123",
1823 "Living Room",
1824 identifiers={IdentifierType.MAC_ADDRESS: "AA:BB:CC:DD:EE:01"},
1825 )
1826 sonos_player._attr_supported_features.add(PlayerFeature.PLAY_MEDIA)
1827 sonos_player._attr_supported_features.add(PlayerFeature.SET_MEMBERS)
1828 sonos_player._attr_can_group_with = {"sonos_456"}
1829
1830 # Create another Sonos player
1831 sonos_player_b = MockPlayer(
1832 sonos_provider,
1833 "sonos_456",
1834 "Kitchen",
1835 identifiers={IdentifierType.MAC_ADDRESS: "AA:BB:CC:DD:EE:02"},
1836 )
1837 sonos_player_b._attr_supported_features.add(PlayerFeature.SET_MEMBERS)
1838
1839 # Create AirPlay protocol player
1840 sonos_airplay = MockPlayer(
1841 airplay_provider,
1842 "airplay_sonos",
1843 "Living Room (AirPlay)",
1844 player_type=PlayerType.PROTOCOL,
1845 identifiers={IdentifierType.MAC_ADDRESS: "AA:BB:CC:DD:EE:01"},
1846 )
1847 sonos_airplay._attr_supported_features.add(PlayerFeature.SET_MEMBERS)
1848 sonos_airplay.set_protocol_parent_id("sonos_123")
1849
1850 sonos_player.set_linked_output_protocols(
1851 [
1852 OutputProtocol(
1853 output_protocol_id="airplay_sonos",
1854 name="AirPlay",
1855 protocol_domain="airplay",
1856 priority=10,
1857 available=True,
1858 )
1859 ]
1860 )
1861
1862 mock_mass.players = controller
1863 controller._players = {
1864 "sonos_123": sonos_player,
1865 "sonos_456": sonos_player_b,
1866 "airplay_sonos": sonos_airplay,
1867 }
1868 controller._player_throttlers = {
1869 "sonos_123": Throttler(1, 0.05),
1870 "sonos_456": Throttler(1, 0.05),
1871 "airplay_sonos": Throttler(1, 0.05),
1872 }
1873
1874 # Group players via protocol (simulate grouping through AirPlay)
1875 # This should NOT set active_output_protocol anymore
1876 await controller._forward_protocol_set_members(
1877 parent_player=sonos_player,
1878 parent_protocol_player=sonos_airplay,
1879 protocol_members_to_add=["airplay_other"], # Add a protocol member
1880 protocol_members_to_remove=[],
1881 )
1882
1883 # NEW BEHAVIOR: Protocol should NOT be set during grouping without playback
1884 # After grouping, protocol should not be activated until playback starts
1885 assert sonos_player.active_output_protocol is None
1886
1887 async def test_protocol_selected_at_playback_time(self, mock_mass: MagicMock) -> None:
1888 """Test that protocol is selected when playback starts, not during grouping."""
1889 controller = PlayerController(mock_mass)
1890
1891 sonos_provider = MockProvider("sonos", instance_id="sonos_instance", mass=mock_mass)
1892 airplay_provider = MockProvider("airplay", instance_id="airplay_instance", mass=mock_mass)
1893
1894 # Create Sonos player with AirPlay support
1895 sonos_player = MockPlayer(
1896 sonos_provider,
1897 "sonos_123",
1898 "Living Room",
1899 identifiers={IdentifierType.MAC_ADDRESS: "AA:BB:CC:DD:EE:01"},
1900 )
1901 sonos_player._attr_supported_features.add(PlayerFeature.PLAY_MEDIA)
1902 sonos_player._attr_supported_features.add(PlayerFeature.SET_MEMBERS)
1903
1904 # Create AirPlay protocol player with group members
1905 sonos_airplay = MockPlayer(
1906 airplay_provider,
1907 "airplay_sonos",
1908 "Living Room (AirPlay)",
1909 player_type=PlayerType.PROTOCOL,
1910 identifiers={IdentifierType.MAC_ADDRESS: "AA:BB:CC:DD:EE:01"},
1911 )
1912 sonos_airplay._attr_supported_features.add(PlayerFeature.SET_MEMBERS)
1913 sonos_airplay._attr_supported_features.add(PlayerFeature.PLAY_MEDIA)
1914 sonos_airplay.set_protocol_parent_id("sonos_123")
1915 # Simulate that AirPlay protocol has group members (needs >1 for grouping check)
1916 sonos_airplay._attr_group_members = ["airplay_sonos", "airplay_other"]
1917
1918 sonos_player.set_linked_output_protocols(
1919 [
1920 OutputProtocol(
1921 output_protocol_id="airplay_sonos",
1922 name="AirPlay",
1923 protocol_domain="airplay",
1924 priority=10,
1925 available=True,
1926 )
1927 ]
1928 )
1929
1930 mock_mass.players = controller
1931 controller._players = {
1932 "sonos_123": sonos_player,
1933 "airplay_sonos": sonos_airplay,
1934 }
1935
1936 # Update state to apply group members to state
1937 sonos_airplay.update_state(signal_event=False)
1938 sonos_player.update_state(signal_event=False)
1939
1940 # Protocol should not be set yet
1941 assert sonos_player.active_output_protocol is None
1942
1943 # Select protocol for playback
1944 selected_player, output_protocol = controller._select_best_output_protocol(sonos_player)
1945
1946 # Should select AirPlay protocol because it has group members (Priority 1)
1947 assert selected_player == sonos_airplay
1948 assert output_protocol is not None
1949 assert output_protocol.output_protocol_id == "airplay_sonos"
1950