test(desktop): 前端单元 + 关键组件测试(Vitest + RTL,31 例)
测试基建:vite.config.ts 加 test 配置(jsdom + setup),package.json 加 test/test:watch 脚本,src/test/setup.ts 引 jest-dom;tsconfig 把测试文件 exclude, 使 CI 现有 tsc --noEmit 不检查测试文件(仍绿),测试由 Vitest 自跑(先不接 CI)。 覆盖(31 例 / 4 文件): - dsl:exportDsl 节点/边映射 + config 兜底 + branch 真假边 sourceHandle 条件; validate 空画布/孤立节点/必填项缺失。 - version:isNewer 版本比较矩阵;checkUpdate 有更新/同版/旧版/404限流/离线异常; openExternal Wails / 浏览器双路径。 - run:deriveNodes start→running、end→done+耗时、error、error 不被 start 覆盖、 info 累计 notes、首现顺序、label 更新。 - UpdateBanner(RTL):无更新不渲染、有更新显版本+点下载调 openExternal、点忽略消失。 小改:version.ts 导出 isNewer(原私有)以便直接测版本判定矩阵。 Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
+2420
-1
File diff suppressed because it is too large
Load Diff
@@ -6,7 +6,9 @@
|
|||||||
"scripts": {
|
"scripts": {
|
||||||
"dev": "vite",
|
"dev": "vite",
|
||||||
"build": "tsc && vite build",
|
"build": "tsc && vite build",
|
||||||
"preview": "vite preview"
|
"preview": "vite preview",
|
||||||
|
"test": "vitest run",
|
||||||
|
"test:watch": "vitest"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@xyflow/react": "^12.3.0",
|
"@xyflow/react": "^12.3.0",
|
||||||
@@ -16,13 +18,18 @@
|
|||||||
"react-force-graph-2d": "^1.29.1"
|
"react-force-graph-2d": "^1.29.1"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
|
"@testing-library/jest-dom": "^6.9.1",
|
||||||
|
"@testing-library/react": "^16.3.2",
|
||||||
|
"@testing-library/user-event": "^14.6.1",
|
||||||
"@types/react": "^19.0.0",
|
"@types/react": "^19.0.0",
|
||||||
"@types/react-dom": "^19.0.0",
|
"@types/react-dom": "^19.0.0",
|
||||||
"@vitejs/plugin-react": "^4.3.0",
|
"@vitejs/plugin-react": "^4.3.0",
|
||||||
"autoprefixer": "^10.4.0",
|
"autoprefixer": "^10.4.0",
|
||||||
|
"jsdom": "^29.1.1",
|
||||||
"postcss": "^8.4.0",
|
"postcss": "^8.4.0",
|
||||||
"tailwindcss": "^3.4.0",
|
"tailwindcss": "^3.4.0",
|
||||||
"typescript": "^5.6.0",
|
"typescript": "^5.6.0",
|
||||||
"vite": "^5.4.0"
|
"vite": "^5.4.0",
|
||||||
|
"vitest": "^4.1.9"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,47 @@
|
|||||||
|
import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||||
|
import { render, screen, waitFor } from "@testing-library/react";
|
||||||
|
import userEvent from "@testing-library/user-event";
|
||||||
|
import type { Mock } from "vitest";
|
||||||
|
|
||||||
|
// mock 版本模块:控制 checkUpdate 返回值、监视 openExternal。
|
||||||
|
vi.mock("../lib/version", () => ({
|
||||||
|
checkUpdate: vi.fn(),
|
||||||
|
openExternal: vi.fn(),
|
||||||
|
}));
|
||||||
|
import { UpdateBanner } from "./UpdateBanner";
|
||||||
|
import { checkUpdate, openExternal } from "../lib/version";
|
||||||
|
|
||||||
|
const mockCheck = checkUpdate as Mock;
|
||||||
|
const mockOpen = openExternal as Mock;
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
mockCheck.mockReset();
|
||||||
|
mockOpen.mockReset();
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("UpdateBanner", () => {
|
||||||
|
it("无更新(checkUpdate→null)时不渲染任何东西", async () => {
|
||||||
|
mockCheck.mockResolvedValue(null);
|
||||||
|
const { container } = render(<UpdateBanner />);
|
||||||
|
await waitFor(() => expect(mockCheck).toHaveBeenCalled());
|
||||||
|
expect(container).toBeEmptyDOMElement();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("有更新时显示版本号,点「前往下载」调 openExternal(url)", async () => {
|
||||||
|
mockCheck.mockResolvedValue({ version: "1.2.3", url: "https://rel", notes: "" });
|
||||||
|
render(<UpdateBanner />);
|
||||||
|
expect(await screen.findByText("v1.2.3")).toBeInTheDocument();
|
||||||
|
|
||||||
|
await userEvent.click(screen.getByRole("button", { name: "前往下载" }));
|
||||||
|
expect(mockOpen).toHaveBeenCalledWith("https://rel");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("点忽略后横幅消失", async () => {
|
||||||
|
mockCheck.mockResolvedValue({ version: "1.2.3", url: "https://rel", notes: "" });
|
||||||
|
render(<UpdateBanner />);
|
||||||
|
expect(await screen.findByText("v1.2.3")).toBeInTheDocument();
|
||||||
|
|
||||||
|
await userEvent.click(screen.getByTitle("忽略"));
|
||||||
|
expect(screen.queryByText("v1.2.3")).not.toBeInTheDocument();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,60 @@
|
|||||||
|
import { describe, it, expect } from "vitest";
|
||||||
|
import type { Node, Edge } from "@xyflow/react";
|
||||||
|
import { exportDsl, validate } from "./dsl";
|
||||||
|
|
||||||
|
// 构造画布节点的小工具(只填导出/校验关心的字段)。
|
||||||
|
function node(id: string, kind: string, config?: unknown, label?: string): Node {
|
||||||
|
return { id, position: { x: 0, y: 0 }, data: { kind, label, config } } as unknown as Node;
|
||||||
|
}
|
||||||
|
function edge(source: string, target: string, sourceHandle?: string): Edge {
|
||||||
|
return { id: `${source}-${target}`, source, target, sourceHandle } as unknown as Edge;
|
||||||
|
}
|
||||||
|
|
||||||
|
describe("exportDsl", () => {
|
||||||
|
it("把节点映射为 {id,kind,label,config}", () => {
|
||||||
|
const dsl = exportDsl([node("in", "input", { text: "hi" }, "输入")], []);
|
||||||
|
expect(dsl.version).toBe("1");
|
||||||
|
expect(dsl.nodes).toEqual([{ id: "in", kind: "input", label: "输入", config: { text: "hi" } }]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("缺失 config 时回退为空对象", () => {
|
||||||
|
const dsl = exportDsl([node("in", "input")], []);
|
||||||
|
expect(dsl.nodes[0].config).toEqual({});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("仅在存在 sourceHandle 时才带上该字段(branch 真/假边)", () => {
|
||||||
|
const dsl = exportDsl(
|
||||||
|
[node("b", "branch"), node("t", "agent"), node("f", "agent")],
|
||||||
|
[edge("b", "t", "true"), edge("b", "f")],
|
||||||
|
);
|
||||||
|
expect(dsl.edges[0]).toEqual({ source: "b", target: "t", sourceHandle: "true" });
|
||||||
|
expect(dsl.edges[1]).toEqual({ source: "b", target: "f" });
|
||||||
|
expect("sourceHandle" in dsl.edges[1]).toBe(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("validate", () => {
|
||||||
|
it("空画布报 error", () => {
|
||||||
|
const issues = validate([], []);
|
||||||
|
expect(issues).toHaveLength(1);
|
||||||
|
expect(issues[0].level).toBe("error");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("单节点不报孤立警告(length>1 才校验连线)", () => {
|
||||||
|
const issues = validate([node("in", "input", { text: "hi" })], []);
|
||||||
|
expect(issues.some((i) => i.msg.includes("孤立"))).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("多节点中未连线的节点报孤立 warn", () => {
|
||||||
|
const issues = validate(
|
||||||
|
[node("a", "input", { text: "hi" }), node("b", "agent", { system: "x" })],
|
||||||
|
[], // 都没连
|
||||||
|
);
|
||||||
|
expect(issues.filter((i) => i.msg.includes("孤立"))).toHaveLength(2);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("必填项缺失报 warn(input.text 为必填)", () => {
|
||||||
|
const issues = validate([node("in", "input", { text: "" })], []);
|
||||||
|
expect(issues.some((i) => i.level === "warn" && i.msg.includes("缺必填项"))).toBe(true);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,51 @@
|
|||||||
|
import { describe, it, expect } from "vitest";
|
||||||
|
import type { ExecEvent } from "./api";
|
||||||
|
import { deriveNodes } from "./run";
|
||||||
|
|
||||||
|
let seq = 0;
|
||||||
|
function ev(node: string, phase: string, extra: Partial<ExecEvent> = {}): ExecEvent {
|
||||||
|
return { seq: seq++, ts: 0, node, kind: extra.kind ?? "system", phase, label: extra.label ?? node, ...extra };
|
||||||
|
}
|
||||||
|
|
||||||
|
describe("deriveNodes(ExecEvent 流 → 节点轨迹)", () => {
|
||||||
|
it("start → running", () => {
|
||||||
|
const [n] = deriveNodes([ev("a", "start")]);
|
||||||
|
expect(n.status).toBe("running");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("start→end → done 且带耗时", () => {
|
||||||
|
const [n] = deriveNodes([ev("a", "start"), ev("a", "end", { ms: 123, detail: "ok" })]);
|
||||||
|
expect(n.status).toBe("done");
|
||||||
|
expect(n.ms).toBe(123);
|
||||||
|
expect(n.detail).toBe("ok");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("error → error 状态", () => {
|
||||||
|
const [n] = deriveNodes([ev("a", "start"), ev("a", "error", { ms: 5, detail: "boom" })]);
|
||||||
|
expect(n.status).toBe("error");
|
||||||
|
expect(n.detail).toBe("boom");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("已 error 的节点不会被后续 start 覆盖回 running", () => {
|
||||||
|
const [n] = deriveNodes([ev("a", "error"), ev("a", "start")]);
|
||||||
|
expect(n.status).toBe("error");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("info 事件累计到 notes", () => {
|
||||||
|
const [n] = deriveNodes([
|
||||||
|
ev("a", "info", { detail: "检索到 3 条" }),
|
||||||
|
ev("a", "info", { detail: "命中向量库" }),
|
||||||
|
]);
|
||||||
|
expect(n.notes).toEqual(["检索到 3 条", "命中向量库"]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("按首次出现顺序聚合多个节点", () => {
|
||||||
|
const nodes = deriveNodes([ev("first", "start"), ev("second", "start"), ev("first", "end")]);
|
||||||
|
expect(nodes.map((n) => n.node)).toEqual(["first", "second"]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("后续事件可更新 label", () => {
|
||||||
|
const [n] = deriveNodes([ev("a", "start", { label: "旧" }), ev("a", "end", { label: "新" })]);
|
||||||
|
expect(n.label).toBe("新");
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,81 @@
|
|||||||
|
import { describe, it, expect, vi, afterEach } from "vitest";
|
||||||
|
import { isNewer, checkUpdate, openExternal, APP_VERSION } from "./version";
|
||||||
|
|
||||||
|
describe("isNewer(语义化版本比较)", () => {
|
||||||
|
it.each([
|
||||||
|
["1.0.1", "1.0.0", true],
|
||||||
|
["1.1.0", "1.0.9", true],
|
||||||
|
["2.0.0", "1.9.9", true],
|
||||||
|
["1.0.0", "1.0.0", false],
|
||||||
|
["1.0.0", "1.0.1", false],
|
||||||
|
["0.9.9", "1.0.0", false],
|
||||||
|
])("isNewer(%s, %s) === %s", (a, b, want) => {
|
||||||
|
expect(isNewer(a as string, b as string)).toBe(want);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("缺省的 patch 位按 0 处理", () => {
|
||||||
|
expect(isNewer("1.1", "1.0.9")).toBe(true);
|
||||||
|
expect(isNewer("1.0", "1.0.0")).toBe(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("checkUpdate", () => {
|
||||||
|
afterEach(() => vi.unstubAllGlobals());
|
||||||
|
|
||||||
|
function stubFetch(impl: () => Promise<unknown> | unknown) {
|
||||||
|
vi.stubGlobal("fetch", vi.fn().mockImplementation(impl));
|
||||||
|
}
|
||||||
|
|
||||||
|
it("有更新版本 → 返回 ReleaseInfo", async () => {
|
||||||
|
stubFetch(() => ({
|
||||||
|
ok: true,
|
||||||
|
json: async () => ({ tag_name: "v9.9.9", html_url: "https://x/rel", body: "日志" }),
|
||||||
|
}));
|
||||||
|
const r = await checkUpdate();
|
||||||
|
expect(r).toEqual({ version: "9.9.9", url: "https://x/rel", notes: "日志" });
|
||||||
|
});
|
||||||
|
|
||||||
|
it("同版本 → null", async () => {
|
||||||
|
stubFetch(() => ({ ok: true, json: async () => ({ tag_name: `v${APP_VERSION}` }) }));
|
||||||
|
expect(await checkUpdate()).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("更旧版本 → null", async () => {
|
||||||
|
stubFetch(() => ({ ok: true, json: async () => ({ tag_name: "v0.0.1" }) }));
|
||||||
|
expect(await checkUpdate()).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("HTTP 非 2xx(404/403 限流)→ 静默 null", async () => {
|
||||||
|
stubFetch(() => ({ ok: false, status: 404 }));
|
||||||
|
expect(await checkUpdate()).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("网络异常(离线)→ 静默 null", async () => {
|
||||||
|
stubFetch(() => {
|
||||||
|
throw new Error("offline");
|
||||||
|
});
|
||||||
|
expect(await checkUpdate()).toBeNull();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("openExternal", () => {
|
||||||
|
afterEach(() => {
|
||||||
|
delete (window as unknown as { runtime?: unknown }).runtime;
|
||||||
|
vi.restoreAllMocks();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("有 Wails runtime → 走 BrowserOpenURL", () => {
|
||||||
|
const spy = vi.fn();
|
||||||
|
(window as unknown as { runtime: { BrowserOpenURL: (u: string) => void } }).runtime = {
|
||||||
|
BrowserOpenURL: spy,
|
||||||
|
};
|
||||||
|
openExternal("https://a");
|
||||||
|
expect(spy).toHaveBeenCalledWith("https://a");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("无 runtime(浏览器模式)→ 回退 window.open", () => {
|
||||||
|
const open = vi.spyOn(window, "open").mockImplementation(() => null);
|
||||||
|
openExternal("https://b");
|
||||||
|
expect(open).toHaveBeenCalledWith("https://b", "_blank");
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -37,8 +37,8 @@ export function openExternal(url: string): void {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// isNewer 语义化版本比较:a 是否比 b 新(按 major.minor.patch)。
|
// isNewer 语义化版本比较:a 是否比 b 新(按 major.minor.patch)。导出以便单测覆盖版本判定矩阵。
|
||||||
function isNewer(a: string, b: string): boolean {
|
export function isNewer(a: string, b: string): boolean {
|
||||||
const pa = a.split(".").map((n) => parseInt(n, 10) || 0);
|
const pa = a.split(".").map((n) => parseInt(n, 10) || 0);
|
||||||
const pb = b.split(".").map((n) => parseInt(n, 10) || 0);
|
const pb = b.split(".").map((n) => parseInt(n, 10) || 0);
|
||||||
for (let i = 0; i < 3; i++) {
|
for (let i = 0; i < 3; i++) {
|
||||||
|
|||||||
@@ -0,0 +1,2 @@
|
|||||||
|
// Vitest 全局测试初始化:引入 jest-dom 断言(toBeInTheDocument 等)。
|
||||||
|
import "@testing-library/jest-dom/vitest";
|
||||||
@@ -14,5 +14,6 @@
|
|||||||
"baseUrl": ".",
|
"baseUrl": ".",
|
||||||
"paths": { "@/*": ["./src/*"] }
|
"paths": { "@/*": ["./src/*"] }
|
||||||
},
|
},
|
||||||
"include": ["src"]
|
"include": ["src"],
|
||||||
|
"exclude": ["src/**/*.test.ts", "src/**/*.test.tsx", "src/test"]
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
/// <reference types="vitest/config" />
|
||||||
import { defineConfig } from "vite";
|
import { defineConfig } from "vite";
|
||||||
import react from "@vitejs/plugin-react";
|
import react from "@vitejs/plugin-react";
|
||||||
import path from "node:path";
|
import path from "node:path";
|
||||||
@@ -7,4 +8,11 @@ export default defineConfig({
|
|||||||
resolve: {
|
resolve: {
|
||||||
alias: { "@": path.resolve(__dirname, "./src") },
|
alias: { "@": path.resolve(__dirname, "./src") },
|
||||||
},
|
},
|
||||||
|
// 单元/组件测试:纯逻辑 + 关键组件(jsdom 环境)。运行:npm test
|
||||||
|
test: {
|
||||||
|
environment: "jsdom",
|
||||||
|
globals: true,
|
||||||
|
setupFiles: ["./src/test/setup.ts"],
|
||||||
|
include: ["src/**/*.{test,spec}.{ts,tsx}"],
|
||||||
|
},
|
||||||
});
|
});
|
||||||
|
|||||||
Reference in New Issue
Block a user