diff --git a/docs/index.md b/docs/index.md index 369d6ea..8140bde 100644 --- a/docs/index.md +++ b/docs/index.md @@ -24,6 +24,8 @@ features: details: Fully-typed, compliant with the Cooklang Specifications - title: Useful extensions details: Additional features to the original specs - - title: Parsing, scaling and shopping - details: Classes to parse and scale recipes, as well as parse category configuration and create shopping lists + - title: Parsing and scaling + details: Classes to parse and scale recipes + - title: Shopping + details: Classes to parse category configurations, create shopping lists, and fill in a virtual shopping cart based on a product catalog --- diff --git a/package.json b/package.json index 232b7fe..66cf901 100644 --- a/package.json +++ b/package.json @@ -42,6 +42,7 @@ "@eslint/js": "9.39.1", "@iconify-json/material-symbols": "1.2.44", "@types/big.js": "6.2.2", + "@types/js-yaml": "4.0.9", "@types/node": "22.19.0", "@vitest/coverage-v8": "4.0.8", "@vitest/ui": "4.0.8", @@ -81,6 +82,8 @@ ], "license": "MIT", "dependencies": { - "big.js": "7.0.1" + "big.js": "7.0.1", + "smol-toml": "1.5.2", + "yalps": "0.6.3" } } diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 7c4c664..fb12809 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -16,6 +16,12 @@ importers: big.js: specifier: 7.0.1 version: 7.0.1 + smol-toml: + specifier: ^1.5.2 + version: 1.5.2 + yalps: + specifier: ^0.6.3 + version: 0.6.3 devDependencies: '@eslint/compat': specifier: 1.4.1 @@ -29,6 +35,9 @@ importers: '@types/big.js': specifier: 6.2.2 version: 6.2.2 + '@types/js-yaml': + specifier: ^4.0.9 + version: 4.0.9 '@types/node': specifier: 22.19.0 version: 22.19.0 @@ -959,6 +968,9 @@ packages: '@types/hast@3.0.4': resolution: {integrity: sha512-WPs+bbQw5aCj+x6laNGWLH3wviHtoCv/P3+otBhbOhJgG8qtpdAMlTCxLtsTWA7LH1Oh/bFCHsBn0TPS5m30EQ==} + '@types/js-yaml@4.0.9': + resolution: {integrity: sha512-k4MGaQl5TGo/iipqb2UDG2UwjXziSWkh0uysQelTlJpX1qGlpUZYm8PnO4DxG1qBomtJUdYJ6qR6xdIah10JLg==} + '@types/json-schema@7.0.15': resolution: {integrity: sha512-5+fP8P8MFNC+AyZCDxrB2pkZFPGzqQWUzpSeuuVLvm8VMcorNYavBqoFcxK8bQz4Qsbn4oUEEem4wDLfcysGHA==} @@ -1578,6 +1590,9 @@ packages: hast-util-whitespace@3.0.0: resolution: {integrity: sha512-88JUN06ipLwsnv+dVn+OIYOvAuvBMy/Qoi6O7mQHxdPXpjy+Cd6xRkWwux7DKO+4sYILtLBRIKgsdpS2gQc7qw==} + heap@0.2.7: + resolution: {integrity: sha512-2bsegYkkHO+h/9MGbn6KWcE45cHZgPANo5LXF7EvWdT0yT2EguSVO1nDgU5c8+ZOPwp2vMNa7YFsJhVcDR9Sdg==} + hookable@5.5.3: resolution: {integrity: sha512-Yc+BQe8SvoXH1643Qez1zqLRmbA5rCL+sSmk6TVos0LWVfNIB7PGncdlId77WzLGSIB5KaWgTaNTs2lNVEI6VQ==} @@ -1685,8 +1700,8 @@ packages: js-tokens@9.0.1: resolution: {integrity: sha512-mxa9E9ITFOt0ban3j6L5MpjwegGz6lBQmM1IJkWeBZGcMxto50+eWdjC/52xDbS2vy0k7vIMK0Fe2wfL9OQSpQ==} - js-yaml@4.1.0: - resolution: {integrity: sha512-wpxZs9NoxZaJESJGIZTyDEaYpl0FKSA+FB9aJiyemKhMwkxQg63h4T1KJgUGHpTqPDNRcmmYLugrRjJlBtWvRA==} + js-yaml@4.1.1: + resolution: {integrity: sha512-qQKT4zQxXl8lLwBtHMWwaTcGfFOZviOJet3Oy/xmGk2gZH677CJM9EvtfdSkgWcATZhj/55JZ0rmy3myCT5lsA==} hasBin: true json-buffer@3.0.1: @@ -2063,6 +2078,10 @@ packages: resolution: {integrity: sha512-2wcC/oGxHis/BoHkkPwldgiPSYcpZK3JU28WoMVv55yHJgcZ8rlXvuG9iZggz+sU1d4bRgIGASwyWqjxu3FM0g==} engines: {node: '>=18'} + smol-toml@1.5.2: + resolution: {integrity: sha512-QlaZEqcAH3/RtNyet1IPIYPsEWAaYyXXv1Krsi+1L/QHppjX4Ifm8MQsBISz9vE8cHicIq3clogsheili5vhaQ==} + engines: {node: '>= 18'} + source-map-js@1.2.1: resolution: {integrity: sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==} engines: {node: '>=0.10.0'} @@ -2456,6 +2475,9 @@ packages: resolution: {integrity: sha512-si7QWI6zUMq56bESFvagtmzMdGOtoxfR+Sez11Mobfc7tm+VkUckk9bW2UeffTGVUbOksxmSw0AA2gs8g71NCQ==} engines: {node: '>=12'} + yalps@0.6.3: + resolution: {integrity: sha512-XS1Sb3uejyNMu/Bl+3ZdI4VZGJKT6JSoSDYcLFlapOUy0wJPN9h+XDXAY8xeFk0bQFdEMIOGNAN2/jht4XSJKA==} + yaml@2.8.1: resolution: {integrity: sha512-lcYcMxX2PO9XMGvAJkJ3OsNMw+/7FKes7/hgerGUYWIoWu5j/+YQqcZr5JnPZWzOsEBgMbSbiSTn/dv/69Mkpw==} engines: {node: '>= 14.6'} @@ -2852,7 +2874,7 @@ snapshots: globals: 14.0.0 ignore: 5.3.2 import-fresh: 3.3.1 - js-yaml: 4.1.0 + js-yaml: 4.1.1 minimatch: 3.1.2 strip-json-comments: 3.1.1 transitivePeerDependencies: @@ -3159,6 +3181,8 @@ snapshots: dependencies: '@types/unist': 3.0.3 + '@types/js-yaml@4.0.9': {} + '@types/json-schema@7.0.15': {} '@types/linkify-it@5.0.0': {} @@ -3900,6 +3924,8 @@ snapshots: dependencies: '@types/hast': 3.0.4 + heap@0.2.7: {} + hookable@5.5.3: {} htm@3.1.1: {} @@ -3986,7 +4012,7 @@ snapshots: js-tokens@9.0.1: {} - js-yaml@4.1.0: + js-yaml@4.1.1: dependencies: argparse: 2.0.1 @@ -4369,6 +4395,8 @@ snapshots: mrmime: 2.0.1 totalist: 3.0.1 + smol-toml@1.5.2: {} + source-map-js@1.2.1: {} source-map@0.8.0-beta.0: @@ -4757,6 +4785,10 @@ snapshots: string-width: 5.1.2 strip-ansi: 7.1.0 + yalps@0.6.3: + dependencies: + heap: 0.2.7 + yaml@2.8.1: {} yocto-queue@0.1.0: {} diff --git a/src/classes/product_catalog.ts b/src/classes/product_catalog.ts new file mode 100644 index 0000000..2bd1007 --- /dev/null +++ b/src/classes/product_catalog.ts @@ -0,0 +1,199 @@ +import TOML from "smol-toml"; +import type { + FixedNumericValue, + ProductOption, + ProductOptionToml, +} from "../types"; +import type { TomlTable } from "smol-toml"; +import { + isPositiveIntegerString, + parseQuantityInput, + stringifyQuantityValue, +} from "../parser_helpers"; +import { InvalidProductCatalogFormat } from "../errors"; + +/** + * Product Catalog Manager: used in conjunction with {@link ShoppingCart} + * + * ## Usage + * + * You can either directly populate the products by feeding the {@link ProductCatalog.products | products} property. Alternatively, + * you can provide a catalog in TOML format to either the constructor itself or to the {@link ProductCatalog.parse | parse()} method. + * + * @category Classes + * + * @example + * ```typescript + * import { ProductCatalog } from "@tmlmt/cooklang-parser"; + * + * const catalog = ` + * [eggs] + * aliases = ["oeuf", "huevo"] + * 01123 = { name = "Single Egg", size = "1", price = 2 } + * 11244 = { name = "Pack of 6 eggs", size = "6", price = 10 } + * + * [flour] + * aliases = ["farine", "Mehl"] + * 01124 = { name = "Small pack", size = "100%g", price = 1.5 } + * 14141 = { name = "Big pack", size = "6%kg", price = 10 } + * ` + * const catalog = new ProductCatalog(catalog); + * const eggs = catalog.find("oeuf"); + * ``` + */ +export class ProductCatalog { + public products: ProductOption[] = []; + + constructor(tomlContent?: string) { + if (tomlContent) this.parse(tomlContent); + } + + /** + * Parses a TOML string into a list of product options. + * @param tomlContent - The TOML string to parse. + * @returns A parsed list of `ProductOption`. + */ + public parse(tomlContent: string): ProductOption[] { + const catalogRaw = TOML.parse(tomlContent); + + // Reset internal state + this.products = []; + + if (!this.isValidTomlContent(catalogRaw)) { + throw new InvalidProductCatalogFormat(); + } + + for (const [ingredientName, ingredientData] of Object.entries(catalogRaw)) { + const ingredientTable = ingredientData as TomlTable; + const aliases = ingredientTable.aliases as string[] | undefined; + + for (const [key, productData] of Object.entries(ingredientTable)) { + if (key === "aliases") { + continue; + } + + const productId = key; + const { name, size, price, ...rest } = + productData as unknown as ProductOptionToml; + + const sizeAndUnitRaw = size.split("%"); + const sizeParsed = parseQuantityInput( + sizeAndUnitRaw[0]!, + ) as FixedNumericValue; + + const productOption: ProductOption = { + id: productId, + productName: name, + ingredientName: ingredientName, + price: price, + size: sizeParsed, + ...rest, + }; + if (aliases) { + productOption.ingredientAliases = aliases; + } + + if (sizeAndUnitRaw.length > 1) { + productOption.unit = sizeAndUnitRaw[1]!; + } + + this.products.push(productOption); + } + } + + return this.products; + } + + /** + * Stringifies the catalog to a TOML string. + * @returns The TOML string representation of the catalog. + */ + public stringify(): string { + const grouped: Record = {}; + + for (const product of this.products) { + const { + id, + ingredientName, + ingredientAliases, + size, + unit, + productName, + ...rest + } = product; + if (!grouped[ingredientName]) { + grouped[ingredientName] = {}; + } + if (ingredientAliases && !grouped[ingredientName].aliases) { + grouped[ingredientName].aliases = ingredientAliases; + } + grouped[ingredientName][id] = { + ...rest, + name: productName, + size: unit + ? `${stringifyQuantityValue(size)}%${unit}` + : stringifyQuantityValue(size), + }; + } + + return TOML.stringify(grouped); + } + + /** + * Adds a product to the catalog. + * @param productOption - The product to add. + */ + public add(productOption: ProductOption): void { + this.products.push(productOption); + } + + /** + * Removes a product from the catalog by its ID. + * @param productId - The ID of the product to remove. + */ + public remove(productId: string): void { + this.products = this.products.filter((product) => product.id !== productId); + } + + private isValidTomlContent(catalog: TomlTable): boolean { + for (const productsRaw of Object.values(catalog)) { + if (typeof productsRaw !== "object" || productsRaw === null) { + return false; + } + + for (const [id, obj] of Object.entries(productsRaw)) { + if (id === "aliases") { + if (!Array.isArray(obj)) { + return false; + } + } else { + if (!isPositiveIntegerString(id)) { + return false; + } + if (typeof obj !== "object" || obj === null) { + return false; + } + + const record = obj as Record; + const keys = Object.keys(record); + + const mandatoryKeys = ["name", "size", "price"]; + + if (mandatoryKeys.some((key) => !keys.includes(key))) { + return false; + } + + const hasProductName = typeof record.name === "string"; + const hasSize = typeof record.size === "string"; + const hasPrice = typeof record.price === "number"; + + if (!(hasProductName && hasSize && hasPrice)) { + return false; + } + } + } + } + + return true; + } +} diff --git a/src/classes/shopping_cart.ts b/src/classes/shopping_cart.ts new file mode 100644 index 0000000..66685f6 --- /dev/null +++ b/src/classes/shopping_cart.ts @@ -0,0 +1,379 @@ +import type { + ProductOption, + ProductSelection, + Ingredient, + CartContent, + CartMatch, + CartMisMatch, + FixedNumericValue, + Range, +} from "../types"; +import { ProductCatalog } from "./product_catalog"; +import { ShoppingList } from "./shopping_list"; +import { + NoProductCatalogForCartError, + NoShoppingListForCartError, + NoProductMatchError, +} from "../errors"; +import { + multiplyQuantityValue, + normalizeUnit, + getNumericValue, +} from "../units"; +import { solve, type Model } from "yalps"; + +/** + * Options for the {@link ShoppingCart} constructor + * @category Types + */ +export interface ShoppingCartOptions { + /** + * A product catalog to connect to the cart + */ + catalog?: ProductCatalog; + /** + * A shopping list to connect to the cart + */ + list?: ShoppingList; +} + +/** + * Key information about the {@link ShoppingCart} + * @category Types + */ +export interface ShoppingCartSummary { + /** + * The total price of the cart + */ + totalPrice: number; + /** + * The total number of items in the cart + */ + totalItems: number; +} + +/** + * Shopping Cart Manager: a tool to find the best combination of products to buy (defined in a {@link ProductCatalog}) to satisfy a {@link ShoppingList}. + * + * @example + * ```ts + * const shoppingList = new ShoppingList(); + * const recipe = new Recipe("@flour{600%g}"); + * shoppingList.add_recipe(recipe); + * + * const catalog = new ProductCatalog(); + * catalog.products = [ + * { + * id: "flour-1kg", + * productName: "Flour (1kg)", + * ingredientName: "flour", + * price: 10, + * size: { type: "fixed", value: { type: "decimal", value: 1000 } }, + * unit: "g", + * }, + * { + * id: "flour-500g", + * productName: "Flour (500g)", + * ingredientName: "flour", + * price: 6, + * size: { type: "fixed", value: { type: "decimal", value: 500 } }, + * unit: "g", + * }, + * ]; + * + * const shoppingCart = new ShoppingCart({list: shoppingList, catalog})) + * shoppingCart.buildCart(); + * ``` + * + * @category Classes + */ +export class ShoppingCart { + /** + * The product catalog to use for matching products + */ + productCatalog?: ProductCatalog; + /** + * The shopping list to build the cart from + */ + shoppingList?: ShoppingList; + /** + * The content of the cart + */ + cart: CartContent = []; + /** + * The ingredients that were successfully matched with products + */ + match: CartMatch = []; + /** + * The ingredients that could not be matched with products + */ + misMatch: CartMisMatch = []; + /** + * Key information about the shopping cart + */ + summary: ShoppingCartSummary; + + /** + * Creates a new ShoppingCart instance + * @param options - {@link ShoppingCartOptions | Options} for the constructor + */ + constructor(options?: ShoppingCartOptions) { + if (options?.catalog) this.productCatalog = options.catalog; + if (options?.list) this.shoppingList = options.list; + this.summary = { totalPrice: 0, totalItems: 0 }; + } + + /** + * Sets the product catalog to use for matching products + * To use if a catalog was not provided at the creation of the instance + * @param catalog - The {@link ProductCatalog} to set + */ + setProductCatalog(catalog: ProductCatalog) { + this.productCatalog = catalog; + } + + /** + * Sets the shopping list to build the cart from. + * To use if a shopping list was not provided at the creation of the instance + * @param list - The {@link ShoppingList} to set + */ + setShoppingList(list: ShoppingList) { + this.shoppingList = list; + } + + /** + * Builds the cart from the shopping list and product catalog + * @remarks + * - If a combination of product(s) is successfully found for a given ingredient, the latter will be listed in the {@link ShoppingCart.match | match} array + * in addition to that combination being added to the {@link ShoppingCart.cart | cart}. + * - Otherwise, the latter will be listed in the {@link ShoppingCart.misMatch | misMatch} array. Possible causes can be: + * - No product is listed in the catalog for that ingredient + * - The ingredient has no quantity, a text quantity + * - The ingredient's quantity unit is incompatible with the units of the candidate products listed in the catalog + * @throws {@link NoProductCatalogForCartError} if no product catalog is set + * @throws {@link NoShoppingListForCartError} if no shopping list is set + * @returns `true` if all ingredients in the shopping list have been matched to products in the catalog, or `false` otherwise + */ + buildCart(): boolean { + this.resetCart(); + + if (this.productCatalog === undefined) { + throw new NoProductCatalogForCartError(); + } else if (this.shoppingList === undefined) { + throw new NoShoppingListForCartError(); + } + + for (const ingredient of this.shoppingList.ingredients) { + const productOptions = this.getProductOptions(ingredient); + try { + const optimumMatch = this.getOptimumMatch(ingredient, productOptions); + this.cart.push(...optimumMatch); + this.match.push({ ingredient, selection: optimumMatch }); + } catch (error) { + /* v8 ignore else -- @preserve */ + if (error instanceof NoProductMatchError) { + this.misMatch.push({ ingredient, reason: error.code }); + } + } + } + + this.summarize(); + + return this.misMatch.length > 0; + } + + /** + * Gets the product options for a given ingredient + * @param ingredient - The ingredient to get the product options for + * @returns An array of {@link ProductOption} + */ + private getProductOptions(ingredient: Ingredient): ProductOption[] { + // this function is only called in buildCart() which starts by checking that a product catalog is present + return this.productCatalog!.products.filter( + (product) => + product.ingredientName === ingredient.name || + product.ingredientAliases?.includes(ingredient.name), + ); + } + + /** + * Gets the optimum match for a given ingredient and product option + * @param ingredient - The ingredient to match + * @param options - The product options to choose from + * @returns An array of {@link ProductSelection} + * @throws {@link NoProductMatchError} if no match can be found + */ + private getOptimumMatch( + ingredient: Ingredient, + 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) + 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; + + // 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, + }, + ]; + } + } + + // 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, + ), + }; + + 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, + }; + }); + } + + /** + * 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 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; + } + + /** + * Reset the cart's properties + */ + private resetCart() { + this.cart = []; + this.match = []; + this.misMatch = []; + this.summary = { totalPrice: 0, totalItems: 0 }; + } + + /** + * Calculate the cart's key info and store it in the cart's {@link ShoppingCart.summary | summary} property. + * This function is automatically invoked by {@link ShoppingCart.buildCart | buildCart() } method. + * @returns the total price and number of items in the cart + */ + summarize(): ShoppingCartSummary { + this.summary.totalPrice = this.cart.reduce( + (acc, item) => acc + item.totalPrice, + 0, + ); + this.summary.totalItems = this.cart.length; + return this.summary; + } +} diff --git a/src/errors.ts b/src/errors.ts index b9fd515..e114279 100644 --- a/src/errors.ts +++ b/src/errors.ts @@ -1,4 +1,4 @@ -import { IngredientFlag, CookwareFlag } from "./types"; +import { IngredientFlag, CookwareFlag, NoProductMatchErrorCode } from "./types"; export class ReferencedItemCannotBeRedefinedError extends Error { constructor( @@ -13,3 +13,53 @@ You can either remove the reference to create a new ${item_type} defined as ${ne this.name = "ReferencedItemCannotBeRedefinedError"; } } + +/** + * Error thrown when trying to build a shopping cart without a product catalog + * @category Errors + */ +export class NoProductCatalogForCartError extends Error { + constructor() { + super( + `Cannot build a cart without a product catalog. Please set one using setProductCatalog()`, + ); + this.name = "NoProductCatalogForCartError"; + } +} + +/** + * Error thrown when trying to build a shopping cart without a shopping list + * @category Errors + */ +export class NoShoppingListForCartError extends Error { + constructor() { + super( + `Cannot build a cart without a shopping list. Please set one using setShoppingList()`, + ); + this.name = "NoShoppingListForCartError"; + } +} + +export class NoProductMatchError extends Error { + code: NoProductMatchErrorCode; + + constructor(item_name: string, code: NoProductMatchErrorCode) { + const messageMap: Record = { + incompatibleUnits: `The units of the products in the catalogue are incompatible with ingredient ${item_name} in the shopping list.`, + noProduct: + "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.`, + }; + super(messageMap[code]); + this.code = code; + this.name = "NoProductMatchError"; + } +} + +export class InvalidProductCatalogFormat extends Error { + constructor() { + super("Invalid product catalog format."); + this.name = "InvalidProductCatalogFormat"; + } +} diff --git a/src/index.ts b/src/index.ts index b4a9dc6..e06f92f 100644 --- a/src/index.ts +++ b/src/index.ts @@ -1,8 +1,23 @@ import { CategoryConfig } from "./classes/category_config"; +import { ProductCatalog } from "./classes/product_catalog"; import { Recipe } from "./classes/recipe"; import { ShoppingList } from "./classes/shopping_list"; +import { + ShoppingCart, + type ShoppingCartOptions, + type ShoppingCartSummary, +} from "./classes/shopping_cart"; import { Section } from "./classes/section"; +export { + CategoryConfig, + ProductCatalog, + Recipe, + ShoppingList, + ShoppingCart, + Section, +}; + import type { Metadata, Ingredient, @@ -13,6 +28,7 @@ import type { DecimalValue, FractionValue, TextValue, + FixedNumericValue, Timer, TextItem, IngredientItem, @@ -30,12 +46,19 @@ import type { CategoryIngredient, Category, QuantityPart, + ProductOption, + ProductSelection, + CartContent, + ProductMatch, + CartMatch, + ProductMisMatch, + CartMisMatch, + NoProductMatchErrorCode, } from "./types"; export { - Recipe, - ShoppingList, - CategoryConfig, + ShoppingCartOptions, + ShoppingCartSummary, Metadata, Ingredient, IngredientFlag, @@ -45,6 +68,7 @@ export { DecimalValue, FractionValue, TextValue, + FixedNumericValue, Timer, TextItem, IngredientItem, @@ -61,6 +85,20 @@ export { RecipeWithServings, CategoryIngredient, Category, - Section, QuantityPart, + ProductOption, + ProductSelection, + CartContent, + ProductMatch, + CartMatch, + ProductMisMatch, + CartMisMatch, + NoProductMatchErrorCode, }; + +import { + NoProductCatalogForCartError, + NoShoppingListForCartError, +} from "./errors"; + +export { NoProductCatalogForCartError, NoShoppingListForCartError }; diff --git a/src/parser_helpers.ts b/src/parser_helpers.ts index 370ca86..777624c 100644 --- a/src/parser_helpers.ts +++ b/src/parser_helpers.ts @@ -256,6 +256,21 @@ export const parseFixedValue = ( return { type: "decimal", value: Number(s) }; }; +export function stringifyQuantityValue(quantity: FixedValue | Range): string { + if (quantity.type === "fixed") { + return stringifyFixedValue(quantity); + } else { + return `${stringifyFixedValue({ type: "fixed", value: quantity.min })}-${stringifyFixedValue({ type: "fixed", value: quantity.max })}`; + } +} + +function stringifyFixedValue(quantity: FixedValue): string { + if (quantity.value.type === "fraction") + return `${quantity.value.num}/${quantity.value.den}`; + else return String(quantity.value.value); +} + +// TODO: rename to parseQuantityValue export function parseQuantityInput(input_str: string): FixedValue | Range { const clean_str = String(input_str).trim(); @@ -409,3 +424,7 @@ export function extractMetadata(content: string): MetadataExtract { return { metadata, servings }; } + +export function isPositiveIntegerString(str: string): boolean { + return /^\d+$/.test(str); +} diff --git a/src/types.ts b/src/types.ts index 90798a4..a523d1c 100644 --- a/src/types.ts +++ b/src/types.ts @@ -170,6 +170,16 @@ export interface FixedValue { value: TextValue | DecimalValue | FractionValue; } +/** + * Represents a single, fixed numeric quantity. + * This can be a decimal or fraction. + * @category Types + */ +export interface FixedNumericValue { + type: "fixed"; + value: DecimalValue | FractionValue; +} + /** * Represents a range of quantities, e.g. "1-2" * @category Types @@ -415,3 +425,102 @@ export interface Category { /** The ingredients in the category. */ ingredients: CategoryIngredient[]; } + +/** + * Represents a product option in a {@link ProductCatalog} + * @category Types + */ +export interface ProductOption { + /** The ID of the product */ + id: string; + /** The name of the product */ + productName: string; + /** The name of the ingredient it corresponds to */ + 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; +} + +/** + * Represents a product option as described in a catalog TOML file + * @category Types + */ +export interface ProductOptionToml { + /** The name of the product */ + name: string; + /** The size and unit of the product separated by % */ + size: string; + /** The price of the product */ + price: number; + /** Arbitrary additional metadata */ + // eslint-disable-next-line @typescript-eslint/no-explicit-any + [key: string]: any; +} + +/** + * Represents a product selection in a {@link ShoppingCart} + * @category Types + */ +export interface ProductSelection { + /** The selected product */ + product: ProductOption; + /** The quantity of the selected product */ + quantity: number; + /** The total price for this selected product */ + totalPrice: number; +} + +/** + * Represents the content of the actual cart of the {@link ShoppingCart} + * @category Types + */ +export type CartContent = ProductSelection[]; + +/** + * Represents a successful match between a ingredient and product(s) in the product catalog, in a {@link ShoppingCart} + * @category Types + */ +export interface ProductMatch { + ingredient: Ingredient; + selection: ProductSelection[]; +} + +/** + * Represents all successful matches between ingredients and the product catalog, in a {@link ShoppingCart} + * @category Types + */ +export type CartMatch = ProductMatch[]; + +/** + * Represents the error codes for an ingredient which didn't match with any product in the product catalog, in a {@link ShoppingCart} + * @category Types + */ +export type NoProductMatchErrorCode = + | "incompatibleUnits" + | "noProduct" + | "textValue" + | "noQuantity"; + +/** + * Represents an ingredient which didn't match with any product in the product catalog, in a {@link ShoppingCart} + * @category Types + */ +export interface ProductMisMatch { + ingredient: Ingredient; + reason: NoProductMatchErrorCode; +} + +/** + * Represents all ingredients which didn't match with any product in the product catalog, in a {@link ShoppingCart} + * @category Types + */ +export type CartMisMatch = ProductMisMatch[]; diff --git a/src/units.ts b/src/units.ts index 45f8e2c..94e3584 100644 --- a/src/units.ts +++ b/src/units.ts @@ -195,6 +195,14 @@ export function simplifyFraction( } } +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, diff --git a/test/parser_helpers.test.ts b/test/parser_helpers.test.ts index 1a6eadd..5be19e1 100644 --- a/test/parser_helpers.test.ts +++ b/test/parser_helpers.test.ts @@ -12,6 +12,7 @@ import { extractMetadata, findAndUpsertCookware, findAndUpsertIngredient, + stringifyQuantityValue, } from "../src/parser_helpers"; describe("parseSimpleMetaVar", () => { @@ -595,3 +596,30 @@ describe("parseQuantityInput", () => { }); }); }); + +describe("stringifyQuantityValue", () => { + it("correctly stringify fixed values", () => { + expect( + stringifyQuantityValue({ + type: "fixed", + value: { type: "decimal", value: 1.5 }, + }), + ).toEqual("1.5"); + expect( + stringifyQuantityValue({ + type: "fixed", + value: { type: "fraction", num: 2, den: 3 }, + }), + ).toEqual("2/3"); + }); + + it("correctly stringify ranges", () => { + expect( + stringifyQuantityValue({ + type: "range", + min: { type: "decimal", value: 1 }, + max: { type: "decimal", value: 2 }, + }), + ).toEqual("1-2"); + }); +}); diff --git a/test/product_catalog.test.ts b/test/product_catalog.test.ts new file mode 100644 index 0000000..1922425 --- /dev/null +++ b/test/product_catalog.test.ts @@ -0,0 +1,275 @@ +import { describe, it, expect } from "vitest"; +import { ProductCatalog } from "../src/classes/product_catalog"; +import { InvalidProductCatalogFormat } from "../src/errors"; +import { ProductOption } from "../src"; + +describe("ProductCatalog", () => { + const exampleTomlContent = `[eggs] +01123 = { name = "Single Egg", size = "1", price = 2 } +11244 = { name = "Pack of 6 eggs", size = "6", price = 10 } + +[flour] +01124 = { name = "Small pack", size = "100%g", price = 1.5 } +14141 = { name = "Big pack", size = "6%kg", price = 10 }`; + + const exampleTomlContentAlt = `[eggs.11244] +price = 10 +name = "Pack of 6 eggs" +size = "6" + +[eggs.01123] +price = 2 +name = "Single Egg" +size = "1" + +[flour.14141] +price = 10 +name = "Big pack" +size = "6%kg" + +[flour.01124] +price = 1.5 +name = "Small pack" +size = "100%g" +`; + + const exampleProductOptions: ProductOption[] = [ + { + id: "11244", + productName: "Pack of 6 eggs", + ingredientName: "eggs", + price: 10, + size: { type: "fixed", value: { type: "decimal", value: 6 } }, + }, + { + id: "01123", + productName: "Single Egg", + ingredientName: "eggs", + price: 2, + size: { type: "fixed", value: { type: "decimal", value: 1 } }, + }, + { + id: "14141", + productName: "Big pack", + ingredientName: "flour", + price: 10, + size: { type: "fixed", value: { type: "decimal", value: 6 } }, + unit: "kg", + }, + { + id: "01124", + productName: "Small pack", + ingredientName: "flour", + price: 1.5, + size: { type: "fixed", value: { type: "decimal", value: 100 } }, + unit: "g", + }, + ]; + + describe("parsing", () => { + it("should parse a valid product catalog", () => { + const catalog = new ProductCatalog(); + const products = catalog.parse(exampleTomlContent); + expect(products.length).toBe(4); + expect(products).toEqual(exampleProductOptions); + }); + + it("should parse the same valid product catalog presented in dotted format", () => { + const catalog = new ProductCatalog(); + const products = catalog.parse(exampleTomlContentAlt); + expect(products.length).toBe(4); + expect(products).toEqual(exampleProductOptions); + }); + + it("should parse a product catalog with valid aliases", () => { + const catalog = new ProductCatalog(); + const products = catalog.parse(`[eggs] +aliases = ["oeuf", "huevo"] +01123 = { name = "Single Egg", size = "1", price = 2 }`); + expect(products.length).toBe(1); + expect(products).toEqual([ + { + id: "01123", + productName: "Single Egg", + ingredientName: "eggs", + ingredientAliases: ["oeuf", "huevo"], + price: 2, + size: { type: "fixed", value: { type: "decimal", value: 1 } }, + }, + ]); + }); + + it("should parse a product catalog with additional metadata", () => { + const catalog = new ProductCatalog(); + const products = catalog.parse(`[eggs] +01123 = { name = "Single Egg", size = "1", price = 2, image = "egg.png" }`); + expect(products.length).toBe(1); + expect(products).toEqual([ + { + id: "01123", + image: "egg.png", + productName: "Single Egg", + ingredientName: "eggs", + price: 2, + size: { type: "fixed", value: { type: "decimal", value: 1 } }, + }, + ]); + }); + + it.each([ + // Ingredient value is not a table + `eggs = "not a table"`, + // Product is not an object + `[eggs] +01123 = "not an object"`, + // No price + `[eggs] +01123 = { name = "Single Egg", size = "1" }`, + // No size + `[eggs] +Text = { name = "Single Egg", size = "1", price = 2 }`, + // No ingredient name + ` +01234 = { name = "Single Egg", size = "1", price = 2 }`, + // No product name + `[eggs] +01234 = { size = "1", price = 2 }`, + // Non numerical price + `[flour] +01234 = { name = "Single Pack", size = "100%g", price = "2" }`, + // Invalid aliases definition + `[eggs] +aliases = "not an array"`, + ])( + "should throw an error for an invalid product catalog", + (tomlContent) => { + expect(() => new ProductCatalog(tomlContent)).toThrow( + InvalidProductCatalogFormat, + ); + }, + ); + }); + + describe("stringifying", () => { + it("should stringify a valid product catalog", () => { + const catalog = new ProductCatalog(); + catalog.products = exampleProductOptions; + const stringified = catalog.stringify(); + expect(stringified).toBe(exampleTomlContentAlt); + }); + + it("should handle products with aliases", () => { + const catalog = new ProductCatalog(); + catalog.products = [ + { + id: "11244", + productName: "Pack of 6 eggs", + ingredientName: "eggs", + ingredientAliases: ["oeuf", "huevo"], + price: 10, + size: { type: "fixed", value: { type: "decimal", value: 6 } }, + }, + { + id: "01123", + productName: "Single Egg", + ingredientName: "eggs", + ingredientAliases: ["oeuf", "huevo"], + price: 2, + size: { type: "fixed", value: { type: "decimal", value: 1 } }, + }, + ]; + const stringified = catalog.stringify(); + expect(stringified).toBe(`[eggs] +aliases = [ "oeuf", "huevo" ] + +[eggs.11244] +price = 10 +name = "Pack of 6 eggs" +size = "6" + +[eggs.01123] +price = 2 +name = "Single Egg" +size = "1" +`); + }); + + it("should handle products with arbitrary metadata", () => { + const catalog = new ProductCatalog(); + catalog.products = [ + { + id: "11244", + productName: "Pack of 6 eggs", + ingredientName: "eggs", + price: 10, + size: { type: "fixed", value: { type: "decimal", value: 6 } }, + image: "egg.png", + }, + ]; + const stringified = catalog.stringify(); + expect(stringified).toBe(`[eggs.11244] +price = 10 +image = "egg.png" +name = "Pack of 6 eggs" +size = "6" +`); + }); + }); + + describe("adding", () => { + it("should add a product to the catalog", () => { + const catalog = new ProductCatalog(); + const newProduct: ProductOption = { + id: "12345", + productName: "New Product", + ingredientName: "new-ingredient", + size: { type: "fixed", value: { type: "decimal", value: 1 } }, + unit: "kg", + price: 10, + }; + catalog.add(newProduct); + expect(catalog.products.length).toBe(1); + expect(catalog.products[0]).toEqual(newProduct); + }); + }); + + describe("removing", () => { + it("should remove a product from the catalog", () => { + const catalog = new ProductCatalog(exampleTomlContent); + catalog.remove("11244"); + expect(catalog.products.length).toBe(3); + expect(catalog.products).not.toContainEqual({ + id: "11244", + productName: "Pack of 6 eggs", + ingredientName: "eggs", + price: 10, + size: { type: "fixed", value: { type: "decimal", value: 6 } }, + }); + }); + + it("should do nothing if a product do not exist", () => { + const catalog = new ProductCatalog(exampleTomlContent); + catalog.remove("00000"); + expect(catalog.products.length).toBe(4); + expect(catalog.products).toEqual(exampleProductOptions); + }); + }); + + describe("adding and removing with aliases", () => { + it("should add and remove products with aliases", () => { + const catalog = new ProductCatalog(); + const newProduct: ProductOption = { + id: "12345", + productName: "New Product", + ingredientName: "new-ingredient", + ingredientAliases: ["alias-1"], + size: { type: "fixed", value: { type: "decimal", value: 1 } }, + unit: "kg", + price: 10, + }; + catalog.add(newProduct); + catalog.remove("12345"); + expect(catalog.products.length).toBe(0); + }); + }); +}); diff --git a/test/shopping_cart.test.ts b/test/shopping_cart.test.ts new file mode 100644 index 0000000..d5de399 --- /dev/null +++ b/test/shopping_cart.test.ts @@ -0,0 +1,290 @@ +import { describe, it, expect } from "vitest"; +import { ShoppingCart } from "../src/classes/shopping_cart"; +import { ShoppingList } from "../src/classes/shopping_list"; +import { Recipe } from "../src/classes/recipe"; +import { ProductCatalog } from "../src/classes/product_catalog"; +import { + NoProductCatalogForCartError, + NoShoppingListForCartError, +} from "../src/errors"; +import { + recipeForShoppingList1, + recipeForShoppingList2, +} from "./fixtures/recipes"; + +const productCatalog: ProductCatalog = new ProductCatalog(); +productCatalog.products = [ + { + id: "flour-80g", + productName: "Flour (80g)", + ingredientName: "flour", + price: 25, + size: { type: "fixed", value: { type: "decimal", value: 80 } }, + unit: "g", + }, + { + id: "flour-40g", + productName: "Flour (40g)", + ingredientName: "flour", + price: 15, + size: { type: "fixed", value: { type: "decimal", value: 40 } }, + unit: "g", + }, + { + id: "eggs-1", + productName: "Single Egg", + ingredientName: "eggs", + price: 20, + size: { type: "fixed", value: { type: "decimal", value: 1 } }, + }, + { + id: "milk-1L", + productName: "Milk (1L)", + ingredientName: "milk", + price: 30, + size: { type: "fixed", value: { type: "decimal", value: 1 } }, + unit: "l", + }, +]; + +describe("initialisation", () => { + it("should be initialized directly with the class constructor", () => { + const shoppingList = new ShoppingList(); + shoppingList.add_recipe(new Recipe(recipeForShoppingList1)); + const shoppingCart = new ShoppingCart({ + catalog: productCatalog, + list: shoppingList, + }); + expect(shoppingCart.productCatalog).toBe(productCatalog); + expect(shoppingCart.shoppingList).toBe(shoppingList); + }); + + it("should throw an error if no shopping list is set", () => { + const shoppingCart = new ShoppingCart(); + shoppingCart.setProductCatalog(productCatalog); + expect(() => shoppingCart.buildCart()).toThrow(NoShoppingListForCartError); + }); + + it("should throw an error if no product catalog is set", () => { + const shoppingList = new ShoppingList(); + shoppingList.add_recipe(new Recipe(recipeForShoppingList1)); + const shoppingCart = new ShoppingCart(); + shoppingCart.setShoppingList(shoppingList); + expect(() => shoppingCart.buildCart()).toThrow( + NoProductCatalogForCartError, + ); + }); +}); + +describe("buildCart", () => { + it("should handle ingredients with no matching products", () => { + const shoppingCart = new ShoppingCart(); + const shoppingList = new ShoppingList(); + const recipe = new Recipe("@unknown-ingredient{1}"); + shoppingList.add_recipe(recipe); + shoppingCart.setShoppingList(shoppingList); + shoppingCart.setProductCatalog(productCatalog); + shoppingCart.buildCart(); + + expect(shoppingCart.cart).toEqual([]); + }); + + it("should handle ingredients with no quantity", () => { + const shoppingCart = new ShoppingCart(); + const shoppingList = new ShoppingList(); + const recipe = new Recipe("@flour"); + shoppingList.add_recipe(recipe); + shoppingCart.setShoppingList(shoppingList); + shoppingCart.setProductCatalog(productCatalog); + shoppingCart.buildCart(); + + expect(shoppingCart.cart).toEqual([]); + }); + + it("should handle ingredients with text quantity", () => { + const shoppingCart = new ShoppingCart(); + const shoppingList = new ShoppingList(); + const recipe = new Recipe("@flour{a bit}"); + shoppingList.add_recipe(recipe); + shoppingCart.setShoppingList(shoppingList); + shoppingCart.setProductCatalog(productCatalog); + shoppingCart.buildCart(); + + expect(shoppingCart.cart).toEqual([]); + }); + + it("should handle gracefully ingredient/products with for incompatible units", () => { + const shoppingCart = new ShoppingCart(); + const shoppingList = new ShoppingList(); + const recipe = new Recipe("@flour{1%l}"); + shoppingList.add_recipe(recipe); + 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("flour"); + expect(shoppingCart.misMatch[0]!.reason).toBe("incompatibleUnits"); + + const shoppingCart2 = new ShoppingCart(); + const shoppingList2 = new ShoppingList(); + const recipe2 = new Recipe("@eggs{2}"); + const productCatalog2: ProductCatalog = new ProductCatalog(); + productCatalog2.add({ + id: "eggs-1", + productName: "Pack of 12 eggs", + ingredientName: "eggs", + price: 20, + size: { type: "fixed", value: { type: "decimal", value: 1 } }, + unit: "dozen", + }); + shoppingList2.add_recipe(recipe2); + shoppingCart2.setShoppingList(shoppingList2); + shoppingCart2.setProductCatalog(productCatalog2); + shoppingCart2.buildCart(); + expect(shoppingCart2.match.length).toBe(0); + expect(shoppingCart2.misMatch.length).toBe(1); + expect(shoppingCart2.misMatch[0]!.ingredient.name).toBe("eggs"); + expect(shoppingCart2.misMatch[0]!.reason).toBe("incompatibleUnits"); + + const shoppingCart3 = new ShoppingCart(); + const shoppingList3 = new ShoppingList(); + const recipe3 = new Recipe("@eggs{1%dozen}"); + const productCatalog3: ProductCatalog = new ProductCatalog(); + productCatalog3.add({ + id: "eggs-1", + productName: "Single Egg", + ingredientName: "eggs", + price: 20, + size: { type: "fixed", value: { type: "decimal", value: 1 } }, + }); + shoppingList3.add_recipe(recipe3); + shoppingCart3.setShoppingList(shoppingList3); + shoppingCart3.setProductCatalog(productCatalog3); + shoppingCart3.buildCart(); + expect(shoppingCart3.match.length).toBe(0); + expect(shoppingCart3.misMatch.length).toBe(1); + expect(shoppingCart3.misMatch[0]!.ingredient.name).toBe("eggs"); + expect(shoppingCart3.misMatch[0]!.reason).toBe("incompatibleUnits"); + + const shoppingCart4 = new ShoppingCart(); + const shoppingList4 = new ShoppingList(); + const recipe4 = new Recipe("@peeled tomatoes{2%cans}"); + const productCatalog4: ProductCatalog = new ProductCatalog(); + productCatalog4.add({ + id: "0123", + productName: "Peeled Tomatoes", + ingredientName: "peeled tomatoes", + price: 20, + size: { type: "fixed", value: { type: "decimal", value: 400 } }, + unit: "g", + }); + shoppingList4.add_recipe(recipe4); + shoppingCart4.setShoppingList(shoppingList4); + shoppingCart4.setProductCatalog(productCatalog4); + shoppingCart4.buildCart(); + expect(shoppingCart4.match.length).toBe(0); + expect(shoppingCart4.misMatch.length).toBe(1); + expect(shoppingCart4.misMatch[0]!.ingredient.name).toBe("peeled tomatoes"); + expect(shoppingCart4.misMatch[0]!.reason).toBe("incompatibleUnits"); + }); + + it("should choose the cheapest option", () => { + const shoppingCart = new ShoppingCart(); + const shoppingList = new ShoppingList(); + const recipe = new Recipe("@flour{600%g}"); + shoppingList.add_recipe(recipe); + shoppingCart.setShoppingList(shoppingList); + const catalog = new ProductCatalog(); + catalog.products = [ + { + id: "flour-1kg", + productName: "Flour (1kg)", + ingredientName: "flour", + price: 10, + size: { type: "fixed", value: { type: "decimal", value: 1000 } }, + unit: "g", + }, + { + id: "flour-500g", + productName: "Flour (500g)", + ingredientName: "flour", + price: 6, + size: { type: "fixed", value: { type: "decimal", value: 500 } }, + unit: "g", + }, + ]; + shoppingCart.setProductCatalog(catalog); + shoppingCart.buildCart(); + + // It should choose 1x 1kg pack (price 1) over 2x 500g packs (price 1.2) + expect(shoppingCart.cart).toEqual([ + { product: catalog.products[0], quantity: 1, totalPrice: 10 }, // 1x 1kg + ]); + }); + + it("should handle range quantities", () => { + const shoppingCart = new ShoppingCart(); + const shoppingList = new ShoppingList(); + const recipe = new Recipe("Mix @flour{30-90%g} with @milk{80-120%cL}"); + shoppingList.add_recipe(recipe); + shoppingCart.setShoppingList(shoppingList); + shoppingCart.setProductCatalog(productCatalog); + shoppingCart.buildCart(); + + expect(shoppingCart.cart).toEqual([ + // Needs at least 30g of flour. 1 x 40g should be the solution + { product: productCatalog.products[1], quantity: 1, totalPrice: 15 }, + // Needs at least 80cL of milk, 1 x 1L should be the solution + { product: productCatalog.products[3], quantity: 1, totalPrice: 30 }, + ]); + }); + + it("should build a cart with one recipe", () => { + const shoppingList = new ShoppingList(); + const shoppingCart = new ShoppingCart(); + shoppingList.add_recipe(new Recipe(recipeForShoppingList1)); + shoppingCart.setShoppingList(shoppingList); + shoppingCart.setProductCatalog(productCatalog); + shoppingCart.buildCart(); + + expect(shoppingCart.cart).toEqual([ + { product: productCatalog.products[0], quantity: 1, totalPrice: 25 }, // 1x + { product: productCatalog.products[1], quantity: 1, totalPrice: 15 }, // 1x + { product: productCatalog.products[2], quantity: 2, totalPrice: 40 }, // 1x + { product: productCatalog.products[3], quantity: 1, totalPrice: 30 }, // 1x + ]); + expect(shoppingCart.match.length).toBe(3); + expect(shoppingCart.misMatch.length).toBe(3); + }); + + it("should build a cart with multiple recipes", () => { + const shoppingCart = new ShoppingCart(); + const shoppingList = new ShoppingList(); + shoppingList.add_recipe(new Recipe(recipeForShoppingList1)); + shoppingList.add_recipe(new Recipe(recipeForShoppingList2)); + shoppingCart.setShoppingList(shoppingList); + shoppingCart.setProductCatalog(productCatalog); + shoppingCart.buildCart(); + + expect(shoppingCart.cart).toEqual([ + { product: productCatalog.products[0], quantity: 2, totalPrice: 50 }, // 1x + { product: productCatalog.products[2], quantity: 3, totalPrice: 60 }, // 1x + { product: productCatalog.products[3], quantity: 1, totalPrice: 30 }, // 1x + ]); + expect(shoppingCart.match.length).toBe(3); + expect( + shoppingCart.misMatch.map((mismatch) => [ + mismatch.ingredient.name, + mismatch.reason, + ]), + ).toEqual([ + // there's more reasons, but the first one that matches is captured + ["sugar", "noProduct"], + ["pepper", "noProduct"], + ["spices", "noProduct"], + ["butter", "noProduct"], + ["pepper", "noProduct"], + ]); + }); +}); diff --git a/test/units.test.ts b/test/units.test.ts index 443b2eb..eba0598 100644 --- a/test/units.test.ts +++ b/test/units.test.ts @@ -5,6 +5,7 @@ import { normalizeUnit, simplifyFraction, addNumericValues, + getNumericValue, CannotAddTextValueError, IncompatibleUnitsError, addQuantityValues, @@ -586,4 +587,14 @@ describe("multiplyQuantityValue", () => { 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); + }); + }); });