From 787ec432665b8a460fa7fc9fd77174fad4f03a87 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sun, 15 Jun 2025 20:22:29 -0500 Subject: [PATCH] tests, address review comments --- benchmark_extended.cpp | 161 ++++++++ esphome/components/anova/anova.cpp | 4 +- esphome/components/bedjet/bedjet_hub.cpp | 4 +- .../ble_client/sensor/ble_rssi_sensor.cpp | 4 +- .../ble_client/sensor/ble_sensor.cpp | 4 +- .../text_sensor/ble_text_sensor.cpp | 4 +- test_partitioned_vector.cpp | 378 ++++++++++++++++++ tests/integration/conftest.py | 28 +- tests/integration/test_loop_disable_enable.py | 117 +++++- tests/integration/types.py | 14 +- 10 files changed, 686 insertions(+), 32 deletions(-) create mode 100644 benchmark_extended.cpp create mode 100644 test_partitioned_vector.cpp diff --git a/benchmark_extended.cpp b/benchmark_extended.cpp new file mode 100644 index 0000000000..261fb1246e --- /dev/null +++ b/benchmark_extended.cpp @@ -0,0 +1,161 @@ +#include +#include +#include +#include +#include +#include + +class Component { + public: + Component(int id) : id_(id) {} + + void call() { + // Minimal work to highlight iteration overhead + volatile int x = id_; + x++; + } + + bool should_skip_loop() const { return skip_; } + void set_skip(bool skip) { skip_ = skip; } + + private: + int id_; + bool skip_ = false; + char padding_[119]; // Total size ~128 bytes +}; + +int main() { + const int num_components = 40; + const int iterations = 1000000; // 1 million iterations + + std::cout << "=== Extended Performance Test ===" << std::endl; + std::cout << "Components: " << num_components << std::endl; + std::cout << "Iterations: " << iterations << std::endl; + std::cout << "Testing overhead of flag checking vs list iteration\n" << std::endl; + + // Create components + std::vector> owned; + std::vector components; + for (int i = 0; i < num_components; i++) { + owned.push_back(std::make_unique(i)); + components.push_back(owned.back().get()); + } + + // Test 1: All components active (best case for both) + { + std::cout << "--- Test 1: All components active ---" << std::endl; + + // Vector test + auto start = std::chrono::high_resolution_clock::now(); + for (int iter = 0; iter < iterations; iter++) { + for (auto *comp : components) { + if (!comp->should_skip_loop()) { + comp->call(); + } + } + } + auto end = std::chrono::high_resolution_clock::now(); + auto vector_duration = std::chrono::duration_cast(end - start); + + // List test + std::list list_components(components.begin(), components.end()); + start = std::chrono::high_resolution_clock::now(); + for (int iter = 0; iter < iterations; iter++) { + for (auto *comp : list_components) { + comp->call(); + } + } + end = std::chrono::high_resolution_clock::now(); + auto list_duration = std::chrono::duration_cast(end - start); + + std::cout << "Vector: " << vector_duration.count() << " µs" << std::endl; + std::cout << "List: " << list_duration.count() << " µs" << std::endl; + std::cout << "List is " << std::fixed << std::setprecision(1) + << (list_duration.count() * 100.0 / vector_duration.count() - 100) << "% slower\n" + << std::endl; + } + + // Test 2: 25% components disabled (ESPHome scenario) + { + std::cout << "--- Test 2: 25% components disabled ---" << std::endl; + + // Disable 25% of components + for (int i = 0; i < num_components / 4; i++) { + components[i]->set_skip(true); + } + + // Vector test + auto start = std::chrono::high_resolution_clock::now(); + long long checks = 0, calls = 0; + for (int iter = 0; iter < iterations; iter++) { + for (auto *comp : components) { + checks++; + if (!comp->should_skip_loop()) { + calls++; + comp->call(); + } + } + } + auto end = std::chrono::high_resolution_clock::now(); + auto vector_duration = std::chrono::duration_cast(end - start); + + // List test (with only active components) + std::list list_components; + for (auto *comp : components) { + if (!comp->should_skip_loop()) { + list_components.push_back(comp); + } + } + + start = std::chrono::high_resolution_clock::now(); + long long list_calls = 0; + for (int iter = 0; iter < iterations; iter++) { + for (auto *comp : list_components) { + list_calls++; + comp->call(); + } + } + end = std::chrono::high_resolution_clock::now(); + auto list_duration = std::chrono::duration_cast(end - start); + + std::cout << "Vector: " << vector_duration.count() << " µs (" << checks << " checks, " << calls << " calls)" + << std::endl; + std::cout << "List: " << list_duration.count() << " µs (" << list_calls << " calls, no wasted checks)" << std::endl; + std::cout << "Wasted work in vector: " << (checks - calls) << " flag checks" << std::endl; + + double overhead_percent = (vector_duration.count() - list_duration.count()) * 100.0 / list_duration.count(); + if (overhead_percent > 0) { + std::cout << "Vector is " << std::fixed << std::setprecision(1) << overhead_percent + << "% slower due to flag checking\n" + << std::endl; + } else { + std::cout << "List is " << std::fixed << std::setprecision(1) << -overhead_percent << "% slower\n" << std::endl; + } + } + + // Test 3: Measure just the flag check overhead + { + std::cout << "--- Test 3: Pure flag check overhead ---" << std::endl; + + // Just flag checks, no calls + auto start = std::chrono::high_resolution_clock::now(); + long long skipped = 0; + for (int iter = 0; iter < iterations; iter++) { + for (auto *comp : components) { + if (comp->should_skip_loop()) { + skipped++; + } + } + } + auto end = std::chrono::high_resolution_clock::now(); + auto check_duration = std::chrono::duration_cast(end - start); + + std::cout << "Time for " << (iterations * num_components) << " flag checks: " << check_duration.count() << " µs" + << std::endl; + std::cout << "Average per flag check: " << (check_duration.count() * 1000.0 / (iterations * num_components)) + << " ns" << std::endl; + std::cout << "Checks that would skip work: " << skipped << std::endl; + } + + return 0; +} \ No newline at end of file diff --git a/esphome/components/anova/anova.cpp b/esphome/components/anova/anova.cpp index 05463d4fc2..d0e8f6827f 100644 --- a/esphome/components/anova/anova.cpp +++ b/esphome/components/anova/anova.cpp @@ -18,8 +18,8 @@ void Anova::setup() { } void Anova::loop() { - // This component uses polling via update() and BLE callbacks - // Empty loop not needed, disable to save CPU cycles + // Parent BLEClientNode has a loop() method, but this component uses + // polling via update() and BLE callbacks so loop isn't needed this->disable_loop(); } diff --git a/esphome/components/bedjet/bedjet_hub.cpp b/esphome/components/bedjet/bedjet_hub.cpp index be343eaf18..007ca1ca7d 100644 --- a/esphome/components/bedjet/bedjet_hub.cpp +++ b/esphome/components/bedjet/bedjet_hub.cpp @@ -481,8 +481,8 @@ void BedJetHub::set_clock(uint8_t hour, uint8_t minute) { /* Internal */ void BedJetHub::loop() { - // This component uses polling via update() and BLE callbacks - // Empty loop not needed, disable to save CPU cycles + // Parent BLEClientNode has a loop() method, but this component uses + // polling via update() and BLE callbacks so loop isn't needed this->disable_loop(); } void BedJetHub::update() { this->dispatch_status_(); } diff --git a/esphome/components/ble_client/sensor/ble_rssi_sensor.cpp b/esphome/components/ble_client/sensor/ble_rssi_sensor.cpp index 790d62f378..663c52ac10 100644 --- a/esphome/components/ble_client/sensor/ble_rssi_sensor.cpp +++ b/esphome/components/ble_client/sensor/ble_rssi_sensor.cpp @@ -12,8 +12,8 @@ namespace ble_client { static const char *const TAG = "ble_rssi_sensor"; void BLEClientRSSISensor::loop() { - // This component uses polling via update() and BLE GAP callbacks - // Empty loop not needed, disable to save CPU cycles + // Parent BLEClientNode has a loop() method, but this component uses + // polling via update() and BLE GAP callbacks so loop isn't needed this->disable_loop(); } diff --git a/esphome/components/ble_client/sensor/ble_sensor.cpp b/esphome/components/ble_client/sensor/ble_sensor.cpp index 08e9b9265c..d0ccfe1f2e 100644 --- a/esphome/components/ble_client/sensor/ble_sensor.cpp +++ b/esphome/components/ble_client/sensor/ble_sensor.cpp @@ -12,8 +12,8 @@ namespace ble_client { static const char *const TAG = "ble_sensor"; void BLESensor::loop() { - // This component uses polling via update() and BLE callbacks - // Empty loop not needed, disable to save CPU cycles + // Parent BLEClientNode has a loop() method, but this component uses + // polling via update() and BLE callbacks so loop isn't needed this->disable_loop(); } diff --git a/esphome/components/ble_client/text_sensor/ble_text_sensor.cpp b/esphome/components/ble_client/text_sensor/ble_text_sensor.cpp index c71f7c76e6..e7da297fa0 100644 --- a/esphome/components/ble_client/text_sensor/ble_text_sensor.cpp +++ b/esphome/components/ble_client/text_sensor/ble_text_sensor.cpp @@ -15,8 +15,8 @@ static const char *const TAG = "ble_text_sensor"; static const std::string EMPTY = ""; void BLETextSensor::loop() { - // This component uses polling via update() and BLE callbacks - // Empty loop not needed, disable to save CPU cycles + // Parent BLEClientNode has a loop() method, but this component uses + // polling via update() and BLE callbacks so loop isn't needed this->disable_loop(); } diff --git a/test_partitioned_vector.cpp b/test_partitioned_vector.cpp new file mode 100644 index 0000000000..15d6db18e3 --- /dev/null +++ b/test_partitioned_vector.cpp @@ -0,0 +1,378 @@ +#include +#include +#include +#include +#include + +// Forward declare tests vector +struct Test { + std::string name; + void (*func)(); +}; +std::vector tests; + +// Minimal test framework +#define TEST(name) \ + void test_##name(); \ + struct test_##name##_registrar { \ + test_##name##_registrar() { tests.push_back({#name, test_##name}); } \ + } test_##name##_instance; \ + void test_##name() + +#define ASSERT(cond) \ + do { \ + if (!(cond)) { \ + std::cerr << "FAILED: " #cond " at " << __FILE__ << ":" << __LINE__ << std::endl; \ + exit(1); \ + } \ + } while (0) +#define ASSERT_EQ(a, b) ASSERT((a) == (b)) + +// Mock classes matching ESPHome structure +const uint8_t COMPONENT_STATE_MASK = 0x07; +const uint8_t COMPONENT_STATE_LOOP = 0x02; +const uint8_t COMPONENT_STATE_LOOP_DONE = 0x04; +const uint8_t COMPONENT_STATE_FAILED = 0x03; + +class Component { + protected: + uint8_t component_state_ = COMPONENT_STATE_LOOP; + int id_; + int loop_count_ = 0; + + public: + Component(int id) : id_(id) {} + virtual ~Component() = default; + + virtual void call() { loop_count_++; } + + int get_id() const { return id_; } + int get_loop_count() const { return loop_count_; } + uint8_t get_state() const { return component_state_ & COMPONENT_STATE_MASK; } + + void set_state(uint8_t state) { component_state_ = (component_state_ & ~COMPONENT_STATE_MASK) | state; } +}; + +class Application { + public: + std::vector looping_components_; + uint16_t looping_components_active_end_ = 0; + uint16_t current_loop_index_ = 0; + bool in_loop_ = false; + + void add_component(Component *c) { + looping_components_.push_back(c); + looping_components_active_end_ = looping_components_.size(); + } + + void loop() { + in_loop_ = true; + for (current_loop_index_ = 0; current_loop_index_ < looping_components_active_end_; current_loop_index_++) { + looping_components_[current_loop_index_]->call(); + } + in_loop_ = false; + } + + void disable_component_loop(Component *component) { + for (uint16_t i = 0; i < looping_components_active_end_; i++) { + if (looping_components_[i] == component) { + looping_components_active_end_--; + if (i != looping_components_active_end_) { + std::swap(looping_components_[i], looping_components_[looping_components_active_end_]); + + if (in_loop_ && i == current_loop_index_) { + current_loop_index_--; + } + } + return; + } + } + } + + void enable_component_loop(Component *component) { + const uint16_t size = looping_components_.size(); + for (uint16_t i = 0; i < size; i++) { + if (looping_components_[i] == component) { + if (i < looping_components_active_end_) { + return; // Already active + } + + if (i != looping_components_active_end_) { + std::swap(looping_components_[i], looping_components_[looping_components_active_end_]); + } + looping_components_active_end_++; + return; + } + } + } + + // Helper methods for testing + std::vector get_active_ids() const { + std::vector ids; + for (uint16_t i = 0; i < looping_components_active_end_; i++) { + ids.push_back(looping_components_[i]->get_id()); + } + return ids; + } + + bool is_component_active(Component *c) const { + for (uint16_t i = 0; i < looping_components_active_end_; i++) { + if (looping_components_[i] == c) + return true; + } + return false; + } +}; + +// Test basic functionality +TEST(basic_loop) { + Application app; + std::vector> components; + + for (int i = 0; i < 5; i++) { + components.push_back(std::make_unique(i)); + app.add_component(components.back().get()); + } + + app.loop(); + + for (const auto &c : components) { + ASSERT_EQ(c->get_loop_count(), 1); + } +} + +TEST(disable_component) { + Application app; + std::vector> components; + + for (int i = 0; i < 5; i++) { + components.push_back(std::make_unique(i)); + app.add_component(components.back().get()); + } + + // Disable component 2 + app.disable_component_loop(components[2].get()); + + app.loop(); + + // Components 0,1,3,4 should have been called + ASSERT_EQ(components[0]->get_loop_count(), 1); + ASSERT_EQ(components[1]->get_loop_count(), 1); + ASSERT_EQ(components[2]->get_loop_count(), 0); // Disabled + ASSERT_EQ(components[3]->get_loop_count(), 1); + ASSERT_EQ(components[4]->get_loop_count(), 1); + + // Verify partitioning + ASSERT_EQ(app.looping_components_active_end_, 4); + ASSERT(!app.is_component_active(components[2].get())); +} + +TEST(enable_component) { + Application app; + std::vector> components; + + for (int i = 0; i < 5; i++) { + components.push_back(std::make_unique(i)); + app.add_component(components.back().get()); + } + + // Disable then re-enable + app.disable_component_loop(components[2].get()); + app.enable_component_loop(components[2].get()); + + app.loop(); + + // All should have been called + for (const auto &c : components) { + ASSERT_EQ(c->get_loop_count(), 1); + } + + ASSERT_EQ(app.looping_components_active_end_, 5); +} + +TEST(multiple_disable_enable) { + Application app; + std::vector> components; + + for (int i = 0; i < 10; i++) { + components.push_back(std::make_unique(i)); + app.add_component(components.back().get()); + } + + // Disable multiple + app.disable_component_loop(components[1].get()); + app.disable_component_loop(components[5].get()); + app.disable_component_loop(components[7].get()); + + ASSERT_EQ(app.looping_components_active_end_, 7); + + app.loop(); + + // Check counts + int active_count = 0; + for (const auto &c : components) { + if (c->get_loop_count() == 1) + active_count++; + } + ASSERT_EQ(active_count, 7); + + // Re-enable one + app.enable_component_loop(components[5].get()); + ASSERT_EQ(app.looping_components_active_end_, 8); + + app.loop(); + + ASSERT_EQ(components[5]->get_loop_count(), 1); +} + +// Test reentrant behavior +class SelfDisablingComponent : public Component { + Application *app_; + + public: + SelfDisablingComponent(int id, Application *app) : Component(id), app_(app) {} + + void call() override { + Component::call(); + if (loop_count_ == 2) { + app_->disable_component_loop(this); + } + } +}; + +TEST(reentrant_disable) { + Application app; + std::vector> components; + + // Add regular components + for (int i = 0; i < 3; i++) { + components.push_back(std::make_unique(i)); + app.add_component(components.back().get()); + } + + // Add self-disabling component + auto self_disable = std::make_unique(3, &app); + app.add_component(self_disable.get()); + + // Add more regular components + for (int i = 4; i < 6; i++) { + components.push_back(std::make_unique(i)); + app.add_component(components.back().get()); + } + + // First loop - all active + app.loop(); + ASSERT_EQ(app.looping_components_active_end_, 6); + + // Second loop - self-disabling component disables itself + app.loop(); + ASSERT_EQ(app.looping_components_active_end_, 5); + ASSERT_EQ(self_disable->get_loop_count(), 2); + + // Third loop - self-disabling component should not be called + app.loop(); + ASSERT_EQ(self_disable->get_loop_count(), 2); // Still 2 +} + +// Test edge cases +TEST(disable_already_disabled) { + Application app; + auto comp = std::make_unique(0); + app.add_component(comp.get()); + + app.disable_component_loop(comp.get()); + ASSERT_EQ(app.looping_components_active_end_, 0); + + // Disable again - should be no-op + app.disable_component_loop(comp.get()); + ASSERT_EQ(app.looping_components_active_end_, 0); +} + +TEST(enable_already_enabled) { + Application app; + auto comp = std::make_unique(0); + app.add_component(comp.get()); + + ASSERT_EQ(app.looping_components_active_end_, 1); + + // Enable again - should be no-op + app.enable_component_loop(comp.get()); + ASSERT_EQ(app.looping_components_active_end_, 1); +} + +TEST(disable_last_component) { + Application app; + auto comp = std::make_unique(0); + app.add_component(comp.get()); + + app.disable_component_loop(comp.get()); + ASSERT_EQ(app.looping_components_active_end_, 0); + + app.loop(); // Should not crash with empty active set +} + +// Test that mimics real ESPHome component behavior +class MockSNTPComponent : public Component { + Application *app_; + bool time_synced_ = false; + + public: + MockSNTPComponent(int id, Application *app) : Component(id), app_(app) {} + + void call() override { + Component::call(); + + // Simulate time sync after 3 calls + if (loop_count_ >= 3 && !time_synced_) { + time_synced_ = true; + std::cout << " SNTP: Time synced, disabling loop" << std::endl; + set_state(COMPONENT_STATE_LOOP_DONE); + app_->disable_component_loop(this); + } + } + + bool is_synced() const { return time_synced_; } +}; + +TEST(real_world_sntp) { + Application app; + + // Regular components + std::vector> components; + for (int i = 0; i < 5; i++) { + components.push_back(std::make_unique(i)); + app.add_component(components.back().get()); + } + + // SNTP component + auto sntp = std::make_unique(5, &app); + app.add_component(sntp.get()); + + // Run 5 iterations + for (int i = 0; i < 5; i++) { + app.loop(); + } + + // SNTP should have disabled itself after 3 calls + ASSERT_EQ(sntp->get_loop_count(), 3); + ASSERT(sntp->is_synced()); + ASSERT_EQ(app.looping_components_active_end_, 5); // SNTP removed + + // Regular components should have 5 calls each + for (const auto &c : components) { + ASSERT_EQ(c->get_loop_count(), 5); + } +} + +int main() { + std::cout << "Running partitioned vector tests...\n" << std::endl; + + for (const auto &test : tests) { + std::cout << "Running test: " << test.name << std::endl; + test.func(); + std::cout << " ✓ PASSED" << std::endl; + } + + std::cout << "\nAll " << tests.size() << " tests passed!" << std::endl; + return 0; +} \ No newline at end of file diff --git a/tests/integration/conftest.py b/tests/integration/conftest.py index 90377300a6..53c29dec14 100644 --- a/tests/integration/conftest.py +++ b/tests/integration/conftest.py @@ -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 diff --git a/tests/integration/test_loop_disable_enable.py b/tests/integration/test_loop_disable_enable.py index 212cb40965..9494b061b7 100644 --- a/tests/integration/test_loop_disable_enable.py +++ b/tests/integration/test_loop_disable_enable.py @@ -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!") diff --git a/tests/integration/types.py b/tests/integration/types.py index 6fc3e9435e..5e4bfaa29d 100644 --- a/tests/integration/types.py +++ b/tests/integration/types.py @@ -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]]