feat: support desktop exams with strict answer text verification

This commit is contained in:
薇星幻海
2026-10-10 06:39:36 +08:00
parent 3969c233b3
commit 3d7a1ffed0
16 changed files with 1137 additions and 187 deletions
+102 -2
View File
@@ -60,7 +60,65 @@ function csv(rows) {
assert.throws(() => store.parseCSV('a,b\n未"转义,b'), /未转义/);
assert.notEqual(matcher.normalize('不应在 -50.5℃ 作业'), matcher.normalize('应在 50.5℃ 作业'));
assert.notEqual(matcher.normalize('50.5'), matcher.normalize('505'));
assert.equal(matcher.normalize('−50.5 ℃'), '-50.5 °c');
assert.equal(matcher.normalize('−50.5 ℃'), '−50.5 ℃');
assert.equal(matcher.normalize(' M\u200bPa\t\n 10\u00a0℃\uFEFF '), 'MPa 10 ℃');
for (const [left, right] of [['MPa', 'mPa'], ['m²', 'm2'], ['Ⅰ', 'I'], ['不是', '是'], ['−50', '-50'], ['1.0', '10'], ['A,B', 'A,B']]) {
assert.notEqual(matcher.normalize(left), matcher.normalize(right), '自动匹配必须区分 ' + left + ' / ' + right);
assert.equal(store.normalize(left), matcher.normalize(left), '存储与匹配的严格规范化必须一致');
}
assert.equal(matcher.searchNormalize('−50.5 ℃'), '-50.5 °c');
assert.equal(matcher.searchNormalize('MPA'), matcher.searchNormalize('MPa'));
// Loose search may find similar units, but cannot merge or authorize a different unit.
const semanticRecords = [
store.validateRecord(record({ question: '压力单位 MPa 应怎样使用?', options: [{ label: 'A', text: 'MPa' }, { label: 'B', text: 'mPa' }], answer: 'A' })),
store.validateRecord(record({ question: '压力单位 mPa 应怎样使用?', options: [{ label: 'A', text: 'MPa' }, { label: 'B', text: 'mPa' }], answer: 'B' }))
];
const semanticState = { records: semanticRecords, revision: 1 };
assert.equal(matcher.search('mpa', { role: '主操' }, semanticState).total, 2);
const pressurePage = { ...semanticRecords[0], options: [{ label: 'A', text: 'mPa' }, { label: 'B', text: 'MPa' }] };
const pressureAnswer = matcher.match(pressurePage, { role: '主操' }, semanticState);
assert.equal(pressureAnswer.status, 'matched');
assert.deepEqual(Array.from(pressureAnswer.correctLabels), ['B']);
assert.deepEqual(Array.from(pressureAnswer.correctTexts), ['MPa']);
assert.throws(() => store.validateRecord({ ...semanticRecords[0], correctTexts: ['mPa'] }), /不一致/, '答案校验不能混淆单位大小写');
for (const [bankText, pageText] of [['MPa', 'mPa'], ['m²', 'm2'], ['Ⅰ', 'I'], ['不应', '应'], ['50.5', '505']]) {
const evidence = store.validateRecord(record({ question: '语义不能被自动改写。', options: [{ label: 'A', text: bankText }, { label: 'B', text: '其他选项' }], answer: 'A' }));
const modifiedPage = { ...evidence, options: [{ label: 'A', text: pageText }, { label: 'B', text: '其他选项' }] };
assert.equal(matcher.match(modifiedPage, { role: '主操' }, { records: [evidence], revision: 1 }).status, 'missing', bankText + ' 不得误匹配 ' + pageText);
assert.notEqual(store.optionKey(evidence.options), store.optionKey(modifiedPage.options));
}
const evidenceRecord = store.validateRecord(record({ options: [{ label: 'A', text: 'MPa 10\n设备' }, { label: 'B', text: '备用设备' }], answer: 'A' }));
const evidenceAnswer = matcher.match({ ...evidenceRecord, options: [{ label: 'A', text: '备用设备' }, { label: 'B', text: 'MPa 10 设备' }] }, { role: '主操' }, { records: [evidenceRecord], revision: 1 });
assert.equal(evidenceAnswer.status, 'matched');
assert.deepEqual(Array.from(evidenceAnswer.correctLabels), ['B']);
assert.deepEqual(Array.from(evidenceAnswer.correctTexts), ['MPa 10\n设备'], '返回正确文字须保留题库证据原文');
evidenceAnswer.correctTexts[0] = '不能改写题库';
assert.equal(evidenceRecord.correctTexts[0], 'MPa 10\n设备', '结果数组不应直接暴露题库答案数组');
const multiEvidence = store.validateRecord(record({ type: '多选', options: [{ label: 'A', text: 'MPa 10\n设备' }, { label: 'B', text: '备用\n设备' }, { label: 'C', text: '无关设备' }], answer: 'AB' }));
const multiEvidenceAnswer = matcher.match({ ...multiEvidence, options: [{ label: 'A', text: '备用 设备' }, { label: 'B', text: '无关设备' }, { label: 'C', text: 'MPa 10 设备' }] }, { role: '主操' }, { records: [multiEvidence], revision: 1 });
assert.equal(multiEvidenceAnswer.status, 'matched');
assert.deepEqual(Array.from(multiEvidenceAnswer.correctLabels), ['A', 'C']);
assert.deepEqual(Array.from(multiEvidenceAnswer.correctTexts), ['备用\n设备', 'MPa 10\n设备'], '多选映射后逐项对应字母,保留每个选项的题库原文空白');
const sameStemDifferentAnswers = [semanticRecords[0], store.validateRecord({ ...semanticRecords[0], course: '另一课程', answer: 'B', correctTexts: undefined })];
assert.equal(matcher.match(pressurePage, { role: '主操' }, { records: sameStemDifferentAnswers, revision: 1 }).status, 'conflict', '不同大小写的正确文本应保留为答案冲突');
// Existing v2 data retains its source texts; recompute stale loose keys in memory.
const oldMemory = new Map([['yianxue.assistant.bank.v2', {
records: semanticRecords.map(item => ({ ...item, questionKey: 'old-loose-key', optionKey: 'old-loose-options', id: 'old-id' })),
revision: 7, updatedAt: '2026-10-10T00:00:00.000Z'
}]]);
const upgraded = await runtime('modern', oldMemory).store.load();
assert.equal(upgraded.total, 2, '回读旧库不丢失语义不同的记录');
assert.equal(upgraded.revision, 7);
assert.equal(upgraded.updatedAt, '2026-10-10T00:00:00.000Z');
upgraded.records.forEach(item => {
assert.equal(item.questionKey, matcher.questionKey(item));
assert.equal(item.optionKey, matcher.optionKey(item.options));
assert.notEqual(item.id, 'old-id');
});
assert.notEqual(upgraded.records[0].questionKey, upgraded.records[1].questionKey);
assert.equal(oldMemory.size, 1, '不更换稳定存储键或清空旧库');
const escaped = record({ options: [{ label: 'A', text: "'-50" }, { label: 'B', text: "'引用" }, { label: 'C', text: "''-50" }] });
let state = await store.importFiles([file('风险管理_题库_1题.csv', csv([escaped]))], { role: '主操' });
@@ -130,6 +188,7 @@ function csv(rows) {
const multipleResult = matcher.match({ ...q, type: '多选' }, { role: '主操' }, { records: [multiple], revision: 1 });
assert.equal(multipleResult.status, 'matched');
assert.deepEqual(Array.from(multipleResult.correctLabels), ['B', 'C']);
assert.deepEqual(Array.from(multipleResult.correctTexts), ['作业丙', '作业甲'], '多选字母与题库原答案文字按当前网页顺序逐项对应');
const quotaRuntime = runtime();
await quotaRuntime.store.importFiles([file('风险管理.csv', csv([record()]))], { role: '主操' });
quotaRuntime.environment.GM_setValue = () => { throw new Error('quota exceeded'); };
@@ -157,7 +216,7 @@ function csv(rows) {
await assert.rejects(store.importFiles([file('损坏.zip', await invalidZip.generateAsync({ type: 'nodebuffer' }))]), /不存在的选项/);
assert.equal((await store.load()).total, 1);
await assert.rejects(store.importFiles([file('题库.zip', await zip.generateAsync({ type: 'nodebuffer' })), file('一份.csv', csv([record()]))]), /只选择一个/);
console.log('PASS 本地存储、原子导入、CSV引号/换行/转义、岗位隔离、精确换序匹配、答案冲突、来源消歧与搜索分页');
console.log('PASS 严格语义匹配与宽松搜索分离、原答案证据、旧库重算键、本地存储和原子导入、岗位隔离、换序/冲突/搜索分页');
const realZip = path.join(rootDir, '题库.zip');
if (fs.existsSync(realZip)) {
@@ -176,4 +235,45 @@ function csv(rows) {
assert.equal(new Set(imported.records.map(item => item.sourceFile)).size, 28);
console.log('PASS 实际题库 ZIP:28 CSV,5520 题,岗位数量 ' + JSON.stringify(imported.counts) + ';导入耗时 ' + Math.round(performance.now() - started) + ' ms');
}
// Parse real DOM instead of stripping bracketed numbers/difficulty with a regex.
const { chromium } = require('playwright');
const browser = await chromium.launch({ channel: process.env.PLAYWRIGHT_CHANNEL || 'chrome', headless: true });
try {
const page = await browser.newPage();
const requests = [];
page.on('request', request => requests.push(request.url()));
const optionHTML = (label, text) => `<div class="pc_examtest_content_list_des"><span class="examtest_content_list_des_optionsort">${label}</span>${text}</div>`;
const questionHTML = (stem, options, answer) => `<div class="pc_examtest_content"><div class="pc_examtest_content_question">${stem}</div>${options.map((text, index) => optionHTML(String.fromCharCode(65 + index), text)).join('')}<div class="examtest_content_answer_left">参考答案:<span class="answer">${answer}</span></div></div>`;
await page.setContent('<div class="pc_examtest_title">DOM 元数据验证</div>' +
questionHTML('<p>[单选] 7. 压力(2 MPa)达到(3)时,应保持Ⅰ型设备。<span class="pc_examtest_content_question_value">(2分)</span></p><div class="pc_examtest_content_question_difficulty">难度低</div>', ['MPa', 'mPa'], 'A') +
questionHTML('[判断] 8. 设备(2分)仍须检查难度系数(3)。<span class="pc_examtest_content_question_value">(3分)</span><div class="pc_examtest_content_question_difficulty">难度中</div>', ['对', '错'], 'B') +
questionHTML('[多选] 9. m² 和 m2 是不同写法。', ['m²', 'm2'], 'AB'));
await page.addScriptTag({ content: fs.readFileSync(path.join(rootDir, 'question-bank-core.js'), 'utf8') });
const extracted = await page.evaluate(() => QuestionBankCore.extract(document));
assert.deepEqual(extracted.questions.map(item => item.number), [7, 8, 9]);
assert.deepEqual(extracted.questions.map(item => item.type), ['单选', '判断', '多选']);
assert.equal(extracted.questions[0].question, '压力(2 MPa)达到(3)时,应保持Ⅰ型设备。');
assert.equal(extracted.questions[1].question, '设备(2分)仍须检查难度系数(3)。', '真正的题干括号数字及难度字样须保留');
assert.equal(extracted.questions[2].question, 'm² 和 m2 是不同写法。', '旧版无 p 和无元数据的结构仍可解析');
assert.equal(extracted.questions[0].options[0].text, 'MPa');
assert.equal(extracted.questions[0].options[1].text, 'mPa');
assert(!extracted.questions[0].questionHTML.includes('pc_examtest_content_question_value'));
assert.equal(await page.locator('.pc_examtest_content_question_value').count(), 2, '提取只处理克隆,不能更改网页原 DOM');
const sampleDirectory = path.join(rootDir, '网页样本', '谊安学考试');
const sampleHTML = path.join(sampleDirectory, '谊安学.html');
const structurePath = path.join(sampleDirectory, '题目结构.json');
if (fs.existsSync(sampleHTML) && fs.existsSync(structurePath)) {
const expected = JSON.parse(fs.readFileSync(structurePath, 'utf8'));
const actual = await page.evaluate(html => QuestionBankCore.extract(QuestionBankCore.parseHTML(html)), fs.readFileSync(sampleHTML, 'utf8'));
assert.equal(actual.total, expected.total);
assert.equal(actual.answered, 0);
assert.equal(actual.title, expected.title);
assert.deepEqual(actual.questions.map(item => ({ number: item.number, type: item.type, question: item.question, options: item.options.map(option => ({ label: option.label, text: option.text })) })),
expected.questions.map(item => ({ number: item.number, type: item.type, question: item.question, options: item.options.map(option => ({ label: option.label, text: option.text })) })));
console.log('PASS 新考试样本 40 题:类型/题号/题干/选项与人工结构一致,分数和难度按 DOM 排除');
}
assert.equal(requests.length, 0, '核心提取和保存网页解析不得请求原网站');
console.log('PASS DOM 元数据清理、括号数字保留、旧无 p 结构与原页面不变');
} finally { await browser.close(); }
})().catch(error => { console.error(error); process.exitCode = 1; });