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