8971912d9e
The old avatar-palette test only did expect(["white","black"]).toContain( entry.text), which can never fail (text is typed "white"|"black" and always assigned) — so the load-bearing property "all 20 colors are readable" was only really checked for the single golden name. A generator bug producing a low-contrast or out-of-gamut slot would survive the suite. Export the four existing color-math helpers (oklchToSrgb, isInGamut, relativeLuminance, contrastRatio — no logic change) and assert, for EVERY PALETTE entry: - (a) real contrast of the chosen text on the entry hex >= 3 (the code's threshold), scale-matched (hex 0..255 → /255 before relativeLuminance). Since buildPalette PREFERS white and only falls back to black when white fails 3:1, the test also asserts: if text=="black" then white's contrast is < 3 (black was mandatory) — matching the code's actual decision, not a max-contrast pick. - (b) the OKLCH is in sRGB gamut post-clamp: isInGamut(oklchToSrgb(L,C,h)). Demonstrated non-vacuous: a light bg mislabeled text:"white" → chosen contrast 1.67 (< 3) fails; an out-of-gamut component fails isInGamut. Golden-name and minPairwiseDistance tests untouched. vitest: 15 passed. No palette/hash/consumer logic changed. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
108 lines
4.3 KiB
TypeScript
108 lines
4.3 KiB
TypeScript
import { describe, it, expect } from "vitest";
|
|
import {
|
|
PALETTE,
|
|
avatarStyle,
|
|
avatarBackgroundCss,
|
|
normalizeName,
|
|
minPairwiseDistance,
|
|
relativeLuminance,
|
|
contrastRatio,
|
|
oklchToSrgb,
|
|
isInGamut,
|
|
} from "./avatar-palette";
|
|
|
|
/** Parse "#rrggbb" into sRGB components on the 0..1 scale relativeLuminance expects. */
|
|
function hexToRgb01(hex: string): [number, number, number] {
|
|
return [
|
|
parseInt(hex.slice(1, 3), 16) / 255,
|
|
parseInt(hex.slice(3, 5), 16) / 255,
|
|
parseInt(hex.slice(5, 7), 16) / 255,
|
|
];
|
|
}
|
|
|
|
describe("avatar-palette validation", () => {
|
|
it("palette colors stay distinguishable", () => {
|
|
// 0.06 in OKLab is ~4-5 JNDs — safely distinct at avatar size. If a future
|
|
// RINGS tweak drops this, "almost identical" colors would reappear.
|
|
expect(minPairwiseDistance().distance).toBeGreaterThanOrEqual(0.06);
|
|
expect(PALETTE.length).toBe(20);
|
|
});
|
|
|
|
it("every palette entry is WCAG-readable and in sRGB gamut", () => {
|
|
// white text = luminance 1, black text = luminance 0 (per buildPalette).
|
|
const textLum = { white: 1, black: 0 } as const;
|
|
for (const entry of PALETTE) {
|
|
expect(entry.hex).toMatch(/^#[0-9a-f]{6}$/);
|
|
|
|
// (a) The chosen text color really clears the code's 3:1 threshold on the
|
|
// actual background hex — recomputed independently from the hex, not from
|
|
// the build-time luminance. A slot that picked the wrong text (or a color
|
|
// too dim for either text) would fail here.
|
|
const hexLum = relativeLuminance(hexToRgb01(entry.hex));
|
|
const chosen = contrastRatio(textLum[entry.text], hexLum);
|
|
expect(chosen).toBeGreaterThanOrEqual(3);
|
|
// buildPalette prefers white and only falls back to black when white
|
|
// fails 3:1. Mirror that decision: black is used *only* when white would
|
|
// not clear the threshold — so a mis-assigned "black" on a dark color
|
|
// (where white was fine) fails here.
|
|
if (entry.text === "black") {
|
|
expect(contrastRatio(textLum.white, hexLum)).toBeLessThan(3);
|
|
}
|
|
|
|
// (b) The entry's OKLCH is inside the sRGB gamut after chroma clamping;
|
|
// an out-of-gamut slot (e.g. un-clamped chroma) would produce components
|
|
// outside [0,1] and fail here.
|
|
expect(isInGamut(oklchToSrgb(entry.L, entry.C, entry.h))).toBe(true);
|
|
}
|
|
});
|
|
});
|
|
|
|
describe("avatarStyle", () => {
|
|
it("name-to-avatar mapping is frozen (golden values)", () => {
|
|
// Golden slice: if this breaks, all existing avatars change — make sure
|
|
// that is intentional (a config change in avatar-palette.ts).
|
|
const s = avatarStyle("Backend Developer");
|
|
expect([s.bg, s.bg2, s.angleDeg]).toEqual(["#a55795", "#90355e", 150]);
|
|
expect(s.text).toBe("white");
|
|
});
|
|
|
|
it("is deterministic and normalizes the name", () => {
|
|
expect(avatarStyle("Researcher")).toEqual(avatarStyle("Researcher"));
|
|
// Casing, surrounding and repeated whitespace must not change the avatar.
|
|
expect(avatarStyle(" RESEARCHER ")).toEqual(avatarStyle("researcher"));
|
|
expect(avatarStyle("Backend Developer")).toEqual(
|
|
avatarStyle("backend developer"),
|
|
);
|
|
expect(normalizeName(" PM ")).toBe("pm");
|
|
});
|
|
|
|
it("returns a valid base color, angle and matching text", () => {
|
|
const s = avatarStyle("Нарратор");
|
|
const idx = PALETTE.findIndex((e) => e.hex === s.bg);
|
|
expect(idx).toBe(s.paletteIndex);
|
|
expect(idx).toBeGreaterThanOrEqual(0); // bg is a palette entry
|
|
// Text color comes from the chosen palette entry.
|
|
expect(s.text).toBe(PALETTE[idx].text);
|
|
// Split angle is one of the SPLIT_ANGLE_STEPS (24) directions → multiples of 15.
|
|
expect(s.angleDeg % 15).toBe(0);
|
|
expect(s.angleDeg).toBeGreaterThanOrEqual(0);
|
|
expect(s.angleDeg).toBeLessThan(360);
|
|
});
|
|
|
|
it("distinguishes the agents that used to collide as violet", () => {
|
|
// "Структурный редактор" and "Фактчекер" looked identically violet before.
|
|
expect(avatarStyle("Структурный редактор")).not.toEqual(
|
|
avatarStyle("Фактчекер"),
|
|
);
|
|
});
|
|
});
|
|
|
|
describe("avatarBackgroundCss", () => {
|
|
it("renders a two-stop gradient with a soft boundary", () => {
|
|
const s = avatarStyle("Backend Developer");
|
|
expect(avatarBackgroundCss(s)).toBe(
|
|
"linear-gradient(150deg, #a55795 42%, #90355e 58%)",
|
|
);
|
|
});
|
|
});
|