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>
244 lines
8.0 KiB
TypeScript
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();
|
|
}
|
|
});
|
|
});
|