438 lines
26 KiB
JavaScript
438 lines
26 KiB
JavaScript
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);
|
|
});
|
|
|
|
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]);
|
|
});
|