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

139 lines
12 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', '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 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 = '<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;
}));
};
render();
if (config.image) { const image = document.createElement('img'); image.alt = '需要人工识别的图片'; pane.querySelector('.examtest_content_question').append(image); }
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 }, 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; });