feat: add portable role-based question bank assistant
This commit is contained in:
@@ -0,0 +1,176 @@
|
||||
/* 自动选择真实浏览器回归:本机 HTTP fixture,绝不连接原网站或修改真实练习。 */
|
||||
const assert = require('node:assert/strict');
|
||||
const fs = require('node:fs');
|
||||
const path = require('node:path');
|
||||
const http = require('node:http');
|
||||
const { chromium } = require('playwright');
|
||||
|
||||
const workspace = path.resolve(__dirname, '..');
|
||||
const coreSource = fs.readFileSync(path.join(workspace, 'question-bank-core.js'), 'utf8');
|
||||
const runnerSource = fs.readFileSync(path.join(workspace, 'answer-runner.js'), 'utf8');
|
||||
|
||||
function fixture(items) {
|
||||
const option = (question, label) => `<div class="pc_examtest_content_list_child${question.locked ? 2 : 1}"><div class="pc_examtest_content_list_des${(question.checked || []).includes(label) ? ' examtest_content_list_des_checked' : ''}" data-label="${label}" onclick="window.optionClicked(this)"><span class="examtest_content_list_des_optionsort">${label}</span>选项${label}</div></div>`;
|
||||
return `<app-examtest><div class="pc_examtest_title">测试练习</div>${items.map((question, index) => `<div app-exam-question><div class="pc_examtest_content" data-kind="${question.type}" data-nochange="${question.nochange ? '1' : '0'}"><div class="pc_examtest_content_question">[${question.type}] ${index + 1}. 测试题${index + 1}${question.locked ? '' : '<span class="pc_examtest_content_question_showanswer">查看答案</span>'}</div><div class="pc_examtest_content_list">${['A', 'B', 'C', 'D', 'E'].slice(0, question.type === '判断' ? 2 : 5).map(label => option(question, label)).join('')}</div>${question.locked ? '<div class="hidden pc_examtest_content_form_disabled"></div>' : ''}<div class="hidden"><div class="examtest_content_answer_left">参考答案:<span class="answer">B</span></div></div></div></div>`).join('')}</app-examtest>`;
|
||||
}
|
||||
|
||||
async function main() {
|
||||
const server = http.createServer((request, response) => {
|
||||
response.writeHead(200, { 'Content-Type': 'text/html;charset=utf-8' });
|
||||
response.end('<!doctype html><meta charset="utf-8"><title>自动选择本机回归</title><style>.hidden{display:none}.pc_examtest_content{padding:12px}.pc_examtest_content_list_des{padding:6px}</style><body></body>');
|
||||
});
|
||||
await new Promise(resolve => server.listen(0, '127.0.0.1', resolve));
|
||||
let browser;
|
||||
try {
|
||||
browser = await chromium.launch({ channel: process.env.PLAYWRIGHT_CHANNEL || 'chrome', headless: true });
|
||||
const page = await browser.newPage();
|
||||
const errors = [];
|
||||
page.on('pageerror', error => errors.push(error.message));
|
||||
await page.goto(`http://127.0.0.1:${server.address().port}/practice`);
|
||||
await page.addScriptTag({ content: coreSource });
|
||||
await page.addScriptTag({ content: runnerSource });
|
||||
|
||||
async function setup(items, mode = 'normal') {
|
||||
await page.evaluate(({ html, mode }) => {
|
||||
window.qaRunner?.destroy();
|
||||
document.body.innerHTML = html;
|
||||
window.qaClicks = [];
|
||||
window.qaStates = [];
|
||||
window.qaMatchStarted = false;
|
||||
window.qaReleaseMatch = null;
|
||||
window.qaMode = mode;
|
||||
window.qaContext = { ready: true, practice: true, local: false, role: '主操', course: '测试练习', sourceUrl: location.href, routeKey: 'practice', root: document.querySelector('app-examtest'), pageSignature: 'stable', revision: 1 };
|
||||
// 只在真实 onclick 里更新状态,runner 必须点击选项才会发生选择或锁定。
|
||||
window.optionClicked = node => {
|
||||
const box = node.closest('.pc_examtest_content');
|
||||
window.qaClicks.push([box.dataset.kind, node.dataset.label]);
|
||||
if (box.dataset.nochange === '1') return;
|
||||
if (box.dataset.kind === '多选') {
|
||||
node.classList.toggle('examtest_content_list_des_checked');
|
||||
} else {
|
||||
box.querySelectorAll('.pc_examtest_content_list_des').forEach(option => option.classList.remove('examtest_content_list_des_checked'));
|
||||
node.classList.add('examtest_content_list_des_checked');
|
||||
box.querySelector('.pc_examtest_content_question_showanswer')?.remove();
|
||||
const marker = document.createElement('div');
|
||||
marker.className = 'hidden pc_examtest_content_form_disabled';
|
||||
box.append(marker);
|
||||
}
|
||||
};
|
||||
window.qaRunner = YianAnswerRunner.create({
|
||||
getContext: () => window.qaContext,
|
||||
match: async question => {
|
||||
window.qaMatchStarted = true;
|
||||
if (window.qaMode === 'delayed') await new Promise(resolve => { window.qaReleaseMatch = resolve; });
|
||||
if (window.qaMode === 'title') document.querySelector('.pc_examtest_title').textContent = '新练习';
|
||||
const labels = question.type === '多选' ? ['B', 'E'] : ['B'];
|
||||
return { status: 'matched', correctLabels: labels, correctTexts: labels.map(label => '选项' + label) };
|
||||
},
|
||||
onProgress: counts => {
|
||||
window.qaProgress = counts;
|
||||
if (window.qaMode === 'stop-after-first' && counts.selected === 1) window.qaRunner.stop('测试停止');
|
||||
},
|
||||
onState: (state, reason) => window.qaStates.push([state, reason])
|
||||
});
|
||||
}, { html: fixture(items), mode });
|
||||
}
|
||||
|
||||
async function delayedStart() {
|
||||
await page.evaluate(() => { window.qaPromise = qaRunner.start(); });
|
||||
await page.waitForFunction(() => window.qaMatchStarted && typeof window.qaReleaseMatch === 'function');
|
||||
}
|
||||
|
||||
async function finishMatch() {
|
||||
await page.evaluate(() => { window.qaReleaseMatch(); return qaPromise; });
|
||||
}
|
||||
|
||||
await setup([{ type: '单选' }, { type: '多选', checked: ['A'] }, { type: '判断' }, { type: '单选', checked: ['A'], locked: true }, { type: '单选', nochange: true }]);
|
||||
const startTime = Date.now();
|
||||
const initial = await page.evaluate(async () => {
|
||||
const first = qaRunner.start();
|
||||
const second = qaRunner.start();
|
||||
const samePromise = first === second;
|
||||
await first;
|
||||
return { counts: qaProgress, clicks: qaClicks, states: qaStates, samePromise };
|
||||
});
|
||||
assert.equal(initial.counts.selected, 3);
|
||||
assert.equal(initial.counts.locked, 1);
|
||||
assert.equal(initial.counts.total, 5);
|
||||
assert.deepEqual(initial.clicks.filter(([type]) => type !== '多选'), [['单选', 'B'], ['判断', 'B'], ['单选', 'B']]);
|
||||
console.log('PASS single/judgment selection and locked question preservation');
|
||||
|
||||
assert.equal(initial.counts.pending, 1);
|
||||
assert.deepEqual(initial.clicks.filter(([type]) => type === '多选'), [['多选', 'A'], ['多选', 'B'], ['多选', 'E']]);
|
||||
assert.deepEqual(await page.evaluate(() => Array.from(document.querySelectorAll('[data-kind="多选"] .examtest_content_list_des_checked')).map(node => node.dataset.label)), ['B', 'E']);
|
||||
console.log('PASS multiselect removes wrong choices then adds correct choices');
|
||||
|
||||
assert.equal(initial.counts.failed, 1);
|
||||
assert(initial.counts.details.some(item => item.status === 'failed' && item.reason.includes('限定时间')));
|
||||
assert(Date.now() - startTime < 10000, '无响应点击必须有界结束');
|
||||
assert.equal(initial.states.at(-1)[0], 'complete');
|
||||
console.log('PASS nonresponding option fails once and completes within limit');
|
||||
|
||||
assert(initial.samePromise, '并发 start 必须共用运行 Promise');
|
||||
await page.evaluate(() => qaRunner.start());
|
||||
assert.equal(await page.evaluate(() => qaClicks.length), initial.clicks.length);
|
||||
console.log('PASS concurrent and repeated starts do not duplicate clicks');
|
||||
|
||||
await setup([{ type: '单选' }, { type: '单选' }], 'stop-after-first');
|
||||
await page.evaluate(() => qaRunner.start());
|
||||
assert.equal(await page.evaluate(() => qaClicks.length), 1);
|
||||
assert.equal(await page.evaluate(() => qaRunner.running), false);
|
||||
await page.evaluate(() => { window.qaMode = 'normal'; return qaRunner.start(); });
|
||||
assert.equal(await page.evaluate(() => qaClicks.length), 2);
|
||||
assert.equal(await page.evaluate(() => qaProgress.selected), 2);
|
||||
console.log('PASS switch off stops subsequent clicks and restart continues');
|
||||
|
||||
await setup([{ type: '单选' }], 'delayed');
|
||||
await delayedStart();
|
||||
await page.evaluate(() => { qaContext.role = '班长'; });
|
||||
await finishMatch();
|
||||
assert.equal(await page.evaluate(() => qaClicks.length), 0);
|
||||
await page.evaluate(() => { window.qaMode = 'normal'; return qaRunner.start(); });
|
||||
assert.equal(await page.evaluate(() => qaClicks.length), 1);
|
||||
console.log('PASS role change cancels stale match and restart uses new role');
|
||||
|
||||
for (const field of ['revision', 'routeKey', 'sourceUrl']) {
|
||||
await setup([{ type: '单选' }], 'delayed');
|
||||
await delayedStart();
|
||||
await page.evaluate(field => { qaContext[field] = 'changed'; }, field);
|
||||
await finishMatch();
|
||||
assert.equal(await page.evaluate(() => qaClicks.length), 0, field + ' 切换后不能点击旧题');
|
||||
}
|
||||
console.log('PASS bank revision, route and source changes cancel delayed clicks');
|
||||
|
||||
await setup([{ type: '单选' }], 'title');
|
||||
await page.evaluate(() => qaRunner.start());
|
||||
assert.equal(await page.evaluate(() => qaClicks.length), 0);
|
||||
assert.equal(await page.evaluate(() => qaStates.at(-1)[0]), 'stopped');
|
||||
await setup([{ type: '单选' }], 'delayed');
|
||||
await delayedStart();
|
||||
await page.evaluate(() => { location.hash = 'new-practice'; });
|
||||
await finishMatch();
|
||||
assert.equal(await page.evaluate(() => qaClicks.length), 0);
|
||||
console.log('PASS live title and location changes stop before parent observer updates');
|
||||
|
||||
await setup([{ type: '多选' }], 'delayed');
|
||||
await delayedStart();
|
||||
await page.locator('.pc_examtest_content_list_des').first().click();
|
||||
await finishMatch();
|
||||
assert.equal(await page.evaluate(() => qaClicks.length), 1, '只允许真人本次点击生效');
|
||||
assert.equal(await page.evaluate(() => qaStates.at(-1)[0]), 'paused');
|
||||
console.log('PASS trusted manual answering pauses automatic selection');
|
||||
|
||||
await setup([{ type: '单选' }]);
|
||||
await page.evaluate(() => { qaContext.local = true; return qaRunner.start(); });
|
||||
assert.equal(await page.evaluate(() => qaClicks.length), 0);
|
||||
await page.evaluate(() => { qaContext.local = false; qaContext.practice = false; return qaRunner.start(); });
|
||||
assert.equal(await page.evaluate(() => qaClicks.length), 0);
|
||||
assert.deepEqual(errors, []);
|
||||
console.log('PASS local/nonpractice pages blocked; no browser errors');
|
||||
} finally {
|
||||
if (browser) await browser.close();
|
||||
await new Promise(resolve => server.close(resolve));
|
||||
}
|
||||
}
|
||||
|
||||
main().catch(error => { console.error(error); process.exitCode = 1; });
|
||||
Reference in New Issue
Block a user