Files
yianxue-assistant/tests/exam-assistant.cjs
T

381 lines
30 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/* 桌面考试端到端验证:只载入中和后的保存 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.deepEqual(Array.from(script.matchAll(/^\/\/ @match\s+(.+)$/gm), match => match[1].trim()), ['http://10.119.67.7/clientcn/*']);
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>`;
}
async function activate(page, key) {
const expand = page.getByRole('button', { name: '展开题库助手', exact: true });
if (await expand.isVisible()) await expand.click();
await page.getByTestId('tab-' + key).click();
}
// 模拟题目组件自己的作答状态。点击先更新独立模型,再根据模型渲染 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})();';
const origin = 'http://10.119.67.7';
let localOrigin = '';
await context.route('**/*', async route => {
const url = new URL(route.request().url());
if (url.origin === origin && localOrigin) {
const response = await route.fetch({ url: localOrigin + url.pathname + url.search });
return route.fulfill({ response });
}
if (localOrigin && url.origin === localOrigin || url.protocol === 'file:' && url.href.startsWith(pathToFileURL(qaDir).href + '/')) return route.continue();
externalRequests.push(url.href); 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));
localOrigin = 'http://127.0.0.1:' + server.address().port;
const examURL = origin + '/clientcn/exam/nested/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 page.evaluate(() => YianAssistant.setLearningSetting('answerInterval', 0));
await activate(page, 'exam');
}
await open(page, examURL);
assert.equal(await page.getByRole('switch', { name: '自动选择答案' }).isChecked(), false);
await activate(page, 'settings');
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 activate(page, 'bank');
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 + '&sessioncase=' + index);
await target.evaluate(async () => {
const title = YianAssistant.collectCurrent().title;
const courses = Array.from(new Set(YianAssistant.getSnapshot().records.map(record => record.course))).filter(Boolean);
await YianAssistant.setLearningSetting('associations', { [title]: courses });
});
await activate(target, 'exam');
assert.equal(await target.getByRole('radio').count(), 0, '统一题库没有岗位选择');
await target.waitForFunction(() => YianAssistant.getContext().ready);
assert((await target.evaluate(() => YianAssistant.getContext().courses || [])).length > 0, '明确关联后须确认可用题库主题,再启动答题');
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,
sourceRoles: Array.from(new Set((matched.records || []).map(record => record.role))).sort() };
});
});
assert.equal(expectations.filter(item => item.status === 'matched').length, 40, '统一题库应匹配 40 / 40;' + JSON.stringify(expectations.filter(item => item.status !== 'matched')));
expectations.forEach(item => assert.deepEqual(item.sourceRoles, roles.slice().sort(), '同题聚合保留三个岗位来源'));
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 }).catch(async error => {
console.error('考试超时诊断:' + JSON.stringify(await target.evaluate(() => {
const state = YianAssistant.getState(), context = YianAssistant.getContext();
return { autoAnswer: state.autoAnswer, metrics: state.metrics, examStage: state.exam.stage, examMessage: state.exam.message,
ready: context.ready, courses: context.courses, clicks: examModel.clicks.length,
notice: document.querySelector('#yian-question-assistant').shadowRoot.querySelector('.notice').textContent };
})));
throw error;
});
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), '会话 ' + (index + 1) + ' 第 ' + expected.number + ' 题 DOM 应等于题库全文');
assert.deepEqual(sortedTexts(actual.modelTexts[qIndex]), sortedTexts(expected.bankTexts), '会话 ' + (index + 1) + ' 第 ' + 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 统一题库会话 ' + (index + 1) + ':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 activate(page, 'settings');
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));
await activate(page, 'exam');
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({}, '', '/clientcn/exam/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 activate(page, 'exam');
}
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 saved.goto(pathToFileURL(filePath).href);
const inspectSaved = () => saved.evaluate(() => ({
body: document.body.innerHTML, select: document.body.getAttribute('onselectstart'),
context: document.body.getAttribute('oncontextmenu'), style: document.body.getAttribute('style'),
userSelect: getComputedStyle(document.body).userSelect,
selected: document.querySelectorAll('.examtest_content_list_des_checked').length
}));
const beforeSaved = await inspectSaved();
await saved.addScriptTag({ content: sandboxScript });
assert.equal(await saved.locator('#yian-question-assistant').count(), 0);
assert.deepEqual(await saved.evaluate(() => [typeof window.JSZip, typeof window.QuestionBankCore, typeof window.YianAssistant]), ['undefined', 'undefined', 'undefined']);
assert.deepEqual(await inspectSaved(), beforeSaved, 'file 保存考试不能注入或改变复制限制和选项');
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({}, '', '/clientcn/exam/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; });