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:
Yaojia Wang
2026-06-27 06:21:42 +02:00
parent a02c6b6e4f
commit a6f5d085e5
19 changed files with 2838 additions and 18 deletions

View File

@@ -96,8 +96,8 @@ Aligns `ARCHITECTURE.md §9.3`. This is how every failure gets diagnosed.
1. **TDD 已遵守** — 先写失败测试RED→ 实现到通过GREEN→ 重构REFACTOR。新增/修改的逻辑都有对应测试;改 bug 先写能复现的失败测试。见上文 §TDD。 1. **TDD 已遵守** — 先写失败测试RED→ 实现到通过GREEN→ 重构REFACTOR。新增/修改的逻辑都有对应测试;改 bug 先写能复现的失败测试。见上文 §TDD。
2. **测试覆盖率 ≥ 80%(已工具化 + CI 强制)** — 改动涉及的模块覆盖率不得低于 80%;新代码不许拉低整体覆盖率。跑命令核对真实数字,不要估: 2. **测试覆盖率 ≥ 80%(已工具化 + CI 强制)** — 改动涉及的模块覆盖率不得低于 80%;新代码不许拉低整体覆盖率。跑命令核对真实数字,不要估:
- 后端:`uv run pytest -q --cov --cov-report=term-missing --cov-fail-under=80`(低于 80% 直接非零退出。当前基线 ~88%)。配置在根 `pyproject.toml``[tool.coverage.*]` - 后端:`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%)。 - 前端:`cd apps/web && pnpm test:coverage``vitest.config.ts` 设 lines/functions/branches/statements 阈值 80。当前基线 ~95%)。
- ⚠️ **前端覆盖率范围 = `lib/**` 纯逻辑层**:组件(.tsx)由 E2E/人工验收覆盖React hooks(`use*.ts`)暂排除node-only 测试栈测不了)。**待办(@frontend/@qa**:接入 jsdom + `@testing-library/react`,把 hooks 纳入 vitest 与 80% 门禁,并在 `vitest.config.ts` 移除 `use*.ts` 排除 - **前端覆盖率范围 = `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. **跑完三类自动化测试**(改动触及的那侧必跑,跨栈改动两侧都跑): 3. **跑完三类自动化测试**(改动触及的那侧必跑,跨栈改动两侧都跑):
- **后端 Unit + Integration**`uv run pytest -q`(需要 pg 的集成测试先 `docker compose up -d pg`)。 - **后端 Unit + Integration**`uv run pytest -q`(需要 pg 的集成测试先 `docker compose up -d pg`)。
- **前端自动化测试**`cd apps/web && pnpm test`vitest - **前端自动化测试**`cd apps/web && pnpm test`vitest

View 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();
});
});

View 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);
});
});

View 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("生成后端返回 errorgenStatus=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("入库非冲突 erroringestStatus=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([]);
});
});

View 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("后端返回 errorstatus=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([]);
});
});

View 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);
});
// Assertdone、进度 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);
});
});

View 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");
});
});

View 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");
});
});

View 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("网络抛异常(非 Abortphase=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 被吞掉:不进入 errorphase 维持 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");
});
});

View 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");
});
});

View 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 () => {
// Arrangestart 拿到 device + job_idjob 首拍即 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 failedphase=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",
);
});
});

View 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";
// fetchSSE 流)是 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("网络抛异常(非 Abortphase=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 被吞掉:不进入 errorphase 维持 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("已落草稿");
});
});

View 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_UNAVAILABLEstatus=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();
});
});

View File

@@ -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 传入的 // P1-6 回归守卫学文风轮询完成done 边沿effect 必须用「最近一次 learn 传入的
// projectId」去拉指纹而非闭包捕获的旧值。useStyleLearn 用 ref 追踪 projectId 来保证这点。 // projectId」去拉指纹而非闭包捕获的旧值。useStyleLearn 用 ref 追踪 projectId 来保证这点。
@@ -39,3 +45,239 @@ describe("useStyleLearn projectId 追踪P1-6 stale-closure 守卫)", () =>
expect(t.onDone()).toBeNull(); 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();
});
});

View 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("生成后端返回 errorgenStatus=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("入库非冲突 erroringestStatus=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([]);
});
});

View 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("挂载成功:拉到确定性结果并落到 dataloading 归位", 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);
});
});

View File

@@ -20,6 +20,8 @@
"react-dom": "19.0.0" "react-dom": "19.0.0"
}, },
"devDependencies": { "devDependencies": {
"@testing-library/dom": "^10.4.1",
"@testing-library/react": "^16.3.2",
"@types/node": "^22.10.0", "@types/node": "^22.10.0",
"@types/react": "19.0.0", "@types/react": "19.0.0",
"@types/react-dom": "19.0.0", "@types/react-dom": "19.0.0",
@@ -27,6 +29,7 @@
"autoprefixer": "^10.4.20", "autoprefixer": "^10.4.20",
"eslint": "^9.17.0", "eslint": "^9.17.0",
"eslint-config-next": "15.1.3", "eslint-config-next": "15.1.3",
"jsdom": "^29.1.1",
"openapi-typescript": "^7.5.0", "openapi-typescript": "^7.5.0",
"playwright": "^1.61.1", "playwright": "^1.61.1",
"postcss": "^8.4.49", "postcss": "^8.4.49",

431
apps/web/pnpm-lock.yaml generated
View File

@@ -24,6 +24,12 @@ importers:
specifier: 19.0.0 specifier: 19.0.0
version: 19.0.0(react@19.0.0) version: 19.0.0(react@19.0.0)
devDependencies: 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': '@types/node':
specifier: ^22.10.0 specifier: ^22.10.0
version: 22.19.21 version: 22.19.21
@@ -35,7 +41,7 @@ importers:
version: 19.0.0 version: 19.0.0
'@vitest/coverage-v8': '@vitest/coverage-v8':
specifier: ^2.1.9 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: autoprefixer:
specifier: ^10.4.20 specifier: ^10.4.20
version: 10.5.0(postcss@8.5.15) version: 10.5.0(postcss@8.5.15)
@@ -45,6 +51,9 @@ importers:
eslint-config-next: eslint-config-next:
specifier: 15.1.3 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) 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: openapi-typescript:
specifier: ^7.5.0 specifier: ^7.5.0
version: 7.13.0(typescript@5.9.3) version: 7.13.0(typescript@5.9.3)
@@ -62,7 +71,7 @@ importers:
version: 5.9.3 version: 5.9.3
vitest: vitest:
specifier: ^2.1.8 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: packages:
@@ -74,6 +83,21 @@ packages:
resolution: {integrity: sha512-30iZtAPgz+LTIYoeivqYo853f02jBYSd5uGnGpkFV0M3xOt9aN73erkgYAmZU43x4VfqcnLxW9Kpg3R5LC4YYw==} resolution: {integrity: sha512-30iZtAPgz+LTIYoeivqYo853f02jBYSd5uGnGpkFV0M3xOt9aN73erkgYAmZU43x4VfqcnLxW9Kpg3R5LC4YYw==}
engines: {node: '>=6.0.0'} 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': '@babel/code-frame@7.29.7':
resolution: {integrity: sha512-Aup7aUOfpbAUg2ROOJN6Iw5f9DMBlzu0mIkm/malLQFN/YQgO48wCj0Kxa3sEHJvPVFg7siR+qRInwXd2qhQKw==} resolution: {integrity: sha512-Aup7aUOfpbAUg2ROOJN6Iw5f9DMBlzu0mIkm/malLQFN/YQgO48wCj0Kxa3sEHJvPVFg7siR+qRInwXd2qhQKw==}
engines: {node: '>=6.9.0'} engines: {node: '>=6.9.0'}
@@ -91,6 +115,10 @@ packages:
engines: {node: '>=6.0.0'} engines: {node: '>=6.0.0'}
hasBin: true hasBin: true
'@babel/runtime@7.29.7':
resolution: {integrity: sha512-Nq8OhGWiZIZGV6hLHoyAKLLcJihP/xFeBMGJoUrxTX2psI8dCifzLhZISFb+VWS3wFMRDmCGw5R+dOySCqPLhw==}
engines: {node: '>=6.9.0'}
'@babel/types@7.29.7': '@babel/types@7.29.7':
resolution: {integrity: sha512-4zBIxpPzowiZpusoFkyGVwakdRJUyuH5PxQ/PrqghfdFWWasvnCdPfQXHrenDai+gyLARulZjZowCOj6fjT4pA==} resolution: {integrity: sha512-4zBIxpPzowiZpusoFkyGVwakdRJUyuH5PxQ/PrqghfdFWWasvnCdPfQXHrenDai+gyLARulZjZowCOj6fjT4pA==}
engines: {node: '>=6.9.0'} engines: {node: '>=6.9.0'}
@@ -98,6 +126,46 @@ packages:
'@bcoe/v8-coverage@0.2.3': '@bcoe/v8-coverage@0.2.3':
resolution: {integrity: sha512-0hYQ8SB4Db5zvZB4axdMHGwEaQjkZzFjQiN9LVYvIFB2nSUHW9tYpxWriPrWDASIxiaXax83REcLxuSdnGPZtw==} 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': '@emnapi/core@1.10.0':
resolution: {integrity: sha512-yq6OkJ4p82CAfPl0u9mQebQHKPJkY7WrIuk205cTYnYe+k2Z8YBh11FrbRG/H6ihirqcacOgl2BIO8oyMQLeXw==} resolution: {integrity: sha512-yq6OkJ4p82CAfPl0u9mQebQHKPJkY7WrIuk205cTYnYe+k2Z8YBh11FrbRG/H6ihirqcacOgl2BIO8oyMQLeXw==}
@@ -286,6 +354,15 @@ packages:
resolution: {integrity: sha512-43/qtrDUokr7LJqoF2c3+RInu/t4zfrpYdoSDfYyhg52rwLV6TnOvdG4fXm7IkSB3wErkcmJS9iEhjVtOSEjjA==} resolution: {integrity: sha512-43/qtrDUokr7LJqoF2c3+RInu/t4zfrpYdoSDfYyhg52rwLV6TnOvdG4fXm7IkSB3wErkcmJS9iEhjVtOSEjjA==}
engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} 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': '@humanfs/core@0.19.2':
resolution: {integrity: sha512-UhXNm+CFMWcbChXywFwkmhqjs3PRCmcSa/hfBgLIb7oQ5HNb1wS0icWsGtSAUNgefHeI+eBrA8I1fxmbHsGdvA==} resolution: {integrity: sha512-UhXNm+CFMWcbChXywFwkmhqjs3PRCmcSa/hfBgLIb7oQ5HNb1wS0icWsGtSAUNgefHeI+eBrA8I1fxmbHsGdvA==}
engines: {node: '>=18.18.0'} engines: {node: '>=18.18.0'}
@@ -688,9 +765,31 @@ packages:
'@swc/helpers@0.5.15': '@swc/helpers@0.5.15':
resolution: {integrity: sha512-JQ5TuMi45Owi4/BIMAJBoSQoOJu12oOk/gADqlcUL9JEdHB8vyjUSsxqeNXnmXHjYKMi2WcYtezGEEhqUI/E2g==} 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': '@tybys/wasm-util@0.10.2':
resolution: {integrity: sha512-RoBvJ2X0wuKlWFIjrwffGw1IqZHKQqzIchKaadZZfnNpsAYp2mM0h36JtPCjNDAHGgYez/15uMBpfGwchhiMgg==} resolution: {integrity: sha512-RoBvJ2X0wuKlWFIjrwffGw1IqZHKQqzIchKaadZZfnNpsAYp2mM0h36JtPCjNDAHGgYez/15uMBpfGwchhiMgg==}
'@types/aria-query@5.0.4':
resolution: {integrity: sha512-rfT93uj5s0PRL7EzccGMs3brplhcrghnDoV26NqKhCAS1hVo+WdNsPvE/yb6ilfr5hi2MEk6d5EWJTKdxg8jVw==}
'@types/estree@1.0.9': '@types/estree@1.0.9':
resolution: {integrity: sha512-GhdPgy1el4/ImP05X05Uw4cw2/M93BCUmnEvWZNStlCzEKME4Fkk+YpoA5OiHNQmoS7Cafb8Xa3Pya8m1Qrzeg==} resolution: {integrity: sha512-GhdPgy1el4/ImP05X05Uw4cw2/M93BCUmnEvWZNStlCzEKME4Fkk+YpoA5OiHNQmoS7Cafb8Xa3Pya8m1Qrzeg==}
@@ -959,6 +1058,10 @@ packages:
resolution: {integrity: sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==} resolution: {integrity: sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==}
engines: {node: '>=8'} engines: {node: '>=8'}
ansi-styles@5.2.0:
resolution: {integrity: sha512-Cxwpt2SfTzTtXcfOlzGEee8O+c+MmUgGrNiBcXnuWxuFJHe6a5Hz7qwhwe5OgaSYI0IJvkLqWX1ASG+cJOkEiA==}
engines: {node: '>=10'}
ansi-styles@6.2.3: ansi-styles@6.2.3:
resolution: {integrity: sha512-4Dj6M28JB+oAH8kFkTLUo+a2jwOFkuqb3yucU0CANcRRUbxS0cP0nZYCGjcc3BNXwRIsUVmDGgzawme7zvJHvg==} resolution: {integrity: sha512-4Dj6M28JB+oAH8kFkTLUo+a2jwOFkuqb3yucU0CANcRRUbxS0cP0nZYCGjcc3BNXwRIsUVmDGgzawme7zvJHvg==}
engines: {node: '>=12'} engines: {node: '>=12'}
@@ -976,6 +1079,9 @@ packages:
argparse@2.0.1: argparse@2.0.1:
resolution: {integrity: sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==} 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: aria-query@5.3.2:
resolution: {integrity: sha512-COROpnaoap1E2F000S62r6A60uHZnmlvomhfyT2DlTcrY1OrBKn2UhH7qn5wTC9zMvD0AY7csdPSNwKP+7WiQw==} resolution: {integrity: sha512-COROpnaoap1E2F000S62r6A60uHZnmlvomhfyT2DlTcrY1OrBKn2UhH7qn5wTC9zMvD0AY7csdPSNwKP+7WiQw==}
engines: {node: '>= 0.4'} engines: {node: '>= 0.4'}
@@ -1054,6 +1160,9 @@ packages:
engines: {node: '>=6.0.0'} engines: {node: '>=6.0.0'}
hasBin: true hasBin: true
bidi-js@1.0.3:
resolution: {integrity: sha512-RKshQI1R3YQ+n9YJz2QQ147P66ELpa1FQEg20Dk8oW9t2KgLbpDLLp9aGZ7y8WHSshDknG0bknqGw5/tyCs5tw==}
binary-extensions@2.3.0: binary-extensions@2.3.0:
resolution: {integrity: sha512-Ceh+7ox5qe7LJuLHoY0feh3pHuUDHAcRUeyL2VYghZwfpkNIy/+8Ocg0a3UuSoYzavmylwuLWQOf3hl0jjMMIw==} resolution: {integrity: sha512-Ceh+7ox5qe7LJuLHoY0feh3pHuUDHAcRUeyL2VYghZwfpkNIy/+8Ocg0a3UuSoYzavmylwuLWQOf3hl0jjMMIw==}
engines: {node: '>=8'} engines: {node: '>=8'}
@@ -1158,6 +1267,10 @@ packages:
resolution: {integrity: sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==} resolution: {integrity: sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==}
engines: {node: '>= 8'} 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: cssesc@3.0.0:
resolution: {integrity: sha512-/Tb/JcjK111nNScGob5MNtsntNM1aCNUDipB/TkwZFhyDrrE47SOx/18wF2bbjgc3ZzCSKW1T5nt5EbFoAz/Vg==} resolution: {integrity: sha512-/Tb/JcjK111nNScGob5MNtsntNM1aCNUDipB/TkwZFhyDrrE47SOx/18wF2bbjgc3ZzCSKW1T5nt5EbFoAz/Vg==}
engines: {node: '>=4'} engines: {node: '>=4'}
@@ -1169,6 +1282,10 @@ packages:
damerau-levenshtein@1.0.8: damerau-levenshtein@1.0.8:
resolution: {integrity: sha512-sdQSFB7+llfUcQHUQO3+B8ERRj0Oa4w9POWMI/puGtuf7gFywGmkaLCElnudfTiKZV+NvHqL0ifzdrI8Ro7ESA==} 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: data-view-buffer@1.0.2:
resolution: {integrity: sha512-EmKO5V3OLXh1rtK2wgXRansaK1/mtVdTUEiEI0W8RkvgT05kfxaH29PliLnpLP73yYO6142Q72QNa8Wx/A5CqQ==} resolution: {integrity: sha512-EmKO5V3OLXh1rtK2wgXRansaK1/mtVdTUEiEI0W8RkvgT05kfxaH29PliLnpLP73yYO6142Q72QNa8Wx/A5CqQ==}
engines: {node: '>= 0.4'} engines: {node: '>= 0.4'}
@@ -1198,6 +1315,9 @@ packages:
supports-color: supports-color:
optional: true optional: true
decimal.js@10.6.0:
resolution: {integrity: sha512-YpgQiITW3JXGntzdUmyUR1V812Hn8T1YVXhCu+wO3OpS4eU9l4YdD3qjyiKdV6mvV29zapkMeD390UVEf2lkUg==}
deep-eql@5.0.2: deep-eql@5.0.2:
resolution: {integrity: sha512-h5k/5U50IJJFpzfL6nO9jaaumfjO/f2NjK/oYB2Djzm4p9L+3T9qWpZqZ2hAbLPuuYq9wrU08WQyBTL5GbPk5Q==} resolution: {integrity: sha512-h5k/5U50IJJFpzfL6nO9jaaumfjO/f2NjK/oYB2Djzm4p9L+3T9qWpZqZ2hAbLPuuYq9wrU08WQyBTL5GbPk5Q==}
engines: {node: '>=6'} engines: {node: '>=6'}
@@ -1213,6 +1333,10 @@ packages:
resolution: {integrity: sha512-8QmQKqEASLd5nx0U1B1okLElbUuuttJ/AnYmRXbbbGDWh6uS208EjD4Xqq/I9wK7u0v6O08XhTWnt5XtEbR6Dg==} resolution: {integrity: sha512-8QmQKqEASLd5nx0U1B1okLElbUuuttJ/AnYmRXbbbGDWh6uS208EjD4Xqq/I9wK7u0v6O08XhTWnt5XtEbR6Dg==}
engines: {node: '>= 0.4'} engines: {node: '>= 0.4'}
dequal@2.0.3:
resolution: {integrity: sha512-0je+qPKHEMohvfRTCEo3CrPG6cAzAYgmzKyxRiYSSDkS6eGJdyVJm7WaYA5ECaAD9wLB2T4EEeymA5aFVcYXCA==}
engines: {node: '>=6'}
detect-libc@2.1.2: detect-libc@2.1.2:
resolution: {integrity: sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==} resolution: {integrity: sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==}
engines: {node: '>=8'} engines: {node: '>=8'}
@@ -1227,6 +1351,9 @@ packages:
resolution: {integrity: sha512-35mSku4ZXK0vfCuHEDAwt55dg2jNajHZ1odvF+8SSr82EsZY4QmXfuWso8oEd8zRhVObSN18aM0CjSdoBX7zIw==} resolution: {integrity: sha512-35mSku4ZXK0vfCuHEDAwt55dg2jNajHZ1odvF+8SSr82EsZY4QmXfuWso8oEd8zRhVObSN18aM0CjSdoBX7zIw==}
engines: {node: '>=0.10.0'} engines: {node: '>=0.10.0'}
dom-accessibility-api@0.5.16:
resolution: {integrity: sha512-X7BJ2yElsnOJ30pZF4uIIDfBEVgF4XEBxL9Bxhy6dnrm5hkzqmsWHGTiHqRiITNhMyFLyAiWndIJP7Z1NTteDg==}
dunder-proto@1.0.1: dunder-proto@1.0.1:
resolution: {integrity: sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==} resolution: {integrity: sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==}
engines: {node: '>= 0.4'} engines: {node: '>= 0.4'}
@@ -1243,6 +1370,10 @@ packages:
emoji-regex@9.2.2: emoji-regex@9.2.2:
resolution: {integrity: sha512-L18DaJsXSUk2+42pv8mLs5jJT2hqFkFE4j21wOmgbUqsZ2hL72NsUU785g9RXgo3s0ZNgVl42TiHp3ZtOv/Vyg==} 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: es-abstract-get@1.0.0:
resolution: {integrity: sha512-6PMWXpdhshVvFp+FoWYs1EvG1Nj0tvk0dZM+XcK0xMEM1czRVcP6ohqPWHy6qPagSpC8j4+p89WXlT+xXJs/fg==} resolution: {integrity: sha512-6PMWXpdhshVvFp+FoWYs1EvG1Nj0tvk0dZM+XcK0xMEM1czRVcP6ohqPWHy6qPagSpC8j4+p89WXlT+xXJs/fg==}
engines: {node: '>= 0.4'} engines: {node: '>= 0.4'}
@@ -1572,6 +1703,10 @@ packages:
resolution: {integrity: sha512-T2UbfbBEF32wiepXIsMlTW9+dDYC6wMh/t/vYA4tuOMKqWz/n3vr1NFSxQiyP+zk2mXsoMA/i/7qV6LKut1t1A==} resolution: {integrity: sha512-T2UbfbBEF32wiepXIsMlTW9+dDYC6wMh/t/vYA4tuOMKqWz/n3vr1NFSxQiyP+zk2mXsoMA/i/7qV6LKut1t1A==}
engines: {node: '>= 0.4'} 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: html-escaper@2.0.2:
resolution: {integrity: sha512-H2iMtd0I4Mt5eYiapRdIDjp+XzelXQ0tFE4JS7YFwFevXXMmOp9myNrUvCg0D6ws8iqkRPBfKHgbwig1SmlLfg==} resolution: {integrity: sha512-H2iMtd0I4Mt5eYiapRdIDjp+XzelXQ0tFE4JS7YFwFevXXMmOp9myNrUvCg0D6ws8iqkRPBfKHgbwig1SmlLfg==}
@@ -1685,6 +1820,9 @@ packages:
resolution: {integrity: sha512-41Cifkg6e8TylSpdtTpeLVMqvSBEVzTttHvERD741+pnZ8ANv0004MRL43QKPDlK9cGvNp6NZWZUBlbGXYxxng==} resolution: {integrity: sha512-41Cifkg6e8TylSpdtTpeLVMqvSBEVzTttHvERD741+pnZ8ANv0004MRL43QKPDlK9cGvNp6NZWZUBlbGXYxxng==}
engines: {node: '>=0.12.0'} engines: {node: '>=0.12.0'}
is-potential-custom-element-name@1.0.1:
resolution: {integrity: sha512-bCYeRA2rVibKZd+s2625gGnGF/t7DSqDs4dP7CrLA1m7jKWz6pps0LpYLJN8Q64HtmPKJ1hrN3nzPNKFEKOUiQ==}
is-regex@1.2.1: is-regex@1.2.1:
resolution: {integrity: sha512-MjYsKHO5O7mCsmRGxWcLWheFqN9DJ/2TmngvjKXihe6efViPqc274+Fx/4fYj/r03+ESvBdTXK0V6tA3rgez1g==} resolution: {integrity: sha512-MjYsKHO5O7mCsmRGxWcLWheFqN9DJ/2TmngvjKXihe6efViPqc274+Fx/4fYj/r03+ESvBdTXK0V6tA3rgez1g==}
engines: {node: '>= 0.4'} engines: {node: '>= 0.4'}
@@ -1769,6 +1907,15 @@ packages:
resolution: {integrity: sha512-ePWsvanv0DWuDRsW8dnt+R4jQ31SCRCQ7hhNcPXZPsoBZiemuZNYGf7adZdqX2D86j6rvKp3RpCxVTSb8WQlOw==} resolution: {integrity: sha512-ePWsvanv0DWuDRsW8dnt+R4jQ31SCRCQ7hhNcPXZPsoBZiemuZNYGf7adZdqX2D86j6rvKp3RpCxVTSb8WQlOw==}
hasBin: true 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: json-buffer@3.0.1:
resolution: {integrity: sha512-4bV5BfR2mqfQTJm+V5tPPdf+ZpuhiIvTuAB5g8kcrXOZpTT/QwwVRWBywX1ozr6lEuPdbHxwaJlm9G6mI2sfSQ==} resolution: {integrity: sha512-4bV5BfR2mqfQTJm+V5tPPdf+ZpuhiIvTuAB5g8kcrXOZpTT/QwwVRWBywX1ozr6lEuPdbHxwaJlm9G6mI2sfSQ==}
@@ -1827,11 +1974,19 @@ packages:
lru-cache@10.4.3: lru-cache@10.4.3:
resolution: {integrity: sha512-JNAzZcXrCt42VGLuYz0zfAzDfAvJWW6AfYlDBQyDV5DClI2m5sAmK+OIO7s59XfsRsWHp02jAJrRadPRGTt6SQ==} 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: lucide-react@1.21.0:
resolution: {integrity: sha512-reEZMXq8Qdd5jg5XYkQ5TR1fB/GiQ7ih4vcrthYDtgjSDwh0i6/YLiGjsWsIwgN49gpAnd4J2elSNzncMEEUUQ==} resolution: {integrity: sha512-reEZMXq8Qdd5jg5XYkQ5TR1fB/GiQ7ih4vcrthYDtgjSDwh0i6/YLiGjsWsIwgN49gpAnd4J2elSNzncMEEUUQ==}
peerDependencies: peerDependencies:
react: ^16.5.1 || ^17.0.0 || ^18.0.0 || ^19.0.0 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: magic-string@0.30.21:
resolution: {integrity: sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==} resolution: {integrity: sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==}
@@ -1846,6 +2001,9 @@ packages:
resolution: {integrity: sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==} resolution: {integrity: sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==}
engines: {node: '>= 0.4'} engines: {node: '>= 0.4'}
mdn-data@2.27.1:
resolution: {integrity: sha512-9Yubnt3e8A0OKwxYSXyhLymGW4sCufcLG6VdiDdUGVkPhpqLxlvP5vl1983gQjJl3tqbrM731mjaZaP68AgosQ==}
merge2@1.4.1: merge2@1.4.1:
resolution: {integrity: sha512-8q7VEgMJW4J8tcfVPy8g09NcQwZdbwFEqhe/WZkoIzjn/3TGDwtOCYtXGxA3O8tPzpczCCDgv+P2P5y00ZJOOg==} resolution: {integrity: sha512-8q7VEgMJW4J8tcfVPy8g09NcQwZdbwFEqhe/WZkoIzjn/3TGDwtOCYtXGxA3O8tPzpczCCDgv+P2P5y00ZJOOg==}
engines: {node: '>= 8'} engines: {node: '>= 8'}
@@ -2004,6 +2162,9 @@ packages:
resolution: {integrity: sha512-ybiGyvspI+fAoRQbIPRddCcSTV9/LsJbf0e/S85VLowVGzRmokfneg2kwVW/KU5rOXrPSbF1qAKPMgNTqqROQQ==} resolution: {integrity: sha512-ybiGyvspI+fAoRQbIPRddCcSTV9/LsJbf0e/S85VLowVGzRmokfneg2kwVW/KU5rOXrPSbF1qAKPMgNTqqROQQ==}
engines: {node: '>=18'} engines: {node: '>=18'}
parse5@8.0.1:
resolution: {integrity: sha512-z1e/HMG90obSGeidlli3hj7cbocou0/wa5HacvI3ASx34PecNjNQeaHNo5WIZpWofN9kgkqV1q5YvXe3F0FoPw==}
path-exists@4.0.0: path-exists@4.0.0:
resolution: {integrity: sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w==} resolution: {integrity: sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w==}
engines: {node: '>=8'} engines: {node: '>=8'}
@@ -2118,6 +2279,10 @@ packages:
resolution: {integrity: sha512-vkcDPrRZo1QZLbn5RLGPpg/WmIQ65qoWWhcGKf/b5eplkkarX0m9z8ppCat4mlOqUsWpyNuYgO3VRyrYHSzX5g==} resolution: {integrity: sha512-vkcDPrRZo1QZLbn5RLGPpg/WmIQ65qoWWhcGKf/b5eplkkarX0m9z8ppCat4mlOqUsWpyNuYgO3VRyrYHSzX5g==}
engines: {node: '>= 0.8.0'} 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: prop-types@15.8.1:
resolution: {integrity: sha512-oj87CgZICdulUohogVAR7AjlC0327U4el4L6eAvOqCeudMDVU0NThNaV+b9Df4dXgSP1gXMTnPdhfe/2qDH5cg==} resolution: {integrity: sha512-oj87CgZICdulUohogVAR7AjlC0327U4el4L6eAvOqCeudMDVU0NThNaV+b9Df4dXgSP1gXMTnPdhfe/2qDH5cg==}
@@ -2136,6 +2301,9 @@ packages:
react-is@16.13.1: react-is@16.13.1:
resolution: {integrity: sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ==} resolution: {integrity: sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ==}
react-is@17.0.2:
resolution: {integrity: sha512-w2GsyukL62IJnlaff/nRegPQR94C/XXamvMWmSHRJ4y7Ts/4ocGRmTHvOs8PSE6pB3dWOrD/nueuU5sduBsQ4w==}
react@19.0.0: react@19.0.0:
resolution: {integrity: sha512-V8AVnmPIICiWpGfm6GLzCR/W5FXLchHop40W4nXBmdlEceh16rCN8O8LNWm5bh5XUX91fh7KpA+W0TgMKmgTpQ==} resolution: {integrity: sha512-V8AVnmPIICiWpGfm6GLzCR/W5FXLchHop40W4nXBmdlEceh16rCN8O8LNWm5bh5XUX91fh7KpA+W0TgMKmgTpQ==}
engines: {node: '>=0.10.0'} engines: {node: '>=0.10.0'}
@@ -2200,6 +2368,10 @@ packages:
resolution: {integrity: sha512-x/+Cz4YrimQxQccJf5mKEbIa1NzeCRNI5Ecl/ekmlYaampdNLPalVyIcCZNNH3MvmqBugV5TMYZXv0ljslUlaw==} resolution: {integrity: sha512-x/+Cz4YrimQxQccJf5mKEbIa1NzeCRNI5Ecl/ekmlYaampdNLPalVyIcCZNNH3MvmqBugV5TMYZXv0ljslUlaw==}
engines: {node: '>= 0.4'} engines: {node: '>= 0.4'}
saxes@6.0.0:
resolution: {integrity: sha512-xAg7SOnEhrm5zI3puOOKyy1OMcMlIJZYNJY7xLBwSze0UjhPLnWfj2GF2EpT0jmzaJKIWKHLsaSSajf35bcYnA==}
engines: {node: '>=v12.22.7'}
scheduler@0.25.0: scheduler@0.25.0:
resolution: {integrity: sha512-xFVuu11jh+xcO7JOAGJNOXld8/TcEHK/4CituBUeUb5hqxJLj9YuemAEuvm9gQ/+pgXYfbQuqAkiYu+u7YEsNA==} resolution: {integrity: sha512-xFVuu11jh+xcO7JOAGJNOXld8/TcEHK/4CituBUeUb5hqxJLj9YuemAEuvm9gQ/+pgXYfbQuqAkiYu+u7YEsNA==}
@@ -2360,6 +2532,9 @@ packages:
resolution: {integrity: sha512-ot0WnXS9fgdkgIcePe6RHNk1WA8+muPa6cSjeR3V8K27q9BB1rTE3R1p7Hv0z1ZyAc8s6Vvv8DIyWf681MAt0w==} resolution: {integrity: sha512-ot0WnXS9fgdkgIcePe6RHNk1WA8+muPa6cSjeR3V8K27q9BB1rTE3R1p7Hv0z1ZyAc8s6Vvv8DIyWf681MAt0w==}
engines: {node: '>= 0.4'} engines: {node: '>= 0.4'}
symbol-tree@3.2.4:
resolution: {integrity: sha512-9QNk5KwDF+Bvz+PyObkmSYjI5ksVUYtjW7AU22r2NKcfLJcXp96hkDWU3+XndOsUb+AQ9QhfzfCT2O+CNWT5Tw==}
tailwindcss@3.4.19: tailwindcss@3.4.19:
resolution: {integrity: sha512-3ofp+LL8E+pK/JuPLPggVAIaEuhvIz4qNcf3nA1Xn2o/7fb7s/TYpHhwGDv1ZU3PkBluUVaF8PyCHcm48cKLWQ==} resolution: {integrity: sha512-3ofp+LL8E+pK/JuPLPggVAIaEuhvIz4qNcf3nA1Xn2o/7fb7s/TYpHhwGDv1ZU3PkBluUVaF8PyCHcm48cKLWQ==}
engines: {node: '>=14.0.0'} engines: {node: '>=14.0.0'}
@@ -2398,10 +2573,25 @@ packages:
resolution: {integrity: sha512-n1cw8k1k0x4pgA2+9XrOkFydTerNcJ1zWCO5Nn9scWHTD+5tp8dghT2x1uduQePZTZgd3Tupf+x9BxJjeJi77Q==} resolution: {integrity: sha512-n1cw8k1k0x4pgA2+9XrOkFydTerNcJ1zWCO5Nn9scWHTD+5tp8dghT2x1uduQePZTZgd3Tupf+x9BxJjeJi77Q==}
engines: {node: '>=14.0.0'} 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: to-regex-range@5.0.1:
resolution: {integrity: sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ==} resolution: {integrity: sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ==}
engines: {node: '>=8.0'} 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: ts-api-utils@2.5.0:
resolution: {integrity: sha512-OJ/ibxhPlqrMM0UiNHJ/0CKQkoKF243/AEmplt3qpRgkW8VG7IfOS41h7V8TjITqdByHzrjcS/2si+y4lIh8NA==} resolution: {integrity: sha512-OJ/ibxhPlqrMM0UiNHJ/0CKQkoKF243/AEmplt3qpRgkW8VG7IfOS41h7V8TjITqdByHzrjcS/2si+y4lIh8NA==}
engines: {node: '>=18.12'} engines: {node: '>=18.12'}
@@ -2453,6 +2643,10 @@ packages:
undici-types@6.21.0: undici-types@6.21.0:
resolution: {integrity: sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==} resolution: {integrity: sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==}
undici@7.28.0:
resolution: {integrity: sha512-cRZYrTDwWznlnRiPjggAGxZXanty6M8RV1ff8Wm4LWXBp7/IG8v5DnOm74DtUBp9OONpK75YlPnIjQqX0dBDtA==}
engines: {node: '>=20.18.1'}
unrs-resolver@1.12.2: unrs-resolver@1.12.2:
resolution: {integrity: sha512-dmlRxBJJayXjqTwC+JtF1HhJmgf3ftQ3YejFcZrf4+KKtJv0qDsK1pjqaaVjG7wJ5NJ6UVP1OqRMQ71Z4C3rxQ==} resolution: {integrity: sha512-dmlRxBJJayXjqTwC+JtF1HhJmgf3ftQ3YejFcZrf4+KKtJv0qDsK1pjqaaVjG7wJ5NJ6UVP1OqRMQ71Z4C3rxQ==}
@@ -2532,6 +2726,22 @@ packages:
jsdom: jsdom:
optional: true 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: which-boxed-primitive@1.1.1:
resolution: {integrity: sha512-TbX3mj8n0odCBFVlY8AxkqcHASw3L60jIuF8jFP78az3C2YhmGvqbHBpAjTRH2/xqYunrJ9g1jSyjCjpoWzIAA==} resolution: {integrity: sha512-TbX3mj8n0odCBFVlY8AxkqcHASw3L60jIuF8jFP78az3C2YhmGvqbHBpAjTRH2/xqYunrJ9g1jSyjCjpoWzIAA==}
engines: {node: '>= 0.4'} engines: {node: '>= 0.4'}
@@ -2570,6 +2780,13 @@ packages:
resolution: {integrity: sha512-si7QWI6zUMq56bESFvagtmzMdGOtoxfR+Sez11Mobfc7tm+VkUckk9bW2UeffTGVUbOksxmSw0AA2gs8g71NCQ==} resolution: {integrity: sha512-si7QWI6zUMq56bESFvagtmzMdGOtoxfR+Sez11Mobfc7tm+VkUckk9bW2UeffTGVUbOksxmSw0AA2gs8g71NCQ==}
engines: {node: '>=12'} 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: yaml-ast-parser@0.0.43:
resolution: {integrity: sha512-2PTINUwsRqSd+s8XxKaJWQlUuEMHJQyEuh2edBbW8KNJz0SJPwUSD2zRWqezFEdN7IzAgeuYHFUCF7o8zRdZ0A==} resolution: {integrity: sha512-2PTINUwsRqSd+s8XxKaJWQlUuEMHJQyEuh2edBbW8KNJz0SJPwUSD2zRWqezFEdN7IzAgeuYHFUCF7o8zRdZ0A==}
@@ -2590,6 +2807,26 @@ snapshots:
'@jridgewell/gen-mapping': 0.3.13 '@jridgewell/gen-mapping': 0.3.13
'@jridgewell/trace-mapping': 0.3.31 '@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': '@babel/code-frame@7.29.7':
dependencies: dependencies:
'@babel/helper-validator-identifier': 7.29.7 '@babel/helper-validator-identifier': 7.29.7
@@ -2604,6 +2841,8 @@ snapshots:
dependencies: dependencies:
'@babel/types': 7.29.7 '@babel/types': 7.29.7
'@babel/runtime@7.29.7': {}
'@babel/types@7.29.7': '@babel/types@7.29.7':
dependencies: dependencies:
'@babel/helper-string-parser': 7.29.7 '@babel/helper-string-parser': 7.29.7
@@ -2611,6 +2850,34 @@ snapshots:
'@bcoe/v8-coverage@0.2.3': {} '@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': '@emnapi/core@1.10.0':
dependencies: dependencies:
'@emnapi/wasi-threads': 1.2.1 '@emnapi/wasi-threads': 1.2.1
@@ -2747,6 +3014,8 @@ snapshots:
'@eslint/core': 0.17.0 '@eslint/core': 0.17.0
levn: 0.4.1 levn: 0.4.1
'@exodus/bytes@1.15.1': {}
'@humanfs/core@0.19.2': '@humanfs/core@0.19.2':
dependencies: dependencies:
'@humanfs/types': 0.15.0 '@humanfs/types': 0.15.0
@@ -3025,11 +3294,34 @@ snapshots:
dependencies: dependencies:
tslib: 2.8.1 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': '@tybys/wasm-util@0.10.2':
dependencies: dependencies:
tslib: 2.8.1 tslib: 2.8.1
optional: true optional: true
'@types/aria-query@5.0.4': {}
'@types/estree@1.0.9': {} '@types/estree@1.0.9': {}
'@types/json-schema@7.0.15': {} '@types/json-schema@7.0.15': {}
@@ -3209,21 +3501,21 @@ snapshots:
'@unrs/resolver-binding-win32-x64-msvc@1.12.2': '@unrs/resolver-binding-win32-x64-msvc@1.12.2':
optional: true 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: dependencies:
'@ampproject/remapping': 2.3.0 '@ampproject/remapping': 2.3.0
'@bcoe/v8-coverage': 0.2.3 '@bcoe/v8-coverage': 0.2.3
debug: 4.4.3(supports-color@10.2.2) debug: 4.4.3(supports-color@10.2.2)
istanbul-lib-coverage: 3.2.2 istanbul-lib-coverage: 3.2.2
istanbul-lib-report: 3.0.1 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 istanbul-reports: 3.2.0
magic-string: 0.30.21 magic-string: 0.30.21
magicast: 0.3.5 magicast: 0.3.5
std-env: 3.10.0 std-env: 3.10.0
test-exclude: 7.0.2 test-exclude: 7.0.2
tinyrainbow: 1.2.0 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: transitivePeerDependencies:
- supports-color - supports-color
@@ -3292,6 +3584,8 @@ snapshots:
dependencies: dependencies:
color-convert: 2.0.1 color-convert: 2.0.1
ansi-styles@5.2.0: {}
ansi-styles@6.2.3: {} ansi-styles@6.2.3: {}
any-promise@1.3.0: {} any-promise@1.3.0: {}
@@ -3305,6 +3599,10 @@ snapshots:
argparse@2.0.1: {} argparse@2.0.1: {}
aria-query@5.3.0:
dependencies:
dequal: 2.0.3
aria-query@5.3.2: {} aria-query@5.3.2: {}
array-buffer-byte-length@1.0.2: array-buffer-byte-length@1.0.2:
@@ -3403,6 +3701,10 @@ snapshots:
baseline-browser-mapping@2.10.37: {} baseline-browser-mapping@2.10.37: {}
bidi-js@1.0.3:
dependencies:
require-from-string: 2.0.2
binary-extensions@2.3.0: {} binary-extensions@2.3.0: {}
brace-expansion@1.1.15: brace-expansion@1.1.15:
@@ -3520,12 +3822,24 @@ snapshots:
shebang-command: 2.0.0 shebang-command: 2.0.0
which: 2.0.2 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: {} cssesc@3.0.0: {}
csstype@3.2.3: {} csstype@3.2.3: {}
damerau-levenshtein@1.0.8: {} 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: data-view-buffer@1.0.2:
dependencies: dependencies:
call-bound: 1.0.4 call-bound: 1.0.4
@@ -3554,6 +3868,8 @@ snapshots:
optionalDependencies: optionalDependencies:
supports-color: 10.2.2 supports-color: 10.2.2
decimal.js@10.6.0: {}
deep-eql@5.0.2: {} deep-eql@5.0.2: {}
deep-is@0.1.4: {} deep-is@0.1.4: {}
@@ -3570,6 +3886,8 @@ snapshots:
has-property-descriptors: 1.0.2 has-property-descriptors: 1.0.2
object-keys: 1.1.1 object-keys: 1.1.1
dequal@2.0.3: {}
detect-libc@2.1.2: detect-libc@2.1.2:
optional: true optional: true
@@ -3581,6 +3899,8 @@ snapshots:
dependencies: dependencies:
esutils: 2.0.3 esutils: 2.0.3
dom-accessibility-api@0.5.16: {}
dunder-proto@1.0.1: dunder-proto@1.0.1:
dependencies: dependencies:
call-bind-apply-helpers: 1.0.2 call-bind-apply-helpers: 1.0.2
@@ -3595,6 +3915,8 @@ snapshots:
emoji-regex@9.2.2: {} emoji-regex@9.2.2: {}
entities@8.0.0: {}
es-abstract-get@1.0.0: es-abstract-get@1.0.0:
dependencies: dependencies:
es-errors: 1.3.0 es-errors: 1.3.0
@@ -4102,6 +4424,12 @@ snapshots:
dependencies: dependencies:
function-bind: 1.1.2 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: {} html-escaper@2.0.2: {}
https-proxy-agent@7.0.6(supports-color@10.2.2): https-proxy-agent@7.0.6(supports-color@10.2.2):
@@ -4216,6 +4544,8 @@ snapshots:
is-number@7.0.0: {} is-number@7.0.0: {}
is-potential-custom-element-name@1.0.1: {}
is-regex@1.2.1: is-regex@1.2.1:
dependencies: dependencies:
call-bound: 1.0.4 call-bound: 1.0.4
@@ -4267,7 +4597,7 @@ snapshots:
make-dir: 4.0.0 make-dir: 4.0.0
supports-color: 7.2.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: dependencies:
'@jridgewell/trace-mapping': 0.3.31 '@jridgewell/trace-mapping': 0.3.31
debug: 4.4.3(supports-color@10.2.2) debug: 4.4.3(supports-color@10.2.2)
@@ -4309,6 +4639,32 @@ snapshots:
dependencies: dependencies:
argparse: 2.0.1 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-buffer@3.0.1: {}
json-schema-traverse@0.4.1: {} json-schema-traverse@0.4.1: {}
@@ -4361,10 +4717,14 @@ snapshots:
lru-cache@10.4.3: {} lru-cache@10.4.3: {}
lru-cache@11.5.1: {}
lucide-react@1.21.0(react@19.0.0): lucide-react@1.21.0(react@19.0.0):
dependencies: dependencies:
react: 19.0.0 react: 19.0.0
lz-string@1.5.0: {}
magic-string@0.30.21: magic-string@0.30.21:
dependencies: dependencies:
'@jridgewell/sourcemap-codec': 1.5.5 '@jridgewell/sourcemap-codec': 1.5.5
@@ -4381,6 +4741,8 @@ snapshots:
math-intrinsics@1.1.0: {} math-intrinsics@1.1.0: {}
mdn-data@2.27.1: {}
merge2@1.4.1: {} merge2@1.4.1: {}
micromatch@4.0.8: micromatch@4.0.8:
@@ -4553,6 +4915,10 @@ snapshots:
index-to-position: 1.2.0 index-to-position: 1.2.0
type-fest: 4.41.0 type-fest: 4.41.0
parse5@8.0.1:
dependencies:
entities: 8.0.0
path-exists@4.0.0: {} path-exists@4.0.0: {}
path-key@3.1.1: {} path-key@3.1.1: {}
@@ -4635,6 +5001,12 @@ snapshots:
prelude-ls@1.2.1: {} 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: prop-types@15.8.1:
dependencies: dependencies:
loose-envify: 1.4.0 loose-envify: 1.4.0
@@ -4652,6 +5024,8 @@ snapshots:
react-is@16.13.1: {} react-is@16.13.1: {}
react-is@17.0.2: {}
react@19.0.0: {} react@19.0.0: {}
read-cache@1.0.0: read-cache@1.0.0:
@@ -4760,6 +5134,10 @@ snapshots:
es-errors: 1.3.0 es-errors: 1.3.0
is-regex: 1.2.1 is-regex: 1.2.1
saxes@6.0.0:
dependencies:
xmlchars: 2.2.0
scheduler@0.25.0: {} scheduler@0.25.0: {}
semver@6.3.1: {} semver@6.3.1: {}
@@ -4971,6 +5349,8 @@ snapshots:
supports-preserve-symlinks-flag@1.0.0: {} supports-preserve-symlinks-flag@1.0.0: {}
symbol-tree@3.2.4: {}
tailwindcss@3.4.19: tailwindcss@3.4.19:
dependencies: dependencies:
'@alloc/quick-lru': 5.2.0 '@alloc/quick-lru': 5.2.0
@@ -5028,10 +5408,24 @@ snapshots:
tinyspy@3.0.2: {} 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: to-regex-range@5.0.1:
dependencies: dependencies:
is-number: 7.0.0 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): ts-api-utils@2.5.0(typescript@5.9.3):
dependencies: dependencies:
typescript: 5.9.3 typescript: 5.9.3
@@ -5097,6 +5491,8 @@ snapshots:
undici-types@6.21.0: {} undici-types@6.21.0: {}
undici@7.28.0: {}
unrs-resolver@1.12.2: unrs-resolver@1.12.2:
dependencies: dependencies:
napi-postinstall: 0.3.4 napi-postinstall: 0.3.4
@@ -5165,7 +5561,7 @@ snapshots:
'@types/node': 22.19.21 '@types/node': 22.19.21
fsevents: 2.3.3 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: dependencies:
'@vitest/expect': 2.1.9 '@vitest/expect': 2.1.9
'@vitest/mocker': 2.1.9(vite@5.4.21(@types/node@22.19.21)) '@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 why-is-node-running: 2.3.0
optionalDependencies: optionalDependencies:
'@types/node': 22.19.21 '@types/node': 22.19.21
jsdom: 29.1.1
transitivePeerDependencies: transitivePeerDependencies:
- less - less
- lightningcss - lightningcss
@@ -5200,6 +5597,22 @@ snapshots:
- supports-color - supports-color
- terser - 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: which-boxed-primitive@1.1.1:
dependencies: dependencies:
is-bigint: 1.1.0 is-bigint: 1.1.0
@@ -5264,6 +5677,10 @@ snapshots:
string-width: 5.1.2 string-width: 5.1.2
strip-ansi: 7.2.0 strip-ansi: 7.2.0
xml-name-validator@5.0.0: {}
xmlchars@2.2.0: {}
yaml-ast-parser@0.0.43: {} yaml-ast-parser@0.0.43: {}
yargs-parser@21.1.1: {} yargs-parser@21.1.1: {}

View File

@@ -12,15 +12,12 @@ export default defineConfig({
include: ["lib/**/*.test.ts"], include: ["lib/**/*.test.ts"],
coverage: { coverage: {
provider: "v8", provider: "v8",
// 覆盖范围 = 可单测的纯逻辑层lib/**node 环境即可跑 // 覆盖范围 = lib/** 全部逻辑层(纯逻辑 + React hooks
// 排除项: // hooks(use*.ts) 用 jsdom + @testing-library/react 的 renderHook 单测(见各 *.test.ts 的
// - 组件(.tsx)由 E2E/人工验收覆盖,不在 vitest 范围。 // `// @vitest-environment jsdom` 头)。组件(.tsx)由 E2E/人工验收覆盖,不在 vitest 范围。
// - React hooks(use*.ts):需 jsdom + @testing-library/react 才能单测, // 排除项测试文件本身、OpenAPI codegen 生成物。
// 当前 node-only 测试栈无法覆盖。待办:接入 jsdom 测试栈后移除此排除,
// 把 hooks 纳入 80% 门禁。
// - lib/api/schema.d.tsOpenAPI codegen 生成物。
include: ["lib/**/*.ts"], 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"], reporter: ["text", "text-summary"],
thresholds: { thresholds: {
lines: 80, lines: 80,