import { describe, expect, test } from "bun:test"; import type { ItemId, StoreItem } from "@/app/types"; import { CATALOG_BY_ID } from "./catalog"; import { Cart, buildGroceryList } from "./grocery"; import type { PriceProvider } from "./pricing"; import { RECIPES, RECIPES_BY_ID } from "./recipes"; import { sanitizeState } from "./sanitize"; import { toBaseUnit } from "./units"; // --------------------------------------------------------------------------- // The money math. These are the numbers people budget against, so they get // pinned down here rather than eyeballed in the UI. // // Trick: buildGroceryList accepts a price provider. We hand it a fake store // that stocks ONE item with a package size we choose, so we control exactly // how many packages a real recipe needs. Every other ingredient is "not // stocked" and skipped, which keeps the arithmetic to a single line. // --------------------------------------------------------------------------- const RECIPE = RECIPES_BY_ID.get("chicken-tikka-masala")!; const INGREDIENT = RECIPE.ingredients[0]; const REAL_ITEM = CATALOG_BY_ID.get(INGREDIENT.itemId)!; /** How much of the item one batch of the recipe needs, in the item's base unit. */ const NEEDED = toBaseUnit(INGREDIENT.amount, INGREDIENT.unit, REAL_ITEM); /** A store that only sells our one item, sized so the recipe needs `packagesNeeded` packages. */ function storeWhereRecipeNeeds(packagesNeeded: number, price = 10): PriceProvider { const item: StoreItem = { ...REAL_ITEM, packageSize: NEEDED / packagesNeeded, price }; return { label: "test", getItem: (id: ItemId) => (id === item.id ? item : undefined) }; } /** Cook the recipe once, for exactly as many people as it serves (scale = 1). */ function groceryList(provider: PriceProvider, onHandItems: ItemId[] = []) { return buildGroceryList([RECIPE.id], { householdSize: RECIPE.servings, onHandItems, provider }); } describe("whole-package pricing", () => { test("1.2 packages' worth bills 2 packages", () => { const { lines, checkoutTotal, usedTotal, carryoverValue } = groceryList(storeWhereRecipeNeeds(1.2)); expect(lines).toHaveLength(1); expect(lines[0].packages).toBe(2); expect(checkoutTotal).toBe(20); // 2 × $10 at the register expect(usedTotal).toBe(12); // 1.2 × $10 actually eaten expect(carryoverValue).toBe(8); // the rest goes to the pantry }); test("float noise doesn't buy an extra package (2.000000001 → 2)", () => { const { lines } = groceryList(storeWhereRecipeNeeds(2 + 1e-9)); expect(lines[0].packages).toBe(2); }); test("a tiny amount still buys one whole package", () => { const { lines } = groceryList(storeWhereRecipeNeeds(0.01)); expect(lines[0].packages).toBe(1); }); test("an on-hand item is listed but costs $0", () => { const { lines, checkoutTotal, usedTotal } = groceryList(storeWhereRecipeNeeds(1.2), [REAL_ITEM.id]); expect(lines[0].onHand).toBe(true); expect(lines[0].packages).toBe(2); // still shown, so you can check your shelf expect(lines[0].checkoutCost).toBe(0); expect(checkoutTotal).toBe(0); expect(usedTotal).toBe(0); }); }); describe("Cart (the fast path auto-fill and the meal picker use)", () => { // Cart only looks at the new recipe's ingredients. It must still agree with // the slow, obvious way: build the whole list with and without, subtract. test("marginal cost matches building the list with and without the recipe", () => { const options = { householdSize: 3, onHandItems: [REAL_ITEM.id] }; const planned = RECIPES.filter((_, i) => i % 37 === 0).map((r) => r.id); // a varied ~12-meal plan const cart = new Cart(options, planned); const baseline = buildGroceryList(planned, options); for (const recipe of RECIPES) { const withIt = buildGroceryList([...planned, recipe.id], options); const { checkout, eaten } = cart.marginal(recipe.id); expect(checkout).toBeCloseTo(withIt.checkoutTotal - baseline.checkoutTotal, 1); expect(eaten).toBeCloseTo(withIt.usedTotal - baseline.usedTotal, 1); } }); test("a recipe that reuses an open package adds less than it costs alone", () => { const cart = new Cart({ householdSize: 2, onHandItems: [] }); const alone = cart.marginal(RECIPE.id).checkout; cart.add(RECIPE.id); expect(cart.marginal(RECIPE.id).checkout).toBeLessThan(alone); }); }); describe("sanitizeState", () => { test("clamps days and drops slots outside the grid", () => { const state = sanitizeState({ daysCount: 99, slots: { "9-9": "x" } }); expect(state.daysCount).toBe(7); expect(state.slots).toEqual({}); }); test("drops slots that point at recipes that don't exist", () => { const state = sanitizeState({ slots: { "0-3": "not-a-real-recipe", "1-3": RECIPE.id } }); expect(state.slots).toEqual({ "1-3": RECIPE.id }); }); test("survives garbage input", () => { expect(() => sanitizeState(null)).not.toThrow(); expect(() => sanitizeState("nope")).not.toThrow(); expect(sanitizeState({ mealsPerDay: Number.NaN }).mealsPerDay).toBeGreaterThanOrEqual(1); }); });