273 lines
8.6 KiB
TypeScript
273 lines
8.6 KiB
TypeScript
import { describe, expect, it } from "vitest";
|
||
|
||
import {
|
||
colourHarmony,
|
||
buildCssGradient,
|
||
deltaE,
|
||
exportGradientRecipe,
|
||
exportPalette,
|
||
formatColour,
|
||
normaliseTokenName,
|
||
parsePaletteList,
|
||
parseDtcgTokens,
|
||
paletteContrastMatrix,
|
||
shades,
|
||
simulateColourVision,
|
||
simulateColourVisionSet,
|
||
tints,
|
||
tones,
|
||
} from "../../src/colour";
|
||
|
||
describe("comparison and colour-vision tools", () => {
|
||
it("provides standard Delta-E methods", () => {
|
||
expect(deltaE("#f8f5ee", "#f8f5ee", "2000")).toBe(0);
|
||
expect(deltaE("red", "blue", "76")).toBeGreaterThan(100);
|
||
expect(deltaE("#777", "#787878", "ok")).toBeGreaterThan(0);
|
||
});
|
||
|
||
it("simulates four deficiencies while preserving alpha", () => {
|
||
const original = "rgba(200, 40, 20, .4)";
|
||
const unchanged = simulateColourVision(original, "deuteranopia", {
|
||
severity: 0,
|
||
});
|
||
expect(formatColour(unchanged, "hex8")).toBe("#c8281466");
|
||
const simulated = simulateColourVision(original, "deuteranopia");
|
||
expect(simulated.alpha).toBeCloseTo(0.4);
|
||
expect(formatColour(simulated, "hex")).not.toBe("#c82814");
|
||
expect(Object.keys(simulateColourVisionSet(original))).toEqual([
|
||
"protanopia",
|
||
"deuteranopia",
|
||
"tritanopia",
|
||
"achromatopsia",
|
||
]);
|
||
});
|
||
|
||
it("turns achromatopsia output neutral", () => {
|
||
const simulated = simulateColourVision("#ff8000", "achromatopsia");
|
||
expect(simulated.coords[0]).toBeCloseTo(simulated.coords[1]!, 8);
|
||
expect(simulated.coords[1]).toBeCloseTo(simulated.coords[2]!, 8);
|
||
});
|
||
});
|
||
|
||
describe("palette construction and export", () => {
|
||
it("builds useful hue harmonies", () => {
|
||
expect(colourHarmony("oklch(60% .15 20)", "complementary")).toHaveLength(2);
|
||
expect(colourHarmony("oklch(60% .15 20)", "analogous")).toHaveLength(3);
|
||
expect(colourHarmony("oklch(60% .15 20)", "square")).toHaveLength(4);
|
||
});
|
||
|
||
it("builds deterministic tint, shade and tone scales", () => {
|
||
expect(tints("#336699", 3)).toHaveLength(3);
|
||
expect(shades("#336699", 3, { includeBase: true })).toHaveLength(4);
|
||
expect(tones("#336699", 2, { includeEndpoint: true })).toHaveLength(2);
|
||
expect(
|
||
formatColour(tints("black", 1, { includeEndpoint: true })[0]!, "hex"),
|
||
).toBe("#ffffff");
|
||
});
|
||
|
||
it("parses named text, CSS variables, arrays and token JSON", () => {
|
||
const text = parsePaletteList("Primary: #f8f5ee\n--accent: rgb(20 40 60)");
|
||
expect(text.map((entry) => entry.name)).toEqual(["Primary", "accent"]);
|
||
expect(parsePaletteList('["red", "blue"]')).toHaveLength(2);
|
||
const tokens = parsePaletteList(
|
||
'{"brand":{"$type":"color","$value":"#123456"}}',
|
||
);
|
||
expect(formatColour(tokens[0]!.colour, "hex")).toBe("#123456");
|
||
const nested = parsePaletteList(
|
||
'{"brand":{"light":{"$type":"color","$value":"#eee"},"dark":{"$value":"#111"}}}',
|
||
);
|
||
expect(nested.map((entry) => entry.name)).toEqual([
|
||
"brand-light",
|
||
"brand-dark",
|
||
]);
|
||
expect(
|
||
parsePaletteList(":root { --paper: #f8f5ee; --ink: #111; }"),
|
||
).toHaveLength(2);
|
||
});
|
||
|
||
it("normalises and de-duplicates portable token names", () => {
|
||
expect(normaliseTokenName(" Crème brûlée / 100 ")).toBe(
|
||
"creme-brulee-100",
|
||
);
|
||
const entries = parsePaletteList("Brand: red\nBrand: blue");
|
||
expect(exportPalette(entries, "css", { prefix: "App" })).toContain(
|
||
"--app-brand-2: #0000ff;",
|
||
);
|
||
});
|
||
|
||
it("exports CSS, Sass, JSON, DTCG, Tailwind and CSV", () => {
|
||
const entries = parsePaletteList("paper: #f8f5ee\nink: #111");
|
||
expect(exportPalette(entries, "css")).toContain("--colour-paper: #f8f5ee;");
|
||
expect(exportPalette(entries, "scss")).toContain("$colour-ink: #111111;");
|
||
expect(JSON.parse(exportPalette(entries, "json"))).toEqual({
|
||
paper: "#f8f5ee",
|
||
ink: "#111111",
|
||
});
|
||
expect(JSON.parse(exportPalette(entries, "tokens"))).toMatchObject({
|
||
$schema: "https://www.designtokens.org/schemas/2025.10/format.json",
|
||
paper: {
|
||
$type: "color",
|
||
$value: {
|
||
colorSpace: "srgb",
|
||
components: expect.any(Array),
|
||
hex: "#f8f5ee",
|
||
},
|
||
},
|
||
});
|
||
expect(exportPalette(entries, "tailwind")).toMatch(/^export default /);
|
||
expect(exportPalette(entries, "csv")).toBe(
|
||
"name,value\npaper,#f8f5ee\nink,#111111",
|
||
);
|
||
});
|
||
|
||
it("imports DTCG 2025.10 structured colours and local aliases", () => {
|
||
const result = parseDtcgTokens(
|
||
JSON.stringify({
|
||
$schema: "https://www.designtokens.org/schemas/2025.10/format.json",
|
||
base: {
|
||
$type: "color",
|
||
blue: {
|
||
$value: {
|
||
colorSpace: "display-p3",
|
||
components: [0.2, 0.4, 0.8],
|
||
alpha: 0.75,
|
||
hex: "#3366cc",
|
||
},
|
||
},
|
||
},
|
||
semantic: {
|
||
primary: { $type: "color", $value: "{base.blue}" },
|
||
},
|
||
}),
|
||
);
|
||
expect(result.entries.map((entry) => entry.name)).toEqual([
|
||
"base-blue",
|
||
"semantic-primary",
|
||
]);
|
||
expect(result.diagnostics).toEqual([]);
|
||
expect(formatColour(result.entries[0]!.colour, "hex8")).toMatch(
|
||
/^#[0-9a-f]{8}$/u,
|
||
);
|
||
});
|
||
|
||
it("reports missing DTCG types instead of guessing them", () => {
|
||
const result = parseDtcgTokens(
|
||
'{"untyped":{"$value":{"colorSpace":"srgb","components":[1,0,0]}}}',
|
||
);
|
||
expect(result.entries).toEqual([]);
|
||
expect(result.diagnostics[0]).toMatchObject({ severity: "error" });
|
||
});
|
||
|
||
it("inherits a DTCG alias type and rejects out-of-range components", () => {
|
||
const alias = parseDtcgTokens(
|
||
JSON.stringify({
|
||
base: {
|
||
$type: "color",
|
||
$value: { colorSpace: "srgb", components: [1, 0, 0] },
|
||
},
|
||
alias: { $value: "{base}" },
|
||
}),
|
||
);
|
||
expect(alias.entries.map((entry) => entry.name)).toEqual(["base", "alias"]);
|
||
expect(alias.diagnostics).toEqual([]);
|
||
|
||
const invalid = parseDtcgTokens(
|
||
JSON.stringify({
|
||
bad: {
|
||
$type: "color",
|
||
$value: { colorSpace: "display-p3", components: [1.2, 0, 0] },
|
||
},
|
||
}),
|
||
);
|
||
expect(invalid.entries).toEqual([]);
|
||
expect(invalid.diagnostics[0]?.message).toMatch(/from 0 to 1/u);
|
||
});
|
||
|
||
it("resolves DTCG JSON Pointer aliases and property-level references", () => {
|
||
const result = parseDtcgTokens(
|
||
JSON.stringify({
|
||
base: {
|
||
$type: "color",
|
||
$value: {
|
||
colorSpace: "srgb",
|
||
components: [0.2, 0.4, 0.8],
|
||
hex: "#3366cc",
|
||
},
|
||
},
|
||
alias: { $ref: "#/base/$value" },
|
||
mixed: {
|
||
$type: "color",
|
||
$value: {
|
||
colorSpace: "srgb",
|
||
components: [
|
||
{ $ref: "#/base/$value/components/0" },
|
||
{ $ref: "#/base/$value/components/1" },
|
||
0.7,
|
||
],
|
||
},
|
||
},
|
||
}),
|
||
);
|
||
expect(result.entries.map((entry) => entry.name)).toEqual([
|
||
"base",
|
||
"alias",
|
||
"mixed",
|
||
]);
|
||
expect(result.diagnostics).toEqual([]);
|
||
});
|
||
|
||
it("reports cyclic and malformed DTCG property references", () => {
|
||
const result = parseDtcgTokens(
|
||
JSON.stringify({
|
||
cycle: {
|
||
$type: "color",
|
||
$value: { $ref: "#/cycle/$value" },
|
||
},
|
||
siblings: {
|
||
$type: "color",
|
||
$value: { $ref: "#/cycle/$value", extra: true },
|
||
},
|
||
}),
|
||
);
|
||
expect(result.entries).toEqual([]);
|
||
expect(result.diagnostics.map((item) => item.message).join(" ")).toMatch(
|
||
/cycle|sibling/iu,
|
||
);
|
||
});
|
||
});
|
||
|
||
describe("palette accessibility and gradients", () => {
|
||
it("builds a directional contrast matrix with WCAG thresholds", () => {
|
||
const matrix = paletteContrastMatrix(["black", "white", "#777"]);
|
||
expect(matrix.size).toBe(3);
|
||
expect(matrix.cells[0]?.[1]).toMatchObject({
|
||
ratio: 21,
|
||
aaNormal: true,
|
||
aaaNormal: true,
|
||
});
|
||
expect(matrix.cells[2]?.[1]?.aaNormal).toBe(false);
|
||
expect(() => paletteContrastMatrix([])).toThrow(/1–64/u);
|
||
});
|
||
|
||
it("exports linear, radial, conic and repeating CSS gradients", () => {
|
||
const stops = [
|
||
{ colour: "red", position: 0 },
|
||
{ colour: "blue", position: 1 },
|
||
];
|
||
expect(buildCssGradient(stops, { angle: 45 })).toMatch(
|
||
/^linear-gradient\(45deg,/u,
|
||
);
|
||
expect(
|
||
buildCssGradient(stops, { kind: "radial", radialShape: "circle" }),
|
||
).toMatch(/^radial-gradient\(circle at center,/u);
|
||
expect(buildCssGradient(stops, { kind: "conic", repeating: true })).toMatch(
|
||
/^repeating-conic-gradient\(/u,
|
||
);
|
||
expect(JSON.parse(exportGradientRecipe(stops))).toMatchObject({
|
||
schemaVersion: 1,
|
||
type: "css-gradient",
|
||
stops: [{ position: 0 }, { position: 1 }],
|
||
});
|
||
});
|
||
});
|