test: 添加完整测试套件(52 个文件,326 个用例)

基于 Vitest 搭建测试基础设施,覆盖后端纯函数、API 路由、
前端 hooks、UI 组件和页面级集成测试。
This commit is contained in:
2026-02-28 20:19:14 +08:00
parent 11eeec868e
commit 3ccd1262f9
59 changed files with 8131 additions and 3 deletions
+204
View File
@@ -0,0 +1,204 @@
import { describe, it, expect, vi, beforeEach } from "vitest";
import { render, screen, fireEvent, waitFor } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import AuthModal from "./AuthModal";
vi.mock("@/lib/userId", () => ({
setCachedProfile: vi.fn(),
}));
const mockFetch = vi.fn();
vi.stubGlobal("fetch", mockFetch);
const mockOnAuth = vi.fn();
const mockOnClose = vi.fn();
beforeEach(() => {
vi.clearAllMocks();
// Reset localStorage
Object.defineProperty(window, "localStorage", {
value: { setItem: vi.fn(), getItem: vi.fn(), removeItem: vi.fn() },
writable: true,
});
});
function renderModal(props = {}) {
return render(
<AuthModal open onClose={mockOnClose} onAuth={mockOnAuth} {...props} />,
);
}
describe("AuthModal", () => {
it("renders login tab by default", () => {
renderModal();
expect(screen.getAllByText("登录").length).toBeGreaterThanOrEqual(1);
expect(screen.getAllByText("注册").length).toBeGreaterThanOrEqual(1);
expect(screen.getByPlaceholderText("请输入用户名")).toBeInTheDocument();
});
it("renders register tab when specified", () => {
renderModal({ defaultTab: "register" });
expect(screen.getByPlaceholderText("2-16 个字符")).toBeInTheDocument();
expect(screen.getByText("确认密码")).toBeInTheDocument();
});
it("switches between login and register tabs", async () => {
renderModal();
expect(screen.getByPlaceholderText("请输入用户名")).toBeInTheDocument();
fireEvent.click(screen.getByText("注册"));
expect(screen.getByPlaceholderText("2-16 个字符")).toBeInTheDocument();
expect(screen.getByText("确认密码")).toBeInTheDocument();
});
it("shows error when username is empty", async () => {
renderModal();
const submitBtn = screen.getAllByText("登录").find(
(el) => el.closest("button[class*='bg-accent']"),
)!;
fireEvent.click(submitBtn);
await waitFor(() => {
expect(screen.getByText("请输入用户名")).toBeInTheDocument();
});
});
it("shows error when password is empty", async () => {
renderModal();
const user = userEvent.setup();
await user.type(screen.getByPlaceholderText("请输入用户名"), "testuser");
const submitBtn = screen.getAllByText("登录").find(
(el) => el.closest("button[class*='bg-accent']"),
)!;
fireEvent.click(submitBtn);
await waitFor(() => {
expect(screen.getByText("请输入密码")).toBeInTheDocument();
});
});
it("shows register-specific validation errors", async () => {
renderModal({ defaultTab: "register" });
const user = userEvent.setup();
await user.type(screen.getByPlaceholderText("2-16 个字符"), "a");
await user.type(screen.getByPlaceholderText("至少 6 个字符"), "123456");
await user.type(screen.getByPlaceholderText("再次输入密码"), "654321");
const submitBtn = screen.getAllByText("注册").find(
(el) => el.closest("button[class*='bg-accent']"),
)!;
fireEvent.click(submitBtn);
await waitFor(() => {
expect(screen.getByText("用户名需要 2-16 个字符")).toBeInTheDocument();
});
});
it("shows password mismatch error during registration", async () => {
renderModal({ defaultTab: "register" });
const user = userEvent.setup();
await user.type(screen.getByPlaceholderText("2-16 个字符"), "testuser");
await user.type(screen.getByPlaceholderText("至少 6 个字符"), "123456");
await user.type(screen.getByPlaceholderText("再次输入密码"), "654321");
const submitBtn = screen.getAllByText("注册").find(
(el) => el.closest("button[class*='bg-accent']"),
)!;
fireEvent.click(submitBtn);
await waitFor(() => {
expect(screen.getByText("两次密码不一致")).toBeInTheDocument();
});
});
it("submits login successfully", async () => {
mockFetch.mockResolvedValue({
ok: true,
json: () => Promise.resolve({ id: "u1", username: "testuser", avatar: "🐱" }),
});
renderModal();
const user = userEvent.setup();
await user.type(screen.getByPlaceholderText("请输入用户名"), "testuser");
await user.type(screen.getByPlaceholderText("请输入密码"), "password123");
const submitBtn = screen.getAllByText("登录").find(
(el) => el.closest("button[class*='bg-accent']"),
)!;
fireEvent.click(submitBtn);
await waitFor(() => {
expect(mockOnAuth).toHaveBeenCalledWith({
id: "u1",
username: "testuser",
avatar: "🐱",
});
expect(mockOnClose).toHaveBeenCalled();
});
});
it("shows API error on failed login", async () => {
mockFetch.mockResolvedValue({
ok: false,
json: () => Promise.resolve({ error: "用户名或密码错误" }),
});
renderModal();
const user = userEvent.setup();
await user.type(screen.getByPlaceholderText("请输入用户名"), "testuser");
await user.type(screen.getByPlaceholderText("请输入密码"), "wrong");
const submitBtn = screen.getAllByText("登录").find(
(el) => el.closest("button[class*='bg-accent']"),
)!;
fireEvent.click(submitBtn);
await waitFor(() => {
expect(screen.getByText("用户名或密码错误")).toBeInTheDocument();
});
});
it("shows network error on fetch failure", async () => {
mockFetch.mockRejectedValue(new Error("network"));
renderModal();
const user = userEvent.setup();
await user.type(screen.getByPlaceholderText("请输入用户名"), "testuser");
await user.type(screen.getByPlaceholderText("请输入密码"), "password123");
const submitBtn = screen.getAllByText("登录").find(
(el) => el.closest("button[class*='bg-accent']"),
)!;
fireEvent.click(submitBtn);
await waitFor(() => {
expect(screen.getByText("网络错误,请重试")).toBeInTheDocument();
});
});
it("toggles password visibility", () => {
renderModal();
const toggle = screen.getByLabelText("显示密码");
const passwordInput = screen.getByPlaceholderText("请输入密码");
expect(passwordInput).toHaveAttribute("type", "password");
fireEvent.click(toggle);
expect(passwordInput).toHaveAttribute("type", "text");
});
it("does not render when closed", () => {
render(
<AuthModal open={false} onClose={mockOnClose} onAuth={mockOnAuth} />,
);
expect(screen.queryByText("欢迎")).not.toBeInTheDocument();
});
it("renders avatar selection in register mode", () => {
renderModal({ defaultTab: "register" });
expect(screen.getByText("选择头像")).toBeInTheDocument();
expect(screen.getByText("🐱")).toBeInTheDocument();
});
});
@@ -0,0 +1,46 @@
import { describe, it, expect } from "vitest";
import { render, screen } from "@testing-library/react";
import BlindboxDrawnHistory, { type DrawnIdea } from "./BlindboxDrawnHistory";
const mockItems: DrawnIdea[] = [
{
id: "drawn-1",
content: "去公园野餐",
createdAt: "2025-03-01T10:00:00Z",
user: { id: "u1", username: "小明", avatar: "🐱" },
drawnBy: { id: "u2", username: "小红", avatar: "🐶" },
},
{
id: "drawn-2",
content: "看展览",
createdAt: "2025-03-02T10:00:00Z",
user: { id: "u2", username: "小红", avatar: "🐶" },
drawnBy: null,
},
];
describe("BlindboxDrawnHistory", () => {
it("renders nothing when items are empty", () => {
const { container } = render(<BlindboxDrawnHistory items={[]} />);
expect(container.innerHTML).toBe("");
});
it("renders history title and items", () => {
render(<BlindboxDrawnHistory items={mockItems} />);
expect(screen.getByText("履约记录")).toBeInTheDocument();
expect(screen.getByText("去公园野餐")).toBeInTheDocument();
expect(screen.getByText("看展览")).toBeInTheDocument();
});
it("shows user who contributed and who drew", () => {
render(<BlindboxDrawnHistory items={mockItems} />);
expect(screen.getByText(/小明 投入/)).toBeInTheDocument();
expect(screen.getByText(/小红 抽中/)).toBeInTheDocument();
});
it("renders formatted date", () => {
render(<BlindboxDrawnHistory items={mockItems} />);
const dateElements = screen.getAllByText(/月/);
expect(dateElements.length).toBeGreaterThan(0);
});
});
+95
View File
@@ -0,0 +1,95 @@
import { describe, it, expect, vi } from "vitest";
import { render, screen, fireEvent, waitFor } from "@testing-library/react";
import BlindboxMyIdeas, { type MyIdea, CategoryBadge, DurationLabel } from "./BlindboxMyIdeas";
const mockIdeas: MyIdea[] = [
{
id: "idea-1",
content: "去爬山",
createdAt: new Date().toISOString(),
category: "outdoor",
estimatedMinutes: 120,
},
{
id: "idea-2",
content: "看电影",
createdAt: new Date().toISOString(),
category: "entertainment",
estimatedMinutes: 150,
},
];
const mockOnEdit = vi.fn().mockResolvedValue(undefined);
const mockOnDelete = vi.fn().mockResolvedValue(undefined);
describe("BlindboxMyIdeas", () => {
it("renders idea list with count", () => {
render(
<BlindboxMyIdeas ideas={mockIdeas} onEdit={mockOnEdit} onDelete={mockOnDelete} />,
);
expect(screen.getByText(/我投入的想法(2/)).toBeInTheDocument();
expect(screen.getByText("去爬山")).toBeInTheDocument();
expect(screen.getByText("看电影")).toBeInTheDocument();
});
it("shows duration labels", () => {
render(
<BlindboxMyIdeas ideas={mockIdeas} onEdit={mockOnEdit} onDelete={mockOnDelete} />,
);
expect(screen.getByText("~2h")).toBeInTheDocument();
expect(screen.getByText("~2.5h")).toBeInTheDocument();
});
it("renders empty list", () => {
render(
<BlindboxMyIdeas ideas={[]} onEdit={mockOnEdit} onDelete={mockOnDelete} />,
);
expect(screen.getByText(/我投入的想法(0/)).toBeInTheDocument();
});
it("enters edit mode on pencil button click", async () => {
render(
<BlindboxMyIdeas ideas={[mockIdeas[0]]} onEdit={mockOnEdit} onDelete={mockOnDelete} />,
);
const editButtons = screen.getAllByRole("button");
const pencilBtn = editButtons.find((b) => b.querySelector("svg"));
fireEvent.click(pencilBtn!);
await waitFor(() => {
expect(screen.getByRole("textbox")).toBeInTheDocument();
});
});
});
describe("CategoryBadge", () => {
it("renders icon for known category", () => {
const { container } = render(<CategoryBadge category="dining" />);
expect(container.querySelector("svg")).toBeInTheDocument();
});
it("renders fallback for unknown category", () => {
const { container } = render(<CategoryBadge category="unknown" />);
expect(container.textContent).toBe("💡");
});
it("renders fallback for null category", () => {
const { container } = render(<CategoryBadge category={null} />);
expect(container.textContent).toBe("💡");
});
});
describe("DurationLabel", () => {
it("renders minutes", () => {
render(<DurationLabel minutes={45} />);
expect(screen.getByText("~45min")).toBeInTheDocument();
});
it("renders hours", () => {
render(<DurationLabel minutes={60} />);
expect(screen.getByText("~1h")).toBeInTheDocument();
});
it("renders nothing for null", () => {
const { container } = render(<DurationLabel minutes={null} />);
expect(container.innerHTML).toBe("");
});
});
+156
View File
@@ -0,0 +1,156 @@
import { describe, it, expect, vi, beforeEach } from "vitest";
import { render, screen, fireEvent, waitFor } from "@testing-library/react";
import BlindboxPlan from "./BlindboxPlan";
import type { WeekendPlanData } from "@/types";
beforeEach(() => {
Element.prototype.scrollTo = vi.fn();
});
vi.mock("@/components/BlindboxMyIdeas", () => ({
CategoryBadge: ({ category }: { category: string }) => (
<span data-testid="category-badge">{category}</span>
),
}));
const mockDays: WeekendPlanData[] = [
{
date: "周六",
summary: "轻松的一天",
items: [
{
time: "10:00",
activity: "去公园散步",
poi: "朝阳公园",
address: "朝阳区",
duration: 120,
lat: 39.9,
lng: 116.4,
reason: "空气好",
},
{
time: "14:00",
activity: "午餐",
poi: "海底捞",
address: "朝阳区",
duration: 90,
lat: 39.9,
lng: 116.4,
},
],
},
{
date: "周日",
summary: "文艺的一天",
items: [
{
time: "10:00",
activity: "参观博物馆",
poi: "国家博物馆",
address: "东城区",
duration: 180,
lat: 39.9,
lng: 116.4,
},
],
},
];
const mockOnAccept = vi.fn();
const mockOnRegenerate = vi.fn();
const mockOnShare = vi.fn();
const mockOnBack = vi.fn();
function renderPlan(overrides = {}) {
return render(
<BlindboxPlan
days={mockDays}
onAccept={mockOnAccept}
onRegenerate={mockOnRegenerate}
onShare={mockOnShare}
onBack={mockOnBack}
{...overrides}
/>,
);
}
describe("BlindboxPlan", () => {
it("renders day header with date", () => {
renderPlan();
expect(screen.getByText(/周六 · 行程规划/)).toBeInTheDocument();
});
it("renders summary text", () => {
renderPlan();
expect(screen.getByText("轻松的一天")).toBeInTheDocument();
});
it("renders timeline items", () => {
renderPlan();
expect(screen.getByText("去公园散步")).toBeInTheDocument();
expect(screen.getByText("朝阳公园")).toBeInTheDocument();
expect(screen.getByText("午餐")).toBeInTheDocument();
});
it("shows duration formatted", () => {
renderPlan();
expect(screen.getByText("2h")).toBeInTheDocument();
expect(screen.getByText("1h30min")).toBeInTheDocument();
});
it("shows accept and regenerate buttons when not accepted", () => {
renderPlan();
expect(screen.getByText("接受契约")).toBeInTheDocument();
expect(screen.getByText("换一个方案")).toBeInTheDocument();
});
it("shows share button when accepted", () => {
renderPlan({ accepted: true });
expect(screen.getByText("分享计划")).toBeInTheDocument();
expect(screen.queryByText("接受契约")).not.toBeInTheDocument();
});
it("calls onAccept", () => {
renderPlan();
fireEvent.click(screen.getByText("接受契约"));
expect(mockOnAccept).toHaveBeenCalled();
});
it("calls onRegenerate", () => {
renderPlan();
fireEvent.click(screen.getByText("换一个方案"));
expect(mockOnRegenerate).toHaveBeenCalled();
});
it("calls onBack", () => {
renderPlan();
fireEvent.click(screen.getByText("返回想法池"));
expect(mockOnBack).toHaveBeenCalled();
});
it("navigates between days", async () => {
renderPlan();
expect(screen.getByText(/周六 · 行程规划/)).toBeInTheDocument();
fireEvent.click(screen.getByText("周日"));
await waitFor(() => {
expect(screen.getByText(/周日 · 行程规划/)).toBeInTheDocument();
});
});
it("shows day indicators for multi-day plans", () => {
renderPlan();
expect(screen.getByText("1 / 2")).toBeInTheDocument();
});
it("shows navigation link for items with coordinates", () => {
renderPlan();
const navLinks = screen.getAllByText("导航");
expect(navLinks.length).toBeGreaterThan(0);
});
it("shows reason when available", () => {
renderPlan();
expect(screen.getByText("空气好")).toBeInTheDocument();
});
});
+67
View File
@@ -0,0 +1,67 @@
import { describe, it, expect } from "vitest";
import { render, screen } from "@testing-library/react";
import Button from "./Button";
describe("Button", () => {
it("renders children text", () => {
render(<Button>Click me</Button>);
expect(screen.getByText("Click me")).toBeInTheDocument();
});
it("renders with primary variant by default", () => {
render(<Button>Primary</Button>);
const button = screen.getByRole("button");
expect(button.className).toContain("bg-accent");
});
it("renders secondary variant", () => {
render(<Button variant="secondary">Secondary</Button>);
const button = screen.getByRole("button");
expect(button.className).toContain("bg-surface");
});
it("renders danger variant", () => {
render(<Button variant="danger">Danger</Button>);
const button = screen.getByRole("button");
expect(button.className).toContain("bg-rose-600");
});
it("shows loading spinner and disables button", () => {
render(<Button loading>Loading</Button>);
const button = screen.getByRole("button");
expect(button).toBeDisabled();
expect(button.querySelector(".animate-spin")).toBeTruthy();
});
it("shows loadingText when loading", () => {
render(
<Button loading loadingText="请稍候...">
Submit
</Button>,
);
expect(screen.getByText("请稍候...")).toBeInTheDocument();
});
it("disables button when disabled prop is set", () => {
render(<Button disabled>Disabled</Button>);
expect(screen.getByRole("button")).toBeDisabled();
});
it("applies full width class", () => {
render(<Button fullWidth>Full Width</Button>);
expect(screen.getByRole("button").className).toContain("w-full");
});
it("applies pill shape", () => {
render(<Button shape="pill">Pill</Button>);
expect(screen.getByRole("button").className).toContain("rounded-full");
});
it("applies size classes", () => {
const { rerender } = render(<Button size="sm">Small</Button>);
expect(screen.getByRole("button").className).toContain("h-8");
rerender(<Button size="lg">Large</Button>);
expect(screen.getByRole("button").className).toContain("h-11");
});
});
+47
View File
@@ -0,0 +1,47 @@
import { describe, it, expect, vi } from "vitest";
import { render, screen, fireEvent } from "@testing-library/react";
import { Package } from "lucide-react";
import EmptyState from "./EmptyState";
describe("EmptyState", () => {
it("renders title", () => {
render(<EmptyState icon={Package} title="没有数据" />);
expect(screen.getByText("没有数据")).toBeInTheDocument();
});
it("renders subtitle when provided", () => {
render(
<EmptyState icon={Package} title="空" subtitle="还没有任何内容" />,
);
expect(screen.getByText("还没有任何内容")).toBeInTheDocument();
});
it("does not render subtitle when not provided", () => {
const { container } = render(<EmptyState icon={Package} title="空" />);
expect(container.querySelectorAll("p")).toHaveLength(1);
});
it("renders CTA button when ctaLabel and onCta provided", () => {
const onCta = vi.fn();
render(
<EmptyState icon={Package} title="空" ctaLabel="添加" onCta={onCta} />,
);
const button = screen.getByText("添加");
expect(button).toBeInTheDocument();
fireEvent.click(button);
expect(onCta).toHaveBeenCalled();
});
it("does not render CTA without both ctaLabel and onCta", () => {
render(<EmptyState icon={Package} title="空" ctaLabel="添加" />);
expect(screen.queryByText("添加")).not.toBeInTheDocument();
});
it("renders image when provided", () => {
render(
<EmptyState icon={Package} title="空" image="/test-image.png" />,
);
expect(screen.getByAltText("空")).toBeInTheDocument();
});
});
+47
View File
@@ -0,0 +1,47 @@
import { describe, it, expect, vi } from "vitest";
import { render, screen } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import Input from "./Input";
describe("Input", () => {
it("renders an input element", () => {
render(<Input placeholder="Enter text" />);
expect(screen.getByPlaceholderText("Enter text")).toBeInTheDocument();
});
it("accepts user input", async () => {
const user = userEvent.setup();
render(<Input placeholder="Type here" />);
const input = screen.getByPlaceholderText("Type here");
await user.type(input, "hello");
expect(input).toHaveValue("hello");
});
it("applies size variants", () => {
const { rerender } = render(<Input size="sm" data-testid="input" />);
expect(screen.getByTestId("input").className).toContain("h-8");
rerender(<Input size="lg" data-testid="input" />);
expect(screen.getByTestId("input").className).toContain("h-10");
rerender(<Input size="xl" data-testid="input" />);
expect(screen.getByTestId("input").className).toContain("h-11");
});
it("applies purple variant", () => {
render(<Input variant="purple" data-testid="input" />);
expect(screen.getByTestId("input").className).toContain("focus:ring-purple-600");
});
it("forwards ref", () => {
const ref = { current: null as HTMLInputElement | null };
render(<Input ref={ref} />);
expect(ref.current).toBeInstanceOf(HTMLInputElement);
});
it("supports disabled state", () => {
render(<Input disabled data-testid="input" />);
expect(screen.getByTestId("input")).toBeDisabled();
});
});
+155
View File
@@ -0,0 +1,155 @@
import { describe, it, expect, vi, beforeEach } from "vitest";
import { render, screen } from "@testing-library/react";
import { TEST_RESTAURANT, TEST_RESTAURANT_2 } from "@/__tests__/helpers/fixtures";
import type { ReactNode } from "react";
import React from "react";
import { ToastContext, type ToastContextValue } from "@/hooks/useToast";
vi.mock("next/navigation", () => ({
useRouter: () => ({ push: vi.fn() }),
}));
vi.mock("@/lib/celebrate", () => ({
fireCelebration: vi.fn(),
playChime: vi.fn(),
}));
vi.mock("@/lib/userId", () => ({
isRegistered: vi.fn().mockReturnValue(true),
}));
vi.mock("./ShareCardModal", () => ({
default: () => null,
}));
vi.mock("./RestaurantImage", () => ({
default: ({ alt }: { alt: string }) => <img alt={alt} />,
}));
vi.mock("./AuthModal", () => ({
default: () => null,
}));
vi.mock("./NoMatchResult", () => ({
default: ({ onReset }: { onReset: () => void }) => (
<div data-testid="no-match">
<button onClick={onReset}></button>
</div>
),
}));
vi.mock("./RunnerUpCard", () => ({
default: ({ restaurant }: { restaurant: { name: string } }) => (
<div data-testid="runner-up">{restaurant.name}</div>
),
}));
const mockFetch = vi.fn().mockResolvedValue({ ok: true, json: () => Promise.resolve({}) });
vi.stubGlobal("fetch", mockFetch);
import MatchResult from "./MatchResult";
const toastCtx: ToastContextValue = { show: vi.fn() };
function Wrapper({ children }: { children: ReactNode }) {
return React.createElement(
ToastContext.Provider,
{ value: toastCtx },
children,
);
}
const baseProps = {
restaurant: TEST_RESTAURANT,
matchLikes: 2,
runnerUps: [],
allRestaurants: [TEST_RESTAURANT, TEST_RESTAURANT_2],
userCount: 2,
roomId: "ROOM01",
userId: "user-1",
onReset: vi.fn().mockResolvedValue(undefined),
onNarrow: vi.fn().mockResolvedValue(undefined),
resetting: false,
};
beforeEach(() => {
vi.clearAllMocks();
});
describe("MatchResult", () => {
it("renders unanimous match display", () => {
render(
<Wrapper>
<MatchResult {...baseProps} matchType="unanimous" />
</Wrapper>,
);
expect(screen.getByText("就去这了")).toBeInTheDocument();
expect(screen.getByText("大家一拍即合!")).toBeInTheDocument();
expect(screen.getByText("测试餐厅")).toBeInTheDocument();
});
it("renders best match display", () => {
render(
<Wrapper>
<MatchResult {...baseProps} matchType="best" />
</Wrapper>,
);
expect(screen.getByText("就去这了")).toBeInTheDocument();
expect(screen.getByText("2/2 人想去这家")).toBeInTheDocument();
});
it("renders no_match by delegating to NoMatchResult", () => {
render(
<Wrapper>
<MatchResult {...baseProps} matchType="no_match" />
</Wrapper>,
);
expect(screen.getByTestId("no-match")).toBeInTheDocument();
});
it("shows navigation button", () => {
render(
<Wrapper>
<MatchResult {...baseProps} matchType="unanimous" />
</Wrapper>,
);
expect(screen.getByText("导航过去")).toBeInTheDocument();
});
it("shows phone button when tel available", () => {
render(
<Wrapper>
<MatchResult {...baseProps} matchType="unanimous" />
</Wrapper>,
);
expect(screen.getByText("打电话订位")).toBeInTheDocument();
});
it("shows share button", () => {
render(
<Wrapper>
<MatchResult {...baseProps} matchType="best" />
</Wrapper>,
);
expect(screen.getByText("生成分享卡片")).toBeInTheDocument();
});
it("shows solo message when userCount is 1", () => {
render(
<Wrapper>
<MatchResult {...baseProps} matchType="unanimous" userCount={1} />
</Wrapper>,
);
expect(screen.getByText("帮你选好了")).toBeInTheDocument();
expect(screen.getByText("你的首选,别犹豫了")).toBeInTheDocument();
});
it("shows reset button", () => {
render(
<Wrapper>
<MatchResult {...baseProps} matchType="unanimous" />
</Wrapper>,
);
expect(screen.getByText("再来一轮")).toBeInTheDocument();
});
});
+70
View File
@@ -0,0 +1,70 @@
import { describe, it, expect, vi } from "vitest";
import { render, screen, fireEvent } from "@testing-library/react";
import Modal from "./Modal";
describe("Modal", () => {
it("renders children when open", () => {
render(
<Modal open onClose={() => {}}>
<p>Modal content</p>
</Modal>,
);
expect(screen.getByText("Modal content")).toBeInTheDocument();
});
it("does not render when closed", () => {
render(
<Modal open={false} onClose={() => {}}>
<p>Hidden content</p>
</Modal>,
);
expect(screen.queryByText("Hidden content")).not.toBeInTheDocument();
});
it("calls onClose when clicking backdrop", () => {
const onClose = vi.fn();
render(
<Modal open onClose={onClose}>
<p>Content</p>
</Modal>,
);
const backdrop = screen.getByText("Content").closest("[class*='fixed']");
if (backdrop) {
fireEvent.click(backdrop);
expect(onClose).toHaveBeenCalled();
}
});
it("does not close when clicking content", () => {
const onClose = vi.fn();
render(
<Modal open onClose={onClose}>
<p>Content</p>
</Modal>,
);
fireEvent.click(screen.getByText("Content"));
expect(onClose).not.toHaveBeenCalled();
});
it("applies sheet variant by default", () => {
render(
<Modal open onClose={() => {}}>
<p>Sheet</p>
</Modal>,
);
const content = screen.getByText("Sheet").closest("div[class*='rounded']");
expect(content?.className).toContain("rounded-t-3xl");
});
it("applies dialog variant", () => {
render(
<Modal open onClose={() => {}} variant="dialog">
<p>Dialog</p>
</Modal>,
);
const content = screen.getByText("Dialog").closest("div[class*='rounded']");
expect(content?.className).toContain("rounded-2xl");
});
});
+65
View File
@@ -0,0 +1,65 @@
import { describe, it, expect } from "vitest";
import { render } from "@testing-library/react";
import {
Skeleton,
SkeletonCircle,
RoomCardSkeleton,
ProfileCardSkeleton,
RecordItemSkeleton,
SwipeDeckSkeleton,
BlindboxRoomSkeleton,
BlindboxListSkeleton,
} from "./Skeleton";
describe("Skeleton", () => {
it("renders with animate-pulse", () => {
const { container } = render(<Skeleton />);
expect(container.firstChild).toHaveClass("animate-pulse");
});
it("accepts custom className", () => {
const { container } = render(<Skeleton className="h-4 w-20" />);
const el = container.firstChild as HTMLElement;
expect(el.className).toContain("h-4");
expect(el.className).toContain("w-20");
});
});
describe("SkeletonCircle", () => {
it("renders rounded-full shape", () => {
const { container } = render(<SkeletonCircle />);
expect(container.firstChild).toHaveClass("rounded-full");
});
});
describe("Skeleton composites", () => {
it("renders RoomCardSkeleton", () => {
const { container } = render(<RoomCardSkeleton />);
expect(container.querySelectorAll(".animate-pulse").length).toBeGreaterThan(0);
});
it("renders ProfileCardSkeleton", () => {
const { container } = render(<ProfileCardSkeleton />);
expect(container.querySelectorAll(".animate-pulse").length).toBeGreaterThan(0);
});
it("renders RecordItemSkeleton", () => {
const { container } = render(<RecordItemSkeleton />);
expect(container.querySelectorAll(".animate-pulse").length).toBeGreaterThan(0);
});
it("renders SwipeDeckSkeleton", () => {
const { container } = render(<SwipeDeckSkeleton />);
expect(container.querySelectorAll(".animate-pulse").length).toBeGreaterThan(0);
});
it("renders BlindboxRoomSkeleton", () => {
const { container } = render(<BlindboxRoomSkeleton />);
expect(container.querySelectorAll(".animate-pulse").length).toBeGreaterThan(0);
});
it("renders BlindboxListSkeleton", () => {
const { container } = render(<BlindboxListSkeleton />);
expect(container.querySelectorAll(".animate-pulse").length).toBeGreaterThan(0);
});
});
+100
View File
@@ -0,0 +1,100 @@
import { describe, it, expect, vi } from "vitest";
import { render, screen } from "@testing-library/react";
import SwipeDeck from "./SwipeDeck";
import {
TEST_RESTAURANT,
TEST_RESTAURANT_2,
TEST_RESTAURANT_3,
} from "@/__tests__/helpers/fixtures";
vi.mock("./SwipeableCard", () => ({
default: ({ restaurant }: { restaurant: { name: string } }) => (
<div data-testid="swipeable-card">{restaurant.name}</div>
),
}));
vi.mock("./ActionButtons", () => ({
default: ({ disabled }: { disabled: boolean }) => (
<div data-testid="action-buttons" data-disabled={disabled} />
),
}));
vi.mock("./MatchResult", () => ({
default: ({ restaurant }: { restaurant: { name: string } }) => (
<div data-testid="match-result">{restaurant.name}</div>
),
}));
vi.mock("./SwipeGuide", () => ({
default: () => <div data-testid="swipe-guide" />,
}));
vi.mock("./UserAvatar", () => ({
default: () => <span data-testid="user-avatar" />,
}));
const restaurants = [TEST_RESTAURANT, TEST_RESTAURANT_2, TEST_RESTAURANT_3];
const defaultProps = {
restaurants,
roomId: "ROOM01",
userId: "user-1",
initialIndex: 0,
matchedRestaurantId: null,
matchType: null as const,
matchLikes: 0,
runnerUps: [],
likeCounts: {},
swipeCounts: {},
userCount: 2,
userProfiles: {},
onReset: vi.fn().mockResolvedValue(undefined),
onNarrow: vi.fn().mockResolvedValue(undefined),
};
describe("SwipeDeck", () => {
it("renders restaurant cards at initial index", () => {
render(<SwipeDeck {...defaultProps} />);
const cards = screen.getAllByTestId("swipeable-card");
expect(cards.length).toBeGreaterThan(0);
});
it("renders action buttons", () => {
render(<SwipeDeck {...defaultProps} />);
expect(screen.getByTestId("action-buttons")).toBeInTheDocument();
});
it("shows swipe guide at index 0", () => {
render(<SwipeDeck {...defaultProps} />);
expect(screen.getByTestId("swipe-guide")).toBeInTheDocument();
});
it("does not show swipe guide when initialIndex > 0", () => {
render(<SwipeDeck {...defaultProps} initialIndex={1} />);
expect(screen.queryByTestId("swipe-guide")).not.toBeInTheDocument();
});
it("shows match result when matchedRestaurantId is set", () => {
render(
<SwipeDeck
{...defaultProps}
matchedRestaurantId={TEST_RESTAURANT.id}
matchType="unanimous"
matchLikes={2}
/>,
);
expect(screen.getByTestId("match-result")).toBeInTheDocument();
});
it("disables action buttons when match exists", () => {
render(
<SwipeDeck
{...defaultProps}
matchedRestaurantId={TEST_RESTAURANT.id}
matchType="unanimous"
/>,
);
const actions = screen.getByTestId("action-buttons");
expect(actions.getAttribute("data-disabled")).toBe("true");
});
});
+76
View File
@@ -0,0 +1,76 @@
import { describe, it, expect, vi } from "vitest";
import { render, screen } from "@testing-library/react";
import SwipeableCard from "./SwipeableCard";
import { TEST_RESTAURANT } from "@/__tests__/helpers/fixtures";
vi.mock("./RestaurantCard", () => ({
default: ({ restaurant }: { restaurant: { name: string } }) => (
<div data-testid="restaurant-card">{restaurant.name}</div>
),
}));
describe("SwipeableCard", () => {
it("renders restaurant card", () => {
render(
<SwipeableCard
restaurant={TEST_RESTAURANT}
isTop
onSwipe={() => {}}
likeCount={0}
/>,
);
expect(screen.getByTestId("restaurant-card")).toBeInTheDocument();
expect(screen.getByText("测试餐厅")).toBeInTheDocument();
});
it("renders swipe overlays (LIKE and NOPE)", () => {
const { container } = render(
<SwipeableCard
restaurant={TEST_RESTAURANT}
isTop
onSwipe={() => {}}
likeCount={0}
/>,
);
expect(container.textContent).toContain("LIKE");
expect(container.textContent).toContain("NOPE");
});
it("calls registerSwipe when provided and isTop", () => {
const registerSwipe = vi.fn();
render(
<SwipeableCard
restaurant={TEST_RESTAURANT}
isTop
onSwipe={() => {}}
registerSwipe={registerSwipe}
likeCount={0}
/>,
);
expect(registerSwipe).toHaveBeenCalledWith(expect.any(Function));
});
it("does not pass registerSwipe for non-top cards", () => {
render(
<SwipeableCard
restaurant={TEST_RESTAURANT}
isTop={false}
onSwipe={() => {}}
likeCount={0}
/>,
);
expect(screen.getByTestId("restaurant-card")).toBeInTheDocument();
});
it("displays like count", () => {
render(
<SwipeableCard
restaurant={TEST_RESTAURANT}
isTop
onSwipe={() => {}}
likeCount={3}
/>,
);
expect(screen.getByTestId("restaurant-card")).toBeInTheDocument();
});
});
+27
View File
@@ -0,0 +1,27 @@
import { describe, it, expect } from "vitest";
import { render, screen } from "@testing-library/react";
import Toast from "./Toast";
describe("Toast", () => {
it("renders message when provided", () => {
render(<Toast message="操作成功" />);
expect(screen.getByText("操作成功")).toBeInTheDocument();
});
it("does not render when message is empty", () => {
const { container } = render(<Toast message="" />);
expect(container.textContent).toBe("");
});
it("applies top position class by default", () => {
render(<Toast message="test" />);
const el = screen.getByText("test").closest("div[class*='fixed']");
expect(el?.className).toContain("top-10");
});
it("applies bottom position class", () => {
render(<Toast message="test" position="bottom" />);
const el = screen.getByText("test").closest("div[class*='fixed']");
expect(el?.className).toContain("bottom-8");
});
});
+59
View File
@@ -0,0 +1,59 @@
import { describe, it, expect, vi } from "vitest";
import { render, screen, fireEvent } from "@testing-library/react";
import TopNav from "./TopNav";
vi.mock("./QrInviteModal", () => ({
default: ({ open }: { open: boolean }) =>
open ? <div data-testid="qr-modal">QR Modal</div> : null,
}));
vi.mock("./RoomManageModal", () => ({
default: ({ open }: { open: boolean }) =>
open ? <div data-testid="manage-modal">Manage Modal</div> : null,
}));
describe("TopNav", () => {
it("renders room ID in invite button", () => {
render(<TopNav roomId="ROOM01" />);
expect(screen.getByText(/ROOM01/)).toBeInTheDocument();
});
it("renders exit button", () => {
const onExit = vi.fn();
render(<TopNav roomId="ROOM01" onExit={onExit} />);
const exitBtn = screen.getByLabelText("退出房间");
fireEvent.click(exitBtn);
expect(onExit).toHaveBeenCalled();
});
it("shows manage button only for creator", () => {
const { rerender } = render(<TopNav roomId="ROOM01" isCreator={false} />);
expect(screen.queryByText("管理")).not.toBeInTheDocument();
rerender(<TopNav roomId="ROOM01" isCreator />);
expect(screen.getByText("管理")).toBeInTheDocument();
});
it("opens QR invite modal on button click", () => {
render(<TopNav roomId="ROOM01" />);
expect(screen.queryByTestId("qr-modal")).not.toBeInTheDocument();
fireEvent.click(screen.getByText(/ROOM01/));
expect(screen.getByTestId("qr-modal")).toBeInTheDocument();
});
it("opens manage modal on manage button click", () => {
render(<TopNav roomId="ROOM01" isCreator />);
expect(screen.queryByTestId("manage-modal")).not.toBeInTheDocument();
fireEvent.click(screen.getByText("管理"));
expect(screen.getByTestId("manage-modal")).toBeInTheDocument();
});
it("renders brand title", () => {
render(<TopNav roomId="ROOM01" />);
expect(screen.getByText("NoWhatever")).toBeInTheDocument();
expect(screen.getByText("别说随便")).toBeInTheDocument();
});
});
@@ -0,0 +1,63 @@
import { describe, it, expect, vi } from "vitest";
import { render, screen, fireEvent } from "@testing-library/react";
import WeekendTimeSelector from "./WeekendTimeSelector";
const mockOnConfirm = vi.fn();
const mockOnClose = vi.fn();
function renderSelector(props = {}) {
return render(
<WeekendTimeSelector
onConfirm={mockOnConfirm}
onClose={mockOnClose}
{...props}
/>,
);
}
describe("WeekendTimeSelector", () => {
it("renders time presets", () => {
renderSelector();
expect(screen.getByText("周六全天")).toBeInTheDocument();
expect(screen.getByText("周日全天")).toBeInTheDocument();
expect(screen.getByText("整个周末")).toBeInTheDocument();
});
it("renders hour selectors", () => {
renderSelector();
const selects = screen.getAllByRole("combobox");
expect(selects).toHaveLength(2);
});
it("shows confirm button", () => {
renderSelector();
expect(screen.getByText("生成周末计划")).toBeInTheDocument();
});
it("calls onConfirm with default config", () => {
renderSelector();
fireEvent.click(screen.getByText("生成周末计划"));
expect(mockOnConfirm).toHaveBeenCalledWith({
date: "周六",
startHour: 10,
endHour: 21,
});
});
it("calls onClose when close button clicked", () => {
renderSelector();
fireEvent.click(screen.getByLabelText("关闭"));
expect(mockOnClose).toHaveBeenCalled();
});
it("switches preset on click", () => {
renderSelector();
fireEvent.click(screen.getByText("周日全天"));
fireEvent.click(screen.getByText("生成周末计划"));
expect(mockOnConfirm).toHaveBeenCalledWith({
date: "周日",
startHour: 10,
endHour: 21,
});
});
});