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
+135 -14
View File
@@ -9,9 +9,9 @@ const workspace = path.resolve(__dirname, '..');
const coreSource = fs.readFileSync(path.join(workspace, 'question-bank-core.js'), 'utf8');
const runnerSource = fs.readFileSync(path.join(workspace, 'answer-runner.js'), 'utf8');
function fixture(items) {
const option = (question, label) => `<div class="pc_examtest_content_list_child${question.locked ? 2 : 1}"><div class="pc_examtest_content_list_des${(question.checked || []).includes(label) ? ' examtest_content_list_des_checked' : ''}" data-label="${label}" onclick="window.optionClicked(this)"><span class="examtest_content_list_des_optionsort">${label}</span>选项${label}</div></div>`;
return `<app-examtest><div class="pc_examtest_title">测试练习</div>${items.map((question, index) => `<div app-exam-question><div class="pc_examtest_content" data-kind="${question.type}" data-nochange="${question.nochange ? '1' : '0'}"><div class="pc_examtest_content_question">[${question.type}] ${index + 1}. 测试题${index + 1}${question.locked ? '' : '<span class="pc_examtest_content_question_showanswer">查看答案</span>'}</div><div class="pc_examtest_content_list">${['A', 'B', 'C', 'D', 'E'].slice(0, question.type === '判断' ? 2 : 5).map(label => option(question, label)).join('')}</div>${question.locked ? '<div class="hidden pc_examtest_content_form_disabled"></div>' : ''}<div class="hidden"><div class="examtest_content_answer_left">参考答案:<span class="answer">B</span></div></div></div></div>`).join('')}</app-examtest>`;
function fixture(items, exam = false) {
const option = (question, label) => `<div class="pc_examtest_content_list_child${question.locked ? 2 : 1}"><div class="pc_examtest_content_list_des${(question.checked || []).includes(label) ? ' examtest_content_list_des_checked' : ''}" data-label="${label}" onclick="window.optionClicked(this)"><span class="examtest_content_list_des_optionsort">${label}</span>${question.texts && question.texts[label] || '选项' + label}</div></div>`;
return `<app-examtest><div class="pc_examtest_title">测试练习</div>${items.map((question, index) => `<div app-exam-question><div class="pc_examtest_content" data-kind="${question.type}" data-nochange="${question.nochange ? '1' : '0'}"><div class="pc_examtest_content_question">[${question.type}] ${index + 1}. 测试题${index + 1}${question.locked || exam ? '' : '<span class="pc_examtest_content_question_showanswer">查看答案</span>'}</div><div class="pc_examtest_content_list">${['A', 'B', 'C', 'D', 'E'].slice(0, question.type === '判断' ? 2 : 5).map(label => option(question, label)).join('')}</div>${question.locked ? '<div class="hidden pc_examtest_content_form_disabled"></div>' : ''}${exam ? '' : '<div class="hidden"><div class="examtest_content_answer_left">参考答案:<span class="answer">B</span></div></div>'}</div></div>`).join('')}${exam ? '<div class="pc_examtest_commit" onclick="window.submitClicked()">交卷</div>' : ''}</app-examtest>`;
}
async function main() {
@@ -30,8 +30,8 @@ async function main() {
await page.addScriptTag({ content: coreSource });
await page.addScriptTag({ content: runnerSource });
async function setup(items, mode = 'normal') {
await page.evaluate(({ html, mode }) => {
async function setup(items, mode = 'normal', exam = false) {
await page.evaluate(({ html, mode, exam }) => {
window.qaRunner?.destroy();
document.body.innerHTML = html;
window.qaClicks = [];
@@ -39,7 +39,12 @@ async function main() {
window.qaMatchStarted = false;
window.qaReleaseMatch = null;
window.qaMode = mode;
window.qaContext = { ready: true, practice: true, local: false, role: '主操', course: '测试练习', sourceUrl: location.href, routeKey: 'practice', root: document.querySelector('app-examtest'), pageSignature: 'stable', revision: 1 };
window.qaExam = exam;
window.qaSubmitted = 0;
window.qaMatchCalls = 0;
history.replaceState(null, '', '/practice?flag=' + (exam ? 'test' : 'exercise'));
window.qaContext = { ready: true, practice: !exam, exam: exam, mode: exam ? 'test' : 'exercise', local: false, role: '主操', course: '测试练习', sourceUrl: location.href, routeKey: 'practice', root: document.querySelector('app-examtest'), pageSignature: 'stable', revision: 1 };
window.submitClicked = () => { window.qaSubmitted++; window.qaContext.exam = false; };
// 只在真实 onclick 里更新状态,runner 必须点击选项才会发生选择或锁定。
window.optionClicked = node => {
const box = node.closest('.pc_examtest_content');
@@ -50,20 +55,24 @@ async function main() {
} else {
box.querySelectorAll('.pc_examtest_content_list_des').forEach(option => option.classList.remove('examtest_content_list_des_checked'));
node.classList.add('examtest_content_list_des_checked');
box.querySelector('.pc_examtest_content_question_showanswer')?.remove();
const marker = document.createElement('div');
marker.className = 'hidden pc_examtest_content_form_disabled';
box.append(marker);
if (!window.qaExam) {
box.querySelector('.pc_examtest_content_question_showanswer')?.remove();
const marker = document.createElement('div');
marker.className = 'hidden pc_examtest_content_form_disabled';
box.append(marker);
}
}
};
window.qaRunner = YianAnswerRunner.create({
getContext: () => window.qaContext,
match: async question => {
window.qaMatchStarted = true;
window.qaMatchCalls++;
if (window.qaMode === 'delayed') await new Promise(resolve => { window.qaReleaseMatch = resolve; });
if (window.qaMode === 'title') document.querySelector('.pc_examtest_title').textContent = '新练习';
const labels = question.type === '多选' ? ['B', 'E'] : ['B'];
return { status: 'matched', correctLabels: labels, correctTexts: labels.map(label => '选项' + label) };
const labels = question.type === '多选' ? window.qaMode === 'reverse-evidence' ? ['E', 'B'] : ['B', 'E'] : ['B'];
const texts = window.qaMode === 'empty-evidence' ? [] : window.qaMode === 'wrong-evidence' ? ['选项B的一部分'] : window.qaMode === 'case-evidence' ? ['5 mPa'] : labels.map(label => '选项' + label);
return { status: 'matched', correctLabels: labels, correctTexts: texts, records: [{ course: '题库来源课程' }] };
},
onProgress: counts => {
window.qaProgress = counts;
@@ -71,7 +80,7 @@ async function main() {
},
onState: (state, reason) => window.qaStates.push([state, reason])
});
}, { html: fixture(items), mode });
}, { html: fixture(items, exam), mode, exam });
}
async function delayedStart() {
@@ -165,8 +174,120 @@ async function main() {
assert.equal(await page.evaluate(() => qaClicks.length), 0);
await page.evaluate(() => { qaContext.local = false; qaContext.practice = false; return qaRunner.start(); });
assert.equal(await page.evaluate(() => qaClicks.length), 0);
console.log('PASS local/nonpractice pages blocked');
await setup([{ type: '单选', checked: ['A'] }, { type: '判断', checked: ['A'] }, { type: '多选', checked: ['A', 'E'] }], 'reverse-evidence', true);
assert.equal(await page.locator('.pc_examtest_content_question_showanswer').count(), 0);
assert.equal(await page.locator('.examtest_content_answer_left').count(), 0);
await page.evaluate(() => qaRunner.start());
const examResult = await page.evaluate(() => ({ counts: qaProgress, clicks: qaClicks, submitted: qaSubmitted }));
assert.equal(examResult.counts.selected, 3);
assert.equal(examResult.counts.pending, 0);
assert.equal(examResult.counts.locked, 0);
assert.deepEqual(examResult.clicks, [['单选', 'B'], ['判断', 'B'], ['多选', 'A'], ['多选', 'B']]);
assert.equal(examResult.submitted, 0);
const multiEvidence = examResult.counts.details.find(detail => detail.number === 3);
assert.equal(multiEvidence.question, '测试题3');
assert.equal(multiEvidence.course, '题库来源课程');
assert.equal(multiEvidence.pageCourse, '测试练习');
assert.deepEqual(multiEvidence.correctTexts, ['选项B', '选项E']);
assert.deepEqual(multiEvidence.currentLabels, ['B', 'E']);
assert.deepEqual(multiEvidence.currentTexts, ['选项B', '选项E']);
console.log('PASS exam corrects single/judgment/multiple answers without analysis controls; evidence aligned');
await page.evaluate(() => qaRunner.stop('测试暂停后修改已选答案'));
await page.locator('[data-kind="单选"] [data-label="A"]').click();
await page.locator('[data-kind="多选"] [data-label="B"]').click();
await page.locator('[data-kind="多选"] [data-label="C"]').click();
const callsBeforeResume = await page.evaluate(() => qaMatchCalls);
const clicksBeforeResume = await page.evaluate(() => qaClicks.length);
await page.evaluate(() => qaRunner.start());
assert.equal(await page.evaluate(() => qaMatchCalls), callsBeforeResume + 3, '已 selected 题必须重新匹配');
assert.deepEqual(await page.evaluate(count => qaClicks.slice(count), clicksBeforeResume), [['单选', 'B'], ['多选', 'C'], ['多选', 'B']]);
assert.equal(await page.evaluate(() => qaProgress.selected), 3);
const correctedClicks = await page.evaluate(() => qaClicks.length);
await page.evaluate(() => qaRunner.start());
assert.equal(await page.evaluate(() => qaClicks.length), correctedClicks);
console.log('PASS exam stop/resume rechecks previously selected questions and corrects manual changes');
await setup([{ type: '单选' }, { type: '多选' }], 'normal', true);
await page.evaluate(() => qaRunner.start());
assert.equal(await page.evaluate(() => qaRunner.running), false);
assert.equal(await page.evaluate(() => qaStates.at(-1)[0]), 'complete');
await page.locator('[data-kind="单选"] [data-label="A"]').click();
const invalidated = await page.evaluate(() => ({ counts: qaProgress, state: qaStates.at(-1) }));
assert.equal(invalidated.state[0], 'paused');
assert.equal(invalidated.counts.selected, 1);
assert.equal(invalidated.counts.unsupported, 1);
const invalidEvidence = invalidated.counts.details.find(detail => detail.number === 1);
assert.equal(invalidEvidence.status, 'unsupported');
assert.equal(invalidEvidence.reason, '手动作答后需重新核对');
assert.deepEqual(invalidEvidence.currentLabels, [], '捕获阶段不能继续展示原先的 B 作为当前答案');
assert.deepEqual(invalidEvidence.currentTexts, []);
assert.deepEqual(invalidEvidence.correctTexts, []);
assert.deepEqual(await page.evaluate(() => Array.from(document.querySelectorAll('[data-kind="单选"] .examtest_content_list_des_checked')).map(node => node.dataset.label)), ['A']);
// 首次改选已经关闭自动选择,另一道已完成题后续手动改选也不能留下旧证据。
await page.locator('[data-kind="多选"] [data-label="A"]').click();
assert.equal(await page.evaluate(() => qaProgress.selected), 0);
assert.equal(await page.evaluate(() => qaProgress.unsupported), 2);
const matchesBeforeRecheck = await page.evaluate(() => qaMatchCalls);
const clicksBeforeRecheck = await page.evaluate(() => qaClicks.length);
await page.evaluate(() => qaRunner.start());
assert.equal(await page.evaluate(() => qaMatchCalls), matchesBeforeRecheck + 2);
assert.deepEqual(await page.evaluate(count => qaClicks.slice(count), clicksBeforeRecheck), [['单选', 'B'], ['多选', 'A']]);
const rechecked = await page.evaluate(() => qaProgress);
assert.equal(rechecked.selected, 2);
assert.equal(rechecked.unsupported, 0);
assert.deepEqual(rechecked.details.find(detail => detail.number === 1).currentLabels, ['B']);
assert.deepEqual(rechecked.details.find(detail => detail.number === 1).correctTexts, ['选项B']);
await setup([{ type: '单选' }, { type: '判断' }], 'stop-after-first', true);
await page.evaluate(() => qaRunner.start());
assert.equal(await page.evaluate(() => qaStates.at(-1)[0]), 'stopped');
assert.equal(await page.evaluate(() => qaProgress.selected), 1);
await page.locator('[data-kind="单选"] [data-label="A"]').click();
assert.equal(await page.evaluate(() => qaProgress.selected), 0);
assert.equal(await page.evaluate(() => qaProgress.unsupported), 1);
assert.deepEqual(await page.evaluate(() => qaProgress.details[0].currentLabels), []);
await page.evaluate(() => { window.qaMode = 'normal'; return qaRunner.start(); });
assert.equal(await page.evaluate(() => qaProgress.selected), 2);
assert.equal(await page.evaluate(() => qaProgress.unsupported), 0);
console.log('PASS manual change after completion or partial stop invalidates evidence; reopening rechecks/corrects it');
await setup([{ type: '单选', checked: ['A'], locked: true }, { type: '判断' }], 'normal', true);
await page.evaluate(() => { document.querySelector('[data-kind="判断"]').setAttribute('aria-disabled', 'true'); return qaRunner.start(); });
assert.equal(await page.evaluate(() => qaClicks.length), 0);
assert.equal(await page.evaluate(() => qaProgress.locked), 2);
for (const field of ['exam', 'mode']) {
await setup([{ type: '单选' }], 'delayed', true);
await delayedStart();
await page.evaluate(field => { qaContext[field] = field === 'exam' ? false : 'submitted'; }, field);
await finishMatch();
assert.equal(await page.evaluate(() => qaClicks.length), 0, field + ' 变化后必须停止');
assert.equal(await page.evaluate(() => qaStates.at(-1)[0]), 'stopped');
}
await setup([{ type: '单选' }], 'normal', true);
await page.evaluate(() => { qaContext.exam = false; return qaRunner.start(); });
assert.equal(await page.evaluate(() => qaClicks.length), 0);
console.log('PASS exam disabled questions and submitted/time-expired context prevent clicks');
for (const mode of ['empty-evidence', 'wrong-evidence', 'case-evidence']) {
await setup([{ type: '单选', texts: mode === 'case-evidence' ? { B: '5 MPa' } : undefined }], mode, true);
await page.evaluate(() => qaRunner.start());
assert.equal(await page.evaluate(() => qaClicks.length), 0, '必须核验题库答案全文:' + mode);
assert.equal(await page.evaluate(() => qaProgress.unsupported), 1);
}
console.log('PASS missing/full-text-mismatched/case-sensitive answer evidence never authorizes a click');
await setup([{ type: '单选' }], 'delayed', true);
await delayedStart();
await page.locator('.pc_examtest_commit').click();
await finishMatch();
assert.equal(await page.evaluate(() => qaStates.at(-1)[0]), 'paused');
assert.equal(await page.evaluate(() => qaSubmitted), 1, '保留真人交卷点击,runner 不交卷');
assert.equal(await page.evaluate(() => qaClicks.length), 0);
assert.deepEqual(errors, []);
console.log('PASS local/nonpractice pages blocked; no browser errors');
console.log('PASS trusted manual submit pauses immediately; no browser errors');
} finally {
if (browser) await browser.close();
await new Promise(resolve => server.close(resolve));