mirror of
https://github.com/kuhyx/diet-guard.git
synced 2026-11-11 13:27:39 +01:00
Ninth batch of the 250-line sweep. Every Python file in the repo is now under cap (over-cap count 25 -> 18; the 18 remaining are all Dart). _foodbank.py 365 -> 210 (+ _foodbank_search, _foodbank_rebuild) test_averages.py 351 -> 222 (+ test_averages_report.py) _estimator.py 325 -> 137 (+ _estimator_off.py) _budget.py 323 -> 227 (+ _budget_biometrics, _budget_derived) test_foodbank.py 311 -> 117 (+ test_foodbank_rebuild.py) _averages.py 298 -> 174 (+ _averages_periods, _averages_report) test_budget_history.py 288 -> 162 (+ test_budget_history_io.py) The split boundaries are constrained by which module owns each conftest-patched state path, not by line count. BUDGET_FILE stays in _budget, FOOD_BANK_FILE in _foodbank: the derived/search/rebuild halves reach the file only through their parent's private helpers. Two of the new modules had copied FOOD_BANK_FILE into their headers as an incidental import, which is exactly the leak the guard is for -- both cleaned. test_state_redirect.py now covers all five redirected paths (FOOD_LOG_FILE, BUDGET_FILE, BUDGET_HISTORY_FILE, FOOD_BANK_FILE, MANUAL_BANK_FILE) rather than just the food log, in both directions: only the owning module may name each constant, and each redirect demonstrably moves a real write off the live path. Two rewires fail-closed rather than silently: `_budget.budget_weight` and `_estimator.requests` were module-attribute references no import rewrite touches, and both raised AttributeError immediately. The latter appeared only after ruff removed the now-unused `requests` import from _estimator -- the import was load-bearing solely as a patch target. Verified: 707 Python tests / 100% branch coverage, 5 layout-fits tests under xvfb, check_patch_targets 0, pre-commit clean, ~/.local/share/diet_guard byte-identical. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01RA9ynjqXPnexRQnR5oAuCC
214 lines
7.9 KiB
Python
214 lines
7.9 KiB
Python
"""Tests for the History tab (_GateCalendar): notebook wiring, no-bypass
|
|
regression, budget-degrade, month navigation, and budget editing."""
|
|
|
|
from __future__ import annotations
|
|
|
|
from typing import TYPE_CHECKING
|
|
from unittest.mock import patch
|
|
|
|
from diet_guard import _budget
|
|
from diet_guard._budget import daily_budget, write_budget
|
|
from diet_guard._budget_derived import budget_weight
|
|
from diet_guard._state import log_meal
|
|
from diet_guard.tests.conftest import _nutrition
|
|
|
|
if TYPE_CHECKING:
|
|
from diet_guard._gatelock import MealGate
|
|
|
|
|
|
class TestNotebookWiring:
|
|
"""The gate builds a two-tab notebook without breaking construction."""
|
|
|
|
def test_builds_a_notebook_with_both_tabs(self, gate: MealGate) -> None:
|
|
labels = [text for _child, text in gate._notebook.tabs]
|
|
assert labels == ["Log Meal", "History"]
|
|
|
|
def test_calendar_state_starts_on_the_current_month(self, gate: MealGate) -> None:
|
|
assert 1 <= gate._cal_month <= 12
|
|
assert gate._cal_year >= 2024
|
|
|
|
|
|
class TestNoBypassRegression:
|
|
"""Switching to the History tab must never affect the lock's dismissal."""
|
|
|
|
def test_switching_to_the_history_tab_does_not_touch_pending_or_close(
|
|
self,
|
|
gate: MealGate,
|
|
) -> None:
|
|
pending_before = list(gate._pending)
|
|
history_frame = gate._cal_widgets.frame
|
|
with patch.object(gate, "close") as mock_close:
|
|
gate._notebook.select(history_frame)
|
|
assert gate._notebook.select() == 1
|
|
assert gate._pending == pending_before
|
|
mock_close.assert_not_called()
|
|
|
|
def test_switching_back_to_the_log_meal_tab_is_also_a_no_op(
|
|
self,
|
|
gate: MealGate,
|
|
) -> None:
|
|
pending_before = list(gate._pending)
|
|
with patch.object(gate, "close") as mock_close:
|
|
gate._notebook.select(gate._cal_widgets.frame)
|
|
gate._notebook.select(gate._widgets.bundles[0].frame)
|
|
assert gate._notebook.select() == 0
|
|
assert gate._pending == pending_before
|
|
mock_close.assert_not_called()
|
|
|
|
|
|
class TestBudgetDefaultAndDegradation:
|
|
"""No budget ever set defaults to 2200; a corrupt file still degrades."""
|
|
|
|
def test_no_budget_set_computes_real_streaks_against_the_default(
|
|
self,
|
|
gate: MealGate,
|
|
) -> None:
|
|
gate._refresh_calendar()
|
|
assert "Logging streak" in gate._cal_vars.streaks.get()
|
|
assert "This year" in gate._cal_vars.ytd.get()
|
|
|
|
def test_no_budget_set_shows_2200_in_the_entry(self, gate: MealGate) -> None:
|
|
gate._refresh_calendar()
|
|
assert gate._cal_vars.budget.get() == "2200"
|
|
|
|
def test_entry_starts_read_only(self, gate: MealGate) -> None:
|
|
assert gate._cal_widgets.budget_entry.configured.get("state") == "readonly"
|
|
|
|
def test_edit_button_starts_labelled_edit(self, gate: MealGate) -> None:
|
|
assert gate._cal_widgets.budget_edit_button.configured.get("text") == "Edit"
|
|
|
|
def test_no_budget_set_still_renders_every_grid_cell(
|
|
self,
|
|
gate: MealGate,
|
|
) -> None:
|
|
gate._refresh_calendar()
|
|
for cell in gate._cal_widgets.day_cells:
|
|
assert "bg" in cell.configured
|
|
|
|
def test_a_real_budget_set_shows_in_the_entry(self, gate: MealGate) -> None:
|
|
write_budget(1800)
|
|
log_meal("oatmeal", _nutrition(kcal=300), None)
|
|
gate._refresh_calendar()
|
|
assert gate._cal_vars.budget.get() == "1800"
|
|
assert "Logging streak" in gate._cal_vars.streaks.get()
|
|
|
|
def test_a_corrupt_budget_file_degrades_with_an_error_message(
|
|
self,
|
|
gate: MealGate,
|
|
) -> None:
|
|
_budget.BUDGET_FILE.write_text("not json")
|
|
gate._refresh_calendar()
|
|
assert "corrupt" in gate._cal_vars.streaks.get()
|
|
assert gate._cal_vars.ytd.get() == ""
|
|
|
|
|
|
class TestMonthNavigation:
|
|
"""Prev/next steps the displayed month, wrapping the year correctly."""
|
|
|
|
def test_prev_month_steps_back_within_the_year(self, gate: MealGate) -> None:
|
|
gate._cal_year, gate._cal_month = 2026, 7
|
|
gate._on_prev_month()
|
|
assert (gate._cal_year, gate._cal_month) == (2026, 6)
|
|
|
|
def test_prev_month_wraps_the_year_at_january(self, gate: MealGate) -> None:
|
|
gate._cal_year, gate._cal_month = 2026, 1
|
|
gate._on_prev_month()
|
|
assert (gate._cal_year, gate._cal_month) == (2025, 12)
|
|
|
|
def test_next_month_steps_forward_within_the_year(self, gate: MealGate) -> None:
|
|
gate._cal_year, gate._cal_month = 2026, 6
|
|
gate._on_next_month()
|
|
assert (gate._cal_year, gate._cal_month) == (2026, 7)
|
|
|
|
def test_next_month_wraps_the_year_at_december(self, gate: MealGate) -> None:
|
|
gate._cal_year, gate._cal_month = 2026, 12
|
|
gate._on_next_month()
|
|
assert (gate._cal_year, gate._cal_month) == (2027, 1)
|
|
|
|
|
|
class TestBudgetEditToggle:
|
|
"""Edit/Save toggle: first click unlocks, second click validates+saves."""
|
|
|
|
def _type(self, gate: MealGate, value: str) -> None:
|
|
"""Replace the budget entry's text (as if the user retyped it).
|
|
|
|
The entry is variable-backed since the per-output migration, so
|
|
typing is modelled by setting the shared variable -- which is also
|
|
what makes the field agree across monitors.
|
|
"""
|
|
gate._cal_vars.budget.set(value)
|
|
|
|
def test_first_click_unlocks_editing_without_persisting(
|
|
self,
|
|
gate: MealGate,
|
|
) -> None:
|
|
gate._on_edit_or_save_budget()
|
|
assert gate._cal_editing_budget is True
|
|
assert gate._cal_widgets.budget_edit_button.configured.get("text") == "Save"
|
|
assert gate._cal_widgets.budget_entry.configured.get("state") == "normal"
|
|
|
|
def test_second_click_with_non_numeric_input_stays_in_edit_mode(
|
|
self,
|
|
gate: MealGate,
|
|
) -> None:
|
|
gate._on_edit_or_save_budget()
|
|
self._type(gate, "not a number")
|
|
gate._on_edit_or_save_budget()
|
|
assert gate._cal_editing_budget is True
|
|
assert "whole number" in gate._cal_vars.budget_status.get()
|
|
assert gate._cal_widgets.budget_edit_button.configured.get("text") == "Save"
|
|
|
|
def test_second_click_with_zero_is_rejected_and_stays_in_edit_mode(
|
|
self,
|
|
gate: MealGate,
|
|
) -> None:
|
|
gate._on_edit_or_save_budget()
|
|
self._type(gate, "0")
|
|
gate._on_edit_or_save_budget()
|
|
assert gate._cal_editing_budget is True
|
|
assert "positive" in gate._cal_vars.budget_status.get()
|
|
|
|
def test_second_click_with_negative_is_rejected(self, gate: MealGate) -> None:
|
|
gate._on_edit_or_save_budget()
|
|
self._type(gate, "-500")
|
|
gate._on_edit_or_save_budget()
|
|
assert "positive" in gate._cal_vars.budget_status.get()
|
|
|
|
def test_second_click_with_a_valid_value_saves_and_exits_edit_mode(
|
|
self,
|
|
gate: MealGate,
|
|
) -> None:
|
|
gate._on_edit_or_save_budget()
|
|
self._type(gate, "1900")
|
|
gate._on_edit_or_save_budget()
|
|
assert gate._cal_editing_budget is False
|
|
assert gate._cal_vars.budget_status.get() == "Saved."
|
|
assert gate._cal_widgets.budget_edit_button.configured.get("text") == "Edit"
|
|
assert gate._cal_widgets.budget_entry.configured.get("state") == "readonly"
|
|
assert daily_budget() == 1900
|
|
|
|
def test_saving_preserves_a_previously_stored_weight(
|
|
self,
|
|
gate: MealGate,
|
|
) -> None:
|
|
write_budget(2000, weight_kg=80)
|
|
gate._on_edit_or_save_budget()
|
|
self._type(gate, "2100")
|
|
gate._on_edit_or_save_budget()
|
|
assert budget_weight() == 80
|
|
|
|
def test_saving_refreshes_the_dashboard_headline(self, gate: MealGate) -> None:
|
|
gate._on_edit_or_save_budget()
|
|
self._type(gate, "1700")
|
|
gate._on_edit_or_save_budget()
|
|
assert "1700" in gate._vars.cal_headline.get()
|
|
|
|
def test_navigating_months_mid_edit_does_not_overwrite_the_typed_value(
|
|
self,
|
|
gate: MealGate,
|
|
) -> None:
|
|
gate._on_edit_or_save_budget()
|
|
self._type(gate, "1234")
|
|
gate._on_prev_month()
|
|
assert gate._cal_vars.budget.get() == "1234"
|