feat: add opt-in answer selection and global hidden panel

This commit is contained in:
suanyuit-cyber
2026-10-29 18:29:58 +08:00
parent 6b04243428
commit 83ca85cc17
10 changed files with 1171 additions and 89 deletions
+222 -17
View File
@@ -12,7 +12,7 @@ const http = require('node:http');
const ROOT = path.resolve(__dirname, '..');
const RECORDS_KEY = 'huayi-qb.records.v1';
const CONFIG_KEY = 'huayi-qb.config.v1';
const QA_DIR = path.join(os.tmpdir(), 'huayi-qb-qa-v110');
const QA_DIR = path.join(os.tmpdir(), 'huayi-qb-qa-v120');
const BUNDLED = path.join(os.homedir(), '.cache/codex-runtimes/codex-primary-runtime/dependencies/node/node_modules');
function loadPlaywright() {
@@ -125,6 +125,9 @@ function installGMShim() {
window.GM_registerMenuCommand = (label, callback) => { window.__qbMenus.push({ label, callback }); return window.__qbMenus.length; };
window.GM_addValueChangeListener = () => 1;
window.GM_removeValueChangeListener = () => {};
window.__qbOriginalFetch = window.fetch;
window.__qbOriginalXHROpen = XMLHttpRequest.prototype.open;
window.__qbOriginalXHRSend = XMLHttpRequest.prototype.send;
}
async function waitFor(predicate, message, timeout = 5000) {
@@ -142,6 +145,7 @@ async function storageRecords(page) {
}
async function activate(page, tab) {
if (!await page.locator('#qb-panel').isVisible()) await page.keyboard.press('Alt+Shift+Q');
const exact = page.locator(`#huayi-qb-root [data-tab="${tab}"]`);
if (await exact.count()) { await exact.click(); return; }
const names = { search: /检索|查询/, library: /题库|导出/, settings: /设置/, collect: /采集/ };
@@ -152,6 +156,7 @@ async function activate(page, tab) {
async function importFile(page, name, text) {
await activate(page, 'library');
await page.locator('#qb-import-file').setInputFiles({ name, mimeType: name.endsWith('.json') ? 'application/json' : 'text/csv', buffer: Buffer.from(text) });
await waitFor(async () => await page.locator('#qb-import-file').evaluate(input => input.files.length === 0), '等待异步文件解析和界面刷新完成');
}
async function selectQuestion(page) {
@@ -176,6 +181,57 @@ async function setSingleQuestion(page) {
});
}
function ordinaryFixture() {
return `<!doctype html><html lang="zh-CN"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1"><title>普通网页自动答题验收</title>
<style>body{margin:30px;background:#f1f5f8;color:#253b50;font:16px/1.7 system-ui,sans-serif}main{max-width:700px;background:white;border-radius:12px;padding:25px}fieldset{border:1px solid #ccd9e4;border-radius:10px;padding:18px}legend{font-weight:600}label{display:block;padding:10px;border-bottom:1px solid #edf0f3}button{margin:12px 8px 0 0;padding:8px 14px}@media(max-width:500px){body{margin:12px}main{padding:14px}}</style>
</head><body><main><h1>普通网页练习</h1><p>使用本地题库,仅选择已确认的选项。</p><form id="ordinary-form"><div id="ordinary-question"></div><button type="submit" id="ordinary-submit">提交答案</button><button type="button" id="ordinary-next">下一题</button></form><button type="button" id="ordinary-unrelated">普通功能按钮</button></main></body></html>`;
}
const AUTO_FIXTURES = {
single: { type: 'single', stem: '通用单选:哪项操作有助于提高温度?', options: [['hold', '保持不变'], ['cool', '降低温度'], ['heat', '提高温度']], answer: ['提高温度'] },
judge: { type: 'judge', stem: '通用判断:停车检修时可以跳过确认步骤。', options: [['1', '正确'], ['0', '错误']], answer: ['错误'] },
multiple: { type: 'multiple', stem: '通用多选:巡检时需要检查哪些参数?', options: [['skip', '跳过检查'], ['pressure', '压力'], ['temperature', '温度']], answer: ['压力', '温度'] },
continuous: { type: 'single', stem: '连续模式:换题后应选择哪个已确认的项目?', options: [['hold', '保持状态'], ['check', '完成检查']], answer: ['完成检查'] },
stopped: { type: 'single', stem: '关闭模式:这一题应由用户自行选择。', options: [['stop-a', '选项甲'], ['stop-b', '选项乙']], answer: ['选项乙'] },
ambiguous: { type: 'single', stem: '歧义题:同题干两个来源给出不同答案。', options: [['a', '做法甲'], ['b', '做法乙']], answer: ['做法甲'] },
missing: { type: 'single', stem: '尚无标准答案的通用题目。', options: [['a', '选项一'], ['b', '选项二']], answer: [] }
};
function autoRecords() {
const records = Object.entries(AUTO_FIXTURES).filter(([key]) => key !== 'missing').map(([key, item]) => ({
bankId: 'generic-seed-bank', bankName: '通用已确认题库', questionId: `generic-${key}`, type: item.type, stem: item.stem,
options: item.options.map(([id, text], index) => ({ id, text, label: item.type === 'judge' ? text : String.fromCharCode(65 + index) })),
answerTexts: item.answer, correctOptionIds: item.options.filter(([, text]) => item.answer.includes(text)).map(([id]) => id), answerStatus: 'confirmed'
}));
const ambiguous = records.find(record => record.questionId === 'generic-ambiguous');
records.push({ ...ambiguous, bankId: 'other-generic-bank', bankName: '另一来源题库', questionId: 'generic-ambiguous-other', answerTexts: ['做法乙'], correctOptionIds: ['b'] });
return records;
}
async function installOrdinaryQuestion(page, key, wrongChecked = false) {
await page.evaluate(({ question, wrongChecked }) => {
const root = document.querySelector('#ordinary-question');
root.replaceChildren();
const fieldset = document.createElement('fieldset');
fieldset.id = 'generic-question';
const legend = document.createElement('legend');
legend.textContent = question.stem;
fieldset.append(legend);
question.options.forEach(([id, text], index) => {
const label = document.createElement('label');
const input = document.createElement('input');
input.type = question.type === 'multiple' ? 'checkbox' : 'radio';
input.name = 'ordinary-answer';
input.value = id;
input.id = `ordinary-${id}`;
if (wrongChecked && index === 0) input.checked = true;
label.append(input, document.createTextNode(` ${text}`));
fieldset.append(label);
});
root.append(fieldset);
}, { question: AUTO_FIXTURES[key], wrongChecked });
}
test('真实浏览器:采集、答案映射、导入导出与面板隐藏恢复', { timeout: 150000 }, async t => {
const { chromium } = loadPlaywright();
const builtPath = path.join(ROOT, 'dist/huayi-question-bank.user.js');
@@ -231,6 +287,10 @@ test('真实浏览器:采集、答案映射、导入导出与面板隐藏恢
try {
await page.goto(`${origin}/o2o/#/playinfo?taskId=qa-task-initial`, { waitUntil: 'domcontentloaded' });
await page.locator('#huayi-qb-root').waitFor({ state: 'attached' });
assert.equal(await page.locator('#qb-panel').isVisible(), false, '首次加载默认完全隐藏');
assert.equal(await page.locator('#qb-launcher').isVisible(), false, '默认不保留悬浮按钮');
await page.keyboard.press('Alt+Shift+Q');
await page.locator('#qb-panel').waitFor({ state: 'visible' });
await t.test('页面非空、脱敏DOM夹具采集缺答案题目并恢复文字选择', async () => {
assert.match(await page.title(), /判断题库1/);
@@ -281,7 +341,7 @@ test('真实浏览器:采集、答案映射、导入导出与面板隐藏恢
await activate(page, 'search');
});
await t.test('折叠后可展开;隐藏与刷新后恢复入口保留', async () => {
await t.test('折叠后可展开;隐藏无悬浮入口且刷新后仍完全隐藏', async () => {
const fullHeight = (await page.locator('#qb-panel').boundingBox()).height;
await page.locator('[data-action="collapse"]').click();
const collapsedHeight = (await page.locator('#qb-panel').boundingBox()).height;
@@ -289,41 +349,34 @@ test('真实浏览器:采集、答案映射、导入导出与面板隐藏恢
await page.locator('[data-action="collapse"]').click();
await page.locator('.header [data-action="hide"]').click();
assert.equal(await page.locator('#qb-panel').isVisible(), false);
assert.equal(await page.locator('#qb-launcher').isVisible(), true);
assert.equal(await page.locator('#qb-launcher').isVisible(), false);
await page.reload({ waitUntil: 'domcontentloaded' });
await page.locator('#qb-launcher').waitFor({ state: 'visible' });
await page.locator('#huayi-qb-root').waitFor({ state: 'attached' });
assert.equal(await page.locator('#qb-panel').isVisible(), false);
await selectQuestion(page);
await page.waitForTimeout(300);
assert.equal(await page.locator('#qb-panel').isVisible(), false, '隐藏后划词不能擅自弹出面板');
await page.screenshot({ path: path.join(QA_DIR, 'hidden-launcher.png') });
await page.locator('[data-action="restore"]').click();
await page.screenshot({ path: path.join(QA_DIR, 'hidden-default.png') });
await page.keyboard.press('Alt+Shift+Q');
assert.equal(await page.locator('#qb-panel').isVisible(), true);
assert.match(await page.locator('#qb-search').inputValue(), /干燥过程传质推动力/, '恢复面板后应保留隐藏时划词检索的内容');
});
await t.test('完全隐藏后快捷键与油猴菜单均可唤出', async () => {
await t.test('快捷键切换显隐;即使旧配置为打开和悬浮按钮刷新也保持隐藏', async () => {
await activate(page, 'settings');
await page.locator('#qb-keep-launcher').uncheck();
assert.equal(await page.locator('#qb-keep-launcher').count(), 0, '默认隐藏不再提供悬浮按钮偏好');
await page.locator('.header [data-action="hide"]').click();
assert.equal(await page.locator('#qb-panel').isVisible(), false);
assert.equal(await page.locator('#qb-launcher').isVisible(), false);
await page.evaluate(key => window.GM_setValue(key, { ...window.GM_getValue(key, {}), mode: 'open', keepLauncher: true }), CONFIG_KEY);
await page.reload({ waitUntil: 'domcontentloaded' });
await page.waitForSelector('#huayi-qb-root', { state: 'attached' });
assert.equal(await page.locator('#qb-launcher').isVisible(), false);
await page.keyboard.press('Alt+Shift+Q');
await page.locator('#qb-panel').waitFor({ state: 'visible' });
await page.locator('.header [data-action="hide"]').click();
const menuFound = await page.evaluate(() => {
const menu = window.__qbMenus.find(item => /显示|唤出|恢复|打开/.test(item.label));
if (!menu) return false;
menu.callback();
return true;
});
assert.equal(menuFound, true, '应提供油猴菜单恢复入口');
await page.keyboard.press('Alt+Shift+Q');
assert.equal(await page.locator('#qb-panel').isVisible(), true);
await activate(page, 'settings');
await page.locator('#qb-keep-launcher').check();
});
await t.test('JSON导入与当前页面选项乱序:正确内容映射为C', async () => {
@@ -470,6 +523,9 @@ test('真实浏览器:采集、答案映射、导入导出与面板隐藏恢
bankPage.on('console', message => { if (message.type() === 'error') errors.push(message.text()); });
try {
await bankPage.goto(`${origin}/multi-bank#/playinfo?taskId=${BANK_FIXTURES[0].taskId}`, { waitUntil: 'domcontentloaded' });
await bankPage.locator('#huayi-qb-root').waitFor({ state: 'attached' });
assert.equal(await bankPage.locator('#qb-panel').isVisible(), false);
await bankPage.keyboard.press('Alt+Shift+Q');
await bankPage.locator('#qb-panel').waitFor({ state: 'visible' });
await setupBankSwitches(bankPage);
await waitFor(async () => (await storageRecords(bankPage)).some(record => record.bankId === 'judge-1'), '初始判断题库应采集到稳定身份');
@@ -499,6 +555,30 @@ test('真实浏览器:采集、答案映射、导入导出与面板隐藏恢
await bankPage.screenshot({ path: path.join(QA_DIR, 'current-bank-export.png') });
});
await group.test('华谊题目DOM可自动选择多选和乱序单选,保持提交与下一题由用户操作', async () => {
await bankPage.evaluate(() => {
window.__qaSiteButtonClicks = 0;
document.querySelector('.fixture-content').addEventListener('click', event => {
if (event.target.closest('button')) window.__qaSiteButtonClicks++;
});
});
await activate(bankPage, 'auto');
assert.equal(await bankPage.locator('#qb-auto-enabled').isChecked(), false);
await bankPage.locator('[data-action="auto-answer-current"]').click();
await waitFor(async () => (await bankPage.locator('.yxtulcdsdk-form input:checked').count()) === 2, '华谊多选应同时选择两个标准答案');
assert.equal(await bankPage.locator('.yxtulcdsdk-form input[value="check-t"]').isChecked(), true);
assert.equal(await bankPage.locator('.yxtulcdsdk-form input[value="check-p"]').isChecked(), true);
assert.equal(await bankPage.locator('.yxtulcdsdk-form input[value="skip"]').isChecked(), false);
await bankPage.locator('[data-qa-bank="single-1"]').click();
await waitFor(async () => (await bankPage.locator('#qb-context-name').innerText()).includes('单选题库1'), '切到单选库等待题库身份更新');
await waitFor(async () => (await bankPage.locator('.yxtulcdsdk-form__content > [data-rich-text]').innerText()).includes('单选库一'), '等待新题目正文加载');
await bankPage.locator('[data-action="auto-answer-current"]').click();
await waitFor(async () => await bankPage.locator('.yxtulcdsdk-form input[value="yes"]').isChecked(), '华谊单选应按当前C位置选中标准内容');
assert.equal(await bankPage.locator('.yxtulcdsdk-form input:checked').count(), 1);
assert.equal(await bankPage.evaluate(() => window.__qaSiteButtonClicks), 0, '自动答题不能点击题目区的上一题、下一题或提交按钮');
await bankPage.screenshot({ path: path.join(QA_DIR, 'huayi-auto-answer.png') });
});
await group.test('文字搜索可手输题干、选项与标准答案,支持当前库和全部库', async () => {
await bankPage.evaluate(() => { window.__qaLateResponse = fetch('/upm/start?praId=multiple&item=bank-multiple&delay=500').then(response => response.json()); });
await bankPage.locator('[data-qa-bank="single-1"]').click();
@@ -619,6 +699,131 @@ test('真实浏览器:采集、答案映射、导入导出与面板隐藏恢
} finally { await bankContext.close(); }
});
await t.test('全局网页隐藏唤出与自动答题', async group => {
assert.ok(/\/\/ @match\s+\*:\/\/\*\/\*/.test(script) || (/\/\/ @match\s+http:\/\/\*\/\*/.test(script) && /\/\/ @match\s+https:\/\/\*\/\*/.test(script)), '安装元数据应覆盖普通HTTP/HTTPS网页');
const genericContext = await browser.newContext({ viewport: { width: 1440, height: 1000 }, acceptDownloads: true });
const seed = JSON.stringify(autoRecords());
const initialization = `(${installGMShim.toString()})();\nif(!localStorage.getItem(${JSON.stringify(RECORDS_KEY)}))localStorage.setItem(${JSON.stringify(RECORDS_KEY)},${JSON.stringify(seed)});\nlocalStorage.setItem(${JSON.stringify(CONFIG_KEY)},JSON.stringify({mode:'open',keepLauncher:true,copyEnabled:true,collecting:true,tab:'search'}));\n${script}`;
await genericContext.addInitScript({ content: initialization });
await genericContext.route('**/*', route => {
if (route.request().url().endsWith('/favicon.ico')) return route.fulfill({ status: 204, body: '' });
if (new URL(route.request().url()).pathname === '/upm/start') return route.fulfill({ status: 200, contentType: 'application/json', body: JSON.stringify(bankResponse(BANK_FIXTURES[0])) });
return route.fulfill({ status: 200, contentType: 'text/html; charset=utf-8', body: ordinaryFixture() });
});
const genericPage = await genericContext.newPage();
genericPage.on('pageerror', error => errors.push(error.message));
genericPage.on('console', message => { if (message.type() === 'error') errors.push(message.text()); });
try {
await genericPage.goto('http://ordinary.example.test/exercise', { waitUntil: 'domcontentloaded' });
await genericPage.locator('#huayi-qb-root').waitFor({ state: 'attached' });
await genericPage.evaluate(() => {
window.__qaSubmitCount = 0; window.__qaNextCount = 0; window.__qaUnrelatedCount = 0;
document.querySelector('#ordinary-form').addEventListener('submit', event => { event.preventDefault(); window.__qaSubmitCount++; });
document.querySelector('#ordinary-next').addEventListener('click', () => window.__qaNextCount++);
document.querySelector('#ordinary-unrelated').addEventListener('click', () => window.__qaUnrelatedCount++);
});
await installOrdinaryQuestion(genericPage, 'single');
await group.test('普通域名每次默认隐藏,快捷键显示/隐藏,不拦截网页网络和采集普通表单', async () => {
assert.equal(await genericPage.locator('#qb-panel').isVisible(), false);
assert.equal(await genericPage.locator('#qb-launcher').isVisible(), false);
assert.equal(await genericPage.evaluate(() => window.fetch === window.__qbOriginalFetch && XMLHttpRequest.prototype.open === window.__qbOriginalXHROpen && XMLHttpRequest.prototype.send === window.__qbOriginalXHRSend), true, '普通网页网络函数应保持原样');
await genericPage.evaluate(() => fetch('/upm/start?praId=judge-1').then(response => response.json()));
await genericPage.waitForTimeout(300);
assert.equal((await storageRecords(genericPage)).length, autoRecords().length, '普通页面不得把任意业务响应采集进题库');
await genericPage.keyboard.press('Alt+Shift+Q');
await genericPage.locator('#qb-panel').waitFor({ state: 'visible' });
assert.equal(await genericPage.locator('#qb-search-scope').inputValue(), '', '没有当前题库的普通页默认搜索全部库');
await genericPage.locator('#qb-search').fill('提高温度');
await genericPage.locator('#qb-search').press('Enter');
await waitFor(async () => (await genericPage.locator('#qb-results article').count()) === 1, '普通网页也可查已导入题库');
await genericPage.keyboard.press('Alt+Shift+Q');
assert.equal(await genericPage.locator('#qb-panel').isVisible(), false);
await genericPage.keyboard.press('Alt+Shift+Q');
assert.equal(await genericPage.locator('#qb-panel').isVisible(), true);
await activate(genericPage, 'auto');
assert.equal(await genericPage.locator('#qb-auto-enabled').isChecked(), false, '自动答题默认关闭');
assert.equal(await genericPage.locator('#generic-question input:checked').count(), 0);
});
await group.test('单选乱序按内容选择当前答案且不提交或点击下一题', async () => {
await genericPage.locator('[data-action="auto-answer-current"]').click();
await waitFor(async () => await genericPage.locator('#ordinary-heat').isChecked(), '应选择当前顺序中的提高温度');
assert.equal(await genericPage.locator('#generic-question input:checked').count(), 1);
assert.equal(await genericPage.evaluate(() => window.__qaSubmitCount + window.__qaNextCount), 0);
await genericPage.screenshot({ path: path.join(QA_DIR, 'generic-auto-single.png') });
});
await group.test('判断与多选完整集合正确,取消原来选错的多选项', async () => {
await installOrdinaryQuestion(genericPage, 'judge');
await genericPage.locator('[data-action="auto-answer-current"]').click();
await waitFor(async () => await genericPage.locator('#ordinary-0').isChecked(), '判断题应选择错误,不能将0当真');
assert.equal(await genericPage.locator('#generic-question input:checked').count(), 1);
await installOrdinaryQuestion(genericPage, 'multiple', true);
assert.equal(await genericPage.locator('#ordinary-skip').isChecked(), true);
await genericPage.locator('[data-action="auto-answer-current"]').click();
await waitFor(async () => await genericPage.locator('#ordinary-pressure').isChecked() && await genericPage.locator('#ordinary-temperature').isChecked() && !await genericPage.locator('#ordinary-skip').isChecked(), '多选必须选择全部正确项并取消错误项');
assert.equal(await genericPage.locator('#generic-question input:checked').count(), 2);
assert.equal(await genericPage.evaluate(() => window.__qaSubmitCount + window.__qaNextCount), 0);
});
await group.test('缺答案与不同来源冲突题跳过,不误点普通功能按钮', async () => {
for (const key of ['missing', 'ambiguous']) {
await installOrdinaryQuestion(genericPage, key);
await genericPage.locator('[data-action="auto-answer-current"]').click();
await genericPage.waitForTimeout(200);
assert.equal(await genericPage.locator('#generic-question input:checked').count(), 0, `${key}题目不应猜测答案`);
assert.match(await genericPage.locator('#qb-auto-status').innerText(), /未|没有|跳过|冲突|歧义|不明确|无法|多个/);
}
assert.equal(await genericPage.evaluate(() => window.__qaUnrelatedCount), 0);
});
await group.test('连续模式选择动态新题,关闭后立即停止,刷新后关闭并隐藏', async () => {
await installOrdinaryQuestion(genericPage, 'continuous');
await genericPage.locator('#qb-auto-enabled').check();
await waitFor(async () => await genericPage.locator('#ordinary-check').isChecked(), '连续模式开启后选择已确认答案');
await installOrdinaryQuestion(genericPage, 'single');
await waitFor(async () => await genericPage.locator('#ordinary-heat').isChecked(), '连续模式应处理动态换题');
await genericPage.locator('#qb-auto-enabled').uncheck();
await installOrdinaryQuestion(genericPage, 'stopped');
await genericPage.waitForTimeout(600);
assert.equal(await genericPage.locator('#generic-question input:checked').count(), 0, '关闭后不得继续操作新题');
await genericPage.locator('#qb-auto-enabled').check();
await waitFor(async () => await genericPage.locator('#ordinary-stop-b').isChecked(), '重新开启后可选择当前题');
await genericPage.reload({ waitUntil: 'domcontentloaded' });
await genericPage.locator('#huayi-qb-root').waitFor({ state: 'attached' });
assert.equal(await genericPage.locator('#qb-panel').isVisible(), false, '刷新不应记住打开状态');
assert.equal(await genericPage.locator('#qb-launcher').isVisible(), false);
await genericPage.keyboard.press('Alt+Shift+Q');
await activate(genericPage, 'auto');
assert.equal(await genericPage.locator('#qb-auto-enabled').isChecked(), false, '连续模式不能跨页面自动开启');
await installOrdinaryQuestion(genericPage, 'single');
await genericPage.waitForTimeout(300);
assert.equal(await genericPage.locator('#generic-question input:checked').count(), 0);
});
await group.test('另一个HTTPS普通域名也默认隐藏,普通无题页面和窄屏不误操作', async () => {
await genericPage.goto('https://another.example.test/ordinary', { waitUntil: 'domcontentloaded' });
await genericPage.locator('#huayi-qb-root').waitFor({ state: 'attached' });
assert.equal(await genericPage.locator('#qb-panel').isVisible(), false);
await genericPage.keyboard.press('Alt+Shift+Q');
await activate(genericPage, 'auto');
await genericPage.evaluate(() => {
document.querySelector('#ordinary-form').replaceChildren();
const option = document.createElement('input'); option.type = 'checkbox'; option.id = 'ordinary-unrelated-checkbox';
document.querySelector('main').append(option);
});
await genericPage.locator('[data-action="auto-answer-current"]').click();
await genericPage.waitForTimeout(200);
assert.equal(await genericPage.locator('#ordinary-unrelated-checkbox').isChecked(), false);
await genericPage.setViewportSize({ width: 390, height: 844 });
const rect = await genericPage.locator('#qb-panel').boundingBox();
assert.ok(rect.x >= 0 && rect.x + rect.width <= 391 && rect.y >= 0 && rect.y + rect.height <= 845);
await genericPage.screenshot({ path: path.join(QA_DIR, 'generic-auto-mobile.png') });
});
} finally { await genericContext.close(); }
});
await t.test('控制台无脚本错误', async () => { assert.deepEqual(errors, []); });
t.diagnostic(`Browser plugin not available; cached Chromium via Playwright. Local origin: ${origin}; viewports 1440x1000 and 390x844; evidence: ${QA_DIR}`);
} finally {