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:
Kayos 2026-04-29 06:26:54 -07:00
parent cc6222139d
commit 36aba73f66
9 changed files with 1724 additions and 33 deletions

175
tests/test_list_view.py Normal file
View 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()