test: setup vitest, add unit tests for smart-update

This commit is contained in:
Justineo
2025-09-17 23:12:03 +08:00
committed by GU Yiling
parent 468f7dbfbd
commit 087be22721
8 changed files with 1769 additions and 36 deletions

View File

@ -1,16 +1,21 @@
# Repository Guidelines # Repository Guidelines
## Project Structure & Module Organization ## Project Structure & Module Organization
Core source lives in `src/`, implemented in TypeScript with the Vue 3 Composition API. Key entry modules include `src/index.ts` for exports, `src/ECharts.ts` for the main component, and utilities under `src/composables/` and `src/utils.ts`. Demo site assets sit in `demo/` (Vite-powered) and should be kept in sync with new features. Bundled artifacts in `dist/` are generated by `pnpm build`; avoid editing them manually. Shared build helpers reside in `scripts/`. Core source lives in `src/`, implemented in TypeScript with the Vue 3 Composition API. Key entry modules include `src/index.ts` for exports, `src/ECharts.ts` for the main component, and utilities under `src/composables/` and `src/utils.ts`. Demo site assets sit in `demo/` (Vite-powered) and should be kept in sync with new features. Bundled artifacts in `dist/` are generated by `pnpm build`; avoid editing them manually. Shared build helpers reside in `scripts/`.
## Build, Test, and Development Commands ## Build, Test, and Development Commands
Use `pnpm install` to set up dependencies. `pnpm dev` serves the demo playground at `http://localhost:5173` for interactive testing. Run `pnpm build` (tsdown) to produce distributable output under `dist/`, and `pnpm dev:build` or `pnpm dev:preview` when you need the Vite bundle for the demo. `pnpm typecheck` validates the librarys TypeScript contracts, `pnpm lint` applies ESLint with autofix, and `pnpm format` runs Prettier. Use `pnpm publint` before releases to confirm the published surface.
Use `pnpm install` to set up dependencies. `pnpm dev` serves the demo playground at `http://localhost:5173` for interactive testing. Run `pnpm build` (tsdown) to produce distributable output under `dist/`, and `pnpm dev:build` or `pnpm dev:preview` when you need the Vite bundle for the demo. `pnpm typecheck` validates the librarys TypeScript contracts, `pnpm lint` applies ESLint with autofix, `pnpm format` runs Prettier, and `pnpm test` executes the Vitest suite. Use `pnpm publint` before releases to confirm the published surface.
## Coding Style & Naming Conventions ## Coding Style & Naming Conventions
This project targets Vue 3 + TypeScript with ECMAScript modules. Follow the existing 2-space indentation, trailing commas where valid, and single quotes for strings unless interpolation is required. Components and exported composables use PascalCase (e.g., `VChart`), while local helpers remain camelCase. Honor `eslint.config.ts` and Prettier defaults; always run `pnpm lint && pnpm format` before sending patches. Keep public exports centralized in `src/index.ts` and add accompanying CSS changes to `src/style.css`. This project targets Vue 3 + TypeScript with ECMAScript modules. Follow the existing 2-space indentation, trailing commas where valid, and single quotes for strings unless interpolation is required. Components and exported composables use PascalCase (e.g., `VChart`), while local helpers remain camelCase. Honor `eslint.config.ts` and Prettier defaults; always run `pnpm lint && pnpm format` before sending patches. Keep public exports centralized in `src/index.ts` and add accompanying CSS changes to `src/style.css`.
## Testing Guidelines ## Testing Guidelines
There is no standalone unit-test runner yet; rely on TypeScript, linting, and manual QA in the demo. Before opening a PR, run `pnpm lint`, `pnpm typecheck`, and `pnpm build`. Exercise relevant demos in `demo/src/` and add or update examples that showcase new behaviors. For major fixes, include reproduction and verification steps in the PR description so reviewers can follow along. There is no standalone unit-test runner yet; rely on TypeScript, linting, and manual QA in the demo. Before opening a PR, run `pnpm lint`, `pnpm typecheck`, and `pnpm build`. Exercise relevant demos in `demo/src/` and add or update examples that showcase new behaviors. For major fixes, include reproduction and verification steps in the PR description so reviewers can follow along.
## Commit & Pull Request Guidelines ## Commit & Pull Request Guidelines
Commit history follows Conventional Commits (`type(scope): summary`), e.g., `feat(runtime): add renderer option` or `chore(deps): update vue`. Use concise, imperative summaries and group related changes together. PRs should describe user-facing effects, list verification commands, and link issues with `Fixes #123` when applicable. Include screenshots or GIFs for visual updates to the demo, and note any doc changes (`README.md`, `demo/`) in the description. Ensure CI checks mirror local commands: `pnpm lint`, `pnpm typecheck`, and `pnpm build`. Commit history follows Conventional Commits (`type(scope): summary`), e.g., `feat(runtime): add renderer option` or `chore(deps): update vue`. Use concise, imperative summaries and group related changes together. PRs should describe user-facing effects, list verification commands, and link issues with `Fixes #123` when applicable. Include screenshots or GIFs for visual updates to the demo, and note any doc changes (`README.md`, `demo/`) in the description. Ensure CI checks mirror local commands: `pnpm lint`, `pnpm typecheck`, and `pnpm build`.

View File

@ -19,7 +19,9 @@
"dev:preview": "vite preview", "dev:preview": "vite preview",
"dev:typecheck": "vue-tsc -p ./demo", "dev:typecheck": "vue-tsc -p ./demo",
"docs": "jiti ./scripts/docs.ts", "docs": "jiti ./scripts/docs.ts",
"release": "bumpp --execute \"pnpm run docs\" --all" "release": "bumpp --execute \"pnpm run docs\" --all",
"test": "vitest run",
"test:watch": "vitest watch"
}, },
"packageManager": "pnpm@10.15.1", "packageManager": "pnpm@10.15.1",
"type": "module", "type": "module",
@ -67,6 +69,7 @@
"typescript": "^5.9.2", "typescript": "^5.9.2",
"unplugin-raw": "^0.6.0", "unplugin-raw": "^0.6.0",
"vite": "npm:rolldown-vite@^7.1.2", "vite": "npm:rolldown-vite@^7.1.2",
"vitest": "^3.2.4",
"vue": "^3.5.18", "vue": "^3.5.18",
"vue-tsc": "^3.0.5" "vue-tsc": "^3.0.5"
}, },

1463
pnpm-lock.yaml generated

File diff suppressed because it is too large Load Diff

View File

@ -27,8 +27,8 @@ import {
import type { PublicMethods, SlotsTypes } from "./composables"; import type { PublicMethods, SlotsTypes } from "./composables";
import { isOn, omitOn } from "./utils"; import { isOn, omitOn } from "./utils";
import { register, TAG_NAME } from "./wc"; import { register, TAG_NAME } from "./wc";
import { planUpdate } from "./merge"; import { planUpdate } from "./smart-update";
import type { Signature, UpdatePlan } from "./merge"; import type { Signature, UpdatePlan } from "./smart-update";
import type { PropType, InjectionKey } from "vue"; import type { PropType, InjectionKey } from "vue";
import type { import type {
@ -81,15 +81,13 @@ export default defineComponent({
const { autoresize, manualUpdate, loading, loadingOptions } = toRefs(props); const { autoresize, manualUpdate, loading, loadingOptions } = toRefs(props);
const realOption = computed(() => props.option || undefined); const realOption = computed(() => props.option || {});
const realTheme = computed( const realTheme = computed(() => props.theme || toValue(defaultTheme));
() => props.theme || toValue(defaultTheme) || undefined,
);
const realInitOptions = computed( const realInitOptions = computed(
() => props.initOptions || toValue(defaultInitOptions) || undefined, () => props.initOptions || toValue(defaultInitOptions) || undefined,
); );
const realUpdateOptions = computed( const realUpdateOptions = computed(
() => props.updateOptions || toValue(defaultUpdateOptions) || undefined, () => props.updateOptions || toValue(defaultUpdateOptions),
); );
const nonEventAttrs = computed(() => omitOn(attrs)); const nonEventAttrs = computed(() => omitOn(attrs));
const nativeListeners: Record<string, unknown> = {}; const nativeListeners: Record<string, unknown> = {};

298
tests/smart-update.test.ts Normal file
View File

@ -0,0 +1,298 @@
import { describe, it, expect } from "vitest";
import { buildSignature, planUpdate } from "../src/smart-update";
import type { EChartsOption } from "echarts";
describe("smart-update", () => {
describe("buildSignature", () => {
it("collects scalars, objects, and array summaries", () => {
const option: EChartsOption = {
title: { text: "foo" },
tooltip: { show: true },
color: "#000",
dataset: [{ id: "ds1", source: [] }, { source: [] }],
series: [
{ id: "a", type: "bar" },
{ type: "line" },
],
};
const signature = buildSignature(option);
expect(signature.objects).toEqual(["title", "tooltip"]);
expect(signature.scalars).toEqual(["color"]);
expect(signature.arrays.dataset?.idsSorted).toEqual(["ds1"]);
expect(signature.arrays.dataset?.noIdCount).toBe(1);
expect(signature.arrays.series?.idsSorted).toEqual(["a"]);
expect(signature.arrays.series?.noIdCount).toBe(1);
});
});
describe("planUpdate", () => {
describe("bootstrap & neutral cases", () => {
it("returns neutral plan when previous signature missing", () => {
const option: EChartsOption = {
legend: { show: true },
series: [{ type: "bar", data: [1, 2, 3] }],
};
const result = planUpdate(undefined, option);
expect(result.plan.notMerge).toBe(false);
expect(result.plan.replaceMerge).toBeUndefined();
expect(result.option).toEqual(option);
});
it("returns neutral plan when signatures match", () => {
const option: EChartsOption = {
title: { text: "foo" },
series: [{ id: "a" }],
};
const prev = buildSignature(option);
const next = planUpdate(prev, option);
expect(next.plan.notMerge).toBe(false);
expect(next.plan.replaceMerge).toBeUndefined();
expect(next.option).toEqual(option);
});
it("keeps merge when scalar value changes", () => {
const prev = buildSignature({ color: "red" });
const next = planUpdate(prev, { color: "blue" });
expect(next.plan.notMerge).toBe(false);
expect(next.plan.replaceMerge).toBeUndefined();
expect(next.option.color).toBe("blue");
});
it("keeps merge when new series IDs are added", () => {
const base: EChartsOption = {
series: [{ id: "latte", type: "bar", data: [10, 20] }],
};
const update: EChartsOption = {
series: [
{ id: "latte", type: "bar", data: [12, 24] },
{ id: "mocha", type: "bar", data: [14, 28] },
],
};
const result = planUpdate(buildSignature(base), update);
expect(result.plan.notMerge).toBe(false);
expect(result.plan.replaceMerge).toBeUndefined();
expect(result.option.series).toEqual(update.series);
});
});
describe("shrink detection", () => {
it("forces rebuild when options shrink", () => {
const prev = buildSignature({ options: [{}, {}] });
const { plan } = planUpdate(prev, { options: [{}] });
expect(plan.notMerge).toBe(true);
});
it("forces rebuild when scalars disappear", () => {
const prev = buildSignature({ color: "red", title: { text: "foo" } });
const { plan } = planUpdate(prev, { title: { text: "foo" } });
expect(plan.notMerge).toBe(true);
});
it("injects null for removed objects", () => {
const prev = buildSignature({ legend: { show: true } });
const next = planUpdate(prev, {});
expect(next.option.legend).toBeNull();
expect(next.plan.notMerge).toBe(false);
});
it("injects empty array and replaceMerge when array removed", () => {
const prev = buildSignature({ series: [{ id: "a" }, {}] });
const next = planUpdate(prev, {});
expect(next.option.series).toEqual([]);
expect(next.plan.replaceMerge).toEqual(["series"]);
});
it("adds replaceMerge when ids shrink", () => {
const prev = buildSignature({ series: [{ id: "a" }, { id: "b" }] });
const next = planUpdate(prev, { series: [{ id: "a" }] });
expect(next.plan.replaceMerge).toEqual(["series"]);
});
it("adds replaceMerge when anonymous count shrinks", () => {
const prev = buildSignature({ series: [{}, {}] });
const next = planUpdate(prev, { series: [{}] });
expect(next.plan.replaceMerge).toEqual(["series"]);
});
});
describe("real data scenarios", () => {
it("handles legend removal and series shrink", () => {
const base: EChartsOption = {
legend: { show: true },
dataset: [
{
id: "sales",
source: [
["product", "2015", "2016"],
["Matcha Latte", 43.3, 85.8],
],
},
],
series: [
{ id: "2015", type: "bar", datasetId: "sales" },
{ id: "2016", type: "bar", datasetId: "sales" },
],
};
const update: EChartsOption = {
dataset: [
{
id: "sales",
source: [
["product", "2015"],
["Matcha Latte", 55.1],
],
},
],
series: [{ id: "2015", type: "bar", datasetId: "sales" }],
};
const result = planUpdate(buildSignature(base), update);
expect(result.option.legend).toBeNull();
expect(result.option.series).toEqual(update.series);
expect(result.plan.notMerge).toBe(false);
expect(result.plan.replaceMerge).toEqual(["series"]);
});
it("clears dataset when removed entirely", () => {
const base: EChartsOption = {
dataset: [
{
id: "sales",
source: [
["product", "2015"],
["Latte", 30],
],
},
],
series: [{ id: "sales-series", type: "bar", datasetId: "sales" }],
};
const update: EChartsOption = {
series: [{ id: "sales-series", type: "bar", data: [35] }],
};
const result = planUpdate(buildSignature(base), update);
expect(result.option.dataset).toEqual([]);
expect(result.plan.notMerge).toBe(false);
expect(result.plan.replaceMerge).toContain("dataset");
});
it("tracks multiple array shrink operations", () => {
const base: EChartsOption = {
legend: { show: true },
dataset: [
{
id: "2015",
source: [
["Latte", 30],
["Mocha", 24],
],
},
{
id: "2016",
source: [
["Latte", 40],
["Mocha", 35],
],
},
],
series: [
{ id: "latte", type: "bar", datasetId: "2015" },
{ id: "mocha", type: "bar", datasetId: "2016" },
],
};
const update: EChartsOption = {
series: [{ id: "latte", type: "bar", datasetId: "2015" }],
};
const result = planUpdate(buildSignature(base), update);
expect(result.option.legend).toBeNull();
expect(result.option.dataset).toEqual([]);
expect(result.plan.notMerge).toBe(false);
expect(result.plan.replaceMerge).toEqual(["dataset", "series"]);
});
it("injects null for tooltip removal while keeping explicit arrays", () => {
const base: EChartsOption = {
tooltip: { trigger: "axis" },
xAxis: [{ type: "category", data: ["Jan", "Feb"] }],
series: [{ type: "line", data: [10, 20] }],
};
const update: EChartsOption = {
xAxis: [{ type: "category", data: ["Jan", "Feb"] }],
series: [{ type: "line", data: [12, 18] }],
};
const result = planUpdate(buildSignature(base), update);
expect(result.option.tooltip).toBeNull();
expect(result.option.xAxis).toEqual(update.xAxis);
expect(result.plan.notMerge).toBe(false);
expect(result.plan.replaceMerge).toBeUndefined();
});
it("handles dataset to series data migration", () => {
const base: EChartsOption = {
dataset: [
{
id: "sales",
source: [
["Latte", 30],
["Mocha", 40],
],
},
],
series: [{ id: "sales", type: "bar", datasetId: "sales" }],
};
const update: EChartsOption = {
series: [{ id: "sales", type: "bar", data: [35, 44] }],
};
const result = planUpdate(buildSignature(base), update);
expect(result.option.dataset).toEqual([]);
expect(result.option.series).toEqual(update.series);
expect(result.plan.replaceMerge).toEqual(["dataset"]);
});
it("tracks series ID removal while keeping modifications", () => {
const base: EChartsOption = {
series: [
{ id: "latte", type: "bar", data: [10, 20] },
{ id: "mocha", type: "bar", data: [15, 25] },
],
};
const update: EChartsOption = {
series: [{ id: "latte", type: "line", data: [11, 22] }],
};
const result = planUpdate(buildSignature(base), update);
expect(result.option.series).toEqual(update.series);
expect(result.plan.replaceMerge).toEqual(["series"]);
});
});
});
});

8
tsconfig.vitest.json Normal file
View File

@ -0,0 +1,8 @@
{
"extends": "./tsconfig.json",
"compilerOptions": {
"types": ["vitest/globals", "node"],
"noEmit": true
},
"include": ["tests/**/*.ts", "src/**/*.ts"]
}

12
vitest.config.ts Normal file
View File

@ -0,0 +1,12 @@
import { defineConfig } from "vitest/config";
export default defineConfig({
test: {
globals: true,
environment: "node",
include: ["tests/**/*.test.ts"],
coverage: {
reporter: ["text", "lcov"],
},
},
});