test: 接入 jsdom 测试栈, 把 15 个 React hooks 纳入 80% 覆盖率门禁

新增 jsdom + @testing-library/react, 为 lib/** 全部 use*.ts hooks 写 renderHook 单测:
生成/CRUD(world/character/generator/outline/accept/foreshadow/rules)、
SSE流(draftStream/reviewStream)、定时轮询(autosave/jobPoll/kimiOauth)、
文风与注入(refine/styleLearn/injection)。覆盖率 mock api 客户端+Toast, 不打真实 LLM。
vitest.config.ts 去掉 use*.ts 排除; 前端覆盖率 63%→95%。CLAUDE.md 同步说明。
This commit is contained in:
Yaojia Wang
2026-06-27 06:21:42 +02:00
parent a02c6b6e4f
commit a6f5d085e5
19 changed files with 2838 additions and 18 deletions

View File

@@ -0,0 +1,219 @@
// @vitest-environment jsdom
import { act, renderHook } from "@testing-library/react";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { useKimiOauth } from "./useKimiOauth";
// 后端客户端POST start/disconnect + GET job/status经真实 useJobPoll与 Toast 是副作用边界。
// kimiOauth.ts/job.ts 纯逻辑保留真实。
const post = vi.fn();
const get = vi.fn();
const toast = vi.fn();
vi.mock("@/lib/api/client", () => ({
api: {
POST: (...a: unknown[]) => post(...a),
GET: (...a: unknown[]) => get(...a),
},
}));
vi.mock("@/components/Toast", () => ({ useToast: () => toast }));
const START = "/settings/providers/kimi-code/oauth/start";
const DISCONNECT = "/settings/providers/kimi-code/oauth/disconnect";
const STATUS = "/settings/providers/kimi-code/oauth/status";
const JOB = "/jobs/{job_id}";
const startOk = {
data: {
job_id: "job-1",
user_code: "ABCD-1234",
verification_uri: "https://kimi.example/verify",
verification_uri_complete: "https://kimi.example/verify?code=ABCD-1234",
expires_in: 600,
interval: 5,
},
error: null,
};
// 把 GET 按 path 路由job 轮询 vs 状态查询。
function routeGet(jobResult: unknown, statusResult: unknown): void {
get.mockImplementation((path: string) => {
if (path === JOB) return Promise.resolve(jobResult);
return Promise.resolve(statusResult);
});
}
describe("useKimiOauth", () => {
beforeEach(() => {
vi.useFakeTimers();
post.mockReset();
get.mockReset();
toast.mockReset();
vi.spyOn(window, "open").mockReturnValue(null);
});
afterEach(() => {
vi.useRealTimers();
vi.clearAllMocks();
});
it("初始未连接phase=idle、无 device、不忙、无错误", () => {
const { result } = renderHook(() =>
useKimiOauth({ initialConnected: false, initialExpiresAt: null }),
);
expect(result.current.phase).toBe("idle");
expect(result.current.device).toBeNull();
expect(result.current.busy).toBe(false);
expect(result.current.error).toBeNull();
});
it("初始已连接phase=connected 并带过期时刻", () => {
const { result } = renderHook(() =>
useKimiOauth({
initialConnected: true,
initialExpiresAt: "2026-12-31T00:00:00Z",
}),
);
expect(result.current.phase).toBe("connected");
expect(result.current.expiresAt).toBe("2026-12-31T00:00:00Z");
});
it("connect 成功pending→authorized展示 device、轮询 done 后置已连接并弹成功 toast", async () => {
// Arrangestart 拿到 device + job_idjob 首拍即 done 且 connected状态查询确认已连接。
post.mockImplementation((path: string) =>
path === START ? Promise.resolve(startOk) : Promise.resolve({ data: {}, error: null }),
);
routeGet(
{ data: { id: "job-1", status: "done", progress: 100, result: { connected: true } }, error: null },
{ data: { connected: true, expires_at: "2027-01-01T00:00:00Z" }, error: null },
);
const { result } = renderHook(() =>
useKimiOauth({ initialConnected: false, initialExpiresAt: null }),
);
// Act发起连接刷掉 job 轮询 + refreshStatus 的微任务。
await act(async () => {
await result.current.connect();
await vi.advanceTimersByTimeAsync(0);
});
await act(async () => {
await vi.advanceTimersByTimeAsync(0);
});
// Assert已展示 device、phase=connected、刷新到新过期时刻、弹成功 toast。
expect(result.current.device?.userCode).toBe("ABCD-1234");
expect(window.open).toHaveBeenCalledWith(
"https://kimi.example/verify?code=ABCD-1234",
"_blank",
"noopener,noreferrer",
);
expect(result.current.phase).toBe("connected");
expect(result.current.expiresAt).toBe("2027-01-01T00:00:00Z");
expect(toast).toHaveBeenCalledWith("已连接 Kimi Code。", "success");
});
it("connect 后授权进行中job 仍 running → phase=awaiting 且 busy", async () => {
post.mockImplementation((path: string) =>
path === START ? Promise.resolve(startOk) : Promise.resolve({ data: {}, error: null }),
);
routeGet(
{ data: { id: "job-1", status: "running", progress: 20 }, error: null },
{ data: { connected: false }, error: null },
);
const { result } = renderHook(() =>
useKimiOauth({ initialConnected: false, initialExpiresAt: null }),
);
await act(async () => {
await result.current.connect();
await vi.advanceTimersByTimeAsync(0);
});
expect(result.current.phase).toBe("awaiting");
expect(result.current.busy).toBe(true);
});
it("connect 轮询失败job failedphase=error 并弹失败 toast", async () => {
post.mockImplementation((path: string) =>
path === START ? Promise.resolve(startOk) : Promise.resolve({ data: {}, error: null }),
);
routeGet(
{ data: { id: "job-1", status: "failed", progress: 0, error: "授权已过期" }, error: null },
{ data: { connected: false }, error: null },
);
const { result } = renderHook(() =>
useKimiOauth({ initialConnected: false, initialExpiresAt: null }),
);
await act(async () => {
await result.current.connect();
await vi.advanceTimersByTimeAsync(0);
});
await act(async () => {
await vi.advanceTimersByTimeAsync(0);
});
expect(result.current.phase).toBe("error");
expect(result.current.error).toBe("授权已过期");
expect(toast).toHaveBeenCalledWith(
expect.stringContaining("连接失败"),
"error",
);
});
it("connect 发起失败start 返回 error保持 idle 并弹错误 toast", async () => {
post.mockResolvedValue({ data: null, error: { detail: "rate limited" } });
const { result } = renderHook(() =>
useKimiOauth({ initialConnected: false, initialExpiresAt: null }),
);
await act(async () => {
await result.current.connect();
});
expect(result.current.phase).toBe("idle");
expect(result.current.device).toBeNull();
expect(toast).toHaveBeenCalledWith(
"发起 Kimi Code 连接失败,请稍后重试。",
"error",
);
});
it("disconnect 成功:复位为未连接并弹成功 toast", async () => {
post.mockResolvedValue({ data: { ok: true }, error: null });
const { result } = renderHook(() =>
useKimiOauth({
initialConnected: true,
initialExpiresAt: "2026-12-31T00:00:00Z",
}),
);
await act(async () => {
await result.current.disconnect();
});
expect(result.current.phase).toBe("idle");
expect(result.current.device).toBeNull();
expect(result.current.expiresAt).toBeNull();
expect(toast).toHaveBeenCalledWith("已断开 Kimi Code。", "success");
});
it("disconnect 失败(返回 error弹错误 toast、不复位", async () => {
post.mockResolvedValue({ data: null, error: { detail: "boom" } });
const { result } = renderHook(() =>
useKimiOauth({
initialConnected: true,
initialExpiresAt: "2026-12-31T00:00:00Z",
}),
);
await act(async () => {
await result.current.disconnect();
});
expect(result.current.phase).toBe("connected");
expect(toast).toHaveBeenCalledWith(
"断开 Kimi Code 失败,请稍后重试。",
"error",
);
});
});