feat: add portable role-based question bank assistant
This commit is contained in:
@@ -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; });
|
||||
@@ -0,0 +1,179 @@
|
||||
/* Pure storage/matching checks, including the user-supplied ZIP. */
|
||||
'use strict';
|
||||
const assert = require('node:assert/strict');
|
||||
const fs = require('node:fs');
|
||||
const path = require('node:path');
|
||||
const vm = require('node:vm');
|
||||
const rootDir = path.resolve(__dirname, '..');
|
||||
const bankSource = fs.readFileSync(path.join(rootDir, 'bank-store.js'), 'utf8');
|
||||
const matcherSource = fs.readFileSync(path.join(rootDir, 'answer-matcher.js'), 'utf8');
|
||||
const JSZip = require(path.join(rootDir, 'vendor', 'jszip.min.js'));
|
||||
|
||||
function runtime(kind = 'legacy', memory = new Map()) {
|
||||
const environment = { TextDecoder, URL, URLSearchParams, JSZip, console };
|
||||
if (kind === 'legacy') {
|
||||
environment.GM_getValue = (key, fallback) => memory.has(key) ? structuredClone(memory.get(key)) : fallback;
|
||||
environment.GM_setValue = (key, value) => { memory.set(key, structuredClone(value)); };
|
||||
} else if (kind === 'modern') {
|
||||
environment.GM = {
|
||||
getValue: async (key, fallback) => memory.has(key) ? structuredClone(memory.get(key)) : fallback,
|
||||
setValue: async (key, value) => { memory.set(key, structuredClone(value)); }
|
||||
};
|
||||
} else {
|
||||
environment.localStorage = { getItem: key => memory.get(key) ?? null, setItem: (key, value) => memory.set(key, value) };
|
||||
}
|
||||
vm.createContext(environment);
|
||||
vm.runInContext(bankSource, environment);
|
||||
vm.runInContext(matcherSource, environment);
|
||||
return { store: environment.YianBankStore, matcher: environment.YianAnswerMatcher, environment, memory };
|
||||
}
|
||||
|
||||
function file(name, text) {
|
||||
const bytes = Buffer.isBuffer(text) ? text : Buffer.from(text);
|
||||
return { name, size: bytes.length, arrayBuffer: async () => bytes.buffer.slice(bytes.byteOffset, bytes.byteOffset + bytes.byteLength) };
|
||||
}
|
||||
|
||||
function record(overrides = {}) {
|
||||
return {
|
||||
role: '主操', course: '风险管理', sourceUrl: 'http://old-host/clientcn/exam/risk/questions?title=x&flag=exercise', number: 1,
|
||||
type: '单选', question: '温度低于 -50.5 ℃ 时,不应进行的作业?',
|
||||
options: [{ label: 'A', text: '作业甲' }, { label: 'B', text: '作业乙' }, { label: 'C', text: '作业丙' }],
|
||||
answer: 'B', explanation: '', ...overrides
|
||||
};
|
||||
}
|
||||
|
||||
function csv(rows) {
|
||||
const headers = ['题号', '题型', '题目', '选项A', '选项B', '选项C', '参考答案', '题目解析', '来源网址', '题库名称'];
|
||||
const cell = value => '"' + String(value ?? '').replace(/"/g, '""') + '"';
|
||||
return '\uFEFF' + [headers, ...rows.map(row => [row.number, row.type, row.question, ...['A', 'B', 'C'].map(label => row.options.find(option => option.label === label)?.text || ''), row.answer, row.explanation, row.sourceUrl, row.course])].map(row => row.map(cell).join(',')).join('\r\n') + '\r\n';
|
||||
}
|
||||
|
||||
(async () => {
|
||||
const { store, matcher, memory } = runtime();
|
||||
assert.equal((await store.load()).storage, 'userscript');
|
||||
assert.equal((await store.load()).total, 0);
|
||||
assert.deepEqual(Array.from(store.roles), ['主操', '班长', '副操']);
|
||||
const parsed = store.parseCSV('\uFEFF"题目","选项A"\r\n"逗号,引号""和\n换行","甲"\r\n');
|
||||
assert.equal(parsed[1][0], '逗号,引号"和\n换行');
|
||||
assert.throws(() => store.parseCSV('a,b\n"未闭合'), /未闭合/);
|
||||
assert.throws(() => store.parseCSV('a,b\n"关闭"坏,b'), /非法字符/);
|
||||
assert.throws(() => store.parseCSV('a,b\n未"转义,b'), /未转义/);
|
||||
assert.notEqual(matcher.normalize('不应在 -50.5℃ 作业'), matcher.normalize('应在 50.5℃ 作业'));
|
||||
assert.notEqual(matcher.normalize('50.5'), matcher.normalize('505'));
|
||||
assert.equal(matcher.normalize('−50.5 ℃'), '-50.5 °c');
|
||||
|
||||
const escaped = record({ options: [{ label: 'A', text: "'-50" }, { label: 'B', text: "'引用" }, { label: 'C', text: "''-50" }] });
|
||||
let state = await store.importFiles([file('风险管理_题库_1题.csv', csv([escaped]))], { role: '主操' });
|
||||
assert.equal(state.records[0].options[0].text, '-50');
|
||||
assert.equal(state.records[0].options[1].text, "'引用");
|
||||
assert.equal(state.records[0].options[2].text, "''-50");
|
||||
assert.equal(state.records[0].correctTexts[0], "'引用");
|
||||
|
||||
state = await store.importFiles([file('风险管理.json', JSON.stringify({ title: '风险管理', sourceUrl: record().sourceUrl, questions: [{ ...record(), role: undefined }] }))], { role: '主操' });
|
||||
assert.equal(state.total, 1, '相同岗位/课程/来源的导入应替换旧数据');
|
||||
assert.equal(state.records[0].questionKey, matcher.questionKey(state.records[0]));
|
||||
assert.equal(state.records[0].optionKey, matcher.optionKey(state.records[0].options));
|
||||
state = await store.importFiles([file('班长.csv', csv([record({ course: '班长课程', sourceUrl: 'http://host/exam/leader/questions' })]))], { role: '班长' });
|
||||
assert.equal(state.total, 2, '导入一个岗位不丢失其他岗位');
|
||||
assert.equal(state.counts.主操, 1); assert.equal(state.counts.班长, 1);
|
||||
const revision = state.revision;
|
||||
await assert.rejects(store.importFiles([file('坏.csv', csv([record({ answer: 'H' })]))], { role: '主操' }), /不存在的选项/);
|
||||
assert.equal((await store.load()).revision, revision, '无效导入应保持原题库不变');
|
||||
await assert.rejects(store.importFiles([file('没有岗位.csv', csv([record()]))]), /选择主操/);
|
||||
await assert.rejects(store.importFiles([file('坏.json', JSON.stringify([record({ correctTexts: ['作业甲'] })]))]), /不一致/);
|
||||
await assert.rejects(store.importFiles([file('空.json', '{"records":[]}')]), /没有题目/);
|
||||
await store.setSetting('role', '班长');
|
||||
assert.equal(await store.getSetting('role', ''), '班长');
|
||||
assert.equal(await store.getSetting('missing', false), false);
|
||||
const reloaded = runtime('modern', memory);
|
||||
assert.equal((await reloaded.store.load()).total, 2);
|
||||
assert.equal(await reloaded.store.getSetting('role'), '班长');
|
||||
const origin = runtime('origin');
|
||||
assert.equal((await origin.store.load()).storage, 'origin');
|
||||
assert.equal((await origin.store.importFiles([file('风险管理.csv', csv([record()]))], { role: '主操' })).storage, 'origin');
|
||||
|
||||
const q = record({ options: [{ label: 'A', text: '作业乙' }, { label: 'B', text: '作业丙' }, { label: 'C', text: '作业甲' }] });
|
||||
let answer = matcher.match(q, { role: '主操', course: '风险管理' }, state);
|
||||
assert.equal(answer.status, 'matched'); assert.deepEqual(Array.from(answer.correctLabels), ['A']);
|
||||
assert.equal(matcher.match({ ...q, question: q.question.replace('不应', '应') }, { role: '主操' }, state).status, 'missing');
|
||||
assert.equal(matcher.match({ ...q, options: q.options.slice(0, 2) }, { role: '主操' }, state).status, 'missing');
|
||||
assert.equal(matcher.match({ ...q, type: '多选' }, { role: '主操' }, state).status, 'missing');
|
||||
assert.equal(matcher.match(q, {}, state).status, 'unsupported');
|
||||
assert.equal(matcher.match({ ...q, questionImages: ['image.png'] }, { role: '主操' }, state).status, 'unsupported');
|
||||
assert.equal(matcher.match({ ...q, options: [q.options[0], { label: 'B', text: q.options[0].text }] }, { role: '主操' }, state).status, 'unsupported');
|
||||
assert.deepEqual(Array.from(matcher.match({ ...q, options: q.options.map(option => ({ ...option, label: option.label + '.' })) }, { role: '主操' }, state).correctLabels), ['A']);
|
||||
assert.equal(matcher.sourceIdentity('http://one:8080/clientcn/exam/risk/questions?flag=exercise'), matcher.sourceIdentity('https://two/#/clientcn/exam/risk/questions?title=风险管理&flag=exercise'));
|
||||
|
||||
const conflict = store.validateRecord(record({ course: '重大危险源', sourceUrl: 'http://other/exam/danger/questions', answer: 'A' }));
|
||||
const sameAnswer = store.validateRecord(record({ number: 2, course: '课程二', sourceUrl: 'http://other/exam/another/questions' }));
|
||||
const groupedState = { records: [state.records[0], conflict, sameAnswer, state.records[1]], revision: 99 };
|
||||
assert.equal(matcher.match(q, { role: '主操' }, groupedState).status, 'conflict');
|
||||
assert.equal(matcher.match(q, { role: '主操', course: '风险管理' }, groupedState).status, 'matched');
|
||||
answer = matcher.match(q, { role: '主操', sourceUrl: 'http://new-ip:1234/#/clientcn/exam/risk/questions?flag=exercise' }, groupedState);
|
||||
assert.equal(answer.status, 'matched'); assert.equal(answer.records.length, 1);
|
||||
answer = matcher.match(q, { role: '主操', course: '重大危险源' }, groupedState);
|
||||
assert.equal(answer.status, 'matched'); assert.deepEqual(Array.from(answer.correctLabels), ['C']);
|
||||
assert.equal(matcher.search('温度', {}, groupedState).total, 0);
|
||||
assert.equal(matcher.search('', { role: '主操' }, groupedState).total, 0);
|
||||
let found = matcher.search('温度 -50.5 作业乙', { role: '主操' }, groupedState);
|
||||
assert.equal(found.total, 2, '同题同选项同答案合并,冲突答案分别展示');
|
||||
assert(found.items.every(item => item.conflict));
|
||||
assert(found.items.some(item => item.sources.length === 2));
|
||||
assert.equal(matcher.search('温度', { role: '班长' }, groupedState).total, 1);
|
||||
assert.equal(matcher.search('温度', { role: '主操', course: '风险管理' }, groupedState).items[0].conflict, false);
|
||||
assert.equal(matcher.search('温度不存在', { role: '主操' }, groupedState).total, 0);
|
||||
found = matcher.search('温度', { role: '主操', limit: 1, offset: 0 }, groupedState);
|
||||
assert.equal(found.items.length, 1); assert.equal(found.hasMore, true);
|
||||
assert.equal(matcher.search('温度', { role: '主操', limit: 1, offset: 1 }, groupedState).hasMore, false);
|
||||
|
||||
const multiple = store.validateRecord(record({ type: '多选', answer: 'AC' }));
|
||||
const multipleResult = matcher.match({ ...q, type: '多选' }, { role: '主操' }, { records: [multiple], revision: 1 });
|
||||
assert.equal(multipleResult.status, 'matched');
|
||||
assert.deepEqual(Array.from(multipleResult.correctLabels), ['B', 'C']);
|
||||
const quotaRuntime = runtime();
|
||||
await quotaRuntime.store.importFiles([file('风险管理.csv', csv([record()]))], { role: '主操' });
|
||||
quotaRuntime.environment.GM_setValue = () => { throw new Error('quota exceeded'); };
|
||||
await assert.rejects(quotaRuntime.store.importFiles([file('班长.csv', csv([record({ course: '班长课程' })]))], { role: '班长' }), /quota/);
|
||||
assert.equal((await quotaRuntime.store.load()).total, 1, '保存失败应保持可用的旧题库');
|
||||
|
||||
// Calls are serialized so independently imported roles cannot overwrite each other.
|
||||
const parallelRuntime = runtime();
|
||||
await Promise.all([
|
||||
parallelRuntime.store.importFiles([file('主操.csv', csv([record()]))], { role: '主操' }),
|
||||
parallelRuntime.store.importFiles([file('班长.csv', csv([record()]))], { role: '班长' })
|
||||
]);
|
||||
assert.equal((await parallelRuntime.store.load()).total, 2);
|
||||
|
||||
// ZIP all-or-nothing replacement; lock files and unrelated metadata are ignored.
|
||||
const zip = new JSZip();
|
||||
zip.file('题库/副操/风险管理.csv', csv([record({ sourceUrl: 'http://host/exam/a/questions' })]));
|
||||
zip.file('题库/副操/~$风险管理.csv', 'broken');
|
||||
zip.file('__MACOSX/._file.csv', 'broken');
|
||||
state = await store.importFiles([file('替换.zip', await zip.generateAsync({ type: 'nodebuffer' }))]);
|
||||
assert.equal(state.total, 1); assert.equal(state.counts.副操, 1); assert.equal(state.counts.主操, 0);
|
||||
const invalidZip = new JSZip();
|
||||
invalidZip.file('题库/主操/正常.csv', csv([record()]));
|
||||
invalidZip.file('题库/班长/损坏.csv', csv([record({ answer: 'H' })]));
|
||||
await assert.rejects(store.importFiles([file('损坏.zip', await invalidZip.generateAsync({ type: 'nodebuffer' }))]), /不存在的选项/);
|
||||
assert.equal((await store.load()).total, 1);
|
||||
await assert.rejects(store.importFiles([file('题库.zip', await zip.generateAsync({ type: 'nodebuffer' })), file('一份.csv', csv([record()]))]), /只选择一个/);
|
||||
console.log('PASS 本地存储、原子导入、CSV引号/换行/转义、岗位隔离、精确换序匹配、答案冲突、来源消歧与搜索分页');
|
||||
|
||||
const realZip = path.join(rootDir, '题库.zip');
|
||||
if (fs.existsSync(realZip)) {
|
||||
const started = performance.now();
|
||||
const imported = await store.importFiles([file('题库.zip', fs.readFileSync(realZip))]);
|
||||
assert.equal(imported.total, 5520);
|
||||
assert.equal(imported.counts.主操 + imported.counts.班长 + imported.counts.副操, 5520);
|
||||
assert(imported.records.every(row => row.correctTexts.length > 0));
|
||||
const courseConflicts = matcher.search('常压罐组收料作业中', { role: '主操', limit: 100 }, imported);
|
||||
assert(courseConflicts.items.some(item => item.conflict), '真实跨课程冲突应显式显示');
|
||||
const noSource = matcher.match(courseConflicts.items.find(item => item.conflict), { role: '主操' }, imported);
|
||||
assert.equal(noSource.status, 'conflict');
|
||||
const exact = imported.records[0];
|
||||
const matched = matcher.match(exact, { role: exact.role, course: exact.course, sourceUrl: exact.sourceUrl }, imported);
|
||||
assert.equal(matched.status, 'matched');
|
||||
assert.equal(new Set(imported.records.map(item => item.sourceFile)).size, 28);
|
||||
console.log('PASS 实际题库 ZIP:28 CSV,5520 题,岗位数量 ' + JSON.stringify(imported.counts) + ';导入耗时 ' + Math.round(performance.now() - started) + ' ms');
|
||||
}
|
||||
})().catch(error => { console.error(error); process.exitCode = 1; });
|
||||
@@ -0,0 +1,6 @@
|
||||
const { spawnSync } = require('node:child_process');
|
||||
const path = require('node:path');
|
||||
for (const file of ['bank-engine.cjs', 'runner-browser.cjs', 'verify.cjs', 'assistant.cjs']) {
|
||||
const result = spawnSync(process.execPath, [path.join(__dirname, file)], { stdio: 'inherit' });
|
||||
if (result.status !== 0) process.exit(result.status || 1);
|
||||
}
|
||||
@@ -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; });
|
||||
+3
-3
@@ -121,7 +121,7 @@ function parseCSV(text) {
|
||||
document.querySelector('app-examtest').innerHTML = new DOMParser().parseFromString(html, 'text/html').querySelector('app-examtest').innerHTML;
|
||||
}, { url: practiceURL('缺答案练习'), html: fixture('缺答案练习', { missingAnswer: true }) });
|
||||
await page.waitForTimeout(2600); assert.equal(downloads.length, beforeMissing);
|
||||
assert((await page.locator('#yian-question-bank-exporter').innerText()).includes('缺少 1 个答案'));
|
||||
assert((await page.locator('#yian-question-assistant .page-status').innerText()).includes('缺少 1 个网页答案'));
|
||||
const manual = page.waitForEvent('download'); await page.getByRole('button', { name: '导出 CSV', exact: true }).click();
|
||||
assert((await manual).suggestedFilename().includes('_不完整'));
|
||||
const missing = await page.evaluate(() => QuestionBankCore.extract(document));
|
||||
@@ -134,9 +134,9 @@ function parseCSV(text) {
|
||||
}, { url: practiceURL('缺题练习'), html: fixture('缺题练习', { cardCount: 4 }) });
|
||||
const beforeCount = downloads.length;
|
||||
await page.waitForTimeout(2600); assert.equal(downloads.length, beforeCount);
|
||||
assert((await page.locator('#yian-question-bank-exporter').innerText()).includes('3 / 4'));
|
||||
assert((await page.locator('#yian-question-assistant .page-status').innerText()).includes('3 / 4'));
|
||||
await page.evaluate(() => history.pushState({}, '', location.href.replace('flag=exercise', 'flag=exam')));
|
||||
await page.waitForTimeout(1100); assert.equal(await page.locator('#yian-question-bank-exporter').count(), 0);
|
||||
await page.waitForTimeout(1100); assert(await page.getByRole('button', { name: '导出 CSV', exact: true }).isDisabled());
|
||||
assert.equal(await page.evaluate(() => window.answerClicks), 0);
|
||||
console.log('PASS 缺题等待、考试页面不触发、不改变答题状态');
|
||||
|
||||
|
||||
Reference in New Issue
Block a user