evOo-local/packages/core/src/oo-rig.test.ts
cynarAI 63578bb645
feat(avatar): dress up any Oo in the Atelier on web and mobile (#143)
The Atelier replaces Avatar Studio: pick a species, colour, eyes,
glasses, hats, neckwear, arms and temperament, with every option drawn
as your own Oo wearing it. Onboarding's "Design your Oo" now offers
species looks per vibe and opens the Atelier for the rest; mobile bot
settings carry the same editor instead of a bare colour row, so picking
a colour there no longer throws away the rest of the look.


Claude-Session: https://claude.ai/code/session_01EKuME5CgL1ddGGN7LGvtBj

Co-authored-by: Claude <noreply@anthropic.com>
Co-authored-by: Cursor Agent <cursoragent@cursor.com>
Co-authored-by: cynarAI <cynarAI@users.noreply.github.com>
2026-10-05 03:20:05 +02:00

244 lines
8.0 KiB
TypeScript

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<OoAvatar>, 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();
}
});
});