import type { CookingPhaseView, CookingTaskIngredientView, CookingTaskView, IngredientView, TechStepView, UnitView, } from "@batch-cooking/shared"; import { expect } from "chai"; import { type OptimizerRecipeInput, type OptimizerStepInput, type OptimizerTechStepInput, optimizeCookingPlan, } from "../../src/lib/recipe-matching/cooking-optimizer.js"; /** * Pure unit tests for the batch-cooking optimizer — no database, fixtures * hand-built (they're technique/ingredient reference shapes, not the * personal data `@faker-js/faker` covers). * * The suite is black-box: everything goes through `optimizeCookingPlan` * (the only public entry point), never the private helpers, so the tests * stay valid across any internal refactor that keeps the contract. */ describe("cooking-optimizer", () => { // --- Fixture builders ---------------------------------------------------- /** Minimal `TechStepView` — only `key` drives the optimizer, `id` is passed through. */ function tech(key: string): TechStepView { // Stable pseudo-id per key so two fixtures naming the same technique // get the same `id` without a lookup table. let hash = 0; for (const char of key) hash = (hash * 31 + char.charCodeAt(0)) % 100000; return { id: hash, key }; } /** Minimal `IngredientView` — the optimizer only reads `id`/`key`. */ function ingredient(id: number, key: string): IngredientView { return { id, key, icon: "VEGETABLE", category: "freshProduce", subcategory: "vegetables", reproducible: false, allergens: [], diets: [], }; } /** Reference ingredient catalog shared by the fixtures below. */ const ING = { onion: ingredient(1, "onion"), garlic: ingredient(2, "garlic"), carrot: ingredient(3, "carrot"), potato: ingredient(4, "potato"), tomato: ingredient(5, "tomato"), beef: ingredient(6, "beef"), chicken: ingredient(7, "chicken"), parsley: ingredient(8, "parsley"), cream: ingredient(9, "cream"), flour: ingredient(10, "flour"), butter: ingredient(11, "butter"), rice: ingredient(12, "rice"), lentils: ingredient(13, "lentils"), egg: ingredient(14, "egg"), cheese: ingredient(15, "cheese"), } as const; const GRAM: UnitView = { id: 1, key: "gram", type: "MASS", toBaseFactor: 1 }; const PIECE: UnitView = { id: 2, key: "piece", type: "COUNT", toBaseFactor: 1 }; const KILOGRAM: UnitView = { id: 3, key: "kilogram", type: "MASS", toBaseFactor: 1000 }; function line( ing: IngredientView, quantity: number | null = null, unit: UnitView | null = null, ): CookingTaskIngredientView { return { ingredient: ing, quantity, unit }; } function utensil(id: number, key: string) { return { id, key }; } /** One technique clause of a step. */ function clause( techKey: string, ingredients: CookingTaskIngredientView[] = [], utensils: { id: number; key: string }[] = [], ): OptimizerTechStepInput { return { techStep: tech(techKey), order: 0, ingredients, utensils }; } /** A step described by an ordered list of technique clauses. */ function step( stepId: number, order: number, description: string, clauses: OptimizerTechStepInput[], ): OptimizerStepInput { return { stepId, order, description, techSteps: clauses.map((c, i) => ({ ...c, order: i })), }; } /** Shorthand for a single-technique step. */ function simpleStep( stepId: number, order: number, description: string, techKey: string, ingredients: CookingTaskIngredientView[] = [], ): OptimizerStepInput { return step(stepId, order, description, [clause(techKey, ingredients)]); } function recipe( recipeId: number, name: string, steps: OptimizerStepInput[], portions = 4, recipePortions = 4, ): OptimizerRecipeInput { return { recipeId, name, portions, recipePortions, steps }; } // --- Assertion helpers ------------------------------------------------- const allTasks = (plan: { phases: CookingPhaseView[] }): CookingTaskView[] => plan.phases.flatMap((phase) => phase.tasks); const mergedTasks = (plan: { phases: CookingPhaseView[] }): CookingTaskView[] => allTasks(plan).filter((task) => task.kind === "merged-prep"); const stepTasks = (plan: { phases: CookingPhaseView[] }): CookingTaskView[] => allTasks(plan).filter((task) => task.kind === "step"); /** * Structural invariant: every input step is accounted for **exactly * once** — either it survived as its own `step` task, or it was absorbed * into a `merged-prep` pool (its text shows in that pool's * `originalSteps`), never both and never neither. Also: no `step` task id * is emitted twice, and every `step` task maps back to a real input step. */ function assertEveryStepAccountedForOnce( recipes: OptimizerRecipeInput[], plan: { phases: CookingPhaseView[] }, ) { const stepTaskIds = new Set(); for (const task of stepTasks(plan)) { expect(stepTaskIds.has(task.id), `duplicate step task ${task.id}`).to.equal(false); stepTaskIds.add(task.id); } const absorbedTexts = new Set( mergedTasks(plan).flatMap((task) => task.originalSteps.map((s) => s.description)), ); recipes.forEach((r, recipeIndex) => { for (const s of r.steps) { const asStepTask = stepTaskIds.has(`step:${recipeIndex}:${s.stepId}`); const asAbsorbed = absorbedTexts.has(s.description); expect( asStepTask !== asAbsorbed, `step "${s.description}" (recipe ${recipeIndex}) should appear exactly once (stepTask=${asStepTask}, absorbed=${asAbsorbed})`, ).to.equal(true); } }); // Every emitted step task points at a real input step. const validIds = new Set( recipes.flatMap((r, i) => r.steps.map((s) => `step:${i}:${s.stepId}`)), ); for (const id of stepTaskIds) { expect(validIds.has(id), `step task ${id} has no matching input step`).to.equal(true); } } /** * Task ids are globally unique across the plan; within any single phase, * no id (task or background) collides. A background line legitimately * repeats across consecutive phases (a long cook shown as still running), * so background ids are only checked for uniqueness *within* a phase. */ function assertUniqueIds(plan: { phases: CookingPhaseView[] }) { const taskIds: string[] = []; for (const phase of plan.phases) { const perPhase = new Set(); for (const task of phase.tasks) { taskIds.push(task.id); expect( perPhase.has(task.id), `duplicate id ${task.id} within phase ${phase.index}`, ).to.equal(false); perPhase.add(task.id); } for (const bg of phase.background) { expect(perPhase.has(bg.id), `duplicate id ${bg.id} within phase ${phase.index}`).to.equal( false, ); perPhase.add(bg.id); } } expect(taskIds.length, "task ids should be globally unique").to.equal(new Set(taskIds).size); } /** Phase indices are 0..n-1 and match array position. */ function assertPhaseIndices(plan: { phases: CookingPhaseView[] }) { plan.phases.forEach((phase, i) => { expect(phase.index).to.equal(i); }); } // ===================================================================== // Basic contract // ===================================================================== describe("basic contract", () => { it("returns an empty plan for no recipes", () => { expect(optimizeCookingPlan([])).to.deep.equal({ recipes: [], phases: [] }); }); it("returns an empty plan for recipes that have no steps", () => { const plan = optimizeCookingPlan([recipe(1, "Vide", []), recipe(2, "Vide aussi", [])]); expect(plan.phases).to.deep.equal([]); expect(plan.recipes.map((r) => r.name)).to.deep.equal(["Vide", "Vide aussi"]); }); it("keeps a single recipe's steps in order across phases", () => { const plan = optimizeCookingPlan([ recipe(1, "Omelette", [ simpleStep(1, 0, "Battre les œufs", "whisk", [line(ING.egg, 3, PIECE)]), simpleStep(2, 1, "Cuire", "cook"), simpleStep(3, 2, "Servir", "plate"), ]), ]); const descriptions = stepTasks(plan).map((t) => t.description); expect(descriptions).to.deep.equal(["Battre les œufs", "Cuire", "Servir"]); assertEveryStepAccountedForOnce( [ recipe(1, "Omelette", [ simpleStep(1, 0, "Battre les œufs", "whisk", [line(ING.egg, 3, PIECE)]), simpleStep(2, 1, "Cuire", "cook"), simpleStep(3, 2, "Servir", "plate"), ]), ], plan, ); }); it("exposes each planned recipe in the legend, de-duplicating an exact repeat", () => { const plan = optimizeCookingPlan([ recipe(1, "A", [simpleStep(1, 0, "Cuire", "cook")], 4, 4), recipe(1, "A", [simpleStep(1, 0, "Cuire", "cook")], 4, 4), recipe(1, "A", [simpleStep(1, 0, "Cuire", "cook")], 8, 4), ]); expect(plan.recipes).to.deep.equal([ { recipeId: 1, name: "A", portions: 4 }, { recipeId: 1, name: "A", portions: 8 }, ]); }); it("is deterministic — identical input yields byte-identical output", () => { const build = () => [ recipe(1, "Soupe", [ simpleStep(1, 0, "Émincer l'oignon", "chop", [line(ING.onion, 2, PIECE)]), simpleStep(2, 1, "Mijoter", "simmer"), ]), recipe(2, "Tarte", [ simpleStep(3, 0, "Émincer l'oignon", "chop", [line(ING.onion, 3, PIECE)]), simpleStep(4, 1, "Enfourner", "bake"), ]), ]; const a = optimizeCookingPlan(build()); const b = optimizeCookingPlan(build()); expect(JSON.stringify(a)).to.equal(JSON.stringify(b)); }); }); // ===================================================================== // Technique classification (attention + prep) // ===================================================================== describe("technique classification", () => { it("pulls a SETUP step (preheat / boil) into the mise-en-place phase, out of recipe order", () => { const plan = optimizeCookingPlan([ recipe(1, "Gratin", [ simpleStep(1, 0, "Éplucher les pommes de terre", "peel", [line(ING.potato, 800, GRAM)]), simpleStep(2, 1, "Préchauffer le four à 200°C", "preheat"), simpleStep(3, 2, "Assembler", "coat"), simpleStep(4, 3, "Enfourner", "bake"), ]), ]); const mise = plan.phases[0]; expect(mise?.kind).to.equal("mise-en-place"); const miseDescriptions = mise?.tasks.map((t) => t.description) ?? []; expect(miseDescriptions).to.include("Préchauffer le four à 200°C"); expect(miseDescriptions).to.include("Éplucher les pommes de terre"); // ...and it does not reappear later. const laterDescriptions = plan.phases .slice(1) .flatMap((p) => p.tasks.map((t) => t.description)); expect(laterDescriptions).to.not.include("Préchauffer le four à 200°C"); }); it("treats a step whose LAST technique is passive as a background cook", () => { const plan = optimizeCookingPlan([ recipe(1, "Bœuf braisé", [ simpleStep(1, 0, "Saisir la viande", "brown", [line(ING.beef, 1, KILOGRAM)]), step(2, 1, "Ajouter le bouillon et laisser braiser 2h", [ clause("deglaze"), clause("braise"), ]), simpleStep(3, 2, "Dresser", "plate"), ]), recipe(2, "Salade", [ simpleStep(4, 0, "Laver", "mix"), simpleStep(5, 1, "Assaisonner", "season"), simpleStep(6, 2, "Servir", "plate"), ]), ]); const backgrounds = plan.phases.flatMap((p) => p.background.map((b) => b.description)); expect(backgrounds).to.include("Ajouter le bouillon et laisser braiser 2h"); }); it("does NOT treat a step as prep when it mixes a cut with a cooking technique", () => { const recipes = [ recipe(1, "Poêlée A", [ step(1, 0, "Émincer puis faire revenir l'oignon", [ clause("chop", [line(ING.onion, 2, PIECE)]), clause("panFry", [line(ING.onion, 2, PIECE)]), ]), ]), recipe(2, "Poêlée B", [ step(2, 0, "Émincer puis faire revenir l'oignon", [ clause("chop", [line(ING.onion, 2, PIECE)]), clause("panFry", [line(ING.onion, 2, PIECE)]), ]), ]), ]; const plan = optimizeCookingPlan(recipes); // Identical text in two recipes, but the panFry keeps each a cooking // step — no pooling, both survive as their own step task. expect(mergedTasks(plan)).to.have.lengthOf(0); expect(stepTasks(plan)).to.have.lengthOf(2); assertEveryStepAccountedForOnce(recipes, plan); }); it("classifies a step with no detected technique as ordinary active work", () => { const plan = optimizeCookingPlan([ recipe(1, "Libre", [ { stepId: 1, order: 0, description: "Faire quelque chose", techSteps: [] }, simpleStep(2, 1, "Puis autre chose", "mix"), ]), ]); // No technique → not prep, not passive, not setup: it flows through the // cooking phases like any active step, and carries a null technique. const task = stepTasks(plan).find((t) => t.description === "Faire quelque chose"); expect(task, "the no-technique step should still be scheduled").to.not.equal(undefined); expect(task?.technique).to.equal(null); expect(plan.phases.some((p) => p.kind === "mise-en-place")).to.equal(false); }); }); // ===================================================================== // Prep pooling // ===================================================================== describe("prep pooling", () => { it("pools identical prep from two recipes into one merged-prep task with summed quantity", () => { const recipes = [ recipe(1, "Soupe", [ simpleStep(1, 0, "Émincer les oignons", "chop", [line(ING.onion, 2, PIECE)]), simpleStep(2, 1, "Faire mijoter", "simmer"), ]), recipe(2, "Tarte", [ simpleStep(3, 0, "Émincer les oignons", "chop", [line(ING.onion, 3, PIECE)]), simpleStep(4, 1, "Enfourner", "bake"), ]), ]; const plan = optimizeCookingPlan(recipes); const merged = mergedTasks(plan); expect(merged).to.have.lengthOf(1); expect(merged[0]?.technique?.key).to.equal("chop"); expect(merged[0]?.description).to.equal(null); expect(merged[0]?.sourceRecipes.map((r) => r.recipeId)).to.have.members([1, 2]); expect(merged[0]?.ingredients[0]?.quantity).to.equal(5); expect(merged[0]?.ingredients[0]?.unit?.key).to.equal("piece"); expect(merged[0]?.id).to.equal("prep:chop:onion"); assertEveryStepAccountedForOnce(recipes, plan); }); it("pools across THREE recipes and lists every source recipe once", () => { const recipes = [1, 2, 3].map((id) => recipe(id, `Recette ${id}`, [ simpleStep(id * 10, 0, "Presser l'ail", "mince", [line(ING.garlic, 2, PIECE)]), simpleStep(id * 10 + 1, 1, "Cuire", "cook"), ]), ); const plan = optimizeCookingPlan(recipes); const merged = mergedTasks(plan); expect(merged).to.have.lengthOf(1); expect(merged[0]?.sourceRecipes.map((r) => r.recipeId)).to.deep.equal([1, 2, 3]); expect(merged[0]?.ingredients[0]?.quantity).to.equal(6); assertEveryStepAccountedForOnce(recipes, plan); }); it("keeps prep that only one recipe needs as its own step task (no merge)", () => { const recipes = [ recipe(1, "Curry", [ simpleStep(1, 0, "Râper les carottes", "chop", [line(ING.carrot, 200, GRAM)]), simpleStep(2, 1, "Cuire", "cook"), ]), recipe(2, "Gratin", [simpleStep(3, 0, "Cuire au four", "bake")]), ]; const plan = optimizeCookingPlan(recipes); expect(mergedTasks(plan)).to.have.lengthOf(0); const prepTask = allTasks(plan).find((t) => t.description === "Râper les carottes"); expect(prepTask?.kind).to.equal("step"); expect(plan.phases[0]?.kind).to.equal("mise-en-place"); expect(plan.phases[0]?.tasks).to.include(prepTask); assertEveryStepAccountedForOnce(recipes, plan); }); it("does not pool the same ingredient cut with two DIFFERENT techniques", () => { const recipes = [ recipe(1, "A", [simpleStep(1, 0, "Émincer l'oignon", "chop", [line(ING.onion, 1, PIECE)])]), recipe(2, "B", [ simpleStep(2, 0, "Tailler l'oignon en brunoise", "brunoise", [line(ING.onion, 1, PIECE)]), ]), ]; const plan = optimizeCookingPlan(recipes); expect(mergedTasks(plan)).to.have.lengthOf(0); assertEveryStepAccountedForOnce(recipes, plan); }); it("pools a multi-ingredient prep step only when the whole cut signature matches", () => { const matching = [ recipe(1, "Mirepoix A", [ step(1, 0, "Tailler oignon et carotte", [ clause("chop", [line(ING.onion, 1, PIECE)]), clause("chop", [line(ING.carrot, 1, PIECE)]), ]), ]), recipe(2, "Mirepoix B", [ step(2, 0, "Tailler oignon et carotte", [ clause("chop", [line(ING.onion, 2, PIECE)]), clause("chop", [line(ING.carrot, 2, PIECE)]), ]), ]), ]; const plan = optimizeCookingPlan(matching); const merged = mergedTasks(plan); expect(merged).to.have.lengthOf(1); // Two ingredient lines, each summed within its own (ingredient, unit) group. const byKey = Object.fromEntries( (merged[0]?.ingredients ?? []).map((i) => [i.ingredient.key, i.quantity]), ); expect(byKey).to.deep.equal({ carrot: 3, onion: 3 }); assertEveryStepAccountedForOnce(matching, plan); }); it("does NOT pool multi-ingredient prep steps whose signatures differ", () => { const recipes = [ recipe(1, "A", [ step(1, 0, "Tailler oignon et carotte", [ clause("chop", [line(ING.onion, 1, PIECE)]), clause("chop", [line(ING.carrot, 1, PIECE)]), ]), ]), recipe(2, "B", [ step(2, 0, "Tailler oignon et pomme de terre", [ clause("chop", [line(ING.onion, 1, PIECE)]), clause("chop", [line(ING.potato, 1, PIECE)]), ]), ]), ]; const plan = optimizeCookingPlan(recipes); expect(mergedTasks(plan)).to.have.lengthOf(0); assertEveryStepAccountedForOnce(recipes, plan); }); it("drops the quantity when one recipe names the ingredient without a measurable amount", () => { const recipes = [ recipe(1, "A", [ simpleStep(1, 0, "Émincer les oignons", "chop", [line(ING.onion, 2, PIECE)]), ]), recipe(2, "B", [ simpleStep(2, 0, "Émincer les oignons", "chop", [line(ING.onion, null, PIECE)]), ]), ]; const plan = optimizeCookingPlan(recipes); const merged = mergedTasks(plan); expect(merged).to.have.lengthOf(1); expect(merged[0]?.ingredients[0]?.quantity).to.equal(null); expect(merged[0]?.ingredients[0]?.unit).to.equal(null); }); it("unions the utensils of every pooled step, de-duplicated", () => { const recipes = [ recipe(1, "A", [ step(1, 0, "Émincer les oignons", [ clause( "chop", [line(ING.onion, 1, PIECE)], [utensil(1, "knife"), utensil(2, "cuttingBoard")], ), ]), ]), recipe(2, "B", [ step(2, 0, "Émincer les oignons", [ clause( "chop", [line(ING.onion, 1, PIECE)], [utensil(1, "knife"), utensil(3, "mandoline")], ), ]), ]), ]; const plan = optimizeCookingPlan(recipes); const merged = mergedTasks(plan); expect(merged[0]?.utensils.map((u) => u.key)).to.deep.equal([ "cuttingBoard", "knife", "mandoline", ]); }); it("still pools when the SAME recipe is planned twice at different portions", () => { const recipes = [ recipe( 1, "Bolo", [simpleStep(1, 0, "Émincer l'oignon", "chop", [line(ING.onion, 1, PIECE)])], 4, 4, ), recipe( 1, "Bolo", [simpleStep(1, 0, "Émincer l'oignon", "chop", [line(ING.onion, 1, PIECE)])], 8, 4, ), ]; const plan = optimizeCookingPlan(recipes); const merged = mergedTasks(plan); expect(merged).to.have.lengthOf(1); // 1 × (4/4) + 1 × (8/4) = 3 expect(merged[0]?.ingredients[0]?.quantity).to.equal(3); expect(merged[0]?.sourceRecipes).to.have.lengthOf(2); }); it("does not pool a prep clause that resolved no ingredient — it stays a standalone mise-en-place task", () => { const recipes = [ recipe(1, "A", [simpleStep(1, 0, "Émincer finement", "chop", [])]), recipe(2, "B", [simpleStep(2, 0, "Émincer finement", "chop", [])]), ]; const plan = optimizeCookingPlan(recipes); expect(mergedTasks(plan)).to.have.lengthOf(0); // Both are pure-prep with no ingredient → each lands in mise-en-place as its own task. expect(plan.phases[0]?.kind).to.equal("mise-en-place"); expect( plan.phases[0]?.tasks.filter((t) => t.description === "Émincer finement"), ).to.have.lengthOf(2); assertEveryStepAccountedForOnce(recipes, plan); }); }); // ===================================================================== // Quantity scaling // ===================================================================== describe("quantity scaling", () => { it("scales technique-clause quantities by portions / recipePortions", () => { const plan = optimizeCookingPlan([ recipe(1, "Base", [simpleStep(1, 0, "Émincer", "chop", [line(ING.onion, 2, PIECE)])], 8, 4), recipe( 2, "Autre", [simpleStep(2, 0, "Émincer", "chop", [line(ING.onion, 1, PIECE)])], 4, 4, ), ]); const merged = mergedTasks(plan)[0]; // (2 × 8/4) + (1 × 4/4) = 5 expect(merged?.ingredients[0]?.quantity).to.equal(5); }); it("falls back to 1× when recipePortions is zero or missing (bad data guard)", () => { const plan = optimizeCookingPlan([ recipe(1, "A", [simpleStep(1, 0, "Émincer", "chop", [line(ING.onion, 3, PIECE)])], 10, 0), recipe(2, "B", [simpleStep(2, 0, "Émincer", "chop", [line(ING.onion, 1, PIECE)])], 4, 4), ]); const merged = mergedTasks(plan)[0]; // recipe 1 scaled 1× (guard) → 3 ; recipe 2 → 1 ; total 4 expect(merged?.ingredients[0]?.quantity).to.equal(4); }); it("leaves a null-quantity clause null after scaling", () => { const plan = optimizeCookingPlan([ recipe(1, "A", [simpleStep(1, 0, "Saler", "season", [line(ING.onion, null, null)])], 8, 4), ]); const task = stepTasks(plan).find((t) => t.description === "Saler"); expect(task?.ingredients[0]?.quantity).to.equal(null); }); }); // ===================================================================== // Phase scheduling & parallelism // ===================================================================== describe("phase scheduling", () => { it("floats a passive cook into later phases' background while another recipe works, then clears it", () => { const plan = optimizeCookingPlan([ recipe(1, "Ragoût", [ simpleStep(1, 0, "Faire mijoter la viande", "simmer"), simpleStep(2, 1, "Rectifier l'assaisonnement", "season"), simpleStep(3, 2, "Dresser", "plate"), ]), recipe(2, "Salade", [ simpleStep(4, 0, "Mélanger", "mix"), simpleStep(5, 1, "Assaisonner", "season"), simpleStep(6, 2, "Servir", "plate"), ]), ]); const cooking = plan.phases.filter((p) => p.kind !== "mise-en-place"); const withSimmerBg = cooking.filter((p) => p.background.some((b) => b.description === "Faire mijoter la viande"), ); expect(withSimmerBg.length).to.be.greaterThan(0); expect(withSimmerBg[0]?.background[0]?.recipeId).to.equal(1); // Once recipe 1 pops its next step, the simmer stops being background. const lastPhase = plan.phases[plan.phases.length - 1]; expect( lastPhase?.background.some((b) => b.description === "Faire mijoter la viande"), ).to.equal(false); }); it("does not surface a single recipe's own passive cook as its own background (nothing else to do)", () => { const plan = optimizeCookingPlan([ recipe(1, "Soupe", [ simpleStep(1, 0, "Mijoter", "simmer"), simpleStep(2, 1, "Mixer", "mix"), ]), ]); const backgrounds = plan.phases.flatMap((p) => p.background); expect(backgrounds).to.deep.equal([]); }); it("marks the last phase 'finishing' when it holds only plating", () => { const plan = optimizeCookingPlan([ recipe(1, "A", [simpleStep(1, 0, "Cuire", "cook"), simpleStep(2, 1, "Dresser", "plate")]), recipe(2, "B", [simpleStep(3, 0, "Cuire", "cook"), simpleStep(4, 1, "Dresser", "plate")]), ]); const last = plan.phases[plan.phases.length - 1]; expect(last?.kind).to.equal("finishing"); expect(last?.tasks.every((t) => t.technique?.key === "plate")).to.equal(true); }); it("keeps a mixed final phase as 'cooking', not 'finishing'", () => { const plan = optimizeCookingPlan([ recipe(1, "A", [simpleStep(1, 0, "Dresser", "plate")]), recipe(2, "B", [simpleStep(2, 0, "Étape 1", "mix"), simpleStep(3, 1, "Étape 2", "cook")]), ]); expect(plan.phases.every((p) => p.kind !== "finishing")).to.equal(true); }); it("breaks the stall when every remaining recipe is holding on a passive cook", () => { // Both recipes: a passive step then a final step. After each starts its // passive cook they both 'hold' the next phase — the optimizer must // release the holds rather than loop or emit an empty phase. const plan = optimizeCookingPlan([ recipe(1, "A", [ simpleStep(1, 0, "Mijoter A", "simmer"), simpleStep(2, 1, "Finir A", "plate"), ]), recipe(2, "B", [ simpleStep(3, 0, "Rôtir B", "roast"), simpleStep(4, 1, "Finir B", "plate"), ]), ]); expect(plan.phases.every((p) => p.tasks.length > 0)).to.equal(true); assertEveryStepAccountedForOnce( [ recipe(1, "A", [ simpleStep(1, 0, "Mijoter A", "simmer"), simpleStep(2, 1, "Finir A", "plate"), ]), recipe(2, "B", [ simpleStep(3, 0, "Rôtir B", "roast"), simpleStep(4, 1, "Finir B", "plate"), ]), ], plan, ); }); it("omits the mise-en-place phase entirely when there is no prep and no setup, and re-indexes from 0", () => { const plan = optimizeCookingPlan([ recipe(1, "A", [simpleStep(1, 0, "Cuire", "cook"), simpleStep(2, 1, "Dresser", "plate")]), ]); expect(plan.phases[0]?.kind).to.not.equal("mise-en-place"); assertPhaseIndices(plan); }); it("starts the long cook first within a phase (passive tasks sorted ahead of active ones)", () => { const plan = optimizeCookingPlan([ recipe(1, "Active", [simpleStep(1, 0, "Touiller", "mix")]), recipe(2, "Passive", [simpleStep(2, 0, "Mettre à mijoter", "simmer")]), ]); const firstCooking = plan.phases.find((p) => p.kind !== "mise-en-place"); expect(firstCooking?.tasks[0]?.description).to.equal("Mettre à mijoter"); }); }); // ===================================================================== // Production-shaped datasets: a household's full week // ===================================================================== describe("full-week planning (production-shaped)", () => { /** * A realistic household week — seven dinners, each a genuine * multi-step recipe. Onion / garlic / carrot / parsley recur across * several recipes with a real prep step, so the optimizer has plenty * to pool; several recipes have a long passive cook (simmer / bake / * roast / braise) to exercise the background scheduler. */ function householdWeek(): OptimizerRecipeInput[] { return [ recipe(101, "Soupe à l'oignon", [ simpleStep(1, 0, "Émincer les oignons", "chop", [line(ING.onion, 6, PIECE)]), simpleStep(2, 1, "Faire suer les oignons au beurre", "sweat", [ line(ING.butter, 40, GRAM), ]), simpleStep(3, 2, "Mouiller au bouillon et laisser mijoter 30 min", "simmer"), simpleStep(4, 3, "Gratiner au four", "bake", [line(ING.cheese, 150, GRAM)]), simpleStep(5, 4, "Servir bien chaud", "plate"), ]), recipe(102, "Bœuf bourguignon", [ simpleStep(10, 0, "Tailler les carottes", "chop", [line(ING.carrot, 400, GRAM)]), simpleStep(11, 1, "Émincer les oignons", "chop", [line(ING.onion, 3, PIECE)]), simpleStep(12, 2, "Colorer la viande", "brown", [line(ING.beef, 1200, GRAM)]), simpleStep(13, 3, "Déglacer au vin rouge", "deglaze"), simpleStep(14, 4, "Braiser 3 h à couvert", "braise"), simpleStep(15, 5, "Dresser", "plate"), ]), recipe(103, "Curry de lentilles", [ simpleStep(20, 0, "Émincer les oignons", "chop", [line(ING.onion, 2, PIECE)]), simpleStep(21, 1, "Presser l'ail", "mince", [line(ING.garlic, 3, PIECE)]), simpleStep(22, 2, "Faire revenir les épices", "panFry"), simpleStep(23, 3, "Ajouter lentilles et tomates, mijoter 25 min", "simmer", [ line(ING.lentils, 300, GRAM), line(ING.tomato, 400, GRAM), ]), simpleStep(24, 4, "Parsemer de persil", "plate", [line(ING.parsley, null, null)]), ]), recipe(104, "Poulet rôti & pommes de terre", [ simpleStep(30, 0, "Préchauffer le four à 210°C", "preheat"), simpleStep(31, 1, "Éplucher les pommes de terre", "peel", [ line(ING.potato, 1, KILOGRAM), ]), simpleStep(32, 2, "Presser l'ail", "mince", [line(ING.garlic, 4, PIECE)]), simpleStep(33, 3, "Enfourner le poulet 1 h 15", "roast", [line(ING.chicken, 1600, GRAM)]), simpleStep(34, 4, "Laisser reposer 10 min", "rest"), simpleStep(35, 5, "Découper et dresser", "plate"), ]), recipe(105, "Gratin dauphinois", [ simpleStep(40, 0, "Préchauffer le four à 180°C", "preheat"), simpleStep(41, 1, "Éplucher les pommes de terre", "peel", [ line(ING.potato, 1, KILOGRAM), ]), simpleStep(42, 2, "Émincer finement à la mandoline", "chop", [ line(ING.potato, 1, KILOGRAM), ]), simpleStep(43, 3, "Monter le gratin avec la crème", "coat", [line(ING.cream, 500, GRAM)]), simpleStep(44, 4, "Cuire 1 h au four", "bake"), ]), recipe(106, "Risotto aux champignons", [ simpleStep(50, 0, "Émincer les oignons", "chop", [line(ING.onion, 1, PIECE)]), simpleStep(51, 1, "Nacrer le riz", "panFry", [line(ING.rice, 320, GRAM)]), simpleStep(52, 2, "Mouiller louche à louche 18 min", "simmer"), simpleStep(53, 3, "Lier au beurre et parmesan", "mix", [line(ING.cheese, 80, GRAM)]), simpleStep(54, 4, "Servir aussitôt", "plate"), ]), recipe(107, "Salade & omelette", [ simpleStep(60, 0, "Battre les œufs", "whisk", [line(ING.egg, 6, PIECE)]), simpleStep(61, 1, "Hacher le persil", "chop", [line(ING.parsley, 20, GRAM)]), simpleStep(62, 2, "Cuire l'omelette", "cook"), simpleStep(63, 3, "Assaisonner la salade", "season"), simpleStep(64, 4, "Servir", "plate"), ]), ]; } it("produces a coherent plan: mise-en-place first, every phase non-empty, indices 0..n-1", () => { const week = householdWeek(); const plan = optimizeCookingPlan(week); expect(plan.recipes).to.have.lengthOf(week.length); expect(plan.phases.length).to.be.greaterThan(1); expect(plan.phases[0]?.kind).to.equal("mise-en-place"); expect(plan.phases.every((p) => p.tasks.length > 0)).to.equal(true); assertPhaseIndices(plan); assertUniqueIds(plan); }); it("accounts for every one of the ~40 planned steps exactly once", () => { const week = householdWeek(); const plan = optimizeCookingPlan(week); assertEveryStepAccountedForOnce(week, plan); }); it("pools the shared knife work — onion, garlic and potato each become one merged-prep task", () => { const week = householdWeek(); const plan = optimizeCookingPlan(week); const merged = mergedTasks(plan); const byIngredient = merged.map((t) => t.ingredients[0]?.ingredient.key).sort(); // onion: recipes 101,102,103,106 — garlic: 103,104 — potato peel: 104,105 expect(byIngredient).to.include("onion"); expect(byIngredient).to.include("garlic"); expect(byIngredient).to.include("potato"); const onionPool = merged.find((t) => t.ingredients[0]?.ingredient.key === "onion"); expect(onionPool?.sourceRecipes.map((r) => r.recipeId)).to.have.members([101, 102, 103, 106]); // 6 + 3 + 2 + 1 pieces expect(onionPool?.ingredients[0]?.quantity).to.equal(12); expect(onionPool?.technique?.key).to.equal("chop"); }); it("pulls every 'préchauffer le four' into the mise-en-place phase", () => { const week = householdWeek(); const plan = optimizeCookingPlan(week); const startsWithPreheat = (d: string | null) => (d ?? "").startsWith("Préchauffer le four"); const miseDescr = plan.phases[0]?.tasks.map((t) => t.description) ?? []; expect(miseDescr.filter(startsWithPreheat)).to.have.lengthOf(2); const laterDescr = plan.phases.slice(1).flatMap((p) => p.tasks.map((t) => t.description)); expect(laterDescr.some(startsWithPreheat)).to.equal(false); }); it("runs the long cooks in the background of later phases (bourguignon braise, poulet rôti, gratins)", () => { const week = householdWeek(); const plan = optimizeCookingPlan(week); const bgDescr = plan.phases.flatMap((p) => p.background.map((b) => b.description)); expect(bgDescr.some((d) => d.includes("Braiser 3 h"))).to.equal(true); expect(bgDescr.some((d) => d.includes("Enfourner le poulet"))).to.equal(true); // Every background line is the echo of a real passive step scheduled earlier. const passiveStepIds = new Set(plan.phases.flatMap((p) => p.tasks).map((t) => t.id)); for (const phase of plan.phases) { for (const bg of phase.background) { expect(passiveStepIds.has(bg.id.replace(/^bg:/, ""))).to.equal(true); } } }); it("never emits a merged-prep for a cut that lives inside a cooking step (mandoline slice in the gratin)", () => { // Recipe 105 step 42 "Émincer finement à la mandoline" IS pure prep // (chop only) on potato — it should be eligible to pool with 105's own // peel? No: different technique. It pools with nothing else here // because no other recipe chops potato. So it stays a solo mise task. const week = householdWeek(); const plan = optimizeCookingPlan(week); const mandoline = allTasks(plan).find( (t) => t.description === "Émincer finement à la mandoline", ); expect(mandoline?.kind).to.equal("step"); expect(plan.phases[0]?.tasks).to.include(mandoline); }); it("is deterministic on the full week", () => { const a = optimizeCookingPlan(householdWeek()); const b = optimizeCookingPlan(householdWeek()); expect(JSON.stringify(a)).to.equal(JSON.stringify(b)); }); }); // ===================================================================== // Two people / two households combined into one big cook // ===================================================================== describe("multiple people — combined week", () => { /** Alice's 3 dinners + Bob's 3 dinners, all pooled into one session. */ function combinedWeek(): OptimizerRecipeInput[] { const alice: OptimizerRecipeInput[] = [ recipe( 201, "Alice — Chili", [ simpleStep(1, 0, "Émincer les oignons", "chop", [line(ING.onion, 2, PIECE)]), simpleStep(2, 1, "Presser l'ail", "mince", [line(ING.garlic, 2, PIECE)]), simpleStep(3, 2, "Mijoter 40 min", "simmer"), simpleStep(4, 3, "Servir", "plate"), ], 2, 4, ), recipe( 202, "Alice — Ratatouille", [ simpleStep(10, 0, "Tailler les tomates", "concasse", [line(ING.tomato, 500, GRAM)]), simpleStep(11, 1, "Émincer les oignons", "chop", [line(ING.onion, 1, PIECE)]), simpleStep(12, 2, "Compoter à feu doux", "compote"), simpleStep(13, 3, "Dresser", "plate"), ], 2, 4, ), recipe( 203, "Alice — Salade de lentilles", [ simpleStep(20, 0, "Cuire les lentilles", "boil", [line(ING.lentils, 200, GRAM)]), simpleStep(21, 1, "Hacher le persil", "chop", [line(ING.parsley, 15, GRAM)]), simpleStep(22, 2, "Assaisonner", "season"), ], 2, 4, ), ]; const bob: OptimizerRecipeInput[] = [ recipe( 301, "Bob — Bolognaise", [ simpleStep(30, 0, "Émincer les oignons", "chop", [line(ING.onion, 2, PIECE)]), simpleStep(31, 1, "Tailler les carottes", "chop", [line(ING.carrot, 200, GRAM)]), simpleStep(32, 2, "Colorer la viande", "brown", [line(ING.beef, 500, GRAM)]), simpleStep(33, 3, "Mijoter 1 h", "simmer"), simpleStep(34, 4, "Servir", "plate"), ], 3, 4, ), recipe( 302, "Bob — Poulet basquaise", [ simpleStep(40, 0, "Émincer les oignons", "chop", [line(ING.onion, 2, PIECE)]), simpleStep(41, 1, "Presser l'ail", "mince", [line(ING.garlic, 2, PIECE)]), simpleStep(42, 2, "Saisir le poulet", "brown", [line(ING.chicken, 800, GRAM)]), simpleStep(43, 3, "Mijoter 35 min", "simmer"), simpleStep(44, 4, "Dresser", "plate"), ], 3, 4, ), recipe( 303, "Bob — Riz pilaf", [ simpleStep(50, 0, "Nacrer le riz", "panFry", [line(ING.rice, 250, GRAM)]), simpleStep(51, 1, "Cuire couvert 17 min", "simmer"), simpleStep(52, 2, "Égrainer et servir", "plate"), ], 3, 4, ), ]; return [...alice, ...bob]; } it("pools knife work across both people's recipes", () => { const week = combinedWeek(); const plan = optimizeCookingPlan(week); const merged = mergedTasks(plan); const onionPool = merged.find((t) => t.ingredients[0]?.ingredient.key === "onion"); // onion chopped in 201, 202, 301, 302 expect(onionPool?.sourceRecipes.map((r) => r.recipeId)).to.have.members([201, 202, 301, 302]); // 2×(2/4) + 1×(2/4) + 2×(3/4) + 2×(3/4) = 1 + 0.5 + 1.5 + 1.5 = 4.5 expect(onionPool?.ingredients[0]?.quantity).to.equal(4.5); const garlicPool = merged.find((t) => t.ingredients[0]?.ingredient.key === "garlic"); expect(garlicPool?.sourceRecipes.map((r) => r.recipeId)).to.have.members([201, 302]); }); it("accounts for every step and keeps every id unique across the combined plan", () => { const week = combinedWeek(); const plan = optimizeCookingPlan(week); assertEveryStepAccountedForOnce(week, plan); assertUniqueIds(plan); assertPhaseIndices(plan); }); it("still schedules six simultaneous simmers without an empty or infinite phase", () => { const week = combinedWeek(); const plan = optimizeCookingPlan(week); expect(plan.phases.every((p) => p.tasks.length > 0)).to.equal(true); // Every recipe's very last step is scheduled somewhere. const stepTaskIds = new Set(stepTasks(plan).map((t) => t.id)); week.forEach((r, recipeIndex) => { const lastStep = [...r.steps].sort((a, b) => a.order - b.order).at(-1); expect( stepTaskIds.has(`step:${recipeIndex}:${lastStep?.stepId}`), `${r.name}'s last step should be scheduled`, ).to.equal(true); }); }); it("orders the plan so all pooled prep is done before any recipe's cooking step", () => { const week = combinedWeek(); const plan = optimizeCookingPlan(week); const firstCookingPhaseIndex = plan.phases.findIndex((p) => p.kind !== "mise-en-place"); const misePhases = plan.phases.slice(0, firstCookingPhaseIndex); // All merged-prep tasks live in the mise-en-place phase(s). const mergedOutsideMise = plan.phases .slice(firstCookingPhaseIndex) .flatMap((p) => p.tasks) .filter((t) => t.kind === "merged-prep"); expect(mergedOutsideMise).to.deep.equal([]); expect(misePhases.length).to.equal(1); }); }); });