planmymeal/app/lib/grocery.test.ts
Cayden Schwechten c19b910c36 init
2026-10-31 20:44:29 -05:00

112 lines
5 KiB
TypeScript
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.

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);
});
});