搭建 Vitest 测试框架;新增答案等价判定 lib(answer-match),QuizQ 支持 judgeMode
Co-Authored-By: Claude Haiku 4.5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Haiku 4.5
parent
637c6032d4
commit
bb8bb25096
Generated
+1924
-8
File diff suppressed because it is too large
Load Diff
+8
-2
@@ -11,7 +11,8 @@
|
|||||||
"db:push": "drizzle-kit push",
|
"db:push": "drizzle-kit push",
|
||||||
"db:seed": "tsx scripts/seed.ts",
|
"db:seed": "tsx scripts/seed.ts",
|
||||||
"db:seed-math": "tsx scripts/seed-math.ts",
|
"db:seed-math": "tsx scripts/seed-math.ts",
|
||||||
"db:seed-exam": "tsx scripts/seed-exam.ts"
|
"db:seed-exam": "tsx scripts/seed-exam.ts",
|
||||||
|
"test": "vitest run"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@fontsource-variable/plus-jakarta-sans": "^5.3.0",
|
"@fontsource-variable/plus-jakarta-sans": "^5.3.0",
|
||||||
@@ -34,13 +35,18 @@
|
|||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@tailwindcss/postcss": "^4.1.11",
|
"@tailwindcss/postcss": "^4.1.11",
|
||||||
|
"@testing-library/jest-dom": "^7.0.1",
|
||||||
|
"@testing-library/react": "^16.3.2",
|
||||||
|
"@testing-library/user-event": "^14.6.5",
|
||||||
"@types/node": "^24.0.0",
|
"@types/node": "^24.0.0",
|
||||||
"@types/react": "^19.1.8",
|
"@types/react": "^19.1.8",
|
||||||
"@types/react-dom": "^19.1.6",
|
"@types/react-dom": "^19.1.6",
|
||||||
"dotenv": "^17.2.0",
|
"dotenv": "^17.2.0",
|
||||||
"drizzle-kit": "^0.31.4",
|
"drizzle-kit": "^0.31.4",
|
||||||
|
"jsdom": "^30.0.1",
|
||||||
"tailwindcss": "^4.1.11",
|
"tailwindcss": "^4.1.11",
|
||||||
"tsx": "^4.20.3",
|
"tsx": "^4.20.3",
|
||||||
"typescript": "^5.8.3"
|
"typescript": "^5.8.3",
|
||||||
|
"vitest": "^4.1.11"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,45 @@
|
|||||||
|
import { describe, expect, it } from "vitest";
|
||||||
|
import { answersMatch, normalizeAnswer } from "./answer-match";
|
||||||
|
|
||||||
|
describe("normalizeAnswer", () => {
|
||||||
|
it("分数转小数", () => {
|
||||||
|
expect(normalizeAnswer("1/2")).toBe("0.5");
|
||||||
|
expect(normalizeAnswer("2/4")).toBe("0.5");
|
||||||
|
});
|
||||||
|
it("百分数转小数", () => {
|
||||||
|
expect(normalizeAnswer("50%")).toBe("0.5");
|
||||||
|
});
|
||||||
|
it("小数去尾零", () => {
|
||||||
|
expect(normalizeAnswer("0.50")).toBe("0.5");
|
||||||
|
});
|
||||||
|
it("整数原样", () => {
|
||||||
|
expect(normalizeAnswer("10")).toBe("10");
|
||||||
|
});
|
||||||
|
it("文本去空白/全角/大小写", () => {
|
||||||
|
expect(normalizeAnswer("ABC")).toBe("abc");
|
||||||
|
expect(normalizeAnswer("10 个")).toBe("10个");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("answersMatch", () => {
|
||||||
|
it("等值命中:1/2 ≡ 0.5", () => {
|
||||||
|
expect(answersMatch(["0.5", "1/2"], "1/2")).toBe(true);
|
||||||
|
expect(answersMatch(["0.5"], "0.50")).toBe(true);
|
||||||
|
});
|
||||||
|
it("百分数命中", () => {
|
||||||
|
expect(answersMatch(["0.5"], "50%")).toBe(true);
|
||||||
|
});
|
||||||
|
it("文本大小写/全角不敏感", () => {
|
||||||
|
expect(answersMatch(["across"], "ACROSS")).toBe(true);
|
||||||
|
});
|
||||||
|
it("单位严格:10个 ≠ 10", () => {
|
||||||
|
expect(answersMatch(["10个"], "10")).toBe(false);
|
||||||
|
expect(answersMatch(["10", "10个"], "10个")).toBe(true);
|
||||||
|
});
|
||||||
|
it("空白答案不判对", () => {
|
||||||
|
expect(answersMatch(["0.5"], " ")).toBe(false);
|
||||||
|
});
|
||||||
|
it("空期望数组不判对", () => {
|
||||||
|
expect(answersMatch([], "0.5")).toBe(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,27 @@
|
|||||||
|
// xuchao: 答案等价判定——数值归一(分数/小数/百分数互转),文本归一(去空白/全角/小写)
|
||||||
|
const toHalf = (s: string) =>
|
||||||
|
s.replace(/[!-~]/g, (c) => String.fromCharCode(c.charCodeAt(0) - 0xfee0)).replace(/ /g, " ");
|
||||||
|
|
||||||
|
function stripWs(s: string): string {
|
||||||
|
return toHalf(s).replace(/\s+/g, "").toLowerCase();
|
||||||
|
}
|
||||||
|
|
||||||
|
export function normalizeAnswer(s: string): string {
|
||||||
|
const t = stripWs(s);
|
||||||
|
const frac = t.match(/^(-?\d+)\/(-?\d+)$/);
|
||||||
|
if (frac && Number(frac[2]) !== 0) {
|
||||||
|
const v = Number(frac[1]) / Number(frac[2]);
|
||||||
|
if (Number.isFinite(v)) return String(v);
|
||||||
|
}
|
||||||
|
const pct = t.match(/^(-?\d+(?:\.\d+)?)%$/);
|
||||||
|
if (pct) return String(Number(pct[1]) / 100);
|
||||||
|
if (/^-?\d+\.\d+$/.test(t)) return String(Number(t));
|
||||||
|
return t;
|
||||||
|
}
|
||||||
|
|
||||||
|
// xuchao: 任一期望答案等值命中即判对;空白答案一律不判对
|
||||||
|
export function answersMatch(expected: string[], given: string): boolean {
|
||||||
|
const g = normalizeAnswer(given);
|
||||||
|
if (!g) return false;
|
||||||
|
return expected.some((x) => normalizeAnswer(x) === g);
|
||||||
|
}
|
||||||
@@ -13,6 +13,9 @@ export type QuizQ = {
|
|||||||
points?: number; // solve:本题满分
|
points?: number; // solve:本题满分
|
||||||
reference?: string; // solve:完整参考解答
|
reference?: string; // solve:完整参考解答
|
||||||
rubric?: { point: string; score: number }[]; // solve:评分点,合计 = points
|
rubric?: { point: string; score: number }[]; // solve:评分点,合计 = points
|
||||||
|
// xuchao: 应用题判定模式——rubric=AI 逐评分点(缺省,存量兼容);answer=最终答案数组匹配秒判
|
||||||
|
judgeMode?: "rubric" | "answer";
|
||||||
|
// judgeMode==="answer" 时复用上面 answers 数组(等价答法都填)
|
||||||
// xuchao: 错题本里的解答题作答快照(照片 + 逐点判定),交卷入错题本时写入
|
// xuchao: 错题本里的解答题作答快照(照片 + 逐点判定),交卷入错题本时写入
|
||||||
solveSnap?: { ossKey: string; earned: number; hits: { point: string; score: number; hit: boolean; reason: string }[] };
|
solveSnap?: { ossKey: string; earned: number; hits: { point: string; score: number; hit: boolean; reason: string }[] };
|
||||||
explain: string;
|
explain: string;
|
||||||
@@ -33,3 +36,6 @@ export function extractQuiz(md: string): QuizQ[] {
|
|||||||
}
|
}
|
||||||
return out;
|
return out;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// xuchao: 应用题 answer 模式的提交载荷(最终答案 + 照片 key,供秒判与错题本快照)
|
||||||
|
export type SolveAnswerPayload = { answer: string; ossKey: string };
|
||||||
|
|||||||
@@ -0,0 +1 @@
|
|||||||
|
import "@testing-library/jest-dom/vitest";
|
||||||
@@ -0,0 +1,12 @@
|
|||||||
|
import path from "node:path";
|
||||||
|
import { defineConfig } from "vitest/config";
|
||||||
|
|
||||||
|
// xuchao: Vitest 配置——@ 别名对齐 tsconfig;组件测试文件用 // @vitest-environment jsdom 标注
|
||||||
|
export default defineConfig({
|
||||||
|
resolve: { alias: { "@": path.resolve(__dirname, "src") } },
|
||||||
|
test: {
|
||||||
|
environment: "node",
|
||||||
|
include: ["src/**/*.test.{ts,tsx}"],
|
||||||
|
setupFiles: ["src/test-setup.ts"],
|
||||||
|
},
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user