From 7f6a007d27e3505da26b003a9cd5bd0b68c9ff1a Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Tue, 25 Aug 2026 13:44:24 -0500 Subject: [PATCH] Add direct tests for the shared pch helpers --- tests/unit_tests/build_helpers/test_ccache.py | 9 ++ tests/unit_tests/build_helpers/test_pch.py | 116 ++++++++++++++++++ 2 files changed, 125 insertions(+) create mode 100644 tests/unit_tests/build_helpers/test_pch.py diff --git a/tests/unit_tests/build_helpers/test_ccache.py b/tests/unit_tests/build_helpers/test_ccache.py index 0237db4081..d86589d378 100644 --- a/tests/unit_tests/build_helpers/test_ccache.py +++ b/tests/unit_tests/build_helpers/test_ccache.py @@ -10,6 +10,7 @@ from unittest.mock import patch import pytest from esphome.build_helpers import ccache +from esphome.core import CORE def test_resolve_opt_out() -> None: @@ -120,3 +121,11 @@ def test_parse_enable_env_spelling_tables( """cv.boolean's spelling tables plus the 1/0 env convention.""" monkeypatch.setenv("ESPHOME_CCACHE_ENABLE", raw) assert ccache.parse_enable_env("ESPHOME_CCACHE_ENABLE") is expected + + +def test_effective_ccache_basedir_prefers_user_value(tmp_path: Path) -> None: + CORE.build_path = tmp_path + with patch.dict(os.environ, {"CCACHE_BASEDIR": "/custom/base"}, clear=True): + assert ccache.effective_ccache_basedir() == "/custom/base" + with patch.dict(os.environ, {}, clear=True): + assert ccache.effective_ccache_basedir() == str(tmp_path.resolve()) diff --git a/tests/unit_tests/build_helpers/test_pch.py b/tests/unit_tests/build_helpers/test_pch.py new file mode 100644 index 0000000000..39b46dbc06 --- /dev/null +++ b/tests/unit_tests/build_helpers/test_pch.py @@ -0,0 +1,116 @@ +"""Tests for esphome.build_helpers.pch.""" + +from __future__ import annotations + +import os +from pathlib import Path +from unittest.mock import patch + +import pytest + +from esphome.build_helpers import pch + + +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 + + +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" + assert env["CCACHE_PCH_EXTSUM"] == "true" + + +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_ccache_pch_env_respects_user_values() -> None: + with patch.dict(os.environ, {"CCACHE_SLOPPINESS": "locale"}, clear=True): + env = pch.ccache_pch_env() + assert "CCACHE_SLOPPINESS" not in env + assert env["CCACHE_PCH_EXTSUM"] == "true" + + +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 \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.skipif( + os.name == "nt" or os.geteuid() == 0, reason="chmod is ineffective here" +) +def test_include_closure_skips_unreadable(tmp_path: Path) -> None: + _write(tmp_path, "a.h", '#include "locked.h"\n') + locked = tmp_path / "locked.h" + locked.write_text("") + locked.chmod(0) + try: + assert sorted(pch._include_closure(tmp_path, ["a.h"])) == ["a.h"] + finally: + locked.chmod(0o644)