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:
Blizzard
2026-06-24 11:53:54 +08:00
parent a2d184b7ec
commit f37d046f2c
10 changed files with 2682 additions and 6 deletions
File diff suppressed because it is too large Load Diff
+9 -2
View File
@@ -6,7 +6,9 @@
"scripts": {
"dev": "vite",
"build": "tsc && vite build",
"preview": "vite preview"
"preview": "vite preview",
"test": "vitest run",
"test:watch": "vitest"
},
"dependencies": {
"@xyflow/react": "^12.3.0",
@@ -16,13 +18,18 @@
"react-force-graph-2d": "^1.29.1"
},
"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-dom": "^19.0.0",
"@vitejs/plugin-react": "^4.3.0",
"autoprefixer": "^10.4.0",
"jsdom": "^29.1.1",
"postcss": "^8.4.0",
"tailwindcss": "^3.4.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("必填项缺失报 warninput.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("deriveNodesExecEvent 流 → 节点轨迹)", () => {
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 非 2xx404/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");
});
});
+2 -2
View File
@@ -37,8 +37,8 @@ export function openExternal(url: string): void {
}
}
// isNewer 语义化版本比较:a 是否比 b 新(按 major.minor.patch)。
function isNewer(a: string, b: string): boolean {
// isNewer 语义化版本比较:a 是否比 b 新(按 major.minor.patch)。导出以便单测覆盖版本判定矩阵。
export function isNewer(a: string, b: string): boolean {
const pa = a.split(".").map((n) => parseInt(n, 10) || 0);
const pb = b.split(".").map((n) => parseInt(n, 10) || 0);
for (let i = 0; i < 3; i++) {
@@ -0,0 +1,2 @@
// Vitest 全局测试初始化:引入 jest-dom 断言(toBeInTheDocument 等)。
import "@testing-library/jest-dom/vitest";
+2 -1
View File
@@ -14,5 +14,6 @@
"baseUrl": ".",
"paths": { "@/*": ["./src/*"] }
},
"include": ["src"]
"include": ["src"],
"exclude": ["src/**/*.test.ts", "src/**/*.test.tsx", "src/test"]
}
+8
View File
@@ -1,3 +1,4 @@
/// <reference types="vitest/config" />
import { defineConfig } from "vite";
import react from "@vitejs/plugin-react";
import path from "node:path";
@@ -7,4 +8,11 @@ export default defineConfig({
resolve: {
alias: { "@": path.resolve(__dirname, "./src") },
},
// 单元/组件测试:纯逻辑 + 关键组件(jsdom 环境)。运行:npm test
test: {
environment: "jsdom",
globals: true,
setupFiles: ["./src/test/setup.ts"],
include: ["src/**/*.{test,spec}.{ts,tsx}"],
},
});