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:
SpecialX
2026-07-13 15:12:56 +08:00
parent d49d211425
commit 18d94c0cc2
98 changed files with 11977 additions and 112 deletions

View 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();
});
});