Files
writer-work-flow/apps/web/lib/workbench/useInjection.test.ts
Yaojia Wang a6f5d085e5 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 同步说明。
2026-06-27 06:21:42 +02:00

182 lines
6.2 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

// @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);
});
});