'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); });