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:
SpecialX
2026-07-13 13:10:07 +08:00
parent 7cf9aec20e
commit 7c8e0f5dea
79 changed files with 9179 additions and 284 deletions

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

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

View 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,
};
}

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

View 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,
};
}

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

View 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,
};
}

View 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("成绩查询失败");
});
});

View 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("作业查询失败");
});
});

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

View 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,
};
}

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

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

View File

@@ -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 switchChildparent-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,

View 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 → 9090 < 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");
});
});

View 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,
};
}

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

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

View 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 storesetChildren + 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([]);
});
});

View 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 mockmock 中 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 mockgrade_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 mutationfire-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 保持 nullloading=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);
});
});

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

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

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

View File

@@ -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") ?? ""}`,
},