diff --git a/script/sync_components.py b/script/sync_components.py index 3800d0f8..e0a07f76 100644 --- a/script/sync_components.py +++ b/script/sync_components.py @@ -1821,13 +1821,14 @@ def _convert_field(key: str, raw: dict, schema_dir: Path) -> dict | None: # noq is_structural = entry_type == "pin" or bool(references) advanced = _classify_advanced(key, required=required, is_structural=is_structural) + default_value, gated_component = _extract_default(raw, key=key) entry: dict[str, Any] = { "key": key, "type": entry_type, "label": _key_to_label(key), "description": docs.text or None, "required": required, - "default_value": _coerce_default(raw.get("default")), + "default_value": default_value, "options": _build_options(raw), "allow_custom_value": False, "range": list(_DATA_TYPE_RANGE[data_type]) if data_type in _DATA_TYPE_RANGE else None, @@ -1836,7 +1837,7 @@ def _convert_field(key: str, raw: dict, schema_dir: Path) -> dict | None: # noq "depends_on": None, "depends_on_value": None, "depends_on_value_not": None, - "depends_on_component": None, + "depends_on_component": gated_component, "references_component": references, "pin_features": _resolve_pin_features(raw) if entry_type == "pin" else [], "pin_mode": None, @@ -1969,6 +1970,31 @@ def _coerce_default(value: Any) -> Any: return value +def _extract_default(raw: dict, key: str = "") -> tuple[Any, str | None]: + """Resolve ``(default_value, depends_on_component)`` for a field. + + Reads ``default_with`` (``cv.OnlyWith``, esphome/esphome#16276) + in preference to plain ``default``. ``default_without`` + (``cv.OnlyWithout``) has inverse-gate semantics that + ``depends_on_component`` can't model — no default surfaces for + those fields. Multi-component ``default_with`` picks the first + component and logs a warning (no upstream call site uses a + list today). *key* is the field name for the log context. + """ + if (gated := raw.get("default_with")) is not None: + components = gated.get("components") or [] + if len(components) > 1: + _LOGGER.warning( + "%s: default_with with multiple components %s; only " + "the first (%s) will be used as depends_on_component.", + key or "", + components, + components[0], + ) + return _coerce_default(gated.get("value")), components[0] if components else None + return _coerce_default(raw.get("default")), None + + def _resolve_use_id_reference(raw: dict) -> str | None: """Map ``use_id_type: 'ns::Class'`` to a component domain. diff --git a/tests/test_sync_components_default_extraction.py b/tests/test_sync_components_default_extraction.py new file mode 100644 index 00000000..a7bac124 --- /dev/null +++ b/tests/test_sync_components_default_extraction.py @@ -0,0 +1,191 @@ +"""Tests for ``_extract_default`` and the ``_convert_field`` end-to-end. + +Covers both the resolver and the full conversion pipeline against +real raw-schema fixtures captured from +``script/build_language_schema.py`` post esphome/esphome#16276. +""" + +from __future__ import annotations + +import logging +from pathlib import Path + +import pytest + +from script.sync_components import ( # type: ignore[import-not-found] + _convert_field, + _extract_default, +) + + +def test_unconditional_default_returns_value_and_no_gate() -> None: + """Plain ``default: "..."`` flows through unchanged.""" + assert _extract_default({"default": "true"}) == (True, None) + assert _extract_default({"default": "False"}) == (False, None) + assert _extract_default({"default": "5"}) == ("5", None) + + +def test_no_default_returns_pair_of_nones() -> None: + """No ``default`` and no ``default_with`` → ``(None, None)``.""" + assert _extract_default({"key": "Optional"}) == (None, None) + + +def test_default_with_single_component_returns_value_and_gate() -> None: + """``default_with`` with one component → gated default.""" + raw = {"default_with": {"value": "True", "components": ["wifi"]}} + assert _extract_default(raw) == (True, "wifi") + + +def test_default_with_multi_component_picks_first_with_warning( + caplog: pytest.LogCaptureFixture, +) -> None: + """Multi-component ``default_with`` → first component + log.warning. + + No upstream call site uses a list today; pick the first and + log so the field still gets a default. + """ + raw = { + "default_with": { + "value": "DC_SOURCE", + "components": ["zigbee", "nrf52"], + }, + } + with caplog.at_level(logging.WARNING, logger="sync_components"): + value, gate = _extract_default(raw, key="power_source") + assert value == "DC_SOURCE" + assert gate == "zigbee" + assert len(caplog.records) == 1 + msg = caplog.records[0].getMessage() + assert "power_source" in msg + assert "zigbee" in msg + assert "nrf52" in msg + + +def test_default_with_empty_components_returns_no_gate() -> None: + """Empty ``components`` → value flows, gate stays None.""" + raw = {"default_with": {"value": "True", "components": []}} + assert _extract_default(raw) == (True, None) + + +def test_default_with_takes_precedence_over_default() -> None: + """``default_with`` wins when both are present.""" + raw = { + "default": "False", + "default_with": {"value": "True", "components": ["wifi"]}, + } + assert _extract_default(raw) == (True, "wifi") + + +# Real raw entries captured from the patched build_language_schema.py +# against ESPHome's tree. Pasted verbatim so the device-builder side +# has a concrete contract to test against without needing the +# upstream PR merged. The fixtures are the canary if the upstream +# field name changes. + +_FIXTURE_SOFTWARE_COEXISTENCE: dict = { + "default_with": {"value": "True", "components": ["wifi"]}, + "key": "Optional", + "type": "boolean", +} + +_FIXTURE_POWER_SOURCE: dict = { + "default_with": {"value": "DC_SOURCE", "components": ["nrf52"]}, + "key": "Optional", + "type": "enum", + "values": { + "BATTERY": None, + "DC_SOURCE": None, + "EMERGENCY_MAINS_CONST": None, + "EMERGENCY_MAINS_TRANSF": None, + "MAINS_SINGLE_PHASE": None, + "MAINS_THREE_PHASE": None, + "UNKNOWN": None, + }, +} + +_FIXTURE_TX_POWER: dict = { + "default_without": {"value": "3dBm", "components": ["esp32_hosted"]}, + "key": "Optional", + "type": "enum", + "values": { + "-12": None, + "-3": None, + "-6": None, + "-9": None, + "0": None, + "3": None, + "6": None, + "9": None, + }, +} + + +def test_extract_default_software_coexistence_fixture() -> None: + """Real ``software_coexistence`` raw entry.""" + assert _extract_default(_FIXTURE_SOFTWARE_COEXISTENCE) == (True, "wifi") + + +def test_extract_default_power_source_fixture() -> None: + """Real ``power_source`` raw entry — string default.""" + assert _extract_default(_FIXTURE_POWER_SOURCE) == ("DC_SOURCE", "nrf52") + + +def test_extract_default_tx_power_fixture_skipped_for_now() -> None: + """``default_without`` returns ``(None, None)`` — inverse-gate follow-up.""" + assert _extract_default(_FIXTURE_TX_POWER) == (None, None) + + +@pytest.fixture +def schema_dir(tmp_path: Path) -> Path: + """Empty dir for ``_convert_field`` (only used for ``extends`` lookups).""" + return tmp_path + + +def test_convert_field_software_coexistence_carries_gate_and_default( + schema_dir: Path, +) -> None: + """``cv.OnlyWith(K, "wifi", default=True)`` → boolean entry, gated.""" + entry = _convert_field("software_coexistence", _FIXTURE_SOFTWARE_COEXISTENCE, schema_dir) + assert entry is not None + assert entry["type"] == "boolean" + assert entry["default_value"] is True + assert entry["depends_on_component"] == "wifi" + assert entry["required"] is False + + +def test_convert_field_power_source_carries_gate_and_string_default( + schema_dir: Path, +) -> None: + """OnlyWith enum field with a string default — verifies no bool coercion.""" + entry = _convert_field("power_source", _FIXTURE_POWER_SOURCE, schema_dir) + assert entry is not None + assert entry["default_value"] == "DC_SOURCE" + assert entry["depends_on_component"] == "nrf52" + option_values = {opt["value"] for opt in entry["options"] or []} + assert "DC_SOURCE" in option_values + assert "BATTERY" in option_values + + +def test_convert_field_tx_power_default_without_no_gate( + schema_dir: Path, +) -> None: + """``cv.OnlyWithout`` field → no default, no gate (follow-up).""" + entry = _convert_field("tx_power", _FIXTURE_TX_POWER, schema_dir) + assert entry is not None + assert entry["default_value"] is None + assert entry["depends_on_component"] is None + option_values = {opt["value"] for opt in entry["options"] or []} + assert "3" in option_values + + +def test_convert_field_unconditional_default_unchanged(schema_dir: Path) -> None: + """Plain ``cv.Optional(K, default=True)`` flows through with no gate. + + ``retain``'s ``_COMPONENT_GATED_KEYS`` membership applies in + ``_convert_config_vars``, not ``_convert_field``. + """ + raw = {"default": "true", "key": "Optional", "type": "boolean"} + entry = _convert_field("retain", raw, schema_dir) + assert entry is not None + assert entry["default_value"] is True + assert entry["depends_on_component"] is None