feat: add portable role-based question bank assistant

This commit is contained in:
薇星幻海
2026-10-10 03:39:43 +08:00
parent e265983170
commit 0ea302bbab
18 changed files with 4659 additions and 330 deletions
+180
View File
@@ -0,0 +1,180 @@
/* 完整面板与跨网址存储验证;GM 桥接仅用于本机模拟,不请求业务服务器。 */
const assert = require('node:assert/strict');
const fs = require('node:fs');
const path = require('node:path');
const os = require('node:os');
const http = require('node:http');
const { chromium } = require('playwright');
const workspace = path.resolve(__dirname, '..');
const script = fs.readFileSync(path.join(workspace, '谊安学题库导出.user.js'), 'utf8');
assert(script.includes('// @match *://*/*'));
assert(!/\/\/ @match\s+https?:\/\/10\./.test(script));
const qaDir = process.env.YIAN_QA_DIR || path.join(os.tmpdir(), 'yianxue-assistant-qa');
fs.mkdirSync(qaDir, { recursive: true });
const questions = [
{ type: '单选', stem: '广西华谊新材料有限公司涉及几种高危工艺?', options: ['5种', '4种', '3种', '2种'] },
{ type: '多选', stem: '以下哪些属于广西华谊新材料有限公司涉及的高危工艺?', options: ['聚合', '蒸馏', '氧化', '裂解'] },
{ type: '判断', stem: '安全不仅是企业的底线,更是华谊对员工、对社会的庄严承诺。', options: ['错', '对'] }
];
async function bankInput() {
const zipPath = path.join(workspace, '题库.zip');
if (fs.existsSync(zipPath) && process.env.YIAN_TEST_SYNTHETIC_BANK !== '1') return { file: zipPath, total: 5520, counts: { 主操: 1620, 班长: 2340, 副操: 1560 }, real: true };
const JSZip = require(path.join(workspace, 'vendor', 'jszip.min.js'));
const zip = new JSZip();
for (const role of ['主操', '班长', '副操']) {
const records = questions.map((q, index) => ({ role, course: '安全领导力-1', number: index + 1,
type: q.type, question: q.stem, options: [['2种', '3种', '4种', '5种'], ['裂解', '氧化', '聚合', '蒸馏'], ['对', '错']][index]
.map((text, i) => ({ label: String.fromCharCode(65 + i), text })), answer: ['C', 'AB', 'A'][index] }));
for (const [index, course] of ['风险管理', '重大危险源安全管理'].entries()) records.push({ role, course, number: 1,
type: '多选', question: '常压罐组收料作业中,以下哪些属于工程控制措施?',
options: ['装置甲', '装置乙', '装置丙', '防火堤'].map((text, i) => ({ label: String.fromCharCode(65 + i), text })), answer: index ? 'ABCD' : 'ABC' });
for (let i = 0; i < 25; i++) records.push({ role, course: '作业课程', number: i + 1, type: '单选',
question: '作业检索测试题' + i, options: [{ label: 'A', text: '正确项' }, { label: 'B', text: '错误项' }], answer: 'A' });
zip.file(role + '/测试题库.json', JSON.stringify(records));
}
return { file: { name: '测试题库.zip', mimeType: 'application/zip', buffer: await zip.generateAsync({ type: 'nodebuffer' }) },
total: 90, counts: { 主操: 30, 班长: 30, 副操: 30 }, real: false };
}
function practiceHTML() {
return `<app-examtest><div class="pc_examtest_title">安全领导力-1</div>${questions.map((q, index) =>
`<div app-exam-question class="pc_exam_list"><a id="examtest${index}"></a><div class="pc_examtest_content" data-kind="${q.type}" data-number="${index + 1}"><div class="pc_examtest_content_question"><p>[${q.type}] ${index + 1}. ${q.stem}<span class="pc_examtest_content_question_showanswer">查看答案</span></p></div><div class="pc_examtest_content_list">${q.options.map((text, i) => `<div class="pc_examtest_content_list_child1"><div class="pc_examtest_content_list_des${index === 1 && i === 0 ? ' examtest_content_list_des_checked' : ''}"><span class="examtest_content_list_des_optionsort">${String.fromCharCode(65 + i)}</span>${text}</div></div>`).join('')}</div><div class="hidden"><div class="examtest_content_answer_left">参考答案:<span class="answer"></span></div><div class="pc_examtest_content_resolve">题目解析:</div></div></div></div>`).join('')}<div class="pc_examtest_card">${questions.map((_, i) => `<div class="examtestcard_count_defult">${i + 1}</div>`).join('')}</div><div class="pc_examtest_commit">重新练习</div><button id="submit">交卷</button></app-examtest>`;
}
function documentHTML(practice) {
return `<!doctype html><meta charset="utf-8"><title>${practice ? '助手练习验证' : '普通网页验证'}</title><style>body{font:16px system-ui;margin:32px;background:#f4f7fa;user-select:none}.pc_examtest_content{padding:16px;background:white;border-bottom:1px solid #ddd;max-width:750px}.pc_examtest_content_list_des{padding:8px;cursor:pointer}.examtest_content_list_des_checked{background:#dff1e5}.hidden{display:none}.pc_examtest_title{font-size:24px;margin:18px 0}.pc_examtest_content_question_showanswer{margin-left:12px;color:#3268ac}</style><body onselectstart="return false" oncontextmenu="return false"><h1>${practice ? '练习页面' : '任意网址的题库搜索'}</h1><p id="copy-target">广西华谊新材料有限公司涉及几种高危工艺</p>${practice ? practiceHTML() : '<p>网页没有练习题目,仍可使用岗位题库搜索与复制开关。</p>'}<script>
window.optionClicks=[];window.parseClicks=0;window.submitClicks=0;
document.addEventListener('click',function(event){const n=event.target.closest('.pc_examtest_content_list_des');if(n){const box=n.closest('.pc_examtest_content');if(box.dataset.showanswer==='true')return;const label=n.querySelector('.examtest_content_list_des_optionsort').textContent;window.optionClicks.push(box.dataset.number+':'+label);if(box.dataset.kind==='多选')n.classList.toggle('examtest_content_list_des_checked');else{box.querySelectorAll('.pc_examtest_content_list_des').forEach(x=>x.classList.remove('examtest_content_list_des_checked'));n.classList.add('examtest_content_list_des_checked');box.dataset.showanswer='true';box.querySelector('.pc_examtest_content_question_showanswer').remove();box.querySelectorAll('.pc_examtest_content_list_child1').forEach(x=>x.className='pc_examtest_content_list_child2');}}if(event.target.closest('.pc_examtest_content_question_showanswer'))window.parseClicks++;if(event.target.id==='submit')window.submitClicks++;});
</script></body>`;
}
(async () => {
const memory = new Map([['yianxue.assistant.setting.v2.autoExport', false]]);
let clipboard = '';
const server = http.createServer((req, res) => {
res.writeHead(200, { 'Content-Type': 'text/html;charset=utf-8' });
res.end(documentHTML(req.url.includes('exercise') || req.url.includes('flag=test')));
});
await new Promise(resolve => server.listen(0, resolve));
const port = server.address().port;
let browser;
try {
browser = await chromium.launch({ channel: process.env.PLAYWRIGHT_CHANNEL || 'chrome', headless: true });
const context = await browser.newContext({ viewport: { width: 1366, height: 900 }, acceptDownloads: true });
await context.exposeBinding('__gmRead', (source, key, fallback) => memory.has(key) ? structuredClone(memory.get(key)) : fallback);
await context.exposeBinding('__gmWrite', (source, key, value) => { memory.set(key, structuredClone(value)); });
await context.exposeBinding('__gmCopy', (source, text) => { clipboard = text; });
// API 仅作为沙箱全局变量存在,刻意不放到 window 上。
const sandboxScript = '(function(){const GM_getValue=(key,fallback)=>window.__gmRead(key,fallback);'
+ 'const GM_setValue=(key,value)=>window.__gmWrite(key,value);const GM_setClipboard=text=>window.__gmCopy(text);\n'
+ script + '\n})();';
const errors = [];
function capture(page) { page.on('pageerror', e => errors.push(e.message)); page.on('console', m => { if (m.type() === 'error') errors.push(m.text()); }); }
const page = await context.newPage(); capture(page);
const startURL = `http://127.0.0.1:${port}/completely-unrelated-path`;
await page.goto(startURL); await page.addScriptTag({ content: sandboxScript });
await page.evaluate(() => YianAssistant.ready);
assert.equal(page.url(), startURL); assert.equal(await page.title(), '普通网页验证');
assert(await page.getByRole('heading', { name: '任意网址的题库搜索' }).isVisible());
await page.getByRole('button', { name: '展开题库助手' }).click();
assert(await page.getByRole('switch', { name: '自动选择答案' }).isDisabled());
assert.equal(await page.evaluate(() => typeof window.GM_getValue), 'undefined');
console.log('PASS 任意路径显示搜索面板,非练习页禁止自动选择,沙箱API兼容');
const input = await bankInput();
await page.locator('#yian-question-assistant input[type=file]').setInputFiles(input.file);
await page.waitForFunction(total => YianAssistant.getSnapshot().total === total, input.total);
assert.deepEqual(await page.evaluate(() => YianAssistant.getSnapshot().counts), input.counts);
assert.equal(await page.evaluate(() => YianAssistant.getSnapshot().storage), 'userscript');
await page.getByRole('radio', { name: '主操', exact: true }).check();
await page.getByRole('textbox', { name: '搜索题目' }).fill('工程控制措施');
await page.getByRole('button', { name: '搜索', exact: true }).click();
assert(await page.locator('#yian-question-assistant .result .conflict').count() > 0);
assert((await page.locator('#yian-question-assistant .results-title').innerText()).includes('主操'));
console.log('PASS ' + (input.real ? '实际ZIP导入5520条' : '合成ZIP导入90条') + '、岗位数量与冲突来源显示');
// 未点击搜索按钮的新输入,也应在换岗位时立即检索。
const query = '广西华谊新材料有限公司涉及几种高危工艺';
await page.getByRole('textbox', { name: '搜索题目' }).fill(query);
await page.getByRole('radio', { name: '副操', exact: true }).check();
assert.equal(await page.getByRole('textbox', { name: '搜索题目' }).inputValue(), query);
assert((await page.locator('#yian-question-assistant .results-title').innerText()).includes('副操'));
assert(await page.locator('#yian-question-assistant .result').count() > 0);
assert((await page.locator('#yian-question-assistant .sources').first().innerText()).includes('副操'));
await page.getByRole('button', { name: '复制题目与答案' }).first().click();
assert(clipboard.includes('正确答案:')); assert(clipboard.includes('高危工艺'));
await page.getByRole('radio', { name: '班长', exact: true }).check();
assert((await page.locator('#yian-question-assistant .results-title').innerText()).includes('班长'));
await page.getByRole('radio', { name: '主操', exact: true }).check();
await page.getByRole('textbox', { name: '搜索题目' }).fill('作业');
await page.getByRole('button', { name: '搜索', exact: true }).click();
assert.equal(await page.locator('#yian-question-assistant .result').count(), 20);
await page.getByRole('button', { name: '下一页', exact: true }).click();
assert((await page.locator('#yian-question-assistant .pagination').innerText()).includes('21–'));
assert(await page.locator('#yian-question-assistant .result').count() > 0);
console.log('PASS 三岗位隔离、换岗位保留当前关键词、结果复制与20条分页');
const restrictions = () => page.evaluate(() => ({ select: document.body.getAttribute('onselectstart'), context: document.body.getAttribute('oncontextmenu'), blocked: !document.body.dispatchEvent(new Event('selectstart', { bubbles: true, cancelable: true })), style: getComputedStyle(document.querySelector('#copy-target')).userSelect }));
assert.deepEqual(await restrictions(), { select: null, context: null, blocked: false, style: 'text' });
await page.getByRole('switch', { name: '允许复制文字' }).uncheck();
assert.deepEqual(await restrictions(), { select: 'return false', context: 'return false', blocked: true, style: 'none' });
await page.getByRole('switch', { name: '允许复制文字' }).check();
await page.evaluate(() => { const range = document.createRange(); range.selectNodeContents(document.getElementById('copy-target')); const selection = getSelection(); selection.removeAllRanges(); selection.addRange(range); });
await page.getByRole('button', { name: '用选中文字搜索', exact: true }).click();
assert.equal(await page.getByRole('textbox', { name: '搜索题目' }).inputValue(), query);
await page.locator('#yian-question-assistant .body').evaluate(node => { node.scrollTop = 0; });
await page.screenshot({ path: path.join(qaDir, 'search-desktop.png') });
console.log('PASS 允许复制开启/关闭恢复原限制,正文选中文字搜索');
await page.locator('#yian-question-assistant input[type=file]').setInputFiles({ name: 'invalid.csv', mimeType: 'text/csv', buffer: Buffer.from('题目,参考答案\n坏数据,A') });
await page.waitForFunction(() => document.querySelector('#yian-question-assistant').shadowRoot.querySelector('.notice').textContent.includes('导入失败'));
assert.equal(await page.evaluate(() => YianAssistant.getSnapshot().total), input.total);
console.log('PASS 无效导入提示与原题库保留');
const second = await context.newPage(); capture(second);
const secondURL = `http://localhost:${port}/different/application/route?flag=exercise`;
await second.goto(secondURL); await second.addScriptTag({ content: sandboxScript }); await second.evaluate(() => YianAssistant.ready);
await second.waitForFunction(() => YianAssistant.getState().pageReady);
assert.equal(await second.evaluate(() => YianAssistant.getSnapshot().total), input.total);
assert.equal(await second.evaluate(() => YianAssistant.getState().role), '主操');
assert.equal(await second.getByRole('switch', { name: '自动选择答案' }).isChecked(), false);
await second.getByRole('switch', { name: '自动选择答案' }).check();
await second.waitForFunction(() => YianAssistant.getState().metrics.selected === 3);
assert.deepEqual(await second.evaluate(() => window.optionClicks), ['1:B', '2:A', '2:C', '2:D', '3:B']);
assert.equal(await second.evaluate(() => YianAssistant.getState().metrics.pending), 1);
assert.equal(await second.evaluate(() => window.parseClicks + window.submitClicks), 0);
await second.waitForTimeout(1500); assert.equal(await second.evaluate(() => window.optionClicks.length), 5);
await second.getByRole('switch', { name: '自动选择答案' }).uncheck();
await second.getByRole('switch', { name: '自动选择答案' }).check();
await second.waitForTimeout(500); assert.equal(await second.evaluate(() => window.optionClicks.length), 5);
await second.getByRole('switch', { name: '自动选择答案' }).uncheck();
console.log('PASS 更换域名/路径后题库岗位共用,实际选项点击、换序、多选差集与无重复');
await second.evaluate(html => { history.pushState({}, '', '/another/random-route?flag=exercise'); const doc = new DOMParser().parseFromString(html, 'text/html'); document.querySelector('app-examtest').replaceWith(doc.querySelector('app-examtest')); window.optionClicks = []; }, practiceHTML());
await second.waitForFunction(() => YianAssistant.getContext().ready);
await second.getByRole('switch', { name: '自动选择答案' }).check();
await second.waitForFunction(() => window.optionClicks.length > 0);
await second.getByRole('switch', { name: '自动选择答案' }).uncheck();
const stoppedAt = await second.evaluate(() => window.optionClicks.length);
await second.waitForTimeout(700); assert.equal(await second.evaluate(() => window.optionClicks.length), stoppedAt);
await second.getByRole('switch', { name: '自动选择答案' }).check();
await second.waitForFunction(() => { const m = YianAssistant.getState().metrics; return m.selected + m.locked === 3; });
await second.getByRole('switch', { name: '自动选择答案' }).uncheck();
const modeURL = second.url().replace('flag=exercise', 'flag=test');
await second.evaluate(url => history.pushState({}, '', url), modeURL);
await second.waitForTimeout(800); assert(await second.getByRole('switch', { name: '自动选择答案' }).isDisabled());
assert.equal(await second.evaluate(() => YianAssistant.setAutoAnswer(true)), false);
console.log('PASS 面板关闭立即停止后续点击、重新开启续作、明确考试模式禁用');
await page.setViewportSize({ width: 390, height: 844 });
await page.getByRole('button', { name: '复制题目与答案' }).first().click();
await page.locator('#yian-question-assistant .body').evaluate(node => { node.scrollTop = 0; });
assert(await page.evaluate(() => document.documentElement.scrollWidth <= innerWidth));
await page.screenshot({ path: path.join(qaDir, 'search-mobile.png') });
await page.setViewportSize({ width: 1366, height: 900 });
await page.locator('#yian-question-assistant .body').evaluate(node => { node.scrollTop = 0; });
await page.locator('#yian-question-assistant').screenshot({ path: path.join(qaDir, 'assistant-panel.png') });
assert.deepEqual(errors, []);
console.log('PASS 桌面/手机面板、页面身份与内容、无错误覆盖层/控制台错误');
console.log('截图:' + qaDir);
} finally { if (browser) await browser.close(); await new Promise(resolve => server.close(resolve)); }
})().catch(error => { console.error(error); process.exitCode = 1; });