From 51dbd3a63f26a0f83113f31fc87092b014d2553a Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Wed, 7 Oct 2026 02:33:20 -1000 Subject: [PATCH] [core] Test that if and while branches resume after delays, stops, nesting and script modes (#20282) --- .../fixtures/branch_resume_async.yaml | 261 ++++++++++++++++++ tests/integration/test_branch_resume_async.py | 140 ++++++++++ 2 files changed, 401 insertions(+) create mode 100644 tests/integration/fixtures/branch_resume_async.yaml create mode 100644 tests/integration/test_branch_resume_async.py diff --git a/tests/integration/fixtures/branch_resume_async.yaml b/tests/integration/fixtures/branch_resume_async.yaml new file mode 100644 index 0000000000..fb09323809 --- /dev/null +++ b/tests/integration/fixtures/branch_resume_async.yaml @@ -0,0 +1,261 @@ +esphome: + name: test-branch-resume-async + +host: + +logger: + level: DEBUG + +script: + - id: delay_in_if + then: + - if: + condition: + lambda: 'return true;' + then: + - delay: 50ms + - logger.log: "if-branch-after-delay" + else: + - logger.log: "if-else-should-not-run" + - logger.log: "after-if" + + - id: delay_in_else + then: + - if: + condition: + lambda: 'return false;' + then: + - logger.log: "if-then-should-not-run" + else: + - delay: 50ms + - logger.log: "else-branch-after-delay" + - logger.log: "after-else" + + - id: delay_in_while + then: + - lambda: 'id(counter) = 0;' + - while: + condition: + lambda: 'return id(counter) < 3;' + then: + - delay: 20ms + - lambda: 'id(counter) += 1;' + - logger.log: + format: "while-body-%d" + args: ['id(counter)'] + - logger.log: "after-while" + + - id: stop_in_branch + then: + - if: + condition: + lambda: 'return true;' + then: + - delay: 500ms + - logger.log: "stopped-branch-should-not-run" + - logger.log: "stopped-after-if-should-not-run" + + - id: parallel_branch + mode: parallel + parameters: + run: int + then: + - if: + condition: + lambda: 'return run >= 0;' + then: + - delay: 100ms + - logger.log: + format: "parallel-branch-done-%d" + args: ['run'] + - logger.log: + format: "parallel-after-if-%d" + args: ['run'] + + - id: nested_if_last_in_while + then: + - lambda: 'id(counter) = 0;' + - while: + condition: + lambda: 'return id(counter) < 2;' + then: + - lambda: 'id(counter) += 1;' + - if: + condition: + lambda: 'return true;' + then: + - delay: 20ms + - logger.log: + format: "nested-if-in-while-%d" + args: ['id(counter)'] + - logger.log: "after-nested-while" + + - id: stop_mid_while + then: + - lambda: 'id(counter) = 0;' + - while: + condition: + lambda: 'return id(counter) < 5;' + then: + - delay: 200ms + - lambda: 'id(counter) += 1;' + - logger.log: + format: "stop-while-body-%d" + args: ['id(counter)'] + - logger.log: "stop-while-done" + + - id: queued_branch + mode: queued + parameters: + run: int + then: + - if: + condition: + lambda: 'return true;' + then: + - delay: 30ms + - logger.log: + format: "queued-branch-%d" + args: ['run'] + - logger.log: + format: "queued-after-if-%d" + args: ['run'] + + - id: restart_branch + mode: restart + parameters: + run: int + then: + - if: + condition: + lambda: 'return true;' + then: + - delay: 100ms + - logger.log: + format: "restart-branch-%d" + args: ['run'] + - logger.log: + format: "restart-after-if-%d" + args: ['run'] + + - id: delay_last_in_branch + then: + - if: + condition: + lambda: 'return true;' + then: + - logger.log: "delay-last-start" + - delay: 30ms + - logger.log: "after-delay-last" + + - id: stop_in_if_condition + then: + - if: + condition: + lambda: |- + id(stop_in_if_condition).stop(); + return true; + then: + - logger.log: "if-stopped-by-condition-should-not-run" + else: + - logger.log: "if-else-stopped-by-condition-should-not-run" + - logger.log: "after-if-stopped-by-condition-should-not-run" + - id: stop_in_if_else_condition + then: + - if: + condition: + lambda: |- + id(stop_in_if_else_condition).stop(); + return false; + else: + - logger.log: "else-stopped-by-condition-should-not-run" + - logger.log: "after-else-stopped-by-condition-should-not-run" + - id: stop_in_while_condition + then: + - while: + condition: + lambda: |- + id(stop_in_while_condition).stop(); + return true; + then: + - logger.log: "while-stopped-by-condition-should-not-run" + - logger.log: "after-while-stopped-by-condition-should-not-run" + +globals: + - id: counter + type: int + initial_value: '0' + +api: + actions: + - action: run_delay_in_if + then: + - script.execute: delay_in_if + - action: run_delay_in_else + then: + - script.execute: delay_in_else + - action: run_delay_in_while + then: + - script.execute: delay_in_while + - action: run_stop_in_branch + then: + - script.execute: stop_in_branch + - delay: 100ms + - script.stop: stop_in_branch + - lambda: |- + ESP_LOGI("test", "stop-running=%d", id(stop_in_branch).is_running() ? 1 : 0); + - delay: 600ms + - logger.log: "stop-check-done" + - action: run_nested_if_last_in_while + then: + - script.execute: nested_if_last_in_while + - action: run_stop_mid_while + then: + - script.execute: stop_mid_while + - delay: 300ms + - script.stop: stop_mid_while + - lambda: |- + ESP_LOGI("test", "stop-while-running=%d", id(stop_mid_while).is_running() ? 1 : 0); + - delay: 400ms + - script.execute: stop_mid_while + - action: run_queued_branch + then: + - script.execute: + id: queued_branch + run: 1 + - script.execute: + id: queued_branch + run: 2 + - script.execute: + id: queued_branch + run: 3 + - action: run_restart_branch + then: + - script.execute: + id: restart_branch + run: 1 + - delay: 30ms + - script.execute: + id: restart_branch + run: 2 + - action: run_delay_last_in_branch + then: + - script.execute: delay_last_in_branch + - action: run_stop_in_conditions + then: + - script.execute: stop_in_if_condition + - script.execute: stop_in_if_else_condition + - script.execute: stop_in_while_condition + - delay: 200ms + - logger.log: "stop-in-conditions-done" + - action: run_parallel_branch + then: + - script.execute: + id: parallel_branch + run: 1 + - script.execute: + id: parallel_branch + run: 2 + - script.execute: + id: parallel_branch + run: 3 diff --git a/tests/integration/test_branch_resume_async.py b/tests/integration/test_branch_resume_async.py new file mode 100644 index 0000000000..78064cbe30 --- /dev/null +++ b/tests/integration/test_branch_resume_async.py @@ -0,0 +1,140 @@ +"""Test that if/else and while branches resume correctly after async actions, stop and parallel runs.""" + +from __future__ import annotations + +import asyncio +import re + +import pytest + +from .types import APIClientConnectedFactory, RunCompiledFunction + + +@pytest.mark.asyncio +async def test_branch_resume_async( + yaml_config: str, + run_compiled: RunCompiledFunction, + api_client_connected: APIClientConnectedFactory, +) -> None: + """A branch that ends after a delay must resume its owner exactly once.""" + lines: list[str] = [] + waiters: list[tuple[str, asyncio.Future[None]]] = [] + + def has_marker(text: str, line: str) -> bool: + # Whole markers only: "after-if" must not match "queued-after-if-1" + return re.search(rf"(? None: + lines.append(line) + for text, future in waiters: + if has_marker(text, line) and not future.done(): + future.set_result(None) + + def wait_for(text: str) -> asyncio.Future[None]: + future: asyncio.Future[None] = asyncio.get_running_loop().create_future() + waiters.append((text, future)) + return future + + def count(text: str) -> int: + return sum(has_marker(text, line) for line in lines) + + async with ( + run_compiled(yaml_config, line_callback=check_output), + api_client_connected() as client, + ): + _, services = await client.list_entities_services() + service = {s.name: s for s in services} + + done = wait_for("after-if") + await client.execute_service(service["run_delay_in_if"], {}) + await asyncio.wait_for(done, timeout=2.0) + assert count("if-branch-after-delay") == 1 + assert count("if-else-should-not-run") == 0 + assert count("after-if") == 1 + + done = wait_for("after-else") + await client.execute_service(service["run_delay_in_else"], {}) + await asyncio.wait_for(done, timeout=2.0) + assert count("else-branch-after-delay") == 1 + assert count("if-then-should-not-run") == 0 + assert count("after-else") == 1 + + done = wait_for("after-while") + await client.execute_service(service["run_delay_in_while"], {}) + await asyncio.wait_for(done, timeout=2.0) + assert [count(f"while-body-{i}") for i in (1, 2, 3)] == [1, 1, 1] + assert count("while-body-4") == 0 + assert count("after-while") == 1 + + done = wait_for("stop-check-done") + await client.execute_service(service["run_stop_in_branch"], {}) + await asyncio.wait_for(done, timeout=3.0) + assert count("stop-running=0") == 1 + assert count("stopped-branch-should-not-run") == 0 + assert count("stopped-after-if-should-not-run") == 0 + + done = wait_for("after-nested-while") + await client.execute_service(service["run_nested_if_last_in_while"], {}) + await asyncio.wait_for(done, timeout=2.0) + assert [count(f"nested-if-in-while-{i}") for i in (1, 2)] == [1, 1] + assert count("nested-if-in-while-3") == 0 + assert count("after-nested-while") == 1 + + # Stop during the second iteration's delay: that iteration and the line after the loop never run; + # a fresh run then completes + done = wait_for("stop-while-done") + await client.execute_service(service["run_stop_mid_while"], {}) + await asyncio.wait_for(done, timeout=3.0) + assert count("stop-while-running=0") == 1 + assert count("stop-while-body-1") == 2 + assert [count(f"stop-while-body-{i}") for i in (2, 3, 4, 5)] == [1, 1, 1, 1] + assert count("stop-while-done") == 1 + + done = wait_for("queued-after-if-3") + await client.execute_service(service["run_queued_branch"], {}) + await asyncio.wait_for(done, timeout=2.0) + queued = [line for line in lines if "queued-" in line] + order = [ + "queued-branch-1", + "queued-after-if-1", + "queued-branch-2", + "queued-after-if-2", + "queued-branch-3", + "queued-after-if-3", + ] + assert [next(o for o in order if o in line) for line in queued] == order + + done = wait_for("restart-after-if-2") + await client.execute_service(service["run_restart_branch"], {}) + await asyncio.wait_for(done, timeout=2.0) + await asyncio.sleep(0.2) + assert count("restart-branch-1") == 0 + assert count("restart-after-if-1") == 0 + assert count("restart-branch-2") == 1 + assert count("restart-after-if-2") == 1 + + done = wait_for("after-delay-last") + await client.execute_service(service["run_delay_last_in_branch"], {}) + await asyncio.wait_for(done, timeout=2.0) + assert count("delay-last-start") == 1 + assert count("after-delay-last") == 1 + + done = wait_for("parallel-after-if-3") + await client.execute_service(service["run_parallel_branch"], {}) + await asyncio.wait_for(done, timeout=2.0) + await asyncio.sleep(0.2) + for run in (1, 2, 3): + assert count(f"parallel-branch-done-{run}") == 1 + assert count(f"parallel-after-if-{run}") == 1 + + # A condition that stops its own script must not start a branch or continue the chain + done = wait_for("stop-in-conditions-done") + await client.execute_service(service["run_stop_in_conditions"], {}) + await asyncio.wait_for(done, timeout=2.0) + assert count("if-stopped-by-condition-should-not-run") == 0 + assert count("if-else-stopped-by-condition-should-not-run") == 0 + assert count("after-if-stopped-by-condition-should-not-run") == 0 + assert count("else-stopped-by-condition-should-not-run") == 0 + assert count("after-else-stopped-by-condition-should-not-run") == 0 + assert count("while-stopped-by-condition-should-not-run") == 0 + assert count("after-while-stopped-by-condition-should-not-run") == 0