/* 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', '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 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 = '' + (correct ? '恭喜你' : '对不起') + '' + (correct ? '回答正确' : '回答错误') + '
参考答案:' + escape(q.wanted.join(';')) + '
';
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 = '' + ['单选', '多选', '判断'].map(type => '
[' + type + ']' + escape(q.question) + '
').join('') + '
' + q.page.map((text, i) => '
' + String.fromCharCode(65 + i) + '' + escape(text) + '
').join('') + '
';
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;
}));
};
render();
if (config.image) { const image = document.createElement('img'); image.alt = '需要人工识别的图片'; pane.querySelector('.examtest_content_question').append(image); }
if (config.unknown) pane.innerHTML = '';
window.qaSolve = () => YianQuizAdapter.solve({ document, snapshot: qaSnapshot, matcher: YianAnswerMatcher,
context: { course: '当前视频标题', courses: ['安全课中题'], answerInterval: 0 }, 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 均不猜选、不提交');
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; });