import string from hypothesis import example, given from hypothesis.strategies import builds, integers, ip_addresses, one_of, text import pytest import voluptuous as vol from esphome import config_validation 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 ( PLATFORM_BK72XX, PLATFORM_ESP32, PLATFORM_ESP8266, PLATFORM_HOST, PLATFORM_LN882X, PLATFORM_RP2040, PLATFORM_RTL87XX, SCHEDULER_DONT_RUN, ) from esphome.core import CORE, HexInt, Lambda from esphome.yaml_util import SensitiveStr def test_check_not_templatable__invalid(): with pytest.raises(Invalid, match="This option is not templatable!"): config_validation.check_not_templatable(Lambda("")) @pytest.mark.parametrize("value", ("foo", 1, "D12", False)) def test_alphanumeric__valid(value): actual = config_validation.alphanumeric(value) assert actual == str(value) @pytest.mark.parametrize("value", ("£23", "Foo!")) def test_alphanumeric__invalid(value): with pytest.raises(Invalid): config_validation.alphanumeric(value) @given(value=text(alphabet=string.ascii_lowercase + string.digits + "-_")) def test_valid_name__valid(value): actual = config_validation.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): config_validation.valid_name(value) @pytest.mark.parametrize("value", ("${name}", "${NAME}", "$NAME", "${name}_name")) def test_valid_name__substitution_valid(value): CORE.vscode = True actual = config_validation.valid_name(value) assert actual == value CORE.vscode = False with pytest.raises(Invalid): actual = config_validation.valid_name(value) @pytest.mark.parametrize("value", ("{NAME}", "${A NAME}")) def test_valid_name__substitution_like_invalid(value): with pytest.raises(Invalid): config_validation.valid_name(value) @pytest.mark.parametrize("value", ("myid", "anID", "SOME_ID_test", "MYID_99")) def test_validate_id_name__valid(value): actual = config_validation.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): config_validation.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 = config_validation.validate_id_name(value) assert actual == value CORE.vscode = False with pytest.raises(Invalid): config_validation.validate_id_name(value) @given(one_of(integers(), text())) def test_string__valid(value): actual = config_validation.string(value) assert actual == str(value) @pytest.mark.parametrize("value", ({}, [], True, False, None)) def test_string__invalid(value): with pytest.raises(Invalid): config_validation.string(value) @given(text()) def test_strict_string__valid(value): actual = config_validation.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"): config_validation.string_strict(value) def test_sensitive__default_delegates_to_string() -> None: validator = config_validation.sensitive() assert isinstance(validator, config_validation.SensitiveValidator) assert validator.inner is config_validation.string assert validator("hunter2") == "hunter2" assert validator(42) == "42" def test_sensitive__custom_inner_delegates_validation() -> None: validator = config_validation.sensitive(config_validation.string_strict) assert validator.inner is config_validation.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 = config_validation.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 = config_validation.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 = config_validation.sensitive(inner) assert validator("anything") is sentinel def test_sensitive__is_detectable_via_isinstance() -> None: validator = config_validation.sensitive() assert isinstance(validator, config_validation.SensitiveValidator) 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(config_validation.sensitive(config_validation.string)) == repr( config_validation.string ) assert repr(config_validation.sensitive(config_validation.string)) == repr( config_validation.sensitive(config_validation.string) ) def test_sensitive_key_fragments__covers_common_terms() -> None: assert isinstance(config_validation.SENSITIVE_KEY_FRAGMENTS, frozenset) for term in ("password", "passcode", "secret", "token", "api_key", "apikey", "psk"): assert term in config_validation.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 = config_validation.icon(value) assert actual == value def test_icon__invalid(): with pytest.raises(Invalid, match="Icons must match the format "): config_validation.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 config_validation.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"): config_validation.icon(too_long) def test_byte_length() -> None: """Test ByteLength validator checks UTF-8 byte length, not char count.""" validator = config_validation.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 config_validation.boolean(value) is True @pytest.mark.parametrize("value", ("False", "NO", "off", "disAblE", False)) def test_boolean__valid_false(value): assert config_validation.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"): config_validation.boolean(value) @given(value=ip_addresses(v=4).map(str)) def test_ipv4__valid(value): config_validation.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"): config_validation.ipv4address(value) @given(value=ip_addresses(v=6).map(str)) def test_ipv6__valid(value): config_validation.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"): config_validation.ipaddress(value) # TODO: ensure_list @given(integers()) def hex_int__valid(value): actual = config_validation.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_RP2040, 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", "rp2040": "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 = config_validation.Schema( { config_validation.SplitDefault( "full", **common_mappings, **idf_mappings, **arduino_mappings ): str, config_validation.SplitDefault( "idf", **common_mappings, **idf_mappings ): str, config_validation.SplitDefault( "arduino", **common_mappings, **arduino_mappings ): str, config_validation.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_RP2040, "RP2040 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] = config_validation.Version(1, 0, 0) assert ( config_validation.require_framework_version( esp_idf=config_validation.Version(0, 5, 0), esp32_arduino=config_validation.Version(0, 5, 0), esp8266_arduino=config_validation.Version(0, 5, 0), rp2040_arduino=config_validation.Version(0, 5, 0), bk72xx_arduino=config_validation.Version(0, 5, 0), host=config_validation.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", ): config_validation.require_framework_version( esp_idf=config_validation.Version(2, 0, 0), esp32_arduino=config_validation.Version(2, 0, 0), esp8266_arduino=config_validation.Version(2, 0, 0), rp2040_arduino=config_validation.Version(2, 0, 0), bk72xx_arduino=config_validation.Version(2, 0, 0), host=config_validation.Version(2, 0, 0), extra_message="test 2", )("test") assert ( config_validation.require_framework_version( esp_idf=config_validation.Version(1, 5, 0), esp32_arduino=config_validation.Version(1, 5, 0), esp8266_arduino=config_validation.Version(1, 5, 0), rp2040_arduino=config_validation.Version(1, 5, 0), bk72xx_arduino=config_validation.Version(1, 5, 0), host=config_validation.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", ): config_validation.require_framework_version( esp_idf=config_validation.Version(0, 5, 0), esp32_arduino=config_validation.Version(0, 5, 0), esp8266_arduino=config_validation.Version(0, 5, 0), rp2040_arduino=config_validation.Version(0, 5, 0), bk72xx_arduino=config_validation.Version(0, 5, 0), host=config_validation.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" ): config_validation.require_framework_version( extra_message="test 5", )("test") def test_only_with_single_component_loaded() -> None: """Test OnlyWith with single component when component is loaded.""" CORE.loaded_integrations = {"mqtt"} schema = config_validation.Schema( { config_validation.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 = config_validation.Schema( { config_validation.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 = config_validation.Schema( { config_validation.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 = config_validation.Schema( { config_validation.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 = config_validation.Schema( { config_validation.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 = config_validation.Schema( { config_validation.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 = config_validation.Schema( { config_validation.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 = config_validation.Schema( { config_validation.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 = config_validation.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 = config_validation.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 config_validation.string_no_slash(long_value) == long_value def test_string_no_slash__empty() -> None: assert config_validation.string_no_slash("") == "" @pytest.mark.parametrize("value", ("Temperature", "Living Room Light", "温度传感器")) def test_validate_entity_name__valid(value: str) -> None: actual = config_validation._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 = config_validation._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 config_validation._validate_entity_name("x" * 120) == "x" * 120 # 121 bytes should fail with pytest.raises(Invalid, match="too long.*121 bytes.*Maximum.*120"): config_validation._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 config_validation._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"): config_validation._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"): config_validation._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 = config_validation._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 config_validation.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): config_validation.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 config_validation.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): config_validation.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 config_validation.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): config_validation.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 config_validation.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): config_validation.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"): config_validation.unbounded_percentage(value) with pytest.raises(Invalid, match="percent sign"): config_validation.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 = config_validation.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 config_validation.update_interval("1ms").total_milliseconds == 1 assert config_validation.update_interval("50ms").total_milliseconds == 50 assert config_validation.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 = config_validation.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 = config_validation.Optional("foo") assert o.visibility is None def test_optional_visibility_advanced() -> None: """``visibility=Visibility.ADVANCED`` is recorded on the marker.""" o = config_validation.Optional( "foo", visibility=config_validation.Visibility.ADVANCED ) assert o.visibility is config_validation.Visibility.ADVANCED def test_optional_visibility_yaml_only() -> None: """``visibility=Visibility.YAML_ONLY`` is recorded on the marker.""" o = config_validation.Optional( "foo", visibility=config_validation.Visibility.YAML_ONLY ) assert o.visibility is config_validation.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(config_validation.Visibility.ADVANCED) == "advanced" assert str(config_validation.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 = config_validation.Schema( {config_validation.Optional("foo", default=42): config_validation.int_} ) flagged = config_validation.Schema( { config_validation.Optional( "foo", default=42, visibility=config_validation.Visibility.YAML_ONLY, ): config_validation.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 = config_validation.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 = config_validation.Required( "foo", visibility=config_validation.Visibility.ADVANCED ) assert r.visibility is config_validation.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 = config_validation.polling_component_schema("15min") advanced = config_validation.polling_component_schema( "15min", visibility=config_validation.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 config_validation.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 config_validation.Visibility.YAML_ONLY ) assert ( advanced_keys["setup_priority"].visibility is config_validation.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 = config_validation.polling_component_schema( None, visibility=config_validation.Visibility.ADVANCED ) keys = {str(k): k for k in schema.schema} assert isinstance(keys["update_interval"], config_validation.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 = config_validation.Optional("baz") inner_yaml_only = config_validation.Optional( "qux", visibility=config_validation.Visibility.YAML_ONLY ) parent = config_validation.Optional( "foo", visibility=config_validation.Visibility.ADVANCED ) # Wire them into a nested schema — none of the markers' own # ``visibility`` should change as a result. schema = config_validation.Schema( { parent: config_validation.Schema( { inner_unset: config_validation.int_, inner_yaml_only: config_validation.string, } ) } ) assert schema # touch the schema so any deferred mutation runs assert parent.visibility is config_validation.Visibility.ADVANCED assert inner_unset.visibility is None assert inner_yaml_only.visibility is config_validation.Visibility.YAML_ONLY