Bots now render as an Oo rig built once in core: nine species, styled Oo eyes, eyewear, headwear, neckwear, arms and moment props, with a level of detail per size. A shared animation frame loop moves only visible avatars and holds still under reduced motion. Claude-Session: https://claude.ai/code/session_01EKuME5CgL1ddGGN7LGvtBj Co-authored-by: Claude <noreply@anthropic.com>
119 lines
4.7 KiB
TypeScript
119 lines
4.7 KiB
TypeScript
import { ACTIVE_RUN_STATUSES, OO_DEFAULT_COLOR } from "@rakazo/core";
|
|
import { renderToString } from "react-dom/server";
|
|
import { describe, expect, it } from "vitest";
|
|
import { BotAvatar, OoShapePreview, ooMomentForPose, parseBotAvatar } from "./bot-avatar.js";
|
|
|
|
describe("BotAvatar", () => {
|
|
it("renders distinct shape gradient IDs for concurrent working avatars", () => {
|
|
const html = renderToString(
|
|
<div>
|
|
<BotAvatar color="#8B5CF6" status="running" />
|
|
<BotAvatar color="#10B981" status="running" />
|
|
</div>,
|
|
);
|
|
|
|
const bodyGradIds = [...html.matchAll(/id="(oo[^"]+-body)"/g)].map((m) => m[1]);
|
|
expect(bodyGradIds).toHaveLength(2);
|
|
expect(new Set(bodyGradIds).size).toBe(2);
|
|
for (const id of bodyGradIds) {
|
|
expect(html).toContain(`url(#${id})`);
|
|
}
|
|
});
|
|
|
|
it.each([...ACTIVE_RUN_STATUSES])("marks active run status %s as working", (status) => {
|
|
const html = renderToString(<BotAvatar color="#3B82F6" status={status} />);
|
|
expect(html).toContain("<svg");
|
|
expect(html).toContain('data-working="true"');
|
|
});
|
|
|
|
it("keeps working attribute false when idle", () => {
|
|
const html = renderToString(<BotAvatar color="#F59E0B" status="idle" />);
|
|
expect(html).toContain('data-working="false"');
|
|
});
|
|
|
|
it("draws the Oo eyes for plain color values", () => {
|
|
const html = renderToString(
|
|
<BotAvatar color="#D9508A" identity="maya" size={28} status="running" />,
|
|
);
|
|
expect(html).toContain("<svg");
|
|
expect(html).toContain("<path");
|
|
expect(html).toContain('data-part="eye-big"');
|
|
expect(html).toContain('data-part="eye-small"');
|
|
expect(html).toContain('data-oo-moment="working"');
|
|
expect(html).toContain('data-working="true"');
|
|
});
|
|
|
|
it("draws the stored look", () => {
|
|
const html = renderToString(
|
|
<BotAvatar color="oo1:c=FF781C;sp=cat;ew=round;hd=chef" size={72} />,
|
|
);
|
|
expect(html).toContain('data-oo-species="cat"');
|
|
expect(html).toContain('data-part="ear-left"');
|
|
expect(html).toContain("#FFFFFF");
|
|
});
|
|
|
|
it("keeps small avatars free of arms and props", () => {
|
|
const small = renderToString(<BotAvatar color="#8B5CF6" status="running" size={24} />);
|
|
const large = renderToString(<BotAvatar color="#8B5CF6" status="running" size={72} />);
|
|
expect(small).not.toContain('data-part="arm-left"');
|
|
expect(large).toContain('data-part="arm-left"');
|
|
});
|
|
|
|
it("maps mascot poses to moments", () => {
|
|
expect(ooMomentForPose("delighted")).toBe("success");
|
|
expect(ooMomentForPose("typing")).toBe("working");
|
|
expect(ooMomentForPose("error")).toBe("concerned");
|
|
expect(ooMomentForPose("listening")).toBe("listening");
|
|
const html = renderToString(<BotAvatar color="#8B5CF6" pose="thinking" size={72} />);
|
|
expect(html).toContain('data-oo-moment="thinking"');
|
|
});
|
|
|
|
it("renders distinct shapes for distinct bot identities", () => {
|
|
const maya = renderToString(<BotAvatar color="#D9508A" identity="maya" />);
|
|
const github = renderToString(<BotAvatar color="#D9508A" identity="github" />);
|
|
expect(maya).not.toEqual(github);
|
|
});
|
|
|
|
it("parses shape indexes from encoded color values", () => {
|
|
const parsed = parseBotAvatar(`${OO_DEFAULT_COLOR}::shape_3`);
|
|
expect(parsed.color).toBe(OO_DEFAULT_COLOR);
|
|
expect(parsed.shapeIndex).toBe(3);
|
|
expect(parsed.isImage).toBe(false);
|
|
});
|
|
|
|
it("normalizes malformed shape suffixes to shape 0", () => {
|
|
expect(parseBotAvatar(`${OO_DEFAULT_COLOR}::shape_-1`).shapeIndex).toBe(0);
|
|
expect(parseBotAvatar(`${OO_DEFAULT_COLOR}::shape_3junk`).shapeIndex).toBe(0);
|
|
expect(parseBotAvatar(`${OO_DEFAULT_COLOR}::shape_`).shapeIndex).toBe(0);
|
|
});
|
|
|
|
it("renders uploaded images without the geometric svg", () => {
|
|
const html = renderToString(
|
|
<BotAvatar color="data:image/png;base64,abc" identity="maya" size={32} />,
|
|
);
|
|
expect(html).toContain("<img");
|
|
expect(html).not.toContain("<path");
|
|
expect(html).not.toContain("data-part");
|
|
});
|
|
|
|
it("does not treat arbitrary http(s) color values as remote images", () => {
|
|
const parsed = parseBotAvatar("https://evil.example/track.png");
|
|
expect(parsed.isImage).toBe(false);
|
|
expect(parsed.imageUrl).toBeUndefined();
|
|
const html = renderToString(
|
|
<BotAvatar color="https://evil.example/track.png" identity="maya" size={32} />,
|
|
);
|
|
expect(html).not.toContain("<img");
|
|
expect(html).not.toContain("evil.example");
|
|
});
|
|
|
|
it("exposes shape picker name and pressed state", () => {
|
|
const html = renderToString(
|
|
<OoShapePreview shapeIndex={0} color="#8B5CF6" selected onClick={() => undefined} />,
|
|
);
|
|
expect(html).toContain('aria-label="EvOo"');
|
|
expect(html).toContain('aria-pressed="true"');
|
|
expect(html).toContain("focus-visible:ring-2");
|
|
});
|
|
});
|