first commit

This commit is contained in:
2026-07-21 16:50:58 +02:00
commit 256599626e
407 changed files with 30489 additions and 0 deletions
+10
View File
@@ -0,0 +1,10 @@
{
"name": "@ben-to/builder",
"version": "0.1.0",
"type": "module",
"main": "src/domain.ts",
"types": "src/domain.ts",
"exports": {
"./domain": "./src/domain.ts"
}
}
@@ -0,0 +1,52 @@
import { describe, expect, it } from "vitest";
import {
baseFlavorGroupKey,
baseIdsInSameFlavorGroup,
computeRecipeSteps,
createInitialSelection,
primaryBaseIdForVariant,
type BaseOption,
type VariantOption
} from "./domain";
const emptyText = { fr: "", en: "", ko: "", zh: "" };
describe("builder integration (Gherkin @int-builder-*)", () => {
/** @int-builder-continue-after-base */
it("étape base : une sélection avec base permet de poursuivre vers les étapes recette", () => {
const selection = Object.freeze({ ...createInitialSelection(), baseId: "base:onigiri" });
expect(selection.baseId).toBe("base:onigiri");
const merged = computeRecipeSteps({
base: {
id: "base:onigiri",
flavor: "savory",
name: emptyText,
history: emptyText,
ingredients: [{ id: "rice", name: emptyText, info: emptyText, quantity: 1, unit: "g" }],
steps: [{ id: "s1", priority: 1, phase: "prep", dependsOn: [], text: emptyText }]
},
variant: null
});
expect(merged.steps).toHaveLength(1);
});
/** @int-builder-variant-filter */
it("variantes : seules les bases compatibles restent dans le groupe saveur", () => {
const mk = (id: string, slug: string, flavor: "savory" | "sweet"): BaseOption => ({
id,
slug,
flavor,
name: emptyText,
history: emptyText,
ingredients: [],
steps: []
});
const bases = [mk("base:onigiri-savory", "onigiri-savory", "savory"), mk("base:onigiri-sweet", "onigiri-sweet", "sweet")];
expect(baseFlavorGroupKey(bases[0])).toBe("onigiri");
const same = baseIdsInSameFlavorGroup(bases, "base:onigiri-savory");
expect(same).toContain("base:onigiri-savory");
expect(same).toContain("base:onigiri-sweet");
const variant: Pick<VariantOption, "baseIds"> = { baseIds: ["base:onigiri-savory", "base:onigiri-sweet"] };
expect(primaryBaseIdForVariant(variant, "base:onigiri-sweet")).toBe("base:onigiri-sweet");
});
});
+126
View File
@@ -0,0 +1,126 @@
export type LocalizedText = Readonly<Record<"fr" | "en" | "ko" | "zh", string>>;
export type StepKind = "base" | "variant" | "recipe";
/** Découpe dans `/ingredients.png` (grille 32×32 ; coordonnées depuis `ingredient-sprites.bentext`). */
export type CoverSprite = Readonly<{
sheet: string;
col: number;
row: number;
cell: number;
sheetWidth: number;
sheetHeight: number;
}>;
export type Ingredient = Readonly<{
id: string;
name: LocalizedText;
info: LocalizedText;
quantity: number;
unit: string;
coverSprite?: CoverSprite | null;
}>;
export type RecipeStep = Readonly<{
id: string;
priority: number;
phase: "prep" | "cook" | "assemble";
dependsOn: ReadonlyArray<string>;
text: LocalizedText;
}>;
export type BaseOption = Readonly<{
id: string;
flavor: "savory" | "sweet";
name: LocalizedText;
history: LocalizedText;
servings?: number;
ingredients: ReadonlyArray<Ingredient>;
steps: ReadonlyArray<RecipeStep>;
slug?: string;
coverImageUrl?: string | null;
coverSprite?: CoverSprite | null;
tags?: ReadonlyArray<string>;
}>;
export type VariantOption = Readonly<{
id: string;
baseIds: ReadonlyArray<string>;
name: LocalizedText;
story: LocalizedText;
servings?: number;
ingredients: ReadonlyArray<Ingredient>;
steps: ReadonlyArray<RecipeStep>;
slug?: string;
coverImageUrl?: string | null;
coverSprite?: CoverSprite | null;
recipeCoverImageUrl?: string | null;
tags?: ReadonlyArray<string>;
}>;
export type BentoSelection = Readonly<{
baseId: string | null;
variantId: string | null;
}>;
export const createInitialSelection = (): BentoSelection =>
Object.freeze({
baseId: null,
variantId: null
});
/**
* Regroupe les bases « salée / sucrée » partageant le même slug racine (`…-savory` / `…-sweet`).
* Sinon la clé est `id` (base unique).
*/
export const baseFlavorGroupKey = (base: Pick<BaseOption, "id" | "slug">): string => {
const slug = base.slug;
if (typeof slug === "string") {
if (slug.endsWith("-savory")) return slug.slice(0, -"-savory".length);
if (slug.endsWith("-sweet")) return slug.slice(0, -"-sweet".length);
}
return base.id;
};
export const baseIdsInSameFlavorGroup = (bases: ReadonlyArray<BaseOption>, baseId: string): ReadonlyArray<string> => {
const base = bases.find((item) => item.id === baseId);
if (!base) return [];
const key = baseFlavorGroupKey(base);
return bases.filter((item) => baseFlavorGroupKey(item) === key).map((item) => item.id);
};
/** Choisit lid de base catalogue à associer à une variante (plusieurs `baseIds` si groupe salé/sucré). */
export const primaryBaseIdForVariant = (
variant: Pick<VariantOption, "baseIds">,
prevBaseId: string | null
): string | null => {
if (variant.baseIds.length === 0) return prevBaseId;
if (variant.baseIds.length === 1) return variant.baseIds[0] ?? null;
if (prevBaseId && variant.baseIds.includes(prevBaseId)) return prevBaseId;
return variant.baseIds[0] ?? null;
};
/**
* Recette affichée : une variante contient déjà la recette complète (ingrédients + étapes) ;
* la base sert au choix / contexte. Ne pas concaténer base + variante (doublons dans le catalogue).
*/
export const computeRecipeSteps = (input: {
base: BaseOption | null;
variant: VariantOption | null;
}): Readonly<{ servings: number; ingredients: ReadonlyArray<Ingredient>; steps: ReadonlyArray<RecipeStep> }> => {
if (input.variant) {
const steps = [...input.variant.steps].sort((left, right) => left.priority - right.priority);
return Object.freeze({
servings: input.variant.servings ?? 1,
ingredients: Object.freeze([...input.variant.ingredients]),
steps: Object.freeze(steps)
});
}
const ingredients = [...(input.base?.ingredients ?? [])];
const steps = [...(input.base?.steps ?? [])].sort((left, right) => left.priority - right.priority);
return Object.freeze({
servings: input.base?.servings ?? 1,
ingredients: Object.freeze(ingredients),
steps: Object.freeze(steps)
});
};
@@ -0,0 +1,101 @@
import { describe, expect, it } from "vitest";
import {
type BaseOption,
baseFlavorGroupKey,
baseIdsInSameFlavorGroup,
computeRecipeSteps,
createInitialSelection,
primaryBaseIdForVariant
} from "./domain";
describe("builder domain", () => {
it("creates an immutable initial selection", () => {
const selection = createInitialSelection();
expect(selection.baseId).toBeNull();
expect(Object.isFrozen(selection)).toBe(true);
});
it("nutilise que la variante (recette complète) quand elle est présente, tri par priorité", () => {
const merged = computeRecipeSteps({
base: {
id: "base",
flavor: "savory",
name: { fr: "", en: "", ko: "", zh: "" },
history: { fr: "", en: "", ko: "", zh: "" },
servings: 4,
ingredients: [{ id: "base-only", name: { fr: "", en: "", ko: "", zh: "" }, info: { fr: "", en: "", ko: "", zh: "" }, quantity: 1, unit: "g" }],
steps: [{ id: "b", priority: 20, phase: "prep", dependsOn: [], text: { fr: "", en: "", ko: "", zh: "" } }]
},
variant: {
id: "variant",
baseIds: ["base"],
name: { fr: "", en: "", ko: "", zh: "" },
story: { fr: "", en: "", ko: "", zh: "" },
servings: 9,
ingredients: [{ id: "v1", name: { fr: "", en: "", ko: "", zh: "" }, info: { fr: "", en: "", ko: "", zh: "" }, quantity: 2, unit: "g" }],
steps: [
{ id: "a", priority: 30, phase: "prep", dependsOn: [], text: { fr: "", en: "", ko: "", zh: "" } },
{ id: "c", priority: 5, phase: "prep", dependsOn: [], text: { fr: "", en: "", ko: "", zh: "" } }
]
}
});
expect(merged.servings).toBe(9);
expect(merged.ingredients.map((i) => i.id)).toEqual(["v1"]);
expect(merged.steps.map((step) => step.id)).toEqual(["c", "a"]);
});
it("regroupe slug …-savory / …-sweet sous une même clé", () => {
expect(
baseFlavorGroupKey({ id: "base:empanadas-savory", slug: "empanadas-savory" })
).toBe("empanadas");
expect(baseFlavorGroupKey({ id: "base:empanadas-sweet", slug: "empanadas-sweet" })).toBe("empanadas");
expect(baseFlavorGroupKey({ id: "base:onigiri", slug: "onigiri" })).toBe("base:onigiri");
});
it("liste les ids de bases dun même groupe saveur", () => {
const empty = { fr: "", en: "", ko: "", zh: "" };
const mk = (id: string, slug: string, flavor: "savory" | "sweet"): BaseOption => ({
id,
slug,
flavor,
name: empty,
history: empty,
ingredients: [],
steps: []
});
const bases = [
mk("base:empanadas-savory", "empanadas-savory", "savory"),
mk("base:empanadas-sweet", "empanadas-sweet", "sweet")
];
expect([...baseIdsInSameFlavorGroup(bases, "base:empanadas-savory")].sort()).toEqual([
"base:empanadas-savory",
"base:empanadas-sweet"
]);
});
it("choisit la base catalogue pour une variante à plusieurs baseIds", () => {
const v = { baseIds: ["base:a", "base:b"] };
expect(primaryBaseIdForVariant(v, "base:b")).toBe("base:b");
expect(primaryBaseIdForVariant(v, null)).toBe("base:a");
});
it("sans variante, utilise uniquement la base", () => {
const merged = computeRecipeSteps({
base: {
id: "base",
flavor: "savory",
name: { fr: "", en: "", ko: "", zh: "" },
history: { fr: "", en: "", ko: "", zh: "" },
servings: 6,
ingredients: [],
steps: [
{ id: "b", priority: 20, phase: "prep", dependsOn: [], text: { fr: "", en: "", ko: "", zh: "" } },
{ id: "a", priority: 10, phase: "prep", dependsOn: [], text: { fr: "", en: "", ko: "", zh: "" } }
]
},
variant: null
});
expect(merged.servings).toBe(6);
expect(merged.steps.map((step) => step.id)).toEqual(["a", "b"]);
});
});
+7
View File
@@ -0,0 +1,7 @@
{
"extends": "../../../tsconfig.base.json",
"compilerOptions": {
"composite": true
},
"include": ["src"]
}