Correction de comportement sur la gestion des recettes de sources externes — l'implémentation précédente avait dérivé d'une lecture erronée du besoin : - Plus aucun bouton d'import nulle part. Parcourir une source (RecipesPage, hors planning) ne fait plus jamais que prévisualiser — RecipeDetailPanel n'affiche plus de lien "Importer cette recette", seulement un bouton icône discret vers la page d'origine quand la recette en a une (nouveau .recipe-detail-panel__source-link, même emplacement que l'étoile favori). - Une recette externe n'est importée dans la base qu'au moment où quelqu'un l'ajoute effectivement à son planning — jamais avant. RecipePickerDialog.handleSelectDraftItem est désormais le seul endroit de toute l'appli qui importe quoi que ce soit : cliquer sur un item pas encore importé y déclenche une tentative d'import transparente (POST /sources/.../import puis POST /planning/items), sans écran intermédiaire, dès que rien ne manque (tryBuildCompleteImport, nouveau apps/web/src/features/recipes/ recipe-import-draft.ts). Seul un ingrédient non résolu (ou une erreur réseau) fait encore basculer vers l'écran de revue existant (ImportRecipePage), pré-rempli, pour compléter ce qui manque. - RecipeSourcesPanel gagne onSelectDraftItem (remplace planningSlot, qui n'a plus de raison d'être puisqu'il n'y a plus de lien d'import à qui le transmettre) : quand ce callback est fourni (RecipePickerDialog uniquement), un item pas encore importé n'est plus prévisualisé sur place, il est remonté tel quel à l'appelant. Tests : - planning.feature : le scénario existant retire l'étape "je clique le lien Importer cette recette" (redirection désormais automatique puisque le draft de test a un ingrédient non résolu) ; nouveau scénario pour le chemin transparent (draft entièrement résolu, aucun écran de revue). - recipe-sources.feature : le scénario qui important depuis /recettes (hors planning) est supprimé — cette capacité n'existe plus hors planning. Le scénario de deep-link vérifie maintenant l'absence du bouton d'import et la présence du lien discret. - pnpm exec tsc -b --force (web) — propre. - pnpm exec biome check — propre. - pnpm --filter web build — propre. - Cypress non exécutable localement sur cette machine (crash GPU Electron connu) — scénarios vérifiés par relecture attentive contre le markup/les clés i18n réels ; CI (GitHub Actions) fera foi à l'exécution. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
301 lines
9.5 KiB
TypeScript
301 lines
9.5 KiB
TypeScript
import { Given, Then, When } from "@badeball/cypress-cucumber-preprocessor";
|
||
|
||
// 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 API
|
||
// behavior against a real database (see test/sources.test.ts,
|
||
// test/planning.test.ts).
|
||
//
|
||
// planning.feature's journey crosses both `RecipePickerDialog` (browsing an
|
||
// external source from a planning slot) and the import review screen
|
||
// (`ImportRecipePage`) it hands off to — same "each spec's own
|
||
// self-contained fixtures" precedent recipe-sources.ts already sets (the
|
||
// Cucumber preprocessor's step lookup isn't global across cypress/e2e/, see
|
||
// its own comment for the full reasoning), so most of what's below mirrors
|
||
// recipe-sources.ts's fixtures rather than importing them.
|
||
|
||
// Flips once, from `false` to `true`, as the single scenario in this file
|
||
// actually performs the planning-add — module-level `let` rather than
|
||
// something reset per-scenario, since there's only ever the one here (see
|
||
// household-settings.ts for the same pattern used across several scenarios
|
||
// instead).
|
||
let fishPiePlanned = false;
|
||
|
||
Given("the recipe catalog contains nothing", () => {
|
||
cy.intercept("GET", /\/recipes\?/, { statusCode: 200, body: [] });
|
||
});
|
||
|
||
Given("the household has enabled TheMealDB", () => {
|
||
cy.intercept("GET", "**/house/current/sources", { statusCode: 200, body: [1] });
|
||
});
|
||
|
||
Given("browsing TheMealDB returns some items", () => {
|
||
cy.intercept("GET", "**/sources/theMealDb/browse*", {
|
||
statusCode: 200,
|
||
body: {
|
||
items: [
|
||
{
|
||
externalId: "52795",
|
||
title: "Chicken Handi",
|
||
picture: null,
|
||
url: "https://www.themealdb.com/meal/52795",
|
||
alreadyImported: true,
|
||
recipeId: 2,
|
||
},
|
||
{
|
||
externalId: "9999",
|
||
title: "Fish Pie",
|
||
picture: null,
|
||
url: "https://www.themealdb.com/meal/9999",
|
||
alreadyImported: false,
|
||
recipeId: null,
|
||
},
|
||
// Draft's own preview ("previewing TheMealDB item ... is fully
|
||
// resolved") has nothing left for a person to fix — unlike "Fish
|
||
// Pie" above, exercises the transparent-import path instead of the
|
||
// review screen.
|
||
{
|
||
externalId: "7777",
|
||
title: "Ratatouille",
|
||
picture: null,
|
||
url: "https://www.themealdb.com/meal/7777",
|
||
alreadyImported: false,
|
||
recipeId: null,
|
||
},
|
||
],
|
||
nextCursor: null,
|
||
},
|
||
});
|
||
});
|
||
|
||
Given("previewing TheMealDB item {string} is available", (externalId: string) => {
|
||
cy.intercept("GET", `**/sources/theMealDb/preview/${externalId}`, {
|
||
statusCode: 200,
|
||
body: {
|
||
sourceKey: "theMealDb",
|
||
externalId,
|
||
name: "Fish Pie",
|
||
description: null,
|
||
picture: null,
|
||
portions: 4,
|
||
sourceUrl: "https://www.themealdb.com/meal/9999",
|
||
ingredients: [
|
||
{
|
||
rawText: "1 onion",
|
||
quantity: 1,
|
||
ingredient: {
|
||
id: 1,
|
||
key: "onion",
|
||
icon: "VEGETABLE",
|
||
category: "freshProduce",
|
||
subcategory: "vegetables",
|
||
reproducible: false,
|
||
allergens: [],
|
||
diets: [],
|
||
},
|
||
unit: null,
|
||
},
|
||
{ rawText: "some mystery paste", quantity: null, ingredient: null, unit: null },
|
||
],
|
||
steps: [{ description: "Cuire à la poêle.", picture: null, techSteps: [] }],
|
||
},
|
||
});
|
||
});
|
||
|
||
// Unlike "previewing TheMealDB item ... is available" above, every
|
||
// ingredient line here already resolved to a real ingredient/unit/quantity
|
||
// — `tryBuildCompleteImport` (RecipePickerDialog.tsx) accepts a draft
|
||
// shaped exactly like this one as-is, no review screen needed.
|
||
Given(
|
||
"previewing TheMealDB item {string} is fully resolved as {string}",
|
||
(externalId: string, name: string) => {
|
||
cy.intercept("GET", `**/sources/theMealDb/preview/${externalId}`, {
|
||
statusCode: 200,
|
||
body: {
|
||
sourceKey: "theMealDb",
|
||
externalId,
|
||
name,
|
||
description: null,
|
||
picture: null,
|
||
portions: 4,
|
||
sourceUrl: `https://www.themealdb.com/meal/${externalId}`,
|
||
ingredients: [
|
||
{
|
||
rawText: "1 onion",
|
||
quantity: 1,
|
||
ingredient: {
|
||
id: 1,
|
||
key: "onion",
|
||
icon: "VEGETABLE",
|
||
category: "freshProduce",
|
||
subcategory: "vegetables",
|
||
reproducible: false,
|
||
allergens: [],
|
||
diets: [],
|
||
},
|
||
unit: { id: 1, key: "piece", type: "COUNT", toBaseFactor: 1 },
|
||
},
|
||
],
|
||
steps: [{ description: "Cuire à la poêle.", picture: null, techSteps: [] }],
|
||
},
|
||
});
|
||
},
|
||
);
|
||
|
||
Given(
|
||
"importing item {string} will succeed and return id {int}",
|
||
(externalId: string, id: number) => {
|
||
cy.intercept("POST", `**/sources/theMealDb/import/${externalId}`, {
|
||
statusCode: 201,
|
||
body: { id },
|
||
}).as("importItem");
|
||
},
|
||
);
|
||
|
||
Given("adding recipe {int} to the planning will succeed", (recipeId: number) => {
|
||
cy.intercept("POST", "**/planning/items", {
|
||
statusCode: 201,
|
||
body: {
|
||
id: 2,
|
||
weekDay: "lundi",
|
||
meal: "petit-dejeuner",
|
||
portions: 4,
|
||
recipe: { id: recipeId, name: "Ratatouille" },
|
||
},
|
||
}).as("addPlanningItem");
|
||
});
|
||
|
||
Then(
|
||
"the import request for {string} should have included the name {string}",
|
||
(_externalId: string, name: string) => {
|
||
cy.wait("@importItem").its("request.body.name").should("eq", name);
|
||
},
|
||
);
|
||
|
||
// Covers every reference catalog both `RecipePickerDialog` (ingredients/
|
||
// diets, for its own filters) and `ImportRecipePage` (ingredients/diets/
|
||
// units, for the review form) fetch — same endpoints, one fixture for both.
|
||
Given("the ingredient and diet catalog is available for import", () => {
|
||
cy.intercept("GET", "**/reference/ingredients", {
|
||
statusCode: 200,
|
||
body: [
|
||
{
|
||
id: 1,
|
||
key: "onion",
|
||
icon: "VEGETABLE",
|
||
category: "freshProduce",
|
||
subcategory: "vegetables",
|
||
allergens: [],
|
||
diets: [],
|
||
},
|
||
{
|
||
id: 2,
|
||
key: "salt",
|
||
icon: "SPICE",
|
||
category: "condimentsAndSpices",
|
||
subcategory: "spices",
|
||
allergens: [],
|
||
diets: [],
|
||
},
|
||
],
|
||
});
|
||
cy.intercept("GET", "**/reference/diets", {
|
||
statusCode: 200,
|
||
body: [{ id: 1, key: "omnivore" }],
|
||
});
|
||
cy.intercept("GET", "**/reference/units", {
|
||
statusCode: 200,
|
||
body: [{ id: 1, key: "piece", type: "COUNT", toBaseFactor: 1 }],
|
||
});
|
||
});
|
||
|
||
Given("importing the previewed item will succeed and return id {int}", (id: number) => {
|
||
cy.intercept("POST", "**/sources/theMealDb/import/9999", {
|
||
statusCode: 201,
|
||
body: { id },
|
||
}).as("importRecipe");
|
||
});
|
||
|
||
Given("adding the imported recipe to the planning will succeed", () => {
|
||
cy.intercept("POST", "**/planning/items", (req) => {
|
||
fishPiePlanned = true;
|
||
req.reply({
|
||
statusCode: 201,
|
||
body: {
|
||
id: 1,
|
||
weekDay: "lundi",
|
||
meal: "petit-dejeuner",
|
||
portions: 4,
|
||
recipe: { id: 99, name: "Fish Pie" },
|
||
},
|
||
});
|
||
}).as("addPlanningItem");
|
||
});
|
||
|
||
// Stateful — landing back on "/" after the import journey remounts
|
||
// `PlanningPage` from scratch (a real cross-route navigation, not a
|
||
// same-component state update: see `ImportRecipePage`'s `navigate("/")`),
|
||
// so only a fresh `GET /planning?date=` that reflects the just-added item
|
||
// makes it show up there — nothing client-side survives that remount to
|
||
// patch it in locally the way `PlanningPage`'s own `patchPlanningItems`
|
||
// does for an add made without leaving the page.
|
||
Given("the planning request reflects whatever's been added so far", () => {
|
||
cy.intercept("GET", /\/planning\?/, (req) => {
|
||
req.reply({
|
||
statusCode: 200,
|
||
body: fishPiePlanned
|
||
? {
|
||
id: 1,
|
||
startDate: "2026-08-17T00:00:00.000Z",
|
||
finishDate: "2026-08-23T00:00:00.000Z",
|
||
items: [
|
||
{
|
||
id: 1,
|
||
weekDay: "lundi",
|
||
meal: "petit-dejeuner",
|
||
portions: 4,
|
||
recipe: { id: 99, name: "Fish Pie" },
|
||
},
|
||
],
|
||
}
|
||
: null,
|
||
});
|
||
});
|
||
});
|
||
|
||
// The very first "+" in DOM order is Lundi's Petit-déjeuner cell (`MEALS`'s
|
||
// first entry × `WEEK_DAYS`'s first entry, see `PlanningGrid`) — the exact
|
||
// slot this feature's fixtures above (weekDay "lundi", meal
|
||
// "petit-dejeuner") are written against.
|
||
When("I click the add button for the first empty planning slot", () => {
|
||
cy.get(".add-recipe-btn").first().click();
|
||
});
|
||
|
||
When("I click the source item {string}", (title: string) => {
|
||
cy.contains(".recipe-table__name", title).click();
|
||
});
|
||
|
||
When("I choose an ingredient for the unresolved line {string}", (rawText: string) => {
|
||
cy.contains(".import-recipe__unresolved-row", rawText)
|
||
.contains("button", "Choisir un ingrédient")
|
||
.click();
|
||
});
|
||
|
||
Then(
|
||
"the planning add request should have included recipe {int}, weekDay {string}, meal {string}, and portions {int}",
|
||
(recipeId: number, weekDay: string, meal: string, portions: number) => {
|
||
cy.wait("@addPlanningItem")
|
||
.its("request.body")
|
||
.should("deep.include", { recipeId, weekDay, meal, portions });
|
||
},
|
||
);
|
||
|
||
Then(
|
||
"the recipe {string} should appear in the first planning slot with {int} portions",
|
||
(name: string, portions: number) => {
|
||
cy.get(".planning-grid tbody tr")
|
||
.first()
|
||
.within(() => {
|
||
cy.contains(".recipe-chip", `${name} · ×${portions}`).should("be.visible");
|
||
});
|
||
},
|
||
);
|