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