feat: publish verified five-bank question dataset

This commit is contained in:
suanyuit-cyber
2026-10-29 23:09:32 +08:00
parent c3edf3975e
commit 26e0a920b6
26 changed files with 109610 additions and 1560 deletions
+55 -4
View File
@@ -42,9 +42,12 @@ test('multiple answer plan accepts every explicitly correct occurrence of repeat
assert.deepEqual(planAnswer([subset], question, core).labels, ['A', 'D']);
});
test('repeated text still skips partial answers, text-only answers, ID contradictions and duplicate labels', () => {
test('repeated text skips partial answers without exact identities, text-only answers, ID contradictions and duplicate labels', () => {
const question = repeatedMultiple({ answerStatus: 'missing', correctOptionIds: [], answerTexts: [] });
assert.equal(planAnswer([repeatedMultiple({ correctOptionIds: ['b'], answerTexts: ['下降'] })], question, core).status, 'ambiguous');
const partial = repeatedMultiple({ correctOptionIds: ['b'], answerTexts: ['下降'] });
assert.equal(planAnswer([partial], question, core).status, 'ready');
const fresh = { ...question, options: question.options.map(option => ({ ...option, id: `new-${option.id}` })) };
assert.equal(planAnswer([partial], fresh, core).status, 'ambiguous');
assert.equal(planAnswer([repeatedMultiple({ correctOptionIds: [], answerTexts: ['下降'] })], question, core).status, 'ambiguous');
const changed = repeatedMultiple();
[changed.options[0].id, changed.options[1].id] = [changed.options[1].id, changed.options[0].id];
@@ -52,6 +55,36 @@ test('repeated text still skips partial answers, text-only answers, ID contradic
question.options[3].label = 'B';
assert.equal(planAnswer([repeatedMultiple()], question, core).status, 'ambiguous');
});
function repeatedSingle(extra = {}) {
return single({ options: [{ id: 'a', text: '其他产物甲', label: 'A' }, { id: 'b', text: '其他产物乙', label: 'B' },
{ id: 'c', text: '生成Cu和Fe2+', label: 'C' }, { id: 'd', text: '生成Cu和Fe2+', label: 'D' }],
correctOptionIds: ['c'], answerTexts: ['生成Cu和Fe2+'], ...extra });
}
test('repeated single-choice text maps by complete exact option IDs after labels reorder', () => {
const record = repeatedSingle();
const question = repeatedSingle({ answerStatus: 'missing', correctOptionIds: [], answerTexts: [] });
assert.deepEqual(planAnswer([record], question, core).labels, ['C']);
question.options = [question.options[3], question.options[2], question.options[0], question.options[1]]
.map((option, index) => ({ ...option, label: 'ABCD'[index] }));
const plan = planAnswer([record], question, core);
assert.equal(plan.status, 'ready');
assert.deepEqual(plan.labels, ['B']);
assert.deepEqual(plan.answerTexts, ['生成Cu和Fe2+']);
});
test('repeated single-choice text remains ambiguous when full ID identity cannot be proved', () => {
const record = repeatedSingle();
const question = repeatedSingle({ answerStatus: 'missing', correctOptionIds: [], answerTexts: [] });
const fresh = { ...question, options: question.options.map(option => ({ ...option, id: `new-${option.id}` })) };
assert.equal(planAnswer([record], fresh, core).status, 'ambiguous');
const missingID = { ...question, options: question.options.map(option => ({ ...option, id: option.id === 'a' ? '' : option.id })) };
assert.equal(planAnswer([record], missingID, core).status, 'ambiguous');
const changed = { ...question, options: question.options.map(option => ({ ...option, id: option.id === 'a' ? 'c' : option.id === 'c' ? 'a' : option.id })) };
assert.equal(planAnswer([record], changed, core).status, 'ambiguous');
assert.equal(planAnswer([repeatedSingle({ correctOptionIds: [] })], question, core).status, 'ambiguous');
});
test('judgement uses explicit correct and incorrect content, including zero ID', () => {
const record = { bankId: 'j', type: 'judge', stem: '这项说法正确吗?', options: [{ id: '1', text: '正确' }, { id: '0', text: '错误' }], correctOptionIds: ['0'], answerStatus: 'confirmed' };
const question = { ...record, options: [{ id: '0', text: '错误', label: '错误' }, { id: '1', text: '正确', label: '正确' }] };
@@ -79,7 +112,8 @@ test('without bank identity, contradictory cross-bank matches are ambiguous', ()
});
test('different complete option sets, duplicate option text and wrong option IDs are refused', () => {
assert.equal(planAnswer([single()], current({ options: [{ id: 'a', text: '降低温度' }, { id: 'b', text: '提高温度' }] }), core).status, 'missing');
assert.equal(planAnswer([single()], current({ options: [{ text: '提高温度' }, { text: '提高温度' }] }), core).status, 'ambiguous');
const duplicate = single({ options: [{ id: 'a', text: '提高温度' }, { id: 'b', text: '提高温度' }] });
assert.equal(planAnswer([duplicate], current({ options: [{ text: '提高温度' }, { text: '提高温度' }] }), core).status, 'ambiguous');
assert.equal(planAnswer([single()], current({ options: [{ id: 'b', text: '保持不变', label: 'A' }, { id: 'a', text: '降低温度', label: 'B' }, { id: 'c', text: '提高温度', label: 'C' }] }), core).status, 'ambiguous');
});
test('normalization preserves negatives, signs, units and superscripts', () => {
@@ -141,13 +175,30 @@ test('automatic selection chooses all correct repeated checkboxes and clears an
test('automatic selection never clicks an ambiguous repeated-text subset', async () => {
const page = checkboxPage({ texts: ['下降', '增加', '下降'] });
page.record.correctOptionIds = ['0']; page.record.answerTexts = ['下降'];
page.record.options.forEach(option => { option.id = `saved-${option.id}`; });
page.record.correctOptionIds = ['saved-0']; page.record.answerTexts = ['下降'];
const engine = create({ window: page.win, document: page.doc, core, getRecords: () => [page.record] });
const result = await engine.answerCurrent();
assert.equal(result.status, 'skipped'); assert.deepEqual(page.clicks, []);
engine.destroy();
});
test('single-choice clicks the correct repeated option only with complete exact IDs', async () => {
for (const freshIDs of [false, true]) {
const page = checkboxPage({ texts: ['其他产物甲', '其他产物乙', '生成Cu和Fe2+', '生成Cu和Fe2+'] });
page.inputs.forEach(input => { input.type = 'radio'; });
page.record.type = 'single';
if (freshIDs) page.record.options.forEach(option => { option.id = `saved-${option.id}`; });
page.record.correctOptionIds = [freshIDs ? 'saved-2' : '2']; page.record.answerTexts = ['生成Cu和Fe2+'];
const engine = create({ window: page.win, document: page.doc, core, getRecords: () => [page.record] });
const result = await engine.answerCurrent();
assert.equal(result.status, freshIDs ? 'skipped' : 'answered');
assert.deepEqual(page.clicks, freshIDs ? [] : [2]);
assert.deepEqual(page.inputs.map(input => input.checked), freshIDs ? [false, false, false, false] : [false, false, true, false]);
engine.destroy();
}
});
test('automatic selection reads image stems and choices consistently with network records', async () => {
const { extractQuestions } = require('../src/adapter.js');
for (const richLabels of [false, true]) {
+77 -4
View File
@@ -34,9 +34,18 @@ test('same ID with different content is not treated as the answer', () => {
assert.equal(core.mapAnswers(record, current).status, 'unmapped');
});
test('duplicate option content is ambiguous even when ID exists', () => {
test('duplicate option content maps only with complete unchanged option ID identities', () => {
const record = core.normalizeRecord(fixture({ options: [{ id: 'x', text: '提高温度', label: 'A' }, { id: 'y', text: '提高温度', label: 'B' }] }));
assert.equal(core.mapAnswers(record, record).status, 'ambiguous');
assert.deepEqual(core.mapAnswers(record, record), { status: 'matched', labels: ['B'], answerTexts: ['提高温度'] });
const reordered = { ...record, options: [...record.options].reverse().map((option, index) => ({ ...option, label: 'AB'[index] })) };
assert.deepEqual(core.mapAnswers(record, reordered).labels, ['A']);
const newIds = { ...reordered, options: reordered.options.map((option, index) => ({ ...option, id: `new-${index}` })) };
assert.equal(core.mapAnswers(record, newIds).status, 'ambiguous');
const oneChangedId = { ...reordered, options: reordered.options.map((option, index) => index ? { ...option, id: 'new-id' } : option) };
assert.equal(core.mapAnswers(record, oneChangedId).status, 'ambiguous');
assert.equal(core.mapAnswers({ ...record, correctOptionIds: [] }, record).status, 'ambiguous');
assert.equal(core.mapAnswers(record, { ...record, options: record.options.map(option => ({ ...option, id: '' })) }).status, 'ambiguous');
assert.ok(core.toCSV([record]).includes('"B"'), 'self export should preserve the observed correct letter');
});
const repeatedMultiple = (overrides = {}) => fixture({
@@ -73,10 +82,12 @@ test('all correct occurrences of repeated multiple-choice text map after IDs and
assert.deepEqual(core.mapAnswers(subset, current).labels, ['A', 'D']);
});
test('partly correct or text-only repeated options remain ambiguous', () => {
test('partly correct repeated options require complete IDs; text-only repeated options remain ambiguous', () => {
const partial = core.normalizeRecord(repeatedMultiple({ correctOptionIds: ['b'], answerTexts: ['下降'] }));
assert.equal(partial.answerStatus, 'confirmed');
assert.equal(core.mapAnswers(partial, partial).status, 'ambiguous');
assert.deepEqual(core.mapAnswers(partial, partial).labels, ['B']);
const changedIds = { ...partial, options: partial.options.map((option, index) => ({ ...option, id: `new-${index}` })) };
assert.equal(core.mapAnswers(partial, changedIds).status, 'ambiguous');
const textOnly = core.normalizeRecord(repeatedMultiple({ correctOptionIds: [], answerTexts: ['下降'] }));
assert.equal(core.mapAnswers(textOnly, textOnly).status, 'ambiguous');
});
@@ -240,6 +251,68 @@ test('image upgrade refuses partial, ambiguous or semantically changed evidence'
});
});
function mathEntityFixture() {
const value = imageUpgradeFixture();
value.existing[0].stem = '传热速率=系数×推动力,允许偏差±2。';
value.incoming[0].stem = '传热速率=系数×推动力,允许偏差±2。';
return value;
}
test('complete source snapshot repairs only the evidenced times and plusmn spellings', () => {
const { existing, incoming, context } = mathEntityFixture();
const original = JSON.stringify([existing, incoming]);
assert.equal(core.mapAnswers(existing[0], incoming[0]).status, 'unmapped', 'ordinary comparisons must not decode literal entities');
const result = core.upgradeLegacyMathEntities(existing, incoming, context);
assert.equal(result.upgraded.length, 1);
assert.equal(result.upgraded[0].kind, 'math-entity');
assert.equal(result.existing.length, 1);
assert.ok(result.incoming[0].questionIdAliases.includes(existing[0].questionId));
assert.equal(JSON.stringify([existing, incoming]), original);
assert.equal(core.compactRecords(core.upsert(result.existing, result.incoming)).records.length, 2);
const alreadyDuplicated = core.upgradeLegacyMathEntities([...existing, incoming[0]], incoming, context);
assert.equal(alreadyDuplicated.upgraded.length, 1, 'a previous failed migration containing both representations must recover');
assert.equal(core.compactRecords(core.upsert(alreadyDuplicated.existing, alreadyDuplicated.incoming)).records.length, 2);
const sameId = mathEntityFixture(); sameId.incoming[0].questionId = sameId.existing[0].questionId;
assert.equal(core.upgradeLegacyMathEntities(sameId.existing, sameId.incoming, context).upgraded.length, 1);
const options = imageUpgradeFixture();
options.incoming[0].stem = options.existing[0].stem;
options.existing[0].options[0].text = '12+1×2×2';
options.incoming[0].options[0].text = '12+1×2×2';
assert.equal(core.upgradeLegacyMathEntities(options.existing, options.incoming, context).upgraded.length, 1, 'non-correct option text can be completed without changing the standard answer');
});
test('math entity completion retains literal entities, missing positions and real differences', async t => {
const cases = [
['incomplete snapshot', value => { value.incoming.pop(); }],
['unverified page count', value => { value.context.expectedCount = 3; }],
['not a full snapshot', value => { value.context.fullSnapshot = false; }],
['wrong bank', value => { value.context.bankId = 'other-bank'; }],
['old position missing', value => { delete value.existing[0].questionIndex; }],
['different position', value => { value.existing[0].questionIndex = 1; }],
['new positions overlap', value => { value.incoming[1].questionIndex = 0; }],
['different option ID identity', value => { value.incoming[0].options[0].id = 'replacement-id'; }],
['different standard answer', value => { value.incoming[0].correctOptionIds = ['x']; value.incoming[0].answerTexts = ['降低温度']; }],
['unknown entity outside the whitelist', value => { value.existing[0].stem = '系数÷推动力'; value.incoming[0].stem = '系数÷推动力'; }],
['literal entity remains in the new question', value => { value.incoming[0].stem = value.existing[0].stem; }],
['part of the entity text intentionally remains literal', value => { value.incoming[0].stem = '传热速率=系数×推动力,允许偏差±2。'; }],
['different operator', value => { value.incoming[0].stem = '传热速率=系数x推动力,允许偏差±2。'; }],
['different image content', value => { value.existing[0].stem += '[图片:https://example.test/a.png]'; value.incoming[0].stem += '[图片:https://example.test/b.png]'; }],
['same legacy spelling occurs twice', value => { value.existing.push({ ...value.existing[0], questionId: 'second-literal', questionIndex: 4 }); }],
['separate modern question at a different position', value => { value.existing.push({ ...value.incoming[0], questionIndex: 4 }); }],
['a true second question intentionally uses literal spelling', value => { value.incoming[1] = { ...value.existing[0], questionId: 'literal-second', questionIndex: 1, source: value.incoming[1].source }; }],
['old source is not a network list', value => { value.existing[0].source.kind = 'dom'; }],
['mixed incoming batches', value => { value.incoming[1].source.batchId = 'different'; }],
['same batch is not evidence of an upgrade', value => { value.existing[0].source.batchId = value.incoming[0].source.batchId; }]
];
for (const [name, mutate] of cases) await t.test(name, () => {
const value = mathEntityFixture(); mutate(value);
const result = core.upgradeLegacyMathEntities(value.existing, value.incoming, value.context);
assert.equal(result.upgraded.length, 0);
assert.equal(result.existing.length, value.existing.length);
});
});
test('same stem but different option sets never auto-map', () => {
const record = core.normalizeRecord(fixture());
const current = reorder(record);
+27
View File
@@ -133,6 +133,33 @@ test('历史误报和图片补全:原始备份、设置导出、旧标签写
assert.equal(rawBackup.migrations.length, 2, '内存和GM中的两个原始元数据版本均有明确退休证据');
await context.close(); context = null;
const oldMath = { ...oldImage, questionId: 'old-math', stem: '传热速率=系数×推动力,误差±2。' };
const newMath = core.normalizeRecord({ ...snapshot[0], questionId: 'new-math', stem: '传热速率=系数×推动力,误差±2。' });
const existingModern = core.normalizeRecord({ ...newMath, questionId: 'existing-modern-math', questionIdAliases: ['older-modern-math'],
source: { ...newSource, batchId: 'earlier-modern-batch' } });
context = await createContext([oldMath, existingModern, oldOther]);
const mathPage = await context.newPage();
await mathPage.goto('https://www.shhuayi-elearning.cn/repair-fixture');
await mathPage.locator('#huayi-qb-root').waitFor({ state: 'attached' });
await mathPage.evaluate(({ snapshot, bankId }) => window.__qaReceive(snapshot, { fullSnapshot: true, pageQuestionCount: 2, currentBankId: bankId }),
{ snapshot: [newMath, snapshot[1]], bankId: bank.bankId });
await waitFor(async () => (await storageRecords(mathPage)).length === 2, '实体补全应修复已经同时保存新旧两种文字的题库');
const mathRecords = await storageRecords(mathPage);
const canonicalMath = mathRecords.find(record => record.stem === newMath.stem);
assert.equal(canonicalMath.questionId, existingModern.questionId, '合并可保留已有别名记录的canonical ID');
assert.ok(canonicalMath.questionIdAliases.includes(newMath.questionId));
assert.ok(canonicalMath.questionIdAliases.includes(oldMath.questionId));
const mathBackup = await mathPage.evaluate(key => GM_getValue(key, null), IMAGE_UPGRADE_BACKUP_KEY);
assert.ok(mathBackup.records.some(record => record.questionId === oldMath.questionId && record.stem === oldMath.stem));
assert.ok(mathBackup.migrations.every(item => item.kind === 'math-entity'));
await mathPage.evaluate(({ stale, key }) => { GM_setValue(key, stale); window.__qaRemoteChange(key, [], stale, true); },
{ stale: [...mathRecords, oldMath], key: RECORDS_KEY });
await waitFor(async () => (await storageRecords(mathPage)).length === 2, '别名合并改变replacement key后,旧实体版本仍不得复活');
await mathPage.reload();
await mathPage.locator('#huayi-qb-root').waitFor({ state: 'attached' });
assert.equal((await storageRecords(mathPage)).length, 2);
await context.close(); context = null;
context = await createContext([legacy], true);
const blocked = await context.newPage();
await blocked.goto('https://www.shhuayi-elearning.cn/repair-fixture');
+103
View File
@@ -0,0 +1,103 @@
'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);
});