fix: require evidence for judgment answers and correct legacy banks
This commit is contained in:
+138
-1
@@ -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();
|
||||
});
|
||||
|
||||
@@ -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
@@ -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-')) {
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user