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'); }); const repeatedMultiple = (overrides = {}) => fixture({ 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: ['降低', '下降', '增加'], ...overrides }); test('multiple correct IDs with repeated option text normalize without a false conflict', () => { const record = core.normalizeRecord(repeatedMultiple()); assert.equal(record.answerStatus, 'confirmed'); assert.deepEqual(record.correctOptionIds, ['a', 'b', 'c', 'd']); assert.deepEqual(record.answerTexts, ['降低', '下降', '增加']); assert.deepEqual(record.conflicts, []); assert.deepEqual(core.normalizeRecord(record), record); assert.deepEqual(core.fromCSV(core.toCSV([record])), [record]); }); test('all correct occurrences of repeated multiple-choice text map after IDs and order change', () => { const record = core.normalizeRecord(repeatedMultiple()); const current = repeatedMultiple({ answerStatus: 'missing', correctOptionIds: [], answerTexts: [] }); current.options = [current.options[3], current.options[2], current.options[0], current.options[1]] .map((option, index) => ({ ...option, id: `new-${index}`, label: 'ABCD'[index] })); const mapped = core.mapAnswers(record, current); assert.equal(mapped.status, 'matched'); assert.deepEqual([...mapped.labels].sort(), ['A', 'B', 'C', 'D']); const subset = core.normalizeRecord(repeatedMultiple({ correctOptionIds: ['b', 'd'], answerTexts: ['下降'] })); assert.equal(subset.answerStatus, 'confirmed'); assert.deepEqual(core.mapAnswers(subset, current).labels, ['A', 'D']); }); test('partly correct or text-only repeated options remain ambiguous', () => { const partial = core.normalizeRecord(repeatedMultiple({ correctOptionIds: ['b'], answerTexts: ['下降'] })); assert.equal(partial.answerStatus, 'confirmed'); assert.equal(core.mapAnswers(partial, partial).status, 'ambiguous'); const textOnly = core.normalizeRecord(repeatedMultiple({ correctOptionIds: [], answerTexts: ['下降'] })); assert.equal(core.mapAnswers(textOnly, textOnly).status, 'ambiguous'); }); test('repeated text does not override contradictory IDs, answer content or saved conflicts', () => { const record = core.normalizeRecord(repeatedMultiple()); const changed = repeatedMultiple(); [changed.options[0].id, changed.options[1].id] = [changed.options[1].id, changed.options[0].id]; assert.equal(core.mapAnswers(record, changed).status, 'unmapped'); assert.equal(core.normalizeRecord(repeatedMultiple({ answerTexts: ['降低', '下降'] })).answerStatus, 'conflict'); const conflicts = [{ reason: '正确选项ID与正确答案内容不一致', answerTexts: record.answerTexts, correctOptionIds: record.correctOptionIds }]; const legacy = core.normalizeRecord({ ...record, answerStatus: 'conflict', conflicts }); assert.equal(legacy.answerStatus, 'conflict'); assert.deepEqual(legacy.conflicts, conflicts); assert.equal(core.mergeRecord(legacy, record).answerStatus, 'conflict'); }); test('different selections among repeated options remain real merge conflicts', () => { const all = repeatedMultiple(); const partial = repeatedMultiple({ correctOptionIds: ['a', 'b', 'c'], answerTexts: ['降低', '下降', '增加'] }); assert.equal(core.mergeRecord(all, partial).answerStatus, 'conflict'); const first = repeatedMultiple({ correctOptionIds: ['b'], answerTexts: ['下降'] }); const second = repeatedMultiple({ correctOptionIds: ['d'], answerTexts: ['下降'] }); assert.equal(core.mergeRecord(first, second).answerStatus, 'conflict'); }); function legacyRepeatedMultiple(overrides = {}) { const record = core.normalizeRecord(repeatedMultiple({ source: { kind: 'network', url: 'https://example.test/upm/start', observedAt: '2026-10-29T00:00:00Z', batchId: 'batch-one' }, ...overrides })); return { ...record, answerStatus: 'conflict', conflicts: [ { reason: '正确选项ID与正确答案内容不一致', answerTexts: [...record.answerTexts], correctOptionIds: [...record.correctOptionIds] }, { reason: '同一题目出现不同或不一致的标准答案', stem: record.stem, options: record.options.map(option => ({ ...option })), answerTexts: [...record.answerTexts], correctOptionIds: [...record.correctOptionIds], source: { ...record.answerSource } } ] }; } test('explicit legacy duplicate-text repair preserves the original and is idempotent', () => { const legacy = legacyRepeatedMultiple(); const original = JSON.stringify(legacy); assert.equal(core.normalizeRecord(legacy).answerStatus, 'conflict', 'normalization alone must remain conservative'); const result = core.repairDuplicateOptionConflicts([legacy]); assert.equal(result.repaired.length, 1); assert.equal(result.repaired[0].original, legacy, 'caller receives the exact original input for backup'); assert.equal(JSON.stringify(legacy), original, 'pure repair must not mutate the input or snapshots'); assert.equal(result.records[0].answerStatus, 'confirmed'); assert.deepEqual(result.records[0].conflicts, []); assert.deepEqual(result.records[0].correctOptionIds, ['a', 'b', 'c', 'd']); assert.deepEqual([...core.mapAnswers(result.records[0], result.records[0]).labels].sort(), ['A', 'B', 'C', 'D']); assert.deepEqual(core.fromCSV(core.toCSV(result.records)), result.records); const again = core.repairDuplicateOptionConflicts(result.records); assert.deepEqual(again.records, result.records); assert.deepEqual(again.repaired, []); }); test('legacy repair requires complete agreeing evidence and never clears actual conflicts', async t => { const cases = [ ['different correct IDs despite the same text set', value => { value.conflicts[1].correctOptionIds = ['a', 'b', 'c']; }], ['different answer content', value => { value.conflicts[1].answerTexts = ['下降']; }], ['changed option content', value => { value.conflicts[1].options[0].text = '升高'; }], ['changed option identities', value => { value.conflicts[1].options[0].id = 'replacement'; }], ['partial options snapshot', value => { value.conflicts[1].options.pop(); }], ['stem without its full options snapshot', value => { delete value.conflicts[1].options; }], ['options without their full stem snapshot', value => { delete value.conflicts[1].stem; }], ['changed stem', value => { value.conflicts[1].stem += '另一个条件'; }], ['different question identity', value => { value.conflicts[1].questionId = 'other'; }], ['different source batch', value => { value.conflicts[1].source.batchId = 'another-batch'; }], ['source without the recorded batch', value => { delete value.conflicts[1].source.batchId; }], ['different observation in source', value => { value.conflicts[1].source.observedAt = '2026-10-29T01:00:00Z'; }], ['explicit different batch', value => { value.conflicts[1].batchId = 'another-batch'; }], ['unknown conflict reason', value => { value.conflicts.push({ ...value.conflicts[0], reason: '人工标记答案有疑问' }); }], ['malformed conflict evidence', value => { value.conflicts.push(null); }], ['missing answer evidence', value => { delete value.conflicts[1].correctOptionIds; }], ['missing old mismatch reason', value => { value.conflicts.shift(); }], ['duplicate option ID', value => { value.options[3].id = 'b'; }], ['duplicate correct ID', value => { value.correctOptionIds.push('b'); }], ['unknown correct ID', value => { value.correctOptionIds.push('unknown'); }], ['no duplicate correct option text', value => { value.correctOptionIds = ['a', 'b', 'c']; value.conflicts.forEach(snapshot => { snapshot.correctOptionIds = ['a', 'b', 'c']; }); }], ['non-multiple conflict', value => { value.type = 'single'; }] ]; for (const [name, mutate] of cases) await t.test(name, () => { const value = legacyRepeatedMultiple(); mutate(value); const result = core.repairDuplicateOptionConflicts([value]); assert.equal(result.repaired.length, 0); assert.equal(result.records[0].answerStatus, 'conflict'); }); }); function imageUpgradeFixture() { const source = { kind: 'network', url: 'https://example.test/upm/start', observedAt: '2026-10-29T00:00:00Z', batchId: 'old-image-batch' }; const existing = [0, 1].map(index => core.normalizeRecord(fixture({ questionId: `old-image-${index}`, stem: index ? '另一道无需图片的题目' : '根据溶解度曲线,应如何操作?', questionIndex: index, source }))); const incoming = existing.map((record, index) => core.normalizeRecord({ ...record, questionId: `new-image-${index}`, stem: record.stem + (index ? '' : '[图片:https://example.test/curve.png]'), source: { ...source, observedAt: '2026-10-29T01:00:00Z', batchId: 'new-image-batch' }, answerSource: { ...source, observedAt: '2026-10-29T01:00:00Z', batchId: 'new-image-batch' } })); return { existing, incoming, context: { bankId: 'bank-1', expectedCount: 2, fullSnapshot: true } }; } test('complete image snapshot upgrades only the unique old text record and retains its aliases', () => { const { existing, incoming, context } = imageUpgradeFixture(); const before = JSON.stringify([existing, incoming]); const result = core.upgradeLegacyImageRecords(existing, incoming, context); assert.equal(result.upgraded.length, 1); assert.equal(result.upgraded[0].original, existing[0]); assert.equal(result.existing.length, 1); assert.equal(result.incoming.length, 2); assert.ok(result.incoming[0].stem.includes('[图片:https://example.test/curve.png]')); assert.deepEqual(result.incoming[0].questionIdAliases, ['old-image-0', 'new-image-0']); assert.equal(JSON.stringify([existing, incoming]), before, 'upgrade must not mutate original inputs'); const merged = core.compactRecords(core.upsert(result.existing, result.incoming)).records; assert.equal(merged.length, 2, 'the image addition must not turn a two-question bank into three'); assert.equal(core.upgradeLegacyImageRecords(merged, incoming, context).upgraded.length, 0); }); test('image completion also upgrades an unchanged question ID before normal merge can mark a conflict', () => { const { existing, incoming, context } = imageUpgradeFixture(); incoming[0].questionId = existing[0].questionId; const result = core.upgradeLegacyImageRecords(existing, incoming, context); assert.equal(result.upgraded.length, 1); const merged = core.compactRecords(core.upsert(result.existing, result.incoming)).records; assert.equal(merged.length, 2); assert.ok(merged.every(record => record.answerStatus === 'confirmed')); }); test('image upgrade refuses partial, ambiguous or semantically changed evidence', async t => { const cases = [ ['not a full snapshot', value => { value.context.fullSnapshot = false; }], ['page count mismatch', value => { value.context.expectedCount = 3; }], ['wrong current bank', value => { value.context.bankId = 'other-bank'; }], ['incomplete snapshot', value => { value.incoming.pop(); }], ['missing position', value => { delete value.incoming[0].questionIndex; }], ['duplicate snapshot position', value => { value.incoming[1].questionIndex = 0; }], ['old position differs', value => { value.existing[0].questionIndex = 1; }], ['old position missing', value => { delete value.existing[0].questionIndex; }], ['old source is not a list response', value => { value.existing[0].source.url = 'https://example.test/answer'; }], ['new observation is only DOM', value => { value.incoming[0].source.kind = 'dom'; }], ['new snapshot has mixed batches', value => { value.incoming[1].source.batchId = 'another-batch'; }], ['old record already has a different image', value => { value.existing[0].stem += '[图片:https://example.test/other-curve.png]'; }], ['new text has changed', value => { value.incoming[0].stem = '根据溶解度曲线,不应如何操作?[图片:https://example.test/curve.png]'; }], ['new options have changed', value => { value.incoming[0].options[0].text += '且提高压力'; }], ['answer differs', value => { value.incoming[0].correctOptionIds = ['x']; value.incoming[0].answerTexts = ['降低温度']; }], ['old answer is conflicted', value => { value.existing[0].answerStatus = 'conflict'; }], ['two old candidates with the same complete text', value => { value.existing.push({ ...value.existing[0], questionId: 'another-old', questionIndex: 7 }); }], ['new snapshot has two equal text candidates', value => { value.incoming[1] = { ...value.incoming[0], questionId: 'new-image-1', questionIndex: 1 }; }], ['duplicate option content cannot be matched one-to-one', value => { value.existing[0].options[2].text = value.existing[0].options[0].text; value.incoming[0].options[2].text = value.incoming[0].options[0].text; }] ]; for (const [name, mutate] of cases) await t.test(name, () => { const value = imageUpgradeFixture(); mutate(value); const result = core.upgradeLegacyImageRecords(value.existing, value.incoming, value.context); assert.equal(result.upgraded.length, 0); assert.equal(result.existing.length, value.existing.length); assert.ok(result.incoming.every(record => !record.questionIdAliases?.includes('old-image-0'))); }); }); 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); }); function repeatedListSnapshots(size, duplicateCount = 3, batches = 3) { const result = []; for (let batch = 0; batch < batches; batch++) for (let index = 0; index < size; index++) { const contentIndex = index >= size - duplicateCount ? index - (size - duplicateCount) : index; const answer = contentIndex % 2 ? '错误' : '正确'; result.push({ bankId: 'bank-repeated', bankName: '判断题库', questionId: `session-${batch}-question-${index}`, type: 'judge', stem: `第${contentIndex}项设备检查结论。`, options: [{ id: '1', text: '正确', label: '正确' }, { id: '0', text: '错误', label: '错误' }], answerTexts: [answer], correctOptionIds: [answer === '正确' ? '1' : '0'], answerStatus: 'confirmed', source: { kind: 'network', url: 'https://api.example.test/ote/upm/start', observedAt: new Date(Date.UTC(2026, 9, 29, 1, batch, 0, index)).toISOString() } }); } return result; } test('legacy 696 rows compact to 232 while retaining legitimate repeated questions in each snapshot', () => { const raw = repeatedListSnapshots(232); const repaired = core.compactRecords(raw); assert.equal(repaired.records.length, 232); assert.equal(repaired.removed, 464); assert.equal(repaired.groups, 232); assert.equal(repaired.conflictCount, 0); assert.ok(repaired.records.every(record => record.questionIdAliases.length === 3)); assert.equal(new Set(repaired.records.map(record => record.stem)).size, 229); const repeated = repaired.records.filter(record => record.stem === raw[0].stem); assert.deepEqual(repeated.map(record => record.contentOccurrence).sort(), [0, 1]); assert.equal(core.upsert(raw, repaired.records).length, 232); assert.equal(core.upsert(repaired.records, raw).length, 232); }); test('three 500-row snapshots plus anonymous missing DOM observations repair to 500', () => { const raw = repeatedListSnapshots(500, 2); const dom = { ...raw[0], questionId: '', bankId: '', answerStatus: 'missing', answerTexts: [], correctOptionIds: [], source: { kind: 'dom', url: 'https://practice.example.test/' } }; raw.splice(500, 0, dom); raw.push({ ...dom, bankId: 'bank-repeated' }); const repaired = core.compactRecords(raw); assert.equal(repaired.records.length, 500); assert.equal(repaired.removed, 1002); assert.equal(core.upsert(raw, repaired.records).length, 500); assert.equal(core.compactRecords(core.upsert(repaired.records, raw)).records.length, 500); }); test('explicit batch and occurrence metadata preserves multiplicity without relying on timestamps', () => { const raw = repeatedListSnapshots(5, 2); const counters = new Map(); raw.forEach((record, index) => { const batch = Math.floor(index / 5); const key = `${batch}:${record.stem}`; record.questionIndex = index % 5; record.contentOccurrence = counters.get(key) || 0; counters.set(key, record.contentOccurrence + 1); record.source.batchId = `batch-${batch}`; record.source.observedAt = '2026-10-29T05:00:00Z'; }); const repaired = core.compactRecords(raw); assert.equal(repaired.records.length, 5); assert.ok(repaired.records.every(record => Number.isSafeInteger(record.questionIndex) && Number.isSafeInteger(record.contentOccurrence))); const csvRecords = core.fromCSV(core.toCSV(repaired.records)); assert.deepEqual(csvRecords, repaired.records); assert.equal(core.compactRecords(csvRecords).records.length, 5); assert.ok(csvRecords.every(record => record.source.batchId)); }); test('without snapshot or occurrence evidence, different identified questions stay separate', () => { const first = fixture({ questionId: 'first' }); const second = fixture({ questionId: 'second' }); assert.equal(core.compactRecords([first, second]).records.length, 2); const incomplete = [first, second].map(record => ({ ...record, contentOccurrence: 0, options: [], correctOptionIds: [], answerTexts: [], answerStatus: 'missing' })); assert.equal(core.compactRecords(incomplete).records.length, 2); }); test('compaction never crosses different known bank IDs or guesses an ambiguous bank name', () => { const row = fixture({ questionId: 'a', contentOccurrence: 0 }); const other = { ...row, questionId: 'b', bankId: 'bank-2' }; const unresolved = { ...row, questionId: 'c', bankId: '' }; assert.equal(core.compactRecords([row, other, unresolved]).records.length, 3); const uniqueName = core.compactRecords([row, unresolved]); assert.equal(uniqueName.records.length, 1); assert.equal(uniqueName.records[0].bankId, row.bankId); assert.deepEqual(new Set(uniqueName.records[0].questionIdAliases), new Set(['a', 'c'])); }); test('compaction preserves differing answers as a conflict and keeps original ID aliases', () => { const first = fixture({ questionId: 'first', contentOccurrence: 0 }); const other = fixture({ questionId: 'second', contentOccurrence: 0, correctOptionIds: ['x'] }); const repaired = core.compactRecords([first, other]); assert.equal(repaired.records.length, 1); assert.equal(repaired.conflictCount, 1); assert.equal(repaired.records[0].answerStatus, 'conflict'); assert.equal(repaired.records[0].conflicts.length, 2); assert.deepEqual(new Set(repaired.records[0].questionIdAliases), new Set(['first', 'second'])); assert.equal(core.mapAnswers(repaired.records[0], first).status, 'ambiguous'); assert.equal(core.upsert(repaired.records, [first]).length, 1); assert.equal(core.upsert(repaired.records, [first])[0].answerStatus, 'conflict'); }); test('compaction retains option IDs and their correct answers atomically when IDs change', () => { const first = fixture({ questionId: 'first', contentOccurrence: 0 }); const second = reorder(fixture({ questionId: 'second', contentOccurrence: 0 })); second.options = second.options.map((option, index) => ({ ...option, id: `new-${index}` })); second.correctOptionIds = ['new-2']; const repaired = core.compactRecords([first, second]).records[0]; assert.equal(repaired.answerStatus, 'confirmed'); assert.deepEqual(repaired.answerTexts, ['提高温度']); assert.equal(repaired.options.find(option => option.id === repaired.correctOptionIds[0]).text, repaired.answerTexts[0]); assert.equal(core.mapAnswers(repaired, second).status, 'matched'); }); test('compaction distinguishes different option sets, negation and mathematical units', () => { const rows = [ fixture({ questionId: 'a', contentOccurrence: 0, stem: 'P水 > P气,面积2m²' }), fixture({ questionId: 'b', contentOccurrence: 0, stem: 'P水 < P气,面积2m²' }), fixture({ questionId: 'c', contentOccurrence: 0, stem: 'P水 > P气,面积2m2' }), fixture({ questionId: 'd', contentOccurrence: 0, stem: '不正确的操作' }), fixture({ questionId: 'e', contentOccurrence: 0, stem: '正确的操作' }), fixture({ questionId: 'f', contentOccurrence: 0, options: [{ id: 'x', text: '新选项' }, { id: 'y', text: '提高温度' }] }) ]; assert.equal(core.compactRecords(rows).records.length, rows.length); }); test('anonymous snapshot duplicates with explicit occurrence have distinct keys', () => { const first = fixture({ questionId: '', contentOccurrence: 0 }); const second = fixture({ questionId: '', contentOccurrence: 1 }); assert.notEqual(core.normalizeRecord(first).key, core.normalizeRecord(second).key); assert.equal(core.upsert([], [first, second]).length, 2); assert.equal(core.compactRecords([first, second]).records.length, 2); }); test('metadata validation refuses corrupt occurrences and preserves explicit aliases in JSON', () => { assert.throws(() => core.normalizeRecord(fixture({ contentOccurrence: -1 })), /非负整数/); assert.throws(() => core.normalizeRecord(fixture({ questionIndex: '1' })), /非负整数/); assert.throws(() => core.normalizeRecord(fixture({ questionIdAliases: [Number.MAX_SAFE_INTEGER + 1] })), /不安全/); const record = core.normalizeRecord(fixture({ contentOccurrence: 0, questionIndex: 5, questionIdAliases: ['legacy-id'], source: { kind: 'network', batchId: 'batch-one', observedAt: '2026-10-29T00:00:00Z' } })); assert.deepEqual(core.normalizeRecord(JSON.parse(JSON.stringify(record))), record); assert.deepEqual(core.fromCSV(core.toCSV([record])), [record]); });