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
This commit is contained in:
416
apps/parent-portal/src/components/ChildDetailPanel.test.tsx
Normal file
416
apps/parent-portal/src/components/ChildDetailPanel.test.tsx
Normal file
@@ -0,0 +1,416 @@
|
||||
// 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();
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user