import { describe, expect, it } from "vitest"; import type { OoAvatar } from "./oo-avatar.js"; import { DEFAULT_OO_AVATAR, decodeOoAvatar, encodeOoAvatar, OO_EYE_ASYMMETRY_MIN_RATIO, OO_EYEWEAR, OO_RIG_EYES, OO_SPECIES, } from "./oo-avatar.js"; import { createOoLife, ooFrame, ooMomentAfterStatus, ooMomentForStatus, ooSeededRandom, } from "./oo-motion.js"; import { OO_DEFAULT_COLOR, OO_PALETTE_HEXES, ooIdentityShapeIndex, ooLookForEditing, ooRandomAvatar, resolveOoClassicShape, resolveOoColor, } from "./oo-palette.js"; import type { OoGroup, OoLod, OoMoment, OoShape } from "./oo-rig.js"; import { buildOoRig, OO_MOMENTS, ooLodForSize } from "./oo-rig.js"; function rigFor(avatar: Partial, moment: OoMoment = "idle", lod: OoLod = "full") { return buildOoRig({ avatar: { ...DEFAULT_OO_AVATAR, ...avatar }, color: "#8B5CF6", fallbackShape: 0, moment, lod, }); } function findPart(shapes: OoShape[], part: string): OoGroup | undefined { for (const shape of shapes) { if (shape.type !== "group") continue; if (shape.part === part) return shape; const nested = findPart(shape.children, part); if (nested) return nested; } return undefined; } function allParts(shapes: OoShape[], out: string[] = []): string[] { for (const shape of shapes) { if (shape.type !== "group") continue; if (shape.part) out.push(shape.part); allParts(shape.children, out); } return out; } function radiusOf(group: OoGroup | undefined): number { const white = group?.children.find( (shape) => shape.type === "circle" && shape.fill === "#FFFFFF", ); return white?.type === "circle" ? white.r : 0; } describe("oo rig", () => { it.each(OO_SPECIES)("keeps the Oo eyes on the %s", (species) => { const rig = rigFor({ species }); const big = radiusOf(findPart(rig.shapes, "eye-big")); const small = radiusOf(findPart(rig.shapes, "eye-small")); expect(big).toBe(OO_RIG_EYES.big.r); expect(big / small).toBeGreaterThanOrEqual(OO_EYE_ASYMMETRY_MIN_RATIO); }); it.each(OO_EYEWEAR.filter((kind) => kind !== "none"))("draws %s eyewear", (eyewear) => { const plain = rigFor({}).shapes; const styled = rigFor({ eyewear }).shapes; expect(JSON.stringify(styled).length).toBeGreaterThan(JSON.stringify(plain).length); }); it("drops arms, props and effects at small sizes", () => { expect(ooLodForSize(24)).toBe("xs"); expect(ooLodForSize(40)).toBe("mid"); expect(ooLodForSize(96)).toBe("full"); const xs = allParts(rigFor({}, "thinking", "xs").shapes); expect(xs).not.toContain("arm-left"); expect(xs).not.toContain("fx"); const mid = allParts(rigFor({}, "thinking", "mid").shapes); expect(mid).toContain("arm-left"); expect(mid).not.toContain("fx"); expect(allParts(rigFor({}, "thinking").shapes)).toContain("fx"); }); it("puts the chef hat on for cooking unless a hat is worn", () => { const cooking = JSON.stringify(rigFor({}, "cooking").shapes); const crowned = JSON.stringify(rigFor({ head: "crown" }, "cooking").shapes); expect(cooking).toContain("#D9D3DE"); expect(crowned).not.toContain("#D9D3DE"); }); it("gives ears and tails a pivot", () => { const cat = rigFor({ species: "cat" }); expect(findPart(cat.shapes, "tail")?.pivot).toBeDefined(); expect(findPart(cat.shapes, "ear-left")?.pivot).toBeDefined(); }); }); describe("oo motion", () => { it.each(OO_MOMENTS)("draws a deterministic %s frame", (moment) => { const rig = rigFor({ species: "dog", playful: 0.9 }, moment); const run = () => { const life = createOoLife(ooSeededRandom(7)); const avatar = { ...DEFAULT_OO_AVATAR, species: "dog" as const, playful: 0.9 }; const frames = [0.5, 3, 6.2].map((t) => ooFrame({ rig, avatar, moment, t, life })); return frames.map((frame) => ({ parts: frame.parts, mouth: frame.mouth, fx: frame.fx("spark", 1), })); }; expect(run()).toEqual(run()); }); it("closes the eyes while asleep and smiles them shut for success", () => { const life = createOoLife(ooSeededRandom(1)); const asleep = ooFrame({ rig: rigFor({}, "asleep"), avatar: DEFAULT_OO_AVATAR, moment: "asleep", t: 1, life, still: true, }); expect(asleep.parts["eye-big"]?.hidden).toBe(true); expect(asleep.parts["closed-big"]?.hidden).toBe(false); const success = ooFrame({ rig: rigFor({}, "success"), avatar: DEFAULT_OO_AVATAR, moment: "success", t: 1, life, still: true, }); expect(success.parts["happy-small"]?.hidden).toBe(false); }); it("blinks on its own but never in still frames", () => { const rig = rigFor({}); const life = createOoLife(ooSeededRandom(3)); let blinked = false; for (let t = 0; t < 12; t += 1 / 30) { const frame = ooFrame({ rig, avatar: DEFAULT_OO_AVATAR, moment: "idle", t, life }); if (frame.parts["closed-big"]?.hidden === false) blinked = true; } expect(blinked).toBe(true); const still = createOoLife(ooSeededRandom(3)); for (let t = 0; t < 12; t += 1 / 30) { const frame = ooFrame({ rig, avatar: DEFAULT_OO_AVATAR, moment: "idle", t, life: still, still: true, }); expect(frame.parts["closed-big"]?.hidden).toBe(true); } }); it("uses the cat mouth for a cat's smile", () => { const life = createOoLife(ooSeededRandom(1)); const avatar = { ...DEFAULT_OO_AVATAR, species: "cat" as const }; const frame = ooFrame({ rig: rigFor({ species: "cat" }), avatar, moment: "idle", t: 0.6, life, still: true, }); expect(frame.mouth.d).toBe("M93 138Q98 144 103 138Q108 144 113 138"); }); it("maps run status to moments", () => { expect(ooMomentForStatus("running")).toBe("working"); expect(ooMomentForStatus("waiting_input")).toBe("waiting"); expect(ooMomentForStatus("failed")).toBe("concerned"); expect(ooMomentForStatus("idle", true)).toBe("speaking"); expect(ooMomentForStatus(undefined)).toBe("idle"); }); }); describe("oo palette", () => { it("exposes the violet identity color as the shared default", () => { expect(OO_PALETTE_HEXES).toContain(OO_DEFAULT_COLOR); }); it("resolves explicit colors and shapes", () => { expect(resolveOoColor("bot", "#10B981").hex.toLowerCase()).toBe("#10b981"); expect(resolveOoColor("bot", "#fff").hex).toBe("#fff"); expect(resolveOoClassicShape("bot", "hex")).toContain("M"); }); it("falls back to the identity palette for invalid custom hex", () => { expect(resolveOoColor("bot", "#zzzzzz")).toEqual(resolveOoColor("bot")); expect(resolveOoColor("bot", "#ggg")).toEqual(resolveOoColor("bot")); }); it("gives each identity a stable classic shape", () => { expect(ooIdentityShapeIndex("maya")).toBe(ooIdentityShapeIndex("maya")); expect(ooIdentityShapeIndex("maya")).toBeLessThan(8); }); }); describe("oo moments after a status change", () => { it("celebrates a finished run only", () => { expect(ooMomentAfterStatus("running", "completed")).toBe("success"); expect(ooMomentAfterStatus("running", "idle")).toBe("success"); expect(ooMomentAfterStatus("running", "failed")).toBeNull(); expect(ooMomentAfterStatus("waiting_input", "idle")).toBeNull(); expect(ooMomentAfterStatus(undefined, "idle")).toBeNull(); }); }); describe("oo atelier helpers", () => { it("edits legacy values with a hex colour", () => { expect(ooLookForEditing("violet", "bot").color).toBe(resolveOoColor("bot", "violet").hex); expect(ooLookForEditing("#8B5CF6::shape_2", "bot")).toMatchObject({ color: "#8B5CF6", shape: 2, }); expect(ooLookForEditing("data:image/png;base64,AAAA", "bot").species).toBe("classic"); }); it("surprises with looks that always encode", () => { const random = ooSeededRandom(5); for (let i = 0; i < 50; i++) { const look = ooRandomAvatar(random); expect(decodeOoAvatar(encodeOoAvatar(look))).not.toBeNull(); } }); });