Files
huayi-question-bank/tests/core.test.cjs
T
2026-10-29 23:09:32 +08:00

712 lines
45 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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 maps only with complete unchanged option ID identities', () => {
const record = core.normalizeRecord(fixture({ options: [{ id: 'x', text: '提高温度', label: 'A' }, { id: 'y', text: '提高温度', label: 'B' }] }));
assert.deepEqual(core.mapAnswers(record, record), { status: 'matched', labels: ['B'], answerTexts: ['提高温度'] });
const reordered = { ...record, options: [...record.options].reverse().map((option, index) => ({ ...option, label: 'AB'[index] })) };
assert.deepEqual(core.mapAnswers(record, reordered).labels, ['A']);
const newIds = { ...reordered, options: reordered.options.map((option, index) => ({ ...option, id: `new-${index}` })) };
assert.equal(core.mapAnswers(record, newIds).status, 'ambiguous');
const oneChangedId = { ...reordered, options: reordered.options.map((option, index) => index ? { ...option, id: 'new-id' } : option) };
assert.equal(core.mapAnswers(record, oneChangedId).status, 'ambiguous');
assert.equal(core.mapAnswers({ ...record, correctOptionIds: [] }, record).status, 'ambiguous');
assert.equal(core.mapAnswers(record, { ...record, options: record.options.map(option => ({ ...option, id: '' })) }).status, 'ambiguous');
assert.ok(core.toCSV([record]).includes('"B"'), 'self export should preserve the observed correct letter');
});
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 repeated options require complete IDs; text-only repeated options remain ambiguous', () => {
const partial = core.normalizeRecord(repeatedMultiple({ correctOptionIds: ['b'], answerTexts: ['下降'] }));
assert.equal(partial.answerStatus, 'confirmed');
assert.deepEqual(core.mapAnswers(partial, partial).labels, ['B']);
const changedIds = { ...partial, options: partial.options.map((option, index) => ({ ...option, id: `new-${index}` })) };
assert.equal(core.mapAnswers(partial, changedIds).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')));
});
});
function mathEntityFixture() {
const value = imageUpgradeFixture();
value.existing[0].stem = '传热速率=系数×推动力,允许偏差±2。';
value.incoming[0].stem = '传热速率=系数×推动力,允许偏差±2。';
return value;
}
test('complete source snapshot repairs only the evidenced times and plusmn spellings', () => {
const { existing, incoming, context } = mathEntityFixture();
const original = JSON.stringify([existing, incoming]);
assert.equal(core.mapAnswers(existing[0], incoming[0]).status, 'unmapped', 'ordinary comparisons must not decode literal entities');
const result = core.upgradeLegacyMathEntities(existing, incoming, context);
assert.equal(result.upgraded.length, 1);
assert.equal(result.upgraded[0].kind, 'math-entity');
assert.equal(result.existing.length, 1);
assert.ok(result.incoming[0].questionIdAliases.includes(existing[0].questionId));
assert.equal(JSON.stringify([existing, incoming]), original);
assert.equal(core.compactRecords(core.upsert(result.existing, result.incoming)).records.length, 2);
const alreadyDuplicated = core.upgradeLegacyMathEntities([...existing, incoming[0]], incoming, context);
assert.equal(alreadyDuplicated.upgraded.length, 1, 'a previous failed migration containing both representations must recover');
assert.equal(core.compactRecords(core.upsert(alreadyDuplicated.existing, alreadyDuplicated.incoming)).records.length, 2);
const sameId = mathEntityFixture(); sameId.incoming[0].questionId = sameId.existing[0].questionId;
assert.equal(core.upgradeLegacyMathEntities(sameId.existing, sameId.incoming, context).upgraded.length, 1);
const options = imageUpgradeFixture();
options.incoming[0].stem = options.existing[0].stem;
options.existing[0].options[0].text = '12+1×2×2';
options.incoming[0].options[0].text = '12+1×2×2';
assert.equal(core.upgradeLegacyMathEntities(options.existing, options.incoming, context).upgraded.length, 1, 'non-correct option text can be completed without changing the standard answer');
});
test('math entity completion retains literal entities, missing positions and real differences', async t => {
const cases = [
['incomplete snapshot', value => { value.incoming.pop(); }],
['unverified page count', value => { value.context.expectedCount = 3; }],
['not a full snapshot', value => { value.context.fullSnapshot = false; }],
['wrong bank', value => { value.context.bankId = 'other-bank'; }],
['old position missing', value => { delete value.existing[0].questionIndex; }],
['different position', value => { value.existing[0].questionIndex = 1; }],
['new positions overlap', value => { value.incoming[1].questionIndex = 0; }],
['different option ID identity', value => { value.incoming[0].options[0].id = 'replacement-id'; }],
['different standard answer', value => { value.incoming[0].correctOptionIds = ['x']; value.incoming[0].answerTexts = ['降低温度']; }],
['unknown entity outside the whitelist', value => { value.existing[0].stem = '系数÷推动力'; value.incoming[0].stem = '系数÷推动力'; }],
['literal entity remains in the new question', value => { value.incoming[0].stem = value.existing[0].stem; }],
['part of the entity text intentionally remains literal', value => { value.incoming[0].stem = '传热速率=系数×推动力,允许偏差±2。'; }],
['different operator', value => { value.incoming[0].stem = '传热速率=系数x推动力,允许偏差±2。'; }],
['different image content', value => { value.existing[0].stem += '[图片:https://example.test/a.png]'; value.incoming[0].stem += '[图片:https://example.test/b.png]'; }],
['same legacy spelling occurs twice', value => { value.existing.push({ ...value.existing[0], questionId: 'second-literal', questionIndex: 4 }); }],
['separate modern question at a different position', value => { value.existing.push({ ...value.incoming[0], questionIndex: 4 }); }],
['a true second question intentionally uses literal spelling', value => { value.incoming[1] = { ...value.existing[0], questionId: 'literal-second', questionIndex: 1, source: value.incoming[1].source }; }],
['old source is not a network list', value => { value.existing[0].source.kind = 'dom'; }],
['mixed incoming batches', value => { value.incoming[1].source.batchId = 'different'; }],
['same batch is not evidence of an upgrade', value => { value.existing[0].source.batchId = value.incoming[0].source.batchId; }]
];
for (const [name, mutate] of cases) await t.test(name, () => {
const value = mathEntityFixture(); mutate(value);
const result = core.upgradeLegacyMathEntities(value.existing, value.incoming, value.context);
assert.equal(result.upgraded.length, 0);
assert.equal(result.existing.length, value.existing.length);
});
});
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]);
});