"""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