diff --git a/custom_components/nestquest/completion.py b/custom_components/nestquest/completion.py index 3f22089..15aa11c 100644 --- a/custom_components/nestquest/completion.py +++ b/custom_components/nestquest/completion.py @@ -8,8 +8,10 @@ DAO or SQL directly. This module is the only Feature-08 caller of instance (no-op if already done) is :func:`complete_instance`; un-completing (no-op if already open) is :func:`uncomplete_instance`. Instance current state is derived from the latest event via -:func:`instance_state` (``open`` / ``done``); there is no status -column. +:func:`instance_state` (``open`` / ``done`` / ``missed``); there is +no status column. ``missed`` is derived for past-due open instances +(``due_date`` before HA-local ``today``) and is never stored as an +event_type. Actor policy matches the schema CHECKs (D-008): ``actor_source`` is ``'user'`` or ``'panel'`` only — never ``'service'``. ``'user'`` @@ -95,6 +97,37 @@ def _completion_moment( return completion_date, now.time() +def _resolve_today(today: datetime.date | None) -> datetime.date: + """Return the threaded HA-local date, or the host calendar date.""" + if today is None: + return datetime.date.today() + return today + + +def _derived_state( + instance: QuestInstanceRecord, + latest: CompletionEventRecord | None, + today: datetime.date, +) -> str: + """Return ``open``, ``done``, or ``missed`` from event plus due date. + + A latest completed event is ``done`` even when past due. No events + or a latest uncompleted event is ``open``, or ``missed`` when + ``due_date`` is before ``today``. ``missed`` is never an event_type. + """ + if latest is None or latest.event_type == EVENT_UNCOMPLETED: + due_date = datetime.date.fromisoformat(instance.due_date) + if due_date < today: + return "missed" + return "open" + if latest.event_type == EVENT_COMPLETED: + return "done" + raise ValueError( + f"event_type must be '{EVENT_COMPLETED}' or " + f"'{EVENT_UNCOMPLETED}', got {latest.event_type!r}" + ) + + def _was_on_time( instance: QuestInstanceRecord, now: datetime.datetime | None, @@ -291,15 +324,21 @@ async def append_event( async def instance_state( database: NestQuestDatabase, instance_id: int, + *, + today: datetime.date | None = None, ) -> str: - """Return ``open`` or ``done`` from the instance's latest event. + """Return ``open``, ``done``, or ``missed`` from the latest event. - No events, or a latest event of uncompleted, is ``open``. A latest - event of completed is ``done``. Unknown ``instance_id`` raises - ValueError naming the field. Existence and the latest event are - read inside the connection lock so the answer is one snapshot. + No events, or a latest event of uncompleted, is ``open`` unless + ``due_date`` is before ``today`` (HA-local, threaded), in which + case it is ``missed``. A latest event of completed is ``done`` + even when past due. ``missed`` is never stored as an event_type. + Unknown ``instance_id`` raises ValueError naming the field. + Existence and the latest event are read inside the connection lock + so the answer is one snapshot. """ instance_id = _validate_int_id(instance_id, "instance_id") + today_date = _resolve_today(today) async with _connection_lock(database): instance = await QuestInstancesDao(database).get_by_id(instance_id) if instance is None: @@ -309,14 +348,7 @@ async def instance_state( latest = await CompletionEventsDao(database).get_latest_for_instance( instance_id ) - if latest is None or latest.event_type == EVENT_UNCOMPLETED: - return "open" - if latest.event_type == EVENT_COMPLETED: - return "done" - raise ValueError( - f"event_type must be '{EVENT_COMPLETED}' or " - f"'{EVENT_UNCOMPLETED}', got {latest.event_type!r}" - ) + return _derived_state(instance, latest, today_date) async def complete_instance( @@ -385,19 +417,23 @@ async def uncomplete_instance( actor_user_id: str | None = None, actor_child_id: int | None = None, now: datetime.datetime | None = None, + today: datetime.date | None = None, ) -> str: """Append an uncompleted event unless the instance is already open. - Returns the derived state ``open``. An already-open instance is a - no-op: nothing is appended. Unknown ``instance_id`` raises - ValueError naming the field. Existence, latest-event, and the - append run under one connection lock so a concurrent uncomplete - cannot double-append. The original completed row is not modified. + Returns the derived state ``open``, or ``missed`` when the instance + is past due (``due_date`` before HA-local ``today``). An + already-open instance is a no-op: nothing is appended. Unknown + ``instance_id`` raises ValueError naming the field. Existence, + latest-event, and the append run under one connection lock so a + concurrent uncomplete cannot double-append. The original completed + row is not modified. """ instance_id = _validate_int_id(instance_id, "instance_id") actor_source, actor_user_id, actor_child_id = _validate_actor( actor_source, actor_user_id, actor_child_id ) + today_date = _resolve_today(today) async with _reentrant_connection_lock(database): instance = await QuestInstancesDao(database).get_by_id(instance_id) if instance is None: @@ -408,13 +444,13 @@ async def uncomplete_instance( instance_id ) if latest is None or latest.event_type == EVENT_UNCOMPLETED: - return "open" + return _derived_state(instance, latest, today_date) if latest.event_type != EVENT_COMPLETED: raise ValueError( f"event_type must be '{EVENT_COMPLETED}' or " f"'{EVENT_UNCOMPLETED}', got {latest.event_type!r}" ) - await append_event( + latest = await append_event( database, instance.id, instance.child_id, @@ -425,4 +461,35 @@ async def uncomplete_instance( was_on_time=None, now=now, ) - return "open" + return _derived_state(instance, latest, today_date) + + +async def list_missed_for_child( + database: NestQuestDatabase, + child_id: int, + range_start: str, + range_end: str, + *, + today: datetime.date | None = None, +) -> list[QuestInstanceRecord]: + """Return the child's missed instances due in the closed date range. + + Missed means the latest event state is open and ``due_date`` is + before ``today`` (HA-local, threaded). Uses + :meth:`QuestInstancesDao.list_by_date_range` plus derived state. + Never appends a missed event. Instances and latest events are + read inside the connection lock so the list is one snapshot. + """ + child_id = _validate_int_id(child_id, "child_id") + today_date = _resolve_today(today) + async with _connection_lock(database): + instances = await QuestInstancesDao(database).list_by_date_range( + child_id, range_start, range_end + ) + events = CompletionEventsDao(database) + missed: list[QuestInstanceRecord] = [] + for instance in instances: + latest = await events.get_latest_for_instance(instance.id) + if _derived_state(instance, latest, today_date) == "missed": + missed.append(instance) + return missed diff --git a/tests/test_completion.py b/tests/test_completion.py index da2493e..a5641f0 100644 --- a/tests/test_completion.py +++ b/tests/test_completion.py @@ -12,6 +12,7 @@ from custom_components.nestquest.completion import ( append_event, complete_instance, instance_state, + list_missed_for_child, uncomplete_instance, ) from custom_components.nestquest.dao_children import ChildrenDao @@ -340,6 +341,7 @@ def test_module_never_exposes_update_or_delete() -> None: assert "instance_state" in public assert "complete_instance" in public assert "uncomplete_instance" in public + assert "list_missed_for_child" in public source = inspect.getsource(completion) assert "UPDATE" not in source assert "DELETE" not in source @@ -1123,3 +1125,239 @@ def test_uncomplete_instance_concurrent_second_call_is_noop(tmp_path) -> None: await database.close() _run(_main()) + + +# --------------------------------------------------------------------------- +# missed: derived for past-due open instances, never stored +# --------------------------------------------------------------------------- + + +def test_append_event_rejects_missed_event_type() -> None: + async def _body(): + with pytest.raises(ValueError, match="event_type"): + await append_event( + object(), + 1, + 1, + "missed", + actor_source="user", + actor_user_id="user-1", + was_on_time=True, + ) + + _run(_body()) + + +def test_instance_state_missed_when_past_due_and_open(tmp_path) -> None: + async def _body(database, child, instance): + due = datetime.date.fromisoformat(instance.due_date) + later = due + datetime.timedelta(days=1) + assert await instance_state(database, instance.id, today=later) == "missed" + rows = await CompletionEventsDao(database).list_by_instance(instance.id) + assert rows == [] + + _with_db(tmp_path, "state-missed-open.db")(_body) + + +def test_instance_state_open_when_due_today(tmp_path) -> None: + async def _body(database, child, instance): + due = datetime.date.fromisoformat(instance.due_date) + assert await instance_state(database, instance.id, today=due) == "open" + + _with_db(tmp_path, "state-open-due-today.db")(_body) + + +def test_instance_state_done_when_past_due_and_completed(tmp_path) -> None: + async def _body(database, child, instance): + due = datetime.date.fromisoformat(instance.due_date) + await complete_instance( + database, + instance.id, + actor_source="user", + actor_user_id="user-1", + today=due, + ) + later = due + datetime.timedelta(days=1) + assert await instance_state(database, instance.id, today=later) == "done" + + _with_db(tmp_path, "state-done-past-due.db")(_body) + + +def test_instance_state_missed_after_uncompleted_when_past_due(tmp_path) -> None: + async def _body(database, child, instance): + due = datetime.date.fromisoformat(instance.due_date) + await append_event( + database, + instance.id, + child.id, + EVENT_COMPLETED, + actor_source="user", + actor_user_id="user-1", + was_on_time=True, + ) + await append_event( + database, + instance.id, + child.id, + EVENT_UNCOMPLETED, + actor_source="user", + actor_user_id="user-1", + ) + later = due + datetime.timedelta(days=1) + assert await instance_state(database, instance.id, today=later) == "missed" + + _with_db(tmp_path, "state-missed-uncompleted.db")(_body) + + +def test_instance_state_missed_uses_host_today_when_unthreaded(tmp_path) -> None: + async def _body(database, child, instance): + yesterday = datetime.date.today() - datetime.timedelta(days=1) + past = await QuestInstancesDao(database).upsert( + instance.definition_id, + child.id, + yesterday.isoformat(), + _now_stamp(), + window="afternoon", + today=yesterday, + ) + assert await instance_state(database, past.id) == "missed" + rows = await CompletionEventsDao(database).list_by_instance(past.id) + assert rows == [] + assert all(row.event_type != "missed" for row in rows) + + _with_db(tmp_path, "state-missed-host-today.db")(_body) + + +def test_uncomplete_past_due_done_returns_missed_not_done(tmp_path) -> None: + async def _body(database, child, instance): + due = datetime.date.fromisoformat(instance.due_date) + await complete_instance( + database, + instance.id, + actor_source="user", + actor_user_id="user-1", + today=due, + ) + later = due + datetime.timedelta(days=1) + assert await instance_state(database, instance.id, today=later) == "done" + state = await uncomplete_instance( + database, + instance.id, + actor_source="user", + actor_user_id="user-1", + today=later, + ) + assert state == "missed" + assert await instance_state(database, instance.id, today=later) == "missed" + rows = await CompletionEventsDao(database).list_by_instance(instance.id) + assert [row.event_type for row in rows] == [ + EVENT_COMPLETED, + EVENT_UNCOMPLETED, + ] + assert all(row.event_type != "missed" for row in rows) + + _with_db(tmp_path, "uncomplete-past-due-missed.db")(_body) + + +def test_uncomplete_past_due_already_open_is_noop_missed(tmp_path) -> None: + async def _body(database, child, instance): + later = datetime.date.fromisoformat(instance.due_date) + datetime.timedelta( + days=1 + ) + events = CompletionEventsDao(database) + assert await events.list_by_instance(instance.id) == [] + state = await uncomplete_instance( + database, + instance.id, + actor_source="user", + actor_user_id="user-1", + today=later, + ) + assert state == "missed" + assert await events.list_by_instance(instance.id) == [] + + _with_db(tmp_path, "uncomplete-past-due-noop.db")(_body) + + +def test_complete_instance_of_missed_returns_done(tmp_path) -> None: + async def _body(database, child, instance): + due = datetime.date.fromisoformat(instance.due_date) + later = due + datetime.timedelta(days=1) + assert await instance_state(database, instance.id, today=later) == "missed" + state = await complete_instance( + database, + instance.id, + actor_source="user", + actor_user_id="user-1", + now=_ha_local(later, 8, 0), + today=later, + ) + assert state == "done" + assert await instance_state(database, instance.id, today=later) == "done" + rows = await CompletionEventsDao(database).list_by_instance(instance.id) + assert len(rows) == 1 + assert rows[0].event_type == EVENT_COMPLETED + assert rows[0].was_on_time is False + + _with_db(tmp_path, "complete-missed.db")(_body) + + +def test_list_missed_for_child_over_date_range(tmp_path) -> None: + async def _body(database, child, instance): + due = datetime.date.fromisoformat(instance.due_date) + later_due = due + datetime.timedelta(days=1) + other = await QuestInstancesDao(database).upsert( + instance.definition_id, + child.id, + later_due.isoformat(), + _now_stamp(), + window="morning", + ) + await complete_instance( + database, + instance.id, + actor_source="user", + actor_user_id="user-1", + today=due, + ) + today = later_due + datetime.timedelta(days=1) + found = await list_missed_for_child( + database, + child.id, + instance.due_date, + later_due.isoformat(), + today=today, + ) + assert [row.id for row in found] == [other.id] + events = CompletionEventsDao(database) + assert await events.list_by_instance(other.id) == [] + done_rows = await events.list_by_instance(instance.id) + assert [row.event_type for row in done_rows] == [EVENT_COMPLETED] + assert all(row.event_type != "missed" for row in done_rows) + + _with_db(tmp_path, "list-missed-range.db")(_body) + + +def test_list_missed_for_child_empty_when_due_today(tmp_path) -> None: + async def _body(database, child, instance): + due = datetime.date.fromisoformat(instance.due_date) + found = await list_missed_for_child( + database, + child.id, + instance.due_date, + instance.due_date, + today=due, + ) + assert found == [] + + _with_db(tmp_path, "list-missed-due-today.db")(_body) + + +def test_list_missed_for_child_rejects_non_integer_id() -> None: + async def _body(): + with pytest.raises(ValueError, match="child_id"): + await list_missed_for_child(object(), "1", D1, D1) + with pytest.raises(ValueError, match="child_id"): + await list_missed_for_child(object(), True, D1, D1) + + _run(_body())