mirror of
https://github.com/basnijholt/adaptive-lighting.git
synced 2026-10-29 14:37:47 +01:00
297 lines
10 KiB
Python
297 lines
10 KiB
Python
"""Native-group scene errors behavior at HA and device boundaries."""
|
|
|
|
# pylint: disable=protected-access
|
|
import asyncio
|
|
|
|
import pytest
|
|
from homeassistant.components.adaptive_lighting.const import TURNING_OFF_DELAY
|
|
from homeassistant.components.light import (
|
|
ATTR_BRIGHTNESS,
|
|
ATTR_COLOR_TEMP_KELVIN,
|
|
)
|
|
from homeassistant.components.light.const import DATA_COMPONENT
|
|
from homeassistant.components.switch import (
|
|
DATA_COMPONENT as SWITCH_COMPONENT,
|
|
)
|
|
from homeassistant.components.switch import (
|
|
SwitchEntity,
|
|
)
|
|
from homeassistant.const import (
|
|
STATE_OFF,
|
|
STATE_ON,
|
|
)
|
|
from homeassistant.core import Context
|
|
from homeassistant.exceptions import HomeAssistantError
|
|
|
|
from .support.native_lights import GROUP, RecordingMember
|
|
from .support.native_scenarios import (
|
|
prepare_reported_members,
|
|
scene_state,
|
|
setup_reported_group,
|
|
setup_scene_group,
|
|
)
|
|
|
|
|
|
@pytest.mark.parametrize("outcome", ["failure", "cancellation"])
|
|
async def test_native_scene_original_failure_retires_transaction(hass, outcome):
|
|
"""Failure before dispatch cannot flush deferred colour or retain valid claims."""
|
|
setup = await setup_scene_group(hass)
|
|
planner = setup.switch.manager.native_groups
|
|
registered = hass.services.async_services()["scene"]["apply"]
|
|
entered = asyncio.Event()
|
|
transactions = []
|
|
failure = RuntimeError("scene handler failed")
|
|
|
|
async def original(_call):
|
|
transactions.append(planner.active.get())
|
|
entered.set()
|
|
if outcome == "failure":
|
|
raise failure
|
|
await asyncio.Event().wait()
|
|
|
|
hass.services.async_register("scene", "apply", original, registered.schema)
|
|
await hass.async_block_till_done()
|
|
task = asyncio.create_task(
|
|
hass.services.async_call(
|
|
"scene",
|
|
"apply",
|
|
{
|
|
"entities": {
|
|
member.entity_id: scene_state(3500, 100) for member in setup.members
|
|
},
|
|
},
|
|
blocking=True,
|
|
context=Context(),
|
|
),
|
|
)
|
|
await asyncio.wait_for(entered.wait(), 2)
|
|
if outcome == "failure":
|
|
with pytest.raises(RuntimeError, match="scene handler failed") as exc_info:
|
|
await task
|
|
assert exc_info.value is failure
|
|
else:
|
|
task.cancel()
|
|
with pytest.raises(asyncio.CancelledError):
|
|
await task
|
|
await hass.async_block_till_done()
|
|
|
|
assert len(transactions) == 1
|
|
assert transactions[0] is not None
|
|
assert transactions[0].execution.aborted
|
|
assert transactions[0].execution.closed
|
|
assert planner.active.get() is None
|
|
assert setup.commands == []
|
|
assert all(member.physical_kelvin == 4000 for member in setup.members)
|
|
for target in [GROUP, *(member.entity_id for member in setup.members)]:
|
|
claim = planner.take_activation_claim(target)
|
|
assert planner.activation_coverage(target, claim) == (False, False, False)
|
|
|
|
|
|
class BrokenSceneSwitch(SwitchEntity):
|
|
"""Expose a real non-light scene target that reports an original error."""
|
|
|
|
_attr_should_poll = False
|
|
_attr_is_on = False
|
|
_attr_name = "Broken scene review switch"
|
|
entity_id = "switch.broken_scene_review"
|
|
|
|
def __init__(self, error, before=None):
|
|
"""Retain the original exception and optional dispatch rendezvous."""
|
|
self.error = error
|
|
self.before = before
|
|
|
|
async def async_turn_on(self, **kwargs): # noqa: ARG002
|
|
"""Fail without changing the switch state."""
|
|
if self.before is not None:
|
|
await self.before()
|
|
raise self.error
|
|
|
|
|
|
def _desired_members(setup, brightness=None):
|
|
return {
|
|
member.entity_id: {
|
|
"state": STATE_ON,
|
|
ATTR_COLOR_TEMP_KELVIN: 3500,
|
|
**({ATTR_BRIGHTNESS: brightness} if brightness is not None else {}),
|
|
}
|
|
for member in setup.members
|
|
}
|
|
|
|
|
|
@pytest.mark.parametrize("planner_enabled", [False, True])
|
|
@pytest.mark.parametrize("failure_kind", ["light_off", "switch"])
|
|
async def test_healthy_scene_colour_survives_unrelated_failure(
|
|
hass,
|
|
monkeypatch,
|
|
planner_enabled,
|
|
failure_kind,
|
|
):
|
|
"""Ordinary scene errors preserve independent healthy colour and identity."""
|
|
setup = await setup_reported_group(hass)
|
|
await prepare_reported_members(hass, setup, on=(True, True, True))
|
|
setup.switch.manager.native_groups.disabled = not planner_enabled
|
|
error = HomeAssistantError("unrelated scene device unreachable")
|
|
if failure_kind == "light_off":
|
|
other = RecordingMember("light.other_scene_review", setup.commands)
|
|
other._attr_is_on = True
|
|
await hass.data[DATA_COMPONENT].async_add_entities([other])
|
|
|
|
async def broken(**kwargs):
|
|
raise error
|
|
|
|
monkeypatch.setattr(other, "async_turn_off", broken)
|
|
other_state = STATE_OFF
|
|
else:
|
|
other = BrokenSceneSwitch(error)
|
|
await hass.data[SWITCH_COMPONENT].async_add_entities([other])
|
|
other_state = STATE_ON
|
|
with pytest.raises(
|
|
HomeAssistantError,
|
|
match="unrelated scene device unreachable",
|
|
) as exc_info:
|
|
await hass.services.async_call(
|
|
"scene",
|
|
"apply",
|
|
{
|
|
"entities": {
|
|
**_desired_members(setup),
|
|
other.entity_id: {"state": other_state},
|
|
},
|
|
},
|
|
blocking=True,
|
|
context=Context(),
|
|
)
|
|
assert exc_info.value is error
|
|
await hass.async_block_till_done()
|
|
assert [member.physical_kelvin for member in setup.members] == [
|
|
3500,
|
|
] * 3, setup.commands
|
|
|
|
|
|
@pytest.mark.parametrize("cancel", [False, True])
|
|
async def test_partial_scene_error_settles_power_before_colour_or_cancellation(
|
|
hass,
|
|
monkeypatch,
|
|
cancel,
|
|
):
|
|
"""Parent errors wait for accepted power; cancellation never flushes colour."""
|
|
setup = await setup_reported_group(hass)
|
|
await prepare_reported_members(hass, setup, on=(False, False, False))
|
|
entered, release, failed = asyncio.Event(), asyncio.Event(), asyncio.Event()
|
|
error = HomeAssistantError("unrelated scene device unreachable")
|
|
|
|
async def hold_power():
|
|
entered.set()
|
|
await release.wait()
|
|
|
|
async def fail_after_power_starts():
|
|
await asyncio.wait_for(entered.wait(), 2)
|
|
|
|
setup.members[0].before_turn_on = hold_power
|
|
other = BrokenSceneSwitch(error, fail_after_power_starts)
|
|
await hass.data[SWITCH_COMPONENT].async_add_entities([other])
|
|
planner = setup.switch.manager.native_groups
|
|
original_flush = planner._flush
|
|
original_run = planner._run
|
|
flush_states = []
|
|
|
|
async def observe_flush(batch):
|
|
flush_states.append([member.is_on for member in setup.members])
|
|
await original_flush(batch)
|
|
|
|
async def observe_invocation_error(batch, invoke):
|
|
async def observed_invoke():
|
|
try:
|
|
return await invoke()
|
|
except Exception:
|
|
# Setting the event does not yield: _run receives the exception
|
|
# before this test resumes and inspects the held power task.
|
|
failed.set()
|
|
raise
|
|
|
|
return await original_run(batch, observed_invoke)
|
|
|
|
monkeypatch.setattr(planner, "_flush", observe_flush)
|
|
monkeypatch.setattr(planner, "_run", observe_invocation_error)
|
|
task = asyncio.create_task(
|
|
hass.services.async_call(
|
|
"scene",
|
|
"apply",
|
|
{
|
|
"entities": {
|
|
**_desired_members(setup, 100),
|
|
other.entity_id: {"state": STATE_ON},
|
|
},
|
|
},
|
|
blocking=True,
|
|
context=Context(),
|
|
),
|
|
)
|
|
try:
|
|
await asyncio.wait_for(failed.wait(), 2)
|
|
assert not task.done()
|
|
assert not flush_states
|
|
if cancel:
|
|
task.cancel()
|
|
with pytest.raises(asyncio.CancelledError):
|
|
await task
|
|
finally:
|
|
release.set()
|
|
if cancel:
|
|
await asyncio.gather(task, return_exceptions=True)
|
|
else:
|
|
with pytest.raises(HomeAssistantError) as exc_info:
|
|
await task
|
|
assert exc_info.value is error
|
|
await hass.async_block_till_done()
|
|
if cancel:
|
|
assert not flush_states
|
|
assert not any(
|
|
call.get(ATTR_COLOR_TEMP_KELVIN) == 3500 for call in setup.commands
|
|
)
|
|
else:
|
|
assert flush_states == [[True, True, True]]
|
|
assert [member.physical_kelvin for member in setup.members] == [
|
|
3500,
|
|
] * 3, setup.commands
|
|
|
|
|
|
async def test_partial_scene_error_preserves_successful_late_report_coverage(
|
|
hass,
|
|
freezer,
|
|
):
|
|
"""An unrelated error does not turn a late successful ON report into new work."""
|
|
setup = await setup_reported_group(hass)
|
|
await prepare_reported_members(hass, setup, on=(False, False, False))
|
|
member = setup.members[0]
|
|
member.hold_reports = True
|
|
error = HomeAssistantError("unrelated scene device unreachable")
|
|
other = BrokenSceneSwitch(error)
|
|
await hass.data[SWITCH_COMPONENT].async_add_entities([other])
|
|
with pytest.raises(HomeAssistantError) as exc_info:
|
|
await hass.services.async_call(
|
|
"scene",
|
|
"apply",
|
|
{
|
|
"entities": {
|
|
**_desired_members(setup, 100),
|
|
other.entity_id: {"state": STATE_ON},
|
|
},
|
|
},
|
|
blocking=True,
|
|
context=Context(),
|
|
)
|
|
assert exc_info.value is error
|
|
await hass.async_block_till_done()
|
|
assert member.physical_kelvin == 3500
|
|
assert hass.states.is_state(member.entity_id, STATE_OFF)
|
|
commands = list(setup.commands)
|
|
# The delayed report arrives after the recent-OFF polling guard. Advance the
|
|
# clock so the test exercises retained scene coverage without real sleeps.
|
|
freezer.tick(TURNING_OFF_DELAY + 1)
|
|
member.hold_reports = False
|
|
member.async_set_context(Context())
|
|
member.async_write_ha_state()
|
|
await hass.async_block_till_done()
|
|
assert hass.states.is_state(member.entity_id, STATE_ON)
|
|
assert setup.commands == commands
|