diff --git a/frontend/components/Domain/Recipe/RecipePage/RecipePageParts/RecipePageScale.vue b/frontend/components/Domain/Recipe/RecipePage/RecipePageParts/RecipePageScale.vue index 6db9d01662df..d42dc5d56750 100644 --- a/frontend/components/Domain/Recipe/RecipePage/RecipePageParts/RecipePageScale.vue +++ b/frontend/components/Domain/Recipe/RecipePage/RecipePageParts/RecipePageScale.vue @@ -33,6 +33,8 @@ import RecipeRating from "~/components/Domain/Recipe/RecipeRating.vue"; import { NoUndefinedField } from "~/lib/api/types/non-generated"; import { Recipe } from "~/lib/api/types/recipe"; import { usePageState } from "~/composables/recipe-page/shared-state"; +import { useExtractRecipeYield } from "~/composables/recipe-page/use-extract-recipe-yield"; + export default defineComponent({ components: { RecipeScaleEditButton, @@ -65,29 +67,11 @@ export default defineComponent({ }); const scaledYield = computed(() => { - const regMatchNum = /\d+/; - const yieldString = props.recipe.recipeYield; - const num = yieldString?.match(regMatchNum); - - if (num && num?.length > 0) { - const yieldAsInt = parseInt(num[0]); - return yieldString?.replace(num[0], String(yieldAsInt * scaleValue.value)); - } - - return props.recipe.recipeYield; + return useExtractRecipeYield(props.recipe.recipeYield, scaleValue.value); }); const basicYield = computed(() => { - const regMatchNum = /\d+/; - const yieldString = props.recipe.recipeYield; - const num = yieldString?.match(regMatchNum); - - if (num && num?.length > 0) { - const yieldAsInt = parseInt(num[0]); - return yieldString?.replace(num[0], String(yieldAsInt)); - } - - return props.recipe.recipeYield; + return useExtractRecipeYield(props.recipe.recipeYield, 1); }); return { diff --git a/frontend/composables/recipe-page/use-extract-recipe-yield.test.ts b/frontend/composables/recipe-page/use-extract-recipe-yield.test.ts new file mode 100644 index 000000000000..3bc8e7996e85 --- /dev/null +++ b/frontend/composables/recipe-page/use-extract-recipe-yield.test.ts @@ -0,0 +1,111 @@ +import { describe, expect, test } from "vitest"; +import { useExtractRecipeYield } from "./use-extract-recipe-yield"; + +describe("test use extract recipe yield", () => { + test("when text empty return empty", () => { + const result = useExtractRecipeYield(null, 1); + expect(result).toStrictEqual(""); + }); + + test("when text matches nothing return text", () => { + const val = "this won't match anything"; + const result = useExtractRecipeYield(val, 1); + expect(result).toStrictEqual(val); + + const resultScaled = useExtractRecipeYield(val, 5); + expect(resultScaled).toStrictEqual(val); + }); + + test("when text matches a mixed fraction, return a scaled fraction", () => { + const val = "10 1/2 units"; + const result = useExtractRecipeYield(val, 1); + expect(result).toStrictEqual(val); + + const resultScaled = useExtractRecipeYield(val, 3); + expect(resultScaled).toStrictEqual("31 1/2 units"); + + const resultScaledPartial = useExtractRecipeYield(val, 2.5); + expect(resultScaledPartial).toStrictEqual("26 1/4 units"); + + const resultScaledInt = useExtractRecipeYield(val, 4); + expect(resultScaledInt).toStrictEqual("42 units"); + }); + + test("when text matches a fraction, return a scaled fraction", () => { + const val = "1/3 plates"; + const result = useExtractRecipeYield(val, 1); + expect(result).toStrictEqual(val); + + const resultScaled = useExtractRecipeYield(val, 2); + expect(resultScaled).toStrictEqual("2/3 plates"); + + const resultScaledInt = useExtractRecipeYield(val, 3); + expect(resultScaledInt).toStrictEqual("1 plates"); + + const resultScaledPartial = useExtractRecipeYield(val, 2.5); + expect(resultScaledPartial).toStrictEqual("5/6 plates"); + + const resultScaledMixed = useExtractRecipeYield(val, 4); + expect(resultScaledMixed).toStrictEqual("1 1/3 plates"); + }); + + test("when text matches a decimal, return a scaled, rounded decimal", () => { + const val = "1.25 parts"; + const result = useExtractRecipeYield(val, 1); + expect(result).toStrictEqual(val); + + const resultScaled = useExtractRecipeYield(val, 2); + expect(resultScaled).toStrictEqual("2.5 parts"); + + const resultScaledInt = useExtractRecipeYield(val, 4); + expect(resultScaledInt).toStrictEqual("5 parts"); + + const resultScaledPartial = useExtractRecipeYield(val, 2.5); + expect(resultScaledPartial).toStrictEqual("3.125 parts"); + + const roundedVal = "1.33333333333333333333 parts"; + const resultScaledRounded = useExtractRecipeYield(roundedVal, 2); + expect(resultScaledRounded).toStrictEqual("2.667 parts"); + }); + + test("when text matches an int, return a scaled int", () => { + const val = "5 bowls"; + const result = useExtractRecipeYield(val, 1); + expect(result).toStrictEqual(val); + + const resultScaled = useExtractRecipeYield(val, 2); + expect(resultScaled).toStrictEqual("10 bowls"); + + const resultScaledPartial = useExtractRecipeYield(val, 2.5); + expect(resultScaledPartial).toStrictEqual("12.5 bowls"); + + const resultScaledLarge = useExtractRecipeYield(val, 10); + expect(resultScaledLarge).toStrictEqual("50 bowls"); + }); + + test("when text contains an invalid fraction, return the original string", () => { + const valDivZero = "3/0 servings"; + const resultDivZero = useExtractRecipeYield(valDivZero, 3); + expect(resultDivZero).toStrictEqual(valDivZero); + + const valDivZeroMixed = "2 4/0 servings"; + const resultDivZeroMixed = useExtractRecipeYield(valDivZeroMixed, 6); + expect(resultDivZeroMixed).toStrictEqual(valDivZeroMixed); + }); + + test("when text contains a weird or small fraction, return the original string", () => { + const valWeird = "2323231239087/134527431962272135 servings"; + const resultWeird = useExtractRecipeYield(valWeird, 5); + expect(resultWeird).toStrictEqual(valWeird); + + const valSmall = "1/20230225 lovable servings"; + const resultSmall = useExtractRecipeYield(valSmall, 12); + expect(resultSmall).toStrictEqual(valSmall); + }); + + test("when text contains multiple numbers, the first is parsed as the servings amount", () => { + const val = "100 sets of 55 bowls"; + const result = useExtractRecipeYield(val, 3); + expect(result).toStrictEqual("300 sets of 55 bowls"); + }) +}); diff --git a/frontend/composables/recipe-page/use-extract-recipe-yield.ts b/frontend/composables/recipe-page/use-extract-recipe-yield.ts new file mode 100644 index 000000000000..53d17b264b96 --- /dev/null +++ b/frontend/composables/recipe-page/use-extract-recipe-yield.ts @@ -0,0 +1,132 @@ +import { useFraction } from "~/composables/recipes"; + +const matchMixedFraction = /(?:\d*\s\d*\d*|0)\/\d*\d*/; +const matchFraction = /(?:\d*\d*|0)\/\d*\d*/; +const matchDecimal = /(\d+.\d+)|(.\d+)/; +const matchInt = /\d+/; + + + +function extractServingsFromMixedFraction(fractionString: string): number | undefined { + const mixedSplit = fractionString.split(/\s/); + const wholeNumber = parseInt(mixedSplit[0]); + const fraction = mixedSplit[1]; + + const fractionSplit = fraction.split("/"); + const numerator = parseInt(fractionSplit[0]); + const denominator = parseInt(fractionSplit[1]); + + if (denominator === 0) { + return undefined; // if the denominator is zero, just give up + } + else { + return wholeNumber + (numerator / denominator); + } +} + +function extractServingsFromFraction(fractionString: string): number | undefined { + const fractionSplit = fractionString.split("/"); + const numerator = parseInt(fractionSplit[0]); + const denominator = parseInt(fractionSplit[1]); + + if (denominator === 0) { + return undefined; // if the denominator is zero, just give up + } + else { + return numerator / denominator; + } +} + + + +function findMatch(yieldString: string): [matchString: string, servings: number, isFraction: boolean] | null { + if (!yieldString) { + return null; + } + + const mixedFractionMatch = yieldString.match(matchMixedFraction); + if (mixedFractionMatch?.length) { + const match = mixedFractionMatch[0]; + const servings = extractServingsFromMixedFraction(match); + + // if the denominator is zero, return no match + if (servings === undefined) { + return null; + } else { + return [match, servings, true]; + } + } + + const fractionMatch = yieldString.match(matchFraction); + if (fractionMatch?.length) { + const match = fractionMatch[0] + const servings = extractServingsFromFraction(match); + + // if the denominator is zero, return no match + if (servings === undefined) { + return null; + } else { + return [match, servings, true]; + } + } + + const decimalMatch = yieldString.match(matchDecimal); + if (decimalMatch?.length) { + const match = decimalMatch[0]; + return [match, parseFloat(match), false]; + } + + const intMatch = yieldString.match(matchInt); + if (intMatch?.length) { + const match = intMatch[0]; + return [match, parseInt(match), false]; + } + + return null; +} + +function formatServings(servings: number, scale: number, isFraction: boolean): string { + const val = servings * scale; + if (Number.isInteger(val)) { + return val.toString(); + } else if (!isFraction) { + return (Math.round(val * 1000) / 1000).toString(); + } + + // convert val into a fraction string + const { frac } = useFraction(); + + let valString = ""; + const fraction = frac(val, 10, true); + + if (fraction[0] !== undefined && fraction[0] > 0) { + valString += fraction[0]; + } + + if (fraction[1] > 0) { + valString += ` ${fraction[1]}/${fraction[2]}`; + } + + return valString.trim(); +} + + +export function useExtractRecipeYield(yieldString: string | null, scale: number): string { + if (!yieldString) { + return ""; + } + + const match = findMatch(yieldString); + if (!match) { + return yieldString; + } + + const [matchString, servings, isFraction] = match; + + const formattedServings = formatServings(servings, scale, isFraction); + if (!formattedServings) { + return yieldString // this only happens with very weird or small fractions + } else { + return yieldString.replace(matchString, formatServings(servings, scale, isFraction)); + } +}