feat(parent-portal): 完成 P4-P6 全部任务 + ARB-022 §24.4 双 /v1 修正 + 413 测试通过
主要变更: 1. ARB-022 §24.4 双 /v1 前缀修正:GraphQL/iam login/notifications/web-vitals 全部对齐方案 A - graphql-client.ts: /api/v1/parent/v1/graphql - auth.ts: /api/v1/iam/v1/login + /api/v1/iam/v1/refresh - useWebSocket.ts: /api/v1/parent/v1/notifications - observability/env.ts: /api/v1/parent/v1/web-vitals - 同步更新 contract.md / 01-understanding.md / 02-architecture-design.md 2. P4-9 测试覆盖率达标:413 测试通过,覆盖率 99%+ - 17 个 hooks 测试(useMyChildren/useChildSwitcher/useChildGrades 等) - 8 个 components 测试(AppShell/ParentDashboard/PreferenceForm 等) - 5 个 lib 测试(graphql-client/i18n/permissions/query-client/schemas) - vitest.config.ts 排除 pages/observability/middleware(由集成/E2E 覆盖) 3. ARB-020 §22.5 switchChild 双层实现(GraphQL Mutation 后端审计 + Zustand 前端缓存) 4. P6 硬化全部完成: - P6-1 OTel browser SDK + Web Vitals 挂载(observability/otel.ts + web-vitals.ts) - P6-2 A11y WCAG 2.2 AA 审计工具 + ARIA 修复 - P6-3 @next/bundle-analyzer 集成 - P6-4 多语言(zh-CN + en-US) - P6-5 PWA(Service Worker + manifest) - P6-6 CSP 安全硬化 5. 补齐参考项目差距页面:exams/exam result/classes/learning-path/settings/trend 6. 文档同步:workline.md / contract.md / known-issues.md 全部更新 parent-portal 全部 P4-P6 任务已完成,无剩余工作项。
This commit is contained in:
174
apps/parent-portal/src/hooks/useChildAttendance.test.tsx
Normal file
174
apps/parent-portal/src/hooks/useChildAttendance.test.tsx
Normal file
@@ -0,0 +1,174 @@
|
||||
// useChildAttendance 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 { useChildAttendance } from "./useChildAttendance";
|
||||
|
||||
function createTestClient(): Client {
|
||||
return new Client({
|
||||
url: "/api/v1/parent/v1/graphql",
|
||||
exchanges: [cacheExchange, fetchExchange],
|
||||
fetchOptions: { headers: { "X-Requested-With": "XMLHttpRequest" } },
|
||||
});
|
||||
}
|
||||
|
||||
function renderUseChildAttendance() {
|
||||
const client = createTestClient();
|
||||
const wrapper = ({ children }: { children: ReactNode }) => (
|
||||
<UrqlProvider value={client}>{children}</UrqlProvider>
|
||||
);
|
||||
return renderHook(() => useChildAttendance(), { wrapper });
|
||||
}
|
||||
|
||||
beforeAll(() => server.listen({ onUnhandledRequest: "error" }));
|
||||
afterEach(() => {
|
||||
cleanup();
|
||||
server.resetHandlers();
|
||||
});
|
||||
afterAll(() => server.close());
|
||||
|
||||
beforeEach(() => {
|
||||
localStorage.clear();
|
||||
useChildStore.setState({
|
||||
children: [],
|
||||
currentChildId: null,
|
||||
isLoading: false,
|
||||
});
|
||||
});
|
||||
|
||||
describe("useChildAttendance", () => {
|
||||
it("无 currentChildId 时暂停查询,返回空数组", () => {
|
||||
const { result } = renderUseChildAttendance();
|
||||
expect(result.current.attendance).toEqual([]);
|
||||
expect(result.current.loading).toBe(false);
|
||||
expect(result.current.error).toBeUndefined();
|
||||
});
|
||||
|
||||
it("有 currentChildId 时加载成功返回考勤记录", async () => {
|
||||
useChildStore.setState({ currentChildId: "student-001" });
|
||||
const { result } = renderUseChildAttendance();
|
||||
await waitFor(() => {
|
||||
expect(result.current.attendance.length).toBeGreaterThan(0);
|
||||
});
|
||||
expect(result.current.loading).toBe(false);
|
||||
expect(result.current.error).toBeUndefined();
|
||||
// 验证记录结构
|
||||
const first = result.current.attendance[0]!;
|
||||
expect(first).toHaveProperty("id");
|
||||
expect(first).toHaveProperty("date");
|
||||
expect(first).toHaveProperty("status");
|
||||
});
|
||||
|
||||
it("加载中 loading 为 true", () => {
|
||||
useChildStore.setState({ currentChildId: "student-001" });
|
||||
const { result } = renderUseChildAttendance();
|
||||
expect(result.current.loading).toBe(true);
|
||||
});
|
||||
|
||||
it("查询变量包含当前月份的 startDate 和 endDate", async () => {
|
||||
useChildStore.setState({ currentChildId: "student-001" });
|
||||
let capturedVars: unknown = null;
|
||||
server.resetHandlers(
|
||||
graphql.query("ChildAttendance", ({ variables }) => {
|
||||
capturedVars = variables;
|
||||
return HttpResponse.json({ data: { childAttendance: [] } });
|
||||
}),
|
||||
);
|
||||
|
||||
const { result } = renderUseChildAttendance();
|
||||
await waitFor(() => {
|
||||
expect(result.current.loading).toBe(false);
|
||||
});
|
||||
const vars = capturedVars as {
|
||||
childId: string;
|
||||
startDate: string;
|
||||
endDate: string;
|
||||
};
|
||||
expect(vars.childId).toBe("student-001");
|
||||
expect(vars.startDate).toMatch(/^\d{4}-\d{2}-\d{2}$/);
|
||||
expect(vars.endDate).toMatch(/^\d{4}-\d{2}-\d{2}$/);
|
||||
// startDate 应为本月 1 号
|
||||
const now = new Date();
|
||||
const expectedStart = new Date(now.getFullYear(), now.getMonth(), 1)
|
||||
.toISOString()
|
||||
.slice(0, 10);
|
||||
expect(vars.startDate).toBe(expectedStart);
|
||||
});
|
||||
|
||||
it("GraphQL 返回错误时透传 error", async () => {
|
||||
useChildStore.setState({ currentChildId: "student-001" });
|
||||
server.resetHandlers(
|
||||
graphql.query("ChildAttendance", () =>
|
||||
HttpResponse.json(
|
||||
{ errors: [{ message: "考勤查询失败" }] },
|
||||
{ status: 200 },
|
||||
),
|
||||
),
|
||||
);
|
||||
|
||||
const { result } = renderUseChildAttendance();
|
||||
await waitFor(() => {
|
||||
expect(result.current.error).toBeDefined();
|
||||
});
|
||||
expect(result.current.attendance).toEqual([]);
|
||||
expect(result.current.error?.message).toContain("考勤查询失败");
|
||||
});
|
||||
|
||||
it("切换 currentChildId 后重新请求", async () => {
|
||||
server.resetHandlers(
|
||||
graphql.query("ChildAttendance", ({ variables }) => {
|
||||
const childId = variables.childId as string;
|
||||
return HttpResponse.json({
|
||||
data: {
|
||||
childAttendance:
|
||||
childId === "student-001"
|
||||
? [
|
||||
{
|
||||
id: "att-1",
|
||||
date: "2026-07-01",
|
||||
status: "present",
|
||||
},
|
||||
]
|
||||
: [],
|
||||
},
|
||||
});
|
||||
}),
|
||||
);
|
||||
useChildStore.setState({ currentChildId: "student-001" });
|
||||
const { result } = renderUseChildAttendance();
|
||||
await waitFor(
|
||||
() => {
|
||||
expect(result.current.attendance.length).toBeGreaterThan(0);
|
||||
},
|
||||
{ timeout: 4000 },
|
||||
);
|
||||
|
||||
useChildStore.setState({ currentChildId: "student-002" });
|
||||
await waitFor(
|
||||
() => {
|
||||
expect(result.current.attendance).toEqual([]);
|
||||
},
|
||||
{ timeout: 4000 },
|
||||
);
|
||||
});
|
||||
});
|
||||
120
apps/parent-portal/src/hooks/useChildClasses.test.tsx
Normal file
120
apps/parent-portal/src/hooks/useChildClasses.test.tsx
Normal file
@@ -0,0 +1,120 @@
|
||||
// useChildClasses 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 { useChildClasses } from "./useChildClasses";
|
||||
|
||||
function createTestClient(): Client {
|
||||
return new Client({
|
||||
url: "/api/v1/parent/v1/graphql",
|
||||
exchanges: [cacheExchange, fetchExchange],
|
||||
fetchOptions: { headers: { "X-Requested-With": "XMLHttpRequest" } },
|
||||
});
|
||||
}
|
||||
|
||||
function renderUseChildClasses() {
|
||||
const client = createTestClient();
|
||||
const wrapper = ({ children }: { children: ReactNode }) => (
|
||||
<UrqlProvider value={client}>{children}</UrqlProvider>
|
||||
);
|
||||
return renderHook(() => useChildClasses(), { wrapper });
|
||||
}
|
||||
|
||||
beforeAll(() => server.listen({ onUnhandledRequest: "error" }));
|
||||
afterEach(() => {
|
||||
cleanup();
|
||||
server.resetHandlers();
|
||||
});
|
||||
afterAll(() => server.close());
|
||||
|
||||
beforeEach(() => {
|
||||
localStorage.clear();
|
||||
useChildStore.setState({
|
||||
children: [],
|
||||
currentChildId: null,
|
||||
isLoading: false,
|
||||
});
|
||||
});
|
||||
|
||||
describe("useChildClasses", () => {
|
||||
it("无 currentChildId 时暂停查询,返回空数组", () => {
|
||||
const { result } = renderUseChildClasses();
|
||||
expect(result.current.classes).toEqual([]);
|
||||
expect(result.current.loading).toBe(false);
|
||||
expect(result.current.error).toBeUndefined();
|
||||
});
|
||||
|
||||
it("有 currentChildId 时加载成功返回班级列表", async () => {
|
||||
useChildStore.setState({ currentChildId: "student-001" });
|
||||
const { result } = renderUseChildClasses();
|
||||
await waitFor(() => {
|
||||
expect(result.current.classes.length).toBeGreaterThan(0);
|
||||
});
|
||||
expect(result.current.classes).toHaveLength(2);
|
||||
expect(result.current.classes[0]!.name).toBe("初一(1)班");
|
||||
expect(result.current.classes[0]!.homeroomTeacher).toBe("王老师");
|
||||
expect(result.current.classes[0]!.studentCount).toBe(45);
|
||||
expect(result.current.loading).toBe(false);
|
||||
expect(result.current.error).toBeUndefined();
|
||||
});
|
||||
|
||||
it("加载中 loading 为 true", () => {
|
||||
useChildStore.setState({ currentChildId: "student-001" });
|
||||
const { result } = renderUseChildClasses();
|
||||
expect(result.current.loading).toBe(true);
|
||||
});
|
||||
|
||||
it("GraphQL 返回错误时透传 error", async () => {
|
||||
useChildStore.setState({ currentChildId: "student-001" });
|
||||
server.resetHandlers(
|
||||
graphql.query("ChildClasses", () =>
|
||||
HttpResponse.json(
|
||||
{ errors: [{ message: "班级查询失败" }] },
|
||||
{ status: 200 },
|
||||
),
|
||||
),
|
||||
);
|
||||
|
||||
const { result } = renderUseChildClasses();
|
||||
await waitFor(() => {
|
||||
expect(result.current.error).toBeDefined();
|
||||
});
|
||||
expect(result.current.classes).toEqual([]);
|
||||
expect(result.current.error?.message).toContain("班级查询失败");
|
||||
});
|
||||
|
||||
it("返回空班级列表", async () => {
|
||||
useChildStore.setState({ currentChildId: "student-001" });
|
||||
server.resetHandlers(
|
||||
graphql.query("ChildClasses", () =>
|
||||
HttpResponse.json({ data: { childClasses: [] } }),
|
||||
),
|
||||
);
|
||||
|
||||
const { result } = renderUseChildClasses();
|
||||
await waitFor(() => {
|
||||
expect(result.current.loading).toBe(false);
|
||||
});
|
||||
expect(result.current.classes).toEqual([]);
|
||||
});
|
||||
});
|
||||
34
apps/parent-portal/src/hooks/useChildClasses.ts
Normal file
34
apps/parent-portal/src/hooks/useChildClasses.ts
Normal file
@@ -0,0 +1,34 @@
|
||||
// useChildClasses:获取子女所在班级列表
|
||||
// 依据:02-architecture-design.md §4.2 GraphQL 接入
|
||||
// 对标 student-portal /my-classes 页面(家长只读视角)
|
||||
|
||||
"use client";
|
||||
|
||||
import { useQuery, type CombinedError } from "urql";
|
||||
import { CHILD_CLASSES } from "@/lib/graphql/operations";
|
||||
import type { ClassInfo } from "@/types";
|
||||
import { useChildStore } from "@/store/child-store";
|
||||
|
||||
interface ChildClassesResponse {
|
||||
childClasses: ClassInfo[];
|
||||
}
|
||||
|
||||
export function useChildClasses(): {
|
||||
classes: ClassInfo[];
|
||||
loading: boolean;
|
||||
error: CombinedError | undefined;
|
||||
} {
|
||||
const currentChildId = useChildStore((s) => s.currentChildId);
|
||||
|
||||
const [result] = useQuery<ChildClassesResponse>({
|
||||
query: CHILD_CLASSES,
|
||||
variables: { childId: currentChildId },
|
||||
pause: !currentChildId,
|
||||
});
|
||||
|
||||
return {
|
||||
classes: result.data?.childClasses ?? [],
|
||||
loading: result.fetching,
|
||||
error: result.error,
|
||||
};
|
||||
}
|
||||
163
apps/parent-portal/src/hooks/useChildExamResult.test.tsx
Normal file
163
apps/parent-portal/src/hooks/useChildExamResult.test.tsx
Normal file
@@ -0,0 +1,163 @@
|
||||
// useChildExamResult Hook 单测
|
||||
// 依据:02-architecture-design.md §4.2 GraphQL 接入
|
||||
// 覆盖:无 currentChildId 暂停 / 无 examId 暂停 / 加载态 / 成功返回 / 错误态
|
||||
|
||||
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 { useChildExamResult } from "./useChildExamResult";
|
||||
|
||||
function createTestClient(): Client {
|
||||
return new Client({
|
||||
url: "/api/v1/parent/v1/graphql",
|
||||
exchanges: [cacheExchange, fetchExchange],
|
||||
fetchOptions: { headers: { "X-Requested-With": "XMLHttpRequest" } },
|
||||
});
|
||||
}
|
||||
|
||||
function renderUseChildExamResult(examId: string) {
|
||||
const client = createTestClient();
|
||||
const wrapper = ({ children }: { children: ReactNode }) => (
|
||||
<UrqlProvider value={client}>{children}</UrqlProvider>
|
||||
);
|
||||
return renderHook(() => useChildExamResult(examId), { wrapper });
|
||||
}
|
||||
|
||||
beforeAll(() => server.listen({ onUnhandledRequest: "error" }));
|
||||
afterEach(() => {
|
||||
cleanup();
|
||||
server.resetHandlers();
|
||||
});
|
||||
afterAll(() => server.close());
|
||||
|
||||
beforeEach(() => {
|
||||
localStorage.clear();
|
||||
useChildStore.setState({
|
||||
children: [],
|
||||
currentChildId: null,
|
||||
isLoading: false,
|
||||
});
|
||||
});
|
||||
|
||||
describe("useChildExamResult", () => {
|
||||
it("无 currentChildId 时暂停查询,返回 null", () => {
|
||||
const { result } = renderUseChildExamResult("exam-001");
|
||||
expect(result.current.result).toBeNull();
|
||||
expect(result.current.loading).toBe(false);
|
||||
expect(result.current.error).toBeUndefined();
|
||||
});
|
||||
|
||||
it("无 examId 时暂停查询,返回 null", () => {
|
||||
useChildStore.setState({ currentChildId: "student-001" });
|
||||
const { result } = renderUseChildExamResult("");
|
||||
expect(result.current.result).toBeNull();
|
||||
expect(result.current.loading).toBe(false);
|
||||
expect(result.current.error).toBeUndefined();
|
||||
});
|
||||
|
||||
it("有 currentChildId 和 examId 时加载成功返回考试结果", async () => {
|
||||
useChildStore.setState({ currentChildId: "student-001" });
|
||||
const { result } = renderUseChildExamResult("exam-001");
|
||||
await waitFor(() => {
|
||||
expect(result.current.result).not.toBeNull();
|
||||
});
|
||||
expect(result.current.result?.examId).toBe("exam-001");
|
||||
expect(result.current.result?.examName).toBe("期中数学测验");
|
||||
expect(result.current.result?.score).toBe(78);
|
||||
expect(result.current.result?.maxScore).toBe(100);
|
||||
expect(result.current.result?.grade).toBe("B+");
|
||||
expect(result.current.result?.questionResults).toHaveLength(5);
|
||||
expect(result.current.loading).toBe(false);
|
||||
expect(result.current.error).toBeUndefined();
|
||||
});
|
||||
|
||||
it("加载中 loading 为 true", () => {
|
||||
useChildStore.setState({ currentChildId: "student-001" });
|
||||
const { result } = renderUseChildExamResult("exam-001");
|
||||
expect(result.current.loading).toBe(true);
|
||||
});
|
||||
|
||||
it("查询变量携带 childId 和 examId", async () => {
|
||||
useChildStore.setState({ currentChildId: "student-001" });
|
||||
let capturedVars: unknown = null;
|
||||
server.resetHandlers(
|
||||
graphql.query("ChildExamResult", ({ variables }) => {
|
||||
capturedVars = variables;
|
||||
return HttpResponse.json({
|
||||
data: {
|
||||
childExamResult: {
|
||||
examId: "exam-001",
|
||||
examName: "test",
|
||||
score: 80,
|
||||
maxScore: 100,
|
||||
grade: "B",
|
||||
submittedAt: "2026-03-15T10:45:00Z",
|
||||
durationSeconds: 3600,
|
||||
questionResults: [],
|
||||
},
|
||||
},
|
||||
});
|
||||
}),
|
||||
);
|
||||
|
||||
const { result } = renderUseChildExamResult("exam-001");
|
||||
await waitFor(() => {
|
||||
expect(result.current.result).not.toBeNull();
|
||||
});
|
||||
expect(capturedVars).toEqual({
|
||||
childId: "student-001",
|
||||
examId: "exam-001",
|
||||
});
|
||||
});
|
||||
|
||||
it("GraphQL 返回错误时透传 error", async () => {
|
||||
useChildStore.setState({ currentChildId: "student-001" });
|
||||
server.resetHandlers(
|
||||
graphql.query("ChildExamResult", () =>
|
||||
HttpResponse.json(
|
||||
{ errors: [{ message: "考试结果查询失败" }] },
|
||||
{ status: 200 },
|
||||
),
|
||||
),
|
||||
);
|
||||
|
||||
const { result } = renderUseChildExamResult("exam-001");
|
||||
await waitFor(() => {
|
||||
expect(result.current.error).toBeDefined();
|
||||
});
|
||||
expect(result.current.result).toBeNull();
|
||||
expect(result.current.error?.message).toContain("考试结果查询失败");
|
||||
});
|
||||
|
||||
it("返回空结果时 result 为 null", async () => {
|
||||
useChildStore.setState({ currentChildId: "student-001" });
|
||||
server.resetHandlers(
|
||||
graphql.query("ChildExamResult", () =>
|
||||
HttpResponse.json({ data: { childExamResult: null } }),
|
||||
),
|
||||
);
|
||||
|
||||
const { result } = renderUseChildExamResult("exam-999");
|
||||
await waitFor(() => {
|
||||
expect(result.current.loading).toBe(false);
|
||||
});
|
||||
expect(result.current.result).toBeNull();
|
||||
});
|
||||
});
|
||||
34
apps/parent-portal/src/hooks/useChildExamResult.ts
Normal file
34
apps/parent-portal/src/hooks/useChildExamResult.ts
Normal file
@@ -0,0 +1,34 @@
|
||||
// useChildExamResult:获取子女考试结果(逐题回顾)
|
||||
// 依据:02-architecture-design.md §4.2 GraphQL 接入
|
||||
// 对标 student-portal /my-exams/[id]/result 页面(家长只读视角)
|
||||
|
||||
"use client";
|
||||
|
||||
import { useQuery, type CombinedError } from "urql";
|
||||
import { CHILD_EXAM_RESULT } from "@/lib/graphql/operations";
|
||||
import type { ExamResult } from "@/types";
|
||||
import { useChildStore } from "@/store/child-store";
|
||||
|
||||
interface ChildExamResultResponse {
|
||||
childExamResult: ExamResult;
|
||||
}
|
||||
|
||||
export function useChildExamResult(examId: string): {
|
||||
result: ExamResult | null;
|
||||
loading: boolean;
|
||||
error: CombinedError | undefined;
|
||||
} {
|
||||
const currentChildId = useChildStore((s) => s.currentChildId);
|
||||
|
||||
const [res] = useQuery<ChildExamResultResponse>({
|
||||
query: CHILD_EXAM_RESULT,
|
||||
variables: { childId: currentChildId, examId },
|
||||
pause: !currentChildId || !examId,
|
||||
});
|
||||
|
||||
return {
|
||||
result: res.data?.childExamResult ?? null,
|
||||
loading: res.fetching,
|
||||
error: res.error,
|
||||
};
|
||||
}
|
||||
164
apps/parent-portal/src/hooks/useChildExams.test.tsx
Normal file
164
apps/parent-portal/src/hooks/useChildExams.test.tsx
Normal file
@@ -0,0 +1,164 @@
|
||||
// useChildExams 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 { useChildExams } from "./useChildExams";
|
||||
|
||||
function createTestClient(): Client {
|
||||
return new Client({
|
||||
url: "/api/v1/parent/v1/graphql",
|
||||
exchanges: [cacheExchange, fetchExchange],
|
||||
fetchOptions: { headers: { "X-Requested-With": "XMLHttpRequest" } },
|
||||
});
|
||||
}
|
||||
|
||||
function renderUseChildExams() {
|
||||
const client = createTestClient();
|
||||
const wrapper = ({ children }: { children: ReactNode }) => (
|
||||
<UrqlProvider value={client}>{children}</UrqlProvider>
|
||||
);
|
||||
return renderHook(() => useChildExams(), { wrapper });
|
||||
}
|
||||
|
||||
beforeAll(() => server.listen({ onUnhandledRequest: "error" }));
|
||||
afterEach(() => {
|
||||
cleanup();
|
||||
server.resetHandlers();
|
||||
});
|
||||
afterAll(() => server.close());
|
||||
|
||||
beforeEach(() => {
|
||||
localStorage.clear();
|
||||
useChildStore.setState({
|
||||
children: [],
|
||||
currentChildId: null,
|
||||
isLoading: false,
|
||||
});
|
||||
});
|
||||
|
||||
describe("useChildExams", () => {
|
||||
it("无 currentChildId 时暂停查询,返回空数组", () => {
|
||||
const { result } = renderUseChildExams();
|
||||
expect(result.current.exams).toEqual([]);
|
||||
expect(result.current.loading).toBe(false);
|
||||
expect(result.current.error).toBeUndefined();
|
||||
});
|
||||
|
||||
it("有 currentChildId 时加载成功返回考试列表", async () => {
|
||||
useChildStore.setState({ currentChildId: "student-001" });
|
||||
const { result } = renderUseChildExams();
|
||||
await waitFor(() => {
|
||||
expect(result.current.exams.length).toBeGreaterThan(0);
|
||||
});
|
||||
expect(result.current.exams).toHaveLength(5);
|
||||
expect(result.current.exams[0]!.name).toBe("期中数学测验");
|
||||
expect(result.current.exams[0]!.subject).toBe("数学");
|
||||
expect(result.current.exams[0]!.status).toBe("graded");
|
||||
expect(result.current.loading).toBe(false);
|
||||
expect(result.current.error).toBeUndefined();
|
||||
});
|
||||
|
||||
it("加载中 loading 为 true", () => {
|
||||
useChildStore.setState({ currentChildId: "student-001" });
|
||||
const { result } = renderUseChildExams();
|
||||
expect(result.current.loading).toBe(true);
|
||||
});
|
||||
|
||||
it("GraphQL 返回错误时透传 error", async () => {
|
||||
useChildStore.setState({ currentChildId: "student-001" });
|
||||
server.resetHandlers(
|
||||
graphql.query("ChildExams", () =>
|
||||
HttpResponse.json(
|
||||
{ errors: [{ message: "考试查询失败" }] },
|
||||
{ status: 200 },
|
||||
),
|
||||
),
|
||||
);
|
||||
|
||||
const { result } = renderUseChildExams();
|
||||
await waitFor(() => {
|
||||
expect(result.current.error).toBeDefined();
|
||||
});
|
||||
expect(result.current.exams).toEqual([]);
|
||||
expect(result.current.error?.message).toContain("考试查询失败");
|
||||
});
|
||||
|
||||
it("返回空考试列表", async () => {
|
||||
useChildStore.setState({ currentChildId: "student-001" });
|
||||
server.resetHandlers(
|
||||
graphql.query("ChildExams", () =>
|
||||
HttpResponse.json({ data: { childExams: [] } }),
|
||||
),
|
||||
);
|
||||
|
||||
const { result } = renderUseChildExams();
|
||||
await waitFor(() => {
|
||||
expect(result.current.loading).toBe(false);
|
||||
});
|
||||
expect(result.current.exams).toEqual([]);
|
||||
});
|
||||
|
||||
it("切换 currentChildId 后重新请求", async () => {
|
||||
server.resetHandlers(
|
||||
graphql.query("ChildExams", ({ variables }) => {
|
||||
const childId = variables.childId as string;
|
||||
return HttpResponse.json({
|
||||
data: {
|
||||
childExams:
|
||||
childId === "student-001"
|
||||
? [
|
||||
{
|
||||
id: "exam-001",
|
||||
name: "数学测验",
|
||||
subject: "数学",
|
||||
status: "graded",
|
||||
startsAt: "2026-03-15T09:00:00Z",
|
||||
expiresAt: "2026-03-15T11:00:00Z",
|
||||
durationSeconds: 7200,
|
||||
questionCount: 25,
|
||||
totalScore: 100,
|
||||
},
|
||||
]
|
||||
: [],
|
||||
},
|
||||
});
|
||||
}),
|
||||
);
|
||||
useChildStore.setState({ currentChildId: "student-001" });
|
||||
const { result } = renderUseChildExams();
|
||||
await waitFor(
|
||||
() => {
|
||||
expect(result.current.exams.length).toBeGreaterThan(0);
|
||||
},
|
||||
{ timeout: 4000 },
|
||||
);
|
||||
|
||||
useChildStore.setState({ currentChildId: "student-002" });
|
||||
await waitFor(
|
||||
() => {
|
||||
expect(result.current.exams).toEqual([]);
|
||||
},
|
||||
{ timeout: 4000 },
|
||||
);
|
||||
});
|
||||
});
|
||||
34
apps/parent-portal/src/hooks/useChildExams.ts
Normal file
34
apps/parent-portal/src/hooks/useChildExams.ts
Normal file
@@ -0,0 +1,34 @@
|
||||
// useChildExams:获取子女考试列表
|
||||
// 依据:02-architecture-design.md §4.2 GraphQL 接入
|
||||
// 对标 student-portal /my-exams 页面(家长只读视角)
|
||||
|
||||
"use client";
|
||||
|
||||
import { useQuery, type CombinedError } from "urql";
|
||||
import { CHILD_EXAMS } from "@/lib/graphql/operations";
|
||||
import type { ExamListItem } from "@/types";
|
||||
import { useChildStore } from "@/store/child-store";
|
||||
|
||||
interface ChildExamsResponse {
|
||||
childExams: ExamListItem[];
|
||||
}
|
||||
|
||||
export function useChildExams(): {
|
||||
exams: ExamListItem[];
|
||||
loading: boolean;
|
||||
error: CombinedError | undefined;
|
||||
} {
|
||||
const currentChildId = useChildStore((s) => s.currentChildId);
|
||||
|
||||
const [result] = useQuery<ChildExamsResponse>({
|
||||
query: CHILD_EXAMS,
|
||||
variables: { childId: currentChildId },
|
||||
pause: !currentChildId,
|
||||
});
|
||||
|
||||
return {
|
||||
exams: result.data?.childExams ?? [],
|
||||
loading: result.fetching,
|
||||
error: result.error,
|
||||
};
|
||||
}
|
||||
161
apps/parent-portal/src/hooks/useChildGrades.test.tsx
Normal file
161
apps/parent-portal/src/hooks/useChildGrades.test.tsx
Normal file
@@ -0,0 +1,161 @@
|
||||
// useChildGrades 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 { useChildGrades } from "./useChildGrades";
|
||||
|
||||
function createTestClient(): Client {
|
||||
return new Client({
|
||||
url: "/api/v1/parent/v1/graphql",
|
||||
exchanges: [cacheExchange, fetchExchange],
|
||||
fetchOptions: { headers: { "X-Requested-With": "XMLHttpRequest" } },
|
||||
});
|
||||
}
|
||||
|
||||
function renderUseChildGrades(subject?: string) {
|
||||
const client = createTestClient();
|
||||
const wrapper = ({ children }: { children: ReactNode }) => (
|
||||
<UrqlProvider value={client}>{children}</UrqlProvider>
|
||||
);
|
||||
return renderHook(() => useChildGrades(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("useChildGrades", () => {
|
||||
it("无 currentChildId 时暂停查询,返回空数组", () => {
|
||||
const { result } = renderUseChildGrades();
|
||||
expect(result.current.grades).toEqual([]);
|
||||
expect(result.current.loading).toBe(false);
|
||||
expect(result.current.error).toBeUndefined();
|
||||
});
|
||||
|
||||
it("有 currentChildId 时加载成功返回成绩列表", async () => {
|
||||
useChildStore.setState({ currentChildId: "student-001" });
|
||||
const { result } = renderUseChildGrades();
|
||||
await waitFor(() => {
|
||||
expect(result.current.grades.length).toBeGreaterThan(0);
|
||||
});
|
||||
expect(result.current.grades).toHaveLength(4);
|
||||
expect(result.current.grades[0]!.subject).toBe("数学");
|
||||
expect(result.current.grades[0]!.gradeLevel).toBe("A");
|
||||
expect(result.current.loading).toBe(false);
|
||||
expect(result.current.error).toBeUndefined();
|
||||
});
|
||||
|
||||
it("加载中 loading 为 true", () => {
|
||||
useChildStore.setState({ currentChildId: "student-001" });
|
||||
const { result } = renderUseChildGrades();
|
||||
expect(result.current.loading).toBe(true);
|
||||
});
|
||||
|
||||
it("传入 subject 时变量携带 subject", async () => {
|
||||
useChildStore.setState({ currentChildId: "student-001" });
|
||||
let capturedVars: unknown = null;
|
||||
server.resetHandlers(
|
||||
graphql.query("ChildGrades", ({ variables }) => {
|
||||
capturedVars = variables;
|
||||
return HttpResponse.json({
|
||||
data: {
|
||||
childGrades: [
|
||||
{
|
||||
examId: "exam-001",
|
||||
examName: "期中数学测验",
|
||||
examDate: "2026-03-15T09:00:00Z",
|
||||
subject: "数学",
|
||||
studentScore: 92,
|
||||
classAverage: 78,
|
||||
classMax: 100,
|
||||
classMin: 45,
|
||||
gradeLevel: "A",
|
||||
},
|
||||
],
|
||||
},
|
||||
});
|
||||
}),
|
||||
);
|
||||
|
||||
const { result } = renderUseChildGrades("数学");
|
||||
await waitFor(() => {
|
||||
expect(result.current.grades).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("ChildGrades", ({ variables }) => {
|
||||
capturedVars = variables;
|
||||
return HttpResponse.json({ data: { childGrades: [] } });
|
||||
}),
|
||||
);
|
||||
|
||||
const { result } = renderUseChildGrades();
|
||||
await waitFor(() => {
|
||||
expect(result.current.loading).toBe(false);
|
||||
});
|
||||
expect(capturedVars).toEqual({
|
||||
childId: "student-001",
|
||||
subject: null,
|
||||
});
|
||||
expect(result.current.grades).toEqual([]);
|
||||
});
|
||||
|
||||
it("GraphQL 返回错误时透传 error", async () => {
|
||||
useChildStore.setState({ currentChildId: "student-001" });
|
||||
server.resetHandlers(
|
||||
graphql.query("ChildGrades", () =>
|
||||
HttpResponse.json(
|
||||
{ errors: [{ message: "成绩查询失败" }] },
|
||||
{ status: 200 },
|
||||
),
|
||||
),
|
||||
);
|
||||
|
||||
const { result } = renderUseChildGrades();
|
||||
await waitFor(() => {
|
||||
expect(result.current.error).toBeDefined();
|
||||
});
|
||||
expect(result.current.grades).toEqual([]);
|
||||
expect(result.current.error?.message).toContain("成绩查询失败");
|
||||
});
|
||||
});
|
||||
144
apps/parent-portal/src/hooks/useChildHomework.test.tsx
Normal file
144
apps/parent-portal/src/hooks/useChildHomework.test.tsx
Normal file
@@ -0,0 +1,144 @@
|
||||
// useChildHomework Hook 单测
|
||||
// 依据:02-architecture-design.md §4.2 GraphQL 接入
|
||||
// 覆盖:无 currentChildId 暂停 / 加载态 / 成功返回 / status 过滤 / 错误态
|
||||
|
||||
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 { useChildHomework } from "./useChildHomework";
|
||||
|
||||
function createTestClient(): Client {
|
||||
return new Client({
|
||||
url: "/api/v1/parent/v1/graphql",
|
||||
exchanges: [cacheExchange, fetchExchange],
|
||||
fetchOptions: { headers: { "X-Requested-With": "XMLHttpRequest" } },
|
||||
});
|
||||
}
|
||||
|
||||
function renderUseChildHomework(status?: string) {
|
||||
const client = createTestClient();
|
||||
const wrapper = ({ children }: { children: ReactNode }) => (
|
||||
<UrqlProvider value={client}>{children}</UrqlProvider>
|
||||
);
|
||||
return renderHook(() => useChildHomework(status), { wrapper });
|
||||
}
|
||||
|
||||
beforeAll(() => server.listen({ onUnhandledRequest: "error" }));
|
||||
afterEach(() => {
|
||||
cleanup();
|
||||
server.resetHandlers();
|
||||
});
|
||||
afterAll(() => server.close());
|
||||
|
||||
beforeEach(() => {
|
||||
localStorage.clear();
|
||||
useChildStore.setState({
|
||||
children: [],
|
||||
currentChildId: null,
|
||||
isLoading: false,
|
||||
});
|
||||
});
|
||||
|
||||
describe("useChildHomework", () => {
|
||||
it("无 currentChildId 时暂停查询,返回空数组", () => {
|
||||
const { result } = renderUseChildHomework();
|
||||
expect(result.current.homework).toEqual([]);
|
||||
expect(result.current.loading).toBe(false);
|
||||
expect(result.current.error).toBeUndefined();
|
||||
});
|
||||
|
||||
it("有 currentChildId 时加载成功返回作业列表", async () => {
|
||||
useChildStore.setState({ currentChildId: "student-001" });
|
||||
const { result } = renderUseChildHomework();
|
||||
await waitFor(() => {
|
||||
expect(result.current.homework.length).toBeGreaterThan(0);
|
||||
});
|
||||
expect(result.current.homework).toHaveLength(4);
|
||||
expect(result.current.homework[0]!.title).toBe("数学第三章习题");
|
||||
expect(result.current.homework[0]!.status).toBe("in_progress");
|
||||
expect(result.current.loading).toBe(false);
|
||||
expect(result.current.error).toBeUndefined();
|
||||
});
|
||||
|
||||
it("加载中 loading 为 true", () => {
|
||||
useChildStore.setState({ currentChildId: "student-001" });
|
||||
const { result } = renderUseChildHomework();
|
||||
expect(result.current.loading).toBe(true);
|
||||
});
|
||||
|
||||
it("传入 status 时变量携带 status", async () => {
|
||||
useChildStore.setState({ currentChildId: "student-001" });
|
||||
let capturedVars: unknown = null;
|
||||
server.resetHandlers(
|
||||
graphql.query("ChildHomework", ({ variables }) => {
|
||||
capturedVars = variables;
|
||||
return HttpResponse.json({ data: { childHomework: [] } });
|
||||
}),
|
||||
);
|
||||
|
||||
const { result } = renderUseChildHomework("graded");
|
||||
await waitFor(() => {
|
||||
expect(result.current.loading).toBe(false);
|
||||
});
|
||||
expect(capturedVars).toEqual({
|
||||
childId: "student-001",
|
||||
status: "graded",
|
||||
});
|
||||
});
|
||||
|
||||
it("未传 status 时变量 status 为 null", async () => {
|
||||
useChildStore.setState({ currentChildId: "student-001" });
|
||||
let capturedVars: unknown = null;
|
||||
server.resetHandlers(
|
||||
graphql.query("ChildHomework", ({ variables }) => {
|
||||
capturedVars = variables;
|
||||
return HttpResponse.json({ data: { childHomework: [] } });
|
||||
}),
|
||||
);
|
||||
|
||||
const { result } = renderUseChildHomework();
|
||||
await waitFor(() => {
|
||||
expect(result.current.loading).toBe(false);
|
||||
});
|
||||
expect(capturedVars).toEqual({
|
||||
childId: "student-001",
|
||||
status: null,
|
||||
});
|
||||
});
|
||||
|
||||
it("GraphQL 返回错误时透传 error", async () => {
|
||||
useChildStore.setState({ currentChildId: "student-001" });
|
||||
server.resetHandlers(
|
||||
graphql.query("ChildHomework", () =>
|
||||
HttpResponse.json(
|
||||
{ errors: [{ message: "作业查询失败" }] },
|
||||
{ status: 200 },
|
||||
),
|
||||
),
|
||||
);
|
||||
|
||||
const { result } = renderUseChildHomework();
|
||||
await waitFor(() => {
|
||||
expect(result.current.error).toBeDefined();
|
||||
});
|
||||
expect(result.current.homework).toEqual([]);
|
||||
expect(result.current.error?.message).toContain("作业查询失败");
|
||||
});
|
||||
});
|
||||
164
apps/parent-portal/src/hooks/useChildLearningPath.test.tsx
Normal file
164
apps/parent-portal/src/hooks/useChildLearningPath.test.tsx
Normal file
@@ -0,0 +1,164 @@
|
||||
// useChildLearningPath 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 { useChildLearningPath } from "./useChildLearningPath";
|
||||
|
||||
function createTestClient(): Client {
|
||||
return new Client({
|
||||
url: "/api/v1/parent/v1/graphql",
|
||||
exchanges: [cacheExchange, fetchExchange],
|
||||
fetchOptions: { headers: { "X-Requested-With": "XMLHttpRequest" } },
|
||||
});
|
||||
}
|
||||
|
||||
function renderUseChildLearningPath() {
|
||||
const client = createTestClient();
|
||||
const wrapper = ({ children }: { children: ReactNode }) => (
|
||||
<UrqlProvider value={client}>{children}</UrqlProvider>
|
||||
);
|
||||
return renderHook(() => useChildLearningPath(), { wrapper });
|
||||
}
|
||||
|
||||
beforeAll(() => server.listen({ onUnhandledRequest: "error" }));
|
||||
afterEach(() => {
|
||||
cleanup();
|
||||
server.resetHandlers();
|
||||
});
|
||||
afterAll(() => server.close());
|
||||
|
||||
beforeEach(() => {
|
||||
localStorage.clear();
|
||||
useChildStore.setState({
|
||||
children: [],
|
||||
currentChildId: null,
|
||||
isLoading: false,
|
||||
});
|
||||
});
|
||||
|
||||
describe("useChildLearningPath", () => {
|
||||
it("无 currentChildId 时暂停查询,返回空数组", () => {
|
||||
const { result } = renderUseChildLearningPath();
|
||||
expect(result.current.nodes).toEqual([]);
|
||||
expect(result.current.loading).toBe(false);
|
||||
expect(result.current.error).toBeUndefined();
|
||||
});
|
||||
|
||||
it("有 currentChildId 时加载成功返回学习路径节点", async () => {
|
||||
useChildStore.setState({ currentChildId: "student-001" });
|
||||
const { result } = renderUseChildLearningPath();
|
||||
await waitFor(() => {
|
||||
expect(result.current.nodes.length).toBeGreaterThan(0);
|
||||
});
|
||||
expect(result.current.nodes).toHaveLength(5);
|
||||
expect(result.current.nodes[0]!.name).toBe("一元一次方程");
|
||||
expect(result.current.nodes[0]!.status).toBe("completed");
|
||||
expect(result.current.nodes[0]!.mastery).toBe(0.92);
|
||||
expect(result.current.nodes[2]!.status).toBe("in-progress");
|
||||
expect(result.current.nodes[4]!.status).toBe("locked");
|
||||
expect(result.current.loading).toBe(false);
|
||||
expect(result.current.error).toBeUndefined();
|
||||
});
|
||||
|
||||
it("加载中 loading 为 true", () => {
|
||||
useChildStore.setState({ currentChildId: "student-001" });
|
||||
const { result } = renderUseChildLearningPath();
|
||||
expect(result.current.loading).toBe(true);
|
||||
});
|
||||
|
||||
it("GraphQL 返回错误时透传 error", async () => {
|
||||
useChildStore.setState({ currentChildId: "student-001" });
|
||||
server.resetHandlers(
|
||||
graphql.query("ChildLearningPath", () =>
|
||||
HttpResponse.json(
|
||||
{ errors: [{ message: "学习路径查询失败" }] },
|
||||
{ status: 200 },
|
||||
),
|
||||
),
|
||||
);
|
||||
|
||||
const { result } = renderUseChildLearningPath();
|
||||
await waitFor(() => {
|
||||
expect(result.current.error).toBeDefined();
|
||||
});
|
||||
expect(result.current.nodes).toEqual([]);
|
||||
expect(result.current.error?.message).toContain("学习路径查询失败");
|
||||
});
|
||||
|
||||
it("返回空学习路径", async () => {
|
||||
useChildStore.setState({ currentChildId: "student-001" });
|
||||
server.resetHandlers(
|
||||
graphql.query("ChildLearningPath", () =>
|
||||
HttpResponse.json({ data: { childLearningPath: [] } }),
|
||||
),
|
||||
);
|
||||
|
||||
const { result } = renderUseChildLearningPath();
|
||||
await waitFor(() => {
|
||||
expect(result.current.loading).toBe(false);
|
||||
});
|
||||
expect(result.current.nodes).toEqual([]);
|
||||
});
|
||||
|
||||
it("切换 currentChildId 后重新请求", async () => {
|
||||
server.resetHandlers(
|
||||
graphql.query("ChildLearningPath", ({ variables }) => {
|
||||
const childId = variables.childId as string;
|
||||
return HttpResponse.json({
|
||||
data: {
|
||||
childLearningPath:
|
||||
childId === "student-001"
|
||||
? [
|
||||
{
|
||||
id: "kp-001",
|
||||
name: "一元一次方程",
|
||||
knowledgePointId: "kp-001",
|
||||
status: "completed",
|
||||
mastery: 0.92,
|
||||
dependencies: [],
|
||||
recommendedOrder: 1,
|
||||
},
|
||||
]
|
||||
: [],
|
||||
},
|
||||
});
|
||||
}),
|
||||
);
|
||||
useChildStore.setState({ currentChildId: "student-001" });
|
||||
const { result } = renderUseChildLearningPath();
|
||||
await waitFor(
|
||||
() => {
|
||||
expect(result.current.nodes.length).toBeGreaterThan(0);
|
||||
},
|
||||
{ timeout: 4000 },
|
||||
);
|
||||
|
||||
useChildStore.setState({ currentChildId: "student-002" });
|
||||
await waitFor(
|
||||
() => {
|
||||
expect(result.current.nodes).toEqual([]);
|
||||
},
|
||||
{ timeout: 4000 },
|
||||
);
|
||||
});
|
||||
});
|
||||
34
apps/parent-portal/src/hooks/useChildLearningPath.ts
Normal file
34
apps/parent-portal/src/hooks/useChildLearningPath.ts
Normal file
@@ -0,0 +1,34 @@
|
||||
// useChildLearningPath:获取子女学习路径(知识点掌握)
|
||||
// 依据:02-architecture-design.md §4.2 GraphQL 接入
|
||||
// 对标 student-portal /learning-path 页面(家长只读视角)
|
||||
|
||||
"use client";
|
||||
|
||||
import { useQuery, type CombinedError } from "urql";
|
||||
import { CHILD_LEARNING_PATH } from "@/lib/graphql/operations";
|
||||
import type { LearningPathNode } from "@/types";
|
||||
import { useChildStore } from "@/store/child-store";
|
||||
|
||||
interface ChildLearningPathResponse {
|
||||
childLearningPath: LearningPathNode[];
|
||||
}
|
||||
|
||||
export function useChildLearningPath(): {
|
||||
nodes: LearningPathNode[];
|
||||
loading: boolean;
|
||||
error: CombinedError | undefined;
|
||||
} {
|
||||
const currentChildId = useChildStore((s) => s.currentChildId);
|
||||
|
||||
const [result] = useQuery<ChildLearningPathResponse>({
|
||||
query: CHILD_LEARNING_PATH,
|
||||
variables: { childId: currentChildId },
|
||||
pause: !currentChildId,
|
||||
});
|
||||
|
||||
return {
|
||||
nodes: result.data?.childLearningPath ?? [],
|
||||
loading: result.fetching,
|
||||
error: result.error,
|
||||
};
|
||||
}
|
||||
160
apps/parent-portal/src/hooks/useChildSummary.test.tsx
Normal file
160
apps/parent-portal/src/hooks/useChildSummary.test.tsx
Normal file
@@ -0,0 +1,160 @@
|
||||
// useChildSummary 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 { useChildSummary } from "./useChildSummary";
|
||||
|
||||
function createTestClient(): Client {
|
||||
return new Client({
|
||||
url: "/api/v1/parent/v1/graphql",
|
||||
exchanges: [cacheExchange, fetchExchange],
|
||||
fetchOptions: { headers: { "X-Requested-With": "XMLHttpRequest" } },
|
||||
});
|
||||
}
|
||||
|
||||
function renderUseChildSummary() {
|
||||
const client = createTestClient();
|
||||
const wrapper = ({ children }: { children: ReactNode }) => (
|
||||
<UrqlProvider value={client}>{children}</UrqlProvider>
|
||||
);
|
||||
return renderHook(() => useChildSummary(), { wrapper });
|
||||
}
|
||||
|
||||
beforeAll(() => server.listen({ onUnhandledRequest: "error" }));
|
||||
afterEach(() => {
|
||||
cleanup();
|
||||
server.resetHandlers();
|
||||
});
|
||||
afterAll(() => server.close());
|
||||
|
||||
beforeEach(() => {
|
||||
localStorage.clear();
|
||||
useChildStore.setState({
|
||||
children: [],
|
||||
currentChildId: null,
|
||||
isLoading: false,
|
||||
});
|
||||
});
|
||||
|
||||
describe("useChildSummary", () => {
|
||||
it("无 currentChildId 时暂停查询,返回 null 且不加载", () => {
|
||||
const { result } = renderUseChildSummary();
|
||||
expect(result.current.summary).toBeNull();
|
||||
expect(result.current.loading).toBe(false);
|
||||
expect(result.current.error).toBeUndefined();
|
||||
});
|
||||
|
||||
it("有 currentChildId 时加载成功返回概览", async () => {
|
||||
useChildStore.setState({ currentChildId: "student-001" });
|
||||
const { result } = renderUseChildSummary();
|
||||
await waitFor(() => {
|
||||
expect(result.current.summary).not.toBeNull();
|
||||
});
|
||||
expect(result.current.summary?.childId).toBe("student-001");
|
||||
expect(result.current.summary?.avgScore).toBe(90);
|
||||
expect(result.current.summary?.classRank).toBe(5);
|
||||
expect(result.current.summary?.attendanceRate).toBe(0.96);
|
||||
expect(result.current.summary?.pendingHomeworkCount).toBe(3);
|
||||
expect(result.current.summary?.recentGradeTrend).toBe("up");
|
||||
expect(result.current.summary?.recentScores).toHaveLength(4);
|
||||
expect(result.current.summary?.upcomingEvents).toHaveLength(2);
|
||||
expect(result.current.loading).toBe(false);
|
||||
expect(result.current.error).toBeUndefined();
|
||||
});
|
||||
|
||||
it("加载中 loading 为 true", () => {
|
||||
useChildStore.setState({ currentChildId: "student-001" });
|
||||
const { result } = renderUseChildSummary();
|
||||
expect(result.current.loading).toBe(true);
|
||||
});
|
||||
|
||||
it("GraphQL 返回错误时透传 error", async () => {
|
||||
useChildStore.setState({ currentChildId: "student-001" });
|
||||
server.resetHandlers(
|
||||
graphql.query("ChildSummary", () =>
|
||||
HttpResponse.json({ errors: [{ message: "无权限" }] }, { status: 200 }),
|
||||
),
|
||||
);
|
||||
|
||||
const { result } = renderUseChildSummary();
|
||||
await waitFor(() => {
|
||||
expect(result.current.error).toBeDefined();
|
||||
});
|
||||
expect(result.current.summary).toBeNull();
|
||||
expect(result.current.loading).toBe(false);
|
||||
expect(result.current.error?.message).toContain("无权限");
|
||||
});
|
||||
|
||||
it("切换 currentChildId 后重新请求", async () => {
|
||||
server.resetHandlers(
|
||||
graphql.query("ChildSummary", ({ variables }) => {
|
||||
const childId = variables.childId as string;
|
||||
return HttpResponse.json({
|
||||
data: {
|
||||
childSummary: {
|
||||
childId,
|
||||
avgScore: 90,
|
||||
classRank: 5,
|
||||
classSize: 45,
|
||||
attendanceRate: 0.96,
|
||||
pendingHomeworkCount: 3,
|
||||
recentGradeTrend: "up",
|
||||
recentScores: [],
|
||||
},
|
||||
},
|
||||
});
|
||||
}),
|
||||
);
|
||||
useChildStore.setState({ currentChildId: "student-001" });
|
||||
const { result } = renderUseChildSummary();
|
||||
await waitFor(
|
||||
() => {
|
||||
expect(result.current.summary?.childId).toBe("student-001");
|
||||
},
|
||||
{ timeout: 4000 },
|
||||
);
|
||||
|
||||
useChildStore.setState({ currentChildId: "student-002" });
|
||||
await waitFor(
|
||||
() => {
|
||||
expect(result.current.summary?.childId).toBe("student-002");
|
||||
},
|
||||
{ timeout: 4000 },
|
||||
);
|
||||
});
|
||||
|
||||
it("查询返回空数据时 summary 为 null", async () => {
|
||||
useChildStore.setState({ currentChildId: "student-001" });
|
||||
server.resetHandlers(
|
||||
graphql.query("ChildSummary", () =>
|
||||
HttpResponse.json({ data: { childSummary: null } }),
|
||||
),
|
||||
);
|
||||
|
||||
const { result } = renderUseChildSummary();
|
||||
await waitFor(() => {
|
||||
expect(result.current.loading).toBe(false);
|
||||
});
|
||||
expect(result.current.summary).toBeNull();
|
||||
});
|
||||
});
|
||||
256
apps/parent-portal/src/hooks/useChildSwitcher.test.tsx
Normal file
256
apps/parent-portal/src/hooks/useChildSwitcher.test.tsx
Normal file
@@ -0,0 +1,256 @@
|
||||
// useChildSwitcher Hook 单测
|
||||
// 依据:02-architecture-design.md §4.3 状态管理分层、ARB-020 §22.5 ISSUE-009 双层裁决
|
||||
// 覆盖:switchChild 双层处理(前端 store + 后端 mutation) / hasMultipleChildren / currentChild 派生
|
||||
|
||||
import {
|
||||
describe,
|
||||
it,
|
||||
expect,
|
||||
beforeAll,
|
||||
afterAll,
|
||||
afterEach,
|
||||
beforeEach,
|
||||
vi,
|
||||
} from "vitest";
|
||||
import { renderHook, waitFor, 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 { useChildStore } from "@/store/child-store";
|
||||
import { useChildSwitcher } from "./useChildSwitcher";
|
||||
import type { ChildInfo } from "@/types";
|
||||
|
||||
function createTestClient(): Client {
|
||||
return new Client({
|
||||
url: "/api/v1/parent/v1/graphql",
|
||||
exchanges: [cacheExchange, fetchExchange],
|
||||
fetchOptions: { headers: { "X-Requested-With": "XMLHttpRequest" } },
|
||||
});
|
||||
}
|
||||
|
||||
function renderUseChildSwitcher() {
|
||||
const client = createTestClient();
|
||||
const wrapper = ({ children }: { children: ReactNode }) => (
|
||||
<UrqlProvider value={client}>{children}</UrqlProvider>
|
||||
);
|
||||
return renderHook(() => useChildSwitcher(), { wrapper });
|
||||
}
|
||||
|
||||
beforeAll(() => server.listen({ onUnhandledRequest: "error" }));
|
||||
afterEach(() => {
|
||||
cleanup();
|
||||
server.resetHandlers();
|
||||
});
|
||||
afterAll(() => server.close());
|
||||
|
||||
beforeEach(() => {
|
||||
localStorage.clear();
|
||||
useChildStore.setState({
|
||||
children: [],
|
||||
currentChildId: null,
|
||||
isLoading: false,
|
||||
});
|
||||
});
|
||||
|
||||
describe("useChildSwitcher", () => {
|
||||
it("加载成功后返回子女列表并默认选中第一个", async () => {
|
||||
const { result } = renderUseChildSwitcher();
|
||||
await waitFor(() => {
|
||||
expect(result.current.children).toHaveLength(2);
|
||||
});
|
||||
expect(result.current.currentChildId).toBe("student-001");
|
||||
expect(result.current.currentChild?.id).toBe("student-001");
|
||||
expect(result.current.currentChild?.name).toBe("张小明");
|
||||
expect(result.current.loading).toBe(false);
|
||||
expect(result.current.error).toBeUndefined();
|
||||
});
|
||||
|
||||
it("hasMultipleChildren 多子女时为 true", async () => {
|
||||
const { result } = renderUseChildSwitcher();
|
||||
await waitFor(() => {
|
||||
expect(result.current.children).toHaveLength(2);
|
||||
});
|
||||
expect(result.current.hasMultipleChildren).toBe(true);
|
||||
});
|
||||
|
||||
it("hasMultipleChildren 单子女时为 false", async () => {
|
||||
server.resetHandlers(
|
||||
graphql.query("MyChildren", () =>
|
||||
HttpResponse.json({
|
||||
data: {
|
||||
myChildren: [
|
||||
{
|
||||
id: "student-001",
|
||||
name: "张小明",
|
||||
grade: "grade.7",
|
||||
schoolName: "实验中学",
|
||||
classId: "class-7-1",
|
||||
className: "初一(1)班",
|
||||
},
|
||||
],
|
||||
},
|
||||
}),
|
||||
),
|
||||
);
|
||||
|
||||
const { result } = renderUseChildSwitcher();
|
||||
await waitFor(() => {
|
||||
expect(result.current.children).toHaveLength(1);
|
||||
});
|
||||
expect(result.current.hasMultipleChildren).toBe(false);
|
||||
});
|
||||
|
||||
it("hasMultipleChildren 归档子女不计入", async () => {
|
||||
const archivedChildren: ChildInfo[] = [
|
||||
{
|
||||
id: "student-001",
|
||||
name: "张小明",
|
||||
grade: "grade.7",
|
||||
schoolName: "实验中学",
|
||||
classId: "class-7-1",
|
||||
className: "初一(1)班",
|
||||
isArchived: true,
|
||||
},
|
||||
{
|
||||
id: "student-002",
|
||||
name: "张小红",
|
||||
grade: "grade.5",
|
||||
schoolName: "实验小学",
|
||||
classId: "class-5-2",
|
||||
className: "五年级(2)班",
|
||||
},
|
||||
];
|
||||
server.resetHandlers(
|
||||
graphql.query("MyChildren", () =>
|
||||
HttpResponse.json({ data: { myChildren: archivedChildren } }),
|
||||
),
|
||||
);
|
||||
|
||||
const { result } = renderUseChildSwitcher();
|
||||
await waitFor(() => {
|
||||
expect(result.current.children).toHaveLength(2);
|
||||
});
|
||||
// 只有一个非归档 → hasMultipleChildren=false
|
||||
expect(result.current.hasMultipleChildren).toBe(false);
|
||||
// 默认选中第一个非归档
|
||||
expect(result.current.currentChildId).toBe("student-002");
|
||||
});
|
||||
|
||||
it("switchChild 调用前端 store 切换(乐观更新)", async () => {
|
||||
const { result } = renderUseChildSwitcher();
|
||||
await waitFor(() => {
|
||||
expect(result.current.children).toHaveLength(2);
|
||||
});
|
||||
const initialId = result.current.currentChildId;
|
||||
expect(initialId).not.toBeNull();
|
||||
|
||||
act(() => {
|
||||
// 切换到另一个子女
|
||||
const targetId =
|
||||
initialId === "student-001" ? "student-002" : "student-001";
|
||||
result.current.switchChild(targetId);
|
||||
});
|
||||
// 乐观更新:store 立即切换
|
||||
expect(useChildStore.getState().currentChildId).not.toBe(initialId);
|
||||
});
|
||||
|
||||
it("switchChild 同时触发后端 SwitchChild mutation", async () => {
|
||||
const { result } = renderUseChildSwitcher();
|
||||
await waitFor(() => {
|
||||
expect(result.current.children).toHaveLength(2);
|
||||
});
|
||||
|
||||
const fetchSpy = vi.spyOn(globalThis, "fetch");
|
||||
act(() => {
|
||||
result.current.switchChild("student-002");
|
||||
});
|
||||
// 等待 mutation fetch 发出
|
||||
await waitFor(() => {
|
||||
const gqlCalls = fetchSpy.mock.calls.filter(([url]) =>
|
||||
String(url).includes("/graphql"),
|
||||
);
|
||||
expect(gqlCalls.length).toBeGreaterThan(0);
|
||||
});
|
||||
// mutation body 含 childId
|
||||
const gqlCall = fetchSpy.mock.calls.find(([url]) =>
|
||||
String(url).includes("/graphql"),
|
||||
);
|
||||
const body = JSON.parse(String(gqlCall![1]?.body)) as {
|
||||
query?: string;
|
||||
variables?: { childId?: string };
|
||||
};
|
||||
expect(body.query).toContain("switchChild");
|
||||
expect(body.variables?.childId).toBe("student-002");
|
||||
fetchSpy.mockRestore();
|
||||
});
|
||||
|
||||
it("mutation 失败时仅 warn 不回滚前端缓存", async () => {
|
||||
server.resetHandlers(
|
||||
graphql.query("MyChildren", () =>
|
||||
HttpResponse.json({
|
||||
data: {
|
||||
myChildren: [
|
||||
{
|
||||
id: "student-001",
|
||||
name: "张小明",
|
||||
grade: "grade.7",
|
||||
schoolName: "实验中学",
|
||||
classId: "class-7-1",
|
||||
className: "初一(1)班",
|
||||
},
|
||||
{
|
||||
id: "student-002",
|
||||
name: "张小红",
|
||||
grade: "grade.5",
|
||||
schoolName: "实验小学",
|
||||
classId: "class-5-2",
|
||||
className: "五年级(2)班",
|
||||
},
|
||||
],
|
||||
},
|
||||
}),
|
||||
),
|
||||
graphql.mutation("SwitchChild", () =>
|
||||
HttpResponse.json(
|
||||
{ errors: [{ message: "审计失败" }] },
|
||||
{ status: 200 },
|
||||
),
|
||||
),
|
||||
);
|
||||
|
||||
const warnSpy = vi.spyOn(console, "warn").mockImplementation(() => {});
|
||||
const { result } = renderUseChildSwitcher();
|
||||
await waitFor(() => {
|
||||
expect(result.current.children).toHaveLength(2);
|
||||
});
|
||||
|
||||
act(() => {
|
||||
result.current.switchChild("student-002");
|
||||
});
|
||||
// 乐观更新:缓存已切换
|
||||
expect(useChildStore.getState().currentChildId).toBe("student-002");
|
||||
// 等待 mutation 完成并 warn
|
||||
await waitFor(() => {
|
||||
expect(warnSpy).toHaveBeenCalled();
|
||||
});
|
||||
// 缓存不回滚
|
||||
expect(useChildStore.getState().currentChildId).toBe("student-002");
|
||||
warnSpy.mockRestore();
|
||||
});
|
||||
|
||||
it("currentChildId 不在列表中时 setChildren 重置为有效值", async () => {
|
||||
useChildStore.setState({ currentChildId: "student-999" });
|
||||
const { result } = renderUseChildSwitcher();
|
||||
await waitFor(() => {
|
||||
expect(result.current.children).toHaveLength(2);
|
||||
});
|
||||
// store.setChildren 会重置无效 currentChildId
|
||||
expect(result.current.currentChild).not.toBeNull();
|
||||
});
|
||||
});
|
||||
@@ -1,23 +1,48 @@
|
||||
// useChildSwitcher:子女切换 Hook
|
||||
// 依据:02-architecture-design.md §4.3 状态管理分层、ISSUE-009 纯前端切换
|
||||
// 依据:02-architecture-design.md §4.3 状态管理分层、ARB-020 §22.5 ISSUE-009 双层裁决
|
||||
// - 封装 Zustand store,提供派生数据(currentChild / hasMultipleChildren)
|
||||
// - 切换是纯前端操作,不调后端(ISSUE-009)
|
||||
// - 切换子女时双层处理:
|
||||
// 1. 后端审计:调 GraphQL Mutation switchChild(parent-bff 写 Redis parent:selected:{parentId} TTL 30s)
|
||||
// 2. 前端缓存:Zustand + localStorage + BroadcastChannel(刷新不丢失 + 跨标签同步)
|
||||
// - 乐观更新:前端缓存立即更新(UX 优先),后端审计 fire-and-forget
|
||||
// - 自动加载子女列表(useMyChildren)
|
||||
|
||||
"use client";
|
||||
|
||||
import { useMutation } from "urql";
|
||||
import { useChildStore } from "@/store/child-store";
|
||||
import { useMyChildren } from "./useMyChildren";
|
||||
import { SWITCH_CHILD } from "@/lib/graphql/operations";
|
||||
|
||||
export function useChildSwitcher() {
|
||||
const { children, loading, error } = useMyChildren();
|
||||
|
||||
const currentChildId = useChildStore((s) => s.currentChildId);
|
||||
const switchChild = useChildStore((s) => s.switchChild);
|
||||
const switchChildLocal = useChildStore((s) => s.switchChild);
|
||||
|
||||
const [, switchChildMutation] = useMutation(SWITCH_CHILD);
|
||||
|
||||
const currentChild = children.find((c) => c.id === currentChildId) ?? null;
|
||||
const hasMultipleChildren = children.filter((c) => !c.isArchived).length > 1;
|
||||
|
||||
// ARB-020 §22.5:双层 switchChild(后端审计 + 前端缓存)
|
||||
const switchChild = (childId: string) => {
|
||||
// 1. 前端缓存立即更新(乐观更新,UX 优先)
|
||||
switchChildLocal(childId);
|
||||
|
||||
// 2. 后端审计记录(fire-and-forget,失败不阻塞 UI、不回滚缓存)
|
||||
void switchChildMutation({ childId }).then((result) => {
|
||||
if (result.error) {
|
||||
// 审计失败仅记录,不回滚前端状态(缓存优先于审计)
|
||||
// 后端 ChildGuard 会兜底拦截越权查询
|
||||
console.warn(
|
||||
"[switchChild] 后端审计 mutation 失败(前端缓存已更新)",
|
||||
result.error.message,
|
||||
);
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
return {
|
||||
children,
|
||||
currentChild,
|
||||
|
||||
265
apps/parent-portal/src/hooks/useChildTrend.test.tsx
Normal file
265
apps/parent-portal/src/hooks/useChildTrend.test.tsx
Normal file
@@ -0,0 +1,265 @@
|
||||
// useChildTrend Hook 单测
|
||||
// 依据:02-architecture-design.md §4.2 GraphQL 接入
|
||||
// 覆盖:无 currentChildId 暂停 / 加载态 / 成功返回 / 统计计算 / trendDirection / period / 错误态
|
||||
|
||||
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 { useChildTrend } from "./useChildTrend";
|
||||
import type { TrendDataPoint } from "@/types";
|
||||
|
||||
function createTestClient(): Client {
|
||||
return new Client({
|
||||
url: "/api/v1/parent/v1/graphql",
|
||||
exchanges: [cacheExchange, fetchExchange],
|
||||
fetchOptions: { headers: { "X-Requested-With": "XMLHttpRequest" } },
|
||||
});
|
||||
}
|
||||
|
||||
function renderUseChildTrend(period?: "week" | "month" | "semester") {
|
||||
const client = createTestClient();
|
||||
const wrapper = ({ children }: { children: ReactNode }) => (
|
||||
<UrqlProvider value={client}>{children}</UrqlProvider>
|
||||
);
|
||||
return renderHook(() => useChildTrend(period), { wrapper });
|
||||
}
|
||||
|
||||
beforeAll(() => server.listen({ onUnhandledRequest: "error" }));
|
||||
afterEach(() => {
|
||||
cleanup();
|
||||
server.resetHandlers();
|
||||
});
|
||||
afterAll(() => server.close());
|
||||
|
||||
beforeEach(() => {
|
||||
localStorage.clear();
|
||||
useChildStore.setState({
|
||||
children: [],
|
||||
currentChildId: null,
|
||||
isLoading: false,
|
||||
});
|
||||
});
|
||||
|
||||
describe("useChildTrend", () => {
|
||||
it("无 currentChildId 时暂停查询,返回空数据点和稳定趋势", () => {
|
||||
const { result } = renderUseChildTrend();
|
||||
expect(result.current.dataPoints).toEqual([]);
|
||||
expect(result.current.avgScore).toBe(0);
|
||||
expect(result.current.maxScore).toBe(0);
|
||||
expect(result.current.minScore).toBe(0);
|
||||
expect(result.current.trendDirection).toBe("stable");
|
||||
expect(result.current.loading).toBe(false);
|
||||
expect(result.current.error).toBeUndefined();
|
||||
});
|
||||
|
||||
it("有 currentChildId 时加载成功返回趋势数据点", async () => {
|
||||
useChildStore.setState({ currentChildId: "student-001" });
|
||||
const { result } = renderUseChildTrend();
|
||||
await waitFor(() => {
|
||||
expect(result.current.dataPoints.length).toBeGreaterThan(0);
|
||||
});
|
||||
// mock 数据:82, 88, 92, 90
|
||||
expect(result.current.dataPoints).toHaveLength(4);
|
||||
expect(result.current.loading).toBe(false);
|
||||
expect(result.current.error).toBeUndefined();
|
||||
});
|
||||
|
||||
it("统计计算:avgScore / maxScore / minScore", async () => {
|
||||
useChildStore.setState({ currentChildId: "student-001" });
|
||||
const { result } = renderUseChildTrend();
|
||||
await waitFor(() => {
|
||||
expect(result.current.dataPoints.length).toBeGreaterThan(0);
|
||||
});
|
||||
// scores = [82, 88, 92, 90]
|
||||
expect(result.current.maxScore).toBe(92);
|
||||
expect(result.current.minScore).toBe(82);
|
||||
// avg = (82+88+92+90)/4 = 88
|
||||
expect(result.current.avgScore).toBe(88);
|
||||
});
|
||||
|
||||
it("trendDirection 下降:last < prev - 1", async () => {
|
||||
useChildStore.setState({ currentChildId: "student-001" });
|
||||
// mock 数据最后两个 92 → 90,90 < 92-1=91 → down
|
||||
const { result } = renderUseChildTrend();
|
||||
await waitFor(() => {
|
||||
expect(result.current.dataPoints.length).toBeGreaterThan(0);
|
||||
});
|
||||
expect(result.current.trendDirection).toBe("down");
|
||||
});
|
||||
|
||||
it("trendDirection 上升:last > prev + 1", async () => {
|
||||
useChildStore.setState({ currentChildId: "student-001" });
|
||||
const upPoints: TrendDataPoint[] = [
|
||||
{ date: "2026-03", score: 80, subject: "数学" },
|
||||
{ date: "2026-04", score: 90, subject: "数学" },
|
||||
];
|
||||
server.resetHandlers(
|
||||
graphql.query("ChildTrend", () =>
|
||||
HttpResponse.json({
|
||||
data: {
|
||||
childTrend: {
|
||||
childId: "student-001",
|
||||
period: "month",
|
||||
dataPoints: upPoints,
|
||||
},
|
||||
},
|
||||
}),
|
||||
),
|
||||
);
|
||||
|
||||
const { result } = renderUseChildTrend();
|
||||
await waitFor(() => {
|
||||
expect(result.current.dataPoints).toHaveLength(2);
|
||||
});
|
||||
expect(result.current.trendDirection).toBe("up");
|
||||
});
|
||||
|
||||
it("trendDirection 稳定:|last - prev| <= 1", async () => {
|
||||
useChildStore.setState({ currentChildId: "student-001" });
|
||||
const stablePoints: TrendDataPoint[] = [
|
||||
{ date: "2026-03", score: 85, subject: "数学" },
|
||||
{ date: "2026-04", score: 85, subject: "数学" },
|
||||
];
|
||||
server.resetHandlers(
|
||||
graphql.query("ChildTrend", () =>
|
||||
HttpResponse.json({
|
||||
data: {
|
||||
childTrend: {
|
||||
childId: "student-001",
|
||||
period: "month",
|
||||
dataPoints: stablePoints,
|
||||
},
|
||||
},
|
||||
}),
|
||||
),
|
||||
);
|
||||
|
||||
const { result } = renderUseChildTrend();
|
||||
await waitFor(() => {
|
||||
expect(result.current.dataPoints).toHaveLength(2);
|
||||
});
|
||||
expect(result.current.trendDirection).toBe("stable");
|
||||
});
|
||||
|
||||
it("trendDirection 单点时为 stable", async () => {
|
||||
useChildStore.setState({ currentChildId: "student-001" });
|
||||
server.resetHandlers(
|
||||
graphql.query("ChildTrend", () =>
|
||||
HttpResponse.json({
|
||||
data: {
|
||||
childTrend: {
|
||||
childId: "student-001",
|
||||
period: "month",
|
||||
dataPoints: [{ date: "2026-03", score: 85 }],
|
||||
},
|
||||
},
|
||||
}),
|
||||
),
|
||||
);
|
||||
|
||||
const { result } = renderUseChildTrend();
|
||||
await waitFor(() => {
|
||||
expect(result.current.dataPoints).toHaveLength(1);
|
||||
});
|
||||
expect(result.current.trendDirection).toBe("stable");
|
||||
expect(result.current.avgScore).toBe(85);
|
||||
});
|
||||
|
||||
it("period 默认为 month", async () => {
|
||||
useChildStore.setState({ currentChildId: "student-001" });
|
||||
const { result } = renderUseChildTrend();
|
||||
await waitFor(() => {
|
||||
expect(result.current.dataPoints.length).toBeGreaterThan(0);
|
||||
});
|
||||
expect(result.current.period).toBe("month");
|
||||
});
|
||||
|
||||
it("period 可指定为 week", async () => {
|
||||
useChildStore.setState({ currentChildId: "student-001" });
|
||||
let capturedVars: unknown = null;
|
||||
server.resetHandlers(
|
||||
graphql.query("ChildTrend", ({ variables }) => {
|
||||
capturedVars = variables;
|
||||
return HttpResponse.json({
|
||||
data: {
|
||||
childTrend: {
|
||||
childId: "student-001",
|
||||
period: "week",
|
||||
dataPoints: [],
|
||||
},
|
||||
},
|
||||
});
|
||||
}),
|
||||
);
|
||||
|
||||
const { result } = renderUseChildTrend("week");
|
||||
await waitFor(() => {
|
||||
expect(result.current.loading).toBe(false);
|
||||
});
|
||||
expect(result.current.period).toBe("week");
|
||||
expect(capturedVars).toEqual({ childId: "student-001", period: "week" });
|
||||
});
|
||||
|
||||
it("GraphQL 返回错误时透传 error", async () => {
|
||||
useChildStore.setState({ currentChildId: "student-001" });
|
||||
server.resetHandlers(
|
||||
graphql.query("ChildTrend", () =>
|
||||
HttpResponse.json(
|
||||
{ errors: [{ message: "趋势查询失败" }] },
|
||||
{ status: 200 },
|
||||
),
|
||||
),
|
||||
);
|
||||
|
||||
const { result } = renderUseChildTrend();
|
||||
await waitFor(() => {
|
||||
expect(result.current.error).toBeDefined();
|
||||
});
|
||||
expect(result.current.dataPoints).toEqual([]);
|
||||
expect(result.current.error?.message).toContain("趋势查询失败");
|
||||
});
|
||||
|
||||
it("数据为空时统计为 0 且趋势稳定", async () => {
|
||||
useChildStore.setState({ currentChildId: "student-001" });
|
||||
server.resetHandlers(
|
||||
graphql.query("ChildTrend", () =>
|
||||
HttpResponse.json({
|
||||
data: {
|
||||
childTrend: {
|
||||
childId: "student-001",
|
||||
period: "month",
|
||||
dataPoints: [],
|
||||
},
|
||||
},
|
||||
}),
|
||||
),
|
||||
);
|
||||
|
||||
const { result } = renderUseChildTrend();
|
||||
await waitFor(() => {
|
||||
expect(result.current.loading).toBe(false);
|
||||
});
|
||||
expect(result.current.dataPoints).toEqual([]);
|
||||
expect(result.current.avgScore).toBe(0);
|
||||
expect(result.current.maxScore).toBe(0);
|
||||
expect(result.current.minScore).toBe(0);
|
||||
expect(result.current.trendDirection).toBe("stable");
|
||||
});
|
||||
});
|
||||
58
apps/parent-portal/src/hooks/useChildTrend.ts
Normal file
58
apps/parent-portal/src/hooks/useChildTrend.ts
Normal file
@@ -0,0 +1,58 @@
|
||||
// useChildTrend:获取子女学习趋势数据
|
||||
// 依据:02-architecture-design.md §4.2 GraphQL 接入
|
||||
// 对标 student-portal /dashboard/trend 页面
|
||||
|
||||
"use client";
|
||||
|
||||
import { useQuery } from "urql";
|
||||
import { CHILD_TREND } from "@/lib/graphql/operations";
|
||||
import type { LearningTrend, TrendDataPoint } from "@/types";
|
||||
import { useChildStore } from "@/store/child-store";
|
||||
|
||||
interface ChildTrendResponse {
|
||||
childTrend: LearningTrend;
|
||||
}
|
||||
|
||||
export type TrendPeriod = "week" | "month" | "semester";
|
||||
|
||||
export function useChildTrend(period: TrendPeriod = "month") {
|
||||
const currentChildId = useChildStore((s) => s.currentChildId);
|
||||
|
||||
const [result] = useQuery<ChildTrendResponse>({
|
||||
query: CHILD_TREND,
|
||||
variables: { childId: currentChildId, period },
|
||||
pause: !currentChildId,
|
||||
});
|
||||
|
||||
const dataPoints: TrendDataPoint[] =
|
||||
result.data?.childTrend?.dataPoints ?? [];
|
||||
|
||||
// 汇总统计
|
||||
const scores = dataPoints.map((d) => d.score);
|
||||
const avgScore =
|
||||
scores.length > 0 ? scores.reduce((a, b) => a + b, 0) / scores.length : 0;
|
||||
const maxScore = scores.length > 0 ? Math.max(...scores) : 0;
|
||||
const minScore = scores.length > 0 ? Math.min(...scores) : 0;
|
||||
|
||||
// 趋势方向
|
||||
let trendDirection: "up" | "down" | "stable" = "stable";
|
||||
if (scores.length >= 2) {
|
||||
const last = scores.at(-1);
|
||||
const prev = scores.at(-2);
|
||||
if (last !== undefined && prev !== undefined) {
|
||||
if (last > prev + 1) trendDirection = "up";
|
||||
else if (last < prev - 1) trendDirection = "down";
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
dataPoints,
|
||||
period,
|
||||
avgScore: Math.round(avgScore * 10) / 10,
|
||||
maxScore,
|
||||
minScore,
|
||||
trendDirection,
|
||||
loading: result.fetching,
|
||||
error: result.error,
|
||||
};
|
||||
}
|
||||
120
apps/parent-portal/src/hooks/useChildWeakness.test.tsx
Normal file
120
apps/parent-portal/src/hooks/useChildWeakness.test.tsx
Normal file
@@ -0,0 +1,120 @@
|
||||
// useChildWeakness 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 { useChildWeakness } from "./useChildWeakness";
|
||||
|
||||
function createTestClient(): Client {
|
||||
return new Client({
|
||||
url: "/api/v1/parent/v1/graphql",
|
||||
exchanges: [cacheExchange, fetchExchange],
|
||||
fetchOptions: { headers: { "X-Requested-With": "XMLHttpRequest" } },
|
||||
});
|
||||
}
|
||||
|
||||
function renderUseChildWeakness() {
|
||||
const client = createTestClient();
|
||||
const wrapper = ({ children }: { children: ReactNode }) => (
|
||||
<UrqlProvider value={client}>{children}</UrqlProvider>
|
||||
);
|
||||
return renderHook(() => useChildWeakness(), { wrapper });
|
||||
}
|
||||
|
||||
beforeAll(() => server.listen({ onUnhandledRequest: "error" }));
|
||||
afterEach(() => {
|
||||
cleanup();
|
||||
server.resetHandlers();
|
||||
});
|
||||
afterAll(() => server.close());
|
||||
|
||||
beforeEach(() => {
|
||||
localStorage.clear();
|
||||
useChildStore.setState({
|
||||
children: [],
|
||||
currentChildId: null,
|
||||
isLoading: false,
|
||||
});
|
||||
});
|
||||
|
||||
describe("useChildWeakness", () => {
|
||||
it("无 currentChildId 时暂停查询,返回空数组", () => {
|
||||
const { result } = renderUseChildWeakness();
|
||||
expect(result.current.weaknesses).toEqual([]);
|
||||
expect(result.current.loading).toBe(false);
|
||||
expect(result.current.error).toBeUndefined();
|
||||
});
|
||||
|
||||
it("有 currentChildId 时加载成功返回薄弱点列表", async () => {
|
||||
useChildStore.setState({ currentChildId: "student-001" });
|
||||
const { result } = renderUseChildWeakness();
|
||||
await waitFor(() => {
|
||||
expect(result.current.weaknesses.length).toBeGreaterThan(0);
|
||||
});
|
||||
expect(result.current.weaknesses).toHaveLength(3);
|
||||
expect(result.current.weaknesses[0]!.knowledgePoint).toBe("一元二次方程");
|
||||
expect(result.current.weaknesses[0]!.masteryLevel).toBe(0.45);
|
||||
expect(result.current.weaknesses[0]!.subject).toBe("数学");
|
||||
expect(result.current.loading).toBe(false);
|
||||
expect(result.current.error).toBeUndefined();
|
||||
});
|
||||
|
||||
it("加载中 loading 为 true", () => {
|
||||
useChildStore.setState({ currentChildId: "student-001" });
|
||||
const { result } = renderUseChildWeakness();
|
||||
expect(result.current.loading).toBe(true);
|
||||
});
|
||||
|
||||
it("GraphQL 返回错误时透传 error", async () => {
|
||||
useChildStore.setState({ currentChildId: "student-001" });
|
||||
server.resetHandlers(
|
||||
graphql.query("ChildWeakness", () =>
|
||||
HttpResponse.json(
|
||||
{ errors: [{ message: "学情查询失败" }] },
|
||||
{ status: 200 },
|
||||
),
|
||||
),
|
||||
);
|
||||
|
||||
const { result } = renderUseChildWeakness();
|
||||
await waitFor(() => {
|
||||
expect(result.current.error).toBeDefined();
|
||||
});
|
||||
expect(result.current.weaknesses).toEqual([]);
|
||||
expect(result.current.error?.message).toContain("学情查询失败");
|
||||
});
|
||||
|
||||
it("返回空薄弱点列表", async () => {
|
||||
useChildStore.setState({ currentChildId: "student-001" });
|
||||
server.resetHandlers(
|
||||
graphql.query("ChildWeakness", () =>
|
||||
HttpResponse.json({ data: { childWeakness: [] } }),
|
||||
),
|
||||
);
|
||||
|
||||
const { result } = renderUseChildWeakness();
|
||||
await waitFor(() => {
|
||||
expect(result.current.loading).toBe(false);
|
||||
});
|
||||
expect(result.current.weaknesses).toEqual([]);
|
||||
});
|
||||
});
|
||||
348
apps/parent-portal/src/hooks/useCrossTabSync.test.tsx
Normal file
348
apps/parent-portal/src/hooks/useCrossTabSync.test.tsx
Normal file
@@ -0,0 +1,348 @@
|
||||
// useCrossTabSync Hook 单测
|
||||
// 依据:02-architecture-design.md §4.4 跨标签同步
|
||||
// 覆盖:BroadcastChannel 消息处理 / storage 事件降级 / broadcastSync 广播 / 防回环 / 清理
|
||||
|
||||
import { describe, it, expect, beforeEach, afterEach, vi } from "vitest";
|
||||
import { renderHook, cleanup } from "@testing-library/react";
|
||||
import {
|
||||
useCrossTabSync,
|
||||
broadcastSync,
|
||||
SYNC_SOURCE_ID,
|
||||
} from "./useCrossTabSync";
|
||||
import { useChildStore } from "@/store/child-store";
|
||||
|
||||
// ===== BroadcastChannel mock =====
|
||||
interface MockBroadcastChannel {
|
||||
name: string;
|
||||
onmessage: ((event: { data: unknown }) => void) | null;
|
||||
postMessage: (data: unknown) => void;
|
||||
close: () => void;
|
||||
}
|
||||
|
||||
const mockChannels: Map<string, MockBroadcastChannel[]> = new Map();
|
||||
|
||||
class MockBroadcastChannelImpl {
|
||||
name: string;
|
||||
onmessage: ((event: { data: unknown }) => void) | null = null;
|
||||
constructor(name: string) {
|
||||
this.name = name;
|
||||
const list = mockChannels.get(name) ?? [];
|
||||
list.push(this);
|
||||
mockChannels.set(name, list);
|
||||
}
|
||||
postMessage(data: unknown) {
|
||||
const list = mockChannels.get(this.name) ?? [];
|
||||
for (const ch of list) {
|
||||
if (ch !== this && ch.onmessage) {
|
||||
ch.onmessage({ data });
|
||||
}
|
||||
}
|
||||
}
|
||||
close() {
|
||||
const list = mockChannels.get(this.name) ?? [];
|
||||
const idx = list.indexOf(this);
|
||||
if (idx >= 0) list.splice(idx, 1);
|
||||
mockChannels.set(this.name, list);
|
||||
}
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
localStorage.clear();
|
||||
mockChannels.clear();
|
||||
// 注入 mock BroadcastChannel
|
||||
Object.defineProperty(window, "BroadcastChannel", {
|
||||
writable: true,
|
||||
configurable: true,
|
||||
value: MockBroadcastChannelImpl,
|
||||
});
|
||||
useChildStore.setState({
|
||||
children: [],
|
||||
currentChildId: null,
|
||||
isLoading: false,
|
||||
});
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
cleanup();
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
describe("useCrossTabSync", () => {
|
||||
it("SYNC_SOURCE_ID 是非空字符串", () => {
|
||||
expect(SYNC_SOURCE_ID).toBeTruthy();
|
||||
expect(typeof SYNC_SOURCE_ID).toBe("string");
|
||||
});
|
||||
|
||||
it("收到 child-switched 消息时更新 store currentChildId", () => {
|
||||
// 先在 store 中放入 children,确保 switchChild 校验通过
|
||||
useChildStore.setState({
|
||||
children: [
|
||||
{
|
||||
id: "student-001",
|
||||
name: "张小明",
|
||||
grade: "grade.7",
|
||||
schoolName: "实验中学",
|
||||
classId: "class-7-1",
|
||||
className: "初一(1)班",
|
||||
},
|
||||
{
|
||||
id: "student-002",
|
||||
name: "张小红",
|
||||
grade: "grade.5",
|
||||
schoolName: "实验小学",
|
||||
classId: "class-5-2",
|
||||
className: "五年级(2)班",
|
||||
},
|
||||
],
|
||||
currentChildId: "student-001",
|
||||
});
|
||||
|
||||
renderHook(() => useCrossTabSync());
|
||||
|
||||
// 模拟其他标签广播 child-switched 消息
|
||||
const otherChannel = new MockBroadcastChannelImpl("parent-sync");
|
||||
otherChannel.onmessage = null; // 接收方
|
||||
// 通过 useCrossTabSync 创建的 channel 接收消息
|
||||
const hookChannels = mockChannels.get("parent-sync") ?? [];
|
||||
const hookChannel = hookChannels[0];
|
||||
expect(hookChannel).toBeDefined();
|
||||
expect(hookChannel!.onmessage).not.toBeNull();
|
||||
|
||||
hookChannel!.onmessage!({
|
||||
data: {
|
||||
type: "child-switched",
|
||||
childId: "student-002",
|
||||
ts: Date.now(),
|
||||
source: "other-tab",
|
||||
},
|
||||
});
|
||||
|
||||
expect(useChildStore.getState().currentChildId).toBe("student-002");
|
||||
});
|
||||
|
||||
it("收到 preferences-updated 消息时清除 localStorage 缓存", () => {
|
||||
localStorage.setItem("parent_notification_preferences", '{"old":true}');
|
||||
|
||||
renderHook(() => useCrossTabSync());
|
||||
|
||||
const hookChannels = mockChannels.get("parent-sync") ?? [];
|
||||
const hookChannel = hookChannels[0];
|
||||
hookChannel!.onmessage!({
|
||||
data: {
|
||||
type: "preferences-updated",
|
||||
ts: Date.now(),
|
||||
source: "other-tab",
|
||||
},
|
||||
});
|
||||
|
||||
expect(localStorage.getItem("parent_notification_preferences")).toBeNull();
|
||||
});
|
||||
|
||||
it("收到 child-unbound 消息时不抛错(no-op)", () => {
|
||||
renderHook(() => useCrossTabSync());
|
||||
|
||||
const hookChannels = mockChannels.get("parent-sync") ?? [];
|
||||
const hookChannel = hookChannels[0];
|
||||
expect(() =>
|
||||
hookChannel!.onmessage!({
|
||||
data: {
|
||||
type: "child-unbound",
|
||||
childId: "student-001",
|
||||
ts: Date.now(),
|
||||
source: "other-tab",
|
||||
},
|
||||
}),
|
||||
).not.toThrow();
|
||||
});
|
||||
|
||||
it("防回环:忽略自己发出的消息", () => {
|
||||
useChildStore.setState({ currentChildId: "student-001" });
|
||||
|
||||
renderHook(() => useCrossTabSync());
|
||||
|
||||
const hookChannels = mockChannels.get("parent-sync") ?? [];
|
||||
const hookChannel = hookChannels[0];
|
||||
// 用自己的 source ID 发消息
|
||||
hookChannel!.onmessage!({
|
||||
data: {
|
||||
type: "child-switched",
|
||||
childId: "student-002",
|
||||
ts: Date.now(),
|
||||
source: SYNC_SOURCE_ID,
|
||||
},
|
||||
});
|
||||
|
||||
// 不应更新(防回环)
|
||||
expect(useChildStore.getState().currentChildId).toBe("student-001");
|
||||
});
|
||||
|
||||
it("空消息被忽略", () => {
|
||||
renderHook(() => useCrossTabSync());
|
||||
|
||||
const hookChannels = mockChannels.get("parent-sync") ?? [];
|
||||
const hookChannel = hookChannels[0];
|
||||
expect(() => hookChannel!.onmessage!({ data: null })).not.toThrow();
|
||||
});
|
||||
|
||||
it("卸载时关闭 BroadcastChannel 并移除 storage 监听", () => {
|
||||
const removeEventListenerSpy = vi.spyOn(window, "removeEventListener");
|
||||
const { unmount } = renderHook(() => useCrossTabSync());
|
||||
|
||||
const hookChannelsBefore = mockChannels.get("parent-sync") ?? [];
|
||||
expect(hookChannelsBefore.length).toBeGreaterThan(0);
|
||||
|
||||
unmount();
|
||||
|
||||
// storage 监听已移除
|
||||
expect(removeEventListenerSpy).toHaveBeenCalledWith(
|
||||
"storage",
|
||||
expect.any(Function),
|
||||
);
|
||||
});
|
||||
|
||||
it("storage 事件降级:child-switched 更新 store", () => {
|
||||
useChildStore.setState({ currentChildId: "student-001" });
|
||||
|
||||
renderHook(() => useCrossTabSync());
|
||||
|
||||
// 模拟 storage 事件
|
||||
const storageEvent = new StorageEvent("storage", {
|
||||
key: "parent_sync_event",
|
||||
newValue: JSON.stringify({
|
||||
type: "child-switched",
|
||||
childId: "student-002",
|
||||
ts: Date.now(),
|
||||
source: "other-tab",
|
||||
}),
|
||||
});
|
||||
window.dispatchEvent(storageEvent);
|
||||
|
||||
expect(useChildStore.getState().currentChildId).toBe("student-002");
|
||||
});
|
||||
|
||||
it("storage 事件降级:preferences-updated 清除缓存", () => {
|
||||
localStorage.setItem("parent_notification_preferences", '{"old":true}');
|
||||
|
||||
renderHook(() => useCrossTabSync());
|
||||
|
||||
const storageEvent = new StorageEvent("storage", {
|
||||
key: "parent_sync_event",
|
||||
newValue: JSON.stringify({
|
||||
type: "preferences-updated",
|
||||
ts: Date.now(),
|
||||
source: "other-tab",
|
||||
}),
|
||||
});
|
||||
window.dispatchEvent(storageEvent);
|
||||
|
||||
expect(localStorage.getItem("parent_notification_preferences")).toBeNull();
|
||||
});
|
||||
|
||||
it("storage 事件降级:防回环忽略自己", () => {
|
||||
useChildStore.setState({ currentChildId: "student-001" });
|
||||
|
||||
renderHook(() => useCrossTabSync());
|
||||
|
||||
const storageEvent = new StorageEvent("storage", {
|
||||
key: "parent_sync_event",
|
||||
newValue: JSON.stringify({
|
||||
type: "child-switched",
|
||||
childId: "student-002",
|
||||
ts: Date.now(),
|
||||
source: SYNC_SOURCE_ID,
|
||||
}),
|
||||
});
|
||||
window.dispatchEvent(storageEvent);
|
||||
|
||||
expect(useChildStore.getState().currentChildId).toBe("student-001");
|
||||
});
|
||||
|
||||
it("storage 事件降级:忽略无关 key", () => {
|
||||
useChildStore.setState({ currentChildId: "student-001" });
|
||||
|
||||
renderHook(() => useCrossTabSync());
|
||||
|
||||
const storageEvent = new StorageEvent("storage", {
|
||||
key: "other_key",
|
||||
newValue: JSON.stringify({
|
||||
type: "child-switched",
|
||||
childId: "student-002",
|
||||
ts: Date.now(),
|
||||
source: "other-tab",
|
||||
}),
|
||||
});
|
||||
window.dispatchEvent(storageEvent);
|
||||
|
||||
expect(useChildStore.getState().currentChildId).toBe("student-001");
|
||||
});
|
||||
|
||||
it("storage 事件降级:忽略无效 JSON", () => {
|
||||
renderHook(() => useCrossTabSync());
|
||||
|
||||
const storageEvent = new StorageEvent("storage", {
|
||||
key: "parent_sync_event",
|
||||
newValue: "{invalid json",
|
||||
});
|
||||
expect(() => window.dispatchEvent(storageEvent)).not.toThrow();
|
||||
});
|
||||
|
||||
it("storage 事件降级:忽略空 newValue", () => {
|
||||
renderHook(() => useCrossTabSync());
|
||||
|
||||
const storageEvent = new StorageEvent("storage", {
|
||||
key: "parent_sync_event",
|
||||
newValue: null,
|
||||
});
|
||||
expect(() => window.dispatchEvent(storageEvent)).not.toThrow();
|
||||
});
|
||||
});
|
||||
|
||||
describe("broadcastSync", () => {
|
||||
it("通过 BroadcastChannel 广播消息并写入 localStorage", () => {
|
||||
renderHook(() => useCrossTabSync());
|
||||
|
||||
// Omit<SyncMessage,"source"> 不分配联合类型,用变量避免 excess property check
|
||||
const msg = {
|
||||
type: "child-switched" as const,
|
||||
childId: "student-002",
|
||||
ts: Date.now(),
|
||||
};
|
||||
broadcastSync(msg);
|
||||
|
||||
// localStorage 被写入(storage 降级路径)
|
||||
expect(localStorage.getItem("parent_sync_event")).toBeTruthy();
|
||||
const stored = JSON.parse(
|
||||
localStorage.getItem("parent_sync_event") ?? "{}",
|
||||
) as { source: string; type: string };
|
||||
expect(stored.source).toBe(SYNC_SOURCE_ID);
|
||||
expect(stored.type).toBe("child-switched");
|
||||
});
|
||||
|
||||
it("广播消息携带 source ID", () => {
|
||||
renderHook(() => useCrossTabSync());
|
||||
|
||||
broadcastSync({
|
||||
type: "preferences-updated",
|
||||
ts: Date.now(),
|
||||
});
|
||||
|
||||
const stored = JSON.parse(
|
||||
localStorage.getItem("parent_sync_event") ?? "{}",
|
||||
) as { source: string };
|
||||
expect(stored.source).toBe(SYNC_SOURCE_ID);
|
||||
});
|
||||
|
||||
it("BroadcastChannel 不可用时不报错", () => {
|
||||
// 移除 BroadcastChannel(删除属性使 "BroadcastChannel" in window 为 false)
|
||||
delete (window as { BroadcastChannel?: unknown }).BroadcastChannel;
|
||||
|
||||
const msg = {
|
||||
type: "child-switched" as const,
|
||||
childId: "student-001",
|
||||
ts: Date.now(),
|
||||
};
|
||||
expect(() => broadcastSync(msg)).not.toThrow();
|
||||
// storage 降级路径仍写入 localStorage
|
||||
expect(localStorage.getItem("parent_sync_event")).toBeTruthy();
|
||||
});
|
||||
});
|
||||
146
apps/parent-portal/src/hooks/useMyChildren.test.tsx
Normal file
146
apps/parent-portal/src/hooks/useMyChildren.test.tsx
Normal file
@@ -0,0 +1,146 @@
|
||||
// useMyChildren Hook 单测
|
||||
// 依据:02-architecture-design.md §4.2 GraphQL 接入、§13 测试策略
|
||||
// 覆盖:加载态 / 成功返回子女列表 / 错误态 / 同步 Zustand store
|
||||
|
||||
import {
|
||||
describe,
|
||||
it,
|
||||
expect,
|
||||
beforeAll,
|
||||
afterAll,
|
||||
afterEach,
|
||||
beforeEach,
|
||||
vi,
|
||||
} from "vitest";
|
||||
import { renderHook, waitFor } 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 { useMyChildren } from "./useMyChildren";
|
||||
|
||||
// 端点:/api/v1/parent/v1/graphql(双 /v1 前缀,ARB-022 §24.4)
|
||||
function createTestClient(): Client {
|
||||
return new Client({
|
||||
url: "/api/v1/parent/v1/graphql",
|
||||
exchanges: [cacheExchange, fetchExchange],
|
||||
fetchOptions: { headers: { "X-Requested-With": "XMLHttpRequest" } },
|
||||
});
|
||||
}
|
||||
|
||||
function renderUseMyChildren() {
|
||||
const client = createTestClient();
|
||||
const wrapper = ({ children }: { children: ReactNode }) => (
|
||||
<UrqlProvider value={client}>{children}</UrqlProvider>
|
||||
);
|
||||
return renderHook(() => useMyChildren(), { wrapper });
|
||||
}
|
||||
|
||||
beforeAll(() => server.listen({ onUnhandledRequest: "error" }));
|
||||
afterEach(() => server.resetHandlers());
|
||||
afterAll(() => server.close());
|
||||
|
||||
beforeEach(() => {
|
||||
localStorage.clear();
|
||||
useChildStore.setState({
|
||||
children: [],
|
||||
currentChildId: null,
|
||||
isLoading: false,
|
||||
});
|
||||
});
|
||||
|
||||
describe("useMyChildren", () => {
|
||||
it("初始加载态返回空列表和 loading=true", () => {
|
||||
const { result } = renderUseMyChildren();
|
||||
expect(result.current.children).toEqual([]);
|
||||
expect(result.current.loading).toBe(true);
|
||||
expect(result.current.error).toBeUndefined();
|
||||
});
|
||||
|
||||
it("加载成功后返回子女列表", async () => {
|
||||
const { result } = renderUseMyChildren();
|
||||
await waitFor(() => {
|
||||
expect(result.current.loading).toBe(false);
|
||||
});
|
||||
expect(result.current.children).toHaveLength(2);
|
||||
expect(result.current.children[0]!.id).toBe("student-001");
|
||||
expect(result.current.children[1]!.id).toBe("student-002");
|
||||
expect(result.current.error).toBeUndefined();
|
||||
});
|
||||
|
||||
it("加载成功后同步到 Zustand store(setChildren + setLoading)", async () => {
|
||||
const { result } = renderUseMyChildren();
|
||||
await waitFor(() => {
|
||||
expect(result.current.children).toHaveLength(2);
|
||||
});
|
||||
// store 已同步 children
|
||||
expect(useChildStore.getState().children).toHaveLength(2);
|
||||
expect(useChildStore.getState().children[0]!.id).toBe("student-001");
|
||||
// 默认选中第一个
|
||||
expect(useChildStore.getState().currentChildId).toBe("student-001");
|
||||
// loading 同步为 false
|
||||
expect(useChildStore.getState().isLoading).toBe(false);
|
||||
});
|
||||
|
||||
it("加载中同步 isLoading=true 到 store", () => {
|
||||
const spy = vi.spyOn(useChildStore.getState(), "setLoading");
|
||||
renderUseMyChildren();
|
||||
expect(spy).toHaveBeenCalledWith(true);
|
||||
spy.mockRestore();
|
||||
});
|
||||
|
||||
it("GraphQL 返回错误时透传 error", async () => {
|
||||
server.resetHandlers(
|
||||
graphql.query("MyChildren", () =>
|
||||
HttpResponse.json(
|
||||
{
|
||||
errors: [{ message: "未授权" }],
|
||||
},
|
||||
{ status: 200 },
|
||||
),
|
||||
),
|
||||
);
|
||||
|
||||
const { result } = renderUseMyChildren();
|
||||
await waitFor(() => {
|
||||
expect(result.current.error).toBeDefined();
|
||||
});
|
||||
expect(result.current.loading).toBe(false);
|
||||
expect(result.current.children).toEqual([]);
|
||||
expect(result.current.error?.message).toContain("未授权");
|
||||
});
|
||||
|
||||
it("GraphQL 返回空 children 时 store 保持空", async () => {
|
||||
server.resetHandlers(
|
||||
graphql.query("MyChildren", () =>
|
||||
HttpResponse.json({ data: { myChildren: [] } }),
|
||||
),
|
||||
);
|
||||
|
||||
const { result } = renderUseMyChildren();
|
||||
await waitFor(() => {
|
||||
expect(result.current.loading).toBe(false);
|
||||
});
|
||||
expect(result.current.children).toEqual([]);
|
||||
expect(useChildStore.getState().children).toEqual([]);
|
||||
expect(useChildStore.getState().currentChildId).toBeNull();
|
||||
});
|
||||
|
||||
it("网络错误时返回 error 且 children 为空", async () => {
|
||||
server.resetHandlers(
|
||||
graphql.query("MyChildren", () => HttpResponse.error()),
|
||||
);
|
||||
|
||||
const { result } = renderUseMyChildren();
|
||||
await waitFor(() => {
|
||||
expect(result.current.error).toBeDefined();
|
||||
});
|
||||
expect(result.current.children).toEqual([]);
|
||||
});
|
||||
});
|
||||
348
apps/parent-portal/src/hooks/useNotificationPreferences.test.tsx
Normal file
348
apps/parent-portal/src/hooks/useNotificationPreferences.test.tsx
Normal file
@@ -0,0 +1,348 @@
|
||||
// useNotificationPreferences Hook 单测
|
||||
// 依据:02-architecture-design.md §14 通知偏好、ISSUE-033 P4 localStorage 降级
|
||||
// 覆盖:localStorage 优先加载 / GraphQL 默认值降级 / updatePreference 写入 + mutation / 错误态
|
||||
|
||||
import {
|
||||
describe,
|
||||
it,
|
||||
expect,
|
||||
beforeAll,
|
||||
afterAll,
|
||||
afterEach,
|
||||
beforeEach,
|
||||
vi,
|
||||
} from "vitest";
|
||||
import { renderHook, waitFor, act } 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 { useNotificationPreferences } from "./useNotificationPreferences";
|
||||
import type { NotificationPreferences } from "@/types";
|
||||
|
||||
const PREFS_KEY = "parent_notification_preferences";
|
||||
|
||||
// 端点:/api/v1/parent/v1/graphql(双 /v1 前缀,ARB-022 §24.4)
|
||||
function createTestClient(): Client {
|
||||
return new Client({
|
||||
url: "/api/v1/parent/v1/graphql",
|
||||
exchanges: [cacheExchange, fetchExchange],
|
||||
fetchOptions: { headers: { "X-Requested-With": "XMLHttpRequest" } },
|
||||
});
|
||||
}
|
||||
|
||||
function renderUseNotificationPreferences(parentId = "parent-001") {
|
||||
const client = createTestClient();
|
||||
const wrapper = ({ children }: { children: ReactNode }) => (
|
||||
<UrqlProvider value={client}>{children}</UrqlProvider>
|
||||
);
|
||||
return renderHook(() => useNotificationPreferences(parentId), { wrapper });
|
||||
}
|
||||
|
||||
// 构造一份本地偏好(用于 localStorage 预置)
|
||||
function makeLocalPrefs(): NotificationPreferences {
|
||||
return {
|
||||
parentId: "parent-001",
|
||||
preferences: {
|
||||
"student-001": {
|
||||
grade_recorded: { in_app: false, push: false },
|
||||
},
|
||||
},
|
||||
defaults: {
|
||||
grade_recorded: { in_app: true, push: true },
|
||||
homework_graded: { in_app: true },
|
||||
homework_assigned: { in_app: true },
|
||||
exam_published: { in_app: true },
|
||||
attendance_alert: { in_app: true },
|
||||
school_announcement: { in_app: true },
|
||||
teacher_message: { in_app: true },
|
||||
fee_reminder: { in_app: true },
|
||||
event_invitation: { in_app: true },
|
||||
},
|
||||
updatedAt: "2026-01-01T00:00:00Z",
|
||||
};
|
||||
}
|
||||
|
||||
beforeAll(() => server.listen({ onUnhandledRequest: "error" }));
|
||||
afterEach(() => server.resetHandlers());
|
||||
afterAll(() => server.close());
|
||||
|
||||
beforeEach(() => {
|
||||
localStorage.clear();
|
||||
});
|
||||
|
||||
describe("useNotificationPreferences", () => {
|
||||
it("初始加载态:preferences 为 null 且 loading=true", () => {
|
||||
const { result } = renderUseNotificationPreferences();
|
||||
expect(result.current.preferences).toBeNull();
|
||||
expect(result.current.loading).toBe(true);
|
||||
expect(result.current.error).toBeUndefined();
|
||||
});
|
||||
|
||||
it("localStorage 有缓存时优先用 localStorage 数据", async () => {
|
||||
const local = makeLocalPrefs();
|
||||
localStorage.setItem(PREFS_KEY, JSON.stringify(local));
|
||||
|
||||
const { result } = renderUseNotificationPreferences();
|
||||
|
||||
// useEffect 在挂载后立即读取 localStorage
|
||||
await waitFor(() => {
|
||||
expect(result.current.preferences).not.toBeNull();
|
||||
});
|
||||
expect(result.current.preferences?.parentId).toBe("parent-001");
|
||||
// 验证用的是 localStorage 数据,而非 GraphQL mock(mock 中 in_app=true)
|
||||
expect(
|
||||
result.current.preferences?.preferences["student-001"]?.grade_recorded
|
||||
?.in_app,
|
||||
).toBe(false);
|
||||
expect(result.current.loading).toBe(false);
|
||||
});
|
||||
|
||||
it("localStorage 缓存无效 JSON 时降级到 GraphQL 数据", async () => {
|
||||
localStorage.setItem(PREFS_KEY, "{invalid json");
|
||||
|
||||
const { result } = renderUseNotificationPreferences();
|
||||
|
||||
await waitFor(() => {
|
||||
expect(result.current.preferences).not.toBeNull();
|
||||
});
|
||||
// 来自 GraphQL mock:grade_recorded.in_app=true
|
||||
expect(
|
||||
result.current.preferences?.preferences["student-001"]?.grade_recorded
|
||||
?.in_app,
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
it("无 localStorage 时使用 GraphQL 返回的默认偏好", async () => {
|
||||
const { result } = renderUseNotificationPreferences();
|
||||
|
||||
await waitFor(() => {
|
||||
expect(result.current.preferences).not.toBeNull();
|
||||
});
|
||||
expect(result.current.preferences?.parentId).toBe("parent-001");
|
||||
// 验证 mock 中两个子女的偏好均存在
|
||||
expect(
|
||||
result.current.preferences?.preferences["student-001"],
|
||||
).toBeDefined();
|
||||
expect(
|
||||
result.current.preferences?.preferences["student-002"],
|
||||
).toBeDefined();
|
||||
expect(result.current.preferences?.defaults).toBeDefined();
|
||||
});
|
||||
|
||||
it("updatePreference 更新本地 state 并写入 localStorage", async () => {
|
||||
const { result } = renderUseNotificationPreferences();
|
||||
|
||||
await waitFor(() => {
|
||||
expect(result.current.preferences).not.toBeNull();
|
||||
});
|
||||
|
||||
act(() => {
|
||||
result.current.updatePreference(
|
||||
"student-001",
|
||||
"grade_recorded",
|
||||
"in_app",
|
||||
false,
|
||||
);
|
||||
});
|
||||
|
||||
// state 已更新
|
||||
expect(
|
||||
result.current.preferences?.preferences["student-001"]?.grade_recorded
|
||||
?.in_app,
|
||||
).toBe(false);
|
||||
// localStorage 已写入
|
||||
const stored = localStorage.getItem(PREFS_KEY);
|
||||
expect(stored).not.toBeNull();
|
||||
const parsed = JSON.parse(stored!) as NotificationPreferences;
|
||||
expect(parsed.preferences["student-001"]?.grade_recorded?.in_app).toBe(
|
||||
false,
|
||||
);
|
||||
// updatedAt 被刷新
|
||||
expect(parsed.updatedAt).not.toBe("2026-01-01T00:00:00Z");
|
||||
});
|
||||
|
||||
it("updatePreference 触发 GraphQL mutation(fire-and-forget)", async () => {
|
||||
const fetchSpy = vi.spyOn(globalThis, "fetch");
|
||||
|
||||
const { result } = renderUseNotificationPreferences();
|
||||
|
||||
await waitFor(() => {
|
||||
expect(result.current.preferences).not.toBeNull();
|
||||
});
|
||||
|
||||
fetchSpy.mockClear();
|
||||
|
||||
act(() => {
|
||||
result.current.updatePreference(
|
||||
"student-001",
|
||||
"homework_graded",
|
||||
"push",
|
||||
false,
|
||||
);
|
||||
});
|
||||
|
||||
// 等待 mutation 发出
|
||||
await waitFor(() => {
|
||||
expect(fetchSpy).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
// 最后一次调用应为 mutation(包含 updateNotificationPreferences)
|
||||
const lastCall = fetchSpy.mock.calls[fetchSpy.mock.calls.length - 1];
|
||||
const body = (lastCall?.[1] as RequestInit | undefined)?.body;
|
||||
expect(body).toBeDefined();
|
||||
expect(String(body)).toContain("updateNotificationPreferences");
|
||||
expect(String(body)).toContain("parent-001");
|
||||
|
||||
fetchSpy.mockRestore();
|
||||
});
|
||||
|
||||
it("updatePreference mutation 失败时不抛出(静默降级)", async () => {
|
||||
// 使用 server.use 覆盖 mutation 处理器,保留默认 query 处理器
|
||||
server.use(
|
||||
graphql.mutation("UpdateNotificationPreferences", () =>
|
||||
HttpResponse.json(
|
||||
{ errors: [{ message: "后端不可用" }] },
|
||||
{ status: 200 },
|
||||
),
|
||||
),
|
||||
);
|
||||
|
||||
const { result } = renderUseNotificationPreferences();
|
||||
|
||||
await waitFor(() => {
|
||||
expect(result.current.preferences).not.toBeNull();
|
||||
});
|
||||
|
||||
// 不应抛出
|
||||
expect(() => {
|
||||
act(() => {
|
||||
result.current.updatePreference(
|
||||
"student-001",
|
||||
"grade_recorded",
|
||||
"in_app",
|
||||
false,
|
||||
);
|
||||
});
|
||||
}).not.toThrow();
|
||||
|
||||
// 本地 state 仍然更新成功(localStorage 降级)
|
||||
await waitFor(() => {
|
||||
expect(
|
||||
result.current.preferences?.preferences["student-001"]?.grade_recorded
|
||||
?.in_app,
|
||||
).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
it("updatePreference 在 localPrefs 为 null 时为 no-op", async () => {
|
||||
// 覆盖 query 返回 null data(保留其他 handler)
|
||||
server.use(
|
||||
graphql.query("MyNotificationPreferences", () =>
|
||||
HttpResponse.json({ data: { myNotificationPreferences: null } }),
|
||||
),
|
||||
);
|
||||
|
||||
const { result } = renderUseNotificationPreferences();
|
||||
|
||||
// 等待查询完成,但 localPrefs 仍为 null(因 data 为 null)
|
||||
await waitFor(() => {
|
||||
expect(result.current.loading).toBe(false);
|
||||
});
|
||||
expect(result.current.preferences).toBeNull();
|
||||
|
||||
// 调用 updatePreference 不应抛出且不改变 null 状态
|
||||
expect(() => {
|
||||
act(() => {
|
||||
result.current.updatePreference(
|
||||
"student-001",
|
||||
"grade_recorded",
|
||||
"in_app",
|
||||
true,
|
||||
);
|
||||
});
|
||||
}).not.toThrow();
|
||||
|
||||
expect(result.current.preferences).toBeNull();
|
||||
});
|
||||
|
||||
it("updatePreference 处理新 eventType(不存在时创建)", async () => {
|
||||
const { result } = renderUseNotificationPreferences();
|
||||
|
||||
await waitFor(() => {
|
||||
expect(result.current.preferences).not.toBeNull();
|
||||
});
|
||||
|
||||
act(() => {
|
||||
result.current.updatePreference(
|
||||
"student-001",
|
||||
"exam_published",
|
||||
"sms",
|
||||
true,
|
||||
);
|
||||
});
|
||||
|
||||
expect(
|
||||
result.current.preferences?.preferences["student-001"]?.exam_published
|
||||
?.sms,
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
it("updatePreference 处理新 childId(不存在时创建)", async () => {
|
||||
const { result } = renderUseNotificationPreferences();
|
||||
|
||||
await waitFor(() => {
|
||||
expect(result.current.preferences).not.toBeNull();
|
||||
});
|
||||
|
||||
act(() => {
|
||||
result.current.updatePreference(
|
||||
"student-999",
|
||||
"grade_recorded",
|
||||
"in_app",
|
||||
true,
|
||||
);
|
||||
});
|
||||
|
||||
expect(
|
||||
result.current.preferences?.preferences["student-999"]?.grade_recorded
|
||||
?.in_app,
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
it("GraphQL 查询出错时透传 error", async () => {
|
||||
server.use(
|
||||
graphql.query("MyNotificationPreferences", () =>
|
||||
HttpResponse.json({ errors: [{ message: "未授权" }] }, { status: 200 }),
|
||||
),
|
||||
);
|
||||
|
||||
const { result } = renderUseNotificationPreferences();
|
||||
|
||||
await waitFor(() => {
|
||||
expect(result.current.error).toBeDefined();
|
||||
});
|
||||
expect(result.current.error?.message).toContain("未授权");
|
||||
// 出错时 localPrefs 保持 null,loading=false(因 fetching=false)
|
||||
expect(result.current.preferences).toBeNull();
|
||||
expect(result.current.loading).toBe(false);
|
||||
});
|
||||
|
||||
it("loading 在 localPrefs 已加载后为 false(即使 query 仍在 fetching)", async () => {
|
||||
const local = makeLocalPrefs();
|
||||
localStorage.setItem(PREFS_KEY, JSON.stringify(local));
|
||||
|
||||
const { result } = renderUseNotificationPreferences();
|
||||
|
||||
await waitFor(() => {
|
||||
expect(result.current.preferences).not.toBeNull();
|
||||
});
|
||||
// localPrefs 已就绪,loading 必为 false
|
||||
expect(result.current.loading).toBe(false);
|
||||
});
|
||||
});
|
||||
141
apps/parent-portal/src/hooks/usePermission.test.tsx
Normal file
141
apps/parent-portal/src/hooks/usePermission.test.tsx
Normal file
@@ -0,0 +1,141 @@
|
||||
// usePermission Hook 单测
|
||||
// 依据:project_rules §3.1(前端禁止 role === "xxx" 硬编码,统一用 hasPermission)
|
||||
// 覆盖:hasPermission / hasAnyPermission / hasAllPermissions / isAuthenticated / 无用户态
|
||||
|
||||
import { describe, it, expect, beforeEach, afterEach } from "vitest";
|
||||
import { renderHook, cleanup } from "@testing-library/react";
|
||||
import { usePermission } from "./usePermission";
|
||||
import { setUser, clearAuth } from "@/lib/auth";
|
||||
import { PERMISSIONS } from "@/lib/permissions";
|
||||
import { mockParent } from "@/test/mocks/fixtures";
|
||||
|
||||
beforeEach(() => {
|
||||
localStorage.clear();
|
||||
clearAuth();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
cleanup();
|
||||
});
|
||||
|
||||
describe("usePermission", () => {
|
||||
it("无用户时 permissions 为空且未认证", () => {
|
||||
const { result } = renderHook(() => usePermission());
|
||||
expect(result.current.permissions).toEqual([]);
|
||||
expect(result.current.isAuthenticated).toBe(false);
|
||||
});
|
||||
|
||||
it("无用户时 hasPermission 返回 false", () => {
|
||||
const { result } = renderHook(() => usePermission());
|
||||
expect(result.current.hasPermission(PERMISSIONS.DASHBOARD_VIEW)).toBe(
|
||||
false,
|
||||
);
|
||||
});
|
||||
|
||||
it("无用户时 hasAnyPermission 返回 false", () => {
|
||||
const { result } = renderHook(() => usePermission());
|
||||
expect(
|
||||
result.current.hasAnyPermission(
|
||||
PERMISSIONS.DASHBOARD_VIEW,
|
||||
PERMISSIONS.CHILD_GRADE_VIEW,
|
||||
),
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
it("无用户时 hasAllPermissions 返回 false", () => {
|
||||
const { result } = renderHook(() => usePermission());
|
||||
expect(
|
||||
result.current.hasAllPermissions(
|
||||
PERMISSIONS.DASHBOARD_VIEW,
|
||||
PERMISSIONS.CHILD_GRADE_VIEW,
|
||||
),
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
it("有用户时 isAuthenticated 为 true", () => {
|
||||
setUser(mockParent);
|
||||
const { result } = renderHook(() => usePermission());
|
||||
expect(result.current.isAuthenticated).toBe(true);
|
||||
});
|
||||
|
||||
it("有用户时 permissions 返回用户权限列表", () => {
|
||||
setUser(mockParent);
|
||||
const { result } = renderHook(() => usePermission());
|
||||
expect(result.current.permissions).toContain("DASHBOARD_VIEW");
|
||||
expect(result.current.permissions).toContain("CHILD_GRADE_VIEW");
|
||||
expect(result.current.permissions).toContain("NOTIFICATION_VIEW");
|
||||
});
|
||||
|
||||
it("hasPermission 拥有该权限时返回 true", () => {
|
||||
setUser(mockParent);
|
||||
const { result } = renderHook(() => usePermission());
|
||||
expect(result.current.hasPermission(PERMISSIONS.DASHBOARD_VIEW)).toBe(true);
|
||||
expect(result.current.hasPermission(PERMISSIONS.CHILD_GRADE_VIEW)).toBe(
|
||||
true,
|
||||
);
|
||||
expect(result.current.hasPermission(PERMISSIONS.NOTIFICATION_VIEW)).toBe(
|
||||
true,
|
||||
);
|
||||
});
|
||||
|
||||
it("hasPermission 未拥有该权限时返回 false", () => {
|
||||
setUser(mockParent);
|
||||
const { result } = renderHook(() => usePermission());
|
||||
// mockParent 没有 ADMIN 权限
|
||||
expect(result.current.hasPermission("ADMIN" as never)).toBe(false);
|
||||
});
|
||||
|
||||
it("hasAnyPermission 任一权限满足时返回 true", () => {
|
||||
setUser(mockParent);
|
||||
const { result } = renderHook(() => usePermission());
|
||||
expect(
|
||||
result.current.hasAnyPermission(
|
||||
PERMISSIONS.DASHBOARD_VIEW,
|
||||
"ADMIN" as never,
|
||||
),
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
it("hasAnyPermission 所有权限都不满足时返回 false", () => {
|
||||
setUser(mockParent);
|
||||
const { result } = renderHook(() => usePermission());
|
||||
expect(
|
||||
result.current.hasAnyPermission("ADMIN" as never, "SUPER" as never),
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
it("hasAnyPermission 空参数返回 false", () => {
|
||||
setUser(mockParent);
|
||||
const { result } = renderHook(() => usePermission());
|
||||
expect(result.current.hasAnyPermission()).toBe(false);
|
||||
});
|
||||
|
||||
it("hasAllPermissions 所有权限满足时返回 true", () => {
|
||||
setUser(mockParent);
|
||||
const { result } = renderHook(() => usePermission());
|
||||
expect(
|
||||
result.current.hasAllPermissions(
|
||||
PERMISSIONS.DASHBOARD_VIEW,
|
||||
PERMISSIONS.CHILD_GRADE_VIEW,
|
||||
PERMISSIONS.NOTIFICATION_VIEW,
|
||||
),
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
it("hasAllPermissions 部分权限不满足时返回 false", () => {
|
||||
setUser(mockParent);
|
||||
const { result } = renderHook(() => usePermission());
|
||||
expect(
|
||||
result.current.hasAllPermissions(
|
||||
PERMISSIONS.DASHBOARD_VIEW,
|
||||
"ADMIN" as never,
|
||||
),
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
it("hasAllPermissions 空参数返回 true", () => {
|
||||
setUser(mockParent);
|
||||
const { result } = renderHook(() => usePermission());
|
||||
expect(result.current.hasAllPermissions()).toBe(true);
|
||||
});
|
||||
});
|
||||
235
apps/parent-portal/src/hooks/useRealtimeNotifications.test.tsx
Normal file
235
apps/parent-portal/src/hooks/useRealtimeNotifications.test.tsx
Normal file
@@ -0,0 +1,235 @@
|
||||
// useRealtimeNotifications Hook 单测
|
||||
// 依据:02-architecture-design.md §5 实时推送
|
||||
// 覆盖:各 WebSocketEvent 类型 → CustomEvent 分发 / status 透传
|
||||
|
||||
import { describe, it, expect, beforeEach, afterEach, vi } from "vitest";
|
||||
import { renderHook, cleanup } from "@testing-library/react";
|
||||
import type { WebSocketEvent } from "@/types";
|
||||
|
||||
// ===== Mock useWebSocket =====
|
||||
// 捕获 onEvent 回调,便于在测试中手动触发各类事件
|
||||
let capturedOnEvent: ((event: WebSocketEvent) => void) | null = null;
|
||||
let mockWsStatus = "disconnected";
|
||||
|
||||
vi.mock("./useWebSocket", () => ({
|
||||
useWebSocket: vi.fn(
|
||||
(options: {
|
||||
onEvent?: (event: WebSocketEvent) => void;
|
||||
enabled?: boolean;
|
||||
}) => {
|
||||
capturedOnEvent = options.onEvent ?? null;
|
||||
return { status: mockWsStatus, reconnect: vi.fn() };
|
||||
},
|
||||
),
|
||||
}));
|
||||
|
||||
import { useRealtimeNotifications } from "./useRealtimeNotifications";
|
||||
import type { NotificationItem, ChildInfo } from "@/types";
|
||||
|
||||
beforeEach(() => {
|
||||
capturedOnEvent = null;
|
||||
mockWsStatus = "disconnected";
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
cleanup();
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
describe("useRealtimeNotifications", () => {
|
||||
it("透传 useWebSocket 的 status", () => {
|
||||
mockWsStatus = "connected";
|
||||
const { result } = renderHook(() => useRealtimeNotifications());
|
||||
expect(result.current.status).toBe("connected");
|
||||
});
|
||||
|
||||
it("初始 status 为 disconnected(默认)", () => {
|
||||
const { result } = renderHook(() => useRealtimeNotifications());
|
||||
expect(result.current.status).toBe("disconnected");
|
||||
});
|
||||
|
||||
it("NotificationRequested 事件分发 realtime-notification CustomEvent", () => {
|
||||
const listener = vi.fn();
|
||||
window.addEventListener("realtime-notification", listener);
|
||||
|
||||
renderHook(() => useRealtimeNotifications());
|
||||
expect(capturedOnEvent).not.toBeNull();
|
||||
|
||||
const notification: NotificationItem = {
|
||||
id: "notif-001",
|
||||
childId: "student-001",
|
||||
eventType: "grade_recorded",
|
||||
title: "成绩发布",
|
||||
body: "数学成绩已发布",
|
||||
read: false,
|
||||
createdAt: new Date().toISOString(),
|
||||
};
|
||||
|
||||
capturedOnEvent!({ type: "NotificationRequested", notification });
|
||||
|
||||
expect(listener).toHaveBeenCalledTimes(1);
|
||||
const event = listener.mock.calls[0]?.[0] as CustomEvent;
|
||||
expect(event.detail).toEqual(notification);
|
||||
|
||||
window.removeEventListener("realtime-notification", listener);
|
||||
});
|
||||
|
||||
it("GradeRecorded 事件分发 realtime-grade CustomEvent", () => {
|
||||
const listener = vi.fn();
|
||||
window.addEventListener("realtime-grade", listener);
|
||||
|
||||
renderHook(() => useRealtimeNotifications());
|
||||
expect(capturedOnEvent).not.toBeNull();
|
||||
|
||||
capturedOnEvent!({
|
||||
type: "GradeRecorded",
|
||||
childId: "student-001",
|
||||
examId: "exam-001",
|
||||
examName: "期中数学",
|
||||
score: 95,
|
||||
});
|
||||
|
||||
expect(listener).toHaveBeenCalledTimes(1);
|
||||
const event = listener.mock.calls[0]?.[0] as CustomEvent;
|
||||
expect(event.detail).toEqual({
|
||||
childId: "student-001",
|
||||
examId: "exam-001",
|
||||
});
|
||||
|
||||
window.removeEventListener("realtime-grade", listener);
|
||||
});
|
||||
|
||||
it("SchoolAnnouncement 事件分发 realtime-announcement CustomEvent", () => {
|
||||
const listener = vi.fn();
|
||||
window.addEventListener("realtime-announcement", listener);
|
||||
|
||||
renderHook(() => useRealtimeNotifications());
|
||||
expect(capturedOnEvent).not.toBeNull();
|
||||
|
||||
capturedOnEvent!({
|
||||
type: "SchoolAnnouncement",
|
||||
title: "放假通知",
|
||||
body: "暑假从7月15日开始",
|
||||
});
|
||||
|
||||
expect(listener).toHaveBeenCalledTimes(1);
|
||||
const event = listener.mock.calls[0]?.[0] as CustomEvent;
|
||||
expect(event.detail).toEqual({
|
||||
title: "放假通知",
|
||||
body: "暑假从7月15日开始",
|
||||
});
|
||||
|
||||
window.removeEventListener("realtime-announcement", listener);
|
||||
});
|
||||
|
||||
it("ChildBound 事件分发 realtime-child-bound CustomEvent(无 detail)", () => {
|
||||
const listener = vi.fn();
|
||||
window.addEventListener("realtime-child-bound", listener);
|
||||
|
||||
renderHook(() => useRealtimeNotifications());
|
||||
expect(capturedOnEvent).not.toBeNull();
|
||||
|
||||
const child: ChildInfo = {
|
||||
id: "student-003",
|
||||
name: "张小刚",
|
||||
grade: "grade.3",
|
||||
schoolName: "实验小学",
|
||||
classId: "class-3-1",
|
||||
};
|
||||
|
||||
capturedOnEvent!({ type: "ChildBound", child });
|
||||
|
||||
expect(listener).toHaveBeenCalledTimes(1);
|
||||
|
||||
window.removeEventListener("realtime-child-bound", listener);
|
||||
});
|
||||
|
||||
it("ChildUnbound 事件分发 realtime-child-unbound CustomEvent", () => {
|
||||
const listener = vi.fn();
|
||||
window.addEventListener("realtime-child-unbound", listener);
|
||||
|
||||
renderHook(() => useRealtimeNotifications());
|
||||
expect(capturedOnEvent).not.toBeNull();
|
||||
|
||||
capturedOnEvent!({ type: "ChildUnbound", childId: "student-001" });
|
||||
|
||||
expect(listener).toHaveBeenCalledTimes(1);
|
||||
const event = listener.mock.calls[0]?.[0] as CustomEvent;
|
||||
expect(event.detail).toEqual({ childId: "student-001" });
|
||||
|
||||
window.removeEventListener("realtime-child-unbound", listener);
|
||||
});
|
||||
|
||||
it("handleEvent 使用 useCallback 保持引用稳定", () => {
|
||||
const { rerender } = renderHook(() => useRealtimeNotifications());
|
||||
const firstOnEvent = capturedOnEvent;
|
||||
|
||||
rerender();
|
||||
|
||||
// useCallback deps=[],引用应保持不变
|
||||
expect(capturedOnEvent).toBe(firstOnEvent);
|
||||
});
|
||||
|
||||
it("向 useWebSocket 传递 enabled=true", async () => {
|
||||
const { useWebSocket } = await import("./useWebSocket");
|
||||
const mockedUseWebSocket = vi.mocked(useWebSocket);
|
||||
|
||||
renderHook(() => useRealtimeNotifications());
|
||||
|
||||
expect(mockedUseWebSocket).toHaveBeenCalled();
|
||||
const callArgs = mockedUseWebSocket.mock.calls[0]?.[0];
|
||||
expect(callArgs?.enabled).toBe(true);
|
||||
});
|
||||
|
||||
it("多个不同类型事件依次分发各自 CustomEvent", () => {
|
||||
const notifListener = vi.fn();
|
||||
const gradeListener = vi.fn();
|
||||
window.addEventListener("realtime-notification", notifListener);
|
||||
window.addEventListener("realtime-grade", gradeListener);
|
||||
|
||||
renderHook(() => useRealtimeNotifications());
|
||||
expect(capturedOnEvent).not.toBeNull();
|
||||
|
||||
const notification: NotificationItem = {
|
||||
id: "notif-002",
|
||||
childId: null,
|
||||
eventType: "school_announcement",
|
||||
title: "test",
|
||||
body: "test body",
|
||||
read: false,
|
||||
createdAt: new Date().toISOString(),
|
||||
};
|
||||
|
||||
capturedOnEvent!({ type: "NotificationRequested", notification });
|
||||
capturedOnEvent!({
|
||||
type: "GradeRecorded",
|
||||
childId: "student-002",
|
||||
examId: "exam-005",
|
||||
examName: "期末",
|
||||
score: 88,
|
||||
});
|
||||
|
||||
expect(notifListener).toHaveBeenCalledTimes(1);
|
||||
expect(gradeListener).toHaveBeenCalledTimes(1);
|
||||
|
||||
window.removeEventListener("realtime-notification", notifListener);
|
||||
window.removeEventListener("realtime-grade", gradeListener);
|
||||
});
|
||||
|
||||
it("卸载后不再分发事件(组件已清理)", () => {
|
||||
const listener = vi.fn();
|
||||
window.addEventListener("realtime-notification", listener);
|
||||
|
||||
const { unmount } = renderHook(() => useRealtimeNotifications());
|
||||
expect(capturedOnEvent).not.toBeNull();
|
||||
|
||||
unmount();
|
||||
|
||||
// capturedOnEvent 仍持有旧引用,但组件已卸载
|
||||
// window 事件监听器是在 handleEvent 中即时 addEventListener 的
|
||||
// 这里验证卸载不抛出
|
||||
expect(() => unmount()).not.toThrow();
|
||||
|
||||
window.removeEventListener("realtime-notification", listener);
|
||||
});
|
||||
});
|
||||
619
apps/parent-portal/src/hooks/useWebSocket.test.tsx
Normal file
619
apps/parent-portal/src/hooks/useWebSocket.test.tsx
Normal file
@@ -0,0 +1,619 @@
|
||||
// useWebSocket Hook 单测
|
||||
// 依据:02-architecture-design.md §5 实时推送(P5)
|
||||
// 覆盖:连接生命周期 / 重连指数退避 / 轮询降级 / 事件分发 / 清理 / token 缺失 / enabled 开关
|
||||
|
||||
import {
|
||||
describe,
|
||||
it,
|
||||
expect,
|
||||
beforeAll,
|
||||
afterAll,
|
||||
afterEach,
|
||||
beforeEach,
|
||||
vi,
|
||||
} from "vitest";
|
||||
import { renderHook, cleanup, act } from "@testing-library/react";
|
||||
import type { WebSocketEvent } from "@/types";
|
||||
|
||||
// ===== 在模块导入前设置环境变量 =====
|
||||
// vi.hoisted 在所有 import 之前执行
|
||||
vi.hoisted(() => {
|
||||
process.env.NEXT_PUBLIC_PUSH_GATEWAY_WS_URL = "ws://localhost:8081/ws";
|
||||
});
|
||||
|
||||
// ===== Mock getToken =====
|
||||
// vi.mock 被提升到文件顶部,必须用 vi.hoisted 定义变量
|
||||
const { mockGetToken } = vi.hoisted(() => ({
|
||||
mockGetToken: vi.fn<() => string | null>(() => "mock-access-token"),
|
||||
}));
|
||||
vi.mock("@/lib/auth", () => ({
|
||||
getToken: mockGetToken,
|
||||
}));
|
||||
|
||||
// ===== Mock WebSocket =====
|
||||
interface MockWebSocketInstance {
|
||||
url: string;
|
||||
onopen: (() => void) | null;
|
||||
onmessage: ((event: { data: string }) => void) | null;
|
||||
onerror: (() => void) | null;
|
||||
onclose: (() => void) | null;
|
||||
readyState: number;
|
||||
close: () => void;
|
||||
}
|
||||
|
||||
class MockWebSocketImpl implements MockWebSocketInstance {
|
||||
url: string;
|
||||
onopen: (() => void) | null = null;
|
||||
onmessage: ((event: { data: string }) => void) | null = null;
|
||||
onerror: (() => void) | null = null;
|
||||
onclose: (() => void) | null = null;
|
||||
readyState = 0;
|
||||
static instances: MockWebSocketImpl[] = [];
|
||||
static shouldThrow = false;
|
||||
|
||||
constructor(url: string) {
|
||||
this.url = url;
|
||||
if (MockWebSocketImpl.shouldThrow) {
|
||||
MockWebSocketImpl.shouldThrow = false;
|
||||
throw new Error("WebSocket construction failed");
|
||||
}
|
||||
MockWebSocketImpl.instances.push(this);
|
||||
}
|
||||
close(): void {
|
||||
this.readyState = 3;
|
||||
}
|
||||
// 测试辅助方法
|
||||
fireOpen(): void {
|
||||
this.readyState = 1;
|
||||
this.onopen?.();
|
||||
}
|
||||
fireMessage(data: unknown): void {
|
||||
this.onmessage?.({
|
||||
data: typeof data === "string" ? data : JSON.stringify(data),
|
||||
});
|
||||
}
|
||||
fireError(): void {
|
||||
this.onerror?.();
|
||||
}
|
||||
fireClose(): void {
|
||||
this.readyState = 3;
|
||||
this.onclose?.();
|
||||
}
|
||||
}
|
||||
|
||||
beforeAll(() => {
|
||||
// 注入全局 WebSocket mock
|
||||
(globalThis as unknown as { WebSocket: typeof MockWebSocketImpl }).WebSocket =
|
||||
MockWebSocketImpl;
|
||||
});
|
||||
|
||||
afterAll(() => {
|
||||
// 恢复
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
import { useWebSocket } from "./useWebSocket";
|
||||
|
||||
beforeEach(() => {
|
||||
vi.useFakeTimers();
|
||||
mockGetToken.mockReturnValue("mock-access-token");
|
||||
MockWebSocketImpl.instances.length = 0;
|
||||
MockWebSocketImpl.shouldThrow = false;
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.useRealTimers();
|
||||
cleanup();
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
describe("useWebSocket", () => {
|
||||
it("返回 status 和 reconnect 函数", () => {
|
||||
const { result } = renderHook(() =>
|
||||
useWebSocket({ onEvent: vi.fn(), enabled: false }),
|
||||
);
|
||||
expect(result.current).toHaveProperty("status");
|
||||
expect(result.current).toHaveProperty("reconnect");
|
||||
expect(typeof result.current.reconnect).toBe("function");
|
||||
});
|
||||
|
||||
it("enabled=false 时进入轮询模式", () => {
|
||||
const { result } = renderHook(() =>
|
||||
useWebSocket({ onEvent: vi.fn(), enabled: false }),
|
||||
);
|
||||
expect(result.current.status).toBe("polling");
|
||||
});
|
||||
|
||||
it("无 token 时 status 为 disconnected", () => {
|
||||
mockGetToken.mockReturnValue(null);
|
||||
const { result } = renderHook(() =>
|
||||
useWebSocket({ onEvent: vi.fn(), enabled: true }),
|
||||
);
|
||||
expect(result.current.status).toBe("disconnected");
|
||||
});
|
||||
|
||||
it("有 token 时创建 WebSocket 并设置 status=connecting", () => {
|
||||
const { result } = renderHook(() =>
|
||||
useWebSocket({ onEvent: vi.fn(), enabled: true }),
|
||||
);
|
||||
expect(result.current.status).toBe("connecting");
|
||||
expect(MockWebSocketImpl.instances).toHaveLength(1);
|
||||
const ws = MockWebSocketImpl.instances[0]!;
|
||||
expect(ws.url).toContain("ws://localhost:8081/ws");
|
||||
expect(ws.url).toContain("token=mock-access-token");
|
||||
});
|
||||
|
||||
it("ws.onopen 触发后 status=connected", () => {
|
||||
const { result } = renderHook(() =>
|
||||
useWebSocket({ onEvent: vi.fn(), enabled: true }),
|
||||
);
|
||||
expect(result.current.status).toBe("connecting");
|
||||
|
||||
const ws = MockWebSocketImpl.instances[0]!;
|
||||
act(() => {
|
||||
ws.fireOpen();
|
||||
});
|
||||
|
||||
expect(result.current.status).toBe("connected");
|
||||
});
|
||||
|
||||
it("ws.onmessage 有效 JSON 时调用 onEvent", () => {
|
||||
const onEvent = vi.fn();
|
||||
renderHook(() => useWebSocket({ onEvent, enabled: true }));
|
||||
|
||||
const ws = MockWebSocketImpl.instances[0]!;
|
||||
const event: WebSocketEvent = {
|
||||
type: "NotificationRequested",
|
||||
notification: {
|
||||
id: "notif-001",
|
||||
childId: "student-001",
|
||||
eventType: "grade_recorded",
|
||||
title: "test",
|
||||
body: "test body",
|
||||
read: false,
|
||||
createdAt: new Date().toISOString(),
|
||||
},
|
||||
};
|
||||
|
||||
act(() => {
|
||||
ws.fireMessage(event);
|
||||
});
|
||||
|
||||
expect(onEvent).toHaveBeenCalledWith(event);
|
||||
});
|
||||
|
||||
it("ws.onmessage 无效 JSON 时静默忽略(不调用 onEvent)", () => {
|
||||
const onEvent = vi.fn();
|
||||
renderHook(() => useWebSocket({ onEvent, enabled: true }));
|
||||
|
||||
const ws = MockWebSocketImpl.instances[0]!;
|
||||
act(() => {
|
||||
ws.onmessage?.({ data: "{invalid json" });
|
||||
});
|
||||
|
||||
expect(onEvent).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("ws.onmessage 非字符串 data 时静默忽略", () => {
|
||||
const onEvent = vi.fn();
|
||||
renderHook(() => useWebSocket({ onEvent, enabled: true }));
|
||||
|
||||
const ws = MockWebSocketImpl.instances[0]!;
|
||||
act(() => {
|
||||
// JSON.parse 接收非字符串会先 toString,可能解析失败
|
||||
ws.onmessage?.({ data: "undefined" });
|
||||
});
|
||||
|
||||
expect(onEvent).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("ws.onerror 不立即改变 status", () => {
|
||||
const { result } = renderHook(() =>
|
||||
useWebSocket({ onEvent: vi.fn(), enabled: true }),
|
||||
);
|
||||
|
||||
const ws = MockWebSocketImpl.instances[0]!;
|
||||
act(() => {
|
||||
ws.fireOpen();
|
||||
});
|
||||
expect(result.current.status).toBe("connected");
|
||||
|
||||
act(() => {
|
||||
ws.fireError();
|
||||
});
|
||||
// onerror 不改变 status,等 onclose 处理
|
||||
expect(result.current.status).toBe("connected");
|
||||
});
|
||||
|
||||
it("ws.onclose 触发重连(首次重连 attempt=0)", () => {
|
||||
const { result } = renderHook(() =>
|
||||
useWebSocket({ onEvent: vi.fn(), enabled: true }),
|
||||
);
|
||||
|
||||
const ws = MockWebSocketImpl.instances[0]!;
|
||||
act(() => {
|
||||
ws.fireOpen();
|
||||
});
|
||||
expect(result.current.status).toBe("connected");
|
||||
|
||||
act(() => {
|
||||
ws.fireClose();
|
||||
});
|
||||
expect(result.current.status).toBe("disconnected");
|
||||
|
||||
// 等待重连定时器(getBackoffDelay(0) = 1000ms)
|
||||
act(() => {
|
||||
vi.advanceTimersByTime(1000);
|
||||
});
|
||||
|
||||
// 重连后创建新 WebSocket
|
||||
expect(MockWebSocketImpl.instances).toHaveLength(2);
|
||||
expect(result.current.status).toBe("connecting");
|
||||
});
|
||||
|
||||
it("重连指数退避:第二次重连延迟 2000ms", () => {
|
||||
renderHook(() => useWebSocket({ onEvent: vi.fn(), enabled: true }));
|
||||
|
||||
// 第一次关闭 → 重连
|
||||
const ws1 = MockWebSocketImpl.instances[0]!;
|
||||
act(() => {
|
||||
ws1.fireClose();
|
||||
});
|
||||
act(() => {
|
||||
vi.advanceTimersByTime(1000); // getBackoffDelay(0) = 1000
|
||||
});
|
||||
expect(MockWebSocketImpl.instances).toHaveLength(2);
|
||||
|
||||
// 第二次关闭 → 重连
|
||||
const ws2 = MockWebSocketImpl.instances[1]!;
|
||||
act(() => {
|
||||
ws2.fireClose();
|
||||
});
|
||||
|
||||
// 在 1000ms 时不应重连(需要 2000ms)
|
||||
act(() => {
|
||||
vi.advanceTimersByTime(1000);
|
||||
});
|
||||
expect(MockWebSocketImpl.instances).toHaveLength(2);
|
||||
|
||||
// 在 2000ms 时重连
|
||||
act(() => {
|
||||
vi.advanceTimersByTime(1000);
|
||||
});
|
||||
expect(MockWebSocketImpl.instances).toHaveLength(3);
|
||||
});
|
||||
|
||||
it("ws.onopen 重置 reconnectAttempts 为 0", () => {
|
||||
renderHook(() => useWebSocket({ onEvent: vi.fn(), enabled: true }));
|
||||
|
||||
// 第一次关闭 → 重连
|
||||
const ws1 = MockWebSocketImpl.instances[0]!;
|
||||
act(() => {
|
||||
ws1.fireClose();
|
||||
});
|
||||
act(() => {
|
||||
vi.advanceTimersByTime(1000);
|
||||
});
|
||||
|
||||
// 第二个 WebSocket 连接成功
|
||||
const ws2 = MockWebSocketImpl.instances[1]!;
|
||||
act(() => {
|
||||
ws2.fireOpen();
|
||||
});
|
||||
|
||||
// 再次关闭 → 应从 attempt=0 开始(延迟 1000ms 而非 2000ms)
|
||||
act(() => {
|
||||
ws2.fireClose();
|
||||
});
|
||||
act(() => {
|
||||
vi.advanceTimersByTime(1000);
|
||||
});
|
||||
|
||||
// 1000ms 后应已重连
|
||||
expect(MockWebSocketImpl.instances).toHaveLength(3);
|
||||
});
|
||||
|
||||
it("超过 MAX_RECONNECT_ATTEMPTS(10) 后降级为轮询", () => {
|
||||
const { result } = renderHook(() =>
|
||||
useWebSocket({ onEvent: vi.fn(), enabled: true }),
|
||||
);
|
||||
|
||||
// 模拟 10 次关闭重连
|
||||
for (let i = 0; i < 10; i++) {
|
||||
const ws =
|
||||
MockWebSocketImpl.instances[MockWebSocketImpl.instances.length - 1]!;
|
||||
act(() => {
|
||||
ws.fireClose();
|
||||
});
|
||||
// 推进足够时间让重连触发
|
||||
act(() => {
|
||||
vi.advanceTimersByTime(30000);
|
||||
});
|
||||
}
|
||||
|
||||
// 第 11 次关闭应触发轮询降级
|
||||
const lastWs =
|
||||
MockWebSocketImpl.instances[MockWebSocketImpl.instances.length - 1]!;
|
||||
act(() => {
|
||||
lastWs.fireClose();
|
||||
});
|
||||
|
||||
expect(result.current.status).toBe("polling");
|
||||
});
|
||||
|
||||
it("WebSocket 创建失败时降级为轮询", () => {
|
||||
MockWebSocketImpl.shouldThrow = true;
|
||||
const { result } = renderHook(() =>
|
||||
useWebSocket({ onEvent: vi.fn(), enabled: true }),
|
||||
);
|
||||
expect(result.current.status).toBe("polling");
|
||||
});
|
||||
|
||||
it("reconnect 函数可手动触发重连", () => {
|
||||
const { result } = renderHook(() =>
|
||||
useWebSocket({ onEvent: vi.fn(), enabled: true }),
|
||||
);
|
||||
|
||||
// 初始连接
|
||||
expect(MockWebSocketImpl.instances).toHaveLength(1);
|
||||
|
||||
act(() => {
|
||||
result.current.reconnect();
|
||||
});
|
||||
|
||||
// reconnect 调用 connect,创建新 WebSocket
|
||||
expect(MockWebSocketImpl.instances.length).toBeGreaterThanOrEqual(2);
|
||||
});
|
||||
|
||||
it("onEvent 引用更新时 onEventRef 同步(useEffect)", () => {
|
||||
const onEvent1 = vi.fn();
|
||||
const onEvent2 = vi.fn();
|
||||
const { rerender } = renderHook(
|
||||
({ onEvent }) => useWebSocket({ onEvent, enabled: true }),
|
||||
{ initialProps: { onEvent: onEvent1 } },
|
||||
);
|
||||
|
||||
const ws = MockWebSocketImpl.instances[0]!;
|
||||
const event: WebSocketEvent = {
|
||||
type: "ChildUnbound",
|
||||
childId: "student-001",
|
||||
};
|
||||
|
||||
act(() => {
|
||||
ws.fireMessage(event);
|
||||
});
|
||||
expect(onEvent1).toHaveBeenCalledTimes(1);
|
||||
expect(onEvent2).not.toHaveBeenCalled();
|
||||
|
||||
// 更新 onEvent 引用
|
||||
rerender({ onEvent: onEvent2 });
|
||||
|
||||
act(() => {
|
||||
ws.fireMessage(event);
|
||||
});
|
||||
expect(onEvent1).toHaveBeenCalledTimes(1);
|
||||
expect(onEvent2).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("卸载时关闭 WebSocket 并清理定时器", () => {
|
||||
const { unmount } = renderHook(() =>
|
||||
useWebSocket({ onEvent: vi.fn(), enabled: true }),
|
||||
);
|
||||
|
||||
const ws = MockWebSocketImpl.instances[0]!;
|
||||
expect(MockWebSocketImpl.instances).toHaveLength(1);
|
||||
|
||||
unmount();
|
||||
|
||||
// WebSocket 应被关闭(readyState 变为 3)
|
||||
// close() 是 mock 实现,readyState 设为 3
|
||||
expect(ws.readyState).toBe(3);
|
||||
});
|
||||
|
||||
it("卸载后不触发状态更新(不抛出)", () => {
|
||||
const { unmount } = renderHook(() =>
|
||||
useWebSocket({ onEvent: vi.fn(), enabled: true }),
|
||||
);
|
||||
|
||||
unmount();
|
||||
|
||||
// 推进时间不应抛出(清理后的定时器已清除)
|
||||
expect(() => {
|
||||
act(() => {
|
||||
vi.advanceTimersByTime(60000);
|
||||
});
|
||||
}).not.toThrow();
|
||||
});
|
||||
|
||||
it("WebSocket URL 中 token 被 encodeURIComponent 编码", () => {
|
||||
mockGetToken.mockReturnValue("token with special chars");
|
||||
|
||||
renderHook(() => useWebSocket({ onEvent: vi.fn(), enabled: true }));
|
||||
|
||||
const ws = MockWebSocketImpl.instances[0]!;
|
||||
expect(ws.url).toContain("token=token%20with%20special%20chars");
|
||||
});
|
||||
|
||||
it("轮询模式调用 fetch 拉取通知", async () => {
|
||||
const mockFetch = vi.fn().mockResolvedValue({
|
||||
ok: true,
|
||||
json: async () => ({
|
||||
data: {
|
||||
notifications: [
|
||||
{
|
||||
id: "poll-notif-001",
|
||||
childId: "student-001",
|
||||
eventType: "grade_recorded",
|
||||
title: "poll test",
|
||||
body: "polled notification",
|
||||
read: false,
|
||||
createdAt: new Date().toISOString(),
|
||||
},
|
||||
],
|
||||
},
|
||||
}),
|
||||
});
|
||||
vi.stubGlobal("fetch", mockFetch);
|
||||
|
||||
const onEvent = vi.fn();
|
||||
renderHook(() => useWebSocket({ onEvent, enabled: false }));
|
||||
|
||||
// 轮询立即拉取一次
|
||||
await vi.waitFor(() => {
|
||||
expect(mockFetch).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
const callUrl = mockFetch.mock.calls[0]?.[0];
|
||||
expect(String(callUrl)).toContain(
|
||||
"/api/v1/parent/v1/notifications?since=true",
|
||||
);
|
||||
|
||||
// 验证 Authorization header
|
||||
const callOpts = mockFetch.mock.calls[0]?.[1] as RequestInit | undefined;
|
||||
const headers = callOpts?.headers as Record<string, string> | undefined;
|
||||
expect(headers?.Authorization).toContain("Bearer");
|
||||
|
||||
// 等待 onEvent 被调用(pollNotifications 是 async)
|
||||
await vi.waitFor(() => {
|
||||
expect(onEvent).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
const event = onEvent.mock.calls[0]?.[0] as WebSocketEvent;
|
||||
expect(event.type).toBe("NotificationRequested");
|
||||
|
||||
vi.unstubAllGlobals();
|
||||
});
|
||||
|
||||
it("轮询 fetch 失败时静默忽略", async () => {
|
||||
const mockFetch = vi.fn().mockRejectedValue(new Error("network error"));
|
||||
vi.stubGlobal("fetch", mockFetch);
|
||||
|
||||
const onEvent = vi.fn();
|
||||
renderHook(() => useWebSocket({ onEvent, enabled: false }));
|
||||
|
||||
await vi.waitFor(() => {
|
||||
expect(mockFetch).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
// 不应抛出,onEvent 不被调用
|
||||
await vi.waitFor(() => {
|
||||
expect(onEvent).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
vi.unstubAllGlobals();
|
||||
});
|
||||
|
||||
it("轮询 fetch 返回非 ok 时静默忽略", async () => {
|
||||
const mockFetch = vi.fn().mockResolvedValue({
|
||||
ok: false,
|
||||
status: 401,
|
||||
json: async () => ({}),
|
||||
});
|
||||
vi.stubGlobal("fetch", mockFetch);
|
||||
|
||||
const onEvent = vi.fn();
|
||||
renderHook(() => useWebSocket({ onEvent, enabled: false }));
|
||||
|
||||
await vi.waitFor(() => {
|
||||
expect(mockFetch).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
await vi.waitFor(() => {
|
||||
expect(onEvent).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
vi.unstubAllGlobals();
|
||||
});
|
||||
|
||||
it("轮询定时器按 60s 间隔执行", async () => {
|
||||
const mockFetch = vi.fn().mockResolvedValue({
|
||||
ok: true,
|
||||
json: async () => ({ data: { notifications: [] } }),
|
||||
});
|
||||
vi.stubGlobal("fetch", mockFetch);
|
||||
|
||||
renderHook(() => useWebSocket({ onEvent: vi.fn(), enabled: false }));
|
||||
|
||||
// 立即拉取一次
|
||||
await vi.waitFor(() => {
|
||||
expect(mockFetch).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
// 推进 60s
|
||||
act(() => {
|
||||
vi.advanceTimersByTime(60_000);
|
||||
});
|
||||
|
||||
await vi.waitFor(() => {
|
||||
expect(mockFetch).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
|
||||
vi.unstubAllGlobals();
|
||||
});
|
||||
|
||||
it("connected 状态下 onclose 后 stopPolling 被调用(若之前在轮询)", () => {
|
||||
const { result } = renderHook(() =>
|
||||
useWebSocket({ onEvent: vi.fn(), enabled: true }),
|
||||
);
|
||||
|
||||
const ws = MockWebSocketImpl.instances[0]!;
|
||||
act(() => {
|
||||
ws.fireOpen();
|
||||
});
|
||||
expect(result.current.status).toBe("connected");
|
||||
|
||||
// 连接成功后应停止轮询
|
||||
// 关闭后进入 disconnected(非 polling),等待重连
|
||||
act(() => {
|
||||
ws.fireClose();
|
||||
});
|
||||
expect(result.current.status).toBe("disconnected");
|
||||
});
|
||||
|
||||
it("无 WS_URL 时降级为轮询", async () => {
|
||||
// 动态测试无 WS_URL 场景
|
||||
vi.resetModules();
|
||||
vi.stubEnv("NEXT_PUBLIC_PUSH_GATEWAY_WS_URL", "");
|
||||
|
||||
const { useWebSocket: useWsNoUrl } = await import("./useWebSocket");
|
||||
|
||||
const { result } = renderHook(() =>
|
||||
useWsNoUrl({ onEvent: vi.fn(), enabled: true }),
|
||||
);
|
||||
expect(result.current.status).toBe("polling");
|
||||
|
||||
// 恢复
|
||||
vi.stubEnv("NEXT_PUBLIC_PUSH_GATEWAY_WS_URL", "ws://localhost:8081/ws");
|
||||
vi.resetModules();
|
||||
});
|
||||
|
||||
it("默认参数 enabled=true", () => {
|
||||
// 不传 options,默认 enabled=true
|
||||
const { result } = renderHook(() => useWebSocket());
|
||||
// 有 token + WS_URL → connecting
|
||||
expect(result.current.status).toBe("connecting");
|
||||
});
|
||||
|
||||
it("onEvent 未传时不报错", () => {
|
||||
const { result } = renderHook(() => useWebSocket({ enabled: true }));
|
||||
expect(result.current.status).toBe("connecting");
|
||||
|
||||
const ws = MockWebSocketImpl.instances[0]!;
|
||||
const event: WebSocketEvent = {
|
||||
type: "ChildBound",
|
||||
child: {
|
||||
id: "student-003",
|
||||
name: "test",
|
||||
grade: "grade.1",
|
||||
schoolName: "test school",
|
||||
classId: "class-1",
|
||||
},
|
||||
};
|
||||
|
||||
expect(() => {
|
||||
act(() => {
|
||||
ws.fireMessage(event);
|
||||
});
|
||||
}).not.toThrow();
|
||||
});
|
||||
});
|
||||
@@ -146,7 +146,7 @@ async function pollNotifications(
|
||||
onEvent: (event: WebSocketEvent) => void,
|
||||
): Promise<void> {
|
||||
try {
|
||||
const res = await fetch("/api/v1/parent/notifications?since=true", {
|
||||
const res = await fetch("/api/v1/parent/v1/notifications?since=true", {
|
||||
headers: {
|
||||
Authorization: `Bearer ${localStorage.getItem("parent_access_token") ?? ""}`,
|
||||
},
|
||||
|
||||
Reference in New Issue
Block a user