mirror of
				https://github.com/esphome/esphome.git
				synced 2025-10-31 15:12:06 +00:00 
			
		
		
		
	Merge remote-tracking branch 'dala318/multi_device' into integration
This commit is contained in:
		| @@ -646,7 +646,9 @@ lvgl: | ||||
|             on_click: | ||||
|               lvgl.qrcode.update: | ||||
|                 id: lv_qr | ||||
|                 text: homeassistant.io | ||||
|                 text: | ||||
|                   format: "A string with a number %d" | ||||
|                   args: ['(int)(random_uint32() % 1000)'] | ||||
|  | ||||
|         - slider: | ||||
|             min_value: 0 | ||||
|   | ||||
| @@ -1,211 +0,0 @@ | ||||
| esphome: | ||||
|   name: duplicate-entities-test | ||||
|   # Define devices to test multi-device duplicate handling | ||||
|   devices: | ||||
|     - id: controller_1 | ||||
|       name: Controller 1 | ||||
|     - id: controller_2 | ||||
|       name: Controller 2 | ||||
|  | ||||
| host: | ||||
| api:  # Port will be automatically injected | ||||
| logger: | ||||
|  | ||||
| # Create duplicate entities across different scenarios | ||||
|  | ||||
| # Scenario 1: Multiple sensors with same name on same device (should get _2, _3, _4) | ||||
| sensor: | ||||
|   - platform: template | ||||
|     name: Temperature | ||||
|     lambda: return 1.0; | ||||
|     update_interval: 0.1s | ||||
|  | ||||
|   - platform: template | ||||
|     name: Temperature | ||||
|     lambda: return 2.0; | ||||
|     update_interval: 0.1s | ||||
|  | ||||
|   - platform: template | ||||
|     name: Temperature | ||||
|     lambda: return 3.0; | ||||
|     update_interval: 0.1s | ||||
|  | ||||
|   - platform: template | ||||
|     name: Temperature | ||||
|     lambda: return 4.0; | ||||
|     update_interval: 0.1s | ||||
|  | ||||
|   # Scenario 2: Device-specific duplicates using device_id configuration | ||||
|   - platform: template | ||||
|     name: Device Temperature | ||||
|     device_id: controller_1 | ||||
|     lambda: return 10.0; | ||||
|     update_interval: 0.1s | ||||
|  | ||||
|   - platform: template | ||||
|     name: Device Temperature | ||||
|     device_id: controller_1 | ||||
|     lambda: return 11.0; | ||||
|     update_interval: 0.1s | ||||
|  | ||||
|   - platform: template | ||||
|     name: Device Temperature | ||||
|     device_id: controller_1 | ||||
|     lambda: return 12.0; | ||||
|     update_interval: 0.1s | ||||
|  | ||||
|   # Different device, same name - should not conflict | ||||
|   - platform: template | ||||
|     name: Device Temperature | ||||
|     device_id: controller_2 | ||||
|     lambda: return 20.0; | ||||
|     update_interval: 0.1s | ||||
|  | ||||
| # Scenario 3: Binary sensors (different platform, same name) | ||||
| binary_sensor: | ||||
|   - platform: template | ||||
|     name: Temperature | ||||
|     lambda: return true; | ||||
|  | ||||
|   - platform: template | ||||
|     name: Temperature | ||||
|     lambda: return false; | ||||
|  | ||||
|   - platform: template | ||||
|     name: Temperature | ||||
|     lambda: return true; | ||||
|  | ||||
|   # Scenario 5: Binary sensors on devices | ||||
|   - platform: template | ||||
|     name: Device Temperature | ||||
|     device_id: controller_1 | ||||
|     lambda: return true; | ||||
|  | ||||
|   - platform: template | ||||
|     name: Device Temperature | ||||
|     device_id: controller_2 | ||||
|     lambda: return false; | ||||
|  | ||||
|   # Issue #6953: Empty names on binary sensors | ||||
|   - platform: template | ||||
|     name: "" | ||||
|     lambda: return true; | ||||
|   - platform: template | ||||
|     name: "" | ||||
|     lambda: return false; | ||||
|  | ||||
|   - platform: template | ||||
|     name: "" | ||||
|     lambda: return true; | ||||
|  | ||||
|   - platform: template | ||||
|     name: "" | ||||
|     lambda: return false; | ||||
|  | ||||
| # Scenario 6: Test with special characters that need sanitization | ||||
| text_sensor: | ||||
|   - platform: template | ||||
|     name: "Status Message!" | ||||
|     lambda: return {"status1"}; | ||||
|     update_interval: 0.1s | ||||
|  | ||||
|   - platform: template | ||||
|     name: "Status Message!" | ||||
|     lambda: return {"status2"}; | ||||
|     update_interval: 0.1s | ||||
|  | ||||
|   - platform: template | ||||
|     name: "Status Message!" | ||||
|     lambda: return {"status3"}; | ||||
|     update_interval: 0.1s | ||||
|  | ||||
| # Scenario 7: More switch duplicates | ||||
| switch: | ||||
|   - platform: template | ||||
|     name: "Power Switch" | ||||
|     lambda: return false; | ||||
|     turn_on_action: [] | ||||
|     turn_off_action: [] | ||||
|  | ||||
|   - platform: template | ||||
|     name: "Power Switch" | ||||
|     lambda: return true; | ||||
|     turn_on_action: [] | ||||
|     turn_off_action: [] | ||||
|  | ||||
|   # Scenario 8: Issue #6953 - Multiple entities with empty names | ||||
|   # Empty names on main device - should use device name with suffixes | ||||
|   - platform: template | ||||
|     name: "" | ||||
|     lambda: return false; | ||||
|     turn_on_action: [] | ||||
|     turn_off_action: [] | ||||
|  | ||||
|   - platform: template | ||||
|     name: "" | ||||
|     lambda: return true; | ||||
|     turn_on_action: [] | ||||
|     turn_off_action: [] | ||||
|  | ||||
|   - platform: template | ||||
|     name: "" | ||||
|     lambda: return false; | ||||
|     turn_on_action: [] | ||||
|     turn_off_action: [] | ||||
|  | ||||
|   # Scenario 9: Issue #6953 - Empty names on sub-devices | ||||
|   # Empty names on sub-device - should use sub-device name with suffixes | ||||
|   - platform: template | ||||
|     name: "" | ||||
|     device_id: controller_1 | ||||
|     lambda: return false; | ||||
|     turn_on_action: [] | ||||
|     turn_off_action: [] | ||||
|  | ||||
|   - platform: template | ||||
|     name: "" | ||||
|     device_id: controller_1 | ||||
|     lambda: return true; | ||||
|     turn_on_action: [] | ||||
|     turn_off_action: [] | ||||
|  | ||||
|   - platform: template | ||||
|     name: "" | ||||
|     device_id: controller_1 | ||||
|     lambda: return false; | ||||
|     turn_on_action: [] | ||||
|     turn_off_action: [] | ||||
|  | ||||
|   # Empty names on different sub-device | ||||
|   - platform: template | ||||
|     name: "" | ||||
|     device_id: controller_2 | ||||
|     lambda: return false; | ||||
|     turn_on_action: [] | ||||
|     turn_off_action: [] | ||||
|  | ||||
|   - platform: template | ||||
|     name: "" | ||||
|     device_id: controller_2 | ||||
|     lambda: return true; | ||||
|     turn_on_action: [] | ||||
|     turn_off_action: [] | ||||
|  | ||||
|   # Scenario 10: Issue #6953 - Duplicate "xyz" names | ||||
|   - platform: template | ||||
|     name: "xyz" | ||||
|     lambda: return false; | ||||
|     turn_on_action: [] | ||||
|     turn_off_action: [] | ||||
|  | ||||
|   - platform: template | ||||
|     name: "xyz" | ||||
|     lambda: return true; | ||||
|     turn_on_action: [] | ||||
|     turn_off_action: [] | ||||
|  | ||||
|   - platform: template | ||||
|     name: "xyz" | ||||
|     lambda: return false; | ||||
|     turn_on_action: [] | ||||
|     turn_off_action: [] | ||||
| @@ -0,0 +1,154 @@ | ||||
| esphome: | ||||
|   name: duplicate-entities-test | ||||
|   # Define devices to test multi-device duplicate handling | ||||
|   devices: | ||||
|     - id: controller_1 | ||||
|       name: Controller 1 | ||||
|     - id: controller_2 | ||||
|       name: Controller 2 | ||||
|     - id: controller_3 | ||||
|       name: Controller 3 | ||||
|  | ||||
| host: | ||||
| api:  # Port will be automatically injected | ||||
| logger: | ||||
|  | ||||
| # Test that duplicate entity names are allowed on different devices | ||||
|  | ||||
| # Scenario 1: Same sensor name on different devices (allowed) | ||||
| sensor: | ||||
|   - platform: template | ||||
|     name: Temperature | ||||
|     device_id: controller_1 | ||||
|     lambda: return 21.0; | ||||
|     update_interval: 0.1s | ||||
|  | ||||
|   - platform: template | ||||
|     name: Temperature | ||||
|     device_id: controller_2 | ||||
|     lambda: return 22.0; | ||||
|     update_interval: 0.1s | ||||
|  | ||||
|   - platform: template | ||||
|     name: Temperature | ||||
|     device_id: controller_3 | ||||
|     lambda: return 23.0; | ||||
|     update_interval: 0.1s | ||||
|  | ||||
|   # Main device sensor (no device_id) | ||||
|   - platform: template | ||||
|     name: Temperature | ||||
|     lambda: return 20.0; | ||||
|     update_interval: 0.1s | ||||
|  | ||||
|   # Different sensor with unique name | ||||
|   - platform: template | ||||
|     name: Humidity | ||||
|     lambda: return 60.0; | ||||
|     update_interval: 0.1s | ||||
|  | ||||
| # Scenario 2: Same binary sensor name on different devices (allowed) | ||||
| binary_sensor: | ||||
|   - platform: template | ||||
|     name: Status | ||||
|     device_id: controller_1 | ||||
|     lambda: return true; | ||||
|  | ||||
|   - platform: template | ||||
|     name: Status | ||||
|     device_id: controller_2 | ||||
|     lambda: return false; | ||||
|  | ||||
|   - platform: template | ||||
|     name: Status | ||||
|     lambda: return true;  # Main device | ||||
|  | ||||
|   # Different platform can have same name as sensor | ||||
|   - platform: template | ||||
|     name: Temperature | ||||
|     lambda: return true; | ||||
|  | ||||
| # Scenario 3: Same text sensor name on different devices | ||||
| text_sensor: | ||||
|   - platform: template | ||||
|     name: Device Info | ||||
|     device_id: controller_1 | ||||
|     lambda: return {"Controller 1 Active"}; | ||||
|     update_interval: 0.1s | ||||
|  | ||||
|   - platform: template | ||||
|     name: Device Info | ||||
|     device_id: controller_2 | ||||
|     lambda: return {"Controller 2 Active"}; | ||||
|     update_interval: 0.1s | ||||
|  | ||||
|   - platform: template | ||||
|     name: Device Info | ||||
|     lambda: return {"Main Device Active"}; | ||||
|     update_interval: 0.1s | ||||
|  | ||||
| # Scenario 4: Same switch name on different devices | ||||
| switch: | ||||
|   - platform: template | ||||
|     name: Power | ||||
|     device_id: controller_1 | ||||
|     lambda: return false; | ||||
|     turn_on_action: [] | ||||
|     turn_off_action: [] | ||||
|  | ||||
|   - platform: template | ||||
|     name: Power | ||||
|     device_id: controller_2 | ||||
|     lambda: return true; | ||||
|     turn_on_action: [] | ||||
|     turn_off_action: [] | ||||
|  | ||||
|   - platform: template | ||||
|     name: Power | ||||
|     device_id: controller_3 | ||||
|     lambda: return false; | ||||
|     turn_on_action: [] | ||||
|     turn_off_action: [] | ||||
|  | ||||
|   # Unique switch on main device | ||||
|   - platform: template | ||||
|     name: Main Power | ||||
|     lambda: return true; | ||||
|     turn_on_action: [] | ||||
|     turn_off_action: [] | ||||
|  | ||||
| # Scenario 5: Empty names on different devices (should use device name) | ||||
| button: | ||||
|   - platform: template | ||||
|     name: "" | ||||
|     device_id: controller_1 | ||||
|     on_press: [] | ||||
|  | ||||
|   - platform: template | ||||
|     name: "" | ||||
|     device_id: controller_2 | ||||
|     on_press: [] | ||||
|  | ||||
|   - platform: template | ||||
|     name: "" | ||||
|     on_press: []  # Main device | ||||
|  | ||||
| # Scenario 6: Special characters in names | ||||
| number: | ||||
|   - platform: template | ||||
|     name: "Temperature Setpoint!" | ||||
|     device_id: controller_1 | ||||
|     min_value: 10.0 | ||||
|     max_value: 30.0 | ||||
|     step: 0.1 | ||||
|     lambda: return 21.0; | ||||
|     set_action: [] | ||||
|  | ||||
|   - platform: template | ||||
|     name: "Temperature Setpoint!" | ||||
|     device_id: controller_2 | ||||
|     min_value: 10.0 | ||||
|     max_value: 30.0 | ||||
|     step: 0.1 | ||||
|     lambda: return 22.0; | ||||
|     set_action: [] | ||||
| @@ -1,4 +1,4 @@ | ||||
| """Integration test for duplicate entity handling.""" | ||||
| """Integration test for duplicate entity handling with new validation.""" | ||||
|  | ||||
| from __future__ import annotations | ||||
|  | ||||
| @@ -11,12 +11,12 @@ from .types import APIClientConnectedFactory, RunCompiledFunction | ||||
|  | ||||
|  | ||||
| @pytest.mark.asyncio | ||||
| async def test_duplicate_entities( | ||||
| async def test_duplicate_entities_on_different_devices( | ||||
|     yaml_config: str, | ||||
|     run_compiled: RunCompiledFunction, | ||||
|     api_client_connected: APIClientConnectedFactory, | ||||
| ) -> None: | ||||
|     """Test that duplicate entity names are automatically suffixed with _2, _3, _4.""" | ||||
|     """Test that duplicate entity names are allowed on different devices.""" | ||||
|     async with run_compiled(yaml_config), api_client_connected() as client: | ||||
|         # Get device info | ||||
|         device_info = await client.device_info() | ||||
| @@ -24,14 +24,16 @@ async def test_duplicate_entities( | ||||
|  | ||||
|         # Get devices | ||||
|         devices = device_info.devices | ||||
|         assert len(devices) >= 2, f"Expected at least 2 devices, got {len(devices)}" | ||||
|         assert len(devices) >= 3, f"Expected at least 3 devices, got {len(devices)}" | ||||
|  | ||||
|         # Find our test devices | ||||
|         controller_1 = next((d for d in devices if d.name == "Controller 1"), None) | ||||
|         controller_2 = next((d for d in devices if d.name == "Controller 2"), None) | ||||
|         controller_3 = next((d for d in devices if d.name == "Controller 3"), None) | ||||
|  | ||||
|         assert controller_1 is not None, "Controller 1 device not found" | ||||
|         assert controller_2 is not None, "Controller 2 device not found" | ||||
|         assert controller_3 is not None, "Controller 3 device not found" | ||||
|  | ||||
|         # Get entity list | ||||
|         entities = await client.list_entities_services() | ||||
| @@ -48,203 +50,120 @@ async def test_duplicate_entities( | ||||
|             e for e in all_entities if e.__class__.__name__ == "TextSensorInfo" | ||||
|         ] | ||||
|         switches = [e for e in all_entities if e.__class__.__name__ == "SwitchInfo"] | ||||
|         buttons = [e for e in all_entities if e.__class__.__name__ == "ButtonInfo"] | ||||
|         numbers = [e for e in all_entities if e.__class__.__name__ == "NumberInfo"] | ||||
|  | ||||
|         # Scenario 1: Check sensors with duplicate "Temperature" names | ||||
|         # Scenario 1: Check sensors with same "Temperature" name on different devices | ||||
|         temp_sensors = [s for s in sensors if s.name == "Temperature"] | ||||
|         temp_object_ids = sorted([s.object_id for s in temp_sensors]) | ||||
|  | ||||
|         # Should have temperature, temperature_2, temperature_3, temperature_4 | ||||
|         assert len(temp_object_ids) >= 4, ( | ||||
|             f"Expected at least 4 temperature sensors, got {len(temp_object_ids)}" | ||||
|         ) | ||||
|         assert "temperature" in temp_object_ids, ( | ||||
|             "First temperature sensor should not have suffix" | ||||
|         ) | ||||
|         assert "temperature_2" in temp_object_ids, ( | ||||
|             "Second temperature sensor should be temperature_2" | ||||
|         ) | ||||
|         assert "temperature_3" in temp_object_ids, ( | ||||
|             "Third temperature sensor should be temperature_3" | ||||
|         ) | ||||
|         assert "temperature_4" in temp_object_ids, ( | ||||
|             "Fourth temperature sensor should be temperature_4" | ||||
|         assert len(temp_sensors) == 4, ( | ||||
|             f"Expected exactly 4 temperature sensors, got {len(temp_sensors)}" | ||||
|         ) | ||||
|  | ||||
|         # Scenario 2: Check device-specific sensors don't conflict | ||||
|         device_temp_sensors = [s for s in sensors if s.name == "Device Temperature"] | ||||
|         # Verify each sensor is on a different device | ||||
|         temp_device_ids = set() | ||||
|         temp_object_ids = set() | ||||
|  | ||||
|         # Group by device | ||||
|         controller_1_temps = [ | ||||
|             s | ||||
|             for s in device_temp_sensors | ||||
|             if getattr(s, "device_id", None) == controller_1.device_id | ||||
|         ] | ||||
|         controller_2_temps = [ | ||||
|             s | ||||
|             for s in device_temp_sensors | ||||
|             if getattr(s, "device_id", None) == controller_2.device_id | ||||
|         ] | ||||
|         for sensor in temp_sensors: | ||||
|             temp_device_ids.add(sensor.device_id) | ||||
|             temp_object_ids.add(sensor.object_id) | ||||
|  | ||||
|         # Controller 1 should have device_temperature, device_temperature_2, device_temperature_3 | ||||
|         c1_object_ids = sorted([s.object_id for s in controller_1_temps]) | ||||
|         assert len(c1_object_ids) >= 3, ( | ||||
|             f"Expected at least 3 sensors on controller_1, got {len(c1_object_ids)}" | ||||
|         ) | ||||
|         assert "device_temperature" in c1_object_ids, ( | ||||
|             "First device sensor should not have suffix" | ||||
|         ) | ||||
|         assert "device_temperature_2" in c1_object_ids, ( | ||||
|             "Second device sensor should be device_temperature_2" | ||||
|         ) | ||||
|         assert "device_temperature_3" in c1_object_ids, ( | ||||
|             "Third device sensor should be device_temperature_3" | ||||
|             # All should have object_id "temperature" (no suffix) | ||||
|             assert sensor.object_id == "temperature", ( | ||||
|                 f"Expected object_id 'temperature', got '{sensor.object_id}'" | ||||
|             ) | ||||
|  | ||||
|         # Should have 4 different device IDs (including None for main device) | ||||
|         assert len(temp_device_ids) == 4, ( | ||||
|             f"Temperature sensors should be on different devices, got {temp_device_ids}" | ||||
|         ) | ||||
|  | ||||
|         # Controller 2 should have only device_temperature (no suffix) | ||||
|         c2_object_ids = [s.object_id for s in controller_2_temps] | ||||
|         assert len(c2_object_ids) >= 1, ( | ||||
|             f"Expected at least 1 sensor on controller_2, got {len(c2_object_ids)}" | ||||
|         ) | ||||
|         assert "device_temperature" in c2_object_ids, ( | ||||
|             "Controller 2 sensor should not have suffix" | ||||
|         # Scenario 2: Check binary sensors "Status" on different devices | ||||
|         status_binary = [b for b in binary_sensors if b.name == "Status"] | ||||
|         assert len(status_binary) == 3, ( | ||||
|             f"Expected exactly 3 status binary sensors, got {len(status_binary)}" | ||||
|         ) | ||||
|  | ||||
|         # Scenario 3: Check binary sensors (different platform, same name) | ||||
|         # All should have object_id "status" | ||||
|         for binary in status_binary: | ||||
|             assert binary.object_id == "status", ( | ||||
|                 f"Expected object_id 'status', got '{binary.object_id}'" | ||||
|             ) | ||||
|  | ||||
|         # Scenario 3: Check that sensor and binary_sensor can have same name | ||||
|         temp_binary = [b for b in binary_sensors if b.name == "Temperature"] | ||||
|         binary_object_ids = sorted([b.object_id for b in temp_binary]) | ||||
|         assert len(temp_binary) == 1, ( | ||||
|             f"Expected exactly 1 temperature binary sensor, got {len(temp_binary)}" | ||||
|         ) | ||||
|         assert temp_binary[0].object_id == "temperature" | ||||
|  | ||||
|         # Should have temperature, temperature_2, temperature_3 (no conflict with sensor platform) | ||||
|         assert len(binary_object_ids) >= 3, ( | ||||
|             f"Expected at least 3 binary sensors, got {len(binary_object_ids)}" | ||||
|         ) | ||||
|         assert "temperature" in binary_object_ids, ( | ||||
|             "First binary sensor should not have suffix" | ||||
|         ) | ||||
|         assert "temperature_2" in binary_object_ids, ( | ||||
|             "Second binary sensor should be temperature_2" | ||||
|         ) | ||||
|         assert "temperature_3" in binary_object_ids, ( | ||||
|             "Third binary sensor should be temperature_3" | ||||
|         # Scenario 4: Check text sensors "Device Info" on different devices | ||||
|         info_text = [t for t in text_sensors if t.name == "Device Info"] | ||||
|         assert len(info_text) == 3, ( | ||||
|             f"Expected exactly 3 device info text sensors, got {len(info_text)}" | ||||
|         ) | ||||
|  | ||||
|         # Scenario 4: Check text sensors with special characters | ||||
|         status_sensors = [t for t in text_sensors if t.name == "Status Message!"] | ||||
|         status_object_ids = sorted([t.object_id for t in status_sensors]) | ||||
|         # All should have object_id "device_info" | ||||
|         for text in info_text: | ||||
|             assert text.object_id == "device_info", ( | ||||
|                 f"Expected object_id 'device_info', got '{text.object_id}'" | ||||
|             ) | ||||
|  | ||||
|         # Special characters should be sanitized to _ | ||||
|         assert len(status_object_ids) >= 3, ( | ||||
|             f"Expected at least 3 status sensors, got {len(status_object_ids)}" | ||||
|         ) | ||||
|         assert "status_message_" in status_object_ids, ( | ||||
|             "First status sensor should be status_message_" | ||||
|         ) | ||||
|         assert "status_message__2" in status_object_ids, ( | ||||
|             "Second status sensor should be status_message__2" | ||||
|         ) | ||||
|         assert "status_message__3" in status_object_ids, ( | ||||
|             "Third status sensor should be status_message__3" | ||||
|         # Scenario 5: Check switches "Power" on different devices | ||||
|         power_switches = [s for s in switches if s.name == "Power"] | ||||
|         assert len(power_switches) == 3, ( | ||||
|             f"Expected exactly 3 power switches, got {len(power_switches)}" | ||||
|         ) | ||||
|  | ||||
|         # Scenario 5: Check switches with duplicate names | ||||
|         power_switches = [s for s in switches if s.name == "Power Switch"] | ||||
|         power_object_ids = sorted([s.object_id for s in power_switches]) | ||||
|         # All should have object_id "power" | ||||
|         for switch in power_switches: | ||||
|             assert switch.object_id == "power", ( | ||||
|                 f"Expected object_id 'power', got '{switch.object_id}'" | ||||
|             ) | ||||
|  | ||||
|         # Should have power_switch, power_switch_2 | ||||
|         assert len(power_object_ids) >= 2, ( | ||||
|             f"Expected at least 2 power switches, got {len(power_object_ids)}" | ||||
|         # Scenario 6: Check empty name buttons (should use device name) | ||||
|         empty_buttons = [b for b in buttons if b.name == ""] | ||||
|         assert len(empty_buttons) == 3, ( | ||||
|             f"Expected exactly 3 empty name buttons, got {len(empty_buttons)}" | ||||
|         ) | ||||
|         assert "power_switch" in power_object_ids, ( | ||||
|             "First power switch should be power_switch" | ||||
|         ) | ||||
|         assert "power_switch_2" in power_object_ids, ( | ||||
|             "Second power switch should be power_switch_2" | ||||
|         ) | ||||
|  | ||||
|         # Scenario 6: Check empty names on main device (Issue #6953) | ||||
|         empty_binary = [b for b in binary_sensors if b.name == ""] | ||||
|         empty_binary_ids = sorted([b.object_id for b in empty_binary]) | ||||
|  | ||||
|         # Should use device name "duplicate-entities-test" (sanitized, not snake_case) | ||||
|         assert len(empty_binary_ids) >= 4, ( | ||||
|             f"Expected at least 4 empty name binary sensors, got {len(empty_binary_ids)}" | ||||
|         ) | ||||
|         assert "duplicate-entities-test" in empty_binary_ids, ( | ||||
|             "First empty binary sensor should use device name" | ||||
|         ) | ||||
|         assert "duplicate-entities-test_2" in empty_binary_ids, ( | ||||
|             "Second empty binary sensor should be duplicate-entities-test_2" | ||||
|         ) | ||||
|         assert "duplicate-entities-test_3" in empty_binary_ids, ( | ||||
|             "Third empty binary sensor should be duplicate-entities-test_3" | ||||
|         ) | ||||
|         assert "duplicate-entities-test_4" in empty_binary_ids, ( | ||||
|             "Fourth empty binary sensor should be duplicate-entities-test_4" | ||||
|         ) | ||||
|  | ||||
|         # Scenario 7: Check empty names on sub-devices (Issue #6953) | ||||
|         empty_switches = [s for s in switches if s.name == ""] | ||||
|  | ||||
|         # Group by device | ||||
|         c1_empty_switches = [ | ||||
|             s | ||||
|             for s in empty_switches | ||||
|             if getattr(s, "device_id", None) == controller_1.device_id | ||||
|         ] | ||||
|         c2_empty_switches = [ | ||||
|             s | ||||
|             for s in empty_switches | ||||
|             if getattr(s, "device_id", None) == controller_2.device_id | ||||
|         ] | ||||
|         main_empty_switches = [ | ||||
|             s | ||||
|             for s in empty_switches | ||||
|             if getattr(s, "device_id", None) | ||||
|             not in [controller_1.device_id, controller_2.device_id] | ||||
|         ] | ||||
|         c1_buttons = [b for b in empty_buttons if b.device_id == controller_1.device_id] | ||||
|         c2_buttons = [b for b in empty_buttons if b.device_id == controller_2.device_id] | ||||
|  | ||||
|         # Controller 1 empty switches should use "controller_1" | ||||
|         c1_empty_ids = sorted([s.object_id for s in c1_empty_switches]) | ||||
|         assert len(c1_empty_ids) >= 3, ( | ||||
|             f"Expected at least 3 empty switches on controller_1, got {len(c1_empty_ids)}" | ||||
|         # For main device, device_id is 0 | ||||
|         main_buttons = [b for b in empty_buttons if b.device_id == 0] | ||||
|  | ||||
|         # Check object IDs for empty name entities | ||||
|         assert len(c1_buttons) == 1 and c1_buttons[0].object_id == "controller_1" | ||||
|         assert len(c2_buttons) == 1 and c2_buttons[0].object_id == "controller_2" | ||||
|         assert ( | ||||
|             len(main_buttons) == 1 | ||||
|             and main_buttons[0].object_id == "duplicate-entities-test" | ||||
|         ) | ||||
|         assert "controller_1" in c1_empty_ids, "First should be controller_1" | ||||
|         assert "controller_1_2" in c1_empty_ids, "Second should be controller_1_2" | ||||
|         assert "controller_1_3" in c1_empty_ids, "Third should be controller_1_3" | ||||
|  | ||||
|         # Controller 2 empty switches | ||||
|         c2_empty_ids = sorted([s.object_id for s in c2_empty_switches]) | ||||
|         assert len(c2_empty_ids) >= 2, ( | ||||
|             f"Expected at least 2 empty switches on controller_2, got {len(c2_empty_ids)}" | ||||
|         # Scenario 7: Check special characters in number names | ||||
|         temp_numbers = [n for n in numbers if n.name == "Temperature Setpoint!"] | ||||
|         assert len(temp_numbers) == 2, ( | ||||
|             f"Expected exactly 2 temperature setpoint numbers, got {len(temp_numbers)}" | ||||
|         ) | ||||
|         assert "controller_2" in c2_empty_ids, "First should be controller_2" | ||||
|         assert "controller_2_2" in c2_empty_ids, "Second should be controller_2_2" | ||||
|  | ||||
|         # Main device empty switches | ||||
|         main_empty_ids = sorted([s.object_id for s in main_empty_switches]) | ||||
|         assert len(main_empty_ids) >= 3, ( | ||||
|             f"Expected at least 3 empty switches on main device, got {len(main_empty_ids)}" | ||||
|         ) | ||||
|         assert "duplicate-entities-test" in main_empty_ids | ||||
|         assert "duplicate-entities-test_2" in main_empty_ids | ||||
|         assert "duplicate-entities-test_3" in main_empty_ids | ||||
|  | ||||
|         # Scenario 8: Check "xyz" duplicates (Issue #6953) | ||||
|         xyz_switches = [s for s in switches if s.name == "xyz"] | ||||
|         xyz_ids = sorted([s.object_id for s in xyz_switches]) | ||||
|  | ||||
|         assert len(xyz_ids) >= 3, ( | ||||
|             f"Expected at least 3 xyz switches, got {len(xyz_ids)}" | ||||
|         ) | ||||
|         assert "xyz" in xyz_ids, "First xyz switch should be xyz" | ||||
|         assert "xyz_2" in xyz_ids, "Second xyz switch should be xyz_2" | ||||
|         assert "xyz_3" in xyz_ids, "Third xyz switch should be xyz_3" | ||||
|         # Special characters should be sanitized to _ in object_id | ||||
|         for number in temp_numbers: | ||||
|             assert number.object_id == "temperature_setpoint_", ( | ||||
|                 f"Expected object_id 'temperature_setpoint_', got '{number.object_id}'" | ||||
|             ) | ||||
|  | ||||
|         # Verify we can get states for all entities (ensures they're functional) | ||||
|         loop = asyncio.get_running_loop() | ||||
|         states_future: asyncio.Future[None] = loop.create_future() | ||||
|         state_count = 0 | ||||
|         expected_count = ( | ||||
|             len(sensors) + len(binary_sensors) + len(text_sensors) + len(switches) | ||||
|             len(sensors) | ||||
|             + len(binary_sensors) | ||||
|             + len(text_sensors) | ||||
|             + len(switches) | ||||
|             + len(buttons) | ||||
|             + len(numbers) | ||||
|         ) | ||||
|  | ||||
|         def on_state(state) -> None: | ||||
|   | ||||
							
								
								
									
										0
									
								
								tests/unit_tests/core/__init__.py
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										0
									
								
								tests/unit_tests/core/__init__.py
									
									
									
									
									
										Normal file
									
								
							
							
								
								
									
										33
									
								
								tests/unit_tests/core/common.py
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										33
									
								
								tests/unit_tests/core/common.py
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,33 @@ | ||||
| """Common test utilities for core unit tests.""" | ||||
|  | ||||
| from collections.abc import Callable | ||||
| from pathlib import Path | ||||
| from unittest.mock import patch | ||||
|  | ||||
| from esphome import config, yaml_util | ||||
| from esphome.config import Config | ||||
| from esphome.core import CORE | ||||
|  | ||||
|  | ||||
| def load_config_from_yaml( | ||||
|     yaml_file: Callable[[str], str], yaml_content: str | ||||
| ) -> Config | None: | ||||
|     """Load configuration from YAML content.""" | ||||
|     yaml_path = yaml_file(yaml_content) | ||||
|     parsed_yaml = yaml_util.load_yaml(yaml_path) | ||||
|  | ||||
|     # Mock yaml_util.load_yaml to return our parsed content | ||||
|     with ( | ||||
|         patch.object(yaml_util, "load_yaml", return_value=parsed_yaml), | ||||
|         patch.object(CORE, "config_path", yaml_path), | ||||
|     ): | ||||
|         return config.read_config({}) | ||||
|  | ||||
|  | ||||
| def load_config_from_fixture( | ||||
|     yaml_file: Callable[[str], str], fixture_name: str, fixtures_dir: Path | ||||
| ) -> Config | None: | ||||
|     """Load configuration from a fixture file.""" | ||||
|     fixture_path = fixtures_dir / fixture_name | ||||
|     yaml_content = fixture_path.read_text() | ||||
|     return load_config_from_yaml(yaml_file, yaml_content) | ||||
							
								
								
									
										18
									
								
								tests/unit_tests/core/conftest.py
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										18
									
								
								tests/unit_tests/core/conftest.py
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,18 @@ | ||||
| """Shared fixtures for core unit tests.""" | ||||
|  | ||||
| from collections.abc import Callable | ||||
| from pathlib import Path | ||||
|  | ||||
| import pytest | ||||
|  | ||||
|  | ||||
| @pytest.fixture | ||||
| def yaml_file(tmp_path: Path) -> Callable[[str], str]: | ||||
|     """Create a temporary YAML file for testing.""" | ||||
|  | ||||
|     def _yaml_file(content: str) -> str: | ||||
|         yaml_path = tmp_path / "test.yaml" | ||||
|         yaml_path.write_text(content) | ||||
|         return str(yaml_path) | ||||
|  | ||||
|     return _yaml_file | ||||
| @@ -3,55 +3,18 @@ | ||||
| from collections.abc import Callable | ||||
| from pathlib import Path | ||||
| from typing import Any | ||||
| from unittest.mock import patch | ||||
|  | ||||
| import pytest | ||||
|  | ||||
| from esphome import config, config_validation as cv, core, yaml_util | ||||
| from esphome.config import Config | ||||
| from esphome import config_validation as cv, core | ||||
| from esphome.const import CONF_AREA, CONF_AREAS, CONF_DEVICES | ||||
| from esphome.core import CORE | ||||
| from esphome.core.config import Area, validate_area_config | ||||
|  | ||||
| from .common import load_config_from_fixture | ||||
|  | ||||
| FIXTURES_DIR = Path(__file__).parent.parent / "fixtures" / "core" / "config" | ||||
|  | ||||
|  | ||||
| @pytest.fixture | ||||
| def yaml_file(tmp_path: Path) -> Callable[[str], str]: | ||||
|     """Create a temporary YAML file for testing.""" | ||||
|  | ||||
|     def _yaml_file(content: str) -> str: | ||||
|         yaml_path = tmp_path / "test.yaml" | ||||
|         yaml_path.write_text(content) | ||||
|         return str(yaml_path) | ||||
|  | ||||
|     return _yaml_file | ||||
|  | ||||
|  | ||||
| def load_config_from_yaml( | ||||
|     yaml_file: Callable[[str], str], yaml_content: str | ||||
| ) -> Config | None: | ||||
|     """Load configuration from YAML content.""" | ||||
|     yaml_path = yaml_file(yaml_content) | ||||
|     parsed_yaml = yaml_util.load_yaml(yaml_path) | ||||
|  | ||||
|     # Mock yaml_util.load_yaml to return our parsed content | ||||
|     with ( | ||||
|         patch.object(yaml_util, "load_yaml", return_value=parsed_yaml), | ||||
|         patch.object(CORE, "config_path", yaml_path), | ||||
|     ): | ||||
|         return config.read_config({}) | ||||
|  | ||||
|  | ||||
| def load_config_from_fixture( | ||||
|     yaml_file: Callable[[str], str], fixture_name: str | ||||
| ) -> Config | None: | ||||
|     """Load configuration from a fixture file.""" | ||||
|     fixture_path = FIXTURES_DIR / fixture_name | ||||
|     yaml_content = fixture_path.read_text() | ||||
|     return load_config_from_yaml(yaml_file, yaml_content) | ||||
|  | ||||
|  | ||||
| def test_validate_area_config_with_string() -> None: | ||||
|     """Test that string area config is converted to structured format.""" | ||||
|     result = validate_area_config("Living Room") | ||||
| @@ -82,7 +45,7 @@ def test_validate_area_config_with_dict() -> None: | ||||
|  | ||||
| def test_device_with_valid_area_id(yaml_file: Callable[[str], str]) -> None: | ||||
|     """Test that device with valid area_id works correctly.""" | ||||
|     result = load_config_from_fixture(yaml_file, "valid_area_device.yaml") | ||||
|     result = load_config_from_fixture(yaml_file, "valid_area_device.yaml", FIXTURES_DIR) | ||||
|     assert result is not None | ||||
|  | ||||
|     esphome_config = result["esphome"] | ||||
| @@ -105,7 +68,9 @@ def test_device_with_valid_area_id(yaml_file: Callable[[str], str]) -> None: | ||||
|  | ||||
| def test_multiple_areas_and_devices(yaml_file: Callable[[str], str]) -> None: | ||||
|     """Test multiple areas and devices configuration.""" | ||||
|     result = load_config_from_fixture(yaml_file, "multiple_areas_devices.yaml") | ||||
|     result = load_config_from_fixture( | ||||
|         yaml_file, "multiple_areas_devices.yaml", FIXTURES_DIR | ||||
|     ) | ||||
|     assert result is not None | ||||
|  | ||||
|     esphome_config = result["esphome"] | ||||
| @@ -141,7 +106,9 @@ def test_legacy_string_area( | ||||
|     yaml_file: Callable[[str], str], caplog: pytest.LogCaptureFixture | ||||
| ) -> None: | ||||
|     """Test legacy string area configuration with deprecation warning.""" | ||||
|     result = load_config_from_fixture(yaml_file, "legacy_string_area.yaml") | ||||
|     result = load_config_from_fixture( | ||||
|         yaml_file, "legacy_string_area.yaml", FIXTURES_DIR | ||||
|     ) | ||||
|     assert result is not None | ||||
|  | ||||
|     esphome_config = result["esphome"] | ||||
| @@ -160,7 +127,7 @@ def test_area_id_collision( | ||||
|     yaml_file: Callable[[str], str], capsys: pytest.CaptureFixture[str] | ||||
| ) -> None: | ||||
|     """Test that duplicate area IDs are detected.""" | ||||
|     result = load_config_from_fixture(yaml_file, "area_id_collision.yaml") | ||||
|     result = load_config_from_fixture(yaml_file, "area_id_collision.yaml", FIXTURES_DIR) | ||||
|     assert result is None | ||||
|  | ||||
|     # Check for the specific error message in stdout | ||||
| @@ -171,7 +138,9 @@ def test_area_id_collision( | ||||
|  | ||||
| def test_device_without_area(yaml_file: Callable[[str], str]) -> None: | ||||
|     """Test that devices without area_id work correctly.""" | ||||
|     result = load_config_from_fixture(yaml_file, "device_without_area.yaml") | ||||
|     result = load_config_from_fixture( | ||||
|         yaml_file, "device_without_area.yaml", FIXTURES_DIR | ||||
|     ) | ||||
|     assert result is not None | ||||
|  | ||||
|     esphome_config = result["esphome"] | ||||
| @@ -193,7 +162,9 @@ def test_device_with_invalid_area_id( | ||||
|     yaml_file: Callable[[str], str], capsys: pytest.CaptureFixture[str] | ||||
| ) -> None: | ||||
|     """Test that device with non-existent area_id fails validation.""" | ||||
|     result = load_config_from_fixture(yaml_file, "device_invalid_area.yaml") | ||||
|     result = load_config_from_fixture( | ||||
|         yaml_file, "device_invalid_area.yaml", FIXTURES_DIR | ||||
|     ) | ||||
|     assert result is None | ||||
|  | ||||
|     # Check for the specific error message in stdout | ||||
| @@ -208,7 +179,9 @@ def test_device_id_hash_collision( | ||||
|     yaml_file: Callable[[str], str], capsys: pytest.CaptureFixture[str] | ||||
| ) -> None: | ||||
|     """Test that device IDs with hash collisions are detected.""" | ||||
|     result = load_config_from_fixture(yaml_file, "device_id_collision.yaml") | ||||
|     result = load_config_from_fixture( | ||||
|         yaml_file, "device_id_collision.yaml", FIXTURES_DIR | ||||
|     ) | ||||
|     assert result is None | ||||
|  | ||||
|     # Check for the specific error message about hash collision | ||||
| @@ -224,7 +197,9 @@ def test_area_id_hash_collision( | ||||
|     yaml_file: Callable[[str], str], capsys: pytest.CaptureFixture[str] | ||||
| ) -> None: | ||||
|     """Test that area IDs with hash collisions are detected.""" | ||||
|     result = load_config_from_fixture(yaml_file, "area_id_hash_collision.yaml") | ||||
|     result = load_config_from_fixture( | ||||
|         yaml_file, "area_id_hash_collision.yaml", FIXTURES_DIR | ||||
|     ) | ||||
|     assert result is None | ||||
|  | ||||
|     # Check for the specific error message about hash collision | ||||
| @@ -240,7 +215,9 @@ def test_device_duplicate_id( | ||||
|     yaml_file: Callable[[str], str], capsys: pytest.CaptureFixture[str] | ||||
| ) -> None: | ||||
|     """Test that duplicate device IDs are detected by IDPassValidationStep.""" | ||||
|     result = load_config_from_fixture(yaml_file, "device_duplicate_id.yaml") | ||||
|     result = load_config_from_fixture( | ||||
|         yaml_file, "device_duplicate_id.yaml", FIXTURES_DIR | ||||
|     ) | ||||
|     assert result is None | ||||
|  | ||||
|     # Check for the specific error message from IDPassValidationStep | ||||
|   | ||||
| @@ -1,21 +1,26 @@ | ||||
| """Test get_base_entity_object_id function matches C++ behavior.""" | ||||
| 
 | ||||
| from collections.abc import Generator | ||||
| from collections.abc import Callable, Generator | ||||
| from pathlib import Path | ||||
| import re | ||||
| from typing import Any | ||||
| 
 | ||||
| import pytest | ||||
| 
 | ||||
| from esphome import entity | ||||
| from esphome.config_validation import Invalid | ||||
| from esphome.const import CONF_DEVICE_ID, CONF_DISABLED_BY_DEFAULT, CONF_ICON, CONF_NAME | ||||
| from esphome.core import CORE, ID | ||||
| from esphome.core import CORE, ID, entity_helpers | ||||
| from esphome.core.entity_helpers import get_base_entity_object_id, setup_entity | ||||
| from esphome.cpp_generator import MockObj | ||||
| from esphome.entity import get_base_entity_object_id, setup_entity | ||||
| from esphome.helpers import sanitize, snake_case | ||||
| 
 | ||||
| from .common import load_config_from_fixture | ||||
| 
 | ||||
| # Pre-compiled regex pattern for extracting object IDs from expressions | ||||
| OBJECT_ID_PATTERN = re.compile(r'\.set_object_id\(["\'](.*?)["\']\)') | ||||
| 
 | ||||
| FIXTURES_DIR = Path(__file__).parent.parent / "fixtures" / "core" / "entity_helpers" | ||||
| 
 | ||||
| 
 | ||||
| @pytest.fixture(autouse=True) | ||||
| def restore_core_state() -> Generator[None, None, None]: | ||||
| @@ -239,7 +244,7 @@ def setup_test_environment() -> Generator[list[str], None, None]: | ||||
|     CORE.friendly_name = "Test Device" | ||||
|     # Store original add function | ||||
| 
 | ||||
|     original_add = entity.add | ||||
|     original_add = entity_helpers.add | ||||
|     # Track what gets added | ||||
|     added_expressions: list[str] = [] | ||||
| 
 | ||||
| @@ -247,11 +252,11 @@ def setup_test_environment() -> Generator[list[str], None, None]: | ||||
|         added_expressions.append(str(expression)) | ||||
|         return original_add(expression) | ||||
| 
 | ||||
|     # Patch add function in entity module | ||||
|     entity.add = mock_add | ||||
|     # Patch add function in entity_helpers module | ||||
|     entity_helpers.add = mock_add | ||||
|     yield added_expressions | ||||
|     # Clean up | ||||
|     entity.add = original_add | ||||
|     entity_helpers.add = original_add | ||||
| 
 | ||||
| 
 | ||||
| def extract_object_id_from_expressions(expressions: list[str]) -> str | None: | ||||
| @@ -300,35 +305,6 @@ async def test_setup_entity_no_duplicates(setup_test_environment: list[str]) -> | ||||
|     assert object_id2 == "humidity" | ||||
| 
 | ||||
| 
 | ||||
| @pytest.mark.asyncio | ||||
| async def test_setup_entity_with_duplicates(setup_test_environment: list[str]) -> None: | ||||
|     """Test setup_entity with duplicate names.""" | ||||
| 
 | ||||
|     added_expressions = setup_test_environment | ||||
| 
 | ||||
|     # Create mock entities | ||||
|     entities = [MockObj(f"sensor{i}") for i in range(4)] | ||||
| 
 | ||||
|     # Set up entities with same name | ||||
|     config = { | ||||
|         CONF_NAME: "Temperature", | ||||
|         CONF_DISABLED_BY_DEFAULT: False, | ||||
|     } | ||||
| 
 | ||||
|     object_ids: list[str] = [] | ||||
|     for var in entities: | ||||
|         added_expressions.clear() | ||||
|         await setup_entity(var, config, "sensor") | ||||
|         object_id = extract_object_id_from_expressions(added_expressions) | ||||
|         object_ids.append(object_id) | ||||
| 
 | ||||
|     # Check that object IDs were set with proper suffixes | ||||
|     assert object_ids[0] == "temperature" | ||||
|     assert object_ids[1] == "temperature_2" | ||||
|     assert object_ids[2] == "temperature_3" | ||||
|     assert object_ids[3] == "temperature_4" | ||||
| 
 | ||||
| 
 | ||||
| @pytest.mark.asyncio | ||||
| async def test_setup_entity_different_platforms( | ||||
|     setup_test_environment: list[str], | ||||
| @@ -369,17 +345,17 @@ async def test_setup_entity_different_platforms( | ||||
| def mock_get_variable() -> Generator[dict[ID, MockObj], None, None]: | ||||
|     """Mock get_variable to return test devices.""" | ||||
|     devices = {} | ||||
|     original_get_variable = entity.get_variable | ||||
|     original_get_variable = entity_helpers.get_variable | ||||
| 
 | ||||
|     async def _mock_get_variable(device_id: ID) -> MockObj: | ||||
|         if device_id in devices: | ||||
|             return devices[device_id] | ||||
|         return await original_get_variable(device_id) | ||||
| 
 | ||||
|     entity.get_variable = _mock_get_variable | ||||
|     entity_helpers.get_variable = _mock_get_variable | ||||
|     yield devices | ||||
|     # Clean up | ||||
|     entity.get_variable = original_get_variable | ||||
|     entity_helpers.get_variable = original_get_variable | ||||
| 
 | ||||
| 
 | ||||
| @pytest.mark.asyncio | ||||
| @@ -448,34 +424,6 @@ async def test_setup_entity_empty_name(setup_test_environment: list[str]) -> Non | ||||
|     assert object_id == "test_device" | ||||
| 
 | ||||
| 
 | ||||
| @pytest.mark.asyncio | ||||
| async def test_setup_entity_empty_name_duplicates( | ||||
|     setup_test_environment: list[str], | ||||
| ) -> None: | ||||
|     """Test setup_entity with multiple empty names.""" | ||||
| 
 | ||||
|     added_expressions = setup_test_environment | ||||
| 
 | ||||
|     entities = [MockObj(f"sensor{i}") for i in range(3)] | ||||
| 
 | ||||
|     config = { | ||||
|         CONF_NAME: "", | ||||
|         CONF_DISABLED_BY_DEFAULT: False, | ||||
|     } | ||||
| 
 | ||||
|     object_ids: list[str] = [] | ||||
|     for var in entities: | ||||
|         added_expressions.clear() | ||||
|         await setup_entity(var, config, "sensor") | ||||
|         object_id = extract_object_id_from_expressions(added_expressions) | ||||
|         object_ids.append(object_id) | ||||
| 
 | ||||
|     # Should use device name with suffixes | ||||
|     assert object_ids[0] == "test_device" | ||||
|     assert object_ids[1] == "test_device_2" | ||||
|     assert object_ids[2] == "test_device_3" | ||||
| 
 | ||||
| 
 | ||||
| @pytest.mark.asyncio | ||||
| async def test_setup_entity_special_characters( | ||||
|     setup_test_environment: list[str], | ||||
| @@ -484,24 +432,18 @@ async def test_setup_entity_special_characters( | ||||
| 
 | ||||
|     added_expressions = setup_test_environment | ||||
| 
 | ||||
|     entities = [MockObj(f"sensor{i}") for i in range(3)] | ||||
|     var = MockObj("sensor1") | ||||
| 
 | ||||
|     config = { | ||||
|         CONF_NAME: "Temperature Sensor!", | ||||
|         CONF_DISABLED_BY_DEFAULT: False, | ||||
|     } | ||||
| 
 | ||||
|     object_ids: list[str] = [] | ||||
|     for var in entities: | ||||
|         added_expressions.clear() | ||||
|         await setup_entity(var, config, "sensor") | ||||
|         object_id = extract_object_id_from_expressions(added_expressions) | ||||
|         object_ids.append(object_id) | ||||
|     await setup_entity(var, config, "sensor") | ||||
|     object_id = extract_object_id_from_expressions(added_expressions) | ||||
| 
 | ||||
|     # Special characters should be sanitized | ||||
|     assert object_ids[0] == "temperature_sensor_" | ||||
|     assert object_ids[1] == "temperature_sensor__2" | ||||
|     assert object_ids[2] == "temperature_sensor__3" | ||||
|     assert object_id == "temperature_sensor_" | ||||
| 
 | ||||
| 
 | ||||
| @pytest.mark.asyncio | ||||
| @@ -549,48 +491,105 @@ async def test_setup_entity_disabled_by_default( | ||||
|     ) | ||||
| 
 | ||||
| 
 | ||||
| @pytest.mark.asyncio | ||||
| async def test_setup_entity_mixed_duplicates(setup_test_environment: list[str]) -> None: | ||||
|     """Test complex duplicate scenario with multiple platforms and devices.""" | ||||
| def test_entity_duplicate_validator() -> None: | ||||
|     """Test the entity_duplicate_validator function.""" | ||||
|     from esphome.core.entity_helpers import entity_duplicate_validator | ||||
| 
 | ||||
|     added_expressions = setup_test_environment | ||||
|     # Reset CORE unique_ids for clean test | ||||
|     CORE.unique_ids.clear() | ||||
| 
 | ||||
|     # Track results | ||||
|     results: list[tuple[str, str]] = [] | ||||
|     # Create validator for sensor platform | ||||
|     validator = entity_duplicate_validator("sensor") | ||||
| 
 | ||||
|     # 3 sensors named "Status" | ||||
|     for i in range(3): | ||||
|         added_expressions.clear() | ||||
|         var = MockObj(f"sensor_status_{i}") | ||||
|         await setup_entity( | ||||
|             var, {CONF_NAME: "Status", CONF_DISABLED_BY_DEFAULT: False}, "sensor" | ||||
|         ) | ||||
|         object_id = extract_object_id_from_expressions(added_expressions) | ||||
|         results.append(("sensor", object_id)) | ||||
|     # First entity should pass | ||||
|     config1 = {CONF_NAME: "Temperature"} | ||||
|     validated1 = validator(config1) | ||||
|     assert validated1 == config1 | ||||
|     assert ("", "sensor", "temperature") in CORE.unique_ids | ||||
| 
 | ||||
|     # 2 binary_sensors named "Status" | ||||
|     for i in range(2): | ||||
|         added_expressions.clear() | ||||
|         var = MockObj(f"binary_sensor_status_{i}") | ||||
|         await setup_entity( | ||||
|             var, {CONF_NAME: "Status", CONF_DISABLED_BY_DEFAULT: False}, "binary_sensor" | ||||
|         ) | ||||
|         object_id = extract_object_id_from_expressions(added_expressions) | ||||
|         results.append(("binary_sensor", object_id)) | ||||
|     # Second entity with different name should pass | ||||
|     config2 = {CONF_NAME: "Humidity"} | ||||
|     validated2 = validator(config2) | ||||
|     assert validated2 == config2 | ||||
|     assert ("", "sensor", "humidity") in CORE.unique_ids | ||||
| 
 | ||||
|     # 1 text_sensor named "Status" | ||||
|     added_expressions.clear() | ||||
|     var = MockObj("text_sensor_status") | ||||
|     await setup_entity( | ||||
|         var, {CONF_NAME: "Status", CONF_DISABLED_BY_DEFAULT: False}, "text_sensor" | ||||
|     # Duplicate entity should fail | ||||
|     config3 = {CONF_NAME: "Temperature"} | ||||
|     with pytest.raises( | ||||
|         Invalid, match=r"Duplicate sensor entity with name 'Temperature' found" | ||||
|     ): | ||||
|         validator(config3) | ||||
| 
 | ||||
| 
 | ||||
| def test_entity_duplicate_validator_with_devices() -> None: | ||||
|     """Test entity_duplicate_validator with devices.""" | ||||
|     from esphome.core.entity_helpers import entity_duplicate_validator | ||||
| 
 | ||||
|     # Reset CORE unique_ids for clean test | ||||
|     CORE.unique_ids.clear() | ||||
| 
 | ||||
|     # Create validator for sensor platform | ||||
|     validator = entity_duplicate_validator("sensor") | ||||
| 
 | ||||
|     # Create mock device IDs | ||||
|     device1 = ID("device1", type="Device") | ||||
|     device2 = ID("device2", type="Device") | ||||
| 
 | ||||
|     # Same name on different devices should pass | ||||
|     config1 = {CONF_NAME: "Temperature", CONF_DEVICE_ID: device1} | ||||
|     validated1 = validator(config1) | ||||
|     assert validated1 == config1 | ||||
|     assert ("device1", "sensor", "temperature") in CORE.unique_ids | ||||
| 
 | ||||
|     config2 = {CONF_NAME: "Temperature", CONF_DEVICE_ID: device2} | ||||
|     validated2 = validator(config2) | ||||
|     assert validated2 == config2 | ||||
|     assert ("device2", "sensor", "temperature") in CORE.unique_ids | ||||
| 
 | ||||
|     # Duplicate on same device should fail | ||||
|     config3 = {CONF_NAME: "Temperature", CONF_DEVICE_ID: device1} | ||||
|     with pytest.raises( | ||||
|         Invalid, | ||||
|         match=r"Duplicate sensor entity with name 'Temperature' found on device 'device1'", | ||||
|     ): | ||||
|         validator(config3) | ||||
| 
 | ||||
| 
 | ||||
| def test_duplicate_entity_yaml_validation( | ||||
|     yaml_file: Callable[[str], str], capsys: pytest.CaptureFixture[str] | ||||
| ) -> None: | ||||
|     """Test that duplicate entity names are caught during YAML config validation.""" | ||||
|     result = load_config_from_fixture(yaml_file, "duplicate_entity.yaml", FIXTURES_DIR) | ||||
|     assert result is None | ||||
| 
 | ||||
|     # Check for the duplicate entity error message | ||||
|     captured = capsys.readouterr() | ||||
|     assert "Duplicate sensor entity with name 'Temperature' found" in captured.out | ||||
| 
 | ||||
| 
 | ||||
| def test_duplicate_entity_with_devices_yaml_validation( | ||||
|     yaml_file: Callable[[str], str], capsys: pytest.CaptureFixture[str] | ||||
| ) -> None: | ||||
|     """Test duplicate entity validation with devices.""" | ||||
|     result = load_config_from_fixture( | ||||
|         yaml_file, "duplicate_entity_with_devices.yaml", FIXTURES_DIR | ||||
|     ) | ||||
|     object_id = extract_object_id_from_expressions(added_expressions) | ||||
|     results.append(("text_sensor", object_id)) | ||||
|     assert result is None | ||||
| 
 | ||||
|     # Check results - each platform has its own namespace | ||||
|     assert results[0] == ("sensor", "status")  # sensor | ||||
|     assert results[1] == ("sensor", "status_2")  # sensor | ||||
|     assert results[2] == ("sensor", "status_3")  # sensor | ||||
|     assert results[3] == ("binary_sensor", "status")  # binary_sensor (new namespace) | ||||
|     assert results[4] == ("binary_sensor", "status_2")  # binary_sensor | ||||
|     assert results[5] == ("text_sensor", "status")  # text_sensor (new namespace) | ||||
|     # Check for the duplicate entity error message with device | ||||
|     captured = capsys.readouterr() | ||||
|     assert ( | ||||
|         "Duplicate sensor entity with name 'Temperature' found on device 'device1'" | ||||
|         in captured.out | ||||
|     ) | ||||
| 
 | ||||
| 
 | ||||
| def test_entity_different_platforms_yaml_validation( | ||||
|     yaml_file: Callable[[str], str], | ||||
| ) -> None: | ||||
|     """Test that same entity name on different platforms is allowed.""" | ||||
|     result = load_config_from_fixture( | ||||
|         yaml_file, "entity_different_platforms.yaml", FIXTURES_DIR | ||||
|     ) | ||||
|     # This should succeed | ||||
|     assert result is not None | ||||
| @@ -0,0 +1,13 @@ | ||||
| esphome: | ||||
|   name: test-duplicate | ||||
|  | ||||
| esp32: | ||||
|   board: esp32dev | ||||
|  | ||||
| sensor: | ||||
|   - platform: template | ||||
|     name: "Temperature" | ||||
|     lambda: return 21.0; | ||||
|   - platform: template | ||||
|     name: "Temperature"  # Duplicate - should fail | ||||
|     lambda: return 22.0; | ||||
| @@ -0,0 +1,26 @@ | ||||
| esphome: | ||||
|   name: test-duplicate-devices | ||||
|   devices: | ||||
|     - id: device1 | ||||
|       name: "Device 1" | ||||
|     - id: device2 | ||||
|       name: "Device 2" | ||||
|  | ||||
| esp32: | ||||
|   board: esp32dev | ||||
|  | ||||
| sensor: | ||||
|   # Same name on different devices - should pass | ||||
|   - platform: template | ||||
|     device_id: device1 | ||||
|     name: "Temperature" | ||||
|     lambda: return 21.0; | ||||
|   - platform: template | ||||
|     device_id: device2 | ||||
|     name: "Temperature" | ||||
|     lambda: return 22.0; | ||||
|   # Duplicate on same device - should fail | ||||
|   - platform: template | ||||
|     device_id: device1 | ||||
|     name: "Temperature" | ||||
|     lambda: return 23.0; | ||||
| @@ -0,0 +1,20 @@ | ||||
| esphome: | ||||
|   name: test-different-platforms | ||||
|  | ||||
| esp32: | ||||
|   board: esp32dev | ||||
|  | ||||
| sensor: | ||||
|   - platform: template | ||||
|     name: "Status" | ||||
|     lambda: return 1.0; | ||||
|  | ||||
| binary_sensor: | ||||
|   - platform: template | ||||
|     name: "Status"  # Same name, different platform - should pass | ||||
|     lambda: return true; | ||||
|  | ||||
| text_sensor: | ||||
|   - platform: template | ||||
|     name: "Status"  # Same name, different platform - should pass | ||||
|     lambda: return {"OK"}; | ||||
		Reference in New Issue
	
	Block a user