import { describe, expect, it } from "vitest"; import { CANVAS_VERSION, emptyCanvas, isCanvas, validateCanvas, type Canvas, type CanvasElement } from "./schema.js"; const sticky = (over: Partial = {}): CanvasElement => ({ id: "s1", type: "sticky", x: 0, y: 0, w: 100, h: 80, z: 1, text: "hi", color: "yellow", ...over }) as CanvasElement; describe("canvas schema (T-167)", () => { it("accepts a valid minimal canvas", () => { const c: Canvas = { version: CANVAS_VERSION, elements: { s1: sticky() } }; const r = validateCanvas(c); expect(r.ok).toBe(true); if (r.ok) { expect(r.canvas.elements.s1!.type).toBe("sticky"); expect(isCanvas(r.canvas)).toBe(true); } }); it("rejects an unknown version (→ renderer read-only banner)", () => { const r = validateCanvas({ version: 99, elements: {} }); expect(r.ok).toBe(false); if (!r.ok) expect(r.errors[0]).toMatch(/unsupported canvas version/); }); it("rejects a non-object / non-map canvas", () => { expect(validateCanvas("nope").ok).toBe(false); expect(validateCanvas({ version: CANVAS_VERSION, elements: [1, 2] }).ok).toBe(false); expect(validateCanvas({ version: CANVAS_VERSION }).ok).toBe(false); }); it("rejects bad element ids, non-finite numbers, negative size, non-int z", () => { const badId = validateCanvas({ version: CANVAS_VERSION, elements: { "bad id!": sticky() } }); expect(badId.ok).toBe(false); const badNum = validateCanvas({ version: CANVAS_VERSION, elements: { s1: sticky({ x: NaN }) } }); expect(badNum.ok).toBe(false); const neg = validateCanvas({ version: CANVAS_VERSION, elements: { s1: sticky({ w: -5 }) } }); expect(neg.ok).toBe(false); const badZ = validateCanvas({ version: CANVAS_VERSION, elements: { s1: sticky({ z: 1.5 }) } }); expect(badZ.ok).toBe(false); }); it("enforces per-type fields (text length, fontSize range, color palette, points parity, strokeWidth)", () => { const longText = validateCanvas({ version: CANVAS_VERSION, elements: { s1: sticky({ text: "x".repeat(20_001) }) } }); expect(longText.ok).toBe(false); const badColor = validateCanvas({ version: CANVAS_VERSION, elements: { s1: sticky({ color: "neon" }) } }); expect(badColor.ok).toBe(false); const textEl: CanvasElement = { id: "t1", type: "text", x: 0, y: 0, w: 10, h: 10, z: 1, text: "a", fontSize: 4 }; expect(validateCanvas({ version: CANVAS_VERSION, elements: { t1: textEl } }).ok).toBe(false); const fh: CanvasElement = { id: "f1", type: "freehand", x: 0, y: 0, w: 10, h: 10, z: 1, points: [0, 0, 1], strokeWidth: 2 }; expect(validateCanvas({ version: CANVAS_VERSION, elements: { f1: fh } }).ok).toBe(false); // odd points const fh2: CanvasElement = { id: "f1", type: "freehand", x: 0, y: 0, w: 10, h: 10, z: 1, points: [0, 0, 1, 1], strokeWidth: 41 }; expect(validateCanvas({ version: CANVAS_VERSION, elements: { f1: fh2 } }).ok).toBe(false); // strokeWidth > 40 }); it("enforces referential integrity (connector endpoints + group children)", () => { const conn: CanvasElement = { id: "c1", type: "connector", x: 0, y: 0, w: 0, h: 0, z: 1, from: { elementId: "missing", anchor: "right" }, to: { elementId: "s1", anchor: "left" }, kind: "straight", arrow: true }; const r = validateCanvas({ version: CANVAS_VERSION, elements: { s1: sticky(), c1: conn } }); expect(r.ok).toBe(false); if (!r.ok) expect(r.errors.join(" ")).toMatch(/missing/); const grp: CanvasElement = { id: "g1", type: "group", x: 0, y: 0, w: 10, h: 10, z: 1, children: ["s1", "ghost"] }; const rg = validateCanvas({ version: CANVAS_VERSION, elements: { s1: sticky(), g1: grp } }); expect(rg.ok).toBe(false); }); it("round-trips: a valid canvas serializes to the same validated shape", () => { const original: Canvas = { version: CANVAS_VERSION, elements: { s1: sticky(), r1: { id: "r1", type: "rect", x: 1, y: 2, w: 30, h: 40, z: 2, fill: "yellow", stroke: null } as CanvasElement, f1: { id: "f1", type: "freehand", x: 0, y: 0, w: 50, h: 50, z: 3, points: [0, 0, 5, 5, 10, 2], strokeWidth: 3 } as CanvasElement, c1: { id: "c1", type: "connector", x: 0, y: 0, w: 0, h: 0, z: 4, from: { elementId: "s1", anchor: "right" }, to: { elementId: "r1", anchor: "left" }, kind: "orthogonal", arrow: true } as CanvasElement } }; const r = validateCanvas(JSON.parse(JSON.stringify(original))); expect(r.ok).toBe(true); if (r.ok) { expect(Object.keys(r.canvas.elements).sort()).toEqual(["c1", "f1", "r1", "s1"]); expect(r.canvas.elements.c1!.type).toBe("connector"); expect((r.canvas.elements.f1! as Extract).points).toEqual([0, 0, 5, 5, 10, 2]); } }); it("emptyCanvas() is valid and version-stamped", () => { expect(validateCanvas(emptyCanvas()).ok).toBe(true); expect(emptyCanvas().version).toBe(CANVAS_VERSION); }); }); describe("element link (T-170)", () => { it("accepts page/task/url links on any element type and round-trips them", () => { for (const link of [ { kind: "page" as const, refId: "p_abc" }, { kind: "task" as const, refId: "t_123" }, { kind: "url" as const, href: "https://example.com/x" } ]) { const r = validateCanvas({ version: CANVAS_VERSION, elements: { s1: { ...sticky(), link } } }); expect(r.ok, JSON.stringify(r)).toBe(true); if (r.ok) expect(r.canvas.elements.s1!.link).toEqual(link); } }); it("treats link as optional — absent stays absent (no null junk persisted)", () => { const r = validateCanvas({ version: CANVAS_VERSION, elements: { s1: sticky() } }); expect(r.ok).toBe(true); if (r.ok) expect("link" in (r.canvas.elements.s1! as object)).toBe(false); }); it("rejects malformed links (bad kind, missing refId, non-http href, oversize)", () => { const bad = (link: unknown) => validateCanvas({ version: CANVAS_VERSION, elements: { s1: { ...sticky(), link } } }); expect(bad({ kind: "task" }).ok).toBe(false); // missing refId expect(bad({ kind: "url" }).ok).toBe(false); // missing href expect(bad({ kind: "url", href: "javascript:alert(1)" }).ok).toBe(false); // not http(s) expect(bad({ kind: "url", href: "ftp://x" }).ok).toBe(false); expect(bad({ kind: "nope" }).ok).toBe(false); // bad kind expect(bad({ kind: "url", href: "https://x/" + "a".repeat(2100) }).ok).toBe(false); // > 2000 expect(bad("just a string").ok).toBe(false); // the happy 2000-char boundary passes expect(bad({ kind: "url", href: "https://x/" + "a".repeat(1990) }).ok).toBe(true); }); });