Thread configured horizon_days (Feature 07 integration fix) (#86)

This commit was merged in pull request #86.
This commit is contained in:
2026-09-17 17:07:10 -04:00
parent c001aca62d
commit 3add2d2f48
6 changed files with 465 additions and 32 deletions
+125
View File
@@ -10,6 +10,7 @@ from conftest import make_config_entry, make_hass, wire_entry_to_registry
from custom_components.nestquest import DOMAIN, async_setup_entry, async_unload_entry
from custom_components.nestquest.const import (
CONF_DAY_ROLLOVER_TIME,
CONF_HORIZON_DAYS,
DEFAULT_HORIZON_DAYS,
SERVICE_REGENERATE,
DOMAIN as DOMAIN_CONST,
@@ -351,6 +352,39 @@ async def test_day_rollover_listener_materializes_ha_local_horizon(
)
async def test_day_rollover_uses_configured_horizon_days(hass, make_entry) -> None:
"""The daily run materializes only the configured horizon window."""
import datetime
from custom_components.nestquest.dao_instances import QuestInstancesDao
entry = _wire(make_entry(options={CONF_HORIZON_DAYS: 5}), hass.registry)
assert await async_setup_entry(hass, entry) is True
database = entry.runtime_data.database
# A definition created AFTER setup has no instances yet (create does not
# materialize); firing the daily listener must generate only the horizon.
child_id = await _seed_daily_child_and_definition(
database, _local_today(hass)
)
await hass.time_change.fire()
today = _local_today(hass)
end = (today + datetime.timedelta(days=5)).isoformat()
records = await QuestInstancesDao(database).list_by_date_range(
child_id, today.isoformat(), end
)
assert len(records) == 6
beyond = (today + datetime.timedelta(days=6)).isoformat()
beyond_end = (
today + datetime.timedelta(days=DEFAULT_HORIZON_DAYS)
).isoformat()
assert await QuestInstancesDao(database).list_by_date_range(
child_id, beyond, beyond_end
) == []
def test_day_rollover_time_change_tracker_rejects_local_kwarg() -> None:
"""The fake async_track_time_change mirrors HA 2024.6 (no ``local`` kwarg).
@@ -447,6 +481,63 @@ async def test_setup_backfills_instances_over_horizon(hass, make_entry) -> None:
assert len(records) == DEFAULT_HORIZON_DAYS + 1
async def test_setup_backfills_configured_horizon_days(hass, make_entry) -> None:
"""Setup backfills only the configured (non-default) horizon window."""
import datetime
from custom_components.nestquest.dao_instances import QuestInstancesDao
from custom_components.nestquest.db import NestQuestDatabase
from custom_components.nestquest.migrations import apply_migrations
from custom_components.nestquest.store import async_get_db_path
db_path = await async_get_db_path(hass)
pre = NestQuestDatabase(hass)
await pre.open(db_path)
await apply_migrations(pre)
child_id = await _seed_daily_child_and_definition(pre, _local_today(hass))
await pre.close()
entry = _wire(
make_entry(options={CONF_HORIZON_DAYS: 5}), hass.registry
)
assert await async_setup_entry(hass, entry) is True
today = _local_today(hass)
end = (today + datetime.timedelta(days=5)).isoformat()
records = await QuestInstancesDao(
entry.runtime_data.database
).list_by_date_range(child_id, today.isoformat(), end)
assert len(records) == 6
beyond = (today + datetime.timedelta(days=6)).isoformat()
beyond_end = (
today + datetime.timedelta(days=DEFAULT_HORIZON_DAYS)
).isoformat()
assert await QuestInstancesDao(
entry.runtime_data.database
).list_by_date_range(child_id, beyond, beyond_end) == []
def test_configured_horizon_days_validates_and_falls_back() -> None:
"""The configured horizon is validated; a malformed value uses the default."""
import custom_components.nestquest as nestquest
assert nestquest._configured_horizon_days({CONF_HORIZON_DAYS: 5}) == 5
assert nestquest._configured_horizon_days({}) == DEFAULT_HORIZON_DAYS
assert (
nestquest._configured_horizon_days({CONF_HORIZON_DAYS: True})
== DEFAULT_HORIZON_DAYS
)
assert (
nestquest._configured_horizon_days({CONF_HORIZON_DAYS: "5"})
== DEFAULT_HORIZON_DAYS
)
assert (
nestquest._configured_horizon_days({CONF_HORIZON_DAYS: 0})
== DEFAULT_HORIZON_DAYS
)
async def test_regenerate_service_registered_and_materializes(
hass, make_entry
) -> None:
@@ -474,6 +565,40 @@ async def test_regenerate_service_registered_and_materializes(
assert len(records) == DEFAULT_HORIZON_DAYS + 1
async def test_regenerate_service_uses_configured_horizon_days(
hass, make_entry
) -> None:
"""``nestquest.regenerate`` regenerates only the configured horizon."""
import datetime
from custom_components.nestquest.dao_instances import QuestInstancesDao
entry = _wire(
make_entry(options={CONF_HORIZON_DAYS: 5}), hass.registry
)
assert await async_setup_entry(hass, entry) is True
database = entry.runtime_data.database
child_id = await _seed_daily_child_and_definition(database, _local_today(hass))
await hass.services.call(DOMAIN, SERVICE_REGENERATE)
today = _local_today(hass)
end = (today + datetime.timedelta(days=5)).isoformat()
records = await QuestInstancesDao(database).list_by_date_range(
child_id, today.isoformat(), end
)
assert len(records) == 6
beyond = (today + datetime.timedelta(days=6)).isoformat()
beyond_end = (
today + datetime.timedelta(days=DEFAULT_HORIZON_DAYS)
).isoformat()
assert await QuestInstancesDao(database).list_by_date_range(
child_id, beyond, beyond_end
) == []
async def test_unload_deregisters_regenerate_service(hass, make_entry) -> None:
"""Unload removes the ``regenerate`` service so no stale entry leaks."""
entry = _wire(make_entry(), hass.registry)
+153
View File
@@ -7,6 +7,7 @@ import datetime
import pytest
from custom_components.nestquest.dao_children import ChildrenDao
from custom_components.nestquest.const import DEFAULT_HORIZON_DAYS
from custom_components.nestquest.dao_instances import (
CompletionEventsDao,
QuestInstancesDao,
@@ -1113,6 +1114,158 @@ def test_regenerate_for_child_is_child_scoped(tmp_path) -> None:
_with_db(tmp_path, "regenerate-child-scoped.db")(_body)
def test_regenerate_for_definition_respects_horizon_days(tmp_path) -> None:
"""A non-default horizon_days sizes the definition's rebuild window."""
async def _body(database):
children = ChildrenDao(database)
child = await children.create("Ada", NOW)
today = datetime.date.today()
start_iso = today.isoformat()
created = await create_quest_definition(
database,
"Daily chore",
ScheduleRule(rule_type=RuleType.DAILY, start_date=start_iso),
[child.id],
[("morning", "09:00")],
)
# ``create`` does not materialize; regeneration over a 5-day horizon
# must build exactly today..today+5 (six daily rows).
await regenerate_for_definition(
database, created.definition.id, horizon_days=5
)
dao = QuestInstancesDao(database)
horizon_end = today + datetime.timedelta(days=5)
records = await dao.list_by_date_range(
child.id, start_iso, horizon_end.isoformat()
)
assert len(records) == 6
# The window is BOUNDED: nothing must exist at day 6 or later (a
# hard-coded 14-day default would still emit rows there).
beyond = (today + datetime.timedelta(days=6)).isoformat()
beyond_end = (
today + datetime.timedelta(days=DEFAULT_HORIZON_DAYS)
).isoformat()
assert await dao.list_by_date_range(child.id, beyond, beyond_end) == []
return None
_with_db(tmp_path, "regenerate-def-horizon.db")(_body)
def test_regenerate_for_child_respects_horizon_days(tmp_path) -> None:
"""A non-default horizon_days sizes the child's rebuild window."""
async def _body(database):
children = ChildrenDao(database)
child = await children.create("Ada", NOW)
today = datetime.date.today()
start_iso = today.isoformat()
await create_quest_definition(
database,
"Daily chore",
ScheduleRule(rule_type=RuleType.DAILY, start_date=start_iso),
[child.id],
[("morning", "09:00")],
)
await regenerate_for_child(database, child.id, horizon_days=5)
dao = QuestInstancesDao(database)
horizon_end = today + datetime.timedelta(days=5)
records = await dao.list_by_date_range(
child.id, start_iso, horizon_end.isoformat()
)
assert len(records) == 6
# Bounded window: nothing must exist at day 6 or later.
beyond = (today + datetime.timedelta(days=6)).isoformat()
beyond_end = (
today + datetime.timedelta(days=DEFAULT_HORIZON_DAYS)
).isoformat()
assert await dao.list_by_date_range(child.id, beyond, beyond_end) == []
return None
_with_db(tmp_path, "regenerate-child-horizon.db")(_body)
@pytest.mark.parametrize("bad", [-1, 0, True, False, "5", 1.5])
def test_regenerate_for_definition_rejects_invalid_horizon_before_delete(
tmp_path, bad
) -> None:
"""A malformed horizon fails BEFORE any future-instance delete."""
async def _body(database):
children = ChildrenDao(database)
child = await children.create("Ada", NOW)
today = datetime.date.today()
start_iso = today.isoformat()
horizon_end = today + datetime.timedelta(days=DEFAULT_HORIZON_DAYS)
created = await create_quest_definition(
database,
"Daily chore",
ScheduleRule(rule_type=RuleType.DAILY, start_date=start_iso),
[child.id],
[("morning", "09:00")],
)
dao = QuestInstancesDao(database)
await materialize(database, start_iso, horizon_end.isoformat())
with pytest.raises(ValueError, match="horizon_days"):
await regenerate_for_definition(
database, created.definition.id, horizon_days=bad
)
# The refused call deleted nothing: the seeded horizon survives.
records = await dao.list_by_date_range(
child.id, start_iso, horizon_end.isoformat()
)
assert len(records) == DEFAULT_HORIZON_DAYS + 1
return None
_with_db(tmp_path, "regenerate-def-horizon-invalid.db")(_body)
@pytest.mark.parametrize("bad", [-1, 0, True, False, "5", 1.5])
def test_regenerate_for_child_rejects_invalid_horizon_before_delete(
tmp_path, bad
) -> None:
"""A malformed child horizon fails BEFORE any future-instance delete."""
async def _body(database):
children = ChildrenDao(database)
child = await children.create("Ada", NOW)
today = datetime.date.today()
start_iso = today.isoformat()
horizon_end = today + datetime.timedelta(days=DEFAULT_HORIZON_DAYS)
await create_quest_definition(
database,
"Daily chore",
ScheduleRule(rule_type=RuleType.DAILY, start_date=start_iso),
[child.id],
[("morning", "09:00")],
)
dao = QuestInstancesDao(database)
await materialize(database, start_iso, horizon_end.isoformat())
with pytest.raises(ValueError, match="horizon_days"):
await regenerate_for_child(database, child.id, horizon_days=bad)
records = await dao.list_by_date_range(
child.id, start_iso, horizon_end.isoformat()
)
assert len(records) == DEFAULT_HORIZON_DAYS + 1
return None
_with_db(tmp_path, "regenerate-child-horizon-invalid.db")(_body)
#: Fixed end-to-end anchor: 2026-06-01 is a Monday AND the 1st of its month,
#: so the two-week custody cycle is week-aligned and the yearly rule's
#: "1st of the anchor month" is GUARANTEED to fire inside the 28-day window
+69
View File
@@ -8,6 +8,7 @@ import re
import pytest
from custom_components.nestquest.dao_children import ChildrenDao
from custom_components.nestquest.const import DEFAULT_HORIZON_DAYS
from custom_components.nestquest.dao_instances import (
CompletionEventsDao,
QuestInstancesDao,
@@ -1938,3 +1939,71 @@ def test_full_lifecycle_preserves_completed_and_history(tmp_path) -> None:
return reactivated
_with_db(tmp_path, "full-lifecycle.db")(_body)
@pytest.mark.parametrize(
"mode",
["edit", "assign", "unassign", "set_active"],
)
def test_wrappers_forward_horizon_days(tmp_path, mode) -> None:
"""The four change wrappers forward a non-default horizon_days.
Each wrapper threads ``horizon_days`` into the regeneration it triggers;
passing 5 here must bound the regenerated window to today..today+5 —
proving the forwarding argument is actually honored, not dropped.
"""
async def _body(database, children, rules, definitions, child):
created = await create_quest_definition(
database,
"Chore",
ScheduleRule(rule_type=RuleType.DAILY, start_date=_today_iso()),
[child.id],
["morning"],
)
definition_id = created.definition.id
instances = QuestInstancesDao(database)
if mode == "edit":
await edit_quest_definition(
database,
definition_id,
windows=[("morning", "10:30")],
horizon_days=5,
)
tracked = child
elif mode == "assign":
bo = await children.create("Bo", NOW)
await assign_child(database, definition_id, bo.id, horizon_days=5)
tracked = bo
elif mode == "unassign":
# The remaining assignee (``child``) must be regenerated over
# the 5-day window after ``bo`` is removed.
bo = await children.create("Bo", NOW)
await assign_child(database, definition_id, bo.id)
await unassign_child(
database, definition_id, bo.id, horizon_days=5
)
tracked = child
else: # set_active
await set_quest_definition_active(
database, definition_id, True, horizon_days=5
)
tracked = child
today = datetime.date.today()
end = (today + datetime.timedelta(days=5)).isoformat()
records = await instances.list_by_date_range(
tracked.id, today.isoformat(), end
)
assert len(records) == 6
beyond = (today + datetime.timedelta(days=6)).isoformat()
beyond_end = (
today + datetime.timedelta(days=DEFAULT_HORIZON_DAYS)
).isoformat()
assert await instances.list_by_date_range(
tracked.id, beyond, beyond_end
) == []
return None
_with_db(tmp_path, f"wrapper-horizon-{mode}.db")(_body)