mirror of
https://github.com/kuhyx/diet-guard.git
synced 2026-11-11 15:33:57 +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
215 lines
7.6 KiB
Python
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
|