mirror of
https://github.com/esphome/esphome.git
synced 2026-10-02 09:20:22 +00:00
tests, address review comments
This commit is contained in:
@@ -3,12 +3,13 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
from collections.abc import AsyncGenerator, Generator
|
||||
from collections.abc import AsyncGenerator, Callable, Generator
|
||||
from contextlib import AbstractAsyncContextManager, asynccontextmanager
|
||||
import logging
|
||||
import os
|
||||
from pathlib import Path
|
||||
import platform
|
||||
import pty
|
||||
import signal
|
||||
import socket
|
||||
import sys
|
||||
@@ -46,8 +47,6 @@ if platform.system() == "Windows":
|
||||
"Integration tests are not supported on Windows", allow_module_level=True
|
||||
)
|
||||
|
||||
import pty # not available on Windows
|
||||
|
||||
|
||||
@pytest.fixture(scope="module", autouse=True)
|
||||
def enable_aioesphomeapi_debug_logging():
|
||||
@@ -362,7 +361,10 @@ async def api_client_connected(
|
||||
|
||||
|
||||
async def _read_stream_lines(
|
||||
stream: asyncio.StreamReader, lines: list[str], output_stream: TextIO
|
||||
stream: asyncio.StreamReader,
|
||||
lines: list[str],
|
||||
output_stream: TextIO,
|
||||
line_callback: Callable[[str], None] | None = None,
|
||||
) -> None:
|
||||
"""Read lines from a stream, append to list, and echo to output stream."""
|
||||
log_parser = LogParser()
|
||||
@@ -380,6 +382,9 @@ async def _read_stream_lines(
|
||||
file=output_stream,
|
||||
flush=True,
|
||||
)
|
||||
# Call the callback if provided
|
||||
if line_callback:
|
||||
line_callback(decoded_line.rstrip())
|
||||
|
||||
|
||||
@asynccontextmanager
|
||||
@@ -388,6 +393,7 @@ async def run_binary_and_wait_for_port(
|
||||
host: str,
|
||||
port: int,
|
||||
timeout: float = PORT_WAIT_TIMEOUT,
|
||||
line_callback: Callable[[str], None] | None = None,
|
||||
) -> AsyncGenerator[None]:
|
||||
"""Run a binary, wait for it to open a port, and clean up on exit."""
|
||||
# Create a pseudo-terminal to make the binary think it's running interactively
|
||||
@@ -435,7 +441,9 @@ async def run_binary_and_wait_for_port(
|
||||
# Read from output stream
|
||||
output_tasks = [
|
||||
asyncio.create_task(
|
||||
_read_stream_lines(output_reader, stdout_lines, sys.stdout)
|
||||
_read_stream_lines(
|
||||
output_reader, stdout_lines, sys.stdout, line_callback
|
||||
)
|
||||
)
|
||||
]
|
||||
|
||||
@@ -515,6 +523,7 @@ async def run_compiled_context(
|
||||
compile_esphome: CompileFunction,
|
||||
port: int,
|
||||
port_socket: socket.socket | None = None,
|
||||
line_callback: Callable[[str], None] | None = None,
|
||||
) -> AsyncGenerator[None]:
|
||||
"""Context manager to write, compile and run an ESPHome configuration."""
|
||||
# Write the YAML config
|
||||
@@ -528,7 +537,9 @@ async def run_compiled_context(
|
||||
port_socket.close()
|
||||
|
||||
# Run the binary and wait for the API server to start
|
||||
async with run_binary_and_wait_for_port(binary_path, LOCALHOST, port):
|
||||
async with run_binary_and_wait_for_port(
|
||||
binary_path, LOCALHOST, port, line_callback=line_callback
|
||||
):
|
||||
yield
|
||||
|
||||
|
||||
@@ -542,7 +553,9 @@ async def run_compiled(
|
||||
port, port_socket = reserved_tcp_port
|
||||
|
||||
def _run_compiled(
|
||||
yaml_content: str, filename: str | None = None
|
||||
yaml_content: str,
|
||||
filename: str | None = None,
|
||||
line_callback: Callable[[str], None] | None = None,
|
||||
) -> AbstractAsyncContextManager[asyncio.subprocess.Process]:
|
||||
return run_compiled_context(
|
||||
yaml_content,
|
||||
@@ -551,6 +564,7 @@ async def run_compiled(
|
||||
compile_esphome,
|
||||
port,
|
||||
port_socket,
|
||||
line_callback=line_callback,
|
||||
)
|
||||
|
||||
yield _run_compiled
|
||||
|
||||
@@ -2,8 +2,10 @@
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import logging
|
||||
from pathlib import Path
|
||||
import re
|
||||
|
||||
import pytest
|
||||
|
||||
@@ -29,24 +31,111 @@ async def test_loop_disable_enable(
|
||||
"EXTERNAL_COMPONENT_PATH", external_components_path
|
||||
)
|
||||
|
||||
# Write, compile and run the ESPHome device, then connect to API
|
||||
async with run_compiled(yaml_config), api_client_connected() as client:
|
||||
# Track log messages and events
|
||||
log_messages = []
|
||||
self_disable_10_disabled = asyncio.Event()
|
||||
normal_component_10_loops = asyncio.Event()
|
||||
redundant_enable_tested = asyncio.Event()
|
||||
redundant_disable_tested = asyncio.Event()
|
||||
self_disable_10_counts = []
|
||||
normal_component_counts = []
|
||||
|
||||
def on_log_line(line: str) -> None:
|
||||
"""Process each log line from the process output."""
|
||||
# Strip ANSI color codes
|
||||
clean_line = re.sub(r"\x1b\[[0-9;]*m", "", line)
|
||||
|
||||
if "loop_test_component" not in clean_line:
|
||||
return
|
||||
|
||||
log_messages.append(clean_line)
|
||||
|
||||
# Track specific events using the cleaned line
|
||||
if "[self_disable_10]" in clean_line:
|
||||
if "Loop count:" in clean_line:
|
||||
# Extract loop count
|
||||
try:
|
||||
count = int(clean_line.split("Loop count: ")[1])
|
||||
self_disable_10_counts.append(count)
|
||||
except (IndexError, ValueError):
|
||||
pass
|
||||
elif "Disabling self after 10 loops" in clean_line:
|
||||
self_disable_10_disabled.set()
|
||||
|
||||
elif "[normal_component]" in clean_line and "Loop count:" in clean_line:
|
||||
try:
|
||||
count = int(clean_line.split("Loop count: ")[1])
|
||||
normal_component_counts.append(count)
|
||||
if count >= 10:
|
||||
normal_component_10_loops.set()
|
||||
except (IndexError, ValueError):
|
||||
pass
|
||||
|
||||
elif (
|
||||
"[redundant_enable]" in clean_line
|
||||
and "Testing enable when already enabled" in clean_line
|
||||
):
|
||||
redundant_enable_tested.set()
|
||||
|
||||
elif (
|
||||
"[redundant_disable]" in clean_line
|
||||
and "Testing disable when will be disabled" in clean_line
|
||||
):
|
||||
redundant_disable_tested.set()
|
||||
|
||||
# Write, compile and run the ESPHome device with log callback
|
||||
async with (
|
||||
run_compiled(yaml_config, line_callback=on_log_line),
|
||||
api_client_connected() as client,
|
||||
):
|
||||
# Verify we can connect and get device info
|
||||
device_info = await client.device_info()
|
||||
assert device_info is not None
|
||||
assert device_info.name == "loop-test"
|
||||
|
||||
# The fact that this compiles and runs proves that:
|
||||
# 1. The partitioned vector implementation works
|
||||
# 2. Components can call disable_loop() and enable_loop()
|
||||
# 3. The system handles multiple component instances correctly
|
||||
# 4. Actions for enabling/disabling components work
|
||||
# Wait for self_disable_10 to disable itself
|
||||
try:
|
||||
await asyncio.wait_for(self_disable_10_disabled.wait(), timeout=10.0)
|
||||
except asyncio.TimeoutError:
|
||||
pytest.fail("self_disable_10 did not disable itself within 10 seconds")
|
||||
|
||||
# Note: Host platform doesn't send component logs through API,
|
||||
# so we can't verify the runtime behavior through logs.
|
||||
# However, the successful compilation and execution proves
|
||||
# the implementation is correct.
|
||||
|
||||
_LOGGER.info(
|
||||
"Loop disable/enable test passed - code compiles and runs successfully!"
|
||||
# Verify it ran exactly 10 times
|
||||
assert len(self_disable_10_counts) == 10, (
|
||||
f"Expected 10 loops for self_disable_10, got {len(self_disable_10_counts)}"
|
||||
)
|
||||
assert self_disable_10_counts == list(range(1, 11)), (
|
||||
f"Expected counts 1-10, got {self_disable_10_counts}"
|
||||
)
|
||||
|
||||
# Wait for normal_component to run at least 10 times
|
||||
try:
|
||||
await asyncio.wait_for(normal_component_10_loops.wait(), timeout=10.0)
|
||||
except asyncio.TimeoutError:
|
||||
pytest.fail(
|
||||
f"normal_component did not reach 10 loops within timeout, got {len(normal_component_counts)}"
|
||||
)
|
||||
|
||||
# Wait for redundant operation tests
|
||||
try:
|
||||
await asyncio.wait_for(redundant_enable_tested.wait(), timeout=10.0)
|
||||
except asyncio.TimeoutError:
|
||||
pytest.fail("redundant_enable did not test enabling when already enabled")
|
||||
|
||||
try:
|
||||
await asyncio.wait_for(redundant_disable_tested.wait(), timeout=10.0)
|
||||
except asyncio.TimeoutError:
|
||||
pytest.fail(
|
||||
"redundant_disable did not test disabling when will be disabled"
|
||||
)
|
||||
|
||||
# Wait a bit to see if self_disable_10 gets re-enabled
|
||||
await asyncio.sleep(3)
|
||||
|
||||
# Check final counts
|
||||
later_self_disable_counts = [c for c in self_disable_10_counts if c > 10]
|
||||
if later_self_disable_counts:
|
||||
_LOGGER.info(
|
||||
f"self_disable_10 was successfully re-enabled and ran {len(later_self_disable_counts)} more times"
|
||||
)
|
||||
|
||||
_LOGGER.info("Loop disable/enable test passed - all assertions verified!")
|
||||
|
||||
@@ -13,7 +13,19 @@ from aioesphomeapi import APIClient
|
||||
ConfigWriter = Callable[[str, str | None], Awaitable[Path]]
|
||||
CompileFunction = Callable[[Path], Awaitable[Path]]
|
||||
RunFunction = Callable[[Path], Awaitable[asyncio.subprocess.Process]]
|
||||
RunCompiledFunction = Callable[[str, str | None], AbstractAsyncContextManager[None]]
|
||||
|
||||
|
||||
class RunCompiledFunction(Protocol):
|
||||
"""Protocol for run_compiled function with optional line callback."""
|
||||
|
||||
def __call__( # noqa: E704
|
||||
self,
|
||||
yaml_content: str,
|
||||
filename: str | None = None,
|
||||
line_callback: Callable[[str], None] | None = None,
|
||||
) -> AbstractAsyncContextManager[None]: ...
|
||||
|
||||
|
||||
WaitFunction = Callable[[APIClient, float], Awaitable[bool]]
|
||||
|
||||
|
||||
|
||||
Reference in New Issue
Block a user