主要变更: 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 任务已完成,无剩余工作项。
162 lines
4.7 KiB
TypeScript
162 lines
4.7 KiB
TypeScript
// 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("成绩查询失败");
|
||
});
|
||
});
|