mirror of
https://github.com/basnijholt/adaptive-lighting.git
synced 2026-09-27 20:34:20 +02:00
Merge remote-tracking branch 'origin/main' into fix/group-target-resolution
# Conflicts: # custom_components/adaptive_lighting/strings.json # custom_components/adaptive_lighting/translations/de.json # custom_components/adaptive_lighting/translations/en.json # tests/test_switch.py
This commit is contained in:
commit
7284ebeaea
84 changed files with 7326 additions and 2037 deletions
|
|
@ -1,8 +1,16 @@
|
|||
# Developer notes for the tests directory
|
||||
|
||||
To run the tests, check out the [CI configuration](../.github/workflows/pytest.yml) to see how they are executed in the CI pipeline.
|
||||
To run the tests, check out the [CI configuration](../.github/workflows/pytest.yaml) to see how they are executed in the CI pipeline.
|
||||
Alternatively, you can use the provided Docker image to run the tests locally or run them with VS Code directly in the dev container.
|
||||
|
||||
## Coverage reports
|
||||
|
||||
Open a `pytest` workflow run in GitHub Actions to see line and branch coverage in each job's summary. Download its `coverage-<Home Assistant version>-py<Python version>` artifact for the XML and JSON reports and the browsable HTML report. After extracting it, open `htmlcov/index.html` to inspect missing lines and branches. Supported stable Home Assistant versions require at least 89% line coverage and 80% branch coverage. The `dev` job reports coverage without enforcing these floors.
|
||||
|
||||
Coverage measures executed code, not whether assertions would catch a bug. Add tests for observable behavior: emitted light commands, final states, manual-control events, and timer expiry. The integration suite runs inside Home Assistant with simulated lights; it does not establish physical-device behavior. It also does not execute every documentation generator included in the package's coverage total.
|
||||
|
||||
The tests in `test_automation_examples.py` load YAML directly from `README.md` and execute it through Home Assistant's automation and script engines. Edit the README source when changing those examples, then run `./scripts/update-generated-content` to update the documentation pages.
|
||||
|
||||
## Prerequisites
|
||||
|
||||
Before running tests with Docker, you need a local Home Assistant core checkout with symlinks:
|
||||
|
|
|
|||
|
|
@ -95,14 +95,82 @@ async def test_split_service_call_data(input_data, expected_data_list):
|
|||
),
|
||||
(
|
||||
{ATTR_ENTITY_ID: "light.test", ATTR_BRIGHTNESS: 10, ATTR_TRANSITION: 2},
|
||||
State("light.test", STATE_ON, {ATTR_BRIGHTNESS: 11}),
|
||||
State("light.test", STATE_ON, {ATTR_BRIGHTNESS: 13}),
|
||||
{ATTR_ENTITY_ID: "light.test", ATTR_BRIGHTNESS: 10, ATTR_TRANSITION: 2},
|
||||
),
|
||||
(
|
||||
{ATTR_ENTITY_ID: "light.test", ATTR_BRIGHTNESS: 230, ATTR_TRANSITION: 2},
|
||||
State("light.test", STATE_ON, {ATTR_BRIGHTNESS: 229}),
|
||||
{ATTR_ENTITY_ID: "light.test", ATTR_TRANSITION: 2},
|
||||
),
|
||||
(
|
||||
{ATTR_ENTITY_ID: "light.test", ATTR_BRIGHTNESS: 230, ATTR_TRANSITION: 2},
|
||||
State("light.test", STATE_ON, {ATTR_BRIGHTNESS: 227}),
|
||||
{ATTR_ENTITY_ID: "light.test", ATTR_BRIGHTNESS: 230, ATTR_TRANSITION: 2},
|
||||
),
|
||||
(
|
||||
{
|
||||
ATTR_ENTITY_ID: "light.test",
|
||||
ATTR_COLOR_TEMP_KELVIN: 5500,
|
||||
ATTR_TRANSITION: 2,
|
||||
},
|
||||
State("light.test", STATE_ON, {ATTR_COLOR_TEMP_KELVIN: 5495}),
|
||||
{ATTR_ENTITY_ID: "light.test", ATTR_TRANSITION: 2},
|
||||
),
|
||||
(
|
||||
{
|
||||
ATTR_ENTITY_ID: "light.test",
|
||||
ATTR_COLOR_TEMP_KELVIN: 5500,
|
||||
ATTR_TRANSITION: 2,
|
||||
},
|
||||
State("light.test", STATE_ON, {ATTR_COLOR_TEMP_KELVIN: 5524}),
|
||||
{ATTR_ENTITY_ID: "light.test", ATTR_TRANSITION: 2},
|
||||
),
|
||||
(
|
||||
{
|
||||
ATTR_ENTITY_ID: "light.test",
|
||||
ATTR_COLOR_TEMP_KELVIN: 6500,
|
||||
ATTR_TRANSITION: 2,
|
||||
},
|
||||
State("light.test", STATE_ON, {ATTR_COLOR_TEMP_KELVIN: 6494}),
|
||||
{ATTR_ENTITY_ID: "light.test", ATTR_TRANSITION: 2},
|
||||
),
|
||||
(
|
||||
{
|
||||
ATTR_ENTITY_ID: "light.test",
|
||||
ATTR_COLOR_TEMP_KELVIN: 5500,
|
||||
ATTR_TRANSITION: 2,
|
||||
},
|
||||
State("light.test", STATE_ON, {ATTR_COLOR_TEMP_KELVIN: 5400}),
|
||||
{
|
||||
ATTR_ENTITY_ID: "light.test",
|
||||
ATTR_COLOR_TEMP_KELVIN: 5500,
|
||||
ATTR_TRANSITION: 2,
|
||||
},
|
||||
),
|
||||
(
|
||||
{ATTR_ENTITY_ID: "light.test", ATTR_HS_COLOR: (30.0, 40.0)},
|
||||
State("light.test", STATE_ON, {ATTR_HS_COLOR: (30.0, 40.0)}),
|
||||
{ATTR_ENTITY_ID: "light.test"},
|
||||
),
|
||||
(
|
||||
{ATTR_ENTITY_ID: "light.test", ATTR_BRIGHTNESS: 10},
|
||||
State("light.test", STATE_ON, {ATTR_BRIGHTNESS: None}),
|
||||
{ATTR_ENTITY_ID: "light.test", ATTR_BRIGHTNESS: 10},
|
||||
),
|
||||
],
|
||||
ids=[
|
||||
"pass all attributes on empty state",
|
||||
"remove attributes whose values equal the state",
|
||||
"keep attributes whose values differ from the state",
|
||||
"remove brightness within quantization tolerance (0-99 device scale)",
|
||||
"keep brightness outside quantization tolerance",
|
||||
"remove color temp within one mired (round-converting integration)",
|
||||
"remove color temp within one mired (floor-converting HA core helpers)",
|
||||
"remove color temp within one mired (6500 K)",
|
||||
"keep color temp more than one mired away",
|
||||
"remove non-numeric attributes on exact equality",
|
||||
"keep attribute when state value is None",
|
||||
],
|
||||
)
|
||||
async def test_remove_redundant_attributes(
|
||||
|
|
@ -167,18 +235,18 @@ async def test_has_relevant_service_data_attributes(
|
|||
[],
|
||||
),
|
||||
(
|
||||
[{ATTR_ENTITY_ID: "light.test", ATTR_BRIGHTNESS: 11}],
|
||||
[{ATTR_ENTITY_ID: "light.test", ATTR_BRIGHTNESS: 15}],
|
||||
True,
|
||||
[{ATTR_ENTITY_ID: "light.test", ATTR_BRIGHTNESS: 11}],
|
||||
[{ATTR_ENTITY_ID: "light.test", ATTR_BRIGHTNESS: 15}],
|
||||
),
|
||||
(
|
||||
[
|
||||
{ATTR_ENTITY_ID: "light.test", ATTR_BRIGHTNESS: 11},
|
||||
{ATTR_ENTITY_ID: "light.test", ATTR_BRIGHTNESS: 15},
|
||||
{ATTR_ENTITY_ID: "light.test", ATTR_BRIGHTNESS: 22},
|
||||
],
|
||||
True,
|
||||
[
|
||||
{ATTR_ENTITY_ID: "light.test", ATTR_BRIGHTNESS: 11},
|
||||
{ATTR_ENTITY_ID: "light.test", ATTR_BRIGHTNESS: 15},
|
||||
{ATTR_ENTITY_ID: "light.test", ATTR_BRIGHTNESS: 22},
|
||||
],
|
||||
),
|
||||
|
|
@ -192,6 +260,11 @@ async def test_has_relevant_service_data_attributes(
|
|||
{ATTR_ENTITY_ID: "light.test", ATTR_BRIGHTNESS: 22},
|
||||
],
|
||||
),
|
||||
(
|
||||
[{ATTR_ENTITY_ID: "light.test", ATTR_BRIGHTNESS: 11}],
|
||||
True,
|
||||
[],
|
||||
),
|
||||
],
|
||||
ids=[
|
||||
"single item passed through without filtering",
|
||||
|
|
@ -201,6 +274,7 @@ async def test_has_relevant_service_data_attributes(
|
|||
"filter keeps item with relevant attribute that is different from state",
|
||||
"filter keeps two items with relevant attributes that are different from state",
|
||||
"filter removes item that equals state and keeps items that differs from state",
|
||||
"filter removes item with relevant attribute within tolerance of the state",
|
||||
],
|
||||
)
|
||||
async def test_create_service_call_data_iterator(
|
||||
|
|
@ -480,3 +554,50 @@ def test_get_light_control_attributes(
|
|||
):
|
||||
"""Test determination of light control attributes."""
|
||||
assert get_light_control_attributes(service_data) == expected_flags
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("already_applied", "expected"),
|
||||
[
|
||||
(
|
||||
LightControlAttributes.BRIGHTNESS,
|
||||
[
|
||||
{
|
||||
ATTR_ENTITY_ID: "light.test",
|
||||
ATTR_COLOR_TEMP_KELVIN: 3448,
|
||||
ATTR_TRANSITION: 1,
|
||||
},
|
||||
],
|
||||
),
|
||||
(
|
||||
LightControlAttributes.COLOR,
|
||||
[{ATTR_ENTITY_ID: "light.test", ATTR_BRIGHTNESS: 171, ATTR_TRANSITION: 1}],
|
||||
),
|
||||
(LightControlAttributes.ALL, []),
|
||||
],
|
||||
)
|
||||
async def test_remaining_split_commands_preserve_transition(
|
||||
hass,
|
||||
already_applied,
|
||||
expected,
|
||||
):
|
||||
"""Removing the shared command must not redistribute its transition time."""
|
||||
data = prepare_adaptation_data(
|
||||
hass,
|
||||
"light.test",
|
||||
Context(),
|
||||
transition=2,
|
||||
split_delay=0.1,
|
||||
service_data={
|
||||
ATTR_ENTITY_ID: "light.test",
|
||||
ATTR_BRIGHTNESS: 171,
|
||||
ATTR_COLOR_TEMP_KELVIN: 3448,
|
||||
ATTR_TRANSITION: 2,
|
||||
},
|
||||
split=True,
|
||||
filter_by_state=False,
|
||||
force=False,
|
||||
already_applied=already_applied,
|
||||
)
|
||||
assert [command async for command in data.service_call_datas] == expected
|
||||
assert data.sleep_time == 1.1
|
||||
|
|
|
|||
691
tests/test_automation_examples.py
Normal file
691
tests/test_automation_examples.py
Normal file
|
|
@ -0,0 +1,691 @@
|
|||
"""Execute the automation examples published in README.md."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import re
|
||||
from datetime import UTC, datetime, timedelta
|
||||
from pathlib import Path
|
||||
from typing import TYPE_CHECKING
|
||||
from unittest.mock import patch
|
||||
|
||||
import pytest
|
||||
import yaml
|
||||
from homeassistant.components import automation, script
|
||||
from homeassistant.components.adaptive_lighting.adaptation_utils import (
|
||||
LightControlAttributes,
|
||||
)
|
||||
from homeassistant.components.adaptive_lighting.const import (
|
||||
CONF_INITIAL_TRANSITION,
|
||||
CONF_LIGHTS,
|
||||
CONF_MAX_BRIGHTNESS,
|
||||
CONF_MAX_COLOR_TEMP,
|
||||
CONF_MIN_BRIGHTNESS,
|
||||
CONF_MIN_COLOR_TEMP,
|
||||
CONF_NAME,
|
||||
CONF_SLEEP_BRIGHTNESS,
|
||||
CONF_SLEEP_COLOR_TEMP,
|
||||
CONF_SLEEP_RGB_OR_COLOR_TEMP,
|
||||
CONF_SUNRISE_TIME,
|
||||
CONF_SUNSET_TIME,
|
||||
CONF_TRANSITION,
|
||||
DOMAIN,
|
||||
)
|
||||
from homeassistant.components.light import (
|
||||
ATTR_BRIGHTNESS,
|
||||
ATTR_COLOR_TEMP_KELVIN,
|
||||
ATTR_RGB_COLOR,
|
||||
)
|
||||
from homeassistant.components.light import (
|
||||
DOMAIN as LIGHT_DOMAIN,
|
||||
)
|
||||
from homeassistant.components.switch import DOMAIN as SWITCH_DOMAIN
|
||||
from homeassistant.const import (
|
||||
ATTR_ENTITY_ID,
|
||||
EVENT_CALL_SERVICE,
|
||||
EVENT_STATE_CHANGED,
|
||||
SERVICE_TURN_OFF,
|
||||
SERVICE_TURN_ON,
|
||||
STATE_OFF,
|
||||
STATE_ON,
|
||||
)
|
||||
from homeassistant.core import CoreState, Event, HomeAssistant, State, callback
|
||||
from homeassistant.setup import async_setup_component
|
||||
from homeassistant.util import dt as dt_util
|
||||
|
||||
from tests.common import async_fire_time_changed
|
||||
|
||||
from .test_switch import setup_switch
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from collections.abc import Callable
|
||||
|
||||
README = Path(__file__).resolve().parents[1] / "README.md"
|
||||
|
||||
|
||||
def _yaml_documents(summary: str) -> list[object]:
|
||||
"""Load every YAML fence from one README details block."""
|
||||
readme = README.read_text()
|
||||
details = re.search(
|
||||
rf"<summary>{re.escape(summary)}</summary>(.*?)</details>",
|
||||
readme,
|
||||
flags=re.DOTALL,
|
||||
)
|
||||
assert details is not None, f"README example not found: {summary}"
|
||||
blocks = re.findall(r"```yaml\n(.*?)```", details.group(1), flags=re.DOTALL)
|
||||
assert blocks, f"README example has no YAML: {summary}"
|
||||
return [yaml.safe_load(block) for block in blocks]
|
||||
|
||||
|
||||
async def _setup_automation(hass: HomeAssistant, config: object) -> None:
|
||||
"""Load an extracted automation through Home Assistant."""
|
||||
assert await async_setup_component(
|
||||
hass,
|
||||
automation.DOMAIN,
|
||||
{automation.DOMAIN: config},
|
||||
)
|
||||
await hass.async_block_till_done()
|
||||
|
||||
|
||||
async def _setup_script(hass: HomeAssistant, config: dict) -> None:
|
||||
"""Load an extracted script through Home Assistant."""
|
||||
assert await async_setup_component(hass, script.DOMAIN, config)
|
||||
await hass.async_block_till_done()
|
||||
|
||||
|
||||
async def _setup_template_lights(
|
||||
hass: HomeAssistant,
|
||||
names: list[str],
|
||||
) -> None:
|
||||
"""Create color-temperature and RGB lights with documented entity IDs."""
|
||||
lights = [
|
||||
{
|
||||
"name": name,
|
||||
"unique_id": name.lower().replace(" ", "_"),
|
||||
"turn_on": None,
|
||||
"turn_off": None,
|
||||
"set_level": None,
|
||||
"set_temperature": None,
|
||||
"set_rgb": None,
|
||||
}
|
||||
for name in names
|
||||
]
|
||||
assert await async_setup_component(
|
||||
hass,
|
||||
"template",
|
||||
{"template": {"light": lights}},
|
||||
)
|
||||
await hass.async_block_till_done()
|
||||
|
||||
|
||||
def _state_waiter(
|
||||
hass: HomeAssistant,
|
||||
entity_id: str,
|
||||
predicate: Callable[[State], bool],
|
||||
) -> tuple[asyncio.Future[State], Callable[[], None]]:
|
||||
"""Return a future that resolves when an entity state matches a predicate."""
|
||||
future = hass.loop.create_future()
|
||||
|
||||
@callback
|
||||
def state_changed(event: Event) -> None:
|
||||
new_state = event.data.get("new_state")
|
||||
if (
|
||||
not future.done()
|
||||
and new_state is not None
|
||||
and new_state.entity_id == entity_id
|
||||
and predicate(new_state)
|
||||
):
|
||||
future.set_result(new_state)
|
||||
|
||||
remove_listener = hass.bus.async_listen(EVENT_STATE_CHANGED, state_changed)
|
||||
return future, remove_listener
|
||||
|
||||
|
||||
def _prepare_hass_startup(hass: HomeAssistant) -> None:
|
||||
"""Reset the standard running test fixture to exercise a real HA start."""
|
||||
hass.set_state(CoreState.not_running)
|
||||
|
||||
|
||||
async def test_schedule_profile_executes_blocks_and_restore(
|
||||
hass: HomeAssistant,
|
||||
) -> None:
|
||||
"""Catch ignored attribute changes, incomplete restore, or switch coupling."""
|
||||
summary = "Use a Schedule helper as a step-based custom lighting profile."
|
||||
automation_config = _yaml_documents(summary)[-1]
|
||||
_, adaptive_switch = await setup_switch(
|
||||
hass,
|
||||
{
|
||||
CONF_NAME: "Living Room",
|
||||
CONF_LIGHTS: ["light.manually_controlled"],
|
||||
CONF_MIN_BRIGHTNESS: 8,
|
||||
CONF_MAX_BRIGHTNESS: 88,
|
||||
CONF_MIN_COLOR_TEMP: 2100,
|
||||
CONF_MAX_COLOR_TEMP: 5100,
|
||||
},
|
||||
)
|
||||
adaptive_switch.manager.set_manual_control_attributes(
|
||||
"light.manually_controlled",
|
||||
LightControlAttributes.BRIGHTNESS,
|
||||
)
|
||||
hass.states.async_set(
|
||||
"schedule.adaptive_lighting_profile",
|
||||
STATE_OFF,
|
||||
)
|
||||
await _setup_automation(hass, automation_config)
|
||||
|
||||
hass.states.async_set(
|
||||
"schedule.adaptive_lighting_profile",
|
||||
STATE_ON,
|
||||
{"brightness_pct": 20, "color_temp_kelvin": 2500},
|
||||
)
|
||||
await hass.async_block_till_done()
|
||||
assert adaptive_switch._sun_light_settings.min_brightness == 20
|
||||
assert adaptive_switch._sun_light_settings.max_brightness == 20
|
||||
assert adaptive_switch._sun_light_settings.min_color_temp == 2500
|
||||
assert adaptive_switch._sun_light_settings.max_color_temp == 2500
|
||||
|
||||
hass.states.async_set(
|
||||
"schedule.adaptive_lighting_profile",
|
||||
STATE_ON,
|
||||
{"brightness_pct": 60, "color_temp_kelvin": 4000},
|
||||
)
|
||||
await hass.async_block_till_done()
|
||||
assert adaptive_switch._sun_light_settings.min_brightness == 60
|
||||
assert adaptive_switch._sun_light_settings.max_brightness == 60
|
||||
assert adaptive_switch._sun_light_settings.min_color_temp == 4000
|
||||
assert adaptive_switch._sun_light_settings.max_color_temp == 4000
|
||||
|
||||
hass.states.async_set("schedule.adaptive_lighting_profile", STATE_OFF)
|
||||
await hass.async_block_till_done()
|
||||
assert adaptive_switch._sun_light_settings.min_brightness == 8
|
||||
assert adaptive_switch._sun_light_settings.max_brightness == 88
|
||||
assert adaptive_switch._sun_light_settings.min_color_temp == 2100
|
||||
assert adaptive_switch._sun_light_settings.max_color_temp == 5100
|
||||
assert adaptive_switch.manager.manual_control["light.manually_controlled"]
|
||||
|
||||
await hass.services.async_call(
|
||||
SWITCH_DOMAIN,
|
||||
SERVICE_TURN_OFF,
|
||||
{ATTR_ENTITY_ID: adaptive_switch.entity_id},
|
||||
blocking=True,
|
||||
)
|
||||
hass.states.async_set(
|
||||
"schedule.adaptive_lighting_profile",
|
||||
STATE_ON,
|
||||
{"brightness_pct": 35, "color_temp_kelvin": 2750},
|
||||
)
|
||||
await hass.async_block_till_done()
|
||||
assert adaptive_switch.is_on is False
|
||||
assert adaptive_switch._sun_light_settings.min_brightness == 35
|
||||
assert adaptive_switch._sun_light_settings.max_color_temp == 2750
|
||||
|
||||
|
||||
async def test_schedule_profile_reapplies_at_startup(hass: HomeAssistant) -> None:
|
||||
"""Verify startup applies the already-active schedule block."""
|
||||
_prepare_hass_startup(hass)
|
||||
summary = "Use a Schedule helper as a step-based custom lighting profile."
|
||||
automation_config = _yaml_documents(summary)[-1]
|
||||
_, adaptive_switch = await setup_switch(hass, {CONF_NAME: "Living Room"})
|
||||
hass.states.async_set(
|
||||
"schedule.adaptive_lighting_profile",
|
||||
STATE_ON,
|
||||
{"brightness_pct": 20, "color_temp_kelvin": 2500},
|
||||
)
|
||||
await _setup_automation(hass, automation_config)
|
||||
|
||||
await hass.async_start()
|
||||
await hass.async_block_till_done()
|
||||
assert adaptive_switch._sun_light_settings.min_brightness == 20
|
||||
assert adaptive_switch._sun_light_settings.max_brightness == 20
|
||||
assert adaptive_switch._sun_light_settings.min_color_temp == 2500
|
||||
assert adaptive_switch._sun_light_settings.max_color_temp == 2500
|
||||
|
||||
|
||||
async def test_lux_profile_executes_hysteresis(hass: HomeAssistant) -> None:
|
||||
"""Catch missing threshold actions or changes inside the dead band."""
|
||||
summary = (
|
||||
"Reduce daytime brightness when an illuminance sensor detects strong daylight."
|
||||
)
|
||||
automation_config = _yaml_documents(summary)[0]
|
||||
_, adaptive_switch = await setup_switch(
|
||||
hass,
|
||||
{CONF_NAME: "Living Room", CONF_MAX_BRIGHTNESS: 80},
|
||||
)
|
||||
hass.states.async_set("sensor.living_room_illuminance", "250")
|
||||
await _setup_automation(hass, automation_config)
|
||||
|
||||
hass.states.async_set("sensor.living_room_illuminance", "350")
|
||||
await hass.async_block_till_done()
|
||||
assert adaptive_switch._sun_light_settings.max_brightness == 30
|
||||
|
||||
hass.states.async_set("sensor.living_room_illuminance", "250")
|
||||
await hass.async_block_till_done()
|
||||
assert adaptive_switch._sun_light_settings.max_brightness == 30
|
||||
|
||||
hass.states.async_set("sensor.living_room_illuminance", "150")
|
||||
await hass.async_block_till_done()
|
||||
assert adaptive_switch._sun_light_settings.max_brightness == 100
|
||||
|
||||
hass.states.async_set("sensor.living_room_illuminance", "250")
|
||||
await hass.async_block_till_done()
|
||||
assert adaptive_switch._sun_light_settings.max_brightness == 100
|
||||
|
||||
|
||||
async def test_lux_profile_executes_unknown_recovery_at_startup(
|
||||
hass: HomeAssistant,
|
||||
) -> None:
|
||||
"""Catch a startup hang or failure to recover from an unknown sensor."""
|
||||
_prepare_hass_startup(hass)
|
||||
summary = (
|
||||
"Reduce daytime brightness when an illuminance sensor detects strong daylight."
|
||||
)
|
||||
automation_config = _yaml_documents(summary)[0]
|
||||
_, adaptive_switch = await setup_switch(
|
||||
hass,
|
||||
{CONF_NAME: "Living Room", CONF_MAX_BRIGHTNESS: 80},
|
||||
)
|
||||
hass.states.async_set("sensor.living_room_illuminance", "unknown")
|
||||
await _setup_automation(hass, automation_config)
|
||||
waiting, remove_listener = _state_waiter(
|
||||
hass,
|
||||
"automation.adaptive_lighting_limit_brightness_in_daylight",
|
||||
lambda state: state.attributes.get("current") == 1,
|
||||
)
|
||||
|
||||
await hass.async_start()
|
||||
await asyncio.wait_for(waiting, timeout=1)
|
||||
remove_listener()
|
||||
assert adaptive_switch._sun_light_settings.max_brightness == 80
|
||||
|
||||
hass.states.async_set("sensor.living_room_illuminance", "350")
|
||||
await hass.async_block_till_done()
|
||||
assert adaptive_switch._sun_light_settings.max_brightness == 30
|
||||
|
||||
|
||||
async def test_hue_script_applies_current_values_to_fresh_profile_targets(
|
||||
hass: HomeAssistant,
|
||||
) -> None:
|
||||
"""Catch an invalid script wrapper or a one-shot apply that skips off lights."""
|
||||
summary = "Turn on Hue-controlled lights with the current Adaptive Lighting values."
|
||||
script_config = _yaml_documents(summary)[0]
|
||||
await _setup_template_lights(
|
||||
hass,
|
||||
["Living Room Ceiling", "Living Room Table"],
|
||||
)
|
||||
_, adaptive_switch = await setup_switch(
|
||||
hass,
|
||||
{
|
||||
CONF_NAME: "Living Room",
|
||||
CONF_LIGHTS: [
|
||||
"light.living_room_ceiling",
|
||||
"light.living_room_table",
|
||||
],
|
||||
CONF_SUNRISE_TIME: "06:00:00",
|
||||
CONF_SUNSET_TIME: "18:00:00",
|
||||
CONF_MIN_BRIGHTNESS: 10,
|
||||
CONF_MAX_BRIGHTNESS: 80,
|
||||
CONF_MIN_COLOR_TEMP: 2000,
|
||||
CONF_MAX_COLOR_TEMP: 5000,
|
||||
CONF_INITIAL_TRANSITION: 0,
|
||||
CONF_TRANSITION: 0,
|
||||
},
|
||||
)
|
||||
await hass.services.async_call(
|
||||
SWITCH_DOMAIN,
|
||||
SERVICE_TURN_OFF,
|
||||
{ATTR_ENTITY_ID: adaptive_switch.entity_id},
|
||||
blocking=True,
|
||||
)
|
||||
adaptive_switch.manager.set_manual_control_attributes(
|
||||
"light.living_room_ceiling",
|
||||
LightControlAttributes.BRIGHTNESS | LightControlAttributes.COLOR,
|
||||
)
|
||||
await _setup_script(hass, script_config)
|
||||
|
||||
noon = datetime(2026, 9, 6, 12, tzinfo=dt_util.DEFAULT_TIME_ZONE).astimezone(UTC)
|
||||
with patch(
|
||||
"homeassistant.components.adaptive_lighting.color_and_brightness.utcnow",
|
||||
return_value=noon,
|
||||
):
|
||||
await hass.services.async_call(
|
||||
script.DOMAIN,
|
||||
"living_room_adaptive_lighting",
|
||||
blocking=True,
|
||||
)
|
||||
await hass.async_block_till_done()
|
||||
|
||||
for entity_id in ("light.living_room_ceiling", "light.living_room_table"):
|
||||
state = hass.states.get(entity_id)
|
||||
assert state is not None
|
||||
assert state.state == STATE_ON
|
||||
assert state.attributes[ATTR_BRIGHTNESS] == 204
|
||||
assert state.attributes[ATTR_COLOR_TEMP_KELVIN] == 5000
|
||||
assert adaptive_switch.is_on is False
|
||||
assert adaptive_switch.manager.manual_control["light.living_room_ceiling"]
|
||||
|
||||
|
||||
async def test_rgb_bedtime_script_applies_stage_then_restores_configuration(
|
||||
hass: HomeAssistant,
|
||||
) -> None:
|
||||
"""Catch a wrong fresh child ID, delayed first stage, or missing restoration."""
|
||||
summary = "Use a fixed RGB stage before sleep mode."
|
||||
script_config = _yaml_documents(summary)[0]
|
||||
await _setup_template_lights(hass, ["Bedroom"])
|
||||
_, adaptive_switch = await setup_switch(
|
||||
hass,
|
||||
{
|
||||
CONF_NAME: "Bedroom",
|
||||
CONF_LIGHTS: ["light.bedroom"],
|
||||
CONF_SLEEP_BRIGHTNESS: 7,
|
||||
CONF_SLEEP_COLOR_TEMP: 2300,
|
||||
CONF_SLEEP_RGB_OR_COLOR_TEMP: "color_temp",
|
||||
CONF_INITIAL_TRANSITION: 0,
|
||||
CONF_TRANSITION: 0,
|
||||
},
|
||||
)
|
||||
await hass.services.async_call(
|
||||
LIGHT_DOMAIN,
|
||||
SERVICE_TURN_ON,
|
||||
{ATTR_ENTITY_ID: "light.bedroom"},
|
||||
blocking=True,
|
||||
)
|
||||
adaptive_switch.manager.set_manual_control_attributes(
|
||||
"light.bedroom",
|
||||
LightControlAttributes.ALL,
|
||||
)
|
||||
await _setup_script(hass, script_config)
|
||||
light_staged, remove_light_listener = _state_waiter(
|
||||
hass,
|
||||
"light.bedroom",
|
||||
lambda state: (
|
||||
state.attributes.get(ATTR_BRIGHTNESS) == 51
|
||||
and state.attributes.get(ATTR_RGB_COLOR) == (255, 56, 0)
|
||||
),
|
||||
)
|
||||
sleep_enabled, remove_sleep_listener = _state_waiter(
|
||||
hass,
|
||||
"switch.adaptive_lighting_bedroom_sleep_mode",
|
||||
lambda state: state.state == STATE_ON,
|
||||
)
|
||||
|
||||
await hass.services.async_call(
|
||||
script.DOMAIN,
|
||||
"adaptive_lighting_bedtime",
|
||||
blocking=False,
|
||||
)
|
||||
await asyncio.wait_for(asyncio.gather(light_staged, sleep_enabled), timeout=1)
|
||||
remove_light_listener()
|
||||
remove_sleep_listener()
|
||||
# Let the script continue from the completed switch action into its delay.
|
||||
await asyncio.sleep(0)
|
||||
sleep_state = hass.states.get("switch.adaptive_lighting_bedroom_sleep_mode")
|
||||
assert sleep_state is not None
|
||||
assert sleep_state.state == STATE_ON
|
||||
assert adaptive_switch._sun_light_settings.sleep_brightness == 20
|
||||
assert adaptive_switch._sun_light_settings.sleep_rgb_or_color_temp == "rgb_color"
|
||||
assert adaptive_switch._sun_light_settings.sleep_rgb_color == [255, 56, 0]
|
||||
assert not adaptive_switch.manager.manual_control["light.bedroom"]
|
||||
bedroom_state = hass.states.get("light.bedroom")
|
||||
assert bedroom_state is not None
|
||||
assert bedroom_state.attributes[ATTR_BRIGHTNESS] == 51
|
||||
assert bedroom_state.attributes[ATTR_RGB_COLOR] == (255, 56, 0)
|
||||
|
||||
async_fire_time_changed(hass, dt_util.utcnow() + timedelta(minutes=30))
|
||||
await hass.async_block_till_done()
|
||||
assert adaptive_switch._sun_light_settings.sleep_brightness == 7
|
||||
assert adaptive_switch._sun_light_settings.sleep_rgb_or_color_temp == "color_temp"
|
||||
assert adaptive_switch._sun_light_settings.sleep_color_temp == 2300
|
||||
bedroom_state = hass.states.get("light.bedroom")
|
||||
assert bedroom_state is not None
|
||||
assert bedroom_state.attributes[ATTR_COLOR_TEMP_KELVIN] == pytest.approx(
|
||||
2300,
|
||||
abs=5,
|
||||
)
|
||||
|
||||
|
||||
async def test_fixed_virtual_day_curve_and_power_automation(
|
||||
hass: HomeAssistant,
|
||||
freezer,
|
||||
) -> None:
|
||||
"""Catch a broken cross-midnight curve or power-trigger branch."""
|
||||
summary = "Run a fixed virtual day across midnight."
|
||||
integration_config, automation_config = _yaml_documents(summary)
|
||||
await _setup_template_lights(hass, ["Indoor Garden"])
|
||||
assert isinstance(integration_config, dict)
|
||||
assert await async_setup_component(hass, DOMAIN, integration_config)
|
||||
await hass.async_block_till_done()
|
||||
entry = hass.config_entries.async_entries(DOMAIN)[0]
|
||||
adaptive_switch = hass.data[DOMAIN][entry.entry_id][SWITCH_DOMAIN]
|
||||
|
||||
expected_brightness = {
|
||||
datetime(2026, 9, 6, 16, tzinfo=dt_util.DEFAULT_TIME_ZONE): 10,
|
||||
datetime(2026, 9, 6, 22, tzinfo=dt_util.DEFAULT_TIME_ZONE): 100,
|
||||
datetime(2026, 9, 7, 4, tzinfo=dt_util.DEFAULT_TIME_ZONE): 10,
|
||||
}
|
||||
for now, expected in expected_brightness.items():
|
||||
assert adaptive_switch._sun_light_settings.brightness_pct(
|
||||
now,
|
||||
False,
|
||||
) == pytest.approx(
|
||||
expected,
|
||||
)
|
||||
|
||||
freezer.move_to(
|
||||
datetime(2026, 9, 6, 15, 59, tzinfo=dt_util.DEFAULT_TIME_ZONE),
|
||||
)
|
||||
await _setup_automation(hass, automation_config)
|
||||
await hass.async_start()
|
||||
async_fire_time_changed(
|
||||
hass,
|
||||
datetime(2026, 9, 6, 16, tzinfo=dt_util.DEFAULT_TIME_ZONE).astimezone(UTC),
|
||||
)
|
||||
await hass.async_block_till_done()
|
||||
garden_state = hass.states.get("light.indoor_garden")
|
||||
assert garden_state is not None
|
||||
assert garden_state.state == STATE_ON
|
||||
|
||||
async_fire_time_changed(
|
||||
hass,
|
||||
datetime(2026, 9, 7, 4, tzinfo=dt_util.DEFAULT_TIME_ZONE).astimezone(UTC),
|
||||
)
|
||||
await hass.async_block_till_done()
|
||||
garden_state = hass.states.get("light.indoor_garden")
|
||||
assert garden_state is not None
|
||||
assert garden_state.state == STATE_OFF
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("start_hour", "expected_state"),
|
||||
[(10, STATE_OFF), (22, STATE_ON)],
|
||||
)
|
||||
async def test_fixed_virtual_day_reconciles_power_at_startup(
|
||||
hass: HomeAssistant,
|
||||
freezer,
|
||||
start_hour: int,
|
||||
expected_state: str,
|
||||
) -> None:
|
||||
"""Catch a power schedule that misses a trigger while HA is offline."""
|
||||
_prepare_hass_startup(hass)
|
||||
summary = "Run a fixed virtual day across midnight."
|
||||
_, automation_config = _yaml_documents(summary)
|
||||
await _setup_template_lights(hass, ["Indoor Garden"])
|
||||
await _setup_automation(hass, automation_config)
|
||||
freezer.move_to(
|
||||
datetime(2026, 9, 6, start_hour, tzinfo=dt_util.DEFAULT_TIME_ZONE),
|
||||
)
|
||||
|
||||
await hass.async_start()
|
||||
await hass.async_block_till_done()
|
||||
|
||||
garden_state = hass.states.get("light.indoor_garden")
|
||||
assert garden_state is not None
|
||||
assert garden_state.state == expected_state
|
||||
|
||||
|
||||
async def test_autoreset_manual_control_uses_one_renewable_timer(
|
||||
hass: HomeAssistant,
|
||||
) -> None:
|
||||
"""Validate the documented built-in timeout and its renewal behavior."""
|
||||
summary = "Automatically reset manual control after one hour."
|
||||
integration_config = _yaml_documents(summary)[0]
|
||||
await _setup_template_lights(hass, ["Living Room"])
|
||||
assert isinstance(integration_config, dict)
|
||||
assert await async_setup_component(hass, DOMAIN, integration_config)
|
||||
await hass.async_block_till_done()
|
||||
entry = hass.config_entries.async_entries(DOMAIN)[0]
|
||||
adaptive_switch = hass.data[DOMAIN][entry.entry_id][SWITCH_DOMAIN]
|
||||
assert adaptive_switch._auto_reset_manual_control_time == 3600
|
||||
|
||||
service_data = {
|
||||
ATTR_ENTITY_ID: adaptive_switch.entity_id,
|
||||
CONF_LIGHTS: ["light.living_room"],
|
||||
"manual_control": True,
|
||||
}
|
||||
await hass.services.async_call(
|
||||
DOMAIN,
|
||||
"set_manual_control",
|
||||
service_data,
|
||||
blocking=True,
|
||||
)
|
||||
first_timer = adaptive_switch.manager.auto_reset_manual_control_timers[
|
||||
"light.living_room"
|
||||
]
|
||||
first_task = first_timer.task
|
||||
|
||||
await hass.services.async_call(
|
||||
DOMAIN,
|
||||
"set_manual_control",
|
||||
service_data,
|
||||
blocking=True,
|
||||
)
|
||||
renewed_timer = adaptive_switch.manager.auto_reset_manual_control_timers[
|
||||
"light.living_room"
|
||||
]
|
||||
await asyncio.sleep(0)
|
||||
assert renewed_timer is first_timer
|
||||
assert renewed_timer.task is not first_task
|
||||
assert first_task is not None
|
||||
assert first_task.cancelled()
|
||||
assert adaptive_switch.manager.manual_control["light.living_room"]
|
||||
|
||||
await renewed_timer.callback()
|
||||
assert not adaptive_switch.manager.manual_control["light.living_room"]
|
||||
|
||||
|
||||
async def test_sleep_toggle_uses_fresh_profile_entity_ids(
|
||||
hass: HomeAssistant,
|
||||
) -> None:
|
||||
"""Execute state triggers against fresh child entity IDs."""
|
||||
summary = (
|
||||
'Toggle multiple Adaptive Lighting switches to "sleep mode" using an '
|
||||
"<code>input_boolean.sleep_mode</code>."
|
||||
)
|
||||
automation_config = _yaml_documents(summary)[0]
|
||||
assert await async_setup_component(
|
||||
hass,
|
||||
"input_boolean",
|
||||
{"input_boolean": {"sleep_mode": {}}},
|
||||
)
|
||||
await setup_switch(hass, {CONF_NAME: "Living Room"})
|
||||
await setup_switch(hass, {CONF_NAME: "Bedroom"})
|
||||
await _setup_automation(hass, automation_config)
|
||||
|
||||
await hass.services.async_call(
|
||||
"input_boolean",
|
||||
SERVICE_TURN_ON,
|
||||
{ATTR_ENTITY_ID: "input_boolean.sleep_mode"},
|
||||
blocking=True,
|
||||
)
|
||||
await hass.async_block_till_done()
|
||||
for entity_id in (
|
||||
"switch.adaptive_lighting_living_room_sleep_mode",
|
||||
"switch.adaptive_lighting_bedroom_sleep_mode",
|
||||
):
|
||||
state = hass.states.get(entity_id)
|
||||
assert state is not None
|
||||
assert state.state == STATE_ON
|
||||
|
||||
await hass.services.async_call(
|
||||
"input_boolean",
|
||||
SERVICE_TURN_OFF,
|
||||
{ATTR_ENTITY_ID: "input_boolean.sleep_mode"},
|
||||
blocking=True,
|
||||
)
|
||||
await hass.async_block_till_done()
|
||||
for entity_id in (
|
||||
"switch.adaptive_lighting_living_room_sleep_mode",
|
||||
"switch.adaptive_lighting_bedroom_sleep_mode",
|
||||
):
|
||||
state = hass.states.get(entity_id)
|
||||
assert state is not None
|
||||
assert state.state == STATE_OFF
|
||||
|
||||
|
||||
async def test_sleep_toggle_applies_restored_state_at_startup(
|
||||
hass: HomeAssistant,
|
||||
) -> None:
|
||||
"""Verify startup applies the input boolean's restored state."""
|
||||
_prepare_hass_startup(hass)
|
||||
summary = (
|
||||
'Toggle multiple Adaptive Lighting switches to "sleep mode" using an '
|
||||
"<code>input_boolean.sleep_mode</code>."
|
||||
)
|
||||
automation_config = _yaml_documents(summary)[0]
|
||||
assert await async_setup_component(
|
||||
hass,
|
||||
"input_boolean",
|
||||
{"input_boolean": {"sleep_mode": {}}},
|
||||
)
|
||||
await setup_switch(hass, {CONF_NAME: "Living Room"})
|
||||
await hass.services.async_call(
|
||||
"input_boolean",
|
||||
SERVICE_TURN_ON,
|
||||
{ATTR_ENTITY_ID: "input_boolean.sleep_mode"},
|
||||
blocking=True,
|
||||
)
|
||||
await _setup_automation(hass, automation_config)
|
||||
|
||||
await hass.async_start()
|
||||
await hass.async_block_till_done()
|
||||
state = hass.states.get("switch.adaptive_lighting_living_room_sleep_mode")
|
||||
assert state is not None
|
||||
assert state.state == STATE_ON
|
||||
|
||||
|
||||
async def test_alarm_script_updates_its_profile_once(
|
||||
hass: HomeAssistant,
|
||||
freezer,
|
||||
) -> None:
|
||||
"""Execute the alarm script and verify one profile update with a 12-hour day."""
|
||||
summary = "Set sunrise and sunset from an alarm."
|
||||
script_config = _yaml_documents(summary)[0]
|
||||
freezer.move_to(
|
||||
datetime(2026, 11, 1, 0, 30, tzinfo=dt_util.DEFAULT_TIME_ZONE),
|
||||
)
|
||||
_, adaptive_switch = await setup_switch(hass, {CONF_NAME: "Alarm Lights"})
|
||||
await _setup_script(hass, script_config)
|
||||
calls = []
|
||||
|
||||
def record_service_call(event) -> None:
|
||||
if (
|
||||
event.data["domain"] == DOMAIN
|
||||
and event.data["service"] == "change_switch_settings"
|
||||
):
|
||||
calls.append(event)
|
||||
|
||||
hass.bus.async_listen(EVENT_CALL_SERVICE, record_service_call)
|
||||
await hass.services.async_call(
|
||||
script.DOMAIN,
|
||||
"set_adaptive_lighting_alarm_times",
|
||||
blocking=True,
|
||||
)
|
||||
await hass.async_block_till_done()
|
||||
|
||||
assert len(calls) == 1
|
||||
sunrise = adaptive_switch._sun_light_settings.sunrise_time
|
||||
sunset = adaptive_switch._sun_light_settings.sunset_time
|
||||
assert sunrise is not None
|
||||
assert sunset is not None
|
||||
sunrise_seconds = sunrise.hour * 3600 + sunrise.minute * 60 + sunrise.second
|
||||
sunset_seconds = sunset.hour * 3600 + sunset.minute * 60 + sunset.second
|
||||
assert (sunset_seconds - sunrise_seconds) % (24 * 3600) == 12 * 3600
|
||||
|
|
@ -1,15 +1,19 @@
|
|||
import datetime as dt
|
||||
import zoneinfo
|
||||
|
||||
import astral.sun
|
||||
import pytest
|
||||
from astral import LocationInfo
|
||||
from astral.location import Location
|
||||
from homeassistant.components.adaptive_lighting.color_and_brightness import (
|
||||
_POLAR_SUN_EVENT_OFFSET,
|
||||
SunEvent,
|
||||
SunEvents,
|
||||
SunLightSettings,
|
||||
clamp,
|
||||
)
|
||||
|
||||
# Create a mock astral_location object
|
||||
# Create a mock astral location object (its `.observer` is passed to `SunEvents`)
|
||||
location = Location(LocationInfo())
|
||||
|
||||
LAT_LONG_TZS = [
|
||||
|
|
@ -40,7 +44,7 @@ def test_replace_time(tzinfo_and_location):
|
|||
tzinfo, location = tzinfo_and_location
|
||||
sun_events = SunEvents(
|
||||
name="test",
|
||||
astral_location=location,
|
||||
astral_observer=location.observer,
|
||||
sunrise_time=None,
|
||||
min_sunrise_time=None,
|
||||
max_sunrise_time=None,
|
||||
|
|
@ -61,7 +65,7 @@ def test_sunrise_without_offset(tzinfo_and_location):
|
|||
|
||||
sun_events = SunEvents(
|
||||
name="test",
|
||||
astral_location=location,
|
||||
astral_observer=location.observer,
|
||||
sunrise_time=None,
|
||||
min_sunrise_time=None,
|
||||
max_sunrise_time=None,
|
||||
|
|
@ -79,7 +83,7 @@ def test_sun_position_no_fixed_sunset_and_sunrise(tzinfo_and_location):
|
|||
tzinfo, location = tzinfo_and_location
|
||||
sun_events = SunEvents(
|
||||
name="test",
|
||||
astral_location=location,
|
||||
astral_observer=location.observer,
|
||||
sunrise_time=None,
|
||||
min_sunrise_time=None,
|
||||
max_sunrise_time=None,
|
||||
|
|
@ -107,7 +111,7 @@ def test_sun_position_fixed_sunset_and_sunrise(tzinfo_and_location):
|
|||
tzinfo, location = tzinfo_and_location
|
||||
sun_events = SunEvents(
|
||||
name="test",
|
||||
astral_location=location,
|
||||
astral_observer=location.observer,
|
||||
sunrise_time=dt.time(6, 0),
|
||||
min_sunrise_time=None,
|
||||
max_sunrise_time=None,
|
||||
|
|
@ -134,7 +138,7 @@ def test_noon_and_midnight(tzinfo_and_location):
|
|||
tzinfo, location = tzinfo_and_location
|
||||
sun_events = SunEvents(
|
||||
name="test",
|
||||
astral_location=location,
|
||||
astral_observer=location.observer,
|
||||
sunrise_time=None,
|
||||
min_sunrise_time=None,
|
||||
max_sunrise_time=None,
|
||||
|
|
@ -153,7 +157,7 @@ def test_sun_events(tzinfo_and_location):
|
|||
tzinfo, location = tzinfo_and_location
|
||||
sun_events = SunEvents(
|
||||
name="test",
|
||||
astral_location=location,
|
||||
astral_observer=location.observer,
|
||||
sunrise_time=None,
|
||||
min_sunrise_time=None,
|
||||
max_sunrise_time=None,
|
||||
|
|
@ -173,7 +177,7 @@ def test_prev_and_next_events(tzinfo_and_location):
|
|||
tzinfo, location = tzinfo_and_location
|
||||
sun_events = SunEvents(
|
||||
name="test",
|
||||
astral_location=location,
|
||||
astral_observer=location.observer,
|
||||
sunrise_time=None,
|
||||
min_sunrise_time=None,
|
||||
max_sunrise_time=None,
|
||||
|
|
@ -193,7 +197,7 @@ def test_closest_event(tzinfo_and_location):
|
|||
tzinfo, location = tzinfo_and_location
|
||||
sun_events = SunEvents(
|
||||
name="test",
|
||||
astral_location=location,
|
||||
astral_observer=location.observer,
|
||||
sunrise_time=None,
|
||||
min_sunrise_time=None,
|
||||
max_sunrise_time=None,
|
||||
|
|
@ -207,3 +211,304 @@ def test_closest_event(tzinfo_and_location):
|
|||
event_name, ts = sun_events.closest_event(sunrise)
|
||||
assert event_name == SunEvent.SUNRISE
|
||||
assert ts == location.sunrise(sunrise.date()).timestamp()
|
||||
|
||||
|
||||
def _make_brightness_settings(
|
||||
tzinfo,
|
||||
location,
|
||||
*,
|
||||
min_brightness,
|
||||
max_brightness,
|
||||
brightness_mode,
|
||||
):
|
||||
"""Build a SunLightSettings with only the fields brightness_pct() needs."""
|
||||
return SunLightSettings(
|
||||
name="test",
|
||||
astral_observer=location.observer,
|
||||
adapt_until_sleep=False,
|
||||
max_brightness=max_brightness,
|
||||
max_color_temp=6500,
|
||||
min_brightness=min_brightness,
|
||||
min_color_temp=2000,
|
||||
sleep_brightness=1,
|
||||
sleep_rgb_or_color_temp="color_temp",
|
||||
sleep_color_temp=2000,
|
||||
sleep_rgb_color=(255, 56, 0),
|
||||
sunrise_time=None,
|
||||
min_sunrise_time=None,
|
||||
max_sunrise_time=None,
|
||||
sunset_time=None,
|
||||
min_sunset_time=None,
|
||||
max_sunset_time=None,
|
||||
brightness_mode_time_dark=dt.timedelta(minutes=30),
|
||||
brightness_mode_time_light=dt.timedelta(minutes=30),
|
||||
brightness_mode=brightness_mode,
|
||||
timezone=tzinfo,
|
||||
)
|
||||
|
||||
|
||||
def test_clamp_handles_inverted_bounds():
|
||||
"""A user can intentionally set min_brightness > max_brightness for an
|
||||
inverted timescale (#1421, e.g. a porch light that should be brighter at
|
||||
night than during the day). clamp() must still bound the value between
|
||||
whichever of the two is actually smaller/larger, not silently collapse
|
||||
to `minimum` for every input the way `max(minimum, min(value, maximum))`
|
||||
does when minimum > maximum.
|
||||
"""
|
||||
assert clamp(50, 100, 15) == 50
|
||||
assert clamp(0, 100, 15) == 15
|
||||
assert clamp(200, 100, 15) == 100
|
||||
|
||||
|
||||
def test_clamp_normal_bounds_unaffected():
|
||||
"""The ordinary min <= max case must keep behaving exactly as before."""
|
||||
assert clamp(50, 0, 100) == 50
|
||||
assert clamp(-10, 0, 100) == 0
|
||||
assert clamp(150, 0, 100) == 100
|
||||
|
||||
|
||||
@pytest.mark.parametrize("brightness_mode", ["linear", "tanh"])
|
||||
def test_brightness_pct_varies_with_inverted_brightness_bounds(
|
||||
tzinfo_and_location,
|
||||
brightness_mode,
|
||||
):
|
||||
"""#1421: with min_brightness > max_brightness, linear/tanh modes got
|
||||
stuck returning min_brightness for every sample, because the final
|
||||
`clamp(brightness, self.min_brightness, self.max_brightness)` call
|
||||
collapsed to `minimum` regardless of the computed value. Sampling a few
|
||||
points around sunrise must show the brightness actually move instead of
|
||||
being pinned to one value.
|
||||
"""
|
||||
tzinfo, location = tzinfo_and_location
|
||||
settings = _make_brightness_settings(
|
||||
tzinfo,
|
||||
location,
|
||||
min_brightness=100,
|
||||
max_brightness=15,
|
||||
brightness_mode=brightness_mode,
|
||||
)
|
||||
|
||||
sunrise = location.sunrise(dt.datetime(2022, 6, 1).date())
|
||||
samples = [
|
||||
settings.brightness_pct(
|
||||
sunrise + dt.timedelta(minutes=offset),
|
||||
is_sleep=False,
|
||||
)
|
||||
for offset in (-20, -10, 0, 10, 20)
|
||||
]
|
||||
|
||||
assert len({round(value) for value in samples}) > 1, samples
|
||||
assert all(15 <= value <= 100 for value in samples), samples
|
||||
|
||||
|
||||
# Tromsø, Norway (69.6°N) has polar night (Nov-Jan) and midnight sun (May-Jul).
|
||||
TROMSO = Location(
|
||||
LocationInfo(
|
||||
name="Tromsø",
|
||||
region="Norway",
|
||||
timezone="Europe/Oslo",
|
||||
latitude=69.6489,
|
||||
longitude=18.9551,
|
||||
),
|
||||
)
|
||||
POLAR_NIGHT_DATE = dt.date(2026, 1, 7)
|
||||
MIDNIGHT_SUN_DATE = dt.date(2026, 7, 7)
|
||||
MCMURDO = Location(
|
||||
LocationInfo(
|
||||
name="McMurdo Station",
|
||||
region="Antarctica",
|
||||
timezone="Antarctica/McMurdo",
|
||||
latitude=-77.8419,
|
||||
longitude=166.6863,
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
def _polar_sun_events(location=TROMSO, **kwargs):
|
||||
defaults = {
|
||||
"name": "test",
|
||||
"astral_observer": location.observer,
|
||||
"sunrise_time": None,
|
||||
"min_sunrise_time": None,
|
||||
"max_sunrise_time": None,
|
||||
"sunset_time": None,
|
||||
"min_sunset_time": None,
|
||||
"max_sunset_time": None,
|
||||
"timezone": zoneinfo.ZoneInfo(location.timezone),
|
||||
}
|
||||
return SunEvents(**{**defaults, **kwargs})
|
||||
|
||||
|
||||
def test_polar_night_synthesizes_short_day():
|
||||
# `astral` cannot compute sunrise/sunset (the sun never rises), see #1485
|
||||
with pytest.raises(ValueError): # noqa: PT011
|
||||
astral.sun.sunrise(TROMSO.observer, POLAR_NIGHT_DATE)
|
||||
sun_events = _polar_sun_events()
|
||||
noon = astral.sun.noon(TROMSO.observer, POLAR_NIGHT_DATE)
|
||||
assert sun_events.sunrise(POLAR_NIGHT_DATE) == noon - _POLAR_SUN_EVENT_OFFSET
|
||||
assert sun_events.sunset(POLAR_NIGHT_DATE) == noon + _POLAR_SUN_EVENT_OFFSET
|
||||
|
||||
|
||||
def test_midnight_sun_synthesizes_short_night():
|
||||
# `astral` cannot compute sunrise/sunset (the sun never sets), see #1485
|
||||
with pytest.raises(ValueError): # noqa: PT011
|
||||
astral.sun.sunset(TROMSO.observer, MIDNIGHT_SUN_DATE)
|
||||
sun_events = _polar_sun_events()
|
||||
midnight = astral.sun.midnight(TROMSO.observer, MIDNIGHT_SUN_DATE)
|
||||
next_midnight = astral.sun.midnight(
|
||||
TROMSO.observer,
|
||||
MIDNIGHT_SUN_DATE + dt.timedelta(days=1),
|
||||
)
|
||||
assert sun_events.sunrise(MIDNIGHT_SUN_DATE) == midnight + _POLAR_SUN_EVENT_OFFSET
|
||||
assert (
|
||||
sun_events.sunset(MIDNIGHT_SUN_DATE) == next_midnight - _POLAR_SUN_EVENT_OFFSET
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("date", "midnight_sun"),
|
||||
[(dt.date(2026, 1, 7), True), (dt.date(2026, 7, 7), False)],
|
||||
)
|
||||
def test_polar_fallback_handles_southern_hemisphere(date, midnight_sun):
|
||||
sun_events = _polar_sun_events(MCMURDO)
|
||||
noon = astral.sun.noon(MCMURDO.observer, date)
|
||||
midnight = astral.sun.midnight(MCMURDO.observer, date)
|
||||
next_midnight = astral.sun.midnight(MCMURDO.observer, date + dt.timedelta(days=1))
|
||||
|
||||
if midnight_sun:
|
||||
assert sun_events.sunrise(date) == midnight + _POLAR_SUN_EVENT_OFFSET
|
||||
assert sun_events.sunset(date) == next_midnight - _POLAR_SUN_EVENT_OFFSET
|
||||
else:
|
||||
assert sun_events.sunrise(date) == noon - _POLAR_SUN_EVENT_OFFSET
|
||||
assert sun_events.sunset(date) == noon + _POLAR_SUN_EVENT_OFFSET
|
||||
|
||||
|
||||
def test_boundary_day_with_real_sunrise_and_synthetic_sunset():
|
||||
# At the start of the midnight sun period, `astral` computes a real
|
||||
# sunrise for this date but raises for sunset (this exact date depends on
|
||||
# astral's numerics). The synthetic sunset must stay consistent with the
|
||||
# nearly 24-hour day instead of collapsing into a polar-night day.
|
||||
date = dt.date(2026, 5, 18)
|
||||
astral.sun.sunrise(TROMSO.observer, date) # does not raise
|
||||
with pytest.raises(ValueError): # noqa: PT011
|
||||
astral.sun.sunset(TROMSO.observer, date)
|
||||
sun_events = _polar_sun_events()
|
||||
day_length = sun_events.sunset(date) - sun_events.sunrise(date)
|
||||
assert day_length > dt.timedelta(hours=22)
|
||||
|
||||
|
||||
@pytest.mark.parametrize("date", [POLAR_NIGHT_DATE, MIDNIGHT_SUN_DATE])
|
||||
def test_sun_position_on_polar_days(date):
|
||||
sun_events = _polar_sun_events()
|
||||
datetime = dt.datetime(date.year, date.month, date.day, tzinfo=dt.timezone.utc)
|
||||
noon, midnight = sun_events.noon_and_midnight(datetime)
|
||||
assert sun_events.sun_position(noon) == 1
|
||||
assert sun_events.sun_position(midnight) == -1
|
||||
assert sun_events.sun_position(sun_events.sunrise(date)) == 0
|
||||
assert sun_events.sun_position(sun_events.sunset(date)) == 0
|
||||
|
||||
|
||||
def test_polar_night_min_max_times_shape_the_synthetic_day():
|
||||
# The (min/max)_(sunrise/sunset)_time options apply on top of the
|
||||
# synthetic sun events, so users can still shape their schedule.
|
||||
sun_events = _polar_sun_events(
|
||||
max_sunrise_time=dt.time(9, 0),
|
||||
min_sunset_time=dt.time(17, 0),
|
||||
timezone=dt.timezone.utc,
|
||||
)
|
||||
expected_sunrise = dt.datetime(2026, 1, 7, 9, 0, tzinfo=dt.timezone.utc)
|
||||
expected_sunset = dt.datetime(2026, 1, 7, 17, 0, tzinfo=dt.timezone.utc)
|
||||
assert sun_events.sunrise(POLAR_NIGHT_DATE) == expected_sunrise
|
||||
assert sun_events.sunset(POLAR_NIGHT_DATE) == expected_sunset
|
||||
|
||||
|
||||
@pytest.mark.parametrize("date", [POLAR_NIGHT_DATE, MIDNIGHT_SUN_DATE])
|
||||
@pytest.mark.parametrize(
|
||||
("sunrise_offset", "sunset_offset"),
|
||||
[
|
||||
(dt.timedelta(hours=-20), dt.timedelta(hours=-20)),
|
||||
(dt.timedelta(hours=-20), dt.timedelta(hours=20)),
|
||||
(dt.timedelta(hours=20), dt.timedelta(hours=-20)),
|
||||
(dt.timedelta(hours=20), dt.timedelta(hours=20)),
|
||||
],
|
||||
)
|
||||
def test_polar_offsets_cannot_invert_event_order(
|
||||
date,
|
||||
sunrise_offset,
|
||||
sunset_offset,
|
||||
):
|
||||
sun_events = _polar_sun_events(
|
||||
sunrise_offset=sunrise_offset,
|
||||
sunset_offset=sunset_offset,
|
||||
)
|
||||
|
||||
events = dict(
|
||||
sun_events.sun_events(dt.datetime.combine(date, dt.time(), tzinfo=dt.UTC)),
|
||||
)
|
||||
midnight = dt.datetime.fromtimestamp(events[SunEvent.MIDNIGHT], tz=dt.UTC)
|
||||
next_midnight = astral.sun.midnight(TROMSO.observer, date + dt.timedelta(days=1))
|
||||
noon = dt.datetime.fromtimestamp(events[SunEvent.NOON], tz=dt.UTC)
|
||||
sunrise = dt.datetime.fromtimestamp(events[SunEvent.SUNRISE], tz=dt.UTC)
|
||||
sunset = dt.datetime.fromtimestamp(events[SunEvent.SUNSET], tz=dt.UTC)
|
||||
|
||||
assert midnight < sunrise < noon < sunset < next_midnight
|
||||
|
||||
|
||||
def test_polar_fallback_applies_offsets_within_solar_anchors():
|
||||
offset = dt.timedelta(minutes=15)
|
||||
plain = _polar_sun_events()
|
||||
shifted = _polar_sun_events(
|
||||
sunrise_offset=offset,
|
||||
sunset_offset=offset,
|
||||
)
|
||||
|
||||
assert (
|
||||
shifted.sunrise(MIDNIGHT_SUN_DATE) - plain.sunrise(MIDNIGHT_SUN_DATE) == offset
|
||||
)
|
||||
assert shifted.sunset(MIDNIGHT_SUN_DATE) - plain.sunset(MIDNIGHT_SUN_DATE) == offset
|
||||
|
||||
|
||||
def test_sun_position_all_year_in_polar_region():
|
||||
# Covers the transitions into and out of polar night and midnight sun;
|
||||
# `sun_position` internally validates the order of the sun events.
|
||||
sun_events = _polar_sun_events()
|
||||
datetime = dt.datetime(2026, 1, 1, tzinfo=dt.timezone.utc)
|
||||
end = dt.datetime(2027, 1, 1, tzinfo=dt.timezone.utc)
|
||||
while datetime < end:
|
||||
position = sun_events.sun_position(datetime)
|
||||
assert -1 <= position <= 1
|
||||
datetime += dt.timedelta(hours=8)
|
||||
|
||||
|
||||
@pytest.mark.parametrize("date", [POLAR_NIGHT_DATE, MIDNIGHT_SUN_DATE])
|
||||
def test_brightness_and_color_on_polar_days(date):
|
||||
settings = SunLightSettings(
|
||||
name="test",
|
||||
astral_observer=TROMSO.observer,
|
||||
adapt_until_sleep=False,
|
||||
max_brightness=100,
|
||||
max_color_temp=5500,
|
||||
min_brightness=30,
|
||||
min_color_temp=2000,
|
||||
sleep_brightness=1,
|
||||
sleep_rgb_or_color_temp="color_temp",
|
||||
sleep_color_temp=1000,
|
||||
sleep_rgb_color=(255, 56, 0),
|
||||
sunrise_time=None,
|
||||
min_sunrise_time=None,
|
||||
max_sunrise_time=None,
|
||||
sunset_time=None,
|
||||
min_sunset_time=None,
|
||||
max_sunset_time=None,
|
||||
brightness_mode_time_dark=dt.timedelta(hours=1),
|
||||
brightness_mode_time_light=dt.timedelta(hours=1),
|
||||
timezone=zoneinfo.ZoneInfo("Europe/Oslo"),
|
||||
)
|
||||
datetime = dt.datetime(date.year, date.month, date.day, tzinfo=dt.timezone.utc)
|
||||
noon, midnight = settings.sun.noon_and_midnight(datetime)
|
||||
at_noon = settings.brightness_and_color(noon, is_sleep=False)
|
||||
assert at_noon["brightness_pct"] == 100
|
||||
assert at_noon["color_temp_kelvin"] == 5500
|
||||
at_midnight = settings.brightness_and_color(midnight, is_sleep=False)
|
||||
assert at_midnight["brightness_pct"] == 30
|
||||
assert at_midnight["color_temp_kelvin"] == 2000
|
||||
|
|
|
|||
|
|
@ -1,6 +1,17 @@
|
|||
"""Test Adaptive Lighting config flow."""
|
||||
|
||||
import json
|
||||
|
||||
import pytest
|
||||
import voluptuous as vol
|
||||
|
||||
try:
|
||||
from probatio import to_field_list
|
||||
except ImportError:
|
||||
from voluptuous_serialize import convert as to_field_list
|
||||
from homeassistant.components.adaptive_lighting.const import (
|
||||
BASIC_OPTIONS,
|
||||
CONF_INITIAL_TRANSITION,
|
||||
CONF_SUNRISE_TIME,
|
||||
CONF_SUNSET_TIME,
|
||||
DEFAULT_NAME,
|
||||
|
|
@ -10,12 +21,34 @@ from homeassistant.components.adaptive_lighting.const import (
|
|||
)
|
||||
from homeassistant.config_entries import SOURCE_IMPORT
|
||||
from homeassistant.const import CONF_NAME
|
||||
from homeassistant.data_entry_flow import FlowResultType
|
||||
from homeassistant.data_entry_flow import FlowResultType, section
|
||||
from homeassistant.helpers import config_validation as cv
|
||||
|
||||
from tests.common import MockConfigEntry
|
||||
|
||||
DEFAULT_DATA = {key: default for key, default, _ in VALIDATION_TUPLES}
|
||||
|
||||
# Split DEFAULT_DATA into basic and advanced for section-based input
|
||||
BASIC_DATA = {key: value for key, value in DEFAULT_DATA.items() if key in BASIC_OPTIONS}
|
||||
ADVANCED_DATA = {
|
||||
key: value for key, value in DEFAULT_DATA.items() if key not in BASIC_OPTIONS
|
||||
}
|
||||
|
||||
|
||||
def _schema_defaults(schema: vol.Schema) -> dict[str, object]:
|
||||
"""Return the defaults from a voluptuous schema."""
|
||||
return {
|
||||
key.schema: key.default() if callable(key.default) else key.default
|
||||
for key in schema.schema
|
||||
}
|
||||
|
||||
|
||||
def _advanced_section(result) -> section:
|
||||
"""Return the advanced options section from a flow result."""
|
||||
advanced = result["data_schema"].schema["advanced"]
|
||||
assert isinstance(advanced, section)
|
||||
return advanced
|
||||
|
||||
|
||||
async def test_flow_manual_configuration(hass):
|
||||
"""Test that config flow works."""
|
||||
|
|
@ -53,7 +86,7 @@ async def test_import_success(hass):
|
|||
|
||||
|
||||
async def test_options(hass):
|
||||
"""Test updating options."""
|
||||
"""Test updating options with collapsible sections."""
|
||||
entry = MockConfigEntry(
|
||||
domain=DOMAIN,
|
||||
title=DEFAULT_NAME,
|
||||
|
|
@ -68,20 +101,79 @@ async def test_options(hass):
|
|||
assert result["type"] == FlowResultType.FORM
|
||||
assert result["step_id"] == "init"
|
||||
|
||||
data = DEFAULT_DATA.copy()
|
||||
data[CONF_SUNRISE_TIME] = NONE_STR
|
||||
data[CONF_SUNSET_TIME] = NONE_STR
|
||||
# Build input with advanced options nested in "advanced" section
|
||||
advanced_data = ADVANCED_DATA.copy()
|
||||
advanced_data[CONF_INITIAL_TRANSITION] = 23
|
||||
advanced_data[CONF_SUNRISE_TIME] = NONE_STR
|
||||
advanced_data[CONF_SUNSET_TIME] = NONE_STR
|
||||
basic_data = {**BASIC_DATA, "min_brightness": 12}
|
||||
user_input = {
|
||||
**basic_data,
|
||||
"advanced": advanced_data,
|
||||
}
|
||||
result = await hass.config_entries.options.async_configure(
|
||||
result["flow_id"],
|
||||
user_input=data,
|
||||
user_input=user_input,
|
||||
)
|
||||
assert result["type"] == FlowResultType.CREATE_ENTRY
|
||||
for key, value in data.items():
|
||||
|
||||
# Verify flattened data is saved correctly
|
||||
expected_data = {**basic_data, **advanced_data}
|
||||
for key, value in expected_data.items():
|
||||
assert result["data"][key] == value
|
||||
|
||||
assert "advanced" not in result["data"]
|
||||
|
||||
async def test_incorrect_options(hass):
|
||||
"""Test updating incorrect options."""
|
||||
# Starting the flow again must load the saved flat options into both parts
|
||||
# of the sectioned form.
|
||||
result = await hass.config_entries.options.async_init(entry.entry_id)
|
||||
assert _schema_defaults(result["data_schema"])["min_brightness"] == 12
|
||||
assert (
|
||||
_schema_defaults(_advanced_section(result).schema)[CONF_INITIAL_TRANSITION]
|
||||
== 23
|
||||
)
|
||||
|
||||
|
||||
async def test_options_schema_has_each_setting_once(hass):
|
||||
"""Test that basic and advanced options partition all settings."""
|
||||
entry = MockConfigEntry(
|
||||
domain=DOMAIN,
|
||||
title=DEFAULT_NAME,
|
||||
data={CONF_NAME: DEFAULT_NAME, "interval": 120, "min_brightness": 7},
|
||||
options={"min_brightness": 12},
|
||||
)
|
||||
entry.add_to_hass(hass)
|
||||
|
||||
result = await hass.config_entries.options.async_init(entry.entry_id)
|
||||
schema = result["data_schema"].schema
|
||||
advanced = _advanced_section(result)
|
||||
|
||||
assert advanced.options == {"collapsed": True}
|
||||
assert {key.schema for key in schema if key.schema != "advanced"} == BASIC_OPTIONS
|
||||
assert {key.schema for key in advanced.schema.schema} == set(
|
||||
DEFAULT_DATA,
|
||||
) - BASIC_OPTIONS
|
||||
assert _schema_defaults(result["data_schema"])["interval"] == 120
|
||||
assert _schema_defaults(result["data_schema"])["min_brightness"] == 12
|
||||
|
||||
serialized_schema = to_field_list(
|
||||
result["data_schema"],
|
||||
custom_serializer=cv.custom_serializer,
|
||||
)
|
||||
json.dumps(serialized_schema)
|
||||
serialized_advanced = next(
|
||||
field for field in serialized_schema if field["name"] == "advanced"
|
||||
)
|
||||
assert serialized_advanced["type"] == "expandable"
|
||||
assert serialized_advanced["expanded"] is False
|
||||
assert {field["name"] for field in serialized_advanced["schema"]} == set(
|
||||
DEFAULT_DATA,
|
||||
) - BASIC_OPTIONS
|
||||
|
||||
|
||||
@pytest.mark.parametrize("lights", [[], ["light.missing"]])
|
||||
async def test_incorrect_options(hass, lights):
|
||||
"""Test updating incorrect options in advanced section."""
|
||||
entry = MockConfigEntry(
|
||||
domain=DOMAIN,
|
||||
title=DEFAULT_NAME,
|
||||
|
|
@ -93,12 +185,30 @@ async def test_incorrect_options(hass):
|
|||
await hass.config_entries.async_setup(entry.entry_id)
|
||||
|
||||
result = await hass.config_entries.options.async_init(entry.entry_id)
|
||||
data = DEFAULT_DATA.copy()
|
||||
data[CONF_SUNRISE_TIME] = "yolo"
|
||||
data[CONF_SUNSET_TIME] = "yolo"
|
||||
|
||||
# Build input with invalid advanced options nested in section
|
||||
advanced_data = ADVANCED_DATA.copy()
|
||||
advanced_data[CONF_SUNRISE_TIME] = "yolo"
|
||||
advanced_data[CONF_SUNSET_TIME] = "yolo"
|
||||
basic_data = {**BASIC_DATA, "min_brightness": 12, "lights": lights}
|
||||
user_input = {
|
||||
**basic_data,
|
||||
"advanced": advanced_data,
|
||||
}
|
||||
result = await hass.config_entries.options.async_configure(
|
||||
result["flow_id"],
|
||||
user_input=data,
|
||||
user_input=user_input,
|
||||
)
|
||||
# Should show form with errors
|
||||
assert result["type"] == FlowResultType.FORM
|
||||
expected_errors = {"base": "option_error"}
|
||||
if lights:
|
||||
expected_errors["lights"] = "entity_missing"
|
||||
assert result["errors"] == expected_errors
|
||||
assert _schema_defaults(result["data_schema"])["lights"] == lights
|
||||
assert _schema_defaults(result["data_schema"])["min_brightness"] == 12
|
||||
assert (
|
||||
_schema_defaults(_advanced_section(result).schema)[CONF_SUNRISE_TIME] == "yolo"
|
||||
)
|
||||
|
||||
|
||||
|
|
@ -145,6 +255,10 @@ async def test_options_flow_for_yaml_import(hass):
|
|||
assert result["type"] == FlowResultType.FORM
|
||||
assert result["step_id"] == "init"
|
||||
assert result.get("data_schema") is None
|
||||
assert result["description_placeholders"] == {
|
||||
"docs_url": "https://github.com/basnijholt/adaptive-lighting#readme",
|
||||
"webapp_url": "https://basnijholt.github.io/adaptive-lighting",
|
||||
}
|
||||
|
||||
|
||||
async def test_menu_shown_when_entries_exist(hass):
|
||||
|
|
|
|||
|
|
@ -1,12 +1,21 @@
|
|||
"""Tests for Adaptive Lighting integration."""
|
||||
|
||||
import pytest
|
||||
import voluptuous.error
|
||||
from homeassistant.components import adaptive_lighting
|
||||
from homeassistant.components.adaptive_lighting.const import (
|
||||
CONF_LIGHTS,
|
||||
DEFAULT_NAME,
|
||||
SERVICE_APPLY,
|
||||
SERVICE_CHANGE_SWITCH_SETTINGS,
|
||||
SERVICE_SET_MANUAL_CONTROL,
|
||||
UNDO_UPDATE_LISTENER,
|
||||
)
|
||||
from homeassistant.components.switch import DOMAIN as SWITCH_DOMAIN
|
||||
from homeassistant.config_entries import ConfigEntryState
|
||||
from homeassistant.const import CONF_NAME
|
||||
from homeassistant.const import ATTR_ENTITY_ID, CONF_NAME
|
||||
from homeassistant.exceptions import ServiceValidationError
|
||||
from homeassistant.helpers import service
|
||||
from homeassistant.setup import async_setup_component
|
||||
|
||||
from tests.common import MockConfigEntry
|
||||
|
|
@ -53,3 +62,126 @@ async def test_unload_entry(hass):
|
|||
|
||||
assert entry.state == ConfigEntryState.NOT_LOADED
|
||||
assert adaptive_lighting.DOMAIN not in hass.data
|
||||
|
||||
|
||||
async def test_services_survive_entry_unload_and_reload(hass):
|
||||
"""Test integration services remain registered across entry lifecycle."""
|
||||
assert await async_setup_component(hass, adaptive_lighting.DOMAIN, {})
|
||||
service_names = (
|
||||
SERVICE_APPLY,
|
||||
SERVICE_CHANGE_SWITCH_SETTINGS,
|
||||
SERVICE_SET_MANUAL_CONTROL,
|
||||
)
|
||||
services = hass.services.async_services()[adaptive_lighting.DOMAIN]
|
||||
assert SERVICE_APPLY in services
|
||||
assert SERVICE_SET_MANUAL_CONTROL in services
|
||||
if hasattr(service, "async_register_platform_entity_service"):
|
||||
assert SERVICE_CHANGE_SWITCH_SETTINGS in services
|
||||
else:
|
||||
assert SERVICE_CHANGE_SWITCH_SETTINGS not in services
|
||||
|
||||
entry = MockConfigEntry(
|
||||
domain=adaptive_lighting.DOMAIN,
|
||||
data={CONF_NAME: DEFAULT_NAME},
|
||||
)
|
||||
entry.add_to_hass(hass)
|
||||
assert await hass.config_entries.async_setup(entry.entry_id)
|
||||
registered = {
|
||||
name: hass.services.async_services()[adaptive_lighting.DOMAIN][name]
|
||||
for name in service_names
|
||||
}
|
||||
switch = hass.data[adaptive_lighting.DOMAIN][entry.entry_id][SWITCH_DOMAIN]
|
||||
assert await hass.config_entries.async_unload(entry.entry_id)
|
||||
|
||||
for name in service_names:
|
||||
assert (
|
||||
hass.services.async_services()[adaptive_lighting.DOMAIN][name]
|
||||
is registered[name]
|
||||
)
|
||||
|
||||
with pytest.raises(ServiceValidationError, match="No Adaptive Lighting"):
|
||||
await hass.services.async_call(
|
||||
adaptive_lighting.DOMAIN,
|
||||
SERVICE_APPLY,
|
||||
{ATTR_ENTITY_ID: switch.entity_id},
|
||||
blocking=True,
|
||||
)
|
||||
|
||||
assert await hass.config_entries.async_setup(entry.entry_id)
|
||||
for name in service_names:
|
||||
assert (
|
||||
hass.services.async_services()[adaptive_lighting.DOMAIN][name]
|
||||
is registered[name]
|
||||
)
|
||||
|
||||
await hass.services.async_call(
|
||||
adaptive_lighting.DOMAIN,
|
||||
SERVICE_CHANGE_SWITCH_SETTINGS,
|
||||
{ATTR_ENTITY_ID: switch.entity_id},
|
||||
blocking=True,
|
||||
)
|
||||
|
||||
|
||||
async def test_service_call_without_loaded_entry(hass):
|
||||
"""Test global services reject calls when no profile is loaded."""
|
||||
assert await async_setup_component(hass, adaptive_lighting.DOMAIN, {})
|
||||
|
||||
with pytest.raises(ServiceValidationError, match="No Adaptive Lighting"):
|
||||
await hass.services.async_call(
|
||||
adaptive_lighting.DOMAIN,
|
||||
SERVICE_APPLY,
|
||||
{CONF_LIGHTS: ["light.test"]},
|
||||
blocking=True,
|
||||
)
|
||||
|
||||
pending_entry = MockConfigEntry(
|
||||
domain=adaptive_lighting.DOMAIN,
|
||||
data={CONF_NAME: "pending"},
|
||||
)
|
||||
pending_entry.add_to_hass(hass)
|
||||
hass.data[adaptive_lighting.DOMAIN] = {pending_entry.entry_id: {}}
|
||||
with pytest.raises(ServiceValidationError, match="not found in any switch"):
|
||||
await hass.services.async_call(
|
||||
adaptive_lighting.DOMAIN,
|
||||
SERVICE_APPLY,
|
||||
{CONF_LIGHTS: ["light.test"]},
|
||||
blocking=True,
|
||||
)
|
||||
|
||||
|
||||
async def test_apply_rejects_unknown_light(hass):
|
||||
"""Test the apply service rejects an unknown light target."""
|
||||
entry = MockConfigEntry(
|
||||
domain=adaptive_lighting.DOMAIN,
|
||||
data={CONF_NAME: DEFAULT_NAME},
|
||||
)
|
||||
entry.add_to_hass(hass)
|
||||
assert await hass.config_entries.async_setup(entry.entry_id)
|
||||
with pytest.raises(ServiceValidationError, match="not found in any switch"):
|
||||
await hass.services.async_call(
|
||||
adaptive_lighting.DOMAIN,
|
||||
SERVICE_APPLY,
|
||||
{CONF_LIGHTS: ["light.does_not_exist"]},
|
||||
blocking=True,
|
||||
)
|
||||
|
||||
|
||||
async def test_change_switch_settings_requires_entity_target(hass):
|
||||
"""Test change_switch_settings rejects a missing entity target."""
|
||||
entry = MockConfigEntry(
|
||||
domain=adaptive_lighting.DOMAIN,
|
||||
data={CONF_NAME: DEFAULT_NAME},
|
||||
)
|
||||
entry.add_to_hass(hass)
|
||||
assert await hass.config_entries.async_setup(entry.entry_id)
|
||||
|
||||
with pytest.raises(
|
||||
voluptuous.error.MultipleInvalid,
|
||||
match=r"must contain at least one of entity_id.*area_id",
|
||||
):
|
||||
await hass.services.async_call(
|
||||
adaptive_lighting.DOMAIN,
|
||||
SERVICE_CHANGE_SWITCH_SETTINGS,
|
||||
{},
|
||||
blocking=True,
|
||||
)
|
||||
|
|
|
|||
2232
tests/test_switch.py
2232
tests/test_switch.py
File diff suppressed because it is too large
Load diff
Loading…
Add table
Add a link
Reference in a new issue