mirror of
https://github.com/kuhyx/diet-guard.git
synced 2026-11-11 14:27:39 +01:00
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
229 lines
8.7 KiB
Python
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
|