Files

Ignoring revisions in .git-blame-ignore-revs. Click here to bypass and see the normal blame view.

726 lines
24 KiB
Python
Raw Permalink Normal View History

2026-04-29 16:27:52 +02:00
"""Test timer triggers."""
2026-05-06 05:54:49 +02:00
from datetime import timedelta
import logging
2026-04-29 16:27:52 +02:00
from typing import Any
2026-05-06 05:54:49 +02:00
from freezegun.api import FrozenDateTimeFactory
2026-04-29 16:27:52 +02:00
import pytest
2026-05-06 05:54:49 +02:00
import voluptuous as vol
2026-04-29 16:27:52 +02:00
from homeassistant.components.timer import (
2026-05-06 05:54:49 +02:00
ATTR_FINISHES_AT,
2026-04-29 16:27:52 +02:00
ATTR_LAST_TRANSITION,
DOMAIN,
STATUS_ACTIVE,
STATUS_IDLE,
STATUS_PAUSED,
)
2026-05-06 05:54:49 +02:00
from homeassistant.const import (
ATTR_LABEL_ID,
CONF_ENTITY_ID,
CONF_OPTIONS,
CONF_PLATFORM,
CONF_TARGET,
)
from homeassistant.core import Context, HomeAssistant, callback
from homeassistant.helpers import entity_registry as er, label_registry as lr
from homeassistant.helpers.trigger import (
async_initialize_triggers,
async_validate_trigger_config,
)
from homeassistant.helpers.typing import TemplateVarsType
from homeassistant.util import dt as dt_util
2026-04-29 16:27:52 +02:00
2026-05-06 05:54:49 +02:00
from tests.common import async_fire_time_changed
2026-04-29 16:27:52 +02:00
from tests.components.common import (
TriggerStateDescription,
2026-05-27 16:01:11 +02:00
assert_trigger_behavior_all,
assert_trigger_behavior_each,
2026-04-29 16:27:52 +02:00
assert_trigger_behavior_first,
assert_trigger_options_supported,
parametrize_target_entities,
parametrize_trigger_states,
target_entities,
)
@pytest.fixture
async def target_timers(hass: HomeAssistant) -> dict[str, list[str]]:
"""Create multiple timer entities associated with different targets."""
return await target_entities(hass, DOMAIN)
@pytest.mark.parametrize(
("trigger_key", "base_options", "supports_behavior", "supports_duration"),
[
("timer.cancelled", {}, True, True),
("timer.finished", {}, True, True),
("timer.paused", {}, True, True),
("timer.restarted", {}, True, True),
("timer.started", {}, True, True),
("timer.remaining_time_reached", {"remaining": {"hours": 1}}, False, False),
2026-04-29 16:27:52 +02:00
],
)
async def test_timer_trigger_options_validation(
hass: HomeAssistant,
trigger_key: str,
base_options: dict[str, Any] | None,
supports_behavior: bool,
supports_duration: bool,
) -> None:
"""Test that timer triggers support the expected options."""
await assert_trigger_options_supported(
hass,
trigger_key,
base_options,
supports_behavior=supports_behavior,
supports_duration=supports_duration,
)
@pytest.mark.parametrize(
("trigger_target_config", "entity_id", "entities_in_target"),
parametrize_target_entities(DOMAIN),
)
@pytest.mark.parametrize(
("trigger", "trigger_options", "states"),
[
*parametrize_trigger_states(
trigger="timer.cancelled",
target_states=[(STATUS_IDLE, {ATTR_LAST_TRANSITION: "cancelled"})],
other_states=[(STATUS_ACTIVE, {ATTR_LAST_TRANSITION: "started"})],
),
*parametrize_trigger_states(
trigger="timer.finished",
target_states=[(STATUS_IDLE, {ATTR_LAST_TRANSITION: "finished"})],
other_states=[(STATUS_ACTIVE, {ATTR_LAST_TRANSITION: "started"})],
),
*parametrize_trigger_states(
trigger="timer.paused",
target_states=[(STATUS_PAUSED, {ATTR_LAST_TRANSITION: "paused"})],
other_states=[(STATUS_ACTIVE, {ATTR_LAST_TRANSITION: "started"})],
),
*parametrize_trigger_states(
trigger="timer.restarted",
target_states=[(STATUS_ACTIVE, {ATTR_LAST_TRANSITION: "restarted"})],
other_states=[(STATUS_ACTIVE, {ATTR_LAST_TRANSITION: "started"})],
),
*parametrize_trigger_states(
trigger="timer.started",
target_states=[(STATUS_ACTIVE, {ATTR_LAST_TRANSITION: "started"})],
other_states=[(STATUS_IDLE, {ATTR_LAST_TRANSITION: "cancelled"})],
),
],
)
2026-05-27 16:01:11 +02:00
async def test_timer_trigger_behavior_each(
2026-04-29 16:27:52 +02:00
hass: HomeAssistant,
target_timers: dict[str, list[str]],
trigger_target_config: dict[str, Any],
entity_id: str,
entities_in_target: int,
trigger: str,
trigger_options: dict[str, Any],
states: list[TriggerStateDescription],
) -> None:
"""Test timer trigger fires on any timer last_transition change."""
2026-05-27 16:01:11 +02:00
await assert_trigger_behavior_each(
2026-04-29 16:27:52 +02:00
hass,
target_entities=target_timers,
trigger_target_config=trigger_target_config,
entity_id=entity_id,
entities_in_target=entities_in_target,
trigger=trigger,
trigger_options=trigger_options,
states=states,
)
@pytest.mark.parametrize(
("trigger_target_config", "entity_id", "entities_in_target"),
parametrize_target_entities(DOMAIN),
)
@pytest.mark.parametrize(
("trigger", "trigger_options", "states"),
[
*parametrize_trigger_states(
trigger="timer.cancelled",
target_states=[(STATUS_IDLE, {ATTR_LAST_TRANSITION: "cancelled"})],
other_states=[(STATUS_ACTIVE, {ATTR_LAST_TRANSITION: "started"})],
),
*parametrize_trigger_states(
trigger="timer.finished",
target_states=[(STATUS_IDLE, {ATTR_LAST_TRANSITION: "finished"})],
other_states=[(STATUS_ACTIVE, {ATTR_LAST_TRANSITION: "started"})],
),
*parametrize_trigger_states(
trigger="timer.paused",
target_states=[(STATUS_PAUSED, {ATTR_LAST_TRANSITION: "paused"})],
other_states=[(STATUS_ACTIVE, {ATTR_LAST_TRANSITION: "started"})],
),
*parametrize_trigger_states(
trigger="timer.restarted",
target_states=[(STATUS_ACTIVE, {ATTR_LAST_TRANSITION: "restarted"})],
other_states=[(STATUS_ACTIVE, {ATTR_LAST_TRANSITION: "started"})],
),
*parametrize_trigger_states(
trigger="timer.started",
target_states=[(STATUS_ACTIVE, {ATTR_LAST_TRANSITION: "started"})],
other_states=[(STATUS_IDLE, {ATTR_LAST_TRANSITION: "cancelled"})],
),
],
)
async def test_timer_trigger_behavior_first(
hass: HomeAssistant,
target_timers: dict[str, list[str]],
trigger_target_config: dict[str, Any],
entity_id: str,
entities_in_target: int,
trigger: str,
trigger_options: dict[str, Any],
states: list[TriggerStateDescription],
) -> None:
"""Test timer trigger fires on first timer last_transition change."""
2026-04-29 16:27:52 +02:00
await assert_trigger_behavior_first(
hass,
target_entities=target_timers,
trigger_target_config=trigger_target_config,
entity_id=entity_id,
entities_in_target=entities_in_target,
trigger=trigger,
trigger_options=trigger_options,
states=states,
)
@pytest.mark.parametrize(
("trigger_target_config", "entity_id", "entities_in_target"),
parametrize_target_entities(DOMAIN),
)
@pytest.mark.parametrize(
("trigger", "trigger_options", "states"),
[
*parametrize_trigger_states(
trigger="timer.cancelled",
target_states=[(STATUS_IDLE, {ATTR_LAST_TRANSITION: "cancelled"})],
other_states=[(STATUS_ACTIVE, {ATTR_LAST_TRANSITION: "started"})],
),
*parametrize_trigger_states(
trigger="timer.finished",
target_states=[(STATUS_IDLE, {ATTR_LAST_TRANSITION: "finished"})],
other_states=[(STATUS_ACTIVE, {ATTR_LAST_TRANSITION: "started"})],
),
*parametrize_trigger_states(
trigger="timer.paused",
target_states=[(STATUS_PAUSED, {ATTR_LAST_TRANSITION: "paused"})],
other_states=[(STATUS_ACTIVE, {ATTR_LAST_TRANSITION: "started"})],
),
*parametrize_trigger_states(
trigger="timer.restarted",
target_states=[(STATUS_ACTIVE, {ATTR_LAST_TRANSITION: "restarted"})],
other_states=[(STATUS_ACTIVE, {ATTR_LAST_TRANSITION: "started"})],
),
*parametrize_trigger_states(
trigger="timer.started",
target_states=[(STATUS_ACTIVE, {ATTR_LAST_TRANSITION: "started"})],
other_states=[(STATUS_IDLE, {ATTR_LAST_TRANSITION: "cancelled"})],
),
],
)
2026-05-27 16:01:11 +02:00
async def test_timer_trigger_behavior_all(
2026-04-29 16:27:52 +02:00
hass: HomeAssistant,
target_timers: dict[str, list[str]],
trigger_target_config: dict[str, Any],
entity_id: str,
entities_in_target: int,
trigger: str,
trigger_options: dict[str, Any],
states: list[TriggerStateDescription],
) -> None:
2026-05-27 16:01:11 +02:00
"""Test timer trigger fires when all timers have transitioned."""
await assert_trigger_behavior_all(
2026-04-29 16:27:52 +02:00
hass,
target_entities=target_timers,
trigger_target_config=trigger_target_config,
entity_id=entity_id,
entities_in_target=entities_in_target,
trigger=trigger,
trigger_options=trigger_options,
states=states,
)
2026-05-06 05:54:49 +02:00
# --- time_remaining trigger tests ---
async def _arm_time_remaining_trigger(
hass: HomeAssistant,
entity_id: str,
remaining: dict[str, int],
calls: list[dict[str, Any]],
*,
target: dict[str, Any] | None = None,
) -> None:
"""Arm the time_remaining trigger."""
trigger_config = await async_validate_trigger_config(
hass,
[
{
CONF_PLATFORM: "timer.remaining_time_reached",
2026-05-06 05:54:49 +02:00
CONF_TARGET: target or {CONF_ENTITY_ID: entity_id},
CONF_OPTIONS: {"remaining": remaining},
}
],
)
@callback
def action(run_variables: TemplateVarsType, context: Context | None = None) -> None:
calls.append(run_variables["trigger"])
logger = logging.getLogger(__name__)
def log_cb(level: int, msg: str, **kwargs: Any) -> None:
logger._log(level, "%s", msg, **kwargs)
await async_initialize_triggers(
hass,
trigger_config,
action,
domain="test",
name="test_trigger",
log_cb=log_cb,
)
async def test_time_remaining_trigger_validation(hass: HomeAssistant) -> None:
"""Test time_remaining trigger config validation."""
# Valid config
await async_validate_trigger_config(
hass,
[
{
CONF_PLATFORM: "timer.remaining_time_reached",
2026-05-06 05:54:49 +02:00
CONF_TARGET: {CONF_ENTITY_ID: "timer.test"},
CONF_OPTIONS: {"remaining": {"seconds": 30}},
}
],
)
# Missing remaining option
with pytest.raises(vol.Invalid):
await async_validate_trigger_config(
hass,
[
{
CONF_PLATFORM: "timer.remaining_time_reached",
2026-05-06 05:54:49 +02:00
CONF_TARGET: {CONF_ENTITY_ID: "timer.test"},
CONF_OPTIONS: {},
}
],
)
async def test_time_remaining_trigger_fires(
hass: HomeAssistant,
freezer: FrozenDateTimeFactory,
) -> None:
"""Test time_remaining trigger fires at the right time."""
now = dt_util.utcnow()
calls: list[dict[str, Any]] = []
hass.states.async_set("timer.test", STATUS_IDLE, {ATTR_LAST_TRANSITION: None})
await hass.async_block_till_done()
await _arm_time_remaining_trigger(hass, "timer.test", {"seconds": 30}, calls)
# Start timer with 60 second duration
finishes_at = now + timedelta(seconds=60)
hass.states.async_set(
"timer.test",
STATUS_ACTIVE,
{ATTR_LAST_TRANSITION: "started", ATTR_FINISHES_AT: finishes_at.isoformat()},
)
await hass.async_block_till_done()
assert len(calls) == 0
# Advance to 25 seconds - 35 seconds remaining, should not fire
freezer.move_to(now + timedelta(seconds=25))
async_fire_time_changed(hass)
await hass.async_block_till_done()
assert len(calls) == 0
# Advance to 30 seconds - 30 seconds remaining, should fire
freezer.move_to(now + timedelta(seconds=30))
async_fire_time_changed(hass)
await hass.async_block_till_done()
assert len(calls) == 1
assert calls[0]["entity_id"] == "timer.test"
assert calls[0]["remaining"] == timedelta(seconds=30)
async def test_time_remaining_trigger_paused_before_threshold(
hass: HomeAssistant,
freezer: FrozenDateTimeFactory,
) -> None:
"""Test time_remaining trigger does not fire when timer is paused."""
2026-05-06 05:54:49 +02:00
now = dt_util.utcnow()
calls: list[dict[str, Any]] = []
hass.states.async_set("timer.test", STATUS_IDLE, {ATTR_LAST_TRANSITION: None})
await hass.async_block_till_done()
await _arm_time_remaining_trigger(hass, "timer.test", {"seconds": 30}, calls)
# Start timer with 60 second duration
finishes_at = now + timedelta(seconds=60)
hass.states.async_set(
"timer.test",
STATUS_ACTIVE,
{ATTR_LAST_TRANSITION: "started", ATTR_FINISHES_AT: finishes_at.isoformat()},
)
await hass.async_block_till_done()
# Pause timer at 10 seconds (before the 30-second threshold)
freezer.move_to(now + timedelta(seconds=10))
hass.states.async_set(
"timer.test",
STATUS_PAUSED,
{ATTR_LAST_TRANSITION: "paused"},
)
await hass.async_block_till_done()
# Advance past the original fire time - should not fire since paused
freezer.move_to(now + timedelta(seconds=35))
async_fire_time_changed(hass)
await hass.async_block_till_done()
assert len(calls) == 0
async def test_time_remaining_trigger_cancelled_before_threshold(
hass: HomeAssistant,
freezer: FrozenDateTimeFactory,
) -> None:
"""Test time_remaining trigger does not fire when timer is cancelled."""
2026-05-06 05:54:49 +02:00
now = dt_util.utcnow()
calls: list[dict[str, Any]] = []
hass.states.async_set("timer.test", STATUS_IDLE, {ATTR_LAST_TRANSITION: None})
await hass.async_block_till_done()
await _arm_time_remaining_trigger(hass, "timer.test", {"seconds": 30}, calls)
# Start timer with 60 second duration
finishes_at = now + timedelta(seconds=60)
hass.states.async_set(
"timer.test",
STATUS_ACTIVE,
{ATTR_LAST_TRANSITION: "started", ATTR_FINISHES_AT: finishes_at.isoformat()},
)
await hass.async_block_till_done()
# Cancel timer at 10 seconds
freezer.move_to(now + timedelta(seconds=10))
hass.states.async_set(
"timer.test",
STATUS_IDLE,
{ATTR_LAST_TRANSITION: "cancelled"},
)
await hass.async_block_till_done()
# Advance past the original fire time - should not fire since cancelled
freezer.move_to(now + timedelta(seconds=35))
async_fire_time_changed(hass)
await hass.async_block_till_done()
assert len(calls) == 0
async def test_time_remaining_trigger_restarted(
hass: HomeAssistant,
freezer: FrozenDateTimeFactory,
) -> None:
"""Test time_remaining trigger reschedules when timer is restarted."""
now = dt_util.utcnow()
calls: list[dict[str, Any]] = []
hass.states.async_set("timer.test", STATUS_IDLE, {ATTR_LAST_TRANSITION: None})
await hass.async_block_till_done()
await _arm_time_remaining_trigger(hass, "timer.test", {"seconds": 30}, calls)
# Start timer with 60 second duration
finishes_at = now + timedelta(seconds=60)
hass.states.async_set(
"timer.test",
STATUS_ACTIVE,
{ATTR_LAST_TRANSITION: "started", ATTR_FINISHES_AT: finishes_at.isoformat()},
)
await hass.async_block_till_done()
# Restart timer at 10 seconds with a new 60-second duration
freezer.move_to(now + timedelta(seconds=10))
new_finishes_at = now + timedelta(seconds=70) # 10s elapsed + 60s new
hass.states.async_set(
"timer.test",
STATUS_ACTIVE,
{
ATTR_LAST_TRANSITION: "restarted",
ATTR_FINISHES_AT: new_finishes_at.isoformat(),
},
)
await hass.async_block_till_done()
assert len(calls) == 0
# Original fire time (30s) should not fire since rescheduled
freezer.move_to(now + timedelta(seconds=30))
async_fire_time_changed(hass)
await hass.async_block_till_done()
assert len(calls) == 0
# New fire time: new_finishes_at - 30s = now + 40s
freezer.move_to(now + timedelta(seconds=40))
async_fire_time_changed(hass)
await hass.async_block_till_done()
assert len(calls) == 1
async def test_time_remaining_trigger_short_timer(
hass: HomeAssistant,
freezer: FrozenDateTimeFactory,
) -> None:
"""Test time_remaining trigger skips when duration < threshold."""
2026-05-06 05:54:49 +02:00
now = dt_util.utcnow()
calls: list[dict[str, Any]] = []
hass.states.async_set("timer.test", STATUS_IDLE, {ATTR_LAST_TRANSITION: None})
await hass.async_block_till_done()
await _arm_time_remaining_trigger(hass, "timer.test", {"seconds": 30}, calls)
# Start timer with only 20 second duration (less than 30s threshold)
finishes_at = now + timedelta(seconds=20)
hass.states.async_set(
"timer.test",
STATUS_ACTIVE,
{ATTR_LAST_TRANSITION: "started", ATTR_FINISHES_AT: finishes_at.isoformat()},
)
await hass.async_block_till_done()
# fire_at = now + 20 - 30 = now - 10 (in the past), should not schedule
# Advance past the timer's end time
freezer.move_to(now + timedelta(seconds=25))
async_fire_time_changed(hass)
await hass.async_block_till_done()
assert len(calls) == 0
async def test_time_remaining_trigger_already_active_at_attach(
hass: HomeAssistant,
freezer: FrozenDateTimeFactory,
) -> None:
"""Test trigger schedules for timers already active when the trigger attaches."""
now = dt_util.utcnow()
calls: list[dict[str, Any]] = []
# Timer is already active before the trigger is armed
finishes_at = now + timedelta(seconds=60)
hass.states.async_set(
"timer.test",
STATUS_ACTIVE,
{ATTR_LAST_TRANSITION: "started", ATTR_FINISHES_AT: finishes_at.isoformat()},
)
await hass.async_block_till_done()
await _arm_time_remaining_trigger(hass, "timer.test", {"seconds": 30}, calls)
# No fire yet
assert len(calls) == 0
# Before fire_at (finishes_at - 30s = now + 30s) — should not fire
freezer.move_to(now + timedelta(seconds=25))
async_fire_time_changed(hass)
await hass.async_block_till_done()
assert len(calls) == 0
# At fire_at — should fire even though no state change occurred
freezer.move_to(now + timedelta(seconds=30))
async_fire_time_changed(hass)
await hass.async_block_till_done()
assert len(calls) == 1
assert calls[0]["entity_id"] == "timer.test"
assert calls[0]["remaining"] == timedelta(seconds=30)
async def test_time_remaining_trigger_already_active_past_threshold_at_attach(
hass: HomeAssistant,
freezer: FrozenDateTimeFactory,
) -> None:
"""Test trigger ignores timers already past the fire point at attach."""
2026-05-06 05:54:49 +02:00
now = dt_util.utcnow()
calls: list[dict[str, Any]] = []
# Timer is active but only 20 seconds remain — past the 30s threshold already
finishes_at = now + timedelta(seconds=20)
hass.states.async_set(
"timer.test",
STATUS_ACTIVE,
{ATTR_LAST_TRANSITION: "started", ATTR_FINISHES_AT: finishes_at.isoformat()},
)
await hass.async_block_till_done()
await _arm_time_remaining_trigger(hass, "timer.test", {"seconds": 30}, calls)
# Advance past the timer's finishing time — should never fire
freezer.move_to(now + timedelta(seconds=25))
async_fire_time_changed(hass)
await hass.async_block_till_done()
assert len(calls) == 0
async def test_time_remaining_trigger_idle_at_attach(
hass: HomeAssistant,
freezer: FrozenDateTimeFactory,
) -> None:
"""Test trigger does not schedule for non-active timers at attach time."""
now = dt_util.utcnow()
calls: list[dict[str, Any]] = []
hass.states.async_set("timer.test", STATUS_IDLE, {ATTR_LAST_TRANSITION: None})
await hass.async_block_till_done()
await _arm_time_remaining_trigger(hass, "timer.test", {"seconds": 30}, calls)
# Even far in the future, no fire because timer never started
freezer.move_to(now + timedelta(seconds=120))
async_fire_time_changed(hass)
await hass.async_block_till_done()
assert len(calls) == 0
async def test_time_remaining_trigger_active_on_first_state_event(
hass: HomeAssistant,
freezer: FrozenDateTimeFactory,
) -> None:
"""Test trigger schedules when first observed state event has no from_state.
This simulates a timer entity that is created/restored after the trigger
is attached and appears directly in active state (e.g., RestoreEntity on
restart), where the initial state-change event has from_state=None.
"""
now = dt_util.utcnow()
calls: list[dict[str, Any]] = []
await _arm_time_remaining_trigger(hass, "timer.test", {"seconds": 30}, calls)
# First state event for the entity has no old_state
finishes_at = now + timedelta(seconds=60)
hass.states.async_set(
"timer.test",
STATUS_ACTIVE,
{ATTR_LAST_TRANSITION: "started", ATTR_FINISHES_AT: finishes_at.isoformat()},
)
await hass.async_block_till_done()
assert len(calls) == 0
# Advance to fire time — should still fire even though from_state was None
freezer.move_to(now + timedelta(seconds=30))
async_fire_time_changed(hass)
await hass.async_block_till_done()
assert len(calls) == 1
assert calls[0]["entity_id"] == "timer.test"
assert calls[0]["remaining"] == timedelta(seconds=30)
async def test_time_remaining_trigger_entity_removed_from_target(
hass: HomeAssistant,
freezer: FrozenDateTimeFactory,
entity_registry: er.EntityRegistry,
) -> None:
"""Test trigger cancels scheduled fire when entity is removed from the target."""
now = dt_util.utcnow()
calls: list[dict[str, Any]] = []
label_reg = lr.async_get(hass)
label = label_reg.async_create("Test Time Remaining")
entry = entity_registry.async_get_or_create(
domain=DOMAIN, platform="test", unique_id="time_remaining_remove"
)
entity_registry.async_update_entity(entry.entity_id, labels={label.label_id})
hass.states.async_set(entry.entity_id, STATUS_IDLE, {ATTR_LAST_TRANSITION: None})
await hass.async_block_till_done()
await _arm_time_remaining_trigger(
hass,
entry.entity_id,
{"seconds": 30},
calls,
target={ATTR_LABEL_ID: label.label_id},
)
# Start the timer — this schedules a fire via the state-change path
finishes_at = now + timedelta(seconds=60)
hass.states.async_set(
entry.entity_id,
STATUS_ACTIVE,
{ATTR_LAST_TRANSITION: "started", ATTR_FINISHES_AT: finishes_at.isoformat()},
)
await hass.async_block_till_done()
# Remove the entity from the target by stripping its label
freezer.move_to(now + timedelta(seconds=10))
entity_registry.async_update_entity(entry.entity_id, labels=set())
await hass.async_block_till_done()
# Advance past the original fire time — should not fire since cancelled
freezer.move_to(now + timedelta(seconds=35))
async_fire_time_changed(hass)
await hass.async_block_till_done()
assert len(calls) == 0
async def test_time_remaining_trigger_entity_added_to_target(
hass: HomeAssistant,
freezer: FrozenDateTimeFactory,
entity_registry: er.EntityRegistry,
) -> None:
"""Test trigger schedules a fire for an active timer added to the target later."""
now = dt_util.utcnow()
calls: list[dict[str, Any]] = []
label_reg = lr.async_get(hass)
label = label_reg.async_create("Test Time Remaining Add")
entry = entity_registry.async_get_or_create(
domain=DOMAIN, platform="test", unique_id="time_remaining_add"
)
# Timer is active, but not in the target yet
finishes_at = now + timedelta(seconds=60)
hass.states.async_set(
entry.entity_id,
STATUS_ACTIVE,
{ATTR_LAST_TRANSITION: "started", ATTR_FINISHES_AT: finishes_at.isoformat()},
)
await hass.async_block_till_done()
await _arm_time_remaining_trigger(
hass,
entry.entity_id,
{"seconds": 30},
calls,
target={ATTR_LABEL_ID: label.label_id},
)
# Now label the entity so it joins the target
entity_registry.async_update_entity(entry.entity_id, labels={label.label_id})
await hass.async_block_till_done()
# Advance to the fire time — should fire even though no state change occurred
freezer.move_to(now + timedelta(seconds=30))
async_fire_time_changed(hass)
await hass.async_block_till_done()
assert len(calls) == 1
assert calls[0]["entity_id"] == entry.entity_id
assert calls[0]["remaining"] == timedelta(seconds=30)