编辑器新建应用题仅 answer 模式+数据完整性修复(最终评审:rubric 0分守卫/答案下标/points/ossKey 前缀/测试修正)

Co-Authored-By: Claude Haiku 4.5 <noreply@anthropic.com>
This commit is contained in:
jacobxu666
2026-08-20 14:49:21 +08:00
co-authored by Claude Haiku 4.5
parent fe68ed7512
commit 5e4904742a
10 changed files with 83 additions and 52 deletions
+7 -3
View File
@@ -149,10 +149,14 @@ export async function takeExam(paperId: string, answers: (AnswerValue | null)[])
const hits: GradeVerdict["hits"] = [ const hits: GradeVerdict["hits"] = [
{ point: "最终答案", score: earned, hit, reason: hit ? "与最终答案一致" : `期望:${(q.answers ?? []).join(" / ")},实际:${given || "未作答"}` }, { 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: "" }); // xuchao: ossKey 前缀校验——防止客户端伪造路径
storedAnswers.push({ type: "solve", qIndex: i, ossKey: payload?.ossKey ?? "", earned, points: pts }); 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) { 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; return;
} }
+42 -10
View File
@@ -1,22 +1,29 @@
// @vitest-environment jsdom // @vitest-environment jsdom
import { fireEvent, render, screen } from "@testing-library/react"; import { fireEvent, render, screen } from "@testing-library/react";
import userEvent from "@testing-library/user-event"; 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 { 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" }; 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 结构化编辑", () => { describe("QuestionList 结构化编辑", () => {
it("渲染题目卡片(题型徽标+题干)并可删除", () => { it("渲染题目卡片(题型徽标+题干)并可删除", () => {
render(<QuestionList value={[{ ...choiceQ }]} onChange={() => {}} />); let saved: QuizQ[] = [{ ...choiceQ }];
render(<QuestionList value={saved} onChange={(v) => (saved = v)} />);
expect(screen.getByText("选择题")).toBeInTheDocument(); expect(screen.getByText("选择题")).toBeInTheDocument();
expect(screen.getByText("1+1=?")).toBeInTheDocument(); expect(screen.getByText("1+1=?")).toBeInTheDocument();
fireEvent.click(screen.getByLabelText("删除第 1 题")); fireEvent.click(screen.getByLabelText("删除第 1 题"));
expect(saved.length).toBe(0);
}); });
it("新增选择题:填题干/选项/点选答案/解析后保存进列表", async () => { it("新增选择题:填题干/选项/点选答案/解析后保存进列表", async () => {
const user = userEvent.setup(); const user = userEvent.setup();
let saved: unknown[] = []; let saved: QuizQ[] = [];
render(<QuestionList value={[]} onChange={(v) => (saved = v)} />); render(<QuestionList value={[]} onChange={(v) => (saved = v)} />);
await user.click(screen.getByText("+ 新增题目")); await user.click(screen.getByText("+ 新增题目"));
await user.selectOptions(screen.getByLabelText("题型"), "choice"); await user.selectOptions(screen.getByLabelText("题型"), "choice");
@@ -33,23 +40,48 @@ describe("QuestionList 结构化编辑", () => {
expect((saved[0] as Record<string, unknown>).explain).toBe("e1"); expect((saved[0] as Record<string, unknown>).explain).toBe("e1");
}); });
it("应用题 judgeMode 切换:answer 显示最终答案数组,rubric 显示评分点", async () => { it("新建应用题仅 answer 模式(无判分模式 select)", async () => {
const user = userEvent.setup(); const user = userEvent.setup();
render(<QuestionList value={[{ type: "solve", stem: "解方程", points: 10, reference: "x=2", explain: "e" }]} onChange={() => {}} />); render(<QuestionList value={[]} onChange={() => {}} />);
await user.click(screen.getByText("+ 新增题目"));
await user.selectOptions(screen.getByLabelText("题型"), "solve");
expect(screen.queryByLabelText("判分模式")).not.toBeInTheDocument();
expect(screen.getByText("仅最终答案(秒判)")).toBeInTheDocument();
});
it("编辑存量 rubric 题显示只读徽标", () => {
render(
<QuestionList
value={[{ type: "solve", stem: "解方程", points: 10, judgeMode: "rubric" as const, reference: "x=2", rubric: [{ point: "过程", score: 6 }], explain: "e" }]}
onChange={() => {}}
/>,
);
fireEvent.click(screen.getByLabelText("编辑第 1 题")); fireEvent.click(screen.getByLabelText("编辑第 1 题"));
await user.selectOptions(screen.getByLabelText("判分模式"), "answer"); expect(screen.queryByLabelText("判分模式")).not.toBeInTheDocument();
expect(screen.getByText("最终答案(等价答法可填多个)")).toBeInTheDocument(); expect(screen.getByText("AI 逐评分点判分(存量题,保留原评分点)")).toBeInTheDocument();
await user.selectOptions(screen.getByLabelText("判分模式"), "rubric");
expect(screen.getByLabelText("参考解答")).toBeInTheDocument();
}); });
it("题干为空保存时报错,不写入列表", async () => { it("题干为空保存时报错,不写入列表", async () => {
const user = userEvent.setup(); const user = userEvent.setup();
let saved: unknown[] = []; let saved: QuizQ[] = [];
render(<QuestionList value={[]} onChange={(v) => (saved = v)} />); render(<QuestionList value={[]} onChange={(v) => (saved = v)} />);
await user.click(screen.getByText("+ 新增题目")); await user.click(screen.getByText("+ 新增题目"));
await user.click(screen.getByText("完成")); await user.click(screen.getByText("完成"));
expect(saved.length).toBe(0); expect(saved.length).toBe(0);
expect(screen.getByText(/缺少题干/)).toBeInTheDocument(); 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(<QuestionList value={saved} onChange={(v) => (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<string, unknown>;
expect(d.answer).toBe(1);
});
}); });
+21 -32
View File
@@ -41,16 +41,18 @@ export function QuestionList({
const commit = () => { const commit = () => {
const d = draft as QuizQ; const d = draft as QuizQ;
if (!d.stem.trim()) return setError("第 1 题缺少题干(stem)"); const no = `第 ${editIndex != null ? editIndex + 1 : value.length + 1} 题`;
if (!d.explain.trim()) return setError("第 1 题缺少解析(explain)"); if (!d.stem.trim()) return setError(`${no}缺少题干(stem)`);
if (d.type === "choice" && !d.options?.some((o) => o.trim())) return setError("第 1 题缺少选项(options)"); if (!d.explain.trim()) return setError(`${no}缺少解析(explain)`);
if (d.type === "fill" && (d.answers ?? []).every((a) => !a.trim())) return setError("第 1 题缺少参考答案(answers 数组)"); 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.type === "solve") {
if (d.judgeMode === "answer") { 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()) { } 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(""); setError("");
const q = { ...d, stem: d.stem.trim(), explain: d.explain.trim() }; const q = { ...d, stem: d.stem.trim(), explain: d.explain.trim() };
@@ -108,7 +110,7 @@ export function QuestionList({
aria-label="题型" aria-label="题型"
onChange={(e) => { onChange={(e) => {
const t = e.target.value as QuizQ["type"]; 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" 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}`} 为正确答案`} aria-label={`选 ${o || `选项${oi + 1}`} 为正确答案`}
/> />
<Input placeholder={`选项${oi + 1}`} value={o} onChange={(e) => update({ options: (draft.options ?? []).map((x, xi) => (xi === oi ? e.target.value : x)) })} /> <Input placeholder={`选项${oi + 1}`} value={o} onChange={(e) => update({ options: (draft.options ?? []).map((x, xi) => (xi === oi ? e.target.value : x)) })} />
<Button size="sm" variant="ghost" className="h-7 px-2" onClick={() => update({ options: (draft.options ?? []).filter((_, xi) => xi !== oi) })}> <Button size="sm" variant="ghost" className="h-7 px-2" onClick={() => {
const newOpts = (draft.options ?? []).filter((_, xi) => xi !== oi);
const patch: Partial<QuizQ> = { options: newOpts };
if (draft.answer === oi) patch.answer = 0;
else if (typeof draft.answer === "number" && draft.answer > oi) patch.answer = draft.answer - 1;
update(patch);
}}>
<X className="size-4" /> <X className="size-4" />
</Button> </Button>
</div> </div>
@@ -201,32 +209,13 @@ export function QuestionList({
onChange={(e) => update({ points: Number(e.target.value) || undefined })} onChange={(e) => update({ points: Number(e.target.value) || undefined })}
/> />
</div> </div>
<div className="rounded-md bg-slate-50 px-3 py-2 text-xs text-slate-600">
{draft.judgeMode === "rubric" ? "AI 逐评分点判分(存量题,保留原评分点)" : "仅最终答案(秒判)"}
</div>
<div className="space-y-1"> <div className="space-y-1">
<Label>判分模式</Label> <Label>参考解答(可选,给孩子看)</Label>
<select <Input aria-label="参考解答" value={draft.reference ?? ""} onChange={(e) => update({ reference: e.target.value })} />
aria-label="判分模式"
value={draft.judgeMode ?? "rubric"}
onChange={(e) => update({ judgeMode: e.target.value as "rubric" | "answer" })}
className="h-8 rounded-lg border border-slate-200 bg-white px-2 text-sm"
>
<option value="answer">仅最终答案(秒判)</option>
<option value="rubric">AI 逐评分点判分</option>
</select>
</div> </div>
{draft.judgeMode === "answer" && draft.answers == null && (
<p className="text-xs text-slate-500">选「仅最终答案」后请填写最终答案数组(第 1 题缺少最终答案时无法保存)。</p>
)}
{draft.judgeMode === "answer" ? (
<div className="space-y-1">
<Label>参考解答(可选,给孩子看)</Label>
<Input aria-label="参考解答" value={draft.reference ?? ""} onChange={(e) => update({ reference: e.target.value })} />
</div>
) : (
<div className="space-y-1">
<Label>参考解答(完整解题过程,展示给孩子)</Label>
<Input aria-label="参考解答" value={draft.reference ?? ""} onChange={(e) => update({ reference: e.target.value })} />
</div>
)}
</div> </div>
)} )}
+4
View File
@@ -51,6 +51,10 @@ describe("validateQuestions solve / judgeMode", () => {
const { reference: _ref, ...rest } = solveRubric; const { reference: _ref, ...rest } = solveRubric;
expect(validateQuestions(JSON.stringify([rest])).error).toContain("缺少参考解答"); 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", () => { it("answer 模式缺 answers 报错,不要求 reference", () => {
const q = { ...solveRubric, judgeMode: "answer", answers: [], rubric: undefined }; const q = { ...solveRubric, judgeMode: "answer", answers: [], rubric: undefined };
delete q.rubric; delete q.rubric;
+2
View File
@@ -23,6 +23,7 @@ export function validateQuestions(raw: string): ValidationResult {
if (!EXAM_TYPES.includes(t)) return { error: `${no}的 type 非法:${String(q.type)}` }; 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" || 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" && (!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 === "fill" && !Array.isArray(q.answers)) return { error: `${no}(fill)缺少参考答案(answers 数组)` };
if (t === "order") { if (t === "order") {
if (!Array.isArray(q.answer)) return { error: `${no}(order)缺少答案(answer 数组)` }; if (!Array.isArray(q.answer)) return { error: `${no}(order)缺少答案(answer 数组)` };
@@ -40,6 +41,7 @@ export function validateQuestions(raw: string): ValidationResult {
if (Array.isArray(q.rubric) && q.rubric.length > 0) return { error: `${no}(solve·answer 模式)不应携带 rubric 评分点` }; if (Array.isArray(q.rubric) && q.rubric.length > 0) return { error: `${no}(solve·answer 模式)不应携带 rubric 评分点` };
} else { } else {
if (typeof q.reference !== "string" || !q.reference.trim()) return { error: `${no}(solve)缺少参考解答(reference)` }; 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)` };
} }
} }
} }