evOo-local/packages/core/src/cards.test.ts
2026-10-05 01:13:52 +02:00

239 lines
8.2 KiB
TypeScript

import type { CardBlock } from "@rakazo/contracts";
import { describe, expect, it } from "vitest";
import {
applyCardAct,
buildCardFromTool,
cardHistoryText,
cardScale,
checklistItemDisplay,
formatCardAmount,
} from "./cards.js";
function card(args: Parameters<typeof buildCardFromTool>[0]): CardBlock {
const built = buildCardFromTool(args, "c1");
if (!("ok" in built)) throw new Error(built.error);
return built.block;
}
describe("buildCardFromTool", () => {
it("builds sections in a fixed order with stable ids", () => {
const block = card({
actions: ["Send"],
options: ["Pasta", "Curry"],
checklist: ["Milk", "Eggs"],
facts: [{ label: "Time", value: "30 min" }],
title: "Dinner",
meta: ["4 servings"],
});
expect(block.sections?.map((part) => part.type)).toEqual([
"header",
"facts",
"checklist",
"select",
"actions",
]);
expect(block.sections?.[2]).toMatchObject({ items: [{ id: "i1" }, { id: "i2" }] });
expect(block.sections?.[3]).toMatchObject({ options: [{ id: "o1" }, { id: "o2" }] });
});
it("rejects cards that say nothing or cannot be answered", () => {
expect(buildCardFromTool({ title: "Only a title" }, "c1")).toHaveProperty("error");
expect(buildCardFromTool({ title: "", checklist: ["a"] }, "c1")).toHaveProperty("error");
expect(buildCardFromTool({ title: "One", options: ["a"] }, "c1")).toHaveProperty("error");
expect(
buildCardFromTool({ title: "Many", options: ["a", "b"], multiple: true }, "c1"),
).toHaveProperty("error");
expect(buildCardFromTool({ title: "Long", checklist: ["x".repeat(81)] }, "c1")).toHaveProperty(
"error",
);
});
});
describe("applyCardAct", () => {
it("sets checklist state idempotently, even after the card was answered", () => {
const block = { ...card({ title: "List", checklist: ["Milk"] }), answer: { text: "Done" } };
const first = applyCardAct(block, {
type: "toggle",
sectionId: "checklist",
itemId: "i1",
checked: true,
});
expect(first).toMatchObject({ ok: true, changed: true });
if (!first.ok) return;
const again = applyCardAct(first.block, {
type: "toggle",
sectionId: "checklist",
itemId: "i1",
checked: true,
});
expect(again).toMatchObject({ ok: true, changed: false });
expect(cardHistoryText(first.block)).toContain("checklist 1/1 done");
});
it("answers a single choice with the option's label, once", () => {
const block = card({ title: "Dinner", options: ["Pasta", "Curry"] });
const picked = applyCardAct(block, { type: "select", sectionId: "select", optionId: "o2" });
expect(picked).toMatchObject({
ok: true,
reply: "Curry",
block: { answer: { text: "Curry" } },
});
if (!picked.ok) return;
expect(
applyCardAct(picked.block, { type: "select", sectionId: "select", optionId: "o1" }),
).toMatchObject({ ok: false });
});
it("collects a multiple choice in card order and sends it with an action", () => {
let block = card({
title: "Toppings",
options: ["A", "B", "C"],
multiple: true,
actions: ["Send"],
});
expect(applyCardAct(block, { type: "action", actionId: "a1" })).toMatchObject({ ok: false });
for (const optionId of ["o3", "o1"]) {
const next = applyCardAct(block, {
type: "select",
sectionId: "select",
optionId,
selected: true,
});
if (!next.ok) throw new Error(next.error);
block = next.block;
}
const sent = applyCardAct(block, { type: "action", actionId: "a1" });
expect(sent).toMatchObject({ ok: true, reply: "Send: A, C" });
expect(cardHistoryText(sent.ok ? sent.block : block)).toContain("answered: Send: A, C");
});
it("reads legacy cards as their lines", () => {
expect(cardHistoryText({ kind: "card", lines: [{ k: "Mail", v: "sent" }] })).toBe("Mail: sent");
});
});
describe("recipe cards", () => {
const recipe = () =>
card({
title: "Pancakes",
stepper: { label: "Servings", value: 2 },
checklist: [
{ label: "Flour", amount: 200, unit: "g" },
{ label: "Egg", plural: "Eggs", amount: 1 },
"Salt",
],
steps: ["Mix everything.", { text: "Rest the batter.", timer_minutes: 10 }],
start_label: "Cook",
actions: ["Every week"],
});
it("lays a recipe out as header, servings, ingredients, steps and actions", () => {
const block = recipe();
expect(block.sections?.map((part) => part.type)).toEqual([
"header",
"stepper",
"checklist",
"steps",
"actions",
]);
expect(block.sections?.[3]).toMatchObject({ steps: [{ id: "s1" }, { timerSeconds: 600 }] });
expect(block.sections?.[4]).toMatchObject({
actions: [
{ id: "start", kind: "steps" },
{ id: "a1", kind: "reply" },
],
});
});
it("scales 200 g to 300 g and one egg to 1½ eggs for three servings", () => {
const scaled = applyCardAct(recipe(), { type: "stepper", sectionId: "stepper", value: 3 });
if (!scaled.ok) throw new Error(scaled.error);
const factor = cardScale(scaled.block);
const list = scaled.block.sections?.[2];
if (list?.type !== "checklist") throw new Error("no checklist");
expect(list.items.map((item) => checklistItemDisplay(item, factor))).toEqual([
{ amount: "300 g", label: "Flour" },
{ amount: "1½", label: "Eggs" },
{ label: "Salt" },
]);
expect(cardHistoryText(scaled.block)).toContain("Servings: 3");
expect(cardHistoryText(scaled.block)).toContain("open: 300 g Flour, 1½ Eggs, Salt");
});
it("writes amounts the way a cook would", () => {
expect(formatCardAmount(1)).toBe("1");
expect(formatCardAmount(0.5)).toBe("½");
expect(formatCardAmount(2.34)).toBe("2⅓");
expect(formatCardAmount(2.9)).toBe("3");
expect(formatCardAmount(12.6)).toBe("13");
expect(formatCardAmount(299.99)).toBe("300");
expect(formatCardAmount(0.05)).toBe("0.1");
expect(
checklistItemDisplay({ label: "Egg", quantity: { amount: 1, plural: "Eggs" } }, 1),
).toEqual({
amount: "1",
label: "Egg",
});
});
it("remembers the step and a timer's end, idempotently", () => {
const now = new Date("2026-10-04T12:00:00Z");
const moved = applyCardAct(recipe(), { type: "step", sectionId: "steps", index: 1 });
if (!moved.ok) throw new Error(moved.error);
const started = applyCardAct(
moved.block,
{ type: "timer", sectionId: "steps", stepId: "s2", running: true },
now,
);
if (!started.ok) throw new Error(started.error);
expect(started.block.sections?.[3]).toMatchObject({
current: 1,
steps: [{}, { timerEndsAt: "2026-10-04T12:10:00.000Z" }],
});
const again = applyCardAct(
started.block,
{ type: "timer", sectionId: "steps", stepId: "s2", running: true },
new Date("2026-10-04T12:05:00Z"),
);
expect(again).toMatchObject({ ok: true, changed: false });
const stopped = applyCardAct(
started.block,
{
type: "timer",
sectionId: "steps",
stepId: "s2",
running: false,
},
new Date("2026-10-04T12:05:00Z"),
);
const stoppedSteps = stopped.ok ? stopped.block.sections?.[3] : undefined;
expect(stoppedSteps?.type === "steps" && stoppedSteps.steps[1]?.timerEndsAt).toBeFalsy();
expect(stopped).toMatchObject({ ok: true, changed: true });
expect(
applyCardAct(moved.block, { type: "timer", sectionId: "steps", stepId: "s1", running: true }),
).toMatchObject({ ok: false });
expect(applyCardAct(recipe(), { type: "action", actionId: "start" })).toMatchObject({
ok: false,
});
});
it("refuses more than five parts and a start without steps", () => {
expect(
buildCardFromTool(
{
title: "Too much",
facts: [{ label: "a", value: "b" }],
stepper: { label: "Servings", value: 2 },
checklist: ["x"],
steps: ["y"],
actions: ["z"],
},
"c1",
),
).toMatchObject({ error: expect.stringContaining("at most 5") });
expect(
buildCardFromTool({ title: "No steps", checklist: ["x"], start_label: "Cook" }, "c1"),
).toHaveProperty("error");
});
});