fix: require evidence for judgment answers and correct legacy banks

This commit is contained in:
suanyuit-cyber
2026-10-30 03:34:38 +08:00
parent 4bf2de1bad
commit 31a2640adb
21 changed files with 4875 additions and 11110 deletions
+193
View File
@@ -780,3 +780,196 @@ test('large repeated alias banks preserve every canonical record and complete CS
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]);
});