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
+1 -1
View File
@@ -167,7 +167,7 @@ function documentHTML(practice) {
await second.evaluate(url => history.pushState({}, '', url), modeURL);
await second.waitForTimeout(800); assert(await second.getByRole('switch', { name: '自动选择答案' }).isDisabled());
assert.equal(await second.evaluate(() => YianAssistant.setAutoAnswer(true)), false);
console.log('PASS 面板关闭立即停止后续点击、重新开启续作、明确考试模式禁用');
console.log('PASS 面板关闭立即停止后续点击、重新开启续作、未适配的考试结构禁用');
await page.setViewportSize({ width: 390, height: 844 });
await page.getByRole('button', { name: '复制题目与答案' }).first().click();
+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; });
+338
View File
@@ -0,0 +1,338 @@
/* 桌面考试端到端验证:只载入中和后的保存 DOM,原站脚本及业务请求均不运行。 */
const assert = require('node:assert/strict');
const fs = require('node:fs');
const path = require('node:path');
const os = require('node:os');
const http = require('node:http');
const { pathToFileURL } = require('node:url');
const { chromium } = require('playwright');
const workspace = path.resolve(__dirname, '..');
const scriptPath = path.join(workspace, '谊安学助手 2.0.user.js');
const script = fs.readFileSync(scriptPath, 'utf8');
const coreScript = fs.readFileSync(path.join(workspace, 'question-bank-core.js'), 'utf8');
assert(script.includes('// @name 谊安学助手 2.0'));
assert(script.includes('// @match *://*/*'));
const qaDir = process.env.YIAN_EXAM_QA_DIR || path.join(os.tmpdir(), 'yianxue-exam-qa');
fs.mkdirSync(qaDir, { recursive: true });
const roles = ['主操', '班长', '副操'];
const bankPaths = roles.map(role => path.join(workspace, '题库', role + '_合并题库.csv'));
const samplePath = path.join(workspace, '网页样本', '谊安学考试', '谊安学.html');
const realSample = fs.existsSync(samplePath) && bankPaths.every(file => fs.existsSync(file))
&& process.env.YIAN_TEST_SYNTHETIC_EXAM !== '1';
const normalize = text => String(text == null ? '' : text).replace(/[\u200b-\u200d\u2060\uFEFF]/g, '').replace(/\s+/g, ' ').trim();
const sortedTexts = texts => texts.map(normalize).sort();
const escapeHTML = text => String(text).replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;').replace(/"/g, '&quot;');
function syntheticFixture() {
const course = '考试自动选择验证';
const records = [];
const questions = Array.from({ length: 40 }, (_, index) => {
const type = index < 15 ? '单选' : index < 30 ? '多选' : '判断';
const texts = type === '判断' ? ['对', '错'] : type === '多选'
? ['正确甲' + index, '正确乙' + index, '错误丙' + index, '错误丁' + index]
: ['正确甲' + index, '错误乙' + index, '错误丙' + index, '错误丁' + index];
const question = '考试核对题 ' + (index + 1) + ':请选择符合要求的选项。';
roles.forEach(role => records.push({ role, course, number: index + 1, type, question,
options: texts.map((text, i) => ({ label: String.fromCharCode(65 + i), text })), answer: type === '多选' ? 'AB' : 'A' }));
return { type, question, texts: index < 20 ? texts.slice(1).concat(texts[0]) : texts };
});
const html = `<div class="pc_examtest"><div class="pc_examtest_left"><div class="pc_examtest_title">${course}</div>${questions.map((q, index) =>
`<div app-exam-question><div class="pc_examtest_content"><div class="pc_examtest_content_question"><p>[${q.type}] ${index + 1}. ${q.question}<span class="pc_examtest_content_question_value">(2分)</span></p><div class="pc_examtest_content_question_difficulty">难度低</div></div><div class="pc_examtest_content_list">${q.texts.map((text, i) =>
`<div class="pc_examtest_content_list_child1"><div class="pc_examtest_content_list_des"><span class="examtest_content_list_des_optionsort">${String.fromCharCode(65 + i)}</span>${text}</div></div>`).join('')}</div></div></div>`).join('')}</div><div class="pc_examtest_right"><div class="pc_examtest_countdown">倒计时:<span class="time">00:29:24</span></div><div class="pc_examtest_right_col"><h4>0</h4><h4>40</h4></div><div class="pc_examtest_card">${questions.map((_, i) => `<div class="examtestcard_count_defult">${i + 1}</div>`).join('')}</div><div class="pc_examtest_commit">交卷</div></div></div><div class="modal examine_box" aria-hidden="true"><p>是否确认交卷?</p><button>取消</button></div>`;
return { html, title: course, records };
}
function documentHTML(fixture) {
return `<!doctype html><html><head><meta charset="utf-8"><title>助手考试验证</title><style>
body{font:14px/1.6 system-ui,"Microsoft YaHei",sans-serif;margin:24px;background:#f3f6fa;color:#23334c}h1{font-size:24px;margin:0 0 12px}.pc_examtest{display:grid;grid-template-columns:minmax(0,1fr) 205px;gap:20px;max-width:1120px}.pc_examtest_left{min-width:0}.pc_examtest_right{background:white;padding:15px;align-self:start;border-radius:9px}.pc_examtest_title{font-size:20px;margin-bottom:12px}.pc_examtest_content{background:white;padding:14px 18px;margin-bottom:9px;border:1px solid #e1e7ef;border-radius:8px;overflow-wrap:anywhere}.pc_examtest_content_question p{margin:0 0 8px}.pc_examtest_content_question_value,.pc_examtest_content_question_difficulty{color:#8792a3;font-size:11px}.pc_examtest_content_list_des{padding:6px 9px;cursor:pointer;border-radius:5px}.examtest_content_list_des_optionsort{display:inline-block;margin-right:8px}.examtest_content_list_des_checked{background:#e3f5e9;color:#246240}.hidden,[hidden]{display:none!important}.modal:not(.show):not(.in){display:none}.examine_box.show{position:fixed;z-index:1000;inset:130px auto auto 160px;background:white;border:1px solid #ccd6e2;padding:24px;border-radius:10px}.pc_examtest_countdown{margin-bottom:15px}.pc_examtest_card{display:flex;flex-wrap:wrap;gap:5px;margin-top:12px}.examtestcard_count_defult{border:1px solid #d7dee7;border-radius:4px;padding:2px 5px;font-size:11px}.examtestcard_count_assignment{background:#e4f4e9;color:#296343}.pc_examtest_commit{margin-top:14px;background:#285fd3;color:white;padding:8px;border-radius:6px;text-align:center;cursor:pointer}.pc_examtest_right_col{display:flex;gap:15px}.pc_examtest_right_col h4{margin:4px 0}.pc_examtest_content img{max-width:100%}@media(max-width:700px){body{margin:12px}.pc_examtest{display:block}.pc_examtest_right{display:none}.pc_examtest_content{padding:12px}}
</style></head><body onselectstart="return false" oncontextmenu="return false"><h1>考试答案全文核对</h1><p>本地验证:40 道保存题目,交卷由用户操作。</p><app-examtest>${fixture.html}</app-examtest></body></html>`;
}
// 模拟题目组件自己的作答状态。点击先更新独立模型,再根据模型渲染 class 与答题卡。
// 不加载原站 Angular,也不实现提交接口。
function installExamModel() {
const CHECKED = 'examtest_content_list_des_checked';
const root = document.querySelector('app-examtest');
const extracted = QuestionBankCore.extract(document);
const boxes = Array.from(root.querySelectorAll('.pc_examtest_content'));
const cards = Array.from(root.querySelectorAll('.examtestcard_count_defult'));
document.querySelectorAll('.qa-confirm-dialog').forEach(node => node.remove());
const confirmation = document.createElement('div');
confirmation.className = 'modal dialog_box qa-confirm-dialog';
confirmation.setAttribute('aria-hidden', 'true');
const message = document.createElement('div'); message.className = 'modal-body'; message.textContent = '是否确认交卷?';
confirmation.appendChild(message); document.body.appendChild(confirmation);
const model = { questions: extracted.questions.map(q => ({ number: q.number, type: q.type,
options: q.options.map(option => ({ label: option.label, text: option.text })) })), userAnswer: [], clicks: [], commitClicks: 0 };
model.userAnswer = boxes.map(box => Array.from(box.querySelectorAll('.pc_examtest_content_list_des')).filter(node => node.classList.contains(CHECKED)).map(node => node.querySelector('.examtest_content_list_des_optionsort').textContent.trim()));
const render = index => {
const selected = new Set(model.userAnswer[index]);
Array.from(boxes[index].querySelectorAll('.pc_examtest_content_list_des')).forEach((node, optionIndex) => node.classList.toggle(CHECKED, selected.has(model.questions[index].options[optionIndex].label)));
if (cards[index]) cards[index].classList.toggle('examtestcard_count_assignment', selected.size > 0);
const count = model.userAnswer.filter(answer => answer.length > 0).length;
const counters = root.querySelectorAll('.pc_examtest_right_col h4');
if (counters[0]) counters[0].textContent = String(count);
if (counters[1]) counters[1].textContent = String(boxes.length - count);
};
boxes.forEach((box, index) => {
const question = model.questions[index];
Array.from(box.querySelectorAll('.pc_examtest_content_list_des')).forEach((node, optionIndex) => {
node.addEventListener('click', () => {
const option = question.options[optionIndex];
model.clicks.push({ number: question.number, label: option.label, text: option.text });
if (question.type === '多选') {
const next = new Set(model.userAnswer[index]);
if (next.has(option.label)) next.delete(option.label); else next.add(option.label);
model.userAnswer[index] = Array.from(next);
} else model.userAnswer[index] = [option.label];
render(index);
});
});
render(index);
});
const commit = root.querySelector('.pc_examtest_commit');
if (commit) commit.addEventListener('click', () => {
model.commitClicks++;
confirmation.classList.add('show'); confirmation.setAttribute('aria-hidden', 'false');
});
window.examModel = model;
}
(async () => {
const startedAt = Date.now();
const memory = new Map();
const errors = [], externalRequests = [], downloads = [];
let browser, server;
try {
browser = await chromium.launch({ channel: process.env.PLAYWRIGHT_CHANNEL || 'chrome', headless: true });
const context = await browser.newContext({ viewport: { width: 1366, height: 900 }, acceptDownloads: true });
await context.exposeBinding('__gmRead', (_, key, fallback) => memory.has(key) ? structuredClone(memory.get(key)) : fallback);
await context.exposeBinding('__gmWrite', (_, key, value) => { memory.set(key, structuredClone(value)); });
const sandboxScript = '(function(){const GM_getValue=(key,fallback)=>window.__gmRead(key,fallback);const GM_setValue=(key,value)=>window.__gmWrite(key,value);\n' + script + '\n})();';
let origin = '';
await context.route('**/*', route => {
const url = route.request().url();
if (url.startsWith(origin + '/') && origin || url.startsWith(pathToFileURL(qaDir).href + '/')) return route.continue();
externalRequests.push(url); return route.abort();
});
const capture = page => {
page.on('pageerror', error => errors.push(error.message));
page.on('console', message => { if (message.type() === 'error') errors.push(message.text()); });
page.on('download', download => downloads.push(download.suggestedFilename()));
};
const prepare = await context.newPage(); capture(prepare);
await prepare.addScriptTag({ content: coreScript });
let fixture;
if (realSample) {
fixture = await prepare.evaluate(raw => {
const inert = QuestionBankCore.parseHTML(raw);
const root = inert.querySelector('app-examtest');
if (!root) throw new Error('真实考试样本缺少 app-examtest');
const bank = QuestionBankCore.extract(inert);
if (root.querySelector('script,[src],[srcset],iframe[src],link[href]')) throw new Error('样本中存在未中和的可加载资源');
return { html: root.innerHTML, title: bank.title, count: bank.total };
}, fs.readFileSync(samplePath, 'utf8'));
assert.equal(fixture.count, 40, '真实考试应有 40 题');
} else fixture = syntheticFixture();
await prepare.close();
const pageHTML = documentHTML(fixture);
server = http.createServer((req, res) => { res.writeHead(200, { 'Content-Type': 'text/html;charset=utf-8' }); res.end(pageHTML); });
await new Promise(resolve => server.listen(0, '127.0.0.1', resolve));
origin = 'http://127.0.0.1:' + server.address().port;
const examURL = origin + '/unfixed/exam-path?flag=test&title=' + encodeURIComponent(fixture.title);
const page = await context.newPage(); capture(page);
async function open(page, url) {
await page.goto(url);
await page.addScriptTag({ content: sandboxScript });
await page.evaluate(installExamModel);
await page.evaluate(() => YianAssistant.ready);
}
await open(page, examURL);
assert.equal(await page.getByRole('switch', { name: '自动选择答案' }).isChecked(), false);
assert.equal(await page.getByRole('switch', { name: '导出题库', exact: true }).isChecked(), false);
assert.equal(await page.locator('#yian-question-assistant .section-title').filter({ hasText: '当前考试' }).count(), 1);
const extracted = await page.evaluate(() => YianAssistant.collectCurrent().questions);
assert.equal(extracted.length, 40);
extracted.forEach(question => {
assert(!/[((]\s*\d+(?:\.\d+)?\s*分\s*[))]/.test(question.question), '题干应去除分值');
assert(!/(?:难度低|难度中|难度高)/.test(question.question), '题干应去除难度');
});
const bankFiles = realSample ? bankPaths : { name: '三岗位考试题库.json', mimeType: 'application/json', buffer: Buffer.from(JSON.stringify(fixture.records)) };
await page.locator('#yian-question-assistant input[type=file]').setInputFiles(bankFiles);
await page.waitForFunction(() => YianAssistant.getSnapshot().counts.主操 > 0 && YianAssistant.getSnapshot().counts.班长 > 0 && YianAssistant.getSnapshot().counts.副操 > 0);
console.log('PASS ' + (realSample ? '真实保存考试 DOM 与仓库三岗位 CSV 导入' : '可独立运行的 40 题 / 三岗位内部 fixture') + '、考试识别、默认关闭、分值与难度去除');
const pages = [page, await context.newPage(), await context.newPage()];
pages.slice(1).forEach(capture);
await Promise.all(pages.map(async (target, index) => {
if (index) await open(target, examURL + '&rolecase=' + index);
await target.getByRole('radio', { name: roles[index], exact: true }).check();
await target.waitForFunction(() => YianAssistant.getContext().ready);
const expectations = await target.evaluate(() => {
const context = YianAssistant.getContext(), snapshot = YianAssistant.getSnapshot();
return YianAssistant.collectCurrent().questions.map(question => {
const matched = YianAnswerMatcher.match(question, context, snapshot);
return { number: question.number, type: question.type, status: matched.status, reason: matched.reason,
originalAnswer: matched.records[0] && matched.records[0].answer,
bankTexts: matched.records[0] && matched.records[0].correctTexts,
currentLabels: matched.correctLabels, evidenceTexts: matched.correctTexts };
});
});
assert.equal(expectations.filter(item => item.status === 'matched').length, 40, roles[index] + '应匹配 40 / 40;' + JSON.stringify(expectations.filter(item => item.status !== 'matched')));
assert.equal(expectations.filter(item => item.currentLabels.join('') !== item.originalAnswer).length, 20, '应有 20 题需要按全文重新映射字母');
target.__expectations = expectations;
await target.getByRole('switch', { name: '自动选择答案' }).check();
await target.waitForFunction(() => YianAssistant.getState().metrics.selected === 40, undefined, { timeout: 35000 });
const actual = await target.evaluate(() => {
const bank = YianAssistant.collectCurrent();
const rows = Array.from(document.querySelectorAll('.pc_examtest_content'));
return { state: YianAssistant.getState(), commitClicks: examModel.commitClicks,
cards: document.querySelectorAll('.examtestcard_count_assignment').length,
selectedTexts: rows.map((box, index) => Array.from(box.querySelectorAll('.pc_examtest_content_list_des')).flatMap((node, i) => node.classList.contains('examtest_content_list_des_checked') ? [bank.questions[index].options[i].text] : [])),
modelTexts: examModel.questions.map((question, index) => question.options.filter(option => examModel.userAnswer[index].includes(option.label)).map(option => option.text)),
evidence: Array.from(document.querySelector('#yian-question-assistant').shadowRoot.querySelectorAll('.answer-check-item')).map(node => node.textContent) };
});
assert.equal(actual.state.metrics.pending, 0, '考试多选不应显示待练习确认');
assert.equal(actual.state.metrics.failed + actual.state.metrics.locked + actual.state.metrics.missing + actual.state.metrics.conflict + actual.state.metrics.unsupported, 0);
assert.equal(actual.commitClicks, 0, '脚本不得点击交卷');
assert.equal(actual.cards, 40, '独立模型应更新全部答题卡');
assert.equal(actual.evidence.length, 40);
expectations.forEach((expected, qIndex) => {
assert.deepEqual(sortedTexts(actual.selectedTexts[qIndex]), sortedTexts(expected.bankTexts), roles[index] + '第 ' + expected.number + ' 题 DOM 应等于题库全文');
assert.deepEqual(sortedTexts(actual.modelTexts[qIndex]), sortedTexts(expected.bankTexts), roles[index] + '第 ' + expected.number + ' 题独立作答模型应等于题库全文');
expected.evidenceTexts.forEach((text, optionIndex) => assert(normalize(actual.evidence[qIndex]).includes(normalize(expected.currentLabels[optionIndex] + '. ' + text)), '核对面板应显示原库全文与对应当前字母'));
});
await target.getByRole('switch', { name: '自动选择答案' }).uncheck();
console.log('PASS ' + roles[index] + ':40 / 40 已选择,20 题字母变化;所有 DOM、独立模型及核对面板均与原题库正确答案全文一致');
}));
await Promise.all(pages.slice(1).map(target => target.close()));
const evidenceDetails = page.locator('#yian-question-assistant .answer-check');
await evidenceDetails.locator('summary').click();
await page.locator('#yian-question-assistant .answer-check-item').first().evaluate(node => { node.dataset.qaPersistent = '1'; });
await page.waitForTimeout(650);
assert.equal(await evidenceDetails.evaluate(node => node.open), true);
assert.equal(await page.locator('#yian-question-assistant .answer-check-item').first().getAttribute('data-qa-persistent'), '1', '刷新不应重建核对节点');
await page.screenshot({ path: path.join(qaDir, 'exam-desktop.png') });
await page.locator('#yian-question-assistant').screenshot({ path: path.join(qaDir, 'exam-answer-panel.png') });
await page.setViewportSize({ width: 390, height: 844 });
assert(await page.evaluate(() => document.documentElement.scrollWidth <= innerWidth), '小屏页面不得横向溢出');
await page.screenshot({ path: path.join(qaDir, 'exam-mobile.png') });
await page.setViewportSize({ width: 1366, height: 900 });
await page.evaluate(() => YianAssistant.setExportEnabled(true));
assert.equal(await page.getByRole('button', { name: '导出 CSV', exact: true }).isDisabled(), true);
assert.equal(await page.evaluate(() => { try { YianAssistant.exportCurrent('csv'); return ''; } catch (error) { return error.message; } }), '请先进入支持的练习页面');
await page.evaluate(() => YianAssistant.setExportEnabled(false));
console.log('PASS 答案核对展开状态 / 节点复用、桌面与小屏截图、考试禁用导出');
let sequence = 0;
async function reset(mode = 'test', mutate, waitReady = true) {
await page.evaluate(({ html, mode, title, sequence }) => {
YianAssistant.setAutoAnswer(false);
const root = document.createElement('app-examtest'); root.innerHTML = html;
document.querySelector('app-examtest').replaceWith(root);
history.replaceState({}, '', '/unfixed/case-' + sequence + '?flag=' + mode + '&title=' + encodeURIComponent(title));
if (mode === 'exercise') {
root.querySelector('.pc_examtest_commit').textContent = '重新练习';
root.querySelectorAll('.pc_examtest_content_question p').forEach(p => { const button = document.createElement('span'); button.className = 'pc_examtest_content_question_showanswer'; button.textContent = '查看答案'; p.appendChild(button); });
}
}, { html: fixture.html, mode, title: fixture.title, sequence: ++sequence });
if (mutate) await page.evaluate(mutate);
await page.evaluate(installExamModel);
if (waitReady) await page.waitForFunction(() => YianAssistant.getContext().ready);
else await page.waitForTimeout(1600);
}
await reset();
await page.getByRole('switch', { name: '自动选择答案' }).check();
await page.waitForFunction(() => examModel.clicks.length > 0);
await page.evaluate(() => YianAssistant.setAutoAnswer(false));
const stoppedAt = await page.evaluate(() => examModel.clicks.length);
await page.waitForTimeout(400);
assert.equal(await page.evaluate(() => examModel.clicks.length), stoppedAt);
assert(stoppedAt < 40, '应在后续题目之前关闭');
// 考试的单选可以改选:关闭后手动改第一题,再开启应重新核对已答题。
const wrongIndex = await page.evaluate(() => {
const q = YianAssistant.collectCurrent().questions[0];
const matched = YianAnswerMatcher.match(q, YianAssistant.getContext(), YianAssistant.getSnapshot());
return q.options.findIndex(option => !matched.correctLabels.includes(option.label));
});
await page.locator('.pc_examtest_content').first().locator('.pc_examtest_content_list_des').nth(wrongIndex).click();
await page.getByRole('switch', { name: '自动选择答案' }).check();
await page.waitForFunction(() => {
const q = YianAssistant.collectCurrent().questions[0];
const matched = YianAnswerMatcher.match(q, YianAssistant.getContext(), YianAssistant.getSnapshot());
return examModel.userAnswer[0].length === matched.correctLabels.length && matched.correctLabels.every(label => examModel.userAnswer[0].includes(label));
});
await page.evaluate(() => YianAssistant.setAutoAnswer(false));
console.log('PASS 关闭立即停止后续点击,恢复后重新核对人工改过的单选');
await reset();
await page.getByRole('switch', { name: '自动选择答案' }).check();
await page.waitForFunction(() => examModel.clicks.length > 0);
await page.getByRole('button', { name: '收起题库助手' }).click();
await page.locator('.pc_examtest_commit').click();
const modalClicks = await page.evaluate(() => examModel.clicks.length);
await page.waitForFunction(() => YianAssistant.getState().autoAnswer === false);
await page.waitForTimeout(250);
assert.equal(await page.evaluate(() => examModel.clicks.length), modalClicks, '交卷确认打开后不得继续选择');
assert.equal(await page.evaluate(() => examModel.commitClicks), 1, '只有测试模拟的用户点击了一次交卷');
assert.equal(await page.evaluate(() => YianAssistant.setAutoAnswer(true)), false);
console.log('PASS 用户打开交卷确认后停止,确认期间拒绝重新开启');
await reset('test', () => { const blocker = document.createElement('div'); blocker.id = 'examtestDisabled'; document.querySelector('app-examtest').appendChild(blocker); });
assert.equal(await page.evaluate(() => YianAssistant.setAutoAnswer(true)), false);
assert.equal(await page.evaluate(() => examModel.clicks.length), 0);
await reset('test', () => { const blocker = document.createElement('div'); blocker.setAttribute('beingsubmitted', ''); blocker.textContent = '正在提交'; document.querySelector('app-examtest').appendChild(blocker); });
assert.equal(await page.evaluate(() => YianAssistant.setAutoAnswer(true)), false);
assert.equal(await page.evaluate(() => examModel.clicks.length), 0);
await reset();
await page.getByRole('switch', { name: '自动选择答案' }).check();
await page.waitForFunction(() => examModel.clicks.length > 0);
await page.evaluate(() => { document.querySelector('.pc_examtest_countdown .time').textContent = '00:00:00'; });
const expiredAt = await page.evaluate(() => examModel.clicks.length);
await page.waitForFunction(() => YianAssistant.getState().autoAnswer === false);
assert.equal(await page.evaluate(() => examModel.clicks.length), expiredAt);
assert.equal(await page.evaluate(() => YianAssistant.setAutoAnswer(true)), false);
console.log('PASS 提交 disabled 状态拒绝开启,倒计时 00 停止');
await reset('unknown');
assert.equal(await page.evaluate(() => YianAssistant.setAutoAnswer(true)), false);
await reset('test', () => { document.querySelector('.pc_examtest_left').className = 'mobile_examtest_left'; });
assert.equal(await page.evaluate(() => YianAssistant.setAutoAnswer(true)), false);
await reset('test', () => { document.querySelectorAll('.examtestcard_count_defult').forEach(node => node.remove()); }, false);
assert.equal(await page.evaluate(() => YianAssistant.getState().pageReady), false, '空答题卡不能确认完整试卷');
assert.equal(await page.evaluate(() => YianAssistant.setAutoAnswer(true)), false);
const filePath = path.join(qaDir, 'saved-exam.html');
fs.writeFileSync(filePath, '<!-- saved from url=(0000)' + examURL + ' -->\n' + pageHTML);
const saved = await context.newPage(); capture(saved);
await open(saved, pathToFileURL(filePath).href);
await saved.waitForFunction(() => YianAssistant.getState().pageReady);
assert.equal(await saved.evaluate(() => YianAssistant.setAutoAnswer(true)), false);
assert.equal(await saved.evaluate(() => examModel.clicks.length), 0);
await saved.close();
await reset('exercise');
const navigation = await page.evaluate(({ html, title }) => {
const enabled = YianAssistant.setAutoAnswer(true);
const root = document.createElement('app-examtest'); root.innerHTML = html;
document.querySelector('app-examtest').replaceWith(root);
history.pushState({}, '', '/unfixed/final-exam?flag=test&title=' + encodeURIComponent(title));
dispatchEvent(new PopStateEvent('popstate'));
return { enabled, afterNavigation: YianAssistant.getState().autoAnswer };
}, { html: fixture.html, title: fixture.title });
assert.equal(navigation.enabled, true);
assert.equal(navigation.afterNavigation, false);
await page.waitForTimeout(250);
assert.equal(await page.getByRole('switch', { name: '自动选择答案' }).isChecked(), false);
console.log('PASS 未知 flag、移动页面、file 保存考试拒绝自动选择;练习转考试恢复关闭');
assert.deepEqual(externalRequests, [], '测试不允许任何外部业务请求');
assert.deepEqual(downloads, [], '考试不得自动或手动发起题库下载');
assert.deepEqual(errors, [], '页面和控制台不应出现错误');
console.log('PASS 零外部请求、零交卷接口调用、零导出下载、无页面 / 控制台错误');
console.log('截图:' + qaDir);
console.log('总耗时:' + ((Date.now() - startedAt) / 1000).toFixed(1) + ' 秒');
} finally {
if (browser) await browser.close();
if (server) await new Promise(resolve => server.close(resolve));
}
})().catch(error => { console.error(error); process.exitCode = 1; });
+1 -1
View File
@@ -1,6 +1,6 @@
const { spawnSync } = require('node:child_process');
const path = require('node:path');
for (const file of ['bank-engine.cjs', 'runner-browser.cjs', 'verify.cjs', 'assistant.cjs']) {
for (const file of ['bank-engine.cjs', 'runner-browser.cjs', 'verify.cjs', 'assistant.cjs', 'exam-assistant.cjs']) {
const result = spawnSync(process.execPath, [path.join(__dirname, file)], { stdio: 'inherit' });
if (result.status !== 0) process.exit(result.status || 1);
}
+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));
+1 -1
View File
@@ -150,7 +150,7 @@ function parseCSV(text) {
await page.evaluate(() => history.pushState({}, '', location.href.replace('flag=exercise', 'flag=exam')));
await page.waitForTimeout(1100); assert(await page.getByRole('button', { name: '导出 CSV', exact: true }).isDisabled());
assert.equal(await page.evaluate(() => window.answerClicks), 0);
console.log('PASS 缺题等待、考试页面不触发、不改变答题状态');
console.log('PASS 缺题等待、未知考试模式不触发、不改变答题状态');
await page.getByRole('switch', { name: '导出题库', exact: true }).uncheck();
assert(await page.getByRole('button', { name: '导出 CSV', exact: true, includeHidden: true }).isHidden());