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
+90
View File
@@ -318,3 +318,93 @@ test('initial HTML parsing and atomic full SPA render do not block a new form',
assert.equal(question.stem, '同步渲染的新题目?');
page.adapter.stop();
});
test('ordinary pages do not observe requests, capture questions or intercept copy events', async () => {
const page = fakePage(async () => new Response(JSON.stringify({ listQues: [single()] }), { headers: { 'content-type': 'application/json' } }));
page.win.location.href = 'https://ordinary.example/account';
page.doc.title = '普通网页标题不应成为题库';
const originalFetch = page.win.fetch;
const originalOpen = page.win.XMLHttpRequest.prototype.open;
const originalSend = page.win.XMLHttpRequest.prototype.send;
const listeners = [];
const observers = [];
page.win.addEventListener = type => listeners.push(type);
page.win.removeEventListener = () => {};
page.win.MutationObserver = class {
constructor(callback) { observers.push(callback); }
observe() {}
};
page.doc.createElement = () => assert.fail('普通网页不能注入复制恢复样式');
const changes = [];
const adapter = create({ window: page.win, document: page.doc, observeNetwork: false,
onQuestions: values => page.received.push(...values), onBankChange: bank => changes.push(bank) });
adapter.start();
adapter.setCopyEnabled(true);
assert.equal(adapter.isSupportedPage(), false);
assert.equal(adapter.getCurrentQuestion(), null);
assert.equal(adapter.captureCurrent(), null);
assert.deepEqual(adapter.getCurrentBank(), { bankId: '', bankName: '', taskId: '', loading: false });
await page.win.fetch('/upm/start?praId=not-a-bank');
const xhr = new page.win.XMLHttpRequest();
xhr.open('GET', '/upm/start?praId=not-a-bank'); xhr.send(); xhr.respond({ listQues: [single()] });
await flush();
adapter.stop();
assert.equal(page.win.fetch, originalFetch);
assert.equal(page.win.XMLHttpRequest.prototype.open, originalOpen);
assert.equal(page.win.XMLHttpRequest.prototype.send, originalSend);
assert.deepEqual(page.received, [], '普通页响应即使碰巧形似练习数据也不能写入题库');
assert.deepEqual(listeners, [], '普通页无需安装copy、selection或导航监听');
assert.deepEqual(observers, [], '普通页不需要观察整个DOM');
assert.ok(changes.every(bank => bank.bankName === '' && bank.bankId === ''));
});
test('network observation can be disabled independently on a supported exercise page', async () => {
const page = bankPage();
const originalFetch = page.win.fetch;
const originalOpen = page.win.XMLHttpRequest.prototype.open;
const adapter = create({ window: page.win, document: page.doc, observeNetwork: false,
onQuestions: values => page.received.push(...values) });
adapter.start();
assert.equal(adapter.isSupportedPage(), true);
assert.equal(page.received.length, 1, '已知站点的页面采集不依赖网络钩子');
assert.equal(page.received[0].stem, '升温时应选择?');
assert.equal(page.received[0].bankName, '判断题库1');
assert.equal(page.received[0].answerStatus, 'missing');
assert.equal(page.win.fetch, originalFetch);
assert.equal(page.win.XMLHttpRequest.prototype.open, originalOpen);
adapter.stop();
});
test('a known root appearing later is detectable without automatically enabling network observation', () => {
const page = fakePage();
page.win.location.href = 'https://ordinary.example/demo';
page.doc.title = '显式题目结构演示';
let exerciseRoot = null;
page.doc.querySelector = selector => selector === ROOT_SELECTOR_FOR_TEST ? exerciseRoot : null;
const originalFetch = page.win.fetch;
const originalOpen = page.win.XMLHttpRequest.prototype.open;
const adapter = create({ window: page.win, document: page.doc, observeNetwork: false,
onQuestions: values => page.received.push(...values) });
adapter.start();
assert.equal(adapter.isSupportedPage(), false);
assert.equal(adapter.getCurrentBank().bankName, '');
exerciseRoot = {};
assert.equal(adapter.isSupportedPage(), true);
assert.equal(adapter.getCurrentBank().bankName, '显式题目结构演示');
assert.equal(page.win.fetch, originalFetch);
assert.equal(page.win.XMLHttpRequest.prototype.open, originalOpen);
assert.deepEqual(page.received, []);
adapter.stop();
});
test('a lookalike hostname is not treated as the supported learning platform', () => {
const page = fakePage();
page.win.location.href = 'https://www.shhuayi-elearning.cn.ordinary.example/';
page.doc.title = '页面标题不是题库';
const adapter = create({ window: page.win, document: page.doc, observeNetwork: false });
adapter.start();
assert.equal(adapter.isSupportedPage(), false);
assert.equal(adapter.getCurrentBank().bankName, '');
assert.equal(adapter.getCurrentQuestion(), null);
adapter.stop();
});
+125
View File
@@ -0,0 +1,125 @@
'use strict';
const test = require('node:test');
const assert = require('node:assert/strict');
const core = require('../src/core.js');
const { planAnswer, create } = require('../src/autofill.js');
function single(extra = {}) {
return { bankId: 'bank-a', bankName: '题库一', questionId: 'q1', type: 'single', stem: '以下哪项操作可以提高温度?',
options: [{ id: 'a', text: '降低温度', label: 'A' }, { id: 'b', text: '提高温度', label: 'B' }, { id: 'c', text: '保持不变', label: 'C' }],
correctOptionIds: ['b'], answerTexts: ['提高温度'], answerStatus: 'confirmed', ...extra };
}
function current(extra = {}) {
return single({ questionId: '', options: [{ id: 'c', text: '保持不变', label: 'A' }, { id: 'a', text: '降低温度', label: 'B' }, { id: 'b', text: '提高温度', label: 'C' }], answerTexts: [], correctOptionIds: [], answerStatus: 'missing', ...extra });
}
test('single answer plan maps content to the current shuffled choice', () => {
const plan = planAnswer([single()], current(), core);
assert.equal(plan.status, 'ready'); assert.deepEqual(plan.labels, ['C']); assert.deepEqual(plan.answerTexts, ['提高温度']);
});
test('multiple answer plan preserves the complete shuffled correct set', () => {
const record = single({ type: 'multiple', answerTexts: ['降低温度', '提高温度'], correctOptionIds: ['a', 'b'] });
const plan = planAnswer([record], current({ type: 'multiple' }), core);
assert.equal(plan.status, 'ready'); assert.deepEqual(new Set(plan.labels), new Set(['B', 'C']));
});
test('judgement uses explicit correct and incorrect content, including zero ID', () => {
const record = { bankId: 'j', type: 'judge', stem: '这项说法正确吗?', options: [{ id: '1', text: '正确' }, { id: '0', text: '错误' }], correctOptionIds: ['0'], answerStatus: 'confirmed' };
const question = { ...record, options: [{ id: '0', text: '错误', label: '错误' }, { id: '1', text: '正确', label: '正确' }] };
assert.deepEqual(planAnswer([record], question, core).answerTexts, ['错误']);
assert.deepEqual(planAnswer([record], question, core).labels, ['错误']);
});
test('missing and fuzzy-only matches never cause an answer plan', () => {
assert.equal(planAnswer([single({ answerStatus: 'missing' })], current(), core).status, 'missing');
assert.equal(planAnswer([single()], current({ stem: '以下哪些操作可以提高温度?' }), core).status, 'missing');
});
test('any conflict or different confirmed answers makes the choice ambiguous', () => {
assert.equal(planAnswer([single(), single({ answerStatus: 'conflict' })], current(), core).status, 'ambiguous');
assert.equal(planAnswer([single(), single({ questionId: 'q2', correctOptionIds: ['a'], answerTexts: ['降低温度'] })], current(), core).status, 'ambiguous');
});
test('duplicate records with the same confirmed answer remain safe', () => {
assert.equal(planAnswer([single(), single({ questionId: 'q2' })], current(), core).status, 'ready');
});
test('a known bank ID rejects answers from other banks', () => {
assert.equal(planAnswer([single({ bankId: 'bank-b' })], current(), core).status, 'missing');
assert.equal(planAnswer([single(), single({ bankId: 'bank-b', correctOptionIds: ['a'], answerTexts: ['降低温度'] })], current(), core).status, 'ready');
});
test('without bank identity, contradictory cross-bank matches are ambiguous', () => {
const plan = planAnswer([single(), single({ bankId: 'bank-b', bankName: '题库二', correctOptionIds: ['a'], answerTexts: ['降低温度'] })], current({ bankId: '', bankName: '' }), core);
assert.equal(plan.status, 'ambiguous');
});
test('different complete option sets, duplicate option text and wrong option IDs are refused', () => {
assert.equal(planAnswer([single()], current({ options: [{ id: 'a', text: '降低温度' }, { id: 'b', text: '提高温度' }] }), core).status, 'missing');
assert.equal(planAnswer([single()], current({ options: [{ text: '提高温度' }, { text: '提高温度' }] }), core).status, 'ambiguous');
assert.equal(planAnswer([single()], current({ options: [{ id: 'b', text: '保持不变', label: 'A' }, { id: 'a', text: '降低温度', label: 'B' }, { id: 'c', text: '提高温度', label: 'C' }] }), core).status, 'ambiguous');
});
test('normalization preserves negatives, signs, units and superscripts', () => {
const record = single({ stem: '压力不大于10 MPa,且流量为2 m³/h。' });
assert.equal(planAnswer([record], current({ stem: '压力大于10 MPa,且流量为2 m³/h。' }), core).status, 'missing');
assert.equal(planAnswer([record], current({ stem: '压力不大于10 MPa,且流量为2 m3/h。' }), core).status, 'missing');
assert.equal(planAnswer([record], current({ stem: '压力不大于10 MPa,且流量为2 m³/h。' }), core).status, 'ready');
});
test('unsupported, incomplete and malformed questions are skipped', () => {
assert.equal(planAnswer([], null, core).status, 'unsupported');
assert.equal(planAnswer([], { stem: '简答题', type: 'unknown' }, core).status, 'unsupported');
assert.equal(planAnswer([{}, null], current(), core).status, 'missing');
});
test('continuous selection starts disabled and can be destroyed without scanning or persistence', () => {
const calls = [];
const win = { clearTimeout() {}, clearInterval() {}, setTimeout() { calls.push('timeout'); }, setInterval() { calls.push('interval'); } };
const engine = create({ window: win, document: {}, core });
assert.equal(engine.isEnabled(), false);
assert.equal(engine.getState().answeredCount, 0);
engine.destroy();
engine.setEnabled(true);
assert.equal(engine.isEnabled(), false); assert.deepEqual(calls, []);
});
function checkboxPage({ disabled = false, ariaDisabled = false, duringClick } = {}) {
const clicks = [];
const baseNode = () => ({ nodeType: 1, isConnected: true, parentElement: null, hidden: false, getAttribute: () => null, closest: () => null });
const container = { ...baseNode(), matches: () => false, contains: () => true };
const title = { ...baseNode(), textContent: '选择全部正确的巡检操作。', querySelector: () => null };
const inputs = ['检查温度', '检查压力', '跳过巡检'].map((text, index) => {
const label = { ...baseNode(), textContent: text, querySelector: () => null,
cloneNode: () => ({ textContent: text, querySelectorAll: () => [] }) };
const input = { ...baseNode(), type: 'checkbox', value: String(index), name: 'checks', id: `check-${index}`, checked: false, disabled: false, labels: [label],
matches: selector => selector === ':disabled' && disabled,
closest: selector => selector === '[aria-disabled="true"]' ? (ariaDisabled ? container : null) : selector === 'label' ? label : selector.startsWith('fieldset') ? container : null,
click() { clicks.push(index); input.checked = !input.checked; duringClick?.(index); }
};
return input;
});
container.querySelectorAll = selector => selector.startsWith('input') ? inputs : [title];
const doc = { querySelectorAll: selector => selector.startsWith('.yxtulcdsdk') ? [] : [container] };
const win = { getComputedStyle: () => ({ display: 'block', visibility: 'visible' }), clearTimeout() {}, clearInterval() {}, setTimeout() {}, setInterval() {} };
const record = { type: 'multiple', stem: title.textContent, options: inputs.map((input, index) => ({ id: input.value, text: ['检查温度', '检查压力', '跳过巡检'][index] })), answerTexts: ['检查温度', '检查压力'], correctOptionIds: ['0', '1'], answerStatus: 'confirmed' };
return { win, doc, record, inputs, clicks, title };
}
test('disabled fieldsets and aria-disabled ancestors are never clicked', async () => {
for (const config of [{ disabled: true }, { ariaDisabled: true }]) {
const page = checkboxPage(config);
const engine = create({ window: page.win, document: page.doc, core, getRecords: () => [page.record] });
const result = await engine.answerCurrent();
assert.equal(result.status, 'skipped'); assert.deepEqual(page.clicks, []);
engine.destroy();
}
});
test('setEnabled(false) cancels an in-progress one-shot before a second checkbox click', async () => {
let engine;
const page = checkboxPage({ duringClick: index => { if (index === 0) engine.setEnabled(false); } });
engine = create({ window: page.win, document: page.doc, core, getRecords: () => [page.record] });
const result = await engine.answerCurrent();
assert.equal(result.status, 'cancelled'); assert.deepEqual(page.clicks, [0]);
assert.equal(page.inputs[1].checked, false); assert.equal(engine.getState().busy, false);
engine.destroy();
});
test('a question changing during the first click stops the rest of a multiple selection', async () => {
let page;
page = checkboxPage({ duringClick: index => { if (index === 0) page.title.textContent = '已经切换到另一题。'; } });
const engine = create({ window: page.win, document: page.doc, core, getRecords: () => [page.record] });
const result = await engine.answerCurrent();
assert.equal(result.status, 'skipped'); assert.deepEqual(page.clicks, [0]);
assert.equal(page.inputs[1].checked, false);
engine.destroy();
});
+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 {