fix: reduce userscript page blocking and stale option clicks

This commit is contained in:
suanyuit-cyber
2026-10-30 01:47:03 +08:00
parent 26e0a920b6
commit 4bf2de1bad
12 changed files with 1128 additions and 170 deletions
+90 -2
View File
@@ -275,10 +275,14 @@ function bankPage(fetchImpl) {
const player = { get textContent() { return state.player; } };
const exam = { get textContent() { return state.exam; } };
const container = { getAttribute: key => key === 'task-id' ? state.containerTask : null };
const main = { querySelectorAll: () => [] };
const main = { querySelectorAll: selector => selector === '.yxtulcdsdk-examing__result-line' ? (state.feedback || []).map(value => ({
getAttribute: name => name === 'data-question-id' ? value.questionId || null : null,
querySelector: selector => selector === '.yxtulcdsdk-examing__result-line-left' ? { textContent: value.heading } : { textContent: value.answer },
closest: () => null
})) : [] };
const stem = { get textContent() { return state.stem; }, get innerHTML() { return state.stemHTML; }, matches: selector => selector === '[data-rich-text]' };
const options = ['降低温度', '提高温度'].map((value, index) => ({
querySelector: selector => selector === 'input' ? { value: `a${index + 1}` } : selector === '.yxtf-radio__label, .yxtf-checkbox__label' ? { textContent: `${String.fromCharCode(65 + index)}. ${value}`, querySelector: () => state.optionHTML ? { innerHTML: state.optionHTML[index], textContent: '' } : null } : null,
querySelector: selector => selector === 'input' ? { value: state.optionIds?.[index] || `a${index + 1}` } : selector === '.yxtf-radio__label, .yxtf-checkbox__label' ? { textContent: `${String.fromCharCode(65 + index)}. ${value}`, querySelector: () => state.optionHTML ? { innerHTML: state.optionHTML[index], textContent: '' } : null } : null,
textContent: value
}));
const form = {
@@ -650,3 +654,87 @@ test('DOM question number distinguishes legitimate identical items in the latest
assert.equal(page.adapter.getCurrentQuestion(), null, '题号与已加载完整列表不一致时应等待页面同步');
page.adapter.stop();
});
test('hidden parent answer permission cannot be bypassed by nested resultInfo', () => {
const raw = single();
raw.allowViewSingleQuesAnswer = false;
raw.resultInfo = { answers: ['a2'], judgeAnswer: 1 };
assert.equal(extractQuestions(raw)[0].answerStatus, 'missing');
});
test('old unbound feedback letters cannot become the next question standard answer', async () => {
const next = { ...single(), id: 'q2', content: '另一道题目?' };
next.choiceItems.forEach(option => delete option.answer);
const page = bankPage(async () => new Response(JSON.stringify({ listQues: [single(), next] }), { headers: { 'content-type': 'application/json' } }));
page.adapter.start();
await page.win.fetch('/upm/start?praId=bank-a'); await flush(); await flush();
page.state.stem = '另一道题目?'; page.state.questionNumber = 2;
page.state.feedback = [{ heading: '正确答案', answer: 'A' }, { heading: '解析', answer: '上一题遗留解析' }];
const unbound = page.adapter.getCurrentQuestion();
assert.equal(unbound.questionId, 'q2');
assert.equal(unbound.answerStatus, 'missing');
assert.equal(unbound.explanation, '');
page.state.feedback = [{ heading: '正确答案', answer: 'A', questionId: 'q1' }];
assert.equal(page.adapter.getCurrentQuestion().answerStatus, 'missing', '明确属于上一题的反馈也不能套在新题');
page.state.feedback = [{ heading: '正确答案', answer: 'B', questionId: 'q2' }];
assert.deepEqual(page.adapter.getCurrentQuestion().answerTexts, ['提高温度']);
page.adapter.stop();
});
test('new stem with stale radio option ids waits until DOM values match its network identity', async () => {
const raw = { ...single(), id: 'q-new', choiceItems: [
{ id: 'new-a', content: '降低温度', answer: 0 }, { id: 'new-b', content: '提高温度', answer: 1 }
] };
const page = bankPage(async () => new Response(JSON.stringify({ listQues: [raw] }), { headers: { 'content-type': 'application/json' } }));
page.adapter.start();
await page.win.fetch('/upm/start?praId=bank-a'); await flush(); await flush();
assert.equal(page.adapter.getCurrentQuestion(), null, '显示文字一致也不能给旧radio值绑定新题ID');
page.state.optionIds = ['new-a', 'new-b'];
const question = page.adapter.getCurrentQuestion();
assert.equal(question.questionId, 'q-new');
assert.deepEqual(question.options.map(option => option.id), ['new-a', 'new-b']);
page.adapter.stop();
});
test('observer ignores unrelated and selection mutations and batches relevant work before scanning', () => {
const page = bankPage();
let callback, reads = 0, sequence = 0;
const pending = new Map();
page.win.MutationObserver = class { constructor(value) { callback = value; } observe() {} };
page.win.setTimeout = fn => { const key = ++sequence; pending.set(key, fn); return key; };
page.win.clearTimeout = key => pending.delete(key);
const originalOne = page.doc.querySelector;
const originalAll = page.doc.querySelectorAll;
page.doc.querySelector = selector => { reads++; return originalOne(selector); };
page.doc.querySelectorAll = selector => { reads++; return originalAll(selector); };
function element(selectors, parentElement = null, id = '') {
const result = { nodeType: 1, id, parentElement,
matches: selector => selector.split(',').some(part => selectors.includes(part.trim()) || part.trim() === `#${id}`),
querySelector: () => null,
closest(selector) { return this.matches(selector) ? this : parentElement?.closest(selector) || null; }
};
return result;
}
page.adapter.start();
const baseline = reads;
const plain = element(['.unrelated-timer']);
const own = element([], null, 'huayi-qb-root');
const main = element([ROOT_SELECTOR_FOR_TEST]);
const radio = element(['.yxtulcdsdk-ques-radio'], main);
const card = element(['.yxtulcdsdk-answer-card__item']);
for (let i = 0; i < 1000; i++) callback([
{ type: 'characterData', target: { nodeType: 3, parentElement: plain } },
{ type: 'childList', target: own, addedNodes: [], removedNodes: [] },
{ type: 'attributes', attributeName: 'class', target: radio },
{ type: 'attributes', attributeName: 'class', target: card }
]);
assert.equal(reads, baseline, '无关页面变化不进行任何全页查询');
assert.equal(pending.size, 0, '选中/答题卡/助手自己的变化不调度扫描');
const stem = element(['.yxtulcdsdk-form__content'], main);
for (let i = 0; i < 100; i++) callback([{ type: 'characterData', target: { nodeType: 3, parentElement: stem } }]);
assert.equal(reads, baseline, '相关变化也不在observer回调里同步重扫');
assert.equal(pending.size, 1, '连续100次题目变化只调度一次检查');
const task = [...pending.values()][0]; pending.clear(); task();
assert.ok(reads > baseline, '合批后仍检查真实题目更新');
page.adapter.stop();
});