import { describe, it, expect, beforeEach, vi } from "vitest"; import { defineComponent, h, nextTick, provide, ref, shallowRef } from "vue"; import { render } from "./helpers/testing"; import { init, enqueueChart, resetECharts, type ChartStub, } from "./helpers/mock"; import type { UpdateOptions } from "../src/types"; import { withConsoleWarn } from "./helpers/dom"; import ECharts, { UPDATE_OPTIONS_KEY } from "../src/ECharts"; import { renderChart } from "./helpers/renderChart"; let chartStub: ChartStub; beforeEach(() => { resetECharts(); chartStub = enqueueChart(); }); describe("ECharts component", () => { it("initializes and reacts to reactive props", async () => { const option = ref({ title: { text: "coffee" } }); const group = ref("group-a"); const exposed = shallowRef(); const screen = renderChart( () => ({ option: option.value, group: group.value }), exposed, ); await nextTick(); expect(init).toHaveBeenCalledTimes(1); const [rootEl, theme, initOptions] = init.mock.calls[0]; expect(rootEl).toBeInstanceOf(HTMLElement); expect(theme).toBeNull(); expect(initOptions).toBeUndefined(); expect(chartStub.setOption).toHaveBeenCalledTimes(1); expect(chartStub.setOption.mock.calls[0][0]).toMatchObject({ title: { text: "coffee" }, }); expect(chartStub.group).toBe("group-a"); option.value = { title: { text: "latte" } }; await nextTick(); expect(chartStub.setOption).toHaveBeenCalledTimes(2); expect(chartStub.setOption.mock.calls[1][0]).toMatchObject({ title: { text: "latte" }, }); group.value = "group-b"; await nextTick(); expect(chartStub.group).toBe("group-b"); screen.unmount(); await nextTick(); expect(chartStub.dispose).toHaveBeenCalledTimes(1); }); it("exposes setOption for manual updates", async () => { const optionRef = ref(); const exposed = shallowRef(); renderChart( () => ({ option: optionRef.value, manualUpdate: true }), exposed, ); await nextTick(); expect(typeof exposed.value?.setOption).toBe("function"); const manualOption = { series: [{ type: "bar", data: [1, 2, 3] }] }; exposed.value.setOption(manualOption); expect(chartStub.setOption).toHaveBeenCalledTimes(2); expect(chartStub.setOption.mock.calls[1][0]).toMatchObject(manualOption); expect(chartStub.setOption.mock.calls[1][1]).toEqual({}); }); it("ignores setOption when manual-update is false", async () => { const option = ref({ title: { text: "initial" } }); const exposed = shallowRef(); renderChart(() => ({ option: option.value }), exposed); await nextTick(); const initialCalls = chartStub.setOption.mock.calls.length; withConsoleWarn((warnSpy) => { exposed.value.setOption({ title: { text: "ignored" } }, true); expect(chartStub.setOption).toHaveBeenCalledTimes(initialCalls); expect(warnSpy).toHaveBeenCalledWith( expect.stringContaining("[vue-echarts] setOption is only available"), ); }); }); it("passes theme and initOptions props and reacts to theme changes", async () => { const option = ref({ title: { text: "brew" } }); const theme = ref("dark"); const initOptions = ref({ renderer: "svg" }); const exposed = shallowRef(); renderChart( () => ({ option: option.value, theme: theme.value, initOptions: initOptions.value, }), exposed, ); await nextTick(); const [rootEl, passedTheme, passedInit] = init.mock.calls[0]; expect(rootEl).toBeInstanceOf(HTMLElement); expect(passedTheme).toBe("dark"); expect(passedInit).toEqual({ renderer: "svg" }); const currentStub = chartStub; theme.value = { palette: ["#fff"] } as any; await nextTick(); expect(currentStub.setTheme).toHaveBeenCalledWith({ palette: ["#fff"] }); }); it("re-initializes when initOptions change", async () => { const option = ref({ title: { text: "coffee" } }); const initOptions = ref({ useDirtyRect: true }); const exposed = shallowRef(); renderChart( () => ({ option: option.value, initOptions: initOptions.value }), exposed, ); await nextTick(); const firstStub = chartStub; const secondStub = enqueueChart(); chartStub = secondStub; initOptions.value = { useDirtyRect: false }; await nextTick(); expect(firstStub.dispose).toHaveBeenCalledTimes(1); expect(init).toHaveBeenCalledTimes(2); expect(secondStub.setOption).toHaveBeenCalledTimes(1); expect(secondStub.setOption.mock.calls[0][0]).toMatchObject({ title: { text: "coffee" }, }); }); it("passes updateOptions when provided", async () => { const option = ref({ title: { text: "first" } }); const updateOptions = ref({ notMerge: true, replaceMerge: ["series"] }); const exposed = shallowRef(); renderChart( () => ({ option: option.value, updateOptions: updateOptions.value }), exposed, ); await nextTick(); expect(chartStub.setOption.mock.calls[0][1]).toBe(updateOptions.value); chartStub.setOption.mockClear(); option.value = { title: { text: "second" } }; await nextTick(); expect(chartStub.setOption.mock.calls[0][1]).toBe(updateOptions.value); }); it("switches between manual and reactive updates", async () => { const option = ref({ title: { text: "initial" } }); const manualUpdate = ref(true); const exposed = shallowRef(); renderChart( () => ({ option: option.value, manualUpdate: manualUpdate.value, }), exposed, ); await nextTick(); expect(chartStub.setOption).toHaveBeenCalledTimes(1); option.value = { title: { text: "manual" } }; await nextTick(); expect(chartStub.setOption).toHaveBeenCalledTimes(1); manualUpdate.value = false; await nextTick(); option.value = { title: { text: "reactive" } }; await nextTick(); expect(chartStub.setOption).toHaveBeenCalledTimes(2); expect(chartStub.setOption.mock.calls[1][0]).toMatchObject({ title: { text: "reactive" }, }); }); it("uses injected updateOptions defaults when not provided via props", async () => { const option = ref({ series: [{ type: "bar", data: [1, 2] }] }); const defaults = ref({ lazyUpdate: true, replaceMerge: ["dataset"], }); const exposed = shallowRef(); const Root = defineComponent({ setup() { provide(UPDATE_OPTIONS_KEY, () => defaults.value); return () => h(ECharts, { option: option.value, ref: (value: unknown) => { exposed.value = value; }, }); }, }); render(Root); await nextTick(); expect(chartStub.setOption.mock.calls[0][1]).toEqual({ lazyUpdate: true, replaceMerge: ["dataset"], }); chartStub.setOption.mockClear(); defaults.value = { notMerge: true }; option.value = { series: [{ type: "line", data: [3, 4] }] }; await nextTick(); expect(chartStub.setOption.mock.calls[0][1]).toEqual({ notMerge: true }); }); it("handles manual setOption when chart instance is missing", async () => { const optionRef = ref({ title: { text: "initial" } }); const exposed = shallowRef(); renderChart( () => ({ option: optionRef.value, manualUpdate: true }), exposed, ); await nextTick(); const replacement = enqueueChart(); const initCallsBefore = init.mock.calls.length; exposed.value.chart.value = undefined; await nextTick(); const manualOption = { title: { text: "rehydrate" } }; exposed.value.setOption(manualOption); expect(init.mock.calls.length).toBe(initCallsBefore); expect(replacement.setOption).not.toHaveBeenCalled(); expect(exposed.value.chart.value).toBeUndefined(); }); it("ignores falsy reactive options", async () => { const option = ref({ title: { text: "present" } }); const exposed = shallowRef(); renderChart(() => ({ option: option.value }), exposed); await nextTick(); const replacementStub = chartStub; expect(replacementStub.setOption.mock.calls.length).toBeGreaterThan(0); replacementStub.setOption.mockClear(); option.value = undefined as any; await nextTick(); await nextTick(); expect(replacementStub.setOption).not.toHaveBeenCalled(); }); it("disposes chart on unmount when root element is unavailable", async () => { const option = ref({ title: { text: "cleanup" } }); const exposed = shallowRef(); const screen = renderChart(() => ({ option: option.value }), exposed); await nextTick(); chartStub.dispose.mockClear(); exposed.value.root.value = undefined; screen.unmount(); await nextTick(); expect(chartStub.dispose).toHaveBeenCalledTimes(1); }); it("shows and hides loading based on props", async () => { const option = ref({}); const loading = ref(true); const loadingOptions = ref({ text: "Loading" }); const exposed = shallowRef(); renderChart( () => ({ option: option.value, loading: loading.value, loadingOptions: loadingOptions.value, }), exposed, ); await nextTick(); expect(chartStub.showLoading).toHaveBeenCalledWith( expect.objectContaining({ text: "Loading" }), ); loading.value = false; await nextTick(); expect(chartStub.hideLoading).toHaveBeenCalledTimes(1); }); it("binds chart, zr, and native event listeners", async () => { const clickHandler = vi.fn(); const nativeClick = vi.fn(); const zrMove = vi.fn(); const option = ref({}); const exposed = shallowRef(); renderChart( () => ({ option: option.value, onClick: clickHandler, "onNative:click": nativeClick, "onZr:mousemoveOnce": zrMove, }), exposed, ); await nextTick(); expect(chartStub.on).toHaveBeenCalledWith("click", expect.any(Function)); const chartListener = chartStub.on.mock.calls[0][1]; chartListener("payload"); expect(clickHandler).toHaveBeenCalledWith("payload"); const zr = chartStub.getZr(); expect(zr.on).toHaveBeenCalledWith("mousemove", expect.any(Function)); const zrListener = zr.on.mock.calls[0][1]; zrListener("zr-payload"); expect(zrMove).toHaveBeenCalledWith("zr-payload"); expect(zr.off).toHaveBeenCalledWith("mousemove", zrListener); await nextTick(); const rootEl = (exposed.value?.root?.value as HTMLElement | undefined) ?? (document.querySelector("x-vue-echarts") as HTMLElement | null); expect(rootEl).toBeInstanceOf(HTMLElement); rootEl!.dispatchEvent(new MouseEvent("click", { bubbles: true })); expect(nativeClick).toHaveBeenCalledTimes(1); }); it("removes once listeners after first invocation", async () => { const clickOnce = vi.fn(); const zrOnce = vi.fn(); const option = ref({}); const exposed = shallowRef(); renderChart( () => ({ option: option.value, onClickOnce: clickOnce, "onZr:clickOnce": zrOnce, }), exposed, ); await nextTick(); const chartCall = chartStub.on.mock.calls.find( (call: any[]) => call[0] === "click", ); expect(chartCall).toBeTruthy(); const chartListener = chartCall?.[1]; chartListener?.("payload"); chartListener?.("again"); expect(clickOnce).toHaveBeenCalledTimes(1); expect(chartStub.off).toHaveBeenCalledWith("click", chartListener); const zr = chartStub.getZr(); const zrCall = zr.on.mock.calls.find((call: any[]) => call[0] === "click"); expect(zrCall).toBeTruthy(); const zrListener = zrCall?.[1]; zrListener?.("zr"); zrListener?.("zr-again"); expect(zrOnce).toHaveBeenCalledTimes(1); expect(zr.off).toHaveBeenCalledWith("click", zrListener); }); it("plans replaceMerge when series id is removed", async () => { const option = ref({ series: [ { id: "a", type: "bar", data: [1] }, { id: "b", type: "bar", data: [2] }, ], }); const exposed = shallowRef(); renderChart(() => ({ option: option.value }), exposed); await nextTick(); chartStub.setOption.mockClear(); // Remove one id to trigger replaceMerge planning option.value = { series: [{ id: "b", type: "bar", data: [3] }], } as any; await nextTick(); expect(chartStub.setOption).toHaveBeenCalledTimes(1); const updateOptions = chartStub.setOption.mock.calls[0][1]; expect(updateOptions).toEqual( expect.objectContaining({ replaceMerge: ["series"] }), ); }); it("calls resize before commit when autoresize is true", async () => { const option = ref({ title: { text: "auto" } }); const exposed = shallowRef(); renderChart(() => ({ option: option.value, autoresize: true }), exposed); await nextTick(); expect(chartStub.resize).toHaveBeenCalled(); }); it("supports boolean notMerge in manual setOption", async () => { const option = ref({ title: { text: "manual" } }); const exposed = shallowRef(); renderChart(() => ({ option: option.value, manualUpdate: true }), exposed); await nextTick(); chartStub.setOption.mockClear(); exposed.value.setOption({ title: { text: "b" } }, true, false); expect(chartStub.setOption).toHaveBeenCalledTimes(1); const updateOptions = chartStub.setOption.mock.calls[0][1]; expect(updateOptions).toEqual({ notMerge: true, lazyUpdate: false }); }); it("applies empty object when theme becomes falsy", async () => { const option = ref({}); const theme = ref({ palette: ["#000"] } as any); const exposed = shallowRef(); renderChart(() => ({ option: option.value, theme: theme.value }), exposed); await nextTick(); const current = chartStub; theme.value = undefined as any; await nextTick(); expect(current.setTheme).toHaveBeenCalledWith({}); }); it("sets notMerge when options array shrinks", async () => { const option = ref({ options: [{}, {}] } as any); const exposed = shallowRef(); renderChart(() => ({ option: option.value }), exposed); await nextTick(); chartStub.setOption.mockClear(); option.value = { options: [{}] } as any; await nextTick(); const updateOptions = chartStub.setOption.mock.calls[0][1]; expect(updateOptions).toEqual(expect.objectContaining({ notMerge: true })); }); it("does not re-initialize when calling setOption with an existing instance (manual)", async () => { const option = ref({ title: { text: "init-manual" } }); const exposed = shallowRef(); renderChart(() => ({ option: option.value, manualUpdate: true }), exposed); init.mockClear(); chartStub.setOption.mockClear(); exposed.value.setOption({ title: { text: "after" } }); await nextTick(); expect(init).not.toHaveBeenCalled(); expect(chartStub.setOption).toHaveBeenCalledTimes(1); }); it("applies option reactively without re-initialization when option becomes defined", async () => { const option = ref(null); const exposed = shallowRef(); renderChart(() => ({ option: option.value }), exposed); init.mockClear(); chartStub.setOption.mockClear(); option.value = { title: { text: "now-defined" } }; await nextTick(); expect(init).not.toHaveBeenCalled(); expect(chartStub.setOption).toHaveBeenCalledTimes(1); }); it("honors override.replaceMerge in update options", async () => { const option = ref({ series: [{ type: "bar", data: [1] }] }); const exposed = shallowRef(); renderChart(() => ({ option: option.value, manualUpdate: true }), exposed); await nextTick(); chartStub.setOption.mockClear(); exposed.value.setOption({ series: [{ type: "bar", data: [2] }] }, { replaceMerge: ["series"], } as any); expect(chartStub.setOption).toHaveBeenCalledTimes(1); const updateOptions = chartStub.setOption.mock.calls[0][1]; expect(updateOptions).toEqual( expect.objectContaining({ replaceMerge: ["series"] }), ); }); it("merges base updateOptions from props during reactive updates", async () => { const option = ref({ title: { text: "merge-base" } }); const exposed = shallowRef(); renderChart( () => ({ option: option.value, updateOptions: { lazyUpdate: true } }), exposed, ); await nextTick(); chartStub.setOption.mockClear(); // Change option to trigger reactive update without special plan flags option.value = { title: { text: "merge-base-2" } }; await nextTick(); const updateOptions = chartStub.setOption.mock.calls[0][1]; expect(updateOptions).toEqual( expect.objectContaining({ lazyUpdate: true }), ); }); it("sets __dispose on root during unmount when wcRegistered and cleanup runs via disconnectedCallback", async () => { const option = ref({ title: { text: "wc-dispose" } }); const exposed = shallowRef(); const screen = renderChart(() => ({ option: option.value }), exposed); await nextTick(); const el: any = (exposed.value?.root?.value as HTMLElement | undefined) ?? (document.querySelector("x-vue-echarts") as HTMLElement | null); expect(el).toBeInstanceOf(HTMLElement); chartStub.dispose.mockClear(); // Unmount triggers custom element disconnectedCallback, which invokes __dispose immediately screen.unmount(); await nextTick(); expect(chartStub.dispose).toHaveBeenCalledTimes(1); // wc disconnectedCallback should null out the hook after calling it expect(el.__dispose).toBeNull(); }); it("setOption after unmount is a safe no-op (manual)", async () => { const option = ref({ title: { text: "mounted" } }); const exposed = shallowRef(); const screen = renderChart( () => ({ option: option.value, manualUpdate: true }), exposed, ); await nextTick(); const callsBefore = chartStub.setOption.mock.calls.length; // Capture the function reference before unmount; template ref becomes null on unmount const callSetOption = exposed.value.setOption as ( opt: any, notMerge?: any, lazyUpdate?: any, ) => void; // Unmount disposes and clears chart.value internally screen.unmount(); await nextTick(); // Calling setOption after unmount should be a no-op and not throw expect(() => callSetOption({ title: { text: "after" } })).not.toThrow(); expect(chartStub.setOption.mock.calls.length).toBe(callsBefore); }); it("re-applies option when slot set changes (auto mode)", async () => { const option = ref({ title: { text: "with-slots" } }); const showExtra = ref(true); const exposed = shallowRef(); const Root = defineComponent({ setup() { return () => h( ECharts, { option: option.value, ref: (v: any) => (exposed.value = v), }, showExtra.value ? { tooltip: () => [h("span", "t")], "tooltip-extra": () => [h("span", "x")], } : { tooltip: () => [h("span", "t")], }, ); }, }); render(Root); await nextTick(); // One initial setOption from mount const initialCalls = chartStub.setOption.mock.calls.length; // Changing slot set triggers useSlotOption onChange, which applies current option again showExtra.value = false; await nextTick(); await nextTick(); expect(chartStub.setOption.mock.calls.length).toBeGreaterThan(initialCalls); }); it("skips resize when instance is disposed in autoresize path", async () => { const option = ref({}); const exposed = shallowRef(); // Force the disposed branch in resize() chartStub.isDisposed.mockReturnValue(true as any); renderChart(() => ({ option: option.value, autoresize: true }), exposed); await nextTick(); // resize should be skipped, commit should still apply option expect(chartStub.resize).not.toHaveBeenCalled(); expect(chartStub.setOption).toHaveBeenCalled(); }); it("stops reactive updates after toggling manualUpdate to true", async () => { const option = ref({ title: { text: "start" } }); const manual = ref(false); const exposed = shallowRef(); renderChart( () => ({ option: option.value, manualUpdate: manual.value }), exposed, ); await nextTick(); chartStub.setOption.mockClear(); option.value = { title: { text: "reactive-1" } } as any; await nextTick(); expect(chartStub.setOption).toHaveBeenCalledTimes(1); // Toggle to manual mode; watcher should be cleaned up (unwatchOption branch) manual.value = true; await nextTick(); chartStub.setOption.mockClear(); option.value = { title: { text: "reactive-2" } } as any; await nextTick(); expect(chartStub.setOption).not.toHaveBeenCalled(); }); });