batchCooking/apps/web/cypress/e2e/planning.ts
Nicolas 85fd9bae7d feat(planning): le picker prévisualise avant de confirmer, formulaire de revue intégré
Trois ajustements successifs sur le dialogue de sélection de recette
(RecipePickerDialog), demandés en continu après le premier correctif
de débordement :

1. Dialogue élargi et à hauteur fixe (95vw plafonné à 85rem, 80vh) au
   lieu de dépendre du contenu, avec la répartition liste/détail
   redéfinie en fractions du dialogue lui-même (3fr/2fr) plutôt qu'en
   vw — cette dernière suivait la largeur du viewport, sans rapport
   avec la largeur désormais fixe du dialogue.

2. Le formulaire de revue d'import (ex-ImportRecipePage) est extrait
   dans un composant partagé, RecipeImportForm — toujours monté en
   page autonome (route directe/rechargement), mais désormais aussi
   intégré comme une étape du dialogue lui-même quand un item de
   source a besoin d'une résolution manuelle, au lieu de naviguer et
   perdre le contexte du picker (recherche, filtres, créneau).

3. Cliquer sur une recette dans le dialogue ne fait plus que la
   sélectionner/prévisualiser (RecipeDetailPanel, comme /recettes) —
   plus de saut automatique vers l'étape suivante. Un nouveau pied de
   dialogue (Dialog.tsx gagne une prop ) porte Confirmer/
   Fermer : Confirmer agit sur la sélection en cours (recette réelle
   → étape portions existante ; item de source pas encore importé →
   import transparent ou formulaire intégré, point 2). Les onglets
   réguliers gagnent leur propre paire maître-détail (RecipeTable +
   RecipeDetailPanel, showActions=false) sur ce même modèle ; les
   onglets source prévisualisent désormais aussi les items déjà
   importés en interne (RecipeSourcesPanel), plus de saut direct.

Cypress (planning.feature/planning.ts) mis à jour en conséquence :
sélectionner puis confirmer sont deux étapes distinctes, le clic sur
la ligne ne déclenche plus rien tout seul.

Bug pré-existant trouvé en testant en direct (sans rapport avec ce qui
précède) : l'import d'une recette source plante avec une contrainte
d'unicité Prisma dès que deux lignes d'ingrédient se résolvent au même
ingrédient catalogue — signalé séparément (tâche en arrière-plan), pas
corrigé ici.

Vérifié en direct (navigateur, comptes de test) : sélection sans saut
d'écran, pied de dialogue activé/désactivé correctement, Confirmer sur
un item de source non résolu bascule vers le formulaire intégré,
Fermer ferme bien le dialogue.

pnpm exec tsc -b --force (web) — propre.
pnpm exec biome check — propre.
pnpm --filter web build — propre.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-21 00:05:17 +02:00

270 lines
8.5 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 { 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.
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", {
statusCode: 201,
body: {
id: 1,
weekDay: "lundi",
meal: "petit-dejeuner",
portions: 4,
recipe: { id: 99, name: "Fish Pie" },
},
}).as("addPlanningItem");
});
// Every scenario here confirms/imports without ever leaving "/" (the
// footer's "Confirmer" patches the grid locally via `PlanningPage`'s own
// `onAdded` — `RecipePickerDialog`'s doc comment — rather than navigating
// away and back), so unlike a real cross-route remount, this fixture never
// needs to reflect what's been added: the grid picks it up from local
// state, not a fresh fetch.
Then("the recipe picker dialog should be closed", () => {
cy.get(".dialog-panel").should("not.exist");
});
// 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");
});
},
);