diff --git a/TEST_SUPPLEMENT_PLAN_2026-03-03.md b/TEST_SUPPLEMENT_PLAN_2026-03-03.md index 105b51a..1ff093e 100644 --- a/TEST_SUPPLEMENT_PLAN_2026-03-03.md +++ b/TEST_SUPPLEMENT_PLAN_2026-03-03.md @@ -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) diff --git a/src/hooks/useBlindboxDraw.test.ts b/src/hooks/useBlindboxDraw.test.ts new file mode 100644 index 0000000..be01415 --- /dev/null +++ b/src/hooks/useBlindboxDraw.test.ts @@ -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(); + }); +}); diff --git a/src/hooks/useBlindboxPlan.test.ts b/src/hooks/useBlindboxPlan.test.ts new file mode 100644 index 0000000..949f7f8 --- /dev/null +++ b/src/hooks/useBlindboxPlan.test.ts @@ -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("接受契约失败"); + }); +}); diff --git a/src/hooks/useBlindboxRoom.test.ts b/src/hooks/useBlindboxRoom.test.ts new file mode 100644 index 0000000..6435615 --- /dev/null +++ b/src/hooks/useBlindboxRoom.test.ts @@ -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(); + }); +}); diff --git a/src/lib/fetcher.test.ts b/src/lib/fetcher.test.ts new file mode 100644 index 0000000..f8fc987 --- /dev/null +++ b/src/lib/fetcher.test.ts @@ -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 }); + }); +});