feat(announcements): add tests, skeleton, pagination, and service context
- 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
This commit is contained in:
303
src/modules/announcements/components/announcement-card.test.tsx
Normal file
303
src/modules/announcements/components/announcement-card.test.tsx
Normal file
@@ -0,0 +1,303 @@
|
||||
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()
|
||||
})
|
||||
})
|
||||
})
|
||||
Reference in New Issue
Block a user