Files
writer-work-flow/apps/web/lib/foreshadow/useForeshadow.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

135 lines
4.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 } 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);
});
});