Files

202 lines
7.3 KiB
Python

"""Tests for esphome.build_helpers.pch."""
from __future__ import annotations
import logging
import os
from pathlib import Path
import sys
from unittest.mock import patch
import pytest
from esphome.build_helpers import pch
from esphome.const import KEY_CORE, KEY_TARGET_PLATFORM
from esphome.core import CORE
def _write(src_dir: Path, name: str, content: str) -> None:
path = src_dir / name
path.parent.mkdir(parents=True, exist_ok=True)
path.write_text(content)
@pytest.mark.parametrize(
("value", "expected"),
[
(None, True),
("1", True),
("0", False),
("false", False),
("", False),
],
)
def test_pch_enabled(value: str | None, expected: bool) -> None:
env = {} if value is None else {"ESPHOME_PCH_ENABLE": value}
with patch.dict(os.environ, env, clear=True):
assert pch.pch_enabled() is expected
@pytest.mark.parametrize(
("version", "expected"),
[
((), False),
((10, 3), False),
((12, 2, 1), False),
((14, 2, 0), False),
((14, 3), False),
((14, 4), True),
((14,), False),
((15, 2, 0), False),
((15, 3), True),
((16, 0), True),
((17, 1), True),
],
)
def test_gcc_relocates_pch_on_windows(version: tuple[int, ...], expected: bool) -> None:
assert pch.gcc_relocates_pch_on_windows(version) is expected
@pytest.mark.parametrize(
("banner", "expected"),
[
("xtensa-esp32-elf-g++ (crosstool-NG esp-14.2.0_20260121) 14.2.0", (14, 2, 0)),
("arm-zephyr-eabi-g++ (Zephyr SDK 0.16.8) 12.2.0", (12, 2, 0)),
("g++.exe (Rev3, Built by MSYS2 project) 14.2.0", (14, 2, 0)),
("clang version 15.0.0", None),
("Apple clang version 17.0.0 (clang-1700.0.13.3)", None),
("something else", ()),
],
)
def test_gcc_version_reads_the_banner(banner: str, expected: object) -> None:
cxx = (sys.executable, "-c", f"print({banner!r}); print('more')")
assert pch.gcc_version(cxx) == expected
def test_gcc_version_of_a_compiler_that_cannot_run() -> None:
assert pch.gcc_version(("/nonexistent/g++",)) == ()
def test_pch_usable_asks_the_compiler_on_windows_only(
monkeypatch: pytest.MonkeyPatch, caplog: pytest.LogCaptureFixture
) -> None:
caplog.set_level(logging.INFO, logger=pch.__name__)
monkeypatch.delenv("ESPHOME_PCH_ENABLE")
monkeypatch.setattr(pch.sys, "platform", "darwin")
with patch.object(pch, "gcc_version", side_effect=AssertionError("off Windows")):
assert pch.pch_usable(("g++",))
monkeypatch.setattr(pch.sys, "platform", "win32")
with patch.object(pch, "gcc_version", return_value=(14, 2, 0)):
assert not pch.pch_usable(("g++",))
assert "GCC 14.2.0 cannot load a precompiled header on Windows" in caplog.text
with patch.object(pch, "gcc_version", return_value=(14, 4, 0)):
assert pch.pch_usable(("g++",))
# The bug is GCC's; another compiler is not held to its table
with patch.object(pch, "gcc_version", return_value=None):
assert pch.pch_usable(("clang++",))
# The knob overrides the rule both ways
with patch.object(pch, "gcc_version", side_effect=AssertionError("forced")):
monkeypatch.setenv("ESPHOME_PCH_ENABLE", "1")
assert pch.pch_usable(("g++",))
monkeypatch.setenv("ESPHOME_PCH_ENABLE", "0")
assert not pch.pch_usable(("g++",))
def test_ccache_pch_env_enabled() -> None:
with patch.dict(os.environ, {}, clear=True):
env = pch.ccache_pch_env()
assert env == {
"CCACHE_SLOPPINESS": "pch_defines,time_macros",
"CCACHE_PCH_EXTSUM": "true",
}
def test_ccache_pch_env_keeps_user_values() -> None:
"""A user sloppiness list without the pch entries would stop ccache
from caching every compile that loads the .gch."""
user = {"CCACHE_SLOPPINESS": "locale, time_macros", "CCACHE_PCH_EXTSUM": "false"}
with patch.dict(os.environ, user, clear=True):
assert pch.ccache_pch_env() == {
"CCACHE_SLOPPINESS": "locale,time_macros,pch_defines"
}
def test_ccache_pch_env_disabled() -> None:
with patch.dict(os.environ, {"ESPHOME_PCH_ENABLE": "0"}, clear=True):
assert pch.ccache_pch_env() == {}
def test_pch_header_text_preserves_order() -> None:
text = pch.pch_header_text(["b.h", "a.h"])
assert text == '#include "b.h"\n#include "a.h"\n'
def test_include_closure_resolves_relative_and_root(tmp_path: Path) -> None:
"""Sibling includes resolve against the includer's directory first,
full paths against the src root; unresolvable names end the walk."""
_write(tmp_path, "esphome/components/x/a.h", '#include "b.h"\n')
_write(
tmp_path,
"esphome/components/x/b.h",
'#include "esphome/core/deep.h"\n#include <system.h>\n#include "missing.h"\n',
)
_write(tmp_path, "esphome/core/deep.h", "")
closure = pch._include_closure(tmp_path, ["esphome/components/x/a.h"])
assert sorted(closure) == [
"esphome/components/x/a.h",
"esphome/components/x/b.h",
"esphome/core/deep.h",
]
def test_include_closure_handles_cycles(tmp_path: Path) -> None:
_write(tmp_path, "a.h", '#include "b.h"\n')
_write(tmp_path, "b.h", '#include "a.h"\n')
assert sorted(pch._include_closure(tmp_path, ["a.h"])) == ["a.h", "b.h"]
def test_include_closure_blocks_parent_escape(tmp_path: Path) -> None:
_write(tmp_path / "src", "a.h", '#include "../outside.h"\n')
(tmp_path / "outside.h").write_text("")
assert sorted(pch._include_closure(tmp_path / "src", ["a.h"])) == ["a.h"]
def test_pch_checksum_tracks_closure_content(tmp_path: Path) -> None:
"""A transitive header edit or an extra-identity change must change the
digest; unrelated files must not."""
_write(tmp_path, "root.h", '#include "nested.h"\n')
_write(tmp_path, "nested.h", "int a;\n")
_write(tmp_path, "unrelated.h", "int u;\n")
base = pch.pch_checksum(tmp_path, ["root.h"], ["id"])
assert base == pch.pch_checksum(tmp_path, ["root.h"], ["id"])
assert base != pch.pch_checksum(tmp_path, ["root.h"], ["other-id"])
_write(tmp_path, "unrelated.h", "int changed;\n")
assert base == pch.pch_checksum(tmp_path, ["root.h"], ["id"])
_write(tmp_path, "nested.h", "int b;\n")
assert base != pch.pch_checksum(tmp_path, ["root.h"], ["id"])
@pytest.mark.parametrize(
"platform", ["bk72xx", "esp32", "esp8266", "ln882x", "rp2", "rtl87xx"]
)
def test_pch_script_enabled(platform: str, monkeypatch: pytest.MonkeyPatch) -> None:
CORE.data[KEY_CORE] = {KEY_TARGET_PLATFORM: platform}
assert pch.pch_script_enabled()
monkeypatch.setenv("ESPHOME_PCH_ENABLE", "0")
assert not pch.pch_script_enabled()
@pytest.mark.parametrize("platform", sorted(pch.PCH_SCRIPT_EXCLUDED_PLATFORMS))
def test_pch_script_excluded_platform(platform: str) -> None:
CORE.data[KEY_CORE] = {KEY_TARGET_PLATFORM: platform}
assert not pch.pch_script_enabled()
def test_include_closure_walks_angle_includes_under_src(tmp_path: Path) -> None:
"""An angle include resolving under src/ must enter the digest; one
that does not simply ends the walk."""
_write(tmp_path, "a.h", "#include <local.h>\n#include <Arduino.h>\n")
(tmp_path / "local.h").write_text("")
closure = pch._include_closure(tmp_path, ["a.h"])
assert set(closure) == {"a.h", "local.h"}