补充核心回归测试并完成补测清单 T1-T4

This commit is contained in:
2026-03-03 13:33:42 +08:00
parent 3cd4c26d3c
commit d91ea8c1e8
5 changed files with 612 additions and 9 deletions
+16 -9
View File
@@ -7,7 +7,7 @@
## 执行清单
### T1 `requestJson` 统一请求层单测(`src/lib/fetcher.ts`)【完成】
### T1 `requestJson` 统一请求层单测(`src/lib/fetcher.ts`)【完成】
- 新增测试文件:`src/lib/fetcher.test.ts`
- 用例清单:
1. 200 JSON 响应:返回解析后的对象。
@@ -20,8 +20,10 @@
- 通过标准:
1. `npx vitest run src/lib/fetcher.test.ts` 通过。
2. `npx tsc --noEmit` 通过。
- 完成记录:
1. 已新增 `src/lib/fetcher.test.ts`,覆盖 7 个用例,执行通过(2026-03-03)。
### T2 `useBlindboxDraw` 状态机补测(`src/hooks/useBlindboxDraw.ts`)【完成】
### T2 `useBlindboxDraw` 状态机补测(`src/hooks/useBlindboxDraw.ts`)【完成】
- 新增测试文件:`src/hooks/useBlindboxDraw.test.ts`
- 用例清单:
1. `poolCount=0`:直接报错,不触发请求。
@@ -30,8 +32,10 @@
- 通过标准:
1. `npx vitest run src/hooks/useBlindboxDraw.test.ts` 通过。
2. `npx tsc --noEmit` 通过。
- 完成记录:
1. 已新增 `src/hooks/useBlindboxDraw.test.ts`,覆盖空池/成功/失败 3 条关键分支(2026-03-03)。
### T3 `useBlindboxRoom` 关键交互补测(`src/hooks/useBlindboxRoom.ts`)【完成】
### T3 `useBlindboxRoom` 关键交互补测(`src/hooks/useBlindboxRoom.ts`)【完成】
- 新增测试文件:`src/hooks/useBlindboxRoom.test.ts`
- 用例清单:
1. 初始化拉取房间成功后,成员判断正确(`isMember=true`)。
@@ -40,8 +44,10 @@
- 通过标准:
1. `npx vitest run src/hooks/useBlindboxRoom.test.ts` 通过。
2. `npx tsc --noEmit` 通过。
- 完成记录:
1. 已新增 `src/hooks/useBlindboxRoom.test.ts`,覆盖初始化成员识别、仅 code 入参加入、二次确认删除(2026-03-03)。
### T4 `useBlindboxPlan` 核心分支补测(`src/hooks/useBlindboxPlan.ts`)【完成】
### T4 `useBlindboxPlan` 核心分支补测(`src/hooks/useBlindboxPlan.ts`)【完成】
- 新增测试文件:`src/hooks/useBlindboxPlan.test.ts`
- 用例清单:
1. `fetchAcceptedPlan` 正常读取已接受计划并写入 `activeContract`
@@ -51,10 +57,11 @@
- 通过标准:
1. `npx vitest run src/hooks/useBlindboxPlan.test.ts` 通过。
2. `npx tsc --noEmit` 通过。
- 完成记录:
1. 已新增 `src/hooks/useBlindboxPlan.test.ts`,覆盖 latest、流式失败 fallback、accept 成功/失败四个关键分支(2026-03-03)。
## 状态追踪
- T1未开始
- T2未开始
- T3未开始
- T4未开始
- T1已完成(2026-03-03
- T2已完成(2026-03-03
- T3已完成(2026-03-03
- T4已完成(2026-03-03
+135
View File
@@ -0,0 +1,135 @@
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
import { renderHook, act } from "@testing-library/react";
import type { DrawnIdea } from "@/components/BlindboxDrawnHistory";
const {
mockRequestJson,
mockConfetti,
mockConfettiReset,
mockBoxStart,
} = vi.hoisted(() => ({
mockRequestJson: vi.fn(),
mockConfetti: vi.fn(),
mockConfettiReset: vi.fn(),
mockBoxStart: vi.fn().mockResolvedValue(undefined),
}));
vi.mock("@/lib/fetcher", () => ({
requestJson: mockRequestJson,
}));
vi.mock("canvas-confetti", () => {
Object.assign(mockConfetti, { reset: mockConfettiReset });
return { default: mockConfetti };
});
vi.mock("framer-motion", () => ({
useAnimation: () => ({ start: mockBoxStart }),
}));
import { useBlindboxDraw } from "./useBlindboxDraw";
const room = {
id: "room-1",
code: "ABC123",
name: "周末盲盒",
creatorId: "user-1",
city: "上海",
address: "人民广场",
lat: 31.23,
lng: 121.47,
poolCount: 2,
members: [{ id: "user-1", username: "alice", avatar: "🐱" }],
};
const profile = { id: "user-1", username: "alice", avatar: "🐱" };
describe("useBlindboxDraw", () => {
beforeEach(() => {
vi.clearAllMocks();
vi.useFakeTimers();
});
afterEach(() => {
vi.runOnlyPendingTimers();
vi.useRealTimers();
});
it("returns error immediately when pool is empty", async () => {
const setPoolCount = vi.fn();
const setDrawnHistory = vi.fn();
const setError = vi.fn();
const setPhase = vi.fn();
const { result } = renderHook(() =>
useBlindboxDraw(null, profile, 0, setPoolCount, setDrawnHistory, setError, setPhase),
);
await act(async () => {
await result.current.handleDraw();
});
expect(setError).toHaveBeenCalledWith("盒子是空的,先往里面塞点想法吧!");
expect(setPhase).not.toHaveBeenCalled();
expect(mockRequestJson).not.toHaveBeenCalled();
});
it("updates phase and state when draw succeeds", async () => {
const setPoolCount = vi.fn();
const setDrawnHistory = vi.fn();
const setError = vi.fn();
const setPhase = vi.fn();
const idea: DrawnIdea = {
id: "idea-1",
content: "去看展",
createdAt: new Date().toISOString(),
user: { id: "u2", username: "bob", avatar: "🐶" },
drawnBy: { id: "user-1", username: "alice", avatar: "🐱" },
};
mockRequestJson.mockResolvedValueOnce(idea);
const { result } = renderHook(() =>
useBlindboxDraw(room, profile, 2, setPoolCount, setDrawnHistory, setError, setPhase),
);
await act(async () => {
await result.current.handleDraw();
});
expect(mockRequestJson).toHaveBeenCalledWith("/api/blindbox/draw", {
method: "POST",
body: { roomId: "room-1" },
});
expect(setPhase).toHaveBeenNthCalledWith(1, "shaking");
expect(setPhase).toHaveBeenNthCalledWith(2, "reveal");
expect(result.current.revealedIdea).toEqual(idea);
expect(mockConfetti).toHaveBeenCalled();
const poolUpdater = setPoolCount.mock.calls[0][0] as (value: number) => number;
expect(poolUpdater(2)).toBe(1);
const historyUpdater = setDrawnHistory.mock.calls[0][0] as (value: DrawnIdea[]) => DrawnIdea[];
expect(historyUpdater([])).toEqual([idea]);
});
it("falls back to pool phase and sets error when draw fails", async () => {
const setPoolCount = vi.fn();
const setDrawnHistory = vi.fn();
const setError = vi.fn();
const setPhase = vi.fn();
mockRequestJson.mockRejectedValueOnce(new Error("网络异常"));
const { result } = renderHook(() =>
useBlindboxDraw(room, profile, 2, setPoolCount, setDrawnHistory, setError, setPhase),
);
await act(async () => {
await result.current.handleDraw();
});
expect(setPhase).toHaveBeenNthCalledWith(1, "shaking");
expect(setPhase).toHaveBeenNthCalledWith(2, "pool");
expect(setError).toHaveBeenCalledWith("网络异常");
expect(result.current.revealedIdea).toBeNull();
});
});
+204
View File
@@ -0,0 +1,204 @@
import { describe, it, expect, vi, beforeEach } from "vitest";
import { renderHook, act, waitFor } from "@testing-library/react";
import type { WeekendPlanData } from "@/types";
const {
mockRequestJson,
mockToastShow,
mockFetch,
} = vi.hoisted(() => ({
mockRequestJson: vi.fn(),
mockToastShow: vi.fn(),
mockFetch: vi.fn(),
}));
vi.mock("@/lib/fetcher", () => ({
requestJson: mockRequestJson,
}));
vi.mock("@/hooks/useToast", () => ({
useToast: () => ({ show: mockToastShow }),
}));
vi.stubGlobal("fetch", mockFetch);
import { useBlindboxPlan } from "./useBlindboxPlan";
const room = {
id: "room-1",
code: "ABC123",
name: "周末契约",
creatorId: "user-1",
city: "上海",
address: "黄浦区",
lat: 31.23,
lng: 121.47,
poolCount: 2,
members: [{ id: "user-1", username: "alice", avatar: "🐱" }],
};
const profile = { id: "user-1", username: "alice", avatar: "🐱" };
const generatedDays: WeekendPlanData[] = [
{
date: "周六",
summary: "轻松逛吃",
items: [
{
time: "10:00",
activity: "看展",
poi: "上海博物馆",
address: "黄浦区人民大道201号",
lat: 31.23,
lng: 121.47,
duration: 120,
reason: "交通方便",
},
],
},
];
function createRequestJsonMock(opts: { acceptShouldFail?: boolean; latestPlan?: boolean } = {}) {
const { acceptShouldFail = false, latestPlan = false } = opts;
return async (url: string, init?: { method?: string; body?: unknown }) => {
if (url === "/api/blindbox/plan?mode=pending") {
return { pending: [] };
}
if (url === "/api/blindbox/plan?mode=latest&roomId=room-1") {
return latestPlan
? { plan: { id: "plan-latest", days: generatedDays, endTime: null } }
: { plan: null };
}
if (url === "/api/blindbox/plan" && init?.method === "POST") {
return {
id: "plan-1",
days: generatedDays,
createdAt: "2026-03-03T00:00:00.000Z",
};
}
if (url === "/api/blindbox/plan" && init?.method === "PATCH") {
const body = init.body as { action?: string };
if (body?.action === "accept") {
if (acceptShouldFail) throw new Error("接受契约失败");
return { endTime: null };
}
return { ok: true };
}
throw new Error(`unexpected request: ${url}`);
};
}
describe("useBlindboxPlan", () => {
beforeEach(() => {
vi.clearAllMocks();
mockFetch.mockRejectedValue(new Error("stream unavailable"));
mockRequestJson.mockImplementation(createRequestJsonMock());
});
it("loads latest accepted plan into activeContract", async () => {
mockRequestJson.mockImplementation(createRequestJsonMock({ latestPlan: true }));
const setPhase = vi.fn();
const fireConfetti = vi.fn();
const { result } = renderHook(() =>
useBlindboxPlan(room, profile, "pool", setPhase, fireConfetti),
);
await act(async () => {
await result.current.fetchAcceptedPlan();
});
expect(mockRequestJson).toHaveBeenCalledWith("/api/blindbox/plan?mode=latest&roomId=room-1");
expect(result.current.activeContract?.id).toBe("plan-latest");
expect(result.current.activeContract?.days).toEqual(generatedDays);
});
it("falls back to normal plan API when stream generation fails", async () => {
const setPhase = vi.fn();
const fireConfetti = vi.fn();
const { result } = renderHook(() =>
useBlindboxPlan(room, profile, "pool", setPhase, fireConfetti),
);
await act(async () => {
await result.current.handleGeneratePlan({
date: "2026-03-07",
startHour: 9,
endHour: 18,
});
});
expect(mockRequestJson).toHaveBeenCalledWith("/api/blindbox/plan", {
method: "POST",
body: {
roomId: "room-1",
availableTime: { date: "2026-03-07", startHour: 9, endHour: 18 },
},
});
expect(setPhase).toHaveBeenCalledWith("planning");
expect(setPhase).toHaveBeenCalledWith("plan_reveal");
expect(result.current.planId).toBe("plan-1");
expect(result.current.planDays).toEqual(generatedDays);
expect(fireConfetti).toHaveBeenCalled();
});
it("accepts plan successfully after generation", async () => {
const setPhase = vi.fn();
const fireConfetti = vi.fn();
const { result } = renderHook(() =>
useBlindboxPlan(room, profile, "pool", setPhase, fireConfetti),
);
await act(async () => {
await result.current.handleGeneratePlan({
date: "2026-03-08",
startHour: 10,
endHour: 19,
});
});
fireConfetti.mockClear();
mockToastShow.mockClear();
await act(async () => {
await result.current.handleAcceptPlan();
});
expect(mockRequestJson).toHaveBeenCalledWith("/api/blindbox/plan", {
method: "PATCH",
body: { planId: "plan-1", action: "accept" },
});
expect(result.current.planAccepted).toBe(true);
expect(result.current.activeContract?.id).toBe("plan-1");
expect(fireConfetti).toHaveBeenCalledTimes(1);
expect(mockToastShow).toHaveBeenCalledWith("契约已接受!");
});
it("does not mark accepted when accept request fails", async () => {
mockRequestJson.mockImplementation(createRequestJsonMock({ acceptShouldFail: true }));
const setPhase = vi.fn();
const fireConfetti = vi.fn();
const { result } = renderHook(() =>
useBlindboxPlan(room, profile, "pool", setPhase, fireConfetti),
);
await act(async () => {
await result.current.handleGeneratePlan({
date: "2026-03-09",
startHour: 10,
endHour: 20,
});
});
fireConfetti.mockClear();
mockToastShow.mockClear();
await act(async () => {
await result.current.handleAcceptPlan();
});
await waitFor(() => {
expect(result.current.planAccepted).toBe(false);
});
expect(fireConfetti).not.toHaveBeenCalled();
expect(mockToastShow).toHaveBeenCalledWith("接受契约失败");
});
});
+157
View File
@@ -0,0 +1,157 @@
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
import { renderHook, act, waitFor } from "@testing-library/react";
const {
mockRequestJson,
mockRouter,
mockGetCachedProfile,
mockIsRegistered,
mockToastShow,
mockShare,
mockCopyToClipboard,
} = vi.hoisted(() => ({
mockRequestJson: vi.fn(),
mockRouter: {
push: vi.fn(),
refresh: vi.fn(),
replace: vi.fn(),
},
mockGetCachedProfile: vi.fn(),
mockIsRegistered: vi.fn(),
mockToastShow: vi.fn(),
mockShare: vi.fn(),
mockCopyToClipboard: vi.fn(),
}));
vi.mock("next/navigation", () => ({
useRouter: () => mockRouter,
}));
vi.mock("@/lib/fetcher", () => ({
requestJson: mockRequestJson,
}));
vi.mock("@/lib/userId", () => ({
getCachedProfile: mockGetCachedProfile,
isRegistered: mockIsRegistered,
}));
vi.mock("@/hooks/useToast", () => ({
useToast: () => ({ show: mockToastShow }),
}));
vi.mock("@/hooks/useShare", () => ({
useShare: () => ({ share: mockShare, copyToClipboard: mockCopyToClipboard }),
}));
import { useBlindboxRoom } from "./useBlindboxRoom";
function buildRoom(memberIds: string[] = ["user-1"]) {
return {
id: "room-1",
code: "ABC123",
name: "周末契约",
creatorId: "user-1",
city: "上海",
address: "黄浦区",
lat: 31.23,
lng: 121.47,
poolCount: 3,
members: memberIds.map((id) => ({ id, username: id, avatar: "🐱" })),
};
}
describe("useBlindboxRoom", () => {
beforeEach(() => {
vi.clearAllMocks();
mockIsRegistered.mockReturnValue(true);
mockGetCachedProfile.mockReturnValue({
id: "user-1",
username: "alice",
avatar: "🐱",
});
mockRequestJson.mockImplementation(async (url: string, init?: { method?: string }) => {
if (url === "/api/blindbox/room/ABC123" && !init) {
return buildRoom(["user-1", "user-2"]);
}
if (url === "/api/blindbox/room/ABC123" && init?.method === "DELETE") {
return { action: "left" };
}
if (url === "/api/blindbox/room/join") {
return { id: "room-1", code: "ABC123", name: "周末契约" };
}
throw new Error(`unexpected request: ${url}`);
});
});
afterEach(() => {
vi.clearAllTimers();
});
it("loads room data and marks current user as member", async () => {
const { result } = renderHook(() => useBlindboxRoom("ABC123"));
await waitFor(() => {
expect(result.current.room?.id).toBe("room-1");
});
expect(result.current.isMember).toBe(true);
expect(mockRequestJson).toHaveBeenCalledWith("/api/blindbox/room/ABC123");
});
it("joins room with code-only payload and refreshes room info", async () => {
let joined = false;
mockRequestJson.mockImplementation(async (url: string, init?: { method?: string; body?: unknown }) => {
if (url === "/api/blindbox/room/ABC123" && !init) {
return buildRoom(joined ? ["user-1", "user-2"] : ["user-2"]);
}
if (url === "/api/blindbox/room/join") {
joined = true;
return { id: "room-1", code: "ABC123", name: "周末契约" };
}
throw new Error(`unexpected request: ${url}`);
});
const { result } = renderHook(() => useBlindboxRoom("ABC123"));
await waitFor(() => {
expect(result.current.room?.id).toBe("room-1");
expect(result.current.isMember).toBe(false);
});
await act(async () => {
await result.current.handleJoinRoom();
});
expect(mockRequestJson).toHaveBeenCalledWith("/api/blindbox/room/join", {
method: "POST",
body: { code: "ABC123" },
});
await waitFor(() => {
expect(result.current.isMember).toBe(true);
});
});
it("requires confirmation before leave/delete and sends delete without body", async () => {
const { result } = renderHook(() => useBlindboxRoom("ABC123"));
await waitFor(() => {
expect(result.current.room?.id).toBe("room-1");
});
await act(async () => {
await result.current.handleLeaveOrDelete();
});
expect(result.current.confirmLeave).toBe(true);
await act(async () => {
await result.current.handleLeaveOrDelete();
});
expect(mockRequestJson).toHaveBeenCalledWith("/api/blindbox/room/ABC123", {
method: "DELETE",
});
expect(mockRouter.push).toHaveBeenCalledWith("/blindbox");
expect(mockRouter.refresh).toHaveBeenCalled();
});
});
+100
View File
@@ -0,0 +1,100 @@
import { describe, it, expect, vi, beforeEach } from "vitest";
import { requestJson, ApiRequestError } from "./fetcher";
const mockFetch = vi.fn();
vi.stubGlobal("fetch", mockFetch);
beforeEach(() => {
vi.clearAllMocks();
});
describe("requestJson", () => {
it("returns parsed JSON payload on success", async () => {
mockFetch.mockResolvedValueOnce(
new Response(JSON.stringify({ ok: true }), {
status: 200,
headers: { "Content-Type": "application/json" },
}),
);
const data = await requestJson<{ ok: boolean }>("/api/demo");
expect(data).toEqual({ ok: true });
});
it("serializes body and injects application/json header by default", async () => {
mockFetch.mockResolvedValueOnce(
new Response(JSON.stringify({ done: true }), { status: 200 }),
);
await requestJson("/api/demo", {
method: "POST",
body: { name: "alice" },
});
const init = mockFetch.mock.calls[0][1] as RequestInit;
expect(init.body).toBe(JSON.stringify({ name: "alice" }));
expect(new Headers(init.headers).get("Content-Type")).toBe("application/json");
});
it("keeps caller provided Content-Type header", async () => {
mockFetch.mockResolvedValueOnce(
new Response(JSON.stringify({ done: true }), { status: 200 }),
);
await requestJson("/api/demo", {
method: "POST",
headers: { "Content-Type": "text/plain" },
body: { raw: true },
});
const init = mockFetch.mock.calls[0][1] as RequestInit;
expect(new Headers(init.headers).get("Content-Type")).toBe("text/plain");
});
it("returns undefined for 204 responses", async () => {
mockFetch.mockResolvedValueOnce(new Response(null, { status: 204 }));
const data = await requestJson("/api/no-content");
expect(data).toBeUndefined();
});
it("throws ApiRequestError with JSON error payload", async () => {
mockFetch.mockResolvedValueOnce(
new Response(JSON.stringify({ error: "参数错误" }), {
status: 400,
headers: { "Content-Type": "application/json" },
}),
);
const promise = requestJson("/api/fail");
await expect(promise).rejects.toBeInstanceOf(ApiRequestError);
await expect(promise).rejects.toMatchObject({
message: "参数错误",
status: 400,
payload: { error: "参数错误" },
});
});
it("falls back to plain text message on failed responses", async () => {
mockFetch.mockResolvedValueOnce(new Response("服务不可用", { status: 503 }));
await expect(requestJson("/api/fail")).rejects.toMatchObject({
message: "服务不可用",
status: 503,
});
});
it("parses payload when response only provides json()", async () => {
mockFetch.mockResolvedValueOnce({
ok: true,
status: 200,
json: vi.fn().mockResolvedValue({ fromJsonOnly: true }),
} as unknown as Response);
const data = await requestJson<{ fromJsonOnly: boolean }>("/api/mock-only-json");
expect(data).toEqual({ fromJsonOnly: true });
});
});