mirror of
https://github.com/sstent/foodplanner.git
synced 2026-03-30 09:05:23 +00:00
Compare commits
14 Commits
8d3e91a825
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
| e411a8f6c4 | |||
| e68a1f52af | |||
| 7fc17967e0 | |||
| e47af2c839 | |||
| 7099577f92 | |||
| ced3c63f92 | |||
| 5c73ce9caf | |||
| 0e4cb5a5ed | |||
| b834e89a97 | |||
| 00600a76fa | |||
| cc6b4ca145 | |||
| f0430c810b | |||
| 326a82ea5d | |||
| afdf9fa5b7 |
59
.github/workflows/build-and-push-dev.yml
vendored
Normal file
59
.github/workflows/build-and-push-dev.yml
vendored
Normal file
@@ -0,0 +1,59 @@
|
||||
name: Build and Push Docker Image (DEV)
|
||||
|
||||
on:
|
||||
workflow_dispatch:
|
||||
push:
|
||||
branches:
|
||||
- dev
|
||||
|
||||
jobs:
|
||||
build-and-push:
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
contents: read
|
||||
packages: write
|
||||
outputs:
|
||||
container_sha: ${{ github.sha }}
|
||||
registry_url: ${{ steps.registry.outputs.url }}
|
||||
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Set registry URL
|
||||
id: registry
|
||||
run: |
|
||||
if [ "${{ github.server_url }}" = "https://github.com" ]; then
|
||||
echo "url=ghcr.io" >> $GITHUB_OUTPUT
|
||||
else
|
||||
echo "url=${{ github.server_url }}" | sed 's|https://||' >> $GITHUB_OUTPUT
|
||||
fi
|
||||
|
||||
- name: Set up QEMU
|
||||
uses: docker/setup-qemu-action@v3
|
||||
|
||||
- name: Log in to Container Registry
|
||||
uses: docker/login-action@v3
|
||||
with:
|
||||
registry: ${{ steps.registry.outputs.url }}
|
||||
username: ${{ github.actor }}
|
||||
password: ${{ secrets.PACKAGE_TOKEN || secrets.GITHUB_TOKEN }}
|
||||
|
||||
- name: Set up Docker Buildx
|
||||
uses: docker/setup-buildx-action@v3
|
||||
|
||||
- name: Build and push multi-arch Docker image
|
||||
uses: docker/build-push-action@v5
|
||||
with:
|
||||
context: .
|
||||
push: true
|
||||
platforms: linux/amd64,linux/arm64
|
||||
tags: |
|
||||
${{ steps.registry.outputs.url }}/${{ github.repository }}:dev
|
||||
${{ steps.registry.outputs.url }}/${{ github.repository }}:${{ github.sha }}
|
||||
build-args: |
|
||||
COMMIT_SHA=${{ github.sha }}
|
||||
cache-from: type=registry,ref=${{ steps.registry.outputs.url }}/${{ github.repository }}:buildcache-dev
|
||||
cache-to: type=registry,ref=${{ steps.registry.outputs.url }}/${{ github.repository }}:buildcache-dev,mode=max
|
||||
|
||||
labels: org.opencontainers.image.source=${{ github.server_url }}/${{ github.repository }}
|
||||
56
.github/workflows/nomad-deploy-dev.yml
vendored
Normal file
56
.github/workflows/nomad-deploy-dev.yml
vendored
Normal file
@@ -0,0 +1,56 @@
|
||||
name: Deploy to Nomad (DEV)
|
||||
|
||||
on:
|
||||
workflow_run:
|
||||
workflows: ["Build and Push Docker Image (DEV)"] # Must match your build workflow name exactly
|
||||
types:
|
||||
- completed
|
||||
workflow_dispatch: # Allows manual triggering for testing
|
||||
inputs:
|
||||
container_sha:
|
||||
description: 'Container SHA to deploy (leave empty for latest commit)'
|
||||
required: false
|
||||
type: string
|
||||
|
||||
jobs:
|
||||
nomad:
|
||||
runs-on: ubuntu-latest
|
||||
name: Deploy to Nomad (DEV)
|
||||
# Only run if the build workflow succeeded
|
||||
if: ${{ github.event.workflow_run.conclusion == 'success' || github.event_name == 'workflow_dispatch' }}
|
||||
|
||||
steps:
|
||||
# 1. Checkout Code
|
||||
- name: Checkout Repository
|
||||
uses: actions/checkout@v4
|
||||
|
||||
# 2. Install Nomad CLI
|
||||
- name: Setup Nomad CLI
|
||||
uses: hashicorp/setup-nomad@main
|
||||
with:
|
||||
version: '1.10.0' # Use your desired version or remove for 'latest'
|
||||
|
||||
# 3. Determine container version to deploy
|
||||
- name: Set Container Version
|
||||
id: container_version
|
||||
run: |
|
||||
if [ "${{ github.event_name }}" = "workflow_dispatch" ] && [ -n "${{ inputs.container_sha }}" ]; then
|
||||
echo "sha=${{ inputs.container_sha }}" >> $GITHUB_OUTPUT
|
||||
elif [ "${{ github.event_name }}" = "workflow_run" ]; then
|
||||
echo "sha=${{ github.event.workflow_run.head_sha }}" >> $GITHUB_OUTPUT
|
||||
else
|
||||
echo "sha=${{ github.sha }}" >> $GITHUB_OUTPUT
|
||||
fi
|
||||
|
||||
# 4. Deploy the Nomad Job
|
||||
- name: Deploy Nomad Job
|
||||
id: deploy
|
||||
env:
|
||||
# REQUIRED: Set the Nomad server address
|
||||
NOMAD_ADDR: http://nomad.service.dc1.consul:4646
|
||||
run: |
|
||||
echo "Deploying container version: ${{ steps.container_version.outputs.sha }}"
|
||||
nomad status
|
||||
nomad job run \
|
||||
-var="container_version=${{ steps.container_version.outputs.sha }}" \
|
||||
foodplanner-dev.nomad
|
||||
27
alembic/versions/7fdcc454e056_add_name_to_tracked_meal.py
Normal file
27
alembic/versions/7fdcc454e056_add_name_to_tracked_meal.py
Normal file
@@ -0,0 +1,27 @@
|
||||
"""add_name_to_tracked_meal
|
||||
|
||||
Revision ID: 7fdcc454e056
|
||||
Revises: e1c2d8d5c1a8
|
||||
Create Date: 2026-02-24 06:29:46.441129
|
||||
|
||||
"""
|
||||
from typing import Sequence, Union
|
||||
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
|
||||
|
||||
# revision identifiers, used by Alembic.
|
||||
revision: str = '7fdcc454e056'
|
||||
down_revision: Union[str, None] = 'e1c2d8d5c1a8'
|
||||
branch_labels: Union[str, Sequence[str], None] = None
|
||||
depends_on: Union[str, Sequence[str], None] = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
op.add_column('tracked_meals', sa.Column('name', sa.String(), nullable=True))
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
with op.batch_alter_table('tracked_meals') as batch_op:
|
||||
batch_op.drop_column('name')
|
||||
@@ -15,7 +15,10 @@ router = APIRouter()
|
||||
@router.get("/meals", response_class=HTMLResponse)
|
||||
async def meals_page(request: Request, db: Session = Depends(get_db)):
|
||||
from sqlalchemy.orm import joinedload
|
||||
meals = db.query(Meal).options(joinedload(Meal.meal_foods).joinedload(MealFood.food)).all()
|
||||
# Filter out single food entries and snapshots
|
||||
meals = db.query(Meal).filter(
|
||||
Meal.meal_type.notin_(["single_food", "tracked_snapshot"])
|
||||
).options(joinedload(Meal.meal_foods).joinedload(MealFood.food)).all()
|
||||
foods = db.query(Food).all()
|
||||
return templates.TemplateResponse("meals.html",
|
||||
{"request": request, "meals": meals, "foods": foods})
|
||||
|
||||
@@ -746,23 +746,25 @@ async def tracker_add_food(data: dict = Body(...), db: Session = Depends(get_db)
|
||||
# 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
|
||||
new_meal = Meal(name=food_item.name, meal_type="single_food", meal_time=meal_time)
|
||||
db.add(new_meal)
|
||||
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=grams)
|
||||
db.add(meal_food)
|
||||
|
||||
# Create tracked meal entry
|
||||
# Create tracked meal entry without a parent Meal template
|
||||
tracked_meal = TrackedMeal(
|
||||
tracked_day_id=tracked_day.id,
|
||||
meal_id=new_meal.id,
|
||||
meal_time=meal_time
|
||||
meal_id=None,
|
||||
meal_time=meal_time,
|
||||
name=food_item.name
|
||||
)
|
||||
db.add(tracked_meal)
|
||||
db.flush() # Flush to get the tracked_meal ID
|
||||
|
||||
# Link the food directly to the tracked meal via TrackedMealFood
|
||||
new_entry = TrackedMealFood(
|
||||
tracked_meal_id=tracked_meal.id,
|
||||
food_id=food_id,
|
||||
quantity=grams,
|
||||
is_override=False,
|
||||
is_deleted=False
|
||||
)
|
||||
db.add(new_entry)
|
||||
|
||||
# Mark day as modified
|
||||
tracked_day.is_modified = True
|
||||
|
||||
@@ -148,8 +148,9 @@ class TrackedMeal(Base):
|
||||
|
||||
id = Column(Integer, primary_key=True, index=True)
|
||||
tracked_day_id = Column(Integer, ForeignKey("tracked_days.id"))
|
||||
meal_id = Column(Integer, ForeignKey("meals.id"))
|
||||
meal_id = Column(Integer, ForeignKey("meals.id"), nullable=True)
|
||||
meal_time = Column(String) # Breakfast, Lunch, Dinner, Snack 1, Snack 2, Beverage 1, Beverage 2
|
||||
name = Column(String, nullable=True) # For single food items or custom names
|
||||
|
||||
tracked_day = relationship("TrackedDay", back_populates="tracked_meals")
|
||||
meal = relationship("Meal")
|
||||
@@ -427,9 +428,11 @@ def calculate_tracked_meal_nutrition(tracked_meal, db: Session):
|
||||
'fiber': 0, 'sugar': 0, 'sodium': 0, 'calcium': 0
|
||||
}
|
||||
|
||||
# 1. Get base foods from the meal
|
||||
# 1. Get base foods from the meal (if it exists)
|
||||
# access via relationship, assume eager loading or lazy loading
|
||||
base_foods = {mf.food_id: mf for mf in tracked_meal.meal.meal_foods}
|
||||
base_foods = {}
|
||||
if tracked_meal.meal:
|
||||
base_foods = {mf.food_id: mf for mf in tracked_meal.meal.meal_foods}
|
||||
|
||||
# 2. Get tracked foods (overrides, deletions, additions)
|
||||
tracked_foods = tracked_meal.tracked_foods
|
||||
|
||||
@@ -3,3 +3,6 @@
|
||||
This file tracks all major tracks for the project. Each track has its own detailed plan in its respective folder.
|
||||
|
||||
---
|
||||
|
||||
- [x] **Track: Refactor the meal tracking system to decouple 'Journal Logs' from 'Cookbook Recipes'**
|
||||
*Link: [./tracks/meal_tracker_refactor_20250223/](./tracks/meal_tracker_refactor_20250223/)*
|
||||
|
||||
5
conductor/tracks/meal_tracker_refactor_20250223/index.md
Normal file
5
conductor/tracks/meal_tracker_refactor_20250223/index.md
Normal file
@@ -0,0 +1,5 @@
|
||||
# Track meal_tracker_refactor_20250223 Context
|
||||
|
||||
- [Specification](./spec.md)
|
||||
- [Implementation Plan](./plan.md)
|
||||
- [Metadata](./metadata.json)
|
||||
@@ -0,0 +1,8 @@
|
||||
{
|
||||
"track_id": "meal_tracker_refactor_20250223",
|
||||
"type": "refactor",
|
||||
"status": "new",
|
||||
"created_at": "2025-02-23T12:00:00Z",
|
||||
"updated_at": "2025-02-23T12:00:00Z",
|
||||
"description": "Refactor the meal tracking system to decouple 'Journal Logs' from 'Cookbook Recipes', resolving database pollution and improving system structure."
|
||||
}
|
||||
28
conductor/tracks/meal_tracker_refactor_20250223/plan.md
Normal file
28
conductor/tracks/meal_tracker_refactor_20250223/plan.md
Normal file
@@ -0,0 +1,28 @@
|
||||
# Implementation Plan - Meal Tracker Refactoring
|
||||
|
||||
This plan outlines the steps for refactoring the meal tracking system to decouple "Journal Logs" from "Cookbook Recipes," resolving database pollution and improving system structure.
|
||||
|
||||
## Phase 1: Preparation & Schema Updates [checkpoint: 326a82e]
|
||||
- [x] Task: Create a new branch for the refactoring track.
|
||||
- [x] Task: Add the 'name' column to the 'TrackedMeal' table and make 'meal_id' nullable in 'app/database.py'.
|
||||
- [x] Task: Create and run an Alembic migration for the schema changes.
|
||||
- [ ] Task: Conductor - User Manual Verification 'Phase 1: Preparation & Schema Updates' (Protocol in workflow.md)
|
||||
|
||||
## Phase 2: Logic & Calculation Updates [checkpoint: cc6b4ca]
|
||||
- [ ] Task: Write failing unit tests for 'calculate_tracked_meal_nutrition' with 'meal_id=None'.
|
||||
- [ ] Task: Implement support for 'meal_id=None' in 'calculate_tracked_meal_nutrition' within 'app/database.py'.
|
||||
- [ ] Task: Write failing unit tests for the refactored 'tracker_add_food' endpoint.
|
||||
- [ ] Task: Refactor the 'tracker_add_food' route in 'app/api/routes/tracker.py' to use the new 'TrackedMeal' structure.
|
||||
- [ ] Task: Conductor - User Manual Verification 'Phase 2: Logic & Calculation Updates' (Protocol in workflow.md)
|
||||
|
||||
## Phase 3: UI & Cookbook Refinement [checkpoint: b834e89]
|
||||
- [ ] Task: Update the 'tracker.html' template to display 'TrackedMeal.name' for template-less logs.
|
||||
- [ ] Task: Update the Meals page in 'app/api/routes/meals.py' to filter out 'single_food' and 'snapshot' types.
|
||||
- [ ] Task: Write failing E2E tests for the new tracking workflow.
|
||||
- [ ] Task: Conductor - User Manual Verification 'Phase 3: UI & Cookbook Refinement' (Protocol in workflow.md)
|
||||
|
||||
## Phase 4: Database Migration & Cleanup [checkpoint: 5c73ce9]
|
||||
- [x] Task: Create a Python migration script for cleaning up existing 'single_food' entries.
|
||||
- [x] Task: Run the migration script on the development PostgreSQL database.
|
||||
- [x] Task: Verify the database state and ensure no orphans remain.
|
||||
- [x] Task: Conductor - User Manual Verification 'Phase 4: Database Migration & Cleanup' (Protocol in workflow.md)
|
||||
28
conductor/tracks/meal_tracker_refactor_20250223/spec.md
Normal file
28
conductor/tracks/meal_tracker_refactor_20250223/spec.md
Normal file
@@ -0,0 +1,28 @@
|
||||
# Specification - Meal Tracker Refactoring
|
||||
|
||||
**Overview:**
|
||||
Refactor the meal tracking system to decouple "Journal Logs" from "Cookbook Recipes". Currently, adding a single food item via the tracker incorrectly creates a permanent 'Meal' record of type 'single_food', leading to database pollution and duplicate entries in the Meals library.
|
||||
|
||||
**Functional Requirements:**
|
||||
- **TrackedMeal Schema Update:** Add a 'name' column to the 'TrackedMeal' model to store the display name of a logged meal or a single food item.
|
||||
- **Nullable meal_id:** Modify 'TrackedMeal.meal_id' to be nullable, allowing "template-less" logs.
|
||||
- **Refactored Tracker Logic:** Update the 'tracker_add_food' route to log single items directly as a 'TrackedMeal' with 'meal_id=NULL' and the 'name' set to the food item's name.
|
||||
- **Nutrition Calculation:** Update nutrition calculation logic to handle 'TrackedMeal' entries without a parent 'Meal' template.
|
||||
- **Tracker UI Update:** Ensure the tracker page displays 'TrackedMeal.name' for these logs and maintains the seamless visual style of existing entries.
|
||||
- **Cookbook Cleanup (One-time Migration):** Migrate existing 'single_food' meals to the new format and purge the redundant records from the 'meals' and 'meal_foods' tables.
|
||||
- **Cookbook Filtering:** Update the Meals page to exclude 'single_food' and 'snapshot' meal types from view.
|
||||
|
||||
**Non-Functional Requirements:**
|
||||
- **Database Integrity:** Ensure all existing logs remain accurate and correctly linked to their food items during migration.
|
||||
- **Performance:** The tracker page should remain fast and responsive with the new logic.
|
||||
|
||||
**Acceptance Criteria:**
|
||||
- [ ] Adding a single food to the tracker does **not** create a new entry in the 'meals' table.
|
||||
- [ ] Existing 'single_food' duplicates are removed from the 'meals' library.
|
||||
- [ ] The Meals page only shows "Cookbook Recipes" (e.g., proper combined meals).
|
||||
- [ ] The Tracker page correctly displays names and calculates nutrition for all logs (both template-based and template-less).
|
||||
- [ ] "Save as New Meal" remains available for all log entries, including single foods.
|
||||
|
||||
**Out of Scope:**
|
||||
- Refactoring the entire meal planning system beyond the tracker/cookbook separation.
|
||||
- Changes to the external Open Food Facts integration.
|
||||
59
foodplanner-dev.nomad
Normal file
59
foodplanner-dev.nomad
Normal file
@@ -0,0 +1,59 @@
|
||||
variable "container_version" {
|
||||
default = "dev"
|
||||
}
|
||||
|
||||
job "foodplanner-dev" {
|
||||
datacenters = ["dc1"]
|
||||
|
||||
type = "service"
|
||||
|
||||
group "app" {
|
||||
count = 1
|
||||
|
||||
network {
|
||||
port "http" {
|
||||
to = 8999
|
||||
}
|
||||
}
|
||||
|
||||
service {
|
||||
name = "foodplanner-dev"
|
||||
port = "http"
|
||||
|
||||
check {
|
||||
type = "http"
|
||||
path = "/"
|
||||
interval = "10s"
|
||||
timeout = "2s"
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
task "app" {
|
||||
driver = "docker"
|
||||
|
||||
config {
|
||||
image = "ghcr.io/sstent/foodplanner:${var.container_version}"
|
||||
ports = ["http"]
|
||||
}
|
||||
env {
|
||||
DATABASE_URL = "postgresql://postgres:postgres@master.postgres.service.dc1.consul/meal_planner_dev"
|
||||
|
||||
}
|
||||
resources {
|
||||
cpu = 500
|
||||
memory = 1024
|
||||
}
|
||||
|
||||
# Restart policy
|
||||
restart {
|
||||
attempts = 3
|
||||
interval = "10m"
|
||||
delay = "15s"
|
||||
mode = "fail"
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
}
|
||||
}
|
||||
@@ -70,15 +70,15 @@
|
||||
{% for tracked_meal in meals_for_time %}
|
||||
{# 1. Create stable slugs #}
|
||||
{% set meal_time_slug = meal_time|slugify %}
|
||||
{% set meal_name_safe = tracked_meal.meal.name|slugify %}
|
||||
{% set display_meal_name = (tracked_meal.name or tracked_meal.meal.name) if (tracked_meal.name or tracked_meal.meal) else "Unnamed Meal" %}
|
||||
{% set meal_name_safe = display_meal_name|slugify %}
|
||||
|
||||
{# 2. Construct the core Unique Meal ID for non-ambiguous locating #}
|
||||
{% set unique_meal_id = meal_time_slug + '-' + meal_name_safe + '-' + loop.index|string %}
|
||||
<div class="mb-3 p-3 bg-light rounded" data-testid="meal-card-{{ unique_meal_id }}">
|
||||
<div class="d-flex justify-content-between align-items-center mb-2">
|
||||
<div>
|
||||
<strong data-testid="meal-name-{{ unique_meal_id }}">{{ tracked_meal.meal.name
|
||||
}}</strong>
|
||||
<strong data-testid="meal-name-{{ unique_meal_id }}">{{ display_meal_name }}</strong>
|
||||
</div>
|
||||
<div>
|
||||
<button class="btn btn-sm btn-outline-secondary me-1"
|
||||
@@ -126,6 +126,7 @@
|
||||
</thead>
|
||||
<tbody>
|
||||
{# Display base meal foods, applying overrides #}
|
||||
{% if tracked_meal.meal %}
|
||||
{% for meal_food in tracked_meal.meal.meal_foods %}
|
||||
{% if meal_food.food_id not in deleted_food_ids and meal_food.food_id not in
|
||||
overrides.keys() %}
|
||||
@@ -162,6 +163,7 @@
|
||||
</tr>
|
||||
{% endif %}
|
||||
{% endfor %}
|
||||
{% endif %}
|
||||
|
||||
{# Display overridden/new foods #}
|
||||
{% for food_id, tmf in overrides.items() %}
|
||||
|
||||
@@ -98,20 +98,9 @@ def test_add_food_quantity_saved_correctly(test_client: TestClient, test_engine)
|
||||
query_session = sessionmaker(autocommit=False, autoflush=False, bind=test_engine)()
|
||||
|
||||
try:
|
||||
# Find the created Meal
|
||||
created_meal = query_session.query(Meal).order_by(Meal.id.desc()).first()
|
||||
assert created_meal is not None
|
||||
assert created_meal.name == "Test Food"
|
||||
assert created_meal.meal_type == "single_food"
|
||||
|
||||
# Find the MealFood
|
||||
meal_food = query_session.query(MealFood).filter(MealFood.meal_id == created_meal.id).first()
|
||||
assert meal_food is not None
|
||||
assert meal_food.food_id == food.id
|
||||
|
||||
# This assertion fails because the backend used data.get("grams", 1.0), so quantity=1.0 instead of 50.0
|
||||
# After the fix changing to data.get("quantity", 1.0), it will pass
|
||||
assert meal_food.quantity == 50.0, f"Expected quantity 50.0, but got {meal_food.quantity}"
|
||||
# Verify NO new Meal was created
|
||||
meals = query_session.query(Meal).all()
|
||||
assert len(meals) == 0
|
||||
|
||||
# Also verify TrackedDay and TrackedMeal were created
|
||||
tracked_day = query_session.query(TrackedDay).filter(
|
||||
@@ -123,8 +112,16 @@ def test_add_food_quantity_saved_correctly(test_client: TestClient, test_engine)
|
||||
|
||||
tracked_meal = query_session.query(TrackedMeal).filter(TrackedMeal.tracked_day_id == tracked_day.id).first()
|
||||
assert tracked_meal is not None
|
||||
assert tracked_meal.meal_id == created_meal.id
|
||||
assert tracked_meal.meal_id is None
|
||||
assert tracked_meal.name == "Test Food"
|
||||
assert tracked_meal.meal_time == "Snack 1"
|
||||
|
||||
# Find the TrackedMealFood
|
||||
from app.database import TrackedMealFood
|
||||
tmf = query_session.query(TrackedMealFood).filter(TrackedMealFood.tracked_meal_id == tracked_meal.id).first()
|
||||
assert tmf is not None
|
||||
assert tmf.food_id == food.id
|
||||
assert tmf.quantity == 50.0
|
||||
|
||||
finally:
|
||||
query_session.close()
|
||||
|
||||
@@ -140,10 +140,13 @@ def test_tracker_add_food_grams_input(client, session, sample_food_100g):
|
||||
assert response.json()["status"] == "success"
|
||||
|
||||
# Verify the tracked meal food quantity
|
||||
tracked_meal = session.query(Meal).filter(Meal.name == sample_food_100g.name).first()
|
||||
tracked_meal = session.query(TrackedMeal).filter(TrackedMeal.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 == grams
|
||||
assert tracked_meal.meal_id is None
|
||||
|
||||
tmf = session.query(TrackedMealFood).filter(TrackedMealFood.tracked_meal_id == tracked_meal.id).first()
|
||||
assert tmf is not None
|
||||
assert tmf.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"""
|
||||
|
||||
113
tests/test_tracked_meal_refactor.py
Normal file
113
tests/test_tracked_meal_refactor.py
Normal file
@@ -0,0 +1,113 @@
|
||||
import pytest
|
||||
from app.database import Food, TrackedMeal, TrackedMealFood, calculate_tracked_meal_nutrition
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
def test_calculate_tracked_meal_nutrition_no_meal_template(db_session: Session):
|
||||
"""Test nutrition calculation for a tracked meal with no parent meal template (meal_id=None)"""
|
||||
# Create a food
|
||||
food = Food(
|
||||
name="Test Food",
|
||||
serving_size=100.0,
|
||||
serving_unit="g",
|
||||
calories=100.0,
|
||||
protein=10.0,
|
||||
carbs=20.0,
|
||||
fat=5.0,
|
||||
fiber=5.0,
|
||||
sugar=10.0,
|
||||
sodium=100.0,
|
||||
calcium=50.0
|
||||
)
|
||||
db_session.add(food)
|
||||
db_session.commit()
|
||||
db_session.refresh(food)
|
||||
|
||||
# Create a tracked meal without a template
|
||||
tracked_meal = TrackedMeal(
|
||||
meal_id=None,
|
||||
meal_time="Snack",
|
||||
name="Single Food Log"
|
||||
)
|
||||
db_session.add(tracked_meal)
|
||||
db_session.commit()
|
||||
db_session.refresh(tracked_meal)
|
||||
|
||||
# Add a tracked food entry to it
|
||||
tracked_food = TrackedMealFood(
|
||||
tracked_meal_id=tracked_meal.id,
|
||||
food_id=food.id,
|
||||
quantity=200.0, # 2 servings
|
||||
is_override=False,
|
||||
is_deleted=False
|
||||
)
|
||||
db_session.add(tracked_food)
|
||||
db_session.commit()
|
||||
db_session.refresh(tracked_food)
|
||||
|
||||
# Calculate nutrition
|
||||
nutrition = calculate_tracked_meal_nutrition(tracked_meal, db_session)
|
||||
|
||||
# Assertions
|
||||
assert nutrition['calories'] == 200.0
|
||||
assert nutrition['protein'] == 20.0
|
||||
assert nutrition['carbs'] == 40.0
|
||||
assert nutrition['fat'] == 10.0
|
||||
assert nutrition['fiber'] == 10.0
|
||||
assert nutrition['sugar'] == 20.0
|
||||
assert nutrition['sodium'] == 200.0
|
||||
assert nutrition['calcium'] == 100.0
|
||||
assert nutrition['net_carbs'] == 30.0
|
||||
assert nutrition['protein_pct'] == 40.0 # (20 * 4) / 200 = 80 / 200 = 40%
|
||||
assert nutrition['carbs_pct'] == 80.0 # (40 * 4) / 200 = 160 / 200 = 80%
|
||||
assert nutrition['fat_pct'] == 45.0 # (10 * 9) / 200 = 90 / 200 = 45%
|
||||
|
||||
def test_tracker_add_food_api_no_new_meal(client, db_session: Session):
|
||||
"""Test /tracker/add_food endpoint to ensure it doesn't create redundant Meal templates"""
|
||||
# Create a food
|
||||
food = Food(
|
||||
name="API 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()
|
||||
db_session.refresh(food)
|
||||
|
||||
from app.database import Meal
|
||||
initial_meal_count = db_session.query(Meal).count()
|
||||
|
||||
# Call the API
|
||||
response = client.post("/tracker/add_food", json={
|
||||
"person": "Sarah",
|
||||
"date": "2025-02-24",
|
||||
"food_id": food.id,
|
||||
"quantity": 150.0,
|
||||
"meal_time": "Snack"
|
||||
})
|
||||
|
||||
assert response.status_code == 200
|
||||
assert response.json()["status"] == "success"
|
||||
|
||||
# Verify NO new Meal was created
|
||||
assert db_session.query(Meal).count() == initial_meal_count
|
||||
|
||||
# Verify TrackedMeal exists with meal_id=None and correct name
|
||||
from app.database import TrackedMeal, TrackedDay
|
||||
tracked_day = db_session.query(TrackedDay).filter(TrackedDay.date == "2025-02-24").first()
|
||||
assert tracked_day is not None
|
||||
|
||||
tracked_meal = db_session.query(TrackedMeal).filter(TrackedMeal.tracked_day_id == tracked_day.id).first()
|
||||
assert tracked_meal is not None
|
||||
assert tracked_meal.meal_id is None
|
||||
assert tracked_meal.name == "API Test Food"
|
||||
|
||||
# Verify TrackedMealFood exists
|
||||
from app.database import TrackedMealFood
|
||||
tmf = db_session.query(TrackedMealFood).filter(TrackedMealFood.tracked_meal_id == tracked_meal.id).first()
|
||||
assert tmf is not None
|
||||
assert tmf.food_id == food.id
|
||||
assert tmf.quantity == 150.0
|
||||
@@ -384,12 +384,13 @@ class TestTrackerAddFood:
|
||||
assert len(tracked_meals) == 1
|
||||
|
||||
tracked_meal = tracked_meals[0]
|
||||
assert tracked_meal.meal.name == sample_food.name # The meal name should be the food name
|
||||
assert tracked_meal.name == sample_food.name # The meal name should be the food name
|
||||
assert tracked_meal.meal_id is None
|
||||
|
||||
# Verify the food is in the tracked meal's foods
|
||||
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 == 100.0
|
||||
assert len(tracked_meal.tracked_foods) == 1
|
||||
assert tracked_meal.tracked_foods[0].food_id == sample_food.id
|
||||
assert tracked_meal.tracked_foods[0].quantity == 100.0
|
||||
|
||||
|
||||
def test_add_food_to_tracker_with_meal_time(self, client, sample_food, db_session):
|
||||
@@ -418,11 +419,12 @@ class TestTrackerAddFood:
|
||||
assert len(tracked_meals) == 1
|
||||
|
||||
tracked_meal = tracked_meals[0]
|
||||
assert tracked_meal.meal.name == sample_food.name
|
||||
assert tracked_meal.name == sample_food.name
|
||||
assert tracked_meal.meal_id is None
|
||||
|
||||
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 == 150.0
|
||||
assert len(tracked_meal.tracked_foods) == 1
|
||||
assert tracked_meal.tracked_foods[0].food_id == sample_food.id
|
||||
assert tracked_meal.tracked_foods[0].quantity == 150.0
|
||||
|
||||
def test_add_food_quantity_is_correctly_converted_to_servings(self, client, db_session):
|
||||
"""
|
||||
@@ -464,12 +466,13 @@ class TestTrackerAddFood:
|
||||
assert len(tracked_meals) == 1
|
||||
|
||||
tracked_meal = tracked_meals[0]
|
||||
assert tracked_meal.meal.name == food.name
|
||||
assert tracked_meal.name == food.name
|
||||
assert tracked_meal.meal_id is None
|
||||
|
||||
# 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 == grams_to_add
|
||||
# Verify the food is in the tracked meal's foods
|
||||
assert len(tracked_meal.tracked_foods) == 1
|
||||
assert tracked_meal.tracked_foods[0].food_id == food.id
|
||||
assert tracked_meal.tracked_foods[0].quantity == grams_to_add
|
||||
|
||||
# Verify nutrition calculation
|
||||
day_nutrition = calculate_day_nutrition_tracked([tracked_meal], db_session)
|
||||
|
||||
32
tests/tracked_meal_refactor.spec.js
Normal file
32
tests/tracked_meal_refactor.spec.js
Normal file
@@ -0,0 +1,32 @@
|
||||
const { test, expect } = require('@playwright/test');
|
||||
|
||||
test('add single food to tracker and verify it is not in meals page', async ({ page }) => {
|
||||
await page.goto('/tracker');
|
||||
|
||||
// Add single food to breakfast
|
||||
await page.locator('[data-testid="add-food-breakfast"]').click();
|
||||
// Select a food (Verification Beans)
|
||||
await page.locator('#addSingleFoodModal select[name="food_id"]').selectOption({ label: 'Verification Beans' });
|
||||
await page.locator('#addSingleFoodModal input[name="quantity"]').fill('200');
|
||||
await page.getByRole('button', { name: 'Add Food', exact: true }).click();
|
||||
|
||||
// Verify it appears in the tracker
|
||||
// The name should be just the food name
|
||||
const mealNameLocator = page.locator('[data-testid^="meal-name-breakfast-verification-beans"]');
|
||||
await expect(mealNameLocator).toBeVisible();
|
||||
await expect(mealNameLocator).toHaveText('Verification Beans');
|
||||
|
||||
// Verify it contains the food with correct quantity
|
||||
const foodRowLocator = page.locator('[data-testid^="food-row-breakfast-verification-beans"][data-testid$="verification-beans"]');
|
||||
await expect(foodRowLocator).toBeVisible();
|
||||
await expect(foodRowLocator).toContainText('Verification Beans');
|
||||
await expect(foodRowLocator).toContainText('200.0 g');
|
||||
|
||||
// Navigate to Meals page
|
||||
await page.goto('/meals');
|
||||
|
||||
// Verify 'Verification Beans' is NOT in the meals list as a meal name
|
||||
// It might be in the ingredients dropdown, but shouldn't be a <strong> heading in a card
|
||||
const mealCardHeading = page.locator('.card-title:has-text("Verification Beans")');
|
||||
await expect(mealCardHeading).not.toBeVisible();
|
||||
});
|
||||
Reference in New Issue
Block a user