/
/
/
1"""Tests for protocol player linking and universal player creation."""
2
3import logging
4from unittest.mock import MagicMock
5
6import pytest
7from music_assistant_models.enums import (
8 IdentifierType,
9 PlayerFeature,
10 PlayerType,
11)
12from music_assistant_models.player import OutputProtocol
13
14from music_assistant.controllers.players import PlayerController
15from music_assistant.helpers.throttle_retry import Throttler
16from music_assistant.models.player import DeviceInfo, Player
17from music_assistant.providers.universal_player.provider import UniversalPlayerProvider
18
19
20def create_mock_config(name: str) -> MagicMock:
21 """Create a mock player config with the given name."""
22 config = MagicMock()
23 config.name = None # No custom name, use default
24 config.default_name = name
25 return config
26
27
28def create_mock_universal_provider(mock_mass: MagicMock) -> UniversalPlayerProvider:
29 """Create a mock UniversalPlayerProvider for testing."""
30 # Create a mock manifest
31 manifest = MagicMock()
32 manifest.domain = "universal_player"
33 manifest.name = "Universal Player"
34
35 # Create provider with the mock manifest
36 provider = UniversalPlayerProvider.__new__(UniversalPlayerProvider)
37 provider.mass = mock_mass
38 provider.manifest = manifest
39 provider.logger = logging.getLogger("test.universal_player")
40 return provider
41
42
43class MockProvider:
44 """Mock player provider for testing."""
45
46 def __init__(
47 self, domain: str, instance_id: str = "test_instance", mass: MagicMock | None = None
48 ) -> None:
49 """Initialize the mock provider."""
50 self.domain = domain
51 self.instance_id = instance_id
52 self.name = f"Mock {domain.title()}"
53 self.manifest = MagicMock()
54 self.manifest.name = f"Mock {domain} Provider"
55 self.mass = mass or MagicMock()
56 self.logger = logging.getLogger(f"test.{domain}")
57
58
59class MockPlayer(Player):
60 """Mock player for testing."""
61
62 def __init__(
63 self,
64 provider: MockProvider,
65 player_id: str,
66 name: str,
67 player_type: PlayerType = PlayerType.PLAYER,
68 identifiers: dict[IdentifierType, str] | None = None,
69 ) -> None:
70 """Initialize the mock player."""
71 # Set up the mock config before calling super().__init__
72 # because the parent __init__ accesses config
73 provider.mass.config.get_base_player_config.return_value = create_mock_config(name)
74
75 super().__init__(provider, player_id) # type: ignore[arg-type]
76 self._attr_name = name
77 # Set type as instance attribute (overrides class attribute)
78 self._attr_type = player_type
79 self._attr_available = True
80 self._attr_powered = True
81 self._attr_supported_features = {PlayerFeature.VOLUME_SET}
82 self._attr_can_group_with = set()
83
84 # Set up device info with identifiers
85 self._attr_device_info = DeviceInfo(
86 model="Test Model",
87 manufacturer="Test Manufacturer",
88 )
89 if identifiers:
90 for conn_type, value in identifiers.items():
91 self._attr_device_info.add_identifier(conn_type, value)
92
93 # Clear cached properties after modifying attributes
94 self._cache.clear()
95
96 # Update state to reflect the modified attributes
97 self.update_state(signal_event=False)
98
99 async def stop(self) -> None:
100 """Stop playback - required abstract method."""
101
102
103@pytest.fixture
104def mock_mass() -> MagicMock:
105 """Create a mock MusicAssistant instance."""
106 mass = MagicMock()
107 mass.closing = False
108 mass.config = MagicMock()
109 mass.config.get = MagicMock(return_value=[])
110 mass.config.get_raw_player_config_value = MagicMock(return_value="auto")
111 # Return "GLOBAL" for log level config (standard default)
112 mass.config.get_raw_core_config_value = MagicMock(return_value="GLOBAL")
113 mass.config.set = MagicMock()
114 mass.signal_event = MagicMock()
115 mass.get_providers = MagicMock(return_value=[])
116 return mass
117
118
119class TestIdentifiersMatch:
120 """Tests for identifier matching logic."""
121
122 def test_mac_address_match(self, mock_mass: MagicMock) -> None:
123 """Test that MAC addresses match correctly."""
124 controller = PlayerController(mock_mass)
125
126 provider = MockProvider("test")
127 player_a = MockPlayer(
128 provider,
129 "player_a",
130 "Player A",
131 identifiers={IdentifierType.MAC_ADDRESS: "AA:BB:CC:DD:EE:FF"},
132 )
133 player_b = MockPlayer(
134 provider,
135 "player_b",
136 "Player B",
137 identifiers={IdentifierType.MAC_ADDRESS: "aa:bb:cc:dd:ee:ff"}, # lowercase
138 )
139
140 assert controller._identifiers_match(player_a, player_b) is True
141
142 def test_mac_address_no_match(self, mock_mass: MagicMock) -> None:
143 """Test that different MAC addresses don't match."""
144 controller = PlayerController(mock_mass)
145
146 provider = MockProvider("test")
147 player_a = MockPlayer(
148 provider,
149 "player_a",
150 "Player A",
151 identifiers={IdentifierType.MAC_ADDRESS: "AA:BB:CC:DD:EE:FF"},
152 )
153 player_b = MockPlayer(
154 provider,
155 "player_b",
156 "Player B",
157 identifiers={IdentifierType.MAC_ADDRESS: "11:22:33:44:55:66"},
158 )
159
160 assert controller._identifiers_match(player_a, player_b) is False
161
162 def test_ip_address_no_match(self, mock_mass: MagicMock) -> None:
163 """Test that IP addresses don't match (IP is excluded as it's not stable)."""
164 controller = PlayerController(mock_mass)
165
166 provider = MockProvider("test")
167 player_a = MockPlayer(
168 provider,
169 "player_a",
170 "Player A",
171 identifiers={IdentifierType.IP_ADDRESS: "192.168.1.100"},
172 )
173 player_b = MockPlayer(
174 provider,
175 "player_b",
176 "Player B",
177 identifiers={IdentifierType.IP_ADDRESS: "192.168.1.100"},
178 )
179
180 # IP address matching is intentionally disabled to prevent false matches
181 assert controller._identifiers_match(player_a, player_b) is False
182
183 def test_sonos_uuid_dlna_suffix_match(self, mock_mass: MagicMock) -> None:
184 """Test Sonos UUID matching with DLNA _MR suffix."""
185 controller = PlayerController(mock_mass)
186
187 provider = MockProvider("test")
188 # Sonos native player
189 player_a = MockPlayer(
190 provider,
191 "player_a",
192 "Sonos Player",
193 identifiers={IdentifierType.UUID: "RINCON_000E58123456"},
194 )
195 # DLNA player with _MR suffix
196 player_b = MockPlayer(
197 provider,
198 "player_b",
199 "DLNA Player",
200 identifiers={IdentifierType.UUID: "RINCON_000E58123456_MR"},
201 )
202
203 assert controller._identifiers_match(player_a, player_b) is True
204
205 def test_no_identifiers_no_match(self, mock_mass: MagicMock) -> None:
206 """Test that players without identifiers don't match."""
207 controller = PlayerController(mock_mass)
208
209 provider = MockProvider("test")
210 player_a = MockPlayer(provider, "player_a", "Player A")
211 player_b = MockPlayer(provider, "player_b", "Player B")
212
213 assert controller._identifiers_match(player_a, player_b) is False
214
215
216class TestProtocolPlayerDetection:
217 """Tests for protocol player type detection."""
218
219 def test_is_protocol_player_true(self, mock_mass: MagicMock) -> None:
220 """Test that PlayerType.PROTOCOL is correctly detected."""
221 controller = PlayerController(mock_mass)
222
223 provider = MockProvider("airplay")
224 player = MockPlayer(
225 provider,
226 "ap_123456",
227 "Samsung TV (AirPlay)",
228 player_type=PlayerType.PROTOCOL,
229 )
230
231 assert controller._is_protocol_player(player) is True
232
233 def test_is_protocol_player_false(self, mock_mass: MagicMock) -> None:
234 """Test that PlayerType.PLAYER is not detected as protocol."""
235 controller = PlayerController(mock_mass)
236
237 provider = MockProvider("airplay")
238 player = MockPlayer(
239 provider,
240 "ap_123456",
241 "HomePod",
242 player_type=PlayerType.PLAYER, # Apple device with native support
243 )
244
245 assert controller._is_protocol_player(player) is False
246
247
248class TestFindMatchingProtocolPlayers:
249 """Tests for finding matching protocol players."""
250
251 def test_find_matching_by_mac(self, mock_mass: MagicMock) -> None:
252 """Test finding matching protocol players by MAC address."""
253 controller = PlayerController(mock_mass)
254
255 # Set up providers
256 airplay_provider = MockProvider("airplay")
257 chromecast_provider = MockProvider("chromecast")
258
259 # Create matching protocol players (same device, different protocols)
260 airplay_player = MockPlayer(
261 airplay_provider,
262 "ap_aabbccddee",
263 "Samsung TV (AirPlay)",
264 player_type=PlayerType.PROTOCOL,
265 identifiers={IdentifierType.MAC_ADDRESS: "AA:BB:CC:DD:EE:FF"},
266 )
267 chromecast_player = MockPlayer(
268 chromecast_provider,
269 "cc_aabbccddee",
270 "Samsung TV (Chromecast)",
271 player_type=PlayerType.PROTOCOL,
272 identifiers={IdentifierType.MAC_ADDRESS: "AA:BB:CC:DD:EE:FF"},
273 )
274
275 # Register players
276 controller._players = {
277 "ap_aabbccddee": airplay_player,
278 "cc_aabbccddee": chromecast_player,
279 }
280 controller._player_throttlers = {
281 "ap_aabbccddee": Throttler(1, 0.05),
282 "cc_aabbccddee": Throttler(1, 0.05),
283 }
284
285 # Find matching players for AirPlay player
286 matches = controller._find_matching_protocol_players(airplay_player)
287
288 assert len(matches) == 2
289 assert airplay_player in matches
290 assert chromecast_player in matches
291
292 def test_same_protocol_not_matched(self, mock_mass: MagicMock) -> None:
293 """Test that multiple players of same protocol on same host are NOT matched together."""
294 controller = PlayerController(mock_mass)
295
296 # Set up provider
297 snapcast_provider = MockProvider("snapcast")
298
299 # Create multiple Snapcast players on same host (same MAC/IP)
300 # This simulates multiple Snapcast clients running on the same server
301 snapcast_player_1 = MockPlayer(
302 snapcast_provider,
303 "snapcast_client_1",
304 "Snapcast Client 1",
305 player_type=PlayerType.PROTOCOL,
306 identifiers={
307 IdentifierType.MAC_ADDRESS: "AA:BB:CC:DD:EE:FF",
308 IdentifierType.IP_ADDRESS: "192.168.1.100",
309 },
310 )
311 snapcast_player_2 = MockPlayer(
312 snapcast_provider,
313 "snapcast_client_2",
314 "Snapcast Client 2",
315 player_type=PlayerType.PROTOCOL,
316 identifiers={
317 IdentifierType.MAC_ADDRESS: "AA:BB:CC:DD:EE:FF",
318 IdentifierType.IP_ADDRESS: "192.168.1.100",
319 },
320 )
321
322 # Register players
323 controller._players = {
324 "snapcast_client_1": snapcast_player_1,
325 "snapcast_client_2": snapcast_player_2,
326 }
327 controller._player_throttlers = {
328 "snapcast_client_1": Throttler(1, 0.05),
329 "snapcast_client_2": Throttler(1, 0.05),
330 }
331
332 # Find matching players for first Snapcast player
333 matches = controller._find_matching_protocol_players(snapcast_player_1)
334
335 # Should only match itself, NOT the other Snapcast player (same protocol domain)
336 assert len(matches) == 1
337 assert snapcast_player_1 in matches
338 assert snapcast_player_2 not in matches
339
340
341class TestGetDeviceKeyFromPlayers:
342 """Tests for device key generation."""
343
344 def test_device_key_from_mac(self, mock_mass: MagicMock) -> None:
345 """Test device key generation from MAC address."""
346 universal_provider = create_mock_universal_provider(mock_mass)
347
348 provider = MockProvider("airplay")
349 player = MockPlayer(
350 provider,
351 "ap_123456",
352 "Test Player",
353 identifiers={IdentifierType.MAC_ADDRESS: "AA:BB:CC:DD:EE:FF"},
354 )
355
356 device_key = universal_provider._get_device_key_from_players([player])
357
358 assert device_key == "aabbccddeeff"
359
360 def test_device_key_from_uuid_fallback(self, mock_mass: MagicMock) -> None:
361 """Test device key generation falls back to UUID when no MAC available."""
362 universal_provider = create_mock_universal_provider(mock_mass)
363
364 provider = MockProvider("dlna")
365 player = MockPlayer(
366 provider,
367 "dlna_123456",
368 "Test Player",
369 identifiers={IdentifierType.UUID: "uuid:12345678-1234-1234-1234-123456789abc"},
370 )
371
372 device_key = universal_provider._get_device_key_from_players([player])
373
374 assert device_key == "uuid12345678123412341234123456789abc"
375
376 def test_device_key_from_ip_falls_back_to_player_id(self, mock_mass: MagicMock) -> None:
377 """Test that device key falls back to player_id for IP-only players (IP not used)."""
378 universal_provider = create_mock_universal_provider(mock_mass)
379
380 provider = MockProvider("airplay")
381 player = MockPlayer(
382 provider,
383 "ap_123456",
384 "Test Player",
385 identifiers={IdentifierType.IP_ADDRESS: "192.168.1.100"},
386 )
387
388 device_key = universal_provider._get_device_key_from_players([player])
389
390 # IP address is not used for device key - falls back to player_id
391 # This allows protocol players without MAC/UUID to still get a UniversalPlayer
392 assert device_key == "ap_123456"
393
394 def test_device_key_from_no_identifiers_falls_back_to_player_id(
395 self, mock_mass: MagicMock
396 ) -> None:
397 """Test that device key falls back to player_id when no identifiers at all."""
398 universal_provider = create_mock_universal_provider(mock_mass)
399
400 provider = MockProvider("sendspin")
401 player = MockPlayer(
402 provider,
403 "sendspin-device-abc",
404 "Test Player",
405 # No identifiers at all (like Sendspin protocol players)
406 )
407
408 device_key = universal_provider._get_device_key_from_players([player])
409
410 # Falls back to player_id when no MAC/UUID identifiers
411 assert device_key == "sendspindeviceabc"
412
413
414class TestGetCleanPlayerName:
415 """Tests for player name selection."""
416
417 def test_prefers_chromecast_name(self, mock_mass: MagicMock) -> None:
418 """Test that Chromecast names are preferred over other protocols."""
419 universal_provider = create_mock_universal_provider(mock_mass)
420
421 airplay_provider = MockProvider("airplay")
422 chromecast_provider = MockProvider("chromecast")
423
424 airplay_player = MockPlayer(
425 airplay_provider,
426 "ap_123456",
427 "Samsung TV",
428 player_type=PlayerType.PROTOCOL,
429 )
430 chromecast_player = MockPlayer(
431 chromecast_provider,
432 "cc_123456",
433 "Living Room Speaker",
434 player_type=PlayerType.PROTOCOL,
435 )
436
437 # Chromecast should be preferred (priority 1)
438 clean_name = universal_provider._get_clean_player_name([airplay_player, chromecast_player])
439 assert clean_name == "Living Room Speaker"
440
441 def test_filters_mac_address_names(self, mock_mass: MagicMock) -> None:
442 """Test that MAC address-like names are filtered out."""
443 universal_provider = create_mock_universal_provider(mock_mass)
444
445 squeezelite_provider = MockProvider("squeezelite")
446 airplay_provider = MockProvider("airplay")
447
448 # Squeezelite with MAC address as name
449 sq_player = MockPlayer(
450 squeezelite_provider,
451 "sq_123456",
452 "AA:BB:CC:DD:EE:FF",
453 player_type=PlayerType.PROTOCOL,
454 )
455 # AirPlay with proper name
456 ap_player = MockPlayer(
457 airplay_provider,
458 "ap_123456",
459 "Kitchen Speaker",
460 player_type=PlayerType.PROTOCOL,
461 )
462
463 # Should prefer Kitchen Speaker over MAC address
464 clean_name = universal_provider._get_clean_player_name([sq_player, ap_player])
465 assert clean_name == "Kitchen Speaker"
466
467 def test_filters_player_id_names(self, mock_mass: MagicMock) -> None:
468 """Test that player ID-like names are filtered out."""
469 universal_provider = create_mock_universal_provider(mock_mass)
470
471 sendspin_provider = MockProvider("sendspin")
472 dlna_provider = MockProvider("dlna")
473
474 # SendSpin with player ID as name
475 ss_player = MockPlayer(
476 sendspin_provider,
477 "sendspin_123456",
478 "sendspin_device_abc",
479 player_type=PlayerType.PROTOCOL,
480 )
481 # DLNA with proper name
482 dlna_player = MockPlayer(
483 dlna_provider,
484 "dlna_123456",
485 "Bedroom TV",
486 player_type=PlayerType.PROTOCOL,
487 )
488
489 # Should prefer Bedroom TV over player ID
490 clean_name = universal_provider._get_clean_player_name([ss_player, dlna_player])
491 assert clean_name == "Bedroom TV"
492
493 def test_valid_name_unchanged(self, mock_mass: MagicMock) -> None:
494 """Test that valid names are returned unchanged."""
495 universal_provider = create_mock_universal_provider(mock_mass)
496
497 provider = MockProvider("airplay")
498 player = MockPlayer(
499 provider,
500 "ap_123456",
501 "HomePod Mini",
502 player_type=PlayerType.PLAYER,
503 )
504
505 clean_name = universal_provider._get_clean_player_name([player])
506 assert clean_name == "HomePod Mini"
507
508
509class TestCachedProtocolParentRestore:
510 """Tests for restoring cached protocol parent links."""
511
512 def test_protocol_parent_id_restored_from_config(self, mock_mass: MagicMock) -> None:
513 """Test that cached protocol_parent_id is loaded and used for immediate linking."""
514 controller = PlayerController(mock_mass)
515
516 # Mock config to return cached parent_id when queried
517 def mock_config_get(key: str, default: str | None = None) -> str | None:
518 if "protocol_parent_id" in str(key):
519 return "native_player_id"
520 return default
521
522 mock_mass.config.get.side_effect = mock_config_get
523
524 # Create native player
525 native_provider = MockProvider("sonos", mass=mock_mass)
526 native_player = MockPlayer(
527 native_provider,
528 "native_player_id",
529 "Sonos Speaker",
530 identifiers={IdentifierType.MAC_ADDRESS: "AA:BB:CC:DD:EE:FF"},
531 )
532
533 # Create protocol player
534 dlna_provider = MockProvider("dlna", mass=mock_mass)
535 protocol_player = MockPlayer(
536 dlna_provider,
537 "uuid:RINCON_AABBCCDDEEFF_MR",
538 "Sonos DLNA",
539 player_type=PlayerType.PROTOCOL,
540 )
541
542 # Register native player
543 controller._players = {"native_player_id": native_player}
544 controller._player_throttlers = {"native_player_id": Throttler(1, 0.05)}
545
546 # Try to link protocol to native - should load cached parent_id
547 controller._try_link_protocol_to_native(protocol_player)
548
549 # Verify protocol_parent_id was set
550 assert protocol_player.protocol_parent_id == "native_player_id"
551
552 # Verify protocol was linked to native player
553 assert any(
554 link.output_protocol_id == protocol_player.player_id
555 for link in native_player.linked_output_protocols
556 )
557
558 def test_protocol_parent_id_prevents_universal_player_creation(
559 self, mock_mass: MagicMock
560 ) -> None:
561 """Test that cached protocol_parent_id prevents creating universal player."""
562 controller = PlayerController(mock_mass)
563
564 # Mock config to return cached parent_id (parent not yet registered)
565 def mock_config_get(key: str, default: str | None = None) -> str | None:
566 if "protocol_parent_id" in str(key):
567 return "native_player_id"
568 return default
569
570 mock_mass.config.get.side_effect = mock_config_get
571
572 # Create protocol player
573 dlna_provider = MockProvider("dlna", mass=mock_mass)
574 protocol_player = MockPlayer(
575 dlna_provider,
576 "uuid:RINCON_AABBCCDDEEFF_MR",
577 "Sonos DLNA",
578 player_type=PlayerType.PROTOCOL,
579 )
580
581 # No native player registered yet
582 controller._players = {}
583
584 # Try to link protocol - should set parent_id and skip evaluation
585 controller._try_link_protocol_to_native(protocol_player)
586
587 # Verify protocol_parent_id was set
588 assert protocol_player.protocol_parent_id == "native_player_id"
589
590 # Since parent_id is set, delayed evaluation won't create a universal player
591
592
593class TestSelectBestOutputProtocol:
594 """Tests for output protocol selection logic."""
595
596 def test_select_native_when_preferred_is_native(self, mock_mass: MagicMock) -> None:
597 """Test that native protocol is selected when user prefers native."""
598 # Mock config to return "native" as preferred
599 mock_mass.config.get_raw_player_config_value = MagicMock(return_value="native")
600
601 controller = PlayerController(mock_mass)
602 provider = MockProvider("sonos", mass=mock_mass)
603
604 # Create native player with PLAY_MEDIA support
605 native_player = MockPlayer(
606 provider,
607 "sonos_123",
608 "Kantoor",
609 identifiers={IdentifierType.MAC_ADDRESS: "AA:BB:CC:DD:EE:FF"},
610 )
611 native_player._attr_supported_features.add(PlayerFeature.PLAY_MEDIA)
612
613 # Wire up mock_mass.players to controller
614 mock_mass.players = controller
615
616 # Register players
617 controller._players = {"sonos_123": native_player}
618 controller._player_throttlers = {"sonos_123": Throttler(1, 0.05)}
619
620 # Select protocol
621 selected_player, output_protocol = controller._select_best_output_protocol(native_player)
622
623 # Should select native player
624 assert selected_player == native_player
625 assert output_protocol is None # None means native playback
626
627 def test_select_dlna_when_preferred_is_dlna(self, mock_mass: MagicMock) -> None:
628 """Test that DLNA protocol is selected when user prefers DLNA."""
629 # Mock config to return the full player ID as preferred
630 mock_mass.config.get_raw_player_config_value = MagicMock(return_value="dlna_AABBCCDDEEFF")
631
632 controller = PlayerController(mock_mass)
633
634 # Create native player with linked protocols
635 sonos_provider = MockProvider("sonos", mass=mock_mass)
636 native_player = MockPlayer(
637 sonos_provider,
638 "sonos_123",
639 "Kantoor",
640 identifiers={IdentifierType.MAC_ADDRESS: "AA:BB:CC:DD:EE:FF"},
641 )
642 native_player._attr_supported_features.add(PlayerFeature.PLAY_MEDIA)
643
644 # Create DLNA protocol player
645 dlna_provider = MockProvider("dlna", mass=mock_mass)
646 dlna_player = MockPlayer(
647 dlna_provider,
648 "dlna_AABBCCDDEEFF",
649 "Kantoor DLNA",
650 player_type=PlayerType.PROTOCOL,
651 identifiers={IdentifierType.MAC_ADDRESS: "AA:BB:CC:DD:EE:FF"},
652 )
653
654 # Register players
655 controller._players = {
656 "sonos_123": native_player,
657 "dlna_AABBCCDDEEFF": dlna_player,
658 }
659 controller._player_throttlers = {
660 "sonos_123": Throttler(1, 0.05),
661 "dlna_AABBCCDDEEFF": Throttler(1, 0.05),
662 }
663
664 # Link DLNA protocol to native player
665 native_player.set_linked_output_protocols(
666 [
667 OutputProtocol(
668 output_protocol_id="dlna_AABBCCDDEEFF",
669 name="DLNA",
670 protocol_domain="dlna",
671 priority=30,
672 )
673 ]
674 )
675
676 # Select protocol
677 selected_player, output_protocol = controller._select_best_output_protocol(native_player)
678
679 # Should select DLNA player, not native
680 assert selected_player == dlna_player
681 assert output_protocol is not None
682 assert output_protocol.output_protocol_id == "dlna_AABBCCDDEEFF"
683
684 def test_select_airplay_when_preferred_is_airplay(self, mock_mass: MagicMock) -> None:
685 """Test that AirPlay protocol is selected when user prefers AirPlay."""
686 # Mock config to return the full player ID as preferred
687 mock_mass.config.get_raw_player_config_value = MagicMock(
688 return_value="airplay_AABBCCDDEEFF"
689 )
690
691 controller = PlayerController(mock_mass)
692
693 # Create native player
694 sonos_provider = MockProvider("sonos", mass=mock_mass)
695 native_player = MockPlayer(
696 sonos_provider,
697 "sonos_123",
698 "Kantoor",
699 identifiers={IdentifierType.MAC_ADDRESS: "AA:BB:CC:DD:EE:FF"},
700 )
701 native_player._attr_supported_features.add(PlayerFeature.PLAY_MEDIA)
702
703 # Create AirPlay and DLNA protocol players
704 airplay_provider = MockProvider("airplay", mass=mock_mass)
705 airplay_player = MockPlayer(
706 airplay_provider,
707 "airplay_AABBCCDDEEFF",
708 "Kantoor AirPlay",
709 player_type=PlayerType.PROTOCOL,
710 identifiers={IdentifierType.MAC_ADDRESS: "AA:BB:CC:DD:EE:FF"},
711 )
712
713 dlna_provider = MockProvider("dlna", mass=mock_mass)
714 dlna_player = MockPlayer(
715 dlna_provider,
716 "dlna_AABBCCDDEEFF",
717 "Kantoor DLNA",
718 player_type=PlayerType.PROTOCOL,
719 identifiers={IdentifierType.MAC_ADDRESS: "AA:BB:CC:DD:EE:FF"},
720 )
721
722 # Register players
723 controller._players = {
724 "sonos_123": native_player,
725 "airplay_AABBCCDDEEFF": airplay_player,
726 "dlna_AABBCCDDEEFF": dlna_player,
727 }
728 controller._player_throttlers = {
729 "sonos_123": Throttler(1, 0.05),
730 "airplay_AABBCCDDEEFF": Throttler(1, 0.05),
731 "dlna_AABBCCDDEEFF": Throttler(1, 0.05),
732 }
733
734 # Link protocols to native player
735 native_player.set_linked_output_protocols(
736 [
737 OutputProtocol(
738 output_protocol_id="airplay_AABBCCDDEEFF",
739 name="AirPlay",
740 protocol_domain="airplay",
741 priority=10,
742 ),
743 OutputProtocol(
744 output_protocol_id="dlna_AABBCCDDEEFF",
745 name="DLNA",
746 protocol_domain="dlna",
747 priority=30,
748 ),
749 ]
750 )
751
752 # Select protocol
753 selected_player, output_protocol = controller._select_best_output_protocol(native_player)
754
755 # Should select AirPlay player (even though DLNA has lower priority value),
756 # because user preference overrides priority
757 assert selected_player == airplay_player
758 assert output_protocol is not None
759 assert output_protocol.output_protocol_id == "airplay_AABBCCDDEEFF"
760
761 def test_fallback_to_native_when_auto(self, mock_mass: MagicMock) -> None:
762 """Test that native playback is used when preference is auto."""
763 # Mock config to return "auto" as preferred
764 mock_mass.config.get_raw_player_config_value = MagicMock(return_value="auto")
765
766 controller = PlayerController(mock_mass)
767 provider = MockProvider("sonos", mass=mock_mass)
768
769 native_player = MockPlayer(
770 provider,
771 "sonos_123",
772 "Kantoor",
773 identifiers={IdentifierType.MAC_ADDRESS: "AA:BB:CC:DD:EE:FF"},
774 )
775 native_player._attr_supported_features.add(PlayerFeature.PLAY_MEDIA)
776
777 controller._players = {"sonos_123": native_player}
778 controller._player_throttlers = {"sonos_123": Throttler(1, 0.05)}
779
780 # Select protocol with auto preference
781 selected_player, output_protocol = controller._select_best_output_protocol(native_player)
782
783 # Should select native player
784 assert selected_player == native_player
785 assert output_protocol is None # None means native playback
786
787
788class TestPlayerGrouping:
789 """Tests for player grouping scenarios."""
790
791 def test_native_to_native_grouping(self, mock_mass: MagicMock) -> None:
792 """Test that native players from same provider can group together."""
793 controller = PlayerController(mock_mass)
794
795 sonos_provider = MockProvider("sonos", mass=mock_mass)
796
797 # Create two Sonos players
798 player_a = MockPlayer(
799 sonos_provider,
800 "sonos_123",
801 "Living Room",
802 identifiers={IdentifierType.MAC_ADDRESS: "AA:BB:CC:DD:EE:01"},
803 )
804 player_a._attr_supported_features.add(PlayerFeature.SET_MEMBERS)
805 player_a._attr_supported_features.add(PlayerFeature.PLAY_MEDIA)
806 player_a._attr_can_group_with = {"sonos_456"}
807 player_a._cache.clear() # Clear cached properties after modifying attributes
808
809 player_b = MockPlayer(
810 sonos_provider,
811 "sonos_456",
812 "Kitchen",
813 identifiers={IdentifierType.MAC_ADDRESS: "AA:BB:CC:DD:EE:02"},
814 )
815 player_b._attr_supported_features.add(PlayerFeature.PLAY_MEDIA)
816 player_b._cache.clear()
817
818 controller._players = {
819 "sonos_123": player_a,
820 "sonos_456": player_b,
821 }
822 controller._player_throttlers = {
823 "sonos_123": Throttler(1, 0.05),
824 "sonos_456": Throttler(1, 0.05),
825 }
826
827 # Translate members for native grouping
828 protocol_members, native_members, _, _ = controller._translate_members_for_protocols(
829 parent_player=player_a,
830 player_ids=["sonos_456"],
831 parent_protocol_player=None,
832 parent_protocol_domain=None,
833 )
834
835 # Should use native grouping (same provider)
836 assert len(native_members) == 1
837 assert "sonos_456" in native_members
838 assert len(protocol_members) == 0
839
840 def test_protocol_to_protocol_grouping(self, mock_mass: MagicMock) -> None:
841 """Test that protocol players can group via shared protocol."""
842 controller = PlayerController(mock_mass)
843
844 # Create two players with AirPlay protocol support
845 sonos_provider = MockProvider("sonos", mass=mock_mass)
846 wiim_provider = MockProvider("wiim", mass=mock_mass)
847 airplay_provider = MockProvider("airplay", mass=mock_mass)
848
849 # Sonos player
850 sonos_player = MockPlayer(
851 sonos_provider,
852 "sonos_123",
853 "Living Room",
854 identifiers={IdentifierType.MAC_ADDRESS: "AA:BB:CC:DD:EE:01"},
855 )
856 sonos_player._attr_supported_features.add(PlayerFeature.PLAY_MEDIA)
857 sonos_player._cache.clear()
858
859 # WiiM player
860 wiim_player = MockPlayer(
861 wiim_provider,
862 "wiim_456",
863 "Bedroom",
864 identifiers={IdentifierType.MAC_ADDRESS: "AA:BB:CC:DD:EE:02"},
865 )
866 wiim_player._attr_supported_features.add(PlayerFeature.PLAY_MEDIA)
867 wiim_player._cache.clear()
868
869 # AirPlay protocol players
870 sonos_airplay = MockPlayer(
871 airplay_provider,
872 "airplay_sonos",
873 "Living Room (AirPlay)",
874 player_type=PlayerType.PROTOCOL,
875 identifiers={IdentifierType.MAC_ADDRESS: "AA:BB:CC:DD:EE:01"},
876 )
877 sonos_airplay._attr_supported_features.add(PlayerFeature.SET_MEMBERS)
878 sonos_airplay._attr_can_group_with = {"airplay_wiim"}
879 sonos_airplay._cache.clear()
880 sonos_airplay.update_state(signal_event=False)
881
882 wiim_airplay = MockPlayer(
883 airplay_provider,
884 "airplay_wiim",
885 "Bedroom (AirPlay)",
886 player_type=PlayerType.PROTOCOL,
887 identifiers={IdentifierType.MAC_ADDRESS: "AA:BB:CC:DD:EE:02"},
888 )
889
890 # Link protocol players to native players
891 sonos_player.set_linked_output_protocols(
892 [
893 OutputProtocol(
894 output_protocol_id="airplay_sonos",
895 name="AirPlay",
896 protocol_domain="airplay",
897 priority=10,
898 available=True,
899 )
900 ]
901 )
902 wiim_player.set_linked_output_protocols(
903 [
904 OutputProtocol(
905 output_protocol_id="airplay_wiim",
906 name="AirPlay",
907 protocol_domain="airplay",
908 priority=10,
909 available=True,
910 )
911 ]
912 )
913
914 controller._players = {
915 "sonos_123": sonos_player,
916 "wiim_456": wiim_player,
917 "airplay_sonos": sonos_airplay,
918 "airplay_wiim": wiim_airplay,
919 }
920 controller._player_throttlers = {
921 "sonos_123": Throttler(1, 0.05),
922 "wiim_456": Throttler(1, 0.05),
923 "airplay_sonos": Throttler(1, 0.05),
924 "airplay_wiim": Throttler(1, 0.05),
925 }
926
927 # Translate members for protocol grouping (via AirPlay)
928 protocol_members, native_members, protocol_player, _ = (
929 controller._translate_members_for_protocols(
930 parent_player=sonos_player,
931 player_ids=["wiim_456"],
932 parent_protocol_player=sonos_airplay,
933 parent_protocol_domain="airplay",
934 )
935 )
936
937 # Should use protocol grouping (AirPlay)
938 assert len(protocol_members) == 1
939 assert "airplay_wiim" in protocol_members
940 assert len(native_members) == 0
941 assert protocol_player == sonos_airplay
942
943 def test_hybrid_grouping(self, mock_mass: MagicMock) -> None:
944 """Test hybrid grouping: native + protocol players in same group."""
945 controller = PlayerController(mock_mass)
946
947 # Create Sonos players (native grouping capability)
948 sonos_provider = MockProvider("sonos", instance_id="sonos_instance", mass=mock_mass)
949 sonos_a = MockPlayer(
950 sonos_provider,
951 "sonos_123",
952 "Living Room",
953 identifiers={IdentifierType.MAC_ADDRESS: "AA:BB:CC:DD:EE:01"},
954 )
955 sonos_a._attr_supported_features.add(PlayerFeature.SET_MEMBERS)
956 sonos_a._attr_supported_features.add(PlayerFeature.PLAY_MEDIA)
957 sonos_a._attr_can_group_with = {"sonos_456"}
958 sonos_a._cache.clear()
959
960 sonos_b = MockPlayer(
961 sonos_provider,
962 "sonos_456",
963 "Kitchen",
964 identifiers={IdentifierType.MAC_ADDRESS: "AA:BB:CC:DD:EE:02"},
965 )
966 sonos_b._attr_supported_features.add(PlayerFeature.PLAY_MEDIA)
967 sonos_b._cache.clear()
968
969 # Create WiiM player with AirPlay protocol
970 wiim_provider = MockProvider("wiim", instance_id="wiim_instance", mass=mock_mass)
971 airplay_provider = MockProvider("airplay", instance_id="airplay_instance", mass=mock_mass)
972
973 wiim_player = MockPlayer(
974 wiim_provider,
975 "wiim_789",
976 "Bedroom",
977 identifiers={IdentifierType.MAC_ADDRESS: "AA:BB:CC:DD:EE:03"},
978 )
979 wiim_player._attr_supported_features.add(PlayerFeature.PLAY_MEDIA)
980 wiim_player._cache.clear()
981
982 # AirPlay protocol players
983 sonos_airplay = MockPlayer(
984 airplay_provider,
985 "airplay_sonos",
986 "Living Room (AirPlay)",
987 player_type=PlayerType.PROTOCOL,
988 identifiers={IdentifierType.MAC_ADDRESS: "AA:BB:CC:DD:EE:01"},
989 )
990 sonos_airplay._attr_supported_features.add(PlayerFeature.SET_MEMBERS)
991 sonos_airplay._attr_can_group_with = {"airplay_wiim"}
992 sonos_airplay._cache.clear()
993 sonos_airplay.update_state(signal_event=False)
994
995 wiim_airplay = MockPlayer(
996 airplay_provider,
997 "airplay_wiim",
998 "Bedroom (AirPlay)",
999 player_type=PlayerType.PROTOCOL,
1000 identifiers={IdentifierType.MAC_ADDRESS: "AA:BB:CC:DD:EE:03"},
1001 )
1002
1003 # Link AirPlay to Sonos A
1004 sonos_a.set_linked_output_protocols(
1005 [
1006 OutputProtocol(
1007 output_protocol_id="airplay_sonos",
1008 name="AirPlay",
1009 protocol_domain="airplay",
1010 priority=10,
1011 available=True,
1012 )
1013 ]
1014 )
1015 wiim_player.set_linked_output_protocols(
1016 [
1017 OutputProtocol(
1018 output_protocol_id="airplay_wiim",
1019 name="AirPlay",
1020 protocol_domain="airplay",
1021 priority=10,
1022 available=True,
1023 )
1024 ]
1025 )
1026 wiim_player.set_active_output_protocol("airplay_wiim")
1027 wiim_player.set_protocol_parent_id("airplay_wiim")
1028
1029 # Wire up mock_mass.players to controller so get_linked_protocol works
1030 mock_mass.players = controller
1031
1032 controller._players = {
1033 "sonos_123": sonos_a,
1034 "sonos_456": sonos_b,
1035 "wiim_789": wiim_player,
1036 "airplay_sonos": sonos_airplay,
1037 "airplay_wiim": wiim_airplay,
1038 }
1039 controller._player_throttlers = {
1040 "sonos_123": Throttler(1, 0.05),
1041 "sonos_456": Throttler(1, 0.05),
1042 "wiim_789": Throttler(1, 0.05),
1043 "airplay_sonos": Throttler(1, 0.05),
1044 "airplay_wiim": Throttler(1, 0.05),
1045 }
1046
1047 # Group Sonos B (native) + WiiM (via AirPlay) to Sonos A
1048 protocol_members, native_members, _protocol_player, _ = (
1049 controller._translate_members_for_protocols(
1050 parent_player=sonos_a,
1051 player_ids=["sonos_456", "wiim_789"],
1052 parent_protocol_player=sonos_airplay,
1053 parent_protocol_domain="airplay",
1054 )
1055 )
1056
1057 # Should have hybrid group: native Sonos B + protocol WiiM
1058 assert len(native_members) == 1
1059 assert "sonos_456" in native_members
1060 assert len(protocol_members) == 1
1061 assert "airplay_wiim" in protocol_members
1062
1063 def test_protocol_selection_requires_set_members(self, mock_mass: MagicMock) -> None:
1064 """Test that only protocols with SET_MEMBERS support are selected for grouping."""
1065 controller = PlayerController(mock_mass)
1066
1067 sonos_provider = MockProvider("sonos", mass=mock_mass)
1068 wiim_provider = MockProvider("wiim", mass=mock_mass)
1069 dlna_provider = MockProvider("dlna", mass=mock_mass)
1070 airplay_provider = MockProvider("airplay", mass=mock_mass)
1071
1072 # Sonos player
1073 sonos_player = MockPlayer(
1074 sonos_provider,
1075 "sonos_123",
1076 "Living Room",
1077 identifiers={IdentifierType.MAC_ADDRESS: "AA:BB:CC:DD:EE:01"},
1078 )
1079 sonos_player._attr_supported_features.add(PlayerFeature.PLAY_MEDIA)
1080 sonos_player._cache.clear()
1081
1082 # WiiM player
1083 wiim_player = MockPlayer(
1084 wiim_provider,
1085 "wiim_456",
1086 "Bedroom",
1087 identifiers={IdentifierType.MAC_ADDRESS: "AA:BB:CC:DD:EE:02"},
1088 )
1089 wiim_player._attr_supported_features.add(PlayerFeature.PLAY_MEDIA)
1090 wiim_player._cache.clear()
1091
1092 # DLNA protocol (does NOT support SET_MEMBERS)
1093 sonos_dlna = MockPlayer(
1094 dlna_provider,
1095 "dlna_sonos",
1096 "Living Room (DLNA)",
1097 player_type=PlayerType.PROTOCOL,
1098 identifiers={IdentifierType.MAC_ADDRESS: "AA:BB:CC:DD:EE:01"},
1099 )
1100 # Note: NO SET_MEMBERS feature
1101
1102 wiim_dlna = MockPlayer(
1103 dlna_provider,
1104 "dlna_wiim",
1105 "Bedroom (DLNA)",
1106 player_type=PlayerType.PROTOCOL,
1107 identifiers={IdentifierType.MAC_ADDRESS: "AA:BB:CC:DD:EE:02"},
1108 )
1109
1110 # AirPlay protocol (DOES support SET_MEMBERS)
1111 sonos_airplay = MockPlayer(
1112 airplay_provider,
1113 "airplay_sonos",
1114 "Living Room (AirPlay)",
1115 player_type=PlayerType.PROTOCOL,
1116 identifiers={IdentifierType.MAC_ADDRESS: "AA:BB:CC:DD:EE:01"},
1117 )
1118 sonos_airplay._attr_supported_features.add(PlayerFeature.SET_MEMBERS)
1119 sonos_airplay._attr_can_group_with = {"airplay_wiim"}
1120 sonos_airplay._cache.clear()
1121
1122 wiim_airplay = MockPlayer(
1123 airplay_provider,
1124 "airplay_wiim",
1125 "Bedroom (AirPlay)",
1126 player_type=PlayerType.PROTOCOL,
1127 identifiers={IdentifierType.MAC_ADDRESS: "AA:BB:CC:DD:EE:02"},
1128 )
1129 wiim_airplay._attr_supported_features.add(PlayerFeature.SET_MEMBERS)
1130 wiim_airplay._attr_can_group_with = {"airplay_sonos"}
1131 wiim_airplay._cache.clear()
1132
1133 # Link protocols (DLNA has lower priority than AirPlay)
1134 sonos_player.set_linked_output_protocols(
1135 [
1136 OutputProtocol(
1137 output_protocol_id="dlna_sonos",
1138 name="DLNA",
1139 protocol_domain="dlna",
1140 priority=30, # Lower priority (higher number)
1141 available=True,
1142 ),
1143 OutputProtocol(
1144 output_protocol_id="airplay_sonos",
1145 name="AirPlay",
1146 protocol_domain="airplay",
1147 priority=10, # Higher priority (lower number)
1148 available=True,
1149 ),
1150 ]
1151 )
1152 wiim_player.set_linked_output_protocols(
1153 [
1154 OutputProtocol(
1155 output_protocol_id="dlna_wiim",
1156 name="DLNA",
1157 protocol_domain="dlna",
1158 priority=30,
1159 available=True,
1160 ),
1161 OutputProtocol(
1162 output_protocol_id="airplay_wiim",
1163 name="AirPlay",
1164 protocol_domain="airplay",
1165 priority=10,
1166 available=True,
1167 ),
1168 ]
1169 )
1170
1171 controller._players = {
1172 "sonos_123": sonos_player,
1173 "wiim_456": wiim_player,
1174 "dlna_sonos": sonos_dlna,
1175 "dlna_wiim": wiim_dlna,
1176 "airplay_sonos": sonos_airplay,
1177 "airplay_wiim": wiim_airplay,
1178 }
1179 controller._player_throttlers = {
1180 "sonos_123": Throttler(1, 0.05),
1181 "wiim_456": Throttler(1, 0.05),
1182 "dlna_sonos": Throttler(1, 0.05),
1183 "dlna_wiim": Throttler(1, 0.05),
1184 "airplay_sonos": Throttler(1, 0.05),
1185 "airplay_wiim": Throttler(1, 0.05),
1186 }
1187
1188 # Update state after modifying attributes
1189 sonos_airplay.update_state(signal_event=False)
1190 wiim_airplay.update_state(signal_event=False)
1191
1192 # Translate members - should skip DLNA (no SET_MEMBERS) and select AirPlay
1193 protocol_members, _native_members, protocol_player, protocol_domain = (
1194 controller._translate_members_for_protocols(
1195 parent_player=sonos_player,
1196 player_ids=["wiim_456"],
1197 parent_protocol_player=None,
1198 parent_protocol_domain=None,
1199 )
1200 )
1201
1202 # Should select AirPlay (supports SET_MEMBERS) not DLNA
1203 assert len(protocol_members) == 1
1204 assert "airplay_wiim" in protocol_members
1205 assert protocol_domain == "airplay"
1206 assert protocol_player == sonos_airplay
1207
1208
1209class TestCanGroupWith:
1210 """Tests for can_group_with property with three scenarios."""
1211
1212 def test_scenario_1_native_active_only_native_players(self, mock_mass: MagicMock) -> None:
1213 """Test Scenario 1: Native playback active -> all protocols shown (new behavior)."""
1214 controller = PlayerController(mock_mass)
1215
1216 sonos_provider = MockProvider("sonos", instance_id="sonos_instance", mass=mock_mass)
1217 airplay_provider = MockProvider("airplay", instance_id="airplay_instance", mass=mock_mass)
1218
1219 # Create Sonos player with native and AirPlay support
1220 sonos_player = MockPlayer(
1221 sonos_provider,
1222 "sonos_123",
1223 "Living Room",
1224 identifiers={IdentifierType.MAC_ADDRESS: "AA:BB:CC:DD:EE:01"},
1225 )
1226 sonos_player._attr_supported_features.add(PlayerFeature.PLAY_MEDIA)
1227 sonos_player._attr_supported_features.add(PlayerFeature.SET_MEMBERS)
1228 sonos_player._attr_can_group_with = {"sonos_456"}
1229 sonos_player._cache.clear()
1230 sonos_player.set_active_output_protocol("native")
1231
1232 # Create another Sonos player
1233 sonos_player_b = MockPlayer(
1234 sonos_provider,
1235 "sonos_456",
1236 "Kitchen",
1237 identifiers={IdentifierType.MAC_ADDRESS: "AA:BB:CC:DD:EE:02"},
1238 )
1239
1240 # Create AirPlay protocol player
1241 sonos_airplay = MockPlayer(
1242 airplay_provider,
1243 "airplay_sonos",
1244 "Living Room (AirPlay)",
1245 player_type=PlayerType.PROTOCOL,
1246 identifiers={IdentifierType.MAC_ADDRESS: "AA:BB:CC:DD:EE:01"},
1247 )
1248 sonos_airplay._attr_supported_features.add(PlayerFeature.SET_MEMBERS)
1249 sonos_airplay._attr_can_group_with = {"airplay_other"}
1250 sonos_airplay._cache.clear()
1251 sonos_airplay.set_protocol_parent_id("sonos_123")
1252
1253 sonos_player.set_linked_output_protocols(
1254 [
1255 OutputProtocol(
1256 output_protocol_id="airplay_sonos",
1257 name="AirPlay",
1258 protocol_domain="airplay",
1259 priority=10,
1260 available=True,
1261 )
1262 ]
1263 )
1264
1265 # Wire up mock_mass.players to controller so get_linked_protocol works
1266 mock_mass.players = controller
1267
1268 controller._players = {
1269 "sonos_123": sonos_player,
1270 "sonos_456": sonos_player_b,
1271 "airplay_sonos": sonos_airplay,
1272 }
1273 controller._player_throttlers = {
1274 "sonos_123": Throttler(1, 0.05),
1275 "sonos_456": Throttler(1, 0.05),
1276 "airplay_sonos": Throttler(1, 0.05),
1277 }
1278
1279 # Update state after modifying attributes and registering with controller
1280 sonos_player.update_state(signal_event=False)
1281 sonos_player_b.update_state(signal_event=False)
1282 sonos_airplay.update_state(signal_event=False)
1283
1284 # Get can_group_with while native is active
1285 groupable = sonos_player.state.can_group_with
1286
1287 # NEW BEHAVIOR: Should show both native AND protocol players
1288 # even when native protocol is active
1289 assert "sonos_456" in groupable # Native Sonos player
1290 # Note: airplay_other is not registered in controller._players, so it won't appear
1291 # But the logic should still allow showing AirPlay options if they were registered
1292
1293 def test_scenario_2_protocol_active_hybrid_groups(self, mock_mass: MagicMock) -> None:
1294 """Test Scenario 2: Protocol active -> show all protocols (new behavior)."""
1295 controller = PlayerController(mock_mass)
1296
1297 sonos_provider = MockProvider("sonos", instance_id="sonos_instance", mass=mock_mass)
1298 airplay_provider = MockProvider("airplay", instance_id="airplay_instance", mass=mock_mass)
1299
1300 # Create Sonos player with AirPlay active
1301 sonos_player = MockPlayer(
1302 sonos_provider,
1303 "sonos_123",
1304 "Living Room",
1305 identifiers={IdentifierType.MAC_ADDRESS: "AA:BB:CC:DD:EE:01"},
1306 )
1307 sonos_player._attr_supported_features.add(PlayerFeature.PLAY_MEDIA)
1308 sonos_player._attr_supported_features.add(PlayerFeature.SET_MEMBERS)
1309 sonos_player._attr_can_group_with = {"sonos_456"}
1310 sonos_player._cache.clear()
1311
1312 # Create another Sonos player
1313 sonos_player_b = MockPlayer(
1314 sonos_provider,
1315 "sonos_456",
1316 "Kitchen",
1317 identifiers={IdentifierType.MAC_ADDRESS: "AA:BB:CC:DD:EE:02"},
1318 )
1319
1320 # Create AirPlay protocol player
1321 sonos_airplay = MockPlayer(
1322 airplay_provider,
1323 "airplay_sonos",
1324 "Living Room (AirPlay)",
1325 player_type=PlayerType.PROTOCOL,
1326 identifiers={IdentifierType.MAC_ADDRESS: "AA:BB:CC:DD:EE:01"},
1327 )
1328 sonos_airplay._attr_supported_features.add(PlayerFeature.SET_MEMBERS)
1329 sonos_airplay._attr_can_group_with = {"airplay_other"}
1330 sonos_airplay._cache.clear()
1331 sonos_airplay.set_protocol_parent_id("sonos_123")
1332
1333 # Create another device with AirPlay
1334 wiim_player = MockPlayer(
1335 sonos_provider,
1336 "wiim_789",
1337 "Bedroom",
1338 identifiers={IdentifierType.MAC_ADDRESS: "AA:BB:CC:DD:EE:03"},
1339 )
1340
1341 airplay_other = MockPlayer(
1342 airplay_provider,
1343 "airplay_other",
1344 "Bedroom (AirPlay)",
1345 player_type=PlayerType.PROTOCOL,
1346 identifiers={IdentifierType.MAC_ADDRESS: "AA:BB:CC:DD:EE:03"},
1347 )
1348 airplay_other._attr_supported_features.add(PlayerFeature.SET_MEMBERS)
1349 airplay_other._attr_can_group_with = {"airplay_sonos"}
1350 airplay_other._cache.clear()
1351 airplay_other.set_protocol_parent_id("wiim_789")
1352
1353 wiim_player.set_linked_output_protocols(
1354 [
1355 OutputProtocol(
1356 output_protocol_id="airplay_other",
1357 name="AirPlay",
1358 protocol_domain="airplay",
1359 priority=10,
1360 available=True,
1361 ),
1362 ]
1363 )
1364
1365 sonos_player.set_linked_output_protocols(
1366 [
1367 OutputProtocol(
1368 output_protocol_id="airplay_sonos",
1369 name="AirPlay",
1370 protocol_domain="airplay",
1371 priority=10,
1372 available=True,
1373 )
1374 ]
1375 )
1376 sonos_player.set_active_output_protocol("airplay_sonos")
1377
1378 # Wire up mock_mass.players to controller so get_linked_protocol works
1379 mock_mass.players = controller
1380
1381 controller._players = {
1382 "sonos_123": sonos_player,
1383 "sonos_456": sonos_player_b,
1384 "wiim_789": wiim_player,
1385 "airplay_sonos": sonos_airplay,
1386 "airplay_other": airplay_other,
1387 }
1388 controller._player_throttlers = {
1389 "sonos_123": Throttler(1, 0.05),
1390 "sonos_456": Throttler(1, 0.05),
1391 "wiim_789": Throttler(1, 0.05),
1392 "airplay_sonos": Throttler(1, 0.05),
1393 "airplay_other": Throttler(1, 0.05),
1394 }
1395
1396 # Clear cache after setting linked protocols
1397 sonos_player._cache.clear()
1398 wiim_player._cache.clear()
1399
1400 # Update state after modifying attributes and registering with controller
1401 # IMPORTANT: Update protocol players FIRST, then parent players
1402 sonos_airplay.update_state(signal_event=False)
1403 airplay_other.update_state(signal_event=False)
1404 sonos_player.update_state(signal_event=False)
1405 sonos_player_b.update_state(signal_event=False)
1406 wiim_player.update_state(signal_event=False)
1407
1408 # Get can_group_with while AirPlay is active
1409 groupable = sonos_player.state.can_group_with
1410
1411 # NEW BEHAVIOR: Should show ALL protocols + native players
1412 # regardless of which protocol is active
1413 assert "sonos_456" in groupable # Native Sonos player
1414 assert "wiim_789" in groupable # Via airplay_other protocol
1415
1416 def test_scenario_3_no_active_output_all_protocols_shown(self, mock_mass: MagicMock) -> None:
1417 """Test Scenario 3: No active output -> show all compatible protocols + native."""
1418 controller = PlayerController(mock_mass)
1419
1420 sonos_provider = MockProvider("sonos", instance_id="sonos_instance", mass=mock_mass)
1421 airplay_provider = MockProvider("airplay", instance_id="airplay_instance", mass=mock_mass)
1422 dlna_provider = MockProvider("dlna", instance_id="dlna_instance", mass=mock_mass)
1423
1424 # Create Sonos player (no active protocol)
1425 sonos_player = MockPlayer(
1426 sonos_provider,
1427 "sonos_123",
1428 "Living Room",
1429 identifiers={IdentifierType.MAC_ADDRESS: "AA:BB:CC:DD:EE:01"},
1430 )
1431 sonos_player._attr_supported_features.add(PlayerFeature.PLAY_MEDIA)
1432 sonos_player._attr_supported_features.add(PlayerFeature.SET_MEMBERS)
1433 sonos_player._attr_can_group_with = {"sonos_456"}
1434 sonos_player._cache.clear()
1435 # No active output protocol set
1436
1437 # Create another Sonos player
1438 sonos_player_b = MockPlayer(
1439 sonos_provider,
1440 "sonos_456",
1441 "Kitchen",
1442 identifiers={IdentifierType.MAC_ADDRESS: "AA:BB:CC:DD:EE:02"},
1443 )
1444
1445 # Create AirPlay protocol player (supports SET_MEMBERS)
1446 sonos_airplay = MockPlayer(
1447 airplay_provider,
1448 "airplay_sonos",
1449 "Living Room (AirPlay)",
1450 player_type=PlayerType.PROTOCOL,
1451 identifiers={IdentifierType.MAC_ADDRESS: "AA:BB:CC:DD:EE:01"},
1452 )
1453 sonos_airplay._attr_supported_features.add(PlayerFeature.SET_MEMBERS)
1454 sonos_airplay._attr_can_group_with = {"airplay_other"}
1455 sonos_airplay._cache.clear()
1456 sonos_airplay.set_protocol_parent_id("sonos_123")
1457
1458 # Create DLNA protocol player (does NOT support SET_MEMBERS)
1459 sonos_dlna = MockPlayer(
1460 dlna_provider,
1461 "dlna_sonos",
1462 "Living Room (DLNA)",
1463 player_type=PlayerType.PROTOCOL,
1464 identifiers={IdentifierType.MAC_ADDRESS: "AA:BB:CC:DD:EE:01"},
1465 )
1466 # No SET_MEMBERS support
1467 sonos_dlna._attr_can_group_with = {"dlna_other"}
1468 sonos_dlna.set_protocol_parent_id("sonos_123")
1469
1470 # Another device
1471 wiim_player = MockPlayer(
1472 sonos_provider,
1473 "wiim_789",
1474 "Bedroom",
1475 identifiers={IdentifierType.MAC_ADDRESS: "AA:BB:CC:DD:EE:03"},
1476 )
1477
1478 airplay_other = MockPlayer(
1479 airplay_provider,
1480 "airplay_other",
1481 "Bedroom (AirPlay)",
1482 player_type=PlayerType.PROTOCOL,
1483 identifiers={IdentifierType.MAC_ADDRESS: "AA:BB:CC:DD:EE:03"},
1484 )
1485 airplay_other._attr_supported_features.add(PlayerFeature.SET_MEMBERS)
1486 airplay_other._attr_can_group_with = {"airplay_sonos"}
1487 airplay_other._cache.clear()
1488 airplay_other.set_protocol_parent_id("wiim_789")
1489
1490 sonos_player.set_linked_output_protocols(
1491 [
1492 OutputProtocol(
1493 output_protocol_id="airplay_sonos",
1494 name="AirPlay",
1495 protocol_domain="airplay",
1496 priority=10,
1497 available=True,
1498 ),
1499 OutputProtocol(
1500 output_protocol_id="dlna_sonos",
1501 name="DLNA",
1502 protocol_domain="dlna",
1503 priority=30,
1504 available=True,
1505 ),
1506 ]
1507 )
1508
1509 wiim_player.set_linked_output_protocols(
1510 [
1511 OutputProtocol(
1512 output_protocol_id="airplay_other",
1513 name="AirPlay",
1514 protocol_domain="airplay",
1515 priority=10,
1516 available=True,
1517 ),
1518 ]
1519 )
1520
1521 # Clear cache after setting linked protocols (output_protocols is cached)
1522 sonos_player._cache.clear()
1523 wiim_player._cache.clear()
1524
1525 # Wire up mock_mass.players to controller so get_linked_protocol works
1526 mock_mass.players = controller
1527
1528 controller._players = {
1529 "sonos_123": sonos_player,
1530 "sonos_456": sonos_player_b,
1531 "wiim_789": wiim_player,
1532 "airplay_sonos": sonos_airplay,
1533 "airplay_other": airplay_other,
1534 "dlna_sonos": sonos_dlna,
1535 }
1536 controller._player_throttlers = {
1537 "sonos_123": Throttler(1, 0.05),
1538 "sonos_456": Throttler(1, 0.05),
1539 "wiim_789": Throttler(1, 0.05),
1540 "airplay_sonos": Throttler(1, 0.05),
1541 "airplay_other": Throttler(1, 0.05),
1542 "dlna_sonos": Throttler(1, 0.05),
1543 }
1544
1545 # Update state after modifying attributes and registering with controller
1546 # Note: set_linked_output_protocols calls trigger_player_update, but since mass.players
1547 # is a MagicMock, we need to manually call update_state
1548 # IMPORTANT: Update protocol players FIRST, then parent players, because parent players
1549 # access protocol_player.state.can_group_with during their update_state()
1550 sonos_airplay.update_state(signal_event=False)
1551 airplay_other.update_state(signal_event=False)
1552 sonos_dlna.update_state(signal_event=False)
1553 sonos_player.update_state(signal_event=False)
1554 sonos_player_b.update_state(signal_event=False)
1555 wiim_player.update_state(signal_event=False)
1556
1557 # Get can_group_with with no active protocol
1558 groupable = sonos_player.state.can_group_with
1559
1560 # Should show native players + AirPlay players (supports SET_MEMBERS)
1561 # but NOT DLNA players (no SET_MEMBERS support)
1562 assert "sonos_456" in groupable
1563 assert "wiim_789" in groupable # Via AirPlay protocol
1564 # DLNA players should not be shown since DLNA doesn't support SET_MEMBERS
1565
1566
1567class TestProtocolSwitchingDuringPlayback:
1568 """Tests for dynamic protocol switching when group members change during playback."""
1569
1570 async def test_no_protocol_set_during_grouping_without_playback(
1571 self, mock_mass: MagicMock
1572 ) -> None:
1573 """Test that no protocol is set when grouping players without active playback."""
1574 controller = PlayerController(mock_mass)
1575
1576 sonos_provider = MockProvider("sonos", instance_id="sonos_instance", mass=mock_mass)
1577 airplay_provider = MockProvider("airplay", instance_id="airplay_instance", mass=mock_mass)
1578
1579 # Create Sonos player with AirPlay support
1580 sonos_player = MockPlayer(
1581 sonos_provider,
1582 "sonos_123",
1583 "Living Room",
1584 identifiers={IdentifierType.MAC_ADDRESS: "AA:BB:CC:DD:EE:01"},
1585 )
1586 sonos_player._attr_supported_features.add(PlayerFeature.PLAY_MEDIA)
1587 sonos_player._attr_supported_features.add(PlayerFeature.SET_MEMBERS)
1588 sonos_player._attr_can_group_with = {"sonos_456"}
1589
1590 # Create another Sonos player
1591 sonos_player_b = MockPlayer(
1592 sonos_provider,
1593 "sonos_456",
1594 "Kitchen",
1595 identifiers={IdentifierType.MAC_ADDRESS: "AA:BB:CC:DD:EE:02"},
1596 )
1597 sonos_player_b._attr_supported_features.add(PlayerFeature.SET_MEMBERS)
1598
1599 # Create AirPlay protocol player
1600 sonos_airplay = MockPlayer(
1601 airplay_provider,
1602 "airplay_sonos",
1603 "Living Room (AirPlay)",
1604 player_type=PlayerType.PROTOCOL,
1605 identifiers={IdentifierType.MAC_ADDRESS: "AA:BB:CC:DD:EE:01"},
1606 )
1607 sonos_airplay._attr_supported_features.add(PlayerFeature.SET_MEMBERS)
1608 sonos_airplay.set_protocol_parent_id("sonos_123")
1609
1610 sonos_player.set_linked_output_protocols(
1611 [
1612 OutputProtocol(
1613 output_protocol_id="airplay_sonos",
1614 name="AirPlay",
1615 protocol_domain="airplay",
1616 priority=10,
1617 available=True,
1618 )
1619 ]
1620 )
1621
1622 mock_mass.players = controller
1623 controller._players = {
1624 "sonos_123": sonos_player,
1625 "sonos_456": sonos_player_b,
1626 "airplay_sonos": sonos_airplay,
1627 }
1628 controller._player_throttlers = {
1629 "sonos_123": Throttler(1, 0.05),
1630 "sonos_456": Throttler(1, 0.05),
1631 "airplay_sonos": Throttler(1, 0.05),
1632 }
1633
1634 # Group players via protocol (simulate grouping through AirPlay)
1635 # This should NOT set active_output_protocol anymore
1636 await controller._forward_protocol_set_members(
1637 parent_player=sonos_player,
1638 parent_protocol_player=sonos_airplay,
1639 protocol_members_to_add=["airplay_other"], # Add a protocol member
1640 protocol_members_to_remove=[],
1641 )
1642
1643 # NEW BEHAVIOR: Protocol should NOT be set during grouping without playback
1644 # After grouping, protocol should not be activated until playback starts
1645 assert sonos_player.active_output_protocol is None
1646
1647 async def test_protocol_selected_at_playback_time(self, mock_mass: MagicMock) -> None:
1648 """Test that protocol is selected when playback starts, not during grouping."""
1649 controller = PlayerController(mock_mass)
1650
1651 sonos_provider = MockProvider("sonos", instance_id="sonos_instance", mass=mock_mass)
1652 airplay_provider = MockProvider("airplay", instance_id="airplay_instance", mass=mock_mass)
1653
1654 # Create Sonos player with AirPlay support
1655 sonos_player = MockPlayer(
1656 sonos_provider,
1657 "sonos_123",
1658 "Living Room",
1659 identifiers={IdentifierType.MAC_ADDRESS: "AA:BB:CC:DD:EE:01"},
1660 )
1661 sonos_player._attr_supported_features.add(PlayerFeature.PLAY_MEDIA)
1662 sonos_player._attr_supported_features.add(PlayerFeature.SET_MEMBERS)
1663
1664 # Create AirPlay protocol player with group members
1665 sonos_airplay = MockPlayer(
1666 airplay_provider,
1667 "airplay_sonos",
1668 "Living Room (AirPlay)",
1669 player_type=PlayerType.PROTOCOL,
1670 identifiers={IdentifierType.MAC_ADDRESS: "AA:BB:CC:DD:EE:01"},
1671 )
1672 sonos_airplay._attr_supported_features.add(PlayerFeature.SET_MEMBERS)
1673 sonos_airplay._attr_supported_features.add(PlayerFeature.PLAY_MEDIA)
1674 sonos_airplay.set_protocol_parent_id("sonos_123")
1675 # Simulate that AirPlay protocol has group members (needs >1 for grouping check)
1676 sonos_airplay._attr_group_members = ["airplay_sonos", "airplay_other"]
1677
1678 sonos_player.set_linked_output_protocols(
1679 [
1680 OutputProtocol(
1681 output_protocol_id="airplay_sonos",
1682 name="AirPlay",
1683 protocol_domain="airplay",
1684 priority=10,
1685 available=True,
1686 )
1687 ]
1688 )
1689
1690 mock_mass.players = controller
1691 controller._players = {
1692 "sonos_123": sonos_player,
1693 "airplay_sonos": sonos_airplay,
1694 }
1695
1696 # Update state to apply group members to state
1697 sonos_airplay.update_state(signal_event=False)
1698 sonos_player.update_state(signal_event=False)
1699
1700 # Protocol should not be set yet
1701 assert sonos_player.active_output_protocol is None
1702
1703 # Select protocol for playback
1704 selected_player, output_protocol = controller._select_best_output_protocol(sonos_player)
1705
1706 # Should select AirPlay protocol because it has group members (Priority 1)
1707 assert selected_player == sonos_airplay
1708 assert output_protocol is not None
1709 assert output_protocol.output_protocol_id == "airplay_sonos"
1710