`@biomejs/biome` passe de 1.9.4 à 2.5.9 (config migrée via `biome migrate
--write`) — nécessaire pour noFloatingPromises, une règle type-aware
apparue en 2.0 (nursery).
- noExplicitAny : déjà "recommended", actif depuis toujours, aucun changement.
- noConsole (biome.json) : bloque tout `console.*` sauf error/warn/info/
debug/table/assert — équivalent à "pas de console.log" sans interdire
les niveaux nommés (voir le nouveau log service dans le prochain commit,
qui centralise justement ces appels).
- noFloatingPromises (nursery) activé explicitement sous `rules.nursery`
sans avoir besoin d'activer le domaine "types" au sens large (ça aurait
aussi allumé des dizaines d'autres règles type-aware type
noUnresolvedImports/noUnnecessaryConditions, hors scope ici).
Le reste du diff, c'est soit du reformatage automatique (import sort, 2.x
ordonne différemment de 1.9.4 — `biome check --write --unsafe`), soit les
corrections des ~20 promesses flottantes que la nouvelle règle a fait
remonter :
- La plupart sont des `navigate(...)` non attendus (react-router v7 type
`navigate` en `void | Promise<void>`) — préfixés `void navigate(...)`,
aucun changement de comportement.
- Trois chargements initiaux en useEffect (OnboardingAllergensPage,
OnboardingDietPage, OnboardingHouseholdPage, HouseholdSettingsPage)
n'avaient jamais de `.catch()` du tout — ajouté (dégradation silencieuse
vers un état vide/par défaut, même raisonnement que le `.catch()` déjà
présent dans OnboardingSourcesPage).
- HouseholdSettingsPage : `loadHouse` était une fonction déclarée à chaque
render (donc une référence différente à chaque fois) utilisée comme
dépendance de useEffect ET passée en callback à des enfants — le
useEffect se re-déclenchait donc à chaque re-render provoqué par son
propre fetch, un vrai bug de boucle infinie de requêtes que
noFloatingPromises a fait remonter indirectement (via
useExhaustiveDependencies). Corrigé avec useCallback([]).
- RecipeDetailPanel : une clé de liste `${index}-...}` sur une liste
statique (draft.steps, sans id stable — DraftRecipeStepView n'en a pas)
— biome-ignore justifié, pas de bug réel.
- recipe.test.ts : variable `agent` non utilisée, retirée.
Vérifié : `pnpm --filter api test` (295/295), `pnpm lint` et `pnpm build`
clean sur tout le repo.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
460 lines
16 KiB
TypeScript
460 lines
16 KiB
TypeScript
import { expect } from "chai";
|
|
import { prisma } from "../src/db/prisma.js";
|
|
import type { IngredientMatchEntry, UnitMatchEntry } from "../src/lib/ingredient-matcher.js";
|
|
import type { ParsedRecipe, ParsedRecipeIngredient } from "../src/lib/recipe-source-adapter.js";
|
|
import {
|
|
mergeDuplicateIngredients,
|
|
type TranslatedRecipeIngredient,
|
|
translateRecipe,
|
|
translateRecipeIngredients,
|
|
translateRecipeSteps,
|
|
type UnitConversionEntry,
|
|
} from "../src/lib/recipe-translation.js";
|
|
import type { TechStepMappingRule } from "../src/lib/tech-step-matcher.js";
|
|
import { resetDatabase } from "../test-support/reset-db.js";
|
|
|
|
/** A minimal fixture `ParsedRecipe` — only `steps` (built from `descriptions`) matters for most tests here, the rest is filler to prove it survives translation untouched. */
|
|
function buildParsedRecipe(descriptions: string[]): ParsedRecipe {
|
|
return {
|
|
name: "Test Recipe",
|
|
description: "A recipe for testing",
|
|
picture: "https://example.test/recipe.jpg",
|
|
portions: 4,
|
|
sourceUrl: "https://example.test/recipes/1",
|
|
ingredients: [{ rawText: "1 egg", quantity: null, unit: null, name: "egg" }],
|
|
steps: descriptions.map((description, i) => ({
|
|
description,
|
|
picture: i === 0 ? "https://example.test/step1.jpg" : null,
|
|
})),
|
|
};
|
|
}
|
|
|
|
describe("recipe-translation", () => {
|
|
describe("translateRecipeSteps", () => {
|
|
const simmer: TechStepMappingRule = {
|
|
techStepId: 1,
|
|
expression: "\\bmijot(er|ez|e|ant|é)\\b",
|
|
weight: 15,
|
|
};
|
|
const preheat: TechStepMappingRule = {
|
|
techStepId: 2,
|
|
expression: "\\bpr[ée]chauff(er|ez|é|ée)\\b",
|
|
weight: 20,
|
|
};
|
|
const melt: TechStepMappingRule = {
|
|
techStepId: 3,
|
|
expression: "\\bfaire fondre\\b|\\bfaites fondre\\b",
|
|
weight: 15,
|
|
};
|
|
|
|
it("declares each step's technique sequence, preserving order", () => {
|
|
const recipe = buildParsedRecipe([
|
|
"Préchauffer la poêle, puis faire fondre le beurre",
|
|
"Servir immédiatement",
|
|
"Faire mijoter à feu doux",
|
|
]);
|
|
|
|
const translated = translateRecipeSteps(recipe, [simmer, preheat, melt]);
|
|
|
|
expect(translated.steps.map((step) => step.techStepIds)).to.deep.equal([[2, 3], [], [1]]);
|
|
});
|
|
|
|
it("leaves description/picture untouched on each step", () => {
|
|
const recipe = buildParsedRecipe(["Faire mijoter à feu doux", "Servir"]);
|
|
|
|
const translated = translateRecipeSteps(recipe, [simmer]);
|
|
|
|
expect(translated.steps[0]).to.deep.equal({
|
|
description: "Faire mijoter à feu doux",
|
|
picture: "https://example.test/step1.jpg",
|
|
techStepIds: [1],
|
|
});
|
|
expect(translated.steps[1]).to.deep.equal({
|
|
description: "Servir",
|
|
picture: null,
|
|
techStepIds: [],
|
|
});
|
|
});
|
|
|
|
it("passes every other field through unchanged", () => {
|
|
const recipe = buildParsedRecipe(["Servir"]);
|
|
|
|
const translated = translateRecipeSteps(recipe, []);
|
|
|
|
expect(translated.name).to.equal(recipe.name);
|
|
expect(translated.description).to.equal(recipe.description);
|
|
expect(translated.picture).to.equal(recipe.picture);
|
|
expect(translated.portions).to.equal(recipe.portions);
|
|
expect(translated.sourceUrl).to.equal(recipe.sourceUrl);
|
|
});
|
|
|
|
it("stubs every ingredient's ingredientId/unitId to null, leaving the rest of it untouched — actual ingredient matching is translateRecipeIngredients' job", () => {
|
|
const recipe = buildParsedRecipe(["Servir"]);
|
|
|
|
const translated = translateRecipeSteps(recipe, []);
|
|
|
|
expect(translated.ingredients).to.deep.equal([
|
|
{ ...recipe.ingredients[0], ingredientId: null, unitId: null },
|
|
]);
|
|
});
|
|
|
|
it("gives every step an empty sequence when there are no mappings at all", () => {
|
|
const recipe = buildParsedRecipe(["Faire mijoter à feu doux", "Préchauffer le four"]);
|
|
|
|
const translated = translateRecipeSteps(recipe, []);
|
|
|
|
expect(translated.steps.map((step) => step.techStepIds)).to.deep.equal([[], []]);
|
|
});
|
|
|
|
it("handles a recipe with no steps without error", () => {
|
|
const recipe = buildParsedRecipe([]);
|
|
|
|
const translated = translateRecipeSteps(recipe, [simmer]);
|
|
|
|
expect(translated.steps).to.deep.equal([]);
|
|
});
|
|
});
|
|
|
|
describe("translateRecipeIngredients", () => {
|
|
const tomato: IngredientMatchEntry = { ingredientId: 1, label: "Tomato" };
|
|
const chicken: IngredientMatchEntry = { ingredientId: 2, label: "Chicken" };
|
|
const chickenBreast: IngredientMatchEntry = { ingredientId: 3, label: "Chicken breast" };
|
|
const onion: IngredientMatchEntry = { ingredientId: 4, label: "Onion" };
|
|
|
|
const gram: UnitMatchEntry = { unitId: 10, synonyms: ["g", "gram", "grams"] };
|
|
const cup: UnitMatchEntry = { unitId: 11, synonyms: ["cup", "cups"] };
|
|
|
|
function buildIngredient(
|
|
overrides: Partial<ParsedRecipeIngredient> & { rawText: string; name: string },
|
|
): ParsedRecipeIngredient {
|
|
return { quantity: null, unit: null, ...overrides };
|
|
}
|
|
|
|
it("resolves ingredientId from free-text name, tolerating extra descriptive words and plurals", () => {
|
|
const translated = translateRecipeIngredients(
|
|
[
|
|
buildIngredient({
|
|
rawText: "2 large diced yellow onions",
|
|
name: "large diced yellow onions",
|
|
}),
|
|
],
|
|
[tomato, chicken, chickenBreast, onion],
|
|
[],
|
|
);
|
|
|
|
expect(translated[0].ingredientId).to.equal(onion.ingredientId);
|
|
});
|
|
|
|
it("prefers the more specific multi-word label over a shorter one it contains", () => {
|
|
const translated = translateRecipeIngredients(
|
|
[
|
|
buildIngredient({
|
|
rawText: "2 boneless chicken breasts",
|
|
name: "boneless chicken breasts",
|
|
}),
|
|
],
|
|
[tomato, chicken, chickenBreast, onion],
|
|
[],
|
|
);
|
|
|
|
expect(translated[0].ingredientId).to.equal(chickenBreast.ingredientId);
|
|
});
|
|
|
|
it("returns a null ingredientId when nothing in the catalog matches", () => {
|
|
const translated = translateRecipeIngredients(
|
|
[buildIngredient({ rawText: "1 mango", name: "mango" })],
|
|
[tomato, chicken, chickenBreast, onion],
|
|
[],
|
|
);
|
|
|
|
expect(translated[0].ingredientId).to.equal(null);
|
|
});
|
|
|
|
it("extracts a mixed-number quantity and unit from rawText when the source left them null", () => {
|
|
const translated = translateRecipeIngredients(
|
|
[buildIngredient({ rawText: "1 1/2 cups chicken breast", name: "chicken breast" })],
|
|
[chickenBreast],
|
|
[cup],
|
|
);
|
|
|
|
expect(translated[0].quantity).to.equal(1.5);
|
|
expect(translated[0].unitId).to.equal(cup.unitId);
|
|
});
|
|
|
|
it("trusts the source's own quantity/unit over re-deriving them from rawText", () => {
|
|
const translated = translateRecipeIngredients(
|
|
[
|
|
buildIngredient({
|
|
rawText: "some raw text that happens to mention cups",
|
|
name: "tomato",
|
|
quantity: 3,
|
|
unit: "g",
|
|
}),
|
|
],
|
|
[tomato],
|
|
[gram, cup],
|
|
);
|
|
|
|
expect(translated[0].quantity).to.equal(3);
|
|
expect(translated[0].unitId).to.equal(gram.unitId);
|
|
});
|
|
|
|
it("falls back to the generic 'piece' unit when a quantity was found but no unit word was (issue #53)", () => {
|
|
const piece: UnitMatchEntry = { unitId: 12, synonyms: ["piece", "pieces", "pc", "pcs"] };
|
|
|
|
const translated = translateRecipeIngredients(
|
|
[buildIngredient({ rawText: "4 Egg Yolks", name: "Egg Yolks" })],
|
|
[],
|
|
[gram, cup, piece],
|
|
);
|
|
|
|
expect(translated[0].quantity).to.equal(4);
|
|
expect(translated[0].unitId).to.equal(piece.unitId);
|
|
});
|
|
|
|
it("does not fall back to 'piece' when there's no quantity at all to count", () => {
|
|
const piece: UnitMatchEntry = { unitId: 12, synonyms: ["piece", "pieces", "pc", "pcs"] };
|
|
|
|
const translated = translateRecipeIngredients(
|
|
[buildIngredient({ rawText: "salt to taste", name: "salt" })],
|
|
[],
|
|
[piece],
|
|
);
|
|
|
|
expect(translated[0].quantity).to.equal(null);
|
|
expect(translated[0].unitId).to.equal(null);
|
|
});
|
|
|
|
it("leaves quantity/unitId null when rawText has neither a leading number nor a recognizable unit", () => {
|
|
const translated = translateRecipeIngredients(
|
|
[buildIngredient({ rawText: "salt to taste", name: "salt" })],
|
|
[],
|
|
[],
|
|
);
|
|
|
|
expect(translated[0].quantity).to.equal(null);
|
|
expect(translated[0].unitId).to.equal(null);
|
|
});
|
|
|
|
it("leaves rawText/name/description untouched", () => {
|
|
const ingredient = buildIngredient({ rawText: "1 cup onions", name: "onions" });
|
|
|
|
const translated = translateRecipeIngredients([ingredient], [onion], [cup]);
|
|
|
|
expect(translated[0].rawText).to.equal(ingredient.rawText);
|
|
expect(translated[0].name).to.equal(ingredient.name);
|
|
});
|
|
});
|
|
|
|
describe("mergeDuplicateIngredients", () => {
|
|
const gram: UnitConversionEntry = { id: 1, type: "MASS", toBaseFactor: 1 };
|
|
const kilogram: UnitConversionEntry = { id: 2, type: "MASS", toBaseFactor: 1000 };
|
|
const milliliter: UnitConversionEntry = { id: 3, type: "VOLUME", toBaseFactor: 1 };
|
|
const piece: UnitConversionEntry = { id: 4, type: "COUNT", toBaseFactor: 1 };
|
|
const slice: UnitConversionEntry = { id: 5, type: "COUNT", toBaseFactor: 1 };
|
|
|
|
function buildLine(
|
|
overrides: Partial<TranslatedRecipeIngredient> & { rawText: string },
|
|
): TranslatedRecipeIngredient {
|
|
return {
|
|
name: overrides.rawText,
|
|
quantity: null,
|
|
unit: null,
|
|
ingredientId: null,
|
|
unitId: null,
|
|
...overrides,
|
|
};
|
|
}
|
|
|
|
it("sums the quantity of two lines resolving to the same ingredient, same unit (issue #53 follow-up)", () => {
|
|
const merged = mergeDuplicateIngredients(
|
|
[
|
|
buildLine({ rawText: "100g Sugar", ingredientId: 1, quantity: 100, unitId: gram.id }),
|
|
buildLine({ rawText: "45g Sugar", ingredientId: 1, quantity: 45, unitId: gram.id }),
|
|
],
|
|
[gram, kilogram, milliliter, piece, slice],
|
|
);
|
|
|
|
expect(merged).to.have.length(1);
|
|
expect(merged[0].quantity).to.equal(145);
|
|
expect(merged[0].unitId).to.equal(gram.id);
|
|
expect(merged[0].rawText).to.equal("100g Sugar + 45g Sugar");
|
|
});
|
|
|
|
it("converts through toBaseFactor when the duplicate uses a different unit of the same type", () => {
|
|
const merged = mergeDuplicateIngredients(
|
|
[
|
|
buildLine({ rawText: "500g Flour", ingredientId: 1, quantity: 500, unitId: gram.id }),
|
|
buildLine({
|
|
rawText: "0.5kg Flour",
|
|
ingredientId: 1,
|
|
quantity: 0.5,
|
|
unitId: kilogram.id,
|
|
}),
|
|
],
|
|
[gram, kilogram, milliliter, piece, slice],
|
|
);
|
|
|
|
expect(merged).to.have.length(1);
|
|
expect(merged[0].quantity).to.equal(1000);
|
|
expect(merged[0].unitId).to.equal(gram.id);
|
|
});
|
|
|
|
it("keeps duplicate COUNT-unit lines separate rather than guessing — a slice isn't a piece", () => {
|
|
const merged = mergeDuplicateIngredients(
|
|
[
|
|
buildLine({ rawText: "2 piece Bread", ingredientId: 1, quantity: 2, unitId: piece.id }),
|
|
buildLine({ rawText: "3 slice Bread", ingredientId: 1, quantity: 3, unitId: slice.id }),
|
|
],
|
|
[gram, kilogram, milliliter, piece, slice],
|
|
);
|
|
|
|
expect(merged).to.have.length(2);
|
|
});
|
|
|
|
it("keeps duplicate lines with incompatible unit types separate (MASS vs VOLUME)", () => {
|
|
const merged = mergeDuplicateIngredients(
|
|
[
|
|
buildLine({ rawText: "200g Milk", ingredientId: 1, quantity: 200, unitId: gram.id }),
|
|
buildLine({
|
|
rawText: "200ml Milk",
|
|
ingredientId: 1,
|
|
quantity: 200,
|
|
unitId: milliliter.id,
|
|
}),
|
|
],
|
|
[gram, kilogram, milliliter, piece, slice],
|
|
);
|
|
|
|
expect(merged).to.have.length(2);
|
|
});
|
|
|
|
it("never merges two unresolved lines (ingredientId: null) together", () => {
|
|
const merged = mergeDuplicateIngredients(
|
|
[
|
|
buildLine({ rawText: "1 vanilla pod", quantity: 1 }),
|
|
buildLine({ rawText: "1 vanilla pod", quantity: 1 }),
|
|
],
|
|
[gram, kilogram, milliliter, piece, slice],
|
|
);
|
|
|
|
expect(merged).to.have.length(2);
|
|
});
|
|
|
|
it("leaves non-duplicate lines untouched and preserves order", () => {
|
|
const sugar = buildLine({ rawText: "Sugar", ingredientId: 1, quantity: 1, unitId: gram.id });
|
|
const salt = buildLine({ rawText: "Salt", ingredientId: 2, quantity: 1, unitId: gram.id });
|
|
|
|
const merged = mergeDuplicateIngredients([sugar, salt], [gram, kilogram, milliliter]);
|
|
|
|
expect(merged).to.deep.equal([sugar, salt]);
|
|
});
|
|
});
|
|
|
|
describe("translateRecipe", () => {
|
|
beforeEach(async () => {
|
|
await resetDatabase();
|
|
});
|
|
|
|
after(async () => {
|
|
await prisma.$disconnect();
|
|
});
|
|
|
|
it("resolves real TechStep ids from the seeded French catalog", async () => {
|
|
const simmer = await prisma.techStep.findFirstOrThrow({ where: { key: "simmer" } });
|
|
const chop = await prisma.techStep.findFirstOrThrow({ where: { key: "chop" } });
|
|
const recipe = buildParsedRecipe(["Hacher les oignons", "Faire mijoter à feu doux"]);
|
|
|
|
const translated = await translateRecipe(recipe, "fr");
|
|
|
|
expect(translated.steps.map((step) => step.techStepIds)).to.deep.equal([
|
|
[chop.id],
|
|
[simmer.id],
|
|
]);
|
|
});
|
|
|
|
it("finds nothing for English text against the French catalog — locales are separate rule sets, never mixed", async () => {
|
|
// A step lifted verbatim from a real TheMealDB recipe.
|
|
const recipe = buildParsedRecipe([
|
|
"Bring a large saucepan of salted water to the boil",
|
|
"Chop the onions finely",
|
|
]);
|
|
|
|
const translated = await translateRecipe(recipe, "fr");
|
|
|
|
expect(translated.steps.map((step) => step.techStepIds)).to.deep.equal([[], []]);
|
|
});
|
|
|
|
it("resolves real TechStep ids from the seeded English catalog", async () => {
|
|
const boil = await prisma.techStep.findFirstOrThrow({ where: { key: "boil" } });
|
|
const chop = await prisma.techStep.findFirstOrThrow({ where: { key: "chop" } });
|
|
// Same two steps as the French/English mismatch test above, this
|
|
// time matched against the matching-language catalog.
|
|
const recipe = buildParsedRecipe([
|
|
"Bring a large saucepan of salted water to the boil",
|
|
"Chop the onions finely",
|
|
]);
|
|
|
|
const translated = await translateRecipe(recipe, "en");
|
|
|
|
expect(translated.steps.map((step) => step.techStepIds)).to.deep.equal([
|
|
[boil.id],
|
|
[chop.id],
|
|
]);
|
|
});
|
|
|
|
it("finds nothing for a locale with no mappings at all", async () => {
|
|
const recipe = buildParsedRecipe(["Faire mijoter à feu doux"]);
|
|
|
|
const translated = await translateRecipe(recipe, "de");
|
|
|
|
expect(translated.steps.map((step) => step.techStepIds)).to.deep.equal([[]]);
|
|
});
|
|
|
|
it("resolves real Ingredient/Unit ids from the seeded English catalog for an 'en' translation", async () => {
|
|
const onion = await prisma.ingredient.findFirstOrThrow({ where: { key: "onion" } });
|
|
const cup = await prisma.unit.findFirstOrThrow({ where: { key: "cup" } });
|
|
const recipe: ParsedRecipe = {
|
|
...buildParsedRecipe(["Chop the onions finely"]),
|
|
ingredients: [
|
|
{ rawText: "1 cup onions, chopped", quantity: null, unit: null, name: "onions" },
|
|
],
|
|
};
|
|
|
|
const translated = await translateRecipe(recipe, "en");
|
|
|
|
expect(translated.ingredients).to.deep.equal([
|
|
{
|
|
rawText: "1 cup onions, chopped",
|
|
quantity: 1,
|
|
unit: null,
|
|
name: "onions",
|
|
ingredientId: onion.id,
|
|
unitId: cup.id,
|
|
},
|
|
]);
|
|
});
|
|
|
|
it("leaves ingredients untouched (no quantity extraction either) for a non-English locale — no matching data exists yet, and the DB isn't even queried for it", async () => {
|
|
const recipe: ParsedRecipe = {
|
|
...buildParsedRecipe(["Faire mijoter à feu doux"]),
|
|
ingredients: [
|
|
{ rawText: "1 cup onions, chopped", quantity: null, unit: null, name: "onions" },
|
|
],
|
|
};
|
|
|
|
const translated = await translateRecipe(recipe, "fr");
|
|
|
|
expect(translated.ingredients).to.deep.equal([
|
|
{
|
|
rawText: "1 cup onions, chopped",
|
|
quantity: null,
|
|
unit: null,
|
|
name: "onions",
|
|
ingredientId: null,
|
|
unitId: null,
|
|
},
|
|
]);
|
|
});
|
|
});
|
|
});
|