mirror of
https://github.com/kuhyx/diet-guard.git
synced 2026-11-10 19:27:14 +01:00
Split the three gate test files; assert fake_tk really swaps tkinter
Seventh batch of the 250-line sweep (over-cap count 31 -> 28). The test files mirror the source split from the previous commit one-for-one: test_gatelock_mealflow.py 379 -> 227 (+ test_gatelock_dashboard.py) test_gatelock_fanout.py 335 -> 128 (+ test_gatelock_groups.py) test_gatelock.py 331 -> 224 (+ test_gatelock_form.py) `test_gatelock_dashboard.py` follows `_gatelock_dashboard`/the slot walk and manual sync; `test_gatelock_groups.py` takes the group primitives that `_gatelock_widgetgroups` now owns; `test_gatelock_form.py` takes the form and basis/amount cases. Also closes the remaining half of the fake-tk fail-open. The previous commit's canary proved every widget-building module is *named* in `_GATE_TK_MODULES`; it could not prove the patch installs something that is not real tkinter. If `_FAKE_TK` were imported from the wrong place after the coming `_tk_fakes.py` split, every module would still be "patched" and the suite would still pass while building real widgets. `test_fake_tk_actually_replaces_real_tkinter` now asserts `module.tk is _FAKE_TK` for every entry *inside* the `fake_tk()` context, plus both `ttk` fakes and restoration afterwards. Verified it fails closed by making one module skip the patch: it reported exactly that module. Verified: 704 Python tests / 100% branch coverage, check_patch_targets 0, pre-commit clean, ~/.local/share/diet_guard byte-identical (gate timer paused for the batch). Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01RA9ynjqXPnexRQnR5oAuCC
This commit is contained in:
parent
e8f0ecaae1
commit
8f2d1011c7
@ -2,7 +2,7 @@ name: diet_guard_app
|
||||
description: "Companion phone app for diet_guard: log meals on the go."
|
||||
publish_to: 'none'
|
||||
|
||||
version: 1.0.19+1
|
||||
version: 1.0.20+1
|
||||
|
||||
environment:
|
||||
sdk: ^3.12.2
|
||||
|
||||
@ -17,8 +17,11 @@ from __future__ import annotations
|
||||
|
||||
import ast
|
||||
import pathlib
|
||||
import tkinter as tk
|
||||
|
||||
from diet_guard.tests._gate_fixtures import _GATE_TK_MODULES
|
||||
from diet_guard import _gatelock_calendar, _gatelock_calendar_ui, _gatelock_ui
|
||||
from diet_guard.tests._gate_fixtures import _GATE_TK_MODULES, fake_tk
|
||||
from diet_guard.tests._tk_fakes import _FAKE_TK, _FAKE_TTK
|
||||
|
||||
#: Modules that bind ``tk`` but build no part of the gate's widget tree, so
|
||||
#: they never need the shared fake. Each is exempt for a stated reason -- this
|
||||
@ -117,3 +120,28 @@ def test_no_widgets_declarations_are_live() -> None:
|
||||
"stale _NO_WIDGETS entries (no longer bind `tk`): "
|
||||
f"{sorted(_NO_WIDGETS - importers)}"
|
||||
)
|
||||
|
||||
|
||||
def test_fake_tk_actually_replaces_real_tkinter() -> None:
|
||||
"""Inside ``fake_tk()``, every listed module's ``tk`` really is the fake.
|
||||
|
||||
The list-completeness test above proves each module is *named* in the patch
|
||||
set. It cannot prove the patch swaps in something that is not real tkinter:
|
||||
if ``_FAKE_TK`` were ever imported from the wrong place after a split of
|
||||
``_tk_fakes.py``, or silently became an alias of the real module, every
|
||||
listed module would still be "patched" and the suite would still pass --
|
||||
while building real widgets. So assert the identity directly, inside the
|
||||
context manager, which is the only place it is true.
|
||||
"""
|
||||
assert _FAKE_TK is not tk, "_FAKE_TK must not be the real tkinter module"
|
||||
|
||||
with fake_tk():
|
||||
wrong = [
|
||||
module.__name__ for module in _GATE_TK_MODULES if module.tk is not _FAKE_TK
|
||||
]
|
||||
assert not wrong, f"fake_tk() left real tkinter in place for: {wrong}"
|
||||
assert _gatelock_calendar.ttk is _FAKE_TTK
|
||||
assert _gatelock_calendar_ui.ttk is _FAKE_TTK
|
||||
|
||||
# ...and is restored afterwards, so the fake cannot leak into other tests.
|
||||
assert _gatelock_ui.tk is tk
|
||||
|
||||
@ -18,9 +18,7 @@ import pytest
|
||||
from diet_guard import (
|
||||
_gatelock,
|
||||
_gatelock_support,
|
||||
_gatelock_ui,
|
||||
)
|
||||
from diet_guard._budget import write_budget
|
||||
from diet_guard._gatelock import (
|
||||
MealGate,
|
||||
_pending_slots,
|
||||
@ -30,18 +28,12 @@ from diet_guard._gatelock import (
|
||||
from diet_guard._gatelock_core import _safe_float
|
||||
from diet_guard._gatelock_nutrition import _format_preview
|
||||
from diet_guard._gatelock_support import wait_for_display
|
||||
from diet_guard._gatelock_ui import DEFAULT_PER_GRAMS
|
||||
from diet_guard._portions import DEFAULT_ITEM_GRAMS
|
||||
from diet_guard.tests.conftest import (
|
||||
_FakeTclError,
|
||||
_nutrition,
|
||||
fake_tk,
|
||||
)
|
||||
|
||||
# --------------------------------------------------------------------------
|
||||
# Module-level helpers
|
||||
# --------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestModuleHelpers:
|
||||
"""Pure functions and the single-instance lock."""
|
||||
@ -138,106 +130,6 @@ class TestConstruction:
|
||||
# --------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestFormBasics:
|
||||
"""Field helpers and the numeric validator."""
|
||||
|
||||
def test_numeric_validator(self) -> None:
|
||||
"""Blank and numbers are allowed; words are not."""
|
||||
assert _gatelock_ui.is_numeric_or_blank("")
|
||||
assert _gatelock_ui.is_numeric_or_blank("12.5")
|
||||
assert not _gatelock_ui.is_numeric_or_blank("abc")
|
||||
|
||||
def test_desc_get_set(self, gate: MealGate) -> None:
|
||||
"""The description round-trips through its helpers, trimmed."""
|
||||
gate._set_desc(" shoarma ")
|
||||
assert gate._get_desc() == "shoarma"
|
||||
|
||||
def test_desc_return_suppresses_newline(self, gate: MealGate) -> None:
|
||||
"""Enter in the description submits and returns the break sentinel."""
|
||||
gate._set_desc("apple")
|
||||
with patch.object(gate, "_on_submit") as submit:
|
||||
assert gate._on_desc_return(None) == "break"
|
||||
submit.assert_called_once()
|
||||
|
||||
def test_macro_values_non_numeric(self, gate: MealGate) -> None:
|
||||
"""A non-numeric macro field makes the whole read None."""
|
||||
gate._widgets.macros.kcal.insert(0, "abc")
|
||||
assert gate._macro_values() is None
|
||||
|
||||
|
||||
class TestBasisAndAmount:
|
||||
"""Edge branches in the grams/items basis and amount maths."""
|
||||
|
||||
def test_basis_typed_value(self, gate: MealGate) -> None:
|
||||
"""A typed per-value is honoured directly."""
|
||||
gate._set_entry(gate._widgets.per_entry, "50")
|
||||
assert gate._basis_grams() == 50
|
||||
|
||||
def test_basis_items_known_staple(self, gate: MealGate) -> None:
|
||||
"""Items mode with a blank per falls back to the staple weight."""
|
||||
gate._widgets.per_entry.delete(0)
|
||||
gate._vars.unit.set("items")
|
||||
gate._set_desc("apple")
|
||||
assert gate._basis_grams() == 182
|
||||
|
||||
def test_basis_items_unknown(self, gate: MealGate) -> None:
|
||||
"""An unknown item uses the default piece weight."""
|
||||
gate._widgets.per_entry.delete(0)
|
||||
gate._vars.unit.set("items")
|
||||
gate._set_desc("mystery")
|
||||
assert gate._basis_grams() == DEFAULT_ITEM_GRAMS
|
||||
|
||||
def test_basis_grams_default(self, gate: MealGate) -> None:
|
||||
"""Grams mode with a blank per uses the per-100 g default."""
|
||||
gate._widgets.per_entry.delete(0)
|
||||
assert gate._basis_grams() == DEFAULT_PER_GRAMS
|
||||
|
||||
def test_eaten_grams_none(self, gate: MealGate) -> None:
|
||||
"""No amount typed yields no eaten weight."""
|
||||
assert gate._eaten_grams() is None
|
||||
|
||||
def test_eaten_grams_items(self, gate: MealGate) -> None:
|
||||
"""Items mode multiplies the count by the per-item weight."""
|
||||
gate._vars.unit.set("items")
|
||||
gate._set_desc("apple")
|
||||
gate._set_entry(gate._widgets.per_entry, "182")
|
||||
gate._set_entry(gate._widgets.amount_entry, "5")
|
||||
assert gate._eaten_grams() == 5 * 182
|
||||
|
||||
def test_amount_change_refreshes(self, gate: MealGate) -> None:
|
||||
"""Changing the amount recomputes the preview."""
|
||||
gate._set_entry(gate._widgets.macros.kcal, "100")
|
||||
gate._set_entry(gate._widgets.amount_entry, "200")
|
||||
gate._on_amount_change(None)
|
||||
assert gate._vars.preview.get()
|
||||
|
||||
def test_projection_else_without_item(self, gate: MealGate) -> None:
|
||||
"""With a budget but no priced item, no after-this-item is shown."""
|
||||
write_budget(2000)
|
||||
gate._refresh_projection()
|
||||
text = gate._vars.projection.get()
|
||||
assert "left" in text
|
||||
assert "after this item" not in text
|
||||
|
||||
def test_keyrelease_grams_mode(self, gate: MealGate) -> None:
|
||||
"""In grams mode the per-item weight is not touched on keyrelease."""
|
||||
gate._vars.unit.set("grams")
|
||||
gate._set_desc("apple")
|
||||
gate._on_desc_keyrelease(None)
|
||||
|
||||
def test_keyrelease_items_unknown(self, gate: MealGate) -> None:
|
||||
"""An unknown item in items mode leaves the per field unchanged."""
|
||||
gate._vars.unit.set("items")
|
||||
gate._set_desc("zzzz")
|
||||
gate._on_desc_keyrelease(None)
|
||||
|
||||
def test_apply_reference_keeps_existing_amount(self, gate: MealGate) -> None:
|
||||
"""A grams-mode pick does not overwrite an amount already typed."""
|
||||
gate._set_entry(gate._widgets.amount_entry, "50")
|
||||
gate._apply_reference(_nutrition(100, 100))
|
||||
assert gate._widgets.amount_entry.get() == "50"
|
||||
|
||||
|
||||
class TestLockDelegation:
|
||||
"""MealGate's gatelock wiring: hooks delegate, run()/close() delegate."""
|
||||
|
||||
|
||||
171
diet_guard/tests/test_gatelock_dashboard.py
Normal file
171
diet_guard/tests/test_gatelock_dashboard.py
Normal file
@ -0,0 +1,171 @@
|
||||
"""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
|
||||
from unittest.mock import 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()
|
||||
|
||||
|
||||
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_shared_log") 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_shared_log",
|
||||
return_value="sync unavailable (x)",
|
||||
):
|
||||
gate._on_fetch_sync()
|
||||
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_shared_log", return_value=None),
|
||||
patch.object(_gatelock_mealflow, "due_slots", return_value=(8, 12)),
|
||||
):
|
||||
gate._on_fetch_sync()
|
||||
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_shared_log", return_value=None),
|
||||
patch.object(_gatelock_mealflow, "due_slots", return_value=(12,)),
|
||||
):
|
||||
gate._on_fetch_sync()
|
||||
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_shared_log", return_value=None),
|
||||
patch.object(_gatelock_mealflow, "due_slots", return_value=(16,)),
|
||||
):
|
||||
gate._on_fetch_sync()
|
||||
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_shared_log", return_value=None),
|
||||
patch.object(_gatelock_mealflow, "due_slots", return_value=()),
|
||||
):
|
||||
gate._on_fetch_sync()
|
||||
assert gate._pending == []
|
||||
assert "unlocking" in gate._vars.status.get()
|
||||
@ -17,12 +17,6 @@ import pytest
|
||||
|
||||
from diet_guard import _gatelock_calendar
|
||||
from diet_guard._gatelock import MealGate
|
||||
from diet_guard._gatelock_groups import (
|
||||
EntryGroup,
|
||||
ListboxGroup,
|
||||
TextGroup,
|
||||
WidgetGroup,
|
||||
)
|
||||
from diet_guard.tests.conftest import (
|
||||
_FAKE_TK,
|
||||
_FAKE_TTK,
|
||||
@ -128,208 +122,3 @@ class TestGateFansOut:
|
||||
dual_gate.teardown_surface(MagicMock(output_name=TWO_OUTPUTS[0].name))
|
||||
|
||||
assert dual_gate._widgets.bundles == [survivor]
|
||||
|
||||
|
||||
class TestGroupsInIsolation:
|
||||
"""The group primitives, without a gate around them."""
|
||||
|
||||
def test_config_reaches_a_dead_copy_without_raising(self) -> None:
|
||||
"""A monitor that vanished mid-update must not break the others."""
|
||||
dead, alive = MagicMock(), MagicMock()
|
||||
dead.configure.side_effect = tk.TclError("bad window path name")
|
||||
|
||||
WidgetGroup([dead, alive]).config(fg="#ff0000")
|
||||
|
||||
alive.configure.assert_called_once_with(fg="#ff0000")
|
||||
|
||||
def test_focus_skips_a_dead_copy(self) -> None:
|
||||
"""Focus is singular: the first live copy takes it."""
|
||||
dead, alive = MagicMock(), MagicMock()
|
||||
dead.focus_set.side_effect = tk.TclError("gone")
|
||||
dead.focus_force.side_effect = tk.TclError("gone")
|
||||
group = WidgetGroup([dead, alive])
|
||||
|
||||
group.focus_set()
|
||||
group.focus_force()
|
||||
|
||||
alive.focus_set.assert_called_once_with()
|
||||
alive.focus_force.assert_called_once_with()
|
||||
|
||||
def test_bind_reaches_every_copy_and_survives_a_dead_one(self) -> None:
|
||||
"""Re-wiring after an output returns must not raise on a dead copy."""
|
||||
dead, alive = MagicMock(), MagicMock()
|
||||
dead.bind.side_effect = tk.TclError("gone")
|
||||
handler = MagicMock()
|
||||
|
||||
WidgetGroup([dead, alive]).bind("<Return>", handler)
|
||||
|
||||
# ``add=""`` is Tk's own spelling for "replace any existing binding",
|
||||
# which is what this gate needs: _wire_events runs once per surface
|
||||
# *and* again on rebuild, so an additive bind would stack a second
|
||||
# handler on every surviving widget and submit the meal twice.
|
||||
alive.bind.assert_called_once_with("<Return>", handler, add="")
|
||||
|
||||
def test_first_on_an_empty_group_raises(self) -> None:
|
||||
"""The gate always builds a copy before reading one.
|
||||
|
||||
gatelock's ``first`` returns ``None`` for the zero-output case, which
|
||||
is right for a library. Here it is re-narrowed to the widget type so
|
||||
the specialized groups can read it without a None check, so the empty
|
||||
case has to be an explicit error rather than a ``None`` that would
|
||||
surface later as an ``AttributeError`` somewhere unrelated.
|
||||
"""
|
||||
with pytest.raises(IndexError, match="empty"):
|
||||
_ = WidgetGroup([]).first
|
||||
|
||||
def test_rebinding_replaces_rather_than_stacking(self) -> None:
|
||||
"""``_wire_events`` runs again on rebuild; it must stay idempotent.
|
||||
|
||||
gatelock's own ``WidgetGroup.bind`` defaults to *additive*, because its
|
||||
callers bind once and must not clobber someone else's handler. This
|
||||
gate is the other case: every re-wire would otherwise leave a second
|
||||
``<Return>`` handler attached and log the meal twice.
|
||||
"""
|
||||
widget = MagicMock()
|
||||
handler = MagicMock()
|
||||
group = WidgetGroup([widget])
|
||||
|
||||
group.bind("<Return>", handler)
|
||||
group.bind("<Return>", handler)
|
||||
|
||||
assert [call.kwargs["add"] for call in widget.bind.call_args_list] == ["", ""]
|
||||
|
||||
def test_focus_when_every_copy_is_gone(self) -> None:
|
||||
"""All surfaces destroyed: nothing to focus, and no exception."""
|
||||
dead = MagicMock()
|
||||
dead.focus_set.side_effect = tk.TclError("gone")
|
||||
dead.focus_force.side_effect = tk.TclError("gone")
|
||||
group = WidgetGroup([dead])
|
||||
|
||||
group.focus_set() # must not raise
|
||||
group.focus_force()
|
||||
|
||||
assert dead.focus_set.called
|
||||
|
||||
def test_entry_group_reads_and_writes_through_the_variable(self) -> None:
|
||||
"""The widgets are never consulted, so copies cannot disagree."""
|
||||
var = MagicMock()
|
||||
var.get.return_value = "42"
|
||||
group = EntryGroup([MagicMock(), MagicMock()], var)
|
||||
|
||||
group.insert(0, "42")
|
||||
value = group.get()
|
||||
group.delete(0, "end")
|
||||
|
||||
assert value == "42"
|
||||
var.set.assert_any_call("42")
|
||||
var.set.assert_called_with("")
|
||||
|
||||
def test_text_group_falls_back_to_the_primary_when_all_blank(self) -> None:
|
||||
"""An untouched form still yields a string for validation to reject."""
|
||||
first, second = MagicMock(), MagicMock()
|
||||
first.get.return_value = ""
|
||||
second.get.return_value = " "
|
||||
|
||||
assert TextGroup([first, second]).get("1.0", "end") == ""
|
||||
|
||||
def test_text_group_skips_a_dead_copy(self) -> None:
|
||||
"""A destroyed monitor's box is stepped over, not fatal."""
|
||||
dead, typed = MagicMock(), MagicMock()
|
||||
dead.get.side_effect = tk.TclError("gone")
|
||||
typed.get.return_value = "porridge"
|
||||
|
||||
assert TextGroup([dead, typed]).get("1.0", "end") == "porridge"
|
||||
|
||||
def test_text_group_writes_survive_a_dead_copy(self) -> None:
|
||||
"""delete/insert reach the live copy even if another has gone."""
|
||||
dead, alive = MagicMock(), MagicMock()
|
||||
dead.delete.side_effect = tk.TclError("gone")
|
||||
dead.insert.side_effect = tk.TclError("gone")
|
||||
group = TextGroup([dead, alive])
|
||||
|
||||
group.delete("1.0", "end")
|
||||
group.insert("1.0", "eggs")
|
||||
|
||||
alive.delete.assert_called_once_with("1.0", "end")
|
||||
alive.insert.assert_called_once_with("1.0", "eggs")
|
||||
|
||||
def test_listbox_selection_comes_from_the_copy_clicked(self) -> None:
|
||||
"""A mouse pick lands on one monitor; that is the one that counts."""
|
||||
unclicked, clicked = MagicMock(), MagicMock()
|
||||
unclicked.curselection.return_value = ()
|
||||
clicked.curselection.return_value = (3,)
|
||||
|
||||
assert ListboxGroup([unclicked, clicked]).curselection() == (3,)
|
||||
|
||||
def test_listbox_selection_empty_everywhere(self) -> None:
|
||||
"""Nothing picked anywhere reads back as no selection."""
|
||||
first, second = MagicMock(), MagicMock()
|
||||
first.curselection.return_value = ()
|
||||
second.curselection.return_value = ()
|
||||
|
||||
assert ListboxGroup([first, second]).curselection() == ()
|
||||
|
||||
def test_listbox_selection_skips_a_dead_copy(self) -> None:
|
||||
"""A destroyed copy does not block reading the live one."""
|
||||
dead, clicked = MagicMock(), MagicMock()
|
||||
dead.curselection.side_effect = tk.TclError("gone")
|
||||
clicked.curselection.return_value = (1,)
|
||||
|
||||
assert ListboxGroup([dead, clicked]).curselection() == (1,)
|
||||
|
||||
def test_listbox_writes_and_size_fan_out(self) -> None:
|
||||
"""Rows are written to every copy; the count reads from the primary."""
|
||||
first, second = MagicMock(), MagicMock()
|
||||
first.size.return_value = 2
|
||||
group = ListboxGroup([first, second])
|
||||
|
||||
group.delete(0, "end")
|
||||
group.insert("end", "a", "b")
|
||||
group.selection_set(1)
|
||||
group.selection_clear(0, "end")
|
||||
|
||||
assert group.size() == 2
|
||||
second.insert.assert_called_once_with("end", "a", "b")
|
||||
second.selection_set.assert_called_once_with(1)
|
||||
second.selection_clear.assert_called_once_with(0, "end")
|
||||
|
||||
def test_listbox_selection_helpers_survive_a_dead_copy(self) -> None:
|
||||
"""A dead copy is stepped over by both selection helpers."""
|
||||
dead, alive = MagicMock(), MagicMock()
|
||||
dead.selection_set.side_effect = tk.TclError("gone")
|
||||
dead.selection_clear.side_effect = tk.TclError("gone")
|
||||
dead.delete.side_effect = tk.TclError("gone")
|
||||
dead.insert.side_effect = tk.TclError("gone")
|
||||
group = ListboxGroup([dead, alive])
|
||||
|
||||
group.selection_set(0, 2)
|
||||
group.selection_clear(0)
|
||||
group.delete(0)
|
||||
group.insert(0, "x")
|
||||
|
||||
alive.selection_set.assert_called_once_with(0, 2)
|
||||
alive.selection_clear.assert_called_once_with(0)
|
||||
|
||||
def test_first_exposes_the_primary_copy(self) -> None:
|
||||
"""Reads that cannot fan out use the primary monitor's widget."""
|
||||
first, second = MagicMock(), MagicMock()
|
||||
|
||||
assert WidgetGroup([first, second]).first is first
|
||||
|
||||
def test_iterating_yields_every_copy(self) -> None:
|
||||
"""Callers that genuinely need each widget can have them."""
|
||||
first, second = MagicMock(), MagicMock()
|
||||
|
||||
assert list(WidgetGroup([first, second])) == [first, second]
|
||||
|
||||
def test_discarding_an_unknown_output_changes_nothing(self) -> None:
|
||||
"""Tearing down an output that was never built is a no-op."""
|
||||
from diet_guard._gatelock_groups import GateWidgetsGroup
|
||||
|
||||
group = GateWidgetsGroup(MagicMock())
|
||||
bundle = MagicMock()
|
||||
group.add(bundle, "DP-0")
|
||||
|
||||
group.discard("HDMI-9")
|
||||
|
||||
assert group.bundles == [bundle]
|
||||
|
||||
129
diet_guard/tests/test_gatelock_form.py
Normal file
129
diet_guard/tests/test_gatelock_form.py
Normal file
@ -0,0 +1,129 @@
|
||||
"""Tests for _gatelock.py — the fullscreen log-to-unlock gate window.
|
||||
|
||||
Construction, MealGate's gatelock wiring (LockConfig choice, hooks), and the
|
||||
shared module-level helpers. The fullscreen/grab/VT-disable mechanics
|
||||
themselves are tested in the ``gatelock`` package, not here. The
|
||||
nutrition/meal-flow tests live in :mod:`test_gatelock_mealflow`; the
|
||||
functional fake ``tk`` widgets and the ``gate`` fixture live in
|
||||
``conftest.py`` and are shared by both files.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import TYPE_CHECKING
|
||||
from unittest.mock import patch
|
||||
|
||||
from diet_guard import (
|
||||
_gatelock_ui,
|
||||
)
|
||||
from diet_guard._budget import write_budget
|
||||
from diet_guard._gatelock_ui import DEFAULT_PER_GRAMS
|
||||
from diet_guard._portions import DEFAULT_ITEM_GRAMS
|
||||
from diet_guard.tests.conftest import (
|
||||
_nutrition,
|
||||
)
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from diet_guard._gatelock import (
|
||||
MealGate,
|
||||
)
|
||||
|
||||
|
||||
class TestFormBasics:
|
||||
"""Field helpers and the numeric validator."""
|
||||
|
||||
def test_numeric_validator(self) -> None:
|
||||
"""Blank and numbers are allowed; words are not."""
|
||||
assert _gatelock_ui.is_numeric_or_blank("")
|
||||
assert _gatelock_ui.is_numeric_or_blank("12.5")
|
||||
assert not _gatelock_ui.is_numeric_or_blank("abc")
|
||||
|
||||
def test_desc_get_set(self, gate: MealGate) -> None:
|
||||
"""The description round-trips through its helpers, trimmed."""
|
||||
gate._set_desc(" shoarma ")
|
||||
assert gate._get_desc() == "shoarma"
|
||||
|
||||
def test_desc_return_suppresses_newline(self, gate: MealGate) -> None:
|
||||
"""Enter in the description submits and returns the break sentinel."""
|
||||
gate._set_desc("apple")
|
||||
with patch.object(gate, "_on_submit") as submit:
|
||||
assert gate._on_desc_return(None) == "break"
|
||||
submit.assert_called_once()
|
||||
|
||||
def test_macro_values_non_numeric(self, gate: MealGate) -> None:
|
||||
"""A non-numeric macro field makes the whole read None."""
|
||||
gate._widgets.macros.kcal.insert(0, "abc")
|
||||
assert gate._macro_values() is None
|
||||
|
||||
|
||||
class TestBasisAndAmount:
|
||||
"""Edge branches in the grams/items basis and amount maths."""
|
||||
|
||||
def test_basis_typed_value(self, gate: MealGate) -> None:
|
||||
"""A typed per-value is honoured directly."""
|
||||
gate._set_entry(gate._widgets.per_entry, "50")
|
||||
assert gate._basis_grams() == 50
|
||||
|
||||
def test_basis_items_known_staple(self, gate: MealGate) -> None:
|
||||
"""Items mode with a blank per falls back to the staple weight."""
|
||||
gate._widgets.per_entry.delete(0)
|
||||
gate._vars.unit.set("items")
|
||||
gate._set_desc("apple")
|
||||
assert gate._basis_grams() == 182
|
||||
|
||||
def test_basis_items_unknown(self, gate: MealGate) -> None:
|
||||
"""An unknown item uses the default piece weight."""
|
||||
gate._widgets.per_entry.delete(0)
|
||||
gate._vars.unit.set("items")
|
||||
gate._set_desc("mystery")
|
||||
assert gate._basis_grams() == DEFAULT_ITEM_GRAMS
|
||||
|
||||
def test_basis_grams_default(self, gate: MealGate) -> None:
|
||||
"""Grams mode with a blank per uses the per-100 g default."""
|
||||
gate._widgets.per_entry.delete(0)
|
||||
assert gate._basis_grams() == DEFAULT_PER_GRAMS
|
||||
|
||||
def test_eaten_grams_none(self, gate: MealGate) -> None:
|
||||
"""No amount typed yields no eaten weight."""
|
||||
assert gate._eaten_grams() is None
|
||||
|
||||
def test_eaten_grams_items(self, gate: MealGate) -> None:
|
||||
"""Items mode multiplies the count by the per-item weight."""
|
||||
gate._vars.unit.set("items")
|
||||
gate._set_desc("apple")
|
||||
gate._set_entry(gate._widgets.per_entry, "182")
|
||||
gate._set_entry(gate._widgets.amount_entry, "5")
|
||||
assert gate._eaten_grams() == 5 * 182
|
||||
|
||||
def test_amount_change_refreshes(self, gate: MealGate) -> None:
|
||||
"""Changing the amount recomputes the preview."""
|
||||
gate._set_entry(gate._widgets.macros.kcal, "100")
|
||||
gate._set_entry(gate._widgets.amount_entry, "200")
|
||||
gate._on_amount_change(None)
|
||||
assert gate._vars.preview.get()
|
||||
|
||||
def test_projection_else_without_item(self, gate: MealGate) -> None:
|
||||
"""With a budget but no priced item, no after-this-item is shown."""
|
||||
write_budget(2000)
|
||||
gate._refresh_projection()
|
||||
text = gate._vars.projection.get()
|
||||
assert "left" in text
|
||||
assert "after this item" not in text
|
||||
|
||||
def test_keyrelease_grams_mode(self, gate: MealGate) -> None:
|
||||
"""In grams mode the per-item weight is not touched on keyrelease."""
|
||||
gate._vars.unit.set("grams")
|
||||
gate._set_desc("apple")
|
||||
gate._on_desc_keyrelease(None)
|
||||
|
||||
def test_keyrelease_items_unknown(self, gate: MealGate) -> None:
|
||||
"""An unknown item in items mode leaves the per field unchanged."""
|
||||
gate._vars.unit.set("items")
|
||||
gate._set_desc("zzzz")
|
||||
gate._on_desc_keyrelease(None)
|
||||
|
||||
def test_apply_reference_keeps_existing_amount(self, gate: MealGate) -> None:
|
||||
"""A grams-mode pick does not overwrite an amount already typed."""
|
||||
gate._set_entry(gate._widgets.amount_entry, "50")
|
||||
gate._apply_reference(_nutrition(100, 100))
|
||||
assert gate._widgets.amount_entry.get() == "50"
|
||||
226
diet_guard/tests/test_gatelock_groups.py
Normal file
226
diet_guard/tests/test_gatelock_groups.py
Normal file
@ -0,0 +1,226 @@
|
||||
"""Per-output fan-out: the point of the gatelock v0.2.0 migration.
|
||||
|
||||
Every other test here runs against the single-output default, where "the gate
|
||||
is on every monitor" and "the gate is on the primary and nowhere else" are
|
||||
indistinguishable. These run on a two-monitor desk, so a loop that only ever
|
||||
touched the first surface fails here and nowhere else.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import tkinter as tk
|
||||
from unittest.mock import MagicMock
|
||||
|
||||
import pytest
|
||||
|
||||
from diet_guard._gatelock_groups import (
|
||||
EntryGroup,
|
||||
ListboxGroup,
|
||||
TextGroup,
|
||||
WidgetGroup,
|
||||
)
|
||||
|
||||
|
||||
class TestGroupsInIsolation:
|
||||
"""The group primitives, without a gate around them."""
|
||||
|
||||
def test_config_reaches_a_dead_copy_without_raising(self) -> None:
|
||||
"""A monitor that vanished mid-update must not break the others."""
|
||||
dead, alive = MagicMock(), MagicMock()
|
||||
dead.configure.side_effect = tk.TclError("bad window path name")
|
||||
|
||||
WidgetGroup([dead, alive]).config(fg="#ff0000")
|
||||
|
||||
alive.configure.assert_called_once_with(fg="#ff0000")
|
||||
|
||||
def test_focus_skips_a_dead_copy(self) -> None:
|
||||
"""Focus is singular: the first live copy takes it."""
|
||||
dead, alive = MagicMock(), MagicMock()
|
||||
dead.focus_set.side_effect = tk.TclError("gone")
|
||||
dead.focus_force.side_effect = tk.TclError("gone")
|
||||
group = WidgetGroup([dead, alive])
|
||||
|
||||
group.focus_set()
|
||||
group.focus_force()
|
||||
|
||||
alive.focus_set.assert_called_once_with()
|
||||
alive.focus_force.assert_called_once_with()
|
||||
|
||||
def test_bind_reaches_every_copy_and_survives_a_dead_one(self) -> None:
|
||||
"""Re-wiring after an output returns must not raise on a dead copy."""
|
||||
dead, alive = MagicMock(), MagicMock()
|
||||
dead.bind.side_effect = tk.TclError("gone")
|
||||
handler = MagicMock()
|
||||
|
||||
WidgetGroup([dead, alive]).bind("<Return>", handler)
|
||||
|
||||
# ``add=""`` is Tk's own spelling for "replace any existing binding",
|
||||
# which is what this gate needs: _wire_events runs once per surface
|
||||
# *and* again on rebuild, so an additive bind would stack a second
|
||||
# handler on every surviving widget and submit the meal twice.
|
||||
alive.bind.assert_called_once_with("<Return>", handler, add="")
|
||||
|
||||
def test_first_on_an_empty_group_raises(self) -> None:
|
||||
"""The gate always builds a copy before reading one.
|
||||
|
||||
gatelock's ``first`` returns ``None`` for the zero-output case, which
|
||||
is right for a library. Here it is re-narrowed to the widget type so
|
||||
the specialized groups can read it without a None check, so the empty
|
||||
case has to be an explicit error rather than a ``None`` that would
|
||||
surface later as an ``AttributeError`` somewhere unrelated.
|
||||
"""
|
||||
with pytest.raises(IndexError, match="empty"):
|
||||
_ = WidgetGroup([]).first
|
||||
|
||||
def test_rebinding_replaces_rather_than_stacking(self) -> None:
|
||||
"""``_wire_events`` runs again on rebuild; it must stay idempotent.
|
||||
|
||||
gatelock's own ``WidgetGroup.bind`` defaults to *additive*, because its
|
||||
callers bind once and must not clobber someone else's handler. This
|
||||
gate is the other case: every re-wire would otherwise leave a second
|
||||
``<Return>`` handler attached and log the meal twice.
|
||||
"""
|
||||
widget = MagicMock()
|
||||
handler = MagicMock()
|
||||
group = WidgetGroup([widget])
|
||||
|
||||
group.bind("<Return>", handler)
|
||||
group.bind("<Return>", handler)
|
||||
|
||||
assert [call.kwargs["add"] for call in widget.bind.call_args_list] == ["", ""]
|
||||
|
||||
def test_focus_when_every_copy_is_gone(self) -> None:
|
||||
"""All surfaces destroyed: nothing to focus, and no exception."""
|
||||
dead = MagicMock()
|
||||
dead.focus_set.side_effect = tk.TclError("gone")
|
||||
dead.focus_force.side_effect = tk.TclError("gone")
|
||||
group = WidgetGroup([dead])
|
||||
|
||||
group.focus_set() # must not raise
|
||||
group.focus_force()
|
||||
|
||||
assert dead.focus_set.called
|
||||
|
||||
def test_entry_group_reads_and_writes_through_the_variable(self) -> None:
|
||||
"""The widgets are never consulted, so copies cannot disagree."""
|
||||
var = MagicMock()
|
||||
var.get.return_value = "42"
|
||||
group = EntryGroup([MagicMock(), MagicMock()], var)
|
||||
|
||||
group.insert(0, "42")
|
||||
value = group.get()
|
||||
group.delete(0, "end")
|
||||
|
||||
assert value == "42"
|
||||
var.set.assert_any_call("42")
|
||||
var.set.assert_called_with("")
|
||||
|
||||
def test_text_group_falls_back_to_the_primary_when_all_blank(self) -> None:
|
||||
"""An untouched form still yields a string for validation to reject."""
|
||||
first, second = MagicMock(), MagicMock()
|
||||
first.get.return_value = ""
|
||||
second.get.return_value = " "
|
||||
|
||||
assert TextGroup([first, second]).get("1.0", "end") == ""
|
||||
|
||||
def test_text_group_skips_a_dead_copy(self) -> None:
|
||||
"""A destroyed monitor's box is stepped over, not fatal."""
|
||||
dead, typed = MagicMock(), MagicMock()
|
||||
dead.get.side_effect = tk.TclError("gone")
|
||||
typed.get.return_value = "porridge"
|
||||
|
||||
assert TextGroup([dead, typed]).get("1.0", "end") == "porridge"
|
||||
|
||||
def test_text_group_writes_survive_a_dead_copy(self) -> None:
|
||||
"""delete/insert reach the live copy even if another has gone."""
|
||||
dead, alive = MagicMock(), MagicMock()
|
||||
dead.delete.side_effect = tk.TclError("gone")
|
||||
dead.insert.side_effect = tk.TclError("gone")
|
||||
group = TextGroup([dead, alive])
|
||||
|
||||
group.delete("1.0", "end")
|
||||
group.insert("1.0", "eggs")
|
||||
|
||||
alive.delete.assert_called_once_with("1.0", "end")
|
||||
alive.insert.assert_called_once_with("1.0", "eggs")
|
||||
|
||||
def test_listbox_selection_comes_from_the_copy_clicked(self) -> None:
|
||||
"""A mouse pick lands on one monitor; that is the one that counts."""
|
||||
unclicked, clicked = MagicMock(), MagicMock()
|
||||
unclicked.curselection.return_value = ()
|
||||
clicked.curselection.return_value = (3,)
|
||||
|
||||
assert ListboxGroup([unclicked, clicked]).curselection() == (3,)
|
||||
|
||||
def test_listbox_selection_empty_everywhere(self) -> None:
|
||||
"""Nothing picked anywhere reads back as no selection."""
|
||||
first, second = MagicMock(), MagicMock()
|
||||
first.curselection.return_value = ()
|
||||
second.curselection.return_value = ()
|
||||
|
||||
assert ListboxGroup([first, second]).curselection() == ()
|
||||
|
||||
def test_listbox_selection_skips_a_dead_copy(self) -> None:
|
||||
"""A destroyed copy does not block reading the live one."""
|
||||
dead, clicked = MagicMock(), MagicMock()
|
||||
dead.curselection.side_effect = tk.TclError("gone")
|
||||
clicked.curselection.return_value = (1,)
|
||||
|
||||
assert ListboxGroup([dead, clicked]).curselection() == (1,)
|
||||
|
||||
def test_listbox_writes_and_size_fan_out(self) -> None:
|
||||
"""Rows are written to every copy; the count reads from the primary."""
|
||||
first, second = MagicMock(), MagicMock()
|
||||
first.size.return_value = 2
|
||||
group = ListboxGroup([first, second])
|
||||
|
||||
group.delete(0, "end")
|
||||
group.insert("end", "a", "b")
|
||||
group.selection_set(1)
|
||||
group.selection_clear(0, "end")
|
||||
|
||||
assert group.size() == 2
|
||||
second.insert.assert_called_once_with("end", "a", "b")
|
||||
second.selection_set.assert_called_once_with(1)
|
||||
second.selection_clear.assert_called_once_with(0, "end")
|
||||
|
||||
def test_listbox_selection_helpers_survive_a_dead_copy(self) -> None:
|
||||
"""A dead copy is stepped over by both selection helpers."""
|
||||
dead, alive = MagicMock(), MagicMock()
|
||||
dead.selection_set.side_effect = tk.TclError("gone")
|
||||
dead.selection_clear.side_effect = tk.TclError("gone")
|
||||
dead.delete.side_effect = tk.TclError("gone")
|
||||
dead.insert.side_effect = tk.TclError("gone")
|
||||
group = ListboxGroup([dead, alive])
|
||||
|
||||
group.selection_set(0, 2)
|
||||
group.selection_clear(0)
|
||||
group.delete(0)
|
||||
group.insert(0, "x")
|
||||
|
||||
alive.selection_set.assert_called_once_with(0, 2)
|
||||
alive.selection_clear.assert_called_once_with(0)
|
||||
|
||||
def test_first_exposes_the_primary_copy(self) -> None:
|
||||
"""Reads that cannot fan out use the primary monitor's widget."""
|
||||
first, second = MagicMock(), MagicMock()
|
||||
|
||||
assert WidgetGroup([first, second]).first is first
|
||||
|
||||
def test_iterating_yields_every_copy(self) -> None:
|
||||
"""Callers that genuinely need each widget can have them."""
|
||||
first, second = MagicMock(), MagicMock()
|
||||
|
||||
assert list(WidgetGroup([first, second])) == [first, second]
|
||||
|
||||
def test_discarding_an_unknown_output_changes_nothing(self) -> None:
|
||||
"""Tearing down an output that was never built is a no-op."""
|
||||
from diet_guard._gatelock_groups import GateWidgetsGroup
|
||||
|
||||
group = GateWidgetsGroup(MagicMock())
|
||||
bundle = MagicMock()
|
||||
group.add(bundle, "DP-0")
|
||||
|
||||
group.discard("HDMI-9")
|
||||
|
||||
assert group.bundles == [bundle]
|
||||
@ -13,8 +13,6 @@ from typing import TYPE_CHECKING
|
||||
from unittest.mock import 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:
|
||||
@ -227,153 +225,3 @@ class TestRecord:
|
||||
"""Unlock sets the closing status and schedules teardown."""
|
||||
gate._unlock("logged X")
|
||||
assert "unlocking" in gate._vars.status.get()
|
||||
|
||||
|
||||
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()
|
||||
|
||||
|
||||
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_shared_log") 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_shared_log",
|
||||
return_value="sync unavailable (x)",
|
||||
):
|
||||
gate._on_fetch_sync()
|
||||
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_shared_log", return_value=None),
|
||||
patch.object(_gatelock_mealflow, "due_slots", return_value=(8, 12)),
|
||||
):
|
||||
gate._on_fetch_sync()
|
||||
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_shared_log", return_value=None),
|
||||
patch.object(_gatelock_mealflow, "due_slots", return_value=(12,)),
|
||||
):
|
||||
gate._on_fetch_sync()
|
||||
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_shared_log", return_value=None),
|
||||
patch.object(_gatelock_mealflow, "due_slots", return_value=(16,)),
|
||||
):
|
||||
gate._on_fetch_sync()
|
||||
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_shared_log", return_value=None),
|
||||
patch.object(_gatelock_mealflow, "due_slots", return_value=()),
|
||||
):
|
||||
gate._on_fetch_sync()
|
||||
assert gate._pending == []
|
||||
assert "unlocking" in gate._vars.status.get()
|
||||
|
||||
Loading…
Reference in New Issue
Block a user