v0.3 step 3+4: AI plan generator + /list shopping aggregation
- migrations 012 + 013: cauldron_meal_plan_slots + cauldron_pick_points - db: list_plan_slots, save_plan_slots, delete_plan_slots, mark_plan_generated, clear_plan_generated, award_pick_points, enrich_plan_with_slots; scoreboard extended with points (sum from pick_points) and weeks_locked alias - forge.generate_plan: sonnet prompt builds 7-day plan respecting picks, validates slot count + day uniqueness + slug-in-pool, fills picker_subs from ground-truth picks (model output is advisory) - POST /api/plan/generate: race-safe (existing slots → 409 with plan), lock-aware (locked → 409), idempotent - POST /api/plan/regenerate: re-roll for the original generator, gated by ownership + lock; wipes slots + pick_points then re-runs generate - plan.html: generate CTA + 7 day cards with picker chips + AI reason + re-roll button (generator-only, pre-lock); scoreboard now shows points + wins - /list: pulls plan slots, queries Mealie for ingredients, runs aggregator, renders 48px-tall checkbox shopping list with localStorage state per plan_id - tests: 13 new tests across forge.generate_plan + /api/plan/generate routes + /list view + scoreboard SQL inspection. conftest+_testenv stub pymysql/oidc/foods at import time so tests run against module-level app without a live DB. Both pytest and `unittest discover` paths green (27/27). Defers: bulk sterilizer admin (A1), foods dedupe (A2), Mealie shopping-list- export (button rendered but disabled). 7-slot count is fixed at the endpoint (no UI for slot-count selection yet). Spec: memory/spec-cauldron-v0.3.md
This commit is contained in:
parent
cc6222139d
commit
36aba73f66
9 changed files with 1724 additions and 33 deletions
175
tests/test_list_view.py
Normal file
175
tests/test_list_view.py
Normal file
|
|
@ -0,0 +1,175 @@
|
|||
"""Tests for the /list shopping-list view.
|
||||
|
||||
Two paths exercised:
|
||||
1. plan with no slots → renders the "go to /plan" CTA
|
||||
2. plan with slots + a (mocked) Mealie client returning canned recipes →
|
||||
aggregator runs and the rendered HTML contains the expected lines
|
||||
"""
|
||||
import unittest
|
||||
from datetime import date
|
||||
from unittest.mock import MagicMock, patch
|
||||
|
||||
# Trigger import-time stubs (env, pymysql, oidc) before pulling cauldron.server.
|
||||
# Absolute import so unittest discover finds it.
|
||||
import os, sys
|
||||
sys.path.insert(0, os.path.dirname(__file__))
|
||||
import _testenv # noqa: E402, F401
|
||||
|
||||
from cauldron import server as srv
|
||||
|
||||
|
||||
class _ListTestBase(unittest.TestCase):
|
||||
def setUp(self):
|
||||
self.client = srv.app.test_client()
|
||||
with self.client.session_transaction() as s:
|
||||
s["user"] = {"sub": "sub-cobb", "email": "cobb@sulkta.com", "name": "Cobb"}
|
||||
|
||||
|
||||
def _fake_db(*, plan, slots=None):
|
||||
fake = MagicMock()
|
||||
fake.get_user_household_id.return_value = 1
|
||||
fake.get_or_create_plan.return_value = dict(plan)
|
||||
fake.list_plan_slots.return_value = slots or []
|
||||
fake.list_household_member_subs.return_value = ["sub-1"]
|
||||
|
||||
def _enrich(p):
|
||||
p["slots"] = slots or []
|
||||
return p
|
||||
fake.enrich_plan_with_slots.side_effect = _enrich
|
||||
|
||||
# connection ctx for any auxiliary lookups (none needed here, but defensive)
|
||||
from contextlib import contextmanager
|
||||
@contextmanager
|
||||
def _conn():
|
||||
yield FakeConn()
|
||||
fake.conn.side_effect = _conn
|
||||
|
||||
return fake
|
||||
|
||||
|
||||
class FakeCursor:
|
||||
def execute(self, *a, **kw): pass
|
||||
def fetchone(self): return None
|
||||
def fetchall(self): return []
|
||||
def __enter__(self): return self
|
||||
def __exit__(self, *a): return False
|
||||
|
||||
|
||||
class FakeConn:
|
||||
def cursor(self): return FakeCursor()
|
||||
def commit(self): pass
|
||||
def rollback(self): pass
|
||||
def close(self): pass
|
||||
|
||||
|
||||
class TestListNoPlan(_ListTestBase):
|
||||
def test_list_renders_no_plan(self):
|
||||
plan = {
|
||||
"id": 99, "household_id": 1,
|
||||
"week_start": date(2026, 4, 27),
|
||||
"generated_by_sub": None, "generated_at": None,
|
||||
"locked_by_sub": None, "locked_at": None, "locked_reason": None,
|
||||
}
|
||||
fake_db = _fake_db(plan=plan, slots=[])
|
||||
with patch.object(srv, "db", fake_db):
|
||||
r = self.client.get("/list")
|
||||
self.assertEqual(r.status_code, 200)
|
||||
body = r.get_data(as_text=True).lower()
|
||||
# CTA text
|
||||
self.assertIn("no plan yet", body)
|
||||
self.assertIn("/plan", body)
|
||||
|
||||
|
||||
class TestListAggregated(_ListTestBase):
|
||||
def test_list_renders_aggregated(self):
|
||||
plan = {
|
||||
"id": 100, "household_id": 1,
|
||||
"week_start": date(2026, 4, 27),
|
||||
"generated_by_sub": "sub-cobb",
|
||||
"generated_at": None,
|
||||
"locked_by_sub": None, "locked_at": None, "locked_reason": None,
|
||||
}
|
||||
slots = [
|
||||
{"id": 1, "plan_id": 100, "day": "monday",
|
||||
"recipe_slug": "stew", "recipe_name": "Stew",
|
||||
"source": "mealie", "picker_subs": [], "reason": "",
|
||||
"notes": None, "created_at": None},
|
||||
{"id": 2, "plan_id": 100, "day": "tuesday",
|
||||
"recipe_slug": "rice-bowl", "recipe_name": "Rice Bowl",
|
||||
"source": "mealie", "picker_subs": [], "reason": "",
|
||||
"notes": None, "created_at": None},
|
||||
]
|
||||
|
||||
# Canned Mealie recipes: stew has 1 lb rice, rice-bowl has 2 cup rice
|
||||
# → density-mixed agg should produce a single rice line
|
||||
fake_mealie = MagicMock()
|
||||
def _get_recipe(slug):
|
||||
if slug == "stew":
|
||||
return {
|
||||
"name": "Stew",
|
||||
"recipeIngredient": [
|
||||
{"quantity": 1, "unit": {"name": "lb"},
|
||||
"food": {"name": "rice"}, "note": "",
|
||||
"display": "1 lb rice"},
|
||||
{"quantity": 2, "unit": {"name": "tbsp"},
|
||||
"food": {"name": "olive oil"}, "note": "",
|
||||
"display": "2 tbsp olive oil"},
|
||||
],
|
||||
}
|
||||
if slug == "rice-bowl":
|
||||
return {
|
||||
"name": "Rice Bowl",
|
||||
"recipeIngredient": [
|
||||
{"quantity": 2, "unit": {"name": "cup"},
|
||||
"food": {"name": "rice"}, "note": "",
|
||||
"display": "2 cups rice"},
|
||||
{"quantity": 1, "unit": {"name": "cup"},
|
||||
"food": {"name": "olive oil"}, "note": "",
|
||||
"display": "1 cup olive oil"},
|
||||
],
|
||||
}
|
||||
raise KeyError(slug)
|
||||
fake_mealie.get_recipe.side_effect = _get_recipe
|
||||
|
||||
fake_db = _fake_db(plan=plan, slots=slots)
|
||||
|
||||
# foods.search_food returns canonical names + density for rice + olive oil
|
||||
def _search_food(db, name, *, limit=1):
|
||||
n = (name or "").strip().lower()
|
||||
if "rice" in n:
|
||||
return [{"id": 1, "canonical_name": "rice",
|
||||
"density_g_per_ml": 0.85,
|
||||
"default_unit_class": "mass",
|
||||
"common_size_g": None, "category": "grain"}]
|
||||
if "olive" in n or "oil" in n:
|
||||
return [{"id": 2, "canonical_name": "olive oil",
|
||||
"density_g_per_ml": 0.92,
|
||||
"default_unit_class": "volume",
|
||||
"common_size_g": None, "category": "oils"}]
|
||||
return []
|
||||
|
||||
with patch.object(srv, "db", fake_db), \
|
||||
patch.object(srv, "current_user_mealie", create=True), \
|
||||
patch("cauldron.foods.search_food", side_effect=_search_food):
|
||||
# patch the closure-bound current_user_mealie inside server.py
|
||||
# via patching the helper Mealie constructor route
|
||||
with patch("cauldron.server.Mealie", return_value=fake_mealie):
|
||||
# Make the user-token blob path return a fake encrypted blob
|
||||
fake_db.get_user_mealie_token_blob.return_value = b"fake-blob"
|
||||
with patch.object(srv.crypto, "decrypt", return_value="t"):
|
||||
r = self.client.get("/list")
|
||||
|
||||
self.assertEqual(r.status_code, 200, r.get_data(as_text=True))
|
||||
body = r.get_data(as_text=True).lower()
|
||||
# Rice should appear exactly once as an aggregated line; olive oil too
|
||||
self.assertIn("rice", body)
|
||||
self.assertIn("olive oil", body)
|
||||
# Aggregated rice: 1 lb + 2 cup × 0.85 g/ml × 236.588 ml/cup
|
||||
# = 453.6g + 402g = ~855g → display_mass picks lb (rounded .25)
|
||||
self.assertIn("lb", body)
|
||||
# localStorage data attribute should be present
|
||||
self.assertIn('data-plan-id="100"', body)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
Loading…
Add table
Add a link
Reference in a new issue