Files

631 lines
51 KiB
JavaScript

/* 本机隔离浏览器学习调度回归;所有业务地址均被 route 拦截,无真实学习记录。 */
const assert = require('node:assert/strict');
const fs = require('node:fs');
const path = require('node:path');
const { chromium } = require('playwright');
const ZIP = require('../vendor/jszip.min.js');
const workspace = path.resolve(__dirname, '..');
const source = fs.readFileSync(path.join(workspace, 'learning-engine.js'), 'utf8');
const attemptSource = fs.readFileSync(path.join(workspace, 'attempt-policy.js'), 'utf8');
const core = fs.readFileSync(path.join(workspace, 'question-bank-core.js'), 'utf8');
const origin = 'http://10.119.67.7/clientcn/';
const videoHTML = '<app-course><div class="course-courseware-other-top-left">测试课程</div><li class="cn_courseware_img active" data-courseware-id="v1" data-required-seconds="100"><p>视频一</p><div class="cn_courseware_img_icon">视频</div><div class="menu_courseware_left">已学习20秒</div><div class="courseware_finished_status"><img class="en_courseware_finished en_courseware_noFinished"></div></li><app-course-courseware-video><video id="player1"></video></app-course-courseware-video></app-course>';
async function main() {
const browser = await chromium.launch({ channel: process.env.PLAYWRIGHT_CHANNEL || 'chrome', headless: true });
const context = await browser.newContext();
const page = await context.newPage();
const errors = [];
page.on('pageerror', e => errors.push(e.message));
await page.route('**/*', route => route.request().isNavigationRequest() ? route.fulfill({ status: 200, contentType: 'text/html', body: '<!doctype html><meta charset="utf-8"><body></body>' }) : route.abort());
await page.goto(origin + 'course/test');
await page.addScriptTag({ content: core });
await page.addScriptTag({ content: source });
await page.addScriptTag({ content: attemptSource });
let checks = 0;
try {
async function setup(html = videoHTML, config = {}, settings = {}) {
await page.evaluate(async ({ html, config, settings }) => {
window.qaEngine?.destroy();
history.replaceState(null, '', '/clientcn/course/test');
document.body.innerHTML = html;
window.qaClock = 100000; window.qaMemory = new Map(); window.qaReloads = 0; window.qaNavigations = []; window.qaWorkerURLs = []; window.qaPlayCalls = 0; window.qaPauseCalls = 0; window.qaExamCalls = 0; window.qaQuizCalls = 0; window.qaEnterCount = 0;
window.qaVisibility = 'visible';
Object.defineProperty(document, 'visibilityState', { configurable: true, get: () => qaVisibility });
window.qaVideo = { currentTime: 20, duration: 100, ended: false, paused: true, playbackRate: 1 };
window.qaExam = { stage: 'answering', total: 40, verifiedCount: 0 };
window.qaSnapshot = { document, url: location.href, accountId: 'fixture-account', bankVersion: 1, planId: 'fixture-plan', courses: ['测试课程'] };
window.qaStorage = { get: async key => qaMemory.has(key) ? JSON.parse(JSON.stringify(qaMemory.get(key))) : null, set: async (key, value) => { qaMemory.set(key, JSON.parse(JSON.stringify(value))); } };
window.qaInstallVideo = () => {
const video = document.querySelector('video'); if (!video) return;
for (const key of ['currentTime', 'duration', 'ended', 'paused', 'playbackRate']) Object.defineProperty(video, key, { configurable: true, get: () => qaVideo[key], set: v => { qaVideo[key] = v; } });
video.play = async () => { qaPlayCalls++; if (settings.rejectPlay) throw new Error('autoplay'); qaVideo.paused = false; };
video.pause = () => { qaPauseCalls++; qaVideo.paused = true; };
};
qaInstallVideo();
window.qaCreate = (extra = {}) => YianLearningEngine.create(Object.assign({
getSnapshot: () => Object.assign({}, qaSnapshot, { document, url: location.href }),
getExamState: () => qaExam,
onExamStart: async () => { qaExamCalls++; return settings.examNotReady ? false : true; },
onExamPause: () => {},
onState: state => { window.qaLastState = state; },
storage: qaStorage, now: () => qaClock,
reload: async () => { qaReloads++; }, navigate: async url => { qaNavigations.push(url); },
openWorker: async url => { qaWorkerURLs.push(url); return settings.blockWorker ? false : {}; },
solveQuiz: settings.noQuizAdapter ? undefined : async () => { qaQuizCalls++; if (settings.solveQuiz) { document.querySelector('[data-learning-quiz],.question-modal-pc')?.remove(); return { status: 'completed', verified: true }; } return { status: 'waiting', reason: 'fixture无精确答案' }; }
}, extra));
window.qaEngine = qaCreate(); await qaEngine.configure(config);
}, { html, config, settings });
}
async function read() { return page.evaluate(() => qaEngine.getState()); }
async function tick(delta = 0) { return page.evaluate(async delta => { qaClock += delta; return qaEngine.tick(); }, delta); }
// 安全解析实际7页,只读取DOM,不执行任何归档脚本和媒体。
const samples = [];
const archiveAvailable = fs.existsSync(path.join(workspace, '谊安学网页.zip'));
if (archiveAvailable) {
const zip = await ZIP.loadAsync(fs.readFileSync(path.join(workspace, '谊安学网页.zip')));
for (const file of Object.values(zip.files).filter(f => /\.html$/i.test(f.name))) samples.push({ name: file.name, html: await file.async('string') });
} else {
// 仓库不提交原站ZIP;新克隆仍用最小DOM覆盖同一适配器。
samples.push({ name: 'fixture/01/page.html', html: '<app-learning-map-activity-list>' + Array.from({ length: 26 }, (_, i) => '<div class="activity_content"><div class="activity_name">课程' + i + '</div><div class="acitvity_other_info">必修课程</div></div>').join('') + '</app-learning-map-activity-list>' });
samples.push({ name: 'fixture/02/page.html', html: videoHTML.replace('测试课程', '1-1安全生产方针和目标').replace('</li>', '<p class="related_test">关联考试</p></li>') });
samples.push({ name: 'fixture/05/page.html', html: videoHTML.replace('已学习20秒', '已学习27秒') });
samples.push({ name: 'fixture/06/page.html', html: '<app-examdetail><div class="examdetail_header">测试课程 已答0/40</div></app-examdetail>' });
}
const inspected = await page.evaluate(samples => samples.map(({ name, html }) => {
const doc = QuestionBankCore.parseHTML(html); const p = YianLearningEngine.describePage(doc, location.href); const tasks = YianLearningEngine.discoverTasks(doc, location.href);
return { name, kind: p.kind, title: p.title, learned: p.learnedSeconds, complete: p.platformComplete, quiz: Boolean(p.quiz), tasks: tasks.map(t => ({ kind: t.kind, title: t.title, status: t.status })) };
}), samples);
const map = inspected.find(x => x.name.includes('/01/'));
assert.equal(map.tasks.length, 26, '实际26课程不能把9阶段当作任务数');
assert.equal(map.tasks.filter(t => t.kind === 'course').length, 26);
const detail = inspected.find(x => x.name.includes('/02/'));
assert.equal(detail.title, '1-1安全生产方针和目标');
assert.deepEqual(detail.tasks.map(t => t.kind), ['video', 'exam']);
const played = inspected.find(x => x.name.includes('/05/'));
assert.equal(played.complete, false, 'noFinished不能因共同class前缀误认为完成');
assert.equal(played.learned, 27); assert.equal(played.quiz, false);
assert.equal(inspected.find(x => x.name.includes('/06/')).kind, 'exam');
checks++;
const identities = await page.evaluate(() => {
const d = new DOMParser().parseFromString('<a data-kind="video" href="/clientcn/course/one">同名</a><a data-kind="video" href="/clientcn/course/two">同名</a><a href="http://example.com/clientcn/exam/other">外站</a>', 'text/html');
return YianLearningEngine.discoverTasks(d, location.href);
});
assert.equal(identities.length, 2); assert.notEqual(identities[0].id, identities[1].id); checks++;
const topics = await page.evaluate(() => {
const d = new DOMParser().parseFromString('<app-examdetail><div class="examdetail_header">安全课程 已答0/40</div></app-examdetail>', 'text/html');
return [YianLearningEngine.preflightTopic(d, location.href, { courses: [] }, {}), YianLearningEngine.preflightTopic(d, { courses: ['安全课程'] }, {}), YianLearningEngine.preflightTopic(d, { courses: ['安全'] }, {}), YianLearningEngine.preflightTopic(d, { courses: ['安全领导力'] }, { 安全课程: ['安全领导力'] }), YianLearningEngine.preflightTopic(d, location.href, { records: [{ course: '安全课程' }] }, {})];
});
assert.deepEqual(topics.map(t => t.status), ['missing', 'matched', 'waiting', 'matched', 'matched']); checks++;
await setup(videoHTML, { rate: 4, mode: 'videoQuiz' });
await page.evaluate(() => qaEngine.start());
let state = await read();
assert.equal(state.rate, 2); assert.equal(state.answerInterval, 4); assert.equal(state.video.actualRate, 2);
assert.equal(state.total, 1); assert.equal(state.completed, 0); assert.equal(state.etaSeconds, 80);
await page.evaluate(() => { qaVideo.currentTime = 100; qaVideo.ended = true; });
state = await tick(1000); assert.equal(state.tasks[0].status, 'waitingPlatform'); assert.equal(state.completed, 0);
await tick(180000); assert.equal(await page.evaluate(() => qaReloads), 0);
await page.evaluate(() => document.querySelector('.cn_courseware_img').setAttribute('data-is-learned', 'true'));
state = await tick(); assert.equal(state.completed, 1); assert.equal(state.tasks[0].status, 'completed'); checks++;
await setup(); await page.evaluate(() => qaEngine.start());
await page.evaluate(() => qaEngine.configure({ videoPaused: true })); await tick();
assert.equal(await page.evaluate(() => qaVideo.paused), true);
await page.evaluate(() => qaEngine.resume()); assert.equal((await read()).videoPaused, false);
await page.evaluate(() => qaEngine.pause()); const prior = await page.evaluate(() => qaPlayCalls);
await tick(180000); assert.equal(await page.evaluate(() => qaPlayCalls), prior); assert.equal(await page.evaluate(() => qaReloads), 0); checks++;
await setup(videoHTML + '<div data-learning-quiz><p data-question>强制题</p></div>', { mode: 'video' });
state = await page.evaluate(() => qaEngine.start()); assert.equal(state.tasks[0].status, 'waiting');
await tick(180000); assert.equal(await page.evaluate(() => qaQuizCalls), 0); assert.equal(await page.evaluate(() => qaReloads), 0); checks++;
await setup(videoHTML + '<div class="cdk-overlay-pane question-modal-pc"><div class="question_box"><p>真实portal题</p></div></div>', { mode: 'videoQuiz' }, { solveQuiz: true });
await page.evaluate(() => qaEngine.start()); await tick();
assert.equal(await page.evaluate(() => qaQuizCalls), 1); assert.equal((await read()).tasks[0].status, 'playing'); checks++;
await setup(videoHTML + '<div data-learning-quiz><p data-question>未知题</p></div>', { mode: 'videoQuiz' });
state = await page.evaluate(() => qaEngine.start()); assert.equal(state.tasks[0].status, 'waiting');
assert.match(state.tasks[0].reason, /无精确答案/); await tick(180000); assert.equal(await page.evaluate(() => qaReloads), 0); checks++;
await setup(); await page.evaluate(() => qaEngine.start()); await tick(60000);
assert.equal(await page.evaluate(() => qaReloads), 0); assert.equal((await read()).tasks[0].status, 'retrying');
await tick(15000); assert.equal(await page.evaluate(() => qaReloads), 1); assert.equal((await read()).tasks[0].retryCount, 1);
const recovery = await page.evaluate(async () => { qaEngine.destroy(); await new Promise(r => setTimeout(r, 0)); qaEngine = qaCreate(); return qaEngine.tick(); });
assert.equal(recovery.status, 'running'); assert.equal(recovery.tasks[0].retryCount, 1);
for (let i = 0; i < 3; i++) { await tick(60000); await tick(15000); }
assert.equal(await page.evaluate(() => qaReloads), 3); assert.equal((await read()).tasks[0].status, 'failed'); checks++;
await setup(); await page.evaluate(() => qaEngine.start());
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.match(state.message, /人工刷新/);
await page.evaluate(() => qaEngine.resume()); assert.equal((await read()).status, 'running');
await page.evaluate(() => { qaSnapshot.accountId = 'other-account'; });
state = await tick(); assert.equal(state.status, 'paused'); assert.match(state.message, /账号/); checks++;
// 正常任务跳转有一次性授权;人工刷新同一地址没有该授权。
await setup('<app-learning-map-activity-list><div class="activity_content"><div class="activity_name">测试课程</div><div class="acitvity_other_info">课程</div></div></app-learning-map-activity-list>');
await page.evaluate(() => qaEngine.start());
state = await page.evaluate(async html => {
qaEngine.destroy(); await new Promise(r => setTimeout(r, 0));
history.replaceState(null, '', '/clientcn/course/authorized-target'); document.body.innerHTML = html; qaInstallVideo(); qaEngine = qaCreate(); return qaEngine.tick();
}, videoHTML);
assert.equal(state.status, 'running'); assert.equal(state.tasks.filter(t => t.kind === 'course')[0].status, 'expanded'); assert.equal(state.video.title, '视频一'); checks++;
// 播放器内的用户主动暂停不触发卡顿恢复。
await setup(videoHTML); await page.evaluate(() => qaEngine.start());
await page.evaluate(() => { qaClock += 1000; const v = document.querySelector('video'); v.style.cssText = 'width:200px;height:100px'; v.onclick = () => { qaVideo.paused = true; v.dispatchEvent(new Event('pause', { bubbles: true })); }; });
await page.locator('video').click(); state = await tick(); assert.equal(state.tasks[0].status, 'waiting'); assert.match(state.tasks[0].reason, /主动暂停/);
await tick(180000); assert.equal(await page.evaluate(() => qaReloads), 0); checks++;
await setup(videoHTML, {}, { rejectPlay: true });
state = await page.evaluate(() => qaEngine.start()); assert.equal(state.tasks[0].status, 'waiting');
await tick(180000); assert.equal(await page.evaluate(() => qaReloads), 0); checks++;
// 原站 hidden 分支主动暂停;后台时不进行卡顿刷新,回前台立即续播原位置。
await setup(); await page.evaluate(() => qaEngine.start());
const playsBeforeHide = await page.evaluate(() => {
qaVideo.currentTime = 37.5; qaVisibility = 'hidden'; document.dispatchEvent(new Event('visibilitychange'));
document.querySelector('video').pause(); document.querySelector('video').dispatchEvent(new Event('pause'));
return qaPlayCalls;
});
state = await tick(180000); assert.equal(state.video.status, 'backgroundPaused');
assert.equal(await page.evaluate(() => qaPlayCalls), playsBeforeHide); assert.equal(await page.evaluate(() => qaReloads), 0);
await page.evaluate(() => { qaVisibility = 'visible'; document.dispatchEvent(new Event('visibilitychange')); });
await page.waitForFunction(() => !qaVideo.paused);
assert.equal(await page.evaluate(() => qaPlayCalls), playsBeforeHide + 1);
assert.equal(await page.evaluate(() => qaVideo.currentTime), 37.5); assert.equal((await read()).completed, 0); checks++;
// 开关关闭、全局暂停、播放器手动暂停均不能被 focus/visible 事件重新播放。
await setup(videoHTML, { resumePlaybackOnReturn: false }); await page.evaluate(() => qaEngine.start());
await page.evaluate(() => { qaVisibility = 'hidden'; document.dispatchEvent(new Event('visibilitychange')); document.querySelector('video').pause(); });
await tick(180000);
await page.evaluate(() => { qaVisibility = 'visible'; document.dispatchEvent(new Event('visibilitychange')); });
await page.waitForTimeout(50); state = await tick();
assert.equal(await page.evaluate(() => qaPlayCalls), 1); assert.equal(state.tasks[0].status, 'waiting');
assert.match(state.tasks[0].reason, /自动恢复已关闭/); assert.equal(await page.evaluate(() => qaReloads), 0); checks++;
for (const kind of ['global', 'local', 'manual', 'quiz']) {
await setup(); await page.evaluate(() => qaEngine.start());
if (kind === 'global') await page.evaluate(() => qaEngine.pause());
if (kind === 'local') await page.evaluate(() => qaEngine.configure({ videoPaused: true }));
if (kind === 'manual') {
await page.evaluate(() => { qaClock += 1000; const v = document.querySelector('video'); v.style.cssText = 'width:200px;height:100px'; v.onclick = () => { v.pause(); v.dispatchEvent(new Event('pause')); }; });
await page.locator('video').click(); await tick();
}
await page.evaluate(kind => {
qaVisibility = 'hidden'; document.dispatchEvent(new Event('visibilitychange')); document.querySelector('video').pause();
if (kind === 'quiz') document.querySelector('app-course').insertAdjacentHTML('beforeend', '<div data-learning-quiz><p data-question>等待人工处理的题</p></div>');
}, kind);
const before = await page.evaluate(() => qaPlayCalls);
await page.evaluate(() => { qaVisibility = 'visible'; document.dispatchEvent(new Event('visibilitychange')); });
await page.waitForTimeout(50); await tick(180000);
assert.equal(await page.evaluate(() => qaPlayCalls), before, kind + ' 优先于自动恢复');
assert.equal(await page.evaluate(() => qaReloads), 0); checks++;
}
const playbackPrompt = '<div class="modal-dialog dialog_box"><div class="modal-content"><div class="modal-body modal-dialog-scrollable"><div>是否继续上次播放的位置?</div></div><div class="modal-footer"><button class="btn" type="button" aria-label="Close" onclick="qaRestartClicks++"><span aria-hidden="true">重新开始</span></button><button class="btn" type="button" aria-label="Close" onclick="qaContinueClicks++;qaVideo.currentTime=37.5;this.closest(\'.modal-dialog\').remove()"><span aria-hidden="true">继续播放</span></button></div></div></div>';
for (const enabled of [true, false]) {
await setup(videoHTML + playbackPrompt, { autoContinuePlayback: enabled });
await page.evaluate(() => { window.qaContinueClicks = 0; window.qaRestartClicks = 0; });
state = await page.evaluate(() => qaEngine.start());
await tick(); await tick(180000);
assert.equal(await page.evaluate(() => qaContinueClicks), enabled ? 1 : 0);
assert.equal(await page.evaluate(() => qaRestartClicks), 0);
if (enabled) assert.equal(await page.evaluate(() => qaVideo.currentTime), 37.5);
else { assert.equal(state.tasks[0].status, 'waiting'); assert.equal(await page.evaluate(() => qaPlayCalls), 0); }
assert.equal(await page.evaluate(() => qaReloads), 0); checks++;
}
// 唯一弹窗/按钮及全局运行状态是续播前提,不能接管普通“继续播放”按钮或考试弹窗。
for (const variant of ['duplicate', 'disabled', 'otherPrompt', 'paused']) {
const modal = variant === 'duplicate' ? playbackPrompt + playbackPrompt : variant === 'disabled' ? playbackPrompt.replace('onclick="qaContinueClicks++', 'disabled onclick="qaContinueClicks++') : variant === 'otherPrompt' ? playbackPrompt.replace('是否继续上次播放的位置?', '是否继续考试?') : playbackPrompt;
await setup(videoHTML + modal);
await page.evaluate(() => { window.qaContinueClicks = 0; window.qaRestartClicks = 0; });
if (variant === 'paused') { await page.evaluate(async () => { await qaEngine.start(); await qaEngine.pause(); document.body.insertAdjacentHTML('beforeend', '<button onclick="qaContinueClicks++">继续播放</button>'); }); }
else await page.evaluate(() => qaEngine.start());
const before = await page.evaluate(() => qaContinueClicks);
await tick(180000); assert.equal(await page.evaluate(() => qaContinueClicks), before);
if (variant !== 'paused') assert.equal(before, 0);
assert.equal(await page.evaluate(() => qaRestartClicks), 0); checks++;
}
const examDetail = '<app-examdetail><div class="examdetail_header">测试课程 已答0/40</div><div class="examdetail_button" onclick="window.qaEnterCount=(window.qaEnterCount||0)+1">开始答题</div></app-examdetail>';
for (const enabled of [true, false]) {
await setup(examDetail.replace('</app-examdetail>', '<p>允许考试次数:1次</p></app-examdetail>'), { mode: 'exam', skipSingleAttemptQuiz: enabled });
state = await page.evaluate(() => qaEngine.start());
assert.equal(await page.evaluate(() => window.qaEnterCount || 0), enabled ? 0 : 1);
if (enabled) { assert.equal(state.tasks[0].status, 'waiting'); assert.equal(state.exam.requiresManualSubmission, true); }
else {
await page.evaluate(() => { document.body.innerHTML = '<app-examtest><div class="pc_examtest_title">测试课程</div></app-examtest>'; });
await tick(); assert.equal(await page.evaluate(() => qaEngine.getAttemptPolicy().blocked), true, '试卷无次数标签时仍保留同一考试详情证据');
}
checks++;
}
await setup(examDetail.replace('</app-examdetail>', '<p>允许考试次数:1次</p></app-examdetail>'), { mode: 'exam', skipSingleAttemptQuiz: false });
await page.evaluate(() => { history.replaceState(null, '', '/clientcn/exam/limited'); });
await page.evaluate(() => qaEngine.start());
await page.evaluate(() => {
history.replaceState(null, '', '/clientcn/exam/unrelated/questions');
document.body.innerHTML = '<app-examtest><div class="pc_examtest_title">测试课程</div><p>允许考试次数:3次</p></app-examtest>';
});
state = await tick(); assert.equal(await page.evaluate(() => qaExamCalls), 0, '相同标题的其他考试不得接管');
assert.equal(state.tasks.find(t => t.url.includes('/exam/limited')).attemptPolicy.count, 1, '其他考试不得覆写旧考试次数');
await page.evaluate(() => {
history.replaceState(null, '', '/clientcn/exam/limited/questions');
document.body.innerHTML = '<app-examtest><div class="pc_examtest_title">测试课程</div></app-examtest>';
});
assert.equal(await page.evaluate(() => qaEngine.getAttemptPolicy().count), 1, '同一考试ID的试卷继承详情次数,外部考试不污染'); checks++;
await setup(examDetail, { mode: 'exam' });
await page.evaluate(() => qaEngine.start()); assert.equal(await page.evaluate(() => qaEnterCount), 1);
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: 80, message: '预计得分正好80分等待人工' }; });
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(videoHTML.replace('</li>', '<p class="related_test">测试课程</p></li>'), { mode: 'all' });
await page.evaluate(() => document.querySelector('.cn_courseware_img').setAttribute('data-is-learned', 'true'));
await page.evaluate(() => qaEngine.start());
state = await page.evaluate(async html => {
qaEngine.destroy(); await new Promise(resolve => setTimeout(resolve, 0));
history.replaceState(null, '', '/clientcn/exam/linked-full-navigation'); document.body.innerHTML = html;
qaEngine = qaCreate(); return qaEngine.tick();
}, examDetail);
assert.equal(state.status, 'running', '关联考试完整导航沿用一次性转场授权');
assert.equal(state.tasks.filter(t => t.kind === 'exam').length, 1); assert.equal(await page.evaluate(() => qaEnterCount), 1); 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++;
await setup(videoHTML + '<a data-kind="exam" href="/clientcn/exam/e1">测试课程</a>', { execution: 'parallel' }, { blockWorker: true });
state = await page.evaluate(() => qaEngine.start());
assert.equal(await page.evaluate(() => qaWorkerURLs.length), 1); assert.equal(state.tasks.find(t => t.kind === 'exam').status, 'waiting');
assert.match(state.tasks.find(t => t.kind === 'exam').reason, /拦截/); checks++;
await setup(videoHTML + '<a data-kind="exam" href="/clientcn/exam/e1">测试课程</a>', { execution: 'parallel' });
await page.evaluate(() => qaEngine.start());
const opened = await page.evaluate(() => qaWorkerURLs[0]); assert.match(opened, /__yian_worker=/);
state = await tick(31000); assert.equal(state.tasks.find(t => t.kind === 'exam').status, 'waiting'); assert.match(state.tasks.find(t => t.kind === 'exam').reason, /未确认启动/); checks++;
await setup(); await page.evaluate(() => qaEngine.start());
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;
const control = await qaStorage.get('control.v1');
return { state: qaEngine.getState(), policy: await qaStorage.get('policy:' + control.sessionId + ':manualConfirmIncomplete') };
});
assert.equal(stalePolicyRead.state.manualConfirmIncomplete, false, '迟到的旧共享读数不得覆盖刚发布的开关');
assert.equal(stalePolicyRead.policy.value, 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);
await context.exposeBinding('__learningSet', (_, key, value) => { shared.set(key, JSON.parse(JSON.stringify(value))); });
const relatedHTML = videoHTML.replace('</li>', '<p class="related_test">测试课程</p></li>');
await setup(relatedHTML, { execution: 'parallel' });
await page.evaluate(async () => { qaEngine.destroy(); qaStorage = { get: key => __learningGet(key), set: (key, value) => __learningSet(key, value) }; qaEngine = qaCreate(); await qaEngine.configure({ execution: 'parallel' }); await qaEngine.start(); });
const workerURL = await page.evaluate(() => qaWorkerURLs[0]);
const workerPage = await context.newPage();
try {
await workerPage.route('**/*', route => route.request().isNavigationRequest() ? route.fulfill({ status: 200, contentType: 'text/html', body: '<!doctype html><meta charset="utf-8">' + relatedHTML }) : route.abort());
await workerPage.goto(workerURL); await workerPage.addScriptTag({ content: source });
let workerState = await workerPage.evaluate(async examDetail => {
window.fixtureExamCalls = 0; window.fixtureExamPause = 0;
document.querySelector('.related_test').onclick = () => { document.body.innerHTML = examDetail; };
window.fixtureEngine = YianLearningEngine.create({ getSnapshot: () => ({ document, url: location.href, courses: ['测试课程'], accountId: 'fixture-account', bankVersion: 1, planId: 'fixture-plan' }),
storage: { get: key => __learningGet(key), set: (key, value) => __learningSet(key, value) }, now: () => 100000,
onExamStart: async () => { fixtureExamCalls++; return true; }, getExamState: () => window.fixtureExamState || ({ stage: 'answering', predictedScore: 84, verifiedCount: 32, total: 40, elapsedSeconds: 120, waitSeconds: 240 }), onExamPause: () => { fixtureExamPause++; } });
return fixtureEngine.tick();
}, examDetail);
assert.equal(workerState.status, 'running'); assert.equal(workerState.tasks.find(t => t.kind === 'exam').status, 'loading');
assert.equal(await workerPage.evaluate(() => Boolean(document.querySelector('app-examdetail'))), true, '无href时从真实课程页点击原关联考试入口');
await workerPage.evaluate(() => { history.replaceState(null, '', '/clientcn/examtest/e1?flag=test'); document.body.innerHTML = '<app-examtest><div class="pc_examtest_title">测试课程</div></app-examtest>'; });
workerState = await workerPage.evaluate(() => fixtureEngine.tick()); assert.equal(await workerPage.evaluate(() => fixtureExamCalls), 1);
state = await tick(); assert.match(state.message, /并行执行器已确认/); assert.equal(state.tasks.filter(t => t.kind === 'exam').length, 1, '详情转试卷不得复制同一考试');
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(shared.get('policy:' + synchronizedControl.sessionId + ':manualConfirmIncomplete').value, 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', '工作页的全局暂停同步主页面');
await workerPage.evaluate(() => fixtureEngine.resume()); await tick(); state = await tick(); assert.equal(state.status, 'running');
await page.evaluate(() => document.querySelector('.cn_courseware_img').setAttribute('data-is-learned', 'true'));
state = await tick(); assert.equal(state.completed, 1); assert.equal(state.status, 'running', '视频先完成仍等待另一页考试');
await workerPage.evaluate(() => { window.fixtureExamState = { stage: 'submitted', predictedScore: 84, total: 40, verifiedCount: 32 }; });
await workerPage.evaluate(() => fixtureEngine.tick()); state = await tick();
assert.equal(state.completed, 2); assert.equal(state.status, 'finished', '本地任务完成后仍合并远端终态');
await page.evaluate(() => qaEngine.start()); workerState = await workerPage.evaluate(() => fixtureEngine.tick());
// 旧执行器已完成,不会因新一轮开始重新答题。
assert.ok(['finished', 'paused'].includes(workerState.status)); checks++;
} finally { await workerPage.close(); }
await setup(); await page.evaluate(() => qaEngine.start());
await page.evaluate(() => { for (let i = 0; i < 520; i++) qaEngine.log('info', '日志' + i); });
assert.equal((await read()).logs.length, 500);
await page.evaluate(() => qaEngine.clearLogs()); state = await read(); assert.equal(state.logs.length, 0); assert.equal(state.tasks.length, 1); checks++;
await setup(); await page.evaluate(() => qaEngine.start()); await page.evaluate(() => qaEngine.pause());
const initializedRace = await page.evaluate(async () => {
qaEngine.destroy();
const previous = qaStorage; let release; const gate = new Promise(resolve => { release = resolve; });
qaStorage = { get: async key => { if (key === 'session.v1') await gate; return previous.get(key); }, set: previous.set };
qaEngine = qaCreate(); const first = qaEngine.tick(); const update = qaEngine.configure({ rate: 2, mode: 'video' });
await new Promise(r => setTimeout(r, 10)); release(); await Promise.all([first, update]); return qaEngine.getState();
});
assert.equal(initializedRace.rate, 2); assert.equal(initializedRace.mode, 'video'); checks++;
// 长时间课中题异步流程也须续租,超过18秒后仍只有原执行器操作。
await setup(videoHTML + '<div data-learning-quiz><p data-question>异步核验题</p></div>', { mode: 'videoQuiz' });
await page.evaluate(() => {
qaEngine.destroy(); window.qaQuizCommitted = 0;
qaEngine = qaCreate({ solveQuiz: async () => {
await new Promise(resolve => { window.qaReleaseQuiz = resolve; });
if (qaEngine.getState().status !== 'running') return { status: 'waiting', reason: '任务已失效' };
qaQuizCommitted++; document.querySelector('[data-learning-quiz]').remove(); return { status: 'completed', verified: true };
} });
window.qaHeldStart = qaEngine.start();
});
await page.waitForFunction(() => typeof qaReleaseQuiz === 'function');
await tick(10000); await tick(10000);
const heldLease = await page.evaluate(async () => ({ lease: await qaStorage.get('lease:video'), clock: qaClock, status: qaEngine.getState().status }));
assert.equal(heldLease.status, 'running'); assert.ok(heldLease.lease.expires > heldLease.clock, '异步题超过18秒仍续租');
await page.evaluate(async () => { qaReleaseQuiz(); await qaHeldStart; });
assert.equal(await page.evaluate(() => qaQuizCommitted), 1); checks++;
// 租约被另一执行器取得后,旧异步题不得继续提交,也不得清除新租约。
await setup(videoHTML + '<div data-learning-quiz><p data-question>异步核验题</p></div>', { mode: 'videoQuiz' });
await page.evaluate(() => {
qaEngine.destroy(); window.qaReleaseQuiz = null; window.qaQuizCommitted = 0;
qaEngine = qaCreate({ solveQuiz: async () => {
await new Promise(resolve => { window.qaReleaseQuiz = resolve; });
if (qaEngine.getState().status !== 'running') return { status: 'waiting', reason: '任务已失效' };
qaQuizCommitted++; return { status: 'completed', verified: true };
} });
window.qaHeldStart = qaEngine.start();
});
await page.waitForFunction(() => typeof qaReleaseQuiz === 'function');
await page.evaluate(async () => { const lease = await qaStorage.get('lease:video'); lease.owner = 'another-worker'; lease.expires = qaClock + 18000; await qaStorage.set('lease:video', lease); });
state = await tick(1000); assert.equal(state.status, 'paused'); assert.match(state.message, /租约/);
assert.equal(await page.evaluate(() => qaVideo.paused), true);
await page.evaluate(async () => { qaReleaseQuiz(); await qaHeldStart; });
assert.equal(await page.evaluate(() => qaQuizCommitted), 0); assert.equal(await page.evaluate(async () => (await qaStorage.get('lease:video')).owner), 'another-worker'); checks++;
// HTTP 内网页无法依赖 Web Locks:不同标签同时改不同开关,逐键记录不能丢失任一修改。
await setup(); await page.evaluate(() => qaEngine.start()); await page.evaluate(() => qaEngine.pause());
const concurrentPolicies = await page.evaluate(async () => {
qaEngine.destroy();
const previous = qaStorage; let gated = false, capturedReads = 0, release, captured;
const gate = new Promise(resolve => { release = resolve; });
const bothCaptured = new Promise(resolve => { captured = resolve; });
qaStorage = {
get: async key => {
const value = await previous.get(key);
if (gated && key === 'control.v1') { if (++capturedReads === 2) captured(); await gate; }
return value;
}, set: previous.set
};
qaEngine = qaCreate(); window.qaPolicyPeer = qaCreate();
await qaEngine.tick(); await qaPolicyPeer.tick();
gated = true;
const first = qaEngine.configure({ autoContinuePlayback: false });
const second = qaPolicyPeer.configure({ resumePlaybackOnReturn: false });
await bothCaptured; release(); await Promise.all([first, second]); gated = false;
await qaEngine.tick(); await qaPolicyPeer.tick();
const state = qaEngine.getState(), peer = qaPolicyPeer.getState();
const saved = await qaStorage.get('session.v1');
const result = { state, peer, saved: saved.config,
independent: Array.from(qaMemory.keys()).filter(key => key.startsWith('policy:' + saved.id + ':')) };
qaPolicyPeer.destroy(); return result;
});
for (const value of [concurrentPolicies.state, concurrentPolicies.peer, concurrentPolicies.saved]) {
assert.equal(value.autoContinuePlayback, false, '并发切换不能丢失第一个标签关闭自动续播');
assert.equal(value.resumePlaybackOnReturn, false, '并发切换不能丢失另一个标签关闭切回恢复');
}
assert.equal(concurrentPolicies.state.status, 'paused'); assert.equal(concurrentPolicies.peer.status, 'paused');
assert.equal(concurrentPolicies.independent.length, 2, '不同策略写入两个互不覆盖的独立存储键'); checks++;
const delayedIndependentRead = await page.evaluate(async () => {
const previous = qaStorage; let holdNext = true, release, captured;
const gate = new Promise(resolve => { release = resolve; });
const held = new Promise(resolve => { captured = resolve; });
qaEngine.destroy();
qaStorage = {
get: async key => {
const value = await previous.get(key);
if (holdNext && key.endsWith(':autoContinuePlayback')) { holdNext = false; captured(); await gate; }
return value;
}, set: previous.set
};
// 初始化先正常读取;随后挂起的旧读取必须晚于新本地修改完成才释放。
holdNext = false; qaEngine = qaCreate(); await qaEngine.tick(); holdNext = true;
const old = qaEngine.tick(); await held;
await qaEngine.configure({ autoContinuePlayback: true }); release(); await old;
return qaEngine.getState();
});
assert.equal(delayedIndependentRead.autoContinuePlayback, true, '迟到的逐键旧读取不能覆盖刚保存的新值');
assert.equal(delayedIndependentRead.resumePlaybackOnReturn, false, '更新单键保留另一键的已同步设置');
assert.equal(delayedIndependentRead.status, 'paused'); checks++;
for (const delayedKey of ['control.v1', ':autoContinuePlayback']) {
await setup();
const pausedDuringRead = await page.evaluate(async delayedKey => {
qaEngine.destroy();
const previous = qaStorage; let holdNext = false, release, captured;
const gate = new Promise(resolve => { release = resolve; });
const held = new Promise(resolve => { captured = resolve; });
qaStorage = {
get: async key => {
const value = await previous.get(key);
if (holdNext && (delayedKey === 'control.v1' ? key === delayedKey : key.endsWith(delayedKey))) {
holdNext = false; captured(); await gate;
}
return value;
}, set: previous.set
};
qaEngine = qaCreate(); await qaEngine.start();
holdNext = true; const oldTick = qaEngine.tick(); await held;
await qaEngine.pause(); const paused = qaEngine.getState(); const plays = qaPlayCalls;
release(); await oldTick;
return { paused, state: qaEngine.getState(), plays, afterPlays: qaPlayCalls, shared: await qaStorage.get('control.v1'), videoPaused: qaVideo.paused };
}, delayedKey);
assert.equal(pausedDuringRead.paused.status, 'paused');
assert.equal(pausedDuringRead.state.status, 'paused', delayedKey + ' 的旧 running 读取返回后不能恢复刚暂停的任务');
assert.equal(pausedDuringRead.shared.status, 'paused');
assert.equal(pausedDuringRead.afterPlays, pausedDuringRead.plays, '旧异步读取不能额外触发 video.play');
assert.equal(pausedDuringRead.videoPaused, true); checks++;
}
// 设置仅发布独立策略,挂起中的旧设置不得覆盖本页或另一执行器随后发布的暂停。
for (const pauseElsewhere of [false, true]) {
await setup();
const pausedDuringConfigure = await page.evaluate(async pauseElsewhere => {
qaEngine.destroy();
const previous = qaStorage; let holdNext = false, release, captured;
const gate = new Promise(resolve => { release = resolve; });
const held = new Promise(resolve => { captured = resolve; });
qaStorage = {
get: async key => {
const value = await previous.get(key);
if (holdNext && key.endsWith(':autoContinuePlayback')) { holdNext = false; captured(); await gate; }
return value;
}, set: previous.set
};
qaEngine = qaCreate(); await qaEngine.start();
window.qaPolicyPeer = pauseElsewhere ? qaCreate() : null;
if (qaPolicyPeer) await qaPolicyPeer.tick();
holdNext = true; const changing = qaEngine.configure({ autoContinuePlayback: false }); await held;
const pausing = qaPolicyPeer || qaEngine; await pausing.pause(); const paused = pausing.getState().status; const plays = qaPlayCalls;
release(); await changing; await qaEngine.tick();
const shared = await qaStorage.get('control.v1');
const policy = await qaStorage.get('policy:' + shared.sessionId + ':autoContinuePlayback');
const result = { paused, state: qaEngine.getState(), shared, policy, plays, afterPlays: qaPlayCalls, videoPaused: qaVideo.paused };
qaPolicyPeer?.destroy(); return result;
}, pauseElsewhere);
assert.equal(pausedDuringConfigure.paused, 'paused');
assert.equal(pausedDuringConfigure.shared.status, 'paused', '旧设置写回不得覆盖' + (pauseElsewhere ? '另一执行器' : '本页') + '的新暂停');
assert.equal(pausedDuringConfigure.state.status, 'paused');
assert.equal(pausedDuringConfigure.state.autoContinuePlayback, false);
assert.equal(pausedDuringConfigure.policy.value, false, '暂停不丢失已请求关闭的独立设置');
assert.equal(pausedDuringConfigure.afterPlays, pausedDuringConfigure.plays);
assert.equal(pausedDuringConfigure.videoPaused, true); checks++;
}
// 全量 learningSettings 可能是另一标签较早的快照;重载不能重新发布其中的旧策略。
await setup(); await page.evaluate(() => qaEngine.start()); await page.evaluate(() => qaEngine.pause());
const restoredPolicies = await page.evaluate(async () => {
await qaEngine.configure({ autoContinuePlayback: false, resumePlaybackOnReturn: false });
const control = await qaStorage.get('control.v1');
const keys = ['autoContinuePlayback', 'resumePlaybackOnReturn'].map(key => 'policy:' + control.sessionId + ':' + key);
const before = await Promise.all(keys.map(key => qaStorage.get(key)));
const plays = qaPlayCalls;
qaEngine.destroy(); qaEngine = qaCreate();
await qaEngine.configure({ autoContinuePlayback: true, resumePlaybackOnReturn: true, manualConfirmIncomplete: true,
skipSingleAttemptQuiz: true, rate: 2, answerInterval: 7, mode: 'video' }, { restore: true });
const after = await Promise.all(keys.map(key => qaStorage.get(key)));
return { before, after, state: qaEngine.getState(), plays, afterPlays: qaPlayCalls };
});
assert.equal(restoredPolicies.state.autoContinuePlayback, false, '重载的旧全量快照不能覆盖已发布的自动续播设置');
assert.equal(restoredPolicies.state.resumePlaybackOnReturn, false, '重载保留另一标签已发布的切回恢复设置');
assert.equal(restoredPolicies.state.status, 'paused');
assert.equal(restoredPolicies.state.rate, 2, '普通保存设置仍正常恢复');
assert.equal(restoredPolicies.state.answerInterval, 7);
assert.equal(restoredPolicies.state.mode, 'video');
assert.equal(restoredPolicies.afterPlays, restoredPolicies.plays);
assert.deepEqual(restoredPolicies.after, restoredPolicies.before, '恢复不能重新发布已存在的权威策略记录'); checks++;
// 本地暂停先生效;共享 paused 尚未写完时,新的轮询也不能把旧 running 当作远端继续。
await setup();
const pendingPauseWrite = await page.evaluate(async () => {
qaEngine.destroy();
const previous = qaStorage; let holdNext = false, release, captured;
const gate = new Promise(resolve => { release = resolve; });
const held = new Promise(resolve => { captured = resolve; });
qaStorage = {
get: previous.get,
set: async (key, value) => {
if (holdNext && key === 'control.v1' && value.status === 'paused') { holdNext = false; captured(); await gate; }
return previous.set(key, value);
}
};
qaEngine = qaCreate(); await qaEngine.start();
const plays = qaPlayCalls; holdNext = true; const pausing = qaEngine.pause(); await held;
const before = qaEngine.getState(); const oldShared = await qaStorage.get('control.v1');
await qaEngine.tick(); const during = qaEngine.getState();
release(); await pausing; await qaEngine.tick();
return { before, oldShared, during, state: qaEngine.getState(), shared: await qaStorage.get('control.v1'), plays, afterPlays: qaPlayCalls, videoPaused: qaVideo.paused };
});
assert.equal(pendingPauseWrite.before.status, 'paused');
assert.equal(pendingPauseWrite.oldShared.status, 'running', '明确覆盖本地 paused 已生效但共享写入仍挂起的窗口');
assert.equal(pendingPauseWrite.during.status, 'paused', '写入挂起期间的新 tick 不能接受旧 running 而恢复任务');
assert.equal(pendingPauseWrite.state.status, 'paused');
assert.equal(pendingPauseWrite.shared.status, 'paused');
assert.equal(pendingPauseWrite.afterPlays, pendingPauseWrite.plays);
assert.equal(pendingPauseWrite.videoPaused, true); checks++;
assert.deepEqual(errors, []);
console.log('learning-engine:', checks, 'checks passed (' + (archiveAvailable ? '真实归档DOM' : '合成DOM(未提供原站归档)') + '、平台完成、课中题、恢复、主题预检、租约、并行确认)');
} finally { await context.close(); await browser.close(); }
}
main().catch(error => { console.error(error); process.exitCode = 1; });