Files

190 lines
18 KiB
JavaScript

/* Browser plugin not available. 完整 bundle 视频→真实模板课中题→平台完成;全部业务 URL 被本地响应拦截。 */
'use strict';
const assert = require('node:assert/strict');
const fs = require('node:fs');
const path = require('node:path');
const os = require('node:os');
const { chromium } = require('playwright');
const workspace = path.resolve(__dirname, '..');
const source = fs.readFileSync(path.join(workspace, '谊安学助手 2.0.user.js'), 'utf8');
const qaDir = process.env.YIAN_VIDEO_QA_DIR || path.join(os.tmpdir(), 'yianxue-video-workflow-qa');
fs.mkdirSync(qaDir, { recursive: true });
const fixtureURL = 'http://10.119.67.7/clientcn/course/video-workflow';
const course = '视频联动安全课程';
const fixture = `<!doctype html><meta charset="utf-8"><title>谊安学视频联动验证</title><style>
body{margin:26px;background:#edf2f8;color:#203451;font:15px/1.5 system-ui}h1{font-size:24px}app-course{display:block;max-width:700px}li{list-style:none;background:white;padding:14px;border-radius:8px;margin-bottom:15px}li>p{font-weight:600}video{display:block;width:640px;max-width:100%;height:220px;background:#172a49;border-radius:10px}.menu_courseware_left{font-size:12px;color:#64758e}.cn_courseware_img_icon{color:#64758e}.hidden,[hidden],[aria-hidden="true"]{display:none!important}.question-modal-pc{position:fixed;inset:130px auto auto 60px;width:530px;max-width:calc(100vw - 50px);padding:20px;background:white;box-shadow:0 10px 60px #172a4955;border-radius:12px;z-index:1000}.examtest_content_list_des{padding:10px;margin:6px 0;border:1px solid #dae3ef;border-radius:6px;cursor:pointer}.examtest_content_list_des_optionsort{margin-right:10px}.examtest_content_list_des_checked{background:#e0f3e7}.modal-footer button{padding:7px 16px;background:#225aca;color:white;border:0;border-radius:6px}
</style><h1>视频学习与课中答题联动</h1><p>独立模拟播放与网站判分,所有网络已拦截。</p><app-course><div data-course-title>${course}</div><li class="cn_courseware_img active" data-courseware-id="workflow-video" data-required-seconds="100"><p>${course}</p><div class="cn_courseware_img_icon">视频</div><div class="menu_courseware_left">已学习10秒</div><div class="courseware_finished_status"><span class="en_courseware_finished en_courseware_noFinished">尚未完成</span></div></li><app-course-courseware-video><app-video-base><video id="player1" controls></video></app-video-base></app-course-courseware-video><app-question-modal></app-question-modal></app-course><div class="cdk-overlay-container"></div>`;
async function main() {
const browser = await chromium.launch({ channel: process.env.PLAYWRIGHT_CHANNEL || 'chrome', headless: true });
const memory = new Map(), requests = [], blocked = [], errors = [];
try {
const context = await browser.newContext({ viewport: { width: 1366, height: 900 } });
await context.exposeBinding('__gmRead', (_, key, fallback) => memory.has(key) ? structuredClone(memory.get(key)) : fallback);
await context.exposeBinding('__gmWrite', (_, key, value) => { memory.set(key, structuredClone(value)); });
await context.route('**/*', route => {
requests.push(route.request().url());
if (route.request().isNavigationRequest() && route.request().url() === fixtureURL) 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));
page.on('console', message => { if (message.type() === 'error' || message.type() === 'warning') errors.push(message.text()); });
await page.goto(fixtureURL);
assert.equal(page.url(), fixtureURL); assert.equal(await page.title(), '谊安学视频联动验证');
await page.evaluate(() => {
window.qaMedia = { currentTime: 10, duration: 100, ended: false, paused: true, playbackRate: 1, playCalls: 0, pauseCalls: 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.pauseCalls++; qaMedia.paused = true; };
window.qaQuiz = { clicks: [], selected: [], submits: 0, correct: false, closes: 0, videoPausedAtSelection: false };
window.qaShowQuiz = () => {
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++;
qaQuiz.correct = JSON.stringify(qaQuiz.selected) === JSON.stringify(['保持 MPa 单位并核对']);
pane.innerHTML = '<div class="answer"><div class="answer_box fade_in"><div class="modal-dialog"><div class="modal-content"><div class="modal-body"><div class="model_dec"><span>网站判分</span><span class="model_dec_span">' + (qaQuiz.correct ? '回答正确' : '回答错误') + '</span></div><div class="model_answer_wrapper"><div class="model_answer">参考答案:保持 MPa 单位并核对</div></div></div><div class="modal-footer"><button type="button"><span aria-hidden="true">关闭</span></button></div></div></div></div></div>';
pane.querySelector('button').addEventListener('click', () => { qaQuiz.closes++; pane.remove(); });
};
pane.querySelectorAll('.option-row').forEach((row, index) => row.addEventListener('click', () => {
qaQuiz.clicks.push({ label: String.fromCharCode(65 + index), text: texts[index] });
qaQuiz.videoPausedAtSelection = qaMedia.paused;
qaQuiz.selected = [texts[index]];
pane.querySelectorAll('.examtest_content_list_des').forEach((node, i) => node.classList.toggle('examtest_content_list_des_checked', i === index));
const footer = pane.querySelector('.modal-footer');
if (!footer.querySelector('button')) { const button = document.createElement('button'); button.type = 'button'; button.innerHTML = '<span aria-hidden="true">查看答案</span>'; button.addEventListener('click', grade); footer.append(button); }
if (window.qaPauseAfterSelection) { qaPauseAfterSelection = false; YianAssistant.taskAction('pause'); }
}));
};
});
const sandbox = '(function(){const GM_getValue=(key,fallback)=>window.__gmRead(key,fallback);const GM_setValue=(key,value)=>window.__gmWrite(key,value);\n' + source + '\n})();';
await page.addScriptTag({ content: sandbox }); await page.evaluate(() => YianAssistant.ready);
const host = page.locator('#yian-question-assistant');
await host.getByRole('button', { name: '展开题库助手', exact: true }).click();
await host.getByTestId('tab-bank').click();
await host.locator('input[type=file]').setInputFiles({ name: '视频课中题.json', mimeType: 'application/json', buffer: Buffer.from(JSON.stringify([{ course, type: '单选', question: '压力达到 2 MPa 时应该怎样操作?', options: ['保持 MPa 单位并核对', '切换 mPa 单位', '任意操作'].map((text, index) => ({ label: String.fromCharCode(65 + index), text })), answer: 'A' }])) });
await page.waitForFunction(() => YianAssistant.getSnapshot().total === 1);
assert.equal(await page.evaluate(() => YianAssistant.getSnapshot().records[0].role), '', '不带岗位的统一题库可导入');
await page.evaluate(async () => {
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 () => {
await YianAssistant.setLearningSetting('stallSeconds', 1);
await YianAssistant.setLearningSetting('retryDelaySeconds', 1);
qaShowQuiz();
});
const pausedPlays = await page.evaluate(() => qaMedia.playCalls);
await page.waitForTimeout(2400);
assert.equal(await page.evaluate(() => qaMedia.playCalls), pausedPlays, '全局暂停后不能自动续播');
assert.equal(await page.evaluate(() => qaQuiz.clicks.length + qaQuiz.submits), 0, '暂停期间不操作课中题');
assert.equal(requests.length, 1, '暂停期间没有故障刷新或其他业务请求');
assert.equal(await page.locator('.question_box').count(), 1);
await page.evaluate(async () => {
await YianAssistant.setLearningSetting('stallSeconds', 60);
await YianAssistant.setLearningSetting('retryDelaySeconds', 15);
window.qaPauseAfterSelection = true;
});
await host.getByTestId('global-pause').click();
await page.waitForFunction(() => qaQuiz.clicks.length === 1 && YianAssistant.getLearningState().status === 'paused');
await page.waitForTimeout(400);
assert.equal(await page.evaluate(() => qaQuiz.submits), 0, '选中后暂停必须取消后续查看答案/提交');
assert.equal(await page.evaluate(() => qaMedia.paused), true);
await host.getByTestId('global-pause').click();
await page.waitForFunction(() => qaQuiz.closes === 1 && !qaMedia.paused);
const quiz = await page.evaluate(() => qaQuiz);
assert.deepEqual(quiz.clicks, [{ label: 'C', text: '保持 MPa 单位并核对' }], '原库 A 按正确文字映射到当前 C,保留 MPa/mPa 大小写差异');
assert.deepEqual(quiz.selected, ['保持 MPa 单位并核对']);
assert(quiz.videoPausedAtSelection, '课中答题前实际暂停播放器');
assert.equal(quiz.submits, 1); assert.equal(quiz.correct, true); assert.equal(quiz.closes, 1);
assert.equal(await page.locator('.question_box,.answer_box,.question-modal-pc').count(), 0);
assert.equal(await page.evaluate(() => YianAssistant.getLearningState().completed), 0);
assert(await page.evaluate(() => qaMedia.playCalls) > pausedPlays, '正常关闭课中题后继续播放器');
console.log('PASS 六栏不影响任务;全局暂停停止课中答题和恢复;继续后真实 portal 全文选中→查看答案→网站判正确→关闭→续播');
await page.evaluate(() => { qaMedia.currentTime = qaMedia.duration; qaMedia.ended = true; document.querySelector('.menu_courseware_left').textContent = '已学习90秒'; });
await page.waitForFunction(() => YianAssistant.getLearningState().tasks[0].status === 'waitingPlatform');
assert.equal(await page.evaluate(() => YianAssistant.getLearningState().completed), 0, '播放到末尾但平台未确认时不得完成');
await host.getByTestId('tab-overview').click();
await page.screenshot({ path: path.join(qaDir, '01-waiting-platform.png') });
await page.evaluate(() => {
document.querySelector('app-course-courseware-video').setAttribute('data-is-learned', 'true');
document.querySelector('.menu_courseware_left').textContent = '已学习100秒';
});
await page.waitForFunction(() => YianAssistant.getLearningState().completed === 1);
assert.equal(await page.evaluate(() => YianAssistant.getLearningState().tasks[0].status), 'completed');
await page.waitForFunction(() => YianAssistant.getLearningState().status === 'finished');
await page.screenshot({ path: path.join(qaDir, '02-platform-completed.png') });
await host.getByTestId('tab-video').click();
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(); }
}
main().catch(error => { console.error(error); process.exitCode = 1; });