- 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
175 lines
6.7 KiB
Python
175 lines
6.7 KiB
Python
"""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()
|