first commit
This commit is contained in:
@@ -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");
|
||||
});
|
||||
});
|
||||
@@ -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 l’id 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("n’utilise 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 d’un 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"]);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,7 @@
|
||||
{
|
||||
"extends": "../../../tsconfig.base.json",
|
||||
"compilerOptions": {
|
||||
"composite": true
|
||||
},
|
||||
"include": ["src"]
|
||||
}
|
||||
Reference in New Issue
Block a user