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); });