Materialize instances when a quest definition is created
create_quest_definition persisted the rule, definition, assignees and windows but never called regenerate_for_definition, unlike edit, assign, unassign and activate. A new task therefore had no quest_instances rows and stayed off the panel board until an unrelated write regenerated it. Call regenerate_for_definition after the DAO write, using the host clock and the default horizon, and add a test that a created weekly definition has only today-or-later instances. Update test fixtures that relied on create writing no instances: close the seeded one-day rules with end_date, clear the table before the gated materialize walk, and adjust the admin regenerate tests to the new create behaviour. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
@@ -235,6 +235,11 @@ async def create_quest_definition(
|
|||||||
transaction, so the returned bundle is a consistent creation
|
transaction, so the returned bundle is a consistent creation
|
||||||
snapshot. Raises ValueError on any rejected argument; a rejected or
|
snapshot. Raises ValueError on any rejected argument; a rejected or
|
||||||
failed create persists nothing.
|
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)
|
name = _validate_text(title, "title", required=True)
|
||||||
assert name is not None
|
assert name is not None
|
||||||
@@ -269,6 +274,9 @@ async def create_quest_definition(
|
|||||||
) from error
|
) from error
|
||||||
raise
|
raise
|
||||||
decoded_rule = schedule_rule_from_storage(storage)
|
decoded_rule = schedule_rule_from_storage(storage)
|
||||||
|
|
||||||
|
await regenerate_for_definition(database, snapshot.definition.id)
|
||||||
|
|
||||||
return CreatedQuestDefinition(
|
return CreatedQuestDefinition(
|
||||||
definition=snapshot.definition,
|
definition=snapshot.definition,
|
||||||
rule=decoded_rule,
|
rule=decoded_rule,
|
||||||
|
|||||||
@@ -514,8 +514,10 @@ async def test_regenerate_household_never_duplicates(
|
|||||||
client = admin_household.client
|
client = admin_household.client
|
||||||
database = admin_household.database
|
database = admin_household.database
|
||||||
|
|
||||||
before = await _instance_count(database)
|
# The seed's creates materialized the horizon BEFORE Ada's custody
|
||||||
assert before > 0 # the seed already materialized a few days
|
# 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(
|
first = await client.post(
|
||||||
"/api/v1/admin/regenerate", json={}, headers=_admin_headers()
|
"/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["scope"] == "household"
|
||||||
assert body["definition_id"] is None
|
assert body["definition_id"] is None
|
||||||
assert body["child_id"] is None
|
assert body["child_id"] is None
|
||||||
assert body["count"] >= before
|
assert body["count"] > 0
|
||||||
after_first = await _instance_count(database)
|
after_first = await _instance_count(database)
|
||||||
assert after_first >= before
|
|
||||||
|
|
||||||
second = await client.post(
|
second = await client.post(
|
||||||
"/api/v1/admin/regenerate", json={}, headers=_admin_headers()
|
"/api/v1/admin/regenerate", json={}, headers=_admin_headers()
|
||||||
@@ -544,17 +545,18 @@ async def test_regenerate_definition_scope_rematerializes(
|
|||||||
) -> None:
|
) -> None:
|
||||||
"""The definition scope rebuilds that definition's horizon, twice, stably.
|
"""The definition scope rebuilds that definition's horizon, twice, stably.
|
||||||
|
|
||||||
Creating a definition through the admin route materializes NOTHING
|
Creating a definition through the admin route already materializes
|
||||||
(the core's create path does not regenerate); the scoped regenerate
|
its horizon (the core's create path regenerates); the scoped
|
||||||
call is what materializes its instances, and re-running it leaves
|
regenerate call rebuilds the same rows, and re-running it leaves
|
||||||
the row count exactly where the first run left it.
|
the row count exactly where the create left it.
|
||||||
"""
|
"""
|
||||||
client = admin_api.client
|
client = admin_api.client
|
||||||
database = admin_api.database
|
database = admin_api.database
|
||||||
ada = await _create_child(client, "Ada")
|
ada = await _create_child(client, "Ada")
|
||||||
definition_id = await _create_daily_definition(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(
|
first = await client.post(
|
||||||
"/api/v1/admin/regenerate",
|
"/api/v1/admin/regenerate",
|
||||||
@@ -568,7 +570,7 @@ async def test_regenerate_definition_scope_rematerializes(
|
|||||||
assert body["child_id"] is None
|
assert body["child_id"] is None
|
||||||
assert body["count"] > 0
|
assert body["count"] > 0
|
||||||
after_first = await _instance_count(database)
|
after_first = await _instance_count(database)
|
||||||
assert after_first == body["count"]
|
assert after_first == body["count"] == created_count
|
||||||
|
|
||||||
second = await client.post(
|
second = await client.post(
|
||||||
"/api/v1/admin/regenerate",
|
"/api/v1/admin/regenerate",
|
||||||
|
|||||||
@@ -151,12 +151,17 @@ async def _seed_missed_household(
|
|||||||
database, yesterday_iso, yesterday_iso, today=yesterday
|
database, yesterday_iso, yesterday_iso, today=yesterday
|
||||||
)
|
)
|
||||||
# Today's quest: created after the yesterday walk so it has no
|
# 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(
|
await _quest_definitions.create_quest_definition(
|
||||||
database,
|
database,
|
||||||
"Recoverable chore",
|
"Recoverable chore",
|
||||||
_recurrence.ScheduleRule.from_dict(
|
_recurrence.ScheduleRule.from_dict(
|
||||||
{"rule_type": "daily", "start_date": today_iso}
|
{
|
||||||
|
"rule_type": "daily",
|
||||||
|
"start_date": today_iso,
|
||||||
|
"end_date": today_iso,
|
||||||
|
}
|
||||||
),
|
),
|
||||||
[ada.id],
|
[ada.id],
|
||||||
[("afternoon", "17:00")],
|
[("afternoon", "17:00")],
|
||||||
|
|||||||
@@ -149,13 +149,18 @@ async def _seed_household(
|
|||||||
(today + datetime.timedelta(days=3)).isoformat(),
|
(today + datetime.timedelta(days=3)).isoformat(),
|
||||||
today=today,
|
today=today,
|
||||||
)
|
)
|
||||||
# The stale quest is created AFTER the today walk, so its only
|
# The stale quest's rule ends YESTERDAY, so create's own
|
||||||
# instance is the yesterday one written below.
|
# regeneration writes nothing and its only instance is the
|
||||||
|
# yesterday one written below.
|
||||||
await _quest_definitions.create_quest_definition(
|
await _quest_definitions.create_quest_definition(
|
||||||
database,
|
database,
|
||||||
"Stale chore",
|
"Stale chore",
|
||||||
_recurrence.ScheduleRule.from_dict(
|
_recurrence.ScheduleRule.from_dict(
|
||||||
{"rule_type": "daily", "start_date": yesterday_iso}
|
{
|
||||||
|
"rule_type": "daily",
|
||||||
|
"start_date": yesterday_iso,
|
||||||
|
"end_date": yesterday_iso,
|
||||||
|
}
|
||||||
),
|
),
|
||||||
[ada.id],
|
[ada.id],
|
||||||
[("morning", "09:00")],
|
[("morning", "09:00")],
|
||||||
|
|||||||
@@ -102,15 +102,20 @@ async def _seed_missed_household(
|
|||||||
database, "Old chore", rule, [ada.id], [("evening", "18:30")]
|
database, "Old chore", rule, [ada.id], [("evening", "18:30")]
|
||||||
)
|
)
|
||||||
await materialize(database, yesterday_iso, yesterday_iso, today=yesterday)
|
await materialize(database, yesterday_iso, yesterday_iso, today=yesterday)
|
||||||
# A quest due TODAY (created after the yesterday walk so it has
|
# A quest due TODAY only (created after the yesterday walk so it
|
||||||
# no past-due instance): the post-downtime catch-up run's
|
# 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
|
# target — inside [watermark=today, later_today) but not
|
||||||
# inside the day-one sweep's window.
|
# inside the day-one sweep's window.
|
||||||
await create_quest_definition(
|
await create_quest_definition(
|
||||||
database,
|
database,
|
||||||
"Recoverable chore",
|
"Recoverable chore",
|
||||||
ScheduleRule.from_dict(
|
ScheduleRule.from_dict(
|
||||||
{"rule_type": "daily", "start_date": today_iso}
|
{
|
||||||
|
"rule_type": "daily",
|
||||||
|
"start_date": today_iso,
|
||||||
|
"end_date": today_iso,
|
||||||
|
}
|
||||||
),
|
),
|
||||||
[ada.id],
|
[ada.id],
|
||||||
[("afternoon", "17:00")],
|
[("afternoon", "17:00")],
|
||||||
|
|||||||
@@ -468,7 +468,11 @@ async def _materialize_with_change(
|
|||||||
proceeds with the snapped presence). This deterministically
|
proceeds with the snapped presence). This deterministically
|
||||||
exercises the snapshot-to-insert gap that a config edit can
|
exercises the snapshot-to-insert gap that a config edit can
|
||||||
interleave into.
|
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
|
original_upsert = QuestInstancesDao.upsert_if_valid
|
||||||
entered = asyncio.Event()
|
entered = asyncio.Event()
|
||||||
release = asyncio.Event()
|
release = asyncio.Event()
|
||||||
|
|||||||
@@ -168,6 +168,42 @@ def test_create_several_children_several_windows_with_due_times(
|
|||||||
_with_db(tmp_path, "create-many.db")(_body)
|
_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
|
# create_quest_definition: argument rejections
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
|
|||||||
Reference in New Issue
Block a user