feat: add opt-in answer selection and global hidden panel
This commit is contained in:
@@ -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();
|
||||
});
|
||||
|
||||
@@ -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
@@ -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 {
|
||||
|
||||
Reference in New Issue
Block a user