batchCooking/apps/api/test/recipe-matching/cooking-optimizer.test.ts
Nicolas 83a12d474d feat(cooking): moteur d'optimisation des etapes de batch-cooking
Coeur pur du module "Calcul batch-cooking" (specs/batch-cooking-architecture.md,
jusqu'ici TODO) : optimizeCookingPlan() prend les recettes planifiees d'une
semaine et les reorganise en phases ordonnees.

- Mutualisation de la mise en place : une meme technique de decoupe (chop, peel,
  mince...) appliquee au meme ingredient par >= 2 recettes est regroupee en une
  seule tache "merged-prep" (les oignons de plusieurs recettes = une decoupe).
- Parallelisme : les cuissons passives (simmer, braise, bake, marinate...) sont
  poussees en tache de fond des phases suivantes pendant qu'une autre recette
  avance en actif.
- Fonction pure sans base (meme split matchXxx pur / loadXxx DB-backed que
  ingredient-matcher / tech-step-matcher), testable en isolation.

Types partages : OptimizedCookingPlanView + schema getCookingSessionSchema.
Tests Mocha purs (6 cas) : merge, non-merge d'une decoupe solo, tache de fond,
mise a l'echelle par portions, plan vide, legende.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-28 19:06:24 +02:00

1024 lines
41 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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<string>();
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<string>();
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);
});
});
});