import { describe, expect, it } from "vitest"; import { addElement, bringToFront, CanvasOpError, cloneCanvas, groupElements, moveElement, moveMany, nextZ, rebindGroup, removeElement, ungroupElement, updateElement } from "./ops.js"; import type { Canvas, CanvasElement } from "./schema.js"; const sticky = (id: string, over: Partial = {}): CanvasElement => ({ id, type: "sticky", x: 0, y: 0, w: 100, h: 80, z: 1, text: "hi", color: "yellow", ...over }) as CanvasElement; function twoCanvas(): Canvas { const c: Canvas = { version: 1, elements: {} }; return { ...c, elements: { a: sticky("a", { x: 0, y: 0, z: 1 }), b: sticky("b", { x: 10, y: 10, z: 2 }) } }; } describe("canvas ops (T-167)", () => { it("cloneCanvas deep-copies nested arrays/objects (no aliasing)", () => { const c = twoCanvas(); const d = cloneCanvas(c); d.elements.a!.x = 999; expect(c.elements.a!.x).toBe(0); // freehand points + group children + connector endpoints are copied const c2: Canvas = { version: 1, elements: { f: { id: "f", type: "freehand", x: 0, y: 0, w: 1, h: 1, z: 1, points: [0, 0, 1, 1], strokeWidth: 2 } as CanvasElement, g: { id: "g", type: "group", x: 0, y: 0, w: 1, h: 1, z: 1, children: ["f"] } as CanvasElement } }; const d2 = cloneCanvas(c2); (d2.elements.f as Extract).points[0] = 42; expect((c2.elements.f as Extract).points[0]).toBe(0); (d2.elements.g as Extract).children.push("x"); expect((c2.elements.g as Extract).children).toEqual(["f"]); }); it("addElement rejects duplicate ids; nextZ is max+1", () => { const c = twoCanvas(); expect(nextZ(c)).toBe(3); expect(() => addElement(c, sticky("a"))).toThrow(CanvasOpError); const added = addElement(c, sticky("c", { z: 5 })); expect(added.elements.c).toBeTruthy(); expect(c.elements.c).toBeUndefined(); // input untouched }); it("updateElement merges a patch, preserves id/type, and guards type-specific fields", () => { const c = twoCanvas(); const u = updateElement(c, "a", { x: 5, text: "new" }); expect(u.elements.a!.x).toBe(5); expect((u.elements.a! as Extract).text).toBe("new"); expect(c.elements.a!.x).toBe(0); // immutable expect(() => updateElement(c, "missing", { x: 1 })).toThrow(CanvasOpError); const textOnGroup: Canvas = { version: 1, elements: { g: { id: "g", type: "group", x: 0, y: 0, w: 1, h: 1, z: 1, children: [] } as CanvasElement } }; expect(() => updateElement(textOnGroup, "g", { text: "nope" } as never)).toThrow(CanvasOpError); }); it("moveElement translates sticky bounds AND freehand points", () => { const c: Canvas = { version: 1, elements: { s: sticky("s", { x: 0, y: 0 }), f: { id: "f", type: "freehand", x: 0, y: 0, w: 10, h: 10, z: 1, points: [0, 0, 10, 10], strokeWidth: 2 } as CanvasElement } }; const moved = moveElement(c, "f", 5, 7); const f = moved.elements.f as Extract; expect(f.x).toBe(5); expect(f.y).toBe(7); expect(f.points).toEqual([5, 7, 15, 17]); const ms = moveElement(c, "s", 1, 2); expect(ms.elements.s).toMatchObject({ x: 1, y: 2 }); }); it("moveMany moves each selected element", () => { const c = twoCanvas(); const m = moveMany(c, ["a", "b"], 3, 4); expect(m.elements.a).toMatchObject({ x: 3, y: 4 }); expect(m.elements.b).toMatchObject({ x: 13, y: 14 }); }); it("removeElement also removes connectors that reference it and prunes groups", () => { const c: Canvas = { version: 1, elements: { a: sticky("a"), b: sticky("b"), c1: { id: "c1", type: "connector", x: 0, y: 0, w: 0, h: 0, z: 1, from: { elementId: "a", anchor: "right" }, to: { elementId: "b", anchor: "left" }, kind: "straight", arrow: false } as CanvasElement, g: { id: "g", type: "group", x: 0, y: 0, w: 1, h: 1, z: 1, children: ["a", "b"] } as CanvasElement } }; const r = removeElement(c, "a"); expect(r.elements.a).toBeUndefined(); expect(r.elements.c1).toBeUndefined(); // dangling connector removed expect((r.elements.g as Extract).children).toEqual(["b"]); }); it("groupElements creates a group with union bounds; rejects <2 children or nested groups", () => { const c = twoCanvas(); const g = groupElements(c, { id: "g" }, ["a", "b"]); const grp = g.elements.g as Extract; expect(grp.children).toEqual(["a", "b"]); expect({ x: grp.x, y: grp.y, w: grp.w, h: grp.h }).toEqual({ x: 0, y: 0, w: 110, h: 90 }); expect(() => groupElements(c, { id: "g2" }, ["a"])).toThrow(CanvasOpError); const withGroup: Canvas = { ...c, elements: { ...c.elements, g: grp } }; expect(() => groupElements(withGroup, { id: "g3" }, ["a", "g"])).toThrow(CanvasOpError); }); it("rebindGroup recomputes bounds after children move; ungroupElement removes the container only", () => { const c = twoCanvas(); const g = groupElements(c, { id: "g" }, ["a", "b"]); const moved = moveElement(g, "b", 100, 0); const rebound = rebindGroup(moved, "g"); const grp = rebound.elements.g as Extract; // a: x0..100, y0..80; b: x110..210, y10..90 → union 0..210 × 0..90 expect({ x: grp.x, y: grp.y, w: grp.w, h: grp.h }).toEqual({ x: 0, y: 0, w: 210, h: 90 }); const un = ungroupElement(rebound, "g"); expect(un.elements.g).toBeUndefined(); expect(un.elements.a).toBeTruthy(); expect(un.elements.b).toBeTruthy(); }); it("bringToFront re-z-orders in the given order", () => { const c = twoCanvas(); const f = bringToFront(c, ["a", "b"]); expect(f.elements.a!.z).toBe(3); expect(f.elements.b!.z).toBe(4); }); }); describe("element link ops (T-170)", () => { it("updateElement sets a link on any type (base field) and null clears it", () => { let c = addElement({ version: 1, elements: {} }, sticky("a")); c = updateElement(c, "a", { link: { kind: "page", refId: "p1" } }); expect(c.elements.a!.link).toEqual({ kind: "page", refId: "p1" }); c = updateElement(c, "a", { link: null }); expect("link" in (c.elements.a! as object)).toBe(false); }); it("cloneCanvas deep-copies the link (mutating the clone does not touch the source)", () => { const src = addElement({ version: 1, elements: {} }, { ...sticky("a"), link: { kind: "url", href: "https://a" } } as CanvasElement); const copy = cloneCanvas(src); (copy.elements.a!.link as { href: string }).href = "https://mutated"; expect(src.elements.a!.link).toEqual({ kind: "url", href: "https://a" }); }); });