Files
yianxue-assistant/tests/verify.cjs
T
2026-10-10 02:03:40 +08:00

196 lines
14 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.
/* 真实浏览器验证:只启动本机测试服务,不请求原网站。 */
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 { chromium } = require('playwright');
const root = path.resolve(__dirname, '..');
const coreSource = fs.readFileSync(path.join(root, 'question-bank-core.js'), 'utf8');
const scriptSource = fs.readFileSync(path.join(root, '谊安学题库导出.user.js'), 'utf8');
const qaDir = process.env.YIAN_QA_DIR || path.join(os.tmpdir(), 'yianxue-export-qa');
fs.mkdirSync(qaDir, { recursive: true });
const samplePath = path.join(root, '网页样本', '考试答案', '谊安学.html');
function option(label, text, selected = false) {
return `<div class="pc_examtest_content_list_child1"><div class="pc_examtest_content_list_des${selected ? ' examtest_content_list_des_checked' : ''}" onclick="window.answerClicks++"><span class="examtest_content_list_des_optionsort">${label}</span>${text}</div></div>`;
}
function fixture(title = '模拟练习', { missingAnswer = false, cardCount = 3 } = {}) {
const content = [
{ type: '单选', stem: '包含引号的项目 "甲,乙"?', options: [option('A', '错误选择', true), option('B', '正确选项')], answer: '<span class="answer">B</span>', explanation: '请依据参考答案。' },
{ type: '多选', stem: '选择符合条件的项目。', options: ['A', 'B', 'C', 'D', 'E'].map(x => option(x, '项目' + x)), answer: '<span class="answer">A</span><span class="answer">E</span>', explanation: '' },
{ type: '判断', stem: '隐藏答案应能读取。', options: [option('A', '对'), option('B', '错')], answer: missingAnswer ? '' : '<span class="answer">B</span>', explanation: '' }
].map((q, i) => `<div class="pc_exam_list" app-exam-question><a id="examtest${i}"></a><div class="pc_examtest_content"><div class="pc_examtest_content_question"><p><span>[${q.type}]</span> ${i + 1}. ${q.stem}<span class="pc_examtest_content_question_showanswer" onclick="window.answerClicks++">查看答案</span></p></div><div class="pc_examtest_content_list">${q.options.join('')}</div><div class="hidden"><div class="pc_examtest_content_answer_top"><div class="examtest_content_answer_left">参考答案:${q.answer}</div><div>答错了</div></div><div class="pc_examtest_content_resolve">题目解析:<span>${q.explanation}</span></div></div></div></div>`).join('');
return `<!doctype html><title>模拟谊安学练习</title><style>.hidden{display:none}body{font:16px system-ui;margin:30px}.pc_examtest_content{padding:15px;border-bottom:1px solid #ddd}.pc_examtest_title{font-size:24px}</style><app-examtest><div class="pc_examtest_title">${title}</div>${content}<div class="pc_examtest_card">${Array.from({ length: cardCount }, (_, i) => `<div class="examtestcard_count_defult">${i + 1}</div>`).join('')}</div></app-examtest>`;
}
function parseCSV(text) {
text = text.replace(/^\uFEFF/, '');
const rows = []; let row = []; let cell = ''; let quoted = false;
for (let i = 0; i < text.length; i++) {
const ch = text[i];
if (ch === '"') {
if (quoted && text[i + 1] === '"') { cell += '"'; i++; }
else quoted = !quoted;
} else if (!quoted && ch === ',') { row.push(cell); cell = ''; }
else if (!quoted && (ch === '\r' || ch === '\n')) {
if (ch === '\r' && text[i + 1] === '\n') i++;
row.push(cell); rows.push(row); row = []; cell = '';
} else cell += ch;
}
if (cell || row.length) { row.push(cell); rows.push(row); }
assert.equal(quoted, false, 'CSV 引号需成对');
return rows;
}
(async function main() {
const server = http.createServer((req, res) => {
const requestURL = new URL(req.url, 'http://localhost');
if (requestURL.pathname.endsWith('/question-bank-core.js')) {
res.writeHead(200, { 'Content-Type': 'text/javascript;charset=utf-8' }); res.end(coreSource);
} else if (requestURL.pathname.startsWith('/tool/')) {
res.writeHead(200, { 'Content-Type': 'text/html;charset=utf-8' }); res.end(fs.readFileSync(path.join(root, '离线导出题库.html')));
} else {
res.writeHead(200, { 'Content-Type': 'text/html;charset=utf-8' }); res.end(fixture(requestURL.searchParams.get('title') || '模拟练习'));
}
});
await new Promise(resolve => server.listen(0, '127.0.0.1', resolve));
const origin = `http://127.0.0.1:${server.address().port}`;
let browser;
try {
browser = await chromium.launch({ channel: process.env.PLAYWRIGHT_CHANNEL || 'chrome', headless: true });
const context = await browser.newContext({ acceptDownloads: true, viewport: { width: 1366, height: 900 } });
const page = await context.newPage();
const errors = []; page.on('pageerror', error => errors.push(error.message));
page.on('console', message => { if (message.type() === 'error' || message.type() === 'warning') errors.push(message.text()); });
const downloads = []; page.on('download', download => downloads.push(download));
const practiceURL = title => origin + '/clientcn/exam/' + encodeURIComponent(title) + '/questions?title=' + encodeURIComponent(title) + '&flag=exercise';
await page.goto(practiceURL('模拟练习'));
await page.evaluate(() => { window.answerClicks = 0; });
const firstDownload = page.waitForEvent('download');
await page.addScriptTag({ content: scriptSource });
const downloaded = await firstDownload;
await downloaded.saveAs(path.join(qaDir, 'synthetic.csv'));
const bank = await page.evaluate(() => QuestionBankCore.extract(document));
assert.equal(bank.total, 3); assert.equal(bank.answered, 3); assert.equal(bank.complete, true);
assert.equal(bank.questions[0].answer, 'B'); assert.equal(bank.questions[0].options[0].text, '错误选择');
assert.equal(bank.questions[1].answer, 'AE'); assert.equal(bank.questions[1].options.length, 5);
assert.equal(bank.questions[2].answerText, 'B. 错'); assert.equal(bank.questions[0].explanation, '请依据参考答案。');
assert(bank.questions.every(q => !q.question.includes('查看答案')));
assert.equal(await page.evaluate(() => window.answerClicks), 0);
const csv = fs.readFileSync(path.join(qaDir, 'synthetic.csv'), 'utf8');
assert.equal(csv.charCodeAt(0), 0xFEFF);
const rows = parseCSV(csv); assert.equal(rows.length, 4);
assert(rows.every(row => row.length === rows[0].length));
assert.equal(rows[1][rows[0].indexOf('题目')], '包含引号的项目 "甲,乙"?');
assert(rows[0].includes('选项E'));
const formulaCSV = await page.evaluate(() => {
const b = QuestionBankCore.extract(document); b.questions[0].question = '=HYPERLINK("http://invalid")'; return QuestionBankCore.toCSV(b);
});
assert(parseCSV(formulaCSV)[1][2].startsWith("'="));
await page.waitForTimeout(2400); assert.equal(downloads.length, 1, '同一题库不应重复自动下载');
await page.screenshot({ path: path.join(qaDir, 'practice-desktop.png') });
console.log('PASS 隐藏答案、错误已选项隔离、多选、判断、完整CSV与单次自动下载');
// 新 URL 与新 DOM 在下一次扫描前一同更新,仍应成功自动导出。
const nextDownload = page.waitForEvent('download');
await page.evaluate(({ url, html }) => {
history.pushState({}, '', url);
const doc = new DOMParser().parseFromString(html, 'text/html');
document.querySelector('app-examtest').replaceWith(doc.querySelector('app-examtest'));
}, { url: practiceURL('第二份练习'), html: fixture('第二份练习') });
assert.equal((await nextDownload).suggestedFilename(), '第二份练习_题库_3题.csv');
console.log('PASS SPA 页面与地址同步切换自动导出');
// URL 先改变、旧内容仍在时不得导出旧题;题目延后到达再导出。
const beforeDelayed = downloads.length;
await page.evaluate(url => history.pushState({}, '', url), practiceURL('延迟练习'));
await page.waitForTimeout(2600); assert.equal(downloads.length, beforeDelayed);
const delayedDownload = page.waitForEvent('download');
await page.evaluate(html => {
const doc = new DOMParser().parseFromString(html, 'text/html');
document.querySelector('app-examtest').innerHTML = doc.querySelector('app-examtest').innerHTML;
}, fixture('延迟练习'));
assert.equal((await delayedDownload).suggestedFilename(), '延迟练习_题库_3题.csv');
console.log('PASS SPA 旧页面残留与延迟加载等待');
const beforeMissing = downloads.length;
await page.evaluate(({ url, html }) => {
history.pushState({}, '', url);
document.querySelector('app-examtest').innerHTML = new DOMParser().parseFromString(html, 'text/html').querySelector('app-examtest').innerHTML;
}, { url: practiceURL('缺答案练习'), html: fixture('缺答案练习', { missingAnswer: true }) });
await page.waitForTimeout(2600); assert.equal(downloads.length, beforeMissing);
assert((await page.locator('#yian-question-bank-exporter').innerText()).includes('缺少 1 个答案'));
const manual = page.waitForEvent('download'); await page.getByRole('button', { name: '导出 CSV', exact: true }).click();
assert((await manual).suggestedFilename().includes('_不完整'));
const missing = await page.evaluate(() => QuestionBankCore.extract(document));
assert.equal(missing.complete, false); assert.deepEqual(missing.missingAnswers, [3]);
console.log('PASS 缺答案不自动下载,手动导出标记不完整');
await page.evaluate(({ url, html }) => {
history.pushState({}, '', url);
document.querySelector('app-examtest').innerHTML = new DOMParser().parseFromString(html, 'text/html').querySelector('app-examtest').innerHTML;
}, { url: practiceURL('缺题练习'), html: fixture('缺题练习', { cardCount: 4 }) });
const beforeCount = downloads.length;
await page.waitForTimeout(2600); assert.equal(downloads.length, beforeCount);
assert((await page.locator('#yian-question-bank-exporter').innerText()).includes('3 / 4'));
await page.evaluate(() => history.pushState({}, '', location.href.replace('flag=exercise', 'flag=exam')));
await page.waitForTimeout(1100); assert.equal(await page.locator('#yian-question-bank-exporter').count(), 0);
assert.equal(await page.evaluate(() => window.answerClicks), 0);
console.log('PASS 缺题等待、考试页面不触发、不改变答题状态');
const sampleHTML = fs.existsSync(samplePath) ? fs.readFileSync(samplePath, 'utf8') : fixture();
const tool = await context.newPage();
tool.on('pageerror', error => errors.push(error.message));
tool.on('console', message => { if (message.type() === 'error' || message.type() === 'warning') errors.push(message.text()); });
const toolURL = require('node:url').pathToFileURL(path.join(root, '离线导出题库.html')).href;
await tool.goto(toolURL);
assert.equal(tool.url(), toolURL);
assert.equal(await tool.title(), '离线导出题库');
assert(await tool.getByRole('heading', { name: '从已保存的练习网页导出题库' }).isVisible());
const sourceRequests = [];
tool.on('request', request => { if (!request.url().startsWith(origin)) sourceRequests.push(request.url()); });
await tool.locator('#file').setInputFiles({ name: '谊安学.html', mimeType: 'text/html', buffer: Buffer.from(sampleHTML) });
await tool.locator('#csv').waitFor({ state: 'visible' });
await tool.waitForFunction(() => !document.getElementById('csv').disabled);
assert.equal(sourceRequests.length, 0, '解析网页时不应请求原网站资源');
const realBank = await tool.evaluate(html => QuestionBankCore.extract(QuestionBankCore.parseHTML(html)), sampleHTML);
if (fs.existsSync(samplePath)) {
assert.equal(realBank.total, 120); assert.equal(realBank.answered, 120); assert.equal(realBank.expectedTotal, 120);
assert.equal(realBank.complete, true);
assert.deepEqual(realBank.questions.reduce((o, q) => (o[q.type] = (o[q.type] || 0) + 1, o), {}), { 单选: 40, 多选: 40, 判断: 40 });
assert.equal(realBank.questions[0].answer, 'C'); assert.equal(realBank.questions[0].options[0].text, '2种');
assert.equal(realBank.questions[20].answer, 'AB'); assert.equal(realBank.questions[40].answerText, 'A. 对');
assert.equal(realBank.questions[100].answerText, 'B. 错');
assert(realBank.questions.every(q => q.explanation === ''));
assert.deepEqual(realBank.questions.map(q => q.number), Array.from({ length: 120 }, (_, i) => i + 1));
console.log('PASS 真实样本120题120答案,三种题型各40题、全部解析为空');
}
for (const format of ['csv', 'json', 'txt']) {
const promise = tool.waitForEvent('download'); await tool.locator('#' + format).click();
const file = await promise; await file.saveAs(path.join(qaDir, file.suggestedFilename()));
assert((await file.failure()) === null);
}
await tool.screenshot({ path: path.join(qaDir, 'offline-desktop.png'), fullPage: true });
await tool.setViewportSize({ width: 390, height: 844 });
assert(await tool.evaluate(() => document.documentElement.scrollWidth <= window.innerWidth));
await tool.screenshot({ path: path.join(qaDir, 'offline-mobile.png'), fullPage: true });
console.log('PASS 离线工具文件导入、三格式下载、无联网、桌面与手机布局');
// 验证原网页的文档级保存注释可以激活 file: 模式。
const local = await context.newPage();
const localHTML = '<!doctype html><!-- saved from url=(0100)http://10.119.67.7/clientcn/exam/local/questions?flag=exercise -->' + fixture('本地练习');
const localPath = path.join(qaDir, 'local-practice.html'); fs.writeFileSync(localPath, localHTML);
await local.goto(require('node:url').pathToFileURL(localPath).href);
const localDownload = local.waitForEvent('download'); await local.addScriptTag({ content: scriptSource });
assert.equal((await localDownload).suggestedFilename(), '本地练习_题库_3题.csv');
assert.equal(errors.length, 0, '浏览器不应有控制台/运行时错误:' + errors.join('\n'));
console.log('PASS 本地保存网页自动识别与浏览器错误检查');
console.log('验证截图和下载文件:' + qaDir);
} finally {
if (browser) await browser.close();
await new Promise(resolve => server.close(resolve));
}
})().catch(error => { console.error(error); process.exitCode = 1; });