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