fix(dashboard): v3 审计修复 — 数据完整性、i18n、类型安全、死代码清理
P0 修复(严重):
- admin ContentRow 标签与值错配(stats.users→textbooks 等 6 处)
- admin/error.tsx 硬编码中文替换为 useTranslations
- UserGrowthChart 空数据时渲染 EmptyState(userGrowth/homeworkTrend 永远为空数组)
P1 修复(高):
- 新增 admin/dashboard 和 student/dashboard 的 loading.tsx + error.tsx
- 抽取 DashboardLoadingSkeleton 和 DashboardErrorFallback 共享组件,消除 5 套重复文件
- formatDate/formatLongDate 传入用户 locale(admin/teacher/student 共 6 个组件)
- 移除死代码:getCachedAdminDashboard、AvatarImage src={undefined}、TeacherStats isLoading prop
- filterTodaySchedule 改为泛型函数,消除 as 类型断言
- 辅助函数 getStatus/getDueUrgency 新增显式返回类型
- UserGrowthChart 新增 labelKey prop 区分用户增长/作业提交趋势标签
P2 修复(中):
- 4 个组件从客户端转为服务端组件(DashboardGreetingHeader、TeacherQuickActions、TeacherDashboardHeader、StudentDashboardHeader)
- Student dashboard 空状态新增 CTA(viewSchedule、viewAll)
- TeacherHomeworkCard 图标按钮新增 aria-label
- TeacherTodoCard 排序逻辑重写为可读的 if/return 模式
同步更新:
- docs/architecture/005_architecture_data.json 新增 DashboardLoadingSkeleton、DashboardErrorFallback 条目
- 新增 docs/architecture/audit/dashboard-audit-report-v3.md 审计报告
- dashboard.json 新增 6 个 i18n 键(textbooks/chapters/questions/exams/totalAssignments/totalSubmissions)
This commit is contained in:
433
src/modules/homework/lib/question-content-utils.test.ts
Normal file
433
src/modules/homework/lib/question-content-utils.test.ts
Normal file
@@ -0,0 +1,433 @@
|
||||
/**
|
||||
* 6.5: 单测覆盖权限校验与数据流转
|
||||
*
|
||||
* 覆盖范围:
|
||||
* - question-content-utils 的纯函数(数据流转核心)
|
||||
* - exam-homework-role-config 的角色特性合并逻辑(权限校验配置层)
|
||||
* - applyAutoGrades 自动判分流程
|
||||
*/
|
||||
|
||||
import { describe, it, expect } from "vitest"
|
||||
import {
|
||||
isRecord,
|
||||
getQuestionText,
|
||||
getOptions,
|
||||
getChoiceCorrectIds,
|
||||
getJudgmentCorrectAnswer,
|
||||
getTextCorrectAnswers,
|
||||
parseSavedAnswer,
|
||||
extractAnswerValue,
|
||||
normalizeText,
|
||||
isAutoGradable,
|
||||
computeIsCorrect,
|
||||
getCorrectnessState,
|
||||
applyAutoGrades,
|
||||
formatStudentAnswer,
|
||||
type AutoGradableAnswer,
|
||||
} from "./question-content-utils"
|
||||
|
||||
describe("isRecord", () => {
|
||||
it("returns true for non-null objects (including arrays)", () => {
|
||||
expect(isRecord({})).toBe(true)
|
||||
expect(isRecord({ a: 1 })).toBe(true)
|
||||
// Note: arrays are objects in JS, so isRecord returns true for them.
|
||||
// Callers that need to exclude arrays should check Array.isArray separately.
|
||||
expect(isRecord([1, 2])).toBe(true)
|
||||
})
|
||||
it("returns false for null and primitives", () => {
|
||||
expect(isRecord(null)).toBe(false)
|
||||
expect(isRecord(undefined)).toBe(false)
|
||||
expect(isRecord("string")).toBe(false)
|
||||
expect(isRecord(42)).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
describe("getQuestionText", () => {
|
||||
it("extracts text from valid content", () => {
|
||||
expect(getQuestionText({ text: "What is 2+2?" })).toBe("What is 2+2?")
|
||||
})
|
||||
it("returns empty string for missing or non-string text", () => {
|
||||
expect(getQuestionText({})).toBe("")
|
||||
expect(getQuestionText({ text: 123 })).toBe("")
|
||||
expect(getQuestionText(null)).toBe("")
|
||||
expect(getQuestionText("string")).toBe("")
|
||||
})
|
||||
})
|
||||
|
||||
describe("getOptions", () => {
|
||||
it("parses valid options array", () => {
|
||||
const content = {
|
||||
options: [
|
||||
{ id: "a", text: "Option A", isCorrect: true },
|
||||
{ id: "b", text: "Option B" },
|
||||
],
|
||||
}
|
||||
const opts = getOptions(content)
|
||||
expect(opts).toHaveLength(2)
|
||||
expect(opts[0]).toEqual({ id: "a", text: "Option A", isCorrect: true })
|
||||
expect(opts[1]).toEqual({ id: "b", text: "Option B", isCorrect: false })
|
||||
})
|
||||
it("filters out options missing id or text", () => {
|
||||
const content = {
|
||||
options: [
|
||||
{ id: "a", text: "Valid" },
|
||||
{ id: "", text: "No ID" },
|
||||
{ id: "c", text: "" },
|
||||
{ id: "d" },
|
||||
{ text: "No ID" },
|
||||
],
|
||||
}
|
||||
expect(getOptions(content)).toHaveLength(1)
|
||||
})
|
||||
it("returns empty array when options is missing or not an array", () => {
|
||||
expect(getOptions({})).toEqual([])
|
||||
expect(getOptions({ options: "not-array" })).toEqual([])
|
||||
expect(getOptions(null)).toEqual([])
|
||||
})
|
||||
})
|
||||
|
||||
describe("getChoiceCorrectIds", () => {
|
||||
it("returns IDs of options marked isCorrect", () => {
|
||||
const content = {
|
||||
options: [
|
||||
{ id: "a", text: "A", isCorrect: true },
|
||||
{ id: "b", text: "B" },
|
||||
{ id: "c", text: "C", isCorrect: true },
|
||||
],
|
||||
}
|
||||
expect(getChoiceCorrectIds(content)).toEqual(["a", "c"])
|
||||
})
|
||||
it("returns empty array when no correct options", () => {
|
||||
const content = { options: [{ id: "a", text: "A" }] }
|
||||
expect(getChoiceCorrectIds(content)).toEqual([])
|
||||
})
|
||||
})
|
||||
|
||||
describe("getJudgmentCorrectAnswer", () => {
|
||||
it("returns boolean when correctAnswer is boolean", () => {
|
||||
expect(getJudgmentCorrectAnswer({ correctAnswer: true })).toBe(true)
|
||||
expect(getJudgmentCorrectAnswer({ correctAnswer: false })).toBe(false)
|
||||
})
|
||||
it("returns null for missing or non-boolean", () => {
|
||||
expect(getJudgmentCorrectAnswer({})).toBeNull()
|
||||
expect(getJudgmentCorrectAnswer({ correctAnswer: "true" })).toBeNull()
|
||||
expect(getJudgmentCorrectAnswer(null)).toBeNull()
|
||||
})
|
||||
})
|
||||
|
||||
describe("getTextCorrectAnswers", () => {
|
||||
it("returns array from string correctAnswer", () => {
|
||||
expect(getTextCorrectAnswers({ correctAnswer: "yes" })).toEqual(["yes"])
|
||||
})
|
||||
it("returns array from string[] correctAnswer", () => {
|
||||
expect(getTextCorrectAnswers({ correctAnswer: ["yes", "Yes", "YES"] })).toEqual([
|
||||
"yes",
|
||||
"Yes",
|
||||
"YES",
|
||||
])
|
||||
})
|
||||
it("returns empty array for missing or invalid", () => {
|
||||
expect(getTextCorrectAnswers({})).toEqual([])
|
||||
expect(getTextCorrectAnswers({ correctAnswer: 123 })).toEqual([])
|
||||
expect(getTextCorrectAnswers({ correctAnswer: [1, 2] })).toEqual([])
|
||||
})
|
||||
})
|
||||
|
||||
describe("parseSavedAnswer", () => {
|
||||
it("parses object with answer field", () => {
|
||||
expect(parseSavedAnswer({ answer: "test" }, "text")).toEqual({ answer: "test" })
|
||||
expect(parseSavedAnswer({ answer: ["a", "b"] }, "multiple_choice")).toEqual({
|
||||
answer: ["a", "b"],
|
||||
})
|
||||
})
|
||||
it("returns empty answer for null/undefined input", () => {
|
||||
expect(parseSavedAnswer(null, "text")).toEqual({ answer: "" })
|
||||
expect(parseSavedAnswer(undefined, "text")).toEqual({ answer: "" })
|
||||
})
|
||||
it("coerces non-matching types to defaults", () => {
|
||||
expect(parseSavedAnswer("raw-string", "text")).toEqual({ answer: "raw-string" })
|
||||
expect(parseSavedAnswer(123, "text")).toEqual({ answer: "" })
|
||||
expect(parseSavedAnswer("not-boolean", "judgment")).toEqual({ answer: false })
|
||||
})
|
||||
})
|
||||
|
||||
describe("extractAnswerValue", () => {
|
||||
it("extracts answer from object shape", () => {
|
||||
expect(extractAnswerValue({ answer: "test" })).toBe("test")
|
||||
expect(extractAnswerValue({ answer: ["a", "b"] })).toEqual(["a", "b"])
|
||||
})
|
||||
it("returns raw value for non-object shapes", () => {
|
||||
expect(extractAnswerValue("test")).toBe("test")
|
||||
expect(extractAnswerValue(["a", "b"])).toEqual(["a", "b"])
|
||||
expect(extractAnswerValue(null)).toBeNull()
|
||||
})
|
||||
})
|
||||
|
||||
describe("normalizeText", () => {
|
||||
it("trims and lowercases", () => {
|
||||
expect(normalizeText(" Hello World ")).toBe("hello world")
|
||||
})
|
||||
it("collapses internal whitespace", () => {
|
||||
expect(normalizeText("a b\tc")).toBe("a b c")
|
||||
})
|
||||
it("returns empty string for empty input", () => {
|
||||
expect(normalizeText("")).toBe("")
|
||||
expect(normalizeText(" ")).toBe("")
|
||||
})
|
||||
})
|
||||
|
||||
describe("isAutoGradable", () => {
|
||||
const choiceContent = {
|
||||
options: [{ id: "a", text: "A", isCorrect: true }],
|
||||
}
|
||||
const judgmentContent = { correctAnswer: true }
|
||||
|
||||
it("returns true for choice types with correct options", () => {
|
||||
expect(isAutoGradable({ questionType: "single_choice", questionContent: choiceContent })).toBe(true)
|
||||
expect(isAutoGradable({ questionType: "multiple_choice", questionContent: choiceContent })).toBe(true)
|
||||
})
|
||||
it("returns false for choice types without correct options", () => {
|
||||
expect(isAutoGradable({ questionType: "single_choice", questionContent: {} })).toBe(false)
|
||||
expect(isAutoGradable({ questionType: "multiple_choice", questionContent: {} })).toBe(false)
|
||||
})
|
||||
it("returns true for judgment with boolean correctAnswer", () => {
|
||||
expect(isAutoGradable({ questionType: "judgment", questionContent: judgmentContent })).toBe(true)
|
||||
})
|
||||
it("returns false for judgment without correctAnswer", () => {
|
||||
expect(isAutoGradable({ questionType: "judgment", questionContent: {} })).toBe(false)
|
||||
})
|
||||
it("returns true for text type with correctAnswer", () => {
|
||||
expect(
|
||||
isAutoGradable({ questionType: "text", questionContent: { correctAnswer: "yes" } })
|
||||
).toBe(true)
|
||||
})
|
||||
it("returns false for text type without correctAnswer", () => {
|
||||
expect(isAutoGradable({ questionType: "text", questionContent: {} })).toBe(false)
|
||||
})
|
||||
it("returns false for unknown types", () => {
|
||||
expect(isAutoGradable({ questionType: "essay", questionContent: {} })).toBe(false)
|
||||
expect(isAutoGradable({ questionType: "fill_blank", questionContent: {} })).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
describe("computeIsCorrect", () => {
|
||||
const singleChoiceContent = {
|
||||
options: [
|
||||
{ id: "a", text: "A", isCorrect: true },
|
||||
{ id: "b", text: "B" },
|
||||
],
|
||||
}
|
||||
const multipleChoiceContent = {
|
||||
options: [
|
||||
{ id: "a", text: "A", isCorrect: true },
|
||||
{ id: "b", text: "B" },
|
||||
{ id: "c", text: "C", isCorrect: true },
|
||||
],
|
||||
}
|
||||
const judgmentContent = { correctAnswer: true }
|
||||
const textContent = { correctAnswer: ["yes", "Yes"] }
|
||||
|
||||
it("single_choice: correct", () => {
|
||||
expect(
|
||||
computeIsCorrect({
|
||||
questionType: "single_choice",
|
||||
questionContent: singleChoiceContent,
|
||||
studentAnswer: "a",
|
||||
})
|
||||
).toBe(true)
|
||||
})
|
||||
it("single_choice: incorrect", () => {
|
||||
expect(
|
||||
computeIsCorrect({
|
||||
questionType: "single_choice",
|
||||
questionContent: singleChoiceContent,
|
||||
studentAnswer: "b",
|
||||
})
|
||||
).toBe(false)
|
||||
})
|
||||
it("multiple_choice: correct (all and only correct)", () => {
|
||||
expect(
|
||||
computeIsCorrect({
|
||||
questionType: "multiple_choice",
|
||||
questionContent: multipleChoiceContent,
|
||||
studentAnswer: ["a", "c"],
|
||||
})
|
||||
).toBe(true)
|
||||
})
|
||||
it("multiple_choice: incorrect (missing one correct)", () => {
|
||||
expect(
|
||||
computeIsCorrect({
|
||||
questionType: "multiple_choice",
|
||||
questionContent: multipleChoiceContent,
|
||||
studentAnswer: ["a"],
|
||||
})
|
||||
).toBe(false)
|
||||
})
|
||||
it("multiple_choice: incorrect (extra wrong option)", () => {
|
||||
expect(
|
||||
computeIsCorrect({
|
||||
questionType: "multiple_choice",
|
||||
questionContent: multipleChoiceContent,
|
||||
studentAnswer: ["a", "b", "c"],
|
||||
})
|
||||
).toBe(false)
|
||||
})
|
||||
it("judgment: correct", () => {
|
||||
expect(
|
||||
computeIsCorrect({
|
||||
questionType: "judgment",
|
||||
questionContent: judgmentContent,
|
||||
studentAnswer: true,
|
||||
})
|
||||
).toBe(true)
|
||||
})
|
||||
it("judgment: incorrect", () => {
|
||||
expect(
|
||||
computeIsCorrect({
|
||||
questionType: "judgment",
|
||||
questionContent: judgmentContent,
|
||||
studentAnswer: false,
|
||||
})
|
||||
).toBe(false)
|
||||
})
|
||||
it("text: correct (case-insensitive)", () => {
|
||||
expect(
|
||||
computeIsCorrect({
|
||||
questionType: "text",
|
||||
questionContent: textContent,
|
||||
studentAnswer: " YES ",
|
||||
})
|
||||
).toBe(true)
|
||||
})
|
||||
it("text: incorrect", () => {
|
||||
expect(
|
||||
computeIsCorrect({
|
||||
questionType: "text",
|
||||
questionContent: textContent,
|
||||
studentAnswer: "no",
|
||||
})
|
||||
).toBe(false)
|
||||
})
|
||||
it("returns null for non-auto-gradable types", () => {
|
||||
expect(
|
||||
computeIsCorrect({
|
||||
questionType: "essay",
|
||||
questionContent: {},
|
||||
studentAnswer: "some text",
|
||||
})
|
||||
).toBeNull()
|
||||
})
|
||||
})
|
||||
|
||||
describe("getCorrectnessState", () => {
|
||||
it("returns 'ungraded' when score is null", () => {
|
||||
expect(getCorrectnessState({ score: null, maxScore: 10 })).toBe("ungraded")
|
||||
})
|
||||
it("returns 'correct' when score equals maxScore", () => {
|
||||
expect(getCorrectnessState({ score: 10, maxScore: 10 })).toBe("correct")
|
||||
})
|
||||
it("returns 'incorrect' when score is 0", () => {
|
||||
expect(getCorrectnessState({ score: 0, maxScore: 10 })).toBe("incorrect")
|
||||
})
|
||||
it("returns 'partial' when 0 < score < maxScore", () => {
|
||||
expect(getCorrectnessState({ score: 5, maxScore: 10 })).toBe("partial")
|
||||
})
|
||||
})
|
||||
|
||||
describe("applyAutoGrades", () => {
|
||||
const baseAnswers: AutoGradableAnswer[] = [
|
||||
{
|
||||
id: "a1",
|
||||
questionId: "q1",
|
||||
questionType: "single_choice",
|
||||
questionContent: {
|
||||
options: [
|
||||
{ id: "a", text: "A", isCorrect: true },
|
||||
{ id: "b", text: "B" },
|
||||
],
|
||||
},
|
||||
studentAnswer: "a",
|
||||
score: null,
|
||||
maxScore: 5,
|
||||
feedback: null,
|
||||
order: 0,
|
||||
},
|
||||
{
|
||||
id: "a2",
|
||||
questionId: "q2",
|
||||
questionType: "single_choice",
|
||||
questionContent: {
|
||||
options: [
|
||||
{ id: "a", text: "A", isCorrect: true },
|
||||
{ id: "b", text: "B" },
|
||||
],
|
||||
},
|
||||
studentAnswer: "b",
|
||||
score: null,
|
||||
maxScore: 5,
|
||||
feedback: null,
|
||||
order: 1,
|
||||
},
|
||||
{
|
||||
id: "a3",
|
||||
questionId: "q3",
|
||||
questionType: "essay",
|
||||
questionContent: {},
|
||||
studentAnswer: "some text",
|
||||
score: null,
|
||||
maxScore: 10,
|
||||
feedback: null,
|
||||
order: 2,
|
||||
},
|
||||
{
|
||||
id: "a4",
|
||||
questionId: "q4",
|
||||
questionType: "single_choice",
|
||||
questionContent: {
|
||||
options: [{ id: "a", text: "A", isCorrect: true }],
|
||||
},
|
||||
studentAnswer: "a",
|
||||
score: 5,
|
||||
maxScore: 5,
|
||||
feedback: null,
|
||||
order: 3,
|
||||
},
|
||||
]
|
||||
|
||||
it("auto-grades correct answer to full score", () => {
|
||||
const result = applyAutoGrades(baseAnswers)
|
||||
expect(result[0].score).toBe(5)
|
||||
})
|
||||
it("auto-grades incorrect answer to 0", () => {
|
||||
const result = applyAutoGrades(baseAnswers)
|
||||
expect(result[1].score).toBe(0)
|
||||
})
|
||||
it("leaves non-auto-gradable answers with null score", () => {
|
||||
const result = applyAutoGrades(baseAnswers)
|
||||
expect(result[2].score).toBeNull()
|
||||
})
|
||||
it("does not overwrite already-graded answers", () => {
|
||||
const result = applyAutoGrades(baseAnswers)
|
||||
expect(result[3].score).toBe(5)
|
||||
})
|
||||
})
|
||||
|
||||
describe("formatStudentAnswer", () => {
|
||||
it("formats string answer as-is", () => {
|
||||
expect(formatStudentAnswer("my answer")).toBe("my answer")
|
||||
})
|
||||
it("formats boolean answer", () => {
|
||||
expect(formatStudentAnswer(true)).toBe("True")
|
||||
expect(formatStudentAnswer(false)).toBe("False")
|
||||
})
|
||||
it("formats array answer as comma-separated", () => {
|
||||
expect(formatStudentAnswer(["a", "b", "c"])).toBe("a, b, c")
|
||||
})
|
||||
it("formats null answer as dash", () => {
|
||||
expect(formatStudentAnswer(null)).toBe("—")
|
||||
expect(formatStudentAnswer(undefined)).toBe("—")
|
||||
})
|
||||
it("formats object answer by extracting answer field", () => {
|
||||
expect(formatStudentAnswer({ answer: "test" })).toBe("test")
|
||||
})
|
||||
})
|
||||
226
src/modules/homework/lib/question-content-utils.ts
Normal file
226
src/modules/homework/lib/question-content-utils.ts
Normal file
@@ -0,0 +1,226 @@
|
||||
/**
|
||||
* 题目内容解析纯函数
|
||||
*
|
||||
* 从 `unknown` 类型的题目内容中安全提取文本、选项、正确答案等。
|
||||
* 所有函数均为纯函数,无副作用,便于单测。
|
||||
*/
|
||||
|
||||
export type QuestionOption = {
|
||||
id: string
|
||||
text: string
|
||||
isCorrect?: boolean
|
||||
}
|
||||
|
||||
export const isRecord = (v: unknown): v is Record<string, unknown> =>
|
||||
typeof v === "object" && v !== null
|
||||
|
||||
export const getQuestionText = (content: unknown): string => {
|
||||
if (!isRecord(content)) return ""
|
||||
return typeof content.text === "string" ? content.text : ""
|
||||
}
|
||||
|
||||
export const getOptions = (content: unknown): QuestionOption[] => {
|
||||
if (!isRecord(content)) return []
|
||||
const raw = content.options
|
||||
if (!Array.isArray(raw)) return []
|
||||
const out: QuestionOption[] = []
|
||||
for (const item of raw) {
|
||||
if (!isRecord(item)) continue
|
||||
const id = typeof item.id === "string" ? item.id : ""
|
||||
const text = typeof item.text === "string" ? item.text : ""
|
||||
if (!id || !text) continue
|
||||
const isCorrect = item.isCorrect === true
|
||||
out.push({ id, text, isCorrect })
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
export const getChoiceCorrectIds = (content: unknown): string[] => {
|
||||
return getOptions(content)
|
||||
.filter((o): o is QuestionOption & { isCorrect: true } => o.isCorrect === true)
|
||||
.map((o) => o.id)
|
||||
}
|
||||
|
||||
export const getJudgmentCorrectAnswer = (content: unknown): boolean | null => {
|
||||
if (!isRecord(content)) return null
|
||||
return typeof content.correctAnswer === "boolean" ? content.correctAnswer : null
|
||||
}
|
||||
|
||||
export const getTextCorrectAnswers = (content: unknown): string[] => {
|
||||
if (!isRecord(content)) return []
|
||||
const raw = content.correctAnswer
|
||||
if (typeof raw === "string") return [raw]
|
||||
if (Array.isArray(raw)) return raw.filter((x): x is string => typeof x === "string")
|
||||
return []
|
||||
}
|
||||
|
||||
export type QuestionType = "single_choice" | "multiple_choice" | "judgment" | "text" | string
|
||||
|
||||
export type AnswerShape =
|
||||
| { answer: string }
|
||||
| { answer: boolean }
|
||||
| { answer: string[] }
|
||||
| { answer: unknown }
|
||||
|
||||
export const toAnswerShape = (questionType: QuestionType, v: unknown): AnswerShape => {
|
||||
if (questionType === "text") return { answer: typeof v === "string" ? v : "" }
|
||||
if (questionType === "judgment") return { answer: typeof v === "boolean" ? v : false }
|
||||
if (questionType === "single_choice") return { answer: typeof v === "string" ? v : "" }
|
||||
if (questionType === "multiple_choice") {
|
||||
return {
|
||||
answer: Array.isArray(v) ? v.filter((x): x is string => typeof x === "string") : [],
|
||||
}
|
||||
}
|
||||
return { answer: v }
|
||||
}
|
||||
|
||||
export const parseSavedAnswer = (saved: unknown, questionType: QuestionType): AnswerShape => {
|
||||
if (isRecord(saved) && "answer" in saved) {
|
||||
return toAnswerShape(questionType, saved.answer)
|
||||
}
|
||||
return toAnswerShape(questionType, saved)
|
||||
}
|
||||
|
||||
export const extractAnswerValue = (studentAnswer: unknown): unknown => {
|
||||
if (isRecord(studentAnswer) && "answer" in studentAnswer) return studentAnswer.answer
|
||||
return studentAnswer
|
||||
}
|
||||
|
||||
export const normalizeText = (v: string): string =>
|
||||
v.trim().replace(/\s+/g, " ").toLowerCase()
|
||||
|
||||
/**
|
||||
* 判断题目是否可自动判分(有标准答案)
|
||||
*/
|
||||
export const isAutoGradable = (input: {
|
||||
questionType: QuestionType
|
||||
questionContent: unknown
|
||||
}): boolean => {
|
||||
if (input.questionType === "single_choice" || input.questionType === "multiple_choice") {
|
||||
return getChoiceCorrectIds(input.questionContent).length > 0
|
||||
}
|
||||
if (input.questionType === "judgment") {
|
||||
return getJudgmentCorrectAnswer(input.questionContent) !== null
|
||||
}
|
||||
if (input.questionType === "text") {
|
||||
return getTextCorrectAnswers(input.questionContent).length > 0
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
export type CorrectnessState = "ungraded" | "correct" | "incorrect" | "partial"
|
||||
|
||||
/**
|
||||
* 计算单题对错状态
|
||||
* @returns "correct" | "incorrect" | "partial" | "ungraded";无标准答案返回 null
|
||||
*/
|
||||
export const computeIsCorrect = (input: {
|
||||
questionType: QuestionType
|
||||
questionContent: unknown
|
||||
studentAnswer: unknown
|
||||
}): boolean | null => {
|
||||
const studentVal = extractAnswerValue(input.studentAnswer)
|
||||
|
||||
if (input.questionType === "single_choice") {
|
||||
const correct = getChoiceCorrectIds(input.questionContent)
|
||||
if (correct.length === 0) return null
|
||||
if (typeof studentVal !== "string") return false
|
||||
return correct.includes(studentVal)
|
||||
}
|
||||
|
||||
if (input.questionType === "multiple_choice") {
|
||||
const correct = getChoiceCorrectIds(input.questionContent)
|
||||
if (correct.length === 0) return null
|
||||
const studentArr = Array.isArray(studentVal)
|
||||
? studentVal.filter((x): x is string => typeof x === "string")
|
||||
: []
|
||||
const correctSet = new Set(correct)
|
||||
const studentSet = new Set(studentArr)
|
||||
if (studentSet.size !== correctSet.size) return false
|
||||
for (const id of correctSet) {
|
||||
if (!studentSet.has(id)) return false
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
if (input.questionType === "judgment") {
|
||||
const correct = getJudgmentCorrectAnswer(input.questionContent)
|
||||
if (correct === null) return null
|
||||
if (typeof studentVal !== "boolean") return false
|
||||
return studentVal === correct
|
||||
}
|
||||
|
||||
if (input.questionType === "text") {
|
||||
const correctAnswers = getTextCorrectAnswers(input.questionContent)
|
||||
if (correctAnswers.length === 0) return null
|
||||
if (typeof studentVal !== "string") return false
|
||||
const normalizedStudent = normalizeText(studentVal)
|
||||
return correctAnswers.some((c) => normalizeText(c) === normalizedStudent)
|
||||
}
|
||||
|
||||
return null
|
||||
}
|
||||
|
||||
/**
|
||||
* 根据分数与满分推断对错状态
|
||||
*/
|
||||
export const getCorrectnessState = (input: {
|
||||
score: number | null
|
||||
maxScore: number
|
||||
}): CorrectnessState => {
|
||||
if (input.score === null) return "ungraded"
|
||||
if (input.score === input.maxScore) return "correct"
|
||||
if (input.score === 0) return "incorrect"
|
||||
return "partial"
|
||||
}
|
||||
|
||||
/**
|
||||
* 自动判分输入项
|
||||
*/
|
||||
export interface AutoGradableAnswer {
|
||||
id: string
|
||||
questionId: string
|
||||
questionType: QuestionType
|
||||
questionContent: unknown
|
||||
maxScore: number
|
||||
studentAnswer: unknown
|
||||
score: number | null
|
||||
feedback: string | null
|
||||
order: number
|
||||
}
|
||||
|
||||
/**
|
||||
* 对未判分的题目应用自动判分
|
||||
* - 已有分数(score !== null)的不覆盖
|
||||
* - 无标准答案的不判分
|
||||
* - 否则按 computeIsCorrect 给满分或 0 分
|
||||
*/
|
||||
export const applyAutoGrades = <T extends AutoGradableAnswer>(incoming: T[]): T[] => {
|
||||
return incoming.map((a) => {
|
||||
if (a.score !== null) return a
|
||||
if (!isAutoGradable({ questionType: a.questionType, questionContent: a.questionContent })) {
|
||||
return a
|
||||
}
|
||||
const isCorrect = computeIsCorrect({
|
||||
questionType: a.questionType,
|
||||
questionContent: a.questionContent,
|
||||
studentAnswer: a.studentAnswer,
|
||||
})
|
||||
if (isCorrect === null) return a
|
||||
return { ...a, score: isCorrect ? a.maxScore : 0 }
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* 格式化学生答案为可读字符串
|
||||
*/
|
||||
export const formatStudentAnswer = (studentAnswer: unknown): string => {
|
||||
const v = extractAnswerValue(studentAnswer)
|
||||
if (typeof v === "string") return v
|
||||
if (typeof v === "boolean") return v ? "True" : "False"
|
||||
if (Array.isArray(v)) {
|
||||
return v.map((x) => (typeof x === "string" ? x : JSON.stringify(x))).join(", ")
|
||||
}
|
||||
if (v == null) return "—"
|
||||
return JSON.stringify(v)
|
||||
}
|
||||
Reference in New Issue
Block a user