'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'])); }); function repeatedMultiple(extra = {}) { return single({ type: 'multiple', options: [{ id: 'a', text: '降低', label: 'A' }, { id: 'b', text: '下降', label: 'B' }, { id: 'c', text: '增加', label: 'C' }, { id: 'd', text: '下降', label: 'D' }], correctOptionIds: ['a', 'b', 'c', 'd'], answerTexts: ['降低', '下降', '增加'], ...extra }); } test('multiple answer plan accepts every explicitly correct occurrence of repeated text', () => { const record = repeatedMultiple(); const question = repeatedMultiple({ answerStatus: 'missing', correctOptionIds: [], answerTexts: [] }); question.options = [question.options[3], question.options[2], question.options[0], question.options[1]] .map((option, index) => ({ ...option, id: `new-${index}`, label: 'ABCD'[index] })); const plan = planAnswer([record], question, core); assert.equal(plan.status, 'ready'); assert.deepEqual([...plan.labels].sort(), ['A', 'B', 'C', 'D']); const subset = repeatedMultiple({ correctOptionIds: ['b', 'd'], answerTexts: ['下降'] }); assert.deepEqual(planAnswer([subset], question, core).labels, ['A', 'D']); }); test('repeated text skips partial answers without exact identities, text-only answers, ID contradictions and duplicate labels', () => { const question = repeatedMultiple({ answerStatus: 'missing', correctOptionIds: [], answerTexts: [] }); const partial = repeatedMultiple({ correctOptionIds: ['b'], answerTexts: ['下降'] }); assert.equal(planAnswer([partial], question, core).status, 'ready'); const fresh = { ...question, options: question.options.map(option => ({ ...option, id: `new-${option.id}` })) }; assert.equal(planAnswer([partial], fresh, core).status, 'ambiguous'); assert.equal(planAnswer([repeatedMultiple({ correctOptionIds: [], answerTexts: ['下降'] })], question, core).status, 'ambiguous'); const changed = repeatedMultiple(); [changed.options[0].id, changed.options[1].id] = [changed.options[1].id, changed.options[0].id]; assert.equal(planAnswer([repeatedMultiple()], changed, core).status, 'ambiguous'); question.options[3].label = 'B'; assert.equal(planAnswer([repeatedMultiple()], question, core).status, 'ambiguous'); }); function repeatedSingle(extra = {}) { return single({ options: [{ id: 'a', text: '其他产物甲', label: 'A' }, { id: 'b', text: '其他产物乙', label: 'B' }, { id: 'c', text: '生成Cu和Fe2+', label: 'C' }, { id: 'd', text: '生成Cu和Fe2+', label: 'D' }], correctOptionIds: ['c'], answerTexts: ['生成Cu和Fe2+'], ...extra }); } test('repeated single-choice text maps by complete exact option IDs after labels reorder', () => { const record = repeatedSingle(); const question = repeatedSingle({ answerStatus: 'missing', correctOptionIds: [], answerTexts: [] }); assert.deepEqual(planAnswer([record], question, core).labels, ['C']); question.options = [question.options[3], question.options[2], question.options[0], question.options[1]] .map((option, index) => ({ ...option, label: 'ABCD'[index] })); const plan = planAnswer([record], question, core); assert.equal(plan.status, 'ready'); assert.deepEqual(plan.labels, ['B']); assert.deepEqual(plan.answerTexts, ['生成Cu和Fe2+']); }); test('repeated single-choice text remains ambiguous when full ID identity cannot be proved', () => { const record = repeatedSingle(); const question = repeatedSingle({ answerStatus: 'missing', correctOptionIds: [], answerTexts: [] }); const fresh = { ...question, options: question.options.map(option => ({ ...option, id: `new-${option.id}` })) }; assert.equal(planAnswer([record], fresh, core).status, 'ambiguous'); const missingID = { ...question, options: question.options.map(option => ({ ...option, id: option.id === 'a' ? '' : option.id })) }; assert.equal(planAnswer([record], missingID, core).status, 'ambiguous'); const changed = { ...question, options: question.options.map(option => ({ ...option, id: option.id === 'a' ? 'c' : option.id === 'c' ? 'a' : option.id })) }; assert.equal(planAnswer([record], changed, core).status, 'ambiguous'); assert.equal(planAnswer([repeatedSingle({ correctOptionIds: [] })], question, core).status, 'ambiguous'); }); 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'); const duplicate = single({ options: [{ id: 'a', text: '提高温度' }, { id: 'b', text: '提高温度' }] }); assert.equal(planAnswer([duplicate], 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, texts = ['检查温度', '检查压力', '跳过巡检'], htmls, stemHTML, richLabels = false } = {}) { 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: '选择全部正确的巡检操作。', innerHTML: stemHTML, querySelector: () => null }; const inputs = texts.map((text, index) => { const label = { ...baseNode(), textContent: text, querySelector: selector => richLabels && selector.includes('[data-rich-text]') ? { innerHTML: htmls?.[index], textContent: text } : null, cloneNode: () => ({ textContent: text, innerHTML: htmls?.[index], 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: texts[index] })), answerTexts: texts.slice(0, 2), correctOptionIds: ['0', '1'], answerStatus: 'confirmed' }; return { win, doc, record, inputs, clicks, title }; } test('automatic selection chooses all correct repeated checkboxes and clears an incorrect choice', async () => { const page = checkboxPage({ texts: ['下降', '增加', '下降'] }); page.record.correctOptionIds = ['0', '2']; page.record.answerTexts = ['下降']; page.inputs[1].checked = true; const engine = create({ window: page.win, document: page.doc, core, getRecords: () => [page.record] }); const result = await engine.answerCurrent(); assert.equal(result.status, 'answered'); assert.deepEqual(page.inputs.map(input => input.checked), [true, false, true]); assert.deepEqual(page.clicks, [0, 1, 2]); engine.destroy(); }); test('automatic selection never clicks an ambiguous repeated-text subset', async () => { const page = checkboxPage({ texts: ['下降', '增加', '下降'] }); page.record.options.forEach(option => { option.id = `saved-${option.id}`; }); page.record.correctOptionIds = ['saved-0']; page.record.answerTexts = ['下降']; 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('single-choice clicks the correct repeated option only with complete exact IDs', async () => { for (const freshIDs of [false, true]) { const page = checkboxPage({ texts: ['其他产物甲', '其他产物乙', '生成Cu和Fe2+', '生成Cu和Fe2+'] }); page.inputs.forEach(input => { input.type = 'radio'; }); page.record.type = 'single'; if (freshIDs) page.record.options.forEach(option => { option.id = `saved-${option.id}`; }); page.record.correctOptionIds = [freshIDs ? 'saved-2' : '2']; page.record.answerTexts = ['生成Cu和Fe2+']; const engine = create({ window: page.win, document: page.doc, core, getRecords: () => [page.record] }); const result = await engine.answerCurrent(); assert.equal(result.status, freshIDs ? 'skipped' : 'answered'); assert.deepEqual(page.clicks, freshIDs ? [] : [2]); assert.deepEqual(page.inputs.map(input => input.checked), freshIDs ? [false, false, false, false] : [false, false, true, false]); engine.destroy(); } }); test('automatic selection reads image stems and choices consistently with network records', async () => { const { extractQuestions } = require('../src/adapter.js'); for (const richLabels of [false, true]) { const stemHTML = '
选择对应结构
', '
'];
const [record] = extractQuestions({ id: 'image-single', quesType: 0, content: stemHTML,
choiceItems: htmls.map((itemContent, index) => ({ id: String(index), itemContent })), correctAnswers: ['1'] });
const page = checkboxPage({ texts: ['', ''], htmls, stemHTML, richLabels });
page.inputs.forEach(input => { input.type = 'radio'; });
const engine = create({ window: page.win, document: page.doc, core, getRecords: () => [record] });
const result = await engine.answerCurrent();
assert.equal(result.status, 'answered');
assert.deepEqual(result.labels, ['B']);
assert.deepEqual(result.answerTexts, ['[图片:https://images.example/b.png?x=1&y=2]']);
assert.deepEqual(page.clicks, [1]);
engine.destroy();
}
});
test('a different image URL cannot reuse a known image answer', async () => {
const page = checkboxPage({ texts: ['', ''], htmls: ['
', '
'] });
const record = single({ type: 'multiple', stem: page.title.textContent,
options: [{ id: '0', text: '[图片:https://images.example/a.png]' }, { id: '1', text: '[图片:https://images.example/b.png]' }],
correctOptionIds: ['1'], answerTexts: ['[图片:https://images.example/b.png]'] });
const engine = create({ window: page.win, document: page.doc, core, getRecords: () => [record] });
const result = await engine.answerCurrent();
assert.equal(result.status, 'skipped'); assert.deepEqual(page.clicks, []);
engine.destroy();
});
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();
});
test('reusing the same input nodes with different option values stops the remaining clicks', async () => {
let page;
page = checkboxPage({ duringClick: index => {
if (index === 0) [page.inputs[1].value, page.inputs[2].value] = [page.inputs[2].value, page.inputs[1].value];
} });
const engine = create({ window: page.win, document: page.doc, core, getRecords: () => [page.record] });
await engine.answerCurrent();
assert.deepEqual(page.clicks, [0], 'an old positional plan must not click a reused node now carrying another option ID');
engine.destroy();
});
test('a nested framework microtask that changes the question is observed before another click', async () => {
let page;
page = checkboxPage({ duringClick: index => {
if (index === 0) queueMicrotask(() => queueMicrotask(() => { page.title.textContent = '框架异步渲染的新题目。'; }));
} });
const engine = create({ window: page.win, document: page.doc, core, getRecords: () => [page.record] });
await engine.answerCurrent();
assert.deepEqual(page.clicks, [0], 'a single awaited Promise is too early to safely click the next option');
engine.destroy();
});
test('an 1884-record bank only normalizes exact-stem candidates, not every unrelated question per poll', () => {
const records = Array.from({ length: 1884 }, (_, index) => single({ questionId: `question-${index}`, stem: `无关的题库练习 ${index}。` }));
records[1000] = single();
let normalizedCount = 0;
const observed = { ...core, normalizeRecord(value) { normalizedCount++; return core.normalizeRecord(value); } };
assert.equal(planAnswer(records, current(), observed).status, 'ready');
const initial = normalizedCount;
for (let attempt = 0; attempt < 20; attempt++) assert.equal(planAnswer(records, current(), observed).status, 'ready');
assert.ok(initial <= 3, `first lookup should not normalize all 1884 records (observed ${initial})`);
assert.ok(normalizedCount - initial <= 20, 'repeat lookups only need to validate the current question again');
});
test('the answer index detects in-place option/answer mutations and changed raw records', () => {
const records = [single()];
assert.equal(planAnswer(records, current(), core).status, 'ready');
records[0].correctOptionIds = ['a']; records[0].answerTexts = ['降低温度'];
assert.deepEqual(planAnswer(records, current(), core).labels, ['B']);
records[0].answerStatus = 'conflict';
assert.equal(planAnswer(records, current(), core).status, 'ambiguous');
records[0] = single();
assert.deepEqual(planAnswer(records, current(), core).labels, ['C']);
records.push(single({ questionId: 'changed-stem', stem: '此前与目标无关。', correctOptionIds: ['a'], answerTexts: ['降低温度'] }));
assert.equal(planAnswer(records, current(), core).status, 'ready');
records[1].stem = single().stem;
assert.equal(planAnswer(records, current(), core).status, 'ambiguous', 'a record changing into an exact match must invalidate the question index');
});