From 303b47cf00f83059c2fc9058b334834123fc548e Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Fri, 26 Sep 2025 16:05:34 -0500 Subject: [PATCH 1/6] [core] Fix platform component normalization happening too late in validation pipeline --- esphome/config.py | 12 +- tests/unit_tests/fixtures/ota_empty_dict.yaml | 17 ++ .../unit_tests/fixtures/ota_no_platform.yaml | 17 ++ .../fixtures/ota_with_platform_list.yaml | 19 ++ tests/unit_tests/test_config.py | 271 ++++++++++++++++++ 5 files changed, 330 insertions(+), 6 deletions(-) create mode 100644 tests/unit_tests/fixtures/ota_empty_dict.yaml create mode 100644 tests/unit_tests/fixtures/ota_no_platform.yaml create mode 100644 tests/unit_tests/fixtures/ota_with_platform_list.yaml create mode 100644 tests/unit_tests/test_config.py diff --git a/esphome/config.py b/esphome/config.py index a7e47f646b..a5297a53cb 100644 --- a/esphome/config.py +++ b/esphome/config.py @@ -382,6 +382,12 @@ class LoadValidationStep(ConfigValidationStep): result.add_str_error(f"Component not found: {self.domain}", path) return CORE.loaded_integrations.add(self.domain) + # For platform components, normalize conf before creating MetadataValidationStep + if component.is_platform_component: + if not self.conf: + result[self.domain] = self.conf = [] + elif not isinstance(self.conf, list): + result[self.domain] = self.conf = [self.conf] # Process AUTO_LOAD for load in component.auto_load: @@ -399,12 +405,6 @@ class LoadValidationStep(ConfigValidationStep): # Remove this is as an output path result.remove_output_path([self.domain], self.domain) - # Ensure conf is a list - if not self.conf: - result[self.domain] = self.conf = [] - elif not isinstance(self.conf, list): - result[self.domain] = self.conf = [self.conf] - for i, p_config in enumerate(self.conf): path = [self.domain, i] # Construct temporary unknown output path diff --git a/tests/unit_tests/fixtures/ota_empty_dict.yaml b/tests/unit_tests/fixtures/ota_empty_dict.yaml new file mode 100644 index 0000000000..cf9b166afa --- /dev/null +++ b/tests/unit_tests/fixtures/ota_empty_dict.yaml @@ -0,0 +1,17 @@ +esphome: + name: test-device2 + +esp32: + board: esp32dev + framework: + type: esp-idf + +# OTA with empty dict - should be normalized +ota: {} + +wifi: + ssid: "test" + password: "test" + +# Captive portal auto-loads ota.web_server which triggers the issue +captive_portal: diff --git a/tests/unit_tests/fixtures/ota_no_platform.yaml b/tests/unit_tests/fixtures/ota_no_platform.yaml new file mode 100644 index 0000000000..0b09c836fb --- /dev/null +++ b/tests/unit_tests/fixtures/ota_no_platform.yaml @@ -0,0 +1,17 @@ +esphome: + name: test-device + +esp32: + board: esp32dev + framework: + type: esp-idf + +# OTA with no value - this should be normalized to empty list +ota: + +wifi: + ssid: "test" + password: "test" + +# Captive portal auto-loads ota.web_server which triggers the issue +captive_portal: diff --git a/tests/unit_tests/fixtures/ota_with_platform_list.yaml b/tests/unit_tests/fixtures/ota_with_platform_list.yaml new file mode 100644 index 0000000000..b1b03743ae --- /dev/null +++ b/tests/unit_tests/fixtures/ota_with_platform_list.yaml @@ -0,0 +1,19 @@ +esphome: + name: test-device3 + +esp32: + board: esp32dev + framework: + type: esp-idf + +# OTA with proper list format +ota: + - platform: esphome + password: "test123" + +wifi: + ssid: "test" + password: "test" + +# Captive portal auto-loads ota.web_server +captive_portal: diff --git a/tests/unit_tests/test_config.py b/tests/unit_tests/test_config.py new file mode 100644 index 0000000000..1d420c2d14 --- /dev/null +++ b/tests/unit_tests/test_config.py @@ -0,0 +1,271 @@ +"""Unit tests for esphome.config module.""" + +from collections.abc import Generator +from pathlib import Path +from unittest.mock import MagicMock, Mock, patch + +import pytest + +from esphome import config, yaml_util +from esphome.core import CORE + + +@pytest.fixture +def mock_get_component() -> Generator[Mock, None, None]: + """Fixture for mocking get_component.""" + with patch("esphome.config.get_component") as mock_get_component: + yield mock_get_component + + +@pytest.fixture +def mock_get_platform() -> Generator[Mock, None, None]: + """Fixture for mocking get_platform.""" + with patch("esphome.config.get_platform") as mock_get_platform: + # Default mock platform + mock_get_platform.return_value = MagicMock() + yield mock_get_platform + + +@pytest.fixture +def fixtures_dir() -> Path: + """Get the fixtures directory.""" + return Path(__file__).parent / "fixtures" + + +def test_iter_components_handles_non_list_platform_component( + mock_get_component: Mock, +) -> None: + """Test that iter_components handles platform components that have been normalized to empty list.""" + # After LoadValidationStep normalization, platform components without config + # are converted to empty list + test_config = { + "ota": [], # Normalized from None/dict to empty list by LoadValidationStep + "wifi": {"ssid": "test"}, + } + + # Set up mock components + components = { + "ota": MagicMock(is_platform_component=True), + "wifi": MagicMock(is_platform_component=False), + } + + mock_get_component.side_effect = lambda domain: components.get( + domain, MagicMock(is_platform_component=False) + ) + + # This should not raise TypeError + components_list = list(config.iter_components(test_config)) + + # Verify we got the expected components + assert len(components_list) == 2 # ota and wifi (ota has no platforms) + + +def test_iter_component_configs_handles_non_list_platform_component( + mock_get_component: Mock, + mock_get_platform: Mock, +) -> None: + """Test that iter_component_configs handles platform components that have been normalized.""" + + # After LoadValidationStep normalization + test_config = { + "ota": [], # Normalized from None/dict to empty list by LoadValidationStep + "one_wire": [ # List config for platform component + {"platform": "gpio", "pin": 10} + ], + } + + # Set up mock components + components: dict[str, Mock] = { + "ota": MagicMock(is_platform_component=True, multi_conf=False), + "one_wire": MagicMock(is_platform_component=True, multi_conf=False), + } + + # Default mock for unknown components + default_mock = MagicMock(is_platform_component=False, multi_conf=False) + + mock_get_component.side_effect = lambda domain: components.get(domain, default_mock) + + # This should not raise TypeError + configs = list(config.iter_component_configs(test_config)) + + # Should have 3 items: ota (empty list), one_wire, and one_wire.gpio + assert len(configs) == 3 + + # Check the domains + domains = [c[0] for c in configs] + assert "ota" in domains + assert "one_wire" in domains + assert "one_wire.gpio" in domains + + +def test_iter_components_with_valid_platform_list( + mock_get_component: Mock, + mock_get_platform: Mock, +) -> None: + """Test that iter_components works correctly with valid platform component list.""" + + # Create a mock component that is a platform component + mock_component = MagicMock() + mock_component.is_platform_component = True + + # Create test config with proper list format + test_config = { + "sensor": [ + {"platform": "dht", "pin": 5}, + {"platform": "bme280", "address": 0x76}, + ], + } + + mock_get_component.return_value = mock_component + + # Get all components + components = list(config.iter_components(test_config)) + + # Should have 3 items: sensor, sensor.dht, sensor.bme280 + assert len(components) == 3 + + # Check the domains + domains = [c[0] for c in components] + assert "sensor" in domains + assert "sensor.dht" in domains + assert "sensor.bme280" in domains + + +def test_ota_with_proper_platform_list( + mock_get_component: Mock, + mock_get_platform: Mock, +) -> None: + """Test that OTA component works correctly when configured as a list with platforms.""" + + # Create test config where ota is properly configured as a list + test_config = { + "ota": [ + {"platform": "esphome", "password": "test123"}, + ], + } + + mock_get_component.return_value = MagicMock(is_platform_component=True) + + # This should work without TypeError + components = list(config.iter_components(test_config)) + + # Should have 2 items: ota and ota.esphome + assert len(components) == 2 + + # Check the domains + domains = [c[0] for c in components] + assert "ota" in domains + assert "ota.esphome" in domains + + +def test_ota_component_configs_with_proper_platform_list( + mock_get_component: Mock, + mock_get_platform: Mock, +) -> None: + """Test that iter_component_configs handles OTA properly configured as a list.""" + + # Create test config where ota is properly configured as a list + test_config = { + "ota": [ + {"platform": "esphome", "password": "test123", "id": "my_ota"}, + ], + } + + mock_get_component.return_value = MagicMock( + is_platform_component=True, multi_conf=False + ) + + # This should work without TypeError + configs = list(config.iter_component_configs(test_config)) + + # Should have 2 items: ota config and ota.esphome platform config + assert len(configs) == 2 + + # Check the domains and configs + assert configs[0][0] == "ota" + assert configs[0][2] == test_config["ota"] # The list itself + + assert configs[1][0] == "ota.esphome" + assert configs[1][2]["platform"] == "esphome" + assert configs[1][2]["password"] == "test123" + + +def test_iter_component_configs_with_multi_conf(mock_get_component: Mock) -> None: + """Test that iter_component_configs handles multi_conf components correctly.""" + # Create test config + test_config = { + "switch": [ + {"name": "Switch 1"}, + {"name": "Switch 2"}, + ], + } + + # Set up mock component with multi_conf + mock_get_component.return_value = MagicMock( + is_platform_component=False, multi_conf=True + ) + + # Get all configs + configs = list(config.iter_component_configs(test_config)) + + # Should have 2 items (one for each switch) + assert len(configs) == 2 + + # Both should be for "switch" domain + for domain, component, conf in configs: + assert domain == "switch" + assert "name" in conf + + +def test_ota_no_platform_with_captive_portal(fixtures_dir: Path) -> None: + """Test OTA with no platform (ota:) gets normalized when captive_portal auto-loads.""" + # Set up CORE config path + CORE.config_path = fixtures_dir / "dummy.yaml" + + # Load config with OTA having no value (ota:) + config_file = fixtures_dir / "ota_no_platform.yaml" + raw_config = yaml_util.load_yaml(config_file) + result = config.validate_config(raw_config, {}) + + # Check that OTA was normalized to a list and captive_portal added web_server + assert "ota" in result + assert isinstance(result["ota"], list), f"Expected list, got {type(result['ota'])}" + # After captive_portal auto-loads, OTA should have web_server platform + platforms = {p.get("platform") for p in result["ota"]} + assert "web_server" in platforms, f"Expected web_server platform in {platforms}" + + +def test_ota_empty_dict_with_captive_portal(fixtures_dir: Path) -> None: + """Test OTA with empty dict ({}) gets normalized when captive_portal auto-loads.""" + # Set up CORE config path + CORE.config_path = fixtures_dir / "dummy.yaml" + + # Load config with OTA having empty dict (ota: {}) + config_file = fixtures_dir / "ota_empty_dict.yaml" + raw_config = yaml_util.load_yaml(config_file) + result = config.validate_config(raw_config, {}) + + # Check that OTA was normalized to a list and captive_portal added web_server + assert "ota" in result + assert isinstance(result["ota"], list), f"Expected list, got {type(result['ota'])}" + # The empty dict gets normalized and web_server is added + platforms = {p.get("platform") for p in result["ota"]} + assert "web_server" in platforms, f"Expected web_server platform in {platforms}" + + +def test_ota_with_platform_list_and_captive_portal(fixtures_dir: Path) -> None: + """Test OTA with proper platform list remains valid when captive_portal auto-loads.""" + # Set up CORE config path + CORE.config_path = fixtures_dir / "dummy.yaml" + + # Load config with OTA having proper list format + config_file = fixtures_dir / "ota_with_platform_list.yaml" + raw_config = yaml_util.load_yaml(config_file) + result = config.validate_config(raw_config, {}) + + # Check that OTA remains a list with both esphome and web_server platforms + assert "ota" in result + assert isinstance(result["ota"], list), f"Expected list, got {type(result['ota'])}" + platforms = {p.get("platform") for p in result["ota"]} + assert "esphome" in platforms, f"Expected esphome platform in {platforms}" + assert "web_server" in platforms, f"Expected web_server platform in {platforms}" From 7de2ed7658ac3a2825c31607d0de9722aec648d6 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Fri, 26 Sep 2025 16:08:42 -0500 Subject: [PATCH 2/6] [core] Fix platform component normalization happening too late in validation pipeline --- tests/unit_tests/test_config.py | 56 +-------------------------------- 1 file changed, 1 insertion(+), 55 deletions(-) diff --git a/tests/unit_tests/test_config.py b/tests/unit_tests/test_config.py index 1d420c2d14..c0be847904 100644 --- a/tests/unit_tests/test_config.py +++ b/tests/unit_tests/test_config.py @@ -36,14 +36,11 @@ def test_iter_components_handles_non_list_platform_component( mock_get_component: Mock, ) -> None: """Test that iter_components handles platform components that have been normalized to empty list.""" - # After LoadValidationStep normalization, platform components without config - # are converted to empty list test_config = { "ota": [], # Normalized from None/dict to empty list by LoadValidationStep "wifi": {"ssid": "test"}, } - # Set up mock components components = { "ota": MagicMock(is_platform_component=True), "wifi": MagicMock(is_platform_component=False), @@ -53,10 +50,7 @@ def test_iter_components_handles_non_list_platform_component( domain, MagicMock(is_platform_component=False) ) - # This should not raise TypeError components_list = list(config.iter_components(test_config)) - - # Verify we got the expected components assert len(components_list) == 2 # ota and wifi (ota has no platforms) @@ -65,33 +59,22 @@ def test_iter_component_configs_handles_non_list_platform_component( mock_get_platform: Mock, ) -> None: """Test that iter_component_configs handles platform components that have been normalized.""" - - # After LoadValidationStep normalization test_config = { "ota": [], # Normalized from None/dict to empty list by LoadValidationStep - "one_wire": [ # List config for platform component - {"platform": "gpio", "pin": 10} - ], + "one_wire": [{"platform": "gpio", "pin": 10}], } - # Set up mock components components: dict[str, Mock] = { "ota": MagicMock(is_platform_component=True, multi_conf=False), "one_wire": MagicMock(is_platform_component=True, multi_conf=False), } - # Default mock for unknown components default_mock = MagicMock(is_platform_component=False, multi_conf=False) - mock_get_component.side_effect = lambda domain: components.get(domain, default_mock) - # This should not raise TypeError configs = list(config.iter_component_configs(test_config)) - - # Should have 3 items: ota (empty list), one_wire, and one_wire.gpio assert len(configs) == 3 - # Check the domains domains = [c[0] for c in configs] assert "ota" in domains assert "one_wire" in domains @@ -103,12 +86,9 @@ def test_iter_components_with_valid_platform_list( mock_get_platform: Mock, ) -> None: """Test that iter_components works correctly with valid platform component list.""" - - # Create a mock component that is a platform component mock_component = MagicMock() mock_component.is_platform_component = True - # Create test config with proper list format test_config = { "sensor": [ {"platform": "dht", "pin": 5}, @@ -118,13 +98,9 @@ def test_iter_components_with_valid_platform_list( mock_get_component.return_value = mock_component - # Get all components components = list(config.iter_components(test_config)) - - # Should have 3 items: sensor, sensor.dht, sensor.bme280 assert len(components) == 3 - # Check the domains domains = [c[0] for c in components] assert "sensor" in domains assert "sensor.dht" in domains @@ -136,8 +112,6 @@ def test_ota_with_proper_platform_list( mock_get_platform: Mock, ) -> None: """Test that OTA component works correctly when configured as a list with platforms.""" - - # Create test config where ota is properly configured as a list test_config = { "ota": [ {"platform": "esphome", "password": "test123"}, @@ -145,14 +119,9 @@ def test_ota_with_proper_platform_list( } mock_get_component.return_value = MagicMock(is_platform_component=True) - - # This should work without TypeError components = list(config.iter_components(test_config)) - # Should have 2 items: ota and ota.esphome assert len(components) == 2 - - # Check the domains domains = [c[0] for c in components] assert "ota" in domains assert "ota.esphome" in domains @@ -163,8 +132,6 @@ def test_ota_component_configs_with_proper_platform_list( mock_get_platform: Mock, ) -> None: """Test that iter_component_configs handles OTA properly configured as a list.""" - - # Create test config where ota is properly configured as a list test_config = { "ota": [ {"platform": "esphome", "password": "test123", "id": "my_ota"}, @@ -175,13 +142,9 @@ def test_ota_component_configs_with_proper_platform_list( is_platform_component=True, multi_conf=False ) - # This should work without TypeError configs = list(config.iter_component_configs(test_config)) - - # Should have 2 items: ota config and ota.esphome platform config assert len(configs) == 2 - # Check the domains and configs assert configs[0][0] == "ota" assert configs[0][2] == test_config["ota"] # The list itself @@ -192,7 +155,6 @@ def test_ota_component_configs_with_proper_platform_list( def test_iter_component_configs_with_multi_conf(mock_get_component: Mock) -> None: """Test that iter_component_configs handles multi_conf components correctly.""" - # Create test config test_config = { "switch": [ {"name": "Switch 1"}, @@ -200,18 +162,13 @@ def test_iter_component_configs_with_multi_conf(mock_get_component: Mock) -> Non ], } - # Set up mock component with multi_conf mock_get_component.return_value = MagicMock( is_platform_component=False, multi_conf=True ) - # Get all configs configs = list(config.iter_component_configs(test_config)) - - # Should have 2 items (one for each switch) assert len(configs) == 2 - # Both should be for "switch" domain for domain, component, conf in configs: assert domain == "switch" assert "name" in conf @@ -219,51 +176,40 @@ def test_iter_component_configs_with_multi_conf(mock_get_component: Mock) -> Non def test_ota_no_platform_with_captive_portal(fixtures_dir: Path) -> None: """Test OTA with no platform (ota:) gets normalized when captive_portal auto-loads.""" - # Set up CORE config path CORE.config_path = fixtures_dir / "dummy.yaml" - # Load config with OTA having no value (ota:) config_file = fixtures_dir / "ota_no_platform.yaml" raw_config = yaml_util.load_yaml(config_file) result = config.validate_config(raw_config, {}) - # Check that OTA was normalized to a list and captive_portal added web_server assert "ota" in result assert isinstance(result["ota"], list), f"Expected list, got {type(result['ota'])}" - # After captive_portal auto-loads, OTA should have web_server platform platforms = {p.get("platform") for p in result["ota"]} assert "web_server" in platforms, f"Expected web_server platform in {platforms}" def test_ota_empty_dict_with_captive_portal(fixtures_dir: Path) -> None: """Test OTA with empty dict ({}) gets normalized when captive_portal auto-loads.""" - # Set up CORE config path CORE.config_path = fixtures_dir / "dummy.yaml" - # Load config with OTA having empty dict (ota: {}) config_file = fixtures_dir / "ota_empty_dict.yaml" raw_config = yaml_util.load_yaml(config_file) result = config.validate_config(raw_config, {}) - # Check that OTA was normalized to a list and captive_portal added web_server assert "ota" in result assert isinstance(result["ota"], list), f"Expected list, got {type(result['ota'])}" - # The empty dict gets normalized and web_server is added platforms = {p.get("platform") for p in result["ota"]} assert "web_server" in platforms, f"Expected web_server platform in {platforms}" def test_ota_with_platform_list_and_captive_portal(fixtures_dir: Path) -> None: """Test OTA with proper platform list remains valid when captive_portal auto-loads.""" - # Set up CORE config path CORE.config_path = fixtures_dir / "dummy.yaml" - # Load config with OTA having proper list format config_file = fixtures_dir / "ota_with_platform_list.yaml" raw_config = yaml_util.load_yaml(config_file) result = config.validate_config(raw_config, {}) - # Check that OTA remains a list with both esphome and web_server platforms assert "ota" in result assert isinstance(result["ota"], list), f"Expected list, got {type(result['ota'])}" platforms = {p.get("platform") for p in result["ota"]} From 3f202c291ae6dbb57f8658d5439131b41646089e Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Fri, 26 Sep 2025 16:10:04 -0500 Subject: [PATCH 3/6] [core] Fix platform component normalization happening too late in validation pipeline --- tests/unit_tests/test_config.py | 72 +-------------------------------- 1 file changed, 2 insertions(+), 70 deletions(-) diff --git a/tests/unit_tests/test_config.py b/tests/unit_tests/test_config.py index c0be847904..0306d57fea 100644 --- a/tests/unit_tests/test_config.py +++ b/tests/unit_tests/test_config.py @@ -32,33 +32,11 @@ def fixtures_dir() -> Path: return Path(__file__).parent / "fixtures" -def test_iter_components_handles_non_list_platform_component( - mock_get_component: Mock, -) -> None: - """Test that iter_components handles platform components that have been normalized to empty list.""" - test_config = { - "ota": [], # Normalized from None/dict to empty list by LoadValidationStep - "wifi": {"ssid": "test"}, - } - - components = { - "ota": MagicMock(is_platform_component=True), - "wifi": MagicMock(is_platform_component=False), - } - - mock_get_component.side_effect = lambda domain: components.get( - domain, MagicMock(is_platform_component=False) - ) - - components_list = list(config.iter_components(test_config)) - assert len(components_list) == 2 # ota and wifi (ota has no platforms) - - def test_iter_component_configs_handles_non_list_platform_component( mock_get_component: Mock, mock_get_platform: Mock, ) -> None: - """Test that iter_component_configs handles platform components that have been normalized.""" + """Test iter_component_configs handles normalized platform components.""" test_config = { "ota": [], # Normalized from None/dict to empty list by LoadValidationStep "one_wire": [{"platform": "gpio", "pin": 10}], @@ -81,57 +59,11 @@ def test_iter_component_configs_handles_non_list_platform_component( assert "one_wire.gpio" in domains -def test_iter_components_with_valid_platform_list( - mock_get_component: Mock, - mock_get_platform: Mock, -) -> None: - """Test that iter_components works correctly with valid platform component list.""" - mock_component = MagicMock() - mock_component.is_platform_component = True - - test_config = { - "sensor": [ - {"platform": "dht", "pin": 5}, - {"platform": "bme280", "address": 0x76}, - ], - } - - mock_get_component.return_value = mock_component - - components = list(config.iter_components(test_config)) - assert len(components) == 3 - - domains = [c[0] for c in components] - assert "sensor" in domains - assert "sensor.dht" in domains - assert "sensor.bme280" in domains - - -def test_ota_with_proper_platform_list( - mock_get_component: Mock, - mock_get_platform: Mock, -) -> None: - """Test that OTA component works correctly when configured as a list with platforms.""" - test_config = { - "ota": [ - {"platform": "esphome", "password": "test123"}, - ], - } - - mock_get_component.return_value = MagicMock(is_platform_component=True) - components = list(config.iter_components(test_config)) - - assert len(components) == 2 - domains = [c[0] for c in components] - assert "ota" in domains - assert "ota.esphome" in domains - - def test_ota_component_configs_with_proper_platform_list( mock_get_component: Mock, mock_get_platform: Mock, ) -> None: - """Test that iter_component_configs handles OTA properly configured as a list.""" + """Test iter_component_configs handles OTA properly configured as a list.""" test_config = { "ota": [ {"platform": "esphome", "password": "test123", "id": "my_ota"}, From b134f40201eef391cc2ee6c0a5f427074d5459b8 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Fri, 26 Sep 2025 16:10:53 -0500 Subject: [PATCH 4/6] [core] Fix platform component normalization happening too late in validation pipeline --- tests/unit_tests/test_config.py | 27 --------------------------- 1 file changed, 27 deletions(-) diff --git a/tests/unit_tests/test_config.py b/tests/unit_tests/test_config.py index 0306d57fea..4b79ddd426 100644 --- a/tests/unit_tests/test_config.py +++ b/tests/unit_tests/test_config.py @@ -32,33 +32,6 @@ def fixtures_dir() -> Path: return Path(__file__).parent / "fixtures" -def test_iter_component_configs_handles_non_list_platform_component( - mock_get_component: Mock, - mock_get_platform: Mock, -) -> None: - """Test iter_component_configs handles normalized platform components.""" - test_config = { - "ota": [], # Normalized from None/dict to empty list by LoadValidationStep - "one_wire": [{"platform": "gpio", "pin": 10}], - } - - components: dict[str, Mock] = { - "ota": MagicMock(is_platform_component=True, multi_conf=False), - "one_wire": MagicMock(is_platform_component=True, multi_conf=False), - } - - default_mock = MagicMock(is_platform_component=False, multi_conf=False) - mock_get_component.side_effect = lambda domain: components.get(domain, default_mock) - - configs = list(config.iter_component_configs(test_config)) - assert len(configs) == 3 - - domains = [c[0] for c in configs] - assert "ota" in domains - assert "one_wire" in domains - assert "one_wire.gpio" in domains - - def test_ota_component_configs_with_proper_platform_list( mock_get_component: Mock, mock_get_platform: Mock, From f5bba6f8ccac0cbe618033c61324007e767e45eb Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Fri, 26 Sep 2025 16:13:38 -0500 Subject: [PATCH 5/6] rename to workaround the test conflict --- tests/unit_tests/test_config.py | 122 ------ tests/unit_tests/test_config_validation.py | 449 +++++---------------- 2 files changed, 96 insertions(+), 475 deletions(-) delete mode 100644 tests/unit_tests/test_config.py diff --git a/tests/unit_tests/test_config.py b/tests/unit_tests/test_config.py deleted file mode 100644 index 4b79ddd426..0000000000 --- a/tests/unit_tests/test_config.py +++ /dev/null @@ -1,122 +0,0 @@ -"""Unit tests for esphome.config module.""" - -from collections.abc import Generator -from pathlib import Path -from unittest.mock import MagicMock, Mock, patch - -import pytest - -from esphome import config, yaml_util -from esphome.core import CORE - - -@pytest.fixture -def mock_get_component() -> Generator[Mock, None, None]: - """Fixture for mocking get_component.""" - with patch("esphome.config.get_component") as mock_get_component: - yield mock_get_component - - -@pytest.fixture -def mock_get_platform() -> Generator[Mock, None, None]: - """Fixture for mocking get_platform.""" - with patch("esphome.config.get_platform") as mock_get_platform: - # Default mock platform - mock_get_platform.return_value = MagicMock() - yield mock_get_platform - - -@pytest.fixture -def fixtures_dir() -> Path: - """Get the fixtures directory.""" - return Path(__file__).parent / "fixtures" - - -def test_ota_component_configs_with_proper_platform_list( - mock_get_component: Mock, - mock_get_platform: Mock, -) -> None: - """Test iter_component_configs handles OTA properly configured as a list.""" - test_config = { - "ota": [ - {"platform": "esphome", "password": "test123", "id": "my_ota"}, - ], - } - - mock_get_component.return_value = MagicMock( - is_platform_component=True, multi_conf=False - ) - - configs = list(config.iter_component_configs(test_config)) - assert len(configs) == 2 - - assert configs[0][0] == "ota" - assert configs[0][2] == test_config["ota"] # The list itself - - assert configs[1][0] == "ota.esphome" - assert configs[1][2]["platform"] == "esphome" - assert configs[1][2]["password"] == "test123" - - -def test_iter_component_configs_with_multi_conf(mock_get_component: Mock) -> None: - """Test that iter_component_configs handles multi_conf components correctly.""" - test_config = { - "switch": [ - {"name": "Switch 1"}, - {"name": "Switch 2"}, - ], - } - - mock_get_component.return_value = MagicMock( - is_platform_component=False, multi_conf=True - ) - - configs = list(config.iter_component_configs(test_config)) - assert len(configs) == 2 - - for domain, component, conf in configs: - assert domain == "switch" - assert "name" in conf - - -def test_ota_no_platform_with_captive_portal(fixtures_dir: Path) -> None: - """Test OTA with no platform (ota:) gets normalized when captive_portal auto-loads.""" - CORE.config_path = fixtures_dir / "dummy.yaml" - - config_file = fixtures_dir / "ota_no_platform.yaml" - raw_config = yaml_util.load_yaml(config_file) - result = config.validate_config(raw_config, {}) - - assert "ota" in result - assert isinstance(result["ota"], list), f"Expected list, got {type(result['ota'])}" - platforms = {p.get("platform") for p in result["ota"]} - assert "web_server" in platforms, f"Expected web_server platform in {platforms}" - - -def test_ota_empty_dict_with_captive_portal(fixtures_dir: Path) -> None: - """Test OTA with empty dict ({}) gets normalized when captive_portal auto-loads.""" - CORE.config_path = fixtures_dir / "dummy.yaml" - - config_file = fixtures_dir / "ota_empty_dict.yaml" - raw_config = yaml_util.load_yaml(config_file) - result = config.validate_config(raw_config, {}) - - assert "ota" in result - assert isinstance(result["ota"], list), f"Expected list, got {type(result['ota'])}" - platforms = {p.get("platform") for p in result["ota"]} - assert "web_server" in platforms, f"Expected web_server platform in {platforms}" - - -def test_ota_with_platform_list_and_captive_portal(fixtures_dir: Path) -> None: - """Test OTA with proper platform list remains valid when captive_portal auto-loads.""" - CORE.config_path = fixtures_dir / "dummy.yaml" - - config_file = fixtures_dir / "ota_with_platform_list.yaml" - raw_config = yaml_util.load_yaml(config_file) - result = config.validate_config(raw_config, {}) - - assert "ota" in result - assert isinstance(result["ota"], list), f"Expected list, got {type(result['ota'])}" - platforms = {p.get("platform") for p in result["ota"]} - assert "esphome" in platforms, f"Expected esphome platform in {platforms}" - assert "web_server" in platforms, f"Expected web_server platform in {platforms}" diff --git a/tests/unit_tests/test_config_validation.py b/tests/unit_tests/test_config_validation.py index 2928c5c83a..4b79ddd426 100644 --- a/tests/unit_tests/test_config_validation.py +++ b/tests/unit_tests/test_config_validation.py @@ -1,379 +1,122 @@ -import string +"""Unit tests for esphome.config module.""" + +from collections.abc import Generator +from pathlib import Path +from unittest.mock import MagicMock, Mock, patch -from hypothesis import example, given -from hypothesis.strategies import builds, integers, ip_addresses, one_of, text import pytest -from esphome import config_validation -from esphome.components.esp32.const import ( - VARIANT_ESP32, - VARIANT_ESP32C2, - VARIANT_ESP32C3, - VARIANT_ESP32C6, - VARIANT_ESP32H2, - VARIANT_ESP32S2, - VARIANT_ESP32S3, -) -from esphome.config_validation import Invalid -from esphome.const import ( - PLATFORM_BK72XX, - PLATFORM_ESP32, - PLATFORM_ESP8266, - PLATFORM_HOST, - PLATFORM_LN882X, - PLATFORM_RP2040, - PLATFORM_RTL87XX, -) -from esphome.core import CORE, HexInt, Lambda +from esphome import config, yaml_util +from esphome.core import CORE -def test_check_not_templatable__invalid(): - with pytest.raises(Invalid, match="This option is not templatable!"): - config_validation.check_not_templatable(Lambda("")) +@pytest.fixture +def mock_get_component() -> Generator[Mock, None, None]: + """Fixture for mocking get_component.""" + with patch("esphome.config.get_component") as mock_get_component: + yield mock_get_component -@pytest.mark.parametrize("value", ("foo", 1, "D12", False)) -def test_alphanumeric__valid(value): - actual = config_validation.alphanumeric(value) - - assert actual == str(value) +@pytest.fixture +def mock_get_platform() -> Generator[Mock, None, None]: + """Fixture for mocking get_platform.""" + with patch("esphome.config.get_platform") as mock_get_platform: + # Default mock platform + mock_get_platform.return_value = MagicMock() + yield mock_get_platform -@pytest.mark.parametrize("value", ("£23", "Foo!")) -def test_alphanumeric__invalid(value): - with pytest.raises(Invalid): - config_validation.alphanumeric(value) +@pytest.fixture +def fixtures_dir() -> Path: + """Get the fixtures directory.""" + return Path(__file__).parent / "fixtures" -@given(value=text(alphabet=string.ascii_lowercase + string.digits + "-_")) -def test_valid_name__valid(value): - actual = config_validation.valid_name(value) - - assert actual == value - - -@pytest.mark.parametrize("value", ("foo bar", "FooBar", "foo::bar")) -def test_valid_name__invalid(value): - with pytest.raises(Invalid): - config_validation.valid_name(value) - - -@pytest.mark.parametrize("value", ("${name}", "${NAME}", "$NAME", "${name}_name")) -def test_valid_name__substitution_valid(value): - CORE.vscode = True - actual = config_validation.valid_name(value) - assert actual == value - - CORE.vscode = False - with pytest.raises(Invalid): - actual = config_validation.valid_name(value) - - -@pytest.mark.parametrize("value", ("{NAME}", "${A NAME}")) -def test_valid_name__substitution_like_invalid(value): - with pytest.raises(Invalid): - config_validation.valid_name(value) - - -@pytest.mark.parametrize("value", ("myid", "anID", "SOME_ID_test", "MYID_99")) -def test_validate_id_name__valid(value): - actual = config_validation.validate_id_name(value) - - assert actual == value - - -@pytest.mark.parametrize("value", ("id of mine", "id-4", "{name_id}", "id::name")) -def test_validate_id_name__invalid(value): - with pytest.raises(Invalid): - config_validation.validate_id_name(value) - - -@pytest.mark.parametrize("value", ("${id}", "${ID}", "${ID}_test_1", "$MYID")) -def test_validate_id_name__substitution_valid(value): - CORE.vscode = True - actual = config_validation.validate_id_name(value) - assert actual == value - - CORE.vscode = False - with pytest.raises(Invalid): - config_validation.validate_id_name(value) - - -@given(one_of(integers(), text())) -def test_string__valid(value): - actual = config_validation.string(value) - - assert actual == str(value) - - -@pytest.mark.parametrize("value", ({}, [], True, False, None)) -def test_string__invalid(value): - with pytest.raises(Invalid): - config_validation.string(value) - - -@given(text()) -def test_strict_string__valid(value): - actual = config_validation.string_strict(value) - - assert actual == value - - -@pytest.mark.parametrize("value", (None, 123)) -def test_string_string__invalid(value): - with pytest.raises(Invalid, match="Must be string, got"): - config_validation.string_strict(value) - - -@given( - builds( - lambda v: "mdi:" + v, - text( - alphabet=string.ascii_letters + string.digits + "-_", - min_size=1, - max_size=20, - ), - ) -) -@example("") -def test_icon__valid(value): - actual = config_validation.icon(value) - - assert actual == value - - -def test_icon__invalid(): - with pytest.raises(Invalid, match="Icons must match the format "): - config_validation.icon("foo") - - -@pytest.mark.parametrize("value", ("True", "YES", "on", "enAblE", True)) -def test_boolean__valid_true(value): - assert config_validation.boolean(value) is True - - -@pytest.mark.parametrize("value", ("False", "NO", "off", "disAblE", False)) -def test_boolean__valid_false(value): - assert config_validation.boolean(value) is False - - -@pytest.mark.parametrize("value", (None, 1, 0, "foo")) -def test_boolean__invalid(value): - with pytest.raises(Invalid, match="Expected boolean value"): - config_validation.boolean(value) - - -@given(value=ip_addresses(v=4).map(str)) -def test_ipv4__valid(value): - config_validation.ipv4address(value) - - -@pytest.mark.parametrize("value", ("127.0.0", "localhost", "")) -def test_ipv4__invalid(value): - with pytest.raises(Invalid, match="is not a valid IPv4 address"): - config_validation.ipv4address(value) - - -@given(value=ip_addresses(v=6).map(str)) -def test_ipv6__valid(value): - config_validation.ipaddress(value) - - -@pytest.mark.parametrize("value", ("127.0.0", "localhost", "", "2001:db8::2::3")) -def test_ipv6__invalid(value): - with pytest.raises(Invalid, match="is not a valid IP address"): - config_validation.ipaddress(value) - - -# TODO: ensure_list -@given(integers()) -def hex_int__valid(value): - actual = config_validation.hex_int(value) - - assert isinstance(actual, HexInt) - assert actual == value - - -@pytest.mark.parametrize( - "framework, platform, variant, full, idf, arduino, simple", - [ - ("arduino", PLATFORM_ESP8266, None, "1", "1", "1", "1"), - ("arduino", PLATFORM_ESP32, VARIANT_ESP32, "3", "2", "3", "2"), - ("esp-idf", PLATFORM_ESP32, VARIANT_ESP32, "4", "4", "2", "2"), - ("arduino", PLATFORM_ESP32, VARIANT_ESP32C2, "3", "2", "3", "2"), - ("esp-idf", PLATFORM_ESP32, VARIANT_ESP32C2, "4", "4", "2", "2"), - ("arduino", PLATFORM_ESP32, VARIANT_ESP32S2, "6", "5", "6", "5"), - ("esp-idf", PLATFORM_ESP32, VARIANT_ESP32S2, "7", "7", "5", "5"), - ("arduino", PLATFORM_ESP32, VARIANT_ESP32S3, "9", "8", "9", "8"), - ("esp-idf", PLATFORM_ESP32, VARIANT_ESP32S3, "10", "10", "8", "8"), - ("arduino", PLATFORM_ESP32, VARIANT_ESP32C3, "12", "11", "12", "11"), - ("esp-idf", PLATFORM_ESP32, VARIANT_ESP32C3, "13", "13", "11", "11"), - ("arduino", PLATFORM_ESP32, VARIANT_ESP32C6, "15", "14", "15", "14"), - ("esp-idf", PLATFORM_ESP32, VARIANT_ESP32C6, "16", "16", "14", "14"), - ("arduino", PLATFORM_ESP32, VARIANT_ESP32H2, "18", "17", "18", "17"), - ("esp-idf", PLATFORM_ESP32, VARIANT_ESP32H2, "19", "19", "17", "17"), - ("arduino", PLATFORM_RP2040, None, "20", "20", "20", "20"), - ("arduino", PLATFORM_BK72XX, None, "21", "21", "21", "21"), - ("arduino", PLATFORM_RTL87XX, None, "22", "22", "22", "22"), - ("arduino", PLATFORM_LN882X, None, "23", "23", "23", "23"), - ("host", PLATFORM_HOST, None, "24", "24", "24", "24"), - ], -) -def test_split_default(framework, platform, variant, full, idf, arduino, simple): - from esphome.components.esp32.const import KEY_ESP32 - from esphome.const import ( - KEY_CORE, - KEY_TARGET_FRAMEWORK, - KEY_TARGET_PLATFORM, - KEY_VARIANT, - ) - - CORE.data[KEY_CORE] = {} - CORE.data[KEY_CORE][KEY_TARGET_PLATFORM] = platform - CORE.data[KEY_CORE][KEY_TARGET_FRAMEWORK] = framework - if platform == PLATFORM_ESP32: - CORE.data[KEY_ESP32] = {} - CORE.data[KEY_ESP32][KEY_VARIANT] = variant - - common_mappings = { - "esp8266": "1", - "esp32": "2", - "esp32_s2": "5", - "esp32_s3": "8", - "esp32_c3": "11", - "esp32_c6": "14", - "esp32_h2": "17", - "rp2040": "20", - "bk72xx": "21", - "rtl87xx": "22", - "ln882x": "23", - "host": "24", +def test_ota_component_configs_with_proper_platform_list( + mock_get_component: Mock, + mock_get_platform: Mock, +) -> None: + """Test iter_component_configs handles OTA properly configured as a list.""" + test_config = { + "ota": [ + {"platform": "esphome", "password": "test123", "id": "my_ota"}, + ], } - idf_mappings = { - "esp32_idf": "4", - "esp32_s2_idf": "7", - "esp32_s3_idf": "10", - "esp32_c3_idf": "13", - "esp32_c6_idf": "16", - "esp32_h2_idf": "19", + mock_get_component.return_value = MagicMock( + is_platform_component=True, multi_conf=False + ) + + configs = list(config.iter_component_configs(test_config)) + assert len(configs) == 2 + + assert configs[0][0] == "ota" + assert configs[0][2] == test_config["ota"] # The list itself + + assert configs[1][0] == "ota.esphome" + assert configs[1][2]["platform"] == "esphome" + assert configs[1][2]["password"] == "test123" + + +def test_iter_component_configs_with_multi_conf(mock_get_component: Mock) -> None: + """Test that iter_component_configs handles multi_conf components correctly.""" + test_config = { + "switch": [ + {"name": "Switch 1"}, + {"name": "Switch 2"}, + ], } - arduino_mappings = { - "esp32_arduino": "3", - "esp32_s2_arduino": "6", - "esp32_s3_arduino": "9", - "esp32_c3_arduino": "12", - "esp32_c6_arduino": "15", - "esp32_h2_arduino": "18", - } - - schema = config_validation.Schema( - { - config_validation.SplitDefault( - "full", **common_mappings, **idf_mappings, **arduino_mappings - ): str, - config_validation.SplitDefault( - "idf", **common_mappings, **idf_mappings - ): str, - config_validation.SplitDefault( - "arduino", **common_mappings, **arduino_mappings - ): str, - config_validation.SplitDefault("simple", **common_mappings): str, - } + mock_get_component.return_value = MagicMock( + is_platform_component=False, multi_conf=True ) - assert schema({}).get("full") == full - assert schema({}).get("idf") == idf - assert schema({}).get("arduino") == arduino - assert schema({}).get("simple") == simple + configs = list(config.iter_component_configs(test_config)) + assert len(configs) == 2 + + for domain, component, conf in configs: + assert domain == "switch" + assert "name" in conf -@pytest.mark.parametrize( - "framework, platform, message", - [ - ("esp-idf", PLATFORM_ESP32, "ESP32 using esp-idf framework"), - ("arduino", PLATFORM_ESP32, "ESP32 using arduino framework"), - ("arduino", PLATFORM_ESP8266, "ESP8266 using arduino framework"), - ("arduino", PLATFORM_RP2040, "RP2040 using arduino framework"), - ("arduino", PLATFORM_BK72XX, "BK72XX using arduino framework"), - ("host", PLATFORM_HOST, "HOST using host framework"), - ], -) -def test_require_framework_version(framework, platform, message): - import voluptuous as vol +def test_ota_no_platform_with_captive_portal(fixtures_dir: Path) -> None: + """Test OTA with no platform (ota:) gets normalized when captive_portal auto-loads.""" + CORE.config_path = fixtures_dir / "dummy.yaml" - from esphome.const import ( - KEY_CORE, - KEY_FRAMEWORK_VERSION, - KEY_TARGET_FRAMEWORK, - KEY_TARGET_PLATFORM, - ) + config_file = fixtures_dir / "ota_no_platform.yaml" + raw_config = yaml_util.load_yaml(config_file) + result = config.validate_config(raw_config, {}) - CORE.data[KEY_CORE] = {} - CORE.data[KEY_CORE][KEY_TARGET_PLATFORM] = platform - CORE.data[KEY_CORE][KEY_TARGET_FRAMEWORK] = framework - CORE.data[KEY_CORE][KEY_FRAMEWORK_VERSION] = config_validation.Version(1, 0, 0) + assert "ota" in result + assert isinstance(result["ota"], list), f"Expected list, got {type(result['ota'])}" + platforms = {p.get("platform") for p in result["ota"]} + assert "web_server" in platforms, f"Expected web_server platform in {platforms}" - assert ( - config_validation.require_framework_version( - esp_idf=config_validation.Version(0, 5, 0), - esp32_arduino=config_validation.Version(0, 5, 0), - esp8266_arduino=config_validation.Version(0, 5, 0), - rp2040_arduino=config_validation.Version(0, 5, 0), - bk72xx_arduino=config_validation.Version(0, 5, 0), - host=config_validation.Version(0, 5, 0), - extra_message="test 1", - )("test") - == "test" - ) - with pytest.raises( - vol.error.Invalid, - match="This feature requires at least framework version 2.0.0. test 2", - ): - config_validation.require_framework_version( - esp_idf=config_validation.Version(2, 0, 0), - esp32_arduino=config_validation.Version(2, 0, 0), - esp8266_arduino=config_validation.Version(2, 0, 0), - rp2040_arduino=config_validation.Version(2, 0, 0), - bk72xx_arduino=config_validation.Version(2, 0, 0), - host=config_validation.Version(2, 0, 0), - extra_message="test 2", - )("test") +def test_ota_empty_dict_with_captive_portal(fixtures_dir: Path) -> None: + """Test OTA with empty dict ({}) gets normalized when captive_portal auto-loads.""" + CORE.config_path = fixtures_dir / "dummy.yaml" - assert ( - config_validation.require_framework_version( - esp_idf=config_validation.Version(1, 5, 0), - esp32_arduino=config_validation.Version(1, 5, 0), - esp8266_arduino=config_validation.Version(1, 5, 0), - rp2040_arduino=config_validation.Version(1, 5, 0), - bk72xx_arduino=config_validation.Version(1, 5, 0), - host=config_validation.Version(1, 5, 0), - max_version=True, - extra_message="test 3", - )("test") - == "test" - ) + config_file = fixtures_dir / "ota_empty_dict.yaml" + raw_config = yaml_util.load_yaml(config_file) + result = config.validate_config(raw_config, {}) - with pytest.raises( - vol.error.Invalid, - match="This feature requires framework version 0.5.0 or lower. test 4", - ): - config_validation.require_framework_version( - esp_idf=config_validation.Version(0, 5, 0), - esp32_arduino=config_validation.Version(0, 5, 0), - esp8266_arduino=config_validation.Version(0, 5, 0), - rp2040_arduino=config_validation.Version(0, 5, 0), - bk72xx_arduino=config_validation.Version(0, 5, 0), - host=config_validation.Version(0, 5, 0), - max_version=True, - extra_message="test 4", - )("test") + assert "ota" in result + assert isinstance(result["ota"], list), f"Expected list, got {type(result['ota'])}" + platforms = {p.get("platform") for p in result["ota"]} + assert "web_server" in platforms, f"Expected web_server platform in {platforms}" - with pytest.raises( - vol.error.Invalid, match=f"This feature is incompatible with {message}. test 5" - ): - config_validation.require_framework_version( - extra_message="test 5", - )("test") + +def test_ota_with_platform_list_and_captive_portal(fixtures_dir: Path) -> None: + """Test OTA with proper platform list remains valid when captive_portal auto-loads.""" + CORE.config_path = fixtures_dir / "dummy.yaml" + + config_file = fixtures_dir / "ota_with_platform_list.yaml" + raw_config = yaml_util.load_yaml(config_file) + result = config.validate_config(raw_config, {}) + + assert "ota" in result + assert isinstance(result["ota"], list), f"Expected list, got {type(result['ota'])}" + platforms = {p.get("platform") for p in result["ota"]} + assert "esphome" in platforms, f"Expected esphome platform in {platforms}" + assert "web_server" in platforms, f"Expected web_server platform in {platforms}" From ae2773a7a738389e508bc75abeb4194aeab2b5d9 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Fri, 26 Sep 2025 16:14:43 -0500 Subject: [PATCH 6/6] fixes --- tests/unit_tests/test_config_normalization.py | 122 +++++ tests/unit_tests/test_config_validation.py | 445 ++++++++++++++---- 2 files changed, 473 insertions(+), 94 deletions(-) create mode 100644 tests/unit_tests/test_config_normalization.py diff --git a/tests/unit_tests/test_config_normalization.py b/tests/unit_tests/test_config_normalization.py new file mode 100644 index 0000000000..4b79ddd426 --- /dev/null +++ b/tests/unit_tests/test_config_normalization.py @@ -0,0 +1,122 @@ +"""Unit tests for esphome.config module.""" + +from collections.abc import Generator +from pathlib import Path +from unittest.mock import MagicMock, Mock, patch + +import pytest + +from esphome import config, yaml_util +from esphome.core import CORE + + +@pytest.fixture +def mock_get_component() -> Generator[Mock, None, None]: + """Fixture for mocking get_component.""" + with patch("esphome.config.get_component") as mock_get_component: + yield mock_get_component + + +@pytest.fixture +def mock_get_platform() -> Generator[Mock, None, None]: + """Fixture for mocking get_platform.""" + with patch("esphome.config.get_platform") as mock_get_platform: + # Default mock platform + mock_get_platform.return_value = MagicMock() + yield mock_get_platform + + +@pytest.fixture +def fixtures_dir() -> Path: + """Get the fixtures directory.""" + return Path(__file__).parent / "fixtures" + + +def test_ota_component_configs_with_proper_platform_list( + mock_get_component: Mock, + mock_get_platform: Mock, +) -> None: + """Test iter_component_configs handles OTA properly configured as a list.""" + test_config = { + "ota": [ + {"platform": "esphome", "password": "test123", "id": "my_ota"}, + ], + } + + mock_get_component.return_value = MagicMock( + is_platform_component=True, multi_conf=False + ) + + configs = list(config.iter_component_configs(test_config)) + assert len(configs) == 2 + + assert configs[0][0] == "ota" + assert configs[0][2] == test_config["ota"] # The list itself + + assert configs[1][0] == "ota.esphome" + assert configs[1][2]["platform"] == "esphome" + assert configs[1][2]["password"] == "test123" + + +def test_iter_component_configs_with_multi_conf(mock_get_component: Mock) -> None: + """Test that iter_component_configs handles multi_conf components correctly.""" + test_config = { + "switch": [ + {"name": "Switch 1"}, + {"name": "Switch 2"}, + ], + } + + mock_get_component.return_value = MagicMock( + is_platform_component=False, multi_conf=True + ) + + configs = list(config.iter_component_configs(test_config)) + assert len(configs) == 2 + + for domain, component, conf in configs: + assert domain == "switch" + assert "name" in conf + + +def test_ota_no_platform_with_captive_portal(fixtures_dir: Path) -> None: + """Test OTA with no platform (ota:) gets normalized when captive_portal auto-loads.""" + CORE.config_path = fixtures_dir / "dummy.yaml" + + config_file = fixtures_dir / "ota_no_platform.yaml" + raw_config = yaml_util.load_yaml(config_file) + result = config.validate_config(raw_config, {}) + + assert "ota" in result + assert isinstance(result["ota"], list), f"Expected list, got {type(result['ota'])}" + platforms = {p.get("platform") for p in result["ota"]} + assert "web_server" in platforms, f"Expected web_server platform in {platforms}" + + +def test_ota_empty_dict_with_captive_portal(fixtures_dir: Path) -> None: + """Test OTA with empty dict ({}) gets normalized when captive_portal auto-loads.""" + CORE.config_path = fixtures_dir / "dummy.yaml" + + config_file = fixtures_dir / "ota_empty_dict.yaml" + raw_config = yaml_util.load_yaml(config_file) + result = config.validate_config(raw_config, {}) + + assert "ota" in result + assert isinstance(result["ota"], list), f"Expected list, got {type(result['ota'])}" + platforms = {p.get("platform") for p in result["ota"]} + assert "web_server" in platforms, f"Expected web_server platform in {platforms}" + + +def test_ota_with_platform_list_and_captive_portal(fixtures_dir: Path) -> None: + """Test OTA with proper platform list remains valid when captive_portal auto-loads.""" + CORE.config_path = fixtures_dir / "dummy.yaml" + + config_file = fixtures_dir / "ota_with_platform_list.yaml" + raw_config = yaml_util.load_yaml(config_file) + result = config.validate_config(raw_config, {}) + + assert "ota" in result + assert isinstance(result["ota"], list), f"Expected list, got {type(result['ota'])}" + platforms = {p.get("platform") for p in result["ota"]} + assert "esphome" in platforms, f"Expected esphome platform in {platforms}" + assert "web_server" in platforms, f"Expected web_server platform in {platforms}" diff --git a/tests/unit_tests/test_config_validation.py b/tests/unit_tests/test_config_validation.py index 4b79ddd426..2928c5c83a 100644 --- a/tests/unit_tests/test_config_validation.py +++ b/tests/unit_tests/test_config_validation.py @@ -1,122 +1,379 @@ -"""Unit tests for esphome.config module.""" - -from collections.abc import Generator -from pathlib import Path -from unittest.mock import MagicMock, Mock, patch +import string +from hypothesis import example, given +from hypothesis.strategies import builds, integers, ip_addresses, one_of, text import pytest -from esphome import config, yaml_util -from esphome.core import CORE +from esphome import config_validation +from esphome.components.esp32.const import ( + VARIANT_ESP32, + VARIANT_ESP32C2, + VARIANT_ESP32C3, + VARIANT_ESP32C6, + VARIANT_ESP32H2, + VARIANT_ESP32S2, + VARIANT_ESP32S3, +) +from esphome.config_validation import Invalid +from esphome.const import ( + PLATFORM_BK72XX, + PLATFORM_ESP32, + PLATFORM_ESP8266, + PLATFORM_HOST, + PLATFORM_LN882X, + PLATFORM_RP2040, + PLATFORM_RTL87XX, +) +from esphome.core import CORE, HexInt, Lambda -@pytest.fixture -def mock_get_component() -> Generator[Mock, None, None]: - """Fixture for mocking get_component.""" - with patch("esphome.config.get_component") as mock_get_component: - yield mock_get_component +def test_check_not_templatable__invalid(): + with pytest.raises(Invalid, match="This option is not templatable!"): + config_validation.check_not_templatable(Lambda("")) -@pytest.fixture -def mock_get_platform() -> Generator[Mock, None, None]: - """Fixture for mocking get_platform.""" - with patch("esphome.config.get_platform") as mock_get_platform: - # Default mock platform - mock_get_platform.return_value = MagicMock() - yield mock_get_platform +@pytest.mark.parametrize("value", ("foo", 1, "D12", False)) +def test_alphanumeric__valid(value): + actual = config_validation.alphanumeric(value) + + assert actual == str(value) -@pytest.fixture -def fixtures_dir() -> Path: - """Get the fixtures directory.""" - return Path(__file__).parent / "fixtures" +@pytest.mark.parametrize("value", ("£23", "Foo!")) +def test_alphanumeric__invalid(value): + with pytest.raises(Invalid): + config_validation.alphanumeric(value) -def test_ota_component_configs_with_proper_platform_list( - mock_get_component: Mock, - mock_get_platform: Mock, -) -> None: - """Test iter_component_configs handles OTA properly configured as a list.""" - test_config = { - "ota": [ - {"platform": "esphome", "password": "test123", "id": "my_ota"}, - ], - } +@given(value=text(alphabet=string.ascii_lowercase + string.digits + "-_")) +def test_valid_name__valid(value): + actual = config_validation.valid_name(value) - mock_get_component.return_value = MagicMock( - is_platform_component=True, multi_conf=False + assert actual == value + + +@pytest.mark.parametrize("value", ("foo bar", "FooBar", "foo::bar")) +def test_valid_name__invalid(value): + with pytest.raises(Invalid): + config_validation.valid_name(value) + + +@pytest.mark.parametrize("value", ("${name}", "${NAME}", "$NAME", "${name}_name")) +def test_valid_name__substitution_valid(value): + CORE.vscode = True + actual = config_validation.valid_name(value) + assert actual == value + + CORE.vscode = False + with pytest.raises(Invalid): + actual = config_validation.valid_name(value) + + +@pytest.mark.parametrize("value", ("{NAME}", "${A NAME}")) +def test_valid_name__substitution_like_invalid(value): + with pytest.raises(Invalid): + config_validation.valid_name(value) + + +@pytest.mark.parametrize("value", ("myid", "anID", "SOME_ID_test", "MYID_99")) +def test_validate_id_name__valid(value): + actual = config_validation.validate_id_name(value) + + assert actual == value + + +@pytest.mark.parametrize("value", ("id of mine", "id-4", "{name_id}", "id::name")) +def test_validate_id_name__invalid(value): + with pytest.raises(Invalid): + config_validation.validate_id_name(value) + + +@pytest.mark.parametrize("value", ("${id}", "${ID}", "${ID}_test_1", "$MYID")) +def test_validate_id_name__substitution_valid(value): + CORE.vscode = True + actual = config_validation.validate_id_name(value) + assert actual == value + + CORE.vscode = False + with pytest.raises(Invalid): + config_validation.validate_id_name(value) + + +@given(one_of(integers(), text())) +def test_string__valid(value): + actual = config_validation.string(value) + + assert actual == str(value) + + +@pytest.mark.parametrize("value", ({}, [], True, False, None)) +def test_string__invalid(value): + with pytest.raises(Invalid): + config_validation.string(value) + + +@given(text()) +def test_strict_string__valid(value): + actual = config_validation.string_strict(value) + + assert actual == value + + +@pytest.mark.parametrize("value", (None, 123)) +def test_string_string__invalid(value): + with pytest.raises(Invalid, match="Must be string, got"): + config_validation.string_strict(value) + + +@given( + builds( + lambda v: "mdi:" + v, + text( + alphabet=string.ascii_letters + string.digits + "-_", + min_size=1, + max_size=20, + ), + ) +) +@example("") +def test_icon__valid(value): + actual = config_validation.icon(value) + + assert actual == value + + +def test_icon__invalid(): + with pytest.raises(Invalid, match="Icons must match the format "): + config_validation.icon("foo") + + +@pytest.mark.parametrize("value", ("True", "YES", "on", "enAblE", True)) +def test_boolean__valid_true(value): + assert config_validation.boolean(value) is True + + +@pytest.mark.parametrize("value", ("False", "NO", "off", "disAblE", False)) +def test_boolean__valid_false(value): + assert config_validation.boolean(value) is False + + +@pytest.mark.parametrize("value", (None, 1, 0, "foo")) +def test_boolean__invalid(value): + with pytest.raises(Invalid, match="Expected boolean value"): + config_validation.boolean(value) + + +@given(value=ip_addresses(v=4).map(str)) +def test_ipv4__valid(value): + config_validation.ipv4address(value) + + +@pytest.mark.parametrize("value", ("127.0.0", "localhost", "")) +def test_ipv4__invalid(value): + with pytest.raises(Invalid, match="is not a valid IPv4 address"): + config_validation.ipv4address(value) + + +@given(value=ip_addresses(v=6).map(str)) +def test_ipv6__valid(value): + config_validation.ipaddress(value) + + +@pytest.mark.parametrize("value", ("127.0.0", "localhost", "", "2001:db8::2::3")) +def test_ipv6__invalid(value): + with pytest.raises(Invalid, match="is not a valid IP address"): + config_validation.ipaddress(value) + + +# TODO: ensure_list +@given(integers()) +def hex_int__valid(value): + actual = config_validation.hex_int(value) + + assert isinstance(actual, HexInt) + assert actual == value + + +@pytest.mark.parametrize( + "framework, platform, variant, full, idf, arduino, simple", + [ + ("arduino", PLATFORM_ESP8266, None, "1", "1", "1", "1"), + ("arduino", PLATFORM_ESP32, VARIANT_ESP32, "3", "2", "3", "2"), + ("esp-idf", PLATFORM_ESP32, VARIANT_ESP32, "4", "4", "2", "2"), + ("arduino", PLATFORM_ESP32, VARIANT_ESP32C2, "3", "2", "3", "2"), + ("esp-idf", PLATFORM_ESP32, VARIANT_ESP32C2, "4", "4", "2", "2"), + ("arduino", PLATFORM_ESP32, VARIANT_ESP32S2, "6", "5", "6", "5"), + ("esp-idf", PLATFORM_ESP32, VARIANT_ESP32S2, "7", "7", "5", "5"), + ("arduino", PLATFORM_ESP32, VARIANT_ESP32S3, "9", "8", "9", "8"), + ("esp-idf", PLATFORM_ESP32, VARIANT_ESP32S3, "10", "10", "8", "8"), + ("arduino", PLATFORM_ESP32, VARIANT_ESP32C3, "12", "11", "12", "11"), + ("esp-idf", PLATFORM_ESP32, VARIANT_ESP32C3, "13", "13", "11", "11"), + ("arduino", PLATFORM_ESP32, VARIANT_ESP32C6, "15", "14", "15", "14"), + ("esp-idf", PLATFORM_ESP32, VARIANT_ESP32C6, "16", "16", "14", "14"), + ("arduino", PLATFORM_ESP32, VARIANT_ESP32H2, "18", "17", "18", "17"), + ("esp-idf", PLATFORM_ESP32, VARIANT_ESP32H2, "19", "19", "17", "17"), + ("arduino", PLATFORM_RP2040, None, "20", "20", "20", "20"), + ("arduino", PLATFORM_BK72XX, None, "21", "21", "21", "21"), + ("arduino", PLATFORM_RTL87XX, None, "22", "22", "22", "22"), + ("arduino", PLATFORM_LN882X, None, "23", "23", "23", "23"), + ("host", PLATFORM_HOST, None, "24", "24", "24", "24"), + ], +) +def test_split_default(framework, platform, variant, full, idf, arduino, simple): + from esphome.components.esp32.const import KEY_ESP32 + from esphome.const import ( + KEY_CORE, + KEY_TARGET_FRAMEWORK, + KEY_TARGET_PLATFORM, + KEY_VARIANT, ) - configs = list(config.iter_component_configs(test_config)) - assert len(configs) == 2 + CORE.data[KEY_CORE] = {} + CORE.data[KEY_CORE][KEY_TARGET_PLATFORM] = platform + CORE.data[KEY_CORE][KEY_TARGET_FRAMEWORK] = framework + if platform == PLATFORM_ESP32: + CORE.data[KEY_ESP32] = {} + CORE.data[KEY_ESP32][KEY_VARIANT] = variant - assert configs[0][0] == "ota" - assert configs[0][2] == test_config["ota"] # The list itself - - assert configs[1][0] == "ota.esphome" - assert configs[1][2]["platform"] == "esphome" - assert configs[1][2]["password"] == "test123" - - -def test_iter_component_configs_with_multi_conf(mock_get_component: Mock) -> None: - """Test that iter_component_configs handles multi_conf components correctly.""" - test_config = { - "switch": [ - {"name": "Switch 1"}, - {"name": "Switch 2"}, - ], + common_mappings = { + "esp8266": "1", + "esp32": "2", + "esp32_s2": "5", + "esp32_s3": "8", + "esp32_c3": "11", + "esp32_c6": "14", + "esp32_h2": "17", + "rp2040": "20", + "bk72xx": "21", + "rtl87xx": "22", + "ln882x": "23", + "host": "24", } - mock_get_component.return_value = MagicMock( - is_platform_component=False, multi_conf=True + idf_mappings = { + "esp32_idf": "4", + "esp32_s2_idf": "7", + "esp32_s3_idf": "10", + "esp32_c3_idf": "13", + "esp32_c6_idf": "16", + "esp32_h2_idf": "19", + } + + arduino_mappings = { + "esp32_arduino": "3", + "esp32_s2_arduino": "6", + "esp32_s3_arduino": "9", + "esp32_c3_arduino": "12", + "esp32_c6_arduino": "15", + "esp32_h2_arduino": "18", + } + + schema = config_validation.Schema( + { + config_validation.SplitDefault( + "full", **common_mappings, **idf_mappings, **arduino_mappings + ): str, + config_validation.SplitDefault( + "idf", **common_mappings, **idf_mappings + ): str, + config_validation.SplitDefault( + "arduino", **common_mappings, **arduino_mappings + ): str, + config_validation.SplitDefault("simple", **common_mappings): str, + } ) - configs = list(config.iter_component_configs(test_config)) - assert len(configs) == 2 - - for domain, component, conf in configs: - assert domain == "switch" - assert "name" in conf + assert schema({}).get("full") == full + assert schema({}).get("idf") == idf + assert schema({}).get("arduino") == arduino + assert schema({}).get("simple") == simple -def test_ota_no_platform_with_captive_portal(fixtures_dir: Path) -> None: - """Test OTA with no platform (ota:) gets normalized when captive_portal auto-loads.""" - CORE.config_path = fixtures_dir / "dummy.yaml" +@pytest.mark.parametrize( + "framework, platform, message", + [ + ("esp-idf", PLATFORM_ESP32, "ESP32 using esp-idf framework"), + ("arduino", PLATFORM_ESP32, "ESP32 using arduino framework"), + ("arduino", PLATFORM_ESP8266, "ESP8266 using arduino framework"), + ("arduino", PLATFORM_RP2040, "RP2040 using arduino framework"), + ("arduino", PLATFORM_BK72XX, "BK72XX using arduino framework"), + ("host", PLATFORM_HOST, "HOST using host framework"), + ], +) +def test_require_framework_version(framework, platform, message): + import voluptuous as vol - config_file = fixtures_dir / "ota_no_platform.yaml" - raw_config = yaml_util.load_yaml(config_file) - result = config.validate_config(raw_config, {}) + from esphome.const import ( + KEY_CORE, + KEY_FRAMEWORK_VERSION, + KEY_TARGET_FRAMEWORK, + KEY_TARGET_PLATFORM, + ) - assert "ota" in result - assert isinstance(result["ota"], list), f"Expected list, got {type(result['ota'])}" - platforms = {p.get("platform") for p in result["ota"]} - assert "web_server" in platforms, f"Expected web_server platform in {platforms}" + CORE.data[KEY_CORE] = {} + CORE.data[KEY_CORE][KEY_TARGET_PLATFORM] = platform + CORE.data[KEY_CORE][KEY_TARGET_FRAMEWORK] = framework + CORE.data[KEY_CORE][KEY_FRAMEWORK_VERSION] = config_validation.Version(1, 0, 0) + assert ( + config_validation.require_framework_version( + esp_idf=config_validation.Version(0, 5, 0), + esp32_arduino=config_validation.Version(0, 5, 0), + esp8266_arduino=config_validation.Version(0, 5, 0), + rp2040_arduino=config_validation.Version(0, 5, 0), + bk72xx_arduino=config_validation.Version(0, 5, 0), + host=config_validation.Version(0, 5, 0), + extra_message="test 1", + )("test") + == "test" + ) -def test_ota_empty_dict_with_captive_portal(fixtures_dir: Path) -> None: - """Test OTA with empty dict ({}) gets normalized when captive_portal auto-loads.""" - CORE.config_path = fixtures_dir / "dummy.yaml" + with pytest.raises( + vol.error.Invalid, + match="This feature requires at least framework version 2.0.0. test 2", + ): + config_validation.require_framework_version( + esp_idf=config_validation.Version(2, 0, 0), + esp32_arduino=config_validation.Version(2, 0, 0), + esp8266_arduino=config_validation.Version(2, 0, 0), + rp2040_arduino=config_validation.Version(2, 0, 0), + bk72xx_arduino=config_validation.Version(2, 0, 0), + host=config_validation.Version(2, 0, 0), + extra_message="test 2", + )("test") - config_file = fixtures_dir / "ota_empty_dict.yaml" - raw_config = yaml_util.load_yaml(config_file) - result = config.validate_config(raw_config, {}) + assert ( + config_validation.require_framework_version( + esp_idf=config_validation.Version(1, 5, 0), + esp32_arduino=config_validation.Version(1, 5, 0), + esp8266_arduino=config_validation.Version(1, 5, 0), + rp2040_arduino=config_validation.Version(1, 5, 0), + bk72xx_arduino=config_validation.Version(1, 5, 0), + host=config_validation.Version(1, 5, 0), + max_version=True, + extra_message="test 3", + )("test") + == "test" + ) - assert "ota" in result - assert isinstance(result["ota"], list), f"Expected list, got {type(result['ota'])}" - platforms = {p.get("platform") for p in result["ota"]} - assert "web_server" in platforms, f"Expected web_server platform in {platforms}" + with pytest.raises( + vol.error.Invalid, + match="This feature requires framework version 0.5.0 or lower. test 4", + ): + config_validation.require_framework_version( + esp_idf=config_validation.Version(0, 5, 0), + esp32_arduino=config_validation.Version(0, 5, 0), + esp8266_arduino=config_validation.Version(0, 5, 0), + rp2040_arduino=config_validation.Version(0, 5, 0), + bk72xx_arduino=config_validation.Version(0, 5, 0), + host=config_validation.Version(0, 5, 0), + max_version=True, + extra_message="test 4", + )("test") - -def test_ota_with_platform_list_and_captive_portal(fixtures_dir: Path) -> None: - """Test OTA with proper platform list remains valid when captive_portal auto-loads.""" - CORE.config_path = fixtures_dir / "dummy.yaml" - - config_file = fixtures_dir / "ota_with_platform_list.yaml" - raw_config = yaml_util.load_yaml(config_file) - result = config.validate_config(raw_config, {}) - - assert "ota" in result - assert isinstance(result["ota"], list), f"Expected list, got {type(result['ota'])}" - platforms = {p.get("platform") for p in result["ota"]} - assert "esphome" in platforms, f"Expected esphome platform in {platforms}" - assert "web_server" in platforms, f"Expected web_server platform in {platforms}" + with pytest.raises( + vol.error.Invalid, match=f"This feature is incompatible with {message}. test 5" + ): + config_validation.require_framework_version( + extra_message="test 5", + )("test")