Files
huayi-question-bank/tests/select-bank-snapshots.test.cjs

203 lines
12 KiB
JavaScript

'use strict';
const test = require('node:test');
const assert = require('node:assert/strict');
const fs = require('node:fs');
const os = require('node:os');
const path = require('node:path');
const core = require('../src/core.js');
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';
function record(bankName, index, time = latest, overrides = {}) {
const type = bankName.includes('判断') ? 'judge' : bankName.includes('多选') ? 'multiple' : 'single';
return core.normalizeRecord({ bankId: `bank-${EXPECTED_BANKS.indexOf(bankName)}`, bankName,
questionId: `${time}-${index}`, questionIndex: index, questionIdAliases: [`legacy-${index}`], contentOccurrence: index,
type, stem: '同文题也必须按题目位置保留。',
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 } });
test('latest explicit answer batch wins without collapsing repeated positions or aliases', () => {
const rows = [record(EXPECTED_BANKS[0], 0, older), record(EXPECTED_BANKS[0], 0), record(EXPECTED_BANKS[0], 1)];
rows[1].source = { kind: 'dom', observedAt: '2026-10-29T12:00:00.000Z' };
const selected = selectSnapshot(input(rows), 2);
assert.equal(selected.audit.selectedBatchId, `${latest}:1`);
assert.equal(selected.audit.excludedRecords, 1);
assert.deepEqual(selected.audit.excludedKeys, [rows[0].key]);
assert.deepEqual(selected.records.map(row => row.questionIndex), [0, 1]);
assert.deepEqual(selected.records[0].questionIdAliases, rows[1].questionIdAliases);
assert.equal(selected.records[0].stem, selected.records[1].stem);
});
test('an incomplete newest batch fails rather than selecting an older complete batch', () => {
const rows = [record(EXPECTED_BANKS[0], 0, older), record(EXPECTED_BANKS[0], 1, older), record(EXPECTED_BANKS[0], 0)];
assert.throws(() => selectSnapshot(input(rows), 2), /最新批次 1 条.*不会回退/s);
});
test('newest batch rejects duplicate indexes, IDs, wrong types and unconfirmed answers', () => {
const changes = [
{ questionIndex: 0 }, { questionId: `${latest}-0` }, { type: 'single' }, { answerStatus: 'conflict' },
{ questionId: '' }, { correctOptionIds: ['not-an-option'] }
];
for (const change of changes) {
const rows = [record(EXPECTED_BANKS[0], 0), record(EXPECTED_BANKS[0], 1, latest, change)];
assert.throws(() => selectSnapshot(input(rows), 2), /校验失败/);
}
const noBatch = record(EXPECTED_BANKS[0], 0);
noBatch.answerSource = noBatch.source = { kind: 'network', observedAt: latest };
assert.throws(() => selectSnapshot(input([noBatch]), 1), /没有带明确批次/);
const tie = record(EXPECTED_BANKS[0], 1);
tie.answerSource = tie.source = { kind: 'network', observedAt: latest, batchId: `${latest}:2` };
assert.throws(() => selectSnapshot(input([record(EXPECTED_BANKS[0], 0), tie]), 1), /无法唯一确定最新组/);
});
function fixtureDirectory(t) {
const root = fs.mkdtempSync(path.join(os.tmpdir(), 'qb-snapshot-test-'));
t.after(() => {
const resolved = path.resolve(root), temp = path.resolve(os.tmpdir());
assert.equal(path.dirname(resolved), temp);
assert.ok(path.basename(resolved).startsWith('qb-snapshot-test-'));
fs.rmSync(resolved, { recursive: true, force: true });
});
const manifestPath = path.join(root, 'counts.json');
fs.writeFileSync(manifestPath, JSON.stringify({ banks: EXPECTED_BANKS.map(bankName => ({ bankName, questionCount: 2 })) }));
const files = EXPECTED_BANKS.map((bankName, index) => {
const filename = path.join(root, `raw-${index}.csv`);
fs.writeFileSync(filename, core.toCSV([record(bankName, 0, older), record(bankName, 0), record(bankName, 1)]));
return filename;
});
return { files, manifestPath, outDir: path.join(root, 'output'), force: false };
}
test('all five are validated before any output and raw CSV bytes stay unchanged', t => {
const options = fixtureDirectory(t);
const before = options.files.map(filename => fs.readFileSync(filename));
const prepared = prepare(options);
assert.equal(prepared.audit.selectedRecords, 10);
assert.equal(prepared.audit.excludedRecords, 5);
assert.equal(prepared.files.length, 6);
const written = writeOutput(options, prepared);
assert.equal(written.length, 6);
assert.deepEqual(options.files.map(filename => fs.readFileSync(filename)), before);
for (const filename of written.filter(name => name.endsWith('.csv'))) assert.equal(core.fromCSV(fs.readFileSync(filename, 'utf8')).length, 2);
const audit = JSON.parse(fs.readFileSync(path.join(options.outDir, '导出批次核对.json'), 'utf8'));
assert.ok(audit.banks.every(bank => /^[a-f0-9]{64}$/.test(bank.rawSha256)));
});
test('failure in the fifth newest batch leaves no output directory', t => {
const options = fixtureDirectory(t), lastName = EXPECTED_BANKS.at(-1);
fs.writeFileSync(options.files.at(-1), core.toCSV([record(lastName, 0, older), record(lastName, 1, older), record(lastName, 0)]));
assert.throws(() => prepare(options), /不会回退较旧批次/);
assert.equal(fs.existsSync(options.outDir), false);
});
test('destination conflicts are checked before writing the first bank', t => {
const options = fixtureDirectory(t), prepared = prepare(options);
fs.mkdirSync(options.outDir);
fs.writeFileSync(path.join(options.outDir, '导出批次核对.json'), 'existing audit');
assert.throws(() => writeOutput(options, prepared), /输出已存在/);
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);
});