first commit
This commit is contained in:
@@ -0,0 +1,115 @@
|
||||
/**
|
||||
* Assets IO — lecture des métadonnées PNG, copie des fichiers ingrédients
|
||||
* et branding vers `apps/web/public`, sélection d'une cover recette.
|
||||
*
|
||||
* Aucun parsing : ces fonctions touchent le système de fichiers. Le parsing
|
||||
* bentext / sprite vit dans `parse-bentext.mjs` et `sprites.mjs`.
|
||||
*/
|
||||
|
||||
import { promises as fs } from "node:fs";
|
||||
import path from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
|
||||
const repoRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "..", "..");
|
||||
|
||||
/** Fichiers d’ingrédients copiés tels quels vers `apps/web/public`. */
|
||||
const INGREDIENT_ASSET_FILES = ["ingredients.png", "ingredient-sprites.csv", "ingredient-unknown.png"];
|
||||
|
||||
/** Fichiers de branding copiés tels quels vers `apps/web/public/branding`. */
|
||||
export const BRANDING_FILES = ["onigiri-logo.png", "og.jpg"];
|
||||
|
||||
export const PATHS = {
|
||||
repoRoot,
|
||||
recipesDir: path.join(repoRoot, "ressources/bentext"),
|
||||
outDir: path.join(repoRoot, "ressources/generated"),
|
||||
webPublicDir: path.join(repoRoot, "apps/web/public/recipes"),
|
||||
webCoverDir: path.join(repoRoot, "apps/web/public/recipe-covers"),
|
||||
webBrandingDir: path.join(repoRoot, "apps/web/public/branding"),
|
||||
webPublicRoot: path.join(repoRoot, "apps/web/public"),
|
||||
publicAssetsDir: path.join(repoRoot, "ressources/public"),
|
||||
placeholderCoversDir: path.join(repoRoot, "ressources/recipe-placeholder-covers"),
|
||||
/** Grille des tuiles : coordonnées et alias listés dans ce fichier (plus d’API). */
|
||||
ingredientSpritesBentextPath: path.join(repoRoot, "ressources/public/ingredient-sprites.bentext"),
|
||||
ingredientsPngPath: path.join(repoRoot, "ressources/public/ingredients.png")
|
||||
};
|
||||
|
||||
const COVER_EXTENSIONS = [".jpg", ".jpeg", ".png", ".webp"];
|
||||
|
||||
const readPngDimensions = async (filePath) => {
|
||||
try {
|
||||
const buf = await fs.readFile(filePath);
|
||||
if (buf.length < 24 || buf[0] !== 0x89) return null;
|
||||
const width = buf.readUInt32BE(16);
|
||||
const height = buf.readUInt32BE(20);
|
||||
return { width, height };
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
};
|
||||
|
||||
const copyIngredientAssetsToWeb = async () => {
|
||||
await fs.mkdir(PATHS.webPublicRoot, { recursive: true });
|
||||
for (const name of INGREDIENT_ASSET_FILES) {
|
||||
const src = path.join(PATHS.publicAssetsDir, name);
|
||||
const dest = path.join(PATHS.webPublicRoot, name);
|
||||
try {
|
||||
await fs.copyFile(src, dest);
|
||||
} catch (error) {
|
||||
if (error && error.code === "ENOENT") continue;
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const copyBrandingAssets = async () => {
|
||||
await fs.mkdir(PATHS.webBrandingDir, { recursive: true });
|
||||
for (const name of BRANDING_FILES) {
|
||||
const src = path.join(PATHS.publicAssetsDir, name);
|
||||
const dest = path.join(PATHS.webBrandingDir, name);
|
||||
try {
|
||||
await fs.copyFile(src, dest);
|
||||
} catch (error) {
|
||||
if (error && error.code === "ENOENT") {
|
||||
console.warn(`Branding asset missing, skip: ${name}`);
|
||||
continue;
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Sélectionne une cover pour `slug` :
|
||||
* 1. Image HD dans `ressources/recipe-placeholder-covers/<slug>.png`.
|
||||
* 2. Sinon, image dans `ressources/public/<slug>.<ext>`.
|
||||
* Copie le fichier dans `apps/web/public/recipe-covers/` et retourne l’URL publique.
|
||||
*/
|
||||
const pickCoverImage = async (slug) => {
|
||||
await fs.mkdir(PATHS.webCoverDir, { recursive: true });
|
||||
/* Priorité aux visuels HD du dépôt ; sinon ressources/public (override legacy). */
|
||||
const placeholderPng = path.join(PATHS.placeholderCoversDir, `${slug}.png`);
|
||||
try {
|
||||
await fs.access(placeholderPng);
|
||||
const publicName = `${slug}.png`;
|
||||
const target = path.join(PATHS.webCoverDir, publicName);
|
||||
await fs.copyFile(placeholderPng, target);
|
||||
return `/recipe-covers/${publicName}`;
|
||||
} catch {
|
||||
// continue
|
||||
}
|
||||
for (const extension of COVER_EXTENSIONS) {
|
||||
const candidate = path.join(PATHS.publicAssetsDir, `${slug}${extension}`);
|
||||
try {
|
||||
await fs.access(candidate);
|
||||
const publicName = `${slug}${extension}`;
|
||||
const target = path.join(PATHS.webCoverDir, publicName);
|
||||
await fs.copyFile(candidate, target);
|
||||
return `/recipe-covers/${publicName}`;
|
||||
} catch {
|
||||
// continue
|
||||
}
|
||||
}
|
||||
return null;
|
||||
};
|
||||
|
||||
export { readPngDimensions, copyIngredientAssetsToWeb, copyBrandingAssets, pickCoverImage };
|
||||
@@ -0,0 +1,406 @@
|
||||
/**
|
||||
* Classification & construction du catalogue recette.
|
||||
* Pur (aucun IO) — prend en entrée les recettes « fusionnées » issues de
|
||||
* `pipeline.mjs` et les définitions de sprites résolues par `sprites.mjs`,
|
||||
* renvoie le graph `{ bases, variants }` qui sera sérialisé en `catalog.json`.
|
||||
*
|
||||
* Le tagging des bases (slug/tags → `base:xxx`) reste dans
|
||||
* `scripts/recipe-category.mjs` (testé indépendamment).
|
||||
*/
|
||||
|
||||
import { RECIPE_BASE_DISPLAY_ORDER, RECIPE_BASE_FLAVOR, resolveRecipeBaseId } from "../recipe-category.mjs";
|
||||
import {
|
||||
aliasToIconPx,
|
||||
buildIngredientCoverSprite,
|
||||
findAliasForIconPx,
|
||||
isPreferredVariantAlias,
|
||||
resolveIconPxForIngredientName
|
||||
} from "./sprites.mjs";
|
||||
|
||||
/** Mots-clés pour la classification sucrée/salée du bandeau de recette. */
|
||||
const flavorKeywords = {
|
||||
sweet: ["dessert", "gateau", "gâteau", "cake", "biscuit", "muffin", "financier", "madeleine", "crumble", "crème", "cremeux", "citron", "orange", "banane", "pomme", "chocolat", "sucré", "sucre"],
|
||||
savory: ["sale", "salé", "bento", "riz", "onigiri", "mandu", "gimbap", "empanada", "naan", "dan-bing", "omelette", "œuf", "oeuf"]
|
||||
};
|
||||
|
||||
/**
|
||||
* Renvoie `"sweet"` ou `"savory"` selon les mots-clés présents dans le slug
|
||||
* concaténé aux tags. En cas d’égalité, fallback `"savory"` (préserve le
|
||||
* comportement historique du pipeline).
|
||||
*/
|
||||
const classifyFlavor = (slug, tags) => {
|
||||
const haystack = `${slug} ${tags.join(" ")}`.toLowerCase();
|
||||
const sweetHits = flavorKeywords.sweet.filter((keyword) => haystack.includes(keyword)).length;
|
||||
const savoryHits = flavorKeywords.savory.filter((keyword) => haystack.includes(keyword)).length;
|
||||
if (sweetHits > savoryHits) return "sweet";
|
||||
if (savoryHits > sweetHits) return "savory";
|
||||
return "savory";
|
||||
};
|
||||
|
||||
const buildLocalizedText = (localesMap, fallback) => {
|
||||
const fr = localesMap.fr?.title ?? fallback;
|
||||
const en = localesMap.en?.title ?? fr;
|
||||
const ko = localesMap.ko?.title ?? en;
|
||||
const zh = localesMap.zh?.title ?? en;
|
||||
return { fr, en, ko, zh };
|
||||
};
|
||||
|
||||
const buildLocalizedParagraph = (localesMap, fallback) => {
|
||||
const fr = localesMap.fr?.description ?? fallback;
|
||||
const en = localesMap.en?.description ?? fr;
|
||||
const ko = localesMap.ko?.description ?? en;
|
||||
const zh = localesMap.zh?.description ?? en;
|
||||
return { fr, en, ko, zh };
|
||||
};
|
||||
|
||||
/** Libellés affichés (familles produit) — voir `scripts/recipe-category.mjs` pour l’association slug/tags → id. */
|
||||
const BASE_LABELS = {
|
||||
"base:onigiri": {
|
||||
name: { fr: "Bouchées de riz", en: "Rice bites", ko: "밥 한 입", zh: "饭团一口" },
|
||||
history: {
|
||||
fr: "Riz compact, façonné, garni ou enveloppé — onigiri, inarizushi, musubi…",
|
||||
en: "Compact rice, shaped, filled or wrapped—onigiri, inarizushi, musubi…",
|
||||
ko: "조리해 모양 낸 밥 — 오니기리, 이나리즈시 등.",
|
||||
zh: "塑形或包裹的米饭一口食——饭团、稻荷寿司等。"
|
||||
}
|
||||
},
|
||||
"base:gimbap": {
|
||||
name: { fr: "Rouleaux de riz", en: "Rice rolls", ko: "밥 말이", zh: "饭卷" },
|
||||
history: {
|
||||
fr: "Riz roulé puis découpé — gimbap, maki-like.",
|
||||
en: "Rolled and sliced rice—gimbap, maki-style rolls.",
|
||||
ko: "말아 자른 밥 — 김밥, 마키 스타일.",
|
||||
zh: "卷起再切的饭——紫菜包饭、卷饭。"
|
||||
}
|
||||
},
|
||||
"base:mandu": {
|
||||
name: { fr: "Mandu", en: "Mandu / dumplings", ko: "만두", zh: "饺子 / 煎饺" },
|
||||
history: {
|
||||
fr: "Raviolis et bouchées farcies — mandu, gyoza, jiaozi…",
|
||||
en: "Stuffed dumplings—mandu, gyoza, jiaozi…",
|
||||
ko: "속을 채운 만두류.",
|
||||
zh: "带馅面团——饺子、锅贴等。"
|
||||
}
|
||||
},
|
||||
"base:empanadas-savory": {
|
||||
name: { fr: "Empanadas", en: "Empanadas", ko: "엠파나다", zh: "烤饺" },
|
||||
history: {
|
||||
fr: "Chaussons farcis — empanadas, hand pies, pastels salés.",
|
||||
en: "Filled turnovers—empanadas, hand pies, savoury pastels.",
|
||||
ko: "속을 채운 페이스트리.",
|
||||
zh: "酥皮咸馅小包。"
|
||||
}
|
||||
},
|
||||
"base:empanadas-sweet": {
|
||||
name: { fr: "Empanadas", en: "Empanadas", ko: "엠파나다", zh: "烤饺" },
|
||||
history: {
|
||||
fr: "Chaussons sucrés — farce dessert.",
|
||||
en: "Sweet filled turnovers.",
|
||||
ko: "달콤한 속을 넣은 페이스트리.",
|
||||
zh: "甜馅酥皮小包。"
|
||||
}
|
||||
},
|
||||
"base:galettes": {
|
||||
name: { fr: "Galettes", en: "Flatbreads & griddled cakes", ko: "전·난·플랫브레드", zh: "烙饼 / 扁面包" },
|
||||
history: {
|
||||
fr: "Pains plats, crêpes, galettes poêlées — naan, dan bing, jeon, paratha…",
|
||||
en: "Flatbreads and griddled pancakes—naan, dan bing, jeon, paratha…",
|
||||
ko: "난, 단빙, 전 등 납작한 빵·전병.",
|
||||
zh: "烙饼、印度烤饼、煎饼类等。"
|
||||
}
|
||||
},
|
||||
"base:bao": {
|
||||
name: { fr: "Bao", en: "Steamed buns", ko: "바오·만두빵", zh: "包子 / 包点" },
|
||||
history: {
|
||||
fr: "Pains vapeur farcis ou garnis — bao salé ou sucré, mantou garni.",
|
||||
en: "Steamed buns, savoury or sweet filling, stuffed mantou.",
|
||||
ko: "찐 빵·만두빵.",
|
||||
zh: "蒸制包点。"
|
||||
}
|
||||
},
|
||||
"base:muffin": {
|
||||
name: { fr: "Muffins", en: "Muffins", ko: "머핀", zh: "玛芬" },
|
||||
history: {
|
||||
fr: "Portion moulée individuelle — muffins sucrés ou salés.",
|
||||
en: "Individual molded portions—sweet or savoury muffins.",
|
||||
ko: "한 입 크기 머핀.",
|
||||
zh: "一人份模烤点心。"
|
||||
}
|
||||
},
|
||||
"base:mini-cakes": {
|
||||
name: { fr: "Mini-cakes", en: "Small portable cakes", ko: "미니 케이크", zh: "小蛋糕" },
|
||||
history: {
|
||||
fr: "Petits gâteaux transportables — madeleines, financiers, mini-cakes.",
|
||||
en: "Portable small cakes—madeleines, financiers, mini cakes.",
|
||||
ko: "휴대하기 좋은 작은 케이크.",
|
||||
zh: "便于携带的小蛋糕。"
|
||||
}
|
||||
},
|
||||
"base:biscuits-secs": {
|
||||
name: { fr: "Biscuits secs", en: "Crisp cookies", ko: "바삭한 비스킷", zh: "酥脆饼干" },
|
||||
history: {
|
||||
fr: "Sec, solide, compatible bureau — cookies, sablés, biscuits croquants.",
|
||||
en: "Dry, snack-friendly—cookies, shortbread, crisp biscuits.",
|
||||
ko: "바삭한 과자·쿠키.",
|
||||
zh: "干爽酥脆饼干。"
|
||||
}
|
||||
},
|
||||
"base:fluffy-cakes": {
|
||||
name: { fr: "Fluffy cakes", en: "Fluffy cakes", ko: "폭신 케이크", zh: "松软蛋糕" },
|
||||
history: {
|
||||
fr: "Gâteaux légers, tranchables ou sandwichables — sponge, génoises, fluffy.",
|
||||
en: "Light, sliceable or layer cakes—sponge, génoise, fluffy styles.",
|
||||
ko: "가볍게 썰어 먹는 케이크.",
|
||||
zh: "松软可分切的蛋糕。"
|
||||
}
|
||||
},
|
||||
"base:cakes": {
|
||||
name: { fr: "Cakes", en: "Cakes", ko: "케이크", zh: "蛋糕" },
|
||||
history: {
|
||||
fr: "Gâteaux type layer cake, carrot cake, banana bread…",
|
||||
en: "Layer cakes, carrot cake, banana bread-style bakes.",
|
||||
ko: "당근 케이크, 바나나 브레드 등.",
|
||||
zh: "胡萝卜蛋糕、香蕉蛋糕等。"
|
||||
}
|
||||
},
|
||||
"base:carnet": {
|
||||
name: { fr: "Carnet", en: "Notebook", ko: "노트", zh: "手记" },
|
||||
history: {
|
||||
fr: "Recettes bonus, hors concept strict Ben-to — à taguer « carnet » dans les tags.",
|
||||
en: "Bonus recipes outside the core Ben-to grid—tag « carnet » in bentext tags.",
|
||||
ko: "보조 레시피 — 태그에 carnet.",
|
||||
zh: "附录食谱——在标签中加 carnet。"
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
/** Icônes des tuiles « base » : alias dans `ingredient-sprites.bentext` → coordonnées pixels. */
|
||||
const BASE_SPRITE_ALIASES = {
|
||||
"base:onigiri": "onigiri",
|
||||
"base:gimbap": "gimbap",
|
||||
"base:mandu": "mandu",
|
||||
"base:empanadas-savory": "empenadas",
|
||||
"base:empanadas-sweet": "empenadas",
|
||||
"base:galettes": "naan",
|
||||
"base:bao": "bao",
|
||||
"base:muffin": "muffin",
|
||||
"base:mini-cakes": "madelaine",
|
||||
"base:biscuits-secs": "muffin",
|
||||
"base:fluffy-cakes": "gateau de savoie",
|
||||
"base:cakes": "tranche cake",
|
||||
"base:carnet": "carnet"
|
||||
};
|
||||
|
||||
const buildBaseIngredientsSteps = (recipe, sheetMeta) => {
|
||||
const ingredients = recipe.mergedIngredients.map((ingredient) => {
|
||||
const { iconPx, id, names, notes, quantity, unit } = ingredient;
|
||||
const coverSprite = buildIngredientCoverSprite(iconPx, sheetMeta);
|
||||
const row = {
|
||||
id,
|
||||
name: {
|
||||
fr: names.fr ?? names.en,
|
||||
en: names.en ?? names.fr,
|
||||
ko: names.ko ?? names.en,
|
||||
zh: names.zh ?? names.en
|
||||
},
|
||||
info: {
|
||||
fr: notes.fr ?? "",
|
||||
en: notes.en ?? "",
|
||||
ko: notes.ko ?? "",
|
||||
zh: notes.zh ?? ""
|
||||
},
|
||||
quantity,
|
||||
unit
|
||||
};
|
||||
if (coverSprite) row.coverSprite = coverSprite;
|
||||
return row;
|
||||
});
|
||||
const steps = recipe.mergedSteps.map((step) => ({
|
||||
id: step.id,
|
||||
priority: step.priority,
|
||||
phase: step.phase,
|
||||
dependsOn: step.dependsOn,
|
||||
text: {
|
||||
fr: step.text.fr,
|
||||
en: step.text.en,
|
||||
ko: step.text.ko,
|
||||
zh: step.text.zh
|
||||
}
|
||||
}));
|
||||
return { ingredients, steps };
|
||||
};
|
||||
|
||||
const buildVariantPayload = (recipe, sheetMeta) => {
|
||||
const title = buildLocalizedText(recipe.locales, recipe.slug);
|
||||
const story = buildLocalizedParagraph(recipe.locales, "");
|
||||
const ingredients = recipe.mergedIngredients.map((ingredient) => {
|
||||
const { iconPx, id, names, notes, quantity, unit } = ingredient;
|
||||
const coverSprite = buildIngredientCoverSprite(iconPx, sheetMeta);
|
||||
const row = {
|
||||
id: `variant:${recipe.slug}:${id}`,
|
||||
name: {
|
||||
fr: names.fr ?? names.en,
|
||||
en: names.en ?? names.fr,
|
||||
ko: names.ko ?? names.en,
|
||||
zh: names.zh ?? names.en
|
||||
},
|
||||
info: {
|
||||
fr: notes.fr ?? "",
|
||||
en: notes.en ?? "",
|
||||
ko: notes.ko ?? "",
|
||||
zh: notes.zh ?? ""
|
||||
},
|
||||
quantity,
|
||||
unit
|
||||
};
|
||||
if (coverSprite) row.coverSprite = coverSprite;
|
||||
return row;
|
||||
});
|
||||
const steps = recipe.mergedSteps.map((step) => ({
|
||||
id: `variant:${recipe.slug}:${step.id}`,
|
||||
priority: step.priority + 5000,
|
||||
phase: step.phase,
|
||||
dependsOn: step.dependsOn.map((dependency) => `variant:${recipe.slug}:${dependency}`),
|
||||
text: {
|
||||
fr: step.text.fr,
|
||||
en: step.text.en,
|
||||
ko: step.text.ko,
|
||||
zh: step.text.zh
|
||||
}
|
||||
}));
|
||||
return { title, story, ingredients, steps, servings: recipe.servings };
|
||||
};
|
||||
|
||||
const buildRecipeGraph = (recipes, sheetMeta, spriteByAlias, preferredVariantAliases) => {
|
||||
const baseBuckets = new Map();
|
||||
for (const baseId of RECIPE_BASE_DISPLAY_ORDER) {
|
||||
baseBuckets.set(baseId, []);
|
||||
}
|
||||
for (const recipe of recipes) {
|
||||
const id = resolveRecipeBaseId(recipe.slug, recipe.tags);
|
||||
baseBuckets.get(id).push(recipe);
|
||||
}
|
||||
|
||||
const bases = [];
|
||||
for (const baseId of RECIPE_BASE_DISPLAY_ORDER) {
|
||||
const bucket = baseBuckets.get(baseId) ?? [];
|
||||
bucket.sort((a, b) => a.slug.localeCompare(b.slug));
|
||||
const representative = bucket[0] ?? null;
|
||||
const labels = BASE_LABELS[baseId];
|
||||
const { ingredients, steps } = representative
|
||||
? buildBaseIngredientsSteps(representative, sheetMeta)
|
||||
: { ingredients: [], steps: [] };
|
||||
const servings = representative?.servings ?? 1;
|
||||
const tags = representative ? [...representative.tags] : [];
|
||||
const baseSpriteAlias = BASE_SPRITE_ALIASES[baseId];
|
||||
const baseIconPx = baseSpriteAlias ? aliasToIconPx(spriteByAlias, baseSpriteAlias) : null;
|
||||
if (baseSpriteAlias && !baseIconPx) {
|
||||
console.warn(
|
||||
`[build-recipes] Icône base absente de ingredient-sprites.bentext (alias « ${baseSpriteAlias} » pour ${baseId}).`
|
||||
);
|
||||
}
|
||||
const baseCoverSprite = buildIngredientCoverSprite(baseIconPx, sheetMeta);
|
||||
bases.push(
|
||||
Object.freeze({
|
||||
id: baseId,
|
||||
flavor: RECIPE_BASE_FLAVOR[baseId],
|
||||
name: labels.name,
|
||||
history: labels.history,
|
||||
servings,
|
||||
ingredients: Object.freeze(ingredients),
|
||||
steps: Object.freeze(steps),
|
||||
slug: baseId.replace("base:", ""),
|
||||
coverImageUrl: null,
|
||||
coverSprite: baseCoverSprite ?? null,
|
||||
tags: Object.freeze(tags)
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
const variants = [];
|
||||
for (const recipe of recipes) {
|
||||
const baseId = resolveRecipeBaseId(recipe.slug, recipe.tags);
|
||||
const { title, story, ingredients, steps } = buildVariantPayload(recipe, sheetMeta);
|
||||
const variantId = `variant:${recipe.slug}`;
|
||||
const variantCoverSprite = resolveVariantCoverSprite(
|
||||
recipe,
|
||||
sheetMeta,
|
||||
spriteByAlias,
|
||||
preferredVariantAliases
|
||||
);
|
||||
variants.push(
|
||||
Object.freeze({
|
||||
id: variantId,
|
||||
baseIds: Object.freeze([baseId]),
|
||||
name: title,
|
||||
story,
|
||||
servings: recipe.servings,
|
||||
ingredients: Object.freeze(ingredients),
|
||||
steps: Object.freeze(steps),
|
||||
slug: recipe.slug,
|
||||
coverImageUrl: null,
|
||||
coverSprite: variantCoverSprite ?? null,
|
||||
recipeCoverImageUrl: recipe.coverImageUrl ?? null,
|
||||
tags: Object.freeze(recipe.tags)
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
return { bases: Object.freeze(bases), variants: Object.freeze(variants) };
|
||||
};
|
||||
|
||||
/**
|
||||
* Icône tuile garniture : override explicite via tag bentext `sprite:<alias>`
|
||||
* d’abord, sinon ingrédient « prioritaire », sinon premier ingrédient avec sprite résolu.
|
||||
* Émet un `console.warn` quand l’alias explicite est introuvable.
|
||||
*/
|
||||
const resolveVariantCoverSprite = (recipe, sheetMeta, spriteByAlias, preferredVariantAliases) => {
|
||||
const tileAlias = recipe.variantTileSpriteAlias ?? null;
|
||||
if (tileAlias) {
|
||||
const sprite = resolveVariantCoverSpriteFromTileAlias(tileAlias, sheetMeta, spriteByAlias);
|
||||
if (sprite) return sprite;
|
||||
console.warn(
|
||||
`[build-recipes] Tuile variante : alias « ${tileAlias} » introuvable (${recipe.slug}), fallback automatique.`
|
||||
);
|
||||
}
|
||||
return pickVariantCoverSprite(recipe, sheetMeta, spriteByAlias, preferredVariantAliases);
|
||||
};
|
||||
|
||||
const resolveVariantCoverSpriteFromTileAlias = (aliasKey, sheetMeta, spriteByAlias) => {
|
||||
if (!aliasKey || typeof aliasKey !== "string") return null;
|
||||
const normalized = normalizeAliasKeyLocal(aliasKey);
|
||||
const px =
|
||||
resolveIconPxForIngredientName(aliasKey, spriteByAlias) ??
|
||||
aliasToIconPx(spriteByAlias, normalized);
|
||||
if (!px || typeof px.x !== "number" || typeof px.y !== "number") return null;
|
||||
return buildIngredientCoverSprite(px, sheetMeta);
|
||||
};
|
||||
|
||||
const pickVariantCoverSprite = (recipe, sheetMeta, spriteByAlias, preferredAliases) => {
|
||||
const merged = recipe.mergedIngredients ?? [];
|
||||
const tryPick = (preferredOnly) => {
|
||||
for (const ing of merged) {
|
||||
const px = ing.iconPx;
|
||||
if (!px || typeof px.x !== "number" || typeof px.y !== "number") continue;
|
||||
const alias = findAliasForIconPx(spriteByAlias, px);
|
||||
if (preferredOnly) {
|
||||
if (!alias || !isPreferredVariantAlias(preferredAliases, alias)) continue;
|
||||
}
|
||||
return buildIngredientCoverSprite(px, sheetMeta);
|
||||
}
|
||||
return null;
|
||||
};
|
||||
return tryPick(true) ?? tryPick(false) ?? null;
|
||||
};
|
||||
|
||||
const normalizeAliasKeyLocal = (s) => s.trim().toLowerCase().replace(/\s+/g, " ");
|
||||
|
||||
export {
|
||||
classifyFlavor,
|
||||
buildLocalizedText,
|
||||
buildLocalizedParagraph,
|
||||
buildBaseIngredientsSteps,
|
||||
buildVariantPayload,
|
||||
buildRecipeGraph,
|
||||
resolveVariantCoverSprite
|
||||
};
|
||||
@@ -0,0 +1,230 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import {
|
||||
buildBaseIngredientsSteps,
|
||||
buildLocalizedParagraph,
|
||||
buildLocalizedText,
|
||||
buildRecipeGraph,
|
||||
buildVariantPayload,
|
||||
classifyFlavor
|
||||
} from "./classification.mjs";
|
||||
|
||||
describe("classifyFlavor", () => {
|
||||
it("retombe sur sweet si mots-clés sucrés dominent", () => {
|
||||
expect(classifyFlavor("gateau-chocolat", [])).toBe("sweet");
|
||||
expect(classifyFlavor("muffin-banane", [])).toBe("sweet");
|
||||
});
|
||||
|
||||
it("retombe sur savory si mots-clés salés dominent", () => {
|
||||
expect(classifyFlavor("onigiri-thon", [])).toBe("savory");
|
||||
expect(classifyFlavor("naan-fromage", [])).toBe("savory");
|
||||
});
|
||||
|
||||
it("considère aussi les tags (pas seulement le slug)", () => {
|
||||
expect(classifyFlavor("recette-x", ["bento", "riz", "onigiri"])).toBe("savory");
|
||||
expect(classifyFlavor("recette-x", ["dessert", "chocolat"])).toBe("sweet");
|
||||
});
|
||||
|
||||
it("fallback savory en cas d'égalité (comportement historique)", () => {
|
||||
// « cake » sucré + « bento » salé = 1-1
|
||||
expect(classifyFlavor("cake-bento", [])).toBe("savory");
|
||||
});
|
||||
});
|
||||
|
||||
describe("buildLocalizedText & buildLocalizedParagraph", () => {
|
||||
it("cascade fr → en → ko → zh avec fallback en cas de locale manquante", () => {
|
||||
expect(
|
||||
buildLocalizedText(
|
||||
{ fr: { title: "Gâteau" }, en: { title: "Cake" } },
|
||||
"default"
|
||||
)
|
||||
).toEqual({ fr: "Gâteau", en: "Cake", ko: "Cake", zh: "Cake" });
|
||||
|
||||
expect(
|
||||
buildLocalizedText(
|
||||
{ fr: { title: "Gâteau" } },
|
||||
"default"
|
||||
)
|
||||
).toEqual({ fr: "Gâteau", en: "Gâteau", ko: "Gâteau", zh: "Gâteau" });
|
||||
|
||||
expect(buildLocalizedText({}, "default")).toEqual({
|
||||
fr: "default",
|
||||
en: "default",
|
||||
ko: "default",
|
||||
zh: "default"
|
||||
});
|
||||
});
|
||||
|
||||
it("buildLocalizedParagraph utilise la clé `description`", () => {
|
||||
expect(
|
||||
buildLocalizedParagraph(
|
||||
{ fr: { description: "Une histoire" }, en: { description: "A story" } },
|
||||
"fallback"
|
||||
)
|
||||
).toEqual({ fr: "Une histoire", en: "A story", ko: "A story", zh: "A story" });
|
||||
});
|
||||
});
|
||||
|
||||
describe("buildBaseIngredientsSteps & buildVariantPayload", () => {
|
||||
const sheetMeta = { width: 256, height: 256 };
|
||||
|
||||
it("produit ingrédients + steps localisés pour une base", () => {
|
||||
const recipe = {
|
||||
slug: "onigiri",
|
||||
mergedIngredients: [
|
||||
{
|
||||
id: "onigiri-ingredient-0",
|
||||
iconPx: { x: 0, y: 0 },
|
||||
names: { fr: "Riz", en: "Rice" },
|
||||
notes: { fr: "note fr", en: "" },
|
||||
quantity: 100,
|
||||
unit: "g"
|
||||
}
|
||||
],
|
||||
mergedSteps: [
|
||||
{ id: "step-1", priority: 10, phase: "cook", dependsOn: [], text: { fr: "Cuire", en: "Cook" } }
|
||||
]
|
||||
};
|
||||
const { ingredients, steps } = buildBaseIngredientsSteps(recipe, sheetMeta);
|
||||
expect(ingredients).toHaveLength(1);
|
||||
expect(ingredients[0].id).toBe("onigiri-ingredient-0");
|
||||
expect(ingredients[0].name.fr).toBe("Riz");
|
||||
expect(ingredients[0].coverSprite).toBeTruthy();
|
||||
expect(steps[0]).toMatchObject({
|
||||
id: "step-1",
|
||||
priority: 10,
|
||||
phase: "cook",
|
||||
dependsOn: [],
|
||||
text: { fr: "Cuire", en: "Cook" }
|
||||
});
|
||||
});
|
||||
|
||||
it("préfixe les ids et bump la priority pour les variantes", () => {
|
||||
const recipe = {
|
||||
slug: "onigiri",
|
||||
servings: 3,
|
||||
locales: { fr: { title: "Onigiri thon" } },
|
||||
mergedIngredients: [
|
||||
{
|
||||
id: "onigiri-ingredient-0",
|
||||
iconPx: null,
|
||||
names: { fr: "Riz", en: "Rice" },
|
||||
notes: { fr: "", en: "" },
|
||||
quantity: 1,
|
||||
unit: "g"
|
||||
}
|
||||
],
|
||||
mergedSteps: [
|
||||
{ id: "step-1", priority: 10, phase: "cook", dependsOn: [], text: { fr: "Cuire", en: "Cook" } },
|
||||
{ id: "step-2", priority: 20, phase: "cook", dependsOn: ["step-1"], text: { fr: "Servir", en: "Serve" } }
|
||||
]
|
||||
};
|
||||
const { title, story, ingredients, steps, servings } = buildVariantPayload(recipe, sheetMeta);
|
||||
expect(servings).toBe(3);
|
||||
expect(title).toEqual({ fr: "Onigiri thon", en: "Onigiri thon", ko: "Onigiri thon", zh: "Onigiri thon" });
|
||||
expect(story.fr).toBe("");
|
||||
expect(ingredients[0].id).toBe("variant:onigiri:onigiri-ingredient-0");
|
||||
expect(steps[0].id).toBe("variant:onigiri:step-1");
|
||||
expect(steps[0].priority).toBe(5010);
|
||||
expect(steps[1].dependsOn).toEqual(["variant:onigiri:step-1"]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("buildRecipeGraph", () => {
|
||||
const sheetMeta = { width: 256, height: 256 };
|
||||
const spriteByAlias = {
|
||||
onigiri: { x: 0, y: 0 },
|
||||
madelaine: { x: 32, y: 0 },
|
||||
carnet: { x: 64, y: 0 }
|
||||
};
|
||||
const preferredVariantAliases = new Set(["onigiri"]);
|
||||
|
||||
it("construit 13 bases dans l'ordre d'affichage", () => {
|
||||
const { bases } = buildRecipeGraph([], sheetMeta, spriteByAlias, preferredVariantAliases);
|
||||
expect(bases).toHaveLength(13);
|
||||
expect(bases.map((b) => b.id)).toEqual([
|
||||
"base:onigiri",
|
||||
"base:gimbap",
|
||||
"base:mandu",
|
||||
"base:empanadas-savory",
|
||||
"base:empanadas-sweet",
|
||||
"base:galettes",
|
||||
"base:bao",
|
||||
"base:muffin",
|
||||
"base:mini-cakes",
|
||||
"base:biscuits-secs",
|
||||
"base:fluffy-cakes",
|
||||
"base:cakes",
|
||||
"base:carnet"
|
||||
]);
|
||||
// base sans recette = pas d'ingrédients, pas d'erreur
|
||||
expect(bases[0].ingredients).toEqual([]);
|
||||
});
|
||||
|
||||
it("attache la base, les ingrédients et les étapes à la base du slug représentatif", () => {
|
||||
const recipes = [
|
||||
{
|
||||
slug: "onigiri-thon",
|
||||
servings: 2,
|
||||
flavor: "savory",
|
||||
tags: ["bento"],
|
||||
variantTileSpriteAlias: null,
|
||||
coverImageUrl: null,
|
||||
locales: { fr: { title: "Onigiri thon" } },
|
||||
mergedIngredients: [
|
||||
{
|
||||
id: "onigiri-thon-ingredient-0",
|
||||
iconPx: { x: 0, y: 0 },
|
||||
names: { fr: "Riz", en: "Rice" },
|
||||
notes: { fr: "", en: "" },
|
||||
quantity: 1,
|
||||
unit: "g"
|
||||
}
|
||||
],
|
||||
mergedSteps: [
|
||||
{ id: "step-1", priority: 10, phase: "cook", dependsOn: [], text: { fr: "Cuire", en: "Cook" } }
|
||||
]
|
||||
}
|
||||
];
|
||||
const { bases, variants } = buildRecipeGraph(recipes, sheetMeta, spriteByAlias, preferredVariantAliases);
|
||||
const onigiriBase = bases.find((b) => b.id === "base:onigiri");
|
||||
expect(onigiriBase.servings).toBe(2);
|
||||
expect(onigiriBase.ingredients).toHaveLength(1);
|
||||
expect(onigiriBase.steps).toHaveLength(1);
|
||||
expect(onigiriBase.tags).toEqual(["bento"]);
|
||||
|
||||
expect(variants).toHaveLength(1);
|
||||
expect(variants[0].baseIds).toEqual(["base:onigiri"]);
|
||||
expect(variants[0].servings).toBe(2);
|
||||
expect(variants[0].slug).toBe("onigiri-thon");
|
||||
expect(variants[0].coverSprite).toBeTruthy();
|
||||
});
|
||||
|
||||
it("utilise un alias sprite: explicite pour la coverSprite variante", () => {
|
||||
const recipes = [
|
||||
{
|
||||
slug: "onigiri-saumon",
|
||||
flavor: "savory",
|
||||
tags: ["bento"],
|
||||
variantTileSpriteAlias: "madelaine",
|
||||
coverImageUrl: null,
|
||||
locales: { fr: { title: "Onigiri saumon" } },
|
||||
mergedIngredients: [
|
||||
{
|
||||
id: "onigiri-saumon-ingredient-0",
|
||||
iconPx: { x: 0, y: 0 },
|
||||
names: { fr: "Riz", en: "Rice" },
|
||||
notes: { fr: "", en: "" },
|
||||
quantity: 1,
|
||||
unit: "g"
|
||||
}
|
||||
],
|
||||
mergedSteps: [
|
||||
{ id: "step-1", priority: 10, phase: "cook", dependsOn: [], text: { fr: "Cuire", en: "Cook" } }
|
||||
]
|
||||
}
|
||||
];
|
||||
const { variants } = buildRecipeGraph(recipes, sheetMeta, spriteByAlias, preferredVariantAliases);
|
||||
// madelaine → (32, 0) → col=1, row=0
|
||||
expect(variants[0].coverSprite).toMatchObject({ col: 1, row: 0 });
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,84 @@
|
||||
/**
|
||||
* Parsing pur des fichiers `.bentext` (sources recette).
|
||||
* Aucun effet de bord : ne touche pas au système de fichiers, au catalogue,
|
||||
* ni aux assets. Utilisé par `scripts/build-recipes.mjs`.
|
||||
*
|
||||
* Format bentext :
|
||||
* Title
|
||||
* Version
|
||||
* Description
|
||||
* ---
|
||||
* Ingredients (une ligne par ingrédient : `Nom|quantité|unité|note`)
|
||||
* ---
|
||||
* Steps (une ligne par étape, ordre du fichier = priorité)
|
||||
* ---
|
||||
* Notes (optionnel, présent si 6+ blocs `---`)
|
||||
* Tags (optionnel dès 4 blocs)
|
||||
* Meta (optionnel dès 5 blocs)
|
||||
*/
|
||||
|
||||
const splitIngredientLine = (line) => {
|
||||
const parts = line.split("|").map((token) => token.trim());
|
||||
const name = parts[0] ?? "";
|
||||
const quantity = Number(parts[1] ?? "0");
|
||||
const unit = parts[2] ?? "";
|
||||
const note = parts.slice(3).join(" | ").trim();
|
||||
return { name, quantity, unit, note };
|
||||
};
|
||||
|
||||
const splitNonEmptyLines = (raw) =>
|
||||
raw
|
||||
.split("\n")
|
||||
.map((line) => line.trim())
|
||||
.filter(Boolean);
|
||||
|
||||
const readBentext = (raw) => {
|
||||
const parts = raw.split("---").map((part) => part.trim());
|
||||
|
||||
const header = (parts[0] ?? "").split("\n").map((line) => line.trim());
|
||||
const title = header[0] ?? "";
|
||||
const version = header[1] ?? "";
|
||||
const servings = Number.parseInt(version, 10);
|
||||
const description = header.slice(2).join("\n").trim();
|
||||
|
||||
const ingredientsBlock = parts[1] ?? "";
|
||||
const stepsBlock = parts[2] ?? "";
|
||||
|
||||
let notesBlock = "";
|
||||
let tagsBlock = "";
|
||||
let metaBlock = "";
|
||||
|
||||
if (parts.length === 4) {
|
||||
tagsBlock = parts[3] ?? "";
|
||||
} else if (parts.length === 5) {
|
||||
tagsBlock = parts[3] ?? "";
|
||||
metaBlock = parts[4] ?? "";
|
||||
} else if (parts.length >= 6) {
|
||||
notesBlock = parts[3] ?? "";
|
||||
tagsBlock = parts[4] ?? "";
|
||||
metaBlock = parts[5] ?? "";
|
||||
}
|
||||
|
||||
const stepLines = splitNonEmptyLines(stepsBlock);
|
||||
const steps = stepLines.map((line, index) => ({
|
||||
id: `step-${index + 1}`,
|
||||
priority: (index + 1) * 10,
|
||||
phase: "cook",
|
||||
dependsOn: index === 0 ? [] : [`step-${index}`],
|
||||
text: line
|
||||
}));
|
||||
|
||||
return {
|
||||
title,
|
||||
version,
|
||||
servings: Number.isFinite(servings) && servings > 0 ? servings : 1,
|
||||
description,
|
||||
ingredients: splitNonEmptyLines(ingredientsBlock).map((line) => splitIngredientLine(line)),
|
||||
steps,
|
||||
notes: splitNonEmptyLines(notesBlock),
|
||||
tags: splitNonEmptyLines(tagsBlock).map((line) => line.toLowerCase()),
|
||||
meta: splitNonEmptyLines(metaBlock)
|
||||
};
|
||||
};
|
||||
|
||||
export { readBentext, splitIngredientLine };
|
||||
@@ -0,0 +1,176 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { readBentext, splitIngredientLine } from "./parse-bentext.mjs";
|
||||
|
||||
describe("readBentext", () => {
|
||||
it("parse header, ingredients, steps (3 blocs)", () => {
|
||||
const raw = `Blinis
|
||||
4
|
||||
Petites crêpes épaisses.
|
||||
---
|
||||
Farine|200|g
|
||||
Œufs|2|pcs
|
||||
---
|
||||
Mélanger la farine.
|
||||
Cuire dans une poêle.
|
||||
`;
|
||||
const result = readBentext(raw);
|
||||
|
||||
expect(result.title).toBe("Blinis");
|
||||
expect(result.version).toBe("4");
|
||||
expect(result.servings).toBe(4);
|
||||
expect(result.description).toBe("Petites crêpes épaisses.");
|
||||
expect(result.ingredients).toEqual([
|
||||
{ name: "Farine", quantity: 200, unit: "g", note: "" },
|
||||
{ name: "Œufs", quantity: 2, unit: "pcs", note: "" }
|
||||
]);
|
||||
expect(result.steps).toEqual([
|
||||
{ id: "step-1", priority: 10, phase: "cook", dependsOn: [], text: "Mélanger la farine." },
|
||||
{ id: "step-2", priority: 20, phase: "cook", dependsOn: ["step-1"], text: "Cuire dans une poêle." }
|
||||
]);
|
||||
expect(result.notes).toEqual([]);
|
||||
expect(result.tags).toEqual([]);
|
||||
expect(result.meta).toEqual([]);
|
||||
});
|
||||
|
||||
it("lit les tags, notes et meta quand 6 blocs --- sont présents", () => {
|
||||
const raw = `Title
|
||||
1
|
||||
Description.
|
||||
---
|
||||
Farine|100|g
|
||||
---
|
||||
Mélanger.
|
||||
---
|
||||
Note A
|
||||
Note B
|
||||
---
|
||||
apéritif
|
||||
blinis
|
||||
sprite:blinis
|
||||
---
|
||||
Meta X
|
||||
Meta Y
|
||||
`;
|
||||
const result = readBentext(raw);
|
||||
|
||||
expect(result.notes).toEqual(["Note A", "Note B"]);
|
||||
expect(result.tags).toEqual(["apéritif", "blinis", "sprite:blinis"]);
|
||||
expect(result.meta).toEqual(["Meta X", "Meta Y"]);
|
||||
});
|
||||
|
||||
it("ne place les tags qu'à partir de 4 blocs (sans notes ni meta)", () => {
|
||||
const raw = `Title
|
||||
1
|
||||
Description.
|
||||
---
|
||||
Farine|100|g
|
||||
---
|
||||
Mélanger.
|
||||
---
|
||||
apéritif
|
||||
blinis
|
||||
`;
|
||||
const result = readBentext(raw);
|
||||
expect(result.tags).toEqual(["apéritif", "blinis"]);
|
||||
expect(result.notes).toEqual([]);
|
||||
expect(result.meta).toEqual([]);
|
||||
});
|
||||
|
||||
it("place tags + meta dès 5 blocs (sans notes)", () => {
|
||||
const raw = `Title
|
||||
1
|
||||
Description.
|
||||
---
|
||||
Farine|100|g
|
||||
---
|
||||
Mélanger.
|
||||
---
|
||||
apéritif
|
||||
---
|
||||
Meta X
|
||||
`;
|
||||
const result = readBentext(raw);
|
||||
expect(result.tags).toEqual(["apéritif"]);
|
||||
expect(result.notes).toEqual([]);
|
||||
expect(result.meta).toEqual(["Meta X"]);
|
||||
});
|
||||
|
||||
it("convertit les tags en minuscules", () => {
|
||||
const raw = `T
|
||||
1
|
||||
D
|
||||
---
|
||||
A|1|g
|
||||
---
|
||||
S1
|
||||
---
|
||||
Bento
|
||||
CARNET
|
||||
`;
|
||||
const result = readBentext(raw);
|
||||
expect(result.tags).toEqual(["bento", "carnet"]);
|
||||
});
|
||||
|
||||
it("gère les notes et tags vides (blocs absents)", () => {
|
||||
const raw = `T
|
||||
|
||||
D
|
||||
---
|
||||
|
||||
---
|
||||
S1
|
||||
`;
|
||||
const result = readBentext(raw);
|
||||
expect(result.title).toBe("T");
|
||||
expect(result.version).toBe("");
|
||||
expect(result.servings).toBe(1);
|
||||
expect(result.description).toBe("D");
|
||||
expect(result.ingredients).toEqual([]);
|
||||
expect(result.steps).toEqual([{ id: "step-1", priority: 10, phase: "cook", dependsOn: [], text: "S1" }]);
|
||||
expect(result.notes).toEqual([]);
|
||||
expect(result.tags).toEqual([]);
|
||||
expect(result.meta).toEqual([]);
|
||||
});
|
||||
|
||||
it("extrait la note ingrédient après position 3 (parties | supplémentaires)", () => {
|
||||
const raw = `T
|
||||
1
|
||||
D
|
||||
---
|
||||
Farine|200|g|type 55
|
||||
---
|
||||
Mélanger.
|
||||
---
|
||||
---
|
||||
---
|
||||
`;
|
||||
const result = readBentext(raw);
|
||||
expect(result.ingredients).toEqual([
|
||||
{ name: "Farine", quantity: 200, unit: "g", note: "type 55" }
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("splitIngredientLine", () => {
|
||||
it("split name | quantity | unit", () => {
|
||||
expect(splitIngredientLine("Farine|200|g")).toEqual({ name: "Farine", quantity: 200, unit: "g", note: "" });
|
||||
});
|
||||
|
||||
it("renvoie quantity=NaN si non numérique (comportement préservé)", () => {
|
||||
expect(splitIngredientLine("Sel|abc|c. à c.")).toEqual({
|
||||
name: "Sel",
|
||||
quantity: Number.NaN,
|
||||
unit: "c. à c.",
|
||||
note: ""
|
||||
});
|
||||
});
|
||||
|
||||
it("conserve la note avec | à l'intérieur", () => {
|
||||
expect(splitIngredientLine("Sauce|2|c. à s.|soja | sucrée")).toEqual({
|
||||
name: "Sauce",
|
||||
quantity: 2,
|
||||
unit: "c. à s.",
|
||||
note: "soja | sucrée"
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,223 @@
|
||||
/**
|
||||
* Orchestration du pipeline recettes : agrège les bentext multi-locales,
|
||||
* résout les icônes, fusionne ingrédients / étapes, et sérialise le catalogue.
|
||||
*
|
||||
* Responsabilités :
|
||||
* 1. Charger les définitions de sprites (parsing pur) + métadonnées PNG.
|
||||
* 2. Lire les `.bentext`, fusionner par slug.
|
||||
* 3. Calculer flavour + cover + icônes par ingrédient.
|
||||
* 4. Construire le graph `bases` + `variants` (logique pure).
|
||||
* 5. Écrire `catalog.json` (+ `recipes.json`) + copier les assets vers `apps/web/public`.
|
||||
*
|
||||
* Les responsabilités pures (parsing, classification, graph) sont déléguées
|
||||
* aux modules de `scripts/recipes/`.
|
||||
*/
|
||||
|
||||
import { promises as fs } from "node:fs";
|
||||
import path from "node:path";
|
||||
|
||||
import {
|
||||
copyBrandingAssets,
|
||||
copyIngredientAssetsToWeb,
|
||||
PATHS,
|
||||
pickCoverImage,
|
||||
readPngDimensions
|
||||
} from "./assets.mjs";
|
||||
import { buildRecipeGraph, classifyFlavor } from "./classification.mjs";
|
||||
import { readBentext } from "./parse-bentext.mjs";
|
||||
import {
|
||||
aliasToIconPx,
|
||||
parseIngredientSpritesBentext,
|
||||
resolveIconPxForIngredientName
|
||||
} from "./sprites.mjs";
|
||||
import {
|
||||
extractVariantTileSpriteAlias,
|
||||
stripVariantTileSpriteTags
|
||||
} from "../recipe-variant-tile-sprite.mjs";
|
||||
|
||||
/** Locales prises en charge dans le pipeline (aligné sur les fichiers bentext versionnés). */
|
||||
const LOCALES = ["fr", "en", "ko", "ja", "zh"];
|
||||
|
||||
/** Charge la grille de sprites et écrit le CSV dérivé (compat / rétro). */
|
||||
const loadIngredientSpriteDefinitions = async () => {
|
||||
try {
|
||||
const raw = await fs.readFile(PATHS.ingredientSpritesBentextPath, "utf8");
|
||||
const parsed = parseIngredientSpritesBentext(raw);
|
||||
await fs.writeFile(path.join(PATHS.publicAssetsDir, "ingredient-sprites.csv"), parsed.csv, "utf8");
|
||||
return {
|
||||
byAlias: parsed.byAlias,
|
||||
preferredAliases: parsed.preferredAliases
|
||||
};
|
||||
} catch (e) {
|
||||
console.warn("[build-recipes] ingredient-sprites.bentext absent ou erreur:", e?.message ?? e);
|
||||
return { byAlias: {}, preferredAliases: new Set() };
|
||||
}
|
||||
};
|
||||
|
||||
/** Lit `ressources/bentext/*.<locale>.bentext` et les regroupe par slug. */
|
||||
const readAllBentext = async () => {
|
||||
const files = await fs.readdir(PATHS.recipesDir);
|
||||
const grouped = new Map();
|
||||
for (const file of files) {
|
||||
if (!file.endsWith(".bentext")) continue;
|
||||
const [slug, locale] = file.replace(".bentext", "").split(".");
|
||||
const raw = await fs.readFile(path.join(PATHS.recipesDir, file), "utf8");
|
||||
const parsed = readBentext(raw);
|
||||
const current = grouped.get(slug) ?? { slug, locales: {} };
|
||||
current.locales[locale] = parsed;
|
||||
grouped.set(slug, current);
|
||||
}
|
||||
return grouped;
|
||||
};
|
||||
|
||||
/** Fusionne les ingrédients de toutes les locales en un seul objet {id, names, notes, …}. */
|
||||
const mergeIngredients = (slug, locales) => {
|
||||
const ingredientMap = new Map();
|
||||
|
||||
for (const locale of LOCALES) {
|
||||
const localized = locales[locale];
|
||||
if (!localized) continue;
|
||||
localized.ingredients.forEach((ingredient, index) => {
|
||||
/* Clé stable par rang : le nom diffère selon la langue (farine / flour / 面粉),
|
||||
* sinon une entrée par locale → liste d’ingrédients multipliée. */
|
||||
const id = `${slug}-ingredient-${index}`;
|
||||
const current = ingredientMap.get(id) ?? {
|
||||
id,
|
||||
quantity: ingredient.quantity,
|
||||
unit: ingredient.unit,
|
||||
names: {},
|
||||
notes: {}
|
||||
};
|
||||
current.names[locale] = ingredient.name;
|
||||
current.notes[locale] = ingredient.note;
|
||||
ingredientMap.set(id, current);
|
||||
});
|
||||
}
|
||||
|
||||
return Array.from(ingredientMap.values()).map((ingredient) => ({
|
||||
...ingredient,
|
||||
names: {
|
||||
fr: ingredient.names.fr ?? ingredient.names.en,
|
||||
en: ingredient.names.en ?? ingredient.names.fr,
|
||||
ko: ingredient.names.ko ?? ingredient.names.en,
|
||||
zh: ingredient.names.zh ?? ingredient.names.ja ?? ingredient.names.en
|
||||
},
|
||||
notes: {
|
||||
fr: ingredient.notes.fr ?? "",
|
||||
en: ingredient.notes.en ?? "",
|
||||
ko: ingredient.notes.ko ?? "",
|
||||
zh: ingredient.notes.zh ?? ingredient.notes.ja ?? ""
|
||||
}
|
||||
}));
|
||||
};
|
||||
|
||||
/** Fusionne les étapes : `fr` est la référence, chaque locale comble les trous. */
|
||||
const mergeSteps = (slug, locales) => {
|
||||
const localizedSteps = {};
|
||||
for (const locale of LOCALES) {
|
||||
const localized = locales[locale];
|
||||
if (!localized) continue;
|
||||
localizedSteps[locale] = localized.steps.map((step, index) => ({
|
||||
...step,
|
||||
text: step.text,
|
||||
index
|
||||
}));
|
||||
}
|
||||
|
||||
const frSteps = localizedSteps.fr ?? [];
|
||||
return frSteps.map((step, index) => ({
|
||||
id: step.id,
|
||||
priority: step.priority,
|
||||
phase: step.phase,
|
||||
dependsOn: step.dependsOn,
|
||||
text: {
|
||||
fr: step.text,
|
||||
en: localizedSteps.en?.[index]?.text ?? step.text,
|
||||
ko: localizedSteps.ko?.[index]?.text ?? localizedSteps.en?.[index]?.text ?? step.text,
|
||||
zh: localizedSteps.zh?.[index]?.text ?? localizedSteps.ja?.[index]?.text ?? localizedSteps.en?.[index]?.text ?? step.text
|
||||
}
|
||||
}));
|
||||
};
|
||||
|
||||
/** Annote chaque ingrédient avec ses coordonnées pixel (sprite sheet) ou `null`. */
|
||||
const attachIngredientIcons = (ingredients, spriteByAlias) =>
|
||||
ingredients.map((ingredient) => ({
|
||||
...ingredient,
|
||||
iconPx:
|
||||
resolveIconPxForIngredientName(ingredient.names.fr ?? "", spriteByAlias) ??
|
||||
resolveIconPxForIngredientName(ingredient.names.en ?? "", spriteByAlias) ??
|
||||
null
|
||||
}));
|
||||
|
||||
/**
|
||||
* Pour chaque slug groupé : sélectionne cover, calcule flavour, fusionne ingrédients/étapes,
|
||||
* résout les icônes, et ajoute l’alias tuile variante (`sprite:xxx`) si présent dans les tags.
|
||||
*/
|
||||
const buildMergedRecipes = async (grouped, spriteByAlias) => {
|
||||
const mergedRecipes = [];
|
||||
for (const entry of grouped.values()) {
|
||||
const fr = entry.locales.fr;
|
||||
if (!fr) {
|
||||
console.warn(`Skipping ${entry.slug}: missing fr locale`);
|
||||
continue;
|
||||
}
|
||||
|
||||
const tagsRaw = fr.tags;
|
||||
const variantTileSpriteAlias = extractVariantTileSpriteAlias(tagsRaw);
|
||||
const tags = stripVariantTileSpriteTags(tagsRaw);
|
||||
const flavor = classifyFlavor(entry.slug, tags);
|
||||
const coverImageUrl = await pickCoverImage(entry.slug);
|
||||
|
||||
const mergedIngredients = attachIngredientIcons(
|
||||
mergeIngredients(entry.slug, entry.locales),
|
||||
spriteByAlias
|
||||
);
|
||||
const mergedSteps = mergeSteps(entry.slug, entry.locales);
|
||||
|
||||
mergedRecipes.push({
|
||||
slug: entry.slug,
|
||||
flavor,
|
||||
tags,
|
||||
variantTileSpriteAlias,
|
||||
coverImageUrl,
|
||||
servings: fr.servings,
|
||||
locales: entry.locales,
|
||||
mergedIngredients,
|
||||
mergedSteps
|
||||
});
|
||||
}
|
||||
return mergedRecipes;
|
||||
};
|
||||
|
||||
const writeCatalog = async (grouped, catalog) => {
|
||||
await fs.mkdir(PATHS.outDir, { recursive: true });
|
||||
await fs.mkdir(PATHS.webPublicDir, { recursive: true });
|
||||
|
||||
const rawBundle = Array.from(grouped.values());
|
||||
await fs.writeFile(path.join(PATHS.outDir, "recipes.json"), JSON.stringify(rawBundle, null, 2), "utf8");
|
||||
await fs.writeFile(path.join(PATHS.outDir, "catalog.json"), JSON.stringify(catalog, null, 2), "utf8");
|
||||
await fs.writeFile(path.join(PATHS.webPublicDir, "catalog.json"), JSON.stringify(catalog, null, 2), "utf8");
|
||||
};
|
||||
|
||||
const run = async () => {
|
||||
const { byAlias: spriteByAlias, preferredAliases: preferredVariantTileAliases } =
|
||||
await loadIngredientSpriteDefinitions();
|
||||
const sheetMeta = await readPngDimensions(PATHS.ingredientsPngPath);
|
||||
|
||||
await copyBrandingAssets();
|
||||
|
||||
const grouped = await readAllBentext();
|
||||
const mergedRecipes = await buildMergedRecipes(grouped, spriteByAlias);
|
||||
const catalog = buildRecipeGraph(mergedRecipes, sheetMeta, spriteByAlias, preferredVariantTileAliases);
|
||||
|
||||
await writeCatalog(grouped, catalog);
|
||||
await copyIngredientAssetsToWeb();
|
||||
|
||||
console.log(
|
||||
`Generated ${grouped.size} recipes → ${catalog.variants.length} variantes, ${catalog.bases.length} bases canoniques`
|
||||
);
|
||||
};
|
||||
|
||||
// Re-export pour les consommateurs qui voudraient réutiliser la résolution sprite
|
||||
// (ex. audit-recipe-assets ou tests). Le pipeline lui-même n'en dépend pas.
|
||||
export { loadIngredientSpriteDefinitions, aliasToIconPx, run };
|
||||
@@ -0,0 +1,183 @@
|
||||
/**
|
||||
* Sprites ingrédients — parsing de `ingredient-sprites.bentext` et résolution
|
||||
* alias → coordonnées pixel sur la planche. Pure, sans effet de bord.
|
||||
*
|
||||
* Format de `ingredient-sprites.bentext` :
|
||||
* --- (séparateur de blocs)
|
||||
* <row> <col> (coordonnées dans la grille, taille de cellule = INGREDIENT_SPRITE_CELL)
|
||||
* alias 1 | alias 2 (synonymes séparés par `|`, lignes supplémentaires acceptées)
|
||||
* ---
|
||||
* ...
|
||||
*
|
||||
* Sortie de `parseIngredientSpritesBentext` :
|
||||
* - byAlias : { [aliasLower]: { x, y, X, y: y } }
|
||||
* - preferredAliases : Set<aliasLower> (alias des lignes « prioritaires »)
|
||||
* - csv : rendu CSV (compat / rétro pour les outils existants)
|
||||
*/
|
||||
|
||||
export const INGREDIENT_SPRITE_CELL = 32;
|
||||
|
||||
/** Lignes de tuiles « prioritaires » pour la carte variante (aligné sur l’ancienne grille CSV). */
|
||||
export const PREFERRED_VARIANT_SPRITE_ROWS = new Set([8, 9, 10, 12, 13, 14, 15, 16]);
|
||||
|
||||
const normalizeIngredientAliasKey = (s) => s.trim().toLowerCase().replace(/\s+/g, " ");
|
||||
|
||||
const foldAccents = (s) => s.normalize("NFD").replace(/\p{M}+/gu, "").toLowerCase();
|
||||
|
||||
/**
|
||||
* Parse `ingredient-sprites.bentext` : blocs `---` puis ligne `row col`, puis alias (`|` = synonymes).
|
||||
*/
|
||||
const parseIngredientSpritesBentext = (raw) => {
|
||||
const byAlias = {};
|
||||
const preferredAliases = new Set();
|
||||
const grid = new Map();
|
||||
|
||||
const chunks = raw
|
||||
.replace(/^\uFEFF/, "")
|
||||
.trim()
|
||||
.split(/\r?\n---\r?\n/);
|
||||
for (const chunk of chunks) {
|
||||
const trimmedChunk = chunk.trim();
|
||||
if (!trimmedChunk) continue;
|
||||
const lines = trimmedChunk
|
||||
.split(/\r?\n/)
|
||||
.map((l) => l.trim())
|
||||
.filter((l) => l.length > 0);
|
||||
if (lines.length < 2) continue;
|
||||
const coordMatch = lines[0].match(/^(\d+)\s+(\d+)/);
|
||||
if (!coordMatch) {
|
||||
if (lines[0] === "---") continue;
|
||||
console.warn(`[build-recipes] Bloc sprite ignoré (coords invalides): ${lines[0].slice(0, 48)}`);
|
||||
continue;
|
||||
}
|
||||
const row = Number(coordMatch[1]);
|
||||
const col = Number(coordMatch[2]);
|
||||
const x = col * INGREDIENT_SPRITE_CELL;
|
||||
const y = row * INGREDIENT_SPRITE_CELL;
|
||||
const entry = { x, y, X: x, Y: y };
|
||||
|
||||
const aliasLines = lines.slice(1);
|
||||
const cellText = aliasLines.join("|");
|
||||
const ck = `${row},${col}`;
|
||||
grid.set(ck, grid.has(ck) ? `${grid.get(ck)}|${cellText}` : cellText);
|
||||
|
||||
const isPreferredRow = PREFERRED_VARIANT_SPRITE_ROWS.has(row);
|
||||
|
||||
for (const line of aliasLines) {
|
||||
for (const part of line.split("|")) {
|
||||
const key = normalizeIngredientAliasKey(part);
|
||||
if (!key) continue;
|
||||
const prev = byAlias[key];
|
||||
if (!prev) {
|
||||
byAlias[key] = entry;
|
||||
} else if (prev.x !== x || prev.y !== y) {
|
||||
console.warn(
|
||||
`[build-recipes] Alias sprite « ${key} » en conflit (${prev.x},${prev.y}) vs (${x},${y}) — première définition conservée.`
|
||||
);
|
||||
continue;
|
||||
}
|
||||
if (isPreferredRow) preferredAliases.add(key);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
let maxR = 0;
|
||||
let maxC = 0;
|
||||
for (const k of grid.keys()) {
|
||||
const [r, c] = k.split(",").map(Number);
|
||||
maxR = Math.max(maxR, r);
|
||||
maxC = Math.max(maxC, c);
|
||||
}
|
||||
const csvLines = [];
|
||||
for (let r = 0; r <= maxR; r++) {
|
||||
const cols = [];
|
||||
for (let c = 0; c <= maxC; c++) {
|
||||
cols.push(grid.get(`${r},${c}`) ?? "");
|
||||
}
|
||||
csvLines.push(cols.join(","));
|
||||
}
|
||||
const csv = `${csvLines.join("\n")}\n`;
|
||||
|
||||
return { byAlias, preferredAliases, csv };
|
||||
};
|
||||
|
||||
const resolveIconPxForIngredientName = (name, byAlias) => {
|
||||
if (!name || typeof name !== "string") return null;
|
||||
const n = normalizeIngredientAliasKey(name);
|
||||
if (byAlias[n]) return aliasToIconPx(byAlias, n);
|
||||
const nFold = foldAccents(n);
|
||||
for (const key of Object.keys(byAlias)) {
|
||||
if (foldAccents(key) === nFold) return aliasToIconPx(byAlias, key);
|
||||
}
|
||||
const segments = n.split(/[,;]|(?:\s+\/\s+)|(?:\s+et\s+)/i).map((s) => s.trim()).filter(Boolean);
|
||||
for (const seg of segments) {
|
||||
if (byAlias[seg]) return aliasToIconPx(byAlias, seg);
|
||||
const sf = foldAccents(seg);
|
||||
for (const key of Object.keys(byAlias)) {
|
||||
if (foldAccents(key) === sf) return aliasToIconPx(byAlias, key);
|
||||
}
|
||||
}
|
||||
const keysSorted = Object.keys(byAlias).sort((a, b) => b.length - a.length);
|
||||
for (const k of keysSorted) {
|
||||
if (k.length < 3) continue;
|
||||
if (n.includes(k) || nFold.includes(foldAccents(k))) return aliasToIconPx(byAlias, k);
|
||||
}
|
||||
return null;
|
||||
};
|
||||
|
||||
/** Lit les coordonnées pixel dans `byAlias` (API : `X`/`Y` ou `x`/`y`). */
|
||||
const aliasToIconPx = (byAlias, alias) => {
|
||||
if (!byAlias || typeof alias !== "string") return null;
|
||||
const entry = byAlias[alias];
|
||||
if (!entry || typeof entry !== "object") return null;
|
||||
const x = typeof entry.x === "number" ? entry.x : typeof entry.X === "number" ? entry.X : NaN;
|
||||
const y = typeof entry.y === "number" ? entry.y : typeof entry.Y === "number" ? entry.Y : NaN;
|
||||
if (!Number.isFinite(x) || !Number.isFinite(y)) return null;
|
||||
return { x, y };
|
||||
};
|
||||
|
||||
/** Alias manifeste correspondant à des coordonnées exactes sur la planche (pour filtrer les tuiles). */
|
||||
const findAliasForIconPx = (byAlias, px) => {
|
||||
if (!byAlias || !px || typeof px.x !== "number" || typeof px.y !== "number") return null;
|
||||
for (const [alias, entry] of Object.entries(byAlias)) {
|
||||
if (!entry || typeof entry !== "object") continue;
|
||||
const x = typeof entry.x === "number" ? entry.x : typeof entry.X === "number" ? entry.X : NaN;
|
||||
const y = typeof entry.y === "number" ? entry.y : typeof entry.Y === "number" ? entry.Y : NaN;
|
||||
if (px.x === x && px.y === y) return alias;
|
||||
}
|
||||
return null;
|
||||
};
|
||||
|
||||
const isPreferredVariantAlias = (preferredSet, manifestAlias) => {
|
||||
if (!manifestAlias || preferredSet.size === 0) return false;
|
||||
const lo = manifestAlias.trim().toLowerCase();
|
||||
if (preferredSet.has(lo)) return true;
|
||||
return [...preferredSet].some((p) => p.toLowerCase() === lo);
|
||||
};
|
||||
|
||||
const buildIngredientCoverSprite = (iconPx, sheet) => {
|
||||
if (!iconPx || typeof iconPx.x !== "number" || typeof iconPx.y !== "number" || !sheet) {
|
||||
return null;
|
||||
}
|
||||
const col = Math.round(iconPx.x / INGREDIENT_SPRITE_CELL);
|
||||
const row = Math.round(iconPx.y / INGREDIENT_SPRITE_CELL);
|
||||
return {
|
||||
sheet: "/ingredients.png",
|
||||
col,
|
||||
row,
|
||||
cell: INGREDIENT_SPRITE_CELL,
|
||||
sheetWidth: sheet.width,
|
||||
sheetHeight: sheet.height
|
||||
};
|
||||
};
|
||||
|
||||
export {
|
||||
parseIngredientSpritesBentext,
|
||||
resolveIconPxForIngredientName,
|
||||
aliasToIconPx,
|
||||
findAliasForIconPx,
|
||||
isPreferredVariantAlias,
|
||||
buildIngredientCoverSprite,
|
||||
normalizeIngredientAliasKey,
|
||||
foldAccents
|
||||
};
|
||||
@@ -0,0 +1,160 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import {
|
||||
aliasToIconPx,
|
||||
buildIngredientCoverSprite,
|
||||
findAliasForIconPx,
|
||||
foldAccents,
|
||||
INGREDIENT_SPRITE_CELL,
|
||||
isPreferredVariantAlias,
|
||||
normalizeIngredientAliasKey,
|
||||
parseIngredientSpritesBentext,
|
||||
resolveIconPxForIngredientName
|
||||
} from "./sprites.mjs";
|
||||
|
||||
describe("parseIngredientSpritesBentext", () => {
|
||||
it("parse les blocs --- avec coordonnées et alias (| = synonymes)", () => {
|
||||
const raw = `2 0
|
||||
eau|vinaigre de riz|Vinaigre blanc
|
||||
---
|
||||
0 4
|
||||
carnet
|
||||
---
|
||||
8 1
|
||||
fromage
|
||||
`;
|
||||
const { byAlias, preferredAliases, csv } = parseIngredientSpritesBentext(raw);
|
||||
|
||||
// eau → (0, 64) ; carnet → (128, 0) ; fromage → (32, 256) — ligne 8 = preferred
|
||||
expect(byAlias.eau).toEqual({ x: 0, y: 64, X: 0, Y: 64 });
|
||||
expect(byAlias["vinaigre de riz"]).toEqual({ x: 0, y: 64, X: 0, Y: 64 });
|
||||
expect(byAlias["vinaigre blanc"]).toEqual({ x: 0, y: 64, X: 0, Y: 64 });
|
||||
expect(byAlias.carnet).toEqual({ x: 128, y: 0, X: 128, Y: 0 });
|
||||
expect(byAlias.fromage).toEqual({ x: 32, y: 256, X: 32, Y: 256 });
|
||||
|
||||
expect(preferredAliases.has("fromage")).toBe(true);
|
||||
expect(preferredAliases.has("eau")).toBe(false);
|
||||
expect(preferredAliases.has("carnet")).toBe(false);
|
||||
|
||||
expect(csv).toContain("carnet");
|
||||
expect(csv).toContain("fromage");
|
||||
});
|
||||
|
||||
it("ignore un bloc sans coordonnées valides", () => {
|
||||
const raw = `not_a_coord
|
||||
eau
|
||||
---
|
||||
0 0
|
||||
couvert
|
||||
`;
|
||||
const { byAlias } = parseIngredientSpritesBentext(raw);
|
||||
expect(byAlias.eau).toBeUndefined();
|
||||
expect(byAlias.couvert).toEqual({ x: 0, y: 0, X: 0, Y: 0 });
|
||||
});
|
||||
|
||||
it("conserve la première définition en cas de conflit d’alias", () => {
|
||||
const raw = `0 0
|
||||
eau
|
||||
---
|
||||
1 0
|
||||
eau
|
||||
`;
|
||||
const { byAlias } = parseIngredientSpritesBentext(raw);
|
||||
expect(byAlias.eau).toEqual({ x: 0, y: 0, X: 0, Y: 0 });
|
||||
});
|
||||
|
||||
it("retire le BOM et tolère CRLF entre blocs", () => {
|
||||
const raw = "\uFEFF0 0\r\ncouvert\r\n---\r\n0 1\r\nassiette\r\n";
|
||||
const { byAlias } = parseIngredientSpritesBentext(raw);
|
||||
expect(byAlias.couvert).toBeDefined();
|
||||
expect(byAlias.assiette).toBeDefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe("resolveIconPxForIngredientName", () => {
|
||||
const byAlias = {
|
||||
riz: { x: 0, y: 0 },
|
||||
"riz gluant": { x: 32, y: 0 },
|
||||
avocat: { x: 64, y: 0 }
|
||||
};
|
||||
|
||||
it("trouve l’alias direct", () => {
|
||||
expect(resolveIconPxForIngredientName("riz", byAlias)).toEqual({ x: 0, y: 0 });
|
||||
});
|
||||
|
||||
it("recherche sans accents", () => {
|
||||
expect(resolveIconPxForIngredientName("AVOCAT", byAlias)).toEqual({ x: 64, y: 0 });
|
||||
});
|
||||
|
||||
it("retombe sur l’alias le plus long en inclusion", () => {
|
||||
expect(resolveIconPxForIngredientName("Riz gluant blanc", byAlias)).toEqual({ x: 32, y: 0 });
|
||||
});
|
||||
|
||||
it("retourne null si pas de correspondance", () => {
|
||||
expect(resolveIconPxForIngredientName("introuvable", byAlias)).toBeNull();
|
||||
});
|
||||
|
||||
it("gère un nom composé avec / ou et (segments)", () => {
|
||||
const local = { "eau": { x: 0, y: 0 }, "sel": { x: 32, y: 0 } };
|
||||
expect(resolveIconPxForIngredientName("Sel / eau", local)).toEqual({ x: 32, y: 0 });
|
||||
});
|
||||
});
|
||||
|
||||
describe("aliasToIconPx", () => {
|
||||
it("lit les coordonnées (x, y) ou (X, Y)", () => {
|
||||
expect(aliasToIconPx({ a: { x: 1, y: 2 } }, "a")).toEqual({ x: 1, y: 2 });
|
||||
expect(aliasToIconPx({ a: { X: 3, Y: 4 } }, "a")).toEqual({ x: 3, y: 4 });
|
||||
});
|
||||
|
||||
it("retourne null si alias ou coords absentes", () => {
|
||||
expect(aliasToIconPx({}, "x")).toBeNull();
|
||||
expect(aliasToIconPx({ a: { x: "a", y: 2 } }, "a")).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe("findAliasForIconPx", () => {
|
||||
it("renvoie l’alias correspondant aux coordonnées exactes", () => {
|
||||
const byAlias = { a: { x: 0, y: 0 }, b: { x: 32, y: 0 } };
|
||||
expect(findAliasForIconPx(byAlias, { x: 32, y: 0 })).toBe("b");
|
||||
expect(findAliasForIconPx(byAlias, { x: 100, y: 0 })).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe("isPreferredVariantAlias", () => {
|
||||
it("matche case-insensitive et tolère un set vide", () => {
|
||||
const preferred = new Set(["fromage", "tomate"]);
|
||||
expect(isPreferredVariantAlias(preferred, "Fromage")).toBe(true);
|
||||
expect(isPreferredVariantAlias(preferred, "autre")).toBe(false);
|
||||
expect(isPreferredVariantAlias(new Set(), "fromage")).toBe(false);
|
||||
expect(isPreferredVariantAlias(preferred, "")).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("buildIngredientCoverSprite", () => {
|
||||
const sheet = { width: 512, height: 1024 };
|
||||
|
||||
it("renvoie null si coords ou sheet invalides", () => {
|
||||
expect(buildIngredientCoverSprite(null, sheet)).toBeNull();
|
||||
expect(buildIngredientCoverSprite({ x: 0, y: 0 }, null)).toBeNull();
|
||||
});
|
||||
|
||||
it("calcule col/row depuis les pixels et fige sheet/cell", () => {
|
||||
const result = buildIngredientCoverSprite({ x: 64, y: 256 }, sheet);
|
||||
expect(result).toEqual({
|
||||
sheet: "/ingredients.png",
|
||||
col: 2,
|
||||
row: 8,
|
||||
cell: INGREDIENT_SPRITE_CELL,
|
||||
sheetWidth: 512,
|
||||
sheetHeight: 1024
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("normalizeIngredientAliasKey & foldAccents", () => {
|
||||
it("normalise espaces et casse", () => {
|
||||
expect(normalizeIngredientAliasKey(" Riz gluant ")).toBe("riz gluant");
|
||||
});
|
||||
it("plie les accents", () => {
|
||||
expect(foldAccents("Crème pâtissière")).toBe("creme patissiere");
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user