From b61ca70bed9d84ebaad2c5da50c3ff38e48bc170 Mon Sep 17 00:00:00 2001 From: aaronaxvig Date: Fri, 14 Aug 2026 13:28:39 -0500 Subject: [PATCH] feat: annotate scaled ingredient quantities Refs #1, #2 --- lib/recipe-scaler.js | 48 +++++++++++++++++++++++++++----------- test/recipe-scaler.test.js | 19 ++++++++++++++- 2 files changed, 52 insertions(+), 15 deletions(-) diff --git a/lib/recipe-scaler.js b/lib/recipe-scaler.js index df59b24..e22ee38 100644 --- a/lib/recipe-scaler.js +++ b/lib/recipe-scaler.js @@ -30,33 +30,53 @@ function formatQuantity(value) { )); if (Math.abs(fraction - remainder) <= 0.02) { const adjustedWhole = fraction === 1 ? whole + 1 : whole; - return `${adjustedWhole || ""}${adjustedWhole && symbol ? " " : ""}${symbol}` || "0"; + return { + value: `${adjustedWhole || ""}${adjustedWhole && symbol ? " " : ""}${symbol}` || "0", + approximate: false + }; } - return Number(value.toFixed(2)).toString(); + return { value: Number(value.toFixed(2)).toString(), approximate: true }; +} + +function scaleIngredientWithStatus(ingredient, factor) { + if (typeof ingredient !== "string" || factor === 1) { + return { value: ingredient, status: "unchanged" }; + } + const match = ingredient.match(/^(\s*)(?:(\d+)\s+)?(\d+\/\d+|\d+(?:\.\d+)?|[¼½¾⅓⅔⅛⅜⅝⅞])(?=\s|[a-zA-Z])/); + if (!match) { + const hasLeadingQuantity = /^\s*(?:\d|[¼½¾⅓⅔⅛⅜⅝⅞])/.test(ingredient); + return { value: ingredient, status: hasLeadingQuantity ? "unscalable" : "unquantified" }; + } + const whole = Number(match[2] || 0); + const quantity = parseQuantity(match[3]); + if (!Number.isFinite(quantity)) return { value: ingredient, status: "unscalable" }; + const formatted = formatQuantity((whole + quantity) * factor); + const replacement = `${match[1]}${formatted.value}`; + return { + value: replacement + ingredient.slice(match[0].length), + status: formatted.approximate ? "approximate" : "scaled" + }; } function scaleIngredient(ingredient, factor) { - if (typeof ingredient !== "string" || factor === 1) return ingredient; - const match = ingredient.match(/^(\s*)(?:(\d+)\s+)?(\d+\/\d+|\d+(?:\.\d+)?|[¼½¾⅓⅔⅛⅜⅝⅞])(?=\s|[a-zA-Z])/); - if (!match) return ingredient; - const whole = Number(match[2] || 0); - const quantity = parseQuantity(match[3]); - if (!Number.isFinite(quantity)) return ingredient; - const replacement = `${match[1]}${formatQuantity((whole + quantity) * factor)}`; - return replacement + ingredient.slice(match[0].length); + return scaleIngredientWithStatus(ingredient, factor).value; } function scaleRecipe(recipe, portions) { const basePortions = parseYield(recipe?.recipeYield); if (!basePortions || !Number.isInteger(portions) || portions < 1) return recipe; const suffix = String(recipe.recipeYield).replace(/^\s*\d+(?:\.\d+)?\s*/, ""); + const factor = portions / basePortions; + const ingredients = (recipe.recipeIngredient || []).map((ingredient) => ( + scaleIngredientWithStatus(ingredient, factor) + )); return { ...recipe, recipeYield: suffix ? `${portions} ${suffix}` : portions, - recipeIngredient: (recipe.recipeIngredient || []).map((ingredient) => ( - scaleIngredient(ingredient, portions / basePortions) - )) + originalRecipeYield: recipe.recipeYield, + recipeIngredient: ingredients.map(({ value }) => value), + recipeIngredientStatus: ingredients.map(({ status }) => status) }; } -module.exports = { parseYield, scaleIngredient, scaleRecipe }; +module.exports = { parseYield, scaleIngredient, scaleIngredientWithStatus, scaleRecipe }; diff --git a/test/recipe-scaler.test.js b/test/recipe-scaler.test.js index e2d250e..121111d 100644 --- a/test/recipe-scaler.test.js +++ b/test/recipe-scaler.test.js @@ -1,6 +1,6 @@ const test = require("node:test"); const assert = require("node:assert/strict"); -const { parseYield, scaleIngredient, scaleRecipe } = require("../lib/recipe-scaler"); +const { parseYield, scaleIngredient, scaleIngredientWithStatus, scaleRecipe } = require("../lib/recipe-scaler"); test("extracts the number of portions from a recipe yield", () => { assert.equal(parseYield("6 servings"), 6); @@ -25,4 +25,21 @@ test("returns a scaled copy of a recipe", () => { assert.equal(scaled.recipeYield, "9 servings"); assert.deepEqual(scaled.recipeIngredient, ["3 cups oats", "Salt to taste"]); assert.equal(original.recipeYield, "6 servings"); + assert.equal(scaled.originalRecipeYield, "6 servings"); + assert.deepEqual(scaled.recipeIngredientStatus, ["scaled", "unquantified"]); +}); + +test("reports ingredient scaling confidence", () => { + assert.deepEqual(scaleIngredientWithStatus("2 cups oats", 1.5), { + value: "3 cups oats", status: "scaled" + }); + assert.deepEqual(scaleIngredientWithStatus("Salt to taste", 2), { + value: "Salt to taste", status: "unquantified" + }); + assert.deepEqual(scaleIngredientWithStatus("1-2 tbsp oil", 2), { + value: "1-2 tbsp oil", status: "unscalable" + }); + assert.deepEqual(scaleIngredientWithStatus("1 cup milk", 0.7), { + value: "0.7 cup milk", status: "approximate" + }); });