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
+138 -1
View File
@@ -64,7 +64,7 @@ test('selected, submitContent, userAnswer and top-level answer/answers are not s
test('judge zero and string zero mean incorrect, never truthy correct', () => {
for (const judgeAnswer of [0, '0', 1, '1', null, '-1']) {
const [q] = extractQuestions({ listQues: [{ id: 'j1', quesType: 2, content: '压力越高越安全。', judgeAnswer }] });
const [q] = extractQuestions({ questionList: [{ id: 'j1', quesType: 2, content: '压力越高越安全。', judgeAnswer }] });
const valid = judgeAnswer === 0 || judgeAnswer === '0' || judgeAnswer === 1 || judgeAnswer === '1';
assert.equal(q.answerStatus, valid ? 'confirmed' : 'missing');
assert.deepEqual(q.answerTexts, valid ? [String(judgeAnswer) === '1' ? '正确' : '错误'] : []);
@@ -738,3 +738,140 @@ test('observer ignores unrelated and selection mutations and batches relevant wo
assert.ok(reads > baseline, '合批后仍检查真实题目更新');
page.adapter.stop();
});
test('unsubmitted listQues judgment placeholders 0 and 1 remain missing without evidence', () => {
for (const judgeAnswer of [0, '0', 1, '1']) {
for (const submitQuesAnswers of [undefined, []]) {
const [record] = extractQuestions({ listQues: [{ id: 'j-unanswered', quesType: 2,
content: '未作答题的占位字段不是标准答案', judgeAnswer, submitQuesAnswers, isAnswered: 1 }] });
assert.equal(record.answerStatus, 'missing');
assert.deepEqual(record.answerTexts, []);
assert.deepEqual(record.correctOptionIds, []);
assert.equal(record.answerEvidence, undefined);
}
}
});
test('submitted practice judgments use judgeAnswer rather than the student submitted choice', () => {
for (const [judgeAnswer, submitted, expected] of [[0, '1', '错误'], [1, '0', '正确'], ['0', '1', '错误'], ['1', '0', '正确']]) {
const [record] = extractQuestions({ listQues: [{ id: 'j-answered', quesType: 2,
content: '已提交后可用标准反馈', judgeAnswer, submitQuesAnswers: [submitted] }] });
assert.equal(record.answerStatus, 'confirmed');
assert.deepEqual(record.answerTexts, [expected]);
assert.equal(record.answerEvidence, 'submitted-list');
}
});
test('an explicit answered resultInfo can confirm a practice judgment independently of placeholders', () => {
const [record] = extractQuestions({ listQues: [{ id: 'j-result', quesType: 2, content: '明确答后结果',
judgeAnswer: 0, submitQuesAnswers: [], resultInfo: { judgeAnswer: 1 } }] });
assert.deepEqual(record.answerTexts, ['正确']);
assert.equal(record.answerEvidence, 'submitted-list');
const [empty] = extractQuestions({ listQues: [{ id: 'j-empty', quesType: 2, content: '空结果对象也不是标准答案',
judgeAnswer: 0, resultInfo: {} }] });
assert.equal(empty.answerStatus, 'missing');
assert.equal(empty.answerEvidence, undefined);
});
test('explicit questionList standards retain 0/1 semantics while parent hidden flags win', () => {
const raw = { id: 'j-standard', quesType: 2, content: '显式题库标准答案', judgeAnswer: 0 };
const [standard] = extractQuestions({ questionList: [raw] }, { url: 'https://api.yunxuetang.cn/ote/pra/p1/questions' });
assert.deepEqual(standard.answerTexts, ['错误']);
assert.equal(standard.answerEvidence, 'explicit-standard');
for (const parent of [{ allowViewSingleQuesAnswer: false }, { info: { allowViewSingleQuesAnswer: 0 } }]) {
const [hidden] = extractQuestions({ ...parent, data: { questionList: [{ ...raw, allowViewSingleQuesAnswer: true }] } });
assert.equal(hidden.answerStatus, 'missing');
assert.equal(hidden.answerEvidence, undefined);
}
});
test('a direct upm start payload cannot bypass the practice-list evidence gate', async () => {
const page = fakePage(async () => new Response(JSON.stringify({ id: 'j-direct', quesType: 2,
content: '起始接口直接对象仍需已答证据', judgeAnswer: 1 }), { headers: { 'content-type': 'application/json' } }));
page.adapter.start();
await page.win.fetch('/upm/wrong/start?praId=p1'); await flush(); await flush();
assert.equal(page.received.at(-1).answerStatus, 'missing');
assert.equal(page.received.at(-1).answerEvidence, undefined);
page.adapter.stop();
});
test('same-question judgment answer receipts confirm standards and anonymize user URL segments', () => {
const page = fakePage();
page.adapter.start();
const initial = new page.win.XMLHttpRequest();
initial.open('GET', '/upm/start?praId=p1'); initial.send();
initial.respond({ listQues: [{ id: 'j1', quesType: 2, content: '断续噪声的判断示例', judgeAnswer: 0, submitQuesAnswers: [] }] });
assert.equal(page.received.at(-1).answerStatus, 'missing');
const answer = new page.win.XMLHttpRequest();
answer.open('PUT', '/upm/p1/users/private-user/wrong/answer');
answer.send(JSON.stringify({ answers: [{ quesId: 'j1', answer: ['0'] }] }));
answer.respond([{ id: 'receipt-id', judgeAnswer: 1, corrected: false }]);
const record = page.received.at(-1);
assert.equal(record.questionId, 'j1');
assert.deepEqual(record.answerTexts, ['正确']);
assert.equal(record.answerEvidence, 'answer-response');
assert.equal(record.source.url, 'https://www.shhuayi-elearning.cn/upm/p1/users/_/wrong/answer');
page.adapter.stop();
});
test('DOM standard feedback carries bound or known trusted answer evidence', async () => {
const raw = single();
raw.choiceItems.forEach(option => delete option.answer);
const page = bankPage(async () => new Response(JSON.stringify({ listQues: [raw] }), { headers: { 'content-type': 'application/json' } }));
page.adapter.start();
await page.win.fetch('/upm/start?praId=bank-a'); await flush(); await flush();
page.state.feedback = [{ heading: '正确答案', answer: 'B' }];
assert.equal(page.adapter.getCurrentQuestion().answerStatus, 'missing', '未验证网络占位题不能替裸DOM反馈提供证据');
page.state.feedback = [{ heading: '正确答案', answer: 'B', questionId: 'q1' }];
assert.equal(page.adapter.getCurrentQuestion().answerEvidence, 'bound-dom');
const answer = new page.win.XMLHttpRequest();
answer.open('PUT', '/upm/bank-a/users/u1/answer');
answer.send(JSON.stringify({ answers: [{ quesId: 'q1', answer: ['a1'] }] }));
answer.respond([{ answers: ['a2'] }]);
page.state.feedback = [{ heading: '正确答案', answer: 'B' }];
assert.equal(page.adapter.getCurrentQuestion().answerEvidence, 'answer-response', '与可信同题反馈一致时保留原始证据');
page.adapter.stop();
});
test('an older answer receipt cannot attach its answer to a newer same-id full snapshot', () => {
const page = fakePage();
page.adapter.start();
const load = content => {
const request = new page.win.XMLHttpRequest();
request.open('GET', '/upm/start?praId=p1'); request.send();
request.respond({ listQues: [{ id: 'same-id', quesType: 2, content, judgeAnswer: 0, submitQuesAnswers: [] }] });
};
load('旧版本题干');
const answer = new page.win.XMLHttpRequest();
answer.open('PUT', '/upm/p1/users/u1/answer');
answer.send(JSON.stringify({ answers: [{ quesId: 'same-id', answer: ['0'] }] }));
load('重新加载后的新版本题干');
const count = page.received.length;
answer.respond([{ judgeAnswer: 1 }]);
assert.equal(page.received.length, count, '旧回执不能用较新缓存题干拼接成已确认答案');
assert.equal(page.received.at(-1).stem, '重新加载后的新版本题干');
assert.equal(page.received.at(-1).answerStatus, 'missing');
page.adapter.stop();
});
test('a newer snapshot of a different bank does not discard an independent answer receipt', () => {
const page = fakePage();
page.adapter.start();
const first = new page.win.XMLHttpRequest();
first.open('GET', '/upm/start?praId=p1'); first.send();
first.respond({ listQues: [{ id: 'q1', quesType: 2, content: '第一题库题目', judgeAnswer: 0, submitQuesAnswers: [] }] });
const answer = new page.win.XMLHttpRequest();
answer.open('PUT', '/upm/p1/users/u1/answer');
answer.send(JSON.stringify({ answers: [{ quesId: 'q1', answer: ['0'] }] }));
page.doc.title = '第二题库';
const second = new page.win.XMLHttpRequest();
second.open('GET', '/upm/start?praId=p2'); second.send();
second.respond({ listQues: [{ id: 'q1', quesType: 2, content: '第二题库题目', judgeAnswer: 0, submitQuesAnswers: [] }] });
answer.respond([{ judgeAnswer: 1 }]);
const received = page.received.at(-1);
assert.equal(received.bankId, 'p1');
assert.equal(received.stem, '第一题库题目');
assert.equal(received.answerEvidence, 'answer-response');
assert.deepEqual(received.answerTexts, ['正确']);
page.adapter.stop();
});
+193
View File
@@ -780,3 +780,196 @@ test('large repeated alias banks preserve every canonical record and complete CS
assert.equal(compacted.removed, 0);
assert.equal(JSON.stringify(compacted.records), JSON.stringify(records));
});
const legacyJudge = (overrides = {}) => fixture({
type: 'judge', stem: '断续噪声对人体危害更大。',
options: [{ id: '1', text: '正确', label: '正确' }, { id: '0', text: '错误', label: '错误' }],
correctOptionIds: ['0'], answerTexts: ['错误'], questionIndex: 66, contentOccurrence: 0,
questionIdAliases: ['old-question'],
source: { kind: 'network', url: 'https://api.example.test/ote/upm/start', observedAt: '2026-10-29T00:00:00Z', batchId: 'old-list' },
...overrides,
});
const officialJudgeSource = (suffix = 'answer') => ({ kind: 'network',
url: `https://api.example.test/ote/upm/practice-id/users/_/${suffix}`, observedAt: '2026-10-30T00:00:00Z' });
const judgeSnapshot = (record) => ({ reason: '同一题目出现不同或不一致的标准答案', stem: record.stem,
options: record.options, answerTexts: record.answerTexts, correctOptionIds: record.correctOptionIds,
source: record.answerSource || record.source, ...(record.answerEvidence ? { answerEvidence: record.answerEvidence } : {}) });
test('unverified legacy practice-list judge values 0 and 1 both become missing without guessing', () => {
const input = [legacyJudge(), legacyJudge({ questionId: 'true-placeholder', correctOptionIds: ['1'], answerTexts: ['正确'],
source: { kind: 'network', url: 'https://api.example.test/ote/upm/wrong/start?lang=zh', observedAt: '2026-10-29T00:00:00Z' } })];
const raw = JSON.stringify(input);
const result = core.repairLegacyJudgeAnswers(input);
assert.equal(result.records.length, input.length);
assert.equal(result.changed.length, 2);
result.records.forEach((record, index) => {
assert.equal(record.answerStatus, 'missing');
assert.deepEqual(record.answerTexts, []);
assert.deepEqual(record.correctOptionIds, []);
assert.deepEqual(record.conflicts, []);
assert.equal(record.answerEvidence, undefined);
assert.equal(record.answerSource, undefined);
assert.equal(record.stem, input[index].stem);
assert.equal(record.questionIndex, input[index].questionIndex);
assert.equal(record.contentOccurrence, input[index].contentOccurrence);
assert.deepEqual(record.questionIdAliases, [...input[index].questionIdAliases, input[index].questionId]);
assert.strictEqual(result.changed[index].original, input[index]);
assert.equal(core.mapAnswers(record, input[index]).status, 'unmapped');
});
assert.equal(JSON.stringify(input), raw);
assert.deepEqual(core.fromCSV(core.toCSV(result.records)), result.records);
assert.equal(core.search(result.records, '错误', { field: 'answer' }).length, 0);
const again = core.repairLegacyJudgeAnswers(result.records);
assert.strictEqual(again.records, result.records);
assert.deepEqual(again.changed, []);
});
test('answer evidence validates and survives normalization, conflict snapshots, JSON and CSV', () => {
for (const answerEvidence of ['submitted-list', 'answer-response', 'bound-dom', 'explicit-standard']) {
const record = core.normalizeRecord(legacyJudge({ answerEvidence }));
assert.equal(record.answerEvidence, answerEvidence);
assert.deepEqual(core.normalizeRecord(JSON.parse(JSON.stringify(record))), record);
assert.deepEqual(core.fromCSV(core.toCSV([record])), [record]);
assert.strictEqual(core.repairLegacyJudgeAnswers([record]).records[0], record);
const merged = core.mergeRecord(record, { ...record, answerStatus: 'missing', answerEvidence: undefined,
answerTexts: [], correctOptionIds: [], source: { kind: 'dom', observedAt: '2026-10-30T01:00:00Z' } });
assert.equal(merged.answerEvidence, answerEvidence);
assert.deepEqual(merged.answerSource, record.answerSource);
const repeated = core.mergeRecord(record, legacyJudge({ source: { ...record.source, observedAt: '2026-10-30T02:00:00Z' } }));
assert.equal(repeated.answerEvidence, answerEvidence);
assert.deepEqual(repeated.answerSource, record.answerSource);
assert.equal(repeated.source.observedAt, '2026-10-30T02:00:00Z');
const conflict = core.mergeRecord(record, legacyJudge({ answerEvidence: 'bound-dom', correctOptionIds: ['1'], answerTexts: ['正确'] }));
assert.ok(conflict.conflicts.some(snapshot => snapshot.answerEvidence === answerEvidence));
assert.ok(conflict.conflicts.some(snapshot => snapshot.answerEvidence === 'bound-dom'));
}
assert.throws(() => core.normalizeRecord(legacyJudge({ answerEvidence: 'unsubmitted-list' })), /无效答案证据/);
});
test('manual standard imports, non-judge types and unrelated sources retain their answers', () => {
const input = [
legacyJudge({ source: { kind: 'csv-import', observedAt: '2026-10-29T00:00:00Z' } }),
legacyJudge({ source: { kind: 'network', url: 'https://example.test/upm/startAgain', observedAt: '2026-10-29T00:00:00Z' } }),
fixture({ source: legacyJudge().source }),
legacyJudge({ source: officialJudgeSource() }),
legacyJudge({ answerEvidence: 'bound-dom', source: { kind: 'dom', observedAt: '2026-10-29T00:00:00Z' } }),
];
const result = core.repairLegacyJudgeAnswers(input);
assert.strictEqual(result.records, input);
assert.deepEqual(result.changed, []);
const csv = core.fromCSV('题型,题干,选项A,选项B,正确答案内容\r\n判断题,人工明确标准,正确,错误,正确\r\n');
assert.equal(core.repairLegacyJudgeAnswers(csv).records[0].answerStatus, 'confirmed');
});
test('DOM-inherited conflicts containing only unverified list values become missing', () => {
const old = core.normalizeRecord(legacyJudge());
const later = core.normalizeRecord(legacyJudge({ correctOptionIds: ['1'], answerTexts: ['正确'] }));
const record = core.normalizeRecord({ ...later, answerStatus: 'conflict',
source: { kind: 'dom', observedAt: '2026-10-30T00:00:00Z' }, answerSource: { kind: 'dom', observedAt: '2026-10-30T00:00:00Z' },
conflicts: [judgeSnapshot(old), judgeSnapshot(later)] });
const result = core.repairLegacyJudgeAnswers([record]);
assert.equal(result.records[0].answerStatus, 'missing');
assert.deepEqual(result.records[0].conflicts, []);
assert.deepEqual(result.records[0].answerTexts, []);
assert.equal(result.changed.length, 1);
});
test('standalone old DOM judge answers without retained list history are quarantined with their raw originals', () => {
const source = { kind: 'dom', url: 'https://practice.example.test/', observedAt: '2026-10-29T00:00:00Z' };
const input = [legacyJudge({ source }), legacyJudge({ source, questionId: 'dom-true', correctOptionIds: ['1'], answerTexts: ['正确'] })];
const raw = JSON.stringify(input);
const result = core.repairLegacyJudgeAnswers(input);
assert.equal(result.changed.length, 2);
result.records.forEach((record, index) => {
assert.equal(record.answerStatus, 'missing');
assert.deepEqual(record.answerTexts, []);
assert.deepEqual(record.correctOptionIds, []);
assert.strictEqual(result.changed[index].original, input[index]);
});
assert.equal(JSON.stringify(input), raw);
assert.deepEqual(core.repairLegacyJudgeAnswers(result.records).changed, []);
const proven = input.map(record => core.normalizeRecord({ ...record, answerEvidence: 'bound-dom' }));
assert.strictEqual(core.repairLegacyJudgeAnswers(proven).records, proven);
});
test('standalone DOM provenance retains genuine receipts and recovers trusted snapshots without list history', () => {
const dom = { kind: 'dom', url: 'https://practice.example.test/', observedAt: '2026-10-29T00:00:00Z' };
const receipt = core.normalizeRecord(legacyJudge({ source: officialJudgeSource(), correctOptionIds: ['1'], answerTexts: ['正确'] }));
const preserved = core.normalizeRecord({ ...receipt, source: dom });
assert.strictEqual(core.repairLegacyJudgeAnswers([preserved]).records[0], preserved);
const old = core.normalizeRecord(legacyJudge({ source: dom }));
const conflict = { ...old, answerStatus: 'conflict', conflicts: [judgeSnapshot(old), judgeSnapshot(receipt)] };
const repaired = core.repairLegacyJudgeAnswers([conflict]).records[0];
assert.equal(repaired.answerStatus, 'confirmed');
assert.equal(repaired.answerEvidence, 'answer-response');
assert.deepEqual(repaired.answerTexts, ['正确']);
assert.deepEqual(repaired.answerSource, receipt.source);
assert.deepEqual(repaired.conflicts, []);
const onlyDom = { ...old, answerStatus: 'conflict', conflicts: [judgeSnapshot(old), { ...judgeSnapshot(old), answerTexts: ['正确'], correctOptionIds: ['1'] }] };
assert.equal(core.repairLegacyJudgeAnswers([onlyDom]).records[0].answerStatus, 'missing');
});
test('a real answer receipt recovers the standard from legacy conflicts with atomic option IDs', () => {
for (const suffix of ['answer', 'wrong/answer']) {
const old = core.normalizeRecord(legacyJudge());
const receipt = core.normalizeRecord(legacyJudge({
options: [{ id: 'new-false', text: '错误', label: 'A' }, { id: 'new-true', text: '正确', label: 'B' }],
correctOptionIds: ['new-true'], answerTexts: ['正确'], source: officialJudgeSource(suffix),
}));
const contaminated = { ...old, answerStatus: 'conflict', conflicts: [judgeSnapshot(old), judgeSnapshot(receipt)] };
const result = core.repairLegacyJudgeAnswers([contaminated]);
const repaired = result.records[0];
assert.equal(repaired.answerStatus, 'confirmed');
assert.equal(repaired.answerEvidence, 'answer-response');
assert.deepEqual(repaired.answerTexts, ['正确']);
assert.deepEqual(repaired.correctOptionIds, ['new-true']);
assert.deepEqual(repaired.options, receipt.options);
assert.deepEqual(repaired.answerSource, receipt.source);
assert.deepEqual(repaired.source, old.source);
assert.deepEqual(repaired.conflicts, []);
assert.equal(core.mapAnswers(repaired, receipt).status, 'matched');
assert.strictEqual(result.changed[0].original, contaminated);
assert.deepEqual(core.repairLegacyJudgeAnswers(result.records).changed, []);
}
const receipt = core.normalizeRecord(legacyJudge({ source: officialJudgeSource() }));
const observedAgain = core.mergeRecord(receipt, legacyJudge());
assert.deepEqual(observedAgain.answerSource, receipt.source);
assert.deepEqual(core.repairLegacyJudgeAnswers([observedAgain]).changed, []);
});
test('proven submitted or bound answers replace placeholders while two proven answers remain conflicting', () => {
const old = core.normalizeRecord(legacyJudge());
const first = core.normalizeRecord(legacyJudge({ answerEvidence: 'submitted-list', correctOptionIds: ['1'], answerTexts: ['正确'] }));
const second = core.normalizeRecord(legacyJudge({ answerEvidence: 'bound-dom' }));
const good = core.repairLegacyJudgeAnswers([{ ...first, answerStatus: 'conflict', conflicts: [judgeSnapshot(old), judgeSnapshot(first)] }]).records[0];
assert.equal(good.answerStatus, 'confirmed');
assert.deepEqual(good.answerTexts, ['正确']);
const conflict = core.repairLegacyJudgeAnswers([{ ...second, answerStatus: 'conflict',
conflicts: [judgeSnapshot(old), judgeSnapshot(first), judgeSnapshot(second)] }]).records[0];
assert.equal(conflict.answerStatus, 'conflict');
assert.ok(conflict.conflicts.every(snapshot => snapshot.answerEvidence));
assert.deepEqual(new Set(conflict.conflicts.flatMap(snapshot => snapshot.answerTexts)), new Set(['正确', '错误']));
assert.equal(core.mapAnswers(conflict, first).status, 'ambiguous');
});
test('conflict dedup retains independent standard provenance when its answer equals an old placeholder', () => {
const old = core.normalizeRecord(legacyJudge());
const trueReceipt = core.normalizeRecord(legacyJudge({ source: officialJudgeSource(), correctOptionIds: ['1'], answerTexts: ['正确'], answerEvidence: 'answer-response' }));
const falseReceipt = core.normalizeRecord(legacyJudge({ source: officialJudgeSource(), answerEvidence: 'answer-response' }));
const merged = core.mergeRecord(core.mergeRecord(old, trueReceipt), falseReceipt);
assert.ok(merged.conflicts.some(snapshot => snapshot.source.url.endsWith('/start')));
assert.ok(merged.conflicts.some(snapshot => snapshot.answerTexts[0] === '错误' && snapshot.answerEvidence === 'answer-response'));
const inherited = { ...merged, answerSource: { kind: 'dom', observedAt: '2026-10-30T01:00:00Z' }, answerEvidence: undefined };
const repaired = core.repairLegacyJudgeAnswers([inherited]).records[0];
assert.equal(repaired.answerStatus, 'conflict');
assert.ok(repaired.conflicts.every(snapshot => snapshot.answerEvidence === 'answer-response'));
});
test('unknown network conflict evidence is retained conservatively rather than restored as a standard', () => {
const old = core.normalizeRecord(legacyJudge());
const unknown = { ...judgeSnapshot(old), source: { kind: 'network', url: 'https://example.test/unknown-standard', observedAt: '2026-10-30T00:00:00Z' } };
const repaired = core.repairLegacyJudgeAnswers([{ ...old, answerStatus: 'conflict', conflicts: [judgeSnapshot(old), unknown] }]).records[0];
assert.equal(repaired.answerStatus, 'conflict');
assert.deepEqual(repaired.answerTexts, []);
assert.deepEqual(repaired.conflicts, [unknown]);
});
+125 -3
View File
@@ -15,6 +15,7 @@ const CONFIG_KEY = 'huayi-qb.config.v1';
const DEDUP_BACKUP_KEY = 'huayi-qb.records.before-dedup.v1';
const OPTION_REPAIR_BACKUP_KEY = 'huayi-qb.records.before-duplicate-option-repair.v1';
const IMAGE_UPGRADE_BACKUP_KEY = 'huayi-qb.records.before-image-upgrade.v1';
const JUDGE_EVIDENCE_BACKUP_KEY = 'huayi-qb.records.before-judge-evidence-repair.v1';
const QA_DIR = path.join(os.tmpdir(), 'huayi-qb-qa-v121');
const BUNDLED = path.join(os.homedir(), '.cache/codex-runtimes/codex-primary-runtime/dependencies/node/node_modules');
@@ -24,6 +25,124 @@ function loadPlaywright() {
catch { return require(path.join(BUNDLED, 'playwright')); }
}
test('判断题答案证据:旧默认值隔离、反馈恢复、备份及搜索候选区分', { timeout: 60000 }, async () => {
const core = require('../src/core.js');
const source = { kind: 'network', url: 'https://api-phx-ali.yunxuetang.cn/ote/upm/start', observedAt: '2026-10-29T00:00:00Z', batchId: 'old-judge-batch' };
const makeJudge = (questionId, stem, overrides = {}) => core.normalizeRecord({ bankId: 'judge-bank', bankName: '判断验收题库', questionId,
type: 'judge', stem, options: [{ id: '1', text: '正确', label: '正确' }, { id: '0', text: '错误', label: '错误' }],
correctOptionIds: ['0'], answerTexts: ['错误'], answerStatus: 'confirmed', source, ...overrides });
const legacy = makeJudge('old-zero', '精馏操作时,当回流罐液面达到1/3时启动回流泵给塔加入回流液。', { questionIndex: 66 });
const feedback = { ...legacy, correctOptionIds: ['1'], answerTexts: ['正确'], answerEvidence: 'answer-response',
answerSource: { kind: 'network', url: 'https://api-phx-ali.yunxuetang.cn/ote/upm/judge-bank/users/test/answer', observedAt: '2026-10-30T00:00:00Z' } };
const otherLegacy = makeJudge('polluted', '被旧默认值污染但存在可信反馈的判断题。');
const contaminated = { ...otherLegacy, answerStatus: 'conflict', conflicts: [
{ reason: '同一题目出现不同或不一致的标准答案', stem: otherLegacy.stem, options: otherLegacy.options, correctOptionIds: ['0'], answerTexts: ['错误'], source },
{ reason: '同一题目出现不同或不一致的标准答案', stem: otherLegacy.stem, options: otherLegacy.options, correctOptionIds: ['1'], answerTexts: ['正确'],
source: feedback.answerSource, answerEvidence: 'answer-response' }
] };
const manualSource = { kind: 'import', observedAt: '2026-10-30T00:00:00Z' };
const numericCandidate = makeJudge('one-half', legacy.stem.replace('1/3', '1/2'), { source: manualSource, answerSource: manualSource, questionIndex: 417 });
const negative = makeJudge('must-not', '电解操作工处理电解槽故障时,必须穿绝缘靴,不准两手同时接触电解槽。',
{ correctOptionIds: ['1'], answerTexts: ['正确'], source: manualSource, answerSource: manualSource });
const negativeCandidate = makeJudge('may-touch', negative.stem.replace('不准两手', '两手可以'), { source: manualSource, answerSource: manualSource });
const seed = [legacy, contaminated, numericCandidate, negative, negativeCandidate];
const current = { ...legacy, answerStatus: 'missing', correctOptionIds: [], answerTexts: [], source: { kind: 'dom' }, answerSource: undefined };
const bank = { bankId: 'judge-bank', bankName: '判断验收题库', questionCount: 500, loading: false };
const stubs = `const QBAdapter={create(options){window.__qaReceive=options.onQuestions;return {start(){},stop(){},setCopyEnabled(){},getCurrentQuestion(){return window.__qaCurrent||null},getCurrentBank(){return ${JSON.stringify(bank)}}}}};
const QBAuto={create(){return {getState(){return {enabled:false,busy:false,answeredCount:0,skippedCount:0,message:''}},setEnabled(){},answerCurrent(){return Promise.resolve()}}}};`;
const appSource = `(function(){${fs.readFileSync(path.join(ROOT, 'src/core.js'), 'utf8')}\n${stubs}\n${fs.readFileSync(path.join(ROOT, 'src/app.js'), 'utf8')}\n})();`;
const { chromium } = loadPlaywright(), browser = await chromium.launch(browserOptions());
let context;
const createContext = async (initial, blocked = false) => {
const result = await browser.newContext({ acceptDownloads: true });
await result.route('**/*', route => route.fulfill({ status: 200, contentType: 'text/html', body: '<!doctype html><html><head><meta charset="utf-8"></head><body>判断题本地验收</body></html>' }));
await result.addInitScript({ content: `(${installGMShim.toString()})();
GM_addValueChangeListener=(key,callback)=>{window.__qaRemoteChange=callback;return 1};
if(localStorage.getItem(${JSON.stringify(RECORDS_KEY)})===null){GM_setValue(${JSON.stringify(RECORDS_KEY)},${JSON.stringify(initial)});GM_setValue(${JSON.stringify(CONFIG_KEY)},{collecting:false});}
window.__qaCurrent=${JSON.stringify(current)};
${blocked ? `const originalSet=GM_setValue;GM_setValue=(key,value)=>{if(key===${JSON.stringify(JUDGE_EVIDENCE_BACKUP_KEY)}){${blocked === 'silent' ? 'return;' : "throw new Error('fixture judge backup denied');"}}return originalSet(key,value)};` : ''}
${appSource}` });
return result;
};
try {
context = await createContext(seed);
const page = await context.newPage();
await page.goto('https://www.shhuayi-elearning.cn/judge-evidence-fixture');
await page.locator('#huayi-qb-root').waitFor({ state: 'attached' });
await waitFor(async () => (await storageRecords(page)).find(record => record.questionId === legacy.questionId)?.answerStatus === 'missing', '旧列表默认值应撤回为待补');
let stored = await storageRecords(page);
assert.equal(stored.find(record => record.questionId === contaminated.questionId).answerStatus, 'confirmed');
assert.deepEqual(stored.find(record => record.questionId === contaminated.questionId).answerTexts, ['正确']);
assert.deepEqual((await page.evaluate(key => GM_getValue(key, null), JUDGE_EVIDENCE_BACKUP_KEY)).records, [legacy, contaminated]);
await activate(page, 'search');
await page.locator('#qb-search').fill('精馏操作时,当回流罐液面达到');
await page.locator('#qb-search').press('Enter');
const first = page.locator('#qb-results .result').first();
await waitFor(async () => (await first.locator('.stem').textContent()) === legacy.stem, '当前完整题即使部分关键词分数较低也应在前');
assert.equal(await first.locator('.answer strong').textContent(), '标准答案未确认');
const other = page.locator('#qb-results .result').filter({ hasText: numericCandidate.stem });
assert.equal(await other.locator('.answer-label').textContent(), '候选题答案');
assert.match(await other.locator('.mapping').textContent(), /核对题干/);
assert.doesNotMatch(await other.locator('.mapping').textContent(), /当前页面对应/);
await page.evaluate(record => window.__qaReceive([record]), feedback);
await waitFor(async () => (await storageRecords(page)).find(record => record.questionId === legacy.questionId)?.answerStatus === 'confirmed', '可信反馈应恢复被撤回的标准答案');
assert.deepEqual((await storageRecords(page)).find(record => record.questionId === legacy.questionId).answerTexts, ['正确']);
assert.equal(await first.locator('.answer-label').textContent(), '本题正确答案');
assert.match(await first.locator('.mapping').textContent(), /当前页面对应:正确/);
await page.evaluate(record => { window.__qaCurrent = record; }, { ...negative, answerStatus: 'missing', correctOptionIds: [], answerTexts: [] });
await page.locator('#qb-search').fill('电解操作工处理电解槽故障时'); await page.locator('#qb-search').press('Enter');
const opposite = page.locator('#qb-results .result').filter({ hasText: negativeCandidate.stem });
assert.equal(await opposite.locator('.answer-label').textContent(), '候选题答案');
assert.doesNotMatch(await opposite.locator('.mapping').textContent(), /当前页面对应/);
await activate(page, 'settings');
const downloading = page.waitForEvent('download');
await page.locator('[data-action="export-judge-evidence-backup"]').click();
const downloaded = await downloading;
assert.deepEqual(JSON.parse(fs.readFileSync(await downloaded.path(), 'utf8')).records, [legacy, contaminated]);
assert.ok((await page.evaluate(() => window.__qbMenus.map(menu => menu.label))).some(label => label.includes('判断题答案核验前')));
const imported = makeJudge('old-import', '后来导入的旧列表默认判断答案。');
await importFile(page, 'old-judge.json', JSON.stringify({ schemaVersion: 1, records: [imported] }));
assert.equal((await storageRecords(page)).find(record => record.questionId === imported.questionId).answerStatus, 'missing');
const received = makeJudge('old-response', '从旧标签收到的未确认列表判断答案。', { correctOptionIds: ['1'], answerTexts: ['正确'] });
await page.evaluate(record => window.__qaReceive([record]), received);
await waitFor(async () => (await storageRecords(page)).find(record => record.questionId === received.questionId)?.answerStatus === 'missing', '新收到的无证据旧列表值也应撤回');
const backup = await page.evaluate(key => GM_getValue(key, null), JUDGE_EVIDENCE_BACKUP_KEY);
assert.ok(backup.records.some(record => record.questionId === imported.questionId));
assert.ok(backup.records.some(record => record.questionId === received.questionId));
stored = await storageRecords(page);
const stale = stored.map(record => record.questionId === legacy.questionId ? legacy : record);
await page.evaluate(({ value, key }) => { GM_setValue(key, value); window.__qaRemoteChange(key, [], value, true); }, { value: stale, key: RECORDS_KEY });
await waitFor(async () => (await storageRecords(page)).find(record => record.questionId === legacy.questionId)?.answerTexts.includes('正确'), '跨标签旧默认值不能污染可信反馈');
assert.equal((await storageRecords(page)).find(record => record.questionId === legacy.questionId).answerStatus, 'confirmed');
const staleWithoutEvent = (await storageRecords(page)).map(record => record.questionId === legacy.questionId ? legacy : record);
const trigger = makeJudge('persist-trigger', '触发持久化时整理未收到通知的旧标签记录。', { source: manualSource, answerSource: manualSource });
await page.evaluate(({ value, key, trigger }) => { GM_setValue(key, value); window.__qaReceive([trigger]); }, { value: staleWithoutEvent, key: RECORDS_KEY, trigger });
await waitFor(async () => (await storageRecords(page)).find(record => record.questionId === legacy.questionId)?.answerTexts.includes('正确'), '持久化读到未通知的旧默认值时,也应先隔离再合并');
assert.equal((await storageRecords(page)).find(record => record.questionId === legacy.questionId).answerStatus, 'confirmed');
await context.close(); context = null;
for (const failure of [true, 'silent']) {
context = await createContext([legacy], failure);
const blocked = await context.newPage();
await blocked.goto('https://www.shhuayi-elearning.cn/judge-evidence-fixture');
await blocked.locator('#huayi-qb-root').waitFor({ state: 'attached' });
await blocked.waitForTimeout(1100);
assert.deepEqual(await storageRecords(blocked), [legacy], '判断题独立备份抛错或静默失败时均不得覆写原题库');
assert.equal(await blocked.evaluate(key => GM_getValue(key, null), JUDGE_EVIDENCE_BACKUP_KEY), null);
await context.close(); context = null;
}
} finally {
if (context) await context.close();
await browser.close();
}
});
test('历史误报和图片补全:原始备份、设置导出、旧标签写回与失败保护', { timeout: 60000 }, async () => {
// Use the current sources without building dist in a concurrently edited tree.
// The adapter fixture supplies explicit snapshot evidence; real storage, UI,
@@ -224,7 +343,8 @@ function bankResponse(bank) {
const choiceItems = bank.type === 'judge' ? undefined : bank.type === 'multiple'
? [{ id: 'check-t', content: '检查温度', answer: 1 }, { id: 'check-p', content: '检查压力', answer: 1 }, { id: 'skip', content: '跳过巡检', answer: 0 }]
: [{ id: 'yes', content: bank.answer, answer: 1 }, { id: 'hold', content: '保持不变', answer: 0 }, { id: 'no', content: '停止运行', answer: 0 }];
return { data: { praId: bank.id, praName: bank.name, listQues: [{ id: `question-${bank.id}`, quesType: { single: 0, multiple: 1, judge: 2 }[bank.type], content: bank.stem, ...(choiceItems ? { choiceItems } : { judgeAnswer: bank.answer === '正确' ? 1 : 0 }) }] } };
return { data: { praId: bank.id, praName: bank.name, listQues: [{ id: `question-${bank.id}`, quesType: { single: 0, multiple: 1, judge: 2 }[bank.type], content: bank.stem,
...(choiceItems ? { choiceItems } : { judgeAnswer: bank.answer === '正确' ? 1 : 0, submitQuesAnswers: [bank.answer === '正确' ? '0' : '1'] }) }] } };
}
const DEDUP_BANK = { id: 'dedup-judge-2', name: '华谊集团化工总控工(高级)-判断题库2' };
@@ -232,11 +352,13 @@ const DEDUP_BANK = { id: 'dedup-judge-2', name: '华谊集团化工总控工(
function duplicateJudgeRecords() {
// The real 232-question bank contains three pairs with the same wording.
// Across three fetches, IDs change, but each original occurrence must remain.
// This dedup fixture has submitted-list proof; the separate judge migration
// test covers old start placeholders that have no standard-answer evidence.
return Array.from({ length: 3 }, (_, copy) => Array.from({ length: 232 }, (_, index) => ({
bankId: DEDUP_BANK.id, bankName: DEDUP_BANK.name, questionId: `legacy-${index}-${copy}`,
type: 'judge', stem: `去重验收第${(index < 229 ? index : index - 229) + 1}项:设备巡检要求已经确认。`,
options: [{ id: '1', text: '正确', label: '正确' }, { id: '0', text: '错误', label: '错误' }],
correctOptionIds: ['1'], answerTexts: ['正确'], answerStatus: 'confirmed',
correctOptionIds: ['1'], answerTexts: ['正确'], answerStatus: 'confirmed', answerEvidence: 'submitted-list',
source: { kind: 'network', url: 'https://api-ulcd.yunxuetang.cn/v2/upm/start', observedAt: `2026-10-29T01:0${copy}:00.000Z` }
}))).flat();
}
@@ -419,7 +541,7 @@ test('真实浏览器:采集、答案映射、导入导出与面板隐藏恢
if (item === 'legacy-duplicates') {
const copy = Math.max(0, Math.min(2, Number(new URL(req.url, 'http://127.0.0.1').searchParams.get('copy')) || 0));
res.end(JSON.stringify({ data: { praId: DEDUP_BANK.id, praName: DEDUP_BANK.name,
listQues: duplicateJudgeRecords().filter(record => record.questionId.endsWith(`-${copy}`)).map(record => ({ id: record.questionId, quesType: 2, content: record.stem, judgeAnswer: 1 })) } }));
listQues: duplicateJudgeRecords().filter(record => record.questionId.endsWith(`-${copy}`)).map(record => ({ id: record.questionId, quesType: 2, content: record.stem, judgeAnswer: 1, submitQuesAnswers: ['0'] })) } }));
return;
}
if (item.startsWith('bank-')) {
+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);
});