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:
181
apps/web/lib/workbench/useInjection.test.ts
Normal file
181
apps/web/lib/workbench/useInjection.test.ts
Normal 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("挂载成功:拉到确定性结果并落到 data,loading 归位", 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);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user