Compute was_on_time at completion (Feature 08, task 5) (#92)

This commit was merged in pull request #92.
This commit is contained in:
2026-09-18 07:02:29 -04:00
parent c3495bb277
commit 2ec016ebfe
2 changed files with 266 additions and 6 deletions
+201
View File
@@ -687,6 +687,207 @@ def test_complete_instance_concurrent_second_call_is_noop(tmp_path) -> None:
_run(_main())
_HA_TZ = datetime.timezone(datetime.timedelta(hours=-8))
def _ha_local(day: datetime.date, hour: int, minute: int = 0) -> datetime.datetime:
return datetime.datetime(
day.year, day.month, day.day, hour, minute, 0,
tzinfo=_HA_TZ,
)
def test_complete_instance_on_due_date_before_due_time_is_on_time(
tmp_path,
) -> None:
async def _body(database, child, instance):
due_date = datetime.date.fromisoformat(instance.due_date)
instance = await QuestInstancesDao(database).upsert(
instance.definition_id,
child.id,
instance.due_date,
_now_stamp(),
window=instance.window,
due_time="12:00",
)
await complete_instance(
database,
instance.id,
actor_source="user",
actor_user_id="user-1",
now=_ha_local(due_date, 11, 0),
today=due_date,
)
rows = await CompletionEventsDao(database).list_by_instance(
instance.id
)
assert rows[0].was_on_time is True
_with_db(tmp_path, "ontime-before-due-time.db")(_body)
def test_complete_instance_on_due_date_after_due_time_is_late(
tmp_path,
) -> None:
async def _body(database, child, instance):
due_date = datetime.date.fromisoformat(instance.due_date)
instance = await QuestInstancesDao(database).upsert(
instance.definition_id,
child.id,
instance.due_date,
_now_stamp(),
window=instance.window,
due_time="12:00",
)
await complete_instance(
database,
instance.id,
actor_source="user",
actor_user_id="user-1",
now=_ha_local(due_date, 12, 1),
today=due_date,
)
rows = await CompletionEventsDao(database).list_by_instance(
instance.id
)
assert rows[0].was_on_time is False
_with_db(tmp_path, "late-after-due-time.db")(_body)
def test_complete_instance_microsecond_after_due_time_is_late(tmp_path) -> None:
async def _body(database, child, instance):
due_date = datetime.date.fromisoformat(instance.due_date)
instance = await QuestInstancesDao(database).upsert(
instance.definition_id,
child.id,
instance.due_date,
_now_stamp(),
window=instance.window,
due_time="12:00",
)
now = datetime.datetime(
due_date.year, due_date.month, due_date.day, 12, 0, 0, 500,
tzinfo=_HA_TZ,
)
await complete_instance(
database,
instance.id,
actor_source="user",
actor_user_id="user-1",
now=now,
today=due_date,
)
rows = await CompletionEventsDao(database).list_by_instance(
instance.id
)
assert rows[0].was_on_time is False
_with_db(tmp_path, "late-microsecond-after-due-time.db")(_body)
def test_complete_instance_no_due_time_on_due_date_is_on_time(
tmp_path,
) -> None:
async def _body(database, child, instance):
due_date = datetime.date.fromisoformat(instance.due_date)
assert instance.due_time is None
await complete_instance(
database,
instance.id,
actor_source="user",
actor_user_id="user-1",
now=_ha_local(due_date, 23, 59),
today=due_date,
)
rows = await CompletionEventsDao(database).list_by_instance(
instance.id
)
assert rows[0].was_on_time is True
_with_db(tmp_path, "ontime-no-due-time.db")(_body)
def test_complete_instance_no_due_time_after_due_date_is_late(
tmp_path,
) -> None:
async def _body(database, child, instance):
due_date = datetime.date.fromisoformat(instance.due_date)
later = due_date + datetime.timedelta(days=1)
assert instance.due_time is None
await complete_instance(
database,
instance.id,
actor_source="user",
actor_user_id="user-1",
now=_ha_local(later, 8, 0),
today=later,
)
rows = await CompletionEventsDao(database).list_by_instance(
instance.id
)
assert rows[0].was_on_time is False
_with_db(tmp_path, "late-after-due-date.db")(_body)
def test_complete_instance_ha_local_wall_clock_not_utc(tmp_path) -> None:
"""HA-local 11:00 with due_time 12:00 is on time even when UTC is 19:00.
The helper's zone is UTC-8, so 11:00 local is 19:00 UTC. Converting
to UTC before comparing would mark this late.
"""
async def _body(database, child, instance):
due_date = datetime.date.fromisoformat(instance.due_date)
instance = await QuestInstancesDao(database).upsert(
instance.definition_id,
child.id,
instance.due_date,
_now_stamp(),
window=instance.window,
due_time="12:00",
)
now = _ha_local(due_date, 11, 0)
assert now.astimezone(datetime.timezone.utc).hour == 19
await complete_instance(
database,
instance.id,
actor_source="user",
actor_user_id="user-1",
now=now,
today=due_date,
)
rows = await CompletionEventsDao(database).list_by_instance(
instance.id
)
assert rows[0].was_on_time is True
_with_db(tmp_path, "ontime-ha-local-not-utc.db")(_body)
def test_complete_instance_due_time_requires_now_on_due_date(tmp_path) -> None:
async def _body(database, child, instance):
due_date = datetime.date.fromisoformat(instance.due_date)
instance = await QuestInstancesDao(database).upsert(
instance.definition_id,
child.id,
instance.due_date,
_now_stamp(),
window=instance.window,
due_time="12:00",
)
with pytest.raises(ValueError, match="now is required to compare due_time"):
await complete_instance(
database,
instance.id,
actor_source="user",
actor_user_id="user-1",
today=due_date,
)
_with_db(tmp_path, "due-time-requires-now.db")(_body)
# ---------------------------------------------------------------------------
# uncomplete_instance: append uncompleted, no-op if already open
# ---------------------------------------------------------------------------