Files
yianxue-assistant/tests/video-exam-workflow.cjs
T

126 lines
13 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/* 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; });