Files
yianxue-assistant/tests/learning-engine.cjs
T

287 lines
26 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 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 });
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.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++;
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>';
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++;
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++;
// 两个实际标签通过同一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.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++;
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; });