diet-guard/diet_guard/tests/test_gatelock_dashboard.py
Krzysztof kuhy Rudnicki c7371d7e0d perf(gate): unfreeze "Fetch from sync" and bound the interactive timeout
The button ran a full sync tick inline on the Tk thread, so the fullscreen
lock was dead for its whole duration -- ~18-27s measured against the live
remote, and unbounded in the worst case.

Two changes, and the second is what makes the first cheap:

* the pull moves to a daemon worker thread, with the result marshalled back
  through a queue polled on the Tk thread. The worker touches no widget, by
  construction: Tcl is not thread-safe and that includes `after`. Polling
  also makes the completion path testable with no thread at all.
* the button now runs the *narrow* pull. Its question is identical to the
  gate's -- "did a peer log this slot?" -- and `_reconcile_after_fetch`
  reads only `due_slots()`, so the budget and food banks it used to fetch
  were never consulted here. The full tick still runs after the window
  closes. Measured 84ms against the live remote.

Narrowing also shrinks the write race from tens of seconds to imperceptible:
the pull persists its merge, so a meal logged mid-fetch could be overwritten
by a snapshot taken before it. A second click while a fetch is in flight is
now ignored, which is the reentrancy guard and the race fix in one.

That guard started out as a disabled button, which is how it surfaced a
latent bug worth recording: the fetch button widget is never stored on
`GateWidgets`, so `self._widgets.fetch_button` did not exist and the disable
was a silent no-op -- hidden because the suppression tuple included
`AttributeError`. Coverage would have reported 100% on a guard that did
nothing. Guarding on `_fetch_result` needs no widget at all.

Also adds `INTERACTIVE_TIMEOUT_SECONDS = 2.0` for the paths a user waits on.
`SYNC_TIMEOUT_SECONDS` only ever reached GitHub: crdt_sync v0.6.0's
`firebase_client_for` takes no timeout, so Firebase used its 15s default and
one hung session refresh stalled the lock window -- the journal has five such
refresh failures in five days. Verified to fail closed to the local decision.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LJ5YnA3CERitQ57HjMu2Qz
2026-08-21 13:07:37 +02:00

229 lines
8.7 KiB
Python

"""Tests for the nutrition model, lookup, and meal-building flow of MealGate.
Covers :mod:`._gatelock_nutrition` (reference -> total maths, suggestions,
unit toggling) and :mod:`._gatelock_mealflow` (submit/lookup/record, the
dashboard, and multi-item meals). The functional fake ``tk`` widgets and the
``gate`` fixture live in ``conftest.py`` and are shared with
:mod:`test_gatelock`.
"""
from __future__ import annotations
from typing import TYPE_CHECKING, cast
from unittest.mock import MagicMock, patch
from diet_guard import _gatelock_mealflow
from diet_guard._budget import write_budget
from diet_guard._state import log_meal
from diet_guard.tests.conftest import _nutrition
if TYPE_CHECKING:
from diet_guard._gatelock import MealGate
class TestDashboard:
"""The running calorie/macro panel."""
def test_headline_with_budget(self, gate: MealGate) -> None:
"""A sealed budget shows consumed/target/remaining."""
write_budget(2000)
gate._refresh_dashboard()
assert "left" in gate._vars.cal_headline.get()
def test_headline_without_budget(self, gate: MealGate) -> None:
"""With no budget, only today's total is shown."""
gate._refresh_dashboard()
assert "kcal today" in gate._vars.cal_headline.get()
def test_dashboard_lists_entries(self, gate: MealGate) -> None:
"""Logged entries appear in the detail panel."""
write_budget(2000, weight_kg=80)
log_meal("apple", _nutrition(95, 100), 8)
gate._refresh_dashboard()
text = gate._vars.dashboard.get()
assert "apple" in text
assert "protein" in text
def test_dashboard_empty(self, gate: MealGate) -> None:
"""With nothing logged, the panel says so."""
gate._refresh_dashboard()
assert "nothing logged yet" in gate._vars.dashboard.get()
def test_slot_header_variants(self, gate: MealGate) -> None:
"""The header covers none / one / several pending slots."""
gate._pending = []
gate._refresh_slot_header()
assert "All meals logged" in gate._vars.slot_header.get()
gate._pending = [8]
gate._refresh_slot_header()
assert "Log your" in gate._vars.slot_header.get()
gate._pending = [8, 12]
gate._refresh_slot_header()
assert "remaining" in gate._vars.slot_header.get()
def test_projection_with_budget(self, gate: MealGate) -> None:
"""The projection shows the after-this-item remaining when priced."""
write_budget(2000)
gate._widgets.macros.kcal.insert(0, "300")
gate._refresh_projection()
assert "after this item" in gate._vars.projection.get()
class TestSlotWalk:
"""Slot tagging and the per-slot input reset."""
def test_slot_for_log_demo_is_none(self, gate: MealGate) -> None:
"""A demo gate tags logs with no real slot."""
gate._pending = [8]
assert gate._slot_for_log() is None
def test_slot_for_log_production_is_slot(self, gate: MealGate) -> None:
"""A production gate tags logs with the current slot."""
gate.demo_mode = False
gate._pending = [12]
assert gate._slot_for_log() == 12
def test_clear_inputs_resets_the_form(self, gate: MealGate) -> None:
"""Clearing between slots empties the description and the macros."""
gate._set_desc("salad")
gate._widgets.macros.kcal.insert(0, "80")
gate._clear_inputs()
assert gate._get_desc() == ""
assert gate._widgets.macros.kcal.get() == ""
def test_finish_slot_unlocks_on_last(self, gate: MealGate) -> None:
"""Finishing the final slot triggers unlock."""
gate._pending = [20]
with patch.object(gate, "_unlock") as unlock:
gate._finish_slot("done")
unlock.assert_called_once()
def _drive_fetch(gate: MealGate) -> None:
"""Run a started fetch to completion, deterministically.
``_on_fetch_sync`` now hands the pull to a worker thread and schedules a
poll via ``root.after``; under test ``after`` is a MagicMock, so nothing
would ever pick the result up. Joining on the queue and calling the poll
directly keeps these assertions synchronous without a sleep.
"""
result = gate._fetch_result
assert result is not None, "no fetch was started"
result.put(result.get(timeout=5))
gate._poll_fetch()
class TestFetchFromSync:
"""The manual "Fetch from sync" button on the lock screen."""
def test_demo_mode_does_not_sync(self, gate: MealGate) -> None:
"""In demo the button is inert and never touches the network."""
with patch.object(_gatelock_mealflow, "pull_peer_logs") as pull:
gate._on_fetch_sync()
pull.assert_not_called()
assert "only available on the real lock" in gate._vars.status.get()
def test_pull_failure_keeps_lock(self, gate: MealGate) -> None:
"""A failed pull shows the reason and leaves pending slots intact."""
gate.demo_mode = False
gate._pending = [8, 12]
with patch.object(
_gatelock_mealflow,
"pull_peer_logs",
return_value="sync unavailable (x)",
):
gate._on_fetch_sync()
_drive_fetch(gate)
assert gate._pending == [8, 12]
assert "still locked" in gate._vars.status.get()
def test_no_new_meals_keeps_all_slots(self, gate: MealGate) -> None:
"""A clean pull that satisfies nothing reports so and keeps the slots."""
gate.demo_mode = False
gate._pending = [8, 12]
with (
patch.object(_gatelock_mealflow, "pull_peer_logs", return_value=None),
patch.object(_gatelock_mealflow, "due_slots", return_value=(8, 12)),
):
gate._on_fetch_sync()
_drive_fetch(gate)
assert gate._pending == [8, 12]
assert "No new meals" in gate._vars.status.get()
def test_partial_advances_to_next_slot(self, gate: MealGate) -> None:
"""One slot pulled in leaves the rest; the window advances (singular)."""
gate.demo_mode = False
gate._pending = [8, 12]
with (
patch.object(_gatelock_mealflow, "pull_peer_logs", return_value=None),
patch.object(_gatelock_mealflow, "due_slots", return_value=(12,)),
):
gate._on_fetch_sync()
_drive_fetch(gate)
assert gate._pending == [12]
assert "Pulled 1 meal " in gate._vars.status.get()
def test_partial_plural_wording(self, gate: MealGate) -> None:
"""Two slots pulled in uses the plural 'meals'."""
gate.demo_mode = False
gate._pending = [8, 12, 16]
with (
patch.object(_gatelock_mealflow, "pull_peer_logs", return_value=None),
patch.object(_gatelock_mealflow, "due_slots", return_value=(16,)),
):
gate._on_fetch_sync()
_drive_fetch(gate)
assert gate._pending == [16]
assert "Pulled 2 meals" in gate._vars.status.get()
def test_all_satisfied_unlocks(self, gate: MealGate) -> None:
"""When the pull satisfies every pending slot, the gate unlocks."""
gate.demo_mode = False
gate._pending = [8, 12]
with (
patch.object(_gatelock_mealflow, "pull_peer_logs", return_value=None),
patch.object(_gatelock_mealflow, "due_slots", return_value=()),
):
gate._on_fetch_sync()
_drive_fetch(gate)
assert gate._pending == []
assert "unlocking" in gate._vars.status.get()
def test_poll_without_a_fetch_is_inert(self, gate: MealGate) -> None:
"""A stray poll after completion must stop, not block on an empty queue."""
gate._fetch_result = None
gate._poll_fetch() # must simply return
assert gate._fetch_result is None
def test_poll_rearms_while_the_worker_is_still_running(
self, gate: MealGate
) -> None:
"""An empty queue means "not finished yet" -- reschedule, do not block."""
import queue as _queue
gate.demo_mode = False
gate._fetch_result = _queue.Queue()
gate._poll_fetch()
after = cast("MagicMock", gate.root.after)
after.assert_called_with(_gatelock_mealflow.FETCH_POLL_MS, gate._poll_fetch)
assert gate._fetch_result is not None
def test_a_second_click_while_fetching_is_ignored(self, gate: MealGate) -> None:
"""Two workers would race two log writes and orphan one result."""
gate.demo_mode = False
with patch.object(
_gatelock_mealflow, "pull_peer_logs", return_value=None
) as pull:
gate._on_fetch_sync()
first = gate._fetch_result
gate._on_fetch_sync() # second click, still in flight
assert gate._fetch_result is first
_drive_fetch(gate)
assert pull.call_count == 1