From 192158ef1ad7101759cef86a63155400e09193de Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Tue, 24 Jun 2025 23:22:18 +0200 Subject: [PATCH] cleanup --- tests/unit_tests/core/__init__.py | 0 tests/unit_tests/core/common.py | 33 ++++++ tests/unit_tests/core/test_config.py | 63 +++++------ tests/unit_tests/core/test_entity_helpers.py | 108 ++++++++++++++++++- 4 files changed, 166 insertions(+), 38 deletions(-) create mode 100644 tests/unit_tests/core/__init__.py create mode 100644 tests/unit_tests/core/common.py diff --git a/tests/unit_tests/core/__init__.py b/tests/unit_tests/core/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/tests/unit_tests/core/common.py b/tests/unit_tests/core/common.py new file mode 100644 index 0000000000..1848d5397b --- /dev/null +++ b/tests/unit_tests/core/common.py @@ -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) diff --git a/tests/unit_tests/core/test_config.py b/tests/unit_tests/core/test_config.py index c98dd01f19..46e3b513d7 100644 --- a/tests/unit_tests/core/test_config.py +++ b/tests/unit_tests/core/test_config.py @@ -3,43 +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" -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") @@ -70,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"] @@ -93,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"] @@ -129,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"] @@ -148,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 @@ -159,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"] @@ -181,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 @@ -196,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 @@ -212,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 @@ -228,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 diff --git a/tests/unit_tests/core/test_entity_helpers.py b/tests/unit_tests/core/test_entity_helpers.py index 475d8a3b54..ffb155cc2d 100644 --- a/tests/unit_tests/core/test_entity_helpers.py +++ b/tests/unit_tests/core/test_entity_helpers.py @@ -1,6 +1,7 @@ """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 @@ -13,9 +14,13 @@ 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_yaml + # 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]: @@ -548,3 +553,104 @@ def test_entity_duplicate_validator_with_devices() -> None: 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.""" + yaml_content = """ +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; +""" + result = load_config_from_yaml(yaml_file, yaml_content) + 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.""" + yaml_content = """ +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; +""" + result = load_config_from_yaml(yaml_file, yaml_content) + 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.""" + yaml_content = """ +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"}; +""" + result = load_config_from_yaml(yaml_file, yaml_content) + # This should succeed + assert result is not None