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[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"); }); });