Files

308 lines
20 KiB
JavaScript

'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 = '<p>选择对应结构<img src="https://images.example/curve.jpg"></p>';
const htmls = ['<img src="https://images.example/a.png">', '<img src="https://images.example/b.png?x=1&amp;y=2">'];
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: ['<img src="https://images.example/a.png">', '<img src="https://images.example/c.png">'] });
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');
});