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>
1024 lines
41 KiB
TypeScript
1024 lines
41 KiB
TypeScript
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);
|
||
});
|
||
});
|
||
});
|