1import { describe, expect, it } from "vitest";
2import {
3 CANVAS_VERSION,
4 emptyCanvas,
5 isCanvas,
6 validateCanvas,
7 type Canvas,
8 type CanvasElement
9} from "./schema.js";
10
11const sticky = (over: Partial<CanvasElement> = {}): CanvasElement =>
12 ({ id: "s1", type: "sticky", x: 0, y: 0, w: 100, h: 80, z: 1, text: "hi", color: "yellow", ...over }) as CanvasElement;
13
14describe("canvas schema (T-167)", () => {
15 it("accepts a valid minimal canvas", () => {
16 const c: Canvas = { version: CANVAS_VERSION, elements: { s1: sticky() } };
17 const r = validateCanvas(c);
18 expect(r.ok).toBe(true);
19 if (r.ok) {
20 expect(r.canvas.elements.s1!.type).toBe("sticky");
21 expect(isCanvas(r.canvas)).toBe(true);
22 }
23 });
24
25 it("rejects an unknown version (→ renderer read-only banner)", () => {
26 const r = validateCanvas({ version: 99, elements: {} });
27 expect(r.ok).toBe(false);
28 if (!r.ok) expect(r.errors[0]).toMatch(/unsupported canvas version/);
29 });
30
31 it("rejects a non-object / non-map canvas", () => {
32 expect(validateCanvas("nope").ok).toBe(false);
33 expect(validateCanvas({ version: CANVAS_VERSION, elements: [1, 2] }).ok).toBe(false);
34 expect(validateCanvas({ version: CANVAS_VERSION }).ok).toBe(false);
35 });
36
37 it("rejects bad element ids, non-finite numbers, negative size, non-int z", () => {
38 const badId = validateCanvas({ version: CANVAS_VERSION, elements: { "bad id!": sticky() } });
39 expect(badId.ok).toBe(false);
40 const badNum = validateCanvas({ version: CANVAS_VERSION, elements: { s1: sticky({ x: NaN }) } });
41 expect(badNum.ok).toBe(false);
42 const neg = validateCanvas({ version: CANVAS_VERSION, elements: { s1: sticky({ w: -5 }) } });
43 expect(neg.ok).toBe(false);
44 const badZ = validateCanvas({ version: CANVAS_VERSION, elements: { s1: sticky({ z: 1.5 }) } });
45 expect(badZ.ok).toBe(false);
46 });
47
48 it("enforces per-type fields (text length, fontSize range, color palette, points parity, strokeWidth)", () => {
49 const longText = validateCanvas({ version: CANVAS_VERSION, elements: { s1: sticky({ text: "x".repeat(20_001) }) } });
50 expect(longText.ok).toBe(false);
51 const badColor = validateCanvas({ version: CANVAS_VERSION, elements: { s1: sticky({ color: "neon" }) } });
52 expect(badColor.ok).toBe(false);
53
54 const textEl: CanvasElement = { id: "t1", type: "text", x: 0, y: 0, w: 10, h: 10, z: 1, text: "a", fontSize: 4 };
55 expect(validateCanvas({ version: CANVAS_VERSION, elements: { t1: textEl } }).ok).toBe(false);
56
57 const fh: CanvasElement = { id: "f1", type: "freehand", x: 0, y: 0, w: 10, h: 10, z: 1, points: [0, 0, 1], strokeWidth: 2 };
58 expect(validateCanvas({ version: CANVAS_VERSION, elements: { f1: fh } }).ok).toBe(false);
59
60 const fh2: CanvasElement = { id: "f1", type: "freehand", x: 0, y: 0, w: 10, h: 10, z: 1, points: [0, 0, 1, 1], strokeWidth: 41 };
61 expect(validateCanvas({ version: CANVAS_VERSION, elements: { f1: fh2 } }).ok).toBe(false);
62 });
63
64 it("enforces referential integrity (connector endpoints + group children)", () => {
65 const conn: CanvasElement = {
66 id: "c1", type: "connector", x: 0, y: 0, w: 0, h: 0, z: 1,
67 from: { elementId: "missing", anchor: "right" }, to: { elementId: "s1", anchor: "left" },
68 kind: "straight", arrow: true
69 };
70 const r = validateCanvas({ version: CANVAS_VERSION, elements: { s1: sticky(), c1: conn } });
71 expect(r.ok).toBe(false);
72 if (!r.ok) expect(r.errors.join(" ")).toMatch(/missing/);
73
74 const grp: CanvasElement = { id: "g1", type: "group", x: 0, y: 0, w: 10, h: 10, z: 1, children: ["s1", "ghost"] };
75 const rg = validateCanvas({ version: CANVAS_VERSION, elements: { s1: sticky(), g1: grp } });
76 expect(rg.ok).toBe(false);
77 });
78
79 it("round-trips: a valid canvas serializes to the same validated shape", () => {
80 const original: Canvas = {
81 version: CANVAS_VERSION,
82 elements: {
83 s1: sticky(),
84 r1: { id: "r1", type: "rect", x: 1, y: 2, w: 30, h: 40, z: 2, fill: "yellow", stroke: null } as CanvasElement,
85 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,
86 c1: {
87 id: "c1", type: "connector", x: 0, y: 0, w: 0, h: 0, z: 4,
88 from: { elementId: "s1", anchor: "right" }, to: { elementId: "r1", anchor: "left" }, kind: "orthogonal", arrow: true
89 } as CanvasElement
90 }
91 };
92 const r = validateCanvas(JSON.parse(JSON.stringify(original)));
93 expect(r.ok).toBe(true);
94 if (r.ok) {
95 expect(Object.keys(r.canvas.elements).sort()).toEqual(["c1", "f1", "r1", "s1"]);
96 expect(r.canvas.elements.c1!.type).toBe("connector");
97 expect((r.canvas.elements.f1! as Extract<CanvasElement, { type: "freehand" }>).points).toEqual([0, 0, 5, 5, 10, 2]);
98 }
99 });
100
101 it("emptyCanvas() is valid and version-stamped", () => {
102 expect(validateCanvas(emptyCanvas()).ok).toBe(true);
103 expect(emptyCanvas().version).toBe(CANVAS_VERSION);
104 });
105});
106
107describe("element link (T-170)", () => {
108 it("accepts page/task/url links on any element type and round-trips them", () => {
109 for (const link of [
110 { kind: "page" as const, refId: "p_abc" },
111 { kind: "task" as const, refId: "t_123" },
112 { kind: "url" as const, href: "https://example.com/x" }
113 ]) {
114 const r = validateCanvas({ version: CANVAS_VERSION, elements: { s1: { ...sticky(), link } } });
115 expect(r.ok, JSON.stringify(r)).toBe(true);
116 if (r.ok) expect(r.canvas.elements.s1!.link).toEqual(link);
117 }
118 });
119
120 it("treats link as optional — absent stays absent (no null junk persisted)", () => {
121 const r = validateCanvas({ version: CANVAS_VERSION, elements: { s1: sticky() } });
122 expect(r.ok).toBe(true);
123 if (r.ok) expect("link" in (r.canvas.elements.s1! as object)).toBe(false);
124 });
125
126 it("rejects malformed links (bad kind, missing refId, non-http href, oversize)", () => {
127 const bad = (link: unknown) => validateCanvas({ version: CANVAS_VERSION, elements: { s1: { ...sticky(), link } } });
128 expect(bad({ kind: "task" }).ok).toBe(false);
129 expect(bad({ kind: "url" }).ok).toBe(false);
130 expect(bad({ kind: "url", href: "javascript:alert(1)" }).ok).toBe(false);
131 expect(bad({ kind: "url", href: "ftp://x" }).ok).toBe(false);
132 expect(bad({ kind: "nope" }).ok).toBe(false);
133 expect(bad({ kind: "url", href: "https://x/" + "a".repeat(2100) }).ok).toBe(false);
134 expect(bad("just a string").ok).toBe(false);
135
136 expect(bad({ kind: "url", href: "https://x/" + "a".repeat(1990) }).ok).toBe(true);
137 });
138});
139