976 lines
62 KiB
JavaScript
976 lines
62 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 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]);
|
||
});
|
||
|
||
test('indexed aliases retain traversal order when a later match reveals more aliases', () => {
|
||
const rows = [
|
||
fixture({ questionId: 'root', questionIdAliases: ['root', 'bridge'], explanation: '根记录' }),
|
||
fixture({ questionId: 'earlier', explanation: '较早记录' }),
|
||
fixture({ questionId: 'bridge', questionIdAliases: ['bridge', 'earlier', 'later', 'after'], explanation: '桥接记录' }),
|
||
fixture({ questionId: 'later', explanation: '最后记录' }),
|
||
fixture({ questionId: 'bridge', bankId: 'other-bank', explanation: '另一题库' }),
|
||
fixture({ questionId: 'later', stem: '同ID别名但内容不同' }),
|
||
fixture({ questionId: 'after', explanation: '后续新增别名记录' }),
|
||
];
|
||
const result = core.upsert([], rows);
|
||
assert.deepEqual(result.map(record => [record.bankId, record.questionId]), [
|
||
['bank-1', 'root'], ['bank-1', 'earlier'], ['bank-1', 'later'], ['other-bank', 'bridge']
|
||
]);
|
||
// The late alias was not known while visiting the earlier position. Preserve
|
||
// that existing one-pass behavior rather than changing the answer's history.
|
||
assert.deepEqual(result[0].questionIdAliases, ['root', 'bridge', 'earlier', 'later', 'after']);
|
||
assert.equal(result[0].explanation, '后续新增别名记录');
|
||
assert.equal(result[2].answerStatus, 'conflict');
|
||
assert.deepEqual(core.fromCSV(core.toCSV(result)), result);
|
||
});
|
||
|
||
test('indexed anonymous promotion remains unique in both directions and preserves record order', () => {
|
||
const rows = [
|
||
fixture({ questionId: '', contentOccurrence: 0 }),
|
||
fixture({ questionId: 'unrelated', stem: '另一个题干' }),
|
||
fixture({ questionId: '', contentOccurrence: 1 }),
|
||
fixture({ questionId: 'ambiguous' }),
|
||
fixture({ questionId: 'unique', stem: '唯一可升级题干' }),
|
||
fixture({ questionId: '', stem: '唯一可升级题干', explanation: '后出现的说明' }),
|
||
];
|
||
const result = core.upsert([], rows);
|
||
assert.deepEqual(result.map(record => record.questionId), ['', 'unrelated', '', 'ambiguous', 'unique']);
|
||
assert.deepEqual(result.filter(record => !record.questionId).map(record => record.contentOccurrence), [0, 1]);
|
||
assert.equal(result.at(-1).explanation, '后出现的说明');
|
||
});
|
||
|
||
test('fast internal merges preserve fresh DOM metadata and revalidate externally mutated inputs', () => {
|
||
const original = core.normalizeRecord(fixture({ questionIndex: 3, contentOccurrence: 0, questionIdAliases: ['old'] }));
|
||
const dom = { ...original, answerStatus: 'missing', correctOptionIds: [], answerTexts: [], explanation: '新增解析',
|
||
questionIdAliases: ['another-id'], updatedAt: '2026-10-30T01:00:00Z',
|
||
source: { kind: 'dom', url: 'https://practice.example.test/new', observedAt: '2026-10-30T01:00:00Z' } };
|
||
const merged = core.upsert([original], [dom])[0];
|
||
assert.deepEqual(merged.answerTexts, original.answerTexts);
|
||
assert.deepEqual(merged.correctOptionIds, original.correctOptionIds);
|
||
assert.deepEqual(merged.answerSource, original.answerSource);
|
||
assert.deepEqual(merged.source, dom.source);
|
||
assert.equal(merged.updatedAt, dom.updatedAt);
|
||
assert.equal(merged.explanation, dom.explanation);
|
||
assert.deepEqual(merged.questionIdAliases, ['old', original.questionId, 'another-id']);
|
||
assert.equal(merged.questionIndex, 3);
|
||
assert.equal(merged.contentOccurrence, 0);
|
||
dom.options = dom.options.map(option => option.id === 'y' ? { ...option, text: '改变选项内容' } : option);
|
||
assert.equal(core.upsert([original], [dom])[0].answerStatus, 'conflict');
|
||
assert.equal(original.options.find(option => option.id === 'y').text, '提高温度');
|
||
});
|
||
|
||
test('large repeated alias banks preserve every canonical record and complete CSV content', () => {
|
||
const records = Array.from({ length: 600 }, (_, index) => core.normalizeRecord(fixture({
|
||
questionId: `canonical-${index}`, questionIdAliases: [`canonical-${index}`, `old-${index}`],
|
||
stem: `设备检查${index}:P水 > P气,面积2m²`, questionIndex: index, contentOccurrence: 0,
|
||
source: { kind: 'network', url: 'https://example.test/upm/start', observedAt: '2026-10-29T00:00:00Z', batchId: 'complete-list' }
|
||
})));
|
||
const result = core.upsert(records, records);
|
||
assert.equal(JSON.stringify(result), JSON.stringify(records));
|
||
assert.equal(core.toCSV(result), core.toCSV(records));
|
||
const compacted = core.compactRecords(result);
|
||
assert.equal(compacted.removed, 0);
|
||
assert.equal(JSON.stringify(compacted.records), JSON.stringify(records));
|
||
});
|
||
|
||
const legacyJudge = (overrides = {}) => fixture({
|
||
type: 'judge', stem: '断续噪声对人体危害更大。',
|
||
options: [{ id: '1', text: '正确', label: '正确' }, { id: '0', text: '错误', label: '错误' }],
|
||
correctOptionIds: ['0'], answerTexts: ['错误'], questionIndex: 66, contentOccurrence: 0,
|
||
questionIdAliases: ['old-question'],
|
||
source: { kind: 'network', url: 'https://api.example.test/ote/upm/start', observedAt: '2026-10-29T00:00:00Z', batchId: 'old-list' },
|
||
...overrides,
|
||
});
|
||
const officialJudgeSource = (suffix = 'answer') => ({ kind: 'network',
|
||
url: `https://api.example.test/ote/upm/practice-id/users/_/${suffix}`, observedAt: '2026-10-30T00:00:00Z' });
|
||
const judgeSnapshot = (record) => ({ reason: '同一题目出现不同或不一致的标准答案', stem: record.stem,
|
||
options: record.options, answerTexts: record.answerTexts, correctOptionIds: record.correctOptionIds,
|
||
source: record.answerSource || record.source, ...(record.answerEvidence ? { answerEvidence: record.answerEvidence } : {}) });
|
||
|
||
test('unverified legacy practice-list judge values 0 and 1 both become missing without guessing', () => {
|
||
const input = [legacyJudge(), legacyJudge({ questionId: 'true-placeholder', correctOptionIds: ['1'], answerTexts: ['正确'],
|
||
source: { kind: 'network', url: 'https://api.example.test/ote/upm/wrong/start?lang=zh', observedAt: '2026-10-29T00:00:00Z' } })];
|
||
const raw = JSON.stringify(input);
|
||
const result = core.repairLegacyJudgeAnswers(input);
|
||
assert.equal(result.records.length, input.length);
|
||
assert.equal(result.changed.length, 2);
|
||
result.records.forEach((record, index) => {
|
||
assert.equal(record.answerStatus, 'missing');
|
||
assert.deepEqual(record.answerTexts, []);
|
||
assert.deepEqual(record.correctOptionIds, []);
|
||
assert.deepEqual(record.conflicts, []);
|
||
assert.equal(record.answerEvidence, undefined);
|
||
assert.equal(record.answerSource, undefined);
|
||
assert.equal(record.stem, input[index].stem);
|
||
assert.equal(record.questionIndex, input[index].questionIndex);
|
||
assert.equal(record.contentOccurrence, input[index].contentOccurrence);
|
||
assert.deepEqual(record.questionIdAliases, [...input[index].questionIdAliases, input[index].questionId]);
|
||
assert.strictEqual(result.changed[index].original, input[index]);
|
||
assert.equal(core.mapAnswers(record, input[index]).status, 'unmapped');
|
||
});
|
||
assert.equal(JSON.stringify(input), raw);
|
||
assert.deepEqual(core.fromCSV(core.toCSV(result.records)), result.records);
|
||
assert.equal(core.search(result.records, '错误', { field: 'answer' }).length, 0);
|
||
const again = core.repairLegacyJudgeAnswers(result.records);
|
||
assert.strictEqual(again.records, result.records);
|
||
assert.deepEqual(again.changed, []);
|
||
});
|
||
|
||
test('answer evidence validates and survives normalization, conflict snapshots, JSON and CSV', () => {
|
||
for (const answerEvidence of ['submitted-list', 'answer-response', 'bound-dom', 'explicit-standard']) {
|
||
const record = core.normalizeRecord(legacyJudge({ answerEvidence }));
|
||
assert.equal(record.answerEvidence, answerEvidence);
|
||
assert.deepEqual(core.normalizeRecord(JSON.parse(JSON.stringify(record))), record);
|
||
assert.deepEqual(core.fromCSV(core.toCSV([record])), [record]);
|
||
assert.strictEqual(core.repairLegacyJudgeAnswers([record]).records[0], record);
|
||
const merged = core.mergeRecord(record, { ...record, answerStatus: 'missing', answerEvidence: undefined,
|
||
answerTexts: [], correctOptionIds: [], source: { kind: 'dom', observedAt: '2026-10-30T01:00:00Z' } });
|
||
assert.equal(merged.answerEvidence, answerEvidence);
|
||
assert.deepEqual(merged.answerSource, record.answerSource);
|
||
const repeated = core.mergeRecord(record, legacyJudge({ source: { ...record.source, observedAt: '2026-10-30T02:00:00Z' } }));
|
||
assert.equal(repeated.answerEvidence, answerEvidence);
|
||
assert.deepEqual(repeated.answerSource, record.answerSource);
|
||
assert.equal(repeated.source.observedAt, '2026-10-30T02:00:00Z');
|
||
const conflict = core.mergeRecord(record, legacyJudge({ answerEvidence: 'bound-dom', correctOptionIds: ['1'], answerTexts: ['正确'] }));
|
||
assert.ok(conflict.conflicts.some(snapshot => snapshot.answerEvidence === answerEvidence));
|
||
assert.ok(conflict.conflicts.some(snapshot => snapshot.answerEvidence === 'bound-dom'));
|
||
}
|
||
assert.throws(() => core.normalizeRecord(legacyJudge({ answerEvidence: 'unsubmitted-list' })), /无效答案证据/);
|
||
});
|
||
|
||
test('manual standard imports, non-judge types and unrelated sources retain their answers', () => {
|
||
const input = [
|
||
legacyJudge({ source: { kind: 'csv-import', observedAt: '2026-10-29T00:00:00Z' } }),
|
||
legacyJudge({ source: { kind: 'network', url: 'https://example.test/upm/startAgain', observedAt: '2026-10-29T00:00:00Z' } }),
|
||
fixture({ source: legacyJudge().source }),
|
||
legacyJudge({ source: officialJudgeSource() }),
|
||
legacyJudge({ answerEvidence: 'bound-dom', source: { kind: 'dom', observedAt: '2026-10-29T00:00:00Z' } }),
|
||
];
|
||
const result = core.repairLegacyJudgeAnswers(input);
|
||
assert.strictEqual(result.records, input);
|
||
assert.deepEqual(result.changed, []);
|
||
const csv = core.fromCSV('题型,题干,选项A,选项B,正确答案内容\r\n判断题,人工明确标准,正确,错误,正确\r\n');
|
||
assert.equal(core.repairLegacyJudgeAnswers(csv).records[0].answerStatus, 'confirmed');
|
||
});
|
||
|
||
test('DOM-inherited conflicts containing only unverified list values become missing', () => {
|
||
const old = core.normalizeRecord(legacyJudge());
|
||
const later = core.normalizeRecord(legacyJudge({ correctOptionIds: ['1'], answerTexts: ['正确'] }));
|
||
const record = core.normalizeRecord({ ...later, answerStatus: 'conflict',
|
||
source: { kind: 'dom', observedAt: '2026-10-30T00:00:00Z' }, answerSource: { kind: 'dom', observedAt: '2026-10-30T00:00:00Z' },
|
||
conflicts: [judgeSnapshot(old), judgeSnapshot(later)] });
|
||
const result = core.repairLegacyJudgeAnswers([record]);
|
||
assert.equal(result.records[0].answerStatus, 'missing');
|
||
assert.deepEqual(result.records[0].conflicts, []);
|
||
assert.deepEqual(result.records[0].answerTexts, []);
|
||
assert.equal(result.changed.length, 1);
|
||
});
|
||
|
||
test('standalone old DOM judge answers without retained list history are quarantined with their raw originals', () => {
|
||
const source = { kind: 'dom', url: 'https://practice.example.test/', observedAt: '2026-10-29T00:00:00Z' };
|
||
const input = [legacyJudge({ source }), legacyJudge({ source, questionId: 'dom-true', correctOptionIds: ['1'], answerTexts: ['正确'] })];
|
||
const raw = JSON.stringify(input);
|
||
const result = core.repairLegacyJudgeAnswers(input);
|
||
assert.equal(result.changed.length, 2);
|
||
result.records.forEach((record, index) => {
|
||
assert.equal(record.answerStatus, 'missing');
|
||
assert.deepEqual(record.answerTexts, []);
|
||
assert.deepEqual(record.correctOptionIds, []);
|
||
assert.strictEqual(result.changed[index].original, input[index]);
|
||
});
|
||
assert.equal(JSON.stringify(input), raw);
|
||
assert.deepEqual(core.repairLegacyJudgeAnswers(result.records).changed, []);
|
||
const proven = input.map(record => core.normalizeRecord({ ...record, answerEvidence: 'bound-dom' }));
|
||
assert.strictEqual(core.repairLegacyJudgeAnswers(proven).records, proven);
|
||
});
|
||
|
||
test('standalone DOM provenance retains genuine receipts and recovers trusted snapshots without list history', () => {
|
||
const dom = { kind: 'dom', url: 'https://practice.example.test/', observedAt: '2026-10-29T00:00:00Z' };
|
||
const receipt = core.normalizeRecord(legacyJudge({ source: officialJudgeSource(), correctOptionIds: ['1'], answerTexts: ['正确'] }));
|
||
const preserved = core.normalizeRecord({ ...receipt, source: dom });
|
||
assert.strictEqual(core.repairLegacyJudgeAnswers([preserved]).records[0], preserved);
|
||
const old = core.normalizeRecord(legacyJudge({ source: dom }));
|
||
const conflict = { ...old, answerStatus: 'conflict', conflicts: [judgeSnapshot(old), judgeSnapshot(receipt)] };
|
||
const repaired = core.repairLegacyJudgeAnswers([conflict]).records[0];
|
||
assert.equal(repaired.answerStatus, 'confirmed');
|
||
assert.equal(repaired.answerEvidence, 'answer-response');
|
||
assert.deepEqual(repaired.answerTexts, ['正确']);
|
||
assert.deepEqual(repaired.answerSource, receipt.source);
|
||
assert.deepEqual(repaired.conflicts, []);
|
||
const onlyDom = { ...old, answerStatus: 'conflict', conflicts: [judgeSnapshot(old), { ...judgeSnapshot(old), answerTexts: ['正确'], correctOptionIds: ['1'] }] };
|
||
assert.equal(core.repairLegacyJudgeAnswers([onlyDom]).records[0].answerStatus, 'missing');
|
||
});
|
||
|
||
test('a real answer receipt recovers the standard from legacy conflicts with atomic option IDs', () => {
|
||
for (const suffix of ['answer', 'wrong/answer']) {
|
||
const old = core.normalizeRecord(legacyJudge());
|
||
const receipt = core.normalizeRecord(legacyJudge({
|
||
options: [{ id: 'new-false', text: '错误', label: 'A' }, { id: 'new-true', text: '正确', label: 'B' }],
|
||
correctOptionIds: ['new-true'], answerTexts: ['正确'], source: officialJudgeSource(suffix),
|
||
}));
|
||
const contaminated = { ...old, answerStatus: 'conflict', conflicts: [judgeSnapshot(old), judgeSnapshot(receipt)] };
|
||
const result = core.repairLegacyJudgeAnswers([contaminated]);
|
||
const repaired = result.records[0];
|
||
assert.equal(repaired.answerStatus, 'confirmed');
|
||
assert.equal(repaired.answerEvidence, 'answer-response');
|
||
assert.deepEqual(repaired.answerTexts, ['正确']);
|
||
assert.deepEqual(repaired.correctOptionIds, ['new-true']);
|
||
assert.deepEqual(repaired.options, receipt.options);
|
||
assert.deepEqual(repaired.answerSource, receipt.source);
|
||
assert.deepEqual(repaired.source, old.source);
|
||
assert.deepEqual(repaired.conflicts, []);
|
||
assert.equal(core.mapAnswers(repaired, receipt).status, 'matched');
|
||
assert.strictEqual(result.changed[0].original, contaminated);
|
||
assert.deepEqual(core.repairLegacyJudgeAnswers(result.records).changed, []);
|
||
}
|
||
const receipt = core.normalizeRecord(legacyJudge({ source: officialJudgeSource() }));
|
||
const observedAgain = core.mergeRecord(receipt, legacyJudge());
|
||
assert.deepEqual(observedAgain.answerSource, receipt.source);
|
||
assert.deepEqual(core.repairLegacyJudgeAnswers([observedAgain]).changed, []);
|
||
});
|
||
|
||
test('proven submitted or bound answers replace placeholders while two proven answers remain conflicting', () => {
|
||
const old = core.normalizeRecord(legacyJudge());
|
||
const first = core.normalizeRecord(legacyJudge({ answerEvidence: 'submitted-list', correctOptionIds: ['1'], answerTexts: ['正确'] }));
|
||
const second = core.normalizeRecord(legacyJudge({ answerEvidence: 'bound-dom' }));
|
||
const good = core.repairLegacyJudgeAnswers([{ ...first, answerStatus: 'conflict', conflicts: [judgeSnapshot(old), judgeSnapshot(first)] }]).records[0];
|
||
assert.equal(good.answerStatus, 'confirmed');
|
||
assert.deepEqual(good.answerTexts, ['正确']);
|
||
const conflict = core.repairLegacyJudgeAnswers([{ ...second, answerStatus: 'conflict',
|
||
conflicts: [judgeSnapshot(old), judgeSnapshot(first), judgeSnapshot(second)] }]).records[0];
|
||
assert.equal(conflict.answerStatus, 'conflict');
|
||
assert.ok(conflict.conflicts.every(snapshot => snapshot.answerEvidence));
|
||
assert.deepEqual(new Set(conflict.conflicts.flatMap(snapshot => snapshot.answerTexts)), new Set(['正确', '错误']));
|
||
assert.equal(core.mapAnswers(conflict, first).status, 'ambiguous');
|
||
});
|
||
|
||
test('conflict dedup retains independent standard provenance when its answer equals an old placeholder', () => {
|
||
const old = core.normalizeRecord(legacyJudge());
|
||
const trueReceipt = core.normalizeRecord(legacyJudge({ source: officialJudgeSource(), correctOptionIds: ['1'], answerTexts: ['正确'], answerEvidence: 'answer-response' }));
|
||
const falseReceipt = core.normalizeRecord(legacyJudge({ source: officialJudgeSource(), answerEvidence: 'answer-response' }));
|
||
const merged = core.mergeRecord(core.mergeRecord(old, trueReceipt), falseReceipt);
|
||
assert.ok(merged.conflicts.some(snapshot => snapshot.source.url.endsWith('/start')));
|
||
assert.ok(merged.conflicts.some(snapshot => snapshot.answerTexts[0] === '错误' && snapshot.answerEvidence === 'answer-response'));
|
||
const inherited = { ...merged, answerSource: { kind: 'dom', observedAt: '2026-10-30T01:00:00Z' }, answerEvidence: undefined };
|
||
const repaired = core.repairLegacyJudgeAnswers([inherited]).records[0];
|
||
assert.equal(repaired.answerStatus, 'conflict');
|
||
assert.ok(repaired.conflicts.every(snapshot => snapshot.answerEvidence === 'answer-response'));
|
||
});
|
||
|
||
test('unknown network conflict evidence is retained conservatively rather than restored as a standard', () => {
|
||
const old = core.normalizeRecord(legacyJudge());
|
||
const unknown = { ...judgeSnapshot(old), source: { kind: 'network', url: 'https://example.test/unknown-standard', observedAt: '2026-10-30T00:00:00Z' } };
|
||
const repaired = core.repairLegacyJudgeAnswers([{ ...old, answerStatus: 'conflict', conflicts: [judgeSnapshot(old), unknown] }]).records[0];
|
||
assert.equal(repaired.answerStatus, 'conflict');
|
||
assert.deepEqual(repaired.answerTexts, []);
|
||
assert.deepEqual(repaired.conflicts, [unknown]);
|
||
});
|