112 lines
5 KiB
TypeScript
112 lines
5 KiB
TypeScript
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);
|
||
});
|
||
});
|