batchCooking/apps/api/test/ingredient-matcher.test.ts
Nicolas abc5b0a0e9 fix(recipes): corrige plusieurs bugs d'import TheMealDB
- Les instructions TheMealDB numérotées sur leur propre ligne ("1\n\ntexte...\n\n2\n\ntexte...") créaient des étapes parasites ne contenant qu'un chiffre — filtrées désormais (#52).
- Un ingrédient compté sans mot d'unité dans le texte source (ex. "4 Egg Yolks") laissait l'import bloqué sur "Importer" indéfiniment, sans indication visuelle de la ligne en cause — matchUnit retombe maintenant sur l'unité générique "piece" quand une quantité a été extraite, et RecipeImportForm/RecipeFormPage surlignent désormais toute ligne dont l'unité manque, avec un message explicite (#53).
- Ajout de INGREDIENT_LABEL_SYNONYMS_EN pour reconnaître des formulations alternatives fréquentes chez les sources anglophones ("vanilla pod" en plus de "vanilla bean") sans élargir INGREDIENT_LABELS_EN à un tableau pour ses ~550 entrées (#54).
- Effet de bord découvert en vérifiant #53 de bout en bout : deux lignes source résolues vers le même ingrédient catalogue (ex. "Egg Yolks"/"Eggs" -> "Œuf") faisaient planter la création en 500 (contrainte unique recipe_id+ingredient_id) au lieu d'un 400 propre. createRecipeSchema rejette maintenant les ingredientId en double, et le formulaire d'import surligne les doublons avant même de soumettre.

Vérifié de bout en bout dans le navigateur (import réel de la recette "Flan" depuis TheMealDB, jusqu'au planning) en plus des tests ajoutés.

Closes #52, #53, #54

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-21 07:55:00 +02:00

243 lines
9.2 KiB
TypeScript

import { INGREDIENT_LABEL_SYNONYMS_EN } from "@batch-cooking/shared";
import { expect } from "chai";
import { prisma } from "../src/db/prisma.js";
import {
type IngredientMatchEntry,
type UnitMatchEntry,
extractQuantity,
loadIngredientCatalog,
loadUnitCatalog,
matchIngredientName,
matchUnit,
} from "../src/lib/ingredient-matcher.js";
import { resetDatabase } from "../test-support/reset-db.js";
describe("ingredient-matcher", () => {
describe("matchIngredientName", () => {
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 allPurposeFlour: IngredientMatchEntry = { ingredientId: 5, label: "All-purpose flour" };
const catalog = [tomato, chicken, chickenBreast, onion, allPurposeFlour];
it("matches an exact single-word label", () => {
expect(matchIngredientName("tomato", catalog)).to.equal(tomato.ingredientId);
});
it("is case- and accent-insensitive", () => {
expect(matchIngredientName("TOMATO", catalog)).to.equal(tomato.ingredientId);
expect(matchIngredientName("Tömato", catalog)).to.equal(tomato.ingredientId);
});
it("tolerates a regular plural", () => {
expect(matchIngredientName("tomatoes", catalog)).to.equal(tomato.ingredientId);
expect(matchIngredientName("onions", catalog)).to.equal(onion.ingredientId);
});
it("tolerates extra descriptive words around the match", () => {
expect(matchIngredientName("2 large diced yellow onions", catalog)).to.equal(
onion.ingredientId,
);
});
it("prefers the more specific multi-word label over a shorter one it contains", () => {
expect(matchIngredientName("boneless skinless chicken breasts", catalog)).to.equal(
chickenBreast.ingredientId,
);
});
it("still matches the shorter label when the more specific one isn't mentioned", () => {
expect(matchIngredientName("diced chicken thighs", catalog)).to.equal(chicken.ingredientId);
});
it("matches a hyphenated multi-word label", () => {
expect(matchIngredientName("2 cups all-purpose flour", catalog)).to.equal(
allPurposeFlour.ingredientId,
);
});
it("doesn't false-positive a short label inside an unrelated longer word", () => {
// "egg" must not match inside "eggplant" — whole-token comparison, not substring.
const eggplant: IngredientMatchEntry = { ingredientId: 6, label: "Eggplant" };
const egg: IngredientMatchEntry = { ingredientId: 7, label: "Egg" };
expect(matchIngredientName("eggplant", [egg, eggplant])).to.equal(eggplant.ingredientId);
});
it("returns null when nothing matches", () => {
expect(matchIngredientName("mango", catalog)).to.equal(null);
});
it("returns null for an empty catalog", () => {
expect(matchIngredientName("tomato", [])).to.equal(null);
});
it("returns null for an empty name", () => {
expect(matchIngredientName("", catalog)).to.equal(null);
});
it("matches an alternate wording of the same ingredient via a second catalog entry sharing its ingredientId (issue #54)", () => {
const vanillaBean: IngredientMatchEntry = { ingredientId: 8, label: "Vanilla bean" };
const vanillaBeanSynonym: IngredientMatchEntry = { ingredientId: 8, label: "Vanilla pod" };
const synonymCatalog = [vanillaBean, vanillaBeanSynonym];
expect(matchIngredientName("1 vanilla pod", synonymCatalog)).to.equal(8);
expect(matchIngredientName("1 vanilla bean", synonymCatalog)).to.equal(8);
});
it("breaks a same-specificity tie by the lowest ingredientId", () => {
const onionA: IngredientMatchEntry = { ingredientId: 20, label: "Onion" };
const onionB: IngredientMatchEntry = { ingredientId: 21, label: "Onion" };
expect(matchIngredientName("onion", [onionB, onionA])).to.equal(20);
});
});
describe("matchUnit", () => {
const gram: UnitMatchEntry = { unitId: 1, synonyms: ["g", "gram", "grams"] };
const tablespoon: UnitMatchEntry = {
unitId: 2,
synonyms: ["tbsp", "tbs", "tablespoon", "tablespoons"],
};
const cup: UnitMatchEntry = { unitId: 3, synonyms: ["cup", "cups"] };
const catalog = [gram, tablespoon, cup];
it("matches a full word synonym", () => {
expect(matchUnit("tablespoon", catalog)).to.equal(tablespoon.unitId);
});
it("matches an abbreviation synonym", () => {
expect(matchUnit("tbsp", catalog)).to.equal(tablespoon.unitId);
});
it("matches a plural synonym via the same stemming as ingredients", () => {
expect(matchUnit("cups", catalog)).to.equal(cup.unitId);
});
it("is case-insensitive", () => {
expect(matchUnit("TBSP", catalog)).to.equal(tablespoon.unitId);
});
it("only looks at the first word — ignores trailing text", () => {
expect(matchUnit("cup flour", catalog)).to.equal(cup.unitId);
});
it("doesn't match a short abbreviation inside an unrelated word", () => {
// "g" alone must not match "grated" — whole-token comparison.
expect(matchUnit("grated", catalog)).to.equal(null);
});
it("returns null when nothing matches", () => {
expect(matchUnit("pound", catalog)).to.equal(null);
});
it("returns null for an empty catalog", () => {
expect(matchUnit("cup", [])).to.equal(null);
});
it("returns null for an empty string", () => {
expect(matchUnit("", catalog)).to.equal(null);
});
});
describe("extractQuantity", () => {
it("extracts a plain integer", () => {
expect(extractQuantity("2 onions")).to.deep.equal({ quantity: 2, remainder: "onions" });
});
it("extracts a decimal using a dot", () => {
expect(extractQuantity("1.5 cups flour")).to.deep.equal({
quantity: 1.5,
remainder: "cups flour",
});
});
it("extracts a decimal using a comma", () => {
expect(extractQuantity("1,5 cups flour")).to.deep.equal({
quantity: 1.5,
remainder: "cups flour",
});
});
it("extracts a simple fraction", () => {
expect(extractQuantity("1/2 cup sugar")).to.deep.equal({
quantity: 0.5,
remainder: "cup sugar",
});
});
it("extracts a mixed number", () => {
expect(extractQuantity("1 1/2 cups sugar")).to.deep.equal({
quantity: 1.5,
remainder: "cups sugar",
});
});
it("returns null quantity and the trimmed original text when there's no leading number", () => {
expect(extractQuantity("salt to taste")).to.deep.equal({
quantity: null,
remainder: "salt to taste",
});
});
it("trims surrounding whitespace", () => {
expect(extractQuantity(" 2 eggs ")).to.deep.equal({ quantity: 2, remainder: "eggs" });
});
it("only takes the first number of a hyphenated range", () => {
expect(extractQuantity("2-3 carrots")).to.deep.equal({
quantity: 2,
remainder: "-3 carrots",
});
});
});
describe("loadIngredientCatalog / loadUnitCatalog", () => {
beforeEach(async () => {
await resetDatabase();
});
after(async () => {
await prisma.$disconnect();
});
it("loads one entry per Ingredient that has an English label, plus one per alternate wording (INGREDIENT_LABEL_SYNONYMS_EN), keyed by real ingredientId", async () => {
const tomato = await prisma.ingredient.findFirstOrThrow({ where: { key: "tomato" } });
const vanillaBean = await prisma.ingredient.findFirstOrThrow({
where: { key: "vanillaBean" },
});
const ingredientCount = await prisma.ingredient.count();
const synonymCount = Object.values(INGREDIENT_LABEL_SYNONYMS_EN).reduce(
(sum, synonyms) => sum + synonyms.length,
0,
);
const catalog = await loadIngredientCatalog();
// Every seeded ingredient has an authored English label (verified at
// generation time — see packages/shared/src/data/catalog-labels-en.ts),
// so nothing should be silently skipped — plus one extra entry per
// synonym (issue #54), sharing the same ingredientId as the primary
// label's entry.
expect(catalog).to.have.length(ingredientCount + synonymCount);
const tomatoEntry = catalog.find((entry) => entry.ingredientId === tomato.id);
expect(tomatoEntry?.label).to.equal("Tomato");
const vanillaBeanEntries = catalog.filter((entry) => entry.ingredientId === vanillaBean.id);
expect(vanillaBeanEntries.map((entry) => entry.label)).to.deep.equal([
"Vanilla bean",
"Vanilla pod",
]);
});
it("loads one entry per Unit that has English synonyms, keyed by real unitId", async () => {
const cup = await prisma.unit.findFirstOrThrow({ where: { key: "cup" } });
const unitCount = await prisma.unit.count();
const catalog = await loadUnitCatalog();
expect(catalog).to.have.length(unitCount);
const cupEntry = catalog.find((entry) => entry.unitId === cup.id);
expect(cupEntry?.synonyms).to.deep.equal(["cup", "cups"]);
});
});
});