Files
esphome/tests/unit_tests/test_espidf_clang_tidy.py

297 lines
10 KiB
Python

"""Tests for esphome.espidf.clang_tidy tidy-project generation."""
# pylint: disable=protected-access
import json
import os
from pathlib import Path
from types import SimpleNamespace
from unittest.mock import patch
import pytest
import yaml
from esphome.components.esp32.const import KEY_ESP32, KEY_IDF_VERSION
import esphome.config_validation as cv
from esphome.const import KEY_CORE, KEY_TARGET_FRAMEWORK, KEY_TARGET_PLATFORM
from esphome.core import CORE
from esphome.espidf import clang_tidy
from esphome.espidf.clang_tidy import (
_arduino_excluded_stubs,
_convert_pio_libs,
_esphome_manifest_deps,
_Settings,
_setup_core,
_write_tidy_project,
)
import esphome.espidf.component as espidf_component
REPO_ROOT = Path(__file__).resolve().parents[2]
def _settings(idf_target: str = "esp32", target_framework: str = "espidf") -> _Settings:
return _Settings(
idf_target=idf_target,
variant=idf_target.upper(),
idf_version="5.5.4",
target_framework=target_framework,
platform_defines=(
"USE_ESP32",
f"USE_ESP32_VARIANT_{idf_target.upper()}",
"USE_ESP_IDF",
),
framework_deps={},
)
def test_write_tidy_project_copies_base_sdkconfig(tmp_path: Path) -> None:
"""The shared sdkconfig.defaults is always copied; no per-target file for esp32."""
_write_tidy_project(tmp_path, [], {}, _settings("esp32"))
assert (tmp_path / "sdkconfig.defaults").is_file()
# esp32 has no sdkconfig.defaults.esp32, so nothing extra is copied.
assert not (tmp_path / "sdkconfig.defaults.esp32").exists()
def test_write_tidy_project_copies_per_target_sdkconfig(tmp_path: Path) -> None:
"""A repo-root sdkconfig.defaults.<target> is also copied into the build dir."""
_write_tidy_project(tmp_path, [], {}, _settings("esp32c6"))
target = tmp_path / "sdkconfig.defaults.esp32c6"
assert (tmp_path / "sdkconfig.defaults").is_file()
assert target.is_file()
assert target.read_text(encoding="utf-8") == (
REPO_ROOT / "sdkconfig.defaults.esp32c6"
).read_text(encoding="utf-8")
@pytest.mark.parametrize(
("target_framework", "expected"),
[("arduino", "1"), ("espidf", "0")],
)
def test_setup_core_sets_arduino_env(
tmp_path: Path,
monkeypatch: pytest.MonkeyPatch,
target_framework: str,
expected: str,
) -> None:
"""_setup_core sets ESPHOME_ARDUINO_COMPONENT, which gates arduino-only manifest deps."""
# monkeypatch snapshots os.environ, so the env var _setup_core writes is
# restored after the test instead of leaking into later tests.
monkeypatch.delenv("ESPHOME_ARDUINO_COMPONENT", raising=False)
_setup_core(tmp_path / "proj", _settings(target_framework=target_framework))
assert os.environ["ESPHOME_ARDUINO_COMPONENT"] == expected
def test_idedata_from_tidy_project(tmp_path) -> None:
"""The tidy TU's compile entry is assembled into consumer-shaped idedata."""
compile_commands = tmp_path / "compile_commands.json"
compile_commands.write_text(
json.dumps(
[
{
"directory": str(tmp_path),
"file": str(tmp_path / "main" / "tidy.cpp"),
"command": "/tc/xtensa-esp32-elf-g++ -DUSE_ESP32 "
f"-I{tmp_path}/inc -c main/tidy.cpp -o tidy.o",
}
]
)
)
with patch(
"esphome.espidf.clang_tidy.get_toolchain_includes", return_value=["/tc/inc"]
):
data = clang_tidy._idedata_from_tidy_project(compile_commands)
assert data["cxx_path"] == "/tc/xtensa-esp32-elf-g++"
assert data["defines"] == ["USE_ESP32"]
assert data["includes"]["toolchain"] == ["/tc/inc"]
assert any(inc.endswith("/inc") for inc in data["includes"]["build"])
def test_idedata_from_tidy_project_missing_tu_raises(tmp_path) -> None:
compile_commands = tmp_path / "compile_commands.json"
compile_commands.write_text(json.dumps([]))
with pytest.raises(RuntimeError, match="tidy.cpp not found"):
clang_tidy._idedata_from_tidy_project(compile_commands)
@pytest.mark.parametrize(
("reconfigure_rcs", "error"),
[
((1,), "ESP-IDF CMake configure \\(discovery\\) failed"),
((0, 1), "ESP-IDF CMake configure failed"),
((0, 0), None),
],
ids=["discovery", "full", "ok"],
)
def test_generate_compile_commands_configures_twice(
tmp_path: Path, reconfigure_rcs: tuple[int, ...], error: str | None
) -> None:
"""Discovery configure, then a configure requiring what it found."""
with (
patch.object(clang_tidy, "_setup_core"),
patch.object(clang_tidy, "_convert_pio_libs", return_value={}),
patch.object(clang_tidy, "_write_tidy_project") as mock_write,
patch("esphome.espidf.toolchain.run_reconfigure", side_effect=reconfigure_rcs),
patch(
"esphome.build_gen.espidf.get_available_components",
return_value=["lwip", "esp_timer"],
),
):
if error:
with pytest.raises(RuntimeError, match=error):
clang_tidy._generate_compile_commands(
tmp_path, _settings(), tmp_path / "platformio.ini"
)
return
result = clang_tidy._generate_compile_commands(
tmp_path, _settings(), tmp_path / "platformio.ini"
)
assert result == tmp_path / "build" / "compile_commands.json"
assert mock_write.call_args_list[1].args[1] == ["esp_timer", "lwip"]
def test_esphome_manifest_deps_reads_repo_manifest() -> None:
"""Top-level dependency names, independent of the per-dependency rules."""
manifest = yaml.safe_load(
(REPO_ROOT / "esphome" / "idf_component.yml").read_text(encoding="utf-8")
)
deps = _esphome_manifest_deps()
assert isinstance(deps, set)
assert "esphome/noise-c" in deps
assert "esphome/libsodium" in deps
# Cross-check against a fresh parse instead of hardcoding the manifest's
# whole key list, so this doesn't need updating whenever a dependency is
# added or removed.
assert deps == set(manifest["dependencies"])
def test_convert_pio_libs_arduino_framework_passes_empty_managed(
tmp_path: Path,
monkeypatch: pytest.MonkeyPatch,
) -> None:
"""Below IDF 6.0 the manifest gates noise-c/libsodium off on Arduino, so they
still go through the converter."""
_set_idf_version(cv.Version(5, 5, 5))
monkeypatch.setattr(clang_tidy, "_parse_lib_deps", lambda ini, framework: [])
captured: dict[str, set[str] | None] = {}
# A converted library the batch resolves, so the loop wiring its
# override_path into the returned deps mapping is exercised for real too.
converted = SimpleNamespace(
get_sanitized_name=lambda: "esphome/other-lib",
path=tmp_path / "other-lib",
)
def fake_generate_idf_components(libraries, managed=None):
captured["managed"] = managed
return [converted]
monkeypatch.setattr(
espidf_component, "generate_idf_components", fake_generate_idf_components
)
result = _convert_pio_libs(tmp_path / "platformio.ini", "arduino")
assert captured["managed"] == set()
assert result == {
"esphome/other-lib": {"override_path": str(tmp_path / "other-lib")}
}
def test_convert_pio_libs_arduino_idf_6_passes_manifest_deps(
tmp_path: Path,
monkeypatch: pytest.MonkeyPatch,
) -> None:
"""From IDF 6.0 the manifest enables them on Arduino too, so the converter
must skip them."""
_set_idf_version(cv.Version(6, 0, 0))
monkeypatch.setattr(clang_tidy, "_parse_lib_deps", lambda ini, framework: [])
captured: dict[str, set[str] | None] = {}
def fake_generate_idf_components(libraries, managed=None):
captured["managed"] = managed
return []
monkeypatch.setattr(
espidf_component, "generate_idf_components", fake_generate_idf_components
)
assert _convert_pio_libs(tmp_path / "platformio.ini", "arduino") == {}
assert captured["managed"] == _esphome_manifest_deps()
def test_convert_pio_libs_espidf_framework_passes_manifest_deps(
tmp_path: Path,
monkeypatch: pytest.MonkeyPatch,
) -> None:
"""Manifest-provided components are passed as ``managed`` so the converter
skips them."""
_set_idf_version(cv.Version(5, 5, 5), "espidf")
monkeypatch.setattr(clang_tidy, "_parse_lib_deps", lambda ini, framework: [])
captured: dict[str, set[str] | None] = {}
def fake_generate_idf_components(libraries, managed=None):
captured["managed"] = managed
return []
monkeypatch.setattr(
espidf_component, "generate_idf_components", fake_generate_idf_components
)
result = _convert_pio_libs(tmp_path / "platformio.ini", "espidf")
assert captured["managed"] == _esphome_manifest_deps()
assert "esphome/noise-c" in captured["managed"]
assert result == {}
def _set_idf_version(version: cv.Version, framework: str = "arduino") -> None:
CORE.reset()
CORE.data[KEY_ESP32] = {KEY_IDF_VERSION: version}
CORE.data[KEY_CORE] = {
KEY_TARGET_PLATFORM: "esp32",
KEY_TARGET_FRAMEWORK: framework,
}
def test_arduino_excluded_stubs_skips_components_esphome_manifest_provides(
tmp_path: Path,
) -> None:
"""A component ESPHome's own manifest declares (espressif/lan867x for ethernet)
is not stubbed; one only arduino-esp32 bundles still is."""
_set_idf_version(cv.Version(5, 5, 4))
deps = _arduino_excluded_stubs(tmp_path)
# lan867x is a real ESPHome dependency (esphome/idf_component.yml), so it
# must be excluded from the stub set.
assert "espressif/lan867x" not in deps
# espressif/libsodium (arduino-esp32's bundled copy) is a different
# package from ESPHome's own esphome/libsodium, so below IDF 6.0, where
# arduino-esp32 still declares it, it is stubbed.
assert "espressif/libsodium" in deps
stub_info = deps["espressif/libsodium"]
assert stub_info["version"] == "*"
stub_path = Path(stub_info["override_path"])
assert (stub_path / "CMakeLists.txt").is_file()
def test_arduino_excluded_stubs_skips_libsodium_from_idf_6(tmp_path: Path) -> None:
"""From IDF 6.0 arduino-esp32 drops espressif/libsodium; a stub would clash
with esphome/libsodium."""
_set_idf_version(cv.Version(6, 0, 0))
deps = _arduino_excluded_stubs(tmp_path)
assert "espressif/libsodium" not in deps
# Other arduino-bundled components are still stubbed.
assert "espressif/cbor" in deps