* fix(recipes): corrige la liste vide de sevenFiftyGAdapter quand le filtre est vide L'endpoint IA que list() utilisait pour toute recherche (SEARCH_URL, /genius/query/) répond avec un corps de réponse vide dès que query est vide — vérifié en direct. Résultat : parcourir la source 750g sans filtre ne remontait jamais aucune recette. Corrigé en lisant un endpoint différent quand query est vide/absent : dernieres-recettes.htm, le vrai catalogue paginé "dernières recettes" de 750g.com (pagination réelle via &page=N, contrairement à l'endpoint de recherche). nextCursor suit désormais cette même distinction : toujours null pour une recherche par texte (l'endpoint ne pagine pas), calculé normalement pour le parcours sans filtre (une page sans aucune carte en est le signal de fin, cet endpoint ne renvoyant ni 404 ni redirection une fois la dernière page dépassée). Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> * feat(recipes): scroll infini + placeholders sur le parcours des sources externes Remplace le bouton "Voir plus" de RecipeSourcesPanel par un scroll infini : une ligne sentinelle en fin de liste (SourceItemTable), observée via IntersectionObserver scopé au conteneur scrollable de la table, déclenche le chargement de la page suivante quand elle approche du bas. Le panel précharge en plus la page suivante dès que la page courante s'affiche (pas seulement au moment où la sentinelle devient visible), pour qu'un défilement rapide tombe le plus souvent sur une réponse déjà arrivée plutôt que de déclencher un aller-retour réseau à ce moment précis. Pendant un chargement (préchargé ou non), SourceItemTable ajoute des lignes squelettes qui pulsent en bas de la liste au lieu de laisser un vide. Un échec de chargement n'efface plus la liste déjà chargée comme avant (bug corrigé au passage) — un message avec un lien "Réessayer" s'affiche à la place ; ce correctif inclut aussi le nettoyage d'un préchargement en échec qui, sinon, aurait fait rejouer indéfiniment la même promesse déjà rejetée à chaque tentative de réessai. Deux nouveaux scénarios Cypress (recipe-sources.feature) : chargement automatique de pages supplémentaires sans bouton, et réessai après un échec du chargement suivant. Suite e2e complète relancée (78/79, le seul échec restant est un test préexistant sans rapport, recipe-form.feature, signalé séparément). Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> --------- Co-authored-by: Claude Sonnet 5 <noreply@anthropic.com>
218 lines
6.9 KiB
TypeScript
218 lines
6.9 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).
|
|
//
|
|
// "the sources reference list has options"/"the household's enabled
|
|
// sources are empty" resolve from cypress/support/step_definitions/ (the
|
|
// preprocessor's step-lookup is *not* global across cypress/e2e/ — only a
|
|
// feature's own same-named file/directory plus that shared folder are
|
|
// searched, see its error message when a step isn't found). recipes.ts
|
|
// sits directly in cypress/e2e/ (not that shared folder), so its own
|
|
// "the disliked ingredients list is empty"/"the recipe catalog
|
|
// contains"/"recipe 2's detail is available" are scoped to recipes.feature
|
|
// only — this file redeclares its own minimal equivalents rather than
|
|
// relocating shared infra, the same "each spec's own self-contained
|
|
// fixtures" precedent recipes.cy.ts already sets alongside recipes.ts.
|
|
|
|
Given("the disliked ingredients list is empty", () => {
|
|
cy.intercept("GET", "**/profile/disliked-ingredients", { statusCode: 200, body: [] });
|
|
});
|
|
|
|
Given("the recipe catalog contains nothing", () => {
|
|
cy.intercept("GET", /\/recipes\?/, { statusCode: 200, body: [] });
|
|
});
|
|
|
|
Given("recipe 2's detail is available", () => {
|
|
cy.intercept("GET", "**/recipes/2", {
|
|
statusCode: 200,
|
|
body: {
|
|
id: 2,
|
|
name: "Omelette",
|
|
description: null,
|
|
picture: null,
|
|
portions: 2,
|
|
authorId: 1,
|
|
visibility: "PERSONAL",
|
|
allergens: [],
|
|
diets: [],
|
|
isFavorite: false,
|
|
ingredients: [],
|
|
steps: [
|
|
{
|
|
id: 1,
|
|
description: "Cuire à la poêle.",
|
|
picture: null,
|
|
order: 1,
|
|
techSteps: [{ techStep: { id: 1, key: "cook" }, start: 0, end: 5 }],
|
|
},
|
|
],
|
|
},
|
|
});
|
|
});
|
|
|
|
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,
|
|
},
|
|
],
|
|
nextCursor: null,
|
|
},
|
|
});
|
|
});
|
|
|
|
/**
|
|
* A second, distinct item from `Given("browsing TheMealDB returns some
|
|
* items")`'s page-1 pair — used by the infinite-scroll/retry scenarios
|
|
* below, which need to tell "the item that only shows up once the *next*
|
|
* page has loaded" apart from what's already visible on page 1.
|
|
*/
|
|
const BEEF_WELLINGTON_ITEM = {
|
|
externalId: "77123",
|
|
title: "Beef Wellington",
|
|
picture: null,
|
|
url: "https://www.themealdb.com/meal/77123",
|
|
alreadyImported: false,
|
|
recipeId: null,
|
|
};
|
|
|
|
const CHICKEN_HANDI_AND_FISH_PIE_PAGE_1 = {
|
|
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,
|
|
},
|
|
],
|
|
nextCursor: "2",
|
|
};
|
|
|
|
Given("browsing TheMealDB returns two pages of items", () => {
|
|
cy.intercept("GET", "**/sources/theMealDb/browse*", (req) => {
|
|
const isNextPage = req.url.includes("cursor=");
|
|
req.reply({
|
|
statusCode: 200,
|
|
body: isNextPage
|
|
? { items: [BEEF_WELLINGTON_ITEM], nextCursor: null }
|
|
: CHICKEN_HANDI_AND_FISH_PIE_PAGE_1,
|
|
});
|
|
});
|
|
});
|
|
|
|
// The panel prefetches the next page as soon as page 1 is on screen (before
|
|
// anyone's actually waited on it), so the *first* request for it is that
|
|
// prefetch — this is what actually fails "once", not a request triggered by
|
|
// a click. `handleLoadMore`'s own retry then makes a genuinely fresh
|
|
// request (see its own doc comment on why a failed prefetch gets cleared),
|
|
// which is the one that succeeds here.
|
|
Given("browsing TheMealDB's next page fails once, then succeeds", () => {
|
|
let nextPageAttempts = 0;
|
|
cy.intercept("GET", "**/sources/theMealDb/browse*", (req) => {
|
|
if (!req.url.includes("cursor=")) {
|
|
req.reply({ statusCode: 200, body: CHICKEN_HANDI_AND_FISH_PIE_PAGE_1 });
|
|
return;
|
|
}
|
|
nextPageAttempts += 1;
|
|
if (nextPageAttempts === 1) {
|
|
req.reply({ statusCode: 500, body: {} });
|
|
} else {
|
|
req.reply({ statusCode: 200, body: { items: [BEEF_WELLINGTON_ITEM], nextCursor: null } });
|
|
}
|
|
});
|
|
});
|
|
|
|
Then("I should see a message to retry loading more", () => {
|
|
cy.contains(".recipes-page__status--error", "Réessayer").should("be.visible");
|
|
});
|
|
|
|
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: [{ techStep: { id: 1, key: "cook" }, start: 0, end: 5 }],
|
|
},
|
|
],
|
|
},
|
|
});
|
|
});
|
|
|
|
Then("I should see the source item {string}", (title: string) => {
|
|
cy.contains(".recipe-table__name", title).should("be.visible");
|
|
});
|
|
|
|
When("I click the source item {string}", (title: string) => {
|
|
cy.contains(".recipe-table__name", title).click();
|
|
});
|
|
|
|
Then("the source item {string} should be marked as already imported", (title: string) => {
|
|
cy.contains("tr", title).find(".source-item-table__imported-badge").should("be.visible");
|
|
});
|
|
|
|
// Browsing a source (outside of adding-to-planning, `RecipePickerDialog`'s
|
|
// own scenarios in planning.ts) never imports anything — the only action
|
|
// this preview offers is a discreet way out to the item's own page.
|
|
Then("I should see a discreet link to the item's original page", () => {
|
|
cy.get(".recipe-detail-panel__source-link").should("be.visible");
|
|
});
|