From 5e4904742a6750d83ac645b1b29f6e55442d1a93 Mon Sep 17 00:00:00 2001 From: jacobxu666 Date: Thu, 20 Aug 2026 14:49:21 +0800 Subject: [PATCH] =?UTF-8?q?=E7=BC=96=E8=BE=91=E5=99=A8=E6=96=B0=E5=BB=BA?= =?UTF-8?q?=E5=BA=94=E7=94=A8=E9=A2=98=E4=BB=85=20answer=20=E6=A8=A1?= =?UTF-8?q?=E5=BC=8F+=E6=95=B0=E6=8D=AE=E5=AE=8C=E6=95=B4=E6=80=A7?= =?UTF-8?q?=E4=BF=AE=E5=A4=8D(=E6=9C=80=E7=BB=88=E8=AF=84=E5=AE=A1:rubric?= =?UTF-8?q?=200=E5=88=86=E5=AE=88=E5=8D=AB/=E7=AD=94=E6=A1=88=E4=B8=8B?= =?UTF-8?q?=E6=A0=87/points/ossKey=20=E5=89=8D=E7=BC=80/=E6=B5=8B=E8=AF=95?= =?UTF-8?q?=E4=BF=AE=E6=AD=A3)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Haiku 4.5 --- .gitea/workflows/test.yaml | 2 +- src/app/exam/actions.ts | 10 ++-- src/app/parent/exams/question-list.test.tsx | 52 ++++++++++++++++---- src/app/parent/exams/question-list.tsx | 53 ++++++++------------- src/lib/answer-match.test.ts | 2 +- src/lib/answer-match.ts | 2 +- src/lib/question-validation.test.ts | 6 ++- src/lib/question-validation.ts | 4 +- src/test-setup.ts | 2 +- vitest.config.ts | 2 +- 10 files changed, 83 insertions(+), 52 deletions(-) diff --git a/.gitea/workflows/test.yaml b/.gitea/workflows/test.yaml index 274404c..836f329 100644 --- a/.gitea/workflows/test.yaml +++ b/.gitea/workflows/test.yaml @@ -9,4 +9,4 @@ jobs: steps: - uses: actions/checkout@v4 - run: npm ci - - run: npm run test \ No newline at end of file + - run: npm run test diff --git a/src/app/exam/actions.ts b/src/app/exam/actions.ts index 2f6834f..6993d62 100644 --- a/src/app/exam/actions.ts +++ b/src/app/exam/actions.ts @@ -149,10 +149,14 @@ export async function takeExam(paperId: string, answers: (AnswerValue | null)[]) const hits: GradeVerdict["hits"] = [ { point: "最终答案", score: earned, hit, reason: hit ? "与最终答案一致" : `期望:${(q.answers ?? []).join(" / ")},实际:${given || "未作答"}` }, ]; - solves.push({ qIndex: i, ossKey: payload?.ossKey ?? "", status: hit ? "ok" : "wrong", earned, points: pts, hits, comment: "" }); - storedAnswers.push({ type: "solve", qIndex: i, ossKey: payload?.ossKey ?? "", earned, points: pts }); + // xuchao: ossKey 前缀校验——防止客户端伪造路径 + const prefix = `exam-solve/${user.id}/${paperId}/`; + const rawKey = payload?.ossKey ?? ""; + const safeKey = rawKey.startsWith(prefix) && rawKey.endsWith(".jpg") ? rawKey : ""; + solves.push({ qIndex: i, ossKey: safeKey, status: hit ? "ok" : "wrong", earned, points: pts, hits, comment: "" }); + storedAnswers.push({ type: "solve", qIndex: i, ossKey: safeKey, earned, points: pts }); if (!hit) { - void recordWrong(user.id, "exam", paperId, paper.title, q, null, { ossKey: payload?.ossKey ?? "", earned, hits }); + void recordWrong(user.id, "exam", paperId, paper.title, q, null, { ossKey: safeKey, earned, hits }); } return; } diff --git a/src/app/parent/exams/question-list.test.tsx b/src/app/parent/exams/question-list.test.tsx index e369eb7..8dd2407 100644 --- a/src/app/parent/exams/question-list.test.tsx +++ b/src/app/parent/exams/question-list.test.tsx @@ -1,22 +1,29 @@ // @vitest-environment jsdom import { fireEvent, render, screen } from "@testing-library/react"; import userEvent from "@testing-library/user-event"; -import { describe, expect, it } from "vitest"; +import { beforeEach, describe, expect, it, vi } from "vitest"; import { QuestionList } from "./question-list"; +import type { QuizQ } from "@/lib/quiz-parse"; const choiceQ = { type: "choice" as const, stem: "1+1=?", options: ["1", "2"], answer: 1, explain: "e" }; +beforeEach(() => { + vi.spyOn(window, "confirm").mockReturnValue(true); +}); + describe("QuestionList 结构化编辑", () => { it("渲染题目卡片(题型徽标+题干)并可删除", () => { - render( {}} />); + let saved: QuizQ[] = [{ ...choiceQ }]; + render( (saved = v)} />); expect(screen.getByText("选择题")).toBeInTheDocument(); expect(screen.getByText("1+1=?")).toBeInTheDocument(); fireEvent.click(screen.getByLabelText("删除第 1 题")); + expect(saved.length).toBe(0); }); it("新增选择题:填题干/选项/点选答案/解析后保存进列表", async () => { const user = userEvent.setup(); - let saved: unknown[] = []; + let saved: QuizQ[] = []; render( (saved = v)} />); await user.click(screen.getByText("+ 新增题目")); await user.selectOptions(screen.getByLabelText("题型"), "choice"); @@ -33,23 +40,48 @@ describe("QuestionList 结构化编辑", () => { expect((saved[0] as Record).explain).toBe("e1"); }); - it("应用题 judgeMode 切换:answer 显示最终答案数组,rubric 显示评分点", async () => { + it("新建应用题仅 answer 模式(无判分模式 select)", async () => { const user = userEvent.setup(); - render( {}} />); + render( {}} />); + await user.click(screen.getByText("+ 新增题目")); + await user.selectOptions(screen.getByLabelText("题型"), "solve"); + expect(screen.queryByLabelText("判分模式")).not.toBeInTheDocument(); + expect(screen.getByText("仅最终答案(秒判)")).toBeInTheDocument(); + }); + + it("编辑存量 rubric 题显示只读徽标", () => { + render( + {}} + />, + ); fireEvent.click(screen.getByLabelText("编辑第 1 题")); - await user.selectOptions(screen.getByLabelText("判分模式"), "answer"); - expect(screen.getByText("最终答案(等价答法可填多个)")).toBeInTheDocument(); - await user.selectOptions(screen.getByLabelText("判分模式"), "rubric"); - expect(screen.getByLabelText("参考解答")).toBeInTheDocument(); + expect(screen.queryByLabelText("判分模式")).not.toBeInTheDocument(); + expect(screen.getByText("AI 逐评分点判分(存量题,保留原评分点)")).toBeInTheDocument(); }); it("题干为空保存时报错,不写入列表", async () => { const user = userEvent.setup(); - let saved: unknown[] = []; + let saved: QuizQ[] = []; render( (saved = v)} />); await user.click(screen.getByText("+ 新增题目")); await user.click(screen.getByText("完成")); expect(saved.length).toBe(0); expect(screen.getByText(/缺少题干/)).toBeInTheDocument(); }); + + it("删除选项后 answer 下标正确回落", async () => { + const user = userEvent.setup(); + let saved: QuizQ[] = [{ type: "choice" as const, stem: "q", options: ["a", "b", "c"], answer: 2, explain: "e" }]; + render( (saved = v)} />); + fireEvent.click(screen.getByLabelText("编辑第 1 题")); + // xuchao: buttons = [上移,下移,编辑,删除, 取消, 添加选项, 删a, 删b, 删c, 完成] → 删b = [7] + const buttons = screen.getAllByRole("button"); + fireEvent.click(buttons[7]); + // xuchao: 点"完成"保存,onChange 被调用 + await user.click(screen.getByText("完成")); + const d = saved[0] as Record; + expect(d.answer).toBe(1); + }); }); diff --git a/src/app/parent/exams/question-list.tsx b/src/app/parent/exams/question-list.tsx index ae05c63..13efd26 100644 --- a/src/app/parent/exams/question-list.tsx +++ b/src/app/parent/exams/question-list.tsx @@ -41,16 +41,18 @@ export function QuestionList({ const commit = () => { const d = draft as QuizQ; - if (!d.stem.trim()) return setError("第 1 题缺少题干(stem)"); - if (!d.explain.trim()) return setError("第 1 题缺少解析(explain)"); - if (d.type === "choice" && !d.options?.some((o) => o.trim())) return setError("第 1 题缺少选项(options)"); - if (d.type === "fill" && (d.answers ?? []).every((a) => !a.trim())) return setError("第 1 题缺少参考答案(answers 数组)"); + const no = `第 ${editIndex != null ? editIndex + 1 : value.length + 1} 题`; + if (!d.stem.trim()) return setError(`${no}缺少题干(stem)`); + if (!d.explain.trim()) return setError(`${no}缺少解析(explain)`); + if (d.type === "choice" && !d.options?.some((o) => o.trim())) return setError(`${no}缺少选项(options)`); + if (d.type === "fill" && (d.answers ?? []).every((a) => !a.trim())) return setError(`${no}缺少参考答案(answers 数组)`); if (d.type === "solve") { if (d.judgeMode === "answer") { - if ((d.answers ?? []).every((a) => !a.trim())) return setError("第 1 题缺少最终答案(answers 数组)"); + if ((d.answers ?? []).every((a) => !a.trim())) return setError(`${no}缺少最终答案(answers 数组)`); } else if (!d.reference?.trim()) { - return setError("第 1 题缺少参考解答(reference)"); + return setError(`${no}缺少参考解答(reference)`); } + if (!(d.points && d.points > 0)) return setError(`${no}缺少满分(points>0)`); } setError(""); const q = { ...d, stem: d.stem.trim(), explain: d.explain.trim() }; @@ -108,7 +110,7 @@ export function QuestionList({ aria-label="题型" onChange={(e) => { const t = e.target.value as QuizQ["type"]; - update({ type: t, answers: undefined, options: undefined, reference: undefined }); + update({ type: t, options: undefined, answers: undefined, reference: undefined, judgeMode: undefined, points: undefined, ...(t === "solve" ? { judgeMode: "answer", points: 10 } : {}) }); }} className="ml-2 h-8 rounded-lg border border-slate-200 bg-white px-2 text-sm" > @@ -148,7 +150,13 @@ export function QuestionList({ aria-label={`选 ${o || `选项${oi + 1}`} 为正确答案`} /> update({ options: (draft.options ?? []).map((x, xi) => (xi === oi ? e.target.value : x)) })} /> - @@ -201,32 +209,13 @@ export function QuestionList({ onChange={(e) => update({ points: Number(e.target.value) || undefined })} /> +
+ {draft.judgeMode === "rubric" ? "AI 逐评分点判分(存量题,保留原评分点)" : "仅最终答案(秒判)"} +
- - + + update({ reference: e.target.value })} />
- {draft.judgeMode === "answer" && draft.answers == null && ( -

选「仅最终答案」后请填写最终答案数组(第 1 题缺少最终答案时无法保存)。

- )} - {draft.judgeMode === "answer" ? ( -
- - update({ reference: e.target.value })} /> -
- ) : ( -
- - update({ reference: e.target.value })} /> -
- )} )} diff --git a/src/lib/answer-match.test.ts b/src/lib/answer-match.test.ts index c046e62..7ad5345 100644 --- a/src/lib/answer-match.test.ts +++ b/src/lib/answer-match.test.ts @@ -54,4 +54,4 @@ describe("solveAnswerHit", () => { expect(solveAnswerHit(q, "3")).toBe(false); expect(solveAnswerHit({ answers: [] }, "2")).toBe(false); }); -}); \ No newline at end of file +}); diff --git a/src/lib/answer-match.ts b/src/lib/answer-match.ts index a30bfd0..a836781 100644 --- a/src/lib/answer-match.ts +++ b/src/lib/answer-match.ts @@ -30,4 +30,4 @@ export function answersMatch(expected: string[], given: string): boolean { // xuchao: 应用题 answer 模式判定——最终答案等值即判对 export function solveAnswerHit(q: Pick, given: string): boolean { return answersMatch(q.answers ?? [], given); -} \ No newline at end of file +} diff --git a/src/lib/question-validation.test.ts b/src/lib/question-validation.test.ts index c08906a..4bb0370 100644 --- a/src/lib/question-validation.test.ts +++ b/src/lib/question-validation.test.ts @@ -51,6 +51,10 @@ describe("validateQuestions solve / judgeMode", () => { const { reference: _ref, ...rest } = solveRubric; expect(validateQuestions(JSON.stringify([rest])).error).toContain("缺少参考解答"); }); + it("rubric 模式缺 rubric 评分点报错", () => { + const { rubric: _rub, ...rest } = solveRubric; + expect(validateQuestions(JSON.stringify([rest])).error).toContain("缺少评分点"); + }); it("answer 模式缺 answers 报错,不要求 reference", () => { const q = { ...solveRubric, judgeMode: "answer", answers: [], rubric: undefined }; delete q.rubric; @@ -77,4 +81,4 @@ describe("validateQuestions solve / judgeMode", () => { 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 index 74a9e26..0b657d2 100644 --- a/src/lib/question-validation.ts +++ b/src/lib/question-validation.ts @@ -23,6 +23,7 @@ export function validateQuestions(raw: string): ValidationResult { 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 === "choice" && (typeof q.answer !== "number" || q.answer < 0 || q.answer >= (q.options as unknown[]).length)) return { error: `${no}(choice)答案下标越界` }; 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 数组)` }; @@ -40,8 +41,9 @@ export function validateQuestions(raw: string): ValidationResult { 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)` }; + if (!Array.isArray(q.rubric) || q.rubric.length === 0) return { error: `${no}(solve·rubric 模式)缺少评分点(rubric)` }; } } } return { count: arr.length }; -} \ No newline at end of file +} diff --git a/src/test-setup.ts b/src/test-setup.ts index c359671..7dc6606 100644 --- a/src/test-setup.ts +++ b/src/test-setup.ts @@ -3,4 +3,4 @@ import { afterEach } from "vitest"; import { cleanup } from "@testing-library/react"; // xuchao: 组件测试后自动清理 DOM,避免跨测试污染 -afterEach(() => cleanup()); \ No newline at end of file +afterEach(() => cleanup()); diff --git a/vitest.config.ts b/vitest.config.ts index 8908ca6..fcb65fa 100644 --- a/vitest.config.ts +++ b/vitest.config.ts @@ -10,4 +10,4 @@ export default defineConfig({ include: ["src/**/*.test.{ts,tsx}"], setupFiles: ["src/test-setup.ts"], }, -}); \ No newline at end of file +});