mirror of
https://github.com/esphome/esphome.git
synced 2026-10-07 11:26:39 +00:00
Merge remote-tracking branch 'origin/api-stringref-user-services' into integration
This commit is contained in:
@@ -0,0 +1,177 @@
|
||||
"""Tests for esphome.automation module."""
|
||||
|
||||
from collections.abc import Generator
|
||||
from unittest.mock import patch
|
||||
|
||||
import pytest
|
||||
|
||||
from esphome.automation import has_non_synchronous_actions
|
||||
from esphome.util import RegistryEntry
|
||||
|
||||
|
||||
def _make_registry(non_synchronous_actions: set[str]) -> dict[str, RegistryEntry]:
|
||||
"""Create a mock ACTION_REGISTRY with specified non-synchronous actions.
|
||||
|
||||
Uses the default synchronous=False, matching the real registry behavior.
|
||||
"""
|
||||
registry: dict[str, RegistryEntry] = {}
|
||||
for name in non_synchronous_actions:
|
||||
registry[name] = RegistryEntry(name, lambda: None, None, None)
|
||||
return registry
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def mock_registry() -> Generator[dict[str, RegistryEntry]]:
|
||||
"""Fixture that patches ACTION_REGISTRY with delay, wait_until, script.wait as non-synchronous."""
|
||||
registry: dict[str, RegistryEntry] = _make_registry(
|
||||
{"delay", "wait_until", "script.wait"}
|
||||
)
|
||||
registry["logger.log"] = RegistryEntry(
|
||||
"logger.log", lambda: None, None, None, synchronous=True
|
||||
)
|
||||
with patch("esphome.automation.ACTION_REGISTRY", registry):
|
||||
yield registry
|
||||
|
||||
|
||||
def test_has_non_synchronous_actions_empty_list(
|
||||
mock_registry: dict[str, RegistryEntry],
|
||||
) -> None:
|
||||
assert has_non_synchronous_actions([]) is False
|
||||
|
||||
|
||||
def test_has_non_synchronous_actions_empty_dict(
|
||||
mock_registry: dict[str, RegistryEntry],
|
||||
) -> None:
|
||||
assert has_non_synchronous_actions({}) is False
|
||||
|
||||
|
||||
def test_has_non_synchronous_actions_non_dict_non_list(
|
||||
mock_registry: dict[str, RegistryEntry],
|
||||
) -> None:
|
||||
assert has_non_synchronous_actions("string") is False
|
||||
assert has_non_synchronous_actions(42) is False
|
||||
assert has_non_synchronous_actions(None) is False
|
||||
|
||||
|
||||
def test_has_non_synchronous_actions_delay(
|
||||
mock_registry: dict[str, RegistryEntry],
|
||||
) -> None:
|
||||
assert has_non_synchronous_actions([{"delay": "1s"}]) is True
|
||||
|
||||
|
||||
def test_has_non_synchronous_actions_wait_until(
|
||||
mock_registry: dict[str, RegistryEntry],
|
||||
) -> None:
|
||||
assert has_non_synchronous_actions([{"wait_until": {"condition": {}}}]) is True
|
||||
|
||||
|
||||
def test_has_non_synchronous_actions_script_wait(
|
||||
mock_registry: dict[str, RegistryEntry],
|
||||
) -> None:
|
||||
assert has_non_synchronous_actions([{"script.wait": "script_id"}]) is True
|
||||
|
||||
|
||||
def test_has_non_synchronous_actions_synchronous(
|
||||
mock_registry: dict[str, RegistryEntry],
|
||||
) -> None:
|
||||
assert has_non_synchronous_actions([{"logger.log": "hello"}]) is False
|
||||
|
||||
|
||||
def test_has_non_synchronous_actions_unknown_not_in_registry(
|
||||
mock_registry: dict[str, RegistryEntry],
|
||||
) -> None:
|
||||
"""Unknown actions not in registry are not flagged (only registered actions count)."""
|
||||
assert has_non_synchronous_actions([{"unknown.action": "value"}]) is False
|
||||
|
||||
|
||||
def test_has_non_synchronous_actions_default_non_synchronous(
|
||||
mock_registry: dict[str, RegistryEntry],
|
||||
) -> None:
|
||||
"""Actions registered without explicit synchronous=True default to non-synchronous."""
|
||||
mock_registry["some.action"] = RegistryEntry(
|
||||
"some.action", lambda: None, None, None
|
||||
)
|
||||
assert has_non_synchronous_actions([{"some.action": "value"}]) is True
|
||||
|
||||
|
||||
def test_has_non_synchronous_actions_nested_in_then(
|
||||
mock_registry: dict[str, RegistryEntry],
|
||||
) -> None:
|
||||
"""Non-synchronous action nested inside a synchronous action's then block."""
|
||||
actions: list[dict[str, object]] = [
|
||||
{
|
||||
"logger.log": "first",
|
||||
"then": [{"delay": "1s"}],
|
||||
}
|
||||
]
|
||||
assert has_non_synchronous_actions(actions) is True
|
||||
|
||||
|
||||
def test_has_non_synchronous_actions_deeply_nested(
|
||||
mock_registry: dict[str, RegistryEntry],
|
||||
) -> None:
|
||||
"""Non-synchronous action deeply nested in action structure."""
|
||||
actions: list[dict[str, object]] = [
|
||||
{
|
||||
"if": {
|
||||
"then": [
|
||||
{"logger.log": "hello"},
|
||||
{"delay": "500ms"},
|
||||
]
|
||||
}
|
||||
}
|
||||
]
|
||||
assert has_non_synchronous_actions(actions) is True
|
||||
|
||||
|
||||
def test_has_non_synchronous_actions_none_in_nested(
|
||||
mock_registry: dict[str, RegistryEntry],
|
||||
) -> None:
|
||||
"""No non-synchronous actions even with nesting."""
|
||||
actions: list[dict[str, object]] = [
|
||||
{
|
||||
"if": {
|
||||
"then": [
|
||||
{"logger.log": "hello"},
|
||||
]
|
||||
}
|
||||
}
|
||||
]
|
||||
assert has_non_synchronous_actions(actions) is False
|
||||
|
||||
|
||||
def test_has_non_synchronous_actions_multiple_one_non_synchronous(
|
||||
mock_registry: dict[str, RegistryEntry],
|
||||
) -> None:
|
||||
assert (
|
||||
has_non_synchronous_actions(
|
||||
[
|
||||
{"logger.log": "first"},
|
||||
{"delay": "1s"},
|
||||
{"logger.log": "second"},
|
||||
]
|
||||
)
|
||||
is True
|
||||
)
|
||||
|
||||
|
||||
def test_has_non_synchronous_actions_multiple_all_synchronous(
|
||||
mock_registry: dict[str, RegistryEntry],
|
||||
) -> None:
|
||||
assert (
|
||||
has_non_synchronous_actions(
|
||||
[
|
||||
{"logger.log": "first"},
|
||||
{"logger.log": "second"},
|
||||
]
|
||||
)
|
||||
is False
|
||||
)
|
||||
|
||||
|
||||
def test_has_non_synchronous_actions_dict_input(
|
||||
mock_registry: dict[str, RegistryEntry],
|
||||
) -> None:
|
||||
"""Direct dict input (single action)."""
|
||||
assert has_non_synchronous_actions({"delay": "1s"}) is True
|
||||
assert has_non_synchronous_actions({"logger.log": "hello"}) is False
|
||||
@@ -6,7 +6,7 @@ import os
|
||||
from pathlib import Path
|
||||
import shutil
|
||||
from types import SimpleNamespace
|
||||
from unittest.mock import MagicMock, Mock, patch
|
||||
from unittest.mock import MagicMock, Mock, call, patch
|
||||
|
||||
import pytest
|
||||
|
||||
@@ -673,6 +673,200 @@ def test_process_stacktrace_bad_alloc(
|
||||
assert state is False
|
||||
|
||||
|
||||
def test_patch_file_downloader_succeeds_first_try() -> None:
|
||||
"""Test patch_file_downloader succeeds on first attempt."""
|
||||
mock_exception_cls = type("PackageException", (Exception,), {})
|
||||
original_init = MagicMock()
|
||||
|
||||
with patch.dict(
|
||||
"sys.modules",
|
||||
{
|
||||
"platformio": MagicMock(),
|
||||
"platformio.package": MagicMock(),
|
||||
"platformio.package.download": SimpleNamespace(
|
||||
FileDownloader=type("FileDownloader", (), {"__init__": original_init})
|
||||
),
|
||||
"platformio.package.exception": SimpleNamespace(
|
||||
PackageException=mock_exception_cls
|
||||
),
|
||||
},
|
||||
):
|
||||
platformio_api.patch_file_downloader()
|
||||
|
||||
from platformio.package.download import FileDownloader
|
||||
|
||||
instance = object.__new__(FileDownloader)
|
||||
FileDownloader.__init__(instance, "http://example.com/file.zip")
|
||||
|
||||
original_init.assert_called_once()
|
||||
|
||||
|
||||
def test_patch_file_downloader_retries_on_failure() -> None:
|
||||
"""Test patch_file_downloader retries with backoff on PackageException."""
|
||||
mock_exception_cls = type("PackageException", (Exception,), {})
|
||||
call_count = 0
|
||||
|
||||
def failing_init(self, *args, **kwargs):
|
||||
nonlocal call_count
|
||||
call_count += 1
|
||||
if call_count < 3:
|
||||
raise mock_exception_cls(f"502 error attempt {call_count}")
|
||||
|
||||
with (
|
||||
patch.dict(
|
||||
"sys.modules",
|
||||
{
|
||||
"platformio": MagicMock(),
|
||||
"platformio.package": MagicMock(),
|
||||
"platformio.package.download": SimpleNamespace(
|
||||
FileDownloader=type(
|
||||
"FileDownloader", (), {"__init__": failing_init}
|
||||
)
|
||||
),
|
||||
"platformio.package.exception": SimpleNamespace(
|
||||
PackageException=mock_exception_cls
|
||||
),
|
||||
},
|
||||
),
|
||||
patch("time.sleep") as mock_sleep,
|
||||
):
|
||||
platformio_api.patch_file_downloader()
|
||||
|
||||
from platformio.package.download import FileDownloader
|
||||
|
||||
instance = object.__new__(FileDownloader)
|
||||
FileDownloader.__init__(instance, "http://example.com/file.zip")
|
||||
|
||||
# Should have been called 3 times (2 failures + 1 success)
|
||||
assert call_count == 3
|
||||
|
||||
# Should have slept with exponential backoff: 2s, 4s
|
||||
assert mock_sleep.call_count == 2
|
||||
mock_sleep.assert_any_call(2)
|
||||
mock_sleep.assert_any_call(4)
|
||||
|
||||
|
||||
def test_patch_file_downloader_raises_after_max_retries() -> None:
|
||||
"""Test patch_file_downloader raises after exhausting all retries."""
|
||||
mock_exception_cls = type("PackageException", (Exception,), {})
|
||||
|
||||
def always_failing_init(self, *args, **kwargs):
|
||||
raise mock_exception_cls("502 error")
|
||||
|
||||
with (
|
||||
patch.dict(
|
||||
"sys.modules",
|
||||
{
|
||||
"platformio": MagicMock(),
|
||||
"platformio.package": MagicMock(),
|
||||
"platformio.package.download": SimpleNamespace(
|
||||
FileDownloader=type(
|
||||
"FileDownloader", (), {"__init__": always_failing_init}
|
||||
)
|
||||
),
|
||||
"platformio.package.exception": SimpleNamespace(
|
||||
PackageException=mock_exception_cls
|
||||
),
|
||||
},
|
||||
),
|
||||
patch("time.sleep") as mock_sleep,
|
||||
):
|
||||
platformio_api.patch_file_downloader()
|
||||
|
||||
from platformio.package.download import FileDownloader
|
||||
|
||||
instance = object.__new__(FileDownloader)
|
||||
with pytest.raises(mock_exception_cls, match="502 error"):
|
||||
FileDownloader.__init__(instance, "http://example.com/file.zip")
|
||||
|
||||
# Should have slept 4 times (before attempts 2-5), not on final attempt
|
||||
assert mock_sleep.call_count == 4
|
||||
mock_sleep.assert_has_calls([call(2), call(4), call(8), call(16)])
|
||||
|
||||
|
||||
def test_patch_file_downloader_closes_session_and_response_between_retries() -> None:
|
||||
"""Test patch_file_downloader closes HTTP session and response between retries."""
|
||||
mock_exception_cls = type("PackageException", (Exception,), {})
|
||||
mock_session = MagicMock()
|
||||
mock_response = MagicMock()
|
||||
call_count = 0
|
||||
|
||||
def failing_init_with_session(self, *args, **kwargs):
|
||||
nonlocal call_count
|
||||
call_count += 1
|
||||
self._http_session = mock_session
|
||||
self._http_response = mock_response
|
||||
if call_count < 2:
|
||||
raise mock_exception_cls("502 error")
|
||||
|
||||
with (
|
||||
patch.dict(
|
||||
"sys.modules",
|
||||
{
|
||||
"platformio": MagicMock(),
|
||||
"platformio.package": MagicMock(),
|
||||
"platformio.package.download": SimpleNamespace(
|
||||
FileDownloader=type(
|
||||
"FileDownloader",
|
||||
(),
|
||||
{"__init__": failing_init_with_session},
|
||||
)
|
||||
),
|
||||
"platformio.package.exception": SimpleNamespace(
|
||||
PackageException=mock_exception_cls
|
||||
),
|
||||
},
|
||||
),
|
||||
patch("time.sleep"),
|
||||
):
|
||||
platformio_api.patch_file_downloader()
|
||||
|
||||
from platformio.package.download import FileDownloader
|
||||
|
||||
instance = object.__new__(FileDownloader)
|
||||
FileDownloader.__init__(instance, "http://example.com/file.zip")
|
||||
|
||||
# Both response and session should have been closed between retries
|
||||
mock_response.close.assert_called_once()
|
||||
mock_session.close.assert_called_once()
|
||||
|
||||
|
||||
def test_patch_file_downloader_idempotent() -> None:
|
||||
"""Test patch_file_downloader does not stack wrappers when called multiple times."""
|
||||
mock_exception_cls = type("PackageException", (Exception,), {})
|
||||
call_count = 0
|
||||
|
||||
def counting_init(self, *args, **kwargs):
|
||||
nonlocal call_count
|
||||
call_count += 1
|
||||
|
||||
with patch.dict(
|
||||
"sys.modules",
|
||||
{
|
||||
"platformio": MagicMock(),
|
||||
"platformio.package": MagicMock(),
|
||||
"platformio.package.download": SimpleNamespace(
|
||||
FileDownloader=type("FileDownloader", (), {"__init__": counting_init})
|
||||
),
|
||||
"platformio.package.exception": SimpleNamespace(
|
||||
PackageException=mock_exception_cls
|
||||
),
|
||||
},
|
||||
):
|
||||
# Patch multiple times
|
||||
platformio_api.patch_file_downloader()
|
||||
platformio_api.patch_file_downloader()
|
||||
platformio_api.patch_file_downloader()
|
||||
|
||||
from platformio.package.download import FileDownloader
|
||||
|
||||
instance = object.__new__(FileDownloader)
|
||||
FileDownloader.__init__(instance, "http://example.com/file.zip")
|
||||
|
||||
# Should only be called once, not 3 times from stacked wrappers
|
||||
assert call_count == 1
|
||||
|
||||
|
||||
def test_platformio_log_filter_allows_non_platformio_messages() -> None:
|
||||
"""Test that non-platformio logger messages are allowed through."""
|
||||
log_filter = platformio_api.PlatformioLogFilter()
|
||||
|
||||
Reference in New Issue
Block a user