fix: resume video playback and advance linked exams with attempt controls

This commit is contained in:
薇星幻海
2026-10-18 02:12:06 +08:00
parent 2e044655bb
commit 1b497286dc
20 changed files with 1629 additions and 155 deletions
+69 -6
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, unansweredWarning = false, manualConfirmIncomplete, total = 25 } = {}) {
await page.evaluate(({ html, matched, unknownPoint, confirm, showResult, unansweredWarning, manualConfirmIncomplete }) => {
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 };
@@ -48,6 +48,8 @@ function paperHTML(total = 25) {
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', () => {
@@ -75,17 +77,59 @@ function paperHTML(total = 25) {
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,
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 });
}, { 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');
@@ -339,7 +383,7 @@ function paperHTML(total = 25) {
assert.equal(inferred.waitSeconds, 120);
console.log('PASS 原始入卷时间持久化、0 时间值和明确倒计时反推');
for (const mutation of ['ready', 'route', 'revision', 'answers', 'points', 'topic', 'pause', 'manualConfirmIncomplete']) {
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);
@@ -356,13 +400,16 @@ function paperHTML(total = 25) {
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']) {
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 => {
@@ -372,6 +419,9 @@ function paperHTML(total = 25) {
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);
@@ -417,6 +467,19 @@ function paperHTML(total = 25) {
});
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();