From 23e64de392500b99082d1fbe9d2288666990040c Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Mon, 27 Apr 2026 05:07:14 -0500 Subject: [PATCH 1/2] [core] Add integration test for inline ContinuationAction --- .../fixtures/inline_continuation_actions.yaml | 91 ++++++++++++++++++ .../test_inline_continuation_actions.py | 92 +++++++++++++++++++ 2 files changed, 183 insertions(+) create mode 100644 tests/integration/fixtures/inline_continuation_actions.yaml create mode 100644 tests/integration/test_inline_continuation_actions.py diff --git a/tests/integration/fixtures/inline_continuation_actions.yaml b/tests/integration/fixtures/inline_continuation_actions.yaml new file mode 100644 index 0000000000..0b656386e1 --- /dev/null +++ b/tests/integration/fixtures/inline_continuation_actions.yaml @@ -0,0 +1,91 @@ +esphome: + name: inline-continuation-test +host: +api: +logger: + level: DEBUG + +globals: + - id: counter + type: int + initial_value: "0" + - id: enabled + type: bool + initial_value: "true" + +button: + # Test 1: if/then (HasElse=false) — increments counter when enabled is true + - platform: template + id: btn_if_then + name: "If Then" + on_press: + - if: + condition: + lambda: 'return id(enabled);' + then: + - lambda: 'id(counter) += 1;' + + # Test 2: if/then/else (HasElse=true) — increments by 10 when enabled, else by 100 + - platform: template + id: btn_if_then_else + name: "If Then Else" + on_press: + - if: + condition: + lambda: 'return id(enabled);' + then: + - lambda: 'id(counter) += 10;' + else: + - lambda: 'id(counter) += 100;' + + # Test 3: while loop — increments counter 5 times via inline check + - platform: template + id: btn_while + name: "While" + on_press: + - while: + condition: + lambda: 'return id(counter) % 5 != 0;' + then: + - lambda: 'id(counter) += 1;' + + # Test 4: repeat loop — increments by 7 over 3 iterations + - platform: template + id: btn_repeat + name: "Repeat" + on_press: + - repeat: + count: 3 + then: + - lambda: 'id(counter) += 7;' + + # Test 5: toggle enabled flag (used to test the else branch later) + - platform: template + id: btn_disable + name: "Disable" + on_press: + - lambda: 'id(enabled) = false;' + + # Test 6: reset counter + - platform: template + id: btn_reset + name: "Reset" + on_press: + - lambda: 'id(counter) = 0;' + + # Helper: trigger an update of the counter_value text sensor + - platform: template + id: btn_update_counter + name: "Update Counter" + on_press: + - component.update: counter_value + +text_sensor: + - platform: template + id: counter_value + name: "Counter" + update_interval: never + lambda: |- + char buf[16]; + snprintf(buf, sizeof(buf), "%d", id(counter)); + return std::string(buf); diff --git a/tests/integration/test_inline_continuation_actions.py b/tests/integration/test_inline_continuation_actions.py new file mode 100644 index 0000000000..84d2bcda28 --- /dev/null +++ b/tests/integration/test_inline_continuation_actions.py @@ -0,0 +1,92 @@ +"""Integration test for inline ContinuationAction in If/While/RepeatAction. + +Verifies that if/then, if/then/else, while, and repeat actions all execute +correctly with the inline-member ContinuationAction storage (replacing the +previous heap allocation). +""" + +from __future__ import annotations + +import asyncio + +from aioesphomeapi import ButtonInfo, EntityState, TextSensorInfo, TextSensorState +import pytest + +from .state_utils import InitialStateHelper, require_entity +from .types import APIClientConnectedFactory, RunCompiledFunction + + +@pytest.mark.asyncio +async def test_inline_continuation_actions( + yaml_config: str, + run_compiled: RunCompiledFunction, + api_client_connected: APIClientConnectedFactory, +) -> None: + """Test if/while/repeat all execute correctly with inline continuations.""" + loop = asyncio.get_running_loop() + async with run_compiled(yaml_config), api_client_connected() as client: + text_state_future: asyncio.Future[TextSensorState] | None = None + + def on_state(state: EntityState) -> None: + if ( + isinstance(state, TextSensorState) + and text_state_future is not None + and not text_state_future.done() + ): + text_state_future.set_result(state) + + async def wait_for_text_state(timeout: float = 5.0) -> TextSensorState: + nonlocal text_state_future + text_state_future = loop.create_future() + try: + return await asyncio.wait_for(text_state_future, timeout) + finally: + text_state_future = None + + entities, _ = await client.list_entities_services() + initial_state_helper = InitialStateHelper(entities) + client.subscribe_states(initial_state_helper.on_state_wrapper(on_state)) + await initial_state_helper.wait_for_initial_states() + + # Verify the counter text sensor exists (used implicitly by get_counter) + require_entity(entities, "counter_value", TextSensorInfo) + + async def press(name: str) -> None: + btn = require_entity(entities, name.lower().replace(" ", "_"), ButtonInfo) + client.button_command(btn.key) + # Brief settle delay so the action chain runs to completion + await asyncio.sleep(0.05) + + async def get_counter() -> int: + """Force the template text sensor to update and read the value.""" + update_btn = require_entity(entities, "update_counter", ButtonInfo) + client.button_command(update_btn.key) + state = await wait_for_text_state() + return int(state.state) + + # Test 1: if/then with HasElse=false — counter starts 0, enabled=true → +1 + await press("Reset") + await press("If Then") + assert await get_counter() == 1 + + # Test 2: if/then/else (HasElse=true) with enabled=true → +10 (then branch) + await press("Reset") + await press("If Then Else") + assert await get_counter() == 10 + + # Test 3: while loop — counter=1 (1%5!=0) → loops to 5, exits + await press("Reset") + await press("If Then") # counter=1 + await press("While") + assert await get_counter() == 5 + + # Test 4: repeat 3x +7 = 21 + await press("Reset") + await press("Repeat") + assert await get_counter() == 21 + + # Test 5: if/then/else with enabled=false → +100 (else branch) + await press("Reset") + await press("Disable") + await press("If Then Else") + assert await get_counter() == 100 From 1ab588f10e34498e71ec26d7f3f25c0bd823aa2a Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Mon, 27 Apr 2026 05:11:11 -0500 Subject: [PATCH 2/2] [core] Drop redundant inline_continuation test (covered by test_continuation_actions) --- .../fixtures/inline_continuation_actions.yaml | 91 ------------------ .../test_inline_continuation_actions.py | 92 ------------------- 2 files changed, 183 deletions(-) delete mode 100644 tests/integration/fixtures/inline_continuation_actions.yaml delete mode 100644 tests/integration/test_inline_continuation_actions.py diff --git a/tests/integration/fixtures/inline_continuation_actions.yaml b/tests/integration/fixtures/inline_continuation_actions.yaml deleted file mode 100644 index 0b656386e1..0000000000 --- a/tests/integration/fixtures/inline_continuation_actions.yaml +++ /dev/null @@ -1,91 +0,0 @@ -esphome: - name: inline-continuation-test -host: -api: -logger: - level: DEBUG - -globals: - - id: counter - type: int - initial_value: "0" - - id: enabled - type: bool - initial_value: "true" - -button: - # Test 1: if/then (HasElse=false) — increments counter when enabled is true - - platform: template - id: btn_if_then - name: "If Then" - on_press: - - if: - condition: - lambda: 'return id(enabled);' - then: - - lambda: 'id(counter) += 1;' - - # Test 2: if/then/else (HasElse=true) — increments by 10 when enabled, else by 100 - - platform: template - id: btn_if_then_else - name: "If Then Else" - on_press: - - if: - condition: - lambda: 'return id(enabled);' - then: - - lambda: 'id(counter) += 10;' - else: - - lambda: 'id(counter) += 100;' - - # Test 3: while loop — increments counter 5 times via inline check - - platform: template - id: btn_while - name: "While" - on_press: - - while: - condition: - lambda: 'return id(counter) % 5 != 0;' - then: - - lambda: 'id(counter) += 1;' - - # Test 4: repeat loop — increments by 7 over 3 iterations - - platform: template - id: btn_repeat - name: "Repeat" - on_press: - - repeat: - count: 3 - then: - - lambda: 'id(counter) += 7;' - - # Test 5: toggle enabled flag (used to test the else branch later) - - platform: template - id: btn_disable - name: "Disable" - on_press: - - lambda: 'id(enabled) = false;' - - # Test 6: reset counter - - platform: template - id: btn_reset - name: "Reset" - on_press: - - lambda: 'id(counter) = 0;' - - # Helper: trigger an update of the counter_value text sensor - - platform: template - id: btn_update_counter - name: "Update Counter" - on_press: - - component.update: counter_value - -text_sensor: - - platform: template - id: counter_value - name: "Counter" - update_interval: never - lambda: |- - char buf[16]; - snprintf(buf, sizeof(buf), "%d", id(counter)); - return std::string(buf); diff --git a/tests/integration/test_inline_continuation_actions.py b/tests/integration/test_inline_continuation_actions.py deleted file mode 100644 index 84d2bcda28..0000000000 --- a/tests/integration/test_inline_continuation_actions.py +++ /dev/null @@ -1,92 +0,0 @@ -"""Integration test for inline ContinuationAction in If/While/RepeatAction. - -Verifies that if/then, if/then/else, while, and repeat actions all execute -correctly with the inline-member ContinuationAction storage (replacing the -previous heap allocation). -""" - -from __future__ import annotations - -import asyncio - -from aioesphomeapi import ButtonInfo, EntityState, TextSensorInfo, TextSensorState -import pytest - -from .state_utils import InitialStateHelper, require_entity -from .types import APIClientConnectedFactory, RunCompiledFunction - - -@pytest.mark.asyncio -async def test_inline_continuation_actions( - yaml_config: str, - run_compiled: RunCompiledFunction, - api_client_connected: APIClientConnectedFactory, -) -> None: - """Test if/while/repeat all execute correctly with inline continuations.""" - loop = asyncio.get_running_loop() - async with run_compiled(yaml_config), api_client_connected() as client: - text_state_future: asyncio.Future[TextSensorState] | None = None - - def on_state(state: EntityState) -> None: - if ( - isinstance(state, TextSensorState) - and text_state_future is not None - and not text_state_future.done() - ): - text_state_future.set_result(state) - - async def wait_for_text_state(timeout: float = 5.0) -> TextSensorState: - nonlocal text_state_future - text_state_future = loop.create_future() - try: - return await asyncio.wait_for(text_state_future, timeout) - finally: - text_state_future = None - - entities, _ = await client.list_entities_services() - initial_state_helper = InitialStateHelper(entities) - client.subscribe_states(initial_state_helper.on_state_wrapper(on_state)) - await initial_state_helper.wait_for_initial_states() - - # Verify the counter text sensor exists (used implicitly by get_counter) - require_entity(entities, "counter_value", TextSensorInfo) - - async def press(name: str) -> None: - btn = require_entity(entities, name.lower().replace(" ", "_"), ButtonInfo) - client.button_command(btn.key) - # Brief settle delay so the action chain runs to completion - await asyncio.sleep(0.05) - - async def get_counter() -> int: - """Force the template text sensor to update and read the value.""" - update_btn = require_entity(entities, "update_counter", ButtonInfo) - client.button_command(update_btn.key) - state = await wait_for_text_state() - return int(state.state) - - # Test 1: if/then with HasElse=false — counter starts 0, enabled=true → +1 - await press("Reset") - await press("If Then") - assert await get_counter() == 1 - - # Test 2: if/then/else (HasElse=true) with enabled=true → +10 (then branch) - await press("Reset") - await press("If Then Else") - assert await get_counter() == 10 - - # Test 3: while loop — counter=1 (1%5!=0) → loops to 5, exits - await press("Reset") - await press("If Then") # counter=1 - await press("While") - assert await get_counter() == 5 - - # Test 4: repeat 3x +7 = 21 - await press("Reset") - await press("Repeat") - assert await get_counter() == 21 - - # Test 5: if/then/else with enabled=false → +100 (else branch) - await press("Reset") - await press("Disable") - await press("If Then Else") - assert await get_counter() == 100