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

597 lines
41 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.
/* 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) => `<div app-exam-question><div class="pc_examtest_content" data-number="${i + 1}">
<div class="pc_examtest_content_question"><p>[单选] ${i + 1}. 安全核对题${i + 1}应该怎样操作?<span class="pc_examtest_content_question_value">(${100 / total}分)</span></p><div class="pc_examtest_content_question_difficulty">难度低</div></div>
<div class="pc_examtest_content_list"><div class="pc_examtest_content_list_des examtest_content_list_des_checked" data-label="A"><span class="examtest_content_list_des_optionsort">A</span>核对措施${i + 1}</div><div class="pc_examtest_content_list_des" data-label="B"><span class="examtest_content_list_des_optionsort">B</span>任意操作${i + 1}</div></div>
</div></div>`).join('');
return `<!doctype html><html><head><meta charset="utf-8"><style>body{font:14px system-ui;margin:12px}.pc_examtest_content{padding:3px}.pc_examtest_content_question p{margin:0}.pc_examtest_content_list_des{padding:2px;cursor:pointer}.pc_examtest_content_list_des_checked{background:#ddf2e3}.pc_examtest_commit{padding:12px;background:#ddd;cursor:pointer}.dialog_box{position:fixed;inset:20px auto auto 20px;background:white;border:1px solid;padding:12px}[aria-hidden="true"]{display:none!important}</style></head><body><app-examtest><div class="pc_examtest_title">安全核对考试</div>${questions}<div class="pc_examtest_countdown"><span class="time">00:30:00</span></div><div class="pc_examtest_card">${Array.from({ length: total }, (_, i) => `<div class="examtestcard_count_defult">${i + 1}</div>`).join('')}</div><button class="pc_examtest_commit">交卷</button></app-examtest></body></html>`;
}
(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, unansweredWarning = false, manualConfirmIncomplete, skipSingleAttemptQuiz, attemptPolicy = null, total = 25 } = {}) {
await page.evaluate(({ html, matched, unknownPoint, confirm, showResult, unansweredWarning, manualConfirmIncomplete, skipSingleAttemptQuiz, attemptPolicy }) => {
window.qaExam?.destroy(); window.qaRunner?.destroy();
document.documentElement.innerHTML = html.replace(/^<!doctype html>/, '').replace(/^<html>/, '').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, unansweredWarning };
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 (manualConfirmIncomplete !== undefined) qaSettings.manualConfirmIncomplete = manualConfirmIncomplete;
if (skipSingleAttemptQuiz !== undefined) qaSettings.skipSingleAttemptQuiz = skipSingleAttemptQuiz;
window.qaAttemptPolicy = attemptPolicy || { blocked: false, count: null, signature: 'unknown' };
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 = '<div role="dialog"><p>' + (qaModel.unansweredWarning ? '尚有 1 题未答,是否确认交卷?' : '是否确认交卷?') + '</p><button class="qa-cancel">取消</button><button class="qa-confirm">确认交卷</button></div>';
document.body.appendChild(dialog);
dialog.querySelector('.qa-cancel').addEventListener('click', () => dialog.remove());
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, getAttemptPolicy: () => qaAttemptPolicy,
onState: state => qaStates.push(state), onLog: (level, message) => qaLogs.push({ level, message })
});
window.qaExam = qaMakeController();
}, { html: paperHTML(total), matched, unknownPoint, confirm, showResult, unansweredWarning, manualConfirmIncomplete, skipSingleAttemptQuiz, attemptPolicy });
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);
}
const singleAttempt = { blocked: true, count: 1, reason: '答题机会仅 1 次,等待人工操作', signature: 'total-attempts:1' };
let guarded = await setup({ attemptPolicy: singleAttempt });
assert.equal(guarded.enabled, false, '单次机会守卫默认开启,在启动前拒绝自动计划');
assert.equal(guarded.singleAttemptBlocked, true);
assert.equal(guarded.attemptCount, 1);
assert.match(guarded.message, /1 次.*人工/);
let guardedResult = await tickAt(600);
assert.equal(guardedResult.model.commitClicks, 0, '全部题已核验且满 6 分钟也不能自动消耗唯一机会');
assert.equal(guardedResult.model.confirmClicks, 0);
assert.equal(guardedResult.state.requiresManualSubmission, true);
await page.evaluate(() => { qaSettings.skipSingleAttemptQuiz = false; });
assert.equal((await tickAt(601)).model.commitClicks, 0, '仅关闭开关不会重启此前已停止的计划');
assert.equal(await page.evaluate(() => qaExam.enable()), true, '关闭开关并明确启动可恢复原交卷条件');
assert.equal((await tickAt(602)).model.commitClicks, 1);
guarded = await setup({ skipSingleAttemptQuiz: false, attemptPolicy: singleAttempt, confirm: true, showResult: true });
assert.equal(guarded.enabled, true);
assert.equal(guarded.singleAttemptBlocked, false);
assert.equal((await tickAt(360)).model.commitClicks, 1);
assert.equal((await tickAt(361)).model.confirmClicks, 1);
assert.equal((await tickAt(362)).state.submitted, true, '关闭守卫后仍需网站结果确认成功');
guarded = await setup({ attemptPolicy: { blocked: false, count: null, signature: 'unknown' } });
assert.equal(guarded.enabled, true, '次数证据未知时不猜测为单次机会');
assert.equal((await tickAt(360)).model.commitClicks, 1);
await setup({ attemptPolicy: { blocked: false, count: 2, signature: 'total-attempts:2' } });
await page.evaluate(policy => { qaAttemptPolicy = policy; }, singleAttempt);
guardedResult = await tickAt(360);
assert.equal(guardedResult.model.commitClicks, 0, '运行中发现仅一次机会立即阻止旧自动交卷');
assert.equal(guardedResult.state.singleAttemptBlocked, true);
assert.equal(guardedResult.state.enabled, false);
await setup({ confirm: true, showResult: true });
await tickAt(360);
await page.evaluate(policy => { qaAttemptPolicy = policy; }, singleAttempt);
guardedResult = await tickAt(361);
assert.equal(guardedResult.model.confirmClicks, 0, '已弹交卷确认后出现单次机会也不能自动确认');
assert.equal(guardedResult.state.stage, 'unknown', '已发起交卷后停止自动确认保留结果待确认状态');
await page.locator('.qa-confirm').click();
guardedResult = await tickAt(362);
assert.equal(guardedResult.model.confirmClicks, 1, '用户仍可在原网页人工确认');
assert.equal(guardedResult.state.submitted, true, '守卫生效期间仍只读识别人工交卷的实际网站结果');
assert.equal(guardedResult.state.requiresManualSubmission, false);
console.log('PASS 单次答题机会守卫默认开启、关闭后明确恢复、未知次数不猜、运行变化及人工交卷结果观察');
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.equal(result.state.requiresManualSubmission, true);
assert.match(result.state.message, /人工|手动/);
initial = await setup({ matched: 21 });
assert.equal(initial.predictedScore, 84);
assert.equal(initial.verifiedCount, 21);
assert.equal(initial.missing, 4, '部分题目缺失仍可用已核验题目的实际分值计算');
assert.equal(initial.requiresManualSubmission, true);
assert.equal(initial.unverifiedCount, 4);
assert.equal(initial.stage, 'waiting', '题库缺题立即转为人工交卷等待');
result = await tickAt(240);
assert.equal(result.model.commitClicks, 0, '84 分但只满 4 分钟不得提交');
assert.equal(result.state.waitSeconds, 120);
assert.equal(result.state.stage, 'waiting');
result = await tickAt(360);
assert.equal(result.model.commitClicks, 0, '84 分且满 6 分钟,缺失 4 题仍须原网页人工交卷');
assert.equal(result.model.confirmClicks, 0);
assert.match(result.state.message, /人工|手动/);
await page.evaluate(async () => { qaExam.disable('用户暂停'); await qaExam.enable(); });
result = await tickAt(600);
assert.equal(result.model.commitClicks, 0, '重新启用不能作为部分缺题的人工确认或绕过');
assert.equal(result.model.confirmClicks, 0);
assert.equal(result.state.requiresManualSubmission, true);
const replenished = await page.evaluate(async () => {
qaSnapshot.records = QuestionBankCore.extract(document).questions.map(question => YianBankStore.validateRecord({
...question, role: '', course: '安全知识题库', sourceFile: '补全题库.json', answer: 'A', correctTexts: undefined
}));
qaSnapshot.revision++; qaContext.revision++;
const enabled = await qaExam.enable(); await qaExam.tick();
return { enabled, state: qaExam.getState(), model: qaModel };
});
assert.equal(replenished.enabled, true, '补全题库后仍须明确重新启用');
assert.equal(replenished.state.predictedScore, 100);
assert.equal(replenished.state.verifiedCount, 25);
assert.equal(replenished.state.requiresManualSubmission, false);
assert.equal(replenished.model.commitClicks, 1, '新 revision 全卷匹配核验后才恢复正常交卷');
console.log('PASS 80 分与 84 分缺题均等待原网页人工交卷,反复启用不能绕过;补全全卷并明确启用可恢复');
initial = await setup({ matched: 21, manualConfirmIncomplete: false, confirm: true, unansweredWarning: true, showResult: true });
assert.equal(initial.predictedScore, 84);
assert.equal(initial.requiresManualSubmission, false, '关闭缺题手动确认后恢复部分题自动交卷');
assert.equal(initial.stage, 'waitingSubmit');
result = await tickAt(240);
assert.equal(result.model.commitClicks, 0, '关闭开关后仍须满足 6 分钟');
result = await tickAt(360);
assert.equal(result.model.commitClicks, 1, '关闭开关后 84 分且满 6 分钟允许交卷');
result = await tickAt(361);
assert.equal(result.model.confirmClicks, 1, '关闭开关后正常未答提醒可按旧规则确认');
result = await tickAt(362);
assert.equal(result.state.submitted, true);
await setup({ matched: 20, manualConfirmIncomplete: false });
result = await tickAt(600);
assert.equal(result.state.predictedScore, 80);
assert.equal(result.state.stage, 'waiting');
assert.equal(result.model.commitClicks, 0, '关闭开关也必须严格大于 80 分');
assert.equal(result.model.confirmClicks, 0);
await setup({ matched: 22, manualConfirmIncomplete: false });
await page.evaluate(() => document.querySelector('.pc_examtest_content_list_des').classList.remove('examtest_content_list_des_checked'));
result = await tickAt(600);
assert.equal(result.state.verifiedCount, 21);
assert.equal(result.state.predictedScore, 84, '高于 80 分也不能跳过其余已匹配题的核验');
assert.equal(result.model.commitClicks, 0, '关闭开关仍不能跳过可匹配题的答案全文回读');
console.log('PASS 缺题手动确认默认开启;关闭后部分 84 分可交卷和确认,80 分或可匹配题未核验仍禁止');
initial = await setup();
assert.equal(initial.predictedScore, 100);
assert.equal(initial.requiresManualSubmission, false);
result = await tickAt(240);
assert.equal(result.model.commitClicks, 0, '全卷 100 分但只满 4 分钟不得提交');
assert.equal(result.state.waitSeconds, 120);
assert.equal(result.state.stage, 'waitingSubmit');
result = await tickAt(360);
assert.equal(result.model.commitClicks, 1, '全卷核验 100 分且满 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 全卷覆盖后的 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();
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, 24);
assert.equal(result.state.predictedScore, 96);
assert.equal(result.state.requiresManualSubmission, true);
assert.equal(result.state.unverifiedCount, 1);
assert.equal(result.state.stage, 'waiting');
assert.equal(result.model.commitClicks, 0, '匹配记录存在但实际选中内容未回读核验时不计分');
assert.equal(result.model.confirmClicks, 0);
console.log('PASS 分值未知与实际选中证据变化均阻止自动交卷');
for (const incomplete of ['conflict', 'unsupported']) {
await setup();
const state = await page.evaluate(async incomplete => {
if (incomplete === 'conflict') {
qaSnapshot.records.push(YianBankStore.validateRecord({ ...qaSnapshot.records[0], id: undefined, answer: 'B', correctTexts: undefined }));
} else {
document.querySelectorAll('.examtest_content_list_des_optionsort')[1].textContent = 'A';
}
qaSnapshot.revision++; qaContext.revision++;
await qaExam.enable(); qaSetClock(360000); await qaExam.tick();
return { state: qaExam.getState(), model: qaModel };
}, incomplete);
assert.equal(state.state.predictedScore, 96, incomplete + ' 的题目不计入预计得分');
assert.equal(state.state.requiresManualSubmission, true, incomplete + ' 也需要人工交卷');
assert.equal(state.state.unverifiedCount, 1);
assert.equal(state.state.stage, 'waiting');
assert.equal(state.model.commitClicks, 0);
assert.equal(state.model.confirmClicks, 0);
assert.ok(state.state.details.some(item => item.status === incomplete));
}
for (const manualConfirmIncomplete of [true, false]) for (const incomplete of ['missingCard', 'missingCardEntry', 'missingQuestion']) {
await setup({ manualConfirmIncomplete });
const blocked = await page.evaluate(async incomplete => {
if (incomplete === 'missingCard') document.querySelector('.pc_examtest_card').remove();
if (incomplete === 'missingCardEntry') document.querySelector('.examtestcard_count_defult').remove();
if (incomplete === 'missingQuestion') document.querySelector('[app-exam-question]').remove();
const enabled = await qaExam.enable(); qaSetClock(360000); await qaExam.tick();
return { enabled, state: qaExam.getState(), model: qaModel };
}, incomplete);
assert.equal(blocked.enabled, false, incomplete + ' 在开关为 ' + manualConfirmIncomplete + ' 时不允许将部分 DOM 当作完整试卷启动');
assert.equal(blocked.state.complete, false);
assert.equal(blocked.state.scoreValid, false);
assert.equal(blocked.model.commitClicks, 0);
assert.equal(blocked.model.confirmClicks, 0);
}
console.log('PASS 冲突、不支持、未全文核验及答题卡/题目 DOM 不完整均不能自动交卷');
await setup();
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();
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({ 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({ confirm: true, unansweredWarning: true, showResult: true });
result = await tickAt(360);
assert.equal(result.model.commitClicks, 1, '本地完整核验后可点击初始交卷入口');
result = await tickAt(361);
assert.equal(result.model.confirmClicks, 0, '网站明确提示未答题时必须让用户人工确认');
assert.equal(result.state.requiresManualSubmission, true);
assert.equal(result.state.stage, 'waiting');
assert.equal(result.state.enabled, false);
assert.match(result.state.message, /人工|手动/);
assert.equal(await page.evaluate(() => qaExam.enable()), false, '网站未答题提醒不得通过重新启动绕过');
assert.equal((await tickAt(600)).model.confirmClicks, 0);
const restoredManualWarning = await page.evaluate(async () => {
qaExam.destroy(); qaExam = qaMakeController();
await qaExam.observe(); const enabled = await qaExam.enable(); await qaExam.tick();
return { enabled, state: qaExam.getState(), model: qaModel };
});
assert.equal(restoredManualWarning.enabled, false, '网站未答提醒在 controller 重建后仍禁止自动确认');
assert.equal(restoredManualWarning.state.requiresManualSubmission, true);
assert.equal(restoredManualWarning.state.stage, 'waiting');
assert.equal(restoredManualWarning.model.commitClicks, 1);
assert.equal(restoredManualWarning.model.confirmClicks, 0);
await page.locator('.qa-confirm').click();
result = await tickAt(601);
assert.equal(result.model.confirmClicks, 1, '用户仍能在网站弹窗完成真实人工确认');
assert.equal(result.state.submitted, true);
console.log('PASS 网站未答题提醒优先于本地全卷核验,自动确认停止且只能原网页人工确认');
await setup({ matched: 25, confirm: true, unansweredWarning: true, showResult: true });
await tickAt(360); await tickAt(361);
const unlockedWarning = await page.evaluate(async () => {
qaExam.destroy(); qaExam = qaMakeController(); await qaExam.observe();
const before = await qaExam.enable();
qaSettings.manualConfirmIncomplete = false;
qaExam.disable('设置已变更,请明确继续');
await qaExam.tick(); const blocked = qaModel.confirmClicks;
const withDialog = await qaExam.enable(); await qaExam.tick();
return { before, blocked, withDialog, state: qaExam.getState(), model: qaModel };
});
assert.equal(unlockedWarning.before, false, '已持久化的网站未答提醒在默认开启时仍阻止自动确认');
assert.equal(unlockedWarning.blocked, 0, '切换开关本身不会自动点击确认');
assert.equal(unlockedWarning.withDialog, false, '旧网站弹窗仍打开时拒绝重新启用或接管');
assert.equal(unlockedWarning.model.confirmClicks, 0);
assert.equal(unlockedWarning.model.commitClicks, 1, '旧弹窗仍在时不能重复点击初始交卷入口');
await page.locator('.qa-cancel').click();
const newPlan = await page.evaluate(async () => { const enabled = await qaExam.enable(); await qaExam.tick(); return { enabled, model: qaModel }; });
assert.equal(newPlan.enabled, true, '用户先取消原网页弹窗再明确继续可按关闭后的策略重新核验');
assert.equal(newPlan.model.commitClicks, 2, '新明确启动的计划可重新发起一次交卷');
assert.equal(newPlan.model.confirmClicks, 0);
result = await tickAt(362);
assert.equal(result.model.confirmClicks, 1);
assert.equal((await tickAt(363)).state.submitted, true);
console.log('PASS 网站提醒锁定恢复后,关闭开关→取消旧弹窗→明确继续,才重新核验并发起新交卷');
await setup();
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();
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', 'manualConfirmIncomplete', 'singleAttempt', 'attemptEvidence', 'skipSingleAttemptQuiz']) {
await setup();
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('用户已暂停');
if (mutation === 'manualConfirmIncomplete') qaSettings.manualConfirmIncomplete = false;
if (mutation === 'singleAttempt') qaAttemptPolicy = { blocked: true, count: 1, reason: '仅一次机会,等待人工', signature: 'total-attempts:1' };
if (mutation === 'attemptEvidence') qaAttemptPolicy = { blocked: false, count: 2, signature: 'total-attempts:2' };
if (mutation === 'skipSingleAttemptQuiz') qaSettings.skipSingleAttemptQuiz = false;
};
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', 'manualConfirmIncomplete', 'singleAttempt', 'attemptEvidence', 'skipSingleAttemptQuiz']) {
await setup({ 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('用户已暂停');
if (mutation === 'manualConfirmIncomplete') qaSettings.manualConfirmIncomplete = false;
if (mutation === 'singleAttempt') qaAttemptPolicy = { blocked: true, count: 1, reason: '仅一次机会,等待人工', signature: 'total-attempts:1' };
if (mutation === 'attemptEvidence') qaAttemptPolicy = { blocked: false, count: 2, signature: 'total-attempts:2' };
if (mutation === 'skipSingleAttemptQuiz') qaSettings.skipSingleAttemptQuiz = false;
};
await qaExam.tick(); return { model: qaModel, state: qaExam.getState() };
}, mutation);
assert.equal(blocked.model.confirmClicks, 0, '二次确认保存期间发生 ' + mutation + ' 时禁止旧确认点击');
assert.equal(blocked.state.enabled, false);
}
for (const persistedStage of ['submitting', 'confirming']) {
await setup({ matched: 21, manualConfirmIncomplete: false, confirm: true });
if (persistedStage === 'confirming') await tickAt(360);
const blocked = await page.evaluate(async persistedStage => {
qaSetClock(360000);
qaStorageHook = async (_key, value) => { if (value.stage === persistedStage) qaSettings.manualConfirmIncomplete = true; };
await qaExam.tick(); return { state: qaExam.getState(), model: qaModel };
}, persistedStage);
assert.equal(blocked.model.commitClicks, persistedStage === 'confirming' ? 1 : 0, '保存期间重新开启缺题手动确认,禁止继续原部分题交卷计划');
assert.equal(blocked.model.confirmClicks, 0, '保存期间重新开启缺题手动确认,禁止自动确认');
assert.equal(blocked.state.enabled, false);
}
console.log('PASS 最后点击前复核:异步保存期间页面、题库、选中集合、主题、暂停及确认入口变化均阻止旧动作');
await setup();
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();
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);
await setup();
const changedWhileEnabling = await page.evaluate(async () => {
qaStorageHook = async (_key, value) => {
if (value.stage === 'answering') qaAttemptPolicy = { blocked: true, count: 1, reason: '仅一次机会,等待人工', signature: 'total-attempts:1' };
};
const returned = await qaExam.enable(); await qaExam.tick();
return { returned, state: qaExam.getState(), model: qaModel };
});
assert.equal(changedWhileEnabling.returned, false, '异步启动保存期间出现单次证据不能返回启动成功');
assert.equal(changedWhileEnabling.state.enabled, false);
assert.equal(changedWhileEnabling.state.singleAttemptBlocked, true);
assert.equal(changedWhileEnabling.model.commitClicks, 0);
assert.equal(changedWhileEnabling.model.confirmClicks, 0);
console.log('PASS observe 共享异步读取及 enable 在读写期间被暂停后不复活');
await setup();
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();
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();
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();
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();
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; });