[core] Test that if and while branches resume after delays, stops, nesting and script modes (#20282)

This commit is contained in:
J. Nick Koston
2026-10-07 08:33:20 -04:00
committed by GitHub
parent 19561d44a2
commit 51dbd3a63f
2 changed files with 401 additions and 0 deletions
@@ -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
@@ -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"(?<![\w-]){re.escape(text)}(?![\w-])", line) is not None
def check_output(line: str) -> 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