import { describe, it, expect, vi } from "vitest"; import { buildOption } from "../src/graphic/build"; import { createCollector } from "../src/graphic/collector"; const flushMicrotasks = () => new Promise((resolve) => queueMicrotask(() => resolve())); function getRootGraphicElement(option: unknown): any { const root = (option as any).graphic?.elements?.[0] as any; if (!root) { throw new Error("Expected root graphic element to exist."); } return root; } describe("graphic", () => { it("builds graphic option with ordered children and replace root", () => { const nodes = [ { id: "rect", type: "rect", parentId: null, props: { x: 10, y: 20, width: 30, height: 40, progressive: 8, textContent: { type: "text", style: { text: "label" } }, textConfig: { position: "inside" }, fill: "#f00", }, handlers: {}, order: 1, sourceId: 1, }, { id: "text", type: "text", parentId: null, props: { x: 2, y: 4, width: 120, overflow: "truncate", ellipsis: "...", text: "Hi", textFill: "#000", }, handlers: {}, order: 0, sourceId: 2, }, ]; const option = buildOption(nodes, "root"); const root = getRootGraphicElement(option); expect(root.id).toBe("root"); expect(root.$action).toBe("replace"); const [text, rect] = root.children as any[]; expect(text.type).toBe("text"); expect(text.x).toBe(2); expect(text.y).toBe(4); expect(text.style).toMatchObject({ text: "Hi", textFill: "#000", width: 120, overflow: "truncate", ellipsis: "...", }); expect(text.width).toBeUndefined(); expect(rect.type).toBe("rect"); expect(rect.progressive).toBe(8); expect(rect.textContent).toMatchObject({ type: "text" }); expect(rect.textConfig).toMatchObject({ position: "inside" }); expect(rect.shape).toMatchObject({ x: 10, y: 20, width: 30, height: 40 }); expect(rect.style).toMatchObject({ fill: "#f00" }); expect(root.children.some((child: any) => child.id === "rect")).toBe(true); }); it("keeps user info as-is and maps handlers to graphic onxxx props", () => { const nodes = [ { id: "hit", type: "circle", parentId: null, props: { cx: 1, cy: 2, r: 3, info: { name: "marker" }, }, handlers: { onClick: () => void 0 }, order: 0, sourceId: 1, }, ]; const option = buildOption(nodes, "root"); const root = getRootGraphicElement(option); const child = root.children?.[0] as Record | undefined; if (!child) { throw new Error("Expected child graphic element to exist."); } const info = child.info as Record; expect(info).toMatchObject({ name: "marker" }); expect(typeof child.onclick).toBe("function"); }); it("builds image/group options and covers info fallback branches", () => { const nodes = [ { id: "group", type: "group", parentId: null, props: { info: "root", }, handlers: {}, order: 0, sourceId: 1, }, { id: "img", type: "image", parentId: "group", props: { x: 1, y: 2, width: 3, height: 4, image: "https://example.com/a.png", styleTransition: "all", }, handlers: {}, order: 0, sourceId: 2, }, { id: "img-hit", type: "image", parentId: "group", props: { image: "https://example.com/b.png", }, handlers: { onClick: () => void 0 }, order: 2, sourceId: 7, }, { id: "line", type: "line", parentId: "group", props: { x1: 0, y1: 0, x2: 10, y2: 10, shapeTransition: "shape", info: 42, }, handlers: {}, order: 1, sourceId: 3, }, { id: "custom", type: "custom", parentId: null, props: { info: { level: "custom" } }, handlers: {}, order: 1, sourceId: 4, }, { id: "txt", type: "text", parentId: null, props: { text: "hello", }, handlers: { onMouseover: () => void 0 }, order: 4, sourceId: 8, }, { id: "dup", type: "rect", parentId: null, props: { x: 0, y: 0, width: 1, height: 1, }, handlers: {}, order: 2, sourceId: 5, }, { id: "dup", type: "rect", parentId: null, props: { x: 2, y: 2, width: 1, height: 1, }, handlers: {}, order: 3, sourceId: 6, }, ]; const option = buildOption(nodes, "root"); const root = getRootGraphicElement(option); const group = root.children.find((item: any) => item.id === "group"); if (!group) { throw new Error("Expected group node to exist."); } const image = group.children.find((item: any) => item.id === "img"); const line = group.children.find((item: any) => item.id === "line"); const imageHit = group.children.find((item: any) => item.id === "img-hit"); const custom = root.children.find((item: any) => item.id === "custom"); const text = root.children.find((item: any) => item.id === "txt"); expect(group.info).toBe("root"); expect(image.style).toMatchObject({ image: "https://example.com/a.png", transition: "all", }); expect(line.shape).toMatchObject({ x1: 0, y1: 0, x2: 10, y2: 10, transition: "shape", }); expect(line.info).toBe(42); expect(imageHit.info).toBeUndefined(); expect(custom.info).toMatchObject({ level: "custom" }); expect(text.info).toBeUndefined(); expect(typeof imageHit.onclick).toBe("function"); expect(typeof text.onmouseover).toBe("function"); expect(custom.shape).toBeUndefined(); expect(root.children.filter((item: any) => item.id === "dup")).toHaveLength(2); }); it("coalesces flushes and warns on duplicate ids", async () => { const onFlush = vi.fn(); const collector = createCollector({ onFlush }); const warnSpy = vi.spyOn(console, "warn").mockImplementation(() => undefined); try { collector.register({ id: "dup", type: "rect", parentId: null, props: {}, handlers: {}, sourceId: 1, }); collector.register({ id: "dup", type: "rect", parentId: null, props: {}, handlers: {}, sourceId: 2, }); expect(warnSpy).toHaveBeenCalledTimes(1); expect(onFlush).toHaveBeenCalledTimes(0); await flushMicrotasks(); expect(onFlush).toHaveBeenCalledTimes(1); collector.unregister("dup"); await flushMicrotasks(); expect(onFlush).toHaveBeenCalledTimes(2); } finally { warnSpy.mockRestore(); } }); it("forwards collector.warn without options", () => { const collector = createCollector({ onFlush: () => void 0 }); const warnSpy = vi.spyOn(console, "warn").mockImplementation(() => undefined); try { collector.warn("plain warning"); expect(warnSpy).toHaveBeenCalledTimes(1); expect(String(warnSpy.mock.calls[0][0])).toContain("plain warning"); } finally { warnSpy.mockRestore(); } }); it("exposes current collector nodes", () => { const collector = createCollector({ onFlush: () => void 0, }); collector.register({ id: "a", type: "rect", parentId: null, props: {}, handlers: {}, sourceId: 1, }); collector.register({ id: "b", type: "text", parentId: "a", props: {}, handlers: {}, sourceId: 2, }); const nodes = Array.from(collector.getNodes()); expect(nodes.some((item) => item.id === "a")).toBe(true); expect(nodes.find((item) => item.id === "b")?.parentId).toBe("a"); }); it("ignores unregister from mismatched source and removes with matched source", () => { const collector = createCollector({ onFlush: () => void 0, }); collector.register({ id: "x", type: "rect", parentId: null, props: {}, handlers: {}, sourceId: 1, }); collector.unregister("x", 2); expect(Array.from(collector.getNodes()).some((item) => item.id === "x")).toBe(true); collector.unregister("missing", 1); expect(Array.from(collector.getNodes()).some((item) => item.id === "x")).toBe(true); collector.unregister("x", 1); expect(Array.from(collector.getNodes()).some((item) => item.id === "x")).toBe(false); }); it("does not mark duplicate when same id appears across different passes", () => { const collector = createCollector({ onFlush: () => void 0, }); const warnSpy = vi.spyOn(console, "warn").mockImplementation(() => undefined); try { collector.beginPass(); collector.register({ id: "node", type: "rect", parentId: null, props: {}, handlers: {}, sourceId: 1, }); collector.beginPass(); collector.register({ id: "node", type: "rect", parentId: null, props: {}, handlers: {}, sourceId: 2, }); expect(warnSpy).not.toHaveBeenCalled(); } finally { warnSpy.mockRestore(); } }); it("skips pending flush callback and ignores operations after dispose", async () => { const onFlush = vi.fn(); const collector = createCollector({ onFlush, }); collector.register({ id: "node", type: "rect", parentId: null, props: {}, handlers: {}, sourceId: 1, }); collector.dispose(); await flushMicrotasks(); expect(onFlush).toHaveBeenCalledTimes(0); collector.register({ id: "after-dispose", type: "rect", parentId: null, props: {}, handlers: {}, sourceId: 2, }); collector.unregister("node"); await flushMicrotasks(); expect(Array.from(collector.getNodes())).toEqual([]); }); it("accepts null, bigint, and symbol values", async () => { const onFlush = vi.fn(); const collector = createCollector({ onFlush, }); const onClick = () => void 0; const marker = Symbol("marker"); collector.register({ id: "typed-node", type: "rect", parentId: null, props: { nullable: null, amount: 10n, marker, enabled: true, archived: false, nested: { a: null, b: 20n, c: marker, }, list: [null, 30n, marker], }, handlers: { onClick, }, order: 0, sourceId: 1, }); await flushMicrotasks(); expect(onFlush).toHaveBeenCalledTimes(1); collector.register({ id: "typed-node", type: "rect", parentId: null, props: { nullable: null, amount: 10n, marker, enabled: true, archived: false, nested: { a: null, b: 20n, c: marker, }, list: [null, 30n, marker], }, handlers: { onClick, }, order: 0, sourceId: 1, }); await flushMicrotasks(); expect(onFlush).toHaveBeenCalledTimes(2); }); });