mirror of
https://github.com/esphome/esphome.git
synced 2026-07-10 08:55:36 +00:00
cdd334284e
Co-authored-by: Jonathan Swoboda <154711427+swoboda1337@users.noreply.github.com>
2819 lines
85 KiB
Python
2819 lines
85 KiB
Python
from pathlib import Path
|
||
import string
|
||
|
||
from hypothesis import example, given, settings
|
||
from hypothesis.strategies import builds, integers, ip_addresses, one_of, text
|
||
import pytest
|
||
import voluptuous as vol
|
||
|
||
from esphome import config_validation as cv
|
||
from esphome.components.esp32 import (
|
||
VARIANT_ESP32,
|
||
VARIANT_ESP32C2,
|
||
VARIANT_ESP32C3,
|
||
VARIANT_ESP32C6,
|
||
VARIANT_ESP32H2,
|
||
VARIANT_ESP32S2,
|
||
VARIANT_ESP32S3,
|
||
)
|
||
from esphome.config_validation import Invalid
|
||
from esphome.const import (
|
||
CONF_DAY,
|
||
CONF_HOUR,
|
||
CONF_ID,
|
||
CONF_INTERNAL,
|
||
CONF_MINUTE,
|
||
CONF_MONTH,
|
||
CONF_NAME,
|
||
CONF_REF,
|
||
CONF_SECOND,
|
||
CONF_TYPE,
|
||
CONF_VALUE,
|
||
CONF_YEAR,
|
||
KEY_CORE,
|
||
KEY_FRAMEWORK_VERSION,
|
||
KEY_TARGET_FRAMEWORK,
|
||
KEY_TARGET_PLATFORM,
|
||
PLATFORM_BK72XX,
|
||
PLATFORM_ESP32,
|
||
PLATFORM_ESP8266,
|
||
PLATFORM_HOST,
|
||
PLATFORM_LN882X,
|
||
PLATFORM_RP2,
|
||
PLATFORM_RTL87XX,
|
||
SCHEDULER_DONT_RUN,
|
||
TYPE_GIT,
|
||
TYPE_LOCAL,
|
||
Framework,
|
||
)
|
||
from esphome.core import (
|
||
CORE,
|
||
ID,
|
||
HexInt,
|
||
Lambda,
|
||
MACAddress,
|
||
TimePeriod,
|
||
TimePeriodMicroseconds,
|
||
TimePeriodMinutes,
|
||
TimePeriodNanoseconds,
|
||
TimePeriodSeconds,
|
||
)
|
||
from esphome.schema_extractors import SCHEMA_EXTRACT
|
||
from esphome.util import Registry
|
||
from esphome.yaml_util import ESPHomeDataBase, SensitiveStr, make_data_base
|
||
|
||
|
||
def test_check_not_templatable__invalid():
|
||
with pytest.raises(Invalid, match="This option is not templatable!"):
|
||
cv.check_not_templatable(Lambda(""))
|
||
|
||
|
||
@pytest.mark.parametrize("value", ("foo", 1, "D12", False))
|
||
def test_alphanumeric__valid(value):
|
||
actual = cv.alphanumeric(value)
|
||
|
||
assert actual == str(value)
|
||
|
||
|
||
@pytest.mark.parametrize("value", ("£23", "Foo!"))
|
||
def test_alphanumeric__invalid(value):
|
||
with pytest.raises(Invalid):
|
||
cv.alphanumeric(value)
|
||
|
||
|
||
@given(value=text(alphabet=string.ascii_lowercase + string.digits + "-_"))
|
||
def test_valid_name__valid(value):
|
||
actual = cv.valid_name(value)
|
||
|
||
assert actual == value
|
||
|
||
|
||
@pytest.mark.parametrize("value", ("foo bar", "FooBar", "foo::bar"))
|
||
def test_valid_name__invalid(value):
|
||
with pytest.raises(Invalid):
|
||
cv.valid_name(value)
|
||
|
||
|
||
@pytest.mark.parametrize("value", ("${name}", "${NAME}", "$NAME", "${name}_name"))
|
||
def test_valid_name__substitution_valid(value):
|
||
CORE.vscode = True
|
||
actual = cv.valid_name(value)
|
||
assert actual == value
|
||
|
||
CORE.vscode = False
|
||
with pytest.raises(Invalid):
|
||
actual = cv.valid_name(value)
|
||
|
||
|
||
@pytest.mark.parametrize("value", ("{NAME}", "${A NAME}"))
|
||
def test_valid_name__substitution_like_invalid(value):
|
||
with pytest.raises(Invalid):
|
||
cv.valid_name(value)
|
||
|
||
|
||
@pytest.mark.parametrize("value", ("myid", "anID", "SOME_ID_test", "MYID_99"))
|
||
def test_validate_id_name__valid(value):
|
||
actual = cv.validate_id_name(value)
|
||
|
||
assert actual == value
|
||
|
||
|
||
@pytest.mark.parametrize("value", ("id of mine", "id-4", "{name_id}", "id::name"))
|
||
def test_validate_id_name__invalid(value):
|
||
with pytest.raises(Invalid):
|
||
cv.validate_id_name(value)
|
||
|
||
|
||
@pytest.mark.parametrize("value", ("${id}", "${ID}", "${ID}_test_1", "$MYID"))
|
||
def test_validate_id_name__substitution_valid(value):
|
||
CORE.vscode = True
|
||
actual = cv.validate_id_name(value)
|
||
assert actual == value
|
||
|
||
CORE.vscode = False
|
||
with pytest.raises(Invalid):
|
||
cv.validate_id_name(value)
|
||
|
||
|
||
@given(one_of(integers(), text()))
|
||
def test_string__valid(value):
|
||
actual = cv.string(value)
|
||
|
||
assert actual == str(value)
|
||
|
||
|
||
@pytest.mark.parametrize("value", ({}, [], True, False, None))
|
||
def test_string__invalid(value):
|
||
with pytest.raises(Invalid):
|
||
cv.string(value)
|
||
|
||
|
||
@given(text())
|
||
def test_strict_string__valid(value):
|
||
actual = cv.string_strict(value)
|
||
|
||
assert actual == value
|
||
|
||
|
||
@pytest.mark.parametrize("value", (None, 123))
|
||
def test_string_string__invalid(value):
|
||
with pytest.raises(Invalid, match="Must be string, got"):
|
||
cv.string_strict(value)
|
||
|
||
|
||
def test_sensitive__default_delegates_to_string() -> None:
|
||
validator = cv.sensitive()
|
||
|
||
assert isinstance(validator, cv.SensitiveValidator)
|
||
assert validator.inner is cv.string
|
||
assert validator("hunter2") == "hunter2"
|
||
assert validator(42) == "42"
|
||
|
||
|
||
def test_sensitive__custom_inner_delegates_validation() -> None:
|
||
validator = cv.sensitive(cv.string_strict)
|
||
|
||
assert validator.inner is cv.string_strict
|
||
assert validator("abc") == "abc"
|
||
with pytest.raises(Invalid, match="Must be string, got"):
|
||
validator(123)
|
||
|
||
|
||
def test_sensitive__wraps_string_result_in_sensitive_str() -> None:
|
||
validator = cv.sensitive()
|
||
result = validator("hunter2")
|
||
|
||
assert isinstance(result, SensitiveStr)
|
||
assert isinstance(result, str)
|
||
assert result == "hunter2"
|
||
|
||
|
||
def test_sensitive__does_not_double_tag_already_sensitive() -> None:
|
||
# If the inner validator already returns a SensitiveStr (e.g., nested
|
||
# cv.sensitive wrappers), re-tagging is a no-op rather than a new
|
||
# SensitiveStr around the same value.
|
||
pre_tagged = SensitiveStr("hunter2")
|
||
|
||
def inner(_value):
|
||
return pre_tagged
|
||
|
||
validator = cv.sensitive(inner)
|
||
result = validator("anything")
|
||
|
||
assert result is pre_tagged
|
||
|
||
|
||
def test_sensitive__non_string_result_passes_through() -> None:
|
||
# If an inner validator returns something other than a string (e.g., a
|
||
# Lambda template), the sensitive wrapper must not coerce it.
|
||
sentinel = object()
|
||
|
||
def inner(_value):
|
||
return sentinel
|
||
|
||
validator = cv.sensitive(inner)
|
||
assert validator("anything") is sentinel
|
||
|
||
|
||
def test_sensitive__is_detectable_via_isinstance() -> None:
|
||
validator = cv.sensitive()
|
||
|
||
assert isinstance(validator, cv.SensitiveValidator)
|
||
|
||
|
||
def test_bind_key__bare_usage_validates_and_is_sensitive() -> None:
|
||
# Used bare (cv.bind_key) it is itself a sensitive validator: detectable for
|
||
# frontend masking and validating a value directly tags the result.
|
||
assert isinstance(cv.bind_key, cv.SensitiveValidator)
|
||
|
||
result = cv.bind_key("0123456789ABCDEF0123456789ABCDEF")
|
||
|
||
assert isinstance(result, SensitiveStr)
|
||
assert result == "0123456789ABCDEF0123456789ABCDEF"
|
||
|
||
|
||
def test_bind_key__bare_usage_in_schema() -> None:
|
||
# Voluptuous calls the bare validator with the config value; the result must
|
||
# come through tagged sensitive.
|
||
schema = cv.Schema({cv.Required("key"): cv.bind_key})
|
||
out = schema({"key": "0123456789ABCDEF0123456789ABCDEF"})
|
||
|
||
assert isinstance(out["key"], SensitiveStr)
|
||
|
||
|
||
def test_bind_key__factory_returns_sensitive_validator() -> None:
|
||
# Called with a name (cv.bind_key(name=...)) it returns a new sensitive
|
||
# validator rather than validating.
|
||
validator = cv.bind_key(name="Decryption key")
|
||
|
||
assert isinstance(validator, cv.SensitiveValidator)
|
||
assert validator is not cv.bind_key
|
||
assert isinstance(validator("0123456789ABCDEF0123456789ABCDEF"), SensitiveStr)
|
||
|
||
|
||
@pytest.mark.parametrize(
|
||
("value", "error"),
|
||
(
|
||
("00", "Decryption key must consist of 16 hexadecimal numbers"),
|
||
("0123456789ABCDEF0123456789ABCDEG", "Decryption key must be hex values"),
|
||
),
|
||
)
|
||
def test_bind_key__custom_name_in_error(value: str, error: str) -> None:
|
||
# The ``name`` argument (used by dsmr/dlms_meter) customizes error messages.
|
||
validator = cv.bind_key(name="Decryption key")
|
||
with pytest.raises(Invalid, match=error):
|
||
validator(value)
|
||
|
||
|
||
def test_bind_key__rejects_non_hex_pair_length() -> None:
|
||
# Odd-length input yields a trailing single-char part, hitting the
|
||
# "format XX" branch rather than the hex-value branch.
|
||
with pytest.raises(Invalid, match="Bind key must be format XX"):
|
||
cv.bind_key("0123456789ABCDEF0123456789ABCDE")
|
||
|
||
|
||
def test_bind_key__direct_call_with_name_validates_with_that_name() -> None:
|
||
# Passing both a value and a name validates immediately using the custom
|
||
# name for error wording, and still tags the result sensitive.
|
||
result = cv.bind_key("0123456789ABCDEF0123456789ABCDEF", name="Decryption key")
|
||
assert isinstance(result, SensitiveStr)
|
||
|
||
with pytest.raises(Invalid, match="Decryption key must consist of"):
|
||
cv.bind_key("00", name="Decryption key")
|
||
|
||
|
||
def test_bind_key__factory_without_name_keeps_existing_name() -> None:
|
||
# Re-invoking a named validator without a name preserves its name rather
|
||
# than resetting to the default.
|
||
named = cv.bind_key(name="Decryption key")
|
||
rederived = named()
|
||
|
||
with pytest.raises(Invalid, match="Decryption key must consist of"):
|
||
rederived("00")
|
||
|
||
|
||
def test_bind_key__repr_is_name_keyed_and_non_recursive() -> None:
|
||
# ``self.inner`` is a bound method of the instance, so the inherited
|
||
# ``repr(self.inner)`` would recurse infinitely; the override keeps repr
|
||
# finite and keyed on the name for schema-dump dedup.
|
||
assert repr(cv.bind_key) == "bind_key('Bind key')"
|
||
assert repr(cv.bind_key(name="Decryption key")) == "bind_key('Decryption key')"
|
||
|
||
|
||
def test_sensitive__repr_mirrors_inner() -> None:
|
||
# The schema dump dedups on ``repr(schema)``; mirroring the inner
|
||
# validator's repr keeps two ``cv.sensitive(cv.string)`` wrappers
|
||
# interchangeable for that purpose and avoids leaking the wrapper as
|
||
# noise in voluptuous error messages.
|
||
assert repr(cv.sensitive(cv.string)) == repr(cv.string)
|
||
assert repr(cv.sensitive(cv.string)) == repr(cv.sensitive(cv.string))
|
||
|
||
|
||
def test_sensitive_key_fragments__covers_common_terms() -> None:
|
||
assert isinstance(cv.SENSITIVE_KEY_FRAGMENTS, frozenset)
|
||
for term in ("password", "passcode", "secret", "token", "api_key", "apikey", "psk"):
|
||
assert term in cv.SENSITIVE_KEY_FRAGMENTS
|
||
|
||
|
||
@given(
|
||
builds(
|
||
lambda v: "mdi:" + v,
|
||
text(
|
||
alphabet=string.ascii_letters + string.digits + "-_",
|
||
min_size=1,
|
||
max_size=20,
|
||
),
|
||
)
|
||
)
|
||
@example("")
|
||
def test_icon__valid(value):
|
||
actual = cv.icon(value)
|
||
|
||
assert actual == value
|
||
|
||
|
||
def test_icon__invalid():
|
||
with pytest.raises(Invalid, match="Icons must match the format "):
|
||
cv.icon("foo")
|
||
|
||
|
||
def test_icon__max_length():
|
||
"""Test that icons exceeding 63 bytes are rejected."""
|
||
# Exactly 63 bytes should pass
|
||
max_icon = "mdi:" + "a" * 59 # 63 bytes total
|
||
assert cv.icon(max_icon) == max_icon
|
||
|
||
# 64 bytes should fail
|
||
too_long = "mdi:" + "a" * 60 # 64 bytes total
|
||
with pytest.raises(Invalid, match="Icon string is too long"):
|
||
cv.icon(too_long)
|
||
|
||
|
||
def test_byte_length() -> None:
|
||
"""Test ByteLength validator checks UTF-8 byte length, not char count."""
|
||
validator = cv.ByteLength(max=10) # pylint: disable=no-member
|
||
|
||
# ASCII: 10 chars = 10 bytes, should pass
|
||
assert validator("a" * 10) == "a" * 10
|
||
|
||
# ASCII: 11 chars = 11 bytes, should fail
|
||
with pytest.raises(Invalid, match="too long.*11 bytes.*max 10"):
|
||
validator("a" * 11)
|
||
|
||
# Multibyte: 3 chars × 3 bytes = 9 bytes, should pass
|
||
assert validator("温度传") == "温度传"
|
||
|
||
# Multibyte: 4 chars × 3 bytes = 12 bytes, should fail
|
||
with pytest.raises(Invalid, match="too long.*12 bytes.*max 10"):
|
||
validator("温度传感")
|
||
|
||
|
||
@pytest.mark.parametrize("value", ("True", "YES", "on", "enAblE", True))
|
||
def test_boolean__valid_true(value):
|
||
assert cv.boolean(value) is True
|
||
|
||
|
||
@pytest.mark.parametrize("value", ("False", "NO", "off", "disAblE", False))
|
||
def test_boolean__valid_false(value):
|
||
assert cv.boolean(value) is False
|
||
|
||
|
||
@pytest.mark.parametrize("value", (None, 1, 0, "foo"))
|
||
def test_boolean__invalid(value):
|
||
with pytest.raises(Invalid, match="Expected boolean value"):
|
||
cv.boolean(value)
|
||
|
||
|
||
# deadline disabled: the validator is trivially fast, but Hypothesis's per-example
|
||
# deadline can spuriously trip on slow/loaded CI runners (e.g. one example hitting
|
||
# a GC pause), making this a flaky failure. Matches test_helpers.py.
|
||
@settings(deadline=None)
|
||
@given(value=ip_addresses(v=4).map(str))
|
||
def test_ipv4__valid(value):
|
||
cv.ipv4address(value)
|
||
|
||
|
||
@pytest.mark.parametrize("value", ("127.0.0", "localhost", ""))
|
||
def test_ipv4__invalid(value):
|
||
with pytest.raises(Invalid, match="is not a valid IPv4 address"):
|
||
cv.ipv4address(value)
|
||
|
||
|
||
@settings(deadline=None)
|
||
@given(value=ip_addresses(v=6).map(str))
|
||
def test_ipv6__valid(value):
|
||
cv.ipaddress(value)
|
||
|
||
|
||
@pytest.mark.parametrize("value", ("127.0.0", "localhost", "", "2001:db8::2::3"))
|
||
def test_ipv6__invalid(value):
|
||
with pytest.raises(Invalid, match="is not a valid IP address"):
|
||
cv.ipaddress(value)
|
||
|
||
|
||
# TODO: ensure_list
|
||
@given(integers())
|
||
def hex_int__valid(value):
|
||
actual = cv.hex_int(value)
|
||
|
||
assert isinstance(actual, HexInt)
|
||
assert actual == value
|
||
|
||
|
||
@pytest.mark.parametrize(
|
||
"framework, platform, variant, full, idf, arduino, simple",
|
||
[
|
||
("arduino", PLATFORM_ESP8266, None, "1", "1", "1", "1"),
|
||
("arduino", PLATFORM_ESP32, VARIANT_ESP32, "3", "2", "3", "2"),
|
||
("esp-idf", PLATFORM_ESP32, VARIANT_ESP32, "4", "4", "2", "2"),
|
||
("arduino", PLATFORM_ESP32, VARIANT_ESP32C2, "3", "2", "3", "2"),
|
||
("esp-idf", PLATFORM_ESP32, VARIANT_ESP32C2, "4", "4", "2", "2"),
|
||
("arduino", PLATFORM_ESP32, VARIANT_ESP32S2, "6", "5", "6", "5"),
|
||
("esp-idf", PLATFORM_ESP32, VARIANT_ESP32S2, "7", "7", "5", "5"),
|
||
("arduino", PLATFORM_ESP32, VARIANT_ESP32S3, "9", "8", "9", "8"),
|
||
("esp-idf", PLATFORM_ESP32, VARIANT_ESP32S3, "10", "10", "8", "8"),
|
||
("arduino", PLATFORM_ESP32, VARIANT_ESP32C3, "12", "11", "12", "11"),
|
||
("esp-idf", PLATFORM_ESP32, VARIANT_ESP32C3, "13", "13", "11", "11"),
|
||
("arduino", PLATFORM_ESP32, VARIANT_ESP32C6, "15", "14", "15", "14"),
|
||
("esp-idf", PLATFORM_ESP32, VARIANT_ESP32C6, "16", "16", "14", "14"),
|
||
("arduino", PLATFORM_ESP32, VARIANT_ESP32H2, "18", "17", "18", "17"),
|
||
("esp-idf", PLATFORM_ESP32, VARIANT_ESP32H2, "19", "19", "17", "17"),
|
||
("arduino", PLATFORM_RP2, None, "20", "20", "20", "20"),
|
||
("arduino", PLATFORM_BK72XX, None, "21", "21", "21", "21"),
|
||
("arduino", PLATFORM_RTL87XX, None, "22", "22", "22", "22"),
|
||
("arduino", PLATFORM_LN882X, None, "23", "23", "23", "23"),
|
||
("host", PLATFORM_HOST, None, "24", "24", "24", "24"),
|
||
],
|
||
)
|
||
def test_split_default(framework, platform, variant, full, idf, arduino, simple):
|
||
from esphome.components.esp32 import KEY_ESP32
|
||
from esphome.const import (
|
||
KEY_CORE,
|
||
KEY_TARGET_FRAMEWORK,
|
||
KEY_TARGET_PLATFORM,
|
||
KEY_VARIANT,
|
||
)
|
||
|
||
CORE.data[KEY_CORE] = {}
|
||
CORE.data[KEY_CORE][KEY_TARGET_PLATFORM] = platform
|
||
CORE.data[KEY_CORE][KEY_TARGET_FRAMEWORK] = framework
|
||
if platform == PLATFORM_ESP32:
|
||
CORE.data[KEY_ESP32] = {}
|
||
CORE.data[KEY_ESP32][KEY_VARIANT] = variant
|
||
|
||
common_mappings = {
|
||
"esp8266": "1",
|
||
"esp32": "2",
|
||
"esp32_s2": "5",
|
||
"esp32_s3": "8",
|
||
"esp32_c3": "11",
|
||
"esp32_c6": "14",
|
||
"esp32_h2": "17",
|
||
"rp2": "20",
|
||
"bk72xx": "21",
|
||
"rtl87xx": "22",
|
||
"ln882x": "23",
|
||
"host": "24",
|
||
}
|
||
|
||
arduino_mappings = {
|
||
"esp32_arduino": "3",
|
||
"esp32_s2_arduino": "6",
|
||
"esp32_s3_arduino": "9",
|
||
"esp32_c3_arduino": "12",
|
||
"esp32_c6_arduino": "15",
|
||
"esp32_h2_arduino": "18",
|
||
}
|
||
|
||
idf_mappings = {
|
||
"esp32_idf": "4",
|
||
"esp32_s2_idf": "7",
|
||
"esp32_s3_idf": "10",
|
||
"esp32_c3_idf": "13",
|
||
"esp32_c6_idf": "16",
|
||
"esp32_h2_idf": "19",
|
||
}
|
||
|
||
schema = cv.Schema(
|
||
{
|
||
cv.SplitDefault(
|
||
"full", **common_mappings, **idf_mappings, **arduino_mappings
|
||
): str,
|
||
cv.SplitDefault("idf", **common_mappings, **idf_mappings): str,
|
||
cv.SplitDefault("arduino", **common_mappings, **arduino_mappings): str,
|
||
cv.SplitDefault("simple", **common_mappings): str,
|
||
}
|
||
)
|
||
|
||
assert schema({}).get("full") == full
|
||
assert schema({}).get("idf") == idf
|
||
assert schema({}).get("arduino") == arduino
|
||
assert schema({}).get("simple") == simple
|
||
|
||
|
||
@pytest.mark.parametrize(
|
||
"framework, platform, message",
|
||
[
|
||
("arduino", PLATFORM_ESP32, "ESP32 using arduino framework"),
|
||
("esp-idf", PLATFORM_ESP32, "ESP32 using esp-idf framework"),
|
||
("arduino", PLATFORM_ESP8266, "ESP8266 using arduino framework"),
|
||
("arduino", PLATFORM_RP2, "RP2 using arduino framework"),
|
||
("arduino", PLATFORM_BK72XX, "BK72XX using arduino framework"),
|
||
("host", PLATFORM_HOST, "HOST using host framework"),
|
||
],
|
||
)
|
||
def test_require_framework_version(framework, platform, message):
|
||
from esphome.const import (
|
||
KEY_CORE,
|
||
KEY_FRAMEWORK_VERSION,
|
||
KEY_TARGET_FRAMEWORK,
|
||
KEY_TARGET_PLATFORM,
|
||
)
|
||
|
||
CORE.data[KEY_CORE] = {}
|
||
CORE.data[KEY_CORE][KEY_TARGET_PLATFORM] = platform
|
||
CORE.data[KEY_CORE][KEY_TARGET_FRAMEWORK] = framework
|
||
CORE.data[KEY_CORE][KEY_FRAMEWORK_VERSION] = cv.Version(1, 0, 0)
|
||
|
||
assert (
|
||
cv.require_framework_version(
|
||
esp_idf=cv.Version(0, 5, 0),
|
||
esp32_arduino=cv.Version(0, 5, 0),
|
||
esp8266_arduino=cv.Version(0, 5, 0),
|
||
rp2_arduino=cv.Version(0, 5, 0),
|
||
bk72xx_arduino=cv.Version(0, 5, 0),
|
||
host=cv.Version(0, 5, 0),
|
||
extra_message="test 1",
|
||
)("test")
|
||
== "test"
|
||
)
|
||
|
||
with pytest.raises(
|
||
vol.error.Invalid,
|
||
match="This feature requires at least framework version 2.0.0. test 2",
|
||
):
|
||
cv.require_framework_version(
|
||
esp_idf=cv.Version(2, 0, 0),
|
||
esp32_arduino=cv.Version(2, 0, 0),
|
||
esp8266_arduino=cv.Version(2, 0, 0),
|
||
rp2_arduino=cv.Version(2, 0, 0),
|
||
bk72xx_arduino=cv.Version(2, 0, 0),
|
||
host=cv.Version(2, 0, 0),
|
||
extra_message="test 2",
|
||
)("test")
|
||
|
||
assert (
|
||
cv.require_framework_version(
|
||
esp_idf=cv.Version(1, 5, 0),
|
||
esp32_arduino=cv.Version(1, 5, 0),
|
||
esp8266_arduino=cv.Version(1, 5, 0),
|
||
rp2_arduino=cv.Version(1, 5, 0),
|
||
bk72xx_arduino=cv.Version(1, 5, 0),
|
||
host=cv.Version(1, 5, 0),
|
||
max_version=True,
|
||
extra_message="test 3",
|
||
)("test")
|
||
== "test"
|
||
)
|
||
|
||
with pytest.raises(
|
||
vol.error.Invalid,
|
||
match="This feature requires framework version 0.5.0 or lower. test 4",
|
||
):
|
||
cv.require_framework_version(
|
||
esp_idf=cv.Version(0, 5, 0),
|
||
esp32_arduino=cv.Version(0, 5, 0),
|
||
esp8266_arduino=cv.Version(0, 5, 0),
|
||
rp2_arduino=cv.Version(0, 5, 0),
|
||
bk72xx_arduino=cv.Version(0, 5, 0),
|
||
host=cv.Version(0, 5, 0),
|
||
max_version=True,
|
||
extra_message="test 4",
|
||
)("test")
|
||
|
||
with pytest.raises(
|
||
vol.error.Invalid, match=f"This feature is incompatible with {message}. test 5"
|
||
):
|
||
cv.require_framework_version(
|
||
extra_message="test 5",
|
||
)("test")
|
||
|
||
|
||
def _setup_core_for_framework(platform: str, framework: str) -> None:
|
||
"""Wire CORE.data with the minimum keys for require_framework_version /
|
||
SplitDefault to evaluate without raising KeyError."""
|
||
from esphome.const import (
|
||
KEY_CORE,
|
||
KEY_FRAMEWORK_VERSION,
|
||
KEY_TARGET_FRAMEWORK,
|
||
KEY_TARGET_PLATFORM,
|
||
)
|
||
|
||
CORE.data[KEY_CORE] = {
|
||
KEY_TARGET_PLATFORM: platform,
|
||
KEY_TARGET_FRAMEWORK: framework,
|
||
KEY_FRAMEWORK_VERSION: cv.Version(1, 0, 0),
|
||
}
|
||
|
||
|
||
def test_only_on_rp2_passes_on_rp2_platform() -> None:
|
||
"""``cv.only_on_rp2`` is the canonical family gate. It accepts any value
|
||
untouched when the configured platform is rp2."""
|
||
_setup_core_for_framework(PLATFORM_RP2, "arduino")
|
||
assert cv.only_on_rp2("anything") == "anything"
|
||
|
||
|
||
def test_only_on_rp2_rejects_other_platforms() -> None:
|
||
"""The same gate raises ``Invalid`` outside the rp2 platform."""
|
||
_setup_core_for_framework(PLATFORM_ESP32, "arduino")
|
||
with pytest.raises(Invalid, match="rp2"):
|
||
cv.only_on_rp2("anything")
|
||
|
||
|
||
def test_only_on_rp2040_delegates_and_warns_once(caplog) -> None:
|
||
"""``cv.only_on_rp2040`` is a deprecation shim — it logs a one-shot
|
||
warning, dedupes via CORE.data, and delegates to ``only_on_rp2``.
|
||
Repeated calls in the same run must not log again."""
|
||
import logging
|
||
|
||
_setup_core_for_framework(PLATFORM_RP2, "arduino")
|
||
# Reset the dedupe flag so this test is independent of order.
|
||
CORE.data.pop(cv._ONLY_ON_RP2040_DEPRECATED_KEY, None)
|
||
|
||
with caplog.at_level(logging.WARNING, logger="esphome.config_validation"):
|
||
assert cv.only_on_rp2040("ok") == "ok"
|
||
first_warnings = [r for r in caplog.records if "only_on_rp2040" in r.message]
|
||
assert len(first_warnings) == 1
|
||
assert "2027.7.0" in first_warnings[0].message
|
||
|
||
# Second call dedupes — no additional warning is emitted.
|
||
assert cv.only_on_rp2040("ok") == "ok"
|
||
warnings_after_second = [
|
||
r for r in caplog.records if "only_on_rp2040" in r.message
|
||
]
|
||
assert len(warnings_after_second) == 1
|
||
|
||
|
||
def test_only_on_rp2040_still_gates_on_non_rp2(caplog) -> None:
|
||
"""The deprecation shim must still raise on non-rp2 platforms — it
|
||
delegates to ``only_on_rp2``, so the gating behavior is preserved."""
|
||
import logging
|
||
|
||
_setup_core_for_framework(PLATFORM_ESP32, "arduino")
|
||
CORE.data.pop(cv._ONLY_ON_RP2040_DEPRECATED_KEY, None)
|
||
|
||
with (
|
||
caplog.at_level(logging.WARNING, logger="esphome.config_validation"),
|
||
pytest.raises(Invalid, match="rp2"),
|
||
):
|
||
cv.only_on_rp2040("anything")
|
||
|
||
|
||
def test_require_framework_version_esp32_variant_specific_key() -> None:
|
||
"""ESP32 variant-specific kwargs (``esp32_c3_arduino``) must win over
|
||
the base ``esp32_arduino`` key when the configured variant matches."""
|
||
from esphome.components.esp32 import KEY_ESP32
|
||
from esphome.const import (
|
||
KEY_CORE,
|
||
KEY_FRAMEWORK_VERSION,
|
||
KEY_TARGET_FRAMEWORK,
|
||
KEY_TARGET_PLATFORM,
|
||
KEY_VARIANT,
|
||
)
|
||
|
||
CORE.data[KEY_CORE] = {
|
||
KEY_TARGET_PLATFORM: PLATFORM_ESP32,
|
||
KEY_TARGET_FRAMEWORK: "arduino",
|
||
KEY_FRAMEWORK_VERSION: cv.Version(1, 2, 0),
|
||
}
|
||
CORE.data[KEY_ESP32] = {KEY_VARIANT: VARIANT_ESP32C3}
|
||
|
||
# Variant-specific entry permits this version; base key would reject it.
|
||
assert (
|
||
cv.require_framework_version(
|
||
esp32_arduino=cv.Version(5, 0, 0), # would reject
|
||
esp32_c3_arduino=cv.Version(1, 0, 0), # wins, ok
|
||
)("test")
|
||
== "test"
|
||
)
|
||
|
||
|
||
def test_require_framework_version_rp2_variant_specific_key() -> None:
|
||
"""RP2 variant kwargs (``rp2_2040_arduino``) must win over the base
|
||
``rp2_arduino`` key when ``CORE.data['rp2']['variant']`` is wired."""
|
||
from esphome.const import (
|
||
KEY_CORE,
|
||
KEY_FRAMEWORK_VERSION,
|
||
KEY_TARGET_FRAMEWORK,
|
||
KEY_TARGET_PLATFORM,
|
||
)
|
||
|
||
CORE.data[KEY_CORE] = {
|
||
KEY_TARGET_PLATFORM: PLATFORM_RP2,
|
||
KEY_TARGET_FRAMEWORK: "arduino",
|
||
KEY_FRAMEWORK_VERSION: cv.Version(1, 2, 0),
|
||
}
|
||
CORE.data["rp2"] = {"variant": "RP2040"}
|
||
|
||
# Variant key wins — base ``rp2_arduino`` (which would reject) is ignored.
|
||
assert (
|
||
cv.require_framework_version(
|
||
rp2_arduino=cv.Version(5, 0, 0), # would reject
|
||
rp2_2040_arduino=cv.Version(1, 0, 0), # wins, ok
|
||
)("test")
|
||
== "test"
|
||
)
|
||
|
||
# Without a variant kwarg the base ``rp2_arduino`` is used (fallback).
|
||
CORE.data["rp2"] = {"variant": "RP2350"}
|
||
assert (
|
||
cv.require_framework_version(
|
||
rp2_arduino=cv.Version(1, 0, 0),
|
||
)("test")
|
||
== "test"
|
||
)
|
||
|
||
|
||
def test_split_default_rp2_variant_keys() -> None:
|
||
"""``SplitDefault`` resolves ``rp2_<chip>_<framework>`` first, falling
|
||
back to ``rp2_<chip>`` and ``rp2_<framework>`` before the base key."""
|
||
from esphome.const import KEY_CORE, KEY_TARGET_FRAMEWORK, KEY_TARGET_PLATFORM
|
||
|
||
CORE.data[KEY_CORE] = {
|
||
KEY_TARGET_PLATFORM: PLATFORM_RP2,
|
||
KEY_TARGET_FRAMEWORK: "arduino",
|
||
}
|
||
CORE.data["rp2"] = {"variant": "RP2040"}
|
||
|
||
schema = cv.Schema(
|
||
{
|
||
cv.SplitDefault(
|
||
"full",
|
||
rp2="base",
|
||
rp2_arduino="base-framework",
|
||
rp2_2040="variant-only",
|
||
rp2_2040_arduino="variant-framework",
|
||
): str,
|
||
}
|
||
)
|
||
# Most specific (variant + framework) wins.
|
||
assert schema({}).get("full") == "variant-framework"
|
||
|
||
# Drop the most-specific kwarg → variant-only wins.
|
||
schema = cv.Schema(
|
||
{
|
||
cv.SplitDefault(
|
||
"full",
|
||
rp2="base",
|
||
rp2_arduino="base-framework",
|
||
rp2_2040="variant-only",
|
||
): str,
|
||
}
|
||
)
|
||
assert schema({}).get("full") == "variant-only"
|
||
|
||
# RP2350 variant — no rp2_2350_* kwargs → fall through to base framework.
|
||
CORE.data["rp2"] = {"variant": "RP2350"}
|
||
schema = cv.Schema(
|
||
{
|
||
cv.SplitDefault(
|
||
"full",
|
||
rp2="base",
|
||
rp2_arduino="base-framework",
|
||
rp2_2040="not-this",
|
||
): str,
|
||
}
|
||
)
|
||
assert schema({}).get("full") == "base-framework"
|
||
|
||
|
||
def test_only_with_single_component_loaded() -> None:
|
||
"""Test OnlyWith with single component when component is loaded."""
|
||
CORE.loaded_integrations = {"mqtt"}
|
||
|
||
schema = cv.Schema(
|
||
{
|
||
cv.OnlyWith("mqtt_id", "mqtt", default="test_mqtt"): str,
|
||
}
|
||
)
|
||
|
||
result = schema({})
|
||
assert result.get("mqtt_id") == "test_mqtt"
|
||
|
||
|
||
def test_only_with_single_component_not_loaded() -> None:
|
||
"""Test OnlyWith with single component when component is not loaded."""
|
||
CORE.loaded_integrations = set()
|
||
|
||
schema = cv.Schema(
|
||
{
|
||
cv.OnlyWith("mqtt_id", "mqtt", default="test_mqtt"): str,
|
||
}
|
||
)
|
||
|
||
result = schema({})
|
||
assert "mqtt_id" not in result
|
||
|
||
|
||
def test_only_with_list_all_components_loaded() -> None:
|
||
"""Test OnlyWith with list when all components are loaded."""
|
||
CORE.loaded_integrations = {"zigbee", "nrf52"}
|
||
|
||
schema = cv.Schema(
|
||
{
|
||
cv.OnlyWith("zigbee_id", ["zigbee", "nrf52"], default="test_zigbee"): str,
|
||
}
|
||
)
|
||
|
||
result = schema({})
|
||
assert result.get("zigbee_id") == "test_zigbee"
|
||
|
||
|
||
def test_only_with_list_partial_components_loaded() -> None:
|
||
"""Test OnlyWith with list when only some components are loaded."""
|
||
CORE.loaded_integrations = {"zigbee"} # Only zigbee, not nrf52
|
||
|
||
schema = cv.Schema(
|
||
{
|
||
cv.OnlyWith("zigbee_id", ["zigbee", "nrf52"], default="test_zigbee"): str,
|
||
}
|
||
)
|
||
|
||
result = schema({})
|
||
assert "zigbee_id" not in result
|
||
|
||
|
||
def test_only_with_list_no_components_loaded() -> None:
|
||
"""Test OnlyWith with list when no components are loaded."""
|
||
CORE.loaded_integrations = set()
|
||
|
||
schema = cv.Schema(
|
||
{
|
||
cv.OnlyWith("zigbee_id", ["zigbee", "nrf52"], default="test_zigbee"): str,
|
||
}
|
||
)
|
||
|
||
result = schema({})
|
||
assert "zigbee_id" not in result
|
||
|
||
|
||
def test_only_with_list_multiple_components() -> None:
|
||
"""Test OnlyWith with list requiring three components."""
|
||
CORE.loaded_integrations = {"comp1", "comp2", "comp3"}
|
||
|
||
schema = cv.Schema(
|
||
{
|
||
cv.OnlyWith(
|
||
"test_id", ["comp1", "comp2", "comp3"], default="test_value"
|
||
): str,
|
||
}
|
||
)
|
||
|
||
result = schema({})
|
||
assert result.get("test_id") == "test_value"
|
||
|
||
# Test with one missing
|
||
CORE.loaded_integrations = {"comp1", "comp2"}
|
||
result = schema({})
|
||
assert "test_id" not in result
|
||
|
||
|
||
def test_only_with_empty_list() -> None:
|
||
"""Test OnlyWith with empty list (edge case)."""
|
||
CORE.loaded_integrations = set()
|
||
|
||
schema = cv.Schema(
|
||
{
|
||
cv.OnlyWith("test_id", [], default="test_value"): str,
|
||
}
|
||
)
|
||
|
||
# all([]) returns True, so default should be applied
|
||
result = schema({})
|
||
assert result.get("test_id") == "test_value"
|
||
|
||
|
||
def test_only_with_user_value_overrides_default() -> None:
|
||
"""Test OnlyWith respects user-provided values over defaults."""
|
||
CORE.loaded_integrations = {"mqtt"}
|
||
|
||
schema = cv.Schema(
|
||
{
|
||
cv.OnlyWith("mqtt_id", "mqtt", default="default_id"): str,
|
||
}
|
||
)
|
||
|
||
result = schema({"mqtt_id": "custom_id"})
|
||
assert result.get("mqtt_id") == "custom_id"
|
||
|
||
|
||
@pytest.mark.parametrize("value", ("hello", "Hello World", "test_name", "温度"))
|
||
def test_string_no_slash__valid(value: str) -> None:
|
||
actual = cv.string_no_slash(value)
|
||
assert actual == value
|
||
|
||
|
||
@pytest.mark.parametrize(
|
||
("value", "expected"),
|
||
(
|
||
("has/slash", "has⁄slash"),
|
||
("a/b/c", "a⁄b⁄c"),
|
||
("/leading", "⁄leading"),
|
||
("trailing/", "trailing⁄"),
|
||
),
|
||
)
|
||
def test_string_no_slash__slash_replaced_with_warning(
|
||
value: str, expected: str, caplog: pytest.LogCaptureFixture
|
||
) -> None:
|
||
"""Test that '/' is auto-replaced with fraction slash and warning is logged."""
|
||
actual = cv.string_no_slash(value)
|
||
assert actual == expected
|
||
assert "reserved as a URL path separator" in caplog.text
|
||
assert "will become an error in ESPHome 2026.7.0" in caplog.text
|
||
|
||
|
||
def test_string_no_slash__long_string_allowed() -> None:
|
||
# string_no_slash doesn't enforce length - use cv.Length() separately
|
||
long_value = "x" * 200
|
||
assert cv.string_no_slash(long_value) == long_value
|
||
|
||
|
||
def test_string_no_slash__empty() -> None:
|
||
assert cv.string_no_slash("") == ""
|
||
|
||
|
||
@pytest.mark.parametrize("value", ("Temperature", "Living Room Light", "温度传感器"))
|
||
def test_validate_entity_name__valid(value: str) -> None:
|
||
actual = cv._validate_entity_name(value)
|
||
assert actual == value
|
||
|
||
|
||
def test_validate_entity_name__slash_replaced_with_warning(
|
||
caplog: pytest.LogCaptureFixture,
|
||
) -> None:
|
||
"""Test that '/' in entity names is auto-replaced with fraction slash."""
|
||
actual = cv._validate_entity_name("has/slash")
|
||
assert actual == "has⁄slash"
|
||
assert "reserved as a URL path separator" in caplog.text
|
||
|
||
|
||
def test_validate_entity_name__max_length() -> None:
|
||
# 120 bytes should pass
|
||
assert cv._validate_entity_name("x" * 120) == "x" * 120
|
||
|
||
# 121 bytes should fail
|
||
with pytest.raises(Invalid, match="too long.*121 bytes.*Maximum.*120"):
|
||
cv._validate_entity_name("x" * 121)
|
||
|
||
|
||
def test_validate_entity_name__multibyte_byte_length() -> None:
|
||
# 40 chars of 3-byte UTF-8 = 120 bytes, should pass
|
||
assert cv._validate_entity_name("温" * 40) == "温" * 40
|
||
|
||
# 41 chars of 3-byte UTF-8 = 123 bytes, should fail (over 120 byte limit)
|
||
with pytest.raises(Invalid, match="too long.*123 bytes.*Maximum.*120"):
|
||
cv._validate_entity_name("温" * 41)
|
||
|
||
|
||
def test_validate_entity_name__none_without_friendly_name() -> None:
|
||
# When name is "None" and friendly_name is not set, it should fail
|
||
CORE.friendly_name = None
|
||
with pytest.raises(Invalid, match="friendly_name is not set"):
|
||
cv._validate_entity_name("None")
|
||
|
||
|
||
def test_validate_entity_name__none_with_friendly_name() -> None:
|
||
# When name is "None" but friendly_name is set, it should return None
|
||
CORE.friendly_name = "My Device"
|
||
result = cv._validate_entity_name("None")
|
||
assert result is None
|
||
CORE.friendly_name = None # Reset
|
||
|
||
|
||
# --- percentage validators ---
|
||
|
||
|
||
@pytest.mark.parametrize(
|
||
("value", "expected"),
|
||
(
|
||
("0%", 0.0),
|
||
("50%", 0.5),
|
||
("100%", 1.0),
|
||
(0.0, 0.0),
|
||
(0.5, 0.5),
|
||
(1.0, 1.0),
|
||
("0.0", 0.0),
|
||
("0.5", 0.5),
|
||
("1.0", 1.0),
|
||
),
|
||
)
|
||
def test_percentage__valid(value: object, expected: float) -> None:
|
||
assert cv.percentage(value) == expected
|
||
|
||
|
||
@pytest.mark.parametrize(
|
||
"value",
|
||
(
|
||
"150%",
|
||
"-10%",
|
||
"-0.1",
|
||
"1.1",
|
||
2,
|
||
-1,
|
||
"foo",
|
||
None,
|
||
),
|
||
)
|
||
def test_percentage__invalid(value: object) -> None:
|
||
with pytest.raises(Invalid):
|
||
cv.percentage(value)
|
||
|
||
|
||
@pytest.mark.parametrize(
|
||
("value", "expected"),
|
||
(
|
||
("0%", 0.0),
|
||
("50%", 0.5),
|
||
("100%", 1.0),
|
||
("-50%", -0.5),
|
||
("-100%", -1.0),
|
||
(0.0, 0.0),
|
||
(0.5, 0.5),
|
||
(-0.5, -0.5),
|
||
(1.0, 1.0),
|
||
(-1.0, -1.0),
|
||
),
|
||
)
|
||
def test_possibly_negative_percentage__valid(value: object, expected: float) -> None:
|
||
assert cv.possibly_negative_percentage(value) == expected
|
||
|
||
|
||
@pytest.mark.parametrize(
|
||
"value",
|
||
(
|
||
"150%",
|
||
"-150%",
|
||
2,
|
||
-2,
|
||
"foo",
|
||
None,
|
||
),
|
||
)
|
||
def test_possibly_negative_percentage__invalid(value: object) -> None:
|
||
with pytest.raises(Invalid):
|
||
cv.possibly_negative_percentage(value)
|
||
|
||
|
||
@pytest.mark.parametrize(
|
||
("value", "expected"),
|
||
(
|
||
("0%", 0.0),
|
||
("50%", 0.5),
|
||
("100%", 1.0),
|
||
("150%", 1.5),
|
||
("200%", 2.0),
|
||
(0.0, 0.0),
|
||
(0.5, 0.5),
|
||
(1.0, 1.0),
|
||
),
|
||
)
|
||
def test_unbounded_percentage__valid(value: object, expected: float) -> None:
|
||
assert cv.unbounded_percentage(value) == expected
|
||
|
||
|
||
@pytest.mark.parametrize(
|
||
"value",
|
||
(
|
||
"-10%",
|
||
"-0.5",
|
||
-1,
|
||
"foo",
|
||
None,
|
||
),
|
||
)
|
||
def test_unbounded_percentage__invalid(value: object) -> None:
|
||
with pytest.raises(Invalid):
|
||
cv.unbounded_percentage(value)
|
||
|
||
|
||
@pytest.mark.parametrize(
|
||
("value", "expected"),
|
||
(
|
||
("0%", 0.0),
|
||
("50%", 0.5),
|
||
("150%", 1.5),
|
||
("-50%", -0.5),
|
||
("-150%", -1.5),
|
||
("200%", 2.0),
|
||
("-200%", -2.0),
|
||
(0.0, 0.0),
|
||
(0.5, 0.5),
|
||
(-0.5, -0.5),
|
||
(1.0, 1.0),
|
||
(-1.0, -1.0),
|
||
),
|
||
)
|
||
def test_unbounded_possibly_negative_percentage__valid(
|
||
value: object, expected: float
|
||
) -> None:
|
||
assert cv.unbounded_possibly_negative_percentage(value) == expected
|
||
|
||
|
||
@pytest.mark.parametrize("value", ("foo", None))
|
||
def test_unbounded_possibly_negative_percentage__invalid(value: object) -> None:
|
||
with pytest.raises(Invalid):
|
||
cv.unbounded_possibly_negative_percentage(value)
|
||
|
||
|
||
@pytest.mark.parametrize(
|
||
"value",
|
||
(50, -50, 2, -2),
|
||
)
|
||
def test_percentage_validators__raw_number_above_one_without_percent_sign(
|
||
value: object,
|
||
) -> None:
|
||
"""Raw numeric values outside [-1, 1] must use a percent sign."""
|
||
with pytest.raises(Invalid, match="percent sign"):
|
||
cv.unbounded_percentage(value)
|
||
with pytest.raises(Invalid, match="percent sign"):
|
||
cv.unbounded_possibly_negative_percentage(value)
|
||
|
||
|
||
def test_update_interval__coerces_zero_to_one_ms(
|
||
caplog: pytest.LogCaptureFixture,
|
||
) -> None:
|
||
"""update_interval: 0ms must be coerced to 1ms (not rejected) because a
|
||
literal 0ms schedule causes Scheduler::call() to spin. Coercion keeps
|
||
existing configs compiling on upgrade while emitting a user-facing
|
||
warning that directs them to set a non-zero value."""
|
||
with caplog.at_level("WARNING"):
|
||
result = cv.update_interval("0ms")
|
||
assert result.total_milliseconds == 1
|
||
assert "update_interval of 0ms is not supported" in caplog.text
|
||
assert "1ms" in caplog.text
|
||
|
||
|
||
def test_update_interval__preserves_nonzero_values() -> None:
|
||
"""Non-zero update_interval values must pass through unchanged."""
|
||
assert cv.update_interval("1ms").total_milliseconds == 1
|
||
assert cv.update_interval("50ms").total_milliseconds == 50
|
||
assert cv.update_interval("60s").total_milliseconds == 60000
|
||
|
||
|
||
def test_update_interval__never_passes_through() -> None:
|
||
"""update_interval: never must still map to SCHEDULER_DONT_RUN."""
|
||
result = cv.update_interval("never")
|
||
assert result.total_milliseconds == SCHEDULER_DONT_RUN
|
||
|
||
|
||
# ---------------------------------------------------------------------------
|
||
# Visibility UI-hint kwarg
|
||
# ---------------------------------------------------------------------------
|
||
|
||
|
||
def test_optional_default_visibility_is_none() -> None:
|
||
"""An ``Optional`` with no ``visibility`` kwarg reports ``None``.
|
||
|
||
Consumers can read the attribute directly with plain attribute
|
||
access; absence (``None``) means "render on the editor's main
|
||
form."
|
||
"""
|
||
o = cv.Optional("foo")
|
||
assert o.visibility is None
|
||
|
||
|
||
def test_optional_visibility_advanced() -> None:
|
||
"""``visibility=Visibility.ADVANCED`` is recorded on the marker."""
|
||
o = cv.Optional("foo", visibility=cv.Visibility.ADVANCED)
|
||
assert o.visibility is cv.Visibility.ADVANCED
|
||
|
||
|
||
def test_optional_visibility_yaml_only() -> None:
|
||
"""``visibility=Visibility.YAML_ONLY`` is recorded on the marker."""
|
||
o = cv.Optional("foo", visibility=cv.Visibility.YAML_ONLY)
|
||
assert o.visibility is cv.Visibility.YAML_ONLY
|
||
|
||
|
||
def test_visibility_str_values_match_dump_emission() -> None:
|
||
"""``Visibility`` is a ``StrEnum`` whose values are the literal
|
||
strings the schema dumper emits.
|
||
|
||
The schema bundle consumers (catalog generators, third-party
|
||
schema-aware tooling) shouldn't need an enum import to read the
|
||
field — pinning the on-the-wire spelling here keeps the dump
|
||
contract stable.
|
||
"""
|
||
assert str(cv.Visibility.ADVANCED) == "advanced"
|
||
assert str(cv.Visibility.YAML_ONLY) == "yaml_only"
|
||
|
||
|
||
def test_optional_visibility_does_not_affect_validation() -> None:
|
||
"""The kwarg is an advisory UI hint — it must not change how the
|
||
validator behaves. A schema with ``visibility`` applied must
|
||
accept and reject the same values it would without it.
|
||
"""
|
||
plain = cv.Schema({cv.Optional("foo", default=42): cv.int_})
|
||
flagged = cv.Schema(
|
||
{
|
||
cv.Optional(
|
||
"foo",
|
||
default=42,
|
||
visibility=cv.Visibility.YAML_ONLY,
|
||
): cv.int_
|
||
}
|
||
)
|
||
# Same accept / default-fill behavior.
|
||
assert plain({"foo": 7}) == flagged({"foo": 7}) == {"foo": 7}
|
||
assert plain({}) == flagged({}) == {"foo": 42}
|
||
# Same rejection on bad input.
|
||
with pytest.raises(Invalid):
|
||
plain({"foo": "not-an-int"})
|
||
with pytest.raises(Invalid):
|
||
flagged({"foo": "not-an-int"})
|
||
|
||
|
||
def test_required_default_visibility_is_none() -> None:
|
||
"""``Required`` mirrors ``Optional`` for the ``visibility`` kwarg."""
|
||
r = cv.Required("foo")
|
||
assert r.visibility is None
|
||
|
||
|
||
def test_required_visibility_kwarg() -> None:
|
||
"""``Required`` accepts ``visibility`` for symmetry with ``Optional``.
|
||
|
||
Required fields rarely need the kwarg, but exposing it lets
|
||
consumers apply uniform logic across key markers.
|
||
"""
|
||
r = cv.Required("foo", visibility=cv.Visibility.ADVANCED)
|
||
assert r.visibility is cv.Visibility.ADVANCED
|
||
|
||
|
||
def test_polling_component_schema_visibility_opt_in() -> None:
|
||
"""``visibility=`` propagates to the inherited ``update_interval``.
|
||
|
||
Time platforms pass ``Visibility.ADVANCED``; sensors and other
|
||
polling components leave it ``None`` and keep the un-flagged shape.
|
||
"""
|
||
default = cv.polling_component_schema("15min")
|
||
advanced = cv.polling_component_schema("15min", visibility=cv.Visibility.ADVANCED)
|
||
default_keys = {str(k): k for k in default.schema}
|
||
advanced_keys = {str(k): k for k in advanced.schema}
|
||
assert default_keys["update_interval"].visibility is None
|
||
assert advanced_keys["update_interval"].visibility is cv.Visibility.ADVANCED
|
||
# The opt-in only touches update_interval — setup_priority
|
||
# still inherits its YAML_ONLY visibility from COMPONENT_SCHEMA
|
||
# in both shapes.
|
||
assert default_keys["setup_priority"].visibility is cv.Visibility.YAML_ONLY
|
||
assert advanced_keys["setup_priority"].visibility is cv.Visibility.YAML_ONLY
|
||
|
||
|
||
def test_polling_component_schema_no_default_ignores_visibility() -> None:
|
||
"""``visibility`` is silently ignored when the field is Required.
|
||
|
||
When ``default_update_interval=None`` the field becomes
|
||
``Required``. Hiding a Required field behind an advanced
|
||
disclosure is a UX hazard — a collapsed-by-default editor could
|
||
let the user submit without noticing the form has an unfilled
|
||
required field. The helper accepts the kwarg unconditionally
|
||
for caller ergonomics but doesn't honour it on this branch.
|
||
"""
|
||
schema = cv.polling_component_schema(None, visibility=cv.Visibility.ADVANCED)
|
||
keys = {str(k): k for k in schema.schema}
|
||
assert isinstance(keys["update_interval"], cv.Required)
|
||
assert keys["update_interval"].visibility is None
|
||
|
||
|
||
def test_visibility_marker_is_per_field_no_mutation() -> None:
|
||
"""Each field's ``visibility`` is recorded as the author wrote it.
|
||
|
||
Cascading semantics — "a stricter parent forces its descendants
|
||
at-least as strict" — live on the consumer side, not in the
|
||
marker itself. The schema marker stays as-written so consumers
|
||
can walk the parent chain and compute the effective visibility
|
||
themselves; mutating the marker would lose the per-field author
|
||
intent.
|
||
|
||
Pin both directions of the no-mutation contract: an inner
|
||
``YAML_ONLY`` under an ``ADVANCED`` parent stays ``YAML_ONLY``
|
||
on the marker (the consumer's effective-visibility cascade
|
||
would also report ``YAML_ONLY`` since it's stricter), and an
|
||
un-marked inner field stays ``None`` on the marker (the
|
||
cascade's job is to compute ``ADVANCED`` from the parent — a
|
||
detail this test deliberately doesn't pin, since it's a
|
||
consumer concern).
|
||
"""
|
||
inner_unset = cv.Optional("baz")
|
||
inner_yaml_only = cv.Optional("qux", visibility=cv.Visibility.YAML_ONLY)
|
||
parent = cv.Optional("foo", visibility=cv.Visibility.ADVANCED)
|
||
|
||
# Wire them into a nested schema — none of the markers' own
|
||
# ``visibility`` should change as a result.
|
||
schema = cv.Schema(
|
||
{
|
||
parent: cv.Schema(
|
||
{
|
||
inner_unset: cv.int_,
|
||
inner_yaml_only: cv.string,
|
||
}
|
||
)
|
||
}
|
||
)
|
||
assert schema # touch the schema so any deferred mutation runs
|
||
|
||
assert parent.visibility is cv.Visibility.ADVANCED
|
||
assert inner_unset.visibility is None
|
||
assert inner_yaml_only.visibility is cv.Visibility.YAML_ONLY
|
||
|
||
|
||
def _wrap_str(value: str) -> ESPHomeDataBase:
|
||
"""Wrap a raw string as an ESPHomeDataBase, mimicking a YAML-loaded value."""
|
||
return make_data_base(value)
|
||
|
||
|
||
def _set_core_target(platform: str, framework: str) -> None:
|
||
"""Set CORE target platform/framework for validators that depend on them."""
|
||
CORE.data[KEY_CORE] = {
|
||
KEY_TARGET_PLATFORM: platform,
|
||
KEY_TARGET_FRAMEWORK: framework,
|
||
}
|
||
|
||
|
||
def _set_framework_version(platform: str, framework: str, version: cv.Version) -> None:
|
||
"""Set CORE target platform/framework and framework version."""
|
||
_set_core_target(platform, framework)
|
||
CORE.data[KEY_CORE][KEY_FRAMEWORK_VERSION] = version
|
||
|
||
|
||
# ---------------------------------------------------------------------------
|
||
# Version
|
||
# ---------------------------------------------------------------------------
|
||
|
||
|
||
def test_version_str_with_extra() -> None:
|
||
assert str(cv.Version(1, 2, 3, "b1")) == "1.2.3-b1"
|
||
|
||
|
||
def test_version_str_without_extra() -> None:
|
||
assert str(cv.Version(1, 2, 3)) == "1.2.3"
|
||
|
||
|
||
def test_version_parse_valid() -> None:
|
||
version = cv.Version.parse("2024.5.1")
|
||
assert (version.major, version.minor, version.patch, version.extra) == (
|
||
2024,
|
||
5,
|
||
1,
|
||
"",
|
||
)
|
||
|
||
|
||
def test_version_parse_with_extra() -> None:
|
||
version = cv.Version.parse("2024.5.1-dev20240101")
|
||
assert version.extra == "dev20240101"
|
||
|
||
|
||
def test_version_parse_invalid() -> None:
|
||
with pytest.raises(ValueError, match="Not a valid version number"):
|
||
cv.Version.parse("not.a.version")
|
||
|
||
|
||
def test_version_is_beta() -> None:
|
||
assert cv.Version.parse("2024.5.0b1").is_beta is True
|
||
assert cv.Version.parse("2024.5.0").is_beta is False
|
||
|
||
|
||
def test_version_is_dev() -> None:
|
||
assert cv.Version.parse("2024.5.0-dev").is_dev is True
|
||
assert cv.Version.parse("2024.5.0").is_dev is False
|
||
|
||
|
||
# ---------------------------------------------------------------------------
|
||
# alphanumeric / valid_name / validate_id_name
|
||
# ---------------------------------------------------------------------------
|
||
|
||
|
||
def test_alphanumeric_none() -> None:
|
||
with pytest.raises(Invalid, match="string value is None"):
|
||
cv.alphanumeric(None)
|
||
|
||
|
||
def test_valid_name_vscode_no_substitution() -> None:
|
||
CORE.vscode = True
|
||
assert cv.valid_name("plainname") == "plainname"
|
||
|
||
|
||
def test_validate_id_name_empty() -> None:
|
||
with pytest.raises(Invalid, match="ID must not be empty"):
|
||
cv.validate_id_name("")
|
||
|
||
|
||
def test_validate_id_name_digit_first() -> None:
|
||
with pytest.raises(Invalid, match="First character in ID cannot be a digit"):
|
||
cv.validate_id_name("1abc")
|
||
|
||
|
||
def test_validate_id_name_vscode_no_substitution() -> None:
|
||
CORE.vscode = True
|
||
assert cv.validate_id_name("validid") == "validid"
|
||
|
||
|
||
def test_validate_id_name_reserved() -> None:
|
||
with pytest.raises(Invalid, match="reserved internally"):
|
||
cv.validate_id_name("alarm")
|
||
|
||
|
||
def test_validate_id_name_integration_conflict() -> None:
|
||
CORE.loaded_integrations = {"mqtt"}
|
||
with pytest.raises(
|
||
Invalid, match="conflicts with the name of an esphome integration"
|
||
):
|
||
cv.validate_id_name("mqtt")
|
||
|
||
|
||
# ---------------------------------------------------------------------------
|
||
# sub_device_id
|
||
# ---------------------------------------------------------------------------
|
||
|
||
|
||
def test_sub_device_id_schema_extract() -> None:
|
||
from esphome.core.config import Device
|
||
|
||
assert cv.sub_device_id(SCHEMA_EXTRACT) is Device
|
||
|
||
|
||
def test_sub_device_id_empty() -> None:
|
||
assert cv.sub_device_id(None) is None
|
||
assert cv.sub_device_id("") is None
|
||
|
||
|
||
def test_sub_device_id_valid() -> None:
|
||
result = cv.sub_device_id("my_device")
|
||
assert isinstance(result, ID)
|
||
assert result.id == "my_device"
|
||
|
||
|
||
# ---------------------------------------------------------------------------
|
||
# boolean_false / ensure_list
|
||
# ---------------------------------------------------------------------------
|
||
|
||
|
||
def test_boolean_false_valid() -> None:
|
||
assert cv.boolean_false(False) is False
|
||
assert cv.boolean_false("no") is False
|
||
|
||
|
||
def test_boolean_false_invalid() -> None:
|
||
with pytest.raises(Invalid, match="Expected boolean value to be false"):
|
||
cv.boolean_false(True)
|
||
|
||
|
||
def test_ensure_list_none() -> None:
|
||
assert cv.ensure_list(cv.int_)(None) == []
|
||
|
||
|
||
def test_ensure_list_empty_dict() -> None:
|
||
assert cv.ensure_list(cv.int_)({}) == []
|
||
|
||
|
||
def test_ensure_list_single_value() -> None:
|
||
assert cv.ensure_list(cv.int_)(5) == [5]
|
||
|
||
|
||
def test_ensure_list_actual_list() -> None:
|
||
assert cv.ensure_list(cv.int_)([1, 2, 3]) == [1, 2, 3]
|
||
|
||
|
||
# ---------------------------------------------------------------------------
|
||
# hex_int / int_to_hex_string / int_
|
||
# ---------------------------------------------------------------------------
|
||
|
||
|
||
def test_hex_int() -> None:
|
||
result = cv.hex_int(255)
|
||
assert result == 255
|
||
assert isinstance(result, HexInt)
|
||
|
||
|
||
def test_int_to_hex_string_int() -> None:
|
||
assert cv.int_to_hex_string(64) == "0x40"
|
||
|
||
|
||
def test_int_to_hex_string_passthrough() -> None:
|
||
assert cv.int_to_hex_string("already") == "already"
|
||
|
||
|
||
def test_int_float_whole() -> None:
|
||
assert cv.int_(5.0) == 5
|
||
|
||
|
||
def test_int_float_fractional() -> None:
|
||
with pytest.raises(Invalid, match="only accepts integers with no fractional part"):
|
||
cv.int_(5.5)
|
||
|
||
|
||
def test_int_hex_string() -> None:
|
||
assert cv.int_("0xFF") == 255
|
||
|
||
|
||
# ---------------------------------------------------------------------------
|
||
# int_range / float_range no-min branches
|
||
# ---------------------------------------------------------------------------
|
||
|
||
|
||
def test_int_range_no_min() -> None:
|
||
validator = cv.int_range(max=10)
|
||
assert validator(5) == 5
|
||
|
||
|
||
def test_float_range_no_min() -> None:
|
||
validator = cv.float_range(max=10.0)
|
||
assert validator(5.0) == 5.0
|
||
|
||
|
||
# ---------------------------------------------------------------------------
|
||
# use_id / declare_id / templatable
|
||
# ---------------------------------------------------------------------------
|
||
|
||
|
||
def test_use_id_schema_extract() -> None:
|
||
assert cv.use_id(int)(SCHEMA_EXTRACT) is int
|
||
|
||
|
||
def test_use_id_none() -> None:
|
||
result = cv.use_id(int)(None)
|
||
assert isinstance(result, ID)
|
||
assert result.is_declaration is False
|
||
|
||
|
||
def test_use_id_existing_id_passthrough() -> None:
|
||
existing = ID("foo", is_declaration=False, type=int)
|
||
assert cv.use_id(int)(existing) is existing
|
||
|
||
|
||
def test_use_id_from_string() -> None:
|
||
result = cv.use_id(int)("foo")
|
||
assert isinstance(result, ID)
|
||
assert result.id == "foo"
|
||
assert result.is_declaration is False
|
||
|
||
|
||
def test_declare_id_schema_extract() -> None:
|
||
assert cv.declare_id(int)(SCHEMA_EXTRACT) is int
|
||
|
||
|
||
def test_declare_id_none() -> None:
|
||
result = cv.declare_id(int)(None)
|
||
assert isinstance(result, ID)
|
||
assert result.is_declaration is True
|
||
|
||
|
||
def test_declare_id_from_string() -> None:
|
||
result = cv.declare_id(int)("foo")
|
||
assert result.id == "foo"
|
||
assert result.is_declaration is True
|
||
|
||
|
||
def test_templatable_schema_extract() -> None:
|
||
assert cv.templatable(cv.int_)(SCHEMA_EXTRACT) is cv.int_
|
||
|
||
|
||
def test_templatable_lambda() -> None:
|
||
result = cv.templatable(cv.int_)(Lambda("return 5;"))
|
||
assert isinstance(result, Lambda)
|
||
|
||
|
||
def test_templatable_plain_value() -> None:
|
||
assert cv.templatable(cv.int_)(5) == 5
|
||
|
||
|
||
def test_templatable_dict_validators() -> None:
|
||
validator = cv.templatable({cv.Required("x"): cv.int_})
|
||
assert validator({"x": 5}) == {"x": 5}
|
||
|
||
|
||
# ---------------------------------------------------------------------------
|
||
# only_on / only_with_framework
|
||
# ---------------------------------------------------------------------------
|
||
|
||
|
||
def test_only_on_list_platform_match() -> None:
|
||
_set_core_target(PLATFORM_ESP32, "arduino")
|
||
validator = cv.only_on([PLATFORM_ESP32, PLATFORM_ESP8266])
|
||
assert validator("x") == "x"
|
||
|
||
|
||
def test_only_on_wrong_platform() -> None:
|
||
_set_core_target(PLATFORM_ESP8266, "arduino")
|
||
validator = cv.only_on(PLATFORM_ESP32)
|
||
with pytest.raises(Invalid, match="only available on"):
|
||
validator("x")
|
||
|
||
|
||
def test_only_with_framework_match() -> None:
|
||
_set_core_target(PLATFORM_ESP32, "arduino")
|
||
validator = cv.only_with_framework([Framework.ARDUINO])
|
||
assert validator("x") == "x"
|
||
|
||
|
||
def test_only_with_framework_mismatch_with_suggestion() -> None:
|
||
_set_core_target(PLATFORM_ESP32, "esp-idf")
|
||
validator = cv.only_with_framework(
|
||
Framework.ARDUINO,
|
||
suggestions={Framework.ESP_IDF: ("some_component", "some/path")},
|
||
)
|
||
with pytest.raises(Invalid, match="some/path"):
|
||
validator("x")
|
||
|
||
|
||
def test_only_with_framework_mismatch_no_suggestion() -> None:
|
||
_set_core_target(PLATFORM_ESP32, "esp-idf")
|
||
validator = cv.only_with_framework(Framework.ARDUINO)
|
||
with pytest.raises(Invalid, match="only available with framework"):
|
||
validator("x")
|
||
|
||
|
||
def test_only_with_framework_suggestion_without_docs_path() -> None:
|
||
_set_core_target(PLATFORM_ESP32, "esp-idf")
|
||
validator = cv.only_with_framework(
|
||
Framework.ARDUINO,
|
||
suggestions={Framework.ESP_IDF: ("some_component", None)},
|
||
)
|
||
with pytest.raises(Invalid, match="Please use 'some_component'"):
|
||
validator("x")
|
||
|
||
|
||
# ---------------------------------------------------------------------------
|
||
# has_*_key helpers
|
||
# ---------------------------------------------------------------------------
|
||
|
||
|
||
def test_has_at_least_one_key_not_dict() -> None:
|
||
with pytest.raises(Invalid, match="expected dictionary"):
|
||
cv.has_at_least_one_key("a", "b")([])
|
||
|
||
|
||
def test_has_at_least_one_key_none() -> None:
|
||
with pytest.raises(Invalid, match="at least one of"):
|
||
cv.has_at_least_one_key("a", "b")({"c": 1})
|
||
|
||
|
||
def test_has_at_least_one_key_ok() -> None:
|
||
obj = {"a": 1}
|
||
assert cv.has_at_least_one_key("a", "b")(obj) is obj
|
||
|
||
|
||
def test_has_exactly_one_key_not_dict() -> None:
|
||
with pytest.raises(Invalid, match="expected dictionary"):
|
||
cv.has_exactly_one_key("a", "b")("notdict")
|
||
|
||
|
||
def test_has_exactly_one_key_too_many() -> None:
|
||
with pytest.raises(Invalid, match="Cannot specify more than one"):
|
||
cv.has_exactly_one_key("a", "b")({"a": 1, "b": 2})
|
||
|
||
|
||
def test_has_exactly_one_key_too_few() -> None:
|
||
with pytest.raises(Invalid, match="Must contain exactly one"):
|
||
cv.has_exactly_one_key("a", "b")({"c": 1})
|
||
|
||
|
||
def test_has_exactly_one_key_ok() -> None:
|
||
obj = {"a": 1}
|
||
assert cv.has_exactly_one_key("a", "b")(obj) is obj
|
||
|
||
|
||
def test_has_at_most_one_key_not_dict() -> None:
|
||
with pytest.raises(Invalid, match="expected dictionary"):
|
||
cv.has_at_most_one_key("a", "b")(5)
|
||
|
||
|
||
def test_has_at_most_one_key_too_many() -> None:
|
||
with pytest.raises(vol.MultipleInvalid, match="Cannot specify more than one"):
|
||
cv.has_at_most_one_key("a", "b")({"a": 1, "b": 2})
|
||
|
||
|
||
def test_has_at_most_one_key_ok() -> None:
|
||
obj = {"a": 1}
|
||
assert cv.has_at_most_one_key("a", "b")(obj) is obj
|
||
|
||
|
||
def test_has_none_or_all_keys_not_dict() -> None:
|
||
with pytest.raises(Invalid, match="expected dictionary"):
|
||
cv.has_none_or_all_keys("a", "b")(5)
|
||
|
||
|
||
def test_has_none_or_all_keys_partial() -> None:
|
||
with pytest.raises(Invalid, match="none or all"):
|
||
cv.has_none_or_all_keys("a", "b")({"a": 1})
|
||
|
||
|
||
def test_has_none_or_all_keys_all() -> None:
|
||
obj = {"a": 1, "b": 2}
|
||
assert cv.has_none_or_all_keys("a", "b")(obj) is obj
|
||
|
||
|
||
def test_has_none_or_all_keys_none() -> None:
|
||
obj = {"c": 3}
|
||
assert cv.has_none_or_all_keys("a", "b")(obj) is obj
|
||
|
||
|
||
# ---------------------------------------------------------------------------
|
||
# time_period_str_colon / time_period_str_unit
|
||
# ---------------------------------------------------------------------------
|
||
|
||
|
||
def test_time_period_str_colon_int() -> None:
|
||
with pytest.raises(Invalid, match="wrap time values in quotes"):
|
||
cv.time_period_str_colon(5)
|
||
|
||
|
||
def test_time_period_str_colon_not_str() -> None:
|
||
with pytest.raises(Invalid):
|
||
cv.time_period_str_colon([1, 2])
|
||
|
||
|
||
def test_time_period_str_colon_bad_value() -> None:
|
||
with pytest.raises(Invalid):
|
||
cv.time_period_str_colon("aa:bb")
|
||
|
||
|
||
def test_time_period_str_colon_hh_mm() -> None:
|
||
assert cv.time_period_str_colon("01:30") == TimePeriod(hours=1, minutes=30)
|
||
|
||
|
||
def test_time_period_str_colon_hh_mm_ss() -> None:
|
||
assert cv.time_period_str_colon("01:30:15") == TimePeriod(
|
||
hours=1, minutes=30, seconds=15
|
||
)
|
||
|
||
|
||
def test_time_period_str_colon_too_many_parts() -> None:
|
||
with pytest.raises(Invalid):
|
||
cv.time_period_str_colon("1:2:3:4")
|
||
|
||
|
||
def test_time_period_str_unit_int() -> None:
|
||
with pytest.raises(Invalid, match=r"no time \*unit\*"):
|
||
cv.time_period_str_unit(5)
|
||
|
||
|
||
def test_time_period_str_unit_timeperiod_input() -> None:
|
||
assert cv.time_period_str_unit(TimePeriod(seconds=5)) == TimePeriod(seconds=5)
|
||
|
||
|
||
def test_time_period_str_unit_not_str() -> None:
|
||
with pytest.raises(Invalid, match="Expected string for time period"):
|
||
cv.time_period_str_unit([1])
|
||
|
||
|
||
def test_time_period_str_unit_no_match() -> None:
|
||
with pytest.raises(Invalid, match="Expected time period with unit"):
|
||
cv.time_period_str_unit("5/3")
|
||
|
||
|
||
def test_time_period_str_unit_empty_mantissa() -> None:
|
||
with pytest.raises(Invalid):
|
||
cv.time_period_str_unit("s")
|
||
|
||
|
||
# ---------------------------------------------------------------------------
|
||
# time_period_in_* converters
|
||
# ---------------------------------------------------------------------------
|
||
|
||
|
||
def test_time_period_in_milliseconds_too_precise() -> None:
|
||
with pytest.raises(Invalid, match="Maximum precision is milliseconds"):
|
||
cv.time_period_in_milliseconds_(TimePeriod(microseconds=5))
|
||
|
||
|
||
def test_time_period_in_microseconds_too_precise() -> None:
|
||
with pytest.raises(Invalid, match="Maximum precision is microseconds"):
|
||
cv.time_period_in_microseconds_(TimePeriod(nanoseconds=5))
|
||
|
||
|
||
def test_time_period_in_microseconds_ok() -> None:
|
||
assert cv.time_period_in_microseconds_(
|
||
TimePeriod(microseconds=5)
|
||
) == TimePeriodMicroseconds(microseconds=5)
|
||
|
||
|
||
def test_time_period_in_nanoseconds_ok() -> None:
|
||
assert cv.time_period_in_nanoseconds_(
|
||
TimePeriod(nanoseconds=5)
|
||
) == TimePeriodNanoseconds(nanoseconds=5)
|
||
|
||
|
||
@pytest.mark.parametrize(
|
||
"value",
|
||
[
|
||
TimePeriod(nanoseconds=1),
|
||
TimePeriod(microseconds=1),
|
||
TimePeriod(milliseconds=1),
|
||
],
|
||
)
|
||
def test_time_period_in_seconds_too_precise(value: TimePeriod) -> None:
|
||
with pytest.raises(Invalid, match="Maximum precision is seconds"):
|
||
cv.time_period_in_seconds_(value)
|
||
|
||
|
||
def test_time_period_in_seconds_ok() -> None:
|
||
assert cv.time_period_in_seconds_(TimePeriod(seconds=5)) == TimePeriodSeconds(
|
||
seconds=5
|
||
)
|
||
|
||
|
||
@pytest.mark.parametrize(
|
||
"value",
|
||
[
|
||
TimePeriod(nanoseconds=1),
|
||
TimePeriod(microseconds=1),
|
||
TimePeriod(milliseconds=1),
|
||
TimePeriod(seconds=1),
|
||
],
|
||
)
|
||
def test_time_period_in_minutes_too_precise(value: TimePeriod) -> None:
|
||
with pytest.raises(Invalid, match="Maximum precision is minutes"):
|
||
cv.time_period_in_minutes_(value)
|
||
|
||
|
||
def test_time_period_in_minutes_ok() -> None:
|
||
assert cv.time_period_in_minutes_(TimePeriod(minutes=5)) == TimePeriodMinutes(
|
||
minutes=5
|
||
)
|
||
|
||
|
||
# ---------------------------------------------------------------------------
|
||
# time_of_day / date_time
|
||
# ---------------------------------------------------------------------------
|
||
|
||
|
||
def test_time_of_day_valid() -> None:
|
||
assert cv.time_of_day("12:34:56") == {
|
||
CONF_HOUR: 12,
|
||
CONF_MINUTE: 34,
|
||
CONF_SECOND: 56,
|
||
}
|
||
|
||
|
||
def test_date_time_dict_input() -> None:
|
||
validator = cv.date_time(date=True, time=False)
|
||
result = validator({CONF_YEAR: 2024, CONF_MONTH: 5, CONF_DAY: 1})
|
||
assert result[CONF_YEAR] == 2024
|
||
|
||
|
||
def test_date_time_date_only_string() -> None:
|
||
validator = cv.date_time(date=True, time=False)
|
||
assert validator("2024-5-1") == {CONF_YEAR: 2024, CONF_MONTH: 5, CONF_DAY: 1}
|
||
|
||
|
||
def test_date_time_date_and_time_string() -> None:
|
||
validator = cv.date_time(date=True, time=True)
|
||
result = validator("2024-05-01 13:30:00")
|
||
assert result[CONF_HOUR] == 13
|
||
assert result[CONF_YEAR] == 2024
|
||
|
||
|
||
def test_date_time_invalid_format() -> None:
|
||
validator = cv.date_time(date=False, time=True)
|
||
with pytest.raises(Invalid, match="Invalid time"):
|
||
validator("notatime")
|
||
|
||
|
||
def test_date_time_ampm() -> None:
|
||
validator = cv.date_time(date=False, time=True)
|
||
assert validator("1:30 PM")[CONF_HOUR] == 13
|
||
|
||
|
||
def test_date_time_no_seconds() -> None:
|
||
validator = cv.date_time(date=False, time=True)
|
||
assert validator("13:30")[CONF_SECOND] == 0
|
||
|
||
|
||
def test_date_time_strptime_error() -> None:
|
||
validator = cv.date_time(date=False, time=True)
|
||
with pytest.raises(Invalid, match="Invalid time"):
|
||
validator("25:99")
|
||
|
||
|
||
# ---------------------------------------------------------------------------
|
||
# mac_address / uuid
|
||
# ---------------------------------------------------------------------------
|
||
|
||
|
||
def test_mac_address_valid() -> None:
|
||
result = cv.mac_address("AA:BB:CC:DD:EE:FF")
|
||
assert isinstance(result, MACAddress)
|
||
|
||
|
||
def test_mac_address_wrong_parts() -> None:
|
||
with pytest.raises(Invalid, match="6 : .colon. separated parts"):
|
||
cv.mac_address("AA:BB:CC")
|
||
|
||
|
||
def test_mac_address_wrong_length() -> None:
|
||
with pytest.raises(Invalid, match="format XX:XX"):
|
||
cv.mac_address("A:BB:CC:DD:EE:FF")
|
||
|
||
|
||
def test_mac_address_non_hex() -> None:
|
||
with pytest.raises(Invalid, match="hexadecimal values"):
|
||
cv.mac_address("GG:BB:CC:DD:EE:FF")
|
||
|
||
|
||
def test_uuid_valid() -> None:
|
||
result = cv.uuid("12345678-1234-5678-1234-567812345678")
|
||
assert str(result) == "12345678-1234-5678-1234-567812345678"
|
||
|
||
|
||
# ---------------------------------------------------------------------------
|
||
# float_with_unit family
|
||
# ---------------------------------------------------------------------------
|
||
|
||
|
||
def test_float_with_unit_optional_unit_plain_float() -> None:
|
||
assert cv.angle("1.5") == 1.5
|
||
|
||
|
||
def test_float_with_unit_optional_unit_with_suffix() -> None:
|
||
assert cv.angle("45deg") == 45.0
|
||
|
||
|
||
def test_float_with_unit_with_suffix() -> None:
|
||
assert cv.frequency("10kHz") == 10000.0
|
||
|
||
|
||
def test_float_with_unit_no_match() -> None:
|
||
with pytest.raises(Invalid, match="Expected frequency with unit"):
|
||
cv.frequency("!!")
|
||
|
||
|
||
def test_float_with_unit_invalid_suffix() -> None:
|
||
with pytest.raises(Invalid, match="Invalid frequency suffix"):
|
||
cv.frequency("10xHz")
|
||
|
||
|
||
def test_temperature_celsius() -> None:
|
||
assert cv.temperature("25°C") == 25.0
|
||
|
||
|
||
def test_temperature_kelvin() -> None:
|
||
assert cv.temperature("300K") == pytest.approx(300 - 273.15)
|
||
|
||
|
||
def test_temperature_fahrenheit() -> None:
|
||
assert cv.temperature("32°F") == pytest.approx(0.0)
|
||
|
||
|
||
def test_temperature_invalid() -> None:
|
||
with pytest.raises(Invalid, match="Invalid temperature suffix"):
|
||
cv.temperature("5x")
|
||
|
||
|
||
def test_temperature_delta_celsius() -> None:
|
||
assert cv.temperature_delta("5°C") == 5.0
|
||
|
||
|
||
def test_temperature_delta_kelvin() -> None:
|
||
assert cv.temperature_delta("5K") == 5.0
|
||
|
||
|
||
def test_temperature_delta_fahrenheit() -> None:
|
||
assert cv.temperature_delta("9°F") == pytest.approx(5.0)
|
||
|
||
|
||
def test_temperature_delta_invalid() -> None:
|
||
with pytest.raises(Invalid, match="Invalid temperature suffix"):
|
||
cv.temperature_delta("5x")
|
||
|
||
|
||
def test_color_temperature_mireds() -> None:
|
||
assert cv.color_temperature("153 mireds") == pytest.approx(153.0)
|
||
|
||
|
||
def test_color_temperature_kelvin() -> None:
|
||
assert cv.color_temperature("6536 K") == pytest.approx(1000000.0 / 6536)
|
||
|
||
|
||
def test_color_temperature_negative() -> None:
|
||
with pytest.raises(Invalid, match="cannot be negative"):
|
||
cv.color_temperature("-1 mireds")
|
||
|
||
|
||
# ---------------------------------------------------------------------------
|
||
# validate_bytes
|
||
# ---------------------------------------------------------------------------
|
||
|
||
|
||
def test_validate_bytes_plain() -> None:
|
||
assert cv.validate_bytes("100") == 100
|
||
|
||
|
||
def test_validate_bytes_with_unit() -> None:
|
||
assert cv.validate_bytes("2kB") == 2000
|
||
|
||
|
||
def test_validate_bytes_no_match() -> None:
|
||
with pytest.raises(Invalid, match="Expected number of bytes"):
|
||
cv.validate_bytes("abc")
|
||
|
||
|
||
def test_validate_bytes_invalid_suffix() -> None:
|
||
with pytest.raises(Invalid, match="Invalid metric suffix"):
|
||
cv.validate_bytes("5xx")
|
||
|
||
|
||
def test_validate_bytes_negative_exponent() -> None:
|
||
with pytest.raises(Invalid, match="positive exponents"):
|
||
cv.validate_bytes("5m")
|
||
|
||
|
||
# ---------------------------------------------------------------------------
|
||
# hostname / domain / domain_name / ssid
|
||
# ---------------------------------------------------------------------------
|
||
|
||
|
||
def test_hostname_valid() -> None:
|
||
assert cv.hostname("my-host01") == "my-host01"
|
||
|
||
|
||
def test_hostname_invalid() -> None:
|
||
with pytest.raises(Invalid, match="Invalid hostname"):
|
||
cv.hostname("invalid_host!")
|
||
|
||
|
||
def test_domain_valid_name() -> None:
|
||
assert cv.domain("example.com") == "example.com"
|
||
|
||
|
||
def test_domain_ip_fallback() -> None:
|
||
assert cv.domain("::1") == "::1"
|
||
|
||
|
||
def test_domain_invalid() -> None:
|
||
with pytest.raises(Invalid, match="Invalid domain"):
|
||
cv.domain("::not::valid::")
|
||
|
||
|
||
def test_domain_name_empty() -> None:
|
||
assert cv.domain_name("") == ""
|
||
|
||
|
||
def test_domain_name_valid() -> None:
|
||
assert cv.domain_name(".local") == ".local"
|
||
|
||
|
||
def test_domain_name_no_leading_dot() -> None:
|
||
with pytest.raises(Invalid, match="must start with"):
|
||
cv.domain_name("local")
|
||
|
||
|
||
def test_domain_name_double_dot() -> None:
|
||
with pytest.raises(Invalid, match="single"):
|
||
cv.domain_name("..local")
|
||
|
||
|
||
def test_domain_name_invalid_char() -> None:
|
||
with pytest.raises(Invalid, match="alphanumeric"):
|
||
cv.domain_name(".local!")
|
||
|
||
|
||
def test_ssid_valid() -> None:
|
||
assert cv.ssid("MyNetwork") == "MyNetwork"
|
||
|
||
|
||
def test_ssid_empty() -> None:
|
||
with pytest.raises(Invalid, match="can't be empty"):
|
||
cv.ssid("")
|
||
|
||
|
||
def test_ssid_too_long() -> None:
|
||
with pytest.raises(Invalid, match="longer than 32"):
|
||
cv.ssid("x" * 33)
|
||
|
||
|
||
# ---------------------------------------------------------------------------
|
||
# IP address / network validators
|
||
# ---------------------------------------------------------------------------
|
||
|
||
|
||
def test_ipv6address_valid() -> None:
|
||
assert str(cv.ipv6address("::1")) == "::1"
|
||
|
||
|
||
def test_ipv6address_invalid() -> None:
|
||
with pytest.raises(Invalid, match="not a valid IPv6 address"):
|
||
cv.ipv6address("not-ipv6")
|
||
|
||
|
||
def test_ipv4address_multi_broadcast_multicast() -> None:
|
||
assert str(cv.ipv4address_multi_broadcast("224.0.0.1")) == "224.0.0.1"
|
||
|
||
|
||
def test_ipv4address_multi_broadcast_broadcast() -> None:
|
||
assert str(cv.ipv4address_multi_broadcast("255.255.255.255")) == "255.255.255.255"
|
||
|
||
|
||
def test_ipv4address_multi_broadcast_invalid() -> None:
|
||
with pytest.raises(Invalid, match="not a multicast"):
|
||
cv.ipv4address_multi_broadcast("192.168.0.1")
|
||
|
||
|
||
def test_ipv4network_valid() -> None:
|
||
assert str(cv.ipv4network("192.168.0.0/24")) == "192.168.0.0/24"
|
||
|
||
|
||
def test_ipv4network_invalid() -> None:
|
||
with pytest.raises(Invalid, match="not a valid IPv4 network"):
|
||
cv.ipv4network("notanetwork")
|
||
|
||
|
||
def test_ipv6network_valid() -> None:
|
||
assert str(cv.ipv6network("2001:db8::/32")) == "2001:db8::/32"
|
||
|
||
|
||
def test_ipv6network_invalid() -> None:
|
||
with pytest.raises(Invalid, match="not a valid IPv6 network"):
|
||
cv.ipv6network("notanetwork")
|
||
|
||
|
||
def test_ipnetwork_valid() -> None:
|
||
assert str(cv.ipnetwork("10.0.0.0/8")) == "10.0.0.0/8"
|
||
|
||
|
||
def test_ipnetwork_invalid() -> None:
|
||
with pytest.raises(Invalid, match="not a valid IP network"):
|
||
cv.ipnetwork("notanetwork")
|
||
|
||
|
||
# ---------------------------------------------------------------------------
|
||
# MQTT topic validators
|
||
# ---------------------------------------------------------------------------
|
||
|
||
|
||
def test_valid_topic_none() -> None:
|
||
assert cv._valid_topic(None) == ""
|
||
|
||
|
||
def test_valid_topic_dict() -> None:
|
||
with pytest.raises(Invalid, match="dictionary with topic"):
|
||
cv._valid_topic({"a": 1})
|
||
|
||
|
||
def test_valid_topic_unicode_error() -> None:
|
||
with pytest.raises(Invalid, match="valid UTF-8"):
|
||
cv._valid_topic("\ud800")
|
||
|
||
|
||
def test_valid_topic_empty() -> None:
|
||
with pytest.raises(Invalid, match="must not be empty"):
|
||
cv._valid_topic("")
|
||
|
||
|
||
def test_valid_topic_too_long() -> None:
|
||
with pytest.raises(Invalid, match="not be longer than 65535"):
|
||
cv._valid_topic("x" * 65536)
|
||
|
||
|
||
def test_valid_topic_null_char() -> None:
|
||
with pytest.raises(Invalid, match="null character"):
|
||
cv._valid_topic("a\0b")
|
||
|
||
|
||
def test_subscribe_topic_valid() -> None:
|
||
assert cv.subscribe_topic("home/+/temp") == "home/+/temp"
|
||
|
||
|
||
def test_subscribe_topic_multilevel() -> None:
|
||
assert cv.subscribe_topic("home/#") == "home/#"
|
||
|
||
|
||
def test_subscribe_topic_bad_plus() -> None:
|
||
with pytest.raises(Invalid, match="Single-level wildcard"):
|
||
cv.subscribe_topic("home/a+/temp")
|
||
|
||
|
||
def test_subscribe_topic_hash_not_last() -> None:
|
||
with pytest.raises(Invalid, match="Multi-level wildcard must be the last"):
|
||
cv.subscribe_topic("home/#/temp")
|
||
|
||
|
||
def test_subscribe_topic_hash_not_after_separator() -> None:
|
||
with pytest.raises(Invalid, match="must be after a topic level separator"):
|
||
cv.subscribe_topic("home#")
|
||
|
||
|
||
def test_publish_topic_valid() -> None:
|
||
assert cv.publish_topic("home/temp") == "home/temp"
|
||
|
||
|
||
def test_publish_topic_wildcard() -> None:
|
||
with pytest.raises(Invalid, match="Wildcards can not be used"):
|
||
cv.publish_topic("home/+")
|
||
|
||
|
||
def test_mqtt_payload_none() -> None:
|
||
assert cv.mqtt_payload(None) == ""
|
||
|
||
|
||
def test_mqtt_payload_value() -> None:
|
||
assert cv.mqtt_payload("hello") == "hello"
|
||
|
||
|
||
def test_mqtt_qos_valid() -> None:
|
||
assert cv.mqtt_qos("1") == 1
|
||
|
||
|
||
def test_mqtt_qos_not_int() -> None:
|
||
with pytest.raises(Invalid, match="must be integer"):
|
||
cv.mqtt_qos("abc")
|
||
|
||
|
||
def test_mqtt_qos_out_of_range() -> None:
|
||
with pytest.raises(Invalid):
|
||
cv.mqtt_qos(5)
|
||
|
||
|
||
# ---------------------------------------------------------------------------
|
||
# requires_component / conflicts_with_component
|
||
# ---------------------------------------------------------------------------
|
||
|
||
|
||
def test_requires_component_loaded() -> None:
|
||
CORE.loaded_integrations = {"mqtt"}
|
||
assert cv.requires_component("mqtt")("x") == "x"
|
||
|
||
|
||
def test_requires_component_not_loaded() -> None:
|
||
CORE.loaded_integrations = set()
|
||
with pytest.raises(Invalid, match="requires component mqtt"):
|
||
cv.requires_component("mqtt")("x")
|
||
|
||
|
||
def test_conflicts_with_component_loaded() -> None:
|
||
CORE.loaded_integrations = {"mqtt"}
|
||
with pytest.raises(Invalid, match="not compatible with component mqtt"):
|
||
cv.conflicts_with_component("mqtt")("x")
|
||
|
||
|
||
def test_conflicts_with_component_not_loaded() -> None:
|
||
CORE.loaded_integrations = set()
|
||
assert cv.conflicts_with_component("mqtt")("x") == "x"
|
||
|
||
|
||
# ---------------------------------------------------------------------------
|
||
# percentage_int / invalid / valid
|
||
# ---------------------------------------------------------------------------
|
||
|
||
|
||
def test_percentage_int_with_percent() -> None:
|
||
assert cv.percentage_int("50%") == 50
|
||
|
||
|
||
def test_percentage_int_plain() -> None:
|
||
assert cv.percentage_int(50) == 50
|
||
|
||
|
||
def test_invalid_always_raises() -> None:
|
||
with pytest.raises(Invalid, match="my message"):
|
||
cv.invalid("my message")("anything")
|
||
|
||
|
||
def test_valid_returns_value() -> None:
|
||
obj = object()
|
||
assert cv.valid(obj) is obj
|
||
|
||
|
||
# ---------------------------------------------------------------------------
|
||
# prepend_path / remove_prepend_path
|
||
# ---------------------------------------------------------------------------
|
||
|
||
|
||
def test_prepend_path_single() -> None:
|
||
with pytest.raises(Invalid) as exc_info, cv.prepend_path("foo"):
|
||
raise Invalid("bad")
|
||
assert list(exc_info.value.path) == ["foo"]
|
||
|
||
|
||
def test_prepend_path_list() -> None:
|
||
with pytest.raises(Invalid) as exc_info, cv.prepend_path(["a", "b"]):
|
||
raise Invalid("bad")
|
||
assert list(exc_info.value.path) == ["a", "b"]
|
||
|
||
|
||
def test_remove_prepend_path_matching() -> None:
|
||
with pytest.raises(Invalid) as exc_info, cv.remove_prepend_path(["a"]):
|
||
raise Invalid("bad", path=["a", "b"])
|
||
assert list(exc_info.value.path) == ["b"]
|
||
|
||
|
||
def test_remove_prepend_path_non_matching() -> None:
|
||
with pytest.raises(Invalid) as exc_info, cv.remove_prepend_path("x"):
|
||
raise Invalid("bad", path=["a", "b"])
|
||
assert list(exc_info.value.path) == ["a", "b"]
|
||
|
||
|
||
# ---------------------------------------------------------------------------
|
||
# one_of / enum
|
||
# ---------------------------------------------------------------------------
|
||
|
||
|
||
def test_one_of_extra_kwargs() -> None:
|
||
with pytest.raises(ValueError):
|
||
cv.one_of(1, 2, bogus=True)
|
||
|
||
|
||
def test_one_of_schema_extract() -> None:
|
||
assert cv.one_of("a", "b")(SCHEMA_EXTRACT) == ("a", "b")
|
||
|
||
|
||
def test_one_of_string_and_space() -> None:
|
||
assert cv.one_of("a_b", string=True, space="_")("a b") == "a_b"
|
||
|
||
|
||
def test_one_of_int() -> None:
|
||
assert cv.one_of(1, 2, int=True)("2") == 2
|
||
|
||
|
||
def test_one_of_float() -> None:
|
||
assert cv.one_of(1.0, 2.0, float=True)("2.0") == 2.0
|
||
|
||
|
||
def test_one_of_lower() -> None:
|
||
assert cv.one_of("abc", lower=True)("ABC") == "abc"
|
||
|
||
|
||
def test_one_of_upper() -> None:
|
||
assert cv.one_of("ABC", upper=True)("abc") == "ABC"
|
||
|
||
|
||
def test_one_of_unknown_with_suggestion() -> None:
|
||
with pytest.raises(Invalid, match="did you mean"):
|
||
cv.one_of("apple", "banana")("aple")
|
||
|
||
|
||
def test_one_of_unknown_no_suggestion() -> None:
|
||
with pytest.raises(Invalid, match="valid options are"):
|
||
cv.one_of("apple", "banana")("zzzzzz")
|
||
|
||
|
||
def test_enum_schema_extract() -> None:
|
||
mapping = {"a": 1, "b": 2}
|
||
assert cv.enum(mapping)(SCHEMA_EXTRACT) == mapping
|
||
|
||
|
||
def test_enum_valid() -> None:
|
||
mapping = {"a": 10, "b": 20}
|
||
result = cv.enum(mapping)("a")
|
||
assert result == "a"
|
||
assert result.enum_value == 10
|
||
|
||
|
||
# ---------------------------------------------------------------------------
|
||
# lambda_ / returning_lambda
|
||
# ---------------------------------------------------------------------------
|
||
|
||
|
||
def test_lambda_from_string() -> None:
|
||
result = cv.lambda_(_wrap_str("return 5;"))
|
||
assert isinstance(result, Lambda)
|
||
assert result.value == "return 5;"
|
||
|
||
|
||
def test_lambda_existing_lambda() -> None:
|
||
lam = Lambda("x")
|
||
assert cv.lambda_(lam) is lam
|
||
|
||
|
||
def test_lambda_entity_id_reference() -> None:
|
||
with pytest.raises(Invalid, match="entity-id-style ID"):
|
||
cv.lambda_(Lambda("return id(light.living_room);"))
|
||
|
||
|
||
def test_returning_lambda_valid() -> None:
|
||
assert isinstance(cv.returning_lambda(_wrap_str("return 5;")), Lambda)
|
||
|
||
|
||
def test_returning_lambda_no_return() -> None:
|
||
with pytest.raises(Invalid, match="return statement"):
|
||
cv.returning_lambda(Lambda("int x = 5;"))
|
||
|
||
|
||
# ---------------------------------------------------------------------------
|
||
# dimensions
|
||
# ---------------------------------------------------------------------------
|
||
|
||
|
||
def test_dimensions_list_valid() -> None:
|
||
assert cv.dimensions([320, 240]) == [320, 240]
|
||
|
||
|
||
def test_dimensions_list_wrong_length() -> None:
|
||
with pytest.raises(Invalid, match="length of two"):
|
||
cv.dimensions([1, 2, 3])
|
||
|
||
|
||
def test_dimensions_list_non_int() -> None:
|
||
with pytest.raises(Invalid, match="must be integers"):
|
||
cv.dimensions(["a", "b"])
|
||
|
||
|
||
def test_dimensions_list_non_positive() -> None:
|
||
with pytest.raises(Invalid, match="at least be 1"):
|
||
cv.dimensions([0, 240])
|
||
|
||
|
||
def test_dimensions_string_valid() -> None:
|
||
assert cv.dimensions("320x240") == [320, 240]
|
||
|
||
|
||
def test_dimensions_number_invalid() -> None:
|
||
with pytest.raises(Invalid, match="must be a string"):
|
||
cv.dimensions(320)
|
||
|
||
|
||
def test_dimensions_string_invalid() -> None:
|
||
with pytest.raises(Invalid, match="Only WIDTHxHEIGHT"):
|
||
cv.dimensions("notdimensions")
|
||
|
||
|
||
# ---------------------------------------------------------------------------
|
||
# entity_id
|
||
# ---------------------------------------------------------------------------
|
||
|
||
|
||
def test_entity_id_valid() -> None:
|
||
assert cv.entity_id("Light.Living_Room") == "light.living_room"
|
||
|
||
|
||
def test_entity_id_no_dot() -> None:
|
||
with pytest.raises(Invalid, match="exactly one dot"):
|
||
cv.entity_id("nodot")
|
||
|
||
|
||
def test_entity_id_invalid_char() -> None:
|
||
with pytest.raises(Invalid, match="Invalid character"):
|
||
cv.entity_id("light.living!room")
|
||
|
||
|
||
# ---------------------------------------------------------------------------
|
||
# extract_keys / typed_schema
|
||
# ---------------------------------------------------------------------------
|
||
|
||
|
||
def test_extract_keys_from_schema() -> None:
|
||
schema = cv.Schema({cv.Optional("b"): cv.int_, cv.Required("a"): cv.int_})
|
||
assert cv.extract_keys(schema) == ["a", "b"]
|
||
|
||
|
||
def test_extract_keys_from_dict() -> None:
|
||
assert cv.extract_keys({"x": cv.int_, cv.Optional("y"): cv.int_}) == ["x", "y"]
|
||
|
||
|
||
def test_extract_keys_invalid_key() -> None:
|
||
with pytest.raises(ValueError):
|
||
cv.extract_keys({1: cv.int_})
|
||
|
||
|
||
def test_typed_schema_basic() -> None:
|
||
schema = cv.typed_schema({"foo": cv.Schema({cv.Optional("x"): cv.int_})})
|
||
assert schema({"type": "foo", "x": 5}) == {"type": "foo", "x": 5}
|
||
|
||
|
||
def test_typed_schema_not_dict() -> None:
|
||
schema = cv.typed_schema({"foo": cv.Schema({})})
|
||
with pytest.raises(Invalid, match="must be dict"):
|
||
schema("notdict")
|
||
|
||
|
||
def test_typed_schema_missing_key() -> None:
|
||
schema = cv.typed_schema({"foo": cv.Schema({})})
|
||
with pytest.raises(Invalid, match="type not specified"):
|
||
schema({"x": 5})
|
||
|
||
|
||
def test_typed_schema_default_type() -> None:
|
||
schema = cv.typed_schema({"foo": cv.Schema({})}, default_type="foo")
|
||
assert schema({}) == {"type": "foo"}
|
||
|
||
|
||
def test_typed_schema_with_enum() -> None:
|
||
schema = cv.typed_schema({"foo": cv.Schema({})}, enum={"foo": 42})
|
||
result = schema({"type": "foo"})
|
||
assert result["type"] == "foo"
|
||
assert result["type"].enum_value == 42
|
||
|
||
|
||
# ---------------------------------------------------------------------------
|
||
# SplitDefault / OnlyWithout
|
||
# ---------------------------------------------------------------------------
|
||
|
||
|
||
def test_split_default_no_match() -> None:
|
||
_set_core_target(PLATFORM_ESP8266, "arduino")
|
||
schema = cv.Schema({cv.SplitDefault("key", esp32="value"): cv.string})
|
||
assert "key" not in schema({})
|
||
|
||
|
||
def test_only_without_component_absent() -> None:
|
||
CORE.loaded_integrations = set()
|
||
schema = cv.Schema({cv.OnlyWithout("key", "mqtt", default="dval"): cv.string})
|
||
assert schema({})["key"] == "dval"
|
||
|
||
|
||
def test_only_without_component_present() -> None:
|
||
CORE.loaded_integrations = {"mqtt"}
|
||
schema = cv.Schema({cv.OnlyWithout("key", "mqtt", default="dval"): cv.string})
|
||
assert "key" not in schema({})
|
||
|
||
|
||
# ---------------------------------------------------------------------------
|
||
# _entity_base_validator / ensure_schema
|
||
# ---------------------------------------------------------------------------
|
||
|
||
|
||
def test_entity_base_validator_name_present() -> None:
|
||
result = cv._entity_base_validator({CONF_NAME: "My Name"})
|
||
assert result[CONF_NAME] == "My Name"
|
||
|
||
|
||
def test_entity_base_validator_neither() -> None:
|
||
with pytest.raises(Invalid, match="'id:' or 'name:' is required"):
|
||
cv._entity_base_validator({})
|
||
|
||
|
||
def test_entity_base_validator_id_not_manual() -> None:
|
||
config = {CONF_ID: ID("auto", is_declaration=True, type=int, is_manual=False)}
|
||
with pytest.raises(Invalid, match="'id:' or 'name:' is required"):
|
||
cv._entity_base_validator(config)
|
||
|
||
|
||
def test_entity_base_validator_id_manual() -> None:
|
||
config = {CONF_ID: ID("myid", is_declaration=True, type=int, is_manual=True)}
|
||
result = cv._entity_base_validator(config)
|
||
assert result[CONF_NAME] == "myid"
|
||
assert result[CONF_INTERNAL] is True
|
||
|
||
|
||
def test_entity_base_validator_name_none() -> None:
|
||
result = cv._entity_base_validator({CONF_NAME: None})
|
||
assert result[CONF_NAME] == ""
|
||
|
||
|
||
def test_ensure_schema_passthrough() -> None:
|
||
schema = cv.Schema({})
|
||
assert cv.ensure_schema(schema) is schema
|
||
|
||
|
||
def test_ensure_schema_wraps() -> None:
|
||
result = cv.ensure_schema({cv.Optional("x"): cv.int_})
|
||
assert isinstance(result, cv.Schema)
|
||
|
||
|
||
# ---------------------------------------------------------------------------
|
||
# validate_registry_entry
|
||
# ---------------------------------------------------------------------------
|
||
|
||
|
||
def _make_registry(*names: str, type_id: object = int) -> Registry:
|
||
registry = Registry()
|
||
for name in names:
|
||
registry.register(name, type_id, cv.Schema({cv.Optional("param"): cv.int_}))(
|
||
lambda: None
|
||
)
|
||
return registry
|
||
|
||
|
||
def test_validate_registry_entry_string_shorthand() -> None:
|
||
registry = _make_registry("foo")
|
||
result = cv.validate_registry_entry("action", registry)("foo")
|
||
assert "foo" in result
|
||
|
||
|
||
def test_validate_registry_entry_not_mapping() -> None:
|
||
registry = _make_registry()
|
||
with pytest.raises(Invalid, match="must consist of key-value mapping"):
|
||
cv.validate_registry_entry("action", registry)(5)
|
||
|
||
|
||
def test_validate_registry_entry_missing_key() -> None:
|
||
registry = _make_registry()
|
||
with pytest.raises(Invalid, match="Key missing"):
|
||
cv.validate_registry_entry("action", registry)({})
|
||
|
||
|
||
def test_validate_registry_entry_unknown_key() -> None:
|
||
registry = _make_registry()
|
||
with pytest.raises(Invalid, match="Unable to find action"):
|
||
cv.validate_registry_entry("action", registry)({"unknown": {}})
|
||
|
||
|
||
def test_validate_registry_entry_two_keys() -> None:
|
||
registry = _make_registry("foo", "bar")
|
||
with pytest.raises(Invalid, match="Cannot have two action"):
|
||
cv.validate_registry_entry("action", registry)({"foo": {}, "bar": {}})
|
||
|
||
|
||
def test_validate_registry_entry_none_value() -> None:
|
||
registry = _make_registry("foo")
|
||
result = cv.validate_registry_entry("action", registry)({"foo": None})
|
||
assert "foo" in result
|
||
|
||
|
||
def test_validate_registry_entry_no_type_id() -> None:
|
||
registry = _make_registry("foo", type_id=None)
|
||
result = cv.validate_registry_entry("action", registry)({"foo": {}})
|
||
assert "foo" in result
|
||
|
||
|
||
# ---------------------------------------------------------------------------
|
||
# maybe_simple_value / entity_category
|
||
# ---------------------------------------------------------------------------
|
||
|
||
|
||
def test_maybe_simple_value_schema_extract() -> None:
|
||
schema = cv.Schema({cv.Required(CONF_VALUE): cv.string})
|
||
validator, key = cv.maybe_simple_value(schema)(SCHEMA_EXTRACT)
|
||
assert key == CONF_VALUE
|
||
|
||
|
||
def test_maybe_simple_value_dict_with_key() -> None:
|
||
schema = cv.Schema({cv.Required(CONF_VALUE): cv.string})
|
||
assert cv.maybe_simple_value(schema)({"value": "x"}) == {"value": "x"}
|
||
|
||
|
||
def test_maybe_simple_value_plain() -> None:
|
||
schema = cv.Schema({cv.Required(CONF_VALUE): cv.string})
|
||
assert cv.maybe_simple_value(schema)("x") == {"value": "x"}
|
||
|
||
|
||
def test_maybe_simple_value_custom_key() -> None:
|
||
schema = cv.Schema({cv.Required("name"): cv.string})
|
||
assert cv.maybe_simple_value(schema, key="name")({"name": "x"}) == {"name": "x"}
|
||
|
||
|
||
def test_entity_category_valid() -> None:
|
||
assert cv.entity_category("config") == "config"
|
||
|
||
|
||
def test_entity_category_invalid() -> None:
|
||
with pytest.raises(Invalid):
|
||
cv.entity_category("bogus")
|
||
|
||
|
||
# ---------------------------------------------------------------------------
|
||
# url / git_ref / source_refresh / version helpers
|
||
# ---------------------------------------------------------------------------
|
||
|
||
|
||
def test_url_valid() -> None:
|
||
assert cv.url("https://example.com/path") == "https://example.com/path"
|
||
|
||
|
||
def test_url_file_scheme() -> None:
|
||
assert cv.url("file:///tmp/x") == "file:///tmp/x"
|
||
|
||
|
||
def test_url_invalid_value_error() -> None:
|
||
with pytest.raises(Invalid, match="Not a valid URL"):
|
||
cv.url("http://[::1")
|
||
|
||
|
||
def test_url_no_host() -> None:
|
||
with pytest.raises(Invalid, match="Expected a file scheme"):
|
||
cv.url("notaurl")
|
||
|
||
|
||
def test_git_ref_valid() -> None:
|
||
assert cv.git_ref("v1.2.3") == "v1.2.3"
|
||
|
||
|
||
def test_git_ref_invalid() -> None:
|
||
with pytest.raises(Invalid, match="Not a valid git ref"):
|
||
cv.git_ref("!!!")
|
||
|
||
|
||
def test_source_refresh_always() -> None:
|
||
assert cv.source_refresh("always").total_seconds == 0
|
||
|
||
|
||
def test_source_refresh_never() -> None:
|
||
assert cv.source_refresh("never").total_seconds == 365250 * 24 * 3600
|
||
|
||
|
||
def test_source_refresh_value() -> None:
|
||
assert cv.source_refresh("60s").total_seconds == 60
|
||
|
||
|
||
def test_version_number_valid() -> None:
|
||
assert cv.version_number("2024.5.1") == "2024.5.1"
|
||
|
||
|
||
def test_version_number_invalid() -> None:
|
||
with pytest.raises(Invalid, match="Not a valid version number"):
|
||
cv.version_number("notaversion")
|
||
|
||
|
||
def test_validate_esphome_version_ok() -> None:
|
||
assert cv.validate_esphome_version("1.0.0") == "1.0.0"
|
||
|
||
|
||
def test_validate_esphome_version_too_old() -> None:
|
||
with pytest.raises(Invalid, match="ESPHome version is too old"):
|
||
cv.validate_esphome_version("9999.0.0")
|
||
|
||
|
||
def test_platformio_version_constraint_no_op() -> None:
|
||
assert cv.platformio_version_constraint("1.2.3") == [(None, "1.2.3")]
|
||
|
||
|
||
def test_platformio_version_constraint_with_ops() -> None:
|
||
assert cv.platformio_version_constraint(">=1.2.3,<2.0.0") == [
|
||
(">=", "1.2.3"),
|
||
("<", "2.0.0"),
|
||
]
|
||
|
||
|
||
# ---------------------------------------------------------------------------
|
||
# require_framework_version (no extra_message) / require_esphome_version
|
||
# ---------------------------------------------------------------------------
|
||
|
||
|
||
def test_require_framework_version_incompatible_no_extra() -> None:
|
||
_set_framework_version(PLATFORM_ESP32, "arduino", cv.Version(1, 0, 0))
|
||
with pytest.raises(Invalid, match="incompatible with ESP32"):
|
||
cv.require_framework_version()("test")
|
||
|
||
|
||
def test_require_framework_version_too_low_no_extra() -> None:
|
||
_set_framework_version(PLATFORM_ESP32, "arduino", cv.Version(1, 0, 0))
|
||
with pytest.raises(Invalid, match="at least framework version 2.0.0"):
|
||
cv.require_framework_version(esp32_arduino=cv.Version(2, 0, 0))("test")
|
||
|
||
|
||
def test_require_framework_version_too_high_no_extra() -> None:
|
||
_set_framework_version(PLATFORM_ESP32, "arduino", cv.Version(2, 0, 0))
|
||
with pytest.raises(Invalid, match="version 1.0.0 or lower"):
|
||
cv.require_framework_version(
|
||
esp32_arduino=cv.Version(1, 0, 0), max_version=True
|
||
)("test")
|
||
|
||
|
||
def test_require_esphome_version_ok() -> None:
|
||
assert cv.require_esphome_version(1, 0, 0)("test") == "test"
|
||
|
||
|
||
def test_require_esphome_version_too_old() -> None:
|
||
with pytest.raises(Invalid, match="at least ESPHome version 9999.0.0"):
|
||
cv.require_esphome_version(9999, 0, 0)("test")
|
||
|
||
|
||
# ---------------------------------------------------------------------------
|
||
# suppress_invalid / validate_source_shorthand / rename_key
|
||
# ---------------------------------------------------------------------------
|
||
|
||
|
||
def test_suppress_invalid() -> None:
|
||
with cv.suppress_invalid():
|
||
raise Invalid("suppressed")
|
||
|
||
|
||
def test_validate_source_shorthand_not_string() -> None:
|
||
with pytest.raises(Invalid, match="Shorthand only for strings"):
|
||
cv.validate_source_shorthand(123)
|
||
|
||
|
||
def test_validate_source_shorthand_local_path(setup_core: Path) -> None:
|
||
(setup_core / "mydir").mkdir()
|
||
result = cv.validate_source_shorthand("mydir")
|
||
assert result[CONF_TYPE] == TYPE_LOCAL
|
||
|
||
|
||
def test_validate_source_shorthand_github(setup_core: Path) -> None:
|
||
result = cv.validate_source_shorthand("github://user/repo@main")
|
||
assert result[CONF_TYPE] == TYPE_GIT
|
||
assert result[CONF_REF] == "main"
|
||
|
||
|
||
def test_validate_source_shorthand_github_no_ref(setup_core: Path) -> None:
|
||
result = cv.validate_source_shorthand("github://user/repo")
|
||
assert result[CONF_TYPE] == TYPE_GIT
|
||
assert CONF_REF not in result
|
||
|
||
|
||
def test_validate_source_shorthand_github_pr(setup_core: Path) -> None:
|
||
result = cv.validate_source_shorthand("github://pr#1234")
|
||
assert result[CONF_REF] == "pull/1234/head"
|
||
|
||
|
||
def test_validate_source_shorthand_invalid(setup_core: Path) -> None:
|
||
with pytest.raises(Invalid, match="not a file system path"):
|
||
cv.validate_source_shorthand("notvalid")
|
||
|
||
|
||
def test_rename_key_present() -> None:
|
||
assert cv.rename_key("old", "new")({"old": 5}) == {"new": 5}
|
||
|
||
|
||
def test_rename_key_absent() -> None:
|
||
assert cv.rename_key("old", "new")({"other": 5}) == {"other": 5}
|