mirror of
https://github.com/esphome/esphome.git
synced 2026-10-04 02:05:59 +00:00
Merge branch 'dev' into platformio-skip-private-probe
This commit is contained in:
@@ -0,0 +1,246 @@
|
||||
"""Tests for the ninja build-tool helper script."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from pathlib import Path
|
||||
import subprocess
|
||||
import sys
|
||||
from unittest.mock import MagicMock, patch
|
||||
|
||||
import pytest
|
||||
|
||||
from esphome.build_gen import build_tool
|
||||
|
||||
|
||||
def test_ar_removes_stale_archive(tmp_path: Path) -> None:
|
||||
archive = tmp_path / "lib.a"
|
||||
archive.write_text("stale")
|
||||
rsp = tmp_path / "lib.a.rsp"
|
||||
rsp.write_text("a.o\n")
|
||||
with (
|
||||
patch.object(
|
||||
build_tool.sys,
|
||||
"argv",
|
||||
["build_tool", "ar", "ar-bin", str(archive), str(rsp)],
|
||||
),
|
||||
patch.object(
|
||||
build_tool.subprocess, "run", return_value=MagicMock(returncode=0)
|
||||
) as mock_run,
|
||||
):
|
||||
assert build_tool.main() == 0
|
||||
assert not archive.exists()
|
||||
# The rspfile is expanded by the shim (GNU ar would escape backslashes)
|
||||
assert mock_run.call_args[0][0] == ["ar-bin", "rcs", str(archive), "a.o"]
|
||||
|
||||
|
||||
def test_copy(tmp_path: Path) -> None:
|
||||
src = tmp_path / "firmware.bin"
|
||||
src.write_text("data")
|
||||
dst = tmp_path / "firmware.factory.bin"
|
||||
with patch.object(
|
||||
build_tool.sys, "argv", ["build_tool", "copy", str(src), str(dst)]
|
||||
):
|
||||
assert build_tool.main() == 0
|
||||
assert dst.read_text() == "data"
|
||||
|
||||
|
||||
def test_unknown_mode(capsys: pytest.CaptureFixture[str]) -> None:
|
||||
with patch.object(build_tool.sys, "argv", ["build_tool", "bogus"]):
|
||||
assert build_tool.main() == 1
|
||||
assert "unknown build_tool mode" in capsys.readouterr().err
|
||||
|
||||
|
||||
def test_runs_as_script(tmp_path: Path) -> None:
|
||||
"""The ninja rules invoke the file as a plain script."""
|
||||
|
||||
src = tmp_path / "a.bin"
|
||||
src.write_text("x")
|
||||
dst = tmp_path / "b.bin"
|
||||
result = subprocess.run(
|
||||
[sys.executable, build_tool.__file__, "copy", str(src), str(dst)],
|
||||
check=False,
|
||||
)
|
||||
assert result.returncode == 0
|
||||
assert dst.read_text() == "x"
|
||||
|
||||
|
||||
def test_ar_expands_rspfile_without_escaping(tmp_path) -> None:
|
||||
"""Backslash paths survive: the shim expands the rspfile itself instead
|
||||
of letting GNU ar treat backslashes as escapes."""
|
||||
rsp = tmp_path / "objs.rsp"
|
||||
rsp.write_text("obj/a.o\nsub\\b.o\n")
|
||||
with (
|
||||
patch.object(
|
||||
build_tool.sys,
|
||||
"argv",
|
||||
["build_tool", "ar", "ar-bin", str(tmp_path / "lib.a"), str(rsp)],
|
||||
),
|
||||
patch.object(
|
||||
build_tool.subprocess, "run", return_value=MagicMock(returncode=0)
|
||||
) as mock_run,
|
||||
):
|
||||
assert build_tool.main() == 0
|
||||
assert mock_run.call_args[0][0] == [
|
||||
"ar-bin",
|
||||
"rcs",
|
||||
str(tmp_path / "lib.a"),
|
||||
"obj/a.o",
|
||||
"sub\\b.o",
|
||||
]
|
||||
|
||||
|
||||
def test_ar_unquotes_ninja_escaped_paths(tmp_path: Path) -> None:
|
||||
"""The shim strips a simple surrounding quote, since ninja shell-
|
||||
quotes special rsp paths, so ar sees the real filename."""
|
||||
rsp = tmp_path / "t.rsp"
|
||||
rsp.write_text("'obj/a b.o'\nobj/c.o\n")
|
||||
with (
|
||||
patch.object(
|
||||
build_tool.sys, "argv", ["bt", "ar", "/usr/bin/ar", "lib.a", str(rsp)]
|
||||
),
|
||||
patch.object(build_tool.subprocess, "run") as mock_run,
|
||||
):
|
||||
mock_run.return_value.returncode = 0
|
||||
rc = build_tool.main()
|
||||
assert rc == 0
|
||||
assert mock_run.call_args.args[0] == [
|
||||
"/usr/bin/ar",
|
||||
"rcs",
|
||||
"lib.a",
|
||||
"obj/a b.o",
|
||||
"obj/c.o",
|
||||
]
|
||||
|
||||
|
||||
def test_ar_empty_object_list_fails(
|
||||
tmp_path: Path, capsys: pytest.CaptureFixture[str]
|
||||
) -> None:
|
||||
"""A lost object list is an error here, not undefined symbols at link."""
|
||||
rsp = tmp_path / "t.rsp"
|
||||
rsp.write_text("\n\n")
|
||||
with patch.object(
|
||||
build_tool.sys, "argv", ["bt", "ar", "/usr/bin/ar", "lib.a", str(rsp)]
|
||||
):
|
||||
rc = build_tool.main()
|
||||
assert rc == 1
|
||||
assert "no objects listed" in capsys.readouterr().err
|
||||
|
||||
|
||||
def test_ar_batches_long_object_lists(tmp_path: Path) -> None:
|
||||
"""The expanded argv must stay under the Windows 32767-char limit: a
|
||||
long object list creates with rcs, then appends with qs."""
|
||||
archive = tmp_path / "lib.a"
|
||||
rsp = tmp_path / "lib.a.rsp"
|
||||
objects = [f"dir/{'x' * 120}_{i}.o" for i in range(400)]
|
||||
rsp.write_text("\n".join(objects) + "\n")
|
||||
with (
|
||||
patch.object(
|
||||
build_tool.sys,
|
||||
"argv",
|
||||
["build_tool", "ar", "ar-bin", str(archive), str(rsp)],
|
||||
),
|
||||
patch.object(
|
||||
build_tool.subprocess, "run", return_value=MagicMock(returncode=0)
|
||||
) as mock_run,
|
||||
):
|
||||
assert build_tool.main() == 0
|
||||
calls = [c[0][0] for c in mock_run.call_args_list]
|
||||
assert len(calls) > 1
|
||||
assert calls[0][1] == "rcs"
|
||||
assert all(c[1] == "qs" for c in calls[1:])
|
||||
assert [o for c in calls for o in c[3:]] == objects
|
||||
assert all(sum(len(a) + 1 for a in c) < 32000 for c in calls)
|
||||
|
||||
|
||||
def test_ar_batch_failure_stops(tmp_path: Path) -> None:
|
||||
"""A failing batch propagates its exit code without running the rest."""
|
||||
archive = tmp_path / "lib.a"
|
||||
rsp = tmp_path / "lib.a.rsp"
|
||||
rsp.write_text("\n".join(f"{'y' * 200}_{i}.o" for i in range(300)) + "\n")
|
||||
with (
|
||||
patch.object(
|
||||
build_tool.sys,
|
||||
"argv",
|
||||
["build_tool", "ar", "ar-bin", str(archive), str(rsp)],
|
||||
),
|
||||
patch.object(
|
||||
build_tool.subprocess,
|
||||
"run",
|
||||
side_effect=lambda cmd, **kw: (
|
||||
archive.write_text("partial"),
|
||||
MagicMock(returncode=3),
|
||||
)[1],
|
||||
) as mock_run,
|
||||
):
|
||||
assert build_tool.main() == 3
|
||||
assert mock_run.call_count == 1
|
||||
# The failed batch must not leave a truncated archive behind
|
||||
assert not archive.exists()
|
||||
|
||||
|
||||
def test_ar_exception_leaves_no_partial_archive(tmp_path: Path) -> None:
|
||||
"""A missing ar binary mid-loop must not leave a truncated archive from
|
||||
earlier successful batches."""
|
||||
archive = tmp_path / "lib.a"
|
||||
rsp = tmp_path / "lib.a.rsp"
|
||||
rsp.write_text("a.o\n")
|
||||
with (
|
||||
patch.object(
|
||||
build_tool.sys,
|
||||
"argv",
|
||||
["build_tool", "ar", "ar-bin", str(archive), str(rsp)],
|
||||
),
|
||||
patch.object(
|
||||
build_tool.subprocess,
|
||||
"run",
|
||||
side_effect=lambda cmd, **kw: (
|
||||
archive.write_text("partial"),
|
||||
(_ for _ in ()).throw(FileNotFoundError("no ar")),
|
||||
),
|
||||
),
|
||||
pytest.raises(FileNotFoundError),
|
||||
):
|
||||
build_tool.main()
|
||||
assert not archive.exists()
|
||||
|
||||
|
||||
def test_surplus_arguments_error(capsys: pytest.CaptureFixture[str]) -> None:
|
||||
"""A mis-specified ninja rule passing extra operands errors instead of
|
||||
silently dropping them."""
|
||||
with patch.object(
|
||||
build_tool.sys, "argv", ["build_tool", "copy", "a", "b", "extra"]
|
||||
):
|
||||
assert build_tool.main() == 1
|
||||
assert "expected 2 arguments, got 3" in capsys.readouterr().err
|
||||
|
||||
|
||||
def test_copy_same_file_keeps_the_input(
|
||||
tmp_path: Path, capsys: pytest.CaptureFixture[str]
|
||||
) -> None:
|
||||
"""A same-file copy (dst IS src) must not unlink the input, and fails
|
||||
with a message and exit code like the other shim paths."""
|
||||
src = tmp_path / "firmware.bin"
|
||||
src.write_bytes(b"image")
|
||||
with patch.object(
|
||||
build_tool.sys, "argv", ["build_tool", "copy", str(src), str(src)]
|
||||
):
|
||||
assert build_tool.main() == 1
|
||||
assert src.read_bytes() == b"image"
|
||||
assert "failed" in capsys.readouterr().err
|
||||
|
||||
|
||||
def test_copy_failure_leaves_no_partial_output(tmp_path: Path) -> None:
|
||||
"""A failed copy unlinks the destination; a partial firmware image must
|
||||
never be left on disk."""
|
||||
dst = tmp_path / "firmware.factory.bin"
|
||||
dst.write_text("stale")
|
||||
with (
|
||||
patch.object(build_tool.shutil, "copyfile", side_effect=OSError("disk full")),
|
||||
patch.object(
|
||||
build_tool.sys,
|
||||
"argv",
|
||||
["build_tool", "copy", str(tmp_path / "src.bin"), str(dst)],
|
||||
),
|
||||
):
|
||||
assert build_tool.main() == 1
|
||||
assert not dst.exists()
|
||||
@@ -0,0 +1,72 @@
|
||||
"""Tests for the esp32 sdkconfig write and its toolchain-gated clean."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
from pathlib import Path
|
||||
import time
|
||||
from unittest.mock import patch
|
||||
|
||||
import pytest
|
||||
|
||||
from esphome.components.esp32 import _write_sdkconfig
|
||||
from esphome.components.esp32.const import KEY_SDKCONFIG_OPTIONS
|
||||
from esphome.const import KEY_CORE, KEY_ESP32, KEY_FRAMEWORK_VERSION, Toolchain
|
||||
from esphome.core import CORE
|
||||
from esphome.espidf.toolchain import has_outdated_files
|
||||
|
||||
|
||||
def _setup_core(tmp_path: Path, toolchain: Toolchain | None) -> None:
|
||||
CORE.config_path = tmp_path / "test.yaml"
|
||||
CORE.build_path = tmp_path
|
||||
CORE.toolchain = toolchain
|
||||
CORE.data[KEY_ESP32] = {KEY_SDKCONFIG_OPTIONS: {"CONFIG_X": "y"}}
|
||||
CORE.data[KEY_CORE] = {KEY_FRAMEWORK_VERSION: "5.5.5"}
|
||||
|
||||
|
||||
def _seed_configured_build(tmp_path: Path) -> None:
|
||||
"""A settled native build: configure outputs predate what comes next."""
|
||||
build = tmp_path / "build"
|
||||
(build / "config").mkdir(parents=True)
|
||||
(build / "config" / "sdkconfig.h").write_text("")
|
||||
(build / "CMakeCache.txt").write_text("")
|
||||
(build / "build.ninja").write_text("")
|
||||
# Explicitly older than what the test writes next: has_outdated_files()
|
||||
# compares st_mtime with a strict >, so same-tick writes would pass
|
||||
past = time.time() - 60
|
||||
for f in build.rglob("*"):
|
||||
os.utime(f, (past, past))
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("toolchain", "clean_expected"),
|
||||
[(Toolchain.ESP_IDF, False), (Toolchain.PLATFORMIO, True), (None, True)],
|
||||
)
|
||||
def test_write_sdkconfig_cleans_only_on_platformio(
|
||||
tmp_path: Path, toolchain: Toolchain | None, clean_expected: bool
|
||||
) -> None:
|
||||
"""A changed sdkconfig forces a full clean only under PlatformIO; the
|
||||
esp-idf toolchain reconfigures via has_outdated_files() instead; an
|
||||
unresolved toolchain fails safe onto the clean."""
|
||||
_setup_core(tmp_path, toolchain)
|
||||
_seed_configured_build(tmp_path)
|
||||
with (
|
||||
patch.object(CORE, "name", "test"),
|
||||
patch("esphome.components.esp32.clean_build") as clean,
|
||||
):
|
||||
_write_sdkconfig()
|
||||
assert "CONFIG_X" in CORE.relative_build_path("sdkconfig.test").read_text()
|
||||
assert clean.called is clean_expected
|
||||
if clean_expected:
|
||||
clean.assert_called_once_with(clear_pio_cache=False)
|
||||
# The change must still trigger a reconfigure: the internal
|
||||
# sdkconfig snapshot is now newer than build/CMakeCache.txt
|
||||
assert has_outdated_files() is True
|
||||
clean.reset_mock()
|
||||
# A settled configure restamps the cache; an unchanged rewrite
|
||||
# must then neither clean nor mark the build stale
|
||||
future = time.time() + 60
|
||||
os.utime(CORE.relative_build_path("build/CMakeCache.txt"), (future, future))
|
||||
_write_sdkconfig()
|
||||
clean.assert_not_called()
|
||||
assert has_outdated_files() is False
|
||||
@@ -53,9 +53,12 @@ def test_nonzero_indices_are_nonzero(gamma: float) -> None:
|
||||
assert table[i] >= 1, f"gamma={gamma}, index {i}: got {table[i]}"
|
||||
|
||||
|
||||
@pytest.mark.parametrize("gamma", [1.0, 2.0, 2.2, 2.8, 3.0])
|
||||
@pytest.mark.parametrize("gamma", [1.0, 1.8, 2.0, 2.2, 2.8, 3.0, 4.0])
|
||||
def test_table_monotonically_nondecreasing(gamma: float) -> None:
|
||||
"""The gamma table must be monotonically non-decreasing."""
|
||||
"""The gamma table must be monotonically non-decreasing.
|
||||
|
||||
gamma_table_reverse_search()'s binary search depends on this.
|
||||
"""
|
||||
table = generate_gamma_table(gamma)
|
||||
for i in range(1, 256):
|
||||
assert table[i] >= table[i - 1], (
|
||||
@@ -115,3 +118,13 @@ def test_lut_output_monotonically_nondecreasing() -> None:
|
||||
result = _simulate_gamma_correct_lut(table, value)
|
||||
assert result >= prev, f"value={value}: result {result} < previous {prev}"
|
||||
prev = result
|
||||
|
||||
|
||||
def test_table_matches_raw_power_curve() -> None:
|
||||
"""Check the gamma table against known good values for gamma=2.8."""
|
||||
table = generate_gamma_table(2.8)
|
||||
golden = {1: 1, 5: 1, 15: 24, 27: 122, 28: 135, 100: 4766, 200: 33193, 254: 64818}
|
||||
for i, expected in golden.items():
|
||||
assert table[i] == expected, (
|
||||
f"index {i}: table[{i}]={table[i]} expected {expected}"
|
||||
)
|
||||
|
||||
@@ -1127,6 +1127,34 @@ def test_config_hash_same_for_different_config_dirs(tmp_path: Path) -> None:
|
||||
assert hash1 == hash2
|
||||
|
||||
|
||||
def test_config_hash_same_for_different_data_dirs(
|
||||
tmp_path: Path, monkeypatch: pytest.MonkeyPatch
|
||||
) -> None:
|
||||
"""Test that downloaded file paths hash the same wherever data_dir lives."""
|
||||
config_dir = tmp_path / "config"
|
||||
config_dir.mkdir()
|
||||
|
||||
CORE.reset()
|
||||
CORE.config_path = config_dir / "device.yaml"
|
||||
CORE.config = {
|
||||
"esphome": {"name": "test"},
|
||||
"file": config_dir / ".esphome" / "image" / "c44630d6",
|
||||
}
|
||||
hash1 = CORE.config_hash
|
||||
|
||||
other_data_dir = tmp_path / "data"
|
||||
CORE.reset()
|
||||
monkeypatch.setenv("ESPHOME_DATA_DIR", str(other_data_dir))
|
||||
CORE.config_path = config_dir / "device.yaml"
|
||||
CORE.config = {
|
||||
"esphome": {"name": "test"},
|
||||
"file": other_data_dir / "image" / "c44630d6",
|
||||
}
|
||||
hash2 = CORE.config_hash
|
||||
|
||||
assert hash1 == hash2
|
||||
|
||||
|
||||
def test_make_app_name_cpp_no_mac_simple() -> None:
|
||||
"""Test simple name without MAC suffix returns string literal."""
|
||||
cpp_expr, global_decl, byte_len = make_app_name_cpp(
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -520,6 +520,33 @@ def test_check_esp_idf_install_feature_failure(espidf_mocks: SimpleNamespace) ->
|
||||
check_esp_idf_install(_IDF_VERSION, force=True, features=["fb"])
|
||||
|
||||
|
||||
def test_python_deps_use_uv_when_available(
|
||||
espidf_mocks: SimpleNamespace, monkeypatch: pytest.MonkeyPatch
|
||||
) -> None:
|
||||
"""The python env installs go through uv when on the PATH, pip otherwise."""
|
||||
monkeypatch.delenv("UV_HTTP_RETRIES", raising=False)
|
||||
with patch(
|
||||
"esphome.espidf.framework.shutil.which",
|
||||
# Keyed on the name: the same which() also probes the default tools
|
||||
side_effect=lambda name: "/usr/bin/uv" if name == "uv" else None,
|
||||
):
|
||||
check_esp_idf_install(_IDF_VERSION, force=True, features=["fb"])
|
||||
upgrade_call, feature_call = espidf_mocks.run_ok.call_args_list[1:3]
|
||||
upgrade_cmd, feature_cmd = upgrade_call.args[0], feature_call.args[0]
|
||||
assert upgrade_cmd[:3] == ["/usr/bin/uv", "pip", "install"]
|
||||
assert "--python" in upgrade_cmd
|
||||
assert feature_cmd[:3] == ["/usr/bin/uv", "pip", "install"]
|
||||
assert upgrade_call.kwargs["env"]["UV_HTTP_RETRIES"] == "10"
|
||||
|
||||
espidf_mocks.run_ok.reset_mock()
|
||||
monkeypatch.setenv("UV_HTTP_RETRIES", "3") # an explicit user value wins
|
||||
with patch("esphome.espidf.framework.shutil.which", return_value=None):
|
||||
check_esp_idf_install(_IDF_VERSION, force=True, features=["fb"])
|
||||
upgrade_call = espidf_mocks.run_ok.call_args_list[1]
|
||||
assert upgrade_call.args[0][1:4] == ["-m", "pip", "install"]
|
||||
assert upgrade_call.kwargs["env"]["UV_HTTP_RETRIES"] == "3"
|
||||
|
||||
|
||||
def _mark_installed() -> None:
|
||||
"""Create the extracted marker and python-env interpreter so the install
|
||||
check takes the already-installed path rather than force-installing."""
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import errno
|
||||
import io
|
||||
import logging
|
||||
import os
|
||||
@@ -5,7 +6,7 @@ from pathlib import Path
|
||||
import socket
|
||||
import stat
|
||||
import types
|
||||
from unittest.mock import MagicMock, patch
|
||||
from unittest.mock import MagicMock, call, patch
|
||||
|
||||
from aioesphomeapi.host_resolver import AddrInfo, IPv4Sockaddr, IPv6Sockaddr
|
||||
from hypothesis import given, settings
|
||||
@@ -966,6 +967,77 @@ def test_copy_file_if_changed_nonexistent_source(tmp_path: Path) -> None:
|
||||
helpers.copy_file_if_changed(src, dst)
|
||||
|
||||
|
||||
def test_rmtree_removes_tree(tmp_path: Path) -> None:
|
||||
"""Test rmtree removes a populated directory tree."""
|
||||
target = tmp_path / "target"
|
||||
(target / "sub").mkdir(parents=True)
|
||||
(target / "sub" / "file.txt").write_text("content")
|
||||
|
||||
helpers.rmtree(target)
|
||||
assert not target.exists()
|
||||
|
||||
|
||||
def test_rmtree_nonexistent_path(tmp_path: Path) -> None:
|
||||
"""Test rmtree on an already-removed path is a no-op."""
|
||||
helpers.rmtree(tmp_path / "gone")
|
||||
|
||||
|
||||
def test_rmtree_retries_when_directory_repopulated(tmp_path: Path) -> None:
|
||||
"""Test rmtree retries when a file appears mid-delete (Finder .DS_Store race)."""
|
||||
target = tmp_path / "target"
|
||||
(target / "sub").mkdir(parents=True)
|
||||
real_rmdir = os.rmdir
|
||||
repopulated = False
|
||||
|
||||
def racy_rmdir(path, **kwargs):
|
||||
nonlocal repopulated
|
||||
if not repopulated and Path(path).name == "target":
|
||||
repopulated = True
|
||||
(target / ".DS_Store").write_text("x") # Finder wins the race
|
||||
real_rmdir(path, **kwargs)
|
||||
|
||||
with patch("os.rmdir", side_effect=racy_rmdir), patch("time.sleep"):
|
||||
helpers.rmtree(target)
|
||||
assert repopulated
|
||||
assert not target.exists()
|
||||
|
||||
|
||||
def test_rmtree_raises_after_retries_exhausted(tmp_path: Path) -> None:
|
||||
"""Test rmtree gives up on a persistent ENOTEMPTY once attempts run out."""
|
||||
target = tmp_path / "target"
|
||||
target.mkdir()
|
||||
errs = [
|
||||
OSError(errno.ENOTEMPTY, "Directory not empty", str(target))
|
||||
for _ in range(helpers.RMTREE_MAX_ATTEMPTS)
|
||||
]
|
||||
|
||||
with (
|
||||
patch("shutil.rmtree", side_effect=errs) as mock_rmtree,
|
||||
patch("time.sleep") as mock_sleep,
|
||||
pytest.raises(OSError, match="Directory not empty") as excinfo,
|
||||
):
|
||||
helpers.rmtree(target)
|
||||
assert mock_rmtree.call_count == helpers.RMTREE_MAX_ATTEMPTS
|
||||
assert mock_sleep.call_args_list == [call(0.05), call(0.1)]
|
||||
# Final failure chains to the last retried race
|
||||
assert excinfo.value is errs[-1]
|
||||
assert excinfo.value.__cause__ is errs[-2]
|
||||
|
||||
|
||||
def test_rmtree_does_not_retry_other_oserror(tmp_path: Path) -> None:
|
||||
"""Test rmtree raises non-ENOTEMPTY errors immediately."""
|
||||
target = tmp_path / "target"
|
||||
target.mkdir()
|
||||
err = OSError(errno.EACCES, "Permission denied", str(target))
|
||||
|
||||
with (
|
||||
patch("shutil.rmtree", side_effect=err) as mock_rmtree,
|
||||
pytest.raises(OSError, match="Permission denied"),
|
||||
):
|
||||
helpers.rmtree(target)
|
||||
assert mock_rmtree.call_count == 1
|
||||
|
||||
|
||||
def test_resolve_ip_address_sorting() -> None:
|
||||
"""Test that results are sorted by preference."""
|
||||
# Create multiple address infos with different preferences
|
||||
|
||||
@@ -112,6 +112,7 @@ from esphome.const import (
|
||||
PLATFORM_BK72XX,
|
||||
PLATFORM_ESP32,
|
||||
PLATFORM_ESP8266,
|
||||
PLATFORM_HOST,
|
||||
PLATFORM_NRF52,
|
||||
PLATFORM_RP2,
|
||||
Toolchain,
|
||||
@@ -7254,3 +7255,54 @@ async def test_wrap_to_code_comment_is_insertion_order_independent() -> None:
|
||||
assert first == second
|
||||
assert second.index("alpha") < second.index("beta")
|
||||
assert second.index("a: 2") < second.index("z: 1")
|
||||
|
||||
|
||||
def test_host_program_path_platformio_toolchain() -> None:
|
||||
"""Host + PlatformIO toolchain reads the memoized idedata path."""
|
||||
setup_core(platform=PLATFORM_HOST)
|
||||
idedata = SimpleNamespace(firmware_elf_path="/build/x/.pioenvs/x/program")
|
||||
with patch(
|
||||
"esphome.platformio.toolchain.get_idedata", return_value=idedata
|
||||
) as mock_get:
|
||||
assert main._host_program_path({}) == "/build/x/.pioenvs/x/program"
|
||||
mock_get.assert_called_once_with({})
|
||||
|
||||
|
||||
def test_host_program_path_esp_idf_toolchain() -> None:
|
||||
"""Host + native ESP-IDF toolchain asks the espidf toolchain for the ELF."""
|
||||
setup_core(platform=PLATFORM_HOST)
|
||||
CORE.toolchain = Toolchain.ESP_IDF
|
||||
with patch(
|
||||
"esphome.espidf.toolchain.get_elf_path", return_value=Path("/b/app.elf")
|
||||
):
|
||||
assert main._host_program_path({}) == str(Path("/b/app.elf"))
|
||||
|
||||
|
||||
def test_command_compile_host_logs_program_path(
|
||||
caplog: pytest.LogCaptureFixture,
|
||||
) -> None:
|
||||
"""command_compile on host logs the compiled program path."""
|
||||
setup_core(platform=PLATFORM_HOST)
|
||||
with (
|
||||
patch.object(main, "write_cpp", return_value=0),
|
||||
patch.object(main, "compile_program", return_value=0),
|
||||
patch.object(main, "_host_program_path", return_value="/b/program"),
|
||||
caplog.at_level(logging.INFO),
|
||||
):
|
||||
assert main.command_compile(SimpleNamespace(only_generate=False), {}) == 0
|
||||
assert "Successfully compiled program to path '/b/program'" in caplog.text
|
||||
|
||||
|
||||
def test_command_run_host_executes_program(caplog: pytest.LogCaptureFixture) -> None:
|
||||
"""command_run on host logs and executes the compiled program directly."""
|
||||
setup_core(platform=PLATFORM_HOST)
|
||||
with (
|
||||
patch.object(main, "write_cpp", return_value=0),
|
||||
patch.object(main, "compile_program", return_value=0),
|
||||
patch.object(main, "_host_program_path", return_value="/b/program"),
|
||||
patch.object(main, "run_external_process", return_value=0) as mock_run,
|
||||
caplog.at_level(logging.INFO),
|
||||
):
|
||||
assert main.command_run(SimpleNamespace(), {}) == 0
|
||||
mock_run.assert_called_with("/b/program")
|
||||
assert "Running program from path '/b/program'" in caplog.text
|
||||
|
||||
@@ -11,7 +11,7 @@ from unittest.mock import Mock
|
||||
|
||||
import pytest
|
||||
|
||||
from esphome.core import EsphomeError, Library
|
||||
from esphome.core import CORE, EsphomeError, Library
|
||||
import esphome.platformio.library as lib
|
||||
from esphome.platformio.library import (
|
||||
SOURCE_KIND_FOR_SUFFIX,
|
||||
@@ -30,9 +30,13 @@ from esphome.platformio.library import (
|
||||
)
|
||||
|
||||
|
||||
def _backend(emit=lambda component: None) -> LibraryBackend:
|
||||
def _backend(emit=lambda component: None, provides=None) -> LibraryBackend:
|
||||
return LibraryBackend(
|
||||
platform="espressif32", framework="espidf", emit=emit, cache_key="idf"
|
||||
platform="espressif32",
|
||||
framework="espidf",
|
||||
emit=emit,
|
||||
cache_key="idf",
|
||||
provides=provides,
|
||||
)
|
||||
|
||||
|
||||
@@ -971,3 +975,202 @@ def test_source_kind_map_shape() -> None:
|
||||
assert SOURCE_KIND_FOR_SUFFIX[".S"] == "aspp"
|
||||
assert SOURCE_KIND_FOR_SUFFIX[".c"] == "c"
|
||||
assert SOURCE_KIND_FOR_SUFFIX[".cpp"] == "cxx"
|
||||
# SCons's case-sensitive C++ suffixes: PIO compiles .C as C++
|
||||
assert SOURCE_KIND_FOR_SUFFIX[".C"] == "cxx"
|
||||
assert SOURCE_KIND_FOR_SUFFIX[".C++"] == "cxx"
|
||||
|
||||
|
||||
def test_versionless_platform_filtered_dependency_stays_quiet(
|
||||
tmp_path, monkeypatch, caplog: pytest.LogCaptureFixture
|
||||
) -> None:
|
||||
"""A version-less dependency the platform filter excludes is
|
||||
deliberately absent, not a drop to warn about."""
|
||||
_patch_download_with_manifests(
|
||||
monkeypatch,
|
||||
tmp_path,
|
||||
{
|
||||
"esphome/A": {
|
||||
"name": "A",
|
||||
"dependencies": [{"name": "Hash", "platforms": "espressif8266"}],
|
||||
}
|
||||
},
|
||||
)
|
||||
convert_libraries([Library("esphome/A", None, None)], _backend())
|
||||
assert "has no version to resolve" not in caplog.text
|
||||
|
||||
|
||||
def test_versionless_ignored_dependency_stays_quiet(
|
||||
tmp_path, monkeypatch, caplog: pytest.LogCaptureFixture
|
||||
) -> None:
|
||||
"""A lib_ignore'd version-less dependency is deliberately excluded, not
|
||||
a drop; no reconciliation warning."""
|
||||
_patch_download_with_manifests(
|
||||
monkeypatch,
|
||||
tmp_path,
|
||||
{"esphome/A": {"name": "A", "dependencies": [{"name": "Hash"}]}},
|
||||
)
|
||||
CORE.platformio_options = {"lib_ignore": ["Hash"]}
|
||||
convert_libraries([Library("esphome/A", None, None)], _backend())
|
||||
assert "has no version to resolve" not in caplog.text
|
||||
|
||||
|
||||
def test_versionless_dependency_without_provider_warns(
|
||||
tmp_path, monkeypatch, caplog: pytest.LogCaptureFixture
|
||||
) -> None:
|
||||
"""A backend whose tree could supply the name warns on the drop; one
|
||||
without provides() can never act on it, so it stays at debug."""
|
||||
_patch_download_with_manifests(
|
||||
monkeypatch,
|
||||
tmp_path,
|
||||
{
|
||||
"esphome/A": {
|
||||
"name": "A",
|
||||
# The duplicate entry warns once (reconciliation dedup)
|
||||
"dependencies": [{"name": "Hash"}, {"name": "Hash"}],
|
||||
}
|
||||
},
|
||||
)
|
||||
convert_libraries(
|
||||
[Library("esphome/A", None, None)], _backend(provides=lambda name: False)
|
||||
)
|
||||
assert (
|
||||
caplog.text.count(
|
||||
"Hash of esphome/A has no version to resolve and nothing provides it"
|
||||
)
|
||||
== 1
|
||||
)
|
||||
caplog.clear()
|
||||
with caplog.at_level(logging.DEBUG):
|
||||
convert_libraries([Library("esphome/A", None, None)], _backend())
|
||||
records = [
|
||||
r
|
||||
for r in caplog.records
|
||||
if "has no version to resolve and nothing provides it" in r.message
|
||||
]
|
||||
assert records and all(r.levelno == logging.DEBUG for r in records)
|
||||
|
||||
|
||||
def test_url_version_dependency_is_not_substituted_by_provides(
|
||||
tmp_path, monkeypatch, caplog: pytest.LogCaptureFixture
|
||||
) -> None:
|
||||
"""A URL-valued version names one specific source; the backend-provided
|
||||
skip must not replace it with the bundled copy."""
|
||||
_patch_download_with_manifests(
|
||||
monkeypatch,
|
||||
tmp_path,
|
||||
{
|
||||
"esphome/A": {
|
||||
"name": "A",
|
||||
"dependencies": [
|
||||
{"name": "Hash", "version": "https://github.com/o/Hash.git"}
|
||||
],
|
||||
},
|
||||
"o/Hash": {"name": "Hash"},
|
||||
},
|
||||
)
|
||||
emitted: list[str] = []
|
||||
convert_libraries(
|
||||
[Library("esphome/A", "1.0.0", None)],
|
||||
_backend(emit=lambda c: emitted.append(c.name), provides=lambda name: True),
|
||||
)
|
||||
assert "Skip backend-provided" not in caplog.text
|
||||
assert "using the library bundled" not in caplog.text
|
||||
assert any("o/hash" in n.lower() for n in emitted)
|
||||
|
||||
|
||||
def test_versionless_owner_qualified_dependency_warns_despite_provides(
|
||||
tmp_path, monkeypatch, caplog: pytest.LogCaptureFixture
|
||||
) -> None:
|
||||
"""An owner-qualified version-less dependency is not satisfied by
|
||||
provides(); it must still warn."""
|
||||
_patch_download_with_manifests(
|
||||
monkeypatch,
|
||||
tmp_path,
|
||||
{
|
||||
"esphome/A": {
|
||||
"name": "A",
|
||||
"dependencies": [{"name": "Wire", "owner": "Foo"}],
|
||||
}
|
||||
},
|
||||
)
|
||||
convert_libraries(
|
||||
[Library("esphome/A", None, None)],
|
||||
_backend(provides=lambda name: name == "Wire"),
|
||||
)
|
||||
assert "Wire of esphome/A has no version to resolve" in caplog.text
|
||||
|
||||
|
||||
def test_versionless_provided_dependency_stays_quiet(
|
||||
tmp_path, monkeypatch, caplog: pytest.LogCaptureFixture
|
||||
) -> None:
|
||||
"""An owner-less version-less dependency the backend provides is added
|
||||
by the backend after emit; no reconciliation warning."""
|
||||
_patch_download_with_manifests(
|
||||
monkeypatch,
|
||||
tmp_path,
|
||||
{"esphome/A": {"name": "A", "dependencies": [{"name": "Wire"}]}},
|
||||
)
|
||||
convert_libraries(
|
||||
[Library("esphome/A", None, None)],
|
||||
_backend(provides=lambda name: name == "Wire"),
|
||||
)
|
||||
assert "has no version to resolve" not in caplog.text
|
||||
|
||||
|
||||
def test_versionless_dependency_requested_top_level_stays_quiet(
|
||||
tmp_path, monkeypatch, caplog: pytest.LogCaptureFixture
|
||||
) -> None:
|
||||
"""A version-less dependency the config also requests top-level is in
|
||||
the build; no drop warning even without a provides backend."""
|
||||
_patch_download_with_manifests(
|
||||
monkeypatch,
|
||||
tmp_path,
|
||||
{
|
||||
"esphome/A": {"name": "A", "dependencies": [{"name": "Hash"}]},
|
||||
"Hash": {"name": "Hash"},
|
||||
},
|
||||
)
|
||||
convert_libraries(
|
||||
[Library("esphome/A", None, None), Library("Hash", None, None)],
|
||||
_backend(),
|
||||
)
|
||||
assert "has no version to resolve" not in caplog.text
|
||||
|
||||
|
||||
def test_versionless_url_ish_dependency_name_warns_cleanly(
|
||||
tmp_path, monkeypatch, caplog: pytest.LogCaptureFixture
|
||||
) -> None:
|
||||
"""A malformed URL-ish dependency name falls to the drop warning, never
|
||||
a RuntimeError out of the key parser."""
|
||||
_patch_download_with_manifests(
|
||||
monkeypatch,
|
||||
tmp_path,
|
||||
{"esphome/A": {"name": "A", "dependencies": [{"name": "file://"}]}},
|
||||
)
|
||||
convert_libraries(
|
||||
[Library("esphome/A", None, None)], _backend(provides=lambda name: False)
|
||||
)
|
||||
assert (
|
||||
"file:// of esphome/A has no version to resolve and nothing provides it"
|
||||
in caplog.text
|
||||
)
|
||||
|
||||
|
||||
def test_versionless_dependency_matching_resolved_manifest_name_stays_quiet(
|
||||
tmp_path, monkeypatch, caplog: pytest.LogCaptureFixture
|
||||
) -> None:
|
||||
"""A bare name satisfied by an owner-qualified component's manifest
|
||||
name is not a drop."""
|
||||
_patch_download_with_manifests(
|
||||
monkeypatch,
|
||||
tmp_path,
|
||||
{
|
||||
"esphome/A": {"name": "A", "dependencies": [{"name": "B"}]},
|
||||
"esphome/B": {"name": "B"},
|
||||
},
|
||||
)
|
||||
convert_libraries(
|
||||
[Library("esphome/A", None, None), Library("esphome/B", None, None)],
|
||||
_backend(),
|
||||
)
|
||||
assert "has no version to resolve" not in caplog.text
|
||||
|
||||
@@ -1706,6 +1706,53 @@ def test_dump_path_dotdot_reference_outside_anchor() -> None:
|
||||
assert output.strip() == "file: ../shared/font.ttf"
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"data_dir",
|
||||
[
|
||||
pytest.param(Path("/config/.esphome"), id="cli"),
|
||||
pytest.param(Path("/data"), id="addon"),
|
||||
],
|
||||
)
|
||||
def test_dump_path_under_data_dir_uses_default_location(data_dir: Path) -> None:
|
||||
"""Test that Path values under data_dir dump as .esphome/<rest> for any layout."""
|
||||
anchor = Path("/config").absolute()
|
||||
path = data_dir.absolute() / "image" / "c44630d6"
|
||||
output = yaml_util.dump(
|
||||
{"file": path}, relative_to=anchor, data_dir=data_dir.absolute()
|
||||
)
|
||||
assert output.strip() == "file: .esphome/image/c44630d6"
|
||||
|
||||
|
||||
def test_dump_path_equal_to_data_dir() -> None:
|
||||
"""Test that the data dir itself dumps as .esphome, matching the default layout."""
|
||||
anchor = Path("/config").absolute()
|
||||
data_dir = Path("/data").absolute()
|
||||
output = yaml_util.dump({"dir": data_dir}, relative_to=anchor, data_dir=data_dir)
|
||||
assert output.strip() == "dir: .esphome"
|
||||
default = yaml_util.dump(
|
||||
{"dir": anchor / ".esphome"}, relative_to=anchor, data_dir=anchor / ".esphome"
|
||||
)
|
||||
assert default == output
|
||||
|
||||
|
||||
def test_dump_path_outside_data_dir_still_relative_to_anchor() -> None:
|
||||
"""Test that data_dir does not affect paths that are not under it."""
|
||||
anchor = Path("/config").absolute()
|
||||
path = anchor / "fonts" / "arial.ttf"
|
||||
output = yaml_util.dump(
|
||||
{"file": path}, relative_to=anchor, data_dir=Path("/data").absolute()
|
||||
)
|
||||
assert output.strip() == "file: fonts/arial.ttf"
|
||||
|
||||
|
||||
def test_dump_path_data_dir_without_relative_to_is_unchanged() -> None:
|
||||
"""Test that data_dir alone does not change the output."""
|
||||
data_dir = Path("/data").absolute()
|
||||
path = data_dir / "image" / "c44630d6"
|
||||
output = yaml_util.dump({"file": path}, data_dir=data_dir)
|
||||
assert output.strip() == f"file: {path}"
|
||||
|
||||
|
||||
def test_dump_relative_to_does_not_leak_between_calls() -> None:
|
||||
"""Test that the relative_to flag is scoped to a single dump call."""
|
||||
anchor = Path("/config/esphome").absolute()
|
||||
|
||||
Reference in New Issue
Block a user