music-assistant-server

85.1 KBPY
test_protocol_linking.py
85.1 KB2,270 lines • python
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