feat: add manual confirmation toggle for incomplete exams

This commit is contained in:
薇星幻海
2026-10-18 01:43:23 +08:00
parent eb9c610b9f
commit 2e044655bb
14 changed files with 758 additions and 201 deletions
+199 -28
View File
@@ -25,8 +25,8 @@ function paperHTML(total = 25) {
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 }) => {
async function setup({ matched = 25, unknownPoint = false, confirm = false, showResult = false, unansweredWarning = false, manualConfirmIncomplete, total = 25 } = {}) {
await page.evaluate(({ html, matched, unknownPoint, confirm, showResult, unansweredWarning, manualConfirmIncomplete }) => {
window.qaExam?.destroy(); window.qaRunner?.destroy();
document.documentElement.innerHTML = html.replace(/^<!doctype html>/, '').replace(/^<html>/, '').replace(/<\/html>$/, '');
window.qaClock = { now: 0 };
@@ -36,7 +36,7 @@ function paperHTML(total = 25) {
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.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' };
@@ -47,6 +47,7 @@ function paperHTML(total = 25) {
})) };
window.qaRunnerState = 'complete';
window.qaSettings = { minSubmitMinutes: 6 };
if (manualConfirmIncomplete !== undefined) qaSettings.manualConfirmIncomplete = manualConfirmIncomplete;
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', () => {
@@ -58,8 +59,9 @@ function paperHTML(total = 25) {
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>';
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();
@@ -77,7 +79,7 @@ function paperHTML(total = 25) {
onState: state => qaStates.push(state), onLog: (level, message) => qaLogs.push({ level, message })
});
window.qaExam = qaMakeController();
}, { html: paperHTML(total), matched, unknownPoint, confirm, showResult });
}, { html: paperHTML(total), matched, unknownPoint, confirm, showResult, unansweredWarning, manualConfirmIncomplete });
return page.evaluate(async () => { await qaExam.observe(); const enabled = await qaExam.enable(); await qaExam.tick(); return { enabled, ...qaExam.getState() }; });
}
async function tickAt(seconds) {
@@ -95,18 +97,79 @@ function paperHTML(total = 25) {
let result = await tickAt(360);
assert.equal(result.model.commitClicks, 0, '正好 80 分不得自动交卷');
assert.equal(result.state.stage, 'waiting');
assert.match(result.state.message, /小于或等于 80/);
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, '84 分且满 6 分钟点击一次真实交卷入口');
assert.equal(result.model.commitClicks, 1, '全卷核验 100 分且满 6 分钟点击一次真实交卷入口');
await tickAt(365);
assert.equal((await tickAt(372)).model.commitClicks, 1, '提交后等待结果时不得重复点击');
result = await tickAt(373);
@@ -115,28 +178,69 @@ function paperHTML(total = 25) {
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 分钟门槛、未知结果有界停止并禁止重复提交');
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({ matched: 21 });
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, 20);
assert.equal(result.state.predictedScore, 80);
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 分值未知与实际选中证据变化均阻止自动交卷');
await setup({ matched: 21 });
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.match(result.state.message, /人工|手动/);
assert.equal(result.model.commitClicks, 0, '可信人工点击立即取消交卷计划');
await setup({ matched: 21 });
await setup();
await page.evaluate(() => { qaContext.revision = 2; qaSnapshot.revision = 2; });
result = await tickAt(360);
assert.equal(result.state.enabled, false);
@@ -144,7 +248,7 @@ function paperHTML(total = 25) {
assert.match(result.state.message, /题库|变更/);
console.log('PASS 人工作答取消与题库 revision 变化取消旧交卷授权');
await setup({ matched: 21, confirm: true, showResult: true });
await setup({ confirm: true, showResult: true });
result = await tickAt(360);
assert.equal(result.model.commitClicks, 1);
assert.equal(result.model.confirmClicks, 0);
@@ -157,7 +261,60 @@ function paperHTML(total = 25) {
assert.equal((await tickAt(363)).model.confirmClicks, 1);
console.log('PASS 交卷确认弹窗及网站成功结果,不从点击动作推断成功');
await setup({ matched: 21 });
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();
@@ -170,7 +327,7 @@ function paperHTML(total = 25) {
await page.evaluate(() => qaExam.enable());
assert.equal((await tickAt(360)).model.commitClicks, 1, '同场恢复后仍按原始入卷 6 分钟交卷');
await setup({ matched: 21 });
await setup();
await page.evaluate(() => {
qaExam.destroy(); qaStorage.clear(); qaClock.now = 240000;
qaContext.sourceUrl += '&totaltime=30';
@@ -182,8 +339,8 @@ function paperHTML(total = 25) {
assert.equal(inferred.waitSeconds, 120);
console.log('PASS 原始入卷时间持久化、0 时间值和明确倒计时反推');
for (const mutation of ['ready', 'route', 'revision', 'answers', 'points', 'topic', 'pause']) {
await setup({ matched: 21 });
for (const mutation of ['ready', 'route', 'revision', 'answers', 'points', 'topic', 'pause', 'manualConfirmIncomplete']) {
await setup();
const blocked = await page.evaluate(async mutation => {
qaSetClock(360000);
qaStorageHook = async (_key, value) => {
@@ -198,14 +355,15 @@ function paperHTML(total = 25) {
}
if (mutation === 'topic') qaTopic = { status: 'matched', courses: ['其他主题'] };
if (mutation === 'pause') qaExam.disable('用户已暂停');
if (mutation === 'manualConfirmIncomplete') qaSettings.manualConfirmIncomplete = 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']) {
await setup({ matched: 21, confirm: true });
for (const mutation of ['revision', 'duplicateButton', 'pause', 'manualConfirmIncomplete']) {
await setup({ confirm: true });
await tickAt(360);
const blocked = await page.evaluate(async mutation => {
qaStorageHook = async (_key, value) => {
@@ -213,15 +371,28 @@ function paperHTML(total = 25) {
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;
};
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({ matched: 21 });
await setup();
const interruptedRead = await page.evaluate(async () => {
qaExam.destroy(); qaStorageReads = 0;
let release; qaReadGate = new Promise(resolve => { release = resolve; });
@@ -237,7 +408,7 @@ function paperHTML(total = 25) {
assert.equal(interruptedRead.reads, 1);
assert.equal(interruptedRead.returned, false);
assert.equal(interruptedRead.state.enabled, false, 'storage.get 返回后不得重新启用已暂停的考试');
await setup({ matched: 21 });
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; } };
@@ -248,7 +419,7 @@ function paperHTML(total = 25) {
assert.equal(interruptedWrite.state.enabled, false);
console.log('PASS observe 共享异步读取及 enable 在读写期间被暂停后不复活');
await setup({ matched: 21 });
await setup();
const retake = await page.evaluate(async () => {
qaExam.destroy();
const key = Array.from(qaStorage.keys())[0], saved = qaStorage.get(key);
@@ -268,7 +439,7 @@ function paperHTML(total = 25) {
assert.equal(retake.model.commitClicks, 0);
assert.equal((await tickAt(360)).model.commitClicks, 1);
await setup({ matched: 21 });
await setup();
const missingIdentity = await page.evaluate(async () => {
qaExam.destroy();
const key = Array.from(qaStorage.keys())[0], saved = qaStorage.get(key);
@@ -282,7 +453,7 @@ function paperHTML(total = 25) {
assert.equal(missingIdentity.state.waitSeconds, 360);
assert.equal(missingIdentity.model.commitClicks, 0);
await setup({ matched: 21 });
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,
@@ -299,7 +470,7 @@ function paperHTML(total = 25) {
assert.equal(equalCountdownRetake.after.waitSeconds, 360);
assert.equal(equalCountdownRetake.model.commitClicks, 0);
await setup({ matched: 21 });
await setup();
const noClockEvidence = await page.evaluate(async () => {
qaExam.destroy(); qaClock.now = 360000;
document.querySelector('.pc_examtest_countdown').remove();
@@ -315,7 +486,7 @@ function paperHTML(total = 25) {
assert.equal(noClockEvidence.after.waitSeconds, 360);
assert.equal(noClockEvidence.model.commitClicks, 0);
await setup({ matched: 21 });
await setup();
const pendingAttempt = await page.evaluate(async () => {
qaExam.destroy();
const key = Array.from(qaStorage.keys())[0], saved = qaStorage.get(key);
+62
View File
@@ -169,6 +169,35 @@ async function main() {
state = await tick(); assert.equal(state.tasks[0].status, 'waiting');
await tick(600000); assert.equal(await page.evaluate(() => qaExamCalls), 1); assert.equal(await page.evaluate(() => qaReloads), 0); checks++;
// 缺题改由用户在网站交卷,等待与局部暂停只读同步最终结果,不能重启控制器。
for (const locallyPaused of [false, true]) {
await setup(examDetail, { mode: 'exam' });
await page.evaluate(() => {
qaEngine.destroy(); window.qaExamPauseCalls = 0;
qaEngine = qaCreate({ onExamPause: () => { qaExamPauseCalls++; qaExam = { stage: 'paused', message: '已交给用户处理' }; } });
});
await page.evaluate(() => qaEngine.start());
await page.evaluate(() => { document.body.innerHTML = '<app-examtest><div class="pc_examtest_title">测试课程</div></app-examtest>'; });
await tick(); assert.equal(await page.evaluate(() => qaExamCalls), 1);
await page.evaluate(() => { qaExam = { stage: 'waiting', predictedScore: 84, total: 40, verifiedCount: 32, requiresManualSubmission: true, enabled: false, message: '题目不全,请在网站手动确认交卷' }; });
state = await tick(); assert.equal(state.tasks[0].status, 'waiting');
if (locallyPaused) {
await page.evaluate(() => qaEngine.configure({ examPaused: true }));
assert.equal(await page.evaluate(() => qaExamPauseCalls), 1);
}
for (const stage of ['waiting', 'unknown', 'submitting']) {
await page.evaluate(stage => { qaExam = { stage, submitted: true, confirmed: false, requiresManualSubmission: true, enabled: false }; }, stage);
state = await tick(600000);
assert.equal(state.tasks[0].status, 'waiting', '等待/未知/仅点击提交都不能当作网站确认完成');
assert.equal(state.completed, 0); assert.equal(await page.evaluate(() => qaExamCalls), 1);
}
await page.evaluate(flagOnly => { qaExam = flagOnly ? { stage: 'unknown', submitted: true, confirmed: true } : { stage: 'submitted', submitted: true, confirmed: true }; }, locallyPaused);
state = await tick(); assert.equal(state.tasks[0].status, 'completed'); assert.equal(state.completed, 1);
assert.equal(await page.evaluate(() => qaExamCalls), 1, '人工交卷完成只读同步,不得重新启动答题');
assert.equal(await page.evaluate(() => qaReloads), 0); assert.equal(await page.evaluate(() => qaNavigations.length), 0);
checks++;
}
await setup('<app-examdetail><div class="examdetail_header">没有题库的考试</div><div class="examdetail_button" onclick="window.qaWrongEnter=true">开始答题</div></app-examdetail>', { mode: 'exam' });
state = await page.evaluate(() => qaEngine.start()); assert.equal(state.tasks[0].status, 'skipped'); assert.equal(await page.evaluate(() => Boolean(window.qaWrongEnter)), false); checks++;
@@ -186,6 +215,27 @@ async function main() {
const exclusive = await page.evaluate(async () => { window.qaSecond = qaCreate(); await qaSecond.tick(); await qaSecond.resume(); return { first: qaEngine.getState(), second: qaSecond.getState(), plays: qaPlayCalls }; });
assert.equal(exclusive.plays, 1, '同类租约阻止第二页面重复播放'); assert.match(exclusive.second.tasks[0].reason, /另一个页面/); checks++;
await setup(videoHTML, { manualConfirmIncomplete: false });
await page.evaluate(() => qaEngine.start()); await page.evaluate(() => qaEngine.pause());
state = await page.evaluate(async () => { qaEngine.destroy(); await new Promise(r => setTimeout(r, 0)); qaEngine = qaCreate(); return qaEngine.tick(); });
assert.equal(state.status, 'paused'); assert.equal(state.manualConfirmIncomplete, false, '单页检查点恢复关闭策略且保持暂停');
await page.evaluate(() => qaEngine.configure({ manualConfirmIncomplete: true }));
assert.equal(await page.evaluate(async () => (await qaStorage.get('session.v1')).config.manualConfirmIncomplete), true);
assert.equal((await read()).status, 'paused'); checks++;
const stalePolicyRead = await page.evaluate(async () => {
qaEngine.destroy(); await new Promise(r => setTimeout(r, 0));
const previous = qaStorage; let release; let held; let holdNext = false;
const gate = new Promise(resolve => { release = resolve; }); const captured = new Promise(resolve => { held = resolve; });
qaStorage = { get: async key => { const value = await previous.get(key); if (key === 'control.v1' && holdNext) { holdNext = false; held(); await gate; } return value; }, set: previous.set };
qaEngine = qaCreate(); await qaEngine.tick();
holdNext = true; const oldTick = qaEngine.tick(); await captured;
await qaEngine.configure({ manualConfirmIncomplete: false }); release(); await oldTick;
return { state: qaEngine.getState(), shared: await qaStorage.get('control.v1') };
});
assert.equal(stalePolicyRead.state.manualConfirmIncomplete, false, '迟到的旧共享读数不得覆盖刚发布的开关');
assert.equal(stalePolicyRead.shared.config.manualConfirmIncomplete, false); assert.equal(stalePolicyRead.state.status, 'paused'); checks++;
// 两个实际标签通过同一GM式存储确认工作页,暂停信号同步,完全隔离真实业务网络。
const shared = new Map();
await context.exposeBinding('__learningGet', (_, key) => shared.has(key) ? JSON.parse(JSON.stringify(shared.get(key))) : null);
@@ -214,6 +264,18 @@ async function main() {
assert.equal(state.exam.predictedScore, 84); assert.equal(state.exam.verified, 32); assert.equal(state.etaSeconds, 240, '并行总览显示另一场考试证据和剩余时间');
await page.evaluate(() => qaEngine.pause()); workerState = await workerPage.evaluate(() => fixtureEngine.tick());
assert.equal(workerState.status, 'paused'); assert.ok(await workerPage.evaluate(() => fixtureExamPause) > 0);
await page.evaluate(() => qaEngine.configure({ manualConfirmIncomplete: false }));
workerState = await workerPage.evaluate(() => fixtureEngine.tick()); state = await tick();
assert.equal(state.manualConfirmIncomplete, false); assert.equal(workerState.manualConfirmIncomplete, false, '主页面关闭开关同步工作页');
assert.equal(state.status, 'paused'); assert.equal(workerState.status, 'paused');
await workerPage.evaluate(() => fixtureEngine.configure({ manualConfirmIncomplete: true }));
state = await tick(); workerState = await workerPage.evaluate(() => fixtureEngine.tick());
assert.equal(state.manualConfirmIncomplete, true, '工作页开启开关反向同步主页面'); assert.equal(workerState.manualConfirmIncomplete, true);
assert.equal(state.status, 'paused'); assert.equal(workerState.status, 'paused', '策略同步不能自动恢复任务');
assert.equal(await workerPage.evaluate(() => fixtureExamCalls), 1, '修改共享策略期间不得重启答题');
const synchronizedControl = shared.get('control.v1');
assert.equal(synchronizedControl.status, 'paused'); assert.equal(synchronizedControl.config.manualConfirmIncomplete, true);
assert.equal(shared.get('session.v1').config.manualConfirmIncomplete, true, '主页面暂停时同步保存工作页策略'); checks++;
await page.evaluate(() => qaEngine.resume()); await workerPage.evaluate(() => fixtureEngine.tick()); workerState = await workerPage.evaluate(() => fixtureEngine.tick());
assert.equal(workerState.status, 'running'); assert.equal(await workerPage.evaluate(() => fixtureExamCalls), 2, '全局继续后重新核验考试');
await workerPage.evaluate(() => fixtureEngine.pause()); state = await tick(); assert.equal(state.status, 'paused', '工作页的全局暂停同步主页面');
+35 -1
View File
@@ -58,7 +58,7 @@ async function main() {
const host = page.locator('#yian-question-assistant');
const tab = id => host.getByTestId('tab-' + id);
const pane = id => host.getByTestId('pane-' + id);
async function screenshot(name) { await page.screenshot({ path: path.join(qaDir, name), fullPage: false }); }
async function screenshot(name) { await page.screenshot({ path: path.join(qaDir, name), fullPage: false, animations: 'disabled' }); }
async function visible() {
await page.waitForFunction(() => {
const box = qaPanel.host.getBoundingClientRect();
@@ -118,6 +118,26 @@ async function main() {
assert.equal(await host.getByTestId('setting-answerInterval').inputValue(), '4');
assert.equal(await host.getByTestId('setting-minSubmitMinutes').inputValue(), '6');
assert(await pane('exam').textContent().then(text => text.includes('80 分(题库估计)')));
const incompleteSwitch = host.getByTestId('setting-manualConfirmIncomplete');
assert.equal(await incompleteSwitch.getAttribute('role'), 'switch');
assert.equal(await incompleteSwitch.getAttribute('aria-label'), '题目不全时手动确认');
assert(await incompleteSwitch.isChecked(), '缺题手动确认默认开启');
assert(await host.getByTestId('exam-threshold').textContent().then(text => text.includes('题目不全时由用户在原网页手动确认')));
assert(await host.getByTestId('incomplete-confirmation-hint').textContent().then(text => text.includes('试卷未完整加载均禁止自动交卷')));
await incompleteSwitch.uncheck();
assert.deepEqual(await page.evaluate(() => qaCalls.settings.at(-1)), ['manualConfirmIncomplete', false]);
assert(await host.getByTestId('exam-threshold').textContent().then(text => text.includes('允许题库部分缺题时自动交卷') && text.includes('严格大于 80 分') && text.includes('所有可匹配题均已核验')));
await screenshot('02a-desktop-incomplete-switch-off.png');
const callsAfterIncompleteOff = await page.evaluate(() => JSON.stringify(qaCalls));
await tab('overview').click(); await page.evaluate(() => qaPanel.setState({ learning: { etaSeconds: 360 } })); await tab('exam').click();
assert(!await incompleteSwitch.isChecked(), '切栏及部分状态更新保留关闭状态');
assert.equal(await page.evaluate(() => JSON.stringify(qaCalls)), callsAfterIncompleteOff, '切栏不能修改缺题开关、自动答题或交卷');
await incompleteSwitch.check();
assert.deepEqual(await page.evaluate(() => qaCalls.settings.at(-1)), ['manualConfirmIncomplete', true]);
assert.deepEqual(await page.evaluate(() => qaCalls.settings.filter(([key]) => key === 'manualConfirmIncomplete')), [['manualConfirmIncomplete', false], ['manualConfirmIncomplete', true]]);
assert.deepEqual(await page.evaluate(() => qaCalls.auto), [], '缺题开关与自动选择答案独立');
assert.deepEqual(await page.evaluate(() => qaCalls.actions), [], '缺题开关不启动或提交考试');
console.log('PASS incomplete-question switch defaults on, boolean setting callbacks, persistent tab state and independent answer controls');
await host.getByTestId('setting-answerInterval').fill('5'); await host.getByTestId('setting-answerInterval').press('Tab');
assert.deepEqual(await page.evaluate(() => qaCalls.settings.at(-1)), ['answerInterval', 5]);
await host.getByRole('switch', { name: '自动选择答案', exact: true }).check();
@@ -130,6 +150,17 @@ async function main() {
assert.equal(await host.getByTestId('setting-answerInterval').inputValue(), '5');
await host.getByTestId('task-examResume').click();
assert.equal(await page.evaluate(() => qaCalls.actions.at(-1)), 'examResume');
await page.evaluate(() => qaPanel.setState({ learning: { exam: { title: '安全考试', status: 'waiting', total: 25,
verifiedCount: 21, unverifiedCount: 4, predictedScore: 84, requiresManualSubmission: true,
elapsedSeconds: 360, remainingSeconds: 1000, message: '题目未全部核验,请在网页手动确认交卷' } } }));
assert(await pane('exam').textContent().then(text => text.includes('84 分(题库估计)') && text.includes('21 / 25 题') && text.includes('原网页手动确认')));
await page.evaluate(() => qaPanel.setState({ learning: { manualConfirmIncomplete: false } }));
assert(!await incompleteSwitch.isChecked());
assert(await pane('exam').textContent().then(text => text.includes('题目未全部核验或网站提示未答完,请在原网页手动确认')), '控制器要求人工时优先显示其提示');
await page.evaluate(() => qaPanel.setState({ learning: { manualConfirmIncomplete: true } }));
assert(await incompleteSwitch.isChecked(), '面板反映外部恢复的开关配置');
assert.equal(await pane('exam').getByRole('button', { name: '确认交卷', exact: true }).count(), 0, '面板不能提供跳过缺题的交卷确认入口');
await host.locator('.panes').evaluate(node => { node.scrollTop = 0; });
await screenshot('02-desktop-exam.png');
console.log('PASS exam defaults/score/full answer evidence, partial learning updates and persistent switch/details state');
@@ -182,6 +213,9 @@ async function main() {
const moved = await host.boundingBox(); assert(Math.abs(moved.x - box.x - 35) <= 2);
await page.setViewportSize({ width: 390, height: 844 }); await visible();
assert.equal(await host.locator('.navigation').evaluate(element => getComputedStyle(element).flexDirection), 'row');
await tab('exam').click(); await incompleteSwitch.scrollIntoViewIfNeeded();
assert(await incompleteSwitch.isVisible()); assert(await incompleteSwitch.isChecked());
await screenshot('03a-mobile-incomplete-switch-on.png');
await tab('bank').click(); await screenshot('03-mobile-bank.png');
await page.setViewportSize({ width: 320, height: 480 }); await visible();
await tab('overview').click(); await screenshot('04-small-overview.png');
+118 -75
View File
@@ -6,87 +6,130 @@ const path = require('node:path');
const { chromium } = require('playwright');
const root = path.resolve(__dirname, '..');
const script = fs.readFileSync(path.join(root, '谊安学助手 2.0.user.js'), 'utf8');
function fixture() {
function fixture(partial) {
const questions = Array.from({ length: 25 }, (_, i) => `<div app-exam-question><div class="pc_examtest_content"><div class="pc_examtest_content_question"><p>[单选] ${i + 1}. 联动验证题${i}<span class="pc_examtest_content_question_value">(4分)</span></p></div><div class="pc_examtest_content_list"><div class="pc_examtest_content_list_des"><span class="examtest_content_list_des_optionsort">A</span>错误${i}</div><div class="pc_examtest_content_list_des"><span class="examtest_content_list_des_optionsort">B</span>正确${i}</div></div></div></div>`).join('');
return `<!doctype html><meta charset="utf-8"><title>助手完整流程验证</title><style>body{font:14px system-ui}.pc_examtest_left{max-width:700px}.pc_examtest_content{padding:8px}.pc_examtest_content_list_des{padding:5px;cursor:pointer}.examtest_content_list_des_checked{background:#ded}.modal[aria-hidden=true]{display:none}</style><app-examtest><div class="pc_examtest"><div class="pc_examtest_left"><div class="pc_examtest_title">完整流程考试</div>${questions}</div><div class="pc_examtest_card">${Array.from({ length: 25 }, (_, i) => `<div class="examtestcard_count_defult">${i + 1}</div>`).join('')}</div><div class="pc_examtest_countdown"><span class="time">00:30:00</span></div><div class="pc_examtest_commit">交卷</div></div></app-examtest><div role="dialog" class="modal" aria-hidden="true"><p>仍有题目未答,是否确认交卷?</p><button>确认交卷</button><button>取消</button></div><div data-exam-result hidden>交卷成功,成绩 84</div>`;
return `<!doctype html><meta charset="utf-8"><title>助手完整流程验证</title><style>body{font:14px system-ui}.pc_examtest_left{max-width:700px}.pc_examtest_content{padding:8px}.pc_examtest_content_list_des{padding:5px;cursor:pointer}.examtest_content_list_des_checked{background:#ded}.modal[aria-hidden=true]{display:none}</style><app-examtest><div class="pc_examtest"><div class="pc_examtest_left"><div class="pc_examtest_title">完整流程考试</div>${questions}</div><div class="pc_examtest_card">${Array.from({ length: 25 }, (_, i) => `<div class="examtestcard_count_defult">${i + 1}</div>`).join('')}</div><div class="pc_examtest_countdown"><span class="time">00:30:00</span></div><div class="pc_examtest_commit">交卷</div></div></app-examtest><div role="dialog" class="modal" aria-hidden="true"><p>${partial ? '仍有题目未答,' : ''}是否确认交卷?</p><button>确认交卷</button><button>取消</button></div><div data-exam-result hidden>交卷成功,成绩 ${partial ? 84 : 100}</div>`;
}
(async () => {
const browser = await chromium.launch({ channel: process.env.PLAYWRIGHT_CHANNEL || 'chrome', headless: true });
try {
const memory = new Map(), errors = [], blocked = [];
const context = await browser.newContext({ viewport: { width: 1366, height: 900 } });
await context.exposeBinding('__read', (_, key, fallback) => memory.has(key) ? structuredClone(memory.get(key)) : fallback);
await context.exposeBinding('__write', (_, key, value) => memory.set(key, structuredClone(value)));
const url = 'http://10.119.67.7/clientcn/exam/workflow/questions?flag=test&totaltime=30&title=' + encodeURIComponent('完整流程考试');
await context.route('**/*', route => {
if (route.request().isNavigationRequest() && route.request().url() === url) return route.fulfill({ status: 200, contentType: 'text/html;charset=utf-8', body: fixture() });
blocked.push(route.request().url()); return route.abort();
});
const page = await context.newPage();
page.on('pageerror', error => errors.push(error.message));
await page.goto(url);
await page.evaluate(() => {
window.offset = 0; const nativeNow = Date.now.bind(Date); Date.now = () => nativeNow() + window.offset;
window.advanceExamTime = milliseconds => {
window.offset += milliseconds;
const remaining = Math.max(0, 1800 - Math.floor(window.offset / 1000));
document.querySelector('.pc_examtest_countdown .time').textContent = '00:' + String(Math.floor(remaining / 60)).padStart(2, '0') + ':' + String(remaining % 60).padStart(2, '0');
};
window.commitClicks = 0; window.confirmClicks = 0;
document.querySelectorAll('.pc_examtest_content_list_des').forEach(node => node.addEventListener('click', () => {
node.parentElement.querySelectorAll('.pc_examtest_content_list_des').forEach(n => n.classList.remove('examtest_content_list_des_checked'));
node.classList.add('examtest_content_list_des_checked');
}));
document.querySelector('.pc_examtest_commit').addEventListener('click', () => {
window.commitClicks++; document.querySelector('[role=dialog]').setAttribute('aria-hidden', 'false');
for (const { matched, manualConfirmIncomplete = true } of [{ matched: 21 }, { matched: 25 }, { matched: 21, manualConfirmIncomplete: false }]) {
const partial = matched < 25;
const manual = partial && manualConfirmIncomplete;
const memory = new Map(), errors = [], blocked = [];
const context = await browser.newContext({ viewport: { width: 1366, height: 900 } });
await context.exposeBinding('__read', (_, key, fallback) => memory.has(key) ? structuredClone(memory.get(key)) : fallback);
await context.exposeBinding('__write', (_, key, value) => memory.set(key, structuredClone(value)));
const url = 'http://10.119.67.7/clientcn/exam/workflow/questions?flag=test&totaltime=30&attemptId=workflow-' + matched + '-' + manualConfirmIncomplete + '&title=' + encodeURIComponent('完整流程考试');
await context.route('**/*', route => {
if (route.request().isNavigationRequest() && route.request().url() === url) return route.fulfill({ status: 200, contentType: 'text/html;charset=utf-8', body: fixture(partial) });
blocked.push(route.request().url()); return route.abort();
});
document.querySelector('[role=dialog] button').addEventListener('click', () => {
window.confirmClicks++; document.querySelector('[role=dialog]').setAttribute('aria-hidden', 'true'); document.querySelector('[data-exam-result]').hidden = false;
const page = await context.newPage();
page.on('pageerror', error => errors.push(error.message));
await page.goto(url);
async function mountPage() {
await page.evaluate(() => {
window.offset = 0; const nativeNow = Date.now.bind(Date); Date.now = () => nativeNow() + window.offset;
window.advanceExamTime = milliseconds => {
window.offset += milliseconds;
const remaining = Math.max(0, 1800 - Math.floor(window.offset / 1000));
document.querySelector('.pc_examtest_countdown .time').textContent = '00:' + String(Math.floor(remaining / 60)).padStart(2, '0') + ':' + String(remaining % 60).padStart(2, '0');
};
window.commitClicks = 0; window.confirmClicks = 0;
document.querySelectorAll('.pc_examtest_content_list_des').forEach(node => node.addEventListener('click', () => {
node.parentElement.querySelectorAll('.pc_examtest_content_list_des').forEach(n => n.classList.remove('examtest_content_list_des_checked'));
node.classList.add('examtest_content_list_des_checked');
}));
document.querySelector('.pc_examtest_commit').addEventListener('click', () => {
window.commitClicks++; document.querySelector('[role=dialog]').setAttribute('aria-hidden', 'false');
});
document.querySelector('[role=dialog] button').addEventListener('click', () => {
window.confirmClicks++; document.querySelector('[role=dialog]').setAttribute('aria-hidden', 'true'); document.querySelector('[data-exam-result]').hidden = false;
});
});
await page.addScriptTag({ content: '(function(){const GM_getValue=(k,v)=>window.__read(k,v);const GM_setValue=(k,v)=>window.__write(k,v);\n' + script + '\n})();' });
await page.evaluate(() => YianAssistant.ready);
}
await mountPage();
await page.evaluate(() => { YianAssistant.getState(); });
await page.getByTestId('tab-exam').click();
const manualSwitch = page.getByRole('switch', { name: '题目不全时手动确认', exact: true });
assert.equal(await manualSwitch.isChecked(), true, '新设置默认开启缺题手动确认');
if (!manualConfirmIncomplete) {
await manualSwitch.click();
await page.waitForFunction(() => YianAssistant.getLearningState().manualConfirmIncomplete === false);
await page.waitForFunction(async () => (await window.__read('yianxue.assistant.setting.v2.learningSettings', null))?.manualConfirmIncomplete === false);
assert.equal(await manualSwitch.isChecked(), false);
await page.reload(); await mountPage();
await page.getByTestId('tab-exam').click();
assert.equal(await manualSwitch.isChecked(), false, '刷新后记住已明确关闭的缺题确认开关');
assert.equal(await page.evaluate(() => YianAssistant.getLearningState().manualConfirmIncomplete), false);
assert.equal(await page.evaluate(() => YianAssistant.getState().autoAnswer), false, '设置恢复不会自行开始答题');
assert.equal(await page.evaluate(() => YianAssistant.getExamState().enabled), false, '刷新仍需明确开始交卷计划');
}
const records = Array.from({ length: matched }, (_, i) => ({ role: i % 2 ? '主操' : '班长', course: '流程主题库', type: '单选', number: i + 1,
question: '联动验证题' + i, options: [{ label: 'A', text: '正确' + i }, { label: 'B', text: '错误' + i }], answer: 'A' }));
await page.evaluate(async records => {
const bytes = new TextEncoder().encode(JSON.stringify(records));
await YianAssistant.importFiles([{ name: '统一流程.json', size: bytes.length, arrayBuffer: async () => bytes.buffer }]);
await YianAssistant.setLearningSetting('mode', 'exam');
await YianAssistant.setLearningSetting('answerInterval', 0);
}, records);
await page.waitForFunction(() => YianAssistant.getState().pageReady);
assert.equal(await page.evaluate(() => YianAssistant.setAutoAnswer(true)), false, '没有明确主题关联不能答题');
await page.evaluate(async () => {
await YianAssistant.setLearningSetting('associations', { 完整流程考试: ['流程主题库'] });
await YianAssistant.taskAction('start');
});
});
await page.addScriptTag({ content: '(function(){const GM_getValue=(k,v)=>window.__read(k,v);const GM_setValue=(k,v)=>window.__write(k,v);\n' + script + '\n})();' });
await page.evaluate(() => YianAssistant.ready);
await page.evaluate(() => { YianAssistant.getState(); });
const records = Array.from({ length: 21 }, (_, i) => ({ role: i % 2 ? '主操' : '班长', course: '流程主题库', type: '单选', number: i + 1,
question: '联动验证题' + i, options: [{ label: 'A', text: '正确' + i }, { label: 'B', text: '错误' + i }], answer: 'A' }));
await page.evaluate(async records => {
const bytes = new TextEncoder().encode(JSON.stringify(records));
await YianAssistant.importFiles([{ name: '统一流程.json', size: bytes.length, arrayBuffer: async () => bytes.buffer }]);
await YianAssistant.setLearningSetting('mode', 'exam');
await YianAssistant.setLearningSetting('answerInterval', 0);
}, records);
await page.waitForFunction(() => YianAssistant.getState().pageReady);
assert.equal(await page.evaluate(() => YianAssistant.setAutoAnswer(true)), false, '没有明确主题关联不能答题');
await page.evaluate(async () => {
await YianAssistant.setLearningSetting('associations', { 完整流程考试: ['流程主题库'] });
await YianAssistant.taskAction('start');
});
await page.waitForFunction(() => YianAssistant.getState().metrics.selected === 21, null, { timeout: 15000 });
await page.waitForFunction(() => YianAssistant.getExamState().predictedScore === 84);
assert.equal(await page.evaluate(() => window.commitClicks), 0);
await page.getByTestId('tab-bank').click();
await page.getByRole('textbox', { name: '搜索题目', exact: true }).fill('联动验证题0');
await page.getByRole('button', { name: '搜索', exact: true }).click();
assert.equal(await page.getByRole('switch', { name: '导出题库', exact: true, includeHidden: true }).isChecked(), false);
await page.getByTestId('tab-console').click();
assert.equal(await page.evaluate(() => YianAssistant.getExamState().enabled), true, '切栏不取消交卷授权');
await page.evaluate(() => window.advanceExamTime(4 * 60000));
await page.waitForTimeout(750);
assert.equal(await page.evaluate(() => window.commitClicks), 0, '4分钟不得交卷');
await page.evaluate(() => window.advanceExamTime(2 * 60000 + 500));
await page.waitForFunction(() => YianAssistant.getExamState().submitted === true, null, { timeout: 8000 }).catch(async error => {
console.error(await page.evaluate(() => ({ stage: YianAssistant.getExamState().stage, message: YianAssistant.getExamState().message,
score: YianAssistant.getExamState().predictedScore, tasks: YianAssistant.getLearningState().tasks,
commits: window.commitClicks, confirms: window.confirmClicks, ready: YianAssistant.getContext().ready })));
throw error;
});
assert.equal(await page.evaluate(() => window.commitClicks), 1);
assert.equal(await page.evaluate(() => window.confirmClicks), 1);
await page.waitForFunction(() => YianAssistant.getLearningState().completed === 1);
await page.waitForTimeout(1300);
assert.equal(await page.evaluate(() => window.commitClicks), 1);
assert.deepEqual(errors, []);
assert.deepEqual(blocked, []);
console.log('PASS 完整用户脚本:无主题阻止答题→明确关联→统一题库21题全文回读=84分→切栏不扰动→4分钟等待→6分钟正常交卷→网站确认→任务完成,零业务请求');
await page.waitForFunction(matched => YianAssistant.getState().metrics.selected === matched, matched, { timeout: 15000 });
await page.waitForFunction(matched => YianAssistant.getExamState().predictedScore === matched * 4, matched);
if (manual) {
await page.waitForFunction(() => YianAssistant.getExamState().stage === 'waiting' && YianAssistant.getExamState().requiresManualSubmission === true);
assert.equal(await page.evaluate(() => YianAssistant.getExamState().unverifiedCount), 4);
assert.match(await page.evaluate(() => YianAssistant.getExamState().message), /人工|手动/);
}
assert.equal(await page.evaluate(() => YianAssistant.getExamState().requiresManualSubmission), manual);
assert.equal(await page.evaluate(() => window.commitClicks), 0);
await page.getByTestId('tab-bank').click();
await page.getByRole('textbox', { name: '搜索题目', exact: true }).fill('联动验证题0');
await page.getByRole('button', { name: '搜索', exact: true }).click();
assert.equal(await page.getByRole('switch', { name: '导出题库', exact: true, includeHidden: true }).isChecked(), false);
await page.getByTestId('tab-console').click();
assert.equal(await page.evaluate(() => YianAssistant.getExamState().enabled), !manual, '切栏保留开关决定的交卷策略和当前授权状态');
await page.evaluate(() => window.advanceExamTime(4 * 60000));
await page.waitForTimeout(750);
assert.equal(await page.evaluate(() => window.commitClicks), 0, '4分钟不得交卷');
await page.evaluate(() => window.advanceExamTime(2 * 60000 + 500));
if (manual) {
await page.waitForTimeout(1300);
assert.equal(await page.evaluate(() => window.commitClicks), 0, '84 分且满 6 分钟,缺题仍不得自动点击交卷');
assert.equal(await page.evaluate(() => window.confirmClicks), 0);
assert.equal(await page.evaluate(() => YianAssistant.getExamState().requiresManualSubmission), true);
assert.equal(await page.evaluate(() => YianAssistant.getExamState().stage), 'waiting');
await page.locator('.pc_examtest_commit').click();
await page.getByRole('button', { name: '确认交卷', exact: true }).click();
}
await page.waitForFunction(() => YianAssistant.getExamState().submitted === true, null, { timeout: 8000 }).catch(async error => {
console.error(await page.evaluate(() => ({ stage: YianAssistant.getExamState().stage, message: YianAssistant.getExamState().message,
score: YianAssistant.getExamState().predictedScore, tasks: YianAssistant.getLearningState().tasks,
commits: window.commitClicks, confirms: window.confirmClicks, ready: YianAssistant.getContext().ready })));
throw error;
});
assert.equal(await page.evaluate(() => window.commitClicks), 1);
assert.equal(await page.evaluate(() => window.confirmClicks), 1);
assert.equal(await page.evaluate(() => YianAssistant.getExamState().requiresManualSubmission), false, '网站确认结果后清除待人工交卷提示');
await page.waitForFunction(() => YianAssistant.getLearningState().completed === 1);
await page.waitForTimeout(1300);
assert.equal(await page.evaluate(() => window.commitClicks), 1);
assert.deepEqual(errors, []);
assert.deepEqual(blocked, []);
console.log(manual
? 'PASS 完整用户脚本:21/25 题全文回读=84分→切栏不扰动→满6分钟仍无自动交卷→原网页真实人工交卷和确认→网站结果→任务完成,零业务请求'
: partial
? 'PASS 完整用户脚本:真实界面关闭缺题确认开关→21/25题=84分→4分钟等待→满6分钟自动交卷并确认未答提醒→网站结果→任务完成,零业务请求'
: 'PASS 完整用户脚本:25/25题全文回读=100分→切栏不扰动→4分钟等待→满6分钟自动交卷和确认→网站结果→任务完成,零业务请求');
await context.close();
}
} finally { await browser.close(); }
})().catch(error => { console.error(error); process.exitCode = 1; });