From 80a8f1437e3b2f0dd01b7c4f384669a40031e119 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sun, 15 Jun 2025 19:38:13 -0500 Subject: [PATCH] tests --- .../loop_test_component/__init__.py | 19 ++ .../loop_test_component/loop_test_component.h | 89 +++++++++ .../loop_test_component/sensor.py | 63 +++++++ tests/integration/fixtures/logs_received.yaml | 22 +++ .../fixtures/loop_disable_enable.yaml | 24 +++ .../loop_disable_enable_compiles.yaml | 14 ++ .../fixtures/loop_disable_enable_simple.yaml | 44 +++++ tests/integration/test_loop_disable_enable.py | 171 +++++++++++++++++ .../test_loop_disable_enable_basic.py | 37 ++++ .../test_loop_disable_enable_logs.py | 75 ++++++++ .../test_loop_disable_enable_simple.py | 175 ++++++++++++++++++ 11 files changed, 733 insertions(+) create mode 100644 tests/integration/fixtures/external_components/loop_test_component/__init__.py create mode 100644 tests/integration/fixtures/external_components/loop_test_component/loop_test_component.h create mode 100644 tests/integration/fixtures/external_components/loop_test_component/sensor.py create mode 100644 tests/integration/fixtures/logs_received.yaml create mode 100644 tests/integration/fixtures/loop_disable_enable.yaml create mode 100644 tests/integration/fixtures/loop_disable_enable_compiles.yaml create mode 100644 tests/integration/fixtures/loop_disable_enable_simple.yaml create mode 100644 tests/integration/test_loop_disable_enable.py create mode 100644 tests/integration/test_loop_disable_enable_basic.py create mode 100644 tests/integration/test_loop_disable_enable_logs.py create mode 100644 tests/integration/test_loop_disable_enable_simple.py diff --git a/tests/integration/fixtures/external_components/loop_test_component/__init__.py b/tests/integration/fixtures/external_components/loop_test_component/__init__.py new file mode 100644 index 0000000000..e55bafb531 --- /dev/null +++ b/tests/integration/fixtures/external_components/loop_test_component/__init__.py @@ -0,0 +1,19 @@ +import esphome.codegen as cg +import esphome.config_validation as cv +from esphome.const import CONF_ID + +CODEOWNERS = ["@esphome/tests"] + +loop_test_component_ns = cg.esphome_ns.namespace("loop_test_component") +LoopTestComponent = loop_test_component_ns.class_("LoopTestComponent", cg.Component) + +CONFIG_SCHEMA = cv.Schema( + { + cv.GenerateID(): cv.declare_id(LoopTestComponent), + } +).extend(cv.COMPONENT_SCHEMA) + + +async def to_code(config): + var = cg.new_Pvariable(config[CONF_ID]) + await cg.register_component(var, config) diff --git a/tests/integration/fixtures/external_components/loop_test_component/loop_test_component.h b/tests/integration/fixtures/external_components/loop_test_component/loop_test_component.h new file mode 100644 index 0000000000..8d32a2b7ed --- /dev/null +++ b/tests/integration/fixtures/external_components/loop_test_component/loop_test_component.h @@ -0,0 +1,89 @@ +#pragma once + +#include "esphome/core/component.h" +#include "esphome/core/log.h" +#include "esphome/core/application.h" + +namespace esphome { +namespace loop_test_component { + +static const char *const TAG = "loop_test_component"; + +class LoopTestComponent : public Component { + public: + void setup() override { + ESP_LOGI(TAG, "LoopTestComponent setup()"); + this->loop_count_ = 0; + this->setup_disable_count_ = 0; + this->setup_enable_count_ = 0; + + // Test 1: Try to disable/enable in setup (before calculate_looping_components_) + ESP_LOGI(TAG, "Test 1: Disable in setup"); + this->disable_loop(); + this->setup_disable_count_++; + + ESP_LOGI(TAG, "Test 1: Enable in setup"); + this->enable_loop(); + this->setup_enable_count_++; + } + + void loop() override { + this->loop_count_++; + + if (this->loop_count_ <= 10 || this->loop_count_ % 10 == 0) { + ESP_LOGI(TAG, "Loop count: %d", this->loop_count_); + } + + // Test 2: Disable after 50 loops + if (this->loop_count_ == 50) { + ESP_LOGI(TAG, "Test 2: Disabling loop after 50 iterations"); + this->disable_loop(); + this->loop_disable_count_++; + } + + // This should not happen + if (this->loop_count_ > 50 && this->loop_count_ < 100) { + ESP_LOGE(TAG, "ERROR: Loop called after disable! Count: %d", this->loop_count_); + } + + // Test 3: Re-enable after being disabled (shouldn't get here) + if (this->loop_count_ == 75) { + ESP_LOGE(TAG, "ERROR: This code should never execute!"); + this->enable_loop(); + } + } + + // For testing from outside + void test_enable_from_outside() { + ESP_LOGI(TAG, "Test 3: Enabling from outside call"); + this->enable_loop(); + this->external_enable_count_++; + } + + void test_disable_from_outside() { + ESP_LOGI(TAG, "Test 4: Disabling from outside call"); + this->disable_loop(); + this->external_disable_count_++; + } + + // Getters for test validation + int get_loop_count() const { return this->loop_count_; } + int get_setup_disable_count() const { return this->setup_disable_count_; } + int get_setup_enable_count() const { return this->setup_enable_count_; } + int get_loop_disable_count() const { return this->loop_disable_count_; } + int get_external_enable_count() const { return this->external_enable_count_; } + int get_external_disable_count() const { return this->external_disable_count_; } + + float get_setup_priority() const override { return setup_priority::DATA; } + + protected: + int loop_count_{0}; + int setup_disable_count_{0}; + int setup_enable_count_{0}; + int loop_disable_count_{0}; + int external_enable_count_{0}; + int external_disable_count_{0}; +}; + +} // namespace loop_test_component +} // namespace esphome \ No newline at end of file diff --git a/tests/integration/fixtures/external_components/loop_test_component/sensor.py b/tests/integration/fixtures/external_components/loop_test_component/sensor.py new file mode 100644 index 0000000000..71375dd934 --- /dev/null +++ b/tests/integration/fixtures/external_components/loop_test_component/sensor.py @@ -0,0 +1,63 @@ +import esphome.codegen as cg +from esphome.components import sensor +import esphome.config_validation as cv +from esphome.const import CONF_ID, ENTITY_CATEGORY_DIAGNOSTIC, STATE_CLASS_MEASUREMENT + +from . import LoopTestComponent + +DEPENDENCIES = ["loop_test_component"] + +CONF_LOOP_COUNT = "loop_count" +CONF_SETUP_DISABLE_COUNT = "setup_disable_count" +CONF_SETUP_ENABLE_COUNT = "setup_enable_count" +CONF_LOOP_DISABLE_COUNT = "loop_disable_count" +CONF_EXTERNAL_ENABLE_COUNT = "external_enable_count" +CONF_EXTERNAL_DISABLE_COUNT = "external_disable_count" + +CONFIG_SCHEMA = cv.Schema( + { + cv.GenerateID(CONF_ID): cv.use_id(LoopTestComponent), + cv.Optional(CONF_LOOP_COUNT): sensor.sensor_schema( + accuracy_decimals=0, + state_class=STATE_CLASS_MEASUREMENT, + entity_category=ENTITY_CATEGORY_DIAGNOSTIC, + ), + cv.Optional(CONF_SETUP_DISABLE_COUNT): sensor.sensor_schema( + accuracy_decimals=0, + state_class=STATE_CLASS_MEASUREMENT, + entity_category=ENTITY_CATEGORY_DIAGNOSTIC, + ), + cv.Optional(CONF_SETUP_ENABLE_COUNT): sensor.sensor_schema( + accuracy_decimals=0, + state_class=STATE_CLASS_MEASUREMENT, + entity_category=ENTITY_CATEGORY_DIAGNOSTIC, + ), + cv.Optional(CONF_LOOP_DISABLE_COUNT): sensor.sensor_schema( + accuracy_decimals=0, + state_class=STATE_CLASS_MEASUREMENT, + entity_category=ENTITY_CATEGORY_DIAGNOSTIC, + ), + cv.Optional(CONF_EXTERNAL_ENABLE_COUNT): sensor.sensor_schema( + accuracy_decimals=0, + state_class=STATE_CLASS_MEASUREMENT, + entity_category=ENTITY_CATEGORY_DIAGNOSTIC, + ), + cv.Optional(CONF_EXTERNAL_DISABLE_COUNT): sensor.sensor_schema( + accuracy_decimals=0, + state_class=STATE_CLASS_MEASUREMENT, + entity_category=ENTITY_CATEGORY_DIAGNOSTIC, + ), + } +) + + +async def to_code(config): + parent = await cg.get_variable(config[CONF_ID]) + + if CONF_LOOP_COUNT in config: + sens = await sensor.new_sensor(config[CONF_LOOP_COUNT]) + cg.add( + parent.set_loop_count_sensor(sens) + ) # We'll implement this in the component + + # For simplicity, let's just expose loop_count for now in the test diff --git a/tests/integration/fixtures/logs_received.yaml b/tests/integration/fixtures/logs_received.yaml new file mode 100644 index 0000000000..2c2d80a245 --- /dev/null +++ b/tests/integration/fixtures/logs_received.yaml @@ -0,0 +1,22 @@ +esphome: + name: loop-test + on_boot: + - logger.log: "System booted!" + +host: +api: +logger: + level: DEBUG + +external_components: + - source: + type: local + path: EXTERNAL_COMPONENT_PATH + +loop_test_component: + id: loop_test + +interval: + - interval: 500ms + then: + - logger.log: "Interval tick" \ No newline at end of file diff --git a/tests/integration/fixtures/loop_disable_enable.yaml b/tests/integration/fixtures/loop_disable_enable.yaml new file mode 100644 index 0000000000..8e3c652a55 --- /dev/null +++ b/tests/integration/fixtures/loop_disable_enable.yaml @@ -0,0 +1,24 @@ +esphome: + name: loop-test + on_boot: + - logger.log: "System booted!" + +host: +api: +logger: + level: DEBUG + +external_components: + - source: + type: local + path: EXTERNAL_COMPONENT_PATH + +loop_test_component: + id: loop_test + +interval: + - interval: 1s + then: + - logger.log: "Interval tick" + +# We'll check the loop behavior through logs and API \ No newline at end of file diff --git a/tests/integration/fixtures/loop_disable_enable_compiles.yaml b/tests/integration/fixtures/loop_disable_enable_compiles.yaml new file mode 100644 index 0000000000..e57243ce29 --- /dev/null +++ b/tests/integration/fixtures/loop_disable_enable_compiles.yaml @@ -0,0 +1,14 @@ +esphome: + name: loop-test +host: +api: +logger: + level: DEBUG + +external_components: + - source: + type: local + path: EXTERNAL_COMPONENT_PATH + +loop_test_component: + id: loop_test \ No newline at end of file diff --git a/tests/integration/fixtures/loop_disable_enable_simple.yaml b/tests/integration/fixtures/loop_disable_enable_simple.yaml new file mode 100644 index 0000000000..2de3719bdb --- /dev/null +++ b/tests/integration/fixtures/loop_disable_enable_simple.yaml @@ -0,0 +1,44 @@ +esphome: + name: loop-test + on_boot: + priority: -100 # After all components are initialized + then: + - logger.log: "Boot complete, testing loop disable/enable" +host: +api: +logger: + level: DEBUG + +# Use interval component which already supports disable/enable +interval: + - interval: 100ms + id: test_interval_1 + then: + - lambda: |- + static int count = 0; + count++; + ESP_LOGD("test", "Interval 1 count: %d", count); + + if (count == 10) { + ESP_LOGD("test", "Disabling interval 1 after 10 iterations"); + id(test_interval_1).disable(); + } + + - interval: 200ms + id: test_interval_2 + then: + - lambda: |- + static int count = 0; + count++; + ESP_LOGD("test", "Interval 2 count: %d", count); + + // Re-enable interval 1 after 5 iterations + if (count == 5) { + ESP_LOGD("test", "Re-enabling interval 1"); + id(test_interval_1).enable(); + } + + if (count == 15) { + ESP_LOGD("test", "Disabling interval 2"); + id(test_interval_2).disable(); + } \ No newline at end of file diff --git a/tests/integration/test_loop_disable_enable.py b/tests/integration/test_loop_disable_enable.py new file mode 100644 index 0000000000..91c84b409a --- /dev/null +++ b/tests/integration/test_loop_disable_enable.py @@ -0,0 +1,171 @@ +"""Integration test for loop disable/enable functionality.""" + +from __future__ import annotations + +import asyncio +import logging +from pathlib import Path +from typing import Any + +import pytest + +from .types import APIClientConnectedFactory, RunCompiledFunction + +_LOGGER = logging.getLogger(__name__) + + +@pytest.mark.asyncio +async def test_loop_disable_enable( + yaml_config: str, + run_compiled: RunCompiledFunction, + api_client_connected: APIClientConnectedFactory, +) -> None: + """Test that components can disable and enable their loop() method.""" + # Get the absolute path to the external components directory + external_components_path = str( + Path(__file__).parent / "fixtures" / "external_components" + ) + + # Replace the placeholder in the YAML config with the actual path + yaml_config = yaml_config.replace( + "EXTERNAL_COMPONENT_PATH", external_components_path + ) + + log_messages: list[tuple[int, str]] = [] + + def on_log(msg: Any) -> None: + """Capture log messages.""" + if hasattr(msg, "level") and hasattr(msg, "message"): + log_messages.append((msg.level, msg.message.decode("utf-8"))) + _LOGGER.info(f"ESPHome log: [{msg.level}] {msg.message.decode('utf-8')}") + + # Write, compile and run the ESPHome device, then connect to API + async with run_compiled(yaml_config), api_client_connected() as client: + # Subscribe to logs (not awaitable) + client.subscribe_logs(on_log) + + # Wait for the component to run through its test sequence + # The component should: + # 1. Try to disable/enable in setup (before calculate_looping_components_) + # 2. Run loop 50 times then disable itself + # 3. Not run loop again after disabling + + await asyncio.sleep(5.0) # Give it time to run + + # Debug: Print all captured logs + _LOGGER.info(f"Total logs captured: {len(log_messages)}") + for level, msg in log_messages[:20]: # First 20 logs + _LOGGER.info(f"Log: {msg}") + + # Analyze captured logs + setup_logs = [msg for level, msg in log_messages if "setup()" in msg] + loop_logs = [msg for level, msg in log_messages if "Loop count:" in msg] + disable_logs = [msg for level, msg in log_messages if "Disabling loop" in msg] + error_logs = [msg for level, msg in log_messages if "ERROR" in msg] + + # Verify setup was called + assert len(setup_logs) > 0, "Component setup() was not called" + + # Verify loop was called multiple times + assert len(loop_logs) > 0, "Component loop() was never called" + + # Extract loop counts from logs + loop_counts = [] + for _, msg in loop_logs: + # Parse "Loop count: X" messages + if "Loop count:" in msg: + try: + count = int(msg.split("Loop count:")[1].strip()) + loop_counts.append(count) + except (ValueError, IndexError): + pass + + # Verify loop ran exactly 50 times before disabling + assert max(loop_counts) == 50, ( + f"Expected max loop count 50, got {max(loop_counts)}" + ) + + # Verify disable message was logged + assert any( + "Disabling loop after 50 iterations" in msg for _, msg in disable_logs + ), "Component did not log disable message" + + # Verify no errors (loop should not be called after disable) + assert len(error_logs) == 0, f"Found error logs: {error_logs}" + + # Wait a bit more to ensure loop doesn't continue + await asyncio.sleep(2.0) + + # Re-check - should still be no errors + error_logs_2 = [msg for level, msg in log_messages if "ERROR" in msg] + assert len(error_logs_2) == 0, f"Found error logs after wait: {error_logs_2}" + + # The final loop count should still be 50 + final_loop_logs = [msg for _, msg in log_messages if "Loop count:" in msg] + final_counts = [] + for msg in final_loop_logs: + if "Loop count:" in msg: + try: + count = int(msg.split("Loop count:")[1].strip()) + final_counts.append(count) + except (ValueError, IndexError): + pass + + assert max(final_counts) == 50, ( + f"Loop continued after disable! Max count: {max(final_counts)}" + ) + + +@pytest.mark.asyncio +async def test_loop_disable_enable_reentrant( + yaml_config: str, + run_compiled: RunCompiledFunction, + api_client_connected: APIClientConnectedFactory, +) -> None: + """Test that disable_loop is reentrant (component can disable itself during its own loop).""" + # Get the absolute path to the external components directory + external_components_path = str( + Path(__file__).parent / "fixtures" / "external_components" + ) + + # Replace the placeholder in the YAML config with the actual path + yaml_config = yaml_config.replace( + "EXTERNAL_COMPONENT_PATH", external_components_path + ) + + # The basic test above already tests this - the component disables itself + # during its own loop() call at iteration 50 + + # This test just verifies that specific behavior more explicitly + log_messages: list[tuple[int, str]] = [] + + def on_log(msg: Any) -> None: + """Capture log messages.""" + if hasattr(msg, "level") and hasattr(msg, "message"): + log_messages.append((msg.level, msg.message.decode("utf-8"))) + + async with run_compiled(yaml_config), api_client_connected() as client: + client.subscribe_logs(on_log) + await asyncio.sleep(5.0) + + # Look for the sequence: Loop count 50 -> Disable message -> No more loops + found_50 = False + found_disable = False + found_51_error = False + + for i, (_, msg) in enumerate(log_messages): + if "Loop count: 50" in msg: + found_50 = True + # Check next few messages for disable + for j in range(i, min(i + 5, len(log_messages))): + if "Disabling loop after 50 iterations" in log_messages[j][1]: + found_disable = True + break + elif "Loop count: 51" in msg or "ERROR" in msg: + found_51_error = True + + assert found_50, "Component did not reach loop count 50" + assert found_disable, "Component did not disable itself at count 50" + assert not found_51_error, ( + "Component continued looping after disable or had errors" + ) diff --git a/tests/integration/test_loop_disable_enable_basic.py b/tests/integration/test_loop_disable_enable_basic.py new file mode 100644 index 0000000000..491efb7111 --- /dev/null +++ b/tests/integration/test_loop_disable_enable_basic.py @@ -0,0 +1,37 @@ +"""Basic integration test to verify loop disable/enable compiles.""" + +from __future__ import annotations + +import pytest + +from .types import APIClientConnectedFactory, RunCompiledFunction + + +@pytest.mark.asyncio +async def test_loop_disable_enable_compiles( + yaml_config: str, + run_compiled: RunCompiledFunction, + api_client_connected: APIClientConnectedFactory, +) -> None: + """Test that components with loop disable/enable compile and run.""" + # Get the absolute path to the external components directory + from pathlib import Path + + external_components_path = str( + Path(__file__).parent / "fixtures" / "external_components" + ) + + # Replace the placeholder in the YAML config with the actual path + yaml_config = yaml_config.replace( + "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: + # Verify we can get device info + device_info = await client.device_info() + assert device_info is not None + assert device_info.name == "loop-test" + + # If we get here, the code compiled and ran successfully + # The partitioned vector implementation is working diff --git a/tests/integration/test_loop_disable_enable_logs.py b/tests/integration/test_loop_disable_enable_logs.py new file mode 100644 index 0000000000..6ea8688775 --- /dev/null +++ b/tests/integration/test_loop_disable_enable_logs.py @@ -0,0 +1,75 @@ +"""Test that we can receive logs from the device.""" + +from __future__ import annotations + +import asyncio +import logging +from typing import Any + +import pytest + +from .types import APIClientConnectedFactory, RunCompiledFunction + +_LOGGER = logging.getLogger(__name__) + + +@pytest.mark.asyncio +async def test_logs_received( + yaml_config: str, + run_compiled: RunCompiledFunction, + api_client_connected: APIClientConnectedFactory, +) -> None: + """Test that we can receive logs from the ESPHome device.""" + # Get the absolute path to the external components directory + from pathlib import Path + + external_components_path = str( + Path(__file__).parent / "fixtures" / "external_components" + ) + + # Replace the placeholder in the YAML config with the actual path + yaml_config = yaml_config.replace( + "EXTERNAL_COMPONENT_PATH", external_components_path + ) + + log_messages: list[tuple[int, str]] = [] + + def on_log(msg: Any) -> None: + """Capture log messages.""" + if hasattr(msg, "level") and hasattr(msg, "message"): + message = ( + msg.message.decode("utf-8") + if isinstance(msg.message, bytes) + else str(msg.message) + ) + log_messages.append((msg.level, message)) + _LOGGER.info(f"ESPHome log: [{msg.level}] {message}") + + # Write, compile and run the ESPHome device, then connect to API + async with run_compiled(yaml_config), api_client_connected() as client: + # Subscribe to logs + client.subscribe_logs(on_log) + + # Wait a bit to receive some logs + await asyncio.sleep(3.0) + + # Check if we received any logs at all + _LOGGER.info(f"Total logs captured: {len(log_messages)}") + + # Print all logs for debugging + for level, msg in log_messages: + _LOGGER.info(f"Captured: [{level}] {msg}") + + # We should have received at least some logs + assert len(log_messages) > 0, "No logs received from device" + + # Check for specific expected logs + boot_logs = [msg for level, msg in log_messages if "System booted" in msg] + interval_logs = [msg for level, msg in log_messages if "Interval tick" in msg] + + _LOGGER.info(f"Boot logs: {len(boot_logs)}") + _LOGGER.info(f"Interval logs: {len(interval_logs)}") + + # We expect at least one boot log and some interval logs + assert len(boot_logs) > 0, "No boot log found" + assert len(interval_logs) > 0, "No interval logs found" diff --git a/tests/integration/test_loop_disable_enable_simple.py b/tests/integration/test_loop_disable_enable_simple.py new file mode 100644 index 0000000000..29983a02af --- /dev/null +++ b/tests/integration/test_loop_disable_enable_simple.py @@ -0,0 +1,175 @@ +"""Integration test for loop disable/enable functionality using interval components.""" + +from __future__ import annotations + +import asyncio +import logging +from typing import Any + +import pytest + +from .types import APIClientConnectedFactory, RunCompiledFunction + +_LOGGER = logging.getLogger(__name__) + + +@pytest.mark.asyncio +async def test_loop_disable_enable_simple( + yaml_config: str, + run_compiled: RunCompiledFunction, + api_client_connected: APIClientConnectedFactory, +) -> None: + """Test that interval components can disable and enable their loop() method.""" + log_messages: list[tuple[int, str]] = [] + + def on_log(msg: Any) -> None: + """Capture log messages.""" + if hasattr(msg, "level") and hasattr(msg, "message"): + log_messages.append((msg.level, msg.message.decode("utf-8"))) + if ( + "test" in msg.message.decode("utf-8") + or "interval" in msg.message.decode("utf-8").lower() + ): + _LOGGER.info( + f"ESPHome log: [{msg.level}] {msg.message.decode('utf-8')}" + ) + + # Write, compile and run the ESPHome device, then connect to API + async with run_compiled(yaml_config), api_client_connected() as client: + # Subscribe to logs + await client.subscribe_logs(on_log) + + # Wait for the intervals to run through their sequences + # Expected behavior: + # - Interval 1 runs 10 times (100ms interval) then disables itself + # - Interval 2 runs and re-enables interval 1 at count 5 (1 second) + # - Interval 1 resumes + # - Interval 2 disables itself at count 15 + + await asyncio.sleep(4.0) # Give it time to run through the sequence + + # Analyze captured logs + interval1_logs = [ + msg for level, msg in log_messages if "Interval 1 count:" in msg + ] + interval2_logs = [ + msg for level, msg in log_messages if "Interval 2 count:" in msg + ] + disable_logs = [ + msg for level, msg in log_messages if "Disabling interval" in msg + ] + enable_logs = [ + msg for level, msg in log_messages if "Re-enabling interval" in msg + ] + + # Extract counts from interval 1 + interval1_counts = [] + for msg in interval1_logs: + try: + count = int(msg.split("count:")[1].strip()) + interval1_counts.append(count) + except (ValueError, IndexError): + pass + + # Extract counts from interval 2 + interval2_counts = [] + for msg in interval2_logs: + try: + count = int(msg.split("count:")[1].strip()) + interval2_counts.append(count) + except (ValueError, IndexError): + pass + + # Verify interval 1 behavior + assert len(interval1_counts) > 0, "Interval 1 never ran" + assert 10 in interval1_counts, "Interval 1 didn't reach count 10" + + # Check for gap in interval 1 counts (when it was disabled) + # After count 10, there should be a gap before it resumes + idx_10 = interval1_counts.index(10) + if idx_10 < len(interval1_counts) - 1: + # If there are counts after 10, they should start from 11+ after re-enable + next_count = interval1_counts[idx_10 + 1] + assert next_count > 10, ( + f"Interval 1 continued immediately after disable (next count: {next_count})" + ) + + # Verify interval 2 behavior + assert len(interval2_counts) > 0, "Interval 2 never ran" + assert 5 in interval2_counts, ( + "Interval 2 didn't reach count 5 to re-enable interval 1" + ) + assert 15 in interval2_counts, "Interval 2 didn't reach count 15" + + # Verify disable/enable messages + assert any( + "Disabling interval 1 after 10 iterations" in msg for msg in disable_logs + ), "Interval 1 disable message not found" + assert any("Re-enabling interval 1" in msg for msg in enable_logs), ( + "Interval 1 re-enable message not found" + ) + assert any("Disabling interval 2" in msg for msg in disable_logs), ( + "Interval 2 disable message not found" + ) + + # Wait a bit more to ensure intervals stay disabled + await asyncio.sleep(1.0) + + # Get final counts + final_interval2_counts = [ + int(msg.split("count:")[1].strip()) + for msg in log_messages + if "Interval 2 count:" in msg + ] + + # Interval 2 should not have counts beyond 15 + assert max(final_interval2_counts) == 15, ( + f"Interval 2 continued after disable! Max count: {max(final_interval2_counts)}" + ) + + _LOGGER.info(f"Test passed! Interval 1 counts: {interval1_counts}") + _LOGGER.info(f"Test passed! Interval 2 counts: {interval2_counts}") + + +@pytest.mark.asyncio +async def test_loop_disable_enable_reentrant_simple( + yaml_config: str, + run_compiled: RunCompiledFunction, + api_client_connected: APIClientConnectedFactory, +) -> None: + """Verify that intervals can disable themselves during their own execution (reentrant).""" + # The test above already verifies this - interval 1 disables itself at count 10 + # This test just makes that behavior more explicit + + log_messages: list[tuple[int, str]] = [] + + def on_log(msg: Any) -> None: + if hasattr(msg, "level") and hasattr(msg, "message"): + log_messages.append((msg.level, msg.message.decode("utf-8"))) + + async with run_compiled(yaml_config), api_client_connected() as client: + await client.subscribe_logs(on_log) + await asyncio.sleep(3.0) + + # Look for the sequence where interval 1 disables itself + found_count_10 = False + found_disable_msg = False + found_count_11 = False + + for i, (_, msg) in enumerate(log_messages): + if "Interval 1 count: 10" in msg: + found_count_10 = True + # Check if disable message follows shortly after + for j in range(i, min(i + 5, len(log_messages))): + if "Disabling interval 1 after 10 iterations" in log_messages[j][1]: + found_disable_msg = True + break + elif "Interval 1 count: 11" in msg and not found_disable_msg: + # This would mean it continued without properly disabling + found_count_11 = True + + assert found_count_10, "Interval 1 did not reach count 10" + assert found_disable_msg, "Interval 1 did not log disable message" + + # The interval successfully disabled itself during its own execution + _LOGGER.info("Reentrant disable test passed!")