RefinePanel「再沟通」升级为对话式:意见含糊/极短(门控 <10 字,或点「帮我理清方向」)时先调 clarify 预检端点让 AI 反问,渲染 ChoiceChips 选项芯片(+自由输入兜底);作者选/答后 foldClarifications 把答案折进 instruction,再走既有 refine 回炉(零迁移)。清晰意见直接回炉、 不交往返税;预检失败=放行(useClarify 视 error/异常为 needClarification=false,不阻塞润色)。 新增:lib/workbench/clarify.ts(VM 类型+foldClarifications+needsClarifyGate 纯逻辑) + useClarify(SSE 外调映射 snake→VM) + ChoiceChips.tsx;gen:api 重生成客户端。 门禁绿:tsc/lint/vitest 654(+clarify/useClarify 18 例)/build/coverage 95.36%。
116 lines
4.0 KiB
TypeScript
116 lines
4.0 KiB
TypeScript
// @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();
|
||
});
|
||
});
|