[core] Add unit tests for the smaller core modules (#21333)

This commit is contained in:
Jesse Hills
2026-10-22 15:23:22 +13:00
committed by GitHub
parent ef79d48b6f
commit 3ae5e1dfad
20 changed files with 1226 additions and 10 deletions
@@ -2,10 +2,13 @@
# pylint: disable=protected-access
import ctypes
import json
import logging
import os
from pathlib import Path
import shlex
from types import SimpleNamespace
from unittest.mock import MagicMock, patch
import pytest
@@ -128,6 +131,65 @@ def test_pick_entry_prefers_esphome_tu() -> None:
assert idedata._pick_entry(entries)["file"].endswith("app.cpp")
def test_pick_entry_without_cxx_tu_raises() -> None:
"""A compile DB holding only C files has no usable translation unit."""
entries = [_entry(f"{ABS}build", f"{ABS}build/src/a.c", "gcc -c a.c")]
with pytest.raises(ValueError, match="no C\\+\\+ translation unit"):
idedata._pick_entry(entries)
def test_expand_response_files_absolute_path(tmp_path: Path) -> None:
"""An absolute response file path is read as is, not joined to the directory."""
rsp = tmp_path / "flags.rsp"
rsp.write_text("-DFROM_RSP=1")
other = tmp_path / "elsewhere"
other.mkdir()
assert idedata._expand_response_files([f"@{rsp}"], other) == ["-DFROM_RSP=1"]
def test_parse_entry_drops_bare_input_files() -> None:
"""A source file given without -c is not kept as a compiler flag."""
entry = _entry(
f"{ABS}build",
f"{ABS}build/src/esphome/x.cpp",
"g++ -DFOO x.cpp -std=gnu++20",
)
_, _, _, cxx_flags = idedata.parse_entry(entry)
assert cxx_flags == ["-std=gnu++20"]
@pytest.mark.parametrize(
("cxx_path", "expected"),
[
pytest.param("g++", "gcc", id="bare"),
pytest.param("/usr/bin/g++", "/usr/bin/gcc", id="path"),
pytest.param("xtensa-esp32-elf-g++.exe", "xtensa-esp32-elf-gcc.exe", id="exe"),
pytest.param("C:\\tools\\g++.exe", "C:\\tools\\gcc.exe", id="backslash"),
pytest.param("/usr/bin/clang++", "/usr/bin/clang++", id="clang"),
],
)
def test_cc_path_from_cxx(cxx_path: str, expected: str) -> None:
"""Only a g++ program name is rewritten to gcc."""
assert idedata._cc_path_from_cxx(cxx_path) == expected
@pytest.mark.parametrize(
("token", "expected"),
[
pytest.param("-includefoo.h", True, id="joined"),
pytest.param("-include", False, id="separate"),
pytest.param("-include-pch", False, id="pch"),
pytest.param("-DFOO", False, id="other"),
],
)
def test_is_joined_include(token: str, expected: bool) -> None:
"""Only the joined ``-includefoo.h`` spelling counts."""
assert idedata.is_joined_include(token) is expected
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 = [
@@ -345,6 +407,48 @@ def test_split_command_empty_returns_empty() -> None:
assert idedata._split_command(" ") == []
@pytest.fixture(name="fake_windows_argv")
def fixture_fake_windows_argv(monkeypatch: pytest.MonkeyPatch) -> list[bool]:
"""Run ``_split_command`` down its Windows branch on any host.
``CommandLineToArgvW`` is replaced by a shlex based stand-in; the returned
list records whether ``LocalFree`` released the argv block.
"""
freed: list[bool] = []
def command_line_to_argv(command: str, argc_ref: object) -> list[str]:
tokens = shlex.split(command)
argc_ref._obj.value = len(tokens) # type: ignore[attr-defined]
return tokens
windll = SimpleNamespace(
shell32=SimpleNamespace(CommandLineToArgvW=command_line_to_argv),
kernel32=SimpleNamespace(LocalFree=lambda _ptr: freed.append(True)),
)
monkeypatch.setattr(ctypes, "windll", windll, raising=False)
monkeypatch.setattr(idedata.os, "name", "nt")
return freed
def test_split_command_windows_branch(fake_windows_argv: list[bool]) -> None:
"""On Windows the command is tokenized by the OS parser and freed after."""
assert idedata._split_command("g++ -DFOO=1 -c x.cpp") == [
"g++",
"-DFOO=1",
"-c",
"x.cpp",
]
assert fake_windows_argv == [True]
def test_split_command_windows_branch_blank_command(
fake_windows_argv: list[bool],
) -> None:
"""A blank command yields no tokens without calling the OS parser."""
assert idedata._split_command(" ") == []
assert fake_windows_argv == []
@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."""
@@ -7,8 +7,10 @@ from typing import Any
import pytest
from esphome import final_validate as fv
import esphome.codegen as cg
from esphome.components import button, number, select, switch
from esphome.config import Config
from esphome.config_validation import Invalid
from esphome.const import (
CONF_DEVICE_CLASS,
@@ -28,6 +30,7 @@ from esphome.core.entity_helpers import (
entity_duplicate_validator,
finalize_entity_strings,
get_base_entity_object_id,
inherit_property_from,
register_device_class,
register_icon,
register_unit_of_measurement,
@@ -1607,3 +1610,59 @@ async def test_sub_entities_create_from_whole_config(
assert str(var) == "rf_sw"
assert calls == [("rf_sw", "<esphome__rf_sw__fn>")]
assert setup_test_environment == ["hub->set_rf_switch(rf_sw)"]
def test_entity_duplicate_validator_without_name() -> None:
"""A config with no name is returned untouched and not tracked."""
validator = entity_duplicate_validator("sensor")
config = {CONF_ID: "no_name"}
assert validator(config) is config
assert not CORE.unique_ids
def test_entity_duplicate_validator_testing_mode_allows_duplicates() -> None:
validator = entity_duplicate_validator("sensor")
config = {CONF_NAME: "Temperature"}
validator(dict(config))
CORE.testing_mode = True
try:
assert validator(dict(config)) == config
finally:
CORE.testing_mode = False
def _inherit_config() -> tuple[Config, ID, dict[str, Any]]:
parent_id = ID("parent", is_declaration=True)
child_id = ID("child", is_declaration=True)
parent = {CONF_ID: parent_id, CONF_ICON: "mdi:parent"}
child = {CONF_ID: child_id, "source": ID("parent"), "inner": {"x": 1}}
config = Config()
config["sensor"] = [parent, child]
config.declare_ids.append((parent_id, ["sensor", 0, CONF_ID]))
config.declare_ids.append((child_id, ["sensor", 1, CONF_ID]))
return config, child_id, child
def test_inherit_property_from_path_inherits_value() -> None:
config, child_id, child = _inherit_config()
validator = inherit_property_from(["inner", CONF_ICON], "source")
token = fv.full_config.set(config)
try:
assert validator(child) is child
finally:
fv.full_config.reset(token)
assert config["sensor"][1]["inner"][CONF_ICON] == "mdi:parent"
def test_inherit_property_from_path_missing_is_ignored() -> None:
config, _, child = _inherit_config()
validator = inherit_property_from(["absent", CONF_ICON], "source")
token = fv.full_config.set(config)
try:
assert validator(child) is child
finally:
fv.full_config.reset(token)
assert "absent" not in config["sensor"][1]
+79
View File
@@ -22,6 +22,7 @@ from esphome.bundle import (
_add_bytes_to_tar,
_default_target_dir,
_find_used_secret_keys,
_validate_tar_members,
add_bundle_file,
add_secret_scan_dir,
extract_bundle,
@@ -1872,3 +1873,81 @@ def test_bundle_round_trip_with_secrets(tmp_path: Path) -> None:
manifest = read_bundle_manifest(bundle_path)
assert manifest.has_secrets is True
def test_add_secret_scan_dir_relative_path(tmp_path: Path) -> None:
"""A relative scan directory is taken as relative to the config directory."""
config_dir = _setup_config_dir(tmp_path)
(config_dir / "secrets.yaml").write_text("pw: hunter2\nunused: nope\n")
package_dir = config_dir / "pkg"
package_dir.mkdir()
(package_dir / "base.yml").write_text("password: !secret pw\n")
add_secret_scan_dir(Path("pkg"))
result = ConfigBundleCreator({}).create_bundle()
assert result.manifest[ManifestKey.HAS_SECRETS] is True
with tarfile.open(fileobj=io.BytesIO(result.data), mode="r:gz") as tar:
secrets_data = tar.extractfile("secrets.yaml").read().decode()
assert "hunter2" in secrets_data
assert "unused" not in secrets_data
def test_discover_files_skips_system_includes_c(tmp_path: Path) -> None:
"""System includes in includes_c are not local files."""
_setup_config_dir(tmp_path)
config: dict[str, Any] = {"esphome": {"includes_c": ["<system.h>"]}}
files = ConfigBundleCreator(config).discover_files()
assert [f.path for f in files] == ["test.yaml"]
def test_discover_files_ignores_missing_path_and_scalars(tmp_path: Path) -> None:
"""Absolute Path values that are not files and non-path values are ignored."""
_setup_config_dir(tmp_path)
config: dict[str, Any] = {
"missing": tmp_path / "does_not_exist.bin",
"number": 5,
"flag": True,
}
files = ConfigBundleCreator(config).discover_files()
assert [f.path for f in files] == ["test.yaml"]
def test_build_filtered_secrets_skips_files_outside_config_dir(
tmp_path: Path,
) -> None:
_setup_config_dir(tmp_path)
outside = tmp_path / "outside_secrets.yaml"
outside.write_text("k: v\n")
creator = ConfigBundleCreator({})
creator._secrets_paths.add(outside.resolve())
assert creator._build_filtered_secrets({"k"}) == {}
def test_validate_tar_members_rejects_escape_through_symlinked_dir(
tmp_path: Path,
) -> None:
"""A member resolving outside the target through an existing symlink is refused."""
target = tmp_path / "out"
target.mkdir()
outside = tmp_path / "outside"
outside.mkdir()
try:
(target / "link").symlink_to(outside, target_is_directory=True)
except (OSError, NotImplementedError):
pytest.skip("symlinks not available")
buf = io.BytesIO()
with tarfile.open(fileobj=buf, mode="w") as tar:
_add_bytes_to_tar(tar, "link/file.txt", b"data")
buf.seek(0)
with (
tarfile.open(fileobj=buf, mode="r") as tar,
pytest.raises(EsphomeError, match="outside target"),
):
_validate_tar_members(tar, target.resolve())
+37
View File
@@ -6,6 +6,8 @@ from unittest.mock import patch
import pytest
from esphome.config_helpers import (
Extend,
Remove,
filter_source_files_from_defines,
filter_source_files_from_platform,
frameworks_for_platforms,
@@ -172,3 +174,38 @@ def test_filter_source_files_from_defines() -> None:
mock_core.defines = set()
assert sorted(filter_func()) == ["automation.cpp", "filter.cpp"]
def test_extend_str_repr_and_equality() -> None:
ext = Extend("my_id")
assert str(ext) == "!extend my_id"
assert repr(ext) == "Extend(my_id)"
assert ext == Extend("my_id")
assert ext != Extend("other")
assert ext != "my_id"
def test_remove_str_repr_and_equality() -> None:
rem = Remove("my_id")
assert str(rem) == "!remove my_id"
assert repr(rem) == "Remove(my_id)"
assert rem == Remove("my_id")
assert rem != Remove("other")
assert rem != "my_id"
def test_filter_source_files_from_platform_unknown_pair() -> None:
"""A target platform/framework pair with no PlatformFramework yields no exclusions."""
mock_core_data = {
KEY_CORE: {
KEY_TARGET_PLATFORM: "not_a_platform",
KEY_TARGET_FRAMEWORK: "arduino",
}
}
with patch("esphome.config_helpers.CORE.data", mock_core_data):
func = filter_source_files_from_platform(
{"a.cpp": {PlatformFramework.ESP32_ARDUINO}}
)
assert func() == []
+83 -1
View File
@@ -1,10 +1,18 @@
"""Tests for the coroutine module."""
from collections.abc import AsyncGenerator, Generator
import contextvars
from typing import Any
import pytest
from esphome.coroutine import CoroPriority, FakeEventLoop, coroutine_with_priority
from esphome.coroutine import (
CoroPriority,
FakeEventLoop,
_Task,
coroutine,
coroutine_with_priority,
)
def test_coro_priority_enum_values() -> None:
@@ -262,3 +270,77 @@ def test_context_inherits_ambient_value_at_schedule_time() -> None:
my_var.reset(token)
assert seen == {"value": "ambient"}
def test_coroutine_does_not_rewrap_esphome_coroutine() -> None:
@coroutine
def wrapped() -> Generator[None, None, None]:
yield
assert coroutine(wrapped) is wrapped
def test_coroutine_rejects_async_generator() -> None:
async def agen() -> AsyncGenerator[int, None]:
yield 1
with pytest.raises(ValueError, match="Async generator functions are not allowed"):
coroutine(agen)
def test_coroutine_flattens_awaitable_and_generator_yields() -> None:
results: list[Any] = []
async def native() -> int:
return 3
def inner() -> Generator[None, None, int]:
yield
return 4
@coroutine
def outer() -> Generator[Any, Any, None]:
results.append((yield native()))
results.append((yield inner()))
loop = FakeEventLoop()
loop.add_job(outer)
loop.flush_tasks()
assert results == [3, 4]
def test_task_equality_and_ordering() -> None:
ctx = contextvars.copy_context()
first = _Task(0.0, 0, iter(()), None, ctx)
same = first.with_priority(0.0)
higher = _Task(1.0, 1, iter(()), None, ctx)
assert first == same
assert (first != same) is False
assert first != higher
assert higher < first
def test_add_job_rejects_coroutine_object() -> None:
async def native() -> None:
"""Never awaited; only used to build a coroutine object."""
coro_obj = native()
try:
with pytest.raises(ValueError, match="not coroutine objects"):
FakeEventLoop().add_job(coro_obj)
finally:
coro_obj.close()
def test_flush_tasks_detects_deadlock() -> None:
def forever() -> Generator[None, None, None]:
while True:
yield
loop = FakeEventLoop()
loop.add_job(forever)
with pytest.raises(RuntimeError, match="Circular dependency"):
loop.flush_tasks()
+41
View File
@@ -1,5 +1,6 @@
from collections.abc import Awaitable, Callable
import importlib.util
import inspect
import logging
from pathlib import Path
import sys
@@ -362,3 +363,43 @@ def test_add_optional_skips_none(monkeypatch: pytest.MonkeyPatch) -> None:
assert ch.add_optional(setter, None) is None
setter.assert_not_called()
add_mock.assert_not_called()
def _register_component_mocks(monkeypatch: pytest.MonkeyPatch) -> tuple[Mock, Mock]:
base = Mock()
base.__str__ = lambda self: "foo.bar"
base.type = Mock()
base.type.__str__ = lambda self: "foo::Bar"
var = Mock(base=base)
app_mock = Mock(register_component_=Mock(return_value=var))
monkeypatch.setattr(ch, "App", app_mock)
monkeypatch.setattr(ch, "CORE", Mock(component_ids=["foo.bar"], data={}))
monkeypatch.setattr(ch, "add", Mock())
return var, app_mock
@pytest.mark.asyncio
async def test_register_component_stack_error_warns_and_skips_source(
monkeypatch: pytest.MonkeyPatch, caplog: pytest.LogCaptureFixture
) -> None:
var, app_mock = _register_component_mocks(monkeypatch)
monkeypatch.setattr(inspect, "stack", Mock(side_effect=IndexError("no stack")))
with caplog.at_level(logging.WARNING):
assert await ch.register_component(var, {}) is var
assert "Error while finding name of component" in caplog.text
app_mock.register_component_.assert_called_once_with(var)
@pytest.mark.asyncio
async def test_register_component_without_module_has_no_source(
monkeypatch: pytest.MonkeyPatch,
) -> None:
var, app_mock = _register_component_mocks(monkeypatch)
monkeypatch.setattr(inspect, "stack", Mock(return_value=[(None,), (None,)]))
monkeypatch.setattr(inspect, "getmodule", lambda _: None)
assert await ch.register_component(var, {}) is var
app_mock.register_component_.assert_called_once_with(var)
+50
View File
@@ -367,6 +367,56 @@ def test_generate_cmakelists_txt_references_project_managed_components_variable(
assert "${ESPHOME_PROJECT_MANAGED_COMPONENTS}" in content
def test_generate_cmakelists_txt_uses_declared_src_dir(
tmp_component: IDFComponent,
) -> None:
"""A library.json srcDir is used as is instead of probing src/Src/."""
lib_dir = tmp_component.path / "lib"
lib_dir.mkdir()
(lib_dir / "main.c").write_text("int main() {}")
tmp_component.data = {"build": {"srcDir": "lib"}}
content = generate_cmakelists_txt(tmp_component)
assert "SRCS" in content
assert "main.c" in content
assert "INCLUDE_DIRS" in content
def test_generate_cmakelists_txt_without_include_dirs(
tmp_component: IDFComponent,
) -> None:
"""No INCLUDE_DIRS line when none of the include directories exist."""
tmp_component.data = {"build": {"srcDir": "missing"}}
content = generate_cmakelists_txt(tmp_component)
assert "INCLUDE_DIRS" not in content
def test_generate_idf_component_yml_with_several_dependencies(
tmp_component: IDFComponent, tmp_path: Path
) -> None:
"""Every dependency lands under one shared dependencies section."""
deps = []
for name in ("dep_a", "dep_b"):
dep = IDFComponent(name, "1.0", source=URLSource("http://dummy.com"))
dep.path = tmp_path / name
deps.append(dep)
tmp_component.dependencies = deps
tmp_component.data = {}
result = generate_idf_component_yml(tmp_component)
assert result == (
"dependencies:\n"
" dep_a:\n"
f" override_path: {deps[0].path}\n"
" dep_b:\n"
f" override_path: {deps[1].path}\n"
)
def test_generate_idf_component_yml_basic(tmp_component):
tmp_component.data = {"description": "test", "repository": {"url": "http://aaa"}}
result = generate_idf_component_yml(tmp_component)
+25
View File
@@ -2055,3 +2055,28 @@ def test_perform_ota_tcp_send_network_error_unchanged(mock_socket: Mock) -> None
]
with pytest.raises(espota2.OTANetworkError, match="sending frame"):
espota2.perform_ota(mock_socket, None, io.BytesIO(b"x" * 100), "test.bin")
@pytest.mark.parametrize(
("dashboard", "expect_device_hint"), [(False, True), (True, False)]
)
def test_run_ota_impl_resolve_failure_hint(
mock_resolve_ip: Mock,
firmware_file: Path,
caplog: pytest.LogCaptureFixture,
monkeypatch: pytest.MonkeyPatch,
dashboard: bool,
expect_device_hint: bool,
) -> None:
"""The --device hint is only useful on the command line, not the dashboard."""
from esphome.core import CORE
monkeypatch.setattr(CORE, "dashboard", dashboard)
mock_resolve_ip.side_effect = EsphomeError("no such host")
with caplog.at_level(logging.ERROR), pytest.raises(espota2.OTAError):
espota2.run_ota_impl_("test.local", 3232, None, firmware_file)
assert "Is it connected to WiFi?" in caplog.text
assert ("try --device <IP>" in caplog.text) is expect_device_hint
assert "static IP address" in caplog.text
+28 -1
View File
@@ -2,7 +2,7 @@
import pytest
from esphome import final_validate as fv
from esphome import config_validation as cv, final_validate as fv
from esphome.config import Config
from esphome.const import CONF_ID
from esphome.core import ID
@@ -45,3 +45,30 @@ def test_non_dict_parent_raises_type_error() -> None:
config.declare_ids.append((list_id, ["listed", 0]))
with pytest.raises(TypeError):
fv.get_declaring_config(ID("in_list"), config)
@pytest.mark.parametrize("pre_built", [False, True])
def test_id_declaration_match_schema_validates_declaration(pre_built: bool) -> None:
config, hub_id, _ = _config()
schema = {cv.Required("mode"): cv.string}
if pre_built:
schema = cv.Schema(schema, extra=cv.ALLOW_EXTRA)
validator = fv.id_declaration_match_schema(schema)
token = fv.full_config.set(config)
try:
result = validator(ID("my_hub"))
finally:
fv.full_config.reset(token)
assert result["mode"] == "fast"
def test_id_declaration_match_schema_rejects_mismatch() -> None:
config, _, _ = _config()
validator = fv.id_declaration_match_schema({cv.Required("mode"): cv.one_of("slow")})
token = fv.full_config.set(config)
try:
with pytest.raises(cv.Invalid):
validator(ID("my_hub"))
finally:
fv.full_config.reset(token)
+69
View File
@@ -3233,3 +3233,72 @@ def test_resolve_symlink_stub_returns_none_when_read_bytes_raises(
result = git.resolve_symlink_stub(repo_dir, stub)
assert result is None
def test_git_file_git_url() -> None:
gf = git.GitFile("github.com", "esphome", "repo", "file.yaml")
assert gf.git_url == "https://github.com/esphome/repo.git"
@pytest.mark.parametrize(
("domain", "expected"),
[
(
"codeberg.org",
"https://codeberg.org/owner/repo/raw/commit/main/dir/file.yaml",
),
(
"github.com",
"https://raw.githubusercontent.com/owner/repo/main/dir/file.yaml",
),
("gitlab.com", "https://gitlab.com/owner/repo/-/raw/main/dir/file.yaml"),
],
)
def test_git_file_raw_url(domain: str, expected: str) -> None:
gf = git.GitFile(domain, "owner", "repo", "dir/file.yaml", ref="main")
assert gf.raw_url == expected
def test_git_file_raw_url_requires_ref() -> None:
gf = git.GitFile("github.com", "owner", "repo", "file.yaml")
with pytest.raises(ValueError, match="URL has no ref"):
_ = gf.raw_url
def test_git_file_raw_url_unsupported_domain() -> None:
gf = git.GitFile("example.com", "owner", "repo", "file.yaml", ref="main")
with pytest.raises(NotImplementedError, match="example.com not supported"):
_ = gf.raw_url
def test_git_file_from_shorthand_full() -> None:
gf = git.GitFile.from_shorthand("gitlab://owner/repo/sub/file.yaml@v1.0?query")
assert gf == git.GitFile(
"gitlab.com", "owner", "repo", "sub/file.yaml", ref="v1.0", query="query"
)
def test_git_file_from_shorthand_without_ref_or_query() -> None:
gf = git.GitFile.from_shorthand("codeberg://owner/repo/file.yaml")
assert gf == git.GitFile("codeberg.org", "owner", "repo", "file.yaml")
def test_git_file_from_shorthand_requires_string() -> None:
with pytest.raises(ValueError, match="must be a string"):
git.GitFile.from_shorthand(123)
def test_git_file_from_shorthand_bad_format() -> None:
with pytest.raises(ValueError, match="not in expected"):
git.GitFile.from_shorthand("not a url")
def test_git_file_from_shorthand_unknown_domain() -> None:
with pytest.raises(ValueError, match="Unknown git domain bitbucket"):
git.GitFile.from_shorthand("bitbucket://owner/repo/file.yaml")
+85 -1
View File
@@ -11,7 +11,7 @@ import time
import pytest
from esphome.core import CORE
from esphome.log import AnsiFore, AnsiStyle, color, setup_log
from esphome.log import AnsiFore, AnsiStyle, ESPHomeLogFormatter, color, setup_log
class _FakeTty(io.StringIO):
@@ -339,3 +339,87 @@ def test_setup_log_win32_always_imports_colorama(
monkeypatch.setattr(sys, "stderr", _FakeTty())
setup_log()
assert "colorama" in sys.modules
def _record(level: int, message: str) -> logging.LogRecord:
return logging.LogRecord("esphome", level, __file__, 1, message, None, None)
@pytest.mark.parametrize(
("level", "colour"),
[
(logging.DEBUG, AnsiFore.CYAN),
(logging.INFO, AnsiFore.GREEN),
(logging.WARNING, AnsiFore.YELLOW),
(logging.ERROR, AnsiFore.RED),
(logging.CRITICAL, AnsiFore.RED),
],
)
def test_formatter_colours_by_level(
monkeypatch: pytest.MonkeyPatch, level: int, colour: AnsiFore
) -> None:
monkeypatch.setattr(CORE, "dashboard", False)
formatter = ESPHomeLogFormatter(include_timestamp=False)
result = formatter.format(_record(level, "hello"))
name = logging.getLevelName(level)
assert result == f"{colour.value}{name} hello{AnsiStyle.RESET_ALL.value}"
def test_formatter_unknown_level_has_no_colour_prefix(
monkeypatch: pytest.MonkeyPatch,
) -> None:
monkeypatch.setattr(CORE, "dashboard", False)
formatter = ESPHomeLogFormatter(include_timestamp=False)
result = formatter.format(_record(25, "hello"))
assert result == f"Level 25 hello{AnsiStyle.RESET_ALL.value}"
def test_formatter_includes_timestamp_when_requested(
monkeypatch: pytest.MonkeyPatch,
) -> None:
monkeypatch.setattr(CORE, "dashboard", False)
formatter = ESPHomeLogFormatter(include_timestamp=True)
record = _record(logging.INFO, "hello")
result = formatter.format(record)
assert formatter.formatTime(record) in result
def test_formatter_dashboard_escapes_ansi(monkeypatch: pytest.MonkeyPatch) -> None:
"""The dashboard prints the escape character as literal text."""
monkeypatch.setattr(CORE, "dashboard", True)
formatter = ESPHomeLogFormatter(include_timestamp=False)
result = formatter.format(_record(logging.INFO, "hello"))
assert "\033" not in result
assert result.startswith("\\033[32mINFO hello")
def test_setup_log_debug_sets_verbose(
monkeypatch: pytest.MonkeyPatch, colorama_probe: None
) -> None:
monkeypatch.setattr(CORE, "dashboard", True)
logging.getLogger().handlers.clear()
setup_log(logging.DEBUG)
assert CORE.verbose is True
assert CORE.quiet is False
def test_setup_log_critical_sets_quiet(
monkeypatch: pytest.MonkeyPatch, colorama_probe: None
) -> None:
monkeypatch.setattr(CORE, "dashboard", True)
logging.getLogger().handlers.clear()
setup_log(logging.CRITICAL)
assert CORE.quiet is True
assert CORE.verbose is False
+193 -2
View File
@@ -1,13 +1,26 @@
"""Unit tests for esphome/pins.py."""
from collections.abc import Iterator
import logging
from unittest.mock import AsyncMock, MagicMock, patch
import pytest
from esphome import config_validation as cv, pins
import esphome.codegen as cg
from esphome.const import CONF_ADDRESS
from esphome.core import ID
from esphome.config import Config, path_context
from esphome.const import (
CONF_ADDRESS,
CONF_ALLOW_OTHER_USES,
CONF_ID,
CONF_IGNORE_STRAPPING_WARNING,
CONF_INVERTED,
CONF_MODE,
CONF_NUMBER,
KEY_CORE,
KEY_TARGET_PLATFORM,
)
from esphome.core import CORE, ID
from esphome.schema_extractors import SCHEMA_EXTRACT
SomeHub = cg.esphome_ns.class_("SomeHub")
@@ -166,3 +179,181 @@ async def test_new_parented_pin(args: tuple[str, ...]) -> None:
var.set_inverted.assert_called_once_with(True)
var.set_flags.assert_called_once()
assert add.call_count == 3
@pytest.fixture
def registry() -> pins.PinRegistry:
reg = pins.PinRegistry()
reg.register("hub", lambda conf: conf)(lambda conf: conf)
return reg
@pytest.fixture
def pin_path() -> Iterator[None]:
CORE.data[KEY_CORE] = {KEY_TARGET_PLATFORM: "esp32"}
token = path_context.set(["pin"])
try:
yield
finally:
path_context.reset(token)
@pytest.mark.usefixtures("pin_path")
def test_registry_validate_tracks_usage_and_get_count(
registry: pins.PinRegistry,
) -> None:
assert registry.get_count("hub", "h1", 4) == 0
registry.validate({"hub": "h1", CONF_NUMBER: 4}, "hub")
assert registry.get_count("hub", "h1", 4) == 1
assert registry.get_count("hub", "h1", 5) == 0
@pytest.mark.usefixtures("pin_path")
def test_registry_validate_without_number_is_not_tracked(
registry: pins.PinRegistry,
) -> None:
assert registry.validate({"hub": "h1"}, "hub") == {"hub": "h1"}
assert not registry.pins_used
def test_registry_get_to_code(registry: pins.PinRegistry) -> None:
to_code = registry["hub"][0]
assert registry.get_to_code("hub") is to_code
assert registry.get_to_code({"hub": "h1"}) is to_code
def _final_config(client_id: ID) -> Config:
config = Config()
config["hub"] = [{CONF_ID: client_id}]
config.declare_ids.append((client_id, ["hub", 0, CONF_ID]))
return config
@pytest.mark.parametrize(
("uses", "allow_others", "testing_mode", "error"),
[
(2, False, False, "used in multiple places"),
(2, True, False, None),
(2, False, True, None),
(1, False, False, None),
(1, True, False, "incorrectly sets allow_other_uses"),
(1, True, True, None),
],
)
@pytest.mark.usefixtures("pin_path")
def test_registry_final_validate_reuse_rules(
registry: pins.PinRegistry,
monkeypatch: pytest.MonkeyPatch,
uses: int,
allow_others: bool,
testing_mode: bool,
error: str | None,
) -> None:
client_id = ID("h1", is_declaration=True)
extra = {CONF_ALLOW_OTHER_USES: True} if allow_others else {}
for _ in range(uses):
registry.validate({"hub": client_id, CONF_NUMBER: 4, **extra}, "hub")
monkeypatch.setattr(CORE, "testing_mode", testing_mode)
config = _final_config(client_id)
registry.final_validate(config)
if error is None:
assert not config.errors
else:
assert all(error in str(err) for err in config.errors)
assert config.errors
@pytest.mark.usefixtures("pin_path")
def test_registry_final_validate_runs_hook_with_parent_config(
monkeypatch: pytest.MonkeyPatch,
) -> None:
hook = MagicMock()
registry = pins.PinRegistry()
registry.register("hub", lambda conf: conf, hook)(lambda conf: conf)
client_id = ID("h1", is_declaration=True)
pin = registry.validate({"hub": client_id, CONF_NUMBER: 4}, "hub")
config = _final_config(client_id)
registry.final_validate(config)
hook.assert_called_once_with(pin, config["hub"][0])
assert not config.errors
@pytest.mark.parametrize(
("mode", "expected"),
[
("input", {"input": True}),
("INPUT_PULLUP", {"input": True, "pullup": True}),
("output_open_drain", {"output": True, "open_drain": True}),
],
)
def test_set_mode_parses_string_modes(mode: str, expected: dict[str, bool]) -> None:
assert pins._set_mode({CONF_NUMBER: 1, CONF_MODE: mode}, {}) == {
CONF_NUMBER: 1,
CONF_MODE: expected,
}
def test_set_mode_rejects_unknown_string_mode() -> None:
with pytest.raises(cv.Invalid, match="Unknown pin mode bogus"):
pins._set_mode({CONF_MODE: "bogus"}, {})
def test_set_mode_keeps_dict_mode_and_applies_default() -> None:
explicit = {CONF_MODE: {"input": True}}
assert pins._set_mode(explicit, {"output": True}) is explicit
assert pins._set_mode({}, {"output": True}) == {CONF_MODE: {"output": True}}
def test_gpio_flags_expr() -> None:
assert str(pins.gpio_flags_expr({"input": False})) == str(cg.gpio_Flags.FLAG_NONE)
flags = str(pins.gpio_flags_expr({"input": True, "pullup": True}))
assert "FLAG_INPUT" in flags
assert "FLAG_PULLUP" in flags
def test_gpio_validate_modes() -> None:
mode = {"input": True, "output": False}
assert pins.gpio_validate_modes(mode) is mode
with pytest.raises(cv.Invalid, match="Mode must be input or output"):
pins.gpio_validate_modes({"input": False, "output": False})
INPUT_MODE = {"input": True}
def test_gpio_base_schema_rejects_placeholder_number() -> None:
schema = pins.gpio_base_schema(SomeHub, cv.int_)
with pytest.raises(cv.Invalid, match="placeholder 'GPIOX'"):
schema({CONF_NUMBER: "GPIOX", CONF_MODE: INPUT_MODE})
assert schema({CONF_NUMBER: 3, CONF_MODE: INPUT_MODE})[CONF_NUMBER] == 3
@pytest.mark.parametrize("invertible", [True, False])
def test_gpio_base_schema_invertible(invertible: bool) -> None:
schema = pins.gpio_base_schema(SomeHub, cv.int_, invertible=invertible)
assert (
CONF_INVERTED in schema({CONF_NUMBER: 3, CONF_MODE: INPUT_MODE})
) is invertible
def test_check_strapping_pin(caplog: pytest.LogCaptureFixture) -> None:
logger = logging.getLogger("test_pins")
with caplog.at_level(logging.WARNING, logger="test_pins"):
pins.check_strapping_pin({CONF_NUMBER: 2}, {2}, logger)
pins.check_strapping_pin(
{CONF_NUMBER: 2, CONF_IGNORE_STRAPPING_WARNING: True}, {2}, logger
)
assert caplog.text.count("GPIO2 is a strapping pin") == 1
with pytest.raises(cv.Invalid, match="not a strapping pin"):
pins.check_strapping_pin(
{CONF_NUMBER: 3, CONF_IGNORE_STRAPPING_WARNING: True}, {2}, logger
)
+73 -1
View File
@@ -5,10 +5,12 @@ from __future__ import annotations
from collections.abc import Callable
import io
from pathlib import Path
import runpy
import sys
from types import ModuleType
from types import ModuleType, SimpleNamespace
from unittest.mock import Mock
import platformio
from platformio.registry.client import RegistryClient
import pytest
@@ -146,3 +148,73 @@ def test_patch_registry_private_packages_skips_account_probe(
assert RegistryClient.allowed_private_packages() is False
assert RegistryClient().allowed_private_packages() is False
def test_patch_file_downloader_retries_when_session_cleanup_fails(
monkeypatch: pytest.MonkeyPatch,
) -> None:
"""A failing close of the old session must not stop the retry."""
package_exception = type("PackageException", (Exception,), {})
attempts: list[int] = []
def flaky_init(self: object, *args: object, **kwargs: object) -> None:
attempts.append(1)
if len(attempts) < 2:
raise package_exception("502")
downloader = type("FileDownloader", (), {"__init__": flaky_init})
monkeypatch.setitem(
sys.modules,
"platformio.package.download",
SimpleNamespace(FileDownloader=downloader),
)
monkeypatch.setitem(
sys.modules,
"platformio.package.exception",
SimpleNamespace(PackageException=package_exception),
)
monkeypatch.setattr(runner.time, "sleep", lambda _delay: None)
runner.patch_file_downloader()
instance = object.__new__(downloader)
instance._http_response = Mock(close=Mock(side_effect=OSError("boom")))
downloader.__init__(instance)
assert len(attempts) == 2
instance._http_response.close.assert_called_once()
def test_runner_runs_main_when_executed_as_a_script(
monkeypatch: pytest.MonkeyPatch,
) -> None:
"""Running the file as ``__main__`` exits with PlatformIO's return code."""
monkeypatch.setattr(sys, "argv", ["runner", "run"])
monkeypatch.setattr(sys, "stdout", io.StringIO())
monkeypatch.setattr(sys, "stderr", io.StringIO())
# The script re-imports and applies the PlatformIO patches on its own
# module namespace, so give it stand-ins rather than the real internals.
monkeypatch.setitem(sys.modules, "platformio.run", Mock())
monkeypatch.setitem(
sys.modules,
"platformio.package.download",
SimpleNamespace(FileDownloader=type("FileDownloader", (), {})),
)
monkeypatch.setitem(
sys.modules,
"platformio.package.exception",
SimpleNamespace(PackageException=Exception),
)
monkeypatch.setitem(
sys.modules,
"platformio.registry.client",
SimpleNamespace(RegistryClient=type("RegistryClient", (), {})),
)
pio_main = SimpleNamespace(main=lambda: 3)
monkeypatch.setitem(sys.modules, "platformio.__main__", pio_main)
monkeypatch.setattr(platformio, "__main__", pio_main, raising=False)
with pytest.raises(SystemExit) as exc_info:
runpy.run_path(str(Path(runner.__file__)), run_name="__main__")
assert exc_info.value.code == 3
@@ -112,6 +112,22 @@ def test_idedata_cc_path(setup_core: Path) -> None:
)
@pytest.mark.parametrize(
("raw_data", "expected"),
[
pytest.param(
{"defines": ["USE_ESP32", "FOO=1"]}, ["USE_ESP32", "FOO=1"], id="set"
),
pytest.param({}, [], id="missing"),
],
)
def test_idedata_defines(raw_data: dict[str, list[str]], expected: list[str]) -> None:
"""IDEData.defines returns the defines, or an empty list when absent."""
idedata = toolchain.IDEData({"prog_path": "/path/to/firmware.elf", **raw_data})
assert idedata.defines == expected
def test_flash_image_dataclass() -> None:
"""Test FlashImage dataclass stores path and offset correctly."""
image = FlashImage(path=Path("/path/to/image.bin"), offset="0x10000")
@@ -758,6 +774,17 @@ def test_copy_ccache_script(setup_core: Path) -> None:
assert dest.read_text() == source.read_text()
def test_copy_pch_script(setup_core: Path) -> None:
"""The shared PCH pre-script is copied into the build dir."""
CORE.build_path = setup_core / "build" / "test"
toolchain.copy_pch_script()
dest = setup_core / "build" / "test" / "pch.py"
source = Path(toolchain.__file__).parent / "pch.py.script"
assert dest.read_text() == source.read_text()
class _FakeSConsEnv(dict):
"""Just enough of a SCons construction environment for ccache.py."""
@@ -1015,6 +1042,20 @@ def test_run_platformio_cli_run_builds_command(
)
def test_run_platformio_cli_run_without_verbose(
setup_core: Path, mock_run_platformio_cli: Mock
) -> None:
"""Without verbose, no -v flag is passed to PlatformIO."""
CORE.build_path = str(setup_core / "build" / "test")
mock_run_platformio_cli.return_value = 0
toolchain.run_platformio_cli_run({"name": "test"}, False, "extra")
mock_run_platformio_cli.assert_called_once_with(
"run", "-d", CORE.build_path, "extra"
)
def test_run_compile(setup_core: Path, mock_run_platformio_cli_run: Mock) -> None:
"""Test run_compile with process limit."""
from esphome.const import CONF_COMPILE_PROCESS_LIMIT, CONF_ESPHOME
+18
View File
@@ -2,6 +2,8 @@
from __future__ import annotations
import importlib
import logging
import re
import socket
from unittest.mock import ANY, patch
@@ -10,6 +12,7 @@ from aioesphomeapi.core import ResolveAPIError, ResolveTimeoutAPIError
from aioesphomeapi.host_resolver import AddrInfo, IPv4Sockaddr, IPv6Sockaddr
import pytest
from esphome import resolver
from esphome.async_thread import AsyncDispatchTimeout
from esphome.core import EsphomeError
from esphome.resolver import RESOLVE_TIMEOUT, AsyncResolver
@@ -162,3 +165,18 @@ def test_async_resolver_mixed_addresses(
mock_resolve.assert_called_once_with(
["test.local", "192.168.1.100", "::1"], 6053, timeout=RESOLVE_TIMEOUT
)
def test_invalid_resolve_timeout_env_falls_back_to_default(
monkeypatch: pytest.MonkeyPatch, caplog: pytest.LogCaptureFixture
) -> None:
"""A non-numeric ESPHOME_RESOLVE_TIMEOUT warns and uses the default."""
monkeypatch.setenv("ESPHOME_RESOLVE_TIMEOUT", "soon")
try:
with caplog.at_level(logging.WARNING, logger="esphome.resolver"):
importlib.reload(resolver)
assert resolver.RESOLVE_TIMEOUT == 20.0
assert "ESPHOME_RESOLVE_TIMEOUT='soon' is not a valid number" in caplog.text
finally:
monkeypatch.delenv("ESPHOME_RESOLVE_TIMEOUT")
importlib.reload(resolver)
@@ -0,0 +1,90 @@
"""Tests for esphome.schema_extractors."""
from collections.abc import Callable, Iterator
from typing import Any
import pytest
from esphome import schema_extractors as se
@pytest.fixture
def extraction_enabled() -> Iterator[None]:
"""Enable schema extraction and restore the module state afterwards."""
stores = (
se.extended_schemas,
se.list_schemas,
se.registry_schemas,
se.hidden_schemas,
se.typed_schemas,
)
saved = [dict(store) for store in stores]
previous = se.EnableSchemaExtraction
se.EnableSchemaExtraction = True
try:
yield
finally:
se.EnableSchemaExtraction = previous
for store, old in zip(stores, saved, strict=True):
store.clear()
store.update(old)
def _validator(value: Any) -> Any:
return value
def test_decorators_are_passthrough_when_disabled(
monkeypatch: pytest.MonkeyPatch,
) -> None:
monkeypatch.setattr(se, "EnableSchemaExtraction", False)
assert se.schema_extractor("name")(_validator) is _validator
assert se.schema_extractor_registry(object())(_validator) is _validator
assert se.schema_extractor_extended(_validator) is _validator
assert se.schema_extractor_list(_validator) is _validator
assert se.schema_extractor_typed(_validator) is _validator
@pytest.mark.usefixtures("extraction_enabled")
def test_schema_extractor_records_hidden_schema() -> None:
assert se.schema_extractor("my_validator")(_validator) is _validator
assert se.hidden_schemas[repr(_validator)] == "my_validator"
@pytest.mark.usefixtures("extraction_enabled")
def test_schema_extractor_registry_records_registry() -> None:
registry = object()
assert se.schema_extractor_registry(registry)(_validator) is _validator
assert se.registry_schemas[repr(_validator)] is registry
@pytest.mark.parametrize(
("decorator", "store_name"),
[
(se.schema_extractor_extended, "extended_schemas"),
(se.schema_extractor_list, "list_schemas"),
],
)
@pytest.mark.usefixtures("extraction_enabled")
def test_args_recording_decorators(
decorator: Callable[[Callable[..., Any]], Callable[..., Any]], store_name: str
) -> None:
def build(*args: Any) -> list[Any]:
return list(args)
wrapped = decorator(build)
result = wrapped(1, 2)
assert result == [1, 2]
assert getattr(se, store_name)[repr(result)] == (1, 2)
@pytest.mark.usefixtures("extraction_enabled")
def test_schema_extractor_typed_records_args_and_kwargs() -> None:
def build(*args: Any, **kwargs: Any) -> list[Any]:
return [args, kwargs]
result = se.schema_extractor_typed(build)(1, key="x")
assert result == [(1,), {"key": "x"}]
assert se.typed_schemas[repr(result)] == ((1,), {"key": "x"})
+53 -1
View File
@@ -9,7 +9,11 @@ from unittest.mock import patch
import pytest
from esphome.espidf.size_summary import print_summary
from esphome.espidf.size_summary import (
_find_app_partition_size,
_parse_size,
print_summary,
)
def _write_size_json(tmp_path: Path, data: dict) -> Path:
@@ -317,3 +321,51 @@ def test_print_summary_skips_flash_on_bad_input(
for r in caplog.records
)
assert warned == with_partitions
@pytest.mark.parametrize(
("token", "expected"),
[
pytest.param("", 0, id="empty"),
pytest.param(" ", 0, id="blank"),
pytest.param("0x10000", 0x10000, id="hex"),
pytest.param("0X10", 0x10, id="hex_upper"),
pytest.param("1M", 1024 * 1024, id="mebibyte"),
pytest.param("512k", 512 * 1024, id="kibibyte_lower"),
pytest.param("4096", 4096, id="decimal"),
],
)
def test_parse_size(token: str, expected: int) -> None:
"""Partition sizes accept hex, K/M suffixes, decimals and blanks."""
assert _parse_size(token) == expected
def test_find_app_partition_size_skips_unrelated_rows(tmp_path: Path) -> None:
"""Comments, blank lines, short rows and non-app rows are passed over."""
partitions = tmp_path / "partitions.csv"
partitions.write_text(
"# name, type, subtype, offset, size, flags\n"
"\n"
"short, app, factory\n"
"nvs, data, nvs, 0x9000, 0x6000,\n"
"app0, app, ota_0, 0x10000, 1M,\n"
)
assert _find_app_partition_size(partitions) == 1024 * 1024
def test_find_app_partition_size_without_app_partition_raises(tmp_path: Path) -> None:
"""A table with no app+factory or app+ota_0 row is rejected."""
partitions = tmp_path / "partitions.csv"
partitions.write_text("nvs, data, nvs, 0x9000, 0x6000,\n")
with pytest.raises(ValueError, match="No app\\+factory or app\\+ota_0"):
_find_app_partition_size(partitions)
def test_print_summary_skips_unparseable_json(
tmp_path: Path, capsys: pytest.CaptureFixture[str]
) -> None:
"""A truncated size json prints nothing instead of raising."""
size_json = tmp_path / "esp_idf_size.json"
size_json.write_text("{not json")
print_summary(size_json, _write_partitions(tmp_path), tmp_path / "firmware.elf")
assert capsys.readouterr().out == ""
+3 -1
View File
@@ -1299,7 +1299,6 @@ def test_ordered_dict_repr_is_plain_dict() -> None:
@pytest.mark.parametrize("platform", ["linux", "darwin"])
def test_get_serial_ports(platform: str, monkeypatch: pytest.MonkeyPatch) -> None:
"""Ports are filtered by VID:PID; by-id platform devices are added on Linux."""
monkeypatch.setattr(sys, "platform", platform)
comports = [
("", "empty", "VID:PID=1:2"),
("/dev/ttyUSB1", "usb", "VID:PID=1:2"),
@@ -1320,6 +1319,9 @@ def test_get_serial_ports(platform: str, monkeypatch: pytest.MonkeyPatch) -> Non
patch("serial.tools.list_ports_linux.SysFS", side_effect=make_device),
patch.object(util, "Path", return_value=fake_path),
):
# Only after patch() has imported pyserial: it picks its backend from
# sys.platform at import time, and the macOS one fails on other hosts
monkeypatch.setattr(sys, "platform", platform)
ports = util.get_serial_ports()
paths = [p.path for p in ports]
+23 -2
View File
@@ -7,7 +7,7 @@ import esphome.config_validation as cv
from esphome.core import EsphomeError
def _run_repl_test(input_data):
def _run_repl_test(input_data, *, ace=False):
"""Reusable test function for different input scenarios."""
input_data.append(_exit())
with (
@@ -15,7 +15,8 @@ def _run_repl_test(input_data):
patch("sys.stdout") as mock_stdout,
):
args = Mock([])
args.ace = False
args.ace = ace
args.configuration = "config_dir"
args.substitution = None
vscode.read_config(args)
@@ -264,3 +265,23 @@ logger:
(validation_error,) = error["validation_errors"]
assert validation_error["range"] is None
assert "extra keys not allowed" in validation_error["message"]
def test_ace_mode_loads_config_relative_to_configuration_dir():
output_lines = _run_repl_test(
[
_validate("x.yaml"),
# read_file x.yaml
_file_response("""esphome:
name: test1
esp8266:
board: d1_mini
"""),
],
ace=True,
)
assert output_lines == [
_read_file(str(Path("config_dir", "x.yaml"))),
RESULT_NO_ERROR,
]
+72
View File
@@ -1,5 +1,6 @@
"""Tests for the wizard.py file."""
import importlib.util
from pathlib import Path
from typing import Any
from unittest.mock import MagicMock, patch
@@ -716,3 +717,74 @@ def test_fallback_psk_uses_secrets_choice(
assert 'password: "XXXXXXXXXXXX"' in config
assert mock_choice.call_count == 12
def test_wizard_file_rpipico_has_no_wifi_or_api() -> None:
"""The rpipico has no WiFi, so the config stops after the logger."""
config = wz.wizard_file(name="pico", platform="RP2", board="rpipico")
assert "logger:" in config
assert "api:" not in config
assert "wifi:" not in config
def test_wizard_file_secret_ssid_is_not_quoted(
default_config: dict[str, Any],
) -> None:
default_config["ssid"] = "!secret wifi_ssid"
default_config["psk"] = "!secret wifi_password"
config = wz.wizard_file(**default_config)
assert " ssid: !secret wifi_ssid\n" in config
assert " password: !secret wifi_password\n" in config
def test_wizard_file_without_ssid_comments_out_credentials(
default_config: dict[str, Any],
) -> None:
del default_config["ssid"]
del default_config["psk"]
config = wz.wizard_file(**default_config)
assert ' # ssid: "My SSID"' in config
assert ' # password: "mypassword"' in config
def test_wizard_write_rejects_unknown_board(
tmp_path: Path, default_config: dict[str, Any], monkeypatch: MonkeyPatch
) -> None:
del default_config["platform"]
default_config["board"] = "not_a_real_board"
monkeypatch.setattr(CORE, "config_path", tmp_path.parent)
result = wz.wizard_write(tmp_path / "test.yaml", **default_config)
assert result is False
assert not (tmp_path / "test.yaml").exists()
def test_wizard_returns_error_when_write_fails(
tmp_path: Path, monkeypatch: MonkeyPatch, wizard_answers: list[str]
) -> None:
monkeypatch.setattr("builtins.input", MagicMock(side_effect=wizard_answers))
monkeypatch.setattr(wz, "safe_print", lambda t=None, end=None: 0)
monkeypatch.setattr(wz, "sleep", lambda _: 0)
monkeypatch.setattr(wz, "wizard_write", MagicMock(return_value=False))
assert wz.wizard(tmp_path / "test.yaml") == 1
def test_quickwizard_env_replaces_sleep_with_noop(monkeypatch: MonkeyPatch) -> None:
"""With ESPHOME_QUICKWIZARD set the module defines a no-op sleep."""
monkeypatch.setenv("ESPHOME_QUICKWIZARD", "1")
# Load a private copy so the real module is left untouched
spec = importlib.util.spec_from_file_location("_quick_wizard", wz.__file__)
assert spec is not None
assert spec.loader is not None
module = importlib.util.module_from_spec(spec)
spec.loader.exec_module(module)
assert module.sleep(10) is None
assert module.sleep.__module__ == "_quick_wizard"