// AttendanceCalendar 组件单测 // 依据:02-architecture-design.md §15.5 AttendanceCalendar 设计 // 覆盖:日历网格渲染 / 状态颜色标记 / 统计 / 空数据 / 月份标题 / 月份导航 import { describe, it, expect, beforeEach, afterEach, vi } from "vitest"; import { render, screen, fireEvent } from "@testing-library/react"; import { AttendanceCalendar } from "./AttendanceCalendar"; import type { AttendanceRecord } from "@/types"; // 固定当前时间为 2026-06-15(6 月有 30 天,1 日是周一) const FIXED_DATE = new Date("2026-06-15T12:00:00Z"); beforeEach(() => { vi.useFakeTimers(); vi.setSystemTime(FIXED_DATE); }); afterEach(() => { vi.useRealTimers(); }); // 生成 2026-06 的考勤记录 function makeJuneRecords( overrides: Partial[] = [], ): AttendanceRecord[] { const records: AttendanceRecord[] = []; // 6/1 ~ 6/5 出勤 for (let d = 1; d <= 5; d++) { records.push({ id: `att-${d}`, date: `2026-06-${String(d).padStart(2, "0")}`, status: "present", }); } // 6/8 迟到 records.push({ id: "att-late", date: "2026-06-08", status: "late", }); // 6/10 缺勤 records.push({ id: "att-absent", date: "2026-06-10", status: "absent", }); // 6/12 请假 records.push({ id: "att-leave", date: "2026-06-12", status: "leave", }); // 应用覆盖 for (const o of overrides) { const idx = records.findIndex((r) => r.date === o.date); if (idx >= 0) { records[idx] = { ...records[idx]!, ...o }; } else { records.push(o as AttendanceRecord); } } return records; } describe("AttendanceCalendar 标题与图例", () => { it("渲染当前年月标题(2026年6月)", () => { render(); expect(screen.getByText(/2026年6月考勤/)).toBeInTheDocument(); }); it("渲染 4 种状态图例标签", () => { render(); // 图例 + 统计行均含这些标签,使用 getAllByText 验证至少存在 expect(screen.getAllByText("出勤").length).toBeGreaterThanOrEqual(2); expect(screen.getAllByText("迟到").length).toBeGreaterThanOrEqual(2); expect(screen.getAllByText("缺勤").length).toBeGreaterThanOrEqual(2); expect(screen.getAllByText("请假").length).toBeGreaterThanOrEqual(2); }); it("渲染星期表头(日一二三四五六)", () => { render(); for (const w of ["日", "一", "二", "三", "四", "五", "六"]) { expect(screen.getByText(w)).toBeInTheDocument(); } }); }); describe("AttendanceCalendar 日历网格", () => { it("渲染 6 月所有日期 1-30", () => { render(); for (let d = 1; d <= 30; d++) { expect(screen.getByText(String(d))).toBeInTheDocument(); } }); it("空记录数组时所有日期无状态标记点", () => { const { container } = render(); // 有记录时日期单元格内会有一个小圆点 span(mt-0.5 + rounded-full) const dots = container.querySelectorAll(".mt-0\\.5"); expect(dots.length).toBe(0); }); it("有记录的日期显示状态标记点", () => { const records = makeJuneRecords(); const { container } = render(); const dots = container.querySelectorAll(".mt-0\\.5"); // 5(出勤) + 1(迟到) + 1(缺勤) + 1(请假) = 8 个标记点 expect(dots.length).toBe(8); }); it("有记录的日期单元格 title 属性显示状态标签", () => { const records: AttendanceRecord[] = [ { id: "a", date: "2026-06-01", status: "present" }, { id: "b", date: "2026-06-02", status: "late" }, ]; const { container } = render(); // 直接通过 title 属性定位日期单元格(避免与统计数字冲突) const presentCell = container.querySelector('[title="出勤"]'); expect(presentCell).not.toBeNull(); expect(presentCell).toHaveTextContent("1"); const lateCell = container.querySelector('[title="迟到"]'); expect(lateCell).not.toBeNull(); expect(lateCell).toHaveTextContent("2"); }); }); describe("AttendanceCalendar 统计", () => { it("统计各状态数量", () => { const records = makeJuneRecords(); render(); // 出勤 5 / 迟到 1 / 缺勤 1 / 请假 1 const presentStat = screen.getByText("5", { selector: ".text-success" }); expect(presentStat).toBeInTheDocument(); const lateStat = screen.getByText("1", { selector: ".text-warning" }); expect(lateStat).toBeInTheDocument(); const absentStat = screen.getByText("1", { selector: ".text-danger" }); expect(absentStat).toBeInTheDocument(); }); it("空记录时所有统计为 0", () => { render(); const stats = screen.getAllByText("0"); expect(stats.length).toBeGreaterThanOrEqual(3); }); it("渲染统计标签文案", () => { render(); // 统计行:出勤 / 迟到 / 缺勤 / 请假 const labels = screen.getAllByText(/出勤|迟到|缺勤|请假/); // 至少 4 个统计标签(图例也有这些标签,所以会更多) expect(labels.length).toBeGreaterThanOrEqual(4); }); }); describe("AttendanceCalendar 空数据", () => { it("空记录数组时仍渲染日历骨架", () => { render(); expect(screen.getByText(/2026年6月考勤/)).toBeInTheDocument(); // 日期 1-30 仍然渲染 expect(screen.getByText("15")).toBeInTheDocument(); }); }); describe("AttendanceCalendar 月份导航", () => { it("未传 onMonthChange 时不渲染导航按钮(向后兼容)", () => { render(); expect(screen.queryByRole("button", { name: "上一月" })).not.toBeInTheDocument(); expect(screen.queryByRole("button", { name: "下一月" })).not.toBeInTheDocument(); }); it("传入 onMonthChange 时渲染上一月/下一月按钮", () => { render( , ); expect(screen.getByRole("button", { name: "上一月" })).toBeInTheDocument(); expect(screen.getByRole("button", { name: "下一月" })).toBeInTheDocument(); }); it("传入 month prop 时渲染对应月份标题", () => { render( , ); expect(screen.getByText(/2026年5月考勤/)).toBeInTheDocument(); }); it("点击上一月按钮调用 onMonthChange 传 2026-05", () => { const onMonthChange = vi.fn(); render( , ); fireEvent.click(screen.getByRole("button", { name: "上一月" })); expect(onMonthChange).toHaveBeenCalledWith("2026-05"); }); it("点击下一月按钮调用 onMonthChange 传 2026-07", () => { const onMonthChange = vi.fn(); render( , ); fireEvent.click(screen.getByRole("button", { name: "下一月" })); expect(onMonthChange).toHaveBeenCalledWith("2026-07"); }); it("跨年导航:2026-01 上一月为 2025-12", () => { const onMonthChange = vi.fn(); render( , ); fireEvent.click(screen.getByRole("button", { name: "上一月" })); expect(onMonthChange).toHaveBeenCalledWith("2025-12"); }); it("跨年导航:2026-12 下一月为 2027-01", () => { const onMonthChange = vi.fn(); render( , ); fireEvent.click(screen.getByRole("button", { name: "下一月" })); expect(onMonthChange).toHaveBeenCalledWith("2027-01"); }); });