// @vitest-environment jsdom import { act, renderHook } from "@testing-library/react"; import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import { useClarify } from "./useClarify"; import type { ClarifyDecisionVM } from "./clarify"; const post = vi.fn(); vi.mock("@/lib/api/client", () => ({ api: { POST: (...a: unknown[]) => post(...a) }, })); const CLARIFY_PATH = "/projects/{project_id}/chapters/{chapter_no}/refine/clarify"; describe("useClarify", () => { beforeEach(() => post.mockReset()); afterEach(() => vi.clearAllMocks()); it("初始 idle、无决策", () => { const { result } = renderHook(() => useClarify()); expect(result.current.status).toBe("idle"); expect(result.current.decision).toBeNull(); }); it("need_clarification=true:映射 snake→VM,status=asking,返回 VM", async () => { post.mockResolvedValue({ data: { need_clarification: true, questions: [ { question: "想更冷峻还是更抒情?", options: [ { label: "冷峻", value: "改得更冷峻克制" }, { label: "抒情", value: "改得更抒情" }, ], allow_free_text: true, }, ], verification: null, }, error: null, }); const { result } = renderHook(() => useClarify()); let vm: ClarifyDecisionVM | undefined; await act(async () => { vm = await result.current.check("p1", 3, "这段", "改改"); }); expect(post).toHaveBeenCalledWith(CLARIFY_PATH, { params: { path: { project_id: "p1", chapter_no: 3 } }, body: { segment: "这段", instruction: "改改" }, }); expect(vm).toEqual({ needClarification: true, questions: [ { question: "想更冷峻还是更抒情?", options: [ { label: "冷峻", value: "改得更冷峻克制" }, { label: "抒情", value: "改得更抒情" }, ], allowFreeText: true, }, ], verification: undefined, }); expect(result.current.status).toBe("asking"); expect(result.current.decision?.needClarification).toBe(true); }); it("need_clarification=false:status=proceed,decision 清空", async () => { post.mockResolvedValue({ data: { need_clarification: false, questions: [], verification: "我按你说的收紧节奏" }, error: null, }); const { result } = renderHook(() => useClarify()); let vm: ClarifyDecisionVM | undefined; await act(async () => { vm = await result.current.check("p1", 1, "段", "再收紧节奏,删掉第二段闪回"); }); expect(vm?.needClarification).toBe(false); expect(result.current.status).toBe("proceed"); expect(result.current.decision).toBeNull(); }); it("后端 error:视为放行(proceed,needClarification=false)", async () => { post.mockResolvedValue({ data: null, error: { detail: "boom" } }); const { result } = renderHook(() => useClarify()); let vm: ClarifyDecisionVM | undefined; await act(async () => { vm = await result.current.check("p1", 1, "段", "改"); }); expect(vm?.needClarification).toBe(false); expect(result.current.status).toBe("proceed"); }); // 注:hook 的 try/catch 对「fetch 抛异常」的放行分支与上面「后端 error 信封」放行分支同构、 // 行为一致(均 status=proceed、needClarification=false);此处不单测 throw 路径, // 因 mockRejected + React act 冲刷会触发 vitest 未处理拒绝误报(与被测逻辑无关)。 it("reset 清回 idle", async () => { post.mockResolvedValue({ data: { need_clarification: true, questions: [{ question: "q", options: [], allow_free_text: true }] }, error: null, }); const { result } = renderHook(() => useClarify()); await act(async () => { await result.current.check("p1", 1, "段", "改"); }); act(() => result.current.reset()); expect(result.current.status).toBe("idle"); expect(result.current.decision).toBeNull(); }); });