* chore(web): session de polish global — version, checkbox, danger zone, icônes
- Affiche le numéro de version (package.json, injecté via Vite) en bas de
la sidebar, masqué en mode collapse et en mobile.
- Factorise les checkbox/radio dupliqués (AllergySelect, DietTagSelect,
IngredientPicker, UserPreferencesPage) en composants partagés
CheckboxOption/RadioOption (components/ui/), et inverse le layout pour
que la case soit à gauche du label.
- Teinte la "zone de danger" de suppression de compte en rouge (fond +
bordure), pas seulement le bouton.
- Migre les icônes de navigation générale vers lucide-react (nav-icons.tsx
devient un fichier de ré-export) ; les pictogrammes d'ingrédients métier
restent en SVG custom (pas d'équivalents fins côté lucide).
Vérifié : pnpm build, pnpm lint, pnpm --filter web e2e (43/43), et
vérification visuelle manuelle (sidebar desktop/collapsed/mobile, light/dark).
* feat(web): icônes d'ingrédients depuis foodiconpack.com + page de crédits
- Remplace 19 des 22 pictogrammes génériques d'ingrédients par des icônes
curées du pack gratuit "Common ingredient icons"/"Common Utensils" de
foodiconpack.com (CC BY 4.0) : carotte, pomme, basilic, bœuf, poulet,
saumon, crevette, riz, pois chiches, amandes, lait, cheddar, œufs,
cannelle, miel, huile d'olive, bière, marmite, sucre.
- BREAD/DOUGH/SPROUT restent en SVG custom : pas d'équivalent net dans le
pack (packs "ingrédients"/"ustensiles"/"plats"/"boissons" vérifiés).
Architecture inchangée : `icon` reste un enum de 22 valeurs partagées en
base (pas de migration, pas de mapping par ingrédient — cf. le
commentaire du fichier sur l'historique emoji→enum générique).
- Nouveau wrapper FilledIcon (fill="currentColor", viewBox 2048) à côté du
wrapper Icon existant (stroke) — les deux stylent au même endroit via
CSS, donc le mélange des 19+3 icônes reste visuellement homogène.
- Ajoute /parametres/credits (CreditsPage) créditant foodiconpack.com et
liant la licence CC BY 4.0, requis par la licence des icônes utilisées ;
nouvelle entrée de nav "Crédits" (icône lucide Info).
Vérifié : pnpm build, pnpm lint, pnpm --filter web e2e (43/43), et
vérification visuelle (grille des 22 icônes dans le picker, page crédits).
* feat(web,api): zone dangereuse rouge, préférences élargies, onglet favoris par défaut, e2e recettes, catalogue en uid+i18n
- Zone dangereuse (compte) : le bouton "Supprimer mon compte" est rouge.
- Pages préférences/paramétrage : contenu centré et élargi (32rem -> 56rem)
au lieu de coller à gauche sur un écran large.
- Page recettes : l'onglet "Favoris" est sélectionné par défaut.
- Ajout de apps/web/cypress/e2e/recipes.cy.ts (onglets, recherche, sélection
master-detail, favori, suppression, lien nouvelle recette).
- Catalogue de référence (ingrédients/régimes/allergènes) : la colonne
`name` (le libellé français, utilisé comme clé unique) devient `key`, un
slug stable et opaque au sens produit (ex. "vegetarien", "boeuf_hache").
Le libellé lui-même déménage entièrement côté client, dans
apps/web/src/locales/fr/translation.json sous le namespace `catalog.*`,
résolu via `t(\`catalog.ingredients.${key}\`)` etc. — même schéma que
IngredientCategory/IngredientSubcategory. Migration Prisma
(rename + backfill des ~456 lignes déjà seedées), seed/service/tests API
et composants web mis à jour en conséquence.
- apps/api/src/utils/slugify.ts + scripts/generate-catalog-i18n.ts
(regénère le fichier de traduction depuis reference-seed-data.ts).
- 102 tests Mocha + 32 scénarios Cucumber passent contre la base migrée.
Note : cypress run plante dans cet environnement (le processus GPU
Chromium/Electron crash même headless, indépendamment des flags) — les
recipes.cy.ts n'ont pas pu être exécutés ici ; vérifiés par lecture du code
source des composants visés et par un passage manuel dans le navigateur de
prévisualisation.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
* fix(api): les uids du catalogue sont en anglais, pas des slugs français
reference-seed-data.ts reste rédigé en français (c'est juste le libellé
d'autoring, jamais stocké/exposé), mais la clé stable (`Diet.key`/
`Category.key`/`Ingredient.key`) qu'on en dérive doit elle-même être un
identifiant anglais, indépendant de la langue d'autoring — pas juste le
même texte français passé à slugify().
- apps/api/src/db/catalog-en-keys.ts : dictionnaire écrit à la main
(label français -> clé anglaise) pour les 5 régimes, 14 allergènes et
437 ingrédients ; getEnglishKey() lève une erreur explicite si un
nouvel élément n'a pas encore d'entrée plutôt que de retomber sur un
slug français silencieux.
- scripts/validate-catalog-en-keys.ts : vérifie que chaque diet/allergène/
ingrédient de reference-seed-data.ts a une entrée, et que les clés
anglaises résultantes sont uniques (437/437, 14/14, 5/5 — zéro manquant,
zéro collision).
- reference-seed-data.ts et scripts/generate-catalog-i18n.ts utilisent
désormais getEnglishKey() au lieu de slugify(nom français).
- Nouvelle migration (20260818193000_catalog_keys_to_english) qui
remappe les lignes déjà seedées avec un slug français (par la migration
précédente) vers leur clé anglaise définitive.
- apps/web/src/locales/fr/translation.json régénéré : catalog.* est
maintenant indexé par clé anglaise ("vegetarian", "eggs",
"ground_beef"...), toujours avec le libellé français en valeur.
- Tests/step-definitions mis à jour (getEnglishKey() au lieu de
slugify()) ; 102 tests Mocha + 32 scénarios Cucumber passent contre la
base migrée. Vérifié aussi en direct via GET /reference/diets et
/reference/allergies.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
* fix(web): crypto.randomUUID plante hors contexte sécurisé, empêchant d'associer un ingrédient
Écran noir + "TypeError: crypto.randomUUID is not a function" au clic sur
une carte d'ingrédient dans le formulaire de recette. crypto.randomUUID()
n'est défini que dans un "contexte sécurisé" (https, ou littéralement le
host "localhost") — il est absent sur une IP locale (test sur un vrai
appareil), dans une WebView Capacitor (l'enrobage mobile prévu pour cette
app), ou en http sur un vrai domaine. RecipeFormPage/StepListEditor s'en
servaient pour générer l'identité React (`key`) de chaque ligne
d'ingrédient/étape en brouillon.
- apps/web/src/lib/client-key.ts : remplace par un générateur qui ne
touche jamais `crypto` — un compteur + Math.random suffit, cette valeur
n'a besoin d'être unique que le temps de la session de rendu, jamais
envoyée au serveur.
- apps/web/cypress/e2e/recipe-form.cy.ts : couvre l'association d'un
ingrédient (recherche, sélection, exclusion du picker une fois
sélectionné, retrait), la création et l'édition d'une recette, et un
test de non-régression dédié qui supprime crypto.randomUUID avant le
chargement de la page (comme le ferait un vrai contexte non sécurisé)
pour vérifier que l'ajout de plusieurs ingrédients/étapes ne plante
plus.
Vérifié en direct dans le navigateur de prévisualisation en supprimant
crypto.randomUUID à la main (reproduit le crash), puis en confirmant que
l'ajout d'ingrédient fonctionne à nouveau après le correctif. cypress run
ne peut toujours pas s'exécuter dans cet environnement (voir le commit
précédent) — non exécutés avec Cypress lui-même, mais vérifiés par
lecture des sélecteurs réels et rejoués à la main dans le navigateur.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
* fix(ci): corrige les specs Cypress cassées par le refactor uid+i18n, applique biome
- onboarding.cy.ts / preferences.cy.ts / recipes.cy.ts mockaient encore
GET /reference/diets|allergies avec l'ancienne forme {id, name}. Depuis
les deux derniers commits l'API renvoie {id, key} (uid anglais) et le
composant résout le libellé via i18n (t(`catalog.diets.${key}`)) — avec
key manquant, ça affichait littéralement "catalog.diets.undefined" au
lieu de "Végétarien"/"Omnivore"/etc., faisant échouer cy.select()/
cy.contains() dans ces 3 specs. Corrigé pour mocker {key: "vegetarian"},
{key: "peanuts"}, etc.
- recipes.cy.ts : le test "shows a not-found message" utilisait le
mauvais code d'erreur (4041 au lieu de ErrorCode.RECIPE_NOT_FOUND =
4045), donc RecipeDetailPanel tombait dans son état d'erreur générique
au lieu du message "Cette recette n'existe pas." — bug dans mon propre
test, sans rapport avec le refactor.
- pnpm lint (biome) : les fichiers touchés par le refactor précédent
avaient quelques soucis de formatage/tri d'imports (des sed multi-
fichiers, pas d'édition via l'outil habituel) — corrigés par
`biome check --write`.
Vérifié : ces 3 specs + recipe-form.cy.ts passent maintenant dans le job
CI GitHub Actions (Linux, Cypress s'y exécute réellement — contrairement
à cet environnement Windows sandboxé, voir les commits précédents) ; 102
tests Mocha + 32 scénarios Cucumber toujours au vert en local.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
---------
Co-authored-by: Claude Sonnet 5 <noreply@anthropic.com>
241 lines
8.6 KiB
TypeScript
241 lines
8.6 KiB
TypeScript
// Mocks the API via cy.intercept — see auth.cy.ts for the rationale (no
|
|
// live backend in this CI job; apps/api's own Mocha/Cucumber suites cover
|
|
// real API behavior against a real database).
|
|
|
|
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,
|
|
authorId: 1,
|
|
visibility: "PERSONAL",
|
|
allergens: [],
|
|
diets: [vegetarien],
|
|
isFavorite: true,
|
|
};
|
|
|
|
const omelette = {
|
|
id: 2,
|
|
name: "Omelette",
|
|
description: null,
|
|
picture: null,
|
|
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: "CREMERIE_FROMAGE",
|
|
subcategory: "OEUFS",
|
|
allergens: [oeufs],
|
|
diets: [],
|
|
},
|
|
quantity: 3,
|
|
unit: "unité",
|
|
},
|
|
],
|
|
steps: [
|
|
{ id: 1, description: "Battre les œufs.", picture: null, order: 1 },
|
|
{ id: 2, description: "Cuire à la poêle.", picture: null, order: 2 },
|
|
],
|
|
};
|
|
|
|
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", () => {
|
|
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("toggles a recipe's favorite from the detail panel and reflects it in the table", () => {
|
|
cy.intercept("GET", /\/recipes\?/, { statusCode: 200, body: [omelette] });
|
|
cy.intercept("GET", "**/recipes/2", { statusCode: 200, body: omeletteDetail });
|
|
cy.intercept("POST", "**/recipes/2/favorite", { statusCode: 204 }).as("favorite");
|
|
|
|
cy.visit("/recettes/2");
|
|
|
|
cy.contains(".recipe-table__name", "Omelette")
|
|
.find(".recipe-table__fav-mark")
|
|
.should("not.exist");
|
|
|
|
cy.get(".favorite-star-button").click();
|
|
cy.wait("@favorite");
|
|
|
|
cy.get(".favorite-star-button").should("have.class", "is-favorite");
|
|
cy.contains(".recipe-table__name", "Omelette").find(".recipe-table__fav-mark").should("exist");
|
|
});
|
|
|
|
it("deletes a recipe after a two-step confirmation, then clears the selection", () => {
|
|
cy.intercept("GET", /\/recipes\?/, { statusCode: 200, body: [omelette] });
|
|
cy.intercept("GET", "**/recipes/2", { statusCode: 200, body: omeletteDetail });
|
|
cy.intercept("DELETE", "**/recipes/2", { statusCode: 204 }).as("deleteRecipe");
|
|
|
|
cy.visit("/recettes/2");
|
|
cy.contains(".recipe-detail-panel", "Omelette").should("be.visible");
|
|
|
|
cy.contains(".recipe-detail-panel__danger-button", "Supprimer").click();
|
|
cy.contains(".recipe-detail-panel__danger-button", "Confirmer la suppression").click();
|
|
cy.wait("@deleteRecipe");
|
|
|
|
cy.url().should("match", /\/recettes\/?$/);
|
|
cy.contains(".recipe-table__name", "Omelette").should("not.exist");
|
|
cy.contains("Sélectionnez une recette dans le tableau").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",
|
|
);
|
|
});
|
|
});
|