+34
-14
@@ -30,33 +30,53 @@ function formatQuantity(value) {
|
|||||||
));
|
));
|
||||||
if (Math.abs(fraction - remainder) <= 0.02) {
|
if (Math.abs(fraction - remainder) <= 0.02) {
|
||||||
const adjustedWhole = fraction === 1 ? whole + 1 : whole;
|
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) {
|
function scaleIngredient(ingredient, factor) {
|
||||||
if (typeof ingredient !== "string" || factor === 1) return ingredient;
|
return scaleIngredientWithStatus(ingredient, factor).value;
|
||||||
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);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function scaleRecipe(recipe, portions) {
|
function scaleRecipe(recipe, portions) {
|
||||||
const basePortions = parseYield(recipe?.recipeYield);
|
const basePortions = parseYield(recipe?.recipeYield);
|
||||||
if (!basePortions || !Number.isInteger(portions) || portions < 1) return recipe;
|
if (!basePortions || !Number.isInteger(portions) || portions < 1) return recipe;
|
||||||
const suffix = String(recipe.recipeYield).replace(/^\s*\d+(?:\.\d+)?\s*/, "");
|
const suffix = String(recipe.recipeYield).replace(/^\s*\d+(?:\.\d+)?\s*/, "");
|
||||||
|
const factor = portions / basePortions;
|
||||||
|
const ingredients = (recipe.recipeIngredient || []).map((ingredient) => (
|
||||||
|
scaleIngredientWithStatus(ingredient, factor)
|
||||||
|
));
|
||||||
return {
|
return {
|
||||||
...recipe,
|
...recipe,
|
||||||
recipeYield: suffix ? `${portions} ${suffix}` : portions,
|
recipeYield: suffix ? `${portions} ${suffix}` : portions,
|
||||||
recipeIngredient: (recipe.recipeIngredient || []).map((ingredient) => (
|
originalRecipeYield: recipe.recipeYield,
|
||||||
scaleIngredient(ingredient, portions / basePortions)
|
recipeIngredient: ingredients.map(({ value }) => value),
|
||||||
))
|
recipeIngredientStatus: ingredients.map(({ status }) => status)
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
module.exports = { parseYield, scaleIngredient, scaleRecipe };
|
module.exports = { parseYield, scaleIngredient, scaleIngredientWithStatus, scaleRecipe };
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
const test = require("node:test");
|
const test = require("node:test");
|
||||||
const assert = require("node:assert/strict");
|
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", () => {
|
test("extracts the number of portions from a recipe yield", () => {
|
||||||
assert.equal(parseYield("6 servings"), 6);
|
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.equal(scaled.recipeYield, "9 servings");
|
||||||
assert.deepEqual(scaled.recipeIngredient, ["3 cups oats", "Salt to taste"]);
|
assert.deepEqual(scaled.recipeIngredient, ["3 cups oats", "Salt to taste"]);
|
||||||
assert.equal(original.recipeYield, "6 servings");
|
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"
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
Reference in New Issue
Block a user