/* Template-backed courseware popups with independent selection/grade models. */ '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 rootDir = path.resolve(__dirname, '..'); (async () => { const savedZip = path.join(rootDir, '谊安学网页.zip'); if (fs.existsSync(savedZip)) { const zip = await JSZip.loadAsync(fs.readFileSync(savedZip)); const bundle = Object.values(zip.files).find(file => /yianxue\/01\/.*\/5-es2015\./.test(file.name)); assert(bundle, '保存网页应包含课中题模板所在 bundle'); const source = await bundle.async('string'); for (const evidence of ['selectors:[["app-question-modal"]]', 'question-modal-pc', 'question_box', 'answer_box', 'examtest_content_question', 'examtest_content_list_des_optionsort', 'examtest_content_list_des_checked', 'doCheck(e,t)', 'this.getAnswer(s,n,a,r),this.showAnswerDialog()', 'this.continuePlay.emit(!0)', '查看答案', '回答正确', '下一题', '关闭']) { assert(source.includes(evidence), '真实模板证据缺少:' + evidence); } console.log('PASS 保存 ZIP 静态确认 app-question-modal、PC overlay、文字/选中状态、判分及继续播放模板;未执行原脚本'); } const browser = await chromium.launch({ channel: process.env.PLAYWRIGHT_CHANNEL || 'chrome', headless: true }); const requests = [], errors = []; try { const context = await browser.newContext({ viewport: { width: 1000, height: 800 } }); await context.route('**/*', route => { requests.push(route.request().url()); return route.abort(); }); const page = await context.newPage(); page.on('pageerror', error => errors.push(error.message)); for (const name of ['question-bank-core.js', 'bank-store.js', 'answer-matcher.js', 'attempt-policy.js', 'quiz-adapter.js']) await page.addScriptTag({ content: fs.readFileSync(path.join(rootDir, name), 'utf8') }); async function setup(config = {}) { await page.evaluate(config => { document.body.innerHTML = '
'; window.qaActive = true; window.qaAbort = new AbortController(); window.qaModel = { optionClicks: [], submits: 0, advances: 0, grading: false, index: 0, selected: [], config }; const single = { type: '单选', question: '压力达到 2 MPa 时应该怎样操作?', bank: ['保持 MPa 单位并核对', '切换 mPa 单位', '任意操作'], page: ['任意操作', '切换 mPa 单位', '保持 MPa 单位并核对'], wanted: ['保持 MPa 单位并核对'] }; const multi = { type: '多选', question: '下列哪些属于准确的完整措施?', bank: ['措施甲(2)', '措施乙', '错误措施'], page: ['错误措施', '措施乙', '措施甲(2)'], wanted: ['措施甲(2)', '措施乙'] }; const judge = { type: '判断', question: 'MPa 与 mPa 应当区分。', bank: ['对', '错'], page: ['错', '对'], wanted: ['对'] }; window.qaQuestions = config.multiple ? [single, multi, judge] : [config.type === 'multi' ? multi : config.type === 'judge' ? judge : single]; window.qaSnapshot = { revision: 1, records: qaQuestions.map((q, i) => YianBankStore.validateRecord({ role: '', course: '安全课中题', number: i + 1, type: q.type, question: q.question, options: q.bank.map((text, index) => ({ label: String.fromCharCode(65 + index), text })), answer: q.wanted.map(text => String.fromCharCode(65 + q.bank.indexOf(text))).join('') })) }; if (config.conflict) qaSnapshot.records.push(YianBankStore.validateRecord({ ...qaSnapshot.records[0], course: '安全课中题', sourceFile: '冲突题库.json', answer: 'B', correctTexts: undefined })); if (config.missing) qaSnapshot.records = []; const pane = document.querySelector('.question-modal-pc'); const escape = text => String(text).replace(/&/g, '&').replace(//g, '>'); const attemptLabel = () => config.attemptLabel ? '
' + escape(config.attemptLabel) + '
' : ''; const grade = () => { const q = qaQuestions[qaModel.index]; qaModel.grading = true; const correct = !config.wrongResult && JSON.stringify(qaModel.selected.slice().sort()) === JSON.stringify(q.wanted.slice().sort()); pane.innerHTML = '
'; pane.querySelector('button').addEventListener('click', () => { qaModel.advances++; if (qaModel.index < qaQuestions.length - 1) { qaModel.index++; if (config.deferNext) { pane.innerHTML = ''; setTimeout(render, 150); } else render(); } else pane.innerHTML = ''; }); }; const render = () => { const q = qaQuestions[qaModel.index]; qaModel.selected = config.type === 'multi' || qaModel.index === 1 ? ['错误措施'] : []; pane.innerHTML = attemptLabel() + '
'; const footer = pane.querySelector('.modal-footer'); const submit = () => { if (footer.querySelector('button')) return; const button = document.createElement('button'); button.type = 'button'; button.innerHTML = ''; footer.append(button); button.addEventListener('click', () => { qaModel.submits++; if (config.noGrade) return; if (config.deferGrade) { pane.innerHTML = ''; window.qaReleaseGrade = grade; } else grade(); }); }; pane.querySelectorAll('.option-row').forEach((row, i) => row.addEventListener('click', () => { qaModel.optionClicks.push({ index: qaModel.index, text: q.page[i], label: String.fromCharCode(65 + i) }); if (config.noSelection) return; if (q.type === '多选') qaModel.selected = qaModel.selected.includes(q.page[i]) ? qaModel.selected.filter(text => text !== q.page[i]) : qaModel.selected.concat(q.page[i]); else qaModel.selected = [q.page[i]]; pane.querySelectorAll('.examtest_content_list_des').forEach((node, j) => node.classList.toggle('examtest_content_list_des_checked', qaModel.selected.includes(q.page[j]))); submit(); if (config.cancelAfterClick) qaActive = false; if (config.singleAfterClick) pane.querySelector('.quiz-attempt-status').textContent = '剩余答题次数:1 次'; })); }; render(); if (config.image) { const image = document.createElement('img'); image.alt = '需要人工识别的图片'; pane.querySelector('.examtest_content_question').append(image); } if (config.attemptInQuestion) { const note = document.createElement('span'); note.textContent = '答题次数:1 次'; pane.querySelector('.examtest_content_question').append(note); } if (config.unknown) pane.innerHTML = '

新题型

'; window.qaSolve = () => YianQuizAdapter.solve({ document, snapshot: qaSnapshot, matcher: YianAnswerMatcher, context: { course: '当前视频标题', courses: ['安全课中题'], answerInterval: 0, skipSingleAttemptQuiz: config.skipSingleAttemptQuiz }, signal: qaAbort.signal, getActive: () => qaActive, timeoutMs: config.timeoutMs || 300 }); }, config); } await setup(); let result = await page.evaluate(() => qaSolve()); assert.equal(result.status, 'complete'); assert.equal(result.verified, true); let model = await page.evaluate(() => qaModel); assert.deepEqual(model.optionClicks.map(item => item.label), ['C'], '按原答案全文映射到打乱后的 C,不使用原 A'); assert.equal(model.submits, 1); assert.equal(model.advances, 1); assert.equal(await page.locator('.question_box,.answer_box').count(), 0, '正确结果后正常关闭弹题'); await setup({ multiple: true, deferNext: true }); result = await page.evaluate(() => qaSolve()); assert.equal(result.status, 'complete'); assert.equal(result.verifiedCount, 3); model = await page.evaluate(() => qaModel); assert.deepEqual(model.optionClicks.filter(item => item.index === 1).map(item => item.label), ['A', 'B', 'C'], '多选先去除错误 A,再补选 B、C'); assert.equal(model.submits, 3); assert.equal(model.advances, 3); console.log('PASS 空 app-question-modal + 真实 PC portal:乱序全文映射、单选/多选/判断、完整集合回读、判分及下一题异步空隙/关闭'); for (const config of [{ conflict: true }, { missing: true }, { image: true }, { unknown: true }]) { await setup(config); result = await page.evaluate(() => qaSolve()); assert.equal(result.status, config.conflict ? 'conflict' : config.missing ? 'missing' : 'waiting'); model = await page.evaluate(() => qaModel); assert.equal(model.optionClicks.length, 0); assert.equal(model.submits, 0); } console.log('PASS 冲突、未命中、图片与未知 DOM 均不猜选、不提交'); for (const attemptLabel of ['允许答题次数:1 次', '最多可答题 1 次', '剩余答题次数:一 次', '仅有 1 次答题机会', '还可补考 1 次', '总答题次数:3 次;剩余答题次数:1 次']) { await setup({ attemptLabel }); result = await page.evaluate(() => qaSolve()); assert.equal(result.status, 'waiting'); assert.equal(result.manualRequired, true); assert.match(result.reason, /1 次/); model = await page.evaluate(() => qaModel); assert.equal(model.optionClicks.length, 0); assert.equal(model.submits, 0); assert.equal(model.advances, 0); assert.equal(await page.locator('.question_box').count(), 1, '仅一次机会的弹题必须保留供人工操作'); } await setup({ attemptLabel: '允许答题次数:1 次', skipSingleAttemptQuiz: false }); result = await page.evaluate(() => qaSolve()); assert.equal(result.status, 'complete', '关闭开关后按原流程答题'); for (const config of [{ attemptLabel: '允许答题次数:2 次' }, { attemptLabel: '已答题次数:1 次' }, { attemptLabel: '答题次数:1 次' }, { attemptLabel: '允许答题次数:1 次', hiddenAttemptLabel: true }, { attemptLabel: '答题次数:1/3' }]) { await setup(config); result = await page.evaluate(() => qaSolve()); assert.equal(result.status, 'complete', '已用次数、隐藏提示及不明确分数不冒充仅一次机会'); } await setup({ attemptInQuestion: true }); result = await page.evaluate(() => qaSolve()); assert.equal(result.status, 'complete', '题干区域的次数文字不作为弹题可用次数证据'); assert.equal(result.manualRequired, undefined); await setup({ attemptLabel: '剩余答题次数:2 次', singleAfterClick: true }); result = await page.evaluate(() => qaSolve()); assert.equal(result.status, 'waiting'); assert.equal(result.manualRequired, true); assert.match(result.reason, /1 次/); assert.equal((await page.evaluate(() => qaModel)).submits, 0, '选中后次数变成一时也不得提交或关闭'); await setup({ attemptLabel: '剩余答题次数:2 次' }); result = await page.evaluate(() => YianQuizAdapter.solve({ document, snapshot: qaSnapshot, matcher: { match: async (...args) => { const result = await YianAnswerMatcher.match(...args); document.querySelector('.quiz-attempt-status').textContent = '允许答题次数:1 次'; return result; } }, context: { courses: ['安全课中题'], answerInterval: 0 }, getActive: () => qaActive })); assert.equal(result.status, 'waiting'); assert.equal(result.manualRequired, true); assert.equal((await page.evaluate(() => qaModel)).optionClicks.length, 0, '匹配结束后重新核验次数,不能执行旧的点击计划'); await setup({ attemptLabel: '允许答题次数:2 次' }); result = await page.evaluate(async () => { const policy = YianAttemptPolicy; delete window.YianAttemptPolicy; try { return await qaSolve(); } finally { window.YianAttemptPolicy = policy; } }); assert.equal(result.status, 'waiting'); assert.match(result.reason, /组件未就绪/); assert.equal((await page.evaluate(() => qaModel)).optionClicks.length, 0, '开关开启时次数组件缺失不得冒险自动答题'); console.log('PASS 一次机会默认等待人工且保留弹题、可关闭开关、仅认可可见次数提示及点击前重新核验'); await setup({ cancelAfterClick: true }); result = await page.evaluate(() => qaSolve()); assert.equal(result.status, 'waiting'); assert.equal((await page.evaluate(() => qaModel)).submits, 0); await setup(); result = await page.evaluate(() => { qaAbort.abort(); return qaSolve(); }); assert.equal(result.status, 'waiting'); assert.equal((await page.evaluate(() => qaModel)).optionClicks.length, 0); await setup({ noSelection: true }); result = await page.evaluate(() => qaSolve()); assert.equal(result.status, 'waiting'); assert.equal((await page.evaluate(() => qaModel)).submits, 0); console.log('PASS getActive 失效、signal 中止、网站选中状态无回读时立即停止'); await setup({ deferGrade: true, timeoutMs: 2000 }); await page.evaluate(() => { window.qaDone = false; window.qaPromise = qaSolve().then(result => { qaDone = true; return result; }); }); await page.waitForFunction(() => typeof window.qaReleaseGrade === 'function'); assert.equal(await page.evaluate(() => qaDone), false, '题干消失但网站尚未返回结果时不得视为完成'); await page.evaluate(() => qaReleaseGrade()); result = await page.evaluate(() => qaPromise); assert.equal(result.status, 'complete'); assert.equal(result.verified, true); await setup({ wrongResult: true }); result = await page.evaluate(() => qaSolve()); assert.equal(result.status, 'waiting'); assert.match(result.reason, /错误/); assert.equal((await page.evaluate(() => qaModel)).advances, 0, '网站判错时不自动点击下一题'); await setup({ noGrade: true }); result = await page.evaluate(() => qaSolve()); assert.equal(result.status, 'waiting'); assert.equal((await page.evaluate(() => qaModel)).submits, 1, '结果未确认只提交一次'); console.log('PASS 题干消失不冒充结果、待网站明确判分、判错等待人工与未知结果不重复提交'); assert.equal(requests.length, 0, '所有适配测试不得连接业务或任何网络'); assert.deepEqual(errors, []); } finally { await browser.close(); } })().catch(error => { console.error(error); process.exitCode = 1; });