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 }], }); }); });