Claude-Session: https://claude.ai/code/session_01UmSCnoCgXqBp2fU3vwz4o1 Co-authored-by: Claude <noreply@anthropic.com>
239 lines
8.2 KiB
TypeScript
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");
|
|
});
|
|
});
|