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
85
tests/_testenv.py
Normal file
85
tests/_testenv.py
Normal file
|
|
@ -0,0 +1,85 @@
|
|||
"""Test environment bootstrap.
|
||||
|
||||
Cauldron's server module instantiates a DB + OAuth client + Mealie client at
|
||||
import time, so tests must stub those before importing cauldron.server.
|
||||
|
||||
This module performs that stubbing exactly once (idempotent — safe to import
|
||||
multiple times). Both pytest's conftest.py and direct `unittest discover`
|
||||
runs route through here.
|
||||
"""
|
||||
import os
|
||||
from unittest.mock import MagicMock, patch
|
||||
|
||||
from cryptography.fernet import Fernet
|
||||
|
||||
|
||||
_BOOTSTRAPPED = False
|
||||
|
||||
|
||||
def _stub_env() -> None:
|
||||
os.environ.setdefault("SECRET_KEY", "test-secret")
|
||||
os.environ.setdefault("MEALIE_BASE_URL", "http://mealie.test")
|
||||
os.environ.setdefault("MEALIE_API_TOKEN", "mealie-test-token")
|
||||
os.environ.setdefault("CLAWDFORGE_URL", "http://forge.test")
|
||||
os.environ.setdefault("CLAWDFORGE_TOKEN", "forge-test-token")
|
||||
os.environ.setdefault("ADMIN_BEARER", "admin-test-bearer")
|
||||
os.environ.setdefault("OIDC_ISSUER", "http://authentik.test/application/o/cauldron")
|
||||
os.environ.setdefault("OIDC_CLIENT_ID", "test-client")
|
||||
os.environ.setdefault("OIDC_CLIENT_SECRET", "test-secret-client")
|
||||
os.environ.setdefault("OIDC_REDIRECT_URI", "http://localhost/auth/callback")
|
||||
os.environ.setdefault("DB_HOST", "localhost")
|
||||
os.environ.setdefault("DB_NAME", "cauldron_test")
|
||||
os.environ.setdefault("DB_USER", "cauldron")
|
||||
os.environ.setdefault("DB_PASSWORD", "test")
|
||||
os.environ.setdefault("CAULDRON_FERNET_KEY", Fernet.generate_key().decode())
|
||||
|
||||
|
||||
class _FakeCursor:
|
||||
def __init__(self):
|
||||
self.lastrowid = 0
|
||||
self.rowcount = 0
|
||||
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 __init__(self, *a, **kw): pass
|
||||
def cursor(self): return _FakeCursor()
|
||||
def commit(self): pass
|
||||
def rollback(self): pass
|
||||
def close(self): pass
|
||||
|
||||
|
||||
def bootstrap() -> None:
|
||||
"""Apply env + import-time monkey patches. Idempotent."""
|
||||
global _BOOTSTRAPPED
|
||||
if _BOOTSTRAPPED:
|
||||
return
|
||||
_stub_env()
|
||||
|
||||
# pymysql.connect → no-op fake
|
||||
patch("pymysql.connect", _FakeConn).start()
|
||||
|
||||
# Pre-import dotted modules so the next patches resolve
|
||||
import cauldron.db # noqa: F401
|
||||
import cauldron.oidc # noqa: F401
|
||||
import cauldron.foods # noqa: F401
|
||||
|
||||
# Skip migrations
|
||||
patch("cauldron.db.DB.migrate", lambda self: []).start()
|
||||
|
||||
# Skip OIDC metadata fetch
|
||||
oauth_obj = MagicMock()
|
||||
oauth_obj.cauldron = MagicMock()
|
||||
patch("cauldron.oidc.init_oauth", lambda *a, **kw: oauth_obj).start()
|
||||
|
||||
# Skip foods seed loader
|
||||
patch("cauldron.foods.load_seed_if_empty", lambda db: 0).start()
|
||||
|
||||
_BOOTSTRAPPED = True
|
||||
|
||||
|
||||
bootstrap()
|
||||
5
tests/conftest.py
Normal file
5
tests/conftest.py
Normal file
|
|
@ -0,0 +1,5 @@
|
|||
"""Pytest auto-loaded conftest. Routes through _testenv so both pytest and
|
||||
`unittest discover` see the same stubs."""
|
||||
import os, sys
|
||||
sys.path.insert(0, os.path.dirname(__file__))
|
||||
import _testenv # noqa: E402, F401
|
||||
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()
|
||||
381
tests/test_plan_generator.py
Normal file
381
tests/test_plan_generator.py
Normal file
|
|
@ -0,0 +1,381 @@
|
|||
"""Tests for the AI plan generator (forge.generate_plan + the
|
||||
/api/plan/generate endpoint).
|
||||
|
||||
The endpoint tests use Flask's test client. db methods on the module-level
|
||||
cauldron.server.db object are swapped out with MagicMocks per-test — this
|
||||
avoids needing a real MariaDB to test routing + the orchestration logic.
|
||||
"""
|
||||
import json
|
||||
import unittest
|
||||
from unittest.mock import MagicMock, patch
|
||||
|
||||
# Run conftest's import-time patches BEFORE pulling in cauldron.server.
|
||||
# pytest auto-loads conftest, but unittest doesn't, so do it explicitly.
|
||||
# Import path is absolute so `unittest discover` (which doesn't treat tests/
|
||||
# as a package) and pytest both resolve it.
|
||||
import os, sys
|
||||
sys.path.insert(0, os.path.dirname(__file__))
|
||||
import _testenv # noqa: E402, F401
|
||||
|
||||
from cauldron import server as srv
|
||||
from cauldron.forge import Forge, ForgeError
|
||||
|
||||
|
||||
# ---------- forge.generate_plan unit tests --------------------------------
|
||||
|
||||
|
||||
class TestForgeGeneratePlan(unittest.TestCase):
|
||||
def setUp(self):
|
||||
self.forge = Forge(
|
||||
base_url="http://forge.test", token="t",
|
||||
default_model="sonnet", default_timeout=60,
|
||||
)
|
||||
|
||||
def _ok_run(self, slots_payload):
|
||||
"""Patch self.forge.run to return a dict shaped like clawdforge's."""
|
||||
return patch.object(
|
||||
self.forge, "run",
|
||||
return_value={"result": {"slots": slots_payload}},
|
||||
)
|
||||
|
||||
def test_validates_slot_count_matches(self):
|
||||
recipes = [
|
||||
{"slug": "r1", "name": "Stew"},
|
||||
{"slug": "r2", "name": "Tacos"},
|
||||
{"slug": "r3", "name": "Pasta"},
|
||||
{"slug": "r4", "name": "Pie"},
|
||||
{"slug": "r5", "name": "Curry"},
|
||||
{"slug": "r6", "name": "Bowl"},
|
||||
{"slug": "r7", "name": "Soup"},
|
||||
]
|
||||
# Model returns only 5 slots — must raise
|
||||
bad = [{"day": d, "recipe_slug": "r1", "picker_subs": [], "reason": ""}
|
||||
for d in ("monday", "tuesday", "wednesday", "thursday", "friday")]
|
||||
with self._ok_run(bad):
|
||||
with self.assertRaises(ForgeError) as cm:
|
||||
self.forge.generate_plan(picks=[], recipes=recipes, slots=7, week_start="2026-04-27")
|
||||
self.assertIn("expected 7", str(cm.exception))
|
||||
|
||||
def test_rejects_unknown_slug(self):
|
||||
recipes = [{"slug": "r1", "name": "A"}]
|
||||
bad = [{"day": "monday", "recipe_slug": "r-not-real", "picker_subs": [], "reason": ""}]
|
||||
with self._ok_run(bad):
|
||||
with self.assertRaises(ForgeError) as cm:
|
||||
self.forge.generate_plan(picks=[], recipes=recipes, slots=1, week_start="2026-04-27")
|
||||
self.assertIn("unknown recipe_slug", str(cm.exception))
|
||||
|
||||
def test_rejects_duplicate_day(self):
|
||||
recipes = [{"slug": "r1", "name": "A"}, {"slug": "r2", "name": "B"}]
|
||||
bad = [
|
||||
{"day": "monday", "recipe_slug": "r1", "picker_subs": [], "reason": ""},
|
||||
{"day": "monday", "recipe_slug": "r2", "picker_subs": [], "reason": ""},
|
||||
]
|
||||
with self._ok_run(bad):
|
||||
with self.assertRaises(ForgeError) as cm:
|
||||
self.forge.generate_plan(picks=[], recipes=recipes, slots=2, week_start="2026-04-27")
|
||||
self.assertIn("duplicate day", str(cm.exception))
|
||||
|
||||
def test_picker_attribution_uses_real_subs(self):
|
||||
"""Even if the model omits picker_subs, our ground-truth pick map
|
||||
is what ends up on the slot."""
|
||||
recipes = [{"slug": "r1", "name": "Stew"}]
|
||||
picks = [{"slug": "r1", "name": "Stew", "picker_subs": ["sub-abby", "sub-cobb"]}]
|
||||
# Model returns empty picker_subs — we should fill from the picks
|
||||
slots_in = [{"day": "monday", "recipe_slug": "r1", "picker_subs": [], "reason": "honors picks"}]
|
||||
with self._ok_run(slots_in):
|
||||
out = self.forge.generate_plan(
|
||||
picks=picks, recipes=recipes, slots=1, week_start="2026-04-27",
|
||||
)
|
||||
self.assertEqual(len(out), 1)
|
||||
self.assertEqual(out[0]["picker_subs"], ["sub-abby", "sub-cobb"])
|
||||
self.assertEqual(out[0]["source"], "pick")
|
||||
self.assertEqual(out[0]["recipe_name"], "Stew")
|
||||
self.assertEqual(out[0]["reason"], "honors picks")
|
||||
|
||||
def test_string_response_is_parsed(self):
|
||||
"""clawdforge sometimes returns the JSON as a string in `result`."""
|
||||
recipes = [{"slug": "r1", "name": "A"}]
|
||||
payload = {"slots": [{"day": "monday", "recipe_slug": "r1",
|
||||
"picker_subs": [], "reason": "ai"}]}
|
||||
with patch.object(self.forge, "run",
|
||||
return_value={"result": json.dumps(payload)}):
|
||||
out = self.forge.generate_plan(
|
||||
picks=[], recipes=recipes, slots=1, week_start="2026-04-27",
|
||||
)
|
||||
self.assertEqual(len(out), 1)
|
||||
self.assertEqual(out[0]["recipe_slug"], "r1")
|
||||
self.assertEqual(out[0]["source"], "mealie") # no picks → mealie source
|
||||
|
||||
def test_code_fenced_response_is_parsed(self):
|
||||
recipes = [{"slug": "r1", "name": "A"}]
|
||||
payload = {"slots": [{"day": "monday", "recipe_slug": "r1",
|
||||
"picker_subs": [], "reason": ""}]}
|
||||
fenced = "```json\n" + json.dumps(payload) + "\n```"
|
||||
with patch.object(self.forge, "run", return_value={"result": fenced}):
|
||||
out = self.forge.generate_plan(
|
||||
picks=[], recipes=recipes, slots=1, week_start="2026-04-27",
|
||||
)
|
||||
self.assertEqual(out[0]["recipe_slug"], "r1")
|
||||
|
||||
|
||||
# ---------- /api/plan/generate route tests --------------------------------
|
||||
|
||||
|
||||
def _make_db_stub(*, plan, picks=None, recipe_rows=None,
|
||||
existing_slots=None, save_inserted=None):
|
||||
"""Build a fake db with the methods the route uses."""
|
||||
fake = MagicMock()
|
||||
fake.list_household_picks_with_pickers.return_value = picks or []
|
||||
fake.list_indexed_recipes.return_value = recipe_rows or []
|
||||
fake.list_plan_slots.return_value = existing_slots or []
|
||||
fake.get_or_create_plan.return_value = dict(plan)
|
||||
fake.auto_lock_past_unlocked_plans.return_value = 0
|
||||
fake.list_household_member_subs.return_value = ["sub-1"]
|
||||
fake.get_user_household_id.return_value = 1
|
||||
fake.list_household_pick_slugs.return_value = set()
|
||||
fake.household_scoreboard.return_value = []
|
||||
fake.household_streak.return_value = None
|
||||
fake.upsert_user.return_value = None
|
||||
|
||||
# save_plan_slots returns inserted count (1+ default, or override for race tests)
|
||||
if save_inserted is None:
|
||||
save_inserted = lambda plan_id, slots: len(slots)
|
||||
fake.save_plan_slots.side_effect = save_inserted
|
||||
|
||||
# mark_plan_generated returns updated plan dict
|
||||
def _mark(plan_id, sub):
|
||||
p = dict(plan)
|
||||
p["generated_by_sub"] = sub
|
||||
from datetime import datetime
|
||||
p["generated_at"] = datetime(2026, 4, 27, 12, 0, 0)
|
||||
return p
|
||||
fake.mark_plan_generated.side_effect = _mark
|
||||
|
||||
# enrich_plan_with_slots adds slots to the plan dict in-place
|
||||
def _enrich(p):
|
||||
p["slots"] = fake.list_plan_slots.return_value
|
||||
return p
|
||||
fake.enrich_plan_with_slots.side_effect = _enrich
|
||||
|
||||
# conn() context manager stub for the display-name resolution
|
||||
from contextlib import contextmanager
|
||||
@contextmanager
|
||||
def _conn():
|
||||
yield FakeConn()
|
||||
fake.conn.side_effect = _conn
|
||||
|
||||
return fake
|
||||
|
||||
|
||||
class FakeCursor:
|
||||
def __init__(self):
|
||||
self._rows = []
|
||||
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 _RouteTestBase(unittest.TestCase):
|
||||
def setUp(self):
|
||||
self.client = srv.app.test_client()
|
||||
# Inject a session via a context override
|
||||
with self.client.session_transaction() as s:
|
||||
s["user"] = {"sub": "sub-cobb", "email": "cobb@sulkta.com", "name": "Cobb"}
|
||||
|
||||
def _patch_db(self, fake_db):
|
||||
return patch.object(srv, "db", fake_db)
|
||||
|
||||
|
||||
class TestGenerateRoute(_RouteTestBase):
|
||||
def test_generate_creates_slots(self):
|
||||
from datetime import date
|
||||
plan = {
|
||||
"id": 42, "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,
|
||||
}
|
||||
recipe_rows = [
|
||||
{"slug": f"r{i}", "name": f"Recipe {i}", "raw_json": "{}"}
|
||||
for i in range(1, 11)
|
||||
]
|
||||
slots_returned = [
|
||||
{"day": d, "recipe_slug": "r1", "recipe_name": "Recipe 1",
|
||||
"picker_subs": [], "reason": "ai", "source": "mealie"}
|
||||
for d in ("monday", "tuesday", "wednesday", "thursday",
|
||||
"friday", "saturday", "sunday")
|
||||
]
|
||||
# Make slots use unique recipes per day for realism
|
||||
for i, s in enumerate(slots_returned):
|
||||
s["recipe_slug"] = f"r{i+1}"
|
||||
s["recipe_name"] = f"Recipe {i+1}"
|
||||
|
||||
fake_db = _make_db_stub(plan=plan, recipe_rows=recipe_rows)
|
||||
with self._patch_db(fake_db), \
|
||||
patch.object(srv.forge, "generate_plan", return_value=slots_returned):
|
||||
r = self.client.post("/api/plan/generate")
|
||||
self.assertEqual(r.status_code, 200, r.get_data(as_text=True))
|
||||
body = r.get_json()
|
||||
self.assertTrue(body["ok"])
|
||||
# save_plan_slots called with the plan id and the slots list
|
||||
fake_db.save_plan_slots.assert_called_once()
|
||||
args, _ = fake_db.save_plan_slots.call_args
|
||||
self.assertEqual(args[0], 42)
|
||||
self.assertEqual(len(args[1]), 7)
|
||||
self.assertEqual(args[1][0]["day"], "monday")
|
||||
# mark_plan_generated called with cobb's sub
|
||||
fake_db.mark_plan_generated.assert_called_once_with(42, "sub-cobb")
|
||||
|
||||
def test_generate_when_locked_409(self):
|
||||
from datetime import date, datetime
|
||||
plan = {
|
||||
"id": 7, "household_id": 1, "week_start": date(2026, 4, 27),
|
||||
"generated_by_sub": None, "generated_at": None,
|
||||
"locked_by_sub": "sub-abby",
|
||||
"locked_at": datetime(2026, 4, 27, 18, 0),
|
||||
"locked_reason": "user",
|
||||
}
|
||||
fake_db = _make_db_stub(plan=plan)
|
||||
with self._patch_db(fake_db), \
|
||||
patch.object(srv.forge, "generate_plan") as gp:
|
||||
r = self.client.post("/api/plan/generate")
|
||||
self.assertEqual(r.status_code, 409)
|
||||
self.assertEqual(r.get_json()["error"], "plan_locked")
|
||||
gp.assert_not_called()
|
||||
|
||||
def test_generate_when_already_generated_409(self):
|
||||
from datetime import date
|
||||
plan = {
|
||||
"id": 9, "household_id": 1, "week_start": date(2026, 4, 27),
|
||||
"generated_by_sub": "sub-abby", "generated_at": None,
|
||||
"locked_by_sub": None, "locked_at": None, "locked_reason": None,
|
||||
}
|
||||
existing = [{
|
||||
"id": 1, "plan_id": 9, "day": "monday",
|
||||
"recipe_slug": "r1", "recipe_name": "Stew",
|
||||
"source": "mealie", "picker_subs": [], "reason": "", "notes": None,
|
||||
"created_at": None,
|
||||
}]
|
||||
fake_db = _make_db_stub(plan=plan, existing_slots=existing)
|
||||
with self._patch_db(fake_db), \
|
||||
patch.object(srv.forge, "generate_plan") as gp:
|
||||
r = self.client.post("/api/plan/generate")
|
||||
self.assertEqual(r.status_code, 409)
|
||||
body = r.get_json()
|
||||
self.assertEqual(body["error"], "plan_already_generated")
|
||||
self.assertIn("plan", body)
|
||||
self.assertEqual(len(body["plan"]["slots"]), 1)
|
||||
gp.assert_not_called()
|
||||
|
||||
def test_pick_points_awarded_on_pick_use(self):
|
||||
from datetime import date
|
||||
plan = {
|
||||
"id": 11, "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,
|
||||
}
|
||||
recipe_rows = [
|
||||
{"slug": "stew", "name": "Stew", "raw_json": "{}"},
|
||||
{"slug": "tacos", "name": "Tacos", "raw_json": "{}"},
|
||||
]
|
||||
picks = [
|
||||
{"slug": "stew", "name": "Stew",
|
||||
"pickers": ["abby"], "picker_subs": ["sub-abby"]},
|
||||
{"slug": "tacos", "name": "Tacos",
|
||||
"pickers": ["cobb", "abby"], "picker_subs": ["sub-cobb", "sub-abby"]},
|
||||
]
|
||||
# Slot fixture: monday = stew (abby picks), tuesday = tacos (cobb +
|
||||
# abby picks), wed-sun = stew (ai-chosen, no pickers).
|
||||
slots_full = []
|
||||
days = ("monday", "tuesday", "wednesday", "thursday",
|
||||
"friday", "saturday", "sunday")
|
||||
for i, d in enumerate(days):
|
||||
if i == 0:
|
||||
slots_full.append({
|
||||
"day": d, "recipe_slug": "stew", "recipe_name": "Stew",
|
||||
"picker_subs": ["sub-abby"], "reason": "abby's pick",
|
||||
"source": "pick",
|
||||
})
|
||||
elif i == 1:
|
||||
slots_full.append({
|
||||
"day": d, "recipe_slug": "tacos", "recipe_name": "Tacos",
|
||||
"picker_subs": ["sub-cobb", "sub-abby"], "reason": "co",
|
||||
"source": "pick",
|
||||
})
|
||||
else:
|
||||
slots_full.append({
|
||||
"day": d, "recipe_slug": "stew", "recipe_name": "Stew",
|
||||
"picker_subs": [], "reason": "ai", "source": "mealie",
|
||||
})
|
||||
|
||||
fake_db = _make_db_stub(plan=plan, picks=picks, recipe_rows=recipe_rows)
|
||||
with self._patch_db(fake_db), \
|
||||
patch.object(srv.forge, "generate_plan", return_value=slots_full):
|
||||
r = self.client.post("/api/plan/generate")
|
||||
self.assertEqual(r.status_code, 200, r.get_data(as_text=True))
|
||||
|
||||
# 1pt for sub-abby on monday + 1pt sub-cobb + 1pt sub-abby on tuesday
|
||||
# = 3 award_pick_points calls total
|
||||
self.assertEqual(fake_db.award_pick_points.call_count, 3)
|
||||
# All calls should be (1, 11, <sub>, 1, "pick_used")
|
||||
called_subs = [c.args[2] for c in fake_db.award_pick_points.call_args_list]
|
||||
self.assertEqual(sorted(called_subs), ["sub-abby", "sub-abby", "sub-cobb"])
|
||||
for call in fake_db.award_pick_points.call_args_list:
|
||||
self.assertEqual(call.args[3], 1) # points
|
||||
self.assertEqual(call.args[4], "pick_used")
|
||||
|
||||
|
||||
# ---------- household_scoreboard SQL test --------------------------------
|
||||
|
||||
|
||||
class TestScoreboardSchema(unittest.TestCase):
|
||||
"""The scoreboard SELECT must reference cauldron_pick_points and return
|
||||
a `points` field. Verified by inspecting the generated SQL via a
|
||||
capturing fake cursor."""
|
||||
|
||||
def test_scoreboard_query_includes_points(self):
|
||||
from cauldron.db import DB
|
||||
captured = {"sql": None}
|
||||
|
||||
class CapCursor:
|
||||
def execute(self, sql, params=None):
|
||||
captured["sql"] = sql
|
||||
def fetchall(self):
|
||||
return [
|
||||
{
|
||||
"sub": "sub-cobb", "email": "cobb@x.com",
|
||||
"display_name": "Cobb",
|
||||
"wins": 2, "last_win": None, "points": 5,
|
||||
},
|
||||
]
|
||||
def __enter__(self): return self
|
||||
def __exit__(self, *a): return False
|
||||
|
||||
class CapConn:
|
||||
def cursor(self): return CapCursor()
|
||||
def commit(self): pass
|
||||
def rollback(self): pass
|
||||
def close(self): pass
|
||||
|
||||
db = DB(host="x", port=3306, name="x", user="x", password="x")
|
||||
with patch("pymysql.connect", lambda **kw: CapConn()):
|
||||
rows = db.household_scoreboard(1)
|
||||
|
||||
self.assertIn("cauldron_pick_points", captured["sql"])
|
||||
self.assertIn("points", captured["sql"])
|
||||
# And the row decoder coerces points to int + adds weeks_locked alias
|
||||
self.assertEqual(rows[0]["points"], 5)
|
||||
self.assertEqual(rows[0]["weeks_locked"], 2)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
Loading…
Add table
Add a link
Reference in a new issue