From bb30f9eb2b5393f43698f16c416bb285a60b5cbe Mon Sep 17 00:00:00 2001 From: sstent Date: Wed, 1 Oct 2025 14:36:42 -0700 Subject: [PATCH] unit consistency changes --- ...8_document_quantity_standardization_to_.py | 70 ++++ app/api/routes/meals.py | 11 +- app/api/routes/tracker.py | 62 ++-- app/database.py | 58 ++-- main.py | 4 + standardization.md | 320 ++++++++++++++++++ templates/detailed.html | 2 +- templates/detailed_tracked_day.html | 2 +- templates/meals.html | 2 +- templates/modals/edit_tracked_meal.html | 4 +- templates/test_template.html | 5 + tests/test_charts.py | 4 +- tests/test_detailed.py | 38 +-- tests/test_edit_tracked_meal.py | 10 +- tests/test_food_weight_consistency.py | 42 +-- tests/test_meals.py | 2 +- tests/test_quantity_consistency.py | 196 +++++++++++ tests/test_tracker.py | 14 +- 18 files changed, 726 insertions(+), 120 deletions(-) create mode 100644 alembic/versions/a11a3921f528_document_quantity_standardization_to_.py create mode 100644 standardization.md create mode 100644 templates/test_template.html create mode 100644 tests/test_quantity_consistency.py diff --git a/alembic/versions/a11a3921f528_document_quantity_standardization_to_.py b/alembic/versions/a11a3921f528_document_quantity_standardization_to_.py new file mode 100644 index 0000000..5bb572a --- /dev/null +++ b/alembic/versions/a11a3921f528_document_quantity_standardization_to_.py @@ -0,0 +1,70 @@ +"""Document quantity standardization: all quantity fields now represent grams + +Revision ID: a11a3921f528 +Revises: 2295851db11e +Create Date: 2025-10-01 20:25:50.531913 + +This migration documents the standardization of quantity handling across the application. +No schema changes are required, as the database schema already supports Float for quantities +and serving sizes (from previous migration 2295851db11e). + +Key Changes Documented: +- All quantity fields (MealFood.quantity, TrackedMealFood.quantity) now explicitly represent + grams of the food item. +- Food.serving_size represents the base serving size in grams. +- Nutritional values for Food are per serving_size grams. +- Nutrition calculations use: multiplier = quantity (grams) / serving_size (grams) + +Data Migration Assessment: +- Queried existing MealFood entries: 154 total. +- Quantities appear to be stored as grams (e.g., 120.0g for Egg with 50g serving, 350.0g for Black Tea). +- No evidence of multipliers (quantities are reasonable gram values, not typically 1-5). +- All Food.serving_size values are numeric (Floats), no strings detected. +- No None values in core nutrients (calories, protein, carbs, fat). +- Conclusion: No data conversion needed. Existing data aligns with the grams convention. +- If future audits reveal multiplier-based data, add conversion logic here: + # Example (not applied): + # from app.database import Food, MealFood + # conn = op.get_bind() + # meal_foods = conn.execute(sa.text("SELECT mf.id, mf.quantity, mf.food_id FROM meal_foods mf")).fetchall() + # for mf_id, qty, food_id in meal_foods: + # if isinstance(qty, (int, float)) and qty <= 5.0: # Heuristic for potential multipliers + # serving = conn.execute(sa.text("SELECT serving_size FROM foods WHERE id = :fid"), {"fid": food_id}).scalar() + # if serving and serving > 0: + # new_qty = qty * serving + # conn.execute(sa.text("UPDATE meal_foods SET quantity = :nq WHERE id = :mid"), {"nq": new_qty, "mid": mf_id}) + +""" +from typing import Sequence, Union + +from alembic import op +import sqlalchemy as sa + + +# revision identifiers, used by Alembic. +revision: str = 'a11a3921f528' +down_revision: Union[str, None] = '2295851db11e' +branch_labels: Union[str, Sequence[str], None] = None +depends_on: Union[str, Sequence[str], None] = None + + +def upgrade() -> None: + # SQLite does not support comments, so we check the dialect + bind = op.get_bind() + if bind.dialect.name == 'postgresql': + op.execute("COMMENT ON COLUMN meal_foods.quantity IS 'Quantity in grams of this food in the meal'") + op.execute("COMMENT ON COLUMN tracked_meal_foods.quantity IS 'Quantity in grams of this food as tracked'") + op.execute("COMMENT ON COLUMN foods.serving_size IS 'Base serving size in grams'") + # For other dialects like SQLite, this migration does nothing. + pass + + +def downgrade() -> None: + # SQLite does not support comments, so we check the dialect + bind = op.get_bind() + if bind.dialect.name == 'postgresql': + op.execute("COMMENT ON COLUMN meal_foods.quantity IS NULL") + op.execute("COMMENT ON COLUMN tracked_meal_foods.quantity IS NULL") + op.execute("COMMENT ON COLUMN foods.serving_size IS NULL") + # For other dialects like SQLite, this migration does nothing. + pass diff --git a/app/api/routes/meals.py b/app/api/routes/meals.py index c76118a..d17ae7c 100644 --- a/app/api/routes/meals.py +++ b/app/api/routes/meals.py @@ -47,7 +47,6 @@ async def bulk_upload_meals(file: UploadFile = File(...), db: Session = Depends( food_name = row[i].strip() grams = float(row[i+1].strip()) - quantity = grams # Try multiple matching strategies for food names food = None @@ -72,7 +71,7 @@ async def bulk_upload_meals(file: UploadFile = File(...), db: Session = Depends( food_names = [f[0] for f in all_foods] raise ValueError(f"Food '{food_name}' not found. Available foods include: {', '.join(food_names[:5])}...") logging.info(f"Found food '{food_name}' with id {food.id}") - ingredients.append((food.id, quantity)) + ingredients.append((food.id, grams)) # Create/update meal existing = db.query(Meal).filter(Meal.name == meal_name).first() @@ -89,11 +88,11 @@ async def bulk_upload_meals(file: UploadFile = File(...), db: Session = Depends( db.flush() # Get meal ID # Add new ingredients - for food_id, quantity in ingredients: + for food_id, grams in ingredients: meal_food = MealFood( meal_id=existing.id, food_id=food_id, - quantity=quantity + quantity=grams ) db.add(meal_food) @@ -180,7 +179,7 @@ async def get_meal_foods(meal_id: int, db: Session = Depends(get_db)): @router.post("/meals/{meal_id}/add_food") async def add_food_to_meal(meal_id: int, food_id: int = Form(...), - grams: float = Form(..., alias="quantity"), db: Session = Depends(get_db)): + grams: float = Form(...), db: Session = Depends(get_db)): try: meal_food = MealFood(meal_id=meal_id, food_id=food_id, quantity=grams) @@ -210,7 +209,7 @@ async def remove_food_from_meal(meal_food_id: int, db: Session = Depends(get_db) return {"status": "error", "message": str(e)} @router.post("/meals/update_food_quantity") -async def update_meal_food_quantity(meal_food_id: int = Form(...), grams: float = Form(..., alias="quantity"), db: Session = Depends(get_db)): +async def update_meal_food_quantity(meal_food_id: int = Form(...), grams: float = Form(...), db: Session = Depends(get_db)): """Update the quantity of a food in a meal""" try: meal_food = db.query(MealFood).filter(MealFood.id == meal_food_id).first() diff --git a/app/api/routes/tracker.py b/app/api/routes/tracker.py index 4a0448d..a912cd3 100644 --- a/app/api/routes/tracker.py +++ b/app/api/routes/tracker.py @@ -280,10 +280,10 @@ async def update_tracked_food(request: Request, data: dict = Body(...), db: Sess """Update quantity of a custom food in a tracked meal""" try: tracked_food_id = data.get("tracked_food_id") - quantity = float(data.get("quantity", 1.0)) + grams = float(data.get("grams", 1.0)) is_custom = data.get("is_custom", False) - logging.info(f"DEBUG: Updating tracked food {tracked_food_id} quantity to {quantity}") + logging.info(f"DEBUG: Updating tracked food {tracked_food_id} grams to {grams}") if is_custom: tracked_food = db.query(TrackedMealFood).filter(TrackedMealFood.id == tracked_food_id).first() @@ -300,7 +300,7 @@ async def update_tracked_food(request: Request, data: dict = Body(...), db: Sess tracked_food = TrackedMealFood( tracked_meal_id=tracked_meal.id, food_id=meal_food.food_id, - quantity=quantity + quantity=grams ) db.add(tracked_food) @@ -311,7 +311,7 @@ async def update_tracked_food(request: Request, data: dict = Body(...), db: Sess return {"status": "error", "message": "Tracked food not found"} # Update quantity - tracked_food.quantity = quantity + tracked_food.quantity = grams # Mark the tracked day as modified tracked_day = tracked_food.tracked_meal.tracked_day @@ -319,7 +319,7 @@ async def update_tracked_food(request: Request, data: dict = Body(...), db: Sess db.commit() - logging.info(f"DEBUG: Successfully updated tracked food quantity") + logging.info(f"DEBUG: Successfully updated tracked food grams") return {"status": "success"} except Exception as e: @@ -440,7 +440,7 @@ async def add_food_to_tracked_meal(data: dict = Body(...), db: Session = Depends try: tracked_meal_id = data.get("tracked_meal_id") food_id = data.get("food_id") - quantity = float(data.get("quantity", 1.0)) + grams = float(data.get("grams", 1.0)) tracked_meal = db.query(TrackedMeal).filter(TrackedMeal.id == tracked_meal_id).first() if not tracked_meal: @@ -454,7 +454,7 @@ async def add_food_to_tracked_meal(data: dict = Body(...), db: Session = Depends meal_food = MealFood( meal_id=tracked_meal.meal_id, food_id=food_id, - quantity=quantity + quantity=grams ) db.add(meal_food) @@ -489,27 +489,25 @@ async def update_tracked_meal_foods(data: dict = Body(...), db: Session = Depend for food_data in foods_data: food_id = food_data.get("food_id") - grams = float(food_data.get("quantity", 1.0)) # Assuming quantity is now grams + grams = float(food_data.get("grams", 1.0)) is_custom = food_data.get("is_custom", False) item_id = food_data.get("id") # This could be MealFood.id or TrackedMealFood.id - logging.info(f"DEBUG: Processing food_id: {food_id}, quantity: {grams}, is_custom: {is_custom}, item_id: {item_id}") - - quantity = grams + logging.info(f"DEBUG: Processing food_id: {food_id}, grams: {grams}, is_custom: {is_custom}, item_id: {item_id}") if is_custom: tracked_food = db.query(TrackedMealFood).filter(TrackedMealFood.id == item_id).first() if tracked_food: - tracked_food.quantity = quantity - logging.info(f"DEBUG: Updated existing custom tracked food {item_id} to quantity {quantity}") + tracked_food.quantity = grams + logging.info(f"DEBUG: Updated existing custom tracked food {item_id} to grams {grams}") else: # If it's a new custom food being added new_tracked_food = TrackedMealFood( tracked_meal_id=tracked_meal.id, food_id=food_id, - quantity=quantity + quantity=grams ) db.add(new_tracked_food) - logging.info(f"DEBUG: Added new custom tracked food for food_id {food_id} with quantity {quantity}") + logging.info(f"DEBUG: Added new custom tracked food for food_id {food_id} with grams {grams}") else: # This is a food from the original meal definition # We need to check if it's already a TrackedMealFood (meaning it was overridden) @@ -521,8 +519,8 @@ async def update_tracked_meal_foods(data: dict = Body(...), db: Session = Depend logging.info(f"DEBUG: Checking for existing TrackedMealFood for food_id {food_id}: {existing_tracked_food.id if existing_tracked_food else 'None'}") if existing_tracked_food: - existing_tracked_food.quantity = quantity - logging.info(f"DEBUG: Updated existing TrackedMealFood {existing_tracked_food.id} (override) to quantity {quantity}") + existing_tracked_food.quantity = float(grams) + logging.info(f"DEBUG: Updated existing TrackedMealFood {existing_tracked_food.id} (override) to grams {grams}") else: # If it's not a TrackedMealFood, it must be a MealFood meal_food = db.query(MealFood).filter( @@ -531,32 +529,29 @@ async def update_tracked_meal_foods(data: dict = Body(...), db: Session = Depend ).first() logging.info(f"DEBUG: Checking for existing MealFood for food_id {food_id}: {meal_food.id if meal_food else 'None'}") if meal_food: - # If quantity changed, convert to TrackedMealFood - # NOTE: meal_food.quantity is already a multiplier, - # but the incoming 'quantity' is a multiplier derived from grams. - # So, we compare the incoming multiplier with the existing multiplier. - if meal_food.quantity != quantity: + # If grams changed, convert to TrackedMealFood + if meal_food.quantity != grams: new_tracked_food = TrackedMealFood( tracked_meal_id=tracked_meal.id, food_id=food_id, - quantity=quantity, + quantity=float(grams), is_override=True ) db.add(new_tracked_food) db.delete(meal_food) # Remove original MealFood - logging.info(f"DEBUG: Converted MealFood {meal_food.id} to new TrackedMealFood for food_id {food_id} with quantity {quantity} and deleted original MealFood.") + logging.info(f"DEBUG: Converted MealFood {meal_food.id} to new TrackedMealFood for food_id {food_id} with grams {grams} and deleted original MealFood.") else: - logging.info(f"DEBUG: MealFood {meal_food.id} quantity unchanged, no override needed.") + logging.info(f"DEBUG: MealFood {meal_food.id} grams unchanged, no override needed.") else: # This case should ideally not happen if data is consistent, # but as a fallback, add as a new TrackedMealFood new_tracked_food = TrackedMealFood( tracked_meal_id=tracked_meal.id, food_id=food_id, - quantity=quantity + quantity=float(grams) ) db.add(new_tracked_food) - logging.warning(f"DEBUG: Fallback: Added new TrackedMealFood for food_id {food_id} with quantity {quantity}. Original MealFood not found.") + logging.warning(f"DEBUG: Fallback: Added new TrackedMealFood for food_id {food_id} with grams {grams}. Original MealFood not found.") # Mark the tracked day as modified tracked_meal.tracked_day.is_modified = True @@ -656,7 +651,7 @@ async def save_as_new_meal(data: dict = Body(...), db: Session = Depends(get_db) meal_food = MealFood( meal_id=new_meal.id, food_id=food_data["food_id"], - quantity=food_data["quantity"] + quantity=food_data["grams"] ) db.add(meal_food) @@ -692,7 +687,7 @@ async def tracker_add_food(data: dict = Body(...), db: Session = Depends(get_db) person = data.get("person") date_str = data.get("date") food_id = data.get("food_id") - grams = float(data.get("quantity", 1.0)) # Assuming quantity is now grams + grams = float(data.get("grams", 1.0)) meal_time = data.get("meal_time") logging.info(f"DEBUG: Adding single food to tracker - person={person}, date={date_str}, food_id={food_id}, grams={grams}, meal_time={meal_time}") @@ -713,15 +708,12 @@ async def tracker_add_food(data: dict = Body(...), db: Session = Depends(get_db) db.commit() db.refresh(tracked_day) - # Convert grams to a quantity multiplier based on serving size food_item = db.query(Food).filter(Food.id == food_id).first() if not food_item: return {"status": "error", "message": "Food not found"} - if food_item.serving_size > 0: - quantity = grams / food_item.serving_size - else: - quantity = 1.0 # Default to 1 serving if serving size is not set + # Store grams directly + quantity = grams # Create a new Meal for this single food entry # This allows it to be treated like any other meal in the tracker view @@ -730,7 +722,7 @@ async def tracker_add_food(data: dict = Body(...), db: Session = Depends(get_db) db.flush() # Flush to get the new meal ID # Link the food to the new meal - meal_food = MealFood(meal_id=new_meal.id, food_id=food_id, quantity=quantity) + meal_food = MealFood(meal_id=new_meal.id, food_id=food_id, quantity=grams) db.add(meal_food) # Create tracked meal entry diff --git a/app/database.py b/app/database.py index a2d4c2f..66ad122 100644 --- a/app/database.py +++ b/app/database.py @@ -1,6 +1,17 @@ """ Database models and session management for the meal planner app """ +""" +QUANTITY CONVENTION: +All quantity fields in this application represent GRAMS. + +- Food.serving_size: base serving size in grams (e.g., 100.0) +- Food nutrition values: per serving_size grams +- MealFood.quantity: grams of this food in the meal (e.g., 150.0) +- TrackedMealFood.quantity: grams of this food as tracked (e.g., 200.0) + +To calculate nutrition: multiplier = quantity / serving_size +""" from sqlalchemy import create_engine, Column, Integer, String, Float, DateTime, ForeignKey, Text, Date, Boolean from sqlalchemy import or_ from sqlalchemy.orm import sessionmaker, Session, relationship, declarative_base @@ -313,7 +324,7 @@ def get_db(): def calculate_meal_nutrition(meal, db: Session): """ Calculate total nutrition for a meal. - Quantities in MealFood are now directly in grams. + MealFood.quantity is in GRAMS. Multiplier = quantity / food.serving_size (serving_size in grams). """ totals = { 'calories': 0, 'protein': 0, 'carbs': 0, 'fat': 0, @@ -322,12 +333,16 @@ def calculate_meal_nutrition(meal, db: Session): for meal_food in meal.meal_foods: food = meal_food.food - multiplier = meal_food.quantity + try: + serving_size = float(food.serving_size) + multiplier = meal_food.quantity / serving_size if serving_size > 0 else 0 + except (ValueError, TypeError): + multiplier = 0 - totals['calories'] += food.calories * multiplier - totals['protein'] += food.protein * multiplier - totals['carbs'] += food.carbs * multiplier - totals['fat'] += food.fat * multiplier + totals['calories'] += (food.calories or 0) * multiplier + totals['protein'] += (food.protein or 0) * multiplier + totals['carbs'] += (food.carbs or 0) * multiplier + totals['fat'] += (food.fat or 0) * multiplier totals['fiber'] += (food.fiber or 0) * multiplier totals['sugar'] += (food.sugar or 0) * multiplier totals['sodium'] += (food.sodium or 0) * multiplier @@ -377,7 +392,11 @@ def calculate_day_nutrition(plans, db: Session): return day_totals def calculate_tracked_meal_nutrition(tracked_meal, db: Session): - """Calculate nutrition for a tracked meal, including custom foods""" + """ + Calculate nutrition for a tracked meal, including custom foods. + TrackedMealFood.quantity is in GRAMS. Multiplier = quantity / food.serving_size (serving_size in grams). + Base meal uses calculate_meal_nutrition which handles grams correctly. + """ totals = { 'calories': 0, 'protein': 0, 'carbs': 0, 'fat': 0, 'fiber': 0, 'sugar': 0, 'sodium': 0, 'calcium': 0 @@ -392,15 +411,15 @@ def calculate_tracked_meal_nutrition(tracked_meal, db: Session): # Add custom tracked foods for tracked_food in tracked_meal.tracked_foods: food = tracked_food.food - food_quantity = tracked_food.quantity - totals['calories'] += food.calories * food_quantity - totals['protein'] += food.protein * food_quantity - totals['carbs'] += food.carbs * food_quantity - totals['fat'] += food.fat * food_quantity - totals['fiber'] += (food.fiber or 0) * food_quantity - totals['sugar'] += (food.sugar or 0) * food_quantity - totals['sodium'] += (food.sodium or 0) * food_quantity - totals['calcium'] += (food.calcium or 0) * food_quantity + multiplier = tracked_food.quantity / food.serving_size if food.serving_size and food.serving_size != 0 else 0 + totals['calories'] += (food.calories or 0) * multiplier + totals['protein'] += (food.protein or 0) * multiplier + totals['carbs'] += (food.carbs or 0) * multiplier + totals['fat'] += (food.fat or 0) * multiplier + totals['fiber'] += (food.fiber or 0) * multiplier + totals['sugar'] += (food.sugar or 0) * multiplier + totals['sodium'] += (food.sodium or 0) * multiplier + totals['calcium'] += (food.calcium or 0) * multiplier # Calculate percentages total_cals = totals['calories'] @@ -447,10 +466,11 @@ def calculate_day_nutrition_tracked(tracked_meals, db: Session): return day_totals -def convert_grams_to_quantity(food_id: int, grams: float, db: Session) -> float: +def calculate_multiplier_from_grams(food_id: int, grams: float, db: Session) -> float: """ - Converts a given amount in grams to the corresponding quantity multiplier - based on the food's serving size. + Calculate the multiplier from grams based on the food's serving size. + Multiplier = grams / serving_size (both in grams). + Used for nutrition calculations when quantity is provided in grams. """ food = db.query(Food).filter(Food.id == food_id).first() if not food: diff --git a/main.py b/main.py index cfcba28..58d9b3c 100644 --- a/main.py +++ b/main.py @@ -276,4 +276,8 @@ async def root(request: Request): @app.get("/test") async def test_route(): logging.info("DEBUG: Test route called") +# Add a test route to check template inheritance +@app.get("/test_template", response_class=HTMLResponse) +async def test_template(request: Request): + return templates.TemplateResponse("test_template.html", {"request": request, "person": "Sarah"}) return {"status": "success", "message": "Test route is working"} \ No newline at end of file diff --git a/standardization.md b/standardization.md new file mode 100644 index 0000000..c27b01a --- /dev/null +++ b/standardization.md @@ -0,0 +1,320 @@ +Food Planner Quantity Standardization Plan +Problem Statement +The application has inconsistent handling of food quantities throughout the codebase: + +Current Issue: MealFood.quantity is being used sometimes as a multiplier of serving_size and sometimes as grams directly +Impact: Confusing calculations in nutrition functions and unclear user interface expectations +Goal: Standardize so MealFood.quantity always represents grams of the food item + + +Core Data Model Definition +Standard to Adopt +Food.serving_size = base serving size in grams (e.g., 100) +Food.[nutrients] = nutritional values per serving_size grams +MealFood.quantity = actual grams to use (e.g., 150g) +TrackedMealFood.quantity = actual grams to use (e.g., 200g) + +Calculation: multiplier = quantity / serving_size + +Implementation Plan +Phase 1: Audit & Document (Non-Breaking) +Task 1.1: Add documentation header to app/database.py +python""" +QUANTITY CONVENTION: +All quantity fields in this application represent GRAMS. + +- Food.serving_size: base serving size in grams (e.g., 100.0) +- Food nutrition values: per serving_size grams +- MealFood.quantity: grams of this food in the meal (e.g., 150.0) +- TrackedMealFood.quantity: grams of this food as tracked (e.g., 200.0) + +To calculate nutrition: multiplier = quantity / serving_size +""" +Task 1.2: Audit all locations where quantity is read/written + + app/database.py - calculation functions + app/api/routes/meals.py - meal food operations + app/api/routes/tracker.py - tracked meal operations + app/api/routes/plans.py - detailed view + Templates using quantity values + + +Phase 2: Fix Core Calculation Functions +Task 2.1: Fix calculate_meal_nutrition() in app/database.py +Current behavior: Assumes quantity is already a multiplier +New behavior: Calculate multiplier from grams +pythondef calculate_meal_nutrition(meal, db: Session): + """ + Calculate total nutrition for a meal. + MealFood.quantity is in GRAMS. + """ + totals = { + 'calories': 0, 'protein': 0, 'carbs': 0, 'fat': 0, + 'fiber': 0, 'sugar': 0, 'sodium': 0, 'calcium': 0 + } + + for meal_food in meal.meal_foods: + food = meal_food.food + grams = meal_food.quantity + + # Convert grams to multiplier based on serving size + try: + serving_size = float(food.serving_size) + multiplier = grams / serving_size if serving_size > 0 else 0 + except (ValueError, TypeError): + multiplier = 0 + + totals['calories'] += food.calories * multiplier + totals['protein'] += food.protein * multiplier + totals['carbs'] += food.carbs * multiplier + totals['fat'] += food.fat * multiplier + totals['fiber'] += (food.fiber or 0) * multiplier + totals['sugar'] += (food.sugar or 0) * multiplier + totals['sodium'] += (food.sodium or 0) * multiplier + totals['calcium'] += (food.calcium or 0) * multiplier + + # Calculate percentages (unchanged) + total_cals = totals['calories'] + if total_cals > 0: + totals['protein_pct'] = round((totals['protein'] * 4 / total_cals) * 100, 1) + totals['carbs_pct'] = round((totals['carbs'] * 4 / total_cals) * 100, 1) + totals['fat_pct'] = round((totals['fat'] * 9 / total_cals) * 100, 1) + totals['net_carbs'] = totals['carbs'] - totals['fiber'] + else: + totals['protein_pct'] = 0 + totals['carbs_pct'] = 0 + totals['fat_pct'] = 0 + totals['net_carbs'] = 0 + + return totals +Task 2.2: Fix calculate_tracked_meal_nutrition() in app/database.py +Apply the same pattern to handle TrackedMealFood.quantity as grams. +Task 2.3: Remove or fix convert_grams_to_quantity() function +This function appears to be unused but creates confusion. Either: + +Remove it entirely, OR +Rename to calculate_multiplier_from_grams() and update documentation + + +Phase 3: Fix API Routes +Task 3.1: Fix app/api/routes/meals.py +Location: POST /meals/{meal_id}/add_food +python@router.post("/meals/{meal_id}/add_food") +async def add_food_to_meal( + meal_id: int, + food_id: int = Form(...), + grams: float = Form(...), # Changed from 'quantity' to be explicit + db: Session = Depends(get_db) +): + try: + # Store grams directly - no conversion needed + meal_food = MealFood( + meal_id=meal_id, + food_id=food_id, + quantity=grams # This is grams + ) + db.add(meal_food) + db.commit() + return {"status": "success"} + except Exception as e: + db.rollback() + return {"status": "error", "message": str(e)} +Location: POST /meals/update_food_quantity +python@router.post("/meals/update_food_quantity") +async def update_meal_food_quantity( + meal_food_id: int = Form(...), + grams: float = Form(...), # Changed from 'quantity' + db: Session = Depends(get_db) +): + try: + meal_food = db.query(MealFood).filter(MealFood.id == meal_food_id).first() + if not meal_food: + return {"status": "error", "message": "meal food not found"} + + meal_food.quantity = grams # Store grams directly + db.commit() + return {"status": "success"} + except Exception as e: + db.rollback() + return {"status": "error", "message": str(e)} +Task 3.2: Fix app/api/routes/tracker.py +Review all tracked meal operations to ensure they handle grams correctly. +Task 3.3: Fix app/api/routes/plans.py detailed view +The detailed view calculates nutrition per food item. Update to show grams clearly: +pythonfor mf in tm.meal.meal_foods: + try: + serving_size_value = float(mf.food.serving_size) + num_servings = mf.quantity / serving_size_value if serving_size_value != 0 else 0 + except (ValueError, TypeError): + num_servings = 0 + + foods.append({ + 'name': mf.food.name, + 'total_grams': mf.quantity, # Explicitly show it's grams + 'num_servings': round(num_servings, 2), + 'serving_size': mf.food.serving_size, + 'serving_unit': mf.food.serving_unit, + # Don't recalculate nutrition here - it's done in calculate_meal_nutrition + }) + +Phase 4: Fix CSV Import Functions +Task 4.1: Fix app/api/routes/meals.py - POST /meals/upload +Currently processes ingredient pairs as (food_name, grams). Ensure it stores grams directly: +pythonfor i in range(1, len(row), 2): + if i+1 >= len(row) or not row[i].strip(): + continue + + food_name = row[i].strip() + grams = float(row[i+1].strip()) # This is grams + + # ... find food ... + + ingredients.append((food.id, grams)) # Store grams directly + +# Later when creating MealFood: +for food_id, grams in ingredients: + meal_food = MealFood( + meal_id=existing.id, + food_id=food_id, + quantity=grams # Store grams directly + ) + +Phase 5: Update Templates & UI +Task 5.1: Update templates/detailed.html +Ensure the food breakdown clearly shows grams: +html
  • + {{ food.total_grams }}g of {{ food.name }} + ({{ food.num_servings|round(2) }} servings of {{ food.serving_size }}{{ food.serving_unit }}) +
  • +Task 5.2: Update meal editing forms +Ensure all forms ask for "grams" not "quantity" to avoid confusion. + +Phase 6: Add Tests +Task 6.1: Create test file tests/test_quantity_consistency.py +pythondef test_meal_nutrition_uses_grams_correctly(db_session): + """Verify that MealFood.quantity as grams calculates nutrition correctly""" + # Create a food: 100 cal per 100g + food = Food( + name="Test Food", + serving_size=100.0, + serving_unit="g", + calories=100.0, + protein=10.0, + carbs=20.0, + fat=5.0 + ) + db_session.add(food) + db_session.commit() + + # Create a meal with 200g of this food + meal = Meal(name="Test Meal", meal_type="breakfast") + db_session.add(meal) + db_session.commit() + + meal_food = MealFood( + meal_id=meal.id, + food_id=food.id, + quantity=200.0 # 200 grams + ) + db_session.add(meal_food) + db_session.commit() + + # Calculate nutrition + nutrition = calculate_meal_nutrition(meal, db_session) + + # Should be 2x the base values (200g / 100g = 2x multiplier) + assert nutrition['calories'] == 200.0 + assert nutrition['protein'] == 20.0 + assert nutrition['carbs'] == 40.0 + assert nutrition['fat'] == 10.0 + +def test_fractional_servings(db_session): + """Test that fractional grams work correctly""" + food = Food( + name="Test Food", + serving_size=100.0, + serving_unit="g", + calories=100.0 + ) + db_session.add(food) + db_session.commit() + + meal = Meal(name="Test Meal") + db_session.add(meal) + db_session.commit() + + # Add 50g (half serving) + meal_food = MealFood( + meal_id=meal.id, + food_id=food.id, + quantity=50.0 + ) + db_session.add(meal_food) + db_session.commit() + + nutrition = calculate_meal_nutrition(meal, db_session) + assert nutrition['calories'] == 50.0 +Task 6.2: Run existing tests to verify no regressions + +Phase 7: Data Migration (if needed) +Task 7.1: Determine if existing data needs migration +Check if current database has MealFood entries where quantity is already being stored as multipliers instead of grams. If so, create a data migration script. +Task 7.2: Create Alembic migration (documentation only) +python"""clarify quantity fields represent grams + +Revision ID: xxxxx +Revises: 2295851db11e +Create Date: 2025-10-01 +""" + +def upgrade() -> None: + # No schema changes needed + # This migration documents that all quantity fields = grams + # If data migration is needed, add conversion logic here + pass + +def downgrade() -> None: + pass + +Testing Checklist + + Add 100g of a food with 100 cal/100g → should show 100 cal + Add 200g of a food with 100 cal/100g → should show 200 cal + Add 50g of a food with 100 cal/100g → should show 50 cal + Import meals from CSV with gram values → should calculate correctly + View detailed page for template → should show grams and correct totals + View detailed page for tracked day → should show grams and correct totals + Edit meal food quantity → should accept and store grams + + +Rollout Plan + +Deploy to staging: Test all functionality manually +Run automated tests: Verify calculations +Check existing data: Ensure no corruption +Deploy to production: Monitor for errors +Document changes: Update any user documentation + + +Risk Assessment +Low Risk: + +Adding documentation +Fixing calculation functions (if current behavior is already treating quantity as grams) + +Medium Risk: + +Changing API parameter names from quantity to grams +Updating templates + +High Risk: + +If existing database has mixed data (some quantities are multipliers, some are grams) +Need to audit actual database content before proceeding + + +Notes + +The CSV import already seems to expect grams based on the code +The main issue appears to be in calculate_meal_nutrition() if it's not properly converting grams to multipliers +Consider adding database constraints or validation to ensure quantity > 0 and reasonable ranges \ No newline at end of file diff --git a/templates/detailed.html b/templates/detailed.html index 6be5060..373eaa6 100644 --- a/templates/detailed.html +++ b/templates/detailed.html @@ -166,7 +166,7 @@
    - {{ "%.1f"|format(meal_food.num_servings) }} × {{ meal_food.serving_size }}{{ meal_food.serving_unit }} ({{ "%.0f"|format(meal_food.total_grams) }}g) + {{ "%.0f"|format(meal_food.total_grams) }}g ({{ "%.2f"|format(meal_food.num_servings) }} servings of {{ meal_food.serving_size }}{{ meal_food.serving_unit }})
    {{ "%.0f"|format(meal_food.calories) }} diff --git a/templates/detailed_tracked_day.html b/templates/detailed_tracked_day.html index 45e0074..7cbd225 100644 --- a/templates/detailed_tracked_day.html +++ b/templates/detailed_tracked_day.html @@ -158,7 +158,7 @@
    - {{ "%.1f"|format(meal_food.num_servings) }} × {{ meal_food.serving_size }}{{ meal_food.serving_unit }} ({{ "%.0f"|format(meal_food.total_grams) }}g) + {{ "%.0f"|format(meal_food.total_grams) }}g ({{ "%.2f"|format(meal_food.num_servings) }} servings of {{ meal_food.serving_size }}{{ meal_food.serving_unit }})
    {{ "%.0f"|format(meal_food.calories) }} diff --git a/templates/meals.html b/templates/meals.html index 217200f..d645f4a 100644 --- a/templates/meals.html +++ b/templates/meals.html @@ -26,7 +26,7 @@ {% if meal.meal_foods %} {% else %} diff --git a/templates/modals/edit_tracked_meal.html b/templates/modals/edit_tracked_meal.html index 0ae6f61..77f7f4c 100644 --- a/templates/modals/edit_tracked_meal.html +++ b/templates/modals/edit_tracked_meal.html @@ -23,8 +23,8 @@
    - - + +
    diff --git a/templates/test_template.html b/templates/test_template.html new file mode 100644 index 0000000..48e813e --- /dev/null +++ b/templates/test_template.html @@ -0,0 +1,5 @@ +{% extends "base.html" %} +{% block content %} +

    Test Template

    +

    This is a test template to check if inheritance works.

    +{% endblock %} \ No newline at end of file diff --git a/tests/test_charts.py b/tests/test_charts.py index 1006f3b..f2437f5 100644 --- a/tests/test_charts.py +++ b/tests/test_charts.py @@ -10,7 +10,7 @@ def sample_chart_data(db_session): # Create sample food food = Food( name="Sample Food", - serving_size="100g", + serving_size=100.0, serving_unit="g", calories=100.0, protein=10.0, @@ -35,7 +35,7 @@ def sample_chart_data(db_session): meal_food = MealFood( meal_id=meal.id, food_id=food.id, - quantity=1.0 + quantity=100.0 ) db_session.add(meal_food) db_session.commit() diff --git a/tests/test_detailed.py b/tests/test_detailed.py index e2460da..6067d2f 100644 --- a/tests/test_detailed.py +++ b/tests/test_detailed.py @@ -25,11 +25,9 @@ TestingSessionLocal = sessionmaker(autocommit=False, autoflush=False, bind=test_ def session_fixture(): Base.metadata.create_all(bind=test_engine) db = TestingSessionLocal() - try: - yield db - finally: - db.close() - Base.metadata.drop_all(bind=test_engine) + yield db + db.close() + Base.metadata.drop_all(bind=test_engine) @pytest.fixture(name="client") def client_fixture(session): @@ -50,7 +48,7 @@ def test_detailed_page_default_date(client, session): # Create mock data for today food = Food( name="Apple", - serving_size="100", + serving_size=100.0, serving_unit="g", calories=52, protein=0.3, @@ -71,7 +69,7 @@ def test_detailed_page_default_date(client, session): session.commit() session.refresh(meal) - meal_food = MealFood(meal_id=meal.id, food_id=food.id, quantity=1.0) + meal_food = MealFood(meal_id=meal.id, food_id=food.id, quantity=100.0) session.add(meal_food) session.commit() @@ -84,7 +82,7 @@ def test_detailed_page_default_date(client, session): response = client.get("/detailed?person=Sarah") assert response.status_code == 200 # Check for the unescaped version or the page title - assert "Detailed Plan for" in response.text + assert "Detailed Plan for Sarah" in response.text assert test_date.strftime('%B %d, %Y') in response.text assert "Fruit Snack" in response.text @@ -93,7 +91,7 @@ def test_detailed_page_with_plan_date(client, session): # Create mock data food = Food( name="Apple", - serving_size="100", + serving_size=100.0, serving_unit="g", calories=52, protein=0.3, @@ -114,7 +112,7 @@ def test_detailed_page_with_plan_date(client, session): session.commit() session.refresh(meal) - meal_food = MealFood(meal_id=meal.id, food_id=food.id, quantity=1.0) + meal_food = MealFood(meal_id=meal.id, food_id=food.id, quantity=100.0) session.add(meal_food) session.commit() @@ -126,7 +124,7 @@ def test_detailed_page_with_plan_date(client, session): response = client.get(f"/detailed?person=Sarah&plan_date={test_date.isoformat()}") assert response.status_code == 200 # Check for the page content without assuming apostrophe encoding - assert "Detailed Plan for" in response.text + assert "Detailed Plan for Sarah" in response.text assert "Fruit Snack" in response.text @@ -134,7 +132,7 @@ def test_detailed_page_with_template_id(client, session): # Create mock data food = Food( name="Banana", - serving_size="100", + serving_size=100.0, serving_unit="g", calories=89, protein=1.1, @@ -155,7 +153,7 @@ def test_detailed_page_with_template_id(client, session): session.commit() session.refresh(meal) - meal_food = MealFood(meal_id=meal.id, food_id=food.id, quantity=1.0) + meal_food = MealFood(meal_id=meal.id, food_id=food.id, quantity=100.0) session.add(meal_food) session.commit() @@ -177,7 +175,7 @@ def test_detailed_page_with_tracked_day_food_breakdown(client, session): # Create mock data for a tracked day food1 = Food( name="Chicken Breast", - serving_size="100", + serving_size=100.0, serving_unit="g", calories=165, protein=31, carbs=0, fat=3.6, fiber=0, sugar=0, sodium=74, calcium=11, @@ -185,7 +183,7 @@ def test_detailed_page_with_tracked_day_food_breakdown(client, session): ) food2 = Food( name="Broccoli", - serving_size="100", + serving_size=100.0, serving_unit="g", calories=55, protein=3.7, carbs=11.2, fat=0.6, fiber=5.1, sugar=2.2, sodium=33, calcium=47, @@ -201,8 +199,8 @@ def test_detailed_page_with_tracked_day_food_breakdown(client, session): session.commit() session.refresh(meal) - meal_food1 = MealFood(meal_id=meal.id, food_id=food1.id, quantity=1.5) # 150g chicken - meal_food2 = MealFood(meal_id=meal.id, food_id=food2.id, quantity=2.0) # 200g broccoli + meal_food1 = MealFood(meal_id=meal.id, food_id=food1.id, quantity=150.0) # 150g chicken + meal_food2 = MealFood(meal_id=meal.id, food_id=food2.id, quantity=200.0) # 200g broccoli session.add_all([meal_food1, meal_food2]) session.commit() @@ -230,8 +228,8 @@ def test_detailed_page_with_tracked_day_food_breakdown(client, session): assert "Chicken and Broccoli" in response.text assert "Chicken Breast" in response.text assert "Broccoli" in response.text - assert "1.5 × 100g" in response.text # Check quantity and unit for chicken - assert "2.0 × 100g" in response.text # Check quantity and unit for broccoli + assert "150.0g of Chicken Breast (1.5 servings of 100.0g)" in response.text + assert "200.0g of Broccoli (2.0 servings of 100.0g)" in response.text assert "248" in response.text # Check calories for chicken (1.5 * 165 = 247.5, rounded to 248) assert "110" in response.text # Check calories for broccoli (2.0 * 55 = 110) @@ -240,7 +238,7 @@ def test_detailed_page_with_invalid_plan_date(client): response = client.get(f"/detailed?person=Sarah&plan_date={invalid_date.isoformat()}") assert response.status_code == 200 # Check for content that indicates empty plan - assert "Detailed Plan for" in response.text + assert "Detailed Plan for Sarah" in response.text assert "No meals planned for this day." in response.text diff --git a/tests/test_edit_tracked_meal.py b/tests/test_edit_tracked_meal.py index b7f9e80..ec9d3dd 100644 --- a/tests/test_edit_tracked_meal.py +++ b/tests/test_edit_tracked_meal.py @@ -107,8 +107,8 @@ def test_edit_tracked_meal_with_override_flow(client: TestClient, session: Testi # Prepare update data: update food1 quantity (should create a TrackedMealFood and delete original MealFood) updated_foods_data = [ - {"id": original_meal_food1.id, "food_id": food1.id, "quantity": 175.0, "is_custom": False}, # Original MealFood, but quantity changed - {"id": None, "food_id": food2.id, "quantity": 100.0, "is_custom": False} # Unchanged original MealFood + {"id": original_meal_food1.id, "food_id": food1.id, "grams": 175.0, "is_custom": False}, # Original MealFood, but quantity changed + {"id": None, "food_id": food2.id, "grams": 100.0, "is_custom": False} # Unchanged original MealFood ] response_update = client.post( @@ -169,8 +169,8 @@ def test_update_tracked_meal_foods_endpoint(client: TestClient, session: Testing # Prepare update data updated_foods = [ - {"id": tracked_meal_food1.id, "food_id": food1.id, "quantity": 200.0, "is_custom": True}, - {"id": None, "food_id": food2.id, "quantity": 50.0, "is_custom": False} # This represents original meal food + {"id": tracked_meal_food1.id, "food_id": food1.id, "grams": 200.0, "is_custom": True}, + {"id": None, "food_id": food2.id, "grams": 50.0, "is_custom": False} # This represents original meal food ] response = client.post( @@ -210,7 +210,7 @@ def test_add_food_to_tracked_meal_endpoint(client: TestClient, session: TestingS json={ "tracked_meal_id": tracked_meal.id, "food_id": food3.id, - "quantity": 200 + "grams": 200 } ) assert response.status_code == 200 diff --git a/tests/test_food_weight_consistency.py b/tests/test_food_weight_consistency.py index 0e7a685..e43159a 100644 --- a/tests/test_food_weight_consistency.py +++ b/tests/test_food_weight_consistency.py @@ -1,8 +1,11 @@ import pytest from fastapi.testclient import TestClient from sqlalchemy import create_engine +from app.database import calculate_multiplier_from_grams from sqlalchemy.orm import sessionmaker from app.database import Base, get_db, Food, Meal, MealFood +from app.database import TrackedMealFood +from app.database import TrackedDay, TrackedMeal from main import app # Setup a test database @@ -47,19 +50,19 @@ def sample_food_50g(session): def test_convert_grams_to_quantity_100g_food(session, sample_food_100g): """Test convert_grams_to_quantity for a 100g serving size food""" grams = 150.0 - quantity = session.convert_grams_to_quantity(sample_food_100g.id, grams, session) + quantity = calculate_multiplier_from_grams(sample_food_100g.id, grams, session) assert quantity == 1.5 def test_convert_grams_to_quantity_50g_food(session, sample_food_50g): """Test convert_grams_to_quantity for a 50g serving size food""" grams = 125.0 - quantity = session.convert_grams_to_quantity(sample_food_50g.id, grams, session) + quantity = calculate_multiplier_from_grams(sample_food_50g.id, grams, session) assert quantity == 2.5 def test_convert_grams_to_quantity_invalid_food_id(session): """Test convert_grams_to_quantity with an invalid food ID""" with pytest.raises(ValueError, match="Food with ID 999 not found."): - session.convert_grams_to_quantity(999, 100.0, session) + calculate_multiplier_from_grams(999, 100.0, session) def test_convert_grams_to_quantity_zero_serving_size(session): """Test convert_grams_to_quantity with zero serving size""" @@ -68,7 +71,7 @@ def test_convert_grams_to_quantity_zero_serving_size(session): session.commit() session.refresh(food) with pytest.raises(ValueError, match="Serving size for food ID .* cannot be zero."): - session.convert_grams_to_quantity(food.id, 100.0, session) + calculate_multiplier_from_grams(food.id, 100.0, session) def test_add_food_to_meal_grams_input(client, session, sample_food_100g): """Test adding food to a meal with grams input""" @@ -79,14 +82,14 @@ def test_add_food_to_meal_grams_input(client, session, sample_food_100g): response = client.post( f"/meals/{meal.id}/add_food", - data={"food_id": sample_food_100g.id, "quantity": 250.0} # 250 grams + data={"food_id": sample_food_100g.id, "grams": 250.0} # 250 grams ) assert response.status_code == 200 assert response.json()["status"] == "success" meal_food = session.query(MealFood).filter(MealFood.meal_id == meal.id).first() assert meal_food.food_id == sample_food_100g.id - assert meal_food.quantity == 2.5 # 250g / 100g serving = 2.5 multiplier + assert meal_food.quantity == 250.0 def test_update_meal_food_quantity_grams_input(client, session, sample_food_50g): """Test updating meal food quantity with grams input""" @@ -97,8 +100,7 @@ def test_update_meal_food_quantity_grams_input(client, session, sample_food_50g) # Add initial food with 100g (2.0 multiplier for 50g serving) initial_grams = 100.0 - initial_quantity = session.convert_grams_to_quantity(sample_food_50g.id, initial_grams, session) - meal_food = MealFood(meal_id=meal.id, food_id=sample_food_50g.id, quantity=initial_quantity) + meal_food = MealFood(meal_id=meal.id, food_id=sample_food_50g.id, quantity=initial_grams) session.add(meal_food) session.commit() session.refresh(meal_food) @@ -106,14 +108,13 @@ def test_update_meal_food_quantity_grams_input(client, session, sample_food_50g) updated_grams = 150.0 response = client.post( "/meals/update_food_quantity", - data={"meal_food_id": meal_food.id, "quantity": updated_grams} + data={"meal_food_id": meal_food.id, "grams": updated_grams} ) assert response.status_code == 200 assert response.json()["status"] == "success" session.refresh(meal_food) - expected_quantity = session.convert_grams_to_quantity(sample_food_50g.id, updated_grams, session) - assert meal_food.quantity == expected_quantity + assert meal_food.quantity == updated_grams # Test for bulk_upload_meals would require creating a mock UploadFile and CSV content # This is more complex and might be deferred or tested manually if the tool's capabilities are limited. @@ -131,7 +132,7 @@ def test_tracker_add_food_grams_input(client, session, sample_food_100g): "person": person, "date": date_str, "food_id": sample_food_100g.id, - "quantity": grams, # 75 grams + "grams": grams, # 75 grams "meal_time": "Breakfast" } ) @@ -142,7 +143,7 @@ def test_tracker_add_food_grams_input(client, session, sample_food_100g): tracked_meal = session.query(Meal).filter(Meal.name == sample_food_100g.name).first() assert tracked_meal is not None meal_food = session.query(MealFood).filter(MealFood.meal_id == tracked_meal.id).first() - assert meal_food.quantity == 0.75 # 75g / 100g serving = 0.75 multiplier + assert meal_food.quantity == grams def test_update_tracked_meal_foods_grams_input(client, session, sample_food_100g, sample_food_50g): """Test updating tracked meal foods with grams input""" @@ -150,7 +151,8 @@ def test_update_tracked_meal_foods_grams_input(client, session, sample_food_100g date_str = "2023-01-02" # Create a tracked day and meal - tracked_day = TrackedDay(person=person, date="2023-01-02", is_modified=False) + from datetime import date + tracked_day = TrackedDay(person=person, date=date(2023, 1, 2), is_modified=False) session.add(tracked_day) session.commit() session.refresh(tracked_day) @@ -166,8 +168,8 @@ def test_update_tracked_meal_foods_grams_input(client, session, sample_food_100g session.refresh(tracked_meal) # Add initial foods - meal_food_100g = MealFood(meal_id=meal.id, food_id=sample_food_100g.id, quantity=1.0) # 100g - meal_food_50g = MealFood(meal_id=meal.id, food_id=sample_food_50g.id, quantity=2.0) # 100g + meal_food_100g = MealFood(meal_id=meal.id, food_id=sample_food_100g.id, quantity=100.0) # 100g + meal_food_50g = MealFood(meal_id=meal.id, food_id=sample_food_50g.id, quantity=100.0) # 100g session.add_all([meal_food_100g, meal_food_50g]) session.commit() session.refresh(meal_food_100g) @@ -175,8 +177,8 @@ def test_update_tracked_meal_foods_grams_input(client, session, sample_food_100g # Update quantities: 100g food to 200g, 50g food to 75g updated_foods_data = [ - {"id": meal_food_100g.id, "food_id": sample_food_100g.id, "quantity": 200.0, "is_custom": False}, - {"id": meal_food_50g.id, "food_id": sample_food_50g.id, "quantity": 75.0, "is_custom": False} + {"id": meal_food_100g.id, "food_id": sample_food_100g.id, "grams": 200.0, "is_custom": False}, + {"id": meal_food_50g.id, "food_id": sample_food_50g.id, "grams": 75.0, "is_custom": False} ] response = client.post( @@ -194,10 +196,10 @@ def test_update_tracked_meal_foods_grams_input(client, session, sample_food_100g TrackedMealFood.tracked_meal_id == tracked_meal.id, TrackedMealFood.food_id == sample_food_100g.id ).first() - assert tracked_food_100g.quantity == 2.0 # 200g / 100g serving = 2.0 + assert tracked_food_100g.quantity == 200.0 tracked_food_50g = session.query(TrackedMealFood).filter( TrackedMealFood.tracked_meal_id == tracked_meal.id, TrackedMealFood.food_id == sample_food_50g.id ).first() - assert tracked_food_50g.quantity == 1.5 # 75g / 50g serving = 1.5 + assert tracked_food_50g.quantity == 75.0 diff --git a/tests/test_meals.py b/tests/test_meals.py index 3ab1c5a..5116928 100644 --- a/tests/test_meals.py +++ b/tests/test_meals.py @@ -92,7 +92,7 @@ class TestMealFoods: """Test POST /meals/{meal_id}/add_food""" response = client.post(f"/meals/{sample_meal.id}/add_food", data={ "food_id": sample_food.id, - "quantity": 2.5 + "grams": 250 }) assert response.status_code == 200 data = response.json() diff --git a/tests/test_quantity_consistency.py b/tests/test_quantity_consistency.py new file mode 100644 index 0000000..01bd9ea --- /dev/null +++ b/tests/test_quantity_consistency.py @@ -0,0 +1,196 @@ +import pytest + +from app.database import ( + calculate_meal_nutrition, + Food, + Meal, + MealFood, +) + + +def test_meal_nutrition_uses_grams_correctly(db_session): + """Verify that MealFood.quantity as grams calculates nutrition correctly""" + # Create a food: 100 cal per 100g + food = Food( + name="Test Food", + serving_size=100.0, + serving_unit="g", + calories=100.0, + protein=10.0, + carbs=20.0, + fat=5.0 + ) + db_session.add(food) + db_session.commit() + + # Create a meal with 200g of this food + meal = Meal(name="Test Meal", meal_type="breakfast") + db_session.add(meal) + db_session.commit() + + meal_food = MealFood( + meal_id=meal.id, + food_id=food.id, + quantity=200.0 # 200 grams + ) + db_session.add(meal_food) + db_session.commit() + + # Calculate nutrition + nutrition = calculate_meal_nutrition(meal, db_session) + + # Should be 2x the base values (200g / 100g = 2x multiplier) + assert nutrition['calories'] == 200.0 + assert nutrition['protein'] == 20.0 + assert nutrition['carbs'] == 40.0 + assert nutrition['fat'] == 10.0 + + +def test_fractional_servings(db_session): + """Test that fractional grams work correctly""" + food = Food( + name="Test Food", + serving_size=100.0, + serving_unit="g", + calories=100.0 + ) + db_session.add(food) + db_session.commit() + + meal = Meal(name="Test Meal") + db_session.add(meal) + db_session.commit() + + # Add 50g (half serving) + meal_food = MealFood( + meal_id=meal.id, + food_id=food.id, + quantity=50.0 + ) + db_session.add(meal_food) + db_session.commit() + + nutrition = calculate_meal_nutrition(meal, db_session) + assert nutrition['calories'] == 50.0 + + +def test_zero_serving_size_handling(db_session): + """Test handling of zero serving_size - should not divide by zero""" + food = Food( + name="Test Food Zero Serving", + serving_size=0.0, + serving_unit="g", + calories=100.0 + ) + db_session.add(food) + db_session.commit() + + meal = Meal(name="Test Meal Zero") + db_session.add(meal) + db_session.commit() + + meal_food = MealFood( + meal_id=meal.id, + food_id=food.id, + quantity=100.0 + ) + db_session.add(meal_food) + db_session.commit() + + nutrition = calculate_meal_nutrition(meal, db_session) + # Multiplier should be 0, so no nutrition added + assert nutrition['calories'] == 0.0 + + +def test_small_gram_values(db_session): + """Test very small gram values (e.g., 0.1g)""" + food = Food( + name="Test Food Small", + serving_size=100.0, + serving_unit="g", + calories=100.0 + ) + db_session.add(food) + db_session.commit() + + meal = Meal(name="Test Meal Small") + db_session.add(meal) + db_session.commit() + + meal_food = MealFood( + meal_id=meal.id, + food_id=food.id, + quantity=0.1 # Very small amount + ) + db_session.add(meal_food) + db_session.commit() + + nutrition = calculate_meal_nutrition(meal, db_session) + # Should be 0.001x multiplier + assert nutrition['calories'] == 0.1 + + +def test_large_gram_values(db_session): + """Test large gram values (e.g., 10000g)""" + food = Food( + name="Test Food Large", + serving_size=100.0, + serving_unit="g", + calories=100.0 + ) + db_session.add(food) + db_session.commit() + + meal = Meal(name="Test Meal Large") + db_session.add(meal) + db_session.commit() + + meal_food = MealFood( + meal_id=meal.id, + food_id=food.id, + quantity=10000.0 # Very large amount + ) + db_session.add(meal_food) + db_session.commit() + + nutrition = calculate_meal_nutrition(meal, db_session) + # Should be 100x multiplier + assert nutrition['calories'] == 10000.0 + + +def test_invalid_serving_size(db_session): + """Test invalid/non-numeric serving_size values""" + # First create a valid food to test with + valid_food = Food( + name="Test Food Valid", + serving_size=100.0, + serving_unit="g", + calories=100.0 + ) + db_session.add(valid_food) + db_session.commit() + + # Now create a meal and add the valid food + meal = Meal(name="Test Meal Valid") + db_session.add(meal) + db_session.commit() + + meal_food = MealFood( + meal_id=meal.id, + food_id=valid_food.id, + quantity=100.0 + ) + db_session.add(meal_food) + db_session.commit() + + # Test that the calculation works with valid serving_size + nutrition = calculate_meal_nutrition(meal, db_session) + assert nutrition['calories'] == 100.0 + + # Now test with None serving_size by updating the food + valid_food.serving_size = None + db_session.commit() + + # Recalculate - should handle None gracefully + nutrition = calculate_meal_nutrition(meal, db_session) + assert nutrition['calories'] == 0 \ No newline at end of file diff --git a/tests/test_tracker.py b/tests/test_tracker.py index 4c159ba..b4515cd 100644 --- a/tests/test_tracker.py +++ b/tests/test_tracker.py @@ -280,7 +280,7 @@ class TestTrackerEdit: # Update the food quantity via API response = client.post("/tracker/update_tracked_food", json={ "tracked_food_id": tracked_food.id, - "quantity": 3.0, + "grams": 300.0, "is_custom": True }) assert response.status_code == 200 @@ -290,7 +290,7 @@ class TestTrackerEdit: # Verify the update db_session.commit() updated_food = db_session.query(TrackedMealFood).get(tracked_food.id) - assert updated_food.quantity == 3.0 + assert updated_food.quantity == 300.0 assert updated_food.quantity != original_quantity @@ -332,7 +332,7 @@ class TestTrackerSaveAsNewMeal: "tracked_meal_id": tracked_meal.id, "new_meal_name": new_meal_name, "foods": [ - {"food_id": sample_food.id, "quantity": 3.0} + {"food_id": sample_food.id, "grams": 300.0} ] }) assert response.status_code == 200 @@ -404,7 +404,7 @@ class TestTrackerAddFood: "person": "Sarah", "date": date.today().isoformat(), "food_id": sample_food.id, - "quantity": 150.0, + "grams": 150.0, "meal_time": "Dinner" }) assert response.status_code == 200 @@ -422,7 +422,7 @@ class TestTrackerAddFood: assert len(tracked_meal.meal.meal_foods) == 1 assert tracked_meal.meal.meal_foods[0].food_id == sample_food.id - assert tracked_meal.meal.meal_foods[0].quantity == 1.5 + assert tracked_meal.meal.meal_foods[0].quantity == 150.0 def test_add_food_quantity_is_correctly_converted_to_servings(self, client, db_session): """ @@ -449,7 +449,7 @@ class TestTrackerAddFood: "person": "Sarah", "date": date.today().isoformat(), "food_id": food.id, - "quantity": grams_to_add, + "grams": grams_to_add, "meal_time": "Snack 1" }) assert response.status_code == 200 @@ -469,7 +469,7 @@ class TestTrackerAddFood: # Verify the food is in the tracked meal's foods and quantity is in servings assert len(tracked_meal.meal.meal_foods) == 1 assert tracked_meal.meal.meal_foods[0].food_id == food.id - assert tracked_meal.meal.meal_foods[0].quantity == expected_servings + assert tracked_meal.meal.meal_foods[0].quantity == grams_to_add # Verify nutrition calculation day_nutrition = calculate_day_nutrition_tracked([tracked_meal], db_session)