diff --git a/tests/integration/fixtures/multi_click_trigger.yaml b/tests/integration/fixtures/multi_click_trigger.yaml new file mode 100644 index 0000000000..3bd53d594c --- /dev/null +++ b/tests/integration/fixtures/multi_click_trigger.yaml @@ -0,0 +1,105 @@ +esphome: + name: test-multi-click + +host: +api: + batch_delay: 0ms + services: + - service: run_all_tests + then: + # Prime the binary sensor with an initial OFF state. + # trigger_on_initial_state defaults to false, so the first + # state change from unknown won't fire callbacks. + - binary_sensor.template.publish: + id: test_button + state: false + - delay: 50ms + + # Test 1: Single click (ON < 50ms, OFF >= 30ms) + - binary_sensor.template.publish: + id: test_button + state: true + - delay: 20ms + - binary_sensor.template.publish: + id: test_button + state: false + # Wait for single click trigger (30ms) + cooldown (100ms) + margin + - delay: 200ms + + # Test 2: Double click (ON < 50ms, OFF < 25ms, ON < 50ms, OFF >= 25ms) + - binary_sensor.template.publish: + id: test_button + state: true + - delay: 20ms + - binary_sensor.template.publish: + id: test_button + state: false + - delay: 15ms + - binary_sensor.template.publish: + id: test_button + state: true + - delay: 20ms + - binary_sensor.template.publish: + id: test_button + state: false + # Wait for double click trigger (25ms) + cooldown (100ms) + margin + - delay: 200ms + + # Test 3: Long press (ON >= 80ms) + - binary_sensor.template.publish: + id: test_button + state: true + - delay: 100ms + - binary_sensor.template.publish: + id: test_button + state: false + +logger: + level: VERBOSE + +globals: + - id: single_click_count + type: int + initial_value: "0" + - id: double_click_count + type: int + initial_value: "0" + - id: long_press_count + type: int + initial_value: "0" + +binary_sensor: + - platform: template + name: "Test Button" + id: test_button + on_multi_click: + # Single press + - timing: + - ON for at most 50ms + - OFF for at least 30ms + invalid_cooldown: 100ms + then: + - lambda: |- + id(single_click_count) += 1; + ESP_LOGI("multi_click_test", "SINGLE_CLICK count=%d", id(single_click_count)); + + # Double press + - timing: + - ON for at most 50ms + - OFF for at most 25ms + - ON for at most 50ms + - OFF for at least 25ms + invalid_cooldown: 100ms + then: + - lambda: |- + id(double_click_count) += 1; + ESP_LOGI("multi_click_test", "DOUBLE_CLICK count=%d", id(double_click_count)); + + # Long press + - timing: + - ON for at least 80ms + invalid_cooldown: 100ms + then: + - lambda: |- + id(long_press_count) += 1; + ESP_LOGI("multi_click_test", "LONG_PRESS count=%d", id(long_press_count)); diff --git a/tests/integration/test_multi_click_trigger.py b/tests/integration/test_multi_click_trigger.py new file mode 100644 index 0000000000..8a020dd18b --- /dev/null +++ b/tests/integration/test_multi_click_trigger.py @@ -0,0 +1,82 @@ +"""Integration test for on_multi_click binary sensor automation. + +Tests that on_multi_click correctly triggers for single click, double click, +and long press patterns using a template binary sensor with timing +orchestrated entirely in YAML. + +""" + +from __future__ import annotations + +import asyncio +import re + +import pytest + +from .types import APIClientConnectedFactory, RunCompiledFunction + + +@pytest.mark.asyncio +async def test_multi_click_trigger( + yaml_config: str, + run_compiled: RunCompiledFunction, + api_client_connected: APIClientConnectedFactory, +) -> None: + """Test that on_multi_click triggers for single, double, and long press patterns.""" + loop = asyncio.get_running_loop() + + single_click_pattern = re.compile(r"SINGLE_CLICK count=(\d+)") + double_click_pattern = re.compile(r"DOUBLE_CLICK count=(\d+)") + long_press_pattern = re.compile(r"LONG_PRESS count=(\d+)") + + single_click_future: asyncio.Future[int] = loop.create_future() + double_click_future: asyncio.Future[int] = loop.create_future() + long_press_future: asyncio.Future[int] = loop.create_future() + + def check_output(line: str) -> None: + """Check log output for multi-click trigger messages.""" + if m := single_click_pattern.search(line): + if not single_click_future.done(): + single_click_future.set_result(int(m.group(1))) + elif m := double_click_pattern.search(line): + if not double_click_future.done(): + double_click_future.set_result(int(m.group(1))) + elif (m := long_press_pattern.search(line)) and not long_press_future.done(): + long_press_future.set_result(int(m.group(1))) + + async with ( + run_compiled(yaml_config, line_callback=check_output), + api_client_connected() as client, + ): + _entities, services = await client.list_entities_services() + + test_service = next((s for s in services if s.name == "run_all_tests"), None) + assert test_service is not None, "run_all_tests service not found" + + # Kick off the entire test sequence (runs in YAML with delays) + await client.execute_service(test_service, {}) + + # Wait for all three triggers + try: + count = await asyncio.wait_for(single_click_future, timeout=5.0) + except TimeoutError: + pytest.fail( + "Timeout waiting for SINGLE_CLICK - on_multi_click did not trigger." + ) + assert count == 1, f"Expected single click count=1, got {count}" + + try: + count = await asyncio.wait_for(double_click_future, timeout=5.0) + except TimeoutError: + pytest.fail( + "Timeout waiting for DOUBLE_CLICK - on_multi_click did not trigger." + ) + assert count == 1, f"Expected double click count=1, got {count}" + + try: + count = await asyncio.wait_for(long_press_future, timeout=5.0) + except TimeoutError: + pytest.fail( + "Timeout waiting for LONG_PRESS - on_multi_click did not trigger." + ) + assert count == 1, f"Expected long press count=1, got {count}"