Files
NextEdu/src/modules/announcements/components/announcement-card.test.tsx
SpecialX 2b95fd668b 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
2026-07-03 10:24:50 +08:00

304 lines
10 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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/navigationrouter.refresh 不做任何事
vi.mock("next/navigation", () => ({
useRouter: () => ({
refresh: vi.fn(),
push: vi.fn(),
replace: vi.fn(),
}),
}))
// Mock sonnertoast.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("渲染类型 badgeschool", () => {
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("a11yP2-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()
})
})
})