Files
penguin-harness/packages/web/test/avatar.test.ts
T
Yaowei Zheng 6d25adfef4 feat(web): letter avatars for custom model groups and agents (#31)
Co-authored-by: Alice <alice@prismshadow.com>
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-07-22 22:04:34 +08:00

144 lines
5.2 KiB
TypeScript

/**
* Letter-avatar helper unit tests: hue/tile determinism and spread across
* names; initial extraction — grapheme based (CJK / ZWJ emoji / flags stay
* whole), uppercasing, and the empty/whitespace → fallback → "?" chain; and
* an exhaustive WCAG check that both theme inks keep ≥ 4.5:1 contrast against
* the tinted tile over every app surface, for all 360 hues.
*/
import { describe, expect, it } from "vitest";
import { avatarHue, avatarInitial, avatarTile } from "../src/lib/avatar";
describe("avatarHue / avatarTile", () => {
it("is deterministic for the same key", () => {
expect(avatarHue("my-provider")).toBe(avatarHue("my-provider"));
expect(avatarTile("agent-1")).toEqual(avatarTile("agent-1"));
});
it("stays inside the 0-359 hue range", () => {
for (const key of ["", "a", "my-provider", "深度求索", "🐧"]) {
const hue = avatarHue(key);
expect(Number.isInteger(hue)).toBe(true);
expect(hue).toBeGreaterThanOrEqual(0);
expect(hue).toBeLessThan(360);
}
});
it("spreads distinct hues across realistic group names", () => {
const names = ["my-llm", "local-vllm", "team-proxy", "backup"];
expect(new Set(names.map(avatarHue)).size).toBe(names.length);
});
it("formats the tint background and the two theme inks from the key's hue", () => {
const h = avatarHue("x");
expect(avatarTile("x")).toEqual({
bg: `hsl(${h} 55% 50% / 0.14)`,
fg: `hsl(${h} 55% 28%)`,
fgDark: `hsl(${h} 55% 73%)`,
});
});
});
describe("avatarInitial", () => {
it("takes the first character, uppercased", () => {
expect(avatarInitial("my-provider")).toBe("M");
expect(avatarInitial("Zeta")).toBe("Z");
});
it("keeps case-less characters (CJK) as-is", () => {
expect(avatarInitial("深度求索")).toBe("深");
});
it("treats a surrogate-pair character as one initial", () => {
expect(avatarInitial("🐧 harness")).toBe("🐧");
});
it("keeps multi-code-point grapheme clusters whole (ZWJ emoji, flags)", () => {
expect(avatarInitial("👩‍💻 dev tools")).toBe("👩‍💻");
expect(avatarInitial("🇨🇦 north")).toBe("🇨🇦");
});
it("trims whitespace before picking the initial", () => {
expect(avatarInitial(" agent")).toBe("A");
});
it("falls back to the fallback's initial, then ?", () => {
expect(avatarInitial("", "agent-1")).toBe("A");
expect(avatarInitial(" ", " x")).toBe("X");
expect(avatarInitial("")).toBe("?");
expect(avatarInitial(" ", " ")).toBe("?");
});
});
// ---- WCAG contrast of the initial ink on the tinted tile (both themes, all hues) ----
function hslToRgb(h: number, s: number, l: number): [number, number, number] {
s /= 100;
l /= 100;
const c = (1 - Math.abs(2 * l - 1)) * s;
const x = c * (1 - Math.abs(((h / 60) % 2) - 1));
const m = l - c / 2;
const [r, g, b] =
h < 60
? [c, x, 0]
: h < 120
? [x, c, 0]
: h < 180
? [0, c, x]
: h < 240
? [0, x, c]
: h < 300
? [x, 0, c]
: [c, 0, x];
return [r + m, g + m, b + m];
}
const lin = (c: number) => (c <= 0.04045 ? c / 12.92 : ((c + 0.055) / 1.055) ** 2.4);
const luminance = ([r, g, b]: [number, number, number]) =>
0.2126 * lin(r) + 0.7152 * lin(g) + 0.0722 * lin(b);
function contrast(a: [number, number, number], b: [number, number, number]): number {
const la = luminance(a);
const lb = luminance(b);
const [hi, lo] = la > lb ? [la, lb] : [lb, la];
return (hi + 0.05) / (lo + 0.05);
}
/** The 14%-alpha tile tint composited over a surface (sRGB blend, like the browser). */
function tileOver(h: number, surface: [number, number, number]): [number, number, number] {
const tint = hslToRgb(h, 55, 50);
return [0, 1, 2].map((i) => tint[i]! * 0.14 + surface[i]! * 0.86) as [number, number, number];
}
const hexRgb = (s: string) =>
[1, 3, 5].map((i) => parseInt(s.slice(i, i + 2), 16) / 255) as [number, number, number];
// App surfaces the tile sits on: light = white / gray-50 rows / gray-100 hover;
// dark = the true-neutral overrides in styles.css (gray-900/800/700).
const LIGHT_SURFACES = ["#ffffff", "#f9fafb", "#f3f4f6"].map(hexRgb);
const DARK_SURFACES = ["#0d0d0d", "#1f1f1f", "#303030"].map(hexRgb);
/** Saturation/lightness actually emitted by avatarTile (parsed so this test can't drift from the implementation). */
function parseSL(color: string): [number, number] {
const m = /^hsl\(\d+ (\d+)% (\d+)%\)$/.exec(color);
expect(m, `unexpected ink format: ${color}`).toBeTruthy();
return [Number(m![1]), Number(m![2])];
}
describe("avatarTile contrast (WCAG AA)", () => {
it("keeps ≥ 4.5:1 for every hue: light ink on light surfaces, dark ink on dark surfaces", () => {
const sample = avatarTile("x");
const [fgS, fgL] = parseSL(sample.fg);
const [fgDarkS, fgDarkL] = parseSL(sample.fgDark);
for (let h = 0; h < 360; h++) {
const fg = hslToRgb(h, fgS, fgL);
const fgDark = hslToRgb(h, fgDarkS, fgDarkL);
for (const surface of LIGHT_SURFACES) {
expect(contrast(fg, tileOver(h, surface))).toBeGreaterThanOrEqual(4.5);
}
for (const surface of DARK_SURFACES) {
expect(contrast(fgDark, tileOver(h, surface))).toBeGreaterThanOrEqual(4.5);
}
}
});
});