Files
writer-work-flow/apps/web/lib/style/useStyleLearn.test.ts
Yaojia Wang a6f5d085e5 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 同步说明。
2026-06-27 06:21:42 +02:00

284 lines
9.6 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 { 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 来保证这点。
//
// 该 hook 依赖浏览器/React 渲染node 环境无法整体跑;这里以最小模型固化 ref 追踪契约:
// 切项目(连续两次 learndone 时读到的应是后者,不会是首次的陈旧值。
describe("useStyleLearn projectId 追踪P1-6 stale-closure 守卫)", () => {
// 复刻 hook 内部learn 同步写 refdone 时同步读 ref。
function makeTracker() {
const projectIdRef: { current: string | null } = { current: null };
return {
// learn(pid):受理时记录最近项目。
learn(pid: string): void {
projectIdRef.current = pid;
},
// onDone():轮询完成时取用于拉指纹的项目(应为最新)。
onDone(): string | null {
return projectIdRef.current;
},
};
}
it("done 时用最近一次 learn 的 projectId切项目后不复用旧值", () => {
// Arrange
const t = makeTracker();
// Act先 learn 项目 A再切到项目 B模拟用户换项目重学此时 A 的轮询尚未完成。
t.learn("project-A");
t.learn("project-B");
const used = t.onDone();
// Assertdone 边沿读到的是 B而非闭包里陈旧的 A。
expect(used).toBe("project-B");
});
it("未 learn 时为 null不误拉指纹", () => {
const t = makeTracker();
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();
});
});