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,84 @@
// useAcademicYears Hook 单测
// 依据02-architecture-design.md §4.2 GraphQL 接入
// 覆盖:加载态 / 成功返回 / 错误态
import {
describe,
it,
expect,
beforeAll,
afterAll,
afterEach,
} from "vitest";
import { renderHook, waitFor, cleanup } from "@testing-library/react";
import {
Provider as UrqlProvider,
Client,
cacheExchange,
fetchExchange,
} from "urql";
import { graphql, HttpResponse } from "msw";
import type { ReactNode } from "react";
import { server } from "@/test/mocks/server";
import { useAcademicYears } from "./useAcademicYears";
function createTestClient(): Client {
return new Client({
url: "/api/v1/parent/v1/graphql",
exchanges: [cacheExchange, fetchExchange],
fetchOptions: { headers: { "X-Requested-With": "XMLHttpRequest" } },
});
}
function renderUseAcademicYears() {
const client = createTestClient();
const wrapper = ({ children }: { children: ReactNode }) => (
<UrqlProvider value={client}>{children}</UrqlProvider>
);
return renderHook(() => useAcademicYears(), { wrapper });
}
beforeAll(() => server.listen({ onUnhandledRequest: "error" }));
afterEach(() => {
cleanup();
server.resetHandlers();
});
afterAll(() => server.close());
describe("useAcademicYears", () => {
it("加载中 loading 为 true", () => {
const { result } = renderUseAcademicYears();
expect(result.current.loading).toBe(true);
expect(result.current.academicYears).toEqual([]);
});
it("加载成功返回学年列表", async () => {
const { result } = renderUseAcademicYears();
await waitFor(() => {
expect(result.current.academicYears.length).toBeGreaterThan(0);
});
expect(result.current.academicYears).toHaveLength(2);
expect(result.current.academicYears[0]!.id).toBe("ay-2025-2026");
expect(result.current.academicYears[0]!.name).toBe("2025-2026");
expect(result.current.loading).toBe(false);
expect(result.current.error).toBeUndefined();
});
it("GraphQL 返回错误时透传 error", async () => {
server.resetHandlers(
graphql.query("AcademicYears", () =>
HttpResponse.json(
{ errors: [{ message: "学年查询失败" }] },
{ status: 200 },
),
),
);
const { result } = renderUseAcademicYears();
await waitFor(() => {
expect(result.current.error).toBeDefined();
});
expect(result.current.academicYears).toEqual([]);
expect(result.current.error?.message).toContain("学年查询失败");
});
});

View File

@@ -0,0 +1,28 @@
// useAcademicYears获取学年列表
// 依据02-architecture-design.md §4.2 GraphQL 接入
"use client";
import { useQuery, type CombinedError } from "urql";
import { ACADEMIC_YEARS } from "@/lib/graphql/operations";
import type { AcademicYear } from "@/types";
interface AcademicYearsResponse {
academicYears: AcademicYear[];
}
export function useAcademicYears(): {
academicYears: AcademicYear[];
loading: boolean;
error: CombinedError | undefined;
} {
const [result] = useQuery<AcademicYearsResponse>({
query: ACADEMIC_YEARS,
});
return {
academicYears: result.data?.academicYears ?? [],
loading: result.fetching,
error: result.error,
};
}

View File

@@ -1,10 +1,12 @@
// useChildAttendance获取子女考勤记录
// 依据02-architecture-design.md §4.2 GraphQL 接入
// - 支持可选 month 参数YYYY-MM 格式),默认当前月
// - 支持可选 childId 参数,默认使用 store 中的 currentChildId
"use client";
import { useMemo } from "react";
import { useQuery } from "urql";
import { useQuery, type CombinedError } from "urql";
import { CHILD_ATTENDANCE } from "@/lib/graphql/operations";
import type { AttendanceRecord } from "@/types";
import { useChildStore } from "@/store/child-store";
@@ -13,6 +15,11 @@ interface ChildAttendanceResponse {
childAttendance: AttendanceRecord[];
}
interface UseChildAttendanceOptions {
childId?: string;
month?: string; // YYYY-MM 格式
}
function getCurrentMonthRange(): { startDate: string; endDate: string } {
const now = new Date();
const start = new Date(now.getFullYear(), now.getMonth(), 1);
@@ -23,14 +30,37 @@ function getCurrentMonthRange(): { startDate: string; endDate: string } {
};
}
export function useChildAttendance() {
function getMonthRange(month: string): { startDate: string; endDate: string } {
const [yearStr, monthStr] = month.split("-");
const year = parseInt(yearStr ?? "0", 10);
const monthIdx = parseInt(monthStr ?? "1", 10) - 1;
const start = new Date(year, monthIdx, 1);
const end = new Date(year, monthIdx + 1, 0);
return {
startDate: start.toISOString().slice(0, 10),
endDate: end.toISOString().slice(0, 10),
};
}
export function useChildAttendance(
options?: UseChildAttendanceOptions,
): {
attendance: AttendanceRecord[];
loading: boolean;
error: CombinedError | undefined;
} {
const currentChildId = useChildStore((s) => s.currentChildId);
const { startDate, endDate } = useMemo(getCurrentMonthRange, []);
const targetChildId = options?.childId ?? currentChildId;
const { startDate, endDate } = useMemo(
() =>
options?.month ? getMonthRange(options.month) : getCurrentMonthRange(),
[options?.month],
);
const [result] = useQuery<ChildAttendanceResponse>({
query: CHILD_ATTENDANCE,
variables: { childId: currentChildId, startDate, endDate },
pause: !currentChildId,
variables: { childId: targetChildId, startDate, endDate },
pause: !targetChildId,
});
return {

View File

@@ -0,0 +1,131 @@
// useChildCoursePlanDetail Hook 单测
// 依据02-architecture-design.md §4.2 GraphQL 接入
// 覆盖:无 currentChildId / 无 planId 暂停 / 加载态 / 成功返回详情(含章节)/ 错误态 / 详情为 null
import {
describe,
it,
expect,
beforeAll,
afterAll,
afterEach,
beforeEach,
} from "vitest";
import { renderHook, waitFor, cleanup } from "@testing-library/react";
import {
Provider as UrqlProvider,
Client,
cacheExchange,
fetchExchange,
} from "urql";
import { graphql, HttpResponse } from "msw";
import type { ReactNode } from "react";
import { server } from "@/test/mocks/server";
import { useChildStore } from "@/store/child-store";
import { useChildCoursePlanDetail } from "./useChildCoursePlanDetail";
function createTestClient(): Client {
return new Client({
url: "/api/v1/parent/v1/graphql",
exchanges: [cacheExchange, fetchExchange],
fetchOptions: { headers: { "X-Requested-With": "XMLHttpRequest" } },
});
}
function renderUseChildCoursePlanDetail(planId: string) {
const client = createTestClient();
const wrapper = ({ children }: { children: ReactNode }) => (
<UrqlProvider value={client}>{children}</UrqlProvider>
);
return renderHook(() => useChildCoursePlanDetail(planId), { wrapper });
}
beforeAll(() => server.listen({ onUnhandledRequest: "error" }));
afterEach(() => {
cleanup();
server.resetHandlers();
});
afterAll(() => server.close());
beforeEach(() => {
localStorage.clear();
useChildStore.setState({
children: [],
currentChildId: null,
isLoading: false,
});
});
describe("useChildCoursePlanDetail", () => {
it("无 currentChildId 时暂停查询,返回 null", () => {
const { result } = renderUseChildCoursePlanDetail("cp-001");
expect(result.current.coursePlan).toBeNull();
expect(result.current.loading).toBe(false);
expect(result.current.error).toBeUndefined();
});
it("无 planId 时暂停查询,返回 null", () => {
useChildStore.setState({ currentChildId: "student-001" });
const { result } = renderUseChildCoursePlanDetail("");
expect(result.current.coursePlan).toBeNull();
expect(result.current.loading).toBe(false);
expect(result.current.error).toBeUndefined();
});
it("有 currentChildId 和 planId 时加载成功返回课程计划详情", async () => {
useChildStore.setState({ currentChildId: "student-001" });
const { result } = renderUseChildCoursePlanDetail("cp-001");
await waitFor(() => {
expect(result.current.coursePlan).not.toBeNull();
});
expect(result.current.coursePlan?.id).toBe("cp-001");
expect(result.current.coursePlan?.title).toBe("初一数学下学期课程计划");
expect(result.current.coursePlan?.chapters).toHaveLength(4);
expect(result.current.coursePlan?.chapters[0]!.title).toBe(
"第一章 一元二次方程",
);
expect(result.current.coursePlan?.chapters[0]!.sortOrder).toBe(1);
expect(result.current.loading).toBe(false);
expect(result.current.error).toBeUndefined();
});
it("加载中 loading 为 true", () => {
useChildStore.setState({ currentChildId: "student-001" });
const { result } = renderUseChildCoursePlanDetail("cp-001");
expect(result.current.loading).toBe(true);
});
it("GraphQL 返回错误时透传 error", async () => {
useChildStore.setState({ currentChildId: "student-001" });
server.resetHandlers(
graphql.query("ChildCoursePlanDetail", () =>
HttpResponse.json(
{ errors: [{ message: "课程计划详情查询失败" }] },
{ status: 200 },
),
),
);
const { result } = renderUseChildCoursePlanDetail("cp-001");
await waitFor(() => {
expect(result.current.error).toBeDefined();
});
expect(result.current.coursePlan).toBeNull();
expect(result.current.error?.message).toContain("课程计划详情查询失败");
});
it("详情为 null 时返回 null", async () => {
useChildStore.setState({ currentChildId: "student-001" });
server.resetHandlers(
graphql.query("ChildCoursePlanDetail", () =>
HttpResponse.json({ data: { childCoursePlanDetail: null } }),
),
);
const { result } = renderUseChildCoursePlanDetail("not-exist");
await waitFor(() => {
expect(result.current.loading).toBe(false);
});
expect(result.current.coursePlan).toBeNull();
});
});

View File

@@ -0,0 +1,34 @@
// useChildCoursePlanDetail获取子女课程计划详情含章节
// 依据02-architecture-design.md §4.2 GraphQL 接入
// 对标 /parent/course-plans/[id] 页面(家长只读视角)
"use client";
import { useQuery, type CombinedError } from "urql";
import { CHILD_COURSE_PLAN_DETAIL } from "@/lib/graphql/operations";
import type { CoursePlanDetail } from "@/types";
import { useChildStore } from "@/store/child-store";
interface ChildCoursePlanDetailResponse {
childCoursePlanDetail: CoursePlanDetail;
}
export function useChildCoursePlanDetail(planId: string): {
coursePlan: CoursePlanDetail | null;
loading: boolean;
error: CombinedError | undefined;
} {
const currentChildId = useChildStore((s) => s.currentChildId);
const [result] = useQuery<ChildCoursePlanDetailResponse>({
query: CHILD_COURSE_PLAN_DETAIL,
variables: { childId: currentChildId, planId },
pause: !currentChildId || !planId,
});
return {
coursePlan: result.data?.childCoursePlanDetail ?? null,
loading: result.fetching,
error: result.error,
};
}

View File

@@ -0,0 +1,121 @@
// useChildCoursePlans Hook 单测
// 依据02-architecture-design.md §4.2 GraphQL 接入
// 覆盖:无 currentChildId 暂停 / 加载态 / 成功返回 / 错误态 / 空列表
import {
describe,
it,
expect,
beforeAll,
afterAll,
afterEach,
beforeEach,
} from "vitest";
import { renderHook, waitFor, cleanup } from "@testing-library/react";
import {
Provider as UrqlProvider,
Client,
cacheExchange,
fetchExchange,
} from "urql";
import { graphql, HttpResponse } from "msw";
import type { ReactNode } from "react";
import { server } from "@/test/mocks/server";
import { useChildStore } from "@/store/child-store";
import { useChildCoursePlans } from "./useChildCoursePlans";
function createTestClient(): Client {
return new Client({
url: "/api/v1/parent/v1/graphql",
exchanges: [cacheExchange, fetchExchange],
fetchOptions: { headers: { "X-Requested-With": "XMLHttpRequest" } },
});
}
function renderUseChildCoursePlans() {
const client = createTestClient();
const wrapper = ({ children }: { children: ReactNode }) => (
<UrqlProvider value={client}>{children}</UrqlProvider>
);
return renderHook(() => useChildCoursePlans(), { wrapper });
}
beforeAll(() => server.listen({ onUnhandledRequest: "error" }));
afterEach(() => {
cleanup();
server.resetHandlers();
});
afterAll(() => server.close());
beforeEach(() => {
localStorage.clear();
useChildStore.setState({
children: [],
currentChildId: null,
isLoading: false,
});
});
describe("useChildCoursePlans", () => {
it("无 currentChildId 时暂停查询,返回空数组", () => {
const { result } = renderUseChildCoursePlans();
expect(result.current.coursePlans).toEqual([]);
expect(result.current.loading).toBe(false);
expect(result.current.error).toBeUndefined();
});
it("有 currentChildId 时加载成功返回课程计划列表", async () => {
useChildStore.setState({ currentChildId: "student-001" });
const { result } = renderUseChildCoursePlans();
await waitFor(() => {
expect(result.current.coursePlans.length).toBeGreaterThan(0);
});
expect(result.current.coursePlans).toHaveLength(3);
expect(result.current.coursePlans[0]!.title).toBe("初一数学下学期课程计划");
expect(result.current.coursePlans[0]!.subject).toBe("数学");
expect(result.current.coursePlans[0]!.status).toBe("active");
expect(result.current.coursePlans[0]!.chapterCount).toBe(8);
expect(result.current.loading).toBe(false);
expect(result.current.error).toBeUndefined();
});
it("加载中 loading 为 true", () => {
useChildStore.setState({ currentChildId: "student-001" });
const { result } = renderUseChildCoursePlans();
expect(result.current.loading).toBe(true);
});
it("GraphQL 返回错误时透传 error", async () => {
useChildStore.setState({ currentChildId: "student-001" });
server.resetHandlers(
graphql.query("ChildCoursePlans", () =>
HttpResponse.json(
{ errors: [{ message: "课程计划查询失败" }] },
{ status: 200 },
),
),
);
const { result } = renderUseChildCoursePlans();
await waitFor(() => {
expect(result.current.error).toBeDefined();
});
expect(result.current.coursePlans).toEqual([]);
expect(result.current.error?.message).toContain("课程计划查询失败");
});
it("返回空课程计划列表", async () => {
useChildStore.setState({ currentChildId: "student-001" });
server.resetHandlers(
graphql.query("ChildCoursePlans", () =>
HttpResponse.json({ data: { childCoursePlans: [] } }),
),
);
const { result } = renderUseChildCoursePlans();
await waitFor(() => {
expect(result.current.loading).toBe(false);
});
expect(result.current.coursePlans).toEqual([]);
});
});

View File

@@ -0,0 +1,34 @@
// useChildCoursePlans获取子女课程计划列表
// 依据02-architecture-design.md §4.2 GraphQL 接入
// 对标 /parent/course-plans 页面(家长只读视角)
"use client";
import { useQuery, type CombinedError } from "urql";
import { CHILD_COURSE_PLANS } from "@/lib/graphql/operations";
import type { CoursePlan } from "@/types";
import { useChildStore } from "@/store/child-store";
interface ChildCoursePlansResponse {
childCoursePlans: CoursePlan[];
}
export function useChildCoursePlans(): {
coursePlans: CoursePlan[];
loading: boolean;
error: CombinedError | undefined;
} {
const currentChildId = useChildStore((s) => s.currentChildId);
const [result] = useQuery<ChildCoursePlansResponse>({
query: CHILD_COURSE_PLANS,
variables: { childId: currentChildId },
pause: !currentChildId,
});
return {
coursePlans: result.data?.childCoursePlans ?? [],
loading: result.fetching,
error: result.error,
};
}

View File

@@ -0,0 +1,144 @@
// useChildDetail Hook 单测
// 依据02-architecture-design.md §4.2 GraphQL 接入
// 覆盖:无 childId 暂停 / 加载态 / 成功返回 / 变量传递 / 错误态
import {
describe,
it,
expect,
beforeAll,
afterAll,
afterEach,
} from "vitest";
import { renderHook, waitFor, cleanup } from "@testing-library/react";
import {
Provider as UrqlProvider,
Client,
cacheExchange,
fetchExchange,
} from "urql";
import { graphql, HttpResponse } from "msw";
import type { ReactNode } from "react";
import { server } from "@/test/mocks/server";
import { useChildDetail } from "./useChildDetail";
function createTestClient(): Client {
return new Client({
url: "/api/v1/parent/v1/graphql",
exchanges: [cacheExchange, fetchExchange],
fetchOptions: { headers: { "X-Requested-With": "XMLHttpRequest" } },
});
}
function renderUseChildDetail(childId: string) {
const client = createTestClient();
const wrapper = ({ children }: { children: ReactNode }) => (
<UrqlProvider value={client}>{children}</UrqlProvider>
);
return renderHook(() => useChildDetail(childId), { wrapper });
}
beforeAll(() => server.listen({ onUnhandledRequest: "error" }));
afterEach(() => {
cleanup();
server.resetHandlers();
});
afterAll(() => server.close());
describe("useChildDetail", () => {
it("无 childId 时暂停查询,返回 null", () => {
const { result } = renderUseChildDetail("");
expect(result.current.childDetail).toBeNull();
expect(result.current.loading).toBe(false);
expect(result.current.error).toBeUndefined();
});
it("有 childId 时加载成功返回子女详情", async () => {
const { result } = renderUseChildDetail("student-001");
await waitFor(() => {
expect(result.current.childDetail).not.toBeNull();
});
expect(result.current.childDetail?.childId).toBe("student-001");
expect(result.current.childDetail?.basicInfo.name).toBe("张小明");
expect(result.current.childDetail?.basicInfo.className).toBe("初一(1)班");
expect(result.current.childDetail?.todaySchedule.length).toBeGreaterThan(0);
expect(result.current.childDetail?.weeklySchedule.length).toBeGreaterThan(0);
expect(result.current.childDetail?.homeworkSummary).toBeDefined();
expect(result.current.childDetail?.gradeSummary).toBeDefined();
expect(result.current.childDetail?.examResults).toBeDefined();
expect(result.current.loading).toBe(false);
expect(result.current.error).toBeUndefined();
});
it("加载中 loading 为 true", () => {
const { result } = renderUseChildDetail("student-001");
expect(result.current.loading).toBe(true);
});
it("传入的 childId 变量正确传递", async () => {
let capturedVars: unknown = null;
server.resetHandlers(
graphql.query("ChildDetail", ({ variables }) => {
capturedVars = variables;
return HttpResponse.json({
data: {
childDetail: {
childId: variables.childId,
basicInfo: {
name: "张小红",
grade: "grade.5",
className: "五年级(2)班",
schoolName: "实验小学",
relation: "父亲",
},
todaySchedule: [],
weeklySchedule: [],
homeworkSummary: {
pendingCount: 0,
overdueCount: 0,
submittedCount: 0,
gradedCount: 0,
},
gradeSummary: {
avgScore: 0,
classRank: 0,
classSize: 0,
trend: "stable",
},
examResults: {
upcoming: 0,
completed: 0,
avgScore: 0,
},
},
},
});
}),
);
const { result } = renderUseChildDetail("student-002");
await waitFor(() => {
expect(result.current.childDetail).not.toBeNull();
});
expect(capturedVars).toEqual({ childId: "student-002" });
expect(result.current.childDetail?.basicInfo.name).toBe("张小红");
});
it("GraphQL 返回错误时透传 error", async () => {
server.resetHandlers(
graphql.query("ChildDetail", () =>
HttpResponse.json(
{ errors: [{ message: "子女详情查询失败" }] },
{ status: 200 },
),
),
);
const { result } = renderUseChildDetail("student-001");
await waitFor(() => {
expect(result.current.error).toBeDefined();
});
expect(result.current.childDetail).toBeNull();
expect(result.current.error?.message).toContain("子女详情查询失败");
});
});

View File

@@ -0,0 +1,30 @@
// useChildDetail获取子女详情聚合数据
// 依据02-architecture-design.md §4.2 GraphQL 接入
"use client";
import { useQuery, type CombinedError } from "urql";
import { CHILD_DETAIL } from "@/lib/graphql/operations";
import type { ChildDetail } from "@/types";
interface ChildDetailResponse {
childDetail: ChildDetail;
}
export function useChildDetail(childId: string): {
childDetail: ChildDetail | null;
loading: boolean;
error: CombinedError | undefined;
} {
const [result] = useQuery<ChildDetailResponse>({
query: CHILD_DETAIL,
variables: { childId },
pause: !childId,
});
return {
childDetail: result.data?.childDetail ?? null,
loading: result.fetching,
error: result.error,
};
}

View File

@@ -0,0 +1,149 @@
// useChildDiagnostic Hook 单测
// 依据02-architecture-design.md §4.2 GraphQL 接入
// 覆盖:无 currentChildId 暂停 / 加载态 / 成功返回 / 错误态 / 两个查询合并
import {
describe,
it,
expect,
beforeAll,
afterAll,
afterEach,
beforeEach,
} from "vitest";
import { renderHook, waitFor, cleanup } from "@testing-library/react";
import {
Provider as UrqlProvider,
Client,
cacheExchange,
fetchExchange,
} from "urql";
import { graphql, HttpResponse } from "msw";
import type { ReactNode } from "react";
import { server } from "@/test/mocks/server";
import { useChildStore } from "@/store/child-store";
import { useChildDiagnostic } from "./useChildDiagnostic";
function createTestClient(): Client {
return new Client({
url: "/api/v1/parent/v1/graphql",
exchanges: [cacheExchange, fetchExchange],
fetchOptions: { headers: { "X-Requested-With": "XMLHttpRequest" } },
});
}
function renderUseChildDiagnostic() {
const client = createTestClient();
const wrapper = ({ children }: { children: ReactNode }) => (
<UrqlProvider value={client}>{children}</UrqlProvider>
);
return renderHook(() => useChildDiagnostic(), { wrapper });
}
beforeAll(() => server.listen({ onUnhandledRequest: "error" }));
afterEach(() => {
cleanup();
server.resetHandlers();
});
afterAll(() => server.close());
beforeEach(() => {
localStorage.clear();
useChildStore.setState({
children: [],
currentChildId: null,
isLoading: false,
});
});
describe("useChildDiagnostic", () => {
it("无 currentChildId 时暂停查询,返回空数据", () => {
const { result } = renderUseChildDiagnostic();
expect(result.current.masterySummary).toBeNull();
expect(result.current.reports).toEqual([]);
expect(result.current.loading).toBe(false);
expect(result.current.error).toBeUndefined();
});
it("有 currentChildId 时加载成功返回诊断数据", async () => {
useChildStore.setState({ currentChildId: "student-001" });
const { result } = renderUseChildDiagnostic();
await waitFor(() => {
expect(result.current.masterySummary).not.toBeNull();
});
expect(result.current.masterySummary?.overallMastery).toBe(0.78);
expect(result.current.masterySummary?.totalKps).toBe(86);
expect(result.current.masterySummary?.masteredKps).toBe(67);
expect(result.current.masterySummary?.subjectMastery).toHaveLength(4);
expect(result.current.reports).toHaveLength(2);
expect(result.current.reports[0]!.title).toBe(
"2026 春季学期学情诊断报告",
);
expect(result.current.reports[0]!.status).toBe("published");
expect(result.current.loading).toBe(false);
expect(result.current.error).toBeUndefined();
});
it("加载中 loading 为 true", () => {
useChildStore.setState({ currentChildId: "student-001" });
const { result } = renderUseChildDiagnostic();
expect(result.current.loading).toBe(true);
});
it("ChildMasterySummary 返回错误时透传 error", async () => {
useChildStore.setState({ currentChildId: "student-001" });
server.use(
graphql.query("ChildMasterySummary", () =>
HttpResponse.json(
{ errors: [{ message: "掌握度查询失败" }] },
{ status: 200 },
),
),
);
const { result } = renderUseChildDiagnostic();
await waitFor(() => {
expect(result.current.error).toBeDefined();
});
expect(result.current.masterySummary).toBeNull();
expect(result.current.error?.message).toContain("掌握度查询失败");
});
it("ChildDiagnosticReports 返回错误时透传 error", async () => {
useChildStore.setState({ currentChildId: "student-001" });
server.use(
graphql.query("ChildDiagnosticReports", () =>
HttpResponse.json(
{ errors: [{ message: "诊断报告查询失败" }] },
{ status: 200 },
),
),
);
const { result } = renderUseChildDiagnostic();
await waitFor(() => {
expect(result.current.error).toBeDefined();
});
expect(result.current.reports).toEqual([]);
expect(result.current.error?.message).toContain("诊断报告查询失败");
});
it("两个查询返回空数据时返回空数组与 null masterySummary", async () => {
useChildStore.setState({ currentChildId: "student-001" });
server.use(
graphql.query("ChildMasterySummary", () =>
HttpResponse.json({ data: { childMasterySummary: null } }),
),
graphql.query("ChildDiagnosticReports", () =>
HttpResponse.json({ data: { childDiagnosticReports: [] } }),
),
);
const { result } = renderUseChildDiagnostic();
await waitFor(() => {
expect(result.current.loading).toBe(false);
});
expect(result.current.masterySummary).toBeNull();
expect(result.current.reports).toEqual([]);
});
});

View File

@@ -0,0 +1,49 @@
// useChildDiagnostic获取子女诊断报告数据掌握度摘要 + 诊断报告列表)
// 依据02-architecture-design.md §4.2 GraphQL 接入
// 同时发起两个查询,合并为统一返回值
"use client";
import { useQuery, type CombinedError } from "urql";
import {
CHILD_MASTERY_SUMMARY,
CHILD_DIAGNOSTIC_REPORTS,
} from "@/lib/graphql/operations";
import type { MasterySummary, DiagnosticReport } from "@/types";
import { useChildStore } from "@/store/child-store";
interface ChildMasterySummaryResponse {
childMasterySummary: MasterySummary;
}
interface ChildDiagnosticReportsResponse {
childDiagnosticReports: DiagnosticReport[];
}
export function useChildDiagnostic(): {
masterySummary: MasterySummary | null;
reports: DiagnosticReport[];
loading: boolean;
error: CombinedError | undefined;
} {
const currentChildId = useChildStore((s) => s.currentChildId);
const [masteryResult] = useQuery<ChildMasterySummaryResponse>({
query: CHILD_MASTERY_SUMMARY,
variables: { childId: currentChildId },
pause: !currentChildId,
});
const [reportsResult] = useQuery<ChildDiagnosticReportsResponse>({
query: CHILD_DIAGNOSTIC_REPORTS,
variables: { childId: currentChildId },
pause: !currentChildId,
});
return {
masterySummary: masteryResult.data?.childMasterySummary ?? null,
reports: reportsResult.data?.childDiagnosticReports ?? [],
loading: masteryResult.fetching || reportsResult.fetching,
error: masteryResult.error ?? reportsResult.error,
};
}

View File

@@ -0,0 +1,120 @@
// useChildElective Hook 单测
// 依据02-architecture-design.md §4.2 GraphQL 接入
// 覆盖:无 currentChildId 暂停 / 加载态 / 成功返回 / 错误态 / 空列表
import {
describe,
it,
expect,
beforeAll,
afterAll,
afterEach,
beforeEach,
} from "vitest";
import { renderHook, waitFor, cleanup } from "@testing-library/react";
import {
Provider as UrqlProvider,
Client,
cacheExchange,
fetchExchange,
} from "urql";
import { graphql, HttpResponse } from "msw";
import type { ReactNode } from "react";
import { server } from "@/test/mocks/server";
import { useChildStore } from "@/store/child-store";
import { useChildElective } from "./useChildElective";
function createTestClient(): Client {
return new Client({
url: "/api/v1/parent/v1/graphql",
exchanges: [cacheExchange, fetchExchange],
fetchOptions: { headers: { "X-Requested-With": "XMLHttpRequest" } },
});
}
function renderUseChildElective() {
const client = createTestClient();
const wrapper = ({ children }: { children: ReactNode }) => (
<UrqlProvider value={client}>{children}</UrqlProvider>
);
return renderHook(() => useChildElective(), { wrapper });
}
beforeAll(() => server.listen({ onUnhandledRequest: "error" }));
afterEach(() => {
cleanup();
server.resetHandlers();
});
afterAll(() => server.close());
beforeEach(() => {
localStorage.clear();
useChildStore.setState({
children: [],
currentChildId: null,
isLoading: false,
});
});
describe("useChildElective", () => {
it("无 currentChildId 时暂停查询,返回空数组", () => {
const { result } = renderUseChildElective();
expect(result.current.electiveSelections).toEqual([]);
expect(result.current.loading).toBe(false);
expect(result.current.error).toBeUndefined();
});
it("有 currentChildId 时加载成功返回选修课列表", async () => {
useChildStore.setState({ currentChildId: "student-001" });
const { result } = renderUseChildElective();
await waitFor(() => {
expect(result.current.electiveSelections.length).toBeGreaterThan(0);
});
expect(result.current.electiveSelections).toHaveLength(3);
expect(result.current.electiveSelections[0]!.courseName).toBe("创意写作");
expect(result.current.electiveSelections[0]!.category).toBe("language");
expect(result.current.electiveSelections[0]!.status).toBe("enrolled");
expect(result.current.loading).toBe(false);
expect(result.current.error).toBeUndefined();
});
it("加载中 loading 为 true", () => {
useChildStore.setState({ currentChildId: "student-001" });
const { result } = renderUseChildElective();
expect(result.current.loading).toBe(true);
});
it("GraphQL 返回错误时透传 error", async () => {
useChildStore.setState({ currentChildId: "student-001" });
server.resetHandlers(
graphql.query("ChildElective", () =>
HttpResponse.json(
{ errors: [{ message: "选修课查询失败" }] },
{ status: 200 },
),
),
);
const { result } = renderUseChildElective();
await waitFor(() => {
expect(result.current.error).toBeDefined();
});
expect(result.current.electiveSelections).toEqual([]);
expect(result.current.error?.message).toContain("选修课查询失败");
});
it("返回空选修课列表", async () => {
useChildStore.setState({ currentChildId: "student-001" });
server.resetHandlers(
graphql.query("ChildElective", () =>
HttpResponse.json({ data: { childElective: [] } }),
),
);
const { result } = renderUseChildElective();
await waitFor(() => {
expect(result.current.loading).toBe(false);
});
expect(result.current.electiveSelections).toEqual([]);
});
});

View File

@@ -0,0 +1,34 @@
// useChildElective获取子女选修课列表
// 依据02-architecture-design.md §4.2 GraphQL 接入
// 对标 /parent/elective 页面(家长只读视角)
"use client";
import { useQuery, type CombinedError } from "urql";
import { CHILD_ELECTIVE } from "@/lib/graphql/operations";
import type { ElectiveSelection } from "@/types";
import { useChildStore } from "@/store/child-store";
interface ChildElectiveResponse {
childElective: ElectiveSelection[];
}
export function useChildElective(): {
electiveSelections: ElectiveSelection[];
loading: boolean;
error: CombinedError | undefined;
} {
const currentChildId = useChildStore((s) => s.currentChildId);
const [result] = useQuery<ChildElectiveResponse>({
query: CHILD_ELECTIVE,
variables: { childId: currentChildId },
pause: !currentChildId,
});
return {
electiveSelections: result.data?.childElective ?? [],
loading: result.fetching,
error: result.error,
};
}

View File

@@ -0,0 +1,174 @@
// useChildErrorBook Hook 单测
// 依据02-architecture-design.md §4.2 GraphQL 接入
// 覆盖:无 currentChildId 暂停 / 加载态 / 成功返回 / 错误态 / 三个查询合并
import {
describe,
it,
expect,
beforeAll,
afterAll,
afterEach,
beforeEach,
} from "vitest";
import { renderHook, waitFor, cleanup } from "@testing-library/react";
import {
Provider as UrqlProvider,
Client,
cacheExchange,
fetchExchange,
} from "urql";
import { graphql, HttpResponse } from "msw";
import type { ReactNode } from "react";
import { server } from "@/test/mocks/server";
import { useChildStore } from "@/store/child-store";
import { useChildErrorBook } from "./useChildErrorBook";
function createTestClient(): Client {
return new Client({
url: "/api/v1/parent/v1/graphql",
exchanges: [cacheExchange, fetchExchange],
fetchOptions: { headers: { "X-Requested-With": "XMLHttpRequest" } },
});
}
function renderUseChildErrorBook() {
const client = createTestClient();
const wrapper = ({ children }: { children: ReactNode }) => (
<UrqlProvider value={client}>{children}</UrqlProvider>
);
return renderHook(() => useChildErrorBook(), { wrapper });
}
beforeAll(() => server.listen({ onUnhandledRequest: "error" }));
afterEach(() => {
cleanup();
server.resetHandlers();
});
afterAll(() => server.close());
beforeEach(() => {
localStorage.clear();
useChildStore.setState({
children: [],
currentChildId: null,
isLoading: false,
});
});
describe("useChildErrorBook", () => {
it("无 currentChildId 时暂停查询,返回空数据", () => {
const { result } = renderUseChildErrorBook();
expect(result.current.stats).toBeNull();
expect(result.current.topWrongQuestions).toEqual([]);
expect(result.current.weakKps).toEqual([]);
expect(result.current.loading).toBe(false);
expect(result.current.error).toBeUndefined();
});
it("有 currentChildId 时加载成功返回错题本数据", async () => {
useChildStore.setState({ currentChildId: "student-001" });
const { result } = renderUseChildErrorBook();
await waitFor(() => {
expect(result.current.stats).not.toBeNull();
});
expect(result.current.stats?.totalCount).toBe(128);
expect(result.current.stats?.newCount).toBe(23);
expect(result.current.stats?.learningCount).toBe(45);
expect(result.current.stats?.masteredCount).toBe(60);
expect(result.current.stats?.dueReviewCount).toBe(18);
expect(result.current.stats?.masteredRate).toBe(0.469);
expect(result.current.topWrongQuestions).toHaveLength(4);
expect(result.current.topWrongQuestions[0]!.subject).toBe("数学");
expect(result.current.weakKps).toHaveLength(4);
expect(result.current.weakKps[0]!.knowledgePointName).toBe("一元二次方程");
expect(result.current.loading).toBe(false);
expect(result.current.error).toBeUndefined();
});
it("加载中 loading 为 true", () => {
useChildStore.setState({ currentChildId: "student-001" });
const { result } = renderUseChildErrorBook();
expect(result.current.loading).toBe(true);
});
it("ChildErrorBookStats 返回错误时透传 error", async () => {
useChildStore.setState({ currentChildId: "student-001" });
server.use(
graphql.query("ChildErrorBookStats", () =>
HttpResponse.json(
{ errors: [{ message: "错题统计查询失败" }] },
{ status: 200 },
),
),
);
const { result } = renderUseChildErrorBook();
await waitFor(() => {
expect(result.current.error).toBeDefined();
});
expect(result.current.stats).toBeNull();
expect(result.current.error?.message).toContain("错题统计查询失败");
});
it("ChildTopWrongQuestions 返回错误时透传 error", async () => {
useChildStore.setState({ currentChildId: "student-001" });
server.use(
graphql.query("ChildTopWrongQuestions", () =>
HttpResponse.json(
{ errors: [{ message: "高频错题查询失败" }] },
{ status: 200 },
),
),
);
const { result } = renderUseChildErrorBook();
await waitFor(() => {
expect(result.current.error).toBeDefined();
});
expect(result.current.topWrongQuestions).toEqual([]);
expect(result.current.error?.message).toContain("高频错题查询失败");
});
it("ChildWeakKps 返回错误时透传 error", async () => {
useChildStore.setState({ currentChildId: "student-001" });
server.use(
graphql.query("ChildWeakKps", () =>
HttpResponse.json(
{ errors: [{ message: "薄弱知识点查询失败" }] },
{ status: 200 },
),
),
);
const { result } = renderUseChildErrorBook();
await waitFor(() => {
expect(result.current.error).toBeDefined();
});
expect(result.current.weakKps).toEqual([]);
expect(result.current.error?.message).toContain("薄弱知识点查询失败");
});
it("三个查询返回空数据时返回空数组与 null stats", async () => {
useChildStore.setState({ currentChildId: "student-001" });
server.use(
graphql.query("ChildErrorBookStats", () =>
HttpResponse.json({ data: { childErrorBookStats: null } }),
),
graphql.query("ChildTopWrongQuestions", () =>
HttpResponse.json({ data: { childTopWrongQuestions: [] } }),
),
graphql.query("ChildWeakKps", () =>
HttpResponse.json({ data: { childWeakKps: [] } }),
),
);
const { result } = renderUseChildErrorBook();
await waitFor(() => {
expect(result.current.loading).toBe(false);
});
expect(result.current.stats).toBeNull();
expect(result.current.topWrongQuestions).toEqual([]);
expect(result.current.weakKps).toEqual([]);
});
});

View File

@@ -0,0 +1,65 @@
// useChildErrorBook获取子女错题本数据统计 + Top 错题 + 薄弱知识点)
// 依据02-architecture-design.md §4.2 GraphQL 接入
// 同时发起三个查询,合并为统一返回值
"use client";
import { useQuery, type CombinedError } from "urql";
import {
CHILD_ERROR_BOOK_STATS,
CHILD_TOP_WRONG_QUESTIONS,
CHILD_WEAK_KPS,
} from "@/lib/graphql/operations";
import type { ErrorBookStats, TopWrongQuestion, WeakKp } from "@/types";
import { useChildStore } from "@/store/child-store";
interface ChildErrorBookStatsResponse {
childErrorBookStats: ErrorBookStats;
}
interface ChildTopWrongQuestionsResponse {
childTopWrongQuestions: TopWrongQuestion[];
}
interface ChildWeakKpsResponse {
childWeakKps: WeakKp[];
}
export function useChildErrorBook(): {
stats: ErrorBookStats | null;
topWrongQuestions: TopWrongQuestion[];
weakKps: WeakKp[];
loading: boolean;
error: CombinedError | undefined;
} {
const currentChildId = useChildStore((s) => s.currentChildId);
const [statsResult] = useQuery<ChildErrorBookStatsResponse>({
query: CHILD_ERROR_BOOK_STATS,
variables: { childId: currentChildId },
pause: !currentChildId,
});
const [topWrongResult] = useQuery<ChildTopWrongQuestionsResponse>({
query: CHILD_TOP_WRONG_QUESTIONS,
variables: { childId: currentChildId, limit: 10 },
pause: !currentChildId,
});
const [weakKpsResult] = useQuery<ChildWeakKpsResponse>({
query: CHILD_WEAK_KPS,
variables: { childId: currentChildId, limit: 10 },
pause: !currentChildId,
});
return {
stats: statsResult.data?.childErrorBookStats ?? null,
topWrongQuestions: topWrongResult.data?.childTopWrongQuestions ?? [],
weakKps: weakKpsResult.data?.childWeakKps ?? [],
loading:
statsResult.fetching ||
topWrongResult.fetching ||
weakKpsResult.fetching,
error: statsResult.error ?? topWrongResult.error ?? weakKpsResult.error,
};
}

View File

@@ -0,0 +1,206 @@
// useChildGrowthArchive Hook 单测
// 依据02-architecture-design.md §4.2 GraphQL 接入
// 覆盖:无 currentChildId 暂停 / 加载态 / 成功返回 / subject 过滤 / 错误态 / 可选 childId
import {
describe,
it,
expect,
beforeAll,
afterAll,
afterEach,
beforeEach,
} from "vitest";
import { renderHook, waitFor, cleanup } from "@testing-library/react";
import {
Provider as UrqlProvider,
Client,
cacheExchange,
fetchExchange,
} from "urql";
import { graphql, HttpResponse } from "msw";
import type { ReactNode } from "react";
import { server } from "@/test/mocks/server";
import { useChildStore } from "@/store/child-store";
import { useChildGrowthArchive } from "./useChildGrowthArchive";
function createTestClient(): Client {
return new Client({
url: "/api/v1/parent/v1/graphql",
exchanges: [cacheExchange, fetchExchange],
fetchOptions: { headers: { "X-Requested-With": "XMLHttpRequest" } },
});
}
function renderUseChildGrowthArchive(options?: {
childId?: string;
subject?: string;
}) {
const client = createTestClient();
const wrapper = ({ children }: { children: ReactNode }) => (
<UrqlProvider value={client}>{children}</UrqlProvider>
);
return renderHook(() => useChildGrowthArchive(options), { wrapper });
}
beforeAll(() => server.listen({ onUnhandledRequest: "error" }));
afterEach(() => {
cleanup();
server.resetHandlers();
});
afterAll(() => server.close());
beforeEach(() => {
localStorage.clear();
useChildStore.setState({
children: [],
currentChildId: null,
isLoading: false,
});
});
describe("useChildGrowthArchive", () => {
it("无 currentChildId 时暂停查询,返回 null", () => {
const { result } = renderUseChildGrowthArchive();
expect(result.current.growthArchive).toBeNull();
expect(result.current.loading).toBe(false);
expect(result.current.error).toBeUndefined();
});
it("有 currentChildId 时加载成功返回成长档案", async () => {
useChildStore.setState({ currentChildId: "student-001" });
const { result } = renderUseChildGrowthArchive();
await waitFor(() => {
expect(result.current.growthArchive).not.toBeNull();
});
expect(result.current.growthArchive?.childId).toBe("student-001");
expect(result.current.growthArchive?.subject).toBe("数学");
expect(result.current.growthArchive?.dataPoints).toHaveLength(4);
expect(result.current.growthArchive?.dataPoints[0]?.studentScore).toBe(82);
expect(result.current.growthArchive?.dataPoints[0]?.classAverage).toBe(76);
expect(result.current.loading).toBe(false);
expect(result.current.error).toBeUndefined();
});
it("加载中 loading 为 true", () => {
useChildStore.setState({ currentChildId: "student-001" });
const { result } = renderUseChildGrowthArchive();
expect(result.current.loading).toBe(true);
});
it("传入 subject 时变量携带 subject", async () => {
useChildStore.setState({ currentChildId: "student-001" });
let capturedVars: unknown = null;
server.resetHandlers(
graphql.query("ChildGrowthArchive", ({ variables }) => {
capturedVars = variables;
return HttpResponse.json({
data: {
childGrowthArchive: {
childId: "student-001",
subject: "数学",
dataPoints: [],
},
},
});
}),
);
const { result } = renderUseChildGrowthArchive({ subject: "数学" });
await waitFor(() => {
expect(result.current.loading).toBe(false);
});
expect(capturedVars).toEqual({
childId: "student-001",
subject: "数学",
});
});
it("未传 subject 时变量 subject 为 null", async () => {
useChildStore.setState({ currentChildId: "student-001" });
let capturedVars: unknown = null;
server.resetHandlers(
graphql.query("ChildGrowthArchive", ({ variables }) => {
capturedVars = variables;
return HttpResponse.json({
data: {
childGrowthArchive: {
childId: "student-001",
subject: "数学",
dataPoints: [],
},
},
});
}),
);
const { result } = renderUseChildGrowthArchive();
await waitFor(() => {
expect(result.current.loading).toBe(false);
});
expect(capturedVars).toEqual({
childId: "student-001",
subject: null,
});
});
it("传入 childId 覆盖 store 中的 currentChildId", async () => {
useChildStore.setState({ currentChildId: "student-001" });
let capturedVars: unknown = null;
server.resetHandlers(
graphql.query("ChildGrowthArchive", ({ variables }) => {
capturedVars = variables;
return HttpResponse.json({
data: {
childGrowthArchive: {
childId: variables.childId as string,
subject: "数学",
dataPoints: [],
},
},
});
}),
);
const { result } = renderUseChildGrowthArchive({ childId: "student-002" });
await waitFor(() => {
expect(result.current.growthArchive?.childId).toBe("student-002");
});
const vars = capturedVars as { childId: string };
expect(vars.childId).toBe("student-002");
});
it("GraphQL 返回错误时透传 error", async () => {
useChildStore.setState({ currentChildId: "student-001" });
server.resetHandlers(
graphql.query("ChildGrowthArchive", () =>
HttpResponse.json(
{ errors: [{ message: "成长档案查询失败" }] },
{ status: 200 },
),
),
);
const { result } = renderUseChildGrowthArchive();
await waitFor(() => {
expect(result.current.error).toBeDefined();
});
expect(result.current.growthArchive).toBeNull();
expect(result.current.error?.message).toContain("成长档案查询失败");
});
it("查询返回空数据时 growthArchive 为 null", async () => {
useChildStore.setState({ currentChildId: "student-001" });
server.resetHandlers(
graphql.query("ChildGrowthArchive", () =>
HttpResponse.json({ data: { childGrowthArchive: null } }),
),
);
const { result } = renderUseChildGrowthArchive();
await waitFor(() => {
expect(result.current.loading).toBe(false);
});
expect(result.current.growthArchive).toBeNull();
});
});

View File

@@ -0,0 +1,47 @@
// useChildGrowthArchive获取子女成长档案班级均对比线
// 依据02-architecture-design.md §4.2 GraphQL 接入
// - 依赖 currentChildId从 Zustand store 读取)
// - 支持可选 childId 参数(多子女场景)
// - 支持可选 subject 参数(按学科筛选)
"use client";
import { useQuery, type CombinedError } from "urql";
import { CHILD_GROWTH_ARCHIVE } from "@/lib/graphql/operations";
import type { GrowthArchive } from "@/types";
import { useChildStore } from "@/store/child-store";
interface ChildGrowthArchiveResponse {
childGrowthArchive: GrowthArchive;
}
interface UseChildGrowthArchiveOptions {
childId?: string;
subject?: string;
}
export function useChildGrowthArchive(
options?: UseChildGrowthArchiveOptions,
): {
growthArchive: GrowthArchive | null;
loading: boolean;
error: CombinedError | undefined;
} {
const currentChildId = useChildStore((s) => s.currentChildId);
const targetChildId = options?.childId ?? currentChildId;
const [result] = useQuery<ChildGrowthArchiveResponse>({
query: CHILD_GROWTH_ARCHIVE,
variables: {
childId: targetChildId,
subject: options?.subject ?? null,
},
pause: !targetChildId,
});
return {
growthArchive: result.data?.childGrowthArchive ?? null,
loading: result.fetching,
error: result.error,
};
}

View File

@@ -0,0 +1,142 @@
// useChildLeaveRequests Hook 单测
// 依据02-architecture-design.md §4.2 GraphQL 接入
// 覆盖:无 currentChildId 暂停 / 加载态 / 成功返回 / refetch / 错误态
import {
describe,
it,
expect,
beforeAll,
afterAll,
afterEach,
beforeEach,
} from "vitest";
import { renderHook, waitFor, cleanup } from "@testing-library/react";
import {
Provider as UrqlProvider,
Client,
cacheExchange,
fetchExchange,
} from "urql";
import { graphql, HttpResponse } from "msw";
import type { ReactNode } from "react";
import { server } from "@/test/mocks/server";
import { useChildStore } from "@/store/child-store";
import { useChildLeaveRequests } from "./useChildLeaveRequests";
function createTestClient(): Client {
return new Client({
url: "/api/v1/parent/v1/graphql",
exchanges: [cacheExchange, fetchExchange],
fetchOptions: { headers: { "X-Requested-With": "XMLHttpRequest" } },
});
}
function renderUseChildLeaveRequests() {
const client = createTestClient();
const wrapper = ({ children }: { children: ReactNode }) => (
<UrqlProvider value={client}>{children}</UrqlProvider>
);
return renderHook(() => useChildLeaveRequests(), { wrapper });
}
beforeAll(() => server.listen({ onUnhandledRequest: "error" }));
afterEach(() => {
cleanup();
server.resetHandlers();
});
afterAll(() => server.close());
beforeEach(() => {
localStorage.clear();
useChildStore.setState({
children: [],
currentChildId: null,
isLoading: false,
});
});
describe("useChildLeaveRequests", () => {
it("无 currentChildId 时暂停查询,返回空数组", () => {
const { result } = renderUseChildLeaveRequests();
expect(result.current.leaveRequests).toEqual([]);
expect(result.current.loading).toBe(false);
expect(result.current.error).toBeUndefined();
});
it("有 currentChildId 时加载成功返回请假列表", async () => {
useChildStore.setState({ currentChildId: "student-001" });
const { result } = renderUseChildLeaveRequests();
await waitFor(() => {
expect(result.current.leaveRequests.length).toBeGreaterThan(0);
});
// mock 中 student-001 有 2 条请假记录
expect(result.current.leaveRequests).toHaveLength(2);
expect(result.current.leaveRequests[0]!.type).toBe("sick");
expect(result.current.leaveRequests[0]!.status).toBe("approved");
expect(result.current.loading).toBe(false);
expect(result.current.error).toBeUndefined();
});
it("加载中 loading 为 true", () => {
useChildStore.setState({ currentChildId: "student-001" });
const { result } = renderUseChildLeaveRequests();
expect(result.current.loading).toBe(true);
});
it("refetch 触发重新查询", async () => {
useChildStore.setState({ currentChildId: "student-001" });
let queryCount = 0;
server.resetHandlers(
graphql.query("ChildLeaveRequests", () => {
queryCount += 1;
return HttpResponse.json({
data: {
childLeaveRequests: [
{
id: `leave-${queryCount}`,
childId: "student-001",
type: "sick",
startDate: "2026-05-10",
endDate: "2026-05-11",
reason: "感冒发烧需要休息",
status: "pending",
submittedAt: "2026-05-09T20:00:00Z",
},
],
},
});
}),
);
const { result } = renderUseChildLeaveRequests();
await waitFor(() => {
expect(result.current.leaveRequests).toHaveLength(1);
});
expect(queryCount).toBe(1);
result.current.refetch();
await waitFor(() => {
expect(queryCount).toBeGreaterThanOrEqual(2);
});
});
it("GraphQL 返回错误时透传 error", async () => {
useChildStore.setState({ currentChildId: "student-001" });
server.resetHandlers(
graphql.query("ChildLeaveRequests", () =>
HttpResponse.json(
{ errors: [{ message: "请假查询失败" }] },
{ status: 200 },
),
),
);
const { result } = renderUseChildLeaveRequests();
await waitFor(() => {
expect(result.current.error).toBeDefined();
});
expect(result.current.leaveRequests).toEqual([]);
expect(result.current.error?.message).toContain("请假查询失败");
});
});

View File

@@ -0,0 +1,35 @@
// useChildLeaveRequests获取子女请假记录列表
// 依据02-architecture-design.md §4.2 GraphQL 接入
"use client";
import { useQuery, type CombinedError } from "urql";
import { CHILD_LEAVE_REQUESTS } from "@/lib/graphql/operations";
import type { LeaveRequest } from "@/types";
import { useChildStore } from "@/store/child-store";
interface ChildLeaveRequestsResponse {
childLeaveRequests: LeaveRequest[];
}
export function useChildLeaveRequests(): {
leaveRequests: LeaveRequest[];
loading: boolean;
error: CombinedError | undefined;
refetch: () => void;
} {
const currentChildId = useChildStore((s) => s.currentChildId);
const [result, reexecuteQuery] = useQuery<ChildLeaveRequestsResponse>({
query: CHILD_LEAVE_REQUESTS,
variables: { childId: currentChildId },
pause: !currentChildId,
});
return {
leaveRequests: result.data?.childLeaveRequests ?? [],
loading: result.fetching,
error: result.error,
refetch: () => reexecuteQuery({ requestPolicy: "network-only" }),
};
}

View File

@@ -0,0 +1,133 @@
// useChildLessonPlanDetail Hook 单测
// 依据02-architecture-design.md §4.2 GraphQL 接入
// 覆盖:无 currentChildId / 无 planId 暂停 / 加载态 / 成功返回详情 / 错误态 / 详情为 null
import {
describe,
it,
expect,
beforeAll,
afterAll,
afterEach,
beforeEach,
} from "vitest";
import { renderHook, waitFor, cleanup } from "@testing-library/react";
import {
Provider as UrqlProvider,
Client,
cacheExchange,
fetchExchange,
} from "urql";
import { graphql, HttpResponse } from "msw";
import type { ReactNode } from "react";
import { server } from "@/test/mocks/server";
import { useChildStore } from "@/store/child-store";
import { useChildLessonPlanDetail } from "./useChildLessonPlanDetail";
function createTestClient(): Client {
return new Client({
url: "/api/v1/parent/v1/graphql",
exchanges: [cacheExchange, fetchExchange],
fetchOptions: { headers: { "X-Requested-With": "XMLHttpRequest" } },
});
}
function renderUseChildLessonPlanDetail(planId: string) {
const client = createTestClient();
const wrapper = ({ children }: { children: ReactNode }) => (
<UrqlProvider value={client}>{children}</UrqlProvider>
);
return renderHook(() => useChildLessonPlanDetail(planId), { wrapper });
}
beforeAll(() => server.listen({ onUnhandledRequest: "error" }));
afterEach(() => {
cleanup();
server.resetHandlers();
});
afterAll(() => server.close());
beforeEach(() => {
localStorage.clear();
useChildStore.setState({
children: [],
currentChildId: null,
isLoading: false,
});
});
describe("useChildLessonPlanDetail", () => {
it("无 currentChildId 时暂停查询,返回 null", () => {
const { result } = renderUseChildLessonPlanDetail("lp-002");
expect(result.current.lessonPlan).toBeNull();
expect(result.current.loading).toBe(false);
expect(result.current.error).toBeUndefined();
});
it("无 planId 时暂停查询,返回 null", () => {
useChildStore.setState({ currentChildId: "student-001" });
const { result } = renderUseChildLessonPlanDetail("");
expect(result.current.lessonPlan).toBeNull();
expect(result.current.loading).toBe(false);
expect(result.current.error).toBeUndefined();
});
it("有 currentChildId 和 planId 时加载成功返回备课详情", async () => {
useChildStore.setState({ currentChildId: "student-001" });
const { result } = renderUseChildLessonPlanDetail("lp-002");
await waitFor(() => {
expect(result.current.lessonPlan).not.toBeNull();
});
expect(result.current.lessonPlan?.id).toBe("lp-002");
expect(result.current.lessonPlan?.title).toBe("配方法解一元二次方程");
expect(result.current.lessonPlan?.objectives).toHaveLength(3);
expect(result.current.lessonPlan?.keyPoints).toHaveLength(2);
expect(result.current.lessonPlan?.difficultPoints).toHaveLength(2);
expect(result.current.lessonPlan?.content).toContain("配方法");
expect(result.current.lessonPlan?.homeworkDescription).toContain(
"课本第 25 页",
);
expect(result.current.loading).toBe(false);
expect(result.current.error).toBeUndefined();
});
it("加载中 loading 为 true", () => {
useChildStore.setState({ currentChildId: "student-001" });
const { result } = renderUseChildLessonPlanDetail("lp-002");
expect(result.current.loading).toBe(true);
});
it("GraphQL 返回错误时透传 error", async () => {
useChildStore.setState({ currentChildId: "student-001" });
server.resetHandlers(
graphql.query("ChildLessonPlanDetail", () =>
HttpResponse.json(
{ errors: [{ message: "备课详情查询失败" }] },
{ status: 200 },
),
),
);
const { result } = renderUseChildLessonPlanDetail("lp-002");
await waitFor(() => {
expect(result.current.error).toBeDefined();
});
expect(result.current.lessonPlan).toBeNull();
expect(result.current.error?.message).toContain("备课详情查询失败");
});
it("详情为 null 时返回 null", async () => {
useChildStore.setState({ currentChildId: "student-001" });
server.resetHandlers(
graphql.query("ChildLessonPlanDetail", () =>
HttpResponse.json({ data: { childLessonPlanDetail: null } }),
),
);
const { result } = renderUseChildLessonPlanDetail("not-exist");
await waitFor(() => {
expect(result.current.loading).toBe(false);
});
expect(result.current.lessonPlan).toBeNull();
});
});

View File

@@ -0,0 +1,34 @@
// useChildLessonPlanDetail获取子女备课详情只读
// 依据02-architecture-design.md §4.2 GraphQL 接入
// 对标 /parent/lesson-plans/[planId]/view 页面(家长只读视角)
"use client";
import { useQuery, type CombinedError } from "urql";
import { CHILD_LESSON_PLAN_DETAIL } from "@/lib/graphql/operations";
import type { LessonPlanDetail } from "@/types";
import { useChildStore } from "@/store/child-store";
interface ChildLessonPlanDetailResponse {
childLessonPlanDetail: LessonPlanDetail;
}
export function useChildLessonPlanDetail(planId: string): {
lessonPlan: LessonPlanDetail | null;
loading: boolean;
error: CombinedError | undefined;
} {
const currentChildId = useChildStore((s) => s.currentChildId);
const [result] = useQuery<ChildLessonPlanDetailResponse>({
query: CHILD_LESSON_PLAN_DETAIL,
variables: { childId: currentChildId, planId },
pause: !currentChildId || !planId,
});
return {
lessonPlan: result.data?.childLessonPlanDetail ?? null,
loading: result.fetching,
error: result.error,
};
}

View File

@@ -0,0 +1,163 @@
// useChildLessonPlans Hook 单测
// 依据02-architecture-design.md §4.2 GraphQL 接入
// 覆盖:无 currentChildId 暂停 / 加载态 / 成功返回 / subject 过滤变量 / 错误态
import {
describe,
it,
expect,
beforeAll,
afterAll,
afterEach,
beforeEach,
} from "vitest";
import { renderHook, waitFor, cleanup } from "@testing-library/react";
import {
Provider as UrqlProvider,
Client,
cacheExchange,
fetchExchange,
} from "urql";
import { graphql, HttpResponse } from "msw";
import type { ReactNode } from "react";
import { server } from "@/test/mocks/server";
import { useChildStore } from "@/store/child-store";
import { useChildLessonPlans } from "./useChildLessonPlans";
function createTestClient(): Client {
return new Client({
url: "/api/v1/parent/v1/graphql",
exchanges: [cacheExchange, fetchExchange],
fetchOptions: { headers: { "X-Requested-With": "XMLHttpRequest" } },
});
}
function renderUseChildLessonPlans(subject?: string) {
const client = createTestClient();
const wrapper = ({ children }: { children: ReactNode }) => (
<UrqlProvider value={client}>{children}</UrqlProvider>
);
return renderHook(() => useChildLessonPlans(subject), { wrapper });
}
beforeAll(() => server.listen({ onUnhandledRequest: "error" }));
afterEach(() => {
cleanup();
server.resetHandlers();
});
afterAll(() => server.close());
beforeEach(() => {
localStorage.clear();
useChildStore.setState({
children: [],
currentChildId: null,
isLoading: false,
});
});
describe("useChildLessonPlans", () => {
it("无 currentChildId 时暂停查询,返回空数组", () => {
const { result } = renderUseChildLessonPlans();
expect(result.current.lessonPlans).toEqual([]);
expect(result.current.loading).toBe(false);
expect(result.current.error).toBeUndefined();
});
it("有 currentChildId 时加载成功返回备课列表", async () => {
useChildStore.setState({ currentChildId: "student-001" });
const { result } = renderUseChildLessonPlans();
await waitFor(() => {
expect(result.current.lessonPlans.length).toBeGreaterThan(0);
});
expect(result.current.lessonPlans).toHaveLength(4);
expect(result.current.lessonPlans[0]!.title).toBe("一元二次方程的概念");
expect(result.current.lessonPlans[0]!.subject).toBe("数学");
expect(result.current.lessonPlans[0]!.status).toBe("published");
expect(result.current.loading).toBe(false);
expect(result.current.error).toBeUndefined();
});
it("加载中 loading 为 true", () => {
useChildStore.setState({ currentChildId: "student-001" });
const { result } = renderUseChildLessonPlans();
expect(result.current.loading).toBe(true);
});
it("传入 subject 时变量携带 subject", async () => {
useChildStore.setState({ currentChildId: "student-001" });
let capturedVars: unknown = null;
server.resetHandlers(
graphql.query("ChildLessonPlans", ({ variables }) => {
capturedVars = variables;
return HttpResponse.json({
data: {
childLessonPlans: [
{
id: "lp-001",
title: "一元二次方程的概念",
subject: "数学",
className: "初一(1)班",
teacherName: "王老师",
textbookTitle: "人教版七年级数学下册",
chapterTitle: "第一章 一元二次方程",
status: "published",
publishedAt: "2026-02-18T10:00:00Z",
estimatedMinutes: 45,
},
],
},
});
}),
);
const { result } = renderUseChildLessonPlans("数学");
await waitFor(() => {
expect(result.current.lessonPlans).toHaveLength(1);
});
expect(capturedVars).toEqual({
childId: "student-001",
subject: "数学",
});
});
it("未传 subject 时变量 subject 为 null", async () => {
useChildStore.setState({ currentChildId: "student-001" });
let capturedVars: unknown = null;
server.resetHandlers(
graphql.query("ChildLessonPlans", ({ variables }) => {
capturedVars = variables;
return HttpResponse.json({ data: { childLessonPlans: [] } });
}),
);
const { result } = renderUseChildLessonPlans();
await waitFor(() => {
expect(result.current.loading).toBe(false);
});
expect(capturedVars).toEqual({
childId: "student-001",
subject: null,
});
expect(result.current.lessonPlans).toEqual([]);
});
it("GraphQL 返回错误时透传 error", async () => {
useChildStore.setState({ currentChildId: "student-001" });
server.resetHandlers(
graphql.query("ChildLessonPlans", () =>
HttpResponse.json(
{ errors: [{ message: "备课查询失败" }] },
{ status: 200 },
),
),
);
const { result } = renderUseChildLessonPlans();
await waitFor(() => {
expect(result.current.error).toBeDefined();
});
expect(result.current.lessonPlans).toEqual([]);
expect(result.current.error?.message).toContain("备课查询失败");
});
});

View File

@@ -0,0 +1,34 @@
// useChildLessonPlans获取子女备课列表按学科筛选
// 依据02-architecture-design.md §4.2 GraphQL 接入
// 对标 /parent/lesson-plans 页面(家长只读视角)
"use client";
import { useQuery, type CombinedError } from "urql";
import { CHILD_LESSON_PLANS } from "@/lib/graphql/operations";
import type { LessonPlan } from "@/types";
import { useChildStore } from "@/store/child-store";
interface ChildLessonPlansResponse {
childLessonPlans: LessonPlan[];
}
export function useChildLessonPlans(subject?: string): {
lessonPlans: LessonPlan[];
loading: boolean;
error: CombinedError | undefined;
} {
const currentChildId = useChildStore((s) => s.currentChildId);
const [result] = useQuery<ChildLessonPlansResponse>({
query: CHILD_LESSON_PLANS,
variables: { childId: currentChildId, subject: subject || null },
pause: !currentChildId,
});
return {
lessonPlans: result.data?.childLessonPlans ?? [],
loading: result.fetching,
error: result.error,
};
}

View File

@@ -0,0 +1,147 @@
// useChildPractice Hook 单测
// 依据02-architecture-design.md §4.2 GraphQL 接入
// 覆盖:无 currentChildId 暂停 / 加载态 / 成功返回 / 错误态 / 两个查询合并
import {
describe,
it,
expect,
beforeAll,
afterAll,
afterEach,
beforeEach,
} from "vitest";
import { renderHook, waitFor, cleanup } from "@testing-library/react";
import {
Provider as UrqlProvider,
Client,
cacheExchange,
fetchExchange,
} from "urql";
import { graphql, HttpResponse } from "msw";
import type { ReactNode } from "react";
import { server } from "@/test/mocks/server";
import { useChildStore } from "@/store/child-store";
import { useChildPractice } from "./useChildPractice";
function createTestClient(): Client {
return new Client({
url: "/api/v1/parent/v1/graphql",
exchanges: [cacheExchange, fetchExchange],
fetchOptions: { headers: { "X-Requested-With": "XMLHttpRequest" } },
});
}
function renderUseChildPractice() {
const client = createTestClient();
const wrapper = ({ children }: { children: ReactNode }) => (
<UrqlProvider value={client}>{children}</UrqlProvider>
);
return renderHook(() => useChildPractice(), { wrapper });
}
beforeAll(() => server.listen({ onUnhandledRequest: "error" }));
afterEach(() => {
cleanup();
server.resetHandlers();
});
afterAll(() => server.close());
beforeEach(() => {
localStorage.clear();
useChildStore.setState({
children: [],
currentChildId: null,
isLoading: false,
});
});
describe("useChildPractice", () => {
it("无 currentChildId 时暂停查询,返回空数据", () => {
const { result } = renderUseChildPractice();
expect(result.current.stats).toBeNull();
expect(result.current.sessions).toEqual([]);
expect(result.current.loading).toBe(false);
expect(result.current.error).toBeUndefined();
});
it("有 currentChildId 时加载成功返回练习数据", async () => {
useChildStore.setState({ currentChildId: "student-001" });
const { result } = renderUseChildPractice();
await waitFor(() => {
expect(result.current.stats).not.toBeNull();
});
expect(result.current.stats?.totalSessions).toBe(48);
expect(result.current.stats?.completedSessions).toBe(42);
expect(result.current.stats?.totalQuestionsAnswered).toBe(560);
expect(result.current.stats?.overallAccuracy).toBe(0.83);
expect(result.current.sessions).toHaveLength(5);
expect(result.current.sessions[0]!.subject).toBe("数学");
expect(result.current.sessions[0]!.questionCount).toBe(20);
expect(result.current.loading).toBe(false);
expect(result.current.error).toBeUndefined();
});
it("加载中 loading 为 true", () => {
useChildStore.setState({ currentChildId: "student-001" });
const { result } = renderUseChildPractice();
expect(result.current.loading).toBe(true);
});
it("ChildPracticeStats 返回错误时透传 error", async () => {
useChildStore.setState({ currentChildId: "student-001" });
server.use(
graphql.query("ChildPracticeStats", () =>
HttpResponse.json(
{ errors: [{ message: "练习统计查询失败" }] },
{ status: 200 },
),
),
);
const { result } = renderUseChildPractice();
await waitFor(() => {
expect(result.current.error).toBeDefined();
});
expect(result.current.stats).toBeNull();
expect(result.current.error?.message).toContain("练习统计查询失败");
});
it("ChildPracticeSessions 返回错误时透传 error", async () => {
useChildStore.setState({ currentChildId: "student-001" });
server.use(
graphql.query("ChildPracticeSessions", () =>
HttpResponse.json(
{ errors: [{ message: "练习历史查询失败" }] },
{ status: 200 },
),
),
);
const { result } = renderUseChildPractice();
await waitFor(() => {
expect(result.current.error).toBeDefined();
});
expect(result.current.sessions).toEqual([]);
expect(result.current.error?.message).toContain("练习历史查询失败");
});
it("两个查询返回空数据时返回空数组与 null stats", async () => {
useChildStore.setState({ currentChildId: "student-001" });
server.use(
graphql.query("ChildPracticeStats", () =>
HttpResponse.json({ data: { childPracticeStats: null } }),
),
graphql.query("ChildPracticeSessions", () =>
HttpResponse.json({ data: { childPracticeSessions: [] } }),
),
);
const { result } = renderUseChildPractice();
await waitFor(() => {
expect(result.current.loading).toBe(false);
});
expect(result.current.stats).toBeNull();
expect(result.current.sessions).toEqual([]);
});
});

View File

@@ -0,0 +1,49 @@
// useChildPractice获取子女练习统计数据统计 + 历史会话)
// 依据02-architecture-design.md §4.2 GraphQL 接入
// 同时发起两个查询,合并为统一返回值
"use client";
import { useQuery, type CombinedError } from "urql";
import {
CHILD_PRACTICE_STATS,
CHILD_PRACTICE_SESSIONS,
} from "@/lib/graphql/operations";
import type { PracticeStats, PracticeSession } from "@/types";
import { useChildStore } from "@/store/child-store";
interface ChildPracticeStatsResponse {
childPracticeStats: PracticeStats;
}
interface ChildPracticeSessionsResponse {
childPracticeSessions: PracticeSession[];
}
export function useChildPractice(): {
stats: PracticeStats | null;
sessions: PracticeSession[];
loading: boolean;
error: CombinedError | undefined;
} {
const currentChildId = useChildStore((s) => s.currentChildId);
const [statsResult] = useQuery<ChildPracticeStatsResponse>({
query: CHILD_PRACTICE_STATS,
variables: { childId: currentChildId },
pause: !currentChildId,
});
const [sessionsResult] = useQuery<ChildPracticeSessionsResponse>({
query: CHILD_PRACTICE_SESSIONS,
variables: { childId: currentChildId, limit: 20 },
pause: !currentChildId,
});
return {
stats: statsResult.data?.childPracticeStats ?? null,
sessions: sessionsResult.data?.childPracticeSessions ?? [],
loading: statsResult.fetching || sessionsResult.fetching,
error: statsResult.error ?? sessionsResult.error,
};
}

View File

@@ -14,13 +14,14 @@ interface ChildSummaryResponse {
childSummary: ChildSummary;
}
export function useChildSummary() {
export function useChildSummary(childId?: string) {
const currentChildId = useChildStore((s) => s.currentChildId);
const targetChildId = childId ?? currentChildId;
const [result] = useQuery<ChildSummaryResponse>({
query: CHILD_SUMMARY,
variables: { childId: currentChildId },
pause: !currentChildId,
variables: { childId: targetChildId },
pause: !targetChildId,
});
return {

View File

@@ -0,0 +1,143 @@
// useCreateLeaveRequest Hook 单测
// 依据02-architecture-design.md §4.2 GraphQL 接入
// 覆盖:成功提交 / mutation 错误返回 / loading 状态
import {
describe,
it,
expect,
beforeAll,
afterAll,
afterEach,
} from "vitest";
import { renderHook, act, cleanup } from "@testing-library/react";
import {
Provider as UrqlProvider,
Client,
cacheExchange,
fetchExchange,
} from "urql";
import { graphql, HttpResponse } from "msw";
import type { ReactNode } from "react";
import { server } from "@/test/mocks/server";
import { useCreateLeaveRequest } from "./useCreateLeaveRequest";
import type { LeaveRequestInput } from "@/types";
function createTestClient(): Client {
return new Client({
url: "/api/v1/parent/v1/graphql",
exchanges: [cacheExchange, fetchExchange],
fetchOptions: { headers: { "X-Requested-With": "XMLHttpRequest" } },
});
}
function renderUseCreateLeaveRequest() {
const client = createTestClient();
const wrapper = ({ children }: { children: ReactNode }) => (
<UrqlProvider value={client}>{children}</UrqlProvider>
);
return renderHook(() => useCreateLeaveRequest(), { wrapper });
}
const validInput: LeaveRequestInput = {
childId: "student-001",
type: "sick",
startDate: "2026-07-15",
endDate: "2026-07-16",
reason: "感冒发烧需要在家休息",
};
beforeAll(() => server.listen({ onUnhandledRequest: "error" }));
afterEach(() => {
cleanup();
server.resetHandlers();
});
afterAll(() => server.close());
describe("useCreateLeaveRequest", () => {
it("初始状态 loading 为 false 且 error 为 undefined", () => {
const { result } = renderUseCreateLeaveRequest();
expect(result.current.loading).toBe(false);
expect(result.current.error).toBeUndefined();
});
it("成功提交返回 success=true", async () => {
const { result } = renderUseCreateLeaveRequest();
let createResult: { success: boolean } | undefined;
await act(async () => {
createResult = await result.current.createLeave(validInput);
});
expect(createResult?.success).toBe(true);
expect(result.current.loading).toBe(false);
expect(result.current.error).toBeUndefined();
});
it("提交后 loading 最终回到 false", async () => {
const { result } = renderUseCreateLeaveRequest();
let resolved = false;
await act(async () => {
const r = await result.current.createLeave(validInput);
resolved = r.success;
});
expect(resolved).toBe(true);
expect(result.current.loading).toBe(false);
});
it("mutation 返回错误时返回 success=false 并设置 error", async () => {
server.resetHandlers(
graphql.mutation("CreateLeaveRequest", () =>
HttpResponse.json(
{ errors: [{ message: "请假提交失败:日期冲突" }] },
{ status: 200 },
),
),
);
const { result } = renderUseCreateLeaveRequest();
let createResult: { success: boolean; error?: unknown } | undefined;
await act(async () => {
createResult = await result.current.createLeave(validInput);
});
expect(createResult?.success).toBe(false);
expect(createResult?.error).toBeDefined();
expect(result.current.error?.message).toContain("日期冲突");
expect(result.current.loading).toBe(false);
});
it("提交时携带正确的 input 变量", async () => {
let capturedInput: unknown = null;
server.resetHandlers(
graphql.mutation("CreateLeaveRequest", ({ variables }) => {
capturedInput = variables.input;
return HttpResponse.json({
data: {
createLeaveRequest: {
id: "leave-new",
childId: "student-001",
type: "sick",
startDate: "2026-07-15",
endDate: "2026-07-16",
reason: "感冒发烧",
status: "pending",
submittedAt: "2026-07-13T10:00:00Z",
},
},
});
}),
);
const { result } = renderUseCreateLeaveRequest();
await act(async () => {
await result.current.createLeave(validInput);
});
expect(capturedInput).toEqual(validInput);
});
});

View File

@@ -0,0 +1,43 @@
// useCreateLeaveRequest提交请假申请
// 依据02-architecture-design.md §4.2 GraphQL 接入
"use client";
import { useState } from "react";
import { useMutation, type CombinedError } from "urql";
import { CREATE_LEAVE_REQUEST } from "@/lib/graphql/operations";
import type { LeaveRequest, LeaveRequestInput } from "@/types";
interface CreateLeaveRequestResponse {
createLeaveRequest: LeaveRequest;
}
export function useCreateLeaveRequest(): {
createLeave: (
input: LeaveRequestInput,
) => Promise<{ success: boolean; error?: CombinedError }>;
loading: boolean;
error: CombinedError | undefined;
} {
const [, mutate] = useMutation<CreateLeaveRequestResponse>(
CREATE_LEAVE_REQUEST,
);
const [loading, setLoading] = useState(false);
const [error, setError] = useState<CombinedError | undefined>(undefined);
const createLeave = async (
input: LeaveRequestInput,
): Promise<{ success: boolean; error?: CombinedError }> => {
setLoading(true);
setError(undefined);
const result = await mutate({ input });
setLoading(false);
if (result.error) {
setError(result.error);
return { success: false, error: result.error };
}
return { success: true };
};
return { createLeave, loading, error };
}

View File

@@ -0,0 +1,170 @@
// useExportChildGrades Hook 单测
// 依据02-architecture-design.md §4.2 GraphQL 接入
// 覆盖:成功提交 / mutation 错误返回 / loading 状态 / 携带 subject 变量
import {
describe,
it,
expect,
beforeAll,
afterAll,
afterEach,
} from "vitest";
import { renderHook, act, cleanup } from "@testing-library/react";
import {
Provider as UrqlProvider,
Client,
cacheExchange,
fetchExchange,
} from "urql";
import { graphql, HttpResponse } from "msw";
import type { ReactNode } from "react";
import { server } from "@/test/mocks/server";
import { useExportChildGrades } from "./useExportChildGrades";
function createTestClient(): Client {
return new Client({
url: "/api/v1/parent/v1/graphql",
exchanges: [cacheExchange, fetchExchange],
fetchOptions: { headers: { "X-Requested-With": "XMLHttpRequest" } },
});
}
function renderUseExportChildGrades() {
const client = createTestClient();
const wrapper = ({ children }: { children: ReactNode }) => (
<UrqlProvider value={client}>{children}</UrqlProvider>
);
return renderHook(() => useExportChildGrades(), { wrapper });
}
beforeAll(() => server.listen({ onUnhandledRequest: "error" }));
afterEach(() => {
cleanup();
server.resetHandlers();
});
afterAll(() => server.close());
describe("useExportChildGrades", () => {
it("初始状态 loading 为 false 且 error 为 undefined", () => {
const { result } = renderUseExportChildGrades();
expect(result.current.loading).toBe(false);
expect(result.current.error).toBeUndefined();
});
it("成功提交返回 success=true 和下载链接", async () => {
const { result } = renderUseExportChildGrades();
let exportResult:
| { success: boolean; data?: { downloadUrl: string; expiresAt: string } }
| undefined;
await act(async () => {
exportResult = await result.current.exportGrades({
childId: "student-001",
});
});
expect(exportResult?.success).toBe(true);
expect(exportResult?.data?.downloadUrl).toContain("student-001");
expect(exportResult?.data?.expiresAt).toBeDefined();
expect(result.current.loading).toBe(false);
expect(result.current.error).toBeUndefined();
});
it("提交后 loading 最终回到 false", async () => {
const { result } = renderUseExportChildGrades();
let resolved = false;
await act(async () => {
const r = await result.current.exportGrades({ childId: "student-001" });
resolved = r.success;
});
expect(resolved).toBe(true);
expect(result.current.loading).toBe(false);
});
it("mutation 返回错误时返回 success=false 并设置 error", async () => {
server.resetHandlers(
graphql.mutation("ExportChildGrades", () =>
HttpResponse.json(
{ errors: [{ message: "导出失败:权限不足" }] },
{ status: 200 },
),
),
);
const { result } = renderUseExportChildGrades();
let exportResult:
| { success: boolean; error?: { message: string } }
| undefined;
await act(async () => {
exportResult = await result.current.exportGrades({
childId: "student-001",
});
});
expect(exportResult?.success).toBe(false);
expect(exportResult?.error).toBeDefined();
expect(result.current.error?.message).toContain("权限不足");
expect(result.current.loading).toBe(false);
});
it("未传 subject 时变量 subject 为 null", async () => {
let capturedVars: unknown = null;
server.resetHandlers(
graphql.mutation("ExportChildGrades", ({ variables }) => {
capturedVars = variables;
return HttpResponse.json({
data: {
exportChildGrades: {
downloadUrl: "https://example.com/exports/grades.xlsx",
expiresAt: "2026-07-13T23:59:59Z",
},
},
});
}),
);
const { result } = renderUseExportChildGrades();
await act(async () => {
await result.current.exportGrades({ childId: "student-001" });
});
expect(capturedVars).toEqual({
childId: "student-001",
subject: null,
});
});
it("传入 subject 时变量携带 subject", async () => {
let capturedVars: unknown = null;
server.resetHandlers(
graphql.mutation("ExportChildGrades", ({ variables }) => {
capturedVars = variables;
return HttpResponse.json({
data: {
exportChildGrades: {
downloadUrl: "https://example.com/exports/grades-math.xlsx",
expiresAt: "2026-07-13T23:59:59Z",
},
},
});
}),
);
const { result } = renderUseExportChildGrades();
await act(async () => {
await result.current.exportGrades({
childId: "student-001",
subject: "数学",
});
});
expect(capturedVars).toEqual({
childId: "student-001",
subject: "数学",
});
});
});

View File

@@ -0,0 +1,57 @@
// useExportChildGrades导出子女成绩
// 依据02-architecture-design.md §4.2 GraphQL 接入
// - mutation EXPORT_CHILD_GRADES返回下载链接 + 过期时间
// - 支持可选 subject 参数(按学科筛选导出)
"use client";
import { useState } from "react";
import { useMutation, type CombinedError } from "urql";
import { EXPORT_CHILD_GRADES } from "@/lib/graphql/operations";
import type { ExportResult } from "@/types";
interface ExportChildGradesResponse {
exportChildGrades: ExportResult;
}
interface ExportChildGradesInput {
childId: string;
subject?: string;
}
export function useExportChildGrades(): {
exportGrades: (
input: ExportChildGradesInput,
) => Promise<{ success: boolean; data?: ExportResult; error?: CombinedError }>;
loading: boolean;
error: CombinedError | undefined;
} {
const [, mutate] = useMutation<ExportChildGradesResponse>(
EXPORT_CHILD_GRADES,
);
const [loading, setLoading] = useState(false);
const [error, setError] = useState<CombinedError | undefined>(undefined);
const exportGrades = async (
input: ExportChildGradesInput,
): Promise<{
success: boolean;
data?: ExportResult;
error?: CombinedError;
}> => {
setLoading(true);
setError(undefined);
const result = await mutate({
childId: input.childId,
subject: input.subject ?? null,
});
setLoading(false);
if (result.error) {
setError(result.error);
return { success: false, error: result.error };
}
return { success: true, data: result.data?.exportChildGrades };
};
return { exportGrades, loading, error };
}

View File

@@ -0,0 +1,157 @@
// useReportCard Hook 单测
// 依据02-architecture-design.md §4.2 GraphQL 接入
// 覆盖:无 childId/academicYearId 暂停 / 加载态 / 成功返回 / 变量传递 / 错误态
import {
describe,
it,
expect,
beforeAll,
afterAll,
afterEach,
} from "vitest";
import { renderHook, waitFor, cleanup } from "@testing-library/react";
import {
Provider as UrqlProvider,
Client,
cacheExchange,
fetchExchange,
} from "urql";
import { graphql, HttpResponse } from "msw";
import type { ReactNode } from "react";
import { server } from "@/test/mocks/server";
import { useReportCard } from "./useReportCard";
function createTestClient(): Client {
return new Client({
url: "/api/v1/parent/v1/graphql",
exchanges: [cacheExchange, fetchExchange],
fetchOptions: { headers: { "X-Requested-With": "XMLHttpRequest" } },
});
}
function renderUseReportCard(
childId: string | null,
academicYearId: string | null,
semester: number,
) {
const client = createTestClient();
const wrapper = ({ children }: { children: ReactNode }) => (
<UrqlProvider value={client}>{children}</UrqlProvider>
);
return renderHook(
() => useReportCard(childId, academicYearId, semester),
{ wrapper },
);
}
beforeAll(() => server.listen({ onUnhandledRequest: "error" }));
afterEach(() => {
cleanup();
server.resetHandlers();
});
afterAll(() => server.close());
describe("useReportCard", () => {
it("无 childId 时暂停查询,返回 null", () => {
const { result } = renderUseReportCard(null, "ay-2025-2026", 2);
expect(result.current.reportCard).toBeNull();
expect(result.current.loading).toBe(false);
expect(result.current.error).toBeUndefined();
});
it("无 academicYearId 时暂停查询,返回 null", () => {
const { result } = renderUseReportCard("student-001", null, 2);
expect(result.current.reportCard).toBeNull();
expect(result.current.loading).toBe(false);
expect(result.current.error).toBeUndefined();
});
it("有 childId 和 academicYearId 时加载成功返回报告卡", async () => {
const { result } = renderUseReportCard(
"student-001",
"ay-2025-2026",
2,
);
await waitFor(() => {
expect(result.current.reportCard).not.toBeNull();
});
expect(result.current.reportCard?.childId).toBe("student-001");
expect(result.current.reportCard?.semester).toBe(2);
expect(result.current.reportCard?.subjects).toHaveLength(4);
expect(result.current.loading).toBe(false);
expect(result.current.error).toBeUndefined();
});
it("加载中 loading 为 true", () => {
const { result } = renderUseReportCard(
"student-001",
"ay-2025-2026",
1,
);
expect(result.current.loading).toBe(true);
});
it("传入的变量正确传递", async () => {
let capturedVars: unknown = null;
server.resetHandlers(
graphql.query("ChildReportCard", ({ variables }) => {
capturedVars = variables;
return HttpResponse.json({
data: {
childReportCard: {
childId: variables.childId,
childName: "张小明",
className: "初一(1)班",
academicYearId: variables.academicYearId,
academicYearName: "2025-2026",
semester: variables.semester,
subjects: [],
overallScore: 90,
classRank: 5,
classSize: 45,
teacherComment: "良好",
issuedAt: "2026-07-05T10:00:00Z",
},
},
});
}),
);
const { result } = renderUseReportCard(
"student-002",
"ay-2024-2025",
1,
);
await waitFor(() => {
expect(result.current.reportCard).not.toBeNull();
});
expect(capturedVars).toEqual({
childId: "student-002",
academicYearId: "ay-2024-2025",
semester: 1,
});
});
it("GraphQL 返回错误时透传 error", async () => {
server.resetHandlers(
graphql.query("ChildReportCard", () =>
HttpResponse.json(
{ errors: [{ message: "报告卡查询失败" }] },
{ status: 200 },
),
),
);
const { result } = renderUseReportCard(
"student-001",
"ay-2025-2026",
2,
);
await waitFor(() => {
expect(result.current.error).toBeDefined();
});
expect(result.current.reportCard).toBeNull();
expect(result.current.error?.message).toContain("报告卡查询失败");
});
});

View File

@@ -0,0 +1,34 @@
// useReportCard获取子女报告卡
// 依据02-architecture-design.md §4.2 GraphQL 接入
"use client";
import { useQuery, type CombinedError } from "urql";
import { CHILD_REPORT_CARD } from "@/lib/graphql/operations";
import type { ReportCard } from "@/types";
interface ChildReportCardResponse {
childReportCard: ReportCard;
}
export function useReportCard(
childId: string | null,
academicYearId: string | null,
semester: number,
): {
reportCard: ReportCard | null;
loading: boolean;
error: CombinedError | undefined;
} {
const [result] = useQuery<ChildReportCardResponse>({
query: CHILD_REPORT_CARD,
variables: { childId, academicYearId, semester },
pause: !childId || !academicYearId,
});
return {
reportCard: result.data?.childReportCard ?? null,
loading: result.fetching,
error: result.error,
};
}