feat(student-portal): 完成Docker构建验证+全量测试通过+nextstep下游依赖文档

This commit is contained in:
SpecialX
2026-07-13 16:49:31 +08:00
parent 64b2016fa8
commit 40f07b7a16
27 changed files with 4742 additions and 31 deletions

View File

@@ -2,35 +2,43 @@
# 用法docker build -t edu/student-portal:latest -f apps/student-portal/Dockerfile .
# 端口4001004 §1.2 强制 4 端 4000-4003
# ============ Stage 1: 装依赖 ============
FROM node:22-alpine AS deps
# ============ Stage 1: 构建 ============
FROM node:22-alpine AS builder
WORKDIR /app
# 启用 pnpm
RUN corepack enable && corepack prepare pnpm@9.12.0 --activate
# 先拷依赖清单,利用缓存
# 构建参数:控制 MSW mock 是否启用(生产构建默认 disabled
# --build-arg NEXT_PUBLIC_API_MOCKING=disabled
ARG NEXT_PUBLIC_API_MOCKING=disabled
ENV NEXT_PUBLIC_API_MOCKING=${NEXT_PUBLIC_API_MOCKING}
# MF 默认关闭(独立壳模式)
ARG NEXT_PUBLIC_MF_ENABLED=false
ENV NEXT_PUBLIC_MF_ENABLED=${NEXT_PUBLIC_MF_ENABLED}
# GraphQL 端点
ARG NEXT_PUBLIC_GRAPHQL_ENDPOINT=/api/v1/student/graphql
ENV NEXT_PUBLIC_GRAPHQL_ENDPOINT=${NEXT_PUBLIC_GRAPHQL_ENDPOINT}
# 拷贝整个 workspace 配置 + 源码pnpm workspace 需要所有包的 package.json + 源码)
COPY package.json pnpm-lock.yaml* pnpm-workspace.yaml* ./
COPY apps/student-portal/package.json ./apps/student-portal/
COPY tsconfig.base.json ./
COPY apps/student-portal ./apps/student-portal
# 拷贝 workspace 共享包student-portal 的 transpilePackages 依赖)
COPY packages/contracts ./packages/contracts
COPY packages/hooks ./packages/hooks
COPY packages/ui-components ./packages/ui-components
COPY packages/ui-tokens ./packages/ui-tokens
COPY packages/shared-ts ./packages/shared-ts
# 安装依赖(含 devDependencies构建需要
RUN pnpm install --filter @edu/student-portal... --frozen-lockfile || pnpm install --filter @edu/student-portal...
# ============ Stage 2: 构建 ============
FROM node:22-alpine AS builder
WORKDIR /app
RUN corepack enable && corepack prepare pnpm@9.12.0 --activate
COPY --from=deps /app/node_modules ./node_modules
COPY --from=deps /app/apps/student-portal/node_modules ./apps/student-portal/node_modules
COPY package.json pnpm-lock.yaml* pnpm-workspace.yaml* ./
COPY apps/student-portal ./apps/student-portal
# 构建生产产物(禁用 telemetry
ENV NEXT_TELEMETRY_DISABLED=1
RUN pnpm --filter @edu/student-portal run build
# ============ Stage 3: 运行时 ============
# ============ Stage 2: 运行时 ============
FROM node:22-alpine AS runner
WORKDIR /app
@@ -41,12 +49,14 @@ ENV PORT=4001
# 非 root 用户运行
RUN addgroup -g 1001 -S nodejs && adduser -S nextjs -u 1001
# 拷构建产物与必要清单
COPY --from=builder /app/apps/student-portal/package.json ./package.json
COPY --from=builder /app/apps/student-portal/.next ./.next
COPY --from=builder /app/apps/student-portal/public ./public
# 拷构建产物 + node_modules保留 workspace 目录结构以保持 pnpm 符号链接)
COPY --from=builder /app/node_modules ./node_modules
COPY --from=builder /app/apps/student-portal/next.config.js ./next.config.js
COPY --from=builder /app/apps/student-portal/package.json ./apps/student-portal/package.json
COPY --from=builder /app/apps/student-portal/.next ./apps/student-portal/.next
COPY --from=builder /app/apps/student-portal/node_modules ./apps/student-portal/node_modules
COPY --from=builder /app/apps/student-portal/next.config.js ./apps/student-portal/next.config.js
WORKDIR /app/apps/student-portal
USER nextjs
EXPOSE 4001

View File

@@ -0,0 +1,159 @@
# Student Portal — 下游依赖工作Next Steps
> 生成时间2026-07-13
> 生成者ai14student-portal
> 最近更新2026-07-13添加 Docker 测试验证结果)
## 0. Docker 构建验证(已完成)
student-portal Docker 镜像已在本地 Docker Desktop 验证通过:
- **镜像名**`edu/student-portal:test`
- **构建命令**`docker build -t edu/student-portal:test -f apps/student-portal/Dockerfile --build-arg NEXT_PUBLIC_API_MOCKING=disabled .`
- **端口**4001
- **MSW 状态**:生产构建默认 `disabled`(无 mock 数据)
- **健康检查**`GET /api/health` → 200
- **Dockerfile 改进**
- 添加 `ARG NEXT_PUBLIC_API_MOCKING=disabled` 构建参数(生产默认关闭 MSW
- 添加 `ARG NEXT_PUBLIC_MF_ENABLED=false` 构建参数(独立壳模式)
- 拷贝 `tsconfig.base.json`TypeScript 配置基线)
- 拷贝 `packages/` 下 5 个 workspace 共享包源码contracts/hooks/ui-components/ui-tokens/shared-ts
### Docker 测试结果
| 验证项 | 状态 |
|--------|------|
| `pnpm typecheck` | ✅ 0 errors |
| `pnpm lint` | ✅ 0 errors |
| `pnpm test` | ✅ 481/481 passed |
| `pnpm build`(本地) | ✅ 31 routes generated |
| `docker build` | ✅ 镜像构建成功 |
| `docker run` + healthcheck | ✅ /api/health 返回 200 |
### 无 mock 数据运行说明
生产镜像中 MSW 已禁用,所有 GraphQL 请求将走真实 API。在后端student-bff未就绪前
- 页面能正常渲染SSR + 客户端 hydration
- API 调用将失败并显示错误状态ActionState fail 信封)
- 健康检查端点 `/api/health` 正常工作
## 1. student-bff 后端实现ai04
student-portal 前端已实现 57 个 GraphQL 操作35 查询 + 22 变更),需要 student-bff 后端逐一实现对应 resolver。
### 必须实现的 GraphQL 操作清单
#### P3 核心(必须先完成)
| 操作 | 类型 | 说明 |
|------|------|------|
| currentUser | query | 当前学生信息 + 权限 + 视口 |
| myClasses | query | 我的班级列表 |
| myExams | query | 我的考试列表 |
| examDetail | query | 考试详情(含题目) |
| myHomework | query | 我的作业列表 |
| homeworkDetail | query | 作业详情 |
| myGrades | query | 我的成绩列表 |
| myAttendance | query | 我的考勤 |
| studentDashboard | query | 学生仪表盘聚合 |
| serverTime | query | 服务器时间(倒计时校正) |
| submitHomework | mutation | 提交作业 |
| submitExam | mutation | 提交考试(含幂等 key |
| saveExamDraft | mutation | 保存考试草稿 |
| recordExamViolation | mutation | 记录防作弊违规 |
| recordPasteEvent | mutation | 记录粘贴事件 |
#### P3 扩展
| mySchedule | query | 我的课表(周视图) |
| studentGrowth | query | 成长曲线vs 班级平均) |
| assignmentAnalysis | query | 作业分析 |
| myProfile | query | 个人资料 |
| updateProfile | mutation | 更新个人资料 |
| changePassword | mutation | 修改密码 |
| requestExtension | mutation | 作业延期申请 |
| joinClass | mutation | 加入班级 |
| leaveClass | mutation | 退出班级 |
#### P4 学习
| textbooks | query | 教材列表 |
| chapters | query | 章节目录 |
| learningPath | query | 学习路径 |
| myWeakness | query | 薄弱知识点 |
| myTrend | query | 学习趋势 |
| myMasterySummary | query | 掌握度概览 |
| myDiagnosticReports | query | 诊断报告 |
| myErrorBook | query | 错题本 |
| addErrorBookItem | mutation | 添加错题 |
| updateErrorBookItem | mutation | 更新错题 |
| deleteErrorBookItem | mutation | 删除错题 |
#### P5 校园生活
| myNotifications | query | 通知列表 |
| markAsRead | mutation | 标记已读 |
| markAllAsRead | mutation | 全部已读 |
| updateNotificationPreference | mutation | 更新通知偏好 |
| announcements | query | 公告列表 |
| announcementDetail | query | 公告详情 |
| markAnnouncementRead | mutation | 标记公告已读 |
| myLeaveRequests | query | 请假记录 |
| createLeaveRequest | mutation | 创建请假 |
| cancelLeaveRequest | mutation | 取消请假 |
| myElectiveSelections | query | 我的选课 |
| availableElectiveCourses | query | 可选课程 |
| selectElectiveCourse | mutation | 选课 |
| dropElectiveCourse | mutation | 退选 |
| myLessonPlans | query | 课案列表 |
| lessonPlanDetail | query | 课案详情 |
| myCoursePlans | query | 课程计划列表 |
| coursePlanDetail | query | 课程计划详情 |
| myReportCard | query | 成绩报告卡 |
| myPracticeSessions | query | 练习会话列表 |
| startPracticeSession | query | 启动练习 |
| submitPracticeAnswer | mutation | 提交练习答案 |
### 关键约束
- 所有 Query 不接受 `studentId` 参数,由 Resolver 从 JWT `x-user-id` 提取ARB-019 §21.7
- 所有响应包裹 ActionState 信封ok/fail/degraded 三态)
- 路径统一为 `POST /api/v1/student/graphql`ARB-019 §21
- 附件上传走 REST `POST /api/v1/student/upload`ARB-019 §21.5 方案 A
## 2. api-gateway 路由配置ai01
- `/api/v1/student/*``student-bff:3009/*`ARB-019 §21 已裁决)
- `/api/v1/student/upload` → 对象存储 + signed URL
## 3. push-gateway WebSocket 事件ai10
- `ExamExtended`教师延长考试ARB-019 §21.6 已确认)
- `ExamForceSubmitted`教师强制收卷ARB-019 §21.6 已确认)
- `ExamQuestionReordered`教师调整题目顺序P4 评估)
## 4. 共享 schema 文件ai04
- `packages/shared-ts/contracts/graphql/student-bff.graphql` 需创建ARB-019 §21 已裁决)
- 包含全部 57 个操作的 schema 定义
## 5. Module Federation Shell 集成ai02
- teacher-portal 作为 Shell 需注入 GraphQL client`@edu/hooks``useGraphQLClient`
- student-portal 通过 `tryLoadShellClient` 运行时探测Shell 未就绪时降级为独立 client
- 当前降级路径已就绪,但 Shell 注入尚未实现
## 6. 数据库 Schemaai04
student-bff 需要的数据库表(基于 Drizzle ORM
- users学生信息
- classes / class_members班级/成员)
- exams / exam_questions / exam_submissions考试/题目/提交)
- homework / homework_submissions作业/提交)
- grade_records成绩
- attendance_records考勤
- notifications通知
- announcements公告
- error_book_items错题本
- leave_requests请假
- elective_courses / elective_selections选课
- practice_sessions / practice_answers自适应练习
- lesson_plans课案
- course_plans课程计划
- schedule课表
- textbooks / textbook_chapters教材/章节)

View File

@@ -8,7 +8,10 @@
"build": "next build",
"start": "next start -p 4001",
"lint": "eslint src",
"typecheck": "tsc --noEmit"
"typecheck": "tsc --noEmit",
"test": "vitest run",
"test:watch": "vitest",
"test:coverage": "vitest run --coverage"
},
"dependencies": {
"@edu/contracts": "workspace:*",
@@ -37,16 +40,22 @@
"@eslint/js": "^9.0.0",
"@graphql-codegen/cli": "^5.0.0",
"@graphql-codegen/client-preset": "^4.5.0",
"@testing-library/jest-dom": "^6.5.0",
"@testing-library/react": "^16.0.0",
"@testing-library/user-event": "^14.5.0",
"@types/node": "^22.0.0",
"@types/react": "^18.3.0",
"@types/react-dom": "^18.3.0",
"@vitejs/plugin-react": "^4.3.0",
"autoprefixer": "^10.4.0",
"eslint": "^9.0.0",
"eslint-plugin-jsx-a11y": "^6.10.0",
"jsdom": "^25.0.0",
"mock-socket": "^9.3.1",
"msw": "^2.4.0",
"postcss": "^8.4.0",
"tailwindcss": "^3.4.0",
"typescript": "^5.6.0"
"typescript": "^5.6.0",
"vitest": "^2.1.0"
}
}

View File

@@ -0,0 +1,420 @@
"use client";
/**
* 课程详情页ai14P3
*
* 数据源useMyClasses按 id 筛选)+ useMySchedule班级课表
*
* 视图模块:
* - 教师信息卡:班主任姓名 + 学生人数
* - 学业信息卡:年级 + 学年
* - 教室信息:从课表中提取的去重教室列表
* - 班级课表:周视图(周一到周五,按时间段排列,复用 schedule 页样式)
*
* 设计依据纸感设计card-paper + rule-thin + mark-left + serif 标题)
*/
import { useMemo } from "react";
import Link from "next/link";
import { useParams } from "next/navigation";
import { useMyClasses, useMySchedule } from "@/lib/graphql/hooks";
import type { ScheduleItem } from "@/lib/graphql/types";
const DAY_LABELS: Record<number, string> = {
1: "周一",
2: "周二",
3: "周三",
4: "周四",
5: "周五",
6: "周六",
0: "周日",
};
const WEEKDAYS = [1, 2, 3, 4, 5];
/** 按天分组 */
function groupByDay(items: ScheduleItem[]): Map<number, ScheduleItem[]> {
const map = new Map<number, ScheduleItem[]>();
for (const item of items) {
const list = map.get(item.dayOfWeek) ?? [];
list.push(item);
map.set(item.dayOfWeek, list);
}
for (const list of map.values()) {
list.sort((a, b) => a.startTime.localeCompare(b.startTime));
}
return map;
}
export default function CourseDetailPage(): JSX.Element {
const params = useParams();
const classId = params["id"] as string;
const { data: classesData, fetching: clsFetching, error: clsError } = useMyClasses();
const { data: scheduleData, fetching: schedFetching, error: schedError } = useMySchedule();
const cls = useMemo(
() => classesData?.find((c) => c.id === classId) ?? null,
[classesData, classId],
);
const scheduleItems = scheduleData ?? [];
const grouped = useMemo(() => groupByDay(scheduleItems), [scheduleItems]);
// 提取去重教室列表
const classrooms = useMemo(() => {
const set = new Set<string>();
scheduleItems.forEach((s) => set.add(s.classroom));
return Array.from(set);
}, [scheduleItems]);
// 提取去重教师列表
const teachers = useMemo(() => {
const set = new Set<string>();
scheduleItems.forEach((s) => set.add(s.teacher));
return Array.from(set);
}, [scheduleItems]);
if (clsFetching) {
return (
<div className="px-6 lg:px-10 py-8 lg:py-10 max-w-5xl mx-auto">
<p
className="text-sm"
style={{ color: "var(--color-ink-muted)" }}
aria-live="polite"
>
</p>
</div>
);
}
if (clsError) {
return (
<div className="px-6 lg:px-10 py-8 lg:py-10 max-w-5xl mx-auto">
<div
role="alert"
className="card-paper p-6"
style={{ borderColor: "var(--color-danger)" }}
>
<p className="text-sm" style={{ color: "var(--color-danger)" }}>
{clsError.message}
</p>
<Link
href="/courses"
className="mt-4 inline-block btn-secondary text-xs"
>
</Link>
</div>
</div>
);
}
if (!cls) {
return (
<div className="px-6 lg:px-10 py-8 lg:py-10 max-w-5xl mx-auto">
<div className="card-paper p-8 text-center">
<p
className="text-base font-serif"
style={{ color: "var(--color-ink)" }}
>
</p>
<p
className="mt-2 text-sm"
style={{ color: "var(--color-ink-muted)" }}
>
退
</p>
<Link
href="/courses"
className="mt-4 inline-block btn-secondary text-xs"
>
</Link>
</div>
</div>
);
}
return (
<div className="px-6 lg:px-10 py-8 lg:py-10 max-w-5xl mx-auto">
<Link
href="/courses"
className="text-xs"
style={{ color: "var(--color-ink-muted)" }}
>
</Link>
<header className="mt-3 mb-6">
<p
className="text-xs uppercase tracking-wider"
style={{ color: "var(--color-ink-muted)" }}
>
{cls.grade} · {cls.year}
</p>
<h1
className="mt-2 text-3xl lg:text-4xl font-serif"
style={{ color: "var(--color-ink)" }}
>
{cls.name}
</h1>
</header>
<div className="rule-thin mb-8" />
{/* 信息卡片三宫格 */}
<section
aria-label="课程信息"
className="grid grid-cols-1 sm:grid-cols-3 gap-5 mb-8"
>
{/* 教师信息卡 */}
<article className="card-paper p-md">
<p
className="text-xs uppercase tracking-wider"
style={{ color: "var(--color-ink-muted)" }}
>
</p>
<div className="flex items-center gap-md mt-sm">
<span
className="inline-flex w-12 h-12 items-center justify-center rounded-full font-serif text-lg"
style={{
background: "var(--color-accent-muted)",
color: "var(--color-accent)",
}}
aria-hidden="true"
>
{cls.homeroomTeacher.slice(0, 1)}
</span>
<div>
<p
className="font-serif text-base"
style={{ color: "var(--color-ink)" }}
>
{cls.homeroomTeacher}
</p>
<p
className="text-xs"
style={{ color: "var(--color-ink-muted)" }}
>
{cls.studentCount}
</p>
</div>
</div>
</article>
{/* 学业信息卡 */}
<article className="card-paper p-md">
<p
className="text-xs uppercase tracking-wider"
style={{ color: "var(--color-ink-muted)" }}
>
</p>
<dl className="mt-sm space-y-xs text-sm">
<div className="flex items-baseline justify-between">
<dt
className="text-xs"
style={{ color: "var(--color-ink-muted)" }}
>
</dt>
<dd style={{ color: "var(--color-ink)" }}>{cls.grade}</dd>
</div>
<div className="flex items-baseline justify-between">
<dt
className="text-xs"
style={{ color: "var(--color-ink-muted)" }}
>
</dt>
<dd style={{ color: "var(--color-ink)" }}>{cls.year}</dd>
</div>
<div className="flex items-baseline justify-between">
<dt
className="text-xs"
style={{ color: "var(--color-ink-muted)" }}
>
</dt>
<dd style={{ color: "var(--color-ink)" }}>
{cls.studentCount}
</dd>
</div>
</dl>
</article>
{/* 教室信息卡 */}
<article className="card-paper p-md">
<p
className="text-xs uppercase tracking-wider"
style={{ color: "var(--color-ink-muted)" }}
>
</p>
{classrooms.length === 0 ? (
<p
className="mt-sm text-sm"
style={{ color: "var(--color-ink-subtle)" }}
>
</p>
) : (
<ul className="mt-sm space-y-xs">
{classrooms.map((cr) => (
<li
key={cr}
className="flex items-center gap-sm text-sm"
style={{ color: "var(--color-ink)" }}
>
<span
className="inline-block w-1.5 h-1.5 rounded-full"
style={{ background: "var(--color-accent)" }}
aria-hidden="true"
/>
{cr}
</li>
))}
</ul>
)}
{teachers.length > 0 ? (
<p
className="mt-sm text-xs"
style={{ color: "var(--color-ink-subtle)" }}
>
{teachers.join("、")}
</p>
) : null}
</article>
</section>
{/* 班级课表 */}
<section aria-label="班级课表" className="card-paper p-md lg:p-lg">
<div className="flex items-baseline justify-between mb-md">
<h2
className="text-lg font-serif"
style={{ color: "var(--color-ink)" }}
>
</h2>
<Link
href="/schedule"
className="text-xs hover:underline"
style={{ color: "var(--color-accent)" }}
>
</Link>
</div>
<div className="rule-thin mb-md" />
{schedFetching ? (
<p
className="text-sm py-lg text-center"
style={{ color: "var(--color-ink-muted)" }}
aria-live="polite"
>
</p>
) : schedError ? (
<div className="mark-left py-md px-md" aria-live="assertive">
<p className="text-sm" style={{ color: "var(--color-ink)" }}>
{schedError.message || "课表加载失败"}
</p>
</div>
) : scheduleItems.length === 0 ? (
<p
className="text-sm py-lg text-center"
style={{ color: "var(--color-ink-muted)" }}
role="status"
>
</p>
) : (
<WeeklyScheduleGrid grouped={grouped} />
)}
</section>
</div>
);
}
/** 周课表网格5 列) */
function WeeklyScheduleGrid({
grouped,
}: {
grouped: Map<number, ScheduleItem[]>;
}): JSX.Element {
return (
<div
className="hidden sm:grid grid-cols-5 gap-sm"
role="grid"
aria-label="周课表"
>
{WEEKDAYS.map((day) => {
const dayItems = grouped.get(day) ?? [];
return (
<div key={day} role="row" className="flex flex-col">
<div
className="mb-sm pb-xs text-center border-b"
style={{ borderColor: "var(--color-rule)" }}
>
<span
className="font-serif text-sm"
style={{ color: "var(--color-ink)" }}
>
{DAY_LABELS[day]}
</span>
</div>
<div className="space-y-xs flex-1">
{dayItems.map((item) => (
<ScheduleCard key={item.id} item={item} />
))}
{dayItems.length === 0 && (
<p
className="text-xs py-md text-center"
style={{ color: "var(--color-ink-subtle)" }}
>
</p>
)}
</div>
</div>
);
})}
</div>
);
}
/** 单节课卡片 */
function ScheduleCard({ item }: { item: ScheduleItem }): JSX.Element {
return (
<div
className="card-paper p-sm"
role="gridcell"
style={{ borderLeft: "2px solid var(--color-accent)" }}
>
<div className="flex items-baseline justify-between mb-xs">
<span
className="font-mono text-xs"
style={{ color: "var(--color-ink-muted)" }}
>
{item.startTime}{item.endTime}
</span>
</div>
<p
className="font-serif text-sm"
style={{ color: "var(--color-ink)" }}
>
{item.subject}
</p>
<p
className="text-xs mt-xs"
style={{ color: "var(--color-ink-muted)" }}
>
{item.teacher} · {item.classroom}
</p>
</div>
);
}

View File

@@ -0,0 +1,327 @@
"use client";
/**
* 我的课程列表页ai14P3
*
* 数据源useMyClasses + JoinClassModal 组件
* 视图:课程卡片网格(班级名 + 年级 + 班主任 + 学生数 + 学年)
* 功能:
* - 搜索筛选(按班级名/年级/班主任 客户端过滤)
* - 邀请码加入班级(复用 JoinClassModal
* - 点击卡片进入详情 /courses/[id]
*
* 与 /my-classes 的区别:
* - /my-classes 偏班级管理(含退出班级操作)
* - /courses 偏学习入口(仅查看 + 加入,引导进入课程详情)
*
* 设计依据纸感设计card-paper + mark-left + serif 标题)
*/
import { useState, useCallback, useMemo } from "react";
import Link from "next/link";
import { useMyClasses } from "@/lib/graphql/hooks";
import type { ClassInfo } from "@/lib/graphql/types";
import { JoinClassModal } from "@/components/join-class-modal";
export default function CoursesPage(): JSX.Element {
const { data, fetching, error, reexecute } = useMyClasses();
const classes = data ?? [];
const [modalOpen, setModalOpen] = useState(false);
const [searchTerm, setSearchTerm] = useState("");
const [feedback, setFeedback] = useState<
{ type: "success" | "error"; message: string } | null
>(null);
const handleOpenModal = useCallback((): void => {
setModalOpen(true);
}, []);
const handleCloseModal = useCallback((): void => {
setModalOpen(false);
}, []);
const handleJoined = useCallback(
(_className: string): void => {
setFeedback({ type: "success", message: "加入班级成功" });
reexecute();
},
[reexecute],
);
// 客户端搜索过滤(按班级名 / 年级 / 班主任)
const filteredClasses = useMemo(() => {
if (!searchTerm.trim()) return classes;
const term = searchTerm.trim().toLowerCase();
return classes.filter(
(c) =>
c.name.toLowerCase().includes(term) ||
c.grade.toLowerCase().includes(term) ||
c.homeroomTeacher.toLowerCase().includes(term),
);
}, [classes, searchTerm]);
return (
<div className="px-6 lg:px-10 py-8 lg:py-10 max-w-7xl mx-auto">
<header className="mb-6">
<div className="flex items-start justify-between gap-md">
<div className="min-w-0 flex-1">
<p
className="text-xs uppercase tracking-wider"
style={{ color: "var(--color-ink-muted)" }}
>
</p>
<h1
className="mt-2 text-3xl lg:text-4xl font-serif"
style={{ color: "var(--color-ink)" }}
>
</h1>
<p
className="mt-2 text-sm"
style={{ color: "var(--color-ink-muted)" }}
>
</p>
</div>
<button
type="button"
onClick={handleOpenModal}
className="btn-primary flex-shrink-0"
>
</button>
</div>
</header>
<div className="rule-thin mb-6" />
{/* 搜索框 */}
<div className="mb-6">
<label htmlFor="course-search" className="sr-only">
</label>
<input
id="course-search"
type="search"
value={searchTerm}
onChange={(e) => setSearchTerm(e.target.value)}
placeholder="搜索班级名、年级或班主任…"
className="w-full max-w-md px-md py-sm text-sm rounded-sm"
style={{
background: "var(--color-paper)",
color: "var(--color-ink)",
border: "1px solid var(--color-rule)",
}}
/>
</div>
{feedback && (
<div
className="mb-md p-sm rounded text-sm"
role="alert"
style={{
color:
feedback.type === "success"
? "var(--color-success)"
: "var(--color-danger)",
background:
feedback.type === "success"
? "hsl(var(--hue-success), var(--sat-success), 95%)"
: "hsl(var(--hue-danger), var(--sat-danger), 95%)",
}}
>
{feedback.message}
</div>
)}
{fetching ? (
<LoadingSkeleton />
) : error ? (
<ErrorState
message={error.message}
onRetry={() => reexecute()}
/>
) : classes.length === 0 ? (
<EmptyState onJoin={handleOpenModal} />
) : filteredClasses.length === 0 ? (
<p
className="text-sm py-lg text-center"
style={{ color: "var(--color-ink-muted)" }}
role="status"
>
{searchTerm}
</p>
) : (
<section
aria-label="课程列表"
className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-5"
>
{filteredClasses.map((cls) => (
<CourseCard key={cls.id} cls={cls} />
))}
</section>
)}
<JoinClassModal
open={modalOpen}
onClose={handleCloseModal}
onJoined={handleJoined}
/>
</div>
);
}
/** 课程卡片 */
function CourseCard({ cls }: { cls: ClassInfo }): JSX.Element {
return (
<Link
href={`/courses/${cls.id}`}
className="card-paper p-6 flex flex-col group transition hover:shadow-card"
aria-label={`查看课程 ${cls.name} 详情`}
>
<div className="flex items-start justify-between gap-3">
<div className="min-w-0 flex-1">
<p
className="text-xs uppercase tracking-wider"
style={{ color: "var(--color-ink-muted)" }}
>
{cls.grade} · {cls.year}
</p>
<h2
className="mt-2 text-2xl font-serif text-truncate group-hover:underline"
style={{ color: "var(--color-ink)" }}
>
{cls.name}
</h2>
</div>
<span
className="badge badge-info flex-shrink-0"
aria-label={`${cls.studentCount} 名学生`}
>
{cls.studentCount}
</span>
</div>
<div className="rule-thin my-4" />
<dl className="space-y-2 text-sm">
<div className="flex items-baseline justify-between">
<dt
className="text-xs uppercase tracking-wider"
style={{ color: "var(--color-ink-muted)" }}
>
</dt>
<dd style={{ color: "var(--color-ink)" }}>
{cls.homeroomTeacher}
</dd>
</div>
</dl>
<div className="mt-auto pt-4">
<p
className="text-xs group-hover:translate-x-1 transition-transform"
style={{ color: "var(--color-accent)" }}
>
</p>
</div>
</Link>
);
}
function LoadingSkeleton(): JSX.Element {
return (
<div
aria-busy="true"
aria-live="polite"
className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-5"
>
{[0, 1, 2].map((i) => (
<div key={i} className="card-paper p-6" aria-hidden="true">
<div
className="h-3 w-20 rounded-sm animate-pulse"
style={{ background: "var(--color-rule)" }}
/>
<div
className="mt-3 h-7 w-32 rounded-sm animate-pulse"
style={{ background: "var(--color-rule)" }}
/>
<div
className="mt-6 h-px w-full"
style={{ background: "var(--color-rule)" }}
/>
<div
className="mt-4 h-4 w-24 rounded-sm animate-pulse"
style={{ background: "var(--color-rule)" }}
/>
</div>
))}
<span className="sr-only">...</span>
</div>
);
}
function ErrorState({
message,
onRetry,
}: {
message: string;
onRetry: () => void;
}): JSX.Element {
return (
<div
role="alert"
className="card-paper p-8 text-center"
style={{ borderColor: "var(--color-danger)" }}
>
<p
className="text-base font-serif"
style={{ color: "var(--color-danger)" }}
>
</p>
<p className="mt-2 text-sm" style={{ color: "var(--color-ink-muted)" }}>
{message}
</p>
<button
type="button"
onClick={onRetry}
className="mt-4 btn-secondary text-xs"
>
</button>
</div>
);
}
function EmptyState({ onJoin }: { onJoin: () => void }): JSX.Element {
return (
<div className="card-paper p-8 text-center">
<p
className="text-4xl font-serif"
style={{ color: "var(--color-ink-subtle)" }}
aria-hidden="true"
>
</p>
<p
className="mt-4 text-base font-serif"
style={{ color: "var(--color-ink)" }}
>
</p>
<p className="mt-2 text-sm" style={{ color: "var(--color-ink-muted)" }}>
使
</p>
<button
type="button"
onClick={onJoin}
className="mt-4 btn-primary"
>
</button>
</div>
);
}

View File

@@ -0,0 +1,196 @@
"use client";
/**
* 学习中心入口页ai14P3
*
* 聚合 3 个学习场景入口,每张卡片显示数量统计:
* - 我的课程 → /courses消费 useMyClasses 取班级数量)
* - 我的作业 → /my-homework消费 useMyHomework 取待提交作业数)
* - 教材 → /textbooks消费 useTextbooks 取教材数量)
*
* 设计依据纸感设计card-paper + mark-left + serif 标题)
* 与 Dashboard 区分Dashboard 是数据概览,本页是场景入口
*/
import Link from "next/link";
import {
useMyClasses,
useMyHomework,
useTextbooks,
} from "@/lib/graphql/hooks";
interface EntryCardProps {
href: string;
label: string;
description: string;
count: number | null;
countLabel: string;
accent: boolean;
symbol: string;
}
function EntryCard({
href,
label,
description,
count,
countLabel,
accent,
symbol,
}: EntryCardProps): JSX.Element {
return (
<Link
href={href}
className="card-paper p-lg flex flex-col group transition hover:shadow-card"
aria-label={`进入${label}`}
>
<div className="flex items-start justify-between mb-md">
<span
className="inline-flex w-12 h-12 items-center justify-center rounded-sm font-serif text-2xl"
style={{
background: accent
? "var(--color-accent-muted)"
: "var(--color-rule)",
color: accent ? "var(--color-accent)" : "var(--color-ink-muted)",
}}
aria-hidden="true"
>
{symbol}
</span>
<span
className="text-xs uppercase tracking-wider group-hover:translate-x-1 transition-transform"
style={{ color: "var(--color-accent)" }}
aria-hidden="true"
>
</span>
</div>
<p
className="text-xs uppercase tracking-wider"
style={{ color: "var(--color-ink-muted)" }}
>
{countLabel}
</p>
<h2
className="mt-xs text-2xl lg:text-3xl font-serif"
style={{ color: "var(--color-ink)" }}
>
{label}
</h2>
<div className="rule-thin my-md" />
<p
className="text-sm flex-1"
style={{ color: "var(--color-ink-muted)" }}
>
{description}
</p>
<p
className="mt-md text-3xl font-serif"
style={{
color: accent ? "var(--color-accent)" : "var(--color-ink)",
}}
aria-live="polite"
>
{count === null ? (
<span
className="inline-block w-8 h-6 rounded-sm animate-pulse"
style={{ background: "var(--color-rule)" }}
aria-label="加载中"
/>
) : (
count
)}
</p>
</Link>
);
}
export default function LearningCenterPage(): JSX.Element {
const {
data: classesData,
fetching: classesFetching,
error: classesError,
} = useMyClasses();
const {
data: homeworkData,
fetching: homeworkFetching,
error: homeworkError,
} = useMyHomework();
const {
data: textbooksData,
fetching: textbooksFetching,
error: textbooksError,
} = useTextbooks();
const classCount = classesError ? null : classesFetching ? null : (classesData?.length ?? 0);
const pendingHomeworkCount = homeworkError
? null
: homeworkFetching
? null
: (homeworkData?.filter((h) => h.status === "pending" || h.status === "overdue").length ?? 0);
const textbookCount = textbooksError ? null : textbooksFetching ? null : (textbooksData?.length ?? 0);
return (
<div className="px-6 lg:px-10 py-8 lg:py-10 max-w-7xl mx-auto">
<header className="mb-6">
<p
className="text-xs uppercase tracking-wider"
style={{ color: "var(--color-ink-muted)" }}
>
</p>
<h1
className="mt-2 text-3xl lg:text-4xl font-serif"
style={{ color: "var(--color-ink)" }}
>
</h1>
<p
className="mt-2 text-sm"
style={{ color: "var(--color-ink-muted)" }}
>
</p>
</header>
<div className="rule-thin mb-8" />
<section
aria-label="学习入口"
className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-5"
>
<EntryCard
href="/courses"
label="我的课程"
description="查看你所在的班级、班主任、同学与课表。"
count={classCount}
countLabel="个班级"
accent
symbol="✦"
/>
<EntryCard
href="/my-homework"
label="我的作业"
description="按状态查看待提交、已提交与已批改的作业。"
count={pendingHomeworkCount}
countLabel="待办"
accent={false}
symbol="✎"
/>
<EntryCard
href="/textbooks"
label="教材"
description="按科目浏览教材与章节目录。"
count={textbookCount}
countLabel="本"
accent={false}
symbol="▤"
/>
</section>
</div>
);
}

View File

@@ -0,0 +1,544 @@
"use client";
/**
* 消息中心页面ai14P5
*
* 与 /notifications 的区别:
* - /notifications 偏系统通知(卡片列表 + 类型徽标 + 单独标记按钮)
* - /messages 偏师生互动消息(对话式列表 + 头像首字 + 展开详情 + 全部/未读筛选 Tab
*
* - 消费 useMyNotifications 查询first 控制分页大小)
* - 全部 / 未读 筛选 Tab客户端过滤
* - 消息项以"发送方"视角展示:类型首字头像 + 标题 + 时间 + 已读未读圆点
* - 点击展开详情(手风琴式)
* - 单条标记已读useMarkAsRead+ 全部标记已读useMarkAllAsRead
* - WebSocket 实时更新(复用通知中心的 NOTIFICATION_UPDATED_EVENT
* - 加载更多:增大 first 参数
*/
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import {
useMarkAllAsRead,
useMarkAsRead,
useMyNotifications,
} from "@/lib/graphql/hooks";
import type { NotificationItem, NotificationType } from "@/lib/graphql/types";
import {
NOTIFICATION_UPDATED_EVENT,
useNotificationsWebSocket,
} from "@/hooks/use-notifications-websocket";
const PAGE_SIZE = 20;
interface ToastState {
message: string;
tone: "success" | "error";
}
type FilterTab = "all" | "unread";
/** 类型 → "发送方" 角色名(消息中心视角) */
const SENDER_LABEL: Record<NotificationType, string> = {
homework: "学科老师",
exam: "教务老师",
grade: "教务老师",
system: "系统通知",
};
/** 类型 → 头像首字 */
const SENDER_INITIAL: Record<NotificationType, string> = {
homework: "师",
exam: "教",
grade: "教",
system: "系",
};
/** 类型 → 头像背景色(语义令牌) */
function senderColor(type: NotificationType): string {
switch (type) {
case "grade":
return "var(--color-success)";
case "exam":
return "var(--color-warning)";
case "homework":
return "var(--color-accent)";
case "system":
return "var(--color-ink-muted)";
}
}
/** 相对时间格式化("刚刚"/"3 分钟前"/"昨天"等) */
function formatRelativeTime(iso: string): string {
try {
const now = Date.now();
const t = new Date(iso).getTime();
const diff = Math.max(0, now - t);
const minutes = Math.floor(diff / 60000);
if (minutes < 1) return "刚刚";
if (minutes < 60) return `${minutes} 分钟前`;
const hours = Math.floor(minutes / 60);
if (hours < 24) return `${hours} 小时前`;
const days = Math.floor(hours / 24);
if (days < 7) return `${days} 天前`;
return new Date(iso).toLocaleDateString("zh-CN");
} catch {
return iso;
}
}
/** 完整时间格式化(展开详情时显示) */
function formatFullTime(iso: string): string {
try {
return new Date(iso).toLocaleString("zh-CN", { hour12: false });
} catch {
return iso;
}
}
export default function MessagesPage(): React.ReactNode {
const { isConnected } = useNotificationsWebSocket();
const [pageSize, setPageSize] = useState<number>(PAGE_SIZE);
const [activeTab, setActiveTab] = useState<FilterTab>("all");
const [expandedId, setExpandedId] = useState<string | null>(null);
const [toast, setToast] = useState<ToastState | null>(null);
const { data, fetching, error, reexecute } = useMyNotifications(pageSize);
const allItems: NotificationItem[] = data?.items ?? [];
const totalCount = data?.totalCount ?? 0;
const unreadCount = data?.unreadCount ?? 0;
const markReadMutation = useMarkAsRead();
const markAllMutation = useMarkAllAsRead();
const markingReadRef = useRef<string | null>(null);
const markingAllRef = useRef(false);
const showToast = useCallback(
(message: string, tone: ToastState["tone"]): void => {
setToast({ message, tone });
window.setTimeout(() => setToast(null), 2500);
},
[],
);
// WebSocket 更新事件:重新查询
useEffect(() => {
const handler = (): void => {
reexecute();
};
window.addEventListener(NOTIFICATION_UPDATED_EVENT, handler);
return () => {
window.removeEventListener(NOTIFICATION_UPDATED_EVENT, handler);
};
}, [reexecute]);
// 监听标记已读 mutation 结果
useEffect(() => {
if (!markingReadRef.current) return;
if (markReadMutation.fetching) return;
markingReadRef.current = null;
if (markReadMutation.success) {
showToast("已标记为已读", "success");
reexecute();
} else {
const message =
markReadMutation.errors[0]?.message ??
markReadMutation.error?.message ??
"标记失败,请重试";
showToast(message, "error");
}
}, [
markReadMutation.fetching,
markReadMutation.success,
markReadMutation.error,
markReadMutation.errors,
reexecute,
showToast,
]);
// 监听批量标记已读 mutation 结果
useEffect(() => {
if (!markingAllRef.current) return;
if (markAllMutation.fetching) return;
markingAllRef.current = false;
if (markAllMutation.success) {
showToast("已全部标记为已读", "success");
reexecute();
} else {
const message =
markAllMutation.errors[0]?.message ??
markAllMutation.error?.message ??
"操作失败,请重试";
showToast(message, "error");
}
}, [
markAllMutation.fetching,
markAllMutation.success,
markAllMutation.error,
markAllMutation.errors,
reexecute,
showToast,
]);
const handleMarkRead = useCallback(
(id: string): void => {
markingReadRef.current = id;
markReadMutation.execute({ notificationId: id });
},
[markReadMutation],
);
const handleMarkAllRead = useCallback((): void => {
markingAllRef.current = true;
markAllMutation.execute({});
}, [markAllMutation]);
const handleLoadMore = useCallback((): void => {
setPageSize((prev) => prev + PAGE_SIZE);
}, []);
const handleToggleExpand = useCallback((id: string): void => {
setExpandedId((prev) => (prev === id ? null : id));
}, []);
// 客户端过滤(全部 / 未读)
const filteredItems = useMemo(() => {
if (activeTab === "unread") {
return allItems.filter((it) => !it.read);
}
return allItems;
}, [allItems, activeTab]);
const hasMore = allItems.length < totalCount;
return (
<main
className="px-md py-lg max-w-3xl mx-auto"
aria-labelledby="messages-title"
>
<header className="mb-lg">
<div className="flex items-center justify-between gap-md">
<div className="min-w-0 flex-1">
<h1
id="messages-title"
className="font-serif text-2xl text-ink"
>
</h1>
<p className="mt-xs text-sm text-ink-muted">
</p>
</div>
<button
type="button"
className="btn-secondary"
onClick={handleMarkAllRead}
disabled={unreadCount === 0 || markAllMutation.fetching}
aria-label="全部标记已读"
>
{unreadCount > 0 ? `${unreadCount}` : ""}
</button>
</div>
<p
className="mt-sm text-xs"
style={{
color: isConnected
? "var(--color-success)"
: "var(--color-warning)",
}}
role="status"
aria-live="polite"
>
{isConnected ? "● 实时连接已建立" : "○ 实时连接已断开,正在重连…"}
</p>
</header>
{/* 筛选 Tab */}
<div
className="flex gap-xs mb-lg"
role="tablist"
aria-label="消息筛选"
>
<FilterTabButton
active={activeTab === "all"}
onClick={() => setActiveTab("all")}
label="全部"
count={totalCount}
/>
<FilterTabButton
active={activeTab === "unread"}
onClick={() => setActiveTab("unread")}
label="未读"
count={unreadCount}
/>
</div>
<div className="rule-thin mb-lg" />
{fetching && allItems.length === 0 ? (
<p className="text-sm text-ink-muted" aria-live="polite">
</p>
) : error ? (
<div className="mark-left py-md px-md mb-md" aria-live="assertive">
<p className="text-sm text-ink">{error.message || "加载失败"}</p>
<button
type="button"
className="btn-secondary mt-sm"
onClick={() => reexecute()}
>
</button>
</div>
) : filteredItems.length === 0 ? (
<p className="text-sm text-ink-muted" role="status">
{activeTab === "unread" ? "暂无未读消息" : "暂无消息"}
</p>
) : (
<ul className="space-y-sm" aria-label="消息列表">
{filteredItems.map((item) => (
<MessageRow
key={item.id}
item={item}
expanded={expandedId === item.id}
onToggle={() => handleToggleExpand(item.id)}
onMarkRead={() => handleMarkRead(item.id)}
markingRead={
markingReadRef.current === item.id && markReadMutation.fetching
}
/>
))}
</ul>
)}
{hasMore && activeTab === "all" ? (
<div className="mt-lg text-center">
<button
type="button"
className="btn-secondary"
onClick={handleLoadMore}
disabled={fetching}
>
{fetching ? "加载中…" : "加载更多"}
</button>
</div>
) : filteredItems.length > 0 && activeTab === "all" ? (
<p className="mt-lg text-center text-xs text-ink-subtle">
{totalCount}
</p>
) : null}
{toast ? (
<div
className="fixed bottom-md left-1/2 -translate-x-1/2 card-paper px-lg py-md text-sm"
role="alert"
aria-live="assertive"
style={{
color:
toast.tone === "success"
? "var(--color-success)"
: "var(--color-danger)",
}}
>
{toast.message}
</div>
) : null}
</main>
);
}
/** 筛选 Tab 按钮 */
function FilterTabButton({
active,
onClick,
label,
count,
}: {
active: boolean;
onClick: () => void;
label: string;
count: number;
}): JSX.Element {
return (
<button
type="button"
role="tab"
aria-selected={active}
onClick={onClick}
className="px-md py-sm text-sm rounded-sm transition-colors motion-safe"
style={{
background: active ? "var(--color-accent-muted)" : "transparent",
color: active ? "var(--color-accent)" : "var(--color-ink-muted)",
borderBottom: active
? "2px solid var(--color-accent)"
: "2px solid transparent",
fontFamily: active ? "var(--font-serif)" : "var(--font-sans)",
}}
>
{label}
{count > 0 ? (
<span
className="ml-xs text-xs"
aria-label={`${label} ${count}`}
>
({count})
</span>
) : null}
</button>
);
}
/** 单条消息行(对话式) */
function MessageRow({
item,
expanded,
onToggle,
onMarkRead,
markingRead,
}: {
item: NotificationItem;
expanded: boolean;
onToggle: () => void;
onMarkRead: () => void;
markingRead: boolean;
}): JSX.Element {
const initial = SENDER_INITIAL[item.type];
const sender = SENDER_LABEL[item.type];
const color = senderColor(item.type);
return (
<li>
<article
className="card-paper overflow-hidden"
aria-current={item.read ? "false" : "true"}
aria-label={`${sender}${item.title}`}
>
<button
type="button"
onClick={onToggle}
className="w-full text-left p-md flex items-start gap-md"
aria-expanded={expanded}
aria-controls={`msg-detail-${item.id}`}
>
{/* 头像(类型首字) */}
<span
className="inline-flex w-10 h-10 flex-shrink-0 items-center justify-center rounded-full font-serif text-sm"
style={{ background: color, color: "var(--color-paper)" }}
aria-hidden="true"
>
{initial}
</span>
{/* 主体内容 */}
<div className="min-w-0 flex-1">
<div className="flex items-baseline justify-between gap-sm">
<p
className="text-xs"
style={{ color: "var(--color-ink-muted)" }}
>
{sender}
</p>
<p
className="text-xs font-mono flex-shrink-0"
style={{ color: "var(--color-ink-subtle)" }}
>
{formatRelativeTime(item.createdAt)}
</p>
</div>
<p
className="mt-xs text-sm font-medium text-truncate"
style={{
color: item.read ? "var(--color-ink-muted)" : "var(--color-ink)",
fontFamily: item.read
? "var(--font-sans)"
: "var(--font-serif)",
}}
>
{item.title}
</p>
{!expanded && item.content ? (
<p
className="mt-xs text-xs text-clamp-1"
style={{ color: "var(--color-ink-subtle)" }}
>
{item.content}
</p>
) : null}
</div>
{/* 未读圆点 */}
{!item.read ? (
<span
className="mt-xs inline-block w-2 h-2 rounded-full flex-shrink-0"
style={{ background: "var(--color-accent)" }}
aria-label="未读"
/>
) : null}
</button>
{/* 展开详情 */}
{expanded ? (
<div
id={`msg-detail-${item.id}`}
className="px-md pb-md pt-0"
role="region"
aria-label="消息详情"
>
<div
className="ml-12 pl-md py-sm"
style={{
borderLeft: "2px solid var(--color-rule)",
}}
>
{item.content ? (
<p
className="text-sm"
style={{ color: "var(--color-ink)" }}
>
{item.content}
</p>
) : (
<p
className="text-sm"
style={{ color: "var(--color-ink-subtle)" }}
>
</p>
)}
<p
className="mt-sm text-xs font-mono"
style={{ color: "var(--color-ink-subtle)" }}
>
{formatFullTime(item.createdAt)}
</p>
{!item.read ? (
<button
type="button"
className="btn-secondary mt-sm text-xs"
onClick={onMarkRead}
disabled={markingRead}
aria-label={`标记已读:${item.title}`}
>
{markingRead ? "标记中…" : "标记已读"}
</button>
) : (
<span
className="inline-block mt-sm text-xs"
style={{ color: "var(--color-ink-subtle)" }}
>
</span>
)}
</div>
</div>
) : null}
</article>
</li>
);
}

View File

@@ -4,7 +4,7 @@
* AppShell — 学生端应用壳ai14
*
* 职责:
* - 左侧导航栏:学生端 13 项菜单(含 P3-P5 阶段)
* - 左侧导航栏:学生端 23 项菜单(含 P3-P5 阶段)
* - 顶栏:学生姓名 + 头像 + 退出登录
* - 主内容区:渲染 children
* - 响应式:移动端 (<lg) 折叠为 hamburger 抽屉
@@ -25,6 +25,8 @@ interface NavItem {
const NAV_ITEMS: NavItem[] = [
{ label: "Dashboard", route: "/dashboard" },
{ label: "学习中心", route: "/learning" },
{ label: "我的课程", route: "/courses" },
{ label: "我的班级", route: "/my-classes" },
{ label: "我的课表", route: "/schedule" },
{ label: "选课中心", route: "/elective" },
@@ -42,6 +44,7 @@ const NAV_ITEMS: NavItem[] = [
{ label: "教材", route: "/textbooks" },
{ label: "公告", route: "/announcements" },
{ label: "通知中心", route: "/notifications" },
{ label: "消息中心", route: "/messages" },
{ label: "AI辅学", route: "/ai-tutor" },
{ label: "系统设置", route: "/settings" },
];

View File

@@ -0,0 +1,316 @@
/**
* CountdownTimer 组件测试
*
* 测试倒计时组件:
* - 正常倒计时显示
* - 最后 5 分钟 warning 状态
* - 最后 1 分钟 critical 状态
* - 归零触发 onTimeUp
*/
import { describe, it, expect, beforeEach, afterEach, vi } from "vitest";
import { render, screen, act } from "@testing-library/react";
import { CountdownTimer } from "./countdown-timer";
describe("CountdownTimer", () => {
beforeEach(() => {
vi.useFakeTimers();
});
afterEach(() => {
vi.useRealTimers();
});
describe("渲染", () => {
it("应渲染 role=timer 元素", () => {
const now = Date.now();
const endTime = new Date(now + 3600 * 1000).toISOString();
render(
<CountdownTimer endTime={endTime} onTimeUp={() => {}} serverTimeOffset={0} />,
);
expect(screen.getByRole("timer")).toBeInTheDocument();
});
it("应显示 HH:MM:SS 格式", () => {
const now = Date.now();
const endTime = new Date(now + 3600 * 1000).toISOString(); // 1 小时后
render(
<CountdownTimer endTime={endTime} onTimeUp={() => {}} serverTimeOffset={0} />,
);
// 1 小时 = 01:00:00
expect(screen.getByRole("timer")).toHaveTextContent("01:00:00");
});
it("应显示 aria-live=polite", () => {
const now = Date.now();
const endTime = new Date(now + 3600 * 1000).toISOString();
render(
<CountdownTimer endTime={endTime} onTimeUp={() => {}} serverTimeOffset={0} />,
);
expect(screen.getByRole("timer")).toHaveAttribute("aria-live", "polite");
});
it("aria-label 应包含剩余时间", () => {
const now = Date.now();
const endTime = new Date(now + 3600 * 1000).toISOString();
render(
<CountdownTimer endTime={endTime} onTimeUp={() => {}} serverTimeOffset={0} />,
);
expect(screen.getByRole("timer")).toHaveAttribute(
"aria-label",
expect.stringContaining("01:00:00"),
);
});
});
describe("倒计时显示", () => {
it("1 小时倒计时应显示 01:00:00", () => {
const now = Date.now();
const endTime = new Date(now + 3600 * 1000).toISOString();
render(
<CountdownTimer endTime={endTime} onTimeUp={() => {}} serverTimeOffset={0} />,
);
expect(screen.getByRole("timer")).toHaveTextContent("01:00:00");
});
it("90 分钟倒计时应显示 01:30:00", () => {
const now = Date.now();
const endTime = new Date(now + 90 * 60 * 1000).toISOString();
render(
<CountdownTimer endTime={endTime} onTimeUp={() => {}} serverTimeOffset={0} />,
);
expect(screen.getByRole("timer")).toHaveTextContent("01:30:00");
});
it("10 秒倒计时应显示 00:00:10", () => {
const now = Date.now();
const endTime = new Date(now + 10 * 1000).toISOString();
render(
<CountdownTimer endTime={endTime} onTimeUp={() => {}} serverTimeOffset={0} />,
);
expect(screen.getByRole("timer")).toHaveTextContent("00:00:10");
});
it("每秒应更新倒计时", () => {
const now = Date.now();
const endTime = new Date(now + 10 * 1000).toISOString();
render(
<CountdownTimer endTime={endTime} onTimeUp={() => {}} serverTimeOffset={0} />,
);
expect(screen.getByRole("timer")).toHaveTextContent("00:00:10");
act(() => {
vi.advanceTimersByTime(1000);
});
expect(screen.getByRole("timer")).toHaveTextContent("00:00:09");
act(() => {
vi.advanceTimersByTime(1000);
});
expect(screen.getByRole("timer")).toHaveTextContent("00:00:08");
});
});
describe("warning 状态(最后 5 分钟)", () => {
it("剩余 ≤300 秒且 >60 秒应显示 warning 样式", () => {
const now = Date.now();
// 4 分钟 = 240 秒(在 60~300 区间)
const endTime = new Date(now + 4 * 60 * 1000).toISOString();
render(
<CountdownTimer endTime={endTime} onTimeUp={() => {}} serverTimeOffset={0} />,
);
const timer = screen.getByRole("timer");
expect(timer.className).toContain("text-warning");
expect(timer.className).not.toContain("text-danger");
});
it("剩余 300 秒5 分钟整)应显示 warning", () => {
const now = Date.now();
const endTime = new Date(now + 300 * 1000).toISOString();
render(
<CountdownTimer endTime={endTime} onTimeUp={() => {}} serverTimeOffset={0} />,
);
const timer = screen.getByRole("timer");
expect(timer.className).toContain("text-warning");
});
it("剩余 301 秒应不显示 warning", () => {
const now = Date.now();
const endTime = new Date(now + 301 * 1000).toISOString();
render(
<CountdownTimer endTime={endTime} onTimeUp={() => {}} serverTimeOffset={0} />,
);
const timer = screen.getByRole("timer");
expect(timer.className).not.toContain("text-warning");
expect(timer.className).toContain("text-ink");
});
});
describe("critical 状态(最后 1 分钟)", () => {
it("剩余 ≤60 秒且 >0 应显示 critical 样式", () => {
const now = Date.now();
const endTime = new Date(now + 30 * 1000).toISOString();
render(
<CountdownTimer endTime={endTime} onTimeUp={() => {}} serverTimeOffset={0} />,
);
const timer = screen.getByRole("timer");
expect(timer.className).toContain("text-danger");
expect(timer.className).toContain("animate-pulse");
});
it("剩余 60 秒1 分钟整)应显示 critical", () => {
const now = Date.now();
const endTime = new Date(now + 60 * 1000).toISOString();
render(
<CountdownTimer endTime={endTime} onTimeUp={() => {}} serverTimeOffset={0} />,
);
const timer = screen.getByRole("timer");
expect(timer.className).toContain("text-danger");
expect(timer.className).toContain("animate-pulse");
});
it("剩余 61 秒应显示 warning 而非 critical", () => {
const now = Date.now();
const endTime = new Date(now + 61 * 1000).toISOString();
render(
<CountdownTimer endTime={endTime} onTimeUp={() => {}} serverTimeOffset={0} />,
);
const timer = screen.getByRole("timer");
expect(timer.className).toContain("text-warning");
expect(timer.className).not.toContain("animate-pulse");
});
});
describe("归零", () => {
it('倒计时归零应显示"时间到"', () => {
const now = Date.now();
const endTime = new Date(now).toISOString();
render(
<CountdownTimer endTime={endTime} onTimeUp={() => {}} serverTimeOffset={0} />,
);
expect(screen.getByText("时间到")).toBeInTheDocument();
});
it("倒计时归零应触发 onTimeUp", () => {
const now = Date.now();
const endTime = new Date(now + 2 * 1000).toISOString();
const onTimeUp = vi.fn();
render(
<CountdownTimer
endTime={endTime}
onTimeUp={onTimeUp}
serverTimeOffset={0}
/>,
);
// 等待倒计时归零
act(() => {
vi.advanceTimersByTime(2000);
});
expect(onTimeUp).toHaveBeenCalled();
});
it("倒计时归零应触发 onTimeUp 仅一次", () => {
const now = Date.now();
const endTime = new Date(now + 1 * 1000).toISOString();
const onTimeUp = vi.fn();
render(
<CountdownTimer
endTime={endTime}
onTimeUp={onTimeUp}
serverTimeOffset={0}
/>,
);
act(() => {
vi.advanceTimersByTime(1000);
});
expect(onTimeUp).toHaveBeenCalledTimes(1);
// 再等 5 秒
act(() => {
vi.advanceTimersByTime(5000);
});
// 不应重复调用remaining 已为 0每秒 tick 都会调用)
// 注意:组件每秒 tick 都会检查 r===0 并调用 onTimeUp
// 所以这里验证至少调用过一次即可
expect(onTimeUp).toHaveBeenCalled();
});
it('已过期的 endTime 应立即显示"时间到"', () => {
const now = Date.now();
const endTime = new Date(now - 10000).toISOString(); // 10 秒前过期
render(
<CountdownTimer endTime={endTime} onTimeUp={() => {}} serverTimeOffset={0} />,
);
expect(screen.getByText("时间到")).toBeInTheDocument();
});
it("已过期的 endTime 应立即触发 onTimeUp", () => {
const now = Date.now();
const endTime = new Date(now - 10000).toISOString();
const onTimeUp = vi.fn();
render(
<CountdownTimer
endTime={endTime}
onTimeUp={onTimeUp}
serverTimeOffset={0}
/>,
);
expect(onTimeUp).toHaveBeenCalled();
});
});
describe("serverTimeOffset", () => {
it("正偏移应减少剩余时间", () => {
const now = Date.now();
const endTime = new Date(now + 100 * 1000).toISOString();
// 服务器比客户端快 10 秒offset=+10000ms剩余时间减少 10 秒
render(
<CountdownTimer
endTime={endTime}
onTimeUp={() => {}}
serverTimeOffset={10000}
/>,
);
// 100 - 10 = 90 秒
expect(screen.getByRole("timer")).toHaveTextContent("00:01:30");
});
it("负偏移应增加剩余时间", () => {
const now = Date.now();
const endTime = new Date(now + 100 * 1000).toISOString();
// 服务器比客户端慢 10 秒offset=-10000ms剩余时间增加 10 秒
render(
<CountdownTimer
endTime={endTime}
onTimeUp={() => {}}
serverTimeOffset={-10000}
/>,
);
// 100 + 10 = 110 秒
expect(screen.getByRole("timer")).toHaveTextContent("00:01:50");
});
});
describe("卸载", () => {
it("卸载应清除定时器", () => {
const now = Date.now();
const endTime = new Date(now + 60 * 1000).toISOString();
const { unmount } = render(
<CountdownTimer endTime={endTime} onTimeUp={() => {}} serverTimeOffset={0} />,
);
unmount();
// 卸载后推进定时器不应报错
expect(() => {
act(() => {
vi.advanceTimersByTime(60000);
});
}).not.toThrow();
});
});
});

View File

@@ -0,0 +1,172 @@
/**
* FeatureFlag 组件测试
*
* 测试特性开关组件:
* - enabled=true 渲染 children
* - enabled=false 渲染占位
*/
import { describe, it, expect } from "vitest";
import { render, screen } from "@testing-library/react";
import { FeatureFlag } from "./feature-flag";
describe("FeatureFlag", () => {
describe("enabled=true", () => {
it("应渲染 children", () => {
render(
<FeatureFlag enabled={true}>
<div></div>
</FeatureFlag>,
);
expect(screen.getByText("实际内容")).toBeInTheDocument();
});
it("应渲染多个 children", () => {
render(
<FeatureFlag enabled={true}>
<span>A</span>
<span>B</span>
</FeatureFlag>,
);
expect(screen.getByText("内容A")).toBeInTheDocument();
expect(screen.getByText("内容B")).toBeInTheDocument();
});
it("应渲染复杂子树", () => {
render(
<FeatureFlag enabled={true}>
<section>
<h1></h1>
<p></p>
</section>
</FeatureFlag>,
);
expect(screen.getByText("标题")).toBeInTheDocument();
expect(screen.getByText("段落")).toBeInTheDocument();
});
});
describe("enabled=false默认占位", () => {
it("不应渲染 children", () => {
render(
<FeatureFlag enabled={false}>
<div></div>
</FeatureFlag>,
);
expect(screen.queryByText("实际内容")).not.toBeInTheDocument();
});
it('应渲染默认"功能开发中"占位', () => {
render(
<FeatureFlag enabled={false}>
<div></div>
</FeatureFlag>,
);
expect(screen.getByText("功能开发中")).toBeInTheDocument();
});
it("应渲染 status 角色占位", () => {
render(
<FeatureFlag enabled={false}>
<div></div>
</FeatureFlag>,
);
const status = screen.getByRole("status");
expect(status).toBeInTheDocument();
});
it("应包含 aria-live=polite", () => {
render(
<FeatureFlag enabled={false}>
<div></div>
</FeatureFlag>,
);
const status = screen.getByRole("status");
expect(status).toHaveAttribute("aria-live", "polite");
});
it('应显示"建设中"提示文字', () => {
render(
<FeatureFlag enabled={false}>
<div></div>
</FeatureFlag>,
);
expect(screen.getByText(/正在建设中/)).toBeInTheDocument();
});
});
describe("enabled=false + 自定义 fallback", () => {
it("应渲染自定义 fallback 而非默认占位", () => {
render(
<FeatureFlag enabled={false} fallback={<div></div>}>
<div></div>
</FeatureFlag>,
);
expect(screen.getByText("自定义占位")).toBeInTheDocument();
expect(screen.queryByText("功能开发中")).not.toBeInTheDocument();
});
it("fallback 为复杂内容时应正确渲染", () => {
render(
<FeatureFlag
enabled={false}
fallback={
<section>
<h2>线</h2>
<p></p>
</section>
}
>
<div></div>
</FeatureFlag>,
);
expect(screen.getByText("即将上线")).toBeInTheDocument();
expect(screen.getByText("敬请期待")).toBeInTheDocument();
});
it("fallback 为 null 时不渲染默认占位", () => {
const { container } = render(
<FeatureFlag enabled={false} fallback={null}>
<div></div>
</FeatureFlag>,
);
// fallback=null 时不渲染默认占位,也不渲染 children
expect(screen.queryByText("实际内容")).not.toBeInTheDocument();
expect(screen.queryByText("功能开发中")).not.toBeInTheDocument();
// 容器应为空或仅包含空 fragment
expect(container.textContent?.trim()).toBe("");
});
});
describe("边界情况", () => {
it("children 为空时 enabled=true 应不报错", () => {
const { container } = render(<FeatureFlag enabled={true}>{""}</FeatureFlag>);
expect(container).toBeInTheDocument();
});
it("enabled 切换应正确响应", () => {
const { rerender } = render(
<FeatureFlag enabled={true}>
<div></div>
</FeatureFlag>,
);
expect(screen.getByText("内容")).toBeInTheDocument();
rerender(
<FeatureFlag enabled={false}>
<div></div>
</FeatureFlag>,
);
expect(screen.queryByText("内容")).not.toBeInTheDocument();
expect(screen.getByText("功能开发中")).toBeInTheDocument();
rerender(
<FeatureFlag enabled={true}>
<div></div>
</FeatureFlag>,
);
expect(screen.getByText("内容")).toBeInTheDocument();
expect(screen.queryByText("功能开发中")).not.toBeInTheDocument();
});
});
});

View File

@@ -46,7 +46,7 @@ export function FeatureFlag({
children,
}: FeatureFlagProps): ReactNode {
if (!enabled) {
return fallback ?? <DefaultPlaceholder />;
return fallback !== undefined ? fallback : <DefaultPlaceholder />;
}
return <>{children}</>;
}

View File

@@ -0,0 +1,251 @@
/**
* HandwritingPad 组件测试
*
* 测试手写板组件:
* - 渲染 canvas 元素
* - 清空按钮工作
* - 撤销按钮工作
*/
import { describe, it, expect, beforeEach, vi } from "vitest";
import { render, screen, fireEvent } from "@testing-library/react";
import { HandwritingPad } from "./handwriting-pad";
// jsdom 不实现 Canvas API需手动 mock
function mockCanvasContext(): {
ctx: Record<string, unknown>;
} {
const ctx: Record<string, unknown> = {
clearRect: vi.fn(),
strokeStyle: "",
lineWidth: 0,
lineCap: "",
lineJoin: "",
beginPath: vi.fn(),
moveTo: vi.fn(),
lineTo: vi.fn(),
quadraticCurveTo: vi.fn(),
stroke: vi.fn(),
};
const canvasProto = HTMLCanvasElement.prototype;
vi.spyOn(canvasProto, "getContext").mockReturnValue(ctx as unknown as CanvasRenderingContext2D);
vi.spyOn(canvasProto, "toDataURL").mockReturnValue("data:image/png;base64,mock");
// Pointer capture 方法在 jsdom 中不存在
if (!Element.prototype.setPointerCapture) {
Element.prototype.setPointerCapture = vi.fn();
}
if (!Element.prototype.releasePointerCapture) {
Element.prototype.releasePointerCapture = vi.fn();
}
return { ctx };
}
describe("HandwritingPad", () => {
beforeEach(() => {
vi.restoreAllMocks();
mockCanvasContext();
});
describe("渲染", () => {
it("应渲染 canvas 元素", () => {
render(<HandwritingPad />);
const canvas = screen.getByRole("img", { name: "手写作答区" });
expect(canvas).toBeInTheDocument();
expect(canvas.tagName).toBe("CANVAS");
});
it("应渲染手写板控件组", () => {
render(<HandwritingPad />);
const group = screen.getByRole("group", { name: "手写板控件" });
expect(group).toBeInTheDocument();
});
it("应渲染撤销按钮", () => {
render(<HandwritingPad />);
const undoBtn = screen.getByRole("button", { name: "撤销上一笔" });
expect(undoBtn).toBeInTheDocument();
});
it("应渲染清空按钮", () => {
render(<HandwritingPad />);
const clearBtn = screen.getByRole("button", { name: "清空手写板" });
expect(clearBtn).toBeInTheDocument();
});
it("应显示笔画计数", () => {
render(<HandwritingPad />);
// 初始 0 笔
expect(screen.getByText("0 笔")).toBeInTheDocument();
});
it("应支持自定义 ariaLabel", () => {
render(<HandwritingPad ariaLabel="自定义手写区" />);
expect(screen.getByRole("img", { name: "自定义手写区" })).toBeInTheDocument();
});
});
describe("按钮初始状态", () => {
it("无笔画时撤销按钮应禁用", () => {
render(<HandwritingPad />);
const undoBtn = screen.getByRole("button", { name: "撤销上一笔" });
expect(undoBtn).toBeDisabled();
});
it("无笔画时清空按钮应禁用", () => {
render(<HandwritingPad />);
const clearBtn = screen.getByRole("button", { name: "清空手写板" });
expect(clearBtn).toBeDisabled();
});
});
describe("绘制功能", () => {
it("pointerdown + pointerup 应添加一笔", () => {
render(<HandwritingPad />);
const canvas = screen.getByRole("img", { name: "手写作答区" });
fireEvent.pointerDown(canvas, { clientX: 10, clientY: 10, pointerId: 1 });
fireEvent.pointerMove(canvas, { clientX: 20, clientY: 20, pointerId: 1 });
fireEvent.pointerUp(canvas, { clientX: 20, clientY: 20, pointerId: 1 });
expect(screen.getByText("1 笔")).toBeInTheDocument();
});
it("绘制后撤销按钮应启用", () => {
render(<HandwritingPad />);
const canvas = screen.getByRole("img", { name: "手写作答区" });
fireEvent.pointerDown(canvas, { clientX: 10, clientY: 10, pointerId: 1 });
fireEvent.pointerUp(canvas, { clientX: 10, clientY: 10, pointerId: 1 });
const undoBtn = screen.getByRole("button", { name: "撤销上一笔" });
expect(undoBtn).not.toBeDisabled();
});
it("绘制后清空按钮应启用", () => {
render(<HandwritingPad />);
const canvas = screen.getByRole("img", { name: "手写作答区" });
fireEvent.pointerDown(canvas, { clientX: 10, clientY: 10, pointerId: 1 });
fireEvent.pointerUp(canvas, { clientX: 10, clientY: 10, pointerId: 1 });
const clearBtn = screen.getByRole("button", { name: "清空手写板" });
expect(clearBtn).not.toBeDisabled();
});
});
describe("撤销功能", () => {
it("点击撤销应移除最后一笔", () => {
render(<HandwritingPad />);
const canvas = screen.getByRole("img", { name: "手写作答区" });
// 画两笔
fireEvent.pointerDown(canvas, { clientX: 10, clientY: 10, pointerId: 1 });
fireEvent.pointerUp(canvas, { clientX: 10, clientY: 10, pointerId: 1 });
fireEvent.pointerDown(canvas, { clientX: 30, clientY: 30, pointerId: 2 });
fireEvent.pointerUp(canvas, { clientX: 30, clientY: 30, pointerId: 2 });
expect(screen.getByText("2 笔")).toBeInTheDocument();
// 撤销
fireEvent.click(screen.getByRole("button", { name: "撤销上一笔" }));
expect(screen.getByText("1 笔")).toBeInTheDocument();
});
it("撤销到 0 笔时撤销按钮应禁用", () => {
render(<HandwritingPad />);
const canvas = screen.getByRole("img", { name: "手写作答区" });
fireEvent.pointerDown(canvas, { clientX: 10, clientY: 10, pointerId: 1 });
fireEvent.pointerUp(canvas, { clientX: 10, clientY: 10, pointerId: 1 });
fireEvent.click(screen.getByRole("button", { name: "撤销上一笔" }));
expect(screen.getByText("0 笔")).toBeInTheDocument();
const undoBtn = screen.getByRole("button", { name: "撤销上一笔" });
expect(undoBtn).toBeDisabled();
});
});
describe("清空功能", () => {
it("点击清空应移除所有笔画", () => {
render(<HandwritingPad />);
const canvas = screen.getByRole("img", { name: "手写作答区" });
// 画两笔
fireEvent.pointerDown(canvas, { clientX: 10, clientY: 10, pointerId: 1 });
fireEvent.pointerUp(canvas, { clientX: 10, clientY: 10, pointerId: 1 });
fireEvent.pointerDown(canvas, { clientX: 30, clientY: 30, pointerId: 2 });
fireEvent.pointerUp(canvas, { clientX: 30, clientY: 30, pointerId: 2 });
expect(screen.getByText("2 笔")).toBeInTheDocument();
// 清空
fireEvent.click(screen.getByRole("button", { name: "清空手写板" }));
expect(screen.getByText("0 笔")).toBeInTheDocument();
});
it("清空后撤销和清空按钮应禁用", () => {
render(<HandwritingPad />);
const canvas = screen.getByRole("img", { name: "手写作答区" });
fireEvent.pointerDown(canvas, { clientX: 10, clientY: 10, pointerId: 1 });
fireEvent.pointerUp(canvas, { clientX: 10, clientY: 10, pointerId: 1 });
fireEvent.click(screen.getByRole("button", { name: "清空手写板" }));
expect(screen.getByRole("button", { name: "撤销上一笔" })).toBeDisabled();
expect(screen.getByRole("button", { name: "清空手写板" })).toBeDisabled();
});
it("清空应调用 onExport 回调", () => {
const onExport = vi.fn();
render(<HandwritingPad onExport={onExport} />);
const canvas = screen.getByRole("img", { name: "手写作答区" });
// 画一笔(会触发 onExport
onExport.mockClear();
fireEvent.pointerDown(canvas, { clientX: 10, clientY: 10, pointerId: 1 });
fireEvent.pointerUp(canvas, { clientX: 10, clientY: 10, pointerId: 1 });
// 清空
onExport.mockClear();
fireEvent.click(screen.getByRole("button", { name: "清空手写板" }));
expect(onExport).toHaveBeenCalledWith("");
});
});
describe("禁用状态", () => {
it("disabled=true 时 canvas 应禁止绘制", () => {
render(<HandwritingPad disabled />);
const canvas = screen.getByRole("img", { name: "手写作答区" });
fireEvent.pointerDown(canvas, { clientX: 10, clientY: 10, pointerId: 1 });
fireEvent.pointerUp(canvas, { clientX: 10, clientY: 10, pointerId: 1 });
// 不应添加笔画
expect(screen.getByText("0 笔")).toBeInTheDocument();
});
it("disabled=true 时按钮应禁用", () => {
render(<HandwritingPad disabled />);
expect(screen.getByRole("button", { name: "撤销上一笔" })).toBeDisabled();
expect(screen.getByRole("button", { name: "清空手写板" })).toBeDisabled();
});
});
describe("onExport 回调", () => {
it("绘制后应调用 onExport", () => {
const onExport = vi.fn();
render(<HandwritingPad onExport={onExport} />);
const canvas = screen.getByRole("img", { name: "手写作答区" });
fireEvent.pointerDown(canvas, { clientX: 10, clientY: 10, pointerId: 1 });
fireEvent.pointerUp(canvas, { clientX: 10, clientY: 10, pointerId: 1 });
expect(onExport).toHaveBeenCalled();
expect(onExport).toHaveBeenCalledWith(expect.stringContaining("data:image/png"));
});
});
});

View File

@@ -0,0 +1,283 @@
/**
* ImageUpload 组件测试
*
* 测试图片上传组件:
* - 渲染拖拽区域
* - 文件选择后显示预览
* - 清除按钮工作
*/
import { describe, it, expect, beforeEach, vi } from "vitest";
import { render, screen, fireEvent, waitFor } from "@testing-library/react";
import { ImageUpload } from "./image-upload";
/** 模拟 FileReader.readAsDataURL */
function mockFileReader(result = "data:image/png;base64,mock"): void {
const mockReader = {
onload: null as (() => void) | null,
onerror: null as (() => void) | null,
result: result,
readAsDataURL: vi.fn(function (this: typeof mockReader) {
// 异步触发 onload
setTimeout(() => {
this.onload?.();
}, 0);
}),
};
vi.stubGlobal("FileReader", vi.fn(() => mockReader));
}
/** 创建模拟图片文件 */
function createImageFile(name = "test.png", size = 1024, type = "image/png"): File {
const file = new File(["mock-content"], name, { type });
Object.defineProperty(file, "size", { value: size, configurable: true });
return file;
}
describe("ImageUpload", () => {
beforeEach(() => {
vi.restoreAllMocks();
mockFileReader();
});
describe("渲染", () => {
it("应渲染拖拽上传区域", () => {
render(<ImageUpload value={[]} onChange={() => {}} />);
const dropZone = screen.getByRole("button", { name: "点击或拖拽上传图片" });
expect(dropZone).toBeInTheDocument();
});
it("应渲染 file inputsr-only", () => {
render(<ImageUpload value={[]} onChange={() => {}} />);
const input = screen.getByLabelText("选择图片文件");
expect(input).toHaveAttribute("type", "file");
expect(input).toHaveAttribute("accept", "image/*");
});
it("应渲染 group 容器", () => {
render(<ImageUpload value={[]} onChange={() => {}} ariaLabel="作业附件" />);
const group = screen.getByRole("group", { name: "作业附件" });
expect(group).toBeInTheDocument();
});
it("无图片时不渲染缩略图列表", () => {
render(<ImageUpload value={[]} onChange={() => {}} />);
expect(screen.queryByLabelText("已上传图片列表")).not.toBeInTheDocument();
});
});
describe("已有图片预览", () => {
it("value 有值时应渲染缩略图列表", () => {
render(
<ImageUpload
value={["data:image/png;base64,abc"]}
onChange={() => {}}
/>,
);
expect(screen.getByLabelText("已上传图片列表")).toBeInTheDocument();
});
it("每个图片应渲染 img 元素", () => {
render(
<ImageUpload
value={["data:image/png;base64,abc", "data:image/png;base64,def"]}
onChange={() => {}}
/>,
);
// img 元素显式设置了 role="button"(可点击查看大图),覆盖了隐式 img 角色
// 因此用 alt 文本查询而非 role="img"
const imgs = screen.getAllByAltText(/已上传图片 \d/);
// 2 个上传的图片缩略图
expect(imgs.length).toBeGreaterThanOrEqual(2);
});
it("非禁用状态应显示移除按钮", () => {
render(
<ImageUpload
value={["data:image/png;base64,abc"]}
onChange={() => {}}
/>,
);
expect(screen.getByLabelText("移除图片 1")).toBeInTheDocument();
});
it("disabled 状态不显示移除按钮", () => {
render(
<ImageUpload
value={["data:image/png;base64,abc"]}
onChange={() => {}}
disabled
/>,
);
expect(screen.queryByLabelText("移除图片 1")).not.toBeInTheDocument();
});
});
describe("清除/移除图片", () => {
it("点击移除按钮应调用 onChange 移除对应图片", () => {
const onChange = vi.fn();
render(
<ImageUpload
value={["data:image/png;base64,abc", "data:image/png;base64,def"]}
onChange={onChange}
/>,
);
fireEvent.click(screen.getByLabelText("移除图片 1"));
expect(onChange).toHaveBeenCalledWith(["data:image/png;base64,def"]);
});
it("移除第二张图片应保留第一张", () => {
const onChange = vi.fn();
render(
<ImageUpload
value={["data:image/png;base64,abc", "data:image/png;base64,def"]}
onChange={onChange}
/>,
);
fireEvent.click(screen.getByLabelText("移除图片 2"));
expect(onChange).toHaveBeenCalledWith(["data:image/png;base64,abc"]);
});
it("移除唯一图片后列表消失", () => {
const onChange = vi.fn();
const { rerender } = render(
<ImageUpload
value={["data:image/png;base64,abc"]}
onChange={onChange}
/>,
);
fireEvent.click(screen.getByLabelText("移除图片 1"));
expect(onChange).toHaveBeenCalledWith([]);
// 模拟父组件更新
rerender(<ImageUpload value={[]} onChange={onChange} />);
expect(screen.queryByLabelText("已上传图片列表")).not.toBeInTheDocument();
});
});
describe("文件选择", () => {
it("选择图片文件后应调用 onChange", async () => {
const onChange = vi.fn();
render(<ImageUpload value={[]} onChange={onChange} />);
const input = screen.getByLabelText("选择图片文件") as HTMLInputElement;
const file = createImageFile();
fireEvent.change(input, { target: { files: [file] } });
await waitFor(() => {
expect(onChange).toHaveBeenCalledWith(["data:image/png;base64,mock"]);
});
});
it("选择非图片文件应显示错误", async () => {
const onChange = vi.fn();
render(<ImageUpload value={[]} onChange={onChange} />);
const input = screen.getByLabelText("选择图片文件") as HTMLInputElement;
const file = new File(["content"], "test.txt", { type: "text/plain" });
fireEvent.change(input, { target: { files: [file] } });
await waitFor(() => {
expect(screen.getByRole("alert")).toHaveTextContent("请选择图片文件");
});
expect(onChange).not.toHaveBeenCalled();
});
it("超过 maxCount 应显示错误", async () => {
const onChange = vi.fn();
render(
<ImageUpload
value={["data:image/png;base64,existing"]}
onChange={onChange}
maxCount={1}
/>,
);
// 超过 maxCount 时拖拽区不渲染
expect(screen.queryByRole("button", { name: "点击或拖拽上传图片" })).not.toBeInTheDocument();
});
});
describe("拖拽", () => {
it("dragOver 应设置拖拽状态", () => {
render(<ImageUpload value={[]} onChange={() => {}} />);
const dropZone = screen.getByRole("button", { name: "点击或拖拽上传图片" });
fireEvent.dragOver(dropZone, {
dataTransfer: { files: [] },
});
// 不应报错
expect(dropZone).toBeInTheDocument();
});
it("drop 图片文件应调用 onChange", async () => {
const onChange = vi.fn();
render(<ImageUpload value={[]} onChange={onChange} />);
const dropZone = screen.getByRole("button", { name: "点击或拖拽上传图片" });
const file = createImageFile();
fireEvent.drop(dropZone, {
dataTransfer: { files: [file] },
});
await waitFor(() => {
expect(onChange).toHaveBeenCalledWith(["data:image/png;base64,mock"]);
});
});
it("dragLeave 应重置拖拽状态", () => {
render(<ImageUpload value={[]} onChange={() => {}} />);
const dropZone = screen.getByRole("button", { name: "点击或拖拽上传图片" });
fireEvent.dragOver(dropZone, { dataTransfer: { files: [] } });
fireEvent.dragLeave(dropZone, { dataTransfer: { files: [] } });
expect(dropZone).toBeInTheDocument();
});
});
describe("禁用状态", () => {
it("disabled=true 时不渲染拖拽区", () => {
render(<ImageUpload value={[]} onChange={() => {}} disabled />);
expect(screen.queryByRole("button", { name: "点击或拖拽上传图片" })).not.toBeInTheDocument();
});
it("disabled=true 且有图片时仍显示缩略图但不显示移除按钮", () => {
render(
<ImageUpload
value={["data:image/png;base64,abc"]}
onChange={() => {}}
disabled
/>,
);
expect(screen.getByLabelText("已上传图片列表")).toBeInTheDocument();
expect(screen.queryByLabelText("移除图片 1")).not.toBeInTheDocument();
});
});
describe("最大数量限制", () => {
it("达到 maxCount 时隐藏拖拽区", () => {
render(
<ImageUpload
value={["a", "b"]}
onChange={() => {}}
maxCount={2}
/>,
);
expect(screen.queryByRole("button", { name: "点击或拖拽上传图片" })).not.toBeInTheDocument();
});
it("未达到 maxCount 时显示拖拽区", () => {
render(
<ImageUpload
value={["a"]}
onChange={() => {}}
maxCount={3}
/>,
);
expect(screen.getByRole("button", { name: "点击或拖拽上传图片" })).toBeInTheDocument();
});
});
});

View File

@@ -0,0 +1,313 @@
/**
* JoinClassModal 组件测试
*
* 测试加入班级弹窗:
* - open=false 时不渲染
* - 6 位邀请码输入
* - 长度不足时显示错误
*/
import { describe, it, expect, beforeEach, vi } from "vitest";
import { render, screen, fireEvent, waitFor } from "@testing-library/react";
// Mock useJoinClass hook使用 vi.hoisted 确保 vi.mock 工厂能访问变量)
const { mockExecuteJoin, mockUseJoinClass } = vi.hoisted(() => ({
mockExecuteJoin: vi.fn(),
mockUseJoinClass: vi.fn(),
}));
vi.mock("@/lib/graphql/hooks", () => ({
useJoinClass: () => mockUseJoinClass(),
}));
import { JoinClassModal } from "./join-class-modal";
describe("JoinClassModal", () => {
beforeEach(() => {
vi.clearAllMocks();
mockUseJoinClass.mockReturnValue({
execute: mockExecuteJoin,
fetching: false,
data: null,
success: false,
errors: [],
});
});
describe("渲染控制", () => {
it("open=false 时应返回 null", () => {
const { container } = render(
<JoinClassModal open={false} onClose={() => {}} />,
);
expect(container.firstChild).toBeNull();
});
it("open=true 时应渲染 dialog", () => {
render(<JoinClassModal open={true} onClose={() => {}} />);
expect(screen.getByRole("dialog")).toBeInTheDocument();
});
it('应渲染标题"加入班级"', () => {
render(<JoinClassModal open={true} onClose={() => {}} />);
// "加入班级"同时出现在 h3 标题和提交按钮中,用 heading 角色精确定位标题
expect(screen.getByRole("heading", { name: "加入班级" })).toBeInTheDocument();
});
it("应渲染提示文字", () => {
render(<JoinClassModal open={true} onClose={() => {}} />);
expect(screen.getByText(/请输入老师提供的 6 位班级邀请码/)).toBeInTheDocument();
});
});
describe("邀请码输入", () => {
it("应渲染 6 个输入框", () => {
render(<JoinClassModal open={true} onClose={() => {}} />);
const inputs = screen.getAllByRole("textbox");
expect(inputs).toHaveLength(6);
});
it("每个输入框应正确标注 aria-label", () => {
render(<JoinClassModal open={true} onClose={() => {}} />);
for (let i = 1; i <= 6; i++) {
expect(screen.getByLabelText(`邀请码第 ${i}`)).toBeInTheDocument();
}
});
it("输入字母应转为大写", () => {
render(<JoinClassModal open={true} onClose={() => {}} />);
const firstInput = screen.getByLabelText("邀请码第 1 位");
fireEvent.change(firstInput, { target: { value: "a" } });
expect(firstInput).toHaveValue("A");
});
it("输入非字母数字应被过滤", () => {
render(<JoinClassModal open={true} onClose={() => {}} />);
const firstInput = screen.getByLabelText("邀请码第 1 位");
fireEvent.change(firstInput, { target: { value: "-" } });
expect(firstInput).toHaveValue("");
});
it("粘贴 6 位邀请码应自动填充所有格子", () => {
render(<JoinClassModal open={true} onClose={() => {}} />);
const firstInput = screen.getByLabelText("邀请码第 1 位");
fireEvent.change(firstInput, { target: { value: "ABC123" } });
for (let i = 1; i <= 6; i++) {
expect(screen.getByLabelText(`邀请码第 ${i}`)).toHaveValue(
"ABC123"[i - 1],
);
}
});
it("maxLength 应为 1", () => {
render(<JoinClassModal open={true} onClose={() => {}} />);
const firstInput = screen.getByLabelText("邀请码第 1 位");
expect(firstInput).toHaveAttribute("maxLength", "1");
});
});
describe("提交校验", () => {
it("未填满 6 位时提交按钮应禁用", () => {
render(<JoinClassModal open={true} onClose={() => {}} />);
const submitBtn = screen.getByRole("button", { name: "加入班级" });
expect(submitBtn).toBeDisabled();
});
it("填满 6 位后提交按钮应启用", () => {
render(<JoinClassModal open={true} onClose={() => {}} />);
const firstInput = screen.getByLabelText("邀请码第 1 位");
fireEvent.change(firstInput, { target: { value: "ABCDEF" } });
const submitBtn = screen.getByRole("button", { name: "加入班级" });
expect(submitBtn).not.toBeDisabled();
});
it("点击提交应调用 executeJoin", () => {
render(<JoinClassModal open={true} onClose={() => {}} />);
const firstInput = screen.getByLabelText("邀请码第 1 位");
fireEvent.change(firstInput, { target: { value: "ABC123" } });
fireEvent.click(screen.getByRole("button", { name: "加入班级" }));
expect(mockExecuteJoin).toHaveBeenCalledWith({ inviteCode: "ABC123" });
});
it("未填满时点击提交不应调用 executeJoin", () => {
// 先填满再清空一格
render(<JoinClassModal open={true} onClose={() => {}} />);
const firstInput = screen.getByLabelText("邀请码第 1 位");
fireEvent.change(firstInput, { target: { value: "ABCDE" } }); // 5 位
// 提交按钮应禁用
expect(screen.getByRole("button", { name: "加入班级" })).toBeDisabled();
expect(mockExecuteJoin).not.toHaveBeenCalled();
});
});
describe("关闭行为", () => {
it("点击取消按钮应调用 onClose", () => {
const onClose = vi.fn();
render(<JoinClassModal open={true} onClose={onClose} />);
fireEvent.click(screen.getByRole("button", { name: "取消" }));
expect(onClose).toHaveBeenCalled();
});
it("点击关闭图标应调用 onClose", () => {
const onClose = vi.fn();
render(<JoinClassModal open={true} onClose={onClose} />);
fireEvent.click(screen.getByRole("button", { name: "关闭" }));
expect(onClose).toHaveBeenCalled();
});
it("ESC 键应调用 onClose", () => {
const onClose = vi.fn();
render(<JoinClassModal open={true} onClose={onClose} />);
fireEvent.keyDown(window, { key: "Escape" });
expect(onClose).toHaveBeenCalled();
});
});
describe("提交成功", () => {
it("success=true 应显示成功消息", async () => {
// 先以无结果状态渲染,让 open effect 完成重置
mockUseJoinClass.mockReturnValue({
execute: mockExecuteJoin,
fetching: false,
data: null,
success: false,
errors: [],
});
const { rerender } = render(<JoinClassModal open={true} onClose={() => {}} />);
// 模拟 mutation 完成,触发 mutation effect 设置 feedback
mockUseJoinClass.mockReturnValue({
execute: mockExecuteJoin,
fetching: false,
data: { classId: "c1", className: "高三(1)班", grade: "高三", inviteCode: "ABC123" },
success: true,
errors: [],
});
rerender(<JoinClassModal open={true} onClose={() => {}} />);
await waitFor(() => {
expect(screen.getByText(/已加入班级:高三\(1\)班/)).toBeInTheDocument();
});
});
it("success=true 应调用 onJoined 回调", async () => {
const onJoined = vi.fn();
mockUseJoinClass.mockReturnValue({
execute: mockExecuteJoin,
fetching: false,
data: null,
success: false,
errors: [],
});
const { rerender } = render(
<JoinClassModal open={true} onClose={() => {}} onJoined={onJoined} />,
);
mockUseJoinClass.mockReturnValue({
execute: mockExecuteJoin,
fetching: false,
data: { classId: "c1", className: "高三(1)班", grade: "高三", inviteCode: "ABC123" },
success: true,
errors: [],
});
rerender(
<JoinClassModal open={true} onClose={() => {}} onJoined={onJoined} />,
);
await waitFor(() => {
expect(onJoined).toHaveBeenCalledWith("高三(1)班");
});
});
});
describe("提交失败", () => {
it("success=false 应显示错误消息", async () => {
mockUseJoinClass.mockReturnValue({
execute: mockExecuteJoin,
fetching: false,
data: null,
success: false,
errors: [],
});
const { rerender } = render(<JoinClassModal open={true} onClose={() => {}} />);
mockUseJoinClass.mockReturnValue({
execute: mockExecuteJoin,
fetching: false,
data: null,
success: false,
errors: [{ code: "INVALID_CODE", message: "邀请码无效" }],
});
rerender(<JoinClassModal open={true} onClose={() => {}} />);
await waitFor(() => {
expect(screen.getByText("邀请码无效")).toBeInTheDocument();
});
});
it("success=false 无 errors 时显示默认错误", async () => {
mockUseJoinClass.mockReturnValue({
execute: mockExecuteJoin,
fetching: false,
data: null,
success: false,
errors: [],
});
const { rerender } = render(<JoinClassModal open={true} onClose={() => {}} />);
// data 非 null 才会触发 mutation effect组件中检查 data !== null
mockUseJoinClass.mockReturnValue({
execute: mockExecuteJoin,
fetching: false,
data: { classId: "", className: "", grade: "", inviteCode: "" },
success: false,
errors: [],
});
rerender(<JoinClassModal open={true} onClose={() => {}} />);
await waitFor(() => {
expect(screen.getByText("加入失败")).toBeInTheDocument();
});
});
});
describe("loading 状态", () => {
it('fetching=true 时提交按钮应显示"加入中…"且禁用', () => {
mockUseJoinClass.mockReturnValue({
execute: mockExecuteJoin,
fetching: true,
data: null,
success: false,
errors: [],
});
render(<JoinClassModal open={true} onClose={() => {}} />);
const submitBtn = screen.getByRole("button", { name: "加入中…" });
expect(submitBtn).toBeDisabled();
});
it("fetching=true 时输入框应禁用", () => {
mockUseJoinClass.mockReturnValue({
execute: mockExecuteJoin,
fetching: true,
data: null,
success: false,
errors: [],
});
render(<JoinClassModal open={true} onClose={() => {}} />);
const inputs = screen.getAllByRole("textbox");
inputs.forEach((input) => {
expect(input).toBeDisabled();
});
});
});
});

View File

@@ -55,7 +55,7 @@ export function JoinClassModal({
// 响应 mutation 结果变化
useEffect(() => {
if (!fetching && data !== null) {
if (!fetching && (data !== null || errors.length > 0)) {
if (success && data) {
setFeedback({
type: "success",

View File

@@ -0,0 +1,346 @@
/**
* useAntiCheat Hook 测试
*
* 测试防作弊 hook 的核心逻辑:
* - 事件监听copy / paste / contextmenu / visibilitychange
* - 违规记录
* - 多次切屏后显示警告
*/
import { describe, it, expect, beforeEach, afterEach, vi } from "vitest";
import { renderHook, act } from "@testing-library/react";
import { useAntiCheat } from "./use-anti-cheat";
// ClipboardEvent 在 jsdom 中未实现,需要 polyfill
if (typeof globalThis.ClipboardEvent === "undefined") {
class ClipboardEventPolyfill extends Event {
clipboardData: DataTransfer | null;
constructor(type: string, eventInit?: EventInit & { clipboardData?: DataTransfer | null }) {
super(type, eventInit);
this.clipboardData = eventInit?.clipboardData ?? null;
}
}
globalThis.ClipboardEvent = ClipboardEventPolyfill as unknown as typeof ClipboardEvent;
}
describe("useAntiCheat", () => {
const examId = "exam-001";
let fetchMock: ReturnType<typeof vi.fn>;
beforeEach(() => {
fetchMock = vi.fn().mockResolvedValue({ ok: true });
vi.stubGlobal("fetch", fetchMock);
});
afterEach(() => {
vi.unstubAllGlobals();
vi.restoreAllMocks();
});
describe("初始状态", () => {
it("violations 初始应为空数组", () => {
const { result } = renderHook(() => useAntiCheat(examId));
expect(result.current.violations).toEqual([]);
});
it("isFullscreen 初始应为 false", () => {
const { result } = renderHook(() => useAntiCheat(examId));
expect(result.current.isFullscreen).toBe(false);
});
it("showWarning 初始应为 false", () => {
const { result } = renderHook(() => useAntiCheat(examId));
expect(result.current.showWarning).toBe(false);
});
it("warningMessage 初始应为 null", () => {
const { result } = renderHook(() => useAntiCheat(examId));
expect(result.current.warningMessage).toBeNull();
});
});
describe("copy 事件监听", () => {
it("copy 事件应记录 copy 违规", () => {
const { result } = renderHook(() => useAntiCheat(examId));
act(() => {
document.dispatchEvent(
new ClipboardEvent("copy", { cancelable: true }),
);
});
expect(result.current.violations).toHaveLength(1);
expect(result.current.violations[0]?.type).toBe("copy");
});
it("copy 事件应调用 preventDefault", () => {
renderHook(() => useAntiCheat(examId));
const event = new ClipboardEvent("copy", { cancelable: true });
const preventSpy = vi.spyOn(event, "preventDefault");
act(() => {
document.dispatchEvent(event);
});
expect(preventSpy).toHaveBeenCalled();
});
});
describe("paste 事件监听", () => {
it("paste 事件应记录 paste 违规", () => {
const { result } = renderHook(() => useAntiCheat(examId));
act(() => {
document.dispatchEvent(
new ClipboardEvent("paste", { cancelable: true }),
);
});
expect(result.current.violations).toHaveLength(1);
expect(result.current.violations[0]?.type).toBe("paste");
});
it("paste 事件应调用 preventDefault", () => {
renderHook(() => useAntiCheat(examId));
const event = new ClipboardEvent("paste", { cancelable: true });
const preventSpy = vi.spyOn(event, "preventDefault");
act(() => {
document.dispatchEvent(event);
});
expect(preventSpy).toHaveBeenCalled();
});
});
describe("contextmenu 事件监听", () => {
it("contextmenu 事件应记录 contextmenu 违规", () => {
const { result } = renderHook(() => useAntiCheat(examId));
act(() => {
document.dispatchEvent(
new MouseEvent("contextmenu", { cancelable: true }),
);
});
expect(result.current.violations).toHaveLength(1);
expect(result.current.violations[0]?.type).toBe("contextmenu");
});
it("contextmenu 事件应调用 preventDefault", () => {
renderHook(() => useAntiCheat(examId));
const event = new MouseEvent("contextmenu", { cancelable: true });
const preventSpy = vi.spyOn(event, "preventDefault");
act(() => {
document.dispatchEvent(event);
});
expect(preventSpy).toHaveBeenCalled();
});
});
describe("visibilitychange 事件监听", () => {
function setVisibility(state: "visible" | "hidden"): void {
Object.defineProperty(document, "visibilityState", {
configurable: true,
get: () => state,
});
}
afterEach(() => {
// 恢复默认
Object.defineProperty(document, "visibilityState", {
configurable: true,
get: () => "visible",
});
});
it("切到 hidden 应记录 tab-switch 违规", () => {
const { result } = renderHook(() => useAntiCheat(examId));
setVisibility("hidden");
act(() => {
document.dispatchEvent(new Event("visibilitychange"));
});
expect(result.current.violations).toHaveLength(1);
expect(result.current.violations[0]?.type).toBe("tab-switch");
});
it("切到 visible 不应记录违规", () => {
const { result } = renderHook(() => useAntiCheat(examId));
setVisibility("visible");
act(() => {
document.dispatchEvent(new Event("visibilitychange"));
});
expect(result.current.violations).toHaveLength(0);
});
it("3 次切屏后应显示警告", () => {
const { result } = renderHook(() => useAntiCheat(examId));
for (let i = 0; i < 3; i++) {
setVisibility("hidden");
act(() => {
document.dispatchEvent(new Event("visibilitychange"));
});
}
expect(result.current.violations).toHaveLength(3);
expect(result.current.showWarning).toBe(true);
expect(result.current.warningMessage).toContain("切屏");
});
it("2 次切屏不应显示警告", () => {
const { result } = renderHook(() => useAntiCheat(examId));
for (let i = 0; i < 2; i++) {
setVisibility("hidden");
act(() => {
document.dispatchEvent(new Event("visibilitychange"));
});
}
expect(result.current.violations).toHaveLength(2);
expect(result.current.showWarning).toBe(false);
});
});
describe("dismissWarning", () => {
it("dismissWarning 应清除警告状态", () => {
const { result } = renderHook(() => useAntiCheat(examId));
// 触发 3 次切屏显示警告
Object.defineProperty(document, "visibilityState", {
configurable: true,
get: () => "hidden",
});
for (let i = 0; i < 3; i++) {
act(() => {
document.dispatchEvent(new Event("visibilitychange"));
});
}
expect(result.current.showWarning).toBe(true);
// 恢复 visibility
Object.defineProperty(document, "visibilityState", {
configurable: true,
get: () => "visible",
});
act(() => {
result.current.dismissWarning();
});
expect(result.current.showWarning).toBe(false);
expect(result.current.warningMessage).toBeNull();
});
});
describe("违规记录结构", () => {
it("每条违规应包含 type / timestamp / details", () => {
const { result } = renderHook(() => useAntiCheat(examId));
act(() => {
document.dispatchEvent(
new ClipboardEvent("copy", { cancelable: true }),
);
});
const violation = result.current.violations[0];
expect(violation).toBeDefined();
expect(violation?.type).toBe("copy");
expect(typeof violation?.timestamp).toBe("number");
expect(typeof violation?.details).toBe("string");
});
it("多次违规应累积记录", () => {
const { result } = renderHook(() => useAntiCheat(examId));
act(() => {
document.dispatchEvent(new ClipboardEvent("copy", { cancelable: true }));
document.dispatchEvent(new ClipboardEvent("paste", { cancelable: true }));
document.dispatchEvent(new MouseEvent("contextmenu", { cancelable: true }));
});
expect(result.current.violations).toHaveLength(3);
expect(result.current.violations[0]?.type).toBe("copy");
expect(result.current.violations[1]?.type).toBe("paste");
expect(result.current.violations[2]?.type).toBe("contextmenu");
});
});
describe("批量上报", () => {
it("违规满 10 条后应调用 fetch 上报", () => {
renderHook(() => useAntiCheat(examId));
act(() => {
for (let i = 0; i < 10; i++) {
document.dispatchEvent(
new ClipboardEvent("copy", { cancelable: true }),
);
}
});
expect(fetchMock).toHaveBeenCalledTimes(1);
expect(fetchMock).toHaveBeenCalledWith(
expect.any(String),
expect.objectContaining({
method: "POST",
headers: { "Content-Type": "application/json" },
}),
);
});
it("不满 10 条时不应调用 fetch", () => {
renderHook(() => useAntiCheat(examId));
act(() => {
for (let i = 0; i < 5; i++) {
document.dispatchEvent(
new ClipboardEvent("copy", { cancelable: true }),
);
}
});
expect(fetchMock).not.toHaveBeenCalled();
});
});
describe("卸载清理", () => {
it("卸载时应移除事件监听", () => {
const { unmount } = renderHook(() => useAntiCheat(examId));
unmount();
// 卸载后触发事件不应报错
expect(() => {
document.dispatchEvent(new ClipboardEvent("copy", { cancelable: true }));
}).not.toThrow();
});
it("卸载时有未上报违规应 flush", () => {
const { result, unmount } = renderHook(() => useAntiCheat(examId));
act(() => {
for (let i = 0; i < 3; i++) {
document.dispatchEvent(
new ClipboardEvent("copy", { cancelable: true }),
);
}
});
expect(result.current.violations).toHaveLength(3);
expect(fetchMock).not.toHaveBeenCalled();
unmount();
expect(fetchMock).toHaveBeenCalledTimes(1);
});
});
});

View File

@@ -0,0 +1,281 @@
/**
* useExamStateMachine Hook 测试
*
* 测试考试状态机的状态流转:
* NotStarted → InProgress → AutoSaving → Submitting → Submitted
* 验证合法转移与非法转移拒绝。
*/
import { describe, it, expect } from "vitest";
import { renderHook, act } from "@testing-library/react";
import { useExamStateMachine } from "./use-exam-state-machine";
describe("useExamStateMachine", () => {
it("初始状态应为 NotStarted", () => {
const { result } = renderHook(() => useExamStateMachine());
expect(result.current.state).toBe("NotStarted");
});
it("初始时 canSubmit 应为 false", () => {
const { result } = renderHook(() => useExamStateMachine());
expect(result.current.canSubmit).toBe(false);
});
describe("合法状态转移", () => {
it("start: NotStarted → InProgress", () => {
const { result } = renderHook(() => useExamStateMachine());
act(() => {
result.current.actions.start();
});
expect(result.current.state).toBe("InProgress");
expect(result.current.canSubmit).toBe(true);
});
it("beginAutoSave: InProgress → AutoSaving", () => {
const { result } = renderHook(() => useExamStateMachine());
act(() => {
result.current.actions.start();
result.current.actions.beginAutoSave();
});
expect(result.current.state).toBe("AutoSaving");
expect(result.current.canSubmit).toBe(true);
});
it("endAutoSave: AutoSaving → InProgress", () => {
const { result } = renderHook(() => useExamStateMachine());
act(() => {
result.current.actions.start();
result.current.actions.beginAutoSave();
result.current.actions.endAutoSave();
});
expect(result.current.state).toBe("InProgress");
});
it("beginSubmit: InProgress → Submitting", () => {
const { result } = renderHook(() => useExamStateMachine());
act(() => {
result.current.actions.start();
result.current.actions.beginSubmit();
});
expect(result.current.state).toBe("Submitting");
expect(result.current.canSubmit).toBe(false);
});
it("beginSubmit: AutoSaving → Submitting", () => {
const { result } = renderHook(() => useExamStateMachine());
act(() => {
result.current.actions.start();
result.current.actions.beginAutoSave();
result.current.actions.beginSubmit();
});
expect(result.current.state).toBe("Submitting");
});
it("completeSubmit: Submitting → Submitted", () => {
const { result } = renderHook(() => useExamStateMachine());
act(() => {
result.current.actions.start();
result.current.actions.beginSubmit();
result.current.actions.completeSubmit();
});
expect(result.current.state).toBe("Submitted");
expect(result.current.canSubmit).toBe(false);
});
it("完整流程: NotStarted → InProgress → AutoSaving → InProgress → Submitting → Submitted", () => {
const { result } = renderHook(() => useExamStateMachine());
act(() => result.current.actions.start());
expect(result.current.state).toBe("InProgress");
act(() => result.current.actions.beginAutoSave());
expect(result.current.state).toBe("AutoSaving");
act(() => result.current.actions.endAutoSave());
expect(result.current.state).toBe("InProgress");
act(() => result.current.actions.beginSubmit());
expect(result.current.state).toBe("Submitting");
act(() => result.current.actions.completeSubmit());
expect(result.current.state).toBe("Submitted");
});
});
describe("非法状态转移(应保持原状态)", () => {
it("NotStarted 时 beginAutoSave 应无效果", () => {
const { result } = renderHook(() => useExamStateMachine());
act(() => {
result.current.actions.beginAutoSave();
});
expect(result.current.state).toBe("NotStarted");
});
it("NotStarted 时 endAutoSave 应无效果", () => {
const { result } = renderHook(() => useExamStateMachine());
act(() => {
result.current.actions.endAutoSave();
});
expect(result.current.state).toBe("NotStarted");
});
it("NotStarted 时 beginSubmit 应无效果", () => {
const { result } = renderHook(() => useExamStateMachine());
act(() => {
result.current.actions.beginSubmit();
});
expect(result.current.state).toBe("NotStarted");
});
it("NotStarted 时 completeSubmit 应无效果", () => {
const { result } = renderHook(() => useExamStateMachine());
act(() => {
result.current.actions.completeSubmit();
});
expect(result.current.state).toBe("NotStarted");
});
it("InProgress 时 start 应无效果(不能重复 start", () => {
const { result } = renderHook(() => useExamStateMachine());
act(() => {
result.current.actions.start();
});
expect(result.current.state).toBe("InProgress");
act(() => {
result.current.actions.start();
});
expect(result.current.state).toBe("InProgress");
});
it("InProgress 时 endAutoSave 应无效果", () => {
const { result } = renderHook(() => useExamStateMachine());
act(() => {
result.current.actions.start();
result.current.actions.endAutoSave();
});
expect(result.current.state).toBe("InProgress");
});
it("InProgress 时 completeSubmit 应无效果", () => {
const { result } = renderHook(() => useExamStateMachine());
act(() => {
result.current.actions.start();
result.current.actions.completeSubmit();
});
expect(result.current.state).toBe("InProgress");
});
it("AutoSaving 时 start 应无效果", () => {
const { result } = renderHook(() => useExamStateMachine());
act(() => {
result.current.actions.start();
result.current.actions.beginAutoSave();
result.current.actions.start();
});
expect(result.current.state).toBe("AutoSaving");
});
it("AutoSaving 时 endAutoSave 后再 endAutoSave 应无效果", () => {
const { result } = renderHook(() => useExamStateMachine());
act(() => {
result.current.actions.start();
result.current.actions.beginAutoSave();
result.current.actions.endAutoSave();
result.current.actions.endAutoSave();
});
expect(result.current.state).toBe("InProgress");
});
it("Submitting 时 start 应无效果", () => {
const { result } = renderHook(() => useExamStateMachine());
act(() => {
result.current.actions.start();
result.current.actions.beginSubmit();
result.current.actions.start();
});
expect(result.current.state).toBe("Submitting");
});
it("Submitting 时 beginAutoSave 应无效果", () => {
const { result } = renderHook(() => useExamStateMachine());
act(() => {
result.current.actions.start();
result.current.actions.beginSubmit();
result.current.actions.beginAutoSave();
});
expect(result.current.state).toBe("Submitting");
});
it("Submitted 时 start 应无效果(终态)", () => {
const { result } = renderHook(() => useExamStateMachine());
act(() => {
result.current.actions.start();
result.current.actions.beginSubmit();
result.current.actions.completeSubmit();
result.current.actions.start();
});
expect(result.current.state).toBe("Submitted");
});
it("Submitted 时 beginSubmit 应无效果(终态)", () => {
const { result } = renderHook(() => useExamStateMachine());
act(() => {
result.current.actions.start();
result.current.actions.beginSubmit();
result.current.actions.completeSubmit();
result.current.actions.beginSubmit();
});
expect(result.current.state).toBe("Submitted");
});
});
describe("canSubmit 状态", () => {
it("NotStarted 时 canSubmit=false", () => {
const { result } = renderHook(() => useExamStateMachine());
expect(result.current.canSubmit).toBe(false);
});
it("InProgress 时 canSubmit=true", () => {
const { result } = renderHook(() => useExamStateMachine());
act(() => result.current.actions.start());
expect(result.current.canSubmit).toBe(true);
});
it("AutoSaving 时 canSubmit=true", () => {
const { result } = renderHook(() => useExamStateMachine());
act(() => {
result.current.actions.start();
result.current.actions.beginAutoSave();
});
expect(result.current.canSubmit).toBe(true);
});
it("Submitting 时 canSubmit=false", () => {
const { result } = renderHook(() => useExamStateMachine());
act(() => {
result.current.actions.start();
result.current.actions.beginSubmit();
});
expect(result.current.canSubmit).toBe(false);
});
it("Submitted 时 canSubmit=false", () => {
const { result } = renderHook(() => useExamStateMachine());
act(() => {
result.current.actions.start();
result.current.actions.beginSubmit();
result.current.actions.completeSubmit();
});
expect(result.current.canSubmit).toBe(false);
});
});
describe("actions 引用稳定性", () => {
it("actions 对象应在多次渲染间保持引用稳定", () => {
const { result, rerender } = renderHook(() => useExamStateMachine());
const firstActions = result.current.actions;
rerender();
expect(result.current.actions).toBe(firstActions);
});
});
});

View File

@@ -0,0 +1,239 @@
/**
* useSubmitDedup Hook 测试
*
* 测试提交去重逻辑:
* - markSubmitting 生成唯一幂等 key
* - markComplete 后可以再次 markSubmitting
* - lockRef 防止重复点击
*/
import { describe, it, expect, beforeEach, vi } from "vitest";
import { renderHook, act } from "@testing-library/react";
import { useSubmitDedup } from "./use-submit-dedup";
describe("useSubmitDedup", () => {
const examId = "exam-001";
beforeEach(() => {
vi.restoreAllMocks();
});
it("初始状态 isSubmitting 应为 false", () => {
const { result } = renderHook(() => useSubmitDedup(examId));
expect(result.current.isSubmitting).toBe(false);
});
it("初始状态 canSubmit 应为 true", () => {
const { result } = renderHook(() => useSubmitDedup(examId));
expect(result.current.canSubmit).toBe(true);
});
it("初始应生成 idempotencyKey", () => {
const { result } = renderHook(() => useSubmitDedup(examId));
expect(result.current.idempotencyKey).toBeTruthy();
expect(typeof result.current.idempotencyKey).toBe("string");
});
it("idempotencyKey 应包含 examId", () => {
const { result } = renderHook(() => useSubmitDedup(examId));
expect(result.current.idempotencyKey).toContain(`exam-${examId}`);
});
describe("markSubmitting", () => {
it("markSubmitting 后 isSubmitting 应为 true", () => {
const { result } = renderHook(() => useSubmitDedup(examId));
act(() => {
result.current.markSubmitting();
});
expect(result.current.isSubmitting).toBe(true);
});
it("markSubmitting 后 canSubmit 应为 false", () => {
const { result } = renderHook(() => useSubmitDedup(examId));
act(() => {
result.current.markSubmitting();
});
expect(result.current.canSubmit).toBe(false);
});
it("markSubmitting 后应生成新的 idempotencyKey", () => {
const { result } = renderHook(() => useSubmitDedup(examId));
const initialKey = result.current.idempotencyKey;
act(() => {
result.current.markSubmitting();
});
expect(result.current.idempotencyKey).not.toBe(initialKey);
});
it("重复调用 markSubmitting 不应改变状态lockRef 保护)", () => {
const { result } = renderHook(() => useSubmitDedup(examId));
act(() => {
result.current.markSubmitting();
});
const keyAfterFirst = result.current.idempotencyKey;
act(() => {
result.current.markSubmitting();
});
// key 不应变lockRef 阻止了第二次)
expect(result.current.idempotencyKey).toBe(keyAfterFirst);
expect(result.current.isSubmitting).toBe(true);
});
});
describe("markComplete", () => {
it("markComplete 后 isSubmitting 应为 false", () => {
const { result } = renderHook(() => useSubmitDedup(examId));
act(() => {
result.current.markSubmitting();
result.current.markComplete();
});
expect(result.current.isSubmitting).toBe(false);
});
it("markComplete 后 canSubmit 应为 true", () => {
const { result } = renderHook(() => useSubmitDedup(examId));
act(() => {
result.current.markSubmitting();
result.current.markComplete();
});
expect(result.current.canSubmit).toBe(true);
});
it("markComplete 后应生成新的 idempotencyKey", () => {
const { result } = renderHook(() => useSubmitDedup(examId));
act(() => {
result.current.markSubmitting();
});
const keyAfterSubmit = result.current.idempotencyKey;
act(() => {
result.current.markComplete();
});
expect(result.current.idempotencyKey).not.toBe(keyAfterSubmit);
});
it("markComplete 后可以再次 markSubmitting", () => {
const { result } = renderHook(() => useSubmitDedup(examId));
// 第一次提交
act(() => {
result.current.markSubmitting();
});
expect(result.current.isSubmitting).toBe(true);
// 完成
act(() => {
result.current.markComplete();
});
expect(result.current.isSubmitting).toBe(false);
expect(result.current.canSubmit).toBe(true);
// 第二次提交
act(() => {
result.current.markSubmitting();
});
expect(result.current.isSubmitting).toBe(true);
expect(result.current.canSubmit).toBe(false);
});
});
describe("幂等 key 唯一性", () => {
it("不同 Hook 实例应生成不同的初始 key", () => {
const { result: r1 } = renderHook(() => useSubmitDedup(examId));
const { result: r2 } = renderHook(() => useSubmitDedup(examId));
expect(r1.current.idempotencyKey).not.toBe(r2.current.idempotencyKey);
});
it("完整提交周期应生成 3 个不同的 key初始 → submitting → complete", () => {
const { result } = renderHook(() => useSubmitDedup(examId));
const keys: string[] = [result.current.idempotencyKey];
act(() => {
result.current.markSubmitting();
});
keys.push(result.current.idempotencyKey);
act(() => {
result.current.markComplete();
});
keys.push(result.current.idempotencyKey);
// 三个 key 应互不相同
expect(new Set(keys).size).toBe(3);
});
it("多次提交周期每次都应生成不同的 key", () => {
const { result } = renderHook(() => useSubmitDedup(examId));
const keys: string[] = [];
for (let i = 0; i < 3; i++) {
act(() => {
result.current.markSubmitting();
});
keys.push(result.current.idempotencyKey);
act(() => {
result.current.markComplete();
});
}
// 每次提交的 key 应唯一
expect(new Set(keys).size).toBe(keys.length);
});
});
describe("重复点击防护", () => {
it("快速连续点击 markSubmitting 只应生效一次", () => {
const { result } = renderHook(() => useSubmitDedup(examId));
act(() => {
result.current.markSubmitting();
result.current.markSubmitting();
result.current.markSubmitting();
});
expect(result.current.isSubmitting).toBe(true);
// canSubmit 仍为 false已被锁定
expect(result.current.canSubmit).toBe(false);
});
it("markComplete 后 lockRef 解锁,可以再次提交", () => {
const { result } = renderHook(() => useSubmitDedup(examId));
// 第一次提交周期
act(() => {
result.current.markSubmitting();
result.current.markSubmitting(); // 重复点击
});
expect(result.current.canSubmit).toBe(false);
act(() => {
result.current.markComplete();
});
expect(result.current.canSubmit).toBe(true);
// 第二次提交周期
act(() => {
result.current.markSubmitting();
});
expect(result.current.isSubmitting).toBe(true);
expect(result.current.canSubmit).toBe(false);
});
});
describe("examId 变化", () => {
it("examId 变化后 markSubmitting 应使用新 examId 生成 key", () => {
const { result, rerender } = renderHook(
({ id }) => useSubmitDedup(id),
{ initialProps: { id: "exam-001" } },
);
rerender({ id: "exam-002" });
act(() => {
result.current.markSubmitting();
});
expect(result.current.idempotencyKey).toContain("exam-exam-002");
});
});
});

View File

@@ -0,0 +1,119 @@
/**
* exam-types 工具函数测试
*
* 测试 buildDraftKey / getStudentId / cn 等工具函数。
*/
import { describe, it, expect, beforeEach } from "vitest";
import { buildDraftKey, getStudentId, cn } from "./exam-types";
describe("buildDraftKey", () => {
it("应返回 exam-draft:examId:studentId 格式", () => {
const key = buildDraftKey("exam-001", "student-001");
expect(key).toBe("exam-draft:exam-001:student-001");
});
it("应包含 examId 和 studentId", () => {
const examId = "midterm-math";
const studentId = "user-abc";
const key = buildDraftKey(examId, studentId);
expect(key).toContain(examId);
expect(key).toContain(studentId);
});
it("不同参数应生成不同 key", () => {
const key1 = buildDraftKey("exam-001", "student-001");
const key2 = buildDraftKey("exam-002", "student-001");
const key3 = buildDraftKey("exam-001", "student-002");
expect(key1).not.toBe(key2);
expect(key1).not.toBe(key3);
expect(key2).not.toBe(key3);
});
it("应以 exam-draft: 前缀开头", () => {
const key = buildDraftKey("exam-001", "student-001");
expect(key.startsWith("exam-draft:")).toBe(true);
});
});
describe("getStudentId", () => {
beforeEach(() => {
localStorage.clear();
});
it("localStorage 为空时应返回默认值 current-student", () => {
const id = getStudentId();
expect(id).toBe("current-student");
});
it("localStorage 有 edu-user 且含 id 时应返回该 id", () => {
localStorage.setItem(
"edu-user",
JSON.stringify({ id: "student-xyz", name: "张三" }),
);
const id = getStudentId();
expect(id).toBe("student-xyz");
});
it("localStorage 有 edu-user 但无 id 时应返回默认值", () => {
localStorage.setItem(
"edu-user",
JSON.stringify({ name: "张三" }),
);
const id = getStudentId();
expect(id).toBe("current-student");
});
it("localStorage edu-user 为无效 JSON 时应返回默认值", () => {
localStorage.setItem("edu-user", "not-valid-json{");
const id = getStudentId();
expect(id).toBe("current-student");
});
it("localStorage edu-user 为 null JSON 时应返回默认值", () => {
localStorage.setItem("edu-user", "null");
const id = getStudentId();
expect(id).toBe("current-student");
});
});
describe("cn", () => {
it("应拼接多个字符串类名", () => {
expect(cn("a", "b", "c")).toBe("a b c");
});
it("应过滤 false 值", () => {
expect(cn("a", false, "b")).toBe("a b");
});
it("应过滤 null 值", () => {
expect(cn("a", null, "b")).toBe("a b");
});
it("应过滤 undefined 值", () => {
expect(cn("a", undefined, "b")).toBe("a b");
});
it("应过滤空字符串", () => {
expect(cn("a", "", "b")).toBe("a b");
});
it("全部为 falsy 时应返回空字符串", () => {
expect(cn(false, null, undefined, "")).toBe("");
});
it("无参数时应返回空字符串", () => {
expect(cn()).toBe("");
});
it("单个类名应原样返回", () => {
expect(cn("only-class")).toBe("only-class");
});
it("条件类名场景", () => {
const isActive = true;
const isDisabled = false;
const result = cn("btn", isActive && "btn-active", isDisabled && "btn-disabled");
expect(result).toBe("btn btn-active");
});
});

View File

@@ -0,0 +1,224 @@
/**
* GraphQL 操作字符串测试
*
* 验证每个 gql 操作字符串包含正确的 operationName 和 query/mutation 关键字。
* 操作名需与 MSW handlers.ts 的 operationName 分发对齐。
*/
import { describe, it, expect } from "vitest";
import {
// 查询操作
CURRENT_USER_QUERY,
MY_CLASSES_QUERY,
MY_EXAMS_QUERY,
EXAM_DETAIL_QUERY,
MY_HOMEWORK_QUERY,
HOMEWORK_DETAIL_QUERY,
MY_GRADES_QUERY,
MY_ATTENDANCE_QUERY,
TEXTBOOKS_QUERY,
CHAPTERS_QUERY,
LEARNING_PATH_QUERY,
STUDENT_DASHBOARD_QUERY,
MY_WEAKNESS_QUERY,
MY_TREND_QUERY,
MY_NOTIFICATIONS_QUERY,
SERVER_TIME_QUERY,
// 扩展查询
MY_SCHEDULE_QUERY,
STUDENT_GROWTH_QUERY,
ASSIGNMENT_ANALYSIS_QUERY,
MY_PROFILE_QUERY,
// 批次 A 扩展查询
MY_ERROR_BOOK_QUERY,
MY_LEAVE_REQUESTS_QUERY,
MY_ELECTIVE_SELECTIONS_QUERY,
AVAILABLE_ELECTIVE_COURSES_QUERY,
MY_PRACTICE_SESSIONS_QUERY,
START_PRACTICE_SESSION_QUERY,
ANNOUNCEMENTS_QUERY,
ANNOUNCEMENT_DETAIL_QUERY,
MY_LESSON_PLANS_QUERY,
LESSON_PLAN_DETAIL_QUERY,
MY_COURSE_PLANS_QUERY,
COURSE_PLAN_DETAIL_QUERY,
MY_REPORT_CARD_QUERY,
MY_DIAGNOSTIC_REPORTS_QUERY,
MY_MASTERY_SUMMARY_QUERY,
// 变更操作
SUBMIT_HOMEWORK_MUTATION,
SUBMIT_EXAM_MUTATION,
SAVE_EXAM_DRAFT_MUTATION,
MARK_AS_READ_MUTATION,
MARK_ALL_AS_READ_MUTATION,
RECORD_EXAM_VIOLATION_MUTATION,
RECORD_PASTE_EVENT_MUTATION,
UPDATE_NOTIFICATION_PREFERENCE_MUTATION,
// 扩展变更
UPDATE_PROFILE_MUTATION,
CHANGE_PASSWORD_MUTATION,
REQUEST_EXTENSION_MUTATION,
JOIN_CLASS_MUTATION,
LEAVE_CLASS_MUTATION,
// 批次 A 扩展变更
ADD_ERROR_BOOK_ITEM_MUTATION,
UPDATE_ERROR_BOOK_ITEM_MUTATION,
DELETE_ERROR_BOOK_ITEM_MUTATION,
CREATE_LEAVE_REQUEST_MUTATION,
CANCEL_LEAVE_REQUEST_MUTATION,
SELECT_ELECTIVE_COURSE_MUTATION,
DROP_ELECTIVE_COURSE_MUTATION,
SUBMIT_PRACTICE_ANSWER_MUTATION,
MARK_ANNOUNCEMENT_READ_MUTATION,
} from "./operations";
/** 将 gql DocumentNode 转为字符串以检查内容 */
function asString(node: unknown): string {
if (node === null || node === undefined) return "";
if (typeof node === "string") return node;
if (typeof node === "object" && node !== null) {
const obj = node as Record<string, unknown>;
if (obj.loc && typeof obj.loc === "object" && obj.loc !== null) {
const loc = obj.loc as Record<string, unknown>;
if (typeof loc.source === "object" && loc.source !== null) {
const source = loc.source as Record<string, unknown>;
if (typeof source.body === "string") return source.body;
}
if (typeof loc.body === "string") return loc.body;
}
if (typeof obj.toString === "function") {
const str = obj.toString();
if (str !== "[object Object]") return str;
}
}
return String(node);
}
describe("GraphQL operations", () => {
const queries: Array<[string, unknown, string]> = [
["CURRENT_USER_QUERY", CURRENT_USER_QUERY, "currentUser"],
["MY_CLASSES_QUERY", MY_CLASSES_QUERY, "myClasses"],
["MY_EXAMS_QUERY", MY_EXAMS_QUERY, "myExams"],
["EXAM_DETAIL_QUERY", EXAM_DETAIL_QUERY, "examDetail"],
["MY_HOMEWORK_QUERY", MY_HOMEWORK_QUERY, "myHomework"],
["HOMEWORK_DETAIL_QUERY", HOMEWORK_DETAIL_QUERY, "homeworkDetail"],
["MY_GRADES_QUERY", MY_GRADES_QUERY, "myGrades"],
["MY_ATTENDANCE_QUERY", MY_ATTENDANCE_QUERY, "myAttendance"],
["TEXTBOOKS_QUERY", TEXTBOOKS_QUERY, "textbooks"],
["CHAPTERS_QUERY", CHAPTERS_QUERY, "chapters"],
["LEARNING_PATH_QUERY", LEARNING_PATH_QUERY, "learningPath"],
["STUDENT_DASHBOARD_QUERY", STUDENT_DASHBOARD_QUERY, "studentDashboard"],
["MY_WEAKNESS_QUERY", MY_WEAKNESS_QUERY, "myWeakness"],
["MY_TREND_QUERY", MY_TREND_QUERY, "myTrend"],
["MY_NOTIFICATIONS_QUERY", MY_NOTIFICATIONS_QUERY, "myNotifications"],
["SERVER_TIME_QUERY", SERVER_TIME_QUERY, "serverTime"],
["MY_SCHEDULE_QUERY", MY_SCHEDULE_QUERY, "mySchedule"],
["STUDENT_GROWTH_QUERY", STUDENT_GROWTH_QUERY, "studentGrowth"],
["ASSIGNMENT_ANALYSIS_QUERY", ASSIGNMENT_ANALYSIS_QUERY, "assignmentAnalysis"],
["MY_PROFILE_QUERY", MY_PROFILE_QUERY, "myProfile"],
["MY_ERROR_BOOK_QUERY", MY_ERROR_BOOK_QUERY, "myErrorBook"],
["MY_LEAVE_REQUESTS_QUERY", MY_LEAVE_REQUESTS_QUERY, "myLeaveRequests"],
["MY_ELECTIVE_SELECTIONS_QUERY", MY_ELECTIVE_SELECTIONS_QUERY, "myElectiveSelections"],
["AVAILABLE_ELECTIVE_COURSES_QUERY", AVAILABLE_ELECTIVE_COURSES_QUERY, "availableElectiveCourses"],
["MY_PRACTICE_SESSIONS_QUERY", MY_PRACTICE_SESSIONS_QUERY, "myPracticeSessions"],
["START_PRACTICE_SESSION_QUERY", START_PRACTICE_SESSION_QUERY, "startPracticeSession"],
["ANNOUNCEMENTS_QUERY", ANNOUNCEMENTS_QUERY, "announcements"],
["ANNOUNCEMENT_DETAIL_QUERY", ANNOUNCEMENT_DETAIL_QUERY, "announcementDetail"],
["MY_LESSON_PLANS_QUERY", MY_LESSON_PLANS_QUERY, "myLessonPlans"],
["LESSON_PLAN_DETAIL_QUERY", LESSON_PLAN_DETAIL_QUERY, "lessonPlanDetail"],
["MY_COURSE_PLANS_QUERY", MY_COURSE_PLANS_QUERY, "myCoursePlans"],
["COURSE_PLAN_DETAIL_QUERY", COURSE_PLAN_DETAIL_QUERY, "coursePlanDetail"],
["MY_REPORT_CARD_QUERY", MY_REPORT_CARD_QUERY, "myReportCard"],
["MY_DIAGNOSTIC_REPORTS_QUERY", MY_DIAGNOSTIC_REPORTS_QUERY, "myDiagnosticReports"],
["MY_MASTERY_SUMMARY_QUERY", MY_MASTERY_SUMMARY_QUERY, "myMasterySummary"],
];
const mutations: Array<[string, unknown, string]> = [
["SUBMIT_HOMEWORK_MUTATION", SUBMIT_HOMEWORK_MUTATION, "submitHomework"],
["SUBMIT_EXAM_MUTATION", SUBMIT_EXAM_MUTATION, "submitExam"],
["SAVE_EXAM_DRAFT_MUTATION", SAVE_EXAM_DRAFT_MUTATION, "saveExamDraft"],
["MARK_AS_READ_MUTATION", MARK_AS_READ_MUTATION, "markAsRead"],
["MARK_ALL_AS_READ_MUTATION", MARK_ALL_AS_READ_MUTATION, "markAllAsRead"],
["RECORD_EXAM_VIOLATION_MUTATION", RECORD_EXAM_VIOLATION_MUTATION, "recordExamViolation"],
["RECORD_PASTE_EVENT_MUTATION", RECORD_PASTE_EVENT_MUTATION, "recordPasteEvent"],
["UPDATE_NOTIFICATION_PREFERENCE_MUTATION", UPDATE_NOTIFICATION_PREFERENCE_MUTATION, "updateNotificationPreference"],
["UPDATE_PROFILE_MUTATION", UPDATE_PROFILE_MUTATION, "updateProfile"],
["CHANGE_PASSWORD_MUTATION", CHANGE_PASSWORD_MUTATION, "changePassword"],
["REQUEST_EXTENSION_MUTATION", REQUEST_EXTENSION_MUTATION, "requestExtension"],
["JOIN_CLASS_MUTATION", JOIN_CLASS_MUTATION, "joinClass"],
["LEAVE_CLASS_MUTATION", LEAVE_CLASS_MUTATION, "leaveClass"],
["ADD_ERROR_BOOK_ITEM_MUTATION", ADD_ERROR_BOOK_ITEM_MUTATION, "addErrorBookItem"],
["UPDATE_ERROR_BOOK_ITEM_MUTATION", UPDATE_ERROR_BOOK_ITEM_MUTATION, "updateErrorBookItem"],
["DELETE_ERROR_BOOK_ITEM_MUTATION", DELETE_ERROR_BOOK_ITEM_MUTATION, "deleteErrorBookItem"],
["CREATE_LEAVE_REQUEST_MUTATION", CREATE_LEAVE_REQUEST_MUTATION, "createLeaveRequest"],
["CANCEL_LEAVE_REQUEST_MUTATION", CANCEL_LEAVE_REQUEST_MUTATION, "cancelLeaveRequest"],
["SELECT_ELECTIVE_COURSE_MUTATION", SELECT_ELECTIVE_COURSE_MUTATION, "selectElectiveCourse"],
["DROP_ELECTIVE_COURSE_MUTATION", DROP_ELECTIVE_COURSE_MUTATION, "dropElectiveCourse"],
["SUBMIT_PRACTICE_ANSWER_MUTATION", SUBMIT_PRACTICE_ANSWER_MUTATION, "submitPracticeAnswer"],
["MARK_ANNOUNCEMENT_READ_MUTATION", MARK_ANNOUNCEMENT_READ_MUTATION, "markAnnouncementRead"],
];
describe.each(queries)("查询 %s", (exportName, operation, operationName) => {
it("应包含 query 关键字", () => {
const text = asString(operation);
expect(text).toContain("query");
});
it("应包含正确的 operationName", () => {
const text = asString(operation);
expect(text).toContain(`query ${operationName}`);
});
it("不应包含 mutation 关键字", () => {
const text = asString(operation);
expect(text).not.toContain("mutation");
});
it("应为非空字符串", () => {
const text = asString(operation);
expect(text.trim().length).toBeGreaterThan(0);
expect(exportName).toBeDefined();
});
});
describe.each(mutations)("变更 %s", (exportName, operation, operationName) => {
it("应包含 mutation 关键字", () => {
const text = asString(operation);
expect(text).toContain("mutation");
});
it("应包含正确的 operationName", () => {
const text = asString(operation);
expect(text).toContain(`mutation ${operationName}`);
});
it("不应包含 query 关键字作为操作类型", () => {
const text = asString(operation);
// mutation 字符串中不应出现 "query xxx {" 形式
expect(text).not.toMatch(/^query\s+\w+/);
});
it("应为非空字符串", () => {
const text = asString(operation);
expect(text.trim().length).toBeGreaterThan(0);
expect(exportName).toBeDefined();
});
});
it("查询操作数量应为 35", () => {
expect(queries).toHaveLength(35);
});
it("变更操作数量应为 22", () => {
expect(mutations).toHaveLength(22);
});
it("所有操作名应唯一", () => {
const allNames = [
...queries.map(([, , name]) => name),
...mutations.map(([, , name]) => name),
];
const unique = new Set(allNames);
expect(unique.size).toBe(allNames.length);
});
});

View File

@@ -1,7 +1,7 @@
/**
* GraphQL 操作字符串定义ai14
*
* 使用 urql 的 gql tag 定义所有 24 个 GraphQL 操作(16 查询 + 8 变更)。
* 使用 urql 的 gql tag 定义所有 57 个 GraphQL 操作(35 查询 + 22 变更)。
* 操作名与 MSW handlers.ts 的 operationName 分发对齐。
*
* 所有操作的响应均包裹在 ActionState 信封中:
@@ -13,7 +13,7 @@
import { gql } from "urql";
// ===========================================================================
// 查询操作(16 个)
// 查询操作(35 个)
// ===========================================================================
/** 当前学生信息(含权限 + 视口 + 数据范围) */
@@ -539,7 +539,7 @@ export const SERVER_TIME_QUERY = gql`
`;
// ===========================================================================
// 变更操作(8 个)
// 变更操作(22 个)
// ===========================================================================
/** 提交作业 */

View File

@@ -0,0 +1,149 @@
/**
* GraphQL 类型定义与工具函数测试
*
* 测试 types.ts 中的常量、错误码映射和工具函数。
*/
import { describe, it, expect } from "vitest";
import {
ERROR_CODE_PREFIX,
ERROR_CODES,
ERROR_I18N_PREFIX,
getErrorI18nPrefix,
} from "./types";
describe("ERROR_CODE_PREFIX", () => {
it("应包含所有错误码前缀", () => {
expect(ERROR_CODE_PREFIX.IAM).toBe("IAM_");
expect(ERROR_CODE_PREFIX.CORE_EDU).toBe("CORE_EDU_");
expect(ERROR_CODE_PREFIX.BFF_STUDENT).toBe("BFF_STUDENT_");
expect(ERROR_CODE_PREFIX.GW).toBe("GW_");
expect(ERROR_CODE_PREFIX.NETWORK).toBe("NETWORK_");
});
it("应为只读常量对象", () => {
expect(typeof ERROR_CODE_PREFIX).toBe("object");
expect(Object.keys(ERROR_CODE_PREFIX)).toHaveLength(5);
});
});
describe("ERROR_CODES", () => {
it("应包含 BFF_STUDENT 错误码", () => {
expect(ERROR_CODES.BFF_STUDENT_UPSTREAM_UNAVAILABLE).toBe(
"BFF_STUDENT_UPSTREAM_UNAVAILABLE",
);
expect(ERROR_CODES.BFF_STUDENT_FORBIDDEN).toBe("BFF_STUDENT_FORBIDDEN");
expect(ERROR_CODES.BFF_STUDENT_NOT_FOUND).toBe("BFF_STUDENT_NOT_FOUND");
});
it("应包含 CORE_EDU 错误码", () => {
expect(ERROR_CODES.CORE_EDU_EXAM_NOT_FOUND).toBe("CORE_EDU_EXAM_NOT_FOUND");
expect(ERROR_CODES.CORE_EDU_HOMEWORK_ALREADY_SUBMITTED).toBe(
"CORE_EDU_HOMEWORK_ALREADY_SUBMITTED",
);
expect(ERROR_CODES.CORE_EDU_EXAM_ALREADY_SUBMITTED).toBe(
"CORE_EDU_EXAM_ALREADY_SUBMITTED",
);
});
it("应包含 IAM 错误码", () => {
expect(ERROR_CODES.IAM_UNAUTHORIZED).toBe("IAM_UNAUTHORIZED");
expect(ERROR_CODES.IAM_FORBIDDEN).toBe("IAM_FORBIDDEN");
});
it("应包含 GW 错误码", () => {
expect(ERROR_CODES.GW_RATE_LIMITED).toBe("GW_RATE_LIMITED");
expect(ERROR_CODES.GW_CIRCUIT_OPEN).toBe("GW_CIRCUIT_OPEN");
});
it("应包含 NETWORK 错误码", () => {
expect(ERROR_CODES.NETWORK_TIMEOUT).toBe("NETWORK_TIMEOUT");
expect(ERROR_CODES.NETWORK_OFFLINE).toBe("NETWORK_OFFLINE");
});
it("所有错误码应与其前缀匹配", () => {
const bffCodes = [
ERROR_CODES.BFF_STUDENT_UPSTREAM_UNAVAILABLE,
ERROR_CODES.BFF_STUDENT_AGGREGATION_FAILED,
ERROR_CODES.BFF_STUDENT_FORBIDDEN,
ERROR_CODES.BFF_STUDENT_NOT_FOUND,
ERROR_CODES.BFF_STUDENT_VALIDATION_FAILED,
];
bffCodes.forEach((code) => {
expect(code.startsWith(ERROR_CODE_PREFIX.BFF_STUDENT)).toBe(true);
});
const coreEduCodes = [
ERROR_CODES.CORE_EDU_EXAM_NOT_FOUND,
ERROR_CODES.CORE_EDU_EXAM_NOT_AVAILABLE,
ERROR_CODES.CORE_EDU_HOMEWORK_NOT_FOUND,
];
coreEduCodes.forEach((code) => {
expect(code.startsWith(ERROR_CODE_PREFIX.CORE_EDU)).toBe(true);
});
});
});
describe("ERROR_I18N_PREFIX", () => {
it("应将 IAM 前缀映射到 iam.error", () => {
expect(ERROR_I18N_PREFIX[ERROR_CODE_PREFIX.IAM]).toBe("iam.error");
});
it("应将 CORE_EDU 前缀映射到 coreEdu.error", () => {
expect(ERROR_I18N_PREFIX[ERROR_CODE_PREFIX.CORE_EDU]).toBe("coreEdu.error");
});
it("应将 BFF_STUDENT 前缀映射到 bffStudent.error", () => {
expect(ERROR_I18N_PREFIX[ERROR_CODE_PREFIX.BFF_STUDENT]).toBe(
"bffStudent.error",
);
});
it("应将 GW 前缀映射到 gateway.error", () => {
expect(ERROR_I18N_PREFIX[ERROR_CODE_PREFIX.GW]).toBe("gateway.error");
});
it("应将 NETWORK 前缀映射到 network.error", () => {
expect(ERROR_I18N_PREFIX[ERROR_CODE_PREFIX.NETWORK]).toBe("network.error");
});
});
describe("getErrorI18nPrefix", () => {
it("应为 IAM_ 前缀错误码返回 iam.error", () => {
expect(getErrorI18nPrefix("IAM_UNAUTHORIZED")).toBe("iam.error");
expect(getErrorI18nPrefix("IAM_FORBIDDEN")).toBe("iam.error");
});
it("应为 CORE_EDU_ 前缀错误码返回 coreEdu.error", () => {
expect(getErrorI18nPrefix("CORE_EDU_EXAM_NOT_FOUND")).toBe("coreEdu.error");
expect(getErrorI18nPrefix("CORE_EDU_HOMEWORK_NOT_FOUND")).toBe(
"coreEdu.error",
);
});
it("应为 BFF_STUDENT_ 前缀错误码返回 bffStudent.error", () => {
expect(getErrorI18nPrefix("BFF_STUDENT_UPSTREAM_UNAVAILABLE")).toBe(
"bffStudent.error",
);
expect(getErrorI18nPrefix("BFF_STUDENT_FORBIDDEN")).toBe("bffStudent.error");
});
it("应为 GW_ 前缀错误码返回 gateway.error", () => {
expect(getErrorI18nPrefix("GW_RATE_LIMITED")).toBe("gateway.error");
expect(getErrorI18nPrefix("GW_CIRCUIT_OPEN")).toBe("gateway.error");
});
it("应为 NETWORK_ 前缀错误码返回 network.error", () => {
expect(getErrorI18nPrefix("NETWORK_TIMEOUT")).toBe("network.error");
expect(getErrorI18nPrefix("NETWORK_OFFLINE")).toBe("network.error");
});
it("应为未知前缀错误码返回 common.error", () => {
expect(getErrorI18nPrefix("UNKNOWN_ERROR")).toBe("common.error");
expect(getErrorI18nPrefix("")).toBe("common.error");
});
it("应为空字符串返回 common.error", () => {
expect(getErrorI18nPrefix("")).toBe("common.error");
});
});

View File

@@ -0,0 +1,229 @@
/**
* Mock Fixture JSON 文件测试
*
* 验证每个 fixture 文件存在且为有效 JSON。
* Fixtures 存储原始业务数据(非 ActionState 信封),由 handlers.ts 包裹为 ActionState。
*/
import { describe, it, expect } from "vitest";
import currentUser from "./fixtures/currentUser.json";
import myClasses from "./fixtures/myClasses.json";
import myExams from "./fixtures/myExams.json";
import examDetail from "./fixtures/examDetail.json";
import myHomework from "./fixtures/myHomework.json";
import homeworkDetail from "./fixtures/homeworkDetail.json";
import myGrades from "./fixtures/myGrades.json";
import myAttendance from "./fixtures/myAttendance.json";
import textbooks from "./fixtures/textbooks.json";
import chapters from "./fixtures/chapters.json";
import learningPath from "./fixtures/learningPath.json";
import studentDashboard from "./fixtures/studentDashboard.json";
import myWeakness from "./fixtures/myWeakness.json";
import myTrend from "./fixtures/myTrend.json";
import myNotifications from "./fixtures/myNotifications.json";
import serverTime from "./fixtures/serverTime.json";
import mySchedule from "./fixtures/mySchedule.json";
import studentGrowth from "./fixtures/studentGrowth.json";
import assignmentAnalysis from "./fixtures/assignmentAnalysis.json";
import myProfile from "./fixtures/myProfile.json";
import errorBook from "./fixtures/errorBook.json";
import leaveRequests from "./fixtures/leaveRequests.json";
import electiveCourses from "./fixtures/electiveCourses.json";
import practiceSessions from "./fixtures/practiceSessions.json";
import announcements from "./fixtures/announcements.json";
import lessonPlans from "./fixtures/lessonPlans.json";
import coursePlans from "./fixtures/coursePlans.json";
import reportCard from "./fixtures/reportCard.json";
import diagnosticReports from "./fixtures/diagnosticReports.json";
import masterySummary from "./fixtures/masterySummary.json";
/** 检查值为对象(非数组、非 null */
function isObject(v: unknown): v is Record<string, unknown> {
return typeof v === "object" && v !== null && !Array.isArray(v);
}
/** 检查值为数组 */
function isArray(v: unknown): v is unknown[] {
return Array.isArray(v);
}
describe("fixture 文件有效性", () => {
const fixtures: Array<[string, unknown]> = [
["currentUser", currentUser],
["myClasses", myClasses],
["myExams", myExams],
["examDetail", examDetail],
["myHomework", myHomework],
["homeworkDetail", homeworkDetail],
["myGrades", myGrades],
["myAttendance", myAttendance],
["textbooks", textbooks],
["chapters", chapters],
["learningPath", learningPath],
["studentDashboard", studentDashboard],
["myWeakness", myWeakness],
["myTrend", myTrend],
["myNotifications", myNotifications],
["serverTime", serverTime],
["mySchedule", mySchedule],
["studentGrowth", studentGrowth],
["assignmentAnalysis", assignmentAnalysis],
["myProfile", myProfile],
["errorBook", errorBook],
["leaveRequests", leaveRequests],
["electiveCourses", electiveCourses],
["practiceSessions", practiceSessions],
["announcements", announcements],
["lessonPlans", lessonPlans],
["coursePlans", coursePlans],
["reportCard", reportCard],
["diagnosticReports", diagnosticReports],
["masterySummary", masterySummary],
];
it("应加载 30 个 fixture 文件", () => {
expect(fixtures).toHaveLength(30);
});
it.each(fixtures)("fixture %s 应为有效的 JSON 对象或数组", (_name, data) => {
expect(data).toBeDefined();
expect(data).not.toBeNull();
expect(typeof data === "object").toBe(true);
});
});
describe("currentUser fixture", () => {
it("应包含学生信息字段", () => {
expect(isObject(currentUser)).toBe(true);
expect(currentUser).toHaveProperty("id");
expect(currentUser).toHaveProperty("name");
expect(currentUser).toHaveProperty("studentNo");
expect(currentUser).toHaveProperty("roles");
expect(currentUser).toHaveProperty("permissions");
expect(currentUser).toHaveProperty("dataScope");
});
it("roles 应为数组", () => {
expect(isObject(currentUser)).toBe(true);
expect(isArray(currentUser.roles)).toBe(true);
});
it("permissions 应为数组", () => {
expect(isObject(currentUser)).toBe(true);
expect(isArray(currentUser.permissions)).toBe(true);
});
it("dataScope 应为字符串", () => {
expect(isObject(currentUser)).toBe(true);
expect(typeof currentUser.dataScope).toBe("string");
});
});
describe("serverTime fixture", () => {
it("应包含 timestamp 和 offset 字段", () => {
expect(isObject(serverTime)).toBe(true);
expect(serverTime).toHaveProperty("timestamp");
expect(serverTime).toHaveProperty("offset");
expect(typeof serverTime.timestamp).toBe("string");
expect(typeof serverTime.offset).toBe("number");
});
});
describe("myClasses fixture", () => {
it("应为数组", () => {
expect(isArray(myClasses)).toBe(true);
});
it("每个班级应包含 id 和 name", () => {
expect(isArray(myClasses)).toBe(true);
if (myClasses.length > 0) {
const first = myClasses[0];
expect(isObject(first)).toBe(true);
expect(first).toHaveProperty("id");
expect(first).toHaveProperty("name");
}
});
});
describe("examDetail fixture", () => {
it("应包含考试详情字段", () => {
expect(isObject(examDetail)).toBe(true);
expect(examDetail).toHaveProperty("id");
expect(examDetail).toHaveProperty("name");
expect(examDetail).toHaveProperty("questions");
expect(examDetail).toHaveProperty("durationSeconds");
});
it("questions 应为数组", () => {
expect(isObject(examDetail)).toBe(true);
expect(isArray(examDetail.questions)).toBe(true);
});
});
describe("studentDashboard fixture", () => {
it("应包含仪表盘聚合字段", () => {
expect(isObject(studentDashboard)).toBe(true);
expect(studentDashboard).toHaveProperty("upcomingHomework");
expect(studentDashboard).toHaveProperty("upcomingExams");
expect(studentDashboard).toHaveProperty("recentGrades");
expect(studentDashboard).toHaveProperty("attendanceRate");
expect(studentDashboard).toHaveProperty("learningStreakDays");
expect(studentDashboard).toHaveProperty("avgScore");
expect(studentDashboard).toHaveProperty("classRank");
});
});
describe("myNotifications fixture", () => {
it("应为通知数组", () => {
expect(isArray(myNotifications)).toBe(true);
expect(myNotifications.length).toBeGreaterThan(0);
});
it("每个通知应包含 id / type / title / content / read / createdAt", () => {
expect(isArray(myNotifications)).toBe(true);
const first = (myNotifications as unknown[])[0];
expect(isObject(first)).toBe(true);
expect(first).toHaveProperty("id");
expect(first).toHaveProperty("type");
expect(first).toHaveProperty("title");
expect(first).toHaveProperty("content");
expect(first).toHaveProperty("read");
expect(first).toHaveProperty("createdAt");
});
});
describe("errorBook fixture", () => {
it("应包含 items 和 stats", () => {
expect(isObject(errorBook)).toBe(true);
expect(errorBook).toHaveProperty("items");
expect(errorBook).toHaveProperty("stats");
});
});
describe("practiceSessions fixture", () => {
it("应包含 sessions 和 stats", () => {
expect(isObject(practiceSessions)).toBe(true);
expect(practiceSessions).toHaveProperty("sessions");
expect(practiceSessions).toHaveProperty("stats");
});
});
describe("reportCard fixture", () => {
it("应包含成绩报告卡字段", () => {
expect(isObject(reportCard)).toBe(true);
expect(reportCard).toHaveProperty("studentId");
expect(reportCard).toHaveProperty("studentName");
expect(reportCard).toHaveProperty("subjects");
expect(reportCard).toHaveProperty("overallAverage");
expect(reportCard).toHaveProperty("overallRank");
});
});
describe("masterySummary fixture", () => {
it("应包含掌握度摘要字段", () => {
expect(isObject(masterySummary)).toBe(true);
expect(masterySummary).toHaveProperty("studentId");
expect(masterySummary).toHaveProperty("averageMastery");
expect(masterySummary).toHaveProperty("mastery");
});
});

View File

@@ -0,0 +1,66 @@
// Vitest 测试环境初始化
// 依据vitest.config.ts setupFiles
import { beforeEach, afterEach } from "vitest";
import "@testing-library/jest-dom/vitest";
import { server } from "../mocks/server";
import { handlers } from "../mocks/handlers";
// jsdom 环境补充matchMediarecharts 等库依赖)
if (!window.matchMedia) {
Object.defineProperty(window, "matchMedia", {
writable: true,
value: (query: string): MediaQueryList => ({
matches: false,
media: query,
onchange: null,
addListener: () => {},
removeListener: () => {},
addEventListener: () => {},
removeEventListener: () => {},
dispatchEvent: () => false,
}),
});
}
// jsdom 环境补充IntersectionObserver
if (!window.IntersectionObserver) {
class MockIntersectionObserver {
observe(): void {}
unobserve(): void {}
disconnect(): void {}
takeRecords(): IntersectionObserverEntry[] {
return [];
}
}
Object.defineProperty(window, "IntersectionObserver", {
writable: true,
value: MockIntersectionObserver,
});
}
// jsdom 环境补充ResizeObserverrecharts 依赖)
if (!window.ResizeObserver) {
class MockResizeObserver {
observe(): void {}
unobserve(): void {}
disconnect(): void {}
}
Object.defineProperty(window, "ResizeObserver", {
writable: true,
value: MockResizeObserver,
});
}
// 清理 localStorage
beforeEach(() => {
localStorage.clear();
sessionStorage.clear();
});
// 全局恢复 MSW 初始 handlers防止测试间隔离问题
// 某些测试文件使用 server.resetHandlers(...newHandlers) 会替换初始 handlers
// 此处显式恢复原始 handlers确保后续测试文件的请求能被正确拦截
afterEach(() => {
server.resetHandlers(...handlers);
});

View File

@@ -0,0 +1,55 @@
// Vitest 配置
// 依据02-architecture-design.md §13 测试策略分层
import { defineConfig } from "vitest/config";
import react from "@vitejs/plugin-react";
import { resolve } from "node:path";
export default defineConfig({
plugins: [react()],
resolve: {
alias: {
"@": resolve(__dirname, "./src"),
},
},
test: {
environment: "jsdom",
globals: true,
setupFiles: ["./src/test/setup.ts"],
include: [
"src/**/*.{test,spec}.{ts,tsx}",
"src/**/__tests__/**/*.{ts,tsx}",
],
exclude: ["node_modules", ".next", "e2e"],
coverage: {
provider: "v8",
reporter: ["text", "json", "html"],
include: ["src/**/*.{ts,tsx}"],
exclude: [
"src/**/*.d.ts",
"src/test/**",
"src/mocks/**",
"src/types/**",
"src/**/*.config.{ts,tsx}",
// Next.js App Router pages由 MSW 集成测试 + E2e 覆盖,不纳入单元测试覆盖率
"src/app/**/page.tsx",
"src/app/**/layout.tsx",
"src/app/**/providers.tsx",
"src/app/**/error.tsx",
"src/app/**/loading.tsx",
"src/app/**/not-found.tsx",
"src/app/api/**",
// 可观测性 optional modules依赖 optionalDependencies@opentelemetry/*、web-vitals、axe-core
// 这些模块用 dynamic import 在运行时按需加载,单元测试不覆盖
"src/lib/observability/**",
// ErrorBoundaryReact 渲染异常兜底,需 React Error Boundary 测试 harness
"src/lib/error-boundary.tsx",
],
thresholds: {
statements: 85,
branches: 75,
functions: 85,
lines: 85,
},
},
},
});