/* 自动选择真实浏览器回归:本机 HTTP fixture,绝不连接原网站或修改真实练习。 */ const assert = require('node:assert/strict'); const fs = require('node:fs'); const path = require('node:path'); const http = require('node:http'); const { chromium } = require('playwright'); 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, exam = false) { const option = (question, label) => `
${label}${question.texts && question.texts[label] || '选项' + label}
`; return `
测试练习
${items.map((question, index) => `
[${question.type}] ${index + 1}. 测试题${index + 1}${question.locked || exam ? '' : '查看答案'}
${['A', 'B', 'C', 'D', 'E'].slice(0, question.type === '判断' ? 2 : 5).map(label => option(question, label)).join('')}
${question.locked ? '' : ''}${exam ? '' : ''}
`).join('')}${exam ? '
交卷
' : ''}
`; } async function main() { const server = http.createServer((request, response) => { response.writeHead(200, { 'Content-Type': 'text/html;charset=utf-8' }); response.end('自动选择本机回归'); }); await new Promise(resolve => server.listen(0, '127.0.0.1', resolve)); let browser; try { browser = await chromium.launch({ channel: process.env.PLAYWRIGHT_CHANNEL || 'chrome', headless: true }); const page = await browser.newPage(); const errors = []; page.on('pageerror', error => errors.push(error.message)); await page.goto(`http://127.0.0.1:${server.address().port}/practice`); await page.addScriptTag({ content: coreSource }); await page.addScriptTag({ content: runnerSource }); async function setup(items, mode = 'normal', exam = false) { await page.evaluate(({ html, mode, exam }) => { window.qaRunner?.destroy(); document.body.innerHTML = html; window.qaClicks = []; window.qaStates = []; window.qaMatchStarted = false; window.qaReleaseMatch = null; window.qaMode = mode; 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'); window.qaClicks.push([box.dataset.kind, node.dataset.label]); if (box.dataset.nochange === '1') return; if (box.dataset.kind === '多选') { node.classList.toggle('examtest_content_list_des_checked'); } 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'); 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 === '多选' ? 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; if (window.qaMode === 'stop-after-first' && counts.selected === 1) window.qaRunner.stop('测试停止'); }, onState: (state, reason) => window.qaStates.push([state, reason]) }); }, { html: fixture(items, exam), mode, exam }); } async function delayedStart() { await page.evaluate(() => { window.qaPromise = qaRunner.start(); }); await page.waitForFunction(() => window.qaMatchStarted && typeof window.qaReleaseMatch === 'function'); } async function finishMatch() { await page.evaluate(() => { window.qaReleaseMatch(); return qaPromise; }); } await setup([{ type: '单选' }, { type: '多选', checked: ['A'] }, { type: '判断' }, { type: '单选', checked: ['A'], locked: true }, { type: '单选', nochange: true }]); const startTime = Date.now(); const initial = await page.evaluate(async () => { const first = qaRunner.start(); const second = qaRunner.start(); const samePromise = first === second; await first; return { counts: qaProgress, clicks: qaClicks, states: qaStates, samePromise }; }); assert.equal(initial.counts.selected, 3); assert.equal(initial.counts.locked, 1); assert.equal(initial.counts.total, 5); assert.deepEqual(initial.clicks.filter(([type]) => type !== '多选'), [['单选', 'B'], ['判断', 'B'], ['单选', 'B']]); console.log('PASS single/judgment selection and locked question preservation'); assert.equal(initial.counts.pending, 1); assert.deepEqual(initial.clicks.filter(([type]) => type === '多选'), [['多选', 'A'], ['多选', 'B'], ['多选', 'E']]); assert.deepEqual(await page.evaluate(() => Array.from(document.querySelectorAll('[data-kind="多选"] .examtest_content_list_des_checked')).map(node => node.dataset.label)), ['B', 'E']); console.log('PASS multiselect removes wrong choices then adds correct choices'); assert.equal(initial.counts.failed, 1); assert(initial.counts.details.some(item => item.status === 'failed' && item.reason.includes('限定时间'))); assert(Date.now() - startTime < 10000, '无响应点击必须有界结束'); assert.equal(initial.states.at(-1)[0], 'complete'); console.log('PASS nonresponding option fails once and completes within limit'); assert(initial.samePromise, '并发 start 必须共用运行 Promise'); await page.evaluate(() => qaRunner.start()); assert.equal(await page.evaluate(() => qaClicks.length), initial.clicks.length); console.log('PASS concurrent and repeated starts do not duplicate clicks'); await setup([{ type: '单选' }, { type: '单选' }], 'stop-after-first'); await page.evaluate(() => qaRunner.start()); assert.equal(await page.evaluate(() => qaClicks.length), 1); assert.equal(await page.evaluate(() => qaRunner.running), false); await page.evaluate(() => { window.qaMode = 'normal'; return qaRunner.start(); }); assert.equal(await page.evaluate(() => qaClicks.length), 2); assert.equal(await page.evaluate(() => qaProgress.selected), 2); console.log('PASS switch off stops subsequent clicks and restart continues'); await setup([{ type: '单选' }], 'delayed'); await delayedStart(); await page.evaluate(() => { qaContext.topicKey = '新主题关联'; }); await finishMatch(); assert.equal(await page.evaluate(() => qaClicks.length), 0); await page.evaluate(() => { window.qaMode = 'normal'; return qaRunner.start(); }); assert.equal(await page.evaluate(() => qaClicks.length), 1); console.log('PASS topic association change cancels stale match and restart revalidates'); for (const field of ['revision', 'routeKey', 'sourceUrl']) { await setup([{ type: '单选' }], 'delayed'); await delayedStart(); await page.evaluate(field => { qaContext[field] = 'changed'; }, field); await finishMatch(); assert.equal(await page.evaluate(() => qaClicks.length), 0, field + ' 切换后不能点击旧题'); } console.log('PASS bank revision, route and source changes cancel delayed clicks'); await setup([{ type: '单选' }], 'title'); await page.evaluate(() => qaRunner.start()); assert.equal(await page.evaluate(() => qaClicks.length), 0); assert.equal(await page.evaluate(() => qaStates.at(-1)[0]), 'stopped'); await setup([{ type: '单选' }], 'delayed'); await delayedStart(); await page.evaluate(() => { location.hash = 'new-practice'; }); await finishMatch(); assert.equal(await page.evaluate(() => qaClicks.length), 0); console.log('PASS live title and location changes stop before parent observer updates'); await setup([{ type: '多选' }], 'delayed'); await delayedStart(); await page.locator('.pc_examtest_content_list_des').first().click(); await finishMatch(); assert.equal(await page.evaluate(() => qaClicks.length), 1, '只允许真人本次点击生效'); assert.equal(await page.evaluate(() => qaStates.at(-1)[0]), 'paused'); console.log('PASS trusted manual answering pauses automatic selection'); await setup([{ type: '单选' }]); await page.evaluate(() => { qaContext.local = true; return qaRunner.start(); }); 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 trusted manual submit pauses immediately; no browser errors'); } finally { if (browser) await browser.close(); await new Promise(resolve => server.close(resolve)); } } main().catch(error => { console.error(error); process.exitCode = 1; });