mirror of
https://github.com/basnijholt/adaptive-lighting.git
synced 2026-10-29 14:37:47 +01:00
Add `color_temp_plateau_morning` and `color_temp_plateau_evening` (seconds, default 0, max 12h). They keep `max_color_temp` for that long before / after solar noon, and compress the ramp from/to `min_color_temp` at sunrise/sunset so the curve stays continuous and smooth. Each side is independent, only the color temperature is affected (not brightness, sleep mode or the night), and the default (0) is bit-for-bit identical to the previous behavior. Each plateau is clamped to 90% of the half-day so the ramp never collapses.
375 lines
12 KiB
Python
375 lines
12 KiB
Python
"""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_COLOR_TEMP_PLATEAU_EVENING,
|
|
CONF_COLOR_TEMP_PLATEAU_MORNING,
|
|
CONF_EXPAND_LIGHT_GROUPS,
|
|
CONF_INITIAL_TRANSITION,
|
|
CONF_MANUAL_CONTROL_ON_EXTERNAL_TURN_ON,
|
|
CONF_SUNRISE_TIME,
|
|
CONF_SUNSET_TIME,
|
|
DEFAULT_MANUAL_CONTROL_ON_EXTERNAL_TURN_ON,
|
|
DEFAULT_NAME,
|
|
DOMAIN,
|
|
NONE_STR,
|
|
VALIDATION_TUPLES,
|
|
)
|
|
from homeassistant.config_entries import SOURCE_IMPORT
|
|
from homeassistant.const import CONF_NAME
|
|
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."""
|
|
result = await hass.config_entries.flow.async_init(
|
|
DOMAIN,
|
|
context={"source": "user"},
|
|
)
|
|
|
|
assert result["type"] == FlowResultType.FORM
|
|
assert result["step_id"] == "user"
|
|
assert result["handler"] == "adaptive_lighting"
|
|
|
|
result = await hass.config_entries.flow.async_configure(
|
|
result["flow_id"],
|
|
user_input={CONF_NAME: "living room"},
|
|
)
|
|
assert result["type"] == FlowResultType.CREATE_ENTRY
|
|
assert result["title"] == "living room"
|
|
|
|
|
|
async def test_import_success(hass):
|
|
"""Test import step is successful."""
|
|
data = DEFAULT_DATA.copy()
|
|
data[CONF_NAME] = DEFAULT_NAME
|
|
result = await hass.config_entries.flow.async_init(
|
|
DOMAIN,
|
|
context={"source": "import"},
|
|
data=data,
|
|
)
|
|
|
|
assert result["type"] == FlowResultType.CREATE_ENTRY
|
|
assert result["title"] == DEFAULT_NAME
|
|
for key, value in data.items():
|
|
assert result["data"][key] == value
|
|
|
|
|
|
async def test_options(hass):
|
|
"""Test updating options with collapsible sections."""
|
|
entry = MockConfigEntry(
|
|
domain=DOMAIN,
|
|
title=DEFAULT_NAME,
|
|
data={CONF_NAME: DEFAULT_NAME},
|
|
options={},
|
|
)
|
|
entry.add_to_hass(hass)
|
|
|
|
await hass.config_entries.async_setup(entry.entry_id)
|
|
|
|
result = await hass.config_entries.options.async_init(entry.entry_id)
|
|
assert result["type"] == FlowResultType.FORM
|
|
assert result["step_id"] == "init"
|
|
|
|
# Build input with advanced options nested in "advanced" section
|
|
advanced_data = ADVANCED_DATA.copy()
|
|
advanced_data[CONF_INITIAL_TRANSITION] = 23
|
|
advanced_data[CONF_EXPAND_LIGHT_GROUPS] = False
|
|
advanced_data[CONF_COLOR_TEMP_PLATEAU_MORNING] = 1800
|
|
advanced_data[CONF_COLOR_TEMP_PLATEAU_EVENING] = 10800
|
|
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=user_input,
|
|
)
|
|
assert result["type"] == FlowResultType.CREATE_ENTRY
|
|
|
|
# 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"]
|
|
|
|
# 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 (
|
|
_schema_defaults(advanced.schema)[CONF_MANUAL_CONTROL_ON_EXTERNAL_TURN_ON]
|
|
is DEFAULT_MANUAL_CONTROL_ON_EXTERNAL_TURN_ON
|
|
)
|
|
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,
|
|
data={CONF_NAME: DEFAULT_NAME},
|
|
options={},
|
|
)
|
|
entry.add_to_hass(hass)
|
|
|
|
await hass.config_entries.async_setup(entry.entry_id)
|
|
|
|
result = await hass.config_entries.options.async_init(entry.entry_id)
|
|
|
|
# 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=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"
|
|
)
|
|
|
|
|
|
async def test_import_twice(hass):
|
|
"""Test importing twice."""
|
|
data = DEFAULT_DATA.copy()
|
|
data[CONF_NAME] = DEFAULT_NAME
|
|
for _ in range(2):
|
|
_ = await hass.config_entries.flow.async_init(
|
|
DOMAIN,
|
|
context={"source": "import"},
|
|
data=data,
|
|
)
|
|
|
|
|
|
async def test_options_flow_for_yaml_import(hass):
|
|
"""Test that options flow for YAML-imported entries shows empty form.
|
|
|
|
When a config entry is imported from YAML (source=SOURCE_IMPORT),
|
|
the options flow should show an empty form since the user should
|
|
modify the YAML configuration directly, not through the UI.
|
|
"""
|
|
entry = MockConfigEntry(
|
|
domain=DOMAIN,
|
|
title=DEFAULT_NAME,
|
|
data={CONF_NAME: DEFAULT_NAME},
|
|
source=SOURCE_IMPORT,
|
|
options={},
|
|
)
|
|
entry.add_to_hass(hass)
|
|
|
|
# For YAML imports, the switch setup requires the unique_id to be in
|
|
# hass.data[DOMAIN]["__yaml__"], otherwise it deletes the entry.
|
|
# This simulates what async_step_import does.
|
|
hass.data.setdefault(DOMAIN, {}).setdefault("__yaml__", set()).add(entry.unique_id)
|
|
|
|
await hass.config_entries.async_setup(entry.entry_id)
|
|
await hass.async_block_till_done()
|
|
|
|
result = await hass.config_entries.options.async_init(entry.entry_id)
|
|
|
|
# For YAML imports, the options flow shows an empty form (data_schema=None)
|
|
# This is intentional - users should modify YAML, not UI
|
|
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):
|
|
"""Test that menu step is shown when existing entries exist."""
|
|
# Create an existing entry
|
|
entry = MockConfigEntry(
|
|
domain=DOMAIN,
|
|
title="existing",
|
|
data={CONF_NAME: "existing"},
|
|
options={"min_brightness": 10},
|
|
)
|
|
entry.add_to_hass(hass)
|
|
|
|
# Start a new config flow - should show menu
|
|
result = await hass.config_entries.flow.async_init(
|
|
DOMAIN,
|
|
context={"source": "user"},
|
|
)
|
|
|
|
assert result["type"] == FlowResultType.FORM
|
|
assert result["step_id"] == "menu"
|
|
|
|
|
|
async def test_menu_create_new_instance(hass):
|
|
"""Test creating a new instance through the menu."""
|
|
# Create an existing entry
|
|
entry = MockConfigEntry(
|
|
domain=DOMAIN,
|
|
title="existing",
|
|
data={CONF_NAME: "existing"},
|
|
options={"min_brightness": 10},
|
|
)
|
|
entry.add_to_hass(hass)
|
|
|
|
# Start config flow - shows menu
|
|
result = await hass.config_entries.flow.async_init(
|
|
DOMAIN,
|
|
context={"source": "user"},
|
|
)
|
|
assert result["step_id"] == "menu"
|
|
|
|
# Choose to create new instance
|
|
result = await hass.config_entries.flow.async_configure(
|
|
result["flow_id"],
|
|
user_input={"action": "new"},
|
|
)
|
|
|
|
# Should show name form
|
|
assert result["type"] == FlowResultType.FORM
|
|
assert result["step_id"] == "user"
|
|
|
|
# Enter name
|
|
result = await hass.config_entries.flow.async_configure(
|
|
result["flow_id"],
|
|
user_input={CONF_NAME: "new instance"},
|
|
)
|
|
|
|
assert result["type"] == FlowResultType.CREATE_ENTRY
|
|
assert result["title"] == "new instance"
|
|
# New instance should have no options (not duplicated)
|
|
assert result["options"] == {}
|
|
|
|
|
|
async def test_menu_duplicate_instance(hass):
|
|
"""Test duplicating an existing instance through the menu."""
|
|
# Create an existing entry with custom options
|
|
source_options = {"min_brightness": 20, "max_brightness": 80}
|
|
entry = MockConfigEntry(
|
|
domain=DOMAIN,
|
|
title="source",
|
|
data={CONF_NAME: "source"},
|
|
options=source_options,
|
|
)
|
|
entry.add_to_hass(hass)
|
|
|
|
# Start config flow - shows menu
|
|
result = await hass.config_entries.flow.async_init(
|
|
DOMAIN,
|
|
context={"source": "user"},
|
|
)
|
|
assert result["step_id"] == "menu"
|
|
|
|
# Choose to duplicate existing entry
|
|
result = await hass.config_entries.flow.async_configure(
|
|
result["flow_id"],
|
|
user_input={"action": entry.entry_id},
|
|
)
|
|
|
|
# Should show name form
|
|
assert result["type"] == FlowResultType.FORM
|
|
assert result["step_id"] == "user"
|
|
|
|
# Enter name for duplicated instance
|
|
result = await hass.config_entries.flow.async_configure(
|
|
result["flow_id"],
|
|
user_input={CONF_NAME: "duplicated"},
|
|
)
|
|
|
|
assert result["type"] == FlowResultType.CREATE_ENTRY
|
|
assert result["title"] == "duplicated"
|
|
# Duplicated instance should have copied options
|
|
assert result["options"] == source_options
|