题目校验抽取为可单测 lib;扩展 solve judgeMode(answer/rubric)分支校验
Co-Authored-By: Claude Haiku 4.5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Haiku 4.5
parent
bb8bb25096
commit
e896b2be51
@@ -11,6 +11,7 @@ import { todayStr } from "@/lib/dates";
|
|||||||
import { ensureDay } from "@/lib/tasks";
|
import { ensureDay } from "@/lib/tasks";
|
||||||
import { getSessionUser } from "@/lib/auth";
|
import { getSessionUser } from "@/lib/auth";
|
||||||
import { DEFAULT_RULE_POINTS, upsertPointRule } from "@/lib/points";
|
import { DEFAULT_RULE_POINTS, upsertPointRule } from "@/lib/points";
|
||||||
|
import { validateQuestions } from "@/lib/question-validation";
|
||||||
|
|
||||||
function hashCode(code: string) {
|
function hashCode(code: string) {
|
||||||
return crypto.createHash("sha256").update(code).digest("hex");
|
return crypto.createHash("sha256").update(code).digest("hex");
|
||||||
@@ -325,40 +326,6 @@ export async function deleteExamRecord(id: number): Promise<void> {
|
|||||||
revalidatePath("/parent");
|
revalidatePath("/parent");
|
||||||
}
|
}
|
||||||
|
|
||||||
// xuchao: 试卷题目 JSON 形状校验:数组 + 每题必需字段 + 题型条件字段
|
|
||||||
const EXAM_TYPES = ["choice", "fill", "judge", "order", "match", "solve"];
|
|
||||||
|
|
||||||
function validateQuestionsJson(raw: string): { error?: string; count?: number } {
|
|
||||||
let arr: unknown;
|
|
||||||
try {
|
|
||||||
arr = JSON.parse(raw);
|
|
||||||
} catch {
|
|
||||||
return { error: "题目 JSON 解析失败,请检查语法" };
|
|
||||||
}
|
|
||||||
if (!Array.isArray(arr)) return { error: "题目必须是 JSON 数组" };
|
|
||||||
if (arr.length === 0) return { error: "试卷至少需要 1 道题" };
|
|
||||||
for (let i = 0; i < arr.length; i++) {
|
|
||||||
const q = arr[i] as Record<string, unknown>;
|
|
||||||
const no = `第 ${i + 1} 题`;
|
|
||||||
if (!q || typeof q !== "object") return { error: `${no}不是对象` };
|
|
||||||
if (typeof q.stem !== "string" || !q.stem.trim()) return { error: `${no}缺少题干(stem)` };
|
|
||||||
if (typeof q.explain !== "string" || !q.explain.trim()) return { error: `${no}缺少解析(explain)` };
|
|
||||||
const t = q.type;
|
|
||||||
if (!EXAM_TYPES.includes(t as string)) return { error: `${no}的 type 非法:${String(t)}` };
|
|
||||||
if ((t === "choice" || t === "judge") && typeof q.answer !== "number") return { error: `${no}(${t})缺少答案(answer 数字)` };
|
|
||||||
if (t === "choice" && (!Array.isArray(q.options) || q.options.length === 0)) return { error: `${no}(choice)缺少选项(options)` };
|
|
||||||
if (t === "order" && (!Array.isArray(q.words) || q.words.length === 0)) return { error: `${no}(order)缺少词块(words)` };
|
|
||||||
if (t === "fill" && !Array.isArray(q.answers)) return { error: `${no}(fill)缺少参考答案(answers 数组)` };
|
|
||||||
if (t === "order" && !Array.isArray(q.answer)) return { error: `${no}(order)缺少答案(answer 数组)` };
|
|
||||||
if (t === "match" && !Array.isArray(q.pairs)) return { error: `${no}(match)缺少配对(pairs 数组)` };
|
|
||||||
if (t === "solve") {
|
|
||||||
if (typeof q.reference !== "string" || !q.reference.trim()) return { error: `${no}(solve)缺少参考解答(reference)` };
|
|
||||||
if (typeof q.points !== "number" || q.points <= 0) return { error: `${no}(solve)缺少满分(points>0)` };
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return { count: arr.length };
|
|
||||||
}
|
|
||||||
|
|
||||||
// xuchao: 保存试卷(paperId 重复则全量更新,孩子端 /exam 立即生效)
|
// xuchao: 保存试卷(paperId 重复则全量更新,孩子端 /exam 立即生效)
|
||||||
export async function saveExamPaper(formData: FormData): Promise<{ ok?: boolean; error?: string }> {
|
export async function saveExamPaper(formData: FormData): Promise<{ ok?: boolean; error?: string }> {
|
||||||
const parent = await getSessionUser();
|
const parent = await getSessionUser();
|
||||||
@@ -373,7 +340,7 @@ export async function saveExamPaper(formData: FormData): Promise<{ ok?: boolean;
|
|||||||
if (!Number.isInteger(month) || month < 0 || month > 12) return { error: "月份需为 0-12 的整数" };
|
if (!Number.isInteger(month) || month < 0 || month > 12) return { error: "月份需为 0-12 的整数" };
|
||||||
if (!Number.isInteger(seq) || seq < 0) return { error: "展示序号需为不小于 0 的整数" };
|
if (!Number.isInteger(seq) || seq < 0) return { error: "展示序号需为不小于 0 的整数" };
|
||||||
const questions = f("questions");
|
const questions = f("questions");
|
||||||
const chk = validateQuestionsJson(questions);
|
const chk = validateQuestions(questions);
|
||||||
if (chk.error) return { error: chk.error };
|
if (chk.error) return { error: chk.error };
|
||||||
|
|
||||||
await db
|
await db
|
||||||
|
|||||||
@@ -0,0 +1,80 @@
|
|||||||
|
import { describe, expect, it } from "vitest";
|
||||||
|
import { validateQuestions } from "./question-validation";
|
||||||
|
|
||||||
|
const base = {
|
||||||
|
type: "choice",
|
||||||
|
stem: "x?",
|
||||||
|
options: ["a", "b"],
|
||||||
|
answer: 0,
|
||||||
|
explain: "e",
|
||||||
|
};
|
||||||
|
|
||||||
|
describe("validateQuestions 基础", () => {
|
||||||
|
it("解析失败报错", () => {
|
||||||
|
expect(validateQuestions("{bad").error).toContain("解析失败");
|
||||||
|
});
|
||||||
|
it("非数组报错", () => {
|
||||||
|
expect(validateQuestions("{}").error).toContain("必须是 JSON 数组");
|
||||||
|
});
|
||||||
|
it("空数组报错", () => {
|
||||||
|
expect(validateQuestions("[]").error).toContain("至少需要 1 道题");
|
||||||
|
});
|
||||||
|
it("合法 choice 通过并返回题数", () => {
|
||||||
|
const r = validateQuestions(JSON.stringify([base]));
|
||||||
|
expect(r.error).toBeUndefined();
|
||||||
|
expect(r.count).toBe(1);
|
||||||
|
});
|
||||||
|
it("choice 缺 options 报错", () => {
|
||||||
|
const { stem, answer, explain } = base;
|
||||||
|
expect(validateQuestions(JSON.stringify([{ type: "choice", stem, answer, explain }])).error).toContain("缺少选项");
|
||||||
|
});
|
||||||
|
it("fill 缺 answers 报错", () => {
|
||||||
|
expect(
|
||||||
|
validateQuestions(JSON.stringify([{ type: "fill", stem: "f?", explain: "e" }])).error,
|
||||||
|
).toContain("缺少参考答案");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("validateQuestions solve / judgeMode", () => {
|
||||||
|
const solveRubric = {
|
||||||
|
type: "solve",
|
||||||
|
stem: "解方程",
|
||||||
|
points: 10,
|
||||||
|
reference: "x=2",
|
||||||
|
rubric: [{ point: "过程", score: 6 }, { point: "答案", score: 4 }],
|
||||||
|
explain: "e",
|
||||||
|
};
|
||||||
|
it("合法 rubric solve(缺省 judgeMode)通过", () => {
|
||||||
|
expect(validateQuestions(JSON.stringify([solveRubric])).error).toBeUndefined();
|
||||||
|
});
|
||||||
|
it("rubric 模式缺 reference 报错", () => {
|
||||||
|
const { reference: _ref, ...rest } = solveRubric;
|
||||||
|
expect(validateQuestions(JSON.stringify([rest])).error).toContain("缺少参考解答");
|
||||||
|
});
|
||||||
|
it("answer 模式缺 answers 报错,不要求 reference", () => {
|
||||||
|
const q = { ...solveRubric, judgeMode: "answer", answers: [], rubric: undefined };
|
||||||
|
delete q.rubric;
|
||||||
|
expect(validateQuestions(JSON.stringify([q])).error).toContain("缺少最终答案");
|
||||||
|
const ok = { ...solveRubric, judgeMode: "answer", answers: ["2"], rubric: undefined, reference: undefined };
|
||||||
|
delete ok.rubric;
|
||||||
|
delete ok.reference;
|
||||||
|
expect(validateQuestions(JSON.stringify([ok])).error).toBeUndefined();
|
||||||
|
});
|
||||||
|
it("answer 模式带 rubric 报错", () => {
|
||||||
|
const q = { ...solveRubric, judgeMode: "answer", answers: ["2"], rubric: [solveRubric.rubric[0]] };
|
||||||
|
expect(validateQuestions(JSON.stringify([q])).error).toContain("不应携带 rubric");
|
||||||
|
});
|
||||||
|
it("judgeMode 非法值报错", () => {
|
||||||
|
const q = { ...solveRubric, judgeMode: "auto" };
|
||||||
|
expect(validateQuestions(JSON.stringify([q])).error).toContain("judgeMode 非法");
|
||||||
|
});
|
||||||
|
it("存量 86 题形状全通过(48 choice/30 fill/8 solve 代表用例)", () => {
|
||||||
|
const fill = { type: "fill", stem: "f?", answers: ["a", "b"], explain: "e" };
|
||||||
|
const arr: unknown[] = Array.from({ length: 48 }, () => base);
|
||||||
|
arr.push(...Array.from({ length: 30 }, () => fill));
|
||||||
|
arr.push(...Array.from({ length: 8 }, () => solveRubric));
|
||||||
|
const r = validateQuestions(JSON.stringify(arr));
|
||||||
|
expect(r.error).toBeUndefined();
|
||||||
|
expect(r.count).toBe(86);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,47 @@
|
|||||||
|
// xuchao: 试卷题目 JSON 形状校验(服务端保存前唯一写入门禁),纯函数便于单测
|
||||||
|
export const EXAM_TYPES = ["choice", "fill", "judge", "order", "match", "solve"] as const;
|
||||||
|
const SOLVE_JUDGE_MODES = ["rubric", "answer"] as const;
|
||||||
|
|
||||||
|
export type ValidationResult = { error?: string; count?: number };
|
||||||
|
|
||||||
|
export function validateQuestions(raw: string): ValidationResult {
|
||||||
|
let arr: unknown;
|
||||||
|
try {
|
||||||
|
arr = JSON.parse(raw);
|
||||||
|
} catch {
|
||||||
|
return { error: "题目 JSON 解析失败,请检查语法" };
|
||||||
|
}
|
||||||
|
if (!Array.isArray(arr)) return { error: "题目必须是 JSON 数组" };
|
||||||
|
if (arr.length === 0) return { error: "试卷至少需要 1 道题" };
|
||||||
|
for (let i = 0; i < arr.length; i++) {
|
||||||
|
const q = arr[i] as Record<string, unknown>;
|
||||||
|
const no = `第 ${i + 1} 题`;
|
||||||
|
if (!q || typeof q !== "object") return { error: `${no}不是对象` };
|
||||||
|
if (typeof q.stem !== "string" || !q.stem.trim()) return { error: `${no}缺少题干(stem)` };
|
||||||
|
if (typeof q.explain !== "string" || !q.explain.trim()) return { error: `${no}缺少解析(explain)` };
|
||||||
|
const t = q.type as (typeof EXAM_TYPES)[number];
|
||||||
|
if (!EXAM_TYPES.includes(t)) return { error: `${no}的 type 非法:${String(q.type)}` };
|
||||||
|
if ((t === "choice" || t === "judge") && typeof q.answer !== "number") return { error: `${no}(${t})缺少答案(answer 数字)` };
|
||||||
|
if (t === "choice" && (!Array.isArray(q.options) || q.options.length === 0)) return { error: `${no}(choice)缺少选项(options)` };
|
||||||
|
if (t === "fill" && !Array.isArray(q.answers)) return { error: `${no}(fill)缺少参考答案(answers 数组)` };
|
||||||
|
if (t === "order") {
|
||||||
|
if (!Array.isArray(q.answer)) return { error: `${no}(order)缺少答案(answer 数组)` };
|
||||||
|
if (!Array.isArray(q.words) || q.words.length === 0) return { error: `${no}(order)缺少词块(words)` };
|
||||||
|
}
|
||||||
|
if (t === "match" && !Array.isArray(q.pairs)) return { error: `${no}(match)缺少配对(pairs 数组)` };
|
||||||
|
if (t === "solve") {
|
||||||
|
if (typeof q.points !== "number" || q.points <= 0) return { error: `${no}(solve)缺少满分(points>0)` };
|
||||||
|
const mode = q.judgeMode;
|
||||||
|
if (mode !== undefined && !SOLVE_JUDGE_MODES.includes(mode as (typeof SOLVE_JUDGE_MODES)[number])) {
|
||||||
|
return { error: `${no}(solve)judgeMode 非法:${String(mode)}` };
|
||||||
|
}
|
||||||
|
if (mode === "answer") {
|
||||||
|
if (!Array.isArray(q.answers) || q.answers.length === 0) return { error: `${no}(solve·answer 模式)缺少最终答案(answers 数组)` };
|
||||||
|
if (Array.isArray(q.rubric) && q.rubric.length > 0) return { error: `${no}(solve·answer 模式)不应携带 rubric 评分点` };
|
||||||
|
} else {
|
||||||
|
if (typeof q.reference !== "string" || !q.reference.trim()) return { error: `${no}(solve)缺少参考解答(reference)` };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return { count: arr.length };
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user