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:
SpecialX
2026-07-03 10:24:50 +08:00
parent 2859ef74f2
commit 2b95fd668b
15 changed files with 1539 additions and 206 deletions

View 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/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()
})
})
})