新增 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 同步说明。
135 lines
4.2 KiB
TypeScript
135 lines
4.2 KiB
TypeScript
// @vitest-environment jsdom
|
||
import { act, renderHook } from "@testing-library/react";
|
||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||
|
||
import { useForeshadow } from "./useForeshadow";
|
||
import type { ForeshadowView } from "@/lib/api/types";
|
||
|
||
// 后端客户端与 Toast 是 hook 的外部副作用边界,单测一律 mock。
|
||
const post = vi.fn();
|
||
const patch = vi.fn();
|
||
const toast = vi.fn();
|
||
vi.mock("@/lib/api/client", () => ({
|
||
api: {
|
||
POST: (...a: unknown[]) => post(...a),
|
||
PATCH: (...a: unknown[]) => patch(...a),
|
||
},
|
||
}));
|
||
vi.mock("@/components/Toast", () => ({ useToast: () => toast }));
|
||
|
||
function makeRow(code: string, status = "OPEN"): ForeshadowView {
|
||
return { code, title: code, status } as ForeshadowView;
|
||
}
|
||
|
||
const registerInput = { projectId: "p1", code: "FS-01", title: "线索" };
|
||
|
||
describe("useForeshadow", () => {
|
||
beforeEach(() => {
|
||
post.mockReset();
|
||
patch.mockReset();
|
||
toast.mockReset();
|
||
});
|
||
afterEach(() => vi.clearAllMocks());
|
||
|
||
it("初始 items 取自传入、busy=false", () => {
|
||
const initial = [makeRow("FS-01")];
|
||
const { result } = renderHook(() => useForeshadow(initial));
|
||
expect(result.current.items).toEqual(initial);
|
||
expect(result.current.busy).toBe(false);
|
||
});
|
||
|
||
it("登记成功:追加返回行并弹成功 toast", async () => {
|
||
const row = makeRow("FS-02");
|
||
post.mockResolvedValue({ data: row, error: null });
|
||
|
||
const { result } = renderHook(() => useForeshadow([makeRow("FS-01")]));
|
||
let ok;
|
||
await act(async () => {
|
||
ok = await result.current.register(registerInput);
|
||
});
|
||
|
||
expect(ok).toBe(true);
|
||
expect(result.current.items).toHaveLength(2);
|
||
expect(result.current.items[1]).toEqual(row);
|
||
expect(result.current.busy).toBe(false);
|
||
expect(toast).toHaveBeenCalledWith("已登记伏笔 FS-02", "success");
|
||
});
|
||
|
||
it("登记失败(422 duplicate):不改 items 并弹 reason toast", async () => {
|
||
post.mockResolvedValue({
|
||
data: null,
|
||
error: { error: { details: { reason: "duplicate" } } },
|
||
});
|
||
|
||
const initial = [makeRow("FS-01")];
|
||
const { result } = renderHook(() => useForeshadow(initial));
|
||
let ok;
|
||
await act(async () => {
|
||
ok = await result.current.register(registerInput);
|
||
});
|
||
|
||
expect(ok).toBe(false);
|
||
expect(result.current.items).toEqual(initial);
|
||
expect(toast).toHaveBeenCalledWith("该伏笔代号已存在,请换一个。", "error");
|
||
});
|
||
|
||
it("转移成功:乐观改 status 后用服务端权威行替换", async () => {
|
||
const server = makeRow("FS-01", "CLOSED");
|
||
patch.mockResolvedValue({ data: server, error: null });
|
||
|
||
const { result } = renderHook(() => useForeshadow([makeRow("FS-01")]));
|
||
let ok;
|
||
await act(async () => {
|
||
ok = await result.current.transition("FS-01", {
|
||
projectId: "p1",
|
||
toStatus: "CLOSED",
|
||
});
|
||
});
|
||
|
||
expect(ok).toBe(true);
|
||
expect(result.current.items[0]).toEqual(server);
|
||
expect(toast).toHaveBeenCalledWith("已更新 FS-01", "success");
|
||
});
|
||
|
||
it("转移失败:回滚到旧 items 并弹 reason toast", async () => {
|
||
patch.mockResolvedValue({
|
||
data: null,
|
||
error: { error: { details: { reason: "invalid_transition" } } },
|
||
});
|
||
|
||
const initial = [makeRow("FS-01", "PARTIAL")];
|
||
const { result } = renderHook(() => useForeshadow(initial));
|
||
let ok;
|
||
await act(async () => {
|
||
ok = await result.current.transition("FS-01", {
|
||
projectId: "p1",
|
||
toStatus: "CLOSED",
|
||
});
|
||
});
|
||
|
||
expect(ok).toBe(false);
|
||
expect(result.current.items).toEqual(initial);
|
||
expect(toast).toHaveBeenCalledWith(
|
||
"非法状态转移(CLOSED 为终态,不可再改)。",
|
||
"error",
|
||
);
|
||
});
|
||
|
||
it("转移无 toStatus 时不乐观改、仅按服务端结果替换", async () => {
|
||
const server = makeRow("FS-01", "OPEN");
|
||
patch.mockResolvedValue({ data: server, error: null });
|
||
|
||
const { result } = renderHook(() => useForeshadow([makeRow("FS-01")]));
|
||
let ok;
|
||
await act(async () => {
|
||
ok = await result.current.transition("FS-01", {
|
||
projectId: "p1",
|
||
progressEntry: { chapter: 5, note: "推进" },
|
||
});
|
||
});
|
||
|
||
expect(ok).toBe(true);
|
||
expect(result.current.items[0]).toEqual(server);
|
||
});
|
||
});
|