1import { describe, expect, it } from "vitest";
2import {
3 addElement,
4 bringToFront,
5 CanvasOpError,
6 cloneCanvas,
7 groupElements,
8 moveElement,
9 moveMany,
10 nextZ,
11 rebindGroup,
12 removeElement,
13 ungroupElement,
14 updateElement
15} from "./ops.js";
16import type { Canvas, CanvasElement } from "./schema.js";
17
18const sticky = (id: string, over: Partial<CanvasElement> = {}): CanvasElement =>
19 ({ id, type: "sticky", x: 0, y: 0, w: 100, h: 80, z: 1, text: "hi", color: "yellow", ...over }) as CanvasElement;
20
21function twoCanvas(): Canvas {
22 const c: Canvas = { version: 1, elements: {} };
23 return { ...c, elements: { a: sticky("a", { x: 0, y: 0, z: 1 }), b: sticky("b", { x: 10, y: 10, z: 2 }) } };
24}
25
26describe("canvas ops (T-167)", () => {
27 it("cloneCanvas deep-copies nested arrays/objects (no aliasing)", () => {
28 const c = twoCanvas();
29 const d = cloneCanvas(c);
30 d.elements.a!.x = 999;
31 expect(c.elements.a!.x).toBe(0);
32
33 const c2: Canvas = {
34 version: 1,
35 elements: {
36 f: { id: "f", type: "freehand", x: 0, y: 0, w: 1, h: 1, z: 1, points: [0, 0, 1, 1], strokeWidth: 2 } as CanvasElement,
37 g: { id: "g", type: "group", x: 0, y: 0, w: 1, h: 1, z: 1, children: ["f"] } as CanvasElement
38 }
39 };
40 const d2 = cloneCanvas(c2);
41 (d2.elements.f as Extract<CanvasElement, { type: "freehand" }>).points[0] = 42;
42 expect((c2.elements.f as Extract<CanvasElement, { type: "freehand" }>).points[0]).toBe(0);
43 (d2.elements.g as Extract<CanvasElement, { type: "group" }>).children.push("x");
44 expect((c2.elements.g as Extract<CanvasElement, { type: "group" }>).children).toEqual(["f"]);
45 });
46
47 it("addElement rejects duplicate ids; nextZ is max+1", () => {
48 const c = twoCanvas();
49 expect(nextZ(c)).toBe(3);
50 expect(() => addElement(c, sticky("a"))).toThrow(CanvasOpError);
51 const added = addElement(c, sticky("c", { z: 5 }));
52 expect(added.elements.c).toBeTruthy();
53 expect(c.elements.c).toBeUndefined();
54 });
55
56 it("updateElement merges a patch, preserves id/type, and guards type-specific fields", () => {
57 const c = twoCanvas();
58 const u = updateElement(c, "a", { x: 5, text: "new" });
59 expect(u.elements.a!.x).toBe(5);
60 expect((u.elements.a! as Extract<CanvasElement, { type: "sticky" }>).text).toBe("new");
61 expect(c.elements.a!.x).toBe(0);
62 expect(() => updateElement(c, "missing", { x: 1 })).toThrow(CanvasOpError);
63 const textOnGroup: Canvas = { version: 1, elements: { g: { id: "g", type: "group", x: 0, y: 0, w: 1, h: 1, z: 1, children: [] } as CanvasElement } };
64 expect(() => updateElement(textOnGroup, "g", { text: "nope" } as never)).toThrow(CanvasOpError);
65 });
66
67 it("moveElement translates sticky bounds AND freehand points", () => {
68 const c: Canvas = {
69 version: 1,
70 elements: {
71 s: sticky("s", { x: 0, y: 0 }),
72 f: { id: "f", type: "freehand", x: 0, y: 0, w: 10, h: 10, z: 1, points: [0, 0, 10, 10], strokeWidth: 2 } as CanvasElement
73 }
74 };
75 const moved = moveElement(c, "f", 5, 7);
76 const f = moved.elements.f as Extract<CanvasElement, { type: "freehand" }>;
77 expect(f.x).toBe(5);
78 expect(f.y).toBe(7);
79 expect(f.points).toEqual([5, 7, 15, 17]);
80 const ms = moveElement(c, "s", 1, 2);
81 expect(ms.elements.s).toMatchObject({ x: 1, y: 2 });
82 });
83
84 it("moveMany moves each selected element", () => {
85 const c = twoCanvas();
86 const m = moveMany(c, ["a", "b"], 3, 4);
87 expect(m.elements.a).toMatchObject({ x: 3, y: 4 });
88 expect(m.elements.b).toMatchObject({ x: 13, y: 14 });
89 });
90
91 it("removeElement also removes connectors that reference it and prunes groups", () => {
92 const c: Canvas = {
93 version: 1,
94 elements: {
95 a: sticky("a"),
96 b: sticky("b"),
97 c1: {
98 id: "c1", type: "connector", x: 0, y: 0, w: 0, h: 0, z: 1,
99 from: { elementId: "a", anchor: "right" }, to: { elementId: "b", anchor: "left" }, kind: "straight", arrow: false
100 } as CanvasElement,
101 g: { id: "g", type: "group", x: 0, y: 0, w: 1, h: 1, z: 1, children: ["a", "b"] } as CanvasElement
102 }
103 };
104 const r = removeElement(c, "a");
105 expect(r.elements.a).toBeUndefined();
106 expect(r.elements.c1).toBeUndefined();
107 expect((r.elements.g as Extract<CanvasElement, { type: "group" }>).children).toEqual(["b"]);
108 });
109
110 it("groupElements creates a group with union bounds; rejects <2 children or nested groups", () => {
111 const c = twoCanvas();
112 const g = groupElements(c, { id: "g" }, ["a", "b"]);
113 const grp = g.elements.g as Extract<CanvasElement, { type: "group" }>;
114 expect(grp.children).toEqual(["a", "b"]);
115 expect({ x: grp.x, y: grp.y, w: grp.w, h: grp.h }).toEqual({ x: 0, y: 0, w: 110, h: 90 });
116 expect(() => groupElements(c, { id: "g2" }, ["a"])).toThrow(CanvasOpError);
117 const withGroup: Canvas = { ...c, elements: { ...c.elements, g: grp } };
118 expect(() => groupElements(withGroup, { id: "g3" }, ["a", "g"])).toThrow(CanvasOpError);
119 });
120
121 it("rebindGroup recomputes bounds after children move; ungroupElement removes the container only", () => {
122 const c = twoCanvas();
123 const g = groupElements(c, { id: "g" }, ["a", "b"]);
124 const moved = moveElement(g, "b", 100, 0);
125 const rebound = rebindGroup(moved, "g");
126 const grp = rebound.elements.g as Extract<CanvasElement, { type: "group" }>;
127
128 expect({ x: grp.x, y: grp.y, w: grp.w, h: grp.h }).toEqual({ x: 0, y: 0, w: 210, h: 90 });
129 const un = ungroupElement(rebound, "g");
130 expect(un.elements.g).toBeUndefined();
131 expect(un.elements.a).toBeTruthy();
132 expect(un.elements.b).toBeTruthy();
133 });
134
135 it("bringToFront re-z-orders in the given order", () => {
136 const c = twoCanvas();
137 const f = bringToFront(c, ["a", "b"]);
138 expect(f.elements.a!.z).toBe(3);
139 expect(f.elements.b!.z).toBe(4);
140 });
141});
142
143describe("element link ops (T-170)", () => {
144 it("updateElement sets a link on any type (base field) and null clears it", () => {
145 let c = addElement({ version: 1, elements: {} }, sticky("a"));
146 c = updateElement(c, "a", { link: { kind: "page", refId: "p1" } });
147 expect(c.elements.a!.link).toEqual({ kind: "page", refId: "p1" });
148 c = updateElement(c, "a", { link: null });
149 expect("link" in (c.elements.a! as object)).toBe(false);
150 });
151
152 it("cloneCanvas deep-copies the link (mutating the clone does not touch the source)", () => {
153 const src = addElement({ version: 1, elements: {} }, { ...sticky("a"), link: { kind: "url", href: "https://a" } } as CanvasElement);
154 const copy = cloneCanvas(src);
155 (copy.elements.a!.link as { href: string }).href = "https://mutated";
156 expect(src.elements.a!.link).toEqual({ kind: "url", href: "https://a" });
157 });
158});
159