fix: start course covers and advance verified learning tasks
This commit is contained in:
@@ -0,0 +1,351 @@
|
||||
/* Browser plugin not available. Complete bundle and independently modelled site DOM.
|
||||
* Saved Angular templates supplied the selectors; no archived site scripts run here.
|
||||
* All business routes are fulfilled locally and no learning record reaches the site. */
|
||||
'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 = path.join(os.tmpdir(), 'yianxue-course-entry-qa');
|
||||
fs.mkdirSync(qaDir, { recursive: true });
|
||||
const base = 'http://10.119.67.7/clientcn/';
|
||||
const courseURL = base + 'course/course-entry-workflow';
|
||||
const style = `<style>body{margin:24px;background:#eef2f7;color:#21344d;font:15px/1.5 system-ui}main{max-width:760px}h1{font-size:24px}.pc_course_left_top_header{font-size:23px;font-weight:650}.course-courseware-playdefult{height:260px;margin-top:18px;display:grid;place-items:center;background:#dae5ef;border-radius:10px}.course-courseware-playdefult>span{padding:8px 22px;background:#1697ff;color:white;border-radius:24px;cursor:pointer}li{list-style:none;background:white;padding:14px;border-radius:8px;margin-top:12px}li.active{outline:2px solid #649df0}.cn_courseware_img>p{font-weight:600;margin:0 0 8px}.menu_courseware{display:flex;justify-content:space-between}.menu_courseware_left{display:flex;gap:12px;color:#718093}.en_courseware_finished{display:inline-block;width:15px;height:15px;border-radius:50%;background:#29ab6a}.en_courseware_noFinished{background:#f49821}.related_test{color:#2475d3;cursor:pointer}.completion-tooltip{padding:6px;background:#20324b;color:white;position:absolute;z-index:100}video{width:100%;height:260px;background:#172a49}a{color:#2475d3}[hidden]{display:none!important}</style>`;
|
||||
const cover = '<div class="course-courseware-playdefult"><span>开始学习</span></div>';
|
||||
|
||||
function row(index, { complete = false, active = false, exam = false } = {}) {
|
||||
return '<li class="cn_courseware_img' + (active ? ' active' : '') + '" data-courseware-id="entry-' + index + '"><p><span>' + index + '.</span><span>安全培训视频' + index + '</span></p><div class="menu_courseware"><div class="menu_courseware_left"><div class="cn_courseware_img_icon">视频</div><div>27秒</div></div><div class="menu_courseware_right"><div class="courseware_finished_status"><img class="en_courseware_finished' + (complete ? '' : ' en_courseware_noFinished') + '" alt="' + (complete ? '已完成' : '尚未完成') + '"></div></div></div>' + (exam ? '<div class="related_test_outer"><p class="related_test">入口联动安全课程</p></div>' : '') + '</li>';
|
||||
}
|
||||
|
||||
function documentHTML(body) {
|
||||
return '<!doctype html><html lang="zh-CN"><meta charset="utf-8"><title>谊安学课程入口回归</title>' + style + '<body><h1>课程入口与完成状态</h1><main id="site-view">' + body + '</main><div class="completion-tooltip" hidden></div></body></html>';
|
||||
}
|
||||
function courseHTML(rows, initialCover = cover) {
|
||||
return documentHTML('<app-course><div class="pc_course_left_top_header"><span data-course-title>入口联动安全课程</span><div class="pc_course_left_top_header_info">学习时长:10分钟33秒</div></div><div class="qa-player">' + initialCover + '</div><ol>' + rows + '</ol></app-course>');
|
||||
}
|
||||
|
||||
async function setup(browser, html, options = {}) {
|
||||
const memory = new Map(), requests = [], blocked = [], errors = [];
|
||||
let holdLoadingWrite = false, releaseWrite = null, writeHeld = false;
|
||||
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', async (_, key, value) => {
|
||||
if (holdLoadingWrite && /learning:session\.v1$/.test(key) && value?.tasks?.some(task => task.status === 'loading')) {
|
||||
holdLoadingWrite = false; writeHeld = true;
|
||||
await new Promise(resolve => { releaseWrite = resolve; });
|
||||
}
|
||||
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: html });
|
||||
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(courseURL);
|
||||
assert.equal(page.url(), courseURL); assert.equal(await page.title(), '谊安学课程入口回归');
|
||||
assert(await page.locator('main').innerText(), '页面有真实课程内容');
|
||||
await page.evaluate(({ delay, persistentCover, requirement }) => {
|
||||
window.qaEntry = { coverClicks: 0, wrongClicks: 0, rowClicks: [], playCalls: 0, media: null, exams: 0 };
|
||||
window.qaInstallVideo = () => {
|
||||
document.querySelector('.qa-player').innerHTML = '<app-course-courseware-video><app-video-base><video id="player1" controls></video></app-video-base></app-course-courseware-video>';
|
||||
const media = { currentTime: 27, duration: 100, ended: false, paused: true, playbackRate: 1 };
|
||||
qaEntry.media = media;
|
||||
const video = document.querySelector('video');
|
||||
for (const key of ['currentTime', 'duration', 'ended', 'paused', 'playbackRate']) Object.defineProperty(video, key, { configurable: true, get: () => media[key], set: value => { media[key] = value; } });
|
||||
video.play = async () => { qaEntry.playCalls++; media.paused = false; };
|
||||
video.pause = () => { media.paused = true; };
|
||||
};
|
||||
window.qaWireCover = () => {
|
||||
document.querySelectorAll('.course-courseware-playdefult > span').forEach(button => button.addEventListener('click', () => {
|
||||
qaEntry.coverClicks++;
|
||||
// The site's cover always defaults to the first item, even if already learned.
|
||||
document.querySelectorAll('.cn_courseware_img').forEach((row, index) => row.classList.toggle('active', index === 0));
|
||||
if (!persistentCover) setTimeout(qaInstallVideo, delay);
|
||||
}));
|
||||
document.querySelectorAll('[data-unknown-start]').forEach(button => button.addEventListener('click', () => { qaEntry.wrongClicks++; }));
|
||||
};
|
||||
qaWireCover();
|
||||
document.querySelectorAll('.cn_courseware_img').forEach((node, index) => {
|
||||
node.addEventListener('click', event => {
|
||||
if (event.target.closest('.related_test')) return;
|
||||
qaEntry.rowClicks.push(index + 1);
|
||||
document.querySelectorAll('.cn_courseware_img').forEach(row => row.classList.toggle('active', row === node));
|
||||
// Real li onSelect(item) calls toCourseware directly; it does not show a new cover.
|
||||
qaEntry.media = null;
|
||||
document.querySelector('.qa-player').innerHTML = '<p class="qa-loading">正在打开播放器</p>';
|
||||
if (!persistentCover) setTimeout(qaInstallVideo, delay);
|
||||
});
|
||||
node.querySelector('.en_courseware_finished').addEventListener('mouseover', () => {
|
||||
const tip = document.querySelector('.completion-tooltip');
|
||||
tip.hidden = false; tip.textContent = requirement || '1.观看视频进度100%' + (node.querySelector('.related_test') ? ' 2.完成考试答题,并且及格通过' : '');
|
||||
});
|
||||
node.querySelector('.en_courseware_finished').addEventListener('mouseout', () => { document.querySelector('.completion-tooltip').hidden = true; });
|
||||
});
|
||||
document.querySelectorAll('.related_test').forEach(button => button.addEventListener('click', event => {
|
||||
event.stopPropagation(); qaEntry.exams++;
|
||||
history.pushState({}, '', '/clientcn/exam/entry-linked');
|
||||
document.querySelector('#site-view').innerHTML = '<app-examdetail><div class="examdetail_header">入口联动安全课程 已答0/1</div><div>总考试次数:1 次</div><div class="examdetail_button">开始答题</div></app-examdetail>';
|
||||
document.querySelector('.examdetail_button').onclick = () => { qaEntry.wrongClicks++; };
|
||||
}));
|
||||
window.qaComplete = index => {
|
||||
const node = document.querySelectorAll('.cn_courseware_img')[index - 1];
|
||||
node.querySelector('.en_courseware_finished').classList.remove('en_courseware_noFinished');
|
||||
node.querySelector('.en_courseware_finished').alt = '已完成';
|
||||
};
|
||||
}, { delay: options.delay == null ? 1100 : options.delay, persistentCover: options.persistentCover === true, requirement: options.requirement || '' });
|
||||
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);
|
||||
return { page, context, memory, requests, blocked, errors,
|
||||
holdLoadingWrite() { holdLoadingWrite = true; },
|
||||
async waitForLoadingWrite() {
|
||||
for (let i = 0; i < 100 && !writeHeld; i++) await page.waitForTimeout(50);
|
||||
assert(writeHeld, '应捕获入口点击前等待中的真实GM存储写入');
|
||||
},
|
||||
releaseWrite() { releaseWrite?.(); },
|
||||
async close() {
|
||||
assert.deepEqual(blocked, [], '测试不能访问真实业务接口或归档资源');
|
||||
assert.deepEqual(errors, [], '浏览器无相关错误或警告');
|
||||
assert.equal(await page.locator('vite-error-overlay,nextjs-portal,#webpack-dev-server-client-overlay').count(), 0);
|
||||
await context.close();
|
||||
} };
|
||||
}
|
||||
|
||||
async function configure(page, mode = 'videoQuiz') {
|
||||
await page.evaluate(async mode => { await YianAssistant.setLearningSetting('mode', mode); await YianAssistant.setLearningSetting('answerInterval', 0); }, mode);
|
||||
}
|
||||
async function waitFor(page, predicate, label, timeout = 12000) {
|
||||
await page.waitForFunction(predicate, null, { timeout }).catch(async error => {
|
||||
console.error(label, await page.evaluate(() => ({ entry: qaEntry, learning: YianAssistant.getLearningState() }))); throw error;
|
||||
});
|
||||
}
|
||||
|
||||
(async () => {
|
||||
const browser = await chromium.launch({ channel: process.env.PLAYWRIGHT_CHANNEL || 'chrome', headless: true });
|
||||
try {
|
||||
// The cover receives its click on the child span, exactly as onSelectDefult in the saved template.
|
||||
const entry = await setup(browser, courseHTML(row(1)));
|
||||
await configure(entry.page);
|
||||
await entry.page.waitForTimeout(800);
|
||||
assert.equal(await entry.page.evaluate(() => qaEntry.coverClicks + qaEntry.rowClicks.length), 0, '未授权开始前不点击封面/课程');
|
||||
await entry.page.evaluate(() => YianAssistant.taskAction('start'));
|
||||
await waitFor(entry.page, () => qaEntry.coverClicks === 1 && qaEntry.media && !qaEntry.media.paused, '唯一开始学习入口应被正确点击');
|
||||
await entry.page.waitForTimeout(2200);
|
||||
assert.equal(await entry.page.evaluate(() => qaEntry.coverClicks), 1, '播放器异步出现与重复tick不得重复点封面');
|
||||
assert.deepEqual(await entry.page.evaluate(() => qaEntry.rowClicks), [], '可用的默认封面入口不重复点目录项');
|
||||
await entry.close();
|
||||
console.log('PASS 完整bundle:初始无active时唯一真实封面span点击一次,异步播放器出现并正常播放');
|
||||
|
||||
const flow = await setup(browser, courseHTML(row(1, { complete: true }) + row(2) + row(3)));
|
||||
const { page } = flow;
|
||||
await configure(page);
|
||||
await page.waitForTimeout(800);
|
||||
assert.equal(await page.evaluate(() => qaEntry.coverClicks + qaEntry.rowClicks.length), 0, '未授权开始前不点击封面/课程');
|
||||
await page.evaluate(() => YianAssistant.taskAction('start'));
|
||||
await waitFor(page, () => qaEntry.rowClicks.includes(2) && qaEntry.media && !qaEntry.media.paused, '首课件完成时应选第二目录项');
|
||||
assert(!await page.evaluate(() => qaEntry.rowClicks.includes(1)), '平台已完成课件不能重开');
|
||||
assert.equal(await page.evaluate(() => YianAssistant.getLearningState().completed), 1);
|
||||
await page.waitForTimeout(2200);
|
||||
assert.equal(await page.evaluate(() => qaEntry.coverClicks), 0, '默认封面会打开已完成的第一项,必须直接点击第二目录项');
|
||||
assert.deepEqual(await page.evaluate(() => qaEntry.rowClicks), [2], '播放器异步出现时不得重复点击目录');
|
||||
const host = page.locator('#yian-question-assistant');
|
||||
await host.getByRole('button', { name: '展开题库助手', exact: true }).click();
|
||||
await host.getByTestId('tab-overview').click();
|
||||
await page.screenshot({ path: path.join(qaDir, '01-cover-started-desktop.png') });
|
||||
await page.evaluate(() => qaComplete(2));
|
||||
await waitFor(page, () => qaEntry.rowClicks.includes(3) && qaEntry.media && !qaEntry.media.paused && document.querySelectorAll('.cn_courseware_img')[2].classList.contains('active'), '完成视频二后应自动到视频三');
|
||||
assert.equal(await page.evaluate(() => qaEntry.rowClicks.filter(index => index === 3).length), 1, '下一课件只点击一次');
|
||||
assert.equal(await page.evaluate(() => YianAssistant.getLearningState().completed), 2);
|
||||
await page.evaluate(() => qaComplete(3));
|
||||
await waitFor(page, () => YianAssistant.getLearningState().status === 'finished' && YianAssistant.getLearningState().completed === 3, '全部课件完成应结束当前课件清单');
|
||||
assert.equal(await page.evaluate(() => qaEntry.coverClicks), 0);
|
||||
assert.deepEqual(await page.evaluate(() => qaEntry.rowClicks.filter(index => index === 1)), []);
|
||||
assert.equal(flow.requests.length, 1, '没有已知下课程入口时不能猜测新网址');
|
||||
await page.setViewportSize({ width: 390, height: 844 });
|
||||
await page.screenshot({ path: path.join(qaDir, '02-finished-mobile.png') });
|
||||
await flow.close();
|
||||
console.log('PASS 完整bundle:首项平台完成时直接选择未完成目录,播放器异步只开一次→完成后二到三→全部完成跳过;390px/桌面截图与零业务网络');
|
||||
|
||||
const paused = await setup(browser, courseHTML(row(1)), { persistentCover: true });
|
||||
await configure(paused.page);
|
||||
await paused.page.evaluate(() => YianAssistant.taskAction('start'));
|
||||
await waitFor(paused.page, () => qaEntry.coverClicks === 1, '封面首次点击');
|
||||
await paused.page.evaluate(() => YianAssistant.taskAction('pause'));
|
||||
await paused.page.waitForTimeout(1800);
|
||||
assert.equal(await paused.page.evaluate(() => qaEntry.coverClicks), 1, '全局暂停不能再点开始学习');
|
||||
await paused.page.evaluate(() => { qaInstallVideo(); });
|
||||
await paused.page.waitForTimeout(1400);
|
||||
assert.equal(await paused.page.evaluate(() => qaEntry.playCalls), 0, '暂停后迟到播放器不应启动');
|
||||
await paused.page.evaluate(() => YianAssistant.taskAction('resume'));
|
||||
await waitFor(paused.page, () => qaEntry.playCalls === 1, '明确继续后才播放迟到播放器');
|
||||
await paused.close();
|
||||
console.log('PASS 全局暂停使封面流程停止,异步迟到播放器不播放,明确继续后核验再播放');
|
||||
|
||||
const partial = await setup(browser, courseHTML(row(1)), { persistentCover: true });
|
||||
await configure(partial.page);
|
||||
await partial.page.evaluate(() => YianAssistant.taskAction('start'));
|
||||
await waitFor(partial.page, () => qaEntry.coverClicks === 1, '局部暂停前封面首次点击');
|
||||
await partial.page.evaluate(() => YianAssistant.taskAction('videoPause'));
|
||||
await partial.page.evaluate(() => qaInstallVideo());
|
||||
await partial.page.waitForTimeout(1700);
|
||||
assert.equal(await partial.page.evaluate(() => qaEntry.playCalls), 0, '视频局部暂停期间不启动迟到播放器');
|
||||
assert.equal(await partial.page.evaluate(() => qaEntry.coverClicks), 1);
|
||||
await partial.close();
|
||||
console.log('PASS 视频局部暂停也阻止异步播放器启动和封面重试');
|
||||
|
||||
const localPending = await setup(browser, courseHTML(row(1)));
|
||||
await configure(localPending.page); localPending.holdLoadingWrite();
|
||||
await localPending.page.evaluate(() => { window.qaPendingStart = YianAssistant.taskAction('start'); });
|
||||
await localPending.waitForLoadingWrite();
|
||||
await localPending.page.evaluate(() => YianAssistant.taskAction('videoPause'));
|
||||
localPending.releaseWrite();
|
||||
await localPending.page.evaluate(() => qaPendingStart);
|
||||
await localPending.page.waitForTimeout(700);
|
||||
assert.equal(await localPending.page.evaluate(() => qaEntry.coverClicks + qaEntry.rowClicks.length), 0, '入口存储写入期间局部暂停,迟到的旧动作不得点击封面或目录');
|
||||
assert.equal(await localPending.page.evaluate(() => YianAssistant.getLearningState().videoPaused), true);
|
||||
await localPending.page.evaluate(() => YianAssistant.taskAction('videoResume'));
|
||||
await waitFor(localPending.page, () => qaEntry.coverClicks === 1 && qaEntry.media && !qaEntry.media.paused, '局部继续须重新领取任务后正常点击真实封面');
|
||||
await localPending.page.waitForTimeout(1200);
|
||||
assert.equal(await localPending.page.evaluate(() => qaEntry.coverClicks), 1, '局部继续后仅点击封面一次');
|
||||
assert.deepEqual(await localPending.page.evaluate(() => qaEntry.rowClicks), []);
|
||||
await localPending.close();
|
||||
console.log('PASS GM加载写入等待时局部暂停取消旧点击,局部继续重新领取后真实封面只点一次并播放');
|
||||
|
||||
for (const [name, badCover] of [['未知按钮', '<button data-unknown-start>开始学习</button>'], ['多个封面按钮', cover + cover]]) {
|
||||
const bad = await setup(browser, courseHTML(row(1), badCover));
|
||||
await configure(bad.page); await bad.page.evaluate(() => YianAssistant.taskAction('start'));
|
||||
await bad.page.waitForTimeout(2200);
|
||||
assert.equal(await bad.page.evaluate(() => qaEntry.coverClicks + qaEntry.wrongClicks), 0, name + '必须等待人工确认,不能按文案泛点');
|
||||
await bad.close();
|
||||
}
|
||||
console.log('PASS 未知开始学习按钮与多个真实候选都不猜点');
|
||||
|
||||
const multiRows = row(1, { exam: true }).replace('</p></div></li>', '</p><p class="related_test">另一份安全考核</p></div></li>');
|
||||
const multi = await setup(browser, courseHTML(multiRows));
|
||||
const related = await multi.page.evaluate(() => YianAssistant.getLearningState().tasks.filter(task => task.kind === 'exam'));
|
||||
assert.deepEqual(related.map(task => task.title), ['入口联动安全课程', '另一份安全考核'], '同一课件两个实际关联考试都需保留,不能在子序号加入前被基础id去重');
|
||||
assert.equal(new Set(related.map(task => task.id)).size, 2, '每个关联考试入口有独立任务身份');
|
||||
assert.equal(await multi.page.evaluate(() => qaEntry.exams + qaEntry.coverClicks + qaEntry.rowClicks.length), 0, '只识别多个入口不自动启动');
|
||||
await multi.close();
|
||||
console.log('PASS 同一课件多个实际关联考试分别识别,不遗漏、不重复、不擅自开考');
|
||||
|
||||
const stale = await setup(browser, courseHTML(row(1)), { persistentCover: true });
|
||||
await configure(stale.page); stale.holdLoadingWrite();
|
||||
await stale.page.evaluate(() => { window.qaPendingStart = YianAssistant.taskAction('start'); });
|
||||
await stale.waitForLoadingWrite();
|
||||
await stale.page.evaluate(() => {
|
||||
const old = document.querySelector('app-course');
|
||||
const replacement = old.cloneNode(true);
|
||||
replacement.querySelector('.course-courseware-playdefult > span').onclick = () => { qaEntry.wrongClicks++; };
|
||||
old.replaceWith(replacement);
|
||||
});
|
||||
stale.releaseWrite();
|
||||
await stale.page.evaluate(() => qaPendingStart);
|
||||
await stale.page.evaluate(() => YianAssistant.taskAction('pause'));
|
||||
await stale.page.waitForTimeout(700);
|
||||
assert.equal(await stale.page.evaluate(() => qaEntry.coverClicks + qaEntry.wrongClicks), 0, '同URL同标题的课程根已替换,旧动作不能点击已分离按钮或新封面');
|
||||
await stale.close();
|
||||
console.log('PASS 异步存储写入期间同URL同标题的课程根被替换,旧计划不点击迟到封面');
|
||||
|
||||
const reordered = await setup(browser, courseHTML(row(1) + row(2)), { persistentCover: true });
|
||||
await configure(reordered.page); reordered.holdLoadingWrite();
|
||||
await reordered.page.evaluate(() => { window.qaPendingStart = YianAssistant.taskAction('start'); });
|
||||
await reordered.waitForLoadingWrite();
|
||||
await reordered.page.evaluate(() => {
|
||||
const list = document.querySelector('ol'); list.append(list.firstElementChild);
|
||||
});
|
||||
reordered.releaseWrite();
|
||||
await reordered.page.evaluate(() => qaPendingStart);
|
||||
await reordered.page.evaluate(() => YianAssistant.taskAction('pause'));
|
||||
assert.equal(await reordered.page.evaluate(() => qaEntry.coverClicks + qaEntry.rowClicks.length), 0, '同URL同标题同根的目录顺序已变化,旧索引不能点击封面默认项或错行');
|
||||
await reordered.close();
|
||||
console.log('PASS 异步加载期间目录重排,仍在文档中的旧按钮也不按过期索引点击');
|
||||
|
||||
for (const mode of ['all', 'videoQuiz']) {
|
||||
const combined = await setup(browser, courseHTML(row(1, { exam: true })));
|
||||
await configure(combined.page, mode);
|
||||
await combined.page.evaluate(async () => {
|
||||
const records = [{ course: '入口联动安全课程', type: '单选', question: '课后核验题', options: [{ label: 'A', text: '准确措施' }, { label: 'B', text: '错误措施' }], answer: 'A' }];
|
||||
const bytes = new TextEncoder().encode(JSON.stringify(records));
|
||||
await YianAssistant.importFiles([{ name: '入口联动题库.json', size: bytes.length, arrayBuffer: async () => bytes.buffer }]);
|
||||
await YianAssistant.taskAction('start');
|
||||
});
|
||||
await waitFor(combined.page, () => qaEntry.media && !qaEntry.media.paused, '组合完成条件前视频应正常播放');
|
||||
assert.equal(await combined.page.evaluate(() => qaEntry.exams), 0, '未达到视频进度要求前不进入考试');
|
||||
await combined.page.evaluate(() => { qaEntry.media.currentTime = 100; qaEntry.media.ended = true; });
|
||||
if (mode === 'all') {
|
||||
await waitFor(combined.page, () => qaEntry.exams === 1 && location.pathname === '/clientcn/exam/entry-linked', '视频达到要求后应转同课件实际关联考试');
|
||||
await waitFor(combined.page, () => YianAssistant.getLearningState().tasks.some(task => task.kind === 'exam' && task.status === 'waiting'), '单次机会保护应停在真实考试详情');
|
||||
const state = await combined.page.evaluate(() => YianAssistant.getLearningState());
|
||||
assert.equal(state.tasks.find(task => task.kind === 'video').status, 'waitingExam', '橙色图标仍需考试,不能把整个课件当作平台已完成');
|
||||
assert.equal(state.tasks.find(task => task.kind === 'video').progress < 1 || state.video.platformComplete === false, true, '进度满只表示视频阶段,不能伪报平台完成');
|
||||
assert.notEqual(state.status, 'finished');
|
||||
assert.equal(await combined.page.evaluate(() => qaEntry.wrongClicks), 0, '仅一次机会交给人工,不点击开始答题');
|
||||
} else {
|
||||
await combined.page.waitForTimeout(2400);
|
||||
assert.equal(await combined.page.evaluate(() => qaEntry.exams), 0, 'videoQuiz模式不能扩大授权自动进入课程考试');
|
||||
const state = await combined.page.evaluate(() => YianAssistant.getLearningState());
|
||||
assert.notEqual(state.status, 'finished', '橙图标不能误记完成');
|
||||
assert.equal(state.video.platformComplete, false);
|
||||
assert.match(state.tasks.find(task => task.kind === 'video').reason, /考试|模式|完成条件/);
|
||||
}
|
||||
await combined.close();
|
||||
}
|
||||
console.log('PASS 真实悬浮完成条件:进度满但仍需考试时all让出至唯一关联考试且等待绿图标;videoQuiz保持人工等待,不误报已完成');
|
||||
|
||||
const timed = await setup(browser, courseHTML(row(1, { exam: true })), { requirement: '1.观看课件时长达到1分钟 2.完成考试答题' });
|
||||
await configure(timed.page, 'all');
|
||||
await timed.page.evaluate(async () => {
|
||||
const records = [{ course: '入口联动安全课程', type: '单选', question: '时长要求核验', options: [{ label: 'A', text: '准确措施' }, { label: 'B', text: '错误措施' }], answer: 'A' }];
|
||||
const bytes = new TextEncoder().encode(JSON.stringify(records));
|
||||
await YianAssistant.importFiles([{ name: '时长要求题库.json', size: bytes.length, arrayBuffer: async () => bytes.buffer }]);
|
||||
await YianAssistant.taskAction('start');
|
||||
});
|
||||
await waitFor(timed.page, () => qaEntry.media && !qaEntry.media.paused, '时长要求下正常启动视频');
|
||||
await timed.page.evaluate(() => { qaEntry.media.currentTime = 100; qaEntry.media.ended = true; });
|
||||
await timed.page.waitForTimeout(2200);
|
||||
assert.equal(await timed.page.evaluate(() => qaEntry.exams), 0, '明确要求平台累计1分钟,视频ended不能取代平台27秒记录');
|
||||
assert.equal(await timed.page.evaluate(() => YianAssistant.getLearningState().video.platformComplete), false);
|
||||
await timed.page.evaluate(() => { document.querySelector('.menu_courseware_left > div:last-child').textContent = '60秒'; });
|
||||
await waitFor(timed.page, () => qaEntry.exams === 1, '平台累计达到明确时长后才进入实际关联考试');
|
||||
assert.notEqual(await timed.page.evaluate(() => YianAssistant.getLearningState().status), 'finished', '时长达标仍等待考试及绿色整体完成标记');
|
||||
await timed.close();
|
||||
console.log('PASS 明确观看时长要求使用平台累计记录;仅ended不越过门槛,累计达到1分钟才转关联考试');
|
||||
|
||||
const guarded = await setup(browser, courseHTML(row(1, { exam: true })), { requirement: '1.观看课件时长达到1分钟 2.完成考试答题' });
|
||||
await configure(guarded.page, 'all');
|
||||
await guarded.page.evaluate(async () => {
|
||||
const records = [{ course: '入口联动安全课程', type: '单选', question: '课中核验题', options: [{ label: 'A', text: '准确措施' }, { label: 'B', text: '错误措施' }], answer: 'A' }];
|
||||
const bytes = new TextEncoder().encode(JSON.stringify(records));
|
||||
await YianAssistant.importFiles([{ name: '课中保护题库.json', size: bytes.length, arrayBuffer: async () => bytes.buffer }]);
|
||||
await YianAssistant.taskAction('start');
|
||||
});
|
||||
await waitFor(guarded.page, () => qaEntry.media && !qaEntry.media.paused, '保护场景视频先正常播放');
|
||||
await guarded.page.evaluate(() => {
|
||||
const popup = document.createElement('div'); popup.className = 'question-modal-pc';
|
||||
popup.innerHTML = '<p>剩余答题次数:1次</p><div class="question_box"><div class="examtest_content_question"><p class="show"><span>[单选]</span>课中核验题</p></div><div class="examtest_content_list"><div class="examtest_content_list_des"><span class="examtest_content_list_des_optionsort">A</span>准确措施</div><div class="examtest_content_list_des"><span class="examtest_content_list_des_optionsort">B</span>错误措施</div></div></div>';
|
||||
popup.querySelectorAll('.examtest_content_list_des').forEach(option => { option.onclick = () => { qaEntry.wrongClicks++; }; });
|
||||
document.body.append(popup);
|
||||
qaEntry.media.currentTime = 100; qaEntry.media.ended = true;
|
||||
document.querySelector('.menu_courseware_left > div:last-child').textContent = '60秒';
|
||||
});
|
||||
await waitFor(guarded.page, () => YianAssistant.getLearningState().tasks.some(task => task.kind === 'video' && task.status === 'waiting'), '仅一次课中题先交给人工');
|
||||
await guarded.page.waitForTimeout(1200);
|
||||
assert.equal(await guarded.page.evaluate(() => qaEntry.exams), 0, '课中弹题存在时即使观看时长达标也不越过进入关联考试');
|
||||
assert.equal(await guarded.page.evaluate(() => qaEntry.wrongClicks), 0, '一次机会保护无选项点击');
|
||||
await guarded.page.evaluate(async () => { await YianAssistant.taskAction('pause'); document.querySelector('.question-modal-pc').remove(); });
|
||||
await guarded.page.waitForTimeout(1200);
|
||||
assert.equal(await guarded.page.evaluate(() => qaEntry.exams), 0, '手动暂停优先,弹题消失和时长达标也不跳任务');
|
||||
await guarded.page.evaluate(() => YianAssistant.taskAction('resume'));
|
||||
await waitFor(guarded.page, () => qaEntry.exams === 1, '人工处理课中题并明确继续后才能转实际关联考试');
|
||||
await guarded.close();
|
||||
console.log('PASS 观看达标时课中弹题和暂停优先;一次机会不操作,弹题处理后明确继续才转关联考试');
|
||||
console.log('截图:' + qaDir);
|
||||
} finally { await browser.close(); }
|
||||
})().catch(error => { console.error(error); process.exitCode = 1; });
|
||||
@@ -0,0 +1,303 @@
|
||||
/* Isolated multi-page navigation and retry regression. All site requests are
|
||||
* fulfilled locally; the archived site's scripts and business APIs never run. */
|
||||
'use strict';
|
||||
const assert = require('node:assert/strict');
|
||||
const fs = require('node:fs');
|
||||
const path = require('node:path');
|
||||
const { chromium } = require('playwright');
|
||||
|
||||
const source = fs.readFileSync(path.join(__dirname, '..', 'learning-engine.js'), 'utf8');
|
||||
const origin = 'http://10.119.67.7/clientcn/';
|
||||
|
||||
async function main() {
|
||||
const browser = await chromium.launch({ channel: process.env.PLAYWRIGHT_CHANNEL || 'chrome', headless: true });
|
||||
const context = await browser.newContext();
|
||||
const blocked = [], errors = [];
|
||||
await context.route('**/*', route => {
|
||||
if (route.request().isNavigationRequest() && route.request().url().startsWith(origin)) return route.fulfill({ status: 200, contentType: 'text/html', body: '<!doctype html><meta charset="utf-8"><body></body>' });
|
||||
blocked.push(route.request().url()); return route.abort();
|
||||
});
|
||||
const page = await context.newPage();
|
||||
page.on('pageerror', error => errors.push(error.message));
|
||||
await page.goto(origin + 'learningmap/navigation-fixture');
|
||||
await page.addScriptTag({ content: source });
|
||||
let checks = 0;
|
||||
try {
|
||||
await page.evaluate(async () => {
|
||||
window.qaClock = 100000; window.qaMemory = new Map(); window.qaRequests = []; window.qaPendingNavigation = '';
|
||||
window.qaCourseClicks = [0, 0]; window.qaPlayCalls = 0;
|
||||
window.qaMapURL = location.href;
|
||||
window.qaStorage = { get: async key => qaMemory.has(key) ? structuredClone(qaMemory.get(key)) : null,
|
||||
set: async (key, value) => { qaMemory.set(key, structuredClone(value)); } };
|
||||
window.qaRenderMap = () => {
|
||||
document.body.innerHTML = '<app-learning-map-activity-list>' + ['一', '二'].map((name, index) =>
|
||||
'<div class="activity_content" data-course-id="course-' + index + '"><div class="map_activity_content_info_top"><div class="activity_name">课程' + name + '</div><div class="acitvity_other_info">必修课程</div></div></div>').join('') + '</app-learning-map-activity-list>';
|
||||
document.querySelectorAll('.map_activity_content_info_top').forEach((node, index) => {
|
||||
node.onclick = () => { qaCourseClicks[index]++; history.pushState({}, '', '/clientcn/course/map-course-' + index); qaRenderCourse(index); };
|
||||
});
|
||||
};
|
||||
window.qaRenderCourse = index => {
|
||||
document.body.innerHTML = '<app-course><div data-course-title>课程' + (index ? '二' : '一') + '</div><li class="cn_courseware_img active" data-courseware-id="map-video-' + index + '" data-kind="video"' + (index ? '' : ' data-is-learned="true"') + '><p>视频' + (index ? '二' : '一') + '</p><div class="cn_courseware_img_icon">视频</div><div class="menu_courseware_left">已学习20秒</div></li><app-course-courseware-video><video></video></app-course-courseware-video></app-course>';
|
||||
const media = { currentTime: 20, duration: 100, ended: false, paused: true, playbackRate: 1 };
|
||||
const video = document.querySelector('video');
|
||||
for (const key of Object.keys(media)) Object.defineProperty(video, key, { configurable: true, get: () => media[key], set: value => { media[key] = value; } });
|
||||
video.play = async () => { qaPlayCalls++; media.paused = false; };
|
||||
video.pause = () => { media.paused = true; };
|
||||
};
|
||||
window.qaFlushNavigation = () => {
|
||||
if (!qaPendingNavigation) return false;
|
||||
history.pushState({}, '', qaPendingNavigation); qaPendingNavigation = ''; qaRenderMap(); return true;
|
||||
};
|
||||
qaRenderMap();
|
||||
window.qaEngine = YianLearningEngine.create({ storage: qaStorage, now: () => qaClock,
|
||||
getSnapshot: () => ({ document, url: location.href, planId: 'navigation-fixture', accountId: 'fixture-account' }),
|
||||
navigate: async url => { qaRequests.push(url); qaPendingNavigation = url; } });
|
||||
await qaEngine.configure({ mode: 'video', execution: 'sequential' }); await qaEngine.start();
|
||||
});
|
||||
assert.equal(await page.evaluate(() => qaCourseClicks[0]), 1, '地图原始课程入口被点击一次');
|
||||
await page.evaluate(() => qaEngine.tick());
|
||||
const beforeReturn = await page.evaluate(() => ({ state: qaEngine.getState(), url: location.href, requests: qaRequests, pending: qaPendingNavigation }));
|
||||
assert.equal(beforeReturn.requests[0], origin + 'learningmap/navigation-fixture', '下一课程无 href 时返回访问过的原地图地址');
|
||||
assert.equal(beforeReturn.pending, beforeReturn.requests[0]);
|
||||
assert.match(beforeReturn.url, /\/course\/map-course-0$/, '异步导航生效前仍显示旧课程');
|
||||
assert.equal(beforeReturn.state.tasks.find(task => task.title === '视频一').status, 'completed', '课程一的实际平台完成状态被识别');
|
||||
checks++;
|
||||
|
||||
for (let i = 0; i < 3; i++) await page.evaluate(() => qaEngine.tick());
|
||||
const stale = await page.evaluate(() => ({ tasks: qaEngine.getState().tasks, clicks: qaCourseClicks }));
|
||||
const secondCourse = stale.tasks.find(task => task.kind === 'course' && task.title === '课程二');
|
||||
assert(secondCourse, '保留下一课程任务');
|
||||
assert(!['expanded', 'completed'].includes(secondCourse.status), '旧课程仍显示时不能误展开或完成下一课程');
|
||||
assert(!stale.tasks.some(task => task.parentCourse === secondCourse.id), '旧课程课件不能归到下一课程');
|
||||
assert.deepEqual(stale.clicks, [1, 0], '未到地图前不点击错误课程');
|
||||
checks++;
|
||||
|
||||
assert.equal(await page.evaluate(() => qaFlushNavigation()), true);
|
||||
for (let i = 0; i < 8; i++) {
|
||||
await page.evaluate(() => qaEngine.tick());
|
||||
if (await page.evaluate(() => qaEngine.getState().video?.title === '视频二')) break;
|
||||
}
|
||||
const next = await page.evaluate(() => ({ state: qaEngine.getState(), url: location.href, clicks: qaCourseClicks, playCalls: qaPlayCalls }));
|
||||
assert.deepEqual(next.clicks, [1, 1], '回地图后自动点击第二课程一次');
|
||||
assert.equal(next.url, origin + 'course/map-course-1');
|
||||
assert.equal(next.state.video?.title, '视频二', '第二课程的实际视频自动进入调度');
|
||||
assert.equal(next.state.video?.status, 'playing');
|
||||
assert(next.playCalls >= 1);
|
||||
assert.equal(next.state.tasks.filter(task => task.title === '视频一').length, 1, '回地图不会重复添加旧课件');
|
||||
checks++;
|
||||
|
||||
// A timed-out task can be retried explicitly. The retry must click the real
|
||||
// cover span again and receive a fresh loading window, without resetting
|
||||
// platform progress or the bounded automatic-recovery counter.
|
||||
await page.evaluate(async () => {
|
||||
qaEngine.destroy(); await new Promise(resolve => setTimeout(resolve, 0)); qaMemory.clear();
|
||||
history.replaceState({}, '', '/clientcn/course/retry-loading-fixture');
|
||||
document.body.innerHTML = '<app-course><div data-course-title>重试课程</div><div class="course-courseware-playdefult"><span>开始学习</span></div><li class="cn_courseware_img active" data-courseware-id="retry-video" data-kind="video"><p>重试视频</p><div class="cn_courseware_img_icon">视频</div></li></app-course>';
|
||||
window.qaCoverClicks = 0; document.querySelector('.course-courseware-playdefult > span').onclick = () => { qaCoverClicks++; };
|
||||
qaEngine = YianLearningEngine.create({ storage: qaStorage, now: () => qaClock,
|
||||
getSnapshot: () => ({ document, url: location.href, accountId: 'fixture-account' }) });
|
||||
await qaEngine.configure({ mode: 'video' }); await qaEngine.start();
|
||||
});
|
||||
assert.equal(await page.evaluate(() => qaCoverClicks), 1, '首次正常点击实际封面 span');
|
||||
await page.evaluate(async () => { qaClock += 16001; await qaEngine.tick(); });
|
||||
assert.equal(await page.evaluate(() => qaEngine.getState().tasks.find(task => task.kind === 'video').status), 'waiting');
|
||||
const retry = await page.evaluate(async () => { await qaEngine.resume(); return { clicks: qaCoverClicks, state: qaEngine.getState() }; });
|
||||
assert.equal(retry.clicks, 2, '明确继续后再次点击真实封面入口');
|
||||
assert.notEqual(retry.state.tasks.find(task => task.kind === 'video').status, 'waiting', '旧 openedAt 不能让新重试立即超时');
|
||||
await page.evaluate(async () => { qaClock += 14999; await qaEngine.tick(); });
|
||||
assert.notEqual(await page.evaluate(() => qaEngine.getState().tasks.find(task => task.kind === 'video').status), 'waiting', '本次加载有独立的 15 秒等待窗口');
|
||||
await page.evaluate(async () => { qaClock += 1002; await qaEngine.tick(); });
|
||||
assert.equal(await page.evaluate(() => qaEngine.getState().tasks.find(task => task.kind === 'video').status), 'waiting');
|
||||
assert.equal(await page.evaluate(() => qaCoverClicks), 2, '超时期间不循环点击封面');
|
||||
checks++;
|
||||
|
||||
// Display names are not resource identity. While navigation back to the
|
||||
// map is pending, the old course has exactly the same name as the next.
|
||||
await page.evaluate(async () => {
|
||||
qaEngine.destroy(); await new Promise(resolve => setTimeout(resolve, 0)); qaMemory.clear();
|
||||
history.replaceState({}, '', '/clientcn/learningmap/same-title-fixture');
|
||||
window.qaSameMapURL = location.href; window.qaSamePending = ''; window.qaSameClicks = [0, 0];
|
||||
window.qaRenderSameMap = () => {
|
||||
document.body.innerHTML = '<app-learning-map-activity-list>' + ['resource-first', 'resource-second'].map(resource =>
|
||||
'<div class="activity_content" data-course-id="' + resource + '"><div class="map_activity_content_info_top"><div class="activity_name">同名课程</div><div class="acitvity_other_info">必修课程</div></div></div>').join('') + '</app-learning-map-activity-list>';
|
||||
document.querySelectorAll('.map_activity_content_info_top').forEach((node, index) => {
|
||||
node.onclick = () => {
|
||||
qaSameClicks[index]++; history.pushState({}, '', '/clientcn/course/resource?resourceId=' + (index ? 'resource-second' : 'resource-first'));
|
||||
qaRenderCourse(index); document.querySelector('[data-course-title]').textContent = '同名课程';
|
||||
};
|
||||
});
|
||||
};
|
||||
qaRenderSameMap();
|
||||
qaEngine = YianLearningEngine.create({ storage: qaStorage, now: () => qaClock,
|
||||
getSnapshot: () => ({ document, url: location.href, planId: 'same-title-fixture', accountId: 'fixture-account' }),
|
||||
navigate: async url => { qaSamePending = url; } });
|
||||
await qaEngine.configure({ mode: 'video', execution: 'sequential' }); await qaEngine.start(); await qaEngine.tick();
|
||||
});
|
||||
assert.equal(await page.evaluate(() => qaSamePending), origin + 'learningmap/same-title-fixture');
|
||||
for (let i = 0; i < 3; i++) await page.evaluate(() => qaEngine.tick());
|
||||
const sameTitleStale = await page.evaluate(() => ({ state: qaEngine.getState(), url: location.href, clicks: qaSameClicks }));
|
||||
const sameTitleSecond = sameTitleStale.state.tasks.find(task => task.kind === 'course' && task.identity === 'resource-second');
|
||||
assert(sameTitleSecond, '保留相同名称但资源不同的第二课程');
|
||||
assert(!['expanded', 'completed'].includes(sameTitleSecond.status), '同名旧课程不能冒充尚未点击入口的下一资源');
|
||||
assert(!sameTitleStale.state.tasks.some(task => task.parentCourse === sameTitleSecond.id), '同名旧课件不能挂到第二资源');
|
||||
assert.equal(new URL(sameTitleStale.url).searchParams.get('resourceId'), 'resource-first');
|
||||
assert.deepEqual(sameTitleStale.clicks, [1, 0]);
|
||||
await page.evaluate(() => { history.pushState({}, '', qaSamePending); qaSamePending = ''; qaRenderSameMap(); });
|
||||
for (let i = 0; i < 8; i++) {
|
||||
await page.evaluate(() => qaEngine.tick());
|
||||
if (await page.evaluate(() => qaEngine.getState().video?.title === '视频二')) break;
|
||||
}
|
||||
const sameTitleNext = await page.evaluate(() => ({ state: qaEngine.getState(), url: location.href, clicks: qaSameClicks }));
|
||||
assert.deepEqual(sameTitleNext.clicks, [1, 1]);
|
||||
assert.equal(new URL(sameTitleNext.url).searchParams.get('resourceId'), 'resource-second', '第二资源只能在原地图入口点击后进入');
|
||||
assert.equal(sameTitleNext.state.video?.title, '视频二');
|
||||
assert.equal(sameTitleNext.state.video?.status, 'playing');
|
||||
checks++;
|
||||
|
||||
// A worker reads its task checkpoint after restoring the shared session.
|
||||
// Its old checkpoint must not overwrite the fresh page's loading window.
|
||||
const workerRestore = await page.evaluate(async () => {
|
||||
qaEngine.destroy(); await new Promise(resolve => setTimeout(resolve, 0));
|
||||
const saved = await qaStorage.get('session.v1'); qaMemory.clear();
|
||||
const workerURL = 'http://10.119.67.7/clientcn/course/worker-loading-fixture';
|
||||
const nonce = 'checkpoint-window-fixture'; history.replaceState({}, '', workerURL + '#__yian_worker=' + nonce);
|
||||
document.body.innerHTML = '<app-course><div data-course-title>工作页课程</div><div class="course-courseware-playdefult"><span>开始学习</span></div><li class="cn_courseware_img active" data-courseware-id="worker-video" data-kind="video"><p>工作页视频</p><div class="cn_courseware_img_icon">视频</div></li></app-course>';
|
||||
window.qaWorkerCoverClicks = 0; document.querySelector('.course-courseware-playdefult > span').onclick = () => { qaWorkerCoverClicks++; };
|
||||
const task = YianLearningEngine.discoverTasks(document, workerURL).find(item => item.kind === 'video');
|
||||
Object.assign(task, { status: 'loading', openedAt: qaClock, retryCount: 2, lastRefreshAt: qaClock - 60000 });
|
||||
Object.assign(saved, { status: 'running', tasks: [task], activeTaskId: task.id, accountId: 'fixture-account', planId: '', bankVersion: null, config: Object.assign({}, saved.config, { mode: 'video', execution: 'sequential' }) });
|
||||
await qaStorage.set('session.v1', saved);
|
||||
await qaStorage.set('control.v1', { sessionId: saved.id, status: 'running', config: saved.config });
|
||||
await qaStorage.set('worker:' + nonce, { nonce, sessionId: saved.id, taskId: task.id, url: workerURL, expires: qaClock + 30000, owner: 'old-worker' });
|
||||
await qaStorage.set('task:' + saved.id + ':' + task.id, Object.assign({}, task, { openedAt: qaClock - 90000, coverClickedAt: qaClock - 90000 }));
|
||||
qaEngine = YianLearningEngine.create({ ownerId: 'checkpoint-worker', storage: qaStorage, now: () => qaClock,
|
||||
getSnapshot: () => ({ document, url: location.href, accountId: 'fixture-account' }) });
|
||||
await qaEngine.tick();
|
||||
return { state: qaEngine.getState(), clicks: qaWorkerCoverClicks, worker: await qaStorage.get('worker:' + nonce) };
|
||||
});
|
||||
assert.equal(workerRestore.worker.owner, 'checkpoint-worker', '本例确实恢复受控工作页');
|
||||
assert.equal(workerRestore.state.status, 'running');
|
||||
assert.equal(workerRestore.clicks, 1, 'checkpoint里的旧coverClickedAt不能阻止新工作页点击真实span');
|
||||
assert.notEqual(workerRestore.state.tasks[0].status, 'waiting', '工作页checkpoint读取后仍有新加载窗口');
|
||||
assert.equal(workerRestore.state.tasks[0].retryCount, 2, '页面恢复不清空自动恢复次数');
|
||||
await page.evaluate(async () => { qaClock += 14999; await qaEngine.tick(); });
|
||||
assert.notEqual(await page.evaluate(() => qaEngine.getState().tasks[0].status), 'waiting');
|
||||
await page.evaluate(async () => { qaClock += 1002; await qaEngine.tick(); });
|
||||
assert.equal(await page.evaluate(() => qaEngine.getState().tasks[0].status), 'waiting');
|
||||
assert.equal(await page.evaluate(() => qaWorkerCoverClicks), 1, '工作页新加载窗口内只点一次封面');
|
||||
checks++;
|
||||
|
||||
// Full coordinator chain: video requirements include passing its related
|
||||
// exam, so an ended video is handed off and only the later green website
|
||||
// icon completes the course. Navigation remains asynchronous throughout.
|
||||
await page.evaluate(async () => {
|
||||
qaEngine.destroy(); await new Promise(resolve => setTimeout(resolve, 0)); qaMemory.clear();
|
||||
history.replaceState({}, '', '/clientcn/learningmap/exam-handoff-fixture');
|
||||
window.qaFull = { courseClicks: [0, 0], relatedClicks: 0, enterClicks: 0, coverClicks: 0, firstPlayCalls: 0, examStarts: 0, returns: 0, pending: '', navigations: [], examState: { stage: 'answering', total: 1, verifiedCount: 0 } };
|
||||
window.qaFullURLs = { map: location.href, first: 'http://10.119.67.7/clientcn/course/handoff-first', second: 'http://10.119.67.7/clientcn/course/handoff-second', detail: 'http://10.119.67.7/clientcn/exam/handoff-exam', paper: 'http://10.119.67.7/clientcn/exam/handoff-exam/questions?flag=test' };
|
||||
window.qaFullMap = () => {
|
||||
document.body.innerHTML = '<app-learning-map-activity-list>' + ['联动课程', '后续课程'].map((title, index) => '<div class="activity_content" data-course-id="handoff-course-' + index + '"><div class="map_activity_content_info_top"><div class="activity_name">' + title + '</div><div class="acitvity_other_info">必修课程</div></div></div>').join('') + '</app-learning-map-activity-list>';
|
||||
document.querySelectorAll('.map_activity_content_info_top').forEach((node, index) => { node.onclick = () => {
|
||||
qaFull.courseClicks[index]++; history.pushState({}, '', index ? qaFullURLs.second : qaFullURLs.first); qaFullCourse(index, false);
|
||||
}; });
|
||||
};
|
||||
window.qaFullCourse = (index, returning) => {
|
||||
const title = index ? '后续课程' : '联动课程';
|
||||
document.body.innerHTML = '<app-course><div data-course-title>' + title + '</div><div class="qa-full-player">' + (returning ? '<div class="course-courseware-playdefult"><span>开始学习</span></div>' : '<app-course-courseware-video><video></video></app-course-courseware-video>') + '</div><li class="cn_courseware_img active" data-courseware-id="handoff-video-' + index + '" data-kind="video"><p>' + (index ? '后续视频' : '联动视频') + '</p><div class="cn_courseware_img_icon">视频</div><div class="menu_courseware_left">已学习' + (index ? '20' : '100') + '秒</div><div class="courseware_finished_status"><span class="en_courseware_finished en_courseware_noFinished">尚未完成</span></div>' + (index ? '' : '<p class="related_test">联动课程</p>') + '</li></app-course><div class="completion-tooltip" hidden></div>';
|
||||
const icon = document.querySelector('.en_courseware_noFinished'), tip = document.querySelector('.completion-tooltip');
|
||||
icon.onmouseover = () => { tip.textContent = index ? '1.观看视频进度100%' : '1.观看视频进度100% 2.完成考试答题,并且及格通过'; tip.hidden = false; };
|
||||
icon.onmouseout = () => { tip.hidden = true; };
|
||||
const cover = document.querySelector('.course-courseware-playdefult > span'); if (cover) cover.onclick = () => { qaFull.coverClicks++; };
|
||||
const video = document.querySelector('video');
|
||||
if (video) {
|
||||
const media = { currentTime: index ? 20 : 100, duration: 100, ended: !index, paused: true, playbackRate: 1 };
|
||||
for (const key of Object.keys(media)) Object.defineProperty(video, key, { configurable: true, get: () => media[key], set: value => { media[key] = value; } });
|
||||
video.play = async () => { if (!index) qaFull.firstPlayCalls++; media.paused = false; }; video.pause = () => { media.paused = true; };
|
||||
}
|
||||
const related = document.querySelector('.related_test'); if (related) related.onclick = event => {
|
||||
event.stopPropagation(); qaFull.relatedClicks++; history.pushState({}, '', qaFullURLs.detail);
|
||||
document.body.innerHTML = '<app-examdetail><div class="examdetail_header">联动课程 已答0/1</div><p>总考试次数:3 次</p><div class="examdetail_button">开始答题</div></app-examdetail>';
|
||||
document.querySelector('.examdetail_button').onclick = () => { qaFull.enterClicks++; history.pushState({}, '', qaFullURLs.paper); document.body.innerHTML = '<app-examtest><div class="pc_examtest_title">联动课程</div></app-examtest>'; };
|
||||
};
|
||||
};
|
||||
window.qaFullFlush = () => {
|
||||
if (!qaFull.pending) return false;
|
||||
const url = qaFull.pending; qaFull.pending = ''; history.pushState({}, '', url);
|
||||
if (url === qaFullURLs.map) qaFullMap();
|
||||
else if (url === qaFullURLs.first) { qaFull.returns++; qaFullCourse(0, true); }
|
||||
else throw new Error('Unexpected fixture destination: ' + url);
|
||||
return true;
|
||||
};
|
||||
qaFullMap();
|
||||
qaEngine = YianLearningEngine.create({ storage: qaStorage, now: () => qaClock,
|
||||
getSnapshot: () => ({ document, url: location.href, planId: 'exam-handoff-fixture', accountId: 'fixture-account', courses: ['联动课程'] }),
|
||||
getExamState: () => qaFull.examState, onExamStart: async () => { qaFull.examStarts++; return true; },
|
||||
navigate: async url => { qaFull.navigations.push(url); qaFull.pending = url; } });
|
||||
await qaEngine.configure({ mode: 'all', execution: 'sequential' }); await qaEngine.start();
|
||||
});
|
||||
for (let i = 0; i < 8; i++) {
|
||||
await page.evaluate(() => qaEngine.tick());
|
||||
if (await page.evaluate(() => qaEngine.getState().tasks.some(task => task.title === '联动视频' && task.status === 'waitingExam'))) break;
|
||||
}
|
||||
const handoff = await page.evaluate(() => ({ state: qaEngine.getState(), flow: qaFull }));
|
||||
assert.equal(handoff.state.tasks.find(task => task.title === '联动视频').status, 'waitingExam', '结束视频按网页提示转考试,不能提前计完成');
|
||||
assert.equal(handoff.state.video?.platformComplete, false);
|
||||
assert.equal(handoff.flow.relatedClicks, 0, '交接阶段保留视频与考试独立状态');
|
||||
for (let i = 0; i < 8; i++) {
|
||||
await page.evaluate(() => qaEngine.tick());
|
||||
if (await page.evaluate(() => qaFull.examStarts === 1)) break;
|
||||
}
|
||||
const paper = await page.evaluate(() => ({ state: qaEngine.getState(), flow: qaFull, url: location.href }));
|
||||
assert.equal(paper.flow.relatedClicks, 1, '仅点击一次真实关联考试入口');
|
||||
assert.equal(paper.flow.enterClicks, 1, '仅通过正常详情开考按钮进入试卷');
|
||||
assert.equal(paper.flow.examStarts, 1);
|
||||
assert.equal(paper.url, origin + 'exam/handoff-exam/questions?flag=test');
|
||||
assert.equal(paper.state.tasks.filter(task => task.kind === 'exam').length, 1, '目录、详情和试卷为同一场考试');
|
||||
await page.evaluate(() => { qaFull.examState = { stage: 'completed', total: 1, verifiedCount: 1, submitted: true, confirmed: true }; });
|
||||
for (let i = 0; i < 8; i++) {
|
||||
await page.evaluate(() => qaEngine.tick());
|
||||
if (await page.evaluate(() => qaFull.pending === qaFullURLs.first)) break;
|
||||
}
|
||||
assert.equal(await page.evaluate(() => qaFull.pending), origin + 'course/handoff-first', '考试完成后返回实际原课程核对网站完成标记');
|
||||
for (let i = 0; i < 2; i++) await page.evaluate(() => qaEngine.tick());
|
||||
const stillPaper = await page.evaluate(() => ({ state: qaEngine.getState(), url: location.href, flow: qaFull }));
|
||||
assert.equal(stillPaper.url, origin + 'exam/handoff-exam/questions?flag=test');
|
||||
assert.notEqual(stillPaper.state.tasks.find(task => task.title === '联动视频').status, 'waitingPlatform', '异步返回尚未生效时不能在考试页假装等待课程完成');
|
||||
assert.equal(stillPaper.flow.coverClicks, 0);
|
||||
assert.equal(await page.evaluate(() => qaFullFlush()), true);
|
||||
await page.evaluate(() => qaEngine.tick());
|
||||
const returned = await page.evaluate(() => ({ state: qaEngine.getState(), flow: qaFull }));
|
||||
assert.equal(returned.state.tasks.find(task => task.title === '联动视频').status, 'waitingPlatform');
|
||||
assert.equal(returned.flow.returns, 1);
|
||||
assert.equal(returned.flow.coverClicks, 0, '原课程封面出现时不重播已经到末尾的视频');
|
||||
await page.evaluate(() => { const icon = document.querySelector('.en_courseware_noFinished'); icon.classList.remove('en_courseware_noFinished'); icon.textContent = '已完成'; });
|
||||
for (let i = 0; i < 8; i++) {
|
||||
await page.evaluate(() => qaEngine.tick());
|
||||
if (await page.evaluate(() => qaFull.pending === qaFullURLs.map)) break;
|
||||
}
|
||||
assert.equal(await page.evaluate(() => qaFull.pending), origin + 'learningmap/exam-handoff-fixture');
|
||||
assert.equal(await page.evaluate(() => qaEngine.getState().tasks.find(task => task.title === '联动视频').status), 'completed', '仅真实绿色完成图标才完成原视频任务');
|
||||
assert.equal(await page.evaluate(() => qaFullFlush()), true);
|
||||
for (let i = 0; i < 8; i++) {
|
||||
await page.evaluate(() => qaEngine.tick());
|
||||
if (await page.evaluate(() => qaEngine.getState().video?.title === '后续视频')) break;
|
||||
}
|
||||
const full = await page.evaluate(() => ({ state: qaEngine.getState(), flow: qaFull, url: location.href }));
|
||||
assert.deepEqual(full.flow.courseClicks, [1, 1]); assert.equal(full.flow.coverClicks, 0);
|
||||
assert.equal(full.flow.relatedClicks, 1); assert.equal(full.flow.enterClicks, 1); assert.equal(full.flow.examStarts, 1);
|
||||
assert.equal(full.state.tasks.filter(task => task.kind === 'exam').length, 1);
|
||||
assert.equal(full.state.tasks.find(task => task.kind === 'exam').status, 'completed');
|
||||
assert.equal(full.state.tasks.find(task => task.title === '联动视频').status, 'completed');
|
||||
assert.equal(full.url, origin + 'course/handoff-second');
|
||||
assert.equal(full.state.video?.title, '后续视频'); assert.equal(full.state.video?.status, 'playing');
|
||||
checks++;
|
||||
|
||||
assert.deepEqual(blocked, []); assert.deepEqual(errors, []);
|
||||
console.log('course-map-navigation: ' + checks + ' checks passed (map return, resource identity, fresh retry and worker checkpoint windows, video/exam/green-icon/next-course chain; no business requests)');
|
||||
} finally {
|
||||
await page.evaluate(() => window.qaEngine?.destroy()).catch(() => {});
|
||||
await context.close(); await browser.close();
|
||||
}
|
||||
}
|
||||
|
||||
main().catch(error => { console.error(error); process.exitCode = 1; });
|
||||
@@ -165,7 +165,7 @@ 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('人工保护仍生效')));
|
||||
assert(await host.getByTestId('video-mode-note').textContent().then(text => text.includes('目录完成条件') && 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);
|
||||
|
||||
+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', 'attempt-policy.cjs', 'diagnostic-logger.cjs', 'learning-engine.cjs', 'lease-recovery.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', 'diagnostics-workflow.cjs']) {
|
||||
for (const file of ['bank-engine.cjs', 'unified-bank.cjs', 'runner-browser.cjs', 'verify.cjs', 'attempt-policy.cjs', 'diagnostic-logger.cjs', 'learning-engine.cjs', 'lease-recovery.cjs', 'course-map-navigation.cjs', 'course-entry-workflow.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', 'diagnostics-workflow.cjs']) {
|
||||
const result = spawnSync(process.execPath, [path.join(__dirname, file)], { stdio: 'inherit' });
|
||||
if (result.status !== 0) process.exit(result.status || 1);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user