diff --git a/CLAUDE.md b/CLAUDE.md index f07d1f5..bebf4e0 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -96,8 +96,8 @@ Aligns `ARCHITECTURE.md §9.3`. This is how every failure gets diagnosed. 1. **TDD 已遵守** — 先写失败测试(RED)→ 实现到通过(GREEN)→ 重构(REFACTOR)。新增/修改的逻辑都有对应测试;改 bug 先写能复现的失败测试。见上文 §TDD。 2. **测试覆盖率 ≥ 80%(已工具化 + CI 强制)** — 改动涉及的模块覆盖率不得低于 80%;新代码不许拉低整体覆盖率。跑命令核对真实数字,不要估: - 后端:`uv run pytest -q --cov --cov-report=term-missing --cov-fail-under=80`(低于 80% 直接非零退出。当前基线 ~88%)。配置在根 `pyproject.toml` 的 `[tool.coverage.*]`。 - - 前端:`cd apps/web && pnpm test:coverage`(`vitest.config.ts` 设 lines/functions/branches/statements 阈值 80。当前基线 ~93%)。 - - ⚠️ **前端覆盖率范围 = `lib/**` 纯逻辑层**:组件(.tsx)由 E2E/人工验收覆盖;React hooks(`use*.ts`)暂排除(node-only 测试栈测不了)。**待办(@frontend/@qa)**:接入 jsdom + `@testing-library/react`,把 hooks 纳入 vitest 与 80% 门禁,并在 `vitest.config.ts` 移除 `use*.ts` 排除。 + - 前端:`cd apps/web && pnpm test:coverage`(`vitest.config.ts` 设 lines/functions/branches/statements 阈值 80。当前基线 ~95%)。 + - **前端覆盖率范围 = `lib/**`(纯逻辑 + React hooks)**:hooks(`use*.ts`)用 jsdom + `@testing-library/react` 的 `renderHook` 单测(测试文件首行 `// @vitest-environment jsdom`,mock `@/lib/api/client` + `@/components/Toast`,参见 `lib/generation/useWorldGen.test.ts`)。组件(.tsx)仍由 E2E/人工验收覆盖,不在 vitest 范围(写新 hook 时必须同步补 `renderHook` 测试)。 3. **跑完三类自动化测试**(改动触及的那侧必跑,跨栈改动两侧都跑): - **后端 Unit + Integration**:`uv run pytest -q`(需要 pg 的集成测试先 `docker compose up -d pg`)。 - **前端自动化测试**:`cd apps/web && pnpm test`(vitest)。 diff --git a/apps/web/lib/autosave/useAutosave.test.ts b/apps/web/lib/autosave/useAutosave.test.ts new file mode 100644 index 0000000..c4493c4 --- /dev/null +++ b/apps/web/lib/autosave/useAutosave.test.ts @@ -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(); + }); +}); diff --git a/apps/web/lib/foreshadow/useForeshadow.test.ts b/apps/web/lib/foreshadow/useForeshadow.test.ts new file mode 100644 index 0000000..2f1f101 --- /dev/null +++ b/apps/web/lib/foreshadow/useForeshadow.test.ts @@ -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); + }); +}); diff --git a/apps/web/lib/generation/useCharacterGen.test.ts b/apps/web/lib/generation/useCharacterGen.test.ts new file mode 100644 index 0000000..5eacc34 --- /dev/null +++ b/apps/web/lib/generation/useCharacterGen.test.ts @@ -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([]); + }); +}); diff --git a/apps/web/lib/generation/useWorldGen.test.ts b/apps/web/lib/generation/useWorldGen.test.ts new file mode 100644 index 0000000..e06598e --- /dev/null +++ b/apps/web/lib/generation/useWorldGen.test.ts @@ -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([]); + }); +}); diff --git a/apps/web/lib/jobs/useJobPoll.test.ts b/apps/web/lib/jobs/useJobPoll.test.ts new file mode 100644 index 0000000..e230340 --- /dev/null +++ b/apps/web/lib/jobs/useJobPoll.test.ts @@ -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); + }); +}); diff --git a/apps/web/lib/outline/useOutline.test.ts b/apps/web/lib/outline/useOutline.test.ts new file mode 100644 index 0000000..1a02bad --- /dev/null +++ b/apps/web/lib/outline/useOutline.test.ts @@ -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"); + }); +}); diff --git a/apps/web/lib/review/useAccept.test.ts b/apps/web/lib/review/useAccept.test.ts new file mode 100644 index 0000000..46bf58e --- /dev/null +++ b/apps/web/lib/review/useAccept.test.ts @@ -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"); + }); +}); diff --git a/apps/web/lib/review/useReviewStream.test.ts b/apps/web/lib/review/useReviewStream.test.ts new file mode 100644 index 0000000..850bc05 --- /dev/null +++ b/apps/web/lib/review/useReviewStream.test.ts @@ -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 { + 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({}); + 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"); + }); +}); diff --git a/apps/web/lib/rules/useRules.test.ts b/apps/web/lib/rules/useRules.test.ts new file mode 100644 index 0000000..1328032 --- /dev/null +++ b/apps/web/lib/rules/useRules.test.ts @@ -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"); + }); +}); diff --git a/apps/web/lib/settings/useKimiOauth.test.ts b/apps/web/lib/settings/useKimiOauth.test.ts new file mode 100644 index 0000000..8b3e092 --- /dev/null +++ b/apps/web/lib/settings/useKimiOauth.test.ts @@ -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", + ); + }); +}); diff --git a/apps/web/lib/stream/useDraftStream.test.ts b/apps/web/lib/stream/useDraftStream.test.ts new file mode 100644 index 0000000..b9c87d5 --- /dev/null +++ b/apps/web/lib/stream/useDraftStream.test.ts @@ -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 { + 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("oops", { 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({}); + 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("已落草稿"); + }); +}); diff --git a/apps/web/lib/style/useRefine.test.ts b/apps/web/lib/style/useRefine.test.ts new file mode 100644 index 0000000..2918ed5 --- /dev/null +++ b/apps/web/lib/style/useRefine.test.ts @@ -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(); + }); +}); diff --git a/apps/web/lib/style/useStyleLearn.test.ts b/apps/web/lib/style/useStyleLearn.test.ts index 27d5a7c..d45ed59 100644 --- a/apps/web/lib/style/useStyleLearn.test.ts +++ b/apps/web/lib/style/useStyleLearn.test.ts @@ -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 { + 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(); + }); +}); diff --git a/apps/web/lib/toolbox/useGenerator.test.ts b/apps/web/lib/toolbox/useGenerator.test.ts new file mode 100644 index 0000000..21f0bb9 --- /dev/null +++ b/apps/web/lib/toolbox/useGenerator.test.ts @@ -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([]); + }); +}); diff --git a/apps/web/lib/workbench/useInjection.test.ts b/apps/web/lib/workbench/useInjection.test.ts new file mode 100644 index 0000000..fdca77a --- /dev/null +++ b/apps/web/lib/workbench/useInjection.test.ts @@ -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 { + 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); + }); +}); diff --git a/apps/web/package.json b/apps/web/package.json index d487f02..632606c 100644 --- a/apps/web/package.json +++ b/apps/web/package.json @@ -20,6 +20,8 @@ "react-dom": "19.0.0" }, "devDependencies": { + "@testing-library/dom": "^10.4.1", + "@testing-library/react": "^16.3.2", "@types/node": "^22.10.0", "@types/react": "19.0.0", "@types/react-dom": "19.0.0", @@ -27,6 +29,7 @@ "autoprefixer": "^10.4.20", "eslint": "^9.17.0", "eslint-config-next": "15.1.3", + "jsdom": "^29.1.1", "openapi-typescript": "^7.5.0", "playwright": "^1.61.1", "postcss": "^8.4.49", diff --git a/apps/web/pnpm-lock.yaml b/apps/web/pnpm-lock.yaml index d22d281..d177a0f 100644 --- a/apps/web/pnpm-lock.yaml +++ b/apps/web/pnpm-lock.yaml @@ -24,6 +24,12 @@ importers: specifier: 19.0.0 version: 19.0.0(react@19.0.0) devDependencies: + '@testing-library/dom': + specifier: ^10.4.1 + version: 10.4.1 + '@testing-library/react': + specifier: ^16.3.2 + version: 16.3.2(@testing-library/dom@10.4.1)(@types/react-dom@19.0.0)(@types/react@19.0.0)(react-dom@19.0.0(react@19.0.0))(react@19.0.0) '@types/node': specifier: ^22.10.0 version: 22.19.21 @@ -35,7 +41,7 @@ importers: version: 19.0.0 '@vitest/coverage-v8': specifier: ^2.1.9 - version: 2.1.9(vitest@2.1.9(@types/node@22.19.21)(supports-color@10.2.2)) + version: 2.1.9(supports-color@10.2.2)(vitest@2.1.9(@types/node@22.19.21)(jsdom@29.1.1)(supports-color@10.2.2)) autoprefixer: specifier: ^10.4.20 version: 10.5.0(postcss@8.5.15) @@ -45,6 +51,9 @@ importers: eslint-config-next: specifier: 15.1.3 version: 15.1.3(eslint@9.39.4(jiti@1.21.7)(supports-color@10.2.2))(supports-color@10.2.2)(typescript@5.9.3) + jsdom: + specifier: ^29.1.1 + version: 29.1.1 openapi-typescript: specifier: ^7.5.0 version: 7.13.0(typescript@5.9.3) @@ -62,7 +71,7 @@ importers: version: 5.9.3 vitest: specifier: ^2.1.8 - version: 2.1.9(@types/node@22.19.21)(supports-color@10.2.2) + version: 2.1.9(@types/node@22.19.21)(jsdom@29.1.1)(supports-color@10.2.2) packages: @@ -74,6 +83,21 @@ packages: resolution: {integrity: sha512-30iZtAPgz+LTIYoeivqYo853f02jBYSd5uGnGpkFV0M3xOt9aN73erkgYAmZU43x4VfqcnLxW9Kpg3R5LC4YYw==} engines: {node: '>=6.0.0'} + '@asamuzakjp/css-color@5.1.11': + resolution: {integrity: sha512-KVw6qIiCTUQhByfTd78h2yD1/00waTmm9uy/R7Ck/ctUyAPj+AEDLkQIdJW0T8+qGgj3j5bpNKK7Q3G+LedJWg==} + engines: {node: ^20.19.0 || ^22.12.0 || >=24.0.0} + + '@asamuzakjp/dom-selector@7.1.1': + resolution: {integrity: sha512-67RZDnYRc8H/8MLDgQCDE//zoqVFwajkepHZgmXrbwybzXOEwOWGPYGmALYl9J2DOLfFPPs6kKCqmbzV895hTQ==} + engines: {node: ^20.19.0 || ^22.12.0 || >=24.0.0} + + '@asamuzakjp/generational-cache@1.0.1': + resolution: {integrity: sha512-wajfB8KqzMCN2KGNFdLkReeHncd0AslUSrvHVvvYWuU8ghncRJoA50kT3zP9MVL0+9g4/67H+cdvBskj9THPzg==} + engines: {node: ^20.19.0 || ^22.12.0 || >=24.0.0} + + '@asamuzakjp/nwsapi@2.3.9': + resolution: {integrity: sha512-n8GuYSrI9bF7FFZ/SjhwevlHc8xaVlb/7HmHelnc/PZXBD2ZR49NnN9sMMuDdEGPeeRQ5d0hqlSlEpgCX3Wl0Q==} + '@babel/code-frame@7.29.7': resolution: {integrity: sha512-Aup7aUOfpbAUg2ROOJN6Iw5f9DMBlzu0mIkm/malLQFN/YQgO48wCj0Kxa3sEHJvPVFg7siR+qRInwXd2qhQKw==} engines: {node: '>=6.9.0'} @@ -91,6 +115,10 @@ packages: engines: {node: '>=6.0.0'} hasBin: true + '@babel/runtime@7.29.7': + resolution: {integrity: sha512-Nq8OhGWiZIZGV6hLHoyAKLLcJihP/xFeBMGJoUrxTX2psI8dCifzLhZISFb+VWS3wFMRDmCGw5R+dOySCqPLhw==} + engines: {node: '>=6.9.0'} + '@babel/types@7.29.7': resolution: {integrity: sha512-4zBIxpPzowiZpusoFkyGVwakdRJUyuH5PxQ/PrqghfdFWWasvnCdPfQXHrenDai+gyLARulZjZowCOj6fjT4pA==} engines: {node: '>=6.9.0'} @@ -98,6 +126,46 @@ packages: '@bcoe/v8-coverage@0.2.3': resolution: {integrity: sha512-0hYQ8SB4Db5zvZB4axdMHGwEaQjkZzFjQiN9LVYvIFB2nSUHW9tYpxWriPrWDASIxiaXax83REcLxuSdnGPZtw==} + '@bramus/specificity@2.4.2': + resolution: {integrity: sha512-ctxtJ/eA+t+6q2++vj5j7FYX3nRu311q1wfYH3xjlLOsczhlhxAg2FWNUXhpGvAw3BWo1xBcvOV6/YLc2r5FJw==} + hasBin: true + + '@csstools/color-helpers@6.1.0': + resolution: {integrity: sha512-064IFJdjTfUqnjpCVpMOdbr8FLQBhinbZj6yRv2An2E41O/pLEXqfFRWqGq/SxlE5PEUYTlvWsG2r8MswAVvkg==} + engines: {node: '>=20.19.0'} + + '@csstools/css-calc@3.2.1': + resolution: {integrity: sha512-DtdHlgXh5ZkA43cwBcAm+huzgJiwx3ZTWVjBs94kwz2xKqSimDA3lBgCjphYgwgVUMWatSM0pDd8TILB1yrVVg==} + engines: {node: '>=20.19.0'} + peerDependencies: + '@csstools/css-parser-algorithms': ^4.0.0 + '@csstools/css-tokenizer': ^4.0.0 + + '@csstools/css-color-parser@4.1.9': + resolution: {integrity: sha512-paQcIaOO53Rk5+YrBaBjm/SgrV4INImjo2BT1DtQRYr+XeTRbeAYlS+jxXp9drqvKmtFnWRJKIalDLhZZDu42A==} + engines: {node: '>=20.19.0'} + peerDependencies: + '@csstools/css-parser-algorithms': ^4.0.0 + '@csstools/css-tokenizer': ^4.0.0 + + '@csstools/css-parser-algorithms@4.0.0': + resolution: {integrity: sha512-+B87qS7fIG3L5h3qwJ/IFbjoVoOe/bpOdh9hAjXbvx0o8ImEmUsGXN0inFOnk2ChCFgqkkGFQ+TpM5rbhkKe4w==} + engines: {node: '>=20.19.0'} + peerDependencies: + '@csstools/css-tokenizer': ^4.0.0 + + '@csstools/css-syntax-patches-for-csstree@1.1.5': + resolution: {integrity: sha512-oNjBvzLq2GPZtJphCjLqXow/cHySHSgtxvKZb7OqSZ/xHgw6NWNhfad+6AB9cLeVm6eA9d/qMll3JdEHjy6M+A==} + peerDependencies: + css-tree: ^3.2.1 + peerDependenciesMeta: + css-tree: + optional: true + + '@csstools/css-tokenizer@4.0.0': + resolution: {integrity: sha512-QxULHAm7cNu72w97JUNCBFODFaXpbDg+dP8b/oWFAZ2MTRppA3U00Y2L1HqaS4J6yBqxwa/Y3nMBaxVKbB/NsA==} + engines: {node: '>=20.19.0'} + '@emnapi/core@1.10.0': resolution: {integrity: sha512-yq6OkJ4p82CAfPl0u9mQebQHKPJkY7WrIuk205cTYnYe+k2Z8YBh11FrbRG/H6ihirqcacOgl2BIO8oyMQLeXw==} @@ -286,6 +354,15 @@ packages: resolution: {integrity: sha512-43/qtrDUokr7LJqoF2c3+RInu/t4zfrpYdoSDfYyhg52rwLV6TnOvdG4fXm7IkSB3wErkcmJS9iEhjVtOSEjjA==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + '@exodus/bytes@1.15.1': + resolution: {integrity: sha512-S6mL0yNB/Abt9Ei4tq8gDhcczc4S3+vQ4ra7vxnAf+YHC02srtqxKKZghx2Dq6p0e66THKwR6r8N6P95wEty7Q==} + engines: {node: ^20.19.0 || ^22.12.0 || >=24.0.0} + peerDependencies: + '@noble/hashes': ^1.8.0 || ^2.0.0 + peerDependenciesMeta: + '@noble/hashes': + optional: true + '@humanfs/core@0.19.2': resolution: {integrity: sha512-UhXNm+CFMWcbChXywFwkmhqjs3PRCmcSa/hfBgLIb7oQ5HNb1wS0icWsGtSAUNgefHeI+eBrA8I1fxmbHsGdvA==} engines: {node: '>=18.18.0'} @@ -688,9 +765,31 @@ packages: '@swc/helpers@0.5.15': resolution: {integrity: sha512-JQ5TuMi45Owi4/BIMAJBoSQoOJu12oOk/gADqlcUL9JEdHB8vyjUSsxqeNXnmXHjYKMi2WcYtezGEEhqUI/E2g==} + '@testing-library/dom@10.4.1': + resolution: {integrity: sha512-o4PXJQidqJl82ckFaXUeoAW+XysPLauYI43Abki5hABd853iMhitooc6znOnczgbTYmEP6U6/y1ZyKAIsvMKGg==} + engines: {node: '>=18'} + + '@testing-library/react@16.3.2': + resolution: {integrity: sha512-XU5/SytQM+ykqMnAnvB2umaJNIOsLF3PVv//1Ew4CTcpz0/BRyy/af40qqrt7SjKpDdT1saBMc42CUok5gaw+g==} + engines: {node: '>=18'} + peerDependencies: + '@testing-library/dom': ^10.0.0 + '@types/react': ^18.0.0 || ^19.0.0 + '@types/react-dom': ^18.0.0 || ^19.0.0 + react: ^18.0.0 || ^19.0.0 + react-dom: ^18.0.0 || ^19.0.0 + peerDependenciesMeta: + '@types/react': + optional: true + '@types/react-dom': + optional: true + '@tybys/wasm-util@0.10.2': resolution: {integrity: sha512-RoBvJ2X0wuKlWFIjrwffGw1IqZHKQqzIchKaadZZfnNpsAYp2mM0h36JtPCjNDAHGgYez/15uMBpfGwchhiMgg==} + '@types/aria-query@5.0.4': + resolution: {integrity: sha512-rfT93uj5s0PRL7EzccGMs3brplhcrghnDoV26NqKhCAS1hVo+WdNsPvE/yb6ilfr5hi2MEk6d5EWJTKdxg8jVw==} + '@types/estree@1.0.9': resolution: {integrity: sha512-GhdPgy1el4/ImP05X05Uw4cw2/M93BCUmnEvWZNStlCzEKME4Fkk+YpoA5OiHNQmoS7Cafb8Xa3Pya8m1Qrzeg==} @@ -959,6 +1058,10 @@ packages: resolution: {integrity: sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==} engines: {node: '>=8'} + ansi-styles@5.2.0: + resolution: {integrity: sha512-Cxwpt2SfTzTtXcfOlzGEee8O+c+MmUgGrNiBcXnuWxuFJHe6a5Hz7qwhwe5OgaSYI0IJvkLqWX1ASG+cJOkEiA==} + engines: {node: '>=10'} + ansi-styles@6.2.3: resolution: {integrity: sha512-4Dj6M28JB+oAH8kFkTLUo+a2jwOFkuqb3yucU0CANcRRUbxS0cP0nZYCGjcc3BNXwRIsUVmDGgzawme7zvJHvg==} engines: {node: '>=12'} @@ -976,6 +1079,9 @@ packages: argparse@2.0.1: resolution: {integrity: sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==} + aria-query@5.3.0: + resolution: {integrity: sha512-b0P0sZPKtyu8HkeRAfCq0IfURZK+SuwMjY1UXGBU27wpAiTwQAIlq56IbIO+ytk/JjS1fMR14ee5WBBfKi5J6A==} + aria-query@5.3.2: resolution: {integrity: sha512-COROpnaoap1E2F000S62r6A60uHZnmlvomhfyT2DlTcrY1OrBKn2UhH7qn5wTC9zMvD0AY7csdPSNwKP+7WiQw==} engines: {node: '>= 0.4'} @@ -1054,6 +1160,9 @@ packages: engines: {node: '>=6.0.0'} hasBin: true + bidi-js@1.0.3: + resolution: {integrity: sha512-RKshQI1R3YQ+n9YJz2QQ147P66ELpa1FQEg20Dk8oW9t2KgLbpDLLp9aGZ7y8WHSshDknG0bknqGw5/tyCs5tw==} + binary-extensions@2.3.0: resolution: {integrity: sha512-Ceh+7ox5qe7LJuLHoY0feh3pHuUDHAcRUeyL2VYghZwfpkNIy/+8Ocg0a3UuSoYzavmylwuLWQOf3hl0jjMMIw==} engines: {node: '>=8'} @@ -1158,6 +1267,10 @@ packages: resolution: {integrity: sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==} engines: {node: '>= 8'} + css-tree@3.2.1: + resolution: {integrity: sha512-X7sjQzceUhu1u7Y/ylrRZFU2FS6LRiFVp6rKLPg23y3x3c3DOKAwuXGDp+PAGjh6CSnCjYeAul8pcT8bAl+lSA==} + engines: {node: ^10 || ^12.20.0 || ^14.13.0 || >=15.0.0} + cssesc@3.0.0: resolution: {integrity: sha512-/Tb/JcjK111nNScGob5MNtsntNM1aCNUDipB/TkwZFhyDrrE47SOx/18wF2bbjgc3ZzCSKW1T5nt5EbFoAz/Vg==} engines: {node: '>=4'} @@ -1169,6 +1282,10 @@ packages: damerau-levenshtein@1.0.8: resolution: {integrity: sha512-sdQSFB7+llfUcQHUQO3+B8ERRj0Oa4w9POWMI/puGtuf7gFywGmkaLCElnudfTiKZV+NvHqL0ifzdrI8Ro7ESA==} + data-urls@7.0.0: + resolution: {integrity: sha512-23XHcCF+coGYevirZceTVD7NdJOqVn+49IHyxgszm+JIiHLoB2TkmPtsYkNWT1pvRSGkc35L6NHs0yHkN2SumA==} + engines: {node: ^20.19.0 || ^22.12.0 || >=24.0.0} + data-view-buffer@1.0.2: resolution: {integrity: sha512-EmKO5V3OLXh1rtK2wgXRansaK1/mtVdTUEiEI0W8RkvgT05kfxaH29PliLnpLP73yYO6142Q72QNa8Wx/A5CqQ==} engines: {node: '>= 0.4'} @@ -1198,6 +1315,9 @@ packages: supports-color: optional: true + decimal.js@10.6.0: + resolution: {integrity: sha512-YpgQiITW3JXGntzdUmyUR1V812Hn8T1YVXhCu+wO3OpS4eU9l4YdD3qjyiKdV6mvV29zapkMeD390UVEf2lkUg==} + deep-eql@5.0.2: resolution: {integrity: sha512-h5k/5U50IJJFpzfL6nO9jaaumfjO/f2NjK/oYB2Djzm4p9L+3T9qWpZqZ2hAbLPuuYq9wrU08WQyBTL5GbPk5Q==} engines: {node: '>=6'} @@ -1213,6 +1333,10 @@ packages: resolution: {integrity: sha512-8QmQKqEASLd5nx0U1B1okLElbUuuttJ/AnYmRXbbbGDWh6uS208EjD4Xqq/I9wK7u0v6O08XhTWnt5XtEbR6Dg==} engines: {node: '>= 0.4'} + dequal@2.0.3: + resolution: {integrity: sha512-0je+qPKHEMohvfRTCEo3CrPG6cAzAYgmzKyxRiYSSDkS6eGJdyVJm7WaYA5ECaAD9wLB2T4EEeymA5aFVcYXCA==} + engines: {node: '>=6'} + detect-libc@2.1.2: resolution: {integrity: sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==} engines: {node: '>=8'} @@ -1227,6 +1351,9 @@ packages: resolution: {integrity: sha512-35mSku4ZXK0vfCuHEDAwt55dg2jNajHZ1odvF+8SSr82EsZY4QmXfuWso8oEd8zRhVObSN18aM0CjSdoBX7zIw==} engines: {node: '>=0.10.0'} + dom-accessibility-api@0.5.16: + resolution: {integrity: sha512-X7BJ2yElsnOJ30pZF4uIIDfBEVgF4XEBxL9Bxhy6dnrm5hkzqmsWHGTiHqRiITNhMyFLyAiWndIJP7Z1NTteDg==} + dunder-proto@1.0.1: resolution: {integrity: sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==} engines: {node: '>= 0.4'} @@ -1243,6 +1370,10 @@ packages: emoji-regex@9.2.2: resolution: {integrity: sha512-L18DaJsXSUk2+42pv8mLs5jJT2hqFkFE4j21wOmgbUqsZ2hL72NsUU785g9RXgo3s0ZNgVl42TiHp3ZtOv/Vyg==} + entities@8.0.0: + resolution: {integrity: sha512-zwfzJecQ/Uej6tusMqwAqU/6KL2XaB2VZ2Jg54Je6ahNBGNH6Ek6g3jjNCF0fG9EWQKGZNddNjU5F1ZQn/sBnA==} + engines: {node: '>=20.19.0'} + es-abstract-get@1.0.0: resolution: {integrity: sha512-6PMWXpdhshVvFp+FoWYs1EvG1Nj0tvk0dZM+XcK0xMEM1czRVcP6ohqPWHy6qPagSpC8j4+p89WXlT+xXJs/fg==} engines: {node: '>= 0.4'} @@ -1572,6 +1703,10 @@ packages: resolution: {integrity: sha512-T2UbfbBEF32wiepXIsMlTW9+dDYC6wMh/t/vYA4tuOMKqWz/n3vr1NFSxQiyP+zk2mXsoMA/i/7qV6LKut1t1A==} engines: {node: '>= 0.4'} + html-encoding-sniffer@6.0.0: + resolution: {integrity: sha512-CV9TW3Y3f8/wT0BRFc1/KAVQ3TUHiXmaAb6VW9vtiMFf7SLoMd1PdAc4W3KFOFETBJUb90KatHqlsZMWV+R9Gg==} + engines: {node: ^20.19.0 || ^22.12.0 || >=24.0.0} + html-escaper@2.0.2: resolution: {integrity: sha512-H2iMtd0I4Mt5eYiapRdIDjp+XzelXQ0tFE4JS7YFwFevXXMmOp9myNrUvCg0D6ws8iqkRPBfKHgbwig1SmlLfg==} @@ -1685,6 +1820,9 @@ packages: resolution: {integrity: sha512-41Cifkg6e8TylSpdtTpeLVMqvSBEVzTttHvERD741+pnZ8ANv0004MRL43QKPDlK9cGvNp6NZWZUBlbGXYxxng==} engines: {node: '>=0.12.0'} + is-potential-custom-element-name@1.0.1: + resolution: {integrity: sha512-bCYeRA2rVibKZd+s2625gGnGF/t7DSqDs4dP7CrLA1m7jKWz6pps0LpYLJN8Q64HtmPKJ1hrN3nzPNKFEKOUiQ==} + is-regex@1.2.1: resolution: {integrity: sha512-MjYsKHO5O7mCsmRGxWcLWheFqN9DJ/2TmngvjKXihe6efViPqc274+Fx/4fYj/r03+ESvBdTXK0V6tA3rgez1g==} engines: {node: '>= 0.4'} @@ -1769,6 +1907,15 @@ packages: resolution: {integrity: sha512-ePWsvanv0DWuDRsW8dnt+R4jQ31SCRCQ7hhNcPXZPsoBZiemuZNYGf7adZdqX2D86j6rvKp3RpCxVTSb8WQlOw==} hasBin: true + jsdom@29.1.1: + resolution: {integrity: sha512-ECi4Fi2f7BdJtUKTflYRTiaMxIB0O6zfR1fX0GXpUrf6flp8QIYn1UT20YQqdSOfk2dfkCwS8LAFoJDEppNK5Q==} + engines: {node: ^20.19.0 || ^22.13.0 || >=24.0.0} + peerDependencies: + canvas: ^3.0.0 + peerDependenciesMeta: + canvas: + optional: true + json-buffer@3.0.1: resolution: {integrity: sha512-4bV5BfR2mqfQTJm+V5tPPdf+ZpuhiIvTuAB5g8kcrXOZpTT/QwwVRWBywX1ozr6lEuPdbHxwaJlm9G6mI2sfSQ==} @@ -1827,11 +1974,19 @@ packages: lru-cache@10.4.3: resolution: {integrity: sha512-JNAzZcXrCt42VGLuYz0zfAzDfAvJWW6AfYlDBQyDV5DClI2m5sAmK+OIO7s59XfsRsWHp02jAJrRadPRGTt6SQ==} + lru-cache@11.5.1: + resolution: {integrity: sha512-RPimw/7aMdv2oqRrxKwvZXcPfwBrn/JZ2xYcY9Hus/6LaS3VOAKVWKWgNLCFSiOm1ESXinjsDlidVU7JlnCN2A==} + engines: {node: 20 || >=22} + lucide-react@1.21.0: resolution: {integrity: sha512-reEZMXq8Qdd5jg5XYkQ5TR1fB/GiQ7ih4vcrthYDtgjSDwh0i6/YLiGjsWsIwgN49gpAnd4J2elSNzncMEEUUQ==} peerDependencies: react: ^16.5.1 || ^17.0.0 || ^18.0.0 || ^19.0.0 + lz-string@1.5.0: + resolution: {integrity: sha512-h5bgJWpxJNswbU7qCrV0tIKQCaS3blPDrqKWx+QxzuzL1zGUzij9XCWLrSLsJPu5t+eWA/ycetzYAO5IOMcWAQ==} + hasBin: true + magic-string@0.30.21: resolution: {integrity: sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==} @@ -1846,6 +2001,9 @@ packages: resolution: {integrity: sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==} engines: {node: '>= 0.4'} + mdn-data@2.27.1: + resolution: {integrity: sha512-9Yubnt3e8A0OKwxYSXyhLymGW4sCufcLG6VdiDdUGVkPhpqLxlvP5vl1983gQjJl3tqbrM731mjaZaP68AgosQ==} + merge2@1.4.1: resolution: {integrity: sha512-8q7VEgMJW4J8tcfVPy8g09NcQwZdbwFEqhe/WZkoIzjn/3TGDwtOCYtXGxA3O8tPzpczCCDgv+P2P5y00ZJOOg==} engines: {node: '>= 8'} @@ -2004,6 +2162,9 @@ packages: resolution: {integrity: sha512-ybiGyvspI+fAoRQbIPRddCcSTV9/LsJbf0e/S85VLowVGzRmokfneg2kwVW/KU5rOXrPSbF1qAKPMgNTqqROQQ==} engines: {node: '>=18'} + parse5@8.0.1: + resolution: {integrity: sha512-z1e/HMG90obSGeidlli3hj7cbocou0/wa5HacvI3ASx34PecNjNQeaHNo5WIZpWofN9kgkqV1q5YvXe3F0FoPw==} + path-exists@4.0.0: resolution: {integrity: sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w==} engines: {node: '>=8'} @@ -2118,6 +2279,10 @@ packages: resolution: {integrity: sha512-vkcDPrRZo1QZLbn5RLGPpg/WmIQ65qoWWhcGKf/b5eplkkarX0m9z8ppCat4mlOqUsWpyNuYgO3VRyrYHSzX5g==} engines: {node: '>= 0.8.0'} + pretty-format@27.5.1: + resolution: {integrity: sha512-Qb1gy5OrP5+zDf2Bvnzdl3jsTf1qXVMazbvCoKhtKqVs4/YK4ozX4gKQJJVyNe+cajNPn0KoC0MC3FUmaHWEmQ==} + engines: {node: ^10.13.0 || ^12.13.0 || ^14.15.0 || >=15.0.0} + prop-types@15.8.1: resolution: {integrity: sha512-oj87CgZICdulUohogVAR7AjlC0327U4el4L6eAvOqCeudMDVU0NThNaV+b9Df4dXgSP1gXMTnPdhfe/2qDH5cg==} @@ -2136,6 +2301,9 @@ packages: react-is@16.13.1: resolution: {integrity: sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ==} + react-is@17.0.2: + resolution: {integrity: sha512-w2GsyukL62IJnlaff/nRegPQR94C/XXamvMWmSHRJ4y7Ts/4ocGRmTHvOs8PSE6pB3dWOrD/nueuU5sduBsQ4w==} + react@19.0.0: resolution: {integrity: sha512-V8AVnmPIICiWpGfm6GLzCR/W5FXLchHop40W4nXBmdlEceh16rCN8O8LNWm5bh5XUX91fh7KpA+W0TgMKmgTpQ==} engines: {node: '>=0.10.0'} @@ -2200,6 +2368,10 @@ packages: resolution: {integrity: sha512-x/+Cz4YrimQxQccJf5mKEbIa1NzeCRNI5Ecl/ekmlYaampdNLPalVyIcCZNNH3MvmqBugV5TMYZXv0ljslUlaw==} engines: {node: '>= 0.4'} + saxes@6.0.0: + resolution: {integrity: sha512-xAg7SOnEhrm5zI3puOOKyy1OMcMlIJZYNJY7xLBwSze0UjhPLnWfj2GF2EpT0jmzaJKIWKHLsaSSajf35bcYnA==} + engines: {node: '>=v12.22.7'} + scheduler@0.25.0: resolution: {integrity: sha512-xFVuu11jh+xcO7JOAGJNOXld8/TcEHK/4CituBUeUb5hqxJLj9YuemAEuvm9gQ/+pgXYfbQuqAkiYu+u7YEsNA==} @@ -2360,6 +2532,9 @@ packages: resolution: {integrity: sha512-ot0WnXS9fgdkgIcePe6RHNk1WA8+muPa6cSjeR3V8K27q9BB1rTE3R1p7Hv0z1ZyAc8s6Vvv8DIyWf681MAt0w==} engines: {node: '>= 0.4'} + symbol-tree@3.2.4: + resolution: {integrity: sha512-9QNk5KwDF+Bvz+PyObkmSYjI5ksVUYtjW7AU22r2NKcfLJcXp96hkDWU3+XndOsUb+AQ9QhfzfCT2O+CNWT5Tw==} + tailwindcss@3.4.19: resolution: {integrity: sha512-3ofp+LL8E+pK/JuPLPggVAIaEuhvIz4qNcf3nA1Xn2o/7fb7s/TYpHhwGDv1ZU3PkBluUVaF8PyCHcm48cKLWQ==} engines: {node: '>=14.0.0'} @@ -2398,10 +2573,25 @@ packages: resolution: {integrity: sha512-n1cw8k1k0x4pgA2+9XrOkFydTerNcJ1zWCO5Nn9scWHTD+5tp8dghT2x1uduQePZTZgd3Tupf+x9BxJjeJi77Q==} engines: {node: '>=14.0.0'} + tldts-core@7.4.4: + resolution: {integrity: sha512-vwVLJVvvpslm7vqAH7+XNj/neA/Ynq7DT2EEcMuwc5YzN5XaMyRAqxwU+uX3azZ1FQtB2gvrvnLnAEkvYlVdfg==} + + tldts@7.4.4: + resolution: {integrity: sha512-kFXFK7O4WPextIUAOk8qtnw9dxR9UIXP9CjuH1cTBVBZMDeQcUPgr/IazGiw1B0Yiw5L75gHLWeW4iD793r90g==} + hasBin: true + to-regex-range@5.0.1: resolution: {integrity: sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ==} engines: {node: '>=8.0'} + tough-cookie@6.0.1: + resolution: {integrity: sha512-LktZQb3IeoUWB9lqR5EWTHgW/VTITCXg4D21M+lvybRVdylLrRMnqaIONLVb5mav8vM19m44HIcGq4qASeu2Qw==} + engines: {node: '>=16'} + + tr46@6.0.0: + resolution: {integrity: sha512-bLVMLPtstlZ4iMQHpFHTR7GAGj2jxi8Dg0s2h2MafAE4uSWF98FC/3MomU51iQAMf8/qDUbKWf5GxuvvVcXEhw==} + engines: {node: '>=20'} + ts-api-utils@2.5.0: resolution: {integrity: sha512-OJ/ibxhPlqrMM0UiNHJ/0CKQkoKF243/AEmplt3qpRgkW8VG7IfOS41h7V8TjITqdByHzrjcS/2si+y4lIh8NA==} engines: {node: '>=18.12'} @@ -2453,6 +2643,10 @@ packages: undici-types@6.21.0: resolution: {integrity: sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==} + undici@7.28.0: + resolution: {integrity: sha512-cRZYrTDwWznlnRiPjggAGxZXanty6M8RV1ff8Wm4LWXBp7/IG8v5DnOm74DtUBp9OONpK75YlPnIjQqX0dBDtA==} + engines: {node: '>=20.18.1'} + unrs-resolver@1.12.2: resolution: {integrity: sha512-dmlRxBJJayXjqTwC+JtF1HhJmgf3ftQ3YejFcZrf4+KKtJv0qDsK1pjqaaVjG7wJ5NJ6UVP1OqRMQ71Z4C3rxQ==} @@ -2532,6 +2726,22 @@ packages: jsdom: optional: true + w3c-xmlserializer@5.0.0: + resolution: {integrity: sha512-o8qghlI8NZHU1lLPrpi2+Uq7abh4GGPpYANlalzWxyWteJOCsr/P+oPBA49TOLu5FTZO4d3F9MnWJfiMo4BkmA==} + engines: {node: '>=18'} + + webidl-conversions@8.0.1: + resolution: {integrity: sha512-BMhLD/Sw+GbJC21C/UgyaZX41nPt8bUTg+jWyDeg7e7YN4xOM05YPSIXceACnXVtqyEw/LMClUQMtMZ+PGGpqQ==} + engines: {node: '>=20'} + + whatwg-mimetype@5.0.0: + resolution: {integrity: sha512-sXcNcHOC51uPGF0P/D4NVtrkjSU2fNsm9iog4ZvZJsL3rjoDAzXZhkm2MWt1y+PUdggKAYVoMAIYcs78wJ51Cw==} + engines: {node: '>=20'} + + whatwg-url@16.0.1: + resolution: {integrity: sha512-1to4zXBxmXHV3IiSSEInrreIlu02vUOvrhxJJH5vcxYTBDAx51cqZiKdyTxlecdKNSjj8EcxGBxNf6Vg+945gw==} + engines: {node: ^20.19.0 || ^22.12.0 || >=24.0.0} + which-boxed-primitive@1.1.1: resolution: {integrity: sha512-TbX3mj8n0odCBFVlY8AxkqcHASw3L60jIuF8jFP78az3C2YhmGvqbHBpAjTRH2/xqYunrJ9g1jSyjCjpoWzIAA==} engines: {node: '>= 0.4'} @@ -2570,6 +2780,13 @@ packages: resolution: {integrity: sha512-si7QWI6zUMq56bESFvagtmzMdGOtoxfR+Sez11Mobfc7tm+VkUckk9bW2UeffTGVUbOksxmSw0AA2gs8g71NCQ==} engines: {node: '>=12'} + xml-name-validator@5.0.0: + resolution: {integrity: sha512-EvGK8EJ3DhaHfbRlETOWAS5pO9MZITeauHKJyb8wyajUfQUenkIg2MvLDTZ4T/TgIcm3HU0TFBgWWboAZ30UHg==} + engines: {node: '>=18'} + + xmlchars@2.2.0: + resolution: {integrity: sha512-JZnDKK8B0RCDw84FNdDAIpZK+JuJw+s7Lz8nksI7SIuU3UXJJslUthsi+uWBUYOwPFwW7W7PRLRfUKpxjtjFCw==} + yaml-ast-parser@0.0.43: resolution: {integrity: sha512-2PTINUwsRqSd+s8XxKaJWQlUuEMHJQyEuh2edBbW8KNJz0SJPwUSD2zRWqezFEdN7IzAgeuYHFUCF7o8zRdZ0A==} @@ -2590,6 +2807,26 @@ snapshots: '@jridgewell/gen-mapping': 0.3.13 '@jridgewell/trace-mapping': 0.3.31 + '@asamuzakjp/css-color@5.1.11': + dependencies: + '@asamuzakjp/generational-cache': 1.0.1 + '@csstools/css-calc': 3.2.1(@csstools/css-parser-algorithms@4.0.0(@csstools/css-tokenizer@4.0.0))(@csstools/css-tokenizer@4.0.0) + '@csstools/css-color-parser': 4.1.9(@csstools/css-parser-algorithms@4.0.0(@csstools/css-tokenizer@4.0.0))(@csstools/css-tokenizer@4.0.0) + '@csstools/css-parser-algorithms': 4.0.0(@csstools/css-tokenizer@4.0.0) + '@csstools/css-tokenizer': 4.0.0 + + '@asamuzakjp/dom-selector@7.1.1': + dependencies: + '@asamuzakjp/generational-cache': 1.0.1 + '@asamuzakjp/nwsapi': 2.3.9 + bidi-js: 1.0.3 + css-tree: 3.2.1 + is-potential-custom-element-name: 1.0.1 + + '@asamuzakjp/generational-cache@1.0.1': {} + + '@asamuzakjp/nwsapi@2.3.9': {} + '@babel/code-frame@7.29.7': dependencies: '@babel/helper-validator-identifier': 7.29.7 @@ -2604,6 +2841,8 @@ snapshots: dependencies: '@babel/types': 7.29.7 + '@babel/runtime@7.29.7': {} + '@babel/types@7.29.7': dependencies: '@babel/helper-string-parser': 7.29.7 @@ -2611,6 +2850,34 @@ snapshots: '@bcoe/v8-coverage@0.2.3': {} + '@bramus/specificity@2.4.2': + dependencies: + css-tree: 3.2.1 + + '@csstools/color-helpers@6.1.0': {} + + '@csstools/css-calc@3.2.1(@csstools/css-parser-algorithms@4.0.0(@csstools/css-tokenizer@4.0.0))(@csstools/css-tokenizer@4.0.0)': + dependencies: + '@csstools/css-parser-algorithms': 4.0.0(@csstools/css-tokenizer@4.0.0) + '@csstools/css-tokenizer': 4.0.0 + + '@csstools/css-color-parser@4.1.9(@csstools/css-parser-algorithms@4.0.0(@csstools/css-tokenizer@4.0.0))(@csstools/css-tokenizer@4.0.0)': + dependencies: + '@csstools/color-helpers': 6.1.0 + '@csstools/css-calc': 3.2.1(@csstools/css-parser-algorithms@4.0.0(@csstools/css-tokenizer@4.0.0))(@csstools/css-tokenizer@4.0.0) + '@csstools/css-parser-algorithms': 4.0.0(@csstools/css-tokenizer@4.0.0) + '@csstools/css-tokenizer': 4.0.0 + + '@csstools/css-parser-algorithms@4.0.0(@csstools/css-tokenizer@4.0.0)': + dependencies: + '@csstools/css-tokenizer': 4.0.0 + + '@csstools/css-syntax-patches-for-csstree@1.1.5(css-tree@3.2.1)': + optionalDependencies: + css-tree: 3.2.1 + + '@csstools/css-tokenizer@4.0.0': {} + '@emnapi/core@1.10.0': dependencies: '@emnapi/wasi-threads': 1.2.1 @@ -2747,6 +3014,8 @@ snapshots: '@eslint/core': 0.17.0 levn: 0.4.1 + '@exodus/bytes@1.15.1': {} + '@humanfs/core@0.19.2': dependencies: '@humanfs/types': 0.15.0 @@ -3025,11 +3294,34 @@ snapshots: dependencies: tslib: 2.8.1 + '@testing-library/dom@10.4.1': + dependencies: + '@babel/code-frame': 7.29.7 + '@babel/runtime': 7.29.7 + '@types/aria-query': 5.0.4 + aria-query: 5.3.0 + dom-accessibility-api: 0.5.16 + lz-string: 1.5.0 + picocolors: 1.1.1 + pretty-format: 27.5.1 + + '@testing-library/react@16.3.2(@testing-library/dom@10.4.1)(@types/react-dom@19.0.0)(@types/react@19.0.0)(react-dom@19.0.0(react@19.0.0))(react@19.0.0)': + dependencies: + '@babel/runtime': 7.29.7 + '@testing-library/dom': 10.4.1 + react: 19.0.0 + react-dom: 19.0.0(react@19.0.0) + optionalDependencies: + '@types/react': 19.0.0 + '@types/react-dom': 19.0.0 + '@tybys/wasm-util@0.10.2': dependencies: tslib: 2.8.1 optional: true + '@types/aria-query@5.0.4': {} + '@types/estree@1.0.9': {} '@types/json-schema@7.0.15': {} @@ -3209,21 +3501,21 @@ snapshots: '@unrs/resolver-binding-win32-x64-msvc@1.12.2': optional: true - '@vitest/coverage-v8@2.1.9(vitest@2.1.9(@types/node@22.19.21)(supports-color@10.2.2))': + '@vitest/coverage-v8@2.1.9(supports-color@10.2.2)(vitest@2.1.9(@types/node@22.19.21)(jsdom@29.1.1)(supports-color@10.2.2))': dependencies: '@ampproject/remapping': 2.3.0 '@bcoe/v8-coverage': 0.2.3 debug: 4.4.3(supports-color@10.2.2) istanbul-lib-coverage: 3.2.2 istanbul-lib-report: 3.0.1 - istanbul-lib-source-maps: 5.0.6 + istanbul-lib-source-maps: 5.0.6(supports-color@10.2.2) istanbul-reports: 3.2.0 magic-string: 0.30.21 magicast: 0.3.5 std-env: 3.10.0 test-exclude: 7.0.2 tinyrainbow: 1.2.0 - vitest: 2.1.9(@types/node@22.19.21)(supports-color@10.2.2) + vitest: 2.1.9(@types/node@22.19.21)(jsdom@29.1.1)(supports-color@10.2.2) transitivePeerDependencies: - supports-color @@ -3292,6 +3584,8 @@ snapshots: dependencies: color-convert: 2.0.1 + ansi-styles@5.2.0: {} + ansi-styles@6.2.3: {} any-promise@1.3.0: {} @@ -3305,6 +3599,10 @@ snapshots: argparse@2.0.1: {} + aria-query@5.3.0: + dependencies: + dequal: 2.0.3 + aria-query@5.3.2: {} array-buffer-byte-length@1.0.2: @@ -3403,6 +3701,10 @@ snapshots: baseline-browser-mapping@2.10.37: {} + bidi-js@1.0.3: + dependencies: + require-from-string: 2.0.2 + binary-extensions@2.3.0: {} brace-expansion@1.1.15: @@ -3520,12 +3822,24 @@ snapshots: shebang-command: 2.0.0 which: 2.0.2 + css-tree@3.2.1: + dependencies: + mdn-data: 2.27.1 + source-map-js: 1.2.1 + cssesc@3.0.0: {} csstype@3.2.3: {} damerau-levenshtein@1.0.8: {} + data-urls@7.0.0: + dependencies: + whatwg-mimetype: 5.0.0 + whatwg-url: 16.0.1 + transitivePeerDependencies: + - '@noble/hashes' + data-view-buffer@1.0.2: dependencies: call-bound: 1.0.4 @@ -3554,6 +3868,8 @@ snapshots: optionalDependencies: supports-color: 10.2.2 + decimal.js@10.6.0: {} + deep-eql@5.0.2: {} deep-is@0.1.4: {} @@ -3570,6 +3886,8 @@ snapshots: has-property-descriptors: 1.0.2 object-keys: 1.1.1 + dequal@2.0.3: {} + detect-libc@2.1.2: optional: true @@ -3581,6 +3899,8 @@ snapshots: dependencies: esutils: 2.0.3 + dom-accessibility-api@0.5.16: {} + dunder-proto@1.0.1: dependencies: call-bind-apply-helpers: 1.0.2 @@ -3595,6 +3915,8 @@ snapshots: emoji-regex@9.2.2: {} + entities@8.0.0: {} + es-abstract-get@1.0.0: dependencies: es-errors: 1.3.0 @@ -4102,6 +4424,12 @@ snapshots: dependencies: function-bind: 1.1.2 + html-encoding-sniffer@6.0.0: + dependencies: + '@exodus/bytes': 1.15.1 + transitivePeerDependencies: + - '@noble/hashes' + html-escaper@2.0.2: {} https-proxy-agent@7.0.6(supports-color@10.2.2): @@ -4216,6 +4544,8 @@ snapshots: is-number@7.0.0: {} + is-potential-custom-element-name@1.0.1: {} + is-regex@1.2.1: dependencies: call-bound: 1.0.4 @@ -4267,7 +4597,7 @@ snapshots: make-dir: 4.0.0 supports-color: 7.2.0 - istanbul-lib-source-maps@5.0.6: + istanbul-lib-source-maps@5.0.6(supports-color@10.2.2): dependencies: '@jridgewell/trace-mapping': 0.3.31 debug: 4.4.3(supports-color@10.2.2) @@ -4309,6 +4639,32 @@ snapshots: dependencies: argparse: 2.0.1 + jsdom@29.1.1: + dependencies: + '@asamuzakjp/css-color': 5.1.11 + '@asamuzakjp/dom-selector': 7.1.1 + '@bramus/specificity': 2.4.2 + '@csstools/css-syntax-patches-for-csstree': 1.1.5(css-tree@3.2.1) + '@exodus/bytes': 1.15.1 + css-tree: 3.2.1 + data-urls: 7.0.0 + decimal.js: 10.6.0 + html-encoding-sniffer: 6.0.0 + is-potential-custom-element-name: 1.0.1 + lru-cache: 11.5.1 + parse5: 8.0.1 + saxes: 6.0.0 + symbol-tree: 3.2.4 + tough-cookie: 6.0.1 + undici: 7.28.0 + w3c-xmlserializer: 5.0.0 + webidl-conversions: 8.0.1 + whatwg-mimetype: 5.0.0 + whatwg-url: 16.0.1 + xml-name-validator: 5.0.0 + transitivePeerDependencies: + - '@noble/hashes' + json-buffer@3.0.1: {} json-schema-traverse@0.4.1: {} @@ -4361,10 +4717,14 @@ snapshots: lru-cache@10.4.3: {} + lru-cache@11.5.1: {} + lucide-react@1.21.0(react@19.0.0): dependencies: react: 19.0.0 + lz-string@1.5.0: {} + magic-string@0.30.21: dependencies: '@jridgewell/sourcemap-codec': 1.5.5 @@ -4381,6 +4741,8 @@ snapshots: math-intrinsics@1.1.0: {} + mdn-data@2.27.1: {} + merge2@1.4.1: {} micromatch@4.0.8: @@ -4553,6 +4915,10 @@ snapshots: index-to-position: 1.2.0 type-fest: 4.41.0 + parse5@8.0.1: + dependencies: + entities: 8.0.0 + path-exists@4.0.0: {} path-key@3.1.1: {} @@ -4635,6 +5001,12 @@ snapshots: prelude-ls@1.2.1: {} + pretty-format@27.5.1: + dependencies: + ansi-regex: 5.0.1 + ansi-styles: 5.2.0 + react-is: 17.0.2 + prop-types@15.8.1: dependencies: loose-envify: 1.4.0 @@ -4652,6 +5024,8 @@ snapshots: react-is@16.13.1: {} + react-is@17.0.2: {} + react@19.0.0: {} read-cache@1.0.0: @@ -4760,6 +5134,10 @@ snapshots: es-errors: 1.3.0 is-regex: 1.2.1 + saxes@6.0.0: + dependencies: + xmlchars: 2.2.0 + scheduler@0.25.0: {} semver@6.3.1: {} @@ -4971,6 +5349,8 @@ snapshots: supports-preserve-symlinks-flag@1.0.0: {} + symbol-tree@3.2.4: {} + tailwindcss@3.4.19: dependencies: '@alloc/quick-lru': 5.2.0 @@ -5028,10 +5408,24 @@ snapshots: tinyspy@3.0.2: {} + tldts-core@7.4.4: {} + + tldts@7.4.4: + dependencies: + tldts-core: 7.4.4 + to-regex-range@5.0.1: dependencies: is-number: 7.0.0 + tough-cookie@6.0.1: + dependencies: + tldts: 7.4.4 + + tr46@6.0.0: + dependencies: + punycode: 2.3.1 + ts-api-utils@2.5.0(typescript@5.9.3): dependencies: typescript: 5.9.3 @@ -5097,6 +5491,8 @@ snapshots: undici-types@6.21.0: {} + undici@7.28.0: {} + unrs-resolver@1.12.2: dependencies: napi-postinstall: 0.3.4 @@ -5165,7 +5561,7 @@ snapshots: '@types/node': 22.19.21 fsevents: 2.3.3 - vitest@2.1.9(@types/node@22.19.21)(supports-color@10.2.2): + vitest@2.1.9(@types/node@22.19.21)(jsdom@29.1.1)(supports-color@10.2.2): dependencies: '@vitest/expect': 2.1.9 '@vitest/mocker': 2.1.9(vite@5.4.21(@types/node@22.19.21)) @@ -5189,6 +5585,7 @@ snapshots: why-is-node-running: 2.3.0 optionalDependencies: '@types/node': 22.19.21 + jsdom: 29.1.1 transitivePeerDependencies: - less - lightningcss @@ -5200,6 +5597,22 @@ snapshots: - supports-color - terser + w3c-xmlserializer@5.0.0: + dependencies: + xml-name-validator: 5.0.0 + + webidl-conversions@8.0.1: {} + + whatwg-mimetype@5.0.0: {} + + whatwg-url@16.0.1: + dependencies: + '@exodus/bytes': 1.15.1 + tr46: 6.0.0 + webidl-conversions: 8.0.1 + transitivePeerDependencies: + - '@noble/hashes' + which-boxed-primitive@1.1.1: dependencies: is-bigint: 1.1.0 @@ -5264,6 +5677,10 @@ snapshots: string-width: 5.1.2 strip-ansi: 7.2.0 + xml-name-validator@5.0.0: {} + + xmlchars@2.2.0: {} + yaml-ast-parser@0.0.43: {} yargs-parser@21.1.1: {} diff --git a/apps/web/vitest.config.ts b/apps/web/vitest.config.ts index d1857a3..68740cb 100644 --- a/apps/web/vitest.config.ts +++ b/apps/web/vitest.config.ts @@ -12,15 +12,12 @@ export default defineConfig({ include: ["lib/**/*.test.ts"], coverage: { provider: "v8", - // 覆盖范围 = 可单测的纯逻辑层(lib/**),node 环境即可跑。 - // 排除项: - // - 组件(.tsx):由 E2E/人工验收覆盖,不在 vitest 范围。 - // - React hooks(use*.ts):需 jsdom + @testing-library/react 才能单测, - // 当前 node-only 测试栈无法覆盖。待办:接入 jsdom 测试栈后移除此排除, - // 把 hooks 纳入 80% 门禁。 - // - lib/api/schema.d.ts:OpenAPI codegen 生成物。 + // 覆盖范围 = lib/** 全部逻辑层(纯逻辑 + React hooks)。 + // hooks(use*.ts) 用 jsdom + @testing-library/react 的 renderHook 单测(见各 *.test.ts 的 + // `// @vitest-environment jsdom` 头)。组件(.tsx)仍由 E2E/人工验收覆盖,不在 vitest 范围。 + // 排除项:测试文件本身、OpenAPI codegen 生成物。 include: ["lib/**/*.ts"], - exclude: ["lib/**/*.test.ts", "lib/**/use*.ts", "lib/api/schema.d.ts"], + exclude: ["lib/**/*.test.ts", "lib/api/schema.d.ts"], reporter: ["text", "text-summary"], thresholds: { lines: 80,