fix: resume video playback and advance linked exams with attempt controls
This commit is contained in:
@@ -0,0 +1,91 @@
|
||||
/* The common attempt policy reads visible scoped DOM, never Angular/network state. */
|
||||
'use strict';
|
||||
const assert = require('node:assert/strict');
|
||||
const fs = require('node:fs');
|
||||
const path = require('node:path');
|
||||
const { chromium } = require('playwright');
|
||||
|
||||
(async () => {
|
||||
const browser = await chromium.launch({ channel: process.env.PLAYWRIGHT_CHANNEL || 'chrome', headless: true });
|
||||
const requests = [], errors = [];
|
||||
try {
|
||||
const context = await browser.newContext();
|
||||
await context.route('**/*', route => { requests.push(route.request().url()); return route.abort(); });
|
||||
const page = await context.newPage();
|
||||
page.on('pageerror', error => errors.push(error.message));
|
||||
await page.addScriptTag({ content: fs.readFileSync(path.join(__dirname, '..', 'attempt-policy.js'), 'utf8') });
|
||||
async function read(html, tag = 'app-examdetail') {
|
||||
return page.evaluate(({ html, tag }) => {
|
||||
document.body.innerHTML = '<style>app-examdetail,app-examtest,.question-modal-pc{display:block;padding:12px}.hidden{display:none}</style><' + tag + ' id="scope">' + html + '</' + tag + '>';
|
||||
return YianAttemptPolicy.read(document.querySelector('#scope'));
|
||||
}, { html, tag });
|
||||
}
|
||||
const positive = [
|
||||
'允许答题次数:1次', '总考试次数:1', '最多作答次数为一次', '剩余考试次数:1 次',
|
||||
'可用答题机会:1次', '考试总次数:1次', '答题次数上限:1次', '允许答题次数:仅1次', '考试机会:仅剩1次',
|
||||
'本次仅可答题1次', '仅允许考试一次', '仅有1次答题机会', '本场只剩一次考试机会',
|
||||
'允许作答1次', '总共可考试1次', '还可补考 1 次', '还可补考一次', '剩余答题次数:1次',
|
||||
'剩余 1 次答题机会', '还有一次考试机会', '还可重考1次',
|
||||
'不及格,还可补考 1 次', '(不及格,还可补考 1 次)',
|
||||
'<button>重新答题(不及格,还可补考 1 次)</button>', '<button>重新考试(不及格,还可补考1次)</button>',
|
||||
'<button>重新答题<span>(不及格,还可补考 <strong>1</strong> 次)</span></button>',
|
||||
'<div><span>允许答题次数:</span><strong>1</strong><span>次</span></div>',
|
||||
'<dl><dt>剩余考试次数</dt><dd>1 次</dd></dl>'
|
||||
];
|
||||
for (const html of positive) {
|
||||
const result = await read(html);
|
||||
assert.equal(result.blocked, true, html);
|
||||
assert.equal(result.known, true, html);
|
||||
assert.equal(result.count, 1, html);
|
||||
assert(result.reason.includes('1 次'), html);
|
||||
}
|
||||
console.log('PASS explicit allowed/total/remaining counts, split inline labels, definition rows and real retake wording');
|
||||
for (const html of ['剩余考试次数:0次', '还可补考零次', '允许答题次数:0', '重新答题(不及格,还可补考0次)']) {
|
||||
const result = await read(html); assert(result.blocked, html); assert.equal(result.count, 0, html);
|
||||
}
|
||||
for (const html of ['总考试次数:2次', '剩余答题次数:12次', '还可补考十一次', '允许答题次数:无限次', '重新考试(不及格,还可补考3次)']) {
|
||||
const result = await read(html); assert.equal(result.blocked, false, html); assert.equal(result.count, null, html);
|
||||
}
|
||||
assert.equal((await read('<p>总考试次数:3次</p><p>剩余考试次数:1次</p>')).count, 1);
|
||||
assert.equal((await read('<p>总考试次数:1次</p><p>还可补考0次</p>')).count, 0);
|
||||
console.log('PASS zero/single attempts stop, larger counts are not blocked, remaining zero wins over total one');
|
||||
const ignored = [
|
||||
'已答次数:1次', '已用答题次数:1次', '已考试次数:1次', '已答题数1/40', '已答 1/40', '157人已交卷',
|
||||
'已答题次数:1次,答对 1 题', '答题次数:1', '考试次数:1次', '考试题数:1',
|
||||
'剩余考试次数:1/3', '允许答题次数:1.5次', '允许答题次数:-1次', '还可补考1次是正确答案',
|
||||
'<div class="examtest_content_question">仅有1次答题机会</div>',
|
||||
'<div class="examtest_content_question">(不及格,还可补考1次)是这道题的答案</div>',
|
||||
'<div class="model_answer">重新答题(不及格,还可补考1次)</div>',
|
||||
'<div class="pc_examtest_content_question">允许答题次数1次</div>',
|
||||
'<div class="examtest_content_list_des">还可补考1次</div>',
|
||||
'<div class="model_answer">剩余考试次数1次</div>',
|
||||
'<div data-question-stem>总考试次数1次</div>', '<div class="answer-text">允许答题次数1次</div>',
|
||||
'<div hidden>剩余考试次数1次</div>', '<div aria-hidden="true">还可补考1次</div>',
|
||||
'<div class="hidden">仅有1次答题机会</div>', '<div style="visibility:hidden">总考试次数1次</div>',
|
||||
'<div style="opacity:0"><span>剩余考试次数1次</span></div>', '<div inert>还可补考1次</div>',
|
||||
'<template>允许答题次数1次</template>', '<script type="application/json">{"totalExamCount":1}</script>',
|
||||
'<div data-max-attempts="1" data-remaining-attempts="1">考试已就绪</div>', '<p>这里只剩1题要答</p>'
|
||||
];
|
||||
for (const html of ignored) {
|
||||
const result = await read(html);
|
||||
assert.equal(result.blocked, false, html); assert.equal(result.count, null, html); assert.equal(result.signature, '[]', html);
|
||||
assert.equal(result.known, false, html);
|
||||
}
|
||||
console.log('PASS used counts, question totals, people, decimals, hidden/answer/question text and private/data attributes do not infer limits');
|
||||
for (const tag of ['app-examtest', 'div']) assert.equal((await read('剩余考试次数:1次', tag)).count, 1);
|
||||
const before = await read('<p>总考试次数:3次</p>');
|
||||
const after = await read('<p>总考试次数:2次</p>');
|
||||
assert(before.known && after.known, 'larger explicit count is known even though it does not block');
|
||||
assert.notEqual(before.signature, after.signature, 'larger explicit count changes invalidate earlier evidence');
|
||||
const stable = await read('<p>剩余考试次数:1次</p><p hidden>还可补考0次</p>');
|
||||
assert.equal(stable.signature, (await read('<p>剩余考试次数:1次</p>')).signature);
|
||||
await page.evaluate(() => { document.querySelector('#scope').insertAdjacentHTML('afterend', '<app-examtest id="other">还可补考0次</app-examtest>'); });
|
||||
assert.equal(await page.evaluate(() => YianAttemptPolicy.read(document.querySelector('#scope')).count), 1, 'another exam outside scope is ignored');
|
||||
for (const invalid of ['document', 'body', 'null', 'detached']) {
|
||||
const result = await page.evaluate(invalid => YianAttemptPolicy.read(invalid === 'document' ? document : invalid === 'body' ? document.body : invalid === 'detached' ? document.createElement('app-examtest') : null), invalid);
|
||||
assert.equal(result.blocked, false); assert.equal(result.signature, '[]');
|
||||
}
|
||||
assert.deepEqual(requests, []); assert.deepEqual(errors, []);
|
||||
console.log('PASS scoped containers only, stable signatures, explicit changes, no network or browser errors');
|
||||
} finally { await browser.close(); }
|
||||
})().catch(error => { console.error(error); process.exitCode = 1; });
|
||||
@@ -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();
|
||||
|
||||
+286
-4
@@ -6,6 +6,7 @@ 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>';
|
||||
@@ -20,6 +21,7 @@ async function main() {
|
||||
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 = {}) {
|
||||
@@ -27,7 +29,9 @@ async function main() {
|
||||
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.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: ['测试课程'] };
|
||||
@@ -160,7 +164,100 @@ async function main() {
|
||||
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>'; });
|
||||
@@ -198,6 +295,17 @@ async function main() {
|
||||
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++;
|
||||
|
||||
@@ -231,10 +339,11 @@ async function main() {
|
||||
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') };
|
||||
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.shared.config.manualConfirmIncomplete, false); assert.equal(stalePolicyRead.state.status, 'paused'); checks++;
|
||||
assert.equal(stalePolicyRead.policy.value, false); assert.equal(stalePolicyRead.state.status, 'paused'); checks++;
|
||||
|
||||
// 两个实际标签通过同一GM式存储确认工作页,暂停信号同步,完全隔离真实业务网络。
|
||||
const shared = new Map();
|
||||
@@ -274,7 +383,8 @@ async function main() {
|
||||
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(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, '全局继续后重新核验考试');
|
||||
@@ -341,6 +451,178 @@ async function main() {
|
||||
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(); }
|
||||
|
||||
@@ -165,6 +165,51 @@ async function main() {
|
||||
console.log('PASS exam defaults/score/full answer evidence, partial learning updates and persistent switch/details state');
|
||||
|
||||
await tab('video').click();
|
||||
assert(await host.getByTestId('video-mode-note').textContent().then(text => text.includes('平台确认视频完成后') && text.includes('已开放的关联考试') && text.includes('人工保护仍生效')));
|
||||
const callsBeforeModeHints = await page.evaluate(() => JSON.stringify(qaCalls));
|
||||
for (const mode of ['video', 'videoQuiz']) {
|
||||
await page.evaluate(mode => qaPanel.setState({ learning: { mode } }), mode);
|
||||
assert(await host.getByTestId('video-mode-note').textContent().then(text => text.includes('不会进入独立或关联考试') && text.includes('任务总览选择“视频+考试”')));
|
||||
}
|
||||
await page.evaluate(() => qaPanel.setState({ learning: { mode: 'exam' } }));
|
||||
assert(await host.getByTestId('video-mode-note').textContent().then(text => text.includes('本模式未启用视频')));
|
||||
await page.evaluate(() => qaPanel.setState({ learning: { mode: 'all', skipSingleAttemptQuiz: false } }));
|
||||
assert(await host.getByTestId('video-mode-note').textContent().then(text => text.includes('单次机会保护已关闭')));
|
||||
await page.evaluate(() => qaPanel.setState({ learning: { skipSingleAttemptQuiz: true } }));
|
||||
assert.equal(await page.evaluate(() => JSON.stringify(qaCalls)), callsBeforeModeHints, '模式说明更新只展示状态,不启动任务或改设置');
|
||||
const playbackSwitches = [
|
||||
['autoContinuePlayback', '自动继续上次播放'],
|
||||
['skipSingleAttemptQuiz', '仅 1 次答题机会时交给我处理'],
|
||||
['resumePlaybackOnReturn', '切回页面时自动恢复播放']
|
||||
];
|
||||
for (const [key, label] of playbackSwitches) {
|
||||
const control = host.getByTestId('setting-' + key);
|
||||
assert.equal(await control.getAttribute('role'), 'switch');
|
||||
assert.equal(await control.getAttribute('aria-label'), label);
|
||||
assert(await control.isChecked(), label + '默认开启');
|
||||
await control.uncheck();
|
||||
assert.deepEqual(await page.evaluate(() => qaCalls.settings.at(-1)), [key, false]);
|
||||
}
|
||||
const callsAfterPlaybackOff = await page.evaluate(() => JSON.stringify(qaCalls));
|
||||
await tab('overview').click();
|
||||
await page.evaluate(() => qaPanel.setState({ learning: { etaSeconds: 358 } }));
|
||||
await tab('video').click();
|
||||
for (const [key] of playbackSwitches) assert(!await host.getByTestId('setting-' + key).isChecked(), '切栏及部分更新保留视频开关状态');
|
||||
assert.equal(await page.evaluate(() => JSON.stringify(qaCalls)), callsAfterPlaybackOff, '切栏不能启动续播或自动答题');
|
||||
await page.evaluate(() => qaPanel.setState({ learning: { autoContinuePlayback: true, skipSingleAttemptQuiz: true, resumePlaybackOnReturn: true } }));
|
||||
for (const [key] of playbackSwitches) assert(await host.getByTestId('setting-' + key).isChecked(), '显示外部恢复的视频开关状态');
|
||||
assert.equal(await page.evaluate(() => JSON.stringify(qaCalls)), callsAfterPlaybackOff, '读取保存状态不触发设置或任务回调');
|
||||
assert(await host.getByTestId('playback-return-hint').textContent().then(text => text.includes('手动暂停优先') && text.includes('不会自动播放')));
|
||||
await page.evaluate(() => qaPanel.setState({ learning: { video: { title: '1-1安全生产方针和目标', status: 'backgroundPaused', currentTime: 45, duration: 633, learnedTime: 27, actualRate: 1.5, refreshCount: 1 } } }));
|
||||
assert(await host.locator('.worker-status').textContent().then(text => text.includes('后台暂停,等待返回')));
|
||||
await page.evaluate(() => qaPanel.setState({ learning: { video: { title: '1-1安全生产方针和目标', status: 'playing', currentTime: 45, duration: 633, learnedTime: 27, actualRate: 1.5, refreshCount: 1 } } }));
|
||||
await screenshot('02b-desktop-playback-switches.png');
|
||||
for (const [key] of playbackSwitches) {
|
||||
await host.getByTestId('setting-' + key).uncheck();
|
||||
await host.getByTestId('setting-' + key).check();
|
||||
assert.deepEqual(await page.evaluate(() => qaCalls.settings.at(-1)), [key, true]);
|
||||
}
|
||||
console.log('PASS playback switches default on, boolean callbacks, persisted rendered state and manual-pause guidance');
|
||||
await host.getByTestId('setting-rate').selectOption('2');
|
||||
assert.deepEqual(await page.evaluate(() => qaCalls.settings.at(-1)), ['rate', 2]);
|
||||
await host.getByTestId('task-videoPause').click();
|
||||
@@ -213,6 +258,12 @@ 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('video').click();
|
||||
for (const [key] of playbackSwitches) {
|
||||
const control = host.getByTestId('setting-' + key);
|
||||
await control.scrollIntoViewIfNeeded(); assert(await control.isVisible()); assert(await control.isChecked());
|
||||
}
|
||||
await screenshot('03b-mobile-playback-switches.png');
|
||||
await tab('exam').click(); await incompleteSwitch.scrollIntoViewIfNeeded();
|
||||
assert(await incompleteSwitch.isVisible()); assert(await incompleteSwitch.isChecked());
|
||||
await screenshot('03a-mobile-incomplete-switch-on.png');
|
||||
|
||||
+36
-3
@@ -25,7 +25,7 @@ const rootDir = path.resolve(__dirname, '..');
|
||||
const context = await browser.newContext({ viewport: { width: 1000, height: 800 } });
|
||||
await context.route('**/*', route => { requests.push(route.request().url()); return route.abort(); });
|
||||
const page = await context.newPage(); page.on('pageerror', error => errors.push(error.message));
|
||||
for (const name of ['question-bank-core.js', 'bank-store.js', 'answer-matcher.js', 'quiz-adapter.js']) await page.addScriptTag({ content: fs.readFileSync(path.join(rootDir, name), 'utf8') });
|
||||
for (const name of ['question-bank-core.js', 'bank-store.js', 'answer-matcher.js', 'attempt-policy.js', 'quiz-adapter.js']) await page.addScriptTag({ content: fs.readFileSync(path.join(rootDir, name), 'utf8') });
|
||||
async function setup(config = {}) {
|
||||
await page.evaluate(config => {
|
||||
document.body.innerHTML = '<style>.question_box,.answer_box{display:block;padding:20px;border:1px solid}.hidden,[hidden],[aria-hidden="true"]{display:none!important}.examtest_content_list_des{padding:8px;cursor:pointer}.examtest_content_list_des_checked{background:#ded}.modal-footer{padding:8px}</style><app-question-modal></app-question-modal><div class="cdk-overlay-container"><div class="cdk-overlay-pane question-modal-pc"></div></div>';
|
||||
@@ -41,6 +41,7 @@ const rootDir = path.resolve(__dirname, '..');
|
||||
if (config.missing) qaSnapshot.records = [];
|
||||
const pane = document.querySelector('.question-modal-pc');
|
||||
const escape = text => String(text).replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>');
|
||||
const attemptLabel = () => config.attemptLabel ? '<div class="quiz-attempt-status' + (config.hiddenAttemptLabel ? ' hidden' : '') + '">' + escape(config.attemptLabel) + '</div>' : '';
|
||||
const grade = () => {
|
||||
const q = qaQuestions[qaModel.index]; qaModel.grading = true;
|
||||
const correct = !config.wrongResult && JSON.stringify(qaModel.selected.slice().sort()) === JSON.stringify(q.wanted.slice().sort());
|
||||
@@ -58,7 +59,7 @@ const rootDir = path.resolve(__dirname, '..');
|
||||
const render = () => {
|
||||
const q = qaQuestions[qaModel.index];
|
||||
qaModel.selected = config.type === 'multi' || qaModel.index === 1 ? ['错误措施'] : [];
|
||||
pane.innerHTML = '<div class="question"><div class="question_box fade_in"><div class="modal-dialog"><div class="modal-content"><div class="modal-body"><div class="examtest_content_question">' + ['单选', '多选', '判断'].map(type => '<p class="' + (q.type === type ? 'show' : 'hidden') + '"><span>[' + type + ']</span>' + escape(q.question) + '</p>').join('') + '</div><div class="examtest_content_list">' + q.page.map((text, i) => '<div class="option-row"><div class="examtest_content_list_des' + (qaModel.selected.includes(text) ? ' examtest_content_list_des_checked' : '') + '"><div><span class="examtest_content_list_des_optionsort">' + String.fromCharCode(65 + i) + '</span>' + escape(text) + ' </div></div></div>').join('') + '</div></div><div class="modal-footer"></div></div></div></div></div>';
|
||||
pane.innerHTML = attemptLabel() + '<div class="question"><div class="question_box fade_in"><div class="modal-dialog"><div class="modal-content"><div class="modal-body"><div class="examtest_content_question">' + ['单选', '多选', '判断'].map(type => '<p class="' + (q.type === type ? 'show' : 'hidden') + '"><span>[' + type + ']</span>' + escape(q.question) + '</p>').join('') + '</div><div class="examtest_content_list">' + q.page.map((text, i) => '<div class="option-row"><div class="examtest_content_list_des' + (qaModel.selected.includes(text) ? ' examtest_content_list_des_checked' : '') + '"><div><span class="examtest_content_list_des_optionsort">' + String.fromCharCode(65 + i) + '</span>' + escape(text) + ' </div></div></div>').join('') + '</div></div><div class="modal-footer"></div></div></div></div></div>';
|
||||
const footer = pane.querySelector('.modal-footer');
|
||||
const submit = () => {
|
||||
if (footer.querySelector('button')) return;
|
||||
@@ -78,13 +79,15 @@ const rootDir = path.resolve(__dirname, '..');
|
||||
pane.querySelectorAll('.examtest_content_list_des').forEach((node, j) => node.classList.toggle('examtest_content_list_des_checked', qaModel.selected.includes(q.page[j])));
|
||||
submit();
|
||||
if (config.cancelAfterClick) qaActive = false;
|
||||
if (config.singleAfterClick) pane.querySelector('.quiz-attempt-status').textContent = '剩余答题次数:1 次';
|
||||
}));
|
||||
};
|
||||
render();
|
||||
if (config.image) { const image = document.createElement('img'); image.alt = '需要人工识别的图片'; pane.querySelector('.examtest_content_question').append(image); }
|
||||
if (config.attemptInQuestion) { const note = document.createElement('span'); note.textContent = '答题次数:1 次'; pane.querySelector('.examtest_content_question').append(note); }
|
||||
if (config.unknown) pane.innerHTML = '<div class="unrecognized-popup"><p>新题型</p><button>提交</button></div>';
|
||||
window.qaSolve = () => YianQuizAdapter.solve({ document, snapshot: qaSnapshot, matcher: YianAnswerMatcher,
|
||||
context: { course: '当前视频标题', courses: ['安全课中题'], answerInterval: 0 }, signal: qaAbort.signal, getActive: () => qaActive, timeoutMs: config.timeoutMs || 300 });
|
||||
context: { course: '当前视频标题', courses: ['安全课中题'], answerInterval: 0, skipSingleAttemptQuiz: config.skipSingleAttemptQuiz }, signal: qaAbort.signal, getActive: () => qaActive, timeoutMs: config.timeoutMs || 300 });
|
||||
}, config);
|
||||
}
|
||||
await setup();
|
||||
@@ -110,6 +113,36 @@ const rootDir = path.resolve(__dirname, '..');
|
||||
}
|
||||
console.log('PASS 冲突、未命中、图片与未知 DOM 均不猜选、不提交');
|
||||
|
||||
for (const attemptLabel of ['允许答题次数:1 次', '最多可答题 1 次', '剩余答题次数:一 次', '仅有 1 次答题机会', '还可补考 1 次', '总答题次数:3 次;剩余答题次数:1 次']) {
|
||||
await setup({ attemptLabel }); result = await page.evaluate(() => qaSolve());
|
||||
assert.equal(result.status, 'waiting'); assert.equal(result.manualRequired, true); assert.match(result.reason, /1 次/);
|
||||
model = await page.evaluate(() => qaModel);
|
||||
assert.equal(model.optionClicks.length, 0); assert.equal(model.submits, 0); assert.equal(model.advances, 0);
|
||||
assert.equal(await page.locator('.question_box').count(), 1, '仅一次机会的弹题必须保留供人工操作');
|
||||
}
|
||||
await setup({ attemptLabel: '允许答题次数:1 次', skipSingleAttemptQuiz: false });
|
||||
result = await page.evaluate(() => qaSolve()); assert.equal(result.status, 'complete', '关闭开关后按原流程答题');
|
||||
for (const config of [{ attemptLabel: '允许答题次数:2 次' }, { attemptLabel: '已答题次数:1 次' }, { attemptLabel: '答题次数:1 次' }, { attemptLabel: '允许答题次数:1 次', hiddenAttemptLabel: true }, { attemptLabel: '答题次数:1/3' }]) {
|
||||
await setup(config); result = await page.evaluate(() => qaSolve()); assert.equal(result.status, 'complete', '已用次数、隐藏提示及不明确分数不冒充仅一次机会');
|
||||
}
|
||||
await setup({ attemptInQuestion: true }); result = await page.evaluate(() => qaSolve());
|
||||
assert.equal(result.status, 'complete', '题干区域的次数文字不作为弹题可用次数证据');
|
||||
assert.equal(result.manualRequired, undefined);
|
||||
await setup({ attemptLabel: '剩余答题次数:2 次', singleAfterClick: true }); result = await page.evaluate(() => qaSolve());
|
||||
assert.equal(result.status, 'waiting'); assert.equal(result.manualRequired, true); assert.match(result.reason, /1 次/);
|
||||
assert.equal((await page.evaluate(() => qaModel)).submits, 0, '选中后次数变成一时也不得提交或关闭');
|
||||
await setup({ attemptLabel: '剩余答题次数:2 次' });
|
||||
result = await page.evaluate(() => YianQuizAdapter.solve({ document, snapshot: qaSnapshot,
|
||||
matcher: { match: async (...args) => { const result = await YianAnswerMatcher.match(...args); document.querySelector('.quiz-attempt-status').textContent = '允许答题次数:1 次'; return result; } },
|
||||
context: { courses: ['安全课中题'], answerInterval: 0 }, getActive: () => qaActive }));
|
||||
assert.equal(result.status, 'waiting'); assert.equal(result.manualRequired, true);
|
||||
assert.equal((await page.evaluate(() => qaModel)).optionClicks.length, 0, '匹配结束后重新核验次数,不能执行旧的点击计划');
|
||||
await setup({ attemptLabel: '允许答题次数:2 次' });
|
||||
result = await page.evaluate(async () => { const policy = YianAttemptPolicy; delete window.YianAttemptPolicy; try { return await qaSolve(); } finally { window.YianAttemptPolicy = policy; } });
|
||||
assert.equal(result.status, 'waiting'); assert.match(result.reason, /组件未就绪/);
|
||||
assert.equal((await page.evaluate(() => qaModel)).optionClicks.length, 0, '开关开启时次数组件缺失不得冒险自动答题');
|
||||
console.log('PASS 一次机会默认等待人工且保留弹题、可关闭开关、仅认可可见次数提示及点击前重新核验');
|
||||
|
||||
await setup({ cancelAfterClick: true }); result = await page.evaluate(() => qaSolve());
|
||||
assert.equal(result.status, 'waiting'); assert.equal((await page.evaluate(() => qaModel)).submits, 0);
|
||||
await setup();
|
||||
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
const { spawnSync } = require('node:child_process');
|
||||
const path = require('node:path');
|
||||
for (const file of ['bank-engine.cjs', 'unified-bank.cjs', 'runner-browser.cjs', 'verify.cjs', 'learning-engine.cjs', 'quiz-adapter.cjs', 'exam-controller.cjs', 'panel-tabs.cjs', 'assistant.cjs', 'exam-assistant.cjs', 'panel-drag.cjs', 'workflow.cjs', 'video-workflow.cjs']) {
|
||||
for (const file of ['bank-engine.cjs', 'unified-bank.cjs', 'runner-browser.cjs', 'verify.cjs', 'attempt-policy.cjs', 'learning-engine.cjs', 'quiz-adapter.cjs', 'exam-controller.cjs', 'panel-tabs.cjs', 'assistant.cjs', 'exam-assistant.cjs', 'panel-drag.cjs', 'workflow.cjs', 'video-workflow.cjs', 'video-exam-workflow.cjs']) {
|
||||
const result = spawnSync(process.execPath, [path.join(__dirname, file)], { stdio: 'inherit' });
|
||||
if (result.status !== 0) process.exit(result.status || 1);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,125 @@
|
||||
/* Complete bundle: real course .related_test -> detail -> paper after platform learned.
|
||||
* Every request is intercepted; this fixture never reaches the business server. */
|
||||
'use strict';
|
||||
const assert = require('node:assert/strict');
|
||||
const fs = require('node:fs');
|
||||
const path = require('node:path');
|
||||
const { chromium } = require('playwright');
|
||||
const JSZip = require('../vendor/jszip.min.js');
|
||||
|
||||
const workspace = path.resolve(__dirname, '..');
|
||||
const source = fs.readFileSync(path.join(workspace, '谊安学助手 2.0.user.js'), 'utf8');
|
||||
const course = '1-1安全生产方针和目标';
|
||||
const courseURL = 'http://10.119.67.7/clientcn/course/video-exam-workflow';
|
||||
const detailURL = 'http://10.119.67.7/clientcn/exam/video-exam-workflow';
|
||||
const paperURL = detailURL + '/questions?flag=test&totaltime=30&attemptId=video-linked&title=' + encodeURIComponent(course);
|
||||
const fixture = '<!doctype html><meta charset="utf-8"><title>视频完成后关联考试验证</title><style>body{font:14px system-ui}video{display:block;width:640px;height:220px;background:#172a49}li{list-style:none}.related_test{cursor:pointer}.pc_examtest_content{padding:6px}.pc_examtest_content_list_des{padding:5px;cursor:pointer}.examtest_content_list_des_checked{background:#ded}[hidden],[aria-hidden=true]{display:none!important}</style><main id="site-view"><app-course><div data-course-title>' + course + '</div><li class="cn_courseware_img active"><p><span>1.</span><span>公司安全理念、方针及目标培训</span></p><div class="menu_courseware"><div class="menu_courseware_left"><div class="cn_courseware_img_icon">视频</div><div>27秒</div></div><div class="courseware_finished_status"><span class="en_courseware_finished en_courseware_noFinished">尚未完成</span></div></div><div class="related_test_outer"><p class="related_test"><svg-div class="app-svg-themebox"></svg-div>' + course + '</p></div></li><app-course-courseware-video><app-video-base><video id="player1" controls></video></app-video-base></app-course-courseware-video></app-course></main>';
|
||||
|
||||
(async () => {
|
||||
const savedZip = path.join(workspace, '谊安学网页.zip');
|
||||
if (fs.existsSync(savedZip)) {
|
||||
const zip = await JSZip.loadAsync(fs.readFileSync(savedZip));
|
||||
const saved = Object.values(zip.files).find(file => /\/04\/谊安学\.html$/.test(file.name));
|
||||
assert(saved, '旧归档 04 应包含课程目录');
|
||||
const html = (await saved.async('string')).replace(/<script\b[\s\S]*?<\/script>/gi, '').replace(/<style\b[\s\S]*?<\/style>/gi, '');
|
||||
assert.match(html, /class="related_test(?:\s[^"]*)?"[^>]*>[\s\S]*?1-1安全生产方针和目标\s*<\/p>/);
|
||||
assert(html.includes('公司安全理念、方针及目标培训'), '视频标题与关联考试标题不同,必须使用考试入口的实际文字');
|
||||
console.log('PASS 保存网页静态确认 .related_test 标题为真实考试主题,未运行原站脚本');
|
||||
}
|
||||
const browser = await chromium.launch({ channel: process.env.PLAYWRIGHT_CHANNEL || 'chrome', headless: true });
|
||||
try {
|
||||
for (const mode of ['all', 'videoQuiz']) {
|
||||
const memory = new Map(), requests = [], blocked = [], errors = [];
|
||||
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)));
|
||||
await context.route('**/*', route => {
|
||||
requests.push(route.request().url());
|
||||
if (route.request().isNavigationRequest() && route.request().url() === courseURL) 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(courseURL);
|
||||
await page.evaluate(({ course, detailURL, paperURL }) => {
|
||||
window.qaFlow = { relatedClicks: 0, startClicks: 0, selections: [], commits: 0, confirms: 0 };
|
||||
window.qaMedia = { currentTime: 27, duration: 100, ended: false, paused: true, playbackRate: 1, playCalls: 0 };
|
||||
const video = document.querySelector('video');
|
||||
for (const key of ['currentTime', 'duration', 'ended', 'paused', 'playbackRate']) Object.defineProperty(video, key, { configurable: true, get: () => qaMedia[key], set: value => { qaMedia[key] = value; } });
|
||||
video.play = async () => { qaMedia.playCalls++; qaMedia.paused = false; };
|
||||
video.pause = () => { qaMedia.paused = true; };
|
||||
window.qaOffset = 0; const nativeNow = Date.now.bind(Date); Date.now = () => nativeNow() + qaOffset;
|
||||
window.qaAdvanceExamTime = milliseconds => {
|
||||
qaOffset += milliseconds;
|
||||
const remaining = Math.max(0, 1800 - Math.floor(qaOffset / 1000));
|
||||
document.querySelector('.pc_examtest_countdown .time').textContent = '00:' + String(Math.floor(remaining / 60)).padStart(2, '0') + ':' + String(remaining % 60).padStart(2, '0');
|
||||
};
|
||||
const renderPaper = () => {
|
||||
history.pushState({}, '', paperURL);
|
||||
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('');
|
||||
document.querySelector('#site-view').innerHTML = '<app-examtest><div class="pc_examtest"><div class="pc_examtest_left"><div class="pc_examtest_title">' + course + '</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" aria-hidden="true"><p>是否确认交卷?</p><button>确认交卷</button><button>取消</button></div><div data-exam-result hidden>交卷成功,成绩 100</div>';
|
||||
document.querySelectorAll('.pc_examtest_content_list_des').forEach(node => node.addEventListener('click', () => {
|
||||
node.parentElement.querySelectorAll('.pc_examtest_content_list_des').forEach(sibling => sibling.classList.remove('examtest_content_list_des_checked'));
|
||||
node.classList.add('examtest_content_list_des_checked');
|
||||
qaFlow.selections.push(node.querySelector('.examtest_content_list_des_optionsort').textContent);
|
||||
}));
|
||||
document.querySelector('.pc_examtest_commit').addEventListener('click', () => { qaFlow.commits++; document.querySelector('[role=dialog]').setAttribute('aria-hidden', 'false'); });
|
||||
document.querySelector('[role=dialog] button').addEventListener('click', () => { qaFlow.confirms++; document.querySelector('[role=dialog]').setAttribute('aria-hidden', 'true'); document.querySelector('[data-exam-result]').hidden = false; });
|
||||
};
|
||||
document.querySelector('.related_test').addEventListener('click', event => {
|
||||
event.stopPropagation(); qaFlow.relatedClicks++;
|
||||
history.pushState({}, '', detailURL);
|
||||
document.querySelector('#site-view').innerHTML = '<app-examdetail><div class="examdetail_header">' + course + ' 已答0/25</div><div>总考试次数:3 次</div><div class="examdetail_button">开始答题</div></app-examdetail>';
|
||||
document.querySelector('.examdetail_button').addEventListener('click', () => { qaFlow.startClicks++; renderPaper(); });
|
||||
});
|
||||
}, { course, detailURL, paperURL });
|
||||
await page.addScriptTag({ content: '(function(){const GM_getValue=(key,fallback)=>window.__read(key,fallback);const GM_setValue=(key,value)=>window.__write(key,value);\n' + source + '\n})();' });
|
||||
await page.evaluate(() => YianAssistant.ready);
|
||||
assert.equal(await page.evaluate(() => YianAssistant.getLearningState().mode), 'all', '新安装默认范围应包含视频、课中题与关联考试');
|
||||
const records = Array.from({ length: 25 }, (_, i) => ({ course, type: '单选', number: i + 1, question: '关联考试核验题' + i, options: [{ label: 'A', text: '正确措施' + i }, { label: 'B', text: '错误操作' + i }], answer: 'A' }));
|
||||
await page.evaluate(async ({ records, mode }) => {
|
||||
const bytes = new TextEncoder().encode(JSON.stringify(records));
|
||||
await YianAssistant.importFiles([{ name: '视频关联考试.json', size: bytes.length, arrayBuffer: async () => bytes.buffer }]);
|
||||
await YianAssistant.setLearningSetting('mode', mode);
|
||||
await YianAssistant.setLearningSetting('answerInterval', 0);
|
||||
await YianAssistant.taskAction('start');
|
||||
}, { records, mode });
|
||||
await page.waitForFunction(() => YianAssistant.getLearningState().status === 'running' && !qaMedia.paused);
|
||||
assert.equal(await page.evaluate(() => qaFlow.relatedClicks), 0, '平台未完成视频前不应进入关联考试');
|
||||
await page.evaluate(() => { qaMedia.currentTime = 100; qaMedia.ended = true; document.querySelector('.menu_courseware_left').lastElementChild.textContent = '100秒'; });
|
||||
await page.waitForFunction(() => YianAssistant.getLearningState().tasks.some(task => task.kind === 'video' && task.status === 'waitingPlatform'));
|
||||
assert.equal(await page.evaluate(() => qaFlow.relatedClicks), 0, '播放器到末尾也必须等待平台学习完成');
|
||||
await page.evaluate(() => { document.querySelector('.cn_courseware_img').setAttribute('data-is-learned', 'true'); });
|
||||
if (mode === 'videoQuiz') {
|
||||
await page.waitForFunction(() => YianAssistant.getLearningState().status === 'finished');
|
||||
assert.equal(await page.evaluate(() => qaFlow.relatedClicks), 0, '用户明确选择仅视频和课中题时,不扩大范围自动开考');
|
||||
assert.equal(await page.evaluate(() => YianAssistant.getLearningState().total), 1);
|
||||
console.log('PASS 明确 videoQuiz 模式仍只完成视频和课中题,不擅自开考');
|
||||
} else {
|
||||
await page.waitForFunction(() => qaFlow.relatedClicks === 1, null, { timeout: 10000 }).catch(async error => {
|
||||
console.error('视频完成后的任务清单:', await page.evaluate(() => YianAssistant.getLearningState())); throw error;
|
||||
});
|
||||
await page.waitForFunction(() => qaFlow.startClicks === 1, null, { timeout: 10000 });
|
||||
assert.equal(page.url(), paperURL);
|
||||
await page.waitForFunction(() => YianAssistant.getState().metrics.selected === 25, null, { timeout: 15000 });
|
||||
await page.waitForFunction(() => YianAssistant.getExamState().predictedScore === 100);
|
||||
const model = await page.evaluate(() => qaFlow);
|
||||
assert.deepEqual(model.selections, Array(25).fill('B'), '由原库 A 正确全文映射为当前 B');
|
||||
assert.equal(model.relatedClicks, 1); assert.equal(model.startClicks, 1); assert.equal(model.commits, 0);
|
||||
const state = await page.evaluate(() => YianAssistant.getLearningState());
|
||||
assert.equal(state.tasks.filter(task => task.kind === 'exam').length, 1, '同一考试跨目录、详情和试卷不得重复领取');
|
||||
assert.equal(state.tasks.find(task => task.kind === 'video').status, 'completed');
|
||||
assert.equal(state.video.status, 'completed'); assert.equal(state.video.platformComplete, true, '目录完成标记同步视频面板状态');
|
||||
await page.evaluate(() => qaAdvanceExamTime(6 * 60000 + 500));
|
||||
await page.waitForFunction(() => YianAssistant.getExamState().submitted === true);
|
||||
await page.waitForFunction(() => YianAssistant.getLearningState().status === 'finished' && YianAssistant.getLearningState().completed === 2, null, { timeout: 8000 }).catch(async error => {
|
||||
console.error('网站交卷后的任务清单:', await page.evaluate(() => ({ learning: YianAssistant.getLearningState(), exam: YianAssistant.getExamState() }))); throw error;
|
||||
});
|
||||
assert.equal(await page.evaluate(() => qaFlow.commits), 1); assert.equal(await page.evaluate(() => qaFlow.confirms), 1);
|
||||
console.log('PASS 完整 bundle all 顺序模式:视频末尾→等待平台完成→原始 related_test→详情正常开考→乱序全文答题→满6分钟正常交卷→两个任务完成');
|
||||
}
|
||||
assert.deepEqual(blocked, []); assert.deepEqual(errors, []); assert.equal(requests.length, 1, '所有转场由本地正常按钮模拟,不连接业务站点');
|
||||
await context.close();
|
||||
}
|
||||
} finally { await browser.close(); }
|
||||
})().catch(error => { console.error(error); process.exitCode = 1; });
|
||||
@@ -44,6 +44,7 @@ async function main() {
|
||||
const pane = document.createElement('div'); pane.className = 'cdk-overlay-pane question-modal-pc';
|
||||
const texts = ['任意操作', '切换 mPa 单位', '保持 MPa 单位并核对'];
|
||||
pane.innerHTML = '<div class="question"><div class="question_box fade_in"><div class="modal-dialog"><div class="modal-content"><div class="modal-body"><div class="examtest_content_question"><p class="show"><span>[单选]</span>压力达到 2 MPa 时应该怎样操作?</p><p class="hidden">[多选]隐藏模板</p><p class="hidden">[判断]隐藏模板</p></div><div class="examtest_content_list">' + texts.map((text, index) => '<div class="option-row"><div class="examtest_content_list_des"><div><span class="examtest_content_list_des_optionsort">' + String.fromCharCode(65 + index) + '</span>' + text + '</div></div></div>').join('') + '</div></div><div class="modal-footer"></div></div></div></div></div>';
|
||||
pane.insertAdjacentHTML('afterbegin', '<p class="attempt-status">允许答题次数:1次</p>');
|
||||
document.querySelector('.cdk-overlay-container').append(pane);
|
||||
const grade = () => {
|
||||
qaQuiz.submits++;
|
||||
@@ -74,17 +75,47 @@ async function main() {
|
||||
await YianAssistant.setLearningSetting('mode', 'videoQuiz');
|
||||
await YianAssistant.setLearningSetting('rate', 2);
|
||||
await YianAssistant.setLearningSetting('answerInterval', 0);
|
||||
window.qaResume = { continued: 0, restarted: 0 };
|
||||
const dialog = document.createElement('div'); dialog.className = 'modal-dialog dialog_box';
|
||||
dialog.innerHTML = '<div class="modal-content"><div class="modal-body modal-dialog-scrollable"><div>是否继续上次播放的位置?</div></div><div class="modal-footer"><button type="button"><span aria-hidden="true">重新开始</span></button><button type="button"><span aria-hidden="true">继续播放</span></button></div></div>';
|
||||
const buttons = dialog.querySelectorAll('button');
|
||||
buttons[0].onclick = () => { qaResume.restarted++; qaMedia.currentTime = 0; dialog.remove(); };
|
||||
buttons[1].onclick = () => { qaResume.continued++; qaMedia.currentTime = 37.5; dialog.remove(); };
|
||||
document.querySelector('.cdk-overlay-container').append(dialog);
|
||||
await YianAssistant.taskAction('start');
|
||||
});
|
||||
await page.waitForFunction(() => YianAssistant.getLearningState().status === 'running' && !qaMedia.paused && qaMedia.playbackRate === 2);
|
||||
assert.equal(await page.evaluate(() => YianAssistant.getLearningState().total), 1);
|
||||
assert.equal(await page.evaluate(() => YianAssistant.getLearningState().completed), 0);
|
||||
assert.deepEqual(await page.evaluate(() => qaResume), { continued: 1, restarted: 0 });
|
||||
assert.equal(await page.evaluate(() => qaMedia.currentTime), 37.5);
|
||||
console.log('PASS 完整 bundle:无岗位题库导入、videoQuiz 调度、独立播放器真实 2X 状态');
|
||||
|
||||
await page.evaluate(() => {
|
||||
window.qaVisibility = 'hidden'; Object.defineProperty(document, 'visibilityState', { configurable: true, get: () => qaVisibility });
|
||||
document.dispatchEvent(new Event('visibilitychange')); document.querySelector('video').pause();
|
||||
});
|
||||
await page.waitForFunction(() => YianAssistant.getLearningState().video.status === 'backgroundPaused');
|
||||
const visibilityPlays = await page.evaluate(() => qaMedia.playCalls);
|
||||
await page.evaluate(() => { qaVisibility = 'visible'; document.dispatchEvent(new Event('visibilitychange')); });
|
||||
await page.waitForFunction(() => !qaMedia.paused);
|
||||
assert.equal(await page.evaluate(() => qaMedia.playCalls), visibilityPlays + 1);
|
||||
assert.equal(await page.evaluate(() => qaMedia.currentTime), 37.5);
|
||||
console.log('PASS 精确续播弹窗选择继续播放;网站后台暂停后返回立即续播,保留37.5秒实际位置');
|
||||
|
||||
const beforeTabs = await page.evaluate(() => ({ plays: qaMedia.playCalls, pauses: qaMedia.pauseCalls, task: YianAssistant.getLearningState().tasks[0].id }));
|
||||
for (const key of ['overview', 'video', 'exam', 'bank', 'console', 'settings', 'video']) await host.getByTestId('tab-' + key).click();
|
||||
const afterTabs = await page.evaluate(() => ({ plays: qaMedia.playCalls, pauses: qaMedia.pauseCalls, task: YianAssistant.getLearningState().tasks[0].id }));
|
||||
assert.deepEqual(afterTabs, beforeTabs, '切栏不得重新播放、暂停或领取新任务');
|
||||
await page.evaluate(() => { qaShowQuiz(); });
|
||||
await page.waitForFunction(() => YianAssistant.getLearningState().tasks[0].status === 'waiting');
|
||||
assert.equal(await page.evaluate(() => qaQuiz.clicks.length + qaQuiz.submits + qaQuiz.closes), 0, '默认单次机会开关阻止选项/判分/关闭');
|
||||
assert.match(await page.evaluate(() => YianAssistant.getLearningState().tasks[0].reason), /1 次/);
|
||||
await host.getByTestId('setting-skipSingleAttemptQuiz').uncheck();
|
||||
await page.waitForFunction(() => !YianAssistant.getLearningState().skipSingleAttemptQuiz && YianAssistant.getLearningState().status === 'paused');
|
||||
assert.equal(await page.evaluate(() => qaQuiz.clicks.length + qaQuiz.submits + qaQuiz.closes), 0, '关闭开关不立即作答,需明确继续');
|
||||
await page.evaluate(async () => { document.querySelector('.question-modal-pc').remove(); await YianAssistant.taskAction('resume'); });
|
||||
await page.waitForFunction(() => !qaMedia.paused);
|
||||
await host.getByTestId('global-pause').click();
|
||||
await page.waitForFunction(() => YianAssistant.getLearningState().status === 'paused' && qaMedia.paused);
|
||||
await page.evaluate(async () => {
|
||||
@@ -137,6 +168,20 @@ async function main() {
|
||||
assert(await host.getByTestId('pane-video').textContent().then(text => text.includes('已确认完成')), '视频栏必须同步平台已完成状态');
|
||||
assert.equal(requests.length, 1); assert.deepEqual(blocked, []); assert.deepEqual(errors, []);
|
||||
assert.equal(await page.locator('vite-error-overlay,nextjs-portal,#webpack-dev-server-client-overlay').count(), 0);
|
||||
await host.getByTestId('tab-video').click();
|
||||
await page.screenshot({ path: path.join(qaDir, '03-playback-switches-desktop.png') });
|
||||
await page.setViewportSize({ width: 390, height: 844 });
|
||||
await host.getByTestId('setting-resumePlaybackOnReturn').scrollIntoViewIfNeeded();
|
||||
await page.screenshot({ path: path.join(qaDir, '04-playback-switches-mobile.png') });
|
||||
await host.getByTestId('setting-autoContinuePlayback').uncheck();
|
||||
await host.getByTestId('setting-resumePlaybackOnReturn').uncheck();
|
||||
await page.waitForFunction(() => !YianAssistant.getLearningState().autoContinuePlayback && !YianAssistant.getLearningState().resumePlaybackOnReturn);
|
||||
await page.reload(); await page.addScriptTag({ content: sandbox }); await page.evaluate(() => YianAssistant.ready);
|
||||
const restored = await page.evaluate(() => YianAssistant.getLearningState());
|
||||
for (const key of ['autoContinuePlayback', 'resumePlaybackOnReturn', 'skipSingleAttemptQuiz']) assert.equal(restored[key], false, key + ' 刷新保存');
|
||||
assert.equal(restored.status, 'paused');
|
||||
assert.deepEqual(errors, []);
|
||||
console.log('PASS 单次机会保护ON/OFF真实开关与显式继续、全部三开关保存/刷新不自启、桌面/390px窄屏');
|
||||
console.log('PASS 视频末尾等待平台;明确 data-is-learned 后完成任务并更新面板;零业务网络/刷新、无控制台错误');
|
||||
console.log('截图:' + qaDir);
|
||||
} finally { await browser.close(); }
|
||||
|
||||
Reference in New Issue
Block a user