Expose côté UI ce que tech-step-matcher.ts détecte déjà à la sauvegarde
(Step.techSteps) mais qui restait backend-only : dans le panneau détail
d'une recette, les mots exacts ayant déclenché une technique sont
surlignés, avec un tooltip (survol/focus clavier) donnant son nom.
- tech-step-matcher.ts : matchTechStepSpans(description, mappings) expose
désormais {techStepId, start, end} en plus de la simple séquence d'ids
(déjà calculé en interne, jusqu'ici jeté). matchTechSteps devient un
wrapper fin dessus — aucun changement à ses ~12 tests existants ni à
recipe-translation.ts.
- StepTechStep gagne start/end (nullable, pas de backfill — même leçon que
l'incident de migration ingredient_unit_catalog : NOT NULL sans défaut
sur une table déjà peuplée casse le déploiement). Une ligne pré-existante
sans span est simplement omise de la réponse API plutôt que de fuiter un
null, jusqu'à ce que la recette soit resauvegardée.
- recipe.service.ts : createRecipe/updateRecipe persistent start/end ;
StepView expose techSteps: { techStep: {id,key}, start, end }[]. Le
recalcul complet à chaque édition (ajout/modif/suppression d'étape) était
déjà garanti par le delete-then-recreate existant d'updateRecipe — testé
explicitement (nouveau test "recomputes techniques from scratch...").
- Frontend : StepDescription.tsx (découpe le texte via
highlight-tech-steps.ts, pur et testé) remplace le <p> brut dans
RecipeDetailPanel. Nouveau Tooltip.tsx (composants/ui, CSS pur, aucune
lib externe — même esprit que Dialog.tsx) : un <button> (focusable
nativement, pas de tabIndex sur un <mark> non interactif) affiche le nom
de la technique (catalog.techSteps.<key>) au survol/focus.
Tests : matchTechStepSpans (spans corrects, chevauchement résolu),
recipe.test.ts (forme API + recalcul complet sur modif/ajout/suppression
d'étape, avec vérification que les anciennes lignes StepTechStep sont bien
supprimées), splitDescriptionByTechSteps (tri, bornes invalides ignorées,
chevauchement résiduel ignoré), scénario Cypress recipes.feature
(surlignage + tooltip au focus).
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
219 lines
7.8 KiB
TypeScript
219 lines
7.8 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: [] },
|
||
],
|
||
};
|
||
|
||
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");
|
||
|
||
// The disabled "Sources (bientôt)" placeholder never becomes active.
|
||
cy.contains(".recipe-tabs__tab", "Sources (bientôt)").should("be.disabled");
|
||
});
|
||
|
||
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");
|
||
});
|
||
});
|
||
|
||
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",
|
||
);
|
||
});
|
||
});
|