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) => { 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 }) =>
{children}
, CardHeader: ({ children }: { children: React.ReactNode }) =>
{children}
, CardTitle: ({ children }: { children: React.ReactNode }) =>

{children}

, CardContent: ({ children }: { children: React.ReactNode }) =>
{children}
, })) // Mock @/shared/components/ui/badge vi.mock("@/shared/components/ui/badge", () => ({ Badge: ({ children, variant, className, }: { children: React.ReactNode variant?: string className?: string }) => ( {children} ), })) 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 { const ok = (data: T): ActionState => ({ 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[0]> = {} ) { return render( ) } 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 }) // 没有 标签 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( ) 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) 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) 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) 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) 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() }) }) })