music-assistant-server

489.7 KBPY
test_protocol_linking.py
489.7 KB12,405 lines • python
1"""Tests for protocol player linking and universal player creation."""
2
3import asyncio
4import inspect
5import logging
6import re
7from collections.abc import Awaitable, Iterator
8from typing import Any
9from unittest.mock import AsyncMock, MagicMock, patch
10from uuid import UUID
11
12import pytest
13from music_assistant_models.config_entries import PlayerConfig
14from music_assistant_models.enums import (
15    EventType,
16    IdentifierType,
17    PlaybackState,
18    PlayerFeature,
19    PlayerType,
20)
21from music_assistant_models.media_items import AudioSource
22from music_assistant_models.media_items.provider_mapping import ProviderMapping
23from music_assistant_models.player import PlayerMedia
24
25from music_assistant.constants import (
26    ATTR_ENABLED,
27    CONF_PLAYER_DSP,
28    CONF_PLAYER_QUEUES,
29    CONF_PLAYERS,
30    CONF_PREFERRED_OUTPUT_PROTOCOL,
31    CONF_PROTOCOL_PARENT_ID,
32    CONF_UNDERLYING_PLAYER_ID,
33)
34from music_assistant.controllers.config import ConfigController
35from music_assistant.controllers.config.migrations import migrate
36from music_assistant.controllers.players import PlayerController
37from music_assistant.helpers.util import enrich_device_mac_address
38from music_assistant.models.player import DeviceInfo, LinkedOutputProtocol, Player
39from music_assistant.models.player_provider import PlayerProvider
40from music_assistant.providers.universal_player.constants import CONF_CREATED_AT
41from music_assistant.providers.universal_player.player import UniversalPlayer
42from music_assistant.providers.universal_player.provider import UniversalPlayerProvider
43
44
45def create_mock_config(name: str) -> MagicMock:
46    """Create a mock player config with the given name."""
47    config = MagicMock()
48    config.name = None  # No custom name, use default
49    config.default_name = name
50    return config
51
52
53def create_mock_universal_provider(mock_mass: MagicMock) -> UniversalPlayerProvider:
54    """Create a mock UniversalPlayerProvider for testing."""
55    # Create a mock manifest
56    manifest = MagicMock()
57    manifest.domain = "universal_player"
58    manifest.name = "Universal Player"
59
60    # Create provider with the mock manifest
61    provider = UniversalPlayerProvider.__new__(UniversalPlayerProvider)
62    provider.mass = mock_mass
63    provider.manifest = manifest
64    provider.logger = logging.getLogger("test.universal_player")
65    # Add config mock for instance_id property
66    config = MagicMock()
67    config.instance_id = "universal_player"
68    config.name = None
69    provider.config = config
70    # Initialize the lock that would normally be set in handle_async_init
71    provider._lock = asyncio.Lock()
72    return provider
73
74
75# Player id of the universal player a device was wrapped in earlier, as minted once
76# by UniversalPlayerProvider.mint_player_id.
77STORED_UNIVERSAL_ID = "up1a2b3c4d"
78
79
80def _wire_nested_config(mock_mass: MagicMock, store: dict[str, Any]) -> None:
81    """Wire mass.config get/set/remove to a nested, path-addressable store."""
82
83    def _get(key: str, default: object = None) -> object:
84        node: Any = store
85        for part in key.split("/"):
86            if not isinstance(node, dict) or part not in node:
87                return default
88            node = node[part]
89        return node
90
91    def _set(key: str, value: object) -> None:
92        parts = key.split("/")
93        node: dict[str, Any] = store
94        for part in parts[:-1]:
95            node = node.setdefault(part, {})
96        node[parts[-1]] = value
97
98    def _remove(key: str) -> None:
99        parts = key.split("/")
100        node: Any = store
101        for part in parts[:-1]:
102            node = node.get(part) if isinstance(node, dict) else None
103            if node is None:
104                return
105        if isinstance(node, dict):
106            node.pop(parts[-1], None)
107
108    mock_mass.config.get = MagicMock(side_effect=_get)
109    mock_mass.config.set = MagicMock(side_effect=_set)
110    mock_mass.config.remove = MagicMock(side_effect=_remove)
111
112
113def _fake_create_default_player_config(
114    store: dict[str, Any],
115    player_id: str,
116    provider: str,
117    player_type: PlayerType,
118    name: str | None = None,
119    enabled: bool = True,
120    values: dict[str, Any] | None = None,
121) -> None:
122    """
123    Mirror ConfigController.create_default_player_config against a plain dict store.
124
125    Only the parts the universal player flows depend on: a fresh config keeps the
126    creation-time name as the default name only, an existing config keeps its type.
127    """
128    players: dict[str, Any] = store.setdefault(CONF_PLAYERS, {})
129    if existing := players.get(player_id):
130        if name and name != existing.get("default_name"):
131            existing["default_name"] = name
132        return
133    players[player_id] = {
134        "player_id": player_id,
135        "provider": provider,
136        "player_type": player_type.value,
137        "enabled": enabled,
138        "name": None,
139        "default_name": name,
140        "values": dict(values or {}),
141    }
142
143
144class MockProvider:
145    """Mock player provider for testing."""
146
147    def __init__(
148        self, domain: str, instance_id: str = "test_instance", mass: MagicMock | None = None
149    ) -> None:
150        """Initialize the mock provider."""
151        self.domain = domain
152        self.instance_id = instance_id
153        self.name = f"Mock {domain.title()}"
154        self.manifest = MagicMock()
155        self.manifest.name = f"Mock {domain} Provider"
156        self.mass = mass or MagicMock()
157        self.logger = logging.getLogger(f"test.{domain}")
158        # the controller iterates these when a group related field changes
159        self.players: list[Player] = []
160
161
162class MockPlayer(Player):
163    """Mock player for testing."""
164
165    def __init__(
166        self,
167        provider: MockProvider,
168        player_id: str,
169        name: str,
170        player_type: PlayerType = PlayerType.PLAYER,
171        identifiers: dict[IdentifierType, str] | None = None,
172    ) -> None:
173        """Initialize the mock player."""
174        # Set up the mock config before calling super().__init__
175        # because the parent __init__ accesses config
176        provider.mass.config.get_base_player_config.return_value = create_mock_config(name)
177
178        super().__init__(provider, player_id)  # type: ignore[arg-type]
179        self._attr_name = name
180        # Set type as instance attribute (overrides class attribute)
181        self._attr_type = player_type
182        self._attr_available = True
183        self._attr_powered = True
184        self._attr_supported_features = {PlayerFeature.VOLUME_SET}
185        self._attr_can_group_with = set()
186
187        # Set up device info with identifiers
188        self._attr_device_info = DeviceInfo(
189            model="Test Model",
190            manufacturer="Test Manufacturer",
191        )
192        if identifiers:
193            for conn_type, value in identifiers.items():
194                self._attr_device_info.add_identifier(conn_type, value)
195
196        # Clear cached properties after modifying attributes
197        self._cache.clear()
198
199        # Update state to reflect the modified attributes
200        self.update_state(signal_event=False)
201
202    async def stop(self) -> None:
203        """Stop playback - required abstract method."""
204
205    async def set_members(
206        self,
207        player_ids_to_add: list[str] | None = None,
208        player_ids_to_remove: list[str] | None = None,
209    ) -> None:
210        """Mock implementation of set_members."""
211        current_members = set(getattr(self, "_attr_group_members", []))
212
213        if player_ids_to_add:
214            current_members.update(player_ids_to_add)
215
216        if player_ids_to_remove:
217            current_members.difference_update(player_ids_to_remove)
218
219        # Always include self as first member if there are members
220        if current_members:
221            self._attr_group_members = [self.player_id] + [
222                pid for pid in current_members if pid != self.player_id
223            ]
224        else:
225            self._attr_group_members = []
226
227        # Clear cache to reflect changes
228        self._cache.clear()
229
230
231class SessionBoundMockPlayer(MockPlayer):
232    """Mock player whose native grouping attaches members to its own stream (e.g. AirPlay)."""
233
234    @property
235    def native_grouping_requires_own_stream(self) -> bool:
236        """Return True: native members ride this player's own stream session."""
237        return True
238
239
240@pytest.fixture
241def mock_mass() -> Iterator[MagicMock]:
242    """Create a mock MusicAssistant instance."""
243    mass = MagicMock()
244    mass.closing = False
245    mass.config = MagicMock()
246    mass.config.get = MagicMock(return_value=[])
247
248    def _get_raw_player_config_value(
249        _player_id: str, key: str, default: str | int | None = None
250    ) -> str | int | None:
251        """Return appropriate defaults for player config values."""
252        if key == "min_volume":
253            return 0
254        if key == "max_volume":
255            return 100
256        return default
257
258    mass.config.get_raw_player_config_value = MagicMock(side_effect=_get_raw_player_config_value)
259    # Return "GLOBAL" for log level config (standard default)
260    mass.config.get_raw_core_config_value = MagicMock(return_value="GLOBAL")
261    mass.config.set = MagicMock()
262    mass.signal_event = MagicMock()
263    mass.get_providers = MagicMock(return_value=[])
264    # awaited by the tests that replay the tasks scheduled during a config wipe
265    mass.webserver.auth.remove_from_user_filters = AsyncMock()
266    mass.webserver.auth.replace_player_in_user_filters = AsyncMock()
267    yield mass
268    for call in mass.create_task.call_args_list:
269        if call.args and inspect.iscoroutine(coro := call.args[0]):
270            coro.close()
271
272
273class TestIdentifiersMatch:
274    """Tests for identifier matching logic."""
275
276    def test_mac_address_match(self, mock_mass: MagicMock) -> None:
277        """Test that MAC addresses match correctly."""
278        controller = PlayerController(mock_mass)
279
280        provider = MockProvider("test")
281        player_a = MockPlayer(
282            provider,
283            "player_a",
284            "Player A",
285            identifiers={IdentifierType.MAC_ADDRESS: "AA:BB:CC:DD:EE:FF"},
286        )
287        player_b = MockPlayer(
288            provider,
289            "player_b",
290            "Player B",
291            identifiers={IdentifierType.MAC_ADDRESS: "aa:bb:cc:dd:ee:ff"},  # lowercase
292        )
293
294        assert controller._identifiers_match(player_a, player_b) is True
295
296    def test_mac_address_no_match(self, mock_mass: MagicMock) -> None:
297        """Test that different MAC addresses don't match."""
298        controller = PlayerController(mock_mass)
299
300        provider = MockProvider("test")
301        player_a = MockPlayer(
302            provider,
303            "player_a",
304            "Player A",
305            identifiers={IdentifierType.MAC_ADDRESS: "AA:BB:CC:DD:EE:FF"},
306        )
307        player_b = MockPlayer(
308            provider,
309            "player_b",
310            "Player B",
311            identifiers={IdentifierType.MAC_ADDRESS: "11:22:33:44:55:66"},
312        )
313
314        assert controller._identifiers_match(player_a, player_b) is False
315
316    def test_invalid_mac_address_no_match(self, mock_mass: MagicMock) -> None:
317        """Test that an all-zero MAC is not identity and never matches across devices."""
318        controller = PlayerController(mock_mass)
319
320        provider = MockProvider("test")
321        player_a = MockPlayer(
322            provider,
323            "player_a",
324            "Player A",
325            identifiers={IdentifierType.MAC_ADDRESS: "00:00:00:00:00:00"},
326        )
327        player_b = MockPlayer(
328            provider,
329            "player_b",
330            "Player B",
331            identifiers={IdentifierType.MAC_ADDRESS: "00:00:00:00:00:00"},
332        )
333
334        assert controller._identifiers_match(player_a, player_b) is False
335
336    def test_mac_address_locally_administered_bit_match(self, mock_mass: MagicMock) -> None:
337        """
338        Test that MAC addresses differing only in locally-administered bit match.
339
340        Some protocols (like AirPlay) report a MAC with the locally-administered
341        bit set (bit 1 of first octet), while other protocols report the real
342        hardware MAC. These should match as the same device.
343
344        Example: 54:78:C9:E6:0D:A0 (hardware) vs 56:78:C9:E6:0D:A0 (AirPlay)
345        """
346        controller = PlayerController(mock_mass)
347
348        provider = MockProvider("test")
349        # Real hardware MAC (first byte 0x54 = 01010100, bit 1 = 0)
350        player_a = MockPlayer(
351            provider,
352            "player_a",
353            "WiiM Pro (DLNA)",
354            identifiers={IdentifierType.MAC_ADDRESS: "54:78:C9:E6:0D:A0"},
355        )
356        # AirPlay MAC with locally-administered bit set (first byte 0x56 = 01010110, bit 1 = 1)
357        player_b = MockPlayer(
358            provider,
359            "player_b",
360            "WiiM Pro (AirPlay)",
361            identifiers={IdentifierType.MAC_ADDRESS: "56:78:C9:E6:0D:A0"},
362        )
363
364        # These should match because they differ only in the locally-administered bit
365        assert controller._identifiers_match(player_a, player_b) is True
366
367    def test_mac_address_locally_administered_bit_different_devices_no_match(
368        self, mock_mass: MagicMock
369    ) -> None:
370        """
371        Test that different devices with locally-administered MACs don't match.
372
373        Only the locally-administered bit should be ignored, not other differences.
374        """
375        controller = PlayerController(mock_mass)
376
377        provider = MockProvider("test")
378        player_a = MockPlayer(
379            provider,
380            "player_a",
381            "Device A",
382            identifiers={IdentifierType.MAC_ADDRESS: "54:78:C9:E6:0D:A0"},
383        )
384        player_b = MockPlayer(
385            provider,
386            "player_b",
387            "Device B",
388            identifiers={IdentifierType.MAC_ADDRESS: "56:78:C9:E6:0D:A1"},  # Different last byte
389        )
390
391        # These should NOT match - they differ in more than just the locally-administered bit
392        assert controller._identifiers_match(player_a, player_b) is False
393
394    def test_ip_address_fallback_with_no_mac(self, mock_mass: MagicMock) -> None:
395        """Test that same IP matches when no MAC is available (ARP couldn't help)."""
396        controller = PlayerController(mock_mass)
397
398        provider = MockProvider("test")
399        player_a = MockPlayer(
400            provider,
401            "player_a",
402            "Player A",
403            identifiers={IdentifierType.IP_ADDRESS: "192.168.1.100"},
404        )
405        player_b = MockPlayer(
406            provider,
407            "player_b",
408            "Player B",
409            identifiers={IdentifierType.IP_ADDRESS: "192.168.1.100"},
410        )
411
412        # Same IP with no MAC at all = match (likely same device, ARP unavailable)
413        assert controller._identifiers_match(player_a, player_b) is True
414
415    def test_sonos_uuid_dlna_suffix_match(self, mock_mass: MagicMock) -> None:
416        """Test Sonos UUID matching with DLNA _MR suffix."""
417        controller = PlayerController(mock_mass)
418
419        provider = MockProvider("test")
420        # Sonos native player
421        player_a = MockPlayer(
422            provider,
423            "player_a",
424            "Sonos Player",
425            identifiers={IdentifierType.UUID: "RINCON_000E58123456"},
426        )
427        # DLNA player with _MR suffix
428        player_b = MockPlayer(
429            provider,
430            "player_b",
431            "DLNA Player",
432            identifiers={IdentifierType.UUID: "RINCON_000E58123456_MR"},
433        )
434
435        assert controller._identifiers_match(player_a, player_b) is True
436
437    def test_no_identifiers_no_match(self, mock_mass: MagicMock) -> None:
438        """Test that players without identifiers don't match."""
439        controller = PlayerController(mock_mass)
440
441        provider = MockProvider("test")
442        player_a = MockPlayer(provider, "player_a", "Player A")
443        player_b = MockPlayer(provider, "player_b", "Player B")
444
445        assert controller._identifiers_match(player_a, player_b) is False
446
447    def test_cast_uuid_match(self, mock_mass: MagicMock) -> None:
448        """
449        Test that CAST_UUID identifiers match for cross-protocol linking.
450
451        When a Chromecast device has no valid MAC (non-Google devices like SEI Robotics),
452        the CAST_UUID identifier allows matching the Chromecast player with its
453        Sendspin bridge player.
454        """
455        controller = PlayerController(mock_mass)
456
457        provider = MockProvider("test")
458        # Chromecast player with UUID and CAST_UUID but no MAC
459        player_a = MockPlayer(
460            provider,
461            "player_a",
462            "Android TV (Cast)",
463            player_type=PlayerType.PROTOCOL,
464            identifiers={
465                IdentifierType.UUID: "88ef6168-67d7-3d08-fae8-b1b1709c1ed7",
466                IdentifierType.CAST_UUID: "88ef6168-67d7-3d08-fae8-b1b1709c1ed7",
467                IdentifierType.IP_ADDRESS: "192.168.1.228",
468            },
469        )
470        # Sendspin bridge player with only MAC and CAST_UUID (no UUID, no IP)
471        player_b = MockPlayer(
472            provider,
473            "player_b",
474            "Android TV (Sendspin)",
475            player_type=PlayerType.PROTOCOL,
476            identifiers={
477                IdentifierType.MAC_ADDRESS: "D4:CF:F9:D9:DA:FD",
478                IdentifierType.CAST_UUID: "88ef6168-67d7-3d08-fae8-b1b1709c1ed7",
479            },
480        )
481
482        # Should match via CAST_UUID even though they share no MAC, UUID, or IP
483        assert controller._identifiers_match(player_a, player_b) is True
484
485    def test_cast_uuid_no_match(self, mock_mass: MagicMock) -> None:
486        """Test that different CAST_UUIDs don't match."""
487        controller = PlayerController(mock_mass)
488
489        provider = MockProvider("test")
490        player_a = MockPlayer(
491            provider,
492            "player_a",
493            "Device A",
494            player_type=PlayerType.PROTOCOL,
495            identifiers={IdentifierType.CAST_UUID: "88ef6168-67d7-3d08-fae8-b1b1709c1ed7"},
496        )
497        player_b = MockPlayer(
498            provider,
499            "player_b",
500            "Device B",
501            player_type=PlayerType.PROTOCOL,
502            identifiers={IdentifierType.CAST_UUID: "aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee"},
503        )
504
505        assert controller._identifiers_match(player_a, player_b) is False
506
507    def test_airplay_id_match(self, mock_mass: MagicMock) -> None:
508        """
509        Test that AIRPLAY_ID identifiers match for cross-protocol linking.
510
511        When an AirPlay device's MAC has locally-administered bit differences,
512        AIRPLAY_ID provides a reliable secondary match path between the AirPlay
513        player and its Sendspin bridge player.
514        """
515        controller = PlayerController(mock_mass)
516
517        provider = MockProvider("test")
518        # AirPlay player with its player_id as AIRPLAY_ID
519        player_a = MockPlayer(
520            provider,
521            "player_a",
522            "Apple TV (AirPlay)",
523            player_type=PlayerType.PROTOCOL,
524            identifiers={
525                IdentifierType.MAC_ADDRESS: "54:78:1A:D4:FF:44",
526                IdentifierType.AIRPLAY_ID: "ap54781ad4ff44",
527                IdentifierType.IP_ADDRESS: "192.168.1.50",
528            },
529        )
530        # Sendspin bridge player with MAC and AIRPLAY_ID
531        player_b = MockPlayer(
532            provider,
533            "player_b",
534            "Apple TV (Sendspin)",
535            player_type=PlayerType.PROTOCOL,
536            identifiers={
537                IdentifierType.MAC_ADDRESS: "54:78:1A:D4:FF:44",
538                IdentifierType.AIRPLAY_ID: "ap54781ad4ff44",
539            },
540        )
541
542        # Should match via both MAC and AIRPLAY_ID
543        assert controller._identifiers_match(player_a, player_b) is True
544
545    def test_airplay_id_no_match(self, mock_mass: MagicMock) -> None:
546        """Test that different AIRPLAY_IDs don't match."""
547        controller = PlayerController(mock_mass)
548
549        provider = MockProvider("test")
550        player_a = MockPlayer(
551            provider,
552            "player_a",
553            "Device A",
554            player_type=PlayerType.PROTOCOL,
555            identifiers={IdentifierType.AIRPLAY_ID: "ap54781ad4ff44"},
556        )
557        player_b = MockPlayer(
558            provider,
559            "player_b",
560            "Device B",
561            player_type=PlayerType.PROTOCOL,
562            identifiers={IdentifierType.AIRPLAY_ID: "apaabbccddeeff"},
563        )
564
565        assert controller._identifiers_match(player_a, player_b) is False
566
567    def test_ip_fallback_with_la_mac(self, mock_mass: MagicMock) -> None:
568        """Test IP fallback matching when one player has a locally-administered MAC."""
569        controller = PlayerController(mock_mass)
570
571        provider = MockProvider("test")
572        player_a = MockPlayer(
573            provider,
574            "player_a",
575            "AirPlay Device",
576            player_type=PlayerType.PROTOCOL,
577            identifiers={
578                IdentifierType.MAC_ADDRESS: "C0:F5:35:7B:6D:A0",
579                IdentifierType.IP_ADDRESS: "192.168.1.48",
580            },
581        )
582        player_b = MockPlayer(
583            provider,
584            "player_b",
585            "DLNA Device",
586            player_type=PlayerType.PROTOCOL,
587            identifiers={
588                # Locally-administered MAC from ARP (device uses MAC randomization)
589                IdentifierType.MAC_ADDRESS: "02:4E:52:A1:BC:6B",
590                IdentifierType.IP_ADDRESS: "192.168.1.48",
591            },
592        )
593
594        assert controller._identifiers_match(player_a, player_b) is True
595
596    def test_ip_fallback_with_missing_mac(self, mock_mass: MagicMock) -> None:
597        """Test IP fallback matching when one player has no MAC at all."""
598        controller = PlayerController(mock_mass)
599
600        provider = MockProvider("test")
601        player_a = MockPlayer(
602            provider,
603            "player_a",
604            "AirPlay Device",
605            player_type=PlayerType.PROTOCOL,
606            identifiers={
607                IdentifierType.MAC_ADDRESS: "C0:F5:35:7B:6D:A0",
608                IdentifierType.IP_ADDRESS: "192.168.1.48",
609            },
610        )
611        player_b = MockPlayer(
612            provider,
613            "player_b",
614            "DLNA Device",
615            player_type=PlayerType.PROTOCOL,
616            identifiers={
617                IdentifierType.IP_ADDRESS: "192.168.1.48",
618            },
619        )
620
621        assert controller._identifiers_match(player_a, player_b) is True
622
623    def test_ip_fallback_protocol_players_with_real_macs(self, mock_mass: MagicMock) -> None:
624        """
625        Test that IP matches protocol players even when both have real MACs.
626
627        Some devices (e.g., Yamaha MusicCast) report different valid globally-unique
628        MACs per protocol (DLNA vs AirPlay differ by 1 in the last octet).
629        IP matching is safe here because two devices on a LAN can't share an IP.
630        """
631        controller = PlayerController(mock_mass)
632
633        provider = MockProvider("test")
634        player_a = MockPlayer(
635            provider,
636            "player_a",
637            "Device A",
638            player_type=PlayerType.PROTOCOL,
639            identifiers={
640                IdentifierType.MAC_ADDRESS: "C0:F5:35:7B:6D:A0",
641                IdentifierType.IP_ADDRESS: "192.168.1.48",
642            },
643        )
644        player_b = MockPlayer(
645            provider,
646            "player_b",
647            "Device B",
648            player_type=PlayerType.PROTOCOL,
649            identifiers={
650                # A8 first octet = 10101000, LA bit (0x02) is clear -> real MAC
651                IdentifierType.MAC_ADDRESS: "A8:BB:CC:DD:EE:FF",
652                IdentifierType.IP_ADDRESS: "192.168.1.48",
653            },
654        )
655
656        # Protocol players with real MACs on same IP -> match (same physical device)
657        assert controller._identifiers_match(player_a, player_b) is True
658
659    def test_ip_no_fallback_native_players_with_real_macs(self, mock_mass: MagicMock) -> None:
660        """Test that IP alone does NOT match two native PLAYER-type players with real MACs."""
661        controller = PlayerController(mock_mass)
662
663        provider = MockProvider("test")
664        player_a = MockPlayer(
665            provider,
666            "player_a",
667            "Device A",
668            player_type=PlayerType.PLAYER,
669            identifiers={
670                IdentifierType.MAC_ADDRESS: "C0:F5:35:7B:6D:A0",
671                IdentifierType.IP_ADDRESS: "192.168.1.48",
672            },
673        )
674        player_b = MockPlayer(
675            provider,
676            "player_b",
677            "Device B",
678            player_type=PlayerType.PLAYER,
679            identifiers={
680                IdentifierType.MAC_ADDRESS: "A8:BB:CC:DD:EE:FF",
681                IdentifierType.IP_ADDRESS: "192.168.1.48",
682            },
683        )
684
685        # Two native players with real MACs that don't match - IP should NOT be used
686        assert controller._identifiers_match(player_a, player_b) is False
687
688    def test_ip_no_fallback_different_ips(self, mock_mass: MagicMock) -> None:
689        """Test that different IPs don't match even with LA MAC."""
690        controller = PlayerController(mock_mass)
691
692        provider = MockProvider("test")
693        player_a = MockPlayer(
694            provider,
695            "player_a",
696            "Device A",
697            player_type=PlayerType.PROTOCOL,
698            identifiers={
699                IdentifierType.MAC_ADDRESS: "C0:F5:35:7B:6D:A0",
700                IdentifierType.IP_ADDRESS: "192.168.1.48",
701            },
702        )
703        player_b = MockPlayer(
704            provider,
705            "player_b",
706            "Device B",
707            player_type=PlayerType.PROTOCOL,
708            identifiers={
709                IdentifierType.MAC_ADDRESS: "02:4E:52:A1:BC:6B",
710                IdentifierType.IP_ADDRESS: "192.168.1.99",
711            },
712        )
713
714        assert controller._identifiers_match(player_a, player_b) is False
715
716
717class TestProtocolPlayerDetection:
718    """Tests for protocol player type detection."""
719
720    def test_is_protocol_player_true(self, mock_mass: MagicMock) -> None:
721        """Test that PlayerType.PROTOCOL is correctly detected."""
722        controller = PlayerController(mock_mass)
723
724        provider = MockProvider("airplay")
725        player = MockPlayer(
726            provider,
727            "ap_123456",
728            "Samsung TV (AirPlay)",
729            player_type=PlayerType.PROTOCOL,
730        )
731
732        assert controller._is_protocol_player(player) is True
733
734    def test_is_protocol_player_false(self, mock_mass: MagicMock) -> None:
735        """Test that PlayerType.PLAYER is not detected as protocol."""
736        controller = PlayerController(mock_mass)
737
738        provider = MockProvider("airplay")
739        player = MockPlayer(
740            provider,
741            "ap_123456",
742            "HomePod",
743            player_type=PlayerType.PLAYER,  # Apple device with native support
744        )
745
746        assert controller._is_protocol_player(player) is False
747
748
749class TestFindMatchingProtocolPlayers:
750    """Tests for finding matching protocol players."""
751
752    def test_find_matching_by_mac(self, mock_mass: MagicMock) -> None:
753        """Test finding matching protocol players by MAC address."""
754        controller = PlayerController(mock_mass)
755
756        # Set up providers
757        airplay_provider = MockProvider("airplay")
758        chromecast_provider = MockProvider("chromecast")
759
760        # Create matching protocol players (same device, different protocols)
761        airplay_player = MockPlayer(
762            airplay_provider,
763            "ap_aabbccddee",
764            "Samsung TV (AirPlay)",
765            player_type=PlayerType.PROTOCOL,
766            identifiers={IdentifierType.MAC_ADDRESS: "AA:BB:CC:DD:EE:FF"},
767        )
768        chromecast_player = MockPlayer(
769            chromecast_provider,
770            "cc_aabbccddee",
771            "Samsung TV (Chromecast)",
772            player_type=PlayerType.PROTOCOL,
773            identifiers={IdentifierType.MAC_ADDRESS: "AA:BB:CC:DD:EE:FF"},
774        )
775
776        # Register players
777        controller._players = {
778            "ap_aabbccddee": airplay_player,
779            "cc_aabbccddee": chromecast_player,
780        }
781
782        # Mark players as initialized so they are returned by all_players()
783        airplay_player.set_initialized()
784        chromecast_player.set_initialized()
785
786        # Find matching players for AirPlay player
787        matches = controller._find_matching_protocol_players(airplay_player)
788
789        assert len(matches) == 2
790        assert airplay_player in matches
791        assert chromecast_player in matches
792
793    def test_same_protocol_not_matched(self, mock_mass: MagicMock) -> None:
794        """Test that multiple players of same protocol on same host are NOT matched together."""
795        controller = PlayerController(mock_mass)
796
797        # Set up provider
798        snapcast_provider = MockProvider("snapcast")
799
800        # Create multiple Snapcast players on same host (same MAC/IP)
801        # This simulates multiple Snapcast clients running on the same server
802        snapcast_player_1 = MockPlayer(
803            snapcast_provider,
804            "snapcast_client_1",
805            "Snapcast Client 1",
806            player_type=PlayerType.PROTOCOL,
807            identifiers={
808                IdentifierType.MAC_ADDRESS: "AA:BB:CC:DD:EE:FF",
809                IdentifierType.IP_ADDRESS: "192.168.1.100",
810            },
811        )
812        snapcast_player_2 = MockPlayer(
813            snapcast_provider,
814            "snapcast_client_2",
815            "Snapcast Client 2",
816            player_type=PlayerType.PROTOCOL,
817            identifiers={
818                IdentifierType.MAC_ADDRESS: "AA:BB:CC:DD:EE:FF",
819                IdentifierType.IP_ADDRESS: "192.168.1.100",
820            },
821        )
822
823        # Register players
824        controller._players = {
825            "snapcast_client_1": snapcast_player_1,
826            "snapcast_client_2": snapcast_player_2,
827        }
828
829        # Find matching players for first Snapcast player
830        matches = controller._find_matching_protocol_players(snapcast_player_1)
831
832        # Should only match itself, NOT the other Snapcast player (same protocol domain)
833        assert len(matches) == 1
834        assert snapcast_player_1 in matches
835        assert snapcast_player_2 not in matches
836
837
838class TestResolveStoredUniversalPlayer:
839    """
840    Tests for resolving the universal player a protocol player is persistently linked to.
841
842    Regression tests for music-assistant/support#5888: the universal player id is
843    minted once and a device is always routed back to the wrapper its protocol
844    players point at, so the id API consumers (such as the Home Assistant
845    integration) bind their entities to stays put.
846    """
847
848    @staticmethod
849    def _stored_universal_config(**overrides: Any) -> dict[str, Any]:
850        """Return the stored config of a universal player wrapping the ap_123456 device."""
851        return {
852            "player_id": STORED_UNIVERSAL_ID,
853            "provider": "universal_player",
854            "player_type": "player",
855            "enabled": True,
856            "default_name": "Living Room",
857            "values": {
858                "linked_protocol_ids": ["ap_123456"],
859                "device_identifiers": {"mac_address": "54:78:C9:E6:0D:A0"},
860                "device_info": {"model": "WiiM Pro", "manufacturer": "Linkplay"},
861            },
862        } | overrides
863
864    @staticmethod
865    def _wire_config(
866        mock_mass: MagicMock,
867        parent_id: str | None,
868        parent_config: dict[str, Any] | None = None,
869    ) -> None:
870        """
871        Wire a config store holding the ap_123456 protocol player and its parent.
872
873        :param mock_mass: The mocked MusicAssistant instance to wire.
874        :param parent_id: Parent link persisted on the protocol player, if any.
875        :param parent_config: Stored config of the parent player, when it (still) exists.
876        """
877        players: dict[str, Any] = {
878            "ap_123456": {
879                "player_id": "ap_123456",
880                "provider": "airplay",
881                "player_type": "protocol",
882                "enabled": True,
883                "values": {"protocol_parent_id": parent_id},
884            }
885        }
886        if parent_config is not None:
887            players[parent_config["player_id"]] = parent_config
888        _wire_nested_config(mock_mass, {CONF_PLAYERS: players})
889
890    @staticmethod
891    def _wire_players(mock_mass: MagicMock, registry: dict[str, Player]) -> None:
892        """Wire mass.players so registrations land in the given registry."""
893
894        async def _register(player: Player) -> None:
895            registry[player.player_id] = player
896            player.set_initialized()
897
898        mock_mass.players = MagicMock()
899        mock_mass.players.get_player = MagicMock(
900            side_effect=lambda pid, *_args, **_kwargs: registry.get(pid)
901        )
902        mock_mass.players.register_or_update = AsyncMock(side_effect=_register)
903
904    @staticmethod
905    def _make_protocol_player(mock_mass: MagicMock) -> MockPlayer:
906        """Return the unlinked AirPlay protocol player of the wrapped device."""
907        player = MockPlayer(
908            MockProvider("airplay", mass=mock_mass),
909            "ap_123456",
910            "Test Player",
911            player_type=PlayerType.PROTOCOL,
912            identifiers={IdentifierType.MAC_ADDRESS: "54:78:C9:E6:0D:A0"},
913        )
914        player.set_initialized()
915        return player
916
917    async def test_registered_parent_resolves_to_its_universal_player(
918        self, mock_mass: MagicMock
919    ) -> None:
920        """The stored parent link resolves to the universal player it names."""
921        universal_provider = create_mock_universal_provider(mock_mass)
922        self._wire_config(mock_mass, STORED_UNIVERSAL_ID, self._stored_universal_config())
923        registry: dict[str, Player] = {}
924        self._wire_players(mock_mass, registry)
925        universal = _create_universal_player(
926            mock_mass, STORED_UNIVERSAL_ID, "Living Room", protocol_player_ids=["ap_123456"]
927        )
928        registry[STORED_UNIVERSAL_ID] = universal
929        player = self._make_protocol_player(mock_mass)
930
931        assert await universal_provider._resolve_stored_universal_player(player) is universal
932        mock_mass.players.register_or_update.assert_not_awaited()
933
934    async def test_unregistered_parent_is_restored_from_its_config(
935        self, mock_mass: MagicMock
936    ) -> None:
937        """A stored parent that is not registered is restored under its own player id."""
938        universal_provider = create_mock_universal_provider(mock_mass)
939        self._wire_config(mock_mass, STORED_UNIVERSAL_ID, self._stored_universal_config())
940        registry: dict[str, Player] = {}
941        self._wire_players(mock_mass, registry)
942        player = self._make_protocol_player(mock_mass)
943
944        resolved = await universal_provider._resolve_stored_universal_player(player)
945
946        assert resolved is not None
947        assert resolved.player_id == STORED_UNIVERSAL_ID
948        assert resolved._protocol_player_ids == ["ap_123456"]
949        assert registry[STORED_UNIVERSAL_ID] is resolved
950
951    async def test_parent_config_of_another_provider_is_ignored(self, mock_mass: MagicMock) -> None:
952        """A parent config owned by another provider is never ours to resolve to."""
953        universal_provider = create_mock_universal_provider(mock_mass)
954        # a native player whose id merely happens to start with the universal player prefix
955        self._wire_config(
956            mock_mass,
957            "upnp_media_renderer",
958            {
959                "player_id": "upnp_media_renderer",
960                "provider": "some_native_provider",
961                "enabled": True,
962                "values": {"linked_protocol_ids": ["ap_123456"]},
963            },
964        )
965        registry: dict[str, Player] = {}
966        self._wire_players(mock_mass, registry)
967        player = self._make_protocol_player(mock_mass)
968
969        assert await universal_provider._resolve_stored_universal_player(player) is None
970        mock_mass.players.register_or_update.assert_not_awaited()
971
972    async def test_disabled_parent_config_is_ignored(self, mock_mass: MagicMock) -> None:
973        """A disabled universal player stays off: the user turned this device off."""
974        universal_provider = create_mock_universal_provider(mock_mass)
975        self._wire_config(
976            mock_mass, STORED_UNIVERSAL_ID, self._stored_universal_config(enabled=False)
977        )
978        registry: dict[str, Player] = {}
979        self._wire_players(mock_mass, registry)
980        player = self._make_protocol_player(mock_mass)
981
982        assert await universal_provider._resolve_stored_universal_player(player) is None
983        mock_mass.players.register_or_update.assert_not_awaited()
984
985    async def test_stored_parent_without_a_config_is_ignored(self, mock_mass: MagicMock) -> None:
986        """A parent link left dangling by a deleted config resolves to nothing."""
987        universal_provider = create_mock_universal_provider(mock_mass)
988        self._wire_config(mock_mass, STORED_UNIVERSAL_ID)
989        registry: dict[str, Player] = {}
990        self._wire_players(mock_mass, registry)
991        player = self._make_protocol_player(mock_mass)
992
993        assert await universal_provider._resolve_stored_universal_player(player) is None
994        mock_mass.players.register_or_update.assert_not_awaited()
995
996    async def test_without_a_stored_parent_link_nothing_resolves(
997        self, mock_mass: MagicMock
998    ) -> None:
999        """A protocol player that was never wrapped resolves to no universal player."""
1000        universal_provider = create_mock_universal_provider(mock_mass)
1001        self._wire_config(mock_mass, None)
1002        registry: dict[str, Player] = {}
1003        self._wire_players(mock_mass, registry)
1004        player = self._make_protocol_player(mock_mass)
1005
1006        assert await universal_provider._resolve_stored_universal_player(player) is None
1007
1008
1009class TestMintPlayerId:
1010    """Tests for minting the opaque player id of a new universal player."""
1011
1012    def test_minted_id_is_free_as_a_player_and_as_a_config(self, mock_mass: MagicMock) -> None:
1013        """A minted id is never one that is already taken."""
1014        universal_provider = create_mock_universal_provider(mock_mass)
1015        _wire_nested_config(
1016            mock_mass,
1017            {CONF_PLAYERS: {"upbbbbbbbb": {"player_id": "upbbbbbbbb", "enabled": False}}},
1018        )
1019        registered = {"upaaaaaaaa": MagicMock()}
1020        mock_mass.players = MagicMock()
1021        mock_mass.players.get_player = MagicMock(
1022            side_effect=lambda pid, *_args, **_kwargs: registered.get(pid)
1023        )
1024        candidates = [
1025            UUID("aaaaaaaa-0000-0000-0000-000000000000"),  # taken by a registered player
1026            UUID("bbbbbbbb-0000-0000-0000-000000000000"),  # taken by a (stale) config
1027            UUID("cccccccc-0000-0000-0000-000000000000"),
1028        ]
1029
1030        with patch(
1031            "music_assistant.providers.universal_player.provider.uuid4", side_effect=candidates
1032        ):
1033            assert universal_provider.mint_player_id() == "upcccccccc"
1034
1035
1036class TestGetCleanPlayerName:
1037    """Tests for player name selection."""
1038
1039    def test_prefers_chromecast_name(self, mock_mass: MagicMock) -> None:
1040        """Test that Chromecast names are preferred over other protocols."""
1041        universal_provider = create_mock_universal_provider(mock_mass)
1042
1043        airplay_provider = MockProvider("airplay")
1044        chromecast_provider = MockProvider("chromecast")
1045
1046        airplay_player = MockPlayer(
1047            airplay_provider,
1048            "ap_123456",
1049            "Samsung TV",
1050            player_type=PlayerType.PROTOCOL,
1051        )
1052        chromecast_player = MockPlayer(
1053            chromecast_provider,
1054            "cc_123456",
1055            "Living Room Speaker",
1056            player_type=PlayerType.PROTOCOL,
1057        )
1058
1059        # Chromecast should be preferred (priority 1)
1060        clean_name = universal_provider._get_clean_player_name([airplay_player, chromecast_player])
1061        assert clean_name == "Living Room Speaker"
1062
1063    def test_filters_mac_address_names(self, mock_mass: MagicMock) -> None:
1064        """Test that MAC address-like names are filtered out."""
1065        universal_provider = create_mock_universal_provider(mock_mass)
1066
1067        squeezelite_provider = MockProvider("squeezelite")
1068        airplay_provider = MockProvider("airplay")
1069
1070        # Squeezelite with MAC address as name
1071        sq_player = MockPlayer(
1072            squeezelite_provider,
1073            "sq_123456",
1074            "AA:BB:CC:DD:EE:FF",
1075            player_type=PlayerType.PROTOCOL,
1076        )
1077        # AirPlay with proper name
1078        ap_player = MockPlayer(
1079            airplay_provider,
1080            "ap_123456",
1081            "Kitchen Speaker",
1082            player_type=PlayerType.PROTOCOL,
1083        )
1084
1085        # Should prefer Kitchen Speaker over MAC address
1086        clean_name = universal_provider._get_clean_player_name([sq_player, ap_player])
1087        assert clean_name == "Kitchen Speaker"
1088
1089    def test_filters_player_id_names(self, mock_mass: MagicMock) -> None:
1090        """Test that player ID-like names are filtered out."""
1091        universal_provider = create_mock_universal_provider(mock_mass)
1092
1093        sendspin_provider = MockProvider("sendspin")
1094        dlna_provider = MockProvider("dlna")
1095
1096        # SendSpin with player ID as name
1097        ss_player = MockPlayer(
1098            sendspin_provider,
1099            "sendspin_123456",
1100            "sendspin_device_abc",
1101            player_type=PlayerType.PROTOCOL,
1102        )
1103        # DLNA with proper name
1104        dlna_player = MockPlayer(
1105            dlna_provider,
1106            "dlna_123456",
1107            "Bedroom TV",
1108            player_type=PlayerType.PROTOCOL,
1109        )
1110
1111        # Should prefer Bedroom TV over player ID
1112        clean_name = universal_provider._get_clean_player_name([ss_player, dlna_player])
1113        assert clean_name == "Bedroom TV"
1114
1115    def test_valid_name_unchanged(self, mock_mass: MagicMock) -> None:
1116        """Test that valid names are returned unchanged."""
1117        universal_provider = create_mock_universal_provider(mock_mass)
1118
1119        provider = MockProvider("airplay")
1120        player = MockPlayer(
1121            provider,
1122            "ap_123456",
1123            "HomePod Mini",
1124            player_type=PlayerType.PLAYER,
1125        )
1126
1127        clean_name = universal_provider._get_clean_player_name([player])
1128        assert clean_name == "HomePod Mini"
1129
1130
1131class TestCachedProtocolParentRestore:
1132    """Tests for restoring cached protocol parent links."""
1133
1134    def test_protocol_parent_id_restored_from_config(self, mock_mass: MagicMock) -> None:
1135        """Test that cached protocol_parent_id is loaded and used for immediate linking."""
1136        controller = PlayerController(mock_mass)
1137
1138        # Mock config to return cached parent_id when queried
1139        def mock_config_get(key: str, default: str | None = None) -> str | None:
1140            if "protocol_parent_id" in str(key):
1141                return "native_player_id"
1142            return default
1143
1144        mock_mass.config.get.side_effect = mock_config_get
1145
1146        # Create native player
1147        native_provider = MockProvider("sonos", mass=mock_mass)
1148        native_player = MockPlayer(
1149            native_provider,
1150            "native_player_id",
1151            "Sonos Speaker",
1152            identifiers={IdentifierType.MAC_ADDRESS: "AA:BB:CC:DD:EE:FF"},
1153        )
1154
1155        # Create protocol player
1156        dlna_provider = MockProvider("dlna", mass=mock_mass)
1157        protocol_player = MockPlayer(
1158            dlna_provider,
1159            "uuid:RINCON_AABBCCDDEEFF_MR",
1160            "Sonos DLNA",
1161            player_type=PlayerType.PROTOCOL,
1162        )
1163
1164        # Register native player
1165        controller._players = {"native_player_id": native_player}
1166
1167        # Try to link protocol to native - should load cached parent_id
1168        controller._try_link_protocol_to_native(protocol_player)
1169
1170        # Verify protocol_parent_id was set
1171        assert protocol_player.protocol_parent_id == "native_player_id"
1172
1173        # Verify protocol was linked to native player
1174        assert any(
1175            link.output_protocol_id == protocol_player.player_id
1176            for link in native_player.linked_output_protocols
1177        )
1178
1179    def test_missing_cached_parent_schedules_protocol_evaluation(
1180        self, mock_mass: MagicMock
1181    ) -> None:
1182        """Test that a missing cached parent does not block delayed protocol evaluation."""
1183        controller = PlayerController(mock_mass)
1184
1185        # Mock config to return cached parent_id (parent not yet registered)
1186        def mock_config_get(key: str, default: str | None = None) -> str | None:
1187            if "protocol_parent_id" in str(key):
1188                return "native_player_id"
1189            return default
1190
1191        mock_mass.config.get.side_effect = mock_config_get
1192
1193        # Create protocol player
1194        dlna_provider = MockProvider("dlna", mass=mock_mass)
1195        protocol_player = MockPlayer(
1196            dlna_provider,
1197            "uuid:RINCON_AABBCCDDEEFF_MR",
1198            "Sonos DLNA",
1199            player_type=PlayerType.PROTOCOL,
1200        )
1201
1202        # No native player registered yet
1203        controller._players = {}
1204
1205        with patch.object(controller, "_schedule_protocol_evaluation") as mock_schedule:
1206            controller._try_link_protocol_to_native(protocol_player)
1207
1208        # A cached parent that is not registered yet should be handled by the
1209        # delayed evaluation path, not by assigning a dangling in-memory parent.
1210        assert protocol_player.protocol_parent_id is None
1211        mock_schedule.assert_called_once_with(protocol_player)
1212
1213    def test_stale_cache_does_not_claim_a_protocol_waiting_for_its_owner(
1214        self, mock_mass: MagicMock
1215    ) -> None:
1216        """A protocol whose persisted owner is still starting up is not claimed by another parent."""
1217        controller = PlayerController(mock_mass)
1218
1219        store: dict[str, Any] = {
1220            CONF_PLAYERS: {
1221                "ap_shared": {
1222                    "provider": "airplay--x",
1223                    "player_type": "protocol",
1224                    "values": {CONF_PROTOCOL_PARENT_ID: "sonos_owner"},
1225                },
1226                "heos_player": {
1227                    "provider": "heos--y",
1228                    "player_type": "player",
1229                    "values": {"linked_protocol_ids": ["ap_shared"]},
1230                },
1231                "sonos_owner": {
1232                    "provider": "sonos--z",
1233                    "player_type": "player",
1234                    "values": {"linked_protocol_ids": ["ap_shared"]},
1235                },
1236            }
1237        }
1238        _wire_nested_config(mock_mass, store)
1239
1240        # a native player that kept a stale cached id for a protocol it no longer owns
1241        heos_player = MockPlayer(
1242            MockProvider("heos", mass=mock_mass),
1243            "heos_player",
1244            "Old Receiver",
1245            identifiers={IdentifierType.MAC_ADDRESS: "11:22:33:44:55:66"},
1246        )
1247        heos_player.set_initialized()
1248        airplay = MockPlayer(
1249            MockProvider("airplay", mass=mock_mass),
1250            "ap_shared",
1251            "Speaker (AirPlay)",
1252            player_type=PlayerType.PROTOCOL,
1253            identifiers={IdentifierType.MAC_ADDRESS: "AA:BB:CC:DD:EE:FF"},
1254        )
1255        airplay.set_initialized()
1256        controller._players = {"heos_player": heos_player, "ap_shared": airplay}
1257
1258        # the persisted owner has not registered yet
1259        controller._try_link_protocol_to_native(airplay)
1260
1261        assert airplay.protocol_parent_id is None
1262        assert heos_player.linked_output_protocols == []
1263        assert mock_mass.loop.call_later.call_args.args[0] == 45.0
1264
1265        # once the owner registers it takes its own protocol
1266        sonos_owner = MockPlayer(
1267            MockProvider("sonos", mass=mock_mass),
1268            "sonos_owner",
1269            "Speaker",
1270            identifiers={IdentifierType.MAC_ADDRESS: "AA:BB:CC:DD:EE:FF"},
1271        )
1272        sonos_owner.set_initialized()
1273        controller._players["sonos_owner"] = sonos_owner
1274        controller._try_link_protocols_to_native(sonos_owner)
1275
1276        assert airplay.protocol_parent_id == "sonos_owner"
1277        assert [  # type: ignore[unreachable]
1278            link.output_protocol_id for link in sonos_owner.linked_output_protocols
1279        ] == ["ap_shared"]
1280
1281    def test_identifier_match_does_not_claim_a_protocol_waiting_for_its_owner(
1282        self, mock_mass: MagicMock
1283    ) -> None:
1284        """Identifier matching does not override a protocol's own persisted owner."""
1285        controller = PlayerController(mock_mass)
1286
1287        store: dict[str, Any] = {
1288            CONF_PLAYERS: {
1289                "ap_shared": {
1290                    "provider": "airplay--x",
1291                    "player_type": "protocol",
1292                    "values": {CONF_PROTOCOL_PARENT_ID: "sonos_owner"},
1293                },
1294                "sonos_owner": {"provider": "sonos--z", "player_type": "player", "values": {}},
1295            }
1296        }
1297        _wire_nested_config(mock_mass, store)
1298
1299        # shares identifiers with the protocol but is not its persisted owner
1300        other_native = MockPlayer(
1301            MockProvider("heos", mass=mock_mass),
1302            "heos_player",
1303            "Other Receiver",
1304            identifiers={IdentifierType.MAC_ADDRESS: "AA:BB:CC:DD:EE:FF"},
1305        )
1306        other_native.set_initialized()
1307        airplay = MockPlayer(
1308            MockProvider("airplay", mass=mock_mass),
1309            "ap_shared",
1310            "Speaker (AirPlay)",
1311            player_type=PlayerType.PROTOCOL,
1312            identifiers={IdentifierType.MAC_ADDRESS: "AA:BB:CC:DD:EE:FF"},
1313        )
1314        airplay.set_initialized()
1315        controller._players = {"heos_player": other_native, "ap_shared": airplay}
1316
1317        controller._try_link_protocol_to_native(airplay)
1318
1319        assert airplay.protocol_parent_id is None
1320        assert other_native.linked_output_protocols == []
1321
1322    def test_other_native_does_not_claim_a_protocol_reserved_for_its_owner(
1323        self, mock_mass: MagicMock
1324    ) -> None:
1325        """A native registering before the persisted owner leaves that owner's protocol alone."""
1326        controller = PlayerController(mock_mass)
1327
1328        store: dict[str, Any] = {
1329            CONF_PLAYERS: {
1330                "ap_shared": {
1331                    "provider": "airplay--x",
1332                    "player_type": "protocol",
1333                    "values": {CONF_PROTOCOL_PARENT_ID: "sonos_owner"},
1334                },
1335                "sonos_owner": {"provider": "sonos--z", "player_type": "player", "values": {}},
1336            }
1337        }
1338        _wire_nested_config(mock_mass, store)
1339
1340        airplay = MockPlayer(
1341            MockProvider("airplay", mass=mock_mass),
1342            "ap_shared",
1343            "Speaker (AirPlay)",
1344            player_type=PlayerType.PROTOCOL,
1345            identifiers={IdentifierType.MAC_ADDRESS: "AA:BB:CC:DD:EE:FF"},
1346        )
1347        airplay.set_initialized()
1348        controller._players = {"ap_shared": airplay}
1349
1350        controller._try_link_protocol_to_native(airplay)
1351        assert airplay.protocol_parent_id is None
1352
1353        # another native with a colliding identifier registers before the owner
1354        other_native = MockPlayer(
1355            MockProvider("heos", mass=mock_mass),
1356            "heos_player",
1357            "Other Receiver",
1358            identifiers={IdentifierType.MAC_ADDRESS: "AA:BB:CC:DD:EE:FF"},
1359        )
1360        other_native.set_initialized()
1361        controller._players["heos_player"] = other_native
1362        controller._try_link_protocols_to_native(other_native)
1363
1364        assert airplay.protocol_parent_id is None
1365        assert other_native.linked_output_protocols == []
1366
1367        # the owner still gets its own protocol
1368        sonos_owner = MockPlayer(
1369            MockProvider("sonos", mass=mock_mass),
1370            "sonos_owner",
1371            "Speaker",
1372            identifiers={IdentifierType.MAC_ADDRESS: "AA:BB:CC:DD:EE:FF"},
1373        )
1374        sonos_owner.set_initialized()
1375        controller._players["sonos_owner"] = sonos_owner
1376        controller._try_link_protocols_to_native(sonos_owner)
1377
1378        assert airplay.protocol_parent_id == "sonos_owner"
1379
1380    @pytest.mark.asyncio
1381    async def test_protocol_links_elsewhere_when_its_owner_never_registers(
1382        self, mock_mass: MagicMock
1383    ) -> None:
1384        """A protocol whose persisted owner never registers still links on delayed evaluation."""
1385        controller = PlayerController(mock_mass)
1386
1387        store: dict[str, Any] = {
1388            CONF_PLAYERS: {
1389                "ap_shared": {
1390                    "provider": "airplay--x",
1391                    "player_type": "protocol",
1392                    "values": {CONF_PROTOCOL_PARENT_ID: "gone_owner"},
1393                },
1394                "heos_player": {
1395                    "provider": "heos--y",
1396                    "player_type": "player",
1397                    "values": {"linked_protocol_ids": ["ap_shared"]},
1398                },
1399            }
1400        }
1401        _wire_nested_config(mock_mass, store)
1402
1403        heos_player = MockPlayer(
1404            MockProvider("heos", mass=mock_mass),
1405            "heos_player",
1406            "Receiver",
1407            identifiers={IdentifierType.MAC_ADDRESS: "AA:BB:CC:DD:EE:FF"},
1408        )
1409        heos_player.set_initialized()
1410        airplay = MockPlayer(
1411            MockProvider("airplay", mass=mock_mass),
1412            "ap_shared",
1413            "Speaker (AirPlay)",
1414            player_type=PlayerType.PROTOCOL,
1415            identifiers={IdentifierType.MAC_ADDRESS: "AA:BB:CC:DD:EE:FF"},
1416        )
1417        airplay.set_initialized()
1418        controller._players = {"heos_player": heos_player, "ap_shared": airplay}
1419
1420        controller._try_link_protocol_to_native(airplay)
1421        assert airplay.protocol_parent_id is None
1422
1423        await controller._delayed_protocol_evaluation("ap_shared")
1424
1425        assert airplay.protocol_parent_id == "heos_player"
1426
1427    @pytest.mark.asyncio
1428    async def test_cached_parent_registers_before_delayed_eval_links_without_universal(
1429        self, mock_mass: MagicMock
1430    ) -> None:
1431        """Test cached parent startup grace still links when native registers during delay."""
1432        controller = PlayerController(mock_mass)
1433
1434        def mock_config_get(
1435            key: str, default: str | list[str] | None = None
1436        ) -> str | list[str] | None:
1437            if "protocol_parent_id" in str(key):
1438                return "native_player_id"
1439            return default
1440
1441        mock_mass.config.get.side_effect = mock_config_get
1442
1443        dlna_provider = MockProvider("dlna", mass=mock_mass)
1444        protocol_player = MockPlayer(
1445            dlna_provider,
1446            "uuid:RINCON_AABBCCDDEEFF_MR",
1447            "Sonos DLNA",
1448            player_type=PlayerType.PROTOCOL,
1449            identifiers={IdentifierType.MAC_ADDRESS: "AA:BB:CC:DD:EE:FF"},
1450        )
1451        protocol_player.set_initialized()
1452        controller._players = {protocol_player.player_id: protocol_player}
1453
1454        controller._try_link_protocol_to_native(protocol_player)
1455
1456        assert protocol_player.protocol_parent_id is None
1457        assert mock_mass.loop.call_later.call_args.args[0] == 45.0
1458
1459        native_provider = MockProvider("sonos", mass=mock_mass)
1460        native_player = MockPlayer(
1461            native_provider,
1462            "native_player_id",
1463            "Sonos Speaker",
1464            identifiers={IdentifierType.MAC_ADDRESS: "AA:BB:CC:DD:EE:FF"},
1465        )
1466        native_player.set_initialized()
1467        controller._players[native_player.player_id] = native_player
1468
1469        controller._try_link_protocols_to_native(native_player)
1470
1471        assert protocol_player.protocol_parent_id == native_player.player_id
1472        assert any(  # type: ignore[unreachable]
1473            link.output_protocol_id == protocol_player.player_id
1474            for link in native_player.linked_output_protocols
1475        )
1476
1477        with patch.object(controller, "_create_or_update_universal_player") as mock_create_up:
1478            await controller._delayed_protocol_evaluation(protocol_player.player_id)
1479
1480        mock_create_up.assert_not_called()
1481
1482
1483class TestSelectBestOutputProtocol:
1484    """Tests for output protocol selection logic."""
1485
1486    def test_select_native_when_preferred_is_native(self, mock_mass: MagicMock) -> None:
1487        """Test that native protocol is selected when user prefers native."""
1488
1489        # Mock config to return "native" as preferred, but still handle min/max volume
1490        def _get_raw_config(
1491            _player_id: str, key: str, _default: str | int | None = None
1492        ) -> str | int | None:
1493            if key == "min_volume":
1494                return 0
1495            if key == "max_volume":
1496                return 100
1497            return "native"
1498
1499        mock_mass.config.get_raw_player_config_value = MagicMock(side_effect=_get_raw_config)
1500
1501        controller = PlayerController(mock_mass)
1502        provider = MockProvider("sonos", mass=mock_mass)
1503
1504        # Create native player with PLAY_MEDIA support
1505        native_player = MockPlayer(
1506            provider,
1507            "sonos_123",
1508            "Kantoor",
1509            identifiers={IdentifierType.MAC_ADDRESS: "AA:BB:CC:DD:EE:FF"},
1510        )
1511        native_player._attr_supported_features.add(PlayerFeature.PLAY_MEDIA)
1512
1513        # Wire up mock_mass.players to controller
1514        mock_mass.players = controller
1515
1516        # Register players
1517        controller._players = {"sonos_123": native_player}
1518
1519        # Select protocol
1520        selected_player, output_protocol = controller._select_best_output_protocol(native_player)
1521
1522        # Should select native player
1523        assert selected_player == native_player
1524        assert output_protocol is None  # None means native playback
1525
1526    def test_select_dlna_when_preferred_is_dlna(self, mock_mass: MagicMock) -> None:
1527        """Test that DLNA protocol is selected when user prefers DLNA."""
1528
1529        # Mock config to return the full player ID as preferred, but still handle min/max volume
1530        def _get_raw_config(
1531            _player_id: str, key: str, _default: str | int | None = None
1532        ) -> str | int | None:
1533            if key == "min_volume":
1534                return 0
1535            if key == "max_volume":
1536                return 100
1537            return "dlna_AABBCCDDEEFF"
1538
1539        mock_mass.config.get_raw_player_config_value = MagicMock(side_effect=_get_raw_config)
1540
1541        controller = PlayerController(mock_mass)
1542
1543        # Create native player with linked protocols
1544        sonos_provider = MockProvider("sonos", mass=mock_mass)
1545        native_player = MockPlayer(
1546            sonos_provider,
1547            "sonos_123",
1548            "Kantoor",
1549            identifiers={IdentifierType.MAC_ADDRESS: "AA:BB:CC:DD:EE:FF"},
1550        )
1551        native_player._attr_supported_features.add(PlayerFeature.PLAY_MEDIA)
1552
1553        # Create DLNA protocol player
1554        dlna_provider = MockProvider("dlna", mass=mock_mass)
1555        dlna_player = MockPlayer(
1556            dlna_provider,
1557            "dlna_AABBCCDDEEFF",
1558            "Kantoor DLNA",
1559            player_type=PlayerType.PROTOCOL,
1560            identifiers={IdentifierType.MAC_ADDRESS: "AA:BB:CC:DD:EE:FF"},
1561        )
1562
1563        # Register players
1564        controller._players = {
1565            "sonos_123": native_player,
1566            "dlna_AABBCCDDEEFF": dlna_player,
1567        }
1568
1569        # Link DLNA protocol to native player
1570        native_player.set_linked_output_protocols(
1571            [
1572                LinkedOutputProtocol(
1573                    output_protocol_id="dlna_AABBCCDDEEFF",
1574                    protocol_domain="dlna",
1575                    priority=50,
1576                )
1577            ]
1578        )
1579
1580        # Select protocol
1581        selected_player, output_protocol = controller._select_best_output_protocol(native_player)
1582
1583        # Should select DLNA player, not native
1584        assert selected_player == dlna_player
1585        assert output_protocol is not None
1586        assert output_protocol.output_protocol_id == "dlna_AABBCCDDEEFF"
1587
1588    def test_select_airplay_when_preferred_is_airplay(self, mock_mass: MagicMock) -> None:
1589        """Test that AirPlay protocol is selected when user prefers AirPlay."""
1590
1591        # Mock config to return the full player ID as preferred, but still handle min/max volume
1592        def _get_raw_config(
1593            _player_id: str, key: str, _default: str | int | None = None
1594        ) -> str | int | None:
1595            if key == "min_volume":
1596                return 0
1597            if key == "max_volume":
1598                return 100
1599            return "airplay_AABBCCDDEEFF"
1600
1601        mock_mass.config.get_raw_player_config_value = MagicMock(side_effect=_get_raw_config)
1602
1603        controller = PlayerController(mock_mass)
1604
1605        # Create native player
1606        sonos_provider = MockProvider("sonos", mass=mock_mass)
1607        native_player = MockPlayer(
1608            sonos_provider,
1609            "sonos_123",
1610            "Kantoor",
1611            identifiers={IdentifierType.MAC_ADDRESS: "AA:BB:CC:DD:EE:FF"},
1612        )
1613        native_player._attr_supported_features.add(PlayerFeature.PLAY_MEDIA)
1614
1615        # Create AirPlay and DLNA protocol players
1616        airplay_provider = MockProvider("airplay", mass=mock_mass)
1617        airplay_player = MockPlayer(
1618            airplay_provider,
1619            "airplay_AABBCCDDEEFF",
1620            "Kantoor AirPlay",
1621            player_type=PlayerType.PROTOCOL,
1622            identifiers={IdentifierType.MAC_ADDRESS: "AA:BB:CC:DD:EE:FF"},
1623        )
1624
1625        dlna_provider = MockProvider("dlna", mass=mock_mass)
1626        dlna_player = MockPlayer(
1627            dlna_provider,
1628            "dlna_AABBCCDDEEFF",
1629            "Kantoor DLNA",
1630            player_type=PlayerType.PROTOCOL,
1631            identifiers={IdentifierType.MAC_ADDRESS: "AA:BB:CC:DD:EE:FF"},
1632        )
1633
1634        # Register players
1635        controller._players = {
1636            "sonos_123": native_player,
1637            "airplay_AABBCCDDEEFF": airplay_player,
1638            "dlna_AABBCCDDEEFF": dlna_player,
1639        }
1640
1641        # Link protocols to native player
1642        native_player.set_linked_output_protocols(
1643            [
1644                LinkedOutputProtocol(
1645                    output_protocol_id="airplay_AABBCCDDEEFF",
1646                    protocol_domain="airplay",
1647                    priority=10,
1648                ),
1649                LinkedOutputProtocol(
1650                    output_protocol_id="dlna_AABBCCDDEEFF",
1651                    protocol_domain="dlna",
1652                    priority=50,
1653                ),
1654            ]
1655        )
1656
1657        # Select protocol
1658        selected_player, output_protocol = controller._select_best_output_protocol(native_player)
1659
1660        # Should select AirPlay player (even though DLNA has lower priority value),
1661        # because user preference overrides priority
1662        assert selected_player == airplay_player
1663        assert output_protocol is not None
1664        assert output_protocol.output_protocol_id == "airplay_AABBCCDDEEFF"
1665
1666    def test_fallback_to_native_when_auto(self, mock_mass: MagicMock) -> None:
1667        """Test that native playback is used when preference is auto."""
1668
1669        # Mock config to return "auto" as preferred, but still handle min/max volume
1670        def _get_raw_config(
1671            _player_id: str, key: str, _default: str | int | None = None
1672        ) -> str | int | None:
1673            if key == "min_volume":
1674                return 0
1675            if key == "max_volume":
1676                return 100
1677            return "auto"
1678
1679        mock_mass.config.get_raw_player_config_value = MagicMock(side_effect=_get_raw_config)
1680
1681        controller = PlayerController(mock_mass)
1682        provider = MockProvider("sonos", mass=mock_mass)
1683
1684        native_player = MockPlayer(
1685            provider,
1686            "sonos_123",
1687            "Kantoor",
1688            identifiers={IdentifierType.MAC_ADDRESS: "AA:BB:CC:DD:EE:FF"},
1689        )
1690        native_player._attr_supported_features.add(PlayerFeature.PLAY_MEDIA)
1691
1692        controller._players = {"sonos_123": native_player}
1693
1694        # Select protocol with auto preference
1695        selected_player, output_protocol = controller._select_best_output_protocol(native_player)
1696
1697        # Should select native player
1698        assert selected_player == native_player
1699        assert output_protocol is None  # None means native playback
1700
1701    def test_skip_protocol_that_needs_setup(self, mock_mass: MagicMock) -> None:
1702        """Test that a protocol player which still needs setup is never selected."""
1703        controller = PlayerController(mock_mass)
1704
1705        # Universal player wrapping the protocols of a device without native playback
1706        universal_provider = MockProvider("universal_player", mass=mock_mass)
1707        universal_player = MockPlayer(
1708            universal_provider,
1709            "up_AABBCCDDEEFF",
1710            "Living Room TV",
1711            identifiers={IdentifierType.MAC_ADDRESS: "AA:BB:CC:DD:EE:FF"},
1712        )
1713
1714        # AirPlay protocol player that is discovered but still awaits pairing
1715        airplay_provider = MockProvider("airplay", mass=mock_mass)
1716        airplay_player = MockPlayer(
1717            airplay_provider,
1718            "airplay_AABBCCDDEEFF",
1719            "Living Room TV AirPlay",
1720            player_type=PlayerType.PROTOCOL,
1721            identifiers={IdentifierType.MAC_ADDRESS: "AA:BB:CC:DD:EE:FF"},
1722        )
1723        airplay_player._attr_needs_setup = True
1724
1725        dlna_provider = MockProvider("dlna", mass=mock_mass)
1726        dlna_player = MockPlayer(
1727            dlna_provider,
1728            "dlna_AABBCCDDEEFF",
1729            "Living Room TV DLNA",
1730            player_type=PlayerType.PROTOCOL,
1731            identifiers={IdentifierType.MAC_ADDRESS: "AA:BB:CC:DD:EE:FF"},
1732        )
1733
1734        # Wire up mock_mass.players to controller
1735        mock_mass.players = controller
1736
1737        # Register players
1738        controller._players = {
1739            "up_AABBCCDDEEFF": universal_player,
1740            "airplay_AABBCCDDEEFF": airplay_player,
1741            "dlna_AABBCCDDEEFF": dlna_player,
1742        }
1743
1744        # Link both protocols, AirPlay with the better priority
1745        universal_player.set_linked_output_protocols(
1746            [
1747                LinkedOutputProtocol(
1748                    output_protocol_id="airplay_AABBCCDDEEFF",
1749                    protocol_domain="airplay",
1750                    priority=10,
1751                ),
1752                LinkedOutputProtocol(
1753                    output_protocol_id="dlna_AABBCCDDEEFF",
1754                    protocol_domain="dlna",
1755                    priority=50,
1756                ),
1757            ]
1758        )
1759
1760        selected_player, output_protocol = controller._select_best_output_protocol(universal_player)
1761
1762        # Should skip the unpaired AirPlay output and fall through to DLNA
1763        assert selected_player == dlna_player
1764        assert output_protocol is not None
1765        assert output_protocol.output_protocol_id == "dlna_AABBCCDDEEFF"
1766
1767        # and the unpaired output must be reported as unavailable
1768        universal_player.update_state(signal_event=False)
1769        airplay_output = next(
1770            protocol
1771            for protocol in universal_player.output_protocols
1772            if protocol.output_protocol_id == "airplay_AABBCCDDEEFF"
1773        )
1774        assert airplay_output.available is False
1775
1776
1777class TestGetControlTarget:
1778    """Tests for resolving the target of an audio-path command."""
1779
1780    def test_fallback_follows_protocol_priority(self, mock_mass: MagicMock) -> None:
1781        """
1782        Without an active output the fallback picks the highest priority protocol.
1783
1784        An announcement on an idle player reaches this path, so it has to land on the
1785        same output that regular playback selection would have chosen.
1786        """
1787        controller = PlayerController(mock_mass)
1788        mock_mass.players = controller
1789
1790        universal_provider = MockProvider("universal_player", mass=mock_mass)
1791        universal_player = MockPlayer(universal_provider, "universal_123", "Kantoor")
1792        universal_player._attr_supported_features = set()
1793
1794        sendspin_player = MockPlayer(
1795            MockProvider("sendspin", mass=mock_mass),
1796            "sendspin_AABBCCDDEEFF",
1797            "Kantoor Sendspin",
1798            player_type=PlayerType.PROTOCOL,
1799        )
1800        sendspin_player._attr_supported_features.add(PlayerFeature.PLAY_ANNOUNCEMENT)
1801        airplay_player = MockPlayer(
1802            MockProvider("airplay", mass=mock_mass),
1803            "airplay_AABBCCDDEEFF",
1804            "Kantoor AirPlay",
1805            player_type=PlayerType.PROTOCOL,
1806        )
1807        airplay_player._attr_supported_features.add(PlayerFeature.PLAY_ANNOUNCEMENT)
1808
1809        controller._players = {
1810            "universal_123": universal_player,
1811            "sendspin_AABBCCDDEEFF": sendspin_player,
1812            "airplay_AABBCCDDEEFF": airplay_player,
1813        }
1814        # sendspin links first, but airplay outranks it
1815        universal_player.set_linked_output_protocols(
1816            [
1817                LinkedOutputProtocol(
1818                    output_protocol_id="sendspin_AABBCCDDEEFF",
1819                    protocol_domain="sendspin",
1820                    priority=40,
1821                ),
1822                LinkedOutputProtocol(
1823                    output_protocol_id="airplay_AABBCCDDEEFF",
1824                    protocol_domain="airplay",
1825                    priority=10,
1826                ),
1827            ]
1828        )
1829
1830        target = controller._get_control_target(
1831            universal_player, PlayerFeature.PLAY_ANNOUNCEMENT, require_active=False
1832        )
1833        assert target == airplay_player
1834
1835    def test_fallback_follows_preferred_output_protocol(self, mock_mass: MagicMock) -> None:
1836        """An explicitly preferred output beats the priority order."""
1837        controller = PlayerController(mock_mass)
1838        mock_mass.players = controller
1839
1840        def _get_raw_player_config_value(
1841            _player_id: str, key: str, default: str | int | None = None
1842        ) -> str | int | None:
1843            if key == CONF_PREFERRED_OUTPUT_PROTOCOL:
1844                return "sendspin_AABBCCDDEEFF"
1845            if key == "min_volume":
1846                return 0
1847            if key == "max_volume":
1848                return 100
1849            return default
1850
1851        mock_mass.config.get_raw_player_config_value = MagicMock(
1852            side_effect=_get_raw_player_config_value
1853        )
1854
1855        universal_player = MockPlayer(
1856            MockProvider("universal_player", mass=mock_mass), "universal_123", "Kantoor"
1857        )
1858        universal_player._attr_supported_features = set()
1859        sendspin_player = MockPlayer(
1860            MockProvider("sendspin", mass=mock_mass),
1861            "sendspin_AABBCCDDEEFF",
1862            "Kantoor Sendspin",
1863            player_type=PlayerType.PROTOCOL,
1864        )
1865        sendspin_player._attr_supported_features.add(PlayerFeature.PLAY_ANNOUNCEMENT)
1866        airplay_player = MockPlayer(
1867            MockProvider("airplay", mass=mock_mass),
1868            "airplay_AABBCCDDEEFF",
1869            "Kantoor AirPlay",
1870            player_type=PlayerType.PROTOCOL,
1871        )
1872        airplay_player._attr_supported_features.add(PlayerFeature.PLAY_ANNOUNCEMENT)
1873
1874        controller._players = {
1875            "universal_123": universal_player,
1876            "sendspin_AABBCCDDEEFF": sendspin_player,
1877            "airplay_AABBCCDDEEFF": airplay_player,
1878        }
1879        # airplay outranks sendspin, but the user picked sendspin
1880        universal_player.set_linked_output_protocols(
1881            [
1882                LinkedOutputProtocol(
1883                    output_protocol_id="airplay_AABBCCDDEEFF",
1884                    protocol_domain="airplay",
1885                    priority=10,
1886                ),
1887                LinkedOutputProtocol(
1888                    output_protocol_id="sendspin_AABBCCDDEEFF",
1889                    protocol_domain="sendspin",
1890                    priority=40,
1891                ),
1892            ]
1893        )
1894
1895        target = controller._get_control_target(
1896            universal_player, PlayerFeature.PLAY_ANNOUNCEMENT, require_active=False
1897        )
1898        assert target == sendspin_player
1899
1900    def test_fallback_ignores_preference_for_another_players_output(
1901        self, mock_mass: MagicMock
1902    ) -> None:
1903        """A preference left behind by a relink does not steal another speaker's output."""
1904        controller = PlayerController(mock_mass)
1905        mock_mass.players = controller
1906
1907        def _get_raw_player_config_value(
1908            _player_id: str, key: str, default: str | int | None = None
1909        ) -> str | int | None:
1910            if key == CONF_PREFERRED_OUTPUT_PROTOCOL:
1911                return "airplay_998877665544"
1912            if key == "min_volume":
1913                return 0
1914            if key == "max_volume":
1915                return 100
1916            return default
1917
1918        mock_mass.config.get_raw_player_config_value = MagicMock(
1919            side_effect=_get_raw_player_config_value
1920        )
1921
1922        universal_player = MockPlayer(
1923            MockProvider("universal_player", mass=mock_mass), "universal_123", "Kantoor"
1924        )
1925        universal_player._attr_supported_features = set()
1926        own_player = MockPlayer(
1927            MockProvider("sendspin", mass=mock_mass),
1928            "sendspin_AABBCCDDEEFF",
1929            "Kantoor Sendspin",
1930            player_type=PlayerType.PROTOCOL,
1931        )
1932        own_player._attr_supported_features.add(PlayerFeature.PLAY_ANNOUNCEMENT)
1933        # the preference now points at an output belonging to a different speaker
1934        other_player = MockPlayer(
1935            MockProvider("airplay", mass=mock_mass),
1936            "airplay_998877665544",
1937            "Woonkamer AirPlay",
1938            player_type=PlayerType.PROTOCOL,
1939        )
1940        other_player._attr_supported_features.add(PlayerFeature.PLAY_ANNOUNCEMENT)
1941
1942        controller._players = {
1943            "universal_123": universal_player,
1944            "sendspin_AABBCCDDEEFF": own_player,
1945            "airplay_998877665544": other_player,
1946        }
1947        universal_player.set_linked_output_protocols(
1948            [
1949                LinkedOutputProtocol(
1950                    output_protocol_id="sendspin_AABBCCDDEEFF",
1951                    protocol_domain="sendspin",
1952                    priority=40,
1953                )
1954            ]
1955        )
1956
1957        target = controller._get_control_target(
1958            universal_player, PlayerFeature.PLAY_ANNOUNCEMENT, require_active=False
1959        )
1960        assert target == own_player
1961
1962    def test_require_active_skips_the_fallback(self, mock_mass: MagicMock) -> None:
1963        """With require_active there is no fallback to an idle linked protocol."""
1964        controller = PlayerController(mock_mass)
1965        mock_mass.players = controller
1966
1967        universal_player = MockPlayer(
1968            MockProvider("universal_player", mass=mock_mass),
1969            "universal_123",
1970            "Kantoor",
1971        )
1972        universal_player._attr_supported_features = set()
1973        airplay_player = MockPlayer(
1974            MockProvider("airplay", mass=mock_mass),
1975            "airplay_AABBCCDDEEFF",
1976            "Kantoor AirPlay",
1977            player_type=PlayerType.PROTOCOL,
1978        )
1979        airplay_player._attr_supported_features.add(PlayerFeature.PLAY_ANNOUNCEMENT)
1980
1981        controller._players = {
1982            "universal_123": universal_player,
1983            "airplay_AABBCCDDEEFF": airplay_player,
1984        }
1985        universal_player.set_linked_output_protocols(
1986            [
1987                LinkedOutputProtocol(
1988                    output_protocol_id="airplay_AABBCCDDEEFF",
1989                    protocol_domain="airplay",
1990                    priority=10,
1991                )
1992            ]
1993        )
1994
1995        assert (
1996            controller._get_control_target(
1997                universal_player, PlayerFeature.PLAY_ANNOUNCEMENT, require_active=True
1998            )
1999            is None
2000        )
2001
2002
2003class TestPlayerGrouping:
2004    """Tests for player grouping scenarios."""
2005
2006    def test_native_to_native_grouping(self, mock_mass: MagicMock) -> None:
2007        """Test that native players from same provider can group together."""
2008        controller = PlayerController(mock_mass)
2009
2010        sonos_provider = MockProvider("sonos", mass=mock_mass)
2011
2012        # Create two Sonos players
2013        player_a = MockPlayer(
2014            sonos_provider,
2015            "sonos_123",
2016            "Living Room",
2017            identifiers={IdentifierType.MAC_ADDRESS: "AA:BB:CC:DD:EE:01"},
2018        )
2019        player_a._attr_supported_features.add(PlayerFeature.SET_MEMBERS)
2020        player_a._attr_supported_features.add(PlayerFeature.PLAY_MEDIA)
2021        player_a._attr_can_group_with = {"sonos_456"}
2022        player_a._cache.clear()  # Clear cached properties after modifying attributes
2023
2024        player_b = MockPlayer(
2025            sonos_provider,
2026            "sonos_456",
2027            "Kitchen",
2028            identifiers={IdentifierType.MAC_ADDRESS: "AA:BB:CC:DD:EE:02"},
2029        )
2030        player_b._attr_supported_features.add(PlayerFeature.PLAY_MEDIA)
2031        player_b._cache.clear()
2032
2033        controller._players = {
2034            "sonos_123": player_a,
2035            "sonos_456": player_b,
2036        }
2037
2038        # Translate members for native grouping
2039        protocol_members, native_members, _, _ = controller._translate_members_for_protocols(
2040            parent_player=player_a,
2041            player_ids=["sonos_456"],
2042            parent_protocol_player=None,
2043            parent_protocol_domain=None,
2044        )
2045
2046        # Should use native grouping (same provider)
2047        assert len(native_members) == 1
2048        assert "sonos_456" in native_members
2049        assert len(protocol_members) == 0
2050
2051    def test_protocol_to_protocol_grouping(self, mock_mass: MagicMock) -> None:
2052        """Test that protocol players can group via shared protocol."""
2053        controller = PlayerController(mock_mass)
2054
2055        # Create two players with AirPlay protocol support
2056        sonos_provider = MockProvider("sonos", mass=mock_mass)
2057        wiim_provider = MockProvider("wiim", mass=mock_mass)
2058        airplay_provider = MockProvider("airplay", mass=mock_mass)
2059
2060        # Sonos player
2061        sonos_player = MockPlayer(
2062            sonos_provider,
2063            "sonos_123",
2064            "Living Room",
2065            identifiers={IdentifierType.MAC_ADDRESS: "AA:BB:CC:DD:EE:01"},
2066        )
2067        sonos_player._attr_supported_features.add(PlayerFeature.PLAY_MEDIA)
2068        sonos_player._cache.clear()
2069
2070        # WiiM player
2071        wiim_player = MockPlayer(
2072            wiim_provider,
2073            "wiim_456",
2074            "Bedroom",
2075            identifiers={IdentifierType.MAC_ADDRESS: "AA:BB:CC:DD:EE:02"},
2076        )
2077        wiim_player._attr_supported_features.add(PlayerFeature.PLAY_MEDIA)
2078        wiim_player._cache.clear()
2079
2080        # AirPlay protocol players
2081        sonos_airplay = MockPlayer(
2082            airplay_provider,
2083            "airplay_sonos",
2084            "Living Room (AirPlay)",
2085            player_type=PlayerType.PROTOCOL,
2086            identifiers={IdentifierType.MAC_ADDRESS: "AA:BB:CC:DD:EE:01"},
2087        )
2088        sonos_airplay._attr_supported_features.add(PlayerFeature.SET_MEMBERS)
2089        sonos_airplay._attr_can_group_with = {"airplay_wiim"}
2090        sonos_airplay._cache.clear()
2091        sonos_airplay.update_state(signal_event=False)
2092
2093        wiim_airplay = MockPlayer(
2094            airplay_provider,
2095            "airplay_wiim",
2096            "Bedroom (AirPlay)",
2097            player_type=PlayerType.PROTOCOL,
2098            identifiers={IdentifierType.MAC_ADDRESS: "AA:BB:CC:DD:EE:02"},
2099        )
2100
2101        # Link protocol players to native players
2102        sonos_player.set_linked_output_protocols(
2103            [
2104                LinkedOutputProtocol(
2105                    output_protocol_id="airplay_sonos",
2106                    protocol_domain="airplay",
2107                    priority=10,
2108                )
2109            ]
2110        )
2111        wiim_player.set_linked_output_protocols(
2112            [
2113                LinkedOutputProtocol(
2114                    output_protocol_id="airplay_wiim",
2115                    protocol_domain="airplay",
2116                    priority=10,
2117                )
2118            ]
2119        )
2120
2121        controller._players = {
2122            "sonos_123": sonos_player,
2123            "wiim_456": wiim_player,
2124            "airplay_sonos": sonos_airplay,
2125            "airplay_wiim": wiim_airplay,
2126        }
2127
2128        # Translate members for protocol grouping (via AirPlay)
2129        protocol_members, native_members, protocol_player, _ = (
2130            controller._translate_members_for_protocols(
2131                parent_player=sonos_player,
2132                player_ids=["wiim_456"],
2133                parent_protocol_player=sonos_airplay,
2134                parent_protocol_domain="airplay",
2135            )
2136        )
2137
2138        # Should use protocol grouping (AirPlay)
2139        assert len(protocol_members) == 1
2140        assert "airplay_wiim" in protocol_members
2141        assert len(native_members) == 0
2142        assert protocol_player == sonos_airplay
2143
2144    def test_hybrid_grouping(self, mock_mass: MagicMock) -> None:
2145        """Test hybrid grouping: native + protocol players in same group."""
2146        controller = PlayerController(mock_mass)
2147
2148        # Create Sonos players (native grouping capability)
2149        sonos_provider = MockProvider("sonos", instance_id="sonos_instance", mass=mock_mass)
2150        sonos_a = MockPlayer(
2151            sonos_provider,
2152            "sonos_123",
2153            "Living Room",
2154            identifiers={IdentifierType.MAC_ADDRESS: "AA:BB:CC:DD:EE:01"},
2155        )
2156        sonos_a._attr_supported_features.add(PlayerFeature.SET_MEMBERS)
2157        sonos_a._attr_supported_features.add(PlayerFeature.PLAY_MEDIA)
2158        sonos_a._attr_can_group_with = {"sonos_456"}
2159        sonos_a._cache.clear()
2160
2161        sonos_b = MockPlayer(
2162            sonos_provider,
2163            "sonos_456",
2164            "Kitchen",
2165            identifiers={IdentifierType.MAC_ADDRESS: "AA:BB:CC:DD:EE:02"},
2166        )
2167        sonos_b._attr_supported_features.add(PlayerFeature.PLAY_MEDIA)
2168        sonos_b._cache.clear()
2169
2170        # Create WiiM player with AirPlay protocol
2171        wiim_provider = MockProvider("wiim", instance_id="wiim_instance", mass=mock_mass)
2172        airplay_provider = MockProvider("airplay", instance_id="airplay_instance", mass=mock_mass)
2173
2174        wiim_player = MockPlayer(
2175            wiim_provider,
2176            "wiim_789",
2177            "Bedroom",
2178            identifiers={IdentifierType.MAC_ADDRESS: "AA:BB:CC:DD:EE:03"},
2179        )
2180        wiim_player._attr_supported_features.add(PlayerFeature.PLAY_MEDIA)
2181        wiim_player._cache.clear()
2182
2183        # AirPlay protocol players
2184        sonos_airplay = MockPlayer(
2185            airplay_provider,
2186            "airplay_sonos",
2187            "Living Room (AirPlay)",
2188            player_type=PlayerType.PROTOCOL,
2189            identifiers={IdentifierType.MAC_ADDRESS: "AA:BB:CC:DD:EE:01"},
2190        )
2191        sonos_airplay._attr_supported_features.add(PlayerFeature.SET_MEMBERS)
2192        sonos_airplay._attr_can_group_with = {"airplay_wiim"}
2193        sonos_airplay._cache.clear()
2194        sonos_airplay.update_state(signal_event=False)
2195
2196        wiim_airplay = MockPlayer(
2197            airplay_provider,
2198            "airplay_wiim",
2199            "Bedroom (AirPlay)",
2200            player_type=PlayerType.PROTOCOL,
2201            identifiers={IdentifierType.MAC_ADDRESS: "AA:BB:CC:DD:EE:03"},
2202        )
2203
2204        # Link AirPlay to Sonos A
2205        sonos_a.set_linked_output_protocols(
2206            [
2207                LinkedOutputProtocol(
2208                    output_protocol_id="airplay_sonos",
2209                    protocol_domain="airplay",
2210                    priority=10,
2211                )
2212            ]
2213        )
2214        wiim_player.set_linked_output_protocols(
2215            [
2216                LinkedOutputProtocol(
2217                    output_protocol_id="airplay_wiim",
2218                    protocol_domain="airplay",
2219                    priority=10,
2220                )
2221            ]
2222        )
2223        wiim_player.set_active_output_protocol("airplay_wiim")
2224        wiim_player.set_protocol_parent_id("airplay_wiim")
2225
2226        # Wire up mock_mass.players to controller so player lookups resolve
2227        mock_mass.players = controller
2228
2229        controller._players = {
2230            "sonos_123": sonos_a,
2231            "sonos_456": sonos_b,
2232            "wiim_789": wiim_player,
2233            "airplay_sonos": sonos_airplay,
2234            "airplay_wiim": wiim_airplay,
2235        }
2236
2237        # Group Sonos B (native) + WiiM (via AirPlay) to Sonos A
2238        protocol_members, native_members, _protocol_player, _ = (
2239            controller._translate_members_for_protocols(
2240                parent_player=sonos_a,
2241                player_ids=["sonos_456", "wiim_789"],
2242                parent_protocol_player=sonos_airplay,
2243                parent_protocol_domain="airplay",
2244            )
2245        )
2246
2247        # Should have hybrid group: native Sonos B + protocol WiiM
2248        assert len(native_members) == 1
2249        assert "sonos_456" in native_members
2250        assert len(protocol_members) == 1
2251        assert "airplay_wiim" in protocol_members
2252
2253    def test_protocol_selection_requires_set_members(self, mock_mass: MagicMock) -> None:
2254        """Test that only protocols with SET_MEMBERS support are selected for grouping."""
2255        controller = PlayerController(mock_mass)
2256
2257        sonos_provider = MockProvider("sonos", mass=mock_mass)
2258        wiim_provider = MockProvider("wiim", mass=mock_mass)
2259        dlna_provider = MockProvider("dlna", mass=mock_mass)
2260        airplay_provider = MockProvider("airplay", mass=mock_mass)
2261
2262        # Sonos player
2263        sonos_player = MockPlayer(
2264            sonos_provider,
2265            "sonos_123",
2266            "Living Room",
2267            identifiers={IdentifierType.MAC_ADDRESS: "AA:BB:CC:DD:EE:01"},
2268        )
2269        sonos_player._attr_supported_features.add(PlayerFeature.PLAY_MEDIA)
2270        sonos_player._cache.clear()
2271
2272        # WiiM player
2273        wiim_player = MockPlayer(
2274            wiim_provider,
2275            "wiim_456",
2276            "Bedroom",
2277            identifiers={IdentifierType.MAC_ADDRESS: "AA:BB:CC:DD:EE:02"},
2278        )
2279        wiim_player._attr_supported_features.add(PlayerFeature.PLAY_MEDIA)
2280        wiim_player._cache.clear()
2281
2282        # DLNA protocol (does NOT support SET_MEMBERS)
2283        sonos_dlna = MockPlayer(
2284            dlna_provider,
2285            "dlna_sonos",
2286            "Living Room (DLNA)",
2287            player_type=PlayerType.PROTOCOL,
2288            identifiers={IdentifierType.MAC_ADDRESS: "AA:BB:CC:DD:EE:01"},
2289        )
2290        # Note: NO SET_MEMBERS feature
2291
2292        wiim_dlna = MockPlayer(
2293            dlna_provider,
2294            "dlna_wiim",
2295            "Bedroom (DLNA)",
2296            player_type=PlayerType.PROTOCOL,
2297            identifiers={IdentifierType.MAC_ADDRESS: "AA:BB:CC:DD:EE:02"},
2298        )
2299
2300        # AirPlay protocol (DOES support SET_MEMBERS)
2301        sonos_airplay = MockPlayer(
2302            airplay_provider,
2303            "airplay_sonos",
2304            "Living Room (AirPlay)",
2305            player_type=PlayerType.PROTOCOL,
2306            identifiers={IdentifierType.MAC_ADDRESS: "AA:BB:CC:DD:EE:01"},
2307        )
2308        sonos_airplay._attr_supported_features.add(PlayerFeature.SET_MEMBERS)
2309        sonos_airplay._attr_can_group_with = {"airplay_wiim"}
2310        sonos_airplay._cache.clear()
2311
2312        wiim_airplay = MockPlayer(
2313            airplay_provider,
2314            "airplay_wiim",
2315            "Bedroom (AirPlay)",
2316            player_type=PlayerType.PROTOCOL,
2317            identifiers={IdentifierType.MAC_ADDRESS: "AA:BB:CC:DD:EE:02"},
2318        )
2319        wiim_airplay._attr_supported_features.add(PlayerFeature.SET_MEMBERS)
2320        wiim_airplay._attr_can_group_with = {"airplay_sonos"}
2321        wiim_airplay._cache.clear()
2322
2323        # Link protocols (DLNA has lower priority than AirPlay)
2324        sonos_player.set_linked_output_protocols(
2325            [
2326                LinkedOutputProtocol(
2327                    output_protocol_id="dlna_sonos",
2328                    protocol_domain="dlna",
2329                    priority=50,  # Lower priority (higher number)
2330                ),
2331                LinkedOutputProtocol(
2332                    output_protocol_id="airplay_sonos",
2333                    protocol_domain="airplay",
2334                    priority=10,  # Higher priority (lower number)
2335                ),
2336            ]
2337        )
2338        wiim_player.set_linked_output_protocols(
2339            [
2340                LinkedOutputProtocol(
2341                    output_protocol_id="dlna_wiim",
2342                    protocol_domain="dlna",
2343                    priority=50,
2344                ),
2345                LinkedOutputProtocol(
2346                    output_protocol_id="airplay_wiim",
2347                    protocol_domain="airplay",
2348                    priority=10,
2349                ),
2350            ]
2351        )
2352
2353        mock_mass.players = controller
2354        controller._players = {
2355            "sonos_123": sonos_player,
2356            "wiim_456": wiim_player,
2357            "dlna_sonos": sonos_dlna,
2358            "dlna_wiim": wiim_dlna,
2359            "airplay_sonos": sonos_airplay,
2360            "airplay_wiim": wiim_airplay,
2361        }
2362
2363        # Update state after modifying attributes
2364        sonos_dlna.update_state(signal_event=False)
2365        wiim_dlna.update_state(signal_event=False)
2366        sonos_airplay.update_state(signal_event=False)
2367        wiim_airplay.update_state(signal_event=False)
2368
2369        # Translate members - should skip DLNA (no SET_MEMBERS) and select AirPlay
2370        protocol_members, _native_members, protocol_player, protocol_domain = (
2371            controller._translate_members_for_protocols(
2372                parent_player=sonos_player,
2373                player_ids=["wiim_456"],
2374                parent_protocol_player=None,
2375                parent_protocol_domain=None,
2376            )
2377        )
2378
2379        # Should select AirPlay (supports SET_MEMBERS) not DLNA
2380        assert len(protocol_members) == 1
2381        assert "airplay_wiim" in protocol_members
2382        assert protocol_domain == "airplay"
2383        assert protocol_player == sonos_airplay
2384
2385        # A group already on DLNA must be moved off it as well, because DLNA cannot
2386        # group members itself
2387        protocol_members, _native_members, protocol_player, protocol_domain = (
2388            controller._translate_members_for_protocols(
2389                parent_player=sonos_player,
2390                player_ids=["wiim_456"],
2391                parent_protocol_player=sonos_dlna,
2392                parent_protocol_domain="dlna",
2393            )
2394        )
2395
2396        assert protocol_members == ["airplay_wiim"]
2397        assert protocol_domain == "airplay"
2398        assert protocol_player == sonos_airplay
2399
2400
2401class TestJoinActiveNativeSession:
2402    """
2403    Grouping a child onto a parent that is already playing natively.
2404
2405    A child's preferred output protocol must only steer protocol selection when the child
2406    initiates its own playback. When the child joins a parent that already holds an active
2407    native session, it should adopt native grouping if compatible, instead of forcing the
2408    whole group onto the child's preferred protocol.
2409    """
2410
2411    @staticmethod
2412    def _mark_native_playing(player: MockPlayer) -> None:
2413        """Put the player into a live native playback session."""
2414        player._attr_playback_state = PlaybackState.PLAYING
2415        player._cache.clear()
2416        player.set_active_output_protocol("native")
2417
2418    @staticmethod
2419    def _link_airplay(native_player: MockPlayer, airplay_id: str) -> None:
2420        """Link an AirPlay protocol player to a native player."""
2421        native_player.set_linked_output_protocols(
2422            [
2423                LinkedOutputProtocol(
2424                    output_protocol_id=airplay_id,
2425                    protocol_domain="airplay",
2426                    priority=10,
2427                )
2428            ]
2429        )
2430
2431    def _build_topology(
2432        self, mock_mass: MagicMock
2433    ) -> tuple[PlayerController, dict[str, MockPlayer]]:
2434        """
2435        Build parent Sonos A + child Sonos B, both AirPlay-capable and natively groupable.
2436
2437        The parent also exposes an AirPlay protocol player that supports SET_MEMBERS, so the
2438        child's preferred AirPlay protocol *would* be selectable if it were given priority.
2439        """
2440        controller = PlayerController(mock_mass)
2441        sonos_provider = MockProvider("sonos", instance_id="sonos_instance", mass=mock_mass)
2442        airplay_provider = MockProvider("airplay", instance_id="airplay_instance", mass=mock_mass)
2443
2444        sonos_a = MockPlayer(
2445            sonos_provider,
2446            "sonos_a",
2447            "Office",
2448            identifiers={IdentifierType.MAC_ADDRESS: "AA:BB:CC:DD:EE:01"},
2449        )
2450        sonos_a._attr_supported_features.add(PlayerFeature.SET_MEMBERS)
2451        sonos_a._attr_supported_features.add(PlayerFeature.PLAY_MEDIA)
2452        sonos_a._attr_can_group_with = {"sonos_b"}
2453        sonos_a._cache.clear()
2454
2455        sonos_b = MockPlayer(
2456            sonos_provider,
2457            "sonos_b",
2458            "Bathroom",
2459            identifiers={IdentifierType.MAC_ADDRESS: "AA:BB:CC:DD:EE:02"},
2460        )
2461        sonos_b._attr_supported_features.add(PlayerFeature.PLAY_MEDIA)
2462        sonos_b._attr_can_group_with = {"sonos_a"}
2463        sonos_b._cache.clear()
2464
2465        airplay_a = MockPlayer(
2466            airplay_provider,
2467            "airplay_a",
2468            "Office (AirPlay)",
2469            player_type=PlayerType.PROTOCOL,
2470            identifiers={IdentifierType.MAC_ADDRESS: "AA:BB:CC:DD:EE:01"},
2471        )
2472        airplay_a._attr_supported_features.add(PlayerFeature.SET_MEMBERS)
2473        airplay_a._attr_can_group_with = {"airplay_b"}
2474        airplay_a._cache.clear()
2475
2476        airplay_b = MockPlayer(
2477            airplay_provider,
2478            "airplay_b",
2479            "Bathroom (AirPlay)",
2480            player_type=PlayerType.PROTOCOL,
2481            identifiers={IdentifierType.MAC_ADDRESS: "AA:BB:CC:DD:EE:02"},
2482        )
2483        airplay_b._attr_supported_features.add(PlayerFeature.SET_MEMBERS)
2484        airplay_b._attr_can_group_with = {"airplay_a"}
2485        airplay_b._cache.clear()
2486
2487        self._link_airplay(sonos_a, "airplay_a")
2488        self._link_airplay(sonos_b, "airplay_b")
2489
2490        mock_mass.players = controller
2491        players = {
2492            "sonos_a": sonos_a,
2493            "sonos_b": sonos_b,
2494            "airplay_a": airplay_a,
2495            "airplay_b": airplay_b,
2496        }
2497        controller._players = dict(players)
2498        for player in players.values():
2499            player.update_state(signal_event=False)
2500        return controller, players
2501
2502    def _add_airplay_only_child(
2503        self, controller: PlayerController, players: dict[str, MockPlayer], mock_mass: MagicMock
2504    ) -> None:
2505        """
2506        Add a WiiM child that can only group via AirPlay (no native grouping with Sonos).
2507
2508        Different provider instance and absent from the parent's can_group_with, so
2509        _can_use_native_grouping returns False for it.
2510        """
2511        wiim_provider = MockProvider("wiim", instance_id="wiim_instance", mass=mock_mass)
2512        airplay_provider = MockProvider("airplay", instance_id="airplay_instance", mass=mock_mass)
2513        wiim_c = MockPlayer(
2514            wiim_provider,
2515            "wiim_c",
2516            "Bedroom",
2517            identifiers={IdentifierType.MAC_ADDRESS: "AA:BB:CC:DD:EE:03"},
2518        )
2519        wiim_c._attr_supported_features.add(PlayerFeature.PLAY_MEDIA)
2520        wiim_c._cache.clear()
2521        airplay_c = MockPlayer(
2522            airplay_provider,
2523            "airplay_c",
2524            "Bedroom (AirPlay)",
2525            player_type=PlayerType.PROTOCOL,
2526            identifiers={IdentifierType.MAC_ADDRESS: "AA:BB:CC:DD:EE:03"},
2527        )
2528        airplay_c._attr_supported_features.add(PlayerFeature.SET_MEMBERS)
2529        airplay_c._attr_can_group_with = {"airplay_a"}
2530        airplay_c._cache.clear()
2531        self._link_airplay(wiim_c, "airplay_c")
2532        # Allow the parent's AirPlay protocol to group with the new AirPlay child.
2533        players["airplay_a"]._attr_can_group_with = {"airplay_b", "airplay_c"}
2534        players["airplay_a"]._cache.clear()
2535        controller._players["wiim_c"] = wiim_c
2536        controller._players["airplay_c"] = airplay_c
2537        wiim_c.update_state(signal_event=False)
2538        airplay_c.update_state(signal_event=False)
2539
2540    def test_native_active_parent_ignores_child_preferred_protocol(
2541        self, mock_mass: MagicMock
2542    ) -> None:
2543        """A child joining a natively-playing parent groups natively, not via preferred AirPlay."""
2544
2545        def _get_raw(
2546            player_id: str, key: str, default: str | int | None = None
2547        ) -> str | int | None:
2548            if key == CONF_PREFERRED_OUTPUT_PROTOCOL and player_id == "sonos_b":
2549                return "airplay_b"
2550            if key == "min_volume":
2551                return 0
2552            if key == "max_volume":
2553                return 100
2554            return default
2555
2556        mock_mass.config.get_raw_player_config_value = MagicMock(side_effect=_get_raw)
2557
2558        controller, players = self._build_topology(mock_mass)
2559        # Parent A is already playing natively.
2560        self._mark_native_playing(players["sonos_a"])
2561
2562        protocol_members, native_members, _, _ = controller._translate_members_for_protocols(
2563            parent_player=players["sonos_a"],
2564            player_ids=["sonos_b"],
2565            parent_protocol_player=None,
2566            parent_protocol_domain=None,
2567        )
2568
2569        # Native grouping is used; the child's AirPlay preference is ignored on join.
2570        assert native_members == ["sonos_b"]
2571        assert protocol_members == []
2572
2573    def test_fresh_group_still_honors_child_preferred_protocol(self, mock_mass: MagicMock) -> None:
2574        """When the parent is not playing, the child's preferred protocol still steers selection."""
2575
2576        def _get_raw(
2577            player_id: str, key: str, default: str | int | None = None
2578        ) -> str | int | None:
2579            if key == CONF_PREFERRED_OUTPUT_PROTOCOL and player_id == "sonos_b":
2580                return "airplay_b"
2581            if key == "min_volume":
2582                return 0
2583            if key == "max_volume":
2584                return 100
2585            return default
2586
2587        mock_mass.config.get_raw_player_config_value = MagicMock(side_effect=_get_raw)
2588
2589        controller, players = self._build_topology(mock_mass)
2590        # Parent A has no active session (active_output_protocol stays None).
2591        assert players["sonos_a"].active_output_protocol is None
2592
2593        protocol_members, native_members, _, protocol_domain = (
2594            controller._translate_members_for_protocols(
2595                parent_player=players["sonos_a"],
2596                player_ids=["sonos_b"],
2597                parent_protocol_player=None,
2598                parent_protocol_domain=None,
2599            )
2600        )
2601
2602        # Same topology as the active-native case, but here the preference applies.
2603        assert protocol_members == ["airplay_b"]
2604        assert native_members == []
2605        assert protocol_domain == "airplay"
2606
2607    def test_idle_parent_with_stale_native_protocol_honors_child_preferred(
2608        self, mock_mass: MagicMock
2609    ) -> None:
2610        """A stopped parent (active protocol lingers briefly) does not force the native-join path."""
2611
2612        def _get_raw(
2613            player_id: str, key: str, default: str | int | None = None
2614        ) -> str | int | None:
2615            if key == CONF_PREFERRED_OUTPUT_PROTOCOL and player_id == "sonos_b":
2616                return "airplay_b"
2617            if key == "min_volume":
2618                return 0
2619            if key == "max_volume":
2620                return 100
2621            return default
2622
2623        mock_mass.config.get_raw_player_config_value = MagicMock(side_effect=_get_raw)
2624
2625        controller, players = self._build_topology(mock_mass)
2626        # Parent A stopped: active protocol still reads "native" but playback is idle.
2627        players["sonos_a"].set_active_output_protocol("native")
2628        assert players["sonos_a"].state.playback_state == PlaybackState.IDLE
2629
2630        protocol_members, native_members, _, protocol_domain = (
2631            controller._translate_members_for_protocols(
2632                parent_player=players["sonos_a"],
2633                player_ids=["sonos_b"],
2634                parent_protocol_player=None,
2635                parent_protocol_domain=None,
2636            )
2637        )
2638
2639        # No live session to join, so the child's preference applies.
2640        assert protocol_members == ["airplay_b"]
2641        assert native_members == []
2642        assert protocol_domain == "airplay"
2643
2644    def test_native_active_parent_airplay_only_child_uses_protocol(
2645        self, mock_mass: MagicMock
2646    ) -> None:
2647        """An AirPlay-only child (cannot group natively) still joins via its preferred protocol."""
2648
2649        def _get_raw(
2650            player_id: str, key: str, default: str | int | None = None
2651        ) -> str | int | None:
2652            if key == CONF_PREFERRED_OUTPUT_PROTOCOL and player_id == "wiim_c":
2653                return "airplay_c"
2654            if key == "min_volume":
2655                return 0
2656            if key == "max_volume":
2657                return 100
2658            return default
2659
2660        mock_mass.config.get_raw_player_config_value = MagicMock(side_effect=_get_raw)
2661
2662        controller, players = self._build_topology(mock_mass)
2663        self._add_airplay_only_child(controller, players, mock_mass)
2664        self._mark_native_playing(players["sonos_a"])
2665
2666        protocol_members, native_members, _, protocol_domain = (
2667            controller._translate_members_for_protocols(
2668                parent_player=players["sonos_a"],
2669                player_ids=["wiim_c"],
2670                parent_protocol_player=None,
2671                parent_protocol_domain=None,
2672            )
2673        )
2674
2675        # No native path exists for the WiiM, so it falls back to AirPlay grouping.
2676        assert protocol_members == ["airplay_c"]
2677        assert native_members == []
2678        assert protocol_domain == "airplay"
2679
2680    def test_native_active_parent_protocol_only_child_switches_via_common_protocol(
2681        self, mock_mass: MagicMock
2682    ) -> None:
2683        """
2684        A child with no native path (and no explicit preference) still forces the switch.
2685
2686        Even without a configured preferred protocol, a child that can only reach the group via
2687        a shared protocol (here AirPlay, which the natively-playing parent also supports) is
2688        grouped via that protocol - moving the group onto it.
2689        """
2690        controller, players = self._build_topology(mock_mass)
2691        self._add_airplay_only_child(controller, players, mock_mass)
2692        self._mark_native_playing(players["sonos_a"])
2693
2694        # No preferred_output_protocol override - the WiiM resolves via the common-protocol path.
2695        protocol_members, native_members, _, protocol_domain = (
2696            controller._translate_members_for_protocols(
2697                parent_player=players["sonos_a"],
2698                player_ids=["wiim_c"],
2699                parent_protocol_player=None,
2700                parent_protocol_domain=None,
2701            )
2702        )
2703
2704        assert protocol_members == ["airplay_c"]
2705        assert native_members == []
2706        assert protocol_domain == "airplay"
2707
2708    @pytest.mark.parametrize(
2709        "member_order",
2710        [["wiim_c", "sonos_b"], ["sonos_b", "wiim_c"]],
2711    )
2712    def test_native_active_parent_keeps_mixed_batch_cohesive(
2713        self, mock_mass: MagicMock, member_order: list[str]
2714    ) -> None:
2715        """
2716        A mixed batch stays cohesive on one protocol regardless of member order.
2717
2718        When an AirPlay-only child forces the group onto AirPlay, the natively-groupable Sonos
2719        child adopts that same protocol rather than splitting off into a native sub-group -
2720        whether it is added before or after the AirPlay-only child.
2721        """
2722        controller, players = self._build_topology(mock_mass)
2723        self._add_airplay_only_child(controller, players, mock_mass)
2724        self._mark_native_playing(players["sonos_a"])
2725
2726        protocol_members, native_members, _, protocol_domain = (
2727            controller._translate_members_for_protocols(
2728                parent_player=players["sonos_a"],
2729                player_ids=member_order,
2730                parent_protocol_player=None,
2731                parent_protocol_domain=None,
2732            )
2733        )
2734
2735        assert native_members == []
2736        assert set(protocol_members) == {"airplay_c", "airplay_b"}
2737        assert protocol_domain == "airplay"
2738
2739
2740class TestSessionBoundNativeGrouping:
2741    """
2742    Grouping a parent whose native grouping only works while it renders its own stream.
2743
2744    Such native members are attached to that very stream, so they cannot stay natively
2745    grouped once the group is moved onto one of the parent's output protocols. Parents
2746    with device-side native grouping are unaffected and keep their hybrid groups.
2747    """
2748
2749    @staticmethod
2750    def _link_bridge(player: MockPlayer, bridge_id: str) -> None:
2751        """Link a bridge protocol player to a native player."""
2752        player.set_linked_output_protocols(
2753            [
2754                LinkedOutputProtocol(
2755                    output_protocol_id=bridge_id,
2756                    protocol_domain="sendspin",
2757                    priority=40,
2758                )
2759            ]
2760        )
2761
2762    def _build_topology(
2763        self,
2764        mock_mass: MagicMock,
2765        parent_class: type[MockPlayer],
2766        with_orphan: bool = False,
2767    ) -> tuple[PlayerController, dict[str, MockPlayer]]:
2768        """
2769        Build a leader with a natively groupable member and a bridge-only member.
2770
2771        The leader and its member speak the same (speaker) provider, so they group natively.
2772        Both also expose a bridge protocol player, which is the only way to reach the third
2773        member: a native player of the bridge domain.
2774
2775        :param mock_mass: The mocked MusicAssistant instance.
2776        :param parent_class: The player class to build the leader from.
2777        :param with_orphan: Add a second natively groupable member without a bridge protocol.
2778        """
2779        controller = PlayerController(mock_mass)
2780        speaker_provider = MockProvider("speaker", instance_id="speaker_instance", mass=mock_mass)
2781        bridge_provider = MockProvider("sendspin", instance_id="sendspin_instance", mass=mock_mass)
2782
2783        leader = parent_class(speaker_provider, "speaker_leader", "Living Room")
2784        leader._attr_supported_features.add(PlayerFeature.SET_MEMBERS)
2785        leader._attr_supported_features.add(PlayerFeature.PLAY_MEDIA)
2786        leader._cache.clear()
2787
2788        member = MockPlayer(speaker_provider, "speaker_member", "Kitchen")
2789        member._attr_supported_features.add(PlayerFeature.PLAY_MEDIA)
2790        member._cache.clear()
2791
2792        bridge_leader = MockPlayer(
2793            bridge_provider,
2794            "bridge_leader",
2795            "Living Room (Bridge)",
2796            player_type=PlayerType.PROTOCOL,
2797        )
2798        bridge_leader._attr_supported_features.add(PlayerFeature.SET_MEMBERS)
2799        bridge_leader._cache.clear()
2800        bridge_leader.set_protocol_parent_id("speaker_leader")
2801
2802        bridge_member = MockPlayer(
2803            bridge_provider,
2804            "bridge_member",
2805            "Kitchen (Bridge)",
2806            player_type=PlayerType.PROTOCOL,
2807        )
2808        bridge_member._attr_supported_features.add(PlayerFeature.SET_MEMBERS)
2809        bridge_member._cache.clear()
2810        bridge_member.set_protocol_parent_id("speaker_member")
2811
2812        # native player of the bridge domain: only reachable through the bridge protocol
2813        bridge_only = MockPlayer(bridge_provider, "bridge_only", "Voice Assistant")
2814        bridge_only._attr_supported_features.add(PlayerFeature.PLAY_MEDIA)
2815        bridge_only._cache.clear()
2816
2817        self._link_bridge(leader, "bridge_leader")
2818        self._link_bridge(member, "bridge_member")
2819
2820        players: dict[str, MockPlayer] = {
2821            "speaker_leader": leader,
2822            "speaker_member": member,
2823            "bridge_leader": bridge_leader,
2824            "bridge_member": bridge_member,
2825            "bridge_only": bridge_only,
2826        }
2827        if with_orphan:
2828            orphan = MockPlayer(speaker_provider, "speaker_orphan", "Bedroom")
2829            orphan._attr_supported_features.add(PlayerFeature.PLAY_MEDIA)
2830            orphan._cache.clear()
2831            players["speaker_orphan"] = orphan
2832
2833        mock_mass.players = controller
2834        controller._players = dict(players)
2835        for player in players.values():
2836            player.update_state(signal_event=False)
2837        return controller, players
2838
2839    @pytest.mark.parametrize(
2840        "member_order",
2841        [
2842            ["speaker_member", "bridge_only"],
2843            ["bridge_only", "speaker_member"],
2844        ],
2845    )
2846    def test_native_members_follow_the_group_protocol(
2847        self, mock_mass: MagicMock, member_order: list[str]
2848    ) -> None:
2849        """A natively groupable member moves onto the protocol the group ends up on."""
2850        controller, players = self._build_topology(mock_mass, SessionBoundMockPlayer)
2851
2852        protocol_members, native_members, _, protocol_domain = (
2853            controller._translate_members_for_protocols(
2854                parent_player=players["speaker_leader"],
2855                player_ids=member_order,
2856                parent_protocol_player=None,
2857                parent_protocol_domain=None,
2858            )
2859        )
2860
2861        assert native_members == []
2862        assert set(protocol_members) == {"bridge_only", "bridge_member"}
2863        assert protocol_domain == "sendspin"
2864
2865    def test_member_without_the_group_protocol_is_refused(
2866        self, mock_mass: MagicMock, caplog: pytest.LogCaptureFixture
2867    ) -> None:
2868        """A native member that lacks the group's protocol is dropped, the others still move."""
2869        controller, players = self._build_topology(
2870            mock_mass, SessionBoundMockPlayer, with_orphan=True
2871        )
2872
2873        with caplog.at_level(logging.WARNING):
2874            protocol_members, native_members, _, _ = controller._translate_members_for_protocols(
2875                parent_player=players["speaker_leader"],
2876                player_ids=["speaker_member", "speaker_orphan", "bridge_only"],
2877                parent_protocol_player=None,
2878                parent_protocol_domain=None,
2879            )
2880
2881        assert native_members == []
2882        assert set(protocol_members) == {"bridge_only", "bridge_member"}
2883        assert (
2884            "Cannot group Bedroom with Living Room: the group plays through the sendspin "
2885            "protocol, which Bedroom does not support" in caplog.text
2886        )
2887
2888    def test_device_side_parent_keeps_its_hybrid_group(self, mock_mass: MagicMock) -> None:
2889        """A parent with device-side native grouping keeps its members natively grouped."""
2890        controller, players = self._build_topology(mock_mass, MockPlayer)
2891
2892        protocol_members, native_members, _, protocol_domain = (
2893            controller._translate_members_for_protocols(
2894                parent_player=players["speaker_leader"],
2895                player_ids=["speaker_member", "bridge_only"],
2896                parent_protocol_player=None,
2897                parent_protocol_domain=None,
2898            )
2899        )
2900
2901        assert native_members == ["speaker_member"]
2902        assert protocol_members == ["bridge_only"]
2903        assert protocol_domain == "sendspin"
2904
2905
2906class TestCanGroupWith:
2907    """Tests for can_group_with property with three scenarios."""
2908
2909    def test_scenario_1_native_active_only_native_players(self, mock_mass: MagicMock) -> None:
2910        """Test Scenario 1: Native playback active -> all protocols shown (new behavior)."""
2911        controller = PlayerController(mock_mass)
2912
2913        sonos_provider = MockProvider("sonos", instance_id="sonos_instance", mass=mock_mass)
2914        airplay_provider = MockProvider("airplay", instance_id="airplay_instance", mass=mock_mass)
2915
2916        # Create Sonos player with native and AirPlay support
2917        sonos_player = MockPlayer(
2918            sonos_provider,
2919            "sonos_123",
2920            "Living Room",
2921            identifiers={IdentifierType.MAC_ADDRESS: "AA:BB:CC:DD:EE:01"},
2922        )
2923        sonos_player._attr_supported_features.add(PlayerFeature.PLAY_MEDIA)
2924        sonos_player._attr_supported_features.add(PlayerFeature.SET_MEMBERS)
2925        sonos_player._attr_can_group_with = {"sonos_456"}
2926        sonos_player._cache.clear()
2927        sonos_player.set_active_output_protocol("native")
2928
2929        # Create another Sonos player
2930        sonos_player_b = MockPlayer(
2931            sonos_provider,
2932            "sonos_456",
2933            "Kitchen",
2934            identifiers={IdentifierType.MAC_ADDRESS: "AA:BB:CC:DD:EE:02"},
2935        )
2936
2937        # Create AirPlay protocol player
2938        sonos_airplay = MockPlayer(
2939            airplay_provider,
2940            "airplay_sonos",
2941            "Living Room (AirPlay)",
2942            player_type=PlayerType.PROTOCOL,
2943            identifiers={IdentifierType.MAC_ADDRESS: "AA:BB:CC:DD:EE:01"},
2944        )
2945        sonos_airplay._attr_supported_features.add(PlayerFeature.SET_MEMBERS)
2946        sonos_airplay._attr_can_group_with = {"airplay_other"}
2947        sonos_airplay._cache.clear()
2948        sonos_airplay.set_protocol_parent_id("sonos_123")
2949
2950        sonos_player.set_linked_output_protocols(
2951            [
2952                LinkedOutputProtocol(
2953                    output_protocol_id="airplay_sonos",
2954                    protocol_domain="airplay",
2955                    priority=10,
2956                )
2957            ]
2958        )
2959
2960        # Wire up mock_mass.players to controller so player lookups resolve
2961        mock_mass.players = controller
2962
2963        controller._players = {
2964            "sonos_123": sonos_player,
2965            "sonos_456": sonos_player_b,
2966            "airplay_sonos": sonos_airplay,
2967        }
2968
2969        # Update state after modifying attributes and registering with controller
2970        sonos_player.update_state(signal_event=False)
2971        sonos_player_b.update_state(signal_event=False)
2972        sonos_airplay.update_state(signal_event=False)
2973
2974        # Get can_group_with while native is active
2975        groupable = sonos_player.state.can_group_with
2976
2977        # NEW BEHAVIOR: Should show both native AND protocol players
2978        # even when native protocol is active
2979        assert "sonos_456" in groupable  # Native Sonos player
2980        # Note: airplay_other is not registered in controller._players, so it won't appear
2981        # But the logic should still allow showing AirPlay options if they were registered
2982
2983    def test_scenario_2_protocol_active_hybrid_groups(self, mock_mass: MagicMock) -> None:
2984        """Test Scenario 2: Protocol active -> show all protocols (new behavior)."""
2985        controller = PlayerController(mock_mass)
2986
2987        sonos_provider = MockProvider("sonos", instance_id="sonos_instance", mass=mock_mass)
2988        airplay_provider = MockProvider("airplay", instance_id="airplay_instance", mass=mock_mass)
2989
2990        # Create Sonos player with AirPlay active
2991        sonos_player = MockPlayer(
2992            sonos_provider,
2993            "sonos_123",
2994            "Living Room",
2995            identifiers={IdentifierType.MAC_ADDRESS: "AA:BB:CC:DD:EE:01"},
2996        )
2997        sonos_player._attr_supported_features.add(PlayerFeature.PLAY_MEDIA)
2998        sonos_player._attr_supported_features.add(PlayerFeature.SET_MEMBERS)
2999        sonos_player._attr_can_group_with = {"sonos_456"}
3000        sonos_player._cache.clear()
3001
3002        # Create another Sonos player
3003        sonos_player_b = MockPlayer(
3004            sonos_provider,
3005            "sonos_456",
3006            "Kitchen",
3007            identifiers={IdentifierType.MAC_ADDRESS: "AA:BB:CC:DD:EE:02"},
3008        )
3009
3010        # Create AirPlay protocol player
3011        sonos_airplay = MockPlayer(
3012            airplay_provider,
3013            "airplay_sonos",
3014            "Living Room (AirPlay)",
3015            player_type=PlayerType.PROTOCOL,
3016            identifiers={IdentifierType.MAC_ADDRESS: "AA:BB:CC:DD:EE:01"},
3017        )
3018        sonos_airplay._attr_supported_features.add(PlayerFeature.SET_MEMBERS)
3019        sonos_airplay._attr_can_group_with = {"airplay_other"}
3020        sonos_airplay._cache.clear()
3021        sonos_airplay.set_protocol_parent_id("sonos_123")
3022
3023        # Create another device with AirPlay
3024        wiim_player = MockPlayer(
3025            sonos_provider,
3026            "wiim_789",
3027            "Bedroom",
3028            identifiers={IdentifierType.MAC_ADDRESS: "AA:BB:CC:DD:EE:03"},
3029        )
3030
3031        airplay_other = MockPlayer(
3032            airplay_provider,
3033            "airplay_other",
3034            "Bedroom (AirPlay)",
3035            player_type=PlayerType.PROTOCOL,
3036            identifiers={IdentifierType.MAC_ADDRESS: "AA:BB:CC:DD:EE:03"},
3037        )
3038        airplay_other._attr_supported_features.add(PlayerFeature.SET_MEMBERS)
3039        airplay_other._attr_can_group_with = {"airplay_sonos"}
3040        airplay_other._cache.clear()
3041        airplay_other.set_protocol_parent_id("wiim_789")
3042
3043        wiim_player.set_linked_output_protocols(
3044            [
3045                LinkedOutputProtocol(
3046                    output_protocol_id="airplay_other",
3047                    protocol_domain="airplay",
3048                    priority=10,
3049                ),
3050            ]
3051        )
3052
3053        sonos_player.set_linked_output_protocols(
3054            [
3055                LinkedOutputProtocol(
3056                    output_protocol_id="airplay_sonos",
3057                    protocol_domain="airplay",
3058                    priority=10,
3059                )
3060            ]
3061        )
3062        sonos_player.set_active_output_protocol("airplay_sonos")
3063
3064        # Wire up mock_mass.players to controller so player lookups resolve
3065        mock_mass.players = controller
3066
3067        controller._players = {
3068            "sonos_123": sonos_player,
3069            "sonos_456": sonos_player_b,
3070            "wiim_789": wiim_player,
3071            "airplay_sonos": sonos_airplay,
3072            "airplay_other": airplay_other,
3073        }
3074
3075        # Clear cache after setting linked protocols
3076        sonos_player._cache.clear()
3077        wiim_player._cache.clear()
3078
3079        # Refresh state after modifying attributes and registering with controller
3080        # (refresh_state: registration changed cross-player state without the
3081        # production fan-out running, as events are suppressed in this test)
3082        # IMPORTANT: Update protocol players FIRST, then parent players
3083        sonos_airplay.refresh_state(signal_event=False)
3084        airplay_other.refresh_state(signal_event=False)
3085        sonos_player.refresh_state(signal_event=False)
3086        sonos_player_b.refresh_state(signal_event=False)
3087        wiim_player.refresh_state(signal_event=False)
3088
3089        # Get can_group_with while AirPlay is active
3090        groupable = sonos_player.state.can_group_with
3091
3092        # NEW BEHAVIOR: Should show ALL protocols + native players
3093        # regardless of which protocol is active
3094        assert "sonos_456" in groupable  # Native Sonos player
3095        assert "wiim_789" in groupable  # Via airplay_other protocol
3096
3097    def test_scenario_3_no_active_output_all_protocols_shown(self, mock_mass: MagicMock) -> None:
3098        """Test Scenario 3: No active output -> show all compatible protocols + native."""
3099        controller = PlayerController(mock_mass)
3100
3101        sonos_provider = MockProvider("sonos", instance_id="sonos_instance", mass=mock_mass)
3102        airplay_provider = MockProvider("airplay", instance_id="airplay_instance", mass=mock_mass)
3103        dlna_provider = MockProvider("dlna", instance_id="dlna_instance", mass=mock_mass)
3104
3105        # Create Sonos player (no active protocol)
3106        sonos_player = MockPlayer(
3107            sonos_provider,
3108            "sonos_123",
3109            "Living Room",
3110            identifiers={IdentifierType.MAC_ADDRESS: "AA:BB:CC:DD:EE:01"},
3111        )
3112        sonos_player._attr_supported_features.add(PlayerFeature.PLAY_MEDIA)
3113        sonos_player._attr_supported_features.add(PlayerFeature.SET_MEMBERS)
3114        sonos_player._attr_can_group_with = {"sonos_456"}
3115        sonos_player._cache.clear()
3116        # No active output protocol set
3117
3118        # Create another Sonos player
3119        sonos_player_b = MockPlayer(
3120            sonos_provider,
3121            "sonos_456",
3122            "Kitchen",
3123            identifiers={IdentifierType.MAC_ADDRESS: "AA:BB:CC:DD:EE:02"},
3124        )
3125
3126        # Create AirPlay protocol player (supports SET_MEMBERS)
3127        sonos_airplay = MockPlayer(
3128            airplay_provider,
3129            "airplay_sonos",
3130            "Living Room (AirPlay)",
3131            player_type=PlayerType.PROTOCOL,
3132            identifiers={IdentifierType.MAC_ADDRESS: "AA:BB:CC:DD:EE:01"},
3133        )
3134        sonos_airplay._attr_supported_features.add(PlayerFeature.SET_MEMBERS)
3135        sonos_airplay._attr_can_group_with = {"airplay_other"}
3136        sonos_airplay._cache.clear()
3137        sonos_airplay.set_protocol_parent_id("sonos_123")
3138
3139        # Create DLNA protocol player (does NOT support SET_MEMBERS)
3140        sonos_dlna = MockPlayer(
3141            dlna_provider,
3142            "dlna_sonos",
3143            "Living Room (DLNA)",
3144            player_type=PlayerType.PROTOCOL,
3145            identifiers={IdentifierType.MAC_ADDRESS: "AA:BB:CC:DD:EE:01"},
3146        )
3147        # No SET_MEMBERS support
3148        sonos_dlna._attr_can_group_with = {"dlna_other"}
3149        sonos_dlna.set_protocol_parent_id("sonos_123")
3150
3151        # Another device
3152        wiim_player = MockPlayer(
3153            sonos_provider,
3154            "wiim_789",
3155            "Bedroom",
3156            identifiers={IdentifierType.MAC_ADDRESS: "AA:BB:CC:DD:EE:03"},
3157        )
3158
3159        airplay_other = MockPlayer(
3160            airplay_provider,
3161            "airplay_other",
3162            "Bedroom (AirPlay)",
3163            player_type=PlayerType.PROTOCOL,
3164            identifiers={IdentifierType.MAC_ADDRESS: "AA:BB:CC:DD:EE:03"},
3165        )
3166        airplay_other._attr_supported_features.add(PlayerFeature.SET_MEMBERS)
3167        airplay_other._attr_can_group_with = {"airplay_sonos"}
3168        airplay_other._cache.clear()
3169        airplay_other.set_protocol_parent_id("wiim_789")
3170
3171        sonos_player.set_linked_output_protocols(
3172            [
3173                LinkedOutputProtocol(
3174                    output_protocol_id="airplay_sonos",
3175                    protocol_domain="airplay",
3176                    priority=10,
3177                ),
3178                LinkedOutputProtocol(
3179                    output_protocol_id="dlna_sonos",
3180                    protocol_domain="dlna",
3181                    priority=50,
3182                ),
3183            ]
3184        )
3185
3186        wiim_player.set_linked_output_protocols(
3187            [
3188                LinkedOutputProtocol(
3189                    output_protocol_id="airplay_other",
3190                    protocol_domain="airplay",
3191                    priority=10,
3192                ),
3193            ]
3194        )
3195
3196        # Clear cache after setting linked protocols (output_protocols is cached)
3197        sonos_player._cache.clear()
3198        wiim_player._cache.clear()
3199
3200        # Wire up mock_mass.players to controller so player lookups resolve
3201        mock_mass.players = controller
3202
3203        controller._players = {
3204            "sonos_123": sonos_player,
3205            "sonos_456": sonos_player_b,
3206            "wiim_789": wiim_player,
3207            "airplay_sonos": sonos_airplay,
3208            "airplay_other": airplay_other,
3209            "dlna_sonos": sonos_dlna,
3210        }
3211
3212        # Update state after modifying attributes and registering with controller
3213        # Note: set_linked_output_protocols calls trigger_player_update, but since mass.players
3214        # is a MagicMock, we need to manually call update_state
3215        # IMPORTANT: Update protocol players FIRST, then parent players, because parent players
3216        # access protocol_player.state.can_group_with during their update_state()
3217        sonos_airplay.update_state(signal_event=False)
3218        airplay_other.update_state(signal_event=False)
3219        sonos_dlna.update_state(signal_event=False)
3220        sonos_player.update_state(signal_event=False)
3221        sonos_player_b.update_state(signal_event=False)
3222        wiim_player.update_state(signal_event=False)
3223
3224        # Get can_group_with with no active protocol
3225        groupable = sonos_player.state.can_group_with
3226
3227        # Should show native players + AirPlay players (supports SET_MEMBERS)
3228        # but NOT DLNA players (no SET_MEMBERS support)
3229        assert "sonos_456" in groupable
3230        assert "wiim_789" in groupable  # Via AirPlay protocol
3231        # DLNA players should not be shown since DLNA doesn't support SET_MEMBERS
3232
3233
3234class TestNativePlayerProtocolGrouping:
3235    """Tests for grouping between native PLAYER type and PROTOCOL type AirPlay players."""
3236
3237    def test_native_airplay_player_sees_protocol_players_as_visible_parents(
3238        self, mock_mass: MagicMock
3239    ) -> None:
3240        """Test that a native PLAYER type translates protocol players to visible parents."""
3241        controller = PlayerController(mock_mass)
3242
3243        airplay_provider = MockProvider("airplay", instance_id="airplay", mass=mock_mass)
3244        sonos_provider = MockProvider("sonos", instance_id="sonos", mass=mock_mass)
3245
3246        # HomePod: native AirPlay PLAYER (not PROTOCOL)
3247        homepod = MockPlayer(airplay_provider, "homepod_1", "Office")
3248        homepod._attr_supported_features.add(PlayerFeature.SET_MEMBERS)
3249        homepod._attr_can_group_with = {"airplay"}  # Provider instance ID
3250        homepod._cache.clear()
3251
3252        # Sonos native player (visible to the user)
3253        sonos_player = MockPlayer(sonos_provider, "sonos_1", "Kitchen")
3254        sonos_player._attr_supported_features.add(PlayerFeature.SET_MEMBERS)
3255        sonos_player._cache.clear()
3256
3257        # AirPlay protocol player for the Sonos (hidden, linked to sonos_player)
3258        sonos_airplay = MockPlayer(
3259            airplay_provider,
3260            "airplay_sonos_1",
3261            "Kitchen (AirPlay)",
3262            player_type=PlayerType.PROTOCOL,
3263        )
3264        sonos_airplay._attr_supported_features.add(PlayerFeature.SET_MEMBERS)
3265        sonos_airplay._attr_can_group_with = {"airplay"}
3266        sonos_airplay._cache.clear()
3267        sonos_airplay.set_protocol_parent_id("sonos_1")
3268
3269        sonos_player.set_linked_output_protocols(
3270            [
3271                LinkedOutputProtocol(
3272                    output_protocol_id="airplay_sonos_1",
3273                    protocol_domain="airplay",
3274                    priority=10,
3275                )
3276            ]
3277        )
3278
3279        mock_mass.players = controller
3280        mock_mass.get_provider = MagicMock(return_value=airplay_provider)
3281
3282        controller._players = {
3283            "homepod_1": homepod,
3284            "sonos_1": sonos_player,
3285            "airplay_sonos_1": sonos_airplay,
3286        }
3287
3288        # Mark players as initialized so they are returned by all_players()
3289        homepod.set_initialized()
3290        sonos_player.set_initialized()
3291        sonos_airplay.set_initialized()
3292
3293        # Update protocol players first, then parents
3294        sonos_airplay.update_state(signal_event=False)
3295        sonos_player.update_state(signal_event=False)
3296        homepod.update_state(signal_event=False)
3297
3298        groupable = homepod.state.can_group_with
3299
3300        # HomePod should see Sonos's VISIBLE player, not the hidden protocol player
3301        assert "sonos_1" in groupable
3302        assert "airplay_sonos_1" not in groupable  # Hidden protocol ID must NOT appear
3303
3304    def test_protocol_linked_player_sees_native_airplay_player(self, mock_mass: MagicMock) -> None:
3305        """Test that a player with linked AirPlay protocol sees native PLAYER type players."""
3306        controller = PlayerController(mock_mass)
3307
3308        airplay_provider = MockProvider("airplay", instance_id="airplay", mass=mock_mass)
3309        sonos_provider = MockProvider("sonos", instance_id="sonos", mass=mock_mass)
3310
3311        # HomePod: native AirPlay PLAYER
3312        homepod = MockPlayer(airplay_provider, "homepod_1", "Office")
3313        homepod._attr_supported_features.add(PlayerFeature.SET_MEMBERS)
3314        homepod._attr_can_group_with = {"airplay"}
3315        homepod._cache.clear()
3316
3317        # Sonos native player (visible to the user)
3318        sonos_player = MockPlayer(sonos_provider, "sonos_1", "Kitchen")
3319        sonos_player._attr_supported_features.add(PlayerFeature.PLAY_MEDIA)
3320        sonos_player._attr_supported_features.add(PlayerFeature.SET_MEMBERS)
3321        sonos_player._attr_can_group_with = set()  # No native Sonos grouping peers
3322        sonos_player._cache.clear()
3323
3324        # AirPlay protocol player for the Sonos (hidden, linked to sonos_player)
3325        sonos_airplay = MockPlayer(
3326            airplay_provider,
3327            "airplay_sonos_1",
3328            "Kitchen (AirPlay)",
3329            player_type=PlayerType.PROTOCOL,
3330        )
3331        sonos_airplay._attr_supported_features.add(PlayerFeature.SET_MEMBERS)
3332        sonos_airplay._attr_can_group_with = {"airplay"}  # Provider instance ID
3333        sonos_airplay._cache.clear()
3334        sonos_airplay.set_protocol_parent_id("sonos_1")
3335
3336        sonos_player.set_linked_output_protocols(
3337            [
3338                LinkedOutputProtocol(
3339                    output_protocol_id="airplay_sonos_1",
3340                    protocol_domain="airplay",
3341                    priority=10,
3342                )
3343            ]
3344        )
3345
3346        mock_mass.players = controller
3347        mock_mass.get_provider = MagicMock(return_value=airplay_provider)
3348
3349        controller._players = {
3350            "homepod_1": homepod,
3351            "sonos_1": sonos_player,
3352            "airplay_sonos_1": sonos_airplay,
3353        }
3354
3355        # Mark players as initialized so they are returned by all_players()
3356        homepod.set_initialized()
3357        sonos_player.set_initialized()
3358        sonos_airplay.set_initialized()
3359
3360        # Update protocol players first, then parents
3361        sonos_airplay.update_state(signal_event=False)
3362        homepod.update_state(signal_event=False)
3363        sonos_player.update_state(signal_event=False)
3364
3365        groupable = sonos_player.state.can_group_with
3366
3367        # Sonos should see HomePod via its linked AirPlay protocol's can_group_with
3368        assert "homepod_1" in groupable
3369
3370
3371class TestProtocolSwitchingDuringPlayback:
3372    """Tests for dynamic protocol switching when group members change during playback."""
3373
3374    def _build_session_bound_group(
3375        self, mock_mass: MagicMock, playback_state: PlaybackState
3376    ) -> tuple[PlayerController, dict[str, MockPlayer]]:
3377        """
3378        Build a leader that renders its own stream, one native member and a bridge-only player.
3379
3380        :param mock_mass: The mocked MusicAssistant instance.
3381        :param playback_state: The state the leader is in when the switch happens.
3382        :return: The controller and its players by ID.
3383        """
3384        controller = PlayerController(mock_mass)
3385        speaker_provider = MockProvider("speaker", instance_id="speaker_instance", mass=mock_mass)
3386        bridge_provider = MockProvider("sendspin", instance_id="sendspin_instance", mass=mock_mass)
3387
3388        leader = SessionBoundMockPlayer(speaker_provider, "speaker_leader", "Living Room")
3389        leader._attr_supported_features.add(PlayerFeature.SET_MEMBERS)
3390        leader._attr_supported_features.add(PlayerFeature.PLAY_MEDIA)
3391        leader._attr_group_members = ["speaker_leader", "speaker_member"]
3392        leader._attr_playback_state = playback_state
3393        leader._cache.clear()
3394
3395        member = MockPlayer(speaker_provider, "speaker_member", "Kitchen")
3396        member._attr_supported_features.add(PlayerFeature.PLAY_MEDIA)
3397        member._cache.clear()
3398
3399        bridge_only = MockPlayer(bridge_provider, "bridge_only", "Voice Assistant")
3400        bridge_only._attr_supported_features.add(PlayerFeature.PLAY_MEDIA)
3401        bridge_only._cache.clear()
3402
3403        players: dict[str, MockPlayer] = {
3404            "speaker_leader": leader,
3405            "speaker_member": member,
3406            "bridge_only": bridge_only,
3407        }
3408        for parent_id, bridge_id, bridge_name in (
3409            ("speaker_leader", "bridge_leader", "Living Room (Bridge)"),
3410            ("speaker_member", "bridge_member", "Kitchen (Bridge)"),
3411        ):
3412            bridge = MockPlayer(
3413                bridge_provider, bridge_id, bridge_name, player_type=PlayerType.PROTOCOL
3414            )
3415            bridge._attr_supported_features.add(PlayerFeature.SET_MEMBERS)
3416            bridge._cache.clear()
3417            bridge.set_protocol_parent_id(parent_id)
3418            players[bridge_id] = bridge
3419            players[parent_id].set_linked_output_protocols(
3420                [
3421                    LinkedOutputProtocol(
3422                        output_protocol_id=bridge_id,
3423                        protocol_domain="sendspin",
3424                        priority=40,
3425                    )
3426                ]
3427            )
3428
3429        mock_mass.players = controller
3430        controller._players = dict(players)
3431        for registered_player in players.values():
3432            registered_player.update_state(signal_event=False)
3433        leader.set_active_output_protocol("native")
3434        return controller, players
3435
3436    async def test_no_protocol_set_during_grouping_without_playback(
3437        self, mock_mass: MagicMock
3438    ) -> None:
3439        """Test that no protocol is set when grouping players without active playback."""
3440        controller = PlayerController(mock_mass)
3441
3442        sonos_provider = MockProvider("sonos", instance_id="sonos_instance", mass=mock_mass)
3443        airplay_provider = MockProvider("airplay", instance_id="airplay_instance", mass=mock_mass)
3444
3445        # Create Sonos player with AirPlay support
3446        sonos_player = MockPlayer(
3447            sonos_provider,
3448            "sonos_123",
3449            "Living Room",
3450            identifiers={IdentifierType.MAC_ADDRESS: "AA:BB:CC:DD:EE:01"},
3451        )
3452        sonos_player._attr_supported_features.add(PlayerFeature.PLAY_MEDIA)
3453        sonos_player._attr_supported_features.add(PlayerFeature.SET_MEMBERS)
3454        sonos_player._attr_can_group_with = {"sonos_456"}
3455
3456        # Create another Sonos player
3457        sonos_player_b = MockPlayer(
3458            sonos_provider,
3459            "sonos_456",
3460            "Kitchen",
3461            identifiers={IdentifierType.MAC_ADDRESS: "AA:BB:CC:DD:EE:02"},
3462        )
3463        sonos_player_b._attr_supported_features.add(PlayerFeature.SET_MEMBERS)
3464
3465        # Create AirPlay protocol player
3466        sonos_airplay = MockPlayer(
3467            airplay_provider,
3468            "airplay_sonos",
3469            "Living Room (AirPlay)",
3470            player_type=PlayerType.PROTOCOL,
3471            identifiers={IdentifierType.MAC_ADDRESS: "AA:BB:CC:DD:EE:01"},
3472        )
3473        sonos_airplay._attr_supported_features.add(PlayerFeature.SET_MEMBERS)
3474        sonos_airplay.set_protocol_parent_id("sonos_123")
3475
3476        sonos_player.set_linked_output_protocols(
3477            [
3478                LinkedOutputProtocol(
3479                    output_protocol_id="airplay_sonos",
3480                    protocol_domain="airplay",
3481                    priority=10,
3482                )
3483            ]
3484        )
3485
3486        mock_mass.players = controller
3487        controller._players = {
3488            "sonos_123": sonos_player,
3489            "sonos_456": sonos_player_b,
3490            "airplay_sonos": sonos_airplay,
3491        }
3492
3493        # Group players via protocol (simulate grouping through AirPlay)
3494        # This should NOT set active_output_protocol anymore
3495        await controller._forward_protocol_set_members(
3496            parent_player=sonos_player,
3497            parent_protocol_player=sonos_airplay,
3498            protocol_members_to_add=["airplay_other"],  # Add a protocol member
3499            protocol_members_to_remove=[],
3500        )
3501
3502        # NEW BEHAVIOR: Protocol should NOT be set during grouping without playback
3503        # After grouping, protocol should not be activated until playback starts
3504        assert sonos_player.active_output_protocol is None
3505
3506    async def test_protocol_selected_at_playback_time(self, mock_mass: MagicMock) -> None:
3507        """Test that protocol is selected when playback starts, not during grouping."""
3508        controller = PlayerController(mock_mass)
3509
3510        sonos_provider = MockProvider("sonos", instance_id="sonos_instance", mass=mock_mass)
3511        airplay_provider = MockProvider("airplay", instance_id="airplay_instance", mass=mock_mass)
3512
3513        # Create Sonos player with AirPlay support
3514        sonos_player = MockPlayer(
3515            sonos_provider,
3516            "sonos_123",
3517            "Living Room",
3518            identifiers={IdentifierType.MAC_ADDRESS: "AA:BB:CC:DD:EE:01"},
3519        )
3520        sonos_player._attr_supported_features.add(PlayerFeature.PLAY_MEDIA)
3521        sonos_player._attr_supported_features.add(PlayerFeature.SET_MEMBERS)
3522
3523        # Create AirPlay protocol player with group members
3524        sonos_airplay = MockPlayer(
3525            airplay_provider,
3526            "airplay_sonos",
3527            "Living Room (AirPlay)",
3528            player_type=PlayerType.PROTOCOL,
3529            identifiers={IdentifierType.MAC_ADDRESS: "AA:BB:CC:DD:EE:01"},
3530        )
3531        sonos_airplay._attr_supported_features.add(PlayerFeature.SET_MEMBERS)
3532        sonos_airplay._attr_supported_features.add(PlayerFeature.PLAY_MEDIA)
3533        sonos_airplay.set_protocol_parent_id("sonos_123")
3534        # Simulate that AirPlay protocol has group members (needs >1 for grouping check)
3535        sonos_airplay._attr_group_members = ["airplay_sonos", "airplay_other"]
3536
3537        sonos_player.set_linked_output_protocols(
3538            [
3539                LinkedOutputProtocol(
3540                    output_protocol_id="airplay_sonos",
3541                    protocol_domain="airplay",
3542                    priority=10,
3543                )
3544            ]
3545        )
3546
3547        mock_mass.players = controller
3548        controller._players = {
3549            "sonos_123": sonos_player,
3550            "airplay_sonos": sonos_airplay,
3551        }
3552
3553        # Update state to apply group members to state
3554        sonos_airplay.update_state(signal_event=False)
3555        sonos_player.update_state(signal_event=False)
3556
3557        # Protocol should not be set yet
3558        assert sonos_player.active_output_protocol is None
3559
3560        # Select protocol for playback
3561        selected_player, output_protocol = controller._select_best_output_protocol(sonos_player)
3562
3563        # Should select AirPlay protocol because it has group members (Priority 1)
3564        assert selected_player == sonos_airplay
3565        assert output_protocol is not None
3566        assert output_protocol.output_protocol_id == "airplay_sonos"
3567
3568    async def test_no_restart_from_handle_set_members(self, mock_mass: MagicMock) -> None:
3569        """
3570        Test that _handle_set_members does NOT restart playback.
3571
3572        Protocol switching and playback restarts are handled in _forward_protocol_set_members,
3573        not in _handle_set_members. This test verifies that _handle_set_members doesn't
3574        trigger any redundant playback restarts.
3575        """
3576        controller = PlayerController(mock_mass)
3577
3578        sonos_provider = MockProvider("sonos", instance_id="sonos_instance", mass=mock_mass)
3579        airplay_provider = MockProvider("airplay", instance_id="airplay_instance", mass=mock_mass)
3580
3581        # Create Sonos player currently playing via AirPlay
3582        sonos_player = MockPlayer(
3583            sonos_provider,
3584            "sonos_123",
3585            "Living Room",
3586            identifiers={IdentifierType.MAC_ADDRESS: "AA:BB:CC:DD:EE:01"},
3587        )
3588        sonos_player._attr_supported_features.add(PlayerFeature.PLAY_MEDIA)
3589        sonos_player._attr_supported_features.add(PlayerFeature.SET_MEMBERS)
3590        sonos_player._attr_playback_state = PlaybackState.PLAYING
3591        sonos_player._attr_group_members = ["sonos_123", "sonos_456"]
3592
3593        # Create another Sonos player in the group (member of sonos_123's group)
3594        sonos_player_b = MockPlayer(
3595            sonos_provider,
3596            "sonos_456",
3597            "Kitchen",
3598            identifiers={IdentifierType.MAC_ADDRESS: "AA:BB:CC:DD:EE:02"},
3599        )
3600        sonos_player_b._attr_supported_features.add(PlayerFeature.SET_MEMBERS)
3601        # sonos_player_b's synced_to is derived from group_members, not a direct attribute
3602
3603        # Create AirPlay protocol player (was used for grouping)
3604        sonos_airplay = MockPlayer(
3605            airplay_provider,
3606            "airplay_sonos",
3607            "Living Room (AirPlay)",
3608            player_type=PlayerType.PROTOCOL,
3609            identifiers={IdentifierType.MAC_ADDRESS: "AA:BB:CC:DD:EE:01"},
3610        )
3611        sonos_airplay._attr_supported_features.add(PlayerFeature.SET_MEMBERS)
3612        sonos_airplay.set_protocol_parent_id("sonos_123")
3613
3614        sonos_player.set_linked_output_protocols(
3615            [
3616                LinkedOutputProtocol(
3617                    output_protocol_id="airplay_sonos",
3618                    protocol_domain="airplay",
3619                    priority=10,
3620                )
3621            ]
3622        )
3623
3624        mock_mass.players = controller
3625        controller._players = {
3626            "sonos_123": sonos_player,
3627            "sonos_456": sonos_player_b,
3628            "airplay_sonos": sonos_airplay,
3629        }
3630
3631        # Update state and set active output protocol AFTER registering with controller
3632        sonos_player.update_state(signal_event=False)
3633        sonos_player_b.update_state(signal_event=False)
3634        sonos_airplay.update_state(signal_event=False)
3635
3636        # Set active output protocol (must be done after controller is set up)
3637        sonos_player.set_active_output_protocol("airplay_sonos")
3638
3639        # Track if cmd_resume was called
3640        resume_called = False
3641
3642        async def mock_cmd_resume(
3643            player_id: str,  # noqa: ARG001
3644            source: str | None = None,  # noqa: ARG001
3645            media: PlayerMedia | None = None,  # noqa: ARG001
3646        ) -> None:
3647            nonlocal resume_called
3648            resume_called = True
3649
3650        controller.cmd_resume = mock_cmd_resume  # type: ignore[method-assign]
3651
3652        # Remove member - now only the parent player is left
3653        # After removal, _select_best_output_protocol would return native
3654        sonos_player._attr_group_members = ["sonos_123"]
3655        sonos_player._cache.clear()
3656
3657        # Call _handle_set_members directly to trigger the protocol change check
3658        await controller._handle_set_members(
3659            sonos_player,
3660            player_ids_to_add=None,
3661            player_ids_to_remove=["sonos_456"],
3662        )
3663
3664        # Playback should NOT have been restarted because we're going back to native
3665        assert not resume_called, "cmd_resume should not be called when switching to native"
3666
3667    @pytest.mark.parametrize("playback_state", [PlaybackState.PLAYING, PlaybackState.PAUSED])
3668    async def test_native_session_is_stopped_and_members_migrate(
3669        self, mock_mass: MagicMock, playback_state: PlaybackState
3670    ) -> None:
3671        """
3672        A parent that renders its own stream hands its native members over to the new protocol.
3673
3674        The members join the protocol group in the same call as the new member, after which
3675        the native session releases them and stops. Playback resumes only for a player that
3676        was playing, so adding a member to a paused group does not start it.
3677        """
3678        controller, players = self._build_session_bound_group(mock_mass, playback_state)
3679        leader = players["speaker_leader"]
3680        bridge_leader = players["bridge_leader"]
3681
3682        # record the sequence of provider level calls the switch performs
3683        calls: list[tuple[str, Any]] = []
3684        leader_set_members = leader.set_members
3685        bridge_set_members = bridge_leader.set_members
3686
3687        async def record_leader_set_members(
3688            player_ids_to_add: list[str] | None = None,
3689            player_ids_to_remove: list[str] | None = None,
3690        ) -> None:
3691            calls.append(("leader_set_members", player_ids_to_remove))
3692            await leader_set_members(player_ids_to_add, player_ids_to_remove)
3693
3694        async def record_bridge_set_members(
3695            player_ids_to_add: list[str] | None = None,
3696            player_ids_to_remove: list[str] | None = None,
3697        ) -> None:
3698            calls.append(("bridge_set_members", player_ids_to_add))
3699            await bridge_set_members(player_ids_to_add, player_ids_to_remove)
3700
3701        async def record_leader_stop() -> None:
3702            calls.append(("leader_stop", None))
3703
3704        leader.set_members = record_leader_set_members  # type: ignore[method-assign]
3705        leader.stop = record_leader_stop  # type: ignore[method-assign]
3706        bridge_leader.set_members = record_bridge_set_members  # type: ignore[method-assign]
3707        controller.cmd_resume = AsyncMock()  # type: ignore[method-assign]
3708        controller._handle_set_members = AsyncMock()  # type: ignore[method-assign]
3709
3710        await controller._forward_protocol_set_members(
3711            parent_player=leader,
3712            parent_protocol_player=bridge_leader,
3713            protocol_members_to_add=["bridge_only"],
3714            protocol_members_to_remove=[],
3715        )
3716
3717        assert calls == [
3718            # the native member joins the protocol group together with the new member
3719            ("bridge_set_members", ["bridge_only", "bridge_member"]),
3720            ("leader_set_members", ["speaker_member"]),
3721            ("leader_stop", None),
3722        ]
3723        assert leader.active_output_protocol == "bridge_leader"
3724        # Only a player that was playing resumes: adding a member to a paused group
3725        # hands the output over without starting playback.
3726        if playback_state == PlaybackState.PLAYING:
3727            controller.cmd_resume.assert_awaited_once_with("speaker_leader")
3728        else:
3729            controller.cmd_resume.assert_not_awaited()
3730        controller._handle_set_members.assert_not_awaited()
3731
3732    async def test_idle_native_members_migrate_without_stopping_the_parent(
3733        self, mock_mass: MagicMock
3734    ) -> None:
3735        """
3736        An idle parent hands its native members over too, with no session to stop.
3737
3738        A native group outlives the session it was formed for, so the members are still
3739        attached to the parent when it switches protocol. Leaving them behind would keep
3740        them listed as grouped while nothing feeds them on the next play.
3741        """
3742        controller, players = self._build_session_bound_group(mock_mass, PlaybackState.IDLE)
3743        leader = players["speaker_leader"]
3744        bridge_leader = players["bridge_leader"]
3745
3746        calls: list[tuple[str, Any]] = []
3747        leader_set_members = leader.set_members
3748        bridge_set_members = bridge_leader.set_members
3749
3750        async def record_leader_set_members(
3751            player_ids_to_add: list[str] | None = None,
3752            player_ids_to_remove: list[str] | None = None,
3753        ) -> None:
3754            calls.append(("leader_set_members", player_ids_to_remove))
3755            await leader_set_members(player_ids_to_add, player_ids_to_remove)
3756
3757        async def record_bridge_set_members(
3758            player_ids_to_add: list[str] | None = None,
3759            player_ids_to_remove: list[str] | None = None,
3760        ) -> None:
3761            calls.append(("bridge_set_members", player_ids_to_add))
3762            await bridge_set_members(player_ids_to_add, player_ids_to_remove)
3763
3764        async def record_leader_stop() -> None:
3765            calls.append(("leader_stop", None))
3766
3767        leader.set_members = record_leader_set_members  # type: ignore[method-assign]
3768        leader.stop = record_leader_stop  # type: ignore[method-assign]
3769        bridge_leader.set_members = record_bridge_set_members  # type: ignore[method-assign]
3770        controller.cmd_resume = AsyncMock()  # type: ignore[method-assign]
3771
3772        await controller._forward_protocol_set_members(
3773            parent_player=leader,
3774            parent_protocol_player=bridge_leader,
3775            protocol_members_to_add=["bridge_only"],
3776            protocol_members_to_remove=[],
3777        )
3778
3779        # the native member joins the protocol group and is released from the parent,
3780        # but an idle parent has no session to stop and nothing to resume
3781        assert calls == [
3782            ("bridge_set_members", ["bridge_only", "bridge_member"]),
3783            ("leader_set_members", ["speaker_member"]),
3784        ]
3785        assert leader.active_output_protocol == "bridge_leader"
3786        controller.cmd_resume.assert_not_awaited()
3787
3788    async def test_joining_member_protocol_is_active_before_its_stream_starts(
3789        self, mock_mass: MagicMock
3790    ) -> None:
3791        """
3792        A joining member's parent knows which protocol carries its audio before its stream starts.
3793
3794        The member's stream is started from inside set_members, and providers resolve the
3795        member's volume control while starting it. A device that also exposes a higher
3796        priority sibling interface (e.g. a soundbar that is both Chromecast and AirPlay)
3797        would otherwise resolve volume to that sibling and apply its volume semantics to
3798        a stream it does not attenuate.
3799        """
3800        controller = PlayerController(mock_mass)
3801
3802        airplay_provider = MockProvider("airplay", instance_id="airplay", mass=mock_mass)
3803        cast_provider = MockProvider("chromecast", instance_id="chromecast", mass=mock_mass)
3804
3805        leader = MockPlayer(airplay_provider, "leader", "Kitchen")
3806        leader_airplay = MockPlayer(
3807            airplay_provider,
3808            "leader_airplay",
3809            "Kitchen (AirPlay)",
3810            player_type=PlayerType.PROTOCOL,
3811        )
3812        leader_airplay._attr_supported_features.add(PlayerFeature.SET_MEMBERS)
3813        leader_airplay.set_protocol_parent_id("leader")
3814
3815        # the joining device exposes both a Chromecast and an AirPlay interface, and has no
3816        # native volume of its own, so its volume control is one of the two protocol players
3817        joiner = MockPlayer(cast_provider, "joiner", "Living Room")
3818        joiner._attr_supported_features = set()
3819        joiner_cast = MockPlayer(
3820            cast_provider,
3821            "joiner_cast",
3822            "Living Room (Cast)",
3823            player_type=PlayerType.PROTOCOL,
3824        )
3825        joiner_cast.set_protocol_parent_id("joiner")
3826        joiner_airplay = MockPlayer(
3827            airplay_provider,
3828            "joiner_airplay",
3829            "Living Room (AirPlay)",
3830            player_type=PlayerType.PROTOCOL,
3831        )
3832        joiner_airplay._attr_supported_features.add(PlayerFeature.SET_MEMBERS)
3833        joiner_airplay.set_protocol_parent_id("joiner")
3834
3835        leader.set_linked_output_protocols(
3836            [
3837                LinkedOutputProtocol(
3838                    output_protocol_id="leader_airplay",
3839                    protocol_domain="airplay",
3840                    priority=10,
3841                )
3842            ]
3843        )
3844        joiner.set_linked_output_protocols(
3845            [
3846                LinkedOutputProtocol(
3847                    output_protocol_id="joiner_cast",
3848                    protocol_domain="chromecast",
3849                    priority=10,
3850                ),
3851                LinkedOutputProtocol(
3852                    output_protocol_id="joiner_airplay",
3853                    protocol_domain="airplay",
3854                    priority=20,
3855                ),
3856            ]
3857        )
3858
3859        mock_mass.players = controller
3860        controller._players = {
3861            player.player_id: player
3862            for player in (leader, leader_airplay, joiner, joiner_cast, joiner_airplay)
3863        }
3864        for player in controller._players.values():
3865            player.update_state(signal_event=False)
3866
3867        # While the device sits idle its Chromecast interface owns the volume: the winner is
3868        # decided by the hardcoded per-domain control priority, not by the link priorities.
3869        assert joiner.volume_control == "joiner_cast"
3870
3871        observed_volume_controls: list[str] = []
3872
3873        async def record_set_members(
3874            player_ids_to_add: list[str] | None = None,  # noqa: ARG001
3875            player_ids_to_remove: list[str] | None = None,  # noqa: ARG001
3876        ) -> None:
3877            observed_volume_controls.append(joiner.volume_control)
3878
3879        leader_airplay.set_members = record_set_members  # type: ignore[method-assign]
3880        controller._handle_set_members = AsyncMock()  # type: ignore[method-assign]
3881
3882        await controller._forward_protocol_set_members(
3883            parent_player=leader,
3884            parent_protocol_player=leader_airplay,
3885            protocol_members_to_add=["joiner_airplay"],
3886            protocol_members_to_remove=[],
3887        )
3888
3889        assert observed_volume_controls == ["joiner_airplay"]
3890
3891
3892class TestNativeProtocolPlayerGrouping:
3893    """Tests for grouping with native protocol players (e.g., native AirPlay like Apple TV)."""
3894
3895    def test_native_airplay_groups_with_protocol_linked_player(self, mock_mass: MagicMock) -> None:
3896        """
3897        Test grouping a native AirPlay player (Apple TV) with a protocol-linked player (Sonos).
3898
3899        This tests the scenario where:
3900        - Apple TV is a native AirPlay PLAYER (not PROTOCOL type)
3901        - Sonos has AirPlay as a linked protocol
3902        - Apple TV groups with Sonos via the common AirPlay protocol
3903        """
3904        controller = PlayerController(mock_mass)
3905
3906        airplay_provider = MockProvider("airplay", instance_id="airplay", mass=mock_mass)
3907        sonos_provider = MockProvider("sonos", instance_id="sonos", mass=mock_mass)
3908
3909        # Apple TV: native AirPlay PLAYER (supports grouping via AirPlay)
3910        apple_tv = MockPlayer(airplay_provider, "apple_tv_1", "Apple TV Slaapkamer")
3911        apple_tv._attr_supported_features.add(PlayerFeature.PLAY_MEDIA)
3912        apple_tv._attr_supported_features.add(PlayerFeature.SET_MEMBERS)
3913        apple_tv._attr_can_group_with = {"airplay"}  # Provider instance ID
3914        apple_tv._cache.clear()
3915
3916        # Sonos native player (visible)
3917        sonos_player = MockPlayer(sonos_provider, "sonos_badkamer", "Badkamer")
3918        sonos_player._attr_supported_features.add(PlayerFeature.PLAY_MEDIA)
3919        sonos_player._attr_supported_features.add(PlayerFeature.SET_MEMBERS)
3920        sonos_player._cache.clear()
3921
3922        # AirPlay protocol player for Sonos (hidden, linked to sonos)
3923        sonos_airplay = MockPlayer(
3924            airplay_provider,
3925            "airplay_sonos",
3926            "Badkamer (AirPlay)",
3927            player_type=PlayerType.PROTOCOL,
3928        )
3929        sonos_airplay._attr_supported_features.add(PlayerFeature.SET_MEMBERS)
3930        sonos_airplay._attr_can_group_with = {"airplay"}
3931        sonos_airplay._cache.clear()
3932        sonos_airplay.set_protocol_parent_id("sonos_badkamer")
3933
3934        sonos_player.set_linked_output_protocols(
3935            [
3936                LinkedOutputProtocol(
3937                    output_protocol_id="airplay_sonos",
3938                    protocol_domain="airplay",
3939                    priority=10,
3940                )
3941            ]
3942        )
3943
3944        mock_mass.players = controller
3945        controller._players = {
3946            "apple_tv_1": apple_tv,
3947            "sonos_badkamer": sonos_player,
3948            "airplay_sonos": sonos_airplay,
3949        }
3950
3951        # Update states
3952        sonos_airplay.update_state(signal_event=False)
3953        sonos_player.update_state(signal_event=False)
3954        apple_tv.update_state(signal_event=False)
3955
3956        # Translate members for grouping Sonos to Apple TV
3957        protocol_members, _native_members, protocol_player, protocol_domain = (
3958            controller._translate_members_for_protocols(
3959                parent_player=apple_tv,
3960                player_ids=["sonos_badkamer"],
3961                parent_protocol_player=None,
3962                parent_protocol_domain=None,
3963            )
3964        )
3965
3966        # Should find common AirPlay protocol
3967        assert len(protocol_members) == 1
3968        assert "airplay_sonos" in protocol_members
3969        assert protocol_domain == "airplay"
3970        # For native AirPlay player, protocol_player should be the Apple TV itself
3971        assert protocol_player == apple_tv
3972
3973    def test_get_output_protocol_by_domain_finds_native(self, mock_mass: MagicMock) -> None:
3974        """Test that get_output_protocol_by_domain finds native protocol."""
3975        controller = PlayerController(mock_mass)
3976
3977        airplay_provider = MockProvider("airplay", instance_id="airplay", mass=mock_mass)
3978
3979        # Native AirPlay player
3980        apple_tv = MockPlayer(airplay_provider, "apple_tv_1", "Apple TV")
3981        apple_tv._attr_supported_features.add(PlayerFeature.PLAY_MEDIA)
3982        apple_tv._cache.clear()
3983
3984        mock_mass.players = controller
3985        controller._players = {"apple_tv_1": apple_tv}
3986
3987        apple_tv.update_state(signal_event=False)
3988
3989        # Should find native AirPlay protocol
3990        protocol = apple_tv.get_output_protocol_by_domain("airplay")
3991        assert protocol is not None
3992        assert protocol.output_protocol_id == "native"
3993        assert protocol.protocol_domain == "airplay"
3994        assert protocol.is_native is True
3995
3996
3997class TestFinalGroupMembersTranslation:
3998    """Tests for __final_group_members translation of protocol player IDs."""
3999
4000    def test_final_group_members_translates_protocol_ids(self, mock_mass: MagicMock) -> None:
4001        """
4002        Test that __final_group_members translates protocol player IDs to visible IDs.
4003
4004        When a native AirPlay player (Apple TV) has protocol players in its group_members,
4005        the final state should show the visible parent player IDs instead.
4006        """
4007        controller = PlayerController(mock_mass)
4008
4009        airplay_provider = MockProvider("airplay", instance_id="airplay", mass=mock_mass)
4010        sonos_provider = MockProvider("sonos", instance_id="sonos", mass=mock_mass)
4011
4012        # Apple TV with group members containing a protocol player ID
4013        apple_tv = MockPlayer(airplay_provider, "apple_tv_1", "Apple TV")
4014        apple_tv._attr_supported_features.add(PlayerFeature.PLAY_MEDIA)
4015        apple_tv._attr_supported_features.add(PlayerFeature.SET_MEMBERS)
4016        apple_tv._attr_group_members = ["apple_tv_1", "airplay_sonos"]
4017        apple_tv._cache.clear()
4018
4019        # Sonos visible player
4020        sonos_player = MockPlayer(sonos_provider, "sonos_1", "Sonos")
4021        sonos_player._attr_supported_features.add(PlayerFeature.PLAY_MEDIA)
4022        sonos_player._cache.clear()
4023
4024        # AirPlay protocol player for Sonos
4025        sonos_airplay = MockPlayer(
4026            airplay_provider,
4027            "airplay_sonos",
4028            "Sonos (AirPlay)",
4029            player_type=PlayerType.PROTOCOL,
4030        )
4031        sonos_airplay._cache.clear()
4032        sonos_airplay.set_protocol_parent_id("sonos_1")
4033
4034        mock_mass.players = controller
4035        controller._players = {
4036            "apple_tv_1": apple_tv,
4037            "sonos_1": sonos_player,
4038            "airplay_sonos": sonos_airplay,
4039        }
4040
4041        sonos_airplay.update_state(signal_event=False)
4042        sonos_player.update_state(signal_event=False)
4043        apple_tv.update_state(signal_event=False)
4044
4045        # Final group_members should show visible player IDs
4046        final_members = apple_tv.state.group_members
4047        assert "apple_tv_1" in final_members
4048        assert "sonos_1" in final_members
4049        # Protocol player ID should NOT appear in final state
4050        assert "airplay_sonos" not in final_members
4051
4052
4053class TestFinalActiveGroupTranslation:
4054    """Tests for __final_active_group with translated protocol player IDs."""
4055
4056    def test_active_group_uses_translated_group_members(self, mock_mass: MagicMock) -> None:
4057        """Test that visible players resolve active_group from translated group members."""
4058        controller = PlayerController(mock_mass)
4059
4060        group_provider = MockProvider(
4061            "universal_group", instance_id="universal_group", mass=mock_mass
4062        )
4063        airplay_provider = MockProvider("airplay", instance_id="airplay", mass=mock_mass)
4064        sonos_provider = MockProvider("sonos", instance_id="sonos", mass=mock_mass)
4065
4066        group_player = MockPlayer(
4067            group_provider,
4068            "group_1",
4069            "House Group",
4070            player_type=PlayerType.GROUP,
4071        )
4072        group_player._attr_playback_state = PlaybackState.PLAYING
4073        group_player._attr_group_members = ["airplay_sonos"]
4074        group_player._cache.clear()
4075
4076        sonos_player = MockPlayer(sonos_provider, "sonos_1", "Sonos")
4077        sonos_player._attr_supported_features.add(PlayerFeature.PLAY_MEDIA)
4078        sonos_player._cache.clear()
4079
4080        sonos_airplay = MockPlayer(
4081            airplay_provider,
4082            "airplay_sonos",
4083            "Sonos (AirPlay)",
4084            player_type=PlayerType.PROTOCOL,
4085        )
4086        sonos_airplay.set_protocol_parent_id("sonos_1")
4087        sonos_airplay._cache.clear()
4088
4089        mock_mass.players = controller
4090        controller._players = {
4091            "group_1": group_player,
4092            "sonos_1": sonos_player,
4093            "airplay_sonos": sonos_airplay,
4094        }
4095
4096        group_player.set_initialized()
4097        sonos_player.set_initialized()
4098        sonos_airplay.set_initialized()
4099
4100        group_player.update_state(signal_event=False)
4101        sonos_airplay.update_state(signal_event=False)
4102        sonos_player.update_state(signal_event=False)
4103
4104        assert sonos_player.state.active_group == "group_1"
4105        assert sonos_airplay.state.active_group is None
4106
4107
4108class TestFinalSyncedToWithNativeProtocolParent:
4109    """Tests for __final_synced_to when sync parent is a native protocol player."""
4110
4111    def test_synced_to_native_airplay_player(self, mock_mass: MagicMock) -> None:
4112        """
4113        Test that synced_to correctly shows native AirPlay player as parent.
4114
4115        When a Sonos player's AirPlay protocol player is synced to a native AirPlay
4116        player (Apple TV), the Sonos's final synced_to should show the Apple TV.
4117        """
4118        controller = PlayerController(mock_mass)
4119
4120        airplay_provider = MockProvider("airplay", instance_id="airplay", mass=mock_mass)
4121        sonos_provider = MockProvider("sonos", instance_id="sonos", mass=mock_mass)
4122
4123        # Apple TV: native AirPlay PLAYER (the group leader)
4124        apple_tv = MockPlayer(airplay_provider, "apple_tv_1", "Apple TV")
4125        apple_tv._attr_supported_features.add(PlayerFeature.PLAY_MEDIA)
4126        apple_tv._attr_supported_features.add(PlayerFeature.SET_MEMBERS)
4127        apple_tv._attr_group_members = ["apple_tv_1", "airplay_sonos"]
4128        apple_tv._cache.clear()
4129
4130        # Sonos visible player
4131        sonos_player = MockPlayer(sonos_provider, "sonos_1", "Sonos")
4132        sonos_player._attr_supported_features.add(PlayerFeature.PLAY_MEDIA)
4133        sonos_player._cache.clear()
4134
4135        # AirPlay protocol player for Sonos - synced to Apple TV
4136        sonos_airplay = MockPlayer(
4137            airplay_provider,
4138            "airplay_sonos",
4139            "Sonos (AirPlay)",
4140            player_type=PlayerType.PROTOCOL,
4141        )
4142        # Set group_members with Apple TV first to indicate synced_to Apple TV
4143        sonos_airplay._attr_group_members = ["apple_tv_1", "airplay_sonos"]
4144        sonos_airplay._cache.clear()
4145        sonos_airplay.set_protocol_parent_id("sonos_1")
4146
4147        sonos_player.set_linked_output_protocols(
4148            [
4149                LinkedOutputProtocol(
4150                    output_protocol_id="airplay_sonos",
4151                    protocol_domain="airplay",
4152                    priority=10,
4153                )
4154            ]
4155        )
4156
4157        mock_mass.players = controller
4158        controller._players = {
4159            "apple_tv_1": apple_tv,
4160            "sonos_1": sonos_player,
4161            "airplay_sonos": sonos_airplay,
4162        }
4163
4164        apple_tv.set_initialized()
4165        sonos_player.set_initialized()
4166        sonos_airplay.set_initialized()
4167
4168        apple_tv.update_state(signal_event=False)
4169        sonos_airplay.update_state(signal_event=False)
4170        sonos_player.update_state(signal_event=False)
4171
4172        # Sonos's final synced_to should be Apple TV (visible player)
4173        assert sonos_player.state.synced_to == "apple_tv_1"
4174
4175
4176class TestUngroupTranslation:
4177    """Tests for translation when ungrouping from native protocol players."""
4178
4179    def test_ungroup_translates_visible_to_protocol_id(self, mock_mass: MagicMock) -> None:
4180        """
4181        Test that ungrouping correctly translates visible ID to protocol ID.
4182
4183        When ungrouping Sonos from Apple TV, the visible Sonos ID should be
4184        translated to its AirPlay protocol player ID for the removal.
4185        """
4186        controller = PlayerController(mock_mass)
4187
4188        airplay_provider = MockProvider("airplay", instance_id="airplay", mass=mock_mass)
4189        sonos_provider = MockProvider("sonos", instance_id="sonos", mass=mock_mass)
4190
4191        # Apple TV with Sonos's AirPlay protocol player in group_members
4192        apple_tv = MockPlayer(airplay_provider, "apple_tv_1", "Apple TV")
4193        apple_tv._attr_supported_features.add(PlayerFeature.PLAY_MEDIA)
4194        apple_tv._attr_supported_features.add(PlayerFeature.SET_MEMBERS)
4195        apple_tv._attr_group_members = ["apple_tv_1", "airplay_sonos"]
4196        apple_tv._cache.clear()
4197
4198        # Sonos visible player
4199        sonos_player = MockPlayer(sonos_provider, "sonos_1", "Sonos")
4200        sonos_player._attr_supported_features.add(PlayerFeature.PLAY_MEDIA)
4201        sonos_player._cache.clear()
4202
4203        # AirPlay protocol player for Sonos
4204        sonos_airplay = MockPlayer(
4205            airplay_provider,
4206            "airplay_sonos",
4207            "Sonos (AirPlay)",
4208            player_type=PlayerType.PROTOCOL,
4209        )
4210        sonos_airplay._cache.clear()
4211        sonos_airplay.set_protocol_parent_id("sonos_1")
4212
4213        sonos_player.set_linked_output_protocols(
4214            [
4215                LinkedOutputProtocol(
4216                    output_protocol_id="airplay_sonos",
4217                    protocol_domain="airplay",
4218                    priority=10,
4219                )
4220            ]
4221        )
4222
4223        mock_mass.players = controller
4224        controller._players = {
4225            "apple_tv_1": apple_tv,
4226            "sonos_1": sonos_player,
4227            "airplay_sonos": sonos_airplay,
4228        }
4229
4230        sonos_airplay.update_state(signal_event=False)
4231        sonos_player.update_state(signal_event=False)
4232        apple_tv.update_state(signal_event=False)
4233
4234        # Translate members for removal - visible ID should become protocol ID
4235        _protocol_members, native_members = controller._translate_members_to_remove_for_protocols(
4236            parent_player=apple_tv,
4237            player_ids=["sonos_1"],  # Visible player ID
4238            parent_protocol_player=None,
4239            parent_protocol_domain=None,
4240        )
4241
4242        # Should translate to the protocol player ID for native removal
4243        assert "airplay_sonos" in native_members
4244        assert "sonos_1" not in native_members
4245
4246
4247class TestNativeProtocolDomainPlayerGrouping:
4248    """
4249    Tests for grouping with native protocol-domain players.
4250
4251    This tests the scenario where a player's native provider domain IS the protocol
4252    domain (e.g., a sendspin web player with PlayerType.PLAYER and provider.domain="sendspin"),
4253    rather than having the protocol as a linked protocol player.
4254    """
4255
4256    def test_native_protocol_player_groups_via_active_protocol(self, mock_mass: MagicMock) -> None:
4257        """
4258        Test grouping a native protocol player via the parent's active protocol.
4259
4260        Scenario: Kantoor (has sendspin linked protocol, active) groups with
4261        Web player (native sendspin PlayerType.PLAYER).
4262        This should use Priority 2 (parent's active protocol).
4263        """
4264        controller = PlayerController(mock_mass)
4265
4266        sonos_provider = MockProvider("sonos", instance_id="sonos", mass=mock_mass)
4267        sendspin_provider = MockProvider("sendspin", instance_id="sendspin", mass=mock_mass)
4268
4269        # Kantoor: native Sonos player with sendspin as linked protocol
4270        kantoor = MockPlayer(sonos_provider, "sonos_kantoor", "Kantoor")
4271        kantoor._attr_supported_features.add(PlayerFeature.PLAY_MEDIA)
4272        kantoor._cache.clear()
4273
4274        # Sendspin protocol player linked to Kantoor
4275        sendspin_kantoor = MockPlayer(
4276            sendspin_provider,
4277            "sendspin_kantoor",
4278            "Kantoor (Sendspin)",
4279            player_type=PlayerType.PROTOCOL,
4280        )
4281        sendspin_kantoor._attr_supported_features.add(PlayerFeature.SET_MEMBERS)
4282        sendspin_kantoor._attr_can_group_with = {"sendspin"}
4283        sendspin_kantoor._cache.clear()
4284        sendspin_kantoor.set_protocol_parent_id("sonos_kantoor")
4285
4286        # Web player: native sendspin player (PlayerType.PLAYER, standalone)
4287        web_player = MockPlayer(sendspin_provider, "sendspin_web", "Web (Chrome on Mac)")
4288        web_player._attr_supported_features.add(PlayerFeature.PLAY_MEDIA)
4289        web_player._cache.clear()
4290
4291        # Link sendspin protocol to Kantoor
4292        kantoor.set_linked_output_protocols(
4293            [
4294                LinkedOutputProtocol(
4295                    output_protocol_id="sendspin_kantoor",
4296                    protocol_domain="sendspin",
4297                    priority=40,
4298                )
4299            ]
4300        )
4301
4302        mock_mass.players = controller
4303        controller._players = {
4304            "sonos_kantoor": kantoor,
4305            "sendspin_kantoor": sendspin_kantoor,
4306            "sendspin_web": web_player,
4307        }
4308
4309        # Update states
4310        sendspin_kantoor.update_state(signal_event=False)
4311        kantoor.update_state(signal_event=False)
4312        web_player.update_state(signal_event=False)
4313
4314        # Group Web player with Kantoor, with sendspin as active protocol
4315        protocol_members, native_members, protocol_player, _ = (
4316            controller._translate_members_for_protocols(
4317                parent_player=kantoor,
4318                player_ids=["sendspin_web"],
4319                parent_protocol_player=sendspin_kantoor,
4320                parent_protocol_domain="sendspin",
4321            )
4322        )
4323
4324        # Web player's own player_id should be in protocol_members
4325        assert len(protocol_members) == 1
4326        assert "sendspin_web" in protocol_members
4327        assert len(native_members) == 0
4328        assert protocol_player == sendspin_kantoor
4329
4330    def test_native_protocol_player_groups_via_common_protocol(self, mock_mass: MagicMock) -> None:
4331        """
4332        Test grouping a native protocol player via common protocol search (Priority 4).
4333
4334        Same scenario but without a pre-set active protocol — the common protocol
4335        search should find sendspin as the shared protocol.
4336        """
4337        controller = PlayerController(mock_mass)
4338
4339        sonos_provider = MockProvider("sonos", instance_id="sonos", mass=mock_mass)
4340        sendspin_provider = MockProvider("sendspin", instance_id="sendspin", mass=mock_mass)
4341
4342        # Kantoor: native Sonos player with sendspin as linked protocol
4343        kantoor = MockPlayer(sonos_provider, "sonos_kantoor", "Kantoor")
4344        kantoor._attr_supported_features.add(PlayerFeature.PLAY_MEDIA)
4345        kantoor._cache.clear()
4346
4347        # Sendspin protocol player linked to Kantoor
4348        sendspin_kantoor = MockPlayer(
4349            sendspin_provider,
4350            "sendspin_kantoor",
4351            "Kantoor (Sendspin)",
4352            player_type=PlayerType.PROTOCOL,
4353        )
4354        sendspin_kantoor._attr_supported_features.add(PlayerFeature.SET_MEMBERS)
4355        sendspin_kantoor._attr_can_group_with = {"sendspin"}
4356        sendspin_kantoor._cache.clear()
4357        sendspin_kantoor.set_protocol_parent_id("sonos_kantoor")
4358
4359        # Web player: native sendspin player (PlayerType.PLAYER, standalone)
4360        web_player = MockPlayer(sendspin_provider, "sendspin_web", "Web (Chrome on Mac)")
4361        web_player._attr_supported_features.add(PlayerFeature.PLAY_MEDIA)
4362        web_player._cache.clear()
4363
4364        # Link sendspin protocol to Kantoor
4365        kantoor.set_linked_output_protocols(
4366            [
4367                LinkedOutputProtocol(
4368                    output_protocol_id="sendspin_kantoor",
4369                    protocol_domain="sendspin",
4370                    priority=40,
4371                )
4372            ]
4373        )
4374
4375        mock_mass.players = controller
4376        controller._players = {
4377            "sonos_kantoor": kantoor,
4378            "sendspin_kantoor": sendspin_kantoor,
4379            "sendspin_web": web_player,
4380        }
4381
4382        # Update states
4383        sendspin_kantoor.update_state(signal_event=False)
4384        kantoor.update_state(signal_event=False)
4385        web_player.update_state(signal_event=False)
4386
4387        # Group Web player with Kantoor, without pre-set active protocol
4388        protocol_members, native_members, protocol_player, protocol_domain = (
4389            controller._translate_members_for_protocols(
4390                parent_player=kantoor,
4391                player_ids=["sendspin_web"],
4392                parent_protocol_player=None,
4393                parent_protocol_domain=None,
4394            )
4395        )
4396
4397        # Should find common sendspin protocol via Priority 4
4398        assert len(protocol_members) == 1
4399        assert "sendspin_web" in protocol_members
4400        assert len(native_members) == 0
4401        assert protocol_domain == "sendspin"
4402        assert protocol_player == sendspin_kantoor
4403
4404    def test_ungroup_native_protocol_player(self, mock_mass: MagicMock) -> None:
4405        """
4406        Test ungrouping a native protocol player from a protocol-linked parent.
4407
4408        When ungrouping a native sendspin web player from Kantoor's sendspin group,
4409        the web player's own player_id should be used for removal.
4410        """
4411        controller = PlayerController(mock_mass)
4412
4413        sonos_provider = MockProvider("sonos", instance_id="sonos", mass=mock_mass)
4414        sendspin_provider = MockProvider("sendspin", instance_id="sendspin", mass=mock_mass)
4415
4416        # Kantoor with sendspin linked protocol
4417        kantoor = MockPlayer(sonos_provider, "sonos_kantoor", "Kantoor")
4418        kantoor._attr_supported_features.add(PlayerFeature.PLAY_MEDIA)
4419        kantoor._cache.clear()
4420
4421        # Sendspin protocol player linked to Kantoor, with web player in group
4422        sendspin_kantoor = MockPlayer(
4423            sendspin_provider,
4424            "sendspin_kantoor",
4425            "Kantoor (Sendspin)",
4426            player_type=PlayerType.PROTOCOL,
4427        )
4428        sendspin_kantoor._attr_supported_features.add(PlayerFeature.SET_MEMBERS)
4429        sendspin_kantoor._attr_group_members = ["sendspin_kantoor", "sendspin_web"]
4430        sendspin_kantoor._cache.clear()
4431        sendspin_kantoor.set_protocol_parent_id("sonos_kantoor")
4432
4433        # Web player: native sendspin player
4434        web_player = MockPlayer(sendspin_provider, "sendspin_web", "Web (Chrome on Mac)")
4435        web_player._attr_supported_features.add(PlayerFeature.PLAY_MEDIA)
4436        web_player._cache.clear()
4437
4438        kantoor.set_linked_output_protocols(
4439            [
4440                LinkedOutputProtocol(
4441                    output_protocol_id="sendspin_kantoor",
4442                    protocol_domain="sendspin",
4443                    priority=40,
4444                )
4445            ]
4446        )
4447
4448        mock_mass.players = controller
4449        controller._players = {
4450            "sonos_kantoor": kantoor,
4451            "sendspin_kantoor": sendspin_kantoor,
4452            "sendspin_web": web_player,
4453        }
4454
4455        sendspin_kantoor.update_state(signal_event=False)
4456        kantoor.update_state(signal_event=False)
4457        web_player.update_state(signal_event=False)
4458
4459        # Translate removal — web player's own player_id should be used
4460        protocol_members, native_members = controller._translate_members_to_remove_for_protocols(
4461            parent_player=kantoor,
4462            player_ids=["sendspin_web"],
4463            parent_protocol_player=sendspin_kantoor,
4464            parent_protocol_domain="sendspin",
4465        )
4466
4467        # Web player's player_id should be in protocol removal list
4468        assert "sendspin_web" in protocol_members
4469        assert len(native_members) == 0
4470
4471    def test_filter_protocol_members_accepts_native_protocol_player(
4472        self, mock_mass: MagicMock
4473    ) -> None:
4474        """
4475        Test that _filter_protocol_members accepts native protocol-domain players.
4476
4477        A PlayerType.PLAYER with matching provider domain should pass through the
4478        filter, not just PlayerType.PROTOCOL players.
4479        """
4480        controller = PlayerController(mock_mass)
4481
4482        sendspin_provider = MockProvider("sendspin", instance_id="sendspin", mass=mock_mass)
4483
4484        # Protocol player (the parent's linked sendspin)
4485        sendspin_parent = MockPlayer(
4486            sendspin_provider,
4487            "sendspin_parent",
4488            "Parent (Sendspin)",
4489            player_type=PlayerType.PROTOCOL,
4490        )
4491        sendspin_parent._attr_supported_features.add(PlayerFeature.SET_MEMBERS)
4492        sendspin_parent._cache.clear()
4493
4494        # Native sendspin web player (PlayerType.PLAYER)
4495        web_player = MockPlayer(sendspin_provider, "sendspin_web", "Web (Chrome on Mac)")
4496        web_player._attr_supported_features.add(PlayerFeature.PLAY_MEDIA)
4497        web_player._cache.clear()
4498
4499        # Another protocol type sendspin player
4500        sendspin_other = MockPlayer(
4501            sendspin_provider,
4502            "sendspin_other",
4503            "Other (Sendspin)",
4504            player_type=PlayerType.PROTOCOL,
4505        )
4506        sendspin_other._cache.clear()
4507
4508        mock_mass.players = controller
4509        controller._players = {
4510            "sendspin_parent": sendspin_parent,
4511            "sendspin_web": web_player,
4512            "sendspin_other": sendspin_other,
4513        }
4514
4515        sendspin_parent.update_state(signal_event=False)
4516        web_player.update_state(signal_event=False)
4517        sendspin_other.update_state(signal_event=False)
4518
4519        # Both native and protocol players should pass through the filter
4520        filtered = controller._filter_protocol_members(
4521            ["sendspin_web", "sendspin_other"],
4522            sendspin_parent,
4523        )
4524
4525        assert "sendspin_web" in filtered
4526        assert "sendspin_other" in filtered
4527        assert len(filtered) == 2
4528
4529    def test_sendspin_visualizer_groups_via_common_protocol(self, mock_mass: MagicMock) -> None:
4530        """
4531        Test grouping a sendspin visualizer (e.g. Hue light) with a sendspin-bridged parent.
4532
4533        Visualizer players have no PLAY_MEDIA so they are not native players, but the
4534        Sendspin provider advertises a self-referential sendspin output protocol on them
4535        (is_native=True) so the common-protocol search (Priority 4) can match them against
4536        any parent that has a sendspin bridge linked.
4537        """
4538        controller = PlayerController(mock_mass)
4539
4540        sonos_provider = MockProvider("sonos", instance_id="sonos", mass=mock_mass)
4541        sendspin_provider = MockProvider("sendspin", instance_id="sendspin", mass=mock_mass)
4542
4543        # Mancave: native Sonos parent with sendspin bridge linked
4544        mancave = MockPlayer(sonos_provider, "sonos_mancave", "Mancave")
4545        mancave._attr_supported_features.add(PlayerFeature.PLAY_MEDIA)
4546        mancave._cache.clear()
4547
4548        # Sendspin bridge protocol player linked to Mancave
4549        sendspin_bridge = MockPlayer(
4550            sendspin_provider,
4551            "spb_mancave",
4552            "Mancave (AirPlay)",
4553            player_type=PlayerType.PROTOCOL,
4554        )
4555        sendspin_bridge._attr_supported_features.add(PlayerFeature.SET_MEMBERS)
4556        sendspin_bridge._attr_can_group_with = {"sendspin"}
4557        sendspin_bridge._cache.clear()
4558        sendspin_bridge.set_protocol_parent_id("sonos_mancave")
4559
4560        # Hue visualizer: sendspin provider, no PLAY_MEDIA, has SET_MEMBERS, type LIGHT
4561        hue_light = MockPlayer(
4562            sendspin_provider,
4563            "hue-mancave",
4564            "Hue: Mancave",
4565            player_type=PlayerType.LIGHT,
4566        )
4567        hue_light._attr_supported_features = {PlayerFeature.SET_MEMBERS}
4568        hue_light._cache.clear()
4569
4570        mancave.set_linked_output_protocols(
4571            [
4572                LinkedOutputProtocol(
4573                    output_protocol_id="spb_mancave",
4574                    protocol_domain="sendspin",
4575                    priority=40,
4576                )
4577            ]
4578        )
4579
4580        mock_mass.players = controller
4581        controller._players = {
4582            "sonos_mancave": mancave,
4583            "spb_mancave": sendspin_bridge,
4584            "hue-mancave": hue_light,
4585        }
4586
4587        sendspin_bridge.update_state(signal_event=False)
4588        mancave.update_state(signal_event=False)
4589        hue_light.update_state(signal_event=False)
4590
4591        protocol_members, native_members, protocol_player, protocol_domain = (
4592            controller._translate_members_for_protocols(
4593                parent_player=mancave,
4594                player_ids=["hue-mancave"],
4595                parent_protocol_player=None,
4596                parent_protocol_domain=None,
4597            )
4598        )
4599
4600        # Priority 4 should match sendspin↔sendspin and route via the bridge,
4601        # using the visualizer's player_id directly (is_native=True).
4602        assert protocol_members == ["hue-mancave"]
4603        assert native_members == []
4604        assert protocol_domain == "sendspin"
4605        assert protocol_player == sendspin_bridge
4606
4607
4608class TestEnrichPlayerIdentifiers:
4609    """Tests for MAC address enrichment via ARP lookup."""
4610
4611    @pytest.mark.asyncio
4612    async def test_no_ip_skips_enrichment(self, mock_mass: MagicMock) -> None:
4613        """Test that enrichment is skipped when no IP address is available."""
4614        provider = MockProvider("sendspin", mass=mock_mass)
4615        # Sendspin bridge player has MAC but no IP
4616        player = MockPlayer(
4617            provider,
4618            "spb_62e5974593d3",
4619            "Apple TV (Sendspin)",
4620            player_type=PlayerType.PROTOCOL,
4621            identifiers={IdentifierType.MAC_ADDRESS: "62:E5:97:45:93:D3"},
4622        )
4623
4624        await enrich_device_mac_address(player.device_info)
4625
4626        # MAC should remain unchanged (no IP = no ARP lookup)
4627        assert player.device_info.identifiers[IdentifierType.MAC_ADDRESS] == "62:E5:97:45:93:D3"
4628
4629    @pytest.mark.asyncio
4630    async def test_valid_hardware_mac_skips_enrichment(self, mock_mass: MagicMock) -> None:
4631        """Test that a valid, non-locally-administered MAC skips enrichment."""
4632        provider = MockProvider("sonos", mass=mock_mass)
4633        player = MockPlayer(
4634            provider,
4635            "sonos_123",
4636            "Sonos Speaker",
4637            player_type=PlayerType.PLAYER,
4638            identifiers={
4639                IdentifierType.MAC_ADDRESS: "54:78:C9:E6:0D:A0",
4640                IdentifierType.IP_ADDRESS: "192.168.1.100",
4641            },
4642        )
4643
4644        await enrich_device_mac_address(player.device_info)
4645
4646        # MAC should remain unchanged (valid hardware MAC, not locally administered)
4647        assert player.device_info.identifiers[IdentifierType.MAC_ADDRESS] == "54:78:C9:E6:0D:A0"
4648
4649    @pytest.mark.asyncio
4650    async def test_locally_administered_bit_difference_replaces_mac(
4651        self, mock_mass: MagicMock
4652    ) -> None:
4653        """
4654        Test that ARP MAC replaces reported MAC when only the locally-administered bit differs.
4655
4656        Example: AirPlay reports 56:78:C9:E6:0D:A0, ARP resolves 54:78:C9:E6:0D:A0.
4657        These differ only in bit 1 of the first octet (locally-administered bit).
4658        """
4659        provider = MockProvider("airplay", mass=mock_mass)
4660        player = MockPlayer(
4661            provider,
4662            "ap_5678c9e60da0",
4663            "WiiM Pro (AirPlay)",
4664            player_type=PlayerType.PROTOCOL,
4665            identifiers={
4666                IdentifierType.MAC_ADDRESS: "56:78:C9:E6:0D:A0",
4667                IdentifierType.IP_ADDRESS: "192.168.1.50",
4668            },
4669        )
4670
4671        with patch(
4672            "music_assistant.helpers.util.resolve_real_mac_address",
4673            new_callable=AsyncMock,
4674            return_value="54:78:C9:E6:0D:A0",
4675        ):
4676            await enrich_device_mac_address(player.device_info)
4677
4678        # MAC should be replaced with the ARP-resolved hardware MAC
4679        assert player.device_info.identifiers[IdentifierType.MAC_ADDRESS] == "54:78:C9:E6:0D:A0"
4680
4681    @pytest.mark.asyncio
4682    async def test_completely_different_mac_replaced_by_arp(self, mock_mass: MagicMock) -> None:
4683        """
4684        Test that ARP result always replaces a locally-administered MAC.
4685
4686        Even when the ARP MAC is completely different from the reported MAC
4687        (e.g., Apple devices with random private MACs), the ARP result is used
4688        because it's the hardware truth that reliably unifies protocols.
4689        Sendspin bridges match via AIRPLAY_ID/CAST_UUID, not MAC.
4690        """
4691        provider = MockProvider("airplay", mass=mock_mass)
4692        player = MockPlayer(
4693            provider,
4694            "ap62e5974593d3",
4695            "Apple TV (AirPlay)",
4696            player_type=PlayerType.PLAYER,
4697            identifiers={
4698                IdentifierType.MAC_ADDRESS: "62:E5:97:45:93:D3",
4699                IdentifierType.IP_ADDRESS: "192.168.1.200",
4700            },
4701        )
4702
4703        with patch(
4704            "music_assistant.helpers.util.resolve_real_mac_address",
4705            new_callable=AsyncMock,
4706            return_value="C0:95:6D:51:34:E0",
4707        ):
4708            await enrich_device_mac_address(player.device_info)
4709
4710        # MAC should be replaced with ARP result (hardware truth)
4711        assert player.device_info.identifiers[IdentifierType.MAC_ADDRESS] == "C0:95:6D:51:34:E0"
4712
4713    @pytest.mark.asyncio
4714    async def test_no_reported_mac_uses_arp_result(self, mock_mass: MagicMock) -> None:
4715        """Test that ARP MAC is used when no MAC was reported at all."""
4716        provider = MockProvider("chromecast", mass=mock_mass)
4717        player = MockPlayer(
4718            provider,
4719            "cc_123",
4720            "Chromecast",
4721            player_type=PlayerType.PROTOCOL,
4722            identifiers={IdentifierType.IP_ADDRESS: "192.168.1.75"},
4723        )
4724
4725        with patch(
4726            "music_assistant.helpers.util.resolve_real_mac_address",
4727            new_callable=AsyncMock,
4728            return_value="AA:BB:CC:DD:EE:FF",
4729        ):
4730            await enrich_device_mac_address(player.device_info)
4731
4732        # MAC should be set from ARP since there was none before
4733        assert player.device_info.identifiers[IdentifierType.MAC_ADDRESS] == "AA:BB:CC:DD:EE:FF"
4734
4735    @pytest.mark.asyncio
4736    async def test_invalid_mac_replaced_by_arp(self, mock_mass: MagicMock) -> None:
4737        """Test that an invalid MAC (00:00:00:00:00:00) is replaced by ARP result."""
4738        provider = MockProvider("dlna", mass=mock_mass)
4739        player = MockPlayer(
4740            provider,
4741            "dlna_123",
4742            "DLNA Device",
4743            player_type=PlayerType.PROTOCOL,
4744            identifiers={
4745                IdentifierType.MAC_ADDRESS: "00:00:00:00:00:00",
4746                IdentifierType.IP_ADDRESS: "192.168.1.60",
4747            },
4748        )
4749
4750        with patch(
4751            "music_assistant.helpers.util.resolve_real_mac_address",
4752            new_callable=AsyncMock,
4753            return_value="11:22:33:44:55:66",
4754        ):
4755            await enrich_device_mac_address(player.device_info)
4756
4757        # Invalid MAC should be replaced
4758        assert player.device_info.identifiers[IdentifierType.MAC_ADDRESS] == "11:22:33:44:55:66"
4759
4760    @pytest.mark.asyncio
4761    async def test_arp_returns_none_no_change(self, mock_mass: MagicMock) -> None:
4762        """Test that MAC is unchanged when ARP lookup returns None."""
4763        provider = MockProvider("airplay", mass=mock_mass)
4764        player = MockPlayer(
4765            provider,
4766            "ap_123",
4767            "AirPlay Device",
4768            player_type=PlayerType.PROTOCOL,
4769            identifiers={
4770                IdentifierType.MAC_ADDRESS: "62:E5:97:45:93:D3",
4771                IdentifierType.IP_ADDRESS: "192.168.1.100",
4772            },
4773        )
4774
4775        with patch(
4776            "music_assistant.helpers.util.resolve_real_mac_address",
4777            new_callable=AsyncMock,
4778            return_value=None,
4779        ):
4780            await enrich_device_mac_address(player.device_info)
4781
4782        # MAC should remain unchanged
4783        assert player.device_info.identifiers[IdentifierType.MAC_ADDRESS] == "62:E5:97:45:93:D3"
4784
4785    @pytest.mark.asyncio
4786    async def test_ipv6_mapped_ipv4_normalized(self, mock_mass: MagicMock) -> None:
4787        """Test that IPv6-mapped IPv4 addresses are normalized."""
4788        provider = MockProvider("airplay", mass=mock_mass)
4789        player = MockPlayer(
4790            provider,
4791            "ap_123",
4792            "AirPlay Device",
4793            player_type=PlayerType.PROTOCOL,
4794            identifiers={
4795                IdentifierType.MAC_ADDRESS: "54:78:C9:E6:0D:A0",
4796                IdentifierType.IP_ADDRESS: "::ffff:192.168.1.100",
4797            },
4798        )
4799
4800        await enrich_device_mac_address(player.device_info)
4801
4802        # IP should be normalized to IPv4
4803        assert player.device_info.identifiers[IdentifierType.IP_ADDRESS] == "192.168.1.100"
4804
4805
4806def _create_universal_player(
4807    mock_mass: MagicMock,
4808    player_id: str,
4809    name: str,
4810    protocol_player_ids: list[str],
4811    identifiers: dict[IdentifierType, str] | None = None,
4812) -> UniversalPlayer:
4813    """Create a UniversalPlayer for testing."""
4814    universal_provider = create_mock_universal_provider(mock_mass)
4815    # Set up config so provider.instance_id works
4816    provider_config = MagicMock()
4817    provider_config.instance_id = "universal_player"
4818    provider_config.name = None
4819    universal_provider.config = provider_config
4820    mock_mass.config.get_base_player_config.return_value = create_mock_config(name)
4821
4822    device_info = DeviceInfo(
4823        model="Universal Player",
4824        manufacturer="Music Assistant",
4825    )
4826    if identifiers:
4827        for conn_type, value in identifiers.items():
4828            device_info.add_identifier(conn_type, value)
4829
4830    player = UniversalPlayer(
4831        provider=universal_provider,
4832        player_id=player_id,
4833        name=name,
4834        device_info=device_info,
4835        protocol_player_ids=protocol_player_ids,
4836    )
4837    player._attr_available = True
4838    player._cache.clear()
4839    player.set_initialized()
4840    return player
4841
4842
4843class TestProtocolToUniversalIdentifierFallback:
4844    """
4845    Tests for Fix 1: identifier-based fallback matching for Universal Players.
4846
4847    When a new protocol player (like Sendspin bridge) registers and its ID isn't
4848    in the Universal Player's stored _protocol_player_ids list, the system should
4849    fall back to identifier matching (MAC address) to find the correct parent.
4850    """
4851
4852    def test_new_protocol_matches_universal_by_mac(self, mock_mass: MagicMock) -> None:
4853        """Test that a new protocol player matches a Universal Player by MAC address."""
4854        controller = PlayerController(mock_mass)
4855
4856        # No cached parent_id for the sendspin player
4857        def mock_config_get(_key: str, default: str | None = None) -> str | None:
4858            return default
4859
4860        mock_mass.config.get.side_effect = mock_config_get
4861
4862        # Create existing universal player with known MAC (from AirPlay)
4863        universal = _create_universal_player(
4864            mock_mass,
4865            "up_62e5974593d3",
4866            "Apple TV",
4867            protocol_player_ids=["ap62e5974593d3"],
4868            identifiers={IdentifierType.MAC_ADDRESS: "62:E5:97:45:93:D3"},
4869        )
4870
4871        # Create AirPlay protocol player (already linked)
4872        airplay_provider = MockProvider("airplay", mass=mock_mass)
4873        airplay_player = MockPlayer(
4874            airplay_provider,
4875            "ap62e5974593d3",
4876            "Apple TV (AirPlay)",
4877            player_type=PlayerType.PROTOCOL,
4878            identifiers={IdentifierType.MAC_ADDRESS: "62:E5:97:45:93:D3"},
4879        )
4880        airplay_player.set_protocol_parent_id("up_62e5974593d3")
4881
4882        # Create NEW Sendspin bridge player with same MAC (not yet in _protocol_player_ids)
4883        sendspin_provider = MockProvider("sendspin", mass=mock_mass)
4884        sendspin_player = MockPlayer(
4885            sendspin_provider,
4886            "spb_62e5974593d3",
4887            "Apple TV (Sendspin)",
4888            player_type=PlayerType.PROTOCOL,
4889            identifiers={IdentifierType.MAC_ADDRESS: "62:E5:97:45:93:D3"},
4890        )
4891
4892        mock_mass.players = controller
4893        controller._players = {
4894            "up_62e5974593d3": universal,
4895            "ap62e5974593d3": airplay_player,
4896            "spb_62e5974593d3": sendspin_player,
4897        }
4898
4899        # Initialize players so all_players() returns them
4900        airplay_player.set_initialized()
4901        sendspin_player.set_initialized()
4902
4903        # Try to link Sendspin player
4904        controller._try_link_protocol_to_native(sendspin_player)
4905
4906        # Should be linked to the universal player via identifier matching
4907        assert sendspin_player.protocol_parent_id == "up_62e5974593d3"
4908
4909        # Should be added to universal player's protocol list
4910        assert "spb_62e5974593d3" in universal._protocol_player_ids
4911
4912        # Should have a linked output protocol
4913        assert any(
4914            link.output_protocol_id == "spb_62e5974593d3"
4915            for link in universal.linked_output_protocols
4916        )
4917
4918    def test_new_protocol_no_match_skips_universal(self, mock_mass: MagicMock) -> None:
4919        """Test that a protocol player with different MAC doesn't match the wrong Universal."""
4920        controller = PlayerController(mock_mass)
4921
4922        # No cached parent_id
4923        def mock_config_get(_key: str, default: str | None = None) -> str | None:
4924            return default
4925
4926        mock_mass.config.get.side_effect = mock_config_get
4927
4928        # Create universal player for Device A
4929        universal = _create_universal_player(
4930            mock_mass,
4931            "up_aabbccddeeff",
4932            "Device A",
4933            protocol_player_ids=["ap_aabbccddeeff"],
4934            identifiers={IdentifierType.MAC_ADDRESS: "AA:BB:CC:DD:EE:FF"},
4935        )
4936
4937        # Create protocol player for a completely different device
4938        sendspin_provider = MockProvider("sendspin", mass=mock_mass)
4939        sendspin_player = MockPlayer(
4940            sendspin_provider,
4941            "spb_112233445566",
4942            "Device B (Sendspin)",
4943            player_type=PlayerType.PROTOCOL,
4944            identifiers={IdentifierType.MAC_ADDRESS: "11:22:33:44:55:66"},
4945        )
4946
4947        mock_mass.players = controller
4948        controller._players = {
4949            "up_aabbccddeeff": universal,
4950            "spb_112233445566": sendspin_player,
4951        }
4952
4953        sendspin_player.set_initialized()
4954
4955        controller._try_link_protocol_to_native(sendspin_player)
4956
4957        # Should NOT be linked to the wrong universal player
4958        assert sendspin_player.protocol_parent_id != "up_aabbccddeeff"
4959
4960    def test_known_protocol_id_still_works(self, mock_mass: MagicMock) -> None:
4961        """Test that the existing path (player_id in _protocol_player_ids) still works."""
4962        controller = PlayerController(mock_mass)
4963
4964        # No cached parent_id
4965        def mock_config_get(_key: str, default: str | None = None) -> str | None:
4966            return default
4967
4968        mock_mass.config.get.side_effect = mock_config_get
4969
4970        airplay_provider = MockProvider("airplay", mass=mock_mass)
4971        airplay_player = MockPlayer(
4972            airplay_provider,
4973            "ap_aabbccddeeff",
4974            "AirPlay Device",
4975            player_type=PlayerType.PROTOCOL,
4976            identifiers={IdentifierType.MAC_ADDRESS: "AA:BB:CC:DD:EE:FF"},
4977        )
4978
4979        # Universal player already has this player_id in its list
4980        universal = _create_universal_player(
4981            mock_mass,
4982            "up_aabbccddeeff",
4983            "Device",
4984            protocol_player_ids=["ap_aabbccddeeff"],
4985            identifiers={IdentifierType.MAC_ADDRESS: "AA:BB:CC:DD:EE:FF"},
4986        )
4987
4988        mock_mass.players = controller
4989        controller._players = {
4990            "up_aabbccddeeff": universal,
4991            "ap_aabbccddeeff": airplay_player,
4992        }
4993
4994        airplay_player.set_initialized()
4995
4996        controller._try_link_protocol_to_native(airplay_player)
4997
4998        # Should be linked via the existing stored ID path
4999        assert airplay_player.protocol_parent_id == "up_aabbccddeeff"
5000
5001
5002class TestCachedParentIdentifierCopying:
5003    """
5004    Tests for Fix 2: identifier copying when restoring cached parent links.
5005
5006    When a protocol player reconnects and links to a Universal Player via the
5007    cached_parent_id fast path, identifiers must be copied to the Universal Player.
5008    Restored Universal Players start with empty identifiers, so without this copy,
5009    subsequent protocol players (like Sendspin bridges) cannot match by identifiers.
5010    """
5011
5012    def test_identifiers_copied_on_cached_parent_restore(self, mock_mass: MagicMock) -> None:
5013        """Test that identifiers are copied from protocol player to Universal Player on restore."""
5014        controller = PlayerController(mock_mass)
5015
5016        # Mock config to return cached parent_id
5017        def mock_config_get(key: str, default: str | None = None) -> str | None:
5018            if "protocol_parent_id" in str(key):
5019                return "up_62e5974593d3"
5020            return default
5021
5022        mock_mass.config.get.side_effect = mock_config_get
5023
5024        # Create restored universal player with EMPTY identifiers (simulates restart)
5025        universal = _create_universal_player(
5026            mock_mass,
5027            "up_62e5974593d3",
5028            "Apple TV",
5029            protocol_player_ids=["ap62e5974593d3"],
5030            # No identifiers - simulates restored state
5031        )
5032
5033        # Create AirPlay protocol player reconnecting with its identifiers
5034        airplay_provider = MockProvider("airplay", mass=mock_mass)
5035        airplay_player = MockPlayer(
5036            airplay_provider,
5037            "ap62e5974593d3",
5038            "Apple TV (AirPlay)",
5039            player_type=PlayerType.PROTOCOL,
5040            identifiers={
5041                IdentifierType.MAC_ADDRESS: "62:E5:97:45:93:D3",
5042                IdentifierType.IP_ADDRESS: "192.168.1.200",
5043            },
5044        )
5045
5046        mock_mass.players = controller
5047        controller._players = {
5048            "up_62e5974593d3": universal,
5049            "ap62e5974593d3": airplay_player,
5050        }
5051
5052        # Link protocol player via cached parent path
5053        controller._try_link_protocol_to_native(airplay_player)
5054
5055        # Should be linked
5056        assert airplay_player.protocol_parent_id == "up_62e5974593d3"
5057
5058        # Universal player should now have the MAC from the protocol player
5059        assert (
5060            universal.device_info.identifiers.get(IdentifierType.MAC_ADDRESS) == "62:E5:97:45:93:D3"
5061        )
5062
5063    def test_sendspin_matches_after_identifier_copy(self, mock_mass: MagicMock) -> None:
5064        """
5065        Test the full scenario: AirPlay restores, copies identifiers, Sendspin matches.
5066
5067        AirPlay restores via cache, copies identifiers to the Universal Player,
5068        then the Sendspin bridge matches the Universal Player by MAC.
5069        This is the end-to-end test for the combined Fix 2 + Fix 1 interaction.
5070        """
5071        controller = PlayerController(mock_mass)
5072
5073        # Step 1: Mock config - AirPlay has cached parent, Sendspin does not
5074        def mock_config_get(key: str, default: str | None = None) -> str | None:
5075            if "ap62e5974593d3" in str(key) and "protocol_parent_id" in str(key):
5076                return "up_62e5974593d3"
5077            return default
5078
5079        mock_mass.config.get.side_effect = mock_config_get
5080
5081        # Create restored universal player with empty identifiers
5082        universal = _create_universal_player(
5083            mock_mass,
5084            "up_62e5974593d3",
5085            "Apple TV",
5086            protocol_player_ids=["ap62e5974593d3"],
5087        )
5088
5089        # Create AirPlay protocol player
5090        airplay_provider = MockProvider("airplay", mass=mock_mass)
5091        airplay_player = MockPlayer(
5092            airplay_provider,
5093            "ap62e5974593d3",
5094            "Apple TV (AirPlay)",
5095            player_type=PlayerType.PROTOCOL,
5096            identifiers={IdentifierType.MAC_ADDRESS: "62:E5:97:45:93:D3"},
5097        )
5098
5099        # Create Sendspin bridge player with same MAC
5100        sendspin_provider = MockProvider("sendspin", mass=mock_mass)
5101        sendspin_player = MockPlayer(
5102            sendspin_provider,
5103            "spb_62e5974593d3",
5104            "Apple TV (Sendspin)",
5105            player_type=PlayerType.PROTOCOL,
5106            identifiers={IdentifierType.MAC_ADDRESS: "62:E5:97:45:93:D3"},
5107        )
5108
5109        mock_mass.players = controller
5110        controller._players = {
5111            "up_62e5974593d3": universal,
5112            "ap62e5974593d3": airplay_player,
5113            "spb_62e5974593d3": sendspin_player,
5114        }
5115
5116        # Initialize players so all_players() returns them
5117        airplay_player.set_initialized()
5118        sendspin_player.set_initialized()
5119
5120        # Step 2: AirPlay reconnects first - links via cached parent_id
5121        controller._try_link_protocol_to_native(airplay_player)
5122
5123        # Verify AirPlay linked and identifiers copied
5124        assert airplay_player.protocol_parent_id == "up_62e5974593d3"
5125        assert (
5126            universal.device_info.identifiers.get(IdentifierType.MAC_ADDRESS) == "62:E5:97:45:93:D3"
5127        )
5128
5129        # Step 3: Sendspin registers next - should match Universal Player by MAC
5130        controller._try_link_protocol_to_native(sendspin_player)
5131
5132        # Sendspin should be linked to the SAME universal player
5133        assert sendspin_player.protocol_parent_id == "up_62e5974593d3"
5134        assert "spb_62e5974593d3" in universal._protocol_player_ids
5135
5136    def test_non_universal_parent_skips_identifier_copy(self, mock_mass: MagicMock) -> None:
5137        """Test that identifier copy is only done for Universal Players, not native players."""
5138        controller = PlayerController(mock_mass)
5139
5140        # Mock config to return cached parent_id pointing to a native player
5141        def mock_config_get(key: str, default: str | None = None) -> str | None:
5142            if "protocol_parent_id" in str(key):
5143                return "sonos_123"
5144            return default
5145
5146        mock_mass.config.get.side_effect = mock_config_get
5147
5148        # Create native Sonos player
5149        sonos_provider = MockProvider("sonos", mass=mock_mass)
5150        sonos_player = MockPlayer(
5151            sonos_provider,
5152            "sonos_123",
5153            "Sonos Speaker",
5154            identifiers={IdentifierType.MAC_ADDRESS: "AA:BB:CC:DD:EE:FF"},
5155        )
5156        original_mac = sonos_player.device_info.identifiers[IdentifierType.MAC_ADDRESS]
5157
5158        # Create DLNA protocol player with different MAC
5159        dlna_provider = MockProvider("dlna", mass=mock_mass)
5160        dlna_player = MockPlayer(
5161            dlna_provider,
5162            "dlna_123",
5163            "Sonos DLNA",
5164            player_type=PlayerType.PROTOCOL,
5165            identifiers={IdentifierType.MAC_ADDRESS: "11:22:33:44:55:66"},
5166        )
5167
5168        mock_mass.players = controller
5169        controller._players = {
5170            "sonos_123": sonos_player,
5171            "dlna_123": dlna_player,
5172        }
5173
5174        controller._try_link_protocol_to_native(dlna_player)
5175
5176        # Should be linked
5177        assert dlna_player.protocol_parent_id == "sonos_123"
5178
5179        # Native player's MAC should NOT be overwritten
5180        assert sonos_player.device_info.identifiers[IdentifierType.MAC_ADDRESS] == original_mac
5181
5182
5183class TestEnrichAndMatchIntegration:
5184    """
5185    Integration tests for the interaction between MAC enrichment and identifier matching.
5186
5187    These tests verify that Fix 3 (preserving original MAC when ARP resolves a
5188    completely different address) works correctly with the identifier matching system.
5189    """
5190
5191    @pytest.mark.asyncio
5192    async def test_apple_device_sendspin_matches_via_airplay_id(self, mock_mass: MagicMock) -> None:
5193        """
5194        Test the full Apple device scenario end-to-end.
5195
5196        1. AirPlay player registers with private MAC 62:E5:97:45:93:D3
5197        2. ARP resolves to hardware MAC C0:95:6D:51:34:E0 (always used)
5198        3. Sendspin bridge matches via AIRPLAY_ID, not MAC
5199        """
5200        controller = PlayerController(mock_mass)
5201
5202        # Create AirPlay player (Apple TV - native PLAYER type)
5203        airplay_provider = MockProvider("airplay", mass=mock_mass)
5204        airplay_player = MockPlayer(
5205            airplay_provider,
5206            "ap62e5974593d3",
5207            "Apple TV",
5208            player_type=PlayerType.PLAYER,
5209            identifiers={
5210                IdentifierType.MAC_ADDRESS: "62:E5:97:45:93:D3",
5211                IdentifierType.AIRPLAY_ID: "62:E5:97:45:93:D3",
5212                IdentifierType.IP_ADDRESS: "192.168.1.200",
5213            },
5214        )
5215
5216        # ARP enrichment replaces MAC with hardware truth
5217        with patch(
5218            "music_assistant.helpers.util.resolve_real_mac_address",
5219            new_callable=AsyncMock,
5220            return_value="C0:95:6D:51:34:E0",
5221        ):
5222            await enrich_device_mac_address(airplay_player.device_info)
5223
5224        # MAC replaced with ARP result
5225        assert (
5226            airplay_player.device_info.identifiers[IdentifierType.MAC_ADDRESS]
5227            == "C0:95:6D:51:34:E0"
5228        )
5229
5230        # Create Sendspin bridge player with AIRPLAY_ID (how Sendspin actually matches)
5231        sendspin_provider = MockProvider("sendspin", mass=mock_mass)
5232        sendspin_player = MockPlayer(
5233            sendspin_provider,
5234            "spb_62e5974593d3",
5235            "Apple TV (Sendspin)",
5236            player_type=PlayerType.PROTOCOL,
5237            identifiers={IdentifierType.AIRPLAY_ID: "62:E5:97:45:93:D3"},
5238        )
5239
5240        # Identifiers match via AIRPLAY_ID
5241        assert controller._identifiers_match(airplay_player, sendspin_player, "sendspin") is True
5242
5243    @pytest.mark.asyncio
5244    async def test_wiim_device_mac_replaced_still_matches(self, mock_mass: MagicMock) -> None:
5245        """
5246        Test that WiiM devices match after ARP enrichment.
5247
5248        WiiM AirPlay reports 56:78:C9:E6:0D:A0 (locally administered), ARP resolves to
5249        54:78:C9:E6:0D:A0 (hardware). The MAC is always replaced with the ARP result.
5250        """
5251        controller = PlayerController(mock_mass)
5252
5253        # Create AirPlay player (WiiM - PROTOCOL type)
5254        airplay_provider = MockProvider("airplay", mass=mock_mass)
5255        airplay_player = MockPlayer(
5256            airplay_provider,
5257            "ap_5678c9e60da0",
5258            "WiiM Pro (AirPlay)",
5259            player_type=PlayerType.PROTOCOL,
5260            identifiers={
5261                IdentifierType.MAC_ADDRESS: "56:78:C9:E6:0D:A0",
5262                IdentifierType.IP_ADDRESS: "192.168.1.50",
5263            },
5264        )
5265
5266        # ARP resolves MAC that differs only in locally-administered bit
5267        with patch(
5268            "music_assistant.helpers.util.resolve_real_mac_address",
5269            new_callable=AsyncMock,
5270            return_value="54:78:C9:E6:0D:A0",
5271        ):
5272            await enrich_device_mac_address(airplay_player.device_info)
5273
5274        # MAC should be replaced (only bit difference)
5275        assert (
5276            airplay_player.device_info.identifiers[IdentifierType.MAC_ADDRESS]
5277            == "54:78:C9:E6:0D:A0"
5278        )
5279
5280        # DLNA player also reports hardware MAC
5281        dlna_provider = MockProvider("dlna", mass=mock_mass)
5282        dlna_player = MockPlayer(
5283            dlna_provider,
5284            "dlna_5478c9e60da0",
5285            "WiiM Pro (DLNA)",
5286            player_type=PlayerType.PROTOCOL,
5287            identifiers={IdentifierType.MAC_ADDRESS: "54:78:C9:E6:0D:A0"},
5288        )
5289
5290        # Should match by MAC
5291        assert controller._identifiers_match(airplay_player, dlna_player, "dlna") is True
5292
5293
5294class TestDuplicateProtocolPrevention:
5295    """
5296    Tests for preventing duplicate protocol domain linking.
5297
5298    When multiple instances of the same protocol (e.g., two AirPlay or ShairPort-Sync
5299    instances, or multi-zone Bluesound players) run on the same host, they share the
5300    same IP/MAC. These must NOT all bind to the same parent player — each should get
5301    its own separate universal player.
5302    """
5303
5304    def test_parent_has_active_protocol_from_domain_empty(self, mock_mass: MagicMock) -> None:
5305        """Test helper returns False when parent has no linked protocols."""
5306        controller = PlayerController(mock_mass)
5307
5308        sonos_provider = MockProvider("sonos")
5309        sonos_player = MockPlayer(
5310            sonos_provider, "sonos_1", "Sonos Speaker", player_type=PlayerType.PLAYER
5311        )
5312
5313        assert controller._parent_has_active_protocol_from_domain(sonos_player, "airplay") is False
5314
5315    def test_parent_has_active_protocol_from_domain_true(self, mock_mass: MagicMock) -> None:
5316        """Test helper returns True when parent has an active link from the given domain."""
5317        controller = PlayerController(mock_mass)
5318
5319        sonos_provider = MockProvider("sonos")
5320        sonos_player = MockPlayer(
5321            sonos_provider, "sonos_1", "Sonos Speaker", player_type=PlayerType.PLAYER
5322        )
5323
5324        airplay_provider = MockProvider("airplay")
5325        airplay_player = MockPlayer(
5326            airplay_provider,
5327            "ap_1",
5328            "AirPlay 1",
5329            player_type=PlayerType.PROTOCOL,
5330            identifiers={IdentifierType.MAC_ADDRESS: "AA:BB:CC:DD:EE:FF"},
5331        )
5332        airplay_player._attr_available = True
5333        airplay_player._cache.clear()
5334        airplay_player.update_state(signal_event=False)
5335
5336        # Register players
5337        controller._players = {
5338            "sonos_1": sonos_player,
5339            "ap_1": airplay_player,
5340        }
5341        airplay_player.set_initialized()
5342        sonos_player.set_initialized()
5343
5344        # Add a protocol link
5345        controller._add_protocol_link(sonos_player, airplay_player, "airplay")
5346
5347        assert controller._parent_has_active_protocol_from_domain(sonos_player, "airplay") is True
5348        assert controller._parent_has_active_protocol_from_domain(sonos_player, "dlna") is False
5349
5350    def test_parent_has_active_protocol_excludes_player_id(self, mock_mass: MagicMock) -> None:
5351        """Test helper excludes the specified player_id from the check."""
5352        controller = PlayerController(mock_mass)
5353
5354        sonos_provider = MockProvider("sonos")
5355        sonos_player = MockPlayer(
5356            sonos_provider, "sonos_1", "Sonos Speaker", player_type=PlayerType.PLAYER
5357        )
5358
5359        airplay_provider = MockProvider("airplay")
5360        airplay_player = MockPlayer(
5361            airplay_provider,
5362            "ap_1",
5363            "AirPlay 1",
5364            player_type=PlayerType.PROTOCOL,
5365            identifiers={IdentifierType.MAC_ADDRESS: "AA:BB:CC:DD:EE:FF"},
5366        )
5367        airplay_player._attr_available = True
5368        airplay_player._cache.clear()
5369        airplay_player.update_state(signal_event=False)
5370
5371        controller._players = {
5372            "sonos_1": sonos_player,
5373            "ap_1": airplay_player,
5374        }
5375        airplay_player.set_initialized()
5376        sonos_player.set_initialized()
5377
5378        controller._add_protocol_link(sonos_player, airplay_player, "airplay")
5379
5380        # Should return False when the only match is the excluded player
5381        assert (
5382            controller._parent_has_active_protocol_from_domain(
5383                sonos_player, "airplay", exclude_player_id="ap_1"
5384            )
5385            is False
5386        )
5387
5388    def test_add_protocol_link_refuses_duplicate_domain(self, mock_mass: MagicMock) -> None:
5389        """Test that _add_protocol_link refuses when domain already has an active link."""
5390        controller = PlayerController(mock_mass)
5391
5392        sonos_provider = MockProvider("sonos")
5393        sonos_player = MockPlayer(
5394            sonos_provider, "sonos_1", "Sonos Speaker", player_type=PlayerType.PLAYER
5395        )
5396
5397        airplay_provider = MockProvider("airplay")
5398        ap1 = MockPlayer(
5399            airplay_provider,
5400            "ap_1",
5401            "AirPlay 1",
5402            player_type=PlayerType.PROTOCOL,
5403            identifiers={IdentifierType.MAC_ADDRESS: "AA:BB:CC:DD:EE:FF"},
5404        )
5405        ap2 = MockPlayer(
5406            airplay_provider,
5407            "ap_2",
5408            "AirPlay 2",
5409            player_type=PlayerType.PROTOCOL,
5410            identifiers={IdentifierType.MAC_ADDRESS: "AA:BB:CC:DD:EE:FF"},
5411        )
5412
5413        # Both available
5414        ap1._attr_available = True
5415        ap1._cache.clear()
5416        ap1.update_state(signal_event=False)
5417        ap2._attr_available = True
5418        ap2._cache.clear()
5419        ap2.update_state(signal_event=False)
5420
5421        controller._players = {
5422            "sonos_1": sonos_player,
5423            "ap_1": ap1,
5424            "ap_2": ap2,
5425        }
5426        ap1.set_initialized()
5427        ap2.set_initialized()
5428        sonos_player.set_initialized()
5429
5430        # Link first AirPlay - should succeed
5431        controller._add_protocol_link(sonos_player, ap1, "airplay")
5432        assert ap1.protocol_parent_id == "sonos_1"
5433        assert len(sonos_player.linked_output_protocols) == 1
5434
5435        # Link second AirPlay - should be refused (domain already active)
5436        controller._add_protocol_link(sonos_player, ap2, "airplay")
5437        assert ap2.protocol_parent_id is None
5438        assert len(sonos_player.linked_output_protocols) == 1
5439        assert sonos_player.linked_output_protocols[0].output_protocol_id == "ap_1"
5440
5441    def test_add_protocol_link_allows_different_domains(self, mock_mass: MagicMock) -> None:
5442        """Test that _add_protocol_link allows links from different protocol domains."""
5443        controller = PlayerController(mock_mass)
5444
5445        sonos_provider = MockProvider("sonos")
5446        sonos_player = MockPlayer(
5447            sonos_provider, "sonos_1", "Sonos Speaker", player_type=PlayerType.PLAYER
5448        )
5449
5450        airplay_provider = MockProvider("airplay")
5451        ap1 = MockPlayer(
5452            airplay_provider,
5453            "ap_1",
5454            "AirPlay",
5455            player_type=PlayerType.PROTOCOL,
5456            identifiers={IdentifierType.MAC_ADDRESS: "AA:BB:CC:DD:EE:FF"},
5457        )
5458
5459        dlna_provider = MockProvider("dlna")
5460        dlna1 = MockPlayer(
5461            dlna_provider,
5462            "dlna_1",
5463            "DLNA",
5464            player_type=PlayerType.PROTOCOL,
5465            identifiers={IdentifierType.MAC_ADDRESS: "AA:BB:CC:DD:EE:FF"},
5466        )
5467
5468        ap1._attr_available = True
5469        ap1._cache.clear()
5470        ap1.update_state(signal_event=False)
5471        dlna1._attr_available = True
5472        dlna1._cache.clear()
5473        dlna1.update_state(signal_event=False)
5474
5475        controller._players = {
5476            "sonos_1": sonos_player,
5477            "ap_1": ap1,
5478            "dlna_1": dlna1,
5479        }
5480        ap1.set_initialized()
5481        dlna1.set_initialized()
5482        sonos_player.set_initialized()
5483
5484        # Link AirPlay - should succeed
5485        controller._add_protocol_link(sonos_player, ap1, "airplay")
5486        assert ap1.protocol_parent_id == "sonos_1"
5487
5488        # Link DLNA - should also succeed (different domain)
5489        controller._add_protocol_link(sonos_player, dlna1, "dlna")
5490        assert dlna1.protocol_parent_id == "sonos_1"
5491        assert len(sonos_player.linked_output_protocols) == 2
5492
5493    def test_add_protocol_link_allows_replacing_removed_player(self, mock_mass: MagicMock) -> None:
5494        """Test that a new link can replace a stale one after the old player is removed."""
5495        controller = PlayerController(mock_mass)
5496
5497        sonos_provider = MockProvider("sonos")
5498        sonos_player = MockPlayer(
5499            sonos_provider, "sonos_1", "Sonos Speaker", player_type=PlayerType.PLAYER
5500        )
5501
5502        airplay_provider = MockProvider("airplay")
5503        ap1 = MockPlayer(
5504            airplay_provider,
5505            "ap_1",
5506            "AirPlay 1",
5507            player_type=PlayerType.PROTOCOL,
5508            identifiers={IdentifierType.MAC_ADDRESS: "AA:BB:CC:DD:EE:FF"},
5509        )
5510        ap2 = MockPlayer(
5511            airplay_provider,
5512            "ap_2",
5513            "AirPlay 2",
5514            player_type=PlayerType.PROTOCOL,
5515            identifiers={IdentifierType.MAC_ADDRESS: "AA:BB:CC:DD:EE:FF"},
5516        )
5517
5518        ap1._attr_available = True
5519        ap1._cache.clear()
5520        ap1.update_state(signal_event=False)
5521        ap2._attr_available = True
5522        ap2._cache.clear()
5523        ap2.update_state(signal_event=False)
5524
5525        controller._players = {
5526            "sonos_1": sonos_player,
5527            "ap_1": ap1,
5528            "ap_2": ap2,
5529        }
5530        ap1.set_initialized()
5531        ap2.set_initialized()
5532        sonos_player.set_initialized()
5533
5534        # Link first AirPlay
5535        controller._add_protocol_link(sonos_player, ap1, "airplay")
5536        assert ap1.protocol_parent_id == "sonos_1"
5537
5538        # Remove first AirPlay from registry (provider cleaned up stale player)
5539        del controller._players["ap_1"]
5540
5541        # Link second AirPlay - should succeed since first was removed
5542        controller._add_protocol_link(sonos_player, ap2, "airplay")
5543        assert ap2.protocol_parent_id == "sonos_1"
5544
5545    def test_add_protocol_link_blocks_when_existing_still_registered(
5546        self, mock_mass: MagicMock
5547    ) -> None:
5548        """Test that a new link is blocked when the old player is still registered."""
5549        controller = PlayerController(mock_mass)
5550
5551        sonos_provider = MockProvider("sonos")
5552        sonos_player = MockPlayer(
5553            sonos_provider, "sonos_1", "Sonos Speaker", player_type=PlayerType.PLAYER
5554        )
5555
5556        snap_provider = MockProvider("snapcast")
5557        snap1 = MockPlayer(
5558            snap_provider,
5559            "snap_1",
5560            "Snapcast 1",
5561            player_type=PlayerType.PROTOCOL,
5562            identifiers={IdentifierType.MAC_ADDRESS: "D0:11:E5:00:67:2F"},
5563        )
5564        snap2 = MockPlayer(
5565            snap_provider,
5566            "snap_2",
5567            "Snapcast 2",
5568            player_type=PlayerType.PROTOCOL,
5569            identifiers={IdentifierType.MAC_ADDRESS: "D0:11:E5:00:67:2F"},
5570        )
5571
5572        # Both unavailable
5573        snap1._attr_available = False
5574        snap1._cache.clear()
5575        snap1.update_state(signal_event=False)
5576        snap2._attr_available = False
5577        snap2._cache.clear()
5578        snap2.update_state(signal_event=False)
5579
5580        controller._players = {
5581            "sonos_1": sonos_player,
5582            "snap_1": snap1,
5583            "snap_2": snap2,
5584        }
5585        snap1.set_initialized()
5586        snap2.set_initialized()
5587        sonos_player.set_initialized()
5588
5589        # Link first snapcast
5590        controller._add_protocol_link(sonos_player, snap1, "snapcast")
5591        assert snap1.protocol_parent_id == "sonos_1"
5592
5593        # Try to link second snapcast - should be BLOCKED (snap1 still registered)
5594        controller._add_protocol_link(sonos_player, snap2, "snapcast")
5595        assert snap2.protocol_parent_id is None
5596
5597    def test_try_link_protocol_to_native_skips_duplicate_domain(self, mock_mass: MagicMock) -> None:
5598        """Test that _try_link_protocol_to_native falls through when domain is duplicate."""
5599        controller = PlayerController(mock_mass)
5600
5601        sonos_provider = MockProvider("sonos")
5602        sonos_player = MockPlayer(
5603            sonos_provider,
5604            "sonos_1",
5605            "Sonos Speaker",
5606            player_type=PlayerType.PLAYER,
5607            identifiers={IdentifierType.MAC_ADDRESS: "AA:BB:CC:DD:EE:FF"},
5608        )
5609
5610        airplay_provider = MockProvider("airplay")
5611        ap1 = MockPlayer(
5612            airplay_provider,
5613            "ap_1",
5614            "AirPlay 1",
5615            player_type=PlayerType.PROTOCOL,
5616            identifiers={IdentifierType.MAC_ADDRESS: "AA:BB:CC:DD:EE:FF"},
5617        )
5618        ap2 = MockPlayer(
5619            airplay_provider,
5620            "ap_2",
5621            "AirPlay 2",
5622            player_type=PlayerType.PROTOCOL,
5623            identifiers={IdentifierType.MAC_ADDRESS: "AA:BB:CC:DD:EE:FF"},
5624        )
5625
5626        ap1._attr_available = True
5627        ap1._cache.clear()
5628        ap1.update_state(signal_event=False)
5629        ap2._attr_available = True
5630        ap2._cache.clear()
5631        ap2.update_state(signal_event=False)
5632
5633        controller._players = {
5634            "sonos_1": sonos_player,
5635            "ap_1": ap1,
5636            "ap_2": ap2,
5637        }
5638        ap1.set_initialized()
5639        ap2.set_initialized()
5640        sonos_player.set_initialized()
5641
5642        # Link first AirPlay to native player
5643        controller._add_protocol_link(sonos_player, ap1, "airplay")
5644        assert ap1.protocol_parent_id == "sonos_1"
5645
5646        # Mock _schedule_protocol_evaluation to track if it's called
5647        with patch.object(controller, "_schedule_protocol_evaluation") as mock_schedule:
5648            controller._try_link_protocol_to_native(ap2)
5649
5650        # ap2 should NOT have been linked to sonos
5651        assert ap2.protocol_parent_id is None
5652        # ap2 should be scheduled for delayed evaluation (which will create its own
5653        # universal player)
5654        mock_schedule.assert_called_once_with(ap2)
5655
5656    def test_try_link_protocols_to_native_skips_duplicate_domain(
5657        self, mock_mass: MagicMock
5658    ) -> None:
5659        """Test that _try_link_protocols_to_native skips protocol players with duplicate domain."""
5660        controller = PlayerController(mock_mass)
5661
5662        # Config.get returns {} for CONF_PLAYERS dict lookups
5663        mock_mass.config.get = MagicMock(side_effect=lambda _key, default=None: default or {})
5664
5665        sonos_provider = MockProvider("sonos")
5666        sonos_player = MockPlayer(
5667            sonos_provider,
5668            "sonos_1",
5669            "Sonos Speaker",
5670            player_type=PlayerType.PLAYER,
5671            identifiers={IdentifierType.MAC_ADDRESS: "AA:BB:CC:DD:EE:FF"},
5672        )
5673
5674        airplay_provider = MockProvider("airplay")
5675        ap1 = MockPlayer(
5676            airplay_provider,
5677            "ap_1",
5678            "AirPlay 1",
5679            player_type=PlayerType.PROTOCOL,
5680            identifiers={IdentifierType.MAC_ADDRESS: "AA:BB:CC:DD:EE:FF"},
5681        )
5682        ap2 = MockPlayer(
5683            airplay_provider,
5684            "ap_2",
5685            "AirPlay 2",
5686            player_type=PlayerType.PROTOCOL,
5687            identifiers={IdentifierType.MAC_ADDRESS: "AA:BB:CC:DD:EE:FF"},
5688        )
5689
5690        dlna_provider = MockProvider("dlna")
5691        dlna_player = MockPlayer(
5692            dlna_provider,
5693            "dlna_1",
5694            "DLNA",
5695            player_type=PlayerType.PROTOCOL,
5696            identifiers={IdentifierType.MAC_ADDRESS: "AA:BB:CC:DD:EE:FF"},
5697        )
5698
5699        ap1._attr_available = True
5700        ap1._cache.clear()
5701        ap1.update_state(signal_event=False)
5702        ap2._attr_available = True
5703        ap2._cache.clear()
5704        ap2.update_state(signal_event=False)
5705        dlna_player._attr_available = True
5706        dlna_player._cache.clear()
5707        dlna_player.update_state(signal_event=False)
5708
5709        controller._players = {
5710            "ap_1": ap1,
5711            "ap_2": ap2,
5712            "dlna_1": dlna_player,
5713            "sonos_1": sonos_player,
5714        }
5715        ap1.set_initialized()
5716        ap2.set_initialized()
5717        dlna_player.set_initialized()
5718        sonos_player.set_initialized()
5719
5720        # Call _try_link_protocols_to_native for the Sonos player
5721        # It should link ap1 and dlna but NOT ap2 (duplicate airplay domain)
5722        controller._try_link_protocols_to_native(sonos_player)
5723
5724        # First AirPlay should be linked
5725        assert ap1.protocol_parent_id == "sonos_1"
5726        # DLNA should be linked (different domain)
5727        assert dlna_player.protocol_parent_id == "sonos_1"
5728        # Second AirPlay should NOT be linked (duplicate domain)
5729        assert ap2.protocol_parent_id is None
5730        assert len(sonos_player.linked_output_protocols) == 2
5731
5732    def test_normal_multi_protocol_device_still_works(self, mock_mass: MagicMock) -> None:
5733        """Test that a device with AirPlay+DLNA (different protocols) still links correctly."""
5734        controller = PlayerController(mock_mass)
5735
5736        # Config.get returns {} for CONF_PLAYERS dict lookups
5737        mock_mass.config.get = MagicMock(side_effect=lambda _key, default=None: default or {})
5738
5739        sonos_provider = MockProvider("sonos")
5740        sonos_player = MockPlayer(
5741            sonos_provider,
5742            "sonos_1",
5743            "Sonos Speaker",
5744            player_type=PlayerType.PLAYER,
5745            identifiers={IdentifierType.MAC_ADDRESS: "AA:BB:CC:DD:EE:FF"},
5746        )
5747
5748        airplay_provider = MockProvider("airplay")
5749        ap = MockPlayer(
5750            airplay_provider,
5751            "ap_1",
5752            "AirPlay",
5753            player_type=PlayerType.PROTOCOL,
5754            identifiers={IdentifierType.MAC_ADDRESS: "AA:BB:CC:DD:EE:FF"},
5755        )
5756
5757        dlna_provider = MockProvider("dlna")
5758        dlna = MockPlayer(
5759            dlna_provider,
5760            "dlna_1",
5761            "DLNA",
5762            player_type=PlayerType.PROTOCOL,
5763            identifiers={IdentifierType.MAC_ADDRESS: "AA:BB:CC:DD:EE:FF"},
5764        )
5765
5766        ap._attr_available = True
5767        ap._cache.clear()
5768        ap.update_state(signal_event=False)
5769        dlna._attr_available = True
5770        dlna._cache.clear()
5771        dlna.update_state(signal_event=False)
5772
5773        controller._players = {
5774            "sonos_1": sonos_player,
5775            "ap_1": ap,
5776            "dlna_1": dlna,
5777        }
5778        ap.set_initialized()
5779        dlna.set_initialized()
5780        sonos_player.set_initialized()
5781
5782        # Link both protocols
5783        controller._try_link_protocols_to_native(sonos_player)
5784
5785        assert ap.protocol_parent_id == "sonos_1"
5786        assert dlna.protocol_parent_id == "sonos_1"
5787        assert len(sonos_player.linked_output_protocols) == 2
5788
5789    def test_cached_parent_restore_refuses_duplicate_domain(self, mock_mass: MagicMock) -> None:
5790        """Test that restoring a cached parent is refused when domain already has active link."""
5791        controller = PlayerController(mock_mass)
5792
5793        sonos_provider = MockProvider("sonos")
5794        sonos_player = MockPlayer(
5795            sonos_provider,
5796            "sonos_1",
5797            "Sonos Speaker",
5798            player_type=PlayerType.PLAYER,
5799            identifiers={IdentifierType.MAC_ADDRESS: "AA:BB:CC:DD:EE:FF"},
5800        )
5801
5802        airplay_provider = MockProvider("airplay")
5803        ap1 = MockPlayer(
5804            airplay_provider,
5805            "ap_1",
5806            "AirPlay 1",
5807            player_type=PlayerType.PROTOCOL,
5808            identifiers={IdentifierType.MAC_ADDRESS: "AA:BB:CC:DD:EE:FF"},
5809        )
5810        ap2 = MockPlayer(
5811            airplay_provider,
5812            "ap_2",
5813            "AirPlay 2",
5814            player_type=PlayerType.PROTOCOL,
5815            identifiers={IdentifierType.MAC_ADDRESS: "AA:BB:CC:DD:EE:FF"},
5816        )
5817
5818        ap1._attr_available = True
5819        ap1._cache.clear()
5820        ap1.update_state(signal_event=False)
5821        ap2._attr_available = True
5822        ap2._cache.clear()
5823        ap2.update_state(signal_event=False)
5824
5825        controller._players = {
5826            "sonos_1": sonos_player,
5827            "ap_1": ap1,
5828            "ap_2": ap2,
5829        }
5830        ap1.set_initialized()
5831        ap2.set_initialized()
5832        sonos_player.set_initialized()
5833
5834        # Link first AirPlay
5835        controller._add_protocol_link(sonos_player, ap1, "airplay")
5836        assert ap1.protocol_parent_id == "sonos_1"
5837
5838        # Simulate ap2 having a cached parent from previous session
5839        with (
5840            patch.object(
5841                controller,
5842                "_get_cached_protocol_parent_id",
5843                return_value="sonos_1",
5844            ),
5845            patch.object(controller, "_schedule_protocol_evaluation") as mock_schedule,
5846        ):
5847            controller._try_link_protocol_to_native(ap2)
5848
5849        # ap2 should NOT have been linked (domain already active on sonos_1)
5850        assert ap2.protocol_parent_id is None
5851        # ap2 should be scheduled for delayed evaluation
5852        mock_schedule.assert_called_once_with(ap2)
5853
5854
5855class TestUniversalPlayerMerging:
5856    """Tests for merging universal players when identifiers overlap."""
5857
5858    def test_merge_universal_players_on_identifier_copy(self, mock_mass: MagicMock) -> None:
5859        """
5860        Test that two universal players merge when they share a MAC after identifier copy.
5861
5862        Simulates the Edifier scenario: DLNA creates a UUID-based universal player,
5863        AirPlay creates a MAC-based universal player. When ARP enrichment adds the
5864        real MAC to the DLNA player and it's copied to the DLNA universal player,
5865        the two universal players should merge because they now share the same MAC.
5866        """
5867        controller = PlayerController(mock_mass)
5868        up_provider = create_mock_universal_provider(mock_mass)
5869
5870        # Universal player 1: from DLNA (UUID-based, no MAC initially)
5871        up1 = UniversalPlayer(
5872            provider=up_provider,
5873            player_id="upf15fff97f002",
5874            name="Speaker Liv",
5875            device_info=DeviceInfo(model="DLNA Speaker", manufacturer="Edifier"),
5876            protocol_player_ids=["dlna_uuid_1"],
5877        )
5878        up1._attr_device_info.add_identifier(
5879            IdentifierType.UUID, "FF97F002-783E-6505-6579-F15FFF97F002"
5880        )
5881        up1._cache.clear()
5882        up1.update_state(signal_event=False)
5883        up1.set_initialized()
5884
5885        # Universal player 2: from AirPlay (MAC-based)
5886        up2 = UniversalPlayer(
5887            provider=up_provider,
5888            player_id="up50411c2f00ee",
5889            name="Speaker Liv",
5890            device_info=DeviceInfo(model="AirPlay Speaker", manufacturer="Edifier"),
5891            protocol_player_ids=["ap_1", "spb_1"],
5892        )
5893        up2._attr_device_info.add_identifier(IdentifierType.MAC_ADDRESS, "50:41:1C:2F:00:EE")
5894        up2._attr_device_info.add_identifier(IdentifierType.AIRPLAY_ID, "52:41:1C:2F:00:EE")
5895        up2._cache.clear()
5896        up2.update_state(signal_event=False)
5897        up2.set_initialized()
5898
5899        # Create mock DLNA protocol player (has MAC from ARP enrichment)
5900        dlna_provider = MockProvider("dlna", mass=mock_mass)
5901        dlna_player = MockPlayer(
5902            dlna_provider,
5903            "dlna_uuid_1",
5904            "Speaker Liv DLNA",
5905            player_type=PlayerType.PROTOCOL,
5906            identifiers={
5907                IdentifierType.UUID: "FF97F002-783E-6505-6579-F15FFF97F002",
5908                IdentifierType.MAC_ADDRESS: "50:41:1C:2F:00:EE",  # From ARP
5909                IdentifierType.IP_ADDRESS: "192.168.1.80",
5910            },
5911        )
5912        dlna_player.set_initialized()
5913
5914        # Create mock AirPlay protocol player
5915        airplay_provider = MockProvider("airplay", mass=mock_mass)
5916        ap_player = MockPlayer(
5917            airplay_provider,
5918            "ap_1",
5919            "Speaker Liv AirPlay",
5920            player_type=PlayerType.PROTOCOL,
5921            identifiers={
5922                IdentifierType.MAC_ADDRESS: "50:41:1C:2F:00:EE",
5923                IdentifierType.AIRPLAY_ID: "52:41:1C:2F:00:EE",
5924            },
5925        )
5926        ap_player.set_initialized()
5927
5928        # Link protocols to their respective universal players
5929        controller._players = {
5930            "upf15fff97f002": up1,
5931            "up50411c2f00ee": up2,
5932            "dlna_uuid_1": dlna_player,
5933            "ap_1": ap_player,
5934        }
5935
5936        # Link DLNA to up1
5937        controller._add_protocol_link(up1, dlna_player, "dlna")
5938        assert dlna_player.protocol_parent_id == "upf15fff97f002"
5939
5940        # Link AirPlay to up2
5941        controller._add_protocol_link(up2, ap_player, "airplay")
5942        assert ap_player.protocol_parent_id == "up50411c2f00ee"
5943
5944        # Now simulate what happens when DLNA player copies identifiers to up1
5945        # (this happens in _try_restore_cached_parent or _try_link_to_existing_player)
5946        for conn_type, value in dlna_player.device_info.identifiers.items():
5947            up1.device_info.add_identifier(conn_type, value)
5948
5949        # up1 now has MAC 50:41:1C:2F:00:EE which matches up2
5950        assert up1.device_info.identifiers.get(IdentifierType.MAC_ADDRESS) == "50:41:1C:2F:00:EE"
5951
5952        # Call merge check
5953        controller._check_merge_universal_players(up1)
5954
5955        # Both UPs have one linked protocol -> equal-count merge falls through to
5956        # the deterministic tiebreaker on player_id. "up50411c2f00ee" (up2) is
5957        # lex-smaller than "upf15fff97f002" (up1), so up2 absorbs up1.
5958
5959        # up2 (lex-smaller player_id) should have absorbed up1's DLNA link
5960        protocol_domains = {link.protocol_domain for link in up2.linked_output_protocols}
5961        assert "dlna" in protocol_domains
5962        assert "airplay" in protocol_domains
5963
5964        # up1 should have no more protocol links
5965        assert len(up1.linked_output_protocols) == 0
5966
5967        # DLNA player should now be linked to up2 (was on up1 before merge)
5968        assert dlna_player.protocol_parent_id == "up50411c2f00ee"
5969
5970        # AirPlay player should still be linked to up2
5971        assert ap_player.protocol_parent_id == "up50411c2f00ee"
5972
5973        # up2 should have protocol player IDs from both
5974        assert "dlna_uuid_1" in up2._protocol_player_ids
5975        assert "ap_1" in up2._protocol_player_ids
5976
5977        # Unregister should have been called for up1
5978        mock_mass.create_task.assert_called()
5979
5980    def test_no_merge_when_identifiers_dont_match(self, mock_mass: MagicMock) -> None:
5981        """Test that universal players are not merged when identifiers don't overlap."""
5982        controller = PlayerController(mock_mass)
5983        up_provider = create_mock_universal_provider(mock_mass)
5984
5985        up1 = UniversalPlayer(
5986            provider=up_provider,
5987            player_id="up_1",
5988            name="Speaker A",
5989            device_info=DeviceInfo(model="Model A", manufacturer="Maker A"),
5990            protocol_player_ids=["dlna_1"],
5991        )
5992        up1._attr_device_info.add_identifier(IdentifierType.MAC_ADDRESS, "AA:BB:CC:DD:EE:01")
5993        up1._cache.clear()
5994        up1.update_state(signal_event=False)
5995        up1.set_initialized()
5996
5997        up2 = UniversalPlayer(
5998            provider=up_provider,
5999            player_id="up_2",
6000            name="Speaker B",
6001            device_info=DeviceInfo(model="Model B", manufacturer="Maker B"),
6002            protocol_player_ids=["ap_1"],
6003        )
6004        up2._attr_device_info.add_identifier(IdentifierType.MAC_ADDRESS, "AA:BB:CC:DD:EE:02")
6005        up2._cache.clear()
6006        up2.update_state(signal_event=False)
6007        up2.set_initialized()
6008
6009        controller._players = {"up_1": up1, "up_2": up2}
6010
6011        # Call merge - should do nothing since MACs are different
6012        controller._check_merge_universal_players(up1)
6013
6014        # Both players should remain untouched
6015        assert "up_1" in controller._players
6016        assert "up_2" in controller._players
6017
6018    def test_merge_keeps_player_with_more_protocols(self, mock_mass: MagicMock) -> None:
6019        """Test that of two equally old universal players, the one with more links wins."""
6020        controller = PlayerController(mock_mass)
6021        up_provider = create_mock_universal_provider(mock_mass)
6022
6023        # up1 has only 1 protocol link (DLNA)
6024        up1 = UniversalPlayer(
6025            provider=up_provider,
6026            player_id="up_small",
6027            name="Small Player",
6028            device_info=DeviceInfo(model="Test", manufacturer="Test"),
6029            protocol_player_ids=["dlna_1"],
6030        )
6031        up1._attr_device_info.add_identifier(IdentifierType.MAC_ADDRESS, "AA:BB:CC:DD:EE:FF")
6032        up1._cache.clear()
6033        up1.update_state(signal_event=False)
6034        up1.set_initialized()
6035
6036        # up2 has 2 protocol links (AirPlay + Sendspin)
6037        up2 = UniversalPlayer(
6038            provider=up_provider,
6039            player_id="up_big",
6040            name="Big Player",
6041            device_info=DeviceInfo(model="Test", manufacturer="Test"),
6042            protocol_player_ids=["ap_1", "spb_1"],
6043        )
6044        up2._attr_device_info.add_identifier(IdentifierType.MAC_ADDRESS, "AA:BB:CC:DD:EE:FF")
6045        up2._cache.clear()
6046        up2.update_state(signal_event=False)
6047        up2.set_initialized()
6048
6049        # Create protocol players
6050        dlna_provider = MockProvider("dlna", mass=mock_mass)
6051        dlna_player = MockPlayer(
6052            dlna_provider,
6053            "dlna_1",
6054            "DLNA",
6055            player_type=PlayerType.PROTOCOL,
6056            identifiers={IdentifierType.MAC_ADDRESS: "AA:BB:CC:DD:EE:FF"},
6057        )
6058        dlna_player.set_initialized()
6059
6060        airplay_provider = MockProvider("airplay", mass=mock_mass)
6061        ap_player = MockPlayer(
6062            airplay_provider,
6063            "ap_1",
6064            "AirPlay",
6065            player_type=PlayerType.PROTOCOL,
6066            identifiers={IdentifierType.MAC_ADDRESS: "AA:BB:CC:DD:EE:FF"},
6067        )
6068        ap_player.set_initialized()
6069
6070        sendspin_provider = MockProvider("sendspin", mass=mock_mass)
6071        spb_player = MockPlayer(
6072            sendspin_provider,
6073            "spb_1",
6074            "Sendspin",
6075            player_type=PlayerType.PROTOCOL,
6076            identifiers={IdentifierType.MAC_ADDRESS: "AA:BB:CC:DD:EE:FF"},
6077        )
6078        spb_player.set_initialized()
6079
6080        controller._players = {
6081            "up_small": up1,
6082            "up_big": up2,
6083            "dlna_1": dlna_player,
6084            "ap_1": ap_player,
6085            "spb_1": spb_player,
6086        }
6087
6088        # Link DLNA to small, AirPlay+Sendspin to big
6089        controller._add_protocol_link(up1, dlna_player, "dlna")
6090        controller._add_protocol_link(up2, ap_player, "airplay")
6091        controller._add_protocol_link(up2, spb_player, "sendspin")
6092
6093        # Merge from the small player's perspective
6094        controller._check_merge_universal_players(up1)
6095
6096        # up2 (bigger) should have absorbed up1's DLNA link
6097        protocol_domains = {link.protocol_domain for link in up2.linked_output_protocols}
6098        assert "airplay" in protocol_domains
6099        assert "sendspin" in protocol_domains
6100        assert "dlna" in protocol_domains
6101
6102        # DLNA player should now point to up_big
6103        assert dlna_player.protocol_parent_id == "up_big"
6104
6105        # up1 should have no more links
6106        assert len(up1.linked_output_protocols) == 0
6107
6108    def test_merge_keeps_the_older_player_with_fewer_protocols(self, mock_mass: MagicMock) -> None:
6109        """
6110        The oldest universal player wins even when it serves fewer protocols.
6111
6112        Its player id is the one API consumers have been bound to the longest, and the
6113        links of the absorbed player move over anyway. A player stored before ids were
6114        minted has no creation moment at all, which must count as the oldest of the two.
6115        """
6116        controller = PlayerController(mock_mass)
6117        up_provider = create_mock_universal_provider(mock_mass)
6118        _wire_nested_config(
6119            mock_mass,
6120            {
6121                CONF_PLAYERS: {
6122                    # the established player, from before player ids were minted
6123                    "up_old": {"player_id": "up_old", "enabled": True, "values": {}},
6124                    "up_new": {
6125                        "player_id": "up_new",
6126                        "enabled": True,
6127                        "values": {CONF_CREATED_AT: 9000},
6128                    },
6129                }
6130            },
6131        )
6132
6133        players: dict[str, UniversalPlayer] = {}
6134        for player_id, protocol_ids in (("up_old", ["dlna_1"]), ("up_new", ["ap_1", "spb_1"])):
6135            player = UniversalPlayer(
6136                provider=up_provider,
6137                player_id=player_id,
6138                name=player_id,
6139                device_info=DeviceInfo(model="Test", manufacturer="Test"),
6140                protocol_player_ids=protocol_ids,
6141            )
6142            player._attr_device_info.add_identifier(IdentifierType.MAC_ADDRESS, "AA:BB:CC:DD:EE:FF")
6143            player._cache.clear()
6144            player.update_state(signal_event=False)
6145            player.set_initialized()
6146            players[player_id] = player
6147
6148        protocol_players: dict[str, MockPlayer] = {}
6149        for protocol_id, domain in (("dlna_1", "dlna"), ("ap_1", "airplay"), ("spb_1", "sendspin")):
6150            protocol_player = MockPlayer(
6151                MockProvider(domain, mass=mock_mass),
6152                protocol_id,
6153                domain,
6154                player_type=PlayerType.PROTOCOL,
6155                identifiers={IdentifierType.MAC_ADDRESS: "AA:BB:CC:DD:EE:FF"},
6156            )
6157            protocol_player.set_initialized()
6158            protocol_players[protocol_id] = protocol_player
6159
6160        controller._players = {**players, **protocol_players}
6161        controller._add_protocol_link(players["up_old"], protocol_players["dlna_1"], "dlna")
6162        controller._add_protocol_link(players["up_new"], protocol_players["ap_1"], "airplay")
6163        controller._add_protocol_link(players["up_new"], protocol_players["spb_1"], "sendspin")
6164
6165        controller._check_merge_universal_players(players["up_new"])
6166
6167        domains = {link.protocol_domain for link in players["up_old"].linked_output_protocols}
6168        assert domains == {"dlna", "airplay", "sendspin"}
6169        assert protocol_players["ap_1"].protocol_parent_id == "up_old"
6170        assert len(players["up_new"].linked_output_protocols) == 0
6171
6172    @staticmethod
6173    def _setup_merge_tiebreak(
6174        mock_mass: MagicMock, created_at: dict[str, int]
6175    ) -> tuple[PlayerController, UniversalPlayer, UniversalPlayer, MockPlayer, MockPlayer]:
6176        """
6177        Set up two universal players of one device, each with a single protocol link.
6178
6179        :param mock_mass: The mocked MusicAssistant instance to wire.
6180        :param created_at: Stored creation timestamp per universal player id.
6181        :return: Tuple of (controller, up_aaa, up_bbb, its DLNA player, its AirPlay player).
6182        """
6183        controller = PlayerController(mock_mass)
6184        up_provider = create_mock_universal_provider(mock_mass)
6185        _wire_nested_config(
6186            mock_mass,
6187            {
6188                CONF_PLAYERS: {
6189                    player_id: {
6190                        "player_id": player_id,
6191                        "provider": "universal_player",
6192                        "enabled": True,
6193                        "values": {CONF_CREATED_AT: timestamp},
6194                    }
6195                    for player_id, timestamp in created_at.items()
6196                }
6197            },
6198        )
6199
6200        # up_a: lex-smaller player_id, one protocol link (DLNA)
6201        up_a = UniversalPlayer(
6202            provider=up_provider,
6203            player_id="up_aaa",
6204            name="Player A",
6205            device_info=DeviceInfo(model="Test", manufacturer="Test"),
6206            protocol_player_ids=["dlna_1"],
6207        )
6208        up_a._attr_device_info.add_identifier(IdentifierType.MAC_ADDRESS, "AA:BB:CC:DD:EE:FF")
6209        up_a._cache.clear()
6210        up_a.update_state(signal_event=False)
6211        up_a.set_initialized()
6212
6213        # up_b: lex-larger player_id, one protocol link (AirPlay)
6214        up_b = UniversalPlayer(
6215            provider=up_provider,
6216            player_id="up_bbb",
6217            name="Player B",
6218            device_info=DeviceInfo(model="Test", manufacturer="Test"),
6219            protocol_player_ids=["ap_1"],
6220        )
6221        up_b._attr_device_info.add_identifier(IdentifierType.MAC_ADDRESS, "AA:BB:CC:DD:EE:FF")
6222        up_b._cache.clear()
6223        up_b.update_state(signal_event=False)
6224        up_b.set_initialized()
6225
6226        # Create protocol players
6227        dlna_provider = MockProvider("dlna", mass=mock_mass)
6228        dlna_player = MockPlayer(
6229            dlna_provider,
6230            "dlna_1",
6231            "DLNA",
6232            player_type=PlayerType.PROTOCOL,
6233            identifiers={IdentifierType.MAC_ADDRESS: "AA:BB:CC:DD:EE:FF"},
6234        )
6235        dlna_player.set_initialized()
6236
6237        airplay_provider = MockProvider("airplay", mass=mock_mass)
6238        ap_player = MockPlayer(
6239            airplay_provider,
6240            "ap_1",
6241            "AirPlay",
6242            player_type=PlayerType.PROTOCOL,
6243            identifiers={IdentifierType.MAC_ADDRESS: "AA:BB:CC:DD:EE:FF"},
6244        )
6245        ap_player.set_initialized()
6246
6247        controller._players = {
6248            "up_aaa": up_a,
6249            "up_bbb": up_b,
6250            "dlna_1": dlna_player,
6251            "ap_1": ap_player,
6252        }
6253
6254        controller._add_protocol_link(up_a, dlna_player, "dlna")
6255        controller._add_protocol_link(up_b, ap_player, "airplay")
6256
6257        return controller, up_a, up_b, dlna_player, ap_player
6258
6259    def test_merge_deterministic_tiebreaker_on_equal_link_counts(
6260        self, mock_mass: MagicMock
6261    ) -> None:
6262        """Test that merges of equally old players resolve to the lex-smaller player_id."""
6263        controller, up_a, up_b, _, ap_player = self._setup_merge_tiebreak(
6264            mock_mass, {"up_aaa": 1000, "up_bbb": 1000}
6265        )
6266
6267        # Call merge from the lex-larger side. Under the old non-deterministic
6268        # behavior the caller (up_b) would have won. With the tiebreaker the
6269        # lex-smaller player_id (up_a) wins regardless of which side calls.
6270        controller._check_merge_universal_players(up_b)
6271
6272        # up_a (lex-smaller) should have absorbed up_b's AirPlay link
6273        protocol_domains = {link.protocol_domain for link in up_a.linked_output_protocols}
6274        assert protocol_domains == {"dlna", "airplay"}
6275
6276        # AirPlay player should now point to up_aaa
6277        assert ap_player.protocol_parent_id == "up_aaa"
6278
6279        # up_b should have no more links
6280        assert len(up_b.linked_output_protocols) == 0
6281
6282    def test_merge_tiebreaker_prefers_the_older_universal_player(
6283        self, mock_mass: MagicMock
6284    ) -> None:
6285        """Test that the player that has been around longest absorbs the other one."""
6286        controller, up_a, up_b, dlna_player, _ = self._setup_merge_tiebreak(
6287            mock_mass, {"up_aaa": 2000, "up_bbb": 1000}
6288        )
6289
6290        controller._check_merge_universal_players(up_a)
6291
6292        # up_b is older, so it wins despite its lex-larger player_id
6293        protocol_domains = {link.protocol_domain for link in up_b.linked_output_protocols}
6294        assert protocol_domains == {"dlna", "airplay"}
6295        assert dlna_player.protocol_parent_id == "up_bbb"
6296        assert len(up_a.linked_output_protocols) == 0
6297
6298    async def test_merge_preserves_moved_protocols_during_parent_cleanup(
6299        self, mock_mass: MagicMock
6300    ) -> None:
6301        """Moved protocol ownership must survive the removed parent's permanent cleanup."""
6302        controller = PlayerController(mock_mass)
6303        up_provider = create_mock_universal_provider(mock_mass)
6304
6305        config_store: dict[str, object] = {
6306            "players/up_keep": {"enabled": True},
6307            "players/up_remove": {"enabled": True},
6308            "players/ap_keep": {"enabled": True},
6309            "players/dlna_live": {"enabled": True},
6310            "players/dlna_cached": {
6311                "enabled": True,
6312                "provider": "dlna--test",
6313                "player_type": "protocol",
6314                "values": {"protocol_parent_id": "up_remove"},
6315            },
6316        }
6317
6318        def config_get(key: str, default: object = None) -> object:
6319            return config_store.get(key, default)
6320
6321        def config_set(key: str, value: object) -> None:
6322            config_store[key] = value
6323
6324        def config_remove(key: str) -> None:
6325            config_store.pop(key, None)
6326
6327        scheduled_tasks: list[Awaitable[object]] = []
6328
6329        def capture_task(task: Awaitable[object], *_args: Any, **_kwargs: Any) -> None:
6330            scheduled_tasks.append(task)
6331
6332        mock_mass.config.get = MagicMock(side_effect=config_get)
6333        mock_mass.config.set = MagicMock(side_effect=config_set)
6334        mock_mass.config.remove = MagicMock(side_effect=config_remove)
6335        mock_mass.players = controller
6336        mock_mass.player_queues = MagicMock()
6337        mock_mass.call_later = MagicMock()
6338        mock_mass.loop = MagicMock()
6339        mock_mass.create_task = MagicMock(side_effect=capture_task)
6340
6341        keep = UniversalPlayer(
6342            provider=up_provider,
6343            player_id="up_keep",
6344            name="Keep",
6345            device_info=DeviceInfo(model="Test", manufacturer="Test"),
6346            protocol_player_ids=["ap_keep"],
6347        )
6348        keep._attr_device_info.add_identifier(IdentifierType.MAC_ADDRESS, "AA:BB:CC:DD:EE:FF")
6349        keep._cache.clear()
6350        keep.update_state(signal_event=False)
6351        keep.set_initialized()
6352
6353        remove = UniversalPlayer(
6354            provider=up_provider,
6355            player_id="up_remove",
6356            name="Remove",
6357            device_info=DeviceInfo(model="Test", manufacturer="Test"),
6358            protocol_player_ids=["dlna_live", "dlna_cached"],
6359        )
6360        remove._attr_device_info.add_identifier(IdentifierType.MAC_ADDRESS, "AA:BB:CC:DD:EE:FF")
6361        remove._cache.clear()
6362        remove.update_state(signal_event=False)
6363        remove.set_initialized()
6364
6365        airplay_provider = MockProvider("airplay", mass=mock_mass)
6366        ap_keep = MockPlayer(
6367            airplay_provider,
6368            "ap_keep",
6369            "AirPlay",
6370            player_type=PlayerType.PROTOCOL,
6371            identifiers={IdentifierType.MAC_ADDRESS: "AA:BB:CC:DD:EE:FF"},
6372        )
6373        ap_keep.set_initialized()
6374
6375        dlna_provider = MockProvider("dlna", mass=mock_mass)
6376        dlna_live = MockPlayer(
6377            dlna_provider,
6378            "dlna_live",
6379            "DLNA",
6380            player_type=PlayerType.PROTOCOL,
6381            identifiers={IdentifierType.MAC_ADDRESS: "AA:BB:CC:DD:EE:FF"},
6382        )
6383        dlna_live.set_initialized()
6384
6385        controller._players = {
6386            "up_keep": keep,
6387            "up_remove": remove,
6388            "ap_keep": ap_keep,
6389            "dlna_live": dlna_live,
6390        }
6391
6392        controller._add_protocol_link(keep, ap_keep, "airplay")
6393        controller._add_protocol_link(remove, dlna_live, "dlna")
6394
6395        with patch.object(controller, "_save_universal_player_data"):
6396            controller._check_merge_universal_players(keep)
6397
6398        assert dlna_live.protocol_parent_id == "up_keep"
6399        assert config_store["players/dlna_cached/values/protocol_parent_id"] == "up_keep"
6400        unregister_tasks = [t for t in scheduled_tasks if "unregister" in repr(t)]
6401        assert len(unregister_tasks) == 1
6402
6403        await unregister_tasks[0]
6404
6405        assert "up_remove" not in controller._players
6406        assert dlna_live.protocol_parent_id == "up_keep"
6407        assert "dlna_cached" in keep._protocol_player_ids
6408        assert config_store["players/dlna_cached/values/protocol_parent_id"] == "up_keep"
6409
6410    def test_no_merge_same_domain_protocols_on_same_ip(self, mock_mass: MagicMock) -> None:
6411        """
6412        Test that UPs with same-domain protocols on the same IP are NOT merged.
6413
6414        Scenario: 3 squeezelite instances on the same VM (same IP, different real MACs).
6415        Each gets its own universal player. The merge check must NOT merge them,
6416        because merging would combine protocols from the same domain.
6417
6418        Reproduces: https://github.com/music-assistant/support/issues/5266
6419        """
6420        controller = PlayerController(mock_mass)
6421        up_provider = create_mock_universal_provider(mock_mass)
6422
6423        # Create 3 universal players, each with one squeezelite protocol
6424        squeeze_provider = MockProvider("squeezelite", mass=mock_mass)
6425        ups = []
6426        protocols = []
6427        for i in range(1, 4):
6428            mac = f"00:00:00:00:00:{i:02X}"
6429            up = UniversalPlayer(
6430                provider=up_provider,
6431                player_id=f"up00000000000{i}",
6432                name=f"Zone {i}",
6433                device_info=DeviceInfo(model="Squeezelite", manufacturer="Test"),
6434                protocol_player_ids=[mac],
6435            )
6436            up._attr_device_info.add_identifier(IdentifierType.MAC_ADDRESS, mac)
6437            up._attr_device_info.add_identifier(IdentifierType.IP_ADDRESS, "192.168.1.50")
6438            up._cache.clear()
6439            up.update_state(signal_event=False)
6440            up.set_initialized()
6441            ups.append(up)
6442
6443            proto = MockPlayer(
6444                squeeze_provider,
6445                mac,
6446                f"squeezeplay: {mac}",
6447                player_type=PlayerType.PROTOCOL,
6448                identifiers={
6449                    IdentifierType.MAC_ADDRESS: mac,
6450                    IdentifierType.IP_ADDRESS: "192.168.1.50",
6451                },
6452            )
6453            proto.set_initialized()
6454            protocols.append(proto)
6455
6456        controller._players = {}
6457        for up in ups:
6458            controller._players[up.player_id] = up
6459        for proto in protocols:
6460            controller._players[proto.player_id] = proto
6461
6462        # Link each protocol to its UP
6463        for up, proto in zip(ups, protocols, strict=True):
6464            controller._add_protocol_link(up, proto, "squeezelite")
6465
6466        # Verify initial state: each protocol linked to its own UP
6467        for up, proto in zip(ups, protocols, strict=True):
6468            assert proto.protocol_parent_id == up.player_id
6469
6470        # Now run merge check on each UP - none should merge
6471        with patch.object(controller, "_save_universal_player_data"):
6472            for up in ups:
6473                controller._check_merge_universal_players(up)
6474
6475        # All 3 UPs must still exist with their original protocol links
6476        for up, proto in zip(ups, protocols, strict=True):
6477            assert proto.protocol_parent_id == up.player_id, (
6478                f"Protocol {proto.player_id} should still be linked to {up.player_id}, "
6479                f"but is linked to {proto.protocol_parent_id}"
6480            )
6481            assert len(up.linked_output_protocols) == 1, (
6482                f"UP {up.player_id} should have exactly 1 protocol, "
6483                f"but has {len(up.linked_output_protocols)}"
6484            )
6485
6486    @staticmethod
6487    def _setup_merge_scenario(
6488        mock_mass: MagicMock, config_store: dict[str, Any]
6489    ) -> tuple[PlayerController, UniversalPlayer, list[Awaitable[object]]]:
6490        """
6491        Wire a merge scenario for the config carry-over tests.
6492
6493        Universal players 'up_keep' and 'up_remove' share a MAC; with equal
6494        link counts the deterministic tiebreaker keeps 'up_keep' and removes
6495        'up_remove', so 'up_remove' is the loser whose config is carried over.
6496        """
6497        controller = PlayerController(mock_mass)
6498        up_provider = create_mock_universal_provider(mock_mass)
6499
6500        def config_get(key: str, default: object = None) -> object:
6501            return config_store.get(key, default)
6502
6503        def config_set(key: str, value: object) -> None:
6504            config_store[key] = value
6505
6506        def config_remove(key: str) -> None:
6507            config_store.pop(key, None)
6508
6509        scheduled_tasks: list[Awaitable[object]] = []
6510
6511        def capture_task(task: Awaitable[object], *_args: Any, **_kwargs: Any) -> None:
6512            scheduled_tasks.append(task)
6513
6514        mock_mass.config.get = MagicMock(side_effect=config_get)
6515        mock_mass.config.set = MagicMock(side_effect=config_set)
6516        mock_mass.config.remove = MagicMock(side_effect=config_remove)
6517        mock_mass.players = controller
6518        mock_mass.player_queues = MagicMock()
6519        mock_mass.call_later = MagicMock()
6520        mock_mass.loop = MagicMock()
6521        mock_mass.create_task = MagicMock(side_effect=capture_task)
6522
6523        keep = UniversalPlayer(
6524            provider=up_provider,
6525            player_id="up_keep",
6526            name="Keep (Universal)",
6527            device_info=DeviceInfo(model="Test", manufacturer="Test"),
6528            protocol_player_ids=["ap_keep"],
6529        )
6530        keep._attr_device_info.add_identifier(IdentifierType.MAC_ADDRESS, "AA:BB:CC:DD:EE:FF")
6531        keep._cache.clear()
6532        keep.update_state(signal_event=False)
6533        keep.set_initialized()
6534
6535        remove = UniversalPlayer(
6536            provider=up_provider,
6537            player_id="up_remove",
6538            name="Remove (Universal)",
6539            device_info=DeviceInfo(model="Test", manufacturer="Test"),
6540            protocol_player_ids=["dlna_live"],
6541        )
6542        remove._attr_device_info.add_identifier(IdentifierType.MAC_ADDRESS, "AA:BB:CC:DD:EE:FF")
6543        remove._cache.clear()
6544        remove.update_state(signal_event=False)
6545        remove.set_initialized()
6546
6547        ap_keep = MockPlayer(
6548            MockProvider("airplay", mass=mock_mass),
6549            "ap_keep",
6550            "AirPlay",
6551            player_type=PlayerType.PROTOCOL,
6552            identifiers={IdentifierType.MAC_ADDRESS: "AA:BB:CC:DD:EE:FF"},
6553        )
6554        ap_keep.set_initialized()
6555
6556        dlna_live = MockPlayer(
6557            MockProvider("dlna", mass=mock_mass),
6558            "dlna_live",
6559            "DLNA",
6560            player_type=PlayerType.PROTOCOL,
6561            identifiers={IdentifierType.MAC_ADDRESS: "AA:BB:CC:DD:EE:FF"},
6562        )
6563        dlna_live.set_initialized()
6564
6565        controller._players = {
6566            "up_keep": keep,
6567            "up_remove": remove,
6568            "ap_keep": ap_keep,
6569            "dlna_live": dlna_live,
6570        }
6571        controller._add_protocol_link(keep, ap_keep, "airplay")
6572        controller._add_protocol_link(remove, dlna_live, "dlna")
6573        return controller, keep, scheduled_tasks
6574
6575    async def test_merge_carries_over_universal_config(self, mock_mass: MagicMock) -> None:
6576        """Merge migrates name, values, DSP and queue settings from loser to keeper."""
6577        config_store: dict[str, Any] = {
6578            "players/up_keep": {
6579                "enabled": True,
6580                "name": "Keep (Universal)",
6581                "default_name": "Keep (Universal)",
6582                "values": {"volume_control": "keep_ctrl"},
6583            },
6584            "players/up_remove": {
6585                "enabled": True,
6586                "name": "Living Room",
6587                "default_name": "Remove (Universal)",
6588                "values": {
6589                    "hide_in_ui": True,
6590                    "volume_control": "remove_ctrl",
6591                    "device_info": {"model": "Test", "manufacturer": "Test"},
6592                },
6593            },
6594            "players/ap_keep": {"enabled": True},
6595            "players/dlna_live": {"enabled": True},
6596            "player_dsp/up_remove": {"enabled": True, "filters": []},
6597            "player_queues/up_remove": {
6598                "queue_id": "up_remove",
6599                "values": {"crossfade_duration": 5},
6600            },
6601        }
6602        controller, keep, scheduled_tasks = self._setup_merge_scenario(mock_mass, config_store)
6603
6604        with patch.object(controller, "_save_universal_player_data"):
6605            controller._check_merge_universal_players(keep)
6606
6607        # the custom display name and non-conflicting values moved onto the keeper
6608        assert config_store["players/up_keep/name"] == "Living Room"
6609        assert config_store["players/up_keep/values/hide_in_ui"] is True
6610        # values the keeper has explicitly set are not overwritten
6611        assert "players/up_keep/values/volume_control" not in config_store
6612        # wrapper bookkeeping is not carried over
6613        assert "players/up_keep/values/device_info" not in config_store
6614        # DSP moved wholesale and queue settings moved and re-keyed onto the keeper
6615        assert config_store["player_dsp/up_keep"] == {"enabled": True, "filters": []}
6616        assert config_store["player_queues/up_keep"] == {
6617            "queue_id": "up_keep",
6618            "values": {"crossfade_duration": 5},
6619        }
6620        assert "player_queues/up_remove" not in config_store
6621        # the keeper's in-place config gets reloaded to apply the migrated values
6622        assert any("_reapply_player_config" in repr(t) for t in scheduled_tasks)
6623
6624        # and the losing universal player is still permanently removed
6625        unregister_tasks = [t for t in scheduled_tasks if "unregister" in repr(t)]
6626        assert len(unregister_tasks) == 1
6627        await unregister_tasks[0]
6628        assert "up_remove" not in controller._players
6629
6630    def test_merge_keeps_keeper_explicit_values(self, mock_mass: MagicMock) -> None:
6631        """Values explicitly set on the keeper win over the losing player's."""
6632        config_store: dict[str, Any] = {
6633            "players/up_keep": {
6634                "enabled": True,
6635                "name": "Kitchen",
6636                "default_name": "Keep (Universal)",
6637                "values": {"hide_in_ui": False},
6638            },
6639            "players/up_remove": {
6640                "enabled": True,
6641                "name": "Living Room",
6642                "default_name": "Remove (Universal)",
6643                "values": {"hide_in_ui": True, "flow_mode": True},
6644            },
6645            "players/ap_keep": {"enabled": True},
6646            "players/dlna_live": {"enabled": True},
6647            "player_dsp/up_keep": {"enabled": False, "filters": ["keep"]},
6648            "player_dsp/up_remove": {"enabled": True, "filters": ["remove"]},
6649        }
6650        controller, keep, _ = self._setup_merge_scenario(mock_mass, config_store)
6651
6652        with patch.object(controller, "_save_universal_player_data"):
6653            controller._check_merge_universal_players(keep)
6654
6655        # the keeper's own custom name and DSP config win
6656        assert "players/up_keep/name" not in config_store
6657        assert config_store["players/up_keep"]["name"] == "Kitchen"
6658        assert config_store["player_dsp/up_keep"] == {"enabled": False, "filters": ["keep"]}
6659        # the keeper's explicit value is not overwritten
6660        assert "players/up_keep/values/hide_in_ui" not in config_store
6661        # non-conflicting values still migrate
6662        assert config_store["players/up_keep/values/flow_mode"] is True
6663
6664    def test_merge_does_not_carry_over_the_creation_moment(self, mock_mass: MagicMock) -> None:
6665        """
6666        The absorbed player's creation moment stays behind.
6667
6668        It is bookkeeping of that wrapper alone; copying it onto the keeper would let a
6669        later merge discard the keeper's older - and therefore established - player id.
6670        """
6671        config_store: dict[str, Any] = {
6672            "players/up_keep": {
6673                "enabled": True,
6674                "name": "Keep (Universal)",
6675                "default_name": "Keep (Universal)",
6676                "values": {},
6677            },
6678            "players/up_remove": {
6679                "enabled": True,
6680                "name": "Remove (Universal)",
6681                "default_name": "Remove (Universal)",
6682                "values": {CONF_CREATED_AT: 5000},
6683            },
6684            "players/ap_keep": {"enabled": True},
6685            "players/dlna_live": {"enabled": True},
6686        }
6687        controller, keep, _ = self._setup_merge_scenario(mock_mass, config_store)
6688
6689        with patch.object(controller, "_save_universal_player_data"):
6690            controller._check_merge_universal_players(keep)
6691
6692        assert f"players/up_keep/values/{CONF_CREATED_AT}" not in config_store
6693        assert CONF_CREATED_AT not in config_store["players/up_keep"]["values"]
6694
6695    def test_merge_repoints_group_memberships(self, mock_mass: MagicMock) -> None:
6696        """Merge re-points group memberships from the removed wrapper to the keeper."""
6697        config_store: dict[str, Any] = {
6698            "players/up_keep": {
6699                "enabled": True,
6700                "name": "Keep (Universal)",
6701                "default_name": "Keep (Universal)",
6702                "values": {},
6703            },
6704            "players/up_remove": {
6705                "enabled": True,
6706                "name": "Remove (Universal)",
6707                "default_name": "Remove (Universal)",
6708                "values": {},
6709            },
6710            "players/ap_keep": {"enabled": True},
6711            "players/dlna_live": {"enabled": True},
6712            # nested players dict scanned by _update_group_memberships
6713            "players": {
6714                "group_1": {
6715                    "enabled": True,
6716                    "values": {
6717                        "group_members": ["up_remove", "other"],
6718                        "allowed_members": ["up_remove"],
6719                    },
6720                },
6721                "group_2": {
6722                    "enabled": True,
6723                    # keeper already listed -> no duplicate after re-point
6724                    "values": {"group_members": ["up_keep", "up_remove"]},
6725                },
6726            },
6727        }
6728        controller, keep, _ = self._setup_merge_scenario(mock_mass, config_store)
6729
6730        with patch.object(controller, "_save_universal_player_data"):
6731            controller._check_merge_universal_players(keep)
6732
6733        assert config_store["players/group_1/values/group_members"] == ["up_keep", "other"]
6734        assert config_store["players/group_1/values/allowed_members"] == ["up_keep"]
6735        # no duplicate when the keeper is already a member
6736        assert config_store["players/group_2/values/group_members"] == ["up_keep"]
6737
6738    async def test_merge_stops_playing_loser_before_unregister(self, mock_mass: MagicMock) -> None:
6739        """A playing losing wrapper is stopped before it is permanently removed."""
6740        config_store: dict[str, Any] = {
6741            "players/up_keep": {"enabled": True},
6742            "players/up_remove": {"enabled": True},
6743            "players/ap_keep": {"enabled": True},
6744            "players/dlna_live": {"enabled": True},
6745        }
6746        controller, keep, scheduled_tasks = self._setup_merge_scenario(mock_mass, config_store)
6747        controller._players["up_remove"]._attr_playback_state = PlaybackState.PLAYING
6748
6749        call_order: list[str] = []
6750        mock_mass.player_queues.stop = AsyncMock(
6751            side_effect=lambda *_a, **_k: call_order.append("stop")
6752        )
6753
6754        with patch.object(controller, "_save_universal_player_data"):
6755            controller._check_merge_universal_players(keep)
6756
6757        task = next(t for t in scheduled_tasks if "unregister" in repr(t))
6758
6759        def _record_unregister(*_a: Any, **_k: Any) -> None:
6760            call_order.append("unregister")
6761
6762        with patch.object(controller, "unregister", new=AsyncMock(side_effect=_record_unregister)):
6763            await task
6764
6765        assert call_order == ["stop", "unregister"]
6766        mock_mass.player_queues.stop.assert_awaited_once_with("up_remove")
6767
6768    async def test_merge_hands_the_user_filters_to_the_keeper(self, mock_mass: MagicMock) -> None:
6769        """The absorbed wrapper hands its access filter entries to the keeper."""
6770        config_store: dict[str, Any] = {
6771            "players/up_keep": {"enabled": True},
6772            "players/up_remove": {"enabled": True},
6773            "players/ap_keep": {"enabled": True},
6774            "players/dlna_live": {"enabled": True},
6775        }
6776        controller, keep, scheduled_tasks = self._setup_merge_scenario(mock_mass, config_store)
6777
6778        with patch.object(controller, "_save_universal_player_data"):
6779            controller._check_merge_universal_players(keep)
6780
6781        task = next(t for t in scheduled_tasks if "unregister" in repr(t))
6782        with patch.object(controller, "unregister", new=AsyncMock()) as mock_unregister:
6783            await task
6784
6785        mock_unregister.assert_awaited_once_with(
6786            "up_remove", permanent=True, replacement_player_id="up_keep"
6787        )
6788
6789    async def test_merge_idle_loser_not_stopped(self, mock_mass: MagicMock) -> None:
6790        """An idle losing wrapper is removed without a stop command."""
6791        config_store: dict[str, Any] = {
6792            "players/up_keep": {"enabled": True},
6793            "players/up_remove": {"enabled": True},
6794            "players/ap_keep": {"enabled": True},
6795            "players/dlna_live": {"enabled": True},
6796        }
6797        controller, keep, scheduled_tasks = self._setup_merge_scenario(mock_mass, config_store)
6798        mock_mass.player_queues.stop = AsyncMock()
6799
6800        with patch.object(controller, "_save_universal_player_data"):
6801            controller._check_merge_universal_players(keep)
6802
6803        task = next(t for t in scheduled_tasks if "unregister" in repr(t))
6804        await task
6805
6806        mock_mass.player_queues.stop.assert_not_awaited()
6807        assert "up_remove" not in controller._players
6808
6809
6810class TestUniversalPlayerReplacement:
6811    """Tests for replacing a universal player with a native player."""
6812
6813    @pytest.mark.parametrize(
6814        ("airplay_registered", "missing_protocol_id", "missing_domain", "missing_enabled"),
6815        [
6816            (True, "dlna_cached", "dlna", True),
6817            (True, "dlna_cached", "dlna", False),
6818            (False, "spb_cached", "sendspin", False),
6819        ],
6820    )
6821    async def test_replace_migrates_missing_cached_protocol_during_parent_cleanup(
6822        self,
6823        mock_mass: MagicMock,
6824        airplay_registered: bool,
6825        missing_protocol_id: str,
6826        missing_domain: str,
6827        missing_enabled: bool,
6828    ) -> None:
6829        """Missing cached protocol ownership must survive replacement and cleanup."""
6830        controller = PlayerController(mock_mass)
6831        up_provider = create_mock_universal_provider(mock_mass)
6832
6833        missing_values = {"protocol_parent_id": "up_old"}
6834        if missing_domain == "sendspin":
6835            missing_values[CONF_UNDERLYING_PLAYER_ID] = "ap_live"
6836        config_store: dict[str, Any] = {
6837            CONF_PLAYERS: {
6838                "sonos_1": {"enabled": True, "values": {}},
6839                "up_old": {"enabled": airplay_registered, "values": {}},
6840                "ap_live": {
6841                    "enabled": airplay_registered,
6842                    "provider": "airplay--test",
6843                    "player_type": "protocol",
6844                    "values": {"protocol_parent_id": "up_old"},
6845                },
6846                missing_protocol_id: {
6847                    "enabled": missing_enabled,
6848                    "provider": f"{missing_domain}--test",
6849                    "player_type": "protocol",
6850                    "values": missing_values,
6851                },
6852            }
6853        }
6854        _wire_nested_config(mock_mass, config_store)
6855
6856        scheduled_tasks: list[Awaitable[object]] = []
6857
6858        def capture_task(task: Awaitable[object], *_args: Any, **_kwargs: Any) -> None:
6859            scheduled_tasks.append(task)
6860
6861        mock_mass.players = controller
6862        mock_mass.player_queues = MagicMock()
6863        mock_mass.call_later = MagicMock()
6864        mock_mass.loop = MagicMock()
6865        mock_mass.create_task = MagicMock(side_effect=capture_task)
6866
6867        universal = UniversalPlayer(
6868            provider=up_provider,
6869            player_id="up_old",
6870            name="Old Universal",
6871            device_info=DeviceInfo(model="Test", manufacturer="Test"),
6872            protocol_player_ids=["ap_live", missing_protocol_id],
6873        )
6874        universal._attr_device_info.add_identifier(IdentifierType.MAC_ADDRESS, "AA:BB:CC:DD:EE:FF")
6875        universal._cache.clear()
6876        universal.update_state(signal_event=False)
6877
6878        native_provider = MockProvider("sonos", mass=mock_mass)
6879        native = MockPlayer(
6880            native_provider,
6881            "sonos_1",
6882            "Sonos",
6883            identifiers={IdentifierType.MAC_ADDRESS: "AA:BB:CC:DD:EE:FF"},
6884        )
6885        native.set_initialized()
6886
6887        airplay_provider = MockProvider("airplay", mass=mock_mass)
6888        ap_live = MockPlayer(
6889            airplay_provider,
6890            "ap_live",
6891            "AirPlay",
6892            player_type=PlayerType.PROTOCOL,
6893            identifiers={IdentifierType.MAC_ADDRESS: "AA:BB:CC:DD:EE:FF"},
6894        )
6895        ap_live.set_initialized()
6896
6897        controller._players = {"up_old": universal, "sonos_1": native}
6898        if airplay_registered:
6899            controller._players["ap_live"] = ap_live
6900            controller._add_protocol_link(universal, ap_live, "airplay")
6901        else:
6902            universal.set_linked_output_protocols(
6903                [
6904                    LinkedOutputProtocol(
6905                        output_protocol_id="ap_live",
6906                        protocol_domain="airplay",
6907                        priority=10,
6908                    )
6909                ]
6910            )
6911        universal.set_linked_output_protocols(
6912            [
6913                *universal.linked_output_protocols,
6914                LinkedOutputProtocol(
6915                    output_protocol_id=missing_protocol_id,
6916                    protocol_domain=missing_domain,
6917                    priority=20,
6918                    derived_from="ap_live" if missing_domain == "sendspin" else None,
6919                ),
6920            ]
6921        )
6922
6923        controller._check_replace_universal_player(native)
6924
6925        if airplay_registered:
6926            assert ap_live.protocol_parent_id == "sonos_1"
6927        else:
6928            assert (
6929                config_store[CONF_PLAYERS]["ap_live"]["values"][CONF_PROTOCOL_PARENT_ID]
6930                == "sonos_1"
6931            )
6932        assert set(config_store[CONF_PLAYERS]["sonos_1"]["values"]["linked_protocol_ids"]) == {
6933            "ap_live",
6934            missing_protocol_id,
6935        }
6936        assert (
6937            config_store[CONF_PLAYERS][missing_protocol_id]["values"][CONF_PROTOCOL_PARENT_ID]
6938            == "sonos_1"
6939        )
6940        unregister_tasks = [t for t in scheduled_tasks if "unregister" in repr(t)]
6941        assert len(unregister_tasks) == 1
6942
6943        await unregister_tasks[0]
6944
6945        assert "up_old" not in controller._players
6946        if airplay_registered:
6947            assert ap_live.protocol_parent_id == "sonos_1"
6948        else:
6949            assert (
6950                config_store[CONF_PLAYERS]["ap_live"]["values"][CONF_PROTOCOL_PARENT_ID]
6951                == "sonos_1"
6952            )
6953        assert (
6954            config_store[CONF_PLAYERS][missing_protocol_id]["values"][CONF_PROTOCOL_PARENT_ID]
6955            == "sonos_1"
6956        )
6957        assert set(config_store[CONF_PLAYERS]["sonos_1"]["values"]["linked_protocol_ids"]) == {
6958            "ap_live",
6959            missing_protocol_id,
6960        }
6961
6962    def test_replace_keeps_universal_when_all_links_refused(self, mock_mass: MagicMock) -> None:
6963        """
6964        Keep the universal player when no link could migrate.
6965
6966        If the native player already has an active link from the same protocol
6967        domain as every protocol on the universal player, each `_add_protocol_link`
6968        call refuses and leaves the protocol's parent unchanged. The universal player
6969        must NOT be permanently unregistered in that case, otherwise it disappears
6970        from the UI on restart.
6971        """
6972        controller = PlayerController(mock_mass)
6973        up_provider = create_mock_universal_provider(mock_mass)
6974
6975        config_store: dict[str, object] = {
6976            "players/cast_1": {"enabled": True},
6977            "players/up_old": {"enabled": True},
6978            "players/spb_old": {"enabled": True},
6979            "players/spb_new": {"enabled": True},
6980        }
6981
6982        def config_get(key: str, default: object = None) -> object:
6983            return config_store.get(key, default)
6984
6985        def config_set(key: str, value: object) -> None:
6986            config_store[key] = value
6987
6988        def config_remove(key: str) -> None:
6989            config_store.pop(key, None)
6990
6991        scheduled_tasks: list[Awaitable[object]] = []
6992
6993        def capture_task(task: Awaitable[object], *_args: Any, **_kwargs: Any) -> None:
6994            scheduled_tasks.append(task)
6995
6996        mock_mass.config.get = MagicMock(side_effect=config_get)
6997        mock_mass.config.set = MagicMock(side_effect=config_set)
6998        mock_mass.config.remove = MagicMock(side_effect=config_remove)
6999        mock_mass.players = controller
7000        mock_mass.player_queues = MagicMock()
7001        mock_mass.call_later = MagicMock()
7002        mock_mass.loop = MagicMock()
7003        mock_mass.create_task = MagicMock(side_effect=capture_task)
7004
7005        universal = UniversalPlayer(
7006            provider=up_provider,
7007            player_id="up_old",
7008            name="Soundbar (Universal)",
7009            device_info=DeviceInfo(model="Test", manufacturer="Test"),
7010            protocol_player_ids=["spb_old"],
7011        )
7012        universal._attr_device_info.add_identifier(IdentifierType.MAC_ADDRESS, "AA:BB:CC:DD:EE:FF")
7013        universal._cache.clear()
7014        universal.update_state(signal_event=False)
7015        universal.set_initialized()
7016
7017        cast_provider = MockProvider("cast", mass=mock_mass)
7018        native = MockPlayer(
7019            cast_provider,
7020            "cast_1",
7021            "Soundbar",
7022            identifiers={IdentifierType.MAC_ADDRESS: "AA:BB:CC:DD:EE:FF"},
7023        )
7024        native.set_initialized()
7025
7026        sendspin_provider = MockProvider("sendspin", mass=mock_mass)
7027        spb_old = MockPlayer(
7028            sendspin_provider,
7029            "spb_old",
7030            "Soundbar (Sendspin)",
7031            player_type=PlayerType.PROTOCOL,
7032            identifiers={IdentifierType.MAC_ADDRESS: "AA:BB:CC:DD:EE:FF"},
7033        )
7034        spb_old.set_initialized()
7035        spb_new = MockPlayer(
7036            sendspin_provider,
7037            "spb_new",
7038            "Soundbar (Sendspin v2)",
7039            player_type=PlayerType.PROTOCOL,
7040            identifiers={IdentifierType.MAC_ADDRESS: "AA:BB:CC:DD:EE:FF"},
7041        )
7042        spb_new.set_initialized()
7043
7044        controller._players = {
7045            "up_old": universal,
7046            "cast_1": native,
7047            "spb_old": spb_old,
7048            "spb_new": spb_new,
7049        }
7050
7051        controller._add_protocol_link(universal, spb_old, "sendspin")
7052        controller._add_protocol_link(native, spb_new, "sendspin")
7053
7054        controller._check_replace_universal_player(native)
7055
7056        unregister_tasks = [t for t in scheduled_tasks if "unregister" in repr(t)]
7057        assert unregister_tasks == []
7058        assert "up_old" in controller._players
7059        assert spb_old.protocol_parent_id == "up_old"
7060        assert any(
7061            link.output_protocol_id == "spb_old" for link in universal.linked_output_protocols
7062        )
7063
7064    def test_replace_keeps_universal_when_some_links_refused(self, mock_mass: MagicMock) -> None:
7065        """
7066        Keep the universal player when only some of its links could migrate.
7067
7068        The native player already owns the sendspin domain, so the universal player's
7069        sendspin link is refused while its airplay link migrates. The universal player
7070        must be kept (holding the refused link) instead of being unregistered, which
7071        would orphan the refused protocol during cleanup.
7072        """
7073        controller = PlayerController(mock_mass)
7074        up_provider = create_mock_universal_provider(mock_mass)
7075
7076        config_store: dict[str, object] = {
7077            "players/cast_1": {"enabled": True},
7078            "players/up_old": {"enabled": True},
7079            "players/spb_old": {"enabled": True},
7080            "players/spb_new": {"enabled": True},
7081            "players/ap_old": {"enabled": True},
7082        }
7083
7084        def config_get(key: str, default: object = None) -> object:
7085            return config_store.get(key, default)
7086
7087        def config_set(key: str, value: object) -> None:
7088            config_store[key] = value
7089
7090        def config_remove(key: str) -> None:
7091            config_store.pop(key, None)
7092
7093        scheduled_tasks: list[Awaitable[object]] = []
7094
7095        def capture_task(task: Awaitable[object], *_args: Any, **_kwargs: Any) -> None:
7096            scheduled_tasks.append(task)
7097
7098        mock_mass.config.get = MagicMock(side_effect=config_get)
7099        mock_mass.config.set = MagicMock(side_effect=config_set)
7100        mock_mass.config.remove = MagicMock(side_effect=config_remove)
7101        mock_mass.players = controller
7102        mock_mass.player_queues = MagicMock()
7103        mock_mass.call_later = MagicMock()
7104        mock_mass.loop = MagicMock()
7105        mock_mass.create_task = MagicMock(side_effect=capture_task)
7106
7107        universal = UniversalPlayer(
7108            provider=up_provider,
7109            player_id="up_old",
7110            name="Soundbar (Universal)",
7111            device_info=DeviceInfo(model="Test", manufacturer="Test"),
7112            protocol_player_ids=["spb_old", "ap_old"],
7113        )
7114        universal._attr_device_info.add_identifier(IdentifierType.MAC_ADDRESS, "AA:BB:CC:DD:EE:FF")
7115        universal._cache.clear()
7116        universal.update_state(signal_event=False)
7117        universal.set_initialized()
7118
7119        cast_provider = MockProvider("cast", mass=mock_mass)
7120        native = MockPlayer(
7121            cast_provider,
7122            "cast_1",
7123            "Soundbar",
7124            identifiers={IdentifierType.MAC_ADDRESS: "AA:BB:CC:DD:EE:FF"},
7125        )
7126        native.set_initialized()
7127
7128        sendspin_provider = MockProvider("sendspin", mass=mock_mass)
7129        spb_old = MockPlayer(
7130            sendspin_provider,
7131            "spb_old",
7132            "Soundbar (Sendspin)",
7133            player_type=PlayerType.PROTOCOL,
7134            identifiers={IdentifierType.MAC_ADDRESS: "AA:BB:CC:DD:EE:FF"},
7135        )
7136        spb_old.set_initialized()
7137        spb_new = MockPlayer(
7138            sendspin_provider,
7139            "spb_new",
7140            "Soundbar (Sendspin v2)",
7141            player_type=PlayerType.PROTOCOL,
7142            identifiers={IdentifierType.MAC_ADDRESS: "AA:BB:CC:DD:EE:FF"},
7143        )
7144        spb_new.set_initialized()
7145
7146        airplay_provider = MockProvider("airplay", mass=mock_mass)
7147        ap_old = MockPlayer(
7148            airplay_provider,
7149            "ap_old",
7150            "Soundbar (AirPlay)",
7151            player_type=PlayerType.PROTOCOL,
7152            identifiers={IdentifierType.MAC_ADDRESS: "AA:BB:CC:DD:EE:FF"},
7153        )
7154        ap_old.set_initialized()
7155
7156        controller._players = {
7157            "up_old": universal,
7158            "cast_1": native,
7159            "spb_old": spb_old,
7160            "spb_new": spb_new,
7161            "ap_old": ap_old,
7162        }
7163
7164        controller._add_protocol_link(universal, spb_old, "sendspin")
7165        controller._add_protocol_link(universal, ap_old, "airplay")
7166        controller._add_protocol_link(native, spb_new, "sendspin")
7167
7168        controller._check_replace_universal_player(native)
7169
7170        unregister_tasks = [t for t in scheduled_tasks if "unregister" in repr(t)]
7171        assert unregister_tasks == []
7172        assert "up_old" in controller._players
7173        # The refused sendspin link stays owned by the universal player.
7174        assert spb_old.protocol_parent_id == "up_old"
7175        assert any(
7176            link.output_protocol_id == "spb_old" for link in universal.linked_output_protocols
7177        )
7178        # The airplay link migrated to the native player and left the universal player.
7179        assert ap_old.protocol_parent_id == "cast_1"
7180        assert any(link.output_protocol_id == "ap_old" for link in native.linked_output_protocols)
7181        assert all(
7182            link.output_protocol_id != "ap_old" for link in universal.linked_output_protocols
7183        )
7184
7185    def test_replace_keeps_cached_protocols_when_a_link_is_refused(
7186        self, mock_mass: MagicMock
7187    ) -> None:
7188        """
7189        Keep cached-only protocol ownership on a universal player that survives.
7190
7191        The native player refuses the sendspin link, so the universal player stays in
7192        charge. Ownership that only exists in config must not be handed over, so a
7193        protocol derived from a refused one keeps pointing at the parent it will link to.
7194        """
7195        controller = PlayerController(mock_mass)
7196        up_provider = create_mock_universal_provider(mock_mass)
7197
7198        config_store: dict[str, Any] = {
7199            "players/cast_1": {"enabled": True},
7200            "players/up_old": {"enabled": True},
7201            "players/spb_old": {"enabled": True},
7202            "players/spb_new": {"enabled": True},
7203            "players/ap_old": {"enabled": True},
7204            "players/dlna_cached": {"enabled": False},
7205        }
7206        mock_mass.config.get = MagicMock(side_effect=config_store.get)
7207        mock_mass.config.set = MagicMock(side_effect=config_store.__setitem__)
7208        mock_mass.config.remove = MagicMock(side_effect=lambda key: config_store.pop(key, None))
7209        mock_mass.players = controller
7210        mock_mass.player_queues = MagicMock()
7211        mock_mass.call_later = MagicMock()
7212        mock_mass.loop = MagicMock()
7213        mock_mass.create_task = MagicMock()
7214
7215        identifiers = {IdentifierType.MAC_ADDRESS: "AA:BB:CC:DD:EE:FF"}
7216        universal = UniversalPlayer(
7217            provider=up_provider,
7218            player_id="up_old",
7219            name="Soundbar (Universal)",
7220            device_info=DeviceInfo(model="Test", manufacturer="Test"),
7221            protocol_player_ids=["spb_old", "ap_old", "dlna_cached"],
7222        )
7223        universal._attr_device_info.add_identifier(IdentifierType.MAC_ADDRESS, "AA:BB:CC:DD:EE:FF")
7224        universal._cache.clear()
7225        universal.update_state(signal_event=False)
7226        universal.set_initialized()
7227
7228        native = MockPlayer(
7229            MockProvider("cast", mass=mock_mass), "cast_1", "Soundbar", identifiers=identifiers
7230        )
7231        native.set_initialized()
7232        sendspin_provider = MockProvider("sendspin", mass=mock_mass)
7233        spb_old = MockPlayer(
7234            sendspin_provider,
7235            "spb_old",
7236            "Soundbar (Sendspin)",
7237            player_type=PlayerType.PROTOCOL,
7238            identifiers=identifiers,
7239        )
7240        spb_old.set_initialized()
7241        spb_new = MockPlayer(
7242            sendspin_provider,
7243            "spb_new",
7244            "Soundbar (Sendspin v2)",
7245            player_type=PlayerType.PROTOCOL,
7246            identifiers=identifiers,
7247        )
7248        spb_new.set_initialized()
7249        ap_old = MockPlayer(
7250            MockProvider("airplay", mass=mock_mass),
7251            "ap_old",
7252            "Soundbar (AirPlay)",
7253            player_type=PlayerType.PROTOCOL,
7254            identifiers=identifiers,
7255        )
7256        ap_old.set_initialized()
7257
7258        controller._players = {
7259            "up_old": universal,
7260            "cast_1": native,
7261            "spb_old": spb_old,
7262            "spb_new": spb_new,
7263            "ap_old": ap_old,
7264        }
7265        controller._add_protocol_link(universal, spb_old, "sendspin")
7266        controller._add_protocol_link(universal, ap_old, "airplay")
7267        controller._add_protocol_link(native, spb_new, "sendspin")
7268
7269        controller._check_replace_universal_player(native)
7270
7271        # the registered airplay link moved, the refused sendspin link did not
7272        assert ap_old.protocol_parent_id == "cast_1"
7273        assert spb_old.protocol_parent_id == "up_old"
7274        # the cached-only protocol stays with the universal player that remains in charge
7275        assert "dlna_cached" in universal._protocol_player_ids
7276        assert "dlna_cached" not in (
7277            config_store.get("players/cast_1/values/linked_protocol_ids") or []
7278        )
7279        assert config_store.get("players/dlna_cached/values/protocol_parent_id") != "cast_1"
7280
7281    @staticmethod
7282    def _setup_carry_over_scenario(
7283        mock_mass: MagicMock, config_store: dict[str, Any]
7284    ) -> tuple[PlayerController, MockPlayer, list[Awaitable[object]]]:
7285        """
7286        Wire a replacement scenario for the config carry-over tests.
7287
7288        Universal player 'up_old' (holding protocol player 'ap_live') is
7289        replaceable by native player 'cast_1'.
7290        """
7291        controller = PlayerController(mock_mass)
7292        up_provider = create_mock_universal_provider(mock_mass)
7293
7294        def config_get(key: str, default: object = None) -> object:
7295            return config_store.get(key, default)
7296
7297        def config_set(key: str, value: object) -> None:
7298            config_store[key] = value
7299
7300        def config_remove(key: str) -> None:
7301            config_store.pop(key, None)
7302
7303        scheduled_tasks: list[Awaitable[object]] = []
7304
7305        def capture_task(task: Awaitable[object], *_args: Any, **_kwargs: Any) -> None:
7306            scheduled_tasks.append(task)
7307
7308        mock_mass.config.get = MagicMock(side_effect=config_get)
7309        mock_mass.config.set = MagicMock(side_effect=config_set)
7310        mock_mass.config.remove = MagicMock(side_effect=config_remove)
7311        mock_mass.players = controller
7312        mock_mass.player_queues = MagicMock()
7313        mock_mass.player_queues.get.return_value = None
7314        mock_mass.call_later = MagicMock()
7315        mock_mass.loop = MagicMock()
7316        mock_mass.create_task = MagicMock(side_effect=capture_task)
7317
7318        universal = UniversalPlayer(
7319            provider=up_provider,
7320            player_id="up_old",
7321            name="Soundbar (Universal)",
7322            device_info=DeviceInfo(model="Test", manufacturer="Test"),
7323            protocol_player_ids=["ap_live"],
7324        )
7325        universal._attr_device_info.add_identifier(IdentifierType.MAC_ADDRESS, "AA:BB:CC:DD:EE:FF")
7326        universal._cache.clear()
7327        universal.update_state(signal_event=False)
7328        universal.set_initialized()
7329
7330        cast_provider = MockProvider("cast", mass=mock_mass)
7331        native = MockPlayer(
7332            cast_provider,
7333            "cast_1",
7334            "Soundbar",
7335            identifiers={IdentifierType.MAC_ADDRESS: "AA:BB:CC:DD:EE:FF"},
7336        )
7337        native.set_initialized()
7338
7339        airplay_provider = MockProvider("airplay", mass=mock_mass)
7340        ap_live = MockPlayer(
7341            airplay_provider,
7342            "ap_live",
7343            "Soundbar (AirPlay)",
7344            player_type=PlayerType.PROTOCOL,
7345            identifiers={IdentifierType.MAC_ADDRESS: "AA:BB:CC:DD:EE:FF"},
7346        )
7347        ap_live.set_initialized()
7348
7349        controller._players = {"up_old": universal, "cast_1": native, "ap_live": ap_live}
7350        controller._add_protocol_link(universal, ap_live, "airplay")
7351        return controller, native, scheduled_tasks
7352
7353    async def test_replace_carries_over_universal_config(self, mock_mass: MagicMock) -> None:
7354        """Replacement migrates name, values, DSP and queue settings to the native player."""
7355        config_store: dict[str, Any] = {
7356            # fresh native configs store name == default_name; that must not
7357            # count as a user override blocking the name carry-over
7358            "players/cast_1": {
7359                "enabled": True,
7360                "name": "Soundbar",
7361                "default_name": "Soundbar",
7362                "values": {"volume_control": "native"},
7363            },
7364            "players/up_old": {
7365                "enabled": True,
7366                "name": "Living Room",
7367                "default_name": "Soundbar (Universal)",
7368                "values": {
7369                    "hide_in_ui": True,
7370                    "volume_control": "up_volume_control",
7371                    "linked_protocol_ids": ["ap_live", "ghost_proto"],
7372                    "device_identifiers": {"mac_address": "AA:BB:CC:DD:EE:FF"},
7373                    "device_info": {"model": "Test", "manufacturer": "Test"},
7374                    "ap_live||protocol||output_codec": "flac",
7375                },
7376            },
7377            "players/ap_live": {"enabled": True},
7378            "player_dsp/up_old": {"enabled": True, "filters": []},
7379            "player_queues/up_old": {"queue_id": "up_old", "values": {"crossfade_duration": 5}},
7380        }
7381        controller, native, scheduled_tasks = self._setup_carry_over_scenario(
7382            mock_mass, config_store
7383        )
7384
7385        controller._check_replace_universal_player(native)
7386
7387        # the custom display name and user-set values moved onto the native player
7388        assert config_store["players/cast_1/name"] == "Living Room"
7389        assert config_store["players/cast_1/values/hide_in_ui"] is True
7390        # values the native player has explicitly set are not overwritten
7391        assert "players/cast_1/values/volume_control" not in config_store
7392        # wrapper bookkeeping and protocol-mirror values are not carried over
7393        assert "players/cast_1/values/device_identifiers" not in config_store
7394        assert "players/cast_1/values/device_info" not in config_store
7395        assert "players/cast_1/values/ap_live||protocol||output_codec" not in config_store
7396        # linked ids come from the protocol move machinery, not the universal's raw list
7397        assert config_store["players/cast_1/values/linked_protocol_ids"] == ["ap_live"]
7398        # DSP settings moved wholesale
7399        assert config_store["player_dsp/cast_1"] == {"enabled": True, "filters": []}
7400        # queue settings moved and re-keyed onto the native player's queue
7401        assert config_store["player_queues/cast_1"] == {
7402            "queue_id": "cast_1",
7403            "values": {"crossfade_duration": 5},
7404        }
7405        assert "player_queues/up_old" not in config_store
7406        # the native player's in-place config gets reloaded to apply the new values
7407        assert any("_reapply_player_config" in repr(t) for t in scheduled_tasks)
7408
7409        # and the universal player is still permanently removed
7410        unregister_tasks = [t for t in scheduled_tasks if "unregister" in repr(t)]
7411        assert len(unregister_tasks) == 1
7412        await unregister_tasks[0]
7413        assert "up_old" not in controller._players
7414        # migrated values survive the permanent cleanup of the universal player
7415        assert config_store["players/cast_1/name"] == "Living Room"
7416        assert "players/up_old" not in config_store
7417        assert "player_dsp/up_old" not in config_store
7418
7419    def test_replace_carries_over_nothing_without_user_config(self, mock_mass: MagicMock) -> None:
7420        """A non-customized universal player migrates nothing (no default-name carry)."""
7421        config_store: dict[str, Any] = {
7422            "players/cast_1": {"enabled": True},
7423            "players/up_old": {
7424                "enabled": True,
7425                "name": "Soundbar (Universal)",
7426                "default_name": "Soundbar (Universal)",
7427                "values": {"linked_protocol_ids": ["ap_live"]},
7428            },
7429            "players/ap_live": {"enabled": True},
7430        }
7431        controller, native, scheduled_tasks = self._setup_carry_over_scenario(
7432            mock_mass, config_store
7433        )
7434
7435        controller._check_replace_universal_player(native)
7436
7437        assert "players/cast_1/name" not in config_store
7438        assert "player_dsp/cast_1" not in config_store
7439        assert "player_queues/cast_1" not in config_store
7440        # nothing migrated, so no config reload is scheduled
7441        assert not any("_reapply_player_config" in repr(t) for t in scheduled_tasks)
7442        assert any("unregister" in repr(t) for t in scheduled_tasks)
7443
7444    def test_replace_keeps_native_name_dsp_and_queue_values(self, mock_mass: MagicMock) -> None:
7445        """Values explicitly set on the native player win over the universal player's."""
7446        config_store: dict[str, Any] = {
7447            "players/cast_1": {"enabled": True, "name": "Kitchen", "default_name": "Soundbar"},
7448            "players/up_old": {
7449                "enabled": True,
7450                "name": "Living Room",
7451                "default_name": "Soundbar (Universal)",
7452                "values": {"hide_in_ui": True},
7453            },
7454            "players/ap_live": {"enabled": True},
7455            "player_dsp/cast_1": {"enabled": False, "filters": ["native"]},
7456            "player_dsp/up_old": {"enabled": True, "filters": ["universal"]},
7457            "player_queues/cast_1": {"queue_id": "cast_1", "values": {"crossfade_duration": 2}},
7458            "player_queues/up_old": {
7459                "queue_id": "up_old",
7460                "values": {"crossfade_duration": 8, "autoplay_mode": "smart"},
7461            },
7462        }
7463        controller, native, _ = self._setup_carry_over_scenario(mock_mass, config_store)
7464
7465        controller._check_replace_universal_player(native)
7466
7467        # the native player's own custom name and DSP config win
7468        assert "players/cast_1/name" not in config_store
7469        assert config_store["players/cast_1"]["name"] == "Kitchen"
7470        assert config_store["player_dsp/cast_1"] == {"enabled": False, "filters": ["native"]}
7471        # queue values merge without overwriting the native queue's own values
7472        assert config_store["player_queues/cast_1"] == {
7473            "queue_id": "cast_1",
7474            "values": {"crossfade_duration": 2, "autoplay_mode": "smart"},
7475        }
7476        assert "player_queues/up_old" not in config_store
7477        # non-conflicting values still migrate
7478        assert config_store["players/cast_1/values/hide_in_ui"] is True
7479
7480    async def test_reapply_player_config_refreshes_native_player(
7481        self, mock_mass: MagicMock
7482    ) -> None:
7483        """Reloading the config applies a migrated custom name to the live player."""
7484        controller = PlayerController(mock_mass)
7485        mock_mass.players = controller
7486        mock_mass.player_queues = MagicMock()
7487        mock_mass.call_later = MagicMock()
7488        provider = MockProvider("cast", mass=mock_mass)
7489        native = MockPlayer(provider, "cast_1", "Soundbar")
7490        native.set_initialized()
7491        controller._players = {"cast_1": native}
7492
7493        new_config = PlayerConfig.parse(
7494            [],
7495            {
7496                "player_id": "cast_1",
7497                "provider": "cast",
7498                "name": "Living Room",
7499                "default_name": "Soundbar",
7500            },
7501        )
7502        mock_mass.config.get_player_config = AsyncMock(return_value=new_config)
7503
7504        await controller._reapply_player_config("cast_1")
7505
7506        assert native.config is new_config
7507        assert native.display_name == "Living Room"
7508        signaled_events = [call.args[0] for call in mock_mass.signal_event.call_args_list]
7509        assert EventType.PLAYER_CONFIG_UPDATED in signaled_events
7510
7511    async def test_replace_repoints_group_memberships(self, mock_mass: MagicMock) -> None:
7512        """Replacement re-points group memberships from the wrapper to the native player."""
7513        config_store: dict[str, Any] = {
7514            "players/cast_1": {"enabled": True},
7515            "players/up_old": {
7516                "enabled": True,
7517                "name": "Soundbar (Universal)",
7518                "default_name": "Soundbar (Universal)",
7519                "values": {"linked_protocol_ids": ["ap_live"]},
7520            },
7521            "players/ap_live": {"enabled": True},
7522            # nested players dict scanned by _update_group_memberships
7523            "players": {
7524                "group_1": {
7525                    "enabled": True,
7526                    "values": {
7527                        "group_members": ["up_old", "other"],
7528                        "allowed_members": ["up_old"],
7529                    },
7530                },
7531                "group_2": {
7532                    "enabled": True,
7533                    # native id already listed -> no duplicate after re-point
7534                    "values": {"group_members": ["up_old", "cast_1", "extra"]},
7535                },
7536            },
7537        }
7538        controller, native, scheduled_tasks = self._setup_carry_over_scenario(
7539            mock_mass, config_store
7540        )
7541        group_1 = MockPlayer(MockProvider("player_group", mass=mock_mass), "group_1", "Group")
7542        group_1.set_initialized()
7543        controller._players["group_1"] = group_1
7544
7545        with patch.object(group_1, "refresh_state") as mock_refresh:
7546            controller._check_replace_universal_player(native)
7547            for task in [t for t in scheduled_tasks if "_reload_group_members" in repr(t)]:
7548                await task
7549
7550        # the wrapper id is replaced by the native id on both membership keys
7551        assert config_store["players/group_1/values/group_members"] == ["cast_1", "other"]
7552        assert config_store["players/group_1/values/allowed_members"] == ["cast_1"]
7553        # no duplicate when the native id is already a member
7554        assert config_store["players/group_2/values/group_members"] == ["cast_1", "extra"]
7555        # a registered player whose membership changed re-reads it and is refreshed
7556        mock_refresh.assert_called()
7557
7558    async def test_replace_stops_playing_universal_before_unregister(
7559        self, mock_mass: MagicMock
7560    ) -> None:
7561        """A playing universal player is stopped before it is permanently removed."""
7562        config_store: dict[str, Any] = {
7563            "players/cast_1": {"enabled": True},
7564            "players/up_old": {
7565                "enabled": True,
7566                "name": "Soundbar (Universal)",
7567                "default_name": "Soundbar (Universal)",
7568                "values": {"linked_protocol_ids": ["ap_live"]},
7569            },
7570            "players/ap_live": {"enabled": True},
7571        }
7572        controller, native, scheduled_tasks = self._setup_carry_over_scenario(
7573            mock_mass, config_store
7574        )
7575        controller._players["up_old"]._attr_playback_state = PlaybackState.PLAYING
7576
7577        call_order: list[str] = []
7578        mock_mass.player_queues.stop = AsyncMock(
7579            side_effect=lambda *_a, **_k: call_order.append("stop")
7580        )
7581
7582        controller._check_replace_universal_player(native)
7583        task = next(t for t in scheduled_tasks if "unregister" in repr(t))
7584
7585        def _record_unregister(*_a: Any, **_k: Any) -> None:
7586            call_order.append("unregister")
7587
7588        with patch.object(controller, "unregister", new=AsyncMock(side_effect=_record_unregister)):
7589            await task
7590
7591        assert call_order == ["stop", "unregister"]
7592        mock_mass.player_queues.stop.assert_awaited_once_with("up_old")
7593
7594    async def test_replace_hands_the_user_filters_to_the_native_player(
7595        self, mock_mass: MagicMock
7596    ) -> None:
7597        """The replaced wrapper hands its access filter entries to the native player."""
7598        config_store: dict[str, Any] = {
7599            "players/cast_1": {"enabled": True},
7600            "players/up_old": {
7601                "enabled": True,
7602                "name": "Soundbar (Universal)",
7603                "default_name": "Soundbar (Universal)",
7604                "values": {"linked_protocol_ids": ["ap_live"]},
7605            },
7606            "players/ap_live": {"enabled": True},
7607        }
7608        controller, native, scheduled_tasks = self._setup_carry_over_scenario(
7609            mock_mass, config_store
7610        )
7611
7612        controller._check_replace_universal_player(native)
7613
7614        task = next(t for t in scheduled_tasks if "unregister" in repr(t))
7615        with patch.object(controller, "unregister", new=AsyncMock()) as mock_unregister:
7616            await task
7617
7618        mock_unregister.assert_awaited_once_with(
7619            "up_old", permanent=True, replacement_player_id="cast_1"
7620        )
7621
7622    async def test_replace_idle_universal_not_stopped(self, mock_mass: MagicMock) -> None:
7623        """An idle universal player is removed without a stop command."""
7624        config_store: dict[str, Any] = {
7625            "players/cast_1": {"enabled": True},
7626            "players/up_old": {
7627                "enabled": True,
7628                "name": "Soundbar (Universal)",
7629                "default_name": "Soundbar (Universal)",
7630                "values": {"linked_protocol_ids": ["ap_live"]},
7631            },
7632            "players/ap_live": {"enabled": True},
7633        }
7634        controller, native, scheduled_tasks = self._setup_carry_over_scenario(
7635            mock_mass, config_store
7636        )
7637        mock_mass.player_queues.stop = AsyncMock()
7638
7639        controller._check_replace_universal_player(native)
7640        task = next(t for t in scheduled_tasks if "unregister" in repr(t))
7641        await task
7642
7643        mock_mass.player_queues.stop.assert_not_awaited()
7644        assert "up_old" not in controller._players
7645
7646    async def test_provider_discovery_rechecks_registered_native_players(
7647        self, mock_mass: MagicMock
7648    ) -> None:
7649        """A native player that registered before its wrapper was restored is promoted."""
7650        controller = PlayerController(mock_mass)
7651        config_store: dict[str, Any] = {
7652            CONF_PLAYERS: {
7653                "sonos_1": {"enabled": True, "values": {}},
7654                "up_old": {"enabled": True, "provider": "universal_player", "values": {}},
7655                "ap_live": {
7656                    "enabled": True,
7657                    "provider": "airplay--test",
7658                    "player_type": "protocol",
7659                    "values": {CONF_PROTOCOL_PARENT_ID: "up_old"},
7660                },
7661            }
7662        }
7663        _wire_nested_config(mock_mass, config_store)
7664        mock_mass.players = controller
7665        mock_mass.player_queues = MagicMock()
7666        mock_mass.create_task = MagicMock()
7667        mock_mass.config.get_player_configs = AsyncMock(return_value=[])
7668
7669        provider = create_mock_universal_provider(mock_mass)
7670        identifiers = {IdentifierType.MAC_ADDRESS: "AA:BB:CC:DD:EE:FF"}
7671        universal = _create_universal_player(
7672            mock_mass, "up_old", "Old Universal", ["ap_live"], identifiers=identifiers
7673        )
7674        native = MockPlayer(
7675            MockProvider("sonos", mass=mock_mass), "sonos_1", "Sonos", identifiers=identifiers
7676        )
7677        native.set_initialized()
7678        ap_live = MockPlayer(
7679            MockProvider("airplay", mass=mock_mass),
7680            "ap_live",
7681            "AirPlay",
7682            player_type=PlayerType.PROTOCOL,
7683            identifiers=identifiers,
7684        )
7685        ap_live.set_initialized()
7686        controller._players = {
7687            "up_old": universal,
7688            "sonos_1": native,
7689            "ap_live": ap_live,
7690        }
7691        controller._add_protocol_link(universal, ap_live, "airplay")
7692
7693        await provider.discover_players()
7694
7695        assert ap_live.protocol_parent_id == "sonos_1"
7696        assert native.protocol_parent_id is None
7697
7698    async def test_provider_discovery_does_not_replace_wrapper_with_group(
7699        self, mock_mass: MagicMock
7700    ) -> None:
7701        """A matching group player must never take over a wrapper's protocols."""
7702        controller = PlayerController(mock_mass)
7703        config_store: dict[str, Any] = {
7704            CONF_PLAYERS: {
7705                "up_old": {"enabled": True, "provider": "universal_player", "values": {}},
7706                "ap_owned": {
7707                    "enabled": True,
7708                    "provider": "airplay--test",
7709                    "player_type": "protocol",
7710                    "values": {CONF_PROTOCOL_PARENT_ID: "up_old"},
7711                },
7712            }
7713        }
7714        _wire_nested_config(mock_mass, config_store)
7715        mock_mass.players = controller
7716        mock_mass.player_queues = MagicMock()
7717        mock_mass.create_task = MagicMock()
7718        mock_mass.config.get_player_configs = AsyncMock(return_value=[])
7719
7720        provider = create_mock_universal_provider(mock_mass)
7721        identifiers = {IdentifierType.MAC_ADDRESS: "AA:BB:CC:DD:EE:FF"}
7722        universal = _create_universal_player(
7723            mock_mass, "up_old", "Old Universal", ["ap_owned"], identifiers=identifiers
7724        )
7725        group = MockPlayer(
7726            MockProvider("sync_group", mass=mock_mass),
7727            "group_1",
7728            "Group",
7729            player_type=PlayerType.GROUP,
7730            identifiers=identifiers,
7731        )
7732        group.set_initialized()
7733        ap_owned = MockPlayer(
7734            MockProvider("airplay", mass=mock_mass),
7735            "ap_owned",
7736            "AirPlay",
7737            player_type=PlayerType.PROTOCOL,
7738            identifiers=identifiers,
7739        )
7740        ap_owned.set_initialized()
7741        controller._players = {"up_old": universal, "group_1": group, "ap_owned": ap_owned}
7742        controller._add_protocol_link(universal, ap_owned, "airplay")
7743
7744        await provider.discover_players()
7745
7746        assert ap_owned.protocol_parent_id == "up_old"
7747        assert not group.linked_output_protocols
7748
7749    async def test_eviction_from_an_emptied_wrapper_does_not_schedule_its_removal(
7750        self, mock_mass: MagicMock
7751    ) -> None:
7752        """
7753        Eviction leaves a wrapper it empties registered, and deliberately so.
7754
7755        `_add_protocol_link` evicts a protocol from every other parent, but tearing
7756        down a parent it empties is not its job: that belongs to the flow that owns
7757        the takeover (`_check_replace_universal_player`, `_merge_universal_players`
7758        or `_unlink_from_protocol_parent`). Scheduling a removal here would even be
7759        harmful, because `mass.create_task` runs with `eager_start=True` and
7760        `PlayerController.unregister` drops the player from `_players` before its
7761        first await: the removal would run synchronously and turn the owning flow's
7762        later `permanent=True` removal into a silent no-op, skipping the config
7763        deletion and the PLAYER_REMOVED event.
7764        """
7765        controller = PlayerController(mock_mass)
7766        config_store: dict[str, Any] = {
7767            CONF_PLAYERS: {
7768                "sonos_1": {"enabled": True, "values": {}},
7769                "up_old": {"enabled": True, "provider": "universal_player", "values": {}},
7770                "ap_live": {
7771                    "enabled": True,
7772                    "provider": "airplay--test",
7773                    "player_type": "protocol",
7774                    "values": {CONF_PROTOCOL_PARENT_ID: "up_old"},
7775                },
7776            }
7777        }
7778        _wire_nested_config(mock_mass, config_store)
7779
7780        scheduled_tasks: list[Awaitable[object]] = []
7781
7782        def capture_task(task: Awaitable[object], *_args: Any, **_kwargs: Any) -> None:
7783            scheduled_tasks.append(task)
7784
7785        mock_mass.players = controller
7786        mock_mass.player_queues = MagicMock()
7787        mock_mass.call_later = MagicMock()
7788        mock_mass.loop = MagicMock()
7789        mock_mass.create_task = MagicMock(side_effect=capture_task)
7790
7791        identifiers = {IdentifierType.MAC_ADDRESS: "AA:BB:CC:DD:EE:FF"}
7792        universal = _create_universal_player(
7793            mock_mass, "up_old", "Old Universal", ["ap_live"], identifiers=identifiers
7794        )
7795        native = MockPlayer(
7796            MockProvider("sonos", mass=mock_mass), "sonos_1", "Sonos", identifiers=identifiers
7797        )
7798        native.set_initialized()
7799        ap_live = MockPlayer(
7800            MockProvider("airplay", mass=mock_mass),
7801            "ap_live",
7802            "AirPlay",
7803            player_type=PlayerType.PROTOCOL,
7804            identifiers=identifiers,
7805        )
7806        ap_live.set_initialized()
7807        controller._players = {"up_old": universal, "sonos_1": native, "ap_live": ap_live}
7808
7809        controller._add_protocol_link(universal, ap_live, "airplay")
7810        assert [link.output_protocol_id for link in universal.linked_output_protocols] == [
7811            "ap_live"
7812        ]
7813        scheduled_tasks.clear()
7814
7815        with patch.object(controller, "unregister", new=AsyncMock()) as mock_unregister:
7816            controller._add_protocol_link(native, ap_live, "airplay")
7817            # replay whatever the eviction scheduled, so a missing unregister is what makes
7818            # the assertion below hold and not a task that never got the chance to run
7819            for task in scheduled_tasks:
7820                await task
7821
7822        assert ap_live.protocol_parent_id == "sonos_1"
7823        assert universal.linked_output_protocols == []
7824        assert universal._protocol_player_ids == []
7825        assert "up_old" in controller._players
7826        # removing the emptied wrapper is left to the flow that owns the takeover
7827        assert mock_unregister.call_args_list == []
7828
7829    async def test_wrapper_listing_the_native_id_as_its_protocol_completes_the_takeover(
7830        self, mock_mass: MagicMock
7831    ) -> None:
7832        """
7833        A wrapper that lists the native player's own id is still fully replaced.
7834
7835        A device that keeps its player id across a PROTOCOL -> PLAYER type change stays
7836        one of the wrapper's linked protocols. It cannot be taken over from itself, so it
7837        is left out of the transfer: the wrapper's genuine protocol moves to the native
7838        player, the user's configuration is carried over and the emptied wrapper is
7839        permanently removed.
7840        """
7841        controller = PlayerController(mock_mass)
7842        config_store: dict[str, Any] = {
7843            CONF_PLAYERS: {
7844                "esp_client": {
7845                    "enabled": True,
7846                    "provider": "sendspin--test",
7847                    # left over from before the device changed type
7848                    "values": {CONF_PROTOCOL_PARENT_ID: "up_old"},
7849                },
7850                "up_old": {
7851                    "enabled": True,
7852                    "provider": "universal_player",
7853                    "name": "Living Room",
7854                    "default_name": "ESP (Universal)",
7855                    "values": {"hide_in_ui": True},
7856                },
7857                "ap_live": {
7858                    "enabled": True,
7859                    "provider": "airplay--test",
7860                    "player_type": "protocol",
7861                    "values": {CONF_PROTOCOL_PARENT_ID: "up_old"},
7862                },
7863                # a disabled protocol that never registered, so it only exists in config
7864                "dlna_off": {
7865                    "enabled": False,
7866                    "provider": "dlna--test",
7867                    "player_type": "protocol",
7868                    "values": {CONF_PROTOCOL_PARENT_ID: "up_old"},
7869                },
7870            }
7871        }
7872        _wire_nested_config(mock_mass, config_store)
7873
7874        scheduled_tasks: list[Awaitable[object]] = []
7875
7876        def capture_task(task: Awaitable[object], *_args: Any, **_kwargs: Any) -> None:
7877            scheduled_tasks.append(task)
7878
7879        mock_mass.players = controller
7880        mock_mass.player_queues = MagicMock()
7881        # keep the state refreshes below from scheduling an unrelated palette fetch
7882        mock_mass.player_queues.get = MagicMock(return_value=None)
7883        mock_mass.call_later = MagicMock()
7884        mock_mass.loop = MagicMock()
7885        mock_mass.create_task = MagicMock(side_effect=capture_task)
7886        mock_mass.config.get_player_config = AsyncMock(
7887            return_value=PlayerConfig.parse(
7888                [],
7889                {
7890                    "player_id": "esp_client",
7891                    "provider": "sendspin--test",
7892                    "name": "Living Room",
7893                    "default_name": "ESP",
7894                },
7895            )
7896        )
7897
7898        identifiers = {IdentifierType.MAC_ADDRESS: "AA:BB:CC:DD:EE:FF"}
7899        universal = _create_universal_player(
7900            mock_mass,
7901            "up_old",
7902            "ESP (Universal)",
7903            ["ap_live", "esp_client", "dlna_off"],
7904            identifiers=identifiers,
7905        )
7906        native = MockPlayer(
7907            MockProvider("sendspin", mass=mock_mass), "esp_client", "ESP", identifiers=identifiers
7908        )
7909        native.set_initialized()
7910        ap_live = MockPlayer(
7911            MockProvider("airplay", mass=mock_mass),
7912            "ap_live",
7913            "AirPlay",
7914            player_type=PlayerType.PROTOCOL,
7915            identifiers=identifiers,
7916        )
7917        ap_live.set_initialized()
7918        controller._players = {"up_old": universal, "esp_client": native, "ap_live": ap_live}
7919
7920        controller._add_protocol_link(universal, ap_live, "airplay")
7921        # the wrapper linked the device as a protocol before it changed type
7922        universal.set_linked_output_protocols(
7923            [
7924                *universal.linked_output_protocols,
7925                LinkedOutputProtocol(
7926                    output_protocol_id="esp_client", protocol_domain="sendspin", priority=20
7927                ),
7928            ]
7929        )
7930        scheduled_tasks.clear()
7931
7932        with patch.object(controller, "unregister", new=AsyncMock()) as mock_unregister:
7933            controller._check_replace_universal_player(native)
7934            # replay what the takeover scheduled, otherwise the unregister assertion below
7935            # would hold for a task that never got the chance to run
7936            for task in scheduled_tasks:
7937                await task
7938
7939        # the genuine protocol moved over and the wrapper is left without any output
7940        assert ap_live.protocol_parent_id == "esp_client"
7941        assert universal.linked_output_protocols == []
7942        assert universal._protocol_player_ids == []
7943        # the emptied wrapper is permanently removed in favour of the native player
7944        mock_unregister.assert_awaited_once_with(
7945            "up_old", permanent=True, replacement_player_id="esp_client"
7946        )
7947        # and the user's configuration is carried over before that removal deletes it
7948        assert config_store[CONF_PLAYERS]["esp_client"]["name"] == "Living Room"
7949        assert config_store[CONF_PLAYERS]["esp_client"]["values"]["hide_in_ui"] is True
7950        # ownership is handed over in config as well, for the protocol that moved and for
7951        # the one that only exists in config: nothing may keep pointing at the removed wrapper
7952        ap_values = config_store[CONF_PLAYERS]["ap_live"]["values"]
7953        assert ap_values[CONF_PROTOCOL_PARENT_ID] == "esp_client"
7954        dlna_values = config_store[CONF_PLAYERS]["dlna_off"]["values"]
7955        assert dlna_values[CONF_PROTOCOL_PARENT_ID] == "esp_client"
7956        # the native player is never left pointing at the wrapper it replaces
7957        assert native.protocol_parent_id is None
7958        assert config_store[CONF_PLAYERS]["esp_client"]["values"][CONF_PROTOCOL_PARENT_ID] is None
7959
7960    async def test_wrapper_holding_only_the_native_id_completes_the_takeover(
7961        self, mock_mass: MagicMock
7962    ) -> None:
7963        """
7964        A wrapper whose only protocol is the native player itself is cleaned up.
7965
7966        Leaving the native player out of the transfer empties the wrapper without
7967        anything to hand over, which still carries the user's configuration across
7968        and removes the wrapper next to the player that replaces it.
7969        """
7970        controller = PlayerController(mock_mass)
7971        config_store: dict[str, Any] = {
7972            CONF_PLAYERS: {
7973                "esp_client": {"enabled": True, "provider": "sendspin--test", "values": {}},
7974                "up_old": {
7975                    "enabled": True,
7976                    "provider": "universal_player",
7977                    "name": "Living Room",
7978                    "default_name": "ESP (Universal)",
7979                    "values": {"hide_in_ui": True},
7980                },
7981            }
7982        }
7983        _wire_nested_config(mock_mass, config_store)
7984
7985        scheduled_tasks: list[Awaitable[object]] = []
7986
7987        def capture_task(task: Awaitable[object], *_args: Any, **_kwargs: Any) -> None:
7988            scheduled_tasks.append(task)
7989
7990        mock_mass.players = controller
7991        mock_mass.player_queues = MagicMock()
7992        mock_mass.player_queues.get = MagicMock(return_value=None)
7993        mock_mass.call_later = MagicMock()
7994        mock_mass.loop = MagicMock()
7995        mock_mass.create_task = MagicMock(side_effect=capture_task)
7996        mock_mass.config.get_player_config = AsyncMock(
7997            return_value=PlayerConfig.parse(
7998                [],
7999                {
8000                    "player_id": "esp_client",
8001                    "provider": "sendspin--test",
8002                    "name": "Living Room",
8003                    "default_name": "ESP",
8004                },
8005            )
8006        )
8007
8008        universal = _create_universal_player(mock_mass, "up_old", "ESP (Universal)", ["esp_client"])
8009        universal.set_linked_output_protocols(
8010            [
8011                LinkedOutputProtocol(
8012                    output_protocol_id="esp_client", protocol_domain="sendspin", priority=20
8013                )
8014            ]
8015        )
8016        native = MockPlayer(MockProvider("sendspin", mass=mock_mass), "esp_client", "ESP")
8017        native.set_initialized()
8018        controller._players = {"up_old": universal, "esp_client": native}
8019        scheduled_tasks.clear()
8020
8021        with patch.object(controller, "unregister", new=AsyncMock()) as mock_unregister:
8022            controller._check_replace_universal_player(native)
8023            for task in scheduled_tasks:
8024                await task
8025
8026        assert universal.linked_output_protocols == []
8027        assert universal._protocol_player_ids == []
8028        mock_unregister.assert_awaited_once_with(
8029            "up_old", permanent=True, replacement_player_id="esp_client"
8030        )
8031        assert config_store[CONF_PLAYERS]["esp_client"]["name"] == "Living Room"
8032        assert config_store[CONF_PLAYERS]["esp_client"]["values"]["hide_in_ui"] is True
8033        assert native.protocol_parent_id is None
8034
8035    async def test_wrapper_with_a_bridge_riding_on_the_native_completes_the_takeover(
8036        self, mock_mass: MagicMock
8037    ) -> None:
8038        """
8039        A protocol riding on the native player's own id moves along with the takeover.
8040
8041        The wrapper lists the device that kept its id across a type change together with
8042        the bridge riding on it, so both sit in one group with the device as its base.
8043        Leaving the device out of the transfer must not hold the bridge back: the bridge
8044        moves over as a protocol derived from the native player itself.
8045        """
8046        controller = PlayerController(mock_mass)
8047        config_store: dict[str, Any] = {
8048            CONF_PLAYERS: {
8049                "cast_base": {"enabled": True, "provider": "chromecast--test", "values": {}},
8050                "up_old": {
8051                    "enabled": True,
8052                    "provider": "universal_player",
8053                    "name": "Kitchen",
8054                    "default_name": "Speaker (Universal)",
8055                    "values": {"hide_in_ui": True},
8056                },
8057                "spb_bridge": {
8058                    "enabled": True,
8059                    "provider": "sendspin--test",
8060                    "player_type": "protocol",
8061                    "values": {
8062                        CONF_PROTOCOL_PARENT_ID: "up_old",
8063                        CONF_UNDERLYING_PLAYER_ID: "cast_base",
8064                    },
8065                },
8066            }
8067        }
8068        _wire_nested_config(mock_mass, config_store)
8069
8070        scheduled_tasks: list[Awaitable[object]] = []
8071
8072        def capture_task(task: Awaitable[object], *_args: Any, **_kwargs: Any) -> None:
8073            scheduled_tasks.append(task)
8074
8075        mock_mass.players = controller
8076        mock_mass.player_queues = MagicMock()
8077        # keep the state refreshes below from scheduling an unrelated palette fetch
8078        mock_mass.player_queues.get = MagicMock(return_value=None)
8079        mock_mass.call_later = MagicMock()
8080        mock_mass.loop = MagicMock()
8081        mock_mass.create_task = MagicMock(side_effect=capture_task)
8082        mock_mass.config.get_player_config = AsyncMock(
8083            return_value=PlayerConfig.parse(
8084                [],
8085                {
8086                    "player_id": "cast_base",
8087                    "provider": "chromecast--test",
8088                    "name": "Kitchen",
8089                    "default_name": "Speaker",
8090                },
8091            )
8092        )
8093
8094        identifiers = {IdentifierType.MAC_ADDRESS: "AA:BB:CC:DD:EE:FF"}
8095        universal = _create_universal_player(
8096            mock_mass,
8097            "up_old",
8098            "Speaker (Universal)",
8099            ["cast_base", "spb_bridge"],
8100            identifiers=identifiers,
8101        )
8102        native = MockPlayer(
8103            MockProvider("chromecast", mass=mock_mass),
8104            "cast_base",
8105            "Speaker",
8106            identifiers=identifiers,
8107        )
8108        native.set_initialized()
8109        bridge = MockPlayer(
8110            MockProvider("sendspin", mass=mock_mass),
8111            "spb_bridge",
8112            "Speaker (Bridge)",
8113            player_type=PlayerType.PROTOCOL,
8114        )
8115        bridge._attr_underlying_player_id = "cast_base"
8116        bridge.set_initialized()
8117        controller._players = {"up_old": universal, "cast_base": native, "spb_bridge": bridge}
8118
8119        controller._add_protocol_link(universal, bridge, "sendspin")
8120        # the wrapper linked the device as a protocol before it changed type, which makes
8121        # it the base of the group the bridge rides on
8122        universal.set_linked_output_protocols(
8123            [
8124                *universal.linked_output_protocols,
8125                LinkedOutputProtocol(
8126                    output_protocol_id="cast_base", protocol_domain="chromecast", priority=30
8127                ),
8128            ]
8129        )
8130        scheduled_tasks.clear()
8131
8132        with patch.object(controller, "unregister", new=AsyncMock()) as mock_unregister:
8133            controller._check_replace_universal_player(native)
8134            # replay what the takeover scheduled, otherwise the unregister assertion below
8135            # would hold for a task that never got the chance to run
8136            for task in scheduled_tasks:
8137                await task
8138
8139        assert bridge.protocol_parent_id == "cast_base"
8140        # the bridge now rides on the parent player itself, recorded as "native"
8141        bridge_link = next(
8142            link
8143            for link in native.linked_output_protocols
8144            if link.output_protocol_id == "spb_bridge"
8145        )
8146        assert bridge_link.derived_from == "native"
8147        # the native player is never left pointing at the wrapper it replaces
8148        assert native.protocol_parent_id is None
8149        # the wrapper is left without any output and permanently removed
8150        assert universal.linked_output_protocols == []
8151        assert universal._protocol_player_ids == []
8152        mock_unregister.assert_awaited_once_with(
8153            "up_old", permanent=True, replacement_player_id="cast_base"
8154        )
8155
8156
8157class TestEndToEndDuplicateProtocol:
8158    """
8159    End-to-end integration tests for duplicate protocol → separate universal player flow.
8160
8161    These tests exercise the full async flow through _delayed_protocol_evaluation,
8162    ensure_universal_players_for_protocols, and _create_separate_universal_player,
8163    verifying that a second instance of the same protocol domain on the same host
8164    results in a separate universal player.
8165    """
8166
8167    @staticmethod
8168    def _setup_e2e_controller(
8169        mock_mass: MagicMock,
8170    ) -> tuple[PlayerController, UniversalPlayerProvider]:
8171        """
8172        Set up a PlayerController and UniversalPlayerProvider wired for E2E testing.
8173
8174        The mock_mass is configured so that:
8175        - get_providers(ProviderType.PLAYER) returns the universal provider
8176        - register_or_update adds the player to the controller's _players dict
8177        - config.create_default_player_config does not fail
8178        - config.set does not fail
8179
8180        :return: Tuple of (controller, universal_provider).
8181        """
8182        controller = PlayerController(mock_mass)
8183        up_provider = create_mock_universal_provider(mock_mass)
8184
8185        # Wire get_providers to return our universal provider
8186        mock_mass.get_providers = MagicMock(return_value=[up_provider])
8187
8188        # Wire register_or_update to add the player to controller._players
8189        async def fake_register_or_update(player: Player) -> None:
8190            controller._players[player.player_id] = player
8191            player.set_initialized()
8192
8193        mock_mass.players = MagicMock()
8194        mock_mass.players.register_or_update = AsyncMock(side_effect=fake_register_or_update)
8195        mock_mass.players.get_player = lambda pid: controller._players.get(pid)
8196
8197        # Wire config methods
8198        mock_mass.config.create_default_player_config = MagicMock()
8199        mock_mass.config.set = MagicMock()
8200        mock_mass.config.get_base_player_config.return_value = create_mock_config("Test")
8201
8202        return controller, up_provider
8203
8204    @pytest.mark.asyncio
8205    async def test_path_a_delayed_eval_creates_separate_universal_for_duplicate(
8206        self, mock_mass: MagicMock
8207    ) -> None:
8208        """
8209        Path A: _delayed_protocol_evaluation creates separate universal player for duplicate.
8210
8211        Scenario: Two AirPlay instances register on the same host.
8212        1. First AirPlay links to existing universal player
8213           via _add_protocol_to_existing_universal
8214        2. Second AirPlay arrives at _delayed_protocol_evaluation
8215        3. It finds the existing universal player but is refused (domain duplicate)
8216        4. Falls through to _find_matching_protocol_players (which skips same-domain)
8217        5. Creates a NEW separate universal player via _create_or_update_universal_player
8218        """
8219        controller, _ = self._setup_e2e_controller(mock_mass)
8220
8221        # Create existing universal player with AirPlay already linked
8222        universal = _create_universal_player(
8223            mock_mass,
8224            "up_aabbccddeeff",
8225            "Living Room Speaker",
8226            protocol_player_ids=["ap_1"],
8227            identifiers={IdentifierType.MAC_ADDRESS: "AA:BB:CC:DD:EE:FF"},
8228        )
8229
8230        # Create first AirPlay (already linked to universal)
8231        ap_provider = MockProvider("airplay", mass=mock_mass)
8232        ap1 = MockPlayer(
8233            ap_provider,
8234            "ap_1",
8235            "AirPlay Instance 1",
8236            player_type=PlayerType.PROTOCOL,
8237            identifiers={IdentifierType.MAC_ADDRESS: "AA:BB:CC:DD:EE:FF"},
8238        )
8239        ap1.set_initialized()
8240        controller._add_protocol_link(universal, ap1, "airplay")
8241        assert ap1.protocol_parent_id == "up_aabbccddeeff"
8242
8243        # Create second AirPlay (same MAC, not yet linked)
8244        ap2 = MockPlayer(
8245            ap_provider,
8246            "ap_2",
8247            "AirPlay Instance 2",
8248            player_type=PlayerType.PROTOCOL,
8249            identifiers={IdentifierType.MAC_ADDRESS: "AA:BB:CC:DD:EE:FF"},
8250        )
8251        ap2.set_initialized()
8252
8253        controller._players = {
8254            "up_aabbccddeeff": universal,
8255            "ap_1": ap1,
8256            "ap_2": ap2,
8257        }
8258
8259        # Run delayed evaluation for ap2 - full async flow
8260        await controller._delayed_protocol_evaluation("ap_2")
8261
8262        # ap2 should NOT be linked to the existing universal player
8263        assert ap2.protocol_parent_id != "up_aabbccddeeff"
8264
8265        # ap2 should have its own (freshly minted) universal player
8266        assert ap2.protocol_parent_id is not None
8267        separate_up_id = ap2.protocol_parent_id
8268        assert separate_up_id in controller._players
8269        separate_up = controller._players[separate_up_id]
8270        assert isinstance(separate_up, UniversalPlayer)
8271
8272        # The original universal player should still have only ap_1
8273        original_ap_links = [
8274            link for link in universal.linked_output_protocols if link.protocol_domain == "airplay"
8275        ]
8276        assert len(original_ap_links) == 1
8277        assert original_ap_links[0].output_protocol_id == "ap_1"
8278
8279    @pytest.mark.asyncio
8280    async def test_path_a_delayed_eval_joins_existing_universal_different_domain(
8281        self, mock_mass: MagicMock
8282    ) -> None:
8283        """
8284        Path A: _delayed_protocol_evaluation correctly joins when domain is different.
8285
8286        Scenario: AirPlay universal player exists, DLNA arrives via delayed eval.
8287        DLNA should successfully join the existing universal player (different domain).
8288        """
8289        controller, _ = self._setup_e2e_controller(mock_mass)
8290
8291        # Create existing universal player with AirPlay linked
8292        universal = _create_universal_player(
8293            mock_mass,
8294            "up_aabbccddeeff",
8295            "Living Room Speaker",
8296            protocol_player_ids=["ap_1"],
8297            identifiers={IdentifierType.MAC_ADDRESS: "AA:BB:CC:DD:EE:FF"},
8298        )
8299
8300        ap_provider = MockProvider("airplay", mass=mock_mass)
8301        ap1 = MockPlayer(
8302            ap_provider,
8303            "ap_1",
8304            "AirPlay",
8305            player_type=PlayerType.PROTOCOL,
8306            identifiers={IdentifierType.MAC_ADDRESS: "AA:BB:CC:DD:EE:FF"},
8307        )
8308        ap1.set_initialized()
8309        controller._add_protocol_link(universal, ap1, "airplay")
8310
8311        # Create DLNA player (same device, different protocol)
8312        dlna_provider = MockProvider("dlna", mass=mock_mass)
8313        dlna1 = MockPlayer(
8314            dlna_provider,
8315            "dlna_1",
8316            "DLNA",
8317            player_type=PlayerType.PROTOCOL,
8318            identifiers={IdentifierType.MAC_ADDRESS: "AA:BB:CC:DD:EE:FF"},
8319        )
8320        dlna1.set_initialized()
8321
8322        controller._players = {
8323            "up_aabbccddeeff": universal,
8324            "ap_1": ap1,
8325            "dlna_1": dlna1,
8326        }
8327
8328        # Run delayed evaluation for DLNA
8329        await controller._delayed_protocol_evaluation("dlna_1")
8330
8331        # DLNA should join the existing universal player
8332        assert dlna1.protocol_parent_id == "up_aabbccddeeff"
8333
8334        # Universal player should now have both AirPlay and DLNA
8335        domains = {link.protocol_domain for link in universal.linked_output_protocols}
8336        assert "airplay" in domains
8337        assert "dlna" in domains
8338
8339    @pytest.mark.asyncio
8340    async def test_path_b_ensure_universal_separates_duplicate_domain(
8341        self, mock_mass: MagicMock
8342    ) -> None:
8343        """
8344        Path B: ensure_universal_players_for_protocols separates can_join vs rejected.
8345
8346        Scenario: Two AirPlay players arrive simultaneously for delayed eval.
8347        Both are unlinked, both pass to _create_or_update_universal_player.
8348        The first creates a universal player. The second finds the existing one
8349        and is rejected (duplicate domain), getting its own separate universal player.
8350        """
8351        controller, _ = self._setup_e2e_controller(mock_mass)
8352
8353        ap_provider = MockProvider("airplay", mass=mock_mass)
8354        ap1 = MockPlayer(
8355            ap_provider,
8356            "ap_1",
8357            "AirPlay Instance 1",
8358            player_type=PlayerType.PROTOCOL,
8359            identifiers={IdentifierType.MAC_ADDRESS: "AA:BB:CC:DD:EE:FF"},
8360        )
8361        ap1.set_initialized()
8362
8363        ap2 = MockPlayer(
8364            ap_provider,
8365            "ap_2",
8366            "AirPlay Instance 2",
8367            player_type=PlayerType.PROTOCOL,
8368            identifiers={IdentifierType.MAC_ADDRESS: "AA:BB:CC:DD:EE:FF"},
8369        )
8370        ap2.set_initialized()
8371
8372        controller._players = {
8373            "ap_1": ap1,
8374            "ap_2": ap2,
8375        }
8376
8377        # First: ap1 goes through delayed evaluation, creates a universal player
8378        await controller._delayed_protocol_evaluation("ap_1")
8379
8380        # ap1 should have a universal player parent
8381        assert ap1.protocol_parent_id is not None
8382        first_up_id = ap1.protocol_parent_id
8383        assert first_up_id in controller._players
8384
8385        # Second: ap2 goes through delayed evaluation
8386        # _find_matching_universal_player will find the universal player by MAC
8387        # _add_protocol_to_existing_universal will refuse (duplicate domain)
8388        # _find_matching_protocol_players will skip ap1 (same domain)
8389        # _create_or_update_universal_player → ensure_universal_players_for_protocols
8390        #   finds existing universal player, separates ap2 as rejected
8391        await controller._delayed_protocol_evaluation("ap_2")
8392
8393        # ap2 should have its own separate universal player
8394        assert ap2.protocol_parent_id is not None
8395        second_up_id = ap2.protocol_parent_id
8396        assert second_up_id != first_up_id
8397        assert second_up_id in controller._players
8398
8399        # Both universal players should exist
8400        first_up = controller._players[first_up_id]
8401        second_up = controller._players[second_up_id]
8402        assert isinstance(first_up, UniversalPlayer)
8403        assert isinstance(second_up, UniversalPlayer)
8404
8405    @pytest.mark.asyncio
8406    async def test_path_b_first_creates_then_second_gets_separate(
8407        self, mock_mass: MagicMock
8408    ) -> None:
8409        """
8410        Path B: First protocol player creates universal, second gets separate.
8411
8412        Scenario: AirPlay and DLNA create a universal player together.
8413        Then a second AirPlay arrives and gets its own separate universal player.
8414        """
8415        controller, _ = self._setup_e2e_controller(mock_mass)
8416
8417        ap_provider = MockProvider("airplay", mass=mock_mass)
8418        dlna_provider = MockProvider("dlna", mass=mock_mass)
8419
8420        ap1 = MockPlayer(
8421            ap_provider,
8422            "ap_1",
8423            "AirPlay",
8424            player_type=PlayerType.PROTOCOL,
8425            identifiers={IdentifierType.MAC_ADDRESS: "AA:BB:CC:DD:EE:FF"},
8426        )
8427        ap1.set_initialized()
8428
8429        dlna1 = MockPlayer(
8430            dlna_provider,
8431            "dlna_1",
8432            "DLNA",
8433            player_type=PlayerType.PROTOCOL,
8434            identifiers={IdentifierType.MAC_ADDRESS: "AA:BB:CC:DD:EE:FF"},
8435        )
8436        dlna1.set_initialized()
8437
8438        ap2 = MockPlayer(
8439            ap_provider,
8440            "ap_2",
8441            "AirPlay 2",
8442            player_type=PlayerType.PROTOCOL,
8443            identifiers={IdentifierType.MAC_ADDRESS: "AA:BB:CC:DD:EE:FF"},
8444        )
8445        ap2.set_initialized()
8446
8447        controller._players = {
8448            "ap_1": ap1,
8449            "dlna_1": dlna1,
8450            "ap_2": ap2,
8451        }
8452
8453        # First: ap1 arrives at delayed eval, finds dlna1 as matching (different domain)
8454        # Together they create a universal player
8455        await controller._delayed_protocol_evaluation("ap_1")
8456
8457        assert ap1.protocol_parent_id is not None
8458        # dlna1 should also be linked (found by _find_matching_protocol_players)
8459        assert dlna1.protocol_parent_id == ap1.protocol_parent_id
8460        shared_up_id = ap1.protocol_parent_id
8461
8462        # Second: ap2 arrives at delayed eval
8463        await controller._delayed_protocol_evaluation("ap_2")
8464
8465        # ap2 should get its own universal player
8466        assert ap2.protocol_parent_id is not None
8467        assert ap2.protocol_parent_id != shared_up_id
8468        assert ap2.protocol_parent_id in controller._players
8469
8470        # Shared universal player should have AirPlay + DLNA
8471        shared_up = controller._players[shared_up_id]
8472        shared_domains = {link.protocol_domain for link in shared_up.linked_output_protocols}
8473        assert "airplay" in shared_domains
8474        assert "dlna" in shared_domains
8475
8476    @pytest.mark.asyncio
8477    async def test_path_c_sendspin_duplicate_gets_separate_universal(
8478        self, mock_mass: MagicMock
8479    ) -> None:
8480        """
8481        Path C: Two Sendspin bridge instances on same host get separate universal players.
8482
8483        Sendspin bridges match via CAST_UUID / AIRPLAY_ID, not MAC. Two bridges
8484        on the same host share the same MAC (from ARP) but have different CAST_UUIDs.
8485        Each should get its own universal player.
8486        """
8487        controller, _ = self._setup_e2e_controller(mock_mass)
8488
8489        sendspin_provider = MockProvider("sendspin", mass=mock_mass)
8490
8491        # First Sendspin bridge
8492        spb1 = MockPlayer(
8493            sendspin_provider,
8494            "spb_1",
8495            "Sendspin Bridge 1",
8496            player_type=PlayerType.PROTOCOL,
8497            identifiers={
8498                IdentifierType.MAC_ADDRESS: "AA:BB:CC:DD:EE:FF",
8499                IdentifierType.CAST_UUID: "cast-uuid-111",
8500            },
8501        )
8502        spb1.set_initialized()
8503
8504        # Second Sendspin bridge (same MAC, different CAST_UUID)
8505        spb2 = MockPlayer(
8506            sendspin_provider,
8507            "spb_2",
8508            "Sendspin Bridge 2",
8509            player_type=PlayerType.PROTOCOL,
8510            identifiers={
8511                IdentifierType.MAC_ADDRESS: "AA:BB:CC:DD:EE:FF",
8512                IdentifierType.CAST_UUID: "cast-uuid-222",
8513            },
8514        )
8515        spb2.set_initialized()
8516
8517        controller._players = {
8518            "spb_1": spb1,
8519            "spb_2": spb2,
8520        }
8521
8522        # First: spb1 goes through delayed evaluation
8523        await controller._delayed_protocol_evaluation("spb_1")
8524
8525        assert spb1.protocol_parent_id is not None
8526        first_up_id = spb1.protocol_parent_id
8527
8528        # Second: spb2 goes through delayed evaluation
8529        # _find_matching_protocol_players skips spb1 (same domain: "sendspin")
8530        # _find_matching_universal_player may find the first universal player by MAC
8531        # _add_protocol_to_existing_universal refuses (duplicate domain: "sendspin")
8532        # Falls through to create separate universal player
8533        await controller._delayed_protocol_evaluation("spb_2")
8534
8535        assert spb2.protocol_parent_id is not None
8536        second_up_id = spb2.protocol_parent_id
8537
8538        # Each should have its own universal player
8539        assert first_up_id != second_up_id
8540        assert first_up_id in controller._players
8541        assert second_up_id in controller._players
8542
8543        first_up = controller._players[first_up_id]
8544        second_up = controller._players[second_up_id]
8545        assert isinstance(first_up, UniversalPlayer)
8546        assert isinstance(second_up, UniversalPlayer)
8547
8548    @pytest.mark.asyncio
8549    async def test_three_airplay_instances_get_separate_universals(
8550        self, mock_mass: MagicMock
8551    ) -> None:
8552        """
8553        Test that three AirPlay instances each get their own universal player.
8554
8555        Exercises the flow when there are 3+ instances of the same protocol.
8556        """
8557        controller, _ = self._setup_e2e_controller(mock_mass)
8558
8559        ap_provider = MockProvider("airplay", mass=mock_mass)
8560        players = []
8561        for i in range(1, 4):
8562            p = MockPlayer(
8563                ap_provider,
8564                f"ap_{i}",
8565                f"AirPlay Instance {i}",
8566                player_type=PlayerType.PROTOCOL,
8567                identifiers={IdentifierType.MAC_ADDRESS: "AA:BB:CC:DD:EE:FF"},
8568            )
8569            p.set_initialized()
8570            players.append(p)
8571
8572        controller._players = {p.player_id: p for p in players}
8573
8574        # Run delayed evaluation for each in sequence
8575        for p in players:
8576            await controller._delayed_protocol_evaluation(p.player_id)
8577
8578        # Each should have its own universal player
8579        parent_ids = {p.protocol_parent_id for p in players}
8580        assert None not in parent_ids
8581        # All three should have distinct parents
8582        assert len(parent_ids) == 3
8583
8584        # All three universal players should exist
8585        for pid in parent_ids:
8586            assert pid in controller._players
8587            assert isinstance(controller._players[pid], UniversalPlayer)
8588
8589    @pytest.mark.asyncio
8590    async def test_same_domain_same_ip_get_separate_universals(self, mock_mass: MagicMock) -> None:
8591        """
8592        Test that multiple instances of the same protocol on the same host stay separate.
8593
8594        Scenario: 3 shairport-sync AirPlay instances on the same Raspberry Pi.
8595        All share the same IP and have locally-administered MACs.
8596        The IP fallback must NOT merge them — domain dedup takes precedence.
8597        """
8598        controller, _ = self._setup_e2e_controller(mock_mass)
8599
8600        ap_provider = MockProvider("airplay", mass=mock_mass)
8601        players = []
8602        for i in range(1, 4):
8603            p = MockPlayer(
8604                ap_provider,
8605                f"ap_{i}",
8606                f"Shairport Zone {i}",
8607                player_type=PlayerType.PROTOCOL,
8608                identifiers={
8609                    # Each has a distinct LA MAC (as shairport-sync generates)
8610                    IdentifierType.MAC_ADDRESS: f"02:AA:BB:CC:DD:{i:02X}",
8611                    IdentifierType.IP_ADDRESS: "192.168.1.10",
8612                },
8613            )
8614            p.set_initialized()
8615            players.append(p)
8616
8617        controller._players = {p.player_id: p for p in players}
8618
8619        for p in players:
8620            await controller._delayed_protocol_evaluation(p.player_id)
8621
8622        # Each should have its own universal player (NOT merged)
8623        parent_ids = {p.protocol_parent_id for p in players}
8624        assert None not in parent_ids
8625        assert len(parent_ids) == 3
8626
8627    @pytest.mark.asyncio
8628    async def test_same_domain_same_mac_get_separate_universals(self, mock_mass: MagicMock) -> None:
8629        """
8630        Test that two snapcast instances with the same ARP-resolved MAC stay separate.
8631
8632        Scenario: Two snapcast clients on the same Mac mini. Both have distinct
8633        locally-administered MACs but ARP resolves both to the same real hardware MAC.
8634        They should each get their own universal player, not be merged.
8635        """
8636        controller, _ = self._setup_e2e_controller(mock_mass)
8637
8638        snap_provider = MockProvider("snapcast", mass=mock_mass)
8639        snap1 = MockPlayer(
8640            snap_provider,
8641            "ma_cea0b4ed2221",
8642            "Mac-mini-15269.local",
8643            player_type=PlayerType.PROTOCOL,
8644            identifiers={
8645                # ARP-enriched: both resolve to same real MAC
8646                IdentifierType.MAC_ADDRESS: "D0:11:E5:00:67:2F",
8647                IdentifierType.IP_ADDRESS: "192.168.1.42",
8648            },
8649        )
8650        snap2 = MockPlayer(
8651            snap_provider,
8652            "ma_cea0b4ed2220",
8653            "Mac-mini-63197.local",
8654            player_type=PlayerType.PROTOCOL,
8655            identifiers={
8656                IdentifierType.MAC_ADDRESS: "D0:11:E5:00:67:2F",
8657                IdentifierType.IP_ADDRESS: "192.168.1.42",
8658            },
8659        )
8660        snap1._attr_available = False
8661        snap1._cache.clear()
8662        snap1.update_state(signal_event=False)
8663        snap2._attr_available = False
8664        snap2._cache.clear()
8665        snap2.update_state(signal_event=False)
8666        snap1.set_initialized()
8667        snap2.set_initialized()
8668
8669        controller._players = {"ma_cea0b4ed2221": snap1, "ma_cea0b4ed2220": snap2}
8670
8671        await controller._delayed_protocol_evaluation("ma_cea0b4ed2221")
8672        await controller._delayed_protocol_evaluation("ma_cea0b4ed2220")
8673
8674        # Each should have its own universal player
8675        assert snap1.protocol_parent_id is not None
8676        assert snap2.protocol_parent_id is not None
8677        assert snap1.protocol_parent_id != snap2.protocol_parent_id
8678
8679    @pytest.mark.asyncio
8680    async def test_mixed_protocols_plus_duplicate_creates_correct_topology(
8681        self, mock_mass: MagicMock
8682    ) -> None:
8683        """
8684        Test that mixed protocols + duplicate create correct player topology.
8685
8686        Scenario:
8687        - Device has AirPlay, DLNA, and Chromecast protocols
8688        - Plus a second AirPlay instance (duplicate)
8689        Result:
8690        - One universal player with AirPlay + DLNA + Chromecast
8691        - One separate universal player for the second AirPlay
8692        """
8693        controller, _ = self._setup_e2e_controller(mock_mass)
8694
8695        ap_provider = MockProvider("airplay", mass=mock_mass)
8696        dlna_provider = MockProvider("dlna", mass=mock_mass)
8697        cc_provider = MockProvider("chromecast", mass=mock_mass)
8698
8699        ap1 = MockPlayer(
8700            ap_provider,
8701            "ap_1",
8702            "AirPlay",
8703            player_type=PlayerType.PROTOCOL,
8704            identifiers={IdentifierType.MAC_ADDRESS: "AA:BB:CC:DD:EE:FF"},
8705        )
8706        ap1.set_initialized()
8707
8708        dlna1 = MockPlayer(
8709            dlna_provider,
8710            "dlna_1",
8711            "DLNA",
8712            player_type=PlayerType.PROTOCOL,
8713            identifiers={IdentifierType.MAC_ADDRESS: "AA:BB:CC:DD:EE:FF"},
8714        )
8715        dlna1.set_initialized()
8716
8717        cc1 = MockPlayer(
8718            cc_provider,
8719            "cc_1",
8720            "Chromecast",
8721            player_type=PlayerType.PROTOCOL,
8722            identifiers={IdentifierType.MAC_ADDRESS: "AA:BB:CC:DD:EE:FF"},
8723        )
8724        cc1.set_initialized()
8725
8726        ap2 = MockPlayer(
8727            ap_provider,
8728            "ap_2",
8729            "AirPlay Duplicate",
8730            player_type=PlayerType.PROTOCOL,
8731            identifiers={IdentifierType.MAC_ADDRESS: "AA:BB:CC:DD:EE:FF"},
8732        )
8733        ap2.set_initialized()
8734
8735        controller._players = {
8736            "ap_1": ap1,
8737            "dlna_1": dlna1,
8738            "cc_1": cc1,
8739            "ap_2": ap2,
8740        }
8741
8742        # ap1 goes through delayed eval - finds dlna1 and cc1 as matching
8743        # (different domains), creates universal player with all three
8744        await controller._delayed_protocol_evaluation("ap_1")
8745
8746        assert ap1.protocol_parent_id is not None
8747        assert dlna1.protocol_parent_id == ap1.protocol_parent_id
8748        assert cc1.protocol_parent_id == ap1.protocol_parent_id
8749        shared_up_id = ap1.protocol_parent_id
8750
8751        # ap2 goes through delayed eval - finds universal player but is rejected
8752        await controller._delayed_protocol_evaluation("ap_2")
8753
8754        assert ap2.protocol_parent_id is not None
8755        assert ap2.protocol_parent_id != shared_up_id
8756
8757        # Shared universal player should have 3 protocols
8758        shared_up = controller._players[shared_up_id]
8759        shared_domains = {link.protocol_domain for link in shared_up.linked_output_protocols}
8760        assert shared_domains == {"airplay", "dlna", "chromecast"}
8761
8762
8763class TestEndToEndUniversalPlayerId:
8764    """
8765    End-to-end tests for the minted player id of a universal player.
8766
8767    Regression tests for music-assistant/support#5888: the id used to be derived from
8768    whichever device identifiers happened to be available when the wrapper was created,
8769    so a re-created wrapper could silently change id and orphan the entities API
8770    consumers (such as the Home Assistant integration) bind to it. These tests drive
8771    the real evaluation path against a config store that persists what the flow writes.
8772    """
8773
8774    @staticmethod
8775    def _setup_controller(
8776        mock_mass: MagicMock,
8777    ) -> tuple[PlayerController, dict[str, Any]]:
8778        """
8779        Wire a controller, the universal player provider and a persisting config store.
8780
8781        :param mock_mass: The mocked MusicAssistant instance to wire.
8782        :return: Tuple of (controller, backing config store).
8783        """
8784        controller = PlayerController(mock_mass)
8785        up_provider = create_mock_universal_provider(mock_mass)
8786        mock_mass.get_providers = MagicMock(return_value=[up_provider])
8787
8788        store: dict[str, Any] = {CONF_PLAYERS: {}}
8789        _wire_nested_config(mock_mass, store)
8790        mock_mass.config.create_default_player_config = MagicMock(
8791            side_effect=lambda *args, **kwargs: _fake_create_default_player_config(
8792                store, *args, **kwargs
8793            )
8794        )
8795        mock_mass.config.get_base_player_config.return_value = create_mock_config("Test")
8796
8797        async def fake_register_or_update(player: Player) -> None:
8798            controller._players[player.player_id] = player
8799            player.set_initialized()
8800
8801        mock_mass.players = MagicMock()
8802        mock_mass.players.register_or_update = AsyncMock(side_effect=fake_register_or_update)
8803        mock_mass.players.get_player = lambda pid, *_args, **_kwargs: controller._players.get(pid)
8804        return controller, store
8805
8806    @staticmethod
8807    def _make_airplay_player(
8808        mock_mass: MagicMock, player_id: str, name: str, identifiers: dict[IdentifierType, str]
8809    ) -> MockPlayer:
8810        """Return an unlinked AirPlay protocol player."""
8811        player = MockPlayer(
8812            MockProvider("airplay", mass=mock_mass),
8813            player_id,
8814            name,
8815            player_type=PlayerType.PROTOCOL,
8816            identifiers=identifiers,
8817        )
8818        player.set_initialized()
8819        return player
8820
8821    async def test_id_survives_recreation_with_other_identifiers(
8822        self, mock_mass: MagicMock
8823    ) -> None:
8824        """
8825        A re-created wrapper keeps its id when a different set of identifiers is known.
8826
8827        The first evaluation only knows the device's MAC, the second only its UUID.
8828        The derived key changed along with that, taking the player id with it.
8829        """
8830        controller, store = self._setup_controller(mock_mass)
8831        first = self._make_airplay_player(
8832            mock_mass, "ap_1", "Living Room", {IdentifierType.MAC_ADDRESS: "54:78:C9:E6:0D:A0"}
8833        )
8834        controller._players = {"ap_1": first}
8835
8836        await controller._delayed_protocol_evaluation("ap_1")
8837
8838        universal_id = first.protocol_parent_id
8839        assert universal_id is not None
8840        assert re.fullmatch(r"up[0-9a-f]{8}", universal_id)
8841
8842        # tear the wrapper down (its config is kept, as a non-permanent unregister does)
8843        # and bring the device back with only a UUID to identify it by
8844        del controller._players[universal_id]
8845        second = self._make_airplay_player(
8846            mock_mass,
8847            "ap_1",
8848            "Living Room",
8849            {IdentifierType.UUID: "12345678-1234-1234-1234-123456789abc"},
8850        )
8851        controller._players = {"ap_1": second}
8852
8853        await controller._delayed_protocol_evaluation("ap_1")
8854
8855        assert second.protocol_parent_id == universal_id
8856        assert universal_id in controller._players
8857        assert store[CONF_PLAYERS]["ap_1"]["values"][CONF_PROTOCOL_PARENT_ID] == universal_id
8858
8859    async def test_wrappers_created_in_one_second_still_order(self, mock_mass: MagicMock) -> None:
8860        """
8861        Two wrappers created back to back get distinct creation timestamps.
8862
8863        A merge between them must be able to keep the older one, which it cannot do
8864        when both carry the same timestamp and the tiebreak falls back to the id.
8865        """
8866        controller, store = self._setup_controller(mock_mass)
8867        players = [
8868            self._make_airplay_player(
8869                mock_mass, f"ap_{index}", f"Speaker {index}", {IdentifierType.MAC_ADDRESS: mac}
8870            )
8871            for index, mac in enumerate(("54:78:C9:E6:0D:A0", "AA:BB:CC:DD:EE:FF"), start=1)
8872        ]
8873        controller._players = {player.player_id: player for player in players}
8874
8875        for player in players:
8876            await controller._delayed_protocol_evaluation(player.player_id)
8877
8878        created = [
8879            store[CONF_PLAYERS][player.protocol_parent_id]["values"][CONF_CREATED_AT]
8880            for player in players
8881        ]
8882        assert all(isinstance(value, int) for value in created)
8883        assert created[0] < created[1]
8884
8885    async def test_new_devices_get_distinct_minted_ids(self, mock_mass: MagicMock) -> None:
8886        """Devices that were never wrapped before each get their own minted id."""
8887        controller, _ = self._setup_controller(mock_mass)
8888        players = [
8889            self._make_airplay_player(
8890                mock_mass, f"ap_{index}", f"Speaker {index}", {IdentifierType.MAC_ADDRESS: mac}
8891            )
8892            for index, mac in enumerate(("54:78:C9:E6:0D:A0", "AA:BB:CC:DD:EE:FF"), start=1)
8893        ]
8894        controller._players = {player.player_id: player for player in players}
8895
8896        for player in players:
8897            await controller._delayed_protocol_evaluation(player.player_id)
8898
8899        universal_ids = [player.protocol_parent_id for player in players]
8900        assert all(pid and re.fullmatch(r"up[0-9a-f]{8}", pid) for pid in universal_ids)
8901        assert len(set(universal_ids)) == len(players)
8902
8903    async def test_duplicate_domain_gets_a_second_wrapper_with_a_parent_link(
8904        self, mock_mass: MagicMock
8905    ) -> None:
8906        """
8907        Two players of one protocol domain end up on two wrappers, both linked.
8908
8909        The handoff must persist a parent link for the second player as well,
8910        otherwise it silently stays unwrapped.
8911        """
8912        controller, store = self._setup_controller(mock_mass)
8913        identifiers = {IdentifierType.MAC_ADDRESS: "54:78:C9:E6:0D:A0"}
8914        ap1 = self._make_airplay_player(mock_mass, "ap_1", "Zone 1", identifiers)
8915        ap2 = self._make_airplay_player(mock_mass, "ap_2", "Zone 2", identifiers)
8916        controller._players = {"ap_1": ap1, "ap_2": ap2}
8917
8918        await controller._create_or_update_universal_player([ap1, ap2])
8919
8920        assert ap1.protocol_parent_id is not None
8921        assert ap2.protocol_parent_id is not None
8922        assert ap1.protocol_parent_id != ap2.protocol_parent_id
8923        for player in (ap1, ap2):
8924            stored = store[CONF_PLAYERS][player.player_id]["values"][CONF_PROTOCOL_PARENT_ID]
8925            assert stored == player.protocol_parent_id
8926
8927
8928class TestSiblingProtocolMatching:
8929    """
8930    Tests for sibling protocol matching via _match_via_linked_protocols.
8931
8932    When a native player (e.g., HEOS) doesn't have its own MAC/serial identifiers
8933    but has protocol players (e.g., AirPlay) already linked, a new protocol player
8934    (e.g., Sendspin bridge) should be matched to the native player through the
8935    shared identifiers of sibling protocols.
8936    """
8937
8938    def test_sendspin_matches_heos_via_airplay_sibling(self, mock_mass: MagicMock) -> None:
8939        """
8940        Test Sendspin links to HEOS through AirPlay's shared AIRPLAY_ID.
8941
8942        HEOS has no MAC/UUID identifiers. AirPlay is already linked to HEOS.
8943        Sendspin has AIRPLAY_ID matching AirPlay. Sibling matching should link
8944        Sendspin to HEOS.
8945        """
8946        controller = PlayerController(mock_mass)
8947
8948        # HEOS native player - no MAC, no serial, no UUID (just IP from discovery)
8949        heos_provider = MockProvider("heos", mass=mock_mass)
8950        heos_player = MockPlayer(
8951            heos_provider,
8952            "2140037800",
8953            "Denon AVR-X2700H",
8954            player_type=PlayerType.PLAYER,
8955        )
8956        heos_player._attr_supported_features = {PlayerFeature.VOLUME_SET, PlayerFeature.PLAY_MEDIA}
8957        heos_player._cache.clear()
8958        heos_player.set_initialized()
8959
8960        # AirPlay protocol player already linked to HEOS
8961        ap_provider = MockProvider("airplay", mass=mock_mass)
8962        ap_player = MockPlayer(
8963            ap_provider,
8964            "ap000678747b16",
8965            "Denon AVR-X2700H (AirPlay)",
8966            player_type=PlayerType.PROTOCOL,
8967            identifiers={
8968                IdentifierType.MAC_ADDRESS: "00:06:78:74:7B:16",
8969                IdentifierType.AIRPLAY_ID: "ap000678747b16",
8970            },
8971        )
8972        ap_player.set_initialized()
8973
8974        # Link AirPlay to HEOS
8975        controller._players = {
8976            "2140037800": heos_player,
8977            "ap000678747b16": ap_player,
8978        }
8979        controller._add_protocol_link(heos_player, ap_player, "airplay")
8980        assert ap_player.protocol_parent_id == "2140037800"
8981
8982        # Sendspin bridge with AIRPLAY_ID matching AirPlay
8983        spb_provider = MockProvider("sendspin", mass=mock_mass)
8984        spb_player = MockPlayer(
8985            spb_provider,
8986            "spb_000678747b16",
8987            "Denon AVR-X2700H (Sendspin)",
8988            player_type=PlayerType.PROTOCOL,
8989            identifiers={
8990                IdentifierType.MAC_ADDRESS: "00:06:78:74:7B:16",
8991                IdentifierType.AIRPLAY_ID: "ap000678747b16",
8992            },
8993        )
8994        spb_player.set_initialized()
8995        controller._players["spb_000678747b16"] = spb_player
8996
8997        # Direct identifier match would fail (HEOS has no identifiers)
8998        assert controller._identifiers_match(heos_player, spb_player, "sendspin") is False
8999
9000        # Sibling matching should succeed through AirPlay's identifiers
9001        assert controller._match_via_linked_protocols(heos_player, spb_player, "sendspin") is True
9002        assert spb_player.protocol_parent_id == "2140037800"
9003
9004    def test_sibling_match_no_linked_protocols(self, mock_mass: MagicMock) -> None:
9005        """Test sibling matching returns False when native player has no linked protocols."""
9006        controller = PlayerController(mock_mass)
9007
9008        heos_provider = MockProvider("heos", mass=mock_mass)
9009        heos_player = MockPlayer(
9010            heos_provider, "heos_1", "HEOS Player", player_type=PlayerType.PLAYER
9011        )
9012        heos_player.set_initialized()
9013
9014        spb_provider = MockProvider("sendspin", mass=mock_mass)
9015        spb_player = MockPlayer(
9016            spb_provider,
9017            "spb_1",
9018            "Sendspin",
9019            player_type=PlayerType.PROTOCOL,
9020            identifiers={IdentifierType.AIRPLAY_ID: "ap_1"},
9021        )
9022        spb_player.set_initialized()
9023
9024        controller._players = {"heos_1": heos_player, "spb_1": spb_player}
9025
9026        assert controller._match_via_linked_protocols(heos_player, spb_player, "sendspin") is False
9027
9028    def test_sibling_match_no_identifier_overlap(self, mock_mass: MagicMock) -> None:
9029        """Test sibling matching returns False when identifiers don't overlap."""
9030        controller = PlayerController(mock_mass)
9031
9032        heos_provider = MockProvider("heos", mass=mock_mass)
9033        heos_player = MockPlayer(
9034            heos_provider, "heos_1", "HEOS Player", player_type=PlayerType.PLAYER
9035        )
9036        heos_player.set_initialized()
9037
9038        # AirPlay with one MAC
9039        ap_provider = MockProvider("airplay", mass=mock_mass)
9040        ap_player = MockPlayer(
9041            ap_provider,
9042            "ap_1",
9043            "AirPlay",
9044            player_type=PlayerType.PROTOCOL,
9045            identifiers={IdentifierType.MAC_ADDRESS: "AA:BB:CC:DD:EE:FF"},
9046        )
9047        ap_player.set_initialized()
9048
9049        controller._players = {"heos_1": heos_player, "ap_1": ap_player}
9050        controller._add_protocol_link(heos_player, ap_player, "airplay")
9051
9052        # Sendspin with different MAC, no shared identifiers
9053        spb_provider = MockProvider("sendspin", mass=mock_mass)
9054        spb_player = MockPlayer(
9055            spb_provider,
9056            "spb_1",
9057            "Sendspin",
9058            player_type=PlayerType.PROTOCOL,
9059            identifiers={IdentifierType.MAC_ADDRESS: "11:22:33:44:55:66"},
9060        )
9061        spb_player.set_initialized()
9062        controller._players["spb_1"] = spb_player
9063
9064        assert controller._match_via_linked_protocols(heos_player, spb_player, "sendspin") is False
9065        assert spb_player.protocol_parent_id is None
9066
9067    def test_sibling_match_refuses_duplicate_domain(self, mock_mass: MagicMock) -> None:
9068        """Test sibling matching refuses link when domain is already active on native player."""
9069        controller = PlayerController(mock_mass)
9070
9071        heos_provider = MockProvider("heos", mass=mock_mass)
9072        heos_player = MockPlayer(
9073            heos_provider, "heos_1", "HEOS Player", player_type=PlayerType.PLAYER
9074        )
9075        heos_player.set_initialized()
9076
9077        # AirPlay already linked
9078        ap_provider = MockProvider("airplay", mass=mock_mass)
9079        ap_player = MockPlayer(
9080            ap_provider,
9081            "ap_1",
9082            "AirPlay",
9083            player_type=PlayerType.PROTOCOL,
9084            identifiers={IdentifierType.MAC_ADDRESS: "AA:BB:CC:DD:EE:FF"},
9085        )
9086        ap_player.set_initialized()
9087
9088        controller._players = {"heos_1": heos_player, "ap_1": ap_player}
9089        controller._add_protocol_link(heos_player, ap_player, "airplay")
9090
9091        # Second AirPlay instance with same MAC - should be refused (duplicate domain)
9092        ap2 = MockPlayer(
9093            ap_provider,
9094            "ap_2",
9095            "AirPlay 2",
9096            player_type=PlayerType.PROTOCOL,
9097            identifiers={IdentifierType.MAC_ADDRESS: "AA:BB:CC:DD:EE:FF"},
9098        )
9099        ap2.set_initialized()
9100        controller._players["ap_2"] = ap2
9101
9102        assert controller._match_via_linked_protocols(heos_player, ap2, "airplay") is False
9103        assert ap2.protocol_parent_id is None
9104
9105    def test_try_link_to_existing_uses_sibling_matching(self, mock_mass: MagicMock) -> None:
9106        """Test _try_link_to_existing_player falls back to sibling matching."""
9107        controller = PlayerController(mock_mass)
9108
9109        # No cached protocol IDs for HEOS
9110        mock_mass.config.get = MagicMock(return_value=[])
9111
9112        heos_provider = MockProvider("heos", mass=mock_mass)
9113        heos_player = MockPlayer(
9114            heos_provider,
9115            "2140037800",
9116            "Denon AVR-X2700H",
9117            player_type=PlayerType.PLAYER,
9118        )
9119        heos_player.set_initialized()
9120
9121        ap_provider = MockProvider("airplay", mass=mock_mass)
9122        ap_player = MockPlayer(
9123            ap_provider,
9124            "ap000678747b16",
9125            "Denon AirPlay",
9126            player_type=PlayerType.PROTOCOL,
9127            identifiers={IdentifierType.AIRPLAY_ID: "ap000678747b16"},
9128        )
9129        ap_player.set_initialized()
9130
9131        controller._players = {
9132            "2140037800": heos_player,
9133            "ap000678747b16": ap_player,
9134        }
9135        controller._add_protocol_link(heos_player, ap_player, "airplay")
9136
9137        # Sendspin with matching AIRPLAY_ID
9138        spb_provider = MockProvider("sendspin", mass=mock_mass)
9139        spb_player = MockPlayer(
9140            spb_provider,
9141            "spb_000678747b16",
9142            "Denon Sendspin",
9143            player_type=PlayerType.PROTOCOL,
9144            identifiers={IdentifierType.AIRPLAY_ID: "ap000678747b16"},
9145        )
9146        spb_player.set_initialized()
9147        controller._players["spb_000678747b16"] = spb_player
9148
9149        # _try_link_to_existing_player should succeed via sibling matching
9150        result = controller._try_link_to_existing_player(spb_player, "sendspin")
9151        assert result is True
9152        assert spb_player.protocol_parent_id == "2140037800"
9153
9154
9155class TestDelayedEvalRetry:
9156    """
9157    Tests for the retry of _try_link_to_existing_player in _delayed_protocol_evaluation.
9158
9159    When a protocol player's delayed evaluation fires, native players may have
9160    registered during the delay period. The retry ensures they are checked before
9161    falling through to universal player creation.
9162    """
9163
9164    @pytest.mark.asyncio
9165    async def test_delayed_eval_links_to_native_registered_during_delay(
9166        self, mock_mass: MagicMock
9167    ) -> None:
9168        """
9169        Test delayed eval links to a native player that registered during the delay.
9170
9171        Scenario: Sendspin registers before HEOS. The delayed evaluation fires
9172        after HEOS has registered and has AirPlay linked. The retry should find
9173        HEOS via sibling matching and link Sendspin.
9174        """
9175        controller, _ = TestEndToEndDuplicateProtocol._setup_e2e_controller(mock_mass)
9176
9177        # No cached protocol IDs
9178        mock_mass.config.get = MagicMock(return_value=[])
9179
9180        # HEOS native player (no device identifiers)
9181        heos_provider = MockProvider("heos", mass=mock_mass)
9182        heos_player = MockPlayer(
9183            heos_provider,
9184            "2140037800",
9185            "Denon AVR-X2700H",
9186            player_type=PlayerType.PLAYER,
9187        )
9188        heos_player._attr_supported_features = {PlayerFeature.VOLUME_SET, PlayerFeature.PLAY_MEDIA}
9189        heos_player._cache.clear()
9190        heos_player.set_initialized()
9191
9192        # AirPlay already linked to HEOS
9193        ap_provider = MockProvider("airplay", mass=mock_mass)
9194        ap_player = MockPlayer(
9195            ap_provider,
9196            "ap000678747b16",
9197            "Denon AirPlay",
9198            player_type=PlayerType.PROTOCOL,
9199            identifiers={
9200                IdentifierType.MAC_ADDRESS: "00:06:78:74:7B:16",
9201                IdentifierType.AIRPLAY_ID: "ap000678747b16",
9202            },
9203        )
9204        ap_player.set_initialized()
9205        ap_player.set_protocol_parent_id("2140037800")
9206
9207        # Sendspin (not yet linked)
9208        spb_provider = MockProvider("sendspin", mass=mock_mass)
9209        spb_player = MockPlayer(
9210            spb_provider,
9211            "spb_000678747b16",
9212            "Denon Sendspin",
9213            player_type=PlayerType.PROTOCOL,
9214            identifiers={
9215                IdentifierType.MAC_ADDRESS: "00:06:78:74:7B:16",
9216                IdentifierType.AIRPLAY_ID: "ap000678747b16",
9217            },
9218        )
9219        spb_player.set_initialized()
9220
9221        # Set up players dict (simulating: HEOS registered during the delay)
9222        controller._players = {
9223            "2140037800": heos_player,
9224            "ap000678747b16": ap_player,
9225            "spb_000678747b16": spb_player,
9226        }
9227
9228        # Link AirPlay to HEOS
9229        controller._add_protocol_link(heos_player, ap_player, "airplay")
9230
9231        # Run delayed evaluation for Sendspin
9232        await controller._delayed_protocol_evaluation("spb_000678747b16")
9233
9234        # Sendspin should be linked to HEOS, not a new universal player
9235        assert spb_player.protocol_parent_id == "2140037800"
9236
9237    @pytest.mark.asyncio
9238    async def test_delayed_eval_skips_already_linked(self, mock_mass: MagicMock) -> None:
9239        """Test delayed eval returns early if protocol player was linked during the delay."""
9240        controller, _ = TestEndToEndDuplicateProtocol._setup_e2e_controller(mock_mass)
9241
9242        spb_provider = MockProvider("sendspin", mass=mock_mass)
9243        spb_player = MockPlayer(
9244            spb_provider,
9245            "spb_1",
9246            "Sendspin",
9247            player_type=PlayerType.PROTOCOL,
9248            identifiers={IdentifierType.AIRPLAY_ID: "ap_1"},
9249        )
9250        spb_player.set_initialized()
9251        # Simulate it was linked during the delay
9252        spb_player.set_protocol_parent_id("some_parent")
9253
9254        controller._players = {"spb_1": spb_player}
9255
9256        # Should return early without creating universal player
9257        await controller._delayed_protocol_evaluation("spb_1")
9258        assert spb_player.protocol_parent_id == "some_parent"
9259
9260
9261class TestNativePlayerSiblingLinking:
9262    """
9263    Tests for the second pass in _try_link_protocols_to_native using sibling matching.
9264
9265    After a native player registers and recovers cached protocol links, the second
9266    pass should match remaining unlinked protocol players via sibling identifiers.
9267    """
9268
9269    def test_native_registration_links_sendspin_via_sibling(self, mock_mass: MagicMock) -> None:
9270        """
9271        Test HEOS registration links Sendspin via AirPlay sibling identifiers.
9272
9273        Scenario: Sendspin registered before HEOS. When HEOS registers, the first
9274        pass can't match Sendspin (no direct identifiers). After recovering cached
9275        AirPlay link, the second pass matches Sendspin via AirPlay's AIRPLAY_ID.
9276        """
9277        controller = PlayerController(mock_mass)
9278
9279        # Set up config to return cached AirPlay link for HEOS
9280        def config_get_side_effect(key: str, default: object = None) -> object:
9281            if key == "players/2140037800/values/linked_protocol_ids":
9282                return ["ap000678747b16"]
9283            if key == "players/ap000678747b16":
9284                return {
9285                    "provider": "airplay--test",
9286                    "player_type": "protocol",
9287                    "values": {"protocol_parent_id": "2140037800"},
9288                }
9289            if key == "players":
9290                return {
9291                    "ap000678747b16": {
9292                        "provider": "airplay--test",
9293                        "player_type": "protocol",
9294                        "values": {"protocol_parent_id": "2140037800"},
9295                    },
9296                    "spb_000678747b16": {
9297                        "provider": "sendspin--test",
9298                        "player_type": "protocol",
9299                        "values": {},
9300                    },
9301                }
9302            return default
9303
9304        mock_mass.config.get = MagicMock(side_effect=config_get_side_effect)
9305        mock_mass.get_provider = MagicMock(return_value=None)
9306
9307        # AirPlay protocol player (already registered, waiting for parent)
9308        ap_provider = MockProvider("airplay", mass=mock_mass)
9309        ap_player = MockPlayer(
9310            ap_provider,
9311            "ap000678747b16",
9312            "Denon AirPlay",
9313            player_type=PlayerType.PROTOCOL,
9314            identifiers={
9315                IdentifierType.MAC_ADDRESS: "00:06:78:74:7B:16",
9316                IdentifierType.AIRPLAY_ID: "ap000678747b16",
9317            },
9318        )
9319        ap_player.set_initialized()
9320        # AirPlay has cached parent set but parent hasn't registered yet
9321        ap_player.set_protocol_parent_id("2140037800")
9322
9323        # Sendspin (registered, not linked)
9324        spb_provider = MockProvider("sendspin", mass=mock_mass)
9325        spb_player = MockPlayer(
9326            spb_provider,
9327            "spb_000678747b16",
9328            "Denon Sendspin",
9329            player_type=PlayerType.PROTOCOL,
9330            identifiers={
9331                IdentifierType.MAC_ADDRESS: "00:06:78:74:7B:16",
9332                IdentifierType.AIRPLAY_ID: "ap000678747b16",
9333            },
9334        )
9335        spb_player.set_initialized()
9336
9337        # HEOS native player registers now
9338        heos_provider = MockProvider("heos", mass=mock_mass)
9339        heos_player = MockPlayer(
9340            heos_provider,
9341            "2140037800",
9342            "Denon AVR-X2700H",
9343            player_type=PlayerType.PLAYER,
9344        )
9345        heos_player._attr_supported_features = {PlayerFeature.VOLUME_SET, PlayerFeature.PLAY_MEDIA}
9346        heos_player._cache.clear()
9347        heos_player.set_initialized()
9348
9349        controller._players = {
9350            "ap000678747b16": ap_player,
9351            "spb_000678747b16": spb_player,
9352            "2140037800": heos_player,
9353        }
9354
9355        # Simulate what happens when HEOS registers
9356        controller._try_link_protocols_to_native(heos_player)
9357
9358        # AirPlay should be recovered from cache (linked_output_protocols)
9359        airplay_linked = any(
9360            link.output_protocol_id == "ap000678747b16"
9361            for link in heos_player.linked_output_protocols
9362        )
9363        assert airplay_linked, "AirPlay should be recovered from cached protocol links"
9364
9365        # Sendspin should be linked via sibling matching (second pass)
9366        assert spb_player.protocol_parent_id == "2140037800"
9367        sendspin_linked = any(
9368            link.output_protocol_id == "spb_000678747b16"
9369            for link in heos_player.linked_output_protocols
9370        )
9371        assert sendspin_linked, "Sendspin should be linked via sibling protocol matching"
9372
9373
9374class TestCachedProtocolLinkRecovery:
9375    """Tests for recovering protocol links from config."""
9376
9377    def test_recovered_links_are_applied_through_the_setter(self, mock_mass: MagicMock) -> None:
9378        """Recovery applies a single link per protocol domain through the setter."""
9379        controller = PlayerController(mock_mass)
9380
9381        protocol_configs = {
9382            "ap_first": {
9383                "provider": "airplay--first",
9384                "player_type": "protocol",
9385                "values": {"protocol_parent_id": "heos_player"},
9386            },
9387            "ap_second": {
9388                "provider": "airplay--second",
9389                "player_type": "protocol",
9390                "values": {"protocol_parent_id": "heos_player"},
9391            },
9392        }
9393
9394        def config_get_side_effect(key: str, default: object = None) -> object:
9395            if key == "players/heos_player/values/linked_protocol_ids":
9396                return ["ap_first", "ap_second"]
9397            if key == "players":
9398                return protocol_configs
9399            if key.startswith("players/"):
9400                return protocol_configs.get(key.split("/", maxsplit=1)[1])
9401            return default
9402
9403        mock_mass.config.get = MagicMock(side_effect=config_get_side_effect)
9404
9405        heos_player = MockPlayer(
9406            MockProvider("heos", mass=mock_mass),
9407            "heos_player",
9408            "Denon AVR-X2700H",
9409        )
9410
9411        controller._recover_cached_protocol_links(heos_player)
9412
9413        recovered = [link.output_protocol_id for link in heos_player.linked_output_protocols]
9414        assert recovered == ["ap_first"]
9415        mock_mass.players.trigger_player_update.assert_called_once_with("heos_player")
9416
9417        # a repeat pass has nothing left to recover, so no update is triggered
9418        controller._recover_cached_protocol_links(heos_player)
9419        mock_mass.players.trigger_player_update.assert_called_once_with("heos_player")
9420
9421    def test_protocol_owned_by_another_parent_is_not_recovered(self, mock_mass: MagicMock) -> None:
9422        """A cached protocol that another parent owns stays out of this parent's outputs."""
9423        controller = PlayerController(mock_mass)
9424
9425        store: dict[str, Any] = {
9426            CONF_PLAYERS: {
9427                "ap_shared": {
9428                    "provider": "airplay--x",
9429                    "player_type": "protocol",
9430                    "values": {CONF_PROTOCOL_PARENT_ID: "cast_owner"},
9431                },
9432                "heos_player": {
9433                    "provider": "heos--y",
9434                    "player_type": "player",
9435                    "values": {"linked_protocol_ids": ["ap_shared"]},
9436                },
9437                "cast_owner": {
9438                    "provider": "chromecast--z",
9439                    "player_type": "player",
9440                    "values": {"linked_protocol_ids": ["ap_shared"]},
9441                },
9442            }
9443        }
9444        _wire_nested_config(mock_mass, store)
9445
9446        airplay = MockPlayer(
9447            MockProvider("airplay", mass=mock_mass),
9448            "ap_shared",
9449            "AVR (AirPlay)",
9450            player_type=PlayerType.PROTOCOL,
9451        )
9452        airplay.set_protocol_parent_id("cast_owner")
9453        owner = MockPlayer(MockProvider("chromecast", mass=mock_mass), "cast_owner", "AVR (Cast)")
9454        owner.set_linked_output_protocols(
9455            [LinkedOutputProtocol(output_protocol_id="ap_shared", protocol_domain="airplay")]
9456        )
9457        heos_player = MockPlayer(MockProvider("heos", mass=mock_mass), "heos_player", "AVR")
9458        controller._players = {
9459            "ap_shared": airplay,
9460            "cast_owner": owner,
9461            "heos_player": heos_player,
9462        }
9463
9464        controller._recover_cached_protocol_links(heos_player)
9465
9466        assert heos_player.linked_output_protocols == []
9467        # the domain slot stays free for this parent's own AirPlay endpoint
9468        assert not controller._parent_has_active_protocol_from_domain(heos_player, "airplay")
9469        assert airplay.protocol_parent_id == "cast_owner"
9470
9471    def test_unowned_and_own_cached_protocols_are_still_recovered(
9472        self, mock_mass: MagicMock
9473    ) -> None:
9474        """A disabled protocol without a live parent is still recovered for its parent."""
9475        controller = PlayerController(mock_mass)
9476
9477        store: dict[str, Any] = {
9478            CONF_PLAYERS: {
9479                "ap_disabled": {
9480                    "provider": "airplay--x",
9481                    "player_type": "protocol",
9482                    "values": {CONF_PROTOCOL_PARENT_ID: "heos_player"},
9483                },
9484                "dlna_own": {
9485                    "provider": "dlna--x",
9486                    "player_type": "protocol",
9487                    "values": {CONF_PROTOCOL_PARENT_ID: "heos_player"},
9488                },
9489                "heos_player": {
9490                    "provider": "heos--y",
9491                    "player_type": "player",
9492                    "values": {"linked_protocol_ids": ["ap_disabled", "dlna_own"]},
9493                },
9494            }
9495        }
9496        _wire_nested_config(mock_mass, store)
9497
9498        # registered but not owned by anyone (e.g. freshly detached)
9499        dlna = MockPlayer(
9500            MockProvider("dlna", mass=mock_mass),
9501            "dlna_own",
9502            "AVR (DLNA)",
9503            player_type=PlayerType.PROTOCOL,
9504        )
9505        heos_player = MockPlayer(MockProvider("heos", mass=mock_mass), "heos_player", "AVR")
9506        controller._players = {"dlna_own": dlna, "heos_player": heos_player}
9507
9508        controller._recover_cached_protocol_links(heos_player)
9509
9510        recovered = {link.output_protocol_id for link in heos_player.linked_output_protocols}
9511        # ap_disabled is not registered at all, dlna_own is registered without a parent
9512        assert recovered == {"ap_disabled", "dlna_own"}
9513
9514    def test_protocol_waiting_for_its_owner_is_not_recovered(self, mock_mass: MagicMock) -> None:
9515        """A protocol whose owner has not registered yet is left to that owner."""
9516        controller = PlayerController(mock_mass)
9517
9518        store: dict[str, Any] = {
9519            CONF_PLAYERS: {
9520                "ap_shared": {
9521                    "provider": "airplay--x",
9522                    "player_type": "protocol",
9523                    "values": {CONF_PROTOCOL_PARENT_ID: "cast_owner"},
9524                },
9525                "heos_player": {
9526                    "provider": "heos--y",
9527                    "player_type": "player",
9528                    "values": {"linked_protocol_ids": ["ap_shared"]},
9529                },
9530                "cast_owner": {
9531                    "provider": "chromecast--z",
9532                    "player_type": "player",
9533                    "values": {"linked_protocol_ids": ["ap_shared"]},
9534                },
9535            }
9536        }
9537        _wire_nested_config(mock_mass, store)
9538
9539        # the protocol is registered but still unparented: its owner is not registered yet
9540        airplay = MockPlayer(
9541            MockProvider("airplay", mass=mock_mass),
9542            "ap_shared",
9543            "AVR (AirPlay)",
9544            player_type=PlayerType.PROTOCOL,
9545        )
9546        heos_player = MockPlayer(MockProvider("heos", mass=mock_mass), "heos_player", "AVR")
9547        controller._players = {"ap_shared": airplay, "heos_player": heos_player}
9548
9549        controller._recover_cached_protocol_links(heos_player)
9550
9551        assert airplay.protocol_parent_id is None
9552        assert heos_player.linked_output_protocols == []
9553        assert not controller._parent_has_active_protocol_from_domain(heos_player, "airplay")
9554
9555    def test_bridge_owned_by_wrapper_leaves_native_domain_slot_free(
9556        self, mock_mass: MagicMock
9557    ) -> None:
9558        """
9559        A bridge that stayed with its wrapper is not recovered onto a native player.
9560
9561        A native player may carry a stale cached id for a bridge whose underlying
9562        connection it could not claim. The bridge follows that connection, so the
9563        native player must leave the domain slot free for its own bridge.
9564        """
9565        controller = PlayerController(mock_mass)
9566
9567        store: dict[str, Any] = {
9568            CONF_PLAYERS: {
9569                "cast_base": {
9570                    "provider": "chromecast--x",
9571                    "player_type": "protocol",
9572                    "values": {CONF_PROTOCOL_PARENT_ID: STORED_UNIVERSAL_ID},
9573                },
9574                "spb_bridge": {
9575                    "provider": "sendspin--y",
9576                    "player_type": "protocol",
9577                    "values": {
9578                        CONF_PROTOCOL_PARENT_ID: "heos_player",
9579                        CONF_UNDERLYING_PLAYER_ID: "cast_base",
9580                    },
9581                },
9582                STORED_UNIVERSAL_ID: {
9583                    "provider": "universal_player",
9584                    "player_type": "player",
9585                    "values": {"linked_protocol_ids": ["cast_base"]},
9586                },
9587                "heos_player": {
9588                    "provider": "heos--z",
9589                    "player_type": "player",
9590                    "values": {"linked_protocol_ids": ["spb_bridge"]},
9591                },
9592            }
9593        }
9594        _wire_nested_config(mock_mass, store)
9595
9596        wrapper = _create_universal_player(
9597            mock_mass, STORED_UNIVERSAL_ID, "AVR (wrapper)", ["cast_base"]
9598        )
9599        base = MockPlayer(
9600            MockProvider("chromecast", mass=mock_mass),
9601            "cast_base",
9602            "AVR (Cast 2)",
9603            player_type=PlayerType.PROTOCOL,
9604        )
9605        base.set_protocol_parent_id(STORED_UNIVERSAL_ID)
9606        wrapper.set_linked_output_protocols(
9607            [LinkedOutputProtocol(output_protocol_id="cast_base", protocol_domain="chromecast")]
9608        )
9609        bridge = MockPlayer(
9610            MockProvider("sendspin", mass=mock_mass),
9611            "spb_bridge",
9612            "AVR (Bridge)",
9613            player_type=PlayerType.PROTOCOL,
9614        )
9615        bridge._attr_underlying_player_id = "cast_base"
9616        bridge.set_initialized()
9617        heos_player = MockPlayer(MockProvider("heos", mass=mock_mass), "heos_player", "AVR")
9618        controller._players = {
9619            STORED_UNIVERSAL_ID: wrapper,
9620            "cast_base": base,
9621            "spb_bridge": bridge,
9622            "heos_player": heos_player,
9623        }
9624
9625        # the bridge resolves via its underlying connection, so it joins the wrapper
9626        controller._try_link_protocol_to_native(bridge)
9627        assert bridge.protocol_parent_id == STORED_UNIVERSAL_ID
9628
9629        controller._recover_cached_protocol_links(heos_player)
9630
9631        assert heos_player.linked_output_protocols == []
9632        assert not controller._parent_has_active_protocol_from_domain(heos_player, "sendspin")
9633
9634    def test_claiming_a_protocol_clears_it_from_its_previous_parent(
9635        self, mock_mass: MagicMock
9636    ) -> None:
9637        """
9638        A parent loses its output entry for a protocol that another parent claims.
9639
9640        A bridge that registers before the connection it runs on has no owner to
9641        compare against yet, so a stale cached id still recovers it onto a native
9642        player. Once the connection registers and takes the bridge along, the native
9643        player must give up its entry instead of listing the bridge as well.
9644        """
9645        controller = PlayerController(mock_mass)
9646
9647        store: dict[str, Any] = {
9648            CONF_PLAYERS: {
9649                "cast_base": {
9650                    "provider": "chromecast--x",
9651                    "player_type": "protocol",
9652                    "values": {CONF_PROTOCOL_PARENT_ID: STORED_UNIVERSAL_ID},
9653                },
9654                "spb_bridge": {
9655                    "provider": "sendspin--y",
9656                    "player_type": "protocol",
9657                    "values": {
9658                        CONF_PROTOCOL_PARENT_ID: "heos_player",
9659                        CONF_UNDERLYING_PLAYER_ID: "cast_base",
9660                    },
9661                },
9662                STORED_UNIVERSAL_ID: {
9663                    "provider": "universal_player",
9664                    "player_type": "player",
9665                    "values": {"linked_protocol_ids": ["cast_base"]},
9666                },
9667                "heos_player": {
9668                    "provider": "heos--z",
9669                    "player_type": "player",
9670                    "values": {"linked_protocol_ids": ["spb_bridge"]},
9671                },
9672            }
9673        }
9674        _wire_nested_config(mock_mass, store)
9675
9676        wrapper = _create_universal_player(
9677            mock_mass, STORED_UNIVERSAL_ID, "AVR (wrapper)", ["cast_base"]
9678        )
9679        bridge = MockPlayer(
9680            MockProvider("sendspin", mass=mock_mass),
9681            "spb_bridge",
9682            "AVR (Bridge)",
9683            player_type=PlayerType.PROTOCOL,
9684        )
9685        bridge._attr_underlying_player_id = "cast_base"
9686        bridge.set_initialized()
9687        heos_player = MockPlayer(MockProvider("heos", mass=mock_mass), "heos_player", "AVR")
9688        controller._players = {
9689            STORED_UNIVERSAL_ID: wrapper,
9690            "spb_bridge": bridge,
9691            "heos_player": heos_player,
9692        }
9693
9694        # the bridge has no owner yet, so the cached id recovers it onto the native player
9695        controller._recover_cached_protocol_links(heos_player)
9696        assert [link.output_protocol_id for link in heos_player.linked_output_protocols] == [
9697            "spb_bridge"
9698        ]
9699
9700        # the connection registers and joins the wrapper, taking the bridge with it
9701        base = MockPlayer(
9702            MockProvider("chromecast", mass=mock_mass),
9703            "cast_base",
9704            "AVR (Cast 2)",
9705            player_type=PlayerType.PROTOCOL,
9706        )
9707        controller._players["cast_base"] = base
9708        controller._try_link_protocol_to_native(base)
9709
9710        assert bridge.protocol_parent_id == STORED_UNIVERSAL_ID
9711        assert heos_player.linked_output_protocols == []
9712        assert not controller._parent_has_active_protocol_from_domain(heos_player, "sendspin")
9713        # the stale cached id is gone too, so the bridge is not offered here as disabled
9714        assert store[CONF_PLAYERS]["heos_player"]["values"]["linked_protocol_ids"] == []
9715
9716    def test_claiming_a_protocol_clears_universal_membership(self, mock_mass: MagicMock) -> None:
9717        """A wrapper that loses a protocol also gives up its stored membership."""
9718        controller = PlayerController(mock_mass)
9719
9720        store: dict[str, Any] = {
9721            CONF_PLAYERS: {
9722                "ap_shared": {
9723                    "provider": "airplay--x",
9724                    "player_type": "protocol",
9725                    "values": {CONF_PROTOCOL_PARENT_ID: STORED_UNIVERSAL_ID},
9726                },
9727                STORED_UNIVERSAL_ID: {
9728                    "provider": "universal_player",
9729                    "player_type": "player",
9730                    "values": {"linked_protocol_ids": ["ap_shared"]},
9731                },
9732                "heos_player": {
9733                    "provider": "heos--z",
9734                    "player_type": "player",
9735                    "values": {},
9736                },
9737            }
9738        }
9739        _wire_nested_config(mock_mass, store)
9740
9741        wrapper = _create_universal_player(
9742            mock_mass, STORED_UNIVERSAL_ID, "AVR (wrapper)", ["ap_shared"]
9743        )
9744        wrapper.set_linked_output_protocols(
9745            [LinkedOutputProtocol(output_protocol_id="ap_shared", protocol_domain="airplay")]
9746        )
9747        airplay = MockPlayer(
9748            MockProvider("airplay", mass=mock_mass),
9749            "ap_shared",
9750            "AVR (AirPlay)",
9751            player_type=PlayerType.PROTOCOL,
9752        )
9753        airplay.set_protocol_parent_id(STORED_UNIVERSAL_ID)
9754        heos_player = MockPlayer(MockProvider("heos", mass=mock_mass), "heos_player", "AVR")
9755        controller._players = {
9756            STORED_UNIVERSAL_ID: wrapper,
9757            "ap_shared": airplay,
9758            "heos_player": heos_player,
9759        }
9760
9761        controller._add_protocol_link(heos_player, airplay, "airplay")
9762
9763        assert airplay.protocol_parent_id == "heos_player"
9764        assert wrapper.linked_output_protocols == []
9765        # the wrapper's stored membership is authoritative and gets written back on save,
9766        # so it has to give up the protocol there as well
9767        assert wrapper._protocol_player_ids == []
9768        assert store[CONF_PLAYERS][STORED_UNIVERSAL_ID]["values"]["linked_protocol_ids"] == []
9769
9770
9771class TestMultiMACMatching:
9772    """Tests for multi-MAC matching (reported MAC vs ARP MAC)."""
9773
9774    def test_match_on_reported_mac_when_arp_mac_differs(self, mock_mass: MagicMock) -> None:
9775        """Two players match when one's reported MAC equals the other's ARP-resolved MAC."""
9776        controller = PlayerController(mock_mass)
9777
9778        provider = MockProvider("test")
9779        # Player A has ARP-resolved MAC in identifiers, reported MAC saved in extra_data
9780        player_a = MockPlayer(
9781            provider,
9782            "player_a",
9783            "Player A",
9784            identifiers={IdentifierType.MAC_ADDRESS: "AA:BB:CC:DD:EE:01"},
9785        )
9786        player_a.extra_data["reported_mac"] = "AA:BB:CC:DD:EE:02"
9787
9788        # Player B has a MAC that matches player_a's reported MAC
9789        player_b = MockPlayer(
9790            provider,
9791            "player_b",
9792            "Player B",
9793            identifiers={IdentifierType.MAC_ADDRESS: "AA:BB:CC:DD:EE:02"},
9794        )
9795
9796        assert controller._identifiers_match(player_a, player_b) is True
9797
9798    def test_match_on_reported_mac_reverse(self, mock_mass: MagicMock) -> None:
9799        """Match when player_b's reported MAC matches player_a's ARP MAC."""
9800        controller = PlayerController(mock_mass)
9801
9802        provider = MockProvider("test")
9803        player_a = MockPlayer(
9804            provider,
9805            "player_a",
9806            "Player A",
9807            identifiers={IdentifierType.MAC_ADDRESS: "AA:BB:CC:DD:EE:02"},
9808        )
9809
9810        player_b = MockPlayer(
9811            provider,
9812            "player_b",
9813            "Player B",
9814            identifiers={IdentifierType.MAC_ADDRESS: "AA:BB:CC:DD:EE:01"},
9815        )
9816        player_b.extra_data["reported_mac"] = "AA:BB:CC:DD:EE:02"
9817
9818        assert controller._identifiers_match(player_a, player_b) is True
9819
9820    def test_no_match_when_both_macs_differ(self, mock_mass: MagicMock) -> None:
9821        """No match when all MACs differ between players."""
9822        controller = PlayerController(mock_mass)
9823
9824        provider = MockProvider("test")
9825        player_a = MockPlayer(
9826            provider,
9827            "player_a",
9828            "Player A",
9829            identifiers={IdentifierType.MAC_ADDRESS: "AA:BB:CC:DD:EE:01"},
9830        )
9831        player_a.extra_data["reported_mac"] = "AA:BB:CC:DD:EE:02"
9832
9833        player_b = MockPlayer(
9834            provider,
9835            "player_b",
9836            "Player B",
9837            identifiers={IdentifierType.MAC_ADDRESS: "AA:BB:CC:DD:EE:03"},
9838        )
9839        player_b.extra_data["reported_mac"] = "AA:BB:CC:DD:EE:04"
9840
9841        assert controller._identifiers_match(player_a, player_b) is False
9842
9843    def test_match_reported_mac_with_la_bit_normalization(self, mock_mass: MagicMock) -> None:
9844        """Reported MAC matching also applies LA bit normalization."""
9845        controller = PlayerController(mock_mass)
9846
9847        provider = MockProvider("test")
9848        # Player A has ARP MAC, reported MAC has LA bit set
9849        player_a = MockPlayer(
9850            provider,
9851            "player_a",
9852            "Player A",
9853            identifiers={IdentifierType.MAC_ADDRESS: "AA:BB:CC:DD:EE:01"},
9854        )
9855        player_a.extra_data["reported_mac"] = "56:78:C9:E6:0D:A0"  # LA variant
9856
9857        # Player B has the hardware MAC
9858        player_b = MockPlayer(
9859            provider,
9860            "player_b",
9861            "Player B",
9862            identifiers={IdentifierType.MAC_ADDRESS: "54:78:C9:E6:0D:A0"},  # real MAC
9863        )
9864
9865        assert controller._identifiers_match(player_a, player_b) is True
9866
9867
9868class TestProtocolParentIdPersistence:
9869    """Tests for protocol_parent_id persistence for all parent types."""
9870
9871    def test_parent_id_saved_for_universal_parent(self, mock_mass: MagicMock) -> None:
9872        """protocol_parent_id should be saved when linking to a universal player."""
9873        controller = PlayerController(mock_mass)
9874
9875        def _config_get(key: str, default: object = None) -> object:
9876            # Protocol player config must exist for _save_protocol_parent_id to write
9877            if key == "players/airplay_test":
9878                return {"enabled": True}
9879            return default if default is not None else []
9880
9881        mock_mass.config.get = MagicMock(side_effect=_config_get)
9882
9883        universal_provider = create_mock_universal_provider(mock_mass)
9884        parent = UniversalPlayer(
9885            provider=universal_provider,
9886            player_id="up_test",
9887            name="Test Universal",
9888            device_info=DeviceInfo(),
9889            protocol_player_ids=[],
9890        )
9891        parent.update_state(signal_event=False)
9892
9893        protocol_provider = MockProvider("airplay")
9894        protocol_player = MockPlayer(
9895            protocol_provider,
9896            "airplay_test",
9897            "AirPlay Test",
9898            player_type=PlayerType.PROTOCOL,
9899        )
9900
9901        controller._players = {
9902            "up_test": parent,
9903            "airplay_test": protocol_player,
9904        }
9905
9906        controller._add_protocol_link(parent, protocol_player, "airplay")
9907
9908        # Verify _save_protocol_parent_id was called (config.set with parent ID)
9909        config_set_calls = [
9910            call
9911            for call in mock_mass.config.set.call_args_list
9912            if "protocol_parent_id" in str(call)
9913        ]
9914        assert len(config_set_calls) >= 1, (
9915            "protocol_parent_id should be saved for universal player parents"
9916        )
9917
9918    def test_parent_id_cleared_for_universal_parent(self, mock_mass: MagicMock) -> None:
9919        """protocol_parent_id should be cleared when unlinking from a universal player."""
9920        controller = PlayerController(mock_mass)
9921        mock_mass.config.get = MagicMock(return_value={"enabled": True})
9922
9923        universal_provider = create_mock_universal_provider(mock_mass)
9924        parent = UniversalPlayer(
9925            provider=universal_provider,
9926            player_id="up_test",
9927            name="Test Universal",
9928            device_info=DeviceInfo(),
9929            protocol_player_ids=["airplay_test"],
9930        )
9931        parent.update_state(signal_event=False)
9932
9933        protocol_provider = MockProvider("airplay")
9934        protocol_player = MockPlayer(
9935            protocol_provider,
9936            "airplay_test",
9937            "AirPlay Test",
9938            player_type=PlayerType.PROTOCOL,
9939        )
9940        protocol_player.set_protocol_parent_id("up_test")
9941
9942        # Set up linked protocols
9943        parent.set_linked_output_protocols(
9944            [
9945                LinkedOutputProtocol(
9946                    output_protocol_id="airplay_test",
9947                    protocol_domain="airplay",
9948                    priority=10,
9949                )
9950            ]
9951        )
9952
9953        controller._players = {
9954            "up_test": parent,
9955            "airplay_test": protocol_player,
9956        }
9957
9958        controller._remove_protocol_link(parent, "airplay_test")
9959
9960        # Verify protocol_parent_id was cleared in config
9961        config_set_calls = [
9962            call
9963            for call in mock_mass.config.set.call_args_list
9964            if "protocol_parent_id" in str(call) and call.args[1] is None
9965        ]
9966        assert len(config_set_calls) >= 1, (
9967            "protocol_parent_id should be cleared for universal player parents"
9968        )
9969
9970
9971class TestCleanupProtocolLinks:
9972    """Tests for protocol link cleanup on player removal."""
9973
9974    def test_cleanup_clears_config_parent_id(self, mock_mass: MagicMock) -> None:
9975        """When a universal player is removed, protocol parent_ids are cleared in config."""
9976        controller = PlayerController(mock_mass)
9977        mock_mass.config.get = MagicMock(return_value={"enabled": True})
9978
9979        universal_provider = create_mock_universal_provider(mock_mass)
9980        parent = UniversalPlayer(
9981            provider=universal_provider,
9982            player_id="up_test",
9983            name="Test Universal",
9984            device_info=DeviceInfo(),
9985            protocol_player_ids=["airplay_test"],
9986        )
9987        parent.update_state(signal_event=False)
9988
9989        protocol_provider = MockProvider("airplay")
9990        protocol_player = MockPlayer(
9991            protocol_provider,
9992            "airplay_test",
9993            "AirPlay Test",
9994            player_type=PlayerType.PROTOCOL,
9995        )
9996        protocol_player.set_protocol_parent_id("up_test")
9997
9998        parent.set_linked_output_protocols(
9999            [
10000                LinkedOutputProtocol(
10001                    output_protocol_id="airplay_test",
10002                    protocol_domain="airplay",
10003                    priority=10,
10004                )
10005            ]
10006        )
10007
10008        controller._players = {
10009            "up_test": parent,
10010            "airplay_test": protocol_player,
10011        }
10012
10013        controller._cleanup_protocol_links(parent)
10014
10015        # Verify protocol_parent_id was cleared in config
10016        config_set_calls = [
10017            call
10018            for call in mock_mass.config.set.call_args_list
10019            if "airplay_test" in str(call) and "protocol_parent_id" in str(call)
10020        ]
10021        assert len(config_set_calls) >= 1, (
10022            "protocol_parent_id should be cleared in config on parent removal"
10023        )
10024        # Verify in-memory parent cleared
10025        assert protocol_player.protocol_parent_id is None
10026
10027    def test_cleanup_native_parent_schedules_protocol_re_evaluation(
10028        self, mock_mass: MagicMock
10029    ) -> None:
10030        """Permanent native parent removal should reset links and reschedule protocols."""
10031        controller = PlayerController(mock_mass)
10032
10033        config_store: dict[str, object] = {
10034            "players/sonos_1": {"enabled": True},
10035            "players/sonos_1/values/linked_protocol_ids": ["airplay_live", "dlna_cached"],
10036            "players/airplay_live": {"enabled": True},
10037            "players/dlna_cached": {"enabled": True},
10038        }
10039
10040        def config_get(key: str, default: object = None) -> object:
10041            return config_store.get(key, default)
10042
10043        def config_set(key: str, value: object) -> None:
10044            config_store[key] = value
10045
10046        mock_mass.config.get = MagicMock(side_effect=config_get)
10047        mock_mass.config.set = MagicMock(side_effect=config_set)
10048
10049        native_provider = MockProvider("sonos", mass=mock_mass)
10050        parent = MockPlayer(
10051            native_provider,
10052            "sonos_1",
10053            "Sonos",
10054            identifiers={IdentifierType.MAC_ADDRESS: "AA:BB:CC:DD:EE:FF"},
10055        )
10056
10057        protocol_provider = MockProvider("airplay", mass=mock_mass)
10058        protocol_player = MockPlayer(
10059            protocol_provider,
10060            "airplay_live",
10061            "AirPlay Test",
10062            player_type=PlayerType.PROTOCOL,
10063        )
10064        protocol_player.set_protocol_parent_id("sonos_1")
10065
10066        parent.set_linked_output_protocols(
10067            [
10068                LinkedOutputProtocol(
10069                    output_protocol_id="airplay_live",
10070                    protocol_domain="airplay",
10071                    priority=10,
10072                )
10073            ]
10074        )
10075
10076        controller._players = {
10077            "sonos_1": parent,
10078            "airplay_live": protocol_player,
10079        }
10080
10081        with patch.object(controller, "_schedule_protocol_evaluation") as mock_schedule:
10082            controller._cleanup_protocol_links(parent)
10083
10084        assert protocol_player.protocol_parent_id is None
10085        assert config_store["players/airplay_live/values/protocol_parent_id"] is None
10086        assert config_store["players/dlna_cached/values/protocol_parent_id"] is None
10087        mock_schedule.assert_called_once_with(protocol_player)
10088
10089    def test_cleanup_universal_parent_clears_cached_only_protocol_ids(
10090        self, mock_mass: MagicMock
10091    ) -> None:
10092        """Universal cleanup should also clear cached protocol ids that are not active."""
10093        controller = PlayerController(mock_mass)
10094
10095        config_store: dict[str, object] = {
10096            "players/up_test": {"enabled": True},
10097            "players/airplay_live": {"enabled": True},
10098            "players/dlna_cached": {"enabled": True},
10099        }
10100
10101        def config_get(key: str, default: object = None) -> object:
10102            return config_store.get(key, default)
10103
10104        def config_set(key: str, value: object) -> None:
10105            config_store[key] = value
10106
10107        mock_mass.config.get = MagicMock(side_effect=config_get)
10108        mock_mass.config.set = MagicMock(side_effect=config_set)
10109
10110        universal_provider = create_mock_universal_provider(mock_mass)
10111        parent = UniversalPlayer(
10112            provider=universal_provider,
10113            player_id="up_test",
10114            name="Test Universal",
10115            device_info=DeviceInfo(),
10116            protocol_player_ids=["airplay_live", "dlna_cached"],
10117        )
10118        parent.update_state(signal_event=False)
10119
10120        protocol_provider = MockProvider("airplay", mass=mock_mass)
10121        protocol_player = MockPlayer(
10122            protocol_provider,
10123            "airplay_live",
10124            "AirPlay Test",
10125            player_type=PlayerType.PROTOCOL,
10126        )
10127        protocol_player.set_protocol_parent_id("up_test")
10128
10129        parent.set_linked_output_protocols(
10130            [
10131                LinkedOutputProtocol(
10132                    output_protocol_id="airplay_live",
10133                    protocol_domain="airplay",
10134                    priority=10,
10135                )
10136            ]
10137        )
10138
10139        controller._players = {
10140            "up_test": parent,
10141            "airplay_live": protocol_player,
10142        }
10143
10144        with patch.object(controller, "_schedule_protocol_evaluation") as mock_schedule:
10145            controller._cleanup_protocol_links(parent)
10146
10147        assert protocol_player.protocol_parent_id is None
10148        assert config_store["players/airplay_live/values/protocol_parent_id"] is None
10149        assert config_store["players/dlna_cached/values/protocol_parent_id"] is None
10150        mock_schedule.assert_called_once_with(protocol_player)
10151
10152
10153class TestDetachProtocolChildren:
10154    """Tests for detaching protocol players of a removed player that is not registered."""
10155
10156    @staticmethod
10157    def _setup(
10158        mock_mass: MagicMock, live_parent_id: str | None
10159    ) -> tuple[PlayerController, MockPlayer, dict[str, object]]:
10160        """Set up a registered protocol player whose (unregistered) parent is sonos_1."""
10161        controller = PlayerController(mock_mass)
10162
10163        config_store: dict[str, object] = {
10164            "players": {"sonos_1": {}, "airplay_live": {}},
10165            "players/sonos_1": {"enabled": True},
10166            "players/airplay_live": {"enabled": True},
10167            "players/airplay_live/values/protocol_parent_id": "sonos_1",
10168        }
10169
10170        mock_mass.config.get = MagicMock(
10171            side_effect=lambda key, default=None: config_store.get(key, default)
10172        )
10173        mock_mass.config.set = MagicMock(
10174            side_effect=lambda key, value: config_store.__setitem__(key, value)
10175        )
10176
10177        protocol_player = MockPlayer(
10178            MockProvider("airplay", mass=mock_mass),
10179            "airplay_live",
10180            "AirPlay Test",
10181            player_type=PlayerType.PROTOCOL,
10182        )
10183        protocol_player.set_protocol_parent_id(live_parent_id)
10184        controller._players = {"airplay_live": protocol_player}
10185        return controller, protocol_player, config_store
10186
10187    def test_detach_on_removal_of_an_unregistered_parent(self, mock_mass: MagicMock) -> None:
10188        """Removing a player that is no longer registered detaches its protocol player."""
10189        controller, protocol_player, config_store = self._setup(mock_mass, "sonos_1")
10190
10191        with patch.object(controller, "_schedule_protocol_evaluation") as mock_schedule:
10192            controller.delete_player_config("sonos_1")
10193
10194        assert protocol_player.protocol_parent_id is None
10195        assert config_store["players/airplay_live/values/protocol_parent_id"] is None
10196        mock_schedule.assert_called_once_with(protocol_player)
10197
10198    def test_detach_of_a_protocol_player_waiting_for_its_parent(self, mock_mass: MagicMock) -> None:
10199        """A protocol player that only has the parent link in its config is detached too."""
10200        controller, protocol_player, config_store = self._setup(mock_mass, None)
10201
10202        with patch.object(controller, "_schedule_protocol_evaluation") as mock_schedule:
10203            controller.delete_player_config("sonos_1")
10204
10205        assert config_store["players/airplay_live/values/protocol_parent_id"] is None
10206        mock_schedule.assert_called_once_with(protocol_player)
10207
10208    def test_detach_skips_a_protocol_player_of_another_parent(self, mock_mass: MagicMock) -> None:
10209        """A protocol player that already moved to another parent keeps its link."""
10210        controller, protocol_player, config_store = self._setup(mock_mass, "cast_1")
10211
10212        with patch.object(controller, "_schedule_protocol_evaluation") as mock_schedule:
10213            controller.delete_player_config("sonos_1")
10214
10215        assert protocol_player.protocol_parent_id == "cast_1"
10216        assert config_store["players/airplay_live/values/protocol_parent_id"] == "sonos_1"
10217        mock_schedule.assert_not_called()
10218
10219
10220class TestStaleConfigMigration:
10221    """Tests for protocol parent link repair on startup."""
10222
10223    def test_clears_stale_parent_ids(self, mock_mass: MagicMock) -> None:
10224        """Stale parent_ids pointing to deleted players are cleared on startup."""
10225        controller = PlayerController(mock_mass)
10226
10227        # Set up config with a protocol player pointing to a deleted universal player
10228        mock_mass.config.get = MagicMock(
10229            return_value={
10230                "airplay_test": {
10231                    "player_type": "protocol",
10232                    "values": {
10233                        "protocol_parent_id": "up_deleted_player",
10234                    },
10235                },
10236                "heos_player": {
10237                    "player_type": "player",
10238                    "values": {},
10239                },
10240            }
10241        )
10242
10243        controller._repair_protocol_parent_links()
10244
10245        # Verify the stale parent_id was cleared
10246        mock_mass.config.set.assert_any_call(
10247            "players/airplay_test/values/protocol_parent_id",
10248            None,
10249        )
10250        mock_mass.config.set_player_type.assert_not_called()
10251
10252    def test_keeps_valid_parent_ids(self, mock_mass: MagicMock) -> None:
10253        """Valid parent_ids pointing to existing players are preserved."""
10254        controller = PlayerController(mock_mass)
10255
10256        mock_mass.config.get = MagicMock(
10257            return_value={
10258                "airplay_test": {
10259                    "player_type": "protocol",
10260                    "values": {
10261                        "protocol_parent_id": "up_valid_player",
10262                    },
10263                },
10264                "up_valid_player": {
10265                    "player_type": "group",
10266                    "provider": "universal_player",
10267                    "values": {},
10268                },
10269            }
10270        )
10271
10272        controller._repair_protocol_parent_links()
10273
10274        # Verify no set calls were made to clear the parent_id
10275        for call in mock_mass.config.set.call_args_list:
10276            assert "protocol_parent_id" not in str(call), "Valid parent_id should not be cleared"
10277        mock_mass.config.set_player_type.assert_not_called()
10278
10279    def test_heals_stale_player_type_of_linked_child(self, mock_mass: MagicMock) -> None:
10280        """A parented child whose player_type was corrupted is healed back to protocol."""
10281        controller = PlayerController(mock_mass)
10282
10283        mock_mass.config.get = MagicMock(
10284            return_value={
10285                "squeezelite_child": {
10286                    "player_type": "player",
10287                    "values": {
10288                        "protocol_parent_id": "up_valid_player",
10289                    },
10290                },
10291                "up_valid_player": {
10292                    "player_type": "group",
10293                    "provider": "universal_player",
10294                    "values": {},
10295                },
10296            }
10297        )
10298
10299        controller._repair_protocol_parent_links()
10300
10301        mock_mass.config.set_player_type.assert_called_once_with(
10302            "squeezelite_child", PlayerType.PROTOCOL
10303        )
10304
10305    def test_native_registration_clears_stale_parent_link(self, mock_mass: MagicMock) -> None:
10306        """A player registering with a non-protocol type drops its leftover parent link."""
10307        controller = PlayerController(mock_mass)
10308        provider = MockProvider("sendspin", mass=mock_mass)
10309        player = MockPlayer(provider, "web_player", "Web Player", player_type=PlayerType.PLAYER)
10310
10311        config_values = {
10312            f"{CONF_PLAYERS}/web_player": {"player_type": "player"},
10313            f"{CONF_PLAYERS}/web_player/values/protocol_parent_id": "up_old_parent",
10314        }
10315        mock_mass.config.get = MagicMock(
10316            side_effect=lambda key, default=None: config_values.get(key, default)
10317        )
10318
10319        with patch.object(controller, "_try_link_protocols_to_native"):
10320            controller._evaluate_protocol_links(player)
10321
10322        mock_mass.config.set.assert_any_call(
10323            f"{CONF_PLAYERS}/web_player/values/protocol_parent_id",
10324            None,
10325        )
10326
10327
10328class TestUniversalPlayerRestoreOrphanCleanup:
10329    """Tests for UniversalPlayerProvider._restore_player membership repair behavior."""
10330
10331    @staticmethod
10332    def _setup_config_get(
10333        mock_mass: MagicMock,
10334        universal_id: str,
10335        linked_protocol_ids: list[str],
10336        protocol_configs: dict[str, dict[str, object]],
10337    ) -> None:
10338        """Wire mass.config.get to return universal/protocol configs by key."""
10339        universal_conf = {
10340            "values": {
10341                "linked_protocol_ids": list(linked_protocol_ids),
10342                "device_identifiers": {},
10343                "device_info": {},
10344            },
10345            "name": "Test Universal",
10346        }
10347        all_configs: dict[str, object] = {universal_id: universal_conf, **protocol_configs}
10348
10349        def _get(key: str, default: object = None) -> object:
10350            if key == CONF_PLAYERS:
10351                return all_configs
10352            if key.startswith(f"{CONF_PLAYERS}/"):
10353                pid = key.split("/", 1)[1]
10354                return all_configs.get(pid, default)
10355            return default
10356
10357        mock_mass.config.get.side_effect = _get
10358
10359    @pytest.mark.asyncio
10360    async def test_orphan_protocol_dropped_but_configs_kept(self, mock_mass: MagicMock) -> None:
10361        """Orphan protocol is dropped from membership without deleting any config."""
10362        provider = create_mock_universal_provider(mock_mass)
10363        universal_id = "up_test"
10364        orphan_id = "spb_orphan"
10365
10366        self._setup_config_get(
10367            mock_mass,
10368            universal_id,
10369            [orphan_id],
10370            {
10371                orphan_id: {
10372                    "player_type": "protocol",
10373                    "values": {"protocol_parent_id": None},
10374                },
10375            },
10376        )
10377        mock_mass.players = MagicMock()
10378        mock_mass.players.get_player = MagicMock(return_value=None)
10379        mock_mass.players.delete_player_config = MagicMock()
10380        mock_mass.players.unregister = AsyncMock()
10381        mock_mass.players.register_or_update = AsyncMock()
10382        mock_mass.config.remove_player_config = AsyncMock()
10383
10384        await provider._restore_player(universal_id)
10385
10386        # Neither the orphan's config nor the universal's config is deleted
10387        mock_mass.players.delete_player_config.assert_not_called()
10388        mock_mass.players.unregister.assert_not_called()
10389        mock_mass.config.remove_player_config.assert_not_called()
10390        # With no valid protocols left, the universal is simply not restored
10391        mock_mass.players.register_or_update.assert_not_called()
10392
10393    @pytest.mark.asyncio
10394    async def test_valid_protocol_kept_and_no_cleanup(self, mock_mass: MagicMock) -> None:
10395        """Protocol with a valid protocol_parent_id is kept and no cleanup runs."""
10396        provider = create_mock_universal_provider(mock_mass)
10397        universal_id = "up_test"
10398        protocol_id = "spb_valid"
10399
10400        self._setup_config_get(
10401            mock_mass,
10402            universal_id,
10403            [protocol_id],
10404            {
10405                protocol_id: {
10406                    "player_type": "protocol",
10407                    "values": {"protocol_parent_id": universal_id},
10408                },
10409            },
10410        )
10411        mock_mass.players = MagicMock()
10412        mock_mass.players.get_player = MagicMock(return_value=None)
10413        mock_mass.players.delete_player_config = MagicMock()
10414        mock_mass.players.unregister = AsyncMock()
10415        mock_mass.players.register_or_update = AsyncMock()
10416        mock_mass.config.remove_player_config = AsyncMock()
10417        mock_mass.config.get_base_player_config.return_value = create_mock_config("Test Universal")
10418
10419        await provider._restore_player(universal_id)
10420
10421        mock_mass.players.delete_player_config.assert_not_called()
10422        mock_mass.players.unregister.assert_not_called()
10423        mock_mass.config.remove_player_config.assert_not_called()
10424        # Universal is constructed and registered with the valid protocol kept
10425        mock_mass.players.register_or_update.assert_awaited_once()
10426        registered = mock_mass.players.register_or_update.call_args.args[0]
10427        assert protocol_id in registered._protocol_player_ids
10428
10429    @pytest.mark.asyncio
10430    async def test_restore_keeps_linked_protocol_after_startup_type_rewrite(
10431        self, mock_mass: MagicMock
10432    ) -> None:
10433        """A linked protocol whose type was rewritten must not delete its universal config."""
10434        provider = create_mock_universal_provider(mock_mass)
10435        universal_id = "upe45f0170ef67"
10436        protocol_id = "e4:5f:01:70:ef:67"
10437        universal_config: dict[str, Any] = {
10438            "player_id": universal_id,
10439            "provider": "universal_player",
10440            "player_type": "player",
10441            "enabled": True,
10442            "name": "WC-Player",
10443            "default_name": "solarium-bath-sl",
10444            "values": {
10445                "hide_in_ui": True,
10446                "announce_volume_min": 55,
10447                "announce_volume_max": 98,
10448                "play_media_overrides_group": False,
10449                "linked_protocol_ids": [protocol_id],
10450                "device_identifiers": {},
10451                "device_info": {},
10452            },
10453        }
10454        configs: dict[str, dict[str, Any]] = {
10455            universal_id: universal_config,
10456            protocol_id: {
10457                "player_id": protocol_id,
10458                "provider": "squeezelite",
10459                "player_type": "player",
10460                "enabled": True,
10461                "values": {"protocol_parent_id": universal_id},
10462            },
10463        }
10464
10465        def _config_get(key: str, default: object = None) -> object:
10466            if key == CONF_PLAYERS:
10467                return configs
10468            if key.startswith(f"{CONF_PLAYERS}/"):
10469                pid = key.split("/", 1)[1]
10470                return configs.get(pid, default)
10471            return default
10472
10473        mock_mass.config.get.side_effect = _config_get
10474        mock_mass.config.remove_player_config = AsyncMock()
10475        mock_mass.config.get_base_player_config.return_value = create_mock_config("WC-Player")
10476        mock_mass.players = MagicMock()
10477        mock_mass.players.get_player = MagicMock(return_value=None)
10478        mock_mass.players.delete_player_config = MagicMock()
10479        mock_mass.players.unregister = AsyncMock()
10480        mock_mass.players.register_or_update = AsyncMock()
10481
10482        await provider._restore_player(universal_id)
10483
10484        mock_mass.config.remove_player_config.assert_not_called()
10485        mock_mass.players.delete_player_config.assert_not_called()
10486        mock_mass.players.unregister.assert_not_called()
10487        mock_mass.players.register_or_update.assert_awaited_once()
10488        registered = mock_mass.players.register_or_update.call_args.args[0]
10489        assert protocol_id in registered._protocol_player_ids
10490        assert configs[universal_id]["values"]["hide_in_ui"] is True
10491        assert configs[universal_id]["values"]["announce_volume_min"] == 55
10492
10493    @pytest.mark.asyncio
10494    async def test_membership_augmented_from_child_parent_link(self, mock_mass: MagicMock) -> None:
10495        """A child whose parent link points at the universal is re-added to membership."""
10496        provider = create_mock_universal_provider(mock_mass)
10497        universal_id = "up_test"
10498        stored_id = "spb_stored"
10499        missing_id = "spb_missing"
10500
10501        self._setup_config_get(
10502            mock_mass,
10503            universal_id,
10504            [stored_id],
10505            {
10506                stored_id: {
10507                    "player_type": "protocol",
10508                    "values": {"protocol_parent_id": universal_id},
10509                },
10510                missing_id: {
10511                    "player_type": "protocol",
10512                    "values": {"protocol_parent_id": universal_id},
10513                },
10514            },
10515        )
10516        mock_mass.players = MagicMock()
10517        mock_mass.players.get_player = MagicMock(return_value=None)
10518        mock_mass.players.register_or_update = AsyncMock()
10519        mock_mass.config.remove_player_config = AsyncMock()
10520        mock_mass.config.get_base_player_config.return_value = create_mock_config("Test Universal")
10521
10522        await provider._restore_player(universal_id)
10523
10524        # The missing child is restored to membership and persisted
10525        mock_mass.config.set.assert_any_call(
10526            f"{CONF_PLAYERS}/{universal_id}/values/linked_protocol_ids",
10527            [stored_id, missing_id],
10528        )
10529        registered = mock_mass.players.register_or_update.call_args.args[0]
10530        assert registered._protocol_player_ids == [stored_id, missing_id]
10531
10532    @pytest.mark.asyncio
10533    async def test_universal_config_kept_when_protocol_moved_to_other_parent(
10534        self, mock_mass: MagicMock
10535    ) -> None:
10536        """A protocol that moved to another parent is dropped, universal config is kept."""
10537        provider = create_mock_universal_provider(mock_mass)
10538        universal_id = "up_test"
10539        protocol_id = "spb_moved"
10540
10541        self._setup_config_get(
10542            mock_mass,
10543            universal_id,
10544            [protocol_id],
10545            {
10546                protocol_id: {
10547                    "player_type": "protocol",
10548                    "values": {"protocol_parent_id": "up_other"},
10549                },
10550            },
10551        )
10552        mock_mass.players = MagicMock()
10553        mock_mass.players.get_player = MagicMock(return_value=None)
10554        mock_mass.players.register_or_update = AsyncMock()
10555        mock_mass.config.remove_player_config = AsyncMock()
10556
10557        await provider._restore_player(universal_id)
10558
10559        mock_mass.config.remove_player_config.assert_not_called()
10560        mock_mass.players.register_or_update.assert_not_called()
10561
10562    @pytest.mark.asyncio
10563    async def test_disabled_parent_reparents_and_disables_orphaned_protocols(
10564        self, mock_mass: MagicMock
10565    ) -> None:
10566        """
10567        Self-heal: stale UP whose protocols belong to a disabled native parent.
10568
10569        Restoring such a UP should skip the wrapper, restore each protocol's
10570        parent_id back to the rightful (disabled) native parent, cascade-disable
10571        each protocol so the next registration cycle doesn't rebuild the wrapper,
10572        and hand the wrapper's config over to the native parent before deleting it.
10573        """
10574        provider = create_mock_universal_provider(mock_mass)
10575        universal_id = "up_test"
10576        native_parent_id = "sonos_disabled"
10577        ap_id = "airplay_1"
10578        dlna_id = "dlna_1"
10579
10580        all_configs = {
10581            universal_id: {
10582                "values": {
10583                    "linked_protocol_ids": [ap_id, dlna_id],
10584                    "device_identifiers": {},
10585                    "device_info": {},
10586                },
10587                "name": "Test UP",
10588            },
10589            native_parent_id: {
10590                "enabled": False,
10591                "provider": "sonos",
10592                "values": {"linked_protocol_ids": [ap_id, dlna_id]},
10593            },
10594            ap_id: {
10595                "player_type": "protocol",
10596                "enabled": True,
10597                "values": {"protocol_parent_id": universal_id},
10598            },
10599            dlna_id: {
10600                "player_type": "protocol",
10601                "enabled": True,
10602                "values": {"protocol_parent_id": universal_id},
10603            },
10604        }
10605
10606        def _config_get(key: str, default: object = None) -> object:
10607            if key == CONF_PLAYERS:
10608                return all_configs
10609            if key.startswith(f"{CONF_PLAYERS}/"):
10610                pid = key.split("/", 1)[1]
10611                return all_configs.get(pid, default)
10612            return default
10613
10614        mock_mass.config.get.side_effect = _config_get
10615        mock_mass.config.set = MagicMock()
10616        mock_mass.config.save_player_config = AsyncMock()
10617        mock_mass.config.remove_player_config = AsyncMock()
10618        mock_mass.players = MagicMock()
10619        mock_mass.players.get_player = MagicMock(return_value=None)
10620
10621        await provider._restore_player(universal_id)
10622
10623        # The wrapper is not restored; its settings are handed over to the
10624        # native parent and its now-obsolete config is deleted
10625        mock_mass.players._migrate_universal_player_config.assert_called_once_with(
10626            universal_id, native_parent_id
10627        )
10628        mock_mass.players.delete_player_config.assert_called_once_with(
10629            universal_id, replacement_player_id=native_parent_id
10630        )
10631
10632        # Each protocol's parent_id is restored to the disabled native parent
10633        parent_restorations = {
10634            call.args[0]: call.args[1]
10635            for call in mock_mass.config.set.call_args_list
10636            if "protocol_parent_id" in call.args[0]
10637        }
10638        assert parent_restorations == {
10639            f"{CONF_PLAYERS}/{ap_id}/values/protocol_parent_id": native_parent_id,
10640            f"{CONF_PLAYERS}/{dlna_id}/values/protocol_parent_id": native_parent_id,
10641        }
10642
10643        # Each protocol is cascade-disabled
10644        disabled_ids = {
10645            call.args[0] for call in mock_mass.config.save_player_config.await_args_list
10646        }
10647        assert disabled_ids == {ap_id, dlna_id}
10648
10649    @pytest.mark.asyncio
10650    async def test_universal_replaced_by_linkplay_shell(self, mock_mass: MagicMock) -> None:
10651        """A generic LinkPlay shell that owns DLNA+AirPlay children replaces the Universal."""
10652        provider = create_mock_universal_provider(mock_mass)
10653        universal_id = "up_edifier"
10654        shell_id = "wiim_uuid:FF97F002-783E-6505-6579-F15FFF97F002"
10655        ap_id = "airplay_edifier"
10656        dlna_id = "dlna_edifier"
10657
10658        all_configs = {
10659            universal_id: {
10660                "values": {
10661                    "linked_protocol_ids": [ap_id, dlna_id],
10662                    "device_identifiers": {},
10663                    "device_info": {},
10664                },
10665                "name": "Edifier MS50A",
10666            },
10667            shell_id: {
10668                "enabled": True,
10669                "provider": "wiim",
10670                "player_type": "player",
10671                "values": {"linked_protocol_ids": [ap_id, dlna_id]},
10672            },
10673            ap_id: {
10674                "player_type": "protocol",
10675                "enabled": True,
10676                "values": {"protocol_parent_id": universal_id},
10677            },
10678            dlna_id: {
10679                "player_type": "protocol",
10680                "enabled": True,
10681                "values": {"protocol_parent_id": universal_id},
10682            },
10683        }
10684
10685        def _config_get(key: str, default: object = None) -> object:
10686            if key == CONF_PLAYERS:
10687                return all_configs
10688            if key.startswith(f"{CONF_PLAYERS}/"):
10689                pid = key.split("/", 1)[1]
10690                return all_configs.get(pid, default)
10691            return default
10692
10693        mock_mass.config.get.side_effect = _config_get
10694        mock_mass.config.set = MagicMock()
10695        mock_mass.config.save_player_config = AsyncMock()
10696        mock_mass.config.remove_player_config = AsyncMock()
10697        mock_mass.players = MagicMock()
10698        mock_mass.players.get_player = MagicMock(return_value=None)
10699        mock_mass.players.register_or_update = AsyncMock()
10700
10701        await provider._restore_player(universal_id)
10702
10703        # both protocol children are reparented from the Universal to the LinkPlay shell
10704        parent_restorations = {
10705            call.args[0]: call.args[1]
10706            for call in mock_mass.config.set.call_args_list
10707            if "protocol_parent_id" in call.args[0]
10708        }
10709        assert parent_restorations == {
10710            f"{CONF_PLAYERS}/{ap_id}/values/protocol_parent_id": shell_id,
10711            f"{CONF_PLAYERS}/{dlna_id}/values/protocol_parent_id": shell_id,
10712        }
10713        # the Universal's saved config/name/DSP/preference and group refs migrate to the
10714        # shell, and the now-obsolete Universal is removed
10715        mock_mass.players._migrate_universal_player_config.assert_called_once_with(
10716            universal_id, shell_id
10717        )
10718        mock_mass.players.delete_player_config.assert_called_once_with(
10719            universal_id, replacement_player_id=shell_id
10720        )
10721
10722    @pytest.mark.asyncio
10723    async def test_protocols_reparented_to_their_own_native_claimer(
10724        self, mock_mass: MagicMock
10725    ) -> None:
10726        """Each protocol is reparented to the native player that claims it, not the first found."""
10727        provider = create_mock_universal_provider(mock_mass)
10728        universal_id = "up_test"
10729        ap_id = "airplay_1"
10730        slimproto_id = "slimproto_1"
10731
10732        all_configs = {
10733            universal_id: {
10734                "values": {
10735                    "linked_protocol_ids": [ap_id, slimproto_id],
10736                    "device_identifiers": {},
10737                    "device_info": {},
10738                },
10739                "name": "Test UP",
10740            },
10741            "native_a": {
10742                "provider": "dlna",
10743                "values": {"linked_protocol_ids": [ap_id]},
10744            },
10745            "native_b": {
10746                "provider": "squeezelite",
10747                "values": {"linked_protocol_ids": [slimproto_id]},
10748            },
10749            ap_id: {
10750                "player_type": "protocol",
10751                "values": {"protocol_parent_id": universal_id},
10752            },
10753            slimproto_id: {
10754                "player_type": "protocol",
10755                "values": {"protocol_parent_id": universal_id},
10756            },
10757        }
10758
10759        def _config_get(key: str, default: object = None) -> object:
10760            if key == CONF_PLAYERS:
10761                return all_configs
10762            if key.startswith(f"{CONF_PLAYERS}/"):
10763                pid = key.split("/", 1)[1]
10764                return all_configs.get(pid, default)
10765            return default
10766
10767        mock_mass.config.get.side_effect = _config_get
10768        mock_mass.config.set = MagicMock()
10769        mock_mass.config.save_player_config = AsyncMock()
10770        mock_mass.config.remove_player_config = AsyncMock()
10771        mock_mass.players = MagicMock()
10772        mock_mass.players.get_player = MagicMock(return_value=None)
10773        mock_mass.players.register_or_update = AsyncMock()
10774
10775        await provider._restore_player(universal_id)
10776
10777        mock_mass.players.register_or_update.assert_not_called()
10778        parent_restorations = {
10779            call.args[0]: call.args[1]
10780            for call in mock_mass.config.set.call_args_list
10781            if "protocol_parent_id" in call.args[0]
10782        }
10783        assert parent_restorations == {
10784            f"{CONF_PLAYERS}/{ap_id}/values/protocol_parent_id": "native_a",
10785            f"{CONF_PLAYERS}/{slimproto_id}/values/protocol_parent_id": "native_b",
10786        }
10787        for call in mock_mass.config.save_player_config.await_args_list:
10788            assert call.args[1] == {ATTR_ENABLED: False}
10789        # The wrapper's settings are handed to the first claimer, then its
10790        # config is deleted
10791        mock_mass.players._migrate_universal_player_config.assert_called_once_with(
10792            universal_id, "native_a"
10793        )
10794        mock_mass.players.delete_player_config.assert_called_once_with(
10795            universal_id, replacement_player_id="native_a"
10796        )
10797
10798    @pytest.mark.asyncio
10799    async def test_restore_native_claims_carries_config_and_deletes_wrapper(
10800        self, mock_mass: MagicMock
10801    ) -> None:
10802        """The wrapper's user settings move to the native claimer and its config is deleted."""
10803        universal_id = "up_old"
10804        native_id = "cast_1"
10805        ap_id = "ap_1"
10806        store: dict[str, Any] = {
10807            CONF_PLAYERS: {
10808                universal_id: {
10809                    "provider": "universal_player",
10810                    "enabled": True,
10811                    "name": "Living Room",
10812                    "default_name": "Soundbar (Universal)",
10813                    "values": {
10814                        "hide_in_ui": True,
10815                        "volume_control": "up_volume_control",
10816                        "linked_protocol_ids": [ap_id],
10817                        "device_identifiers": {"mac_address": "AA:BB:CC:DD:EE:FF"},
10818                        "device_info": {"model": "Test", "manufacturer": "Test"},
10819                    },
10820                },
10821                native_id: {
10822                    "provider": "cast",
10823                    "enabled": True,
10824                    "name": "Soundbar",
10825                    "default_name": "Soundbar",
10826                    "values": {
10827                        "volume_control": "native",
10828                        "linked_protocol_ids": [ap_id],
10829                    },
10830                },
10831                ap_id: {
10832                    "player_type": "protocol",
10833                    "enabled": True,
10834                    "values": {"protocol_parent_id": universal_id},
10835                },
10836                "group_1": {
10837                    "provider": "sync_group",
10838                    "enabled": True,
10839                    "values": {
10840                        "group_members": [universal_id, "other"],
10841                        "allowed_members": [universal_id],
10842                    },
10843                },
10844            },
10845            CONF_PLAYER_DSP: {universal_id: {"enabled": True, "filters": []}},
10846            CONF_PLAYER_QUEUES: {
10847                universal_id: {"queue_id": universal_id, "values": {"crossfade_duration": 5}}
10848            },
10849        }
10850        _wire_nested_config(mock_mass, store)
10851        mock_mass.config.save_player_config = AsyncMock()
10852        scheduled_tasks: list[Awaitable[object]] = []
10853        mock_mass.create_task = MagicMock(
10854            side_effect=lambda task, *_a, **_kw: scheduled_tasks.append(task)
10855        )
10856        controller = PlayerController(mock_mass)
10857        controller._players = {}
10858        mock_mass.players = controller
10859        mock_mass.player_queues = MagicMock()
10860        provider = create_mock_universal_provider(mock_mass)
10861
10862        await provider._restore_player(universal_id)
10863
10864        players_tree = store[CONF_PLAYERS]
10865        # the protocol player is reparented to the native claimer
10866        assert players_tree[ap_id]["values"]["protocol_parent_id"] == native_id
10867        # the custom display name and user-set values moved onto the native player
10868        assert players_tree[native_id]["name"] == "Living Room"
10869        assert players_tree[native_id]["values"]["hide_in_ui"] is True
10870        # values the native player has explicitly set are not overwritten
10871        assert players_tree[native_id]["values"]["volume_control"] == "native"
10872        # wrapper bookkeeping values are not carried over
10873        assert "device_identifiers" not in players_tree[native_id]["values"]
10874        assert "device_info" not in players_tree[native_id]["values"]
10875        # DSP and queue settings moved onto the native player
10876        assert store[CONF_PLAYER_DSP] == {native_id: {"enabled": True, "filters": []}}
10877        assert store[CONF_PLAYER_QUEUES] == {
10878            native_id: {"queue_id": native_id, "values": {"crossfade_duration": 5}}
10879        }
10880        # group memberships that referenced the wrapper now point at the native player
10881        assert players_tree["group_1"]["values"]["group_members"] == [native_id, "other"]
10882        assert players_tree["group_1"]["values"]["allowed_members"] == [native_id]
10883        # the wrapper's own config entries are all gone
10884        assert universal_id not in players_tree
10885        # cached queue state of the wrapper is purged as well
10886        mock_mass.player_queues.purge_saved_queue.assert_called_once_with(universal_id)
10887        # the wrapper itself is not registered as a player
10888        assert universal_id not in controller._players
10889        for task in scheduled_tasks:
10890            await task
10891
10892    @pytest.mark.asyncio
10893    async def test_restore_native_claims_keeps_native_name_dsp_and_queue_values(
10894        self, mock_mass: MagicMock
10895    ) -> None:
10896        """Values explicitly set on the native player win over the wrapper's on restore."""
10897        universal_id = "up_old"
10898        native_id = "cast_1"
10899        ap_id = "ap_1"
10900        store: dict[str, Any] = {
10901            CONF_PLAYERS: {
10902                universal_id: {
10903                    "provider": "universal_player",
10904                    "enabled": True,
10905                    "name": "Living Room",
10906                    "default_name": "Soundbar (Universal)",
10907                    "values": {"hide_in_ui": True, "linked_protocol_ids": [ap_id]},
10908                },
10909                native_id: {
10910                    "provider": "cast",
10911                    "enabled": True,
10912                    "name": "Kitchen",
10913                    "default_name": "Soundbar",
10914                    "values": {"linked_protocol_ids": [ap_id]},
10915                },
10916                ap_id: {
10917                    "player_type": "protocol",
10918                    "enabled": True,
10919                    "values": {"protocol_parent_id": universal_id},
10920                },
10921            },
10922            CONF_PLAYER_DSP: {
10923                universal_id: {"enabled": True, "filters": ["universal"]},
10924                native_id: {"enabled": False, "filters": ["native"]},
10925            },
10926            CONF_PLAYER_QUEUES: {
10927                universal_id: {
10928                    "queue_id": universal_id,
10929                    "values": {"crossfade_duration": 8, "autoplay_mode": "smart"},
10930                },
10931                native_id: {"queue_id": native_id, "values": {"crossfade_duration": 2}},
10932            },
10933        }
10934        _wire_nested_config(mock_mass, store)
10935        mock_mass.config.save_player_config = AsyncMock()
10936        scheduled_tasks: list[Awaitable[object]] = []
10937        mock_mass.create_task = MagicMock(
10938            side_effect=lambda task, *_a, **_kw: scheduled_tasks.append(task)
10939        )
10940        controller = PlayerController(mock_mass)
10941        controller._players = {}
10942        mock_mass.players = controller
10943        mock_mass.player_queues = MagicMock()
10944        provider = create_mock_universal_provider(mock_mass)
10945
10946        await provider._restore_player(universal_id)
10947
10948        players_tree = store[CONF_PLAYERS]
10949        # the native player's own custom name and DSP config win
10950        assert players_tree[native_id]["name"] == "Kitchen"
10951        assert store[CONF_PLAYER_DSP] == {native_id: {"enabled": False, "filters": ["native"]}}
10952        # queue values merge without overwriting the native queue's own values
10953        assert store[CONF_PLAYER_QUEUES] == {
10954            native_id: {
10955                "queue_id": native_id,
10956                "values": {"crossfade_duration": 2, "autoplay_mode": "smart"},
10957            }
10958        }
10959        # non-conflicting values still migrate and the wrapper config is gone
10960        assert players_tree[native_id]["values"]["hide_in_ui"] is True
10961        assert universal_id not in players_tree
10962        for task in scheduled_tasks:
10963            await task
10964
10965    @pytest.mark.asyncio
10966    async def test_restore_native_claims_keeps_config_while_wrapper_registered(
10967        self, mock_mass: MagicMock
10968    ) -> None:
10969        """No config carry-over/deletion while the wrapper is still registered."""
10970        provider = create_mock_universal_provider(mock_mass)
10971        universal_id = "up_test"
10972        ap_id = "airplay_1"
10973
10974        all_configs = {
10975            universal_id: {
10976                "values": {
10977                    "linked_protocol_ids": [ap_id],
10978                    "device_identifiers": {},
10979                    "device_info": {},
10980                },
10981                "name": "Test UP",
10982            },
10983            "native_a": {
10984                "provider": "dlna",
10985                "values": {"linked_protocol_ids": [ap_id]},
10986            },
10987            ap_id: {
10988                "player_type": "protocol",
10989                "values": {"protocol_parent_id": universal_id},
10990            },
10991        }
10992
10993        def _config_get(key: str, default: object = None) -> object:
10994            if key == CONF_PLAYERS:
10995                return all_configs
10996            if key.startswith(f"{CONF_PLAYERS}/"):
10997                pid = key.split("/", 1)[1]
10998                return all_configs.get(pid, default)
10999            return default
11000
11001        mock_mass.config.get.side_effect = _config_get
11002        mock_mass.config.set = MagicMock()
11003        mock_mass.config.save_player_config = AsyncMock()
11004        mock_mass.players = MagicMock()
11005        mock_mass.players.get_player = MagicMock(return_value=MagicMock())
11006
11007        await provider._restore_player(universal_id)
11008
11009        # the protocol link repair still runs
11010        parent_restorations = {
11011            call.args[0]: call.args[1]
11012            for call in mock_mass.config.set.call_args_list
11013            if "protocol_parent_id" in call.args[0]
11014        }
11015        assert parent_restorations == {
11016            f"{CONF_PLAYERS}/{ap_id}/values/protocol_parent_id": "native_a",
11017        }
11018        # but the registered wrapper keeps its config and group memberships
11019        mock_mass.players._migrate_universal_player_config.assert_not_called()
11020        mock_mass.players.delete_player_config.assert_not_called()
11021        mock_mass.player_queues.on_player_remove.assert_not_called()
11022
11023
11024class TestParentDisableCascade:
11025    """Tests for cascading enable/disable from a native parent to its protocols."""
11026
11027    @staticmethod
11028    def _make_config(player_id: str, *, enabled: bool, provider: str = "sonos") -> MagicMock:
11029        """Build a minimal PlayerConfig mock for on_player_config_change."""
11030        config = MagicMock()
11031        config.player_id = player_id
11032        config.provider = provider
11033        config.enabled = enabled
11034        config.values = {}
11035        return config
11036
11037    @pytest.mark.asyncio
11038    async def test_disabling_native_parent_cascades_disable_to_linked_protocols(
11039        self, mock_mass: MagicMock
11040    ) -> None:
11041        """Disabling a native parent saves enabled=False for each linked protocol."""
11042        controller = PlayerController(mock_mass)
11043
11044        native_provider = MockProvider("sonos", mass=mock_mass)
11045        native_player = MockPlayer(
11046            native_provider,
11047            "sonos_native",
11048            "Native Speaker",
11049            identifiers={IdentifierType.MAC_ADDRESS: "AA:BB:CC:DD:EE:FF"},
11050        )
11051        # Bypass the power-off branch in on_player_config_change
11052        native_player._attr_available = False
11053        native_player.set_linked_output_protocols(
11054            [
11055                LinkedOutputProtocol(
11056                    output_protocol_id="ap_1",
11057                    protocol_domain="airplay",
11058                    priority=10,
11059                ),
11060                LinkedOutputProtocol(
11061                    output_protocol_id="dlna_1",
11062                    protocol_domain="dlna",
11063                    priority=20,
11064                ),
11065            ]
11066        )
11067        controller._players = {native_player.player_id: native_player}
11068
11069        protocol_configs = {
11070            "ap_1": {"enabled": True, "player_type": "protocol"},
11071            "dlna_1": {"enabled": True, "player_type": "protocol"},
11072        }
11073
11074        def _config_get(key: str, default: object = None) -> object:
11075            if key.startswith(f"{CONF_PLAYERS}/"):
11076                pid = key.split("/", 1)[1]
11077                return protocol_configs.get(pid, default)
11078            return default
11079
11080        mock_mass.config.get = MagicMock(side_effect=_config_get)
11081        mock_mass.config.save_player_config = MagicMock()
11082        mock_mass.create_task = MagicMock()
11083        mock_mass.get_provider = MagicMock(return_value=MagicMock(spec=PlayerProvider))
11084
11085        config = self._make_config(native_player.player_id, enabled=False)
11086
11087        await controller.on_player_config_change(config, {ATTR_ENABLED})
11088
11089        called = mock_mass.config.save_player_config.call_args_list
11090        assert {call.args[0] for call in called} == {"ap_1", "dlna_1"}
11091        for call in called:
11092            assert call.args[1] == {ATTR_ENABLED: False}
11093
11094    @pytest.mark.asyncio
11095    async def test_enabling_native_parent_cascades_enable_to_linked_protocols(
11096        self, mock_mass: MagicMock
11097    ) -> None:
11098        """Re-enabling a native parent saves enabled=True for each (currently disabled) protocol."""
11099        controller = PlayerController(mock_mass)
11100
11101        native_provider = MockProvider("sonos", mass=mock_mass)
11102        native_player = MockPlayer(
11103            native_provider,
11104            "sonos_native",
11105            "Native Speaker",
11106            identifiers={IdentifierType.MAC_ADDRESS: "AA:BB:CC:DD:EE:FF"},
11107        )
11108        native_player._attr_available = False
11109        native_player.set_linked_output_protocols(
11110            [
11111                LinkedOutputProtocol(
11112                    output_protocol_id="ap_1",
11113                    protocol_domain="airplay",
11114                    priority=10,
11115                ),
11116            ]
11117        )
11118        controller._players = {native_player.player_id: native_player}
11119
11120        protocol_configs = {
11121            "ap_1": {"enabled": False, "player_type": "protocol"},
11122        }
11123
11124        def _config_get(key: str, default: object = None) -> object:
11125            if key.startswith(f"{CONF_PLAYERS}/"):
11126                pid = key.split("/", 1)[1]
11127                return protocol_configs.get(pid, default)
11128            return default
11129
11130        mock_mass.config.get = MagicMock(side_effect=_config_get)
11131        mock_mass.config.save_player_config = MagicMock()
11132        mock_mass.create_task = MagicMock()
11133        mock_mass.get_provider = MagicMock(return_value=MagicMock(spec=PlayerProvider))
11134
11135        config = self._make_config(native_player.player_id, enabled=True)
11136
11137        await controller.on_player_config_change(config, {ATTR_ENABLED})
11138
11139        mock_mass.config.save_player_config.assert_called_once_with("ap_1", {ATTR_ENABLED: True})
11140
11141    @pytest.mark.asyncio
11142    async def test_disabling_native_parent_falls_back_to_cached_protocol_ids(
11143        self, mock_mass: MagicMock
11144    ) -> None:
11145        """When the parent isn't registered, cascade uses cached linked_protocol_ids."""
11146        controller = PlayerController(mock_mass)
11147
11148        # Parent is not in _players (e.g. provider didn't register a disabled parent)
11149        controller._players = {}
11150
11151        cached_protocol_ids = ["ap_cached", "dlna_cached"]
11152        protocol_configs = {
11153            "ap_cached": {"enabled": True, "player_type": "protocol"},
11154            "dlna_cached": {"enabled": True, "player_type": "protocol"},
11155        }
11156
11157        def _config_get(key: str, default: object = None) -> object:
11158            if key == f"{CONF_PLAYERS}/unregistered_native/values/linked_protocol_ids":
11159                return list(cached_protocol_ids)
11160            if key.startswith(f"{CONF_PLAYERS}/"):
11161                pid = key.split("/", 1)[1]
11162                return protocol_configs.get(pid, default)
11163            return default
11164
11165        mock_mass.config.get = MagicMock(side_effect=_config_get)
11166        mock_mass.config.save_player_config = MagicMock()
11167        mock_mass.create_task = MagicMock()
11168        mock_mass.get_provider = MagicMock(return_value=MagicMock(spec=PlayerProvider))
11169
11170        config = self._make_config("unregistered_native", enabled=False)
11171
11172        await controller.on_player_config_change(config, {ATTR_ENABLED})
11173
11174        called_ids = {call.args[0] for call in mock_mass.config.save_player_config.call_args_list}
11175        assert called_ids == set(cached_protocol_ids)
11176
11177    @pytest.mark.asyncio
11178    async def test_toggling_protocol_player_does_not_cascade(self, mock_mass: MagicMock) -> None:
11179        """A PROTOCOL-type player flipping enabled must not trigger any cascade."""
11180        controller = PlayerController(mock_mass)
11181
11182        ap_provider = MockProvider("airplay", mass=mock_mass)
11183        ap_player = MockPlayer(
11184            ap_provider,
11185            "ap_1",
11186            "AirPlay",
11187            player_type=PlayerType.PROTOCOL,
11188        )
11189        ap_player._attr_available = False
11190        controller._players = {ap_player.player_id: ap_player}
11191
11192        mock_mass.config.save_player_config = MagicMock()
11193        mock_mass.create_task = MagicMock()
11194        mock_mass.get_provider = MagicMock(return_value=MagicMock(spec=PlayerProvider))
11195
11196        config = self._make_config(ap_player.player_id, enabled=False, provider="airplay")
11197
11198        await controller.on_player_config_change(config, {ATTR_ENABLED})
11199
11200        mock_mass.config.save_player_config.assert_not_called()
11201
11202
11203class TestDelayedEvalDisabledParentGuard:
11204    """Tests for the guard in _delayed_protocol_evaluation against disabled parents."""
11205
11206    @pytest.mark.asyncio
11207    async def test_skips_universal_creation_when_cached_parent_is_disabled(
11208        self, mock_mass: MagicMock
11209    ) -> None:
11210        """No universal player is created when the protocol's cached parent is disabled."""
11211        controller = PlayerController(mock_mass)
11212
11213        dlna_provider = MockProvider("dlna", mass=mock_mass)
11214        protocol_player = MockPlayer(
11215            dlna_provider,
11216            "dlna_orphan",
11217            "Orphan DLNA",
11218            player_type=PlayerType.PROTOCOL,
11219        )
11220        protocol_player.set_initialized()
11221        controller._players = {protocol_player.player_id: protocol_player}
11222
11223        cached_parent_id = "sonos_disabled"
11224
11225        def _config_get(key: str, default: object = None) -> object:
11226            if key.endswith("/protocol_parent_id"):
11227                return cached_parent_id
11228            if key == f"{CONF_PLAYERS}/{cached_parent_id}":
11229                return {"enabled": False, "provider": "sonos"}
11230            return default
11231
11232        mock_mass.config.get = MagicMock(side_effect=_config_get)
11233
11234        with (
11235            patch.object(controller, "_try_link_to_existing_player", return_value=False),
11236            patch.object(controller, "_find_matching_universal_player", return_value=None),
11237            patch.object(controller, "_create_or_update_universal_player") as mock_create_up,
11238        ):
11239            await controller._delayed_protocol_evaluation(protocol_player.player_id)
11240
11241        mock_create_up.assert_not_called()
11242
11243    @pytest.mark.asyncio
11244    async def test_creates_universal_when_cached_parent_is_enabled_but_offline(
11245        self, mock_mass: MagicMock
11246    ) -> None:
11247        """Sanity check: an enabled-but-unregistered cached parent still falls through."""
11248        controller = PlayerController(mock_mass)
11249
11250        dlna_provider = MockProvider("dlna", mass=mock_mass)
11251        protocol_player = MockPlayer(
11252            dlna_provider,
11253            "dlna_orphan",
11254            "Orphan DLNA",
11255            player_type=PlayerType.PROTOCOL,
11256        )
11257        protocol_player.set_initialized()
11258        controller._players = {protocol_player.player_id: protocol_player}
11259
11260        cached_parent_id = "sonos_offline"
11261
11262        def _config_get(key: str, default: object = None) -> object:
11263            if key.endswith("/protocol_parent_id"):
11264                return cached_parent_id
11265            if key == f"{CONF_PLAYERS}/{cached_parent_id}":
11266                return {"enabled": True, "provider": "sonos"}
11267            return default
11268
11269        mock_mass.config.get = MagicMock(side_effect=_config_get)
11270
11271        with (
11272            patch.object(controller, "_try_link_to_existing_player", return_value=False),
11273            patch.object(controller, "_find_matching_universal_player", return_value=None),
11274            patch.object(controller, "_create_or_update_universal_player") as mock_create_up,
11275            patch.object(
11276                controller,
11277                "_find_matching_protocol_players",
11278                return_value=[protocol_player],
11279            ),
11280        ):
11281            await controller._delayed_protocol_evaluation(protocol_player.player_id)
11282
11283        mock_create_up.assert_called_once()
11284
11285
11286class TestUniversalPlayerCurrentMedia:
11287    """Tests for current_media resolution while an output protocol is active."""
11288
11289    def test_current_media_surfaces_active_output_protocol(self, mock_mass: MagicMock) -> None:
11290        """
11291        While outputting via a protocol player, surface that player's raw current_media.
11292
11293        A sync group mirrors its leader's raw current_media to resolve the active queue
11294        item. When the leader is a universal player outputting via AirPlay, returning None
11295        here strands the group with a frozen current item (regression guard for grouped
11296        AirPlay live-metadata never refreshing).
11297        """
11298        protocol_provider = MockProvider("airplay", mass=mock_mass)
11299        protocol_player = MockPlayer(
11300            protocol_provider, "ap_1", "AirPlay", player_type=PlayerType.PROTOCOL
11301        )
11302        media = PlayerMedia(
11303            uri="http://x/flow/sess/queue_1/item_1/ap_1.flac", queue_item_id="item_1"
11304        )
11305        protocol_player._attr_current_media = media
11306
11307        universal = _create_universal_player(mock_mass, "up_1", "Universal", ["ap_1"])
11308        players = {"up_1": universal, "ap_1": protocol_player}
11309        mock_mass.players.get_player = MagicMock(side_effect=lambda pid: players.get(pid))
11310
11311        # without an active output protocol there is nothing to surface
11312        assert universal.current_media is None
11313
11314        universal.set_active_output_protocol("ap_1")
11315        assert universal.current_media is media
11316
11317    def test_current_media_none_for_native_output(self, mock_mass: MagicMock) -> None:
11318        """A native output protocol has no separate protocol player to surface."""
11319        universal = _create_universal_player(mock_mass, "up_1", "Universal", [])
11320        universal.set_active_output_protocol("native")
11321        assert universal.current_media is None
11322
11323
11324class TestSelfReferentialProtocolLinks:
11325    """
11326    Tests for the self-link bug when a Sendspin device changes player type.
11327
11328    A Sendspin device keeps a stable client_id (the MA player_id) across reflashes.
11329    When it first registers as a protocol player it gets wrapped in a universal
11330    player that caches that id as one of its protocols. If the device later
11331    reconnects as a non-protocol player (e.g. its roles changed to display-only)
11332    with the same id, the replace logic could link it to itself, hiding it.
11333    """
11334
11335    def test_add_protocol_link_refuses_self(self, mock_mass: MagicMock) -> None:
11336        """A player must never become its own protocol parent."""
11337        controller = PlayerController(mock_mass)
11338        provider = MockProvider("sendspin", mass=mock_mass)
11339        player = MockPlayer(provider, "esp_client", "ESP", player_type=PlayerType.PROTOCOL)
11340        mock_mass.players = controller
11341        controller._players = {"esp_client": player}
11342
11343        controller._add_protocol_link(player, player, "sendspin")
11344
11345        assert player.protocol_parent_id is None
11346        assert all(
11347            link.output_protocol_id != "esp_client" for link in player.linked_output_protocols
11348        )
11349
11350    def test_type_change_replaces_universal_without_self_link(self, mock_mass: MagicMock) -> None:
11351        """Replacing a universal player with the same-id native player must not self-link."""
11352        universal = _create_universal_player(
11353            mock_mass, "upespclient", "ESP", protocol_player_ids=["esp_client"]
11354        )
11355        display_provider = MockProvider("sendspin", mass=mock_mass)
11356        display_player = MockPlayer(
11357            display_provider, "esp_client", "ESP", player_type=PlayerType.DISPLAY
11358        )
11359
11360        store: dict[str, Any] = {"players/esp_client": {"enabled": True}}
11361        mock_mass.config.get.side_effect = lambda key, default=None: store.get(key, default)
11362        mock_mass.config.set.side_effect = lambda key, value: store.__setitem__(key, value)
11363        mock_mass.create_task = MagicMock()
11364        mock_mass.players = controller = PlayerController(mock_mass)
11365        controller._players = {"upespclient": universal, "esp_client": display_player}
11366
11367        controller._check_replace_universal_player(display_player)
11368
11369        assert display_player.protocol_parent_id is None
11370        assert store.get("players/esp_client/values/protocol_parent_id") != "esp_client"
11371        assert "esp_client" not in store.get("players/esp_client/values/linked_protocol_ids", [])
11372        # the obsolete universal player must not keep listing the native player,
11373        # or its permanent cleanup would re-evaluate it as an orphaned protocol
11374        assert "esp_client" not in universal._protocol_player_ids
11375
11376    async def test_migrate_clears_persisted_self_referential_link(self) -> None:
11377        """Config migration scrubs a self-referential link left by an older version."""
11378        config = ConfigController.__new__(ConfigController)
11379        config._data = {
11380            CONF_PLAYERS: {
11381                "esp_client": {
11382                    "provider": "sendspin",
11383                    "player_id": "esp_client",
11384                    "values": {
11385                        "protocol_parent_id": "esp_client",
11386                        "linked_protocol_ids": ["esp_client"],
11387                    },
11388                }
11389            }
11390        }
11391        await migrate(config._data)
11392
11393        values = config._data[CONF_PLAYERS]["esp_client"]["values"]
11394        assert values["protocol_parent_id"] is None
11395        assert "esp_client" not in values["linked_protocol_ids"]
11396
11397
11398class TestDerivedProtocolLinking:
11399    """
11400    Tests for derived protocol players (players with underlying_player_id set).
11401
11402    Derived protocol players (e.g. Sendspin bridges riding on an AirPlay player)
11403    resolve their parent deterministically via the underlying player instead of
11404    device identifier matching, and never seed universal players themselves.
11405    """
11406
11407    @staticmethod
11408    def _make_derived_player(
11409        mock_mass: MagicMock, player_id: str, underlying_player_id: str
11410    ) -> MockPlayer:
11411        """Create a derived protocol player without any device identifiers."""
11412        provider = MockProvider("sendspin", mass=mock_mass)
11413        player = MockPlayer(
11414            provider,
11415            player_id,
11416            f"Bridge {player_id}",
11417            player_type=PlayerType.PROTOCOL,
11418        )
11419        player._attr_underlying_player_id = underlying_player_id
11420        player.set_initialized()
11421        return player
11422
11423    def test_derived_links_to_parent_of_linked_underlying(self, mock_mass: MagicMock) -> None:
11424        """Test a derived player joins the parent of its (linked) underlying player."""
11425        controller = PlayerController(mock_mass)
11426        mock_mass.config.get = MagicMock(return_value=[])
11427
11428        native = MockPlayer(
11429            MockProvider("heos", mass=mock_mass), "native_1", "AVR", player_type=PlayerType.PLAYER
11430        )
11431        native.set_initialized()
11432        ap_player = MockPlayer(
11433            MockProvider("airplay", mass=mock_mass),
11434            "ap_1",
11435            "AVR AirPlay",
11436            player_type=PlayerType.PROTOCOL,
11437        )
11438        ap_player.set_initialized()
11439        controller._players = {"native_1": native, "ap_1": ap_player}
11440        controller._add_protocol_link(native, ap_player, "airplay")
11441
11442        # The derived player has NO identifiers - the edge alone must resolve it
11443        derived = self._make_derived_player(mock_mass, "spb_1", "ap_1")
11444        controller._players["spb_1"] = derived
11445        controller._try_link_protocol_to_native(derived)
11446
11447        assert derived.protocol_parent_id == "native_1"
11448        # the derived link carries its base output, the non-derived link does not
11449        links = {link.output_protocol_id: link for link in native.linked_output_protocols}
11450        assert links["spb_1"].derived_from == "ap_1"
11451        assert links["ap_1"].derived_from is None
11452
11453    def test_waiting_derived_follows_when_underlying_links(self, mock_mass: MagicMock) -> None:
11454        """Test a derived player that registered early follows once its underlying links."""
11455        controller = PlayerController(mock_mass)
11456        mock_mass.config.get = MagicMock(return_value=[])
11457
11458        native = MockPlayer(
11459            MockProvider("heos", mass=mock_mass), "native_1", "AVR", player_type=PlayerType.PLAYER
11460        )
11461        native.set_initialized()
11462        ap_player = MockPlayer(
11463            MockProvider("airplay", mass=mock_mass),
11464            "ap_1",
11465            "AVR AirPlay",
11466            player_type=PlayerType.PROTOCOL,
11467        )
11468        ap_player.set_initialized()
11469        derived = self._make_derived_player(mock_mass, "spb_1", "ap_1")
11470        controller._players = {"native_1": native, "ap_1": ap_player, "spb_1": derived}
11471
11472        # Underlying is not linked yet: derived stays unlinked
11473        controller._try_link_protocol_to_native(derived)
11474        assert derived.protocol_parent_id is None
11475
11476        # Linking the underlying player propagates to the waiting derived player
11477        controller._add_protocol_link(native, ap_player, "airplay")
11478        assert derived.protocol_parent_id == "native_1"
11479
11480    def test_derived_links_directly_to_non_protocol_underlying(self, mock_mass: MagicMock) -> None:
11481        """Test a derived player parents to the underlying player itself when not a protocol."""
11482        controller = PlayerController(mock_mass)
11483        mock_mass.config.get = MagicMock(side_effect=lambda _key, default=None: default)
11484
11485        local_player = MockPlayer(
11486            MockProvider("local_audio", mass=mock_mass),
11487            "local_1",
11488            "Dummy Output",
11489            player_type=PlayerType.PLAYER,
11490        )
11491        local_player.set_initialized()
11492        derived = self._make_derived_player(mock_mass, "spb_1", "local_1")
11493        controller._players = {"spb_1": derived, "local_1": local_player}
11494
11495        # The final derived pass of _try_link_protocols_to_native picks it up
11496        controller._try_link_protocols_to_native(local_player)
11497
11498        assert derived.protocol_parent_id == "local_1"
11499        # riding on the parent player itself normalizes the base output to "native"
11500        links = {link.output_protocol_id: link for link in local_player.linked_output_protocols}
11501        assert links["spb_1"].derived_from == "native"
11502
11503    @pytest.mark.asyncio
11504    async def test_derived_delayed_eval_never_creates_universal_player(
11505        self, mock_mass: MagicMock
11506    ) -> None:
11507        """Test delayed evaluation of a derived player never wraps it in a universal player."""
11508        controller, _ = TestEndToEndDuplicateProtocol._setup_e2e_controller(mock_mass)
11509        mock_mass.config.get = MagicMock(return_value=[])
11510
11511        derived = self._make_derived_player(mock_mass, "spb_1", "ap_missing")
11512        controller._players = {"spb_1": derived}
11513
11514        await controller._delayed_protocol_evaluation("spb_1")
11515
11516        assert derived.protocol_parent_id is None
11517        assert not any(pid.startswith("up") for pid in controller._players)
11518
11519    def test_derived_link_refused_on_domain_duplicate(self, mock_mass: MagicMock) -> None:
11520        """Test a derived player stays unlinked when the parent already has its domain."""
11521        controller = PlayerController(mock_mass)
11522        mock_mass.config.get = MagicMock(return_value=[])
11523
11524        native = MockPlayer(
11525            MockProvider("heos", mass=mock_mass), "native_1", "AVR", player_type=PlayerType.PLAYER
11526        )
11527        native.set_initialized()
11528        ap_player = MockPlayer(
11529            MockProvider("airplay", mass=mock_mass),
11530            "ap_1",
11531            "AVR AirPlay",
11532            player_type=PlayerType.PROTOCOL,
11533        )
11534        ap_player.set_initialized()
11535        other_sendspin = MockPlayer(
11536            MockProvider("sendspin", mass=mock_mass),
11537            "sendspin_native_client",
11538            "AVR Sendspin",
11539            player_type=PlayerType.PROTOCOL,
11540        )
11541        other_sendspin.set_initialized()
11542        controller._players = {
11543            "native_1": native,
11544            "ap_1": ap_player,
11545            "sendspin_native_client": other_sendspin,
11546        }
11547        controller._add_protocol_link(native, ap_player, "airplay")
11548        controller._add_protocol_link(native, other_sendspin, "sendspin")
11549
11550        derived = self._make_derived_player(mock_mass, "spb_1", "ap_1")
11551        controller._players["spb_1"] = derived
11552        controller._try_link_protocol_to_native(derived)
11553
11554        assert derived.protocol_parent_id is None
11555
11556    def test_derived_joins_universal_parent(self, mock_mass: MagicMock) -> None:
11557        """Test a derived player joins the universal player of its underlying player."""
11558        controller = PlayerController(mock_mass)
11559        mock_mass.config.get = MagicMock(return_value=[])
11560
11561        def _close_coro(coro: Any, *_args: Any, **_kwargs: Any) -> None:
11562            if hasattr(coro, "close"):
11563                coro.close()
11564
11565        mock_mass.create_task = MagicMock(side_effect=_close_coro)
11566
11567        universal = _create_universal_player(
11568            mock_mass,
11569            "up_aabbccddeeff",
11570            "Living Room Speaker",
11571            protocol_player_ids=["ap_1"],
11572            identifiers={IdentifierType.MAC_ADDRESS: "AA:BB:CC:DD:EE:FF"},
11573        )
11574        ap_player = MockPlayer(
11575            MockProvider("airplay", mass=mock_mass),
11576            "ap_1",
11577            "AirPlay Speaker",
11578            player_type=PlayerType.PROTOCOL,
11579            identifiers={IdentifierType.MAC_ADDRESS: "AA:BB:CC:DD:EE:FF"},
11580        )
11581        ap_player.set_initialized()
11582        controller._players = {"up_aabbccddeeff": universal, "ap_1": ap_player}
11583        controller._add_protocol_link(universal, ap_player, "airplay")
11584
11585        derived = self._make_derived_player(mock_mass, "spb_1", "ap_1")
11586        controller._players["spb_1"] = derived
11587        controller._try_link_protocol_to_native(derived)
11588
11589        assert derived.protocol_parent_id == "up_aabbccddeeff"
11590        assert "spb_1" in universal._protocol_player_ids
11591
11592    @pytest.mark.parametrize("derived_first", [False, True])
11593    def test_derived_stays_with_refused_base_on_replacement(
11594        self, mock_mass: MagicMock, derived_first: bool
11595    ) -> None:
11596        """
11597        Keep a derived protocol with its base when the base's link is refused.
11598
11599        The native player absorbs the first universal player's cast protocol, so the
11600        second universal player's cast protocol is refused and stays behind. The
11601        sendspin bridge riding on it must stay behind too instead of moving on its own,
11602        whichever order the two links are listed in.
11603        """
11604        controller = PlayerController(mock_mass)
11605        up_provider = create_mock_universal_provider(mock_mass)
11606
11607        config_store: dict[str, Any] = {
11608            f"players/{player_id}": {"enabled": True}
11609            for player_id in ("native", "up_a", "up_b", "cc_1", "cc_2", "spb_2")
11610        }
11611        mock_mass.config.get = MagicMock(side_effect=config_store.get)
11612        mock_mass.config.set = MagicMock(side_effect=config_store.__setitem__)
11613        mock_mass.config.remove = MagicMock(side_effect=lambda key: config_store.pop(key, None))
11614        mock_mass.players = controller
11615        mock_mass.player_queues = MagicMock()
11616        mock_mass.call_later = MagicMock()
11617        mock_mass.loop = MagicMock()
11618        mock_mass.create_task = MagicMock()
11619
11620        mac = "AA:BB:CC:DD:EE:FF"
11621        identifiers = {IdentifierType.MAC_ADDRESS: mac}
11622        universals = {}
11623        for universal_id, protocol_ids in (("up_a", ["cc_1"]), ("up_b", ["cc_2", "spb_2"])):
11624            universal = UniversalPlayer(
11625                provider=up_provider,
11626                player_id=universal_id,
11627                name=f"Soundbar ({universal_id})",
11628                device_info=DeviceInfo(model="Test", manufacturer="Test"),
11629                protocol_player_ids=protocol_ids,
11630            )
11631            universal._attr_device_info.add_identifier(IdentifierType.MAC_ADDRESS, mac)
11632            universal._cache.clear()
11633            universal.update_state(signal_event=False)
11634            universal.set_initialized()
11635            universals[universal_id] = universal
11636
11637        native = MockPlayer(
11638            MockProvider("linkplay", mass=mock_mass), "native", "Soundbar", identifiers=identifiers
11639        )
11640        native.set_initialized()
11641
11642        cast_provider = MockProvider("chromecast", mass=mock_mass)
11643        cast_players = {}
11644        for cast_id in ("cc_1", "cc_2"):
11645            cast_player = MockPlayer(
11646                cast_provider,
11647                cast_id,
11648                f"Cast {cast_id}",
11649                player_type=PlayerType.PROTOCOL,
11650                identifiers=identifiers,
11651            )
11652            cast_player.set_initialized()
11653            cast_players[cast_id] = cast_player
11654
11655        derived = self._make_derived_player(mock_mass, "spb_2", "cc_2")
11656
11657        controller._players = {
11658            **universals,
11659            **cast_players,
11660            "native": native,
11661            "spb_2": derived,
11662        }
11663        controller._add_protocol_link(universals["up_a"], cast_players["cc_1"], "chromecast")
11664        if derived_first:
11665            controller._add_protocol_link(universals["up_b"], derived, "sendspin")
11666            controller._add_protocol_link(universals["up_b"], cast_players["cc_2"], "chromecast")
11667        else:
11668            controller._add_protocol_link(universals["up_b"], cast_players["cc_2"], "chromecast")
11669            controller._add_protocol_link(universals["up_b"], derived, "sendspin")
11670
11671        controller._check_replace_universal_player(native)
11672
11673        # the second universal player keeps the refused cast protocol and its bridge
11674        assert cast_players["cc_2"].protocol_parent_id == "up_b"
11675        assert derived.protocol_parent_id == "up_b"
11676        assert {link.output_protocol_id for link in universals["up_b"].linked_output_protocols} == {
11677            "cc_2",
11678            "spb_2",
11679        }
11680        # the native player only took over the protocol it could actually claim
11681        assert {link.output_protocol_id for link in native.linked_output_protocols} == {"cc_1"}
11682        assert "spb_2" not in (config_store.get("players/native/values/linked_protocol_ids") or [])
11683
11684    def test_base_stays_with_refused_derived_on_replacement(self, mock_mass: MagicMock) -> None:
11685        """
11686        Keep a base protocol with its derived protocol when the derived link is refused.
11687
11688        The native player already serves the sendspin domain, so the universal player's
11689        sendspin bridge cannot move. The cast protocol it rides on must stay behind with
11690        it instead of moving on its own.
11691        """
11692        controller = PlayerController(mock_mass)
11693        up_provider = create_mock_universal_provider(mock_mass)
11694
11695        config_store: dict[str, Any] = {
11696            f"players/{player_id}": {"enabled": True}
11697            for player_id in ("native", "up_b", "cc_2", "spb_2", "spb_native")
11698        }
11699        mock_mass.config.get = MagicMock(side_effect=config_store.get)
11700        mock_mass.config.set = MagicMock(side_effect=config_store.__setitem__)
11701        mock_mass.config.remove = MagicMock(side_effect=lambda key: config_store.pop(key, None))
11702        mock_mass.players = controller
11703        mock_mass.player_queues = MagicMock()
11704        mock_mass.call_later = MagicMock()
11705        mock_mass.loop = MagicMock()
11706        mock_mass.create_task = MagicMock()
11707
11708        mac = "AA:BB:CC:DD:EE:FF"
11709        identifiers = {IdentifierType.MAC_ADDRESS: mac}
11710        universal = UniversalPlayer(
11711            provider=up_provider,
11712            player_id="up_b",
11713            name="Soundbar (Universal)",
11714            device_info=DeviceInfo(model="Test", manufacturer="Test"),
11715            protocol_player_ids=["cc_2", "spb_2"],
11716        )
11717        universal._attr_device_info.add_identifier(IdentifierType.MAC_ADDRESS, mac)
11718        universal._cache.clear()
11719        universal.update_state(signal_event=False)
11720        universal.set_initialized()
11721
11722        native = MockPlayer(
11723            MockProvider("linkplay", mass=mock_mass), "native", "Soundbar", identifiers=identifiers
11724        )
11725        native.set_initialized()
11726        cast_player = MockPlayer(
11727            MockProvider("chromecast", mass=mock_mass),
11728            "cc_2",
11729            "Cast",
11730            player_type=PlayerType.PROTOCOL,
11731            identifiers=identifiers,
11732        )
11733        cast_player.set_initialized()
11734        derived = self._make_derived_player(mock_mass, "spb_2", "cc_2")
11735        sendspin_native = MockPlayer(
11736            MockProvider("sendspin", mass=mock_mass),
11737            "spb_native",
11738            "Soundbar (Sendspin)",
11739            player_type=PlayerType.PROTOCOL,
11740            identifiers=identifiers,
11741        )
11742        sendspin_native.set_initialized()
11743
11744        controller._players = {
11745            "up_b": universal,
11746            "native": native,
11747            "cc_2": cast_player,
11748            "spb_2": derived,
11749            "spb_native": sendspin_native,
11750        }
11751        controller._add_protocol_link(universal, cast_player, "chromecast")
11752        controller._add_protocol_link(universal, derived, "sendspin")
11753        controller._add_protocol_link(native, sendspin_native, "sendspin")
11754
11755        controller._check_replace_universal_player(native)
11756
11757        # the bridge could not move, so the cast protocol it rides on stays with it
11758        assert derived.protocol_parent_id == "up_b"
11759        assert cast_player.protocol_parent_id == "up_b"
11760        assert {link.output_protocol_id for link in universal.linked_output_protocols} == {
11761            "cc_2",
11762            "spb_2",
11763        }
11764        assert {link.output_protocol_id for link in native.linked_output_protocols} == {
11765            "spb_native"
11766        }
11767
11768    def test_save_underlying_player_id_persists_and_clears(self, mock_mass: MagicMock) -> None:
11769        """Test the derived edge is persisted, cleared when revoked and skipped otherwise."""
11770        controller = PlayerController(mock_mass)
11771        derived = self._make_derived_player(mock_mass, "spb_1", "ap_1")
11772        conf_key = "players/spb_1/values/underlying_player_id"
11773        stored: dict[str, Any] = {"players/spb_1": {"values": {}}}
11774        mock_mass.config.get = MagicMock(
11775            side_effect=lambda key, default=None: stored.get(key, default)
11776        )
11777        mock_mass.config.set = MagicMock(side_effect=stored.__setitem__)
11778
11779        # derived player: edge gets persisted
11780        controller._save_underlying_player_id(derived)
11781        assert stored[conf_key] == "ap_1"
11782
11783        # edge revoked (e.g. bridge client turned web player): stale value is cleared
11784        derived._attr_underlying_player_id = None
11785        controller._save_underlying_player_id(derived)
11786        assert stored[conf_key] is None
11787
11788        # nothing persisted and no edge: no write at all
11789        mock_mass.config.set.reset_mock()
11790        del stored[conf_key]
11791        controller._save_underlying_player_id(derived)
11792        mock_mass.config.set.assert_not_called()
11793
11794        # player config gone: never create partial entries
11795        derived._attr_underlying_player_id = "ap_1"
11796        del stored["players/spb_1"]
11797        controller._save_underlying_player_id(derived)
11798        mock_mass.config.set.assert_not_called()
11799
11800
11801class TestLocalAudioPlayerPromotion:
11802    """
11803    Tests for the local_audio reshape: attribution stub -> regular native player.
11804
11805    The local_audio device player (bare device-uuid player_id) is a regular,
11806    visible PLAYER that parents its Sendspin bridge directly. On upgrade, the
11807    previously auto-created universal player wrapper (up<uuid-hex>) must be
11808    absorbed by the native player without re-wrapping it.
11809    """
11810
11811    DEVICE_UUID = "aabbccdd-1122-3344-5566-77889900aabb"
11812    UNIVERSAL_ID = "upaabbccdd11223344556677889900aabb"
11813
11814    def test_native_local_player_absorbs_universal_wrapper(self, mock_mass: MagicMock) -> None:
11815        """The native player takes over the bridge protocol and the wrapper is emptied."""
11816        universal = _create_universal_player(
11817            mock_mass,
11818            self.UNIVERSAL_ID,
11819            "Dummy Output",
11820            protocol_player_ids=[self.DEVICE_UUID, "spb_1"],
11821        )
11822        native = MockPlayer(
11823            MockProvider("local_audio", mass=mock_mass),
11824            self.DEVICE_UUID,
11825            "Dummy Output",
11826            player_type=PlayerType.PLAYER,
11827            identifiers={IdentifierType.UUID: self.DEVICE_UUID},
11828        )
11829        native.set_initialized()
11830        spb = MockPlayer(
11831            MockProvider("sendspin", mass=mock_mass),
11832            "spb_1",
11833            "Dummy Output Sendspin",
11834            player_type=PlayerType.PROTOCOL,
11835        )
11836        spb._attr_underlying_player_id = self.DEVICE_UUID
11837        spb.set_initialized()
11838
11839        store: dict[str, Any] = {
11840            f"players/{self.DEVICE_UUID}": {"enabled": True},
11841            "players/spb_1": {"enabled": True},
11842        }
11843        mock_mass.config.get = MagicMock(
11844            side_effect=lambda key, default=None: store.get(key, default)
11845        )
11846        mock_mass.config.set = MagicMock(side_effect=store.__setitem__)
11847
11848        def _close_coro(coro: Any, *_args: Any, **_kwargs: Any) -> None:
11849            if hasattr(coro, "close"):
11850                coro.close()
11851
11852        mock_mass.create_task = MagicMock(side_effect=_close_coro)
11853        mock_mass.players = controller = PlayerController(mock_mass)
11854        controller._players = {
11855            self.UNIVERSAL_ID: universal,
11856            self.DEVICE_UUID: native,
11857            "spb_1": spb,
11858        }
11859        controller._add_protocol_link(universal, spb, "sendspin")
11860
11861        controller._try_link_protocols_to_native(native)
11862
11863        assert spb.protocol_parent_id == self.DEVICE_UUID
11864        assert [link.output_protocol_id for link in native.linked_output_protocols] == ["spb_1"]
11865        # the obsolete wrapper keeps neither the bridge nor the native player itself,
11866        # so its permanent cleanup has no orphaned protocols to re-evaluate
11867        assert universal._protocol_player_ids == []
11868        assert store.get("players/spb_1/values/protocol_parent_id") == self.DEVICE_UUID
11869
11870    async def test_delayed_eval_skips_type_changed_player(self, mock_mass: MagicMock) -> None:
11871        """A pending evaluation for a player that is not (or no longer) a protocol is a no-op."""
11872        controller, _ = TestEndToEndDuplicateProtocol._setup_e2e_controller(mock_mass)
11873        mock_mass.config.get = MagicMock(return_value=[])
11874
11875        native = MockPlayer(
11876            MockProvider("local_audio", mass=mock_mass),
11877            self.DEVICE_UUID,
11878            "Dummy Output",
11879            player_type=PlayerType.PLAYER,
11880        )
11881        native.set_initialized()
11882        controller._players = {self.DEVICE_UUID: native}
11883
11884        await controller._delayed_protocol_evaluation(self.DEVICE_UUID)
11885
11886        assert native.protocol_parent_id is None
11887        assert not any(pid.startswith("up") for pid in controller._players)
11888
11889
11890class TestLocalAudioStubMigration:
11891    """Tests for the config migration that promotes local_audio stubs to regular players."""
11892
11893    DEVICE_UUID = "aabbccdd-1122-3344-5566-77889900aabb"
11894    UNIVERSAL_ID = "upaabbccdd11223344556677889900aabb"
11895
11896    def _make_data(self) -> dict[str, Any]:
11897        """Build a settings dict as persisted by the previous local_audio setup."""
11898        return {
11899            CONF_PLAYERS: {
11900                self.DEVICE_UUID: {
11901                    "player_id": self.DEVICE_UUID,
11902                    "provider": "local_audio",
11903                    "player_type": "protocol",
11904                    "enabled": True,
11905                    "values": {"protocol_parent_id": self.UNIVERSAL_ID},
11906                },
11907                self.UNIVERSAL_ID: {
11908                    "player_id": self.UNIVERSAL_ID,
11909                    "provider": "universal_player",
11910                    "player_type": "group",
11911                    "enabled": False,
11912                    "name": "Kitchen Output",
11913                    "values": {
11914                        # ghost_spb has no config anymore (device gone) - must not crash
11915                        "linked_protocol_ids": [self.DEVICE_UUID, "spb_1", "ghost_spb"],
11916                        "device_identifiers": {"uuid": self.DEVICE_UUID},
11917                        "device_info": {"model": "Dummy Output", "manufacturer": "Local Audio"},
11918                        "expose_to_ha": False,
11919                        "hide_in_ui": True,
11920                    },
11921                },
11922                "spb_1": {
11923                    "player_id": "spb_1",
11924                    "provider": "sendspin",
11925                    "player_type": "protocol",
11926                    "enabled": True,
11927                    "values": {"protocol_parent_id": self.UNIVERSAL_ID},
11928                },
11929                "syncgroup_1": {
11930                    "player_id": "syncgroup_1",
11931                    "provider": "sync_group",
11932                    "player_type": "group",
11933                    "enabled": True,
11934                    "values": {
11935                        "group_members": [self.UNIVERSAL_ID, "other_player"],
11936                        "allowed_members": [self.UNIVERSAL_ID],
11937                    },
11938                },
11939            },
11940            CONF_PLAYER_QUEUES: {
11941                self.UNIVERSAL_ID: {
11942                    "queue_id": self.UNIVERSAL_ID,
11943                    "values": {"autoplay_mode": "smart"},
11944                },
11945            },
11946            CONF_PLAYER_DSP: {
11947                self.UNIVERSAL_ID: {"enabled": True},
11948            },
11949        }
11950
11951    async def test_stub_promoted_and_universal_absorbed(self) -> None:
11952        """The stub becomes a regular player carrying the universal player's settings."""
11953        data = self._make_data()
11954
11955        assert await migrate(data) is True
11956
11957        stub_cfg = data[CONF_PLAYERS][self.DEVICE_UUID]
11958        assert stub_cfg["player_type"] == "player"
11959        # the universal player was the device toggle the user operated
11960        assert stub_cfg["enabled"] is False
11961        assert stub_cfg["name"] == "Kitchen Output"
11962        values = stub_cfg["values"]
11963        assert "protocol_parent_id" not in values
11964        assert values["expose_to_ha"] is False
11965        assert values["hide_in_ui"] is True
11966        assert values["linked_protocol_ids"] == ["spb_1", "ghost_spb"]
11967        assert "device_identifiers" not in values
11968        assert "device_info" not in values
11969
11970        # the wrapper is gone and the bridge protocol re-parented
11971        assert self.UNIVERSAL_ID not in data[CONF_PLAYERS]
11972        assert data[CONF_PLAYERS]["spb_1"]["values"]["protocol_parent_id"] == self.DEVICE_UUID
11973        assert data[CONF_PLAYERS]["spb_1"]["player_type"] == "protocol"
11974
11975        # queue settings, DSP config and group memberships follow the new player_id
11976        queue_cfg = data[CONF_PLAYER_QUEUES][self.DEVICE_UUID]
11977        assert queue_cfg["queue_id"] == self.DEVICE_UUID
11978        assert queue_cfg["values"]["autoplay_mode"] == "smart"
11979        assert self.UNIVERSAL_ID not in data[CONF_PLAYER_QUEUES]
11980        assert data[CONF_PLAYER_DSP] == {self.DEVICE_UUID: {"enabled": True}}
11981        group_values = data[CONF_PLAYERS]["syncgroup_1"]["values"]
11982        assert group_values["group_members"] == [self.DEVICE_UUID, "other_player"]
11983        assert group_values["allowed_members"] == [self.DEVICE_UUID]
11984
11985    async def test_stub_without_universal_wrapper(self) -> None:
11986        """A stub without a universal player wrapper is still promoted."""
11987        data = self._make_data()
11988        del data[CONF_PLAYERS][self.UNIVERSAL_ID]
11989
11990        assert await migrate(data) is True
11991
11992        stub_cfg = data[CONF_PLAYERS][self.DEVICE_UUID]
11993        assert stub_cfg["player_type"] == "player"
11994        assert stub_cfg["enabled"] is True
11995        assert "protocol_parent_id" not in stub_cfg["values"]
11996
11997    async def test_migration_is_idempotent(self) -> None:
11998        """A second run finds nothing left to migrate."""
11999        data = self._make_data()
12000
12001        assert await migrate(data) is True
12002        assert await migrate(data) is False
12003
12004
12005class _LockedMockPlayer(MockPlayer):
12006    """A player that keeps its group read-only (e.g. an externally-created mixed group)."""
12007
12008    @property
12009    def grouping_locked(self) -> bool:
12010        """Suppress grouping in the exposed state."""
12011        return True
12012
12013
12014class TestGroupingLockedPolicy:
12015    """A grouping-locked player withholds SET_MEMBERS and group targets in its final state."""
12016
12017    def test_locked_player_withholds_set_members(self, mock_mass: MagicMock) -> None:
12018        """A locked player drops SET_MEMBERS from its exposed features."""
12019        provider = MockProvider("wiim", instance_id="wiim_instance", mass=mock_mass)
12020        player = _LockedMockPlayer(provider, "shell_1", "Shell")
12021        player._attr_supported_features = {PlayerFeature.SET_MEMBERS, PlayerFeature.VOLUME_SET}
12022        player._cache.clear()
12023        player.update_state(signal_event=False)
12024        assert PlayerFeature.SET_MEMBERS not in player.state.supported_features
12025        assert PlayerFeature.VOLUME_SET in player.state.supported_features
12026
12027    def test_locked_player_withholds_protocol_derived_set_members(
12028        self, mock_mass: MagicMock
12029    ) -> None:
12030        """Even a linked protocol player's SET_MEMBERS is suppressed while locked."""
12031        controller = PlayerController(mock_mass)
12032        provider = MockProvider("wiim", instance_id="wiim_instance", mass=mock_mass)
12033        player = _LockedMockPlayer(provider, "shell_1", "Shell")
12034        player._attr_supported_features = set()  # no native grouping of its own
12035        protocol = MockPlayer(provider, "airplay_p", "P (AirPlay)", player_type=PlayerType.PROTOCOL)
12036        protocol._attr_supported_features.add(PlayerFeature.SET_MEMBERS)
12037        protocol._cache.clear()
12038        protocol.set_protocol_parent_id("shell_1")
12039        player.set_linked_output_protocols(
12040            [
12041                LinkedOutputProtocol(
12042                    output_protocol_id="airplay_p", protocol_domain="airplay", priority=10
12043                )
12044            ]
12045        )
12046        mock_mass.players = controller
12047        controller._players = {"shell_1": player, "airplay_p": protocol}
12048        protocol.refresh_state(signal_event=False)
12049        player.refresh_state(signal_event=False)
12050        assert PlayerFeature.SET_MEMBERS not in player.state.supported_features
12051
12052    def test_locked_player_offers_no_group_targets(self, mock_mass: MagicMock) -> None:
12053        """A locked player exposes no group targets, even an otherwise groupable peer."""
12054        controller = PlayerController(mock_mass)
12055        provider = MockProvider("wiim", instance_id="wiim_instance", mass=mock_mass)
12056        player = _LockedMockPlayer(provider, "shell_1", "Shell")
12057        player._attr_supported_features = {PlayerFeature.SET_MEMBERS}
12058        player._attr_can_group_with = {"peer_1"}
12059        peer = MockPlayer(provider, "peer_1", "Peer")
12060        mock_mass.players = controller
12061        controller._players = {"shell_1": player, "peer_1": peer}
12062        player._cache.clear()
12063        peer.refresh_state(signal_event=False)
12064        player.refresh_state(signal_event=False)
12065        assert player.state.can_group_with == set()
12066
12067    def test_unlocked_player_keeps_grouping(self, mock_mass: MagicMock) -> None:
12068        """A normal (unlocked) player keeps SET_MEMBERS and its group targets."""
12069        controller = PlayerController(mock_mass)
12070        provider = MockProvider("wiim", instance_id="wiim_instance", mass=mock_mass)
12071        player = MockPlayer(provider, "shell_2", "Shell2")
12072        player._attr_supported_features = {PlayerFeature.SET_MEMBERS}
12073        player._attr_can_group_with = {"peer_2"}
12074        peer = MockPlayer(provider, "peer_2", "Peer2")
12075        mock_mass.players = controller
12076        controller._players = {"shell_2": player, "peer_2": peer}
12077        player._cache.clear()
12078        peer.refresh_state(signal_event=False)
12079        player.refresh_state(signal_event=False)
12080        assert PlayerFeature.SET_MEMBERS in player.state.supported_features
12081        assert "peer_2" in player.state.can_group_with
12082
12083    def test_locked_peer_excluded_from_group_targets(self, mock_mass: MagicMock) -> None:
12084        """An unlocked leader drops a locked peer but keeps an unlocked one as a target."""
12085        controller = PlayerController(mock_mass)
12086        provider = MockProvider("wiim", instance_id="wiim_instance", mass=mock_mass)
12087        leader = MockPlayer(provider, "shell_1", "Shell")
12088        leader._attr_supported_features = {PlayerFeature.SET_MEMBERS}
12089        leader._attr_can_group_with = {"locked_peer", "open_peer"}
12090        locked_peer = _LockedMockPlayer(provider, "locked_peer", "Locked Peer")
12091        open_peer = MockPlayer(provider, "open_peer", "Open Peer")
12092        mock_mass.players = controller
12093        controller._players = {
12094            "shell_1": leader,
12095            "locked_peer": locked_peer,
12096            "open_peer": open_peer,
12097        }
12098        leader._cache.clear()
12099        for player in (locked_peer, open_peer, leader):
12100            player.refresh_state(signal_event=False)
12101        # the locked peer keeps its own group read-only, so it is not offered as a target,
12102        # while the ordinary peer still is
12103        assert "locked_peer" not in leader.state.can_group_with
12104        assert "open_peer" in leader.state.can_group_with
12105
12106    def test_linked_protocol_cannot_reintroduce_locked_member(self, mock_mass: MagicMock) -> None:
12107        """A locked member offered by a linked protocol is still kept out of the targets."""
12108        controller = PlayerController(mock_mass)
12109        provider = MockProvider("wiim", instance_id="wiim_instance", mass=mock_mass)
12110        leader = MockPlayer(provider, "shell_1", "Shell")
12111        leader._attr_supported_features = set()  # no native grouping of its own
12112        protocol = MockPlayer(provider, "airplay_p", "P (AirPlay)", player_type=PlayerType.PROTOCOL)
12113        protocol._attr_supported_features.add(PlayerFeature.SET_MEMBERS)
12114        protocol._attr_can_group_with = {"locked_peer", "open_peer"}
12115        protocol._cache.clear()
12116        protocol.set_protocol_parent_id("shell_1")
12117        leader.set_linked_output_protocols(
12118            [
12119                LinkedOutputProtocol(
12120                    output_protocol_id="airplay_p", protocol_domain="airplay", priority=10
12121                )
12122            ]
12123        )
12124        locked_peer = _LockedMockPlayer(provider, "locked_peer", "Locked Peer")
12125        open_peer = MockPlayer(provider, "open_peer", "Open Peer")
12126        mock_mass.players = controller
12127        controller._players = {
12128            "shell_1": leader,
12129            "airplay_p": protocol,
12130            "locked_peer": locked_peer,
12131            "open_peer": open_peer,
12132        }
12133        for player in (locked_peer, open_peer, protocol):
12134            player.refresh_state(signal_event=False)
12135        leader._cache.clear()
12136        leader.refresh_state(signal_event=False)
12137        # the linked protocol lists the locked peer, but the final filter still excludes it
12138        assert "locked_peer" not in leader.state.can_group_with
12139        assert "open_peer" in leader.state.can_group_with
12140
12141
12142class _NativeGroupingMockPlayer(MockPlayer):
12143    """A player that runs its own multiroom and prefers native grouping."""
12144
12145    @property
12146    def prefer_native_grouping(self) -> bool:
12147        """Prefer native grouping over any linked protocol."""
12148        return True
12149
12150
12151class _RejectsNativeGroupingMockPlayer(MockPlayer):
12152    """A parent that never accepts another player as a native group peer."""
12153
12154    def is_native_group_compatible(self, other: Player) -> bool:
12155        """Reject every peer as native-incompatible (e.g. a cross-backend device)."""
12156        return False
12157
12158
12159class TestPreferNativeGrouping:
12160    """A player that prefers native grouping uses it before its preferred output protocol."""
12161
12162    def test_prefer_native_uses_native_before_child_preferred(self, mock_mass: MagicMock) -> None:
12163        """A prefer-native child groups natively even with an explicit protocol preference."""
12164        controller = PlayerController(mock_mass)
12165        provider = MockProvider("wiim", instance_id="wiim_instance", mass=mock_mass)
12166        parent = MockPlayer(provider, "shell_parent", "Parent")
12167        child = _NativeGroupingMockPlayer(provider, "shell_child", "Child")
12168        # an explicit child output-protocol preference that would otherwise win
12169        mock_mass.config.get_raw_player_config_value = MagicMock(return_value="airplay_child")
12170        mock_mass.players = controller
12171        controller._players = {"shell_parent": parent, "shell_child": child}
12172        native_members: list[str] = []
12173        protocol_members: list[str] = []
12174        controller._select_grouping_for_member(
12175            child, parent, None, None, True, protocol_members, native_members
12176        )
12177        assert native_members == ["shell_child"]
12178        assert protocol_members == []
12179
12180    def test_prefer_native_falls_through_same_instance_not_native_peer(
12181        self, mock_mass: MagicMock
12182    ) -> None:
12183        """A same-instance pair the parent rejects as a native peer is not forced native."""
12184        controller = PlayerController(mock_mass)
12185        # official + generic LinkPlay share one wiim provider instance, but a cross-backend
12186        # peer is not native-group compatible, so the seam must not force native grouping.
12187        provider = MockProvider("wiim", instance_id="wiim_instance", mass=mock_mass)
12188        parent = _RejectsNativeGroupingMockPlayer(provider, "shell_parent", "Parent")
12189        child = _NativeGroupingMockPlayer(provider, "shell_child", "Child")
12190        mock_mass.config.get_raw_player_config_value = MagicMock(return_value=None)
12191        mock_mass.players = controller
12192        controller._players = {"shell_parent": parent, "shell_child": child}
12193        native_members: list[str] = []
12194        protocol_members: list[str] = []
12195        controller._select_grouping_for_member(
12196            child, parent, None, None, True, protocol_members, native_members
12197        )
12198        assert native_members == []
12199
12200    def test_prefer_native_falls_through_when_incompatible(self, mock_mass: MagicMock) -> None:
12201        """When native grouping is not possible (cross-backend), the seam does not force it."""
12202        controller = PlayerController(mock_mass)
12203        parent_provider = MockProvider("wiim", instance_id="wiim_instance", mass=mock_mass)
12204        child_provider = MockProvider("other", instance_id="other_instance", mass=mock_mass)
12205        parent = MockPlayer(parent_provider, "shell_parent", "Parent")
12206        parent._attr_can_group_with = set()  # does not accept the cross-backend child
12207        child = _NativeGroupingMockPlayer(child_provider, "other_child", "Child")
12208        mock_mass.config.get_raw_player_config_value = MagicMock(return_value=None)
12209        mock_mass.players = controller
12210        controller._players = {"shell_parent": parent, "other_child": child}
12211        native_members: list[str] = []
12212        protocol_members: list[str] = []
12213        controller._select_grouping_for_member(
12214            child, parent, None, None, True, protocol_members, native_members
12215        )
12216        # the preferred-native seam did not force a cross-backend native group
12217        assert native_members == []
12218
12219    def test_default_player_keeps_protocol_first_ordering(self, mock_mass: MagicMock) -> None:
12220        """An ordinary player (no native preference) is unaffected by the seam."""
12221        controller = PlayerController(mock_mass)
12222        provider = MockProvider("wiim", instance_id="wiim_instance", mass=mock_mass)
12223        parent = MockPlayer(provider, "shell_parent", "Parent")
12224        child = MockPlayer(provider, "plain_child", "Child")
12225        assert child.prefer_native_grouping is False
12226        mock_mass.config.get_raw_player_config_value = MagicMock(return_value=None)
12227        mock_mass.players = controller
12228        controller._players = {"shell_parent": parent, "plain_child": child}
12229        native_members: list[str] = []
12230        protocol_members: list[str] = []
12231        controller._select_grouping_for_member(
12232            child, parent, None, None, True, protocol_members, native_members
12233        )
12234        # with no preferred/active/common protocol, the default player still reaches
12235        # native grouping via the normal priority (Priority 3), not the preferred seam
12236        assert native_members == ["plain_child"]
12237
12238
12239def _live_source(item_id: str, provider: str = "spotify_connect") -> AudioSource:
12240    """Build the AudioSource a live session publishes on a player."""
12241    return AudioSource(
12242        item_id=item_id,
12243        provider=provider,
12244        name=f"Live source ({item_id})",
12245        provider_mappings={
12246            ProviderMapping(item_id=item_id, provider_domain=provider, provider_instance=provider)
12247        },
12248    )
12249
12250
12251class TestCanGroupWithExternalSource:
12252    """Grouping candidates while something other than the player's queue is playing."""
12253
12254    @staticmethod
12255    def _build_rig(mock_mass: MagicMock) -> tuple[PlayerController, MockPlayer]:
12256        """
12257        Wire a player with one native peer and one reachable only over AirPlay.
12258
12259        Returns the controller and the player whose grouping candidates are asserted on.
12260        """
12261        controller = PlayerController(mock_mass)
12262        sonos_provider = MockProvider("sonos", instance_id="sonos_instance", mass=mock_mass)
12263        airplay_provider = MockProvider("airplay", instance_id="airplay_instance", mass=mock_mass)
12264
12265        sonos_player = MockPlayer(
12266            sonos_provider,
12267            "sonos_123",
12268            "Living Room",
12269            identifiers={IdentifierType.MAC_ADDRESS: "AA:BB:CC:DD:EE:01"},
12270        )
12271        sonos_player._attr_supported_features.add(PlayerFeature.PLAY_MEDIA)
12272        sonos_player._attr_supported_features.add(PlayerFeature.SET_MEMBERS)
12273        sonos_player._attr_can_group_with = {"sonos_456"}
12274        sonos_player_b = MockPlayer(
12275            sonos_provider,
12276            "sonos_456",
12277            "Kitchen",
12278            identifiers={IdentifierType.MAC_ADDRESS: "AA:BB:CC:DD:EE:02"},
12279        )
12280        sonos_airplay = MockPlayer(
12281            airplay_provider,
12282            "airplay_sonos",
12283            "Living Room (AirPlay)",
12284            player_type=PlayerType.PROTOCOL,
12285            identifiers={IdentifierType.MAC_ADDRESS: "AA:BB:CC:DD:EE:01"},
12286        )
12287        sonos_airplay._attr_supported_features.add(PlayerFeature.SET_MEMBERS)
12288        sonos_airplay._attr_can_group_with = {"airplay_other"}
12289        sonos_airplay.set_protocol_parent_id("sonos_123")
12290
12291        wiim_player = MockPlayer(
12292            sonos_provider,
12293            "wiim_789",
12294            "Bedroom",
12295            identifiers={IdentifierType.MAC_ADDRESS: "AA:BB:CC:DD:EE:03"},
12296        )
12297        airplay_other = MockPlayer(
12298            airplay_provider,
12299            "airplay_other",
12300            "Bedroom (AirPlay)",
12301            player_type=PlayerType.PROTOCOL,
12302            identifiers={IdentifierType.MAC_ADDRESS: "AA:BB:CC:DD:EE:03"},
12303        )
12304        airplay_other._attr_supported_features.add(PlayerFeature.SET_MEMBERS)
12305        airplay_other._attr_can_group_with = {"airplay_sonos"}
12306        airplay_other.set_protocol_parent_id("wiim_789")
12307
12308        for player, protocol_id in (
12309            (sonos_player, "airplay_sonos"),
12310            (wiim_player, "airplay_other"),
12311        ):
12312            player.set_linked_output_protocols(
12313                [
12314                    LinkedOutputProtocol(
12315                        output_protocol_id=protocol_id,
12316                        protocol_domain="airplay",
12317                        priority=10,
12318                    )
12319                ]
12320            )
12321
12322        mock_mass.players = controller
12323        # nothing here is a registered queue, so the external-source check is reachable
12324        mock_mass.player_queues.get = MagicMock(return_value=None)
12325        controller._players = {
12326            "sonos_123": sonos_player,
12327            "sonos_456": sonos_player_b,
12328            "wiim_789": wiim_player,
12329            "airplay_sonos": sonos_airplay,
12330            "airplay_other": airplay_other,
12331        }
12332        for registered in controller._players.values():
12333            registered._cache.clear()
12334        sonos_airplay.refresh_state(signal_event=False)
12335        airplay_other.refresh_state(signal_event=False)
12336        sonos_player.refresh_state(signal_event=False)
12337        sonos_player_b.refresh_state(signal_event=False)
12338        wiim_player.refresh_state(signal_event=False)
12339        return controller, sonos_player
12340
12341    def test_live_audio_source_keeps_protocol_candidates(self, mock_mass: MagicMock) -> None:
12342        """A source MA streams itself (e.g. Spotify Connect) can still be grouped."""
12343        controller, sonos_player = self._build_rig(mock_mass)
12344        controller._start_audio_source_session(
12345            "sonos_123", _live_source("sonos_123"), "spotify_connect"
12346        )
12347        sonos_player.refresh_state(signal_event=False)
12348
12349        assert sonos_player.state.active_source == "spotify_connect://audio_source/sonos_123"
12350        # MA produces the audio, so both the native peer and the one reachable
12351        # only over AirPlay can be added to it
12352        assert sonos_player.state.can_group_with == {"sonos_456", "wiim_789"}
12353
12354    def test_device_own_external_source_hides_protocol_candidates(
12355        self, mock_mass: MagicMock
12356    ) -> None:
12357        """The device can still group its own line-in natively, just not over a protocol."""
12358        controller, sonos_player = self._build_rig(mock_mass)
12359        # a live source elsewhere says nothing about what this player is playing
12360        controller._start_audio_source_session("wiim_789", _live_source("wiim_789"), "vban")
12361        sonos_player._attr_active_source = "tv"
12362        sonos_player._attr_playback_state = PlaybackState.PLAYING
12363        sonos_player.refresh_state(signal_event=False)
12364
12365        assert sonos_player.state.active_source == "tv"
12366        # the device distributes its own input to its own kind, but MA cannot put
12367        # audio it never receives onto an AirPlay/Sendspin protocol player
12368        assert sonos_player.state.can_group_with == {"sonos_456"}
12369
12370
12371class TestExternalSourceTakeover:
12372    """Whether a source change while a protocol renders the audio counts as a takeover."""
12373
12374    @staticmethod
12375    def _playing_over_airplay(
12376        mock_mass: MagicMock,
12377    ) -> tuple[PlayerController, MockPlayer]:
12378        """Wire a player rendering through its AirPlay protocol player."""
12379        controller, sonos_player = TestCanGroupWithExternalSource._build_rig(mock_mass)
12380        sonos_player._attr_playback_state = PlaybackState.PLAYING
12381        sonos_player.set_active_output_protocol("airplay_sonos")
12382        sonos_player.refresh_state(signal_event=False)
12383        return controller, sonos_player
12384
12385    def test_live_audio_source_is_not_a_takeover(self, mock_mass: MagicMock) -> None:
12386        """MA putting a live source on the player must not tear down its protocol group."""
12387        controller, sonos_player = self._playing_over_airplay(mock_mass)
12388        controller._start_audio_source_session(
12389            "sonos_123", _live_source("sonos_123"), "spotify_connect"
12390        )
12391        sonos_player.refresh_state(signal_event=False)
12392
12393        controller._check_external_source_takeover(sonos_player)
12394
12395        assert sonos_player.active_output_protocol == "airplay_sonos"
12396
12397    def test_device_own_external_source_stays_external(self, mock_mass: MagicMock) -> None:
12398        """A source MA does not produce is still classified as external."""
12399        # checked on the classifier directly: while an output protocol renders the audio,
12400        # __final_active_source overrules what the device reports, so a device-local
12401        # source cannot be driven through _check_external_source_takeover at all
12402        controller, sonos_player = TestCanGroupWithExternalSource._build_rig(mock_mass)
12403
12404        assert not controller._is_ma_managed_source(sonos_player, "tv")
12405