/
/
/
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 mock_mass.players = controller
1271 controller._players = {
1272 "sonos_123": sonos_player,
1273 "wiim_456": wiim_player,
1274 "dlna_sonos": sonos_dlna,
1275 "dlna_wiim": wiim_dlna,
1276 "airplay_sonos": sonos_airplay,
1277 "airplay_wiim": wiim_airplay,
1278 }
1279 controller._player_throttlers = {
1280 "sonos_123": Throttler(1, 0.05),
1281 "wiim_456": Throttler(1, 0.05),
1282 "dlna_sonos": Throttler(1, 0.05),
1283 "dlna_wiim": Throttler(1, 0.05),
1284 "airplay_sonos": Throttler(1, 0.05),
1285 "airplay_wiim": Throttler(1, 0.05),
1286 }
1287
1288 # Update state after modifying attributes
1289 sonos_dlna.update_state(signal_event=False)
1290 wiim_dlna.update_state(signal_event=False)
1291 sonos_airplay.update_state(signal_event=False)
1292 wiim_airplay.update_state(signal_event=False)
1293
1294 # Translate members - should skip DLNA (no SET_MEMBERS) and select AirPlay
1295 protocol_members, _native_members, protocol_player, protocol_domain = (
1296 controller._translate_members_for_protocols(
1297 parent_player=sonos_player,
1298 player_ids=["wiim_456"],
1299 parent_protocol_player=None,
1300 parent_protocol_domain=None,
1301 )
1302 )
1303
1304 # Should select AirPlay (supports SET_MEMBERS) not DLNA
1305 assert len(protocol_members) == 1
1306 assert "airplay_wiim" in protocol_members
1307 assert protocol_domain == "airplay"
1308 assert protocol_player == sonos_airplay
1309
1310
1311class TestCanGroupWith:
1312 """Tests for can_group_with property with three scenarios."""
1313
1314 def test_scenario_1_native_active_only_native_players(self, mock_mass: MagicMock) -> None:
1315 """Test Scenario 1: Native playback active -> all protocols shown (new behavior)."""
1316 controller = PlayerController(mock_mass)
1317
1318 sonos_provider = MockProvider("sonos", instance_id="sonos_instance", mass=mock_mass)
1319 airplay_provider = MockProvider("airplay", instance_id="airplay_instance", mass=mock_mass)
1320
1321 # Create Sonos player with native and AirPlay support
1322 sonos_player = MockPlayer(
1323 sonos_provider,
1324 "sonos_123",
1325 "Living Room",
1326 identifiers={IdentifierType.MAC_ADDRESS: "AA:BB:CC:DD:EE:01"},
1327 )
1328 sonos_player._attr_supported_features.add(PlayerFeature.PLAY_MEDIA)
1329 sonos_player._attr_supported_features.add(PlayerFeature.SET_MEMBERS)
1330 sonos_player._attr_can_group_with = {"sonos_456"}
1331 sonos_player._cache.clear()
1332 sonos_player.set_active_output_protocol("native")
1333
1334 # Create another Sonos player
1335 sonos_player_b = MockPlayer(
1336 sonos_provider,
1337 "sonos_456",
1338 "Kitchen",
1339 identifiers={IdentifierType.MAC_ADDRESS: "AA:BB:CC:DD:EE:02"},
1340 )
1341
1342 # Create AirPlay protocol player
1343 sonos_airplay = MockPlayer(
1344 airplay_provider,
1345 "airplay_sonos",
1346 "Living Room (AirPlay)",
1347 player_type=PlayerType.PROTOCOL,
1348 identifiers={IdentifierType.MAC_ADDRESS: "AA:BB:CC:DD:EE:01"},
1349 )
1350 sonos_airplay._attr_supported_features.add(PlayerFeature.SET_MEMBERS)
1351 sonos_airplay._attr_can_group_with = {"airplay_other"}
1352 sonos_airplay._cache.clear()
1353 sonos_airplay.set_protocol_parent_id("sonos_123")
1354
1355 sonos_player.set_linked_output_protocols(
1356 [
1357 OutputProtocol(
1358 output_protocol_id="airplay_sonos",
1359 name="AirPlay",
1360 protocol_domain="airplay",
1361 priority=10,
1362 available=True,
1363 )
1364 ]
1365 )
1366
1367 # Wire up mock_mass.players to controller so get_linked_protocol works
1368 mock_mass.players = controller
1369
1370 controller._players = {
1371 "sonos_123": sonos_player,
1372 "sonos_456": sonos_player_b,
1373 "airplay_sonos": sonos_airplay,
1374 }
1375 controller._player_throttlers = {
1376 "sonos_123": Throttler(1, 0.05),
1377 "sonos_456": Throttler(1, 0.05),
1378 "airplay_sonos": Throttler(1, 0.05),
1379 }
1380
1381 # Update state after modifying attributes and registering with controller
1382 sonos_player.update_state(signal_event=False)
1383 sonos_player_b.update_state(signal_event=False)
1384 sonos_airplay.update_state(signal_event=False)
1385
1386 # Get can_group_with while native is active
1387 groupable = sonos_player.state.can_group_with
1388
1389 # NEW BEHAVIOR: Should show both native AND protocol players
1390 # even when native protocol is active
1391 assert "sonos_456" in groupable # Native Sonos player
1392 # Note: airplay_other is not registered in controller._players, so it won't appear
1393 # But the logic should still allow showing AirPlay options if they were registered
1394
1395 def test_scenario_2_protocol_active_hybrid_groups(self, mock_mass: MagicMock) -> None:
1396 """Test Scenario 2: Protocol active -> show all protocols (new behavior)."""
1397 controller = PlayerController(mock_mass)
1398
1399 sonos_provider = MockProvider("sonos", instance_id="sonos_instance", mass=mock_mass)
1400 airplay_provider = MockProvider("airplay", instance_id="airplay_instance", mass=mock_mass)
1401
1402 # Create Sonos player with AirPlay active
1403 sonos_player = MockPlayer(
1404 sonos_provider,
1405 "sonos_123",
1406 "Living Room",
1407 identifiers={IdentifierType.MAC_ADDRESS: "AA:BB:CC:DD:EE:01"},
1408 )
1409 sonos_player._attr_supported_features.add(PlayerFeature.PLAY_MEDIA)
1410 sonos_player._attr_supported_features.add(PlayerFeature.SET_MEMBERS)
1411 sonos_player._attr_can_group_with = {"sonos_456"}
1412 sonos_player._cache.clear()
1413
1414 # Create another Sonos player
1415 sonos_player_b = MockPlayer(
1416 sonos_provider,
1417 "sonos_456",
1418 "Kitchen",
1419 identifiers={IdentifierType.MAC_ADDRESS: "AA:BB:CC:DD:EE:02"},
1420 )
1421
1422 # Create AirPlay protocol player
1423 sonos_airplay = MockPlayer(
1424 airplay_provider,
1425 "airplay_sonos",
1426 "Living Room (AirPlay)",
1427 player_type=PlayerType.PROTOCOL,
1428 identifiers={IdentifierType.MAC_ADDRESS: "AA:BB:CC:DD:EE:01"},
1429 )
1430 sonos_airplay._attr_supported_features.add(PlayerFeature.SET_MEMBERS)
1431 sonos_airplay._attr_can_group_with = {"airplay_other"}
1432 sonos_airplay._cache.clear()
1433 sonos_airplay.set_protocol_parent_id("sonos_123")
1434
1435 # Create another device with AirPlay
1436 wiim_player = MockPlayer(
1437 sonos_provider,
1438 "wiim_789",
1439 "Bedroom",
1440 identifiers={IdentifierType.MAC_ADDRESS: "AA:BB:CC:DD:EE:03"},
1441 )
1442
1443 airplay_other = MockPlayer(
1444 airplay_provider,
1445 "airplay_other",
1446 "Bedroom (AirPlay)",
1447 player_type=PlayerType.PROTOCOL,
1448 identifiers={IdentifierType.MAC_ADDRESS: "AA:BB:CC:DD:EE:03"},
1449 )
1450 airplay_other._attr_supported_features.add(PlayerFeature.SET_MEMBERS)
1451 airplay_other._attr_can_group_with = {"airplay_sonos"}
1452 airplay_other._cache.clear()
1453 airplay_other.set_protocol_parent_id("wiim_789")
1454
1455 wiim_player.set_linked_output_protocols(
1456 [
1457 OutputProtocol(
1458 output_protocol_id="airplay_other",
1459 name="AirPlay",
1460 protocol_domain="airplay",
1461 priority=10,
1462 available=True,
1463 ),
1464 ]
1465 )
1466
1467 sonos_player.set_linked_output_protocols(
1468 [
1469 OutputProtocol(
1470 output_protocol_id="airplay_sonos",
1471 name="AirPlay",
1472 protocol_domain="airplay",
1473 priority=10,
1474 available=True,
1475 )
1476 ]
1477 )
1478 sonos_player.set_active_output_protocol("airplay_sonos")
1479
1480 # Wire up mock_mass.players to controller so get_linked_protocol works
1481 mock_mass.players = controller
1482
1483 controller._players = {
1484 "sonos_123": sonos_player,
1485 "sonos_456": sonos_player_b,
1486 "wiim_789": wiim_player,
1487 "airplay_sonos": sonos_airplay,
1488 "airplay_other": airplay_other,
1489 }
1490 controller._player_throttlers = {
1491 "sonos_123": Throttler(1, 0.05),
1492 "sonos_456": Throttler(1, 0.05),
1493 "wiim_789": Throttler(1, 0.05),
1494 "airplay_sonos": Throttler(1, 0.05),
1495 "airplay_other": Throttler(1, 0.05),
1496 }
1497
1498 # Clear cache after setting linked protocols
1499 sonos_player._cache.clear()
1500 wiim_player._cache.clear()
1501
1502 # Update state after modifying attributes and registering with controller
1503 # IMPORTANT: Update protocol players FIRST, then parent players
1504 sonos_airplay.update_state(signal_event=False)
1505 airplay_other.update_state(signal_event=False)
1506 sonos_player.update_state(signal_event=False)
1507 sonos_player_b.update_state(signal_event=False)
1508 wiim_player.update_state(signal_event=False)
1509
1510 # Get can_group_with while AirPlay is active
1511 groupable = sonos_player.state.can_group_with
1512
1513 # NEW BEHAVIOR: Should show ALL protocols + native players
1514 # regardless of which protocol is active
1515 assert "sonos_456" in groupable # Native Sonos player
1516 assert "wiim_789" in groupable # Via airplay_other protocol
1517
1518 def test_scenario_3_no_active_output_all_protocols_shown(self, mock_mass: MagicMock) -> None:
1519 """Test Scenario 3: No active output -> show all compatible protocols + native."""
1520 controller = PlayerController(mock_mass)
1521
1522 sonos_provider = MockProvider("sonos", instance_id="sonos_instance", mass=mock_mass)
1523 airplay_provider = MockProvider("airplay", instance_id="airplay_instance", mass=mock_mass)
1524 dlna_provider = MockProvider("dlna", instance_id="dlna_instance", mass=mock_mass)
1525
1526 # Create Sonos player (no active protocol)
1527 sonos_player = MockPlayer(
1528 sonos_provider,
1529 "sonos_123",
1530 "Living Room",
1531 identifiers={IdentifierType.MAC_ADDRESS: "AA:BB:CC:DD:EE:01"},
1532 )
1533 sonos_player._attr_supported_features.add(PlayerFeature.PLAY_MEDIA)
1534 sonos_player._attr_supported_features.add(PlayerFeature.SET_MEMBERS)
1535 sonos_player._attr_can_group_with = {"sonos_456"}
1536 sonos_player._cache.clear()
1537 # No active output protocol set
1538
1539 # Create another Sonos player
1540 sonos_player_b = MockPlayer(
1541 sonos_provider,
1542 "sonos_456",
1543 "Kitchen",
1544 identifiers={IdentifierType.MAC_ADDRESS: "AA:BB:CC:DD:EE:02"},
1545 )
1546
1547 # Create AirPlay protocol player (supports SET_MEMBERS)
1548 sonos_airplay = MockPlayer(
1549 airplay_provider,
1550 "airplay_sonos",
1551 "Living Room (AirPlay)",
1552 player_type=PlayerType.PROTOCOL,
1553 identifiers={IdentifierType.MAC_ADDRESS: "AA:BB:CC:DD:EE:01"},
1554 )
1555 sonos_airplay._attr_supported_features.add(PlayerFeature.SET_MEMBERS)
1556 sonos_airplay._attr_can_group_with = {"airplay_other"}
1557 sonos_airplay._cache.clear()
1558 sonos_airplay.set_protocol_parent_id("sonos_123")
1559
1560 # Create DLNA protocol player (does NOT support SET_MEMBERS)
1561 sonos_dlna = MockPlayer(
1562 dlna_provider,
1563 "dlna_sonos",
1564 "Living Room (DLNA)",
1565 player_type=PlayerType.PROTOCOL,
1566 identifiers={IdentifierType.MAC_ADDRESS: "AA:BB:CC:DD:EE:01"},
1567 )
1568 # No SET_MEMBERS support
1569 sonos_dlna._attr_can_group_with = {"dlna_other"}
1570 sonos_dlna.set_protocol_parent_id("sonos_123")
1571
1572 # Another device
1573 wiim_player = MockPlayer(
1574 sonos_provider,
1575 "wiim_789",
1576 "Bedroom",
1577 identifiers={IdentifierType.MAC_ADDRESS: "AA:BB:CC:DD:EE:03"},
1578 )
1579
1580 airplay_other = MockPlayer(
1581 airplay_provider,
1582 "airplay_other",
1583 "Bedroom (AirPlay)",
1584 player_type=PlayerType.PROTOCOL,
1585 identifiers={IdentifierType.MAC_ADDRESS: "AA:BB:CC:DD:EE:03"},
1586 )
1587 airplay_other._attr_supported_features.add(PlayerFeature.SET_MEMBERS)
1588 airplay_other._attr_can_group_with = {"airplay_sonos"}
1589 airplay_other._cache.clear()
1590 airplay_other.set_protocol_parent_id("wiim_789")
1591
1592 sonos_player.set_linked_output_protocols(
1593 [
1594 OutputProtocol(
1595 output_protocol_id="airplay_sonos",
1596 name="AirPlay",
1597 protocol_domain="airplay",
1598 priority=10,
1599 available=True,
1600 ),
1601 OutputProtocol(
1602 output_protocol_id="dlna_sonos",
1603 name="DLNA",
1604 protocol_domain="dlna",
1605 priority=30,
1606 available=True,
1607 ),
1608 ]
1609 )
1610
1611 wiim_player.set_linked_output_protocols(
1612 [
1613 OutputProtocol(
1614 output_protocol_id="airplay_other",
1615 name="AirPlay",
1616 protocol_domain="airplay",
1617 priority=10,
1618 available=True,
1619 ),
1620 ]
1621 )
1622
1623 # Clear cache after setting linked protocols (output_protocols is cached)
1624 sonos_player._cache.clear()
1625 wiim_player._cache.clear()
1626
1627 # Wire up mock_mass.players to controller so get_linked_protocol works
1628 mock_mass.players = controller
1629
1630 controller._players = {
1631 "sonos_123": sonos_player,
1632 "sonos_456": sonos_player_b,
1633 "wiim_789": wiim_player,
1634 "airplay_sonos": sonos_airplay,
1635 "airplay_other": airplay_other,
1636 "dlna_sonos": sonos_dlna,
1637 }
1638 controller._player_throttlers = {
1639 "sonos_123": Throttler(1, 0.05),
1640 "sonos_456": Throttler(1, 0.05),
1641 "wiim_789": Throttler(1, 0.05),
1642 "airplay_sonos": Throttler(1, 0.05),
1643 "airplay_other": Throttler(1, 0.05),
1644 "dlna_sonos": Throttler(1, 0.05),
1645 }
1646
1647 # Update state after modifying attributes and registering with controller
1648 # Note: set_linked_output_protocols calls trigger_player_update, but since mass.players
1649 # is a MagicMock, we need to manually call update_state
1650 # IMPORTANT: Update protocol players FIRST, then parent players, because parent players
1651 # access protocol_player.state.can_group_with during their update_state()
1652 sonos_airplay.update_state(signal_event=False)
1653 airplay_other.update_state(signal_event=False)
1654 sonos_dlna.update_state(signal_event=False)
1655 sonos_player.update_state(signal_event=False)
1656 sonos_player_b.update_state(signal_event=False)
1657 wiim_player.update_state(signal_event=False)
1658
1659 # Get can_group_with with no active protocol
1660 groupable = sonos_player.state.can_group_with
1661
1662 # Should show native players + AirPlay players (supports SET_MEMBERS)
1663 # but NOT DLNA players (no SET_MEMBERS support)
1664 assert "sonos_456" in groupable
1665 assert "wiim_789" in groupable # Via AirPlay protocol
1666 # DLNA players should not be shown since DLNA doesn't support SET_MEMBERS
1667
1668
1669class TestNativePlayerProtocolGrouping:
1670 """Tests for grouping between native PLAYER type and PROTOCOL type AirPlay players."""
1671
1672 def test_native_airplay_player_sees_protocol_players_as_visible_parents(
1673 self, mock_mass: MagicMock
1674 ) -> None:
1675 """Test that a native PLAYER type translates protocol players to visible parents."""
1676 controller = PlayerController(mock_mass)
1677
1678 airplay_provider = MockProvider("airplay", instance_id="airplay", mass=mock_mass)
1679 sonos_provider = MockProvider("sonos", instance_id="sonos", mass=mock_mass)
1680
1681 # HomePod: native AirPlay PLAYER (not PROTOCOL)
1682 homepod = MockPlayer(airplay_provider, "homepod_1", "Office")
1683 homepod._attr_supported_features.add(PlayerFeature.SET_MEMBERS)
1684 homepod._attr_can_group_with = {"airplay"} # Provider instance ID
1685 homepod._cache.clear()
1686
1687 # Sonos native player (visible to the user)
1688 sonos_player = MockPlayer(sonos_provider, "sonos_1", "Kitchen")
1689 sonos_player._attr_supported_features.add(PlayerFeature.SET_MEMBERS)
1690 sonos_player._cache.clear()
1691
1692 # AirPlay protocol player for the Sonos (hidden, linked to sonos_player)
1693 sonos_airplay = MockPlayer(
1694 airplay_provider,
1695 "airplay_sonos_1",
1696 "Kitchen (AirPlay)",
1697 player_type=PlayerType.PROTOCOL,
1698 )
1699 sonos_airplay._attr_supported_features.add(PlayerFeature.SET_MEMBERS)
1700 sonos_airplay._attr_can_group_with = {"airplay"}
1701 sonos_airplay._cache.clear()
1702 sonos_airplay.set_protocol_parent_id("sonos_1")
1703
1704 sonos_player.set_linked_output_protocols(
1705 [
1706 OutputProtocol(
1707 output_protocol_id="airplay_sonos_1",
1708 name="AirPlay",
1709 protocol_domain="airplay",
1710 priority=10,
1711 available=True,
1712 )
1713 ]
1714 )
1715
1716 mock_mass.players = controller
1717 mock_mass.get_provider = MagicMock(return_value=airplay_provider)
1718
1719 controller._players = {
1720 "homepod_1": homepod,
1721 "sonos_1": sonos_player,
1722 "airplay_sonos_1": sonos_airplay,
1723 }
1724 controller._player_throttlers = {
1725 "homepod_1": Throttler(1, 0.05),
1726 "sonos_1": Throttler(1, 0.05),
1727 "airplay_sonos_1": Throttler(1, 0.05),
1728 }
1729
1730 # Update protocol players first, then parents
1731 sonos_airplay.update_state(signal_event=False)
1732 sonos_player.update_state(signal_event=False)
1733 homepod.update_state(signal_event=False)
1734
1735 groupable = homepod.state.can_group_with
1736
1737 # HomePod should see Sonos's VISIBLE player, not the hidden protocol player
1738 assert "sonos_1" in groupable
1739 assert "airplay_sonos_1" not in groupable # Hidden protocol ID must NOT appear
1740
1741 def test_protocol_linked_player_sees_native_airplay_player(self, mock_mass: MagicMock) -> None:
1742 """Test that a player with linked AirPlay protocol sees native PLAYER type players."""
1743 controller = PlayerController(mock_mass)
1744
1745 airplay_provider = MockProvider("airplay", instance_id="airplay", mass=mock_mass)
1746 sonos_provider = MockProvider("sonos", instance_id="sonos", mass=mock_mass)
1747
1748 # HomePod: native AirPlay PLAYER
1749 homepod = MockPlayer(airplay_provider, "homepod_1", "Office")
1750 homepod._attr_supported_features.add(PlayerFeature.SET_MEMBERS)
1751 homepod._attr_can_group_with = {"airplay"}
1752 homepod._cache.clear()
1753
1754 # Sonos native player (visible to the user)
1755 sonos_player = MockPlayer(sonos_provider, "sonos_1", "Kitchen")
1756 sonos_player._attr_supported_features.add(PlayerFeature.PLAY_MEDIA)
1757 sonos_player._attr_supported_features.add(PlayerFeature.SET_MEMBERS)
1758 sonos_player._attr_can_group_with = set() # No native Sonos grouping peers
1759 sonos_player._cache.clear()
1760
1761 # AirPlay protocol player for the Sonos (hidden, linked to sonos_player)
1762 sonos_airplay = MockPlayer(
1763 airplay_provider,
1764 "airplay_sonos_1",
1765 "Kitchen (AirPlay)",
1766 player_type=PlayerType.PROTOCOL,
1767 )
1768 sonos_airplay._attr_supported_features.add(PlayerFeature.SET_MEMBERS)
1769 sonos_airplay._attr_can_group_with = {"airplay"} # Provider instance ID
1770 sonos_airplay._cache.clear()
1771 sonos_airplay.set_protocol_parent_id("sonos_1")
1772
1773 sonos_player.set_linked_output_protocols(
1774 [
1775 OutputProtocol(
1776 output_protocol_id="airplay_sonos_1",
1777 name="AirPlay",
1778 protocol_domain="airplay",
1779 priority=10,
1780 available=True,
1781 )
1782 ]
1783 )
1784
1785 mock_mass.players = controller
1786 mock_mass.get_provider = MagicMock(return_value=airplay_provider)
1787
1788 controller._players = {
1789 "homepod_1": homepod,
1790 "sonos_1": sonos_player,
1791 "airplay_sonos_1": sonos_airplay,
1792 }
1793 controller._player_throttlers = {
1794 "homepod_1": Throttler(1, 0.05),
1795 "sonos_1": Throttler(1, 0.05),
1796 "airplay_sonos_1": Throttler(1, 0.05),
1797 }
1798
1799 # Update protocol players first, then parents
1800 sonos_airplay.update_state(signal_event=False)
1801 homepod.update_state(signal_event=False)
1802 sonos_player.update_state(signal_event=False)
1803
1804 groupable = sonos_player.state.can_group_with
1805
1806 # Sonos should see HomePod via its linked AirPlay protocol's can_group_with
1807 assert "homepod_1" in groupable
1808
1809
1810class TestProtocolSwitchingDuringPlayback:
1811 """Tests for dynamic protocol switching when group members change during playback."""
1812
1813 async def test_no_protocol_set_during_grouping_without_playback(
1814 self, mock_mass: MagicMock
1815 ) -> None:
1816 """Test that no protocol is set when grouping players without active playback."""
1817 controller = PlayerController(mock_mass)
1818
1819 sonos_provider = MockProvider("sonos", instance_id="sonos_instance", mass=mock_mass)
1820 airplay_provider = MockProvider("airplay", instance_id="airplay_instance", mass=mock_mass)
1821
1822 # Create Sonos player with AirPlay support
1823 sonos_player = MockPlayer(
1824 sonos_provider,
1825 "sonos_123",
1826 "Living Room",
1827 identifiers={IdentifierType.MAC_ADDRESS: "AA:BB:CC:DD:EE:01"},
1828 )
1829 sonos_player._attr_supported_features.add(PlayerFeature.PLAY_MEDIA)
1830 sonos_player._attr_supported_features.add(PlayerFeature.SET_MEMBERS)
1831 sonos_player._attr_can_group_with = {"sonos_456"}
1832
1833 # Create another Sonos player
1834 sonos_player_b = MockPlayer(
1835 sonos_provider,
1836 "sonos_456",
1837 "Kitchen",
1838 identifiers={IdentifierType.MAC_ADDRESS: "AA:BB:CC:DD:EE:02"},
1839 )
1840 sonos_player_b._attr_supported_features.add(PlayerFeature.SET_MEMBERS)
1841
1842 # Create AirPlay protocol player
1843 sonos_airplay = MockPlayer(
1844 airplay_provider,
1845 "airplay_sonos",
1846 "Living Room (AirPlay)",
1847 player_type=PlayerType.PROTOCOL,
1848 identifiers={IdentifierType.MAC_ADDRESS: "AA:BB:CC:DD:EE:01"},
1849 )
1850 sonos_airplay._attr_supported_features.add(PlayerFeature.SET_MEMBERS)
1851 sonos_airplay.set_protocol_parent_id("sonos_123")
1852
1853 sonos_player.set_linked_output_protocols(
1854 [
1855 OutputProtocol(
1856 output_protocol_id="airplay_sonos",
1857 name="AirPlay",
1858 protocol_domain="airplay",
1859 priority=10,
1860 available=True,
1861 )
1862 ]
1863 )
1864
1865 mock_mass.players = controller
1866 controller._players = {
1867 "sonos_123": sonos_player,
1868 "sonos_456": sonos_player_b,
1869 "airplay_sonos": sonos_airplay,
1870 }
1871 controller._player_throttlers = {
1872 "sonos_123": Throttler(1, 0.05),
1873 "sonos_456": Throttler(1, 0.05),
1874 "airplay_sonos": Throttler(1, 0.05),
1875 }
1876
1877 # Group players via protocol (simulate grouping through AirPlay)
1878 # This should NOT set active_output_protocol anymore
1879 await controller._forward_protocol_set_members(
1880 parent_player=sonos_player,
1881 parent_protocol_player=sonos_airplay,
1882 protocol_members_to_add=["airplay_other"], # Add a protocol member
1883 protocol_members_to_remove=[],
1884 )
1885
1886 # NEW BEHAVIOR: Protocol should NOT be set during grouping without playback
1887 # After grouping, protocol should not be activated until playback starts
1888 assert sonos_player.active_output_protocol is None
1889
1890 async def test_protocol_selected_at_playback_time(self, mock_mass: MagicMock) -> None:
1891 """Test that protocol is selected when playback starts, not during grouping."""
1892 controller = PlayerController(mock_mass)
1893
1894 sonos_provider = MockProvider("sonos", instance_id="sonos_instance", mass=mock_mass)
1895 airplay_provider = MockProvider("airplay", instance_id="airplay_instance", mass=mock_mass)
1896
1897 # Create Sonos player with AirPlay support
1898 sonos_player = MockPlayer(
1899 sonos_provider,
1900 "sonos_123",
1901 "Living Room",
1902 identifiers={IdentifierType.MAC_ADDRESS: "AA:BB:CC:DD:EE:01"},
1903 )
1904 sonos_player._attr_supported_features.add(PlayerFeature.PLAY_MEDIA)
1905 sonos_player._attr_supported_features.add(PlayerFeature.SET_MEMBERS)
1906
1907 # Create AirPlay protocol player with group members
1908 sonos_airplay = MockPlayer(
1909 airplay_provider,
1910 "airplay_sonos",
1911 "Living Room (AirPlay)",
1912 player_type=PlayerType.PROTOCOL,
1913 identifiers={IdentifierType.MAC_ADDRESS: "AA:BB:CC:DD:EE:01"},
1914 )
1915 sonos_airplay._attr_supported_features.add(PlayerFeature.SET_MEMBERS)
1916 sonos_airplay._attr_supported_features.add(PlayerFeature.PLAY_MEDIA)
1917 sonos_airplay.set_protocol_parent_id("sonos_123")
1918 # Simulate that AirPlay protocol has group members (needs >1 for grouping check)
1919 sonos_airplay._attr_group_members = ["airplay_sonos", "airplay_other"]
1920
1921 sonos_player.set_linked_output_protocols(
1922 [
1923 OutputProtocol(
1924 output_protocol_id="airplay_sonos",
1925 name="AirPlay",
1926 protocol_domain="airplay",
1927 priority=10,
1928 available=True,
1929 )
1930 ]
1931 )
1932
1933 mock_mass.players = controller
1934 controller._players = {
1935 "sonos_123": sonos_player,
1936 "airplay_sonos": sonos_airplay,
1937 }
1938
1939 # Update state to apply group members to state
1940 sonos_airplay.update_state(signal_event=False)
1941 sonos_player.update_state(signal_event=False)
1942
1943 # Protocol should not be set yet
1944 assert sonos_player.active_output_protocol is None
1945
1946 # Select protocol for playback
1947 selected_player, output_protocol = controller._select_best_output_protocol(sonos_player)
1948
1949 # Should select AirPlay protocol because it has group members (Priority 1)
1950 assert selected_player == sonos_airplay
1951 assert output_protocol is not None
1952 assert output_protocol.output_protocol_id == "airplay_sonos"
1953
1954
1955class TestNativeProtocolPlayerGrouping:
1956 """Tests for grouping with native protocol players (e.g., native AirPlay like Apple TV)."""
1957
1958 def test_native_airplay_groups_with_protocol_linked_player(self, mock_mass: MagicMock) -> None:
1959 """Test grouping a native AirPlay player (Apple TV) with a protocol-linked player (Sonos).
1960
1961 This tests the scenario where:
1962 - Apple TV is a native AirPlay PLAYER (not PROTOCOL type)
1963 - Sonos has AirPlay as a linked protocol
1964 - Apple TV groups with Sonos via the common AirPlay protocol
1965 """
1966 controller = PlayerController(mock_mass)
1967
1968 airplay_provider = MockProvider("airplay", instance_id="airplay", mass=mock_mass)
1969 sonos_provider = MockProvider("sonos", instance_id="sonos", mass=mock_mass)
1970
1971 # Apple TV: native AirPlay PLAYER (supports grouping via AirPlay)
1972 apple_tv = MockPlayer(airplay_provider, "apple_tv_1", "Apple TV Slaapkamer")
1973 apple_tv._attr_supported_features.add(PlayerFeature.PLAY_MEDIA)
1974 apple_tv._attr_supported_features.add(PlayerFeature.SET_MEMBERS)
1975 apple_tv._attr_can_group_with = {"airplay"} # Provider instance ID
1976 apple_tv._cache.clear()
1977
1978 # Sonos native player (visible)
1979 sonos_player = MockPlayer(sonos_provider, "sonos_badkamer", "Badkamer")
1980 sonos_player._attr_supported_features.add(PlayerFeature.PLAY_MEDIA)
1981 sonos_player._attr_supported_features.add(PlayerFeature.SET_MEMBERS)
1982 sonos_player._cache.clear()
1983
1984 # AirPlay protocol player for Sonos (hidden, linked to sonos)
1985 sonos_airplay = MockPlayer(
1986 airplay_provider,
1987 "airplay_sonos",
1988 "Badkamer (AirPlay)",
1989 player_type=PlayerType.PROTOCOL,
1990 )
1991 sonos_airplay._attr_supported_features.add(PlayerFeature.SET_MEMBERS)
1992 sonos_airplay._attr_can_group_with = {"airplay"}
1993 sonos_airplay._cache.clear()
1994 sonos_airplay.set_protocol_parent_id("sonos_badkamer")
1995
1996 sonos_player.set_linked_output_protocols(
1997 [
1998 OutputProtocol(
1999 output_protocol_id="airplay_sonos",
2000 name="AirPlay",
2001 protocol_domain="airplay",
2002 priority=10,
2003 available=True,
2004 )
2005 ]
2006 )
2007
2008 mock_mass.players = controller
2009 controller._players = {
2010 "apple_tv_1": apple_tv,
2011 "sonos_badkamer": sonos_player,
2012 "airplay_sonos": sonos_airplay,
2013 }
2014 controller._player_throttlers = {
2015 "apple_tv_1": Throttler(1, 0.05),
2016 "sonos_badkamer": Throttler(1, 0.05),
2017 "airplay_sonos": Throttler(1, 0.05),
2018 }
2019
2020 # Update states
2021 sonos_airplay.update_state(signal_event=False)
2022 sonos_player.update_state(signal_event=False)
2023 apple_tv.update_state(signal_event=False)
2024
2025 # Translate members for grouping Sonos to Apple TV
2026 protocol_members, _native_members, protocol_player, protocol_domain = (
2027 controller._translate_members_for_protocols(
2028 parent_player=apple_tv,
2029 player_ids=["sonos_badkamer"],
2030 parent_protocol_player=None,
2031 parent_protocol_domain=None,
2032 )
2033 )
2034
2035 # Should find common AirPlay protocol
2036 assert len(protocol_members) == 1
2037 assert "airplay_sonos" in protocol_members
2038 assert protocol_domain == "airplay"
2039 # For native AirPlay player, protocol_player should be the Apple TV itself
2040 assert protocol_player == apple_tv
2041
2042 def test_get_output_protocol_by_domain_finds_native(self, mock_mass: MagicMock) -> None:
2043 """Test that get_output_protocol_by_domain finds native protocol."""
2044 controller = PlayerController(mock_mass)
2045
2046 airplay_provider = MockProvider("airplay", instance_id="airplay", mass=mock_mass)
2047
2048 # Native AirPlay player
2049 apple_tv = MockPlayer(airplay_provider, "apple_tv_1", "Apple TV")
2050 apple_tv._attr_supported_features.add(PlayerFeature.PLAY_MEDIA)
2051 apple_tv._cache.clear()
2052
2053 mock_mass.players = controller
2054 controller._players = {"apple_tv_1": apple_tv}
2055
2056 apple_tv.update_state(signal_event=False)
2057
2058 # Should find native AirPlay protocol
2059 protocol = apple_tv.get_output_protocol_by_domain("airplay")
2060 assert protocol is not None
2061 assert protocol.output_protocol_id == "native"
2062 assert protocol.protocol_domain == "airplay"
2063 assert protocol.is_native is True
2064
2065
2066class TestFinalGroupMembersTranslation:
2067 """Tests for __final_group_members translation of protocol player IDs."""
2068
2069 def test_final_group_members_translates_protocol_ids(self, mock_mass: MagicMock) -> None:
2070 """Test that __final_group_members translates protocol player IDs to visible IDs.
2071
2072 When a native AirPlay player (Apple TV) has protocol players in its group_members,
2073 the final state should show the visible parent player IDs instead.
2074 """
2075 controller = PlayerController(mock_mass)
2076
2077 airplay_provider = MockProvider("airplay", instance_id="airplay", mass=mock_mass)
2078 sonos_provider = MockProvider("sonos", instance_id="sonos", mass=mock_mass)
2079
2080 # Apple TV with group members containing a protocol player ID
2081 apple_tv = MockPlayer(airplay_provider, "apple_tv_1", "Apple TV")
2082 apple_tv._attr_supported_features.add(PlayerFeature.PLAY_MEDIA)
2083 apple_tv._attr_supported_features.add(PlayerFeature.SET_MEMBERS)
2084 apple_tv._attr_group_members = ["apple_tv_1", "airplay_sonos"]
2085 apple_tv._cache.clear()
2086
2087 # Sonos visible player
2088 sonos_player = MockPlayer(sonos_provider, "sonos_1", "Sonos")
2089 sonos_player._attr_supported_features.add(PlayerFeature.PLAY_MEDIA)
2090 sonos_player._cache.clear()
2091
2092 # AirPlay protocol player for Sonos
2093 sonos_airplay = MockPlayer(
2094 airplay_provider,
2095 "airplay_sonos",
2096 "Sonos (AirPlay)",
2097 player_type=PlayerType.PROTOCOL,
2098 )
2099 sonos_airplay._cache.clear()
2100 sonos_airplay.set_protocol_parent_id("sonos_1")
2101
2102 mock_mass.players = controller
2103 controller._players = {
2104 "apple_tv_1": apple_tv,
2105 "sonos_1": sonos_player,
2106 "airplay_sonos": sonos_airplay,
2107 }
2108 controller._player_throttlers = {
2109 "apple_tv_1": Throttler(1, 0.05),
2110 "sonos_1": Throttler(1, 0.05),
2111 "airplay_sonos": Throttler(1, 0.05),
2112 }
2113
2114 sonos_airplay.update_state(signal_event=False)
2115 sonos_player.update_state(signal_event=False)
2116 apple_tv.update_state(signal_event=False)
2117
2118 # Final group_members should show visible player IDs
2119 final_members = apple_tv.state.group_members
2120 assert "apple_tv_1" in final_members
2121 assert "sonos_1" in final_members
2122 # Protocol player ID should NOT appear in final state
2123 assert "airplay_sonos" not in final_members
2124
2125
2126class TestFinalSyncedToWithNativeProtocolParent:
2127 """Tests for __final_synced_to when sync parent is a native protocol player."""
2128
2129 def test_synced_to_native_airplay_player(self, mock_mass: MagicMock) -> None:
2130 """Test that synced_to correctly shows native AirPlay player as parent.
2131
2132 When a Sonos player's AirPlay protocol player is synced to a native AirPlay
2133 player (Apple TV), the Sonos's final synced_to should show the Apple TV.
2134 """
2135 controller = PlayerController(mock_mass)
2136
2137 airplay_provider = MockProvider("airplay", instance_id="airplay", mass=mock_mass)
2138 sonos_provider = MockProvider("sonos", instance_id="sonos", mass=mock_mass)
2139
2140 # Apple TV: native AirPlay PLAYER (the group leader)
2141 apple_tv = MockPlayer(airplay_provider, "apple_tv_1", "Apple TV")
2142 apple_tv._attr_supported_features.add(PlayerFeature.PLAY_MEDIA)
2143 apple_tv._attr_supported_features.add(PlayerFeature.SET_MEMBERS)
2144 apple_tv._cache.clear()
2145
2146 # Sonos visible player
2147 sonos_player = MockPlayer(sonos_provider, "sonos_1", "Sonos")
2148 sonos_player._attr_supported_features.add(PlayerFeature.PLAY_MEDIA)
2149 sonos_player._cache.clear()
2150
2151 # AirPlay protocol player for Sonos - synced to Apple TV
2152 sonos_airplay = MockPlayer(
2153 airplay_provider,
2154 "airplay_sonos",
2155 "Sonos (AirPlay)",
2156 player_type=PlayerType.PROTOCOL,
2157 )
2158 # Set group_members with Apple TV first to indicate synced_to Apple TV
2159 sonos_airplay._attr_group_members = ["apple_tv_1", "airplay_sonos"]
2160 sonos_airplay._cache.clear()
2161 sonos_airplay.set_protocol_parent_id("sonos_1")
2162
2163 sonos_player.set_linked_output_protocols(
2164 [
2165 OutputProtocol(
2166 output_protocol_id="airplay_sonos",
2167 name="AirPlay",
2168 protocol_domain="airplay",
2169 priority=10,
2170 available=True,
2171 )
2172 ]
2173 )
2174
2175 mock_mass.players = controller
2176 controller._players = {
2177 "apple_tv_1": apple_tv,
2178 "sonos_1": sonos_player,
2179 "airplay_sonos": sonos_airplay,
2180 }
2181 controller._player_throttlers = {
2182 "apple_tv_1": Throttler(1, 0.05),
2183 "sonos_1": Throttler(1, 0.05),
2184 "airplay_sonos": Throttler(1, 0.05),
2185 }
2186
2187 apple_tv.update_state(signal_event=False)
2188 sonos_airplay.update_state(signal_event=False)
2189 sonos_player.update_state(signal_event=False)
2190
2191 # Sonos's final synced_to should be Apple TV (visible player)
2192 assert sonos_player.state.synced_to == "apple_tv_1"
2193
2194
2195class TestUngroupTranslation:
2196 """Tests for translation when ungrouping from native protocol players."""
2197
2198 def test_ungroup_translates_visible_to_protocol_id(self, mock_mass: MagicMock) -> None:
2199 """Test that ungrouping correctly translates visible ID to protocol ID.
2200
2201 When ungrouping Sonos from Apple TV, the visible Sonos ID should be
2202 translated to its AirPlay protocol player ID for the removal.
2203 """
2204 controller = PlayerController(mock_mass)
2205
2206 airplay_provider = MockProvider("airplay", instance_id="airplay", mass=mock_mass)
2207 sonos_provider = MockProvider("sonos", instance_id="sonos", mass=mock_mass)
2208
2209 # Apple TV with Sonos's AirPlay protocol player in group_members
2210 apple_tv = MockPlayer(airplay_provider, "apple_tv_1", "Apple TV")
2211 apple_tv._attr_supported_features.add(PlayerFeature.PLAY_MEDIA)
2212 apple_tv._attr_supported_features.add(PlayerFeature.SET_MEMBERS)
2213 apple_tv._attr_group_members = ["apple_tv_1", "airplay_sonos"]
2214 apple_tv._cache.clear()
2215
2216 # Sonos visible player
2217 sonos_player = MockPlayer(sonos_provider, "sonos_1", "Sonos")
2218 sonos_player._attr_supported_features.add(PlayerFeature.PLAY_MEDIA)
2219 sonos_player._cache.clear()
2220
2221 # AirPlay protocol player for Sonos
2222 sonos_airplay = MockPlayer(
2223 airplay_provider,
2224 "airplay_sonos",
2225 "Sonos (AirPlay)",
2226 player_type=PlayerType.PROTOCOL,
2227 )
2228 sonos_airplay._cache.clear()
2229 sonos_airplay.set_protocol_parent_id("sonos_1")
2230
2231 sonos_player.set_linked_output_protocols(
2232 [
2233 OutputProtocol(
2234 output_protocol_id="airplay_sonos",
2235 name="AirPlay",
2236 protocol_domain="airplay",
2237 priority=10,
2238 available=True,
2239 )
2240 ]
2241 )
2242
2243 mock_mass.players = controller
2244 controller._players = {
2245 "apple_tv_1": apple_tv,
2246 "sonos_1": sonos_player,
2247 "airplay_sonos": sonos_airplay,
2248 }
2249 controller._player_throttlers = {
2250 "apple_tv_1": Throttler(1, 0.05),
2251 "sonos_1": Throttler(1, 0.05),
2252 "airplay_sonos": Throttler(1, 0.05),
2253 }
2254
2255 sonos_airplay.update_state(signal_event=False)
2256 sonos_player.update_state(signal_event=False)
2257 apple_tv.update_state(signal_event=False)
2258
2259 # Translate members for removal - visible ID should become protocol ID
2260 _protocol_members, native_members = controller._translate_members_to_remove_for_protocols(
2261 parent_player=apple_tv,
2262 player_ids=["sonos_1"], # Visible player ID
2263 parent_protocol_player=None,
2264 parent_protocol_domain=None,
2265 )
2266
2267 # Should translate to the protocol player ID for native removal
2268 assert "airplay_sonos" in native_members
2269 assert "sonos_1" not in native_members
2270