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

This commit is contained in:
薇星幻海
2026-10-18 02:12:06 +08:00
parent 2e044655bb
commit 1b497286dc
20 changed files with 1629 additions and 155 deletions
+125
View File
@@ -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; });