batchCooking/apps/web/cypress/e2e/recipe-form.ts
Nicolas ba030ec18c
All checks were successful
CI / lint (push) Successful in 3m5s
CI / build (push) Successful in 3m15s
CI / intent-service-test (push) Successful in 5m12s
CI / e2e (push) Successful in 11m1s
CI / test (push) Successful in 19m13s
CI / build-android (push) Has been skipped
feat(recipes): ingrédients d'assaisonnement « selon le goût » sans quantité (#128)
`RecipeIngredient.quantity`/`unitId` deviennent nullable (migration purement
additive) : une ligne de recette peut n'avoir ni quantité ni unité — sel,
poivre… ajoutés selon le goût. Le schéma partagé impose « quantité et unité,
ou aucune des deux » ; `toRecipeView` renvoie `null`/`null`.

`aggregateShoppingList` fait apparaître une telle ligne une seule fois, sans
quantité ni unité, jamais sommée ni mise à l'échelle. `ShoppingListPage`
affiche « selon le goût » à la place de la quantité.

Formulaire : `IngredientRow` gagne une case « Selon le goût » qui masque
quantité/unité ; `RecipeFormPage` et `RecipeImportForm` n'exigent plus
quantité/unité pour ces lignes et les envoient sans. Un import dont la source
laisse une ligne résolue sans quantité (« salt to taste ») arrive
directement en « selon le goût ».

Le chemin clause-de-technique (session de cuisine) tolérait déjà `quantity`
null — inchangé.

Tests : Mocha (création/édition d'une ligne sans quantité, rejet
quantité-sans-unité, liste de courses non sommée) ; Cypress (case à cocher +
parcours), régression recette/liste de courses OK.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-09 14:15:28 +02:00

172 lines
5.9 KiB
TypeScript

import { type DataTable, Given, Then, When } from "@badeball/cypress-cucumber-preprocessor";
const tomato = {
id: 1,
key: "tomato",
icon: "VEGETABLE",
category: "freshProduce",
subcategory: "vegetables",
allergens: [],
diets: [{ id: 2, key: "vegetarian" }],
};
const egg = {
id: 2,
key: "egg",
icon: "EGG",
category: "dairyAndCheese",
subcategory: "eggs",
allergens: [{ id: 1, key: "eggs", kind: "ALLERGY" }],
diets: [],
};
const carrot = {
id: 3,
key: "carrot",
icon: "VEGETABLE",
category: "freshProduce",
subcategory: "vegetables",
allergens: [],
diets: [{ id: 2, key: "vegetarian" }],
};
const diets = [
{ id: 1, key: "omnivore" },
{ id: 2, key: "vegetarian" },
];
const pieceUnit = { id: 1, key: "piece", type: "COUNT", toBaseFactor: 1 };
const gramUnit = { id: 2, key: "gram", type: "MASS", toBaseFactor: 1 };
Given("the ingredient and diet catalog is available", () => {
cy.intercept("GET", "**/reference/ingredients", { statusCode: 200, body: [tomato, egg, carrot] });
cy.intercept("GET", "**/reference/diets", { statusCode: 200, body: diets });
cy.intercept("GET", "**/reference/units", { statusCode: 200, body: [pieceUnit, gramUnit] });
});
Given("creating the recipe will succeed and return id {int}", (id: number) => {
cy.intercept("POST", "**/recipes", { statusCode: 201, body: { id } }).as("createRecipe");
});
When("I visit the new recipe form without a secure random UUID", () => {
cy.visit("/recettes/nouvelle", {
onBeforeLoad(win) {
Object.defineProperty(win.crypto, "randomUUID", {
value: undefined,
configurable: true,
});
},
});
});
Then(
"the recipe creation request should have included name {string}, portions {int}, and ingredient {int} with quantity {int} and unitId {int}",
(name: string, portions: number, ingredientId: number, quantity: number, unitId: number) => {
cy.wait("@createRecipe")
.its("request.body")
.should("deep.include", {
name,
portions,
ingredients: [{ ingredientId, quantity, unitId }],
});
},
);
Then("the picker should offer to add {string} as a placeholder", (name: string) => {
cy.get(".ingredient-picker__add-placeholder").should("contain.text", name);
});
When("I add {string} as a placeholder ingredient", () => {
// The search field already holds the query from the previous step, so the
// "add « … »" button carries the right name.
cy.get(".ingredient-picker__add-placeholder").click();
});
Then("the recipe should include the placeholder ingredient {string}", (name: string) => {
cy.contains(".ingredient-row__name", name)
.find(".ingredient-row__placeholder-badge")
.should("be.visible");
});
Then(
"the recipe creation request should have included a placeholder ingredient {string} with quantity {int} and unitId {int}",
(placeholderName: string, quantity: number, unitId: number) => {
cy.wait("@createRecipe")
.its("request.body.ingredients")
.should("deep.equal", [{ placeholderName, quantity, unitId }]);
},
);
When("I mark the ingredient {string} as seasoning", (name: string) => {
cy.contains(".ingredient-row", name)
.find(".ingredient-row__to-taste input[type=checkbox]")
.check({ force: true });
});
Then("the ingredient {string} should have no quantity field", (name: string) => {
cy.contains(".ingredient-row", name).find(".ingredient-row__quantity").should("not.exist");
});
Then(
"the recipe creation request should have included the seasoning-only ingredient {int}",
(ingredientId: number) => {
cy.wait("@createRecipe")
.its("request.body.ingredients")
.should("deep.equal", [{ ingredientId }]);
},
);
When("I scroll the recipe form to the top", () => {
// `.app-content` is the one scroll container (AppLayout.scss) — the sticky
// action bar pins against it, so it's what has to be scrolled to prove it.
cy.get(".app-content").scrollTo("top");
});
Then("the recipe form action bar is pinned to the bottom of the viewport", () => {
cy.get(".recipe-form__actions--sticky").then(($bar) => {
const rect = $bar[0].getBoundingClientRect();
const viewportHeight = Cypress.config("viewportHeight");
// Scrolled to the top of a form taller than the viewport, a pinned bar
// sits at the container's bottom padding edge (`.app-content` keeps its
// own `--space-xl` padding) rather than far below the fold — allow a
// generous band for that padding + the bar's own.
expect(rect.bottom).to.be.greaterThan(viewportHeight - 120);
expect(rect.bottom).to.be.at.most(viewportHeight + 1);
});
});
Given("recipe 7 exists with an egg omelette", () => {
const existingRecipe = {
id: 7,
name: "Omelette",
description: null,
picture: null,
portions: 2,
authorId: 1,
visibility: "PERSONAL",
allergens: [{ id: 1, key: "eggs", kind: "ALLERGY" }],
diets: [],
isFavorite: false,
ingredients: [{ ingredient: egg, quantity: 3, unit: pieceUnit }],
// `techSteps: []` matches the real `StepView` shape (packages/shared) —
// every other recipe-step mock in the suite sends it, and its absence
// here let `/recettes/7` (rendered after this recipe saves) hit
// `StepDescription` with `techSteps === undefined`.
steps: [{ id: 1, description: "Battre les œufs.", picture: null, order: 0, techSteps: [] }],
};
cy.intercept("GET", "**/recipes/7", { statusCode: 200, body: existingRecipe });
});
Given("updating recipe 7 will succeed", () => {
cy.intercept("PATCH", "**/recipes/7", { statusCode: 200, body: { id: 7 } }).as("updateRecipe");
});
Then(
"the recipe update request should have included these ingredients:",
(dataTable: DataTable) => {
const expected = dataTable.hashes().map((row) => ({
ingredientId: Number(row.ingredientId),
quantity: Number(row.quantity),
unitId: Number(row.unitId),
}));
cy.wait("@updateRecipe").its("request.body.ingredients").should("deep.equal", expected);
},
);