249 lines
7.3 KiB
TypeScript
249 lines
7.3 KiB
TypeScript
import { describe, expect, it } from "vitest";
|
|
import {
|
|
deleteToken,
|
|
exportTokens,
|
|
parseTokenDocument,
|
|
resolveTheme,
|
|
updateToken,
|
|
} from "../../src/core/tokens";
|
|
|
|
const themed = JSON.stringify({
|
|
$sets: {
|
|
core: {
|
|
color: {
|
|
$type: "color",
|
|
brand: { $value: "#112233" },
|
|
link: { $value: "{color.brand}" },
|
|
},
|
|
space: { sm: { $type: "dimension", $value: "8px" } },
|
|
},
|
|
dark: { color: { $type: "color", brand: { $value: "#ddeeff" } } },
|
|
},
|
|
$themes: { light: ["core"], dark: ["core", "dark"] },
|
|
});
|
|
|
|
describe("token model", () => {
|
|
it("composes sets and resolves aliases after overrides", () => {
|
|
const result = resolveTheme(parseTokenDocument(themed), "dark");
|
|
expect(
|
|
result.tokens.find((token) => token.path === "color.link")?.resolvedValue,
|
|
).toBe("#ddeeff");
|
|
expect(result.diagnostics).toEqual([]);
|
|
});
|
|
|
|
it("detects cycles and missing aliases", () => {
|
|
const document = parseTokenDocument(
|
|
'{"a":{"$value":"{b}"},"b":{"$value":"{a}"},"c":{"$value":"{missing}"}}',
|
|
);
|
|
const messages = resolveTheme(document, "default")
|
|
.diagnostics.map((item) => item.message)
|
|
.join(" ");
|
|
expect(messages).toMatch(/cycle/u);
|
|
expect(messages).toMatch(/does not exist/u);
|
|
});
|
|
|
|
it("resolves nested composite aliases and explicit theme modes", () => {
|
|
const document = parseTokenDocument(
|
|
JSON.stringify({
|
|
$sets: {
|
|
core: {
|
|
color: {
|
|
brand: { $type: "color", $value: "#112233" },
|
|
},
|
|
},
|
|
dark: {
|
|
color: {
|
|
brand: { $type: "color", $value: "#ddeeff" },
|
|
},
|
|
},
|
|
components: {
|
|
focus: {
|
|
$type: "border",
|
|
$value: {
|
|
color: "{color.brand}",
|
|
width: "2px",
|
|
style: "solid",
|
|
},
|
|
},
|
|
},
|
|
},
|
|
$themes: {
|
|
product: {
|
|
$sets: ["core", "components"],
|
|
$modes: { light: [], dark: ["dark"] },
|
|
},
|
|
},
|
|
}),
|
|
);
|
|
const result = resolveTheme(document, "product", "dark");
|
|
expect(
|
|
result.tokens.find((token) => token.path === "focus")?.resolvedValue,
|
|
).toEqual({ color: "#ddeeff", width: "2px", style: "solid" });
|
|
expect(result.diagnostics).toEqual([]);
|
|
});
|
|
|
|
it("validates and exports DTCG composite token types", () => {
|
|
const document = parseTokenDocument(
|
|
JSON.stringify({
|
|
easing: { $type: "cubicBezier", $value: [0.2, 0, 0, 1] },
|
|
motion: {
|
|
$type: "transition",
|
|
$value: {
|
|
duration: "180ms",
|
|
delay: "0ms",
|
|
timingFunction: "{easing}",
|
|
},
|
|
},
|
|
}),
|
|
);
|
|
const resolved = resolveTheme(document, "default");
|
|
expect(resolved.diagnostics).toEqual([]);
|
|
expect(exportTokens("css", resolved.tokens).content).toContain(
|
|
"180ms cubic-bezier(0.2, 0, 0, 1) 0ms",
|
|
);
|
|
const dtcg = exportTokens("dtcg", resolved.tokens).content;
|
|
expect(dtcg).toContain('"$type": "transition"');
|
|
expect(dtcg).toContain('"{easing}"');
|
|
});
|
|
|
|
it("rejects malformed wrappers and non-standard hex lengths", () => {
|
|
expect(() => parseTokenDocument('{"$sets":[]}')).toThrow(
|
|
/\$sets must be an object/u,
|
|
);
|
|
const document = parseTokenDocument(
|
|
'{"five":{"$type":"color","$value":"#12345"},"seven":{"$type":"color","$value":"#1234567"}}',
|
|
);
|
|
expect(resolveTheme(document, "default").diagnostics).toHaveLength(2);
|
|
expect(() => parseTokenDocument('{"$themes":{}}')).toThrow(
|
|
/requires a \$sets/u,
|
|
);
|
|
expect(() =>
|
|
parseTokenDocument(
|
|
'{"leaf":{"$value":"x","hidden":{"$value":"ignored"}}}',
|
|
),
|
|
).toThrow(/cannot also contain child/u);
|
|
});
|
|
|
|
it("exports CSS and reports platform loss", () => {
|
|
const tokens = resolveTheme(parseTokenDocument(themed), "light").tokens;
|
|
expect(exportTokens("css", tokens).content).toContain(
|
|
"--color-brand: #112233;",
|
|
);
|
|
const composite = parseTokenDocument(
|
|
'{"text":{"$type":"typography","$value":{"fontFamily":"Inter","fontSize":"16px"}}}',
|
|
);
|
|
expect(
|
|
exportTokens("android", resolveTheme(composite, "default").tokens)
|
|
.losses[0],
|
|
).toMatch(/project-specific/u);
|
|
});
|
|
|
|
it("quotes generated strings and typography and reports name collisions", () => {
|
|
const document = parseTokenDocument(
|
|
JSON.stringify({
|
|
danger: { $type: "string", $value: 'x"; } --owned: red; /*' },
|
|
text: {
|
|
$type: "typography",
|
|
$value: {
|
|
fontFamily: 'Evil"; } body { color: red; /*',
|
|
fontSize: "16px",
|
|
},
|
|
},
|
|
a: { b: { $type: "string", $value: "first" } },
|
|
"a-b": { $type: "string", $value: "second" },
|
|
}),
|
|
);
|
|
const result = exportTokens(
|
|
"css",
|
|
resolveTheme(document, "default").tokens,
|
|
);
|
|
expect(result.content).toContain('--danger: "x\\"; } --owned: red; /*";');
|
|
expect(result.content).toContain('"Evil\\"; } body { color: red; /*"');
|
|
expect(result.losses.join(" ")).toMatch(/identifier a-b collides/u);
|
|
});
|
|
|
|
it("edits and deletes nested tokens", () => {
|
|
const document = parseTokenDocument(themed);
|
|
const updated = updateToken(
|
|
document,
|
|
"core",
|
|
"space.sm",
|
|
"space.md",
|
|
"dimension",
|
|
"16px",
|
|
);
|
|
expect(
|
|
parseTokenDocument(updated).sets.core?.some(
|
|
(token) => token.path === "space.md",
|
|
),
|
|
).toBe(true);
|
|
const removed = deleteToken(
|
|
parseTokenDocument(updated),
|
|
"core",
|
|
"space.md",
|
|
);
|
|
expect(
|
|
parseTokenDocument(removed).sets.core?.some(
|
|
(token) => token.path === "space.md",
|
|
),
|
|
).toBe(false);
|
|
});
|
|
|
|
it("preserves DTCG leaf metadata while editing or renaming", () => {
|
|
const document = parseTokenDocument(
|
|
'{"brand":{"$type":"color","$value":"#112233","$description":"Brand","$extensions":{"vendor":{"id":1}}}}',
|
|
);
|
|
const updated = updateToken(
|
|
document,
|
|
"default",
|
|
"brand",
|
|
"color.brand",
|
|
"color",
|
|
"#445566",
|
|
);
|
|
expect(JSON.parse(updated)).toMatchObject({
|
|
color: {
|
|
brand: {
|
|
$description: "Brand",
|
|
$extensions: { vendor: { id: 1 } },
|
|
$value: "#445566",
|
|
},
|
|
},
|
|
});
|
|
});
|
|
|
|
it("validates composite members rather than only their presence", () => {
|
|
const document = parseTokenDocument(
|
|
JSON.stringify({
|
|
text: {
|
|
$type: "typography",
|
|
$value: { fontFamily: 42, fontSize: "large" },
|
|
},
|
|
depth: {
|
|
$type: "shadow",
|
|
$value: {
|
|
color: "not-a-colour",
|
|
offsetX: "0px",
|
|
offsetY: "2px",
|
|
blur: "4px",
|
|
},
|
|
},
|
|
}),
|
|
);
|
|
const messages = resolveTheme(document, "default")
|
|
.diagnostics.map((diagnostic) => diagnostic.message)
|
|
.join(" ");
|
|
expect(messages).toMatch(/Typography requires valid/iu);
|
|
expect(messages).toMatch(/Each shadow requires valid/iu);
|
|
});
|
|
|
|
it("refuses to create a path through an existing token leaf", () => {
|
|
const document = parseTokenDocument(
|
|
'{"base":{"$type":"string","$value":"leaf"}}',
|
|
);
|
|
expect(() =>
|
|
updateToken(document, "default", null, "base.child", "string", "x"),
|
|
).toThrow(/crosses existing token base/u);
|
|
});
|
|
});
|