mirror of
https://github.com/ecomfe/vue-echarts.git
synced 2026-03-13 08:41:05 +08:00
384 lines
12 KiB
TypeScript
384 lines
12 KiB
TypeScript
import { describe, it, expect, vi } from "vitest";
|
|
import { defineComponent, h, nextTick, ref, shallowRef, watchEffect } from "vue";
|
|
import type { PropType, Ref, VNodeChild, VNodeRef } from "vue";
|
|
import { render } from "./helpers/testing";
|
|
import { makeTooltipParams } from "./helpers/tooltip";
|
|
|
|
import { useSlotOption } from "../src/composables/slot";
|
|
import { withConsoleWarn } from "./helpers/dom";
|
|
import type { Option } from "../src/types";
|
|
import type {
|
|
ToolboxComponentOption,
|
|
TooltipComponentFormatterCallbackParams,
|
|
TooltipComponentFormatterCallback,
|
|
TooltipComponentOption,
|
|
} from "echarts";
|
|
|
|
type SlotTestHandle = {
|
|
patchOption: ReturnType<typeof useSlotOption>["patchOption"];
|
|
render: ReturnType<typeof useSlotOption>["render"];
|
|
};
|
|
|
|
const SlotTestComponent = defineComponent({
|
|
props: {
|
|
onChange: {
|
|
type: Function as PropType<() => void>,
|
|
default: undefined,
|
|
},
|
|
},
|
|
setup(props, ctx) {
|
|
const { render, patchOption } = useSlotOption(ctx.slots, props.onChange ?? (() => {}));
|
|
|
|
ctx.expose({ patchOption, render });
|
|
|
|
return () => h("div", render());
|
|
},
|
|
});
|
|
|
|
type SlotDictionary = Record<string, (...args: unknown[]) => VNodeChild>;
|
|
type TooltipFormatter = TooltipComponentFormatterCallback<TooltipComponentFormatterCallbackParams>;
|
|
|
|
// cleanup and document reset are handled in tests/setup.ts
|
|
|
|
function getExposed(exposed: Ref<SlotTestHandle | undefined>): SlotTestHandle {
|
|
const instance = exposed.value;
|
|
if (!instance) {
|
|
throw new Error("Expected slot test component to expose helpers.");
|
|
}
|
|
return instance;
|
|
}
|
|
|
|
function isSlotTestHandle(value: unknown): value is SlotTestHandle {
|
|
return typeof value === "object" && value !== null && "patchOption" in value && "render" in value;
|
|
}
|
|
|
|
function renderSlotComponent(
|
|
slotFactory: () => SlotDictionary,
|
|
onChange?: () => void,
|
|
): { exposed: ReturnType<typeof shallowRef<SlotTestHandle | undefined>> } {
|
|
const exposed = shallowRef<SlotTestHandle>();
|
|
|
|
const Root = defineComponent({
|
|
setup() {
|
|
const componentRef = shallowRef<SlotTestHandle>();
|
|
const setExposed: VNodeRef = (value) => {
|
|
componentRef.value = isSlotTestHandle(value) ? value : undefined;
|
|
};
|
|
|
|
watchEffect(() => {
|
|
if (componentRef.value) {
|
|
exposed.value = componentRef.value;
|
|
}
|
|
});
|
|
|
|
return () =>
|
|
h(
|
|
SlotTestComponent,
|
|
{
|
|
ref: setExposed,
|
|
onChange,
|
|
},
|
|
slotFactory(),
|
|
);
|
|
},
|
|
});
|
|
|
|
render(Root);
|
|
|
|
return {
|
|
exposed,
|
|
};
|
|
}
|
|
|
|
function getTooltipFormatter(option: Option, label: string): TooltipFormatter {
|
|
const tooltip = (
|
|
option as {
|
|
tooltip?: TooltipComponentOption | TooltipComponentOption[];
|
|
}
|
|
).tooltip;
|
|
if (!tooltip || Array.isArray(tooltip)) {
|
|
throw new Error(`Expected ${label} tooltip to be a single object.`);
|
|
}
|
|
if (typeof tooltip.formatter !== "function") {
|
|
throw new Error(`Expected ${label} tooltip formatter to be injected.`);
|
|
}
|
|
return tooltip.formatter;
|
|
}
|
|
|
|
function getToolboxOption(option: Option): ToolboxComponentOption {
|
|
const toolbox = option.toolbox;
|
|
if (!toolbox || Array.isArray(toolbox)) {
|
|
throw new Error("Expected toolbox option to be a single object.");
|
|
}
|
|
return toolbox;
|
|
}
|
|
|
|
function hasTooltipOption(value: unknown): value is { tooltip?: TooltipComponentOption } {
|
|
return typeof value === "object" && value !== null && "tooltip" in value;
|
|
}
|
|
|
|
function getSeriesOption(option: Option, index: number): TooltipComponentOption {
|
|
const series = option.series;
|
|
if (!series || (typeof series !== "object" && !Array.isArray(series))) {
|
|
throw new Error(`Expected series[${index}] to be available.`);
|
|
}
|
|
const entry = Array.isArray(series)
|
|
? series[index]
|
|
: (series as Record<string, unknown>)[String(index)];
|
|
if (!entry) {
|
|
throw new Error(`Expected series[${index}] to be available.`);
|
|
}
|
|
if (!hasTooltipOption(entry) || !entry.tooltip || Array.isArray(entry.tooltip)) {
|
|
throw new Error(`Expected series[${index}] tooltip to be available.`);
|
|
}
|
|
return entry.tooltip;
|
|
}
|
|
|
|
describe("useSlotOption", () => {
|
|
it("patches tooltip slots and renders teleported content", async () => {
|
|
const changeSpy = vi.fn();
|
|
|
|
const { exposed } = renderSlotComponent(
|
|
() => ({
|
|
tooltip: (...args: unknown[]) => {
|
|
const params = args[0] as { dataIndex: number };
|
|
return [h("span", `tooltip-${params.dataIndex}`)];
|
|
},
|
|
}),
|
|
changeSpy,
|
|
);
|
|
|
|
await nextTick();
|
|
changeSpy.mockClear();
|
|
|
|
const patched = getExposed(exposed).patchOption({});
|
|
expect(changeSpy).not.toHaveBeenCalled();
|
|
|
|
const formatter = getTooltipFormatter(patched, "tooltip");
|
|
const container = formatter(makeTooltipParams(42), "");
|
|
if (!(container instanceof HTMLElement)) {
|
|
throw new Error("Expected tooltip formatter to return an HTMLElement.");
|
|
}
|
|
|
|
await nextTick();
|
|
expect(container.textContent).toBe("tooltip-42");
|
|
});
|
|
|
|
it("patches dataView slots and renders teleported content", async () => {
|
|
const changeSpy = vi.fn();
|
|
|
|
const { exposed } = renderSlotComponent(
|
|
() => ({
|
|
dataView: () => [h("span", "data-view")],
|
|
}),
|
|
changeSpy,
|
|
);
|
|
|
|
await nextTick();
|
|
changeSpy.mockClear();
|
|
|
|
const patched = getExposed(exposed).patchOption({
|
|
toolbox: { feature: {} },
|
|
});
|
|
expect(changeSpy).not.toHaveBeenCalled();
|
|
|
|
const toolbox = getToolboxOption(patched);
|
|
const feature = toolbox.feature;
|
|
if (!feature || !feature.dataView) {
|
|
throw new Error("Expected dataView optionToContent to be injected.");
|
|
}
|
|
const optionToContent = feature.dataView.optionToContent;
|
|
if (typeof optionToContent !== "function") {
|
|
throw new Error("Expected dataView optionToContent to be injected.");
|
|
}
|
|
const container = optionToContent({});
|
|
if (!(container instanceof HTMLElement)) {
|
|
throw new Error("Expected dataView optionToContent to return an HTMLElement.");
|
|
}
|
|
|
|
await nextTick();
|
|
expect(container.textContent).toBe("data-view");
|
|
});
|
|
|
|
it("notifies when slot set changes and cleans state", async () => {
|
|
const changeSpy = vi.fn();
|
|
const showExtra = ref(true);
|
|
|
|
const { exposed } = renderSlotComponent(() => {
|
|
const slots: SlotDictionary = {
|
|
tooltip: (...args: unknown[]) => {
|
|
const params = args[0] as { dataIndex: number };
|
|
return [h("span", `tooltip-${params.dataIndex}`)];
|
|
},
|
|
};
|
|
if (showExtra.value) {
|
|
slots["tooltip-extra"] = () => [h("span", "extra")];
|
|
}
|
|
return slots;
|
|
}, changeSpy);
|
|
|
|
await nextTick();
|
|
changeSpy.mockClear();
|
|
|
|
const patched = getExposed(exposed).patchOption({});
|
|
const formatter = getTooltipFormatter(patched, "tooltip");
|
|
formatter(makeTooltipParams(1), "");
|
|
await nextTick();
|
|
|
|
showExtra.value = false;
|
|
await nextTick();
|
|
|
|
expect(changeSpy).toHaveBeenCalledTimes(1);
|
|
|
|
const patchedAfterRemoval = getExposed(exposed).patchOption({});
|
|
expect("tooltip-extra" in patchedAfterRemoval).toBe(false);
|
|
});
|
|
|
|
it("cleans formatter containers when dynamic tooltip/dataView slot paths are removed", async () => {
|
|
const changeSpy = vi.fn();
|
|
const showNested = ref(true);
|
|
|
|
const { exposed } = renderSlotComponent(() => {
|
|
const slots: SlotDictionary = {};
|
|
if (showNested.value) {
|
|
slots["tooltip-series-0"] = () => [h("span", "nested-tooltip")];
|
|
slots["dataView-panel"] = () => [h("span", "nested-data-view")];
|
|
}
|
|
return slots;
|
|
}, changeSpy);
|
|
|
|
await nextTick();
|
|
changeSpy.mockClear();
|
|
|
|
const patched = getExposed(exposed).patchOption({});
|
|
const tooltip = getSeriesOption(patched, 0);
|
|
if (typeof tooltip.formatter !== "function") {
|
|
throw new Error("Expected nested series tooltip formatter to be injected.");
|
|
}
|
|
|
|
const tooltipContainer = tooltip.formatter(makeTooltipParams(9), "");
|
|
if (!(tooltipContainer instanceof HTMLElement)) {
|
|
throw new Error("Expected nested tooltip formatter to return an HTMLElement.");
|
|
}
|
|
|
|
const panel = (patched as Record<string, unknown>).panel as
|
|
| {
|
|
toolbox?: { feature?: { dataView?: { optionToContent?: (option: unknown) => unknown } } };
|
|
}
|
|
| undefined;
|
|
const optionToContent = panel?.toolbox?.feature?.dataView?.optionToContent;
|
|
if (typeof optionToContent !== "function") {
|
|
throw new Error("Expected nested dataView optionToContent to be injected.");
|
|
}
|
|
const dataViewContainer = optionToContent({});
|
|
if (!(dataViewContainer instanceof HTMLElement)) {
|
|
throw new Error("Expected nested dataView optionToContent to return an HTMLElement.");
|
|
}
|
|
|
|
await nextTick();
|
|
expect(tooltipContainer.textContent).toBe("nested-tooltip");
|
|
expect(dataViewContainer.textContent).toBe("nested-data-view");
|
|
|
|
showNested.value = false;
|
|
await nextTick();
|
|
|
|
expect(changeSpy).toHaveBeenCalledTimes(1);
|
|
expect(tooltip.formatter(makeTooltipParams(10), "")).toBeUndefined();
|
|
expect(optionToContent({})).toBeUndefined();
|
|
|
|
const patchedAfterRemoval = getExposed(exposed).patchOption({});
|
|
expect(patchedAfterRemoval.series).toBeUndefined();
|
|
expect((patchedAfterRemoval as Record<string, unknown>).panel).toBeUndefined();
|
|
});
|
|
|
|
it("warns and skips invalid slot names", async () => {
|
|
const changeSpy = vi.fn();
|
|
const { exposed } = renderSlotComponent(
|
|
() => ({
|
|
legend: () => [h("span", "legend")],
|
|
}),
|
|
changeSpy,
|
|
);
|
|
|
|
await nextTick();
|
|
changeSpy.mockClear();
|
|
|
|
withConsoleWarn((warnSpy) => {
|
|
const patched = getExposed(exposed).patchOption({});
|
|
const flattened = warnSpy.mock.calls.flat().join(" ");
|
|
|
|
expect(flattened).toContain("[vue-echarts] Invalid slot name: legend");
|
|
expect(patched.legend).toBeUndefined();
|
|
expect(changeSpy).not.toHaveBeenCalled();
|
|
});
|
|
});
|
|
|
|
it("clones existing array branches when patching series tooltip slots", async () => {
|
|
const { exposed } = renderSlotComponent(() => ({
|
|
"tooltip-series-0": () => [h("span", "series-0")],
|
|
}));
|
|
|
|
await nextTick();
|
|
|
|
const originalOption = {
|
|
series: [
|
|
{
|
|
tooltip: {},
|
|
},
|
|
],
|
|
};
|
|
|
|
const patched = getExposed(exposed).patchOption(originalOption);
|
|
|
|
expect(patched).not.toBe(originalOption);
|
|
expect(patched.series).not.toBe(originalOption.series);
|
|
|
|
const tooltip = getSeriesOption(patched, 0);
|
|
if (typeof tooltip.formatter !== "function") {
|
|
throw new Error("Expected series tooltip formatter to be injected.");
|
|
}
|
|
const container = tooltip.formatter(makeTooltipParams(7), "");
|
|
if (!(container instanceof HTMLElement)) {
|
|
throw new Error("Expected tooltip formatter to return an HTMLElement.");
|
|
}
|
|
|
|
await nextTick();
|
|
expect(container.textContent).toBe("series-0");
|
|
});
|
|
|
|
it("skips slot patch when path is blocked by non-object", async () => {
|
|
const { exposed } = renderSlotComponent(() => ({
|
|
"tooltip-series-0": () => [h("span", "series-0")],
|
|
}));
|
|
|
|
await nextTick();
|
|
|
|
const option = { series: 1 } as unknown as Option;
|
|
const patched = getExposed(exposed).patchOption(option);
|
|
|
|
expect(patched.series).toBe(1);
|
|
expect(typeof patched.series).toBe("number");
|
|
});
|
|
|
|
it("creates array shells when target slot path is missing", async () => {
|
|
const { exposed } = renderSlotComponent(() => ({
|
|
"tooltip-series-1": () => [h("span", "series-1")],
|
|
}));
|
|
|
|
await nextTick();
|
|
|
|
const patched = getExposed(exposed).patchOption({});
|
|
const tooltip = getSeriesOption(patched, 1);
|
|
if (typeof tooltip.formatter !== "function") {
|
|
throw new Error("Expected series tooltip formatter to be injected.");
|
|
}
|
|
const container = tooltip.formatter(makeTooltipParams(3), "");
|
|
if (!(container instanceof HTMLElement)) {
|
|
throw new Error("Expected tooltip formatter to return an HTMLElement.");
|
|
}
|
|
|
|
await nextTick();
|
|
expect(container.textContent).toBe("series-1");
|
|
});
|
|
});
|