batchCooking/apps/web/cypress/e2e/planning-leftovers.ts
Nicolas 1bc9304cdc
All checks were successful
CI / lint (push) Successful in 4m35s
CI / build (push) Successful in 4m51s
CI / intent-service-test (push) Successful in 5m42s
CI / e2e (push) Successful in 10m29s
CI / test (push) Successful in 17m43s
feat(planning): glisser-déposer un reste sur un autre créneau
Dernière PR (4/4) du chantier « gestion des restes » (issue #34).

`@dnd-kit/core` (+ `@dnd-kit/utilities`) — première et seule utilisation
de DnD de l'app. `PlanningPage` monte un `<DndContext>` (Mouse + Touch +
Keyboard sensors, distance d'activation 5px pour ne pas voler le clic
« Jeter ») autour de la grille et du panier.

- `LeftoversBasket` : chaque ligne a une poignée `⠿` `useDraggable` — le
  nœud draggable est la poignée elle-même (petite), pas toute la `<li>`,
  pour qu'une détection de collision au pointeur (`pointerWithin`, repli
  `closestCenter` pour le clavier) résolve proprement vers la seule case
  sous le curseur. `data` porte `sourceItemId`/`recipeName`/`available`.
- `MealCell` : `useDroppable` (`data: { date, weekDay, meal }`),
  surbrillance `--drop-over` au survol. Un item « restes »
  (`sourceItem !== null`) porte un badge ♻️ sur sa pastille.
- Au drop, `onDragEnd` ouvre `LeftoverAssignDialog` (quantité, défaut =
  disponible, plafonné) → `POST /planning/items/leftover`
  (`apiClient.assignLeftover`) → nouvel item patché dans `planning.items`.

Tests : `planning-leftovers.feature` (Gherkin) — parcours complet drag →
confirmation quantité → pastille ♻️ visible → compteur du panier à jour ;
+ jet d'un reste depuis le panier. Le drag est piloté par événements
souris bruts (mousedown poignée → mousemove ×2 → mouseup cible).
Vérifié localement : 2/2, et aucune régression sur
`leftovers-basket.cy.ts` / `planning-page.cy.ts` / `planning.feature`.

i18n : `planning.leftovers.*` (badge, poignée, dialogue). Docs :
`frontend-architecture.md`.

Closes #34

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-06 00:47:49 +02:00

132 lines
4.5 KiB
TypeScript

import { Given, Then, When } from "@badeball/cypress-cucumber-preprocessor";
// Mocks the API via cy.intercept — this job has no live backend (see
// .github/workflows/ci.yml); apps/api's Mocha suite covers the real
// leftover endpoints. Drives @dnd-kit's MouseSensor with raw mouse
// events (mousedown on the handle → two mousemoves → mouseup on the
// target), the reliable way to script one of its drags in Cypress.
/** One fresh batch: 12 prepared for 10 requested -> a pool of 2, no leftovers item pointing at it yet. */
const SOURCE_ITEM = {
id: 1,
weekDay: "lundi",
meal: "diner",
portions: 12,
portionsRequested: 10,
discardedPortions: 0,
sourceItem: null,
recipe: { id: 1, name: "Lasagnes" },
};
Given("the planning has a batch with leftovers to spare", () => {
cy.intercept("GET", "**/profile/disliked-ingredients", { statusCode: 200, body: [] });
cy.intercept("GET", /\/planning\?/, {
statusCode: 200,
body: {
id: 1,
startDate: "2026-08-17T00:00:00.000Z",
finishDate: "2026-08-23T00:00:00.000Z",
items: [SOURCE_ITEM],
},
}).as("getPlanning");
});
When("I open the leftovers basket", () => {
cy.get(".leftovers-basket__toggle").click();
});
Then(
"the leftovers basket lists {string} with {int} portions",
(recipe: string, portions: number) => {
cy.contains(".leftovers-basket__line", recipe).should("contain.text", `${portions} en restes`);
},
);
/** dnd-kit's MouseSensor listens on the drag element for `mousedown`, then on the document for `mousemove`/`mouseup`; a first move past the 5px activation distance, then a move onto the target, then release. */
function dragLeftoverOnto(targetSelectorChain: () => Cypress.Chainable<JQuery<HTMLElement>>) {
cy.get(".leftovers-basket__drag-handle")
.first()
.then(($handle) => {
const h = $handle[0].getBoundingClientRect();
const start = {
clientX: Math.round(h.left + h.width / 2),
clientY: Math.round(h.top + h.height / 2),
};
targetSelectorChain().then(($target) => {
const t = $target[0].getBoundingClientRect();
const end = {
clientX: Math.round(t.left + t.width / 2),
clientY: Math.round(t.top + t.height / 2),
};
cy.wrap($handle).trigger("mousedown", { button: 0, ...start });
cy.get("body")
.trigger("mousemove", {
button: 0,
clientX: start.clientX + 12,
clientY: start.clientY + 12,
})
.trigger("mousemove", { button: 0, ...end })
.trigger("mouseup", { button: 0, ...end });
});
});
}
/** The `<td>` for (Wednesday, lunch): MEALS order puts `dejeuner` at row index 2, WEEK_DAYS puts `mercredi` at td index 2. */
function wednesdayLunchCell() {
return cy.get(".planning-grid tbody tr").eq(2).find("td").eq(2);
}
When("I drag the {string} leftover onto Wednesday lunch", (_recipe: string) => {
cy.intercept("POST", "**/planning/items/leftover", (req) => {
req.reply({
statusCode: 201,
body: {
id: 99,
weekDay: "mercredi",
meal: "dejeuner",
portions: req.body.portions,
portionsRequested: req.body.portions,
discardedPortions: 0,
sourceItem: { id: req.body.sourceItemId },
recipe: { id: 1, name: "Lasagnes" },
},
});
}).as("assignLeftover");
dragLeftoverOnto(wednesdayLunchCell);
});
When("I confirm serving {int} leftover portions", (portions: number) => {
cy.get("#leftover-assign-portions").clear().type(String(portions));
cy.contains(".recipe-picker-confirm__confirm", "Ajouter au planning").click();
cy.wait("@assignLeftover").its("request.body").should("deep.include", {
sourceItemId: 1,
weekDay: "mercredi",
meal: "dejeuner",
portions,
});
});
Then("Wednesday lunch shows a leftovers chip for {string}", (recipe: string) => {
wednesdayLunchCell()
.find(".recipe-chip--leftover")
.should("contain.text", recipe)
.and("contain.text", "♻️");
});
Then("the leftovers basket count is gone", () => {
cy.get(".leftovers-basket__count").should("not.exist");
});
When("I throw away the {string} leftover", (recipe: string) => {
cy.intercept("PATCH", "**/planning/items/1/discard-leftover", {
statusCode: 200,
body: { ...SOURCE_ITEM, discardedPortions: 2 },
}).as("discard");
cy.contains(".leftovers-basket__line", recipe).find(".leftovers-basket__discard").click();
cy.wait("@discard");
});
Then("the leftovers basket shows nothing left", () => {
cy.contains(".leftovers-basket__empty", "Aucun reste cette semaine.").should("be.visible");
});