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