diet-guard/diet_guard/tests/test_estimator.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

215 lines
7.6 KiB
Python

"""Tests for _estimator.py — Nutrition maths and the Open Food Facts backend.
The HTTP layer is fully mocked (``requests.get``), so the parsing, portion, and
scaling branches are exercised without any network access.
"""
from __future__ import annotations
from unittest.mock import MagicMock, patch
import requests
from diet_guard import _estimator, _estimator_off
from diet_guard._constants import DEFAULT_PORTION_GRAMS
from diet_guard._estimator import Nutrition, manual, scale_nutrition
from diet_guard._estimator_off import estimate, estimate_off, off_candidates
_GOOD = {
"product_name": "Big Mac",
"nutriments": {
"energy-kcal_100g": 250,
"proteins_100g": 12,
"carbohydrates_100g": 30,
"fat_100g": 10,
},
"serving_quantity": 150,
}
def _patch_get(payload: object) -> object:
"""Patch ``requests.get`` to return a response whose JSON is ``payload``."""
response = MagicMock()
response.raise_for_status = MagicMock()
response.json = MagicMock(return_value=payload)
return patch.object(_estimator_off.requests, "get", return_value=response)
def _hits(*products: object) -> dict[str, object]:
"""Wrap products in the Search-a-licious ``hits`` envelope."""
return {"hits": list(products)}
class TestAsFloat:
"""Coercion of OFF numeric fields, including the rejected types."""
def test_bool_rejected(self) -> None:
"""A bool is not a real nutriment value."""
assert _estimator._as_float(value=True) is None
def test_int_and_float(self) -> None:
"""Ints and floats pass straight through."""
assert _estimator._as_float(5) == 5.0
assert _estimator._as_float(2.5) == 2.5
def test_numeric_string(self) -> None:
"""A numeric string parses."""
assert _estimator._as_float("3.5") == 3.5
def test_non_numeric_string(self) -> None:
"""A non-numeric string is None."""
assert _estimator._as_float("abc") is None
def test_other_type(self) -> None:
"""An unrelated type (None) is None."""
assert _estimator._as_float(None) is None
class TestManual:
"""User-supplied nutrition."""
def test_with_grams(self) -> None:
"""Grams are kept for display; source is manual."""
result = manual(500, 250, protein_g=20, carbs_g=40, fat_g=15)
assert result == Nutrition(500.0, 20.0, 40.0, 15.0, 250.0, "manual")
def test_without_grams(self) -> None:
"""Omitting grams records 0.0."""
assert manual(300).grams == 0.0
class TestScaleNutrition:
"""Proportional rescaling and its degenerate guards."""
def test_normal_scaling(self) -> None:
"""Doubling the grams doubles every macro."""
base = Nutrition(100, 10, 5, 2, 100, "x")
scaled = scale_nutrition(base, 200)
assert (scaled.kcal, scaled.protein_g, scaled.grams) == (200.0, 20.0, 200.0)
def test_unknown_basis_keeps_macros(self) -> None:
"""A zero basis cannot scale, so macros stay and only grams update."""
base = Nutrition(100, 10, 5, 2, 0, "x")
scaled = scale_nutrition(base, 250)
assert scaled.kcal == 100
assert scaled.grams == 250
def test_non_positive_new_grams_keeps_basis_grams(self) -> None:
"""A non-positive target weight keeps the basis weight, macros intact."""
base = Nutrition(100, 10, 5, 2, 100, "x")
scaled = scale_nutrition(base, 0)
assert scaled.kcal == 100
assert scaled.grams == 100
class TestOffSearchEnvelope:
"""Defensive parsing of the search payload shape."""
def test_payload_not_dict(self) -> None:
"""A non-object payload yields no candidates."""
with _patch_get("not a dict"):
assert off_candidates("x") == []
def test_hits_not_list(self) -> None:
"""A non-list ``hits`` yields no candidates."""
with _patch_get({"hits": 123}):
assert off_candidates("x") == []
class TestOffCandidates:
"""Building Nutrition from products, with filtering and portions."""
def test_filters_unusable_products(self) -> None:
"""Non-dict hits, bad nutriments, and kcal-less products are dropped."""
with _patch_get(
_hits(
"junk-string",
{"product_name": "NoNutr", "nutriments": "bad"},
{"product_name": "NoKcal", "nutriments": {"proteins_100g": 5}},
_GOOD,
),
):
results = off_candidates("big mac")
assert len(results) == 1
assert results[0].source == "openfoodfacts: Big Mac"
def test_explicit_grams_override_serving(self) -> None:
"""An explicit portion takes priority over the serving size."""
with _patch_get(_hits(_GOOD)):
result = off_candidates("big mac", grams=200)[0]
assert result.grams == 200
assert result.kcal == 500
def test_serving_quantity_used_when_no_grams(self) -> None:
"""With no grams, the product's serving size sets the portion."""
with _patch_get(_hits(_GOOD)):
result = off_candidates("big mac")[0]
assert result.grams == 150
def test_default_portion_when_nothing_known(self) -> None:
"""No grams and no serving falls back to the default portion."""
product = {"product_name": "P", "nutriments": {"energy-kcal_100g": 100}}
with _patch_get(_hits(product)):
result = off_candidates("p")[0]
assert result.grams == DEFAULT_PORTION_GRAMS
def test_blank_name_uses_description(self) -> None:
"""A blank product name falls back to the typed description."""
product = {"product_name": " ", "nutriments": {"energy-kcal_100g": 100}}
with _patch_get(_hits(product)):
result = off_candidates("my food")[0]
assert result.source == "openfoodfacts: my food"
def test_missing_macro_field_is_zero(self) -> None:
"""A product missing a macro records that macro as 0.0."""
product = {"product_name": "P", "nutriments": {"energy-kcal_100g": 100}}
with _patch_get(_hits(product)):
result = off_candidates("p")[0]
assert result.protein_g == 0.0
def test_request_exception_returns_empty(self) -> None:
"""A network failure degrades to an empty candidate list."""
with patch.object(
_estimator_off.requests,
"get",
side_effect=requests.RequestException("boom"),
):
assert off_candidates("x") == []
class TestEstimateOff:
"""The single-best-match convenience wrapper."""
def test_returns_top(self) -> None:
"""The top candidate is returned when one exists."""
with _patch_get(_hits(_GOOD)):
assert estimate_off("big mac", None) is not None
def test_none_when_empty(self) -> None:
"""No matches -> None."""
with _patch_get(_hits()):
assert estimate_off("nothing", None) is None
class TestEstimate:
"""The top-level estimate dispatcher."""
def test_manual_takes_precedence(self) -> None:
"""A manual kcal value skips Open Food Facts entirely."""
result = estimate("anything", manual_kcal=222)
assert result is not None
assert result.source == "manual"
def test_falls_back_to_off(self) -> None:
"""With no manual value, OFF is queried."""
with _patch_get(_hits(_GOOD)):
result = estimate("big mac")
assert result is not None
assert "openfoodfacts" in result.source
def test_nutrition_is_immutable() -> None:
"""The Nutrition value object is frozen (a dataclass safety check)."""
nutrition = Nutrition(1, 2, 3, 4, 5, "x")
assert nutrition.kcal == 1