/* Real DOM exam evidence and submit-state tests. No business scripts or requests. */ 'use strict'; const assert = require('node:assert/strict'); const fs = require('node:fs'); const path = require('node:path'); const { chromium } = require('playwright'); const rootDir = path.resolve(__dirname, '..'); const modules = ['question-bank-core.js', 'bank-store.js', 'answer-matcher.js', 'answer-runner.js', 'exam-controller.js']; function paperHTML(total = 25) { const questions = Array.from({ length: total }, (_, i) => `

[单选] ${i + 1}. 安全核对题${i + 1}应该怎样操作?(${100 / total}分)

难度低
A核对措施${i + 1}
B任意操作${i + 1}
`).join(''); return `
安全核对考试
${questions}
00:30:00
${Array.from({ length: total }, (_, i) => `
${i + 1}
`).join('')}
`; } (async () => { const browser = await chromium.launch({ channel: process.env.PLAYWRIGHT_CHANNEL || 'chrome', headless: true }); const errors = [], requests = []; try { const context = await browser.newContext({ viewport: { width: 1000, height: 800 } }); await context.route('**/*', route => { requests.push(route.request().url()); return route.abort(); }); const page = await context.newPage(); page.on('pageerror', error => errors.push(error.message)); for (const name of modules) await page.addScriptTag({ content: fs.readFileSync(path.join(rootDir, name), 'utf8') }); async function setup({ matched = 25, unknownPoint = false, confirm = false, showResult = false, total = 25 } = {}) { await page.evaluate(({ html, matched, unknownPoint, confirm, showResult }) => { window.qaExam?.destroy(); window.qaRunner?.destroy(); document.documentElement.innerHTML = html.replace(/^/, '').replace(/^/, '').replace(/<\/html>$/, ''); window.qaClock = { now: 0 }; window.qaSetClock = value => { qaClock.now = value; const remaining = Math.max(0, 1800 - Math.floor(value / 1000)); document.querySelector('.pc_examtest_countdown .time').textContent = [Math.floor(remaining / 3600), Math.floor(remaining / 60) % 60, remaining % 60].map(part => String(part).padStart(2, '0')).join(':'); }; window.qaStorage = new Map(); window.qaLogs = []; window.qaStates = []; window.qaStorageHook = null; window.qaReadGate = null; window.qaStorageReads = 0; window.qaModel = { commitClicks: 0, confirmClicks: 0, optionClicks: [], confirm, showResult }; window.qaContext = { mode: 'test', exam: true, practice: false, ready: true, local: false, sourceUrl: 'http://127.0.0.1/clientcn/exam/qa-paper?flag=test', course: '安全核对考试', revision: 1, root: document.querySelector('app-examtest'), routeKey: 'qa-paper', pageSignature: 'qa-stable', topicKey: 'qa-theme' }; window.qaTopic = { status: 'matched', courses: ['安全知识题库'] }; const extracted = QuestionBankCore.extract(document); window.qaSnapshot = { revision: 1, records: extracted.questions.slice(0, matched).map(question => YianBankStore.validateRecord({ ...question, role: '', course: '安全知识题库', sourceFile: '测试题库.json', answer: 'A', correctTexts: undefined })) }; window.qaRunnerState = 'complete'; window.qaSettings = { minSubmitMinutes: 6 }; if (unknownPoint) document.querySelector('.pc_examtest_content_question_value').textContent = '分值待定'; const boxes = Array.from(document.querySelectorAll('.pc_examtest_content')); boxes.forEach((box, index) => box.querySelectorAll('.pc_examtest_content_list_des').forEach(node => node.addEventListener('click', () => { 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'); qaModel.optionClicks.push({ number: index + 1, label: node.dataset.label }); }))); document.querySelector('.pc_examtest_commit').addEventListener('click', () => { qaModel.commitClicks++; if (!qaModel.confirm) return; const dialog = document.createElement('div'); dialog.className = 'dialog_box'; dialog.innerHTML = '

还有未答题,是否确认交卷?

'; document.body.appendChild(dialog); dialog.querySelector('.qa-confirm').addEventListener('click', () => { qaModel.confirmClicks++; dialog.remove(); if (qaModel.showResult) { const result = document.createElement('div'); result.className = 'exam_submited_box'; result.textContent = '交卷成功,考试成绩:84分'; document.body.appendChild(result); } }); }); window.qaMakeController = () => YianExamController.create({ document, now: () => qaClock.now, storage: { get: async (key, fallback) => { qaStorageReads++; if (qaReadGate) await qaReadGate; return qaStorage.has(key) ? structuredClone(qaStorage.get(key)) : fallback; }, set: async (key, value) => { if (qaStorageHook) await qaStorageHook(key, value); qaStorage.set(key, structuredClone(value)); } }, getContext: () => qaContext, getTopic: () => qaTopic, getSnapshot: () => qaSnapshot, getSettings: () => qaSettings, getRunnerState: () => qaRunnerState, onState: state => qaStates.push(state), onLog: (level, message) => qaLogs.push({ level, message }) }); window.qaExam = qaMakeController(); }, { html: paperHTML(total), matched, unknownPoint, confirm, showResult }); return page.evaluate(async () => { await qaExam.observe(); const enabled = await qaExam.enable(); await qaExam.tick(); return { enabled, ...qaExam.getState() }; }); } async function tickAt(seconds) { return page.evaluate(async value => { qaSetClock(value * 1000); await qaExam.tick(); return { state: qaExam.getState(), model: qaModel }; }, seconds); } let initial = await setup({ matched: 20 }); assert.equal(initial.total, 25); assert.equal(initial.totalScore, 100, '按实际 DOM 分值计算 25×4=100'); assert.equal(initial.scoreValid, true); assert.equal(initial.predictedScore, 80); assert.equal(initial.matchedCount, 20); assert.equal(initial.missing, 5); assert.equal(initial.waitSeconds, 360, 'now 从 0 开始且无 totaltime 参数可记录首次入卷'); let result = await tickAt(360); assert.equal(result.model.commitClicks, 0, '正好 80 分不得自动交卷'); assert.equal(result.state.stage, 'waiting'); assert.match(result.state.message, /小于或等于 80/); initial = await setup({ matched: 21 }); assert.equal(initial.predictedScore, 84); assert.equal(initial.verifiedCount, 21); assert.equal(initial.missing, 4, '部分题目缺失仍可用已核验题目的实际分值计算'); result = await tickAt(240); assert.equal(result.model.commitClicks, 0, '84 分但只满 4 分钟不得提交'); assert.equal(result.state.waitSeconds, 120); assert.equal(result.state.stage, 'waitingSubmit'); result = await tickAt(360); assert.equal(result.model.commitClicks, 1, '84 分且满 6 分钟点击一次真实交卷入口'); await tickAt(365); assert.equal((await tickAt(372)).model.commitClicks, 1, '提交后等待结果时不得重复点击'); result = await tickAt(373); assert.equal(result.state.stage, 'unknown'); assert.equal(result.state.enabled, false); assert.equal(result.model.commitClicks, 1); assert.equal((await tickAt(600)).model.commitClicks, 1, '超过 12 秒未确认后仍禁止重复交卷'); assert.equal(await page.evaluate(() => qaExam.enable()), false, '未知提交状态不得通过重复启动绕过'); console.log('PASS 实际分值与部分命中、80 分等待、84 分 4/6 分钟门槛、未知结果有界停止并禁止重复提交'); initial = await setup({ matched: 25, unknownPoint: true }); assert.equal(initial.scoreValid, false); assert.match(initial.reason, /分值/); assert.equal((await tickAt(600)).model.commitClicks, 0, '分值不明,即使命中全部题目也不得提交'); await setup({ matched: 21 }); await page.evaluate(() => { document.querySelector('.pc_examtest_content_list_des').classList.remove('examtest_content_list_des_checked'); }); result = await tickAt(360); assert.equal(result.state.verifiedCount, 20); assert.equal(result.state.predictedScore, 80); assert.equal(result.model.commitClicks, 0, '匹配记录存在但实际选中内容未回读核验时不计分'); console.log('PASS 分值未知与实际选中证据变化均阻止自动交卷'); await setup({ matched: 21 }); await page.locator('.pc_examtest_content').first().locator('[data-label="B"]').click(); result = await tickAt(360); assert.equal(result.state.enabled, false); assert.equal(result.state.stage, 'paused'); assert.match(result.state.message, /人工/); assert.equal(result.model.commitClicks, 0, '可信人工点击立即取消交卷计划'); await setup({ matched: 21 }); await page.evaluate(() => { qaContext.revision = 2; qaSnapshot.revision = 2; }); result = await tickAt(360); assert.equal(result.state.enabled, false); assert.equal(result.model.commitClicks, 0, '题库 revision 更新使旧授权失效'); assert.match(result.state.message, /题库|变更/); console.log('PASS 人工作答取消与题库 revision 变化取消旧交卷授权'); await setup({ matched: 21, confirm: true, showResult: true }); result = await tickAt(360); assert.equal(result.model.commitClicks, 1); assert.equal(result.model.confirmClicks, 0); result = await tickAt(361); assert.equal(result.model.confirmClicks, 1, '单个真实确认按钮只点击一次'); result = await tickAt(362); assert.equal(result.state.stage, 'submitted'); assert.equal(result.state.submitted, true, '只有网站结果 DOM 才确认提交成功'); assert.equal(result.state.enabled, false); assert.equal((await tickAt(363)).model.confirmClicks, 1); console.log('PASS 交卷确认弹窗及网站成功结果,不从点击动作推断成功'); await setup({ matched: 21 }); await tickAt(240); const restored = await page.evaluate(async () => { qaExam.destroy(); qaExam = qaMakeController(); await qaExam.observe(); await qaExam.tick(); return qaExam.getState(); }); assert.equal(restored.elapsedSeconds, 240); assert.equal(restored.waitSeconds, 120, '同场重建 controller 复用原始 startedAt=0,计时不归零'); assert.equal(restored.enabled, false, '刷新恢复仍需明确启动'); await page.evaluate(() => qaExam.enable()); assert.equal((await tickAt(360)).model.commitClicks, 1, '同场恢复后仍按原始入卷 6 分钟交卷'); await setup({ matched: 21 }); await page.evaluate(() => { qaExam.destroy(); qaStorage.clear(); qaClock.now = 240000; qaContext.sourceUrl += '&totaltime=30'; document.querySelector('.pc_examtest_countdown .time').textContent = '00:26:00'; qaExam = qaMakeController(); }); const inferred = await page.evaluate(async () => { await qaExam.observe(); await qaExam.tick(); return qaExam.getState(); }); assert.equal(inferred.elapsedSeconds, 240, '首次注入可由明确考试时长和剩余时间反推入卷时间'); assert.equal(inferred.waitSeconds, 120); console.log('PASS 原始入卷时间持久化、0 时间值和明确倒计时反推'); for (const mutation of ['ready', 'route', 'revision', 'answers', 'points', 'topic', 'pause']) { await setup({ matched: 21 }); const blocked = await page.evaluate(async mutation => { qaSetClock(360000); qaStorageHook = async (_key, value) => { if (value.stage !== 'submitting') return; if (mutation === 'ready') qaContext.ready = false; if (mutation === 'route') qaContext.sourceUrl = 'http://127.0.0.1/clientcn/exam/another?flag=test'; if (mutation === 'revision') { qaContext.revision++; qaSnapshot.revision++; } if (mutation === 'answers') document.querySelector('.pc_examtest_content_list_des').classList.remove('examtest_content_list_des_checked'); if (mutation === 'points') { const nodes = document.querySelectorAll('.pc_examtest_content_question_value'); nodes[0].textContent = '(6分)'; nodes[21].textContent = '(2分)'; } if (mutation === 'topic') qaTopic = { status: 'matched', courses: ['其他主题'] }; if (mutation === 'pause') qaExam.disable('用户已暂停'); }; await qaExam.tick(); return { model: qaModel, state: qaExam.getState() }; }, mutation); assert.equal(blocked.model.commitClicks, 0, '异步保存期间发生 ' + mutation + ' 时禁止旧提交点击'); assert.equal(blocked.state.enabled, false); } for (const mutation of ['revision', 'duplicateButton', 'pause']) { await setup({ matched: 21, confirm: true }); await tickAt(360); const blocked = await page.evaluate(async mutation => { qaStorageHook = async (_key, value) => { if (value.stage !== 'confirming') return; if (mutation === 'revision') { qaContext.revision++; qaSnapshot.revision++; } if (mutation === 'duplicateButton') { const button = document.createElement('button'); button.textContent = '确认交卷'; document.querySelector('.dialog_box').append(button); } if (mutation === 'pause') qaExam.disable('用户已暂停'); }; await qaExam.tick(); return { model: qaModel, state: qaExam.getState() }; }, mutation); assert.equal(blocked.model.confirmClicks, 0, '二次确认保存期间发生 ' + mutation + ' 时禁止旧确认点击'); assert.equal(blocked.state.enabled, false); } console.log('PASS 最后点击前复核:异步保存期间页面、题库、选中集合、主题、暂停及确认入口变化均阻止旧动作'); await setup({ matched: 21 }); const interruptedRead = await page.evaluate(async () => { qaExam.destroy(); qaStorageReads = 0; let release; qaReadGate = new Promise(resolve => { release = resolve; }); qaExam = qaMakeController(); const first = qaExam.observe(), second = qaExam.observe(); const shared = first === second; const enabling = qaExam.enable(); qaExam.disable('用户暂停'); release(); const returned = await enabling; await Promise.all([first, second]); qaReadGate = null; return { returned, shared, reads: qaStorageReads, state: qaExam.getState() }; }); assert.equal(interruptedRead.shared, true, '同场并行 observe 共用一个进行中的 Promise'); assert.equal(interruptedRead.reads, 1); assert.equal(interruptedRead.returned, false); assert.equal(interruptedRead.state.enabled, false, 'storage.get 返回后不得重新启用已暂停的考试'); await setup({ matched: 21 }); const interruptedWrite = await page.evaluate(async () => { let release; const gate = new Promise(resolve => { release = resolve; }); qaStorageHook = async (_key, value) => { if (value.stage === 'answering') { qaExam.disable('用户停止'); await gate; } }; const enabling = qaExam.enable(); release(); return { returned: await enabling, state: qaExam.getState() }; }); assert.equal(interruptedWrite.returned, false); assert.equal(interruptedWrite.state.enabled, false); console.log('PASS observe 共享异步读取及 enable 在读写期间被暂停后不复活'); await setup({ matched: 21 }); const retake = await page.evaluate(async () => { qaExam.destroy(); const key = Array.from(qaStorage.keys())[0], saved = qaStorage.get(key); qaStorage.set(key, { ...saved, startedAt: -1200000, stage: 'submitted' }); qaClock.now = 60000; qaContext.sourceUrl += '&totaltime=30'; document.querySelector('.pc_examtest_countdown .time').textContent = '00:29:00'; qaExam = qaMakeController(); await qaExam.observe(); await qaExam.tick(); const before = qaExam.getState(); const accepted = await qaExam.enable(); await qaExam.tick(); return { before, accepted, after: qaExam.getState(), model: qaModel }; }); assert.equal(retake.before.submitted, false, '缓存已交卷不能当作当前场次网站成功'); assert.equal(retake.before.stage, 'awaitingAttempt'); assert.equal(retake.accepted, true, '明确启动可为当前可作答试卷建立新场次'); assert.equal(retake.after.elapsedSeconds, 60, '当前30分钟/剩29分钟证据优先,禁止沿用上一场20分钟前的时间'); assert.equal(retake.after.waitSeconds, 300); assert.equal(retake.model.commitClicks, 0); assert.equal((await tickAt(360)).model.commitClicks, 1); await setup({ matched: 21 }); const missingIdentity = await page.evaluate(async () => { qaExam.destroy(); const key = Array.from(qaStorage.keys())[0], saved = qaStorage.get(key); qaStorage.set(key, { ...saved, startedAt: -1200000, stage: 'submitted' }); qaClock.now = 1000000; qaExam = qaMakeController(); await qaExam.observe(); const accepted = await qaExam.enable(); await qaExam.tick(); return { accepted, state: qaExam.getState(), model: qaModel }; }); assert.equal(missingIdentity.accepted, true); assert.equal(missingIdentity.state.elapsedSeconds, 0, '没有明确时长时,新场次从本次明确启动时开始计时'); assert.equal(missingIdentity.state.waitSeconds, 360); assert.equal(missingIdentity.model.commitClicks, 0); await setup({ matched: 21 }); const equalCountdownRetake = await page.evaluate(async () => { qaExam.destroy(); qaClock.now = 360000; // Both old checkpoint and new attempt start at 1800. Compare elapsed wall time, // not merely whether the new raw value exceeds the old raw value. document.querySelector('.pc_examtest_countdown .time').textContent = '00:30:00'; qaExam = qaMakeController(); await qaExam.observe(); await qaExam.tick(); const before = qaExam.getState(); const accepted = await qaExam.enable(); await qaExam.tick(); return { before, accepted, after: qaExam.getState(), model: qaModel }; }); assert.equal(equalCountdownRetake.before.stage, 'awaitingAttempt'); assert.equal(equalCountdownRetake.accepted, true); assert.equal(equalCountdownRetake.after.elapsedSeconds, 0, '旧1800秒检查点六分钟后看到新1800秒,不能算本场已过六分钟'); assert.equal(equalCountdownRetake.after.waitSeconds, 360); assert.equal(equalCountdownRetake.model.commitClicks, 0); await setup({ matched: 21 }); const noClockEvidence = await page.evaluate(async () => { qaExam.destroy(); qaClock.now = 360000; document.querySelector('.pc_examtest_countdown').remove(); qaExam = qaMakeController(); await qaExam.observe(); await qaExam.tick(); const before = qaExam.getState(); const accepted = await qaExam.enable(); await qaExam.tick(); return { before, accepted, after: qaExam.getState(), model: qaModel }; }); assert.equal(noClockEvidence.before.stage, 'awaitingAttempt', '没有倒计时或真实场次ID,不能默复用旧时长'); assert.match(noClockEvidence.before.message, /计时连续性|新场次/); assert.equal(noClockEvidence.accepted, true); assert.equal(noClockEvidence.after.elapsedSeconds, 0); assert.equal(noClockEvidence.after.waitSeconds, 360); assert.equal(noClockEvidence.model.commitClicks, 0); await setup({ matched: 21 }); const pendingAttempt = await page.evaluate(async () => { qaExam.destroy(); const key = Array.from(qaStorage.keys())[0], saved = qaStorage.get(key); qaStorage.set(key, { ...saved, stage: 'submitting', submitAt: 0 }); qaExam = qaMakeController(); await qaExam.observe(); const blocked = await qaExam.enable(); const stage = qaExam.getState().stage; qaContext.sourceUrl += '&attemptId=second-real-attempt'; const fresh = await qaExam.enable(); await qaExam.tick(); const queryIsolated = YianExamController.paperKey(qaContext.sourceUrl) !== YianExamController.paperKey(qaContext.sourceUrl.replace('second-real-attempt', 'third-real-attempt')); const first = YianExamController.paperKey(qaContext.sourceUrl, qaContext.root); qaContext.root.setAttribute('data-attempt-id', 'server-dom-attempt'); const domIsolated = first !== YianExamController.paperKey(qaContext.sourceUrl, qaContext.root); return { blocked, stage, fresh, queryIsolated, domIsolated, model: qaModel }; }); assert.equal(pendingAttempt.blocked, false, '未知提交状态禁止按无ID重考自动复用'); assert.equal(pendingAttempt.stage, 'unknown'); assert.equal(pendingAttempt.fresh, true, '真实新 attemptId 使用独立场次'); assert.equal(pendingAttempt.queryIsolated, true); assert.equal(pendingAttempt.domIsolated, true); assert.equal(pendingAttempt.model.commitClicks, 0); console.log('PASS 重考与旧场次隔离:缓存 submitted 不算当前完成、倒计时连续性/无时钟须重确认、未知提交禁止复用及 query/DOM 场次标识'); await setup({ total: 3, matched: 3 }); const interval = await page.evaluate(async () => { qaExam.destroy(); document.querySelectorAll('.pc_examtest_content_list_des').forEach(node => node.classList.remove('examtest_content_list_des_checked')); const nativeSetTimeout = window.setTimeout; const timers = []; window.setTimeout = (callback, delay, ...args) => { timers.push(delay); return nativeSetTimeout(callback, 0, ...args); }; let runnerState = 'idle'; qaRunner = YianAnswerRunner.create({ getContext: () => qaContext, match: question => YianAnswerMatcher.match(question, { courses: qaTopic.courses }, qaSnapshot), getInterval: () => 4, onState: state => { runnerState = state; } }); try { await qaRunner.start(); return { timers, clicks: qaModel.optionClicks.length, state: runnerState }; } finally { window.setTimeout = nativeSetTimeout; qaRunner.destroy(); } }); assert.equal(interval.state, 'complete'); assert.equal(interval.clicks, 3, 'runner 通过真实 DOM 点击完成三题'); assert.equal(interval.timers.filter(delay => delay === 4000).length, 2, 'getInterval=4 按相邻题目设置两个 4000ms 间隔'); assert.equal(requests.length, 0, '整组测试不得请求业务站或其他网络资源'); assert.deepEqual(errors, [], '测试过程无浏览器脚本错误'); console.log('PASS 真实 runner 点击与默认 4 秒题间隔;全部测试零网络请求'); } finally { await browser.close(); } })().catch(error => { console.error(error); process.exitCode = 1; });