Files
writer-work-flow/apps/web/lib/workbench/useClarify.test.ts
Yaojia Wang e30c933c76 feat(frontend): 润色再沟通接入 AI 反问澄清——选项芯片 + 折答案回炉(WFW-9 M1)
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%。
2026-07-08 08:47:28 +02:00

116 lines
4.0 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 { 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→VMstatus=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=falsestatus=proceeddecision 清空", 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视为放行proceedneedClarification=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();
});
});