From f23fadb13f9f05d35b2aeb6668238b29714b9499 Mon Sep 17 00:00:00 2001 From: dev_agent <17+dev_agent@code.cubecraftcreations.com> Date: Fri, 25 Sep 2026 07:22:59 -0400 Subject: [PATCH] create_quest_definition materializes its instances (#235) --- .../nestquest/core/quest_definitions.py | 8 +++++ tests/test_api_admin_uncomplete.py | 22 ++++++------ tests/test_api_missed_sweep.py | 9 +++-- tests/test_api_panel.py | 11 ++++-- tests/test_core_sweep.py | 11 ++++-- tests/test_materialize.py | 4 +++ tests/test_quest_definitions.py | 36 +++++++++++++++++++ 7 files changed, 83 insertions(+), 18 deletions(-) diff --git a/custom_components/nestquest/core/quest_definitions.py b/custom_components/nestquest/core/quest_definitions.py index 9918311..8078ce2 100644 --- a/custom_components/nestquest/core/quest_definitions.py +++ b/custom_components/nestquest/core/quest_definitions.py @@ -235,6 +235,11 @@ async def create_quest_definition( transaction, so the returned bundle is a consistent creation snapshot. Raises ValueError on any rejected argument; a rejected or failed create persists nothing. + + After the write, :func:`~.materialize.regenerate_for_definition` + materializes the new definition's instances over the rolling horizon + (host clock, :data:`~.const.DEFAULT_HORIZON_DAYS`), so it reaches the + board without waiting for an unrelated write. """ name = _validate_text(title, "title", required=True) assert name is not None @@ -269,6 +274,9 @@ async def create_quest_definition( ) from error raise decoded_rule = schedule_rule_from_storage(storage) + + await regenerate_for_definition(database, snapshot.definition.id) + return CreatedQuestDefinition( definition=snapshot.definition, rule=decoded_rule, diff --git a/tests/test_api_admin_uncomplete.py b/tests/test_api_admin_uncomplete.py index 23e8211..43fda46 100644 --- a/tests/test_api_admin_uncomplete.py +++ b/tests/test_api_admin_uncomplete.py @@ -514,8 +514,10 @@ async def test_regenerate_household_never_duplicates( client = admin_household.client database = admin_household.database - before = await _instance_count(database) - assert before > 0 # the seed already materialized a few days + # The seed's creates materialized the horizon BEFORE Ada's custody + # pattern existed, so the first household walk may legitimately + # drop her away-day rows; only the second run must be stable. + assert await _instance_count(database) > 0 first = await client.post( "/api/v1/admin/regenerate", json={}, headers=_admin_headers() @@ -525,9 +527,8 @@ async def test_regenerate_household_never_duplicates( assert body["scope"] == "household" assert body["definition_id"] is None assert body["child_id"] is None - assert body["count"] >= before + assert body["count"] > 0 after_first = await _instance_count(database) - assert after_first >= before second = await client.post( "/api/v1/admin/regenerate", json={}, headers=_admin_headers() @@ -544,17 +545,18 @@ async def test_regenerate_definition_scope_rematerializes( ) -> None: """The definition scope rebuilds that definition's horizon, twice, stably. - Creating a definition through the admin route materializes NOTHING - (the core's create path does not regenerate); the scoped regenerate - call is what materializes its instances, and re-running it leaves - the row count exactly where the first run left it. + Creating a definition through the admin route already materializes + its horizon (the core's create path regenerates); the scoped + regenerate call rebuilds the same rows, and re-running it leaves + the row count exactly where the create left it. """ client = admin_api.client database = admin_api.database ada = await _create_child(client, "Ada") definition_id = await _create_daily_definition(client, [ada]) - assert await _instance_count(database) == 0 + created_count = await _instance_count(database) + assert created_count > 0 first = await client.post( "/api/v1/admin/regenerate", @@ -568,7 +570,7 @@ async def test_regenerate_definition_scope_rematerializes( assert body["child_id"] is None assert body["count"] > 0 after_first = await _instance_count(database) - assert after_first == body["count"] + assert after_first == body["count"] == created_count second = await client.post( "/api/v1/admin/regenerate", diff --git a/tests/test_api_missed_sweep.py b/tests/test_api_missed_sweep.py index c86c9a5..95e774c 100644 --- a/tests/test_api_missed_sweep.py +++ b/tests/test_api_missed_sweep.py @@ -151,12 +151,17 @@ async def _seed_missed_household( database, yesterday_iso, yesterday_iso, today=yesterday ) # Today's quest: created after the yesterday walk so it has no - # past-due instance, then materialized for today only. + # past-due instance; ``end_date`` keeps it (and create's horizon + # regeneration) to today only. await _quest_definitions.create_quest_definition( database, "Recoverable chore", _recurrence.ScheduleRule.from_dict( - {"rule_type": "daily", "start_date": today_iso} + { + "rule_type": "daily", + "start_date": today_iso, + "end_date": today_iso, + } ), [ada.id], [("afternoon", "17:00")], diff --git a/tests/test_api_panel.py b/tests/test_api_panel.py index a37f894..13f9bc3 100644 --- a/tests/test_api_panel.py +++ b/tests/test_api_panel.py @@ -149,13 +149,18 @@ async def _seed_household( (today + datetime.timedelta(days=3)).isoformat(), today=today, ) - # The stale quest is created AFTER the today walk, so its only - # instance is the yesterday one written below. + # The stale quest's rule ends YESTERDAY, so create's own + # regeneration writes nothing and its only instance is the + # yesterday one written below. await _quest_definitions.create_quest_definition( database, "Stale chore", _recurrence.ScheduleRule.from_dict( - {"rule_type": "daily", "start_date": yesterday_iso} + { + "rule_type": "daily", + "start_date": yesterday_iso, + "end_date": yesterday_iso, + } ), [ada.id], [("morning", "09:00")], diff --git a/tests/test_core_sweep.py b/tests/test_core_sweep.py index 4a4215a..944422b 100644 --- a/tests/test_core_sweep.py +++ b/tests/test_core_sweep.py @@ -102,15 +102,20 @@ async def _seed_missed_household( database, "Old chore", rule, [ada.id], [("evening", "18:30")] ) await materialize(database, yesterday_iso, yesterday_iso, today=yesterday) - # A quest due TODAY (created after the yesterday walk so it has - # no past-due instance): the post-downtime catch-up run's + # A quest due TODAY only (created after the yesterday walk so it + # has no past-due instance; ``end_date`` keeps create's horizon + # regeneration to today): the post-downtime catch-up run's # target — inside [watermark=today, later_today) but not # inside the day-one sweep's window. await create_quest_definition( database, "Recoverable chore", ScheduleRule.from_dict( - {"rule_type": "daily", "start_date": today_iso} + { + "rule_type": "daily", + "start_date": today_iso, + "end_date": today_iso, + } ), [ada.id], [("afternoon", "17:00")], diff --git a/tests/test_materialize.py b/tests/test_materialize.py index 829040d..436198a 100644 --- a/tests/test_materialize.py +++ b/tests/test_materialize.py @@ -468,7 +468,11 @@ async def _materialize_with_change( proceeds with the snapped presence). This deterministically exercises the snapshot-to-insert gap that a config edit can interleave into. + + ``create_quest_definition`` already materializes its horizon, so the + table is cleared first: the gated walk must be the only writer. """ + await database.execute("DELETE FROM quest_instances") original_upsert = QuestInstancesDao.upsert_if_valid entered = asyncio.Event() release = asyncio.Event() diff --git a/tests/test_quest_definitions.py b/tests/test_quest_definitions.py index 6a534c8..23cc27a 100644 --- a/tests/test_quest_definitions.py +++ b/tests/test_quest_definitions.py @@ -168,6 +168,42 @@ def test_create_several_children_several_windows_with_due_times( _with_db(tmp_path, "create-many.db")(_body) +def test_create_materializes_future_instances(tmp_path) -> None: + """A fresh create regenerates its rolling horizon immediately. + + Without this the new definition has no ``quest_instances`` rows until + some unrelated write regenerates it, so it never reaches the board. + """ + assert DEFAULT_HORIZON_DAYS >= 7 + + async def _body(database, children, rules, definitions, child): + today = _today_iso() + created = await create_quest_definition( + database, + "Take out bins", + ScheduleRule( + rule_type=RuleType.WEEKLY, + weekday_set={0}, + start_date=today, + ), + [child.id], + ["morning"], + ) + rows = await database.fetch_all( + "SELECT child_id, due_date FROM quest_instances " + "WHERE definition_id = ?", + (created.definition.id,), + ) + assert rows, "create materialized no instances" + assert {row[0] for row in rows} == {child.id} + for _child_id, due_date in rows: + assert due_date >= today + assert datetime.date.fromisoformat(due_date).weekday() == 0 + return None + + _with_db(tmp_path, "create-materializes.db")(_body) + + # --------------------------------------------------------------------------- # create_quest_definition: argument rejections # ---------------------------------------------------------------------------