mirror of
https://github.com/esphome/esphome.git
synced 2026-10-07 03:16:37 +00:00
Merge remote-tracking branch 'origin/dev' into web-server-offline-hint
# Conflicts: # esphome/components/web_server/__init__.py
This commit is contained in:
@@ -1,4 +1,5 @@
|
||||
import importlib
|
||||
import io
|
||||
import json
|
||||
import logging
|
||||
from pathlib import Path
|
||||
@@ -20,6 +21,7 @@ from esphome.components.esp32 import (
|
||||
VARIANT_ESP32S2,
|
||||
VARIANT_ESP32S3,
|
||||
)
|
||||
from esphome.components.substitutions import do_substitution_pass
|
||||
from esphome.config_validation import Invalid
|
||||
from esphome.const import (
|
||||
CONF_DAY,
|
||||
@@ -65,7 +67,13 @@ from esphome.core import (
|
||||
)
|
||||
from esphome.schema_extractors import SCHEMA_EXTRACT
|
||||
from esphome.util import Registry
|
||||
from esphome.yaml_util import ESPHomeDataBase, SensitiveStr, make_data_base
|
||||
from esphome.yaml_util import (
|
||||
ESPHomeDataBase,
|
||||
SensitiveStr,
|
||||
load_yaml,
|
||||
make_data_base,
|
||||
parse_yaml,
|
||||
)
|
||||
|
||||
|
||||
def test_check_not_templatable__invalid():
|
||||
@@ -1394,6 +1402,35 @@ def test_entity_metadata_visibility_hints() -> None:
|
||||
assert web["web_server"].visibility is advanced
|
||||
|
||||
|
||||
def test_with_visibility_remarks_keys() -> None:
|
||||
"""``with_visibility`` re-marks the named keys, preserving each field's
|
||||
default and validator, without touching the other keys or the input schema.
|
||||
"""
|
||||
base = cv.Schema(
|
||||
{
|
||||
cv.Optional("a", default=7): cv.int_,
|
||||
cv.Optional("b", visibility=cv.Visibility.ADVANCED): cv.string,
|
||||
}
|
||||
)
|
||||
promoted = cv.with_visibility(base, cv.Visibility.UI, "a")
|
||||
|
||||
pm = {str(k): k for k in promoted.schema}
|
||||
assert pm["a"].visibility is cv.Visibility.UI # re-marked
|
||||
assert pm["a"].default() == 7 # default preserved
|
||||
assert pm["b"].visibility is cv.Visibility.ADVANCED # sibling untouched
|
||||
assert promoted({}) == {"a": 7} # validator/default still applied
|
||||
|
||||
# The input schema is left untouched (no shared-marker mutation).
|
||||
assert {str(k): k for k in base.schema}["a"].visibility is None
|
||||
|
||||
|
||||
def test_with_visibility_unknown_key_raises() -> None:
|
||||
"""A key not present in the schema is a typo — fail at build time."""
|
||||
base = cv.Schema({cv.Optional("a"): cv.int_})
|
||||
with pytest.raises(ValueError, match="not in schema"):
|
||||
cv.with_visibility(base, cv.Visibility.UI, "nope")
|
||||
|
||||
|
||||
def _wrap_str(value: str) -> ESPHomeDataBase:
|
||||
"""Wrap a raw string as an ESPHomeDataBase, mimicking a YAML-loaded value."""
|
||||
return make_data_base(value)
|
||||
@@ -3145,6 +3182,116 @@ def test_file__existing_relative_path(setup_core: Path) -> None:
|
||||
assert cv.file_("partitions.csv") == setup_core / "partitions.csv"
|
||||
|
||||
|
||||
def _package_value(setup_core: Path, path: str = "assets/ui.js") -> tuple[Path, str]:
|
||||
"""Write a package file next to an ``assets/`` dir; return the dir and its loaded *path* value."""
|
||||
package_dir = setup_core / ".esphome" / "packages" / "abc123" / "vendor"
|
||||
(package_dir / "assets").mkdir(parents=True)
|
||||
(package_dir / "assets" / "ui.js").write_text("js\n")
|
||||
(package_dir / "device.yaml").write_text(f"path: {path}\n")
|
||||
return package_dir, load_yaml(package_dir / "device.yaml")["path"]
|
||||
|
||||
|
||||
def test_file__resolves_relative_to_the_declaring_document(setup_core: Path) -> None:
|
||||
"""A package's own asset path resolves against the package file when the config dir lacks it."""
|
||||
package_dir, value = _package_value(setup_core)
|
||||
|
||||
assert cv.file_(value) == package_dir / "assets" / "ui.js"
|
||||
|
||||
|
||||
def test_file__resolves_a_substituted_path_against_the_use_site(
|
||||
setup_core: Path,
|
||||
) -> None:
|
||||
package_dir, _ = _package_value(setup_core)
|
||||
(package_dir / "device.yaml").write_text(
|
||||
"substitutions:\n ui: assets/ui.js\npath: ${ui}\n"
|
||||
)
|
||||
config = do_substitution_pass(load_yaml(package_dir / "device.yaml"))
|
||||
|
||||
assert cv.file_(config["path"]) == package_dir / "assets" / "ui.js"
|
||||
|
||||
|
||||
def test_file__result_is_absolute_for_a_relative_document(
|
||||
setup_core: Path, monkeypatch: pytest.MonkeyPatch
|
||||
) -> None:
|
||||
"""A document loaded by a cwd-relative path still yields an absolute result."""
|
||||
package_dir, _ = _package_value(setup_core)
|
||||
monkeypatch.chdir(setup_core)
|
||||
value = load_yaml(Path(".esphome/packages/abc123/vendor/device.yaml"))["path"]
|
||||
|
||||
result = cv.file_(value)
|
||||
|
||||
assert result.is_absolute()
|
||||
assert result == package_dir / "assets" / "ui.js"
|
||||
|
||||
|
||||
def test_file__config_dir_entry_of_the_wrong_kind_does_not_shadow_the_package(
|
||||
setup_core: Path,
|
||||
) -> None:
|
||||
package_dir, value = _package_value(setup_core)
|
||||
(setup_core / "assets" / "ui.js").mkdir(parents=True)
|
||||
|
||||
assert cv.file_(value) == package_dir / "assets" / "ui.js"
|
||||
|
||||
|
||||
def test_file__miss_names_the_declaring_document(setup_core: Path) -> None:
|
||||
package_dir, value = _package_value(setup_core, "assets/other.js")
|
||||
|
||||
with pytest.raises(Invalid, match="Could not find file") as excinfo:
|
||||
cv.file_(value)
|
||||
|
||||
assert f"Also looked next to {package_dir / 'device.yaml'}" in str(excinfo.value)
|
||||
|
||||
|
||||
def test_file__document_spelled_through_dotdot_in_the_config_dir_adds_no_hint(
|
||||
setup_core: Path,
|
||||
) -> None:
|
||||
(setup_core / "sub").mkdir()
|
||||
(setup_core / "device.yaml").write_text("path: assets/other.js\n")
|
||||
value = load_yaml(setup_core / "sub" / ".." / "device.yaml")["path"]
|
||||
|
||||
with pytest.raises(Invalid) as excinfo:
|
||||
cv.file_(value)
|
||||
|
||||
assert "Also looked" not in str(excinfo.value)
|
||||
|
||||
|
||||
def test_file__wrong_kind_beside_the_document_is_reported(setup_core: Path) -> None:
|
||||
package_dir, value = _package_value(setup_core, "assets")
|
||||
|
||||
with pytest.raises(Invalid, match="is not a file") as excinfo:
|
||||
cv.file_(value)
|
||||
|
||||
assert str(package_dir / "assets") in str(excinfo.value)
|
||||
|
||||
|
||||
def test_file__config_dir_wins_over_the_declaring_document(setup_core: Path) -> None:
|
||||
_, value = _package_value(setup_core)
|
||||
(setup_core / "assets").mkdir()
|
||||
(setup_core / "assets" / "ui.js").write_text("local\n")
|
||||
|
||||
assert cv.file_(value) == setup_core / "assets" / "ui.js"
|
||||
|
||||
|
||||
def test_file__declared_in_an_in_memory_document_is_not_resolved(
|
||||
setup_core: Path,
|
||||
) -> None:
|
||||
"""A value whose source document isn't on disk falls through to the config-dir error."""
|
||||
value = parse_yaml(Path("<unicode string>"), io.StringIO("path: assets/ui.js\n"))[
|
||||
"path"
|
||||
]
|
||||
|
||||
with pytest.raises(Invalid, match="Could not find file"):
|
||||
cv.file_(value)
|
||||
|
||||
|
||||
def test_directory_resolves_relative_to_the_declaring_document(
|
||||
setup_core: Path,
|
||||
) -> None:
|
||||
package_dir, value = _package_value(setup_core, "assets")
|
||||
|
||||
assert cv.directory(value) == package_dir / "assets"
|
||||
|
||||
|
||||
def test_file__missing_raises(setup_core: Path) -> None:
|
||||
with pytest.raises(Invalid, match="Could not find file"):
|
||||
cv.file_("partitions.csv")
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
"""Tests for the coroutine module."""
|
||||
|
||||
import contextvars
|
||||
|
||||
import pytest
|
||||
|
||||
from esphome.coroutine import CoroPriority, FakeEventLoop, coroutine_with_priority
|
||||
@@ -217,3 +219,46 @@ def test_custom_priority_between_enum_values() -> None:
|
||||
|
||||
# Check execution order
|
||||
assert execution_order == ["core", "custom", "diagnostics"]
|
||||
|
||||
|
||||
def test_context_isolated_between_interleaved_tasks() -> None:
|
||||
"""Test that a contextvar set in one task does not leak into another task that the scheduler interleaves with it."""
|
||||
my_var: contextvars.ContextVar[str] = contextvars.ContextVar("my_var")
|
||||
seen: dict[str, str] = {}
|
||||
|
||||
def task_a():
|
||||
my_var.set("a")
|
||||
yield # suspend so task_b can run before task_a resumes
|
||||
seen["a"] = my_var.get()
|
||||
|
||||
def task_b():
|
||||
my_var.set("b")
|
||||
yield
|
||||
seen["b"] = my_var.get()
|
||||
|
||||
loop = FakeEventLoop()
|
||||
loop.add_job(task_a)
|
||||
loop.add_job(task_b)
|
||||
loop.flush_tasks()
|
||||
|
||||
assert seen == {"a": "a", "b": "b"}
|
||||
|
||||
|
||||
def test_context_inherits_ambient_value_at_schedule_time() -> None:
|
||||
"""Test that a job sees whatever contextvar value was set before it was scheduled."""
|
||||
my_var: contextvars.ContextVar[str] = contextvars.ContextVar("my_var")
|
||||
token = my_var.set("ambient")
|
||||
seen: dict[str, str] = {}
|
||||
|
||||
def task():
|
||||
seen["value"] = my_var.get()
|
||||
yield
|
||||
|
||||
try:
|
||||
loop = FakeEventLoop()
|
||||
loop.add_job(task)
|
||||
loop.flush_tasks()
|
||||
finally:
|
||||
my_var.reset(token)
|
||||
|
||||
assert seen == {"value": "ambient"}
|
||||
|
||||
@@ -85,6 +85,15 @@ class TestCallExpression:
|
||||
assert actual == 'my_function<int32_t, float>(1, "2", false)'
|
||||
|
||||
|
||||
class TestStaticCastExpression:
|
||||
def test_str(self):
|
||||
target = cg.StaticCastExpression(ct.bool_, 42)
|
||||
|
||||
actual = str(target)
|
||||
|
||||
assert actual == "static_cast<bool>(42)"
|
||||
|
||||
|
||||
class TestStructInitializer:
|
||||
def test_str(self):
|
||||
target = cg.StructInitializer(
|
||||
@@ -229,6 +238,76 @@ class TestLambdaExpression:
|
||||
)
|
||||
|
||||
|
||||
class TestCallLambda:
|
||||
"""Tests for the call_lambda() function."""
|
||||
|
||||
def test_call_lambda__return_expression_casts_to_return_type(self):
|
||||
"""A lambda body that is just a return statement reduces to the
|
||||
expression, cast to the lambda's return type."""
|
||||
lamb = cg.LambdaExpression(("return foo + 1;",), (), "", ct.bool_)
|
||||
|
||||
result = cg.call_lambda(lamb)
|
||||
|
||||
assert isinstance(result, cg.StaticCastExpression)
|
||||
assert str(result) == "static_cast<bool>(foo + 1)"
|
||||
|
||||
def test_call_lambda__return_expression_with_class_return_type_no_cast(self):
|
||||
"""A class return type is not cast, since static_cast doesn't apply
|
||||
to arbitrary class types."""
|
||||
mock_class = cg.MockObjClass("foo::Bar", parents=())
|
||||
lamb = cg.LambdaExpression(("return get_bar();",), (), "", mock_class)
|
||||
|
||||
result = cg.call_lambda(lamb)
|
||||
|
||||
assert isinstance(result, cg.RawExpression)
|
||||
assert str(result) == "get_bar()"
|
||||
|
||||
def test_call_lambda__no_return_with_parameters_calls_with_names(self):
|
||||
"""A multi-statement lambda with parameters is called with the
|
||||
parameter names as arguments."""
|
||||
lamb = cg.LambdaExpression(
|
||||
("do_something(x, y);",), ((int, "x"), (float, "y")), "=", ct.bool_
|
||||
)
|
||||
|
||||
result = cg.call_lambda(lamb)
|
||||
|
||||
assert isinstance(result, cg.CallExpression)
|
||||
assert str(result) == (
|
||||
"[=](int32_t x, float y) -> bool {\n do_something(x, y);\n}(x, y)"
|
||||
)
|
||||
|
||||
def test_call_lambda__no_return_type_raises(self):
|
||||
"""Calling a lambda with no declared return type is a developer
|
||||
error: call_lambda is only for value-returning lambdas."""
|
||||
lamb = cg.LambdaExpression(("do_something();",), (), "=")
|
||||
|
||||
with pytest.raises(AssertionError):
|
||||
cg.call_lambda(lamb)
|
||||
|
||||
def test_call_lambda__identifier_starting_with_return_is_not_a_return_statement(
|
||||
self,
|
||||
):
|
||||
"""A body that merely starts with the substring "return" (e.g. a call
|
||||
to a function named returnValue()) must not be mistaken for a return
|
||||
statement -- the match requires a word boundary after "return"."""
|
||||
lamb = cg.LambdaExpression(("returnValue();",), (), "=", ct.bool_)
|
||||
|
||||
result = cg.call_lambda(lamb)
|
||||
|
||||
assert isinstance(result, cg.CallExpression)
|
||||
assert str(result) == "[=]() -> bool {\n returnValue();\n}()"
|
||||
|
||||
def test_call_lambda__no_return_no_parameters_calls_with_no_args(self):
|
||||
"""A multi-statement lambda without parameters is called with no
|
||||
arguments."""
|
||||
lamb = cg.LambdaExpression(("do_something();",), (), "", ct.bool_)
|
||||
|
||||
result = cg.call_lambda(lamb)
|
||||
|
||||
assert isinstance(result, cg.CallExpression)
|
||||
assert str(result) == "[]() -> bool {\n do_something();\n}()"
|
||||
|
||||
|
||||
class TestLiterals:
|
||||
@pytest.mark.parametrize(
|
||||
"target, expected",
|
||||
|
||||
@@ -187,6 +187,31 @@ def test_slot_counter_emits_requested_count() -> None:
|
||||
assert _define_value("TEST_SLOT_COUNT") == "2"
|
||||
|
||||
|
||||
def test_slot_counter_keyed_emits_largest_count() -> None:
|
||||
"""Keyed requests size storage every key declares at the same capacity:
|
||||
the define is the busiest key's count, not the total over all keys."""
|
||||
request = ch.slot_counter("TEST_SLOT_COUNT_KEYED")
|
||||
request("rx_a")
|
||||
request("rx_a")
|
||||
request("rx_a")
|
||||
request("rx_b")
|
||||
assert ch.get_slot_count("TEST_SLOT_COUNT_KEYED") == 3
|
||||
ch.CORE.flush_tasks()
|
||||
assert _define_value("TEST_SLOT_COUNT_KEYED") == "3"
|
||||
|
||||
|
||||
def test_slot_counter_rejects_mixed_keyed_and_unkeyed_requests() -> None:
|
||||
"""A keyed and an unkeyed request for one define cannot be sized together."""
|
||||
request = ch.slot_counter("TEST_SLOT_COUNT_MIXED")
|
||||
request("rx_a")
|
||||
with pytest.raises(ValueError, match="TEST_SLOT_COUNT_MIXED"):
|
||||
request()
|
||||
unkeyed = ch.slot_counter("TEST_SLOT_COUNT_MIXED_2")
|
||||
unkeyed()
|
||||
with pytest.raises(ValueError, match="TEST_SLOT_COUNT_MIXED_2"):
|
||||
unkeyed("rx_a")
|
||||
|
||||
|
||||
def test_slot_counter_without_requests_emits_nothing() -> None:
|
||||
"""No requests, no job, no define — the guarded storage compiles out."""
|
||||
ch.slot_counter("TEST_SLOT_COUNT_UNUSED")
|
||||
|
||||
@@ -416,6 +416,9 @@ def test_perform_ota_no_auth(
|
||||
"Update took 14.00 seconds (prepare 2.00, upload 5.00, commit 7.00)"
|
||||
in caplog.text
|
||||
)
|
||||
# The data phase timeout must outlast the device's 105 s data timeout
|
||||
mock_socket.settimeout.assert_any_call(espota2.DATA_PHASE_TIMEOUT)
|
||||
assert espota2.DATA_PHASE_TIMEOUT > 105.0
|
||||
|
||||
|
||||
@pytest.mark.usefixtures("mock_time")
|
||||
|
||||
@@ -1663,7 +1663,7 @@ def test_preinstall_runs_dependency_waves(tmp_path: Path) -> None:
|
||||
{"name": "SPI"},
|
||||
]
|
||||
m.dependency_to_spec.side_effect = lambda dep: _FakeSpec(name=dep["name"])
|
||||
pf._preinstall(m, [("noise-c@0.1.24", _FakeSpec(name="noise-c"))])
|
||||
pf._preinstall(m, [("noise-c@1.0", _FakeSpec(name="noise-c"))])
|
||||
assert installed == ["noise-c", "libsodium"] # dep deduped, SPI left out
|
||||
# The dep wave carries its compatibility so _install searches qualified
|
||||
dep_call = m._install.call_args_list[-1]
|
||||
@@ -1683,7 +1683,7 @@ def test_preinstall_dependency_wave_skips_seen_names(tmp_path: Path) -> None:
|
||||
m._install.side_effect = lambda spec, skip_dependencies, compatibility=None: (
|
||||
installed.append(getattr(spec, "name", str(spec)))
|
||||
)
|
||||
pf._preinstall(m, [("noise-c@0.1.24", _FakeSpec(name="noise-c"))])
|
||||
pf._preinstall(m, [("noise-c@1.0", _FakeSpec(name="noise-c"))])
|
||||
assert installed == ["noise-c"]
|
||||
|
||||
|
||||
|
||||
Reference in New Issue
Block a user