Skip to content

Commit ddfa812

Browse files
committed
feat(recipe): add variant-aware step index interpretation to getIngredientQuantities
1 parent 90465e5 commit ddfa812

3 files changed

Lines changed: 152 additions & 9 deletions

File tree

src/classes/recipe.ts

Lines changed: 26 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -245,6 +245,17 @@ export class Recipe {
245245
);
246246
}
247247

248+
/**
249+
* Returns whether a step is active (visible) for the given variant.
250+
* Steps with no `variants` tag are active in all variants.
251+
* Use `"*"` for the default variant.
252+
*/
253+
private isStepActiveForVariant(step: Step, variant: string): boolean {
254+
if (!step.variants) return true;
255+
if (variant === "*") return step.variants.includes("*");
256+
return step.variants.includes(variant);
257+
}
258+
248259
/**
249260
* Creates a new Recipe instance.
250261
* @param content - The recipe content to parse.
@@ -728,7 +739,7 @@ export class Recipe {
728739
// Defined as a static type alias for the private method's return type
729740
/** @internal */
730741
private collectQuantityGroups(options?: GetIngredientQuantitiesOptions) {
731-
const { section, step, choices } = options || {};
742+
const { section, step, variant: indexingVariant, choices } = options || {};
732743

733744
// Active variant (undefined or "*" = default)
734745
const activeVariant = choices?.variant;
@@ -795,13 +806,23 @@ export class Recipe {
795806
// Track whether this section is optional
796807
const isOptionalSection = currentSection.optional === true;
797808

809+
// When a variant is given for step-index interpretation, limit indexing
810+
// to the steps active in that variant — matching how step numbers are
811+
// assigned during rendering (inactive steps are skipped/unnumbered).
812+
const stepsForIndexing =
813+
indexingVariant !== undefined
814+
? allSteps.filter((s) =>
815+
this.isStepActiveForVariant(s, indexingVariant),
816+
)
817+
: allSteps;
818+
798819
// Determine steps to process
799820
const stepsToProcess =
800821
step === undefined
801822
? allSteps
802823
: typeof step === "number"
803-
? step >= 0 && step < allSteps.length
804-
? [allSteps[step]!]
824+
? step >= 0 && step < stepsForIndexing.length
825+
? [stepsForIndexing[step]!]
805826
: []
806827
: allSteps.includes(step)
807828
? [step]
@@ -2008,14 +2029,10 @@ export class Recipe {
20082029
for (const alternative of alternatives) {
20092030
// v8 ignore else -- @preserve
20102031
if (alternative.quantity) {
2011-
const converted = convertAlternativeQuantity(
2012-
alternative,
2013-
);
2032+
const converted = convertAlternativeQuantity(alternative);
20142033
alternative.quantity = converted.quantity;
20152034
alternative.unit = converted.unit;
2016-
(
2017-
alternative
2018-
).scalable = converted.scalable;
2035+
alternative.scalable = converted.scalable;
20192036
alternative.equivalents = converted.equivalents;
20202037
}
20212038
}

src/types.ts

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -503,6 +503,16 @@ export interface GetIngredientQuantitiesOptions {
503503
* Can be a Step object or step index (0-based within the section, or global if no section specified).
504504
*/
505505
step?: Step | number;
506+
/**
507+
* When `step` is a number, interpret it as a 0-based index into the steps
508+
* that are active for this variant (i.e., the steps that would be visible
509+
* when rendering with this variant), rather than into all steps in the section.
510+
*
511+
* Has no effect when `step` is a {@link Step} object or when `step` is `undefined`.
512+
*
513+
* Use `"*"` to refer to the default variant (steps tagged `[*]` and untagged steps).
514+
*/
515+
variant?: string;
506516
/**
507517
* The choices to apply when computing quantities.
508518
* If not provided, uses primary alternatives (index 0 for all).

test/recipe_variants.test.ts

Lines changed: 116 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -782,4 +782,120 @@ Serve on a #plate{}.
782782
});
783783
});
784784
});
785+
786+
// ============================================================================
787+
// variant option in getIngredientQuantities — step-number interpretation
788+
// ============================================================================
789+
790+
describe("variant step-index interpretation", () => {
791+
// recipeWithStepVariants steps (all-steps order):
792+
// 0: "Preheat..." — no variant (active everywhere)
793+
// 1: "[vegan] Use flax eggs" — vegan only
794+
// 2: "[*] Crack eggs" — default only
795+
// 3: "Mix flour and sugar" — no variant (active everywhere)
796+
//
797+
// Visible in "vegan" variant: [0, 1, 3] → indices 0, 1, 2
798+
// Visible in "*" variant: [0, 2, 3] → indices 0, 1, 2
799+
800+
it("without variant option, step index uses raw all-steps order (backward compat)", () => {
801+
const recipe = new Recipe(recipeWithStepVariants);
802+
// Step index 3 in all-steps = "Mix flour and sugar"
803+
const ingredients = recipe.getIngredientQuantities({ step: 3 });
804+
const names = ingredients
805+
.filter((i) => i.usedAsPrimary)
806+
.map((i) => i.name);
807+
expect(names).toContain("flour");
808+
expect(names).toContain("sugar");
809+
});
810+
811+
it("with variant='vegan', step 1 maps to the [vegan] step", () => {
812+
const recipe = new Recipe(recipeWithStepVariants);
813+
const ingredients = recipe.getIngredientQuantities({
814+
step: 1,
815+
variant: "vegan",
816+
choices: { variant: "vegan" },
817+
});
818+
const names = ingredients
819+
.filter((i) => i.usedAsPrimary)
820+
.map((i) => i.name);
821+
expect(names).toContain("flax eggs");
822+
expect(names).not.toContain("eggs");
823+
});
824+
825+
it("with variant='*', step 1 maps to the [*] step", () => {
826+
const recipe = new Recipe(recipeWithStepVariants);
827+
const ingredients = recipe.getIngredientQuantities({
828+
step: 1,
829+
variant: "*",
830+
});
831+
const names = ingredients
832+
.filter((i) => i.usedAsPrimary)
833+
.map((i) => i.name);
834+
expect(names).toContain("eggs");
835+
expect(names).not.toContain("flax eggs");
836+
});
837+
838+
it("with variant='vegan', step 2 maps to the last active step (Mix)", () => {
839+
const recipe = new Recipe(recipeWithStepVariants);
840+
// vegan-active steps: [Preheat(0), UseFlaxEggs(1), Mix(2)]
841+
const ingredients = recipe.getIngredientQuantities({
842+
step: 2,
843+
variant: "vegan",
844+
choices: { variant: "vegan" },
845+
});
846+
const names = ingredients
847+
.filter((i) => i.usedAsPrimary)
848+
.map((i) => i.name);
849+
expect(names).toContain("flour");
850+
expect(names).toContain("sugar");
851+
});
852+
853+
it("out-of-bounds step index with variant returns no primary ingredients", () => {
854+
const recipe = new Recipe(recipeWithStepVariants);
855+
// vegan-active steps: 3 steps (indices 0-2); index 3 is out-of-bounds
856+
const ingredients = recipe.getIngredientQuantities({
857+
step: 3,
858+
variant: "vegan",
859+
choices: { variant: "vegan" },
860+
});
861+
const primaries = ingredients.filter((i) => i.usedAsPrimary);
862+
expect(primaries).toHaveLength(0);
863+
});
864+
865+
it("step object reference is unaffected by variant option", () => {
866+
const recipe = new Recipe(recipeWithStepVariants);
867+
const steps = recipe.sections[0]!.content.filter(
868+
(item): item is Step => item.type === "step",
869+
);
870+
// Pass the [vegan] step object directly — variant option should not change behaviour
871+
const ingredients = recipe.getIngredientQuantities({
872+
step: steps[1]!,
873+
variant: "vegan",
874+
choices: { variant: "vegan" },
875+
});
876+
const names = ingredients
877+
.filter((i) => i.usedAsPrimary)
878+
.map((i) => i.name);
879+
expect(names).toContain("flax eggs");
880+
});
881+
882+
it("works correctly combined with section filter", () => {
883+
const recipe = new Recipe(recipeWithSectionAndStepVariants);
884+
// Section 0 "[vegan] Plant-based Additions":
885+
// all steps: [paneer step (vegetarian), tofu step (untagged)]
886+
// vegan-active steps: [tofu step] (paneer excluded as it's [vegetarian])
887+
// So step index 0 with variant="vegan" → tofu step
888+
const ingredients = recipe.getIngredientQuantities({
889+
section: 0,
890+
step: 0,
891+
variant: "vegan",
892+
choices: { variant: "vegan" },
893+
});
894+
const names = ingredients
895+
.filter((i) => i.usedAsPrimary)
896+
.map((i) => i.name);
897+
expect(names).toContain("tofu");
898+
expect(names).not.toContain("paneer");
899+
});
900+
});
785901
});

0 commit comments

Comments
 (0)