This commit is contained in:
Cayden Schwechten 2026-10-31 20:44:29 -05:00
commit c19b910c36
80 changed files with 16134 additions and 0 deletions

112
app/lib/grocery.test.ts Normal file
View file

@ -0,0 +1,112 @@
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);
});
});