import { describe, expect, it } from "vitest"; import { parseDesignTokenContent } from "./design-token-parser.js"; /** * Token-drift robustness for the SINGLE producer the CLI's * buildContractVocabularyFacts consumes (parseDesignTokenContent). Each format * that previously yielded an EMPTY vocabulary (→ FUI2015 silently disabled) is * locked here, plus a NON-SILENT 0-token warning so an empty result is never * quiet. */ function names(content: string, filePath: string): string[] { return parseDesignTokenContent(content, { filePath }).tokens.map((t) => t.name); } describe("parseDesignTokenContent — DTCG/JSON format dispatch (#10)", () => { it("parses a DTCG token file into --kebab CSS names", () => { const content = JSON.stringify({ color: { accent: { $value: "#fff", $type: "color" } }, }); const tokens = names(content, "tokens.tokens.json"); expect(tokens).toContain("--color-accent"); }); it("preserves DTCG token type/category before name heuristics", () => { const content = JSON.stringify({ brand: { primary: { $value: "#39594d", $type: "color" }, corner: { $value: "8px", $type: "dimension" }, }, }); const res = parseDesignTokenContent(content, { filePath: "tokens.tokens.json" }); expect(res.tokens.find((token) => token.name === "--brand-primary")?.category).toBe("color"); expect(res.tokens.find((token) => token.name === "--brand-corner")?.category).toBe("spacing"); }); it("parses DTCG even when the path is generic but content is JSON", () => { // `looksDtcg` also triggers on `{`-leading content, so an unknown extension // with JSON content still routes to the DTCG parser. const content = JSON.stringify({ space: { $type: "dimension", small: { $value: "8px" } }, }); const tokens = names(content, "weird-name.config"); expect(tokens).toContain("--space-small"); }); it("surfaces a NON-SILENT warning for an empty DTCG file (#10)", () => { const res = parseDesignTokenContent("{}", { filePath: "x.tokens.json" }); expect(res.tokens).toHaveLength(0); expect(res.warnings).toHaveLength(1); expect(res.warnings[0]).toMatch(/produced no token names/); }); it("fails soft on malformed JSON instead of throwing", () => { // Malformed `.json`: must NOT throw, must fall through to the CSS path and // still emit a non-silent warning (no tokens found anywhere). const res = parseDesignTokenContent("{ not valid json", { filePath: "broken.tokens.json", }); expect(res.tokens).toHaveLength(0); expect(res.warnings.some((w) => /produced no token names/.test(w))).toBe(true); }); }); describe("parseDesignTokenContent — SCSS $-variable format dispatch (#32)", () => { it("parses SCSS $vars, normalizing $name to --name", () => { const content = "$fui-color-accent: #fff;\n$fui-space-2: 8px;"; const tokens = names(content, "tokens.scss"); expect(tokens).toContain("--fui-color-accent"); expect(tokens).toContain("--fui-space-2"); }); it("dedupes a file declaring BOTH --fui-x and $fui-x to one --fui-x", () => { const content = ":root {\n --fui-x: red;\n}\n$fui-x: red;"; const tokens = names(content, "tokens.scss"); expect(tokens.filter((n) => n === "--fui-x")).toHaveLength(1); }); it("surfaces a NON-SILENT warning for a comment-only SCSS file (#32)", () => { const res = parseDesignTokenContent("// just a comment\n", { filePath: "empty.scss", }); expect(res.tokens).toHaveLength(0); expect(res.warnings).toHaveLength(1); expect(res.warnings[0]).toMatch(/produced no token names/); }); it("routes $-only content to SCSS even without a .scss extension", () => { // No `--name:` declarations + has `$name:` declarations → SCSS branch. const content = "$accent: #fff;"; const tokens = names(content, "_vars"); expect(tokens).toContain("--accent"); }); }); describe("parseDesignTokenContent — CSS terminator robustness (#34/#35)", () => { it("captures a final-in-block declaration with NO trailing semicolon (#34)", () => { const content = ":root {\n --fui-color-accent: #fff\n}"; const tokens = names(content, "tokens.css"); expect(tokens).toContain("--fui-color-accent"); }); it("captures both declarations in a block where the last has no semicolon (#34)", () => { const content = ":root { --a: #000; --b: #fff }"; const tokens = names(content, "tokens.css"); expect(tokens).toContain("--a"); expect(tokens).toContain("--b"); }); it("captures a MULTI-LINE declaration value (#35)", () => { const content = ":root {\n --fui-shadow: 0 1px 2px\n rgba(0,0,0,.1);\n}"; const res = parseDesignTokenContent(content, { filePath: "tokens.css" }); const shadow = res.tokens.find((t) => t.name === "--fui-shadow"); expect(shadow).toBeDefined(); // Line attribution survives the index-based recompute (declaration starts L2). expect(shadow?.lineNumber).toBe(2); // Internal whitespace (incl. the newline) is collapsed. expect(shadow?.rawValue).toBe("0 1px 2px rgba(0,0,0,.1)"); }); it("captures a multi-line value that is also the final unterminated decl (#34+#35)", () => { const content = ":root {\n --fui-shadow: 0 1px 2px\n rgba(0,0,0,.1)\n}"; const tokens = names(content, "tokens.css"); expect(tokens).toContain("--fui-shadow"); }); it("uses value shape before name heuristics for CSS custom properties", () => { const res = parseDesignTokenContent(":root { --brand-primary: #39594d; }", { filePath: "tokens.css", }); expect(res.tokens.find((token) => token.name === "--brand-primary")?.category).toBe("color"); }); it("surfaces a NON-SILENT warning for a CSS file with no token names", () => { const res = parseDesignTokenContent(".foo { color: red; }", { filePath: "styles.css", }); expect(res.tokens).toHaveLength(0); expect(res.warnings.some((w) => /produced no token names/.test(w))).toBe(true); }); it("still parses a normal CSS token block (regression)", () => { const content = ":root {\n --fui-color-accent: #ff0000;\n --fui-space-2: 8px;\n}"; const tokens = names(content, "tokens.css"); expect(tokens).toContain("--fui-color-accent"); expect(tokens).toContain("--fui-space-2"); }); it("categorizes abbreviated --sp-* tokens as spacing", () => { const content = ":root {\n --sp-1: 4px;\n --fui-sp-2: 8px;\n}"; const res = parseDesignTokenContent(content, { filePath: "tokens.css" }); expect(res.tokens.find((token) => token.name === "--sp-1")?.category).toBe("spacing"); expect(res.tokens.find((token) => token.name === "--fui-sp-2")?.category).toBe("spacing"); }); });