1
0
mirror of https://github.com/esphome/esphome.git synced 2025-10-31 07:03:55 +00:00

tests, address review comments

This commit is contained in:
J. Nick Koston
2025-06-15 20:22:29 -05:00
parent a4efc63bf2
commit 787ec43266
10 changed files with 686 additions and 32 deletions

View File

@@ -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

View File

@@ -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!")

View File

@@ -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]]