adaptive-lighting/tests/test_init.py
Casey d5d3ecc9d4 Implement add-runtime-range-controls
Adds four live-tunable `number` entities per AL profile (min/max
brightness, min/max color temp) that own the runtime curve values.
Slider changes take effect on the next curve tick — no integration
reload. State persists across HA restart via `RestoreNumber`.

Curve math now reads `min_brightness`, `max_brightness`,
`min_color_temp`, `max_color_temp` from the four runtime entities
via the entity registry, falling back to `entry.options` when an
entity is unavailable. The options flow seeds its four range fields
from the current entity state so the dialog matches reality.

Also fixes entity friendly names via HA's `has_entity_name`
composition: profile "Dining MVP" now reads as "Dining MVP",
"Dining MVP Brightness", "Dining MVP Color" — short enough for
HA's tightest cards. `unique_id`s are unchanged so existing
entity_ids stay stable.

Manifest bumped to 2.1.0-cdit.1 (minor, no breaking changes).
14 new tests in `tests/test_number_platform.py`; 108 passing
overall. OpenSpec change archived once 9.x live-HA verification
completes.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2026-05-16 22:24:39 +02:00

167 lines
5.5 KiB
Python

"""Tests for the CDiT Adaptive Lighting integration setup.
Covers spec R8 (strict version-break) and R9 (sleep-switch tombstone).
"""
from __future__ import annotations
import logging
from homeassistant.config_entries import ConfigEntryState
from homeassistant.const import CONF_NAME
from homeassistant.helpers import entity_registry as er
from pytest_homeassistant_custom_component.common import MockConfigEntry
from custom_components.adaptive_lighting.const import (
CONFIG_ENTRY_VERSION,
DEFAULT_NAME,
DOMAIN,
)
# ---------------------------------------------------------------------------
# R8 — strict version break
# ---------------------------------------------------------------------------
async def test_successful_setup_on_current_version(hass) -> None:
"""R8: an entry created on the current major succeeds and runs no
tombstone log line.
"""
entry = MockConfigEntry(
domain=DOMAIN,
data={CONF_NAME: DEFAULT_NAME},
version=CONFIG_ENTRY_VERSION,
)
entry.add_to_hass(hass)
assert await hass.config_entries.async_setup(entry.entry_id)
assert entry.state == ConfigEntryState.LOADED
# OptionsFlowWithReload handles reload internally — we don't register
# an update listener (HA rejects the combination).
assert entry.entry_id in hass.data[DOMAIN]
async def test_stale_version_raises_config_entry_error(hass, caplog) -> None:
"""R8: an entry created on the previous major fails with our
ConfigEntryError, leaving the entry in the MIGRATION_ERROR state.
HA routes version mismatches through `async_migrate_entry`.
"""
entry = MockConfigEntry(
domain=DOMAIN,
data={CONF_NAME: DEFAULT_NAME},
version=CONFIG_ENTRY_VERSION - 1,
)
entry.add_to_hass(hass)
assert not await hass.config_entries.async_setup(entry.entry_id)
assert entry.state == ConfigEntryState.MIGRATION_ERROR
# The full captured log (which includes the exception traceback) should
# carry our friendly "delete and recreate" message so the user sees it.
text = caplog.text.lower()
assert "delete" in text and "recreate" in text
async def test_unload_entry(hass) -> None:
"""A current-version entry can be unloaded cleanly."""
entry = MockConfigEntry(
domain=DOMAIN,
data={CONF_NAME: DEFAULT_NAME},
version=CONFIG_ENTRY_VERSION,
)
entry.add_to_hass(hass)
assert await hass.config_entries.async_setup(entry.entry_id)
assert await hass.config_entries.async_unload(entry.entry_id)
await hass.async_block_till_done()
assert entry.state == ConfigEntryState.NOT_LOADED
assert DOMAIN not in hass.data
# ---------------------------------------------------------------------------
# R9 — sleep-switch tombstone cleanup
# ---------------------------------------------------------------------------
async def test_orphan_sleep_entity_is_removed_on_setup(hass, caplog) -> None:
"""R9: a leftover sleep_mode switch owned by this entry is removed and
one INFO log line is emitted.
"""
entry = MockConfigEntry(
domain=DOMAIN,
data={CONF_NAME: DEFAULT_NAME},
version=CONFIG_ENTRY_VERSION,
)
entry.add_to_hass(hass)
# Seed the registry with an orphan sleep-mode entity owned by this entry.
registry = er.async_get(hass)
orphan = registry.async_get_or_create(
domain="switch",
platform=DOMAIN,
unique_id=f"{DEFAULT_NAME}_sleep_mode",
config_entry=entry,
)
with caplog.at_level(logging.INFO):
assert await hass.config_entries.async_setup(entry.entry_id)
assert (
registry.async_get(orphan.entity_id) is None
), "Orphan sleep entity should have been removed."
# One INFO log line naming the entity ID.
assert any(
orphan.entity_id in record.message
for record in caplog.records
if record.levelno == logging.INFO
)
async def test_tombstone_helper_is_idempotent(hass, caplog) -> None:
"""R9: a second setup with no orphans finds nothing and emits no log line."""
entry = MockConfigEntry(
domain=DOMAIN,
data={CONF_NAME: DEFAULT_NAME},
version=CONFIG_ENTRY_VERSION,
)
entry.add_to_hass(hass)
# First setup: no orphans seeded, no log line.
with caplog.at_level(logging.INFO):
assert await hass.config_entries.async_setup(entry.entry_id)
assert not any(
"sleep_mode" in record.message.lower()
for record in caplog.records
if record.levelno == logging.INFO
)
async def test_tombstone_skips_foreign_entities(hass, caplog) -> None:
"""R9 + D12: an entity matching the name pattern but owned by another
config entry is not removed.
"""
our_entry = MockConfigEntry(
domain=DOMAIN,
data={CONF_NAME: DEFAULT_NAME},
version=CONFIG_ENTRY_VERSION,
)
our_entry.add_to_hass(hass)
# Different config entry, different domain, but a name that ends in
# _sleep_mode — should NOT be touched.
foreign_entry = MockConfigEntry(
domain="other_integration",
data={},
)
foreign_entry.add_to_hass(hass)
registry = er.async_get(hass)
foreign_entity = registry.async_get_or_create(
domain="switch",
platform="other_integration",
unique_id=f"unrelated_{DEFAULT_NAME}_sleep_mode",
config_entry=foreign_entry,
)
assert await hass.config_entries.async_setup(our_entry.entry_id)
assert (
registry.async_get(foreign_entity.entity_id) is not None
), "Foreign-owned entity matching name pattern must not be removed."