Dans l'onglet Plan de /cuisiner, la liste de tâches d'une phase était un flux plat — la recette concernée n'apparaissait qu'en petite ligne sous chaque carte. Regroupe désormais les tâches par combinaison exacte de recettes (groupTasksByRecipes, cooking-session.ts) : chaque groupe devient une mini-section titrée par le(s) nom(s) de recette(s), un pool merged-prep/merged-setup formant sa propre section partagée plutôt que d'être rattaché à une seule recette. La ligne "pour X" par carte devient redondante avec le titre de section et est retirée (clé i18n forRecipes supprimée). Couverture ajoutée : cooking-session-page.cy.ts (mode layout, 3 sections distinctes sur la fixture à préchauffages multiples) + cooking-session.feature (parcours Gherkin, section partagée + sections solo). Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
367 lines
13 KiB
TypeScript
367 lines
13 KiB
TypeScript
// Mocks the API via cy.intercept — this job doesn't run a live backend (see
|
|
// .github/workflows/ci.yml); apps/api's own Mocha suite covers real
|
|
// `GET /cooking-session` behavior (including the optimizer) against a real
|
|
// database.
|
|
|
|
const authenticatedProfile = {
|
|
id: 1,
|
|
firstName: "Alice",
|
|
lastName: "Martin",
|
|
email: "alice@example.com",
|
|
tokenVersion: 0,
|
|
houseId: 1,
|
|
dietId: null,
|
|
};
|
|
|
|
// 2026-08-17 is a Monday — frozen so "this week" is deterministic.
|
|
const TODAY = new Date("2026-08-17T09:00:00Z");
|
|
|
|
/** Bare `IngredientView` — only `key` drives the page's label lookup. */
|
|
function ingredient(key: string) {
|
|
return {
|
|
id: 1,
|
|
key,
|
|
icon: "VEGETABLE",
|
|
category: "freshProduce",
|
|
subcategory: "vegetables",
|
|
reproducible: false,
|
|
allergens: [],
|
|
diets: [],
|
|
};
|
|
}
|
|
|
|
/** A 3-phase plan: a pooled prep task in mise-en-place, then two cooking phases, the last with a passive cook floated into its background. */
|
|
function planFixture() {
|
|
return {
|
|
startDate: "2026-08-17T00:00:00.000Z",
|
|
finishDate: "2026-08-23T00:00:00.000Z",
|
|
recipes: [
|
|
{ recipeId: 1, name: "Soupe à l'oignon", portions: 4 },
|
|
{ recipeId: 2, name: "Tarte à l'oignon", portions: 6 },
|
|
],
|
|
phases: [
|
|
{
|
|
index: 0,
|
|
kind: "mise-en-place",
|
|
background: [],
|
|
notes: [],
|
|
tasks: [
|
|
{
|
|
id: "prep:chop:onion",
|
|
kind: "merged-prep",
|
|
technique: { id: 1, key: "chop" },
|
|
description: null,
|
|
ingredients: [
|
|
{
|
|
ingredient: ingredient("onion"),
|
|
quantity: 5,
|
|
unit: { id: 2, key: "piece", type: "COUNT", toBaseFactor: 1 },
|
|
},
|
|
],
|
|
utensils: [{ id: 1, key: "knife" }],
|
|
temperatures: [],
|
|
sourceRecipes: [
|
|
{ recipeId: 1, name: "Soupe à l'oignon", portions: 4 },
|
|
{ recipeId: 2, name: "Tarte à l'oignon", portions: 6 },
|
|
],
|
|
originalSteps: [],
|
|
},
|
|
],
|
|
},
|
|
{
|
|
index: 1,
|
|
kind: "cooking",
|
|
background: [],
|
|
notes: [],
|
|
tasks: [
|
|
{
|
|
id: "step:0:1",
|
|
kind: "step",
|
|
technique: { id: 5, key: "simmer" },
|
|
description: "Faire mijoter le bouillon",
|
|
ingredients: [],
|
|
utensils: [],
|
|
temperatures: [],
|
|
sourceRecipes: [{ recipeId: 1, name: "Soupe à l'oignon", portions: 4 }],
|
|
originalSteps: [],
|
|
},
|
|
],
|
|
},
|
|
{
|
|
index: 2,
|
|
kind: "cooking",
|
|
background: [
|
|
{
|
|
id: "bg:step:0:1",
|
|
technique: { id: 5, key: "simmer" },
|
|
description: "Faire mijoter le bouillon",
|
|
recipeId: 1,
|
|
recipeName: "Soupe à l'oignon",
|
|
},
|
|
],
|
|
notes: [],
|
|
tasks: [
|
|
{
|
|
id: "step:1:3",
|
|
kind: "step",
|
|
technique: { id: 4, key: "bake" },
|
|
description: "Enfourner la tarte",
|
|
ingredients: [],
|
|
utensils: [],
|
|
temperatures: [],
|
|
sourceRecipes: [{ recipeId: 2, name: "Tarte à l'oignon", portions: 6 }],
|
|
originalSteps: [],
|
|
},
|
|
],
|
|
},
|
|
],
|
|
};
|
|
}
|
|
|
|
/**
|
|
* A one-phase plan whose mise-en-place has a pooled `merged-setup` preheat
|
|
* (both recipes, same 180 °C) plus two un-merged preheats at 200 °C / 220 °C
|
|
* that trigger an `appliance-temperature-conflict` note.
|
|
*/
|
|
function setupPlanFixture() {
|
|
const soupe = { recipeId: 1, name: "Soupe", portions: 4 };
|
|
const gratin = { recipeId: 2, name: "Gratin", portions: 4 };
|
|
const roti = { recipeId: 3, name: "Rôti", portions: 4 };
|
|
return {
|
|
startDate: "2026-08-17T00:00:00.000Z",
|
|
finishDate: "2026-08-23T00:00:00.000Z",
|
|
recipes: [soupe, gratin, roti],
|
|
phases: [
|
|
{
|
|
index: 0,
|
|
kind: "mise-en-place",
|
|
background: [],
|
|
notes: [
|
|
{
|
|
kind: "appliance-temperature-conflict",
|
|
technique: { id: 9, key: "preheat" },
|
|
temperatures: [
|
|
{ celsius: 200, gasMark: null, qualitative: null },
|
|
{ celsius: 220, gasMark: null, qualitative: null },
|
|
],
|
|
},
|
|
],
|
|
tasks: [
|
|
{
|
|
id: "setup:preheat:c180",
|
|
kind: "merged-setup",
|
|
technique: { id: 9, key: "preheat" },
|
|
description: null,
|
|
ingredients: [],
|
|
utensils: [],
|
|
temperatures: [{ celsius: 180, gasMark: null, qualitative: null }],
|
|
sourceRecipes: [soupe, gratin],
|
|
originalSteps: [],
|
|
},
|
|
{
|
|
id: "step:1:10",
|
|
kind: "step",
|
|
technique: { id: 9, key: "preheat" },
|
|
description: "Préchauffer le four à 200°C",
|
|
ingredients: [],
|
|
utensils: [],
|
|
temperatures: [{ celsius: 200, gasMark: null, qualitative: null }],
|
|
sourceRecipes: [gratin],
|
|
originalSteps: [],
|
|
},
|
|
{
|
|
id: "step:2:20",
|
|
kind: "step",
|
|
technique: { id: 9, key: "preheat" },
|
|
description: "Préchauffer le four à 220°C",
|
|
ingredients: [],
|
|
utensils: [],
|
|
temperatures: [{ celsius: 220, gasMark: null, qualitative: null }],
|
|
sourceRecipes: [roti],
|
|
originalSteps: [],
|
|
},
|
|
],
|
|
},
|
|
],
|
|
};
|
|
}
|
|
|
|
describe("Cooking session page", () => {
|
|
beforeEach(() => {
|
|
cy.viewport(1400, 900);
|
|
cy.clock(TODAY, ["Date"]);
|
|
cy.intercept("GET", "**/auth/me", { statusCode: 200, body: authenticatedProfile });
|
|
});
|
|
|
|
it("shows the empty message when nothing is planned that week", () => {
|
|
cy.intercept("GET", /\/cooking-session\?/, {
|
|
statusCode: 200,
|
|
body: {
|
|
startDate: "2026-08-17T00:00:00.000Z",
|
|
finishDate: "2026-08-23T00:00:00.000Z",
|
|
recipes: [],
|
|
phases: [],
|
|
},
|
|
});
|
|
|
|
cy.visit("/cuisiner");
|
|
|
|
cy.contains("h1", "Cuisiner cette semaine").should("be.visible");
|
|
cy.contains("Rien de planifié cette semaine à cuisiner").should("be.visible");
|
|
});
|
|
|
|
it("opens on the Plan tab: a stepper of every phase, only the first phase's tasks shown", () => {
|
|
cy.intercept("GET", /\/cooking-session\?/, { statusCode: 200, body: planFixture() }).as(
|
|
"getPlan",
|
|
);
|
|
|
|
cy.visit("/cuisiner?date=2026-08-17");
|
|
cy.wait("@getPlan").its("request.url").should("include", "date=2026-08-17");
|
|
|
|
// One stepper node per phase; the first is the current one.
|
|
cy.get(".cooking-stepper__node").should("have.length", 3);
|
|
cy.get(".cooking-stepper__node").first().should("have.class", "cooking-stepper__node--current");
|
|
cy.contains(".cooking-plan__position", "Étape 1 / 3").should("be.visible");
|
|
cy.contains(".cooking-plan__count", "0 / 1 tâches").should("be.visible");
|
|
|
|
// Mise en place: the pooled prep task, flagged shared, naming both recipes.
|
|
cy.get(".cooking-task--merged-prep")
|
|
.should("contain.text", "Hacher")
|
|
.and("contain.text", "Oignon")
|
|
.and("contain.text", "Mutualisé");
|
|
|
|
// It sits in one mini section headed with both recipes' names.
|
|
cy.get(".cooking-phase__recipe-group-title").should(
|
|
"have.text",
|
|
"Soupe à l'oignon, Tarte à l'oignon",
|
|
);
|
|
|
|
// The next phase's step is not rendered yet.
|
|
cy.contains("Faire mijoter le bouillon").should("not.exist");
|
|
|
|
// Prev is disabled on the first phase.
|
|
cy.contains(".cooking-plan__nav-btn", "Étape précédente").should("be.disabled");
|
|
});
|
|
|
|
it("walks the phases with the next/prev buttons and by clicking a stepper node", () => {
|
|
cy.intercept("GET", /\/cooking-session\?/, { statusCode: 200, body: planFixture() });
|
|
cy.visit("/cuisiner?date=2026-08-17");
|
|
|
|
// Forward to phase 2.
|
|
cy.contains(".cooking-plan__nav-btn", "Étape suivante").click();
|
|
cy.contains(".cooking-plan__position", "Étape 2 / 3").should("be.visible");
|
|
cy.contains(".cooking-phase__tasks", "Faire mijoter le bouillon").should("be.visible");
|
|
|
|
// Forward to phase 3 — its background band shows the soup still simmering.
|
|
cy.contains(".cooking-plan__nav-btn", "Étape suivante").click();
|
|
cy.contains(".cooking-plan__position", "Étape 3 / 3").should("be.visible");
|
|
cy.contains(".cooking-phase__background", "Pendant ce temps")
|
|
.should("contain.text", "Faire mijoter le bouillon")
|
|
.and("contain.text", "Soupe à l'oignon");
|
|
cy.contains(".cooking-phase__tasks", "Enfourner la tarte").should("be.visible");
|
|
cy.contains(".cooking-plan__nav-btn", "Étape suivante").should("be.disabled");
|
|
|
|
// Back one step.
|
|
cy.contains(".cooking-plan__nav-btn", "Étape précédente").click();
|
|
cy.contains(".cooking-plan__position", "Étape 2 / 3").should("be.visible");
|
|
|
|
// Jump straight back to the first phase from the stepper.
|
|
cy.get(".cooking-stepper__node").first().click();
|
|
cy.contains(".cooking-plan__position", "Étape 1 / 3").should("be.visible");
|
|
cy.get(".cooking-task--merged-prep").should("be.visible");
|
|
});
|
|
|
|
it("ticks a task, marks its phase done in the stepper, and keeps the tick after a reload", () => {
|
|
cy.intercept("GET", /\/cooking-session\?/, { statusCode: 200, body: planFixture() });
|
|
cy.visit("/cuisiner?date=2026-08-17");
|
|
|
|
cy.get(".cooking-task").first().find('input[type="checkbox"]').check({ force: true });
|
|
cy.get(".cooking-task").first().should("have.class", "cooking-task--done");
|
|
// The whole phase is now done — its stepper node reflects that.
|
|
cy.get(".cooking-stepper__node").first().should("have.class", "cooking-stepper__node--done");
|
|
cy.contains(".cooking-plan__count", "1 / 1 tâches").should("be.visible");
|
|
|
|
cy.reload();
|
|
|
|
cy.get(".cooking-task").first().should("have.class", "cooking-task--done");
|
|
cy.get(".cooking-stepper__node").first().should("have.class", "cooking-stepper__node--done");
|
|
});
|
|
|
|
it("lists the week's recipes on the Recettes tab, each linking to its own page", () => {
|
|
cy.intercept("GET", /\/cooking-session\?/, { statusCode: 200, body: planFixture() });
|
|
cy.visit("/cuisiner?date=2026-08-17");
|
|
|
|
cy.contains(".cooking-session-page__tab", "Recettes").click();
|
|
|
|
cy.get(".cooking-recipes__item").should("have.length", 2);
|
|
cy.contains(".cooking-recipes__item", "Soupe à l'oignon")
|
|
.find("a")
|
|
.should("have.attr", "href", "/recettes/1");
|
|
cy.contains(".cooking-recipes__item", "Tarte à l'oignon")
|
|
.should("contain.text", "6 portions")
|
|
.find("a")
|
|
.should("have.attr", "href", "/recettes/2");
|
|
|
|
// The stepper isn't part of this tab.
|
|
cy.get(".cooking-stepper").should("not.exist");
|
|
});
|
|
|
|
it("renders a merged-setup preheat with its temperature and the oven-conflict note", () => {
|
|
cy.intercept("GET", /\/cooking-session\?/, { statusCode: 200, body: setupPlanFixture() });
|
|
cy.visit("/cuisiner?date=2026-08-17");
|
|
|
|
// The pooled preheat shows the technique label + its pooled temperature,
|
|
// flagged shared like a merged-prep task.
|
|
cy.get(".cooking-task--merged-setup")
|
|
.should("contain.text", "180 °C")
|
|
.and("contain.text", "Mutualisé");
|
|
|
|
// The phase warns the oven is needed at two more temperatures, in order.
|
|
cy.get(".cooking-phase__note")
|
|
.should("contain.text", "2 températures")
|
|
.and("contain.text", "200 °C puis 220 °C");
|
|
|
|
// The two un-merged preheats are still listed as their own tasks.
|
|
cy.contains(".cooking-task", "Préchauffer le four à 200°C").should("be.visible");
|
|
cy.contains(".cooking-task", "Préchauffer le four à 220°C").should("be.visible");
|
|
cy.contains(".cooking-plan__count", "0 / 3 tâches").should("be.visible");
|
|
});
|
|
|
|
it("splits the phase into mini sections by recipe — pooled task first, then each solo preheat under its own recipe", () => {
|
|
cy.intercept("GET", /\/cooking-session\?/, { statusCode: 200, body: setupPlanFixture() });
|
|
cy.visit("/cuisiner?date=2026-08-17");
|
|
|
|
// Three distinct recipe combinations in this phase's tasks: the pooled
|
|
// preheat (Soupe + Gratin), then each un-merged preheat solo.
|
|
const titles = () => cy.get(".cooking-phase__recipe-group-title");
|
|
titles().should("have.length", 3);
|
|
titles().eq(0).should("have.text", "Soupe, Gratin");
|
|
titles().eq(1).should("have.text", "Gratin");
|
|
titles().eq(2).should("have.text", "Rôti");
|
|
|
|
// Each section holds only its own task.
|
|
cy.get(".cooking-phase__recipe-group")
|
|
.eq(0)
|
|
.find(".cooking-task--merged-setup")
|
|
.should("exist");
|
|
cy.get(".cooking-phase__recipe-group")
|
|
.eq(1)
|
|
.should("contain.text", "Préchauffer le four à 200°C")
|
|
.and("not.contain.text", "220°C");
|
|
cy.get(".cooking-phase__recipe-group")
|
|
.eq(2)
|
|
.should("contain.text", "Préchauffer le four à 220°C")
|
|
.and("not.contain.text", "200°C");
|
|
});
|
|
|
|
it("shows an error state when the request fails", () => {
|
|
cy.intercept("GET", /\/cooking-session\?/, {
|
|
statusCode: 500,
|
|
body: { code: 5000, message: "boom" },
|
|
});
|
|
|
|
cy.visit("/cuisiner");
|
|
|
|
cy.contains("Impossible de charger").should("be.visible");
|
|
});
|
|
});
|