diet-guard/diet_guard/tests/test_budget.py
Krzysztof kuhy Rudnicki 3e51e614b1 Split the last seven Python files; extend the redirect guard to all five paths
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
2026-08-15 20:37:05 +02:00

223 lines
7.6 KiB
Python

"""Tests for _budget.py — the freely-editable, synced daily budget."""
from __future__ import annotations
import json
from pathlib import Path
from typing import TYPE_CHECKING, cast
from unittest.mock import patch
import pytest
from diet_guard import _budget
from diet_guard._budget import (
BudgetFileCorruptError,
BudgetNotInitializedError,
daily_budget,
is_initialized,
write_budget,
)
from diet_guard._budget_biometrics import (
Biometrics,
compute_target_budget,
mifflin_st_jeor_bmr,
)
from diet_guard._budget_derived import budget_weight, protein_target_g
if TYPE_CHECKING:
from collections.abc import Callable, Iterator
# A reusable, realistic body profile (the user's own stats).
_BIO = Biometrics(weight_kg=80.0, height_cm=169.0, age_years=26.0, is_male=True)
def _write_record(record: object) -> None:
"""Write an arbitrary object as the budget file (for corruption tests)."""
_budget.BUDGET_FILE.write_text(json.dumps(record), encoding="utf-8")
def _budget_open_raises(exc: type[BaseException]) -> object:
"""Patch ``Path.open`` to raise ``exc`` ONLY for the budget file.
``Path`` instances use ``__slots__`` so ``patch.object(BUDGET_FILE, "open")``
fails; routing every other path to the real ``open`` keeps the failure
surgically on the budget file.
Args:
exc: The exception type to raise when the budget file is opened.
Returns:
An unstarted ``patch`` context manager.
"""
real_open = cast("Callable[..., Iterator[str]]", Path.open)
def fake_open(self: Path, *args: object, **kwargs: object) -> Iterator[str]:
if self == _budget.BUDGET_FILE:
raise exc
return real_open(self, *args, **kwargs)
return patch("pathlib.Path.open", new=fake_open)
class TestMifflinStJeor:
"""The BMR formula's two sex branches."""
def test_male_constant(self) -> None:
"""Male uses the +5 constant."""
# 10*80 + 6.25*169 - 5*26 + 5 = 1731.25
assert mifflin_st_jeor_bmr(_BIO) == pytest.approx(1731.25)
def test_female_constant(self) -> None:
"""Female uses the -161 constant."""
bio = Biometrics(weight_kg=80.0, height_cm=169.0, age_years=26.0, is_male=False)
assert mifflin_st_jeor_bmr(bio) == pytest.approx(1731.25 - 166.0)
class TestComputeTargetBudget:
"""TDEE minus deficit, with a safety floor."""
def test_typical_value(self) -> None:
"""A light-activity, modest-deficit target rounds as expected."""
# 1731.25 * 1.375 - 180 = 2200.46... -> 2200
result = compute_target_budget(_BIO, activity_factor=1.375, deficit_kcal=180)
assert result == 2200
def test_floored_to_minimum(self) -> None:
"""An absurd deficit cannot compute a starvation-level budget."""
result = compute_target_budget(_BIO, activity_factor=1.0, deficit_kcal=5000)
assert result == _budget._MIN_SANE_BUDGET
class TestExceptions:
"""Each budget error carries a fixed message."""
def test_messages(self) -> None:
"""Constructors set a non-empty message with no arguments."""
assert str(BudgetNotInitializedError())
assert str(BudgetFileCorruptError())
class TestWriteAndRead:
"""Round-tripping the plainly-written budget."""
def test_roundtrip(self) -> None:
"""A written value reads back exactly."""
write_budget(2000)
assert daily_budget() == 2000
def test_is_initialized(self) -> None:
"""is_initialized reflects whether the file exists."""
assert not is_initialized()
write_budget(2000)
assert is_initialized()
def test_file_is_plain_json(self) -> None:
"""The number is stored in the open, readable format -- no wrapping."""
write_budget(2345)
record = json.loads(_budget.BUDGET_FILE.read_text(encoding="utf-8"))
assert record["b"] == 2345
def test_overwrite_replaces_the_value(self) -> None:
"""Writing again replaces the previous value, with no extra ritual."""
write_budget(2000)
write_budget(1800)
assert daily_budget() == 1800
class TestReadFailures:
"""daily_budget's defensive paths."""
def test_missing_file(self) -> None:
"""No file yet -> not initialized."""
with pytest.raises(BudgetNotInitializedError):
daily_budget()
def test_unreadable_file(self) -> None:
"""An OSError while reading surfaces as a corrupt file."""
write_budget(2000)
with _budget_open_raises(OSError), pytest.raises(BudgetFileCorruptError):
daily_budget()
def test_invalid_json(self) -> None:
"""Garbage content -> corrupt file."""
_budget.BUDGET_FILE.write_text("not json", encoding="utf-8")
with pytest.raises(BudgetFileCorruptError):
daily_budget()
def test_record_not_dict(self) -> None:
"""A non-object top level -> corrupt file."""
_write_record([1, 2, 3])
with pytest.raises(BudgetFileCorruptError):
daily_budget()
def test_non_integer_value(self) -> None:
"""A non-integer budget (here a bool) is rejected."""
_write_record({"v": 2, "b": True})
with pytest.raises(BudgetFileCorruptError):
daily_budget()
class TestWeightAndProtein:
"""The stored weight and the protein target derived from it."""
def test_write_with_weight_roundtrips(self) -> None:
"""A weight written alongside the budget reads back."""
write_budget(2200, weight_kg=80.0)
assert daily_budget() == 2200
assert budget_weight() == pytest.approx(80.0)
def test_protein_target_from_weight(self) -> None:
"""The protein target is weight x the per-kg constant."""
write_budget(2200, weight_kg=80.0)
expected = round(80.0 * _budget.PROTEIN_G_PER_KG, 1)
assert protein_target_g() == pytest.approx(expected)
def test_no_weight_has_no_protein_target(self) -> None:
"""A budget written without a weight exposes no weight or target."""
write_budget(2000)
assert budget_weight() is None
assert protein_target_g() is None
def test_protein_target_none_when_uninitialized(self) -> None:
"""With no budget written, the protein target is quietly None."""
assert protein_target_g() is None
def test_budget_weight_rejects_non_numeric(self) -> None:
"""A non-numeric weight yields None, not a crash."""
_write_record({"v": 2, "b": 2000, "w": True})
assert budget_weight() is None
class TestRawRecord:
"""The sync-only raw-record read/write pair, which never raises."""
def test_missing_file_returns_none(self) -> None:
assert _budget.read_raw_record() is None
def test_roundtrips_a_written_record(self) -> None:
_budget.write_raw_record({"v": 2, "b": 1900, "t": "2026-01-01T00:00:00"})
assert _budget.read_raw_record() == {
"v": 2,
"b": 1900,
"t": "2026-01-01T00:00:00",
}
def test_unreadable_file_returns_none(self) -> None:
write_budget(2000)
with _budget_open_raises(OSError):
assert _budget.read_raw_record() is None
def test_invalid_json_returns_none(self) -> None:
_budget.BUDGET_FILE.write_text("not json", encoding="utf-8")
assert _budget.read_raw_record() is None
def test_non_dict_record_returns_none(self) -> None:
_write_record([1, 2, 3])
assert _budget.read_raw_record() is None
def test_write_budget_stamps_an_edit_timestamp(self) -> None:
write_budget(2000)
record = _budget.read_raw_record()
assert record is not None
assert record["t"]