Files
Edu/apps/parent-portal/src/components/ChildDetailPanel.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

417 lines
11 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.
// ChildDetailPanel 组件单测
// 依据02-architecture-design.md §15 组件设计
// 覆盖Tab 导航 / Tab 切换 / overview / homework / grades / exams / schedule 五个视图
import { describe, it, expect, vi } from "vitest";
import { render, screen } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { ChildDetailPanel } from "./ChildDetailPanel";
import type { ChildDetail } from "@/types";
// mock next/link 为普通 <a>,便于断言
vi.mock("next/link", () => ({
default: ({
href,
children,
...rest
}: {
href: string;
children: React.ReactNode;
[key: string]: unknown;
}) => (
<a href={href} {...rest}>
{children}
</a>
),
}));
const mockChildDetail: ChildDetail = {
childId: "student-001",
basicInfo: {
name: "张小明",
avatar: "https://example.com/avatar.png",
grade: "grade.7",
className: "初一(1)班",
schoolName: "实验中学",
relation: "父亲",
},
todaySchedule: [
{
id: "sch-today-1",
dayOfWeek: 1,
period: 1,
subject: "数学",
teacherName: "王老师",
classroom: "初一(1)班教室",
startTime: "08:00",
endTime: "08:45",
},
{
id: "sch-today-2",
dayOfWeek: 1,
period: 2,
subject: "语文",
teacherName: "李老师",
classroom: "初一(1)班教室",
startTime: "08:55",
endTime: "09:40",
},
],
weeklySchedule: [
{
id: "sch-w-1",
dayOfWeek: 1,
period: 1,
subject: "数学",
teacherName: "王老师",
classroom: "初一(1)班教室",
startTime: "08:00",
endTime: "08:45",
},
{
id: "sch-w-2",
dayOfWeek: 2,
period: 1,
subject: "英语",
teacherName: "张老师",
classroom: "初一(1)班教室",
startTime: "08:00",
endTime: "08:45",
},
],
homeworkSummary: {
pendingCount: 3,
overdueCount: 1,
submittedCount: 5,
gradedCount: 8,
},
gradeSummary: {
avgScore: 90,
classRank: 5,
classSize: 45,
trend: "up",
},
examResults: {
upcoming: 2,
completed: 4,
avgScore: 88,
},
};
describe("ChildDetailPanel Tab 导航", () => {
it("渲染 5 个 Tab概览/作业/成绩/考试/课表)", () => {
render(
<ChildDetailPanel
childDetail={mockChildDetail}
activeTab="overview"
onTabChange={vi.fn()}
/>,
);
expect(screen.getByRole("tablist", { name: "子女详情视图" })).toBeInTheDocument();
expect(screen.getByRole("tab", { name: "概览" })).toBeInTheDocument();
expect(screen.getByRole("tab", { name: "作业" })).toBeInTheDocument();
expect(screen.getByRole("tab", { name: "成绩" })).toBeInTheDocument();
expect(screen.getByRole("tab", { name: "考试" })).toBeInTheDocument();
expect(screen.getByRole("tab", { name: "课表" })).toBeInTheDocument();
});
it("activeTab=overview 时概览 tab 选中", () => {
render(
<ChildDetailPanel
childDetail={mockChildDetail}
activeTab="overview"
onTabChange={vi.fn()}
/>,
);
expect(screen.getByRole("tab", { name: "概览" })).toHaveAttribute("aria-selected", "true");
expect(screen.getByRole("tab", { name: "作业" })).toHaveAttribute("aria-selected", "false");
});
it("点击 Tab 调用 onTabChange", async () => {
const user = userEvent.setup();
const onTabChange = vi.fn();
render(
<ChildDetailPanel
childDetail={mockChildDetail}
activeTab="overview"
onTabChange={onTabChange}
/>,
);
await user.click(screen.getByRole("tab", { name: "作业" }));
expect(onTabChange).toHaveBeenCalledWith("homework");
});
it("点击成绩 Tab 调用 onTabChange 传 grades", async () => {
const user = userEvent.setup();
const onTabChange = vi.fn();
render(
<ChildDetailPanel
childDetail={mockChildDetail}
activeTab="overview"
onTabChange={onTabChange}
/>,
);
await user.click(screen.getByRole("tab", { name: "成绩" }));
expect(onTabChange).toHaveBeenCalledWith("grades");
});
});
describe("ChildDetailPanel overview Tab", () => {
it("渲染三个 Section作业摘要/成绩摘要/今日课表)", () => {
render(
<ChildDetailPanel
childDetail={mockChildDetail}
activeTab="overview"
onTabChange={vi.fn()}
/>,
);
expect(screen.getByText("作业摘要")).toBeInTheDocument();
expect(screen.getByText("成绩摘要")).toBeInTheDocument();
expect(screen.getByText("今日课表")).toBeInTheDocument();
});
it("渲染作业摘要数据(待完成/已逾期/已提交/已评分)", () => {
render(
<ChildDetailPanel
childDetail={mockChildDetail}
activeTab="overview"
onTabChange={vi.fn()}
/>,
);
expect(screen.getByText("待完成")).toBeInTheDocument();
expect(screen.getByText("已逾期")).toBeInTheDocument();
expect(screen.getByText("已提交")).toBeInTheDocument();
expect(screen.getByText("已评分")).toBeInTheDocument();
// 验证数值3/1/5/8
expect(screen.getByText("3")).toBeInTheDocument();
expect(screen.getByText("1")).toBeInTheDocument();
expect(screen.getByText("5")).toBeInTheDocument();
expect(screen.getByText("8")).toBeInTheDocument();
});
it("渲染成绩摘要数据(平均分/班级排名/趋势)", () => {
render(
<ChildDetailPanel
childDetail={mockChildDetail}
activeTab="overview"
onTabChange={vi.fn()}
/>,
);
expect(screen.getByText("平均分")).toBeInTheDocument();
expect(screen.getByText("班级排名")).toBeInTheDocument();
expect(screen.getByText("趋势")).toBeInTheDocument();
// 趋势 up → "↑ 上升"
expect(screen.getByText("↑ 上升")).toBeInTheDocument();
});
it("渲染今日课表数据", () => {
render(
<ChildDetailPanel
childDetail={mockChildDetail}
activeTab="overview"
onTabChange={vi.fn()}
/>,
);
expect(screen.getByText("数学")).toBeInTheDocument();
expect(screen.getByText("语文")).toBeInTheDocument();
// 教师姓名与教室渲染在同一 span如 "王老师 · 初一(1)班教室"),用 regex 验证
expect(screen.getByText(/王老师/)).toBeInTheDocument();
expect(screen.getByText(/李老师/)).toBeInTheDocument();
});
});
describe("ChildDetailPanel homework Tab", () => {
it("渲染作业摘要 Section", () => {
render(
<ChildDetailPanel
childDetail={mockChildDetail}
activeTab="homework"
onTabChange={vi.fn()}
/>,
);
expect(screen.getByText("作业摘要")).toBeInTheDocument();
});
it("渲染查看全部作业链接", () => {
render(
<ChildDetailPanel
childDetail={mockChildDetail}
activeTab="homework"
onTabChange={vi.fn()}
/>,
);
const link = screen.getByRole("link", { name: /查看全部作业/ });
expect(link).toHaveAttribute("href", "/parent/homework");
});
});
describe("ChildDetailPanel grades Tab", () => {
it("渲染成绩摘要 Section", () => {
render(
<ChildDetailPanel
childDetail={mockChildDetail}
activeTab="grades"
onTabChange={vi.fn()}
/>,
);
expect(screen.getByText("成绩摘要")).toBeInTheDocument();
});
it("渲染查看全部成绩链接", () => {
render(
<ChildDetailPanel
childDetail={mockChildDetail}
activeTab="grades"
onTabChange={vi.fn()}
/>,
);
const link = screen.getByRole("link", { name: /查看全部成绩/ });
expect(link).toHaveAttribute("href", "/parent/grades");
});
});
describe("ChildDetailPanel exams Tab", () => {
it("渲染考试摘要 Section", () => {
render(
<ChildDetailPanel
childDetail={mockChildDetail}
activeTab="exams"
onTabChange={vi.fn()}
/>,
);
expect(screen.getByText("考试摘要")).toBeInTheDocument();
});
it("渲染考试统计数据(待考/已完成/平均分)", () => {
render(
<ChildDetailPanel
childDetail={mockChildDetail}
activeTab="exams"
onTabChange={vi.fn()}
/>,
);
expect(screen.getByText("待考")).toBeInTheDocument();
expect(screen.getByText("已完成")).toBeInTheDocument();
expect(screen.getByText("平均分")).toBeInTheDocument();
});
it("渲染查看全部考试链接", () => {
render(
<ChildDetailPanel
childDetail={mockChildDetail}
activeTab="exams"
onTabChange={vi.fn()}
/>,
);
const link = screen.getByRole("link", { name: /查看全部考试/ });
expect(link).toHaveAttribute("href", "/parent/exams");
});
});
describe("ChildDetailPanel schedule Tab", () => {
it("渲染今日课表 Section", () => {
render(
<ChildDetailPanel
childDetail={mockChildDetail}
activeTab="schedule"
onTabChange={vi.fn()}
/>,
);
expect(screen.getByText(/今日课表/)).toBeInTheDocument();
});
it("渲染周课表 Section", () => {
render(
<ChildDetailPanel
childDetail={mockChildDetail}
activeTab="schedule"
onTabChange={vi.fn()}
/>,
);
expect(screen.getByText("周课表")).toBeInTheDocument();
});
it("渲染周课表分组(周一/周二)", () => {
render(
<ChildDetailPanel
childDetail={mockChildDetail}
activeTab="schedule"
onTabChange={vi.fn()}
/>,
);
// 周课表含 dayOfWeek 1周一和 2周二
expect(screen.getByText("周一")).toBeInTheDocument();
expect(screen.getByText("周二")).toBeInTheDocument();
});
});
describe("ChildDetailPanel 空态", () => {
it("今日课表为空时显示今日无课程", () => {
const emptySchedule: ChildDetail = {
...mockChildDetail,
todaySchedule: [],
};
render(
<ChildDetailPanel
childDetail={emptySchedule}
activeTab="overview"
onTabChange={vi.fn()}
/>,
);
expect(screen.getByText("今日无课程")).toBeInTheDocument();
});
it("周课表为空时显示暂无周课表", () => {
const emptyWeekly: ChildDetail = {
...mockChildDetail,
weeklySchedule: [],
};
render(
<ChildDetailPanel
childDetail={emptyWeekly}
activeTab="schedule"
onTabChange={vi.fn()}
/>,
);
expect(screen.getByText("暂无周课表")).toBeInTheDocument();
});
});
describe("ChildDetailPanel 趋势显示", () => {
it("gradeSummary.trend=down 时显示 ↓ 下降", () => {
const downTrend: ChildDetail = {
...mockChildDetail,
gradeSummary: {
...mockChildDetail.gradeSummary,
trend: "down",
},
};
render(
<ChildDetailPanel
childDetail={downTrend}
activeTab="overview"
onTabChange={vi.fn()}
/>,
);
expect(screen.getByText("↓ 下降")).toBeInTheDocument();
});
it("gradeSummary.trend=stable 时显示 → 平稳", () => {
const stableTrend: ChildDetail = {
...mockChildDetail,
gradeSummary: {
...mockChildDetail.gradeSummary,
trend: "stable",
},
};
render(
<ChildDetailPanel
childDetail={stableTrend}
activeTab="overview"
onTabChange={vi.fn()}
/>,
);
expect(screen.getByText("→ 平稳")).toBeInTheDocument();
});
});