mirror of
https://github.com/esphome/esphome.git
synced 2026-06-02 19:18:20 +08:00
[core] cpp tests: Allow customizing code generation during tests (#14681)
Co-authored-by: J. Nick Koston <nick@home-assistant.io> Co-authored-by: J. Nick Koston <nick@koston.org>
This commit is contained in:
@@ -615,10 +615,6 @@ class EsphomeCore:
|
|||||||
self.address_cache: AddressCache | None = None
|
self.address_cache: AddressCache | None = None
|
||||||
# Cached config hash (computed lazily)
|
# Cached config hash (computed lazily)
|
||||||
self._config_hash: int | None = None
|
self._config_hash: int | None = None
|
||||||
# True if compiling for C++ unit tests
|
|
||||||
self.cpp_testing = False
|
|
||||||
# Allowlist of components whose to_code should run during C++ testing
|
|
||||||
self.cpp_testing_codegen: set[str] = set()
|
|
||||||
|
|
||||||
def reset(self):
|
def reset(self):
|
||||||
from esphome.pins import PIN_SCHEMA_REGISTRY
|
from esphome.pins import PIN_SCHEMA_REGISTRY
|
||||||
@@ -648,8 +644,6 @@ class EsphomeCore:
|
|||||||
self.current_component = None
|
self.current_component = None
|
||||||
self.address_cache = None
|
self.address_cache = None
|
||||||
self._config_hash = None
|
self._config_hash = None
|
||||||
self.cpp_testing = False
|
|
||||||
self.cpp_testing_codegen = set()
|
|
||||||
PIN_SCHEMA_REGISTRY.reset()
|
PIN_SCHEMA_REGISTRY.reset()
|
||||||
|
|
||||||
@contextmanager
|
@contextmanager
|
||||||
|
|||||||
+10
-5
@@ -71,11 +71,6 @@ class ComponentManifest:
|
|||||||
|
|
||||||
@property
|
@property
|
||||||
def to_code(self) -> Callable[[Any], None] | None:
|
def to_code(self) -> Callable[[Any], None] | None:
|
||||||
if CORE.cpp_testing:
|
|
||||||
# During C++ testing, only run to_code for allowlisted components
|
|
||||||
name = self.module.__package__.rsplit(".", 1)[-1]
|
|
||||||
if name not in CORE.cpp_testing_codegen:
|
|
||||||
return None
|
|
||||||
return getattr(self.module, "to_code", None)
|
return getattr(self.module, "to_code", None)
|
||||||
|
|
||||||
@property
|
@property
|
||||||
@@ -243,3 +238,13 @@ def get_platform(domain: str, platform: str) -> ComponentManifest | None:
|
|||||||
_COMPONENT_CACHE: dict[str, ComponentManifest] = {}
|
_COMPONENT_CACHE: dict[str, ComponentManifest] = {}
|
||||||
CORE_COMPONENTS_PATH = (Path(__file__).parent / "components").resolve()
|
CORE_COMPONENTS_PATH = (Path(__file__).parent / "components").resolve()
|
||||||
_COMPONENT_CACHE["esphome"] = ComponentManifest(esphome.core.config)
|
_COMPONENT_CACHE["esphome"] = ComponentManifest(esphome.core.config)
|
||||||
|
|
||||||
|
|
||||||
|
def _replace_component_manifest(domain: str, manifest: ComponentManifest) -> None:
|
||||||
|
"""Replace the cached manifest for a component.
|
||||||
|
|
||||||
|
This is an intentionally-supported hook for the C++ test infrastructure
|
||||||
|
to install ``ComponentManifestOverride`` wrappers. Normal application
|
||||||
|
code should never call this.
|
||||||
|
"""
|
||||||
|
_COMPONENT_CACHE[domain] = manifest
|
||||||
|
|||||||
@@ -2,21 +2,29 @@
|
|||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from collections.abc import Callable
|
||||||
import hashlib
|
import hashlib
|
||||||
|
import importlib.util
|
||||||
import os
|
import os
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
import subprocess
|
import subprocess
|
||||||
import sys
|
import sys
|
||||||
|
|
||||||
from helpers import get_all_dependencies
|
from helpers import get_all_dependencies, root_path as _root_path
|
||||||
import yaml
|
import yaml
|
||||||
|
|
||||||
|
# Ensure the repo root is on sys.path so that ``tests.testing_helpers`` and
|
||||||
|
# override ``__init__.py`` modules can ``from tests.testing_helpers import ...``.
|
||||||
|
if _root_path not in sys.path:
|
||||||
|
sys.path.insert(0, _root_path)
|
||||||
|
|
||||||
from esphome.__main__ import command_compile, parse_args
|
from esphome.__main__ import command_compile, parse_args
|
||||||
from esphome.config import validate_config
|
from esphome.config import validate_config
|
||||||
from esphome.const import CONF_PLATFORM
|
from esphome.const import CONF_PLATFORM
|
||||||
from esphome.core import CORE
|
from esphome.core import CORE
|
||||||
from esphome.loader import get_component
|
from esphome.loader import get_component, get_platform
|
||||||
from esphome.platformio_api import get_idedata
|
from esphome.platformio_api import get_idedata
|
||||||
|
from tests.testing_helpers import ComponentManifestOverride, set_testing_manifest
|
||||||
|
|
||||||
# This must coincide with the version in /platformio.ini
|
# This must coincide with the version in /platformio.ini
|
||||||
PLATFORMIO_GOOGLE_TEST_LIB = "google/googletest@^1.15.2"
|
PLATFORMIO_GOOGLE_TEST_LIB = "google/googletest@^1.15.2"
|
||||||
@@ -32,19 +40,6 @@ ESPHOME_KEY = "esphome"
|
|||||||
HOST_KEY = "host"
|
HOST_KEY = "host"
|
||||||
LOGGER_KEY = "logger"
|
LOGGER_KEY = "logger"
|
||||||
|
|
||||||
# Base config keys that are always present and must not be fully overridden
|
|
||||||
# by component benchmark.yaml files. esphome: allows sub-key merging.
|
|
||||||
BASE_CONFIG_KEYS = frozenset({ESPHOME_KEY, HOST_KEY, LOGGER_KEY})
|
|
||||||
|
|
||||||
# Shared build flag — enables timezone code paths for testing/benchmarking.
|
|
||||||
USE_TIME_TIMEZONE_FLAG = "-DUSE_TIME_TIMEZONE"
|
|
||||||
|
|
||||||
# Components whose to_code should always run during C++ test/benchmark builds.
|
|
||||||
# These are the minimal infrastructure components needed for host compilation.
|
|
||||||
# Note: "core" is the esphome core config module (esphome/core/config.py),
|
|
||||||
# which registers under package name "core" not "esphome".
|
|
||||||
BASE_CODEGEN_COMPONENTS = {"core", "host", "logger"}
|
|
||||||
|
|
||||||
# Exit codes
|
# Exit codes
|
||||||
EXIT_OK = 0
|
EXIT_OK = 0
|
||||||
EXIT_SKIPPED = 1
|
EXIT_SKIPPED = 1
|
||||||
@@ -110,6 +105,8 @@ def get_platform_components(components: list[str], tests_dir: Path) -> list[str]
|
|||||||
if not domain_dir.is_dir():
|
if not domain_dir.is_dir():
|
||||||
continue
|
continue
|
||||||
domain = domain_dir.name
|
domain = domain_dir.name
|
||||||
|
if domain.startswith("__"):
|
||||||
|
continue
|
||||||
domain_module = get_component(domain)
|
domain_module = get_component(domain)
|
||||||
if domain_module is None or not domain_module.is_platform_component:
|
if domain_module is None or not domain_module.is_platform_component:
|
||||||
raise ValueError(
|
raise ValueError(
|
||||||
@@ -130,7 +127,8 @@ def load_component_yaml_configs(components: list[str], tests_dir: Path) -> dict:
|
|||||||
|
|
||||||
The ``esphome:`` key is special: its sub-keys are merged into the
|
The ``esphome:`` key is special: its sub-keys are merged into the
|
||||||
existing esphome config (e.g. to add ``areas:`` or ``devices:``).
|
existing esphome config (e.g. to add ``areas:`` or ``devices:``).
|
||||||
Other base config keys (``host:``, ``logger:``) are not overridable.
|
Keys already present in the base config (e.g. ``host:``, ``logger:``)
|
||||||
|
are protected by ``setdefault`` in the caller.
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
components: List of component directory names
|
components: List of component directory names
|
||||||
@@ -139,11 +137,6 @@ def load_component_yaml_configs(components: list[str], tests_dir: Path) -> dict:
|
|||||||
Returns:
|
Returns:
|
||||||
Merged dict of component configs to add to the base config
|
Merged dict of component configs to add to the base config
|
||||||
"""
|
"""
|
||||||
# Note: components are processed in sorted order. For conflicting keys
|
|
||||||
# (e.g. two benchmark.yaml files both declaring sensor:), the first
|
|
||||||
# component alphabetically wins via setdefault(). This is fine for now
|
|
||||||
# with a single benchmark component (api) but would need a real merge
|
|
||||||
# strategy if multiple components declare overlapping configs.
|
|
||||||
merged: dict = {}
|
merged: dict = {}
|
||||||
for component in components:
|
for component in components:
|
||||||
yaml_path = tests_dir / component / BENCHMARK_YAML_FILENAME
|
yaml_path = tests_dir / component / BENCHMARK_YAML_FILENAME
|
||||||
@@ -153,9 +146,6 @@ def load_component_yaml_configs(components: list[str], tests_dir: Path) -> dict:
|
|||||||
component_config = yaml.safe_load(f)
|
component_config = yaml.safe_load(f)
|
||||||
if component_config and isinstance(component_config, dict):
|
if component_config and isinstance(component_config, dict):
|
||||||
for key, value in component_config.items():
|
for key, value in component_config.items():
|
||||||
if key in BASE_CONFIG_KEYS - {ESPHOME_KEY}:
|
|
||||||
# host: and logger: are not overridable
|
|
||||||
continue
|
|
||||||
if key == ESPHOME_KEY and isinstance(value, dict):
|
if key == ESPHOME_KEY and isinstance(value, dict):
|
||||||
# Merge esphome sub-keys rather than replacing
|
# Merge esphome sub-keys rather than replacing
|
||||||
esphome_extra = merged.setdefault(ESPHOME_KEY, {})
|
esphome_extra = merged.setdefault(ESPHOME_KEY, {})
|
||||||
@@ -198,11 +188,78 @@ def create_host_config(
|
|||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def _wrap_manifest(
|
||||||
|
comp_name: str,
|
||||||
|
) -> ComponentManifestOverride | None:
|
||||||
|
"""Wrap a component manifest in a ComponentManifestOverride with to_code suppressed.
|
||||||
|
|
||||||
|
If the manifest is already wrapped or not found, returns None.
|
||||||
|
Otherwise returns the newly created override after installing it.
|
||||||
|
"""
|
||||||
|
if "." in comp_name:
|
||||||
|
domain, component = comp_name.split(".", maxsplit=1)
|
||||||
|
manifest = get_platform(domain, component)
|
||||||
|
cache_key = f"{component}.{domain}"
|
||||||
|
else:
|
||||||
|
manifest = get_component(comp_name)
|
||||||
|
cache_key = comp_name
|
||||||
|
|
||||||
|
if manifest is None or isinstance(manifest, ComponentManifestOverride):
|
||||||
|
return None
|
||||||
|
|
||||||
|
override = ComponentManifestOverride(manifest)
|
||||||
|
override.to_code = None # suppress by default
|
||||||
|
set_testing_manifest(cache_key, override)
|
||||||
|
return override
|
||||||
|
|
||||||
|
|
||||||
|
def load_test_manifest_overrides(
|
||||||
|
components: list[str],
|
||||||
|
tests_dir: Path,
|
||||||
|
) -> None:
|
||||||
|
"""Apply per-component manifest overrides from test ``__init__.py`` files.
|
||||||
|
|
||||||
|
For every component, wraps its manifest and suppresses ``to_code``.
|
||||||
|
If the component's test directory contains an ``__init__.py`` that
|
||||||
|
defines ``override_manifest(manifest)``, it is called to customise
|
||||||
|
the override (e.g. ``manifest.enable_codegen()``).
|
||||||
|
"""
|
||||||
|
for comp_name in components:
|
||||||
|
override = _wrap_manifest(comp_name)
|
||||||
|
if override is None:
|
||||||
|
continue
|
||||||
|
|
||||||
|
if "." in comp_name:
|
||||||
|
domain, component = comp_name.split(".", maxsplit=1)
|
||||||
|
cache_key = f"{component}.{domain}"
|
||||||
|
test_init = tests_dir / component / domain / "__init__.py"
|
||||||
|
else:
|
||||||
|
cache_key = comp_name
|
||||||
|
test_init = tests_dir / comp_name / "__init__.py"
|
||||||
|
|
||||||
|
if not test_init.is_file():
|
||||||
|
continue
|
||||||
|
spec = importlib.util.spec_from_file_location(
|
||||||
|
f"_test_manifest_override.{cache_key}", test_init
|
||||||
|
)
|
||||||
|
if spec is None or spec.loader is None:
|
||||||
|
continue
|
||||||
|
mod = importlib.util.module_from_spec(spec)
|
||||||
|
spec.loader.exec_module(mod)
|
||||||
|
override_fn = getattr(mod, "override_manifest", None)
|
||||||
|
if override_fn is not None:
|
||||||
|
override_fn(override)
|
||||||
|
|
||||||
|
|
||||||
|
# Type alias for manifest override loaders
|
||||||
|
ManifestOverrideLoader = Callable[[list[str]], None]
|
||||||
|
|
||||||
|
|
||||||
def compile_and_get_binary(
|
def compile_and_get_binary(
|
||||||
config: dict,
|
config: dict,
|
||||||
components: list[str],
|
components: list[str],
|
||||||
codegen_components: set[str],
|
|
||||||
tests_dir: Path,
|
tests_dir: Path,
|
||||||
|
manifest_override_loader: ManifestOverrideLoader,
|
||||||
label: str = "build",
|
label: str = "build",
|
||||||
) -> tuple[int, str | None]:
|
) -> tuple[int, str | None]:
|
||||||
"""Compile an ESPHome configuration and return the binary path.
|
"""Compile an ESPHome configuration and return the binary path.
|
||||||
@@ -210,8 +267,8 @@ def compile_and_get_binary(
|
|||||||
Args:
|
Args:
|
||||||
config: ESPHome configuration dict (already created via create_host_config)
|
config: ESPHome configuration dict (already created via create_host_config)
|
||||||
components: List of components to include in the build
|
components: List of components to include in the build
|
||||||
codegen_components: Set of component names whose to_code should run
|
|
||||||
tests_dir: Base directory for test files (used as config_path base)
|
tests_dir: Base directory for test files (used as config_path base)
|
||||||
|
manifest_override_loader: Callback to apply manifest overrides for components
|
||||||
label: Label for log messages (e.g. "unit tests", "benchmarks")
|
label: Label for log messages (e.g. "unit tests", "benchmarks")
|
||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
@@ -238,18 +295,21 @@ def compile_and_get_binary(
|
|||||||
else:
|
else:
|
||||||
config.setdefault(key, value)
|
config.setdefault(key, value)
|
||||||
|
|
||||||
|
# Apply manifest overrides before dependency resolution so that any
|
||||||
|
# dependency additions made by override_manifest() are picked up.
|
||||||
|
manifest_override_loader(components)
|
||||||
|
|
||||||
# Obtain possible dependencies BEFORE validate_config, because
|
# Obtain possible dependencies BEFORE validate_config, because
|
||||||
# get_all_dependencies calls CORE.reset() which clears build_path.
|
# get_all_dependencies calls CORE.reset() which clears build_path.
|
||||||
# Always include 'time' because USE_TIME_TIMEZONE is defined as a build flag,
|
|
||||||
# which causes core/time.h to include components/time/posix_tz.h.
|
|
||||||
components_with_dependencies: list[str] = sorted(
|
components_with_dependencies: list[str] = sorted(
|
||||||
get_all_dependencies(set(components) | {"time"}, cpp_testing=True)
|
get_all_dependencies(set(components))
|
||||||
)
|
)
|
||||||
|
|
||||||
|
# Apply overrides for any transitively discovered dependencies.
|
||||||
|
manifest_override_loader(components_with_dependencies)
|
||||||
|
|
||||||
CORE.config_path = tests_dir / "dummy.yaml"
|
CORE.config_path = tests_dir / "dummy.yaml"
|
||||||
CORE.dashboard = None
|
CORE.dashboard = None
|
||||||
CORE.cpp_testing = True
|
|
||||||
CORE.cpp_testing_codegen = codegen_components
|
|
||||||
|
|
||||||
# Validate config will expand the above with defaults:
|
# Validate config will expand the above with defaults:
|
||||||
config = validate_config(config, {})
|
config = validate_config(config, {})
|
||||||
@@ -305,7 +365,7 @@ def compile_and_get_binary(
|
|||||||
def build_and_run(
|
def build_and_run(
|
||||||
selected_components: list[str],
|
selected_components: list[str],
|
||||||
tests_dir: Path,
|
tests_dir: Path,
|
||||||
codegen_components: set[str],
|
manifest_override_loader: ManifestOverrideLoader,
|
||||||
config_prefix: str,
|
config_prefix: str,
|
||||||
friendly_name: str,
|
friendly_name: str,
|
||||||
libraries: str | list[str],
|
libraries: str | list[str],
|
||||||
@@ -324,7 +384,7 @@ def build_and_run(
|
|||||||
Args:
|
Args:
|
||||||
selected_components: Components to include (directory names in tests_dir)
|
selected_components: Components to include (directory names in tests_dir)
|
||||||
tests_dir: Directory containing test/benchmark files
|
tests_dir: Directory containing test/benchmark files
|
||||||
codegen_components: Components whose to_code should run
|
manifest_override_loader: Callback to apply manifest overrides for components
|
||||||
config_prefix: Prefix for the config name (e.g. "cpptests", "cppbench")
|
config_prefix: Prefix for the config name (e.g. "cpptests", "cppbench")
|
||||||
friendly_name: Human-readable name for the config
|
friendly_name: Human-readable name for the config
|
||||||
libraries: PlatformIO library specification(s)
|
libraries: PlatformIO library specification(s)
|
||||||
@@ -382,7 +442,7 @@ def build_and_run(
|
|||||||
)
|
)
|
||||||
|
|
||||||
exit_code, program_path = compile_and_get_binary(
|
exit_code, program_path = compile_and_get_binary(
|
||||||
config, components, codegen_components, tests_dir, label
|
config, components, tests_dir, manifest_override_loader, label
|
||||||
)
|
)
|
||||||
|
|
||||||
if exit_code != EXIT_OK or program_path is None:
|
if exit_code != EXIT_OK or program_path is None:
|
||||||
+7
-11
@@ -2,18 +2,18 @@
|
|||||||
"""Build and run C++ benchmarks for ESPHome components using Google Benchmark."""
|
"""Build and run C++ benchmarks for ESPHome components using Google Benchmark."""
|
||||||
|
|
||||||
import argparse
|
import argparse
|
||||||
|
from functools import partial
|
||||||
import json
|
import json
|
||||||
import os
|
import os
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
import sys
|
import sys
|
||||||
|
|
||||||
from helpers import root_path
|
from build_helpers import (
|
||||||
from test_helpers import (
|
|
||||||
BASE_CODEGEN_COMPONENTS,
|
|
||||||
PLATFORMIO_GOOGLE_BENCHMARK_LIB,
|
PLATFORMIO_GOOGLE_BENCHMARK_LIB,
|
||||||
USE_TIME_TIMEZONE_FLAG,
|
|
||||||
build_and_run,
|
build_and_run,
|
||||||
|
load_test_manifest_overrides,
|
||||||
)
|
)
|
||||||
|
from helpers import root_path
|
||||||
|
|
||||||
# Path to /tests/benchmarks/components
|
# Path to /tests/benchmarks/components
|
||||||
BENCHMARKS_DIR: Path = Path(root_path) / "tests" / "benchmarks" / "components"
|
BENCHMARKS_DIR: Path = Path(root_path) / "tests" / "benchmarks" / "components"
|
||||||
@@ -21,11 +21,6 @@ BENCHMARKS_DIR: Path = Path(root_path) / "tests" / "benchmarks" / "components"
|
|||||||
# Path to /tests/benchmarks/core (always included, not a component)
|
# Path to /tests/benchmarks/core (always included, not a component)
|
||||||
CORE_BENCHMARKS_DIR: Path = Path(root_path) / "tests" / "benchmarks" / "core"
|
CORE_BENCHMARKS_DIR: Path = Path(root_path) / "tests" / "benchmarks" / "core"
|
||||||
|
|
||||||
# Additional codegen components beyond the base set.
|
|
||||||
# json is needed because its to_code adds the ArduinoJson library
|
|
||||||
# (auto-loaded by api, but cpp_testing suppresses to_code unless listed).
|
|
||||||
BENCHMARK_CODEGEN_COMPONENTS = BASE_CODEGEN_COMPONENTS | {"json"}
|
|
||||||
|
|
||||||
PLATFORMIO_OPTIONS = {
|
PLATFORMIO_OPTIONS = {
|
||||||
"build_unflags": [
|
"build_unflags": [
|
||||||
"-Os", # remove default size-opt
|
"-Os", # remove default size-opt
|
||||||
@@ -33,7 +28,6 @@ PLATFORMIO_OPTIONS = {
|
|||||||
"build_flags": [
|
"build_flags": [
|
||||||
"-O2", # optimize for speed (CodSpeed recommends RelWithDebInfo)
|
"-O2", # optimize for speed (CodSpeed recommends RelWithDebInfo)
|
||||||
"-g", # debug symbols for profiling
|
"-g", # debug symbols for profiling
|
||||||
USE_TIME_TIMEZONE_FLAG,
|
|
||||||
"-DUSE_BENCHMARK", # disable WarnIfComponentBlockingGuard in finish()
|
"-DUSE_BENCHMARK", # disable WarnIfComponentBlockingGuard in finish()
|
||||||
],
|
],
|
||||||
# Use deep+ LDF mode to ensure PlatformIO detects the benchmark
|
# Use deep+ LDF mode to ensure PlatformIO detects the benchmark
|
||||||
@@ -73,7 +67,9 @@ def run_benchmarks(selected_components: list[str], build_only: bool = False) ->
|
|||||||
return build_and_run(
|
return build_and_run(
|
||||||
selected_components=selected_components,
|
selected_components=selected_components,
|
||||||
tests_dir=BENCHMARKS_DIR,
|
tests_dir=BENCHMARKS_DIR,
|
||||||
codegen_components=BENCHMARK_CODEGEN_COMPONENTS,
|
manifest_override_loader=partial(
|
||||||
|
load_test_manifest_overrides, tests_dir=BENCHMARKS_DIR
|
||||||
|
),
|
||||||
config_prefix="cppbench",
|
config_prefix="cppbench",
|
||||||
friendly_name="CPP Benchmarks",
|
friendly_name="CPP Benchmarks",
|
||||||
libraries=benchmark_lib,
|
libraries=benchmark_lib,
|
||||||
|
|||||||
@@ -1,15 +1,15 @@
|
|||||||
#!/usr/bin/env python3
|
#!/usr/bin/env python3
|
||||||
import argparse
|
import argparse
|
||||||
|
from functools import partial
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
import sys
|
import sys
|
||||||
|
|
||||||
from helpers import get_all_components, root_path
|
from build_helpers import (
|
||||||
from test_helpers import (
|
|
||||||
BASE_CODEGEN_COMPONENTS,
|
|
||||||
PLATFORMIO_GOOGLE_TEST_LIB,
|
PLATFORMIO_GOOGLE_TEST_LIB,
|
||||||
USE_TIME_TIMEZONE_FLAG,
|
|
||||||
build_and_run,
|
build_and_run,
|
||||||
|
load_test_manifest_overrides,
|
||||||
)
|
)
|
||||||
|
from helpers import get_all_components, root_path
|
||||||
|
|
||||||
# Path to /tests/components
|
# Path to /tests/components
|
||||||
COMPONENTS_TESTS_DIR: Path = Path(root_path) / "tests" / "components"
|
COMPONENTS_TESTS_DIR: Path = Path(root_path) / "tests" / "components"
|
||||||
@@ -21,7 +21,6 @@ PLATFORMIO_OPTIONS = {
|
|||||||
],
|
],
|
||||||
"build_flags": [
|
"build_flags": [
|
||||||
"-Og", # optimize for debug
|
"-Og", # optimize for debug
|
||||||
USE_TIME_TIMEZONE_FLAG,
|
|
||||||
"-DESPHOME_DEBUG", # enable debug assertions
|
"-DESPHOME_DEBUG", # enable debug assertions
|
||||||
# Enable the address and undefined behavior sanitizers
|
# Enable the address and undefined behavior sanitizers
|
||||||
"-fsanitize=address",
|
"-fsanitize=address",
|
||||||
@@ -39,7 +38,9 @@ def run_tests(selected_components: list[str]) -> int:
|
|||||||
return build_and_run(
|
return build_and_run(
|
||||||
selected_components=selected_components,
|
selected_components=selected_components,
|
||||||
tests_dir=COMPONENTS_TESTS_DIR,
|
tests_dir=COMPONENTS_TESTS_DIR,
|
||||||
codegen_components=BASE_CODEGEN_COMPONENTS,
|
manifest_override_loader=partial(
|
||||||
|
load_test_manifest_overrides, tests_dir=COMPONENTS_TESTS_DIR
|
||||||
|
),
|
||||||
config_prefix="cpptests",
|
config_prefix="cpptests",
|
||||||
friendly_name="CPP Unit Tests",
|
friendly_name="CPP Unit Tests",
|
||||||
libraries=PLATFORMIO_GOOGLE_TEST_LIB,
|
libraries=PLATFORMIO_GOOGLE_TEST_LIB,
|
||||||
|
|||||||
@@ -388,7 +388,7 @@ BENCHMARKS_COMPONENTS_PATH = "tests/benchmarks/components"
|
|||||||
BENCHMARK_INFRASTRUCTURE_FILES = frozenset(
|
BENCHMARK_INFRASTRUCTURE_FILES = frozenset(
|
||||||
{
|
{
|
||||||
"script/cpp_benchmark.py",
|
"script/cpp_benchmark.py",
|
||||||
"script/test_helpers.py",
|
"script/build_helpers.py",
|
||||||
"script/setup_codspeed_lib.py",
|
"script/setup_codspeed_lib.py",
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
@@ -402,7 +402,7 @@ def should_run_benchmarks(branch: str | None = None) -> bool:
|
|||||||
1. Core C++ files changed (esphome/core/*)
|
1. Core C++ files changed (esphome/core/*)
|
||||||
2. A directly changed component has benchmark files (no dependency expansion)
|
2. A directly changed component has benchmark files (no dependency expansion)
|
||||||
3. Benchmark infrastructure changed (tests/benchmarks/*, script/cpp_benchmark.py,
|
3. Benchmark infrastructure changed (tests/benchmarks/*, script/cpp_benchmark.py,
|
||||||
script/test_helpers.py, script/setup_codspeed_lib.py)
|
script/build_helpers.py, script/setup_codspeed_lib.py)
|
||||||
|
|
||||||
Unlike unit tests, benchmarks do NOT expand to dependent components.
|
Unlike unit tests, benchmarks do NOT expand to dependent components.
|
||||||
Changing ``sensor`` does not trigger ``api`` benchmarks just because
|
Changing ``sensor`` does not trigger ``api`` benchmarks just because
|
||||||
|
|||||||
+1
-4
@@ -627,14 +627,12 @@ def get_usable_cpu_count() -> int:
|
|||||||
|
|
||||||
|
|
||||||
def get_all_dependencies(
|
def get_all_dependencies(
|
||||||
component_names: set[str], cpp_testing: bool = False
|
component_names: set[str],
|
||||||
) -> set[str]:
|
) -> set[str]:
|
||||||
"""Get all dependencies for a set of components.
|
"""Get all dependencies for a set of components.
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
component_names: Set of component names to get dependencies for
|
component_names: Set of component names to get dependencies for
|
||||||
cpp_testing: If True, set CORE.cpp_testing so AUTO_LOAD callables that
|
|
||||||
conditionally include testing-only dependencies work correctly
|
|
||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
Set of all components including dependencies and auto-loaded components
|
Set of all components including dependencies and auto-loaded components
|
||||||
@@ -652,7 +650,6 @@ def get_all_dependencies(
|
|||||||
|
|
||||||
# Reset CORE to ensure clean state
|
# Reset CORE to ensure clean state
|
||||||
CORE.reset()
|
CORE.reset()
|
||||||
CORE.cpp_testing = cpp_testing
|
|
||||||
|
|
||||||
# Set up fake config path for component loading
|
# Set up fake config path for component loading
|
||||||
root = Path(__file__).parent.parent
|
root = Path(__file__).parent.parent
|
||||||
|
|||||||
@@ -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
|
### 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
|
During C++ test builds, `to_code` is suppressed for every component by default — most components do not
|
||||||
need to generate configuration code for testing.
|
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,
|
#### Manifest overrides
|
||||||
add the component name to the `CPP_TESTING_CODEGEN_COMPONENTS` allowlist in `script/cpp_unit_test.py`.
|
|
||||||
|
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
|
## 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
|
||||||
@@ -1846,7 +1846,7 @@ def test_should_run_benchmarks_benchmark_infra_change() -> None:
|
|||||||
"""Test benchmarks trigger on benchmark infrastructure changes."""
|
"""Test benchmarks trigger on benchmark infrastructure changes."""
|
||||||
for infra_file in [
|
for infra_file in [
|
||||||
"script/cpp_benchmark.py",
|
"script/cpp_benchmark.py",
|
||||||
"script/test_helpers.py",
|
"script/build_helpers.py",
|
||||||
"script/setup_codspeed_lib.py",
|
"script/setup_codspeed_lib.py",
|
||||||
]:
|
]:
|
||||||
with patch.object(determine_jobs, "changed_files", return_value=[infra_file]):
|
with patch.object(determine_jobs, "changed_files", return_value=[infra_file]):
|
||||||
|
|||||||
@@ -1073,28 +1073,6 @@ def test_get_all_dependencies_platform_component_with_dependencies() -> None:
|
|||||||
assert result == {"sensor.bthome", "sensor"}
|
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:
|
def test_get_components_from_integration_fixtures() -> None:
|
||||||
"""Test extraction of components from fixture YAML files."""
|
"""Test extraction of components from fixture YAML files."""
|
||||||
yaml_content = {
|
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 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:
|
def test_component_manifest_resources_with_filter_source_files() -> None:
|
||||||
|
|||||||
Reference in New Issue
Block a user