mirror of
https://github.com/esphome/esphome.git
synced 2026-10-03 17:55:57 +00:00
Merge remote-tracking branch 'origin/cv-sensitive-redact-sentinel' into integration
This commit is contained in:
@@ -2,6 +2,8 @@
|
||||
|
||||
from unittest.mock import patch
|
||||
|
||||
import pytest
|
||||
|
||||
from esphome.components.display import (
|
||||
DisplayMetaData,
|
||||
add_metadata,
|
||||
@@ -74,8 +76,5 @@ def test_add_metadata_overwrites_existing():
|
||||
def test_metadata_is_frozen():
|
||||
"""Test that DisplayMetaData instances are immutable (frozen dataclass)."""
|
||||
meta = DisplayMetaData(320, 240, True, False)
|
||||
try:
|
||||
with pytest.raises(AttributeError):
|
||||
meta.width = 640
|
||||
assert False, "Expected FrozenInstanceError"
|
||||
except AttributeError:
|
||||
pass
|
||||
|
||||
@@ -0,0 +1,71 @@
|
||||
"""Tests for lvgl automation_schema lazy validate_automation build."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from unittest.mock import patch
|
||||
|
||||
import esphome.components.lvgl # noqa: F401
|
||||
from esphome.components.lvgl import schemas as lvgl_schemas
|
||||
from esphome.components.lvgl.schemas import (
|
||||
WIDGET_TYPES,
|
||||
_lazy_validate_automation,
|
||||
automation_schema,
|
||||
)
|
||||
from esphome.components.lvgl.widgets import WidgetType
|
||||
from esphome.config_validation import GenerateID, declare_id
|
||||
from esphome.const import CONF_TRIGGER_ID
|
||||
from esphome.core.config import StartupTrigger
|
||||
|
||||
|
||||
def _widget_type(name: str = "obj") -> WidgetType:
|
||||
wt = WIDGET_TYPES.get(name)
|
||||
assert wt is not None, f"widget type {name!r} not registered"
|
||||
return wt
|
||||
|
||||
|
||||
def _trigger_extra_schema() -> dict:
|
||||
return {GenerateID(CONF_TRIGGER_ID): declare_id(StartupTrigger)}
|
||||
|
||||
|
||||
def test_lazy_validator_defers_build_until_first_call() -> None:
|
||||
with patch(
|
||||
"esphome.components.lvgl.schemas.validate_automation",
|
||||
wraps=lvgl_schemas.validate_automation,
|
||||
) as va_mock:
|
||||
validator = _lazy_validate_automation(_trigger_extra_schema())
|
||||
assert va_mock.call_count == 0
|
||||
validator({"then": []})
|
||||
assert va_mock.call_count == 1
|
||||
validator({"then": []})
|
||||
assert va_mock.call_count == 1
|
||||
|
||||
|
||||
def test_eager_build_when_schema_extraction_enabled() -> None:
|
||||
with (
|
||||
patch("esphome.components.lvgl.schemas.EnableSchemaExtraction", True),
|
||||
patch(
|
||||
"esphome.components.lvgl.schemas.validate_automation",
|
||||
wraps=lvgl_schemas.validate_automation,
|
||||
) as va_mock,
|
||||
):
|
||||
_lazy_validate_automation(_trigger_extra_schema())
|
||||
assert va_mock.call_count == 1
|
||||
|
||||
|
||||
def test_lazy_and_eager_produce_equivalent_validation() -> None:
|
||||
extra = _trigger_extra_schema()
|
||||
with patch("esphome.components.lvgl.schemas.EnableSchemaExtraction", True):
|
||||
eager = _lazy_validate_automation(extra)
|
||||
lazy = _lazy_validate_automation(_trigger_extra_schema())
|
||||
sample = {"then": []}
|
||||
assert lazy(sample) == eager(sample)
|
||||
|
||||
|
||||
def test_automation_schema_uses_lazy_validators() -> None:
|
||||
wt = _widget_type("obj")
|
||||
with patch(
|
||||
"esphome.components.lvgl.schemas.validate_automation",
|
||||
wraps=lvgl_schemas.validate_automation,
|
||||
) as va_mock:
|
||||
automation_schema(wt.w_type)
|
||||
assert va_mock.call_count == 0
|
||||
@@ -0,0 +1,67 @@
|
||||
"""Tests for obj_schema() memoization."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from collections.abc import Generator
|
||||
|
||||
import pytest
|
||||
|
||||
import esphome.components.lvgl # noqa: F401
|
||||
from esphome.components.lvgl import schemas as lvgl_schemas
|
||||
from esphome.components.lvgl.schemas import WIDGET_TYPES, obj_schema
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def _clear_obj_schema_cache() -> Generator[None]:
|
||||
cache = getattr(lvgl_schemas, "_OBJ_SCHEMA_CACHE", None)
|
||||
if cache is not None:
|
||||
cache.clear()
|
||||
yield
|
||||
if cache is not None:
|
||||
cache.clear()
|
||||
|
||||
|
||||
def _widget_type(name: str = "obj"):
|
||||
wt = WIDGET_TYPES.get(name)
|
||||
assert wt is not None, f"widget type {name!r} not registered"
|
||||
return wt
|
||||
|
||||
|
||||
def test_same_widget_type_returns_same_schema() -> None:
|
||||
wt = _widget_type("obj")
|
||||
assert obj_schema(wt) is obj_schema(wt)
|
||||
|
||||
|
||||
def test_different_widget_types_return_different_schemas() -> None:
|
||||
assert obj_schema(_widget_type("obj")) is not obj_schema(_widget_type("label"))
|
||||
|
||||
|
||||
def test_cache_is_populated_after_first_call() -> None:
|
||||
wt = _widget_type("obj")
|
||||
assert id(wt) not in lvgl_schemas._OBJ_SCHEMA_CACHE
|
||||
obj_schema(wt)
|
||||
assert id(wt) in lvgl_schemas._OBJ_SCHEMA_CACHE
|
||||
|
||||
|
||||
def test_cached_schema_produces_equivalent_output() -> None:
|
||||
wt = _widget_type("obj")
|
||||
cached_result = obj_schema(wt)({})
|
||||
lvgl_schemas._OBJ_SCHEMA_CACHE.clear()
|
||||
fresh_result = obj_schema(wt)({})
|
||||
assert cached_result == fresh_result
|
||||
|
||||
|
||||
def test_id_recycling_is_caught_by_identity_guard() -> None:
|
||||
wt = _widget_type("obj")
|
||||
real_schema = obj_schema(wt)
|
||||
|
||||
cached_widget_type, _ = lvgl_schemas._OBJ_SCHEMA_CACHE[id(wt)]
|
||||
sentinel_schema = object()
|
||||
lvgl_schemas._OBJ_SCHEMA_CACHE[id(wt)] = (cached_widget_type, sentinel_schema)
|
||||
assert obj_schema(wt) is sentinel_schema
|
||||
|
||||
other = _widget_type("label")
|
||||
lvgl_schemas._OBJ_SCHEMA_CACHE[id(wt)] = (other, sentinel_schema)
|
||||
rebuilt = obj_schema(wt)
|
||||
assert rebuilt is not sentinel_schema
|
||||
assert rebuilt is not real_schema
|
||||
@@ -0,0 +1,236 @@
|
||||
"""Tests for part_dict / obj_dict / part_schema / obj_schema mapping contracts.
|
||||
|
||||
These guard the dict-merge refactor: the dict helpers must keep returning the
|
||||
same logical mapping as the chained-extend version produced, and the
|
||||
corresponding Schema(...) wrappers must accept and reject the same configs.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from collections.abc import Generator
|
||||
|
||||
import pytest
|
||||
import voluptuous as vol
|
||||
|
||||
from esphome import config_validation as cv
|
||||
import esphome.components.lvgl
|
||||
from esphome.components.lvgl import (
|
||||
_theme_schema,
|
||||
defines as df,
|
||||
schemas as lvgl_schemas,
|
||||
)
|
||||
from esphome.components.lvgl.schemas import (
|
||||
ALIGN_TO_SCHEMA,
|
||||
FLAG_SCHEMA,
|
||||
FULL_STYLE_SCHEMA,
|
||||
STATE_SCHEMA,
|
||||
STYLE_SCHEMA,
|
||||
WIDGET_TYPES,
|
||||
automation_schema,
|
||||
obj_dict,
|
||||
obj_schema,
|
||||
part_dict,
|
||||
part_schema,
|
||||
)
|
||||
from esphome.components.lvgl.types import LvType
|
||||
from esphome.components.lvgl.widgets import WidgetType
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def _clear_obj_dict_cache() -> Generator[None]:
|
||||
cache = getattr(lvgl_schemas, "_OBJ_DICT_CACHE", None)
|
||||
if cache is not None:
|
||||
cache.clear()
|
||||
# The lazily-built theme schema is cached on _build_theme_schema; clear it
|
||||
# too so each test starts from a clean slate.
|
||||
build_theme = getattr(esphome.components.lvgl, "_build_theme_schema", None)
|
||||
if build_theme is not None and hasattr(build_theme, "cache_clear"):
|
||||
build_theme.cache_clear()
|
||||
yield
|
||||
if cache is not None:
|
||||
cache.clear()
|
||||
if build_theme is not None and hasattr(build_theme, "cache_clear"):
|
||||
build_theme.cache_clear()
|
||||
|
||||
|
||||
def _marker_names(mapping) -> set[str]:
|
||||
"""Return the underlying string names of every voluptuous Marker key."""
|
||||
names: set[str] = set()
|
||||
for key in mapping:
|
||||
if isinstance(key, vol.Marker):
|
||||
schema = key.schema
|
||||
if isinstance(schema, str):
|
||||
names.add(schema)
|
||||
return names
|
||||
|
||||
|
||||
def _widget_type(name: str = "obj"):
|
||||
wt = WIDGET_TYPES.get(name)
|
||||
assert wt is not None, f"widget type {name!r} not registered"
|
||||
return wt
|
||||
|
||||
|
||||
def test_part_dict_includes_state_flag_and_part_keys() -> None:
|
||||
parts = ("indicator", "knob")
|
||||
keys = _marker_names(part_dict(parts))
|
||||
|
||||
assert {"indicator", "knob"} <= keys
|
||||
assert _marker_names(STATE_SCHEMA.schema) <= keys
|
||||
assert _marker_names(FLAG_SCHEMA.schema) <= keys
|
||||
|
||||
|
||||
def test_obj_dict_extends_part_dict_with_align_automation_state_group() -> None:
|
||||
wt = _widget_type("obj")
|
||||
part_keys = _marker_names(part_dict(wt.parts))
|
||||
obj_keys = _marker_names(obj_dict(wt))
|
||||
|
||||
assert part_keys <= obj_keys
|
||||
assert _marker_names(ALIGN_TO_SCHEMA) <= obj_keys
|
||||
assert _marker_names(automation_schema(wt.w_type)) <= obj_keys
|
||||
assert {"state", "group"} <= obj_keys
|
||||
|
||||
|
||||
def test_obj_dict_is_memoized_by_widget_type() -> None:
|
||||
wt = _widget_type("obj")
|
||||
first = obj_dict(wt)
|
||||
second = obj_dict(wt)
|
||||
assert first is second
|
||||
# Different widget type, different dict.
|
||||
assert obj_dict(_widget_type("label")) is not first
|
||||
|
||||
|
||||
def test_part_schema_round_trips_known_state_and_part_settings() -> None:
|
||||
schema = part_schema(("indicator",))
|
||||
out = schema(
|
||||
{
|
||||
"bg_color": 0x112233,
|
||||
"checked": {"bg_color": 0x445566},
|
||||
"indicator": {"bg_color": 0x778899},
|
||||
}
|
||||
)
|
||||
assert out["bg_color"] == 0x112233
|
||||
assert out["checked"]["bg_color"] == 0x445566
|
||||
assert out["indicator"]["bg_color"] == 0x778899
|
||||
|
||||
|
||||
def test_part_schema_rejects_unknown_part() -> None:
|
||||
schema = part_schema(("indicator",))
|
||||
with pytest.raises(vol.Invalid):
|
||||
schema({"definitely_not_a_part": {}})
|
||||
|
||||
|
||||
@pytest.mark.parametrize("name", sorted(WIDGET_TYPES))
|
||||
def test_obj_schema_accepts_empty_config_for_every_widget_type(name: str) -> None:
|
||||
obj_schema(_widget_type(name))({})
|
||||
|
||||
|
||||
def test_obj_schema_accepts_align_to_and_state_group() -> None:
|
||||
schema = obj_schema(_widget_type("obj"))
|
||||
out = schema(
|
||||
{
|
||||
df.CONF_ALIGN_TO: {
|
||||
"id": "some_other_widget",
|
||||
df.CONF_ALIGN: "TOP_LEFT",
|
||||
},
|
||||
"state": {"checked": True},
|
||||
}
|
||||
)
|
||||
assert out[df.CONF_ALIGN_TO][df.CONF_ALIGN] == "LV_ALIGN_TOP_LEFT"
|
||||
assert out["state"]["checked"] is True
|
||||
|
||||
|
||||
def test_obj_schema_rejects_unknown_top_level_key() -> None:
|
||||
with pytest.raises(vol.Invalid):
|
||||
obj_schema(_widget_type("obj"))({"definitely_not_a_real_key": 1})
|
||||
|
||||
|
||||
def test_part_schema_returns_cv_schema_for_extend_callers() -> None:
|
||||
schema = part_schema(("indicator",))
|
||||
extended = schema.extend({cv.Optional("extra_key"): cv.string})
|
||||
out = extended({"extra_key": "value", "bg_color": 0xAABBCC})
|
||||
assert out["extra_key"] == "value"
|
||||
assert out["bg_color"] == 0xAABBCC
|
||||
|
||||
|
||||
def test_obj_schema_returns_cv_schema_for_extend_callers() -> None:
|
||||
schema = obj_schema(_widget_type("obj"))
|
||||
extended = schema.extend({cv.Optional("extra_key"): cv.string})
|
||||
extended({"extra_key": "value"})
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"schema",
|
||||
[STATE_SCHEMA, FLAG_SCHEMA, STYLE_SCHEMA, FULL_STYLE_SCHEMA],
|
||||
)
|
||||
def test_spread_sources_carry_no_extra_schemas(schema: cv.Schema) -> None:
|
||||
# part_dict / obj_dict reach into .schema and rebuild via cv.Schema(...),
|
||||
# which silently drops _extra_schemas and any non-default extra/required.
|
||||
# Lock the invariant so a future add_extra() on these sources fails CI
|
||||
# instead of quietly removing validation from part/obj/theme schemas.
|
||||
assert not schema._extra_schemas
|
||||
assert schema.extra is vol.PREVENT_EXTRA
|
||||
assert schema.required is False
|
||||
|
||||
|
||||
def test_theme_schema_merges_obj_dict_and_full_style_props() -> None:
|
||||
# _theme_schema is the riskiest merge: obj_dict(w) and FULL_STYLE_SCHEMA.schema
|
||||
# share many STYLE_SCHEMA marker instances. Exercise the merged schema
|
||||
# end-to-end with one key from each side (a STATE_SCHEMA part from obj_dict
|
||||
# and a FULL_STYLE-only property) to lock the behaviour against future
|
||||
# regressions in either source.
|
||||
out = _theme_schema(
|
||||
{
|
||||
df.CONF_DARK_MODE: True,
|
||||
"obj": {
|
||||
"bg_color": 0x112233,
|
||||
"checked": {"bg_color": 0x445566},
|
||||
df.CONF_PAD_ROW: 4,
|
||||
df.CONF_GRID_CELL_X_ALIGN: "CENTER",
|
||||
},
|
||||
}
|
||||
)
|
||||
assert out[df.CONF_DARK_MODE] is True
|
||||
obj_out = out["obj"]
|
||||
assert obj_out["bg_color"] == 0x112233
|
||||
assert obj_out["checked"]["bg_color"] == 0x445566
|
||||
assert obj_out[df.CONF_PAD_ROW] == 4
|
||||
assert obj_out[df.CONF_GRID_CELL_X_ALIGN] == "LV_GRID_ALIGN_CENTER"
|
||||
|
||||
|
||||
def test_theme_schema_self_heals_when_a_widget_type_is_registered_later() -> None:
|
||||
# _build_theme_schema is functools.cached on a snapshot of WIDGET_TYPES.
|
||||
# any_widget_schema explicitly supports external components registering
|
||||
# widgets lazily, and the device builder revalidates in-process, so a
|
||||
# widget registered after first use must invalidate the cached snapshot.
|
||||
_theme_schema({df.CONF_DARK_MODE: True}) # populate the cache
|
||||
|
||||
name = "test_self_heal_widget"
|
||||
assert name not in WIDGET_TYPES
|
||||
# is_mock=True skips registration side-effects; insert into WIDGET_TYPES
|
||||
# manually so the next theme call sees the new entry.
|
||||
WIDGET_TYPES[name] = WidgetType(name, LvType("test_fake_t"), (), is_mock=True)
|
||||
try:
|
||||
out = _theme_schema({df.CONF_DARK_MODE: False, name: {"bg_color": 0x010203}})
|
||||
assert out[name]["bg_color"] == 0x010203
|
||||
finally:
|
||||
WIDGET_TYPES.pop(name, None)
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"schema",
|
||||
[STATE_SCHEMA, FLAG_SCHEMA, STYLE_SCHEMA, FULL_STYLE_SCHEMA],
|
||||
)
|
||||
def test_spread_sources_have_no_top_level_marker_defaults(schema: cv.Schema) -> None:
|
||||
# _theme_schema merges obj_dict(w) with FULL_STYLE_SCHEMA.schema; on a key
|
||||
# collision, dict-spread keeps the first source's marker (and its default)
|
||||
# but the last source's value, whereas .extend() would take both from the
|
||||
# later source. The two are equivalent today because the overlapping
|
||||
# markers are the same instances (both derive from STYLE_SCHEMA) and none
|
||||
# carry a top-level default. Lock that so a future divergent default would
|
||||
# fail CI rather than silently drift the merged validation.
|
||||
offenders = [
|
||||
marker.schema
|
||||
for marker in schema.schema
|
||||
if isinstance(marker, vol.Optional) and marker.default is not vol.UNDEFINED
|
||||
]
|
||||
assert not offenders, f"top-level Optional with default: {offenders}"
|
||||
@@ -510,15 +510,9 @@ def test_package_merge_by_missing_id() -> None:
|
||||
],
|
||||
}
|
||||
|
||||
error_raised = False
|
||||
try:
|
||||
with pytest.raises(cv.Invalid) as exc_info:
|
||||
packages_pass(config)
|
||||
assert False, "Expected validation error for missing ID"
|
||||
except cv.Invalid as err:
|
||||
error_raised = True
|
||||
assert err.path == [CONF_SENSOR, 2]
|
||||
|
||||
assert error_raised
|
||||
assert exc_info.value.path == [CONF_SENSOR, 2]
|
||||
|
||||
|
||||
def test_package_list_remove_by_id() -> None:
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
"""Tests for the time component."""
|
||||
@@ -0,0 +1,369 @@
|
||||
"""Tests for time component – ha-timezone branch changes.
|
||||
|
||||
Covers:
|
||||
- detect_tz() platform guard (returns None for unsupported platforms)
|
||||
- detect_tz() result caching (avoids duplicate log messages)
|
||||
- detect_tz() error paths (tzlocal None, tzdata missing)
|
||||
- validate_tz() accepts/rejects POSIX timezone strings and IANA keys
|
||||
- TIME_SCHEMA: timezone is now truly optional (was SplitDefault)
|
||||
- homeassistant/time: USE_HOMEASSISTANT_TIMEZONE define emitted iff
|
||||
CONF_TIMEZONE is absent from the config
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from unittest import mock
|
||||
|
||||
import pytest
|
||||
|
||||
from esphome.components.time import DOMAIN, TIME_SCHEMA, detect_tz, validate_tz
|
||||
import esphome.config_validation as cv
|
||||
from esphome.const import (
|
||||
CONF_ID,
|
||||
CONF_TIMEZONE,
|
||||
KEY_CORE,
|
||||
KEY_TARGET_FRAMEWORK,
|
||||
KEY_TARGET_PLATFORM,
|
||||
Platform,
|
||||
PlatformFramework,
|
||||
)
|
||||
from esphome.core import CORE, EsphomeError
|
||||
from tests.component_tests.types import SetCoreConfigCallable
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Helpers
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
# A minimal TZif v2/v3 file that encodes "EST5EDT" as the footer line.
|
||||
# The binary content is not validated at this level – what matters is that
|
||||
# _extract_tz_string() picks up the last-but-one newline-terminated line.
|
||||
_FAKE_TZFILE = b"\x00" * 44 + b"TZif2\x00" * 1 + b"\n" + b"EST5EDT,M3.2.0,M11.1.0\n"
|
||||
|
||||
|
||||
def _set_platform(platform: Platform) -> None:
|
||||
"""Set CORE.data so that CORE.target_platform returns *platform*."""
|
||||
CORE.data[KEY_CORE] = {
|
||||
KEY_TARGET_PLATFORM: platform,
|
||||
KEY_TARGET_FRAMEWORK: "arduino",
|
||||
}
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# detect_tz – platform guard
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"platform_framework",
|
||||
[
|
||||
PlatformFramework.NRF52_ZEPHYR,
|
||||
],
|
||||
)
|
||||
def test_detect_tz_returns_none_for_unsupported_platform(
|
||||
platform_framework: PlatformFramework,
|
||||
set_core_config: SetCoreConfigCallable,
|
||||
) -> None:
|
||||
"""detect_tz() must return None for platforms that do not support TZ auto-detection."""
|
||||
set_core_config(platform_framework)
|
||||
result = detect_tz()
|
||||
assert result is None
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"platform_framework",
|
||||
[
|
||||
PlatformFramework.ESP32_IDF,
|
||||
PlatformFramework.ESP32_ARDUINO,
|
||||
PlatformFramework.ESP8266_ARDUINO,
|
||||
PlatformFramework.RP2040_ARDUINO,
|
||||
PlatformFramework.BK72XX_ARDUINO,
|
||||
PlatformFramework.RTL87XX_ARDUINO,
|
||||
PlatformFramework.LN882X_ARDUINO,
|
||||
PlatformFramework.HOST_NATIVE,
|
||||
],
|
||||
)
|
||||
def test_detect_tz_calls_tzlocal_for_supported_platform(
|
||||
platform_framework: PlatformFramework,
|
||||
set_core_config: SetCoreConfigCallable,
|
||||
) -> None:
|
||||
"""detect_tz() must call tzlocal for every supported platform."""
|
||||
set_core_config(platform_framework)
|
||||
with (
|
||||
mock.patch(
|
||||
"esphome.components.time.tzlocal.get_localzone_name",
|
||||
return_value="America/New_York",
|
||||
),
|
||||
mock.patch(
|
||||
"esphome.components.time._load_tzdata",
|
||||
return_value=_FAKE_TZFILE,
|
||||
),
|
||||
):
|
||||
result = detect_tz()
|
||||
assert result is not None
|
||||
assert isinstance(result, str)
|
||||
assert len(result) > 0
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# detect_tz – caching
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_detect_tz_caches_result(
|
||||
set_core_config: SetCoreConfigCallable,
|
||||
caplog: pytest.LogCaptureFixture,
|
||||
) -> None:
|
||||
"""detect_tz() must cache the TZ string after the first call so that
|
||||
subsequent invocations (e.g. when multiple time platforms are configured)
|
||||
skip tzlocal and avoid duplicate INFO messages."""
|
||||
set_core_config(PlatformFramework.ESP32_IDF)
|
||||
|
||||
with (
|
||||
mock.patch(
|
||||
"esphome.components.time.tzlocal.get_localzone_name",
|
||||
return_value="America/New_York",
|
||||
) as mock_tz,
|
||||
mock.patch(
|
||||
"esphome.components.time._load_tzdata",
|
||||
return_value=_FAKE_TZFILE,
|
||||
) as mock_load,
|
||||
):
|
||||
first = detect_tz()
|
||||
second = detect_tz()
|
||||
|
||||
assert first == second
|
||||
# tzlocal and _load_tzdata must be called exactly once despite two detect_tz() calls
|
||||
mock_tz.assert_called_once()
|
||||
mock_load.assert_called_once()
|
||||
|
||||
|
||||
def test_detect_tz_cache_stored_in_core_data(
|
||||
set_core_config: SetCoreConfigCallable,
|
||||
) -> None:
|
||||
"""The cached TZ string should be stored under CORE.data[DOMAIN][CONF_TIMEZONE]."""
|
||||
set_core_config(PlatformFramework.ESP32_IDF)
|
||||
|
||||
with (
|
||||
mock.patch(
|
||||
"esphome.components.time.tzlocal.get_localzone_name",
|
||||
return_value="Europe/London",
|
||||
),
|
||||
mock.patch(
|
||||
"esphome.components.time._load_tzdata",
|
||||
return_value=_FAKE_TZFILE,
|
||||
),
|
||||
):
|
||||
result = detect_tz()
|
||||
|
||||
assert CORE.data.get(DOMAIN, {}).get(CONF_TIMEZONE) == result
|
||||
|
||||
|
||||
def test_detect_tz_returns_pre_seeded_cache(
|
||||
set_core_config: SetCoreConfigCallable,
|
||||
) -> None:
|
||||
"""If CORE.data already has a cached TZ string, detect_tz() must return it
|
||||
without calling tzlocal at all."""
|
||||
set_core_config(PlatformFramework.ESP32_IDF)
|
||||
CORE.data[DOMAIN] = {CONF_TIMEZONE: "CET-1CEST,M3.5.0,M10.5.0/3"}
|
||||
|
||||
with mock.patch("esphome.components.time.tzlocal.get_localzone_name") as mock_tz:
|
||||
result = detect_tz()
|
||||
|
||||
assert result == "CET-1CEST,M3.5.0,M10.5.0/3"
|
||||
mock_tz.assert_not_called()
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# detect_tz – error paths
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_detect_tz_raises_when_tzlocal_returns_none(
|
||||
set_core_config: SetCoreConfigCallable,
|
||||
) -> None:
|
||||
"""detect_tz() must raise EsphomeError when the local timezone cannot be determined."""
|
||||
set_core_config(PlatformFramework.ESP32_IDF)
|
||||
|
||||
with (
|
||||
mock.patch(
|
||||
"esphome.components.time.tzlocal.get_localzone_name",
|
||||
return_value=None,
|
||||
),
|
||||
pytest.raises(EsphomeError, match="Could not automatically determine timezone"),
|
||||
):
|
||||
detect_tz()
|
||||
|
||||
|
||||
def test_detect_tz_raises_when_tzdata_not_found(
|
||||
set_core_config: SetCoreConfigCallable,
|
||||
) -> None:
|
||||
"""detect_tz() must raise EsphomeError when tzdata has no entry for the IANA key."""
|
||||
set_core_config(PlatformFramework.ESP32_IDF)
|
||||
|
||||
with (
|
||||
mock.patch(
|
||||
"esphome.components.time.tzlocal.get_localzone_name",
|
||||
return_value="Antarctica/Troll",
|
||||
),
|
||||
mock.patch(
|
||||
"esphome.components.time._load_tzdata",
|
||||
return_value=None,
|
||||
),
|
||||
pytest.raises(EsphomeError, match="Could not automatically determine timezone"),
|
||||
):
|
||||
detect_tz()
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# validate_tz
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_validate_tz_accepts_valid_posix_string() -> None:
|
||||
"""validate_tz() must accept a syntactically valid POSIX TZ string."""
|
||||
result = validate_tz("UTC0")
|
||||
assert result == "UTC0"
|
||||
|
||||
|
||||
def test_validate_tz_accepts_posix_string_with_dst() -> None:
|
||||
"""validate_tz() must accept a full POSIX TZ string with DST rules."""
|
||||
tz = "EST5EDT,M3.2.0,M11.1.0"
|
||||
result = validate_tz(tz)
|
||||
assert result == tz
|
||||
|
||||
|
||||
def test_validate_tz_accepts_iana_key_and_converts() -> None:
|
||||
"""validate_tz() must accept an IANA timezone key and return the POSIX string."""
|
||||
with mock.patch(
|
||||
"esphome.components.time._load_tzdata",
|
||||
return_value=_FAKE_TZFILE,
|
||||
):
|
||||
result = validate_tz("America/New_York")
|
||||
|
||||
# Should have been converted from IANA to POSIX via _extract_tz_string
|
||||
assert result == "EST5EDT,M3.2.0,M11.1.0"
|
||||
|
||||
|
||||
def test_validate_tz_rejects_invalid_posix_string() -> None:
|
||||
"""validate_tz() must raise cv.Invalid for a malformed POSIX TZ string."""
|
||||
with pytest.raises(cv.Invalid, match="Invalid POSIX timezone string"):
|
||||
validate_tz("NOTAVALIDTZ!!!")
|
||||
|
||||
|
||||
def test_validate_tz_accepts_empty_string() -> None:
|
||||
"""An empty string is accepted by validate_tz() and signals 'disable timezone'."""
|
||||
result = validate_tz("")
|
||||
assert result == ""
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# TIME_SCHEMA – timezone is now cv.Optional (no SplitDefault)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_time_schema_timezone_is_optional(
|
||||
set_core_config: SetCoreConfigCallable,
|
||||
) -> None:
|
||||
"""TIME_SCHEMA must accept a config with no timezone key on a supported platform."""
|
||||
set_core_config(PlatformFramework.ESP32_IDF)
|
||||
# Should not raise
|
||||
config = TIME_SCHEMA({})
|
||||
assert CONF_TIMEZONE not in config
|
||||
|
||||
|
||||
def test_time_schema_explicit_timezone_accepted(
|
||||
set_core_config: SetCoreConfigCallable,
|
||||
) -> None:
|
||||
"""TIME_SCHEMA must accept an explicit valid POSIX timezone on Arduino/IDF."""
|
||||
set_core_config(PlatformFramework.ESP32_IDF)
|
||||
config = TIME_SCHEMA({CONF_TIMEZONE: "UTC0"})
|
||||
assert config[CONF_TIMEZONE] == "UTC0"
|
||||
|
||||
|
||||
def test_time_schema_explicit_empty_timezone_accepted(
|
||||
set_core_config: SetCoreConfigCallable,
|
||||
) -> None:
|
||||
"""An empty timezone string (timezone-disable sentinel) must pass TIME_SCHEMA."""
|
||||
set_core_config(PlatformFramework.ESP32_IDF)
|
||||
config = TIME_SCHEMA({CONF_TIMEZONE: ""})
|
||||
assert config[CONF_TIMEZONE] == ""
|
||||
|
||||
|
||||
def test_time_schema_timezone_rejected_on_zephyr(
|
||||
set_core_config: SetCoreConfigCallable,
|
||||
) -> None:
|
||||
"""TIME_SCHEMA must reject a timezone value on Zephyr with the framework error.
|
||||
|
||||
The platform check (cv.only_with_framework) must run BEFORE validate_tz so
|
||||
that users receive an actionable "unsupported framework" message rather than a
|
||||
confusing TZ-parsing error.
|
||||
"""
|
||||
set_core_config(PlatformFramework.NRF52_ZEPHYR)
|
||||
with pytest.raises(cv.Invalid, match="only available with framework"):
|
||||
TIME_SCHEMA({CONF_TIMEZONE: "UTC0"})
|
||||
|
||||
|
||||
def test_time_schema_invalid_tz_on_zephyr_gives_framework_error(
|
||||
set_core_config: SetCoreConfigCallable,
|
||||
) -> None:
|
||||
"""Even a syntactically invalid TZ string must produce the framework error on Zephyr.
|
||||
|
||||
This specifically tests that cv.only_with_framework is evaluated before
|
||||
validate_tz: if the order were reversed, an invalid POSIX string would
|
||||
generate a misleading TZ-parsing error instead.
|
||||
"""
|
||||
set_core_config(PlatformFramework.NRF52_ZEPHYR)
|
||||
with pytest.raises(cv.Invalid, match="only available with framework"):
|
||||
TIME_SCHEMA({CONF_TIMEZONE: "NOTAVALIDTZ!!!"})
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# homeassistant/time: USE_HOMEASSISTANT_TIMEZONE define
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def mock_ha_cg():
|
||||
"""Mock codegen functions used by homeassistant/time to_code."""
|
||||
with (
|
||||
mock.patch(
|
||||
"esphome.components.homeassistant.time.cg.new_Pvariable",
|
||||
return_value=mock.MagicMock(),
|
||||
),
|
||||
mock.patch(
|
||||
"esphome.components.homeassistant.time.cg.add_define",
|
||||
) as mock_add_define,
|
||||
mock.patch(
|
||||
"esphome.components.homeassistant.time.cg.register_component",
|
||||
new_callable=mock.AsyncMock,
|
||||
),
|
||||
mock.patch(
|
||||
"esphome.components.homeassistant.time.time_.register_time",
|
||||
new_callable=mock.AsyncMock,
|
||||
),
|
||||
):
|
||||
yield mock_add_define
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_ha_time_defines_ha_timezone_when_no_explicit_tz(mock_ha_cg) -> None:
|
||||
"""When CONF_TIMEZONE is absent from the config, to_code() must call
|
||||
cg.add_define('USE_HOMEASSISTANT_TIMEZONE')."""
|
||||
from esphome.components.homeassistant.time import to_code
|
||||
|
||||
await to_code({CONF_ID: mock.MagicMock()})
|
||||
|
||||
mock_ha_cg.assert_any_call("USE_HOMEASSISTANT_TIMEZONE")
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_ha_time_no_ha_timezone_define_when_explicit_tz(mock_ha_cg) -> None:
|
||||
"""When CONF_TIMEZONE is present in the config, to_code() must NOT call
|
||||
cg.add_define('USE_HOMEASSISTANT_TIMEZONE')."""
|
||||
from esphome.components.homeassistant.time import to_code
|
||||
|
||||
await to_code({CONF_ID: mock.MagicMock(), CONF_TIMEZONE: "UTC0"})
|
||||
|
||||
define_calls = [call.args[0] for call in mock_ha_cg.call_args_list]
|
||||
assert "USE_HOMEASSISTANT_TIME" in define_calls
|
||||
assert "USE_HOMEASSISTANT_TIMEZONE" not in define_calls
|
||||
@@ -120,12 +120,12 @@ api:
|
||||
lambda: 'return condition;'
|
||||
then:
|
||||
- logger.log:
|
||||
format: "Condition true, value: %d"
|
||||
args: ['value']
|
||||
format: "Condition true, value: %ld"
|
||||
args: ['(long) value']
|
||||
else:
|
||||
- logger.log:
|
||||
format: "Condition false, value: %d"
|
||||
args: ['value']
|
||||
format: "Condition false, value: %ld"
|
||||
args: ['(long) value']
|
||||
- logger.log: "After if/else"
|
||||
# Test nested IfAction (multiple ContinuationAction instances)
|
||||
- action: test_nested_if
|
||||
@@ -171,8 +171,8 @@ api:
|
||||
count: !lambda 'return count;'
|
||||
then:
|
||||
- logger.log:
|
||||
format: "Repeat iteration: %d"
|
||||
args: ['iteration']
|
||||
format: "Repeat iteration: %lu"
|
||||
args: ['(unsigned long) iteration']
|
||||
- logger.log: "After repeat"
|
||||
# Test combined continuations (if + while + repeat)
|
||||
- action: test_combined_continuations
|
||||
@@ -193,8 +193,8 @@ api:
|
||||
lambda: 'return id(api_continuation_test_counter) > 0;'
|
||||
then:
|
||||
- logger.log:
|
||||
format: "Combined: repeat=%d, while=%d"
|
||||
args: ['iteration', 'id(api_continuation_test_counter)']
|
||||
format: "Combined: repeat=%lu, while=%d"
|
||||
args: ['(unsigned long) iteration', 'id(api_continuation_test_counter)']
|
||||
- lambda: 'id(api_continuation_test_counter)--;'
|
||||
else:
|
||||
- logger.log: "Skipped loops"
|
||||
@@ -208,8 +208,8 @@ api:
|
||||
- api.respond:
|
||||
success: true
|
||||
- logger.log:
|
||||
format: "Status response sent (call_id=%d)"
|
||||
args: [call_id]
|
||||
format: "Status response sent (call_id=%lu)"
|
||||
args: ['(unsigned long) call_id']
|
||||
|
||||
- action: test_respond_status_error
|
||||
variables:
|
||||
@@ -229,8 +229,8 @@ api:
|
||||
value: float
|
||||
then:
|
||||
- logger.log:
|
||||
format: "Optional response (call_id=%d, return_response=%d)"
|
||||
args: [call_id, return_response]
|
||||
format: "Optional response (call_id=%lu, return_response=%lu)"
|
||||
args: ['(unsigned long) call_id', '(unsigned long) return_response']
|
||||
- api.respond:
|
||||
data: !lambda |-
|
||||
root["sensor"] = sensor_name;
|
||||
@@ -264,8 +264,8 @@ api:
|
||||
input: string
|
||||
then:
|
||||
- logger.log:
|
||||
format: "Only response (call_id=%d)"
|
||||
args: [call_id]
|
||||
format: "Only response (call_id=%lu)"
|
||||
args: ['(unsigned long) call_id']
|
||||
- api.respond:
|
||||
data: !lambda |-
|
||||
root["input"] = input;
|
||||
|
||||
@@ -0,0 +1,11 @@
|
||||
audio_file:
|
||||
- id: test_audio
|
||||
file:
|
||||
type: local
|
||||
path: $component_dir/test.wav
|
||||
|
||||
media_source:
|
||||
- platform: audio_file
|
||||
id: audio_file_source
|
||||
# task_stack_in_psram: false must validate without a psram: component
|
||||
task_stack_in_psram: false
|
||||
@@ -1,6 +1,8 @@
|
||||
<<: !include common.yaml
|
||||
|
||||
esp32_ble_tracker:
|
||||
|
||||
esp32_ble:
|
||||
max_connections: 9
|
||||
|
||||
bluetooth_proxy:
|
||||
|
||||
@@ -3,6 +3,7 @@ esp32_hosted:
|
||||
slot: 1
|
||||
active_high: true
|
||||
reset_pin: GPIO15
|
||||
use_psram: true
|
||||
cmd_pin: GPIO13
|
||||
clk_pin: GPIO12
|
||||
d0_pin: GPIO11
|
||||
|
||||
@@ -2,6 +2,8 @@ esphome:
|
||||
debug_scheduler: true
|
||||
platformio_options:
|
||||
board_build.flash_mode: dio
|
||||
build_flags:
|
||||
- "-DESPHOME_TEST_BUILD_FLAG"
|
||||
environment_variables:
|
||||
TEST_ENV_VAR: "test_value"
|
||||
BUILD_NUMBER: "12345"
|
||||
|
||||
@@ -1313,6 +1313,7 @@ lvgl:
|
||||
width: 6
|
||||
start_value: 0
|
||||
end_value: 360
|
||||
rounded: true
|
||||
- id: page3
|
||||
layout: Horizontal
|
||||
pad_all: 6px
|
||||
|
||||
@@ -1,3 +1,6 @@
|
||||
psram:
|
||||
mode: quad
|
||||
|
||||
i2s_audio:
|
||||
i2s_lrclk_pin: GPIO18
|
||||
i2s_bclk_pin: GPIO19
|
||||
@@ -12,6 +15,7 @@ microphone:
|
||||
|
||||
micro_wake_word:
|
||||
microphone: echo_microphone
|
||||
task_stack_in_psram: true
|
||||
on_wake_word_detected:
|
||||
- logger.log: "Wake word detected"
|
||||
- micro_wake_word.stop:
|
||||
|
||||
@@ -0,0 +1,165 @@
|
||||
#include "../common.h"
|
||||
|
||||
namespace esphome::mitsubishi_cn105::testing {
|
||||
|
||||
TEST(MitsubishiCN105ClimateTests, SupportedSwingModeOffLeavesTraitsEmpty) {
|
||||
TestableMitsubishiCN105Climate sut;
|
||||
|
||||
sut.set_supported_swing_mode(climate::CLIMATE_SWING_OFF);
|
||||
|
||||
EXPECT_FALSE(sut.traits().get_supports_swing_modes());
|
||||
}
|
||||
|
||||
TEST(MitsubishiCN105ClimateTests, SupportedSwingModeVerticalExposesOffAndVertical) {
|
||||
TestableMitsubishiCN105Climate sut;
|
||||
|
||||
sut.set_supported_swing_mode(climate::CLIMATE_SWING_VERTICAL);
|
||||
|
||||
EXPECT_TRUE(sut.traits().supports_swing_mode(climate::CLIMATE_SWING_OFF));
|
||||
EXPECT_TRUE(sut.traits().supports_swing_mode(climate::CLIMATE_SWING_VERTICAL));
|
||||
EXPECT_FALSE(sut.traits().supports_swing_mode(climate::CLIMATE_SWING_HORIZONTAL));
|
||||
EXPECT_FALSE(sut.traits().supports_swing_mode(climate::CLIMATE_SWING_BOTH));
|
||||
}
|
||||
|
||||
TEST(MitsubishiCN105ClimateTests, SupportedSwingModeHorizontalExposesOffAndHorizontal) {
|
||||
TestableMitsubishiCN105Climate sut;
|
||||
|
||||
sut.set_supported_swing_mode(climate::CLIMATE_SWING_HORIZONTAL);
|
||||
|
||||
EXPECT_TRUE(sut.traits().supports_swing_mode(climate::CLIMATE_SWING_OFF));
|
||||
EXPECT_FALSE(sut.traits().supports_swing_mode(climate::CLIMATE_SWING_VERTICAL));
|
||||
EXPECT_TRUE(sut.traits().supports_swing_mode(climate::CLIMATE_SWING_HORIZONTAL));
|
||||
EXPECT_FALSE(sut.traits().supports_swing_mode(climate::CLIMATE_SWING_BOTH));
|
||||
}
|
||||
|
||||
TEST(MitsubishiCN105ClimateTests, SupportedSwingModeBothExposesAllExpectedModes) {
|
||||
TestableMitsubishiCN105Climate sut;
|
||||
|
||||
sut.set_supported_swing_mode(climate::CLIMATE_SWING_BOTH);
|
||||
|
||||
EXPECT_TRUE(sut.traits().supports_swing_mode(climate::CLIMATE_SWING_OFF));
|
||||
EXPECT_TRUE(sut.traits().supports_swing_mode(climate::CLIMATE_SWING_VERTICAL));
|
||||
EXPECT_TRUE(sut.traits().supports_swing_mode(climate::CLIMATE_SWING_HORIZONTAL));
|
||||
EXPECT_TRUE(sut.traits().supports_swing_mode(climate::CLIMATE_SWING_BOTH));
|
||||
}
|
||||
|
||||
TEST(MitsubishiCN105ClimateTests, ApplyValuesMapsVerticalSwingWhenSupported) {
|
||||
TestableMitsubishiCN105Climate sut;
|
||||
|
||||
sut.set_supported_swing_mode(climate::CLIMATE_SWING_VERTICAL);
|
||||
|
||||
sut.status().vane_mode = MitsubishiCN105::VaneMode::SWING;
|
||||
sut.status().wide_vane_mode = MitsubishiCN105::WideVaneMode::CENTER;
|
||||
|
||||
sut.apply_values_();
|
||||
|
||||
EXPECT_EQ(sut.swing_mode, climate::CLIMATE_SWING_VERTICAL);
|
||||
}
|
||||
|
||||
TEST(MitsubishiCN105ClimateTests, ApplyValuesMapsHorizontalSwingWhenSupported) {
|
||||
TestableMitsubishiCN105Climate sut;
|
||||
|
||||
sut.set_supported_swing_mode(climate::CLIMATE_SWING_HORIZONTAL);
|
||||
|
||||
sut.status().vane_mode = MitsubishiCN105::VaneMode::AUTO;
|
||||
sut.status().wide_vane_mode = MitsubishiCN105::WideVaneMode::SWING;
|
||||
|
||||
sut.apply_values_();
|
||||
|
||||
EXPECT_EQ(sut.swing_mode, climate::CLIMATE_SWING_HORIZONTAL);
|
||||
}
|
||||
|
||||
TEST(MitsubishiCN105ClimateTests, ApplyValuesMapsBothSwingWhenSupported) {
|
||||
TestableMitsubishiCN105Climate sut;
|
||||
|
||||
sut.set_supported_swing_mode(climate::CLIMATE_SWING_BOTH);
|
||||
|
||||
sut.status().vane_mode = MitsubishiCN105::VaneMode::SWING;
|
||||
sut.status().wide_vane_mode = MitsubishiCN105::WideVaneMode::SWING;
|
||||
|
||||
sut.apply_values_();
|
||||
|
||||
EXPECT_EQ(sut.swing_mode, climate::CLIMATE_SWING_BOTH);
|
||||
}
|
||||
|
||||
TEST(MitsubishiCN105ClimateTests, ApplyValuesMapsSwingOffWhenNoSwingActive) {
|
||||
TestableMitsubishiCN105Climate sut;
|
||||
|
||||
sut.set_supported_swing_mode(climate::CLIMATE_SWING_BOTH);
|
||||
|
||||
sut.status().vane_mode = MitsubishiCN105::VaneMode::POSITION_3;
|
||||
sut.status().wide_vane_mode = MitsubishiCN105::WideVaneMode::CENTER;
|
||||
|
||||
sut.apply_values_();
|
||||
|
||||
EXPECT_EQ(sut.swing_mode, climate::CLIMATE_SWING_OFF);
|
||||
}
|
||||
|
||||
TEST(MitsubishiCN105ClimateTests, ApplyValuesRemembersLastNonSwingPositions) {
|
||||
TestableMitsubishiCN105Climate sut;
|
||||
|
||||
sut.set_supported_swing_mode(climate::CLIMATE_SWING_BOTH);
|
||||
|
||||
sut.status().vane_mode = MitsubishiCN105::VaneMode::POSITION_4;
|
||||
sut.status().wide_vane_mode = MitsubishiCN105::WideVaneMode::RIGHT;
|
||||
|
||||
sut.apply_values_();
|
||||
|
||||
EXPECT_EQ(sut.last_non_swing_vane_mode_, MitsubishiCN105::VaneMode::POSITION_4);
|
||||
EXPECT_EQ(sut.last_non_swing_wide_vane_mode_, MitsubishiCN105::WideVaneMode::RIGHT);
|
||||
|
||||
sut.status().vane_mode = MitsubishiCN105::VaneMode::SWING;
|
||||
sut.status().wide_vane_mode = MitsubishiCN105::WideVaneMode::SWING;
|
||||
|
||||
sut.apply_values_();
|
||||
|
||||
EXPECT_EQ(sut.last_non_swing_vane_mode_, MitsubishiCN105::VaneMode::POSITION_4);
|
||||
EXPECT_EQ(sut.last_non_swing_wide_vane_mode_, MitsubishiCN105::WideVaneMode::RIGHT);
|
||||
EXPECT_EQ(sut.swing_mode, climate::CLIMATE_SWING_BOTH);
|
||||
}
|
||||
|
||||
TEST(MitsubishiCN105ClimateTests, ApplyValuesDoesNotOverwriteRememberedPositionWithUnknownValues) {
|
||||
TestableMitsubishiCN105Climate sut;
|
||||
|
||||
sut.set_supported_swing_mode(climate::CLIMATE_SWING_BOTH);
|
||||
|
||||
sut.last_non_swing_vane_mode_ = MitsubishiCN105::VaneMode::POSITION_2;
|
||||
sut.last_non_swing_wide_vane_mode_ = MitsubishiCN105::WideVaneMode::LEFT;
|
||||
|
||||
sut.status().vane_mode = MitsubishiCN105::VaneMode::UNKNOWN;
|
||||
sut.status().wide_vane_mode = MitsubishiCN105::WideVaneMode::UNKNOWN;
|
||||
|
||||
sut.apply_values_();
|
||||
|
||||
EXPECT_EQ(sut.last_non_swing_vane_mode_, MitsubishiCN105::VaneMode::POSITION_2);
|
||||
EXPECT_EQ(sut.last_non_swing_wide_vane_mode_, MitsubishiCN105::WideVaneMode::LEFT);
|
||||
EXPECT_EQ(sut.swing_mode, climate::CLIMATE_SWING_OFF);
|
||||
}
|
||||
|
||||
TEST(MitsubishiCN105ClimateTests, ApplyValuesIgnoresUnsupportedVerticalSwingState) {
|
||||
TestableMitsubishiCN105Climate sut;
|
||||
|
||||
sut.set_supported_swing_mode(climate::CLIMATE_SWING_HORIZONTAL);
|
||||
|
||||
sut.status().vane_mode = MitsubishiCN105::VaneMode::SWING;
|
||||
sut.status().wide_vane_mode = MitsubishiCN105::WideVaneMode::CENTER;
|
||||
|
||||
sut.apply_values_();
|
||||
|
||||
EXPECT_EQ(sut.swing_mode, climate::CLIMATE_SWING_OFF);
|
||||
}
|
||||
|
||||
TEST(MitsubishiCN105ClimateTests, ApplyValuesIgnoresUnsupportedHorizontalSwingState) {
|
||||
TestableMitsubishiCN105Climate sut;
|
||||
|
||||
sut.set_supported_swing_mode(climate::CLIMATE_SWING_VERTICAL);
|
||||
|
||||
sut.status().vane_mode = MitsubishiCN105::VaneMode::AUTO;
|
||||
sut.status().wide_vane_mode = MitsubishiCN105::WideVaneMode::SWING;
|
||||
|
||||
sut.apply_values_();
|
||||
|
||||
EXPECT_EQ(sut.swing_mode, climate::CLIMATE_SWING_OFF);
|
||||
}
|
||||
|
||||
} // namespace esphome::mitsubishi_cn105::testing
|
||||
@@ -8,6 +8,7 @@
|
||||
#include <vector>
|
||||
#include "esphome/components/uart/uart_component.h"
|
||||
#include "esphome/components/mitsubishi_cn105/mitsubishi_cn105.h"
|
||||
#include "esphome/components/mitsubishi_cn105/mitsubishi_cn105_climate.h"
|
||||
|
||||
namespace esphome::mitsubishi_cn105::testing {
|
||||
|
||||
@@ -44,6 +45,7 @@ class TestableMitsubishiCN105 : public MitsubishiCN105 {
|
||||
using MitsubishiCN105::State;
|
||||
using MitsubishiCN105::UpdateFlag;
|
||||
using MitsubishiCN105::state_;
|
||||
using MitsubishiCN105::status_;
|
||||
using MitsubishiCN105::operation_start_ms_;
|
||||
using MitsubishiCN105::use_temperature_encoding_b_;
|
||||
using MitsubishiCN105::set_wide_vane_high_bit_;
|
||||
@@ -58,4 +60,13 @@ class TestableMitsubishiCN105 : public MitsubishiCN105 {
|
||||
void set_current_time(uint32_t ms) { test_loop_time_ms = ms; }
|
||||
};
|
||||
|
||||
class TestableMitsubishiCN105Climate : public MitsubishiCN105Climate {
|
||||
public:
|
||||
using MitsubishiCN105Climate::apply_values_;
|
||||
using MitsubishiCN105Climate::last_non_swing_vane_mode_;
|
||||
using MitsubishiCN105Climate::last_non_swing_wide_vane_mode_;
|
||||
|
||||
MitsubishiCN105::Status &status() { return static_cast<TestableMitsubishiCN105 &>(this->hp_).status_; }
|
||||
};
|
||||
|
||||
} // namespace esphome::mitsubishi_cn105::testing
|
||||
|
||||
@@ -3,6 +3,9 @@ climate:
|
||||
id: ac
|
||||
name: "AC Test"
|
||||
uart_id: uart_bus
|
||||
update_interval: 30s
|
||||
current_temperature_min_interval: 120s
|
||||
supported_swing_modes: BOTH
|
||||
|
||||
esphome:
|
||||
on_boot:
|
||||
|
||||
@@ -16,8 +16,12 @@ speaker:
|
||||
id: speaker_id
|
||||
dac_type: external
|
||||
i2s_dout_pin: ${dout_pin}
|
||||
bits_per_sample: 32bit
|
||||
channel: stereo
|
||||
- platform: mixer
|
||||
output_speaker: speaker_id
|
||||
bits_per_sample: 32
|
||||
num_channels: 2
|
||||
source_speakers:
|
||||
- id: source_speaker_1_id
|
||||
- id: source_speaker_2_id
|
||||
|
||||
@@ -0,0 +1,40 @@
|
||||
esphome:
|
||||
on_boot:
|
||||
then:
|
||||
- router.speaker.switch_output:
|
||||
id: router_id
|
||||
target_speaker: speaker_b_id
|
||||
# id omitted: auto-resolved since there's a single router instance
|
||||
- router.speaker.switch_output:
|
||||
target_speaker: !lambda return id(speaker_a_id);
|
||||
|
||||
i2s_audio:
|
||||
i2s_lrclk_pin: ${a_lrclk_pin}
|
||||
i2s_bclk_pin: ${a_bclk_pin}
|
||||
|
||||
speaker:
|
||||
- platform: i2s_audio
|
||||
id: speaker_a_id
|
||||
dac_type: external
|
||||
i2s_dout_pin: ${a_dout_pin}
|
||||
sample_rate: 48000
|
||||
bits_per_sample: 16bit
|
||||
channel: stereo
|
||||
- platform: i2s_audio
|
||||
id: speaker_b_id
|
||||
dac_type: external
|
||||
i2s_dout_pin: ${b_dout_pin}
|
||||
spdif_mode: true
|
||||
use_apll: true
|
||||
sample_rate: 48000
|
||||
bits_per_sample: 16bit
|
||||
channel: stereo
|
||||
i2s_mode: primary
|
||||
- platform: router
|
||||
id: router_id
|
||||
output_speakers:
|
||||
- speaker_a_id
|
||||
- speaker_b_id
|
||||
sample_rate: 48000
|
||||
bits_per_sample: 16
|
||||
num_channels: 2
|
||||
@@ -0,0 +1,7 @@
|
||||
substitutions:
|
||||
a_lrclk_pin: GPIO4
|
||||
a_bclk_pin: GPIO5
|
||||
a_dout_pin: GPIO14
|
||||
b_dout_pin: GPIO19
|
||||
|
||||
<<: !include common.yaml
|
||||
@@ -1,4 +1,5 @@
|
||||
rp2040:
|
||||
variant: rp2040
|
||||
enable_full_printf: false
|
||||
|
||||
logger:
|
||||
|
||||
@@ -0,0 +1,6 @@
|
||||
rp2040:
|
||||
variant: rp2350
|
||||
enable_full_printf: false
|
||||
|
||||
logger:
|
||||
level: VERBOSE
|
||||
@@ -34,9 +34,7 @@ def test_get_base_frontend_path_dev_mode() -> None:
|
||||
# The function uses Path.resolve() which resolves symlinks
|
||||
# The actual function adds "/" to the path, so we simulate that
|
||||
test_path_with_slash = test_path if test_path.endswith("/") else test_path + "/"
|
||||
expected = (
|
||||
Path(os.getcwd()) / test_path_with_slash / "esphome_dashboard"
|
||||
).resolve()
|
||||
expected = (Path.cwd() / test_path_with_slash / "esphome_dashboard").resolve()
|
||||
assert result == expected
|
||||
|
||||
|
||||
@@ -62,9 +60,7 @@ def test_get_base_frontend_path_dev_mode_relative_path() -> None:
|
||||
# The function uses Path.resolve() which resolves symlinks
|
||||
# The actual function adds "/" to the path, so we simulate that
|
||||
test_path_with_slash = test_path if test_path.endswith("/") else test_path + "/"
|
||||
expected = (
|
||||
Path(os.getcwd()) / test_path_with_slash / "esphome_dashboard"
|
||||
).resolve()
|
||||
expected = (Path.cwd() / test_path_with_slash / "esphome_dashboard").resolve()
|
||||
assert result == expected
|
||||
assert result.is_absolute()
|
||||
|
||||
@@ -157,7 +153,7 @@ def test_load_file_path(tmp_path: Path) -> None:
|
||||
test_file = tmp_path / "test.txt"
|
||||
test_file.write_bytes(b"test content")
|
||||
|
||||
with open(test_file, "rb") as f:
|
||||
with test_file.open("rb") as f:
|
||||
content = f.read()
|
||||
assert content == b"test content"
|
||||
|
||||
|
||||
@@ -79,7 +79,7 @@ def shared_platformio_cache() -> Generator[Path]:
|
||||
lock_file = Path.home() / ".esphome-integration-tests-init.lock"
|
||||
|
||||
# Always acquire the lock to ensure cache is ready before proceeding
|
||||
with open(lock_file, "w") as lock_fd:
|
||||
with lock_file.open("w") as lock_fd:
|
||||
fcntl.flock(lock_fd.fileno(), fcntl.LOCK_EX)
|
||||
|
||||
# Check if the native platform is installed (the actual indicator of a populated cache)
|
||||
@@ -407,8 +407,10 @@ async def wait_and_connect_api_client(
|
||||
# Wait for connection with timeout
|
||||
try:
|
||||
await asyncio.wait_for(connected_future, timeout=timeout)
|
||||
except TimeoutError:
|
||||
raise TimeoutError(f"Failed to connect to API after {timeout} seconds")
|
||||
except TimeoutError as err:
|
||||
raise TimeoutError(
|
||||
f"Failed to connect to API after {timeout} seconds"
|
||||
) from err
|
||||
|
||||
if return_disconnect_event:
|
||||
yield client, disconnect_event
|
||||
|
||||
@@ -43,7 +43,7 @@ async def test_gpio_expander_cache(
|
||||
# ensure logs are in the expected order
|
||||
log_order = [
|
||||
(digital_read_hw_pattern, 0),
|
||||
[(digital_read_cache_pattern, i) for i in range(0, 8)],
|
||||
[(digital_read_cache_pattern, i) for i in range(8)],
|
||||
(digital_read_hw_pattern, 8),
|
||||
[(digital_read_cache_pattern, i) for i in range(8, 16)],
|
||||
(digital_read_hw_pattern, 16),
|
||||
@@ -68,7 +68,7 @@ async def test_gpio_expander_cache(
|
||||
# uint16_t component tests (single bank of 16 pins)
|
||||
(uint16_read_hw_pattern, 0), # First pin triggers hw read
|
||||
[
|
||||
(uint16_read_cache_pattern, i) for i in range(0, 16)
|
||||
(uint16_read_cache_pattern, i) for i in range(16)
|
||||
], # All 16 pins return via cache
|
||||
# After cache reset
|
||||
(uint16_read_hw_pattern, 5), # First read after reset triggers hw
|
||||
|
||||
@@ -3,8 +3,11 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import ast
|
||||
import importlib.util
|
||||
from pathlib import Path
|
||||
|
||||
from esphome import config_validation as cv
|
||||
|
||||
SCRIPT_PATH = (
|
||||
Path(__file__).resolve().parent.parent.parent
|
||||
/ "script"
|
||||
@@ -12,10 +15,16 @@ SCRIPT_PATH = (
|
||||
)
|
||||
|
||||
|
||||
def _load_script_module():
|
||||
spec = importlib.util.spec_from_file_location("build_language_schema", SCRIPT_PATH)
|
||||
module = importlib.util.module_from_spec(spec)
|
||||
spec.loader.exec_module(module)
|
||||
return module
|
||||
|
||||
|
||||
def _extract_sort_obj():
|
||||
# build_language_schema.py runs argparse, loads every component, and
|
||||
# calls build_schema() at import time, so a plain import isn't viable
|
||||
# in a unit test. Pull just the pure helper out via AST instead.
|
||||
# ``sort_obj`` is pure and self-contained; pulling it via AST avoids
|
||||
# exercising the module-level component-loading state for these tests.
|
||||
tree = ast.parse(SCRIPT_PATH.read_text())
|
||||
for node in tree.body:
|
||||
if isinstance(node, ast.FunctionDef) and node.name == "sort_obj":
|
||||
@@ -27,6 +36,7 @@ def _extract_sort_obj():
|
||||
|
||||
|
||||
sort_obj = _extract_sort_obj()
|
||||
_bls = _load_script_module()
|
||||
|
||||
|
||||
def test_sort_obj_sorts_dict_keys() -> None:
|
||||
@@ -96,3 +106,56 @@ def test_sort_obj_passes_through_scalars() -> None:
|
||||
assert sort_obj(42) == 42
|
||||
assert sort_obj(None) is None
|
||||
assert sort_obj(True) is True
|
||||
|
||||
|
||||
def test_convert_emits_explicit_sensitive_marker() -> None:
|
||||
config_var: dict = {}
|
||||
_bls.convert(cv.sensitive(cv.string), config_var, "/test")
|
||||
|
||||
assert config_var["sensitive"] is True
|
||||
assert config_var["sensitive_source"] == "explicit"
|
||||
assert config_var["type"] == "string"
|
||||
|
||||
|
||||
def test_convert_keys_emits_heuristic_sensitive_marker() -> None:
|
||||
converted: dict = {}
|
||||
_bls.convert_keys(converted, {cv.Optional("password"): cv.string}, "/root")
|
||||
|
||||
entry = converted["schema"]["config_vars"]["password"]
|
||||
assert entry["sensitive"] is True
|
||||
assert entry["sensitive_source"] == "heuristic"
|
||||
assert entry["type"] == "string"
|
||||
|
||||
|
||||
def test_convert_keys_explicit_beats_heuristic() -> None:
|
||||
# Key name matches a fragment but the validator is explicitly wrapped;
|
||||
# the explicit branch should win and emit ``sensitive_source: explicit``.
|
||||
converted: dict = {}
|
||||
_bls.convert_keys(
|
||||
converted, {cv.Optional("password"): cv.sensitive(cv.string)}, "/root"
|
||||
)
|
||||
|
||||
entry = converted["schema"]["config_vars"]["password"]
|
||||
assert entry["sensitive"] is True
|
||||
assert entry["sensitive_source"] == "explicit"
|
||||
|
||||
|
||||
def test_convert_keys_no_heuristic_for_non_string_leaves() -> None:
|
||||
# Even though the key contains a fragment, a non-string leaf must not
|
||||
# be flagged. Prevents false positives on unrelated fields whose name
|
||||
# happens to embed a substring like "token".
|
||||
converted: dict = {}
|
||||
_bls.convert_keys(converted, {cv.Optional("password"): cv.boolean}, "/root")
|
||||
|
||||
entry = converted["schema"]["config_vars"]["password"]
|
||||
assert "sensitive" not in entry
|
||||
assert "sensitive_source" not in entry
|
||||
|
||||
|
||||
def test_convert_keys_no_marker_for_non_sensitive_field() -> None:
|
||||
converted: dict = {}
|
||||
_bls.convert_keys(converted, {cv.Optional("hostname"): cv.string}, "/root")
|
||||
|
||||
entry = converted["schema"]["config_vars"]["hostname"]
|
||||
assert "sensitive" not in entry
|
||||
assert "sensitive_source" not in entry
|
||||
|
||||
@@ -4,7 +4,6 @@ from __future__ import annotations
|
||||
|
||||
import importlib.util
|
||||
import json
|
||||
import os
|
||||
from pathlib import Path
|
||||
import sys
|
||||
from unittest.mock import patch
|
||||
@@ -13,12 +12,10 @@ import pytest
|
||||
|
||||
# Load the script-under-test as `check_import_time` (it's a hyphenated path
|
||||
# inside `script/` that mirrors the existing `determine_jobs` pattern).
|
||||
script_dir = os.path.abspath(
|
||||
os.path.join(os.path.dirname(__file__), "..", "..", "script")
|
||||
)
|
||||
script_dir = str((Path(__file__).parent / ".." / ".." / "script").resolve())
|
||||
sys.path.insert(0, script_dir)
|
||||
spec = importlib.util.spec_from_file_location(
|
||||
"check_import_time", os.path.join(script_dir, "check_import_time.py")
|
||||
"check_import_time", str(Path(script_dir) / "check_import_time.py")
|
||||
)
|
||||
check_import_time = importlib.util.module_from_spec(spec)
|
||||
spec.loader.exec_module(check_import_time)
|
||||
|
||||
@@ -3,7 +3,6 @@
|
||||
from collections.abc import Generator
|
||||
import importlib.util
|
||||
import json
|
||||
import os
|
||||
from pathlib import Path
|
||||
import sys
|
||||
from unittest.mock import Mock, call, patch
|
||||
@@ -11,9 +10,7 @@ from unittest.mock import Mock, call, patch
|
||||
import pytest
|
||||
|
||||
# Add the script directory to Python path so we can import the module
|
||||
script_dir = os.path.abspath(
|
||||
os.path.join(os.path.dirname(__file__), "..", "..", "script")
|
||||
)
|
||||
script_dir = str((Path(__file__).parent / ".." / ".." / "script").resolve())
|
||||
sys.path.insert(0, script_dir)
|
||||
|
||||
# Import helpers module for patching
|
||||
@@ -22,7 +19,7 @@ import helpers # noqa: E402
|
||||
import script.helpers # noqa: E402
|
||||
|
||||
spec = importlib.util.spec_from_file_location(
|
||||
"determine_jobs", os.path.join(script_dir, "determine-jobs.py")
|
||||
"determine_jobs", str(Path(script_dir) / "determine-jobs.py")
|
||||
)
|
||||
determine_jobs = importlib.util.module_from_spec(spec)
|
||||
spec.loader.exec_module(determine_jobs)
|
||||
|
||||
@@ -12,9 +12,7 @@ import pytest
|
||||
from pytest import MonkeyPatch
|
||||
|
||||
# Add the script directory to Python path so we can import helpers
|
||||
sys.path.insert(
|
||||
0, os.path.abspath(os.path.join(os.path.dirname(__file__), "..", "..", "script"))
|
||||
)
|
||||
sys.path.insert(0, str((Path(__file__).parent / ".." / ".." / "script").resolve()))
|
||||
|
||||
import helpers # noqa: E402
|
||||
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
"""Unit tests for script/build_helpers.py manifest override and build helpers."""
|
||||
|
||||
import os
|
||||
from pathlib import Path
|
||||
import sys
|
||||
import textwrap
|
||||
@@ -9,9 +8,7 @@ from unittest.mock import MagicMock, patch
|
||||
import pytest
|
||||
|
||||
# Add the script directory to Python path so we can import build_helpers
|
||||
sys.path.insert(
|
||||
0, os.path.abspath(os.path.join(os.path.dirname(__file__), "..", "..", "script"))
|
||||
)
|
||||
sys.path.insert(0, str((Path(__file__).parent / ".." / ".." / "script").resolve()))
|
||||
|
||||
import build_helpers # noqa: E402
|
||||
|
||||
|
||||
@@ -4,6 +4,7 @@ esphome:
|
||||
|
||||
esp32:
|
||||
board: esp32-c6-devkitc-1
|
||||
flash_size: 8MB
|
||||
framework:
|
||||
type: esp-idf
|
||||
|
||||
|
||||
@@ -1,6 +1,11 @@
|
||||
"""Tests for RP2040 component public helpers."""
|
||||
"""Tests for RP2040 component public helpers and variant detection."""
|
||||
|
||||
from esphome.components.rp2040 import board_id_has_wifi
|
||||
import pytest
|
||||
|
||||
from esphome.components.rp2040 import _detect_variant, board_id_has_wifi
|
||||
from esphome.components.rp2040.const import VARIANT_RP2040, VARIANT_RP2350
|
||||
import esphome.config_validation as cv
|
||||
from esphome.const import CONF_BOARD, CONF_VARIANT
|
||||
|
||||
|
||||
def test_board_id_has_wifi_for_known_wifi_board() -> None:
|
||||
@@ -27,3 +32,61 @@ def test_board_id_has_wifi_for_unknown_board_returns_true() -> None:
|
||||
"no CYW43" guard at compile time.
|
||||
"""
|
||||
assert board_id_has_wifi("not-a-real-board-id") is True
|
||||
|
||||
|
||||
def test_detect_variant_derives_variant_from_board() -> None:
|
||||
"""Board alone resolves to the matching variant."""
|
||||
result = _detect_variant({CONF_BOARD: "rpipicow"})
|
||||
assert result[CONF_BOARD] == "rpipicow"
|
||||
assert result[CONF_VARIANT] == VARIANT_RP2040
|
||||
|
||||
|
||||
def test_detect_variant_derives_variant_from_rp2350_board() -> None:
|
||||
"""An RP2350 board resolves to ``RP2350``."""
|
||||
result = _detect_variant({CONF_BOARD: "rpipico2"})
|
||||
assert result[CONF_BOARD] == "rpipico2"
|
||||
assert result[CONF_VARIANT] == VARIANT_RP2350
|
||||
|
||||
|
||||
def test_detect_variant_only_picks_default_board_rp2040() -> None:
|
||||
"""Variant alone picks Pico W as the canonical RP2040 board."""
|
||||
result = _detect_variant({CONF_VARIANT: VARIANT_RP2040})
|
||||
assert result[CONF_BOARD] == "rpipicow"
|
||||
assert result[CONF_VARIANT] == VARIANT_RP2040
|
||||
|
||||
|
||||
def test_detect_variant_only_picks_default_board_rp2350() -> None:
|
||||
"""Variant alone picks Pico 2 W as the canonical RP2350 board."""
|
||||
result = _detect_variant({CONF_VARIANT: VARIANT_RP2350})
|
||||
assert result[CONF_BOARD] == "rpipico2w"
|
||||
assert result[CONF_VARIANT] == VARIANT_RP2350
|
||||
|
||||
|
||||
def test_detect_variant_matching_explicit_variant_passes() -> None:
|
||||
"""Specifying both a board and the matching variant is allowed."""
|
||||
result = _detect_variant({CONF_BOARD: "rpipico2", CONF_VARIANT: VARIANT_RP2350})
|
||||
assert result[CONF_BOARD] == "rpipico2"
|
||||
assert result[CONF_VARIANT] == VARIANT_RP2350
|
||||
|
||||
|
||||
def test_detect_variant_mismatched_variant_raises() -> None:
|
||||
"""Board/variant mismatch must be rejected and name the offending board."""
|
||||
with pytest.raises(
|
||||
cv.Invalid, match=r"does not match the selected board 'rpipicow'"
|
||||
):
|
||||
_detect_variant({CONF_BOARD: "rpipicow", CONF_VARIANT: VARIANT_RP2350})
|
||||
|
||||
|
||||
def test_detect_variant_unknown_board_without_variant_raises() -> None:
|
||||
"""Unknown board with no variant tells the user how to recover."""
|
||||
with pytest.raises(cv.Invalid, match="please specify the chip variant"):
|
||||
_detect_variant({CONF_BOARD: "not-a-real-board"})
|
||||
|
||||
|
||||
def test_detect_variant_unknown_board_with_variant_passes() -> None:
|
||||
"""Unknown board + explicit variant is accepted (with a warning)."""
|
||||
result = _detect_variant(
|
||||
{CONF_BOARD: "not-a-real-board", CONF_VARIANT: VARIANT_RP2040}
|
||||
)
|
||||
assert result[CONF_BOARD] == "not-a-real-board"
|
||||
assert result[CONF_VARIANT] == VARIANT_RP2040
|
||||
|
||||
@@ -70,11 +70,11 @@ def test_numeric_offset_slash() -> None:
|
||||
|
||||
|
||||
def test_star() -> None:
|
||||
assert _parse_cron_part("*", 0, 59, {}) == set(range(0, 60))
|
||||
assert _parse_cron_part("*", 0, 59, {}) == set(range(60))
|
||||
|
||||
|
||||
def test_question() -> None:
|
||||
assert _parse_cron_part("?", 0, 59, {}) == set(range(0, 60))
|
||||
assert _parse_cron_part("?", 0, 59, {}) == set(range(60))
|
||||
|
||||
|
||||
def test_range() -> None:
|
||||
|
||||
@@ -486,7 +486,7 @@ def test_preload_core_config_basic(setup_core: Path) -> None:
|
||||
assert CONF_BUILD_PATH in config[CONF_ESPHOME]
|
||||
# Verify default build path is "build/<device_name>"
|
||||
build_path = config[CONF_ESPHOME][CONF_BUILD_PATH]
|
||||
assert build_path.endswith(os.path.join("build", "test_device"))
|
||||
assert build_path.endswith(str(Path("build") / "test_device"))
|
||||
|
||||
|
||||
def test_preload_core_config_with_build_path(setup_core: Path) -> None:
|
||||
@@ -523,7 +523,7 @@ def test_preload_core_config_env_build_path(setup_core: Path) -> None:
|
||||
assert "test_device" in config[CONF_ESPHOME][CONF_BUILD_PATH]
|
||||
# Verify it uses the env var path with device name appended
|
||||
build_path = config[CONF_ESPHOME][CONF_BUILD_PATH]
|
||||
expected_path = os.path.join("/env/build", "test_device")
|
||||
expected_path = str(Path("/env/build") / "test_device")
|
||||
assert build_path == expected_path or build_path == expected_path.replace(
|
||||
"/", os.sep
|
||||
)
|
||||
@@ -739,7 +739,7 @@ async def test_add_includes_with_single_file(
|
||||
"""Test add_includes copies a single header file to build directory."""
|
||||
CORE.config_path = tmp_path / "config.yaml"
|
||||
CORE.build_path = tmp_path / "build"
|
||||
os.makedirs(CORE.build_path, exist_ok=True)
|
||||
CORE.build_path.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
# Create include file
|
||||
include_file = tmp_path / "my_header.h"
|
||||
@@ -769,7 +769,7 @@ async def test_add_includes_with_directory_unix(
|
||||
"""Test add_includes copies all files from a directory on Unix."""
|
||||
CORE.config_path = tmp_path / "config.yaml"
|
||||
CORE.build_path = tmp_path / "build"
|
||||
os.makedirs(CORE.build_path, exist_ok=True)
|
||||
CORE.build_path.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
# Create include directory with files
|
||||
include_dir = tmp_path / "includes"
|
||||
@@ -814,7 +814,7 @@ async def test_add_includes_with_directory_windows(
|
||||
"""Test add_includes copies all files from a directory on Windows."""
|
||||
CORE.config_path = tmp_path / "config.yaml"
|
||||
CORE.build_path = tmp_path / "build"
|
||||
os.makedirs(CORE.build_path, exist_ok=True)
|
||||
CORE.build_path.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
# Create include directory with files
|
||||
include_dir = tmp_path / "includes"
|
||||
@@ -856,7 +856,7 @@ async def test_add_includes_with_multiple_sources(
|
||||
"""Test add_includes with multiple files and directories."""
|
||||
CORE.config_path = tmp_path / "config.yaml"
|
||||
CORE.build_path = tmp_path / "build"
|
||||
os.makedirs(CORE.build_path, exist_ok=True)
|
||||
CORE.build_path.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
# Create various include sources
|
||||
single_file = tmp_path / "single.h"
|
||||
@@ -884,7 +884,7 @@ async def test_add_includes_empty_directory(
|
||||
"""Test add_includes with an empty directory doesn't fail."""
|
||||
CORE.config_path = tmp_path / "config.yaml"
|
||||
CORE.build_path = tmp_path / "build"
|
||||
os.makedirs(CORE.build_path, exist_ok=True)
|
||||
CORE.build_path.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
# Create empty directory
|
||||
empty_dir = tmp_path / "empty"
|
||||
@@ -906,7 +906,7 @@ async def test_add_includes_preserves_directory_structure_unix(
|
||||
"""Test that add_includes preserves relative directory structure on Unix."""
|
||||
CORE.config_path = tmp_path / "config.yaml"
|
||||
CORE.build_path = tmp_path / "build"
|
||||
os.makedirs(CORE.build_path, exist_ok=True)
|
||||
CORE.build_path.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
# Create nested directory structure
|
||||
lib_dir = tmp_path / "lib"
|
||||
@@ -940,7 +940,7 @@ async def test_add_includes_preserves_directory_structure_windows(
|
||||
"""Test that add_includes preserves relative directory structure on Windows."""
|
||||
CORE.config_path = tmp_path / "config.yaml"
|
||||
CORE.build_path = tmp_path / "build"
|
||||
os.makedirs(CORE.build_path, exist_ok=True)
|
||||
CORE.build_path.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
# Create nested directory structure
|
||||
lib_dir = tmp_path / "lib"
|
||||
@@ -973,7 +973,7 @@ async def test_add_includes_overwrites_existing_files(
|
||||
"""Test that add_includes overwrites existing files in build directory."""
|
||||
CORE.config_path = tmp_path / "config.yaml"
|
||||
CORE.build_path = tmp_path / "build"
|
||||
os.makedirs(CORE.build_path, exist_ok=True)
|
||||
CORE.build_path.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
# Create include file
|
||||
include_file = tmp_path / "header.h"
|
||||
|
||||
@@ -67,7 +67,7 @@ def test_iter_component_configs_with_multi_conf(mock_get_component: Mock) -> Non
|
||||
configs = list(config.iter_component_configs(test_config))
|
||||
assert len(configs) == 2
|
||||
|
||||
for domain, component, conf in configs:
|
||||
for domain, _component, conf in configs:
|
||||
assert domain == "switch"
|
||||
assert "name" in conf
|
||||
|
||||
|
||||
@@ -27,6 +27,7 @@ from esphome.const import (
|
||||
SCHEDULER_DONT_RUN,
|
||||
)
|
||||
from esphome.core import CORE, HexInt, Lambda
|
||||
from esphome.yaml_util import SensitiveStr
|
||||
|
||||
|
||||
def test_check_not_templatable__invalid():
|
||||
@@ -127,6 +128,85 @@ def test_string_string__invalid(value):
|
||||
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,
|
||||
|
||||
@@ -261,9 +261,14 @@ def test_check_library_data_invalid_platform(esp32_idf_core):
|
||||
_check_library_data({"platforms": ["other"], "frameworks": "*"})
|
||||
|
||||
|
||||
def test_check_library_data_invalid_framework(esp32_idf_core):
|
||||
with pytest.raises(InvalidIDFComponent):
|
||||
_check_library_data({"platforms": "*", "frameworks": ["other"]})
|
||||
def test_check_library_data_invalid_framework(
|
||||
esp32_idf_core: None, caplog: pytest.LogCaptureFixture
|
||||
) -> None:
|
||||
# Framework mismatch is a warning, not a hard skip: the library is still
|
||||
# included so that PIO manifests that only list "arduino" (but actually
|
||||
# compile under IDF) can be used without forking them.
|
||||
_check_library_data({"name": "lib", "platforms": "*", "frameworks": ["other"]})
|
||||
assert "do not include 'espidf'" in caplog.text
|
||||
|
||||
|
||||
def test_extra_script_captures_libpath_libs_and_defines(tmp_path):
|
||||
@@ -288,7 +293,7 @@ def test_extra_script_captures_libpath_libs_and_defines(tmp_path):
|
||||
|
||||
result = run_extra_script(script, library_dir=tmp_path, idf_target="esp32")
|
||||
|
||||
assert result.libpath == [os.path.join("src", "esp32")]
|
||||
assert result.libpath == [str(Path("src") / "esp32")]
|
||||
assert result.libs == ["algobsec"]
|
||||
assert ("BAR", "1") in result.cppdefines
|
||||
assert "FOO" in result.cppdefines
|
||||
|
||||
@@ -0,0 +1,156 @@
|
||||
"""Tests for esphome.espidf.framework helpers."""
|
||||
|
||||
# pylint: disable=protected-access
|
||||
|
||||
from pathlib import Path
|
||||
from unittest.mock import patch
|
||||
|
||||
import pytest
|
||||
|
||||
from esphome.espidf.framework import _clone_idf_with_submodules, _parse_git_source
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("source", "expected"),
|
||||
[
|
||||
# github:// shorthand
|
||||
(
|
||||
"github://espressif/esp-idf",
|
||||
("https://github.com/espressif/esp-idf.git", None),
|
||||
),
|
||||
(
|
||||
"github://espressif/esp-idf@master",
|
||||
("https://github.com/espressif/esp-idf.git", "master"),
|
||||
),
|
||||
(
|
||||
"github://espressif/esp-idf@release/v6.0",
|
||||
("https://github.com/espressif/esp-idf.git", "release/v6.0"),
|
||||
),
|
||||
# explicit https://github.com/...git URL
|
||||
(
|
||||
"https://github.com/espressif/esp-idf.git",
|
||||
("https://github.com/espressif/esp-idf.git", None),
|
||||
),
|
||||
(
|
||||
"https://github.com/espressif/esp-idf.git@master",
|
||||
("https://github.com/espressif/esp-idf.git", "master"),
|
||||
),
|
||||
(
|
||||
"https://github.com/espressif/esp-idf.git@v6.0.1",
|
||||
("https://github.com/espressif/esp-idf.git", "v6.0.1"),
|
||||
),
|
||||
# Tolerate a trailing ".git" on the shorthand so the user doesn't
|
||||
# silently end up with a doubled "...esp-idf.git.git" URL.
|
||||
(
|
||||
"github://espressif/esp-idf.git",
|
||||
("https://github.com/espressif/esp-idf.git", None),
|
||||
),
|
||||
(
|
||||
"github://espressif/esp-idf.git@master",
|
||||
("https://github.com/espressif/esp-idf.git", "master"),
|
||||
),
|
||||
],
|
||||
)
|
||||
def test_parse_git_source_recognized(
|
||||
source: str, expected: tuple[str, str | None]
|
||||
) -> None:
|
||||
assert _parse_git_source(source) == expected
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"source",
|
||||
[
|
||||
# archive URLs fall through to the existing download path
|
||||
"https://github.com/espressif/esp-idf/archive/refs/heads/master.zip",
|
||||
"https://dl.espressif.com/dl/esp-idf/v6.0.1/esp-idf-v6.0.1.zip",
|
||||
"https://github.com/esphome-libs/esp-idf/releases/download/v5.5.4/esp-idf-v5.5.4.tar.xz",
|
||||
# SSH and other git protocols are intentionally rejected — match
|
||||
# external_components, which only recognizes github:// + structured
|
||||
# dicts for these.
|
||||
"git@github.com:espressif/esp-idf.git",
|
||||
"ssh://git@github.com/espressif/esp-idf.git",
|
||||
"git://github.com/espressif/esp-idf.git",
|
||||
# non-GitHub .git URLs are intentionally rejected for the same reason
|
||||
"https://gitlab.com/foo/bar.git",
|
||||
"https://github.example.com/foo/bar.git",
|
||||
],
|
||||
)
|
||||
def test_parse_git_source_rejected(source: str) -> None:
|
||||
assert _parse_git_source(source) is None
|
||||
|
||||
|
||||
def _make_idf_tree(framework_path: Path) -> None:
|
||||
"""Create the minimum tree _clone_idf_with_submodules sanity-checks for."""
|
||||
(framework_path / "tools").mkdir(parents=True)
|
||||
(framework_path / "tools" / "idf_tools.py").write_text("# stub\n")
|
||||
|
||||
|
||||
def test_clone_idf_with_submodules_without_ref(tmp_path: Path) -> None:
|
||||
framework_path = tmp_path / "idf"
|
||||
framework_path.mkdir()
|
||||
_make_idf_tree(framework_path)
|
||||
|
||||
with patch("esphome.git.run_git_command", return_value="") as run_git_command_mock:
|
||||
_clone_idf_with_submodules(
|
||||
framework_path, "https://github.com/espressif/esp-idf.git", None
|
||||
)
|
||||
|
||||
# No ref -> just clone + submodule update, no fetch/reset.
|
||||
calls = [c.args[0] for c in run_git_command_mock.call_args_list]
|
||||
assert calls[0] == [
|
||||
"git",
|
||||
"clone",
|
||||
"--depth=1",
|
||||
"--",
|
||||
"https://github.com/espressif/esp-idf.git",
|
||||
str(framework_path),
|
||||
]
|
||||
assert calls[-1][:5] == ["git", "submodule", "update", "--init", "--recursive"]
|
||||
assert not any(c[1] == "fetch" for c in calls)
|
||||
assert not any(c[1] == "reset" for c in calls)
|
||||
|
||||
|
||||
def test_clone_idf_with_submodules_with_ref(tmp_path: Path) -> None:
|
||||
framework_path = tmp_path / "idf"
|
||||
framework_path.mkdir()
|
||||
_make_idf_tree(framework_path)
|
||||
|
||||
with patch("esphome.git.run_git_command", return_value="") as run_git_command_mock:
|
||||
_clone_idf_with_submodules(
|
||||
framework_path,
|
||||
"https://github.com/espressif/esp-idf.git",
|
||||
"master",
|
||||
)
|
||||
|
||||
calls = [c.args[0] for c in run_git_command_mock.call_args_list]
|
||||
# clone, fetch ref, reset hard, submodule update
|
||||
assert calls[0][:2] == ["git", "clone"]
|
||||
assert calls[1] == [
|
||||
"git",
|
||||
"fetch",
|
||||
"--depth=1",
|
||||
"--",
|
||||
"origin",
|
||||
"master",
|
||||
]
|
||||
assert calls[2] == ["git", "reset", "--hard", "FETCH_HEAD"]
|
||||
assert calls[3][:5] == ["git", "submodule", "update", "--init", "--recursive"]
|
||||
|
||||
|
||||
def test_clone_idf_with_submodules_raises_when_tree_missing(
|
||||
tmp_path: Path,
|
||||
) -> None:
|
||||
framework_path = tmp_path / "idf"
|
||||
framework_path.mkdir()
|
||||
# Deliberately do NOT call _make_idf_tree — simulate a clone that
|
||||
# returned 0 but produced no tools/idf_tools.py.
|
||||
|
||||
with (
|
||||
patch("esphome.git.run_git_command", return_value=""),
|
||||
pytest.raises(RuntimeError, match="no usable ESP-IDF tree"),
|
||||
):
|
||||
_clone_idf_with_submodules(
|
||||
framework_path,
|
||||
"https://github.com/espressif/esp-idf.git",
|
||||
None,
|
||||
)
|
||||
@@ -120,7 +120,7 @@ def test_is_file_recent_with_old_file(setup_core: Path) -> None:
|
||||
|
||||
old_time = time.time() - 7200
|
||||
mock_stat = MagicMock()
|
||||
mock_stat.st_ctime = old_time
|
||||
mock_stat.st_mtime = old_time
|
||||
|
||||
with patch.object(Path, "stat", return_value=mock_stat):
|
||||
refresh = TimePeriod(seconds=3600)
|
||||
@@ -147,7 +147,7 @@ def test_is_file_recent_with_zero_refresh(setup_core: Path) -> None:
|
||||
|
||||
# Mock stat to return a time 10 seconds ago
|
||||
mock_stat = MagicMock()
|
||||
mock_stat.st_ctime = time.time() - 10
|
||||
mock_stat.st_mtime = time.time() - 10
|
||||
with patch.object(Path, "stat", return_value=mock_stat):
|
||||
refresh = TimePeriod(seconds=0)
|
||||
result = external_files.is_file_recent(test_file, refresh)
|
||||
|
||||
+311
-10
@@ -1,10 +1,10 @@
|
||||
"""Tests for git.py module."""
|
||||
|
||||
from datetime import datetime, timedelta
|
||||
import os
|
||||
from pathlib import Path
|
||||
import time
|
||||
from typing import Any
|
||||
from unittest.mock import Mock
|
||||
from unittest.mock import Mock, patch
|
||||
|
||||
import pytest
|
||||
|
||||
@@ -34,9 +34,9 @@ def _setup_old_repo(repo_dir: Path, days_old: int = 2) -> None:
|
||||
# Create FETCH_HEAD file with old timestamp
|
||||
fetch_head = git_dir / "FETCH_HEAD"
|
||||
fetch_head.write_text("test")
|
||||
old_time = datetime.now() - timedelta(days=days_old)
|
||||
old_time = time.time() - days_old * 86400
|
||||
fetch_head.touch()
|
||||
os.utime(fetch_head, (old_time.timestamp(), old_time.timestamp()))
|
||||
os.utime(fetch_head, (old_time, old_time))
|
||||
|
||||
|
||||
def _get_git_command_type(cmd: list[str]) -> str | None:
|
||||
@@ -285,10 +285,10 @@ def test_clone_or_update_with_refresh_updates_old_repo(
|
||||
# Create FETCH_HEAD file with old timestamp (2 days ago)
|
||||
fetch_head = git_dir / "FETCH_HEAD"
|
||||
fetch_head.write_text("test")
|
||||
old_time = datetime.now() - timedelta(days=2)
|
||||
old_time = time.time() - 2 * 86400
|
||||
fetch_head.touch() # Create the file
|
||||
# Set modification time to 2 days ago
|
||||
os.utime(fetch_head, (old_time.timestamp(), old_time.timestamp()))
|
||||
os.utime(fetch_head, (old_time, old_time))
|
||||
|
||||
# Mock git command responses
|
||||
mock_run_git_command.return_value = "abc123" # SHA for rev-parse
|
||||
@@ -333,10 +333,10 @@ def test_clone_or_update_with_refresh_skips_fresh_repo(
|
||||
# Create FETCH_HEAD file with recent timestamp (1 hour ago)
|
||||
fetch_head = git_dir / "FETCH_HEAD"
|
||||
fetch_head.write_text("test")
|
||||
recent_time = datetime.now() - timedelta(hours=1)
|
||||
recent_time = time.time() - 3600
|
||||
fetch_head.touch() # Create the file
|
||||
# Set modification time to 1 hour ago
|
||||
os.utime(fetch_head, (recent_time.timestamp(), recent_time.timestamp()))
|
||||
os.utime(fetch_head, (recent_time, recent_time))
|
||||
|
||||
# Call with refresh=1d (1 day)
|
||||
refresh = TimePeriodSeconds(days=1)
|
||||
@@ -409,10 +409,10 @@ def test_clone_or_update_with_none_refresh_always_updates(
|
||||
# Create FETCH_HEAD file with very recent timestamp (1 second ago)
|
||||
fetch_head = git_dir / "FETCH_HEAD"
|
||||
fetch_head.write_text("test")
|
||||
recent_time = datetime.now() - timedelta(seconds=1)
|
||||
recent_time = time.time() - 1
|
||||
fetch_head.touch() # Create the file
|
||||
# Set modification time to 1 second ago
|
||||
os.utime(fetch_head, (recent_time.timestamp(), recent_time.timestamp()))
|
||||
os.utime(fetch_head, (recent_time, recent_time))
|
||||
|
||||
# Mock git command responses
|
||||
mock_run_git_command.return_value = "abc123" # SHA for rev-parse
|
||||
@@ -1001,3 +1001,304 @@ def test_refresh_picks_up_new_remote_commits(
|
||||
"--hard",
|
||||
"old_sha",
|
||||
]
|
||||
|
||||
|
||||
def test_resolve_symlink_stub_returns_none_on_non_windows(
|
||||
tmp_path: Path, mock_run_git_command: Mock
|
||||
) -> None:
|
||||
"""On non-Windows, resolve_symlink_stub returns None without calling git."""
|
||||
repo_dir = tmp_path / "repo"
|
||||
repo_dir.mkdir()
|
||||
stub = repo_dir / "file.yaml"
|
||||
stub.write_text("static/file.yaml")
|
||||
|
||||
with patch("esphome.git.sys.platform", "linux"):
|
||||
result = git.resolve_symlink_stub(repo_dir, stub)
|
||||
|
||||
assert result is None
|
||||
mock_run_git_command.assert_not_called()
|
||||
|
||||
|
||||
def test_resolve_symlink_stub_returns_target_for_mode_120000(
|
||||
tmp_path: Path, mock_run_git_command: Mock
|
||||
) -> None:
|
||||
"""A mode-120000 file is recognised as a stub; its target Path is returned."""
|
||||
repo_dir = tmp_path / "repo"
|
||||
repo_dir.mkdir()
|
||||
(repo_dir / "static").mkdir()
|
||||
|
||||
target = repo_dir / "static" / "real.yaml"
|
||||
target.write_text("esphome:\n name: real\n")
|
||||
|
||||
stub = repo_dir / "real.yaml"
|
||||
stub.write_text("static/real.yaml")
|
||||
|
||||
mock_run_git_command.return_value = "120000 abc123 0\treal.yaml"
|
||||
|
||||
with patch("esphome.git.sys.platform", "win32"):
|
||||
result = git.resolve_symlink_stub(repo_dir, stub)
|
||||
|
||||
assert result == target.resolve()
|
||||
# Stub file itself was not modified — only inspected.
|
||||
assert stub.read_text() == "static/real.yaml"
|
||||
|
||||
|
||||
def test_resolve_symlink_stub_resolves_relative_parent_paths(
|
||||
tmp_path: Path, mock_run_git_command: Mock
|
||||
) -> None:
|
||||
"""Symlink targets with ``..`` segments resolve correctly within the repo."""
|
||||
repo_dir = tmp_path / "repo"
|
||||
(repo_dir / "subdir").mkdir(parents=True)
|
||||
(repo_dir / "static").mkdir()
|
||||
|
||||
target = repo_dir / "static" / "shared.yaml"
|
||||
target.write_text("shared content")
|
||||
|
||||
stub = repo_dir / "subdir" / "shared.yaml"
|
||||
stub.write_text("../static/shared.yaml")
|
||||
|
||||
mock_run_git_command.return_value = "120000 abc123 0\tsubdir/shared.yaml"
|
||||
|
||||
with patch("esphome.git.sys.platform", "win32"):
|
||||
result = git.resolve_symlink_stub(repo_dir, stub)
|
||||
|
||||
assert result == target.resolve()
|
||||
|
||||
|
||||
def test_resolve_symlink_stub_refuses_escape_outside_repo(
|
||||
tmp_path: Path, mock_run_git_command: Mock
|
||||
) -> None:
|
||||
"""A symlink pointing outside the repository is not followed."""
|
||||
outside = tmp_path / "outside.yaml"
|
||||
outside.write_text("sensitive")
|
||||
|
||||
repo_dir = tmp_path / "repo"
|
||||
repo_dir.mkdir()
|
||||
|
||||
stub = repo_dir / "escape.yaml"
|
||||
stub.write_text("../outside.yaml")
|
||||
|
||||
mock_run_git_command.return_value = "120000 abc123 0\tescape.yaml"
|
||||
|
||||
with patch("esphome.git.sys.platform", "win32"):
|
||||
result = git.resolve_symlink_stub(repo_dir, stub)
|
||||
|
||||
assert result is None
|
||||
|
||||
|
||||
def test_resolve_symlink_stub_returns_none_for_real_symlink(
|
||||
tmp_path: Path, mock_run_git_command: Mock
|
||||
) -> None:
|
||||
"""A real symlink already opens transparently, so the helper short-circuits.
|
||||
|
||||
Skipped on Windows where symlink creation requires
|
||||
SeCreateSymbolicLinkPrivilege.
|
||||
"""
|
||||
if os.name == "nt":
|
||||
pytest.skip("Requires symlink-creation privilege on Windows")
|
||||
|
||||
repo_dir = tmp_path / "repo"
|
||||
repo_dir.mkdir()
|
||||
target = repo_dir / "real.yaml"
|
||||
target.write_text("real content")
|
||||
|
||||
real_link = repo_dir / "link.yaml"
|
||||
real_link.symlink_to("real.yaml")
|
||||
|
||||
with patch("esphome.git.sys.platform", "win32"):
|
||||
result = git.resolve_symlink_stub(repo_dir, real_link)
|
||||
|
||||
assert result is None
|
||||
# No git call needed for real symlinks.
|
||||
mock_run_git_command.assert_not_called()
|
||||
|
||||
|
||||
def test_resolve_symlink_stub_returns_none_for_regular_file(
|
||||
tmp_path: Path, mock_run_git_command: Mock
|
||||
) -> None:
|
||||
"""A regular file (mode 100644) whose content looks path-shaped is not
|
||||
followed."""
|
||||
repo_dir = tmp_path / "repo"
|
||||
repo_dir.mkdir()
|
||||
|
||||
regular = repo_dir / "looks_like_path.txt"
|
||||
regular.write_text("static/something.yaml")
|
||||
|
||||
mock_run_git_command.return_value = "100644 abc123 0\tlooks_like_path.txt"
|
||||
|
||||
with patch("esphome.git.sys.platform", "win32"):
|
||||
result = git.resolve_symlink_stub(repo_dir, regular)
|
||||
|
||||
assert result is None
|
||||
|
||||
|
||||
def test_resolve_symlink_stub_returns_none_when_git_fails(
|
||||
tmp_path: Path, mock_run_git_command: Mock
|
||||
) -> None:
|
||||
"""If ``git ls-files`` fails (e.g. not a repo), the helper returns None."""
|
||||
repo_dir = tmp_path / "repo"
|
||||
repo_dir.mkdir()
|
||||
|
||||
stub = repo_dir / "real.yaml"
|
||||
stub.write_text("static/real.yaml")
|
||||
|
||||
mock_run_git_command.side_effect = GitCommandError("ls-files exploded")
|
||||
|
||||
with patch("esphome.git.sys.platform", "win32"):
|
||||
result = git.resolve_symlink_stub(repo_dir, stub)
|
||||
|
||||
assert result is None
|
||||
|
||||
|
||||
def test_resolve_symlink_stub_returns_none_for_non_utf8_content(
|
||||
tmp_path: Path, mock_run_git_command: Mock
|
||||
) -> None:
|
||||
"""A file whose bytes are not valid UTF-8 must not raise — return None."""
|
||||
repo_dir = tmp_path / "repo"
|
||||
repo_dir.mkdir()
|
||||
|
||||
stub = repo_dir / "binary.bin"
|
||||
stub.write_bytes(b"\xff\xfe\x00\xff")
|
||||
|
||||
mock_run_git_command.return_value = "120000 abc123 0\tbinary.bin"
|
||||
|
||||
with patch("esphome.git.sys.platform", "win32"):
|
||||
result = git.resolve_symlink_stub(repo_dir, stub)
|
||||
|
||||
assert result is None
|
||||
|
||||
|
||||
def test_resolve_symlink_stub_preserves_whitespace_in_target(
|
||||
tmp_path: Path, mock_run_git_command: Mock
|
||||
) -> None:
|
||||
"""Only trailing CR/LF is stripped — internal whitespace is preserved."""
|
||||
repo_dir = tmp_path / "repo"
|
||||
repo_dir.mkdir()
|
||||
target_dir = repo_dir / "dir with spaces"
|
||||
target_dir.mkdir()
|
||||
target = target_dir / "real.yaml"
|
||||
target.write_text("hello")
|
||||
|
||||
stub = repo_dir / "link.yaml"
|
||||
# Trailing newline (as git's checkout may append) is stripped, but
|
||||
# whitespace inside the target path itself must survive.
|
||||
stub.write_bytes(b"dir with spaces/real.yaml\n")
|
||||
|
||||
mock_run_git_command.return_value = "120000 abc123 0\tlink.yaml"
|
||||
|
||||
with patch("esphome.git.sys.platform", "win32"):
|
||||
result = git.resolve_symlink_stub(repo_dir, stub)
|
||||
|
||||
assert result == target.resolve()
|
||||
|
||||
|
||||
def test_resolve_symlink_stub_returns_none_for_directory_target(
|
||||
tmp_path: Path, mock_run_git_command: Mock
|
||||
) -> None:
|
||||
"""A symlink pointing at a directory has no file content to load."""
|
||||
repo_dir = tmp_path / "repo"
|
||||
repo_dir.mkdir()
|
||||
(repo_dir / "dir_target").mkdir()
|
||||
|
||||
stub = repo_dir / "link_to_dir"
|
||||
stub.write_text("dir_target")
|
||||
|
||||
mock_run_git_command.return_value = "120000 abc123 0\tlink_to_dir"
|
||||
|
||||
with patch("esphome.git.sys.platform", "win32"):
|
||||
result = git.resolve_symlink_stub(repo_dir, stub)
|
||||
|
||||
assert result is None
|
||||
|
||||
|
||||
def test_resolve_symlink_stub_returns_none_when_resolve_raises(
|
||||
tmp_path: Path, mock_run_git_command: Mock
|
||||
) -> None:
|
||||
"""Path.resolve() raising (e.g. on a malformed target) must not propagate."""
|
||||
repo_dir = tmp_path / "repo"
|
||||
repo_dir.mkdir()
|
||||
|
||||
stub = repo_dir / "broken.yaml"
|
||||
stub.write_text("ignored")
|
||||
|
||||
mock_run_git_command.return_value = "120000 abc123 0\tbroken.yaml"
|
||||
|
||||
with (
|
||||
patch("esphome.git.sys.platform", "win32"),
|
||||
patch.object(Path, "resolve", side_effect=OSError("bad path")),
|
||||
):
|
||||
result = git.resolve_symlink_stub(repo_dir, stub)
|
||||
|
||||
assert result is None
|
||||
|
||||
|
||||
def test_resolve_symlink_stub_returns_none_when_file_missing(
|
||||
tmp_path: Path, mock_run_git_command: Mock
|
||||
) -> None:
|
||||
"""A file path that doesn't exist is rejected before git is consulted."""
|
||||
repo_dir = tmp_path / "repo"
|
||||
repo_dir.mkdir()
|
||||
|
||||
missing = repo_dir / "ghost.yaml" # not created
|
||||
|
||||
with patch("esphome.git.sys.platform", "win32"):
|
||||
result = git.resolve_symlink_stub(repo_dir, missing)
|
||||
|
||||
assert result is None
|
||||
mock_run_git_command.assert_not_called()
|
||||
|
||||
|
||||
def test_resolve_symlink_stub_returns_none_when_path_outside_repo(
|
||||
tmp_path: Path, mock_run_git_command: Mock
|
||||
) -> None:
|
||||
"""A file path that isn't under repo_dir is rejected (ValueError from relative_to)."""
|
||||
repo_dir = tmp_path / "repo"
|
||||
repo_dir.mkdir()
|
||||
|
||||
outside = tmp_path / "stray.yaml"
|
||||
outside.write_text("something")
|
||||
|
||||
with patch("esphome.git.sys.platform", "win32"):
|
||||
result = git.resolve_symlink_stub(repo_dir, outside)
|
||||
|
||||
assert result is None
|
||||
mock_run_git_command.assert_not_called()
|
||||
|
||||
|
||||
def test_resolve_symlink_stub_returns_none_when_untracked(
|
||||
tmp_path: Path, mock_run_git_command: Mock
|
||||
) -> None:
|
||||
"""Empty `git ls-files` output (untracked file) makes the helper return None."""
|
||||
repo_dir = tmp_path / "repo"
|
||||
repo_dir.mkdir()
|
||||
|
||||
stub = repo_dir / "untracked.yaml"
|
||||
stub.write_text("static/foo.yaml")
|
||||
|
||||
mock_run_git_command.return_value = ""
|
||||
|
||||
with patch("esphome.git.sys.platform", "win32"):
|
||||
result = git.resolve_symlink_stub(repo_dir, stub)
|
||||
|
||||
assert result is None
|
||||
|
||||
|
||||
def test_resolve_symlink_stub_returns_none_when_read_bytes_raises(
|
||||
tmp_path: Path, mock_run_git_command: Mock
|
||||
) -> None:
|
||||
"""An OSError from read_bytes() (e.g. file vanished mid-call) must not propagate."""
|
||||
repo_dir = tmp_path / "repo"
|
||||
repo_dir.mkdir()
|
||||
|
||||
stub = repo_dir / "racy.yaml"
|
||||
stub.write_text("static/racy.yaml")
|
||||
|
||||
mock_run_git_command.return_value = "120000 abc123 0\tracy.yaml"
|
||||
|
||||
with (
|
||||
patch("esphome.git.sys.platform", "win32"),
|
||||
patch.object(Path, "read_bytes", side_effect=OSError("vanished")),
|
||||
):
|
||||
result = git.resolve_symlink_stub(repo_dir, stub)
|
||||
|
||||
assert result is None
|
||||
|
||||
@@ -7,7 +7,7 @@ import stat
|
||||
from unittest.mock import MagicMock, patch
|
||||
|
||||
from aioesphomeapi.host_resolver import AddrInfo, IPv4Sockaddr, IPv6Sockaddr
|
||||
from hypothesis import given
|
||||
from hypothesis import given, settings
|
||||
from hypothesis.strategies import ip_addresses
|
||||
import pytest
|
||||
|
||||
@@ -151,6 +151,7 @@ def test_is_ip_address__invalid(host):
|
||||
assert actual is False
|
||||
|
||||
|
||||
@settings(deadline=None)
|
||||
@given(value=ip_addresses(v=4).map(str))
|
||||
def test_is_ip_address__valid(value):
|
||||
actual = helpers.is_ip_address(value)
|
||||
|
||||
@@ -11,7 +11,7 @@ from pathlib import Path
|
||||
import re
|
||||
import sys
|
||||
import time
|
||||
from typing import Any
|
||||
from typing import Any, Self
|
||||
from unittest.mock import AsyncMock, MagicMock, Mock, patch
|
||||
|
||||
import pytest
|
||||
@@ -22,6 +22,7 @@ from esphome.__main__ import (
|
||||
Purpose,
|
||||
_get_configured_xtal_freq,
|
||||
_make_crystal_freq_callback,
|
||||
_redact_with_legacy_fallback,
|
||||
_resolve_network_devices,
|
||||
_validate_bootloader_binary,
|
||||
_validate_partition_table_binary,
|
||||
@@ -29,6 +30,7 @@ from esphome.__main__ import (
|
||||
command_analyze_memory,
|
||||
command_bundle,
|
||||
command_clean_all,
|
||||
command_config,
|
||||
command_config_hash,
|
||||
command_rename,
|
||||
command_run,
|
||||
@@ -340,6 +342,135 @@ def mock_ram_strings_analyzer() -> Generator[Mock]:
|
||||
yield mock_class
|
||||
|
||||
|
||||
def test_redact_with_legacy_fallback__wraps_unmarked_field(
|
||||
caplog: pytest.LogCaptureFixture,
|
||||
) -> None:
|
||||
"""Unmarked sensitive-shaped fields are redacted; a deprecation warning
|
||||
is emitted naming the field."""
|
||||
with caplog.at_level(logging.WARNING, logger="esphome.__main__"):
|
||||
out = _redact_with_legacy_fallback("password: hunter2\n")
|
||||
assert "password: \\033[8mhunter2\\033[28m" in out
|
||||
assert any(
|
||||
"password" in rec.message and "cv.sensitive" in rec.message
|
||||
for rec in caplog.records
|
||||
)
|
||||
|
||||
|
||||
def test_redact_with_legacy_fallback__skips_already_wrapped(
|
||||
caplog: pytest.LogCaptureFixture,
|
||||
) -> None:
|
||||
"""Values already wrapped by the SensitiveStr representer don't trigger
|
||||
the heuristic or the warning."""
|
||||
wrapped = "password: \\033[8mhunter2\\033[28m\n"
|
||||
with caplog.at_level(logging.WARNING, logger="esphome.__main__"):
|
||||
out = _redact_with_legacy_fallback(wrapped)
|
||||
assert out == wrapped
|
||||
assert not any("legacy substring" in rec.message for rec in caplog.records)
|
||||
|
||||
|
||||
def test_redact_with_legacy_fallback__captures_full_field_name(
|
||||
caplog: pytest.LogCaptureFixture,
|
||||
) -> None:
|
||||
"""The warning names the actual field, not just the matched fragment."""
|
||||
with caplog.at_level(logging.WARNING, logger="esphome.__main__"):
|
||||
_redact_with_legacy_fallback("encryption_key: abc\n")
|
||||
assert any("encryption_key" in rec.message for rec in caplog.records)
|
||||
|
||||
|
||||
def test_redact_with_legacy_fallback__deduplicates_warnings(
|
||||
caplog: pytest.LogCaptureFixture,
|
||||
) -> None:
|
||||
"""One warning per unique field name even if it appears many times."""
|
||||
text = "password: a\npassword: b\npassword: c\n"
|
||||
with caplog.at_level(logging.WARNING, logger="esphome.__main__"):
|
||||
_redact_with_legacy_fallback(text)
|
||||
password_warnings = [rec for rec in caplog.records if "'password'" in rec.message]
|
||||
assert len(password_warnings) == 1
|
||||
|
||||
|
||||
def test_redact_with_legacy_fallback__skips_lambda_values(
|
||||
caplog: pytest.LogCaptureFixture,
|
||||
) -> None:
|
||||
"""``!lambda`` first line is structural, body is unreachable by a
|
||||
single-line regex anyway, and tagged fields shouldn't trigger a warning."""
|
||||
text = ' ssid: !lambda |-\n return "x";\n'
|
||||
with caplog.at_level(logging.WARNING, logger="esphome.__main__"):
|
||||
out = _redact_with_legacy_fallback(text)
|
||||
assert out == text
|
||||
assert not any("legacy substring" in rec.message for rec in caplog.records)
|
||||
|
||||
|
||||
def test_redact_with_legacy_fallback__skips_secret_references(
|
||||
caplog: pytest.LogCaptureFixture,
|
||||
) -> None:
|
||||
"""``!secret name`` is the dumper's user-friendly representation; the
|
||||
name isn't the secret, so wrapping it would clobber the round-trip."""
|
||||
text = " password: !secret wifi_password\n"
|
||||
with caplog.at_level(logging.WARNING, logger="esphome.__main__"):
|
||||
out = _redact_with_legacy_fallback(text)
|
||||
assert out == text
|
||||
assert not any("legacy substring" in rec.message for rec in caplog.records)
|
||||
|
||||
|
||||
def test_redact_with_legacy_fallback__does_not_match_fragment_in_middle(
|
||||
caplog: pytest.LogCaptureFixture,
|
||||
) -> None:
|
||||
"""Fragment must end the field name; embedded matches like
|
||||
``key_value_pair`` are unrelated to a sensitive key and must not be
|
||||
redacted (matching the prior regex's scope)."""
|
||||
with caplog.at_level(logging.WARNING, logger="esphome.__main__"):
|
||||
out = _redact_with_legacy_fallback("key_value_pair: abc\n")
|
||||
assert "\\033[8m" not in out
|
||||
assert not any("legacy substring" in rec.message for rec in caplog.records)
|
||||
|
||||
|
||||
def test_redact_with_legacy_fallback__does_not_match_fragment_as_suffix(
|
||||
caplog: pytest.LogCaptureFixture,
|
||||
) -> None:
|
||||
"""Fragment must start the name or follow ``_``; ``monkey:`` shouldn't
|
||||
fire a 'legacy heuristic' warning because there's no sensitive field
|
||||
here — the user has nothing to migrate."""
|
||||
with caplog.at_level(logging.WARNING, logger="esphome.__main__"):
|
||||
out = _redact_with_legacy_fallback("monkey: 1234\n")
|
||||
assert "\\033[8m" not in out
|
||||
assert not any("legacy substring" in rec.message for rec in caplog.records)
|
||||
|
||||
|
||||
def test_command_config__invokes_legacy_fallback_when_redacting(
|
||||
tmp_path: Path, capfd: CaptureFixture[str]
|
||||
) -> None:
|
||||
"""``command_config`` runs the legacy fallback on the dumped output when
|
||||
``--show-secrets`` is off. Cover the wiring (not just the helper).
|
||||
"""
|
||||
setup_core(tmp_path=tmp_path, config={"esphome": {"name": "test"}})
|
||||
args = MockArgs()
|
||||
args.show_secrets = False
|
||||
|
||||
result = command_config(args, {"wifi": {"password": "hunter2"}})
|
||||
|
||||
assert result == 0
|
||||
output = capfd.readouterr().out
|
||||
assert "\\033[8mhunter2\\033[28m" in output
|
||||
|
||||
|
||||
def test_command_config__show_secrets_skips_redaction(
|
||||
tmp_path: Path, capfd: CaptureFixture[str]
|
||||
) -> None:
|
||||
"""With ``--show-secrets`` the helper isn't invoked and the value
|
||||
renders raw.
|
||||
"""
|
||||
setup_core(tmp_path=tmp_path, config={"esphome": {"name": "test"}})
|
||||
args = MockArgs()
|
||||
args.show_secrets = True
|
||||
|
||||
result = command_config(args, {"wifi": {"password": "hunter2"}})
|
||||
|
||||
assert result == 0
|
||||
output = capfd.readouterr().out
|
||||
assert "hunter2" in output
|
||||
assert "\\033[8m" not in output
|
||||
|
||||
|
||||
def test_choose_upload_log_host_with_string_default() -> None:
|
||||
"""Test with a single string default device."""
|
||||
setup_core()
|
||||
@@ -5110,11 +5241,11 @@ class MockSerial:
|
||||
self.timeout = 0.1
|
||||
self._is_open = False
|
||||
|
||||
def __enter__(self) -> MockSerial:
|
||||
def __enter__(self) -> Self:
|
||||
self._is_open = True
|
||||
return self
|
||||
|
||||
def __exit__(self, *args: Any) -> None:
|
||||
def __exit__(self, *args: object) -> None:
|
||||
self._is_open = False
|
||||
|
||||
@property
|
||||
|
||||
@@ -576,8 +576,8 @@ def test_esphome_storage_json_last_update_check_property() -> None:
|
||||
assert result.hour == 10
|
||||
assert result.minute == 30
|
||||
|
||||
# Test setter
|
||||
new_date = datetime(2024, 2, 20, 15, 45, 30)
|
||||
# Test setter — naive datetime matches the storage round-trip format.
|
||||
new_date = datetime(2024, 2, 20, 15, 45, 30) # noqa: DTZ001
|
||||
storage.last_update_check = new_date
|
||||
assert storage.last_update_check_str == "2024-02-20T15:45:30"
|
||||
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
import glob
|
||||
import logging
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
@@ -106,7 +105,7 @@ REMOTES = {
|
||||
# Collect all input YAML files for test_substitutions_fixtures parametrized tests:
|
||||
HERE = Path(__file__).parent
|
||||
BASE_DIR = HERE / "fixtures" / "substitutions"
|
||||
SOURCES = sorted(glob.glob(str(BASE_DIR / "*.input.yaml")))
|
||||
SOURCES = sorted(str(p) for p in BASE_DIR.glob("*.input.yaml"))
|
||||
assert SOURCES, f"test_substitutions_fixtures: No input YAML files found in {BASE_DIR}"
|
||||
|
||||
|
||||
@@ -838,3 +837,86 @@ def test_include_vars_applied_to_lambda_value(tmp_path: Path) -> None:
|
||||
|
||||
assert isinstance(result["value"], Lambda)
|
||||
assert result["value"].value == 'return "bar";'
|
||||
|
||||
|
||||
@patch("esphome.git.resolve_symlink_stub")
|
||||
@patch("esphome.git.clone_or_update")
|
||||
def test_remote_package_symlink_stub_is_followed(
|
||||
mock_clone_or_update: MagicMock,
|
||||
mock_resolve_symlink_stub: MagicMock,
|
||||
tmp_path: Path,
|
||||
) -> None:
|
||||
"""When a package YAML is a scalar (symlink stub) and resolve_symlink_stub
|
||||
returns a target, the loader follows the target and uses its content."""
|
||||
CORE.config_path = tmp_path / "test.yaml"
|
||||
|
||||
repo_dir = tmp_path / "repo"
|
||||
repo_dir.mkdir()
|
||||
(repo_dir / "static").mkdir()
|
||||
|
||||
# Stub file: content is the target path string (simulating Windows behavior).
|
||||
stub = repo_dir / "file1.yaml"
|
||||
stub.write_text("static/file1.yaml")
|
||||
|
||||
# Real target with valid YAML mapping.
|
||||
target = repo_dir / "static" / "file1.yaml"
|
||||
target.write_text("substitutions:\n hello: world\n")
|
||||
|
||||
mock_clone_or_update.return_value = (repo_dir, None)
|
||||
mock_resolve_symlink_stub.return_value = target
|
||||
|
||||
config: dict[str, Any] = {
|
||||
"packages": {
|
||||
"test_package": {
|
||||
"url": "https://github.com/esphome/repo1",
|
||||
"ref": "main",
|
||||
"files": ["file1.yaml"],
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
# Must succeed (does not raise the helpful cv.Invalid) because the stub
|
||||
# was followed and a valid mapping was loaded from the target.
|
||||
do_packages_pass(config)
|
||||
assert mock_resolve_symlink_stub.called
|
||||
|
||||
|
||||
@patch("esphome.git.clone_or_update")
|
||||
def test_remote_package_scalar_yaml_raises_helpful_error(
|
||||
mock_clone_or_update: MagicMock, tmp_path: Path
|
||||
) -> None:
|
||||
"""A remote package YAML that is a top-level scalar (e.g. an unmaterialized
|
||||
git symlink on Windows) raises a clear cv.Invalid, not AttributeError.
|
||||
|
||||
Regression test for the case where a repo containing a YAML symlink,
|
||||
checked out on Windows without symlink privilege, lands as a short text
|
||||
file containing the symlink target path. PyYAML parses that as a bare
|
||||
string scalar; the package loader must reject it with a human-readable
|
||||
error instead of dying inside ``.get()``.
|
||||
"""
|
||||
CORE.config_path = tmp_path / "test.yaml"
|
||||
|
||||
repo_dir = tmp_path / "repo"
|
||||
repo_dir.mkdir()
|
||||
# Simulate the broken-symlink state: a YAML file whose entire content is
|
||||
# the symlink target string. PyYAML parses this as a top-level scalar.
|
||||
(repo_dir / "file1.yaml").write_text("static/file1.yaml")
|
||||
|
||||
mock_clone_or_update.return_value = (repo_dir, None)
|
||||
|
||||
config: dict[str, Any] = {
|
||||
"packages": {
|
||||
"test_package": {
|
||||
"url": "https://github.com/esphome/repo1",
|
||||
"ref": "main",
|
||||
"files": ["file1.yaml"],
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
with pytest.raises(cv.Invalid) as exc_info:
|
||||
do_packages_pass(config)
|
||||
|
||||
msg = str(exc_info.value)
|
||||
assert "mapping at the top level" in msg
|
||||
assert "file1.yaml" in msg
|
||||
|
||||
@@ -1361,7 +1361,7 @@ def test_clean_build_handles_readonly_files(
|
||||
# Create a read-only file (simulating git pack files on Windows)
|
||||
readonly_file = git_dir / "pack-abc123.pack"
|
||||
readonly_file.write_text("pack data")
|
||||
os.chmod(readonly_file, stat.S_IRUSR) # Read-only
|
||||
readonly_file.chmod(stat.S_IRUSR) # Read-only
|
||||
|
||||
# Setup mocks
|
||||
mock_core.relative_pioenvs_path.return_value = pioenvs_dir
|
||||
@@ -1396,7 +1396,7 @@ def test_clean_all_handles_readonly_files(
|
||||
subdir.mkdir()
|
||||
readonly_file = subdir / "readonly.txt"
|
||||
readonly_file.write_text("content")
|
||||
os.chmod(readonly_file, stat.S_IRUSR) # Read-only
|
||||
readonly_file.chmod(stat.S_IRUSR) # Read-only
|
||||
|
||||
# Verify file is read-only
|
||||
assert not os.access(readonly_file, os.W_OK)
|
||||
@@ -1425,7 +1425,7 @@ def test_clean_build_reraises_for_other_errors(
|
||||
test_file.write_text("content")
|
||||
|
||||
# Make subdir read-only so files inside can't be deleted
|
||||
os.chmod(subdir, stat.S_IRUSR | stat.S_IXUSR)
|
||||
subdir.chmod(stat.S_IRUSR | stat.S_IXUSR)
|
||||
|
||||
# Setup mocks
|
||||
mock_core.relative_pioenvs_path.return_value = pioenvs_dir
|
||||
@@ -1443,7 +1443,7 @@ def test_clean_build_reraises_for_other_errors(
|
||||
clean_build()
|
||||
finally:
|
||||
# Cleanup - restore write permission so tmp_path cleanup works
|
||||
os.chmod(subdir, stat.S_IRWXU)
|
||||
subdir.chmod(stat.S_IRWXU)
|
||||
|
||||
|
||||
# Tests for get_build_info()
|
||||
|
||||
@@ -15,6 +15,7 @@ from esphome.yaml_util import (
|
||||
DiscoveredYamlFiles,
|
||||
ESPHomeDataBase,
|
||||
ESPLiteralValue,
|
||||
SensitiveStr,
|
||||
discover_user_yaml_files,
|
||||
force_load_include_files,
|
||||
format_path,
|
||||
@@ -907,7 +908,7 @@ def test_format_path_current_obj_without_location_falls_back_to_key():
|
||||
"""An ESPHomeDataBase current_obj with no esp_range falls back to the key's location."""
|
||||
|
||||
class _NoRange(ESPHomeDataBase, str):
|
||||
pass
|
||||
__slots__ = ()
|
||||
|
||||
obj = _NoRange.__new__(_NoRange, "value")
|
||||
str.__init__(obj)
|
||||
@@ -1340,3 +1341,57 @@ def test_frontmatter_included_file_stored(tmp_path: Path) -> None:
|
||||
assert main.resolve() not in core.CORE.frontmatter
|
||||
# Included file's frontmatter is captured
|
||||
assert core.CORE.frontmatter[inc.resolve()]["child_meta"] == "hello"
|
||||
|
||||
|
||||
def test_sensitive_str__is_a_str_subclass() -> None:
|
||||
value = SensitiveStr("hunter2")
|
||||
assert isinstance(value, str)
|
||||
assert value == "hunter2"
|
||||
|
||||
|
||||
def test_dump__redacts_sensitive_str_by_default() -> None:
|
||||
out = yaml_util.dump({"password": SensitiveStr("hunter2")})
|
||||
assert "\\033[8mhunter2\\033[28m" in out
|
||||
assert "hunter2" not in out.replace(
|
||||
"\\033[8mhunter2\\033[28m", ""
|
||||
) # the raw value is only present inside the wrap
|
||||
|
||||
|
||||
def test_dump__show_secrets_emits_sensitive_str_raw() -> None:
|
||||
out = yaml_util.dump({"password": SensitiveStr("hunter2")}, show_secrets=True)
|
||||
assert "hunter2" in out
|
||||
assert "\\033[8m" not in out
|
||||
assert "\\033[28m" not in out
|
||||
|
||||
|
||||
def test_dump__plain_str_is_not_redacted() -> None:
|
||||
out = yaml_util.dump({"hostname": "myserver"})
|
||||
assert "myserver" in out
|
||||
assert "\\033[8m" not in out
|
||||
|
||||
|
||||
def test_dump__secret_reference_wins_over_redaction() -> None:
|
||||
# If the value also has an entry in _SECRET_VALUES (i.e., it was loaded
|
||||
# via !secret), the dump should render it as !secret <name>, not as a
|
||||
# redacted scalar. SensitiveStr layered on top must not change that.
|
||||
value = SensitiveStr("hunter2")
|
||||
yaml_util._SECRET_VALUES[str(value)] = "my_secret_name"
|
||||
try:
|
||||
out = yaml_util.dump({"password": value})
|
||||
assert "!secret" in out
|
||||
assert "my_secret_name" in out
|
||||
assert "\\033[8m" not in out
|
||||
finally:
|
||||
yaml_util._SECRET_VALUES.clear()
|
||||
|
||||
|
||||
def test_dump__redaction_flag_does_not_leak_between_calls() -> None:
|
||||
# Per-call _Dumper subclass means show_secrets in one call doesn't
|
||||
# affect another. Run them in both orders to catch any leakage.
|
||||
redacted = yaml_util.dump({"password": SensitiveStr("hunter2")})
|
||||
raw = yaml_util.dump({"password": SensitiveStr("hunter2")}, show_secrets=True)
|
||||
redacted_again = yaml_util.dump({"password": SensitiveStr("hunter2")})
|
||||
|
||||
assert "\\033[8m" in redacted
|
||||
assert "\\033[8m" not in raw
|
||||
assert "\\033[8m" in redacted_again
|
||||
|
||||
Reference in New Issue
Block a user