[image] Restructure into a platform component (#17416)

This commit is contained in:
Jesse Hills
2026-07-09 07:38:58 +12:00
committed by GitHub
parent 26f48ee9ea
commit dd0d0942f5
39 changed files with 1827 additions and 883 deletions
+83 -2
View File
@@ -1,6 +1,6 @@
"""Unit tests for esphome.config module."""
from collections.abc import Generator
from collections.abc import Callable, Generator
import logging
from pathlib import Path
from unittest.mock import MagicMock, Mock, patch
@@ -8,7 +8,8 @@ from unittest.mock import MagicMock, Mock, patch
import pytest
from esphome import config, yaml_util
from esphome.core import CORE
from esphome.core import CORE, AutoLoad
from esphome.types import ConfigType
@pytest.fixture
@@ -116,6 +117,86 @@ def test_ota_with_platform_list_and_captive_portal(fixtures_dir: Path) -> None:
assert "web_server" in platforms, f"Expected web_server platform in {platforms}"
# ---------------------------------------------------------------------------
# LEGACY_CONFIG_MIGRATE hook on LoadValidationStep -- the removable shim that
# lets a platform component rewrite a pre-platform top-level config.
# ---------------------------------------------------------------------------
def _run_load_step(
domain: str,
conf: object,
migrate: Callable[[ConfigType], list | None] | None,
) -> config.Config:
"""Run a LoadValidationStep for a platform component with a given migrate hook."""
component = Mock()
component.is_platform_component = True
component.multi_conf_no_default = False
component.legacy_config_migrate = migrate
result = config.Config()
with (
patch("esphome.config.get_component", return_value=component),
patch("esphome.config._process_auto_load"),
patch("esphome.config._process_platform_config"),
):
config.LoadValidationStep(domain, conf).run(result)
return result
def test_legacy_migrate_rewrites_conf() -> None:
"""A legacy config that the hook migrates is replaced with the new list."""
migrated = [{"platform": "file", "id": "a"}]
migrate = Mock(return_value=migrated)
result = _run_load_step("image", [{"id": "a", "file": "x.png"}], migrate)
migrate.assert_called_once_with([{"id": "a", "file": "x.png"}])
assert result["image"] == migrated
def test_legacy_migrate_none_keeps_new_format() -> None:
"""When the hook returns None the already-new config is left untouched."""
new_format = [{"platform": "file", "id": "a"}]
migrate = Mock(return_value=None)
result = _run_load_step("image", new_format, migrate)
migrate.assert_called_once_with(new_format)
assert result["image"] == new_format
def test_legacy_migrate_absent_hook_is_noop() -> None:
"""A platform component without the hook normalizes without migration."""
result = _run_load_step("image", {"id": "a"}, None)
# Bare dict still gets wrapped into a list by the normal normalization path.
assert result["image"] == [{"id": "a"}]
def test_legacy_migrate_skipped_for_empty_conf() -> None:
"""An empty config short-circuits before the hook is consulted."""
migrate = Mock(return_value=[{"platform": "file"}])
result = _run_load_step("image", [], migrate)
migrate.assert_not_called()
assert result["image"] == []
def test_legacy_migrate_skipped_for_autoload() -> None:
"""An auto-loaded (AutoLoad) config is never migrated."""
migrate = Mock(return_value=[{"platform": "file"}])
auto = AutoLoad()
auto["id"] = "a"
result = _run_load_step("image", auto, migrate)
migrate.assert_not_called()
# AutoLoad is dict-like, so normalization wraps it into a single-entry list.
assert result["image"] == [auto]
def _write_merge_conflict_config(tmp_path: Path, *, suppress: bool) -> Path:
"""Create a config where two `<<` includes both define `logger:`.