From 455d11a9bd44bd3c793a20d84f2fee6bad656c81 Mon Sep 17 00:00:00 2001 From: Thomas Lamant Date: Tue, 10 Feb 2026 01:30:53 +0100 Subject: [PATCH] feat: pantry inventory management and connection with ShoppingList Resolves: #25 --- docs/examples-shopping-lists.md | 44 +++- docs/index.md | 2 +- src/classes/pantry.ts | 323 ++++++++++++++++++++++++ src/classes/shopping_list.ts | 176 ++++++++++++- src/index.ts | 8 + src/quantities/mutations.ts | 38 +++ src/types.ts | 47 ++++ src/utils/parser_helpers.ts | 167 ++++++++++++ test/pantry.test.ts | 404 ++++++++++++++++++++++++++++++ test/parser_helpers.test.ts | 150 +++++++++++ test/quantities_mutations.test.ts | 98 ++++++++ test/shopping_list.test.ts | 387 +++++++++++++++++++++++++++- 12 files changed, 1838 insertions(+), 6 deletions(-) create mode 100644 src/classes/pantry.ts create mode 100644 test/pantry.test.ts diff --git a/docs/examples-shopping-lists.md b/docs/examples-shopping-lists.md index 0292413..41b42f5 100644 --- a/docs/examples-shopping-lists.md +++ b/docs/examples-shopping-lists.md @@ -48,4 +48,46 @@ shoppingList.setCategoryConfig(myConfig) ### Categorizing according to the category configuration -This is done automatically each time you add or remove a recipe. \ No newline at end of file +This is done automatically each time you add or remove a recipe. + +## Optional: Pantry + +You can provide a [Pantry](/api/classes/Pantry) to the [ShoppingList](/api/classes/ShoppingList) so that on-hand pantry quantities are automatically subtracted from ingredient needs. The original pantry is never mutated — instead, a resulting pantry is recomputed on every recalculation. + +### Adding a pantry + +Use [`addPantry()`](/api/classes/ShoppingList.html#addPantry) with either a `Pantry` instance or a raw TOML string: + +```typescript +import { Pantry, ShoppingList } from "@tmlmt/cooklang-parser"; + +const shoppingList = new ShoppingList(); +shoppingList.addRecipe(myRecipe); + +// From a TOML string +shoppingList.addPantry(` +[pantry] +flour = "500%g" +eggs = "6" +`); + +// Or from a Pantry instance +const pantry = new Pantry(tomlString); +shoppingList.addPantry(pantry); +``` + +When a pantry is set, ingredient quantities in the shopping list are reduced by the corresponding pantry amounts. For example, if a recipe needs 200 g of flour and the pantry has 500 g, the shopping list will show 0 g of flour needed. + +### Getting the resulting pantry + +After recipes have been added, you can retrieve the resulting pantry — reflecting what remains after recipe needs have been subtracted — with [`getPantry()`](/api/classes/ShoppingList.html#getPantry): + +```typescript +const resultingPantry = shoppingList.getPantry(); +if (resultingPantry) { + const flour = resultingPantry.findItem("flour"); + console.log(flour?.quantity); // remaining quantity after subtraction +} +``` + +If no pantry has been set, `getPantry()` returns `undefined`. \ No newline at end of file diff --git a/docs/index.md b/docs/index.md index a744858..d1d7ffe 100644 --- a/docs/index.md +++ b/docs/index.md @@ -31,5 +31,5 @@ features: - 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 + details: Classes to parse category configurations, pantry inventory, create shopping lists, and fill in a virtual shopping cart based on a product catalog --- diff --git a/src/classes/pantry.ts b/src/classes/pantry.ts new file mode 100644 index 0000000..2351c90 --- /dev/null +++ b/src/classes/pantry.ts @@ -0,0 +1,323 @@ +import TOML from "smol-toml"; +import type { TomlTable } from "smol-toml"; +import type { PantryItem, PantryItemToml, PantryOptions } from "../types"; +import type { CategoryConfig } from "./category_config"; +import { + parseQuantityWithUnit, + parseDateFromFormat, + parseFuzzyDate, +} from "../utils/parser_helpers"; +import { getNumericValue } from "../quantities/numeric"; +import { normalizeUnit } from "../units/definitions"; +import { getToBase } from "../units/conversion"; + +/** + * Pantry Inventory Manager: parses and queries a pantry inventory file. + * + * ## Usage + * + * Create a new Pantry instance with optional TOML content and options + * (see {@link Pantry."constructor" | constructor}), then query items + * using {@link Pantry.getDepletedItems | getDepletedItems()}, + * {@link Pantry.getExpiredItems | getExpiredItems()}, + * {@link Pantry.isLow | isLow()}, or {@link Pantry.isExpired | isExpired()}. + * + * A Pantry can also be attached to a {@link ShoppingList} via + * {@link ShoppingList.addPantry | addPantry()} so that on-hand stock + * is subtracted from recipe ingredient needs. + * + * @example + * ```typescript + * import { Pantry } from "@tmlmt/cooklang-parser"; + * + * const pantryToml = ` + * [fridge] + * milk = { expire = "10.05.2024", quantity = "1%L" } + * + * [freezer] + * spinach = { quantity = "1%kg", low = "200%g" } + * `; + * + * const pantry = new Pantry(pantryToml); + * console.log(pantry.getExpiredItems()); + * console.log(pantry.isLow("spinach")); + * ``` + * + * @see [Pantry Configuration](https://cooklang.org/docs/spec/#pantry-configuration) section of the cooklang specs + * + * @category Classes + */ +export class Pantry { + /** + * The parsed pantry items. + */ + items: PantryItem[] = []; + + /** + * Options for date parsing and other configuration. + */ + private options: PantryOptions; + + /** + * Optional category configuration for alias-based lookups. + */ + private categoryConfig?: CategoryConfig; + + /** + * Creates a new Pantry instance. + * @param tomlContent - Optional TOML content to parse. + * @param options - Optional configuration options. + */ + constructor(tomlContent?: string, options: PantryOptions = {}) { + this.options = options; + if (tomlContent) { + this.parse(tomlContent); + } + } + + /** + * Parses a TOML string into pantry items. + * @param tomlContent - The TOML string to parse. + * @returns The parsed list of pantry items. + */ + parse(tomlContent: string): PantryItem[] { + const raw = TOML.parse(tomlContent); + + // Reset internal state + this.items = []; + + for (const [location, locationData] of Object.entries(raw)) { + const locationTable = locationData as TomlTable; + + for (const [itemName, itemData] of Object.entries(locationTable)) { + const item = this.parseItem( + itemName, + location, + itemData as PantryItemToml, + ); + this.items.push(item); + } + } + + return this.items; + } + + /** + * Parses a single pantry item from its TOML representation. + */ + private parseItem( + name: string, + location: string, + data: PantryItemToml, + ): PantryItem { + const item: PantryItem = { name, location }; + + if (typeof data === "string") { + // Simple quantity string, e.g. "500%g" + const parsed = parseQuantityWithUnit(data); + item.quantity = parsed.value; + if (parsed.unit) item.unit = parsed.unit; + } else { + // Object with attributes + if (data.quantity) { + const parsed = parseQuantityWithUnit(data.quantity); + item.quantity = parsed.value; + if (parsed.unit) item.unit = parsed.unit; + } + if (data.low) { + const parsed = parseQuantityWithUnit(data.low); + item.low = parsed.value; + if (parsed.unit) item.lowUnit = parsed.unit; + } + if (data.bought) { + item.bought = this.parseDate(data.bought); + } + if (data.expire) { + item.expire = this.parseDate(data.expire); + } + } + + return item; + } + + /** + * Parses a date string using the configured format or fuzzy detection. + */ + private parseDate(input: string): Date { + if (this.options.dateFormat) { + return parseDateFromFormat(input, this.options.dateFormat); + } + return parseFuzzyDate(input); + } + + /** + * Sets a category configuration for alias-based item lookups. + * @param config - The category configuration to use. + */ + setCategoryConfig(config: CategoryConfig): void { + this.categoryConfig = config; + } + + /** + * Finds a pantry item by name, using exact match first, then alias lookup + * via the stored CategoryConfig. + * @param name - The name to search for. + * @returns The matching pantry item, or undefined if not found. + */ + findItem(name: string): PantryItem | undefined { + const lowerName = name.toLowerCase(); + + // Exact match (case-insensitive) + const exact = this.items.find( + (item) => item.name.toLowerCase() === lowerName, + ); + if (exact) return exact; + + // Alias match via CategoryConfig + if (this.categoryConfig) { + // Find the canonical name for this alias + for (const category of this.categoryConfig.categories) { + for (const catIngredient of category.ingredients) { + if ( + catIngredient.aliases.some( + (alias) => alias.toLowerCase() === lowerName, + ) + ) { + // Found the category ingredient — now look for any of its aliases in the pantry + const canonicalName = catIngredient.name.toLowerCase(); + const byCanonical = this.items.find( + (item) => item.name.toLowerCase() === canonicalName, + ); + if (byCanonical) return byCanonical; + + // Also try all other aliases + for (const alias of catIngredient.aliases) { + const byAlias = this.items.find( + (item) => item.name.toLowerCase() === alias.toLowerCase(), + ); + if (byAlias) return byAlias; + } + } + } + } + } + + return undefined; + } + + /** + * Gets the numeric value of a pantry item's quantity, optionally converted to base units. + * Returns undefined if the quantity has a text value or is not set. + */ + private getItemNumericValue( + quantity: PantryItem["quantity"], + unit?: string, + ): number | undefined { + // v8 ignore if -- @preserve: defensive type guard + if (!quantity) return undefined; + + let numericValue: number; + if (quantity.type === "fixed") { + if (quantity.value.type === "text") return undefined; + numericValue = getNumericValue(quantity.value); + } else { + // Range: use the average (min + max) / 2 + numericValue = + (getNumericValue(quantity.min) + getNumericValue(quantity.max)) / 2; + } + + // Convert to base if unit is known + if (unit) { + const unitDef = normalizeUnit(unit); + if (unitDef) { + const toBase = getToBase(unitDef); + numericValue *= toBase; + } + } + + return numericValue; + } + + /** + * Returns all items that are depleted (quantity = 0) or below their low threshold. + * @returns An array of depleted pantry items. + */ + getDepletedItems(): PantryItem[] { + return this.items.filter((item) => this.isItemLow(item)); + } + + /** + * Returns all items whose expiration date is within `nbDays` days from today + * (or already passed). + * @param nbDays - Number of days ahead to check. Defaults to 0 (already expired). + * @returns An array of expired pantry items. + */ + getExpiredItems(nbDays: number = 0): PantryItem[] { + return this.items.filter((item) => this.isItemExpired(item, nbDays)); + } + + /** + * Checks if a specific item is low (quantity = 0 or below `low` threshold). + * @param itemName - The name of the item to check (supports aliases if CategoryConfig is set). + * @returns true if the item is low, false otherwise. Returns false if item not found. + */ + isLow(itemName: string): boolean { + const item = this.findItem(itemName); + if (!item) return false; + return this.isItemLow(item); + } + + /** + * Checks if a specific item is expired or expires within `nbDays` days. + * @param itemName - The name of the item to check (supports aliases if CategoryConfig is set). + * @param nbDays - Number of days ahead to check. Defaults to 0. + * @returns true if the item is expired, false otherwise. Returns false if item not found. + */ + isExpired(itemName: string, nbDays: number = 0): boolean { + const item = this.findItem(itemName); + if (!item) return false; + return this.isItemExpired(item, nbDays); + } + + /** + * Internal: checks if a pantry item is low. + */ + private isItemLow(item: PantryItem): boolean { + if (!item.quantity) return false; + + const qtyValue = this.getItemNumericValue(item.quantity, item.unit); + if (qtyValue === undefined) return false; + + // Zero quantity + if (qtyValue === 0) return true; + + // Below low threshold + if (item.low) { + const lowValue = this.getItemNumericValue(item.low, item.lowUnit); + if (lowValue !== undefined && qtyValue <= lowValue) return true; + } + + return false; + } + + /** + * Internal: checks if a pantry item is expired. + */ + private isItemExpired(item: PantryItem, nbDays: number): boolean { + if (!item.expire) return false; + + const now = new Date(); + const cutoff = new Date( + now.getFullYear(), + now.getMonth(), + now.getDate() + nbDays, + ); + const expireDay = new Date( + item.expire.getFullYear(), + item.expire.getMonth(), + item.expire.getDate(), + ); + + return expireDay <= cutoff; + } +} diff --git a/src/classes/shopping_list.ts b/src/classes/shopping_list.ts index fafb112..37e55d7 100644 --- a/src/classes/shopping_list.ts +++ b/src/classes/shopping_list.ts @@ -1,4 +1,5 @@ import { CategoryConfig } from "./category_config"; +import { Pantry } from "./pantry"; import { Recipe } from "./recipe"; import type { CategorizedIngredients, @@ -9,10 +10,17 @@ import type { MaybeNestedGroup, FlatOrGroup, AddedRecipeOptions, + PantryOptions, } from "../types"; import { addEquivalentsAndSimplify } from "../quantities/alternatives"; -import { extendAllUnits } from "../quantities/mutations"; -import { isAndGroup } from "../utils/type_guards"; +import { + extendAllUnits, + subtractQuantities, + toExtendedUnit, + toPlainUnit, +} from "../quantities/mutations"; +import { isAndGroup, isOrGroup, isQuantity } from "../utils/type_guards"; +import { deepClone } from "../utils/general"; /** * Shopping List generator. @@ -58,6 +66,15 @@ export class ShoppingList { * The categorized ingredients in the shopping list. */ categories?: CategorizedIngredients; + /** + * The original pantry (never mutated by recipe calculations). + */ + pantry?: Pantry; + /** + * The pantry with quantities updated after subtracting recipe needs. + * Recomputed on every {@link ShoppingList.calculateIngredients | calculateIngredients()} call. + */ + private resultingPantry?: Pantry; /** * Creates a new ShoppingList instance @@ -190,6 +207,124 @@ export class ShoppingList { } } } + + // Subtract pantry quantities from ingredients + this.applyPantrySubtraction(); + } + + /** + * Subtracts pantry item quantities from calculated ingredient quantities + * and updates the resultingPantry to reflect consumed stock. + */ + private applyPantrySubtraction() { + if (!this.pantry) { + this.resultingPantry = undefined; + return; + } + + // Deep clone the original pantry for the resulting pantry + const clonedPantry = new Pantry(); + clonedPantry.items = deepClone(this.pantry.items); + if (this.categoryConfig) { + clonedPantry.setCategoryConfig(this.categoryConfig); + } + + for (const ingredient of this.ingredients) { + if (!ingredient.quantityTotal) continue; + + const pantryItem = clonedPantry.findItem(ingredient.name); + if (!pantryItem || !pantryItem.quantity) continue; + + // Extract leaf quantities from the ingredient (handles simple, AND, OR) + const leaves = this.extractLeafQuantities(ingredient.quantityTotal); + + let pantryExtended: QuantityWithExtendedUnit = { + quantity: pantryItem.quantity, + ...(pantryItem.unit && { unit: { name: pantryItem.unit } }), + }; + + for (const leaf of leaves) { + const ingredientExtended = toExtendedUnit(leaf.quantity); + + try { + // Subtract pantry from ingredient need (clamped to zero) + const remaining = subtractQuantities( + ingredientExtended, + pantryExtended, + { clampToZero: true }, + ); + + // Apply the updated quantity back into the group structure + leaf.apply(toPlainUnit(remaining) as QuantityWithPlainUnit); + + // Update the pantry remainder: subtract what was consumed + const consumed = subtractQuantities( + pantryExtended, + ingredientExtended, + { clampToZero: true }, + ); + pantryExtended = consumed; + } catch { + // Incompatible units — skip subtraction for this leaf + } + } + + pantryItem.quantity = pantryExtended.quantity; + // v8 ignore else -- @preserve + if (pantryExtended.unit) { + pantryItem.unit = pantryExtended.unit.name; + } + } + + this.resultingPantry = clonedPantry; + } + + /** + * Extracts leaf (simple) quantities from a possibly nested group structure. + * Each leaf includes the quantity and an `apply` callback to write back + * a modified value into the original structure in-place. + * + * - Simple quantity → one leaf + * - OR group → recurse into the first entry only (the primary alternative) + * - AND group → recurse into all entries + */ + private extractLeafQuantities( + q: QuantityWithPlainUnit | MaybeNestedGroup, + ): { + quantity: QuantityWithPlainUnit; + apply: (v: QuantityWithPlainUnit) => void; + }[] { + if (isQuantity(q)) { + return [ + { + quantity: q, + apply: (v: QuantityWithPlainUnit) => { + Object.assign(q, v); + }, + }, + ]; + } + + if (isOrGroup(q)) { + // Only subtract from the primary (first) entry + const first = q.or[0]; + /* v8 ignore else -- @preserve */ + if (first) { + return this.extractLeafQuantities(first); + } + /* v8 ignore next -- @preserve */ + return []; + } + + // AND group: recurse into all entries + const results: { + quantity: QuantityWithPlainUnit; + apply: (v: QuantityWithPlainUnit) => void; + }[] = []; + for (const entry of q.and) { + results.push(...this.extractLeafQuantities(entry)); + } + return results; } /** @@ -293,9 +428,43 @@ export class ShoppingList { this.categorize(); } + /** + * Adds a pantry to the shopping list. On-hand pantry quantities will be + * subtracted from recipe ingredient needs on each recalculation. + * @param pantry - A Pantry instance or a TOML string to parse. + * @param options - Options for pantry parsing (only used when providing a TOML string). + */ + addPantry(pantry: Pantry | string, options?: PantryOptions): void { + if (typeof pantry === "string") { + this.pantry = new Pantry(pantry, options); + } else if (pantry instanceof Pantry) { + this.pantry = pantry; + } else { + throw new Error( + "Invalid pantry: expected a Pantry instance or TOML string", + ); + } + if (this.categoryConfig) { + this.pantry.setCategoryConfig(this.categoryConfig); + } + this.calculateIngredients(); + this.categorize(); + } + + /** + * Returns the resulting pantry with quantities updated to reflect + * what was consumed by the shopping list's recipes. + * Returns undefined if no pantry was added. + * @returns The resulting Pantry, or undefined. + */ + getPantry(): Pantry | undefined { + return this.resultingPantry; + } + /** * Sets the category configuration for the shopping list * and automatically categorize current ingredients from the list. + * Also propagates the configuration to the pantry if one is set. * @param config - The category configuration to parse. */ setCategoryConfig(config: string | CategoryConfig) { @@ -303,6 +472,9 @@ export class ShoppingList { this.categoryConfig = new CategoryConfig(config); else if (config instanceof CategoryConfig) this.categoryConfig = config; else throw new Error("Invalid category configuration"); + if (this.pantry) { + this.pantry.setCategoryConfig(this.categoryConfig); + } this.categorize(); } diff --git a/src/index.ts b/src/index.ts index b4ebba1..5ef6c0c 100644 --- a/src/index.ts +++ b/src/index.ts @@ -1,6 +1,7 @@ // Classes import { CategoryConfig } from "./classes/category_config"; +import { Pantry } from "./classes/pantry"; import { ProductCatalog } from "./classes/product_catalog"; import { Recipe } from "./classes/recipe"; import { ShoppingList } from "./classes/shopping_list"; @@ -13,6 +14,7 @@ import { Section } from "./classes/section"; export { CategoryConfig, + Pantry, ProductCatalog, Recipe, ShoppingList, @@ -100,6 +102,9 @@ import type { RecipeWithServings, CategoryIngredient, Category, + PantryItem, + PantryItemToml, + PantryOptions, ProductOptionBase, ProductOptionCore, ProductOption, @@ -184,6 +189,9 @@ export { RecipeWithServings, CategoryIngredient, Category, + PantryItem, + PantryItemToml, + PantryOptions, ProductOptionBase, ProductOptionCore, ProductOption, diff --git a/src/quantities/mutations.ts b/src/quantities/mutations.ts index 34ec520..c28411c 100644 --- a/src/quantities/mutations.ts +++ b/src/quantities/mutations.ts @@ -20,6 +20,7 @@ import { getNumericValue, formatOutputValue, getAverageValue, + multiplyQuantityValue, } from "./numeric"; import { CannotAddTextValueError, IncompatibleUnitsError } from "../errors"; import { isAndGroup, isOrGroup, isQuantity } from "../utils/type_guards"; @@ -688,3 +689,40 @@ export function applyBestUnit( unit: { name: bestUnit.name }, }; } + +/** + * Subtracts one quantity from another (`q1 - q2`), returning the result in the most appropriate unit. + * Reuses {@link addQuantities} internally by negating `q2` first. + * + * @param q1 - The quantity to subtract from. + * @param q2 - The quantity to subtract. + * @param options - Optional configuration. + * @returns The difference of the two quantities. + */ +export function subtractQuantities( + q1: QuantityWithExtendedUnit, + q2: QuantityWithExtendedUnit, + options: { clampToZero?: boolean; system?: SpecificUnitSystem } = {}, +): QuantityWithExtendedUnit { + const { clampToZero = true, system } = options; + + // Negate q2's quantity inline + const negatedQ2: QuantityWithExtendedUnit = { + ...q2, + quantity: multiplyQuantityValue(q2.quantity, -1), + }; + + const result = addQuantities(q1, negatedQ2, system); + + if (clampToZero) { + const avg = getAverageValue(result.quantity); + if (typeof avg === "number" && avg < 0) { + return { + quantity: { type: "fixed", value: { type: "decimal", decimal: 0 } }, + unit: result.unit, + }; + } + } + + return result; +} diff --git a/src/types.ts b/src/types.ts index 070234f..804e493 100644 --- a/src/types.ts +++ b/src/types.ts @@ -744,6 +744,53 @@ export interface ProductOptionToml { [key: string]: any; } +/** + * Represents a pantry item entry in the TOML file. + * Can be a simple quantity string (e.g. `"500%g"`) or an object with details. + * @category Types + */ +export type PantryItemToml = + | string + | { + quantity?: string; + bought?: string; + expire?: string; + low?: string; + }; + +/** + * Represents a parsed pantry item. + * @category Types + */ +export interface PantryItem { + /** The name of the item. */ + name: string; + /** The storage location (TOML section name, e.g. "freezer", "fridge"). */ + location: string; + /** The quantity value of the item. */ + quantity?: FixedValue | Range; + /** The unit of the item's quantity. */ + unit?: string; + /** The date when the item was purchased. */ + bought?: Date; + /** The expiration date of the item. */ + expire?: Date; + /** The low stock threshold value. */ + low?: FixedValue | Range; + /** The unit of the low stock threshold. */ + lowUnit?: string; +} + +/** + * Options for configuring a {@link Pantry}. + * @category Types + */ +export interface PantryOptions { + /** Date format pattern for parsing date strings (e.g. `"DD.MM.YYYY"`, `"MM/DD/YYYY"`, `"YYYY-MM-DD"`). + * If not provided, dates are parsed with fuzzy detection defaulting to day-first when ambiguous. */ + dateFormat?: string; +} + /** * Represents a product selection in a {@link ShoppingCart} * @category Types diff --git a/src/utils/parser_helpers.ts b/src/utils/parser_helpers.ts index e681e4c..42af8e7 100644 --- a/src/utils/parser_helpers.ts +++ b/src/utils/parser_helpers.ts @@ -268,6 +268,173 @@ export function parseQuantityInput(input_str: string): FixedValue | Range { return { type: "fixed", value: parseFixedValue(clean_str) }; } +/** + * Parses a quantity string with unit separated by `%` (e.g. `"500%g"`). + * If no `%` is present, the entire string is treated as a value with no unit. + * @param input - The quantity string to parse. + * @returns An object with parsed `value` and optional `unit`. + */ +export function parseQuantityWithUnit(input: string): { + value: FixedValue | Range; + unit?: string; +} { + const trimmed = input.trim(); + const separatorIndex = trimmed.indexOf("%"); + if (separatorIndex === -1) { + return { value: parseQuantityInput(trimmed) }; + } + const valuePart = trimmed.slice(0, separatorIndex).trim(); + const unitPart = trimmed.slice(separatorIndex + 1).trim(); + return { + value: parseQuantityInput(valuePart), + unit: unitPart || undefined, + }; +} + +/** + * Parses a date string using a specific format pattern. + * The format must contain `DD`, `MM`, and `YYYY` separated by a single delimiter + * character (e.g. `.`, `/`, `-`). + * + * @param input - The date string to parse (e.g. `"05.06.2025"`). + * @param format - The format pattern (e.g. `"DD.MM.YYYY"`). + * @returns A Date object. + * @throws Error if the input doesn't match the format or produces an invalid date. + */ +export function parseDateFromFormat(input: string, format: string): Date { + // Extract delimiter from format (first non-letter character) + const delimiterMatch = format.match(/[^A-Za-z]/); + if (!delimiterMatch) { + throw new Error(`Invalid date format: ${format}. No delimiter found.`); + } + const delimiter = delimiterMatch[0]; + + const formatParts = format.split(delimiter); + const inputParts = input.trim().split(delimiter); + + if (formatParts.length !== 3 || inputParts.length !== 3) { + throw new Error( + `Invalid date input "${input}" for format "${format}". Expected 3 parts.`, + ); + } + + let day = 0, + month = 0, + year = 0; + + for (let i = 0; i < 3; i++) { + const token = formatParts[i]!.toUpperCase(); + const value = parseInt(inputParts[i]!, 10); + if (isNaN(value)) { + throw new Error( + `Invalid date input "${input}": non-numeric part "${inputParts[i]}".`, + ); + } + if (token === "DD") day = value; + else if (token === "MM") month = value; + else if (token === "YYYY") year = value; + else + throw new Error( + `Unknown token "${formatParts[i]}" in format "${format}"`, + ); + } + + const date = new Date(year, month - 1, day); + if ( + date.getFullYear() !== year || + date.getMonth() !== month - 1 || + date.getDate() !== day + ) { + throw new Error(`Invalid date: "${input}" does not form a valid date.`); + } + return date; +} + +/** + * Disambiguates day and month from two numeric parts. + * Defaults to day-first (DD.MM), but if the second part \> 12 + * it must be the day, so we swap to interpret as month-first (MM.DD). + */ +function disambiguateDayMonth( + first: number, + second: number, + year: number, +): [day: number, month: number, year: number] { + // If the second part > 12, it must be the day → input was month-first + if (second > 12 && first <= 12) { + return [second, first, year]; + } + // Otherwise default to day-first + return [first, second, year]; +} + +/** + * Parses a date string with fuzzy format detection. + * + * Supports delimiters `.`, `/`, `-`. + * - If the first part is a 4-digit year → `YYYY.MM.DD` + * - Otherwise defaults to `DD.MM.YYYY` (day first) + * - If the first part \> 12 it must be the day, confirming day-first + * - If the second part \> 12 it must be the day, meaning month-first input, + * but we still default to day-first so this produces an error (invalid month) + * unless the value is unambiguous + * + * @param input - The date string to parse. + * @returns A Date object. + * @throws Error if the input cannot be parsed as a valid date. + */ +export function parseFuzzyDate(input: string): Date { + const trimmed = input.trim(); + + // Detect delimiter + const delimiterMatch = trimmed.match(/[./-]/); + if (!delimiterMatch) { + throw new Error(`Cannot parse date "${input}": no delimiter found.`); + } + const delimiter = delimiterMatch[0]; + const parts = trimmed.split(delimiter); + if (parts.length !== 3) { + throw new Error( + `Cannot parse date "${input}": expected 3 parts, got ${parts.length}.`, + ); + } + + const nums = parts.map((p) => parseInt(p, 10)); + if (nums.some((n) => isNaN(n))) { + throw new Error(`Cannot parse date "${input}": non-numeric parts found.`); + } + + let day: number, month: number, year: number; + + // If first part is a 4-digit year (>= 1000), assume YYYY.MM.DD + if (nums[0]! >= 1000) { + year = nums[0]!; + month = nums[1]!; + day = nums[2]!; + } + // If last part is a 4-digit year, default to DD.MM.YYYY + else if (nums[2]! >= 1000) { + [day, month, year] = disambiguateDayMonth(nums[0]!, nums[1]!, nums[2]!); + } + // All short numbers — assume DD.MM.YY with 2-digit year + else { + if (nums[2]! >= 100) + throw new Error(`Invalid date: "${input}" does not form a valid date.`); + [day, month] = disambiguateDayMonth(nums[0]!, nums[1]!, 0); + year = 2000 + nums[2]!; + } + + const date = new Date(year, month - 1, day); + if ( + date.getFullYear() !== year || + date.getMonth() !== month - 1 || + date.getDate() !== day + ) { + throw new Error(`Invalid date: "${input}" does not form a valid date.`); + } + return date; +} + /** * Parses markdown formatting in a text string and returns an array of TextItems. * diff --git a/test/pantry.test.ts b/test/pantry.test.ts new file mode 100644 index 0000000..47d3f40 --- /dev/null +++ b/test/pantry.test.ts @@ -0,0 +1,404 @@ +import { describe, it, expect } from "vitest"; +import { Pantry } from "../src/classes/pantry"; +import { CategoryConfig } from "../src/classes/category_config"; +import type { PantryItem, FixedValue } from "../src/types"; + +const simplePantryToml = ` +[freezer] +cranberries = "500%g" +spinach = { bought = "05.05.2024", expire = "05.06.2025", quantity = "1%kg" } + +[fridge] +milk = { expire = "10.05.2024", quantity = "1%L" } +cheese = { expire = "15.05.2024" } + +[pantry] +rice = "5%kg" +pasta = { quantity = "1%kg", low = "200%g" } +flour = { quantity = "0%g" } +`; + +const lowStockPantryToml = ` +[pantry] +rice = { quantity = "100%g", low = "200%g" } +pasta = { quantity = "1%kg", low = "200%g" } +flour = { quantity = "0%g" } +sugar = { quantity = "500%g" } +`; + +describe("Pantry", () => { + describe("Parsing", () => { + it("should parse a simple pantry TOML", () => { + const pantry = new Pantry(simplePantryToml); + expect(pantry.items).toHaveLength(7); + }); + + it("should parse item locations correctly", () => { + const pantry = new Pantry(simplePantryToml); + expect(pantry.items[0]).toMatchObject>({ + name: "cranberries", + location: "freezer", + }); + expect(pantry.items[2]).toMatchObject>({ + name: "milk", + location: "fridge", + }); + expect(pantry.items[4]).toMatchObject>({ + name: "rice", + location: "pantry", + }); + }); + + it("should parse simple string quantities", () => { + const pantry = new Pantry(simplePantryToml); + const cranberries = pantry.items.find((i) => i.name === "cranberries")!; + expect(cranberries.quantity).toMatchObject({ + type: "fixed", + value: { type: "decimal", decimal: 500 }, + }); + expect(cranberries.unit).toBe("g"); + }); + + it("should parse object entries with quantity and dates", () => { + const pantry = new Pantry(simplePantryToml); + const spinach = pantry.items.find((i) => i.name === "spinach")!; + expect(spinach.quantity).toMatchObject({ + type: "fixed", + value: { type: "decimal", decimal: 1 }, + }); + expect(spinach.unit).toBe("kg"); + expect(spinach.bought).toEqual(new Date(2024, 4, 5)); + expect(spinach.expire).toEqual(new Date(2025, 5, 5)); + }); + + it("should parse object entries with low threshold", () => { + const pantry = new Pantry(simplePantryToml); + const pasta = pantry.items.find((i) => i.name === "pasta")!; + expect(pasta.low).toMatchObject({ + type: "fixed", + value: { type: "decimal", decimal: 200 }, + }); + expect(pasta.lowUnit).toBe("g"); + }); + + it("should parse object entries with only expire date", () => { + const pantry = new Pantry(simplePantryToml); + const cheese = pantry.items.find((i) => i.name === "cheese")!; + expect(cheese.expire).toEqual(new Date(2024, 4, 15)); + expect(cheese.quantity).toBeUndefined(); + }); + + it("should handle quantities without units", () => { + const pantry = new Pantry(`[pantry]\neggs = "6"`); + const eggs = pantry.items.find((i) => i.name === "eggs")!; + expect(eggs.quantity).toMatchObject({ + type: "fixed", + value: { type: "decimal", decimal: 6 }, + }); + expect(eggs.unit).toBeUndefined(); + }); + + it("should parse via constructor", () => { + const pantry = new Pantry(simplePantryToml); + expect(pantry.items.length).toBeGreaterThan(0); + }); + + it("should allow creating empty pantry", () => { + const pantry = new Pantry(); + expect(pantry.items).toEqual([]); + }); + + it("should reset items on re-parse", () => { + const pantry = new Pantry(simplePantryToml); + expect(pantry.items).toHaveLength(7); + pantry.parse(`[pantry]\nrice = "1%kg"`); + expect(pantry.items).toHaveLength(1); + }); + }); + + describe("Date parsing", () => { + it("should parse DD.MM.YYYY (default fuzzy)", () => { + const pantry = new Pantry(`[fridge]\nmilk = { expire = "15.06.2025" }`); + expect(pantry.items[0]!.expire).toEqual(new Date(2025, 5, 15)); + }); + + it("should parse DD/MM/YYYY (default fuzzy)", () => { + const pantry = new Pantry(`[fridge]\nmilk = { expire = "15/06/2025" }`); + expect(pantry.items[0]!.expire).toEqual(new Date(2025, 5, 15)); + }); + + it("should parse YYYY-MM-DD (default fuzzy)", () => { + const pantry = new Pantry(`[fridge]\nmilk = { expire = "2025-06-15" }`); + expect(pantry.items[0]!.expire).toEqual(new Date(2025, 5, 15)); + }); + + it("should parse DD-MM-YYYY (default fuzzy)", () => { + const pantry = new Pantry(`[fridge]\nmilk = { expire = "15-06-2025" }`); + expect(pantry.items[0]!.expire).toEqual(new Date(2025, 5, 15)); + }); + + it("should parse 2-digit year as 20xx (default fuzzy)", () => { + const pantry = new Pantry(`[fridge]\nmilk = { expire = "15.06.25" }`); + expect(pantry.items[0]!.expire).toEqual(new Date(2025, 5, 15)); + }); + + it("should use explicit date format MM/DD/YYYY", () => { + const pantry = new Pantry(`[fridge]\nmilk = { expire = "06/15/2025" }`, { + dateFormat: "MM/DD/YYYY", + }); + expect(pantry.items[0]!.expire).toEqual(new Date(2025, 5, 15)); + }); + + it("should use explicit date format YYYY-MM-DD", () => { + const pantry = new Pantry(`[fridge]\nmilk = { expire = "2025-06-15" }`, { + dateFormat: "YYYY-MM-DD", + }); + expect(pantry.items[0]!.expire).toEqual(new Date(2025, 5, 15)); + }); + + it("should throw on invalid date input with explicit format", () => { + expect( + () => + new Pantry(`[fridge]\nmilk = { expire = "2025-13-01" }`, { + dateFormat: "YYYY-MM-DD", + }), + ).toThrow(/Invalid date/); + }); + + it("should throw on non-numeric date parts", () => { + expect( + () => new Pantry(`[fridge]\nmilk = { expire = "abc.06.2025" }`), + ).toThrow(/non-numeric/); + }); + }); + + describe("getDepletedItems", () => { + it("should return items with zero quantity", () => { + const pantry = new Pantry(lowStockPantryToml); + const depleted = pantry.getDepletedItems(); + const names = depleted.map((i) => i.name); + expect(names).toContain("flour"); + }); + + it("should return items below low threshold", () => { + const pantry = new Pantry(lowStockPantryToml); + const depleted = pantry.getDepletedItems(); + const names = depleted.map((i) => i.name); + expect(names).toContain("rice"); + }); + + it("should not return items above low threshold", () => { + const pantry = new Pantry(lowStockPantryToml); + const depleted = pantry.getDepletedItems(); + const names = depleted.map((i) => i.name); + expect(names).not.toContain("pasta"); + expect(names).not.toContain("sugar"); + }); + }); + + describe("getExpiredItems", () => { + it("should return items already past expiry date", () => { + const pantry = new Pantry(simplePantryToml); + // All expire dates in simplePantryToml are in 2024/2025, which are past "today" (Feb 2026) + const expired = pantry.getExpiredItems(); + const names = expired.map((i) => i.name); + expect(names).toContain("milk"); + expect(names).toContain("cheese"); + expect(names).toContain("spinach"); + }); + + it("should not return items without an expire date", () => { + const pantry = new Pantry(simplePantryToml); + const expired = pantry.getExpiredItems(); + const names = expired.map((i) => i.name); + expect(names).not.toContain("cranberries"); + expect(names).not.toContain("rice"); + }); + + it("should return items expiring within nbDays", () => { + // Use a date far in the future so it's not expired at nbDays=0 + const futureDate = new Date(); + futureDate.setDate(futureDate.getDate() + 5); + const dd = String(futureDate.getDate()).padStart(2, "0"); + const mm = String(futureDate.getMonth() + 1).padStart(2, "0"); + const yyyy = futureDate.getFullYear(); + const pantry = new Pantry( + `[fridge]\nmilk = { expire = "${dd}.${mm}.${yyyy}" }`, + ); + + expect(pantry.getExpiredItems(0)).toHaveLength(0); + expect(pantry.getExpiredItems(5)).toHaveLength(1); + expect(pantry.getExpiredItems(10)).toHaveLength(1); + }); + }); + + describe("isLow", () => { + it("should return true for items at zero quantity", () => { + const pantry = new Pantry(lowStockPantryToml); + expect(pantry.isLow("flour")).toBe(true); + }); + + it("should return true for items below low threshold", () => { + const pantry = new Pantry(lowStockPantryToml); + expect(pantry.isLow("rice")).toBe(true); + }); + + it("should return false for items above low threshold", () => { + const pantry = new Pantry(lowStockPantryToml); + expect(pantry.isLow("pasta")).toBe(false); + }); + + it("should return false for items without low threshold", () => { + const pantry = new Pantry(lowStockPantryToml); + expect(pantry.isLow("sugar")).toBe(false); + }); + + it("should return false for unknown items", () => { + const pantry = new Pantry(lowStockPantryToml); + expect(pantry.isLow("nonexistent")).toBe(false); + }); + }); + + describe("isExpired", () => { + it("should return true for items past expiry", () => { + const pantry = new Pantry(simplePantryToml); + expect(pantry.isExpired("milk")).toBe(true); + }); + + it("should return false for items without expiry", () => { + const pantry = new Pantry(simplePantryToml); + expect(pantry.isExpired("cranberries")).toBe(false); + }); + + it("should return false for unknown items", () => { + const pantry = new Pantry(simplePantryToml); + expect(pantry.isExpired("nonexistent")).toBe(false); + }); + }); + + describe("findItem with CategoryConfig aliases", () => { + it("should find item by exact name", () => { + const pantry = new Pantry(simplePantryToml); + expect(pantry.findItem("rice")).toBeDefined(); + expect(pantry.findItem("rice")!.name).toBe("rice"); + }); + + it("should find item by exact name case-insensitively", () => { + const pantry = new Pantry(simplePantryToml); + expect(pantry.findItem("Rice")).toBeDefined(); + expect(pantry.findItem("Rice")!.name).toBe("rice"); + }); + + it("should return undefined for unknown item without config", () => { + const pantry = new Pantry(simplePantryToml); + expect(pantry.findItem("riz")).toBeUndefined(); + }); + + it("should find item by alias when CategoryConfig is set", () => { + const pantry = new Pantry(simplePantryToml); + const config = new CategoryConfig(`[Grains]\nrice|riz|arroz`); + pantry.setCategoryConfig(config); + + expect(pantry.findItem("riz")).toBeDefined(); + expect(pantry.findItem("riz")!.name).toBe("rice"); + expect(pantry.findItem("arroz")).toBeDefined(); + expect(pantry.findItem("arroz")!.name).toBe("rice"); + }); + + it("should find item by alias case-insensitively", () => { + const pantry = new Pantry(simplePantryToml); + const config = new CategoryConfig(`[Grains]\nrice|riz`); + pantry.setCategoryConfig(config); + + expect(pantry.findItem("RIZ")).toBeDefined(); + expect(pantry.findItem("RIZ")!.name).toBe("rice"); + }); + + it("should use alias lookup for isLow", () => { + const pantry = new Pantry(lowStockPantryToml); + const config = new CategoryConfig(`[Grains]\nrice|riz`); + pantry.setCategoryConfig(config); + + expect(pantry.isLow("riz")).toBe(true); + }); + + it("should use alias lookup for isExpired", () => { + const pantry = new Pantry(simplePantryToml); + const config = new CategoryConfig(`[Dairy]\nmilk|lait`); + pantry.setCategoryConfig(config); + + expect(pantry.isExpired("lait")).toBe(true); + }); + + it("should return undefined when alias matches but no pantry item uses any of the aliases", () => { + const pantry = new Pantry(`[pantry]\nsugar = "500%g"`); + const config = new CategoryConfig(`[Grains]\nrice|riz`); + pantry.setCategoryConfig(config); + + // "riz" is an alias for "rice" but neither "rice" nor "riz" is in pantry + expect(pantry.findItem("riz")).toBeUndefined(); + }); + }); + + describe("Edge cases", () => { + it("should handle text quantities (not numeric)", () => { + const pantry = new Pantry(`[pantry]\nsalt = "a pinch"`); + const salt = pantry.items.find((i) => i.name === "salt")!; + expect(salt.quantity).toBeDefined(); + // Text quantities should not be considered low + expect(pantry.isLow("salt")).toBe(false); + }); + + it("should handle range quantities", () => { + const pantry = new Pantry( + `[pantry]\nrice = { quantity = "1-2%kg", low = "500%g" }`, + ); + const rice = pantry.items.find((i) => i.name === "rice")!; + expect(rice.quantity).toBeDefined(); + expect(rice.quantity!.type).toBe("range"); + // range average is 1.5kg = 1500g, low is 500g → not low + expect(pantry.isLow("rice")).toBe(false); + }); + + it("should detect low with unit conversion (kg vs g threshold)", () => { + const pantry = new Pantry( + `[pantry]\nrice = { quantity = "100%g", low = "1%kg" }`, + ); + // 100g < 1kg (= 1000g) → should be low + expect(pantry.isLow("rice")).toBe(true); + }); + + it("should not consider items without quantity as low", () => { + const pantry = new Pantry(`[pantry]\ncheese = { expire = "01.01.2030" }`); + expect(pantry.isLow("cheese")).toBe(false); + }); + + it("should handle items with quantity but no low threshold as not low", () => { + const pantry = new Pantry(`[pantry]\nsugar = "500%g"`); + expect(pantry.isLow("sugar")).toBe(false); + }); + + it("should handle low threshold without unit (unitless comparison)", () => { + const pantry = new Pantry( + `[pantry]\neggs = { quantity = "2", low = "6" }`, + ); + // 2 < 6 → should be low + expect(pantry.isLow("eggs")).toBe(true); + }); + + it("should handle low threshold with unknown unit (no base conversion)", () => { + const pantry = new Pantry( + `[pantry]\nwidgets = { quantity = "2%widget", low = "5%widget" }`, + ); + // 2 < 5 → low, "widget" is unknown so no conversion is applied + expect(pantry.isLow("widgets")).toBe(true); + }); + + it("should handle low threshold with low quantity having no unit", () => { + const pantry = new Pantry( + `[pantry]\nstuff = { quantity = "0", low = "5" }`, + ); + expect(pantry.isLow("stuff")).toBe(true); + }); + }); +}); diff --git a/test/parser_helpers.test.ts b/test/parser_helpers.test.ts index b4c9ddf..1294452 100644 --- a/test/parser_helpers.test.ts +++ b/test/parser_helpers.test.ts @@ -25,6 +25,9 @@ import { findAndUpsertIngredient, stringifyQuantityValue, unionOfSets, + parseQuantityWithUnit, + parseDateFromFormat, + parseFuzzyDate, } from "../src/utils/parser_helpers"; import { NoTabAsIndentError, @@ -1436,3 +1439,150 @@ duration: 1 hour }); }); }); + +describe("parseQuantityWithUnit", () => { + it("should parse value and unit separated by %", () => { + const result = parseQuantityWithUnit("500%g"); + expect(result.value).toMatchObject({ + type: "fixed", + value: { type: "decimal", decimal: 500 }, + }); + expect(result.unit).toBe("g"); + }); + + it("should parse value without unit", () => { + const result = parseQuantityWithUnit("6"); + expect(result.value).toMatchObject({ + type: "fixed", + value: { type: "decimal", decimal: 6 }, + }); + expect(result.unit).toBeUndefined(); + }); + + it("should handle whitespace around value and unit", () => { + const result = parseQuantityWithUnit(" 500 % g "); + expect(result.value).toMatchObject({ + type: "fixed", + value: { type: "decimal", decimal: 500 }, + }); + expect(result.unit).toBe("g"); + }); + + it("should parse fractions with unit", () => { + const result = parseQuantityWithUnit("1/2%cup"); + expect(result.value).toMatchObject({ + type: "fixed", + value: { type: "fraction", num: 1, den: 2 }, + }); + expect(result.unit).toBe("cup"); + }); + + it("should return undefined unit when % is at the end", () => { + const result = parseQuantityWithUnit("500%"); + expect(result.unit).toBeUndefined(); + }); +}); + +describe("parseDateFromFormat", () => { + it("should parse DD.MM.YYYY", () => { + expect(parseDateFromFormat("15.06.2025", "DD.MM.YYYY")).toEqual( + new Date(2025, 5, 15), + ); + }); + + it("should parse MM/DD/YYYY", () => { + expect(parseDateFromFormat("06/15/2025", "MM/DD/YYYY")).toEqual( + new Date(2025, 5, 15), + ); + }); + + it("should parse YYYY-MM-DD", () => { + expect(parseDateFromFormat("2025-06-15", "YYYY-MM-DD")).toEqual( + new Date(2025, 5, 15), + ); + }); + + it("should throw on invalid format (no delimiter)", () => { + expect(() => parseDateFromFormat("15062025", "DDMMYYYY")).toThrow( + /No delimiter/, + ); + }); + + it("should throw on wrong number of parts", () => { + expect(() => parseDateFromFormat("15.06", "DD.MM.YYYY")).toThrow( + /Expected 3 parts/, + ); + }); + + it("should throw on non-numeric parts", () => { + expect(() => parseDateFromFormat("abc.06.2025", "DD.MM.YYYY")).toThrow( + /non-numeric/, + ); + }); + + it("should throw on invalid date (e.g. month 13)", () => { + expect(() => parseDateFromFormat("15.13.2025", "DD.MM.YYYY")).toThrow( + /Invalid date/, + ); + }); + + it("should throw on unknown token in format", () => { + expect(() => parseDateFromFormat("15.06.2025", "DD.XX.YYYY")).toThrow( + /Unknown token/, + ); + }); +}); + +describe("parseFuzzyDate", () => { + it("should parse DD.MM.YYYY", () => { + expect(parseFuzzyDate("15.06.2025")).toEqual(new Date(2025, 5, 15)); + }); + + it("should parse DD/MM/YYYY", () => { + expect(parseFuzzyDate("15/06/2025")).toEqual(new Date(2025, 5, 15)); + }); + + it("should parse YYYY-MM-DD", () => { + expect(parseFuzzyDate("2025-06-15")).toEqual(new Date(2025, 5, 15)); + }); + + it("should parse DD-MM-YYYY", () => { + expect(parseFuzzyDate("15-06-2025")).toEqual(new Date(2025, 5, 15)); + }); + + it("should parse 2-digit year as 20xx", () => { + expect(parseFuzzyDate("15.06.25")).toEqual(new Date(2025, 5, 15)); + }); + + it("should throw on input without delimiter", () => { + expect(() => parseFuzzyDate("15062025")).toThrow(/no delimiter/); + }); + + it("should throw on input with wrong number of parts", () => { + expect(() => parseFuzzyDate("15.06")).toThrow(/expected 3 parts/i); + }); + + it("should throw on non-numeric parts", () => { + expect(() => parseFuzzyDate("abc.06.2025")).toThrow(/non-numeric/); + }); + + it("should throw on invalid date", () => { + expect(() => parseFuzzyDate("31.13.2025")).toThrow(/Invalid date/); + expect(() => parseFuzzyDate("31.01.202")).toThrow(/Invalid date/); + }); + + it("should disambiguate month-first when second part > 12", () => { + // 01/25/2025 → second part (25) > 12, must be day → MM/DD/YYYY + expect(parseFuzzyDate("01/25/2025")).toEqual(new Date(2025, 0, 25)); + }); + + it("should disambiguate month-first with 2-digit year", () => { + // 01.25.25 → second part (25) > 12, must be day → MM.DD.YY + expect(parseFuzzyDate("01.25.25")).toEqual(new Date(2025, 0, 25)); + }); + + it("should keep day-first when first part > 12", () => { + // 25.01.2025 → first part (25) > 12, confirms day-first → DD.MM.YYYY + expect(parseFuzzyDate("25.01.2025")).toEqual(new Date(2025, 0, 25)); + }); +}); diff --git a/test/quantities_mutations.test.ts b/test/quantities_mutations.test.ts index 20971cc..5a61d05 100644 --- a/test/quantities_mutations.test.ts +++ b/test/quantities_mutations.test.ts @@ -4,6 +4,7 @@ import { extendAllUnits, addQuantityValues, addQuantities, + subtractQuantities, getDefaultQuantityValue, normalizeAllUnits, convertQuantityToSystem, @@ -1347,3 +1348,100 @@ describe("applyBestUnit", () => { }); }); }); + +describe("subtractQuantities", () => { + it("should subtract two quantities with the same unit", () => { + const q1: QuantityWithExtendedUnit = { + quantity: { type: "fixed", value: { type: "decimal", decimal: 500 } }, + unit: { name: "g" }, + }; + const q2: QuantityWithExtendedUnit = { + quantity: { type: "fixed", value: { type: "decimal", decimal: 200 } }, + unit: { name: "g" }, + }; + const result = subtractQuantities(q1, q2); + expect(result.quantity).toMatchObject({ + type: "fixed", + value: { type: "decimal", decimal: 300 }, + }); + }); + + it("should clamp to zero when result would be negative (default)", () => { + const q1: QuantityWithExtendedUnit = { + quantity: { type: "fixed", value: { type: "decimal", decimal: 100 } }, + unit: { name: "g" }, + }; + const q2: QuantityWithExtendedUnit = { + quantity: { type: "fixed", value: { type: "decimal", decimal: 500 } }, + unit: { name: "g" }, + }; + const result = subtractQuantities(q1, q2); + expect(result.quantity).toMatchObject({ + type: "fixed", + value: { type: "decimal", decimal: 0 }, + }); + }); + + it("should allow negative when clampToZero is false", () => { + const q1: QuantityWithExtendedUnit = { + quantity: { type: "fixed", value: { type: "decimal", decimal: 100 } }, + unit: { name: "g" }, + }; + const q2: QuantityWithExtendedUnit = { + quantity: { type: "fixed", value: { type: "decimal", decimal: 500 } }, + unit: { name: "g" }, + }; + const result = subtractQuantities(q1, q2, { clampToZero: false }); + // addQuantities may convert to a best unit; just check it's negative + expect(result.quantity.type).toBe("fixed"); + if ( + result.quantity.type === "fixed" && + result.quantity.value.type === "decimal" + ) { + expect(result.quantity.value.decimal).toBeLessThan(0); + } + }); + + it("should handle unit conversion (kg - g)", () => { + const q1: QuantityWithExtendedUnit = { + quantity: { type: "fixed", value: { type: "decimal", decimal: 1 } }, + unit: { name: "kg" }, + }; + const q2: QuantityWithExtendedUnit = { + quantity: { type: "fixed", value: { type: "decimal", decimal: 200 } }, + unit: { name: "g" }, + }; + const result = subtractQuantities(q1, q2); + expect(result.quantity).toMatchObject({ + type: "fixed", + value: { type: "decimal", decimal: 800 }, + }); + expect(result.unit?.name).toBe("g"); + }); + + it("should throw for incompatible units", () => { + const q1: QuantityWithExtendedUnit = { + quantity: { type: "fixed", value: { type: "decimal", decimal: 500 } }, + unit: { name: "g" }, + }; + const q2: QuantityWithExtendedUnit = { + quantity: { type: "fixed", value: { type: "decimal", decimal: 1 } }, + unit: { name: "L" }, + }; + expect(() => subtractQuantities(q1, q2)).toThrow(IncompatibleUnitsError); + }); + + it("should handle unitless quantities", () => { + const q1: QuantityWithExtendedUnit = { + quantity: { type: "fixed", value: { type: "decimal", decimal: 5 } }, + }; + const q2: QuantityWithExtendedUnit = { + quantity: { type: "fixed", value: { type: "decimal", decimal: 2 } }, + }; + const result = subtractQuantities(q1, q2); + expect(result.quantity).toMatchObject({ + type: "fixed", + value: { type: "decimal", decimal: 3 }, + }); + }); +}); diff --git a/test/shopping_list.test.ts b/test/shopping_list.test.ts index a027aa9..7342d74 100644 --- a/test/shopping_list.test.ts +++ b/test/shopping_list.test.ts @@ -1,6 +1,7 @@ import { describe, it, expect } from "vitest"; import { ShoppingList } from "../src/classes/shopping_list"; import { CategoryConfig } from "../src/classes/category_config"; +import { Pantry } from "../src/classes/pantry"; import type { CategorizedIngredients, Ingredient } from "../src/types"; import { Recipe } from "../src/classes/recipe"; import { @@ -741,9 +742,391 @@ sugar it("should throw an error when removing a recipe with an invalid index", () => { const shoppingList = new ShoppingList(); shoppingList.addRecipe(recipe1); - expect(() => shoppingList.removeRecipe(1)).toThrow( - "Index out of bounds", + expect(() => shoppingList.removeRecipe(1)).toThrow("Index out of bounds"); + }); + }); + + describe("Pantry integration", () => { + const pantryRecipe = new Recipe( + `Add @flour{200%g} and @sugar{100%g} and @eggs{3}.`, + ); + + it("should subtract pantry quantities from ingredients", () => { + const shoppingList = new ShoppingList(); + shoppingList.addRecipe(pantryRecipe); + shoppingList.addPantry(`[pantry]\nflour = "100%g"`); + + const flour = shoppingList.ingredients.find((i) => i.name === "flour"); + expect(flour).toBeDefined(); + expect(flour!.quantityTotal).toMatchObject({ + quantity: { + type: "fixed", + value: { type: "decimal", decimal: 100 }, + }, + unit: "g", + }); + }); + + it("should clamp ingredient quantity to zero when pantry has more", () => { + const shoppingList = new ShoppingList(); + shoppingList.addRecipe(pantryRecipe); + shoppingList.addPantry(`[pantry]\nflour = "500%g"`); + + const flour = shoppingList.ingredients.find((i) => i.name === "flour"); + expect(flour).toBeDefined(); + expect(flour!.quantityTotal).toMatchObject({ + quantity: { + type: "fixed", + value: { type: "decimal", decimal: 0 }, + }, + }); + }); + + it("should update resulting pantry after subtraction", () => { + const shoppingList = new ShoppingList(); + shoppingList.addRecipe(pantryRecipe); + shoppingList.addPantry(`[pantry]\nflour = "500%g"`); + + const resultingPantry = shoppingList.getPantry(); + expect(resultingPantry).toBeDefined(); + const flour = resultingPantry!.findItem("flour"); + expect(flour).toBeDefined(); + // Pantry had 500g, recipe needs 200g → 300g remaining + expect(flour!.quantity).toMatchObject({ + type: "fixed", + value: { type: "decimal", decimal: 300 }, + }); + }); + + it("should set resulting pantry item to zero when fully consumed", () => { + const shoppingList = new ShoppingList(); + shoppingList.addRecipe(pantryRecipe); + shoppingList.addPantry(`[pantry]\nflour = "100%g"`); + + const resultingPantry = shoppingList.getPantry(); + const flour = resultingPantry!.findItem("flour"); + expect(flour!.quantity).toMatchObject({ + type: "fixed", + value: { type: "decimal", decimal: 0 }, + }); + }); + + it("should not modify ingredients without pantry match", () => { + const shoppingList = new ShoppingList(); + shoppingList.addRecipe(pantryRecipe); + shoppingList.addPantry(`[pantry]\nflour = "100%g"`); + + const sugar = shoppingList.ingredients.find((i) => i.name === "sugar"); + expect(sugar!.quantityTotal).toMatchObject({ + quantity: { + type: "fixed", + value: { type: "decimal", decimal: 100 }, + }, + unit: "g", + }); + }); + + it("should accept a Pantry instance", () => { + const shoppingList = new ShoppingList(); + shoppingList.addRecipe(pantryRecipe); + const pantry = new Pantry(`[pantry]\nflour = "100%g"`); + shoppingList.addPantry(pantry); + + const flour = shoppingList.ingredients.find((i) => i.name === "flour"); + expect(flour!.quantityTotal).toMatchObject({ + quantity: { + type: "fixed", + value: { type: "decimal", decimal: 100 }, + }, + unit: "g", + }); + }); + + it("should throw on invalid pantry argument", () => { + const shoppingList = new ShoppingList(); + expect(() => shoppingList.addPantry(42 as unknown as string)).toThrow( + "Invalid pantry", + ); + }); + + it("should recalculate when adding/removing recipes with pantry", () => { + const shoppingList = new ShoppingList(); + shoppingList.addPantry(`[pantry]\nflour = "100%g"`); + shoppingList.addRecipe(pantryRecipe); + + const flour1 = shoppingList.ingredients.find((i) => i.name === "flour"); + expect(flour1!.quantityTotal).toMatchObject({ + quantity: { + type: "fixed", + value: { type: "decimal", decimal: 100 }, + }, + unit: "g", + }); + + // Remove the recipe → no ingredients, pantry should be intact + shoppingList.removeRecipe(0); + expect(shoppingList.ingredients).toEqual([]); + const resultingPantry = shoppingList.getPantry(); + const flourInPantry = resultingPantry!.findItem("flour"); + expect(flourInPantry!.quantity).toMatchObject({ + type: "fixed", + value: { type: "decimal", decimal: 100 }, + }); + }); + + it("should return undefined from getPantry when no pantry set", () => { + const shoppingList = new ShoppingList(); + expect(shoppingList.getPantry()).toBeUndefined(); + }); + + it("should use CategoryConfig aliases for pantry matching", () => { + const shoppingList = new ShoppingList(); + const config = new CategoryConfig(`[Baking]\nflour|farine`); + shoppingList.setCategoryConfig(config); + + // Recipe uses "flour", pantry uses "farine" + shoppingList.addRecipe(pantryRecipe); + shoppingList.addPantry(`[pantry]\nfarine = "100%g"`); + + // Should find "farine" in pantry for "flour" in recipe + const flour = shoppingList.ingredients.find((i) => i.name === "flour"); + expect(flour!.quantityTotal).toMatchObject({ + quantity: { + type: "fixed", + value: { type: "decimal", decimal: 100 }, + }, + unit: "g", + }); + }); + + it("should propagate CategoryConfig to pantry when set after addPantry", () => { + const shoppingList = new ShoppingList(); + shoppingList.addRecipe(pantryRecipe); + shoppingList.addPantry(`[pantry]\nfarine = "100%g"`); + + // Without config, "flour" won't match "farine" + const flourBefore = shoppingList.ingredients.find( + (i) => i.name === "flour", + ); + expect(flourBefore!.quantityTotal).toMatchObject({ + quantity: { + type: "fixed", + value: { type: "decimal", decimal: 200 }, + }, + unit: "g", + }); + + // Setting config should NOT auto-recalculate (only categorize runs) + // but the config IS propagated to the pantry for future use + const config = new CategoryConfig(`[Baking]\nflour|farine`); + shoppingList.setCategoryConfig(config); + + // Re-add recipe to trigger recalculation + shoppingList.removeRecipe(0); + shoppingList.addRecipe(pantryRecipe); + + const flourAfter = shoppingList.ingredients.find( + (i) => i.name === "flour", ); + expect(flourAfter!.quantityTotal).toMatchObject({ + quantity: { + type: "fixed", + value: { type: "decimal", decimal: 100 }, + }, + unit: "g", + }); + }); + + it("should handle pantry with unit conversion (e.g. kg vs g)", () => { + const shoppingList = new ShoppingList(); + shoppingList.addRecipe(pantryRecipe); // needs 200g flour + shoppingList.addPantry(`[pantry]\nflour = "1%kg"`); // has 1kg = 1000g + + const flour = shoppingList.ingredients.find((i) => i.name === "flour"); + // 200g - 1kg → clamped to 0 + expect(flour!.quantityTotal).toMatchObject({ + quantity: { + type: "fixed", + value: { type: "decimal", decimal: 0 }, + }, + }); + }); + + it("should skip pantry subtraction for ingredients without quantity", () => { + const recipeNoQty = new Recipe(`Add @flour and @sugar{100%g}.`); + const shoppingList = new ShoppingList(); + shoppingList.addRecipe(recipeNoQty); + shoppingList.addPantry(`[pantry]\nflour = "100%g"`); + + // flour has no quantity in recipe, should remain as-is + const flour = shoppingList.ingredients.find((i) => i.name === "flour"); + expect(flour).toBeDefined(); + expect(flour!.quantityTotal).toBeUndefined(); + }); + + it("should skip pantry subtraction for incompatible units", () => { + const shoppingList = new ShoppingList(); + shoppingList.addRecipe(pantryRecipe); // needs 200g flour + // pantry has flour in liters — incompatible with grams + shoppingList.addPantry(`[pantry]\nflour = "1%L"`); + + // Should remain unchanged due to incompatible units + const flour = shoppingList.ingredients.find((i) => i.name === "flour"); + expect(flour!.quantityTotal).toMatchObject({ + quantity: { + type: "fixed", + value: { type: "decimal", decimal: 200 }, + }, + unit: "g", + }); + }); + + it("should subtract pantry from AND group ingredient (multiple units)", () => { + // Recipe produces eggs as AND group: 1 dozen AND 1 half dozen + const recipe3 = new Recipe(`Add @eggs{1%dozen} and @&eggs{1%half dozen}`); + const shoppingList = new ShoppingList(); + shoppingList.addRecipe(recipe3); + + // Pantry has 2 dozen eggs — should subtract from first AND leaf (1 dozen) + // then carry the remaining 1 dozen to the second leaf (1 half dozen). + // 1 dozen = 12, 1 half dozen = 6. Pantry 2 dozen = 24. + // But "dozen" and "half dozen" are different unit strings, + // they may or may not be convertible. Let's check with a simple compatible case. + shoppingList.addPantry(`[pantry]\neggs = "5"`); + + const eggs = shoppingList.ingredients.find((i) => i.name === "eggs"); + expect(eggs).toBeDefined(); + // Unitless pantry (5) vs "dozen" unit — subtraction of unitless from + // unit-bearing quantity: first leaf (1 dozen) gets subtracted → clamped to 0. + // Remaining pantry (4) tries second leaf (1 half dozen) — if incompatible, stays. + expect(eggs!.quantityTotal).toMatchObject({ + and: [ + { + quantity: { + type: "fixed", + value: { type: "decimal", decimal: 0 }, + }, + }, + { + quantity: { + type: "fixed", + value: { type: "decimal", decimal: 1 }, + }, + }, + ], + }); + }); + + it("should subtract pantry from AND group with compatible units", () => { + // Recipe: 200g flour AND 100ml water (same ingredient name in AND) + // We'll create this by adding two recipes with the same ingredient in different units + const recipeA = new Recipe(`Add @flour{200%g}.`); + const recipeB = new Recipe(`Add @flour{0.5%kg}.`); + const shoppingList = new ShoppingList(); + shoppingList.addRecipe(recipeA); + shoppingList.addRecipe(recipeB); + + // flour should be summed: 200g + 500g = 700g + const flourBefore = shoppingList.ingredients.find( + (i) => i.name === "flour", + ); + expect(flourBefore!.quantityTotal).toMatchObject({ + quantity: { + type: "fixed", + value: { type: "decimal", decimal: 700 }, + }, + unit: "g", + }); + + // Now add pantry with 300g flour + shoppingList.addPantry(`[pantry]\nflour = "300%g"`); + + const flourAfter = shoppingList.ingredients.find( + (i) => i.name === "flour", + ); + expect(flourAfter!.quantityTotal).toMatchObject({ + quantity: { + type: "fixed", + value: { type: "decimal", decimal: 400 }, + }, + unit: "g", + }); + }); + + it("should subtract pantry from AND group — partial across leaves", () => { + // Create an AND group ingredient: e.g. pepper "to taste" AND 1 tsp + // Recipe with two instances with different units to create AND group + const recipe = new Recipe( + `Season with @pepper{to taste} and @&pepper{1%tsp}.`, + ); + const shoppingList = new ShoppingList(); + shoppingList.addRecipe(recipe); + + // Verify AND group structure + const pepperBefore = shoppingList.ingredients.find( + (i) => i.name === "pepper", + ); + expect(pepperBefore!.quantityTotal).toMatchObject({ + and: [ + { + quantity: { + type: "fixed", + value: { type: "text", text: "to taste" }, + }, + }, + { + quantity: { + type: "fixed", + value: { type: "decimal", decimal: 1 }, + }, + unit: "tsp", + }, + ], + }); + + // Add pantry with 0.5 tsp pepper — should subtract from the tsp leaf + shoppingList.addPantry(`[pantry]\npepper = "0.5%tsp"`); + + const pepperAfter = shoppingList.ingredients.find( + (i) => i.name === "pepper", + ); + // The tsp leaf should be reduced (1 - 0.5 = 0.5) + // The text leaf remains unchanged (incompatible with tsp) + expect(pepperAfter!.quantityTotal).toMatchObject({ + and: [ + { + quantity: { + type: "fixed", + value: { type: "text", text: "to taste" }, + }, + }, + { + quantity: { + type: "fixed", + }, + unit: "tsp", + }, + ], + }); + }); + + it("should update pantry remainder correctly with group subtraction", () => { + const recipe = new Recipe( + `Season with @pepper{to taste} and @&pepper{1%tsp}.`, + ); + const shoppingList = new ShoppingList(); + shoppingList.addRecipe(recipe); + shoppingList.addPantry(`[pantry]\npepper = "3%tsp"`); + + // Pantry had 3 tsp, recipe needs 1 tsp (text leaf is incompatible) + // → 2 tsp should remain in pantry + const resultingPantry = shoppingList.getPantry(); + const pepper = resultingPantry!.findItem("pepper"); + expect(pepper).toBeDefined(); + expect(pepper!.quantity).toMatchObject({ + type: "fixed", + value: { type: "decimal", decimal: 2 }, + }); }); }); });