cauldron/tests/test_list_view.py
Kayos 36aba73f66 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
2026-04-29 06:26:54 -07:00

175 lines
6.7 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

"""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()