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

363 lines
24 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, total = 25 } = {}) {
await page.evaluate(({ html, matched, unknownPoint, confirm, showResult }) => {
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 };
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 = '<div role="dialog"><p>还有未答题,是否确认交卷?</p><button class="qa-cancel">取消</button><button class="qa-confirm">确认交卷</button></div>';
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; });