diff --git a/frontend/components/Domain/Recipe/RecipeList.vue b/frontend/components/Domain/Recipe/RecipeList.vue index 4aa2958d231..6df73b021bf 100644 --- a/frontend/components/Domain/Recipe/RecipeList.vue +++ b/frontend/components/Domain/Recipe/RecipeList.vue @@ -30,7 +30,7 @@ diff --git a/frontend/composables/recipe-page/use-extract-recipe-yield.test.ts b/frontend/composables/recipe-page/use-extract-recipe-yield.test.ts index 3bc8e7996e8..a85605528ff 100644 --- a/frontend/composables/recipe-page/use-extract-recipe-yield.test.ts +++ b/frontend/composables/recipe-page/use-extract-recipe-yield.test.ts @@ -1,7 +1,20 @@ import { describe, expect, test } from "vitest"; +import { getLowestFraction } from "../recipes/use-fraction"; import { useExtractRecipeYield } from "./use-extract-recipe-yield"; describe("test use extract recipe yield", () => { + test("useFraction", () => { + expect(getLowestFraction(0.5)).toStrictEqual([0, 1, 2]); + expect(getLowestFraction(1.5)).toStrictEqual([1, 1, 2]); + expect(getLowestFraction(3.25)).toStrictEqual([3, 1, 4]); + expect(getLowestFraction(93452.75)).toStrictEqual([93452, 3, 4]); + expect(getLowestFraction(0.125)).toStrictEqual([0, 1, 8]); + expect(getLowestFraction(1.0 / 3.0)).toStrictEqual([0, 1, 3]); + expect(getLowestFraction(0.142857)).toStrictEqual([0, 1, 7]); + expect(getLowestFraction(0.5)).toStrictEqual([0, 1, 2]); + expect(getLowestFraction(0.5)).toStrictEqual([0, 1, 2]); + }); + test("when text empty return empty", () => { const result = useExtractRecipeYield(null, 1); expect(result).toStrictEqual(""); @@ -18,54 +31,63 @@ describe("test use extract recipe yield", () => { test("when text matches a mixed fraction, return a scaled fraction", () => { const val = "10 1/2 units"; - const result = useExtractRecipeYield(val, 1); + const result = useExtractRecipeYield(val, 1, false); expect(result).toStrictEqual(val); - const resultScaled = useExtractRecipeYield(val, 3); + const resultScaled = useExtractRecipeYield(val, 3, false); expect(resultScaled).toStrictEqual("31 1/2 units"); - const resultScaledPartial = useExtractRecipeYield(val, 2.5); + const resultScaledPartial = useExtractRecipeYield(val, 2.5, false); expect(resultScaledPartial).toStrictEqual("26 1/4 units"); const resultScaledInt = useExtractRecipeYield(val, 4); expect(resultScaledInt).toStrictEqual("42 units"); }); + test("when unit precedes number, have correct formatting", () => { + const val = "serves 3"; + const result = useExtractRecipeYield(val, 1, false); + expect(result).toStrictEqual(val) + + const resultScaled = useExtractRecipeYield(val, 2.5, false); + expect(resultScaled).toStrictEqual("serves 7 1/2") + }); + test("when text matches a fraction, return a scaled fraction", () => { const val = "1/3 plates"; - const result = useExtractRecipeYield(val, 1); + const result = useExtractRecipeYield(val, 1, false); expect(result).toStrictEqual(val); - const resultScaled = useExtractRecipeYield(val, 2); + const resultScaled = useExtractRecipeYield(val, 2, false); expect(resultScaled).toStrictEqual("2/3 plates"); const resultScaledInt = useExtractRecipeYield(val, 3); expect(resultScaledInt).toStrictEqual("1 plates"); - const resultScaledPartial = useExtractRecipeYield(val, 2.5); + const resultScaledPartial = useExtractRecipeYield(val, 2.5, false); expect(resultScaledPartial).toStrictEqual("5/6 plates"); - const resultScaledMixed = useExtractRecipeYield(val, 4); + const resultScaledMixed = useExtractRecipeYield(val, 4, false); 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); + expect(result).toStrictEqual("114 parts"); const resultScaled = useExtractRecipeYield(val, 2); - expect(resultScaled).toStrictEqual("2.5 parts"); + expect(resultScaled).toStrictEqual("212 parts"); const resultScaledInt = useExtractRecipeYield(val, 4); expect(resultScaledInt).toStrictEqual("5 parts"); const resultScaledPartial = useExtractRecipeYield(val, 2.5); - expect(resultScaledPartial).toStrictEqual("3.125 parts"); + expect(resultScaledPartial).toStrictEqual("318 parts"); const roundedVal = "1.33333333333333333333 parts"; const resultScaledRounded = useExtractRecipeYield(roundedVal, 2); - expect(resultScaledRounded).toStrictEqual("2.667 parts"); + expect(resultScaledRounded).toStrictEqual("223 parts"); }); test("when text matches an int, return a scaled int", () => { @@ -77,7 +99,7 @@ describe("test use extract recipe yield", () => { expect(resultScaled).toStrictEqual("10 bowls"); const resultScaledPartial = useExtractRecipeYield(val, 2.5); - expect(resultScaledPartial).toStrictEqual("12.5 bowls"); + expect(resultScaledPartial).toStrictEqual("1212 bowls"); const resultScaledLarge = useExtractRecipeYield(val, 10); expect(resultScaledLarge).toStrictEqual("50 bowls"); @@ -93,13 +115,13 @@ describe("test use extract recipe yield", () => { expect(resultDivZeroMixed).toStrictEqual(valDivZeroMixed); }); - test("when text contains a weird or small fraction, return the original string", () => { + test("use an approximation for very weird fractions", () => { const valWeird = "2323231239087/134527431962272135 servings"; - const resultWeird = useExtractRecipeYield(valWeird, 5); - expect(resultWeird).toStrictEqual(valWeird); + const resultWeird = useExtractRecipeYield(valWeird, 5, false); + expect(resultWeird).toStrictEqual("1/11581 servings"); const valSmall = "1/20230225 lovable servings"; - const resultSmall = useExtractRecipeYield(valSmall, 12); + const resultSmall = useExtractRecipeYield(valSmall, 12, false); expect(resultSmall).toStrictEqual(valSmall); }); diff --git a/frontend/composables/recipe-page/use-extract-recipe-yield.ts b/frontend/composables/recipe-page/use-extract-recipe-yield.ts index 53d17b264b9..237e16c3391 100644 --- a/frontend/composables/recipe-page/use-extract-recipe-yield.ts +++ b/frontend/composables/recipe-page/use-extract-recipe-yield.ts @@ -1,4 +1,5 @@ -import { useFraction } from "~/composables/recipes"; +import { getLowestFraction } from "../recipes/use-fraction"; +import { formatFraction } from "../recipes/use-recipe-ingredients"; const matchMixedFraction = /(?:\d*\s\d*\d*|0)\/\d*\d*/; const matchFraction = /(?:\d*\d*|0)\/\d*\d*/; @@ -37,9 +38,7 @@ function extractServingsFromFraction(fractionString: string): number | undefined } } - - -function findMatch(yieldString: string): [matchString: string, servings: number, isFraction: boolean] | null { +function findMatch(yieldString: string): [matchString: string, servings: number] | null { if (!yieldString) { return null; } @@ -53,7 +52,7 @@ function findMatch(yieldString: string): [matchString: string, servings: number, if (servings === undefined) { return null; } else { - return [match, servings, true]; + return [match, servings]; } } @@ -66,52 +65,39 @@ function findMatch(yieldString: string): [matchString: string, servings: number, if (servings === undefined) { return null; } else { - return [match, servings, true]; + return [match, servings]; } } const decimalMatch = yieldString.match(matchDecimal); if (decimalMatch?.length) { const match = decimalMatch[0]; - return [match, parseFloat(match), false]; + return [match, parseFloat(match)]; } const intMatch = yieldString.match(matchInt); if (intMatch?.length) { const match = intMatch[0]; - return [match, parseInt(match), false]; + return [match, parseInt(match)]; } return null; } -function formatServings(servings: number, scale: number, isFraction: boolean): string { +function formatServings(servings: number, scale: number, includeFormating: 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]}`; - } + const fraction = getLowestFraction(val); - return valString.trim(); + return formatFraction(fraction, includeFormating) } -export function useExtractRecipeYield(yieldString: string | null, scale: number): string { +export function useExtractRecipeYield(yieldString: string | null, scale: number, includeFormating = true): string { if (!yieldString) { return ""; } @@ -121,12 +107,12 @@ export function useExtractRecipeYield(yieldString: string | null, scale: number) return yieldString; } - const [matchString, servings, isFraction] = match; + const [matchString, servings] = match; - const formattedServings = formatServings(servings, scale, isFraction); + const formattedServings = formatServings(servings, scale, includeFormating); if (!formattedServings) { return yieldString // this only happens with very weird or small fractions } else { - return yieldString.replace(matchString, formatServings(servings, scale, isFraction)); + return yieldString.replace(matchString, " " + formattedServings + " ").replace(/\s\s+/g, " ").trim(); } } diff --git a/frontend/composables/recipes/index.ts b/frontend/composables/recipes/index.ts index c29e234308e..946348c1222 100644 --- a/frontend/composables/recipes/index.ts +++ b/frontend/composables/recipes/index.ts @@ -1,4 +1,3 @@ -export { useFraction } from "./use-fraction"; export { useRecipe } from "./use-recipe"; export { useRecipes, recentRecipes, allRecipes, useLazyRecipes } from "./use-recipes"; export { parseIngredientText, useParsedIngredientText } from "./use-recipe-ingredients"; diff --git a/frontend/composables/recipes/use-fraction.ts b/frontend/composables/recipes/use-fraction.ts index bbb7eac71e6..e51f819f0c6 100644 --- a/frontend/composables/recipes/use-fraction.ts +++ b/frontend/composables/recipes/use-fraction.ts @@ -1,76 +1,25 @@ -/* frac.js (C) 2012-present SheetJS -- http://sheetjs.com */ -/* https://developer.aliyun.com/mirror/npm/package/frac/v/0.3.0 Apache license */ +/* Loosely based on this Stackoverflow answer: https://stackoverflow.com/questions/14002113/how-to-simplify-a-decimal-into-the-smallest-possible-fraction#14011299 */ -function frac(x: number, D: number, mixed: boolean) { - let n1 = Math.floor(x); - let d1 = 1; - let n2 = n1 + 1; - let d2 = 1; - if (x !== n1) - while (d1 <= D && d2 <= D) { - const m = (n1 + n2) / (d1 + d2); - if (x === m) { - if (d1 + d2 <= D) { - d1 += d2; - n1 += n2; - d2 = D + 1; - } else if (d1 > d2) d2 = D + 1; - else d1 = D + 1; - break; - } else if (x < m) { - n2 = n1 + n2; - d2 = d1 + d2; - } else { - n1 = n1 + n2; - d1 = d1 + d2; - } - } - if (d1 > D) { - d1 = d2; - n1 = n2; - } - if (!mixed) return [0, n1, d1]; - const q = Math.floor(n1 / d1); - return [q, n1 - q * d1, d1]; -} -function cont(x: number, D: number, mixed: boolean) { - const sgn = x < 0 ? -1 : 1; - let B = x * sgn; - let P_2 = 0; - let P_1 = 1; - let P = 0; - let Q_2 = 1; - let Q_1 = 0; - let Q = 0; - let A = Math.floor(B); - while (Q_1 < D) { - A = Math.floor(B); - P = A * P_1 + P_2; - Q = A * Q_1 + Q_2; - if (B - A < 0.00000005) break; - B = 1 / (B - A); - P_2 = P_1; - P_1 = P; - Q_2 = Q_1; - Q_1 = Q; - } - if (Q > D) { - if (Q_1 > D) { - Q = Q_2; - P = P_2; - } else { - Q = Q_1; - P = P_1; - } +export function getLowestFraction(x: number) { + const eps = 1.0E-06; + let numerator, h1, h2, denominator, k1, k2, a; + + const whole = Math.floor(x); + x = x - whole + a = Math.floor(x); + h1 = 1; + k1 = 0; + numerator = a; + denominator = 1; + + while (x - a > eps * denominator * denominator) { + x = 1 / (x - a); + a = Math.floor(x); + h2 = h1; h1 = numerator; + k2 = k1; k1 = denominator; + numerator = h2 + a * h1; + denominator = k2 + a * k1; } - if (!mixed) return [0, sgn * P, Q]; - const q = Math.floor((sgn * P) / Q); - return [q, sgn * P - q * Q, Q]; -} -export const useFraction = function () { - return { - frac, - cont, - }; -}; + return [whole, numerator, denominator]; +} diff --git a/frontend/composables/recipes/use-recipe-ingredients.test.ts b/frontend/composables/recipes/use-recipe-ingredients.test.ts index 4b6a93869f7..28ae6197c6d 100644 --- a/frontend/composables/recipes/use-recipe-ingredients.test.ts +++ b/frontend/composables/recipes/use-recipe-ingredients.test.ts @@ -37,7 +37,8 @@ describe(parseIngredientText.name, () => { test("ingredient text with fraction when unit is null", () => { const ingredient = createRecipeIngredient({ quantity: 1.5, unit: undefined }); - expect(parseIngredientText(ingredient, false, 1, true)).contain("11").and.to.contain("2"); + expect(parseIngredientText(ingredient, false, 1, true)).toStrictEqual("112 Item 1") + expect(parseIngredientText(ingredient, false, 1, false)).toStrictEqual("1 1/2 Item 1") }); test("ingredient text with fraction no formatting", () => { diff --git a/frontend/composables/recipes/use-recipe-ingredients.ts b/frontend/composables/recipes/use-recipe-ingredients.ts index e6d1256f78f..fe361a1f2b1 100644 --- a/frontend/composables/recipes/use-recipe-ingredients.ts +++ b/frontend/composables/recipes/use-recipe-ingredients.ts @@ -1,7 +1,6 @@ import DOMPurify from "isomorphic-dompurify"; -import { useFraction } from "./use-fraction"; +import { getLowestFraction } from "./use-fraction"; import { CreateIngredientFood, CreateIngredientUnit, IngredientFood, IngredientUnit, RecipeIngredient } from "~/lib/api/types/recipe"; -const { frac } = useFraction(); export function sanitizeIngredientHTML(rawHtml: string) { return DOMPurify.sanitize(rawHtml, { @@ -35,6 +34,22 @@ function useUnitName(unit: CreateIngredientUnit | IngredientUnit | undefined, us return returnVal; } + +export function formatFraction(fraction: number[], includeFormating = true): string { + let result = "" + if (fraction[0] !== undefined && fraction[0] > 0) { + result += includeFormating ? `${fraction[0]}` : `${fraction[0]} `; + } + + if (fraction[1] > 0) { + result += includeFormating ? + `${fraction[1]}${fraction[2]}` : + `${fraction[1]}/${fraction[2]}`; + } + + return result +} + export function useParsedIngredientText(ingredient: RecipeIngredient, disableAmount: boolean, scale = 1, includeFormating = true) { if (disableAmount) { return { @@ -56,19 +71,12 @@ export function useParsedIngredientText(ingredient: RecipeIngredient, disableAmo if (unit && !unit.fraction) { returnQty = (quantity * scale).toString(); } else { - const fraction = frac(quantity * scale, 10, true); - if (fraction[0] !== undefined && fraction[0] > 0) { - returnQty += fraction[0]; - } - - if (fraction[1] > 0) { - returnQty += includeFormating ? - `${fraction[1]}${fraction[2]}` : - ` ${fraction[1]}/${fraction[2]}`; - } + const fraction = getLowestFraction(quantity * scale); + returnQty += formatFraction(fraction, includeFormating) } } + const unitName = useUnitName(unit, usePluralUnit); const foodName = useFoodName(food, usePluralFood);