diff --git a/src/classes/recipe.ts b/src/classes/recipe.ts index 6612a5f..a1ad717 100644 --- a/src/classes/recipe.ts +++ b/src/classes/recipe.ts @@ -23,6 +23,7 @@ import type { FixedNumericValue, StepItem, GetIngredientQuantitiesOptions, + RawQuantityGroup, SpecificUnitSystem, Unit, MaybeScalableQuantity, @@ -634,36 +635,10 @@ export class Recipe { } } - /** - * Gets ingredients with their quantities populated, optionally filtered by section/step - * and respecting user choices for alternatives. - * - * When no options are provided, returns all recipe ingredients with quantities - * calculated using primary alternatives (same as after parsing). - * - * @param options - Options for filtering and choice selection: - * - `section`: Filter to a specific section (Section object or 0-based index) - * - `step`: Filter to a specific step (Step object or 0-based index) - * - `choices`: Choices for alternative ingredients (defaults to primary) - * @returns Array of Ingredient objects with quantities populated - * - * @example - * ```typescript - * // Get all ingredients with primary alternatives - * const ingredients = recipe.getIngredientQuantities(); - * - * // Get ingredients for a specific section - * const sectionIngredients = recipe.getIngredientQuantities({ section: 0 }); - * - * // Get ingredients with specific choices applied - * const withChoices = recipe.getIngredientQuantities({ - * choices: { ingredientItems: new Map([['ingredient-item-2', 1]]) } - * }); - * ``` - */ - getIngredientQuantities( - options?: GetIngredientQuantitiesOptions, - ): Ingredient[] { + // Type for accumulated quantities (used internally by collectQuantityGroups) + // Defined as a static type alias for the private method's return type + /** @internal */ + private collectQuantityGroups(options?: GetIngredientQuantitiesOptions) { const { section, step, choices } = options || {}; // Determine sections to process @@ -856,6 +831,98 @@ export class Recipe { } } + return { ingredientGroups, selectedIndices, referencedIndices }; + } + + /** + * Gets the raw (unprocessed) quantity groups for each ingredient, before + * any summation or equivalents simplification. This is useful for cross-recipe + * aggregation (e.g., in {@link ShoppingList}), where quantities from multiple + * recipes should be combined before processing. + * + * @param options - Options for filtering and choice selection (same as {@link getIngredientQuantities}). + * @returns Array of {@link RawQuantityGroup} objects, one per ingredient with quantities. + * + * @example + * ```typescript + * const rawGroups = recipe.getRawQuantityGroups(); + * // Each group has: name, usedAsPrimary, flags, quantities[] + * // quantities are the raw QuantityWithExtendedUnit or FlatOrGroup entries + * ``` + */ + getRawQuantityGroups( + options?: GetIngredientQuantitiesOptions, + ): RawQuantityGroup[] { + const { ingredientGroups, selectedIndices, referencedIndices } = + this.collectQuantityGroups(options); + + const result: RawQuantityGroup[] = []; + + for (let index = 0; index < this.ingredients.length; index++) { + if (!referencedIndices.has(index)) continue; + + const orig = this.ingredients[index]!; + const usedAsPrimary = selectedIndices.has(index); + + // Collect all raw quantities across all signature groups + const quantities: ( + | QuantityWithExtendedUnit + | FlatOrGroup + )[] = []; + + if (usedAsPrimary) { + const groupsForIng = ingredientGroups.get(index); + if (groupsForIng) { + for (const [, group] of groupsForIng) { + quantities.push(...group.quantities); + } + } + } + + result.push({ + name: orig.name, + ...(usedAsPrimary && { usedAsPrimary: true }), + ...(orig.flags && { flags: orig.flags }), + quantities, + }); + } + + return result; + } + + /** + * Gets ingredients with their quantities populated, optionally filtered by section/step + * and respecting user choices for alternatives. + * + * When no options are provided, returns all recipe ingredients with quantities + * calculated using primary alternatives (same as after parsing). + * + * @param options - Options for filtering and choice selection: + * - `section`: Filter to a specific section (Section object or 0-based index) + * - `step`: Filter to a specific step (Step object or 0-based index) + * - `choices`: Choices for alternative ingredients (defaults to primary) + * @returns Array of Ingredient objects with quantities populated + * + * @example + * ```typescript + * // Get all ingredients with primary alternatives + * const ingredients = recipe.getIngredientQuantities(); + * + * // Get ingredients for a specific section + * const sectionIngredients = recipe.getIngredientQuantities({ section: 0 }); + * + * // Get ingredients with specific choices applied + * const withChoices = recipe.getIngredientQuantities({ + * choices: { ingredientItems: new Map([['ingredient-item-2', 1]]) } + * }); + * ``` + */ + getIngredientQuantities( + options?: GetIngredientQuantitiesOptions, + ): Ingredient[] { + const { ingredientGroups, selectedIndices, referencedIndices } = + this.collectQuantityGroups(options); + // Build result const result: Ingredient[] = []; diff --git a/src/classes/shopping_cart.ts b/src/classes/shopping_cart.ts index b39e401..d568d77 100644 --- a/src/classes/shopping_cart.ts +++ b/src/classes/shopping_cart.ts @@ -12,6 +12,7 @@ import type { NoProductMatchErrorCode, FlatOrGroup, MaybeNestedGroup, + QuantityWithPlainUnit, QuantityWithUnitDef, } from "../types"; import { ProductCatalog } from "./product_catalog"; @@ -217,9 +218,37 @@ export class ShoppingCart { if (options.length === 0) throw new NoProductMatchError(ingredient.name, "noProduct"); // If the ingredient has no quantity, we can't match any product - if (!ingredient.quantityTotal) + if (!ingredient.quantities || ingredient.quantities.length === 0) throw new NoProductMatchError(ingredient.name, "noQuantity"); + // Reconstruct a single quantityTotal for normalizeAllUnits + // by combining all quantity entries into one structure, preserving equivalents + const allPlainEntries: ( + | QuantityWithPlainUnit + | MaybeNestedGroup + )[] = []; + for (const q of ingredient.quantities) { + if ("and" in q) { + allPlainEntries.push({ and: q.and }); + } else { + const entry: QuantityWithPlainUnit = { + quantity: q.quantity, + ...(q.unit && { unit: q.unit }), + ...(q.equivalents && { equivalents: q.equivalents }), + }; + allPlainEntries.push(entry); + } + } + + let quantityTotal: + | QuantityWithPlainUnit + | MaybeNestedGroup; + if (allPlainEntries.length === 1) { + quantityTotal = allPlainEntries[0]!; + } else { + quantityTotal = { and: allPlainEntries }; + } + // Normalize options units and scale size to base const normalizedOptions: ProductOptionNormalized[] = options.map( (option) => ({ @@ -239,7 +268,7 @@ export class ShoppingCart { }), }), ); - const normalizedQuantityTotal = normalizeAllUnits(ingredient.quantityTotal); + const normalizedQuantityTotal = normalizeAllUnits(quantityTotal); function getOptimumMatchForQuantityParts( normalizedQuantities: diff --git a/src/classes/shopping_list.ts b/src/classes/shopping_list.ts index 37e55d7..db375f8 100644 --- a/src/classes/shopping_list.ts +++ b/src/classes/shopping_list.ts @@ -7,20 +7,32 @@ import type { AddedIngredient, QuantityWithExtendedUnit, QuantityWithPlainUnit, - MaybeNestedGroup, FlatOrGroup, AddedRecipeOptions, PantryOptions, + SpecificUnitSystem, } from "../types"; -import { addEquivalentsAndSimplify } from "../quantities/alternatives"; import { - extendAllUnits, + addEquivalentsAndSimplify, + getEquivalentUnitsLists, +} from "../quantities/alternatives"; +import { + flattenPlainUnitGroup, subtractQuantities, toExtendedUnit, toPlainUnit, } from "../quantities/mutations"; -import { isAndGroup, isOrGroup, isQuantity } from "../utils/type_guards"; +import { getAverageValue } from "../quantities/numeric"; import { deepClone } from "../utils/general"; +import { NO_UNIT } from "../units/definitions"; +import type { QuantityWithUnitDef } from "../types"; + +/** + * Maps equivalent unit name → (primary unit name → ratio). + * ratio = equiv_quantity_value / primary_quantity_value from the original OR group. + * Used to recompute equivalents after pantry subtraction modifies primaries. + */ +type EquivalenceRatioMap = Record>; /** * Shopping List generator. @@ -49,7 +61,6 @@ import { deepClone } from "../utils/general"; * @category Classes */ export class ShoppingList { - // TODO: backport type change /** * The ingredients in the shopping list. */ @@ -66,6 +77,17 @@ export class ShoppingList { * The categorized ingredients in the shopping list. */ categories?: CategorizedIngredients; + /** + * The unit system to use for quantity simplification. + * When set, overrides per-recipe unit systems. + */ + unitSystem?: SpecificUnitSystem; + /** + * Per-ingredient equivalence ratio maps for recomputing equivalents + * after pantry subtraction. Keyed by ingredient name. + * @internal + */ + private equivalenceRatios = new Map(); /** * The original pantry (never mutated by recipe calculations). */ @@ -89,51 +111,17 @@ export class ShoppingList { private calculateIngredients() { this.ingredients = []; - const addIngredientQuantity = ( - name: string, - quantityTotal: - | QuantityWithPlainUnit - | MaybeNestedGroup, - ) => { - const quantityTotalExtended = extendAllUnits(quantityTotal); - const newQuantities = ( - isAndGroup(quantityTotalExtended) - ? quantityTotalExtended.and - : [quantityTotalExtended] - ) as (QuantityWithExtendedUnit | FlatOrGroup)[]; - const existing = this.ingredients.find((i) => i.name === name); - - if (existing) { - if (!existing.quantityTotal) { - existing.quantityTotal = quantityTotal; - return; - } - try { - const existingQuantityTotalExtended = extendAllUnits( - existing.quantityTotal, - ); - const existingQuantities = ( - isAndGroup(existingQuantityTotalExtended) - ? existingQuantityTotalExtended.and - : [existingQuantityTotalExtended] - ) as ( - | QuantityWithExtendedUnit - | FlatOrGroup - )[]; - existing.quantityTotal = addEquivalentsAndSimplify([ - ...existingQuantities, - ...newQuantities, - ]); - return; - } catch { - // Incompatible - } + // Accumulate raw quantities per ingredient name across all recipes + const rawQuantitiesMap = new Map< + string, + (QuantityWithExtendedUnit | FlatOrGroup)[] + >(); + // Track first-appearance order of ingredient names + const nameOrder: string[] = []; + const trackName = (name: string) => { + if (!nameOrder.includes(name)) { + nameOrder.push(name); } - - this.ingredients.push({ - name, - quantityTotal, - }); }; for (const addedRecipe of this.recipes) { @@ -145,67 +133,88 @@ export class ShoppingList { scaledRecipe = addedRecipe.recipe.scaleTo(addedRecipe.servings); } - // Get computed ingredients with total quantities based on choices (or default) - const ingredients = scaledRecipe.getIngredientQuantities({ + const rawGroups = scaledRecipe.getRawQuantityGroups({ choices: addedRecipe.choices, }); - for (const ingredient of ingredients) { - // Do not add hidden ingredients to the shopping list - if (ingredient.flags && ingredient.flags.includes("hidden")) { + for (const group of rawGroups) { + if (group.flags?.includes("hidden") || !group.usedAsPrimary) { continue; } - // Only add ingredients that were selected (have usedAsPrimary flag) - // This filters out alternative ingredients that weren't chosen - if (!ingredient.usedAsPrimary) { - continue; + trackName(group.name); + + if (group.quantities.length > 0) { + const existing = rawQuantitiesMap.get(group.name) ?? []; + existing.push(...group.quantities); + rawQuantitiesMap.set(group.name, existing); + } + } + } + + // Process each ingredient: addEquivalentsAndSimplify → flattenPlainUnitGroup + this.equivalenceRatios.clear(); + for (const name of nameOrder) { + const rawQuantities = rawQuantitiesMap.get(name); + + if (!rawQuantities || rawQuantities.length === 0) { + this.ingredients.push({ name }); + continue; + } + + // Separate text-value quantities (cannot be summed) from numeric ones + const textEntries: QuantityWithExtendedUnit[] = []; + const numericEntries: ( + | QuantityWithExtendedUnit + | FlatOrGroup + )[] = []; + for (const q of rawQuantities) { + if ( + "quantity" in q && + q.quantity.type === "fixed" && + q.quantity.value.type === "text" + ) { + textEntries.push(q); + } else { + numericEntries.push(q); } + } - // Sum up quantities from the ingredient's quantity groups - if (ingredient.quantities && ingredient.quantities.length > 0) { - // Extract all quantities (converting to plain units for summing) - const allQuantities: ( - | QuantityWithPlainUnit - | MaybeNestedGroup - )[] = []; - for (const qGroup of ingredient.quantities) { - if ("and" in qGroup) { - // AndGroup - add each quantity separately - for (const qty of qGroup.and) { - allQuantities.push(qty); - } - } else { - // Simple quantity (strip alternatives - choices already applied) - const plainQty: QuantityWithPlainUnit = { - quantity: qGroup.quantity, - }; - if (qGroup.unit) plainQty.unit = qGroup.unit; - if (qGroup.equivalents) plainQty.equivalents = qGroup.equivalents; - allQuantities.push(plainQty); - } - } - if (allQuantities.length === 1) { - addIngredientQuantity(ingredient.name, allQuantities[0]!); - } else { - // allQuantities.length > 1 - // Sum up using addEquivalentsAndSimplify - const extendedQuantities = allQuantities.map((q) => - extendAllUnits(q), - ); - const totalQuantity = addEquivalentsAndSimplify( - extendedQuantities as ( - | QuantityWithExtendedUnit - | FlatOrGroup - )[], - ); - // addEquivalentsAndSimplify already returns plain units - addIngredientQuantity(ingredient.name, totalQuantity); - } - } else if (!this.ingredients.some((i) => i.name === ingredient.name)) { - this.ingredients.push({ name: ingredient.name }); + // Build equivalence ratio map for recomputing equivalents after pantry subtraction + if (numericEntries.length > 1) { + const ratioMap = ShoppingList.buildEquivalenceRatioMap( + getEquivalentUnitsLists(...numericEntries), + ); + if (Object.keys(ratioMap).length > 0) { + this.equivalenceRatios.set(name, ratioMap); } } + + const resultQuantities: ( + | QuantityWithPlainUnit + | { + and: QuantityWithPlainUnit[]; + equivalents?: QuantityWithPlainUnit[]; + } + )[] = []; + + // Text values stay as individual entries (placed first to preserve order) + for (const t of textEntries) { + resultQuantities.push(toPlainUnit(t) as QuantityWithPlainUnit); + } + + if (numericEntries.length > 0) { + resultQuantities.push( + ...flattenPlainUnitGroup( + addEquivalentsAndSimplify(numericEntries, this.unitSystem), + ), + ); + } + + this.ingredients.push({ + name, + quantities: resultQuantities, + }); } // Subtract pantry quantities from ingredients @@ -230,47 +239,180 @@ export class ShoppingList { } for (const ingredient of this.ingredients) { - if (!ingredient.quantityTotal) continue; + if (!ingredient.quantities || ingredient.quantities.length === 0) + continue; const pantryItem = clonedPantry.findItem(ingredient.name); if (!pantryItem || !pantryItem.quantity) continue; - // Extract leaf quantities from the ingredient (handles simple, AND, OR) - const leaves = this.extractLeafQuantities(ingredient.quantityTotal); - let pantryExtended: QuantityWithExtendedUnit = { quantity: pantryItem.quantity, ...(pantryItem.unit && { unit: { name: pantryItem.unit } }), }; - for (const leaf of leaves) { - const ingredientExtended = toExtendedUnit(leaf.quantity); + for (let i = 0; i < ingredient.quantities.length; i++) { + const entry = ingredient.quantities[i]!; + // For AND groups, iterate each .and entry individually + const leaves: QuantityWithPlainUnit[] = + "and" in entry ? entry.and : [entry]; + + for (const leaf of leaves) { + const ingredientExtended = toExtendedUnit(leaf); + + // When one side is unitless and the other has a unit, + // addQuantities adopts the other's unit (Case 2), which is wrong + // for pantry subtraction. Use equivalence ratios to convert instead. + const leafHasUnit = leaf.unit !== undefined && leaf.unit !== ""; + const pantryHasUnit = + pantryExtended.unit !== undefined && + pantryExtended.unit.name !== ""; + const ratioMap = this.equivalenceRatios.get(ingredient.name); + const unitMismatch = + leafHasUnit !== pantryHasUnit && ratioMap !== undefined; + + if (unitMismatch) { + const leafUnit = leaf.unit ?? NO_UNIT; + const pantryUnit = pantryExtended.unit?.name ?? NO_UNIT; + const ratioFromPantry = ratioMap[leafUnit]?.[pantryUnit]; + if (ratioFromPantry !== undefined) { + const pantryValue = getAverageValue(pantryExtended.quantity); + const leafValue = getAverageValue(ingredientExtended.quantity); + if ( + typeof pantryValue === "number" && + typeof leafValue === "number" + ) { + const pantryInLeafUnits = pantryValue * ratioFromPantry; + const subtracted = Math.min(pantryInLeafUnits, leafValue); + const remainingLeafValue = Math.max( + leafValue - pantryInLeafUnits, + 0, + ); + + // Write back the remaining value into the leaf + leaf.quantity = { + type: "fixed", + value: { type: "decimal", decimal: remainingLeafValue }, + }; + + // Update pantry remainder: convert consumed back to pantry units + const consumedInPantryUnits = + ratioFromPantry !== 0 + ? subtracted / ratioFromPantry + : pantryValue; + const remainingPantryValue = Math.max( + pantryValue - consumedInPantryUnits, + 0, + ); + pantryExtended = { + quantity: { + type: "fixed", + value: { + type: "decimal", + decimal: remainingPantryValue, + }, + }, + ...(pantryExtended.unit && { unit: pantryExtended.unit }), + }; + continue; + } + } + } - try { - // Subtract pantry from ingredient need (clamped to zero) - const remaining = subtractQuantities( - ingredientExtended, - pantryExtended, - { clampToZero: true }, - ); + try { + const remaining = subtractQuantities( + ingredientExtended, + pantryExtended, + { clampToZero: true }, + ); - // Apply the updated quantity back into the group structure - leaf.apply(toPlainUnit(remaining) as QuantityWithPlainUnit); + // Update the pantry remainder: subtract what was consumed + // Must happen before writing back into leaf, since toExtendedUnit + // may return the same object reference for unitless quantities + const consumed = subtractQuantities( + pantryExtended, + ingredientExtended, + { clampToZero: true }, + ); + pantryExtended = consumed; + + // Write back into the leaf in-place + const updated = toPlainUnit(remaining) as QuantityWithPlainUnit; + leaf.quantity = updated.quantity; + leaf.unit = updated.unit; + } catch { + // Incompatible units — skip subtraction for this leaf + } + } - // Update the pantry remainder: subtract what was consumed - const consumed = subtractQuantities( - pantryExtended, - ingredientExtended, - { clampToZero: true }, + // Remove zero-valued leaves from AND groups + if ("and" in entry) { + const nonZero = entry.and.filter( + (leaf) => + leaf.quantity.type !== "fixed" || + leaf.quantity.value.type !== "decimal" || + leaf.quantity.value.decimal !== 0, ); - pantryExtended = consumed; - } catch { - // Incompatible units — skip subtraction for this leaf + entry.and.length = 0; + entry.and.push(...nonZero); + // Recompute equivalents from updated primaries using stored ratios + const ratioMap = this.equivalenceRatios.get(ingredient.name); + // v8 ignore else --@preserve: defensive type guard + if (entry.equivalents && ratioMap) { + const equivUnits = entry.equivalents.map((e) => e.unit ?? NO_UNIT); + entry.equivalents = ShoppingList.recomputeEquivalents( + entry.and, + ratioMap, + equivUnits, + ); + } + // Collapse single-leaf AND group to a plain IngredientQuantityGroup + // v8 ignore else --@preserve: defensive type guard + if (entry.and.length === 1) { + const single = entry.and[0]!; + ingredient.quantities[i] = { + quantity: single.quantity, + ...(single.unit && { unit: single.unit }), + ...(entry.equivalents && { equivalents: entry.equivalents }), + ...(entry.alternatives && { alternatives: entry.alternatives }), + }; + } + } else if ("equivalents" in entry && entry.equivalents) { + // Recompute equivalents for plain entries with equivalents + const ratioMap = this.equivalenceRatios.get(ingredient.name); + // v8 ignore else --@preserve: defensive type guard + if (ratioMap) { + const equivUnits = entry.equivalents.map( + (e: QuantityWithPlainUnit) => e.unit ?? NO_UNIT, + ); + const recomputed = ShoppingList.recomputeEquivalents( + [entry as QuantityWithPlainUnit], + ratioMap, + equivUnits, + ); + (entry as { equivalents?: QuantityWithPlainUnit[] }).equivalents = + recomputed; + } } } + // Remove empty AND groups (all leaves were zero) + // and remove zero-valued simple entries + ingredient.quantities = ingredient.quantities.filter((entry) => { + if ("and" in entry) return entry.and.length > 0; + return !( + entry.quantity.type === "fixed" && + entry.quantity.value.type === "decimal" && + entry.quantity.value.decimal === 0 + ); + }); + + // Remove ingredient entirely if all quantities were zeroed out + if (ingredient.quantities.length === 0) { + ingredient.quantities = undefined; + } + pantryItem.quantity = pantryExtended.quantity; - // v8 ignore else -- @preserve + /* v8 ignore else -- @preserve */ if (pantryExtended.unit) { pantryItem.unit = pantryExtended.unit.name; } @@ -280,51 +422,69 @@ export class ShoppingList { } /** - * Extracts leaf (simple) quantities from a possibly nested group structure. - * Each leaf includes the quantity and an `apply` callback to write back - * a modified value into the original structure in-place. - * - * - Simple quantity → one leaf - * - OR group → recurse into the first entry only (the primary alternative) - * - AND group → recurse into all entries + * Builds a ratio map from equivalence lists. + * For each equivalence list, stores ratio = equiv_value / primary_value + * for every pair of units, so equivalents can be recomputed after + * pantry subtraction modifies primary quantities. */ - private extractLeafQuantities( - q: QuantityWithPlainUnit | MaybeNestedGroup, - ): { - quantity: QuantityWithPlainUnit; - apply: (v: QuantityWithPlainUnit) => void; - }[] { - if (isQuantity(q)) { - return [ - { - quantity: q, - apply: (v: QuantityWithPlainUnit) => { - Object.assign(q, v); - }, - }, - ]; + private static buildEquivalenceRatioMap( + unitsLists: QuantityWithUnitDef[][], + ): EquivalenceRatioMap { + const ratioMap: EquivalenceRatioMap = {}; + for (const list of unitsLists) { + for (const equiv of list) { + // Equivalent lists do not include string quantities, so it's safe to assume numeric value here + const equivValue = getAverageValue(equiv.quantity) as number; + for (const primary of list) { + if (primary === equiv) continue; + // Equivalent lists do not include string quantities, so it's safe to assume numeric value here + const primaryValue = getAverageValue(primary.quantity) as number; + const equivUnit = equiv.unit.name; + const primaryUnit = primary.unit.name; + ratioMap[equivUnit] ??= {}; + ratioMap[equivUnit][primaryUnit] = equivValue / primaryValue; + } + } } + return ratioMap; + } - if (isOrGroup(q)) { - // Only subtract from the primary (first) entry - const first = q.or[0]; - /* v8 ignore else -- @preserve */ - if (first) { - return this.extractLeafQuantities(first); + /** + * Recomputes equivalent quantities from current primary values and stored ratios. + * For each equivalent unit in equivUnits, new_value = Σ (primary_value × ratio[equivUnit][primaryUnit]). + * Returns undefined if all equivalents compute to zero. + */ + private static recomputeEquivalents( + primaries: QuantityWithPlainUnit[], + ratioMap: EquivalenceRatioMap, + equivUnits: string[], + ): QuantityWithPlainUnit[] | undefined { + const equivalents: QuantityWithPlainUnit[] = []; + + for (const equivUnit of equivUnits) { + const ratios = ratioMap[equivUnit]!; + + let total = 0; + for (const primary of primaries) { + const pUnit = primary.unit ?? NO_UNIT; + // In equivalent unit lists, ratios and pvalues are always defined, and are numbers + const ratio = ratios[pUnit]!; + const pValue = getAverageValue(primary.quantity) as number; + total += pValue * ratio; } - /* v8 ignore next -- @preserve */ - return []; - } - // AND group: recurse into all entries - const results: { - quantity: QuantityWithPlainUnit; - apply: (v: QuantityWithPlainUnit) => void; - }[] = []; - for (const entry of q.and) { - results.push(...this.extractLeafQuantities(entry)); + if (total > 0) { + equivalents.push({ + quantity: { + type: "fixed", + value: { type: "decimal", decimal: total }, + }, + ...(equivUnit !== "" && { unit: equivUnit }), + }); + } } - return results; + + return equivalents.length > 0 ? equivalents : undefined; } /** @@ -391,6 +551,7 @@ export class ShoppingList { // Check for grouped alternatives without choices for (const groupId of recipe.choices.ingredientGroups.keys()) { + // v8 ignore else -- @preserve: detection if if (!choices?.ingredientGroups?.has(groupId)) { missingGroups.push(groupId); } diff --git a/src/index.ts b/src/index.ts index 5ef6c0c..ac81063 100644 --- a/src/index.ts +++ b/src/index.ts @@ -145,6 +145,7 @@ import type { RecipeChoices, RecipeAlternatives, GetIngredientQuantitiesOptions, + RawQuantityGroup, } from "./types"; export { @@ -232,6 +233,7 @@ export { RecipeChoices, RecipeAlternatives, GetIngredientQuantitiesOptions, + RawQuantityGroup, }; // Errors diff --git a/src/types.ts b/src/types.ts index 804e493..9043b00 100644 --- a/src/types.ts +++ b/src/types.ts @@ -436,6 +436,27 @@ export interface GetIngredientQuantitiesOptions { choices?: RecipeChoices; } +/** + * Represents a raw (unprocessed) group of quantities for a single ingredient. + * Returned by {@link Recipe.getRawQuantityGroups | getRawQuantityGroups()}, + * these are the pre-addition quantities that can be fed directly into + * {@link addEquivalentsAndSimplify} for cross-recipe aggregation. + * @category Types + */ +export interface RawQuantityGroup { + /** The name of the ingredient. */ + name: string; + /** Whether this ingredient is used as a primary choice. */ + usedAsPrimary?: boolean; + /** Flags on the ingredient (e.g., "hidden", "optional"). */ + flags?: IngredientFlag[]; + /** The raw, unprocessed quantities for this ingredient across all its mentions. */ + quantities: ( + | QuantityWithExtendedUnit + | FlatOrGroup + )[]; +} + /** * Represents a cookware item in a recipe step. * @category Types @@ -629,12 +650,7 @@ export type AddedRecipeOptions = { * Represents an ingredient that has been added to a shopping list * @category Types */ -export type AddedIngredient = Pick & { - /** The total quantity of the ingredient after applying choices. */ - quantityTotal?: - | QuantityWithPlainUnit - | MaybeNestedGroup; -}; +export type AddedIngredient = Pick; /** * Represents an ingredient in a category. diff --git a/test/quantities_alternatives.test.ts b/test/quantities_alternatives.test.ts index 938ac60..2e21f6b 100644 --- a/test/quantities_alternatives.test.ts +++ b/test/quantities_alternatives.test.ts @@ -52,6 +52,31 @@ describe("getEquivalentUnitsLists", () => { ], ]); }); + + it("should ignore unitless quantities not connected to others", () => { + expect( + getEquivalentUnitsLists( + q(1, NO_UNIT), + { or: [q(1, "small"), q(1, "cup")] }, + { or: [q(1, "large"), q(1.5, "cup")] }, + ), + ).toEqual([ + [ + qWithUnitDef(1, "small"), + qWithUnitDef(1, "cup"), + qWithUnitDef(0.667, "large"), + ], + ]); + }); + + it("keeps the first ratio when equivalents are declared multiple times but with different ratios", () => { + expect( + getEquivalentUnitsLists( + { or: [q(1, "small"), q(1, "cup")] }, + { or: [q(1, "small"), q(2, "cup")] }, + ), + ).toEqual([[qWithUnitDef(1, "small"), qWithUnitDef(1, "cup")]]); + }); }); describe("sortUnitList", () => { @@ -339,4 +364,31 @@ describe("addEquivalentsAndSimplify", () => { ], }); }); + it("should keep unitless quantity separate from AND group with equivalents", () => { + // Unitless quantity (3) has no relationship to the large/small/cup equivalence system + // It should NOT be absorbed into the AND group + const or1: FlatOrGroup = { + or: [q(1, "large", true), q(1.5, "cup")], + }; + const or2: FlatOrGroup = { + or: [q(1, "small"), q(0.5, "cup")], + }; + expect(addEquivalentsAndSimplify([q(3), or1, or2])).toEqual({ + and: [ + { + or: [ + { and: [qPlain(1, "large"), qPlain(1, "small")] }, + { + quantity: { + type: "fixed", + value: { type: "decimal", decimal: 2 }, + }, + unit: "cup", + }, + ], + }, + qPlain(3), + ], + }); + }); }); diff --git a/test/quantities_mutations.test.ts b/test/quantities_mutations.test.ts index 61c8eeb..b770bc8 100644 --- a/test/quantities_mutations.test.ts +++ b/test/quantities_mutations.test.ts @@ -93,6 +93,16 @@ describe("extendAllUnits", () => { }; expect(extended).toEqual(expected); }); + it("should handle a unitless quantity", () => { + const original: QuantityWithPlainUnit = { + quantity: { type: "fixed", value: { type: "decimal", decimal: 3 } }, + }; + const extended = extendAllUnits(original); + const expected: QuantityWithExtendedUnit = { + quantity: { type: "fixed", value: { type: "decimal", decimal: 3 } }, + }; + expect(extended).toEqual(expected); + }); }); describe("normalizeAllUnits", () => { diff --git a/test/shopping_cart.test.ts b/test/shopping_cart.test.ts index ba268a7..9ff6d81 100644 --- a/test/shopping_cart.test.ts +++ b/test/shopping_cart.test.ts @@ -482,4 +482,26 @@ Cook @carrots{three|5%box} "textValue_incompatibleUnits", ); }); + + it("should handle ingredients with AND group quantities", () => { + const shoppingCart = new ShoppingCart(); + const shoppingList = new ShoppingList(); + // Recipe with AND group (incompatible primaries with cup equivalents) + shoppingList.addRecipe( + new Recipe(` +--- +servings: 1 +--- +Cook @carrots{1%=large|1.5%cup} and @&carrots{1%=small|0.5%cup} +`), + ); + shoppingCart.setShoppingList(shoppingList); + shoppingCart.setProductCatalog(productCatalog); + shoppingCart.buildCart(); + // The AND group has "large" and "small" primaries that match products + expect(shoppingCart.cart).toMatchObject([ + { product: { id: "large-carrots" }, quantity: 1 }, + { product: { id: "small-carrots" }, quantity: 1 }, + ]); + }); }); diff --git a/test/shopping_list.test.ts b/test/shopping_list.test.ts index 7342d74..a82db83 100644 --- a/test/shopping_list.test.ts +++ b/test/shopping_list.test.ts @@ -24,48 +24,61 @@ describe("ShoppingList", () => { expect(shoppingList.ingredients).toEqual([ { name: "flour", - quantityTotal: { - quantity: { - type: "fixed", - value: { type: "decimal", decimal: 100 }, + quantities: [ + { + quantity: { + type: "fixed", + value: { type: "decimal", decimal: 100 }, + }, + unit: "g", }, - unit: "g", - }, + ], }, { name: "sugar", - quantityTotal: { - quantity: { - type: "fixed", - value: { type: "decimal", decimal: 50 }, + quantities: [ + { + quantity: { + type: "fixed", + value: { type: "decimal", decimal: 50 }, + }, + unit: "g", }, - unit: "g", - }, + ], }, { name: "eggs", - quantityTotal: { - quantity: { type: "fixed", value: { type: "decimal", decimal: 2 } }, - }, + quantities: [ + { + quantity: { + type: "fixed", + value: { type: "decimal", decimal: 2 }, + }, + }, + ], }, { name: "milk", - quantityTotal: { - quantity: { - type: "fixed", - value: { type: "decimal", decimal: 200 }, + quantities: [ + { + quantity: { + type: "fixed", + value: { type: "decimal", decimal: 200 }, + }, + unit: "ml", }, - unit: "ml", - }, + ], }, { name: "pepper", - quantityTotal: { - quantity: { - type: "fixed", - value: { type: "text", text: "to taste" }, + quantities: [ + { + quantity: { + type: "fixed", + value: { type: "text", text: "to taste" }, + }, }, - }, + ], }, { name: "spices" }, ]); @@ -78,12 +91,90 @@ describe("ShoppingList", () => { shoppingList.addRecipe(recipe1); // adding the same one again to check accumulation expect(shoppingList.ingredients.find((i) => i.name === "eggs")).toEqual({ name: "eggs", - quantityTotal: { - and: [ + quantities: [ + { + quantity: { + type: "fixed", + value: { type: "decimal", decimal: 4 }, + }, + }, + { + quantity: { + type: "fixed", + value: { type: "decimal", decimal: 1 }, + }, + unit: "dozen", + }, + { + quantity: { + type: "fixed", + value: { type: "decimal", decimal: 1 }, + }, + unit: "half dozen", + }, + ], + }); + }); + + it("should merge ingredients from multiple recipes", () => { + const shoppingList = new ShoppingList(); + shoppingList.addRecipe(recipe1); + shoppingList.addRecipe(recipe2); + expect(shoppingList.ingredients).toEqual([ + { + name: "flour", + quantities: [ { quantity: { type: "fixed", - value: { type: "decimal", decimal: 4 }, + value: { type: "decimal", decimal: 150 }, + }, + unit: "g", + }, + ], + }, + { + name: "sugar", + quantities: [ + { + quantity: { + type: "fixed", + value: { type: "decimal", decimal: 50 }, + }, + unit: "g", + }, + ], + }, + { + name: "eggs", + quantities: [ + { + quantity: { + type: "fixed", + value: { type: "decimal", decimal: 3 }, + }, + }, + ], + }, + { + name: "milk", + quantities: [ + { + quantity: { + type: "fixed", + value: { type: "decimal", decimal: 200 }, + }, + unit: "ml", + }, + ], + }, + { + name: "pepper", + quantities: [ + { + quantity: { + type: "fixed", + value: { type: "text", text: "to taste" }, }, }, { @@ -91,293 +182,545 @@ describe("ShoppingList", () => { type: "fixed", value: { type: "decimal", decimal: 1 }, }, - unit: "dozen", + unit: "tsp", }, + ], + }, + { + name: "spices", + quantities: [ { quantity: { type: "fixed", value: { type: "decimal", decimal: 1 }, }, - unit: "half dozen", + unit: "pinch", }, ], }, - }); + { + name: "butter", + quantities: [ + { + quantity: { + type: "fixed", + value: { type: "decimal", decimal: 25 }, + }, + unit: "g", + }, + ], + }, + ]); }); - it("should merge ingredients from multiple recipes", () => { + it("should scale recipe ingredients (deprecated signature)", () => { const shoppingList = new ShoppingList(); - shoppingList.addRecipe(recipe1); - shoppingList.addRecipe(recipe2); + // TODO: Deprecated, to remove in v3 + shoppingList.addRecipe(recipe1, { scaling: { factor: 2 } }); expect(shoppingList.ingredients).toEqual([ { name: "flour", - quantityTotal: { - quantity: { - type: "fixed", - value: { type: "decimal", decimal: 150 }, + quantities: [ + { + quantity: { + type: "fixed", + value: { type: "decimal", decimal: 200 }, + }, + unit: "g", }, - unit: "g", - }, + ], }, { name: "sugar", - quantityTotal: { - quantity: { - type: "fixed", - value: { type: "decimal", decimal: 50 }, + quantities: [ + { + quantity: { + type: "fixed", + value: { type: "decimal", decimal: 100 }, + }, + unit: "g", }, - unit: "g", - }, + ], }, { name: "eggs", - quantityTotal: { - quantity: { type: "fixed", value: { type: "decimal", decimal: 3 } }, - }, + quantities: [ + { + quantity: { + type: "fixed", + value: { type: "decimal", decimal: 4 }, + }, + }, + ], }, { name: "milk", - quantityTotal: { - quantity: { - type: "fixed", - value: { type: "decimal", decimal: 200 }, + quantities: [ + { + quantity: { + type: "fixed", + value: { type: "decimal", decimal: 400 }, + }, + unit: "ml", }, - unit: "ml", - }, + ], }, { name: "pepper", - quantityTotal: { - and: [ - { - quantity: { - type: "fixed", - value: { type: "text", text: "to taste" }, - }, + quantities: [ + { + quantity: { + type: "fixed", + value: { type: "text", text: "to taste" }, }, - { - quantity: { - type: "fixed", - value: { type: "decimal", decimal: 1 }, - }, - unit: "tsp", + }, + ], + }, + { name: "spices" }, + ]); + }); + + it("should scale recipe ingredients (using factor)", () => { + const shoppingList = new ShoppingList(); + shoppingList.addRecipe(recipe1, { scaling: { factor: 2 } }); + expect(shoppingList.ingredients).toEqual([ + { + name: "flour", + quantities: [ + { + quantity: { + type: "fixed", + value: { type: "decimal", decimal: 200 }, }, - ], - }, + unit: "g", + }, + ], }, { - name: "spices", - quantityTotal: { - quantity: { type: "fixed", value: { type: "decimal", decimal: 1 } }, - unit: "pinch", - }, + name: "sugar", + quantities: [ + { + quantity: { + type: "fixed", + value: { type: "decimal", decimal: 100 }, + }, + unit: "g", + }, + ], }, { - name: "butter", - quantityTotal: { - quantity: { - type: "fixed", - value: { type: "decimal", decimal: 25 }, + name: "eggs", + quantities: [ + { + quantity: { + type: "fixed", + value: { type: "decimal", decimal: 4 }, + }, }, - unit: "g", - }, + ], + }, + { + name: "milk", + quantities: [ + { + quantity: { + type: "fixed", + value: { type: "decimal", decimal: 400 }, + }, + unit: "ml", + }, + ], + }, + { + name: "pepper", + quantities: [ + { + quantity: { + type: "fixed", + value: { type: "text", text: "to taste" }, + }, + }, + ], }, + { name: "spices" }, ]); }); - it("should scale recipe ingredients (deprecated signature)", () => { + it("should scale recipe ingredients (using servings)", () => { const shoppingList = new ShoppingList(); - // TODO: Deprecated, to remove in v3 - shoppingList.addRecipe(recipe1, { scaling: { factor: 2 } }); + shoppingList.addRecipe(recipe1, { scaling: { servings: 3 } }); expect(shoppingList.ingredients).toEqual([ { name: "flour", - quantityTotal: { - quantity: { - type: "fixed", - value: { type: "decimal", decimal: 200 }, + quantities: [ + { + quantity: { + type: "fixed", + value: { type: "decimal", decimal: 300 }, + }, + unit: "g", }, - unit: "g", - }, + ], }, { name: "sugar", - quantityTotal: { - quantity: { - type: "fixed", - value: { type: "decimal", decimal: 100 }, + quantities: [ + { + quantity: { + type: "fixed", + value: { type: "decimal", decimal: 150 }, + }, + unit: "g", }, - unit: "g", - }, + ], }, { name: "eggs", - quantityTotal: { - quantity: { type: "fixed", value: { type: "decimal", decimal: 4 } }, - }, + quantities: [ + { + quantity: { + type: "fixed", + value: { type: "decimal", decimal: 6 }, + }, + }, + ], + }, + { + name: "milk", + quantities: [ + { + quantity: { + type: "fixed", + value: { type: "decimal", decimal: 600 }, + }, + unit: "ml", + }, + ], + }, + { + name: "pepper", + quantities: [ + { + quantity: { + type: "fixed", + value: { type: "text", text: "to taste" }, + }, + }, + ], }, + { name: "spices" }, + ]); + }); + + it("should take into account ingredient choices when adding a recipe", () => { + const shoppingList = new ShoppingList(); + const choices = { + ingredientItems: new Map([["ingredient-item-0", 1]]), + }; + shoppingList.addRecipe(recipeAlt, { choices }); + expect(shoppingList.ingredients).toEqual([ + { + name: "almond milk", + quantities: [ + { + quantity: { + type: "fixed", + value: { type: "decimal", decimal: 100 }, + }, + unit: "ml", + }, + ], + }, + ]); + }); + + it("should handle ingredients with AND groups in quantities", () => { + // Recipe with incompatible units that form AND groups (large + small with cup equivalents) + const recipeWithAndGroups = new Recipe(` +Add @potato{1%=large|1.5%cup} and @&potato{1%=small|0.5%cup} +`); + const shoppingList = new ShoppingList(); + shoppingList.addRecipe(recipeWithAndGroups); + // Potato should have quantities combined from the AND group + const potato = shoppingList.ingredients.find((i) => i.name === "potato"); + expect(potato).toBeDefined(); + // large: 1+2=3, small: 1+3=4, cup equivalents: 2+4.5=6.5 + expect(potato?.quantities).toMatchObject([ + { + and: [ + { + quantity: { + type: "fixed", + value: { type: "decimal", decimal: 1 }, + }, + unit: "large", + }, + { + quantity: { + type: "fixed", + value: { type: "decimal", decimal: 1 }, + }, + unit: "small", + }, + ], + equivalents: [ + { + quantity: { + type: "fixed", + value: { type: "decimal", decimal: 2 }, + }, + unit: "cup", + }, + ], + }, + ]); + }); + + it("should merge AND groups with different unit sets across recipes", () => { + // Recipe A has AND group with "large" and "small" units + const recipeA = new Recipe( + `Add @potato{1%=large|1.5%cup} and @&potato{1%=small|0.5%cup}`, + ); + // Recipe B has AND group with "large", "small", AND an extra unit "medium" + const recipeB = new Recipe( + `Add @potato{2%=large|2%cup} and @&potato{3%=small|1%cup} and @&potato{1%=medium|0.5%cup}`, + ); + const shoppingList = new ShoppingList(); + shoppingList.addRecipe(recipeA); + shoppingList.addRecipe(recipeB); + const potato = shoppingList.ingredients.find((i) => i.name === "potato"); + expect(potato).toBeDefined(); + // Unified approach: all raw quantities combined and processed once + // large: 1+2=3, small: 1+3=4, medium: 0+1=1 + // first ratio is retained so cup: 3*1.5+4*0.5+1*0.5 = 7 + expect(potato?.quantities).toMatchObject([ + { + and: [ + { + quantity: { + type: "fixed", + value: { type: "decimal", decimal: 3 }, + }, + unit: "large", + }, + { + quantity: { + type: "fixed", + value: { type: "decimal", decimal: 4 }, + }, + unit: "small", + }, + { + quantity: { + type: "fixed", + value: { type: "decimal", decimal: 1 }, + }, + unit: "medium", + }, + ], + equivalents: [ + { + quantity: { + type: "fixed", + value: { type: "decimal", decimal: 7 }, + }, + unit: "cup", + }, + ], + }, + ]); + }); + + it("should subtract pantry from AND group entries individually", () => { + // Recipe with equivalents that produce a real { and: [...] } group in quantities + const recipe = new Recipe( + `Add @potato{1%=large|1.5%cup} and @&potato{1%=small|0.5%cup}`, + ); + const shoppingList = new ShoppingList(); + shoppingList.addRecipe(recipe); + + // Pantry has 5 large — should subtract from the "large" entry only + shoppingList.addPantry(`[pantry]\npotato = "5%large"`); + + const potato = shoppingList.ingredients.find((i) => i.name === "potato"); + expect(potato).toBeDefined(); + // large: 1-5 = 0 (clamped), small: incompatible unit → stays 1 + expect(potato?.quantities).toMatchObject([ { - name: "milk", - quantityTotal: { - quantity: { - type: "fixed", - value: { type: "decimal", decimal: 400 }, - }, - unit: "ml", + quantity: { + type: "fixed", + value: { type: "decimal", decimal: 1 }, }, - }, - { - name: "pepper", - quantityTotal: { - quantity: { - type: "fixed", - value: { type: "text", text: "to taste" }, + unit: "small", + equivalents: [ + { + quantity: { + type: "fixed", + value: { type: "decimal", decimal: 0.5 }, + }, + unit: "cup", }, - }, + ], }, - { name: "spices" }, ]); }); - it("should scale recipe ingredients (using factor)", () => { + it("should push AND group when no existing AND group for that ingredient", () => { + // First recipe has a simple unitless quantity, second has an AND group + // Unified approach: all raw quantities combined → addEquivalentsAndSimplify + // Unitless quantity stays separate from the equivalence AND group (large + small with cup equivalents) + const recipeSimple = new Recipe(`Add @potato{3}`); + const recipeAnd = new Recipe( + `Add @potato{1%=large|1.5%cup} and @&potato{1%=small|0.5%cup}`, + ); const shoppingList = new ShoppingList(); - shoppingList.addRecipe(recipe1, { scaling: { factor: 2 } }); - expect(shoppingList.ingredients).toEqual([ - { - name: "flour", - quantityTotal: { + shoppingList.addRecipe(recipeSimple); + shoppingList.addRecipe(recipeAnd); + const potato = shoppingList.ingredients.find((i) => i.name === "potato"); + expect(potato).toBeDefined(); + // Should have AND group (large + small with cup equivalents) + separate unitless entry + const quantities = potato?.quantities; + expect(quantities).toHaveLength(2); + expect(quantities![0]).toMatchObject({ + and: [ + { quantity: { type: "fixed", - value: { type: "decimal", decimal: 200 }, + value: { type: "decimal", decimal: 1 }, }, - unit: "g", + unit: "large", }, - }, - { - name: "sugar", - quantityTotal: { + { quantity: { type: "fixed", - value: { type: "decimal", decimal: 100 }, + value: { type: "decimal", decimal: 1 }, }, - unit: "g", - }, - }, - { - name: "eggs", - quantityTotal: { - quantity: { type: "fixed", value: { type: "decimal", decimal: 4 } }, + unit: "small", }, - }, - { - name: "milk", - quantityTotal: { + ], + equivalents: [ + { quantity: { type: "fixed", - value: { type: "decimal", decimal: 400 }, + value: { type: "decimal", decimal: 2 }, }, - unit: "ml", + unit: "cup", }, + ], + }); + expect(quantities![1]).toMatchObject({ + quantity: { + type: "fixed", + value: { type: "decimal", decimal: 3 }, }, + }); + }); + + it("should append unmatched equivalent units during AND group merge", () => { + // Recipe A has AND group with no equivalents + // Recipe B has AND group with ml equivalents + // Unified approach: all raw quantities combined, equivalents computed proportionally + const recipeA = new Recipe(`Add @fruit{1%=large} and @&fruit{1%=small}`); + const recipeB = new Recipe( + `Add @fruit{2%=large|50%ml} and @&fruit{3%=small|90%ml}`, + ); + const shoppingList = new ShoppingList(); + shoppingList.addRecipe(recipeA); + shoppingList.addRecipe(recipeB); + const fruit = shoppingList.ingredients.find((i) => i.name === "fruit"); + expect(fruit).toBeDefined(); + // large: 1+2=3, small: 1+3=4 + // ml equivalents recomputed proportionally from recipe B ratios: 195ml + expect(fruit?.quantities).toMatchObject([ { - name: "pepper", - quantityTotal: { - quantity: { - type: "fixed", - value: { type: "text", text: "to taste" }, + and: [ + { + quantity: { + type: "fixed", + value: { type: "decimal", decimal: 3 }, + }, + unit: "large", }, - }, + { + quantity: { + type: "fixed", + value: { type: "decimal", decimal: 4 }, + }, + unit: "small", + }, + ], + equivalents: [ + { + quantity: { + type: "fixed", + value: { type: "decimal", decimal: 195 }, + }, + unit: "ml", + }, + ], }, - { name: "spices" }, ]); }); - it("should scale recipe ingredients (using servings)", () => { + it("should combine incompatible units from different recipes into single AND group", () => { + // Recipe A has entries with equivalents (g|ml) plus incompatible unit (=large) + // Recipe B has an explicit AND group (big|ml + tiny|ml) + // Unified approach: all raw quantities combined into single addEquivalentsAndSimplify call + // → g, big, tiny share ml equivalents → AND group; large is standalone (no equivalence) + const recipeA = new Recipe( + `Add @fruit{1%g|300%ml} then add @fruit{1%=large}`, + ); + const recipeB = new Recipe( + `Add @fruit{2%=big|400%ml} and @&fruit{3%=tiny|200%ml}`, + ); const shoppingList = new ShoppingList(); - shoppingList.addRecipe(recipe1, { scaling: { servings: 3 } }); - expect(shoppingList.ingredients).toEqual([ - { - name: "flour", - quantityTotal: { - quantity: { - type: "fixed", - value: { type: "decimal", decimal: 300 }, - }, - unit: "g", - }, - }, - { - name: "sugar", - quantityTotal: { + shoppingList.addRecipe(recipeA); + shoppingList.addRecipe(recipeB); + const fruit = shoppingList.ingredients.find((i) => i.name === "fruit"); + expect(fruit).toBeDefined(); + // g, big, tiny in AND group with ml equivalents; large separate + expect(fruit?.quantities).toHaveLength(2); + expect(fruit?.quantities?.[0]).toMatchObject({ + and: [ + { quantity: { type: "fixed", - value: { type: "decimal", decimal: 150 }, + value: { type: "decimal", decimal: 1 }, }, unit: "g", }, - }, - { - name: "eggs", - quantityTotal: { - quantity: { type: "fixed", value: { type: "decimal", decimal: 6 } }, - }, - }, - { - name: "milk", - quantityTotal: { + { quantity: { type: "fixed", - value: { type: "decimal", decimal: 600 }, + value: { type: "decimal", decimal: 2 }, }, - unit: "ml", + unit: "big", }, - }, - { - name: "pepper", - quantityTotal: { + { quantity: { type: "fixed", - value: { type: "text", text: "to taste" }, + value: { type: "decimal", decimal: 3 }, }, + unit: "tiny", }, - }, - { name: "spices" }, - ]); - }); - - it("should take into account ingredient choices when adding a recipe", () => { - const shoppingList = new ShoppingList(); - const choices = { - ingredientItems: new Map([["ingredient-item-0", 1]]), - }; - shoppingList.addRecipe(recipeAlt, { choices }); - expect(shoppingList.ingredients).toEqual([ - { - name: "almond milk", - quantityTotal: { + ], + equivalents: [ + { quantity: { type: "fixed", - value: { type: "decimal", decimal: 100 }, + value: { type: "decimal", decimal: 900 }, }, unit: "ml", }, + ], + }); + expect(fruit?.quantities?.[1]).toMatchObject({ + quantity: { + type: "fixed", + value: { type: "decimal", decimal: 1 }, }, - ]); - }); - - it("should handle ingredients with AND groups in quantities", () => { - // Recipe with incompatible units that form AND groups (large + small with cup equivalents) - const recipeWithAndGroups = new Recipe(` -Add @potato{1%=large|1.5%cup} and @&potato{1%=small|0.5%cup} -`); - const shoppingList = new ShoppingList(); - shoppingList.addRecipe(recipeWithAndGroups); - // Potato should have quantities combined from the AND group - const potato = shoppingList.ingredients.find((i) => i.name === "potato"); - expect(potato).toBeDefined(); - // The AND group (1 large + 1 small) with equivalents (1.5 cup + 0.5 cup = 2 cup) should be processed - expect(potato?.quantityTotal).toBeDefined(); + unit: "large", + }); }); it("should throw an error when adding a recipe with inline alternatives without choices", () => { @@ -397,29 +740,6 @@ Mix @|milk|milk{200%ml} or @|milk|almond milk{100%ml} /Recipe has unresolved alternatives.*ingredientGroups.*milk/, ); }); - - it("should accept grouped alternatives with proper choices", () => { - const shoppingList = new ShoppingList(); - const recipeWithGroups = new Recipe(` -Mix @|milk|milk{200%ml} or @|milk|almond milk{100%ml} -`); - const choices = { - ingredientGroups: new Map([["milk", 1]]), // Choose almond milk (index 1) - }; - shoppingList.addRecipe(recipeWithGroups, { choices }); - expect(shoppingList.ingredients).toEqual([ - { - name: "almond milk", - quantityTotal: { - quantity: { - type: "fixed", - value: { type: "decimal", decimal: 100 }, - }, - unit: "ml", - }, - }, - ]); - }); }); describe("Association with CategoryConfig", () => { @@ -503,86 +823,96 @@ sugar Bakery: [ { name: "flour", - quantityTotal: { - quantity: { - type: "fixed", - value: { type: "decimal", decimal: 150 }, + quantities: [ + { + quantity: { + type: "fixed", + value: { type: "decimal", decimal: 150 }, + }, + unit: "g", }, - unit: "g", - }, + ], }, { name: "sugar", - quantityTotal: { - quantity: { - type: "fixed", - value: { type: "decimal", decimal: 50 }, + quantities: [ + { + quantity: { + type: "fixed", + value: { type: "decimal", decimal: 50 }, + }, + unit: "g", }, - unit: "g", - }, + ], }, ], Dairy: [ { name: "butter", - quantityTotal: { - quantity: { - type: "fixed", - value: { type: "decimal", decimal: 25 }, + quantities: [ + { + quantity: { + type: "fixed", + value: { type: "decimal", decimal: 25 }, + }, + unit: "g", }, - unit: "g", - }, + ], }, { name: "milk", - quantityTotal: { - quantity: { - type: "fixed", - value: { type: "decimal", decimal: 200 }, + quantities: [ + { + quantity: { + type: "fixed", + value: { type: "decimal", decimal: 200 }, + }, + unit: "ml", }, - unit: "ml", - }, + ], }, ], other: [ { name: "eggs", - quantityTotal: { - quantity: { - type: "fixed", - value: { type: "decimal", decimal: 3 }, + quantities: [ + { + quantity: { + type: "fixed", + value: { type: "decimal", decimal: 3 }, + }, }, - }, + ], }, { name: "pepper", - quantityTotal: { - and: [ - { - quantity: { - type: "fixed", - value: { type: "text", text: "to taste" }, - }, + quantities: [ + { + quantity: { + type: "fixed", + value: { type: "text", text: "to taste" }, }, - { - quantity: { - type: "fixed", - value: { type: "decimal", decimal: 1 }, - }, - unit: "tsp", + }, + { + quantity: { + type: "fixed", + value: { type: "decimal", decimal: 1 }, }, - ], - }, + unit: "tsp", + }, + ], }, { name: "spices", - quantityTotal: { - quantity: { - type: "fixed", - value: { type: "decimal", decimal: 1 }, + quantities: [ + { + quantity: { + type: "fixed", + value: { type: "decimal", decimal: 1 }, + }, + unit: "pinch", }, - unit: "pinch", - }, + ], }, ], }; @@ -604,51 +934,61 @@ sugar other: [ { name: "flour", - quantityTotal: { - quantity: { - type: "fixed", - value: { type: "decimal", decimal: 100 }, + quantities: [ + { + quantity: { + type: "fixed", + value: { type: "decimal", decimal: 100 }, + }, + unit: "g", }, - unit: "g", - }, + ], }, { name: "sugar", - quantityTotal: { - quantity: { - type: "fixed", - value: { type: "decimal", decimal: 50 }, + quantities: [ + { + quantity: { + type: "fixed", + value: { type: "decimal", decimal: 50 }, + }, + unit: "g", }, - unit: "g", - }, + ], }, { name: "eggs", - quantityTotal: { - quantity: { - type: "fixed", - value: { type: "decimal", decimal: 2 }, + quantities: [ + { + quantity: { + type: "fixed", + value: { type: "decimal", decimal: 2 }, + }, }, - }, + ], }, { name: "milk", - quantityTotal: { - quantity: { - type: "fixed", - value: { type: "decimal", decimal: 200 }, + quantities: [ + { + quantity: { + type: "fixed", + value: { type: "decimal", decimal: 200 }, + }, + unit: "ml", }, - unit: "ml", - }, + ], }, { name: "pepper", - quantityTotal: { - quantity: { - type: "fixed", - value: { type: "text", text: "to taste" }, + quantities: [ + { + quantity: { + type: "fixed", + value: { type: "text", text: "to taste" }, + }, }, - }, + ], }, { name: "spices" }, ], @@ -665,43 +1005,62 @@ sugar expect(shoppingList.ingredients).toEqual([ { name: "flour", - quantityTotal: { - quantity: { - type: "fixed", - value: { type: "decimal", decimal: 50 }, + quantities: [ + { + quantity: { + type: "fixed", + value: { type: "decimal", decimal: 50 }, + }, + unit: "g", }, - unit: "g", - }, + ], }, { name: "butter", - quantityTotal: { - quantity: { - type: "fixed", - value: { type: "decimal", decimal: 25 }, + quantities: [ + { + quantity: { + type: "fixed", + value: { type: "decimal", decimal: 25 }, + }, + unit: "g", }, - unit: "g", - }, + ], }, { name: "eggs", - quantityTotal: { - quantity: { type: "fixed", value: { type: "decimal", decimal: 1 } }, - }, + quantities: [ + { + quantity: { + type: "fixed", + value: { type: "decimal", decimal: 1 }, + }, + }, + ], }, { name: "pepper", - quantityTotal: { - quantity: { type: "fixed", value: { type: "decimal", decimal: 1 } }, - unit: "tsp", - }, + quantities: [ + { + quantity: { + type: "fixed", + value: { type: "decimal", decimal: 1 }, + }, + unit: "tsp", + }, + ], }, { name: "spices", - quantityTotal: { - quantity: { type: "fixed", value: { type: "decimal", decimal: 1 } }, - unit: "pinch", - }, + quantities: [ + { + quantity: { + type: "fixed", + value: { type: "decimal", decimal: 1 }, + }, + unit: "pinch", + }, + ], }, ]); }); @@ -716,26 +1075,30 @@ sugar expect(shoppingList.categories?.Bakery).toEqual([ { name: "flour", - quantityTotal: { - quantity: { - type: "fixed", - value: { type: "decimal", decimal: 150 }, + quantities: [ + { + quantity: { + type: "fixed", + value: { type: "decimal", decimal: 150 }, + }, + unit: "g", }, - unit: "g", - }, + ], }, ]); shoppingList.removeRecipe(0); expect(shoppingList.categories?.Bakery).toEqual([ { name: "flour", - quantityTotal: { - quantity: { - type: "fixed", - value: { type: "decimal", decimal: 50 }, + quantities: [ + { + quantity: { + type: "fixed", + value: { type: "decimal", decimal: 50 }, + }, + unit: "g", }, - unit: "g", - }, + ], }, ]); }); @@ -758,28 +1121,25 @@ sugar const flour = shoppingList.ingredients.find((i) => i.name === "flour"); expect(flour).toBeDefined(); - expect(flour!.quantityTotal).toMatchObject({ - quantity: { - type: "fixed", - value: { type: "decimal", decimal: 100 }, + expect(flour!.quantities).toMatchObject([ + { + quantity: { + type: "fixed", + value: { type: "decimal", decimal: 100 }, + }, + unit: "g", }, - unit: "g", - }); + ]); }); - it("should clamp ingredient quantity to zero when pantry has more", () => { + it("should remove ingredient when pantry fully covers it", () => { const shoppingList = new ShoppingList(); shoppingList.addRecipe(pantryRecipe); shoppingList.addPantry(`[pantry]\nflour = "500%g"`); const flour = shoppingList.ingredients.find((i) => i.name === "flour"); expect(flour).toBeDefined(); - expect(flour!.quantityTotal).toMatchObject({ - quantity: { - type: "fixed", - value: { type: "decimal", decimal: 0 }, - }, - }); + expect(flour!.quantities).toBeUndefined(); }); it("should update resulting pantry after subtraction", () => { @@ -817,13 +1177,15 @@ sugar shoppingList.addPantry(`[pantry]\nflour = "100%g"`); const sugar = shoppingList.ingredients.find((i) => i.name === "sugar"); - expect(sugar!.quantityTotal).toMatchObject({ - quantity: { - type: "fixed", - value: { type: "decimal", decimal: 100 }, + expect(sugar!.quantities).toMatchObject([ + { + quantity: { + type: "fixed", + value: { type: "decimal", decimal: 100 }, + }, + unit: "g", }, - unit: "g", - }); + ]); }); it("should accept a Pantry instance", () => { @@ -833,13 +1195,15 @@ sugar shoppingList.addPantry(pantry); const flour = shoppingList.ingredients.find((i) => i.name === "flour"); - expect(flour!.quantityTotal).toMatchObject({ - quantity: { - type: "fixed", - value: { type: "decimal", decimal: 100 }, + expect(flour!.quantities).toMatchObject([ + { + quantity: { + type: "fixed", + value: { type: "decimal", decimal: 100 }, + }, + unit: "g", }, - unit: "g", - }); + ]); }); it("should throw on invalid pantry argument", () => { @@ -855,13 +1219,15 @@ sugar shoppingList.addRecipe(pantryRecipe); const flour1 = shoppingList.ingredients.find((i) => i.name === "flour"); - expect(flour1!.quantityTotal).toMatchObject({ - quantity: { - type: "fixed", - value: { type: "decimal", decimal: 100 }, + expect(flour1!.quantities).toMatchObject([ + { + quantity: { + type: "fixed", + value: { type: "decimal", decimal: 100 }, + }, + unit: "g", }, - unit: "g", - }); + ]); // Remove the recipe → no ingredients, pantry should be intact shoppingList.removeRecipe(0); @@ -890,13 +1256,15 @@ sugar // Should find "farine" in pantry for "flour" in recipe const flour = shoppingList.ingredients.find((i) => i.name === "flour"); - expect(flour!.quantityTotal).toMatchObject({ - quantity: { - type: "fixed", - value: { type: "decimal", decimal: 100 }, + expect(flour!.quantities).toMatchObject([ + { + quantity: { + type: "fixed", + value: { type: "decimal", decimal: 100 }, + }, + unit: "g", }, - unit: "g", - }); + ]); }); it("should propagate CategoryConfig to pantry when set after addPantry", () => { @@ -908,13 +1276,15 @@ sugar const flourBefore = shoppingList.ingredients.find( (i) => i.name === "flour", ); - expect(flourBefore!.quantityTotal).toMatchObject({ - quantity: { - type: "fixed", - value: { type: "decimal", decimal: 200 }, + expect(flourBefore!.quantities).toMatchObject([ + { + quantity: { + type: "fixed", + value: { type: "decimal", decimal: 200 }, + }, + unit: "g", }, - unit: "g", - }); + ]); // Setting config should NOT auto-recalculate (only categorize runs) // but the config IS propagated to the pantry for future use @@ -928,13 +1298,15 @@ sugar const flourAfter = shoppingList.ingredients.find( (i) => i.name === "flour", ); - expect(flourAfter!.quantityTotal).toMatchObject({ - quantity: { - type: "fixed", - value: { type: "decimal", decimal: 100 }, + expect(flourAfter!.quantities).toMatchObject([ + { + quantity: { + type: "fixed", + value: { type: "decimal", decimal: 100 }, + }, + unit: "g", }, - unit: "g", - }); + ]); }); it("should handle pantry with unit conversion (e.g. kg vs g)", () => { @@ -943,13 +1315,8 @@ sugar shoppingList.addPantry(`[pantry]\nflour = "1%kg"`); // has 1kg = 1000g const flour = shoppingList.ingredients.find((i) => i.name === "flour"); - // 200g - 1kg → clamped to 0 - expect(flour!.quantityTotal).toMatchObject({ - quantity: { - type: "fixed", - value: { type: "decimal", decimal: 0 }, - }, - }); + // 200g - 1kg → fully covered, quantities removed + expect(flour!.quantities).toBeUndefined(); }); it("should skip pantry subtraction for ingredients without quantity", () => { @@ -961,7 +1328,7 @@ sugar // flour has no quantity in recipe, should remain as-is const flour = shoppingList.ingredients.find((i) => i.name === "flour"); expect(flour).toBeDefined(); - expect(flour!.quantityTotal).toBeUndefined(); + expect(flour!.quantities).toBeUndefined(); }); it("should skip pantry subtraction for incompatible units", () => { @@ -972,48 +1339,87 @@ sugar // Should remain unchanged due to incompatible units const flour = shoppingList.ingredients.find((i) => i.name === "flour"); - expect(flour!.quantityTotal).toMatchObject({ - quantity: { - type: "fixed", - value: { type: "decimal", decimal: 200 }, + expect(flour!.quantities).toMatchObject([ + { + quantity: { + type: "fixed", + value: { type: "decimal", decimal: 200 }, + }, + unit: "g", }, - unit: "g", - }); + ]); }); it("should subtract pantry from AND group ingredient (multiple units)", () => { - // Recipe produces eggs as AND group: 1 dozen AND 1 half dozen - const recipe3 = new Recipe(`Add @eggs{1%dozen} and @&eggs{1%half dozen}`); + const recipe3 = new Recipe( + `Add @eggs{24|2%dozen} and @&eggs{6|1%half dozen}`, + ); const shoppingList = new ShoppingList(); shoppingList.addRecipe(recipe3); - - // Pantry has 2 dozen eggs — should subtract from first AND leaf (1 dozen) - // then carry the remaining 1 dozen to the second leaf (1 half dozen). - // 1 dozen = 12, 1 half dozen = 6. Pantry 2 dozen = 24. - // But "dozen" and "half dozen" are different unit strings, - // they may or may not be convertible. Let's check with a simple compatible case. - shoppingList.addPantry(`[pantry]\neggs = "5"`); + // 30 eggs total (unitless) with equivalents: 2.5 dozen, 5 half dozen + // Pantry has 1 dozen = 12 eggs (via equivalence ratio) + // After subtraction: 30 - 12 = 18 eggs, equivalents recomputed + shoppingList.addPantry(`[pantry]\neggs = "1%dozen"`); const eggs = shoppingList.ingredients.find((i) => i.name === "eggs"); expect(eggs).toBeDefined(); - // Unitless pantry (5) vs "dozen" unit — subtraction of unitless from - // unit-bearing quantity: first leaf (1 dozen) gets subtracted → clamped to 0. - // Remaining pantry (4) tries second leaf (1 half dozen) — if incompatible, stays. - expect(eggs!.quantityTotal).toMatchObject({ - and: [ - { - quantity: { - type: "fixed", - value: { type: "decimal", decimal: 0 }, - }, + expect(eggs!.quantities).toEqual([ + { + quantity: { + type: "fixed", + value: { type: "decimal", decimal: 18 }, }, - { - quantity: { - type: "fixed", - value: { type: "decimal", decimal: 1 }, + equivalents: [ + { + quantity: { + type: "fixed", + value: { type: "decimal", decimal: 1.5 }, + }, + unit: "dozen", }, - }, - ], + { + quantity: { + type: "fixed", + value: { type: "decimal", decimal: 3 }, + }, + unit: "half dozen", + }, + ], + }, + ]); + }); + + it("should remove ingredient when pantry fully covers via equivalence ratio", () => { + // Recipe: 24 eggs (= 2 dozen) AND 6 eggs (= 1 half dozen), total 30 eggs + // Pantry has 3 dozen = 36 eggs → exceeds recipe → ingredient removed + const recipe = new Recipe( + `Add @eggs{24|2%dozen} and @&eggs{6|1%half dozen}`, + ); + const shoppingList = new ShoppingList(); + shoppingList.addRecipe(recipe); + shoppingList.addPantry(`[pantry]\neggs = "3%dozen"`); + + const eggs = shoppingList.ingredients.find((i) => i.name === "eggs"); + expect(eggs).toBeDefined(); + expect(eggs!.quantities).toBeUndefined(); + + // Pantry should have remainder: 3 dozen - 30/12 dozen = 3 - 2.5 = 0.5 dozen + let resultingPantry = shoppingList.getPantry(); + let pantryEggs = resultingPantry!.findItem("eggs"); + expect(pantryEggs).toBeDefined(); + expect(pantryEggs!.quantity).toMatchObject({ + type: "fixed", + value: { type: "decimal", decimal: 0.5 }, + }); + + // Should also work the other way around + shoppingList.addPantry(`[pantry]\neggs = "40"`); + resultingPantry = shoppingList.getPantry(); + pantryEggs = resultingPantry!.findItem("eggs"); + expect(pantryEggs).toBeDefined(); + expect(pantryEggs!.quantity).toMatchObject({ + type: "fixed", + value: { type: "decimal", decimal: 10 }, }); }); @@ -1030,13 +1436,15 @@ sugar const flourBefore = shoppingList.ingredients.find( (i) => i.name === "flour", ); - expect(flourBefore!.quantityTotal).toMatchObject({ - quantity: { - type: "fixed", - value: { type: "decimal", decimal: 700 }, + expect(flourBefore!.quantities).toMatchObject([ + { + quantity: { + type: "fixed", + value: { type: "decimal", decimal: 700 }, + }, + unit: "g", }, - unit: "g", - }); + ]); // Now add pantry with 300g flour shoppingList.addPantry(`[pantry]\nflour = "300%g"`); @@ -1044,13 +1452,15 @@ sugar const flourAfter = shoppingList.ingredients.find( (i) => i.name === "flour", ); - expect(flourAfter!.quantityTotal).toMatchObject({ - quantity: { - type: "fixed", - value: { type: "decimal", decimal: 400 }, + expect(flourAfter!.quantities).toMatchObject([ + { + quantity: { + type: "fixed", + value: { type: "decimal", decimal: 400 }, + }, + unit: "g", }, - unit: "g", - }); + ]); }); it("should subtract pantry from AND group — partial across leaves", () => { @@ -1062,71 +1472,49 @@ sugar const shoppingList = new ShoppingList(); shoppingList.addRecipe(recipe); - // Verify AND group structure + // Verify flat array structure (incompatible units stored as separate entries) const pepperBefore = shoppingList.ingredients.find( (i) => i.name === "pepper", ); - expect(pepperBefore!.quantityTotal).toMatchObject({ - and: [ - { - quantity: { - type: "fixed", - value: { type: "text", text: "to taste" }, - }, + expect(pepperBefore!.quantities).toMatchObject([ + { + quantity: { + type: "fixed", + value: { type: "text", text: "to taste" }, }, - { - quantity: { - type: "fixed", - value: { type: "decimal", decimal: 1 }, - }, - unit: "tsp", + }, + { + quantity: { + type: "fixed", + value: { type: "decimal", decimal: 1 }, }, - ], - }); + unit: "tsp", + }, + ]); - // Add pantry with 0.5 tsp pepper — should subtract from the tsp leaf + // Add pantry with 0.5 tsp pepper — should subtract from the tsp entry shoppingList.addPantry(`[pantry]\npepper = "0.5%tsp"`); const pepperAfter = shoppingList.ingredients.find( (i) => i.name === "pepper", ); - // The tsp leaf should be reduced (1 - 0.5 = 0.5) - // The text leaf remains unchanged (incompatible with tsp) - expect(pepperAfter!.quantityTotal).toMatchObject({ - and: [ - { - quantity: { - type: "fixed", - value: { type: "text", text: "to taste" }, - }, + // The tsp entry should be reduced (1 - 0.5 = 0.5) + // The text entry remains unchanged (incompatible with tsp) + expect(pepperAfter!.quantities).toMatchObject([ + { + quantity: { + type: "fixed", + value: { type: "text", text: "to taste" }, }, - { - quantity: { - type: "fixed", - }, - unit: "tsp", + }, + { + quantity: { + type: "fixed", + value: { type: "fraction", num: 1, den: 2 }, }, - ], - }); - }); - - it("should update pantry remainder correctly with group subtraction", () => { - const recipe = new Recipe( - `Season with @pepper{to taste} and @&pepper{1%tsp}.`, - ); - const shoppingList = new ShoppingList(); - shoppingList.addRecipe(recipe); - shoppingList.addPantry(`[pantry]\npepper = "3%tsp"`); - - // Pantry had 3 tsp, recipe needs 1 tsp (text leaf is incompatible) - // → 2 tsp should remain in pantry - const resultingPantry = shoppingList.getPantry(); - const pepper = resultingPantry!.findItem("pepper"); - expect(pepper).toBeDefined(); - expect(pepper!.quantity).toMatchObject({ - type: "fixed", - value: { type: "decimal", decimal: 2 }, - }); + unit: "tsp", + }, + ]); }); }); });