202 lines
8.6 KiB
TypeScript
202 lines
8.6 KiB
TypeScript
import { expect, test } from "bun:test";
|
|
import {
|
|
CORE_DESIGN_SYSTEMS,
|
|
validateDesignSystem,
|
|
compileDesignSystem,
|
|
} from "@wrnexus/design-system";
|
|
import { MANUAL_TYPOGRAPHY, MANUAL_FONTS, manualDesign } from "../src/manual.ts";
|
|
import { StudioSession } from "../src/session.ts";
|
|
import { checkDecisionLocks } from "../src/decisions.ts";
|
|
|
|
test("manual colors update only their mode and maintain readable contrast and legacy bindings", () => {
|
|
const source = CORE_DESIGN_SYSTEMS.soft;
|
|
const original = JSON.stringify(source);
|
|
const next = manualDesign(source, {
|
|
kind: "color",
|
|
mode: "light",
|
|
name: "primary",
|
|
value: "#000000",
|
|
});
|
|
expect(next.modes!.light!["color.primary"]!.value).toBe("#000000");
|
|
expect(next.modes!.light!["color.primary-contrast"]!.value).toBe("#FFFFFF");
|
|
expect(next.modes!.light!["color.focus"]!.legacy).toBe("color-focus");
|
|
expect(next.modes!.dark).toEqual(source.modes!.dark);
|
|
expect(JSON.stringify(source)).toBe(original);
|
|
expect(validateDesignSystem(next)).toEqual([]);
|
|
expect(() => compileDesignSystem(next, { mode: "light" })).not.toThrow();
|
|
expect(() =>
|
|
manualDesign(source, {
|
|
kind: "color",
|
|
mode: "light",
|
|
name: "primary",
|
|
value: "red; display:none",
|
|
}),
|
|
).toThrow();
|
|
});
|
|
test("manual foundations are recoverable and decision locks still detect changes", () => {
|
|
const session = new StudioSession(CORE_DESIGN_SYSTEMS.soft);
|
|
const before = JSON.stringify(session.valid);
|
|
const next = manualDesign(session.valid, { kind: "token", name: "shape.control", value: "8px" });
|
|
expect(checkDecisionLocks(session.valid, next, ["/tokens/shape.control"])).not.toHaveLength(0);
|
|
session.replace(next, "Manual shape");
|
|
expect(session.valid.tokens["shape.control"]!.value).toBe("8px");
|
|
session.undo();
|
|
expect(JSON.stringify(session.valid)).toBe(before);
|
|
session.redo();
|
|
expect(session.valid.tokens["shape.control"]!.value).toBe("8px");
|
|
expect(() =>
|
|
manualDesign(next, { kind: "token", name: "shape.control", value: "-2px" }),
|
|
).toThrow();
|
|
});
|
|
test("manual typography, spacing, surfaces and semantic colors validate for all core themes", () => {
|
|
for (const original of Object.values(CORE_DESIGN_SYSTEMS)) {
|
|
let system = structuredClone(original);
|
|
for (const [name, value] of [
|
|
["type.body", "Arial, sans-serif"],
|
|
["type.heading", "Georgia, serif"],
|
|
["space.panel", "24px"],
|
|
["shape.panel", "12px"],
|
|
["elevation.panel", "none"],
|
|
])
|
|
system = manualDesign(system, { kind: "token", name: name!, value: value! });
|
|
for (const mode of ["light", "dark"])
|
|
for (const name of ["secondary", "success", "warning", "danger", "info"] as const)
|
|
system = manualDesign(system, { kind: "color", mode, name, value: "#00677B" });
|
|
expect(validateDesignSystem(system)).toEqual([]);
|
|
expect(() => compileDesignSystem(system, { mode: "dark" })).not.toThrow();
|
|
}
|
|
});
|
|
|
|
test("manual button styles, tilt, pill shapes, default mode and advanced colors compile", () => {
|
|
let system = manualDesign(CORE_DESIGN_SYSTEMS.soft, { kind: "buttonStyle", value: "outline" });
|
|
system = manualDesign(system, { kind: "tilt", value: "none" });
|
|
system = manualDesign(system, { kind: "token", name: "shape.control", value: "999px" });
|
|
system = manualDesign(system, { kind: "mode", value: "dark" });
|
|
system = manualDesign(system, {
|
|
kind: "color",
|
|
mode: "dark",
|
|
name: "primary-contrast",
|
|
value: "#FFFFFF",
|
|
});
|
|
expect(system.defaultMode).toBe("dark");
|
|
expect(system.recipes!.Button!.variants!.at(-1)!.parts.control!.backgroundColor).toBe(
|
|
"transparent",
|
|
);
|
|
expect(system.recipes!.Button!.base!.control!.rotate).toBe("0deg");
|
|
expect(() => compileDesignSystem(system)).not.toThrow();
|
|
});
|
|
|
|
test("manual form saves one atomic revision and rejects locked changes without corrupting drafts", async () => {
|
|
const { Window } = await import("happy-dom");
|
|
const { manualHtml, setupManual } = await import("../src/manual-editor.ts");
|
|
const win = new Window();
|
|
const names = ["document", "HTMLInputElement", "Option"] as const;
|
|
const previous = Object.fromEntries(names.map((name) => [name, (globalThis as any)[name]]));
|
|
try {
|
|
(globalThis as any).document = win.document;
|
|
(globalThis as any).HTMLInputElement = win.HTMLInputElement;
|
|
(globalThis as any).Option = (win as any).Option;
|
|
win.document.body.innerHTML =
|
|
manualHtml + '<select id="preview-mode"><option value="light">light</option></select>';
|
|
const session = new StudioSession(CORE_DESIGN_SYSTEMS.soft);
|
|
let notice = "";
|
|
const editor = setupManual(session, {
|
|
changed: async () => {
|
|
editor.render();
|
|
},
|
|
message: (text) => {
|
|
notice = text;
|
|
},
|
|
mode: () => {},
|
|
});
|
|
editor.render();
|
|
const input = win.document.querySelector('[data-manual-token="shape.control"]') as any;
|
|
input.value = "12";
|
|
(win.document.querySelector('select[data-manual-token="type.heading"]') as any).value =
|
|
"google:Inter";
|
|
(win.document.querySelector('[data-manual-token="type.body-size"]') as any).value = "18";
|
|
(win.document.querySelector('[data-manual-token="type.body-leading"]') as any).value = "1.8";
|
|
(win.document.querySelector('input[type=text][data-manual-color="primary"]') as any).value =
|
|
"#6336AD";
|
|
(win.document.querySelector("[data-manual-apply]") as any).click();
|
|
await Bun.sleep(10);
|
|
expect(session.valid.tokens["shape.control"]!.value).toBe("12px");
|
|
expect(session.valid.modes!.light!["color.primary"]!.value).toBe("#6336AD");
|
|
expect(session.valid.tokens["type.heading"]!.value).toBe('"Inter", sans-serif');
|
|
expect(session.valid.googleFonts).toEqual(["Inter"]);
|
|
expect(session.valid.tokens["type.body-size"]!.value).toBe("18px");
|
|
expect(session.valid.tokens["type.body-leading"]!.value).toBe("1.8");
|
|
expect(session.history).toHaveLength(2);
|
|
session.setLocks(["/tokens/shape.control"]);
|
|
editor.render();
|
|
(win.document.querySelector('[data-manual-token="shape.control"]') as any).value = "24";
|
|
(win.document.querySelector("[data-manual-apply]") as any).click();
|
|
await Bun.sleep(10);
|
|
expect(notice).toContain("locked decision");
|
|
expect(session.valid.tokens["shape.control"]!.value).toBe("12px");
|
|
expect(session.diagnostics).toEqual([]);
|
|
} finally {
|
|
for (const name of names) (globalThis as any)[name] = previous[name];
|
|
await win.happyDOM.close();
|
|
}
|
|
});
|
|
|
|
test("manual typography controls persist in exported recipes and reject invalid ranges", () => {
|
|
for (const original of Object.values(CORE_DESIGN_SYSTEMS)) {
|
|
let system = structuredClone(original);
|
|
for (const [name, setting] of Object.entries(MANUAL_TYPOGRAPHY))
|
|
system = manualDesign(system, {
|
|
kind: "token",
|
|
name,
|
|
value: String(setting.initial) + setting.unit,
|
|
});
|
|
system = manualDesign(system, {
|
|
kind: "token",
|
|
name: "type.code",
|
|
value: MANUAL_FONTS.consolas,
|
|
});
|
|
expect(validateDesignSystem(system)).toEqual([]);
|
|
const css = compileDesignSystem(system).css;
|
|
expect(css).toContain("font-size:16px");
|
|
expect(css).toContain("font-weight:700");
|
|
expect(css).toContain("line-height:1.5");
|
|
expect(JSON.parse(JSON.stringify(system)).tokens["type.code"].value).toBe(
|
|
MANUAL_FONTS.consolas,
|
|
);
|
|
expect(() =>
|
|
manualDesign(system, { kind: "token", name: "type.body-size", value: "200px" }),
|
|
).toThrow();
|
|
expect(() =>
|
|
manualDesign(system, { kind: "token", name: "type.body-leading", value: "0" }),
|
|
).toThrow();
|
|
expect(() =>
|
|
manualDesign(system, { kind: "token", name: "type.heading", value: "serif; color:red" }),
|
|
).toThrow();
|
|
}
|
|
});
|
|
|
|
test("Google Fonts survive JSON export, compile first, and reject injected URLs", () => {
|
|
const source = CORE_DESIGN_SYSTEMS.soft;
|
|
const next = manualDesign(source, {
|
|
kind: "googleFont",
|
|
name: "type.heading",
|
|
value: "Playfair Display",
|
|
});
|
|
expect(source.googleFonts).toBeUndefined();
|
|
const portable = JSON.parse(JSON.stringify(next));
|
|
expect(validateDesignSystem(portable)).toEqual([]);
|
|
const compiled = compileDesignSystem(portable);
|
|
expect(
|
|
compiled.css.startsWith(
|
|
'@import url("https://fonts.googleapis.com/css2?family=Playfair%20Display:wght@400;700&display=swap");',
|
|
),
|
|
).toBe(true);
|
|
expect(compiled.recipeCss.startsWith("@import")).toBe(true);
|
|
expect(compiled.css.match(/@import/g)).toHaveLength(1);
|
|
expect(
|
|
validateDesignSystem({ ...next, googleFonts: ['Inter\";}@import url(https://evil.test)'] }),
|
|
).not.toHaveLength(0);
|
|
const changed = manualDesign(next, { kind: "googleFont", name: "type.heading", value: "Lora" });
|
|
expect(changed.googleFonts).toEqual(["Lora"]);
|
|
});
|