fix: retain question images and repair evidenced legacy records
This commit is contained in:
@@ -39,6 +39,207 @@ test('duplicate option content is ambiguous even when ID exists', () => {
|
||||
assert.equal(core.mapAnswers(record, record).status, 'ambiguous');
|
||||
});
|
||||
|
||||
const repeatedMultiple = (overrides = {}) => fixture({
|
||||
type: 'multiple',
|
||||
options: [
|
||||
{ id: 'a', text: '降低', label: 'A' }, { id: 'b', text: '下降', label: 'B' },
|
||||
{ id: 'c', text: '增加', label: 'C' }, { id: 'd', text: '下降', label: 'D' }
|
||||
],
|
||||
correctOptionIds: ['a', 'b', 'c', 'd'], answerTexts: ['降低', '下降', '增加'],
|
||||
...overrides
|
||||
});
|
||||
|
||||
test('multiple correct IDs with repeated option text normalize without a false conflict', () => {
|
||||
const record = core.normalizeRecord(repeatedMultiple());
|
||||
assert.equal(record.answerStatus, 'confirmed');
|
||||
assert.deepEqual(record.correctOptionIds, ['a', 'b', 'c', 'd']);
|
||||
assert.deepEqual(record.answerTexts, ['降低', '下降', '增加']);
|
||||
assert.deepEqual(record.conflicts, []);
|
||||
assert.deepEqual(core.normalizeRecord(record), record);
|
||||
assert.deepEqual(core.fromCSV(core.toCSV([record])), [record]);
|
||||
});
|
||||
|
||||
test('all correct occurrences of repeated multiple-choice text map after IDs and order change', () => {
|
||||
const record = core.normalizeRecord(repeatedMultiple());
|
||||
const current = repeatedMultiple({ answerStatus: 'missing', correctOptionIds: [], answerTexts: [] });
|
||||
current.options = [current.options[3], current.options[2], current.options[0], current.options[1]]
|
||||
.map((option, index) => ({ ...option, id: `new-${index}`, label: 'ABCD'[index] }));
|
||||
const mapped = core.mapAnswers(record, current);
|
||||
assert.equal(mapped.status, 'matched');
|
||||
assert.deepEqual([...mapped.labels].sort(), ['A', 'B', 'C', 'D']);
|
||||
|
||||
const subset = core.normalizeRecord(repeatedMultiple({ correctOptionIds: ['b', 'd'], answerTexts: ['下降'] }));
|
||||
assert.equal(subset.answerStatus, 'confirmed');
|
||||
assert.deepEqual(core.mapAnswers(subset, current).labels, ['A', 'D']);
|
||||
});
|
||||
|
||||
test('partly correct or text-only repeated options remain ambiguous', () => {
|
||||
const partial = core.normalizeRecord(repeatedMultiple({ correctOptionIds: ['b'], answerTexts: ['下降'] }));
|
||||
assert.equal(partial.answerStatus, 'confirmed');
|
||||
assert.equal(core.mapAnswers(partial, partial).status, 'ambiguous');
|
||||
const textOnly = core.normalizeRecord(repeatedMultiple({ correctOptionIds: [], answerTexts: ['下降'] }));
|
||||
assert.equal(core.mapAnswers(textOnly, textOnly).status, 'ambiguous');
|
||||
});
|
||||
|
||||
test('repeated text does not override contradictory IDs, answer content or saved conflicts', () => {
|
||||
const record = core.normalizeRecord(repeatedMultiple());
|
||||
const changed = repeatedMultiple();
|
||||
[changed.options[0].id, changed.options[1].id] = [changed.options[1].id, changed.options[0].id];
|
||||
assert.equal(core.mapAnswers(record, changed).status, 'unmapped');
|
||||
assert.equal(core.normalizeRecord(repeatedMultiple({ answerTexts: ['降低', '下降'] })).answerStatus, 'conflict');
|
||||
const conflicts = [{ reason: '正确选项ID与正确答案内容不一致', answerTexts: record.answerTexts, correctOptionIds: record.correctOptionIds }];
|
||||
const legacy = core.normalizeRecord({ ...record, answerStatus: 'conflict', conflicts });
|
||||
assert.equal(legacy.answerStatus, 'conflict');
|
||||
assert.deepEqual(legacy.conflicts, conflicts);
|
||||
assert.equal(core.mergeRecord(legacy, record).answerStatus, 'conflict');
|
||||
});
|
||||
|
||||
test('different selections among repeated options remain real merge conflicts', () => {
|
||||
const all = repeatedMultiple();
|
||||
const partial = repeatedMultiple({ correctOptionIds: ['a', 'b', 'c'], answerTexts: ['降低', '下降', '增加'] });
|
||||
assert.equal(core.mergeRecord(all, partial).answerStatus, 'conflict');
|
||||
const first = repeatedMultiple({ correctOptionIds: ['b'], answerTexts: ['下降'] });
|
||||
const second = repeatedMultiple({ correctOptionIds: ['d'], answerTexts: ['下降'] });
|
||||
assert.equal(core.mergeRecord(first, second).answerStatus, 'conflict');
|
||||
});
|
||||
|
||||
function legacyRepeatedMultiple(overrides = {}) {
|
||||
const record = core.normalizeRecord(repeatedMultiple({
|
||||
source: { kind: 'network', url: 'https://example.test/upm/start', observedAt: '2026-10-29T00:00:00Z', batchId: 'batch-one' }, ...overrides
|
||||
}));
|
||||
return { ...record, answerStatus: 'conflict', conflicts: [
|
||||
{ reason: '正确选项ID与正确答案内容不一致', answerTexts: [...record.answerTexts], correctOptionIds: [...record.correctOptionIds] },
|
||||
{ reason: '同一题目出现不同或不一致的标准答案', stem: record.stem, options: record.options.map(option => ({ ...option })),
|
||||
answerTexts: [...record.answerTexts], correctOptionIds: [...record.correctOptionIds], source: { ...record.answerSource } }
|
||||
] };
|
||||
}
|
||||
|
||||
test('explicit legacy duplicate-text repair preserves the original and is idempotent', () => {
|
||||
const legacy = legacyRepeatedMultiple();
|
||||
const original = JSON.stringify(legacy);
|
||||
assert.equal(core.normalizeRecord(legacy).answerStatus, 'conflict', 'normalization alone must remain conservative');
|
||||
const result = core.repairDuplicateOptionConflicts([legacy]);
|
||||
assert.equal(result.repaired.length, 1);
|
||||
assert.equal(result.repaired[0].original, legacy, 'caller receives the exact original input for backup');
|
||||
assert.equal(JSON.stringify(legacy), original, 'pure repair must not mutate the input or snapshots');
|
||||
assert.equal(result.records[0].answerStatus, 'confirmed');
|
||||
assert.deepEqual(result.records[0].conflicts, []);
|
||||
assert.deepEqual(result.records[0].correctOptionIds, ['a', 'b', 'c', 'd']);
|
||||
assert.deepEqual([...core.mapAnswers(result.records[0], result.records[0]).labels].sort(), ['A', 'B', 'C', 'D']);
|
||||
assert.deepEqual(core.fromCSV(core.toCSV(result.records)), result.records);
|
||||
const again = core.repairDuplicateOptionConflicts(result.records);
|
||||
assert.deepEqual(again.records, result.records);
|
||||
assert.deepEqual(again.repaired, []);
|
||||
});
|
||||
|
||||
test('legacy repair requires complete agreeing evidence and never clears actual conflicts', async t => {
|
||||
const cases = [
|
||||
['different correct IDs despite the same text set', value => { value.conflicts[1].correctOptionIds = ['a', 'b', 'c']; }],
|
||||
['different answer content', value => { value.conflicts[1].answerTexts = ['下降']; }],
|
||||
['changed option content', value => { value.conflicts[1].options[0].text = '升高'; }],
|
||||
['changed option identities', value => { value.conflicts[1].options[0].id = 'replacement'; }],
|
||||
['partial options snapshot', value => { value.conflicts[1].options.pop(); }],
|
||||
['stem without its full options snapshot', value => { delete value.conflicts[1].options; }],
|
||||
['options without their full stem snapshot', value => { delete value.conflicts[1].stem; }],
|
||||
['changed stem', value => { value.conflicts[1].stem += '另一个条件'; }],
|
||||
['different question identity', value => { value.conflicts[1].questionId = 'other'; }],
|
||||
['different source batch', value => { value.conflicts[1].source.batchId = 'another-batch'; }],
|
||||
['source without the recorded batch', value => { delete value.conflicts[1].source.batchId; }],
|
||||
['different observation in source', value => { value.conflicts[1].source.observedAt = '2026-10-29T01:00:00Z'; }],
|
||||
['explicit different batch', value => { value.conflicts[1].batchId = 'another-batch'; }],
|
||||
['unknown conflict reason', value => { value.conflicts.push({ ...value.conflicts[0], reason: '人工标记答案有疑问' }); }],
|
||||
['malformed conflict evidence', value => { value.conflicts.push(null); }],
|
||||
['missing answer evidence', value => { delete value.conflicts[1].correctOptionIds; }],
|
||||
['missing old mismatch reason', value => { value.conflicts.shift(); }],
|
||||
['duplicate option ID', value => { value.options[3].id = 'b'; }],
|
||||
['duplicate correct ID', value => { value.correctOptionIds.push('b'); }],
|
||||
['unknown correct ID', value => { value.correctOptionIds.push('unknown'); }],
|
||||
['no duplicate correct option text', value => {
|
||||
value.correctOptionIds = ['a', 'b', 'c'];
|
||||
value.conflicts.forEach(snapshot => { snapshot.correctOptionIds = ['a', 'b', 'c']; });
|
||||
}],
|
||||
['non-multiple conflict', value => { value.type = 'single'; }]
|
||||
];
|
||||
for (const [name, mutate] of cases) await t.test(name, () => {
|
||||
const value = legacyRepeatedMultiple();
|
||||
mutate(value);
|
||||
const result = core.repairDuplicateOptionConflicts([value]);
|
||||
assert.equal(result.repaired.length, 0);
|
||||
assert.equal(result.records[0].answerStatus, 'conflict');
|
||||
});
|
||||
});
|
||||
|
||||
function imageUpgradeFixture() {
|
||||
const source = { kind: 'network', url: 'https://example.test/upm/start', observedAt: '2026-10-29T00:00:00Z', batchId: 'old-image-batch' };
|
||||
const existing = [0, 1].map(index => core.normalizeRecord(fixture({ questionId: `old-image-${index}`,
|
||||
stem: index ? '另一道无需图片的题目' : '根据溶解度曲线,应如何操作?', questionIndex: index, source })));
|
||||
const incoming = existing.map((record, index) => core.normalizeRecord({ ...record, questionId: `new-image-${index}`,
|
||||
stem: record.stem + (index ? '' : '[图片:https://example.test/curve.png]'),
|
||||
source: { ...source, observedAt: '2026-10-29T01:00:00Z', batchId: 'new-image-batch' },
|
||||
answerSource: { ...source, observedAt: '2026-10-29T01:00:00Z', batchId: 'new-image-batch' } }));
|
||||
return { existing, incoming, context: { bankId: 'bank-1', expectedCount: 2, fullSnapshot: true } };
|
||||
}
|
||||
|
||||
test('complete image snapshot upgrades only the unique old text record and retains its aliases', () => {
|
||||
const { existing, incoming, context } = imageUpgradeFixture();
|
||||
const before = JSON.stringify([existing, incoming]);
|
||||
const result = core.upgradeLegacyImageRecords(existing, incoming, context);
|
||||
assert.equal(result.upgraded.length, 1);
|
||||
assert.equal(result.upgraded[0].original, existing[0]);
|
||||
assert.equal(result.existing.length, 1);
|
||||
assert.equal(result.incoming.length, 2);
|
||||
assert.ok(result.incoming[0].stem.includes('[图片:https://example.test/curve.png]'));
|
||||
assert.deepEqual(result.incoming[0].questionIdAliases, ['old-image-0', 'new-image-0']);
|
||||
assert.equal(JSON.stringify([existing, incoming]), before, 'upgrade must not mutate original inputs');
|
||||
const merged = core.compactRecords(core.upsert(result.existing, result.incoming)).records;
|
||||
assert.equal(merged.length, 2, 'the image addition must not turn a two-question bank into three');
|
||||
assert.equal(core.upgradeLegacyImageRecords(merged, incoming, context).upgraded.length, 0);
|
||||
});
|
||||
|
||||
test('image completion also upgrades an unchanged question ID before normal merge can mark a conflict', () => {
|
||||
const { existing, incoming, context } = imageUpgradeFixture();
|
||||
incoming[0].questionId = existing[0].questionId;
|
||||
const result = core.upgradeLegacyImageRecords(existing, incoming, context);
|
||||
assert.equal(result.upgraded.length, 1);
|
||||
const merged = core.compactRecords(core.upsert(result.existing, result.incoming)).records;
|
||||
assert.equal(merged.length, 2);
|
||||
assert.ok(merged.every(record => record.answerStatus === 'confirmed'));
|
||||
});
|
||||
|
||||
test('image upgrade refuses partial, ambiguous or semantically changed evidence', async t => {
|
||||
const cases = [
|
||||
['not a full snapshot', value => { value.context.fullSnapshot = false; }],
|
||||
['page count mismatch', value => { value.context.expectedCount = 3; }],
|
||||
['wrong current bank', value => { value.context.bankId = 'other-bank'; }],
|
||||
['incomplete snapshot', value => { value.incoming.pop(); }],
|
||||
['missing position', value => { delete value.incoming[0].questionIndex; }],
|
||||
['duplicate snapshot position', value => { value.incoming[1].questionIndex = 0; }],
|
||||
['old position differs', value => { value.existing[0].questionIndex = 1; }],
|
||||
['old position missing', value => { delete value.existing[0].questionIndex; }],
|
||||
['old source is not a list response', value => { value.existing[0].source.url = 'https://example.test/answer'; }],
|
||||
['new observation is only DOM', value => { value.incoming[0].source.kind = 'dom'; }],
|
||||
['new snapshot has mixed batches', value => { value.incoming[1].source.batchId = 'another-batch'; }],
|
||||
['old record already has a different image', value => { value.existing[0].stem += '[图片:https://example.test/other-curve.png]'; }],
|
||||
['new text has changed', value => { value.incoming[0].stem = '根据溶解度曲线,不应如何操作?[图片:https://example.test/curve.png]'; }],
|
||||
['new options have changed', value => { value.incoming[0].options[0].text += '且提高压力'; }],
|
||||
['answer differs', value => { value.incoming[0].correctOptionIds = ['x']; value.incoming[0].answerTexts = ['降低温度']; }],
|
||||
['old answer is conflicted', value => { value.existing[0].answerStatus = 'conflict'; }],
|
||||
['two old candidates with the same complete text', value => { value.existing.push({ ...value.existing[0], questionId: 'another-old', questionIndex: 7 }); }],
|
||||
['new snapshot has two equal text candidates', value => { value.incoming[1] = { ...value.incoming[0], questionId: 'new-image-1', questionIndex: 1 }; }],
|
||||
['duplicate option content cannot be matched one-to-one', value => {
|
||||
value.existing[0].options[2].text = value.existing[0].options[0].text;
|
||||
value.incoming[0].options[2].text = value.incoming[0].options[0].text;
|
||||
}]
|
||||
];
|
||||
for (const [name, mutate] of cases) await t.test(name, () => {
|
||||
const value = imageUpgradeFixture(); mutate(value);
|
||||
const result = core.upgradeLegacyImageRecords(value.existing, value.incoming, value.context);
|
||||
assert.equal(result.upgraded.length, 0);
|
||||
assert.equal(result.existing.length, value.existing.length);
|
||||
assert.ok(result.incoming.every(record => !record.questionIdAliases?.includes('old-image-0')));
|
||||
});
|
||||
});
|
||||
|
||||
test('same stem but different option sets never auto-map', () => {
|
||||
const record = core.normalizeRecord(fixture());
|
||||
const current = reorder(record);
|
||||
|
||||
Reference in New Issue
Block a user