// @vitest-environment jsdom import { act, renderHook } from "@testing-library/react"; import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import { useAutosave } from "./useAutosave"; import { AUTOSAVE_DELAY_MS } from "./autosave"; // 后端客户端与 Toast 是 hook 的副作用边界,单测一律 mock;防抖逻辑(autosave.ts)保留真实。 const put = vi.fn(); const toast = vi.fn(); vi.mock("@/lib/api/client", () => ({ api: { PUT: (...a: unknown[]) => put(...a) } })); vi.mock("@/components/Toast", () => ({ useToast: () => toast })); describe("useAutosave", () => { beforeEach(() => { vi.useFakeTimers(); put.mockReset(); toast.mockReset(); put.mockResolvedValue({ error: null }); }); afterEach(() => { vi.useRealTimers(); vi.clearAllMocks(); }); it("无初始草稿时初始为 idle、无标签", () => { // Arrange + Act const { result } = renderHook(() => useAutosave("p1", 1)); // Assert expect(result.current.status).toBe("idle"); expect(result.current.savedLabel).toBeNull(); }); it("种入已存草稿时初始即为 saved 并显示「已保存」", () => { const { result } = renderHook(() => useAutosave("p1", 1, "旧草稿")); expect(result.current.status).toBe("saved"); expect(result.current.savedLabel).toBe("已保存"); }); it("防抖合并:快速多次改动只在静默后保存一次", async () => { // Arrange const { result } = renderHook(() => useAutosave("p1", 2)); // Act:连续三次输入,未到延迟前不应保存。 act(() => { result.current.onChange("a"); result.current.onChange("ab"); result.current.onChange("abc"); }); expect(put).not.toHaveBeenCalled(); await act(async () => { await vi.advanceTimersByTimeAsync(AUTOSAVE_DELAY_MS); }); // Assert:只发一次 PUT,且是最后一次文本;状态落到 saved + 时间标签。 expect(put).toHaveBeenCalledTimes(1); expect(put).toHaveBeenCalledWith( "/projects/{project_id}/chapters/{chapter_no}/draft", { params: { path: { project_id: "p1", chapter_no: 2 } }, body: { text: "abc" }, }, ); expect(result.current.status).toBe("saved"); expect(result.current.savedLabel).toMatch(/^保存于 \d{2}:\d{2}$/); }); it("flush 立即触发待保存项,无需等待延迟", async () => { const { result } = renderHook(() => useAutosave("p1", 3)); act(() => result.current.onChange("即时")); await act(async () => { result.current.flush(); await Promise.resolve(); }); expect(put).toHaveBeenCalledTimes(1); expect(result.current.status).toBe("saved"); }); it("文本与已存基线相同时早退、不发请求", async () => { const { result } = renderHook(() => useAutosave("p1", 4, "基线")); act(() => result.current.onChange("基线")); await act(async () => { await vi.advanceTimersByTimeAsync(AUTOSAVE_DELAY_MS); }); expect(put).not.toHaveBeenCalled(); expect(result.current.status).toBe("saved"); }); it("后端返回 error:状态回滚为 error 并弹 toast", async () => { put.mockResolvedValue({ error: { detail: "boom" } }); const { result } = renderHook(() => useAutosave("p1", 5)); act(() => result.current.onChange("变更")); await act(async () => { await vi.advanceTimersByTimeAsync(AUTOSAVE_DELAY_MS); }); expect(result.current.status).toBe("error"); expect(toast).toHaveBeenCalledWith(expect.any(String), "error"); }); it("卸载时取消待保存定时器,不再发请求", async () => { const { result, unmount } = renderHook(() => useAutosave("p1", 6)); act(() => result.current.onChange("待保存")); unmount(); await act(async () => { await vi.advanceTimersByTimeAsync(AUTOSAVE_DELAY_MS * 2); }); expect(put).not.toHaveBeenCalled(); }); });