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:
Yaojia Wang
2026-06-27 06:21:42 +02:00
parent a02c6b6e4f
commit a6f5d085e5
19 changed files with 2838 additions and 18 deletions

View File

@@ -0,0 +1,134 @@
// @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);
});
});