feat: add unified learning workflows and six-tab assistant
This commit is contained in:
@@ -0,0 +1,144 @@
|
||||
/* 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>';
|
||||
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);
|
||||
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);
|
||||
console.log('PASS 完整 bundle:无岗位题库导入、videoQuiz 调度、独立播放器真实 2X 状态');
|
||||
|
||||
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 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);
|
||||
console.log('PASS 视频末尾等待平台;明确 data-is-learned 后完成任务并更新面板;零业务网络/刷新、无控制台错误');
|
||||
console.log('截图:' + qaDir);
|
||||
} finally { await browser.close(); }
|
||||
}
|
||||
main().catch(error => { console.error(error); process.exitCode = 1; });
|
||||
Reference in New Issue
Block a user