104 lines
5.9 KiB
JavaScript
104 lines
5.9 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 { EXPECTED_BANKS } = require('../scripts/merge-question-banks.cjs');
|
|
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: [{ id: 'a', text: '选项甲', label: 'A' }, { id: 'b', text: '选项乙', label: 'B' }],
|
|
correctOptionIds: ['b'], answerStatus: 'confirmed', source: { kind: 'network', 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);
|
|
});
|