From 1f87ce7682225afc821934a3d86139320e08100c Mon Sep 17 00:00:00 2001 From: Thomas Lamant Date: Sun, 25 Oct 2026 20:53:07 +0100 Subject: [PATCH] fix(recipe): collect RatioNotComputableError(E9401 during final quantity aggregation --- docs/e/codes/COOKLANG_E9401.md | 4 +++- src/classes/recipe.ts | 8 +++++++- test/diagnostics.test.ts | 26 ++++++++++++++++++++++++++ test/parser_helpers.test.ts | 6 +++--- test/recipe_parsing.test.ts | 12 +++++++++++- 5 files changed, 50 insertions(+), 6 deletions(-) diff --git a/docs/e/codes/COOKLANG_E9401.md b/docs/e/codes/COOKLANG_E9401.md index dd608463..f2938a5b 100644 --- a/docs/e/codes/COOKLANG_E9401.md +++ b/docs/e/codes/COOKLANG_E9401.md @@ -6,12 +6,14 @@ title: Ratio cannot be computed ## What this error means -The ratio between two quantities cannot be computed because one of them is not numeric. This aborts the whole `parse()`/`scaleBy()`/`convertTo()` call; it is not collected alongside other diagnostics. +The ratio between two quantities cannot be computed because one of them is not numeric. ## Common causes - Merging equivalents where one of the equivalent quantities is text-only, e.g. `@flour{1%cup|a bit}` then `@&flour{2%cup|a lot}` +During `Recipe.parse()`, this is collected with the other diagnostics and thrown after the source has been processed. During `scaleBy()` or `convertTo()`, it is thrown directly by the operation that needs the ratio. + ## How to fix it Use numeric quantities for every equivalent that needs to be merged with another occurrence of the same ingredient. diff --git a/src/classes/recipe.ts b/src/classes/recipe.ts index ec6e9be5..126eafa1 100644 --- a/src/classes/recipe.ts +++ b/src/classes/recipe.ts @@ -88,6 +88,7 @@ import { ReferencedItemCannotBeRedefinedError, NoTabAsIndentError, BadIndentationError, + RatioNotComputableError, DiagnosticError, ParseStoppedError, } from "../errors"; @@ -2095,7 +2096,12 @@ export class Recipe { this.choices.variants = [...allVariants]; } - this._populateIngredientQuantities(true); + try { + this._populateIngredientQuantities(true); + } catch (error) { + if (!(error instanceof RatioNotComputableError)) throw error; + this.diagnostics.push(error.diagnostic); + } // Diagnostics are reported in source order (sort is stable) this.diagnostics.sort( diff --git a/test/diagnostics.test.ts b/test/diagnostics.test.ts index f5dc1618..ba24f2df 100644 --- a/test/diagnostics.test.ts +++ b/test/diagnostics.test.ts @@ -399,4 +399,30 @@ describe("Recipe.parse() throwing", () => { times: 1, }); }); + + it("collects COOKLANG_E9401 with the source during final quantity aggregation", () => { + const source = "Add @flour{1%cup|a bit}\nAdd @&flour{2%cup|a lot}"; + const recipe = new Recipe(); + let thrown: unknown; + + try { + recipe.parse(source); + } catch (error) { + thrown = error; + } + + expect(thrown).toBeInstanceOf(DiagnosticError); + expect(thrown).toMatchObject({ + source, + diagnostics: [ + { + code: "COOKLANG_E9401", + severity: "error", + }, + ], + }); + expect(recipe.diagnostics).toEqual((thrown as DiagnosticError).diagnostics); + expect(recipe.ingredients).toHaveLength(1); + expect(recipe.sections).toHaveLength(1); + }); }); diff --git a/test/parser_helpers.test.ts b/test/parser_helpers.test.ts index 92a47fd1..a811cb9a 100644 --- a/test/parser_helpers.test.ts +++ b/test/parser_helpers.test.ts @@ -940,9 +940,9 @@ describe("findAndUpsertCookware", () => { }, }; - expect(() => findAndUpsertCookware(cookware, newCookware, true)).toThrow( - DivisionByZeroError, - ); + expect(() => + findAndUpsertCookware(cookware, newCookware, true, []), + ).toThrow(DivisionByZeroError); }); it("should throw a ReferencedCookwareNotFoundError if a reference cookware does not exist", () => { diff --git a/test/recipe_parsing.test.ts b/test/recipe_parsing.test.ts index 8d8f285c..3eb4e29d 100644 --- a/test/recipe_parsing.test.ts +++ b/test/recipe_parsing.test.ts @@ -1,6 +1,6 @@ import { describe, it, expect } from "vitest"; import { Recipe } from "../src/classes/recipe"; -import { ParseStoppedError } from "../src/errors"; +import { DiagnosticError, ParseStoppedError } from "../src/errors"; import { parseCollect } from "./mocks/parse_collect"; import { simpleRecipe, @@ -77,6 +77,16 @@ describe("parse function", () => { }); }); + it("rethrows unexpected errors during final quantity aggregation", () => { + const recipe = new Recipe(); + const unexpected = new Error("Unexpected aggregation failure"); + Reflect.set(recipe, "_populateIngredientQuantities", () => { + throw unexpected; + }); + + expect(() => recipe.parse("@flour")).toThrow(unexpected); + }); + it("stops immediately with COOKLANG_E1003 for a zero-denominator fraction", () => { const source = "Add @flour{1/0}\nAdd @sugar{2}"; const recipe = new Recipe();