[core] Extract the shared idedata and size-summary helpers into build_helpers

This commit is contained in:
J. Nick Koston
2026-08-22 21:58:41 -05:00
parent f0651e5c9b
commit dada0f2c2b
21 changed files with 973 additions and 472 deletions
+7 -1
View File
@@ -1120,7 +1120,13 @@ def test_should_run_esp32_platformio_with_branch() -> None:
(["esphome/espidf/runner.py"], True),
(["esphome/espidf/framework.py"], True),
(["esphome/build_gen/espidf.py"], True),
# PlatformIO build gen and esp32 component are NOT IDF-infra triggers
# Shared native-build modules the IDF build imports -> trigger
(["esphome/build_helpers/idedata.py"], True),
(["esphome/platformio/library.py"], True),
(["esphome/platformio/extra_script.py"], True),
# PlatformIO build gen, its toolchain, and the esp32 component are
# NOT IDF-infra triggers
(["esphome/platformio/toolchain.py"], False),
(["esphome/build_gen/platformio.py"], False),
(["esphome/components/esp32/__init__.py"], False),
(["README.md"], False),
View File
@@ -9,7 +9,7 @@ from esphome.analyze_memory.toolchain import (
find_idedata_path,
idedata_candidates,
)
from esphome.espidf.idedata import _cc_path_from_cxx
from esphome.build_helpers.idedata import _cc_path_from_cxx
from esphome.platformio.toolchain import IDEData
@@ -0,0 +1,549 @@
"""Tests for esphome.build_helpers.idedata (compile_commands.json -> idedata)."""
# pylint: disable=protected-access
import json
import logging
import os
from pathlib import Path
from unittest.mock import MagicMock, patch
import pytest
from esphome.build_helpers import idedata
from esphome.core import EsphomeError
# An absolute, forward-slash (shlex-safe) path prefix valid on the host OS, so
# tests exercise the same is-absolute / normalize behavior as a real compile DB
# (a drive-qualified path on Windows, a leading slash elsewhere).
ABS = "C:/" if os.name == "nt" else "/"
def _entry(directory: str, file: str, command: str) -> dict:
return {"directory": directory, "file": file, "command": command}
def test_parse_entry_extracts_fields() -> None:
"""cxx_path, defines, includes and remaining flags are split apart."""
entry = _entry(
f"{ABS}build",
f"{ABS}build/src/esphome/core/application.cpp",
f"/tools/xtensa-esp32-elf-g++ -DUSE_ESP32 -DESPHOME_LOG_LEVEL=5 "
f"-I{ABS}inc/a -isystem {ABS}sys/b -std=gnu++20 -c app.cpp -o app.cpp.o",
)
cxx_path, defines, includes, cxx_flags = idedata.parse_entry(entry)
assert cxx_path == "/tools/xtensa-esp32-elf-g++"
assert "USE_ESP32" in defines
assert "ESPHOME_LOG_LEVEL=5" in defines
assert f"{ABS}inc/a" in includes
assert f"{ABS}sys/b" in includes
assert "-std=gnu++20" in cxx_flags
# input/output files and their flags are not treated as flags
assert "-c" not in cxx_flags
assert "-o" not in cxx_flags
assert "app.cpp" not in cxx_flags
assert "app.cpp.o" not in cxx_flags
def test_parse_entry_space_separated_args() -> None:
"""``-D X`` / ``-I path`` (separate arg) and ``-isystem<path>`` (joined)."""
entry = _entry(
f"{ABS}build",
f"{ABS}build/src/esphome/x.cpp",
f"g++ -D FOO=1 -I {ABS}inc/sep -isystem{ABS}sys/joined -c x.cpp",
)
_, defines, includes, _ = idedata.parse_entry(entry)
assert "FOO=1" in defines
assert f"{ABS}inc/sep" in includes
assert f"{ABS}sys/joined" in includes
def test_parse_entry_resolves_relative_includes() -> None:
"""Relative includes are resolved against the entry's ``directory``."""
directory = f"{ABS}build/proj"
entry = _entry(
directory,
f"{directory}/src/esphome/x.cpp",
"g++ -Iconfig -I../shared -isystem rel/sys -c x.cpp",
)
_, _, includes, _ = idedata.parse_entry(entry)
def resolved(rel: str) -> str:
# parse_entry emits forward slashes for consistency (normpath would
# yield backslashes on Windows).
return os.path.normpath(Path(directory) / rel).replace("\\", "/")
assert resolved("config") in includes
assert resolved("../shared") in includes # ../ normalized away
assert resolved("rel/sys") in includes
# nothing is left relative
assert all(Path(inc).is_absolute() for inc in includes)
def test_parse_entry_skips_dependency_flags() -> None:
"""Dependency-generation flags (and their args) are dropped."""
entry = _entry(
"/build",
"/build/src/esphome/x.cpp",
"g++ -MD -MT x.cpp.o -MF x.cpp.o.d -c x.cpp -o x.cpp.o",
)
_, _, _, cxx_flags = idedata.parse_entry(entry)
for tok in ("-MD", "-MT", "x.cpp.o", "-MF", "x.cpp.o.d", "-c", "-o", "x.cpp"):
assert tok not in cxx_flags
def test_expand_response_files(tmp_path: Path) -> None:
"""``@file`` arguments are inlined relative to the directory."""
rsp = tmp_path / "flags.rsp"
rsp.write_text("-DFROM_RSP -I/rsp/inc")
tokens = idedata._expand_response_files(
["g++", f"@{rsp.name}", "-c", "x.cpp"], tmp_path
)
assert "-DFROM_RSP" in tokens
assert "-I/rsp/inc" in tokens
assert not any(t.startswith("@") for t in tokens)
def test_expand_response_files_keeps_literal_when_missing(tmp_path: Path) -> None:
"""An unreadable ``@file`` token is kept verbatim rather than dropped."""
tokens = idedata._expand_response_files(["g++", "@nope.rsp"], tmp_path)
assert "@nope.rsp" in tokens
def test_pick_entry_prefers_esphome_tu() -> None:
"""A ``/src/esphome/`` C++ TU is picked over other compile entries."""
entries = [
_entry("/b", "/b/managed_components/foo/foo.c", "gcc -c foo.c"),
_entry("/b", "/b/src/esphome/core/app.cpp", "g++ -c app.cpp"),
]
assert idedata._pick_entry(entries)["file"].endswith("app.cpp")
def test_pick_entry_falls_back_to_any_cxx_tu() -> None:
"""With no ``/src/esphome/`` TU present, the first C++ entry is the fallback."""
entries = [
_entry("/b", "/b/managed_components/foo/foo.c", "gcc -c foo.c"),
_entry("/b", "/b/components/x/x.cpp", "g++ -c x.cpp"),
]
assert idedata._pick_entry(entries)["file"].endswith("x.cpp")
def test_is_esphome_src_handles_backslash_paths() -> None:
r"""The src marker must match Windows ``\src\esphome\`` paths too.
compile_commands ``file`` entries use the OS-native separator; if the
marker only matched forward slashes no source would match on Windows and
the build-include union would be silently empty.
"""
assert idedata._is_esphome_src(r"C:\b\src\esphome\core\app.cpp")
assert idedata._is_esphome_src("/b/src/esphome/core/app.cpp")
# non-esphome and non-C++ still rejected regardless of separator
assert not idedata._is_esphome_src(r"C:\b\managed_components\x\x.cpp")
assert not idedata._is_esphome_src(r"C:\b\src\esphome\core\app.h")
def test_idedata_from_build_empty_includes_warns(
tmp_path: Path, caplog: pytest.LogCaptureFixture
) -> None:
"""A compile DB with no ESPHome TU yields no build includes; that is
never a usable idedata, so it must be diagnosable."""
compile_commands = tmp_path / "compile_commands.json"
compile_commands.write_text(
json.dumps(
[
_entry(
f"{ABS}build",
f"{ABS}build/other/lib.cpp",
"/tools/g++ -c other/lib.cpp -o lib.o",
)
]
)
)
with patch.object(idedata, "get_toolchain_includes", return_value=[]):
data = idedata.idedata_from_build(compile_commands)
assert data["includes"]["build"] == []
assert "idedata will be incomplete" in caplog.text
def test_idedata_from_build_dedupes_identical_command_shapes(
tmp_path: Path,
) -> None:
"""Translation units sharing one ninja rule (same command modulo
file/output) carry
identical includes, so only one per shape is tokenized; a differing
shape still contributes its includes."""
def _tu(name: str, inc: str) -> dict:
# ninja's compdb embeds the file and output strings verbatim
file = f"{ABS}build/src/esphome/core/{name}.cpp"
return _entry(
f"{ABS}build", file, f"/tools/g++ -I{ABS}inc/{inc} -c {file} -o {name}.o"
) | {"output": f"{name}.o"}
entries = [_tu(name, "shared") for name in ("application", "component", "helpers")]
entries.append(_tu("extra", "extra"))
compile_commands = tmp_path / "compile_commands.json"
compile_commands.write_text(json.dumps(entries))
with (
patch.object(idedata, "get_toolchain_includes", return_value=[]),
patch.object(idedata, "parse_entry", wraps=idedata.parse_entry) as spy,
):
data = idedata.idedata_from_build(compile_commands)
includes = set(data["includes"]["build"])
assert f"{ABS}inc/shared".replace("\\", "/") in {
i.replace("\\", "/") for i in includes
}
assert any("inc/extra" in i for i in includes)
# Representative + one distinct shape; the two same-shape duplicates
# are never tokenized
assert spy.call_count == 2
def test_idedata_from_build(tmp_path: Path) -> None:
"""Full transform: representative entry + include union + toolchain dirs."""
compile_commands = tmp_path / "compile_commands.json"
entries = [
_entry(
f"{ABS}b",
f"{ABS}b/src/esphome/core/app.cpp",
f"g++ -DUSE_ESP32 -I{ABS}inc/core -std=gnu++20 -c app.cpp -o app.cpp.o",
),
_entry(
f"{ABS}b",
f"{ABS}b/src/esphome/sensor/s.cpp",
f"g++ -DUSE_ESP32 -I{ABS}inc/sensor -c s.cpp -o s.cpp.o",
),
# non-esphome TU: its includes must not leak into the union
_entry(
f"{ABS}b",
f"{ABS}b/managed_components/x/x.c",
f"gcc -I{ABS}inc/managed -c x.c",
),
]
compile_commands.write_text(json.dumps(entries))
fake_proc = MagicMock(
returncode=0,
stderr=(
"ignored\n"
"#include <...> search starts here:\n"
" /tc/inc/c++\n"
" /tc/inc\n"
"End of search list.\n"
"more ignored\n"
),
)
with patch.object(idedata.subprocess, "run", return_value=fake_proc):
data = idedata.idedata_from_build(compile_commands)
assert data["cxx_path"] == "g++"
assert "USE_ESP32" in data["defines"]
assert "-std=gnu++20" in data["cxx_flags"]
# include dirs unioned across all esphome TUs
assert f"{ABS}inc/core" in data["includes"]["build"]
assert f"{ABS}inc/sensor" in data["includes"]["build"]
# the non-esphome TU is excluded from the union
assert f"{ABS}inc/managed" not in data["includes"]["build"]
# toolchain search dirs parsed from the compiler's -v output
assert data["includes"]["toolchain"] == ["/tc/inc/c++", "/tc/inc"]
def test_get_toolchain_includes_raises_on_probe_failure() -> None:
"""A failed compiler probe is a hard error, not a silent empty list."""
fake_proc = MagicMock(returncode=1, stderr="xtensa-esp32-elf-g++: not found")
with (
patch.object(idedata.subprocess, "run", return_value=fake_proc),
pytest.raises(RuntimeError, match="builtin include dirs"),
):
idedata.get_toolchain_includes("/bad/compiler")
def test_get_toolchain_includes_raises_when_no_dirs_found() -> None:
"""Markers present but no dirs (anomalous output) also raises."""
fake_proc = MagicMock(
returncode=0,
stderr="#include <...> search starts here:\nEnd of search list.\n",
)
with (
patch.object(idedata.subprocess, "run", return_value=fake_proc),
pytest.raises(RuntimeError, match="builtin include dirs"),
):
idedata.get_toolchain_includes("/some/compiler")
# ESP-IDF's compile_commands.json on Windows mixes literal backslash path
# separators in the compiler path with shell ``\"`` quote-escaping in defines,
# which only the real Windows argv parser handles. These exercise that path.
@pytest.mark.skipif(os.name != "nt", reason="Windows argv tokenization")
def test_split_command_preserves_paths_and_unescapes_quotes() -> None:
r"""Backslash paths survive while ``\"`` define-quoting is unescaped."""
command = r"C:\esp\bin\riscv32-esp-elf-g++.exe -DVER=\"1.2.3\" -IC:/inc/a -c x.cpp"
tokens = idedata._split_command(command)
assert tokens[0] == r"C:\esp\bin\riscv32-esp-elf-g++.exe"
assert '-DVER="1.2.3"' in tokens
assert "-IC:/inc/a" in tokens
@pytest.mark.skipif(os.name != "nt", reason="Windows argv tokenization")
def test_split_command_empty_returns_empty() -> None:
"""An empty or blank command tokenizes to ``[]`` (e.g. an empty response file).
Guards against ``CommandLineToArgvW("")`` returning the current process name
instead of an empty list.
"""
assert idedata._split_command("") == []
assert idedata._split_command(" ") == []
@pytest.mark.skipif(os.name != "nt", reason="Windows argv tokenization")
def test_parse_entry_normalizes_windows_cxx_path() -> None:
"""A backslash compiler path is emitted forward-slashed; define unescaped."""
entry = _entry(
r"C:\b",
r"C:\b\src\esphome\x.cpp",
r"C:\esp\bin\g++.exe -DVER=\"1.2.3\" -IC:/inc/a -c x.cpp",
)
cxx_path, defines, includes, _ = idedata.parse_entry(entry)
assert cxx_path == "C:/esp/bin/g++.exe"
assert "\\" not in cxx_path
assert 'VER="1.2.3"' in defines
assert "C:/inc/a" in includes
def test_parse_entry_strips_launcher_prefix() -> None:
"""A launcher-wrapped compile names the compiler second; the exact
configured launcher is stripped, not anything ccache-shaped."""
entry = _entry(
f"{ABS}build",
f"{ABS}build/src/esphome/core/application.cpp",
"/opt/homebrew/bin/ccache /tools/xtensa-lx106-elf-g++ -DUSE_ESP8266 "
"-c app.cpp -o app.cpp.o",
)
cxx_path, defines, _, _ = idedata.parse_entry(
entry, launcher="/opt/homebrew/bin/ccache"
)
assert cxx_path == "/tools/xtensa-lx106-elf-g++"
assert defines == ["USE_ESP8266"]
def test_parse_entry_recovers_from_unconfigured_launcher(
caplog: pytest.LogCaptureFixture,
) -> None:
"""A stale compile DB built with a launcher this run no longer configures
still yields the real compiler (the next token), not the launcher."""
entry = _entry(
f"{ABS}build",
f"{ABS}build/src/esphome/core/application.cpp",
"/opt/homebrew/bin/ccache /tools/xtensa-lx106-elf-g++ -c a.cpp -o a.o",
)
caplog.set_level(logging.DEBUG)
cxx_path, _, _, _ = idedata.parse_entry(entry)
assert cxx_path == "/tools/xtensa-lx106-elf-g++"
assert "Stripping unconfigured launcher" in caplog.text
def test_parse_entry_keeps_launcher_without_program() -> None:
"""A launcher followed only by flags (no program to recover) stays as
token zero; the cache layer refuses to persist it."""
entry = _entry(
f"{ABS}build",
f"{ABS}build/src/esphome/core/application.cpp",
"/opt/homebrew/bin/ccache -c a.cpp -o a.o",
)
cxx_path, _, _, _ = idedata.parse_entry(entry)
assert cxx_path == "/opt/homebrew/bin/ccache"
def _write_compile_commands(tmp_path: Path) -> Path:
compile_commands = tmp_path / "compile_commands.json"
compile_commands.write_text(
json.dumps(
[
_entry(
f"{ABS}build",
f"{ABS}build/src/esphome/core/application.cpp",
"/tools/g++ -DUSE_ESP8266 -c app.cpp -o app.cpp.o",
)
]
)
)
return compile_commands
def test_load_or_build_idedata_missing_compile_db(tmp_path: Path) -> None:
assert (
idedata.load_or_build_idedata(
tmp_path / "compile_commands.json", tmp_path / "f.elf", tmp_path / "c.json"
)
is None
)
def test_load_or_build_idedata_builds_and_caches(tmp_path: Path) -> None:
compile_commands = _write_compile_commands(tmp_path)
cache = tmp_path / "cache" / "test.json"
with patch.object(
idedata, "get_toolchain_includes", return_value=["/toolchain/include"]
):
data = idedata.load_or_build_idedata(
compile_commands, tmp_path / "firmware.elf", cache
)
assert data["cc_path"] == "/tools/gcc"
assert data["prog_path"] == str(tmp_path / "firmware.elf")
assert json.loads(cache.read_text()) == data
# A fresh cache is served without re-parsing the compile DB
os.utime(cache, (compile_commands.stat().st_mtime + 10,) * 2)
with patch.object(idedata, "idedata_from_build") as mock_build:
assert (
idedata.load_or_build_idedata(
compile_commands, tmp_path / "firmware.elf", cache
)
== data
)
mock_build.assert_not_called()
def test_load_or_build_idedata_rebuilds_bad_cache(tmp_path: Path) -> None:
compile_commands = _write_compile_commands(tmp_path)
cache = tmp_path / "cache.json"
for bad in ("not json", json.dumps({"no_cc_path": True})):
cache.write_text(bad)
os.utime(cache, (compile_commands.stat().st_mtime + 10,) * 2)
with patch.object(idedata, "get_toolchain_includes", return_value=[]):
data = idedata.load_or_build_idedata(
compile_commands, tmp_path / "f.elf", cache
)
assert "cc_path" in data
def test_load_or_build_idedata_rebuilds_when_compile_db_newer(tmp_path: Path) -> None:
"""A compile DB newer than the cache forces regeneration."""
compile_commands = _write_compile_commands(tmp_path)
cache = tmp_path / "cache.json"
cache.write_text(json.dumps({"cc_path": "stale"}))
os.utime(compile_commands, (cache.stat().st_mtime + 10,) * 2)
with patch.object(idedata, "get_toolchain_includes", return_value=[]):
data = idedata.load_or_build_idedata(
compile_commands, tmp_path / "f.elf", cache
)
assert data["cc_path"] != "stale"
def test_load_or_build_idedata_rebuilds_non_dict_cache(tmp_path: Path) -> None:
"""Valid JSON that is not an object is regenerated, never handed out.
A bare string would otherwise pass the cc_path check by substring.
"""
compile_commands = _write_compile_commands(tmp_path)
cache = tmp_path / "cache.json"
for bad in ('"cc_path is a string"', "[]", "42"):
cache.write_text(bad)
os.utime(cache, (compile_commands.stat().st_mtime + 10,) * 2)
with patch.object(idedata, "get_toolchain_includes", return_value=[]):
data = idedata.load_or_build_idedata(
compile_commands, tmp_path / "f.elf", cache
)
assert isinstance(data, dict)
assert "cc_path" in data
def test_is_launcher_matches_only_known_launchers() -> None:
"""Compilers of any shape pass; only the closed launcher set matches."""
for token in ("/t/g++-13", "gcc-8.4.0", "clang++-17", "armcc", "icx", "cc"):
assert not idedata._is_launcher(token)
for token in ("/opt/homebrew/bin/ccache", "CCACHE.EXE", "distcc", "sccache"):
assert idedata._is_launcher(token)
def test_load_or_build_idedata_corrupted_cache_is_logged(
tmp_path: Path, caplog: pytest.LogCaptureFixture
) -> None:
"""A truncated cache is diagnosable, not a silent slow-build cause."""
compile_commands = _write_compile_commands(tmp_path)
cache = tmp_path / "c.json"
cache.write_text('{"cc_path": trunc')
os.utime(cache, (compile_commands.stat().st_mtime + 5,) * 2)
with patch.object(idedata, "get_toolchain_includes", return_value=[]):
data = idedata.load_or_build_idedata(
compile_commands, tmp_path / "f.elf", cache
)
assert data["cxx_path"] == "/tools/g++"
assert "Discarding unreadable idedata cache" in caplog.text
def test_load_or_build_idedata_discards_unreadable_cache_file(
tmp_path: Path, caplog: pytest.LogCaptureFixture
) -> None:
"""An OSError on the cache read (permissions, I/O) regenerates like a
parse failure instead of aborting the consumer."""
compile_commands = _write_compile_commands(tmp_path)
cache = tmp_path / "c.json"
cache.write_text("{}")
os.utime(cache, (compile_commands.stat().st_mtime + 5,) * 2)
real_read_text = Path.read_text
def fail_cache_read(self: Path, *args: object, **kwargs: object) -> str:
# chmod(0) cannot revoke read access on Windows, so fault the read
# itself for a platform-independent OSError
if self == cache:
raise OSError("permission denied")
return real_read_text(self, *args, **kwargs)
with (
patch.object(idedata, "get_toolchain_includes", return_value=[]),
patch.object(Path, "read_text", fail_cache_read),
):
data = idedata.load_or_build_idedata(
compile_commands, tmp_path / "f.elf", cache
)
assert data["cxx_path"] == "/tools/g++"
assert "Discarding unreadable idedata cache" in caplog.text
def test_load_or_build_idedata_never_caches_a_launcher(tmp_path: Path) -> None:
"""A compile DB naming a launcher as the compiler is rejected, never cached."""
compile_commands = tmp_path / "compile_commands.json"
compile_commands.write_text(
json.dumps(
[
_entry(
f"{ABS}build",
f"{ABS}build/src/esphome/core/application.cpp",
"/opt/homebrew/bin/ccache -c app.cpp -o app.cpp.o",
)
]
)
)
cache = tmp_path / "c.json"
# No probe patch needed: the launcher is rejected before the probe runs
with pytest.raises(EsphomeError, match="compile database is unusable"):
idedata.load_or_build_idedata(compile_commands, tmp_path / "f.elf", cache)
assert not cache.exists()
def test_load_or_build_idedata_cache_hit_skips_rebuild(tmp_path: Path) -> None:
"""A valid cache newer than the compile DB is served without re-parsing."""
compile_commands = _write_compile_commands(tmp_path)
cache = tmp_path / "c.json"
cache.write_text(json.dumps({"cc_path": "/tools/gcc", "cached": True}))
os.utime(cache, (compile_commands.stat().st_mtime + 5,) * 2)
with patch.object(idedata, "idedata_from_build") as mock_build:
data = idedata.load_or_build_idedata(
compile_commands, tmp_path / "f.elf", cache
)
mock_build.assert_not_called()
assert data["cached"] is True
@@ -0,0 +1,22 @@
"""Tests for the shared PlatformIO-format size bar."""
from __future__ import annotations
import pytest
from esphome.build_helpers.size_summary import format_bar, print_size_line
def test_format_bar_zero_total() -> None:
"""A zero total must not divide by zero."""
assert format_bar(0, 0) == "[ ] 0.0% (used 0 bytes from 0 bytes)"
def test_print_size_line_label_padding(capsys: pytest.CaptureFixture[str]) -> None:
"""The label column is exactly what ci_memory_impact_extract.py greps."""
print_size_line("RAM", 47932, 180736)
print_size_line("Flash", 888511, 1835008)
out = capsys.readouterr().out.splitlines()
assert out[0].startswith("RAM: [")
assert out[1].startswith("Flash: [")
assert "26.5% (used 47932 bytes from 180736 bytes)" in out[0]
+1 -1
View File
@@ -6,9 +6,9 @@ from unittest.mock import patch
from hypothesis import given
import pytest
from strategies import mac_addr_strings
from esphome import const, core
from tests.unit_tests.strategies import mac_addr_strings
class TestHexInt:
@@ -1,10 +1,13 @@
"""Tests for esphome.espidf.clang_tidy tidy-project generation."""
import json
import os
from pathlib import Path
from unittest.mock import patch
import pytest
from esphome.espidf import clang_tidy
from esphome.espidf.clang_tidy import _Settings, _setup_core, _write_tidy_project
REPO_ROOT = Path(__file__).resolve().parents[2]
@@ -64,3 +67,35 @@ def test_setup_core_sets_arduino_env(
_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)
-264
View File
@@ -1,264 +0,0 @@
"""Tests for esphome.espidf.idedata (compile_commands.json -> idedata)."""
# pylint: disable=protected-access
import json
import os
from pathlib import Path
from unittest.mock import MagicMock, patch
import pytest
from esphome.espidf import idedata
# An absolute, forward-slash (shlex-safe) path prefix valid on the host OS, so
# tests exercise the same is-absolute / normalize behavior as a real compile DB
# (a drive-qualified path on Windows, a leading slash elsewhere).
ABS = "C:/" if os.name == "nt" else "/"
def _entry(directory: str, file: str, command: str) -> dict:
return {"directory": directory, "file": file, "command": command}
def test_parse_entry_extracts_fields() -> None:
"""cxx_path, defines, includes and remaining flags are split apart."""
entry = _entry(
f"{ABS}build",
f"{ABS}build/src/esphome/core/application.cpp",
f"/tools/xtensa-esp32-elf-g++ -DUSE_ESP32 -DESPHOME_LOG_LEVEL=5 "
f"-I{ABS}inc/a -isystem {ABS}sys/b -std=gnu++20 -c app.cpp -o app.cpp.o",
)
cxx_path, defines, includes, cxx_flags = idedata._parse_entry(entry)
assert cxx_path == "/tools/xtensa-esp32-elf-g++"
assert "USE_ESP32" in defines
assert "ESPHOME_LOG_LEVEL=5" in defines
assert f"{ABS}inc/a" in includes
assert f"{ABS}sys/b" in includes
assert "-std=gnu++20" in cxx_flags
# input/output files and their flags are not treated as flags
assert "-c" not in cxx_flags
assert "-o" not in cxx_flags
assert "app.cpp" not in cxx_flags
assert "app.cpp.o" not in cxx_flags
def test_parse_entry_space_separated_args() -> None:
"""``-D X`` / ``-I path`` (separate arg) and ``-isystem<path>`` (joined)."""
entry = _entry(
f"{ABS}build",
f"{ABS}build/src/esphome/x.cpp",
f"g++ -D FOO=1 -I {ABS}inc/sep -isystem{ABS}sys/joined -c x.cpp",
)
_, defines, includes, _ = idedata._parse_entry(entry)
assert "FOO=1" in defines
assert f"{ABS}inc/sep" in includes
assert f"{ABS}sys/joined" in includes
def test_parse_entry_resolves_relative_includes() -> None:
"""Relative includes are resolved against the entry's ``directory``."""
directory = f"{ABS}build/proj"
entry = _entry(
directory,
f"{directory}/src/esphome/x.cpp",
"g++ -Iconfig -I../shared -isystem rel/sys -c x.cpp",
)
_, _, includes, _ = idedata._parse_entry(entry)
def resolved(rel: str) -> str:
# _parse_entry emits forward slashes for consistency (normpath would
# yield backslashes on Windows).
return os.path.normpath(Path(directory) / rel).replace("\\", "/")
assert resolved("config") in includes
assert resolved("../shared") in includes # ../ normalized away
assert resolved("rel/sys") in includes
# nothing is left relative
assert all(Path(inc).is_absolute() for inc in includes)
def test_parse_entry_skips_dependency_flags() -> None:
"""Dependency-generation flags (and their args) are dropped."""
entry = _entry(
"/build",
"/build/src/esphome/x.cpp",
"g++ -MD -MT x.cpp.o -MF x.cpp.o.d -c x.cpp -o x.cpp.o",
)
_, _, _, cxx_flags = idedata._parse_entry(entry)
for tok in ("-MD", "-MT", "x.cpp.o", "-MF", "x.cpp.o.d", "-c", "-o", "x.cpp"):
assert tok not in cxx_flags
def test_expand_response_files(tmp_path: Path) -> None:
"""``@file`` arguments are inlined relative to the directory."""
rsp = tmp_path / "flags.rsp"
rsp.write_text("-DFROM_RSP -I/rsp/inc")
tokens = idedata._expand_response_files(
["g++", f"@{rsp.name}", "-c", "x.cpp"], tmp_path
)
assert "-DFROM_RSP" in tokens
assert "-I/rsp/inc" in tokens
assert not any(t.startswith("@") for t in tokens)
def test_expand_response_files_keeps_literal_when_missing(tmp_path: Path) -> None:
"""An unreadable ``@file`` token is kept verbatim rather than dropped."""
tokens = idedata._expand_response_files(["g++", "@nope.rsp"], tmp_path)
assert "@nope.rsp" in tokens
def test_pick_entry_prefers_esphome_tu() -> None:
"""A ``/src/esphome/`` C++ TU is picked over other compile entries."""
entries = [
_entry("/b", "/b/managed_components/foo/foo.c", "gcc -c foo.c"),
_entry("/b", "/b/src/esphome/core/app.cpp", "g++ -c app.cpp"),
]
assert idedata._pick_entry(entries)["file"].endswith("app.cpp")
def test_pick_entry_falls_back_to_any_cxx_tu() -> None:
"""With no ``/src/esphome/`` TU present, the first C++ entry is the fallback."""
entries = [
_entry("/b", "/b/managed_components/foo/foo.c", "gcc -c foo.c"),
_entry("/b", "/b/components/x/x.cpp", "g++ -c x.cpp"),
]
assert idedata._pick_entry(entries)["file"].endswith("x.cpp")
def test_is_esphome_src_handles_backslash_paths() -> None:
r"""The src marker must match Windows ``\src\esphome\`` paths too.
compile_commands ``file`` entries use the OS-native separator; if the
marker only matched forward slashes no source would match on Windows and
the build-include union would be silently empty.
"""
assert idedata._is_esphome_src(r"C:\b\src\esphome\core\app.cpp")
assert idedata._is_esphome_src("/b/src/esphome/core/app.cpp")
# non-esphome and non-C++ still rejected regardless of separator
assert not idedata._is_esphome_src(r"C:\b\managed_components\x\x.cpp")
assert not idedata._is_esphome_src(r"C:\b\src\esphome\core\app.h")
def test_idedata_from_build(tmp_path: Path) -> None:
"""Full transform: representative entry + include union + toolchain dirs."""
compile_commands = tmp_path / "compile_commands.json"
entries = [
_entry(
f"{ABS}b",
f"{ABS}b/src/esphome/core/app.cpp",
f"g++ -DUSE_ESP32 -I{ABS}inc/core -std=gnu++20 -c app.cpp -o app.cpp.o",
),
_entry(
f"{ABS}b",
f"{ABS}b/src/esphome/sensor/s.cpp",
f"g++ -DUSE_ESP32 -I{ABS}inc/sensor -c s.cpp -o s.cpp.o",
),
# non-esphome TU: its includes must not leak into the union
_entry(
f"{ABS}b",
f"{ABS}b/managed_components/x/x.c",
f"gcc -I{ABS}inc/managed -c x.c",
),
]
compile_commands.write_text(json.dumps(entries))
fake_proc = MagicMock(
returncode=0,
stderr=(
"ignored\n"
"#include <...> search starts here:\n"
" /tc/inc/c++\n"
" /tc/inc\n"
"End of search list.\n"
"more ignored\n"
),
)
with patch.object(idedata.subprocess, "run", return_value=fake_proc):
data = idedata.idedata_from_build(compile_commands)
assert data["cxx_path"] == "g++"
assert "USE_ESP32" in data["defines"]
assert "-std=gnu++20" in data["cxx_flags"]
# include dirs unioned across all esphome TUs
assert f"{ABS}inc/core" in data["includes"]["build"]
assert f"{ABS}inc/sensor" in data["includes"]["build"]
# the non-esphome TU is excluded from the union
assert f"{ABS}inc/managed" not in data["includes"]["build"]
# toolchain search dirs parsed from the compiler's -v output
assert data["includes"]["toolchain"] == ["/tc/inc/c++", "/tc/inc"]
def test_get_toolchain_includes_raises_on_probe_failure() -> None:
"""A failed compiler probe is a hard error, not a silent empty list."""
fake_proc = MagicMock(returncode=1, stderr="xtensa-esp32-elf-g++: not found")
with (
patch.object(idedata.subprocess, "run", return_value=fake_proc),
pytest.raises(RuntimeError, match="builtin include dirs"),
):
idedata._get_toolchain_includes("/bad/compiler")
def test_get_toolchain_includes_raises_when_no_dirs_found() -> None:
"""Markers present but no dirs (anomalous output) also raises."""
fake_proc = MagicMock(
returncode=0,
stderr="#include <...> search starts here:\nEnd of search list.\n",
)
with (
patch.object(idedata.subprocess, "run", return_value=fake_proc),
pytest.raises(RuntimeError, match="builtin include dirs"),
):
idedata._get_toolchain_includes("/some/compiler")
# ESP-IDF's compile_commands.json on Windows mixes literal backslash path
# separators in the compiler path with shell ``\"`` quote-escaping in defines,
# which only the real Windows argv parser handles. These exercise that path.
@pytest.mark.skipif(os.name != "nt", reason="Windows argv tokenization")
def test_split_command_preserves_paths_and_unescapes_quotes() -> None:
r"""Backslash paths survive while ``\"`` define-quoting is unescaped."""
command = r"C:\esp\bin\riscv32-esp-elf-g++.exe -DVER=\"1.2.3\" -IC:/inc/a -c x.cpp"
tokens = idedata._split_command(command)
assert tokens[0] == r"C:\esp\bin\riscv32-esp-elf-g++.exe"
assert '-DVER="1.2.3"' in tokens
assert "-IC:/inc/a" in tokens
@pytest.mark.skipif(os.name != "nt", reason="Windows argv tokenization")
def test_split_command_empty_returns_empty() -> None:
"""An empty or blank command tokenizes to ``[]`` (e.g. an empty response file).
Guards against ``CommandLineToArgvW("")`` returning the current process name
instead of an empty list.
"""
assert idedata._split_command("") == []
assert idedata._split_command(" ") == []
@pytest.mark.skipif(os.name != "nt", reason="Windows argv tokenization")
def test_parse_entry_normalizes_windows_cxx_path() -> None:
"""A backslash compiler path is emitted forward-slashed; define unescaped."""
entry = _entry(
r"C:\b",
r"C:\b\src\esphome\x.cpp",
r"C:\esp\bin\g++.exe -DVER=\"1.2.3\" -IC:/inc/a -c x.cpp",
)
cxx_path, defines, includes, _ = idedata._parse_entry(entry)
assert cxx_path == "C:/esp/bin/g++.exe"
assert "\\" not in cxx_path
assert 'VER="1.2.3"' in defines
assert "C:/inc/a" in includes
+2 -110
View File
@@ -140,7 +140,7 @@ def test_get_idedata_generates_and_caches(setup_core: Path) -> None:
compile_commands.write_text("[]")
with patch(
"esphome.espidf.idedata.idedata_from_build",
"esphome.build_helpers.idedata.idedata_from_build",
return_value={"cxx_path": "g++"},
) as mock_transform:
result = toolchain.get_idedata()
@@ -151,114 +151,6 @@ def test_get_idedata_generates_and_caches(setup_core: Path) -> None:
assert json.loads(cache.read_text()) == {"cxx_path": "g++", "prog_path": prog_path}
def test_get_idedata_uses_cache_when_valid(setup_core: Path) -> None:
"""A cache at least as new as the compile DB is reused without regenerating."""
compile_commands, cache = _setup_build(setup_core)
compile_commands.parent.mkdir(parents=True, exist_ok=True)
compile_commands.write_text("[]")
cache.parent.mkdir(parents=True, exist_ok=True)
cache.write_text('{"cc_path": "cached-gcc", "cxx_path": "cached"}')
cc_mtime = compile_commands.stat().st_mtime
os.utime(cache, (cc_mtime + 1, cc_mtime + 1))
with patch("esphome.espidf.idedata.idedata_from_build") as mock_transform:
result = toolchain.get_idedata()
mock_transform.assert_not_called()
assert result == {"cc_path": "cached-gcc", "cxx_path": "cached"}
def test_get_idedata_regenerates_cache_without_cc_path(setup_core: Path) -> None:
"""A cache predating cc_path is rebuilt even though it is newer.
Such a cache stays newer than the compile DB forever, so consumers that
derive the binutils paths from cc_path would keep failing on it.
"""
compile_commands, cache = _setup_build(setup_core)
compile_commands.parent.mkdir(parents=True, exist_ok=True)
compile_commands.write_text("[]")
cache.parent.mkdir(parents=True, exist_ok=True)
cache.write_text('{"cxx_path": "cached"}')
cc_mtime = compile_commands.stat().st_mtime
os.utime(cache, (cc_mtime + 1, cc_mtime + 1))
with patch(
"esphome.espidf.idedata.idedata_from_build",
return_value={"cc_path": "gcc", "cxx_path": "g++"},
) as mock_transform:
result = toolchain.get_idedata()
mock_transform.assert_called_once()
assert result["cc_path"] == "gcc"
def test_get_idedata_regenerates_when_compile_commands_newer(setup_core: Path) -> None:
"""A compile DB newer than the cache forces regeneration."""
compile_commands, cache = _setup_build(setup_core)
cache.parent.mkdir(parents=True, exist_ok=True)
cache.write_text('{"cxx_path": "stale"}')
compile_commands.parent.mkdir(parents=True, exist_ok=True)
compile_commands.write_text("[]")
cache_mtime = cache.stat().st_mtime
os.utime(compile_commands, (cache_mtime + 1, cache_mtime + 1))
with patch(
"esphome.espidf.idedata.idedata_from_build",
return_value={"cxx_path": "fresh"},
) as mock_transform:
result = toolchain.get_idedata()
mock_transform.assert_called_once()
assert result == {"cxx_path": "fresh", "prog_path": str(toolchain.get_elf_path())}
@pytest.mark.parametrize("cached", ['"cc_path is a string"', "[]", "42"])
def test_get_idedata_regenerates_on_non_dict_cache(
setup_core: Path, cached: str
) -> None:
"""A newer cache holding valid JSON that is not an object is regenerated.
A bare string would otherwise pass the cc_path check by substring and be
handed to consumers expecting a dict.
"""
compile_commands, cache = _setup_build(setup_core)
compile_commands.parent.mkdir(parents=True, exist_ok=True)
compile_commands.write_text("[]")
cache.parent.mkdir(parents=True, exist_ok=True)
cache.write_text(cached)
cc_mtime = compile_commands.stat().st_mtime
os.utime(cache, (cc_mtime + 1, cc_mtime + 1))
with patch(
"esphome.espidf.idedata.idedata_from_build",
return_value={"cc_path": "gcc", "cxx_path": "g++"},
) as mock_transform:
result = toolchain.get_idedata()
mock_transform.assert_called_once()
assert isinstance(result, dict)
def test_get_idedata_regenerates_on_corrupted_cache(setup_core: Path) -> None:
"""An unparseable (but newer) cache falls back to regeneration."""
compile_commands, cache = _setup_build(setup_core)
compile_commands.parent.mkdir(parents=True, exist_ok=True)
compile_commands.write_text("[]")
cache.parent.mkdir(parents=True, exist_ok=True)
cache.write_text("{not json")
cc_mtime = compile_commands.stat().st_mtime
os.utime(cache, (cc_mtime + 1, cc_mtime + 1))
with patch(
"esphome.espidf.idedata.idedata_from_build",
return_value={"cxx_path": "regen"},
) as mock_transform:
result = toolchain.get_idedata()
mock_transform.assert_called_once()
assert result == {"cxx_path": "regen", "prog_path": str(toolchain.get_elf_path())}
def test_get_idedata_prog_path_points_at_firmware_elf(setup_core: Path) -> None:
"""The idedata exposes prog_path (the ELF) so consumers like build-action
can locate firmware.factory.bin / firmware.ota.bin as its siblings."""
@@ -267,7 +159,7 @@ def test_get_idedata_prog_path_points_at_firmware_elf(setup_core: Path) -> None:
compile_commands.write_text("[]")
with patch(
"esphome.espidf.idedata.idedata_from_build",
"esphome.build_helpers.idedata.idedata_from_build",
return_value={"cxx_path": "g++"},
):
result = toolchain.get_idedata()
+82 -22
View File
@@ -17,6 +17,7 @@ from unittest.mock import AsyncMock, MagicMock, Mock, patch
import pytest
from pytest import CaptureFixture
import serial
from zeroconf import ServiceStateChange
from esphome import __main__ as main, yaml_util
@@ -26,6 +27,7 @@ from esphome.__main__ import (
_make_crystal_freq_callback,
_redact_with_legacy_fallback,
_resolve_network_devices,
_should_subscribe_states,
_split_network_devices,
_unresolved_default_error,
_validate_bootloader_binary,
@@ -69,19 +71,21 @@ from esphome.__main__ import (
)
from esphome.address_cache import AddressCache
from esphome.bundle import BUNDLE_EXTENSION, BundleFile, BundleResult
from esphome.components import esp32, esp8266
from esphome.components import esp32, esp8266, mqtt
from esphome.components.esp32 import (
KEY_ESP32,
KEY_VARIANT,
VARIANT_ESP32,
get_esp32_variant,
)
from esphome.config import Config
from esphome.const import (
CONF_API,
CONF_AUTH,
CONF_BAUD_RATE,
CONF_BROKER,
CONF_DISABLED,
CONF_DISCOVER_IP,
CONF_ESPHOME,
CONF_LEVEL,
CONF_LOG,
@@ -103,6 +107,7 @@ from esphome.const import (
CONF_WEB_SERVER,
CONF_WIFI,
KEY_CORE,
KEY_TARGET_FRAMEWORK,
KEY_TARGET_PLATFORM,
PLATFORM_BK72XX,
PLATFORM_ESP32,
@@ -567,8 +572,6 @@ def test_command_config__no_defaults_dumps_user_snapshot(
) -> None:
"""``--no-defaults`` dumps ``config.user_config`` instead of the
validated config, so schema defaults don't leak into the output."""
from esphome.config import Config
setup_core(tmp_path=tmp_path, config={"esphome": {"name": "test"}})
args = MockArgs()
args.show_secrets = True
@@ -621,8 +624,6 @@ def test_command_config__no_defaults_skips_strip_default_ids(
) -> None:
"""When ``--no-defaults`` is set, ``strip_default_ids`` isn't run --
the user snapshot is already free of schema-injected IDs."""
from esphome.config import Config
setup_core(tmp_path=tmp_path, config={"esphome": {"name": "test"}})
args = MockArgs()
args.show_secrets = True
@@ -3440,9 +3441,6 @@ def test_get_port_type() -> None:
def test_mqtt_reexports_discover_ip() -> None:
"""The old import path must keep working for external code."""
from esphome.components import mqtt
from esphome.const import CONF_DISCOVER_IP
assert mqtt.CONF_DISCOVER_IP is CONF_DISCOVER_IP
@@ -5909,8 +5907,6 @@ class MockSerial:
chunk = self.chunks[self.chunk_index]
if chunk is MOCK_SERIAL_END:
# Sentinel means we're done - simulate port closed
import serial
raise serial.SerialException("Port closed")
# Respect the requested size and keep any remaining bytes
if size <= 0:
@@ -5924,8 +5920,6 @@ class MockSerial:
# Entire chunk consumed; advance to the next one
self.chunk_index += 1
return data # type: ignore[return-value]
import serial
raise serial.SerialException("Port closed")
@@ -6784,8 +6778,6 @@ def test_parse_args_argcomplete_only_runs_when_completing() -> None:
def test_should_subscribe_states_default() -> None:
"""Test that states are shown by default when nothing is set."""
from esphome.__main__ import _should_subscribe_states
args = parse_args(["esphome", "logs", "device.yaml"])
with patch.dict(os.environ, {}, clear=False):
os.environ.pop("ESPHOME_LOG_STATES", None)
@@ -6794,8 +6786,6 @@ def test_should_subscribe_states_default() -> None:
def test_should_subscribe_states_env_suppresses() -> None:
"""Test that ESPHOME_LOG_STATES=false suppresses states by default."""
from esphome.__main__ import _should_subscribe_states
args = parse_args(["esphome", "logs", "device.yaml"])
with patch.dict(os.environ, {"ESPHOME_LOG_STATES": "false"}):
assert _should_subscribe_states(args) is False
@@ -6803,8 +6793,6 @@ def test_should_subscribe_states_env_suppresses() -> None:
def test_should_subscribe_states_env_enables() -> None:
"""Test that ESPHOME_LOG_STATES=true enables states by default."""
from esphome.__main__ import _should_subscribe_states
args = parse_args(["esphome", "logs", "device.yaml"])
with patch.dict(os.environ, {"ESPHOME_LOG_STATES": "true"}):
assert _should_subscribe_states(args) is True
@@ -6812,8 +6800,6 @@ def test_should_subscribe_states_env_enables() -> None:
def test_should_subscribe_states_flag_overrides_env() -> None:
"""Test that --states overrides ESPHOME_LOG_STATES=false."""
from esphome.__main__ import _should_subscribe_states
args = parse_args(["esphome", "logs", "--states", "device.yaml"])
with patch.dict(os.environ, {"ESPHOME_LOG_STATES": "false"}):
assert _should_subscribe_states(args) is True
@@ -6821,8 +6807,6 @@ def test_should_subscribe_states_flag_overrides_env() -> None:
def test_should_subscribe_states_no_flag_overrides_env() -> None:
"""Test that --no-states overrides ESPHOME_LOG_STATES=true."""
from esphome.__main__ import _should_subscribe_states
args = parse_args(["esphome", "logs", "--no-states", "device.yaml"])
with patch.dict(os.environ, {"ESPHOME_LOG_STATES": "true"}):
assert _should_subscribe_states(args) is False
@@ -7135,6 +7119,82 @@ def test_warn_source_tree_mismatch_falls_back_when_stat_fails(
assert not caplog.text
@pytest.mark.parametrize(
"error",
[
FileNotFoundError("no such compiler"),
RuntimeError("Could not query builtin include dirs"),
ValueError("no C++ translation unit found"),
KeyError("command"),
None, # replaced with EsphomeError inside
],
)
def test_compile_program_espidf_idedata_failure_does_not_fail_build(
error: Exception,
caplog: pytest.LogCaptureFixture,
) -> None:
"""A post-compile idedata error is a warning: the firmware already built."""
if error is None:
error = EsphomeError("compile database is unusable")
CORE.toolchain = Toolchain.ESP_IDF
CORE.data[KEY_CORE] = {
KEY_TARGET_PLATFORM: "esp32",
KEY_TARGET_FRAMEWORK: "esp-idf",
}
with (
patch("esphome.espidf.toolchain.run_compile", return_value=0),
patch("esphome.espidf.toolchain.create_factory_bin"),
patch("esphome.espidf.toolchain.create_ota_bin"),
patch("esphome.espidf.toolchain.create_elf_copy"),
patch("esphome.espidf.toolchain.get_idedata", side_effect=error),
patch("esphome.__main__._check_and_emit_build_info"),
):
assert compile_program(MagicMock(), {}) == 0
assert "Could not generate idedata" in caplog.text
def test_compile_program_espidf_idedata_success_is_silent(
caplog: pytest.LogCaptureFixture,
) -> None:
"""The healthy path: idedata generated, nothing to warn about."""
CORE.toolchain = Toolchain.ESP_IDF
CORE.data[KEY_CORE] = {
KEY_TARGET_PLATFORM: "esp32",
KEY_TARGET_FRAMEWORK: "esp-idf",
}
with (
patch("esphome.espidf.toolchain.run_compile", return_value=0),
patch("esphome.espidf.toolchain.create_factory_bin"),
patch("esphome.espidf.toolchain.create_ota_bin"),
patch("esphome.espidf.toolchain.create_elf_copy"),
patch("esphome.espidf.toolchain.get_idedata", return_value={"cc_path": "x"}),
patch("esphome.__main__._check_and_emit_build_info"),
):
assert compile_program(MagicMock(), {}) == 0
assert "idedata" not in caplog.text
def test_compile_program_espidf_idedata_none_warns(
caplog: pytest.LogCaptureFixture,
) -> None:
"""A silent None from the post-compile idedata refresh is made visible."""
CORE.toolchain = Toolchain.ESP_IDF
CORE.data[KEY_CORE] = {
KEY_TARGET_PLATFORM: "esp32",
KEY_TARGET_FRAMEWORK: "esp-idf",
}
with (
patch("esphome.espidf.toolchain.run_compile", return_value=0),
patch("esphome.espidf.toolchain.create_factory_bin"),
patch("esphome.espidf.toolchain.create_ota_bin"),
patch("esphome.espidf.toolchain.create_elf_copy"),
patch("esphome.espidf.toolchain.get_idedata", return_value=None),
patch("esphome.__main__._check_and_emit_build_info"),
):
assert compile_program(MagicMock(), {}) == 0
assert "No idedata was generated" in caplog.text
@pytest.mark.asyncio
async def test_wrap_to_code_comment_is_insertion_order_independent() -> None:
"""The config comment dumps with sorted keys: voluptuous fills schema
+67
View File
@@ -126,3 +126,70 @@ def test_print_summary_handles_no_memory_types(
size_json = _write_size_json(tmp_path, {"image_size": 0})
print_summary(size_json, partitions_csv=None)
assert capsys.readouterr().out == ""
def test_print_summary_flash_line(
tmp_path: Path, capsys: pytest.CaptureFixture[str]
) -> None:
"""image_size + a factory app partition produce the Flash line."""
size_json = tmp_path / "esp_idf_size.json"
size_json.write_text(
json.dumps(
{
"memory_types": {"DRAM": {"used": 100, "size": 200}},
"image_size": 500,
}
)
)
partitions = tmp_path / "partitions.csv"
partitions.write_text(
"# name, type, subtype, offset, size\napp0, app, factory, 0x10000, 0x100000\n"
)
print_summary(size_json, partitions)
out = capsys.readouterr().out
assert "RAM: [===== ] 50.0% (used 100 bytes from 200 bytes)" in out
assert "Flash: [ ] 0.0% (used 500 bytes from 1048576 bytes)" in out
def test_print_summary_missing_ram_region_warns(
tmp_path: Path, caplog: pytest.LogCaptureFixture
) -> None:
"""A missing RAM line is diagnosable, not a silently absent CI metric."""
size_json = _write_size_json(tmp_path, {"memory_types": {}, "image_size": 100})
print_summary(size_json, partitions_csv=None)
assert "Skipping RAM summary" in caplog.text
def test_print_summary_bad_partitions_warns(
tmp_path: Path, caplog: pytest.LogCaptureFixture
) -> None:
"""An unparseable partition table skips the Flash line with a warning."""
size_json = _write_size_json(tmp_path, _esp32_size_data())
partitions = tmp_path / "partitions.csv"
partitions.write_text("not,a,valid,partition,table\n")
print_summary(size_json, partitions_csv=partitions)
assert "Skipping Flash summary" in caplog.text
def test_print_summary_corrupt_json_warns(
tmp_path: Path, caplog: pytest.LogCaptureFixture
) -> None:
size_json = tmp_path / "size.json"
size_json.write_text("{not json")
print_summary(size_json, partitions_csv=None)
assert "Skipping size summary" in caplog.text
def test_print_summary_missing_flash_inputs_warn(
tmp_path: Path, caplog: pytest.LogCaptureFixture
) -> None:
"""Both absent-input paths for the Flash line name their cause."""
size_json = _write_size_json(tmp_path, _esp32_size_data())
print_summary(size_json, partitions_csv=None)
assert "no partition table given" in caplog.text
caplog.clear()
data = _esp32_size_data()
data.pop("image_size", None)
size_json = _write_size_json(tmp_path, data)
print_summary(size_json, partitions_csv=tmp_path / "partitions.cssv")
assert "no image_size" in caplog.text