feat: add Huayi question bank assistant v1.1.0

This commit is contained in:
suanyuit-cyber
2026-10-29 18:08:28 +08:00
commit 6b04243428
13 changed files with 4348 additions and 0 deletions
+300
View File
@@ -0,0 +1,300 @@
const test = require('node:test');
const assert = require('node:assert/strict');
const core = require('../src/core.js');
const fixture = (overrides = {}) => ({
bankId: 'bank-1', bankName: '单选题库', questionId: '2114891149151932417', type: 'single', stem: '温度过低,应如何操作?',
options: [{ id: 'x', text: '降低温度', label: 'A' }, { id: 'y', text: '提高温度', label: 'B' }, { id: 'z', text: '保持不变', label: 'C' }],
correctOptionIds: ['y'], answerStatus: 'confirmed', explanation: '保持合适温度。', source: { kind: 'answer-response', url: 'https://example.test/practice', observedAt: '2026-10-29T00:00:00Z' }, ...overrides,
});
const reorder = (record) => ({ ...record, options: [record.options[2], record.options[0], record.options[1]].map((option, index) => ({ ...option, label: 'ABC'[index] })) });
test('single answers map to current labels after options reorder', () => {
const record = core.normalizeRecord(fixture());
assert.deepEqual(record.answerTexts, ['提高温度']);
assert.deepEqual(core.mapAnswers(record, reorder(record)), { status: 'matched', labels: ['C'], answerTexts: ['提高温度'] });
});
test('multiple answers map all correct options after reorder', () => {
const record = core.normalizeRecord(fixture({ type: 'multiple', correctOptionIds: ['x', 'z'] }));
assert.deepEqual(core.mapAnswers(record, reorder(record)).labels, ['B', 'A']);
});
test('different option IDs can map by unique answer content', () => {
const record = core.normalizeRecord(fixture());
const current = reorder(record);
current.options = current.options.map((option, index) => ({ ...option, id: `new-${index}` }));
assert.equal(core.mapAnswers(record, current).status, 'matched');
});
test('same ID with different content is not treated as the answer', () => {
const record = core.normalizeRecord(fixture());
const current = reorder(record);
current.options[0].id = 'y'; current.options[2].id = 'new-y';
assert.equal(core.mapAnswers(record, current).status, 'unmapped');
});
test('duplicate option content is ambiguous even when ID exists', () => {
const record = core.normalizeRecord(fixture({ options: [{ id: 'x', text: '提高温度', label: 'A' }, { id: 'y', text: '提高温度', label: 'B' }] }));
assert.equal(core.mapAnswers(record, record).status, 'ambiguous');
});
test('same stem but different option sets never auto-map', () => {
const record = core.normalizeRecord(fixture());
const current = reorder(record);
current.options[0].text = '无法判断';
assert.equal(core.mapAnswers(record, current).status, 'unmapped');
});
test('missing answer is not inferred from student selection or truthiness', () => {
const record = core.normalizeRecord(fixture({ correctOptionIds: [], answerStatus: undefined, selectedOptionIds: ['y'], userAnswer: 'B' }));
assert.equal(record.answerStatus, 'missing');
assert.deepEqual(record.answerTexts, []);
const falseRecord = core.normalizeRecord(fixture({ type: 'judge', options: [{ id: 'a', text: '正确' }, { id: 'b', text: '错误' }], correctOptionIds: [], answerTexts: ['false'] }));
assert.deepEqual(falseRecord.answerTexts, ['错误']);
const zeroRecord = core.normalizeRecord(fixture({ type: 'judge', options: [], correctOptionIds: [], answerTexts: ['0'] }));
assert.deepEqual(zeroRecord.answerTexts, ['0']);
});
test('explicit missing status suppresses accidentally supplied answers', () => {
const record = core.normalizeRecord(fixture({ answerStatus: 'missing', answerTexts: ['提高温度'] }));
assert.equal(record.answerStatus, 'missing');
assert.deepEqual(record.correctOptionIds, []);
assert.deepEqual(record.answerTexts, []);
});
test('inconsistent correct ID and answer content becomes conflict', () => {
const record = core.normalizeRecord(fixture({ answerTexts: ['降低温度'] }));
assert.equal(record.answerStatus, 'conflict');
assert.equal(core.mapAnswers(record, record).status, 'ambiguous');
});
test('missing answer is filled and later DOM-only observation preserves confirmed answer', () => {
const missing = fixture({ answerStatus: 'missing', correctOptionIds: [] });
const filled = core.mergeRecord(missing, fixture());
assert.equal(filled.answerStatus, 'confirmed');
const later = { ...reorder(missing), source: { kind: 'dom', observedAt: '2026-10-29T01:00:00Z' } };
const merged = core.mergeRecord(filled, later);
assert.equal(merged.answerStatus, 'confirmed');
assert.deepEqual(merged.answerTexts, ['提高温度']);
assert.equal(merged.answerSource.kind, 'answer-response');
});
test('conflicting answers are retained without silently overwriting', () => {
const merged = core.mergeRecord(fixture(), fixture({ correctOptionIds: ['x'] }));
assert.equal(merged.answerStatus, 'conflict');
assert.equal(merged.conflicts.length, 2);
assert.equal(core.mapAnswers(merged, fixture()).status, 'ambiguous');
assert.equal(core.mergeRecord(merged, fixture()).answerStatus, 'conflict');
});
test('changed content under an existing ID marks stale answer as conflict', () => {
const changed = fixture({ answerStatus: 'missing', correctOptionIds: [], stem: '温度过高,应如何操作?' });
const merged = core.mergeRecord(fixture(), changed);
assert.equal(merged.answerStatus, 'conflict');
assert.equal(core.mapAnswers(merged, changed).status, 'ambiguous');
});
test('fingerprints are order independent, bank aware and keep full option content', () => {
const first = fixture({ questionId: '' });
const second = reorder(first);
assert.equal(core.upsert([], [first, second]).length, 1);
assert.equal(core.upsert([], [first, { ...second, bankId: 'other' }]).length, 2);
assert.equal(core.upsert([], [first, { ...second, stem: '不同题目' }]).length, 2);
});
test('text comparison preserves negation, inequality and superscripts', () => {
const records = ['P水 > P气', 'P水 < P气', 'P水 ≥ P气', '正确的操作', '不正确的操作', '面积为2m²', '面积为2m2'].map((stem, index) => core.normalizeRecord(fixture({ questionId: String(index), stem })));
assert.equal(core.search(records, 'P水 > P气')[0].record.stem, 'P水 > P气');
assert.equal(core.search(records, '不正确的操作')[0].record.stem, '不正确的操作');
assert.equal(core.search(records, '面积为2m²')[0].record.stem, '面积为2m²');
assert.equal(core.search(records, '温').length, 0);
assert.equal(core.search(records, '62. P水 > P气')[0].match, 'exact');
});
test('CSV roundtrip preserves Chinese, quotes, comma, newline, IDs and conflicts', () => {
const record = core.normalizeRecord(fixture({ stem: '题干含有,逗号和"双引号"\n第二行', explanation: '解析\n仍有,逗号' }));
const csv = core.toCSV([record]);
assert.ok(csv.startsWith('\uFEFF'));
assert.match(csv, /'2114891149151932417/);
assert.deepEqual(core.fromCSV(csv), [record]);
const conflict = core.mergeRecord(fixture(), fixture({ correctOptionIds: ['z'] }));
assert.deepEqual(core.fromCSV(core.toCSV([conflict])), [conflict]);
});
test('CSV spreadsheet formula protection is reversible without changing canonical text', () => {
const record = core.normalizeRecord(fixture({ stem: '=SUM(1,2)', explanation: ' @危险公式', bankName: '+题库' }));
const csv = core.toCSV([record]);
assert.match(csv, /"'=SUM\(1,2\)"/);
assert.match(csv, /"'@危险公式"/);
assert.deepEqual(core.fromCSV(csv), [record]);
});
test('CSV rejects malformed quotes, inconsistent columns, invalid JSON/types/statuses', () => {
assert.throws(() => core.fromCSV('题干,题型\n"未闭合,单选题'), /未闭合/);
assert.throws(() => core.fromCSV('题干,题型\n题目,单选题,多出一列'), /列数/);
assert.throws(() => core.fromCSV('完整记录JSON\n"{bad}"'), /JSON/);
assert.throws(() => core.fromCSV('题干,题型\n题目,其他题型'), /题型/);
assert.throws(() => core.fromCSV('题干,题型,答案状态\n题目,单选题,猜测'), /状态/);
assert.throws(() => core.fromCSV('题干,题干\n一题,二题'), /重复/);
assert.throws(() => core.fromCSV('题干,题型\n"题目"非法,单选题'), /引号后/);
});
test('standard human CSV imports explicit standard answers, never user answers', () => {
const rows = core.fromCSV('来源题库,题干,题型,选项A,选项B,正确答案内容,我的答案\n练习,应提高温度吗,判断题,正确,错误,错误,正确\n练习,如何操作,单选题,提高温度,降低温度,,提高温度');
assert.deepEqual(rows[0].answerTexts, ['错误']);
assert.equal(rows[0].answerStatus, 'confirmed');
assert.equal(rows[1].answerStatus, 'missing');
});
test('unsafe numeric IDs are refused rather than rounding identifiers', () => {
assert.throws(() => core.normalizeRecord(fixture({ questionId: 2114891149151932417 })), /不安全/);
assert.equal(core.normalizeRecord(fixture()).questionId, '2114891149151932417');
});
test('DOM-first anonymous record upgrades to unique server identity', () => {
const dom = fixture({ bankId: '', questionId: '', answerStatus: 'missing', correctOptionIds: [] });
const result = core.upsert([dom], [fixture()]);
assert.equal(result.length, 1);
assert.equal(result[0].questionId, fixture().questionId);
assert.equal(result[0].bankId, 'bank-1');
assert.equal(result[0].answerStatus, 'confirmed');
});
test('server-first record absorbs later anonymous DOM and persistence merges', () => {
const dom = reorder(fixture({ bankId: '', questionId: '', answerStatus: 'missing', correctOptionIds: [] }));
const result = core.upsert([fixture()], [dom]);
assert.equal(result.length, 1);
assert.equal(result[0].questionId, fixture().questionId);
assert.deepEqual(result[0].answerTexts, ['提高温度']);
const persisted = core.upsert([dom], result);
assert.equal(persisted.length, 1);
assert.equal(persisted[0].answerStatus, 'confirmed');
});
test('upgrading confirmed anonymous data keeps answer IDs and option IDs atomic', () => {
const old = fixture({ bankId: '', questionId: '' });
const network = reorder(fixture({ answerStatus: 'missing', correctOptionIds: [] }));
network.options = network.options.map((option, index) => ({ ...option, id: `server-${index}` }));
const result = core.upsert([old], [network]);
assert.equal(result.length, 1);
assert.deepEqual(result[0].correctOptionIds, ['y']);
assert.equal(result[0].options.find((option) => option.id === 'y').text, '提高温度');
assert.equal(core.mapAnswers(result[0], network).status, 'matched');
});
test('different known bank IDs and same names never merge anonymous records', () => {
const anonymous = fixture({ questionId: '', bankId: 'other-bank' });
assert.equal(core.upsert([anonymous], [fixture()]).length, 2);
});
test('different known question IDs are never merged by content', () => {
assert.equal(core.upsert([fixture()], [fixture({ questionId: 'other-question' })]).length, 2);
});
test('anonymous data matching multiple IDs or same-name banks remains separate', () => {
const anonymous = fixture({ bankId: '', questionId: '', answerStatus: 'missing', correctOptionIds: [] });
const anotherId = fixture({ questionId: 'other-id' });
assert.equal(core.upsert([anonymous], [fixture(), anotherId]).length, 3);
const otherBank = fixture({ bankId: 'other-bank' });
assert.equal(core.upsert([anonymous], [fixture(), otherBank]).length, 3);
});
test('anonymous upgrade requires complete options and exact type/content/bank', () => {
const network = fixture();
for (const change of [
{ type: 'unknown' }, { bankName: '其他题库' }, { options: [] }, { stem: '温度过高,应如何操作?' },
{ options: [{ id: '', text: '不同选项' }] },
]) {
const anonymous = fixture({ bankId: '', questionId: '', answerStatus: 'missing', correctOptionIds: [], ...change });
assert.equal(core.upsert([anonymous], [network]).length, 2);
}
});
test('JSON and CSV JSON cannot label an answer outside saved options as confirmed', () => {
const invalid = fixture({ correctOptionIds: [], answerTexts: ['不存在的选项'] });
const record = core.normalizeRecord(invalid);
assert.equal(record.answerStatus, 'conflict');
const csv = '完整记录JSON\n"' + JSON.stringify(invalid).replace(/"/g, '""') + '"';
assert.equal(core.fromCSV(csv)[0].answerStatus, 'conflict');
});
test('text search supports stem, options and confirmed answer fields', () => {
const record = core.normalizeRecord(fixture({ stem: '操作措施的选择' }));
const options = core.search([record], '降低温度', { field: 'options' });
assert.equal(options[0].matchedField, 'options');
assert.equal(options[0].match, 'partial');
assert.equal(core.search([record], '降低温度', { field: 'stem' }).length, 0);
const answer = core.search([record], '提高温度', { field: 'answer' });
assert.equal(answer[0].matchedField, 'answer');
assert.equal(core.search([record], '降低温度', { field: 'answer' }).length, 0);
assert.equal(core.search([record], '操作措施')[0].matchedField, 'stem');
assert.equal(core.search([record], '降低温度')[0].matchedField, 'options');
});
test('text search ranks stem exact and partial before options, answers and similar stems', () => {
const make = (id, stem, options = [{ id: 'x', text: '无关操作' }], answerTexts = []) => core.normalizeRecord(fixture({
questionId: id, type: 'unknown', stem, options, correctOptionIds: [], answerTexts,
answerStatus: answerTexts.length ? 'confirmed' : 'missing',
}));
const exact = make('exact', '提高温度');
const partial = make('partial', '如何提高温度以完成干燥');
const option = make('option', '措施选择', [{ id: 'x', text: '提高温度' }]);
const answer = make('answer', '操作说明', [], ['提高温度']);
const similar = make('similar', '提高温度吗');
// Query has no punctuation so similar is made genuinely non-contiguous.
similar.stem = '提高操作温度';
const found = core.search([similar, answer, option, partial, exact], '提高温度');
assert.deepEqual(found.map((item) => item.record.questionId), ['exact', 'partial', 'option', 'answer', 'similar']);
assert.deepEqual(found.map((item) => item.match), ['exact', 'partial', 'partial', 'partial', 'similar']);
});
test('space-separated keywords require every term and can cross fields only in all scope', () => {
const records = [
core.normalizeRecord(fixture({ questionId: 'one', stem: '温度过低如何处理' })),
core.normalizeRecord(fixture({ questionId: 'two', stem: '温度过低是否保持不变', correctOptionIds: ['z'] })),
];
const found = core.search(records, '温度过低 提高温度');
assert.deepEqual(found.map((item) => item.record.questionId), ['one', 'two']);
assert.equal(core.search(records, '温度过低 提高温度', { field: 'stem' }).length, 0);
assert.equal(core.search(records, '温度过低 不存在关键词').length, 0);
assert.deepEqual(core.search(records, '温度过低 处理', { field: 'stem' }).map((item) => item.record.questionId), ['one']);
assert.equal(core.search(records, '提高温度 保持不变', { field: 'options' }).length, 2);
assert.equal(core.search(records, '提高温度 保持不变', { field: 'answer' }).length, 0);
});
test('separate option contents never create a phrase that is absent in all segments', () => {
const record = core.normalizeRecord(fixture({
stem: '措施选择', options: [{ id: 'x', text: '甲' }, { id: 'y', text: '乙' }], correctOptionIds: ['x'],
}));
assert.equal(core.search([record], '甲乙', { field: 'options' }).length, 0);
assert.equal(core.search([record], '甲 乙', { field: 'options' }).length, 1);
});
test('missing and conflicting records are never searched as confirmed answer content', () => {
const missing = core.normalizeRecord(fixture({ questionId: 'missing', answerStatus: 'missing', answerTexts: ['提高温度'] }));
const conflict = core.mergeRecord(fixture(), fixture({ correctOptionIds: ['x'] }));
const records = [missing, conflict];
assert.equal(core.search(records, '提高温度', { field: 'answer' }).length, 0);
assert.ok(core.search(records, '提高温度', { field: 'options' }).every((item) => item.matchedField === 'options'));
});
test('keyword search keeps negation, inequality, case-sensitive units and superscripts', () => {
const records = ['P水 > P气', 'P水 < P气', '面积2m²', '面积2m2', '不正确操作', '正确操作', '压力2MPa', '压力2mPa'].map((stem, index) => core.normalizeRecord(fixture({ questionId: String(index), stem })));
assert.deepEqual(core.search(records, 'P水 > P气', { field: 'stem' }).map((item) => item.record.stem), ['P水 > P气']);
assert.equal(core.search(records, '不正确操作', { field: 'stem' })[0].record.stem, '不正确操作');
assert.equal(core.search(records, '面积2m²', { field: 'stem' })[0].record.stem, '面积2m²');
assert.equal(core.search(records, '压力2MPa', { field: 'stem' })[0].record.stem, '压力2MPa');
});
test('search limit fifty and offset give stable pages independent of input order', () => {
const records = Array.from({ length: 60 }, (_, index) => core.normalizeRecord(fixture({ questionId: `id-${String(index).padStart(2, '0')}`, stem: '完全一致的检索题干' })));
const first = core.search(records, '完全一致的检索题干', { limit: 50 });
const second = core.search([...records].reverse(), '完全一致的检索题干', { limit: 50, offset: 50 });
assert.equal(first.length, 50);
assert.equal(second.length, 10);
assert.equal(first[0].record.questionId, 'id-00');
assert.equal(second[0].record.questionId, 'id-50');
assert.equal(new Set([...first, ...second].map((item) => item.record.questionId)).size, 60);
assert.equal(core.search(records, '完全一致的检索题干', { limit: 0 }).length, 0);
});