"""Tests for time component cron expression parsing.""" import errno import subprocess import sys from unittest.mock import MagicMock, patch import pytest from esphome.components.time import _load_tzdata, _parse_cron_part, validate_tz def test_star_slash_seconds() -> None: assert _parse_cron_part("*/10", 0, 60, {}) == {0, 10, 20, 30, 40, 50, 60} def test_star_slash_minutes() -> None: assert _parse_cron_part("*/5", 0, 59, {}) == { 0, 5, 10, 15, 20, 25, 30, 35, 40, 45, 50, 55, } def test_star_slash_hours() -> None: assert _parse_cron_part("*/2", 0, 23, {}) == { 0, 2, 4, 6, 8, 10, 12, 14, 16, 18, 20, 22, } def test_star_slash_days_of_month() -> None: """days_of_month starts at 1, not 0.""" assert _parse_cron_part("*/5", 1, 31, {}) == {1, 6, 11, 16, 21, 26, 31} def test_question_slash() -> None: assert _parse_cron_part("?/10", 0, 60, {}) == {0, 10, 20, 30, 40, 50, 60} def test_empty_offset_slash() -> None: """Empty offset defaults to min_value.""" assert _parse_cron_part("/10", 0, 60, {}) == {0, 10, 20, 30, 40, 50, 60} def test_empty_offset_slash_nonzero_min() -> None: """Empty offset defaults to min_value, not 0.""" assert _parse_cron_part("/5", 1, 31, {}) == {1, 6, 11, 16, 21, 26, 31} def test_numeric_offset_slash() -> None: assert _parse_cron_part("5/10", 0, 60, {}) == {5, 15, 25, 35, 45, 55} def test_star() -> None: assert _parse_cron_part("*", 0, 59, {}) == set(range(60)) def test_question() -> None: assert _parse_cron_part("?", 0, 59, {}) == set(range(60)) def test_range() -> None: assert _parse_cron_part("1-5", 0, 59, {}) == {1, 2, 3, 4, 5} def test_single_value() -> None: assert _parse_cron_part("30", 0, 59, {}) == {30} def _mock_resources_with_error(error: Exception) -> MagicMock: """Return a mock of importlib.resources.files where read_bytes raises error.""" leaf = MagicMock() leaf.read_bytes.side_effect = error package = MagicMock() package.__truediv__.return_value = leaf return MagicMock(return_value=package) def test_load_tzdata_returns_none_on_windows_einval() -> None: """On Windows, opening a tzdata path with NTFS-illegal chars raises OSError(EINVAL). Regression test for crash when the system TZ resolves to a POSIX string like "<+08>-8" (Asia/Shanghai, IST, etc.) and is fed back into _load_tzdata by validate_tz to check whether it is also a valid IANA key. """ err = OSError(errno.EINVAL, "Invalid argument") with patch( "esphome.components.time.resources.files", _mock_resources_with_error(err), ): assert _load_tzdata("<+08>-8") is None def test_load_tzdata_propagates_unexpected_oserror() -> None: """Unrelated OSErrors (e.g. PermissionError) must not be swallowed.""" with ( patch( "esphome.components.time.resources.files", _mock_resources_with_error( PermissionError(errno.EACCES, "Permission denied") ), ), pytest.raises(PermissionError), ): _load_tzdata("Some/Zone") def test_load_tzdata_returns_none_on_file_not_found() -> None: """Existing behavior: missing tz file returns None rather than raising.""" with patch( "esphome.components.time.resources.files", _mock_resources_with_error(FileNotFoundError()), ): assert _load_tzdata("Not/A/Zone") is None def test_validate_tz_accepts_posix_string_when_read_bytes_raises_einval() -> None: """validate_tz must not crash when _load_tzdata hits the Windows EINVAL path. Simulates the Windows case where the auto-detected POSIX TZ string is fed back through _load_tzdata and the underlying read_bytes raises errno 22. """ with patch( "esphome.components.time.resources.files", _mock_resources_with_error(OSError(errno.EINVAL, "Invalid argument")), ): assert validate_tz("<+08>-8") == "<+08>-8" def _modules_after(code: str) -> set[str]: """Run code in a fresh interpreter and return the imported module names. A subprocess is required because the test process itself has already imported aioesphomeapi via other tests, so sys.modules here is useless. """ result = subprocess.run( [sys.executable, "-c", f"import sys\n{code}\nprint('\\n'.join(sys.modules))"], capture_output=True, text=True, check=True, ) return set(result.stdout.split()) def test_importing_time_does_not_import_aioesphomeapi() -> None: """Importing the time component must not drag in aioesphomeapi. aioesphomeapi is a heavy import (it builds a large number of dataclasses at import time). The time component is auto-loaded by many components, so importing it for its schema during config validation must not pay that cost. The import is deferred to the functions that actually need it. """ modules = _modules_after("import esphome.components.time") assert "aioesphomeapi" not in modules def test_validate_tz_imports_aioesphomeapi_lazily() -> None: """Validating a non-empty timezone is what triggers the lazy import. Documents the boundary: the cost is only paid when a timezone is actually validated, not merely by loading the component. """ modules = _modules_after( "from esphome.components.time import validate_tz\n" "validate_tz('EST5EDT,M3.2.0,M11.1.0')" ) assert "aioesphomeapi" in modules