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
+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-')) {