/
/
/
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 # Mark players as initialized so they are returned by all_players()
342 airplay_player.set_initialized()
343 chromecast_player.set_initialized()
344
345 # Find matching players for AirPlay player
346 matches = controller._find_matching_protocol_players(airplay_player)
347
348 assert len(matches) == 2
349 assert airplay_player in matches
350 assert chromecast_player in matches
351
352 def test_same_protocol_not_matched(self, mock_mass: MagicMock) -> None:
353 """Test that multiple players of same protocol on same host are NOT matched together."""
354 controller = PlayerController(mock_mass)
355
356 # Set up provider
357 snapcast_provider = MockProvider("snapcast")
358
359 # Create multiple Snapcast players on same host (same MAC/IP)
360 # This simulates multiple Snapcast clients running on the same server
361 snapcast_player_1 = MockPlayer(
362 snapcast_provider,
363 "snapcast_client_1",
364 "Snapcast Client 1",
365 player_type=PlayerType.PROTOCOL,
366 identifiers={
367 IdentifierType.MAC_ADDRESS: "AA:BB:CC:DD:EE:FF",
368 IdentifierType.IP_ADDRESS: "192.168.1.100",
369 },
370 )
371 snapcast_player_2 = MockPlayer(
372 snapcast_provider,
373 "snapcast_client_2",
374 "Snapcast Client 2",
375 player_type=PlayerType.PROTOCOL,
376 identifiers={
377 IdentifierType.MAC_ADDRESS: "AA:BB:CC:DD:EE:FF",
378 IdentifierType.IP_ADDRESS: "192.168.1.100",
379 },
380 )
381
382 # Register players
383 controller._players = {
384 "snapcast_client_1": snapcast_player_1,
385 "snapcast_client_2": snapcast_player_2,
386 }
387 controller._player_throttlers = {
388 "snapcast_client_1": Throttler(1, 0.05),
389 "snapcast_client_2": Throttler(1, 0.05),
390 }
391
392 # Find matching players for first Snapcast player
393 matches = controller._find_matching_protocol_players(snapcast_player_1)
394
395 # Should only match itself, NOT the other Snapcast player (same protocol domain)
396 assert len(matches) == 1
397 assert snapcast_player_1 in matches
398 assert snapcast_player_2 not in matches
399
400
401class TestGetDeviceKeyFromPlayers:
402 """Tests for device key generation."""
403
404 def test_device_key_from_mac(self, mock_mass: MagicMock) -> None:
405 """Test device key generation from MAC address.
406
407 Note: Device keys are normalized to clear the locally-administered bit
408 (bit 1 of first octet) to ensure consistent keys across protocols.
409 """
410 universal_provider = create_mock_universal_provider(mock_mass)
411
412 provider = MockProvider("airplay")
413 # Use a MAC without locally-administered bit set for cleaner test
414 # 00:BB:CC:DD:EE:FF has first byte 0x00, bit 1 = 0
415 player = MockPlayer(
416 provider,
417 "ap_123456",
418 "Test Player",
419 identifiers={IdentifierType.MAC_ADDRESS: "00:BB:CC:DD:EE:FF"},
420 )
421
422 device_key = universal_provider._get_device_key_from_players([player])
423
424 assert device_key == "00bbccddeeff"
425
426 def test_device_key_normalizes_locally_administered_mac(self, mock_mass: MagicMock) -> None:
427 """Test that device key normalizes locally-administered MACs.
428
429 A device with hardware MAC 54:78:C9:E6:0D:A0 and AirPlay MAC 56:78:C9:E6:0D:A0
430 should generate the same device key, allowing them to be merged into
431 the same universal player.
432 """
433 universal_provider = create_mock_universal_provider(mock_mass)
434
435 provider_dlna = MockProvider("dlna")
436 provider_airplay = MockProvider("airplay")
437
438 # DLNA player with real hardware MAC
439 player_dlna = MockPlayer(
440 provider_dlna,
441 "dlna_123456",
442 "WiiM Pro (DLNA)",
443 identifiers={IdentifierType.MAC_ADDRESS: "54:78:C9:E6:0D:A0"},
444 )
445
446 # AirPlay player with locally-administered MAC (bit 1 set)
447 player_airplay = MockPlayer(
448 provider_airplay,
449 "ap_123456",
450 "WiiM Pro (AirPlay)",
451 identifiers={IdentifierType.MAC_ADDRESS: "56:78:C9:E6:0D:A0"},
452 )
453
454 # Both should generate the same device key
455 key_dlna = universal_provider._get_device_key_from_players([player_dlna])
456 key_airplay = universal_provider._get_device_key_from_players([player_airplay])
457
458 # Keys should be identical (both normalized to clear locally-administered bit)
459 assert key_dlna == key_airplay
460 # The normalized MAC should have bit 1 cleared (0x54 not 0x56)
461 assert key_dlna == "5478c9e60da0"
462
463 def test_device_key_from_uuid_fallback(self, mock_mass: MagicMock) -> None:
464 """Test device key generation falls back to UUID when no MAC available."""
465 universal_provider = create_mock_universal_provider(mock_mass)
466
467 provider = MockProvider("dlna")
468 player = MockPlayer(
469 provider,
470 "dlna_123456",
471 "Test Player",
472 identifiers={IdentifierType.UUID: "uuid:12345678-1234-1234-1234-123456789abc"},
473 )
474
475 device_key = universal_provider._get_device_key_from_players([player])
476
477 assert device_key == "uuid12345678123412341234123456789abc"
478
479 def test_device_key_from_ip_falls_back_to_player_id(self, mock_mass: MagicMock) -> None:
480 """Test that device key falls back to player_id for IP-only players (IP not used)."""
481 universal_provider = create_mock_universal_provider(mock_mass)
482
483 provider = MockProvider("airplay")
484 player = MockPlayer(
485 provider,
486 "ap_123456",
487 "Test Player",
488 identifiers={IdentifierType.IP_ADDRESS: "192.168.1.100"},
489 )
490
491 device_key = universal_provider._get_device_key_from_players([player])
492
493 # IP address is not used for device key - falls back to player_id
494 # This allows protocol players without MAC/UUID to still get a UniversalPlayer
495 assert device_key == "ap_123456"
496
497 def test_device_key_from_no_identifiers_falls_back_to_player_id(
498 self, mock_mass: MagicMock
499 ) -> None:
500 """Test that device key falls back to player_id when no identifiers at all."""
501 universal_provider = create_mock_universal_provider(mock_mass)
502
503 provider = MockProvider("sendspin")
504 player = MockPlayer(
505 provider,
506 "sendspin-device-abc",
507 "Test Player",
508 # No identifiers at all (like Sendspin protocol players)
509 )
510
511 device_key = universal_provider._get_device_key_from_players([player])
512
513 # Falls back to player_id when no MAC/UUID identifiers
514 assert device_key == "sendspindeviceabc"
515
516
517class TestGetCleanPlayerName:
518 """Tests for player name selection."""
519
520 def test_prefers_chromecast_name(self, mock_mass: MagicMock) -> None:
521 """Test that Chromecast names are preferred over other protocols."""
522 universal_provider = create_mock_universal_provider(mock_mass)
523
524 airplay_provider = MockProvider("airplay")
525 chromecast_provider = MockProvider("chromecast")
526
527 airplay_player = MockPlayer(
528 airplay_provider,
529 "ap_123456",
530 "Samsung TV",
531 player_type=PlayerType.PROTOCOL,
532 )
533 chromecast_player = MockPlayer(
534 chromecast_provider,
535 "cc_123456",
536 "Living Room Speaker",
537 player_type=PlayerType.PROTOCOL,
538 )
539
540 # Chromecast should be preferred (priority 1)
541 clean_name = universal_provider._get_clean_player_name([airplay_player, chromecast_player])
542 assert clean_name == "Living Room Speaker"
543
544 def test_filters_mac_address_names(self, mock_mass: MagicMock) -> None:
545 """Test that MAC address-like names are filtered out."""
546 universal_provider = create_mock_universal_provider(mock_mass)
547
548 squeezelite_provider = MockProvider("squeezelite")
549 airplay_provider = MockProvider("airplay")
550
551 # Squeezelite with MAC address as name
552 sq_player = MockPlayer(
553 squeezelite_provider,
554 "sq_123456",
555 "AA:BB:CC:DD:EE:FF",
556 player_type=PlayerType.PROTOCOL,
557 )
558 # AirPlay with proper name
559 ap_player = MockPlayer(
560 airplay_provider,
561 "ap_123456",
562 "Kitchen Speaker",
563 player_type=PlayerType.PROTOCOL,
564 )
565
566 # Should prefer Kitchen Speaker over MAC address
567 clean_name = universal_provider._get_clean_player_name([sq_player, ap_player])
568 assert clean_name == "Kitchen Speaker"
569
570 def test_filters_player_id_names(self, mock_mass: MagicMock) -> None:
571 """Test that player ID-like names are filtered out."""
572 universal_provider = create_mock_universal_provider(mock_mass)
573
574 sendspin_provider = MockProvider("sendspin")
575 dlna_provider = MockProvider("dlna")
576
577 # SendSpin with player ID as name
578 ss_player = MockPlayer(
579 sendspin_provider,
580 "sendspin_123456",
581 "sendspin_device_abc",
582 player_type=PlayerType.PROTOCOL,
583 )
584 # DLNA with proper name
585 dlna_player = MockPlayer(
586 dlna_provider,
587 "dlna_123456",
588 "Bedroom TV",
589 player_type=PlayerType.PROTOCOL,
590 )
591
592 # Should prefer Bedroom TV over player ID
593 clean_name = universal_provider._get_clean_player_name([ss_player, dlna_player])
594 assert clean_name == "Bedroom TV"
595
596 def test_valid_name_unchanged(self, mock_mass: MagicMock) -> None:
597 """Test that valid names are returned unchanged."""
598 universal_provider = create_mock_universal_provider(mock_mass)
599
600 provider = MockProvider("airplay")
601 player = MockPlayer(
602 provider,
603 "ap_123456",
604 "HomePod Mini",
605 player_type=PlayerType.PLAYER,
606 )
607
608 clean_name = universal_provider._get_clean_player_name([player])
609 assert clean_name == "HomePod Mini"
610
611
612class TestCachedProtocolParentRestore:
613 """Tests for restoring cached protocol parent links."""
614
615 def test_protocol_parent_id_restored_from_config(self, mock_mass: MagicMock) -> None:
616 """Test that cached protocol_parent_id is loaded and used for immediate linking."""
617 controller = PlayerController(mock_mass)
618
619 # Mock config to return cached parent_id when queried
620 def mock_config_get(key: str, default: str | None = None) -> str | None:
621 if "protocol_parent_id" in str(key):
622 return "native_player_id"
623 return default
624
625 mock_mass.config.get.side_effect = mock_config_get
626
627 # Create native player
628 native_provider = MockProvider("sonos", mass=mock_mass)
629 native_player = MockPlayer(
630 native_provider,
631 "native_player_id",
632 "Sonos Speaker",
633 identifiers={IdentifierType.MAC_ADDRESS: "AA:BB:CC:DD:EE:FF"},
634 )
635
636 # Create protocol player
637 dlna_provider = MockProvider("dlna", mass=mock_mass)
638 protocol_player = MockPlayer(
639 dlna_provider,
640 "uuid:RINCON_AABBCCDDEEFF_MR",
641 "Sonos DLNA",
642 player_type=PlayerType.PROTOCOL,
643 )
644
645 # Register native player
646 controller._players = {"native_player_id": native_player}
647 controller._player_throttlers = {"native_player_id": Throttler(1, 0.05)}
648
649 # Try to link protocol to native - should load cached parent_id
650 controller._try_link_protocol_to_native(protocol_player)
651
652 # Verify protocol_parent_id was set
653 assert protocol_player.protocol_parent_id == "native_player_id"
654
655 # Verify protocol was linked to native player
656 assert any(
657 link.output_protocol_id == protocol_player.player_id
658 for link in native_player.linked_output_protocols
659 )
660
661 def test_protocol_parent_id_prevents_universal_player_creation(
662 self, mock_mass: MagicMock
663 ) -> None:
664 """Test that cached protocol_parent_id prevents creating universal player."""
665 controller = PlayerController(mock_mass)
666
667 # Mock config to return cached parent_id (parent not yet registered)
668 def mock_config_get(key: str, default: str | None = None) -> str | None:
669 if "protocol_parent_id" in str(key):
670 return "native_player_id"
671 return default
672
673 mock_mass.config.get.side_effect = mock_config_get
674
675 # Create protocol player
676 dlna_provider = MockProvider("dlna", mass=mock_mass)
677 protocol_player = MockPlayer(
678 dlna_provider,
679 "uuid:RINCON_AABBCCDDEEFF_MR",
680 "Sonos DLNA",
681 player_type=PlayerType.PROTOCOL,
682 )
683
684 # No native player registered yet
685 controller._players = {}
686
687 # Try to link protocol - should set parent_id and skip evaluation
688 controller._try_link_protocol_to_native(protocol_player)
689
690 # Verify protocol_parent_id was set
691 assert protocol_player.protocol_parent_id == "native_player_id"
692
693 # Since parent_id is set, delayed evaluation won't create a universal player
694
695
696class TestSelectBestOutputProtocol:
697 """Tests for output protocol selection logic."""
698
699 def test_select_native_when_preferred_is_native(self, mock_mass: MagicMock) -> None:
700 """Test that native protocol is selected when user prefers native."""
701 # Mock config to return "native" as preferred
702 mock_mass.config.get_raw_player_config_value = MagicMock(return_value="native")
703
704 controller = PlayerController(mock_mass)
705 provider = MockProvider("sonos", mass=mock_mass)
706
707 # Create native player with PLAY_MEDIA support
708 native_player = MockPlayer(
709 provider,
710 "sonos_123",
711 "Kantoor",
712 identifiers={IdentifierType.MAC_ADDRESS: "AA:BB:CC:DD:EE:FF"},
713 )
714 native_player._attr_supported_features.add(PlayerFeature.PLAY_MEDIA)
715
716 # Wire up mock_mass.players to controller
717 mock_mass.players = controller
718
719 # Register players
720 controller._players = {"sonos_123": native_player}
721 controller._player_throttlers = {"sonos_123": Throttler(1, 0.05)}
722
723 # Select protocol
724 selected_player, output_protocol = controller._select_best_output_protocol(native_player)
725
726 # Should select native player
727 assert selected_player == native_player
728 assert output_protocol is None # None means native playback
729
730 def test_select_dlna_when_preferred_is_dlna(self, mock_mass: MagicMock) -> None:
731 """Test that DLNA protocol is selected when user prefers DLNA."""
732 # Mock config to return the full player ID as preferred
733 mock_mass.config.get_raw_player_config_value = MagicMock(return_value="dlna_AABBCCDDEEFF")
734
735 controller = PlayerController(mock_mass)
736
737 # Create native player with linked protocols
738 sonos_provider = MockProvider("sonos", mass=mock_mass)
739 native_player = MockPlayer(
740 sonos_provider,
741 "sonos_123",
742 "Kantoor",
743 identifiers={IdentifierType.MAC_ADDRESS: "AA:BB:CC:DD:EE:FF"},
744 )
745 native_player._attr_supported_features.add(PlayerFeature.PLAY_MEDIA)
746
747 # Create DLNA protocol player
748 dlna_provider = MockProvider("dlna", mass=mock_mass)
749 dlna_player = MockPlayer(
750 dlna_provider,
751 "dlna_AABBCCDDEEFF",
752 "Kantoor DLNA",
753 player_type=PlayerType.PROTOCOL,
754 identifiers={IdentifierType.MAC_ADDRESS: "AA:BB:CC:DD:EE:FF"},
755 )
756
757 # Register players
758 controller._players = {
759 "sonos_123": native_player,
760 "dlna_AABBCCDDEEFF": dlna_player,
761 }
762 controller._player_throttlers = {
763 "sonos_123": Throttler(1, 0.05),
764 "dlna_AABBCCDDEEFF": Throttler(1, 0.05),
765 }
766
767 # Link DLNA protocol to native player
768 native_player.set_linked_output_protocols(
769 [
770 OutputProtocol(
771 output_protocol_id="dlna_AABBCCDDEEFF",
772 name="DLNA",
773 protocol_domain="dlna",
774 priority=30,
775 )
776 ]
777 )
778
779 # Select protocol
780 selected_player, output_protocol = controller._select_best_output_protocol(native_player)
781
782 # Should select DLNA player, not native
783 assert selected_player == dlna_player
784 assert output_protocol is not None
785 assert output_protocol.output_protocol_id == "dlna_AABBCCDDEEFF"
786
787 def test_select_airplay_when_preferred_is_airplay(self, mock_mass: MagicMock) -> None:
788 """Test that AirPlay protocol is selected when user prefers AirPlay."""
789 # Mock config to return the full player ID as preferred
790 mock_mass.config.get_raw_player_config_value = MagicMock(
791 return_value="airplay_AABBCCDDEEFF"
792 )
793
794 controller = PlayerController(mock_mass)
795
796 # Create native player
797 sonos_provider = MockProvider("sonos", mass=mock_mass)
798 native_player = MockPlayer(
799 sonos_provider,
800 "sonos_123",
801 "Kantoor",
802 identifiers={IdentifierType.MAC_ADDRESS: "AA:BB:CC:DD:EE:FF"},
803 )
804 native_player._attr_supported_features.add(PlayerFeature.PLAY_MEDIA)
805
806 # Create AirPlay and DLNA protocol players
807 airplay_provider = MockProvider("airplay", mass=mock_mass)
808 airplay_player = MockPlayer(
809 airplay_provider,
810 "airplay_AABBCCDDEEFF",
811 "Kantoor AirPlay",
812 player_type=PlayerType.PROTOCOL,
813 identifiers={IdentifierType.MAC_ADDRESS: "AA:BB:CC:DD:EE:FF"},
814 )
815
816 dlna_provider = MockProvider("dlna", mass=mock_mass)
817 dlna_player = MockPlayer(
818 dlna_provider,
819 "dlna_AABBCCDDEEFF",
820 "Kantoor DLNA",
821 player_type=PlayerType.PROTOCOL,
822 identifiers={IdentifierType.MAC_ADDRESS: "AA:BB:CC:DD:EE:FF"},
823 )
824
825 # Register players
826 controller._players = {
827 "sonos_123": native_player,
828 "airplay_AABBCCDDEEFF": airplay_player,
829 "dlna_AABBCCDDEEFF": dlna_player,
830 }
831 controller._player_throttlers = {
832 "sonos_123": Throttler(1, 0.05),
833 "airplay_AABBCCDDEEFF": Throttler(1, 0.05),
834 "dlna_AABBCCDDEEFF": Throttler(1, 0.05),
835 }
836
837 # Link protocols to native player
838 native_player.set_linked_output_protocols(
839 [
840 OutputProtocol(
841 output_protocol_id="airplay_AABBCCDDEEFF",
842 name="AirPlay",
843 protocol_domain="airplay",
844 priority=10,
845 ),
846 OutputProtocol(
847 output_protocol_id="dlna_AABBCCDDEEFF",
848 name="DLNA",
849 protocol_domain="dlna",
850 priority=30,
851 ),
852 ]
853 )
854
855 # Select protocol
856 selected_player, output_protocol = controller._select_best_output_protocol(native_player)
857
858 # Should select AirPlay player (even though DLNA has lower priority value),
859 # because user preference overrides priority
860 assert selected_player == airplay_player
861 assert output_protocol is not None
862 assert output_protocol.output_protocol_id == "airplay_AABBCCDDEEFF"
863
864 def test_fallback_to_native_when_auto(self, mock_mass: MagicMock) -> None:
865 """Test that native playback is used when preference is auto."""
866 # Mock config to return "auto" as preferred
867 mock_mass.config.get_raw_player_config_value = MagicMock(return_value="auto")
868
869 controller = PlayerController(mock_mass)
870 provider = MockProvider("sonos", mass=mock_mass)
871
872 native_player = MockPlayer(
873 provider,
874 "sonos_123",
875 "Kantoor",
876 identifiers={IdentifierType.MAC_ADDRESS: "AA:BB:CC:DD:EE:FF"},
877 )
878 native_player._attr_supported_features.add(PlayerFeature.PLAY_MEDIA)
879
880 controller._players = {"sonos_123": native_player}
881 controller._player_throttlers = {"sonos_123": Throttler(1, 0.05)}
882
883 # Select protocol with auto preference
884 selected_player, output_protocol = controller._select_best_output_protocol(native_player)
885
886 # Should select native player
887 assert selected_player == native_player
888 assert output_protocol is None # None means native playback
889
890
891class TestPlayerGrouping:
892 """Tests for player grouping scenarios."""
893
894 def test_native_to_native_grouping(self, mock_mass: MagicMock) -> None:
895 """Test that native players from same provider can group together."""
896 controller = PlayerController(mock_mass)
897
898 sonos_provider = MockProvider("sonos", mass=mock_mass)
899
900 # Create two Sonos players
901 player_a = MockPlayer(
902 sonos_provider,
903 "sonos_123",
904 "Living Room",
905 identifiers={IdentifierType.MAC_ADDRESS: "AA:BB:CC:DD:EE:01"},
906 )
907 player_a._attr_supported_features.add(PlayerFeature.SET_MEMBERS)
908 player_a._attr_supported_features.add(PlayerFeature.PLAY_MEDIA)
909 player_a._attr_can_group_with = {"sonos_456"}
910 player_a._cache.clear() # Clear cached properties after modifying attributes
911
912 player_b = MockPlayer(
913 sonos_provider,
914 "sonos_456",
915 "Kitchen",
916 identifiers={IdentifierType.MAC_ADDRESS: "AA:BB:CC:DD:EE:02"},
917 )
918 player_b._attr_supported_features.add(PlayerFeature.PLAY_MEDIA)
919 player_b._cache.clear()
920
921 controller._players = {
922 "sonos_123": player_a,
923 "sonos_456": player_b,
924 }
925 controller._player_throttlers = {
926 "sonos_123": Throttler(1, 0.05),
927 "sonos_456": Throttler(1, 0.05),
928 }
929
930 # Translate members for native grouping
931 protocol_members, native_members, _, _ = controller._translate_members_for_protocols(
932 parent_player=player_a,
933 player_ids=["sonos_456"],
934 parent_protocol_player=None,
935 parent_protocol_domain=None,
936 )
937
938 # Should use native grouping (same provider)
939 assert len(native_members) == 1
940 assert "sonos_456" in native_members
941 assert len(protocol_members) == 0
942
943 def test_protocol_to_protocol_grouping(self, mock_mass: MagicMock) -> None:
944 """Test that protocol players can group via shared protocol."""
945 controller = PlayerController(mock_mass)
946
947 # Create two players with AirPlay protocol support
948 sonos_provider = MockProvider("sonos", mass=mock_mass)
949 wiim_provider = MockProvider("wiim", mass=mock_mass)
950 airplay_provider = MockProvider("airplay", mass=mock_mass)
951
952 # Sonos player
953 sonos_player = MockPlayer(
954 sonos_provider,
955 "sonos_123",
956 "Living Room",
957 identifiers={IdentifierType.MAC_ADDRESS: "AA:BB:CC:DD:EE:01"},
958 )
959 sonos_player._attr_supported_features.add(PlayerFeature.PLAY_MEDIA)
960 sonos_player._cache.clear()
961
962 # WiiM player
963 wiim_player = MockPlayer(
964 wiim_provider,
965 "wiim_456",
966 "Bedroom",
967 identifiers={IdentifierType.MAC_ADDRESS: "AA:BB:CC:DD:EE:02"},
968 )
969 wiim_player._attr_supported_features.add(PlayerFeature.PLAY_MEDIA)
970 wiim_player._cache.clear()
971
972 # AirPlay protocol players
973 sonos_airplay = MockPlayer(
974 airplay_provider,
975 "airplay_sonos",
976 "Living Room (AirPlay)",
977 player_type=PlayerType.PROTOCOL,
978 identifiers={IdentifierType.MAC_ADDRESS: "AA:BB:CC:DD:EE:01"},
979 )
980 sonos_airplay._attr_supported_features.add(PlayerFeature.SET_MEMBERS)
981 sonos_airplay._attr_can_group_with = {"airplay_wiim"}
982 sonos_airplay._cache.clear()
983 sonos_airplay.update_state(signal_event=False)
984
985 wiim_airplay = MockPlayer(
986 airplay_provider,
987 "airplay_wiim",
988 "Bedroom (AirPlay)",
989 player_type=PlayerType.PROTOCOL,
990 identifiers={IdentifierType.MAC_ADDRESS: "AA:BB:CC:DD:EE:02"},
991 )
992
993 # Link protocol players to native players
994 sonos_player.set_linked_output_protocols(
995 [
996 OutputProtocol(
997 output_protocol_id="airplay_sonos",
998 name="AirPlay",
999 protocol_domain="airplay",
1000 priority=10,
1001 available=True,
1002 )
1003 ]
1004 )
1005 wiim_player.set_linked_output_protocols(
1006 [
1007 OutputProtocol(
1008 output_protocol_id="airplay_wiim",
1009 name="AirPlay",
1010 protocol_domain="airplay",
1011 priority=10,
1012 available=True,
1013 )
1014 ]
1015 )
1016
1017 controller._players = {
1018 "sonos_123": sonos_player,
1019 "wiim_456": wiim_player,
1020 "airplay_sonos": sonos_airplay,
1021 "airplay_wiim": wiim_airplay,
1022 }
1023 controller._player_throttlers = {
1024 "sonos_123": Throttler(1, 0.05),
1025 "wiim_456": Throttler(1, 0.05),
1026 "airplay_sonos": Throttler(1, 0.05),
1027 "airplay_wiim": Throttler(1, 0.05),
1028 }
1029
1030 # Translate members for protocol grouping (via AirPlay)
1031 protocol_members, native_members, protocol_player, _ = (
1032 controller._translate_members_for_protocols(
1033 parent_player=sonos_player,
1034 player_ids=["wiim_456"],
1035 parent_protocol_player=sonos_airplay,
1036 parent_protocol_domain="airplay",
1037 )
1038 )
1039
1040 # Should use protocol grouping (AirPlay)
1041 assert len(protocol_members) == 1
1042 assert "airplay_wiim" in protocol_members
1043 assert len(native_members) == 0
1044 assert protocol_player == sonos_airplay
1045
1046 def test_hybrid_grouping(self, mock_mass: MagicMock) -> None:
1047 """Test hybrid grouping: native + protocol players in same group."""
1048 controller = PlayerController(mock_mass)
1049
1050 # Create Sonos players (native grouping capability)
1051 sonos_provider = MockProvider("sonos", instance_id="sonos_instance", mass=mock_mass)
1052 sonos_a = MockPlayer(
1053 sonos_provider,
1054 "sonos_123",
1055 "Living Room",
1056 identifiers={IdentifierType.MAC_ADDRESS: "AA:BB:CC:DD:EE:01"},
1057 )
1058 sonos_a._attr_supported_features.add(PlayerFeature.SET_MEMBERS)
1059 sonos_a._attr_supported_features.add(PlayerFeature.PLAY_MEDIA)
1060 sonos_a._attr_can_group_with = {"sonos_456"}
1061 sonos_a._cache.clear()
1062
1063 sonos_b = MockPlayer(
1064 sonos_provider,
1065 "sonos_456",
1066 "Kitchen",
1067 identifiers={IdentifierType.MAC_ADDRESS: "AA:BB:CC:DD:EE:02"},
1068 )
1069 sonos_b._attr_supported_features.add(PlayerFeature.PLAY_MEDIA)
1070 sonos_b._cache.clear()
1071
1072 # Create WiiM player with AirPlay protocol
1073 wiim_provider = MockProvider("wiim", instance_id="wiim_instance", mass=mock_mass)
1074 airplay_provider = MockProvider("airplay", instance_id="airplay_instance", mass=mock_mass)
1075
1076 wiim_player = MockPlayer(
1077 wiim_provider,
1078 "wiim_789",
1079 "Bedroom",
1080 identifiers={IdentifierType.MAC_ADDRESS: "AA:BB:CC:DD:EE:03"},
1081 )
1082 wiim_player._attr_supported_features.add(PlayerFeature.PLAY_MEDIA)
1083 wiim_player._cache.clear()
1084
1085 # AirPlay protocol players
1086 sonos_airplay = MockPlayer(
1087 airplay_provider,
1088 "airplay_sonos",
1089 "Living Room (AirPlay)",
1090 player_type=PlayerType.PROTOCOL,
1091 identifiers={IdentifierType.MAC_ADDRESS: "AA:BB:CC:DD:EE:01"},
1092 )
1093 sonos_airplay._attr_supported_features.add(PlayerFeature.SET_MEMBERS)
1094 sonos_airplay._attr_can_group_with = {"airplay_wiim"}
1095 sonos_airplay._cache.clear()
1096 sonos_airplay.update_state(signal_event=False)
1097
1098 wiim_airplay = MockPlayer(
1099 airplay_provider,
1100 "airplay_wiim",
1101 "Bedroom (AirPlay)",
1102 player_type=PlayerType.PROTOCOL,
1103 identifiers={IdentifierType.MAC_ADDRESS: "AA:BB:CC:DD:EE:03"},
1104 )
1105
1106 # Link AirPlay to Sonos A
1107 sonos_a.set_linked_output_protocols(
1108 [
1109 OutputProtocol(
1110 output_protocol_id="airplay_sonos",
1111 name="AirPlay",
1112 protocol_domain="airplay",
1113 priority=10,
1114 available=True,
1115 )
1116 ]
1117 )
1118 wiim_player.set_linked_output_protocols(
1119 [
1120 OutputProtocol(
1121 output_protocol_id="airplay_wiim",
1122 name="AirPlay",
1123 protocol_domain="airplay",
1124 priority=10,
1125 available=True,
1126 )
1127 ]
1128 )
1129 wiim_player.set_active_output_protocol("airplay_wiim")
1130 wiim_player.set_protocol_parent_id("airplay_wiim")
1131
1132 # Wire up mock_mass.players to controller so get_linked_protocol works
1133 mock_mass.players = controller
1134
1135 controller._players = {
1136 "sonos_123": sonos_a,
1137 "sonos_456": sonos_b,
1138 "wiim_789": wiim_player,
1139 "airplay_sonos": sonos_airplay,
1140 "airplay_wiim": wiim_airplay,
1141 }
1142 controller._player_throttlers = {
1143 "sonos_123": Throttler(1, 0.05),
1144 "sonos_456": Throttler(1, 0.05),
1145 "wiim_789": Throttler(1, 0.05),
1146 "airplay_sonos": Throttler(1, 0.05),
1147 "airplay_wiim": Throttler(1, 0.05),
1148 }
1149
1150 # Group Sonos B (native) + WiiM (via AirPlay) to Sonos A
1151 protocol_members, native_members, _protocol_player, _ = (
1152 controller._translate_members_for_protocols(
1153 parent_player=sonos_a,
1154 player_ids=["sonos_456", "wiim_789"],
1155 parent_protocol_player=sonos_airplay,
1156 parent_protocol_domain="airplay",
1157 )
1158 )
1159
1160 # Should have hybrid group: native Sonos B + protocol WiiM
1161 assert len(native_members) == 1
1162 assert "sonos_456" in native_members
1163 assert len(protocol_members) == 1
1164 assert "airplay_wiim" in protocol_members
1165
1166 def test_protocol_selection_requires_set_members(self, mock_mass: MagicMock) -> None:
1167 """Test that only protocols with SET_MEMBERS support are selected for grouping."""
1168 controller = PlayerController(mock_mass)
1169
1170 sonos_provider = MockProvider("sonos", mass=mock_mass)
1171 wiim_provider = MockProvider("wiim", mass=mock_mass)
1172 dlna_provider = MockProvider("dlna", mass=mock_mass)
1173 airplay_provider = MockProvider("airplay", mass=mock_mass)
1174
1175 # Sonos player
1176 sonos_player = MockPlayer(
1177 sonos_provider,
1178 "sonos_123",
1179 "Living Room",
1180 identifiers={IdentifierType.MAC_ADDRESS: "AA:BB:CC:DD:EE:01"},
1181 )
1182 sonos_player._attr_supported_features.add(PlayerFeature.PLAY_MEDIA)
1183 sonos_player._cache.clear()
1184
1185 # WiiM player
1186 wiim_player = MockPlayer(
1187 wiim_provider,
1188 "wiim_456",
1189 "Bedroom",
1190 identifiers={IdentifierType.MAC_ADDRESS: "AA:BB:CC:DD:EE:02"},
1191 )
1192 wiim_player._attr_supported_features.add(PlayerFeature.PLAY_MEDIA)
1193 wiim_player._cache.clear()
1194
1195 # DLNA protocol (does NOT support SET_MEMBERS)
1196 sonos_dlna = MockPlayer(
1197 dlna_provider,
1198 "dlna_sonos",
1199 "Living Room (DLNA)",
1200 player_type=PlayerType.PROTOCOL,
1201 identifiers={IdentifierType.MAC_ADDRESS: "AA:BB:CC:DD:EE:01"},
1202 )
1203 # Note: NO SET_MEMBERS feature
1204
1205 wiim_dlna = MockPlayer(
1206 dlna_provider,
1207 "dlna_wiim",
1208 "Bedroom (DLNA)",
1209 player_type=PlayerType.PROTOCOL,
1210 identifiers={IdentifierType.MAC_ADDRESS: "AA:BB:CC:DD:EE:02"},
1211 )
1212
1213 # AirPlay protocol (DOES support SET_MEMBERS)
1214 sonos_airplay = MockPlayer(
1215 airplay_provider,
1216 "airplay_sonos",
1217 "Living Room (AirPlay)",
1218 player_type=PlayerType.PROTOCOL,
1219 identifiers={IdentifierType.MAC_ADDRESS: "AA:BB:CC:DD:EE:01"},
1220 )
1221 sonos_airplay._attr_supported_features.add(PlayerFeature.SET_MEMBERS)
1222 sonos_airplay._attr_can_group_with = {"airplay_wiim"}
1223 sonos_airplay._cache.clear()
1224
1225 wiim_airplay = MockPlayer(
1226 airplay_provider,
1227 "airplay_wiim",
1228 "Bedroom (AirPlay)",
1229 player_type=PlayerType.PROTOCOL,
1230 identifiers={IdentifierType.MAC_ADDRESS: "AA:BB:CC:DD:EE:02"},
1231 )
1232 wiim_airplay._attr_supported_features.add(PlayerFeature.SET_MEMBERS)
1233 wiim_airplay._attr_can_group_with = {"airplay_sonos"}
1234 wiim_airplay._cache.clear()
1235
1236 # Link protocols (DLNA has lower priority than AirPlay)
1237 sonos_player.set_linked_output_protocols(
1238 [
1239 OutputProtocol(
1240 output_protocol_id="dlna_sonos",
1241 name="DLNA",
1242 protocol_domain="dlna",
1243 priority=30, # Lower priority (higher number)
1244 available=True,
1245 ),
1246 OutputProtocol(
1247 output_protocol_id="airplay_sonos",
1248 name="AirPlay",
1249 protocol_domain="airplay",
1250 priority=10, # Higher priority (lower number)
1251 available=True,
1252 ),
1253 ]
1254 )
1255 wiim_player.set_linked_output_protocols(
1256 [
1257 OutputProtocol(
1258 output_protocol_id="dlna_wiim",
1259 name="DLNA",
1260 protocol_domain="dlna",
1261 priority=30,
1262 available=True,
1263 ),
1264 OutputProtocol(
1265 output_protocol_id="airplay_wiim",
1266 name="AirPlay",
1267 protocol_domain="airplay",
1268 priority=10,
1269 available=True,
1270 ),
1271 ]
1272 )
1273
1274 mock_mass.players = controller
1275 controller._players = {
1276 "sonos_123": sonos_player,
1277 "wiim_456": wiim_player,
1278 "dlna_sonos": sonos_dlna,
1279 "dlna_wiim": wiim_dlna,
1280 "airplay_sonos": sonos_airplay,
1281 "airplay_wiim": wiim_airplay,
1282 }
1283 controller._player_throttlers = {
1284 "sonos_123": Throttler(1, 0.05),
1285 "wiim_456": Throttler(1, 0.05),
1286 "dlna_sonos": Throttler(1, 0.05),
1287 "dlna_wiim": Throttler(1, 0.05),
1288 "airplay_sonos": Throttler(1, 0.05),
1289 "airplay_wiim": Throttler(1, 0.05),
1290 }
1291
1292 # Update state after modifying attributes
1293 sonos_dlna.update_state(signal_event=False)
1294 wiim_dlna.update_state(signal_event=False)
1295 sonos_airplay.update_state(signal_event=False)
1296 wiim_airplay.update_state(signal_event=False)
1297
1298 # Translate members - should skip DLNA (no SET_MEMBERS) and select AirPlay
1299 protocol_members, _native_members, protocol_player, protocol_domain = (
1300 controller._translate_members_for_protocols(
1301 parent_player=sonos_player,
1302 player_ids=["wiim_456"],
1303 parent_protocol_player=None,
1304 parent_protocol_domain=None,
1305 )
1306 )
1307
1308 # Should select AirPlay (supports SET_MEMBERS) not DLNA
1309 assert len(protocol_members) == 1
1310 assert "airplay_wiim" in protocol_members
1311 assert protocol_domain == "airplay"
1312 assert protocol_player == sonos_airplay
1313
1314
1315class TestCanGroupWith:
1316 """Tests for can_group_with property with three scenarios."""
1317
1318 def test_scenario_1_native_active_only_native_players(self, mock_mass: MagicMock) -> None:
1319 """Test Scenario 1: Native playback active -> all protocols shown (new behavior)."""
1320 controller = PlayerController(mock_mass)
1321
1322 sonos_provider = MockProvider("sonos", instance_id="sonos_instance", mass=mock_mass)
1323 airplay_provider = MockProvider("airplay", instance_id="airplay_instance", mass=mock_mass)
1324
1325 # Create Sonos player with native and AirPlay support
1326 sonos_player = MockPlayer(
1327 sonos_provider,
1328 "sonos_123",
1329 "Living Room",
1330 identifiers={IdentifierType.MAC_ADDRESS: "AA:BB:CC:DD:EE:01"},
1331 )
1332 sonos_player._attr_supported_features.add(PlayerFeature.PLAY_MEDIA)
1333 sonos_player._attr_supported_features.add(PlayerFeature.SET_MEMBERS)
1334 sonos_player._attr_can_group_with = {"sonos_456"}
1335 sonos_player._cache.clear()
1336 sonos_player.set_active_output_protocol("native")
1337
1338 # Create another Sonos player
1339 sonos_player_b = MockPlayer(
1340 sonos_provider,
1341 "sonos_456",
1342 "Kitchen",
1343 identifiers={IdentifierType.MAC_ADDRESS: "AA:BB:CC:DD:EE:02"},
1344 )
1345
1346 # Create AirPlay protocol player
1347 sonos_airplay = MockPlayer(
1348 airplay_provider,
1349 "airplay_sonos",
1350 "Living Room (AirPlay)",
1351 player_type=PlayerType.PROTOCOL,
1352 identifiers={IdentifierType.MAC_ADDRESS: "AA:BB:CC:DD:EE:01"},
1353 )
1354 sonos_airplay._attr_supported_features.add(PlayerFeature.SET_MEMBERS)
1355 sonos_airplay._attr_can_group_with = {"airplay_other"}
1356 sonos_airplay._cache.clear()
1357 sonos_airplay.set_protocol_parent_id("sonos_123")
1358
1359 sonos_player.set_linked_output_protocols(
1360 [
1361 OutputProtocol(
1362 output_protocol_id="airplay_sonos",
1363 name="AirPlay",
1364 protocol_domain="airplay",
1365 priority=10,
1366 available=True,
1367 )
1368 ]
1369 )
1370
1371 # Wire up mock_mass.players to controller so get_linked_protocol works
1372 mock_mass.players = controller
1373
1374 controller._players = {
1375 "sonos_123": sonos_player,
1376 "sonos_456": sonos_player_b,
1377 "airplay_sonos": sonos_airplay,
1378 }
1379 controller._player_throttlers = {
1380 "sonos_123": Throttler(1, 0.05),
1381 "sonos_456": Throttler(1, 0.05),
1382 "airplay_sonos": Throttler(1, 0.05),
1383 }
1384
1385 # Update state after modifying attributes and registering with controller
1386 sonos_player.update_state(signal_event=False)
1387 sonos_player_b.update_state(signal_event=False)
1388 sonos_airplay.update_state(signal_event=False)
1389
1390 # Get can_group_with while native is active
1391 groupable = sonos_player.state.can_group_with
1392
1393 # NEW BEHAVIOR: Should show both native AND protocol players
1394 # even when native protocol is active
1395 assert "sonos_456" in groupable # Native Sonos player
1396 # Note: airplay_other is not registered in controller._players, so it won't appear
1397 # But the logic should still allow showing AirPlay options if they were registered
1398
1399 def test_scenario_2_protocol_active_hybrid_groups(self, mock_mass: MagicMock) -> None:
1400 """Test Scenario 2: Protocol active -> show all protocols (new behavior)."""
1401 controller = PlayerController(mock_mass)
1402
1403 sonos_provider = MockProvider("sonos", instance_id="sonos_instance", mass=mock_mass)
1404 airplay_provider = MockProvider("airplay", instance_id="airplay_instance", mass=mock_mass)
1405
1406 # Create Sonos player with AirPlay active
1407 sonos_player = MockPlayer(
1408 sonos_provider,
1409 "sonos_123",
1410 "Living Room",
1411 identifiers={IdentifierType.MAC_ADDRESS: "AA:BB:CC:DD:EE:01"},
1412 )
1413 sonos_player._attr_supported_features.add(PlayerFeature.PLAY_MEDIA)
1414 sonos_player._attr_supported_features.add(PlayerFeature.SET_MEMBERS)
1415 sonos_player._attr_can_group_with = {"sonos_456"}
1416 sonos_player._cache.clear()
1417
1418 # Create another Sonos player
1419 sonos_player_b = MockPlayer(
1420 sonos_provider,
1421 "sonos_456",
1422 "Kitchen",
1423 identifiers={IdentifierType.MAC_ADDRESS: "AA:BB:CC:DD:EE:02"},
1424 )
1425
1426 # Create AirPlay protocol player
1427 sonos_airplay = MockPlayer(
1428 airplay_provider,
1429 "airplay_sonos",
1430 "Living Room (AirPlay)",
1431 player_type=PlayerType.PROTOCOL,
1432 identifiers={IdentifierType.MAC_ADDRESS: "AA:BB:CC:DD:EE:01"},
1433 )
1434 sonos_airplay._attr_supported_features.add(PlayerFeature.SET_MEMBERS)
1435 sonos_airplay._attr_can_group_with = {"airplay_other"}
1436 sonos_airplay._cache.clear()
1437 sonos_airplay.set_protocol_parent_id("sonos_123")
1438
1439 # Create another device with AirPlay
1440 wiim_player = MockPlayer(
1441 sonos_provider,
1442 "wiim_789",
1443 "Bedroom",
1444 identifiers={IdentifierType.MAC_ADDRESS: "AA:BB:CC:DD:EE:03"},
1445 )
1446
1447 airplay_other = MockPlayer(
1448 airplay_provider,
1449 "airplay_other",
1450 "Bedroom (AirPlay)",
1451 player_type=PlayerType.PROTOCOL,
1452 identifiers={IdentifierType.MAC_ADDRESS: "AA:BB:CC:DD:EE:03"},
1453 )
1454 airplay_other._attr_supported_features.add(PlayerFeature.SET_MEMBERS)
1455 airplay_other._attr_can_group_with = {"airplay_sonos"}
1456 airplay_other._cache.clear()
1457 airplay_other.set_protocol_parent_id("wiim_789")
1458
1459 wiim_player.set_linked_output_protocols(
1460 [
1461 OutputProtocol(
1462 output_protocol_id="airplay_other",
1463 name="AirPlay",
1464 protocol_domain="airplay",
1465 priority=10,
1466 available=True,
1467 ),
1468 ]
1469 )
1470
1471 sonos_player.set_linked_output_protocols(
1472 [
1473 OutputProtocol(
1474 output_protocol_id="airplay_sonos",
1475 name="AirPlay",
1476 protocol_domain="airplay",
1477 priority=10,
1478 available=True,
1479 )
1480 ]
1481 )
1482 sonos_player.set_active_output_protocol("airplay_sonos")
1483
1484 # Wire up mock_mass.players to controller so get_linked_protocol works
1485 mock_mass.players = controller
1486
1487 controller._players = {
1488 "sonos_123": sonos_player,
1489 "sonos_456": sonos_player_b,
1490 "wiim_789": wiim_player,
1491 "airplay_sonos": sonos_airplay,
1492 "airplay_other": airplay_other,
1493 }
1494 controller._player_throttlers = {
1495 "sonos_123": Throttler(1, 0.05),
1496 "sonos_456": Throttler(1, 0.05),
1497 "wiim_789": Throttler(1, 0.05),
1498 "airplay_sonos": Throttler(1, 0.05),
1499 "airplay_other": Throttler(1, 0.05),
1500 }
1501
1502 # Clear cache after setting linked protocols
1503 sonos_player._cache.clear()
1504 wiim_player._cache.clear()
1505
1506 # Update state after modifying attributes and registering with controller
1507 # IMPORTANT: Update protocol players FIRST, then parent players
1508 sonos_airplay.update_state(signal_event=False)
1509 airplay_other.update_state(signal_event=False)
1510 sonos_player.update_state(signal_event=False)
1511 sonos_player_b.update_state(signal_event=False)
1512 wiim_player.update_state(signal_event=False)
1513
1514 # Get can_group_with while AirPlay is active
1515 groupable = sonos_player.state.can_group_with
1516
1517 # NEW BEHAVIOR: Should show ALL protocols + native players
1518 # regardless of which protocol is active
1519 assert "sonos_456" in groupable # Native Sonos player
1520 assert "wiim_789" in groupable # Via airplay_other protocol
1521
1522 def test_scenario_3_no_active_output_all_protocols_shown(self, mock_mass: MagicMock) -> None:
1523 """Test Scenario 3: No active output -> show all compatible protocols + native."""
1524 controller = PlayerController(mock_mass)
1525
1526 sonos_provider = MockProvider("sonos", instance_id="sonos_instance", mass=mock_mass)
1527 airplay_provider = MockProvider("airplay", instance_id="airplay_instance", mass=mock_mass)
1528 dlna_provider = MockProvider("dlna", instance_id="dlna_instance", mass=mock_mass)
1529
1530 # Create Sonos player (no active protocol)
1531 sonos_player = MockPlayer(
1532 sonos_provider,
1533 "sonos_123",
1534 "Living Room",
1535 identifiers={IdentifierType.MAC_ADDRESS: "AA:BB:CC:DD:EE:01"},
1536 )
1537 sonos_player._attr_supported_features.add(PlayerFeature.PLAY_MEDIA)
1538 sonos_player._attr_supported_features.add(PlayerFeature.SET_MEMBERS)
1539 sonos_player._attr_can_group_with = {"sonos_456"}
1540 sonos_player._cache.clear()
1541 # No active output protocol set
1542
1543 # Create another Sonos player
1544 sonos_player_b = MockPlayer(
1545 sonos_provider,
1546 "sonos_456",
1547 "Kitchen",
1548 identifiers={IdentifierType.MAC_ADDRESS: "AA:BB:CC:DD:EE:02"},
1549 )
1550
1551 # Create AirPlay protocol player (supports SET_MEMBERS)
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._attr_can_group_with = {"airplay_other"}
1561 sonos_airplay._cache.clear()
1562 sonos_airplay.set_protocol_parent_id("sonos_123")
1563
1564 # Create DLNA protocol player (does NOT support SET_MEMBERS)
1565 sonos_dlna = MockPlayer(
1566 dlna_provider,
1567 "dlna_sonos",
1568 "Living Room (DLNA)",
1569 player_type=PlayerType.PROTOCOL,
1570 identifiers={IdentifierType.MAC_ADDRESS: "AA:BB:CC:DD:EE:01"},
1571 )
1572 # No SET_MEMBERS support
1573 sonos_dlna._attr_can_group_with = {"dlna_other"}
1574 sonos_dlna.set_protocol_parent_id("sonos_123")
1575
1576 # Another device
1577 wiim_player = MockPlayer(
1578 sonos_provider,
1579 "wiim_789",
1580 "Bedroom",
1581 identifiers={IdentifierType.MAC_ADDRESS: "AA:BB:CC:DD:EE:03"},
1582 )
1583
1584 airplay_other = MockPlayer(
1585 airplay_provider,
1586 "airplay_other",
1587 "Bedroom (AirPlay)",
1588 player_type=PlayerType.PROTOCOL,
1589 identifiers={IdentifierType.MAC_ADDRESS: "AA:BB:CC:DD:EE:03"},
1590 )
1591 airplay_other._attr_supported_features.add(PlayerFeature.SET_MEMBERS)
1592 airplay_other._attr_can_group_with = {"airplay_sonos"}
1593 airplay_other._cache.clear()
1594 airplay_other.set_protocol_parent_id("wiim_789")
1595
1596 sonos_player.set_linked_output_protocols(
1597 [
1598 OutputProtocol(
1599 output_protocol_id="airplay_sonos",
1600 name="AirPlay",
1601 protocol_domain="airplay",
1602 priority=10,
1603 available=True,
1604 ),
1605 OutputProtocol(
1606 output_protocol_id="dlna_sonos",
1607 name="DLNA",
1608 protocol_domain="dlna",
1609 priority=30,
1610 available=True,
1611 ),
1612 ]
1613 )
1614
1615 wiim_player.set_linked_output_protocols(
1616 [
1617 OutputProtocol(
1618 output_protocol_id="airplay_other",
1619 name="AirPlay",
1620 protocol_domain="airplay",
1621 priority=10,
1622 available=True,
1623 ),
1624 ]
1625 )
1626
1627 # Clear cache after setting linked protocols (output_protocols is cached)
1628 sonos_player._cache.clear()
1629 wiim_player._cache.clear()
1630
1631 # Wire up mock_mass.players to controller so get_linked_protocol works
1632 mock_mass.players = controller
1633
1634 controller._players = {
1635 "sonos_123": sonos_player,
1636 "sonos_456": sonos_player_b,
1637 "wiim_789": wiim_player,
1638 "airplay_sonos": sonos_airplay,
1639 "airplay_other": airplay_other,
1640 "dlna_sonos": sonos_dlna,
1641 }
1642 controller._player_throttlers = {
1643 "sonos_123": Throttler(1, 0.05),
1644 "sonos_456": Throttler(1, 0.05),
1645 "wiim_789": Throttler(1, 0.05),
1646 "airplay_sonos": Throttler(1, 0.05),
1647 "airplay_other": Throttler(1, 0.05),
1648 "dlna_sonos": Throttler(1, 0.05),
1649 }
1650
1651 # Update state after modifying attributes and registering with controller
1652 # Note: set_linked_output_protocols calls trigger_player_update, but since mass.players
1653 # is a MagicMock, we need to manually call update_state
1654 # IMPORTANT: Update protocol players FIRST, then parent players, because parent players
1655 # access protocol_player.state.can_group_with during their update_state()
1656 sonos_airplay.update_state(signal_event=False)
1657 airplay_other.update_state(signal_event=False)
1658 sonos_dlna.update_state(signal_event=False)
1659 sonos_player.update_state(signal_event=False)
1660 sonos_player_b.update_state(signal_event=False)
1661 wiim_player.update_state(signal_event=False)
1662
1663 # Get can_group_with with no active protocol
1664 groupable = sonos_player.state.can_group_with
1665
1666 # Should show native players + AirPlay players (supports SET_MEMBERS)
1667 # but NOT DLNA players (no SET_MEMBERS support)
1668 assert "sonos_456" in groupable
1669 assert "wiim_789" in groupable # Via AirPlay protocol
1670 # DLNA players should not be shown since DLNA doesn't support SET_MEMBERS
1671
1672
1673class TestNativePlayerProtocolGrouping:
1674 """Tests for grouping between native PLAYER type and PROTOCOL type AirPlay players."""
1675
1676 def test_native_airplay_player_sees_protocol_players_as_visible_parents(
1677 self, mock_mass: MagicMock
1678 ) -> None:
1679 """Test that a native PLAYER type translates protocol players to visible parents."""
1680 controller = PlayerController(mock_mass)
1681
1682 airplay_provider = MockProvider("airplay", instance_id="airplay", mass=mock_mass)
1683 sonos_provider = MockProvider("sonos", instance_id="sonos", mass=mock_mass)
1684
1685 # HomePod: native AirPlay PLAYER (not PROTOCOL)
1686 homepod = MockPlayer(airplay_provider, "homepod_1", "Office")
1687 homepod._attr_supported_features.add(PlayerFeature.SET_MEMBERS)
1688 homepod._attr_can_group_with = {"airplay"} # Provider instance ID
1689 homepod._cache.clear()
1690
1691 # Sonos native player (visible to the user)
1692 sonos_player = MockPlayer(sonos_provider, "sonos_1", "Kitchen")
1693 sonos_player._attr_supported_features.add(PlayerFeature.SET_MEMBERS)
1694 sonos_player._cache.clear()
1695
1696 # AirPlay protocol player for the Sonos (hidden, linked to sonos_player)
1697 sonos_airplay = MockPlayer(
1698 airplay_provider,
1699 "airplay_sonos_1",
1700 "Kitchen (AirPlay)",
1701 player_type=PlayerType.PROTOCOL,
1702 )
1703 sonos_airplay._attr_supported_features.add(PlayerFeature.SET_MEMBERS)
1704 sonos_airplay._attr_can_group_with = {"airplay"}
1705 sonos_airplay._cache.clear()
1706 sonos_airplay.set_protocol_parent_id("sonos_1")
1707
1708 sonos_player.set_linked_output_protocols(
1709 [
1710 OutputProtocol(
1711 output_protocol_id="airplay_sonos_1",
1712 name="AirPlay",
1713 protocol_domain="airplay",
1714 priority=10,
1715 available=True,
1716 )
1717 ]
1718 )
1719
1720 mock_mass.players = controller
1721 mock_mass.get_provider = MagicMock(return_value=airplay_provider)
1722
1723 controller._players = {
1724 "homepod_1": homepod,
1725 "sonos_1": sonos_player,
1726 "airplay_sonos_1": sonos_airplay,
1727 }
1728 controller._player_throttlers = {
1729 "homepod_1": Throttler(1, 0.05),
1730 "sonos_1": Throttler(1, 0.05),
1731 "airplay_sonos_1": Throttler(1, 0.05),
1732 }
1733
1734 # Mark players as initialized so they are returned by all_players()
1735 homepod.set_initialized()
1736 sonos_player.set_initialized()
1737 sonos_airplay.set_initialized()
1738
1739 # Update protocol players first, then parents
1740 sonos_airplay.update_state(signal_event=False)
1741 sonos_player.update_state(signal_event=False)
1742 homepod.update_state(signal_event=False)
1743
1744 groupable = homepod.state.can_group_with
1745
1746 # HomePod should see Sonos's VISIBLE player, not the hidden protocol player
1747 assert "sonos_1" in groupable
1748 assert "airplay_sonos_1" not in groupable # Hidden protocol ID must NOT appear
1749
1750 def test_protocol_linked_player_sees_native_airplay_player(self, mock_mass: MagicMock) -> None:
1751 """Test that a player with linked AirPlay protocol sees native PLAYER type players."""
1752 controller = PlayerController(mock_mass)
1753
1754 airplay_provider = MockProvider("airplay", instance_id="airplay", mass=mock_mass)
1755 sonos_provider = MockProvider("sonos", instance_id="sonos", mass=mock_mass)
1756
1757 # HomePod: native AirPlay PLAYER
1758 homepod = MockPlayer(airplay_provider, "homepod_1", "Office")
1759 homepod._attr_supported_features.add(PlayerFeature.SET_MEMBERS)
1760 homepod._attr_can_group_with = {"airplay"}
1761 homepod._cache.clear()
1762
1763 # Sonos native player (visible to the user)
1764 sonos_player = MockPlayer(sonos_provider, "sonos_1", "Kitchen")
1765 sonos_player._attr_supported_features.add(PlayerFeature.PLAY_MEDIA)
1766 sonos_player._attr_supported_features.add(PlayerFeature.SET_MEMBERS)
1767 sonos_player._attr_can_group_with = set() # No native Sonos grouping peers
1768 sonos_player._cache.clear()
1769
1770 # AirPlay protocol player for the Sonos (hidden, linked to sonos_player)
1771 sonos_airplay = MockPlayer(
1772 airplay_provider,
1773 "airplay_sonos_1",
1774 "Kitchen (AirPlay)",
1775 player_type=PlayerType.PROTOCOL,
1776 )
1777 sonos_airplay._attr_supported_features.add(PlayerFeature.SET_MEMBERS)
1778 sonos_airplay._attr_can_group_with = {"airplay"} # Provider instance ID
1779 sonos_airplay._cache.clear()
1780 sonos_airplay.set_protocol_parent_id("sonos_1")
1781
1782 sonos_player.set_linked_output_protocols(
1783 [
1784 OutputProtocol(
1785 output_protocol_id="airplay_sonos_1",
1786 name="AirPlay",
1787 protocol_domain="airplay",
1788 priority=10,
1789 available=True,
1790 )
1791 ]
1792 )
1793
1794 mock_mass.players = controller
1795 mock_mass.get_provider = MagicMock(return_value=airplay_provider)
1796
1797 controller._players = {
1798 "homepod_1": homepod,
1799 "sonos_1": sonos_player,
1800 "airplay_sonos_1": sonos_airplay,
1801 }
1802 controller._player_throttlers = {
1803 "homepod_1": Throttler(1, 0.05),
1804 "sonos_1": Throttler(1, 0.05),
1805 "airplay_sonos_1": Throttler(1, 0.05),
1806 }
1807
1808 # Mark players as initialized so they are returned by all_players()
1809 homepod.set_initialized()
1810 sonos_player.set_initialized()
1811 sonos_airplay.set_initialized()
1812
1813 # Update protocol players first, then parents
1814 sonos_airplay.update_state(signal_event=False)
1815 homepod.update_state(signal_event=False)
1816 sonos_player.update_state(signal_event=False)
1817
1818 groupable = sonos_player.state.can_group_with
1819
1820 # Sonos should see HomePod via its linked AirPlay protocol's can_group_with
1821 assert "homepod_1" in groupable
1822
1823
1824class TestProtocolSwitchingDuringPlayback:
1825 """Tests for dynamic protocol switching when group members change during playback."""
1826
1827 async def test_no_protocol_set_during_grouping_without_playback(
1828 self, mock_mass: MagicMock
1829 ) -> None:
1830 """Test that no protocol is set when grouping players without active playback."""
1831 controller = PlayerController(mock_mass)
1832
1833 sonos_provider = MockProvider("sonos", instance_id="sonos_instance", mass=mock_mass)
1834 airplay_provider = MockProvider("airplay", instance_id="airplay_instance", mass=mock_mass)
1835
1836 # Create Sonos player with AirPlay support
1837 sonos_player = MockPlayer(
1838 sonos_provider,
1839 "sonos_123",
1840 "Living Room",
1841 identifiers={IdentifierType.MAC_ADDRESS: "AA:BB:CC:DD:EE:01"},
1842 )
1843 sonos_player._attr_supported_features.add(PlayerFeature.PLAY_MEDIA)
1844 sonos_player._attr_supported_features.add(PlayerFeature.SET_MEMBERS)
1845 sonos_player._attr_can_group_with = {"sonos_456"}
1846
1847 # Create another Sonos player
1848 sonos_player_b = MockPlayer(
1849 sonos_provider,
1850 "sonos_456",
1851 "Kitchen",
1852 identifiers={IdentifierType.MAC_ADDRESS: "AA:BB:CC:DD:EE:02"},
1853 )
1854 sonos_player_b._attr_supported_features.add(PlayerFeature.SET_MEMBERS)
1855
1856 # Create AirPlay protocol player
1857 sonos_airplay = MockPlayer(
1858 airplay_provider,
1859 "airplay_sonos",
1860 "Living Room (AirPlay)",
1861 player_type=PlayerType.PROTOCOL,
1862 identifiers={IdentifierType.MAC_ADDRESS: "AA:BB:CC:DD:EE:01"},
1863 )
1864 sonos_airplay._attr_supported_features.add(PlayerFeature.SET_MEMBERS)
1865 sonos_airplay.set_protocol_parent_id("sonos_123")
1866
1867 sonos_player.set_linked_output_protocols(
1868 [
1869 OutputProtocol(
1870 output_protocol_id="airplay_sonos",
1871 name="AirPlay",
1872 protocol_domain="airplay",
1873 priority=10,
1874 available=True,
1875 )
1876 ]
1877 )
1878
1879 mock_mass.players = controller
1880 controller._players = {
1881 "sonos_123": sonos_player,
1882 "sonos_456": sonos_player_b,
1883 "airplay_sonos": sonos_airplay,
1884 }
1885 controller._player_throttlers = {
1886 "sonos_123": Throttler(1, 0.05),
1887 "sonos_456": Throttler(1, 0.05),
1888 "airplay_sonos": Throttler(1, 0.05),
1889 }
1890
1891 # Group players via protocol (simulate grouping through AirPlay)
1892 # This should NOT set active_output_protocol anymore
1893 await controller._forward_protocol_set_members(
1894 parent_player=sonos_player,
1895 parent_protocol_player=sonos_airplay,
1896 protocol_members_to_add=["airplay_other"], # Add a protocol member
1897 protocol_members_to_remove=[],
1898 )
1899
1900 # NEW BEHAVIOR: Protocol should NOT be set during grouping without playback
1901 # After grouping, protocol should not be activated until playback starts
1902 assert sonos_player.active_output_protocol is None
1903
1904 async def test_protocol_selected_at_playback_time(self, mock_mass: MagicMock) -> None:
1905 """Test that protocol is selected when playback starts, not during grouping."""
1906 controller = PlayerController(mock_mass)
1907
1908 sonos_provider = MockProvider("sonos", instance_id="sonos_instance", mass=mock_mass)
1909 airplay_provider = MockProvider("airplay", instance_id="airplay_instance", mass=mock_mass)
1910
1911 # Create Sonos player with AirPlay support
1912 sonos_player = MockPlayer(
1913 sonos_provider,
1914 "sonos_123",
1915 "Living Room",
1916 identifiers={IdentifierType.MAC_ADDRESS: "AA:BB:CC:DD:EE:01"},
1917 )
1918 sonos_player._attr_supported_features.add(PlayerFeature.PLAY_MEDIA)
1919 sonos_player._attr_supported_features.add(PlayerFeature.SET_MEMBERS)
1920
1921 # Create AirPlay protocol player with group members
1922 sonos_airplay = MockPlayer(
1923 airplay_provider,
1924 "airplay_sonos",
1925 "Living Room (AirPlay)",
1926 player_type=PlayerType.PROTOCOL,
1927 identifiers={IdentifierType.MAC_ADDRESS: "AA:BB:CC:DD:EE:01"},
1928 )
1929 sonos_airplay._attr_supported_features.add(PlayerFeature.SET_MEMBERS)
1930 sonos_airplay._attr_supported_features.add(PlayerFeature.PLAY_MEDIA)
1931 sonos_airplay.set_protocol_parent_id("sonos_123")
1932 # Simulate that AirPlay protocol has group members (needs >1 for grouping check)
1933 sonos_airplay._attr_group_members = ["airplay_sonos", "airplay_other"]
1934
1935 sonos_player.set_linked_output_protocols(
1936 [
1937 OutputProtocol(
1938 output_protocol_id="airplay_sonos",
1939 name="AirPlay",
1940 protocol_domain="airplay",
1941 priority=10,
1942 available=True,
1943 )
1944 ]
1945 )
1946
1947 mock_mass.players = controller
1948 controller._players = {
1949 "sonos_123": sonos_player,
1950 "airplay_sonos": sonos_airplay,
1951 }
1952
1953 # Update state to apply group members to state
1954 sonos_airplay.update_state(signal_event=False)
1955 sonos_player.update_state(signal_event=False)
1956
1957 # Protocol should not be set yet
1958 assert sonos_player.active_output_protocol is None
1959
1960 # Select protocol for playback
1961 selected_player, output_protocol = controller._select_best_output_protocol(sonos_player)
1962
1963 # Should select AirPlay protocol because it has group members (Priority 1)
1964 assert selected_player == sonos_airplay
1965 assert output_protocol is not None
1966 assert output_protocol.output_protocol_id == "airplay_sonos"
1967
1968
1969class TestNativeProtocolPlayerGrouping:
1970 """Tests for grouping with native protocol players (e.g., native AirPlay like Apple TV)."""
1971
1972 def test_native_airplay_groups_with_protocol_linked_player(self, mock_mass: MagicMock) -> None:
1973 """Test grouping a native AirPlay player (Apple TV) with a protocol-linked player (Sonos).
1974
1975 This tests the scenario where:
1976 - Apple TV is a native AirPlay PLAYER (not PROTOCOL type)
1977 - Sonos has AirPlay as a linked protocol
1978 - Apple TV groups with Sonos via the common AirPlay protocol
1979 """
1980 controller = PlayerController(mock_mass)
1981
1982 airplay_provider = MockProvider("airplay", instance_id="airplay", mass=mock_mass)
1983 sonos_provider = MockProvider("sonos", instance_id="sonos", mass=mock_mass)
1984
1985 # Apple TV: native AirPlay PLAYER (supports grouping via AirPlay)
1986 apple_tv = MockPlayer(airplay_provider, "apple_tv_1", "Apple TV Slaapkamer")
1987 apple_tv._attr_supported_features.add(PlayerFeature.PLAY_MEDIA)
1988 apple_tv._attr_supported_features.add(PlayerFeature.SET_MEMBERS)
1989 apple_tv._attr_can_group_with = {"airplay"} # Provider instance ID
1990 apple_tv._cache.clear()
1991
1992 # Sonos native player (visible)
1993 sonos_player = MockPlayer(sonos_provider, "sonos_badkamer", "Badkamer")
1994 sonos_player._attr_supported_features.add(PlayerFeature.PLAY_MEDIA)
1995 sonos_player._attr_supported_features.add(PlayerFeature.SET_MEMBERS)
1996 sonos_player._cache.clear()
1997
1998 # AirPlay protocol player for Sonos (hidden, linked to sonos)
1999 sonos_airplay = MockPlayer(
2000 airplay_provider,
2001 "airplay_sonos",
2002 "Badkamer (AirPlay)",
2003 player_type=PlayerType.PROTOCOL,
2004 )
2005 sonos_airplay._attr_supported_features.add(PlayerFeature.SET_MEMBERS)
2006 sonos_airplay._attr_can_group_with = {"airplay"}
2007 sonos_airplay._cache.clear()
2008 sonos_airplay.set_protocol_parent_id("sonos_badkamer")
2009
2010 sonos_player.set_linked_output_protocols(
2011 [
2012 OutputProtocol(
2013 output_protocol_id="airplay_sonos",
2014 name="AirPlay",
2015 protocol_domain="airplay",
2016 priority=10,
2017 available=True,
2018 )
2019 ]
2020 )
2021
2022 mock_mass.players = controller
2023 controller._players = {
2024 "apple_tv_1": apple_tv,
2025 "sonos_badkamer": sonos_player,
2026 "airplay_sonos": sonos_airplay,
2027 }
2028 controller._player_throttlers = {
2029 "apple_tv_1": Throttler(1, 0.05),
2030 "sonos_badkamer": Throttler(1, 0.05),
2031 "airplay_sonos": Throttler(1, 0.05),
2032 }
2033
2034 # Update states
2035 sonos_airplay.update_state(signal_event=False)
2036 sonos_player.update_state(signal_event=False)
2037 apple_tv.update_state(signal_event=False)
2038
2039 # Translate members for grouping Sonos to Apple TV
2040 protocol_members, _native_members, protocol_player, protocol_domain = (
2041 controller._translate_members_for_protocols(
2042 parent_player=apple_tv,
2043 player_ids=["sonos_badkamer"],
2044 parent_protocol_player=None,
2045 parent_protocol_domain=None,
2046 )
2047 )
2048
2049 # Should find common AirPlay protocol
2050 assert len(protocol_members) == 1
2051 assert "airplay_sonos" in protocol_members
2052 assert protocol_domain == "airplay"
2053 # For native AirPlay player, protocol_player should be the Apple TV itself
2054 assert protocol_player == apple_tv
2055
2056 def test_get_output_protocol_by_domain_finds_native(self, mock_mass: MagicMock) -> None:
2057 """Test that get_output_protocol_by_domain finds native protocol."""
2058 controller = PlayerController(mock_mass)
2059
2060 airplay_provider = MockProvider("airplay", instance_id="airplay", mass=mock_mass)
2061
2062 # Native AirPlay player
2063 apple_tv = MockPlayer(airplay_provider, "apple_tv_1", "Apple TV")
2064 apple_tv._attr_supported_features.add(PlayerFeature.PLAY_MEDIA)
2065 apple_tv._cache.clear()
2066
2067 mock_mass.players = controller
2068 controller._players = {"apple_tv_1": apple_tv}
2069
2070 apple_tv.update_state(signal_event=False)
2071
2072 # Should find native AirPlay protocol
2073 protocol = apple_tv.get_output_protocol_by_domain("airplay")
2074 assert protocol is not None
2075 assert protocol.output_protocol_id == "native"
2076 assert protocol.protocol_domain == "airplay"
2077 assert protocol.is_native is True
2078
2079
2080class TestFinalGroupMembersTranslation:
2081 """Tests for __final_group_members translation of protocol player IDs."""
2082
2083 def test_final_group_members_translates_protocol_ids(self, mock_mass: MagicMock) -> None:
2084 """Test that __final_group_members translates protocol player IDs to visible IDs.
2085
2086 When a native AirPlay player (Apple TV) has protocol players in its group_members,
2087 the final state should show the visible parent player IDs instead.
2088 """
2089 controller = PlayerController(mock_mass)
2090
2091 airplay_provider = MockProvider("airplay", instance_id="airplay", mass=mock_mass)
2092 sonos_provider = MockProvider("sonos", instance_id="sonos", mass=mock_mass)
2093
2094 # Apple TV with group members containing a protocol player ID
2095 apple_tv = MockPlayer(airplay_provider, "apple_tv_1", "Apple TV")
2096 apple_tv._attr_supported_features.add(PlayerFeature.PLAY_MEDIA)
2097 apple_tv._attr_supported_features.add(PlayerFeature.SET_MEMBERS)
2098 apple_tv._attr_group_members = ["apple_tv_1", "airplay_sonos"]
2099 apple_tv._cache.clear()
2100
2101 # Sonos visible player
2102 sonos_player = MockPlayer(sonos_provider, "sonos_1", "Sonos")
2103 sonos_player._attr_supported_features.add(PlayerFeature.PLAY_MEDIA)
2104 sonos_player._cache.clear()
2105
2106 # AirPlay protocol player for Sonos
2107 sonos_airplay = MockPlayer(
2108 airplay_provider,
2109 "airplay_sonos",
2110 "Sonos (AirPlay)",
2111 player_type=PlayerType.PROTOCOL,
2112 )
2113 sonos_airplay._cache.clear()
2114 sonos_airplay.set_protocol_parent_id("sonos_1")
2115
2116 mock_mass.players = controller
2117 controller._players = {
2118 "apple_tv_1": apple_tv,
2119 "sonos_1": sonos_player,
2120 "airplay_sonos": sonos_airplay,
2121 }
2122 controller._player_throttlers = {
2123 "apple_tv_1": Throttler(1, 0.05),
2124 "sonos_1": Throttler(1, 0.05),
2125 "airplay_sonos": Throttler(1, 0.05),
2126 }
2127
2128 sonos_airplay.update_state(signal_event=False)
2129 sonos_player.update_state(signal_event=False)
2130 apple_tv.update_state(signal_event=False)
2131
2132 # Final group_members should show visible player IDs
2133 final_members = apple_tv.state.group_members
2134 assert "apple_tv_1" in final_members
2135 assert "sonos_1" in final_members
2136 # Protocol player ID should NOT appear in final state
2137 assert "airplay_sonos" not in final_members
2138
2139
2140class TestFinalSyncedToWithNativeProtocolParent:
2141 """Tests for __final_synced_to when sync parent is a native protocol player."""
2142
2143 def test_synced_to_native_airplay_player(self, mock_mass: MagicMock) -> None:
2144 """Test that synced_to correctly shows native AirPlay player as parent.
2145
2146 When a Sonos player's AirPlay protocol player is synced to a native AirPlay
2147 player (Apple TV), the Sonos's final synced_to should show the Apple TV.
2148 """
2149 controller = PlayerController(mock_mass)
2150
2151 airplay_provider = MockProvider("airplay", instance_id="airplay", mass=mock_mass)
2152 sonos_provider = MockProvider("sonos", instance_id="sonos", mass=mock_mass)
2153
2154 # Apple TV: native AirPlay PLAYER (the group leader)
2155 apple_tv = MockPlayer(airplay_provider, "apple_tv_1", "Apple TV")
2156 apple_tv._attr_supported_features.add(PlayerFeature.PLAY_MEDIA)
2157 apple_tv._attr_supported_features.add(PlayerFeature.SET_MEMBERS)
2158 apple_tv._cache.clear()
2159
2160 # Sonos visible player
2161 sonos_player = MockPlayer(sonos_provider, "sonos_1", "Sonos")
2162 sonos_player._attr_supported_features.add(PlayerFeature.PLAY_MEDIA)
2163 sonos_player._cache.clear()
2164
2165 # AirPlay protocol player for Sonos - synced to Apple TV
2166 sonos_airplay = MockPlayer(
2167 airplay_provider,
2168 "airplay_sonos",
2169 "Sonos (AirPlay)",
2170 player_type=PlayerType.PROTOCOL,
2171 )
2172 # Set group_members with Apple TV first to indicate synced_to Apple TV
2173 sonos_airplay._attr_group_members = ["apple_tv_1", "airplay_sonos"]
2174 sonos_airplay._cache.clear()
2175 sonos_airplay.set_protocol_parent_id("sonos_1")
2176
2177 sonos_player.set_linked_output_protocols(
2178 [
2179 OutputProtocol(
2180 output_protocol_id="airplay_sonos",
2181 name="AirPlay",
2182 protocol_domain="airplay",
2183 priority=10,
2184 available=True,
2185 )
2186 ]
2187 )
2188
2189 mock_mass.players = controller
2190 controller._players = {
2191 "apple_tv_1": apple_tv,
2192 "sonos_1": sonos_player,
2193 "airplay_sonos": sonos_airplay,
2194 }
2195 controller._player_throttlers = {
2196 "apple_tv_1": Throttler(1, 0.05),
2197 "sonos_1": Throttler(1, 0.05),
2198 "airplay_sonos": Throttler(1, 0.05),
2199 }
2200
2201 apple_tv.update_state(signal_event=False)
2202 sonos_airplay.update_state(signal_event=False)
2203 sonos_player.update_state(signal_event=False)
2204
2205 # Sonos's final synced_to should be Apple TV (visible player)
2206 assert sonos_player.state.synced_to == "apple_tv_1"
2207
2208
2209class TestUngroupTranslation:
2210 """Tests for translation when ungrouping from native protocol players."""
2211
2212 def test_ungroup_translates_visible_to_protocol_id(self, mock_mass: MagicMock) -> None:
2213 """Test that ungrouping correctly translates visible ID to protocol ID.
2214
2215 When ungrouping Sonos from Apple TV, the visible Sonos ID should be
2216 translated to its AirPlay protocol player ID for the removal.
2217 """
2218 controller = PlayerController(mock_mass)
2219
2220 airplay_provider = MockProvider("airplay", instance_id="airplay", mass=mock_mass)
2221 sonos_provider = MockProvider("sonos", instance_id="sonos", mass=mock_mass)
2222
2223 # Apple TV with Sonos's AirPlay protocol player in group_members
2224 apple_tv = MockPlayer(airplay_provider, "apple_tv_1", "Apple TV")
2225 apple_tv._attr_supported_features.add(PlayerFeature.PLAY_MEDIA)
2226 apple_tv._attr_supported_features.add(PlayerFeature.SET_MEMBERS)
2227 apple_tv._attr_group_members = ["apple_tv_1", "airplay_sonos"]
2228 apple_tv._cache.clear()
2229
2230 # Sonos visible player
2231 sonos_player = MockPlayer(sonos_provider, "sonos_1", "Sonos")
2232 sonos_player._attr_supported_features.add(PlayerFeature.PLAY_MEDIA)
2233 sonos_player._cache.clear()
2234
2235 # AirPlay protocol player for Sonos
2236 sonos_airplay = MockPlayer(
2237 airplay_provider,
2238 "airplay_sonos",
2239 "Sonos (AirPlay)",
2240 player_type=PlayerType.PROTOCOL,
2241 )
2242 sonos_airplay._cache.clear()
2243 sonos_airplay.set_protocol_parent_id("sonos_1")
2244
2245 sonos_player.set_linked_output_protocols(
2246 [
2247 OutputProtocol(
2248 output_protocol_id="airplay_sonos",
2249 name="AirPlay",
2250 protocol_domain="airplay",
2251 priority=10,
2252 available=True,
2253 )
2254 ]
2255 )
2256
2257 mock_mass.players = controller
2258 controller._players = {
2259 "apple_tv_1": apple_tv,
2260 "sonos_1": sonos_player,
2261 "airplay_sonos": sonos_airplay,
2262 }
2263 controller._player_throttlers = {
2264 "apple_tv_1": Throttler(1, 0.05),
2265 "sonos_1": Throttler(1, 0.05),
2266 "airplay_sonos": Throttler(1, 0.05),
2267 }
2268
2269 sonos_airplay.update_state(signal_event=False)
2270 sonos_player.update_state(signal_event=False)
2271 apple_tv.update_state(signal_event=False)
2272
2273 # Translate members for removal - visible ID should become protocol ID
2274 _protocol_members, native_members = controller._translate_members_to_remove_for_protocols(
2275 parent_player=apple_tv,
2276 player_ids=["sonos_1"], # Visible player ID
2277 parent_protocol_player=None,
2278 parent_protocol_domain=None,
2279 )
2280
2281 # Should translate to the protocol player ID for native removal
2282 assert "airplay_sonos" in native_members
2283 assert "sonos_1" not in native_members
2284