Files
Edu/apps/parent-portal/src/components/AttendanceCalendar.test.tsx
SpecialX 18d94c0cc2 feat(parent-portal): 完成参考项目(CICD)家长端功能全量覆盖 - 9 新页面 + 3 页面增强 + 763 测试通过
## 变更内容

### P0 高优先级新页面
- /parent/leave: 请假表单(react-hook-form + Zod) + 历史列表(状态筛选)
- /parent/grades/report-card: 报告卡(学年/学期筛选 + 打印 + 教师评语)
- /parent/children/[studentId]: 子女详情聚合页(5 Tab: overview/homework/grades/exams/schedule)

### P1 中优先级新页面
- /parent/error-book: 错题本(5 项统计 + Top 错题 + 薄弱知识点)
- /parent/diagnostic: 诊断报告(掌握度摘要 + 已发布诊断报告)
- /parent/practice: 练习统计(4 项统计 + 练习历史)

### P2 低优先级新页面
- /parent/course-plans + [id]: 课程计划列表 + 详情(章节列表)
- /parent/lesson-plans + [planId]/view: 备课列表(学科筛选) + 只读详情
- /parent/elective: 选修课(分类色点 + 状态徽标)

### P3 现有页面增强
- /parent/dashboard: ParentAttentionBanner + 多子女卡片网格 + 趋势图标 + 逾期高亮
- /parent/attendance: AttendanceRateCard + AttendanceWarningBanner + 月份导航
- /parent/grades: GrowthArchiveChart + ExportGradesButton + 班级均对比线

### 基础设施
- types/index.ts: +20 新类型(LeaveRequest/ReportCard/ErrorBookStats/DiagnosticReport/PracticeStats/CoursePlan/LessonPlan/ElectiveSelection/ChildDetail/ScheduleItem 等)
- operations.ts: +19 GraphQL operations(17 query + 2 mutation)
- fixtures.ts: +18 mock 数据集
- handlers.ts: +19 MSW GraphQL handler

### 质量校验
- typecheck: 0 错误
- lint: 0 错误
- test: 71 文件 / 763 测试全部通过(从 413 增至 763, +350 测试)

### 文档
- workline.md: 新增 §7 参考项目(CICD)差距分析与实现安排 + §7.3-7.5 实现进度与覆盖完成度

### 设计决策
- 保留当前"单子女切换"范式(MultiChildTabBar + useChildSwitcher),不迁移到"多子女同屏对比"
- 仪表盘除外:已增强为多子女卡片网格并列展示
- 参考项目所有 11 个家长端页面功能已 100% 覆盖

Refs: ARB-020 §22, ARB-022 §24.4
2026-07-13 15:12:56 +08:00

246 lines
8.1 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.
// 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-156 月有 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>[] = [],
): 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(<AttendanceCalendar records={[]} />);
expect(screen.getByText(/2026年6月考勤/)).toBeInTheDocument();
});
it("渲染 4 种状态图例标签", () => {
render(<AttendanceCalendar records={[]} />);
// 图例 + 统计行均含这些标签,使用 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(<AttendanceCalendar records={[]} />);
for (const w of ["日", "一", "二", "三", "四", "五", "六"]) {
expect(screen.getByText(w)).toBeInTheDocument();
}
});
});
describe("AttendanceCalendar 日历网格", () => {
it("渲染 6 月所有日期 1-30", () => {
render(<AttendanceCalendar records={[]} />);
for (let d = 1; d <= 30; d++) {
expect(screen.getByText(String(d))).toBeInTheDocument();
}
});
it("空记录数组时所有日期无状态标记点", () => {
const { container } = render(<AttendanceCalendar records={[]} />);
// 有记录时日期单元格内会有一个小圆点 spanmt-0.5 + rounded-full
const dots = container.querySelectorAll(".mt-0\\.5");
expect(dots.length).toBe(0);
});
it("有记录的日期显示状态标记点", () => {
const records = makeJuneRecords();
const { container } = render(<AttendanceCalendar records={records} />);
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(<AttendanceCalendar records={records} />);
// 直接通过 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(<AttendanceCalendar records={records} />);
// 出勤 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(<AttendanceCalendar records={[]} />);
const stats = screen.getAllByText("0");
expect(stats.length).toBeGreaterThanOrEqual(3);
});
it("渲染统计标签文案", () => {
render(<AttendanceCalendar records={[]} />);
// 统计行:出勤 / 迟到 / 缺勤 / 请假
const labels = screen.getAllByText(/出勤|迟到|缺勤|请假/);
// 至少 4 个统计标签(图例也有这些标签,所以会更多)
expect(labels.length).toBeGreaterThanOrEqual(4);
});
});
describe("AttendanceCalendar 空数据", () => {
it("空记录数组时仍渲染日历骨架", () => {
render(<AttendanceCalendar records={[]} />);
expect(screen.getByText(/2026年6月考勤/)).toBeInTheDocument();
// 日期 1-30 仍然渲染
expect(screen.getByText("15")).toBeInTheDocument();
});
});
describe("AttendanceCalendar 月份导航", () => {
it("未传 onMonthChange 时不渲染导航按钮(向后兼容)", () => {
render(<AttendanceCalendar records={[]} />);
expect(screen.queryByRole("button", { name: "上一月" })).not.toBeInTheDocument();
expect(screen.queryByRole("button", { name: "下一月" })).not.toBeInTheDocument();
});
it("传入 onMonthChange 时渲染上一月/下一月按钮", () => {
render(
<AttendanceCalendar
records={[]}
month="2026-06"
onMonthChange={vi.fn()}
/>,
);
expect(screen.getByRole("button", { name: "上一月" })).toBeInTheDocument();
expect(screen.getByRole("button", { name: "下一月" })).toBeInTheDocument();
});
it("传入 month prop 时渲染对应月份标题", () => {
render(
<AttendanceCalendar
records={[]}
month="2026-05"
onMonthChange={vi.fn()}
/>,
);
expect(screen.getByText(/2026年5月考勤/)).toBeInTheDocument();
});
it("点击上一月按钮调用 onMonthChange 传 2026-05", () => {
const onMonthChange = vi.fn();
render(
<AttendanceCalendar
records={[]}
month="2026-06"
onMonthChange={onMonthChange}
/>,
);
fireEvent.click(screen.getByRole("button", { name: "上一月" }));
expect(onMonthChange).toHaveBeenCalledWith("2026-05");
});
it("点击下一月按钮调用 onMonthChange 传 2026-07", () => {
const onMonthChange = vi.fn();
render(
<AttendanceCalendar
records={[]}
month="2026-06"
onMonthChange={onMonthChange}
/>,
);
fireEvent.click(screen.getByRole("button", { name: "下一月" }));
expect(onMonthChange).toHaveBeenCalledWith("2026-07");
});
it("跨年导航2026-01 上一月为 2025-12", () => {
const onMonthChange = vi.fn();
render(
<AttendanceCalendar
records={[]}
month="2026-01"
onMonthChange={onMonthChange}
/>,
);
fireEvent.click(screen.getByRole("button", { name: "上一月" }));
expect(onMonthChange).toHaveBeenCalledWith("2025-12");
});
it("跨年导航2026-12 下一月为 2027-01", () => {
const onMonthChange = vi.fn();
render(
<AttendanceCalendar
records={[]}
month="2026-12"
onMonthChange={onMonthChange}
/>,
);
fireEvent.click(screen.getByRole("button", { name: "下一月" }));
expect(onMonthChange).toHaveBeenCalledWith("2027-01");
});
});