Depuis la refonte #29-#32, attacher un ingrédient/ustensile/température à une correction de technique demande de surligner le passage concerné dans la description. Mais le `<p>` de la description est un frère du popover dans le DOM, et le listener « clic extérieur » du popover (`mousedown` hors de son propre DOM → `onClose`) fermait le popover dès le `mousedown` qui démarre ce glisser-sélectionner — avant même le `mouseup`. Le `mouseup` ouvrait alors un nouveau popover vierge pour le texte sélectionné, traité comme une toute nouvelle proposition de technique (la technique choisie et les métadonnées déjà ajoutées étaient perdues). `TechStepCorrectionPopover` accepte désormais un prop optionnel `descriptionContainerRef` (le `containerRef` que `StepDescription` a déjà) : un `mousedown` à l'intérieur n'est plus un clic extérieur. `StepDescription` pilote déjà explicitement chaque interaction dans la description (`setActiveCorrection`/`setResolvedMetadataSpan`), donc retirer cette fermeture concurrente ne perd rien ; un clic vraiment ailleurs ferme toujours le popover. Comble aussi le trou de couverture que `TechStepCorrectionPopover.cy.tsx` prétendait déjà délégué à un scénario e2e inexistant : nouveau test dans `recipes.cy.ts` avec une vraie sélection de texte (Range/Selection sur les nœuds `data-offset`, séquence mousedown→mouseup réelle) — vérifié qu'il échoue sans le correctif. Closes #36 Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
418 lines
16 KiB
TypeScript
418 lines
16 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 API
|
||
// behavior against a real database.
|
||
//
|
||
// The favorite-toggle and delete-recipe journeys moved to recipes.feature —
|
||
// this file now only covers catalog browsing/display.
|
||
|
||
const authenticatedProfile = {
|
||
id: 1,
|
||
firstName: "Alice",
|
||
lastName: "Martin",
|
||
email: "alice@example.com",
|
||
tokenVersion: 0,
|
||
houseId: 1,
|
||
dietId: null,
|
||
};
|
||
|
||
const vegetarien = { id: 1, key: "vegetarian" };
|
||
const _gluten = { id: 1, key: "gluten", kind: "INTOLERANCE" };
|
||
const oeufs = { id: 2, key: "eggs", kind: "ALLERGY" };
|
||
|
||
const ratatouille = {
|
||
id: 1,
|
||
name: "Ratatouille",
|
||
description: null,
|
||
picture: null,
|
||
portions: 4,
|
||
authorId: 1,
|
||
visibility: "PERSONAL",
|
||
allergens: [],
|
||
diets: [vegetarien],
|
||
isFavorite: true,
|
||
};
|
||
|
||
const omelette = {
|
||
id: 2,
|
||
name: "Omelette",
|
||
description: null,
|
||
picture: null,
|
||
portions: 2,
|
||
authorId: 1,
|
||
visibility: "PERSONAL",
|
||
allergens: [oeufs],
|
||
diets: [],
|
||
isFavorite: false,
|
||
};
|
||
|
||
const omeletteDetail = {
|
||
...omelette,
|
||
description: "Une omelette toute simple.",
|
||
ingredients: [
|
||
{
|
||
ingredient: {
|
||
id: 10,
|
||
key: "egg",
|
||
icon: "EGG",
|
||
category: "dairyAndCheese",
|
||
subcategory: "eggs",
|
||
allergens: [oeufs],
|
||
diets: [],
|
||
},
|
||
quantity: 3,
|
||
unit: { id: 1, key: "piece", type: "COUNT", toBaseFactor: 1 },
|
||
},
|
||
],
|
||
steps: [
|
||
{ id: 1, description: "Battre les œufs.", picture: null, order: 1, techSteps: [] },
|
||
{ id: 2, description: "Cuire à la poêle.", picture: null, order: 2, techSteps: [] },
|
||
],
|
||
};
|
||
|
||
/**
|
||
* A 3-step recipe whose middle step already has an existing technique match
|
||
* carrying attached ingredient/utensil metadata — for the step-numbering
|
||
* regression test below, which needs a correction popover with several
|
||
* `<li>` rows of its own open at once (see that test's own comment).
|
||
*/
|
||
const tarteDetail = {
|
||
id: 5,
|
||
name: "Tarte",
|
||
description: null,
|
||
picture: null,
|
||
portions: 4,
|
||
authorId: 1,
|
||
visibility: "PERSONAL",
|
||
allergens: [],
|
||
diets: [],
|
||
isFavorite: false,
|
||
ingredients: [],
|
||
steps: [
|
||
{ id: 1, description: "Préchauffer le four.", picture: null, order: 1, techSteps: [] },
|
||
{
|
||
id: 2,
|
||
description: "Faire revenir les oignons avec une poêle.",
|
||
picture: null,
|
||
order: 2,
|
||
techSteps: [
|
||
{
|
||
techStep: { id: 2, key: "panFry" },
|
||
start: 0,
|
||
end: 13,
|
||
source: "auto",
|
||
ingredients: [
|
||
{
|
||
ingredient: {
|
||
id: 10,
|
||
key: "onion",
|
||
icon: "VEGETABLE",
|
||
category: "freshProduce",
|
||
subcategory: "vegetables",
|
||
allergens: [],
|
||
diets: [],
|
||
},
|
||
quantity: 2,
|
||
unit: { id: 2, key: "piece", type: "COUNT", toBaseFactor: 1 },
|
||
start: 18,
|
||
end: 25,
|
||
source: "auto",
|
||
},
|
||
],
|
||
utensils: [{ utensil: { id: 20, key: "pan" }, start: 31, end: 40, source: "auto" }],
|
||
temperatures: [],
|
||
},
|
||
],
|
||
},
|
||
{ id: 3, description: "Servir chaud.", picture: null, order: 3, techSteps: [] },
|
||
],
|
||
};
|
||
|
||
/**
|
||
* A one-step recipe whose single technique match carries no attached
|
||
* metadata yet — for the issue #36 regression test below, which needs a
|
||
* clean correction popover and a description word ("oignons") that appears
|
||
* *only* in the step text, nowhere in the popover itself.
|
||
*/
|
||
const brownStepRecipe = {
|
||
id: 6,
|
||
name: "Soupe",
|
||
description: null,
|
||
picture: null,
|
||
portions: 4,
|
||
authorId: 1,
|
||
visibility: "PERSONAL",
|
||
allergens: [],
|
||
diets: [],
|
||
isFavorite: false,
|
||
ingredients: [],
|
||
steps: [
|
||
{
|
||
id: 1,
|
||
description: "Faire revenir les oignons.",
|
||
picture: null,
|
||
order: 1,
|
||
techSteps: [
|
||
{
|
||
techStep: { id: 3, key: "brown" },
|
||
start: 0,
|
||
end: 13,
|
||
source: "auto",
|
||
ingredients: [],
|
||
utensils: [],
|
||
temperatures: [],
|
||
},
|
||
],
|
||
},
|
||
],
|
||
};
|
||
|
||
function interceptAuth() {
|
||
cy.intercept("GET", "**/auth/me", { statusCode: 200, body: authenticatedProfile });
|
||
cy.intercept("GET", "**/profile/disliked-ingredients", { statusCode: 200, body: [] });
|
||
}
|
||
|
||
describe("Recipe catalog", () => {
|
||
beforeEach(() => {
|
||
interceptAuth();
|
||
});
|
||
|
||
it("defaults to the Favoris tab and lists its recipes", () => {
|
||
cy.intercept("GET", /\/recipes\?/, (req) => {
|
||
expect(req.url).to.include("tab=favoris");
|
||
req.reply({ statusCode: 200, body: [ratatouille] });
|
||
}).as("listRecipes");
|
||
|
||
cy.visit("/recettes");
|
||
cy.wait("@listRecipes");
|
||
|
||
cy.contains("h1", "Recettes").should("be.visible");
|
||
cy.get(".recipe-tabs__tab.active").should("contain.text", "Favoris");
|
||
cy.contains(".recipe-table__name", "Ratatouille").should("be.visible");
|
||
// The favorited row carries the ★ fav-mark.
|
||
cy.contains(".recipe-table__name", "Ratatouille")
|
||
.find(".recipe-table__fav-mark")
|
||
.should("exist");
|
||
cy.contains(".recipe-table__name", "Ratatouille")
|
||
.parents("tr")
|
||
.find(".diet-badge")
|
||
.should("contain.text", "Végétarien");
|
||
});
|
||
|
||
it("shows the empty state when a tab has no recipes", () => {
|
||
cy.intercept("GET", /\/recipes\?/, { statusCode: 200, body: [] });
|
||
|
||
cy.visit("/recettes");
|
||
|
||
cy.contains("Aucune recette pour le moment.").should("be.visible");
|
||
});
|
||
|
||
it("shows an error state when the catalog fails to load", () => {
|
||
cy.intercept("GET", /\/recipes\?/, { statusCode: 500, body: { code: 5000, message: "boom" } });
|
||
|
||
cy.visit("/recettes");
|
||
|
||
cy.contains(".recipes-page__status--error", "Impossible de charger").should("be.visible");
|
||
});
|
||
|
||
it("switches tabs, re-fetching each one's own recipes", () => {
|
||
cy.intercept("GET", /\/recipes\?/, (req) => {
|
||
const tab = new URL(req.url).searchParams.get("tab");
|
||
const body = tab === "perso" ? [omelette] : [ratatouille];
|
||
req.reply({ statusCode: 200, body });
|
||
}).as("listRecipes");
|
||
|
||
cy.visit("/recettes");
|
||
cy.wait("@listRecipes");
|
||
cy.contains(".recipe-table__name", "Ratatouille").should("be.visible");
|
||
|
||
// Not asserting the specific request URL here — React StrictMode (see
|
||
// main.tsx) double-invokes mount/update effects in dev, so this can
|
||
// legitimately fire twice; the rendered result converges either way
|
||
// (same reasoning as planning-page.cy.ts's week-navigation tests).
|
||
cy.contains(".recipe-tabs__tab", "Perso").click();
|
||
cy.get(".recipe-tabs__tab.active").should("contain.text", "Perso");
|
||
cy.contains(".recipe-table__name", "Omelette").should("be.visible");
|
||
cy.contains(".recipe-table__name", "Ratatouille").should("not.exist");
|
||
});
|
||
|
||
it("searches within the active tab, debounced", () => {
|
||
cy.intercept("GET", /\/recipes\?/, (req) => {
|
||
const search = new URL(req.url).searchParams.get("search");
|
||
req.reply({ statusCode: 200, body: search ? [omelette] : [ratatouille, omelette] });
|
||
}).as("listRecipes");
|
||
|
||
cy.visit("/recettes");
|
||
cy.wait("@listRecipes");
|
||
cy.contains(".recipe-table__name", "Ratatouille").should("be.visible");
|
||
|
||
// Same "assert the rendered result, not the request count/URL" reasoning
|
||
// as the tab-switch test above — the 300ms debounce plus StrictMode's
|
||
// double-invoked effects make the exact number/order of requests an
|
||
// implementation detail, not something worth pinning down here.
|
||
cy.get(".recipes-page__search").type("Omel");
|
||
cy.contains(".recipe-table__name", "Omelette").should("be.visible");
|
||
cy.contains(".recipe-table__name", "Ratatouille").should("not.exist");
|
||
});
|
||
|
||
it("opens a recipe's detail alongside the table when its row is selected", () => {
|
||
// Desktop-only master-detail layout, same reasoning as
|
||
// planning-page.cy.ts's "Planning grid" tests — wider/taller than
|
||
// Cypress's default 1000×660, which doesn't leave the detail panel
|
||
// (photo + header + ingredients + steps) enough height to show
|
||
// everything without needing its own internal scroll (by design, see
|
||
// `.recipe-detail-panel` in recipes.scss) — this test asserts full
|
||
// visibility without scrolling, so it needs the room.
|
||
cy.viewport(1600, 900);
|
||
cy.intercept("GET", /\/recipes\?/, { statusCode: 200, body: [ratatouille, omelette] });
|
||
cy.intercept("GET", "**/recipes/2", { statusCode: 200, body: omeletteDetail }).as("getRecipe");
|
||
|
||
cy.visit("/recettes");
|
||
cy.contains(".recipe-table__name", "Omelette").click();
|
||
cy.wait("@getRecipe");
|
||
|
||
cy.url().should("include", "/recettes/2");
|
||
// The table stays mounted (master-detail, not a page navigation) —
|
||
// both rows are still visible next to the detail panel.
|
||
cy.contains(".recipe-table__name", "Ratatouille").should("be.visible");
|
||
cy.get("tr.selected .recipe-table__name").should("contain.text", "Omelette");
|
||
|
||
cy.get(".recipe-detail-panel").within(() => {
|
||
cy.contains("h2", "Omelette").should("be.visible");
|
||
cy.contains("Une omelette toute simple.").should("be.visible");
|
||
cy.contains("Battre les œufs.").should("be.visible");
|
||
cy.contains("Cuire à la poêle.").should("be.visible");
|
||
});
|
||
});
|
||
|
||
// Regression: `.recipe-detail-panel__steps`'s CSS step counter
|
||
// (recipes.scss) used a bare descendant `li` selector, matching *any*
|
||
// `<li>` nested inside a step — including ones several levels down
|
||
// inside `TechStepCorrectionPopover` (each attached ingredient/utensil/
|
||
// temperature row, and the technique picker's own `<li>` list). Opening
|
||
// a correction with attached metadata inflated the counter, throwing off
|
||
// every *later* step's own displayed number — exactly the "les étapes
|
||
// affichées sont numérotées n'importe comment" bug report this covers.
|
||
it("keeps every step's own number correct while a correction popover with attached metadata is open", () => {
|
||
cy.viewport(1600, 900);
|
||
cy.intercept("GET", /\/recipes\?/, { statusCode: 200, body: [] });
|
||
cy.intercept("GET", "**/recipes/5", { statusCode: 200, body: tarteDetail });
|
||
cy.intercept("GET", "**/reference/tech-steps", {
|
||
statusCode: 200,
|
||
body: [{ id: 2, key: "panFry" }],
|
||
});
|
||
|
||
cy.visit("/recettes/5");
|
||
cy.get(".recipe-detail-panel__steps > li").should("have.length", 3);
|
||
|
||
// Open the middle step's correction — pre-seeded with one ingredient
|
||
// and one utensil, so its popover renders two `.tech-step-correction-popover__row`
|
||
// `<li>`s of its own (plus the technique picker's own `<li>` list once
|
||
// "Changer" is clicked) sitting *inside* the second step's own `<li>`.
|
||
cy.contains(".step-tech-step", "Faire revenir").click();
|
||
cy.get(".tech-step-correction-popover__row").should("have.length", 2);
|
||
cy.contains(".tech-step-correction-popover__technique-change", "Changer").click();
|
||
cy.get(".catalog-search-picker__list li").should("have.length.greaterThan", 0);
|
||
|
||
// None of the popover's own nested `<li>`s join the step counter chain
|
||
// — `getComputedStyle(...).content` isn't reliable for reading a
|
||
// *resolved* counter() value across browsers (it can return the raw
|
||
// `counter(step) ". "` declaration instead), so this checks the actual
|
||
// `counter-increment` computed property instead: a real, directly
|
||
// testable signal for exactly what `> li` (recipes.scss) does and
|
||
// doesn't scope to.
|
||
cy.get(".tech-step-correction-popover__row").each(($row) => {
|
||
expect(window.getComputedStyle($row[0]).counterIncrement).to.equal("none");
|
||
});
|
||
cy.get(".catalog-search-picker__list li").each(($item) => {
|
||
expect(window.getComputedStyle($item[0]).counterIncrement).to.equal("none");
|
||
});
|
||
// The step `<li>`s themselves are still correctly in the chain.
|
||
cy.get(".recipe-detail-panel__steps > li").each(($step) => {
|
||
expect(window.getComputedStyle($step[0]).counterIncrement).to.equal("step 1");
|
||
});
|
||
});
|
||
|
||
// Regression (issue #36): while the correction popover's "add an
|
||
// ingredient/utensil/temperature" flow waits for a text selection, the
|
||
// mousedown that *starts* that drag lands on the step description — a DOM
|
||
// sibling of the popover — and used to count as an outside-click,
|
||
// closing the popover before mouseup; the selection then opened a fresh
|
||
// blank popover for the selected word as a brand-new technique. The
|
||
// description container is now exempt from the popover's outside-click.
|
||
it("keeps the same correction popover open while selecting a span for an ingredient", () => {
|
||
cy.viewport(1600, 900);
|
||
cy.intercept("GET", /\/recipes\?/, { statusCode: 200, body: [] });
|
||
cy.intercept("GET", "**/recipes/6", { statusCode: 200, body: brownStepRecipe });
|
||
cy.intercept("GET", "**/reference/tech-steps", {
|
||
statusCode: 200,
|
||
body: [{ id: 3, key: "brown" }],
|
||
});
|
||
cy.intercept("GET", "**/reference/ingredients", { statusCode: 200, body: [] });
|
||
cy.intercept("GET", "**/reference/units", { statusCode: 200, body: [] });
|
||
cy.intercept("GET", "**/reference/utensils", { statusCode: 200, body: [] });
|
||
|
||
cy.visit("/recettes/6");
|
||
|
||
// Open the technique correction, then ask to attach an ingredient — the
|
||
// popover now shows the "select the span in the description" hint.
|
||
cy.contains(".step-tech-step", "Faire revenir").click();
|
||
cy.get(".tech-step-correction-popover").should("have.length", 1);
|
||
cy.get(".tech-step-correction-popover__selection").should("contain.text", "Faire revenir");
|
||
cy.contains(".tech-step-correction-popover__add-row", "Ajouter un ingrédient").click();
|
||
cy.get(".tech-step-correction-popover__hint").should("be.visible");
|
||
|
||
// Reproduce the real mousedown → drag → mouseup over the description.
|
||
// The mousedown is the event that used to close the popover.
|
||
cy.contains(".recipe-detail-panel__steps p", "Faire revenir").as("stepParagraph");
|
||
cy.get("@stepParagraph").trigger("mousedown");
|
||
cy.window().then((win) => {
|
||
const paragraph = win.document.querySelector(".recipe-detail-panel__steps p") as HTMLElement;
|
||
const span = [...paragraph.querySelectorAll<HTMLElement>("[data-offset]")].find((el) =>
|
||
el.textContent?.includes("oignons"),
|
||
);
|
||
const textNode = span?.firstChild as Text;
|
||
const local = textNode.textContent?.indexOf("oignons") ?? 0;
|
||
const range = win.document.createRange();
|
||
range.setStart(textNode, local);
|
||
range.setEnd(textNode, local + "oignons".length);
|
||
const selection = win.getSelection();
|
||
selection?.removeAllRanges();
|
||
selection?.addRange(range);
|
||
});
|
||
cy.get("@stepParagraph").trigger("mouseup");
|
||
|
||
// Still the *same* popover — its heading still quotes the technique span
|
||
// "Faire revenir", not "oignons" — now showing the ingredient sub-form
|
||
// for the freshly selected span, not a blank new-technique popover.
|
||
cy.get(".tech-step-correction-popover").should("have.length", 1);
|
||
cy.get(".tech-step-correction-popover__selection").should("contain.text", "Faire revenir");
|
||
cy.get(".tech-step-correction-popover__add-form").should("be.visible");
|
||
});
|
||
|
||
it("shows a not-found message for a selected id the API rejects", () => {
|
||
cy.intercept("GET", /\/recipes\?/, { statusCode: 200, body: [] });
|
||
cy.intercept("GET", "**/recipes/999", {
|
||
statusCode: 404,
|
||
// ErrorCode.RECIPE_NOT_FOUND (packages/shared/src/errors/error-codes.ts)
|
||
// — RecipeDetailPanel only renders the "not found" message for this
|
||
// exact code, anything else falls into its generic error state.
|
||
body: { code: 4045, message: "not found" },
|
||
});
|
||
|
||
cy.visit("/recettes/999");
|
||
|
||
cy.contains(".recipe-detail-panel", "Cette recette n'existe pas.").should("be.visible");
|
||
});
|
||
|
||
it("links the new-recipe button to the recipe form", () => {
|
||
cy.intercept("GET", /\/recipes\?/, { statusCode: 200, body: [] });
|
||
|
||
cy.visit("/recettes");
|
||
|
||
cy.contains(".recipes-page__new-button", "Nouvelle recette").should(
|
||
"have.attr",
|
||
"href",
|
||
"/recettes/nouvelle",
|
||
);
|
||
});
|
||
});
|