adaptive-lighting/tests/test_config_flow.py

371 lines
12 KiB
Python
Raw Permalink Normal View History

"""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_EXPAND_LIGHT_GROUPS,
CONF_INITIAL_TRANSITION,
Add manual_control_on_external_turn_on option (#1490) * feat: add `adapt_only_on_ha_turn_on` to skip adapting externally turned-on lights When a light turns on from `off` via a source outside Home Assistant — a physical wall switch or a hub/manufacturer scene (e.g. Lutron) — and `detect_non_ha_changes` is enabled, Adaptive Lighting adapts the light on the resulting `off` → `on` event, overriding the brightness/color the external source just set. Disabling `detect_non_ha_changes` avoids this but also stops detection of manual changes to already-on lights; the two behaviors were coupled to a single flag. Add `adapt_only_on_ha_turn_on` (default `false`, requires `take_over_control`). When enabled, an `off` → `on` transition with no matching HA `light.turn_on` context is marked `manual_control` and left untouched, independent of `detect_non_ha_changes`, decoupling the two behaviors. The off→on guard reduces to the previous expression when the option is `false`, so existing configurations are unaffected. Includes a parametrized regression test, docs, and regenerated strings/services/README via scripts/update-generated-content. Refs #435 Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> * Shorten generated turn-on option description * Document shared turn-on policy limitations * Name external turn-on policy after manual-control behavior * Clarify settings needed to adapt unmatched turn-ons --------- Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com> Co-authored-by: Bas Nijholt <bas@nijho.lt>
2026-09-06 12:02:30 -07:00
CONF_MANUAL_CONTROL_ON_EXTERNAL_TURN_ON,
CONF_SUNRISE_TIME,
CONF_SUNSET_TIME,
Add manual_control_on_external_turn_on option (#1490) * feat: add `adapt_only_on_ha_turn_on` to skip adapting externally turned-on lights When a light turns on from `off` via a source outside Home Assistant — a physical wall switch or a hub/manufacturer scene (e.g. Lutron) — and `detect_non_ha_changes` is enabled, Adaptive Lighting adapts the light on the resulting `off` → `on` event, overriding the brightness/color the external source just set. Disabling `detect_non_ha_changes` avoids this but also stops detection of manual changes to already-on lights; the two behaviors were coupled to a single flag. Add `adapt_only_on_ha_turn_on` (default `false`, requires `take_over_control`). When enabled, an `off` → `on` transition with no matching HA `light.turn_on` context is marked `manual_control` and left untouched, independent of `detect_non_ha_changes`, decoupling the two behaviors. The off→on guard reduces to the previous expression when the option is `false`, so existing configurations are unaffected. Includes a parametrized regression test, docs, and regenerated strings/services/README via scripts/update-generated-content. Refs #435 Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> * Shorten generated turn-on option description * Document shared turn-on policy limitations * Name external turn-on policy after manual-control behavior * Clarify settings needed to adapt unmatched turn-ons --------- Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com> Co-authored-by: Bas Nijholt <bas@nijho.lt>
2026-09-06 12:02:30 -07:00
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_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}
Add manual_control_on_external_turn_on option (#1490) * feat: add `adapt_only_on_ha_turn_on` to skip adapting externally turned-on lights When a light turns on from `off` via a source outside Home Assistant — a physical wall switch or a hub/manufacturer scene (e.g. Lutron) — and `detect_non_ha_changes` is enabled, Adaptive Lighting adapts the light on the resulting `off` → `on` event, overriding the brightness/color the external source just set. Disabling `detect_non_ha_changes` avoids this but also stops detection of manual changes to already-on lights; the two behaviors were coupled to a single flag. Add `adapt_only_on_ha_turn_on` (default `false`, requires `take_over_control`). When enabled, an `off` → `on` transition with no matching HA `light.turn_on` context is marked `manual_control` and left untouched, independent of `detect_non_ha_changes`, decoupling the two behaviors. The off→on guard reduces to the previous expression when the option is `false`, so existing configurations are unaffected. Includes a parametrized regression test, docs, and regenerated strings/services/README via scripts/update-generated-content. Refs #435 Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> * Shorten generated turn-on option description * Document shared turn-on policy limitations * Name external turn-on policy after manual-control behavior * Clarify settings needed to adapt unmatched turn-ons --------- Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com> Co-authored-by: Bas Nijholt <bas@nijho.lt>
2026-09-06 12:02:30 -07:00
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