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,181 @@
// @vitest-environment jsdom
import { act, renderHook, waitFor } from "@testing-library/react";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { useInjection } from "./useInjection";
import type { InjectionResponse } from "./injection";
// api 客户端是 hook 的外部副作用边界GET 拉确定性结果PUT 写覆盖。单测一律 mock。
const get = vi.fn();
const put = vi.fn();
vi.mock("@/lib/api/client", () => ({
api: {
GET: (...a: unknown[]) => get(...a),
PUT: (...a: unknown[]) => put(...a),
},
}));
// 最小 InjectionResponse 桩(仅覆盖纯逻辑读取的字段)。
function makeResponse(over: Partial<InjectionResponse> = {}): InjectionResponse {
return {
pinned: [],
excluded: [],
recent_n: 3,
entities: [],
...over,
} as InjectionResponse;
}
const CHAR = { kind: "character", name: "张三" } as const;
describe("useInjection", () => {
beforeEach(() => {
get.mockReset();
put.mockReset();
});
afterEach(() => vi.clearAllMocks());
it("挂载成功:拉到确定性结果并落到 dataloading 归位", async () => {
const body = makeResponse({ recent_n: 5 });
get.mockResolvedValue({ data: body, error: null });
const { result } = renderHook(() => useInjection("p1", 3));
await waitFor(() => expect(result.current.loading).toBe(false));
expect(result.current.data).toEqual(body);
expect(result.current.error).toBeNull();
});
it("挂载后端返回 error给可读文案、data 置空", async () => {
get.mockResolvedValue({ data: null, error: { detail: "boom" } });
const { result } = renderHook(() => useInjection("p1", 3));
await waitFor(() => expect(result.current.loading).toBe(false));
expect(result.current.error).toBe("注入信息暂不可用");
expect(result.current.data).toBeNull();
});
it("挂载网络层抛错:捕获并给可读文案", async () => {
get.mockRejectedValue(new Error("network down"));
const { result } = renderHook(() => useInjection("p1", 3));
await waitFor(() => expect(result.current.loading).toBe(false));
expect(result.current.error).toBe("注入信息暂不可用");
expect(result.current.data).toBeNull();
});
it("togglePin 成功PUT 覆盖后以服务端确定结果回放 data", async () => {
get.mockResolvedValue({ data: makeResponse(), error: null });
const updated = makeResponse({ pinned: [{ ...CHAR }] });
put.mockResolvedValue({ data: updated, error: null });
const { result } = renderHook(() => useInjection("p1", 3));
await waitFor(() => expect(result.current.loading).toBe(false));
await act(async () => {
await result.current.togglePin(CHAR);
});
expect(put).toHaveBeenCalledTimes(1);
expect(result.current.data).toEqual(updated);
expect(result.current.saving).toBe(false);
expect(result.current.error).toBeNull();
});
it("exclude / restore / setRecentN 各自经 PUT 提交覆盖", async () => {
get.mockResolvedValue({ data: makeResponse(), error: null });
put.mockResolvedValue({ data: makeResponse(), error: null });
const { result } = renderHook(() => useInjection("p1", 3));
await waitFor(() => expect(result.current.loading).toBe(false));
await act(async () => {
await result.current.exclude(CHAR);
});
await act(async () => {
await result.current.restore(CHAR);
});
await act(async () => {
await result.current.setRecentN(8);
});
expect(put).toHaveBeenCalledTimes(3);
// 最后一次 setRecentN 的 body.recent_n 已钳到合法区间。
expect(put).toHaveBeenLastCalledWith(
"/projects/{project_id}/chapters/{chapter_no}/injection",
expect.objectContaining({ body: expect.objectContaining({ recent_n: 8 }) }),
);
});
it("保存后端返回 error给可读文案、本地 data 不变(天然回滚)", async () => {
const loaded = makeResponse({ recent_n: 4 });
get.mockResolvedValue({ data: loaded, error: null });
put.mockResolvedValue({ data: null, error: { detail: "422" } });
const { result } = renderHook(() => useInjection("p1", 3));
await waitFor(() => expect(result.current.loading).toBe(false));
await act(async () => {
await result.current.togglePin(CHAR);
});
expect(result.current.error).toBe("保存注入设置失败,请重试");
expect(result.current.data).toEqual(loaded);
});
it("保存网络层抛错:本地状态不动、给可读文案、不抛", async () => {
const loaded = makeResponse();
get.mockResolvedValue({ data: loaded, error: null });
put.mockRejectedValue(new Error("network down"));
const { result } = renderHook(() => useInjection("p1", 3));
await waitFor(() => expect(result.current.loading).toBe(false));
await act(async () => {
await result.current.togglePin(CHAR);
});
expect(result.current.error).toBe("保存注入设置失败,请重试");
expect(result.current.data).toEqual(loaded);
});
it("data 未就绪时 mutate 早返回、不发 PUT", async () => {
get.mockResolvedValue({ data: null, error: { detail: "fail" } });
const { result } = renderHook(() => useInjection("p1", 3));
await waitFor(() => expect(result.current.loading).toBe(false));
await act(async () => {
await result.current.togglePin(CHAR);
});
expect(put).not.toHaveBeenCalled();
});
it("并发锁:保存在途时再次触发被拦下,只发一次 PUT", async () => {
get.mockResolvedValue({ data: makeResponse(), error: null });
// 第一次 PUT 挂起,制造在途窗口。
let resolveFirst: (v: unknown) => void = () => {};
put.mockImplementationOnce(
() =>
new Promise((res) => {
resolveFirst = res;
}),
);
const { result } = renderHook(() => useInjection("p1", 3));
await waitFor(() => expect(result.current.loading).toBe(false));
await act(async () => {
// 不 await 第一次:保存在途时立即触发第二次,应被 savingRef 拦下。
const first = result.current.togglePin(CHAR);
await result.current.exclude(CHAR);
resolveFirst({ data: makeResponse(), error: null });
await first;
});
expect(put).toHaveBeenCalledTimes(1);
});
});