Files
esphome/tests/component_tests/time/test_init.py
2026-05-26 06:51:15 +10:00

370 lines
12 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""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