[core] Keep scheduler string_test, migrate it to the const char* API

This commit is contained in:
J. Nick Koston
2026-06-21 10:29:04 -05:00
parent 8aa06c9d15
commit ff56d66ced
2 changed files with 506 additions and 0 deletions

View File

@@ -0,0 +1,304 @@
esphome:
name: scheduler-string-test
on_boot:
priority: -100
then:
- logger.log: "Starting scheduler string tests"
debug_scheduler: true # Enable scheduler debug logging
host:
api:
logger:
level: VERBOSE
globals:
- id: timeout_counter
type: int
initial_value: '0'
- id: interval_counter
type: int
initial_value: '0'
- id: static_tests_done
type: bool
initial_value: 'false'
- id: dynamic_tests_done
type: bool
initial_value: 'false'
- id: results_reported
type: bool
initial_value: 'false'
- id: edge_tests_done
type: bool
initial_value: 'false'
- id: empty_cancel_failed
type: bool
initial_value: 'false'
script:
- id: test_static_strings
then:
- logger.log: "Testing static string timeouts and intervals"
- lambda: |-
auto *component1 = id(test_sensor1);
// Test 1: Static string literals with set_timeout
App.scheduler.set_timeout(component1, "static_timeout_1", 50, []() {
ESP_LOGI("test", "Static timeout 1 fired");
id(timeout_counter) += 1;
});
// Test 2: Static const char* with set_timeout
static const char* TIMEOUT_NAME = "static_timeout_2";
App.scheduler.set_timeout(component1, TIMEOUT_NAME, 100, []() {
ESP_LOGI("test", "Static timeout 2 fired");
id(timeout_counter) += 1;
});
// Test 3: Static string literal with set_interval
App.scheduler.set_interval(component1, "static_interval_1", 200, []() {
ESP_LOGI("test", "Static interval 1 fired, count: %d", id(interval_counter));
id(interval_counter) += 1;
if (id(interval_counter) >= 3) {
App.scheduler.cancel_interval(id(test_sensor1), "static_interval_1");
ESP_LOGI("test", "Cancelled static interval 1");
}
});
// Test 4: Empty string (should be handled safely)
App.scheduler.set_timeout(component1, "", 150, []() {
ESP_LOGI("test", "Empty string timeout fired");
});
// Test 5: Cancel timeout with const char* literal
App.scheduler.set_timeout(component1, "cancel_static_timeout", 5000, []() {
ESP_LOGI("test", "This static timeout should be cancelled");
});
// Cancel using const char* directly
App.scheduler.cancel_timeout(component1, "cancel_static_timeout");
ESP_LOGI("test", "Cancelled static timeout using const char*");
// Test 6 & 7: Test defer with const char* overload using a test component
class TestDeferComponent : public Component {
public:
void test_static_defer() {
// Test 6: Static string literal with defer (const char* overload)
this->defer("static_defer_1", []() {
ESP_LOGI("test", "Static defer 1 fired");
id(timeout_counter) += 1;
});
// Test 7: Static const char* with defer
static const char* DEFER_NAME = "static_defer_2";
this->defer(DEFER_NAME, []() {
ESP_LOGI("test", "Static defer 2 fired");
id(timeout_counter) += 1;
});
}
};
static TestDeferComponent test_defer_component;
test_defer_component.test_static_defer();
- id: test_dynamic_strings
then:
- logger.log: "Testing const char* timeouts and intervals"
- lambda: |-
auto *component2 = id(test_sensor2);
// Test 8: const char* name with set_timeout
App.scheduler.set_timeout(component2, "dynamic_timeout", 100, []() {
ESP_LOGI("test", "Dynamic timeout fired");
id(timeout_counter) += 1;
});
// Test 9: const char* name with set_interval, cancelled from inside the callback
App.scheduler.set_interval(component2, "dynamic_interval", 250, []() {
ESP_LOGI("test", "Dynamic interval fired");
id(interval_counter) += 1;
if (id(interval_counter) >= 6) {
App.scheduler.cancel_interval(id(test_sensor2), "dynamic_interval");
ESP_LOGI("test", "Cancelled dynamic interval");
}
});
// Test 10: Cancel with a different pointer but identical content.
// STATIC_STRING names match by content, so a distinct static buffer with the
// same characters still cancels the scheduled timeout.
static const char CANCEL_NAME[] = "cancel_test";
App.scheduler.set_timeout(component2, CANCEL_NAME, 2000, []() {
ESP_LOGI("test", "This should be cancelled");
});
static const char CANCEL_NAME_2[] = "cancel_test";
App.scheduler.cancel_timeout(component2, CANCEL_NAME_2);
ESP_LOGI("test", "Cancelled timeout using different string object");
// Test 11: const char* name with defer
class TestDynamicDeferComponent : public Component {
public:
void test_dynamic_defer() {
this->defer("dynamic_defer", []() {
ESP_LOGI("test", "Dynamic defer fired");
id(timeout_counter) += 1;
});
}
};
static TestDynamicDeferComponent test_dynamic_defer_component;
test_dynamic_defer_component.test_dynamic_defer();
- id: test_cancellation_edge_cases
then:
- logger.log: "Testing cancellation edge cases"
- lambda: |-
auto *component1 = id(test_sensor1);
// Use a different component for empty string tests to avoid interference
auto *component2 = id(test_sensor2);
// Test 12: Cancel with empty string - regression test for issue #9599
// First create a timeout with empty name on component2 to avoid interference
App.scheduler.set_timeout(component2, "", 500, []() {
ESP_LOGE("test", "ERROR: Empty name timeout fired - it should have been cancelled!");
id(empty_cancel_failed) = true;
});
// Now cancel it - this should work after our fix
bool cancelled_empty = App.scheduler.cancel_timeout(component2, "");
ESP_LOGI("test", "Cancel empty string result: %s (should be true)", cancelled_empty ? "true" : "false");
if (!cancelled_empty) {
ESP_LOGE("test", "ERROR: Failed to cancel empty string timeout!");
id(empty_cancel_failed) = true;
}
// Test 13: Cancel non-existent timeout
bool cancelled_nonexistent = App.scheduler.cancel_timeout(component1, "does_not_exist");
ESP_LOGI("test", "Cancel non-existent timeout result: %s",
cancelled_nonexistent ? "true (unexpected!)" : "false (expected)");
// Test 14: Multiple timeouts with same name - only last should execute
for (int i = 0; i < 5; i++) {
App.scheduler.set_timeout(component1, "duplicate_timeout", 200 + i*10, [i]() {
ESP_LOGI("test", "Duplicate timeout %d fired", i);
id(timeout_counter) += 1;
});
}
ESP_LOGI("test", "Created 5 timeouts with same name 'duplicate_timeout'");
// Test 15: Multiple intervals with same name - only last should run
for (int i = 0; i < 3; i++) {
App.scheduler.set_interval(component1, "duplicate_interval", 300, [i]() {
ESP_LOGI("test", "Duplicate interval %d fired", i);
id(interval_counter) += 10; // Large increment to detect multiple
// Cancel after first execution
App.scheduler.cancel_interval(id(test_sensor1), "duplicate_interval");
});
}
ESP_LOGI("test", "Created 3 intervals with same name 'duplicate_interval'");
// Test 16: Cancel with nullptr protection (via empty const char*)
const char* null_name = "";
App.scheduler.set_timeout(component2, null_name, 600, []() {
ESP_LOGE("test", "ERROR: Const char* empty timeout fired - should have been cancelled!");
id(empty_cancel_failed) = true;
});
bool cancelled_const_empty = App.scheduler.cancel_timeout(component2, null_name);
ESP_LOGI("test", "Cancel const char* empty result: %s (should be true)",
cancelled_const_empty ? "true" : "false");
if (!cancelled_const_empty) {
ESP_LOGE("test", "ERROR: Failed to cancel const char* empty timeout!");
id(empty_cancel_failed) = true;
}
// Test 17: Rapid create/cancel/create with same name
App.scheduler.set_timeout(component1, "rapid_test", 5000, []() {
ESP_LOGI("test", "First rapid timeout - should not fire");
id(timeout_counter) += 100;
});
App.scheduler.cancel_timeout(component1, "rapid_test");
App.scheduler.set_timeout(component1, "rapid_test", 250, []() {
ESP_LOGI("test", "Second rapid timeout - should fire");
id(timeout_counter) += 1;
});
// Test 18: Cancel all with a specific name (multiple instances)
// Create multiple with same name
App.scheduler.set_timeout(component1, "multi_cancel", 300, []() {
ESP_LOGI("test", "Multi-cancel timeout 1");
});
App.scheduler.set_timeout(component1, "multi_cancel", 350, []() {
ESP_LOGI("test", "Multi-cancel timeout 2");
});
App.scheduler.set_timeout(component1, "multi_cancel", 400, []() {
ESP_LOGI("test", "Multi-cancel timeout 3 - only this should fire");
id(timeout_counter) += 1;
});
// Note: Each set_timeout with same name cancels the previous one automatically
- id: report_results
then:
- lambda: |-
ESP_LOGI("test", "Final results - Timeouts: %d, Intervals: %d",
id(timeout_counter), id(interval_counter));
// Check if empty string cancellation test passed
if (id(empty_cancel_failed)) {
ESP_LOGE("test", "ERROR: Empty string cancellation test FAILED!");
} else {
ESP_LOGI("test", "Empty string cancellation test PASSED");
}
sensor:
- platform: template
name: Test Sensor 1
id: test_sensor1
lambda: return 1.0;
update_interval: never
- platform: template
name: Test Sensor 2
id: test_sensor2
lambda: return 2.0;
update_interval: never
interval:
# Run static string tests after boot - using script to run once
- interval: 0.1s
then:
- if:
condition:
lambda: 'return id(static_tests_done) == false;'
then:
- lambda: 'id(static_tests_done) = true;'
- script.execute: test_static_strings
- logger.log: "Started static string tests"
# Run dynamic string tests after static tests
- interval: 0.2s
then:
- if:
condition:
lambda: 'return id(static_tests_done) && !id(dynamic_tests_done);'
then:
- lambda: 'id(dynamic_tests_done) = true;'
- delay: 0.2s
- script.execute: test_dynamic_strings
# Run cancellation edge case tests after dynamic tests
- interval: 0.2s
then:
- if:
condition:
lambda: 'return id(dynamic_tests_done) && !id(edge_tests_done);'
then:
- lambda: 'id(edge_tests_done) = true;'
- delay: 0.5s
- script.execute: test_cancellation_edge_cases
# Report results after all tests
- interval: 0.2s
then:
- if:
condition:
lambda: 'return id(edge_tests_done) && !id(results_reported);'
then:
- lambda: 'id(results_reported) = true;'
- delay: 1s
- script.execute: report_results

View File

@@ -0,0 +1,202 @@
"""Test scheduler string optimization with static and dynamic strings."""
import asyncio
import re
import pytest
from .types import APIClientConnectedFactory, RunCompiledFunction
@pytest.mark.asyncio
async def test_scheduler_string_test(
yaml_config: str,
run_compiled: RunCompiledFunction,
api_client_connected: APIClientConnectedFactory,
) -> None:
"""Test that scheduler handles both static and dynamic strings correctly."""
# Track counts
timeout_count = 0
interval_count = 0
# Events for each test completion
static_timeout_1_fired = asyncio.Event()
static_timeout_2_fired = asyncio.Event()
static_interval_fired = asyncio.Event()
static_interval_cancelled = asyncio.Event()
empty_string_timeout_fired = asyncio.Event()
static_timeout_cancelled = asyncio.Event()
static_defer_1_fired = asyncio.Event()
static_defer_2_fired = asyncio.Event()
dynamic_timeout_fired = asyncio.Event()
dynamic_interval_fired = asyncio.Event()
dynamic_defer_fired = asyncio.Event()
cancel_test_done = asyncio.Event()
final_results_logged = asyncio.Event()
# Track interval counts
static_interval_count = 0
dynamic_interval_count = 0
def on_log_line(line: str) -> None:
nonlocal \
timeout_count, \
interval_count, \
static_interval_count, \
dynamic_interval_count
# Strip ANSI color codes
clean_line = re.sub(r"\x1b\[[0-9;]*m", "", line)
# Check for static timeout completions
if "Static timeout 1 fired" in clean_line:
static_timeout_1_fired.set()
timeout_count += 1
elif "Static timeout 2 fired" in clean_line:
static_timeout_2_fired.set()
timeout_count += 1
# Check for static interval
elif "Static interval 1 fired" in clean_line:
match = re.search(r"count: (\d+)", clean_line)
if match:
static_interval_count = int(match.group(1))
static_interval_fired.set()
elif "Cancelled static interval 1" in clean_line:
static_interval_cancelled.set()
# Check for empty string timeout
elif "Empty string timeout fired" in clean_line:
empty_string_timeout_fired.set()
# Check for static timeout cancellation
elif "Cancelled static timeout using const char*" in clean_line:
static_timeout_cancelled.set()
# Check for static defer tests
elif "Static defer 1 fired" in clean_line:
static_defer_1_fired.set()
timeout_count += 1
elif "Static defer 2 fired" in clean_line:
static_defer_2_fired.set()
timeout_count += 1
# Check for dynamic string tests
elif "Dynamic timeout fired" in clean_line:
dynamic_timeout_fired.set()
timeout_count += 1
elif "Dynamic interval fired" in clean_line:
dynamic_interval_count += 1
dynamic_interval_fired.set()
# Check for dynamic defer test
elif "Dynamic defer fired" in clean_line:
dynamic_defer_fired.set()
timeout_count += 1
# Check for cancel test
elif "Cancelled timeout using different string object" in clean_line:
cancel_test_done.set()
# Check for final results
elif "Final results" in clean_line:
match = re.search(r"Timeouts: (\d+), Intervals: (\d+)", clean_line)
if match:
timeout_count = int(match.group(1))
interval_count = int(match.group(2))
final_results_logged.set()
async with (
run_compiled(yaml_config, line_callback=on_log_line),
api_client_connected() as client,
):
# Verify we can connect
device_info = await client.device_info()
assert device_info is not None
assert device_info.name == "scheduler-string-test"
# Wait for static string tests
try:
await asyncio.wait_for(static_timeout_1_fired.wait(), timeout=0.5)
except TimeoutError:
pytest.fail("Static timeout 1 did not fire within 0.5 seconds")
try:
await asyncio.wait_for(static_timeout_2_fired.wait(), timeout=0.5)
except TimeoutError:
pytest.fail("Static timeout 2 did not fire within 0.5 seconds")
try:
await asyncio.wait_for(static_interval_fired.wait(), timeout=1.0)
except TimeoutError:
pytest.fail("Static interval did not fire within 1 second")
try:
await asyncio.wait_for(static_interval_cancelled.wait(), timeout=2.0)
except TimeoutError:
pytest.fail("Static interval was not cancelled within 2 seconds")
# Verify static interval ran at least 3 times
assert static_interval_count >= 2, (
f"Expected static interval to run at least 3 times, got {static_interval_count + 1}"
)
# Verify static timeout was cancelled
assert static_timeout_cancelled.is_set(), (
"Static timeout should have been cancelled"
)
# Wait for static defer tests
try:
await asyncio.wait_for(static_defer_1_fired.wait(), timeout=0.5)
except TimeoutError:
pytest.fail("Static defer 1 did not fire within 0.5 seconds")
try:
await asyncio.wait_for(static_defer_2_fired.wait(), timeout=0.5)
except TimeoutError:
pytest.fail("Static defer 2 did not fire within 0.5 seconds")
# Wait for dynamic string tests
try:
await asyncio.wait_for(dynamic_timeout_fired.wait(), timeout=1.0)
except TimeoutError:
pytest.fail("Dynamic timeout did not fire within 1 second")
try:
await asyncio.wait_for(dynamic_interval_fired.wait(), timeout=1.5)
except TimeoutError:
pytest.fail("Dynamic interval did not fire within 1.5 seconds")
# Wait for dynamic defer test
try:
await asyncio.wait_for(dynamic_defer_fired.wait(), timeout=1.0)
except TimeoutError:
pytest.fail("Dynamic defer did not fire within 1 second")
# Wait for cancel test
try:
await asyncio.wait_for(cancel_test_done.wait(), timeout=1.0)
except TimeoutError:
pytest.fail("Cancel test did not complete within 1 second")
# Wait for final results
try:
await asyncio.wait_for(final_results_logged.wait(), timeout=4.0)
except TimeoutError:
pytest.fail("Final results were not logged within 4 seconds")
# Verify results
assert timeout_count >= 6, (
f"Expected at least 6 timeouts (including defers), got {timeout_count}"
)
assert interval_count >= 3, (
f"Expected at least 3 interval fires, got {interval_count}"
)
# Empty string timeout DOES fire (scheduler accepts empty names)
assert empty_string_timeout_fired.is_set(), "Empty string timeout should fire"