Files
yianxue-assistant/tests/quiz-adapter.cjs
T

172 lines
16 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.
/* 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 = '<style>.question_box,.answer_box{display:block;padding:20px;border:1px solid}.hidden,[hidden],[aria-hidden="true"]{display:none!important}.examtest_content_list_des{padding:8px;cursor:pointer}.examtest_content_list_des_checked{background:#ded}.modal-footer{padding:8px}</style><app-question-modal></app-question-modal><div class="cdk-overlay-container"><div class="cdk-overlay-pane question-modal-pc"></div></div>';
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, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;');
const attemptLabel = () => config.attemptLabel ? '<div class="quiz-attempt-status' + (config.hiddenAttemptLabel ? ' hidden' : '') + '">' + escape(config.attemptLabel) + '</div>' : '';
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 = '<div class="answer"><div class="answer_box fade_in"><div class="modal-dialog"><div class="modal-content"><div class="modal-body"><div class="model_dec"><span>' + (correct ? '恭喜你' : '对不起') + '</span><span class="model_dec_span">' + (correct ? '回答正确' : '回答错误') + '</span></div><div class="model_answer_wrapper"><div class="model_answer">参考答案:' + escape(q.wanted.join(';')) + '</div></div></div><div class="modal-footer"><button type="button"><span aria-hidden="true">' + (qaModel.index < qaQuestions.length - 1 ? '下一题' : '关闭') + '</span></button></div></div></div></div></div>';
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() + '<div class="question"><div class="question_box fade_in"><div class="modal-dialog"><div class="modal-content"><div class="modal-body"><div class="examtest_content_question">' + ['单选', '多选', '判断'].map(type => '<p class="' + (q.type === type ? 'show' : 'hidden') + '"><span>[' + type + ']</span>' + escape(q.question) + '</p>').join('') + '</div><div class="examtest_content_list">' + q.page.map((text, i) => '<div class="option-row"><div class="examtest_content_list_des' + (qaModel.selected.includes(text) ? ' examtest_content_list_des_checked' : '') + '"><div><span class="examtest_content_list_des_optionsort">' + String.fromCharCode(65 + i) + '</span>' + escape(text) + ' </div></div></div>').join('') + '</div></div><div class="modal-footer"></div></div></div></div></div>';
const footer = pane.querySelector('.modal-footer');
const submit = () => {
if (footer.querySelector('button')) return;
const button = document.createElement('button'); button.type = 'button'; button.innerHTML = '<span aria-hidden="true">查看答案</span>'; 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 = '<div class="unrecognized-popup"><p>新题型</p><button>提交</button></div>';
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; });