/* 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 = '视频完成后关联考试验证
' + course + '
  • 1.公司安全理念、方针及目标培训

  • '; (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(//gi, '').replace(//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) => '

    [单选] ' + (i + 1) + '. 关联考试核验题' + i + '(4分)

    A错误操作' + i + '
    B正确措施' + i + '
    ').join(''); document.querySelector('#site-view').innerHTML = '
    ' + course + '
    ' + questions + '
    ' + Array.from({ length: 25 }, (_, i) => '
    ' + (i + 1) + '
    ').join('') + '
    00:30:00
    交卷
    '; 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 = '
    ' + course + ' 已答0/25
    总考试次数:3 次
    开始答题
    '; 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; });