docs(admin-portal): 新增 nextstep-v2.md 记录下游核查结果
v1 声称完成的下游工作经核查实际未完成: - api-gateway: /api/admin/graphql 路由未注册,go vet 编译失败 - teacher-bff: resolver 已完成但 schema 未同步(命名空间 vs 扁平) - iam: proto 缺 BatchGetUsers rpc 声明 v2 记录详细核查证据和修复要求
This commit is contained in:
196
apps/teacher-portal/src/mocks/__tests__/handlers-p4.test.ts
Normal file
196
apps/teacher-portal/src/mocks/__tests__/handlers-p4.test.ts
Normal file
@@ -0,0 +1,196 @@
|
||||
/**
|
||||
* MSW P4 Handlers 单元测试
|
||||
*
|
||||
* 测试 handleP4GraphQL 函数:
|
||||
* - KnowledgeGraph 操作返回 nodes + edges
|
||||
* - ClassAnalytics 操作返回班级统计
|
||||
* - StudentAnalytics 操作返回单生学情
|
||||
* - 未覆盖的 operationName 返回 null(fallthrough)
|
||||
*
|
||||
* 直接测试 handleP4GraphQL 函数返回值(HttpResponse 或 null)。
|
||||
*/
|
||||
|
||||
import { describe, it, expect } from "vitest";
|
||||
import { handleP4GraphQL } from "../handlers-p4";
|
||||
import {
|
||||
mockKnowledgeGraph,
|
||||
filterKnowledgeGraph,
|
||||
} from "../fixtures/knowledge-graph";
|
||||
import {
|
||||
mockClassAnalytics,
|
||||
findStudentAnalytics,
|
||||
} from "../fixtures/analytics";
|
||||
|
||||
/** 从 HttpResponse 中提取 JSON 数据 */
|
||||
async function extractJson(res: Response): Promise<Record<string, unknown>> {
|
||||
return (await res.json()) as Record<string, unknown>;
|
||||
}
|
||||
|
||||
describe("handleP4GraphQL: KnowledgeGraph", () => {
|
||||
it("subject 为 null 时返回全量图谱(13 节点 + 10 边)", async () => {
|
||||
const res = handleP4GraphQL("KnowledgeGraph", { subject: null });
|
||||
expect(res).not.toBeNull();
|
||||
const body = await extractJson(res!);
|
||||
const data = body.data as {
|
||||
knowledgeGraph: typeof mockKnowledgeGraph;
|
||||
};
|
||||
expect(data.knowledgeGraph.nodes.length).toBe(
|
||||
mockKnowledgeGraph.nodes.length,
|
||||
);
|
||||
expect(data.knowledgeGraph.edges.length).toBe(
|
||||
mockKnowledgeGraph.edges.length,
|
||||
);
|
||||
});
|
||||
|
||||
it("subject 为 '数学' 时仅返回数学节点(5 节点)", async () => {
|
||||
const res = handleP4GraphQL("KnowledgeGraph", { subject: "数学" });
|
||||
expect(res).not.toBeNull();
|
||||
const body = await extractJson(res!);
|
||||
const data = body.data as {
|
||||
knowledgeGraph: { nodes: Array<{ subject: string }>; edges: unknown[] };
|
||||
};
|
||||
const expected = filterKnowledgeGraph("数学");
|
||||
expect(data.knowledgeGraph.nodes.length).toBe(expected.nodes.length);
|
||||
for (const node of data.knowledgeGraph.nodes) {
|
||||
expect(node.subject).toBe("数学");
|
||||
}
|
||||
});
|
||||
|
||||
it("subject 为 undefined 时返回全量(兜底)", async () => {
|
||||
const res = handleP4GraphQL("KnowledgeGraph", {});
|
||||
expect(res).not.toBeNull();
|
||||
const body = await extractJson(res!);
|
||||
const data = body.data as {
|
||||
knowledgeGraph: typeof mockKnowledgeGraph;
|
||||
};
|
||||
expect(data.knowledgeGraph.nodes.length).toBe(
|
||||
mockKnowledgeGraph.nodes.length,
|
||||
);
|
||||
});
|
||||
|
||||
it("每条 edge 引用的节点存在于 nodes 列表中", async () => {
|
||||
const res = handleP4GraphQL("KnowledgeGraph", { subject: "数学" });
|
||||
expect(res).not.toBeNull();
|
||||
const body = await extractJson(res!);
|
||||
const data = body.data as {
|
||||
knowledgeGraph: {
|
||||
nodes: Array<{ id: string }>;
|
||||
edges: Array<{ from: string; to: string }>;
|
||||
};
|
||||
};
|
||||
const nodeIds = new Set(data.knowledgeGraph.nodes.map((n) => n.id));
|
||||
for (const edge of data.knowledgeGraph.edges) {
|
||||
expect(nodeIds.has(edge.from)).toBe(true);
|
||||
expect(nodeIds.has(edge.to)).toBe(true);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe("handleP4GraphQL: ClassAnalytics", () => {
|
||||
it("返回班级学情分析数据(含 avgScore / passRate / topStudents)", async () => {
|
||||
const classId = "class-test-001";
|
||||
const res = handleP4GraphQL("ClassAnalytics", { classId });
|
||||
expect(res).not.toBeNull();
|
||||
const body = await extractJson(res!);
|
||||
const data = body.data as {
|
||||
classAnalytics: {
|
||||
classId: string;
|
||||
className: string;
|
||||
avgScore: number;
|
||||
passRate: number;
|
||||
avgTrend: number[];
|
||||
topStudents: unknown[];
|
||||
weakPoints: string[];
|
||||
};
|
||||
};
|
||||
expect(data.classAnalytics.classId).toBe(classId);
|
||||
expect(data.classAnalytics.className).toBe(mockClassAnalytics.className);
|
||||
expect(typeof data.classAnalytics.avgScore).toBe("number");
|
||||
expect(typeof data.classAnalytics.passRate).toBe("number");
|
||||
expect(Array.isArray(data.classAnalytics.avgTrend)).toBe(true);
|
||||
expect(data.classAnalytics.avgTrend.length).toBeGreaterThan(0);
|
||||
expect(Array.isArray(data.classAnalytics.topStudents)).toBe(true);
|
||||
expect(data.classAnalytics.topStudents.length).toBeGreaterThan(0);
|
||||
expect(Array.isArray(data.classAnalytics.weakPoints)).toBe(true);
|
||||
});
|
||||
|
||||
it("classId 为 undefined 时回退到 mockClassAnalytics.classId", async () => {
|
||||
const res = handleP4GraphQL("ClassAnalytics", {});
|
||||
expect(res).not.toBeNull();
|
||||
const body = await extractJson(res!);
|
||||
const data = body.data as { classAnalytics: { classId: string } };
|
||||
expect(data.classAnalytics.classId).toBe(mockClassAnalytics.classId);
|
||||
});
|
||||
});
|
||||
|
||||
describe("handleP4GraphQL: StudentAnalytics", () => {
|
||||
it("返回指定学生的学情数据(命中 stu-001)", async () => {
|
||||
const studentId = "stu-001";
|
||||
const res = handleP4GraphQL("StudentAnalytics", { studentId });
|
||||
expect(res).not.toBeNull();
|
||||
const body = await extractJson(res!);
|
||||
const data = body.data as {
|
||||
studentAnalytics: {
|
||||
studentId: string;
|
||||
studentName: string;
|
||||
avgScore: number;
|
||||
trend: number[];
|
||||
weakPoints: string[];
|
||||
strongPoints: string[];
|
||||
masteryRate: number;
|
||||
};
|
||||
};
|
||||
const expected = findStudentAnalytics(studentId);
|
||||
expect(data.studentAnalytics.studentId).toBe(studentId);
|
||||
expect(data.studentAnalytics.studentName).toBe(expected.studentName);
|
||||
expect(data.studentAnalytics.avgScore).toBe(expected.avgScore);
|
||||
expect(data.studentAnalytics.trend).toEqual(expected.trend);
|
||||
expect(data.studentAnalytics.weakPoints).toEqual(expected.weakPoints);
|
||||
expect(data.studentAnalytics.strongPoints).toEqual(expected.strongPoints);
|
||||
expect(data.studentAnalytics.masteryRate).toBe(expected.masteryRate);
|
||||
});
|
||||
|
||||
it("未命中的 studentId 返回兜底数据(非空)", async () => {
|
||||
const studentId = "stu-unknown-999";
|
||||
const res = handleP4GraphQL("StudentAnalytics", { studentId });
|
||||
expect(res).not.toBeNull();
|
||||
const body = await extractJson(res!);
|
||||
const data = body.data as {
|
||||
studentAnalytics: { studentId: string; avgScore: number };
|
||||
};
|
||||
expect(data.studentAnalytics.studentId).toBe(studentId);
|
||||
expect(typeof data.studentAnalytics.avgScore).toBe("number");
|
||||
});
|
||||
|
||||
it("studentId 为 undefined 时回退到 stu-001", async () => {
|
||||
const res = handleP4GraphQL("StudentAnalytics", {});
|
||||
expect(res).not.toBeNull();
|
||||
const body = await extractJson(res!);
|
||||
const data = body.data as {
|
||||
studentAnalytics: { studentId: string };
|
||||
};
|
||||
expect(data.studentAnalytics.studentId).toBe("stu-001");
|
||||
});
|
||||
});
|
||||
|
||||
describe("handleP4GraphQL: 未覆盖的 operationName", () => {
|
||||
it("非 P4 operation 返回 null(fallthrough)", () => {
|
||||
const res = handleP4GraphQL("Dashboard", {});
|
||||
expect(res).toBeNull();
|
||||
});
|
||||
|
||||
it("Me operation 返回 null", () => {
|
||||
const res = handleP4GraphQL("Me", {});
|
||||
expect(res).toBeNull();
|
||||
});
|
||||
|
||||
it("空字符串 operationName 返回 null", () => {
|
||||
const res = handleP4GraphQL("", {});
|
||||
expect(res).toBeNull();
|
||||
});
|
||||
|
||||
it("Unknown operationName 返回 null", () => {
|
||||
const res = handleP4GraphQL("SomeFutureOperation", {});
|
||||
expect(res).toBeNull();
|
||||
});
|
||||
});
|
||||
192
apps/teacher-portal/src/mocks/__tests__/handlers.test.ts
Normal file
192
apps/teacher-portal/src/mocks/__tests__/handlers.test.ts
Normal file
@@ -0,0 +1,192 @@
|
||||
/**
|
||||
* MSW Handlers 单元测试
|
||||
*
|
||||
* 测试 handlers.ts 拦截的关键 operationName:
|
||||
* - POST /api/auth/login → 登录返回 token
|
||||
* - POST /api/v1/teacher/graphql → Me / Viewports / CreateExam / 未知 operation
|
||||
* - GET /api/health、GET /api/ready → 健康检查
|
||||
*
|
||||
* 使用 msw/node server(在 vitest.setup.ts 中启动)+ fetch 调用验证。
|
||||
*/
|
||||
|
||||
import { describe, it, expect, beforeAll, afterAll } from "vitest";
|
||||
import { server } from "../server";
|
||||
import { handlers } from "../handlers";
|
||||
import { mockUser } from "../fixtures/user";
|
||||
import { mockViewports } from "../fixtures/viewports";
|
||||
|
||||
const GRAPHQL_URL = "http://localhost/api/v1/teacher/graphql";
|
||||
const LOGIN_URL = "http://localhost/api/auth/login";
|
||||
const HEALTH_URL = "http://localhost/api/health";
|
||||
const READY_URL = "http://localhost/api/ready";
|
||||
|
||||
// 替换 MSW handlers 为仅 handlers.ts(避免 p7/p4/p5 抢占同一 endpoint
|
||||
// 导致 request body 被多次读取的 "Body is unusable" 错误)
|
||||
beforeAll(() => {
|
||||
server.resetHandlers(...handlers);
|
||||
});
|
||||
afterAll(() => {
|
||||
server.restoreHandlers();
|
||||
});
|
||||
|
||||
async function graphql(
|
||||
operationName: string,
|
||||
variables: Record<string, unknown> = {},
|
||||
): Promise<Record<string, unknown>> {
|
||||
const res = await fetch(GRAPHQL_URL, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ operationName, variables }),
|
||||
});
|
||||
return (await res.json()) as Record<string, unknown>;
|
||||
}
|
||||
|
||||
describe("handlers: POST /api/auth/login", () => {
|
||||
it("邮箱密码齐全时返回 envelope 包含 user + tokens", async () => {
|
||||
const res = await fetch(LOGIN_URL, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ email: "teacher@edu.test", password: "pass" }),
|
||||
});
|
||||
expect(res.status).toBe(200);
|
||||
const body = (await res.json()) as {
|
||||
success: boolean;
|
||||
data: { user: typeof mockUser; tokens: { accessToken: string } };
|
||||
};
|
||||
expect(body.success).toBe(true);
|
||||
expect(body.data.user.id).toBe(mockUser.id);
|
||||
expect(body.data.user.email).toBe(mockUser.email);
|
||||
expect(typeof body.data.tokens.accessToken).toBe("string");
|
||||
expect(body.data.tokens.accessToken.length).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it("缺少 email 时返回 400", async () => {
|
||||
const res = await fetch(LOGIN_URL, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ password: "pass" }),
|
||||
});
|
||||
expect(res.status).toBe(400);
|
||||
const body = (await res.json()) as {
|
||||
success: boolean;
|
||||
error: { code: string };
|
||||
};
|
||||
expect(body.success).toBe(false);
|
||||
expect(body.error.code).toBe("GW_UNAUTHORIZED");
|
||||
});
|
||||
|
||||
it("缺少 password 时返回 400", async () => {
|
||||
const res = await fetch(LOGIN_URL, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ email: "teacher@edu.test" }),
|
||||
});
|
||||
expect(res.status).toBe(400);
|
||||
});
|
||||
});
|
||||
|
||||
describe("handlers: POST /api/v1/teacher/graphql Me", () => {
|
||||
it("Me 操作返回 mockUser", async () => {
|
||||
const body = await graphql("Me");
|
||||
const data = body.data as { me: typeof mockUser };
|
||||
expect(data.me).toBeDefined();
|
||||
expect(data.me.id).toBe(mockUser.id);
|
||||
expect(data.me.email).toBe(mockUser.email);
|
||||
expect(data.me.name).toBe(mockUser.name);
|
||||
expect(data.me.roles).toEqual(mockUser.roles);
|
||||
expect(data.me.dataScope).toBe(mockUser.dataScope);
|
||||
});
|
||||
});
|
||||
|
||||
describe("handlers: POST /api/v1/teacher/graphql Viewports", () => {
|
||||
it("Viewports 操作返回 mockViewports(实际 23 项)", async () => {
|
||||
const body = await graphql("Viewports");
|
||||
const data = body.data as { viewports: typeof mockViewports };
|
||||
expect(Array.isArray(data.viewports)).toBe(true);
|
||||
expect(data.viewports.length).toBe(mockViewports.length);
|
||||
// 抽样校验首项
|
||||
const first = data.viewports[0];
|
||||
expect(first).toBeDefined();
|
||||
expect(first?.key).toBe("dashboard");
|
||||
expect(first?.label).toBe("仪表盘");
|
||||
expect(first?.route).toBe("/dashboard");
|
||||
expect(first?.sortOrder).toBe("01");
|
||||
});
|
||||
|
||||
it("每项视口包含必需字段", async () => {
|
||||
const body = await graphql("Viewports");
|
||||
const data = body.data as { viewports: typeof mockViewports };
|
||||
for (const v of data.viewports) {
|
||||
expect(typeof v.key).toBe("string");
|
||||
expect(typeof v.label).toBe("string");
|
||||
expect(typeof v.route).toBe("string");
|
||||
expect(typeof v.sortOrder).toBe("string");
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe("handlers: POST /api/v1/teacher/graphql CreateExam", () => {
|
||||
it("CreateExam 操作返回 examId(含 classId 前缀)", async () => {
|
||||
const input = {
|
||||
classId: "class-test-001",
|
||||
title: "单元测试一",
|
||||
description: "覆盖函数与导数",
|
||||
examDate: new Date().toISOString(),
|
||||
duration: 90,
|
||||
totalScore: 100,
|
||||
};
|
||||
const body = await graphql("CreateExam", { input });
|
||||
const data = body.data as {
|
||||
createExam: {
|
||||
id: string;
|
||||
classId: string;
|
||||
title: string;
|
||||
status: string;
|
||||
};
|
||||
};
|
||||
expect(data.createExam).toBeDefined();
|
||||
expect(data.createExam.id).toContain(input.classId);
|
||||
expect(data.createExam.classId).toBe(input.classId);
|
||||
expect(data.createExam.title).toBe(input.title);
|
||||
expect(data.createExam.status).toBe("DRAFT");
|
||||
});
|
||||
});
|
||||
|
||||
describe("handlers: POST /api/v1/teacher/graphql 未知 operation", () => {
|
||||
it("未覆盖的 operationName 返回 GraphQL errors", async () => {
|
||||
const body = await graphql("NotARealOperation");
|
||||
expect(body.data).toBeUndefined();
|
||||
const errors = body.errors as Array<{
|
||||
message: string;
|
||||
extensions: { code: string };
|
||||
}>;
|
||||
expect(Array.isArray(errors)).toBe(true);
|
||||
expect(errors[0]?.extensions.code).toBe("BFF_TEACHER_NOT_IMPLEMENTED");
|
||||
expect(errors[0]?.message).toContain("NotARealOperation");
|
||||
});
|
||||
});
|
||||
|
||||
describe("handlers: GET /api/health", () => {
|
||||
it("返回 ok 状态", async () => {
|
||||
const res = await fetch(HEALTH_URL);
|
||||
expect(res.status).toBe(200);
|
||||
const body = (await res.json()) as { status: string; service: string };
|
||||
expect(body.status).toBe("ok");
|
||||
expect(body.service).toBe("teacher-portal");
|
||||
});
|
||||
});
|
||||
|
||||
describe("handlers: GET /api/ready", () => {
|
||||
it("返回 ok 状态 + checks", async () => {
|
||||
const res = await fetch(READY_URL);
|
||||
expect(res.status).toBe(200);
|
||||
const body = (await res.json()) as {
|
||||
status: string;
|
||||
service: string;
|
||||
checks: { msw: boolean };
|
||||
};
|
||||
expect(body.status).toBe("ok");
|
||||
expect(body.service).toBe("teacher-portal");
|
||||
expect(body.checks.msw).toBe(true);
|
||||
});
|
||||
});
|
||||
204
apps/teacher-portal/src/mocks/fixtures/__tests__/exams.test.ts
Normal file
204
apps/teacher-portal/src/mocks/fixtures/__tests__/exams.test.ts
Normal file
@@ -0,0 +1,204 @@
|
||||
/**
|
||||
* exams fixture 单元测试
|
||||
*
|
||||
* 测试 generateMockExams / findMockExamDetail / createMockExam:
|
||||
* - generateMockExams 返回 3 项考试(PUBLISHED / DRAFT / SCORED)
|
||||
* - 每项考试包含必需字段
|
||||
* - findMockExamDetail 按已存在 ID 返回详情(含 questions)
|
||||
* - findMockExamDetail 未命中 ID 返回兜底详情
|
||||
* - createMockExam 返回 DRAFT 状态新考试,ID 含 classId 前缀
|
||||
*/
|
||||
|
||||
import { describe, it, expect } from "vitest";
|
||||
import {
|
||||
generateMockExams,
|
||||
findMockExamDetail,
|
||||
createMockExam,
|
||||
} from "../exams";
|
||||
import type { ExamItem, ExamDetail, CreateExamInput } from "@/lib/graphql";
|
||||
|
||||
const TEST_CLASS_ID = "class-ut-001";
|
||||
|
||||
describe("generateMockExams", () => {
|
||||
const exams = generateMockExams(TEST_CLASS_ID);
|
||||
|
||||
it("返回 3 项考试", () => {
|
||||
expect(exams).toHaveLength(3);
|
||||
expect(Array.isArray(exams)).toBe(true);
|
||||
});
|
||||
|
||||
it("三项考试状态覆盖 PUBLISHED / DRAFT / SCORED", () => {
|
||||
const statuses = new Set(exams.map((e) => e.status));
|
||||
expect(statuses.has("PUBLISHED")).toBe(true);
|
||||
expect(statuses.has("DRAFT")).toBe(true);
|
||||
expect(statuses.has("SCORED")).toBe(true);
|
||||
});
|
||||
|
||||
it("每项考试 ID 包含 classId 前缀", () => {
|
||||
for (const e of exams) {
|
||||
expect(e.id.startsWith(`${TEST_CLASS_ID}-exam-`)).toBe(true);
|
||||
expect(e.classId).toBe(TEST_CLASS_ID);
|
||||
}
|
||||
});
|
||||
|
||||
it("每项考试包含必需字段且类型正确", () => {
|
||||
for (const e of exams) {
|
||||
expect(typeof e.id).toBe("string");
|
||||
expect(e.id.length).toBeGreaterThan(0);
|
||||
expect(typeof e.classId).toBe("string");
|
||||
expect(typeof e.title).toBe("string");
|
||||
expect(e.title.length).toBeGreaterThan(0);
|
||||
// description 可为 string 或 null
|
||||
expect(e.description === null || typeof e.description === "string").toBe(
|
||||
true,
|
||||
);
|
||||
expect(typeof e.examDate).toBe("string");
|
||||
// examDate 应可被 Date 解析
|
||||
expect(() => new Date(e.examDate).toISOString()).not.toThrow();
|
||||
expect(typeof e.duration).toBe("number");
|
||||
expect(e.duration).toBeGreaterThan(0);
|
||||
expect(typeof e.totalScore).toBe("number");
|
||||
expect(e.totalScore).toBeGreaterThan(0);
|
||||
expect([
|
||||
"DRAFT",
|
||||
"PUBLISHED",
|
||||
"IN_PROGRESS",
|
||||
"GRADING",
|
||||
"SCORED",
|
||||
"ARCHIVED",
|
||||
]).toContain(e.status);
|
||||
}
|
||||
});
|
||||
|
||||
it("类型符合 ExamItem 接口", () => {
|
||||
const sample: ExamItem = exams[0]!;
|
||||
expect(sample).toHaveProperty("id");
|
||||
expect(sample).toHaveProperty("classId");
|
||||
expect(sample).toHaveProperty("title");
|
||||
expect(sample).toHaveProperty("examDate");
|
||||
expect(sample).toHaveProperty("duration");
|
||||
expect(sample).toHaveProperty("totalScore");
|
||||
expect(sample).toHaveProperty("status");
|
||||
});
|
||||
|
||||
it("不同 classId 生成的数据独立(不共享引用)", () => {
|
||||
const a = generateMockExams("class-A");
|
||||
const b = generateMockExams("class-B");
|
||||
expect(a[0]!.id).not.toBe(b[0]!.id);
|
||||
expect(a[0]!.classId).toBe("class-A");
|
||||
expect(b[0]!.classId).toBe("class-B");
|
||||
});
|
||||
});
|
||||
|
||||
describe("findMockExamDetail", () => {
|
||||
it("按已存在 examId 返回详情(含 4 道题目)", () => {
|
||||
const exams = generateMockExams(TEST_CLASS_ID);
|
||||
const firstId = exams[0]!.id;
|
||||
const detail = findMockExamDetail(firstId);
|
||||
expect(detail.id).toBe(firstId);
|
||||
expect(detail.classId).toBe(TEST_CLASS_ID);
|
||||
expect(Array.isArray(detail.questions)).toBe(true);
|
||||
expect(detail.questions).toHaveLength(4);
|
||||
});
|
||||
|
||||
it("题目字段完整(id / examId / title / type / score / order)", () => {
|
||||
const exams = generateMockExams(TEST_CLASS_ID);
|
||||
const firstId = exams[0]!.id;
|
||||
const detail = findMockExamDetail(firstId);
|
||||
for (const q of detail.questions) {
|
||||
expect(typeof q.id).toBe("string");
|
||||
expect(q.examId).toBe(firstId);
|
||||
expect(typeof q.title).toBe("string");
|
||||
expect([
|
||||
"SINGLE_CHOICE",
|
||||
"MULTIPLE_CHOICE",
|
||||
"SHORT_ANSWER",
|
||||
"ESSAY",
|
||||
]).toContain(q.type);
|
||||
expect(typeof q.score).toBe("number");
|
||||
expect(typeof q.order).toBe("number");
|
||||
}
|
||||
});
|
||||
|
||||
it("题目类型覆盖 SINGLE_CHOICE / MULTIPLE_CHOICE / SHORT_ANSWER / ESSAY", () => {
|
||||
const exams = generateMockExams(TEST_CLASS_ID);
|
||||
const firstId = exams[0]!.id;
|
||||
const detail = findMockExamDetail(firstId);
|
||||
const types = new Set(detail.questions.map((q) => q.type));
|
||||
expect(types.has("SINGLE_CHOICE")).toBe(true);
|
||||
expect(types.has("MULTIPLE_CHOICE")).toBe(true);
|
||||
expect(types.has("SHORT_ANSWER")).toBe(true);
|
||||
expect(types.has("ESSAY")).toBe(true);
|
||||
});
|
||||
|
||||
it("未命中的 examId 返回兜底详情(不抛出)", () => {
|
||||
const fakeId = "fake-class-exam-999";
|
||||
const detail = findMockExamDetail(fakeId);
|
||||
expect(detail.id).toBe(fakeId);
|
||||
expect(Array.isArray(detail.questions)).toBe(true);
|
||||
expect(detail.questions.length).toBeGreaterThan(0);
|
||||
expect(detail.status).toBe("DRAFT");
|
||||
});
|
||||
|
||||
it("详情类型符合 ExamDetail 接口", () => {
|
||||
const exams = generateMockExams(TEST_CLASS_ID);
|
||||
const detail: ExamDetail = findMockExamDetail(exams[0]!.id);
|
||||
expect(detail).toHaveProperty("id");
|
||||
expect(detail).toHaveProperty("questions");
|
||||
});
|
||||
});
|
||||
|
||||
describe("createMockExam", () => {
|
||||
const input: CreateExamInput = {
|
||||
classId: TEST_CLASS_ID,
|
||||
title: "测试考试 - 单元一",
|
||||
description: "覆盖函数章节",
|
||||
examDate: "2026-08-01T10:00:00.000Z",
|
||||
duration: 60,
|
||||
totalScore: 80,
|
||||
};
|
||||
|
||||
it("返回 DRAFT 状态的新考试", () => {
|
||||
const exam = createMockExam(input);
|
||||
expect(exam.status).toBe("DRAFT");
|
||||
});
|
||||
|
||||
it("ID 包含 classId 前缀", () => {
|
||||
const exam = createMockExam(input);
|
||||
expect(exam.id.startsWith(`${TEST_CLASS_ID}-exam-`)).toBe(true);
|
||||
});
|
||||
|
||||
it("字段从 input 透传", () => {
|
||||
const exam = createMockExam(input);
|
||||
expect(exam.classId).toBe(input.classId);
|
||||
expect(exam.title).toBe(input.title);
|
||||
expect(exam.description).toBe(input.description);
|
||||
expect(exam.examDate).toBe(input.examDate);
|
||||
expect(exam.duration).toBe(input.duration);
|
||||
expect(exam.totalScore).toBe(input.totalScore);
|
||||
});
|
||||
|
||||
it("description 为 undefined 时透传为 null", () => {
|
||||
const noDescInput: CreateExamInput = {
|
||||
classId: TEST_CLASS_ID,
|
||||
title: "无描述考试",
|
||||
examDate: "2026-08-01T10:00:00.000Z",
|
||||
duration: 90,
|
||||
totalScore: 100,
|
||||
};
|
||||
const exam = createMockExam(noDescInput);
|
||||
expect(exam.description).toBeNull();
|
||||
});
|
||||
|
||||
it("多次调用生成不同 ID(随机后缀)", () => {
|
||||
const a = createMockExam(input);
|
||||
const b = createMockExam(input);
|
||||
expect(a.id).not.toBe(b.id);
|
||||
});
|
||||
|
||||
it("类型符合 ExamItem 接口", () => {
|
||||
const exam: ExamItem = createMockExam(input);
|
||||
expect(exam).toHaveProperty("id");
|
||||
expect(exam).toHaveProperty("status");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,110 @@
|
||||
/**
|
||||
* viewports fixture 单元测试
|
||||
*
|
||||
* 测试 mockViewports 数据:
|
||||
* - 数量符合预期(23 项)
|
||||
* - 每项包含必需字段(key / label / route / sortOrder / requiredPermission)
|
||||
* - sortOrder 单调递增
|
||||
* - key / route 唯一
|
||||
*/
|
||||
|
||||
import { describe, it, expect } from "vitest";
|
||||
import { mockViewports } from "../viewports";
|
||||
import type { ViewportItem } from "@/lib/graphql";
|
||||
|
||||
describe("mockViewports 数据完整性", () => {
|
||||
it("数组长度应为 23", () => {
|
||||
expect(mockViewports).toHaveLength(23);
|
||||
expect(Array.isArray(mockViewports)).toBe(true);
|
||||
});
|
||||
|
||||
it("每项视口包含必需字段且类型正确", () => {
|
||||
for (const v of mockViewports) {
|
||||
expect(typeof v.key).toBe("string");
|
||||
expect(v.key.length).toBeGreaterThan(0);
|
||||
expect(typeof v.label).toBe("string");
|
||||
expect(v.label.length).toBeGreaterThan(0);
|
||||
expect(typeof v.route).toBe("string");
|
||||
expect(v.route.startsWith("/")).toBe(true);
|
||||
expect(typeof v.sortOrder).toBe("string");
|
||||
expect(v.sortOrder.length).toBeGreaterThan(0);
|
||||
}
|
||||
});
|
||||
|
||||
it("sortOrder 单调递增", () => {
|
||||
for (let i = 1; i < mockViewports.length; i++) {
|
||||
const prev = mockViewports[i - 1];
|
||||
const curr = mockViewports[i];
|
||||
// settings 的 sortOrder 为 "99"(兜底末位),允许大于前项
|
||||
expect(prev && curr).toBeDefined();
|
||||
expect(curr!.sortOrder >= prev!.sortOrder).toBe(true);
|
||||
}
|
||||
});
|
||||
|
||||
it("key 字段唯一", () => {
|
||||
const keys = mockViewports.map((v) => v.key);
|
||||
const uniqueKeys = new Set(keys);
|
||||
expect(uniqueKeys.size).toBe(keys.length);
|
||||
});
|
||||
|
||||
it("route 字段唯一", () => {
|
||||
const routes = mockViewports.map((v) => v.route);
|
||||
const uniqueRoutes = new Set(routes);
|
||||
expect(uniqueRoutes.size).toBe(routes.length);
|
||||
});
|
||||
|
||||
it("首项应为 dashboard(sortOrder=01)", () => {
|
||||
const first = mockViewports[0];
|
||||
expect(first).toBeDefined();
|
||||
expect(first?.key).toBe("dashboard");
|
||||
expect(first?.sortOrder).toBe("01");
|
||||
});
|
||||
|
||||
it("末项应为 settings(sortOrder=99)", () => {
|
||||
const last = mockViewports[mockViewports.length - 1];
|
||||
expect(last).toBeDefined();
|
||||
expect(last?.key).toBe("settings");
|
||||
expect(last?.sortOrder).toBe("99");
|
||||
});
|
||||
|
||||
it("requiredPermission 字段为字符串或 null", () => {
|
||||
for (const v of mockViewports) {
|
||||
expect(
|
||||
v.requiredPermission === null ||
|
||||
typeof v.requiredPermission === "string",
|
||||
).toBe(true);
|
||||
}
|
||||
});
|
||||
|
||||
it("icon 字段为 null(mock 数据未配置图标)", () => {
|
||||
for (const v of mockViewports) {
|
||||
expect(v.icon).toBeNull();
|
||||
}
|
||||
});
|
||||
|
||||
it("类型符合 ViewportItem 接口", () => {
|
||||
const sample: ViewportItem = mockViewports[0]!;
|
||||
expect(sample).toHaveProperty("key");
|
||||
expect(sample).toHaveProperty("label");
|
||||
expect(sample).toHaveProperty("route");
|
||||
expect(sample).toHaveProperty("icon");
|
||||
expect(sample).toHaveProperty("sortOrder");
|
||||
expect(sample).toHaveProperty("requiredPermission");
|
||||
});
|
||||
|
||||
it("关键视口存在(dashboard / classes / exams / homework / grades / analytics / settings)", () => {
|
||||
const keys = new Set(mockViewports.map((v) => v.key));
|
||||
const expected = [
|
||||
"dashboard",
|
||||
"classes",
|
||||
"exams",
|
||||
"homework",
|
||||
"grades",
|
||||
"analytics",
|
||||
"settings",
|
||||
];
|
||||
for (const k of expected) {
|
||||
expect(keys.has(k)).toBe(true);
|
||||
}
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user