新增 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 同步说明。
182 lines
6.2 KiB
TypeScript
182 lines
6.2 KiB
TypeScript
// @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);
|
||
});
|
||
});
|