diff --git a/.gitignore b/.gitignore index f645fed..afbf27b 100644 --- a/.gitignore +++ b/.gitignore @@ -3,4 +3,5 @@ dist coverage docs/.vitepress/cache docs/.vitepress/dist -docs/api \ No newline at end of file +docs/api +.nuxt \ No newline at end of file diff --git a/docs/guide-extensions.md b/docs/guide-extensions.md index 5cfad3c..c3328cb 100644 --- a/docs/guide-extensions.md +++ b/docs/guide-extensions.md @@ -96,12 +96,55 @@ When referencing ingredients, the added quantities will be converted to [Decimal ``` Also works with Cookware and Timers - + ## Cookware quantities - Cookware can also be quantified (without any unit, e.g. `#bowls{2}`) - Quantities will be added similarly as ingredients if cookware is referenced, e.g. `#&bowls{2}` +## Alternative units + +You can define equivalent quantities in different units for the same ingredient using the pipe `|` separator within the curly braces. + +Usage: `@flour{100%g|3.5%oz}` + +This is useful for providing multiple units (e.g. metric and imperial) simultaneously for the same ingredient. The first unit is considered the primary unit. + +## Ingredient alternatives + +### Inline alternatives + +You can specify alternative ingredients directly in the ingredient syntax using pipes: `@baseName{quantity}|altName{altQuantity}[note]|...` + +This allows users to select from multiple alternatives when computing recipe quantities. + +Use cases: +- `@milk{200%ml}|almond milk{100%ml}[vegan version]|soy milk{150%ml}[another vegan option]` +- `@sugar{100%g}|brown sugar{100%g}[for a richer flavor]` + +When inline alternatives are defined, the recipe's [`choices`](/api/interfaces/RecipeChoices) property will be populated. You can then use the `calc_ingredient_quantities()` method to compute quantities corresponding to the user's choices. + +All modifiers (`&`, `-`, `?`) work with inline alternatives: +`@&milk{200%ml}|-almond milk{100%ml}[vegan version]|?soy milk{150%ml}[another vegan option]` + +### Grouped alternatives + +Ingredients can also have alternatives by grouping them with the same group key using the syntax: `@|groupKey|ingredientName{}` + +This is useful when you want to provide alternative choices in your recipe text naturally: + +Use cases: +- `Add @|milk|milk{200%ml} or @|milk|almond milk{100%ml} or @|milk|oat milk{150%ml} for a vegan version` +- `Add some @|spices|salt{} or maybe some @|spices|pepper{}` + +When grouped alternatives are defined, the recipe's [`choices`](/api/interfaces/RecipeChoices) property will be populated with available alternatives for each group. You can then use the `calc_ingredient_quantities()` method to compute quantities corresponding to the user's choices. + +All modifiers (`&`, `-`, `?`) work with grouped alternatives: +``` +Add @|flour|&flour tipo 00{100%g} or @|flour|flour tipo 1{50%g} +Add @|spices|-salt{} or @|spices|?pepper{} +``` + ## Ingredient aliases - `@ingredientName|displayAlias{}` will add the ingredient as "ingredientName" in the ingredients list, but will display is as "displayAlias" in the preparation step. diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml index 06ac931..833bb10 100644 --- a/pnpm-workspace.yaml +++ b/pnpm-workspace.yaml @@ -2,4 +2,6 @@ packages: - . onlyBuiltDependencies: + - '@parcel/watcher' - esbuild + - vue-demi diff --git a/src/classes/product_catalog.ts b/src/classes/product_catalog.ts index 2bd1007..28edd02 100644 --- a/src/classes/product_catalog.ts +++ b/src/classes/product_catalog.ts @@ -3,13 +3,14 @@ import type { FixedNumericValue, ProductOption, ProductOptionToml, + ProductSize, } from "../types"; import type { TomlTable } from "smol-toml"; import { isPositiveIntegerString, parseQuantityInput, stringifyQuantityValue, -} from "../parser_helpers"; +} from "../utils/parser_helpers"; import { InvalidProductCatalogFormat } from "../errors"; /** @@ -76,27 +77,32 @@ export class ProductCatalog { const { name, size, price, ...rest } = productData as unknown as ProductOptionToml; - const sizeAndUnitRaw = size.split("%"); - const sizeParsed = parseQuantityInput( - sizeAndUnitRaw[0]!, - ) as FixedNumericValue; + // Handle size as string or string[] + const sizeStrings = Array.isArray(size) ? size : [size]; + const sizes: ProductSize[] = sizeStrings.map((sizeStr) => { + const sizeAndUnitRaw = sizeStr.split("%"); + const sizeParsed = parseQuantityInput( + sizeAndUnitRaw[0]!, + ) as FixedNumericValue; + const productSize: ProductSize = { size: sizeParsed }; + if (sizeAndUnitRaw.length > 1) { + productSize.unit = sizeAndUnitRaw[1]!; + } + return productSize; + }); const productOption: ProductOption = { id: productId, productName: name, ingredientName: ingredientName, price: price, - size: sizeParsed, + sizes, ...rest, }; if (aliases) { productOption.ingredientAliases = aliases; } - if (sizeAndUnitRaw.length > 1) { - productOption.unit = sizeAndUnitRaw[1]!; - } - this.products.push(productOption); } } @@ -116,8 +122,7 @@ export class ProductCatalog { id, ingredientName, ingredientAliases, - size, - unit, + sizes, productName, ...rest } = product; @@ -127,12 +132,19 @@ export class ProductCatalog { if (ingredientAliases && !grouped[ingredientName].aliases) { grouped[ingredientName].aliases = ingredientAliases; } + + // Stringify each size as "value%unit" or just "value" + const sizeStrings = sizes.map((s) => + s.unit + ? `${stringifyQuantityValue(s.size)}%${s.unit}` + : stringifyQuantityValue(s.size), + ); + grouped[ingredientName][id] = { ...rest, name: productName, - size: unit - ? `${stringifyQuantityValue(size)}%${unit}` - : stringifyQuantityValue(size), + // Use array if multiple sizes, otherwise single string + size: sizeStrings.length === 1 ? sizeStrings[0]! : sizeStrings, }; } @@ -184,7 +196,11 @@ export class ProductCatalog { } const hasProductName = typeof record.name === "string"; - const hasSize = typeof record.size === "string"; + // Size can be a string or an array of strings + const hasSize = + typeof record.size === "string" || + (Array.isArray(record.size) && + record.size.every((s) => typeof s === "string")); const hasPrice = typeof record.price === "number"; if (!(hasProductName && hasSize && hasPrice)) { diff --git a/src/classes/recipe.ts b/src/classes/recipe.ts index 2708e4e..c469adf 100644 --- a/src/classes/recipe.ts +++ b/src/classes/recipe.ts @@ -3,6 +3,7 @@ import type { Ingredient, IngredientExtras, IngredientItem, + IngredientItemQuantity, Timer, Step, Note, @@ -11,6 +12,17 @@ import type { CookwareItem, IngredientFlag, CookwareFlag, + RecipeChoices, + RecipeAlternatives, + IngredientAlternative, + FlatOrGroup, + QuantityWithExtendedUnit, + ComputedIngredient, + AlternativeIngredientRef, + QuantityWithPlainUnit, + IngredientQuantityGroup, + IngredientQuantityAndGroup, + IngredientQuantities, } from "../types"; import { Section } from "./section"; import { @@ -18,8 +30,12 @@ import { commentRegex, blockCommentRegex, metadataRegex, + ingredientWithAlternativeRegex, + ingredientWithGroupKeyRegex, ingredientAliasRegex, floatRegex, + quantityAlternativeRegex, + inlineIngredientAlternativesRegex, } from "../regex"; import { flushPendingItems, @@ -28,14 +44,19 @@ import { findAndUpsertCookware, parseQuantityInput, extractMetadata, -} from "../parser_helpers"; + unionOfSets, + getAlternativeSignature, +} from "../utils/parser_helpers"; +import { addEquivalentsAndSimplify } from "../quantities/alternatives"; +import { multiplyQuantityValue } from "../quantities/numeric"; import { - addQuantities, - getDefaultQuantityValue, - multiplyQuantityValue, - type Quantity, -} from "../units"; + toPlainUnit, + toExtendedUnit, + flattenPlainUnitGroup, +} from "../quantities/mutations"; import Big from "big.js"; +import { deepClone } from "../utils/general"; +import { InvalidQuantityFormat } from "../errors"; /** * Recipe parser. @@ -73,6 +94,14 @@ export class Recipe { * The parsed recipe metadata. */ metadata: Metadata = {}; + /** + * The default or manual choice of alternative ingredients. + * Contains the full context including alternatives list and active selection index. + */ + choices: RecipeAlternatives = { + ingredientItems: new Map(), + ingredientGroups: new Map(), + }; /** * The parsed recipe ingredients. */ @@ -98,21 +127,818 @@ export class Recipe { */ servings?: number; + /** + * External storage for item count (not a property on instances). + * Used for giving ID numbers to items during parsing. + */ + private static itemCounts = new WeakMap(); + + /** + * Gets the current item count for this recipe. + */ + private getItemCount(): number { + return Recipe.itemCounts.get(this)!; + } + + /** + * Gets the current item count and increments it. + */ + private getAndIncrementItemCount(): number { + const current = this.getItemCount(); + Recipe.itemCounts.set(this, current + 1); + return current; + } + /** * Creates a new Recipe instance. * @param content - The recipe content to parse. */ constructor(content?: string) { + Recipe.itemCounts.set(this, 0); if (content) { this.parse(content); } } + private _parseQuantityRecursive( + quantityRaw: string, + ): QuantityWithExtendedUnit[] { + let quantityMatch = quantityRaw.match(quantityAlternativeRegex); + const quantities: QuantityWithExtendedUnit[] = []; + while (quantityMatch?.groups) { + const value = quantityMatch.groups.ingredientQuantityValue + ? parseQuantityInput(quantityMatch.groups.ingredientQuantityValue) + : undefined; + const unit = quantityMatch.groups.ingredientUnit; + if (value) { + const newQuantity: QuantityWithExtendedUnit = { quantity: value }; + if (unit) { + if (unit.startsWith("=")) { + newQuantity.unit = { + name: unit.substring(1), + integerProtected: true, + }; + } else { + newQuantity.unit = { name: unit }; + } + } + quantities.push(newQuantity); + } else { + throw new InvalidQuantityFormat(quantityRaw); + } + quantityMatch = quantityMatch.groups.ingredientAltQuantity + ? quantityMatch.groups.ingredientAltQuantity.match( + quantityAlternativeRegex, + ) + : null; + } + return quantities; + } + + private _parseIngredientWithAlternativeRecursive( + ingredientMatchString: string, + items: Step["items"], + ): void { + const alternatives: IngredientAlternative[] = []; + let testString = ingredientMatchString; + while (true) { + const match = testString.match( + alternatives.length > 0 + ? inlineIngredientAlternativesRegex + : ingredientWithAlternativeRegex, + ); + if (!match?.groups) break; + const groups = match.groups; + + // Use variables for readability + // @{quantity%unit|altQuantities}(preparation)[note]| + let name = (groups.mIngredientName || groups.sIngredientName)!; + + // 1. We build up the different parts of the Ingredient object + // Preparation + const preparation = groups.ingredientPreparation; + // Flags + const modifiers = groups.ingredientModifiers; + const reference = modifiers !== undefined && modifiers.includes("&"); + const flags: IngredientFlag[] = []; + if (modifiers !== undefined && modifiers.includes("?")) { + flags.push("optional"); + } + if (modifiers !== undefined && modifiers.includes("-")) { + flags.push("hidden"); + } + if ( + (modifiers !== undefined && modifiers.includes("@")) || + groups.ingredientRecipeAnchor + ) { + flags.push("recipe"); + } + // Extras + let extras: IngredientExtras | undefined = undefined; + // -- if the ingredient is a recipe, we need to extract the name from the path given + if (flags.includes("recipe")) { + extras = { path: `${name}.cook` }; + name = name.substring(name.lastIndexOf("/") + 1); + } + // Distinguish name from display name / name alias + const aliasMatch = name.match(ingredientAliasRegex); + let listName, displayName: string; + if ( + aliasMatch && + aliasMatch.groups!.ingredientListName!.trim().length > 0 && + aliasMatch.groups!.ingredientDisplayName!.trim().length > 0 + ) { + listName = aliasMatch.groups!.ingredientListName!.trim(); + displayName = aliasMatch.groups!.ingredientDisplayName!.trim(); + } else { + listName = name; + displayName = name; + } + + const newIngredient: Ingredient = { + name: listName, + }; + // Only add parameters if they are non null / non empty + if (preparation) { + newIngredient.preparation = preparation; + } + if (flags.length > 0) { + newIngredient.flags = flags; + } + if (extras) { + newIngredient.extras = extras; + } + + const idxInList = findAndUpsertIngredient( + this.ingredients, + newIngredient, + reference, + ); + + // 2. We build up the ingredient item + // -- alternative quantities + let itemQuantity: IngredientItemQuantity | undefined = undefined; + if (groups.ingredientQuantity) { + const parsedQuantities = this._parseQuantityRecursive( + groups.ingredientQuantity, + ); + const [primary, ...rest] = parsedQuantities; + if (primary) { + itemQuantity = { + ...primary, + scalable: groups.ingredientQuantityModifier !== "=", + }; + if (rest.length > 0) { + itemQuantity.equivalents = rest; + } + } + } + + const alternative: IngredientAlternative = { + index: idxInList, + displayName, + }; + // Only add itemQuantity and note if they exist + const note = groups.ingredientNote?.trim(); + if (note) { + alternative.note = note; + } + if (itemQuantity) { + alternative.itemQuantity = itemQuantity; + } + alternatives.push(alternative); + testString = groups.ingredientAlternative || ""; + } + + // Update alternatives list of all processed ingredients + if (alternatives.length > 1) { + const alternativesIndexes = alternatives.map((alt) => alt.index); + for (const ingredientIndex of alternativesIndexes) { + const ingredient = this.ingredients[ingredientIndex]; + // In practice, the ingredient will always be found + /* v8 ignore else -- @preserve */ + if (ingredient) { + if (!ingredient.alternatives) { + ingredient.alternatives = new Set( + alternativesIndexes.filter((index) => index !== ingredientIndex), + ); + } else { + ingredient.alternatives = unionOfSets( + ingredient.alternatives, + new Set( + alternativesIndexes.filter( + (index) => index !== ingredientIndex, + ), + ), + ); + } + } + } + } + + const id = `ingredient-item-${this.getAndIncrementItemCount()}`; + + // Finalize item + const newItem: IngredientItem = { + type: "ingredient", + id, + alternatives, + }; + items.push(newItem); + + if (alternatives.length > 1) { + this.choices.ingredientItems.set(id, alternatives); + } + } + + private _parseIngredientWithGroupKey( + ingredientMatchString: string, + items: Step["items"], + ): void { + const match = ingredientMatchString.match(ingredientWithGroupKeyRegex); + // This is a type guard to ensure match and match.groups are defined + /* v8 ignore if -- @preserve */ + if (!match?.groups) return; + const groups = match.groups; + + // Use variables for readability + // @|{quantity%unit|altQuantities}(preparation)[note] + const groupKey = groups.gIngredientGroupKey!; + let name = (groups.gmIngredientName || groups.gsIngredientName)!; + + // 1. We build up the different parts of the Ingredient object + // Preparation + const preparation = groups.gIngredientPreparation; + // Flags + const modifiers = groups.gIngredientModifiers; + const reference = modifiers !== undefined && modifiers.includes("&"); + const flags: IngredientFlag[] = []; + if (modifiers !== undefined && modifiers.includes("?")) { + flags.push("optional"); + } + if (modifiers !== undefined && modifiers.includes("-")) { + flags.push("hidden"); + } + if ( + (modifiers !== undefined && modifiers.includes("@")) || + groups.gIngredientRecipeAnchor + ) { + flags.push("recipe"); + } + // Extras + let extras: IngredientExtras | undefined = undefined; + // -- if the ingredient is a recipe, we need to extract the name from the path given + if (flags.includes("recipe")) { + extras = { path: `${name}.cook` }; + name = name.substring(name.lastIndexOf("/") + 1); + } + // Distinguish name from display name / name alias + const aliasMatch = name.match(ingredientAliasRegex); + let listName, displayName: string; + if ( + aliasMatch && + aliasMatch.groups!.ingredientListName!.trim().length > 0 && + aliasMatch.groups!.ingredientDisplayName!.trim().length > 0 + ) { + listName = aliasMatch.groups!.ingredientListName!.trim(); + displayName = aliasMatch.groups!.ingredientDisplayName!.trim(); + } else { + listName = name; + displayName = name; + } + + const newIngredient: Ingredient = { + name: listName, + }; + // Only add parameters if they are non null / non empty + if (preparation) { + newIngredient.preparation = preparation; + } + if (flags.length > 0) { + newIngredient.flags = flags; + } + if (extras) { + newIngredient.extras = extras; + } + + const idxInList = findAndUpsertIngredient( + this.ingredients, + newIngredient, + reference, + ); + + // 2. We build up the ingredient item + // -- alternative quantities + let itemQuantity: IngredientItemQuantity | undefined = undefined; + if (groups.gIngredientQuantity) { + const parsedQuantities = this._parseQuantityRecursive( + groups.gIngredientQuantity, + ); + const [primary, ...rest] = parsedQuantities; + itemQuantity = { + ...primary!, // there's necessarily a primary quantity as the match group was detected + scalable: groups.gIngredientQuantityModifier !== "=", + }; + if (rest.length > 0) { + itemQuantity.equivalents = rest; + } + } + + const alternative: IngredientAlternative = { + index: idxInList, + displayName, + }; + // Only add itemQuantity if it exists + if (itemQuantity) { + alternative.itemQuantity = itemQuantity; + } + + const existingAlternatives = this.choices.ingredientGroups.get(groupKey); + // For all alternative ingredients already processed for this group, add the new ingredient as alternative + function upsertAlternativeToIngredient( + ingredients: Ingredient[], + ingredientIdx: number, + newAlternativeIdx: number, + ) { + const ingredient = ingredients[ingredientIdx]; + // In practice, the ingredient will always be found + /* v8 ignore else -- @preserve */ + if (ingredient) { + if (ingredient.alternatives === undefined) { + ingredient.alternatives = new Set([newAlternativeIdx]); + } else { + ingredient.alternatives.add(newAlternativeIdx); + } + } + } + if (existingAlternatives) { + for (const alt of existingAlternatives) { + upsertAlternativeToIngredient(this.ingredients, alt.index, idxInList); + upsertAlternativeToIngredient(this.ingredients, idxInList, alt.index); + } + } + const id = `ingredient-item-${this.getAndIncrementItemCount()}`; + + // Finalize item + const newItem: IngredientItem = { + type: "ingredient", + id, + group: groupKey, + alternatives: [alternative], + }; + items.push(newItem); + + // Populate or update choices + const choiceAlternative = deepClone(alternative); + choiceAlternative.itemId = id; + const existingChoice = this.choices.ingredientGroups.get(groupKey); + if (!existingChoice) { + this.choices.ingredientGroups.set(groupKey, [choiceAlternative]); + } else { + existingChoice.push(choiceAlternative); + } + } + + /** + * Populates the `quantities` property for each ingredient based on + * how they appear in the recipe preparation. Only primary ingredients + * get quantities populated. Primary ingredients get `usedAsPrimary: true` flag. + * + * For inline alternatives (e.g. `\@a|b|c`), the first alternative is primary. + * For grouped alternatives (e.g. `\@|group|a`, `\@|group|b`), the first item in the group is primary. + * + * Quantities are grouped by their alternative signature and summed using addEquivalentsAndSimplify. + * @internal + */ + private _populate_ingredient_quantities(): void { + // Reset quantities and usedAsPrimary flag + this.ingredients = this.ingredients.map((ing) => { + if (ing.quantities) { + delete ing.quantities; + } + if (ing.usedAsPrimary) { + delete ing.usedAsPrimary; + } + return ing; + }); + + // Track which groups we've already seen (to identify first item in each group) + const seenGroups = new Set(); + + // Type for accumulated alternative quantities (using extended units for summing) + type AlternativeQuantitiesMap = Map< + number, + (QuantityWithExtendedUnit | FlatOrGroup)[] + >; + + // Nested map: ingredientIndex -> alternativeSignature -> { alternativeQuantities, quantities } + // This groups quantities by their alternative signature for proper summing + const ingredientGroups = new Map< + number, + Map< + string | null, + { + alternativeQuantities: AlternativeQuantitiesMap; + quantities: ( + | QuantityWithExtendedUnit + | FlatOrGroup + )[]; + } + > + >(); + + // Loop through all ingredient items in all sections + for (const section of this.sections) { + for (const step of section.content.filter( + (item) => item.type === "step", + )) { + for (const item of step.items.filter( + (item) => item.type === "ingredient", + )) { + // For grouped alternatives, only the first item in the group is primary + const isGroupedItem = "group" in item && item.group !== undefined; + const isFirstInGroup = isGroupedItem && !seenGroups.has(item.group!); + if (isGroupedItem) { + seenGroups.add(item.group!); + } + + // Determine if this item's first alternative is a primary ingredient + // - For non-grouped items: always primary (index 0) + // - For grouped items: only primary if first in the group + const isPrimary = !isGroupedItem || isFirstInGroup; + + // Only process the first alternative (primary ingredient) for quantities + const alternative = item.alternatives[0] as IngredientAlternative; + + // Mark this ingredient as used as primary if applicable + if (isPrimary) { + const primaryIngredient = this.ingredients[alternative.index]; + /* v8 ignore else -- @preserve */ + if (primaryIngredient) { + primaryIngredient.usedAsPrimary = true; + } + } + + // Only populate quantities for primary ingredients + if (!isPrimary || !alternative.itemQuantity) continue; + + // Build the primary quantity with equivalents as an OrGroup (like calc_ingredient_quantities) + const allQuantities: QuantityWithExtendedUnit[] = [ + { + quantity: alternative.itemQuantity.quantity, + unit: alternative.itemQuantity.unit, + }, + ]; + if (alternative.itemQuantity.equivalents) { + allQuantities.push(...alternative.itemQuantity.equivalents); + } + + const quantityEntry: + | QuantityWithExtendedUnit + | FlatOrGroup = + allQuantities.length === 1 + ? allQuantities[0]! + : { type: "or", entries: allQuantities }; + + // Check if this ingredient item has alternatives (inline or grouped) + const hasInlineAlternatives = item.alternatives.length > 1; + const hasGroupedAlternatives = + isGroupedItem && this.choices.ingredientGroups.has(item.group!); + + let alternativeRefs: AlternativeIngredientRef[] | undefined; + + if (hasInlineAlternatives) { + // Build alternative refs for inline alternatives (e.g. @milk|almond milk|soy milk) + alternativeRefs = []; + for (let j = 1; j < item.alternatives.length; j++) { + const otherAlt = item.alternatives[j] as IngredientAlternative; + const newRef: AlternativeIngredientRef = { + index: otherAlt.index, + }; + if (otherAlt.itemQuantity) { + // Build the alternativeQuantities with plain units + const altQty: QuantityWithPlainUnit = { + quantity: otherAlt.itemQuantity.quantity, + }; + /* v8 ignore else -- @preserve */ + if (otherAlt.itemQuantity.unit) { + altQty.unit = otherAlt.itemQuantity.unit.name; + } + if (otherAlt.itemQuantity.equivalents) { + altQty.equivalents = otherAlt.itemQuantity.equivalents.map( + (eq) => toPlainUnit(eq) as QuantityWithPlainUnit, + ); + } + newRef.alternativeQuantities = [altQty]; + } + alternativeRefs.push(newRef); + } + } else if (hasGroupedAlternatives) { + // Build alternative refs for grouped alternatives (e.g. @|group|milk, @|group|almond milk) + const groupAlternatives = this.choices.ingredientGroups.get( + item.group!, + )!; + // Skip the first one (that's the primary, which is this ingredient) + alternativeRefs = []; + for (let j = 1; j < groupAlternatives.length; j++) { + const otherAlt = groupAlternatives[j] as IngredientAlternative; + /* v8 ignore else -- @preserve */ + if (otherAlt.itemQuantity) { + // Build the alternativeQuantities with plain units + const altQty: QuantityWithPlainUnit = { + quantity: otherAlt.itemQuantity.quantity, + }; + if (otherAlt.itemQuantity.unit) { + altQty.unit = otherAlt.itemQuantity.unit.name; + } + if (otherAlt.itemQuantity.equivalents) { + altQty.equivalents = otherAlt.itemQuantity.equivalents.map( + (eq) => toPlainUnit(eq) as QuantityWithPlainUnit, + ); + } + alternativeRefs.push({ + index: otherAlt.index, + alternativeQuantities: [altQty], + }); + } + } + if (alternativeRefs.length === 0) { + alternativeRefs = undefined; + } + } + + // Get or create the map for this ingredient + if (!ingredientGroups.has(alternative.index)) { + ingredientGroups.set(alternative.index, new Map()); + } + const groupsForIngredient = ingredientGroups.get(alternative.index)!; + + // Get the alternative signature for grouping + // Include the group name to keep quantities from different choice groups separate + const baseSignature = getAlternativeSignature(alternativeRefs); + const signature = isGroupedItem + ? `group:${item.group}|${baseSignature ?? ""}` + : baseSignature; + + // Get or create the group for this signature + if (!groupsForIngredient.has(signature)) { + groupsForIngredient.set(signature, { + alternativeQuantities: new Map< + number, + ( + | QuantityWithExtendedUnit + | FlatOrGroup + )[] + >(), + quantities: [], + }); + } + const group = groupsForIngredient.get(signature)!; + + // Add the quantity to the group + group.quantities.push(quantityEntry); + + // Also accumulate alternative quantities for summing + if (alternativeRefs) { + for (const ref of alternativeRefs) { + // Always track the alternative index, even without quantity + if (!group.alternativeQuantities.has(ref.index)) { + group.alternativeQuantities.set(ref.index, []); + } + + if ( + ref.alternativeQuantities && + ref.alternativeQuantities.length > 0 + ) { + for (const altQty of ref.alternativeQuantities) { + if (altQty.equivalents && altQty.equivalents.length > 0) { + const entries: QuantityWithExtendedUnit[] = [ + toExtendedUnit({ + quantity: altQty.quantity, + unit: altQty.unit, + }), + ...altQty.equivalents.map((eq) => toExtendedUnit(eq)), + ]; + group.alternativeQuantities + .get(ref.index)! + .push({ type: "or", entries }); + } else { + group.alternativeQuantities.get(ref.index)!.push( + toExtendedUnit({ + quantity: altQty.quantity, + unit: altQty.unit, + }), + ); + } + } + } + } + } + } + } + } + + // Process each ingredient's groups and assign summed quantities + for (const [index, groupsForIngredient] of ingredientGroups) { + const ingredient = this.ingredients[index]!; + + const quantityGroups: IngredientQuantities = []; + + for (const [, group] of groupsForIngredient) { + // Use addEquivalentsAndSimplify to sum all quantities in this group + const summedGroupQuantity = addEquivalentsAndSimplify( + ...group.quantities, + ); + // Convert to proper format (IngredientQuantityGroup or IngredientQuantityAndGroup) + const groupQuantities = flattenPlainUnitGroup(summedGroupQuantity); + + // Process alternatives - they need to be converted similarly + let alternatives: AlternativeIngredientRef[] | undefined; + if (group.alternativeQuantities.size > 0) { + alternatives = []; + for (const [altIndex, altQuantities] of group.alternativeQuantities) { + const ref: AlternativeIngredientRef = { index: altIndex }; + if (altQuantities.length > 0) { + // Sum the alternative quantities using addEquivalentsAndSimplify + const summedAltQuantity = addEquivalentsAndSimplify( + ...altQuantities, + ); + // Convert to array of QuantityWithPlainUnit + const flattenedAlt = flattenPlainUnitGroup(summedAltQuantity); + // Extract quantities from the flattened result + ref.alternativeQuantities = flattenedAlt.flatMap((item) => { + if ("groupQuantity" in item) { + return [item.groupQuantity]; + } else { + // AND group: return entries (could also include equivalents if needed) + return item.entries; + } + }); + } + alternatives.push(ref); + } + } + + // Add quantity groups with alternatives + for (const gq of groupQuantities) { + if ("type" in gq && gq.type === "and") { + // AND group + const andGroup: IngredientQuantityAndGroup = { + type: "and", + entries: gq.entries, + }; + if (gq.equivalents && gq.equivalents.length > 0) { + andGroup.equivalents = gq.equivalents; + } + if (alternatives && alternatives.length > 0) { + andGroup.alternatives = alternatives; + } + quantityGroups.push(andGroup); + } else { + // Simple group + const quantityGroup: IngredientQuantityGroup = + gq as IngredientQuantityGroup; + if (alternatives && alternatives.length > 0) { + quantityGroup.alternatives = alternatives; + } + quantityGroups.push(quantityGroup); + } + } + } + + /* v8 ignore else -- @preserve */ + if (quantityGroups.length > 0) { + ingredient.quantities = quantityGroups; + } + } + } + + /** + * Calculates ingredient quantities based on the provided choices. + * Returns a list of computed ingredients with their total quantities. + * + * @param choices - The recipe choices to apply when computing quantities. + * If not provided, uses the default choices (first alternative for each item). + * @returns An array of ComputedIngredient with quantityTotal calculated based on choices. + */ + calc_ingredient_quantities(choices?: RecipeChoices): ComputedIngredient[] { + // Use provided choices or derive default choices (index 0 for all) + const effectiveChoices: RecipeChoices = choices || { + ingredientItems: new Map( + Array.from(this.choices.ingredientItems.keys()).map((k) => [k, 0]), + ), + ingredientGroups: new Map( + Array.from(this.choices.ingredientGroups.keys()).map((k) => [k, 0]), + ), + }; + + const ingredientQuantities = new Map< + number, + (QuantityWithExtendedUnit | FlatOrGroup)[] + >(); + + // Track which ingredient indices are selected (either directly or as part of alternatives) + const selectedIngredientIndices = new Set(); + + // Looping ingredient items + for (const section of this.sections) { + for (const step of section.content.filter( + (item) => item.type === "step", + )) { + for (const item of step.items.filter( + (item) => item.type === "ingredient", + )) { + for (let i = 0; i < item.alternatives.length; i++) { + const alternative = item.alternatives[i] as IngredientAlternative; + // Is the ingredient selected (potentially by default) + const isAlternativeChoiceItem = + effectiveChoices.ingredientItems?.get(item.id) === i; + const alternativeChoiceGroupIdx = item.group + ? effectiveChoices.ingredientGroups?.get(item.group) + : undefined; + const alternativeChoiceGroup = item.group + ? this.choices.ingredientGroups.get(item.group) + : undefined; + const isAlternativeChoiceGroup = + alternativeChoiceGroup && alternativeChoiceGroupIdx !== undefined + ? alternativeChoiceGroup[alternativeChoiceGroupIdx]?.itemId === + item.id + : false; + + // Determine if this ingredient is selected + const isSelected = + (!("group" in item) && + (item.alternatives.length === 1 || isAlternativeChoiceItem)) || + isAlternativeChoiceGroup; + + if (isSelected) { + selectedIngredientIndices.add(alternative.index); + + if (alternative.itemQuantity) { + // Build equivalents: primary quantity + any additional equivalents + const allQuantities: QuantityWithExtendedUnit[] = [ + { + quantity: alternative.itemQuantity.quantity, + unit: alternative.itemQuantity.unit, + }, + ]; + if (alternative.itemQuantity.equivalents) { + allQuantities.push(...alternative.itemQuantity.equivalents); + } + const equivalents: + | QuantityWithExtendedUnit + | FlatOrGroup = + allQuantities.length === 1 + ? allQuantities[0]! + : { + type: "or", + entries: allQuantities, + }; + ingredientQuantities.set(alternative.index, [ + ...(ingredientQuantities.get(alternative.index) || []), + equivalents, + ]); + } + } + } + } + } + } + + // Build computed ingredients - only include selected ingredients + const computedIngredients: ComputedIngredient[] = []; + for (let index = 0; index < this.ingredients.length; index++) { + if (!selectedIngredientIndices.has(index)) continue; + + const ing = this.ingredients[index]!; + const computed: ComputedIngredient = { + name: ing.name, + }; + if (ing.preparation) { + computed.preparation = ing.preparation; + } + if (ing.flags) { + computed.flags = ing.flags; + } + if (ing.extras) { + computed.extras = ing.extras; + } + const quantities = ingredientQuantities.get(index); + if (quantities && quantities.length > 0) { + computed.quantityTotal = addEquivalentsAndSimplify(...quantities); + } + computedIngredients.push(computed); + } + + return computedIngredients; + } + /** * Parses a recipe from a string. * @param content - The recipe content to parse. */ parse(content: string) { + // Remove noise const cleanContent = content .replace(metadataRegex, "") .replace(commentRegex, "") @@ -120,17 +946,21 @@ export class Recipe { .trim() .split(/\r\n?|\n/); + // Metadata const { metadata, servings }: MetadataExtract = extractMetadata(content); this.metadata = metadata; this.servings = servings; + // Initializing utility variables and property bearers let blankLineBefore = true; let section: Section = new Section(); const items: Step["items"] = []; let note: Note["note"] = ""; let inNote = false; + // We parse content line by line for (const line of cleanContent) { + // A blank line triggers flushing pending stuff if (line.trim().length === 0) { flushPendingItems(section, items); note = flushPendingNote(section, note); @@ -139,6 +969,7 @@ export class Recipe { continue; } + // New section if (line.startsWith("=")) { flushPendingItems(section, items); note = flushPendingNote(section, note); @@ -157,6 +988,7 @@ export class Recipe { continue; } + // New note if (blankLineBefore && line.startsWith(">")) { flushPendingItems(section, items); note = flushPendingNote(section, note); @@ -166,6 +998,7 @@ export class Recipe { continue; } + // Continue note if (inNote) { if (line.startsWith(">")) { note += " " + line.substring(1).trim(); @@ -175,9 +1008,9 @@ export class Recipe { blankLineBefore = false; continue; } - note = flushPendingNote(section, note); + // Detecting items let cursor = 0; for (const match of line.matchAll(tokensRegex)) { const idx = match.index; @@ -188,127 +1021,56 @@ export class Recipe { const groups = match.groups!; + // Ingredient items with potential in-line alternatives if (groups.mIngredientName || groups.sIngredientName) { - let name = (groups.mIngredientName || groups.sIngredientName)!; - const scalableQuantity = - (groups.mIngredientQuantityModifier || - groups.sIngredientQuantityModifier) !== "="; - const quantityRaw = - groups.mIngredientQuantity || groups.sIngredientQuantity; - const unit = groups.mIngredientUnit || groups.sIngredientUnit; - const preparation = - groups.mIngredientPreparation || groups.sIngredientPreparation; - const modifiers = - groups.mIngredientModifiers || groups.sIngredientModifiers; + this._parseIngredientWithAlternativeRecursive(match[0], items); + } + // Ingredient items part of a group of alternative ingredients + else if (groups.gmIngredientName || groups.gsIngredientName) { + this._parseIngredientWithGroupKey(match[0], items); + } + // Cookware items + else if (groups.mCookwareName || groups.sCookwareName) { + const name = (groups.mCookwareName || groups.sCookwareName)!; + const modifiers = groups.cookwareModifiers; + const quantityRaw = groups.cookwareQuantity; const reference = modifiers !== undefined && modifiers.includes("&"); - const flags: IngredientFlag[] = []; + const flags: CookwareFlag[] = []; if (modifiers !== undefined && modifiers.includes("?")) { flags.push("optional"); } if (modifiers !== undefined && modifiers.includes("-")) { flags.push("hidden"); } - if ( - (modifiers !== undefined && modifiers.includes("@")) || - groups.mIngredientRecipeAnchor || - groups.sIngredientRecipeAnchor - ) { - flags.push("recipe"); - } - - let extras: IngredientExtras | undefined = undefined; - // If the ingredient is a recipe, we need to extract the name from the path given - if (flags.includes("recipe")) { - extras = { path: `${name}.cook` }; - name = name.substring(name.lastIndexOf("/") + 1); - } - const quantity = quantityRaw ? parseQuantityInput(quantityRaw) : undefined; - const aliasMatch = name.match(ingredientAliasRegex); - let listName, displayName: string; - if ( - aliasMatch && - aliasMatch.groups!.ingredientListName!.trim().length > 0 && - aliasMatch.groups!.ingredientDisplayName!.trim().length > 0 - ) { - listName = aliasMatch.groups!.ingredientListName!.trim(); - displayName = aliasMatch.groups!.ingredientDisplayName!.trim(); - } else { - listName = name; - displayName = name; - } - - const newIngredient: Ingredient = { - name: listName, - quantity, - quantityParts: quantity - ? [ - { - value: quantity, - unit, - scalable: scalableQuantity, - }, - ] - : undefined, - unit, - preparation, - flags, + const newCookware: Cookware = { + name, }; - - if (extras) { - newIngredient.extras = extras; + if (quantity) { + newCookware.quantity = quantity; + } + if (flags.length > 0) { + newCookware.flags = flags; } - const idxsInList = findAndUpsertIngredient( - this.ingredients, - newIngredient, + // Add cookware in cookware list + const idxInList = findAndUpsertCookware( + this.cookware, + newCookware, reference, ); - const newItem: IngredientItem = { - type: "ingredient", - index: idxsInList.ingredientIndex, - displayName, + // Adding the item itself in the preparation + const newItem: CookwareItem = { + type: "cookware", + index: idxInList, }; - if (idxsInList.quantityPartIndex !== undefined) { - newItem.quantityPartIndex = idxsInList.quantityPartIndex; + if (quantity) { + newItem.quantity = quantity; } items.push(newItem); - } else if (groups.mCookwareName || groups.sCookwareName) { - const name = (groups.mCookwareName || groups.sCookwareName)!; - const modifiers = - groups.mCookwareModifiers || groups.sCookwareModifiers; - const quantityRaw = - groups.mCookwareQuantity || groups.sCookwareQuantity; - const reference = modifiers !== undefined && modifiers.includes("&"); - const flags: CookwareFlag[] = []; - if (modifiers !== undefined && modifiers.includes("?")) { - flags.push("optional"); - } - if (modifiers !== undefined && modifiers.includes("-")) { - flags.push("hidden"); - } - const quantity = quantityRaw - ? parseQuantityInput(quantityRaw) - : undefined; - - const idxsInList = findAndUpsertCookware( - this.cookware, - { - name, - quantity, - quantityParts: quantity ? [quantity] : undefined, - flags, - }, - reference, - ); - items.push({ - type: "cookware", - index: idxsInList.cookwareIndex, - quantityPartIndex: idxsInList.quantityPartIndex, - } as CookwareItem); } // Then it's necessarily a timer which was matched else { @@ -343,6 +1105,8 @@ export class Recipe { if (!section.isBlank()) { this.sections.push(section); } + + this._populate_ingredient_quantities(); } /** @@ -379,44 +1143,73 @@ export class Recipe { throw new Error("Error scaling recipe: no initial servings value set"); } - newRecipe.ingredients = newRecipe.ingredients - .map((ingredient) => { - // Scale first individual parts of total quantity depending on whether they are scalable or not - if (ingredient.quantityParts) { - ingredient.quantityParts = ingredient.quantityParts.map( - (quantityPart) => { - if ( - quantityPart.value.type === "fixed" && - quantityPart.value.value.type === "text" - ) { - return quantityPart; - } - return { - ...quantityPart, - value: multiplyQuantityValue( - quantityPart.value, - quantityPart.scalable ? Big(factor) : 1, - ), - }; - }, - ); - // Recalculate total quantity from quantity parts - if (ingredient.quantityParts.length === 1) { - ingredient.quantity = ingredient.quantityParts[0]!.value; - ingredient.unit = ingredient.quantityParts[0]!.unit; - } else { - const totalQuantity = ingredient.quantityParts.reduce( - (acc, val) => - addQuantities(acc, { value: val.value, unit: val.unit }), - { value: getDefaultQuantityValue() } as Quantity, + function scaleAlternativesBy( + alternatives: IngredientAlternative[], + factor: number | Big, + ) { + for (const alternative of alternatives) { + if (alternative.itemQuantity) { + const scaleFactor = alternative.itemQuantity.scalable + ? Big(factor) + : 1; + // Scale the primary quantity + if ( + alternative.itemQuantity.quantity.type !== "fixed" || + alternative.itemQuantity.quantity.value.type !== "text" + ) { + alternative.itemQuantity.quantity = multiplyQuantityValue( + alternative.itemQuantity.quantity, + scaleFactor, ); - ingredient.quantity = totalQuantity.value; - ingredient.unit = totalQuantity.unit; + } + // Scale equivalents if any + if (alternative.itemQuantity.equivalents) { + alternative.itemQuantity.equivalents = + alternative.itemQuantity.equivalents.map( + (altQuantity: QuantityWithExtendedUnit) => { + if ( + altQuantity.quantity.type === "fixed" && + altQuantity.quantity.value.type === "text" + ) { + return altQuantity; + } else { + return { + ...altQuantity, + quantity: multiplyQuantityValue( + altQuantity.quantity, + scaleFactor, + ), + }; + } + }, + ); } } - return ingredient; - }) - .filter((ingredient) => ingredient.quantity !== null); + } + } + + // Scale IngredientItems + for (const section of newRecipe.sections) { + for (const step of section.content.filter( + (item) => item.type === "step", + )) { + for (const item of step.items.filter( + (item) => item.type === "ingredient", + )) { + scaleAlternativesBy(item.alternatives, factor); + } + } + } + + // Scale Choices + for (const alternatives of newRecipe.choices.ingredientGroups.values()) { + scaleAlternativesBy(alternatives, factor); + } + for (const alternatives of newRecipe.choices.ingredientItems.values()) { + scaleAlternativesBy(alternatives, factor); + } + + newRecipe._populate_ingredient_quantities(); newRecipe.servings = Big(originalServings).times(factor).toNumber(); @@ -483,16 +1276,18 @@ export class Recipe { */ clone(): Recipe { const newRecipe = new Recipe(); + newRecipe.choices = deepClone(this.choices); + Recipe.itemCounts.set(newRecipe, this.getItemCount()); // deep copy - newRecipe.metadata = JSON.parse(JSON.stringify(this.metadata)) as Metadata; - newRecipe.ingredients = JSON.parse( - JSON.stringify(this.ingredients), - ) as Ingredient[]; - newRecipe.sections = JSON.parse(JSON.stringify(this.sections)) as Section[]; - newRecipe.cookware = JSON.parse( - JSON.stringify(this.cookware), - ) as Cookware[]; - newRecipe.timers = JSON.parse(JSON.stringify(this.timers)) as Timer[]; + newRecipe.metadata = deepClone(this.metadata); + newRecipe.ingredients = deepClone(this.ingredients); + newRecipe.sections = this.sections.map((section) => { + const newSection = new Section(section.name); + newSection.content = deepClone(section.content); + return newSection; + }); + newRecipe.cookware = deepClone(this.cookware); + newRecipe.timers = deepClone(this.timers); newRecipe.servings = this.servings; return newRecipe; } diff --git a/src/classes/shopping_cart.ts b/src/classes/shopping_cart.ts index 66685f6..1e019f8 100644 --- a/src/classes/shopping_cart.ts +++ b/src/classes/shopping_cart.ts @@ -1,12 +1,18 @@ import type { ProductOption, ProductSelection, - Ingredient, + AddedIngredient, CartContent, CartMatch, CartMisMatch, FixedNumericValue, Range, + ProductOptionNormalized, + ProductSizeNormalized, + NoProductMatchErrorCode, + FlatOrGroup, + MaybeNestedGroup, + QuantityWithUnitDef, } from "../types"; import { ProductCatalog } from "./product_catalog"; import { ShoppingList } from "./shopping_list"; @@ -15,11 +21,11 @@ import { NoShoppingListForCartError, NoProductMatchError, } from "../errors"; -import { - multiplyQuantityValue, - normalizeUnit, - getNumericValue, -} from "../units"; +import { resolveUnit } from "../units/definitions"; +import { normalizeAllUnits } from "../quantities/mutations"; +import { getNumericValue, multiplyQuantityValue } from "../quantities/numeric"; +import { isAndGroup, isOrGroup } from "../utils/type_guards"; +import { areUnitsCompatible } from "../units/lookup"; import { solve, type Model } from "yalps"; /** @@ -132,6 +138,7 @@ export class ShoppingCart { this.productCatalog = catalog; } + // TODO: harmonize recipe name to use underscores /** * Sets the shopping list to build the cart from. * To use if a shopping list was not provided at the creation of the instance @@ -187,7 +194,7 @@ export class ShoppingCart { * @param ingredient - The ingredient to get the product options for * @returns An array of {@link ProductOption} */ - private getProductOptions(ingredient: Ingredient): ProductOption[] { + private getProductOptions(ingredient: AddedIngredient): ProductOption[] { // this function is only called in buildCart() which starts by checking that a product catalog is present return this.productCatalog!.products.filter( (product) => @@ -204,153 +211,186 @@ export class ShoppingCart { * @throws {@link NoProductMatchError} if no match can be found */ private getOptimumMatch( - ingredient: Ingredient, + ingredient: AddedIngredient, options: ProductOption[], ): ProductSelection[] { // If there's no product option, return an empty match if (options.length === 0) throw new NoProductMatchError(ingredient.name, "noProduct"); // If the ingredient has no quantity, we can't match any product - if (!ingredient.quantity) + if (!ingredient.quantityTotal) throw new NoProductMatchError(ingredient.name, "noQuantity"); - // If the ingredient has a text quantity, we can't match any product - if ( - ingredient.quantity.type === "fixed" && - ingredient.quantity.value.type === "text" - ) - throw new NoProductMatchError(ingredient.name, "textValue"); - // Convert quantities to base - if (!this.checkUnitCompatibility(ingredient, options)) { - throw new NoProductMatchError(ingredient.name, "incompatibleUnits"); - } - const normalizedOptions = options - .map((option) => { - return { ...option, unit: normalizeUnit(option.unit) }; - }) - .map((option) => { - return { - ...option, - size: option.unit - ? (multiplyQuantityValue( - option.size, - option.unit.toBase, - ) as FixedNumericValue) - : option.size, - }; - }); - const normalizedIngredient = { - ...ingredient, - quantity: ingredient.quantity as FixedNumericValue | Range, - unit: normalizeUnit(ingredient.unit), - }; - if (normalizedIngredient.unit && normalizedIngredient.quantity) - normalizedIngredient.quantity = multiplyQuantityValue( - normalizedIngredient.quantity, - normalizedIngredient.unit.toBase, - ) as FixedNumericValue | Range; + // Normalize options units and scale size to base + const normalizedOptions: ProductOptionNormalized[] = options.map( + (option) => ({ + ...option, + sizes: option.sizes.map((s): ProductSizeNormalized => { + const resolvedUnit = resolveUnit(s.unit); + return { + size: + resolvedUnit && "toBase" in resolvedUnit + ? (multiplyQuantityValue( + s.size, + resolvedUnit.toBase, + ) as FixedNumericValue) + : s.size, + unit: resolvedUnit, + }; + }), + }), + ); + const normalizedQuantityTotal = normalizeAllUnits(ingredient.quantityTotal); - // Simple minimization exercise if only one product option - if (normalizedOptions.length == 1) { - // FixedValue - if (normalizedIngredient.quantity.type === "fixed") { - const resQuantity = Math.ceil( - getNumericValue(normalizedIngredient.quantity.value) / - getNumericValue(normalizedOptions[0]!.size.value), - ); - return [ - { - product: options[0]!, - quantity: resQuantity, - totalPrice: resQuantity * options[0]!.price, - }, - ]; - } - // Range - else { - const targetQuantity = normalizedIngredient.quantity.min; - const resQuantity = Math.ceil( - getNumericValue(targetQuantity) / - getNumericValue(normalizedOptions[0]!.size.value), - ); - return [ - { - product: options[0]!, - quantity: resQuantity, - totalPrice: resQuantity * options[0]!.price, - }, - ]; - } - } + function getOptimumMatchForQuantityParts( + normalizedQuantities: + | QuantityWithUnitDef + | MaybeNestedGroup, + normalizedOptions: ProductOptionNormalized[], + selection: ProductSelection[] = [], + ): ProductSelection[] { + if (isAndGroup(normalizedQuantities)) { + for (const q of normalizedQuantities.entries) { + const result = getOptimumMatchForQuantityParts( + q, + normalizedOptions, + selection, + ); + selection.push(...result); + } + } else { + const alternativeUnitsOfQuantity = isOrGroup(normalizedQuantities) + ? (normalizedQuantities as FlatOrGroup).entries + : [normalizedQuantities]; + const solutions: ProductSelection[][] = []; + const errors = new Set(); + for (const alternative of alternativeUnitsOfQuantity) { + // At this stage, we're treating individual Quantities we should try to match + if ( + alternative.quantity.type === "fixed" && + alternative.quantity.value.type === "text" + ) { + errors.add("textValue"); + continue; + } + // At this stage, we know there is a numerical quantity + // So we scale it to base in order to calculate the correct quantity + const scaledQuantity = multiplyQuantityValue( + alternative.quantity, + "toBase" in alternative.unit ? alternative.unit.toBase : 1, + ) as FixedNumericValue | Range; + alternative.quantity = scaledQuantity; + // Are there compatible product options for that specific unit alternative? + // A product is compatible if ANY of its sizes has a compatible unit + const matchOptions = normalizedOptions.filter((option) => + option.sizes.some((s) => + areUnitsCompatible(alternative.unit, s.unit), + ), + ); + if (matchOptions.length > 0) { + // Helper to find the compatible size for a product option + const findCompatibleSize = ( + option: ProductOptionNormalized, + ): ProductSizeNormalized => + option.sizes.find((s) => + areUnitsCompatible(alternative.unit, s.unit), + )!; - // More complex problem if there are several options - const model: Model = { - direction: "minimize", - objective: "price", - integers: true, - constraints: { - size: { - min: - normalizedIngredient.quantity.type === "fixed" - ? getNumericValue(normalizedIngredient.quantity.value) - : getNumericValue(normalizedIngredient.quantity.min), - }, - }, - variables: normalizedOptions.reduce( - (acc, option) => { - acc[option.id] = { - price: option.price, - size: getNumericValue(option.size.value), - }; - return acc; - }, - {} as Record, - ), - }; + // Simple minimization exercise if only one product option + if (matchOptions.length == 1) { + const matchedOption = matchOptions[0]!; + const compatibleSize = findCompatibleSize(matchedOption); + const product = options.find( + (opt) => opt.id === matchedOption.id, + )!; + // FixedValue + const targetQuantity = + scaledQuantity.type === "fixed" + ? scaledQuantity.value + : scaledQuantity.min; + const resQuantity = Math.ceil( + getNumericValue(targetQuantity) / + getNumericValue(compatibleSize.size.value), + ); + solutions.push([ + { + product, + quantity: resQuantity, + totalPrice: resQuantity * matchedOption.price, + }, + ]); + continue; + } - const solution = solve(model); - return solution.variables.map((variable) => { - const resProductSelection = { - product: options.find((option) => option.id === variable[0])!, - quantity: variable[1], - }; - return { - ...resProductSelection, - totalPrice: - resProductSelection.quantity * resProductSelection.product.price, - }; - }); - } + // More complex problem if there are several options + const model: Model = { + direction: "minimize", + objective: "price", + integers: true, + constraints: { + size: { + min: + scaledQuantity.type === "fixed" + ? getNumericValue(scaledQuantity.value) + : getNumericValue(scaledQuantity.min), + }, + }, + variables: matchOptions.reduce( + (acc, option) => { + const compatibleSize = findCompatibleSize(option); + acc[option.id] = { + price: option.price, + size: getNumericValue(compatibleSize.size.value), + }; + return acc; + }, + {} as Record, + ), + }; - /** - * Checks if the units of an ingredient and its product options are compatible - * @param ingredient - The ingredient to check - * @param options - The product options to check - * @returns `true` if the units are compatible, `false` otherwise - */ - private checkUnitCompatibility( - ingredient: Ingredient, - options: ProductOption[], - ): boolean { - if (options.every((option) => option.unit === ingredient.unit)) { - return true; - } - if (!ingredient.unit && options.some((option) => option.unit)) { - return false; - } - if (ingredient.unit && options.some((option) => !option.unit)) { - return false; + const solution = solve(model); + solutions.push( + solution.variables.map((variable) => { + const resProductSelection = { + product: options.find((option) => option.id === variable[0])!, + quantity: variable[1], + }; + return { + ...resProductSelection, + totalPrice: + resProductSelection.quantity * + resProductSelection.product.price, + }; + }), + ); + } else { + errors.add("incompatibleUnits"); + } + } + // All alternatives were checked + if (solutions.length === 0) { + throw new NoProductMatchError( + ingredient.name, + errors.size === 1 + ? (errors.values().next().value as NoProductMatchErrorCode) + : "textValue_incompatibleUnits", + ); + } else { + // We return the cheapest solution among those found + return solutions.sort( + (a, b) => + a.reduce((acc, item) => acc + item.totalPrice, 0) - + b.reduce((acc, item) => acc + item.totalPrice, 0), + )[0]!; + } + } + return selection; } - const optionsUnits = options.map((options) => normalizeUnit(options.unit)); - const normalizedUnit = normalizeUnit(ingredient.unit); - if (!normalizedUnit) { - return false; - } - if (optionsUnits.some((unit) => unit?.type !== normalizedUnit?.type)) { - return false; - } - return true; + return getOptimumMatchForQuantityParts( + normalizedQuantityTotal, + normalizedOptions, + ); } /** diff --git a/src/classes/shopping_list.ts b/src/classes/shopping_list.ts index a4aed38..7e06adc 100644 --- a/src/classes/shopping_list.ts +++ b/src/classes/shopping_list.ts @@ -1,12 +1,18 @@ import { CategoryConfig } from "./category_config"; import { Recipe } from "./recipe"; import type { - Ingredient, CategorizedIngredients, AddedRecipe, AddedIngredient, + QuantityWithExtendedUnit, + QuantityWithPlainUnit, + MaybeNestedGroup, + FlatOrGroup, + AddedRecipeOptions, } from "../types"; -import { addQuantities, type Quantity } from "../units"; +import { addEquivalentsAndSimplify } from "../quantities/alternatives"; +import { extendAllUnits } from "../quantities/mutations"; +import { isAndGroup } from "../utils/type_guards"; /** * Shopping List generator. @@ -35,10 +41,11 @@ import { addQuantities, type Quantity } from "../units"; * @category Classes */ export class ShoppingList { + // TODO: backport type change /** * The ingredients in the shopping list. */ - ingredients: Ingredient[] = []; + ingredients: AddedIngredient[] = []; /** * The recipes in the shopping list. */ @@ -64,6 +71,54 @@ export class ShoppingList { private calculate_ingredients() { this.ingredients = []; + + const addIngredientQuantity = ( + name: string, + quantityTotal: + | QuantityWithPlainUnit + | MaybeNestedGroup, + ) => { + const quantityTotalExtended = extendAllUnits(quantityTotal); + const newQuantities = ( + isAndGroup(quantityTotalExtended) + ? quantityTotalExtended.entries + : [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.entries + : [existingQuantityTotalExtended] + ) as ( + | QuantityWithExtendedUnit + | FlatOrGroup + )[]; + existing.quantityTotal = addEquivalentsAndSimplify( + ...existingQuantities, + ...newQuantities, + ); + return; + } catch { + // Incompatible + } + } + + this.ingredients.push({ + name, + quantityTotal, + }); + }; + for (const addedRecipe of this.recipes) { let scaledRecipe: Recipe; if ("factor" in addedRecipe) { @@ -73,57 +128,21 @@ export class ShoppingList { scaledRecipe = addedRecipe.recipe.scaleTo(addedRecipe.servings); } - for (const ingredient of scaledRecipe.ingredients) { + // Get computed ingredients with total quantities based on choices (or default) + const computedIngredients = scaledRecipe.calc_ingredient_quantities( + addedRecipe.choices, + ); + + for (const ingredient of computedIngredients) { // Do not add hidden ingredients to the shopping list if (ingredient.flags && ingredient.flags.includes("hidden")) { continue; } - const existingIngredient = this.ingredients.find( - (i) => i.name === ingredient.name, - ); - - let addSeparate = false; - try { - if (existingIngredient && ingredient.quantity) { - if (existingIngredient.quantity) { - const newQuantity: Quantity = addQuantities( - { - value: existingIngredient.quantity, - unit: existingIngredient.unit ?? "", - }, - { - value: ingredient.quantity, - unit: ingredient.unit ?? "", - }, - ); - existingIngredient.quantity = newQuantity.value; - if (newQuantity.unit) { - existingIngredient.unit = newQuantity.unit; - } - } else { - existingIngredient.quantity = ingredient.quantity; - - /* v8 ignore else -- only set unit if it is given -- @preserve */ - if (ingredient.unit) { - existingIngredient.unit = ingredient.unit; - } - } - } - } catch { - // Cannot add quantities, adding as separate ingredients - addSeparate = true; - } - - if (!existingIngredient || addSeparate) { - const newIngredient: AddedIngredient = { name: ingredient.name }; - if (ingredient.quantity) { - newIngredient.quantity = ingredient.quantity; - } - if (ingredient.unit) { - newIngredient.unit = ingredient.unit; - } - this.ingredients.push(newIngredient); + if (ingredient.quantityTotal) { + addIngredientQuantity(ingredient.name, ingredient.quantityTotal); + } else if (!this.ingredients.some((i) => i.name === ingredient.name)) { + this.ingredients.push({ name: ingredient.name }); } } } @@ -133,31 +152,28 @@ export class ShoppingList { * Adds a recipe to the shopping list, then automatically * recalculates the quantities and recategorize the ingredients. * @param recipe - The recipe to add. - * @param scaling - The scaling option for the recipe. Can be either a factor or a number of servings - */ - add_recipe( - recipe: Recipe, - scaling?: { factor: number } | { servings: number }, - ): void; - /** - * Adds a recipe to the shopping list, then automatically - * recalculates the quantities and recategorize the ingredients. - * @param recipe - The recipe to add. - * @param factor - The factor to scale the recipe by. - * @deprecated since v2.0.3. Use the other call signature with `scaling` instead. Will be removed in v3 + * @param options - Options for adding the recipe. */ - add_recipe(recipe: Recipe, factor?: number): void; - add_recipe( - recipe: Recipe, - scaling?: { factor: number } | { servings: number } | number, - ): void { - if (typeof scaling === "number" || scaling === undefined) { - this.recipes.push({ recipe, factor: scaling ?? 1 }); + add_recipe(recipe: Recipe, options: AddedRecipeOptions = {}): void { + if (!options.scaling) { + this.recipes.push({ + recipe, + factor: options.scaling ?? 1, + choices: options.choices, + }); } else { - if ("factor" in scaling) { - this.recipes.push({ recipe, factor: scaling.factor }); + if ("factor" in options.scaling) { + this.recipes.push({ + recipe, + factor: options.scaling.factor, + choices: options.choices, + }); } else { - this.recipes.push({ recipe, servings: scaling.servings }); + this.recipes.push({ + recipe, + servings: options.scaling.servings, + choices: options.choices, + }); } } this.calculate_ingredients(); diff --git a/src/errors.ts b/src/errors.ts index e114279..bdf8005 100644 --- a/src/errors.ts +++ b/src/errors.ts @@ -50,6 +50,7 @@ export class NoProductMatchError extends Error { "No product was found linked to ingredient name ${item_name} in the shopping list", textValue: `Ingredient ${item_name} has a text value as quantity and can therefore not be matched with any product in the catalogue.`, noQuantity: `Ingredient ${item_name} has no quantity and can therefore not be matched with any product in the catalogue.`, + textValue_incompatibleUnits: `Multiple alternative quantities were provided for ingredient ${item_name} in the shopping list but they were either text values or no product in catalog were found to have compatible units`, }; super(messageMap[code]); this.code = code; @@ -63,3 +64,26 @@ export class InvalidProductCatalogFormat extends Error { this.name = "InvalidProductCatalogFormat"; } } + +export class CannotAddTextValueError extends Error { + constructor() { + super("Cannot add a quantity with a text value."); + this.name = "CannotAddTextValueError"; + } +} + +export class IncompatibleUnitsError extends Error { + constructor(unit1: string, unit2: string) { + super( + `Cannot add quantities with incompatible or unknown units: ${unit1} and ${unit2}`, + ); + this.name = "IncompatibleUnitsError"; + } +} + +export class InvalidQuantityFormat extends Error { + constructor(value: string) { + super(`Invalid quantity format found in: ${value}`); + this.name = "InvalidQuantityFormat"; + } +} diff --git a/src/index.ts b/src/index.ts index e06f92f..3e34e99 100644 --- a/src/index.ts +++ b/src/index.ts @@ -32,6 +32,7 @@ import type { Timer, TextItem, IngredientItem, + IngredientAlternative, CookwareItem, TimerItem, Item, @@ -41,12 +42,16 @@ import type { CookwareFlag, CategorizedIngredients, AddedRecipe, + AddedRecipeOptions, + AddedIngredient, RecipeWithFactor, RecipeWithServings, CategoryIngredient, Category, - QuantityPart, + ProductOptionBase, + ProductOptionCore, ProductOption, + ProductSize, ProductSelection, CartContent, ProductMatch, @@ -54,6 +59,26 @@ import type { ProductMisMatch, CartMisMatch, NoProductMatchErrorCode, + ComputedIngredient, + QuantityBase, + QuantityWithPlainUnit, + QuantityWithExtendedUnit, + QuantityWithUnitDef, + QuantityWithUnitLike, + Unit, + UnitSystem, + UnitType, + UnitDefinition, + UnitDefinitionLike, + MaybeNestedGroup, + MaybeNestedAndGroup, + MaybeNestedOrGroup, + IngredientQuantities, + IngredientQuantityGroup, + IngredientQuantityAndGroup, + AlternativeIngredientRef, + RecipeChoices, + RecipeAlternatives, } from "./types"; export { @@ -72,6 +97,7 @@ export { Timer, TextItem, IngredientItem, + IngredientAlternative, CookwareItem, TimerItem, Item, @@ -81,12 +107,16 @@ export { CookwareFlag, CategorizedIngredients, AddedRecipe, + AddedRecipeOptions, + AddedIngredient, RecipeWithFactor, RecipeWithServings, CategoryIngredient, Category, - QuantityPart, + ProductOptionBase, + ProductOptionCore, ProductOption, + ProductSize, ProductSelection, CartContent, ProductMatch, @@ -94,6 +124,26 @@ export { ProductMisMatch, CartMisMatch, NoProductMatchErrorCode, + ComputedIngredient, + QuantityBase, + QuantityWithPlainUnit, + QuantityWithExtendedUnit, + QuantityWithUnitDef, + QuantityWithUnitLike, + Unit, + UnitSystem, + UnitType, + UnitDefinition, + UnitDefinitionLike, + MaybeNestedGroup, + MaybeNestedAndGroup, + MaybeNestedOrGroup, + IngredientQuantities, + IngredientQuantityGroup, + IngredientQuantityAndGroup, + AlternativeIngredientRef, + RecipeChoices, + RecipeAlternatives, }; import { diff --git a/src/quantities/alternatives.ts b/src/quantities/alternatives.ts new file mode 100644 index 0000000..4f7815e --- /dev/null +++ b/src/quantities/alternatives.ts @@ -0,0 +1,407 @@ +import { isNoUnit } from "../units/definitions"; +import type { + QuantityWithPlainUnit, + QuantityWithExtendedUnit, + QuantityWithUnitDef, + UnitDefinitionLike, + FlatOrGroup, + MaybeNestedOrGroup, + FlatAndGroup, + FlatGroup, + MaybeNestedGroup, +} from "../types"; +import { resolveUnit } from "../units/definitions"; +import { multiplyQuantityValue, getAverageValue } from "./numeric"; +import Big from "big.js"; +import { + isGroup, + isOrGroup, + isQuantity, + isValueIntegerLike, +} from "../utils/type_guards"; +import { + getDefaultQuantityValue, + addQuantities, + deNormalizeQuantity, + toPlainUnit, +} from "./mutations"; +import { getUnitRatio, getBaseUnitRatio } from "../units/conversion"; +import { + areUnitsCompatible, + findCompatibleQuantityWithinList, + findListWithCompatibleQuantity, +} from "../units/lookup"; +import { deepClone } from "../utils/general"; + +export function getEquivalentUnitsLists( + ...quantities: ( + | QuantityWithExtendedUnit + | FlatOrGroup + )[] +): QuantityWithUnitDef[][] { + const quantitiesCopy = deepClone(quantities); + + const OrGroups = ( + quantitiesCopy.filter(isOrGroup) as FlatOrGroup[] + ).filter((q) => q.entries.length > 1); + + const unitLists: QuantityWithUnitDef[][] = []; + const normalizeOrGroup = (og: FlatOrGroup) => ({ + ...og, + entries: og.entries.map((q) => ({ + ...q, + unit: resolveUnit(q.unit?.name, q.unit?.integerProtected), + })), + }); + + function findLinkIndexForUnits( + lists: QuantityWithUnitDef[][], + unitsToCheck: (UnitDefinitionLike | undefined)[], + ) { + return lists.findIndex((l) => { + const listItem = l.map((q) => resolveUnit(q.unit?.name)); + return unitsToCheck.some((u) => + listItem.some( + (lu) => + lu.name === u?.name || + (lu.system === u?.system && + lu.type === u?.type && + lu.type !== "other"), + ), + ); + }); + } + + function mergeOrGroupIntoList( + lists: QuantityWithUnitDef[][], + idx: number, + og: ReturnType, + ) { + let unitRatio: Big | undefined; + const commonUnitList = lists[idx]!.reduce((acc, v) => { + const normalizedV: QuantityWithUnitDef = { + ...v, + unit: resolveUnit(v.unit?.name, v.unit?.integerProtected), + }; + + const commonQuantity = og.entries.find( + (q) => isQuantity(q) && areUnitsCompatible(q.unit, normalizedV.unit), + ); + if (commonQuantity) { + acc.push(normalizedV); + unitRatio = getUnitRatio(normalizedV, commonQuantity); + } + return acc; + }, [] as QuantityWithUnitDef[]); + + for (const newQ of og.entries) { + if (commonUnitList.some((q) => areUnitsCompatible(q.unit, newQ.unit))) { + continue; + } else { + const scaledQuantity = multiplyQuantityValue(newQ.quantity, unitRatio!); + lists[idx]!.push({ ...newQ, quantity: scaledQuantity }); + } + } + } + + for (const orGroup of OrGroups) { + const orGroupModified = normalizeOrGroup(orGroup); + const units = orGroupModified.entries.map((q) => q.unit); + const linkIndex = findLinkIndexForUnits(unitLists, units); + if (linkIndex === -1) { + unitLists.push(orGroupModified.entries); + } else { + mergeOrGroupIntoList(unitLists, linkIndex, orGroupModified); + } + } + + return unitLists; +} + +/** + * List sorting helper for equivalent units + * @param list - list of quantities to sort + * @returns sorted list of quantities with integerProtected units first, then no-unit, then the rest alphabetically + */ +export function sortUnitList(list: QuantityWithUnitDef[]) { + if (!list || list.length <= 1) return list; + const priorityList: QuantityWithUnitDef[] = []; + const nonPriorityList: QuantityWithUnitDef[] = []; + for (const q of list) { + if (q.unit.integerProtected || q.unit.system === "none") { + priorityList.push(q); + } else { + nonPriorityList.push(q); + } + } + + return priorityList + .sort((a, b) => { + const prefixA = a.unit.integerProtected ? "___" : ""; + const prefixB = b.unit.integerProtected ? "___" : ""; + return (prefixA + a.unit.name).localeCompare(prefixB + b.unit.name, "en"); + }) + .concat(nonPriorityList); +} + +export function reduceOrsToFirstEquivalent( + unitList: QuantityWithUnitDef[][], + quantities: ( + | QuantityWithExtendedUnit + | FlatOrGroup + )[], +): QuantityWithExtendedUnit[] { + function reduceToQuantity(firstQuantity: QuantityWithExtendedUnit) { + // Look for the global list of equivalent for this quantity unit; + const equivalentList = sortUnitList( + findListWithCompatibleQuantity(unitList, firstQuantity)!, + ); + if (!equivalentList) return firstQuantity; + // Find that first quantity in the OR + const firstQuantityInList = findCompatibleQuantityWithinList( + equivalentList, + firstQuantity, + )!; + // Normalize the first quantity's unit + const normalizedFirstQuantity: QuantityWithUnitDef = { + ...firstQuantity, + unit: resolveUnit(firstQuantity.unit?.name), + }; + // Priority 1: the first quantity has an integer-protected unit + if (firstQuantityInList.unit.integerProtected) { + const resultQuantity: QuantityWithExtendedUnit = { + quantity: firstQuantity.quantity, + }; + /* v8 ignore else -- @preserve */ + if (!isNoUnit(normalizedFirstQuantity.unit)) { + resultQuantity.unit = { name: normalizedFirstQuantity.unit.name }; + } + return resultQuantity; + } else { + // Priority 2: the next integer-protected units in the equivalent list + let nextProtected: number | undefined; + const equivalentListTemp = [...equivalentList]; + while (nextProtected !== -1) { + nextProtected = equivalentListTemp.findIndex( + (eq) => eq.unit?.integerProtected, + ); + // Ratio between the values in the OR group vs the ones used in the equivalent unit list + if (nextProtected !== -1) { + const unitRatio = getUnitRatio( + equivalentListTemp[nextProtected]!, + firstQuantityInList, + ); + const nextProtectedQuantityValue = multiplyQuantityValue( + firstQuantity.quantity, + unitRatio, + ); + if (isValueIntegerLike(nextProtectedQuantityValue)) { + const nextProtectedQuantity: QuantityWithExtendedUnit = { + quantity: nextProtectedQuantityValue, + }; + /* v8 ignore else -- @preserve */ + if (!isNoUnit(equivalentListTemp[nextProtected]!.unit)) { + nextProtectedQuantity.unit = { + name: equivalentListTemp[nextProtected]!.unit.name, + }; + } + + return nextProtectedQuantity; + } else { + equivalentListTemp.splice(nextProtected, 1); + } + } + } + + // Priority 3: the first non-integer-Protected value of the list + const firstNonIntegerProtected = equivalentListTemp.filter( + (q) => !q.unit.integerProtected, + )[0]!; + const unitRatio = getUnitRatio( + firstNonIntegerProtected, + firstQuantityInList, + ).times(getBaseUnitRatio(normalizedFirstQuantity, firstQuantityInList)); + const firstEqQuantity: QuantityWithExtendedUnit = { + quantity: + firstNonIntegerProtected.unit.name === firstQuantity.unit!.name + ? firstQuantity.quantity + : multiplyQuantityValue(firstQuantity.quantity, unitRatio), + }; + if (!isNoUnit(firstNonIntegerProtected.unit)) { + firstEqQuantity.unit = { name: firstNonIntegerProtected.unit.name }; + } + return firstEqQuantity; + } + } + return quantities.map((q) => { + if (isQuantity(q)) return reduceToQuantity(q); + // Now, q is necessarily an OR group + // We normalize units and sort them to get integerProtected elements first, then no units, then the rest + const qListModified = sortUnitList( + q.entries.map((qq) => ({ + ...qq, + unit: resolveUnit(qq.unit?.name, qq.unit?.integerProtected), + })), + ); + // We can simply use the first element + return reduceToQuantity(qListModified[0]!); + }); +} + +export function addQuantitiesOrGroups( + ...quantities: ( + | QuantityWithExtendedUnit + | FlatOrGroup + )[] +): { + sum: QuantityWithUnitDef | FlatGroup; + unitsLists: QuantityWithUnitDef[][]; +} { + if (quantities.length === 0) + return { + sum: { + quantity: getDefaultQuantityValue(), + unit: resolveUnit(), + }, + unitsLists: [], + }; + // This is purely theoretical and won't really happen in practice + if (quantities.length === 1) { + if (isQuantity(quantities[0]!)) + return { + sum: { + ...quantities[0], + unit: resolveUnit(quantities[0].unit?.name), + }, + unitsLists: [], + }; + } + // Step 1: find equivalents units + const unitsLists = getEquivalentUnitsLists(...quantities); + // Step 2: reduce the OR group to Quantities + const reducedQuantities = reduceOrsToFirstEquivalent(unitsLists, quantities); + // Step 3: calculate the sum + const sum: QuantityWithUnitDef[] = []; + for (const nextQ of reducedQuantities) { + const existingQ = findCompatibleQuantityWithinList(sum, nextQ); + if (existingQ === undefined) { + sum.push({ + ...nextQ, + unit: resolveUnit(nextQ.unit?.name), + }); + } else { + const sumQ = addQuantities(existingQ, nextQ); + existingQ.quantity = sumQ.quantity; + existingQ.unit = resolveUnit(sumQ.unit?.name); + } + } + if (sum.length === 1) { + return { sum: sum[0]!, unitsLists }; + } + return { sum: { type: "and", entries: sum }, unitsLists }; +} + +export function regroupQuantitiesAndExpandEquivalents( + sum: QuantityWithUnitDef | FlatGroup, + unitsLists: QuantityWithUnitDef[][], +): (QuantityWithExtendedUnit | MaybeNestedOrGroup)[] { + const sumQuantities = isGroup(sum) ? sum.entries : [sum]; + const result: ( + | QuantityWithExtendedUnit + | MaybeNestedOrGroup + )[] = []; + const processedQuantities = new Set(); + + for (const list of unitsLists) { + const listCopy = deepClone(list); + const main: QuantityWithUnitDef[] = []; + const mainCandidates = sumQuantities.filter( + (q) => !processedQuantities.has(q), + ); + if (mainCandidates.length === 0) continue; + + mainCandidates.forEach((q) => { + // If the sum contain a value from the unit list, we push it to the mains and remove it from the list + const mainInList = findCompatibleQuantityWithinList(listCopy, q); + /* v8 ignore else -- @preserve */ + if (mainInList !== undefined) { + processedQuantities.add(q); + main.push(q); + listCopy.splice(listCopy.indexOf(mainInList), 1); + } + }); + + // We sort the equivalent units and calculate the equivalent value for each of them + const equivalents = sortUnitList(listCopy).map((equiv) => { + const initialValue: QuantityWithExtendedUnit = { + quantity: getDefaultQuantityValue(), + }; + /* v8 ignore else -- @preserve */ + if (equiv.unit) { + initialValue.unit = { name: equiv.unit.name }; + } + return main.reduce((acc, v) => { + const mainInList = findCompatibleQuantityWithinList(list, v)!; + const newValue: QuantityWithExtendedUnit = { + quantity: multiplyQuantityValue( + v.quantity, + Big(getAverageValue(equiv.quantity)).div( + getAverageValue(mainInList.quantity), + ), + ), + }; + if (equiv.unit && !isNoUnit(equiv.unit)) { + newValue.unit = { name: equiv.unit.name }; + } + return addQuantities(acc, newValue); + }, initialValue); + }); + + if (main.length + equivalents.length > 1) { + const resultMain: + | QuantityWithExtendedUnit + | FlatAndGroup = + main.length > 1 + ? { + type: "and", + entries: main.map(deNormalizeQuantity), + } + : deNormalizeQuantity(main[0]!); + result.push({ + type: "or", + entries: [resultMain, ...equivalents], + }); + } + // Processing a UnitList with only 1 quantity is purely theoretical and won't happen in practice + else { + result.push(deNormalizeQuantity(main[0]!)); + } + } + + // We add at the end the lone quantities + sumQuantities + .filter((q) => !processedQuantities.has(q)) + .forEach((q) => result.push(deNormalizeQuantity(q))); + + return result; +} + +export function addEquivalentsAndSimplify( + ...quantities: ( + | QuantityWithExtendedUnit + | FlatOrGroup + )[] +): QuantityWithPlainUnit | MaybeNestedGroup { + if (quantities.length === 1) { + return toPlainUnit(quantities[0]!); + } + // Step 1+2+3: find equivalents, reduce groups and add quantities + const { sum, unitsLists } = addQuantitiesOrGroups(...quantities); + // Step 4: regroup and expand equivalents per group + const regrouped = regroupQuantitiesAndExpandEquivalents(sum, unitsLists); + if (regrouped.length === 1) { + return toPlainUnit(regrouped[0]!); + } else { + return { type: "and", entries: regrouped.map(toPlainUnit) }; + } +} diff --git a/src/quantities/mutations.ts b/src/quantities/mutations.ts new file mode 100644 index 0000000..5db85f3 --- /dev/null +++ b/src/quantities/mutations.ts @@ -0,0 +1,401 @@ +import type { + FixedValue, + Range, + DecimalValue, + FractionValue, + Unit, + UnitDefinition, + QuantityWithPlainUnit, + QuantityWithExtendedUnit, + QuantityWithUnitDef, + MaybeNestedGroup, +} from "../types"; +import { + units, + normalizeUnit, + resolveUnit, + isNoUnit, +} from "../units/definitions"; +import { addNumericValues, multiplyQuantityValue } from "./numeric"; +import { CannotAddTextValueError, IncompatibleUnitsError } from "../errors"; +import { isGroup, isOrGroup, isQuantity } from "../utils/type_guards"; + +// `deNormalizeQuantity` is provided by `./math` and re-exported below. + +export function extendAllUnits( + q: QuantityWithPlainUnit | MaybeNestedGroup, +): QuantityWithExtendedUnit | MaybeNestedGroup { + if (isGroup(q)) { + return { ...q, entries: q.entries.map(extendAllUnits) }; + } else { + const newQ: QuantityWithExtendedUnit = { + quantity: q.quantity, + }; + if (q.unit) { + newQ.unit = { name: q.unit }; + } + return newQ; + } +} + +export function normalizeAllUnits( + q: QuantityWithPlainUnit | MaybeNestedGroup, +): QuantityWithUnitDef | MaybeNestedGroup { + if (isGroup(q)) { + return { ...q, entries: q.entries.map(normalizeAllUnits) }; + } else { + const newQ: QuantityWithUnitDef = { + quantity: q.quantity, + unit: resolveUnit(q.unit), + }; + return newQ; + } +} + +export const convertQuantityValue = ( + value: FixedValue | Range, + def: UnitDefinition, + targetDef: UnitDefinition, +): FixedValue | Range => { + if (def.name === targetDef.name) return value; + + const factor = def.toBase / targetDef.toBase; + + return multiplyQuantityValue(value, factor); +}; + +/** + * Get the default / neutral quantity which can be provided to addQuantity + * for it to return the other value as result + * + * @return zero + */ +export function getDefaultQuantityValue(): FixedValue { + return { type: "fixed", value: { type: "decimal", decimal: 0 } }; +} + +/** + * Adds two quantity values together. + * + * - Adding two {@link FixedValue}s returns a new {@link FixedValue}. + * - Adding a {@link Range} to any value returns a {@link Range}. + * + * @param v1 - The first quantity value. + * @param v2 - The second quantity value. + * @returns A new quantity value representing the sum. + */ +export function addQuantityValues(v1: FixedValue, v2: FixedValue): FixedValue; +export function addQuantityValues( + v1: FixedValue | Range, + v2: FixedValue | Range, +): Range; + +export function addQuantityValues( + v1: FixedValue | Range, + v2: FixedValue | Range, +): FixedValue | Range { + if ( + (v1.type === "fixed" && v1.value.type === "text") || + (v2.type === "fixed" && v2.value.type === "text") + ) { + throw new CannotAddTextValueError(); + } + + if (v1.type === "fixed" && v2.type === "fixed") { + const res = addNumericValues( + v1.value as DecimalValue | FractionValue, + v2.value as DecimalValue | FractionValue, + ); + return { type: "fixed", value: res }; + } + const r1 = + v1.type === "range" ? v1 : { type: "range", min: v1.value, max: v1.value }; + const r2 = + v2.type === "range" ? v2 : { type: "range", min: v2.value, max: v2.value }; + const newMin = addNumericValues( + r1.min as DecimalValue | FractionValue, + r2.min as DecimalValue | FractionValue, + ); + const newMax = addNumericValues( + r1.max as DecimalValue | FractionValue, + r2.max as DecimalValue | FractionValue, + ); + return { type: "range", min: newMin, max: newMax }; +} + +/** + * Adds two quantities, returning the result in the most appropriate unit. + */ +export function addQuantities( + q1: QuantityWithExtendedUnit, + q2: QuantityWithExtendedUnit, +): QuantityWithExtendedUnit { + const v1 = q1.quantity; + const v2 = q2.quantity; + // Case 1: one of the two values is a text, we throw an error we can catch on the other end + if ( + (v1.type === "fixed" && v1.value.type === "text") || + (v2.type === "fixed" && v2.value.type === "text") + ) { + throw new CannotAddTextValueError(); + } + + const unit1Def = normalizeUnit(q1.unit?.name); + const unit2Def = normalizeUnit(q2.unit?.name); + + const addQuantityValuesAndSetUnit = ( + val1: FixedValue | Range, + val2: FixedValue | Range, + unit?: Unit, + ): QuantityWithExtendedUnit => ({ + quantity: addQuantityValues(val1, val2), + unit, + }); + + // Case 2: one of the two values doesn't have a unit, we preserve its value and consider its unit to be that of the other one + // If at least one of the two units is "", this preserves it versus setting the resulting unit as undefined + if ( + (q1.unit?.name === "" || q1.unit === undefined) && + q2.unit !== undefined + ) { + return addQuantityValuesAndSetUnit(v1, v2, q2.unit); // Prefer q2's unit + } + if ( + (q2.unit?.name === "" || q2.unit === undefined) && + q1.unit !== undefined + ) { + return addQuantityValuesAndSetUnit(v1, v2, q1.unit); // Prefer q1's unit + } + + // Case 3: the two quantities have the exact same unit + if ( + (!q1.unit && !q2.unit) || + (q1.unit && + q2.unit && + q1.unit.name.toLowerCase() === q2.unit.name.toLowerCase()) + ) { + return addQuantityValuesAndSetUnit(v1, v2, q1.unit); + } + + // Case 4: the two quantities have different units of known type + if (unit1Def && unit2Def) { + // Case 4.1: different unit type => we can't add quantities + if (unit1Def.type !== unit2Def.type) { + throw new IncompatibleUnitsError( + `${unit1Def.type} (${q1.unit?.name})`, + `${unit2Def.type} (${q2.unit?.name})`, + ); + } + + let targetUnitDef: UnitDefinition; + + // Case 4.2: same unit type but different system => we convert to metric + if (unit1Def.system !== unit2Def.system) { + const metricUnitDef = unit1Def.system === "metric" ? unit1Def : unit2Def; + targetUnitDef = units + .filter((u) => u.type === metricUnitDef.type && u.system === "metric") + .reduce((prev, current) => + prev.toBase > current.toBase ? prev : current, + ); + } + // Case 4.3: same unit type, same system but different unit => we use the biggest unit of the two + else { + targetUnitDef = unit1Def.toBase >= unit2Def.toBase ? unit1Def : unit2Def; + } + const convertedV1 = convertQuantityValue(v1, unit1Def, targetUnitDef); + const convertedV2 = convertQuantityValue(v2, unit2Def, targetUnitDef); + const targetUnit: Unit = { name: targetUnitDef.name }; + + return addQuantityValuesAndSetUnit(convertedV1, convertedV2, targetUnit); + } + + // Case 5: the two quantities have different units of unknown type + throw new IncompatibleUnitsError( + q1.unit?.name as string, + q2.unit?.name as string, + ); +} + +export function toPlainUnit( + quantity: + | QuantityWithExtendedUnit + | MaybeNestedGroup, +): QuantityWithPlainUnit | MaybeNestedGroup { + if (isQuantity(quantity)) + return quantity.unit + ? { ...quantity, unit: quantity.unit.name } + : (quantity as QuantityWithPlainUnit); + else { + return { + ...quantity, + entries: quantity.entries.map(toPlainUnit), + } as MaybeNestedGroup; + } +} + +// Convert plain unit to extended unit format for addEquivalentsAndSimplify +// Overloads for precise return types +export function toExtendedUnit( + q: QuantityWithPlainUnit, +): QuantityWithExtendedUnit; +export function toExtendedUnit( + q: MaybeNestedGroup, +): MaybeNestedGroup; +export function toExtendedUnit( + q: QuantityWithPlainUnit | MaybeNestedGroup, +): QuantityWithExtendedUnit | MaybeNestedGroup { + if (isQuantity(q)) { + return q.unit + ? { ...q, unit: { name: q.unit } } + : (q as QuantityWithExtendedUnit); + } else { + return { + ...q, + entries: q.entries.map((entry) => + isQuantity(entry) ? toExtendedUnit(entry) : toExtendedUnit(entry), + ), + }; + } +} + +export function deNormalizeQuantity( + q: QuantityWithUnitDef, +): QuantityWithExtendedUnit { + const result: QuantityWithExtendedUnit = { + quantity: q.quantity, + }; + if (!isNoUnit(q.unit)) { + result.unit = { name: q.unit.name }; + } + return result; +} + +// Helper function to convert addEquivalentsAndSimplify result to IngredientQuantities format +// Returns either a QuantityWithPlainUnit (for simple/OR groups) or an IngredientQuantityAndGroup (for AND groups) +export const flattenPlainUnitGroup = ( + summed: QuantityWithPlainUnit | MaybeNestedGroup, +): ( + | { groupQuantity: QuantityWithPlainUnit } + | { + type: "and"; + entries: QuantityWithPlainUnit[]; + equivalents?: QuantityWithPlainUnit[]; + } +)[] => { + if (isOrGroup(summed)) { + // OR group: check if first entry is an AND group (nested OR-with-AND case from addEquivalentsAndSimplify) + // This happens when we have incompatible integer-protected primaries with compatible equivalents + // e.g., { type: "or", entries: [{ type: "and", entries: [large, small] }, cup] } + const entries = summed.entries; + const andGroupEntry = entries.find( + (e): e is MaybeNestedGroup => + isGroup(e) && e.type === "and", + ); + + if (andGroupEntry) { + // Nested OR-with-AND case: AND group of primaries + equivalents + const andEntries: QuantityWithPlainUnit[] = []; + for (const entry of andGroupEntry.entries) { + if (isQuantity(entry)) { + andEntries.push({ + quantity: entry.quantity, + unit: entry.unit, + }); + } + } + + // The other entries in the OR group are the equivalents + const equivalentsList: QuantityWithPlainUnit[] = entries + .filter((e): e is QuantityWithPlainUnit => isQuantity(e)) + .map((e) => ({ quantity: e.quantity, unit: e.unit })); + + if (equivalentsList.length > 0) { + return [ + { + type: "and", + entries: andEntries, + equivalents: equivalentsList, + }, + ]; + } else { + // No equivalents: flatten to separate entries (shouldn't happen in this branch, but handle it) + return andEntries.map((entry) => ({ groupQuantity: entry })); + } + } + + // Simple OR group: first entry is primary, rest are equivalents + const simpleEntries = entries.filter((e): e is QuantityWithPlainUnit => + isQuantity(e), + ); + /* v8 ignore else -- @preserve */ + if (simpleEntries.length > 0) { + const result: QuantityWithPlainUnit = { + quantity: simpleEntries[0]!.quantity, + unit: simpleEntries[0]!.unit, + }; + if (simpleEntries.length > 1) { + result.equivalents = simpleEntries.slice(1); + } + return [{ groupQuantity: result }]; + } + // Fallback: use first entry regardless + else { + const first = entries[0] as QuantityWithPlainUnit; + return [ + { groupQuantity: { quantity: first.quantity, unit: first.unit } }, + ]; + } + } else if (isGroup(summed)) { + // AND group: check if entries have OR groups (equivalents that can be extracted) + const andEntries: QuantityWithPlainUnit[] = []; + const equivalentsList: QuantityWithPlainUnit[] = []; + + for (const entry of summed.entries) { + if (isOrGroup(entry)) { + // This entry has equivalents: first is primary, rest are equivalents + const orEntries = entry.entries.filter( + (e): e is QuantityWithPlainUnit => isQuantity(e), + ); + if (orEntries.length > 0) { + andEntries.push({ + quantity: orEntries[0]!.quantity, + unit: orEntries[0]!.unit, + }); + // Collect equivalents for later merging + equivalentsList.push(...orEntries.slice(1)); + } + } else if (isQuantity(entry)) { + // Simple quantity, no equivalents + andEntries.push({ + quantity: entry.quantity, + unit: entry.unit, + }); + } + } + + // Build the AND group result + // If there are no equivalents, flatten to separate groupQuantity entries (water case) + // If there are equivalents, return an AND group with the summed equivalents (carrots case) + if (equivalentsList.length === 0) { + // No equivalents: flatten to separate entries + return andEntries.map((entry) => ({ groupQuantity: entry })); + } + + const result: { + type: "and"; + entries: QuantityWithPlainUnit[]; + equivalents?: QuantityWithPlainUnit[]; + } = { + type: "and", + entries: andEntries, + equivalents: equivalentsList, + }; + + return [result]; + } else { + // Simple QuantityWithPlainUnit + return [ + { groupQuantity: { quantity: summed.quantity, unit: summed.unit } }, + ]; + } +}; diff --git a/src/quantities/numeric.ts b/src/quantities/numeric.ts new file mode 100644 index 0000000..ef71fab --- /dev/null +++ b/src/quantities/numeric.ts @@ -0,0 +1,146 @@ +import Big from "big.js"; +import type { DecimalValue, FractionValue, FixedValue, Range } from "../types"; + +function gcd(a: number, b: number): number { + return b === 0 ? a : gcd(b, a % b); +} + +export function simplifyFraction( + num: number, + den: number, +): DecimalValue | FractionValue { + if (den === 0) { + throw new Error("Denominator cannot be zero."); + } + + const commonDivisor = gcd(Math.abs(num), Math.abs(den)); + let simplifiedNum = num / commonDivisor; + let simplifiedDen = den / commonDivisor; + if (simplifiedDen < 0) { + simplifiedNum = -simplifiedNum; + simplifiedDen = -simplifiedDen; + } + + if (simplifiedDen === 1) { + return { type: "decimal", decimal: simplifiedNum }; + } else { + return { type: "fraction", num: simplifiedNum, den: simplifiedDen }; + } +} + +export function getNumericValue(v: DecimalValue | FractionValue): number { + if (v.type === "decimal") { + return v.decimal; + } + return v.num / v.den; +} + +export function multiplyNumericValue( + v: DecimalValue | FractionValue, + factor: number | Big, +): DecimalValue | FractionValue { + if (v.type === "decimal") { + return { + type: "decimal", + decimal: Big(v.decimal).times(factor).toNumber(), + }; + } + return simplifyFraction(Big(v.num).times(factor).toNumber(), v.den); +} + +export function addNumericValues( + val1: DecimalValue | FractionValue, + val2: DecimalValue | FractionValue, +): DecimalValue | FractionValue { + let num1: number; + let den1: number; + let num2: number; + let den2: number; + + if (val1.type === "decimal") { + num1 = val1.decimal; + den1 = 1; + } else { + num1 = val1.num; + den1 = val1.den; + } + + if (val2.type === "decimal") { + num2 = val2.decimal; + den2 = 1; + } else { + num2 = val2.num; + den2 = val2.den; + } + + // Return 0 if both values are 0 + if (num1 === 0 && num2 === 0) { + return { type: "decimal", decimal: 0 }; + } + + // We only return a fraction where both input values are fractions themselves or only one while the other is 0 + if ( + (val1.type === "fraction" && val2.type === "fraction") || + (val1.type === "fraction" && + val2.type === "decimal" && + val2.decimal === 0) || + (val2.type === "fraction" && val1.type === "decimal" && val1.decimal === 0) + ) { + const commonDen = den1 * den2; + const sumNum = num1 * den2 + num2 * den1; + return simplifyFraction(sumNum, commonDen); + } else { + return { + type: "decimal", + decimal: Big(num1).div(den1).add(Big(num2).div(den2)).toNumber(), + }; + } +} + +export const toRoundedDecimal = ( + v: DecimalValue | FractionValue, +): DecimalValue => { + const value = v.type === "decimal" ? v.decimal : v.num / v.den; + return { type: "decimal", decimal: Math.round(value * 1000) / 1000 }; +}; + +export function multiplyQuantityValue( + value: FixedValue | Range, + factor: number | Big, +): FixedValue | Range { + if (value.type === "fixed") { + const newValue = multiplyNumericValue( + value.value as DecimalValue | FractionValue, + Big(factor), + ); + if ( + factor === parseInt(factor.toString()) || // e.g. 2 === int + Big(1).div(factor).toNumber() === parseInt(Big(1).div(factor).toString()) // e.g. 0.25 => 4 === int + ) { + // Preserve fractions + return { + type: "fixed", + value: newValue, + }; + } + // We might multiply with big decimal number so rounding into decimal value + return { + type: "fixed", + value: toRoundedDecimal(newValue), + }; + } + + return { + type: "range", + min: multiplyNumericValue(value.min, factor), + max: multiplyNumericValue(value.max, factor), + }; +} + +export function getAverageValue(q: FixedValue | Range): string | number { + if (q.type === "fixed") { + return q.value.type === "text" ? q.value.text : getNumericValue(q.value); + } else { + return (getNumericValue(q.min) + getNumericValue(q.max)) / 2; + } +} diff --git a/src/regex.ts b/src/regex.ts index 3be83ef..d6bb980 100644 --- a/src/regex.ts +++ b/src/regex.ts @@ -28,84 +28,202 @@ export const scalingMetaValueRegex = (varName: string): RegExp => createRegex() .toRegExp() const nonWordChar = "\\s@#~\\[\\]{(,;:!?" +const nonWordCharStrict = "\\s@#~\\[\\]{(,;:!?|" -const multiwordIngredient = createRegex() +export const ingredientWithAlternativeRegex = createRegex() .literal("@") - .startNamedGroup("mIngredientModifiers") + .startNamedGroup("ingredientModifiers") .anyOf("@\\-&?").zeroOrMore() .endGroup().optional() - .startNamedGroup("mIngredientRecipeAnchor") + .startNamedGroup("ingredientRecipeAnchor") .literal("./") .endGroup().optional() - .startNamedGroup("mIngredientName") - .notAnyOf(nonWordChar).oneOrMore() + .startGroup() .startGroup() - .whitespace().oneOrMore().notAnyOf(nonWordChar).oneOrMore() - .endGroup().oneOrMore() - .notAnyOf("\\."+nonWordChar) + .startNamedGroup("mIngredientName") + .notAnyOf(nonWordChar).oneOrMore() + .startGroup() + .whitespace().oneOrMore().notAnyOf(nonWordChar).oneOrMore() + .endGroup().oneOrMore() + .endGroup() + .positiveLookahead("\\s*(?:\\{[^\\}]*\\}|\\([^)]*\\))") + .endGroup() + .or() + .startNamedGroup("sIngredientName") + .notAnyOf(nonWordChar).zeroOrMore() + .notAnyOf("\\."+nonWordChar) + .endGroup() .endGroup() - .positiveLookahead("\\s*(?:\\{[^\\}]*\\}|\\([^)]*\\))") .startGroup() .literal("{") - .startNamedGroup("mIngredientQuantityModifier") + .startNamedGroup("ingredientQuantityModifier") .literal("=").exactly(1) .endGroup().optional() - .startNamedGroup("mIngredientQuantity") - .notAnyOf("}%").oneOrMore() - .endGroup().optional() - .startGroup() - .literal("%") - .startNamedGroup("mIngredientUnit") + .startNamedGroup("ingredientQuantity") + .startGroup() + .notAnyOf("}|%").oneOrMore() + .endGroup().optional() + .startGroup() + .literal("%") + .notAnyOf("|}").oneOrMore().lazy() + .endGroup().optional() + .startGroup() + .literal("|") .notAnyOf("}").oneOrMore().lazy() - .endGroup() - .endGroup().optional() + .endGroup().zeroOrMore() + .endGroup() .literal("}") .endGroup().optional() .startGroup() .literal("(") - .startNamedGroup("mIngredientPreparation") + .startNamedGroup("ingredientPreparation") .notAnyOf(")").oneOrMore().lazy() .endGroup() .literal(")") .endGroup().optional() + .startGroup() + .literal("[") + .startNamedGroup("ingredientNote") + .notAnyOf("\\]").oneOrMore().lazy() + .endGroup() + .literal("]") + .endGroup().optional() + .startNamedGroup("ingredientAlternative") + .startGroup() + .literal("|") + .startGroup() + .anyOf("@\\-&?").zeroOrMore() + .endGroup().optional() + .startGroup() + .literal("./") + .endGroup().optional() + .startGroup() + .startGroup() + .startGroup() + .notAnyOf(nonWordChar).oneOrMore() + .startGroup() + .whitespace().oneOrMore().notAnyOf(nonWordChar).oneOrMore() + .endGroup().oneOrMore() + .endGroup() + .positiveLookahead("\\s*(?:\\{[^\\}]*\\}|\\([^)]*\\))") + .endGroup() + .or() + .startGroup() + .notAnyOf(nonWordChar).oneOrMore() + .endGroup() + .endGroup() + .startGroup() + .literal("{") + .startGroup() + .literal("=").exactly(1) + .endGroup().optional() + .startGroup() + .notAnyOf("}%").oneOrMore() + .endGroup().optional() + .startGroup() + .literal("%") + .startGroup() + .notAnyOf("}").oneOrMore().lazy() + .endGroup() + .endGroup().optional() + .literal("}") + .endGroup().optional() + .startGroup() + .literal("(") + .startGroup() + .notAnyOf(")").oneOrMore().lazy() + .endGroup() + .literal(")") + .endGroup().optional() + .startGroup() + .literal("[") + .startGroup() + .notAnyOf("\\]").oneOrMore().lazy() + .endGroup() + .literal("]") + .endGroup().optional() + .endGroup().zeroOrMore() + .endGroup() .toRegExp(); -const singleWordIngredient = createRegex() - .literal("@") - .startNamedGroup("sIngredientModifiers") +export const inlineIngredientAlternativesRegex = new RegExp("\\|" + ingredientWithAlternativeRegex.source.slice(1)) + +export const quantityAlternativeRegex = createRegex() + .startNamedGroup("ingredientQuantityValue") + .notAnyOf("}|%").oneOrMore() + .endGroup().optional() + .startGroup() + .literal("%") + .startNamedGroup("ingredientUnit") + .notAnyOf("|}").oneOrMore() + .endGroup() + .endGroup().optional() + .startGroup() + .literal("|") + .startNamedGroup("ingredientAltQuantity") + .startGroup() + .notAnyOf("}").oneOrMore() + .endGroup().zeroOrMore() + .endGroup() + .endGroup().optional() + .toRegExp() + +export const ingredientWithGroupKeyRegex = createRegex() + .literal("@|") + .startNamedGroup("gIngredientGroupKey") + .notAnyOf(nonWordCharStrict).oneOrMore() + .endGroup() + .literal("|") + .startNamedGroup("gIngredientModifiers") .anyOf("@\\-&?").zeroOrMore() .endGroup().optional() - .startNamedGroup("sIngredientRecipeAnchor") + .startNamedGroup("gIngredientRecipeAnchor") .literal("./") .endGroup().optional() - .startNamedGroup("sIngredientName") - .notAnyOf(nonWordChar).zeroOrMore() - .notAnyOf("\\."+nonWordChar) + .startGroup() + .startGroup() + .startNamedGroup("gmIngredientName") + .notAnyOf(nonWordChar).oneOrMore() + .startGroup() + .whitespace().oneOrMore().notAnyOf(nonWordChar).oneOrMore() + .endGroup().oneOrMore() + .endGroup() + .positiveLookahead("\\s*(?:\\{[^\\}]*\\}|\\([^)]*\\))") + .endGroup() + .or() + .startNamedGroup("gsIngredientName") + .notAnyOf(nonWordChar).zeroOrMore() + .notAnyOf("\\."+nonWordChar) + .endGroup() .endGroup() .startGroup() .literal("{") - .startNamedGroup("sIngredientQuantityModifier") + .startNamedGroup("gIngredientQuantityModifier") .literal("=").exactly(1) .endGroup().optional() - .startNamedGroup("sIngredientQuantity") - .notAnyOf("}%").oneOrMore() - .endGroup().optional() - .startGroup() - .literal("%") - .startNamedGroup("sIngredientUnit") + .startNamedGroup("gIngredientQuantity") + .startGroup() + .notAnyOf("}|%").oneOrMore() + .endGroup().optional() + .startGroup() + .literal("%") + .notAnyOf("|}").oneOrMore().lazy() + .endGroup().optional() + .startGroup() + .literal("|") .notAnyOf("}").oneOrMore().lazy() - .endGroup() - .endGroup().optional() + .endGroup().zeroOrMore() + .endGroup() .literal("}") .endGroup().optional() .startGroup() .literal("(") - .startNamedGroup("sIngredientPreparation") + .startNamedGroup("gIngredientPreparation") .notAnyOf(")").oneOrMore().lazy() .endGroup() .literal(")") .endGroup().optional() - .toRegExp(); + .toRegExp() export const ingredientAliasRegex = createRegex() .startAnchor() @@ -119,44 +237,36 @@ export const ingredientAliasRegex = createRegex() .endAnchor() .toRegExp(); -const multiwordCookware = createRegex() +export const cookwareRegex = createRegex() .literal("#") - .startNamedGroup("mCookwareModifiers") + .startNamedGroup("cookwareModifiers") .anyOf("\\-&?").zeroOrMore() .endGroup() - .startNamedGroup("mCookwareName") - .notAnyOf(nonWordChar).oneOrMore() + .startGroup() .startGroup() - .whitespace().oneOrMore().notAnyOf(nonWordChar).oneOrMore() - .endGroup().oneOrMore() - .notAnyOf("\\."+nonWordChar) - .endGroup().positiveLookahead("\\s*(?:\\{[^\\}]*\\})") - .literal("{") - .startNamedGroup("mCookwareQuantity") - .anyCharacter().zeroOrMore().lazy() - .endGroup() - .literal("}") - .toRegExp(); - -const singleWordCookware = createRegex() - .literal("#") - .startNamedGroup("sCookwareModifiers") - .anyOf("\\-&?").zeroOrMore() - .endGroup() - .startNamedGroup("sCookwareName") - .notAnyOf(nonWordChar).zeroOrMore() - .notAnyOf("\\."+nonWordChar) + .startNamedGroup("mCookwareName") + .notAnyOf(nonWordChar).oneOrMore() + .startGroup() + .whitespace().oneOrMore().notAnyOf(nonWordChar).oneOrMore() + .endGroup().oneOrMore() + .endGroup().positiveLookahead("\\s*(?:\\{[^\\}]*\\})") + .endGroup() + .or() + .startNamedGroup("sCookwareName") + .notAnyOf(nonWordChar).zeroOrMore() + .notAnyOf("\\."+nonWordChar) + .endGroup() .endGroup() .startGroup() .literal("{") - .startNamedGroup("sCookwareQuantity") + .startNamedGroup("cookwareQuantity") .anyCharacter().zeroOrMore().lazy() .endGroup() .literal("}") .endGroup().optional() .toRegExp(); -const timer = createRegex() +const timerRegex = createRegex() .literal("~") .startNamedGroup("timerName") .anyCharacter().zeroOrMore().lazy() @@ -176,11 +286,10 @@ const timer = createRegex() export const tokensRegex = new RegExp( [ - multiwordIngredient, - singleWordIngredient, - multiwordCookware, - singleWordCookware, - timer, + ingredientWithGroupKeyRegex, + ingredientWithAlternativeRegex, + cookwareRegex, + timerRegex, ] .map((r) => r.source) .join("|"), diff --git a/src/types.ts b/src/types.ts index a523d1c..aaac9dd 100644 --- a/src/types.ts +++ b/src/types.ts @@ -1,5 +1,4 @@ import type { Recipe } from "./classes/recipe"; -import type { Quantity } from "./units"; /** * Represents the metadata of a recipe. @@ -136,7 +135,7 @@ export interface MetadataExtract { */ export interface TextValue { type: "text"; - value: string; + text: string; } /** @@ -145,7 +144,7 @@ export interface TextValue { */ export interface DecimalValue { type: "decimal"; - value: number; + decimal: number; } /** @@ -190,17 +189,6 @@ export interface Range { max: DecimalValue | FractionValue; } -/** - * Represents a contributor to an ingredient's total quantity - * @category Types - */ -export interface QuantityPart extends Quantity { - /** - If _true_, the quantity will scale - * - If _false_, the quantity is fixed - */ - scalable: boolean; -} - /** * Represents a possible state modifier or other flag for an ingredient in a recipe * @category Types @@ -228,6 +216,73 @@ export interface IngredientExtras { path: string; } +/** + * Represents a reference to an alternative ingredient along with its quantities. + * + * Used in {@link IngredientQuantityGroup} to describe what other ingredients + * could be used in place of the main ingredient. + * @category Types + */ +export interface AlternativeIngredientRef { + /** The index of the alternative ingredient within the {@link Recipe.ingredients} array. */ + index: number; + /** The quantities of the alternative ingredient. Multiple entries when units are incompatible. */ + alternativeQuantities?: QuantityWithPlainUnit[]; +} + +/** + * Represents a group of summed quantities for an ingredient, optionally with alternatives. + * Quantities with the same alternative signature are summed together into a single group. + * When units are incompatible, separate IngredientQuantityGroup entries are created instead of merging. + * @category Types + */ +export interface IngredientQuantityGroup { + /** + * References to alternative ingredients for this quantity group. + * If undefined, this group has no alternatives. + */ + alternatives?: AlternativeIngredientRef[]; + /** + * The summed quantity for this group, potentially with equivalents. + * OR groups from addEquivalentsAndSimplify are converted back to QuantityWithPlainUnit + * (first entry as main, rest as equivalents). + */ + groupQuantity: QuantityWithPlainUnit; +} + +/** + * Represents an AND group of quantities when primary units are incompatible but equivalents can be summed. + * For example: 1 large carrot + 2 small carrots, both with cup equivalents that sum to 5 cups. + * @category Types + */ +export interface IngredientQuantityAndGroup { + type: "and"; + /** + * The incompatible primary quantities (e.g., "1 large" and "2 small"). + */ + entries: QuantityWithPlainUnit[]; + /** + * The summed equivalent quantities (e.g., "5 cups" from summing "1.5 cup + 2 cup + 1.5 cup"). + */ + equivalents?: QuantityWithPlainUnit[]; + /** + * References to alternative ingredients for this quantity group. + * If undefined, this group has no alternatives. + */ + alternatives?: AlternativeIngredientRef[]; +} + +/** + * Represents the quantities list for an ingredient as groups. + * Each group contains summed quantities that share the same alternative signature. + * Groups can be either simple (single unit) or AND groups (incompatible primary units with summed equivalents). + * @category Types + */ +export type IngredientQuantities = ( + | IngredientQuantityGroup + | IngredientQuantityAndGroup +)[]; + /** * Represents an ingredient in a recipe. * @category Types @@ -235,14 +290,26 @@ export interface IngredientExtras { export interface Ingredient { /** The name of the ingredient. */ name: string; - /** The quantity of the ingredient. */ - quantity?: FixedValue | Range; - /** The unit of the ingredient. */ - unit?: string; - /** The array of contributors to the ingredient's total quantity. */ - quantityParts?: QuantityPart[]; + /** + * The quantities of the ingredient as they appear in the recipe preparation. + * Only populated for primary ingredients (not alternative-only). + * Each entry represents a single use of the ingredient in the recipe. + * Quantities without alternatives are merged opportunistically when units are compatible. + * Quantities with alternatives are only merged if the alternatives are exactly the same. + */ + quantities?: IngredientQuantities; /** The preparation of the ingredient. */ preparation?: string; + /** The list of indexes of the ingredients mentioned in the preparation as alternatives to this ingredient */ + alternatives?: Set; + /** + * True if this ingredient appears as the primary choice (first in an alternatives list). + * Only primary ingredients have quantities populated directly. + * + * Alternative-only ingredients (usedAsPrimary undefined/false) have their quantities + * available via the {@link Recipe.choices} structure. + */ + usedAsPrimary?: boolean; /** A list of potential state modifiers or other flags for the ingredient */ flags?: IngredientFlag[]; /** The collection of potential additional metadata for the ingredient */ @@ -250,27 +317,61 @@ export interface Ingredient { } /** - * Represents a timer in a recipe. + * Represents a computed ingredient with its total quantity after applying choices. + * Used as the return type of {@link Recipe.calc_ingredient_quantities}. * @category Types */ -export interface Timer { - /** The name of the timer. */ - name?: string; - /** The duration of the timer. */ - duration: FixedValue | Range; - /** The unit of the timer. */ - unit: string; +export interface ComputedIngredient { + /** The name of the ingredient. */ + name: string; + /** The total quantity of the ingredient after applying choices. */ + quantityTotal?: + | QuantityWithPlainUnit + | MaybeNestedGroup; + /** The preparation of the ingredient. */ + preparation?: string; + /** The list of ingredients mentioned in the preparation as alternatives to this ingredient */ + alternatives?: Set; + /** A list of potential state modifiers or other flags for the ingredient */ + flags?: IngredientFlag[]; + /** The collection of potential additional metadata for the ingredient */ + extras?: IngredientExtras; } /** - * Represents a text item in a recipe step. + * Represents a contributor to an ingredient's total quantity, corresponding + * to a single mention in the recipe text. It can contain multiple + * equivalent quantities (e.g., in different units). * @category Types */ -export interface TextItem { - /** The type of the item. */ - type: "text"; - /** The content of the text item. */ - value: string; +export interface IngredientItemQuantity extends QuantityWithExtendedUnit { + /** + * A list of equivalent quantities/units for this ingredient mention besides the primary quantity. + * For `@salt{1%tsp|5%g}`, the main quantity is 1 tsp and the equivalents will contain 5 g. + */ + equivalents?: QuantityWithExtendedUnit[]; + /** Indicates whether this quantity should be scaled when the recipe serving size changes. */ + scalable: boolean; +} + +/** + * Represents a single ingredient choice within a single or a group of `IngredientItem`s. It points + * to a specific ingredient and its corresponding quantity information. + * @category Types + */ +export interface IngredientAlternative { + /** The index of the ingredient within the {@link Recipe.ingredients} array. */ + index: number; + /** The quantity of this specific mention of the ingredient */ + itemQuantity?: IngredientItemQuantity; + /** The alias/name of the ingredient as it should be displayed for this occurrence. */ + displayName: string; + /** An optional note for this specific choice (e.g., "for a vegan version"). */ + note?: string; + /** When {@link Recipe.choices} is populated for alternatives ingredients + * with group keys: the id of the corresponding ingredient item (e.g. "ingredient-item-2"). + * Can be useful for creating alternative selection UI elements with anchor links */ + itemId?: string; } /** @@ -280,14 +381,48 @@ export interface TextItem { export interface IngredientItem { /** The type of the item. */ type: "ingredient"; - /** The index of the ingredient, within the {@link Recipe.ingredients | list of ingredients} */ - index: number; - /** Index of the quantity part corresponding to this item / this occurence - * of the ingredient, which may be referenced elsewhere. */ - quantityPartIndex?: number; - /** The alias/name of the ingredient as it should be displayed in the preparation - * for this occurence */ - displayName: string; + /** The item identifier */ + id: string; + /** + * A list of alternative ingredient choices. For a standard ingredient, + * this array will contain a single element. + */ + alternatives: IngredientAlternative[]; + /** + * An optional identifier for linking distributed alternatives. If multiple + * `IngredientItem`s in a recipe share the same `group` ID (e.g., from + * `@|group|...` syntax), they represent a single logical choice. + */ + group?: string; +} + +/** + * Represents the choices one can make in a recipe + * @category Types + */ +export interface RecipeAlternatives { + /** Map of choices that can be made at Ingredient Item level + * - Keys are the Ingredient Item IDs (e.g. "ingredient-item-2") + * - Values are arrays of IngredientAlternative objects representing the choices available for that item + */ + ingredientItems: Map; + /** Map of choices that can be made for Grouped Ingredient Item's + * - Keys are the Group IDs (e.g. "eggs" for `@|eggs|...`) + * - Values are arrays of IngredientAlternative objects representing the choices available for that group + */ + ingredientGroups: Map; +} + +/** + * Represents the choices to apply when computing ingredient quantities. + * Maps item/group IDs to the index of the selected alternative. + * @category Types + */ +export interface RecipeChoices { + /** Map of choices that can be made at Ingredient Item level */ + ingredientItems?: Map; + /** Map of choices that can be made for Grouped Ingredient Item's */ + ingredientGroups?: Map; } /** @@ -299,9 +434,8 @@ export interface CookwareItem { type: "cookware"; /** The index of the cookware, within the {@link Recipe.cookware | list of cookware} */ index: number; - /** Index of the quantity part corresponding to this item / this occurence - * of the cookware, which may be referenced elsewhere. */ - quantityPartIndex?: number; + /** The quantity of this specific mention of the cookware */ + quantity?: FixedValue | Range; } /** @@ -315,6 +449,30 @@ export interface TimerItem { index: number; } +/** + * Represents a timer in a recipe. + * @category Types + */ +export interface Timer { + /** The name of the timer. */ + name?: string; + /** The duration of the timer. */ + duration: FixedValue | Range; + /** The unit of the timer. */ + unit: string; +} + +/** + * Represents a text item in a recipe step. + * @category Types + */ +export interface TextItem { + /** The type of the item. */ + type: "text"; + /** The content of the text item. */ + value: string; +} + /** * Represents an item in a recipe step. * @category Types @@ -356,10 +514,8 @@ export interface Cookware { name: string; /** The quantity of cookware */ quantity?: FixedValue | Range; - /** The array of contributors to the cookware's total quantity. */ - quantityParts?: (FixedValue | Range)[]; /** A list of potential state modifiers or other flags for the cookware */ - flags: CookwareFlag[]; + flags?: CookwareFlag[]; } /** @@ -367,7 +523,7 @@ export interface Cookware { * @category Types */ export interface CategorizedIngredients { - [category: string]: Ingredient[]; + [category: string]: AddedIngredient[]; } /** @@ -379,6 +535,8 @@ export interface RecipeWithFactor { recipe: Recipe; /** The factor the recipe is scaled by. */ factor: number; + /** The choices for alternative ingredients. */ + choices?: RecipeChoices; } /** @@ -390,6 +548,8 @@ export interface RecipeWithServings { recipe: Recipe; /** The servings the recipe is scaled to */ servings: number; + /** The choices for alternative ingredients. */ + choices?: RecipeChoices; } /** @@ -398,11 +558,25 @@ export interface RecipeWithServings { */ export type AddedRecipe = RecipeWithFactor | RecipeWithServings; +/** + * Options for adding a recipe to a shopping list + * @category Types + */ +export type AddedRecipeOptions = { + /** The scaling option for the recipe. Can be either a factor or a number of servings */ + scaling?: { factor: number } | { servings: number }; + /** The choices for alternative ingredients. */ + choices?: RecipeChoices; +}; + /** * Represents an ingredient that has been added to a shopping list * @category Types */ -export type AddedIngredient = Pick; +export type AddedIngredient = Pick< + ComputedIngredient, + "name" | "quantityTotal" +>; /** * Represents an ingredient in a category. @@ -427,10 +601,32 @@ export interface Category { } /** - * Represents a product option in a {@link ProductCatalog} + * Represents a single size expression for a product (value + optional unit) + * @category Types + */ +export interface ProductSize { + /** The numeric size value */ + size: FixedNumericValue; + /** The unit of the size (optional) */ + unit?: string; +} + +/** + * Represents a normalized size expression for a product + * @category Types + */ +export interface ProductSizeNormalized { + /** The numeric size value (scaled to base unit) */ + size: FixedNumericValue; + /** The resolved unit definition */ + unit: UnitDefinitionLike; +} + +/** + * Core properties for {@link ProductOption} * @category Types */ -export interface ProductOption { +export interface ProductOptionCore { /** The ID of the product */ id: string; /** The name of the product */ @@ -439,17 +635,41 @@ export interface ProductOption { ingredientName: string; /** The aliases of the ingredient it also corresponds to */ ingredientAliases?: string[]; - /** The size of the product. */ - size: FixedNumericValue; - /** The unit of the product size. */ - unit?: string; /** The price of the product */ price: number; - /** Arbitrary additional metadata */ - // eslint-disable-next-line @typescript-eslint/no-explicit-any - [key: string]: any; } +/** + * Base type for {@link ProductOption} allowing arbitrary additional metadata + * @category Types + */ +export type ProductOptionBase = ProductOptionCore & Record; + +/** + * Represents a product option in a {@link ProductCatalog} + * @category Types + */ +export type ProductOption = ProductOptionBase & { + /** The size(s) of the product. Multiple sizes allow equivalent expressions (e.g., "1%dozen" and "12") */ + sizes: ProductSize[]; +}; + +/** + * Core properties for normalized product options + * @category Types + */ +export interface ProductOptionNormalizedCore extends ProductOptionCore { + /** The normalized size(s) of the product with resolved unit definitions */ + sizes: ProductSizeNormalized[]; +} + +/** + * Represents a product option with normalized unit definitions + * @category Types + */ +export type ProductOptionNormalized = ProductOptionNormalizedCore & + Record; + /** * Represents a product option as described in a catalog TOML file * @category Types @@ -457,8 +677,8 @@ export interface ProductOption { export interface ProductOptionToml { /** The name of the product */ name: string; - /** The size and unit of the product separated by % */ - size: string; + /** The size and unit of the product separated by %. Can be an array for multiple equivalent sizes (e.g., ["1%dozen", "12"]) */ + size: string | string[]; /** The price of the product */ price: number; /** Arbitrary additional metadata */ @@ -506,8 +726,9 @@ export type CartMatch = ProductMatch[]; */ export type NoProductMatchErrorCode = | "incompatibleUnits" - | "noProduct" | "textValue" + | "textValue_incompatibleUnits" + | "noProduct" | "noQuantity"; /** @@ -524,3 +745,160 @@ export interface ProductMisMatch { * @category Types */ export type CartMisMatch = ProductMisMatch[]; + +/** + * Represents the type category of a unit used for quantities + * @category Types + */ +export type UnitType = "mass" | "volume" | "count"; +/** + * Represents the measurement system a unit belongs to + * @category Types + */ +export type UnitSystem = "metric" | "imperial"; + +/** + * Represents a unit used to describe quantities + * @category Types + */ +export interface Unit { + name: string; + /** This property is set to true when the unit is prefixed by an `=` sign in the cooklang file, e.g. `=g` + * Indicates that quantities with this unit should be treated as integers only (no decimal/fractional values). */ + integerProtected?: boolean; +} + +/** + * Represents a fully defined unit with conversion and alias information + * @category Types + */ +export interface UnitDefinition extends Unit { + type: UnitType; + system: UnitSystem; + /** e.g. ['gram', 'grams'] */ + aliases: string[]; + /** Conversion factor to the base unit of its type */ + toBase: number; +} + +/** + * Represents a resolved unit definition or a lightweight placeholder for non-standard units + * @category Types + */ +export type UnitDefinitionLike = + | UnitDefinition + | { name: string; type: "other"; system: "none"; integerProtected?: boolean }; + +/** + * Core quantity container holding a fixed value or a range + * @category Types + */ +export interface QuantityBase { + quantity: FixedValue | Range; +} + +/** + * Represents a quantity with an optional plain (string) unit + * @category Types + */ +export interface QuantityWithPlainUnit extends QuantityBase { + unit?: string; + /** Optional equivalent quantities in different units (for alternative units like `@flour{100%g|3.5%oz}`) */ + equivalents?: QuantityWithPlainUnit[]; +} + +/** + * Represents a quantity with an optional extended `Unit` object + * @category Types + */ +export interface QuantityWithExtendedUnit extends QuantityBase { + unit?: Unit; +} + +/** + * Represents a quantity with a resolved unit definition + * @category Types + */ +export interface QuantityWithUnitDef extends QuantityBase { + unit: UnitDefinitionLike; +} + +/** + * Represents any quantity shape supported by the parser (plain, extended, or resolved unit) + * @category Types + */ +export type QuantityWithUnitLike = + | QuantityWithPlainUnit + | QuantityWithExtendedUnit + | QuantityWithUnitDef; + +/** + * Represents a flat "or" group of alternative quantities (for alternative units) + * @category Types + */ +export interface FlatOrGroup { + type: "or"; + entries: T[]; +} +/** + * Represents an "or" group of alternative quantities that may contain nested groups (alternatives with nested structure) + * @category Types + */ +export interface MaybeNestedOrGroup { + type: "or"; + entries: (T | MaybeNestedGroup)[]; +} + +/** + * Represents a flat "and" group of quantities (combined quantities) + * @category Types + */ +export interface FlatAndGroup { + type: "and"; + entries: T[]; +} + +/** + * Represents an "and" group of quantities that may contain nested groups (combinations with nested structure) + * @category Types + */ +export interface MaybeNestedAndGroup { + type: "and"; + entries: (T | MaybeNestedGroup)[]; +} + +/** + * Represents any flat group type ("and" or "or") + * @category Types + */ +export type FlatGroup = + | FlatAndGroup + | FlatOrGroup; +/** + * Represents any group type that may include nested groups + * @category Types + */ +export type MaybeNestedGroup = + | MaybeNestedAndGroup + | MaybeNestedOrGroup; +/** + * Represents any group type (flat or nested) + * @category Types + */ +export type Group = + | MaybeNestedGroup + | FlatGroup; +/** + * Represents any "or" group (flat or nested) + * @category Types + */ +export type OrGroup = + | MaybeNestedOrGroup + | FlatOrGroup; +/** + * Represents any "and" group (flat or nested) + * @category Types + */ +export type AndGroup = + | MaybeNestedAndGroup + | FlatAndGroup; diff --git a/src/units.ts b/src/units.ts deleted file mode 100644 index 94e3584..0000000 --- a/src/units.ts +++ /dev/null @@ -1,451 +0,0 @@ -import type { FixedValue, Range, DecimalValue, FractionValue } from "./types"; -import Big from "big.js"; -export type UnitType = "mass" | "volume" | "count"; -export type UnitSystem = "metric" | "imperial"; - -export interface UnitDefinition { - name: string; // canonical name, e.g. 'g' - type: UnitType; - system: UnitSystem; - aliases: string[]; // e.g. ['gram', 'grams'] - toBase: number; // conversion factor to the base unit of its type -} - -export interface Quantity { - value: FixedValue | Range; - unit?: string; -} - -// Base units: mass -> gram (g), volume -> milliliter (ml) -const units: UnitDefinition[] = [ - // Mass (Metric) - { - name: "g", - type: "mass", - system: "metric", - aliases: ["gram", "grams", "grammes"], - toBase: 1, - }, - { - name: "kg", - type: "mass", - system: "metric", - aliases: ["kilogram", "kilograms", "kilogrammes", "kilos", "kilo"], - toBase: 1000, - }, - // Mass (Imperial) - { - name: "oz", - type: "mass", - system: "imperial", - aliases: ["ounce", "ounces"], - toBase: 28.3495, - }, - { - name: "lb", - type: "mass", - system: "imperial", - aliases: ["pound", "pounds"], - toBase: 453.592, - }, - - // Volume (Metric) - { - name: "ml", - type: "volume", - system: "metric", - aliases: ["milliliter", "milliliters", "millilitre", "millilitres", "cc"], - toBase: 1, - }, - { - name: "cl", - type: "volume", - system: "metric", - aliases: ["centiliter", "centiliters", "centilitre", "centilitres"], - toBase: 10, - }, - { - name: "dl", - type: "volume", - system: "metric", - aliases: ["deciliter", "deciliters", "decilitre", "decilitres"], - toBase: 100, - }, - { - name: "l", - type: "volume", - system: "metric", - aliases: ["liter", "liters", "litre", "litres"], - toBase: 1000, - }, - { - name: "tsp", - type: "volume", - system: "metric", - aliases: ["teaspoon", "teaspoons"], - toBase: 5, - }, - { - name: "tbsp", - type: "volume", - system: "metric", - aliases: ["tablespoon", "tablespoons"], - toBase: 15, - }, - - // Volume (Imperial) - { - name: "fl-oz", - type: "volume", - system: "imperial", - aliases: ["fluid ounce", "fluid ounces"], - toBase: 29.5735, - }, - { - name: "cup", - type: "volume", - system: "imperial", - aliases: ["cups"], - toBase: 236.588, - }, - { - name: "pint", - type: "volume", - system: "imperial", - aliases: ["pints"], - toBase: 473.176, - }, - { - name: "quart", - type: "volume", - system: "imperial", - aliases: ["quarts"], - toBase: 946.353, - }, - { - name: "gallon", - type: "volume", - system: "imperial", - aliases: ["gallons"], - toBase: 3785.41, - }, - - // Count units (no conversion, but recognized as a type) - { - name: "piece", - type: "count", - system: "metric", - aliases: ["pieces", "pc"], - toBase: 1, - }, -]; - -const unitMap = new Map(); -for (const unit of units) { - unitMap.set(unit.name.toLowerCase(), unit); - for (const alias of unit.aliases) { - unitMap.set(alias.toLowerCase(), unit); - } -} - -export function normalizeUnit(unit: string = ""): UnitDefinition | undefined { - return unitMap.get(unit.toLowerCase().trim()); -} - -export class CannotAddTextValueError extends Error { - constructor() { - super("Cannot add a quantity with a text value."); - this.name = "CannotAddTextValueError"; - } -} - -export class IncompatibleUnitsError extends Error { - constructor(unit1: string, unit2: string) { - super( - `Cannot add quantities with incompatible or unknown units: ${unit1} and ${unit2}`, - ); - this.name = "IncompatibleUnitsError"; - } -} - -function gcd(a: number, b: number): number { - return b === 0 ? a : gcd(b, a % b); -} - -export function simplifyFraction( - num: number, - den: number, -): DecimalValue | FractionValue { - if (den === 0) { - throw new Error("Denominator cannot be zero."); - } - - const commonDivisor = gcd(Math.abs(num), Math.abs(den)); - let simplifiedNum = num / commonDivisor; - let simplifiedDen = den / commonDivisor; - if (simplifiedDen < 0) { - simplifiedNum = -simplifiedNum; - simplifiedDen = -simplifiedDen; - } - - if (simplifiedDen === 1) { - return { type: "decimal", value: simplifiedNum }; - } else { - return { type: "fraction", num: simplifiedNum, den: simplifiedDen }; - } -} - -export function getNumericValue(v: DecimalValue | FractionValue): number { - // TODO: rename NumericValue to NumericalValue for all relevant functions - if (v.type === "decimal") { - return v.value; - } - return v.num / v.den; -} - -export function multiplyNumericValue( - v: DecimalValue | FractionValue, - factor: number | Big, -): DecimalValue | FractionValue { - if (v.type === "decimal") { - return { type: "decimal", value: Big(v.value).times(factor).toNumber() }; - } - return simplifyFraction(Big(v.num).times(factor).toNumber(), v.den); -} - -export function addNumericValues( - val1: DecimalValue | FractionValue, - val2: DecimalValue | FractionValue, -): DecimalValue | FractionValue { - let num1: number; - let den1: number; - let num2: number; - let den2: number; - - if (val1.type === "decimal") { - num1 = val1.value; - den1 = 1; - } else { - num1 = val1.num; - den1 = val1.den; - } - - if (val2.type === "decimal") { - num2 = val2.value; - den2 = 1; - } else { - num2 = val2.num; - den2 = val2.den; - } - - // Return 0 if both values are 0 - if (num1 === 0 && num2 === 0) { - return { type: "decimal", value: 0 }; - } - - // We only return a fraction where both input values are fractions themselves or only one while the other is 0 - if ( - (val1.type === "fraction" && val2.type === "fraction") || - (val1.type === "fraction" && val2.type === "decimal" && val2.value === 0) || - (val2.type === "fraction" && val1.type === "decimal" && val1.value === 0) - ) { - const commonDen = den1 * den2; - const sumNum = num1 * den2 + num2 * den1; - return simplifyFraction(sumNum, commonDen); - } else { - return { - type: "decimal", - value: Big(num1).div(den1).add(Big(num2).div(den2)).toNumber(), - }; - } -} - -const toRoundedDecimal = (v: DecimalValue | FractionValue): DecimalValue => { - const value = v.type === "decimal" ? v.value : v.num / v.den; - return { type: "decimal", value: Math.floor(value * 100) / 100 }; -}; - -export function multiplyQuantityValue( - value: FixedValue | Range, - factor: number | Big, -): FixedValue | Range { - if (value.type === "fixed") { - const newValue = multiplyNumericValue( - value.value as DecimalValue | FractionValue, - Big(factor), - ); - if ( - factor === parseInt(factor.toString()) || // e.g. 2 === int - Big(1).div(factor).toNumber() === parseInt(Big(1).div(factor).toString()) // e.g. 0.25 => 4 === int - ) { - // Preserve fractions - return { - type: "fixed", - value: newValue, - }; - } - // We might multiply with big decimal number so rounding into decimal value - return { - type: "fixed", - value: toRoundedDecimal(newValue), - }; - } - - return { - type: "range", - min: multiplyNumericValue(value.min, factor), - max: multiplyNumericValue(value.max, factor), - }; -} - -const convertQuantityValue = ( - value: FixedValue | Range, - def: UnitDefinition, - targetDef: UnitDefinition, -): FixedValue | Range => { - if (def.name === targetDef.name) return value; - - const factor = def.toBase / targetDef.toBase; - - return multiplyQuantityValue(value, factor); -}; - -/** - * Get the default / neutral quantity which can be provided to addQuantity - * for it to return the other value as result - * - * @return zero - */ -export function getDefaultQuantityValue(): FixedValue { - return { type: "fixed", value: { type: "decimal", value: 0 } }; -} - -/** - * Adds two quantity values together. - * - * - Adding two {@link FixedValue}s returns a new {@link FixedValue}. - * - Adding a {@link Range} to any value returns a {@link Range}. - * - * @param v1 - The first quantity value. - * @param v2 - The second quantity value. - * @returns A new quantity value representing the sum. - */ -export function addQuantityValues(v1: FixedValue, v2: FixedValue): FixedValue; -export function addQuantityValues( - v1: FixedValue | Range, - v2: FixedValue | Range, -): Range; - -export function addQuantityValues( - v1: FixedValue | Range, - v2: FixedValue | Range, -): FixedValue | Range { - if ( - (v1.type === "fixed" && v1.value.type === "text") || - (v2.type === "fixed" && v2.value.type === "text") - ) { - throw new CannotAddTextValueError(); - } - - if (v1.type === "fixed" && v2.type === "fixed") { - const res = addNumericValues( - v1.value as DecimalValue | FractionValue, - v2.value as DecimalValue | FractionValue, - ); - return { type: "fixed", value: res }; - } - const r1 = - v1.type === "range" ? v1 : { type: "range", min: v1.value, max: v1.value }; - const r2 = - v2.type === "range" ? v2 : { type: "range", min: v2.value, max: v2.value }; - const newMin = addNumericValues( - r1.min as DecimalValue | FractionValue, - r2.min as DecimalValue | FractionValue, - ); - const newMax = addNumericValues( - r1.max as DecimalValue | FractionValue, - r2.max as DecimalValue | FractionValue, - ); - return { type: "range", min: newMin, max: newMax }; -} - -/** - * Adds two quantities, returning the result in the most appropriate unit. - */ -export function addQuantities(q1: Quantity, q2: Quantity): Quantity { - const v1 = q1.value; - const v2 = q2.value; - - // Case 1: one of the two values is a text, we throw an error we can catch on the other end - if ( - (v1.type === "fixed" && v1.value.type === "text") || - (v2.type === "fixed" && v2.value.type === "text") - ) { - throw new CannotAddTextValueError(); - } - - const unit1Def = normalizeUnit(q1.unit); - const unit2Def = normalizeUnit(q2.unit); - - const addQuantityValuesAndSetUnit = ( - val1: FixedValue | Range, - val2: FixedValue | Range, - unit: string | undefined, - ): Quantity => ({ value: addQuantityValues(val1, val2), unit }); - - // Case 2: one of the two values doesn't have a unit, we preserve its value and consider its unit to be that of the other one - // If at least one of the two units is "", this preserves it versus setting the resulting unit as undefined - if ((q1.unit === "" || q1.unit === undefined) && q2.unit !== undefined) { - return addQuantityValuesAndSetUnit(v1, v2, q2.unit); // Prefer q2's unit - } - if ((q2.unit === "" || q2.unit === undefined) && q1.unit !== undefined) { - return addQuantityValuesAndSetUnit(v1, v2, q1.unit); // Prefer q1's unit - } - - // Case 3: the two quantities have the exact same unit - if ( - (!q1.unit && !q2.unit) || - (q1.unit && q2.unit && q1.unit.toLowerCase() === q2.unit.toLowerCase()) - ) { - return addQuantityValuesAndSetUnit(v1, v2, q1.unit); - } - - // Case 4: the two quantities have different units of known type - if (unit1Def && unit2Def) { - // Case 4.1: different unit type => we can't add quantities - - if (unit1Def.type !== unit2Def.type) { - throw new IncompatibleUnitsError( - `${unit1Def.type} (${q1.unit})`, - `${unit2Def.type} (${q2.unit})`, - ); - } - - let targetUnitDef: UnitDefinition; - - // Case 4.2: same unit type but different system => we convert to metric - if (unit1Def.system !== unit2Def.system) { - const metricUnitDef = unit1Def.system === "metric" ? unit1Def : unit2Def; - targetUnitDef = units - .filter((u) => u.type === metricUnitDef.type && u.system === "metric") - .reduce((prev, current) => - prev.toBase > current.toBase ? prev : current, - ); - } - // Case 4.3: same unit type, same system but different unit => we use the biggest unit of the two - else { - targetUnitDef = unit1Def.toBase >= unit2Def.toBase ? unit1Def : unit2Def; - } - const convertedV1 = convertQuantityValue(v1, unit1Def, targetUnitDef); - const convertedV2 = convertQuantityValue(v2, unit2Def, targetUnitDef); - - return addQuantityValuesAndSetUnit( - convertedV1, - convertedV2, - targetUnitDef.name, - ); - } - - // Case 5: the two quantities have different units of unknown type - throw new IncompatibleUnitsError(q1.unit!, q2.unit!); -} diff --git a/src/units/conversion.ts b/src/units/conversion.ts new file mode 100644 index 0000000..1e9a1de --- /dev/null +++ b/src/units/conversion.ts @@ -0,0 +1,30 @@ +import Big from "big.js"; +import type { QuantityWithUnitDef } from "../types"; +import { getAverageValue } from "../quantities/numeric"; + +export function getUnitRatio(q1: QuantityWithUnitDef, q2: QuantityWithUnitDef) { + const q1Value = getAverageValue(q1.quantity); + const q2Value = getAverageValue(q2.quantity); + const factor = + "toBase" in q1.unit && "toBase" in q2.unit + ? q1.unit.toBase / q2.unit.toBase + : 1; + + if (typeof q1Value !== "number" || typeof q2Value !== "number") { + throw Error( + "One of both values is not a number, so a ratio cannot be computed", + ); + } + return Big(q1Value).times(factor).div(q2Value); +} + +export function getBaseUnitRatio( + q: QuantityWithUnitDef, + qRef: QuantityWithUnitDef, +) { + if ("toBase" in q.unit && "toBase" in qRef.unit) { + return q.unit.toBase / qRef.unit.toBase; + } else { + return 1; + } +} diff --git a/src/units/definitions.ts b/src/units/definitions.ts new file mode 100644 index 0000000..68d0db1 --- /dev/null +++ b/src/units/definitions.ts @@ -0,0 +1,157 @@ +import type { UnitDefinition, UnitDefinitionLike } from "../types"; + +// Base units: mass -> gram (g), volume -> milliliter (ml) +export const units: UnitDefinition[] = [ + // Mass (Metric) + { + name: "g", + type: "mass", + system: "metric", + aliases: ["gram", "grams", "grammes"], + toBase: 1, + }, + { + name: "kg", + type: "mass", + system: "metric", + aliases: ["kilogram", "kilograms", "kilogrammes", "kilos", "kilo"], + toBase: 1000, + }, + // Mass (Imperial) + { + name: "oz", + type: "mass", + system: "imperial", + aliases: ["ounce", "ounces"], + toBase: 28.3495, + }, + { + name: "lb", + type: "mass", + system: "imperial", + aliases: ["pound", "pounds"], + toBase: 453.592, + }, + + // Volume (Metric) + { + name: "ml", + type: "volume", + system: "metric", + aliases: ["milliliter", "milliliters", "millilitre", "millilitres", "cc"], + toBase: 1, + }, + { + name: "cl", + type: "volume", + system: "metric", + aliases: ["centiliter", "centiliters", "centilitre", "centilitres"], + toBase: 10, + }, + { + name: "dl", + type: "volume", + system: "metric", + aliases: ["deciliter", "deciliters", "decilitre", "decilitres"], + toBase: 100, + }, + { + name: "l", + type: "volume", + system: "metric", + aliases: ["liter", "liters", "litre", "litres"], + toBase: 1000, + }, + { + name: "tsp", + type: "volume", + system: "metric", + aliases: ["teaspoon", "teaspoons"], + toBase: 5, + }, + { + name: "tbsp", + type: "volume", + system: "metric", + aliases: ["tablespoon", "tablespoons"], + toBase: 15, + }, + + // Volume (Imperial) + { + name: "fl-oz", + type: "volume", + system: "imperial", + aliases: ["fluid ounce", "fluid ounces"], + toBase: 29.5735, + }, + { + name: "cup", + type: "volume", + system: "imperial", + aliases: ["cups"], + toBase: 236.588, + }, + { + name: "pint", + type: "volume", + system: "imperial", + aliases: ["pints"], + toBase: 473.176, + }, + { + name: "quart", + type: "volume", + system: "imperial", + aliases: ["quarts"], + toBase: 946.353, + }, + { + name: "gallon", + type: "volume", + system: "imperial", + aliases: ["gallons"], + toBase: 3785.41, + }, + + // Count units (no conversion, but recognized as a type) + { + name: "piece", + type: "count", + system: "metric", + aliases: ["pieces", "pc"], + toBase: 1, + }, +]; + +const unitMap = new Map(); +for (const unit of units) { + unitMap.set(unit.name.toLowerCase(), unit); + for (const alias of unit.aliases) { + unitMap.set(alias.toLowerCase(), unit); + } +} + +export function normalizeUnit(unit: string = ""): UnitDefinition | undefined { + return unitMap.get(unit.toLowerCase().trim()); +} + +export const NO_UNIT = "__no-unit__"; + +export function resolveUnit( + name: string = NO_UNIT, + integerProtected: boolean = false, +): UnitDefinitionLike { + const normalizedUnit = normalizeUnit(name); + const resolvedUnit: UnitDefinitionLike = normalizedUnit + ? { ...normalizedUnit, name } + : { name, type: "other", system: "none" }; + return integerProtected + ? { ...resolvedUnit, integerProtected: true } + : resolvedUnit; +} + +export function isNoUnit(unit?: UnitDefinitionLike): boolean { + if (!unit) return true; + return resolveUnit(unit.name).name === NO_UNIT; +} diff --git a/src/units/lookup.ts b/src/units/lookup.ts new file mode 100644 index 0000000..dea5603 --- /dev/null +++ b/src/units/lookup.ts @@ -0,0 +1,48 @@ +import type { + QuantityWithExtendedUnit, + QuantityWithUnitDef, + UnitDefinitionLike, +} from "../types"; +import { resolveUnit } from "./definitions"; + +export function areUnitsCompatible( + u1: UnitDefinitionLike, + u2: UnitDefinitionLike, +): boolean { + if (u1.name === u2.name) { + return true; + } + if (u1.type !== "other" && u1.type === u2.type && u1.system === u2.system) { + return true; + } + return false; +} + +export function findListWithCompatibleQuantity( + list: QuantityWithUnitDef[][], + quantity: QuantityWithExtendedUnit, +) { + const quantityWithUnitDef = { + ...quantity, + unit: resolveUnit(quantity.unit?.name), + }; + return list.find((l) => + l.some((lq) => areUnitsCompatible(lq.unit, quantityWithUnitDef.unit)), + ); +} + +export function findCompatibleQuantityWithinList( + list: QuantityWithUnitDef[], + quantity: QuantityWithExtendedUnit, +): QuantityWithUnitDef | undefined { + const quantityWithUnitDef = { + ...quantity, + unit: resolveUnit(quantity.unit?.name), + }; + return list.find( + (q) => + q.unit.name === quantityWithUnitDef.unit.name || + (q.unit.type === quantityWithUnitDef.unit.type && + q.unit.type !== "other"), + ); +} diff --git a/src/utils/general.ts b/src/utils/general.ts new file mode 100644 index 0000000..1dbd1d5 --- /dev/null +++ b/src/utils/general.ts @@ -0,0 +1,32 @@ +const legacyDeepClone = (v: T): T => { + if (v === null || typeof v !== "object") { + return v; + } + if (v instanceof Map) { + return new Map( + Array.from(v.entries()).map(([k, val]) => [ + legacyDeepClone(k), + legacyDeepClone(val), + ]) + ) as T; + } + if (v instanceof Set) { + return new Set(Array.from(v).map((val: unknown) => legacyDeepClone(val))) as T; + } + if (v instanceof Date) { + return new Date(v.getTime()) as T; + } + if (Array.isArray(v)) { + return v.map((item: unknown) => legacyDeepClone(item)) as T; + } + const cloned = {} as Record; + for (const key of Object.keys(v)) { + cloned[key] = legacyDeepClone((v as Record)[key]); + } + return cloned as T; +}; + +export const deepClone = (v: T): T => + typeof structuredClone === "function" + ? structuredClone(v) + : legacyDeepClone(v); diff --git a/src/parser_helpers.ts b/src/utils/parser_helpers.ts similarity index 73% rename from src/parser_helpers.ts rename to src/utils/parser_helpers.ts index ac20460..4a9f70c 100644 --- a/src/parser_helpers.ts +++ b/src/utils/parser_helpers.ts @@ -6,24 +6,20 @@ import type { TextValue, DecimalValue, FractionValue, -} from "./types"; +} from "../types"; import { metadataRegex, rangeRegex, numberLikeRegex, scalingMetaValueRegex, -} from "./regex"; -import { Section as SectionObject } from "./classes/section"; -import type { Ingredient, Note, Step, Cookware } from "./types"; +} from "../regex"; +import { Section as SectionObject } from "../classes/section"; +import type { Ingredient, Note, Step, Cookware } from "../types"; +import { addQuantityValues } from "../quantities/mutations"; import { - addQuantities, - getDefaultQuantityValue, CannotAddTextValueError, - IncompatibleUnitsError, - Quantity, - addQuantityValues, -} from "./units"; -import { ReferencedItemCannotBeRedefinedError } from "./errors"; + ReferencedItemCannotBeRedefinedError, +} from "../errors"; /** * Pushes a pending note to the section content if it's not empty. @@ -73,13 +69,9 @@ export function findAndUpsertIngredient( ingredients: Ingredient[], newIngredient: Ingredient, isReference: boolean, -): { - ingredientIndex: number; - quantityPartIndex: number | undefined; -} { - const { name, quantity, unit } = newIngredient; +): number { + const { name } = newIngredient; - // New ingredient if (isReference) { const indexFind = ingredients.findIndex( (i) => i.name.toLowerCase() === name.toLowerCase(), @@ -91,75 +83,50 @@ export function findAndUpsertIngredient( ); } - // Ingredient already exists, update it + // Ingredient already exists const existingIngredient = ingredients[indexFind]!; // Checking whether any provided flags are the same as the original ingredient - for (const flag of newIngredient.flags!) { - /* v8 ignore else -- @preserve */ - if (!existingIngredient.flags!.includes(flag)) { + // TODO: backport fix (check on array length) to v2 + if (!newIngredient.flags) { + if ( + Array.isArray(existingIngredient.flags) && + existingIngredient.flags.length > 0 + ) { throw new ReferencedItemCannotBeRedefinedError( "ingredient", existingIngredient.name, - flag, + existingIngredient.flags[0]!, ); } - } - - let quantityPartIndex = undefined; - if (quantity !== undefined) { - const currentQuantity: Quantity = { - value: existingIngredient.quantity ?? getDefaultQuantityValue(), - unit: existingIngredient.unit ?? "", - }; - const newQuantity = { value: quantity, unit: unit ?? "" }; - try { - const total = addQuantities(currentQuantity, newQuantity); - existingIngredient.quantity = total.value; - existingIngredient.unit = total.unit || undefined; - if (existingIngredient.quantityParts) { - existingIngredient.quantityParts.push( - ...newIngredient.quantityParts!, - ); - } else { - existingIngredient.quantityParts = newIngredient.quantityParts; - } - quantityPartIndex = existingIngredient.quantityParts!.length - 1; - } catch (e) { - /* v8 ignore else -- expliciting error types -- @preserve */ + } else { + for (const flag of newIngredient.flags) { + /* v8 ignore else -- @preserve */ if ( - e instanceof IncompatibleUnitsError || - e instanceof CannotAddTextValueError + existingIngredient.flags === undefined || + !existingIngredient.flags.includes(flag) ) { - // Addition not possible, so add as a new ingredient. - return { - ingredientIndex: ingredients.push(newIngredient) - 1, - quantityPartIndex: 0, - }; + throw new ReferencedItemCannotBeRedefinedError( + "ingredient", + existingIngredient.name, + flag, + ); } } } - return { - ingredientIndex: indexFind, - quantityPartIndex, - }; + + return indexFind; } // Not a reference, so add as a new ingredient. - return { - ingredientIndex: ingredients.push(newIngredient) - 1, - quantityPartIndex: newIngredient.quantity ? 0 : undefined, - }; + return ingredients.push(newIngredient) - 1; } export function findAndUpsertCookware( cookware: Cookware[], newCookware: Cookware, isReference: boolean, -): { - cookwareIndex: number; - quantityPartIndex: number | undefined; -} { +): number { const { name, quantity } = newCookware; if (isReference) { @@ -176,59 +143,55 @@ export function findAndUpsertCookware( const existingCookware = cookware[index]!; // Checking whether any provided flags are the same as the original cookware - for (const flag of newCookware.flags) { - /* v8 ignore else -- @preserve */ - if (!existingCookware.flags.includes(flag)) { + // TODO: backport fix (if/else) + check on array length to v2 + if (!newCookware.flags) { + if ( + Array.isArray(existingCookware.flags) && + existingCookware.flags.length > 0 + ) { throw new ReferencedItemCannotBeRedefinedError( "cookware", existingCookware.name, - flag, + existingCookware.flags[0]!, ); } + } else { + for (const flag of newCookware.flags) { + /* v8 ignore else -- @preserve */ + if ( + existingCookware.flags === undefined || + !existingCookware.flags.includes(flag) + ) { + throw new ReferencedItemCannotBeRedefinedError( + "cookware", + existingCookware.name, + flag, + ); + } + } } - let quantityPartIndex = undefined; if (quantity !== undefined) { if (!existingCookware.quantity) { existingCookware.quantity = quantity; - existingCookware.quantityParts = newCookware.quantityParts; - quantityPartIndex = 0; } else { try { existingCookware.quantity = addQuantityValues( existingCookware.quantity, quantity, ); - if (!existingCookware.quantityParts) { - existingCookware.quantityParts = newCookware.quantityParts; - quantityPartIndex = 0; - } else { - quantityPartIndex = - existingCookware.quantityParts.push( - ...newCookware.quantityParts!, - ) - 1; - } } catch (e) { /* v8 ignore else -- expliciting error type -- @preserve */ if (e instanceof CannotAddTextValueError) { - return { - cookwareIndex: cookware.push(newCookware) - 1, - quantityPartIndex: 0, - }; + return cookware.push(newCookware) - 1; } } } } - return { - cookwareIndex: index, - quantityPartIndex, - }; + return index; } - return { - cookwareIndex: cookware.push(newCookware) - 1, - quantityPartIndex: quantity ? 0 : undefined, - }; + return cookware.push(newCookware) - 1; } // Parser when we know the input is either a number-like value @@ -236,7 +199,7 @@ export const parseFixedValue = ( input_str: string, ): TextValue | DecimalValue | FractionValue => { if (!numberLikeRegex.test(input_str)) { - return { type: "text", value: input_str }; + return { type: "text", text: input_str }; } // After this we know that s is either a fraction or a decimal value @@ -253,7 +216,7 @@ export const parseFixedValue = ( } // decimal - return { type: "decimal", value: Number(s) }; + return { type: "decimal", decimal: Number(s) }; }; export function stringifyQuantityValue(quantity: FixedValue | Range): string { @@ -267,7 +230,9 @@ export function stringifyQuantityValue(quantity: FixedValue | Range): string { function stringifyFixedValue(quantity: FixedValue): string { if (quantity.value.type === "fraction") return `${quantity.value.num}/${quantity.value.den}`; - else return String(quantity.value.value); + else if (quantity.value.type === "decimal") + return String(quantity.value.decimal); + else return quantity.value.text; } // TODO: rename to parseQuantityValue @@ -428,3 +393,27 @@ export function extractMetadata(content: string): MetadataExtract { export function isPositiveIntegerString(str: string): boolean { return /^\d+$/.test(str); } + +export function unionOfSets(s1: Set, s2: Set): Set { + const result = new Set(s1); + for (const item of s2) { + result.add(item); + } + return result; +} + +/** + * Returns a canonical string key from sorted alternative indices for grouping quantities. + * Used to determine if two ingredient items have the same alternatives and can be summed together. + * @param alternatives - Array of alternative ingredient references + * @returns A string of sorted indices (e.g., "0,2,5") or null if no alternatives + */ +export function getAlternativeSignature( + alternatives: { index: number }[] | undefined, +): string | null { + if (!alternatives || alternatives.length === 0) return null; + return alternatives + .map((a) => a.index) + .sort((a, b) => a - b) + .join(","); +} diff --git a/src/utils/type_guards.ts b/src/utils/type_guards.ts new file mode 100644 index 0000000..f997d90 --- /dev/null +++ b/src/utils/type_guards.ts @@ -0,0 +1,41 @@ +import type { + Group, + OrGroup, + AndGroup, + QuantityWithUnitLike, + DecimalValue, + FractionValue, + FixedValue, + Range, +} from "../types"; + +// Helper type-checks (as before) +export function isGroup(x: QuantityWithUnitLike | Group): x is Group { + return x && "type" in x; +} +export function isOrGroup(x: QuantityWithUnitLike | Group): x is OrGroup { + return isGroup(x) && x.type === "or"; +} +export function isAndGroup(x: QuantityWithUnitLike | Group): x is AndGroup { + return isGroup(x) && x.type === "and"; +} +export function isQuantity( + x: QuantityWithUnitLike | Group, +): x is QuantityWithUnitLike { + return x && typeof x === "object" && "quantity" in x; +} + +function isNumericValueIntegerLike(v: DecimalValue | FractionValue): boolean { + if (v.type === "decimal") return Number.isInteger(v.decimal); + // fraction: integer-like when numerator divisible by denominator + return v.num % v.den === 0; +} + +export function isValueIntegerLike(q: FixedValue | Range): boolean { + if (q.type === "fixed") { + if (q.value.type === "text") return false; + return isNumericValueIntegerLike(q.value); + } + // Range: integer-like when both min and max are integer-like + return isNumericValueIntegerLike(q.min) && isNumericValueIntegerLike(q.max); +} diff --git a/test/__snapshots__/recipe_parsing.test.ts.snap b/test/__snapshots__/recipe_parsing.test.ts.snap index e313ffd..d7044eb 100644 --- a/test/__snapshots__/recipe_parsing.test.ts.snap +++ b/test/__snapshots__/recipe_parsing.test.ts.snap @@ -11,9 +11,23 @@ exports[`parse function > extracts steps correctly 1`] = ` "value": "Crack the ", }, { - "displayName": "eggs", - "index": 0, - "quantityPartIndex": 0, + "alternatives": [ + { + "displayName": "eggs", + "index": 0, + "itemQuantity": { + "quantity": { + "type": "fixed", + "value": { + "decimal": 3, + "type": "decimal", + }, + }, + "scalable": true, + }, + }, + ], + "id": "ingredient-item-0", "type": "ingredient", }, { @@ -22,7 +36,13 @@ exports[`parse function > extracts steps correctly 1`] = ` }, { "index": 0, - "quantityPartIndex": 0, + "quantity": { + "type": "fixed", + "value": { + "decimal": 1, + "type": "decimal", + }, + }, "type": "cookware", }, { @@ -30,8 +50,13 @@ exports[`parse function > extracts steps correctly 1`] = ` "value": ". Mix with some ", }, { - "displayName": "flour", - "index": 1, + "alternatives": [ + { + "displayName": "flour", + "index": 1, + }, + ], + "id": "ingredient-item-1", "type": "ingredient", }, { @@ -39,8 +64,13 @@ exports[`parse function > extracts steps correctly 1`] = ` "value": " and add ", }, { - "displayName": "coarse salt", - "index": 2, + "alternatives": [ + { + "displayName": "coarse salt", + "index": 2, + }, + ], + "id": "ingredient-item-2", "type": "ingredient", }, { @@ -57,9 +87,26 @@ exports[`parse function > extracts steps correctly 1`] = ` "value": "Melt the ", }, { - "displayName": "butter", - "index": 3, - "quantityPartIndex": 0, + "alternatives": [ + { + "displayName": "butter", + "index": 3, + "itemQuantity": { + "quantity": { + "type": "fixed", + "value": { + "decimal": 50, + "type": "decimal", + }, + }, + "scalable": true, + "unit": { + "name": "g", + }, + }, + }, + ], + "id": "ingredient-item-3", "type": "ingredient", }, { @@ -68,7 +115,6 @@ exports[`parse function > extracts steps correctly 1`] = ` }, { "index": 1, - "quantityPartIndex": undefined, "type": "cookware", }, { @@ -112,544 +158,366 @@ exports[`parse function > extracts steps correctly 1`] = ` exports[`parse function > parses complex recipes correctly 1`] = ` Recipe { + "choices": { + "ingredientGroups": Map {}, + "ingredientItems": Map {}, + }, "cookware": [ { - "flags": [], "name": "Dutch oven", - "quantity": undefined, - "quantityParts": undefined, }, { - "flags": [], "name": "small bowl", - "quantity": undefined, - "quantityParts": undefined, }, { - "flags": [], "name": "13×9-inch baking dish", - "quantity": undefined, - "quantityParts": undefined, }, { - "flags": [], "name": "rubber spatula", - "quantity": undefined, - "quantityParts": undefined, }, { "flags": [ "optional", ], "name": "icing spatula", - "quantity": undefined, - "quantityParts": undefined, }, ], "ingredients": [ { - "flags": [], "name": "lasagne noodles", - "preparation": undefined, - "quantity": { - "type": "fixed", - "value": { - "type": "decimal", - "value": 9, - }, - }, - "quantityParts": [ + "quantities": [ { - "scalable": true, - "unit": undefined, - "value": { - "type": "fixed", - "value": { - "type": "decimal", - "value": 9, - }, + "groupQuantity": { + "quantity": { + "type": "fixed", + "value": { + "decimal": 9, + "type": "decimal", + }, + }, + "unit": undefined, }, }, ], - "unit": undefined, + "usedAsPrimary": true, }, { - "flags": [], "name": "bulk Italian sausage", - "preparation": undefined, - "quantity": { - "type": "fixed", - "value": { - "type": "decimal", - "value": 1.2, - }, - }, - "quantityParts": [ + "quantities": [ { - "scalable": true, - "unit": "lb", - "value": { - "type": "fixed", - "value": { - "type": "decimal", - "value": 1.2, - }, + "groupQuantity": { + "quantity": { + "type": "fixed", + "value": { + "decimal": 1.2, + "type": "decimal", + }, + }, + "unit": "lb", }, }, ], - "unit": "lb", + "usedAsPrimary": true, }, { - "flags": [], "name": "ground beef", - "preparation": undefined, - "quantity": { - "type": "fixed", - "value": { - "den": 4, - "num": 3, - "type": "fraction", - }, - }, - "quantityParts": [ + "quantities": [ { - "scalable": true, - "unit": "lb", - "value": { - "type": "fixed", - "value": { - "den": 4, - "num": 3, - "type": "fraction", - }, + "groupQuantity": { + "quantity": { + "type": "fixed", + "value": { + "den": 4, + "num": 3, + "type": "fraction", + }, + }, + "unit": "lb", }, }, ], - "unit": "lb", + "usedAsPrimary": true, }, { - "flags": [], "name": "onion", "preparation": "medium, diced", - "quantity": { - "type": "fixed", - "value": { - "type": "decimal", - "value": 1, - }, - }, - "quantityParts": [ + "quantities": [ { - "scalable": true, - "unit": undefined, - "value": { - "type": "fixed", - "value": { - "type": "decimal", - "value": 1, - }, + "groupQuantity": { + "quantity": { + "type": "fixed", + "value": { + "decimal": 1, + "type": "decimal", + }, + }, + "unit": undefined, }, }, ], - "unit": undefined, + "usedAsPrimary": true, }, { - "flags": [], "name": "garlic", - "preparation": undefined, - "quantity": { - "type": "fixed", - "value": { - "type": "decimal", - "value": 3, - }, - }, - "quantityParts": [ + "quantities": [ { - "scalable": true, - "unit": "cloves", - "value": { - "type": "fixed", - "value": { - "type": "decimal", - "value": 3, - }, + "groupQuantity": { + "quantity": { + "type": "fixed", + "value": { + "decimal": 3, + "type": "decimal", + }, + }, + "unit": "cloves", }, }, ], - "unit": "cloves", + "usedAsPrimary": true, }, { - "flags": [], "name": "crushed tomatoes", - "preparation": undefined, - "quantity": { - "type": "fixed", - "value": { - "type": "decimal", - "value": 2, - }, - }, - "quantityParts": [ + "quantities": [ { - "scalable": true, - "unit": "cans", - "value": { - "type": "fixed", - "value": { - "type": "decimal", - "value": 2, - }, + "groupQuantity": { + "quantity": { + "type": "fixed", + "value": { + "decimal": 2, + "type": "decimal", + }, + }, + "unit": "cans", }, }, ], - "unit": "cans", + "usedAsPrimary": true, }, { - "flags": [], "name": "tomato paste", - "preparation": undefined, - "quantity": { - "type": "fixed", - "value": { - "type": "decimal", - "value": 2, - }, - }, - "quantityParts": [ + "quantities": [ { - "scalable": true, - "unit": "cans", - "value": { - "type": "fixed", - "value": { - "type": "decimal", - "value": 2, - }, + "groupQuantity": { + "quantity": { + "type": "fixed", + "value": { + "decimal": 2, + "type": "decimal", + }, + }, + "unit": "cans", }, }, ], - "unit": "cans", + "usedAsPrimary": true, }, { - "flags": [], "name": "water", - "preparation": undefined, - "quantity": { - "type": "fixed", - "value": { - "den": 3, - "num": 2, - "type": "fraction", - }, - }, - "quantityParts": [ + "quantities": [ { - "scalable": true, - "unit": "cup", - "value": { - "type": "fixed", - "value": { - "den": 3, - "num": 2, - "type": "fraction", - }, + "groupQuantity": { + "quantity": { + "type": "fixed", + "value": { + "den": 3, + "num": 2, + "type": "fraction", + }, + }, + "unit": "cup", }, }, ], - "unit": "cup", + "usedAsPrimary": true, }, { - "flags": [], "name": "sugar", - "preparation": undefined, - "quantity": { - "type": "fixed", - "value": { - "type": "decimal", - "value": 2, - }, - }, - "quantityParts": [ + "quantities": [ { - "scalable": true, - "unit": "tbsp", - "value": { - "type": "fixed", - "value": { - "type": "decimal", - "value": 2, - }, + "groupQuantity": { + "quantity": { + "type": "fixed", + "value": { + "decimal": 2, + "type": "decimal", + }, + }, + "unit": "tbsp", }, }, ], - "unit": "tbsp", + "usedAsPrimary": true, }, { - "flags": [], "name": "fresh parsley", "preparation": "minced, divided", - "quantity": { - "type": "fixed", - "value": { - "type": "decimal", - "value": 0.09, - }, - }, - "quantityParts": [ + "quantities": [ { - "scalable": true, - "unit": "tbsp", - "value": { - "type": "fixed", - "value": { - "type": "decimal", - "value": 3, - }, - }, - }, - { - "scalable": true, - "unit": "cup", - "value": { - "type": "fixed", - "value": { - "den": 4, - "num": 1, - "type": "fraction", - }, + "groupQuantity": { + "quantity": { + "type": "fixed", + "value": { + "decimal": 0.104, + "type": "decimal", + }, + }, + "unit": "l", }, }, ], - "unit": "l", + "usedAsPrimary": true, }, { - "flags": [], "name": "dried basil", - "preparation": undefined, - "quantity": { - "type": "fixed", - "value": { - "type": "decimal", - "value": 2, - }, - }, - "quantityParts": [ + "quantities": [ { - "scalable": true, - "unit": "tsp", - "value": { - "type": "fixed", - "value": { - "type": "decimal", - "value": 2, - }, + "groupQuantity": { + "quantity": { + "type": "fixed", + "value": { + "decimal": 2, + "type": "decimal", + }, + }, + "unit": "tsp", }, }, ], - "unit": "tsp", + "usedAsPrimary": true, }, { - "flags": [], "name": "fennel seed", - "preparation": undefined, - "quantity": { - "type": "fixed", - "value": { - "den": 4, - "num": 3, - "type": "fraction", - }, - }, - "quantityParts": [ + "quantities": [ { - "scalable": true, - "unit": "tsp", - "value": { - "type": "fixed", - "value": { - "den": 4, - "num": 3, - "type": "fraction", - }, + "groupQuantity": { + "quantity": { + "type": "fixed", + "value": { + "den": 4, + "num": 3, + "type": "fraction", + }, + }, + "unit": "tsp", }, }, ], - "unit": "tsp", + "usedAsPrimary": true, }, { - "flags": [], "name": "salt", - "preparation": undefined, - "quantity": { - "type": "fixed", - "value": { - "den": 4, - "num": 3, - "type": "fraction", - }, - }, - "quantityParts": [ + "quantities": [ { - "scalable": true, - "unit": "tsp", - "value": { - "type": "fixed", - "value": { - "den": 2, - "num": 1, - "type": "fraction", - }, - }, - }, - { - "scalable": true, - "unit": "tsp", - "value": { - "type": "fixed", - "value": { - "den": 4, - "num": 1, - "type": "fraction", - }, + "groupQuantity": { + "quantity": { + "type": "fixed", + "value": { + "den": 4, + "num": 3, + "type": "fraction", + }, + }, + "unit": "tsp", }, }, ], - "unit": "tsp", + "usedAsPrimary": true, }, { - "flags": [], "name": "pepper", "preparation": "coarsely ground", - "quantity": { - "type": "fixed", - "value": { - "den": 4, - "num": 1, - "type": "fraction", - }, - }, - "quantityParts": [ + "quantities": [ { - "scalable": true, - "unit": "tsp", - "value": { - "type": "fixed", - "value": { - "den": 4, - "num": 1, - "type": "fraction", - }, + "groupQuantity": { + "quantity": { + "type": "fixed", + "value": { + "den": 4, + "num": 1, + "type": "fraction", + }, + }, + "unit": "tsp", }, }, ], - "unit": "tsp", + "usedAsPrimary": true, }, { - "flags": [], "name": "egg", "preparation": "large, lightly beaten", - "quantity": { - "type": "fixed", - "value": { - "type": "decimal", - "value": 1, - }, - }, - "quantityParts": [ + "quantities": [ { - "scalable": true, - "unit": undefined, - "value": { - "type": "fixed", - "value": { - "type": "decimal", - "value": 1, - }, + "groupQuantity": { + "quantity": { + "type": "fixed", + "value": { + "decimal": 1, + "type": "decimal", + }, + }, + "unit": undefined, }, }, ], - "unit": undefined, + "usedAsPrimary": true, }, { - "flags": [], "name": "ricotta cheese", - "preparation": undefined, - "quantity": { - "type": "fixed", - "value": { - "type": "decimal", - "value": 1, - }, - }, - "quantityParts": [ + "quantities": [ { - "scalable": true, - "unit": "carton", - "value": { - "type": "fixed", - "value": { - "type": "decimal", - "value": 1, - }, + "groupQuantity": { + "quantity": { + "type": "fixed", + "value": { + "decimal": 1, + "type": "decimal", + }, + }, + "unit": "carton", }, }, ], - "unit": "carton", + "usedAsPrimary": true, }, { - "flags": [], "name": "mozzarella", "preparation": "shredded", - "quantity": { - "type": "fixed", - "value": { - "type": "decimal", - "value": 4, - }, - }, - "quantityParts": [ + "quantities": [ { - "scalable": true, - "unit": "cups", - "value": { - "type": "fixed", - "value": { - "type": "decimal", - "value": 4, - }, + "groupQuantity": { + "quantity": { + "type": "fixed", + "value": { + "decimal": 4, + "type": "decimal", + }, + }, + "unit": "cups", }, }, ], - "unit": "cups", + "usedAsPrimary": true, }, { - "flags": [], "name": "Parmesan", "preparation": "grated", - "quantity": { - "type": "fixed", - "value": { - "den": 4, - "num": 3, - "type": "fraction", - }, - }, - "quantityParts": [ + "quantities": [ { - "scalable": true, - "unit": "cup", - "value": { - "type": "fixed", - "value": { - "den": 4, - "num": 3, - "type": "fraction", - }, + "groupQuantity": { + "quantity": { + "type": "fixed", + "value": { + "den": 4, + "num": 3, + "type": "fraction", + }, + }, + "unit": "cup", }, }, ], - "unit": "cup", + "usedAsPrimary": true, }, ], "metadata": { @@ -676,9 +544,23 @@ Recipe { "value": "Bring a large pot of salted water to a boil. Cook the ", }, { - "displayName": "lasagne noodles", - "index": 0, - "quantityPartIndex": 0, + "alternatives": [ + { + "displayName": "lasagne noodles", + "index": 0, + "itemQuantity": { + "quantity": { + "type": "fixed", + "value": { + "decimal": 9, + "type": "decimal", + }, + }, + "scalable": true, + }, + }, + ], + "id": "ingredient-item-0", "type": "ingredient", }, { @@ -696,7 +578,6 @@ Recipe { }, { "index": 0, - "quantityPartIndex": undefined, "type": "cookware", }, { @@ -704,9 +585,26 @@ Recipe { "value": " over medium heat. Add the ", }, { - "displayName": "bulk Italian sausage", - "index": 1, - "quantityPartIndex": 0, + "alternatives": [ + { + "displayName": "bulk Italian sausage", + "index": 1, + "itemQuantity": { + "quantity": { + "type": "fixed", + "value": { + "decimal": 1.2, + "type": "decimal", + }, + }, + "scalable": true, + "unit": { + "name": "lb", + }, + }, + }, + ], + "id": "ingredient-item-1", "type": "ingredient", }, { @@ -714,9 +612,27 @@ Recipe { "value": ", ", }, { - "displayName": "ground beef", - "index": 2, - "quantityPartIndex": 0, + "alternatives": [ + { + "displayName": "ground beef", + "index": 2, + "itemQuantity": { + "quantity": { + "type": "fixed", + "value": { + "den": 4, + "num": 3, + "type": "fraction", + }, + }, + "scalable": true, + "unit": { + "name": "lb", + }, + }, + }, + ], + "id": "ingredient-item-2", "type": "ingredient", }, { @@ -724,9 +640,23 @@ Recipe { "value": " and diced ", }, { - "displayName": "onion", - "index": 3, - "quantityPartIndex": 0, + "alternatives": [ + { + "displayName": "onion", + "index": 3, + "itemQuantity": { + "quantity": { + "type": "fixed", + "value": { + "decimal": 1, + "type": "decimal", + }, + }, + "scalable": true, + }, + }, + ], + "id": "ingredient-item-3", "type": "ingredient", }, { @@ -742,9 +672,26 @@ Recipe { "value": " or until the meat is no longer pink, breaking up the meat with a large spoon as it cooks to create gorgeous crumbles. Add the ", }, { - "displayName": "garlic", - "index": 4, - "quantityPartIndex": 0, + "alternatives": [ + { + "displayName": "garlic", + "index": 4, + "itemQuantity": { + "quantity": { + "type": "fixed", + "value": { + "decimal": 3, + "type": "decimal", + }, + }, + "scalable": true, + "unit": { + "name": "cloves", + }, + }, + }, + ], + "id": "ingredient-item-4", "type": "ingredient", }, { @@ -778,9 +725,26 @@ Recipe { "value": "Drain the mixture in the Dutch oven. Then, add the remaining sauce ingredients: ", }, { - "displayName": "crushed tomatoes", - "index": 5, - "quantityPartIndex": 0, + "alternatives": [ + { + "displayName": "crushed tomatoes", + "index": 5, + "itemQuantity": { + "quantity": { + "type": "fixed", + "value": { + "decimal": 2, + "type": "decimal", + }, + }, + "scalable": true, + "unit": { + "name": "cans", + }, + }, + }, + ], + "id": "ingredient-item-5", "type": "ingredient", }, { @@ -788,9 +752,26 @@ Recipe { "value": ", ", }, { - "displayName": "tomato paste", - "index": 6, - "quantityPartIndex": 0, + "alternatives": [ + { + "displayName": "tomato paste", + "index": 6, + "itemQuantity": { + "quantity": { + "type": "fixed", + "value": { + "decimal": 2, + "type": "decimal", + }, + }, + "scalable": true, + "unit": { + "name": "cans", + }, + }, + }, + ], + "id": "ingredient-item-6", "type": "ingredient", }, { @@ -798,9 +779,27 @@ Recipe { "value": ", ", }, { - "displayName": "water", - "index": 7, - "quantityPartIndex": 0, + "alternatives": [ + { + "displayName": "water", + "index": 7, + "itemQuantity": { + "quantity": { + "type": "fixed", + "value": { + "den": 3, + "num": 2, + "type": "fraction", + }, + }, + "scalable": true, + "unit": { + "name": "cup", + }, + }, + }, + ], + "id": "ingredient-item-7", "type": "ingredient", }, { @@ -808,9 +807,26 @@ Recipe { "value": ", ", }, { - "displayName": "sugar", - "index": 8, - "quantityPartIndex": 0, + "alternatives": [ + { + "displayName": "sugar", + "index": 8, + "itemQuantity": { + "quantity": { + "type": "fixed", + "value": { + "decimal": 2, + "type": "decimal", + }, + }, + "scalable": true, + "unit": { + "name": "tbsp", + }, + }, + }, + ], + "id": "ingredient-item-8", "type": "ingredient", }, { @@ -818,9 +834,26 @@ Recipe { "value": ", ", }, { - "displayName": "fresh parsley", - "index": 9, - "quantityPartIndex": 0, + "alternatives": [ + { + "displayName": "fresh parsley", + "index": 9, + "itemQuantity": { + "quantity": { + "type": "fixed", + "value": { + "decimal": 3, + "type": "decimal", + }, + }, + "scalable": true, + "unit": { + "name": "tbsp", + }, + }, + }, + ], + "id": "ingredient-item-9", "type": "ingredient", }, { @@ -828,9 +861,26 @@ Recipe { "value": ", ", }, { - "displayName": "dried basil", - "index": 10, - "quantityPartIndex": 0, + "alternatives": [ + { + "displayName": "dried basil", + "index": 10, + "itemQuantity": { + "quantity": { + "type": "fixed", + "value": { + "decimal": 2, + "type": "decimal", + }, + }, + "scalable": true, + "unit": { + "name": "tsp", + }, + }, + }, + ], + "id": "ingredient-item-10", "type": "ingredient", }, { @@ -838,9 +888,27 @@ Recipe { "value": ", ", }, { - "displayName": "fennel seed", - "index": 11, - "quantityPartIndex": 0, + "alternatives": [ + { + "displayName": "fennel seed", + "index": 11, + "itemQuantity": { + "quantity": { + "type": "fixed", + "value": { + "den": 4, + "num": 3, + "type": "fraction", + }, + }, + "scalable": true, + "unit": { + "name": "tsp", + }, + }, + }, + ], + "id": "ingredient-item-11", "type": "ingredient", }, { @@ -848,9 +916,27 @@ Recipe { "value": ", ", }, { - "displayName": "salt", - "index": 12, - "quantityPartIndex": 0, + "alternatives": [ + { + "displayName": "salt", + "index": 12, + "itemQuantity": { + "quantity": { + "type": "fixed", + "value": { + "den": 2, + "num": 1, + "type": "fraction", + }, + }, + "scalable": true, + "unit": { + "name": "tsp", + }, + }, + }, + ], + "id": "ingredient-item-12", "type": "ingredient", }, { @@ -858,9 +944,27 @@ Recipe { "value": " and ", }, { - "displayName": "pepper", - "index": 13, - "quantityPartIndex": 0, + "alternatives": [ + { + "displayName": "pepper", + "index": 13, + "itemQuantity": { + "quantity": { + "type": "fixed", + "value": { + "den": 4, + "num": 1, + "type": "fraction", + }, + }, + "scalable": true, + "unit": { + "name": "tsp", + }, + }, + }, + ], + "id": "ingredient-item-13", "type": "ingredient", }, { @@ -900,7 +1004,6 @@ Recipe { }, { "index": 1, - "quantityPartIndex": undefined, "type": "cookware", }, { @@ -908,9 +1011,23 @@ Recipe { "value": ", whisk together the ", }, { - "displayName": "egg", - "index": 14, - "quantityPartIndex": 0, + "alternatives": [ + { + "displayName": "egg", + "index": 14, + "itemQuantity": { + "quantity": { + "type": "fixed", + "value": { + "decimal": 1, + "type": "decimal", + }, + }, + "scalable": true, + }, + }, + ], + "id": "ingredient-item-14", "type": "ingredient", }, { @@ -918,9 +1035,26 @@ Recipe { "value": ", ", }, { - "displayName": "ricotta cheese", - "index": 15, - "quantityPartIndex": 0, + "alternatives": [ + { + "displayName": "ricotta cheese", + "index": 15, + "itemQuantity": { + "quantity": { + "type": "fixed", + "value": { + "decimal": 1, + "type": "decimal", + }, + }, + "scalable": true, + "unit": { + "name": "carton", + }, + }, + }, + ], + "id": "ingredient-item-15", "type": "ingredient", }, { @@ -928,9 +1062,27 @@ Recipe { "value": ", and remaining ", }, { - "displayName": "fresh parsley", - "index": 9, - "quantityPartIndex": 1, + "alternatives": [ + { + "displayName": "fresh parsley", + "index": 9, + "itemQuantity": { + "quantity": { + "type": "fixed", + "value": { + "den": 4, + "num": 1, + "type": "fraction", + }, + }, + "scalable": true, + "unit": { + "name": "cup", + }, + }, + }, + ], + "id": "ingredient-item-16", "type": "ingredient", }, { @@ -938,9 +1090,27 @@ Recipe { "value": " and ", }, { - "displayName": "salt", - "index": 12, - "quantityPartIndex": 1, + "alternatives": [ + { + "displayName": "salt", + "index": 12, + "itemQuantity": { + "quantity": { + "type": "fixed", + "value": { + "den": 4, + "num": 1, + "type": "fraction", + }, + }, + "scalable": true, + "unit": { + "name": "tsp", + }, + }, + }, + ], + "id": "ingredient-item-17", "type": "ingredient", }, { @@ -957,9 +1127,26 @@ Recipe { "value": "If you haven’t already, grate the ", }, { - "displayName": "mozzarella", - "index": 16, - "quantityPartIndex": 0, + "alternatives": [ + { + "displayName": "mozzarella", + "index": 16, + "itemQuantity": { + "quantity": { + "type": "fixed", + "value": { + "decimal": 4, + "type": "decimal", + }, + }, + "scalable": true, + "unit": { + "name": "cups", + }, + }, + }, + ], + "id": "ingredient-item-18", "type": "ingredient", }, { @@ -967,9 +1154,27 @@ Recipe { "value": " and ", }, { - "displayName": "Parmesan", - "index": 17, - "quantityPartIndex": 0, + "alternatives": [ + { + "displayName": "Parmesan", + "index": 17, + "itemQuantity": { + "quantity": { + "type": "fixed", + "value": { + "den": 4, + "num": 3, + "type": "fraction", + }, + }, + "scalable": true, + "unit": { + "name": "cup", + }, + }, + }, + ], + "id": "ingredient-item-19", "type": "ingredient", }, { @@ -1005,7 +1210,6 @@ Recipe { }, { "index": 2, - "quantityPartIndex": undefined, "type": "cookware", }, { @@ -1014,7 +1218,6 @@ Recipe { }, { "index": 3, - "quantityPartIndex": undefined, "type": "cookware", }, { @@ -1023,7 +1226,6 @@ Recipe { }, { "index": 4, - "quantityPartIndex": undefined, "type": "cookware", }, { @@ -1108,8 +1310,8 @@ Recipe { "duration": { "type": "fixed", "value": { + "decimal": 10, "type": "decimal", - "value": 10, }, }, "name": undefined, @@ -1119,8 +1321,8 @@ Recipe { "duration": { "type": "fixed", "value": { + "decimal": 1, "type": "decimal", - "value": 1, }, }, "name": undefined, @@ -1130,8 +1332,8 @@ Recipe { "duration": { "type": "fixed", "value": { + "decimal": 30, "type": "decimal", - "value": 30, }, }, "name": undefined, @@ -1141,8 +1343,8 @@ Recipe { "duration": { "type": "fixed", "value": { + "decimal": 25, "type": "decimal", - "value": 25, }, }, "name": undefined, @@ -1152,8 +1354,8 @@ Recipe { "duration": { "type": "fixed", "value": { + "decimal": 25, "type": "decimal", - "value": 25, }, }, "name": undefined, @@ -1163,8 +1365,8 @@ Recipe { "duration": { "type": "fixed", "value": { + "decimal": 15, "type": "decimal", - "value": 15, }, }, "name": undefined, @@ -1189,9 +1391,26 @@ exports[`parse function > parses notes correctly 1`] = ` "value": "Add ", }, { - "displayName": "flour", - "index": 0, - "quantityPartIndex": 0, + "alternatives": [ + { + "displayName": "flour", + "index": 0, + "itemQuantity": { + "quantity": { + "type": "fixed", + "value": { + "decimal": 100, + "type": "decimal", + }, + }, + "scalable": true, + "unit": { + "name": "g", + }, + }, + }, + ], + "id": "ingredient-item-0", "type": "ingredient", }, { diff --git a/test/fixtures/recipes.ts b/test/fixtures/recipes.ts index 44633c3..0d15a86 100644 --- a/test/fixtures/recipes.ts +++ b/test/fixtures/recipes.ts @@ -107,3 +107,42 @@ Mix @flour{50%g}, @butter{25%g} and @eggs{1} Add @pepper{1%tsp} and @spices{1%pinch} `; + +export const recipeForShoppingList3 = ` +--- +servings: 1 +--- +Add @eggs{1%dozen} and @&eggs{1%half dozen} +`; + +export const recipeForShoppingList4 = ` +--- +servings: 1 +--- +Add @carrots{3%large} and @&carrots{2%small} +`; + +export const recipeWithInlineAlternatives = ` +--- +servings: 1 +--- +Mix @milk{200%ml}|almond milk{100%ml}[vegan version]|soy milk{150%ml}[another vegan option]`; + +export const recipeWithGroupedAlternatives = ` +--- +servings: 1 +--- +Mix @|milk|milk{200%ml} or @|milk|almond milk{100%ml} or @|milk|soy milk{150%ml} for a vegan version +`; + +export const recipeWithComplexAlternatives = ` +Use @|dough|./dough{1}(defrosted) or @|dough|@store-bought dough{1} to make the base. +`; + +export const recipeToScaleWithAlternatives = ` +--- +servings: 2 +--- + +Use @sugar{100%g}|brown sugar{100%g}[for a richer flavor] in the mix. +`; diff --git a/test/mocks/quantity.ts b/test/mocks/quantity.ts new file mode 100644 index 0000000..4d49b45 --- /dev/null +++ b/test/mocks/quantity.ts @@ -0,0 +1,48 @@ +import type { + QuantityWithExtendedUnit, + QuantityWithPlainUnit, + QuantityWithUnitDef, +} from "../../src/types"; +import { resolveUnit } from "../../src/units/definitions"; + +// Minimal mock for Quantity and FixedValue for testing +export const q = ( + amount: number, + unit?: string, + integerProtected?: boolean, +): QuantityWithExtendedUnit => { + const quantity: QuantityWithExtendedUnit = { + quantity: { type: "fixed", value: { type: "decimal", decimal: amount } }, + }; + if (unit) { + quantity.unit = { name: unit }; + if (integerProtected) { + quantity.unit.integerProtected = integerProtected; + } + } + return quantity; +}; + +// Minimal mock for Quantity and FixedValue for testing +export const qPlain = ( + amount: number, + unit?: string, +): QuantityWithPlainUnit => { + const quantity: QuantityWithPlainUnit = { + quantity: { type: "fixed", value: { type: "decimal", decimal: amount } }, + }; + if (unit) { + quantity.unit = unit; + } + return quantity; +}; + +export const qWithUnitDef = ( + amount: number, + unit?: string, + integerProtected?: boolean, +): QuantityWithUnitDef => { + const quantity = q(amount, unit, integerProtected); + const resolvedUnit = resolveUnit(quantity.unit?.name, integerProtected); + return { ...quantity, unit: resolvedUnit } as QuantityWithUnitDef; +}; diff --git a/test/parser_helpers.test.ts b/test/parser_helpers.test.ts index 5be19e1..fb1bb2b 100644 --- a/test/parser_helpers.test.ts +++ b/test/parser_helpers.test.ts @@ -13,7 +13,9 @@ import { findAndUpsertCookware, findAndUpsertIngredient, stringifyQuantityValue, -} from "../src/parser_helpers"; + unionOfSets, +} from "../src/utils/parser_helpers"; +import { ReferencedItemCannotBeRedefinedError } from "../src/errors"; describe("parseSimpleMetaVar", () => { it("should parse canonical string vars", () => { @@ -302,10 +304,7 @@ describe("findAndUpsertCookware", () => { it("should correctly add a non-referenced cookware", () => { const cookware: Cookware[] = [{ name: "oven", flags: [] }]; const newCookware: Cookware = { name: "pan", flags: [] }; - expect(findAndUpsertCookware(cookware, newCookware, false)).toEqual({ - cookwareIndex: 1, - quantityPartIndex: undefined, - }); + expect(findAndUpsertCookware(cookware, newCookware, false)).toBe(1); expect(cookware.length).toEqual(2); }); @@ -313,44 +312,34 @@ describe("findAndUpsertCookware", () => { const cookware: Cookware[] = [{ name: "oven", flags: [] }]; const newCookware: Cookware = { name: "oven", - quantity: { type: "fixed", value: { type: "decimal", value: 1 } }, - quantityParts: [{ type: "fixed", value: { type: "decimal", value: 1 } }], + quantity: { type: "fixed", value: { type: "decimal", decimal: 1 } }, flags: [], }; - expect(findAndUpsertCookware(cookware, newCookware, true)).toEqual({ - cookwareIndex: 0, - quantityPartIndex: 0, - }); + expect(findAndUpsertCookware(cookware, newCookware, true)).toBe(0); expect(cookware.length).toBe(1); expect(cookware[0]!.quantity).toEqual({ type: "fixed", - value: { type: "decimal", value: 1 }, + value: { type: "decimal", decimal: 1 }, }); - expect(cookware[0]!.quantityParts).toEqual([ - { - type: "fixed", - value: { type: "decimal", value: 1 }, - }, - ]); }); it("should add quantities of referenced cookware", () => { const cookware: Cookware[] = [ { name: "oven", - quantity: { type: "fixed", value: { type: "decimal", value: 1 } }, + quantity: { type: "fixed", value: { type: "decimal", decimal: 1 } }, flags: [], }, ]; const newCookware: Cookware = { name: "oven", - quantity: { type: "fixed", value: { type: "decimal", value: 2 } }, + quantity: { type: "fixed", value: { type: "decimal", decimal: 2 } }, flags: [], }; findAndUpsertCookware(cookware, newCookware, true); expect(cookware[0]!.quantity).toEqual({ type: "fixed", - value: { type: "decimal", value: 3 }, + value: { type: "decimal", decimal: 3 }, }); }); @@ -358,14 +347,13 @@ describe("findAndUpsertCookware", () => { const cookware: Cookware[] = [ { name: "oven", - quantity: { type: "fixed", value: { type: "text", value: "one" } }, + quantity: { type: "fixed", value: { type: "text", text: "one" } }, flags: [], }, ]; const newCookware: Cookware = { name: "oven", - quantity: { type: "fixed", value: { type: "decimal", value: 1 } }, - flags: [], + quantity: { type: "fixed", value: { type: "decimal", decimal: 1 } }, }; findAndUpsertCookware(cookware, newCookware, true); expect(cookware.length).toEqual(2); @@ -377,6 +365,28 @@ describe("findAndUpsertCookware", () => { "Referenced cookware \"unreferenced-cookware\" not found. A referenced cookware must be declared before being referenced with '&'.", ); }); + + it("should throw an error if flags differ in referenced cookware", () => { + const cookware: Cookware[] = [ + { + name: "oven", + quantity: { type: "fixed", value: { type: "decimal", decimal: 1 } }, + flags: ["hidden"], + }, + ]; + const newCookware: Cookware = { + name: "oven", + quantity: { type: "fixed", value: { type: "decimal", decimal: 1 } }, + }; + + expect(() => findAndUpsertCookware(cookware, newCookware, true)).toThrow( + ReferencedItemCannotBeRedefinedError, + ); + newCookware.flags = ["optional"]; + expect(() => findAndUpsertCookware(cookware, newCookware, true)).toThrow( + ReferencedItemCannotBeRedefinedError, + ); + }); }); describe("findAndUpsertIngredient", () => { @@ -384,176 +394,172 @@ describe("findAndUpsertIngredient", () => { const ingredients: Ingredient[] = []; const newIngredient: Ingredient = { name: "eggs", - quantity: { type: "fixed", value: { type: "decimal", value: 1 } }, - quantityParts: [ + quantities: [ { - value: { type: "fixed", value: { type: "decimal", value: 1 } }, - scalable: true, + groupQuantity: { + quantity: { type: "fixed", value: { type: "decimal", decimal: 1 } }, + }, }, ], - flags: [], }; - expect(findAndUpsertIngredient(ingredients, newIngredient, false)).toEqual({ - ingredientIndex: 0, - quantityPartIndex: 0, - }); + expect(findAndUpsertIngredient(ingredients, newIngredient, false)).toEqual( + 0, + ); expect(ingredients).toEqual([newIngredient]); }); - it("should not return a quantityPartIndex for a non-quantified ingredient", () => { - const ingredients: Ingredient[] = []; - const newIngredient: Ingredient = { - name: "flour", - flags: [], - quantity: undefined, - unit: undefined, - quantityParts: undefined, - preparation: undefined, - }; - expect(findAndUpsertIngredient(ingredients, newIngredient, false)).toEqual({ - ingredientIndex: 0, - quantityPartIndex: undefined, - }); - }); - it("should correctly add a referenced ingredient", () => { const ingredients: Ingredient[] = [ { name: "eggs", - quantity: { type: "fixed", value: { type: "decimal", value: 1 } }, - quantityParts: [ + quantities: [ { - value: { type: "fixed", value: { type: "decimal", value: 1 } }, - scalable: true, + groupQuantity: { + quantity: { + type: "fixed", + value: { type: "decimal", decimal: 1 }, + }, + }, }, ], - flags: [], }, ]; const newIngredient: Ingredient = { name: "eggs", - quantity: { type: "fixed", value: { type: "decimal", value: 2 } }, - quantityParts: [ + quantities: [ { - value: { type: "fixed", value: { type: "decimal", value: 2 } }, - scalable: true, + groupQuantity: { + quantity: { type: "fixed", value: { type: "decimal", decimal: 2 } }, + }, }, ], - flags: [], }; - expect(findAndUpsertIngredient(ingredients, newIngredient, true)).toEqual({ - ingredientIndex: 0, - quantityPartIndex: 1, - }); - expect(ingredients[0]!.quantity).toEqual({ - type: "fixed", - value: { type: "decimal", value: 3 }, - }); + expect(findAndUpsertIngredient(ingredients, newIngredient, true)).toEqual( + 0, + ); + expect(ingredients[0]!.quantities).toEqual([ + { + groupQuantity: { + quantity: { type: "fixed", value: { type: "decimal", decimal: 1 } }, + }, + }, + ]); - const ingredients_noqtt: Ingredient[] = [{ name: "salt", flags: [] }]; - const newIngredient_noqtt: Ingredient = { name: "salt", flags: [] }; + const ingredients_noqtt: Ingredient[] = [{ name: "salt" }]; + const newIngredient_noqtt: Ingredient = { name: "salt" }; expect( findAndUpsertIngredient(ingredients_noqtt, newIngredient_noqtt, true), - ).toEqual({ - ingredientIndex: 0, - quantityPartIndex: undefined, - }); - expect(ingredients_noqtt[0]!.quantity).toBe(undefined); + ).toEqual(0); + expect(ingredients_noqtt[0]!.quantities).toBe(undefined); }); - it("should insert new ingredient if referenced ingredient has a text quantity", () => { + it("should return index of referenced ingredient even if it has a text quantity", () => { const ingredients: Ingredient[] = [ { name: "eggs", - quantity: { type: "fixed", value: { type: "text", value: "one" } }, - quantityParts: [ + quantities: [ { - value: { type: "fixed", value: { type: "text", value: "one" } }, - scalable: true, + groupQuantity: { + quantity: { type: "fixed", value: { type: "text", text: "one" } }, + }, }, ], - flags: [], }, ]; const newIngredient: Ingredient = { name: "eggs", - quantity: { type: "fixed", value: { type: "decimal", value: 1 } }, - quantityParts: [ + quantities: [ { - value: { type: "fixed", value: { type: "decimal", value: 1 } }, - scalable: true, + groupQuantity: { + quantity: { type: "fixed", value: { type: "decimal", decimal: 1 } }, + }, }, ], - flags: [], }; - expect(findAndUpsertIngredient(ingredients, newIngredient, true)).toEqual({ - ingredientIndex: 1, - quantityPartIndex: 0, - }); - expect(ingredients).toHaveLength(2); + expect(findAndUpsertIngredient(ingredients, newIngredient, true)).toEqual( + 0, + ); + expect(ingredients).toHaveLength(1); }); - it("should adopt quantity of new ingredient if referenced one has none", () => { - const ingredients: Ingredient[] = [{ name: "eggs", flags: [] }]; + it("should throw an error if an non-existing ingredient is referenced", () => { + const ingredients: Ingredient[] = [ + { + name: "eggs", + quantities: [ + { + groupQuantity: { + quantity: { + type: "fixed", + value: { type: "decimal", decimal: 1 }, + }, + }, + }, + ], + }, + ]; const newIngredient: Ingredient = { - name: "eggs", - quantity: { type: "fixed", value: { type: "decimal", value: 1 } }, - quantityParts: [ + name: "unreferenced-ingredient", + quantities: [ { - value: { type: "fixed", value: { type: "decimal", value: 1 } }, - scalable: true, + groupQuantity: { + quantity: { + type: "fixed", + value: { type: "decimal", decimal: 100 }, + }, + unit: "g", + }, }, ], flags: [], }; - expect(findAndUpsertIngredient(ingredients, newIngredient, true)).toEqual({ - ingredientIndex: 0, - quantityPartIndex: 0, - }); - expect(ingredients[0]?.quantity).toEqual({ - type: "fixed", - value: { type: "decimal", value: 1 }, - }); + expect(() => + findAndUpsertIngredient(ingredients, newIngredient, true), + ).toThrowError( + "Referenced ingredient \"unreferenced-ingredient\" not found. A referenced ingredient must be declared before being referenced with '&'.", + ); }); - it("should throw an error if an non-existing ingredient is referenced", () => { + it("should throw an error if flags differ in referenced ingredient", () => { const ingredients: Ingredient[] = [ { name: "eggs", - quantity: { type: "fixed", value: { type: "decimal", value: 1 } }, - quantityParts: [ + quantities: [ { - value: { type: "fixed", value: { type: "decimal", value: 1 } }, - scalable: true, + groupQuantity: { + quantity: { + type: "fixed", + value: { type: "decimal", decimal: 1 }, + }, + }, }, ], - flags: [], + flags: ["hidden"], }, ]; const newIngredient: Ingredient = { - name: "unreferenced-ingredient", - quantity: { type: "fixed", value: { type: "decimal", value: 100 } }, - unit: "g", - quantityParts: [ + name: "eggs", + quantities: [ { - value: { type: "fixed", value: { type: "decimal", value: 100 } }, - unit: "g", - scalable: true, + groupQuantity: { + quantity: { type: "fixed", value: { type: "decimal", decimal: 2 } }, + }, }, ], - flags: [], }; expect(() => findAndUpsertIngredient(ingredients, newIngredient, true), - ).toThrowError( - "Referenced ingredient \"unreferenced-ingredient\" not found. A referenced ingredient must be declared before being referenced with '&'.", - ); + ).toThrow(ReferencedItemCannotBeRedefinedError); + newIngredient.flags = ["optional"]; + expect(() => + findAndUpsertIngredient(ingredients, newIngredient, true), + ).toThrow(ReferencedItemCannotBeRedefinedError); }); }); describe("parseFixedValue", () => { it("parses non numerical value as text", () => { - expect(parseFixedValue("1-ish")).toEqual({ type: "text", value: "1-ish" }); + expect(parseFixedValue("1-ish")).toEqual({ type: "text", text: "1-ish" }); }); it("parses fractions as such", () => { @@ -565,9 +571,9 @@ describe("parseFixedValue", () => { }); it("parses decimal values as such", () => { - expect(parseFixedValue("1.5")).toEqual({ type: "decimal", value: 1.5 }); - expect(parseFixedValue("0.1")).toEqual({ type: "decimal", value: 0.1 }); - expect(parseFixedValue("1")).toEqual({ type: "decimal", value: 1 }); + expect(parseFixedValue("1.5")).toEqual({ type: "decimal", decimal: 1.5 }); + expect(parseFixedValue("0.1")).toEqual({ type: "decimal", decimal: 0.1 }); + expect(parseFixedValue("1")).toEqual({ type: "decimal", decimal: 1 }); }); }); @@ -575,24 +581,24 @@ describe("parseQuantityInput", () => { it("correctly parses ranges", () => { expect(parseQuantityInput("1-2")).toEqual({ type: "range", - min: { type: "decimal", value: 1 }, - max: { type: "decimal", value: 2 }, + min: { type: "decimal", decimal: 1 }, + max: { type: "decimal", decimal: 2 }, }); expect(parseQuantityInput("1/2-1")).toEqual({ type: "range", min: { type: "fraction", num: 1, den: 2 }, - max: { type: "decimal", value: 1 }, + max: { type: "decimal", decimal: 1 }, }); }); it("correctly parses fixed values", () => { expect(parseQuantityInput("1")).toEqual({ type: "fixed", - value: { type: "decimal", value: 1 }, + value: { type: "decimal", decimal: 1 }, }); expect(parseQuantityInput("1.2")).toEqual({ type: "fixed", - value: { type: "decimal", value: 1.2 }, + value: { type: "decimal", decimal: 1.2 }, }); }); }); @@ -602,7 +608,7 @@ describe("stringifyQuantityValue", () => { expect( stringifyQuantityValue({ type: "fixed", - value: { type: "decimal", value: 1.5 }, + value: { type: "decimal", decimal: 1.5 }, }), ).toEqual("1.5"); expect( @@ -611,15 +617,29 @@ describe("stringifyQuantityValue", () => { value: { type: "fraction", num: 2, den: 3 }, }), ).toEqual("2/3"); + expect( + stringifyQuantityValue({ + type: "fixed", + value: { type: "text", text: "a pinch" }, + }), + ).toEqual("a pinch"); }); it("correctly stringify ranges", () => { expect( stringifyQuantityValue({ type: "range", - min: { type: "decimal", value: 1 }, - max: { type: "decimal", value: 2 }, + min: { type: "decimal", decimal: 1 }, + max: { type: "decimal", decimal: 2 }, }), ).toEqual("1-2"); }); }); + +describe("unionOfSets", () => { + it("should return the correct union of two sets", () => { + expect( + unionOfSets(new Set(["a", "b", "c"]), new Set(["b", "c", "d", "e"])), + ).toEqual(new Set(["a", "b", "c", "d", "e"])); + }); +}); diff --git a/test/product_catalog.test.ts b/test/product_catalog.test.ts index 1922425..fe6d188 100644 --- a/test/product_catalog.test.ts +++ b/test/product_catalog.test.ts @@ -39,30 +39,42 @@ size = "100%g" productName: "Pack of 6 eggs", ingredientName: "eggs", price: 10, - size: { type: "fixed", value: { type: "decimal", value: 6 } }, + sizes: [ + { size: { type: "fixed", value: { type: "decimal", decimal: 6 } } }, + ], }, { id: "01123", productName: "Single Egg", ingredientName: "eggs", price: 2, - size: { type: "fixed", value: { type: "decimal", value: 1 } }, + sizes: [ + { size: { type: "fixed", value: { type: "decimal", decimal: 1 } } }, + ], }, { id: "14141", productName: "Big pack", ingredientName: "flour", price: 10, - size: { type: "fixed", value: { type: "decimal", value: 6 } }, - unit: "kg", + sizes: [ + { + size: { type: "fixed", value: { type: "decimal", decimal: 6 } }, + unit: "kg", + }, + ], }, { id: "01124", productName: "Small pack", ingredientName: "flour", price: 1.5, - size: { type: "fixed", value: { type: "decimal", value: 100 } }, - unit: "g", + sizes: [ + { + size: { type: "fixed", value: { type: "decimal", decimal: 100 } }, + unit: "g", + }, + ], }, ]; @@ -94,7 +106,33 @@ aliases = ["oeuf", "huevo"] ingredientName: "eggs", ingredientAliases: ["oeuf", "huevo"], price: 2, - size: { type: "fixed", value: { type: "decimal", value: 1 } }, + sizes: [ + { size: { type: "fixed", value: { type: "decimal", decimal: 1 } } }, + ], + }, + ]); + }); + + it("should parse a product catalog with multiple sizes", () => { + const catalog = new ProductCatalog(); + const products = catalog.parse(`[eggs] +11245 = { name = "Pack of 12 eggs", size = ["1%dozen", "12"], price = 18 }`); + expect(products.length).toBe(1); + expect(products).toEqual([ + { + id: "11245", + productName: "Pack of 12 eggs", + ingredientName: "eggs", + price: 18, + sizes: [ + { + size: { type: "fixed", value: { type: "decimal", decimal: 1 } }, + unit: "dozen", + }, + { + size: { type: "fixed", value: { type: "decimal", decimal: 12 } }, + }, + ], }, ]); }); @@ -111,7 +149,9 @@ aliases = ["oeuf", "huevo"] productName: "Single Egg", ingredientName: "eggs", price: 2, - size: { type: "fixed", value: { type: "decimal", value: 1 } }, + sizes: [ + { size: { type: "fixed", value: { type: "decimal", decimal: 1 } } }, + ], }, ]); }); @@ -167,7 +207,9 @@ aliases = "not an array"`, ingredientName: "eggs", ingredientAliases: ["oeuf", "huevo"], price: 10, - size: { type: "fixed", value: { type: "decimal", value: 6 } }, + sizes: [ + { size: { type: "fixed", value: { type: "decimal", decimal: 6 } } }, + ], }, { id: "01123", @@ -175,7 +217,9 @@ aliases = "not an array"`, ingredientName: "eggs", ingredientAliases: ["oeuf", "huevo"], price: 2, - size: { type: "fixed", value: { type: "decimal", value: 1 } }, + sizes: [ + { size: { type: "fixed", value: { type: "decimal", decimal: 1 } } }, + ], }, ]; const stringified = catalog.stringify(); @@ -202,7 +246,9 @@ size = "1" productName: "Pack of 6 eggs", ingredientName: "eggs", price: 10, - size: { type: "fixed", value: { type: "decimal", value: 6 } }, + sizes: [ + { size: { type: "fixed", value: { type: "decimal", decimal: 6 } } }, + ], image: "egg.png", }, ]; @@ -212,6 +258,33 @@ price = 10 image = "egg.png" name = "Pack of 6 eggs" size = "6" +`); + }); + + it("should handle products with multiple sizes", () => { + const catalog = new ProductCatalog(); + catalog.products = [ + { + id: "11245", + productName: "Pack of 12 eggs", + ingredientName: "eggs", + price: 18, + sizes: [ + { + size: { type: "fixed", value: { type: "decimal", decimal: 1 } }, + unit: "dozen", + }, + { + size: { type: "fixed", value: { type: "decimal", decimal: 12 } }, + }, + ], + }, + ]; + const stringified = catalog.stringify(); + expect(stringified).toBe(`[eggs.11245] +price = 18 +name = "Pack of 12 eggs" +size = [ "1%dozen", "12" ] `); }); }); @@ -223,8 +296,12 @@ size = "6" id: "12345", productName: "New Product", ingredientName: "new-ingredient", - size: { type: "fixed", value: { type: "decimal", value: 1 } }, - unit: "kg", + sizes: [ + { + size: { type: "fixed", value: { type: "decimal", decimal: 1 } }, + unit: "kg", + }, + ], price: 10, }; catalog.add(newProduct); @@ -243,7 +320,9 @@ size = "6" productName: "Pack of 6 eggs", ingredientName: "eggs", price: 10, - size: { type: "fixed", value: { type: "decimal", value: 6 } }, + sizes: [ + { size: { type: "fixed", value: { type: "decimal", decimal: 6 } } }, + ], }); }); @@ -263,8 +342,12 @@ size = "6" productName: "New Product", ingredientName: "new-ingredient", ingredientAliases: ["alias-1"], - size: { type: "fixed", value: { type: "decimal", value: 1 } }, - unit: "kg", + sizes: [ + { + size: { type: "fixed", value: { type: "decimal", decimal: 1 } }, + unit: "kg", + }, + ], price: 10, }; catalog.add(newProduct); diff --git a/test/quantities_alternatives.test.ts b/test/quantities_alternatives.test.ts new file mode 100644 index 0000000..23a567c --- /dev/null +++ b/test/quantities_alternatives.test.ts @@ -0,0 +1,306 @@ +import { describe, it, expect } from "vitest"; +import { + addQuantitiesOrGroups, + getEquivalentUnitsLists, + sortUnitList, + reduceOrsToFirstEquivalent, + addEquivalentsAndSimplify, + regroupQuantitiesAndExpandEquivalents, +} from "../src/quantities/alternatives"; +import type { + FlatOrGroup, + QuantityWithExtendedUnit, + QuantityWithUnitDef, +} from "../src/types"; +import { q, qPlain, qWithUnitDef } from "./mocks/quantity"; +import { toPlainUnit } from "../src/quantities/mutations"; +import { NO_UNIT } from "../src/units/definitions"; + +describe("getEquivalentUnitsLists", () => { + it("should consider units of the same system and type as similar", () => { + expect( + getEquivalentUnitsLists( + { type: "or", entries: [q(1, "small"), q(10, "mL"), q(1, "cup")] }, + { type: "or", entries: [q(1, "large"), q(2, "cL"), q(1, "pint")] }, + ), + ).toEqual([ + [ + qWithUnitDef(1, "small"), + qWithUnitDef(10, "mL"), + qWithUnitDef(1, "cup"), + qWithUnitDef(0.5, "large"), + ], + ]); + }); + it("should return the correct list for complex quantity groups", () => { + expect( + getEquivalentUnitsLists( + { type: "or", entries: [q(1, "bucket")], + }, + { type: "or", entries: [q(1, "mini"), q(1, "bag")], + }, + q(1, "small"), + q(1, "mini"), + { type: "or", entries: [q(1, "small"), q(1, "cup")] }, + { type: "or", entries: [q(1, "large", true), q(0.75, "cup"), q(0.5, "pack")], + }, + ), + ).toEqual([ + [qWithUnitDef(1, "mini"), qWithUnitDef(1, "bag")], + [ + qWithUnitDef(1, "small"), + qWithUnitDef(1, "cup"), + qWithUnitDef(1.333, "large", true), + qWithUnitDef(0.667, "pack"), + ], + ]); + }); +}); + +describe("sortUnitList", () => { + it("should sort integer-protected units with no-unit first, then others", () => { + const unitList = [ + qWithUnitDef(1, "small", true), + qWithUnitDef(2, NO_UNIT, true), + qWithUnitDef(3, "large", true), + ]; + expect(sortUnitList(unitList)).toEqual([ + qWithUnitDef(2, NO_UNIT, true), + qWithUnitDef(3, "large", true), + qWithUnitDef(1, "small", true), + ]); + }); + it("should sort integer-protected units before non-integer-protected no-unit", () => { + const unitList = [qWithUnitDef(1, "small", true), qWithUnitDef(2, NO_UNIT)]; + expect(sortUnitList(unitList)).toEqual(unitList); + }); + it("should sort units by integer-protection, type and system", () => { + const unitList = [ + qWithUnitDef(1, "cup"), + qWithUnitDef(2, "large", true), + qWithUnitDef(3), + qWithUnitDef(4, "small", true), + qWithUnitDef(5, "pint"), + ]; + expect(sortUnitList(unitList)).toEqual([ + qWithUnitDef(2, "large", true), + qWithUnitDef(4, "small", true), + qWithUnitDef(3), + qWithUnitDef(1, "cup"), + qWithUnitDef(5, "pint"), + ]); + }); +}); + +describe("reduceOrsToFirstEquivalent", () => { + const unitList = [ + [ + qWithUnitDef(2, "large", true), + qWithUnitDef(1, NO_UNIT), + qWithUnitDef(1.5, "cup"), + qWithUnitDef(3, "small", true), + ], + ]; + it("should keep protected Quantity's intact", () => { + expect( + reduceOrsToFirstEquivalent(unitList, [q(2, "large"), q(5, "small")]), + ).toEqual([q(2, "large"), q(5, "small")]); + }); + it("should correctly reduce or groups to first protected unit", () => { + expect( + reduceOrsToFirstEquivalent(unitList, [ + { type: "or", entries: [q(2, "large"), q(1.5, "cup")] }, + ]), + ).toEqual([q(2, "large")]); + }); + it("should disregard order in the group", () => { + expect( + reduceOrsToFirstEquivalent(unitList, [ + { type: "or", entries: [q(2, "large"), q(1.5, "cup")] }, + { type: "or", entries: [q(1, "cup"), q(3, "large")] }, + ]), + ).toEqual([q(2, "large"), q(3, "large")]); + }); + it("should correctly reduce to the first integer-protected unit, even when the first quantity has no unit", () => { + expect( + reduceOrsToFirstEquivalent(unitList, [ + { type: "or", entries: [q(2), q(3, "cup")] }, + ]), + ).toEqual([q(4, "large")]); + }); + it("should reduce to the first unit provided, if it is an integer-protected one", () => { + expect( + reduceOrsToFirstEquivalent(unitList, [ + { type: "or", entries: [q(2, "small"), q(3, "cup")] }, + ]), + ).toEqual([q(2, "small")]); + }); + it("should handle units of different systems and types", () => { + expect( + reduceOrsToFirstEquivalent( + [[qWithUnitDef(10, "mL"), qWithUnitDef(1, "cup")]], + [ + { type: "or", entries: [q(10, "mL"), q(1, "cup")] }, + { type: "or", entries: [q(2, "cL"), q(1, "pint")] }, + ], + ), + ).toEqual([q(10, "mL"), q(20, "mL")]); + }); + it("should correctly transform Quantity into first compatible protected unit quantity", () => { + expect(reduceOrsToFirstEquivalent(unitList, [q(1.5, "cup")])).toEqual([ + q(2, "large"), + ]); + expect(reduceOrsToFirstEquivalent(unitList, [q(1, "cup")])).toEqual([ + q(2, "small"), + ]); + }); +}); + +describe("addQuantitiesOrGroups", () => { + it("should return correct values in case no quantities are passed", () => { + const result = addQuantitiesOrGroups(); + expect(result).toEqual({ + sum: { + ...q(0), + unit: { name: "__no-unit__", type: "other", system: "none" }, + }, + unitsLists: [], + }); + }); + it("should pass single quantities transparently", () => { + const quantity: QuantityWithExtendedUnit = q(1, "kg"); + const result = addQuantitiesOrGroups(quantity); + expect(result.sum).toEqual({ + ...q(1, "kg"), + unit: { + name: "kg", + type: "mass", + system: "metric", + aliases: ["kilogram", "kilograms", "kilogrammes", "kilos", "kilo"], + toBase: 1000, + }, + }); + }); + it("should reduce an OR group to its most relevant member", () => { + const or: FlatOrGroup = { type: "or", entries: [q(2, "large"), q(1.5, "cup")], + }; + + const { sum } = addQuantitiesOrGroups(or); + expect(sum).toEqual(qWithUnitDef(2, "large")); + }); + it("should add two OR groups to the sum of their most relevant member", () => { + const or1: FlatOrGroup = { type: "or", entries: [q(2, "large"), q(1.5, "cup")], + }; + const or2: FlatOrGroup = { type: "or", entries: [q(4, "large"), q(3, "cup")], + }; + + const { sum } = addQuantitiesOrGroups(or1, or2); + expect(sum).toEqual(qWithUnitDef(6, "large")); + }); + it("should reduce two OR groups partially overlapping to the sum of the most relevant member of the union", () => { + const or1: FlatOrGroup = { type: "or", entries: [q(2, "large"), q(1.5, "cup")], + }; + const or2: FlatOrGroup = { type: "or", entries: [q(2, "small"), q(1, "cup")], + }; + + const { sum } = addQuantitiesOrGroups(or1, or2); + expect(sum).toEqual(qWithUnitDef(3.333, "large")); + }); + it("should handle OR groups with different normalizable units", () => { + const or1: FlatOrGroup = { type: "or", entries: [q(100, "ml"), q(1, "cup")], + }; + const or2: FlatOrGroup = { type: "or", entries: [q(20, "cl"), q(1, "pint")], + }; // 10 cl = 100 ml + + const { sum } = addQuantitiesOrGroups(or1, or2); + expect(sum).toEqual(qWithUnitDef(300, "ml")); + }); +}); + +describe("regroupQuantitiesAndExpandEquivalents", () => { + // Processing a UnitList with only 1 quantity is purely theoretical and won't happen in practice + it("simply passes on a single quantity", () => { + const sum = qWithUnitDef(1, "kg"); + const unitsLists: QuantityWithUnitDef[][] = [[qWithUnitDef(1, "kg")]]; + expect(regroupQuantitiesAndExpandEquivalents(sum, unitsLists)).toEqual([ + q(1, "kg"), + ]); + }); + it("does not process more unit lists if a match has been found", () => { + const sum = qWithUnitDef(4, "large"); + const unitsLists: QuantityWithUnitDef[][] = [ + [ + qWithUnitDef(2, "large"), + qWithUnitDef(1, NO_UNIT), + qWithUnitDef(1.5, "cup"), + qWithUnitDef(3, "small", true), + ], + [qWithUnitDef(1, "bag"), qWithUnitDef(1, "mini")], + ]; + expect(regroupQuantitiesAndExpandEquivalents(sum, unitsLists)).toEqual([ + { type: "or", entries: [q(4, "large"), q(6, "small"), q(2, NO_UNIT), q(3, "cup")], + }, + ]); + }); + it("adds units to the same ") +}); + +describe("addEquivalentsAndSimplify", () => { + it("leaves Quantity's intact", () => { + expect(addEquivalentsAndSimplify(q(2, "kg"))).toEqual(qPlain(2, "kg")); + expect(addEquivalentsAndSimplify(q(2, "kg"), q(2, "large"))).toEqual({ type: "and", entries: [qPlain(2, "kg"), qPlain(2, "large")], + }); + }); + it("leaves single OR group intact", () => { + const or: FlatOrGroup = { type: "or", entries: [q(2, "kg"), q(2, "large")], + }; + expect(addEquivalentsAndSimplify(or)).toEqual(toPlainUnit(or)); + }); + it("correctly adds two groups of equivalent quantities of same unit", () => { + const or1: FlatOrGroup = { type: "or", entries: [q(1, "kg"), q(2, "large")], + }; + const or2: FlatOrGroup = { type: "or", entries: [q(1.5, "kg"), q(3, "large")], + }; + expect(addEquivalentsAndSimplify(or1, or2)).toEqual({ type: "or", entries: [qPlain(5, "large"), qPlain(2.5, "kg")], + }); + }); + it("correctly adds two groups of equivalent quantities of similar unit", () => { + const or1: FlatOrGroup = { type: "or", entries: [q(1, "kg"), q(20, "large")], + }; + const or2: FlatOrGroup = { type: "or", entries: [q(100, "g"), q(2, "large")], + }; + expect(addEquivalentsAndSimplify(or1, or2)).toEqual({ type: "or", entries: [qPlain(22, "large"), qPlain(1.1, "kg")], + }); + }); + it("correctly adds two groups of equivalents with partial overlap", () => { + const or1: FlatOrGroup = { type: "or", entries: [q(2, "large"), q(1.5, "cup")], + }; + const or2: FlatOrGroup = { type: "or", entries: [q(2, "small"), q(1, "cup")], + }; + expect(addEquivalentsAndSimplify(or1, or2)).toEqual({ type: "or", entries: [ + qPlain(3.333, "large"), + qPlain(5, "small"), + qPlain(2.5, "cup"), + ], + }); + }); + it("accepts units of the same type but different system as alternative", () => { + const or1: FlatOrGroup = { type: "or", entries: [q(10, "cup"), q(2366, "mL")], + }; + const or2: FlatOrGroup = { type: "or", entries: [q(1, "pint"), q(473, "mL")], + }; + expect(addEquivalentsAndSimplify(or1, or2)).toEqual({ type: "or", entries: [qPlain(12, "cup"), qPlain(2839.2, "mL")], + }); + }); + it("correctly take integer-protected units into account", () => { + const or1: FlatOrGroup = { type: "or", entries: [q(2, "large", true), q(1.5, "cup")], + }; + const or2: FlatOrGroup = { type: "or", entries: [q(2, "small"), q(1, "cup")], + }; + expect(addEquivalentsAndSimplify(or1, or2)).toEqual({ type: "or", entries: [ + { type: "and", entries: [qPlain(2, "large"), qPlain(2, "small")] }, + qPlain(2.5, "cup"), + ], + }); + }); +}); diff --git a/test/quantities_mutations.test.ts b/test/quantities_mutations.test.ts new file mode 100644 index 0000000..61d54f0 --- /dev/null +++ b/test/quantities_mutations.test.ts @@ -0,0 +1,886 @@ +import { describe, it, expect } from "vitest"; + +import { + extendAllUnits, + addQuantityValues, + addQuantities, + getDefaultQuantityValue, + normalizeAllUnits, + toExtendedUnit, + flattenPlainUnitGroup, +} from "../src/quantities/mutations"; +import { CannotAddTextValueError, IncompatibleUnitsError } from "../src/errors"; +import { + AndGroup, + FlatAndGroup, + FlatOrGroup, + MaybeNestedAndGroup, + QuantityWithExtendedUnit, + QuantityWithPlainUnit, +} from "../src/types"; + +describe("extendAllUnits", () => { + it("should extend units in a simple quantity with plain units", () => { + const original: QuantityWithPlainUnit = { + quantity: { type: "fixed", value: { type: "decimal", decimal: 100 } }, + unit: "g", + }; + const extended = extendAllUnits(original); + const expected: QuantityWithExtendedUnit = { + quantity: { type: "fixed", value: { type: "decimal", decimal: 100 } }, + unit: { name: "g" }, + }; + expect(extended).toEqual(expected); + }); + it("should extend units in a nested group", () => { + const original: MaybeNestedAndGroup = { + type: "and", + entries: [ + { + quantity: { type: "fixed", value: { type: "decimal", decimal: 1 } }, + unit: "cup", + }, + { + type: "or", + entries: [ + { + quantity: { + type: "fixed", + value: { type: "decimal", decimal: 2 }, + }, + unit: "tbsp", + }, + { + quantity: { + type: "fixed", + value: { type: "decimal", decimal: 30 }, + }, + unit: "mL", + }, + ], + }, + ], + }; + const extended = extendAllUnits(original); + const expected: MaybeNestedAndGroup = { + type: "and", + entries: [ + { + quantity: { type: "fixed", value: { type: "decimal", decimal: 1 } }, + unit: { name: "cup" }, + }, + { + type: "or", + entries: [ + { + quantity: { + type: "fixed", + value: { type: "decimal", decimal: 2 }, + }, + unit: { name: "tbsp" }, + }, + { + quantity: { + type: "fixed", + value: { type: "decimal", decimal: 30 }, + }, + unit: { name: "mL" }, + }, + ], + }, + ], + }; + expect(extended).toEqual(expected); + }); +}); + +describe("normalizeAllUnits", () => { + it("should normalize units in a simple quantity with plain units", () => { + const original: QuantityWithPlainUnit = { + quantity: { type: "fixed", value: { type: "decimal", decimal: 10 } }, + unit: "g", + }; + const normalized = normalizeAllUnits(original); + const expected = { + quantity: { type: "fixed", value: { type: "decimal", decimal: 10 } }, + unit: { + name: "g", + type: "mass", + system: "metric", + aliases: ["gram", "grams", "grammes"], + toBase: 1, + }, + }; + expect(normalized).toEqual(expected); + }); + it("should normalize units in a nested group", () => { + const original: MaybeNestedAndGroup = { + type: "and", + entries: [ + { + quantity: { type: "fixed", value: { type: "decimal", decimal: 1 } }, + unit: "cup", + }, + { + type: "or", + entries: [ + { + quantity: { + type: "fixed", + value: { type: "decimal", decimal: 2 }, + }, + unit: "tbsp", + }, + { + quantity: { + type: "fixed", + value: { type: "decimal", decimal: 30 }, + }, + unit: "mL", + }, + ], + }, + ], + }; + const normalized = normalizeAllUnits(original); + const expected = { + type: "and", + entries: [ + { + quantity: { type: "fixed", value: { type: "decimal", decimal: 1 } }, + unit: { + name: "cup", + type: "volume", + system: "imperial", + aliases: ["cups"], + toBase: 236.588, + }, + }, + { + type: "or", + entries: [ + { + quantity: { + type: "fixed", + value: { type: "decimal", decimal: 2 }, + }, + unit: { + name: "tbsp", + type: "volume", + system: "metric", + aliases: ["tablespoon", "tablespoons"], + toBase: 15, + }, + }, + { + quantity: { + type: "fixed", + value: { type: "decimal", decimal: 30 }, + }, + unit: { + name: "mL", + type: "volume", + system: "metric", + aliases: [ + "milliliter", + "milliliters", + "millilitre", + "millilitres", + "cc", + ], + toBase: 1, + }, + }, + ], + }, + ], + }; + expect(normalized).toEqual(expected); + }); +}); + +describe("addQuantityValues", () => { + it("should add two fixed numerical values", () => { + expect( + addQuantityValues( + { type: "fixed", value: { type: "decimal", decimal: 1 } }, + { type: "fixed", value: { type: "decimal", decimal: 2 } }, + ), + ).toEqual({ type: "fixed", value: { type: "decimal", decimal: 3 } }); + }); + + it("should add two range values", () => { + expect( + addQuantityValues( + { + type: "range", + min: { type: "decimal", decimal: 1 }, + max: { type: "decimal", decimal: 2 }, + }, + { + type: "range", + min: { type: "decimal", decimal: 3 }, + max: { type: "decimal", decimal: 4 }, + }, + ), + ).toEqual({ + type: "range", + min: { type: "decimal", decimal: 4 }, + max: { type: "decimal", decimal: 6 }, + }); + }); + + it("should add a fixed and a range value", () => { + expect( + addQuantityValues( + { type: "fixed", value: { type: "decimal", decimal: 1 } }, + { + type: "range", + min: { type: "decimal", decimal: 3 }, + max: { type: "decimal", decimal: 4 }, + }, + ), + ).toEqual({ + type: "range", + min: { type: "decimal", decimal: 4 }, + max: { type: "decimal", decimal: 5 }, + }); + }); + + it("should throw an error if one of the value is a text value", () => { + expect(() => + addQuantityValues( + { type: "fixed", value: { type: "text", text: "to taste" } }, + { + type: "fixed", + value: { type: "decimal", decimal: 1 }, + }, + ), + ).toThrow(CannotAddTextValueError); + }); +}); + +describe("addQuantities", () => { + it("should add same units correctly", () => { + expect( + addQuantities( + { + quantity: { type: "fixed", value: { type: "decimal", decimal: 100 } }, + unit: { name: "g" }, + }, + { + quantity: { type: "fixed", value: { type: "decimal", decimal: 200 } }, + unit: { name: "g" }, + }, + ), + ).toEqual({ + quantity: { type: "fixed", value: { type: "decimal", decimal: 300 } }, + unit: { name: "g" }, + }); + }); + + it("should add big decimal numbers correctly", () => { + expect( + addQuantities( + { + quantity: { type: "fixed", value: { type: "decimal", decimal: 1.1 } }, + unit: { name: "kg" }, + }, + { + quantity: { type: "fixed", value: { type: "decimal", decimal: 1.3 } }, + unit: { name: "kg" }, + }, + ), + ).toEqual({ + quantity: { type: "fixed", value: { type: "decimal", decimal: 2.4 } }, + unit: { name: "kg" }, + }); + }); + + it("should also work when at least one value is a range", () => { + expect( + addQuantities( + { + quantity: { + type: "range", + min: { type: "decimal", decimal: 100 }, + max: { type: "decimal", decimal: 200 }, + }, + unit: { name: "g" }, + }, + { + quantity: { + type: "range", + min: { type: "decimal", decimal: 10 }, + max: { type: "decimal", decimal: 20 }, + }, + unit: { name: "g" }, + }, + ), + ).toEqual({ + quantity: { + type: "range", + min: { type: "decimal", decimal: 110 }, + max: { type: "decimal", decimal: 220 }, + }, + unit: { name: "g" }, + }); + + expect( + addQuantities( + { + quantity: { + type: "fixed", + value: { type: "decimal", decimal: 100 }, + }, + unit: { name: "g" }, + }, + { + quantity: { + type: "range", + min: { type: "decimal", decimal: 10 }, + max: { type: "decimal", decimal: 20 }, + }, + unit: { name: "g" }, + }, + ), + ).toEqual({ + quantity: { + type: "range", + min: { type: "decimal", decimal: 110 }, + max: { type: "decimal", decimal: 120 }, + }, + unit: { name: "g" }, + }); + + expect( + addQuantities( + { + quantity: { + type: "range", + min: { type: "decimal", decimal: 10 }, + max: { type: "decimal", decimal: 20 }, + }, + unit: { name: "g" }, + }, + { + quantity: { + type: "fixed", + value: { type: "decimal", decimal: 100 }, + }, + unit: { name: "g" }, + }, + ), + ).toEqual({ + quantity: { + type: "range", + min: { type: "decimal", decimal: 110 }, + max: { type: "decimal", decimal: 120 }, + }, + unit: { name: "g" }, + }); + }); + + it("should add compatible metric units and convert to largest", () => { + expect( + addQuantities( + { + quantity: { type: "fixed", value: { type: "decimal", decimal: 1 } }, + unit: { name: "kg" }, + }, + { + quantity: { type: "fixed", value: { type: "decimal", decimal: 500 } }, + unit: { name: "g" }, + }, + ), + ).toEqual({ + quantity: { type: "fixed", value: { type: "decimal", decimal: 1.5 } }, + unit: { name: "kg" }, + }); + expect( + addQuantities( + { + quantity: { type: "fixed", value: { type: "decimal", decimal: 500 } }, + unit: { name: "g" }, + }, + { + quantity: { type: "fixed", value: { type: "decimal", decimal: 1 } }, + unit: { name: "kg" }, + }, + ), + ).toEqual({ + quantity: { type: "fixed", value: { type: "decimal", decimal: 1.5 } }, + unit: { name: "kg" }, + }); + }); + + it("should add compatible imperial units and convert to largest", () => { + expect( + addQuantities( + { + quantity: { type: "fixed", value: { type: "decimal", decimal: 1 } }, + unit: { name: "lb" }, + }, + { + quantity: { type: "fixed", value: { type: "decimal", decimal: 8 } }, + unit: { name: "oz" }, + }, + ), + ).toEqual({ + quantity: { type: "fixed", value: { type: "decimal", decimal: 1.5 } }, + unit: { name: "lb" }, + }); + }); + + it("should add compatible metric and imperial units, converting to largest metric", () => { + const result = addQuantities( + { + quantity: { type: "fixed", value: { type: "decimal", decimal: 1 } }, + unit: { name: "lb" }, + }, + { + quantity: { type: "fixed", value: { type: "decimal", decimal: 500 } }, + unit: { name: "g" }, + }, + ); + expect(result.unit).toEqual({ name: "kg" }); + expect(result.quantity).toEqual({ + type: "fixed", + value: { type: "decimal", decimal: 0.954 }, + }); + }); + + it("should handle text quantities", () => { + expect(() => + addQuantities( + { + quantity: { + type: "fixed", + value: { type: "text", text: "to taste" }, + }, + unit: { name: "" }, + }, + { + quantity: { type: "fixed", value: { type: "decimal", decimal: 100 } }, + unit: { name: "g" }, + }, + ), + ).toThrow(CannotAddTextValueError); + }); + + it("should handle adding to a quantity with no unit", () => { + expect( + addQuantities( + { + quantity: { type: "fixed", value: { type: "decimal", decimal: 1 } }, + unit: { name: "" }, + }, + { + quantity: { type: "fixed", value: { type: "decimal", decimal: 2 } }, + unit: { name: "g" }, + }, + ), + ).toEqual({ + quantity: { type: "fixed", value: { type: "decimal", decimal: 3 } }, + unit: { name: "g" }, + }); + expect( + addQuantities( + { + quantity: { type: "fixed", value: { type: "decimal", decimal: 100 } }, + unit: { name: "g" }, + }, + { + quantity: { type: "fixed", value: { type: "decimal", decimal: 1 } }, + unit: { name: "" }, + }, + ), + ).toEqual({ + quantity: { type: "fixed", value: { type: "decimal", decimal: 101 } }, + unit: { name: "g" }, + }); + }); + + it("should simply add two quantities without unit or with empty string unit", () => { + // Empty string unit + expect( + addQuantities( + { + quantity: { type: "fixed", value: { type: "decimal", decimal: 1 } }, + unit: { name: "" }, + }, + { + quantity: { type: "fixed", value: { type: "decimal", decimal: 2 } }, + unit: { name: "" }, + }, + ), + ).toEqual({ + quantity: { type: "fixed", value: { type: "decimal", decimal: 3 } }, + unit: { name: "" }, + }); + // No unit + expect( + addQuantities( + { + quantity: { type: "fixed", value: { type: "decimal", decimal: 1 } }, + }, + { + quantity: { type: "fixed", value: { type: "decimal", decimal: 2 } }, + }, + ), + ).toEqual({ + quantity: { type: "fixed", value: { type: "decimal", decimal: 3 } }, + }); + }); + + it("should throw error if trying to add incompatible units", () => { + expect(() => + addQuantities( + { + quantity: { type: "fixed", value: { type: "decimal", decimal: 100 } }, + unit: { name: "g" }, + }, + { + quantity: { type: "fixed", value: { type: "decimal", decimal: 1 } }, + unit: { name: "L" }, + }, + ), + ).toThrow(IncompatibleUnitsError); + expect(() => + addQuantities( + { + quantity: { type: "fixed", value: { type: "decimal", decimal: 100 } }, + unit: { name: "g" }, + }, + { + quantity: { type: "fixed", value: { type: "decimal", decimal: 1 } }, + unit: { name: "bag" }, + }, + ), + ).toThrow(IncompatibleUnitsError); + }); + + it("should add quantities defined as ranges", () => { + expect( + addQuantities( + { + quantity: { + type: "range", + min: { type: "decimal", decimal: 1 }, + max: { type: "decimal", decimal: 2 }, + }, + unit: { name: "tsp" }, + }, + { + quantity: { type: "fixed", value: { type: "decimal", decimal: 1 } }, + unit: { name: "tsp" }, + }, + ), + ).toEqual({ + quantity: { + type: "range", + min: { type: "decimal", decimal: 2 }, + max: { type: "decimal", decimal: 3 }, + }, + unit: { name: "tsp" }, + }); + }); +}); + +describe("getDefaultQuantityValue + addQuantities", () => { + it("should preseve fractions", () => { + expect( + addQuantities( + { quantity: getDefaultQuantityValue() }, + { + quantity: { + type: "fixed", + value: { type: "fraction", num: 1, den: 2 }, + }, + unit: { name: "" }, + }, + ), + ).toEqual({ + quantity: { type: "fixed", value: { type: "fraction", num: 1, den: 2 } }, + unit: { name: "" }, + }); + }); + it("should preseve ranges", () => { + expect( + addQuantities( + { quantity: getDefaultQuantityValue() }, + { + quantity: { + type: "range", + min: { type: "decimal", decimal: 1 }, + max: { type: "decimal", decimal: 2 }, + }, + unit: { name: "" }, + }, + ), + ).toEqual({ + quantity: { + type: "range", + min: { type: "decimal", decimal: 1 }, + max: { type: "decimal", decimal: 2 }, + }, + unit: { name: "" }, + }); + }); +}); + +describe("toExtendedUnit", () => { + it("should convert a simple QuantityWithPlainUnit", () => { + const input: QuantityWithPlainUnit = { + quantity: { type: "fixed", value: { type: "decimal", decimal: 5 } }, + unit: "g", + }; + const result = toExtendedUnit(input); + expect(result).toEqual({ + quantity: { type: "fixed", value: { type: "decimal", decimal: 5 } }, + unit: { name: "g" }, + }); + }); + + it("should convert an OR group of quantities", () => { + const input: FlatOrGroup = { + type: "or", + entries: [ + { + quantity: { type: "fixed", value: { type: "decimal", decimal: 1 } }, + unit: "cup", + }, + { + quantity: { type: "fixed", value: { type: "decimal", decimal: 250 } }, + unit: "mL", + }, + ], + }; + const result = toExtendedUnit(input); + expect(result).toEqual({ + type: "or", + entries: [ + { + quantity: { type: "fixed", value: { type: "decimal", decimal: 1 } }, + unit: { name: "cup" }, + }, + { + quantity: { type: "fixed", value: { type: "decimal", decimal: 250 } }, + unit: { name: "mL" }, + }, + ], + }); + }); + + it("should convert an AND group of quantities", () => { + const input: AndGroup = { + type: "and", + entries: [ + { + quantity: { type: "fixed", value: { type: "decimal", decimal: 2 } }, + unit: "egg", + }, + { + quantity: { type: "fixed", value: { type: "decimal", decimal: 100 } }, + unit: "g", + }, + ], + }; + const result = toExtendedUnit(input); + expect(result).toEqual({ + type: "and", + entries: [ + { + quantity: { type: "fixed", value: { type: "decimal", decimal: 2 } }, + unit: { name: "egg" }, + }, + { + quantity: { type: "fixed", value: { type: "decimal", decimal: 100 } }, + unit: { name: "g" }, + }, + ], + }); + }); + + it("should convert nested groups", () => { + const input: AndGroup = { + type: "and", + entries: [ + { + type: "or", + entries: [ + { + quantity: { + type: "fixed", + value: { type: "decimal", decimal: 1 }, + }, + unit: "cup", + }, + { + quantity: { + type: "fixed", + value: { type: "decimal", decimal: 250 }, + }, + unit: "mL", + }, + ], + }, + { + quantity: { type: "fixed", value: { type: "decimal", decimal: 100 } }, + unit: "g", + }, + ], + }; + const result = toExtendedUnit(input); + expect(result).toEqual({ + type: "and", + entries: [ + { + type: "or", + entries: [ + { + quantity: { + type: "fixed", + value: { type: "decimal", decimal: 1 }, + }, + unit: { name: "cup" }, + }, + { + quantity: { + type: "fixed", + value: { type: "decimal", decimal: 250 }, + }, + unit: { name: "mL" }, + }, + ], + }, + { + quantity: { type: "fixed", value: { type: "decimal", decimal: 100 } }, + unit: { name: "g" }, + }, + ], + }); + }); + + it("should preserve equivalents array", () => { + const input: QuantityWithPlainUnit = { + quantity: { type: "fixed", value: { type: "decimal", decimal: 1 } }, + unit: "cup", + equivalents: [ + { + quantity: { type: "fixed", value: { type: "decimal", decimal: 250 } }, + unit: "mL", + }, + ], + }; + const result = toExtendedUnit(input); + expect(result).toEqual({ + quantity: { type: "fixed", value: { type: "decimal", decimal: 1 } }, + unit: { name: "cup" }, + equivalents: [ + { + quantity: { type: "fixed", value: { type: "decimal", decimal: 250 } }, + unit: "mL", + }, + ], + }); + }); +}); + +describe("flattenPlainUnitGroup", () => { + // flattenPlainUnitGroup is imported at the top + it("should flatten a simple quantity", () => { + const input: QuantityWithPlainUnit = { + quantity: { type: "fixed", value: { type: "decimal", decimal: 5 } }, + unit: "g", + }; + expect(flattenPlainUnitGroup(input)).toEqual([ + { + groupQuantity: { + quantity: { type: "fixed", value: { type: "decimal", decimal: 5 } }, + unit: "g", + }, + }, + ]); + }); + it("should flatten an OR group to one with equivalents", () => { + const input: FlatOrGroup = { + type: "or", + entries: [ + { + quantity: { type: "fixed", value: { type: "decimal", decimal: 1 } }, + unit: "cup", + }, + { + quantity: { type: "fixed", value: { type: "decimal", decimal: 250 } }, + unit: "mL", + }, + ], + }; + expect(flattenPlainUnitGroup(input)).toEqual([ + { + groupQuantity: { + quantity: { type: "fixed", value: { type: "decimal", decimal: 1 } }, + unit: "cup", + equivalents: [ + { + quantity: { + type: "fixed", + value: { type: "decimal", decimal: 250 }, + }, + unit: "mL", + }, + ], + }, + }, + ]); + }); + it("should flatten an AND group to separate entries", () => { + const input: FlatAndGroup = { + type: "and", + entries: [ + { + quantity: { type: "fixed", value: { type: "decimal", decimal: 2 } }, + unit: "egg", + }, + { + quantity: { type: "fixed", value: { type: "decimal", decimal: 100 } }, + unit: "g", + }, + ], + }; + expect(flattenPlainUnitGroup(input)).toEqual([ + { + groupQuantity: { + quantity: { type: "fixed", value: { type: "decimal", decimal: 2 } }, + unit: "egg", + }, + }, + { + groupQuantity: { + quantity: { type: "fixed", value: { type: "decimal", decimal: 100 } }, + unit: "g", + }, + }, + ]); + }); + it("should flatten a nested OR group with only one entry", () => { + const input: FlatOrGroup = { + type: "or", + entries: [ + { + quantity: { type: "fixed", value: { type: "decimal", decimal: 1 } }, + unit: "cup", + }, + ], + }; + expect(flattenPlainUnitGroup(input)).toEqual([ + { + groupQuantity: { + quantity: { type: "fixed", value: { type: "decimal", decimal: 1 } }, + unit: "cup", + }, + }, + ]); + }); +}); diff --git a/test/recipe_choices.test.ts b/test/recipe_choices.test.ts new file mode 100644 index 0000000..20506f1 --- /dev/null +++ b/test/recipe_choices.test.ts @@ -0,0 +1,94 @@ +import { describe, it, expect } from "vitest"; +import { Recipe } from "../src/classes/recipe"; +import { + recipeWithComplexAlternatives, + recipeWithGroupedAlternatives, + recipeWithInlineAlternatives, +} from "./fixtures/recipes"; +import { RecipeChoices } from "../src"; + +describe("Recipe.calc_ingredient_quantities", () => { + it("should calculate ingredient quantities with default choices", () => { + const recipe = new Recipe(recipeWithInlineAlternatives); + // Add ingredients and choices to the recipe as needed for the test + const computedIngredients = recipe.calc_ingredient_quantities(); + expect(computedIngredients.length).toBe(1); + const milkIngredient = computedIngredients.find( + (ing) => ing.name === "milk", + ); + expect(milkIngredient).toBeDefined(); + expect(milkIngredient?.quantityTotal).toEqual({ + quantity: { type: "fixed", value: { type: "decimal", decimal: 200 } }, + unit: "ml", + }); + + const recipe2 = new Recipe(recipeWithGroupedAlternatives); + // Add ingredients and choices to the recipe as needed for the test + const computedIngredients2 = recipe2.calc_ingredient_quantities(); + expect(computedIngredients2.length).toBe(1); + const milkIngredient2 = computedIngredients2.find( + (ing) => ing.name === "milk", + ); + expect(milkIngredient2).toBeDefined(); + expect(milkIngredient2?.quantityTotal).toEqual({ + quantity: { type: "fixed", value: { type: "decimal", decimal: 200 } }, + unit: "ml", + }); + }); + + it("should calculate ingredient quantities with specific choices", () => { + const recipe = new Recipe(recipeWithInlineAlternatives); + // For inline alternatives, the key is the ingredient item ID + const choices: RecipeChoices = { + ingredientItems: new Map([["ingredient-item-0", 1]]), + }; + const computedIngredients = recipe.calc_ingredient_quantities(choices); + expect(computedIngredients.length).toBe(1); + const milkIngredient = computedIngredients.find( + (ing) => ing.name === "almond milk", + ); + expect(milkIngredient).toBeDefined(); + expect(milkIngredient?.quantityTotal).toEqual({ + quantity: { type: "fixed", value: { type: "decimal", decimal: 100 } }, + unit: "ml", + }); + + const recipe2 = new Recipe(recipeWithGroupedAlternatives); + // For grouped alternatives, the key is the group name and goes in ingredientGroups + const choices2: RecipeChoices = { + ingredientGroups: new Map([["milk", 2]]), + }; + const computedIngredients2 = recipe2.calc_ingredient_quantities(choices2); + expect(computedIngredients2.length).toBe(1); + const milkIngredient2 = computedIngredients2.find( + (ing) => ing.name === "soy milk", + ); + expect(milkIngredient2).toBeDefined(); + expect(milkIngredient2?.quantityTotal).toEqual({ + quantity: { type: "fixed", value: { type: "decimal", decimal: 150 } }, + unit: "ml", + }); + }); + + it("correctly calculate ingredients quantities for complex alternatives", () => { + const recipe = new Recipe(recipeWithComplexAlternatives); + const choices: RecipeChoices = { + ingredientGroups: new Map([["dough", 0]]), + }; + const computedIngredients = recipe.calc_ingredient_quantities(choices); + expect(computedIngredients.length).toBe(1); + const doughIngredient = computedIngredients.find( + (ing) => ing.name === "dough", + ); + expect(doughIngredient).toBeDefined(); + expect(doughIngredient).toEqual({ + name: "dough", + quantityTotal: { + quantity: { type: "fixed", value: { type: "decimal", decimal: 1 } }, + }, + preparation: "defrosted", + flags: ["recipe"], + extras: { path: "dough.cook" }, + }); + }); +}); diff --git a/test/recipe_parsing.test.ts b/test/recipe_parsing.test.ts index 1c54cad..535ee8b 100644 --- a/test/recipe_parsing.test.ts +++ b/test/recipe_parsing.test.ts @@ -1,7 +1,17 @@ import { describe, it, expect } from "vitest"; import { Recipe } from "../src/classes/recipe"; -import { simpleRecipe, complexRecipe } from "./fixtures/recipes"; -import { ReferencedItemCannotBeRedefinedError } from "../src/errors"; +import { + simpleRecipe, + complexRecipe, + recipeToScaleWithAlternatives, + recipeWithGroupedAlternatives, + recipeWithInlineAlternatives, +} from "./fixtures/recipes"; +import { + InvalidQuantityFormat, + ReferencedItemCannotBeRedefinedError, +} from "../src/errors"; +import type { Ingredient, IngredientItem, Step } from "../src/types"; describe("parse function", () => { it("parses basic metadata correctly", () => { @@ -11,65 +21,94 @@ describe("parse function", () => { }); describe("ingredients with variants", () => { - it("extracts ingredients correctly", () => { - const result = new Recipe(simpleRecipe); - expect(result.ingredients.length).toBe(4); + it("extracts single word ingredient with quantity but without unit correctly", () => { + const result = new Recipe("@eggs{3}"); + expect(result.sections.length).toBe(1); + expect(result.sections[0]!.content).toEqual([ + { + type: "step", + items: [ + { + type: "ingredient", + id: "ingredient-item-0", + alternatives: [ + { + displayName: "eggs", + index: 0, + itemQuantity: { + scalable: true, + quantity: { + type: "fixed", + value: { type: "decimal", decimal: 3 }, + }, + }, + }, + ], + }, + ], + }, + ]); expect(result.ingredients).toEqual([ { name: "eggs", - flags: [], - quantity: { - type: "fixed", - value: { type: "decimal", value: 3 }, - }, - quantityParts: [ + quantities: [ { - value: { - type: "fixed", - value: { type: "decimal", value: 3 }, + groupQuantity: { + quantity: { + type: "fixed", + value: { type: "decimal", decimal: 3 }, + }, + unit: undefined, }, - unit: undefined, - scalable: true, }, ], - unit: undefined, - preparation: undefined, + usedAsPrimary: true, }, + ]); + }); + + it("throw an error if quantity has invalid format", () => { + const recipe = "Add @flour{%two}"; + expect(() => new Recipe(recipe)).toThrowError(InvalidQuantityFormat); + }); + + it("extracts plain unquantified single-word ingredient correctly", () => { + const result = new Recipe("@flour"); + expect(result.ingredients).toEqual([ { name: "flour", - flags: [], - quantity: undefined, - unit: undefined, - quantityParts: undefined, - preparation: undefined, + usedAsPrimary: true, }, + ]); + }); + + it("extracts plain unquantified multi-word ingredient correctly", () => { + const result = new Recipe("@coarse salt{}"); + expect(result.ingredients).toEqual([ { name: "coarse salt", - flags: [], - preparation: undefined, - quantity: undefined, - unit: undefined, - quantityParts: undefined, + usedAsPrimary: true, }, + ]); + }); + + it("extracts single-word ingredient with quantity and unit correctly", () => { + const result = new Recipe("@butter{30%g}"); + expect(result.ingredients).toEqual([ { name: "butter", - flags: [], - quantity: { - type: "fixed", - value: { type: "decimal", value: 50 }, - }, - unit: "g", - quantityParts: [ + quantities: [ { - value: { - type: "fixed", - value: { type: "decimal", value: 50 }, + groupQuantity: { + quantity: { + type: "fixed", + value: { type: "decimal", decimal: 30 }, + }, + unit: "g", }, - unit: "g", - scalable: true, }, ], - preparation: undefined, + usedAsPrimary: true, }, ]); }); @@ -102,35 +141,32 @@ describe("parse function", () => { `; const result1 = new Recipe(recipe1); - const expected_dough = { + const expected_dough: Ingredient = { name: "pizza dough", - quantity: { type: "fixed", value: { type: "decimal", value: 1 } }, - quantityParts: [ + quantities: [ { - value: { - type: "fixed", - value: { type: "decimal", value: 1 }, + groupQuantity: { + quantity: { + type: "fixed", + value: { type: "decimal", decimal: 1 }, + }, + unit: undefined, }, - unit: undefined, - scalable: true, }, ], - unit: undefined, - preparation: undefined, flags: ["recipe"], extras: { path: "pizza dough.cook", }, + usedAsPrimary: true, }; - const expected_toppings = { + const expected_toppings: Ingredient = { name: "toppings", - quantity: undefined, - unit: undefined, - preparation: undefined, flags: ["recipe"], extras: { path: "toppings.cook", }, + usedAsPrimary: true, }; expect(result1.ingredients).toHaveLength(2); @@ -155,35 +191,32 @@ describe("parse function", () => { `; const result1 = new Recipe(recipe1); - const expected_dough = { + const expected_dough: Ingredient = { name: "pizza dough", - quantity: { type: "fixed", value: { type: "decimal", value: 1 } }, - quantityParts: [ + quantities: [ { - value: { - type: "fixed", - value: { type: "decimal", value: 1 }, + groupQuantity: { + quantity: { + type: "fixed", + value: { type: "decimal", decimal: 1 }, + }, + unit: undefined, }, - unit: undefined, - scalable: true, }, ], - unit: undefined, - preparation: undefined, flags: ["recipe"], extras: { path: "some essentials/my.doughs/pizza dough.cook", }, + usedAsPrimary: true, }; - const expected_toppings = { + const expected_toppings: Ingredient = { name: "toppings", - quantity: undefined, - unit: undefined, - preparation: undefined, flags: ["recipe"], extras: { path: "../some-essentials/toppings.cook", }, + usedAsPrimary: true, }; expect(result1.ingredients).toHaveLength(2); @@ -211,37 +244,35 @@ describe("parse function", () => { expect(result.ingredients).toHaveLength(2); expect(result.ingredients[0]).toEqual({ name: "wheat flour", - quantity: { type: "fixed", value: { type: "decimal", value: 100 } }, - quantityParts: [ + quantities: [ { - value: { - type: "fixed", - value: { type: "decimal", value: 100 }, + groupQuantity: { + quantity: { + type: "fixed", + value: { type: "decimal", decimal: 100 }, + }, + unit: "g", }, - unit: "g", - scalable: true, }, ], - unit: "g", preparation: "sifted", - flags: [], + usedAsPrimary: true, }); expect(result.ingredients[1]).toEqual({ name: "eggs", - quantity: { type: "fixed", value: { type: "decimal", value: 2 } }, - unit: undefined, - quantityParts: [ + quantities: [ { - value: { - type: "fixed", - value: { type: "decimal", value: 2 }, + groupQuantity: { + quantity: { + type: "fixed", + value: { type: "decimal", decimal: 2 }, + }, + unit: undefined, }, - unit: undefined, - scalable: true, }, ], preparation: "large, beaten", - flags: [], + usedAsPrimary: true, }); }); @@ -255,17 +286,12 @@ describe("parse function", () => { expect(result.ingredients[0]).toEqual({ name: "salt", flags: ["hidden"], - quantity: undefined, - quantityParts: undefined, - unit: undefined, - preparation: undefined, + usedAsPrimary: true, }); expect(result.ingredients[1]).toEqual({ name: "pepper", flags: ["optional"], - quantity: undefined, - unit: undefined, - preparation: undefined, + usedAsPrimary: true, }); }); @@ -278,10 +304,7 @@ describe("parse function", () => { expect(result.ingredients[0]).toEqual({ name: "salt", flags: ["optional", "hidden"], - quantity: undefined, - quantityParts: undefined, - unit: undefined, - preparation: undefined, + usedAsPrimary: true, }); }); @@ -289,50 +312,6 @@ describe("parse function", () => { const recipe = new Recipe(`Mix @flour tipo 00|flour{100%g} with @water{300%mL}, then add more @&flour tipo 00|flour{50%g}`); - expect(recipe.ingredients).toEqual([ - { - name: "flour tipo 00", - quantity: { type: "fixed", value: { type: "decimal", value: 150 } }, - quantityParts: [ - { - value: { - type: "fixed", - value: { type: "decimal", value: 100 }, - }, - unit: "g", - scalable: true, - }, - { - value: { - type: "fixed", - value: { type: "decimal", value: 50 }, - }, - unit: "g", - scalable: true, - }, - ], - unit: "g", - flags: [], - preparation: undefined, - }, - { - name: "water", - quantity: { type: "fixed", value: { type: "decimal", value: 300 } }, - unit: "mL", - quantityParts: [ - { - value: { - type: "fixed", - value: { type: "decimal", value: 300 }, - }, - unit: "mL", - scalable: true, - }, - ], - flags: [], - preparation: undefined, - }, - ]); expect(recipe.sections[0]?.content).toEqual([ { type: "step", @@ -342,20 +321,44 @@ describe("parse function", () => { value: "Mix ", }, { - displayName: "flour", - quantityPartIndex: 0, type: "ingredient", - index: 0, + id: "ingredient-item-0", + alternatives: [ + { + displayName: "flour", + index: 0, + itemQuantity: { + scalable: true, + quantity: { + type: "fixed", + value: { type: "decimal", decimal: 100 }, + }, + unit: { name: "g" }, + }, + }, + ], }, { type: "text", value: " with ", }, { - displayName: "water", - quantityPartIndex: 0, type: "ingredient", - index: 1, + id: "ingredient-item-1", + alternatives: [ + { + displayName: "water", + index: 1, + itemQuantity: { + scalable: true, + quantity: { + type: "fixed", + value: { type: "decimal", decimal: 300 }, + }, + unit: { name: "mL" }, + }, + }, + ], }, { type: "text", @@ -366,12 +369,56 @@ describe("parse function", () => { value: " then add more ", }, { - displayName: "flour", - quantityPartIndex: 1, type: "ingredient", - index: 0, + id: "ingredient-item-2", + alternatives: [ + { + displayName: "flour", + index: 0, + itemQuantity: { + scalable: true, + quantity: { + type: "fixed", + value: { type: "decimal", decimal: 50 }, + }, + unit: { name: "g" }, + }, + }, + ], + }, + ], + }, + ]); + expect(recipe.ingredients).toEqual([ + { + name: "flour tipo 00", + quantities: [ + { + groupQuantity: { + quantity: { + type: "fixed", + value: { type: "decimal", decimal: 150 }, + }, + unit: "g", + }, + }, + ], + usedAsPrimary: true, + }, + { + name: "water", + quantities: [ + { + groupQuantity: { + quantity: { + type: "fixed", + value: { type: "decimal", decimal: 300 }, + }, + unit: "mL", + }, }, ], + usedAsPrimary: true, }, ]); }); @@ -385,30 +432,27 @@ describe("parse function", () => { `; const result = new Recipe(recipe); expect(result.ingredients).toHaveLength(1); + // Quantities are merged opportunistically when units are compatible expect(result.ingredients[0]).toEqual({ name: "flour", - quantity: { type: "fixed", value: { type: "decimal", value: 150 } }, - quantityParts: [ - { - value: { - type: "fixed", - value: { type: "decimal", value: 100 }, - }, - unit: "g", - scalable: true, - }, + quantities: [ { - value: { - type: "fixed", - value: { type: "decimal", value: 50 }, + groupQuantity: { + quantity: { + type: "fixed", + value: { type: "decimal", decimal: 150 }, + }, + unit: "g", }, - unit: "g", - scalable: true, }, ], + usedAsPrimary: true, + }); + // calc_ingredient_quantities returns the same result + const computed = result.calc_ingredient_quantities(); + expect(computed[0]!.quantityTotal).toEqual({ + quantity: { type: "fixed", value: { type: "decimal", decimal: 150 } }, unit: "g", - preparation: undefined, - flags: [], }); }); @@ -430,9 +474,21 @@ describe("parse function", () => { }, { type: "ingredient", - displayName: "flour", - index: 0, - quantityPartIndex: 0, + id: "ingredient-item-0", + alternatives: [ + { + displayName: "flour", + index: 0, + itemQuantity: { + scalable: true, + quantity: { + type: "fixed", + value: { type: "decimal", decimal: 100 }, + }, + unit: { name: "g" }, + }, + }, + ], }, { type: "text", @@ -443,10 +499,22 @@ describe("parse function", () => { value: " Then add some more ", }, { - quantityPartIndex: 1, type: "ingredient", - displayName: "flour", - index: 0, + id: "ingredient-item-1", + alternatives: [ + { + displayName: "flour", + index: 0, + itemQuantity: { + scalable: true, + quantity: { + type: "fixed", + value: { type: "decimal", decimal: 50 }, + }, + unit: { name: "g" }, + }, + }, + ], }, { type: "text", @@ -466,37 +534,33 @@ describe("parse function", () => { expect(result.ingredients).toHaveLength(2); expect(result.ingredients[0]).toEqual({ name: "flour", - quantity: { type: "fixed", value: { type: "decimal", value: 100 } }, - unit: "g", - quantityParts: [ + quantities: [ { - value: { - type: "fixed", - value: { type: "decimal", value: 100 }, + groupQuantity: { + quantity: { + type: "fixed", + value: { type: "decimal", decimal: 100 }, + }, + unit: "g", }, - unit: "g", - scalable: true, }, ], - flags: [], - preparation: undefined, + usedAsPrimary: true, }); expect(result.ingredients[1]).toEqual({ name: "flour", - quantity: { type: "fixed", value: { type: "decimal", value: 50 } }, - unit: "g", - quantityParts: [ + quantities: [ { - value: { - type: "fixed", - value: { type: "decimal", value: 50 }, + groupQuantity: { + quantity: { + type: "fixed", + value: { type: "decimal", decimal: 50 }, + }, + unit: "g", }, - unit: "g", - scalable: true, }, ], - flags: [], - preparation: undefined, + usedAsPrimary: true, }); }); @@ -505,34 +569,22 @@ describe("parse function", () => { expect(result.ingredients).toEqual([ { name: "eggs", - flags: [], preparation: "boiled", - quantity: { - type: "fixed", - value: { - type: "decimal", - value: 2, - }, - }, - unit: undefined, - quantityParts: [ - { - value: { - type: "fixed", - value: { type: "decimal", value: 1 }, - }, - unit: undefined, - scalable: true, - }, + quantities: [ { - value: { - type: "fixed", - value: { type: "decimal", value: 1 }, + groupQuantity: { + quantity: { + type: "fixed", + value: { + type: "decimal", + decimal: 2, + }, + }, + unit: undefined, }, - unit: undefined, - scalable: true, }, ], + usedAsPrimary: true, }, ]); expect(result.sections[0]?.content).toEqual([ @@ -544,20 +596,42 @@ describe("parse function", () => { value: "Mix ", }, { - quantityPartIndex: 0, type: "ingredient", - displayName: "eggs", - index: 0, + id: "ingredient-item-0", + alternatives: [ + { + displayName: "eggs", + index: 0, + itemQuantity: { + scalable: true, + quantity: { + type: "fixed", + value: { type: "decimal", decimal: 1 }, + }, + }, + }, + ], }, { type: "text", value: " and ", }, { - quantityPartIndex: 1, type: "ingredient", - displayName: "eggs", - index: 0, + id: "ingredient-item-1", + alternatives: [ + { + displayName: "eggs", + index: 0, + itemQuantity: { + scalable: true, + quantity: { + type: "fixed", + value: { type: "decimal", decimal: 1 }, + }, + }, + }, + ], }, ], }, @@ -573,28 +647,18 @@ describe("parse function", () => { expect(result.ingredients).toHaveLength(1); expect(result.ingredients[0]).toEqual({ name: "Sugar", // Note: original casing is preserved - quantity: { type: "fixed", value: { type: "decimal", value: 150 } }, - unit: "g", - quantityParts: [ - { - value: { - type: "fixed", - value: { type: "decimal", value: 100 }, - }, - unit: "g", - scalable: true, - }, + quantities: [ { - value: { - type: "fixed", - value: { type: "decimal", value: 50 }, + groupQuantity: { + quantity: { + type: "fixed", + value: { type: "decimal", decimal: 150 }, + }, + unit: "g", }, - unit: "g", - scalable: true, }, ], - flags: [], - preparation: undefined, + usedAsPrimary: true, }); }); @@ -607,28 +671,18 @@ describe("parse function", () => { expect(result.ingredients).toHaveLength(1); expect(result.ingredients[0]).toEqual({ name: "sugar", - quantity: { type: "fixed", value: { type: "decimal", value: 1.5 } }, - unit: "kg", - quantityParts: [ - { - value: { - type: "fixed", - value: { type: "decimal", value: 500 }, - }, - unit: "g", - scalable: true, - }, + quantities: [ { - value: { - type: "fixed", - value: { type: "decimal", value: 1 }, + groupQuantity: { + quantity: { + type: "fixed", + value: { type: "decimal", decimal: 1.5 }, + }, + unit: "kg", }, - unit: "kg", - scalable: true, }, ], - flags: [], - preparation: undefined, + usedAsPrimary: true, }); }); @@ -641,11 +695,17 @@ describe("parse function", () => { expect(result.ingredients).toHaveLength(1); const butter = result.ingredients[0]!; expect(butter.name).toBe("butter"); - expect(butter.unit).toBe("kg"); // largest metric mass unit - expect(butter.quantity).toEqual({ - type: "fixed", - value: { type: "decimal", value: 0.7 }, - }); + expect(butter.quantities).toEqual([ + { + groupQuantity: { + quantity: { + type: "fixed", + value: { type: "decimal", decimal: 0.704 }, + }, + unit: "kg", + }, + }, + ]); // TODO: 700g would be more elegant }); @@ -663,46 +723,36 @@ describe("parse function", () => { ); }); - it("adds a referenced ingredient as a new ingredient when units are incompatible", () => { + it("simply add quantities to the referenced ingredients as separate ones if units are incompatible", () => { const recipe = ` Add @water{1%l}. Then add some more @&water{1%kg}. `; const result = new Recipe(recipe); - expect(result.ingredients).toHaveLength(2); + expect(result.ingredients).toHaveLength(1); expect(result.ingredients[0]).toEqual({ name: "water", - quantity: { type: "fixed", value: { type: "decimal", value: 1 } }, - unit: "l", - quantityParts: [ + quantities: [ { - value: { - type: "fixed", - value: { type: "decimal", value: 1 }, + groupQuantity: { + quantity: { + type: "fixed", + value: { type: "decimal", decimal: 1 }, + }, + unit: "l", }, - unit: "l", - scalable: true, }, - ], - flags: [], - preparation: undefined, - }); - expect(result.ingredients[1]).toEqual({ - name: "water", - quantity: { type: "fixed", value: { type: "decimal", value: 1 } }, - unit: "kg", - quantityParts: [ { - value: { - type: "fixed", - value: { type: "decimal", value: 1 }, + groupQuantity: { + quantity: { + type: "fixed", + value: { type: "decimal", decimal: 1 }, + }, + unit: "kg", }, - unit: "kg", - scalable: true, }, ], - flags: [], - preparation: undefined, + usedAsPrimary: true, }); }); }); @@ -713,15 +763,10 @@ describe("parse function", () => { expect(result.cookware.length).toBe(2); expect(result.cookware[0]).toEqual({ name: "bowl", - quantity: { type: "fixed", value: { type: "decimal", value: 1 } }, - quantityParts: [ - { type: "fixed", value: { type: "decimal", value: 1 } }, - ], - flags: [], + quantity: { type: "fixed", value: { type: "decimal", decimal: 1 } }, }); expect(result.cookware[1]).toEqual({ name: "pan", - flags: [], }); }); @@ -749,18 +794,8 @@ describe("parse function", () => { expect(result.cookware).toHaveLength(1); expect(result.cookware[0]!.quantity).toEqual({ type: "fixed", - value: { type: "decimal", value: 3 }, + value: { type: "decimal", decimal: 3 }, }); - expect(result.cookware[0]!.quantityParts).toEqual([ - { - type: "fixed", - value: { type: "decimal", value: 1 }, - }, - { - type: "fixed", - value: { type: "decimal", value: 2 }, - }, - ]); }); it("should correctly handle modifiers for cookware", () => { @@ -771,7 +806,6 @@ describe("parse function", () => { expect(result.cookware).toHaveLength(3); expect(result.cookware[0]).toEqual({ name: "oven", - flags: [], }); expect(result.cookware[1]).toEqual({ name: "pan", @@ -795,8 +829,7 @@ describe("parse function", () => { const result = new Recipe(simpleRecipe); expect(result.timers.length).toBe(1); expect(result.timers[0]).toEqual({ - name: undefined, - duration: { type: "fixed", value: { type: "decimal", value: 15 } }, + duration: { type: "fixed", value: { type: "decimal", decimal: 15 } }, unit: "minutes", }); // Note: timer name may be empty based on regex }); @@ -892,4 +925,1283 @@ Another step. const result = new Recipe(complexRecipe); expect(result).toMatchSnapshot(); }); + + describe("grouped alternative ingredients", () => { + describe("parses ingredients that are other recipes", () => { + it("parses a recipe in the same directory as the current recipe", () => { + const recipe1 = ` + Defrost @|dough|@pizza dough{1} and form it into a nice disc + And @|toppings|@toppings on top + `; + const result1 = new Recipe(recipe1); + + const expected_dough: Ingredient = { + name: "pizza dough", + quantities: [ + { + groupQuantity: { + quantity: { + type: "fixed", + value: { type: "decimal", decimal: 1 }, + }, + unit: undefined, + }, + }, + ], + flags: ["recipe"], + extras: { + path: "pizza dough.cook", + }, + usedAsPrimary: true, + }; + const expected_toppings: Ingredient = { + name: "toppings", + flags: ["recipe"], + extras: { + path: "toppings.cook", + }, + usedAsPrimary: true, + }; + + expect(result1.ingredients).toHaveLength(2); + expect(result1.choices.ingredientGroups.has("dough")).toBe(true); + expect(result1.ingredients[0]).toEqual(expected_dough); + expect(result1.ingredients[1]).toEqual(expected_toppings); + + const recipe2 = ` + Defrost @|dough|./pizza dough{1} and form it into a nice disc + And @|toppings|./toppings on top + `; + const result2 = new Recipe(recipe2); + + expect(result2.ingredients).toHaveLength(2); + expect(result2.choices.ingredientGroups.has("dough")).toBe(true); + expect(result2.ingredients[0]).toEqual(expected_dough); + expect(result2.ingredients[1]).toEqual(expected_toppings); + expect(result2.choices.ingredientGroups.has("dough")).toBe(true); + }); + + it("parses a recipe in a different relative directory", () => { + const recipe1 = ` + Defrost @|dough|@some essentials/my.doughs/pizza dough{1} and form it into a nice disc + And @|toppings|@../some-essentials/toppings on top + `; + const result1 = new Recipe(recipe1); + + const expected_dough: Ingredient = { + name: "pizza dough", + quantities: [ + { + groupQuantity: { + quantity: { + type: "fixed", + value: { type: "decimal", decimal: 1 }, + }, + unit: undefined, + }, + }, + ], + flags: ["recipe"], + extras: { + path: "some essentials/my.doughs/pizza dough.cook", + }, + usedAsPrimary: true, + }; + const expected_toppings: Ingredient = { + name: "toppings", + flags: ["recipe"], + extras: { + path: "../some-essentials/toppings.cook", + }, + usedAsPrimary: true, + }; + + expect(result1.ingredients).toHaveLength(2); + expect(result1.ingredients[0]).toEqual(expected_dough); + expect(result1.ingredients[1]).toEqual(expected_toppings); + + const recipe2 = ` + Defrost @./some essentials/my.doughs/pizza dough{1} and form it into a nice disc + And @./../some-essentials/toppings{} on top + `; + const result2 = new Recipe(recipe2); + + expect(result2.ingredients).toHaveLength(2); + expect(result2.ingredients[0]).toEqual(expected_dough); + expect(result2.ingredients[1]).toEqual(expected_toppings); + }); + }); + + it("parses ingredients with preparation", () => { + const recipe = ` + Add some @|flour|wheat flour{100%g}(sifted). + And @|eggs|eggs{2}(large, beaten). + `; + const result = new Recipe(recipe); + expect(result.ingredients).toHaveLength(2); + expect(result.choices.ingredientGroups.has("flour")).toBe(true); + expect(result.ingredients[0]).toEqual({ + name: "wheat flour", + quantities: [ + { + groupQuantity: { + quantity: { + type: "fixed", + value: { type: "decimal", decimal: 100 }, + }, + unit: "g", + }, + }, + ], + preparation: "sifted", + usedAsPrimary: true, + }); + expect(result.choices.ingredientGroups.has("eggs")).toBe(true); + expect(result.ingredients[1]).toEqual({ + name: "eggs", + quantities: [ + { + groupQuantity: { + quantity: { + type: "fixed", + value: { type: "decimal", decimal: 2 }, + }, + unit: undefined, + }, + }, + ], + preparation: "large, beaten", + usedAsPrimary: true, + }); + }); + + it("parses hidden or optional ingredients", () => { + const recipe = ` + Add some @|spices|-salt{}. + or maybe some @|spices|?pepper{}. + `; + const result = new Recipe(recipe); + expect(result.ingredients).toHaveLength(2); + expect(result.choices.ingredientGroups.has("spices")).toBe(true); + expect(result.ingredients[0]).toEqual({ + name: "salt", + flags: ["hidden"], + alternatives: new Set([1]), + usedAsPrimary: true, + }); + expect(result.ingredients[1]).toEqual({ + name: "pepper", + flags: ["optional"], + alternatives: new Set([0]), + }); + }); + + it("parses hidden and optional ingredients", () => { + const recipe = ` + Potentially add some @|spices|-?salt{}. + `; + const result = new Recipe(recipe); + expect(result.ingredients).toHaveLength(1); + expect(result.choices.ingredientGroups.has("spices")).toBe(true); + expect(result.ingredients[0]).toEqual({ + name: "salt", + flags: ["optional", "hidden"], + usedAsPrimary: true, + }); + }); + + it("detects and correctly extracts ingredients aliases and references", () => { + const recipe = + new Recipe(`Mix @flour tipo 00{100%g} with either an extra @|flour|&flour tipo 00|same flour{100%g}, + or @|flour|flour tipo 1|whole wheat flour{50%g}`); + expect(recipe.sections[0]?.content).toEqual([ + { + type: "step", + items: [ + { + type: "text", + value: "Mix ", + }, + { + type: "ingredient", + id: "ingredient-item-0", + alternatives: [ + { + displayName: "flour tipo 00", + index: 0, + itemQuantity: { + scalable: true, + quantity: { + type: "fixed", + value: { type: "decimal", decimal: 100 }, + }, + unit: { name: "g" }, + }, + }, + ], + }, + { + type: "text", + value: " with either an extra ", + }, + { + type: "ingredient", + id: "ingredient-item-1", + group: "flour", + alternatives: [ + { + displayName: "same flour", + index: 0, + itemQuantity: { + scalable: true, + quantity: { + type: "fixed", + value: { type: "decimal", decimal: 100 }, + }, + unit: { name: "g" }, + }, + }, + ], + }, + { + type: "text", + value: ", ", + }, + { + type: "text", + value: " or ", + }, + { + type: "ingredient", + id: "ingredient-item-2", + group: "flour", + alternatives: [ + { + displayName: "whole wheat flour", + index: 1, + itemQuantity: { + scalable: true, + quantity: { + type: "fixed", + value: { type: "decimal", decimal: 50 }, + }, + unit: { name: "g" }, + }, + }, + ], + }, + ], + }, + ]); + expect(recipe.ingredients).toEqual([ + { + name: "flour tipo 00", + quantities: [ + { + groupQuantity: { + quantity: { + type: "fixed", + value: { type: "decimal", decimal: 100 }, + }, + unit: "g", + }, + }, + { + groupQuantity: { + quantity: { + type: "fixed", + value: { type: "decimal", decimal: 100 }, + }, + unit: "g", + }, + alternatives: [ + { + index: 1, + alternativeQuantities: [ + { + quantity: { + type: "fixed", + value: { type: "decimal", decimal: 50 }, + }, + unit: "g", + }, + ], + }, + ], + }, + ], + alternatives: new Set([1]), + usedAsPrimary: true, + }, + { + name: "flour tipo 1", + alternatives: new Set([0]), + }, + ]); + }); + + it("parses grouped alternatives correctly", () => { + const result = new Recipe(recipeWithGroupedAlternatives); + expect(result.ingredients).toHaveLength(3); + // All ingredients have their quantities stored as they appear in the recipe + // For grouped alternatives, the primary ingredient's quantities field includes alternatives + const milkIngredient: Ingredient = { + name: "milk", + quantities: [ + { + groupQuantity: { + quantity: { + type: "fixed", + value: { type: "decimal", decimal: 200 }, + }, + unit: "ml", + }, + alternatives: [ + { + index: 1, + alternativeQuantities: [ + { + quantity: { + type: "fixed", + value: { type: "decimal", decimal: 100 }, + }, + unit: "ml", + }, + ], + }, + { + index: 2, + alternativeQuantities: [ + { + quantity: { + type: "fixed", + value: { type: "decimal", decimal: 150 }, + }, + unit: "ml", + }, + ], + }, + ], + }, + ], + alternatives: new Set([1, 2]), + usedAsPrimary: true, + }; + expect(result.ingredients[0]).toEqual(milkIngredient); + // Non-primary grouped alternatives don't have usedAsPrimary or quantities + const almondMilkIngredient: Ingredient = { + name: "almond milk", + alternatives: new Set([0, 2]), + }; + expect(result.ingredients[1]).toEqual(almondMilkIngredient); + const soyMilkIngredient: Ingredient = { + name: "soy milk", + alternatives: new Set([0, 1]), + }; + expect(result.ingredients[2]).toEqual(soyMilkIngredient); + // Choices should be those by default + expect(result.choices).toEqual({ + ingredientGroups: new Map([ + [ + "milk", + [ + { + displayName: "milk", + index: 0, + itemId: "ingredient-item-0", + itemQuantity: { + scalable: true, + quantity: { + type: "fixed", + value: { type: "decimal", decimal: 200 }, + }, + unit: { name: "ml" }, + }, + }, + { + displayName: "almond milk", + index: 1, + itemId: "ingredient-item-1", + itemQuantity: { + scalable: true, + quantity: { + type: "fixed", + value: { type: "decimal", decimal: 100 }, + }, + unit: { name: "ml" }, + }, + }, + { + displayName: "soy milk", + index: 2, + itemId: "ingredient-item-2", + itemQuantity: { + scalable: true, + quantity: { + type: "fixed", + value: { type: "decimal", decimal: 150 }, + }, + unit: { name: "ml" }, + }, + }, + ], + ], + ]), + ingredientItems: new Map(), + }); + // The ingredient items should contain the right fields + const firstIngredientItem = result.sections[0]?.content[0]; + if (firstIngredientItem?.type !== "step") return false; + expect(firstIngredientItem.items[1]).toEqual({ + alternatives: [ + { + displayName: "milk", + index: 0, + itemQuantity: { + quantity: { + type: "fixed", + value: { + decimal: 200, + type: "decimal", + }, + }, + unit: { + name: "ml", + }, + scalable: true, + }, + }, + ], + group: "milk", + id: "ingredient-item-0", + type: "ingredient", + }); + }); + }); + + describe("in-line alternative ingredients", () => { + it("parses quantified in-line alternative ingredients correctly", () => { + const result = new Recipe(recipeWithInlineAlternatives); + expect(result.ingredients).toHaveLength(3); + // Only the primary ingredient (milk) has quantities stored and usedAsPrimary flag + const milkIngredient: Ingredient = { + name: "milk", + quantities: [ + { + groupQuantity: { + quantity: { + type: "fixed", + value: { type: "decimal", decimal: 200 }, + }, + unit: "ml", + }, + alternatives: [ + { + index: 1, + alternativeQuantities: [ + { + quantity: { + type: "fixed", + value: { type: "decimal", decimal: 100 }, + }, + unit: "ml", + }, + ], + }, + { + index: 2, + alternativeQuantities: [ + { + quantity: { + type: "fixed", + value: { type: "decimal", decimal: 150 }, + }, + unit: "ml", + }, + ], + }, + ], + }, + ], + alternatives: new Set([1, 2]), + usedAsPrimary: true, + }; + expect(result.ingredients[0]).toEqual(milkIngredient); + // Alternative-only ingredients have no usedAsPrimary flag and no quantities + const almondMilkIngredient: Ingredient = { + name: "almond milk", + alternatives: new Set([0, 2]), + }; + expect(result.ingredients[1]).toEqual(almondMilkIngredient); + const soyMilkIngredient: Ingredient = { + name: "soy milk", + alternatives: new Set([0, 1]), + }; + expect(result.ingredients[2]).toEqual(soyMilkIngredient); + + // Choices should be those by default + expect(result.choices).toEqual({ + ingredientItems: new Map([ + [ + "ingredient-item-0", + [ + { + displayName: "milk", + index: 0, + itemQuantity: { + scalable: true, + quantity: { + type: "fixed", + value: { type: "decimal", decimal: 200 }, + }, + unit: { name: "ml" }, + }, + }, + { + displayName: "almond milk", + index: 1, + note: "vegan version", + itemQuantity: { + scalable: true, + quantity: { + type: "fixed", + value: { type: "decimal", decimal: 100 }, + }, + unit: { name: "ml" }, + }, + }, + { + displayName: "soy milk", + index: 2, + note: "another vegan option", + itemQuantity: { + scalable: true, + quantity: { + type: "fixed", + value: { type: "decimal", decimal: 150 }, + }, + unit: { name: "ml" }, + }, + }, + ], + ], + ]), + ingredientGroups: new Map(), + }); + }); + + it("parses unquantified in-line alternative ingredients correctly", () => { + const recipe = "Add @sea salt{some}|fleur de sel{}"; + const result = new Recipe(recipe); + expect(result.ingredients).toHaveLength(2); + const seaSaltIngredient: Ingredient = { + name: "sea salt", + quantities: [ + { + groupQuantity: { + quantity: { + type: "fixed", + value: { type: "text", text: "some" }, + }, + unit: undefined, + }, + alternatives: [ + { + index: 1, + }, + ], + }, + ], + alternatives: new Set([1]), + usedAsPrimary: true, + }; + expect(result.ingredients[0]).toEqual(seaSaltIngredient); + const fleurDeSelIngredient: Ingredient = { + name: "fleur de sel", + alternatives: new Set([0]), + }; + expect(result.ingredients[1]).toEqual(fleurDeSelIngredient); + }); + + it("should correctly show alternatives to ingredients subject of variants multiple times", () => { + const recipe = ` + Use @sugar{100%g}|brown sugar{100%g}[for a richer flavor] in the mix. + Then sprinkle some more @&sugar{50%g}|powder sugar{50%g} on top before serving. + `; + const result = new Recipe(recipe); + expect(result.ingredients).toHaveLength(3); + // Sugar ingredient has two quantity entries with different alternatives (not merged) + const sugarIngredient: Ingredient = { + name: "sugar", + quantities: [ + { + groupQuantity: { + quantity: { + type: "fixed", + value: { type: "decimal", decimal: 100 }, + }, + unit: "g", + }, + alternatives: [ + { + index: 1, + alternativeQuantities: [ + { + quantity: { + type: "fixed", + value: { type: "decimal", decimal: 100 }, + }, + unit: "g", + }, + ], + }, + ], + }, + { + groupQuantity: { + quantity: { + type: "fixed", + value: { type: "decimal", decimal: 50 }, + }, + unit: "g", + }, + alternatives: [ + { + index: 2, + alternativeQuantities: [ + { + quantity: { + type: "fixed", + value: { type: "decimal", decimal: 50 }, + }, + unit: "g", + }, + ], + }, + ], + }, + ], + alternatives: new Set([1, 2]), + usedAsPrimary: true, + }; + expect(result.ingredients[0]).toEqual(sugarIngredient); + + const recipe2 = ` + Use @sugar{100%g}|brown sugar{100%g}[for a richer flavor] in the mix. + Then sprinkle some more @&sugar{50%g}|powder sugar{50%g}|&brown sugar{50%g} on top before serving. + `; + const result2 = new Recipe(recipe2); + expect(result2.ingredients).toHaveLength(3); + // Sugar ingredient has two quantity entries with different alternatives (not merged) + const sugarIngredient2: Ingredient = { + name: "sugar", + quantities: [ + { + groupQuantity: { + quantity: { + type: "fixed", + value: { type: "decimal", decimal: 100 }, + }, + unit: "g", + }, + alternatives: [ + { + index: 1, + alternativeQuantities: [ + { + quantity: { + type: "fixed", + value: { type: "decimal", decimal: 100 }, + }, + unit: "g", + }, + ], + }, + ], + }, + { + groupQuantity: { + quantity: { + type: "fixed", + value: { type: "decimal", decimal: 50 }, + }, + unit: "g", + }, + alternatives: [ + { + index: 2, + alternativeQuantities: [ + { + quantity: { + type: "fixed", + value: { type: "decimal", decimal: 50 }, + }, + unit: "g", + }, + ], + }, + { + index: 1, + alternativeQuantities: [ + { + quantity: { + type: "fixed", + value: { type: "decimal", decimal: 50 }, + }, + unit: "g", + }, + ], + }, + ], + }, + ], + alternatives: new Set([1, 2]), + usedAsPrimary: true, + }; + expect(result2.ingredients[0]).toEqual(sugarIngredient2); + }); + + it("should correctly handle alternative ingredients without quantity", () => { + const recipe = "Use @salt{}|pepper to spice things up"; + const result = new Recipe(recipe); + expect(result.ingredients).toHaveLength(2); + const saltIngredient: Ingredient = { + name: "salt", + alternatives: new Set([1]), + usedAsPrimary: true, + }; + expect(result.ingredients[0]).toEqual(saltIngredient); + const pepperIngredient: Ingredient = { + name: "pepper", + alternatives: new Set([0]), + }; + expect(result.ingredients[1]).toEqual(pepperIngredient); + }); + + it("should correctly sum up sets of quantities with the same alternative that can't be summed up", () => { + const recipe = + "Add @aubergine{1}|carrot{1%large} and more this time @&aubergine{1}|&carrot{2%small}"; + const result = new Recipe(recipe); + expect(result.ingredients).toHaveLength(2); + // Aubergine ingredient has quantities summed + // Carrot alternative has incompatible units (large, small) so they're separate entries + const aubergineIngredient: Ingredient = { + name: "aubergine", + quantities: [ + { + groupQuantity: { + quantity: { + type: "fixed", + value: { type: "decimal", decimal: 2 }, + }, + unit: undefined, + }, + alternatives: [ + { + index: 1, + alternativeQuantities: [ + { + quantity: { + type: "fixed", + value: { type: "decimal", decimal: 1 }, + }, + unit: "large", + }, + { + quantity: { + type: "fixed", + value: { type: "decimal", decimal: 2 }, + }, + unit: "small", + }, + ], + }, + ], + }, + ], + usedAsPrimary: true, + alternatives: new Set([1]), + }; + expect(result.ingredients[0]).toEqual(aubergineIngredient); + }); + + it("should correctly sum up sets of quantities with their alternatives if the ingredients involved are the same", () => { + const recipe = ` + Use @sugar{100%g}|brown sugar{100%g}|powder sugar{100%g}[for a richer flavor] in the mix. + Then sprinkle some more @&sugar{50%g}|&powder sugar{30%g}|&brown sugar{20%g} on top before serving. + `; + const result = new Recipe(recipe); + expect(result.ingredients).toHaveLength(3); + // Sugar ingredient has a single quantity entry with summed main quantity + // Alternatives are also summed: brown sugar 100+20=120, powder sugar 100+30=130 + const sugarIngredient: Ingredient = { + name: "sugar", + quantities: [ + { + groupQuantity: { + quantity: { + type: "fixed", + value: { type: "decimal", decimal: 150 }, + }, + unit: "g", + }, + alternatives: [ + { + index: 1, + alternativeQuantities: [ + { + quantity: { + type: "fixed", + value: { type: "decimal", decimal: 120 }, + }, + unit: "g", + }, + ], + }, + { + index: 2, + alternativeQuantities: [ + { + quantity: { + type: "fixed", + value: { type: "decimal", decimal: 130 }, + }, + unit: "g", + }, + ], + }, + ], + }, + ], + alternatives: new Set([1, 2]), + usedAsPrimary: true, + }; + expect(result.ingredients[0]).toEqual(sugarIngredient); + }); + }); + + describe("alternative units", () => { + it("parses ingredients with alternative units correctly", () => { + const recipe = "Add @flour{1%=bag|0.22%lb|3.5%oz}"; + const result = new Recipe(recipe); + expect(result.ingredients).toHaveLength(1); + expect(result.ingredients[0]).toEqual({ + name: "flour", + quantities: [ + { + groupQuantity: { + quantity: { + type: "fixed", + value: { type: "decimal", decimal: 1 }, + }, + unit: "bag", + equivalents: [ + { + quantity: { + type: "fixed", + value: { type: "decimal", decimal: 0.22 }, + }, + unit: "lb", + }, + { + quantity: { + type: "fixed", + value: { type: "decimal", decimal: 3.5 }, + }, + unit: "oz", + }, + ], + }, + }, + ], + usedAsPrimary: true, + }); + const ingredientItem = result.sections[0]?.content[0]; + if (ingredientItem?.type !== "step") return false; + expect(ingredientItem.items[1]).toEqual({ + type: "ingredient", + id: "ingredient-item-0", + alternatives: [ + { + displayName: "flour", + index: 0, + itemQuantity: { + scalable: true, + quantity: { + type: "fixed", + value: { type: "decimal", decimal: 1 }, + }, + unit: { name: "bag", integerProtected: true }, + equivalents: [ + { + quantity: { + type: "fixed", + value: { type: "decimal", decimal: 0.22 }, + }, + unit: { name: "lb" }, + }, + { + quantity: { + type: "fixed", + value: { type: "decimal", decimal: 3.5 }, + }, + unit: { name: "oz" }, + }, + ], + }, + }, + ], + }); + }); + + it("parses inline alternatives with alternative units correctly", () => { + const recipe = "Use @flour{1%=bag|0.22%lb}|wheat flour{1%=bag|0.25%lb}"; + const result = new Recipe(recipe); + expect(result.ingredients).toHaveLength(2); + const flourIngredient: Ingredient = { + name: "flour", + quantities: [ + { + groupQuantity: { + quantity: { + type: "fixed", + value: { type: "decimal", decimal: 1 }, + }, + unit: "bag", + equivalents: [ + { + quantity: { + type: "fixed", + value: { type: "decimal", decimal: 0.22 }, + }, + unit: "lb", + }, + ], + }, + alternatives: [ + { + index: 1, + alternativeQuantities: [ + { + quantity: { + type: "fixed", + value: { type: "decimal", decimal: 1 }, + }, + unit: "bag", + equivalents: [ + { + quantity: { + type: "fixed", + value: { type: "decimal", decimal: 0.25 }, + }, + unit: "lb", + }, + ], + }, + ], + }, + ], + }, + ], + alternatives: new Set([1]), + usedAsPrimary: true, + }; + expect(result.ingredients[0]).toEqual(flourIngredient); + const wheatFlourIngredient: Ingredient = { + name: "wheat flour", + alternatives: new Set([0]), + }; + expect(result.ingredients[1]).toEqual(wheatFlourIngredient); + const ingredientItem = result.sections[0]?.content[0]; + if (ingredientItem?.type !== "step") return false; + const ingredientItem0: IngredientItem = { + type: "ingredient", + id: "ingredient-item-0", + alternatives: [ + { + displayName: "flour", + index: 0, + itemQuantity: { + scalable: true, + quantity: { + type: "fixed", + value: { type: "decimal", decimal: 1 }, + }, + unit: { name: "bag", integerProtected: true }, + equivalents: [ + { + quantity: { + type: "fixed", + value: { type: "decimal", decimal: 0.22 }, + }, + unit: { name: "lb" }, + }, + ], + }, + }, + { + displayName: "wheat flour", + index: 1, + itemQuantity: { + scalable: true, + quantity: { + type: "fixed", + value: { type: "decimal", decimal: 1 }, + }, + unit: { name: "bag", integerProtected: true }, + equivalents: [ + { + quantity: { + type: "fixed", + value: { type: "decimal", decimal: 0.25 }, + }, + unit: { name: "lb" }, + }, + ], + }, + }, + ], + }; + expect(ingredientItem.items[1]).toEqual(ingredientItem0); + }); + + it("parses grouped alternatives with alternative units correctly", () => { + const recipe = + "Add @|flour|wheat flour{1%=bag|0.22%lb} or @|flour|almond flour{1%=bag|0.25%lb}"; + const result = new Recipe(recipe); + expect(result.ingredients).toHaveLength(2); + expect(result.choices.ingredientGroups.has("flour")).toBe(true); + const ingredient0: Ingredient = { + name: "wheat flour", + quantities: [ + { + groupQuantity: { + quantity: { + type: "fixed", + value: { type: "decimal", decimal: 1 }, + }, + unit: "bag", + equivalents: [ + { + quantity: { + type: "fixed", + value: { type: "decimal", decimal: 0.22 }, + }, + unit: "lb", + }, + ], + }, + alternatives: [ + { + index: 1, + alternativeQuantities: [ + { + quantity: { + type: "fixed", + value: { type: "decimal", decimal: 1 }, + }, + unit: "bag", + equivalents: [ + { + quantity: { + type: "fixed", + value: { type: "decimal", decimal: 0.25 }, + }, + unit: "lb", + }, + ], + }, + ], + }, + ], + }, + ], + alternatives: new Set([1]), + usedAsPrimary: true, + }; + expect(result.ingredients[0]).toEqual(ingredient0); + const ingredientItem0: IngredientItem = { + type: "ingredient", + id: "ingredient-item-0", + group: "flour", + alternatives: [ + { + index: 0, + displayName: "wheat flour", + itemQuantity: { + scalable: true, + quantity: { + type: "fixed", + value: { type: "decimal", decimal: 1 }, + }, + unit: { name: "bag", integerProtected: true }, + equivalents: [ + { + quantity: { + type: "fixed", + value: { type: "decimal", decimal: 0.22 }, + }, + unit: { name: "lb" }, + }, + ], + }, + }, + ], + }; + const step = result.sections[0]?.content[0] as Step; + expect(step.items[1]).toEqual(ingredientItem0); + }); + + it("parses additions of alternative units correctly", () => { + const recipe = ` + Use @carrots{1%=large|1.5%cup} and @&carrots{2%=small|2%cup} and again @&carrots{1.5%cup}`; + const result = new Recipe(recipe); + expect(result.ingredients).toHaveLength(1); + const carrotIngredient: Ingredient = { + name: "carrots", + usedAsPrimary: true, + quantities: [ + { + type: "and", + entries: [ + { + quantity: { + type: "fixed", + value: { type: "decimal", decimal: 2 }, + }, + unit: "large", + }, + { + quantity: { + type: "fixed", + value: { type: "decimal", decimal: 2 }, + }, + unit: "small", + }, + ], + equivalents: [ + { + quantity: { + type: "fixed", + value: { type: "decimal", decimal: 5 }, + }, + unit: "cup", + }, + ], + }, + ], + }; + expect(result.ingredients[0]).toEqual(carrotIngredient); + }); + + it("parses complex combinations of alterantive units and ingredients correctly", () => { + const recipe = ` + Use @|flour-0|all-purpose flour{2%=bag|0.5%lb} or @|flour-0|whole wheat flour{1.5%=bag|0.4%lb} + and add @&all-purpose flour{1%=bag|0.25%lb} once mixed to lighten the structure. + Add another @|flour-1|&all-purpose flour{1%pinch} or @|flour-1|&whole wheat flour{1%pinch} just cause we're fancy`; + const result = new Recipe(recipe); + expect(result.ingredients).toHaveLength(2); + // Three separate quantity entries because: + // - "bag" (integer-protected) can't be summed with "pinch" (incompatible units) + // - "pinch" has no lb equivalent, so it can't share equivalents with bag + expect(result.ingredients[0]).toEqual({ + alternatives: new Set([1]), + name: "all-purpose flour", + quantities: [ + { + groupQuantity: { + quantity: { + type: "fixed", + value: { type: "decimal", decimal: 2 }, + }, + unit: "bag", + equivalents: [ + { + quantity: { + type: "fixed", + value: { type: "decimal", decimal: 0.5 }, + }, + unit: "lb", + }, + ], + }, + alternatives: [ + { + index: 1, + alternativeQuantities: [ + { + quantity: { + type: "fixed", + value: { type: "decimal", decimal: 1.5 }, + }, + unit: "bag", + equivalents: [ + { + quantity: { + type: "fixed", + value: { type: "decimal", decimal: 0.4 }, + }, + unit: "lb", + }, + ], + }, + ], + }, + ], + }, + { + groupQuantity: { + quantity: { + type: "fixed", + value: { type: "decimal", decimal: 1 }, + }, + unit: "bag", + equivalents: [ + { + quantity: { + type: "fixed", + value: { type: "decimal", decimal: 0.25 }, + }, + unit: "lb", + }, + ], + }, + }, + { + groupQuantity: { + quantity: { + type: "fixed", + value: { type: "decimal", decimal: 1 }, + }, + unit: "pinch", + }, + alternatives: [ + { + index: 1, + alternativeQuantities: [ + { + quantity: { + type: "fixed", + value: { type: "decimal", decimal: 1 }, + }, + unit: "pinch", + }, + ], + }, + ], + }, + ], + usedAsPrimary: true, + }); + }); + }); + + describe("clone", () => { + it("creates a deep clone of the recipe", () => { + const recipe = new Recipe(recipeToScaleWithAlternatives); + const recipeClone = recipe.clone(); + expect(recipeClone).toEqual(recipe); + expect(recipeClone).not.toBe(recipe); + }); + }); }); diff --git a/test/recipe_scaling.test.ts b/test/recipe_scaling.test.ts index 40f785e..e7b031e 100644 --- a/test/recipe_scaling.test.ts +++ b/test/recipe_scaling.test.ts @@ -1,8 +1,10 @@ import { describe, it, expect } from "vitest"; +import type { IngredientItem, IngredientQuantities, Step } from "../src/types"; import { Recipe } from "../src/classes/recipe"; import { recipeToScale, recipeToScaleSomeFixedQuantities, + recipeWithInlineAlternatives, } from "./fixtures/recipes"; import { recipeWithComplexServings } from "./fixtures/recipes"; @@ -12,38 +14,80 @@ describe("scaleTo", () => { it("should scale up ingredient quantities", () => { const scaledRecipe = baseRecipe.scaleTo(4); expect(scaledRecipe.ingredients.length).toBe(4); - expect(scaledRecipe.ingredients[0]!.quantity).toEqual({ - type: "fixed", - value: { type: "decimal", value: 200 }, - }); - expect(scaledRecipe.ingredients[1]!.quantity).toEqual({ - type: "fixed", - value: { type: "decimal", value: 1 }, - }); - expect(scaledRecipe.ingredients[2]!.quantity).toEqual({ - type: "range", - min: { type: "decimal", value: 4 }, - max: { type: "decimal", value: 6 }, - }); - expect(scaledRecipe.ingredients[3]!.quantity).toBeUndefined(); + expect(scaledRecipe.ingredients[0]!.quantities).toEqual([ + { + groupQuantity: { + quantity: { + type: "fixed", + value: { type: "decimal", decimal: 200 }, + }, + unit: "g", + }, + }, + ]); + expect(scaledRecipe.ingredients[1]!.quantities).toEqual([ + { + groupQuantity: { + quantity: { + type: "fixed", + value: { type: "decimal", decimal: 1 }, + }, + unit: "tsp", + }, + }, + ]); + expect(scaledRecipe.ingredients[2]!.quantities).toEqual([ + { + groupQuantity: { + quantity: { + type: "range", + min: { type: "decimal", decimal: 4 }, + max: { type: "decimal", decimal: 6 }, + }, + unit: undefined, + }, + }, + ]); + expect(scaledRecipe.ingredients[3]!.quantities).toBeUndefined(); }); it("should scale down ingredient quantities", () => { const scaledRecipe = baseRecipe.scaleTo(1); expect(scaledRecipe.ingredients.length).toBe(4); - expect(scaledRecipe.ingredients[0]!.quantity).toEqual({ - type: "fixed", - value: { type: "decimal", value: 50 }, - }); - expect(scaledRecipe.ingredients[1]!.quantity).toEqual({ - type: "fixed", - value: { type: "fraction", num: 1, den: 4 }, - }); - expect(scaledRecipe.ingredients[2]!.quantity).toEqual({ - type: "range", - min: { type: "decimal", value: 1 }, - max: { type: "decimal", value: 1.5 }, - }); + expect(scaledRecipe.ingredients[0]!.quantities).toEqual([ + { + groupQuantity: { + quantity: { + type: "fixed", + value: { type: "decimal", decimal: 50 }, + }, + unit: "g", + }, + }, + ]); + expect(scaledRecipe.ingredients[1]!.quantities).toEqual([ + { + groupQuantity: { + quantity: { + type: "fixed", + value: { type: "fraction", num: 1, den: 4 }, + }, + unit: "tsp", + }, + }, + ]); + expect(scaledRecipe.ingredients[2]!.quantities).toEqual([ + { + groupQuantity: { + quantity: { + type: "range", + min: { type: "decimal", decimal: 1 }, + max: { type: "decimal", decimal: 1.5 }, + }, + unit: undefined, + }, + }, + ]); }); it("should update the servings property", () => { @@ -62,18 +106,18 @@ describe("scaleTo", () => { it("should also scale individual quantity parts of referenced ingredients", () => { const scaledRecipe = baseRecipe.scaleTo(4); - expect(scaledRecipe.ingredients[0]!.quantityParts).toEqual([ - { - unit: "g", - value: { type: "fixed", value: { type: "decimal", value: 100 } }, - scalable: true, - }, - { - unit: "g", - value: { type: "fixed", value: { type: "decimal", value: 100 } }, - scalable: true, - }, - ]); + const step = scaledRecipe.sections[0]!.content[0] as Step; + const item1 = step.items[1] as IngredientItem; + const item3 = step.items[3] as IngredientItem; + + expect(item1.alternatives[0]?.itemQuantity).toMatchObject({ + unit: { name: "g" }, + quantity: { type: "fixed", value: { type: "decimal", decimal: 100 } }, + }); + expect(item3.alternatives[0]?.itemQuantity).toMatchObject({ + unit: { name: "g" }, + quantity: { type: "fixed", value: { type: "decimal", decimal: 100 } }, + }); }); it("should throw an error if no initial servings information", () => { @@ -81,8 +125,17 @@ describe("scaleTo", () => { recipeWithoutServings.ingredients = [ { name: "water", - quantity: { type: "fixed", value: { type: "decimal", value: 1 } }, - unit: "l", + quantities: [ + { + groupQuantity: { + quantity: { + type: "fixed", + value: { type: "decimal", decimal: 1 }, + }, + unit: "l", + }, + }, + ], flags: [], }, ]; @@ -103,10 +156,17 @@ describe("scaleTo", () => { const scaledRecipe = recipeWithNonNumericMeta.scaleTo(4); expect(scaledRecipe.ingredients.length).toBe(1); - expect(scaledRecipe.ingredients[0]!.quantity).toEqual({ - type: "fixed", - value: { type: "decimal", value: 2 }, - }); + expect(scaledRecipe.ingredients[0]!.quantities).toEqual([ + { + groupQuantity: { + quantity: { + type: "fixed", + value: { type: "decimal", decimal: 2 }, + }, + unit: "L", + }, + }, + ]); expect(scaledRecipe.servings).toBe(4); expect(scaledRecipe.metadata.servings).toBe("2, a few"); }); @@ -117,10 +177,17 @@ servings: 3 --- @eggs{6}`); const scaledRecipe = recipe.scaleTo(2); - expect(scaledRecipe.ingredients[0]!.quantity).toEqual({ - type: "fixed", - value: { type: "decimal", value: 4 }, - }); + expect(scaledRecipe.ingredients[0]!.quantities).toEqual([ + { + groupQuantity: { + quantity: { + type: "fixed", + value: { type: "decimal", decimal: 4 }, + }, + unit: undefined, + }, + }, + ]); }); }); @@ -130,38 +197,80 @@ describe("scaleBy", () => { it("should scale up ingredient quantities", () => { const scaledRecipe = baseRecipe.scaleBy(2); expect(scaledRecipe.ingredients.length).toBe(4); - expect(scaledRecipe.ingredients[0]!.quantity).toEqual({ - type: "fixed", - value: { type: "decimal", value: 200 }, - }); - expect(scaledRecipe.ingredients[1]!.quantity).toEqual({ - type: "fixed", - value: { type: "decimal", value: 1 }, - }); - expect(scaledRecipe.ingredients[2]!.quantity).toEqual({ - type: "range", - min: { type: "decimal", value: 4 }, - max: { type: "decimal", value: 6 }, - }); - expect(scaledRecipe.ingredients[3]!.quantity).toBeUndefined(); + expect(scaledRecipe.ingredients[0]!.quantities).toEqual([ + { + groupQuantity: { + quantity: { + type: "fixed", + value: { type: "decimal", decimal: 200 }, + }, + unit: "g", + }, + }, + ]); + expect(scaledRecipe.ingredients[1]!.quantities).toEqual([ + { + groupQuantity: { + quantity: { + type: "fixed", + value: { type: "decimal", decimal: 1 }, + }, + unit: "tsp", + }, + }, + ]); + expect(scaledRecipe.ingredients[2]!.quantities).toEqual([ + { + groupQuantity: { + quantity: { + type: "range", + min: { type: "decimal", decimal: 4 }, + max: { type: "decimal", decimal: 6 }, + }, + unit: undefined, + }, + }, + ]); + expect(scaledRecipe.ingredients[3]!.quantities).toBeUndefined(); }); it("should scale down ingredient quantities", () => { const scaledRecipe = baseRecipe.scaleBy(0.5); expect(scaledRecipe.ingredients.length).toBe(4); - expect(scaledRecipe.ingredients[0]!.quantity).toEqual({ - type: "fixed", - value: { type: "decimal", value: 50 }, - }); - expect(scaledRecipe.ingredients[1]!.quantity).toEqual({ - type: "fixed", - value: { type: "fraction", num: 1, den: 4 }, - }); - expect(scaledRecipe.ingredients[2]!.quantity).toEqual({ - type: "range", - min: { type: "decimal", value: 1 }, - max: { type: "decimal", value: 1.5 }, - }); + expect(scaledRecipe.ingredients[0]!.quantities).toEqual([ + { + groupQuantity: { + quantity: { + type: "fixed", + value: { type: "decimal", decimal: 50 }, + }, + unit: "g", + }, + }, + ]); + expect(scaledRecipe.ingredients[1]!.quantities).toEqual([ + { + groupQuantity: { + quantity: { + type: "fixed", + value: { type: "fraction", num: 1, den: 4 }, + }, + unit: "tsp", + }, + }, + ]); + expect(scaledRecipe.ingredients[2]!.quantities).toEqual([ + { + groupQuantity: { + quantity: { + type: "range", + min: { type: "decimal", decimal: 1 }, + max: { type: "decimal", decimal: 1.5 }, + }, + unit: undefined, + }, + }, + ]); }); it("should update the servings property", () => { @@ -184,8 +293,17 @@ describe("scaleBy", () => { recipeWithoutServings.ingredients = [ { name: "water", - quantity: { type: "fixed", value: { type: "decimal", value: 1 } }, - unit: "l", + quantities: [ + { + groupQuantity: { + quantity: { + type: "fixed", + value: { type: "decimal", decimal: 1 }, + }, + unit: "l", + }, + }, + ], flags: [], }, ]; @@ -206,10 +324,17 @@ describe("scaleBy", () => { const scaledRecipe = recipeWithNonNumericMeta.scaleBy(2); expect(scaledRecipe.ingredients.length).toBe(1); - expect(scaledRecipe.ingredients[0]!.quantity).toEqual({ - type: "fixed", - value: { type: "decimal", value: 2 }, - }); + expect(scaledRecipe.ingredients[0]!.quantities).toEqual([ + { + groupQuantity: { + quantity: { + type: "fixed", + value: { type: "decimal", decimal: 2 }, + }, + unit: "L", + }, + }, + ]); expect(scaledRecipe.servings).toBe(4); expect(scaledRecipe.metadata.servings).toBe("2, a few"); }); @@ -229,16 +354,321 @@ serves: 2, some expect(scaledRecipe.metadata.serves).toBe("2, some"); }); + it("should scale alternative ingredients when scaling by", () => { + const recipe = new Recipe(recipeWithInlineAlternatives); + const scaledRecipe = recipe.scaleBy(2); + expect(scaledRecipe.ingredients.length).toBe(3); + const ingredient0Quantities: IngredientQuantities = [ + { + groupQuantity: { + quantity: { + type: "fixed", + value: { type: "decimal", decimal: 400 }, + }, + unit: "ml", + }, + alternatives: [ + { + index: 1, + alternativeQuantities: [ + { + quantity: { + type: "fixed", + value: { type: "decimal", decimal: 200 }, + }, + unit: "ml", + }, + ], + }, + { + index: 2, + alternativeQuantities: [ + { + quantity: { + type: "fixed", + value: { type: "decimal", decimal: 300 }, + }, + unit: "ml", + }, + ], + }, + ], + }, + ]; + expect(scaledRecipe.ingredients[0]!.quantities).toEqual( + ingredient0Quantities, + ); + const step = scaledRecipe.sections[0]!.content[0] as Step; + const ingredientItem0: IngredientItem = { + id: "ingredient-item-0", + type: "ingredient", + alternatives: [ + { + displayName: "milk", + index: 0, + itemQuantity: { + scalable: true, + quantity: { + type: "fixed", + value: { type: "decimal", decimal: 400 }, + }, + unit: { name: "ml" }, + }, + }, + { + displayName: "almond milk", + index: 1, + itemQuantity: { + scalable: true, + quantity: { + type: "fixed", + value: { type: "decimal", decimal: 200 }, + }, + unit: { name: "ml" }, + }, + note: "vegan version", + }, + { + displayName: "soy milk", + index: 2, + itemQuantity: { + scalable: true, + quantity: { + type: "fixed", + value: { type: "decimal", decimal: 300 }, + }, + unit: { name: "ml" }, + }, + note: "another vegan option", + }, + ], + }; + expect(step.items[1]).toEqual(ingredientItem0); + }); + + it("should scale alternative units of an ingredient when scaling by", () => { + const recipe = new Recipe(`--- +servings: 2 +--- +Use @sugar{100%g|0.5%cups|3.5%oz} in the mix. + `); + const scaledRecipe = recipe.scaleBy(2); + expect(scaledRecipe.ingredients.length).toBe(1); + const ingredient0Quantities: IngredientQuantities = [ + { + groupQuantity: { + quantity: { + type: "fixed", + value: { type: "decimal", decimal: 200 }, + }, + unit: "g", + equivalents: [ + { + quantity: { + type: "fixed", + value: { type: "decimal", decimal: 1 }, + }, + unit: "cups", + }, + { + quantity: { + type: "fixed", + value: { type: "decimal", decimal: 7 }, + }, + unit: "oz", + }, + ], + }, + }, + ]; + expect(scaledRecipe.ingredients[0]!.quantities).toEqual( + ingredient0Quantities, + ); + const ingredientStep0 = scaledRecipe.sections[0]!.content[0] as Step; + const ingredientItem0: IngredientItem = { + id: "ingredient-item-0", + type: "ingredient", + alternatives: [ + { + displayName: "sugar", + index: 0, + itemQuantity: { + scalable: true, + quantity: { + type: "fixed", + value: { type: "decimal", decimal: 200 }, + }, + unit: { name: "g" }, + equivalents: [ + { + quantity: { + type: "fixed", + value: { type: "decimal", decimal: 1 }, + }, + unit: { name: "cups" }, + }, + { + quantity: { + type: "fixed", + value: { type: "decimal", decimal: 7 }, + }, + unit: { name: "oz" }, + }, + ], + }, + }, + ], + }; + expect(ingredientStep0.items[1]).toEqual(ingredientItem0); + }); + + it("should not scale text quantities of equivalents units", () => { + const recipe = new Recipe(`--- +servings: 2 +--- +Use @sugar{100%g|a cup} in the mix. + `); + const scaledRecipe = recipe.scaleBy(2); + expect(scaledRecipe.ingredients.length).toBe(1); + const ingredient0Quantities: IngredientQuantities = [ + { + groupQuantity: { + quantity: { + type: "fixed", + value: { type: "decimal", decimal: 200 }, + }, + unit: "g", + equivalents: [ + { + quantity: { + type: "fixed", + value: { type: "text", text: "a cup" }, + }, + }, + ], + }, + }, + ]; + expect(scaledRecipe.ingredients[0]!.quantities).toEqual( + ingredient0Quantities, + ); + }); + it("should not scale fixed quantities", () => { const recipe = new Recipe(recipeToScaleSomeFixedQuantities); const scaledRecipe = recipe.scaleBy(2); - expect(scaledRecipe.ingredients[0]!.quantity).toEqual({ - type: "fixed", - value: { type: "decimal", value: 100 }, - }); - expect(scaledRecipe.ingredients[1]!.quantity).toEqual({ - type: "fixed", - value: { type: "decimal", value: 10 }, - }); + expect(scaledRecipe.ingredients[0]!.quantities).toEqual([ + { + groupQuantity: { + quantity: { + type: "fixed", + value: { type: "decimal", decimal: 100 }, + }, + unit: "g", + }, + }, + ]); + expect(scaledRecipe.ingredients[1]!.quantities).toEqual([ + { + groupQuantity: { + quantity: { + type: "fixed", + value: { type: "decimal", decimal: 10 }, + }, + unit: "g", + }, + }, + ]); + }); + + it("should scale choices when scaling by", () => { + const recipe = new Recipe(` +--- +servings: 2 +--- +Use @sugar{100%g}|honey{100%g} in the mix. + +Add @|milk|milk{150%mL} or @|milk|oat milk{150%mL} for a vegan version. + `); + const scaledRecipe = recipe.scaleBy(2); + const sugarIngredientChoice = + scaledRecipe.choices.ingredientItems.get("ingredient-item-0"); + expect(sugarIngredientChoice).toEqual([ + { + displayName: "sugar", + index: 0, + itemQuantity: { + quantity: { + type: "fixed", + value: { + decimal: 200, + type: "decimal", + }, + }, + unit: { + name: "g", + }, + scalable: true, + }, + }, + { + displayName: "honey", + index: 1, + itemQuantity: { + quantity: { + type: "fixed", + value: { + decimal: 200, + type: "decimal", + }, + }, + unit: { + name: "g", + }, + scalable: true, + }, + }, + ]); + + const milkIngredientChoice = + scaledRecipe.choices.ingredientGroups.get("milk"); + expect(milkIngredientChoice).toEqual([ + { + displayName: "milk", + index: 2, + itemId: "ingredient-item-1", + itemQuantity: { + quantity: { + type: "fixed", + value: { + decimal: 300, + type: "decimal", + }, + }, + unit: { + name: "mL", + }, + scalable: true, + }, + }, + { + displayName: "oat milk", + index: 3, + itemId: "ingredient-item-2", + itemQuantity: { + quantity: { + type: "fixed", + value: { + decimal: 300, + type: "decimal", + }, + }, + unit: { + name: "mL", + }, + scalable: true, + }, + }, + ]); }); }); diff --git a/test/shopping_cart.test.ts b/test/shopping_cart.test.ts index d5de399..0b2199f 100644 --- a/test/shopping_cart.test.ts +++ b/test/shopping_cart.test.ts @@ -10,6 +10,7 @@ import { import { recipeForShoppingList1, recipeForShoppingList2, + recipeForShoppingList4, } from "./fixtures/recipes"; const productCatalog: ProductCatalog = new ProductCatalog(); @@ -19,31 +20,81 @@ productCatalog.products = [ productName: "Flour (80g)", ingredientName: "flour", price: 25, - size: { type: "fixed", value: { type: "decimal", value: 80 } }, - unit: "g", + sizes: [ + { + size: { type: "fixed", value: { type: "decimal", decimal: 80 } }, + unit: "g", + }, + ], }, { id: "flour-40g", productName: "Flour (40g)", ingredientName: "flour", price: 15, - size: { type: "fixed", value: { type: "decimal", value: 40 } }, - unit: "g", + sizes: [ + { + size: { type: "fixed", value: { type: "decimal", decimal: 40 } }, + unit: "g", + }, + ], }, { id: "eggs-1", productName: "Single Egg", ingredientName: "eggs", price: 20, - size: { type: "fixed", value: { type: "decimal", value: 1 } }, + sizes: [ + { size: { type: "fixed", value: { type: "decimal", decimal: 1 } } }, + ], }, { id: "milk-1L", productName: "Milk (1L)", ingredientName: "milk", price: 30, - size: { type: "fixed", value: { type: "decimal", value: 1 } }, - unit: "l", + sizes: [ + { + size: { type: "fixed", value: { type: "decimal", decimal: 1 } }, + unit: "l", + }, + ], + }, + { + id: "large-carrots", + productName: "Large carrots", + ingredientName: "carrots", + price: 2, + sizes: [ + { + size: { type: "fixed", value: { type: "decimal", decimal: 1 } }, + unit: "large", + }, + ], + }, + { + id: "small-carrots", + productName: "Small carrots", + ingredientName: "carrots", + price: 1, + sizes: [ + { + size: { type: "fixed", value: { type: "decimal", decimal: 1 } }, + unit: "small", + }, + ], + }, + { + id: "carrots-bag", + productName: "Carrots (1kg)", + ingredientName: "carrots", + price: 10, + sizes: [ + { + size: { type: "fixed", value: { type: "decimal", decimal: 1 } }, + unit: "kg", + }, + ], }, ]; @@ -113,6 +164,7 @@ describe("buildCart", () => { expect(shoppingCart.cart).toEqual([]); }); + // TODO: correct typo it("should handle gracefully ingredient/products with for incompatible units", () => { const shoppingCart = new ShoppingCart(); const shoppingList = new ShoppingList(); @@ -135,8 +187,12 @@ describe("buildCart", () => { productName: "Pack of 12 eggs", ingredientName: "eggs", price: 20, - size: { type: "fixed", value: { type: "decimal", value: 1 } }, - unit: "dozen", + sizes: [ + { + size: { type: "fixed", value: { type: "decimal", decimal: 1 } }, + unit: "dozen", + }, + ], }); shoppingList2.add_recipe(recipe2); shoppingCart2.setShoppingList(shoppingList2); @@ -156,7 +212,9 @@ describe("buildCart", () => { productName: "Single Egg", ingredientName: "eggs", price: 20, - size: { type: "fixed", value: { type: "decimal", value: 1 } }, + sizes: [ + { size: { type: "fixed", value: { type: "decimal", decimal: 1 } } }, + ], }); shoppingList3.add_recipe(recipe3); shoppingCart3.setShoppingList(shoppingList3); @@ -176,8 +234,12 @@ describe("buildCart", () => { productName: "Peeled Tomatoes", ingredientName: "peeled tomatoes", price: 20, - size: { type: "fixed", value: { type: "decimal", value: 400 } }, - unit: "g", + sizes: [ + { + size: { type: "fixed", value: { type: "decimal", decimal: 400 } }, + unit: "g", + }, + ], }); shoppingList4.add_recipe(recipe4); shoppingCart4.setShoppingList(shoppingList4); @@ -189,6 +251,76 @@ describe("buildCart", () => { expect(shoppingCart4.misMatch[0]!.reason).toBe("incompatibleUnits"); }); + it("should match products with multiple sizes using different units", () => { + // Product defined with both "1%dozen" and "12" sizes should match both units + const shoppingCart = new ShoppingCart(); + const shoppingList = new ShoppingList(); + const recipe = new Recipe("@eggs{2%dozen}"); + shoppingList.add_recipe(recipe); + + const catalog = new ProductCatalog(); + catalog.products = [ + { + id: "eggs-12pack", + productName: "Pack of 12 eggs", + ingredientName: "eggs", + price: 18, + sizes: [ + { + size: { type: "fixed", value: { type: "decimal", decimal: 1 } }, + unit: "dozen", + }, + { size: { type: "fixed", value: { type: "decimal", decimal: 12 } } }, + ], + }, + ]; + + shoppingCart.setShoppingList(shoppingList); + shoppingCart.setProductCatalog(catalog); + shoppingCart.buildCart(); + + expect(shoppingCart.match.length).toBe(1); + expect(shoppingCart.misMatch.length).toBe(0); + expect(shoppingCart.cart.length).toBe(1); + expect(shoppingCart.cart[0]!.product.id).toBe("eggs-12pack"); + expect(shoppingCart.cart[0]!.quantity).toBe(2); // 2 dozen = 2 packs of 12 + }); + + it("should match products with multiple sizes using unitless quantity", () => { + // Same product, but recipe asks for unitless quantity + const shoppingCart = new ShoppingCart(); + const shoppingList = new ShoppingList(); + const recipe = new Recipe("@eggs{24}"); // 24 eggs = 2 packs of 12 + shoppingList.add_recipe(recipe); + + const catalog = new ProductCatalog(); + catalog.products = [ + { + id: "eggs-12pack", + productName: "Pack of 12 eggs", + ingredientName: "eggs", + price: 18, + sizes: [ + { + size: { type: "fixed", value: { type: "decimal", decimal: 1 } }, + unit: "dozen", + }, + { size: { type: "fixed", value: { type: "decimal", decimal: 12 } } }, + ], + }, + ]; + + shoppingCart.setShoppingList(shoppingList); + shoppingCart.setProductCatalog(catalog); + shoppingCart.buildCart(); + + expect(shoppingCart.match.length).toBe(1); + expect(shoppingCart.misMatch.length).toBe(0); + expect(shoppingCart.cart.length).toBe(1); + expect(shoppingCart.cart[0]!.product.id).toBe("eggs-12pack"); + expect(shoppingCart.cart[0]!.quantity).toBe(2); // 24 / 12 = 2 packs + }); + it("should choose the cheapest option", () => { const shoppingCart = new ShoppingCart(); const shoppingList = new ShoppingList(); @@ -202,16 +334,24 @@ describe("buildCart", () => { productName: "Flour (1kg)", ingredientName: "flour", price: 10, - size: { type: "fixed", value: { type: "decimal", value: 1000 } }, - unit: "g", + sizes: [ + { + size: { type: "fixed", value: { type: "decimal", decimal: 1000 } }, + unit: "g", + }, + ], }, { id: "flour-500g", productName: "Flour (500g)", ingredientName: "flour", price: 6, - size: { type: "fixed", value: { type: "decimal", value: 500 } }, - unit: "g", + sizes: [ + { + size: { type: "fixed", value: { type: "decimal", decimal: 500 } }, + unit: "g", + }, + ], }, ]; shoppingCart.setProductCatalog(catalog); @@ -284,7 +424,62 @@ describe("buildCart", () => { ["pepper", "noProduct"], ["spices", "noProduct"], ["butter", "noProduct"], - ["pepper", "noProduct"], ]); }); }); + +describe("alternative quantities", () => { + it("should handle ingredients with multiple quantities", () => { + const shoppingCart = new ShoppingCart(); + const shoppingList = new ShoppingList(); + shoppingList.add_recipe(new Recipe(recipeForShoppingList4)); + shoppingCart.setShoppingList(shoppingList); + shoppingCart.setProductCatalog(productCatalog); + shoppingCart.buildCart(); + expect(shoppingCart.cart).toEqual([ + { product: productCatalog.products[4], quantity: 3, totalPrice: 6 }, // 1x + { product: productCatalog.products[5], quantity: 2, totalPrice: 2 }, // 1x + ]); + }); + + it("should handle ingredients with alternative units", () => { + const shoppingCart = new ShoppingCart(); + const shoppingList = new ShoppingList(); + shoppingList.add_recipe( + new Recipe(` +--- +servings: 1 +--- +Cook @carrots{10%large|1%kg} +`), + ); + shoppingCart.setShoppingList(shoppingList); + shoppingCart.setProductCatalog(productCatalog); + shoppingCart.buildCart(); + expect(shoppingCart.cart).toEqual([ + { product: productCatalog.products[6], quantity: 1, totalPrice: 10 }, // 1x + ]); + }); + + it("should add to misMatch when no alternatives can be matched", () => { + const shoppingCart = new ShoppingCart(); + const shoppingList = new ShoppingList(); + shoppingList.add_recipe( + new Recipe(` +--- +servings: 1 +--- +Cook @carrots{three|5%box} +`), + ); + shoppingCart.setShoppingList(shoppingList); + shoppingCart.setProductCatalog(productCatalog); + shoppingCart.buildCart(); + expect(shoppingCart.match.length).toBe(0); + expect(shoppingCart.misMatch.length).toBe(1); + expect(shoppingCart.misMatch[0]!.ingredient.name).toBe("carrots"); + expect(shoppingCart.misMatch[0]!.reason).toBe( + "textValue_incompatibleUnits", + ); + }); +}); diff --git a/test/shopping_list.test.ts b/test/shopping_list.test.ts index 44a2f80..6dcc31a 100644 --- a/test/shopping_list.test.ts +++ b/test/shopping_list.test.ts @@ -6,11 +6,15 @@ import { Recipe } from "../src/classes/recipe"; import { recipeForShoppingList1, recipeForShoppingList2, + recipeForShoppingList3, + recipeWithInlineAlternatives, } from "./fixtures/recipes"; describe("ShoppingList", () => { const recipe1 = new Recipe(recipeForShoppingList1); const recipe2 = new Recipe(recipeForShoppingList2); + const recipe3 = new Recipe(recipeForShoppingList3); + const recipeAlt = new Recipe(recipeWithInlineAlternatives); describe("Adding recipes", () => { it("should add a recipe's ingredients", () => { @@ -19,34 +23,88 @@ describe("ShoppingList", () => { expect(shoppingList.ingredients).toEqual([ { name: "flour", - quantity: { type: "fixed", value: { type: "decimal", value: 100 } }, - unit: "g", + quantityTotal: { + quantity: { + type: "fixed", + value: { type: "decimal", decimal: 100 }, + }, + unit: "g", + }, }, { name: "sugar", - quantity: { type: "fixed", value: { type: "decimal", value: 50 } }, - unit: "g", + quantityTotal: { + quantity: { + type: "fixed", + value: { type: "decimal", decimal: 50 }, + }, + unit: "g", + }, }, { name: "eggs", - quantity: { type: "fixed", value: { type: "decimal", value: 2 } }, + quantityTotal: { + quantity: { type: "fixed", value: { type: "decimal", decimal: 2 } }, + }, }, { name: "milk", - quantity: { type: "fixed", value: { type: "decimal", value: 200 } }, - unit: "ml", + quantityTotal: { + quantity: { + type: "fixed", + value: { type: "decimal", decimal: 200 }, + }, + unit: "ml", + }, }, { name: "pepper", - quantity: { - type: "fixed", - value: { type: "text", value: "to taste" }, + quantityTotal: { + quantity: { + type: "fixed", + value: { type: "text", text: "to taste" }, + }, }, }, { name: "spices" }, ]); }); + it("should handle adding a recipe with multiple units for the same ingredient", () => { + const shoppingList = new ShoppingList(); + shoppingList.add_recipe(recipe1); + shoppingList.add_recipe(recipe3); + shoppingList.add_recipe(recipe1); // adding the same one again to check accumulation + expect(shoppingList.ingredients.find((i) => i.name === "eggs")).toEqual({ + name: "eggs", + quantityTotal: { + type: "and", + entries: [ + { + 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.add_recipe(recipe1); @@ -54,44 +112,77 @@ describe("ShoppingList", () => { expect(shoppingList.ingredients).toEqual([ { name: "flour", - quantity: { type: "fixed", value: { type: "decimal", value: 150 } }, - unit: "g", + quantityTotal: { + quantity: { + type: "fixed", + value: { type: "decimal", decimal: 150 }, + }, + unit: "g", + }, }, { name: "sugar", - quantity: { type: "fixed", value: { type: "decimal", value: 50 } }, - unit: "g", + quantityTotal: { + quantity: { + type: "fixed", + value: { type: "decimal", decimal: 50 }, + }, + unit: "g", + }, }, { name: "eggs", - quantity: { type: "fixed", value: { type: "decimal", value: 3 } }, + quantityTotal: { + quantity: { type: "fixed", value: { type: "decimal", decimal: 3 } }, + }, }, { name: "milk", - quantity: { type: "fixed", value: { type: "decimal", value: 200 } }, - unit: "ml", + quantityTotal: { + quantity: { + type: "fixed", + value: { type: "decimal", decimal: 200 }, + }, + unit: "ml", + }, }, { name: "pepper", - quantity: { - type: "fixed", - value: { type: "text", value: "to taste" }, + quantityTotal: { + type: "and", + entries: [ + { + quantity: { + type: "fixed", + value: { type: "text", text: "to taste" }, + }, + }, + { + quantity: { + type: "fixed", + value: { type: "decimal", decimal: 1 }, + }, + unit: "tsp", + }, + ], }, }, { name: "spices", - quantity: { type: "fixed", value: { type: "decimal", value: 1 } }, - unit: "pinch", + quantityTotal: { + quantity: { type: "fixed", value: { type: "decimal", decimal: 1 } }, + unit: "pinch", + }, }, { name: "butter", - quantity: { type: "fixed", value: { type: "decimal", value: 25 } }, - unit: "g", - }, - { - name: "pepper", - quantity: { type: "fixed", value: { type: "decimal", value: 1 } }, - unit: "tsp", + quantityTotal: { + quantity: { + type: "fixed", + value: { type: "decimal", decimal: 25 }, + }, + unit: "g", + }, }, ]); }); @@ -99,32 +190,51 @@ describe("ShoppingList", () => { it("should scale recipe ingredients (deprecated signature)", () => { const shoppingList = new ShoppingList(); // TODO: Deprecated, to remove in v3 - shoppingList.add_recipe(recipe1, 2); + shoppingList.add_recipe(recipe1, { scaling: { factor: 2 } }); expect(shoppingList.ingredients).toEqual([ { name: "flour", - quantity: { type: "fixed", value: { type: "decimal", value: 200 } }, - unit: "g", + quantityTotal: { + quantity: { + type: "fixed", + value: { type: "decimal", decimal: 200 }, + }, + unit: "g", + }, }, { name: "sugar", - quantity: { type: "fixed", value: { type: "decimal", value: 100 } }, - unit: "g", + quantityTotal: { + quantity: { + type: "fixed", + value: { type: "decimal", decimal: 100 }, + }, + unit: "g", + }, }, { name: "eggs", - quantity: { type: "fixed", value: { type: "decimal", value: 4 } }, + quantityTotal: { + quantity: { type: "fixed", value: { type: "decimal", decimal: 4 } }, + }, }, { name: "milk", - quantity: { type: "fixed", value: { type: "decimal", value: 400 } }, - unit: "ml", + quantityTotal: { + quantity: { + type: "fixed", + value: { type: "decimal", decimal: 400 }, + }, + unit: "ml", + }, }, { name: "pepper", - quantity: { - type: "fixed", - value: { type: "text", value: "to taste" }, + quantityTotal: { + quantity: { + type: "fixed", + value: { type: "text", text: "to taste" }, + }, }, }, { name: "spices" }, @@ -133,32 +243,51 @@ describe("ShoppingList", () => { it("should scale recipe ingredients (using factor)", () => { const shoppingList = new ShoppingList(); - shoppingList.add_recipe(recipe1, { factor: 2 }); + shoppingList.add_recipe(recipe1, { scaling: { factor: 2 } }); expect(shoppingList.ingredients).toEqual([ { name: "flour", - quantity: { type: "fixed", value: { type: "decimal", value: 200 } }, - unit: "g", + quantityTotal: { + quantity: { + type: "fixed", + value: { type: "decimal", decimal: 200 }, + }, + unit: "g", + }, }, { name: "sugar", - quantity: { type: "fixed", value: { type: "decimal", value: 100 } }, - unit: "g", + quantityTotal: { + quantity: { + type: "fixed", + value: { type: "decimal", decimal: 100 }, + }, + unit: "g", + }, }, { name: "eggs", - quantity: { type: "fixed", value: { type: "decimal", value: 4 } }, + quantityTotal: { + quantity: { type: "fixed", value: { type: "decimal", decimal: 4 } }, + }, }, { name: "milk", - quantity: { type: "fixed", value: { type: "decimal", value: 400 } }, - unit: "ml", + quantityTotal: { + quantity: { + type: "fixed", + value: { type: "decimal", decimal: 400 }, + }, + unit: "ml", + }, }, { name: "pepper", - quantity: { - type: "fixed", - value: { type: "text", value: "to taste" }, + quantityTotal: { + quantity: { + type: "fixed", + value: { type: "text", text: "to taste" }, + }, }, }, { name: "spices" }, @@ -167,37 +296,76 @@ describe("ShoppingList", () => { it("should scale recipe ingredients (using servings)", () => { const shoppingList = new ShoppingList(); - shoppingList.add_recipe(recipe1, { servings: 3 }); + shoppingList.add_recipe(recipe1, { scaling: { servings: 3 } }); expect(shoppingList.ingredients).toEqual([ { name: "flour", - quantity: { type: "fixed", value: { type: "decimal", value: 300 } }, - unit: "g", + quantityTotal: { + quantity: { + type: "fixed", + value: { type: "decimal", decimal: 300 }, + }, + unit: "g", + }, }, { name: "sugar", - quantity: { type: "fixed", value: { type: "decimal", value: 150 } }, - unit: "g", + quantityTotal: { + quantity: { + type: "fixed", + value: { type: "decimal", decimal: 150 }, + }, + unit: "g", + }, }, { name: "eggs", - quantity: { type: "fixed", value: { type: "decimal", value: 6 } }, + quantityTotal: { + quantity: { type: "fixed", value: { type: "decimal", decimal: 6 } }, + }, }, { name: "milk", - quantity: { type: "fixed", value: { type: "decimal", value: 600 } }, - unit: "ml", + quantityTotal: { + quantity: { + type: "fixed", + value: { type: "decimal", decimal: 600 }, + }, + unit: "ml", + }, }, { name: "pepper", - quantity: { - type: "fixed", - value: { type: "text", value: "to taste" }, + quantityTotal: { + 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.add_recipe(recipeAlt, { choices }); + expect(shoppingList.ingredients).toEqual([ + { + name: "almond milk", + quantityTotal: { + quantity: { + type: "fixed", + value: { type: "decimal", decimal: 100 }, + }, + unit: "ml", + }, + }, + ]); + }); }); describe("Association with CategoryConfig", () => { @@ -281,48 +449,87 @@ sugar Bakery: [ { name: "flour", - quantity: { type: "fixed", value: { type: "decimal", value: 150 } }, - unit: "g", + quantityTotal: { + quantity: { + type: "fixed", + value: { type: "decimal", decimal: 150 }, + }, + unit: "g", + }, }, { name: "sugar", - quantity: { type: "fixed", value: { type: "decimal", value: 50 } }, - unit: "g", + quantityTotal: { + quantity: { + type: "fixed", + value: { type: "decimal", decimal: 50 }, + }, + unit: "g", + }, }, ], Dairy: [ { name: "butter", - quantity: { type: "fixed", value: { type: "decimal", value: 25 } }, - unit: "g", + quantityTotal: { + quantity: { + type: "fixed", + value: { type: "decimal", decimal: 25 }, + }, + unit: "g", + }, }, { name: "milk", - quantity: { type: "fixed", value: { type: "decimal", value: 200 } }, - unit: "ml", + quantityTotal: { + quantity: { + type: "fixed", + value: { type: "decimal", decimal: 200 }, + }, + unit: "ml", + }, }, ], other: [ { name: "eggs", - quantity: { type: "fixed", value: { type: "decimal", value: 3 } }, - }, - { - name: "pepper", - quantity: { - type: "fixed", - value: { type: "text", value: "to taste" }, + quantityTotal: { + quantity: { + type: "fixed", + value: { type: "decimal", decimal: 3 }, + }, }, }, { name: "pepper", - quantity: { type: "fixed", value: { type: "decimal", value: 1 } }, - unit: "tsp", + quantityTotal: { + type: "and", + entries: [ + { + quantity: { + type: "fixed", + value: { type: "text", text: "to taste" }, + }, + }, + { + quantity: { + type: "fixed", + value: { type: "decimal", decimal: 1 }, + }, + unit: "tsp", + }, + ], + }, }, { name: "spices", - quantity: { type: "fixed", value: { type: "decimal", value: 1 } }, - unit: "pinch", + quantityTotal: { + quantity: { + type: "fixed", + value: { type: "decimal", decimal: 1 }, + }, + unit: "pinch", + }, }, ], }; @@ -344,28 +551,50 @@ sugar other: [ { name: "flour", - quantity: { type: "fixed", value: { type: "decimal", value: 100 } }, - unit: "g", + quantityTotal: { + quantity: { + type: "fixed", + value: { type: "decimal", decimal: 100 }, + }, + unit: "g", + }, }, { name: "sugar", - quantity: { type: "fixed", value: { type: "decimal", value: 50 } }, - unit: "g", + quantityTotal: { + quantity: { + type: "fixed", + value: { type: "decimal", decimal: 50 }, + }, + unit: "g", + }, }, { name: "eggs", - quantity: { type: "fixed", value: { type: "decimal", value: 2 } }, + quantityTotal: { + quantity: { + type: "fixed", + value: { type: "decimal", decimal: 2 }, + }, + }, }, { name: "milk", - quantity: { type: "fixed", value: { type: "decimal", value: 200 } }, - unit: "ml", + quantityTotal: { + quantity: { + type: "fixed", + value: { type: "decimal", decimal: 200 }, + }, + unit: "ml", + }, }, { name: "pepper", - quantity: { - type: "fixed", - value: { type: "text", value: "to taste" }, + quantityTotal: { + quantity: { + type: "fixed", + value: { type: "text", text: "to taste" }, + }, }, }, { name: "spices" }, @@ -383,27 +612,43 @@ sugar expect(shoppingList.ingredients).toEqual([ { name: "flour", - quantity: { type: "fixed", value: { type: "decimal", value: 50 } }, - unit: "g", + quantityTotal: { + quantity: { + type: "fixed", + value: { type: "decimal", decimal: 50 }, + }, + unit: "g", + }, }, { name: "butter", - quantity: { type: "fixed", value: { type: "decimal", value: 25 } }, - unit: "g", + quantityTotal: { + quantity: { + type: "fixed", + value: { type: "decimal", decimal: 25 }, + }, + unit: "g", + }, }, { name: "eggs", - quantity: { type: "fixed", value: { type: "decimal", value: 1 } }, + quantityTotal: { + quantity: { type: "fixed", value: { type: "decimal", decimal: 1 } }, + }, }, { name: "pepper", - quantity: { type: "fixed", value: { type: "decimal", value: 1 } }, - unit: "tsp", + quantityTotal: { + quantity: { type: "fixed", value: { type: "decimal", decimal: 1 } }, + unit: "tsp", + }, }, { name: "spices", - quantity: { type: "fixed", value: { type: "decimal", value: 1 } }, - unit: "pinch", + quantityTotal: { + quantity: { type: "fixed", value: { type: "decimal", decimal: 1 } }, + unit: "pinch", + }, }, ]); }); @@ -418,16 +663,26 @@ sugar expect(shoppingList.categories?.Bakery).toEqual([ { name: "flour", - quantity: { type: "fixed", value: { type: "decimal", value: 150 } }, - unit: "g", + quantityTotal: { + quantity: { + type: "fixed", + value: { type: "decimal", decimal: 150 }, + }, + unit: "g", + }, }, ]); shoppingList.remove_recipe(0); expect(shoppingList.categories?.Bakery).toEqual([ { name: "flour", - quantity: { type: "fixed", value: { type: "decimal", value: 50 } }, - unit: "g", + quantityTotal: { + quantity: { + type: "fixed", + value: { type: "decimal", decimal: 50 }, + }, + unit: "g", + }, }, ]); }); diff --git a/test/units.test.ts b/test/units.test.ts deleted file mode 100644 index eba0598..0000000 --- a/test/units.test.ts +++ /dev/null @@ -1,600 +0,0 @@ -import { describe, it, expect } from "vitest"; -import { - addQuantities, - getDefaultQuantityValue, - normalizeUnit, - simplifyFraction, - addNumericValues, - getNumericValue, - CannotAddTextValueError, - IncompatibleUnitsError, - addQuantityValues, - multiplyNumericValue, - multiplyQuantityValue, -} from "../src/units"; -import type { DecimalValue, FixedValue, FractionValue } from "../src/types"; - -describe("normalizeUnit", () => { - it("should normalize various unit strings to a canonical definition", () => { - expect(normalizeUnit("g")?.name).toBe("g"); - expect(normalizeUnit("gram")?.name).toBe("g"); - expect(normalizeUnit("grams")?.name).toBe("g"); - expect(normalizeUnit("kilogram")?.name).toBe("kg"); - expect(normalizeUnit("L")?.name).toBe("l"); - expect(normalizeUnit("pounds")?.name).toBe("lb"); - }); - - it("should return undefined for unknown units", () => { - expect(normalizeUnit("glug")).toBeUndefined(); - }); -}); - -describe("simplifyFraction", () => { - it("should throw an error when the denominator is zero", () => { - expect(() => simplifyFraction(1, 0)).toThrowError( - "Denominator cannot be zero.", - ); - }); - - it("should simplify a fraction correctly", () => { - const result = simplifyFraction(6, 8); - expect(result).toEqual({ type: "fraction", num: 3, den: 4 }); - }); - - it("should handle negative numbers correctly", () => { - expect(simplifyFraction(-4, 6)).toEqual({ - type: "fraction", - num: -2, - den: 3, - }); - expect(simplifyFraction(4, -6)).toEqual({ - type: "fraction", - num: -2, - den: 3, - }); - expect(simplifyFraction(-4, -6)).toEqual({ - type: "fraction", - num: 2, - den: 3, - }); - }); - - it("should return a decimal value when the fraction simplifies to a whole number", () => { - const result = simplifyFraction(10, 2); - expect(result).toEqual({ type: "decimal", value: 5 }); - }); - - it("should handle cases where the numerator is zero", () => { - const result = simplifyFraction(0, 5); - expect(result).toEqual({ type: "decimal", value: 0 }); - }); - - it("should handle cases where the numerator is < 1", () => { - const result = simplifyFraction(0.5, 2); - expect(result).toEqual({ type: "fraction", num: 1, den: 4 }); - }); -}); - -describe("addNumericValues", () => { - it("should add two decimal values", () => { - const val1: DecimalValue = { type: "decimal", value: 1.5 }; - const val2: DecimalValue = { type: "decimal", value: 2.5 }; - expect(addNumericValues(val1, val2)).toEqual({ type: "decimal", value: 4 }); - }); - - it("should add two big decimal values", () => { - const val1: DecimalValue = { type: "decimal", value: 1.1 }; - const val2: DecimalValue = { type: "decimal", value: 1.3 }; - expect(addNumericValues(val1, val2)).toEqual({ - type: "decimal", - value: 2.4, - }); - }); - - it("should add two fraction values", () => { - const val1: FractionValue = { type: "fraction", num: 1, den: 2 }; - const val2: FractionValue = { type: "fraction", num: 1, den: 4 }; - expect(addNumericValues(val1, val2)).toEqual({ - type: "fraction", - num: 3, - den: 4, - }); - }); - - it("should add a decimal and a fraction value", () => { - const val1: DecimalValue = { type: "decimal", value: 0.5 }; - const val2: FractionValue = { type: "fraction", num: 1, den: 4 }; - expect(addNumericValues(val1, val2)).toEqual({ - type: "decimal", - value: 0.75, - }); - }); - - it("should add a fraction and a decimal value", () => { - const val1: FractionValue = { type: "fraction", num: 1, den: 2 }; - const val2: DecimalValue = { type: "decimal", value: 0.25 }; - expect(addNumericValues(val1, val2)).toEqual({ - type: "decimal", - value: 0.75, - }); - }); - - it("should return a decimal when fractions add up to a whole number", () => { - const val1: FractionValue = { type: "fraction", num: 1, den: 2 }; - const val2: FractionValue = { type: "fraction", num: 1, den: 2 }; - expect(addNumericValues(val1, val2)).toEqual({ type: "decimal", value: 1 }); - }); - - it("should simplify the resulting fraction", () => { - const val1: FractionValue = { type: "fraction", num: 1, den: 6 }; - const val2: FractionValue = { type: "fraction", num: 1, den: 6 }; - expect(addNumericValues(val1, val2)).toEqual({ - type: "fraction", - num: 1, - den: 3, - }); - }); - - it("should return 0 if both values are 0", () => { - const val1: DecimalValue = { type: "decimal", value: 0 }; - const val2: DecimalValue = { type: "decimal", value: 0 }; - expect(addNumericValues(val1, val2)).toEqual({ type: "decimal", value: 0 }); - }); -}); - -describe("addQuantityValues", () => { - it("should add two fixed numerical values", () => { - expect( - addQuantityValues( - { type: "fixed", value: { type: "decimal", value: 1 } }, - { type: "fixed", value: { type: "decimal", value: 2 } }, - ), - ).toEqual({ type: "fixed", value: { type: "decimal", value: 3 } }); - }); - - it("should add two range values", () => { - expect( - addQuantityValues( - { - type: "range", - min: { type: "decimal", value: 1 }, - max: { type: "decimal", value: 2 }, - }, - { - type: "range", - min: { type: "decimal", value: 3 }, - max: { type: "decimal", value: 4 }, - }, - ), - ).toEqual({ - type: "range", - min: { type: "decimal", value: 4 }, - max: { type: "decimal", value: 6 }, - }); - }); - - it("should add a fixed and a range value", () => { - expect( - addQuantityValues( - { type: "fixed", value: { type: "decimal", value: 1 } }, - { - type: "range", - min: { type: "decimal", value: 3 }, - max: { type: "decimal", value: 4 }, - }, - ), - ).toEqual({ - type: "range", - min: { type: "decimal", value: 4 }, - max: { type: "decimal", value: 5 }, - }); - }); - - it("should throw an error if one of the value is a text value", () => { - expect(() => - addQuantityValues( - { type: "fixed", value: { type: "text", value: "to taste" } }, - { - type: "fixed", - value: { type: "decimal", value: 1 }, - }, - ), - ).toThrow(CannotAddTextValueError); - }); -}); - -describe("addQuantities", () => { - it("should add same units correctly", () => { - expect( - addQuantities( - { - value: { type: "fixed", value: { type: "decimal", value: 100 } }, - unit: "g", - }, - { - value: { type: "fixed", value: { type: "decimal", value: 200 } }, - unit: "g", - }, - ), - ).toEqual({ - value: { type: "fixed", value: { type: "decimal", value: 300 } }, - unit: "g", - }); - }); - - it("should add big decimal numbers correctly", () => { - expect( - addQuantities( - { - value: { type: "fixed", value: { type: "decimal", value: 1.1 } }, - unit: "kg", - }, - { - value: { type: "fixed", value: { type: "decimal", value: 1.3 } }, - unit: "kg", - }, - ), - ).toEqual({ - value: { type: "fixed", value: { type: "decimal", value: 2.4 } }, - unit: "kg", - }); - }); - - it("should also work when at least one value is a range", () => { - expect( - addQuantities( - { - value: { - type: "range", - min: { type: "decimal", value: 100 }, - max: { type: "decimal", value: 200 }, - }, - unit: "g", - }, - { - value: { - type: "range", - min: { type: "decimal", value: 10 }, - max: { type: "decimal", value: 20 }, - }, - unit: "g", - }, - ), - ).toEqual({ - value: { - type: "range", - min: { type: "decimal", value: 110 }, - max: { type: "decimal", value: 220 }, - }, - unit: "g", - }); - - expect( - addQuantities( - { - value: { - type: "fixed", - value: { type: "decimal", value: 100 }, - }, - unit: "g", - }, - { - value: { - type: "range", - min: { type: "decimal", value: 10 }, - max: { type: "decimal", value: 20 }, - }, - unit: "g", - }, - ), - ).toEqual({ - value: { - type: "range", - min: { type: "decimal", value: 110 }, - max: { type: "decimal", value: 120 }, - }, - unit: "g", - }); - - expect( - addQuantities( - { - value: { - type: "range", - min: { type: "decimal", value: 10 }, - max: { type: "decimal", value: 20 }, - }, - unit: "g", - }, - { - value: { - type: "fixed", - value: { type: "decimal", value: 100 }, - }, - unit: "g", - }, - ), - ).toEqual({ - value: { - type: "range", - min: { type: "decimal", value: 110 }, - max: { type: "decimal", value: 120 }, - }, - unit: "g", - }); - }); - - it("should add compatible metric units and convert to largest", () => { - expect( - addQuantities( - { - value: { type: "fixed", value: { type: "decimal", value: 1 } }, - unit: "kg", - }, - { - value: { type: "fixed", value: { type: "decimal", value: 500 } }, - unit: "g", - }, - ), - ).toEqual({ - value: { type: "fixed", value: { type: "decimal", value: 1.5 } }, - unit: "kg", - }); - expect( - addQuantities( - { - value: { type: "fixed", value: { type: "decimal", value: 500 } }, - unit: "g", - }, - { - value: { type: "fixed", value: { type: "decimal", value: 1 } }, - unit: "kg", - }, - ), - ).toEqual({ - value: { type: "fixed", value: { type: "decimal", value: 1.5 } }, - unit: "kg", - }); - }); - - it("should add compatible imperial units and convert to largest", () => { - expect( - addQuantities( - { - value: { type: "fixed", value: { type: "decimal", value: 1 } }, - unit: "lb", - }, - { - value: { type: "fixed", value: { type: "decimal", value: 8 } }, - unit: "oz", - }, - ), - ).toEqual({ - value: { type: "fixed", value: { type: "decimal", value: 1.5 } }, - unit: "lb", - }); - }); - - it("should add compatible metric and imperial units, converting to largest metric", () => { - const result = addQuantities( - { - value: { type: "fixed", value: { type: "decimal", value: 1 } }, - unit: "lb", - }, - { - value: { type: "fixed", value: { type: "decimal", value: 500 } }, - unit: "g", - }, - ); - expect(result.unit).toBe("kg"); - expect(result.value).toEqual({ - type: "fixed", - value: { type: "decimal", value: 0.95 }, - }); - }); - - it("should handle text quantities", () => { - expect(() => - addQuantities( - { - value: { type: "fixed", value: { type: "text", value: "to taste" } }, - unit: "", - }, - { - value: { type: "fixed", value: { type: "decimal", value: 100 } }, - unit: "g", - }, - ), - ).toThrow(CannotAddTextValueError); - }); - - it("should handle adding to a quantity with no unit", () => { - expect( - addQuantities( - { - value: { type: "fixed", value: { type: "decimal", value: 1 } }, - unit: "", - }, - { - value: { type: "fixed", value: { type: "decimal", value: 2 } }, - unit: "g", - }, - ), - ).toEqual({ - value: { type: "fixed", value: { type: "decimal", value: 3 } }, - unit: "g", - }); - expect( - addQuantities( - { - value: { type: "fixed", value: { type: "decimal", value: 100 } }, - unit: "g", - }, - { - value: { type: "fixed", value: { type: "decimal", value: 1 } }, - unit: "", - }, - ), - ).toEqual({ - value: { type: "fixed", value: { type: "decimal", value: 101 } }, - unit: "g", - }); - }); - - it("should simply add two quantities without unit or with empty string unit", () => { - // Empty string unit - expect( - addQuantities( - { - value: { type: "fixed", value: { type: "decimal", value: 1 } }, - unit: "", - }, - { - value: { type: "fixed", value: { type: "decimal", value: 2 } }, - unit: "", - }, - ), - ).toEqual({ - value: { type: "fixed", value: { type: "decimal", value: 3 } }, - unit: "", - }); - // No unit - expect( - addQuantities( - { - value: { type: "fixed", value: { type: "decimal", value: 1 } }, - }, - { - value: { type: "fixed", value: { type: "decimal", value: 2 } }, - }, - ), - ).toEqual({ - value: { type: "fixed", value: { type: "decimal", value: 3 } }, - }); - }); - - it("should throw error if trying to add incompatible units", () => { - expect(() => - addQuantities( - { - value: { type: "fixed", value: { type: "decimal", value: 100 } }, - unit: "g", - }, - { - value: { type: "fixed", value: { type: "decimal", value: 1 } }, - unit: "L", - }, - ), - ).toThrow(IncompatibleUnitsError); - expect(() => - addQuantities( - { - value: { type: "fixed", value: { type: "decimal", value: 100 } }, - unit: "g", - }, - { - value: { type: "fixed", value: { type: "decimal", value: 1 } }, - unit: "bag", - }, - ), - ).toThrow(IncompatibleUnitsError); - }); - - it("should add quantities defined as ranges", () => { - expect( - addQuantities( - { - value: { - type: "range", - min: { type: "decimal", value: 1 }, - max: { type: "decimal", value: 2 }, - }, - unit: "tsp", - }, - { - value: { type: "fixed", value: { type: "decimal", value: 1 } }, - unit: "tsp", - }, - ), - ).toEqual({ - value: { - type: "range", - min: { type: "decimal", value: 2 }, - max: { type: "decimal", value: 3 }, - }, - unit: "tsp", - }); - }); -}); - -describe("getDefaultQuantityValue + addQuantities", () => { - it("should preseve fractions", () => { - expect( - addQuantities( - { value: getDefaultQuantityValue() }, - { - value: { type: "fixed", value: { type: "fraction", num: 1, den: 2 } }, - unit: "", - }, - ), - ).toEqual({ - value: { type: "fixed", value: { type: "fraction", num: 1, den: 2 } }, - unit: "", - }); - }); - it("should preseve ranges", () => { - expect( - addQuantities( - { value: getDefaultQuantityValue() }, - { - value: { - type: "range", - min: { type: "decimal", value: 1 }, - max: { type: "decimal", value: 2 }, - }, - unit: "", - }, - ), - ).toEqual({ - value: { - type: "range", - min: { type: "decimal", value: 1 }, - max: { type: "decimal", value: 2 }, - }, - unit: "", - }); - }); -}); - -describe("multiplyNumericValue", () => { - it("should multiply a decimal value", () => { - const val: DecimalValue = { type: "decimal", value: 1.2 }; - expect(multiplyNumericValue(val, 3)).toEqual({ - type: "decimal", - value: 3.6, - }); - }); -}); - -describe("multiplyQuantityValue", () => { - it("should multiply a decimal value", () => { - const val: FixedValue = { - type: "fixed", - value: { type: "decimal", value: 1.2 }, - }; - expect(multiplyQuantityValue(val, 3)).toEqual({ - type: "fixed", - value: { type: "decimal", value: 3.6 }, - }); - }); - - describe("getNumericValue", () => { - it("should get the numerical value of a DecimalValue", () => { - expect(getNumericValue({ type: "decimal", value: 1.2 })).toBe(1.2); - }); - - it("should get the numerical value of a FractionValue", () => { - expect(getNumericValue({ type: "fraction", num: 2, den: 3 })).toBe(2 / 3); - }); - }); -}); diff --git a/test/units_conversion.test.ts b/test/units_conversion.test.ts new file mode 100644 index 0000000..4d668f7 --- /dev/null +++ b/test/units_conversion.test.ts @@ -0,0 +1,32 @@ +import { describe, it, expect } from "vitest"; + +import { qWithUnitDef } from "./mocks/quantity"; +import Big from "big.js"; +import { getUnitRatio } from "../src/units/conversion"; + +describe("getUnitRatio", () => { + it("should return the correct ratio for numerical values", () => { + expect( + getUnitRatio(qWithUnitDef(2, "large"), qWithUnitDef(1, "cup")), + ).toEqual(Big(2)); + expect( + getUnitRatio(qWithUnitDef(2, "large"), qWithUnitDef(1.5, "cup")), + ).toEqual(Big(2).div(1.5)); + }); + it("should return the correct ratio for system units", () => { + expect(getUnitRatio(qWithUnitDef(10, "mL"), qWithUnitDef(2, "cL"))).toEqual( + Big(0.5), + ); + }); + it("should throw and error if one of the values is a text", () => { + expect(() => + getUnitRatio( + { + quantity: { type: "fixed", value: { type: "text", text: "two" } }, + unit: { name: "large", type: "other", system: "none" }, + }, + qWithUnitDef(1, "cup"), + ), + ).toThrowError(); + }); +}); diff --git a/test/units_definitions.test.ts b/test/units_definitions.test.ts new file mode 100644 index 0000000..c3d5e92 --- /dev/null +++ b/test/units_definitions.test.ts @@ -0,0 +1,59 @@ +import { describe, it, expect } from "vitest"; + +import { + normalizeUnit, + resolveUnit, + isNoUnit, + NO_UNIT, +} from "../src/units/definitions"; + +describe("normalizeUnit", () => { + it("should normalize various unit strings to a canonical definition", () => { + expect(normalizeUnit("g")?.name).toBe("g"); + expect(normalizeUnit("gram")?.name).toBe("g"); + expect(normalizeUnit("grams")?.name).toBe("g"); + expect(normalizeUnit("kilogram")?.name).toBe("kg"); + expect(normalizeUnit("L")?.name).toBe("l"); + expect(normalizeUnit("pounds")?.name).toBe("lb"); + }); + + it("should return undefined for unknown units", () => { + expect(normalizeUnit("glug")).toBeUndefined(); + }); +}); + +describe("resolveUnit", () => { + it("should add various properties from the corresponding canonical definition but preserve the name", () => { + expect(resolveUnit("g").name).toBe("g"); + expect(resolveUnit("gram")).toEqual({ + name: "gram", + type: "mass", + system: "metric", + aliases: ["gram", "grams", "grammes"], + toBase: 1, + }); + }); + + it("should return type 'other' for unknown units", () => { + expect(resolveUnit("glug").type).toBe("other"); + }); +}); + +describe("isNoUnit", () => { + it("should identify no-unit definitions", () => { + expect(isNoUnit({ name: NO_UNIT, type: "other", system: "none" })).toBe( + true, + ); + expect( + isNoUnit({ + name: "g", + type: "mass", + system: "metric", + aliases: ["gram", "grams", "grammes"], + toBase: 1, + }), + ).toBe(false); + expect(isNoUnit(undefined)).toBe(true); + expect(isNoUnit()).toBe(true); + }); +}); diff --git a/test/units_lookup.test.ts b/test/units_lookup.test.ts new file mode 100644 index 0000000..8ca5d60 --- /dev/null +++ b/test/units_lookup.test.ts @@ -0,0 +1,115 @@ +import { describe, it, expect } from "vitest"; + +import { + areUnitsCompatible, + findListWithCompatibleQuantity, + findCompatibleQuantityWithinList, +} from "../src/units/lookup"; +import { UnitDefinition } from "../src/types"; +import { qWithUnitDef } from "./mocks/quantity"; + +describe("areUnitsCompatible", () => { + it("should return true for identical units", () => { + const unitA: UnitDefinition = { + name: "g", + type: "mass", + system: "metric", + aliases: ["gram", "grams", "grammes"], + toBase: 1, + }; + const unitB: UnitDefinition = { + name: "g", + type: "mass", + system: "metric", + aliases: ["gram", "grams", "grammes"], + toBase: 1, + }; + expect(areUnitsCompatible(unitA, unitB)).toBe(true); + }); + it("should return true for units of the same type and system", () => { + const unitA: UnitDefinition = { + name: "kg", + type: "mass", + system: "metric", + aliases: ["kilogram", "kilograms", "kilogrammes", "kilos", "kilo"], + toBase: 1000, + }; + const unitB: UnitDefinition = { + name: "g", + type: "mass", + system: "metric", + aliases: ["gram", "grams", "grammes"], + toBase: 1, + }; + expect(areUnitsCompatible(unitA, unitB)).toBe(true); + }); + it("should return false for units of different types or systems", () => { + const unitA: UnitDefinition = { + name: "oz", + type: "mass", + system: "imperial", + aliases: ["ounce", "ounces"], + toBase: 28.3495, + }; + const unitB: UnitDefinition = { + name: "ml", + type: "volume", + system: "metric", + aliases: ["milliliter", "milliliters", "millilitre", "millilitres", "cc"], + toBase: 1, + }; + expect(areUnitsCompatible(unitA, unitB)).toBe(false); + }); +}); + +describe("findListWithCompatibleQuantity", () => { + const lists = [ + [qWithUnitDef(1, "small"), qWithUnitDef(10, "mL")], + [qWithUnitDef(1, "bucket"), qWithUnitDef(5, "L")], + ]; + + // The user should explicitely provide the link between units of different systems + it("should not consider a list containing a unit of same type but different system to be compatible", () => { + expect( + findListWithCompatibleQuantity(lists, qWithUnitDef(5, "cup")), + ).toBeUndefined(); + }); + + it("should find the first list containing a compatible quantity", () => { + expect( + findListWithCompatibleQuantity(lists, qWithUnitDef(5, "mL")), + ).toEqual(lists[0]); + expect( + findListWithCompatibleQuantity(lists, qWithUnitDef(5, "bucket")), + ).toEqual(lists[1]); + }); +}); + +describe("findCompatibleQuantityWithinList", () => { + const list = [ + qWithUnitDef(1, "small"), + qWithUnitDef(10, "mL"), + qWithUnitDef(2, "cup"), + ]; + + it("should find a compatible quantity when the name is the same", () => { + expect( + findCompatibleQuantityWithinList(list, qWithUnitDef(5, "small")), + ).toEqual(list[0]); + }); + + it("should find a compatible quantity when the type is the same", () => { + expect( + findCompatibleQuantityWithinList(list, qWithUnitDef(5, "tsp")), + ).toEqual(list[1]); + expect( + findCompatibleQuantityWithinList(list, qWithUnitDef(1, "kg")), + ).toBeUndefined(); + }); + + it("should return undefined when the list is empty", () => { + expect( + findCompatibleQuantityWithinList([], qWithUnitDef(5, "mL")), + ).toBeUndefined(); + }); +}); diff --git a/test/utils_general.test.ts b/test/utils_general.test.ts new file mode 100644 index 0000000..167ac4f --- /dev/null +++ b/test/utils_general.test.ts @@ -0,0 +1,39 @@ +import { describe, it, expect } from "vitest"; + +import { deepClone } from "../src/utils/general"; + +describe("deepClone", () => { + it("should create a deep clone of a simple object", () => { + const original = { a: 1, b: 2 }; + const cloned = deepClone(original); + expect(cloned).toEqual(original); + expect(cloned).not.toBe(original); + }); + + it("should create a deep clone of a nested object", () => { + const original = { a: 1, b: { c: 2, d: 3 } }; + const cloned = deepClone(original); + expect(cloned).toEqual(original); + expect(cloned.b).not.toBe(original.b); + }); + + it("should create a deep clone of an array", () => { + const original = [1, 2, { a: 3 }]; + const cloned = deepClone(original); + expect(cloned).toEqual(original); + expect(cloned[2]).not.toBe(original[2]); + }); + + it("should handle null and undefined values", () => { + expect(deepClone(null)).toBeNull(); + expect(deepClone(undefined)).toBeUndefined(); + }); + + it("should handle objects", () => { + const original = { date: new Date() }; + const cloned = deepClone(original); + expect(cloned).toEqual(original); + expect(cloned).toBeTypeOf("object"); + expect(cloned.date).not.toBe(original.date); + }); +}); diff --git a/test/utils_numeric.test.ts b/test/utils_numeric.test.ts new file mode 100644 index 0000000..d4780cb --- /dev/null +++ b/test/utils_numeric.test.ts @@ -0,0 +1,190 @@ +import { describe, it, expect } from "vitest"; +import { + simplifyFraction, + addNumericValues, + multiplyNumericValue, + getNumericValue, + multiplyQuantityValue, + getAverageValue, +} from "../src/quantities/numeric"; +import type { DecimalValue, FractionValue, FixedValue } from "../src/types"; + +describe("simplifyFraction", () => { + it("should throw an error when the denominator is zero", () => { + expect(() => simplifyFraction(1, 0)).toThrowError( + "Denominator cannot be zero.", + ); + }); + + it("should simplify a fraction correctly", () => { + const result = simplifyFraction(6, 8); + expect(result).toEqual({ type: "fraction", num: 3, den: 4 }); + }); + + it("should handle negative numbers correctly", () => { + expect(simplifyFraction(-4, 6)).toEqual({ + type: "fraction", + num: -2, + den: 3, + }); + expect(simplifyFraction(4, -6)).toEqual({ + type: "fraction", + num: -2, + den: 3, + }); + expect(simplifyFraction(-4, -6)).toEqual({ + type: "fraction", + num: 2, + den: 3, + }); + }); + + it("should return a decimal value when the fraction simplifies to a whole number", () => { + const result = simplifyFraction(10, 2); + expect(result).toEqual({ type: "decimal", decimal: 5 }); + }); + + it("should handle cases where the numerator is zero", () => { + const result = simplifyFraction(0, 5); + expect(result).toEqual({ type: "decimal", decimal: 0 }); + }); + + it("should handle cases where the numerator is < 1", () => { + const result = simplifyFraction(0.5, 2); + expect(result).toEqual({ type: "fraction", num: 1, den: 4 }); + }); +}); + +describe("addNumericValues", () => { + it("should add two decimal values", () => { + const val1: DecimalValue = { type: "decimal", decimal: 1.5 }; + const val2: DecimalValue = { type: "decimal", decimal: 2.5 }; + expect(addNumericValues(val1, val2)).toEqual({ + type: "decimal", + decimal: 4, + }); + }); + + it("should add two big decimal values", () => { + const val1: DecimalValue = { type: "decimal", decimal: 1.1 }; + const val2: DecimalValue = { type: "decimal", decimal: 1.3 }; + expect(addNumericValues(val1, val2)).toEqual({ + type: "decimal", + decimal: 2.4, + }); + }); + + it("should add two fraction values", () => { + const val1: FractionValue = { type: "fraction", num: 1, den: 2 }; + const val2: FractionValue = { type: "fraction", num: 1, den: 4 }; + expect(addNumericValues(val1, val2)).toEqual({ + type: "fraction", + num: 3, + den: 4, + }); + }); + + it("should add a decimal and a fraction value", () => { + const val1: DecimalValue = { type: "decimal", decimal: 0.5 }; + const val2: FractionValue = { type: "fraction", num: 1, den: 4 }; + expect(addNumericValues(val1, val2)).toEqual({ + type: "decimal", + decimal: 0.75, + }); + }); + + it("should add a fraction and a decimal value", () => { + const val1: FractionValue = { type: "fraction", num: 1, den: 2 }; + const val2: DecimalValue = { type: "decimal", decimal: 0.25 }; + expect(addNumericValues(val1, val2)).toEqual({ + type: "decimal", + decimal: 0.75, + }); + }); + + it("should return a decimal when fractions add up to a whole number", () => { + const val1: FractionValue = { type: "fraction", num: 1, den: 2 }; + const val2: FractionValue = { type: "fraction", num: 1, den: 2 }; + expect(addNumericValues(val1, val2)).toEqual({ + type: "decimal", + decimal: 1, + }); + }); + + it("should simplify the resulting fraction", () => { + const val1: FractionValue = { type: "fraction", num: 1, den: 6 }; + const val2: FractionValue = { type: "fraction", num: 1, den: 6 }; + expect(addNumericValues(val1, val2)).toEqual({ + type: "fraction", + num: 1, + den: 3, + }); + }); + + it("should return 0 if both values are 0", () => { + const val1: DecimalValue = { type: "decimal", decimal: 0 }; + const val2: DecimalValue = { type: "decimal", decimal: 0 }; + expect(addNumericValues(val1, val2)).toEqual({ + type: "decimal", + decimal: 0, + }); + }); +}); + +describe("multiplyNumericValue", () => { + it("should multiply a decimal value", () => { + const val: DecimalValue = { type: "decimal", decimal: 1.2 }; + expect(multiplyNumericValue(val, 3)).toEqual({ + type: "decimal", + decimal: 3.6, + }); + }); +}); + +describe("getNumericValue", () => { + it("should get the numerical value of a DecimalValue", () => { + expect(getNumericValue({ type: "decimal", decimal: 1.2 })).toBe(1.2); + }); + + it("should get the numerical value of a FractionValue", () => { + expect(getNumericValue({ type: "fraction", num: 2, den: 3 })).toBe(2 / 3); + }); +}); + +describe("multiplyQuantityValue", () => { + it("should multiply a decimal value", () => { + const val: FixedValue = { + type: "fixed", + value: { type: "decimal", decimal: 1.2 }, + }; + expect(multiplyQuantityValue(val, 3)).toEqual({ + type: "fixed", + value: { type: "decimal", decimal: 3.6 }, + }); + }); +}); + +describe("getAverageValue", () => { + it("should return the correct value for fixed values", () => { + expect( + getAverageValue({ + type: "fixed", + value: { type: "decimal", decimal: 1 }, + }), + ).toBe(1); + }); + it("should return the correct value for ranges", () => { + expect( + getAverageValue({ + type: "range", + min: { type: "decimal", decimal: 1 }, + max: { type: "decimal", decimal: 2 }, + }), + ).toBe(1.5); + }); + it("should return the correct value for text values", () => { + expect( + getAverageValue({ type: "fixed", value: { type: "text", text: "two" } }), + ).toBe("two"); + }); +}); diff --git a/test/utils_type-guards.test.ts b/test/utils_type-guards.test.ts new file mode 100644 index 0000000..397ecfd --- /dev/null +++ b/test/utils_type-guards.test.ts @@ -0,0 +1,106 @@ +import { describe, it, expect } from "vitest"; + +import type { + AndGroup, + FixedValue, + OrGroup, + QuantityWithPlainUnit, + Range, +} from "../src/types"; +import { + isGroup, + isAndGroup, + isOrGroup, + isQuantity, +} from "../src/utils/type_guards"; +import { qPlain } from "./mocks/quantity"; +import { isValueIntegerLike } from "../src/utils/type_guards"; + +describe("Type Guards", () => { + const andGroup: AndGroup = { type: "and", entries: [] }; + const orGroup: OrGroup = { type: "or", entries: [] }; + const quantity: QuantityWithPlainUnit = qPlain(2, "cup"); + + describe("isGroup", () => { + it("should identify Group objects", () => { + expect(isGroup(andGroup)).toBe(true); + expect(isGroup(orGroup)).toBe(true); + expect(isGroup(quantity)).toBe(false); + }); + }); + + describe("isAndGroup", () => { + it("should identify AndGroup objects", () => { + expect(isAndGroup(andGroup)).toBe(true); + expect(isAndGroup(orGroup)).toBe(false); + expect(isAndGroup(quantity)).toBe(false); + }); + }); + + describe("isOrGroup", () => { + it("should identify OrGroup objects", () => { + expect(isOrGroup(orGroup)).toBe(true); + expect(isOrGroup(andGroup)).toBe(false); + expect(isOrGroup(quantity)).toBe(false); + }); + }); + + describe("isQuantity", () => { + it("should identify QuantityWithPlainUnit objects", () => { + expect(isQuantity(quantity)).toBe(true); + expect(isQuantity(andGroup)).toBe(false); + expect(isQuantity(orGroup)).toBe(false); + }); + }); + + describe("isValueIntegerLike", () => { + it("should identify integer-like fixed decimal values", () => { + const fixedInt: FixedValue = { + type: "fixed", + value: { type: "decimal", decimal: 4 }, + }; + const fixedNonInt: FixedValue = { + type: "fixed", + value: { type: "decimal", decimal: 4.5 }, + }; + expect(isValueIntegerLike(fixedInt)).toBe(true); + expect(isValueIntegerLike(fixedNonInt)).toBe(false); + }); + it("should identify integer-like fixed fraction values", () => { + const fixedInt: FixedValue = { + type: "fixed", + value: { type: "fraction", num: 6, den: 3 }, + }; + const fixedNonInt: FixedValue = { + type: "fixed", + value: { type: "fraction", num: 7, den: 3 }, + }; + expect(isValueIntegerLike(fixedInt)).toBe(true); + expect(isValueIntegerLike(fixedNonInt)).toBe(false); + }); + it("should identify integer-like range values", () => { + const rangeInt: Range = { + type: "range", + min: { type: "decimal", decimal: 2 }, + max: { type: "decimal", decimal: 6 }, + }; + const rangeNonInt: Range = { + type: "range", + min: { type: "decimal", decimal: 2.5 }, + max: { type: "decimal", decimal: 6 }, + }; + expect(isValueIntegerLike(rangeInt)).toBe(true); + expect(isValueIntegerLike(rangeNonInt)).toBe(false); + }); + it("should return false for text values", () => { + const fixedText: FixedValue = { + type: "fixed", + value: { + type: "text", + text: "example", + }, + }; + expect(isValueIntegerLike(fixedText)).toBe(false); + }); + }); +}); diff --git a/vitest.config.ts b/vitest.config.ts index fb16602..b38aac0 100644 --- a/vitest.config.ts +++ b/vitest.config.ts @@ -9,7 +9,7 @@ export default defineConfig({ reporter: ["text", "html"], enabled: true, include: ["src/**"], - exclude: ["src/types.ts", "src/index.ts"], + exclude: ["src/types.ts", "src/index.ts", "src/utils/general.ts"], }, }, });