"""Tests for Hue scene select entities.""" from __future__ import annotations import asyncio from copy import deepcopy from unittest.mock import Mock, patch from aiohue.v2.controllers.events import EventType from aiohue.v2.models.scene import Scene as HueScene import pytest from homeassistant.components.hue.v2.select import HueSceneSelectEntity from homeassistant.const import STATE_UNKNOWN, Platform from homeassistant.core import HomeAssistant from homeassistant.helpers import entity_registry as er from homeassistant.util.json import JsonArrayType from .conftest import setup_platform TEST_ROOM_ID = "6ddc9066-7e7d-4a03-a773-c73937968296" TEST_ZONE_ID = "7cee478d-6455-483a-9e32-9f9fdcbcc4f6" TEST_ROOM_SCENE_ENTITY = "select.test_room_test_room_scene" DUPLICATE_SCENE_ID = "22222222-3333-4444-8555-666666666666" LITERAL_SUFFIX_SCENE_ID = "33333333-4444-4555-8666-777777777777" async def test_scene_select_initial_state( hass: HomeAssistant, mock_bridge_v2: Mock, v2_resources_test_data: JsonArrayType ) -> None: """Test that scene select entities are created with correct initial state.""" await mock_bridge_v2.api.load_test_data(v2_resources_test_data) await setup_platform(hass, mock_bridge_v2, [Platform.SCENE, Platform.SELECT]) # A smart scene and its effective regular scene can both be active. The smart # scene is the top-level selection shown by the Hue app. state = hass.states.get("select.test_room_test_room_scene") assert state is not None assert state.state == "Smart Test Scene" assert state.attributes["options"] == [ "Regular Test Scene", "Smart Test Scene", ] assert hass.states.get("select.test_room_test_room_smart_scene") is None # Test Zone has "Dynamic Test Scene" active (dynamic_palette) from fixture state = hass.states.get("select.test_zone_scene") assert state is not None assert state.state == "Dynamic Test Scene" assert state.attributes["options"] == ["Dynamic Test Scene"] assert hass.states.get("select.test_zone_smart_scene") is None async def test_scene_select_becomes_inactive( hass: HomeAssistant, mock_bridge_v2: Mock, v2_resources_test_data: JsonArrayType ) -> None: """Test that the select entity reflects unknown state when no scene is active.""" await mock_bridge_v2.api.load_test_data(v2_resources_test_data) await setup_platform(hass, mock_bridge_v2, [Platform.SCENE, Platform.SELECT]) # The active smart scene takes precedence over its effective regular scene. assert ( hass.states.get("select.test_room_test_room_scene").state == "Smart Test Scene" ) smart_scene_id = "8abe5a3e-94c8-4058-908f-56241818509a" regular_scene_id = "cdbf3740-7977-4a11-8275-8c78636ad4bd" # When the smart scene stops, fall back to the still-active regular scene. mock_bridge_v2.api.emit_event( "update", {"id": smart_scene_id, "type": "smart_scene", "state": "inactive"}, ) await hass.async_block_till_done() assert ( hass.states.get("select.test_room_test_room_scene").state == "Regular Test Scene" ) # Once both scenes are inactive, the select has no active option. mock_bridge_v2.api.emit_event( "update", { "id": regular_scene_id, "type": "scene", "status": {"active": "inactive"}, }, ) await hass.async_block_till_done() assert hass.states.get("select.test_room_test_room_scene").state == STATE_UNKNOWN # Reactivate the scene mock_bridge_v2.api.emit_event( "update", { "id": regular_scene_id, "type": "scene", "status": { "active": "static", "last_recall": "2025-12-31T23:59:59.999Z", }, }, ) await hass.async_block_till_done() assert ( hass.states.get("select.test_room_test_room_scene").state == "Regular Test Scene" ) async def test_scene_select_activate_option( hass: HomeAssistant, mock_bridge_v2: Mock, v2_resources_test_data: JsonArrayType ) -> None: """Test that selecting an option calls the bridge scene recall API.""" await mock_bridge_v2.api.load_test_data(v2_resources_test_data) await setup_platform(hass, mock_bridge_v2, [Platform.SCENE, Platform.SELECT]) # Select an option by calling the select_option service mock_bridge_v2.mock_requests.clear() await hass.services.async_call( "select", "select_option", { "entity_id": "select.test_room_test_room_scene", "option": "Regular Test Scene", }, blocking=True, ) await hass.async_block_till_done() # Bridge API should have been called with the correct scene id regular_scene_id = "cdbf3740-7977-4a11-8275-8c78636ad4bd" assert len(mock_bridge_v2.mock_requests) == 1 path = mock_bridge_v2.mock_requests[0]["path"] assert "/scene/" in path assert regular_scene_id in path async def test_scene_select_disambiguates_duplicate_names( hass: HomeAssistant, mock_bridge_v2: Mock, v2_resources_test_data: JsonArrayType ) -> None: """Test duplicate regular scene names are exposed and recalled distinctly.""" test_data = deepcopy(v2_resources_test_data) duplicate_scene = deepcopy( next( resource for resource in test_data if resource["type"] == "scene" and resource["metadata"]["name"] == "Regular Test Scene" ) ) duplicate_scene["id"] = DUPLICATE_SCENE_ID duplicate_scene["status"] = { "active": "inactive", "last_recall": "2025-09-12T11:41:46.318Z", } test_data.append(duplicate_scene) await mock_bridge_v2.api.load_test_data(test_data) await setup_platform(hass, mock_bridge_v2, [Platform.SCENE, Platform.SELECT]) state = hass.states.get("select.test_room_test_room_scene") assert state is not None assert state.state == "Smart Test Scene" # The duplicate sorts before the original on scene id, so it keeps the bare name. assert state.attributes["options"] == [ "Regular Test Scene", "Regular Test Scene (2)", "Smart Test Scene", ] await hass.services.async_call( "select", "select_option", { "entity_id": "select.test_room_test_room_scene", "option": "Regular Test Scene", }, blocking=True, ) await hass.async_block_till_done() last_request = mock_bridge_v2.mock_requests[-1] assert "/scene/" in last_request["path"] assert DUPLICATE_SCENE_ID in last_request["path"] @pytest.mark.parametrize( ("option", "expected_scene_id"), [ pytest.param( "Regular Test Scene", DUPLICATE_SCENE_ID, id="duplicate_keeps_bare_name", ), pytest.param( "Regular Test Scene (2) (2)", LITERAL_SUFFIX_SCENE_ID, id="literal_name_is_disambiguated", ), ], ) async def test_scene_select_disambiguated_label_does_not_shadow_scene_name( hass: HomeAssistant, mock_bridge_v2: Mock, v2_resources_test_data: JsonArrayType, option: str, expected_scene_id: str, ) -> None: """Test a generated duplicate label cannot shadow a literal scene name.""" test_data = deepcopy(v2_resources_test_data) regular_scene = next( resource for resource in test_data if resource["type"] == "scene" and resource["metadata"]["name"] == "Regular Test Scene" ) duplicate_scene = deepcopy(regular_scene) duplicate_scene["id"] = DUPLICATE_SCENE_ID duplicate_scene["status"]["active"] = "inactive" test_data.append(duplicate_scene) literal_suffix_scene = deepcopy(regular_scene) literal_suffix_scene["id"] = LITERAL_SUFFIX_SCENE_ID literal_suffix_scene["metadata"]["name"] = "Regular Test Scene (2)" literal_suffix_scene["status"]["active"] = "inactive" test_data.append(literal_suffix_scene) await mock_bridge_v2.api.load_test_data(test_data) await setup_platform(hass, mock_bridge_v2, [Platform.SCENE, Platform.SELECT]) state = hass.states.get(TEST_ROOM_SCENE_ENTITY) assert state is not None assert state.attributes["options"] == [ "Regular Test Scene", "Regular Test Scene (2)", "Regular Test Scene (2) (2)", "Smart Test Scene", ] mock_bridge_v2.mock_requests.clear() await hass.services.async_call( "select", "select_option", {"entity_id": TEST_ROOM_SCENE_ENTITY, "option": option}, blocking=True, ) assert expected_scene_id in mock_bridge_v2.mock_requests[0]["path"] async def test_scene_select_refreshes_options_for_scene_events( hass: HomeAssistant, mock_bridge_v2: Mock, v2_resources_test_data: JsonArrayType ) -> None: """Test add, rename, and delete events refresh the unified scene options.""" test_data = deepcopy(v2_resources_test_data) regular_scene = next( resource for resource in test_data if resource["type"] == "scene" and resource["metadata"]["name"] == "Regular Test Scene" ) smart_scene = next( resource for resource in test_data if resource["type"] == "smart_scene" ) await mock_bridge_v2.api.load_test_data(test_data) await setup_platform(hass, mock_bridge_v2, [Platform.SCENE, Platform.SELECT]) added_scene = deepcopy(regular_scene) added_scene["id"] = "22222222-3333-4444-8555-666666666666" added_scene["metadata"]["name"] = "Added scene" added_scene["status"]["active"] = "inactive" mock_bridge_v2.api.emit_event("add", added_scene) await hass.async_block_till_done() state = hass.states.get("select.test_room_test_room_scene") assert state is not None assert state.attributes["options"] == [ "Added scene", "Regular Test Scene", "Smart Test Scene", ] renamed_scene = deepcopy(added_scene) renamed_scene["metadata"]["name"] = "Renamed scene" mock_bridge_v2.api.emit_event("update", renamed_scene) await hass.async_block_till_done() state = hass.states.get("select.test_room_test_room_scene") assert state.attributes["options"] == [ "Regular Test Scene", "Renamed scene", "Smart Test Scene", ] # Deleting the active smart scene removes its option and exposes its effective # regular scene as the current selection. mock_bridge_v2.api.emit_event("delete", smart_scene) await hass.async_block_till_done() state = hass.states.get("select.test_room_test_room_scene") assert state.state == "Regular Test Scene" assert state.attributes["options"] == [ "Regular Test Scene", "Renamed scene", ] async def test_scene_select_prefers_active_smart_scene( hass: HomeAssistant, mock_bridge_v2: Mock, v2_resources_test_data: JsonArrayType ) -> None: """Test smart scene state transitions in the unified scene select.""" await mock_bridge_v2.api.load_test_data(v2_resources_test_data) await setup_platform(hass, mock_bridge_v2, [Platform.SCENE, Platform.SELECT]) # Smart scene starts active assert ( hass.states.get("select.test_room_test_room_scene").state == "Smart Test Scene" ) smart_scene_id = "8abe5a3e-94c8-4058-908f-56241818509a" # Deactivate smart scene mock_bridge_v2.api.emit_event( "update", {"id": smart_scene_id, "type": "smart_scene", "state": "inactive"}, ) await hass.async_block_till_done() assert ( hass.states.get("select.test_room_test_room_scene").state == "Regular Test Scene" ) # Reactivate smart scene mock_bridge_v2.api.emit_event( "update", {"id": smart_scene_id, "type": "smart_scene", "state": "active"}, ) await hass.async_block_till_done() assert ( hass.states.get("select.test_room_test_room_scene").state == "Smart Test Scene" ) async def test_scene_select_activate_smart_scene_option( hass: HomeAssistant, mock_bridge_v2: Mock, v2_resources_test_data: JsonArrayType ) -> None: """Test selecting a smart scene uses the smart scene recall API.""" await mock_bridge_v2.api.load_test_data(v2_resources_test_data) await setup_platform(hass, mock_bridge_v2, [Platform.SCENE, Platform.SELECT]) mock_bridge_v2.mock_requests.clear() await hass.services.async_call( "select", "select_option", {"entity_id": "select.test_room_test_room_scene", "option": "Smart Test Scene"}, blocking=True, ) await hass.async_block_till_done() smart_scene_id = "8abe5a3e-94c8-4058-908f-56241818509a" assert len(mock_bridge_v2.mock_requests) == 1 path = mock_bridge_v2.mock_requests[0]["path"] assert "/smart_scene/" in path assert smart_scene_id in path async def test_scene_select_disambiguates_duplicate_smart_scene_names( hass: HomeAssistant, mock_bridge_v2: Mock, v2_resources_test_data: JsonArrayType ) -> None: """Test duplicate smart scene names are exposed and recalled distinctly.""" test_data = deepcopy(v2_resources_test_data) duplicate_smart_scene = deepcopy( next(resource for resource in test_data if resource["type"] == "smart_scene") ) duplicate_smart_scene_id = "11111111-2222-4333-8444-555555555555" duplicate_smart_scene["id"] = duplicate_smart_scene_id duplicate_smart_scene["state"] = "inactive" test_data.append(duplicate_smart_scene) await mock_bridge_v2.api.load_test_data(test_data) await setup_platform(hass, mock_bridge_v2, [Platform.SCENE, Platform.SELECT]) state = hass.states.get("select.test_room_test_room_scene") assert state is not None # The duplicate sorts before the original on scene id, so it keeps the bare name. assert state.state == "Smart Test Scene (2)" assert state.attributes["options"] == [ "Regular Test Scene", "Smart Test Scene", "Smart Test Scene (2)", ] await hass.services.async_call( "select", "select_option", { "entity_id": "select.test_room_test_room_scene", "option": "Smart Test Scene", }, blocking=True, ) await hass.async_block_till_done() last_request = mock_bridge_v2.mock_requests[-1] assert "/smart_scene/" in last_request["path"] assert duplicate_smart_scene_id in last_request["path"] async def test_scene_select_disambiguates_names_across_scene_types( hass: HomeAssistant, mock_bridge_v2: Mock, v2_resources_test_data: JsonArrayType ) -> None: """Test identical regular and smart scene names remain independently selectable.""" test_data = deepcopy(v2_resources_test_data) smart_scene = next( resource for resource in test_data if resource["type"] == "smart_scene" ) smart_scene["metadata"]["name"] = "Regular Test Scene" await mock_bridge_v2.api.load_test_data(test_data) await setup_platform(hass, mock_bridge_v2, [Platform.SCENE, Platform.SELECT]) state = hass.states.get("select.test_room_test_room_scene") assert state is not None # The smart scene sorts before the regular scene on scene id. assert state.state == "Regular Test Scene" assert state.attributes["options"] == [ "Regular Test Scene", "Regular Test Scene (2)", ] mock_bridge_v2.mock_requests.clear() await hass.services.async_call( "select", "select_option", { "entity_id": "select.test_room_test_room_scene", "option": "Regular Test Scene (2)", }, blocking=True, ) assert "/scene/" in mock_bridge_v2.mock_requests[0]["path"] mock_bridge_v2.mock_requests.clear() await hass.services.async_call( "select", "select_option", { "entity_id": "select.test_room_test_room_scene", "option": "Regular Test Scene", }, blocking=True, ) assert "/smart_scene/" in mock_bridge_v2.mock_requests[0]["path"] @pytest.mark.parametrize( ("entity_id", "expected_options"), [ ( "select.test_room_test_room_scene", ["Regular Test Scene", "Smart Test Scene"], ), ("select.test_zone_scene", ["Dynamic Test Scene"]), ], ) async def test_scene_select_options( hass: HomeAssistant, mock_bridge_v2: Mock, v2_resources_test_data: JsonArrayType, entity_id: str, expected_options: list[str], ) -> None: """Test that each select entity exposes the correct scene options for its group.""" await mock_bridge_v2.api.load_test_data(v2_resources_test_data) await setup_platform(hass, mock_bridge_v2, [Platform.SCENE, Platform.SELECT]) state = hass.states.get(entity_id) assert state is not None assert state.attributes["options"] == expected_options async def test_scene_select_removed_when_group_deleted( hass: HomeAssistant, entity_registry: er.EntityRegistry, mock_bridge_v2: Mock, v2_resources_test_data: JsonArrayType, ) -> None: """Test that deleting a Hue group removes its scene select entity.""" await mock_bridge_v2.api.load_test_data(v2_resources_test_data) await setup_platform(hass, mock_bridge_v2, [Platform.SCENE, Platform.SELECT]) assert hass.states.get(TEST_ROOM_SCENE_ENTITY) is not None assert entity_registry.async_get(TEST_ROOM_SCENE_ENTITY) is not None mock_bridge_v2.api.emit_event( "delete", {"type": "room", "id": TEST_ROOM_ID}, ) await hass.async_block_till_done() await hass.async_block_till_done() assert hass.states.get(TEST_ROOM_SCENE_ENTITY) is None assert entity_registry.async_get(TEST_ROOM_SCENE_ENTITY) is None @pytest.mark.parametrize( ("source_id", "new_id", "new_name", "entity_id"), [ pytest.param( TEST_ROOM_ID, "aaaaaaaa-bbbb-4ccc-8ddd-111111111111", "New Room", "select.new_room_new_room_scene", id="room", ), pytest.param( TEST_ZONE_ID, "aaaaaaaa-bbbb-4ccc-8ddd-222222222222", "New Zone", "select.new_zone_scene", id="zone", ), ], ) async def test_scene_select_created_when_group_added( hass: HomeAssistant, mock_bridge_v2: Mock, v2_resources_test_data: JsonArrayType, source_id: str, new_id: str, new_name: str, entity_id: str, ) -> None: """Test that adding a Hue group at runtime creates its scene select entity.""" await mock_bridge_v2.api.load_test_data(v2_resources_test_data) await setup_platform(hass, mock_bridge_v2, [Platform.SCENE, Platform.SELECT]) assert hass.states.get(entity_id) is None new_group = deepcopy( next( resource for resource in v2_resources_test_data if resource["id"] == source_id ) ) new_group["id"] = new_id new_group["metadata"]["name"] = new_name mock_bridge_v2.api.emit_event("add", new_group) await hass.async_block_till_done() state = hass.states.get(entity_id) assert state is not None assert state.attributes["options"] == [] async def test_scene_select_refreshes_options_missed_before_subscribe( hass: HomeAssistant, mock_bridge_v2: Mock, v2_resources_test_data: JsonArrayType ) -> None: """Test options include a scene added after init and before subscribe.""" test_data = [ deepcopy(resource) for resource in v2_resources_test_data if resource["id"] != TEST_ZONE_ID ] regular_scene = next( resource for resource in test_data if resource["type"] == "scene" and resource["metadata"]["name"] == "Regular Test Scene" ) late_scene = deepcopy(regular_scene) late_scene["id"] = "44444444-5555-4666-8777-888888888888" late_scene["metadata"]["name"] = "Late scene" late_scene["status"]["active"] = "inactive" original_added_to_hass = HueSceneSelectEntity.async_added_to_hass async def async_added_to_hass_with_late_scene( self: HueSceneSelectEntity, ) -> None: assert self.unique_id == f"{TEST_ROOM_ID}_scene_select" scene_added = asyncio.Event() def on_scene_added(_: EventType, scene: HueScene) -> None: scene_added.set() unsubscribe = mock_bridge_v2.api.scenes.subscribe( on_scene_added, late_scene["id"], EventType.RESOURCE_ADDED, ) try: mock_bridge_v2.api.emit_event("add", late_scene) async with asyncio.timeout(5): await scene_added.wait() finally: unsubscribe() assert late_scene["id"] in mock_bridge_v2.api.scenes assert "Late scene" not in self.options await original_added_to_hass(self) await mock_bridge_v2.api.load_test_data(test_data) with patch.object( HueSceneSelectEntity, "async_added_to_hass", async_added_to_hass_with_late_scene, ): await setup_platform(hass, mock_bridge_v2, Platform.SELECT) state = hass.states.get(TEST_ROOM_SCENE_ENTITY) assert state is not None assert state.attributes["options"] == [ "Late scene", "Regular Test Scene", "Smart Test Scene", ]