332 lines
13 KiB
TypeScript
332 lines
13 KiB
TypeScript
import { test, expect } from "bun:test";
|
|
import { CORE_DESIGN_SYSTEMS, validateDesignSystem } from "../../design-system/src/index.ts";
|
|
import {
|
|
generateDesigns,
|
|
DEFAULT_DESIGN_BRIEF,
|
|
type GenerationRequest,
|
|
type GenerationProvider,
|
|
} from "../src/generation.ts";
|
|
import { designRevision, checkDecisionLocks, preserveDecisionLocks } from "../src/decisions.ts";
|
|
import { combineDesignProposals } from "../src/guidance.ts";
|
|
import { StudioSession } from "../src/session.ts";
|
|
|
|
const source = () => structuredClone(CORE_DESIGN_SYSTEMS.square);
|
|
async function request(
|
|
kind: "directions" | "edit" = "directions",
|
|
locks: string[] = [],
|
|
system = source(),
|
|
): Promise<GenerationRequest> {
|
|
return {
|
|
system,
|
|
locks,
|
|
brief: { ...DEFAULT_DESIGN_BRIEF, product: "Booking site", audience: "Mobile customers" },
|
|
baseRevision: await designRevision(system, locks),
|
|
kind,
|
|
instruction: kind === "edit" ? "make buttons rounded and use spacious spacing" : undefined,
|
|
};
|
|
}
|
|
const patched = (changes: unknown[]) =>
|
|
JSON.stringify({
|
|
alternatives: [{ name: "Edited", rationale: "Requested change", patch: changes }],
|
|
});
|
|
const set = (path: string, value: unknown) => ({ op: "set", path, value });
|
|
|
|
test("local guided generation produces three distinct validated directions without changing source", async () => {
|
|
const input = await request();
|
|
const original = JSON.stringify(input.system);
|
|
const result = await generateDesigns(input);
|
|
expect(result.diagnostics).toEqual([]);
|
|
expect(result.alternatives).toHaveLength(3);
|
|
expect(new Set(result.alternatives.map((p) => JSON.stringify(p.system))).size).toBe(3);
|
|
for (const proposal of result.alternatives) {
|
|
expect(validateDesignSystem(proposal.system)).toEqual([]);
|
|
expect(Object.keys(proposal.system.recipes!)).toHaveLength(104);
|
|
expect(proposal.system.id).toBe(input.system.id);
|
|
}
|
|
expect(JSON.stringify(input.system)).toBe(original);
|
|
expect(result.provenance.provider).toBe("local-rules");
|
|
expect(result.provenance.maxOutputTokens).toBe(0);
|
|
});
|
|
|
|
test("guided geometry, density, surface and brand choices affect the brief direction", async () => {
|
|
const input = await request();
|
|
input.brief = {
|
|
...input.brief,
|
|
geometry: "rounded",
|
|
density: "spacious",
|
|
surface: "soft",
|
|
brandColor: "#112233",
|
|
};
|
|
const result = await generateDesigns(input);
|
|
expect(result.diagnostics).toEqual([]);
|
|
const system = result.alternatives[0]!.system;
|
|
expect(system.tokens["shape.control"]!.value).toBe("16px");
|
|
expect(system.tokens["space.control"]!.value).toBe("1.25rem");
|
|
expect(system.tokens["elevation.panel"]!.value).toContain("24px");
|
|
expect(system.modes!.light!["color.primary"]!.value).toBe("#112233");
|
|
expect(result.brief).toEqual(input.brief);
|
|
});
|
|
|
|
test("direct locks, mode overrides and alias changes cannot alter a locked token", () => {
|
|
const base = source();
|
|
base.tokens["shape.alias"] = { type: "dimension", value: { ref: "shape.control" } };
|
|
let next = structuredClone(base);
|
|
next.tokens["shape.control"]!.value = "8px";
|
|
expect(checkDecisionLocks(base, next, ["/tokens/shape.alias"])).not.toEqual([]);
|
|
next = structuredClone(base);
|
|
next.modes!.light!["shape.control"] = { type: "dimension", value: "8px" };
|
|
expect(checkDecisionLocks(base, next, ["/tokens/shape.alias"])).not.toEqual([]);
|
|
next = structuredClone(base);
|
|
next.tokens["space.page"]!.value = "5rem";
|
|
expect(checkDecisionLocks(base, next, ["/tokens/shape.alias"])).toEqual([]);
|
|
});
|
|
|
|
test("component recipe locks protect referenced token values and absent decisions", () => {
|
|
const base = source(),
|
|
next = structuredClone(base);
|
|
next.tokens["shape.control"]!.value = "8px";
|
|
expect(checkDecisionLocks(base, next, ["/recipes/Button"])).not.toEqual([]);
|
|
const added = structuredClone(base);
|
|
added.recipes!.Button!.base!.control!.rotate = "2deg";
|
|
expect(checkDecisionLocks(base, added, ["/recipes/Button/base/control/rotate"])).not.toEqual([]);
|
|
});
|
|
|
|
test("directions preserve locks, including recipe token dependencies", async () => {
|
|
const input = await request("directions", ["/recipes/Button"]);
|
|
const result = await generateDesigns(input);
|
|
expect(result.diagnostics).toEqual([]);
|
|
for (const p of result.alternatives)
|
|
expect(checkDecisionLocks(input.system, p.system, input.locks)).toEqual([]);
|
|
const next = preserveDecisionLocks(
|
|
input.system,
|
|
structuredClone(CORE_DESIGN_SYSTEMS.soft),
|
|
input.locks,
|
|
);
|
|
expect(checkDecisionLocks(input.system, next, input.locks)).toEqual([]);
|
|
});
|
|
|
|
test("manual edits, undo, reload and unlock keep lock-violating drafts recoverable", () => {
|
|
const memory = new Map<string, string>();
|
|
const storage = {
|
|
getItem: (k: string) => memory.get(k) ?? null,
|
|
setItem: (k: string, v: string) => {
|
|
memory.set(k, v);
|
|
},
|
|
};
|
|
const session = new StudioSession(undefined, storage);
|
|
session.setLocks(["/tokens/shape.control"]);
|
|
session.mutate((s) => {
|
|
s.tokens["shape.control"]!.value = "9px";
|
|
}, "Locked edit");
|
|
expect(session.valid.tokens["shape.control"]!.value).toBe("0");
|
|
expect(session.diagnostics[0]!.code).toBe("WRN-STUDIO-LOCK");
|
|
expect(session.source).toContain("9px");
|
|
const restored = new StudioSession(undefined, storage);
|
|
expect(restored.restore()).toBe(true);
|
|
expect(restored.locks).toEqual(session.locks);
|
|
expect(restored.diagnostics[0]!.code).toBe("WRN-STUDIO-LOCK");
|
|
restored.setLocks([]);
|
|
expect(restored.valid.tokens["shape.control"]!.value).toBe("9px");
|
|
restored.undo();
|
|
expect(restored.valid.tokens["shape.control"]!.value).toBe("0");
|
|
});
|
|
|
|
test("word-based edits validate whole requests and reject unknown or locked changes", async () => {
|
|
const result = await generateDesigns(await request("edit"));
|
|
expect(result.diagnostics).toEqual([]);
|
|
expect(result.alternatives[0]!.system.tokens["shape.control"]!.value).toBe("16px");
|
|
const locked = await generateDesigns(await request("edit", ["/tokens/shape.control"]));
|
|
expect(locked.alternatives).toEqual([]);
|
|
expect(locked.diagnostics[0]!.message).toContain("LOCK");
|
|
const input = await request("edit");
|
|
input.instruction = "make buttons rounded and invent a new calendar";
|
|
const failed = await generateDesigns(input);
|
|
expect(failed.alternatives).toEqual([]);
|
|
expect(input.system.tokens["shape.control"]!.value).toBe("0");
|
|
input.instruction = "set primary color to #aabbcc and remove shadows and use serif typography";
|
|
expect((await generateDesigns(input)).diagnostics).toEqual([]);
|
|
});
|
|
|
|
test("stale generation base and stale combinations fail without applying", async () => {
|
|
const input = await request();
|
|
input.baseRevision = "a".repeat(64);
|
|
expect(generateDesigns(input)).rejects.toThrow("WRN-STUDIO-CONFLICT");
|
|
const good = await request();
|
|
const batch = await generateDesigns(good);
|
|
const next = structuredClone(good.system);
|
|
next.name = "Changed";
|
|
await expect(combineDesignProposals(next, batch, { colors: "direction-2" }, [])).rejects.toThrow(
|
|
"CONFLICT",
|
|
);
|
|
});
|
|
|
|
test("foundation decisions combine and revalidate before entering a draft", async () => {
|
|
const input = await request();
|
|
const batch = await generateDesigns(input);
|
|
const next = await combineDesignProposals(
|
|
input.system,
|
|
batch,
|
|
{ geometry: "direction-2", typography: "direction-3" },
|
|
[],
|
|
);
|
|
expect(validateDesignSystem(next)).toEqual([]);
|
|
expect(next.tokens["shape.control"]).toEqual(
|
|
batch.alternatives[1]!.system.tokens["shape.control"],
|
|
);
|
|
expect(next.tokens["type.body"]).toEqual(batch.alternatives[2]!.system.tokens["type.body"]);
|
|
});
|
|
|
|
test("provider adapter uses bounded patches, compact capabilities and one repair", async () => {
|
|
let calls = 0;
|
|
let context = "";
|
|
const provider: GenerationProvider = {
|
|
name: "fixture",
|
|
async generate(prompt, options) {
|
|
calls++;
|
|
context = prompt;
|
|
expect(options!.maxTokens).toBe(512);
|
|
expect(options!.signal).toBeDefined();
|
|
return {
|
|
provider: "fixture",
|
|
model: "test-model",
|
|
value: calls === 1 ? "not json" : patched([set("/tokens/shape.control/value", "11px")]),
|
|
};
|
|
},
|
|
};
|
|
const result = await generateDesigns(await request("edit"), { provider, maxOutputTokens: 512 });
|
|
expect(calls).toBe(2);
|
|
expect(result.diagnostics).toEqual([]);
|
|
expect(result.provenance.model).toBe("test-model");
|
|
expect(result.provenance.attempts).toBe(2);
|
|
expect(context).toContain("capabilities");
|
|
expect(context).toContain("Repair these diagnostics");
|
|
});
|
|
|
|
test("empty drafts require flat typed tokens and reject nested color groups before repair", async () => {
|
|
const system = {
|
|
schemaVersion: 1 as const,
|
|
id: "empty",
|
|
name: "Empty",
|
|
version: "1.0.0",
|
|
tokens: {},
|
|
};
|
|
let calls = 0;
|
|
const batch = await generateDesigns(await request("directions", [], system), {
|
|
provider: {
|
|
name: "fixture",
|
|
async generate(_prompt, options) {
|
|
calls++;
|
|
expect(options!.system).toContain("Token maps are FLAT");
|
|
expect(options!.system).toContain("COMPLETE token object");
|
|
return {
|
|
provider: "fixture",
|
|
value: JSON.stringify({
|
|
alternatives: ["#002FA7", "#00642C", "#A32957"].map((color, index) => ({
|
|
name: `Direction ${index}`,
|
|
rationale: "A distinct brand color.",
|
|
patch: [
|
|
calls === 1
|
|
? set("/tokens/color", { primary: { type: "color", value: color } })
|
|
: set("/tokens/color.primary", {
|
|
type: "color",
|
|
value: color,
|
|
legacy: "color-primary",
|
|
}),
|
|
],
|
|
})),
|
|
}),
|
|
};
|
|
},
|
|
},
|
|
});
|
|
expect(calls).toBe(2);
|
|
expect(batch.alternatives).toHaveLength(3);
|
|
expect(batch.diagnostics).toEqual([]);
|
|
for (const alternative of batch.alternatives)
|
|
expect(validateDesignSystem(alternative.system)).toEqual([]);
|
|
expect(system.tokens).toEqual({});
|
|
});
|
|
|
|
test("provider invalid parts and lock violations cannot survive bounded repair", async () => {
|
|
let calls = 0;
|
|
const provider: GenerationProvider = {
|
|
name: "fixture",
|
|
async generate() {
|
|
calls++;
|
|
return { provider: "fixture", value: patched([set("/tokens/shape.control/value", "12px")]) };
|
|
},
|
|
};
|
|
const result = await generateDesigns(await request("edit", ["/tokens/shape.control"]), {
|
|
provider,
|
|
});
|
|
expect(calls).toBe(2);
|
|
expect(result.alternatives).toEqual([]);
|
|
expect(result.diagnostics[0]!.code).toBe("WRN-STUDIO-PROPOSAL");
|
|
const invalid: GenerationProvider = {
|
|
name: "fixture",
|
|
async generate() {
|
|
return {
|
|
provider: "fixture",
|
|
value: patched([set("/recipes/Button/base/control/position", "fixed")]),
|
|
};
|
|
},
|
|
};
|
|
expect(
|
|
(await generateDesigns(await request("edit"), { provider: invalid })).alternatives,
|
|
).toEqual([]);
|
|
});
|
|
|
|
test("prototype paths, excessive operations and execution strings are rejected", async () => {
|
|
for (const patch of [
|
|
[set("/tokens/__proto__/value", "x")],
|
|
Array.from({ length: 49 }, () => set("/name", "x")),
|
|
[set("/tokens/shape.control/value", "0;}body{display:none")],
|
|
]) {
|
|
const provider: GenerationProvider = {
|
|
name: "fixture",
|
|
async generate() {
|
|
return { provider: "fixture", value: patched(patch) };
|
|
},
|
|
};
|
|
expect((await generateDesigns(await request("edit"), { provider })).alternatives).toEqual([]);
|
|
}
|
|
});
|
|
|
|
test("cancellation and deadline stop even a provider that ignores its signal", async () => {
|
|
const provider: GenerationProvider = { name: "hung", generate: () => new Promise(() => {}) };
|
|
const controller = new AbortController();
|
|
const input = await request("edit");
|
|
const pending = generateDesigns(input, { provider, signal: controller.signal });
|
|
controller.abort();
|
|
await expect(pending).rejects.toThrow();
|
|
await expect(generateDesigns(input, { provider, timeoutMs: 15 })).rejects.toThrow("cancelled");
|
|
expect(input.system.tokens["shape.control"]!.value).toBe("0");
|
|
});
|
|
|
|
test("transport failure is not retried or returned with secret-bearing messages", async () => {
|
|
let count = 0;
|
|
const provider: GenerationProvider = {
|
|
name: "fixture",
|
|
async generate() {
|
|
count++;
|
|
throw Error("secret-key-in-provider-error");
|
|
},
|
|
};
|
|
const result = await generateDesigns(await request("edit"), { provider });
|
|
expect(count).toBe(1);
|
|
expect(JSON.stringify(result)).not.toContain("secret-key-in-provider-error");
|
|
expect(result.diagnostics[0]!.code).toBe("WRN-STUDIO-PROVIDER");
|
|
});
|
|
|
|
test("untrusted brief fields and generation budgets are bounded", async () => {
|
|
const input = await request();
|
|
input.brief.product = "x".repeat(2001);
|
|
await expect(generateDesigns(input)).rejects.toThrow("2000");
|
|
await expect(generateDesigns(await request(), { maxOutputTokens: 9000 })).rejects.toThrow(
|
|
"BUDGET",
|
|
);
|
|
const altered = await request();
|
|
altered.brief.brandColor = "red;}body{}";
|
|
await expect(generateDesigns(altered)).rejects.toThrow("BRIEF");
|
|
});
|