mirror of
https://github.com/esphome/esphome.git
synced 2026-09-28 15:30:21 +00:00
Merge remote-tracking branch 'upstream/fix-slow-serial-log-test' into integration
# Conflicts: # .github/workflows/ci.yml # esphome/components/esp32_ble_server/ble_server.cpp # esphome/components/mqtt/mqtt_backend_esp32.cpp # script/cpp_benchmark.py # script/determine-jobs.py
This commit is contained in:
@@ -0,0 +1,7 @@
|
||||
from tests.testing_helpers import ComponentManifestOverride
|
||||
|
||||
|
||||
def override_manifest(manifest: ComponentManifestOverride) -> None:
|
||||
# core (esphome/core/config.py) must run its to_code during builds
|
||||
# because it bootstraps the fundamental application infrastructure.
|
||||
manifest.enable_codegen()
|
||||
@@ -0,0 +1,7 @@
|
||||
from tests.testing_helpers import ComponentManifestOverride
|
||||
|
||||
|
||||
def override_manifest(manifest: ComponentManifestOverride) -> None:
|
||||
# host must run its to_code during builds because it sets up
|
||||
# the host platform target execution environment.
|
||||
manifest.enable_codegen()
|
||||
@@ -0,0 +1,7 @@
|
||||
from tests.testing_helpers import ComponentManifestOverride
|
||||
|
||||
|
||||
def override_manifest(manifest: ComponentManifestOverride) -> None:
|
||||
# json must run its to_code during benchmark builds because it
|
||||
# adds the ArduinoJson library dependency needed by the API component.
|
||||
manifest.enable_codegen()
|
||||
@@ -0,0 +1,7 @@
|
||||
from tests.testing_helpers import ComponentManifestOverride
|
||||
|
||||
|
||||
def override_manifest(manifest: ComponentManifestOverride) -> None:
|
||||
# logger must run its to_code during builds because it configures
|
||||
# the logging subsystem used by ESP_LOG* macros.
|
||||
manifest.enable_codegen()
|
||||
@@ -0,0 +1,9 @@
|
||||
import esphome.codegen as cg
|
||||
from tests.testing_helpers import ComponentManifestOverride
|
||||
|
||||
|
||||
def override_manifest(manifest: ComponentManifestOverride) -> None:
|
||||
async def to_code(config):
|
||||
cg.add_build_flag("-DUSE_TIME_TIMEZONE")
|
||||
|
||||
manifest.to_code = to_code
|
||||
@@ -14,11 +14,63 @@ include the relevant `.cpp` and `.h` test files there.
|
||||
|
||||
### Override component code generation for testing
|
||||
|
||||
When generating code for testing, ESPHome won't invoke the component's `to_code` function, since most components do not
|
||||
need to generate configuration code for testing.
|
||||
During C++ test builds, `to_code` is suppressed for every component by default — most components do not
|
||||
need to generate configuration code for a unit test binary.
|
||||
|
||||
If you do need to generate code to for example configure compilation flags or add libraries,
|
||||
add the component name to the `CPP_TESTING_CODEGEN_COMPONENTS` allowlist in `script/cpp_unit_test.py`.
|
||||
#### Manifest overrides
|
||||
|
||||
If your component needs to customise code generation behavior for testing — for example to re-enable
|
||||
`to_code`, supply a lightweight stub, add a test-only dependency, or change any other manifest attribute —
|
||||
create an `__init__.py` in your component's test directory and define `override_manifest`:
|
||||
|
||||
**Top-level component** (`tests/components/<component>/__init__.py`):
|
||||
|
||||
```python
|
||||
from tests.testing_helpers import ComponentManifestOverride
|
||||
|
||||
def override_manifest(manifest: ComponentManifestOverride) -> None:
|
||||
# Re-enable the component's own to_code (needed when the component must
|
||||
# emit C++ setup code that the test binary depends on at link time).
|
||||
manifest.enable_codegen()
|
||||
```
|
||||
|
||||
Or supply a lightweight stub instead of the real `to_code`:
|
||||
|
||||
```python
|
||||
from tests.testing_helpers import ComponentManifestOverride
|
||||
|
||||
def override_manifest(manifest: ComponentManifestOverride) -> None:
|
||||
async def to_code_testing(config):
|
||||
# Only emit what the C++ tests actually need
|
||||
pass
|
||||
|
||||
manifest.to_code = to_code_testing
|
||||
manifest.dependencies = manifest.dependencies + ["some_test_only_dep"]
|
||||
```
|
||||
|
||||
**Platform component** (`tests/components/<component>/<domain>/__init__.py`,
|
||||
e.g. `tests/components/my_sensor/sensor/__init__.py`):
|
||||
|
||||
```python
|
||||
from tests.testing_helpers import ComponentManifestOverride
|
||||
|
||||
def override_manifest(manifest: ComponentManifestOverride) -> None:
|
||||
manifest.enable_codegen()
|
||||
```
|
||||
|
||||
`override_manifest` receives a `ComponentManifestOverride` that wraps the real manifest.
|
||||
Attribute assignments store an override; reads fall back to the real manifest when no
|
||||
override is present.
|
||||
|
||||
Key methods:
|
||||
|
||||
| Method | Effect |
|
||||
|---|---|
|
||||
| `manifest.enable_codegen()` | Remove the `to_code` suppression, re-enabling code generation |
|
||||
| `manifest.restore()` | Clear **all** overrides, reverting every attribute to the original |
|
||||
|
||||
The function is called after `to_code` has already been set to `None`, so calling
|
||||
`enable_codegen()` is a deliberate opt-in.
|
||||
|
||||
## Running component unit tests
|
||||
|
||||
|
||||
@@ -0,0 +1,8 @@
|
||||
from tests.testing_helpers import ComponentManifestOverride
|
||||
|
||||
|
||||
def override_manifest(manifest: ComponentManifestOverride) -> None:
|
||||
# core (esphome/core/config.py) must run its to_code during C++ test builds
|
||||
# because it bootstraps the fundamental application infrastructure that all
|
||||
# components depend on (component registration, event loop, etc.).
|
||||
manifest.enable_codegen()
|
||||
@@ -0,0 +1,7 @@
|
||||
from tests.testing_helpers import ComponentManifestOverride
|
||||
|
||||
|
||||
def override_manifest(manifest: ComponentManifestOverride) -> None:
|
||||
# host must run its to_code during C++ test builds because it sets up the
|
||||
# host platform target, which is the execution environment for all unit tests.
|
||||
manifest.enable_codegen()
|
||||
@@ -0,0 +1,7 @@
|
||||
from tests.testing_helpers import ComponentManifestOverride
|
||||
|
||||
|
||||
def override_manifest(manifest: ComponentManifestOverride) -> None:
|
||||
# logger must run its to_code during C++ test builds because it configures
|
||||
# the logging subsystem used by ESP_LOG* macros throughout component code.
|
||||
manifest.enable_codegen()
|
||||
@@ -0,0 +1,9 @@
|
||||
import esphome.codegen as cg
|
||||
from tests.testing_helpers import ComponentManifestOverride
|
||||
|
||||
|
||||
def override_manifest(manifest: ComponentManifestOverride) -> None:
|
||||
async def to_code(config):
|
||||
cg.add_build_flag("-DUSE_TIME_TIMEZONE")
|
||||
|
||||
manifest.to_code = to_code
|
||||
@@ -1821,3 +1821,151 @@ def test_component_batching_beta_branch_40_per_batch(
|
||||
all_components.extend(batch_str.split())
|
||||
assert len(all_components) == 120
|
||||
assert set(all_components) == set(component_names)
|
||||
|
||||
|
||||
# --- should_run_benchmarks tests ---
|
||||
|
||||
|
||||
def test_should_run_benchmarks_core_change() -> None:
|
||||
"""Test benchmarks trigger on core C++ file changes."""
|
||||
with patch.object(
|
||||
determine_jobs, "changed_files", return_value=["esphome/core/scheduler.cpp"]
|
||||
):
|
||||
assert determine_jobs.should_run_benchmarks() is True
|
||||
|
||||
|
||||
def test_should_run_benchmarks_core_header_change() -> None:
|
||||
"""Test benchmarks trigger on core header changes."""
|
||||
with patch.object(
|
||||
determine_jobs, "changed_files", return_value=["esphome/core/helpers.h"]
|
||||
):
|
||||
assert determine_jobs.should_run_benchmarks() is True
|
||||
|
||||
|
||||
def test_should_run_benchmarks_benchmark_infra_change() -> None:
|
||||
"""Test benchmarks trigger on benchmark infrastructure changes."""
|
||||
for infra_file in [
|
||||
"script/cpp_benchmark.py",
|
||||
"script/build_helpers.py",
|
||||
"script/setup_codspeed_lib.py",
|
||||
]:
|
||||
with patch.object(determine_jobs, "changed_files", return_value=[infra_file]):
|
||||
assert determine_jobs.should_run_benchmarks() is True, (
|
||||
f"Expected benchmarks to run for {infra_file}"
|
||||
)
|
||||
|
||||
|
||||
def test_should_run_benchmarks_benchmark_file_change() -> None:
|
||||
"""Test benchmarks trigger on benchmark file changes."""
|
||||
with patch.object(
|
||||
determine_jobs,
|
||||
"changed_files",
|
||||
return_value=["tests/benchmarks/components/api/bench_proto_encode.cpp"],
|
||||
):
|
||||
assert determine_jobs.should_run_benchmarks() is True
|
||||
|
||||
|
||||
def test_should_run_benchmarks_core_benchmark_file_change() -> None:
|
||||
"""Test benchmarks trigger on core benchmark file changes."""
|
||||
with patch.object(
|
||||
determine_jobs,
|
||||
"changed_files",
|
||||
return_value=["tests/benchmarks/core/bench_scheduler.cpp"],
|
||||
):
|
||||
assert determine_jobs.should_run_benchmarks() is True
|
||||
|
||||
|
||||
def test_should_run_benchmarks_benchmarked_component_change(tmp_path: Path) -> None:
|
||||
"""Test benchmarks trigger when a benchmarked component changes."""
|
||||
# Create a fake benchmarks directory with an 'api' component
|
||||
benchmarks_dir = tmp_path / "tests" / "benchmarks" / "components" / "api"
|
||||
benchmarks_dir.mkdir(parents=True)
|
||||
(benchmarks_dir / "bench_proto_encode.cpp").write_text("// benchmark")
|
||||
|
||||
with (
|
||||
patch.object(
|
||||
determine_jobs,
|
||||
"changed_files",
|
||||
return_value=["esphome/components/api/proto.h"],
|
||||
),
|
||||
patch.object(determine_jobs, "root_path", str(tmp_path)),
|
||||
patch.object(
|
||||
determine_jobs,
|
||||
"BENCHMARKS_COMPONENTS_PATH",
|
||||
"tests/benchmarks/components",
|
||||
),
|
||||
):
|
||||
assert determine_jobs.should_run_benchmarks() is True
|
||||
|
||||
|
||||
def test_should_run_benchmarks_non_benchmarked_component_change(
|
||||
tmp_path: Path,
|
||||
) -> None:
|
||||
"""Test benchmarks do NOT trigger for non-benchmarked component changes."""
|
||||
# Create a fake benchmarks directory with only 'api'
|
||||
benchmarks_dir = tmp_path / "tests" / "benchmarks" / "components" / "api"
|
||||
benchmarks_dir.mkdir(parents=True)
|
||||
(benchmarks_dir / "bench_proto_encode.cpp").write_text("// benchmark")
|
||||
|
||||
with (
|
||||
patch.object(
|
||||
determine_jobs,
|
||||
"changed_files",
|
||||
return_value=["esphome/components/sensor/__init__.py"],
|
||||
),
|
||||
patch.object(determine_jobs, "root_path", str(tmp_path)),
|
||||
patch.object(
|
||||
determine_jobs,
|
||||
"BENCHMARKS_COMPONENTS_PATH",
|
||||
"tests/benchmarks/components",
|
||||
),
|
||||
):
|
||||
assert determine_jobs.should_run_benchmarks() is False
|
||||
|
||||
|
||||
def test_should_run_benchmarks_no_dependency_expansion(tmp_path: Path) -> None:
|
||||
"""Test benchmarks do NOT expand to dependent components.
|
||||
|
||||
Changing 'sensor' should not trigger 'api' benchmarks even if api
|
||||
depends on sensor. This is intentional — benchmark runs should be
|
||||
targeted to directly changed components only.
|
||||
"""
|
||||
benchmarks_dir = tmp_path / "tests" / "benchmarks" / "components" / "api"
|
||||
benchmarks_dir.mkdir(parents=True)
|
||||
(benchmarks_dir / "bench_proto_encode.cpp").write_text("// benchmark")
|
||||
|
||||
with (
|
||||
patch.object(
|
||||
determine_jobs,
|
||||
"changed_files",
|
||||
# sensor is a dependency of api, but benchmarks don't expand
|
||||
return_value=["esphome/components/sensor/sensor.cpp"],
|
||||
),
|
||||
patch.object(determine_jobs, "root_path", str(tmp_path)),
|
||||
patch.object(
|
||||
determine_jobs,
|
||||
"BENCHMARKS_COMPONENTS_PATH",
|
||||
"tests/benchmarks/components",
|
||||
),
|
||||
):
|
||||
assert determine_jobs.should_run_benchmarks() is False
|
||||
|
||||
|
||||
def test_should_run_benchmarks_unrelated_change() -> None:
|
||||
"""Test benchmarks do NOT trigger for unrelated changes."""
|
||||
with patch.object(determine_jobs, "changed_files", return_value=["README.md"]):
|
||||
assert determine_jobs.should_run_benchmarks() is False
|
||||
|
||||
|
||||
def test_should_run_benchmarks_no_changes() -> None:
|
||||
"""Test benchmarks do NOT trigger with no changes."""
|
||||
with patch.object(determine_jobs, "changed_files", return_value=[]):
|
||||
assert determine_jobs.should_run_benchmarks() is False
|
||||
|
||||
|
||||
def test_should_run_benchmarks_with_branch() -> None:
|
||||
"""Test should_run_benchmarks passes branch to changed_files."""
|
||||
with patch.object(determine_jobs, "changed_files") as mock_changed:
|
||||
mock_changed.return_value = []
|
||||
determine_jobs.should_run_benchmarks("release")
|
||||
mock_changed.assert_called_with("release")
|
||||
|
||||
@@ -1073,28 +1073,6 @@ def test_get_all_dependencies_platform_component_with_dependencies() -> None:
|
||||
assert result == {"sensor.bthome", "sensor"}
|
||||
|
||||
|
||||
def test_get_all_dependencies_cpp_testing_flag() -> None:
|
||||
"""cpp_testing=True propagates to CORE.cpp_testing during resolution."""
|
||||
from esphome.core import CORE
|
||||
|
||||
with (
|
||||
patch("esphome.loader.get_component") as mock_get_component,
|
||||
patch("esphome.loader.get_platform"),
|
||||
):
|
||||
observed: list[bool] = []
|
||||
|
||||
def capturing_get_component(name: str):
|
||||
observed.append(CORE.cpp_testing)
|
||||
|
||||
mock_get_component.side_effect = capturing_get_component
|
||||
|
||||
helpers.get_all_dependencies({"some_comp"}, cpp_testing=True)
|
||||
|
||||
assert observed and all(observed), (
|
||||
"CORE.cpp_testing should be True during resolution"
|
||||
)
|
||||
|
||||
|
||||
def test_get_components_from_integration_fixtures() -> None:
|
||||
"""Test extraction of components from fixture YAML files."""
|
||||
yaml_content = {
|
||||
|
||||
@@ -0,0 +1,260 @@
|
||||
"""Unit tests for script/build_helpers.py manifest override and build helpers."""
|
||||
|
||||
import os
|
||||
from pathlib import Path
|
||||
import sys
|
||||
import textwrap
|
||||
from unittest.mock import MagicMock, patch
|
||||
|
||||
import pytest
|
||||
|
||||
# Add the script directory to Python path so we can import build_helpers
|
||||
sys.path.insert(
|
||||
0, os.path.abspath(os.path.join(os.path.dirname(__file__), "..", "..", "script"))
|
||||
)
|
||||
|
||||
import build_helpers # noqa: E402
|
||||
|
||||
from esphome.loader import ComponentManifest # noqa: E402
|
||||
from tests.testing_helpers import ComponentManifestOverride # noqa: E402
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Helpers
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def _make_component_manifest(*, to_code=None, dependencies=None) -> ComponentManifest:
|
||||
mod = MagicMock()
|
||||
mod.to_code = to_code
|
||||
mod.DEPENDENCIES = dependencies or []
|
||||
return ComponentManifest(mod)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# filter_components_with_files
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_filter_keeps_components_with_cpp_files(tmp_path: Path) -> None:
|
||||
comp_dir = tmp_path / "mycomp"
|
||||
comp_dir.mkdir()
|
||||
(comp_dir / "mycomp_test.cpp").write_text("")
|
||||
|
||||
result = build_helpers.filter_components_with_files(["mycomp"], tmp_path)
|
||||
|
||||
assert result == ["mycomp"]
|
||||
|
||||
|
||||
def test_filter_keeps_components_with_h_files(tmp_path: Path) -> None:
|
||||
comp_dir = tmp_path / "mycomp"
|
||||
comp_dir.mkdir()
|
||||
(comp_dir / "helpers.h").write_text("")
|
||||
|
||||
result = build_helpers.filter_components_with_files(["mycomp"], tmp_path)
|
||||
|
||||
assert result == ["mycomp"]
|
||||
|
||||
|
||||
def test_filter_drops_components_without_test_dir(tmp_path: Path) -> None:
|
||||
result = build_helpers.filter_components_with_files(["nodir"], tmp_path)
|
||||
|
||||
assert result == []
|
||||
|
||||
|
||||
def test_filter_drops_components_with_no_cpp_or_h(tmp_path: Path) -> None:
|
||||
comp_dir = tmp_path / "mycomp"
|
||||
comp_dir.mkdir()
|
||||
(comp_dir / "README.md").write_text("")
|
||||
|
||||
result = build_helpers.filter_components_with_files(["mycomp"], tmp_path)
|
||||
|
||||
assert result == []
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# get_platform_components
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_get_platform_components_discovers_subdirectory(tmp_path: Path) -> None:
|
||||
(tmp_path / "bthome" / "sensor").mkdir(parents=True)
|
||||
|
||||
sensor_mod = MagicMock()
|
||||
sensor_mod.IS_PLATFORM_COMPONENT = True
|
||||
|
||||
with patch(
|
||||
"build_helpers.get_component", return_value=ComponentManifest(sensor_mod)
|
||||
):
|
||||
result = build_helpers.get_platform_components(["bthome"], tmp_path)
|
||||
|
||||
assert result == ["sensor.bthome"]
|
||||
|
||||
|
||||
def test_get_platform_components_skips_pycache(tmp_path: Path) -> None:
|
||||
(tmp_path / "bthome" / "__pycache__").mkdir(parents=True)
|
||||
|
||||
result = build_helpers.get_platform_components(["bthome"], tmp_path)
|
||||
|
||||
assert result == []
|
||||
|
||||
|
||||
def test_get_platform_components_raises_for_invalid_domain(tmp_path: Path) -> None:
|
||||
(tmp_path / "bthome" / "notadomain").mkdir(parents=True)
|
||||
|
||||
with (
|
||||
patch("build_helpers.get_component", return_value=None),
|
||||
pytest.raises(ValueError, match="notadomain"),
|
||||
):
|
||||
build_helpers.get_platform_components(["bthome"], tmp_path)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# load_test_manifest_overrides
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_load_suppresses_to_code(tmp_path: Path) -> None:
|
||||
"""to_code is always set to None before the override is called."""
|
||||
|
||||
async def real_to_code(config):
|
||||
pass
|
||||
|
||||
inner = _make_component_manifest(to_code=real_to_code)
|
||||
|
||||
with (
|
||||
patch("build_helpers.get_component", return_value=inner),
|
||||
patch("build_helpers.set_testing_manifest") as mock_set,
|
||||
):
|
||||
build_helpers.load_test_manifest_overrides(["mycomp"], tmp_path)
|
||||
installed: ComponentManifestOverride = mock_set.call_args[0][1]
|
||||
|
||||
assert installed.to_code is None
|
||||
|
||||
|
||||
def test_load_calls_override_fn(tmp_path: Path) -> None:
|
||||
"""override_manifest() in test_init is called with the ComponentManifestOverride."""
|
||||
comp_dir = tmp_path / "mycomp"
|
||||
comp_dir.mkdir()
|
||||
init_py = comp_dir / "__init__.py"
|
||||
init_py.write_text(
|
||||
textwrap.dedent("""\
|
||||
def override_manifest(manifest):
|
||||
manifest.dependencies = ["injected"]
|
||||
""")
|
||||
)
|
||||
|
||||
inner = _make_component_manifest()
|
||||
override = ComponentManifestOverride(inner)
|
||||
override.to_code = None
|
||||
|
||||
with (
|
||||
patch("build_helpers.get_component", return_value=inner),
|
||||
patch("build_helpers.set_testing_manifest") as mock_set,
|
||||
):
|
||||
build_helpers.load_test_manifest_overrides(["mycomp"], tmp_path)
|
||||
installed: ComponentManifestOverride = mock_set.call_args[0][1]
|
||||
|
||||
assert installed.dependencies == ["injected"]
|
||||
|
||||
|
||||
def test_load_enable_codegen_in_override(tmp_path: Path) -> None:
|
||||
"""An override_manifest that calls enable_codegen() restores to_code."""
|
||||
|
||||
async def real_to_code(config):
|
||||
pass
|
||||
|
||||
comp_dir = tmp_path / "mycomp"
|
||||
comp_dir.mkdir()
|
||||
init_py = comp_dir / "__init__.py"
|
||||
init_py.write_text(
|
||||
textwrap.dedent("""\
|
||||
def override_manifest(manifest):
|
||||
manifest.enable_codegen()
|
||||
""")
|
||||
)
|
||||
|
||||
inner = _make_component_manifest(to_code=real_to_code)
|
||||
|
||||
with (
|
||||
patch("build_helpers.get_component", return_value=inner),
|
||||
patch("build_helpers.set_testing_manifest") as mock_set,
|
||||
):
|
||||
build_helpers.load_test_manifest_overrides(["mycomp"], tmp_path)
|
||||
installed: ComponentManifestOverride = mock_set.call_args[0][1]
|
||||
|
||||
assert installed.to_code is real_to_code
|
||||
|
||||
|
||||
def test_load_no_override_file(tmp_path: Path) -> None:
|
||||
"""No override file: manifest is wrapped and to_code suppressed, nothing else."""
|
||||
inner = _make_component_manifest()
|
||||
|
||||
with (
|
||||
patch("build_helpers.get_component", return_value=inner),
|
||||
patch("build_helpers.set_testing_manifest") as mock_set,
|
||||
):
|
||||
build_helpers.load_test_manifest_overrides(["mycomp"], tmp_path)
|
||||
|
||||
mock_set.assert_called_once()
|
||||
key, installed = mock_set.call_args[0]
|
||||
assert key == "mycomp"
|
||||
assert isinstance(installed, ComponentManifestOverride)
|
||||
|
||||
|
||||
def test_load_skips_already_wrapped(tmp_path: Path) -> None:
|
||||
"""Components already wrapped as ComponentManifestOverride are not double-wrapped."""
|
||||
inner = _make_component_manifest()
|
||||
already_wrapped = ComponentManifestOverride(inner)
|
||||
|
||||
with (
|
||||
patch("build_helpers.get_component", return_value=already_wrapped),
|
||||
patch("build_helpers.set_testing_manifest") as mock_set,
|
||||
):
|
||||
build_helpers.load_test_manifest_overrides(["mycomp"], tmp_path)
|
||||
|
||||
mock_set.assert_not_called()
|
||||
|
||||
|
||||
def test_load_skips_platform_component_already_wrapped(tmp_path: Path) -> None:
|
||||
inner = _make_component_manifest()
|
||||
already_wrapped = ComponentManifestOverride(inner)
|
||||
|
||||
with (
|
||||
patch("build_helpers.get_platform", return_value=already_wrapped),
|
||||
patch("build_helpers.set_testing_manifest") as mock_set,
|
||||
):
|
||||
build_helpers.load_test_manifest_overrides(["sensor.bthome"], tmp_path)
|
||||
|
||||
mock_set.assert_not_called()
|
||||
|
||||
|
||||
def test_load_wraps_top_level_component(tmp_path: Path) -> None:
|
||||
inner = _make_component_manifest()
|
||||
|
||||
with (
|
||||
patch("build_helpers.get_component", return_value=inner),
|
||||
patch("build_helpers.set_testing_manifest") as mock_set,
|
||||
):
|
||||
build_helpers.load_test_manifest_overrides(["mycomp"], tmp_path)
|
||||
|
||||
mock_set.assert_called_once()
|
||||
key, installed = mock_set.call_args[0]
|
||||
assert key == "mycomp"
|
||||
assert isinstance(installed, ComponentManifestOverride)
|
||||
assert installed.to_code is None
|
||||
|
||||
|
||||
def test_load_wraps_platform_component(tmp_path: Path) -> None:
|
||||
inner = _make_component_manifest()
|
||||
|
||||
with (
|
||||
patch("build_helpers.get_platform", return_value=inner),
|
||||
patch("build_helpers.set_testing_manifest") as mock_set,
|
||||
):
|
||||
build_helpers.load_test_manifest_overrides(["sensor.bthome"], tmp_path)
|
||||
|
||||
mock_set.assert_called_once()
|
||||
key, installed = mock_set.call_args[0]
|
||||
assert key == "bthome.sensor"
|
||||
assert isinstance(installed, ComponentManifestOverride)
|
||||
assert installed.to_code is None
|
||||
@@ -0,0 +1,63 @@
|
||||
from typing import Any
|
||||
|
||||
from esphome.loader import ComponentManifest, _replace_component_manifest
|
||||
|
||||
|
||||
class ComponentManifestOverride:
|
||||
"""Mutable wrapper around ComponentManifest for test-specific attribute overrides.
|
||||
|
||||
When ``tests/components/<name>/__init__.py`` defines::
|
||||
|
||||
def override_manifest(manifest: ComponentManifestOverride) -> None:
|
||||
...
|
||||
|
||||
the function receives an instance of this class wrapping the real component
|
||||
manifest. Any attribute assignment stores an override; reads fall back to
|
||||
the underlying ``ComponentManifest`` when no override has been set.
|
||||
|
||||
Example::
|
||||
|
||||
def override_manifest(manifest: ComponentManifestOverride) -> None:
|
||||
async def to_code_testing(config):
|
||||
pass # lightweight no-op stub for C++ unit tests
|
||||
|
||||
manifest.to_code = to_code_testing
|
||||
manifest.dependencies = manifest.dependencies + ["extra_dep_for_tests"]
|
||||
"""
|
||||
|
||||
def __init__(self, wrapped: "ComponentManifest") -> None:
|
||||
object.__setattr__(self, "_wrapped", wrapped)
|
||||
object.__setattr__(self, "_overrides", {})
|
||||
|
||||
def __getattr__(self, name: str) -> Any:
|
||||
overrides: dict[str, Any] = object.__getattribute__(self, "_overrides")
|
||||
if name in overrides:
|
||||
return overrides[name]
|
||||
wrapped: ComponentManifest = object.__getattribute__(self, "_wrapped")
|
||||
return getattr(wrapped, name)
|
||||
|
||||
def __setattr__(self, name: str, value: Any) -> None:
|
||||
overrides: dict[str, Any] = object.__getattribute__(self, "_overrides")
|
||||
overrides[name] = value
|
||||
|
||||
def enable_codegen(self) -> None:
|
||||
"""Remove the to_code suppression, re-enabling code generation for this component.
|
||||
|
||||
Call this from ``override_manifest`` when the component needs its real (or a
|
||||
custom stub) ``to_code`` to run during C++ unit test builds.
|
||||
"""
|
||||
overrides: dict[str, Any] = object.__getattribute__(self, "_overrides")
|
||||
overrides.pop("to_code", None)
|
||||
|
||||
def restore(self) -> None:
|
||||
"""Clear all overrides, reverting to the wrapped manifest's values."""
|
||||
object.__getattribute__(self, "_overrides").clear()
|
||||
|
||||
|
||||
def set_testing_manifest(domain: str, manifest: ComponentManifestOverride) -> None:
|
||||
"""Install a testing manifest override into the component cache.
|
||||
|
||||
Called from the C++ unit test infrastructure when a component's test
|
||||
directory provides an ``override_manifest`` function.
|
||||
"""
|
||||
_replace_component_manifest(domain, manifest)
|
||||
@@ -2,7 +2,104 @@
|
||||
|
||||
from unittest.mock import MagicMock, patch
|
||||
|
||||
from esphome.loader import ComponentManifest
|
||||
from esphome.loader import ComponentManifest, _replace_component_manifest, get_component
|
||||
from tests.testing_helpers import ComponentManifestOverride
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# ComponentManifestOverride
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def _make_manifest(*, to_code=None, dependencies=None) -> ComponentManifest:
|
||||
"""Return a ComponentManifest backed by a minimal mock module."""
|
||||
mod = MagicMock()
|
||||
mod.to_code = to_code
|
||||
mod.DEPENDENCIES = dependencies or []
|
||||
return ComponentManifest(mod)
|
||||
|
||||
|
||||
def test_testing_manifest_delegates_to_wrapped() -> None:
|
||||
"""Unoverridden attributes fall through to the wrapped manifest."""
|
||||
inner = _make_manifest(dependencies=["wifi"])
|
||||
tm = ComponentManifestOverride(inner)
|
||||
assert tm.dependencies == ["wifi"]
|
||||
|
||||
|
||||
def test_testing_manifest_override_shadows_wrapped() -> None:
|
||||
"""An assigned attribute shadows the wrapped value."""
|
||||
inner = _make_manifest(dependencies=["wifi"])
|
||||
tm = ComponentManifestOverride(inner)
|
||||
tm.dependencies = ["ble"]
|
||||
assert tm.dependencies == ["ble"]
|
||||
# Wrapped value unchanged
|
||||
assert inner.dependencies == ["wifi"]
|
||||
|
||||
|
||||
def test_testing_manifest_to_code_suppression() -> None:
|
||||
"""Setting to_code=None suppresses code generation."""
|
||||
|
||||
async def real_to_code(config):
|
||||
pass
|
||||
|
||||
inner = _make_manifest(to_code=real_to_code)
|
||||
tm = ComponentManifestOverride(inner)
|
||||
tm.to_code = None
|
||||
assert tm.to_code is None
|
||||
|
||||
|
||||
def test_testing_manifest_enable_codegen_removes_suppression() -> None:
|
||||
"""enable_codegen() removes the to_code override, restoring the original."""
|
||||
|
||||
async def real_to_code(config):
|
||||
pass
|
||||
|
||||
inner = _make_manifest(to_code=real_to_code)
|
||||
tm = ComponentManifestOverride(inner)
|
||||
tm.to_code = None
|
||||
assert tm.to_code is None
|
||||
|
||||
tm.enable_codegen()
|
||||
assert tm.to_code is real_to_code
|
||||
|
||||
|
||||
def test_testing_manifest_enable_codegen_preserves_other_overrides() -> None:
|
||||
"""enable_codegen() only removes to_code; other overrides survive."""
|
||||
inner = _make_manifest(dependencies=["wifi"])
|
||||
tm = ComponentManifestOverride(inner)
|
||||
tm.to_code = None
|
||||
tm.dependencies = ["ble"]
|
||||
|
||||
tm.enable_codegen()
|
||||
|
||||
assert tm.to_code is inner.to_code
|
||||
assert tm.dependencies == ["ble"]
|
||||
|
||||
|
||||
def test_testing_manifest_restore_clears_all_overrides() -> None:
|
||||
"""restore() removes every override, reverting all attributes to wrapped values."""
|
||||
|
||||
async def real_to_code(config):
|
||||
pass
|
||||
|
||||
inner = _make_manifest(to_code=real_to_code, dependencies=["wifi"])
|
||||
tm = ComponentManifestOverride(inner)
|
||||
tm.to_code = None
|
||||
tm.dependencies = ["ble"]
|
||||
|
||||
tm.restore()
|
||||
|
||||
assert tm.to_code is real_to_code
|
||||
assert tm.dependencies == ["wifi"]
|
||||
|
||||
|
||||
def test_replace_component_manifest_installs_override() -> None:
|
||||
"""_replace_component_manifest replaces the cached manifest for a domain."""
|
||||
inner = _make_manifest()
|
||||
override = ComponentManifestOverride(inner)
|
||||
|
||||
_replace_component_manifest("_test_dummy_domain", override)
|
||||
|
||||
assert get_component("_test_dummy_domain") is override
|
||||
|
||||
|
||||
def test_component_manifest_resources_with_filter_source_files() -> None:
|
||||
|
||||
@@ -167,6 +167,13 @@ def mock_run_miniterm() -> Generator[Mock]:
|
||||
yield mock
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def mock_wait_for_serial_port() -> Generator[Mock]:
|
||||
"""Mock _wait_for_serial_port for testing."""
|
||||
with patch("esphome.__main__._wait_for_serial_port") as mock:
|
||||
yield mock
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def mock_upload_using_esptool() -> Generator[Mock]:
|
||||
"""Mock upload_using_esptool for testing."""
|
||||
@@ -1706,6 +1713,7 @@ def test_show_logs_serial(
|
||||
mock_get_port_type: Mock,
|
||||
mock_check_permissions: Mock,
|
||||
mock_run_miniterm: Mock,
|
||||
mock_wait_for_serial_port: Mock,
|
||||
) -> None:
|
||||
"""Test show_logs with serial port."""
|
||||
setup_core(config={"logger": {}}, platform=PLATFORM_ESP32)
|
||||
|
||||
Reference in New Issue
Block a user