From e896b2be5126471de18bbc95fe87b687790403fe Mon Sep 17 00:00:00 2001 From: jacobxu666 Date: Thu, 20 Aug 2026 13:59:51 +0800 Subject: [PATCH] =?UTF-8?q?=E9=A2=98=E7=9B=AE=E6=A0=A1=E9=AA=8C=E6=8A=BD?= =?UTF-8?q?=E5=8F=96=E4=B8=BA=E5=8F=AF=E5=8D=95=E6=B5=8B=20lib;=E6=89=A9?= =?UTF-8?q?=E5=B1=95=20solve=20judgeMode(answer/rubric)=E5=88=86=E6=94=AF?= =?UTF-8?q?=E6=A0=A1=E9=AA=8C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Haiku 4.5 --- src/app/parent/actions.ts | 37 +------------ src/lib/question-validation.test.ts | 80 +++++++++++++++++++++++++++++ src/lib/question-validation.ts | 47 +++++++++++++++++ 3 files changed, 129 insertions(+), 35 deletions(-) create mode 100644 src/lib/question-validation.test.ts create mode 100644 src/lib/question-validation.ts diff --git a/src/app/parent/actions.ts b/src/app/parent/actions.ts index f531688..f570891 100644 --- a/src/app/parent/actions.ts +++ b/src/app/parent/actions.ts @@ -11,6 +11,7 @@ import { todayStr } from "@/lib/dates"; import { ensureDay } from "@/lib/tasks"; import { getSessionUser } from "@/lib/auth"; import { DEFAULT_RULE_POINTS, upsertPointRule } from "@/lib/points"; +import { validateQuestions } from "@/lib/question-validation"; function hashCode(code: string) { return crypto.createHash("sha256").update(code).digest("hex"); @@ -325,40 +326,6 @@ export async function deleteExamRecord(id: number): Promise { 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; - 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 立即生效) export async function saveExamPaper(formData: FormData): Promise<{ ok?: boolean; error?: string }> { 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(seq) || seq < 0) return { error: "展示序号需为不小于 0 的整数" }; const questions = f("questions"); - const chk = validateQuestionsJson(questions); + const chk = validateQuestions(questions); if (chk.error) return { error: chk.error }; await db diff --git a/src/lib/question-validation.test.ts b/src/lib/question-validation.test.ts new file mode 100644 index 0000000..c08906a --- /dev/null +++ b/src/lib/question-validation.test.ts @@ -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); + }); +}); \ No newline at end of file diff --git a/src/lib/question-validation.ts b/src/lib/question-validation.ts new file mode 100644 index 0000000..74a9e26 --- /dev/null +++ b/src/lib/question-validation.ts @@ -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; + 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 }; +} \ No newline at end of file