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:
84
apps/parent-portal/src/hooks/useAcademicYears.test.tsx
Normal file
84
apps/parent-portal/src/hooks/useAcademicYears.test.tsx
Normal 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("学年查询失败");
|
||||
});
|
||||
});
|
||||
28
apps/parent-portal/src/hooks/useAcademicYears.ts
Normal file
28
apps/parent-portal/src/hooks/useAcademicYears.ts
Normal 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,
|
||||
};
|
||||
}
|
||||
@@ -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 {
|
||||
|
||||
131
apps/parent-portal/src/hooks/useChildCoursePlanDetail.test.tsx
Normal file
131
apps/parent-portal/src/hooks/useChildCoursePlanDetail.test.tsx
Normal 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();
|
||||
});
|
||||
});
|
||||
34
apps/parent-portal/src/hooks/useChildCoursePlanDetail.ts
Normal file
34
apps/parent-portal/src/hooks/useChildCoursePlanDetail.ts
Normal 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,
|
||||
};
|
||||
}
|
||||
121
apps/parent-portal/src/hooks/useChildCoursePlans.test.tsx
Normal file
121
apps/parent-portal/src/hooks/useChildCoursePlans.test.tsx
Normal 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([]);
|
||||
});
|
||||
});
|
||||
34
apps/parent-portal/src/hooks/useChildCoursePlans.ts
Normal file
34
apps/parent-portal/src/hooks/useChildCoursePlans.ts
Normal 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,
|
||||
};
|
||||
}
|
||||
144
apps/parent-portal/src/hooks/useChildDetail.test.tsx
Normal file
144
apps/parent-portal/src/hooks/useChildDetail.test.tsx
Normal 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("子女详情查询失败");
|
||||
});
|
||||
});
|
||||
30
apps/parent-portal/src/hooks/useChildDetail.ts
Normal file
30
apps/parent-portal/src/hooks/useChildDetail.ts
Normal 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,
|
||||
};
|
||||
}
|
||||
149
apps/parent-portal/src/hooks/useChildDiagnostic.test.tsx
Normal file
149
apps/parent-portal/src/hooks/useChildDiagnostic.test.tsx
Normal 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([]);
|
||||
});
|
||||
});
|
||||
49
apps/parent-portal/src/hooks/useChildDiagnostic.ts
Normal file
49
apps/parent-portal/src/hooks/useChildDiagnostic.ts
Normal 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,
|
||||
};
|
||||
}
|
||||
120
apps/parent-portal/src/hooks/useChildElective.test.tsx
Normal file
120
apps/parent-portal/src/hooks/useChildElective.test.tsx
Normal 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([]);
|
||||
});
|
||||
});
|
||||
34
apps/parent-portal/src/hooks/useChildElective.ts
Normal file
34
apps/parent-portal/src/hooks/useChildElective.ts
Normal 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,
|
||||
};
|
||||
}
|
||||
174
apps/parent-portal/src/hooks/useChildErrorBook.test.tsx
Normal file
174
apps/parent-portal/src/hooks/useChildErrorBook.test.tsx
Normal 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([]);
|
||||
});
|
||||
});
|
||||
65
apps/parent-portal/src/hooks/useChildErrorBook.ts
Normal file
65
apps/parent-portal/src/hooks/useChildErrorBook.ts
Normal 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,
|
||||
};
|
||||
}
|
||||
206
apps/parent-portal/src/hooks/useChildGrowthArchive.test.tsx
Normal file
206
apps/parent-portal/src/hooks/useChildGrowthArchive.test.tsx
Normal 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();
|
||||
});
|
||||
});
|
||||
47
apps/parent-portal/src/hooks/useChildGrowthArchive.ts
Normal file
47
apps/parent-portal/src/hooks/useChildGrowthArchive.ts
Normal 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,
|
||||
};
|
||||
}
|
||||
142
apps/parent-portal/src/hooks/useChildLeaveRequests.test.tsx
Normal file
142
apps/parent-portal/src/hooks/useChildLeaveRequests.test.tsx
Normal 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("请假查询失败");
|
||||
});
|
||||
});
|
||||
35
apps/parent-portal/src/hooks/useChildLeaveRequests.ts
Normal file
35
apps/parent-portal/src/hooks/useChildLeaveRequests.ts
Normal 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" }),
|
||||
};
|
||||
}
|
||||
133
apps/parent-portal/src/hooks/useChildLessonPlanDetail.test.tsx
Normal file
133
apps/parent-portal/src/hooks/useChildLessonPlanDetail.test.tsx
Normal 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();
|
||||
});
|
||||
});
|
||||
34
apps/parent-portal/src/hooks/useChildLessonPlanDetail.ts
Normal file
34
apps/parent-portal/src/hooks/useChildLessonPlanDetail.ts
Normal 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,
|
||||
};
|
||||
}
|
||||
163
apps/parent-portal/src/hooks/useChildLessonPlans.test.tsx
Normal file
163
apps/parent-portal/src/hooks/useChildLessonPlans.test.tsx
Normal 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("备课查询失败");
|
||||
});
|
||||
});
|
||||
34
apps/parent-portal/src/hooks/useChildLessonPlans.ts
Normal file
34
apps/parent-portal/src/hooks/useChildLessonPlans.ts
Normal 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,
|
||||
};
|
||||
}
|
||||
147
apps/parent-portal/src/hooks/useChildPractice.test.tsx
Normal file
147
apps/parent-portal/src/hooks/useChildPractice.test.tsx
Normal 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([]);
|
||||
});
|
||||
});
|
||||
49
apps/parent-portal/src/hooks/useChildPractice.ts
Normal file
49
apps/parent-portal/src/hooks/useChildPractice.ts
Normal 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,
|
||||
};
|
||||
}
|
||||
@@ -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 {
|
||||
|
||||
143
apps/parent-portal/src/hooks/useCreateLeaveRequest.test.tsx
Normal file
143
apps/parent-portal/src/hooks/useCreateLeaveRequest.test.tsx
Normal 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);
|
||||
});
|
||||
});
|
||||
43
apps/parent-portal/src/hooks/useCreateLeaveRequest.ts
Normal file
43
apps/parent-portal/src/hooks/useCreateLeaveRequest.ts
Normal 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 };
|
||||
}
|
||||
170
apps/parent-portal/src/hooks/useExportChildGrades.test.tsx
Normal file
170
apps/parent-portal/src/hooks/useExportChildGrades.test.tsx
Normal 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: "数学",
|
||||
});
|
||||
});
|
||||
});
|
||||
57
apps/parent-portal/src/hooks/useExportChildGrades.ts
Normal file
57
apps/parent-portal/src/hooks/useExportChildGrades.ts
Normal 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 };
|
||||
}
|
||||
157
apps/parent-portal/src/hooks/useReportCard.test.tsx
Normal file
157
apps/parent-portal/src/hooks/useReportCard.test.tsx
Normal 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("报告卡查询失败");
|
||||
});
|
||||
});
|
||||
34
apps/parent-portal/src/hooks/useReportCard.ts
Normal file
34
apps/parent-portal/src/hooks/useReportCard.ts
Normal 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,
|
||||
};
|
||||
}
|
||||
Reference in New Issue
Block a user