mirror of
https://github.com/esphome/esphome.git
synced 2025-09-02 11:22:24 +01:00
[API] Sub devices and areas (#8544)
Co-authored-by: J. Nick Koston <nick@koston.org> Co-authored-by: Jesse Hills <3060199+jesserockz@users.noreply.github.com> Co-authored-by: J. Nick Koston <nick+github@koston.org>
This commit is contained in:
@@ -14,6 +14,8 @@ import sys
|
||||
|
||||
import pytest
|
||||
|
||||
from esphome.core import CORE
|
||||
|
||||
here = Path(__file__).parent
|
||||
|
||||
# Configure location of package root
|
||||
@@ -21,6 +23,13 @@ package_root = here.parent.parent
|
||||
sys.path.insert(0, package_root.as_posix())
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def reset_core():
|
||||
"""Reset CORE after each test."""
|
||||
yield
|
||||
CORE.reset()
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def fixture_path() -> Path:
|
||||
"""
|
||||
|
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
|
225
tests/unit_tests/core/test_config.py
Normal file
225
tests/unit_tests/core/test_config.py
Normal file
@@ -0,0 +1,225 @@
|
||||
"""Unit tests for core config functionality including areas and devices."""
|
||||
|
||||
from collections.abc import Callable
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
import pytest
|
||||
|
||||
from esphome import config_validation as cv, core
|
||||
from esphome.const import CONF_AREA, CONF_AREAS, CONF_DEVICES
|
||||
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"
|
||||
|
||||
|
||||
def test_validate_area_config_with_string() -> None:
|
||||
"""Test that string area config is converted to structured format."""
|
||||
result = validate_area_config("Living Room")
|
||||
|
||||
assert isinstance(result, dict)
|
||||
assert "id" in result
|
||||
assert "name" in result
|
||||
assert result["name"] == "Living Room"
|
||||
assert isinstance(result["id"], core.ID)
|
||||
assert result["id"].is_declaration
|
||||
assert not result["id"].is_manual
|
||||
|
||||
|
||||
def test_validate_area_config_with_dict() -> None:
|
||||
"""Test that structured area config passes through unchanged."""
|
||||
area_id = cv.declare_id(Area)("test_area")
|
||||
input_config: dict[str, Any] = {
|
||||
"id": area_id,
|
||||
"name": "Test Area",
|
||||
}
|
||||
|
||||
result = validate_area_config(input_config)
|
||||
|
||||
assert result == input_config
|
||||
assert result["id"] == area_id
|
||||
assert result["name"] == "Test Area"
|
||||
|
||||
|
||||
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", FIXTURES_DIR)
|
||||
assert result is not None
|
||||
|
||||
esphome_config = result["esphome"]
|
||||
|
||||
# Verify areas were parsed correctly
|
||||
assert CONF_AREAS in esphome_config
|
||||
areas = esphome_config[CONF_AREAS]
|
||||
assert len(areas) == 1
|
||||
assert areas[0]["id"].id == "bedroom_area"
|
||||
assert areas[0]["name"] == "Bedroom"
|
||||
|
||||
# Verify devices were parsed correctly
|
||||
assert CONF_DEVICES in esphome_config
|
||||
devices = esphome_config[CONF_DEVICES]
|
||||
assert len(devices) == 1
|
||||
assert devices[0]["id"].id == "test_device"
|
||||
assert devices[0]["name"] == "Test Device"
|
||||
assert devices[0]["area_id"].id == "bedroom_area"
|
||||
|
||||
|
||||
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", FIXTURES_DIR
|
||||
)
|
||||
assert result is not None
|
||||
|
||||
esphome_config = result["esphome"]
|
||||
|
||||
# Verify main area
|
||||
assert CONF_AREA in esphome_config
|
||||
main_area = esphome_config[CONF_AREA]
|
||||
assert main_area["id"].id == "main_area"
|
||||
assert main_area["name"] == "Main Area"
|
||||
|
||||
# Verify additional areas
|
||||
assert CONF_AREAS in esphome_config
|
||||
areas = esphome_config[CONF_AREAS]
|
||||
assert len(areas) == 2
|
||||
area_ids = {area["id"].id for area in areas}
|
||||
assert area_ids == {"area1", "area2"}
|
||||
|
||||
# Verify devices
|
||||
assert CONF_DEVICES in esphome_config
|
||||
devices = esphome_config[CONF_DEVICES]
|
||||
assert len(devices) == 3
|
||||
|
||||
# Check device-area associations
|
||||
device_area_map = {dev["id"].id: dev["area_id"].id for dev in devices}
|
||||
assert device_area_map == {
|
||||
"device1": "main_area",
|
||||
"device2": "area1",
|
||||
"device3": "area2",
|
||||
}
|
||||
|
||||
|
||||
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", FIXTURES_DIR
|
||||
)
|
||||
assert result is not None
|
||||
|
||||
esphome_config = result["esphome"]
|
||||
|
||||
# Verify the string was converted to structured format
|
||||
assert CONF_AREA in esphome_config
|
||||
area = esphome_config[CONF_AREA]
|
||||
assert isinstance(area, dict)
|
||||
assert area["name"] == "Living Room"
|
||||
assert isinstance(area["id"], core.ID)
|
||||
assert area["id"].is_declaration
|
||||
assert not area["id"].is_manual
|
||||
|
||||
|
||||
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", FIXTURES_DIR)
|
||||
assert result is None
|
||||
|
||||
# Check for the specific error message in stdout
|
||||
captured = capsys.readouterr()
|
||||
# Exact duplicates are now caught by IDPassValidationStep
|
||||
assert "ID duplicate_id redefined! Check esphome->area->id." in captured.out
|
||||
|
||||
|
||||
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", FIXTURES_DIR
|
||||
)
|
||||
assert result is not None
|
||||
|
||||
esphome_config = result["esphome"]
|
||||
|
||||
# Verify device was parsed
|
||||
assert CONF_DEVICES in esphome_config
|
||||
devices = esphome_config[CONF_DEVICES]
|
||||
assert len(devices) == 1
|
||||
|
||||
device = devices[0]
|
||||
assert device["id"].id == "test_device"
|
||||
assert device["name"] == "Test Device"
|
||||
|
||||
# Verify no area_id is present
|
||||
assert "area_id" not in device
|
||||
|
||||
|
||||
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", FIXTURES_DIR
|
||||
)
|
||||
assert result is None
|
||||
|
||||
# Check for the specific error message in stdout
|
||||
captured = capsys.readouterr()
|
||||
assert (
|
||||
"Couldn't find ID 'nonexistent_area'. Please check you have defined an ID with that name in your configuration."
|
||||
in captured.out
|
||||
)
|
||||
|
||||
|
||||
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", FIXTURES_DIR
|
||||
)
|
||||
assert result is None
|
||||
|
||||
# Check for the specific error message about hash collision
|
||||
captured = capsys.readouterr()
|
||||
# The error message shows the ID that collides and includes the hash value
|
||||
assert (
|
||||
"Device ID 'd6ka' with hash 3082558663 collides with existing device ID 'test_2258'"
|
||||
in captured.out
|
||||
)
|
||||
|
||||
|
||||
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", FIXTURES_DIR
|
||||
)
|
||||
assert result is None
|
||||
|
||||
# Check for the specific error message about hash collision
|
||||
captured = capsys.readouterr()
|
||||
# The error message shows the ID that collides and includes the hash value
|
||||
assert (
|
||||
"Area ID 'd6ka' with hash 3082558663 collides with existing area ID 'test_2258'"
|
||||
in captured.out
|
||||
)
|
||||
|
||||
|
||||
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", FIXTURES_DIR
|
||||
)
|
||||
assert result is None
|
||||
|
||||
# Check for the specific error message from IDPassValidationStep
|
||||
captured = capsys.readouterr()
|
||||
assert "ID duplicate_device redefined!" in captured.out
|
595
tests/unit_tests/core/test_entity_helpers.py
Normal file
595
tests/unit_tests/core/test_entity_helpers.py
Normal file
@@ -0,0 +1,595 @@
|
||||
"""Test get_base_entity_object_id function matches C++ behavior."""
|
||||
|
||||
from collections.abc import Callable, Generator
|
||||
from pathlib import Path
|
||||
import re
|
||||
from typing import Any
|
||||
|
||||
import pytest
|
||||
|
||||
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, entity_helpers
|
||||
from esphome.core.entity_helpers import get_base_entity_object_id, setup_entity
|
||||
from esphome.cpp_generator import MockObj
|
||||
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]:
|
||||
"""Save and restore CORE state for tests."""
|
||||
original_name = CORE.name
|
||||
original_friendly_name = CORE.friendly_name
|
||||
yield
|
||||
CORE.name = original_name
|
||||
CORE.friendly_name = original_friendly_name
|
||||
|
||||
|
||||
def test_with_entity_name() -> None:
|
||||
"""Test when entity has its own name - should use entity name."""
|
||||
# Simple name
|
||||
assert get_base_entity_object_id("Temperature Sensor", None) == "temperature_sensor"
|
||||
assert (
|
||||
get_base_entity_object_id("Temperature Sensor", "Device Name")
|
||||
== "temperature_sensor"
|
||||
)
|
||||
# Even with device name, entity name takes precedence
|
||||
assert (
|
||||
get_base_entity_object_id("Temperature Sensor", "Device Name", "Sub Device")
|
||||
== "temperature_sensor"
|
||||
)
|
||||
|
||||
# Name with special characters
|
||||
assert (
|
||||
get_base_entity_object_id("Temp!@#$%^&*()Sensor", None)
|
||||
== "temp__________sensor"
|
||||
)
|
||||
assert get_base_entity_object_id("Temp-Sensor_123", None) == "temp-sensor_123"
|
||||
|
||||
# Already snake_case
|
||||
assert get_base_entity_object_id("temperature_sensor", None) == "temperature_sensor"
|
||||
|
||||
# Mixed case
|
||||
assert get_base_entity_object_id("TemperatureSensor", None) == "temperaturesensor"
|
||||
assert get_base_entity_object_id("TEMPERATURE SENSOR", None) == "temperature_sensor"
|
||||
|
||||
|
||||
def test_empty_name_with_device_name() -> None:
|
||||
"""Test when entity has empty name and is on a sub-device - should use device name."""
|
||||
# C++ behavior: when has_own_name is false and device is set, uses device->get_name()
|
||||
assert (
|
||||
get_base_entity_object_id("", "Friendly Device", "Sub Device 1")
|
||||
== "sub_device_1"
|
||||
)
|
||||
assert (
|
||||
get_base_entity_object_id("", "Kitchen Controller", "controller_1")
|
||||
== "controller_1"
|
||||
)
|
||||
assert get_base_entity_object_id("", None, "Test-Device_123") == "test-device_123"
|
||||
|
||||
|
||||
def test_empty_name_with_friendly_name() -> None:
|
||||
"""Test when entity has empty name and no device - should use friendly name."""
|
||||
# C++ behavior: when has_own_name is false, uses App.get_friendly_name()
|
||||
assert get_base_entity_object_id("", "Friendly Device") == "friendly_device"
|
||||
assert get_base_entity_object_id("", "Kitchen Controller") == "kitchen_controller"
|
||||
assert get_base_entity_object_id("", "Test-Device_123") == "test-device_123"
|
||||
|
||||
# Special characters in friendly name
|
||||
assert get_base_entity_object_id("", "Device!@#$%") == "device_____"
|
||||
|
||||
|
||||
def test_empty_name_no_friendly_name() -> None:
|
||||
"""Test when entity has empty name and no friendly name - should use device name."""
|
||||
# Test with CORE.name set
|
||||
CORE.name = "device-name"
|
||||
assert get_base_entity_object_id("", None) == "device-name"
|
||||
|
||||
CORE.name = "Test Device"
|
||||
assert get_base_entity_object_id("", None) == "test_device"
|
||||
|
||||
|
||||
def test_edge_cases() -> None:
|
||||
"""Test edge cases."""
|
||||
# Only spaces
|
||||
assert get_base_entity_object_id(" ", None) == "___"
|
||||
|
||||
# Unicode characters (should be replaced)
|
||||
assert get_base_entity_object_id("Température", None) == "temp_rature"
|
||||
assert get_base_entity_object_id("测试", None) == "__"
|
||||
|
||||
# Empty string with empty friendly name (empty friendly name is treated as None)
|
||||
# Falls back to CORE.name
|
||||
CORE.name = "device"
|
||||
assert get_base_entity_object_id("", "") == "device"
|
||||
|
||||
# Very long name (should work fine)
|
||||
long_name = "a" * 100 + " " + "b" * 100
|
||||
expected = "a" * 100 + "_" + "b" * 100
|
||||
assert get_base_entity_object_id(long_name, None) == expected
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("name", "expected"),
|
||||
[
|
||||
("Temperature Sensor", "temperature_sensor"),
|
||||
("Living Room Light", "living_room_light"),
|
||||
("Test-Device_123", "test-device_123"),
|
||||
("Special!@#Chars", "special___chars"),
|
||||
("UPPERCASE NAME", "uppercase_name"),
|
||||
("lowercase name", "lowercase_name"),
|
||||
("Mixed Case Name", "mixed_case_name"),
|
||||
(" Spaces ", "___spaces___"),
|
||||
],
|
||||
)
|
||||
def test_matches_cpp_helpers(name: str, expected: str) -> None:
|
||||
"""Test that the logic matches using snake_case and sanitize directly."""
|
||||
# For non-empty names, verify our function produces same result as direct snake_case + sanitize
|
||||
assert get_base_entity_object_id(name, None) == sanitize(snake_case(name))
|
||||
assert get_base_entity_object_id(name, None) == expected
|
||||
|
||||
|
||||
def test_empty_name_fallback() -> None:
|
||||
"""Test empty name handling which falls back to friendly_name or CORE.name."""
|
||||
# Empty name is handled specially - it doesn't just use sanitize(snake_case(""))
|
||||
# Instead it falls back to friendly_name or CORE.name
|
||||
assert sanitize(snake_case("")) == "" # Direct conversion gives empty string
|
||||
# But our function returns a fallback
|
||||
CORE.name = "device"
|
||||
assert get_base_entity_object_id("", None) == "device" # Uses device name
|
||||
|
||||
|
||||
def test_name_add_mac_suffix_behavior() -> None:
|
||||
"""Test behavior related to name_add_mac_suffix.
|
||||
|
||||
In C++, when name_add_mac_suffix is enabled and entity has no name,
|
||||
get_object_id() returns str_sanitize(str_snake_case(App.get_friendly_name()))
|
||||
dynamically. Our function always returns the same result since we're
|
||||
calculating the base for duplicate tracking.
|
||||
"""
|
||||
# The function should always return the same result regardless of
|
||||
# name_add_mac_suffix setting, as we're calculating the base object_id
|
||||
assert get_base_entity_object_id("", "Test Device") == "test_device"
|
||||
assert get_base_entity_object_id("Entity Name", "Test Device") == "entity_name"
|
||||
|
||||
|
||||
def test_priority_order() -> None:
|
||||
"""Test the priority order: entity name > device name > friendly name > CORE.name."""
|
||||
CORE.name = "core-device"
|
||||
|
||||
# 1. Entity name has highest priority
|
||||
assert (
|
||||
get_base_entity_object_id("Entity Name", "Friendly Name", "Device Name")
|
||||
== "entity_name"
|
||||
)
|
||||
|
||||
# 2. Device name is next priority (when entity name is empty)
|
||||
assert (
|
||||
get_base_entity_object_id("", "Friendly Name", "Device Name") == "device_name"
|
||||
)
|
||||
|
||||
# 3. Friendly name is next (when entity and device names are empty)
|
||||
assert get_base_entity_object_id("", "Friendly Name", None) == "friendly_name"
|
||||
|
||||
# 4. CORE.name is last resort
|
||||
assert get_base_entity_object_id("", None, None) == "core-device"
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("name", "friendly_name", "device_name", "expected"),
|
||||
[
|
||||
# name, friendly_name, device_name, expected
|
||||
("Living Room Light", None, None, "living_room_light"),
|
||||
("", "Kitchen Controller", None, "kitchen_controller"),
|
||||
(
|
||||
"",
|
||||
"ESP32 Device",
|
||||
"controller_1",
|
||||
"controller_1",
|
||||
), # Device name takes precedence
|
||||
("GPIO2 Button", None, None, "gpio2_button"),
|
||||
("WiFi Signal", "My Device", None, "wifi_signal"),
|
||||
("", None, "esp32_node", "esp32_node"),
|
||||
("Front Door Sensor", "Home Assistant", "door_controller", "front_door_sensor"),
|
||||
],
|
||||
)
|
||||
def test_real_world_examples(
|
||||
name: str, friendly_name: str | None, device_name: str | None, expected: str
|
||||
) -> None:
|
||||
"""Test real-world entity naming scenarios."""
|
||||
result = get_base_entity_object_id(name, friendly_name, device_name)
|
||||
assert result == expected
|
||||
|
||||
|
||||
def test_issue_6953_scenarios() -> None:
|
||||
"""Test specific scenarios from issue #6953."""
|
||||
# Scenario 1: Multiple empty names on main device with name_add_mac_suffix
|
||||
# The Python code calculates the base, C++ might append MAC suffix dynamically
|
||||
CORE.name = "device-name"
|
||||
CORE.friendly_name = "Friendly Device"
|
||||
|
||||
# All empty names should resolve to same base
|
||||
assert get_base_entity_object_id("", CORE.friendly_name) == "friendly_device"
|
||||
assert get_base_entity_object_id("", CORE.friendly_name) == "friendly_device"
|
||||
assert get_base_entity_object_id("", CORE.friendly_name) == "friendly_device"
|
||||
|
||||
# Scenario 2: Empty names on sub-devices
|
||||
assert (
|
||||
get_base_entity_object_id("", "Main Device", "controller_1") == "controller_1"
|
||||
)
|
||||
assert (
|
||||
get_base_entity_object_id("", "Main Device", "controller_2") == "controller_2"
|
||||
)
|
||||
|
||||
# Scenario 3: xyz duplicates
|
||||
assert get_base_entity_object_id("xyz", None) == "xyz"
|
||||
assert get_base_entity_object_id("xyz", "Device") == "xyz"
|
||||
|
||||
|
||||
# Tests for setup_entity function
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def setup_test_environment() -> Generator[list[str], None, None]:
|
||||
"""Set up test environment for setup_entity tests."""
|
||||
# Set CORE state for tests
|
||||
CORE.name = "test-device"
|
||||
CORE.friendly_name = "Test Device"
|
||||
# Store original add function
|
||||
|
||||
original_add = entity_helpers.add
|
||||
# Track what gets added
|
||||
added_expressions: list[str] = []
|
||||
|
||||
def mock_add(expression: Any) -> Any:
|
||||
added_expressions.append(str(expression))
|
||||
return original_add(expression)
|
||||
|
||||
# Patch add function in entity_helpers module
|
||||
entity_helpers.add = mock_add
|
||||
yield added_expressions
|
||||
# Clean up
|
||||
entity_helpers.add = original_add
|
||||
|
||||
|
||||
def extract_object_id_from_expressions(expressions: list[str]) -> str | None:
|
||||
"""Extract the object ID that was set from the generated expressions."""
|
||||
for expr in expressions:
|
||||
# Look for set_object_id calls with regex to handle various formats
|
||||
# Matches: var.set_object_id("temperature_2") or var.set_object_id('temperature_2')
|
||||
if match := OBJECT_ID_PATTERN.search(expr):
|
||||
return match.group(1)
|
||||
return None
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_setup_entity_no_duplicates(setup_test_environment: list[str]) -> None:
|
||||
"""Test setup_entity with unique names."""
|
||||
|
||||
added_expressions = setup_test_environment
|
||||
|
||||
# Create mock entities
|
||||
var1 = MockObj("sensor1")
|
||||
var2 = MockObj("sensor2")
|
||||
|
||||
# Set up first entity
|
||||
config1 = {
|
||||
CONF_NAME: "Temperature",
|
||||
CONF_DISABLED_BY_DEFAULT: False,
|
||||
}
|
||||
await setup_entity(var1, config1, "sensor")
|
||||
|
||||
# Get object ID from first entity
|
||||
object_id1 = extract_object_id_from_expressions(added_expressions)
|
||||
assert object_id1 == "temperature"
|
||||
|
||||
# Clear for next entity
|
||||
added_expressions.clear()
|
||||
|
||||
# Set up second entity with different name
|
||||
config2 = {
|
||||
CONF_NAME: "Humidity",
|
||||
CONF_DISABLED_BY_DEFAULT: False,
|
||||
}
|
||||
await setup_entity(var2, config2, "sensor")
|
||||
|
||||
# Get object ID from second entity
|
||||
object_id2 = extract_object_id_from_expressions(added_expressions)
|
||||
assert object_id2 == "humidity"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_setup_entity_different_platforms(
|
||||
setup_test_environment: list[str],
|
||||
) -> None:
|
||||
"""Test that same name on different platforms doesn't conflict."""
|
||||
|
||||
added_expressions = setup_test_environment
|
||||
|
||||
# Create mock entities
|
||||
sensor = MockObj("sensor1")
|
||||
binary_sensor = MockObj("binary_sensor1")
|
||||
text_sensor = MockObj("text_sensor1")
|
||||
|
||||
config = {
|
||||
CONF_NAME: "Status",
|
||||
CONF_DISABLED_BY_DEFAULT: False,
|
||||
}
|
||||
|
||||
# Set up entities on different platforms
|
||||
platforms = [
|
||||
(sensor, "sensor"),
|
||||
(binary_sensor, "binary_sensor"),
|
||||
(text_sensor, "text_sensor"),
|
||||
]
|
||||
|
||||
object_ids: list[str] = []
|
||||
for var, platform in platforms:
|
||||
added_expressions.clear()
|
||||
await setup_entity(var, config, platform)
|
||||
object_id = extract_object_id_from_expressions(added_expressions)
|
||||
object_ids.append(object_id)
|
||||
|
||||
# All should get base object ID without suffix
|
||||
assert all(obj_id == "status" for obj_id in object_ids)
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def mock_get_variable() -> Generator[dict[ID, MockObj], None, None]:
|
||||
"""Mock get_variable to return test devices."""
|
||||
devices = {}
|
||||
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_helpers.get_variable = _mock_get_variable
|
||||
yield devices
|
||||
# Clean up
|
||||
entity_helpers.get_variable = original_get_variable
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_setup_entity_with_devices(
|
||||
setup_test_environment: list[str], mock_get_variable: dict[ID, MockObj]
|
||||
) -> None:
|
||||
"""Test that same name on different devices doesn't conflict."""
|
||||
added_expressions = setup_test_environment
|
||||
|
||||
# Create mock devices
|
||||
device1_id = ID("device1", type="Device")
|
||||
device2_id = ID("device2", type="Device")
|
||||
device1 = MockObj("device1_obj")
|
||||
device2 = MockObj("device2_obj")
|
||||
|
||||
# Register devices with the mock
|
||||
mock_get_variable[device1_id] = device1
|
||||
mock_get_variable[device2_id] = device2
|
||||
|
||||
# Create sensors with same name on different devices
|
||||
sensor1 = MockObj("sensor1")
|
||||
sensor2 = MockObj("sensor2")
|
||||
|
||||
config1 = {
|
||||
CONF_NAME: "Temperature",
|
||||
CONF_DEVICE_ID: device1_id,
|
||||
CONF_DISABLED_BY_DEFAULT: False,
|
||||
}
|
||||
|
||||
config2 = {
|
||||
CONF_NAME: "Temperature",
|
||||
CONF_DEVICE_ID: device2_id,
|
||||
CONF_DISABLED_BY_DEFAULT: False,
|
||||
}
|
||||
|
||||
# Get object IDs
|
||||
object_ids: list[str] = []
|
||||
for var, config in [(sensor1, config1), (sensor2, config2)]:
|
||||
added_expressions.clear()
|
||||
await setup_entity(var, config, "sensor")
|
||||
object_id = extract_object_id_from_expressions(added_expressions)
|
||||
object_ids.append(object_id)
|
||||
|
||||
# Both should get base object ID without suffix (different devices)
|
||||
assert object_ids[0] == "temperature"
|
||||
assert object_ids[1] == "temperature"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_setup_entity_empty_name(setup_test_environment: list[str]) -> None:
|
||||
"""Test setup_entity with empty entity name."""
|
||||
|
||||
added_expressions = setup_test_environment
|
||||
|
||||
var = MockObj("sensor1")
|
||||
|
||||
config = {
|
||||
CONF_NAME: "",
|
||||
CONF_DISABLED_BY_DEFAULT: False,
|
||||
}
|
||||
|
||||
await setup_entity(var, config, "sensor")
|
||||
|
||||
object_id = extract_object_id_from_expressions(added_expressions)
|
||||
# Should use friendly name
|
||||
assert object_id == "test_device"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_setup_entity_special_characters(
|
||||
setup_test_environment: list[str],
|
||||
) -> None:
|
||||
"""Test setup_entity with names containing special characters."""
|
||||
|
||||
added_expressions = setup_test_environment
|
||||
|
||||
var = MockObj("sensor1")
|
||||
|
||||
config = {
|
||||
CONF_NAME: "Temperature Sensor!",
|
||||
CONF_DISABLED_BY_DEFAULT: False,
|
||||
}
|
||||
|
||||
await setup_entity(var, config, "sensor")
|
||||
object_id = extract_object_id_from_expressions(added_expressions)
|
||||
|
||||
# Special characters should be sanitized
|
||||
assert object_id == "temperature_sensor_"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_setup_entity_with_icon(setup_test_environment: list[str]) -> None:
|
||||
"""Test setup_entity sets icon correctly."""
|
||||
|
||||
added_expressions = setup_test_environment
|
||||
|
||||
var = MockObj("sensor1")
|
||||
|
||||
config = {
|
||||
CONF_NAME: "Temperature",
|
||||
CONF_DISABLED_BY_DEFAULT: False,
|
||||
CONF_ICON: "mdi:thermometer",
|
||||
}
|
||||
|
||||
await setup_entity(var, config, "sensor")
|
||||
|
||||
# Check icon was set
|
||||
assert any(
|
||||
'sensor1.set_icon("mdi:thermometer")' in expr for expr in added_expressions
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_setup_entity_disabled_by_default(
|
||||
setup_test_environment: list[str],
|
||||
) -> None:
|
||||
"""Test setup_entity sets disabled_by_default correctly."""
|
||||
|
||||
added_expressions = setup_test_environment
|
||||
|
||||
var = MockObj("sensor1")
|
||||
|
||||
config = {
|
||||
CONF_NAME: "Temperature",
|
||||
CONF_DISABLED_BY_DEFAULT: True,
|
||||
}
|
||||
|
||||
await setup_entity(var, config, "sensor")
|
||||
|
||||
# Check disabled_by_default was set
|
||||
assert any(
|
||||
"sensor1.set_disabled_by_default(true)" in expr for expr in added_expressions
|
||||
)
|
||||
|
||||
|
||||
def test_entity_duplicate_validator() -> None:
|
||||
"""Test the entity_duplicate_validator function."""
|
||||
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")
|
||||
|
||||
# First entity should pass
|
||||
config1 = {CONF_NAME: "Temperature"}
|
||||
validated1 = validator(config1)
|
||||
assert validated1 == config1
|
||||
assert ("", "sensor", "temperature") in CORE.unique_ids
|
||||
|
||||
# Second entity with different name should pass
|
||||
config2 = {CONF_NAME: "Humidity"}
|
||||
validated2 = validator(config2)
|
||||
assert validated2 == config2
|
||||
assert ("", "sensor", "humidity") in CORE.unique_ids
|
||||
|
||||
# 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
|
||||
)
|
||||
assert result is None
|
||||
|
||||
# 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
|
10
tests/unit_tests/fixtures/core/config/area_id_collision.yaml
Normal file
10
tests/unit_tests/fixtures/core/config/area_id_collision.yaml
Normal file
@@ -0,0 +1,10 @@
|
||||
esphome:
|
||||
name: test-collision
|
||||
area:
|
||||
id: duplicate_id
|
||||
name: Area 1
|
||||
areas:
|
||||
- id: duplicate_id
|
||||
name: Area 2
|
||||
|
||||
host:
|
@@ -0,0 +1,10 @@
|
||||
esphome:
|
||||
name: test
|
||||
areas:
|
||||
- id: test_2258
|
||||
name: "Area 1"
|
||||
- id: d6ka
|
||||
name: "Area 2"
|
||||
|
||||
esp32:
|
||||
board: esp32dev
|
@@ -0,0 +1,10 @@
|
||||
esphome:
|
||||
name: test
|
||||
devices:
|
||||
- id: duplicate_device
|
||||
name: "Device 1"
|
||||
- id: duplicate_device
|
||||
name: "Device 2"
|
||||
|
||||
esp32:
|
||||
board: esp32dev
|
@@ -0,0 +1,10 @@
|
||||
esphome:
|
||||
name: test
|
||||
devices:
|
||||
- id: test_2258
|
||||
name: "Device 1"
|
||||
- id: d6ka
|
||||
name: "Device 2"
|
||||
|
||||
esp32:
|
||||
board: esp32dev
|
@@ -0,0 +1,12 @@
|
||||
esphome:
|
||||
name: test
|
||||
areas:
|
||||
- id: valid_area
|
||||
name: "Valid Area"
|
||||
devices:
|
||||
- id: test_device
|
||||
name: "Test Device"
|
||||
area_id: nonexistent_area
|
||||
|
||||
esp32:
|
||||
board: esp32dev
|
@@ -0,0 +1,7 @@
|
||||
esphome:
|
||||
name: test-device-no-area
|
||||
devices:
|
||||
- id: test_device
|
||||
name: Test Device
|
||||
|
||||
host:
|
@@ -0,0 +1,5 @@
|
||||
esphome:
|
||||
name: test-legacy-area
|
||||
area: Living Room
|
||||
|
||||
host:
|
@@ -0,0 +1,22 @@
|
||||
esphome:
|
||||
name: test-multiple
|
||||
area:
|
||||
id: main_area
|
||||
name: Main Area
|
||||
areas:
|
||||
- id: area1
|
||||
name: Area 1
|
||||
- id: area2
|
||||
name: Area 2
|
||||
devices:
|
||||
- id: device1
|
||||
name: Device 1
|
||||
area_id: main_area
|
||||
- id: device2
|
||||
name: Device 2
|
||||
area_id: area1
|
||||
- id: device3
|
||||
name: Device 3
|
||||
area_id: area2
|
||||
|
||||
host:
|
11
tests/unit_tests/fixtures/core/config/valid_area_device.yaml
Normal file
11
tests/unit_tests/fixtures/core/config/valid_area_device.yaml
Normal file
@@ -0,0 +1,11 @@
|
||||
esphome:
|
||||
name: test-valid-area
|
||||
areas:
|
||||
- id: bedroom_area
|
||||
name: Bedroom
|
||||
devices:
|
||||
- id: test_device
|
||||
name: Test Device
|
||||
area_id: bedroom_area
|
||||
|
||||
host:
|
@@ -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