Files
calcount/backend/tests/test_summary.py
T
Craig 5372e8cbca TICKET-004: Day summary endpoint
- GET /api/log/summary?date= with totals vs historical target
- Nutrition math consolidated in services/nutrition.py (weight vs count)
- Null nutrition contributes 0; meals contribute 0 (TODO TICKET-007)
- Full suite green (104 passed)
2026-07-26 13:27:03 +01:00

344 lines
14 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""Day summary tests — TICKET-004 (spec §2.1 quantity interpretation, §3.3 /api/log/summary, §8.1 rule 1).
Covers:
- weight-type scaling (150g of 380kcal/100g food = 570)
- count-type scaling (2 × 70kcal egg = 140)
- mixed entries summing
- null nutrition fields contribute 0
- correct target selected for historical dates
- no-target case (target: null)
- meal entries contribute 0 (TODO TICKET-007)
"""
# ── Helpers ──────────────────────────────────────────────────────────────────
def _create_food(client, **overrides) -> dict:
"""Create a food via POST and return the response JSON."""
payload = {
"name": "Test Food",
"calories_per_unit": 250.0,
"source": "manual",
"unit_type": "weight",
}
payload.update(overrides)
resp = client.post("/api/foods", json=payload)
assert resp.status_code == 201, f"food create failed: {resp.text}"
return resp.json()
def _log_entry(client, food_id, quantity, date="2025-06-15", **overrides) -> dict:
"""Create a log entry and return the parsed JSON (asserts 201)."""
payload = {"food_id": food_id, "quantity": quantity, "date": date}
payload.update(overrides)
resp = client.post("/api/log", json=payload)
assert resp.status_code == 201, f"log create failed: {resp.text}"
return resp.json()
def _get_summary(client, date="2025-06-15") -> dict:
"""Call the summary endpoint and return parsed JSON (asserts 200)."""
resp = client.get("/api/log/summary", params={"date": date})
assert resp.status_code == 200, f"summary failed: {resp.text}"
return resp.json()
# ── Basic shape ──────────────────────────────────────────────────────────────
def test_summary_empty_date_returns_zeros_and_null_target(client):
"""A date with no log entries → all nutrition fields zero, target null."""
data = _get_summary(client, "2099-01-01")
assert data["date"] == "2099-01-01"
assert data["target"] is None
t = data["totals"]
assert t["calories"] == 0.0
assert t["protein_g"] == 0.0
assert t["carbs_g"] == 0.0
assert t["fat_g"] == 0.0
# Bonus fields
assert t["fiber_g"] == 0.0
assert t["saturated_fat_g"] == 0.0
assert t["sugars_g"] == 0.0
assert t["sodium_g"] == 0.0
# ── Weight-type scaling (§2.1) ───────────────────────────────────────────────
def test_weight_type_scaling_150g_of_380kcal_per_100g(client):
"""150g of a 380kcal/100g food = 570 kcal. Math: 380 × 150/100 = 570."""
food = _create_food(client, name="Olive Oil", calories_per_unit=380.0, unit_type="weight")
_log_entry(client, food["id"], quantity=150.0, date="2025-07-01")
data = _get_summary(client, "2025-07-01")
assert data["totals"]["calories"] == 570.0
def test_weight_type_scaling_macros(client):
"""Macros also scale per 100g for weight-type foods."""
food = _create_food(
client,
unit_type="weight",
calories_per_unit=200.0,
protein_per_unit=10.0,
carbs_per_unit=20.0,
fat_per_unit=5.0,
)
_log_entry(client, food["id"], quantity=250.0, date="2025-07-02")
data = _get_summary(client, "2025-07-02")
t = data["totals"]
assert t["calories"] == 500.0 # 200 × 250/100
assert t["protein_g"] == 25.0 # 10 × 250/100
assert t["carbs_g"] == 50.0 # 20 × 250/100
assert t["fat_g"] == 12.5 # 5 × 250/100
# ── Count-type scaling (§2.1) ────────────────────────────────────────────────
def test_count_type_scaling_2_eggs(client):
"""2 × 70kcal egg = 140 kcal. Math: 70 × 2 = 140."""
food = _create_food(client, name="Egg", calories_per_unit=70.0, unit_type="count")
_log_entry(client, food["id"], quantity=2.0, date="2025-07-03")
data = _get_summary(client, "2025-07-03")
assert data["totals"]["calories"] == 140.0
def test_count_type_scaling_macros(client):
"""Count-type macros scale per item."""
food = _create_food(
client,
unit_type="count",
calories_per_unit=90.0,
protein_per_unit=6.0,
carbs_per_unit=1.0,
fat_per_unit=7.0,
)
_log_entry(client, food["id"], quantity=3.0, date="2025-07-04")
data = _get_summary(client, "2025-07-04")
t = data["totals"]
assert t["calories"] == 270.0 # 90 × 3
assert t["protein_g"] == 18.0 # 6 × 3
assert t["carbs_g"] == 3.0 # 1 × 3
assert t["fat_g"] == 21.0 # 7 × 3
# ── Mixed entries summing ────────────────────────────────────────────────────
def test_multiple_entries_summed(client):
"""Two weight-type foods on the same date → totals are summed."""
f1 = _create_food(client, name="Rice", calories_per_unit=130.0, unit_type="weight")
f2 = _create_food(client, name="Chicken", calories_per_unit=165.0, unit_type="weight")
_log_entry(client, f1["id"], quantity=200.0, date="2025-07-05") # 260 kcal
_log_entry(client, f2["id"], quantity=150.0, date="2025-07-05") # 247.5 kcal
data = _get_summary(client, "2025-07-05")
assert data["totals"]["calories"] == 507.5 # 260 + 247.5
def test_mixed_unit_types_summed(client):
"""Weight + count entries on the same date sum correctly."""
weight_food = _create_food(
client, name="Pasta", calories_per_unit=350.0, unit_type="weight",
protein_per_unit=12.0, carbs_per_unit=70.0, fat_per_unit=2.0,
)
count_food = _create_food(
client, name="Meatball", calories_per_unit=50.0, unit_type="count",
protein_per_unit=4.0, carbs_per_unit=1.0, fat_per_unit=3.0,
)
_log_entry(client, weight_food["id"], quantity=200.0, date="2025-07-06") # 700 kcal
_log_entry(client, count_food["id"], quantity=4.0, date="2025-07-06") # 200 kcal
data = _get_summary(client, "2025-07-06")
t = data["totals"]
assert t["calories"] == 900.0 # 700 + 200
assert t["protein_g"] == 40.0 # 24 + 16
assert t["carbs_g"] == 144.0 # 140 + 4
assert t["fat_g"] == 16.0 # 4 + 12
# ── Null nutrition fields contribute 0 ───────────────────────────────────────
def test_null_calories_contributes_zero(client):
"""A food with null calories_per_unit contributes 0 (not an error)."""
# Per the CHECK constraint, non-meal foods MUST have calories_per_unit,
# so we use is_meal=True to get null calories.
food = _create_food(
client, name="Null Cal Food", is_meal=True, calories_per_unit=None,
source="meal",
)
_log_entry(client, food["id"], quantity=100.0, date="2025-07-07")
data = _get_summary(client, "2025-07-07")
assert data["totals"]["calories"] == 0.0 # meals → 0 for now
def test_null_macros_contribute_zero(client):
"""Foods with null macro fields contribute 0 for those fields."""
# Create a food with calories but no macros (all null by default)
food = _create_food(
client, name="Sugar Water", calories_per_unit=40.0,
protein_per_unit=None, carbs_per_unit=10.0, fat_per_unit=None,
unit_type="weight",
)
_log_entry(client, food["id"], quantity=200.0, date="2025-07-08")
data = _get_summary(client, "2025-07-08")
t = data["totals"]
assert t["calories"] == 80.0 # 40 × 200/100
assert t["carbs_g"] == 20.0 # 10 × 200/100
assert t["protein_g"] == 0.0 # null → 0
assert t["fat_g"] == 0.0 # null → 0
# ── Bonus nutrition fields included ──────────────────────────────────────────
def test_bonus_fields_fiber_satfat_sugars_sodium(client):
"""fiber, saturated_fat, sugars, sodium are included in summary."""
food = _create_food(
client,
calories_per_unit=200.0, unit_type="weight",
fiber_per_unit=3.0,
saturated_fat_per_unit=2.0,
sugars_per_unit=5.0,
sodium_per_unit=0.4,
)
_log_entry(client, food["id"], quantity=100.0, date="2025-07-09")
data = _get_summary(client, "2025-07-09")
t = data["totals"]
assert t["fiber_g"] == 3.0
assert t["saturated_fat_g"] == 2.0
assert t["sugars_g"] == 5.0
assert t["sodium_g"] == 0.4
# ── Target selection (historical lookup from TICKET-002) ─────────────────────
def test_summary_includes_correct_target_for_date(client):
"""Summary returns the target whose half-open date range covers the log date."""
from database import SessionLocal
from models import Target
from sqlalchemy import delete as sa_delete
# Create targets with sequential start dates; each auto-closes the previous
resp1 = client.post("/api/targets", json={"start_date": "2025-01-01", "calories": 2000})
assert resp1.status_code == 201
tid1 = resp1.json()["id"]
resp2 = client.post("/api/targets", json={"start_date": "2025-04-01", "calories": 2200})
assert resp2.status_code == 201
tid2 = resp2.json()["id"]
resp3 = client.post("/api/targets", json={"start_date": "2025-07-01", "calories": 2500})
assert resp3.status_code == 201
tid3 = resp3.json()["id"]
# Close the last active target to clean up after ourselves
client.put(f"/api/targets/{tid3}", json={"end_date": "2025-12-31"})
try:
# Feb 2025 → target 1 (2000 kcal)
data = _get_summary(client, "2025-02-15")
assert data["target"] is not None
assert data["target"]["id"] == tid1
assert data["target"]["calories"] == 2000
# May 2025 → target 2 (2200 kcal)
data = _get_summary(client, "2025-05-15")
assert data["target"] is not None
assert data["target"]["id"] == tid2
assert data["target"]["calories"] == 2200
# Sep 2025 → target 3 (2500 kcal)
data = _get_summary(client, "2025-09-15")
assert data["target"] is not None
assert data["target"]["id"] == tid3
assert data["target"]["calories"] == 2500
finally:
# Hard-delete these targets so they don't leak into other tests
db = SessionLocal()
try:
db.execute(sa_delete(Target).where(Target.id.in_([tid1, tid2, tid3])))
db.commit()
finally:
db.close()
def test_summary_no_target_returns_null(client):
"""When no target covers the requested date, target is null."""
# Use a date far in the past before any target was created
data = _get_summary(client, "2000-01-01")
assert data["target"] is None
# ── Meal entries contribute 0 (TODO TICKET-007) ──────────────────────────────
def test_meal_entry_contributes_zero(client):
"""Meal foods have null calories_per_unit per the CHECK constraint,
so they temporarily contribute 0 to the day's totals."""
meal = _create_food(
client, name="My Meal", is_meal=True, calories_per_unit=None,
source="meal",
)
_log_entry(client, meal["id"], quantity=1.0, date="2025-07-10")
data = _get_summary(client, "2025-07-10")
assert data["totals"]["calories"] == 0.0
assert data["totals"]["protein_g"] == 0.0
assert data["totals"]["carbs_g"] == 0.0
assert data["totals"]["fat_g"] == 0.0
def test_meal_mixed_with_regular_foods(client):
"""Meal entries contribute 0 while regular foods contribute normally."""
regular = _create_food(client, name="Rice", calories_per_unit=130.0, unit_type="weight")
meal = _create_food(
client, name="Lunch Meal", is_meal=True, calories_per_unit=None,
source="meal",
)
_log_entry(client, regular["id"], quantity=200.0, date="2025-07-11") # 260 kcal
_log_entry(client, meal["id"], quantity=1.0, date="2025-07-11") # 0 kcal
data = _get_summary(client, "2025-07-11")
assert data["totals"]["calories"] == 260.0 # only the regular food
# ── Past/future dates ───────────────────────────────────────────────────────
def test_arbitrary_past_date(client):
"""Summary works for any past date with correct historical data."""
food = _create_food(client, name="Old Food", calories_per_unit=100.0)
_log_entry(client, food["id"], quantity=50.0, date="2023-12-25")
data = _get_summary(client, "2023-12-25")
assert data["totals"]["calories"] == 50.0 # 100 × 50/100
def test_arbitrary_future_date(client):
"""Summary works for future dates (no entries → zeros)."""
data = _get_summary(client, "2030-06-15")
assert data["date"] == "2030-06-15"
assert data["totals"]["calories"] == 0.0
# target may be null or a still-active target — just verify totals are correct
# ── Date isolation ───────────────────────────────────────────────────────────
def test_summary_isolated_by_date(client):
"""Entries on different dates don't cross-contaminate summaries."""
f1 = _create_food(client, name="Monday Food", calories_per_unit=100.0)
f2 = _create_food(client, name="Tuesday Food", calories_per_unit=200.0)
_log_entry(client, f1["id"], quantity=100.0, date="2025-07-12") # 100 kcal
_log_entry(client, f2["id"], quantity=100.0, date="2025-07-13") # 200 kcal
assert _get_summary(client, "2025-07-12")["totals"]["calories"] == 100.0
assert _get_summary(client, "2025-07-13")["totals"]["calories"] == 200.0