Files
writer-work-flow/apps/web/lib/workbench/useInjection.test.ts

233 lines
8.2 KiB
TypeScript
Raw Permalink 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.loadError).toBeNull();
expect(result.current.saveError).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.loadError).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.loadError).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.saveError).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.saveError).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.saveError).toBe("保存注入设置失败,请重试");
expect(result.current.data).toEqual(loaded);
});
it("保存失败不清空 data且 loadError 与 saveError 互不影响", 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));
// 加载成功 → loadError 为空。
expect(result.current.loadError).toBeNull();
await act(async () => {
await result.current.togglePin(CHAR);
});
// 保存失败只设 saveErrordata 与 loadError 不受影响。
expect(result.current.saveError).toBe("保存注入设置失败,请重试");
expect(result.current.loadError).toBeNull();
expect(result.current.data).toEqual(loaded);
// clearSaveError 关闭悬浮提示data 仍在。
act(() => result.current.clearSaveError());
expect(result.current.saveError).toBeNull();
expect(result.current.data).toEqual(loaded);
});
it("retrySave 重放上一次失败的覆盖,成功后回放 data、清空 saveError", async () => {
const loaded = makeResponse({ recent_n: 4 });
get.mockResolvedValue({ data: loaded, error: null });
put.mockResolvedValueOnce({ data: null, error: { detail: "422" } });
const updated = makeResponse({ recent_n: 4, pinned: [{ ...CHAR }] });
put.mockResolvedValueOnce({ 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(result.current.saveError).toBe("保存注入设置失败,请重试");
await act(async () => {
await result.current.retrySave();
});
expect(put).toHaveBeenCalledTimes(2);
expect(result.current.saveError).toBeNull();
expect(result.current.data).toEqual(updated);
});
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);
});
});