test: 接入 jsdom 测试栈, 把 15 个 React hooks 纳入 80% 覆盖率门禁
新增 jsdom + @testing-library/react, 为 lib/** 全部 use*.ts hooks 写 renderHook 单测: 生成/CRUD(world/character/generator/outline/accept/foreshadow/rules)、 SSE流(draftStream/reviewStream)、定时轮询(autosave/jobPoll/kimiOauth)、 文风与注入(refine/styleLearn/injection)。覆盖率 mock api 客户端+Toast, 不打真实 LLM。 vitest.config.ts 去掉 use*.ts 排除; 前端覆盖率 63%→95%。CLAUDE.md 同步说明。
This commit is contained in:
110
apps/web/lib/autosave/useAutosave.test.ts
Normal file
110
apps/web/lib/autosave/useAutosave.test.ts
Normal file
@@ -0,0 +1,110 @@
|
||||
// @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();
|
||||
});
|
||||
});
|
||||
134
apps/web/lib/foreshadow/useForeshadow.test.ts
Normal file
134
apps/web/lib/foreshadow/useForeshadow.test.ts
Normal file
@@ -0,0 +1,134 @@
|
||||
// @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);
|
||||
});
|
||||
});
|
||||
187
apps/web/lib/generation/useCharacterGen.test.ts
Normal file
187
apps/web/lib/generation/useCharacterGen.test.ts
Normal file
@@ -0,0 +1,187 @@
|
||||
// @vitest-environment jsdom
|
||||
import { act, renderHook } from "@testing-library/react";
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
import type { CharacterCardView } from "@/lib/api/types";
|
||||
import { useCharacterGen } from "./useCharacterGen";
|
||||
|
||||
// 后端客户端与 Toast 是 hook 的外部副作用边界,单测一律 mock。
|
||||
const post = vi.fn();
|
||||
const toast = vi.fn();
|
||||
vi.mock("@/lib/api/client", () => ({ api: { POST: (...a: unknown[]) => post(...a) } }));
|
||||
vi.mock("@/components/Toast", () => ({ useToast: () => toast }));
|
||||
|
||||
const conflictEnvelope = {
|
||||
error: {
|
||||
code: "CONFLICT_UNRESOLVED",
|
||||
details: {
|
||||
conflicts: [{ type: "改名", where: "第3章", refs: ["林风"], suggestion: "沿用旧名" }],
|
||||
conflict_count: 1,
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
describe("useCharacterGen", () => {
|
||||
beforeEach(() => {
|
||||
post.mockReset();
|
||||
toast.mockReset();
|
||||
});
|
||||
afterEach(() => vi.clearAllMocks());
|
||||
|
||||
it("初始为 idle、空卡、入库 idle、无冲突", () => {
|
||||
const { result } = renderHook(() => useCharacterGen());
|
||||
expect(result.current.genStatus).toBe("idle");
|
||||
expect(result.current.cards).toEqual([]);
|
||||
expect(result.current.ingestStatus).toBe("idle");
|
||||
expect(result.current.conflicts).toBeNull();
|
||||
expect(result.current.created).toEqual([]);
|
||||
});
|
||||
|
||||
// —— generate ——
|
||||
|
||||
it("生成成功:genStatus 走到 preview 并填充卡片", async () => {
|
||||
const cards = [{ name: "林风", role: "主角" }];
|
||||
post.mockResolvedValue({ data: { cards }, error: null });
|
||||
|
||||
const { result } = renderHook(() => useCharacterGen());
|
||||
await act(async () => {
|
||||
await result.current.generate({ projectId: "p1", brief: "热血少年", count: 3, role: "主角" });
|
||||
});
|
||||
|
||||
expect(result.current.genStatus).toBe("preview");
|
||||
expect(result.current.cards).toEqual(cards);
|
||||
expect(toast).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("生成成功但 cards 缺失:回退为空数组", async () => {
|
||||
post.mockResolvedValue({ data: { cards: null }, error: null });
|
||||
const { result } = renderHook(() => useCharacterGen());
|
||||
await act(async () => {
|
||||
await result.current.generate({ projectId: "p1", brief: "b", count: 1 });
|
||||
});
|
||||
expect(result.current.genStatus).toBe("preview");
|
||||
expect(result.current.cards).toEqual([]);
|
||||
});
|
||||
|
||||
it("生成后端返回 error:genStatus=error 且弹错误 toast", async () => {
|
||||
post.mockResolvedValue({ data: null, error: { detail: "配额不足" } });
|
||||
const { result } = renderHook(() => useCharacterGen());
|
||||
await act(async () => {
|
||||
await result.current.generate({ projectId: "p1", brief: "b", count: 2 });
|
||||
});
|
||||
expect(result.current.genStatus).toBe("error");
|
||||
expect(toast).toHaveBeenCalledWith(expect.any(String), "error");
|
||||
});
|
||||
|
||||
it("生成请求抛异常:genStatus=error 且弹网络异常 toast", async () => {
|
||||
post.mockRejectedValue(new Error("network down"));
|
||||
const { result } = renderHook(() => useCharacterGen());
|
||||
await act(async () => {
|
||||
await result.current.generate({ projectId: "p1", brief: "b", count: 2 });
|
||||
});
|
||||
expect(result.current.genStatus).toBe("error");
|
||||
expect(toast).toHaveBeenCalledWith("生成请求异常,请检查网络。", "error");
|
||||
});
|
||||
|
||||
// —— ingest ——
|
||||
|
||||
it("入库空选择:拒绝并提示,返回 false", async () => {
|
||||
const { result } = renderHook(() => useCharacterGen());
|
||||
let ok = true;
|
||||
await act(async () => {
|
||||
ok = await result.current.ingest("p1", []);
|
||||
});
|
||||
expect(ok).toBe(false);
|
||||
expect(toast).toHaveBeenCalledWith("请至少选择一张角色卡。", "error");
|
||||
expect(post).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("入库成功:ingestStatus=done、记录 created、弹成功 toast,返回 true", async () => {
|
||||
post.mockResolvedValue({ data: { created: ["c1", "c2"] }, error: null });
|
||||
const { result } = renderHook(() => useCharacterGen());
|
||||
let ok = false;
|
||||
await act(async () => {
|
||||
ok = await result.current.ingest("p1", [{ name: "林风" } as CharacterCardView]);
|
||||
});
|
||||
expect(ok).toBe(true);
|
||||
expect(result.current.ingestStatus).toBe("done");
|
||||
expect(result.current.created).toEqual(["c1", "c2"]);
|
||||
expect(result.current.conflicts).toBeNull();
|
||||
expect(toast).toHaveBeenCalledWith("已入库 2 个角色", "success");
|
||||
});
|
||||
|
||||
it("入库成功但 created 缺失:回退空数组并提示 0 个", async () => {
|
||||
post.mockResolvedValue({ data: { created: null }, error: null });
|
||||
const { result } = renderHook(() => useCharacterGen());
|
||||
await act(async () => {
|
||||
await result.current.ingest("p1", [{ name: "林风" } as CharacterCardView]);
|
||||
});
|
||||
expect(result.current.created).toEqual([]);
|
||||
expect(toast).toHaveBeenCalledWith("已入库 0 个角色", "success");
|
||||
});
|
||||
|
||||
it("入库成功且有越权写表:额外弹 info toast", async () => {
|
||||
post.mockResolvedValue({
|
||||
data: { created: ["c1"], rejected_tables: ["secrets", "rules"] },
|
||||
error: null,
|
||||
});
|
||||
const { result } = renderHook(() => useCharacterGen());
|
||||
await act(async () => {
|
||||
await result.current.ingest("p1", [{ name: "林风" } as CharacterCardView]);
|
||||
});
|
||||
expect(toast).toHaveBeenCalledWith("越权写表被丢弃:secrets、rules", "info");
|
||||
});
|
||||
|
||||
it("入库 409 冲突:ingestStatus=conflict 并暴露 conflicts,返回 false", async () => {
|
||||
post.mockResolvedValue({ data: null, error: conflictEnvelope });
|
||||
const { result } = renderHook(() => useCharacterGen());
|
||||
let ok = true;
|
||||
await act(async () => {
|
||||
ok = await result.current.ingest("p1", [{ name: "林风" } as CharacterCardView]);
|
||||
});
|
||||
expect(ok).toBe(false);
|
||||
expect(result.current.ingestStatus).toBe("conflict");
|
||||
expect(result.current.conflicts?.conflictCount).toBe(1);
|
||||
expect(result.current.conflicts?.conflicts).toHaveLength(1);
|
||||
});
|
||||
|
||||
it("入库非冲突 error:ingestStatus=error 且弹错误 toast,返回 false", async () => {
|
||||
post.mockResolvedValue({ data: null, error: { error: { code: "LLM_UNAVAILABLE" } } });
|
||||
const { result } = renderHook(() => useCharacterGen());
|
||||
let ok = true;
|
||||
await act(async () => {
|
||||
ok = await result.current.ingest("p1", [{ name: "林风" } as CharacterCardView]);
|
||||
});
|
||||
expect(ok).toBe(false);
|
||||
expect(result.current.ingestStatus).toBe("error");
|
||||
expect(toast).toHaveBeenCalledWith(expect.any(String), "error");
|
||||
});
|
||||
|
||||
it("入库请求抛异常:ingestStatus=error 且弹网络异常 toast,返回 false", async () => {
|
||||
post.mockRejectedValue(new Error("boom"));
|
||||
const { result } = renderHook(() => useCharacterGen());
|
||||
let ok = true;
|
||||
await act(async () => {
|
||||
ok = await result.current.ingest("p1", [{ name: "林风" } as CharacterCardView]);
|
||||
});
|
||||
expect(ok).toBe(false);
|
||||
expect(result.current.ingestStatus).toBe("error");
|
||||
expect(toast).toHaveBeenCalledWith("入库请求异常,请检查网络。", "error");
|
||||
});
|
||||
|
||||
// —— reset ——
|
||||
|
||||
it("reset 清回初始态", async () => {
|
||||
post.mockResolvedValue({ data: { cards: [{ name: "林风" } as CharacterCardView] }, error: null });
|
||||
const { result } = renderHook(() => useCharacterGen());
|
||||
await act(async () => {
|
||||
await result.current.generate({ projectId: "p1", brief: "b", count: 1 });
|
||||
});
|
||||
act(() => result.current.reset());
|
||||
expect(result.current.genStatus).toBe("idle");
|
||||
expect(result.current.cards).toEqual([]);
|
||||
expect(result.current.ingestStatus).toBe("idle");
|
||||
expect(result.current.conflicts).toBeNull();
|
||||
expect(result.current.created).toEqual([]);
|
||||
});
|
||||
});
|
||||
80
apps/web/lib/generation/useWorldGen.test.ts
Normal file
80
apps/web/lib/generation/useWorldGen.test.ts
Normal file
@@ -0,0 +1,80 @@
|
||||
// @vitest-environment jsdom
|
||||
import { act, renderHook } from "@testing-library/react";
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
import { useWorldGen } from "./useWorldGen";
|
||||
|
||||
// 后端客户端与 Toast 是 hook 的外部副作用边界,单测一律 mock。
|
||||
const post = vi.fn();
|
||||
const toast = vi.fn();
|
||||
vi.mock("@/lib/api/client", () => ({ api: { POST: (...a: unknown[]) => post(...a) } }));
|
||||
vi.mock("@/components/Toast", () => ({ useToast: () => toast }));
|
||||
|
||||
describe("useWorldGen", () => {
|
||||
beforeEach(() => {
|
||||
post.mockReset();
|
||||
toast.mockReset();
|
||||
});
|
||||
afterEach(() => vi.clearAllMocks());
|
||||
|
||||
it("初始为 idle、无实体", () => {
|
||||
const { result } = renderHook(() => useWorldGen());
|
||||
expect(result.current.status).toBe("idle");
|
||||
expect(result.current.entities).toEqual([]);
|
||||
});
|
||||
|
||||
it("生成成功:status 走到 done 并填充实体", async () => {
|
||||
const entities = [{ id: "e1", kind: "place", name: "雾港" }];
|
||||
post.mockResolvedValue({ data: { entities }, error: null });
|
||||
|
||||
const { result } = renderHook(() => useWorldGen());
|
||||
await act(async () => {
|
||||
await result.current.generate("p1", "一个海港城市");
|
||||
});
|
||||
|
||||
expect(result.current.status).toBe("done");
|
||||
expect(result.current.entities).toEqual(entities);
|
||||
expect(toast).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("data 为空时实体回退为空数组", async () => {
|
||||
post.mockResolvedValue({ data: { entities: null }, error: null });
|
||||
const { result } = renderHook(() => useWorldGen());
|
||||
await act(async () => {
|
||||
await result.current.generate("p1", "brief");
|
||||
});
|
||||
expect(result.current.status).toBe("done");
|
||||
expect(result.current.entities).toEqual([]);
|
||||
});
|
||||
|
||||
it("后端返回 error:status=error 且弹错误 toast", async () => {
|
||||
post.mockResolvedValue({ data: null, error: { detail: "配额不足" } });
|
||||
const { result } = renderHook(() => useWorldGen());
|
||||
await act(async () => {
|
||||
await result.current.generate("p1", "brief");
|
||||
});
|
||||
expect(result.current.status).toBe("error");
|
||||
expect(toast).toHaveBeenCalledWith(expect.any(String), "error");
|
||||
});
|
||||
|
||||
it("请求抛异常:status=error 且弹网络异常 toast", async () => {
|
||||
post.mockRejectedValue(new Error("network down"));
|
||||
const { result } = renderHook(() => useWorldGen());
|
||||
await act(async () => {
|
||||
await result.current.generate("p1", "brief");
|
||||
});
|
||||
expect(result.current.status).toBe("error");
|
||||
expect(toast).toHaveBeenCalledWith("生成请求异常,请检查网络。", "error");
|
||||
});
|
||||
|
||||
it("reset 清回 idle 与空实体", async () => {
|
||||
post.mockResolvedValue({ data: { entities: [{ id: "e1" }] }, error: null });
|
||||
const { result } = renderHook(() => useWorldGen());
|
||||
await act(async () => {
|
||||
await result.current.generate("p1", "brief");
|
||||
});
|
||||
act(() => result.current.reset());
|
||||
expect(result.current.status).toBe("idle");
|
||||
expect(result.current.entities).toEqual([]);
|
||||
});
|
||||
});
|
||||
139
apps/web/lib/jobs/useJobPoll.test.ts
Normal file
139
apps/web/lib/jobs/useJobPoll.test.ts
Normal file
@@ -0,0 +1,139 @@
|
||||
// @vitest-environment jsdom
|
||||
import { act, renderHook } from "@testing-library/react";
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
import { useJobPoll } from "./useJobPoll";
|
||||
|
||||
// 后端客户端是 hook 的副作用边界;状态机(job.ts)保留真实。
|
||||
const get = vi.fn();
|
||||
vi.mock("@/lib/api/client", () => ({ api: { GET: (...a: unknown[]) => get(...a) } }));
|
||||
|
||||
const POLL_INTERVAL_MS = 1500;
|
||||
|
||||
describe("useJobPoll", () => {
|
||||
beforeEach(() => {
|
||||
vi.useFakeTimers();
|
||||
get.mockReset();
|
||||
});
|
||||
afterEach(() => {
|
||||
vi.useRealTimers();
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
it("初始为 polling、进度 0、无 job", () => {
|
||||
const { result } = renderHook(() => useJobPoll());
|
||||
expect(result.current.status).toBe("polling");
|
||||
expect(result.current.progress).toBe(0);
|
||||
expect(result.current.job).toBeNull();
|
||||
});
|
||||
|
||||
it("轮询循环:running 持续轮询,done 终态后停止", async () => {
|
||||
// Arrange:首拍 running(50),次拍 done(100)。
|
||||
get
|
||||
.mockResolvedValueOnce({
|
||||
data: { id: "j1", status: "running", progress: 50 },
|
||||
error: null,
|
||||
})
|
||||
.mockResolvedValueOnce({
|
||||
data: { id: "j1", status: "done", progress: 100, result: {} },
|
||||
error: null,
|
||||
});
|
||||
|
||||
const { result } = renderHook(() => useJobPoll());
|
||||
|
||||
// Act:启动轮询,首拍立即执行。
|
||||
await act(async () => {
|
||||
result.current.poll("j1");
|
||||
await vi.advanceTimersByTimeAsync(0);
|
||||
});
|
||||
expect(result.current.status).toBe("polling");
|
||||
expect(result.current.progress).toBe(50);
|
||||
|
||||
// 推进一个轮询间隔触发次拍 → 终态 done。
|
||||
await act(async () => {
|
||||
await vi.advanceTimersByTimeAsync(POLL_INTERVAL_MS);
|
||||
});
|
||||
|
||||
// Assert:done、进度 100;不再继续轮询(仅两次 GET)。
|
||||
expect(result.current.status).toBe("done");
|
||||
expect(result.current.progress).toBe(100);
|
||||
expect(get).toHaveBeenCalledTimes(2);
|
||||
|
||||
await act(async () => {
|
||||
await vi.advanceTimersByTimeAsync(POLL_INTERVAL_MS * 3);
|
||||
});
|
||||
expect(get).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
|
||||
it("job failed:进入 error 终态并带后端错误文案", async () => {
|
||||
get.mockResolvedValue({
|
||||
data: { id: "j1", status: "failed", progress: 30, error: "配额耗尽" },
|
||||
error: null,
|
||||
});
|
||||
const { result } = renderHook(() => useJobPoll());
|
||||
await act(async () => {
|
||||
result.current.poll("j1");
|
||||
await vi.advanceTimersByTimeAsync(0);
|
||||
});
|
||||
expect(result.current.status).toBe("error");
|
||||
expect(result.current.error).toBe("配额耗尽");
|
||||
});
|
||||
|
||||
it("后端返回 error 信封:dispatch 失败并显示通用文案", async () => {
|
||||
get.mockResolvedValue({ data: null, error: { detail: "500" } });
|
||||
const { result } = renderHook(() => useJobPoll());
|
||||
await act(async () => {
|
||||
result.current.poll("j1");
|
||||
await vi.advanceTimersByTimeAsync(0);
|
||||
});
|
||||
expect(result.current.status).toBe("error");
|
||||
expect(result.current.error).toBe("轮询任务状态失败");
|
||||
});
|
||||
|
||||
it("请求抛异常:捕获并以异常消息进入 error", async () => {
|
||||
get.mockRejectedValue(new Error("network down"));
|
||||
const { result } = renderHook(() => useJobPoll());
|
||||
await act(async () => {
|
||||
result.current.poll("j1");
|
||||
await vi.advanceTimersByTimeAsync(0);
|
||||
});
|
||||
expect(result.current.status).toBe("error");
|
||||
expect(result.current.error).toBe("network down");
|
||||
});
|
||||
|
||||
it("reset 停止当前轮询:后续不再发起 GET", async () => {
|
||||
get.mockResolvedValue({
|
||||
data: { id: "j1", status: "running", progress: 10 },
|
||||
error: null,
|
||||
});
|
||||
const { result } = renderHook(() => useJobPoll());
|
||||
await act(async () => {
|
||||
result.current.poll("j1");
|
||||
await vi.advanceTimersByTimeAsync(0);
|
||||
});
|
||||
const callsBefore = get.mock.calls.length;
|
||||
act(() => result.current.reset());
|
||||
await act(async () => {
|
||||
await vi.advanceTimersByTimeAsync(POLL_INTERVAL_MS * 2);
|
||||
});
|
||||
expect(get.mock.calls.length).toBe(callsBefore);
|
||||
});
|
||||
|
||||
it("卸载清理:定时器被清除,不再继续轮询", async () => {
|
||||
get.mockResolvedValue({
|
||||
data: { id: "j1", status: "running", progress: 10 },
|
||||
error: null,
|
||||
});
|
||||
const { result, unmount } = renderHook(() => useJobPoll());
|
||||
await act(async () => {
|
||||
result.current.poll("j1");
|
||||
await vi.advanceTimersByTimeAsync(0);
|
||||
});
|
||||
const callsBefore = get.mock.calls.length;
|
||||
unmount();
|
||||
await act(async () => {
|
||||
await vi.advanceTimersByTimeAsync(POLL_INTERVAL_MS * 2);
|
||||
});
|
||||
expect(get.mock.calls.length).toBe(callsBefore);
|
||||
});
|
||||
});
|
||||
89
apps/web/lib/outline/useOutline.test.ts
Normal file
89
apps/web/lib/outline/useOutline.test.ts
Normal file
@@ -0,0 +1,89 @@
|
||||
// @vitest-environment jsdom
|
||||
import { act, renderHook } from "@testing-library/react";
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
import { useOutline } from "./useOutline";
|
||||
import type { OutlineChapterView } from "@/lib/api/types";
|
||||
|
||||
// 后端客户端与 Toast 是 hook 的外部副作用边界,单测一律 mock。
|
||||
const post = vi.fn();
|
||||
const toast = vi.fn();
|
||||
vi.mock("@/lib/api/client", () => ({ api: { POST: (...a: unknown[]) => post(...a) } }));
|
||||
vi.mock("@/components/Toast", () => ({ useToast: () => toast }));
|
||||
|
||||
function makeChapter(no: number): OutlineChapterView {
|
||||
return { no, volume: 1 };
|
||||
}
|
||||
|
||||
describe("useOutline", () => {
|
||||
beforeEach(() => {
|
||||
post.mockReset();
|
||||
toast.mockReset();
|
||||
});
|
||||
afterEach(() => vi.clearAllMocks());
|
||||
|
||||
it("空初始:status=idle", () => {
|
||||
const { result } = renderHook(() => useOutline([]));
|
||||
expect(result.current.status).toBe("idle");
|
||||
expect(result.current.chapters).toEqual([]);
|
||||
expect(result.current.error).toBeNull();
|
||||
});
|
||||
|
||||
it("非空初始:status=ready 并保留章节", () => {
|
||||
const initial = [makeChapter(1)];
|
||||
const { result } = renderHook(() => useOutline(initial));
|
||||
expect(result.current.status).toBe("ready");
|
||||
expect(result.current.chapters).toEqual(initial);
|
||||
});
|
||||
|
||||
it("生成成功:status 走到 ready 并填充章节并弹成功 toast", async () => {
|
||||
const chapters = [makeChapter(1), makeChapter(2)];
|
||||
post.mockResolvedValue({ data: { chapters }, error: null });
|
||||
|
||||
const { result } = renderHook(() => useOutline([]));
|
||||
await act(async () => {
|
||||
await result.current.generate("p1", 1);
|
||||
});
|
||||
|
||||
expect(result.current.status).toBe("ready");
|
||||
expect(result.current.chapters).toEqual(chapters);
|
||||
expect(result.current.error).toBeNull();
|
||||
expect(toast).toHaveBeenCalledWith("大纲已生成", "success");
|
||||
});
|
||||
|
||||
it("data.chapters 为空时章节回退为空数组", async () => {
|
||||
post.mockResolvedValue({ data: { chapters: null }, error: null });
|
||||
const { result } = renderHook(() => useOutline([]));
|
||||
await act(async () => {
|
||||
await result.current.generate("p1", 1);
|
||||
});
|
||||
expect(result.current.status).toBe("ready");
|
||||
expect(result.current.chapters).toEqual([]);
|
||||
});
|
||||
|
||||
it("后端返回业务错误:status=error 并 surface 友好文案", async () => {
|
||||
post.mockResolvedValue({
|
||||
data: null,
|
||||
error: { error: { code: "LLM_UNAVAILABLE" } },
|
||||
});
|
||||
const { result } = renderHook(() => useOutline([]));
|
||||
await act(async () => {
|
||||
await result.current.generate("p1", 1);
|
||||
});
|
||||
|
||||
expect(result.current.status).toBe("error");
|
||||
expect(result.current.error?.code).toBe("OUTLINE_FAILED");
|
||||
expect(result.current.error?.message).toContain("provider");
|
||||
expect(toast).toHaveBeenCalledWith(expect.any(String), "error");
|
||||
});
|
||||
|
||||
it("data 为空且无 error 也走错误分支", async () => {
|
||||
post.mockResolvedValue({ data: null, error: null });
|
||||
const { result } = renderHook(() => useOutline([]));
|
||||
await act(async () => {
|
||||
await result.current.generate("p1", 1);
|
||||
});
|
||||
expect(result.current.status).toBe("error");
|
||||
expect(result.current.error?.code).toBe("OUTLINE_FAILED");
|
||||
});
|
||||
});
|
||||
132
apps/web/lib/review/useAccept.test.ts
Normal file
132
apps/web/lib/review/useAccept.test.ts
Normal file
@@ -0,0 +1,132 @@
|
||||
// @vitest-environment jsdom
|
||||
import { act, renderHook } from "@testing-library/react";
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
import { useAccept } from "./useAccept";
|
||||
import type { DecisionDraft } from "./decisions";
|
||||
|
||||
// 后端客户端与 Toast 是 hook 的外部副作用边界,单测一律 mock。
|
||||
const post = vi.fn();
|
||||
const toast = vi.fn();
|
||||
vi.mock("@/lib/api/client", () => ({ api: { POST: (...a: unknown[]) => post(...a) } }));
|
||||
vi.mock("@/components/Toast", () => ({ useToast: () => toast }));
|
||||
|
||||
const drafts: readonly DecisionDraft[] = [{ verdict: "accept", note: "" }];
|
||||
|
||||
describe("useAccept", () => {
|
||||
beforeEach(() => {
|
||||
post.mockReset();
|
||||
toast.mockReset();
|
||||
});
|
||||
afterEach(() => vi.clearAllMocks());
|
||||
|
||||
it("初始为 idle、无结果", () => {
|
||||
const { result } = renderHook(() => useAccept());
|
||||
expect(result.current.status).toBe("idle");
|
||||
expect(result.current.result).toBeNull();
|
||||
});
|
||||
|
||||
it("验收成功:status=accepted 并回填结果且弹成功 toast", async () => {
|
||||
const data = { updated: ["digest"] };
|
||||
post.mockResolvedValue({ data, error: null });
|
||||
|
||||
const { result } = renderHook(() => useAccept());
|
||||
let outcome;
|
||||
await act(async () => {
|
||||
outcome = await result.current.accept("p1", 3, "正文", drafts);
|
||||
});
|
||||
|
||||
expect(result.current.status).toBe("accepted");
|
||||
expect(result.current.result).toEqual(data);
|
||||
expect(outcome).toEqual({
|
||||
result: data,
|
||||
missingIndices: [],
|
||||
conflictUnresolved: false,
|
||||
});
|
||||
expect(toast).toHaveBeenCalledWith("本章已验收", "success");
|
||||
});
|
||||
|
||||
it("409 CONFLICT_UNRESOLVED:解析缺判下标且不弹 toast", async () => {
|
||||
post.mockResolvedValue({
|
||||
data: null,
|
||||
error: {
|
||||
error: {
|
||||
code: "CONFLICT_UNRESOLVED",
|
||||
details: { missing_conflict_indices: [0, 2] },
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
const { result } = renderHook(() => useAccept());
|
||||
let outcome;
|
||||
await act(async () => {
|
||||
outcome = await result.current.accept("p1", 3, "正文", drafts);
|
||||
});
|
||||
|
||||
expect(result.current.status).toBe("error");
|
||||
expect(outcome).toEqual({
|
||||
result: null,
|
||||
missingIndices: [0, 2],
|
||||
conflictUnresolved: true,
|
||||
});
|
||||
expect(toast).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("CONFLICT_UNRESOLVED 缺 details 时缺判下标回退为空数组", async () => {
|
||||
post.mockResolvedValue({
|
||||
data: null,
|
||||
error: { error: { code: "CONFLICT_UNRESOLVED" } },
|
||||
});
|
||||
|
||||
const { result } = renderHook(() => useAccept());
|
||||
let outcome;
|
||||
await act(async () => {
|
||||
outcome = await result.current.accept("p1", 3, "正文", drafts);
|
||||
});
|
||||
|
||||
expect(outcome).toEqual({
|
||||
result: null,
|
||||
missingIndices: [],
|
||||
conflictUnresolved: true,
|
||||
});
|
||||
});
|
||||
|
||||
it("其它错误:status=error 并弹失败 toast(正文未丢)", async () => {
|
||||
post.mockResolvedValue({
|
||||
data: null,
|
||||
error: { error: { code: "INTERNAL" } },
|
||||
});
|
||||
|
||||
const { result } = renderHook(() => useAccept());
|
||||
let outcome;
|
||||
await act(async () => {
|
||||
outcome = await result.current.accept("p1", 3, "正文", drafts);
|
||||
});
|
||||
|
||||
expect(result.current.status).toBe("error");
|
||||
expect(outcome).toEqual({
|
||||
result: null,
|
||||
missingIndices: [],
|
||||
conflictUnresolved: false,
|
||||
});
|
||||
expect(toast).toHaveBeenCalledWith("验收失败,请重试(正文未丢失)", "error");
|
||||
});
|
||||
|
||||
it("error 为空但 data 也为空时走通用失败分支", async () => {
|
||||
post.mockResolvedValue({ data: null, error: undefined });
|
||||
|
||||
const { result } = renderHook(() => useAccept());
|
||||
let outcome;
|
||||
await act(async () => {
|
||||
outcome = await result.current.accept("p1", 3, "正文", drafts);
|
||||
});
|
||||
|
||||
expect(result.current.status).toBe("error");
|
||||
expect(outcome).toEqual({
|
||||
result: null,
|
||||
missingIndices: [],
|
||||
conflictUnresolved: false,
|
||||
});
|
||||
expect(toast).toHaveBeenCalledWith("验收失败,请重试(正文未丢失)", "error");
|
||||
});
|
||||
});
|
||||
235
apps/web/lib/review/useReviewStream.test.ts
Normal file
235
apps/web/lib/review/useReviewStream.test.ts
Normal file
@@ -0,0 +1,235 @@
|
||||
// @vitest-environment jsdom
|
||||
import { act, renderHook } from "@testing-library/react";
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
import { useReviewStream } from "./useReviewStream";
|
||||
|
||||
// fetch(审稿 SSE 流)是 hook 的外部副作用边界,单测一律 stub 全局 fetch。
|
||||
const fetchMock = vi.fn();
|
||||
|
||||
function sseStream(chunks: string[]): ReadableStream<Uint8Array> {
|
||||
const enc = new TextEncoder();
|
||||
return new ReadableStream({
|
||||
start(c) {
|
||||
for (const ch of chunks) c.enqueue(enc.encode(ch));
|
||||
c.close();
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
function sseResponse(chunks: string[]): Response {
|
||||
return new Response(sseStream(chunks), {
|
||||
status: 200,
|
||||
headers: { "Content-Type": "text/event-stream" },
|
||||
});
|
||||
}
|
||||
|
||||
describe("useReviewStream", () => {
|
||||
beforeEach(() => {
|
||||
fetchMock.mockReset();
|
||||
vi.stubGlobal("fetch", fetchMock);
|
||||
});
|
||||
afterEach(() => {
|
||||
vi.unstubAllGlobals();
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
it("初始为 idle、各结果集为空、未在审稿", () => {
|
||||
const { result } = renderHook(() => useReviewStream());
|
||||
expect(result.current.state.phase).toBe("idle");
|
||||
expect(result.current.state.conflicts).toEqual([]);
|
||||
expect(result.current.state.sections).toEqual([]);
|
||||
expect(result.current.isReviewing).toBe(false);
|
||||
});
|
||||
|
||||
it("流式累积四类结果:section/conflict/foreshadow/pace/style 折叠进状态", async () => {
|
||||
fetchMock.mockResolvedValue(
|
||||
sseResponse([
|
||||
'event:section\ndata:{"name":"一致性","status":"started"}\n\n',
|
||||
'event:section\ndata:{"name":"一致性","status":"done"}\n\n',
|
||||
'event:conflict\ndata:{"type":"性格漂移","where":"第3段","suggestion":"改回冷静","refs":["c1"]}\n\n',
|
||||
'event:foreshadow\ndata:{"kind":"planted","title":"神秘信物","code":"F1"}\n\n',
|
||||
'event:pace\ndata:{"water":[{"where":"开头","reason":"铺垫过长"}],"hook":true,"beat_map":[1,2,3]}\n\n',
|
||||
'event:style\ndata:{"score":0.82,"segments":[{"idx":0,"text":"原文","score":0.5,"label":"偏离"}]}\n\n',
|
||||
]),
|
||||
);
|
||||
|
||||
const { result } = renderHook(() => useReviewStream());
|
||||
await act(async () => {
|
||||
await result.current.start("p1", 1, "草稿正文");
|
||||
});
|
||||
|
||||
const s = result.current.state;
|
||||
// section 按 name upsert:同名只留最后状态。
|
||||
expect(s.sections).toEqual([{ name: "一致性", status: "done" }]);
|
||||
expect(s.conflicts).toHaveLength(1);
|
||||
expect(s.conflicts[0]).toMatchObject({ type: "性格漂移", where: "第3段" });
|
||||
expect(s.foreshadow).toHaveLength(1);
|
||||
expect(s.foreshadow[0]).toMatchObject({ kind: "planted", title: "神秘信物" });
|
||||
expect(s.pace).toMatchObject({ hook: true, beat_map: [1, 2, 3] });
|
||||
expect(s.style?.score).toBe(0.82);
|
||||
expect(s.style?.segments[0]).toMatchObject({ idx: 0, text: "原文" });
|
||||
});
|
||||
|
||||
it("以 JSON body POST 当前草稿正文", async () => {
|
||||
fetchMock.mockResolvedValue(sseResponse(['event:done\ndata:{"length":0}\n\n']));
|
||||
|
||||
const { result } = renderHook(() => useReviewStream());
|
||||
await act(async () => {
|
||||
await result.current.start("p1", 7, "需要重审的草稿");
|
||||
});
|
||||
|
||||
const [url, init] = fetchMock.mock.calls[0]!;
|
||||
expect(String(url)).toContain("/projects/p1/chapters/7/review");
|
||||
expect(init.method).toBe("POST");
|
||||
expect(JSON.parse(init.body as string)).toEqual({ draft: "需要重审的草稿" });
|
||||
});
|
||||
|
||||
it("收到 done 帧后 phase 走到 done", async () => {
|
||||
fetchMock.mockResolvedValue(
|
||||
sseResponse([
|
||||
'event:conflict\ndata:{"type":"设定违例","where":"第1段","suggestion":"补设定"}\n\n',
|
||||
'event:done\ndata:{"length":120}\n\n',
|
||||
]),
|
||||
);
|
||||
|
||||
const { result } = renderHook(() => useReviewStream());
|
||||
await act(async () => {
|
||||
await result.current.start("p1", 1, "draft");
|
||||
});
|
||||
|
||||
expect(result.current.state.phase).toBe("done");
|
||||
expect(result.current.state.conflicts).toHaveLength(1);
|
||||
});
|
||||
|
||||
it("收到 error 帧:phase=error 且带错误码与文案", async () => {
|
||||
fetchMock.mockResolvedValue(
|
||||
sseResponse(['event:error\ndata:{"code":"TIMEOUT","message":"审稿超时"}\n\n']),
|
||||
);
|
||||
|
||||
const { result } = renderHook(() => useReviewStream());
|
||||
await act(async () => {
|
||||
await result.current.start("p1", 1, "draft");
|
||||
});
|
||||
|
||||
expect(result.current.state.phase).toBe("error");
|
||||
expect(result.current.state.error).toMatchObject({
|
||||
code: "TIMEOUT",
|
||||
message: "审稿超时",
|
||||
});
|
||||
});
|
||||
|
||||
it("流前错误(!res.ok):解析 JSON 信封提取错误码与文案", async () => {
|
||||
fetchMock.mockResolvedValue(
|
||||
new Response(
|
||||
JSON.stringify({ error: { code: "LLM_UNAVAILABLE", message: "无可用凭据" } }),
|
||||
{ status: 503 },
|
||||
),
|
||||
);
|
||||
|
||||
const { result } = renderHook(() => useReviewStream());
|
||||
await act(async () => {
|
||||
await result.current.start("p1", 1, "draft");
|
||||
});
|
||||
|
||||
expect(result.current.state.phase).toBe("error");
|
||||
expect(result.current.state.error).toMatchObject({
|
||||
code: "LLM_UNAVAILABLE",
|
||||
message: "无可用凭据",
|
||||
});
|
||||
});
|
||||
|
||||
it("流前错误且非 JSON 信封:沿用默认 REVIEW_FAILED 文案", async () => {
|
||||
fetchMock.mockResolvedValue(new Response("oops", { status: 500 }));
|
||||
|
||||
const { result } = renderHook(() => useReviewStream());
|
||||
await act(async () => {
|
||||
await result.current.start("p1", 1, "draft");
|
||||
});
|
||||
|
||||
expect(result.current.state.phase).toBe("error");
|
||||
expect(result.current.state.error?.code).toBe("REVIEW_FAILED");
|
||||
expect(result.current.state.error?.message).toContain("500");
|
||||
});
|
||||
|
||||
it("网络抛异常(非 Abort):phase=error 且 code=NETWORK", async () => {
|
||||
fetchMock.mockRejectedValue(new Error("connection reset"));
|
||||
|
||||
const { result } = renderHook(() => useReviewStream());
|
||||
await act(async () => {
|
||||
await result.current.start("p1", 1, "draft");
|
||||
});
|
||||
|
||||
expect(result.current.state.phase).toBe("error");
|
||||
expect(result.current.state.error).toMatchObject({
|
||||
code: "NETWORK",
|
||||
message: "connection reset",
|
||||
});
|
||||
});
|
||||
|
||||
it("非 Error 抛出:回退为未知网络错误文案", async () => {
|
||||
fetchMock.mockRejectedValue(42);
|
||||
|
||||
const { result } = renderHook(() => useReviewStream());
|
||||
await act(async () => {
|
||||
await result.current.start("p1", 1, "draft");
|
||||
});
|
||||
|
||||
expect(result.current.state.error?.message).toBe("未知网络错误");
|
||||
});
|
||||
|
||||
it("AbortError 被吞掉:不进入 error,phase 维持 reviewing", async () => {
|
||||
fetchMock.mockRejectedValue(new DOMException("aborted", "AbortError"));
|
||||
|
||||
const { result } = renderHook(() => useReviewStream());
|
||||
await act(async () => {
|
||||
await result.current.start("p1", 1, "draft");
|
||||
});
|
||||
|
||||
expect(result.current.state.phase).toBe("reviewing");
|
||||
expect(result.current.state.error).toBeNull();
|
||||
});
|
||||
|
||||
it("stop() 主动停止:abort 连接并将 phase 置 aborted", async () => {
|
||||
const hangingBody = new ReadableStream<Uint8Array>({});
|
||||
fetchMock.mockResolvedValue(new Response(hangingBody, { status: 200 }));
|
||||
|
||||
const { result } = renderHook(() => useReviewStream());
|
||||
act(() => {
|
||||
void result.current.start("p1", 1, "draft");
|
||||
});
|
||||
await act(async () => {
|
||||
result.current.stop();
|
||||
});
|
||||
|
||||
expect(result.current.state.phase).toBe("aborted");
|
||||
expect(result.current.isReviewing).toBe(false);
|
||||
});
|
||||
|
||||
it("seed() 用历史留痕种入状态(无需重审即可查看)", () => {
|
||||
const { result } = renderHook(() => useReviewStream());
|
||||
const seedData = {
|
||||
conflicts: [
|
||||
{
|
||||
type: "时间线倒错",
|
||||
where: "第2段",
|
||||
refs: [],
|
||||
suggestion: "对齐时间",
|
||||
original: null,
|
||||
replacement: null,
|
||||
},
|
||||
],
|
||||
foreshadow: [{ kind: "resolved" as const, title: "旧伏笔" }],
|
||||
pace: { water: [], hook: false, beat_map: [1] },
|
||||
style: { score: 0.9, segments: [] },
|
||||
};
|
||||
|
||||
act(() => result.current.seed(seedData));
|
||||
|
||||
expect(result.current.state.conflicts).toEqual(seedData.conflicts);
|
||||
expect(result.current.state.foreshadow).toEqual(seedData.foreshadow);
|
||||
expect(result.current.state.pace).toEqual(seedData.pace);
|
||||
expect(result.current.state.style).toEqual(seedData.style);
|
||||
expect(result.current.state.phase).toBe("idle");
|
||||
});
|
||||
});
|
||||
94
apps/web/lib/rules/useRules.test.ts
Normal file
94
apps/web/lib/rules/useRules.test.ts
Normal file
@@ -0,0 +1,94 @@
|
||||
// @vitest-environment jsdom
|
||||
import { act, renderHook } from "@testing-library/react";
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
import type { RuleView } from "@/lib/api/types";
|
||||
import { useRules } from "./useRules";
|
||||
|
||||
const post = vi.fn();
|
||||
const del = vi.fn();
|
||||
const toast = vi.fn();
|
||||
vi.mock("@/lib/api/client", () => ({
|
||||
api: { POST: (...a: unknown[]) => post(...a), DELETE: (...a: unknown[]) => del(...a) },
|
||||
}));
|
||||
vi.mock("@/components/Toast", () => ({ useToast: () => toast }));
|
||||
|
||||
const seed: RuleView[] = [{ id: "r0", level: "global", content: "已有规则" }];
|
||||
|
||||
describe("useRules", () => {
|
||||
beforeEach(() => {
|
||||
post.mockReset();
|
||||
del.mockReset();
|
||||
toast.mockReset();
|
||||
});
|
||||
afterEach(() => vi.clearAllMocks());
|
||||
|
||||
it("初始 items = 传入值,busy=false", () => {
|
||||
const { result } = renderHook(() => useRules(seed));
|
||||
expect(result.current.items).toEqual(seed);
|
||||
expect(result.current.busy).toBe(false);
|
||||
});
|
||||
|
||||
it("add 空白正文:弹错、不请求、返回 false", async () => {
|
||||
const { result } = renderHook(() => useRules(seed));
|
||||
let ok = true;
|
||||
await act(async () => {
|
||||
ok = await result.current.add("p1", "global", " ");
|
||||
});
|
||||
expect(ok).toBe(false);
|
||||
expect(post).not.toHaveBeenCalled();
|
||||
expect(toast).toHaveBeenCalledWith("请填写规则正文。", "error");
|
||||
});
|
||||
|
||||
it("add 成功:用服务端权威行替换乐观行、弹成功、返回 true", async () => {
|
||||
const authoritative: RuleView = { id: "r1", level: "global", content: "新规则" };
|
||||
post.mockResolvedValue({ data: authoritative, error: null });
|
||||
|
||||
const { result } = renderHook(() => useRules(seed));
|
||||
let ok = false;
|
||||
await act(async () => {
|
||||
ok = await result.current.add("p1", "global", "新规则");
|
||||
});
|
||||
|
||||
expect(ok).toBe(true);
|
||||
expect(result.current.items).toEqual([...seed, authoritative]);
|
||||
expect(result.current.busy).toBe(false);
|
||||
expect(toast).toHaveBeenCalledWith("已新增规则", "success");
|
||||
});
|
||||
|
||||
it("add 后端 error:回滚到快照、弹错、返回 false", async () => {
|
||||
post.mockResolvedValue({ data: null, error: { detail: "boom" } });
|
||||
const { result } = renderHook(() => useRules(seed));
|
||||
let ok = true;
|
||||
await act(async () => {
|
||||
ok = await result.current.add("p1", "global", "新规则");
|
||||
});
|
||||
expect(ok).toBe(false);
|
||||
expect(result.current.items).toEqual(seed);
|
||||
expect(toast).toHaveBeenCalledWith("新增规则失败,请稍后重试。", "error");
|
||||
});
|
||||
|
||||
it("remove 成功:乐观移除、弹成功、返回 true", async () => {
|
||||
del.mockResolvedValue({ error: null });
|
||||
const { result } = renderHook(() => useRules(seed));
|
||||
let ok = false;
|
||||
await act(async () => {
|
||||
ok = await result.current.remove("p1", "r0");
|
||||
});
|
||||
expect(ok).toBe(true);
|
||||
expect(result.current.items).toEqual([]);
|
||||
expect(toast).toHaveBeenCalledWith("已删除规则", "success");
|
||||
});
|
||||
|
||||
it("remove 后端 error:回滚、弹错、返回 false", async () => {
|
||||
del.mockResolvedValue({ error: { detail: "boom" } });
|
||||
const { result } = renderHook(() => useRules(seed));
|
||||
let ok = true;
|
||||
await act(async () => {
|
||||
ok = await result.current.remove("p1", "r0");
|
||||
});
|
||||
expect(ok).toBe(false);
|
||||
expect(result.current.items).toEqual(seed);
|
||||
expect(toast).toHaveBeenCalledWith("删除规则失败,请稍后重试。", "error");
|
||||
});
|
||||
});
|
||||
219
apps/web/lib/settings/useKimiOauth.test.ts
Normal file
219
apps/web/lib/settings/useKimiOauth.test.ts
Normal file
@@ -0,0 +1,219 @@
|
||||
// @vitest-environment jsdom
|
||||
import { act, renderHook } from "@testing-library/react";
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
import { useKimiOauth } from "./useKimiOauth";
|
||||
|
||||
// 后端客户端(POST start/disconnect + GET job/status,经真实 useJobPoll)与 Toast 是副作用边界。
|
||||
// kimiOauth.ts/job.ts 纯逻辑保留真实。
|
||||
const post = vi.fn();
|
||||
const get = vi.fn();
|
||||
const toast = vi.fn();
|
||||
vi.mock("@/lib/api/client", () => ({
|
||||
api: {
|
||||
POST: (...a: unknown[]) => post(...a),
|
||||
GET: (...a: unknown[]) => get(...a),
|
||||
},
|
||||
}));
|
||||
vi.mock("@/components/Toast", () => ({ useToast: () => toast }));
|
||||
|
||||
const START = "/settings/providers/kimi-code/oauth/start";
|
||||
const DISCONNECT = "/settings/providers/kimi-code/oauth/disconnect";
|
||||
const STATUS = "/settings/providers/kimi-code/oauth/status";
|
||||
const JOB = "/jobs/{job_id}";
|
||||
|
||||
const startOk = {
|
||||
data: {
|
||||
job_id: "job-1",
|
||||
user_code: "ABCD-1234",
|
||||
verification_uri: "https://kimi.example/verify",
|
||||
verification_uri_complete: "https://kimi.example/verify?code=ABCD-1234",
|
||||
expires_in: 600,
|
||||
interval: 5,
|
||||
},
|
||||
error: null,
|
||||
};
|
||||
|
||||
// 把 GET 按 path 路由:job 轮询 vs 状态查询。
|
||||
function routeGet(jobResult: unknown, statusResult: unknown): void {
|
||||
get.mockImplementation((path: string) => {
|
||||
if (path === JOB) return Promise.resolve(jobResult);
|
||||
return Promise.resolve(statusResult);
|
||||
});
|
||||
}
|
||||
|
||||
describe("useKimiOauth", () => {
|
||||
beforeEach(() => {
|
||||
vi.useFakeTimers();
|
||||
post.mockReset();
|
||||
get.mockReset();
|
||||
toast.mockReset();
|
||||
vi.spyOn(window, "open").mockReturnValue(null);
|
||||
});
|
||||
afterEach(() => {
|
||||
vi.useRealTimers();
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
it("初始未连接:phase=idle、无 device、不忙、无错误", () => {
|
||||
const { result } = renderHook(() =>
|
||||
useKimiOauth({ initialConnected: false, initialExpiresAt: null }),
|
||||
);
|
||||
expect(result.current.phase).toBe("idle");
|
||||
expect(result.current.device).toBeNull();
|
||||
expect(result.current.busy).toBe(false);
|
||||
expect(result.current.error).toBeNull();
|
||||
});
|
||||
|
||||
it("初始已连接:phase=connected 并带过期时刻", () => {
|
||||
const { result } = renderHook(() =>
|
||||
useKimiOauth({
|
||||
initialConnected: true,
|
||||
initialExpiresAt: "2026-12-31T00:00:00Z",
|
||||
}),
|
||||
);
|
||||
expect(result.current.phase).toBe("connected");
|
||||
expect(result.current.expiresAt).toBe("2026-12-31T00:00:00Z");
|
||||
});
|
||||
|
||||
it("connect 成功(pending→authorized):展示 device、轮询 done 后置已连接并弹成功 toast", async () => {
|
||||
// Arrange:start 拿到 device + job_id;job 首拍即 done 且 connected;状态查询确认已连接。
|
||||
post.mockImplementation((path: string) =>
|
||||
path === START ? Promise.resolve(startOk) : Promise.resolve({ data: {}, error: null }),
|
||||
);
|
||||
routeGet(
|
||||
{ data: { id: "job-1", status: "done", progress: 100, result: { connected: true } }, error: null },
|
||||
{ data: { connected: true, expires_at: "2027-01-01T00:00:00Z" }, error: null },
|
||||
);
|
||||
|
||||
const { result } = renderHook(() =>
|
||||
useKimiOauth({ initialConnected: false, initialExpiresAt: null }),
|
||||
);
|
||||
|
||||
// Act:发起连接,刷掉 job 轮询 + refreshStatus 的微任务。
|
||||
await act(async () => {
|
||||
await result.current.connect();
|
||||
await vi.advanceTimersByTimeAsync(0);
|
||||
});
|
||||
await act(async () => {
|
||||
await vi.advanceTimersByTimeAsync(0);
|
||||
});
|
||||
|
||||
// Assert:已展示 device、phase=connected、刷新到新过期时刻、弹成功 toast。
|
||||
expect(result.current.device?.userCode).toBe("ABCD-1234");
|
||||
expect(window.open).toHaveBeenCalledWith(
|
||||
"https://kimi.example/verify?code=ABCD-1234",
|
||||
"_blank",
|
||||
"noopener,noreferrer",
|
||||
);
|
||||
expect(result.current.phase).toBe("connected");
|
||||
expect(result.current.expiresAt).toBe("2027-01-01T00:00:00Z");
|
||||
expect(toast).toHaveBeenCalledWith("已连接 Kimi Code。", "success");
|
||||
});
|
||||
|
||||
it("connect 后授权进行中:job 仍 running → phase=awaiting 且 busy", async () => {
|
||||
post.mockImplementation((path: string) =>
|
||||
path === START ? Promise.resolve(startOk) : Promise.resolve({ data: {}, error: null }),
|
||||
);
|
||||
routeGet(
|
||||
{ data: { id: "job-1", status: "running", progress: 20 }, error: null },
|
||||
{ data: { connected: false }, error: null },
|
||||
);
|
||||
|
||||
const { result } = renderHook(() =>
|
||||
useKimiOauth({ initialConnected: false, initialExpiresAt: null }),
|
||||
);
|
||||
await act(async () => {
|
||||
await result.current.connect();
|
||||
await vi.advanceTimersByTimeAsync(0);
|
||||
});
|
||||
|
||||
expect(result.current.phase).toBe("awaiting");
|
||||
expect(result.current.busy).toBe(true);
|
||||
});
|
||||
|
||||
it("connect 轮询失败(job failed):phase=error 并弹失败 toast", async () => {
|
||||
post.mockImplementation((path: string) =>
|
||||
path === START ? Promise.resolve(startOk) : Promise.resolve({ data: {}, error: null }),
|
||||
);
|
||||
routeGet(
|
||||
{ data: { id: "job-1", status: "failed", progress: 0, error: "授权已过期" }, error: null },
|
||||
{ data: { connected: false }, error: null },
|
||||
);
|
||||
|
||||
const { result } = renderHook(() =>
|
||||
useKimiOauth({ initialConnected: false, initialExpiresAt: null }),
|
||||
);
|
||||
await act(async () => {
|
||||
await result.current.connect();
|
||||
await vi.advanceTimersByTimeAsync(0);
|
||||
});
|
||||
await act(async () => {
|
||||
await vi.advanceTimersByTimeAsync(0);
|
||||
});
|
||||
|
||||
expect(result.current.phase).toBe("error");
|
||||
expect(result.current.error).toBe("授权已过期");
|
||||
expect(toast).toHaveBeenCalledWith(
|
||||
expect.stringContaining("连接失败"),
|
||||
"error",
|
||||
);
|
||||
});
|
||||
|
||||
it("connect 发起失败(start 返回 error):保持 idle 并弹错误 toast", async () => {
|
||||
post.mockResolvedValue({ data: null, error: { detail: "rate limited" } });
|
||||
|
||||
const { result } = renderHook(() =>
|
||||
useKimiOauth({ initialConnected: false, initialExpiresAt: null }),
|
||||
);
|
||||
await act(async () => {
|
||||
await result.current.connect();
|
||||
});
|
||||
|
||||
expect(result.current.phase).toBe("idle");
|
||||
expect(result.current.device).toBeNull();
|
||||
expect(toast).toHaveBeenCalledWith(
|
||||
"发起 Kimi Code 连接失败,请稍后重试。",
|
||||
"error",
|
||||
);
|
||||
});
|
||||
|
||||
it("disconnect 成功:复位为未连接并弹成功 toast", async () => {
|
||||
post.mockResolvedValue({ data: { ok: true }, error: null });
|
||||
|
||||
const { result } = renderHook(() =>
|
||||
useKimiOauth({
|
||||
initialConnected: true,
|
||||
initialExpiresAt: "2026-12-31T00:00:00Z",
|
||||
}),
|
||||
);
|
||||
await act(async () => {
|
||||
await result.current.disconnect();
|
||||
});
|
||||
|
||||
expect(result.current.phase).toBe("idle");
|
||||
expect(result.current.device).toBeNull();
|
||||
expect(result.current.expiresAt).toBeNull();
|
||||
expect(toast).toHaveBeenCalledWith("已断开 Kimi Code。", "success");
|
||||
});
|
||||
|
||||
it("disconnect 失败(返回 error):弹错误 toast、不复位", async () => {
|
||||
post.mockResolvedValue({ data: null, error: { detail: "boom" } });
|
||||
|
||||
const { result } = renderHook(() =>
|
||||
useKimiOauth({
|
||||
initialConnected: true,
|
||||
initialExpiresAt: "2026-12-31T00:00:00Z",
|
||||
}),
|
||||
);
|
||||
await act(async () => {
|
||||
await result.current.disconnect();
|
||||
});
|
||||
|
||||
expect(result.current.phase).toBe("connected");
|
||||
expect(toast).toHaveBeenCalledWith(
|
||||
"断开 Kimi Code 失败,请稍后重试。",
|
||||
"error",
|
||||
);
|
||||
});
|
||||
});
|
||||
220
apps/web/lib/stream/useDraftStream.test.ts
Normal file
220
apps/web/lib/stream/useDraftStream.test.ts
Normal file
@@ -0,0 +1,220 @@
|
||||
// @vitest-environment jsdom
|
||||
import { act, renderHook } from "@testing-library/react";
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
import { useDraftStream } from "./useDraftStream";
|
||||
|
||||
// fetch(SSE 流)是 hook 的外部副作用边界,单测一律 stub 全局 fetch。
|
||||
const fetchMock = vi.fn();
|
||||
|
||||
// 把若干 SSE 文本块封进 ReadableStream,模拟后端逐块下发的 chunked body。
|
||||
function sseStream(chunks: string[]): ReadableStream<Uint8Array> {
|
||||
const enc = new TextEncoder();
|
||||
return new ReadableStream({
|
||||
start(c) {
|
||||
for (const ch of chunks) c.enqueue(enc.encode(ch));
|
||||
c.close();
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
// 构造一个流式成功响应(200 + text/event-stream body)。
|
||||
function sseResponse(chunks: string[]): Response {
|
||||
return new Response(sseStream(chunks), {
|
||||
status: 200,
|
||||
headers: { "Content-Type": "text/event-stream" },
|
||||
});
|
||||
}
|
||||
|
||||
describe("useDraftStream", () => {
|
||||
beforeEach(() => {
|
||||
fetchMock.mockReset();
|
||||
vi.stubGlobal("fetch", fetchMock);
|
||||
});
|
||||
afterEach(() => {
|
||||
vi.unstubAllGlobals();
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
it("初始为 idle、空文本、未在流式", () => {
|
||||
const { result } = renderHook(() => useDraftStream());
|
||||
expect(result.current.state.phase).toBe("idle");
|
||||
expect(result.current.state.text).toBe("");
|
||||
expect(result.current.isStreaming).toBe(false);
|
||||
});
|
||||
|
||||
it("流式累积 token:多个 token 帧拼成完整文本", async () => {
|
||||
fetchMock.mockResolvedValue(
|
||||
sseResponse([
|
||||
'event:token\ndata:{"text":"你好"}\n\n',
|
||||
'event:token\ndata:{"text":",世界"}\n\n',
|
||||
]),
|
||||
);
|
||||
|
||||
const { result } = renderHook(() => useDraftStream());
|
||||
await act(async () => {
|
||||
await result.current.start("p1", 1);
|
||||
});
|
||||
|
||||
expect(result.current.state.text).toBe("你好,世界");
|
||||
expect(result.current.state.error).toBeNull();
|
||||
});
|
||||
|
||||
it("收到 done 帧后 phase 走到 done", async () => {
|
||||
fetchMock.mockResolvedValue(
|
||||
sseResponse([
|
||||
'event:token\ndata:{"text":"abc"}\n\n',
|
||||
'event:done\ndata:{"length":3}\n\n',
|
||||
]),
|
||||
);
|
||||
|
||||
const { result } = renderHook(() => useDraftStream());
|
||||
await act(async () => {
|
||||
await result.current.start("p1", 1);
|
||||
});
|
||||
|
||||
expect(result.current.state.phase).toBe("done");
|
||||
expect(result.current.state.text).toBe("abc");
|
||||
});
|
||||
|
||||
it("带本章指令时以 JSON body POST 指令", async () => {
|
||||
fetchMock.mockResolvedValue(sseResponse(['event:done\ndata:{"length":0}\n\n']));
|
||||
|
||||
const { result } = renderHook(() => useDraftStream());
|
||||
await act(async () => {
|
||||
await result.current.start("p1", 2, " 写得热血一点 ");
|
||||
});
|
||||
|
||||
const [, init] = fetchMock.mock.calls[0];
|
||||
expect(init.method).toBe("POST");
|
||||
expect(init.headers["Content-Type"]).toBe("application/json");
|
||||
expect(JSON.parse(init.body as string)).toEqual({ directive: "写得热血一点" });
|
||||
});
|
||||
|
||||
it("指令为空白时退回裸 POST(不带 JSON body)", async () => {
|
||||
fetchMock.mockResolvedValue(sseResponse(['event:done\ndata:{"length":0}\n\n']));
|
||||
|
||||
const { result } = renderHook(() => useDraftStream());
|
||||
await act(async () => {
|
||||
await result.current.start("p1", 2, " ");
|
||||
});
|
||||
|
||||
const [, init] = fetchMock.mock.calls[0];
|
||||
expect(init.body).toBeUndefined();
|
||||
expect(init.headers["Content-Type"]).toBeUndefined();
|
||||
});
|
||||
|
||||
it("收到 error 帧:phase=error 且带错误码与文案", async () => {
|
||||
fetchMock.mockResolvedValue(
|
||||
sseResponse([
|
||||
'event:error\ndata:{"code":"RATE_LIMIT","message":"配额不足"}\n\n',
|
||||
]),
|
||||
);
|
||||
|
||||
const { result } = renderHook(() => useDraftStream());
|
||||
await act(async () => {
|
||||
await result.current.start("p1", 1);
|
||||
});
|
||||
|
||||
expect(result.current.state.phase).toBe("error");
|
||||
expect(result.current.state.error).toMatchObject({
|
||||
code: "RATE_LIMIT",
|
||||
message: "配额不足",
|
||||
});
|
||||
});
|
||||
|
||||
it("流前错误(!res.ok):解析 JSON 信封提取错误码与文案", async () => {
|
||||
fetchMock.mockResolvedValue(
|
||||
new Response(
|
||||
JSON.stringify({ error: { code: "LLM_UNAVAILABLE", message: "无可用凭据" } }),
|
||||
{ status: 503 },
|
||||
),
|
||||
);
|
||||
|
||||
const { result } = renderHook(() => useDraftStream());
|
||||
await act(async () => {
|
||||
await result.current.start("p1", 1);
|
||||
});
|
||||
|
||||
expect(result.current.state.phase).toBe("error");
|
||||
expect(result.current.state.error).toMatchObject({
|
||||
code: "LLM_UNAVAILABLE",
|
||||
message: "无可用凭据",
|
||||
});
|
||||
});
|
||||
|
||||
it("流前错误且非 JSON 信封:沿用默认 STREAM_FAILED 文案", async () => {
|
||||
fetchMock.mockResolvedValue(new Response("<html>oops</html>", { status: 500 }));
|
||||
|
||||
const { result } = renderHook(() => useDraftStream());
|
||||
await act(async () => {
|
||||
await result.current.start("p1", 1);
|
||||
});
|
||||
|
||||
expect(result.current.state.phase).toBe("error");
|
||||
expect(result.current.state.error?.code).toBe("STREAM_FAILED");
|
||||
expect(result.current.state.error?.message).toContain("500");
|
||||
});
|
||||
|
||||
it("网络抛异常(非 Abort):phase=error 且 code=NETWORK", async () => {
|
||||
fetchMock.mockRejectedValue(new Error("connection reset"));
|
||||
|
||||
const { result } = renderHook(() => useDraftStream());
|
||||
await act(async () => {
|
||||
await result.current.start("p1", 1);
|
||||
});
|
||||
|
||||
expect(result.current.state.phase).toBe("error");
|
||||
expect(result.current.state.error).toMatchObject({
|
||||
code: "NETWORK",
|
||||
message: "connection reset",
|
||||
});
|
||||
});
|
||||
|
||||
it("非 Error 抛出:回退为未知网络错误文案", async () => {
|
||||
fetchMock.mockRejectedValue("boom-string");
|
||||
|
||||
const { result } = renderHook(() => useDraftStream());
|
||||
await act(async () => {
|
||||
await result.current.start("p1", 1);
|
||||
});
|
||||
|
||||
expect(result.current.state.error?.message).toBe("未知网络错误");
|
||||
});
|
||||
|
||||
it("AbortError 被吞掉:不进入 error,phase 维持 streaming", async () => {
|
||||
fetchMock.mockRejectedValue(new DOMException("aborted", "AbortError"));
|
||||
|
||||
const { result } = renderHook(() => useDraftStream());
|
||||
await act(async () => {
|
||||
await result.current.start("p1", 1);
|
||||
});
|
||||
|
||||
expect(result.current.state.phase).toBe("streaming");
|
||||
expect(result.current.state.error).toBeNull();
|
||||
});
|
||||
|
||||
it("stop() 主动停止:abort 连接并将 phase 置 aborted", async () => {
|
||||
// 用一个永不结束的 body,让流挂起后被 stop 中断(已生成部分保留)。
|
||||
const hangingBody = new ReadableStream<Uint8Array>({});
|
||||
fetchMock.mockResolvedValue(new Response(hangingBody, { status: 200 }));
|
||||
|
||||
const { result } = renderHook(() => useDraftStream());
|
||||
act(() => {
|
||||
void result.current.start("p1", 1);
|
||||
});
|
||||
await act(async () => {
|
||||
result.current.stop();
|
||||
});
|
||||
|
||||
expect(result.current.state.phase).toBe("aborted");
|
||||
expect(result.current.isStreaming).toBe(false);
|
||||
});
|
||||
|
||||
it("reset(text) 用给定文本重置回 idle", () => {
|
||||
const { result } = renderHook(() => useDraftStream());
|
||||
act(() => result.current.reset("已落草稿"));
|
||||
expect(result.current.state.phase).toBe("idle");
|
||||
expect(result.current.state.text).toBe("已落草稿");
|
||||
});
|
||||
});
|
||||
125
apps/web/lib/style/useRefine.test.ts
Normal file
125
apps/web/lib/style/useRefine.test.ts
Normal file
@@ -0,0 +1,125 @@
|
||||
// @vitest-environment jsdom
|
||||
import { act, renderHook } from "@testing-library/react";
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
import { useRefine } from "./useRefine";
|
||||
|
||||
// 后端客户端与 Toast 是 hook 的外部副作用边界,单测一律 mock。
|
||||
const post = vi.fn();
|
||||
const toast = vi.fn();
|
||||
vi.mock("@/lib/api/client", () => ({ api: { POST: (...a: unknown[]) => post(...a) } }));
|
||||
vi.mock("@/components/Toast", () => ({ useToast: () => toast }));
|
||||
|
||||
describe("useRefine", () => {
|
||||
beforeEach(() => {
|
||||
post.mockReset();
|
||||
toast.mockReset();
|
||||
});
|
||||
afterEach(() => vi.clearAllMocks());
|
||||
|
||||
it("初始为 idle、无结果", () => {
|
||||
const { result } = renderHook(() => useRefine());
|
||||
expect(result.current.status).toBe("idle");
|
||||
expect(result.current.result).toBeNull();
|
||||
});
|
||||
|
||||
it("回炉成功:status=done 并返回新旧 diff", async () => {
|
||||
post.mockResolvedValue({
|
||||
data: { original: "旧文", refined: "新文" },
|
||||
error: null,
|
||||
});
|
||||
|
||||
const { result } = renderHook(() => useRefine());
|
||||
let outcome: unknown;
|
||||
await act(async () => {
|
||||
outcome = await result.current.refine("p1", 3, "旧文", "更紧凑");
|
||||
});
|
||||
|
||||
expect(outcome).toEqual({ original: "旧文", refined: "新文" });
|
||||
expect(result.current.status).toBe("done");
|
||||
expect(result.current.result).toEqual({ original: "旧文", refined: "新文" });
|
||||
expect(toast).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("成功路径以 trim 后的段提交、可省略空指令", async () => {
|
||||
post.mockResolvedValue({
|
||||
data: { original: "段", refined: "改" },
|
||||
error: null,
|
||||
});
|
||||
|
||||
const { result } = renderHook(() => useRefine());
|
||||
await act(async () => {
|
||||
await result.current.refine("p1", 1, " 段 ");
|
||||
});
|
||||
|
||||
expect(post).toHaveBeenCalledWith(
|
||||
"/projects/{project_id}/chapters/{chapter_no}/refine",
|
||||
expect.objectContaining({ body: { segment: "段" } }),
|
||||
);
|
||||
});
|
||||
|
||||
it("LLM_UNAVAILABLE:status=error 且提示去设置页", async () => {
|
||||
post.mockResolvedValue({
|
||||
data: null,
|
||||
error: { error: { code: "LLM_UNAVAILABLE" } },
|
||||
});
|
||||
|
||||
const { result } = renderHook(() => useRefine());
|
||||
let outcome: unknown;
|
||||
await act(async () => {
|
||||
outcome = await result.current.refine("p1", 2, "段");
|
||||
});
|
||||
|
||||
expect(outcome).toBeNull();
|
||||
expect(result.current.status).toBe("error");
|
||||
expect(toast).toHaveBeenCalledWith(
|
||||
"未配置提供商,请先去设置页连一家。",
|
||||
"error",
|
||||
);
|
||||
});
|
||||
|
||||
it("其余后端错误:status=error 且弹通用回炉失败 toast", async () => {
|
||||
post.mockResolvedValue({
|
||||
data: null,
|
||||
error: { error: { code: "INTERNAL" } },
|
||||
});
|
||||
|
||||
const { result } = renderHook(() => useRefine());
|
||||
await act(async () => {
|
||||
await result.current.refine("p1", 2, "段");
|
||||
});
|
||||
|
||||
expect(result.current.status).toBe("error");
|
||||
expect(toast).toHaveBeenCalledWith("回炉失败,请稍后重试。", "error");
|
||||
});
|
||||
|
||||
it("请求抛异常:status=error 且弹网络异常 toast", async () => {
|
||||
post.mockRejectedValue(new Error("network down"));
|
||||
|
||||
const { result } = renderHook(() => useRefine());
|
||||
let outcome: unknown;
|
||||
await act(async () => {
|
||||
outcome = await result.current.refine("p1", 2, "段");
|
||||
});
|
||||
|
||||
expect(outcome).toBeNull();
|
||||
expect(result.current.status).toBe("error");
|
||||
expect(toast).toHaveBeenCalledWith("回炉请求异常,请检查网络。", "error");
|
||||
});
|
||||
|
||||
it("reset 清回 idle 与空结果", async () => {
|
||||
post.mockResolvedValue({
|
||||
data: { original: "a", refined: "b" },
|
||||
error: null,
|
||||
});
|
||||
|
||||
const { result } = renderHook(() => useRefine());
|
||||
await act(async () => {
|
||||
await result.current.refine("p1", 1, "a");
|
||||
});
|
||||
act(() => result.current.reset());
|
||||
|
||||
expect(result.current.status).toBe("idle");
|
||||
expect(result.current.result).toBeNull();
|
||||
});
|
||||
});
|
||||
@@ -1,4 +1,10 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
// @vitest-environment jsdom
|
||||
import { act, renderHook } from "@testing-library/react";
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
import { learnSummary, useStyleLearn } from "./useStyleLearn";
|
||||
import type { JobView } from "@/lib/jobs/job";
|
||||
import type { UseJobPoll } from "@/lib/jobs/useJobPoll";
|
||||
|
||||
// P1-6 回归守卫:学文风轮询完成(done 边沿)时,effect 必须用「最近一次 learn 传入的
|
||||
// projectId」去拉指纹,而非闭包捕获的旧值。useStyleLearn 用 ref 追踪 projectId 来保证这点。
|
||||
@@ -39,3 +45,239 @@ describe("useStyleLearn projectId 追踪(P1-6 stale-closure 守卫)", () =>
|
||||
expect(t.onDone()).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
// ── 真实 hook 覆盖:renderHook 驱动编排(POST 受理 → 轮询 → done 拉指纹)─────────
|
||||
const post = vi.fn();
|
||||
const get = vi.fn();
|
||||
const toast = vi.fn();
|
||||
vi.mock("@/lib/api/client", () => ({
|
||||
api: {
|
||||
POST: (...a: unknown[]) => post(...a),
|
||||
GET: (...a: unknown[]) => get(...a),
|
||||
},
|
||||
}));
|
||||
vi.mock("@/components/Toast", () => ({ useToast: () => toast }));
|
||||
|
||||
const STYLE_PATH = "/projects/{project_id}/style";
|
||||
const JOBS_PATH = "/jobs/{job_id}";
|
||||
|
||||
interface GetOpts {
|
||||
params: { path: { project_id?: string; job_id?: string } };
|
||||
}
|
||||
interface PostOpts {
|
||||
params: { path: { project_id: string } };
|
||||
}
|
||||
|
||||
// 微任务 + 0ms 定时器全部冲洗(轮询 tick→dispatch→effect→refetch 链需多跳)。
|
||||
async function flush(): Promise<void> {
|
||||
await act(async () => {
|
||||
await vi.advanceTimersByTimeAsync(0);
|
||||
});
|
||||
}
|
||||
|
||||
const FINGERPRINT_BODY = {
|
||||
dimensions: [{ name: "节奏", value: "快", evidence: ["短句"] }],
|
||||
version: 2,
|
||||
};
|
||||
|
||||
describe("useStyleLearn 真实 hook 编排", () => {
|
||||
beforeEach(() => {
|
||||
vi.useFakeTimers();
|
||||
post.mockReset();
|
||||
get.mockReset();
|
||||
toast.mockReset();
|
||||
});
|
||||
afterEach(() => {
|
||||
vi.clearAllMocks();
|
||||
vi.useRealTimers();
|
||||
});
|
||||
|
||||
it("初始:未提交则 busy=false、pollStatus=idle、指纹为传入初值", () => {
|
||||
const initial = { dimensions: [], version: 1 };
|
||||
const { result } = renderHook(() => useStyleLearn(initial));
|
||||
|
||||
expect(result.current.busy).toBe(false);
|
||||
expect(result.current.pollStatus).toBe("idle");
|
||||
expect(result.current.progress).toBe(0);
|
||||
expect(result.current.fingerprint).toBe(initial);
|
||||
});
|
||||
|
||||
it("受理失败(通用错误):返回 false、弹通用 toast、busy 归位", async () => {
|
||||
post.mockResolvedValue({ data: null, error: { error: { code: "INTERNAL" } } });
|
||||
|
||||
const { result } = renderHook(() => useStyleLearn(null));
|
||||
let ok: boolean | undefined;
|
||||
await act(async () => {
|
||||
ok = await result.current.learn("p1", ["样本"], "create");
|
||||
});
|
||||
|
||||
expect(ok).toBe(false);
|
||||
expect(toast).toHaveBeenCalledWith("学文风受理失败,请稍后重试。", "error");
|
||||
expect(result.current.busy).toBe(false);
|
||||
expect(get).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("受理失败(LLM_UNAVAILABLE):提示去设置页连一家", async () => {
|
||||
post.mockResolvedValue({
|
||||
data: null,
|
||||
error: { error: { code: "LLM_UNAVAILABLE" } },
|
||||
});
|
||||
|
||||
const { result } = renderHook(() => useStyleLearn(null));
|
||||
let ok: boolean | undefined;
|
||||
await act(async () => {
|
||||
ok = await result.current.learn("p1", ["样本"], "create");
|
||||
});
|
||||
|
||||
expect(ok).toBe(false);
|
||||
expect(toast).toHaveBeenCalledWith(
|
||||
"未配置提供商,请先去设置页连一家。",
|
||||
"error",
|
||||
);
|
||||
});
|
||||
|
||||
it("受理成功 → 轮询 done → 拉最新指纹并弹成功 toast", async () => {
|
||||
post.mockResolvedValue({ data: { job_id: "job-1" }, error: null });
|
||||
get.mockImplementation((path: string, opts: GetOpts) => {
|
||||
if (path === JOBS_PATH) {
|
||||
return Promise.resolve({
|
||||
data: { id: "job-1", status: "done", progress: 100 },
|
||||
error: null,
|
||||
});
|
||||
}
|
||||
return Promise.resolve({ data: FINGERPRINT_BODY, error: null });
|
||||
});
|
||||
|
||||
const { result } = renderHook(() => useStyleLearn(null));
|
||||
let ok: boolean | undefined;
|
||||
await act(async () => {
|
||||
ok = await result.current.learn("p1", ["样本"], "create");
|
||||
});
|
||||
await flush();
|
||||
|
||||
expect(ok).toBe(true);
|
||||
expect(result.current.pollStatus).toBe("done");
|
||||
expect(result.current.fingerprint).toEqual({
|
||||
dimensions: [{ name: "节奏", value: "快", evidence: ["短句"] }],
|
||||
version: 2,
|
||||
});
|
||||
expect(toast).toHaveBeenCalledWith("文风指纹已更新。", "success");
|
||||
});
|
||||
|
||||
it("done 但拉指纹失败(GET /style error):指纹不变、仍弹成功 toast", async () => {
|
||||
post.mockResolvedValue({ data: { job_id: "job-1" }, error: null });
|
||||
get.mockImplementation((path: string) => {
|
||||
if (path === JOBS_PATH) {
|
||||
return Promise.resolve({
|
||||
data: { id: "job-1", status: "done", progress: 100 },
|
||||
error: null,
|
||||
});
|
||||
}
|
||||
return Promise.resolve({ data: null, error: { detail: "404" } });
|
||||
});
|
||||
|
||||
const initial = { dimensions: [], version: 9 };
|
||||
const { result } = renderHook(() => useStyleLearn(initial));
|
||||
await act(async () => {
|
||||
await result.current.learn("p1", ["样本"], "create");
|
||||
});
|
||||
await flush();
|
||||
|
||||
expect(result.current.fingerprint).toBe(initial);
|
||||
expect(toast).toHaveBeenCalledWith("文风指纹已更新。", "success");
|
||||
});
|
||||
|
||||
it("轮询失败(job failed):弹学文风失败 toast 带原因", async () => {
|
||||
post.mockResolvedValue({ data: { job_id: "job-1" }, error: null });
|
||||
get.mockImplementation((path: string) => {
|
||||
if (path === JOBS_PATH) {
|
||||
return Promise.resolve({
|
||||
data: { id: "job-1", status: "failed", progress: 40, error: "模型超时" },
|
||||
error: null,
|
||||
});
|
||||
}
|
||||
return Promise.resolve({ data: FINGERPRINT_BODY, error: null });
|
||||
});
|
||||
|
||||
const { result } = renderHook(() => useStyleLearn(null));
|
||||
await act(async () => {
|
||||
await result.current.learn("p1", ["样本"], "create");
|
||||
});
|
||||
await flush();
|
||||
|
||||
expect(result.current.pollStatus).toBe("error");
|
||||
expect(toast).toHaveBeenCalledWith("学文风失败:模型超时", "error");
|
||||
});
|
||||
|
||||
it("切项目:done 边沿用最近一次 learn 的 projectId 拉指纹(stale-closure 守卫)", async () => {
|
||||
const styleProjectIds: string[] = [];
|
||||
post.mockImplementation((_path: string, opts: PostOpts) => {
|
||||
const pid = opts.params.path.project_id;
|
||||
return Promise.resolve({
|
||||
data: { job_id: pid === "project-A" ? "job-A" : "job-B" },
|
||||
error: null,
|
||||
});
|
||||
});
|
||||
get.mockImplementation((path: string, opts: GetOpts) => {
|
||||
if (path === JOBS_PATH) {
|
||||
const jobId = opts.params.path.job_id;
|
||||
// A 仍在跑(排队 setTimeout 下一拍);B 直接 done。
|
||||
const status = jobId === "job-A" ? "running" : "done";
|
||||
return Promise.resolve({
|
||||
data: { id: jobId, status, progress: status === "done" ? 100 : 30 },
|
||||
error: null,
|
||||
});
|
||||
}
|
||||
styleProjectIds.push(opts.params.path.project_id ?? "?");
|
||||
return Promise.resolve({ data: FINGERPRINT_BODY, error: null });
|
||||
});
|
||||
|
||||
const { result } = renderHook(() => useStyleLearn(null));
|
||||
await act(async () => {
|
||||
await result.current.learn("project-A", ["a"], "create");
|
||||
});
|
||||
await act(async () => {
|
||||
await result.current.learn("project-B", ["b"], "update");
|
||||
});
|
||||
await flush();
|
||||
|
||||
// done 边沿拉指纹用的应是后切的 project-B,而非陈旧的 project-A。
|
||||
expect(styleProjectIds).toContain("project-B");
|
||||
expect(styleProjectIds).not.toContain("project-A");
|
||||
expect(toast).toHaveBeenCalledWith("文风指纹已更新。", "success");
|
||||
});
|
||||
});
|
||||
|
||||
// learnSummary 是导出的纯函数:done 且有 job 时回显版本/维度数,否则 null。
|
||||
describe("learnSummary", () => {
|
||||
const baseJob: JobView = {
|
||||
id: "j",
|
||||
kind: "style_learn",
|
||||
status: "done",
|
||||
progress: 100,
|
||||
result: { version: 3, dims_count: 16 },
|
||||
error: null,
|
||||
};
|
||||
const pollOf = (over: Partial<UseJobPoll>): UseJobPoll =>
|
||||
({
|
||||
status: "done",
|
||||
progress: 100,
|
||||
job: baseJob,
|
||||
error: null,
|
||||
poll: vi.fn(),
|
||||
reset: vi.fn(),
|
||||
...over,
|
||||
}) as UseJobPoll;
|
||||
|
||||
it("done 且有 job:回显 version 与 dimsCount", () => {
|
||||
expect(learnSummary(pollOf({}))).toEqual({ version: 3, dimsCount: 16 });
|
||||
});
|
||||
|
||||
it("非 done:返回 null", () => {
|
||||
expect(learnSummary(pollOf({ status: "polling" }))).toBeNull();
|
||||
});
|
||||
|
||||
it("done 但无 job:返回 null", () => {
|
||||
expect(learnSummary(pollOf({ job: null }))).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
216
apps/web/lib/toolbox/useGenerator.test.ts
Normal file
216
apps/web/lib/toolbox/useGenerator.test.ts
Normal file
@@ -0,0 +1,216 @@
|
||||
// @vitest-environment jsdom
|
||||
import { act, renderHook } from "@testing-library/react";
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
import { useGenerator } from "./useGenerator";
|
||||
import type { IngestBuildInput } from "./ingest";
|
||||
|
||||
// 后端客户端与 Toast 是 hook 的外部副作用边界,单测一律 mock。
|
||||
const post = vi.fn();
|
||||
const toast = vi.fn();
|
||||
vi.mock("@/lib/api/client", () => ({ api: { POST: (...a: unknown[]) => post(...a) } }));
|
||||
vi.mock("@/components/Toast", () => ({ useToast: () => toast }));
|
||||
|
||||
const conflictEnvelope = {
|
||||
error: {
|
||||
code: "CONFLICT_UNRESOLVED",
|
||||
details: {
|
||||
conflicts: [{ type: "冲突", where: "world", refs: [], suggestion: "改写" }],
|
||||
conflict_count: 1,
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
// 行式可入库产物(金手指 → world_entities)。
|
||||
const goldenInput: IngestBuildInput = {
|
||||
outputKind: "GoldenFingerResult",
|
||||
rows: [{ name: "吞噬之眼", mechanism: "吞噬", growth: "无限", limits: "反噬" }],
|
||||
};
|
||||
|
||||
describe("useGenerator", () => {
|
||||
beforeEach(() => {
|
||||
post.mockReset();
|
||||
toast.mockReset();
|
||||
});
|
||||
afterEach(() => vi.clearAllMocks());
|
||||
|
||||
it("初始为 idle、无预览、入库 idle、无冲突", () => {
|
||||
const { result } = renderHook(() => useGenerator());
|
||||
expect(result.current.genStatus).toBe("idle");
|
||||
expect(result.current.preview).toBeNull();
|
||||
expect(result.current.rawPreview).toBeUndefined();
|
||||
expect(result.current.outputKind).toBeNull();
|
||||
expect(result.current.ingestStatus).toBe("idle");
|
||||
expect(result.current.conflicts).toBeNull();
|
||||
expect(result.current.created).toEqual([]);
|
||||
});
|
||||
|
||||
// —— generate ——
|
||||
|
||||
it("生成成功:genStatus=preview,映射预览模型 + 记录 rawPreview/outputKind", async () => {
|
||||
const preview = { ideas: [{ premise: "废柴逆袭", hook: "扮猪吃虎" }] };
|
||||
post.mockResolvedValue({
|
||||
data: { output_kind: "IdeaListResult", preview },
|
||||
error: null,
|
||||
});
|
||||
|
||||
const { result } = renderHook(() => useGenerator());
|
||||
await act(async () => {
|
||||
await result.current.generate("p1", "idea", { brief: "脑洞" });
|
||||
});
|
||||
|
||||
expect(result.current.genStatus).toBe("preview");
|
||||
expect(result.current.outputKind).toBe("IdeaListResult");
|
||||
expect(result.current.rawPreview).toEqual(preview);
|
||||
expect(result.current.preview?.kind).toBe("IdeaListResult");
|
||||
expect(result.current.preview?.items[0]?.heading).toBe("废柴逆袭");
|
||||
expect(toast).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("生成后端返回 error:genStatus=error 且弹错误 toast", async () => {
|
||||
post.mockResolvedValue({ data: null, error: { detail: "失败" } });
|
||||
const { result } = renderHook(() => useGenerator());
|
||||
await act(async () => {
|
||||
await result.current.generate("p1", "idea", { brief: "x" });
|
||||
});
|
||||
expect(result.current.genStatus).toBe("error");
|
||||
expect(toast).toHaveBeenCalledWith(expect.any(String), "error");
|
||||
});
|
||||
|
||||
it("生成请求抛异常:genStatus=error 且弹网络异常 toast", async () => {
|
||||
post.mockRejectedValue(new Error("network down"));
|
||||
const { result } = renderHook(() => useGenerator());
|
||||
await act(async () => {
|
||||
await result.current.generate("p1", "idea", { brief: "x" });
|
||||
});
|
||||
expect(result.current.genStatus).toBe("error");
|
||||
expect(toast).toHaveBeenCalledWith("生成请求异常,请检查网络。", "error");
|
||||
});
|
||||
|
||||
// —— ingest ——
|
||||
|
||||
it("入库不支持的产物(buildIngestRequest 返 null):提示并返回 false", async () => {
|
||||
const { result } = renderHook(() => useGenerator());
|
||||
let ok = true;
|
||||
await act(async () => {
|
||||
ok = await result.current.ingest("p1", "opening", {
|
||||
outputKind: "OpeningResult",
|
||||
rows: [{ text: "正文" }],
|
||||
});
|
||||
});
|
||||
expect(ok).toBe(false);
|
||||
expect(toast).toHaveBeenCalledWith("该生成器不支持入库。", "error");
|
||||
expect(post).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("入库空行:提示至少选择一项并返回 false", async () => {
|
||||
const { result } = renderHook(() => useGenerator());
|
||||
let ok = true;
|
||||
await act(async () => {
|
||||
ok = await result.current.ingest("p1", "golden", {
|
||||
outputKind: "GoldenFingerResult",
|
||||
rows: [],
|
||||
});
|
||||
});
|
||||
expect(ok).toBe(false);
|
||||
expect(toast).toHaveBeenCalledWith("请至少选择一项入库。", "error");
|
||||
expect(post).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("入库成功:ingestStatus=done、记录 created、弹成功 toast,返回 true", async () => {
|
||||
post.mockResolvedValue({
|
||||
data: { created: ["w1", "w2"], table: "world_entities" },
|
||||
error: null,
|
||||
});
|
||||
const { result } = renderHook(() => useGenerator());
|
||||
let ok = false;
|
||||
await act(async () => {
|
||||
ok = await result.current.ingest("p1", "golden", goldenInput);
|
||||
});
|
||||
expect(ok).toBe(true);
|
||||
expect(result.current.ingestStatus).toBe("done");
|
||||
expect(result.current.created).toEqual(["w1", "w2"]);
|
||||
expect(result.current.conflicts).toBeNull();
|
||||
expect(toast).toHaveBeenCalledWith("已入库 2 项至 world_entities", "success");
|
||||
});
|
||||
|
||||
it("入库成功但 created 缺失:回退空数组并提示 0 项", async () => {
|
||||
post.mockResolvedValue({ data: { created: null, table: "world_entities" }, error: null });
|
||||
const { result } = renderHook(() => useGenerator());
|
||||
await act(async () => {
|
||||
await result.current.ingest("p1", "golden", goldenInput);
|
||||
});
|
||||
expect(result.current.created).toEqual([]);
|
||||
expect(toast).toHaveBeenCalledWith("已入库 0 项至 world_entities", "success");
|
||||
});
|
||||
|
||||
it("入库成功且有越权写表:额外弹 info toast", async () => {
|
||||
post.mockResolvedValue({
|
||||
data: { created: ["w1"], table: "world_entities", rejected_tables: ["secrets"] },
|
||||
error: null,
|
||||
});
|
||||
const { result } = renderHook(() => useGenerator());
|
||||
await act(async () => {
|
||||
await result.current.ingest("p1", "golden", goldenInput);
|
||||
});
|
||||
expect(toast).toHaveBeenCalledWith("越权写表被丢弃:secrets", "info");
|
||||
});
|
||||
|
||||
it("入库 409 冲突:ingestStatus=conflict 并暴露 conflicts,返回 false", async () => {
|
||||
post.mockResolvedValue({ data: null, error: conflictEnvelope });
|
||||
const { result } = renderHook(() => useGenerator());
|
||||
let ok = true;
|
||||
await act(async () => {
|
||||
ok = await result.current.ingest("p1", "golden", goldenInput);
|
||||
});
|
||||
expect(ok).toBe(false);
|
||||
expect(result.current.ingestStatus).toBe("conflict");
|
||||
expect(result.current.conflicts?.conflictCount).toBe(1);
|
||||
expect(result.current.conflicts?.conflicts).toHaveLength(1);
|
||||
});
|
||||
|
||||
it("入库非冲突 error:ingestStatus=error 且弹错误 toast,返回 false", async () => {
|
||||
post.mockResolvedValue({ data: null, error: { error: { code: "LLM_UNAVAILABLE" } } });
|
||||
const { result } = renderHook(() => useGenerator());
|
||||
let ok = true;
|
||||
await act(async () => {
|
||||
ok = await result.current.ingest("p1", "golden", goldenInput);
|
||||
});
|
||||
expect(ok).toBe(false);
|
||||
expect(result.current.ingestStatus).toBe("error");
|
||||
expect(toast).toHaveBeenCalledWith(expect.any(String), "error");
|
||||
});
|
||||
|
||||
it("入库请求抛异常:ingestStatus=error 且弹网络异常 toast,返回 false", async () => {
|
||||
post.mockRejectedValue(new Error("boom"));
|
||||
const { result } = renderHook(() => useGenerator());
|
||||
let ok = true;
|
||||
await act(async () => {
|
||||
ok = await result.current.ingest("p1", "golden", goldenInput);
|
||||
});
|
||||
expect(ok).toBe(false);
|
||||
expect(result.current.ingestStatus).toBe("error");
|
||||
expect(toast).toHaveBeenCalledWith("入库请求异常,请检查网络。", "error");
|
||||
});
|
||||
|
||||
// —— reset ——
|
||||
|
||||
it("reset 清回初始态", async () => {
|
||||
post.mockResolvedValue({
|
||||
data: { output_kind: "IdeaListResult", preview: { ideas: [{ premise: "p" }] } },
|
||||
error: null,
|
||||
});
|
||||
const { result } = renderHook(() => useGenerator());
|
||||
await act(async () => {
|
||||
await result.current.generate("p1", "idea", { brief: "x" });
|
||||
});
|
||||
act(() => result.current.reset());
|
||||
expect(result.current.genStatus).toBe("idle");
|
||||
expect(result.current.preview).toBeNull();
|
||||
expect(result.current.rawPreview).toBeUndefined();
|
||||
expect(result.current.outputKind).toBeNull();
|
||||
expect(result.current.ingestStatus).toBe("idle");
|
||||
expect(result.current.conflicts).toBeNull();
|
||||
expect(result.current.created).toEqual([]);
|
||||
});
|
||||
});
|
||||
181
apps/web/lib/workbench/useInjection.test.ts
Normal file
181
apps/web/lib/workbench/useInjection.test.ts
Normal file
@@ -0,0 +1,181 @@
|
||||
// @vitest-environment jsdom
|
||||
import { act, renderHook, waitFor } from "@testing-library/react";
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
import { useInjection } from "./useInjection";
|
||||
import type { InjectionResponse } from "./injection";
|
||||
|
||||
// api 客户端是 hook 的外部副作用边界;GET 拉确定性结果,PUT 写覆盖。单测一律 mock。
|
||||
const get = vi.fn();
|
||||
const put = vi.fn();
|
||||
vi.mock("@/lib/api/client", () => ({
|
||||
api: {
|
||||
GET: (...a: unknown[]) => get(...a),
|
||||
PUT: (...a: unknown[]) => put(...a),
|
||||
},
|
||||
}));
|
||||
|
||||
// 最小 InjectionResponse 桩(仅覆盖纯逻辑读取的字段)。
|
||||
function makeResponse(over: Partial<InjectionResponse> = {}): InjectionResponse {
|
||||
return {
|
||||
pinned: [],
|
||||
excluded: [],
|
||||
recent_n: 3,
|
||||
entities: [],
|
||||
...over,
|
||||
} as InjectionResponse;
|
||||
}
|
||||
|
||||
const CHAR = { kind: "character", name: "张三" } as const;
|
||||
|
||||
describe("useInjection", () => {
|
||||
beforeEach(() => {
|
||||
get.mockReset();
|
||||
put.mockReset();
|
||||
});
|
||||
afterEach(() => vi.clearAllMocks());
|
||||
|
||||
it("挂载成功:拉到确定性结果并落到 data,loading 归位", async () => {
|
||||
const body = makeResponse({ recent_n: 5 });
|
||||
get.mockResolvedValue({ data: body, error: null });
|
||||
|
||||
const { result } = renderHook(() => useInjection("p1", 3));
|
||||
await waitFor(() => expect(result.current.loading).toBe(false));
|
||||
|
||||
expect(result.current.data).toEqual(body);
|
||||
expect(result.current.error).toBeNull();
|
||||
});
|
||||
|
||||
it("挂载后端返回 error:给可读文案、data 置空", async () => {
|
||||
get.mockResolvedValue({ data: null, error: { detail: "boom" } });
|
||||
|
||||
const { result } = renderHook(() => useInjection("p1", 3));
|
||||
await waitFor(() => expect(result.current.loading).toBe(false));
|
||||
|
||||
expect(result.current.error).toBe("注入信息暂不可用");
|
||||
expect(result.current.data).toBeNull();
|
||||
});
|
||||
|
||||
it("挂载网络层抛错:捕获并给可读文案", async () => {
|
||||
get.mockRejectedValue(new Error("network down"));
|
||||
|
||||
const { result } = renderHook(() => useInjection("p1", 3));
|
||||
await waitFor(() => expect(result.current.loading).toBe(false));
|
||||
|
||||
expect(result.current.error).toBe("注入信息暂不可用");
|
||||
expect(result.current.data).toBeNull();
|
||||
});
|
||||
|
||||
it("togglePin 成功:PUT 覆盖后以服务端确定结果回放 data", async () => {
|
||||
get.mockResolvedValue({ data: makeResponse(), error: null });
|
||||
const updated = makeResponse({ pinned: [{ ...CHAR }] });
|
||||
put.mockResolvedValue({ data: updated, error: null });
|
||||
|
||||
const { result } = renderHook(() => useInjection("p1", 3));
|
||||
await waitFor(() => expect(result.current.loading).toBe(false));
|
||||
|
||||
await act(async () => {
|
||||
await result.current.togglePin(CHAR);
|
||||
});
|
||||
|
||||
expect(put).toHaveBeenCalledTimes(1);
|
||||
expect(result.current.data).toEqual(updated);
|
||||
expect(result.current.saving).toBe(false);
|
||||
expect(result.current.error).toBeNull();
|
||||
});
|
||||
|
||||
it("exclude / restore / setRecentN 各自经 PUT 提交覆盖", async () => {
|
||||
get.mockResolvedValue({ data: makeResponse(), error: null });
|
||||
put.mockResolvedValue({ data: makeResponse(), error: null });
|
||||
|
||||
const { result } = renderHook(() => useInjection("p1", 3));
|
||||
await waitFor(() => expect(result.current.loading).toBe(false));
|
||||
|
||||
await act(async () => {
|
||||
await result.current.exclude(CHAR);
|
||||
});
|
||||
await act(async () => {
|
||||
await result.current.restore(CHAR);
|
||||
});
|
||||
await act(async () => {
|
||||
await result.current.setRecentN(8);
|
||||
});
|
||||
|
||||
expect(put).toHaveBeenCalledTimes(3);
|
||||
// 最后一次 setRecentN 的 body.recent_n 已钳到合法区间。
|
||||
expect(put).toHaveBeenLastCalledWith(
|
||||
"/projects/{project_id}/chapters/{chapter_no}/injection",
|
||||
expect.objectContaining({ body: expect.objectContaining({ recent_n: 8 }) }),
|
||||
);
|
||||
});
|
||||
|
||||
it("保存后端返回 error:给可读文案、本地 data 不变(天然回滚)", async () => {
|
||||
const loaded = makeResponse({ recent_n: 4 });
|
||||
get.mockResolvedValue({ data: loaded, error: null });
|
||||
put.mockResolvedValue({ data: null, error: { detail: "422" } });
|
||||
|
||||
const { result } = renderHook(() => useInjection("p1", 3));
|
||||
await waitFor(() => expect(result.current.loading).toBe(false));
|
||||
|
||||
await act(async () => {
|
||||
await result.current.togglePin(CHAR);
|
||||
});
|
||||
|
||||
expect(result.current.error).toBe("保存注入设置失败,请重试");
|
||||
expect(result.current.data).toEqual(loaded);
|
||||
});
|
||||
|
||||
it("保存网络层抛错:本地状态不动、给可读文案、不抛", async () => {
|
||||
const loaded = makeResponse();
|
||||
get.mockResolvedValue({ data: loaded, error: null });
|
||||
put.mockRejectedValue(new Error("network down"));
|
||||
|
||||
const { result } = renderHook(() => useInjection("p1", 3));
|
||||
await waitFor(() => expect(result.current.loading).toBe(false));
|
||||
|
||||
await act(async () => {
|
||||
await result.current.togglePin(CHAR);
|
||||
});
|
||||
|
||||
expect(result.current.error).toBe("保存注入设置失败,请重试");
|
||||
expect(result.current.data).toEqual(loaded);
|
||||
});
|
||||
|
||||
it("data 未就绪时 mutate 早返回、不发 PUT", async () => {
|
||||
get.mockResolvedValue({ data: null, error: { detail: "fail" } });
|
||||
|
||||
const { result } = renderHook(() => useInjection("p1", 3));
|
||||
await waitFor(() => expect(result.current.loading).toBe(false));
|
||||
|
||||
await act(async () => {
|
||||
await result.current.togglePin(CHAR);
|
||||
});
|
||||
|
||||
expect(put).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("并发锁:保存在途时再次触发被拦下,只发一次 PUT", async () => {
|
||||
get.mockResolvedValue({ data: makeResponse(), error: null });
|
||||
// 第一次 PUT 挂起,制造在途窗口。
|
||||
let resolveFirst: (v: unknown) => void = () => {};
|
||||
put.mockImplementationOnce(
|
||||
() =>
|
||||
new Promise((res) => {
|
||||
resolveFirst = res;
|
||||
}),
|
||||
);
|
||||
|
||||
const { result } = renderHook(() => useInjection("p1", 3));
|
||||
await waitFor(() => expect(result.current.loading).toBe(false));
|
||||
|
||||
await act(async () => {
|
||||
// 不 await 第一次:保存在途时立即触发第二次,应被 savingRef 拦下。
|
||||
const first = result.current.togglePin(CHAR);
|
||||
await result.current.exclude(CHAR);
|
||||
resolveFirst({ data: makeResponse(), error: null });
|
||||
await first;
|
||||
});
|
||||
|
||||
expect(put).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user