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}"