Suite au retour utilisateur sur le plan « onglet Sources » livré précédemment (#44-#48) : navigation transparente, page recette pour un item externe, comportement d'ajout au planning déjà importé. L'onglet « Sources » (RecipesPage) reste un onglet à part (décision explicite : pas de fusion des listes perso/foyer/publique/externe) — mais son affichage se comporte désormais « comme si c'était importé » : - `SourceItemPreviewPanel` est supprimé, fusionné dans `RecipeDetailPanel` lui-même (nouvel état `"loaded-draft"`) : un item pas encore importé se voit exactement comme une vraie recette — même en-tête, même mise en page description/étapes — la seule différence étant les actions proposées (« Importer cette recette » là où une vraie recette montre Modifier/Supprimer). La liste brute des ingrédients et l'indice « non résolu » disparaissent de cette vue : cette complexité reste réservée à l'écran de revue d'import (ImportRecipePage), pas à un simple aperçu. - Un item pas encore importé gagne une vraie URL adressable — `/recettes/sources/:sourceKey/:externalId` (nouvelle route, RecipesPage) — au même titre qu'une vraie recette a `/recettes/:id`. Avant, le sélectionner ne changeait que de l'état React local dans `RecipeSourcesPanel`, sans URL propre : ni lien direct, ni retour arrière/rafraîchissement possibles. `RecipeSourcesPanel` gagne `initialSelection`/`onItemSelected` pour rester piloté par cette URL sans avoir à connaître le routage lui-même — `RecipePickerDialog` (qui prévisualise dans une modale sans URL propre) laisse les deux non renseignés et garde son comportement inchangé. - `onSelectImportedRecipe` (déjà présent) continue de traiter un item déjà importé exactement comme une vraie recette — c'est justement ce qui rend la navigation transparente pour ce cas. Le troisième point du retour (vérifier si la recette est déjà en base avant de l'ajouter au planning, ne rien faire si oui, l'importer sinon) était déjà le comportement de #48 — inchangé ici, aucune régression: `RecipePickerDialog` résout un item déjà importé vers sa vraie recette sans ré-import, et n'importe que les items qui ne le sont pas encore. Aucun changement backend. Tests : - Cypress : nouvelle assertion d'URL dans le scénario « Previews a not-yet-imported item » de recipe-sources.feature, et nouveau scénario « Deep-links straight to a not-yet-imported item's own page » — la CI confirmera. - `pnpm --filter api test` — 282 tests toujours au vert (aucun changement backend). - `pnpm exec tsc -b --force` (web) — propre. - `pnpm exec biome check` — propre. - `pnpm -r build` — propre. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
293 lines
11 KiB
TypeScript
293 lines
11 KiB
TypeScript
import { Given, Then, When } from "@badeball/cypress-cucumber-preprocessor";
|
|
import { buildProfile, currentProfile, resetProfile, setCurrentProfile } from "../profile";
|
|
|
|
// Steps shared across every feature — signing in/out, navigation, and
|
|
// generic UI assertions/interactions phrased the same way regardless of
|
|
// which page they happen to run against. Anything specific to one feature
|
|
// (its own API responses, its own DOM structure) lives in that feature's
|
|
// own `<name>.steps.ts` instead — same split as apps/api's
|
|
// step-definitions/ (shared "profile already exists" vs. feature-specific
|
|
// steps).
|
|
//
|
|
// Mocks the API via `cy.intercept` — this job doesn't run a live backend
|
|
// (see .github/workflows/ci.yml); apps/api's own Mocha/Cucumber suites
|
|
// cover real API behavior against a real database.
|
|
|
|
Given("I am not signed in", () => {
|
|
cy.intercept("GET", "**/auth/me", { statusCode: 401 });
|
|
});
|
|
|
|
// No `Before()` hook for this reset (deliberately) — registering any
|
|
// Cucumber hook makes the preprocessor's browser runtime read
|
|
// `messages.HookType.{BEFORE,AFTER}_TEST_CASE` to report it, and that enum
|
|
// doesn't exist on the older, CommonJS-only `@cucumber/messages` this repo
|
|
// is pinned to (see `pnpm.overrides` in package.json, and this branch's
|
|
// commit history for why) — every scenario crashed on "Cannot read
|
|
// properties of undefined (reading 'BEFORE_TEST_CASE')" the moment this
|
|
// file registered one. Resetting right here instead, at the one step every
|
|
// profile-building chain always starts with, is equivalent for our
|
|
// purposes without needing a hook at all.
|
|
Given("I am signed in as {string} {string}", (firstName: string, lastName: string) => {
|
|
resetProfile();
|
|
setCurrentProfile(
|
|
buildProfile({
|
|
firstName,
|
|
lastName,
|
|
email: `${firstName.toLowerCase()}@example.com`,
|
|
}),
|
|
);
|
|
cy.intercept("GET", "**/auth/me", { statusCode: 200, body: currentProfile });
|
|
});
|
|
|
|
// Composable with the step above — re-registers the same intercept with an
|
|
// updated profile field. Order between these doesn't matter as long as they
|
|
// all run before the scenario's `visit`/`When` step: Cypress resolves
|
|
// multiple `cy.intercept` calls on the same route by giving the
|
|
// most-recently-registered one priority, so the final, fully-assembled
|
|
// profile is always what the app actually receives.
|
|
Given("my household id is {int}", (houseId: number) => {
|
|
if (!currentProfile) {
|
|
throw new Error('"my household id is" must follow "I am signed in as ..."');
|
|
}
|
|
setCurrentProfile({ ...currentProfile, houseId });
|
|
cy.intercept("GET", "**/auth/me", { statusCode: 200, body: currentProfile });
|
|
});
|
|
|
|
Given("my diet id is {int}", (dietId: number) => {
|
|
if (!currentProfile) {
|
|
throw new Error('"my diet id is" must follow "I am signed in as ..."');
|
|
}
|
|
setCurrentProfile({ ...currentProfile, dietId });
|
|
cy.intercept("GET", "**/auth/me", { statusCode: 200, body: currentProfile });
|
|
});
|
|
|
|
Given("my user id is {int}", (id: number) => {
|
|
if (!currentProfile) {
|
|
throw new Error('"my user id is" must follow "I am signed in as ..."');
|
|
}
|
|
setCurrentProfile({ ...currentProfile, id });
|
|
cy.intercept("GET", "**/auth/me", { statusCode: 200, body: currentProfile });
|
|
});
|
|
|
|
When("I visit {string}", (path: string) => {
|
|
cy.visit(path);
|
|
});
|
|
|
|
Then("the URL should include {string}", (fragment: string) => {
|
|
cy.url().should("include", fragment);
|
|
});
|
|
|
|
Then("the URL should not include {string}", (fragment: string) => {
|
|
cy.url().should("not.include", fragment);
|
|
});
|
|
|
|
Then("the URL should be the home page", () => {
|
|
cy.url().should("eq", `${Cypress.config().baseUrl}/`);
|
|
});
|
|
|
|
Then("I should see {string}", (text: string) => {
|
|
cy.contains(text).should("be.visible");
|
|
});
|
|
|
|
Then("I should see the heading {string}", (text: string) => {
|
|
cy.contains("h1", text).should("be.visible");
|
|
});
|
|
|
|
Then("I should not see {string}", (text: string) => {
|
|
cy.contains(text).should("not.exist");
|
|
});
|
|
|
|
When("I click the button {string}", (text: string) => {
|
|
cy.contains("button", text).click();
|
|
});
|
|
|
|
When("I click the link {string}", (text: string) => {
|
|
cy.contains("a", text).click();
|
|
});
|
|
|
|
When("I fill in the {string} field with {string}", (fieldId: string, value: string) => {
|
|
cy.get(`#${fieldId}`).type(value);
|
|
});
|
|
|
|
When("I clear the {string} field", (fieldId: string) => {
|
|
cy.get(`#${fieldId}`).clear();
|
|
});
|
|
|
|
Then("the {string} field should have the value {string}", (fieldId: string, value: string) => {
|
|
cy.get(`#${fieldId}`).should("have.value", value);
|
|
});
|
|
|
|
When("I select {string} from the {string} field", (value: string, fieldId: string) => {
|
|
cy.get(`#${fieldId}`).select(value);
|
|
});
|
|
|
|
Then("I should see the section {string}", (legend: string) => {
|
|
cy.contains("legend", legend).should("be.visible");
|
|
});
|
|
|
|
// Needed for a section that can render below the fold of `.app-content`'s
|
|
// own scroll (see layout.cy.ts) — a bare `.should("be.visible")` doesn't
|
|
// auto-scroll (only interaction commands like `.click()`/`.check()` do),
|
|
// so a section low on a long page needs this before asserting on it.
|
|
When("I scroll to the section {string}", (legend: string) => {
|
|
cy.contains("legend", legend).scrollIntoView();
|
|
});
|
|
|
|
// `.recipe-detail-panel` is used by both a saved recipe's real detail and
|
|
// an unsaved source item's read-only preview (RecipeDetailPanel's
|
|
// `"loaded"`/`"loaded-draft"` states, same component for both) —
|
|
// recipes.feature and recipe-sources.feature both need this.
|
|
Then("the recipe detail panel heading should be {string}", (text: string) => {
|
|
cy.get(".recipe-detail-panel").contains("h2", text).should("be.visible");
|
|
});
|
|
|
|
// `.step-tech-step`/`.tooltip__bubble` come from StepDescription/Tooltip
|
|
// (components/ui/), rendered by both of those same two panels — same
|
|
// reasoning as the detail-panel-heading step above.
|
|
Then("I should see the highlighted technique {string}", (text: string) => {
|
|
// The steps section can sit below the panel's header/photo/description,
|
|
// off the fold of `.app-content`'s own scroll (see layout.cy.ts) — a
|
|
// bare `.should("be.visible")` doesn't auto-scroll.
|
|
cy.contains(".step-tech-step", text).scrollIntoView().should("be.visible");
|
|
});
|
|
|
|
When("I focus the highlighted technique {string}", (text: string) => {
|
|
cy.contains(".step-tech-step", text).focus();
|
|
});
|
|
|
|
Then("the tooltip should show {string}", (label: string) => {
|
|
cy.get(".tooltip__bubble").contains(label).should("be.visible");
|
|
});
|
|
|
|
// `IngredientPicker`/`IngredientRow`/`StepListEditor` (features/recipes/)
|
|
// back both RecipeFormPage and ImportRecipePage — recipe-form.feature and
|
|
// import-recipe.feature both need these.
|
|
|
|
When("I search the ingredient picker for {string}", (text: string) => {
|
|
cy.get("input[placeholder='Rechercher un ingrédient…']").type(text);
|
|
});
|
|
|
|
When("I select the ingredient {string} from the picker", (name: string) => {
|
|
cy.contains(".ingredient-picker__card", name).click();
|
|
});
|
|
|
|
Then("the ingredient {string} should no longer be in the picker", (name: string) => {
|
|
cy.contains(".ingredient-picker__card", name).should("not.exist");
|
|
});
|
|
|
|
Then("the ingredient {string} should be visible in the picker", (name: string) => {
|
|
cy.contains(".ingredient-picker__card", name).should("be.visible");
|
|
});
|
|
|
|
Then("the recipe should include the ingredient {string}", (name: string) => {
|
|
cy.contains(".ingredient-row__name", name).should("be.visible");
|
|
});
|
|
|
|
Then("there should be {int} ingredient rows", (count: number) => {
|
|
cy.get(".ingredient-row").should("have.length", count);
|
|
});
|
|
|
|
When("I remove the ingredient {string} from the recipe", (name: string) => {
|
|
cy.contains(".ingredient-row", name).find("button[title='Retirer cet ingrédient']").click();
|
|
});
|
|
|
|
When(
|
|
"I fill in the ingredient's quantity with {string} and unit {string}",
|
|
(quantity: string, unit: string) => {
|
|
cy.get(".ingredient-row .ingredient-row__quantity").type(quantity);
|
|
cy.get(".ingredient-row .ingredient-row__unit").select(unit);
|
|
},
|
|
);
|
|
|
|
Then("the ingredient's quantity should be {string}", (quantity: string) => {
|
|
cy.get(".ingredient-row .ingredient-row__quantity").should("have.value", quantity);
|
|
});
|
|
|
|
When("I select unit {string} for the first ingredient", (unit: string) => {
|
|
cy.get(".ingredient-row .ingredient-row__unit").first().select(unit);
|
|
});
|
|
|
|
When(
|
|
"I fill in the last ingredient's quantity with {string} and unit {string}",
|
|
(quantity: string, unit: string) => {
|
|
cy.get(".ingredient-row .ingredient-row__quantity").last().type(quantity);
|
|
cy.get(".ingredient-row .ingredient-row__unit").last().select(unit);
|
|
},
|
|
);
|
|
|
|
When("I add a step", () => {
|
|
cy.contains("button", "Ajouter une étape").click();
|
|
});
|
|
|
|
When("I fill in the step description with {string}", (text: string) => {
|
|
cy.get(".step-list-editor__item textarea").type(text);
|
|
});
|
|
|
|
Then("there should be {int} step editor items", (count: number) => {
|
|
cy.get(".step-list-editor__item").should("have.length", count);
|
|
});
|
|
|
|
Then("the checkbox {string} should be checked", (label: string) => {
|
|
cy.contains("label", label).find("input[type=checkbox]").should("be.checked");
|
|
});
|
|
|
|
Then("the checkbox {string} should not be checked", (label: string) => {
|
|
cy.contains("label", label).find("input[type=checkbox]").should("not.be.checked");
|
|
});
|
|
|
|
When("I check the checkbox {string}", (label: string) => {
|
|
cy.contains("label", label).find("input[type=checkbox]").check();
|
|
});
|
|
|
|
Then("the radio {string} should be checked", (label: string) => {
|
|
cy.contains("label", label).find("input[type=radio]").should("be.checked");
|
|
});
|
|
|
|
Then("the radio {string} should not be checked", (label: string) => {
|
|
cy.contains("label", label).find("input[type=radio]").should("not.be.checked");
|
|
});
|
|
|
|
When("I click the radio {string}", (label: string) => {
|
|
cy.contains("label", label).click();
|
|
});
|
|
|
|
Then("the page should have no theme override", () => {
|
|
cy.get("html").should("not.have.attr", "data-theme");
|
|
});
|
|
|
|
Then("the page theme should be {string}", (theme: string) => {
|
|
cy.get("html").should("have.attr", "data-theme", theme);
|
|
});
|
|
|
|
// Freezes `Date` so "today"/"this week" assertions are deterministic
|
|
// instead of depending on the day the suite happens to run.
|
|
Given("today is frozen at {string}", (iso: string) => {
|
|
cy.clock(new Date(iso), ["Date"]);
|
|
});
|
|
|
|
Given("the viewport is {int} by {int}", (width: number, height: number) => {
|
|
cy.viewport(width, height);
|
|
});
|
|
|
|
Then("the {string} button should not be disabled", (text: string) => {
|
|
cy.contains("button", text).should("not.be.disabled");
|
|
});
|
|
|
|
When("I open the account menu", () => {
|
|
cy.get(".app-sidebar__account-toggle").click();
|
|
});
|
|
|
|
// Not "**/planning*" — that glob also matches the Vite dev request for
|
|
// planning-page.scss. Used by any feature that lands on the home page
|
|
// (Planning) but isn't itself testing the planning grid's content.
|
|
Given("the planning request returns nothing", () => {
|
|
cy.intercept("GET", /\/planning\?/, { statusCode: 200, body: null });
|
|
});
|
|
|
|
Given("the household request returns no household", () => {
|
|
cy.intercept("GET", "**/house/current", { statusCode: 200, body: null });
|
|
});
|
|
|
|
Then("the link {string} should point to {string}", (text: string, href: string) => {
|
|
cy.contains("a", text).should("have.attr", "href", href);
|
|
});
|