import { describe, expect, it } from "vitest"; import { acceptElements, cancelDraw, changeScene, commitDraw, createDraw, documentFingerprint, emptyState, MAX_ELEMENTS, prepareDraw, restoreState, } from "../src/store.ts"; const rectangle = (id: string, extra: Record = {}) => ({ id, type: "rectangle", x: 1, y: 2, width: 30, height: 40, ...extra, }); const snapshot = (state: unknown) => ({ type: "custom", customType: "canvas", data: { kind: "snapshot", state }, }); describe("canvas state store", () => { it("validates IDs, bounds, and cross-references after all live elements are known", () => { expect(() => acceptElements([rectangle("same"), rectangle("same")]), ).toThrow("Duplicate"); expect(() => acceptElements([ rectangle("arrow", { type: "arrow", startBinding: { elementId: "later" }, }), rectangle("later"), ]), ).not.toThrow(); expect(() => acceptElements([ rectangle("arrow", { type: "arrow", startBinding: { elementId: "missing" }, }), ]), ).toThrow("Dangling"); expect(() => acceptElements([ rectangle("dead", { isDeleted: true, frameId: "historic" }), ]), ).not.toThrow(); expect(() => acceptElements([rectangle("bad", { constructor: {} })]), ).toThrow("Unsafe"); expect(() => acceptElements( Array.from({ length: MAX_ELEMENTS + 1 }, (_, index) => rectangle(String(index)), ), ), ).toThrow("1000"); }); it("preserves explicit addition IDs and freezes immutable single-owner preparation", () => { const initial = { ...emptyState(), revision: 2, elements: [rectangle("one")], }; const drawn = createDraw(initial, { baseRevision: 2, title: "add", additions: [rectangle("client-id")], }); const draw = drawn.pendingDraw!; expect(draw.additions[0]?.id).toBe("client-id"); expect(drawn.elements).toEqual(initial.elements); const prepared = prepareDraw(drawn, draw.id, "browser-1", draw.additions); expect(prepared.pendingDraw?.preparedAdditions).toEqual(draw.additions); expect(Object.isFrozen(prepared.pendingDraw?.preparedAdditions)).toBe(true); expect(Object.isFrozen(prepared.pendingDraw?.preparedAdditions?.[0])).toBe( true, ); expect(() => prepareDraw(prepared, draw.id, "browser-1", draw.additions), ).toThrow("Stale"); expect(drawn.pendingDraw?.preparedAdditions).toBeNull(); expect(() => prepareDraw(drawn, draw.id, "browser-2", [ { ...draw.additions[0], x: 999 }, ]), ).toThrow("does not match"); }); it("accepts native defaults and safe labels but rejects source tampering", () => { const drawn = createDraw(emptyState(), { baseRevision: 0, title: "labeled", additions: [ rectangle("shape", { text: "Source", strokeColor: "#123456", label: { text: "Hello" }, }), ], }); const draw = drawn.pendingDraw!; const native = [ { id: "shape", type: "rectangle", x: 1, y: 2, width: 30, height: 40, text: "Source", strokeColor: "#123456", seed: 1, version: 1, boundElements: [{ id: "label", type: "text" }], }, { id: "label", type: "text", x: 2, y: 3, text: "Hello", originalText: "Hello", containerId: "shape", }, ]; expect(() => prepareDraw(drawn, draw.id, "browser", native)).not.toThrow(); for (const changes of [ { type: "ellipse" }, { x: 99 }, { text: "tampered" }, { strokeColor: "#000000" }, ]) expect(() => prepareDraw(drawn, draw.id, "browser", [ { ...native[0]!, ...changes }, native[1]!, ]), ).toThrow("does not match"); }); it("commits exact prepared additions and requested-only updates/deletions", () => { const initial = { ...emptyState(), revision: 2, elements: [rectangle("one"), rectangle("gone")], }; const drawn = createDraw(initial, { baseRevision: 2, title: "edit", additions: [rectangle("new")], updates: [{ id: "one", changes: { opacity: 50 } }], deleteIds: ["gone"], }); const draw = drawn.pendingDraw!; const prepared = prepareDraw(drawn, draw.id, "browser", draw.additions); const final = [ rectangle("one", { opacity: 50 }), { ...rectangle("gone"), isDeleted: true }, draw.additions[0]!, ]; const committed = commitDraw(prepared, draw.id, "browser", 2, final); expect(committed.revision).toBe(3); expect(committed.pendingDraw).toBeNull(); expect(() => commitDraw(prepared, draw.id, "browser", 2, [ rectangle("one", { opacity: 50 }), { ...rectangle("gone"), isDeleted: true }, { ...draw.additions[0]!, width: 99 }, ]), ).toThrow("additions changed"); expect(() => commitDraw(prepared, draw.id, "browser", 2, [ rectangle("one", { opacity: 50, x: 90 }), { ...rectangle("gone"), isDeleted: true }, draw.additions[0]!, ]), ).toThrow("unrelated"); }); it("rejects inbound and outbound bound deletion, stale commits, and cancelled jobs", () => { const bound = { ...emptyState(), elements: [ rectangle("parent", { boundElements: [{ id: "child", type: "text" }] }), rectangle("child"), ], }; expect(() => createDraw(bound, { baseRevision: 0, title: "delete", deleteIds: ["parent"], }), ).toThrow("referenced"); expect(() => createDraw(bound, { baseRevision: 0, title: "delete", deleteIds: ["child"], }), ).toThrow("referenced"); const drawn = createDraw( { ...emptyState(), elements: [rectangle("one")] }, { baseRevision: 0, title: "update", updates: [{ id: "one", changes: { opacity: 40 } }], }, ); const prepared = prepareDraw(drawn, drawn.pendingDraw!.id, "browser", []); const cancelled = cancelDraw(prepared, drawn.pendingDraw!.id, "browser"); expect(() => commitDraw(cancelled, drawn.pendingDraw!.id, "browser", 0, [ rectangle("one", { opacity: 40 }), ]), ).toThrow("Stale"); }); it("rejects unsafe Mermaid, requires output namespace, and preserves conversion exactly", () => { expect(() => createDraw(emptyState(), { baseRevision: 0, title: "unsafe", mermaid: { definition: "flowchart TD\nclick A href 'https://evil.test'", x: 0, y: 0, }, }), ).toThrow("unsafe"); const drawn = createDraw(emptyState(), { baseRevision: 0, title: "diagram", mermaid: { definition: "flowchart TD", x: 0, y: 0 }, }); const draw = drawn.pendingDraw!; const generated = [ rectangle(`${draw.id}-mermaid-0`), rectangle(`${draw.id}-mermaid-1`, { frameId: `${draw.id}-mermaid-0` }), ]; expect(() => prepareDraw(drawn, draw.id, "browser", [rectangle("wrong-mermaid-0")]), ).toThrow("Mermaid element ID"); expect(() => prepareDraw(drawn, draw.id, "browser", [])).toThrow( "produced no native", ); const prepared = prepareDraw(drawn, draw.id, "browser", generated); expect(() => commitDraw(prepared, draw.id, "browser", 0, [ { ...generated[0]!, width: 99 }, generated[1]!, ]), ).toThrow("additions changed"); expect( commitDraw(prepared, draw.id, "browser", 0, generated).elements, ).toEqual(generated); }); it("restores elements and revisions while ignoring historic annotation ledgers", () => { const restored = restoreState([ snapshot({ revision: 7, elements: [rectangle("one")], annotations: [{ malformed: true }], pendingDraw: { unsafe: true }, messages: [{ text: "ignored" }], }), ]); expect(restored).toEqual({ ...emptyState(), revision: 7, elements: [rectangle("one")], }); expect(restored).not.toHaveProperty("annotations"); }); it("enforces complete state limit when preparation duplicates transient additions", () => { const text = "x".repeat(16_000); const additions = Array.from({ length: 25 }, (_, index) => rectangle(`new-${index}`, { text }), ); const drawn = createDraw( { ...emptyState(), elements: Array.from({ length: 25 }, (_, index) => rectangle(`old-${index}`, { text }), ), }, { baseRevision: 0, title: "large", additions }, ); expect(() => prepareDraw(drawn, drawn.pendingDraw!.id, "browser", additions), ).toThrow("size limit"); }); it("keeps original order and rejects tombstone additions or canonical ID reuse", () => { const initial = { ...emptyState(), elements: [rectangle("first"), rectangle("second")], }; const drawn = createDraw(initial, { baseRevision: 0, title: "add", additions: [rectangle("third")], }); const draw = drawn.pendingDraw!; const prepared = prepareDraw(drawn, draw.id, "browser", draw.additions); expect(() => commitDraw(prepared, draw.id, "browser", 0, [ draw.additions[0]!, rectangle("first"), rectangle("second"), ]), ).toThrow("order"); expect(() => createDraw(initial, { baseRevision: 0, title: "tombstone", additions: [rectangle("new", { isDeleted: true })], }), ).toThrow("unique IDs"); expect(() => createDraw(initial, { baseRevision: 0, title: "reuse", additions: [rectangle("first")], }), ).toThrow("unique IDs"); }); it("keeps document fingerprint semantics for scene updates", () => { const state = { ...emptyState(), revision: 1, elements: [rectangle("one", { version: 1, updated: 1 })], }; expect( changeScene(state, 1, [rectangle("one", { version: 2, updated: 2 })]), ).toBe(state); expect(documentFingerprint([rectangle("one"), rectangle("two")])).not.toBe( documentFingerprint([rectangle("two"), rectangle("one")]), ); }); });