- Add announcement-card test for component testing - Add is-announcement-visible test and schema test for logic testing - Add announcement-list-skeleton for loading states - Add announcement-pagination for list pagination - Add announcements-service-context and default-announcements-service for service layer
304 lines
10 KiB
TypeScript
304 lines
10 KiB
TypeScript
import "@testing-library/jest-dom/vitest"
|
||
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest"
|
||
import { render, screen, fireEvent, cleanup } from "@testing-library/react"
|
||
|
||
import { AnnouncementCard } from "./announcement-card"
|
||
import { AnnouncementsServiceProvider } from "./announcements-service-context"
|
||
import type { Announcement, AnnouncementsService } from "../types"
|
||
import type { ActionState } from "@/shared/types/action-state"
|
||
|
||
// Mock next-intl:返回 key 作为字面量,便于断言
|
||
vi.mock("next-intl", () => ({
|
||
useTranslations: () => (key: string, params?: Record<string, string>) => {
|
||
if (!params) return key
|
||
return `${key}:${JSON.stringify(params)}`
|
||
},
|
||
}))
|
||
|
||
// Mock next/navigation:router.refresh 不做任何事
|
||
vi.mock("next/navigation", () => ({
|
||
useRouter: () => ({
|
||
refresh: vi.fn(),
|
||
push: vi.fn(),
|
||
replace: vi.fn(),
|
||
}),
|
||
}))
|
||
|
||
// Mock sonner:toast.success / error 收集调用
|
||
const toastMocks = { success: vi.fn(), error: vi.fn() }
|
||
vi.mock("sonner", () => ({
|
||
toast: {
|
||
success: (...args: unknown[]) => toastMocks.success(...args),
|
||
error: (...args: unknown[]) => toastMocks.error(...args),
|
||
},
|
||
}))
|
||
|
||
// Mock @/shared/lib/utils 的 formatDate 与 cn
|
||
vi.mock("@/shared/lib/utils", () => ({
|
||
cn: (...classes: (string | false | null | undefined)[]) =>
|
||
classes.filter(Boolean).join(" "),
|
||
formatDate: (iso: string) => `FMT:${iso}`,
|
||
}))
|
||
|
||
// Mock @/shared/components/ui/card:渲染 children 即可,避免样式依赖
|
||
vi.mock("@/shared/components/ui/card", () => ({
|
||
Card: ({ children, className }: { children: React.ReactNode; className?: string }) =>
|
||
<div data-testid="card" className={className}>{children}</div>,
|
||
CardHeader: ({ children }: { children: React.ReactNode }) =>
|
||
<div data-testid="card-header">{children}</div>,
|
||
CardTitle: ({ children }: { children: React.ReactNode }) =>
|
||
<h3 data-testid="card-title">{children}</h3>,
|
||
CardContent: ({ children }: { children: React.ReactNode }) =>
|
||
<div data-testid="card-content">{children}</div>,
|
||
}))
|
||
|
||
// Mock @/shared/components/ui/badge
|
||
vi.mock("@/shared/components/ui/badge", () => ({
|
||
Badge: ({
|
||
children,
|
||
variant,
|
||
className,
|
||
}: {
|
||
children: React.ReactNode
|
||
variant?: string
|
||
className?: string
|
||
}) => (
|
||
<span data-testid="badge" data-variant={variant} className={className}>
|
||
{children}
|
||
</span>
|
||
),
|
||
}))
|
||
|
||
const baseAnnouncement: Announcement = {
|
||
id: "ann-1",
|
||
title: "测试公告标题",
|
||
content: "测试公告内容",
|
||
type: "school",
|
||
status: "published",
|
||
targetGradeId: null,
|
||
targetClassId: null,
|
||
authorId: "user-1",
|
||
authorName: "管理员",
|
||
publishedAt: "2026-06-25T00:00:00.000Z",
|
||
isPinned: false,
|
||
createdAt: "2026-06-25T00:00:00.000Z",
|
||
updatedAt: "2026-06-25T00:00:00.000Z",
|
||
}
|
||
|
||
function createMockService(
|
||
overrides: Partial<AnnouncementsService> = {}
|
||
): AnnouncementsService {
|
||
const ok = <T,>(data: T): ActionState<T> => ({
|
||
success: true,
|
||
data,
|
||
message: "ok",
|
||
})
|
||
return {
|
||
create: vi.fn().mockResolvedValue(ok("new-id")),
|
||
update: vi.fn().mockResolvedValue(ok("ann-1")),
|
||
delete: vi.fn().mockResolvedValue(ok("ann-1")),
|
||
publish: vi.fn().mockResolvedValue(ok("ann-1")),
|
||
archive: vi.fn().mockResolvedValue(ok("ann-1")),
|
||
togglePin: vi.fn().mockResolvedValue(ok("ann-1")),
|
||
markRead: vi.fn().mockResolvedValue(ok("ann-1")),
|
||
getReadStatus: vi.fn().mockResolvedValue(ok({})),
|
||
...overrides,
|
||
}
|
||
}
|
||
|
||
function renderCard(
|
||
service: AnnouncementsService,
|
||
props: Partial<Parameters<typeof AnnouncementCard>[0]> = {}
|
||
) {
|
||
return render(
|
||
<AnnouncementsServiceProvider service={service}>
|
||
<AnnouncementCard announcement={baseAnnouncement} canManage {...props} />
|
||
</AnnouncementsServiceProvider>
|
||
)
|
||
}
|
||
|
||
describe("AnnouncementCard", () => {
|
||
beforeEach(() => {
|
||
vi.clearAllMocks()
|
||
})
|
||
|
||
afterEach(() => {
|
||
cleanup()
|
||
})
|
||
|
||
describe("基础渲染", () => {
|
||
it("渲染标题与内容", () => {
|
||
const service = createMockService()
|
||
renderCard(service)
|
||
expect(screen.getByText("测试公告标题")).toBeInTheDocument()
|
||
expect(screen.getByText("测试公告内容")).toBeInTheDocument()
|
||
})
|
||
|
||
it("渲染类型 badge(school)", () => {
|
||
const service = createMockService()
|
||
renderCard(service)
|
||
const badges = screen.getAllByTestId("badge")
|
||
const types = badges.map((b) => b.textContent)
|
||
expect(types).toContain("type.school")
|
||
})
|
||
|
||
it("未提供 href 时不渲染外层 Link", () => {
|
||
const service = createMockService()
|
||
const { container } = renderCard(service, { href: undefined })
|
||
// 没有 <a> 标签
|
||
expect(container.querySelector("a")).toBeNull()
|
||
})
|
||
})
|
||
|
||
describe("isRead 视觉区分", () => {
|
||
it("isRead=false 时应用 ring-2 ring-primary/40 类名", () => {
|
||
const service = createMockService()
|
||
const { container } = renderCard(service, { isRead: false })
|
||
const card = container.querySelector('[data-testid="card"]')
|
||
expect(card?.className).toContain("ring-2")
|
||
expect(card?.className).toContain("ring-primary/40")
|
||
})
|
||
|
||
it("isRead=true 时不应用 ring 类名", () => {
|
||
const service = createMockService()
|
||
const { container } = renderCard(service, { isRead: true })
|
||
const card = container.querySelector('[data-testid="card"]')
|
||
expect(card?.className).not.toContain("ring-2")
|
||
})
|
||
|
||
it("isRead=undefined 时不应用 ring 类名", () => {
|
||
const service = createMockService()
|
||
const { container } = renderCard(service, { isRead: undefined })
|
||
const card = container.querySelector('[data-testid="card"]')
|
||
expect(card?.className).not.toContain("ring-2")
|
||
})
|
||
|
||
it("isRead=false 时渲染未读 badge", () => {
|
||
const service = createMockService()
|
||
renderCard(service, { isRead: false })
|
||
const badges = screen.getAllByTestId("badge")
|
||
const texts = badges.map((b) => b.textContent)
|
||
expect(texts).toContain("status.unread")
|
||
})
|
||
})
|
||
|
||
describe("isPinned 视觉与交互", () => {
|
||
it("isPinned=true 时渲染已置顶 badge 与 Pin 图标 fill", () => {
|
||
const service = createMockService()
|
||
renderCard(service, {
|
||
announcement: { ...baseAnnouncement, isPinned: true },
|
||
})
|
||
const badges = screen.getAllByTestId("badge")
|
||
const texts = badges.map((b) => b.textContent)
|
||
expect(texts).toContain("status.pinned")
|
||
})
|
||
|
||
it("canManage=true 时渲染置顶按钮", () => {
|
||
const service = createMockService()
|
||
renderCard(service)
|
||
const pinButton = screen.getByRole("button", { name: "actions.pin" })
|
||
expect(pinButton).toBeInTheDocument()
|
||
})
|
||
|
||
it("canManage=false 时不渲染置顶按钮", () => {
|
||
const service = createMockService()
|
||
render(
|
||
<AnnouncementsServiceProvider service={service}>
|
||
<AnnouncementCard announcement={baseAnnouncement} canManage={false} />
|
||
</AnnouncementsServiceProvider>
|
||
)
|
||
expect(screen.queryByRole("button", { name: "actions.pin" })).toBeNull()
|
||
})
|
||
|
||
it("点击置顶按钮调用 service.togglePin", async () => {
|
||
const togglePin = vi.fn().mockResolvedValue({
|
||
success: true,
|
||
data: "ann-1",
|
||
message: "ok",
|
||
} satisfies ActionState<string>)
|
||
const service = createMockService({ togglePin })
|
||
renderCard(service)
|
||
|
||
const pinButton = screen.getByRole("button", { name: "actions.pin" })
|
||
await fireEvent.click(pinButton)
|
||
|
||
expect(togglePin).toHaveBeenCalledWith("ann-1")
|
||
// 乐观更新成功后显示 toast
|
||
expect(toastMocks.success).toHaveBeenCalled()
|
||
})
|
||
|
||
it("togglePin 返回失败时回滚 isPinned 并显示 toast.error", async () => {
|
||
const togglePin = vi.fn().mockResolvedValue({
|
||
success: false,
|
||
message: "权限不足",
|
||
} satisfies ActionState<string>)
|
||
const service = createMockService({ togglePin })
|
||
renderCard(service)
|
||
|
||
const pinButton = screen.getByRole("button", { name: "actions.pin" })
|
||
await fireEvent.click(pinButton)
|
||
|
||
expect(togglePin).toHaveBeenCalled()
|
||
expect(toastMocks.error).toHaveBeenCalledWith("权限不足")
|
||
})
|
||
|
||
it("togglePin 抛错时回滚并显示 toast.error", async () => {
|
||
const togglePin = vi.fn().mockRejectedValue(new Error("network"))
|
||
const service = createMockService({ togglePin })
|
||
renderCard(service)
|
||
|
||
const pinButton = screen.getByRole("button", { name: "actions.pin" })
|
||
await fireEvent.click(pinButton)
|
||
|
||
expect(togglePin).toHaveBeenCalled()
|
||
expect(toastMocks.error).toHaveBeenCalled()
|
||
})
|
||
})
|
||
|
||
describe("a11y(P2-1)", () => {
|
||
it("置顶按钮支持 Enter 键触发", async () => {
|
||
const togglePin = vi.fn().mockResolvedValue({
|
||
success: true,
|
||
data: "ann-1",
|
||
message: "ok",
|
||
} satisfies ActionState<string>)
|
||
const service = createMockService({ togglePin })
|
||
renderCard(service)
|
||
|
||
const pinButton = screen.getByRole("button", { name: "actions.pin" })
|
||
await fireEvent.keyDown(pinButton, { key: "Enter" })
|
||
|
||
expect(togglePin).toHaveBeenCalledWith("ann-1")
|
||
})
|
||
|
||
it("置顶按钮支持 Space 键触发", async () => {
|
||
const togglePin = vi.fn().mockResolvedValue({
|
||
success: true,
|
||
data: "ann-1",
|
||
message: "ok",
|
||
} satisfies ActionState<string>)
|
||
const service = createMockService({ togglePin })
|
||
renderCard(service)
|
||
|
||
const pinButton = screen.getByRole("button", { name: "actions.pin" })
|
||
await fireEvent.keyDown(pinButton, { key: " " })
|
||
|
||
expect(togglePin).toHaveBeenCalledWith("ann-1")
|
||
})
|
||
|
||
it("置顶按钮在 toggling 时禁用", async () => {
|
||
// 永不 resolve 的 promise,让 isToggling 永远为 true
|
||
const togglePin = vi.fn().mockReturnValue(new Promise(() => {}))
|
||
const service = createMockService({ togglePin })
|
||
renderCard(service)
|
||
|
||
const pinButton = screen.getByRole("button", { name: "actions.pin" })
|
||
fireEvent.click(pinButton)
|
||
|
||
// 等待下一帧让 setIsToggling(true) 生效
|
||
await new Promise((r) => setTimeout(r, 0))
|
||
expect(pinButton).toBeDisabled()
|
||
})
|
||
})
|
||
})
|