// @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 追踪契约: // 切项目(连续两次 learn)后,done 时读到的应是后者,不会是首次的陈旧值。 describe("useStyleLearn projectId 追踪(P1-6 stale-closure 守卫)", () => { // 复刻 hook 内部:learn 同步写 ref,done 时同步读 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(); // Assert:done 边沿读到的是 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 { 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 => ({ 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(); }); });