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
+102 -3
View File
@@ -5,7 +5,8 @@ const fs = require('node:fs');
const os = require('node:os');
const path = require('node:path');
const core = require('../src/core.js');
const { EXPECTED_BANKS } = require('../scripts/merge-question-banks.cjs');
const merge = require('../scripts/merge-question-banks.cjs');
const { EXPECTED_BANKS } = merge;
const { selectSnapshot, prepare, writeOutput } = require('../scripts/select-bank-snapshots.cjs');
const older = '2026-10-29T10:00:00.000Z', latest = '2026-10-29T11:00:00.000Z';
@@ -14,8 +15,10 @@ function record(bankName, index, time = latest, overrides = {}) {
return core.normalizeRecord({ bankId: `bank-${EXPECTED_BANKS.indexOf(bankName)}`, bankName,
questionId: `${time}-${index}`, questionIndex: index, questionIdAliases: [`legacy-${index}`], contentOccurrence: index,
type, stem: '同文题也必须按题目位置保留。',
options: [{ id: 'a', text: '选项甲', label: 'A' }, { id: 'b', text: '选项乙', label: 'B' }],
correctOptionIds: ['b'], answerStatus: 'confirmed', source: { kind: 'network', batchId: `${time}:1`, observedAt: time }, ...overrides });
options: type === 'judge' ? [{ id: '1', text: '正确', label: '正确' }, { id: '0', text: '错误', label: '错误' }]
: [{ id: 'a', text: '选项甲', label: 'A' }, { id: 'b', text: '选项乙', label: 'B' }],
correctOptionIds: [type === 'judge' ? '0' : 'b'], answerStatus: 'confirmed', answerEvidence: type === 'judge' ? 'submitted-list' : 'explicit-standard',
source: { kind: 'network', url: 'https://example.test/upm/start', batchId: `${time}:1`, observedAt: time }, ...overrides });
}
const input = records => ({ records, source: { filename: 'raw.csv', sha256: 'raw-hash', inputRecords: records.length } });
@@ -101,3 +104,99 @@ test('destination conflicts are checked before writing the first bank', t => {
assert.equal(fs.existsSync(path.join(options.outDir, '分题库')), false);
assert.equal(writeOutput({ ...options, force: true }, prepared).length, 6);
});
function legacyJudge(value, index = 0, time = latest) {
const row = record(EXPECTED_BANKS[0], index, time, { correctOptionIds: [String(value)], answerTexts: [value ? '正确' : '错误'] });
delete row.answerEvidence;
return row;
}
test('raw practice-list judge 0 and 1 cannot satisfy latest complete standard-answer batch', () => {
for (const value of [0, 1]) {
const row = legacyJudge(value);
assert.equal(row.answerStatus, 'confirmed', 'fixture represents the old unsafe export');
assert.throws(() => selectSnapshot(input([row]), 1), /缺失或冲突的标准答案.*不会回退/s);
}
const olderTrusted = record(EXPECTED_BANKS[0], 0, older);
assert.throws(() => selectSnapshot(input([olderTrusted, legacyJudge(0)]), 1), /缺失或冲突的标准答案.*不会回退/s);
});
test('clearing a default answerSource never hides the newest batch behind an older trusted one', t => {
const options = fixtureDirectory(t), row = legacyJudge(0), olderTrusted = record(EXPECTED_BANKS[0], 0, older);
row.source = { kind: 'dom', observedAt: '2026-10-29T12:00:00.000Z' };
assert.throws(() => selectSnapshot(input([olderTrusted, row]), 1), /缺失或冲突的标准答案.*不会回退/s);
for (const extension of ['csv', 'json']) {
const filename = path.join(path.dirname(options.manifestPath), `latest-dom.${extension}`);
fs.writeFileSync(filename, extension === 'csv' ? core.toCSV([olderTrusted, row]) : JSON.stringify([olderTrusted, row]));
const loaded = merge.readInput(filename);
assert.equal(loaded.records[1].answerStatus, 'missing');
assert.equal(loaded.records[1].answerSource, undefined);
assert.equal(loaded.answerBatchSources[1].batchId, `${latest}:1`);
assert.throws(() => selectSnapshot(loaded, 1), /缺失或冲突的标准答案.*不会回退/s);
}
});
test('CSV and raw JSON loaders downgrade unproven legacy judge answers without touching inputs', t => {
const options = fixtureDirectory(t), root = path.dirname(options.manifestPath);
for (const value of [0, 1]) for (const extension of ['csv', 'json']) {
const row = legacyJudge(value), filename = path.join(root, `legacy-${value}.${extension}`);
if (extension === 'json') delete row.answerStatus;
fs.writeFileSync(filename, extension === 'csv' ? core.toCSV([row]) : JSON.stringify({ schemaVersion: 1, records: [row] }));
const bytes = fs.readFileSync(filename), loaded = merge.readInput(filename);
assert.deepEqual(fs.readFileSync(filename), bytes, 'source file must remain byte-identical');
assert.equal(loaded.source.legacyJudgeAnswersRepaired, 1);
assert.equal(loaded.records.length, 1);
assert.equal(loaded.records[0].answerStatus, 'missing');
assert.deepEqual(loaded.records[0].correctOptionIds, []);
assert.deepEqual(loaded.records[0].answerTexts, []);
assert.equal(loaded.records[0].stem, row.stem);
assert.deepEqual(loaded.records[0].options, row.options);
}
});
test('verified submitted-list, answer-response and explicit standard provenance survives file loading', t => {
const options = fixtureDirectory(t), filename = path.join(path.dirname(options.manifestPath), 'proven.json');
const submitted = record(EXPECTED_BANKS[0], 0);
const response = legacyJudge(1, 1);
response.answerSource = { kind: 'network', url: 'https://example.test/upm/pra-1/users/user-1/answer', observedAt: latest, batchId: `${latest}:1` };
const explicit = record(EXPECTED_BANKS[0], 2, latest, { answerEvidence: 'explicit-standard' });
fs.writeFileSync(filename, JSON.stringify([submitted, response, explicit]));
const loaded = merge.readInput(filename);
assert.ok(loaded.records.every(record => record.answerStatus === 'confirmed'));
assert.deepEqual(loaded.records.map(record => record.answerTexts[0]), ['错误', '正确', '错误']);
});
test('strict merge rejects legacy defaults while partial mode separates complete questions from missing answers', t => {
const options = fixtureDirectory(t);
for (let index = 0; index < options.files.length; index++) {
const name = EXPECTED_BANKS[index];
fs.writeFileSync(options.files[index], core.toCSV(name.includes('判断')
? [legacyJudge(0, 0), legacyJudge(1, 1)].map(row => ({ ...row, bankId: `bank-${index}`, bankName: name }))
: [record(name, 0), record(name, 1)]));
}
const bytes = options.files.map(filename => fs.readFileSync(filename));
assert.throws(() => merge.prepare(options), /4 道题没有已确认标准答案/);
assert.equal(fs.existsSync(options.outDir), false);
const partial = merge.prepare({ ...options, allowPartial: true });
assert.equal(partial.summary.questionsComplete, true);
assert.equal(partial.summary.answersComplete, false);
assert.equal(partial.summary.complete, false);
assert.equal(partial.summary.totalQuestions, 10);
assert.equal(partial.summary.confirmedAnswers, 6);
assert.equal(partial.summary.missingAnswers, 4);
assert.equal(partial.summary.legacyJudgeAnswersRepaired, 4);
assert.equal(partial.summary.validation, 'partial');
const reimported = core.fromCSV(partial.files.find(file => file.name.endsWith('.csv')).content);
assert.equal(reimported.length, 10);
assert.ok(reimported.filter(row => row.type === 'judge').every(row => row.answerStatus === 'missing' && !row.answerTexts.length && !row.correctOptionIds.length));
assert.deepEqual(options.files.map(filename => fs.readFileSync(filename)), bytes);
});
test('complete proven five-bank merge reports both questions and answers complete', t => {
const options = fixtureDirectory(t);
const prepared = merge.prepare(options);
assert.equal(prepared.summary.questionsComplete, true);
assert.equal(prepared.summary.answersComplete, true);
assert.equal(prepared.summary.complete, true);
assert.equal(prepared.summary.legacyJudgeAnswersRepaired, 0);
});