diff --git a/docs/examples-scaling-recipes.md b/docs/examples-scaling-recipes.md index 50ab800..86ca9bc 100644 --- a/docs/examples-scaling-recipes.md +++ b/docs/examples-scaling-recipes.md @@ -28,7 +28,10 @@ const recipe = Recipe(`...`) const scaledRecipe = recipe.scaleBy(2) ``` -All the ingredients with numerical quantities have their quantities multiplied by 2, and the metadata and `servings` value will also be multiplied by 2 +In the above example, will be multiplied by 2: +- All the ingredients (including alternative units and alternative ingredients) with scalable numerical quantities +- The scaling metadata and `servings` value +- [Arbitrary scalable quantities](/guide-extensions.html#arbitrary-scalable-quantities) ## Scaling to a specific number of servings @@ -41,4 +44,7 @@ const scaledRecipe = recipe.scaleTo(4) // const scaledRecipe = recipe.scaleBy(2) ``` -All the ingredients with scalable numerical quantities have their quantities adjusted by a factor of 4/2 in this case, and the scaling metadata (if expressed as numbers) and `servings` property value will also be multiplied by the same factor. +In the above example, will be adjusted by a factor of 4/2: +- All the ingredients (including alternative units and alternative ingredients) with scalable numerical quantities have their quantities adjusted by a factor of 4/2 in this case +- The scaling metadata and `servings` value +- [Arbitrary scalable quantities](/guide-extensions.html#arbitrary-scalable-quantities) diff --git a/docs/guide-extensions.md b/docs/guide-extensions.md index c3328cb..6709a49 100644 --- a/docs/guide-extensions.md +++ b/docs/guide-extensions.md @@ -102,6 +102,19 @@ Also works with Cookware and Timers - Cookware can also be quantified (without any unit, e.g. `#bowls{2}`) - Quantities will be added similarly as ingredients if cookware is referenced, e.g. `#&bowls{2}` +## Arbitrary scalable quantities + +Usage: {{name:quantity%unit}} + +These quantities can be added to any step or note and will be scaled but not added to the ingredients list. + +The `name` and `unit` are optional. + +Examples: +- {{5}} +- {{2%kcal}} +- {{factor}} + ## Alternative units You can define equivalent quantities in different units for the same ingredient using the pipe `|` separator within the curly braces. diff --git a/playground/app/components/recipe/NoteContent.vue b/playground/app/components/recipe/NoteContent.vue new file mode 100644 index 0000000..10cecca --- /dev/null +++ b/playground/app/components/recipe/NoteContent.vue @@ -0,0 +1,37 @@ + + + diff --git a/playground/app/components/recipe/RecipeRender.vue b/playground/app/components/recipe/RecipeRender.vue index 455092a..63e4e00 100644 --- a/playground/app/components/recipe/RecipeRender.vue +++ b/playground/app/components/recipe/RecipeRender.vue @@ -150,7 +150,8 @@ const sectionsWithStepNumbers = computed(() => { v-else-if="item.type === 'note'" class="note ml-4 text-gray-600 italic dark:text-gray-300" > - Note: {{ item.note }} + Note: + diff --git a/playground/app/components/recipe/StepContent.vue b/playground/app/components/recipe/StepContent.vue index c598e25..a183ea4 100644 --- a/playground/app/components/recipe/StepContent.vue +++ b/playground/app/components/recipe/StepContent.vue @@ -2,11 +2,12 @@ import type { Recipe, Step, - Item, + StepItem, Timer, IngredientAlternative, IngredientItemQuantity, QuantityWithPlainUnit, + ArbitraryScalable, } from "cooklang-parser"; const props = defineProps<{ @@ -30,7 +31,7 @@ function toPlainEquivalents( * Get the first (primary) alternative for an ingredient item */ function getPrimaryAlternative( - item: Item & { type: "ingredient" }, + item: StepItem & { type: "ingredient" }, ): IngredientAlternative | undefined { return item.alternatives[0]; } @@ -39,7 +40,7 @@ function getPrimaryAlternative( * Get the other alternatives (excluding the primary one) */ function getOtherAlternatives( - item: Item & { type: "ingredient" }, + item: StepItem & { type: "ingredient" }, ): IngredientAlternative[] { return item.alternatives.slice(1); } @@ -47,7 +48,7 @@ function getOtherAlternatives( /** * Check if an ingredient item has alternatives */ -function hasAlternatives(item: Item & { type: "ingredient" }): boolean { +function hasAlternatives(item: StepItem & { type: "ingredient" }): boolean { return item.alternatives.length > 1; } @@ -64,6 +65,13 @@ function getCookware(index: number) { function getTimer(index: number): Timer | undefined { return props.recipe.timers[index]; } + +/** + * Get the arbitrary scalable by index + */ +function getArbitrary(index: number): ArbitraryScalable | undefined { + return props.recipe.arbitraries[index]; +} @@ -123,6 +133,20 @@ function getTimer(index: number): Timer | undefined { :timer="getTimer(item.index)!" /> + diff --git a/playground/app/pages/index.vue b/playground/app/pages/index.vue index afdd258..39d784a 100644 --- a/playground/app/pages/index.vue +++ b/playground/app/pages/index.vue @@ -25,6 +25,8 @@ servings: 8 tags: [baking, vegan-option] --- +> This recipe has an energy content of {{250%kcal}} + Preheat oven to ~{10%minutes}. Mash @ripe bananas{1%=large|1.5%cup} and @&ripe bananas{2%=small|1%cup} in a #large bowl{}. diff --git a/src/classes/recipe.ts b/src/classes/recipe.ts index 0cc7925..9160bdc 100644 --- a/src/classes/recipe.ts +++ b/src/classes/recipe.ts @@ -6,7 +6,7 @@ import type { IngredientItemQuantity, Timer, Step, - Note, + NoteItem, Cookware, MetadataExtract, CookwareItem, @@ -22,6 +22,9 @@ import type { QuantityWithPlainUnit, IngredientQuantityGroup, IngredientQuantityAndGroup, + ArbitraryScalable, + FixedNumericValue, + StepItem, } from "../types"; import { Section } from "./section"; import { @@ -35,6 +38,7 @@ import { floatRegex, quantityAlternativeRegex, inlineIngredientAlternativesRegex, + arbitraryScalableRegex, } from "../regex"; import { flushPendingItems, @@ -117,6 +121,10 @@ export class Recipe { * The parsed recipe timers. */ timers: Timer[] = []; + /** + * The parsed arbitrary quantities. + */ + arbitraries: ArbitraryScalable[] = []; /** * The parsed recipe servings. Used for scaling. Parsed from one of * {@link Metadata.servings}, {@link Metadata.yield} or {@link Metadata.serves} @@ -159,16 +167,82 @@ export class Recipe { } } + /** + * Parses a matched arbitrary scalable quantity and adds it to the given array. + * @private + * @param regexMatchGroups - The regex match groups from arbitrary scalable regex. + * @param intoArray - The array to push the parsed arbitrary scalable item into. + */ + private _parseArbitraryScalable( + regexMatchGroups: RegExpMatchArray["groups"], + intoArray: Array, + ): void { + if (!regexMatchGroups || !regexMatchGroups.arbitraryQuantity) return; + const quantityMatch = regexMatchGroups.arbitraryQuantity + ?.trim() + .match(quantityAlternativeRegex); + if (quantityMatch?.groups) { + const value = quantityMatch.groups.quantity + ? parseQuantityInput(quantityMatch.groups.quantity) + : undefined; + const unit = quantityMatch.groups.unit; + const name = regexMatchGroups.arbitraryName || undefined; + if (!value || (value.type === "fixed" && value.value.type === "text")) { + throw new InvalidQuantityFormat( + regexMatchGroups.arbitraryQuantity?.trim(), + "Arbitrary quantities must have a numerical value", + ); + } + const arbitrary: ArbitraryScalable = { + quantity: value as FixedNumericValue, + }; + if (name) arbitrary.name = name; + if (unit) arbitrary.unit = unit; + intoArray.push({ + type: "arbitrary", + index: this.arbitraries.push(arbitrary) - 1, + }); + } + } + + /** + * Parses text for arbitrary scalables and returns NoteItem array. + * @param text - The text to parse for arbitrary scalables. + * @returns Array of NoteItem (text and arbitrary scalable items). + */ + private _parseNoteText(text: string): NoteItem[] { + const noteItems: NoteItem[] = []; + let cursor = 0; + const globalRegex = new RegExp(arbitraryScalableRegex.source, "g"); + + for (const match of text.matchAll(globalRegex)) { + const idx = match.index; + /* v8 ignore else -- @preserve */ + if (idx > cursor) { + noteItems.push({ type: "text", value: text.slice(cursor, idx) }); + } + + this._parseArbitraryScalable(match.groups, noteItems); + cursor = idx + match[0].length; + } + + if (cursor < text.length) { + noteItems.push({ type: "text", value: text.slice(cursor) }); + } + + return noteItems; + } + private _parseQuantityRecursive( quantityRaw: string, ): QuantityWithExtendedUnit[] { let quantityMatch = quantityRaw.match(quantityAlternativeRegex); const quantities: QuantityWithExtendedUnit[] = []; while (quantityMatch?.groups) { - const value = quantityMatch.groups.ingredientQuantityValue - ? parseQuantityInput(quantityMatch.groups.ingredientQuantityValue) + const value = quantityMatch.groups.quantity + ? parseQuantityInput(quantityMatch.groups.quantity) : undefined; - const unit = quantityMatch.groups.ingredientUnit; + const unit = quantityMatch.groups.unit; if (value) { const newQuantity: QuantityWithExtendedUnit = { quantity: value }; if (unit) { @@ -185,10 +259,8 @@ export class Recipe { } else { throw new InvalidQuantityFormat(quantityRaw); } - quantityMatch = quantityMatch.groups.ingredientAltQuantity - ? quantityMatch.groups.ingredientAltQuantity.match( - quantityAlternativeRegex, - ) + quantityMatch = quantityMatch.groups.alternative + ? quantityMatch.groups.alternative.match(quantityAlternativeRegex) : null; } return quantities; @@ -954,7 +1026,7 @@ export class Recipe { let blankLineBefore = true; let section: Section = new Section(); const items: Step["items"] = []; - let note: Note["note"] = ""; + let noteText = ""; let inNote = false; // We parse content line by line @@ -962,7 +1034,11 @@ export class Recipe { // A blank line triggers flushing pending stuff if (line.trim().length === 0) { flushPendingItems(section, items); - note = flushPendingNote(section, note); + flushPendingNote( + section, + noteText ? this._parseNoteText(noteText) : [], + ); + noteText = ""; blankLineBefore = true; inNote = false; continue; @@ -971,7 +1047,11 @@ export class Recipe { // New section if (line.startsWith("=")) { flushPendingItems(section, items); - note = flushPendingNote(section, note); + flushPendingNote( + section, + noteText ? this._parseNoteText(noteText) : [], + ); + noteText = ""; if (this.sections.length === 0 && section.isBlank()) { section.name = line.replace(/^=+|=+$/g, "").trim(); @@ -990,8 +1070,11 @@ export class Recipe { // New note if (blankLineBefore && line.startsWith(">")) { flushPendingItems(section, items); - note = flushPendingNote(section, note); - note += line.substring(1).trim(); + flushPendingNote( + section, + noteText ? this._parseNoteText(noteText) : [], + ); + noteText = line.substring(1).trim(); inNote = true; blankLineBefore = false; continue; @@ -1000,14 +1083,15 @@ export class Recipe { // Continue note if (inNote) { if (line.startsWith(">")) { - note += " " + line.substring(1).trim(); + noteText += " " + line.substring(1).trim(); } else { - note += " " + line.trim(); + noteText += " " + line.trim(); } blankLineBefore = false; continue; } - note = flushPendingNote(section, note); + flushPendingNote(section, noteText ? this._parseNoteText(noteText) : []); + noteText = ""; // Detecting items let cursor = 0; @@ -1071,6 +1155,10 @@ export class Recipe { } items.push(newItem); } + // Arbitrary scalable quantities + else if (groups.arbitraryQuantity) { + this._parseArbitraryScalable(groups, items); + } // Then it's necessarily a timer which was matched else { const durationStr = groups.timerQuantity!.trim(); @@ -1100,7 +1188,7 @@ export class Recipe { // End of content reached: pushing all temporarily saved elements flushPendingItems(section, items); - note = flushPendingNote(section, note); + flushPendingNote(section, noteText ? this._parseNoteText(noteText) : []); if (!section.isBlank()) { this.sections.push(section); } @@ -1210,6 +1298,14 @@ export class Recipe { scaleAlternativesBy(alternatives, factor); } + // Scale Arbitraries + for (const arbitrary of newRecipe.arbitraries) { + arbitrary.quantity = multiplyQuantityValue( + arbitrary.quantity, + factor, + ) as FixedNumericValue; + } + newRecipe._populate_ingredient_quantities(); newRecipe.servings = Big(originalServings).times(factor).toNumber(); @@ -1289,6 +1385,7 @@ export class Recipe { }); newRecipe.cookware = deepClone(this.cookware); newRecipe.timers = deepClone(this.timers); + newRecipe.arbitraries = deepClone(this.arbitraries); newRecipe.servings = this.servings; return newRecipe; } diff --git a/src/errors.ts b/src/errors.ts index bdf8005..8cce26d 100644 --- a/src/errors.ts +++ b/src/errors.ts @@ -82,8 +82,10 @@ export class IncompatibleUnitsError extends Error { } export class InvalidQuantityFormat extends Error { - constructor(value: string) { - super(`Invalid quantity format found in: ${value}`); + constructor(value: string, extra?: string) { + super( + `Invalid quantity format found in: ${value}${extra ? ` (${extra})` : ""}`, + ); this.name = "InvalidQuantityFormat"; } } diff --git a/src/index.ts b/src/index.ts index dc94e7c..1d75b59 100644 --- a/src/index.ts +++ b/src/index.ts @@ -36,9 +36,12 @@ import type { IngredientAlternative, CookwareItem, TimerItem, - Item, + ArbitraryScalable, + ArbitraryScalableItem, + StepItem, Step, Note, + NoteItem, Cookware, CookwareFlag, CategorizedIngredients, @@ -101,9 +104,12 @@ export { IngredientAlternative, CookwareItem, TimerItem, - Item, + ArbitraryScalable, + ArbitraryScalableItem, + StepItem, Step, Note, + NoteItem, Cookware, CookwareFlag, CategorizedIngredients, diff --git a/src/regex.ts b/src/regex.ts index d6bb980..be0f9a5 100644 --- a/src/regex.ts +++ b/src/regex.ts @@ -149,18 +149,18 @@ export const ingredientWithAlternativeRegex = createRegex() export const inlineIngredientAlternativesRegex = new RegExp("\\|" + ingredientWithAlternativeRegex.source.slice(1)) export const quantityAlternativeRegex = createRegex() - .startNamedGroup("ingredientQuantityValue") + .startNamedGroup("quantity") .notAnyOf("}|%").oneOrMore() .endGroup().optional() .startGroup() .literal("%") - .startNamedGroup("ingredientUnit") + .startNamedGroup("unit") .notAnyOf("|}").oneOrMore() .endGroup() .endGroup().optional() .startGroup() .literal("|") - .startNamedGroup("ingredientAltQuantity") + .startNamedGroup("alternative") .startGroup() .notAnyOf("}").oneOrMore() .endGroup().zeroOrMore() @@ -284,12 +284,37 @@ const timerRegex = createRegex() .literal("}") .toRegExp() +export const arbitraryScalableRegex = createRegex() + .literal("{{") + .startGroup() + .startNamedGroup("arbitraryName") + .notAnyOf("}:%").oneOrMore() + .endGroup() + .literal(":") + .endGroup().optional() + .startNamedGroup("arbitraryQuantity") + .startGroup() + .notAnyOf("}|%").oneOrMore() + .endGroup().optional() + .startGroup() + .literal("%") + .notAnyOf("|}").oneOrMore().lazy() + .endGroup().optional() + .startGroup() + .literal("|") + .notAnyOf("}").oneOrMore().lazy() + .endGroup().zeroOrMore() + .endGroup() + .literal("}}") + .toRegExp(); + export const tokensRegex = new RegExp( [ ingredientWithGroupKeyRegex, ingredientWithAlternativeRegex, cookwareRegex, timerRegex, + arbitraryScalableRegex ] .map((r) => r.source) .join("|"), diff --git a/src/types.ts b/src/types.ts index e79f7f7..33d76d6 100644 --- a/src/types.ts +++ b/src/types.ts @@ -385,12 +385,12 @@ export interface IngredientItem { * @category Types */ export interface RecipeAlternatives { - /** Map of choices that can be made at Ingredient Item level - * - Keys are the Ingredient Item IDs (e.g. "ingredient-item-2") + /** Map of choices that can be made at Ingredient StepItem level + * - Keys are the Ingredient StepItem IDs (e.g. "ingredient-item-2") * - Values are arrays of IngredientAlternative objects representing the choices available for that item */ ingredientItems: Map; - /** Map of choices that can be made for Grouped Ingredient Item's + /** Map of choices that can be made for Grouped Ingredient StepItem's * - Keys are the Group IDs (e.g. "eggs" for `@|eggs|...`) * - Values are arrays of IngredientAlternative objects representing the choices available for that group */ @@ -403,9 +403,9 @@ export interface RecipeAlternatives { * @category Types */ export interface RecipeChoices { - /** Map of choices that can be made at Ingredient Item level */ + /** Map of choices that can be made at Ingredient StepItem level */ ingredientItems?: Map; - /** Map of choices that can be made for Grouped Ingredient Item's */ + /** Map of choices that can be made for Grouped Ingredient StepItem's */ ingredientGroups?: Map; } @@ -457,11 +457,40 @@ export interface TextItem { value: string; } +/** + * Represents an arbitrary scalable quantity in a recipe. + * @category Types + */ +export interface ArbitraryScalable { + /** The name of the arbitrary scalable quantity. */ + name?: string; + /** The numerical value of the arbitrary scalable quantity. */ + quantity: FixedNumericValue; + /** The unit of the arbitrary scalable quantity. */ + unit?: string; +} + +/** + * Represents an arbitrary scalable quantity item in a recipe step. + * @category Types + */ +export interface ArbitraryScalableItem { + /** The type of the item. */ + type: "arbitrary"; + /** The index of the arbitrary scalable quantity, within the {@link Recipe.arbitraries | list of arbitrary scalable quantities} */ + index: number; +} + /** * Represents an item in a recipe step. * @category Types */ -export type Item = TextItem | IngredientItem | CookwareItem | TimerItem; +export type StepItem = + | TextItem + | IngredientItem + | CookwareItem + | TimerItem + | ArbitraryScalableItem; /** * Represents a step in a recipe. @@ -470,17 +499,23 @@ export type Item = TextItem | IngredientItem | CookwareItem | TimerItem; export interface Step { type: "step"; /** The items in the step. */ - items: Item[]; + items: StepItem[]; } +/** + * Represents an item in a note (can be text or arbitrary scalable). + * @category Types + */ +export type NoteItem = TextItem | ArbitraryScalableItem; + /** * Represents a note in a recipe. * @category Types */ export interface Note { type: "note"; - /** The content of the note. */ - note: string; + /** The items in the note. */ + items: NoteItem[]; } /** diff --git a/src/utils/parser_helpers.ts b/src/utils/parser_helpers.ts index 4a9f70c..a03d8ea 100644 --- a/src/utils/parser_helpers.ts +++ b/src/utils/parser_helpers.ts @@ -6,6 +6,7 @@ import type { TextValue, DecimalValue, FractionValue, + NoteItem, } from "../types"; import { metadataRegex, @@ -14,7 +15,7 @@ import { scalingMetaValueRegex, } from "../regex"; import { Section as SectionObject } from "../classes/section"; -import type { Ingredient, Note, Step, Cookware } from "../types"; +import type { Ingredient, Step, Cookware } from "../types"; import { addQuantityValues } from "../quantities/mutations"; import { CannotAddTextValueError, @@ -22,20 +23,20 @@ import { } from "../errors"; /** - * Pushes a pending note to the section content if it's not empty. + * Pushes a pending note to the section content if it has items. * @param section - The current section object. - * @param note - The note content. - * @returns An empty string if the note was pushed, otherwise the original note. + * @param noteItems - The note items array. + * @returns An empty array if the note was pushed, otherwise the original items. */ export function flushPendingNote( section: SectionObject, - note: Note["note"], -): Note["note"] { - if (note.length > 0) { - section.content.push({ type: "note", note }); - return ""; + noteItems: NoteItem[], +): NoteItem[] { + if (noteItems.length > 0) { + section.content.push({ type: "note", items: [...noteItems] }); + return []; } - return note; + return noteItems; } /** diff --git a/test/__snapshots__/recipe_parsing.test.ts.snap b/test/__snapshots__/recipe_parsing.test.ts.snap index e333662..62f380a 100644 --- a/test/__snapshots__/recipe_parsing.test.ts.snap +++ b/test/__snapshots__/recipe_parsing.test.ts.snap @@ -158,6 +158,7 @@ exports[`parse function > extracts steps correctly 1`] = ` exports[`parse function > parses complex recipes correctly 1`] = ` Recipe { + "arbitraries": [], "choices": { "ingredientGroups": Map {}, "ingredientItems": Map {}, @@ -674,7 +675,12 @@ Recipe { "type": "step", }, { - "note": "Editor’s Tip: To ensure the noodles will not stick to each other, stir the water like a whirlpool when you add the pasta sheets.", + "items": [ + { + "type": "text", + "value": "Editor’s Tip: To ensure the noodles will not stick to each other, stir the water like a whirlpool when you add the pasta sheets.", + }, + ], "type": "note", }, ], @@ -1149,7 +1155,12 @@ Recipe { "type": "step", }, { - "note": "Editor’s Tip: For the freshest, creamiest taste, grate your own cheese at home.", + "items": [ + { + "type": "text", + "value": "Editor’s Tip: For the freshest, creamiest taste, grate your own cheese at home.", + }, + ], "type": "note", }, ], @@ -1345,7 +1356,12 @@ exports[`parse function > parses notes correctly 1`] = ` Section { "content": [ { - "note": "This is a note at the beginning.", + "items": [ + { + "type": "text", + "value": "This is a note at the beginning.", + }, + ], "type": "note", }, { @@ -1385,7 +1401,12 @@ exports[`parse function > parses notes correctly 1`] = ` "type": "step", }, { - "note": "This is a note in the middle which continues on the next line.", + "items": [ + { + "type": "text", + "value": "This is a note in the middle which continues on the next line.", + }, + ], "type": "note", }, { @@ -1398,11 +1419,21 @@ exports[`parse function > parses notes correctly 1`] = ` "type": "step", }, { - "note": "Another note on multiple lines starting with > for readability", + "items": [ + { + "type": "text", + "value": "Another note on multiple lines starting with > for readability", + }, + ], "type": "note", }, { - "note": "A final note.", + "items": [ + { + "type": "text", + "value": "A final note.", + }, + ], "type": "note", }, ], diff --git a/test/parser_helpers.test.ts b/test/parser_helpers.test.ts index 4230633..4409636 100644 --- a/test/parser_helpers.test.ts +++ b/test/parser_helpers.test.ts @@ -1,6 +1,12 @@ import { describe, it, expect } from "vitest"; import { Section as SectionObject } from "../src/classes/section"; -import type { Step, MetadataExtract, Cookware, Ingredient } from "../src/types"; +import type { + Step, + MetadataExtract, + Cookware, + Ingredient, + NoteItem, +} from "../src/types"; import { flushPendingNote, flushPendingItems, @@ -252,26 +258,26 @@ images: [https://static01.nyt.com/images/2021/12/28/dining/yf-baked-feta/yf-bake describe("flushPendingNote", () => { it("should add a note to the section if the note is not empty", () => { const section = new SectionObject("Test Section"); - const note = "This is a test note."; + const note: NoteItem[] = [{ type: "text", value: "This is a test note." }]; const result = flushPendingNote(section, note); expect(section.content).toHaveLength(1); expect(section.content[0]).toEqual({ type: "note", - note: "This is a test note.", + items: [{ type: "text", value: "This is a test note." }], }); - expect(result).toBe(""); + expect(result).toEqual([]); }); - it("should not add a note if it is empty and return an empty string", () => { + it("should not add a note if it is empty and return an empty array", () => { const section = new SectionObject("Test Section"); - const note = ""; + const note: NoteItem[] = []; const result = flushPendingNote(section, note); expect(section.content).toHaveLength(0); - expect(result).toBe(""); + expect(result).toEqual([]); }); }); diff --git a/test/recipe_parsing.test.ts b/test/recipe_parsing.test.ts index ac096a7..8431956 100644 --- a/test/recipe_parsing.test.ts +++ b/test/recipe_parsing.test.ts @@ -11,7 +11,7 @@ import { InvalidQuantityFormat, ReferencedItemCannotBeRedefinedError, } from "../src/errors"; -import type { Ingredient, IngredientItem, Step } from "../src/types"; +import type { Ingredient, IngredientItem, Note, Step } from "../src/types"; describe("parse function", () => { it("parses basic metadata correctly", () => { @@ -2120,6 +2120,76 @@ Another step. }); }); + describe("arbitrary scalable quantities", () => { + it("parses arbitrary scalable quantities correctly", () => { + const recipe = "{{2}} {{1.5%cup}} {{calory-factor:5}}"; + const result = new Recipe(recipe); + expect(result.arbitraries).toHaveLength(3); + expect(result.arbitraries[0]).toEqual({ + quantity: { + type: "fixed", + value: { type: "decimal", decimal: 2 }, + }, + }); + expect(result.arbitraries[1]).toEqual({ + quantity: { + type: "fixed", + value: { type: "decimal", decimal: 1.5 }, + }, + unit: "cup", + }); + expect(result.arbitraries[2]).toEqual({ + quantity: { + type: "fixed", + value: { type: "decimal", decimal: 5 }, + }, + name: "calory-factor", + }); + const step = result.sections[0]?.content[0] as Step; + expect(step.items[0]).toEqual({ + type: "arbitrary", + index: 0, + }); + expect(step.items[2]).toEqual({ + type: "arbitrary", + index: 1, + }); + expect(step.items[4]).toEqual({ + type: "arbitrary", + index: 2, + }); + }); + + it("parses notes containing arbitrary scalable quantities correctly", () => { + const recipe = ` + > This is a note with an arbitrary quantity {{3%tbsp}} inside + `; + const result = new Recipe(recipe); + expect(result.arbitraries).toHaveLength(1); + expect(result.arbitraries[0]).toEqual({ + quantity: { + type: "fixed", + value: { type: "decimal", decimal: 3 }, + }, + unit: "tbsp", + }); + const note = result.sections[0]?.content[0] as Note; + expect(note).toEqual({ + type: "note", + items: [ + { type: "text", value: "This is a note with an arbitrary quantity " }, + { type: "arbitrary", index: 0 }, + { type: "text", value: " inside" }, + ], + }); + }); + + it("throws an error if arbitrary scalable quantity has no numeric value", () => { + const recipe = "{{calory-factor}}"; + expect(() => new Recipe(recipe)).toThrowError(InvalidQuantityFormat); + }); + }); + describe("clone", () => { it("creates a deep clone of the recipe", () => { const recipe = new Recipe(recipeToScaleWithAlternatives); diff --git a/test/recipe_scaling.test.ts b/test/recipe_scaling.test.ts index cc7ed95..aee5ba0 100644 --- a/test/recipe_scaling.test.ts +++ b/test/recipe_scaling.test.ts @@ -624,4 +624,23 @@ Add @|milk|milk{150%mL} or @|milk|oat milk{150%mL} for a vegan version. }, ]); }); + + it("should scale arbitraries when scaling by", () => { + const recipe = new Recipe(` +--- +servings: 2 +--- +Add {{sauce:100%g}} of sauce. + `); + const scaledRecipe = recipe.scaleBy(2); + expect(scaledRecipe.arbitraries.length).toBe(1); + expect(scaledRecipe.arbitraries[0]!).toEqual({ + name: "sauce", + quantity: { + type: "fixed", + value: { type: "decimal", decimal: 200 }, + }, + unit: "g", + }); + }); }); diff --git a/test/section.test.ts b/test/section.test.ts index ce61a5f..c58dfcc 100644 --- a/test/section.test.ts +++ b/test/section.test.ts @@ -5,7 +5,10 @@ describe("isBlank", () => { it("should correctly check whether a section is blank", () => { const section = new Section(); expect(section.isBlank()).toBe(true); - section.content.push({ type: "note", note: "test" }); + section.content.push({ + type: "note", + items: [{ type: "text", value: "test" }], + }); expect(section.isBlank()).toBe(false); }); });