evOo-local/packages/core/src/oo-avatar.test.ts
cynarAI c69f00e891
feat(avatars): bots offer a new Oo look from chat (#145)
update_bot takes a look (oo1: value), validated and normalized in core.
A suggested look renders as a "New look" card with the avatar preview on
web and mobile; accepting applies it to the bot.


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

Co-authored-by: Claude <noreply@anthropic.com>
2026-10-05 04:03:10 +02:00

135 lines
4.4 KiB
TypeScript

import { describe, expect, it } from "vitest";
import {
DEFAULT_OO_AVATAR,
decodeOoAvatar,
encodeOoAvatar,
isValidAvatarValue,
normalizeOoLook,
OO_AVATAR_MAX_LENGTH,
OO_EYE_ASYMMETRY_MIN_RATIO,
OO_RIG_EYES,
type OoAvatar,
ooAvatarColor,
ooLensRadius,
ooLookFormat,
} from "./oo-avatar.js";
const styled: OoAvatar = {
species: "cat",
color: "#FF781C",
shape: null,
arms: "paw",
lashes: "full",
liner: "wing",
eyeshadow: "rose",
iris: "green",
sparkle: "heart",
brows: "fine",
cheeks: "freckles",
eyewear: "round",
head: "chef",
neck: "bell",
energy: 1.3,
playful: 0.8,
};
describe("oo avatar value", () => {
it("round-trips a fully styled look", () => {
const value = encodeOoAvatar(styled);
expect(value).toBe(
"oo1:c=FF781C;sp=cat;ar=paw;la=full;li=wing;es=rose;ir=green;sk=heart;br=fine;ck=freckles;ew=round;hd=chef;nk=bell;en=1.30;pl=0.80",
);
expect(decodeOoAvatar(value)).toEqual(styled);
});
it("writes only the colour for the default look", () => {
expect(encodeOoAvatar({ ...DEFAULT_OO_AVATAR, color: "#a97efe" })).toBe("oo1:c=A97EFE");
expect(encodeOoAvatar({ ...DEFAULT_OO_AVATAR, shape: 3 })).toBe("oo1:c=8B5CF6;sh=3");
});
it("clamps temperament and falls back to the default colour when encoding", () => {
const value = encodeOoAvatar({ ...DEFAULT_OO_AVATAR, color: "violet", energy: 9, playful: -1 });
expect(value).toBe("oo1:c=8B5CF6;en=1.60;pl=0.00");
expect(decodeOoAvatar(value)).toMatchObject({ energy: 1.6, playful: 0 });
});
it("reads legacy colour and shape values unchanged", () => {
expect(decodeOoAvatar("#8b5cf6::shape_3")).toEqual({
...DEFAULT_OO_AVATAR,
color: "#8B5CF6",
shape: 3,
});
expect(decodeOoAvatar("#10B981::shape_11")?.shape).toBe(3);
expect(decodeOoAvatar("#10B981::shape_x")?.shape).toBe(0);
expect(decodeOoAvatar("#fff")).toMatchObject({ color: "#FFFFFF", shape: null });
expect(decodeOoAvatar("violet")).toMatchObject({ color: "violet", shape: null });
});
it("treats uploaded pictures as no look", () => {
expect(decodeOoAvatar("data:image/webp;base64,AAAA")).toBeNull();
expect(ooAvatarColor("data:image/webp;base64,AAAA")).toBe("");
});
it("rejects malformed oo1 values", () => {
for (const value of [
"oo1:",
"oo1:sp=cat",
"oo1:c=XYZXYZ",
"oo1:c=FF781C;sp=dragon",
"oo1:c=FF781C;sh=8",
"oo1:c=FF781C;en=3",
"oo1:c=FF781C;pl=1.5",
"oo1:c=FF781C;broken",
`oo1:c=FF781C;${"x=1;".repeat(OO_AVATAR_MAX_LENGTH)}`,
]) {
expect(decodeOoAvatar(value), value).toBeNull();
expect(isValidAvatarValue(value), value).toBe(false);
}
});
it("ignores keys from newer clients", () => {
expect(decodeOoAvatar("oo1:c=FF781C;sp=dog;zz=new")).toMatchObject({
species: "dog",
color: "#FF781C",
});
});
it("accepts every legacy value for persistence", () => {
expect(isValidAvatarValue("#8B5CF6::shape_0")).toBe(true);
expect(isValidAvatarValue("data:image/png;base64,AAAA")).toBe(true);
});
it("exposes a plain colour for CSS consumers", () => {
expect(ooAvatarColor("oo1:c=FF781C;sp=cat")).toBe("#FF781C");
expect(ooAvatarColor("#8B5CF6::shape_2")).toBe("#8B5CF6");
});
it("takes a model-written look only as a valid oo1 value", () => {
expect(normalizeOoLook(" oo1:c=#f4a7b9;sp=cat;ew=round ")).toBe("oo1:c=F4A7B9;sp=cat;ew=round");
expect(normalizeOoLook("oo1:sp=cat")).toBeNull();
expect(normalizeOoLook("oo1:c=F4A7B9;sp=dragon")).toBeNull();
expect(normalizeOoLook("#8B5CF6")).toBeNull();
expect(normalizeOoLook(42)).toBeNull();
});
it("documents the format with the example it gives", () => {
const example = /e\.g\. (oo1:\S+)\./.exec(ooLookFormat())?.[1];
expect(example && normalizeOoLook(example)).toBe(example);
expect(ooLookFormat()).toContain("penguin");
});
});
describe("oo rig brand lock", () => {
it("keeps the big eye φ times the small eye", () => {
const ratio = OO_RIG_EYES.big.r / OO_RIG_EYES.small.r;
expect(ratio).toBeGreaterThanOrEqual(OO_EYE_ASYMMETRY_MIN_RATIO);
expect(ratio).toBeCloseTo(1.618, 3);
});
it("keeps glasses as asymmetric as the eyes", () => {
const ratio = ooLensRadius("big") / ooLensRadius("small");
expect(ratio).toBeGreaterThanOrEqual(OO_EYE_ASYMMETRY_MIN_RATIO);
expect(ooLensRadius("big")).toBeGreaterThan(OO_RIG_EYES.big.r);
});
});