Public Access
feat: planner per-recipe scoring with 5 weighted signals
This commit is contained in:
@@ -0,0 +1,107 @@
|
||||
from datetime import date, timedelta
|
||||
from decimal import Decimal
|
||||
from uuid import uuid4
|
||||
|
||||
from app.services.planner.config import PlannerConfig
|
||||
from app.services.planner.score import (
|
||||
score_recipes,
|
||||
time_bonus,
|
||||
recency_bonus,
|
||||
normalize_savings,
|
||||
)
|
||||
from app.services.planner.types import RecipeCost
|
||||
|
||||
|
||||
_CFG = PlannerConfig()
|
||||
|
||||
|
||||
def test_time_bonus_capped_at_ideal():
|
||||
assert time_bonus(20, _CFG) == 1.0
|
||||
assert time_bonus(25, _CFG) == 1.0
|
||||
|
||||
|
||||
def test_time_bonus_decays_to_zero_at_full():
|
||||
assert time_bonus(45, _CFG) == 0.0
|
||||
|
||||
|
||||
def test_time_bonus_linear_midpoint():
|
||||
# 35 min is halfway between 25 and 45
|
||||
assert abs(time_bonus(35, _CFG) - 0.5) < 1e-6
|
||||
|
||||
|
||||
def test_recency_bonus_full_when_long_ago():
|
||||
today = date(2026, 5, 5)
|
||||
long_ago = today - timedelta(weeks=20)
|
||||
assert recency_bonus(long_ago, today, _CFG) == 1.0
|
||||
|
||||
|
||||
def test_recency_bonus_zero_when_just_eligible():
|
||||
# right at recency_weeks boundary → 0
|
||||
today = date(2026, 5, 5)
|
||||
cutoff = today - timedelta(weeks=_CFG.recency_weeks)
|
||||
assert recency_bonus(cutoff, today, _CFG) == 0.0
|
||||
|
||||
|
||||
def test_recency_bonus_full_when_never_cooked():
|
||||
assert recency_bonus(None, date(2026, 5, 5), _CFG) == 1.0
|
||||
|
||||
|
||||
def test_normalize_savings_minmax():
|
||||
out = normalize_savings([Decimal("0"), Decimal("5"), Decimal("10")])
|
||||
assert out == [0.0, 0.5, 1.0]
|
||||
|
||||
|
||||
def test_normalize_savings_uniform_returns_zeros():
|
||||
out = normalize_savings([Decimal("3"), Decimal("3"), Decimal("3")])
|
||||
assert out == [0.0, 0.0, 0.0]
|
||||
|
||||
|
||||
def test_score_recipes_orders_by_combined_score():
|
||||
recipes = [
|
||||
{
|
||||
"id": uuid4(),
|
||||
"name": "low_savings",
|
||||
"prep_time_minutes": 10,
|
||||
"cook_time_minutes": 30,
|
||||
"protein_type": "chicken",
|
||||
"cuisine_tags": ["american"],
|
||||
},
|
||||
{
|
||||
"id": uuid4(),
|
||||
"name": "high_savings",
|
||||
"prep_time_minutes": 10,
|
||||
"cook_time_minutes": 15, # also lower time
|
||||
"protein_type": "beef",
|
||||
"cuisine_tags": ["mexican"],
|
||||
},
|
||||
]
|
||||
costs = {
|
||||
recipes[0]["id"]: RecipeCost(
|
||||
recipe_id=recipes[0]["id"],
|
||||
total_cost=Decimal("10"),
|
||||
total_savings=Decimal("1"),
|
||||
sale_ingredient_count=1,
|
||||
matched_ingredient_count=4,
|
||||
total_ingredient_count=4,
|
||||
pantry_hit_count=0,
|
||||
line_items=[],
|
||||
),
|
||||
recipes[1]["id"]: RecipeCost(
|
||||
recipe_id=recipes[1]["id"],
|
||||
total_cost=Decimal("12"),
|
||||
total_savings=Decimal("8"),
|
||||
sale_ingredient_count=3,
|
||||
matched_ingredient_count=4,
|
||||
total_ingredient_count=4,
|
||||
pantry_hit_count=2,
|
||||
line_items=[],
|
||||
),
|
||||
}
|
||||
scored = score_recipes(
|
||||
recipes=recipes,
|
||||
recipe_costs=costs,
|
||||
last_cooked_at={},
|
||||
config=_CFG,
|
||||
today=date(2026, 5, 5),
|
||||
)
|
||||
assert scored[0].recipe_id == recipes[1]["id"] # high_savings first
|
||||
Reference in New Issue
Block a user