## 变更内容 ### 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
246 lines
8.1 KiB
TypeScript
246 lines
8.1 KiB
TypeScript
// 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>[] = [],
|
||
): 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={[]} />);
|
||
// 有记录时日期单元格内会有一个小圆点 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(<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");
|
||
});
|
||
});
|